diff --git a/.gitignore b/.gitignore index eaaebbad1..34e70e6ea 100644 --- a/.gitignore +++ b/.gitignore @@ -75,5 +75,5 @@ deprecated *.yml venv/ .idea/ -dexbot/views/ui/*_ui.py +dexbot/views/ui/**/*_ui.py dexbot/resources/*_rc.py diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..59d77a3dc --- /dev/null +++ b/.travis.yml @@ -0,0 +1,35 @@ +sudo: true +matrix: + include: + - os: linux + language: python + python: '3.6' + - os: osx + language: generic + before_install: + - python --version + - brew upgrade python +install: + - make install +script: + - echo "@TODO - Running tests..." + - pyinstaller --distpath dist/$TRAVIS_OS_NAME gui.spec + - pyinstaller --distpath dist/$TRAVIS_OS_NAME cli.spec +before_deploy: + - git config --local user.name "Travis" + - git config --local user.email "travis@travis-ci.org" + - git tag "$(date +'%Y%m%d%H%M%S')-$(git log --format=%h -1)" + - tar -czvf dist/DEXBot-$TRAVIS_OS_NAME.tar.gz dist/$TRAVIS_OS_NAME/* +deploy: + - provider: releases + skip_cleanup: true + api_key: + secure: YHAPA2G3qu7at2hMu4AplXH/niI1ChlgldJVetaKO92iDQiyOk5VqFfhV1ec+nYdX8rtniwfD7YJr2nG2x1ATwKw4MyFcJEXqaOUmKWTeZ/Q3PnQQsa+2BnN4Rfz1aynpsKHDYS9gCU/YTqymujE8bdlxW1WtpYOqOSDkspGxZGZTiUKQ7/qhrjB3Dywm/KF9WEoba/X7tbhmSuU8sL45gBGY008TXZRWqAPM42qa/aBIrG/cIA865VlCUltPC6vzskcWI5q1UtYh6g2CiXJghcpFEO2rWWXmS1A+5nQp6ptJigjRgnhyFHmHb27lRM8aRGRDTeyJvlNuoyIvNj/FxhLXZvomgTyGyzTIl67WIXcxWMKx6KqqrqGyiooRMeFpDEYobZL/FY9whi3M+gUwsofAVQ6oL4a1L185egaXlMKGbM5GYB4OxVLsVtL2c0pJjvNIkCGGDzaqNpdo+vZflB4iCwvw548rWJsqsHnP1XMo28ZU86hibD7V0x+JW2BJEI0lMvOkRBslOhYBafIsbZakO4Zf4d+5b2dd8/xY/wTbuxwgDuBOmpqoByVYeCBah4bbnb8JS6eze+vUyxaI1XLAdQXbLQ788Agr2jdHGuy1wI8io9g5vtzS5oOyq8YFBM1tVKM2Mtw5nkSsTbPJsZg8m/kkre6qiXJl2gPQTE= + file: dist/*.tar.gz + file_glob: true + on: + tags: true +notifications: + email: false + slack: + secure: iQwBqvwq0HmEODoWI52pnNi2trfZ4ly5/fDPmkr6Ez8z9rm5XQ3CBLtpH7JpNdkyen5r+dVTczJDIOTBLpXwe/AzwFKLqZc/0pkXnxzNSENnm++/G6uqS0u5fMdYSoR4fJC1zjzEj2ly11OdS+wX3y9//hD13U96u3iO6T/7EXU2VYt82wekziJXzyfK4JeJMs1L5M2Oz7ZBwiHeAZ/3ZNjKE+9TX7S/mlmG+bNiQhv/wSin2AnsB1recgFjp17ZHq4cW+K77TDnRlPZ0bVsOhGYUtMlW9llidXZbunLj3qITIDl7dufowBG95PTHh+L2KDcPv7UCxlN02kXWuz3nL47UwD7BZcLMJ0RLYk4g+qNBrytgrmhH82gdmenzCQ4PgHI/U1/8hgiEyGlBZWUTXrso5EF3VBRUhCtu8dG/F+rdGHSfK1mZQyDPe6my9E888TvfcWWCpVNammAZicrGWU9nY3Rqn7DFodBL896iFPs1DJD5fTF1th6hHEyRSuKZC80irFZRoxccDPuDYVIfPExJH328tFeh75WOuzQt4QCBFiOsiFDlCYhnQ8tNw/MWntPQHwY8PkUlvpvelPCgfh73ihXtMD61/6Hq+lOijkGFhEzgpqmzL4mSUd/EQRJHLE9lAVvRGdrzlaIV6f4CirJkZSAgf4LuYDl2JMZ3kE= diff --git a/LICENSE.txt b/LICENSE.txt index 0e4f4b8da..8cdb77d71 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,8 @@ The MIT License (MIT) -Copyright (c) 2017 ChainSquad GmbH +Copyright for portions of project DEXBot are held by ChainSquad GmbH 2017 +as part of project stakemachine. All other copyright for project DEXBot +are held by Codaone Oy 2018. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MANIFEST.in b/MANIFEST.in index efa752eaf..d0d04a220 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,2 @@ include *.md +include dexbot/resources/img/* \ No newline at end of file diff --git a/Makefile b/Makefile index b9760ea75..6db5fbe90 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,9 @@ .PHONY: clean-pyc clean-build docs -clean: clean-build clean-pyc +clean: clean-build clean-pyc clean-ui + +clean-ui: + find dexbot/views/ui/*.py ! -name '__init__.py' -type f -exec rm -f {} + clean-build: rm -fr build/ @@ -13,10 +16,13 @@ clean-pyc: find . -name '*.pyo' -exec rm -f {} + find . -name '*~' -exec rm -f {} + +pip: + python3 -m pip install -r requirements.txt + lint: flake8 dexbot/ -build: +build: pip python3 setup.py build install: build @@ -29,10 +35,14 @@ git: git push --all git push --tags -check: +check: pip python3 setup.py check -dist: +package: build + pyinstaller gui.spec + pyinstaller cli.spec + +dist: build python3 setup.py sdist upload -r pypi python3 setup.py bdist_wheel upload diff --git a/README.md b/README.md index e3fae6630..23534351b 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,22 @@ # DEXBot -Trading Bot for the BitShares Decentralized Exchange -(DEX). +Trading Bot for the BitShares Decentralized Exchange (DEX). -**Warning**: This is highly experimental code! Use at your OWN risk! - -## Installation +## Build status - git clone https://github.com/codaone/dexbot - cd dexbot - python3 setup.py install - # or - python3 setup.py install --user +master: +[![Build Status](https://travis-ci.org/Codaone/DEXBot.svg?branch=master)](https://travis-ci.org/Codaone/DEXBot) -## Configuration -Configuration happens in `config.yml` - -## Requirements +**Warning**: This is highly experimental code! Use at your OWN risk! -Add your account's private key to the pybitshares wallet using `uptick` +## Installing and running the software - uptick addkey +See instructions in the [Wiki](https://github.com/Codaone/DEXBot/wiki/Installing-and-Running) -## Execution +## Contributing - dexbot run +Install the software, use it and report any problems by creating a ticket. # IMPORTANT NOTE diff --git a/app.spec b/app.spec deleted file mode 100644 index d4e6d7c91..000000000 --- a/app.spec +++ /dev/null @@ -1,30 +0,0 @@ -# -*- mode: python -*- - -block_cipher = None - - -a = Analysis(['app.py'], - binaries=[], - datas=[('config.yml', '.')], - hiddenimports=[], - hookspath=['hooks'], - runtime_hooks=['hooks/rthook-Crypto.py'], - excludes=[], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher) - -pyz = PYZ(a.pure, a.zipped_data, - cipher=block_cipher) - -exe = EXE(pyz, - a.scripts, - a.binaries, - a.zipfiles, - a.datas, - name='DEXBot', - debug=False, - strip=False, - upx=True, - runtime_tmpdir=None, - console=False) diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 000000000..7d04aabba --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,64 @@ +version: 0.1.{build} + +image: Visual Studio 2015 + +environment: + matrix: + # Python 3.5.3 - 64-bit + - PYTHON: "C:\\Python35-x64" + +#---------------------------------# +# build # +#---------------------------------# + +build: off + +configuration: Release + +install: + - SET PATH=%PYTHON%;%PYTHON%\\Scripts;%PATH%;C:\MinGW\bin + - copy c:\MinGW\bin\mingw32-make.exe c:\MinGW\bin\make.exe + - copy c:\Python35-x64\python.exe c:\Python35-x64\python3.exe + - python --version + - make install + +after_test: + - make package + - '7z a DEXBot-win64.zip %APPVEYOR_BUILD_FOLDER%\dist\DEXBot-gui.exe %APPVEYOR_BUILD_FOLDER%\dist\DEXBot-cli.exe' + +# @TODO: Run tests.. +test_script: + - "echo tests..." + +artifacts: + - path: DEXBot-win64.zip + name: DEXBot-win64.zip + +#---------------------------------# +# deployment # +#---------------------------------# + +shallow_clone: false + +clone_depth: 1 + +deploy: + - provider: GitHub + artifact: DEXBot-win64.zip + draft: false + prerelease: false + force_update: true + auth_token: + secure: 9qvwlVUHFBV4GwMz1Gu2HSnqU8Ex2nv5dsY4mVNCurrb+6ULIoHPgbvJPWTo3qV6 + on: + appveyor_repo_tag: true # deploy on tag push only + +#---------------------------------# +# notifications # +#---------------------------------# + +notifications: + - provider: Slack + auth_token: + secure: G9OMj9l2s3+lX8cRiNXXhuQJpnnjcBc0cqP8gzkdKVWqGA8vBTOIPGxD/536VKpeBH/5dJFQWT+vmnGS+XciaCg4hh5s6hDpnvePq2+uEYE= + channel: '#ci' diff --git a/cli.py b/cli.py index 9ee2f6f34..8a9fa0fc2 100755 --- a/cli.py +++ b/cli.py @@ -2,4 +2,5 @@ from dexbot import cli -cli.main() +if __name__ == '__main__': + cli.main() diff --git a/cli.spec b/cli.spec new file mode 100644 index 000000000..00d38dbb0 --- /dev/null +++ b/cli.spec @@ -0,0 +1,45 @@ +# -*- mode: python -*- + +import os +import sys +block_cipher = None + +hiddenimports_strategies = [ + 'dexbot', + 'dexbot.strategies', + 'dexbot.strategies.echo', + 'dexbot.strategies.relative_orders', + 'dexbot.strategies.staggered_orders', + 'dexbot.strategies.storagedemo', + 'dexbot.strategies.walls', +] + +hiddenimports_packaging = [ + 'packaging', 'packaging.version', 'packaging.specifiers', 'packaging.requirements' +] + +a = Analysis(['dexbot/cli.py'], + binaries=[], + datas=[], + hiddenimports=hiddenimports_packaging + hiddenimports_strategies, + hookspath=['hooks'], + runtime_hooks=['hooks/rthook-Crypto.py'], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher) + +pyz = PYZ(a.pure, a.zipped_data, + cipher=block_cipher) + +exe = EXE(pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + name=os.path.join('dist', 'DEXBot-cli' + ('.exe' if sys.platform == 'win32' else '')), + debug=True, + strip=False, + upx=True, + runtime_tmpdir=None, + console=True ) diff --git a/config.yml b/config.yml deleted file mode 100644 index 38d9ef064..000000000 --- a/config.yml +++ /dev/null @@ -1,3 +0,0 @@ -node: wss://bitshares.openledger.info/ws - -bots: {} \ No newline at end of file diff --git a/dexbot/__init__.py b/dexbot/__init__.py index e69de29bb..9a1effc88 100644 --- a/dexbot/__init__.py +++ b/dexbot/__init__.py @@ -0,0 +1,23 @@ +import pathlib +import os +from appdirs import user_config_dir + +APP_NAME = "dexbot" +VERSION = '0.2.3' +AUTHOR = "codaone" +__version__ = VERSION + + +config_dir = user_config_dir(APP_NAME, appauthor=AUTHOR) +config_file = os.path.join(config_dir, "config.yml") + +default_config = """ +node: wss://bitshares.openledger.info/ws +workers: {} +""" + +if not os.path.isfile(config_file): + pathlib.Path(config_dir).mkdir(parents=True, exist_ok=True) + with open(config_file, 'w') as f: + f.write(default_config) + print("Created default config file at {}".format(config_file)) diff --git a/dexbot/basestrategy.py b/dexbot/basestrategy.py index 987b59038..1d6280950 100644 --- a/dexbot/basestrategy.py +++ b/dexbot/basestrategy.py @@ -1,15 +1,25 @@ -import logging, collections +import logging +import collections +import time +import math + +from .storage import Storage +from .statemachine import StateMachine + from events import Events -from bitshares.asset import Asset +import bitsharesapi +import bitsharesapi.exceptions +from bitshares.amount import Amount from bitshares.market import Market from bitshares.account import Account from bitshares.price import FilledOrder, Order, UpdateCallOrder from bitshares.instance import shared_bitshares_instance -from .storage import Storage -from .statemachine import StateMachine -ConfigElement = collections.namedtuple('ConfigElement','key type default description extra') +MAX_TRIES = 3 + + +ConfigElement = collections.namedtuple('ConfigElement', 'key type default description extra') # bots need to specify their own configuration values # I want this to be UI-agnostic so a future web or GUI interface can use it too # so each bot can have a class method 'configure' which returns a list of ConfigElement @@ -17,8 +27,8 @@ # key: the key in the bot config dictionary that gets saved back to config.yml # type: one of "int", "float", "bool", "string", "choice" # default: the default value. must be right type. -# description: comments to user, full sentences encouraged -# extra: +# description: comments to user, full sentences encouraged +# extra: # for int & float: a (min, max) tuple # for string: a regular expression, entries must match it, can be None which equivalent to .* # for bool, ignored @@ -42,12 +52,12 @@ class BaseStrategy(Storage, StateMachine, Events): * ``basestrategy.add_state``: Add a specific state * ``basestrategy.set_state``: Set finite state machine * ``basestrategy.get_state``: Change state of state machine - * ``basestrategy.account``: The Account object of this bot - * ``basestrategy.market``: The market used by this bot - * ``basestrategy.orders``: List of open orders of the bot's account in the bot's market - * ``basestrategy.balance``: List of assets and amounts available in the bot's account - * ``basestrategy.log``: a per-bot logger (actually LoggerAdapter) adds bot-specific context: botname & account - (Because some UIs might want to display per-bot logs) + * ``basestrategy.account``: The Account object of this worker + * ``basestrategy.market``: The market used by this worker + * ``basestrategy.orders``: List of open orders of the worker's account in the worker's market + * ``basestrategy.balance``: List of assets and amounts available in the worker's account + * ``basestrategy.log``: a per-worker logger (actually LoggerAdapter) adds worker-specific context: + worker name & account (Because some UIs might want to display per-worker logs) Also, Base Strategy inherits :class:`dexbot.storage.Storage` which allows to permanently store data in a sqlite database @@ -57,7 +67,7 @@ class BaseStrategy(Storage, StateMachine, Events): .. note:: This applies a ``json.loads(json.dumps(value))``! - Bots must never attempt to interact with the user, they must assume they are running unattended + Workers must never attempt to interact with the user, they must assume they are running unattended They can log events. If a problem occurs they can't fix they should set self.disabled = True and throw an exception The framework catches all exceptions thrown from event handlers and logs appropriately. """ @@ -87,10 +97,11 @@ def configure(kls): """ # these configs are common to all bots return [ - ConfigElement("account","string","","BitShares account name for the bot to operate with",""), - ConfigElement("market","string","USD:BTS","BitShares market to operate on, in the format ASSET:OTHERASSET, for example \"USD:BTS\"","[A-Z]+:[A-Z]+") + ConfigElement("account", "string", "", "BitShares account name for the bot to operate with", ""), + ConfigElement("market", "string", "USD:BTS", + "BitShares market to operate on, in the format ASSET:OTHERASSET, for example \"USD:BTS\"", "[A-Z]+:[A-Z]+") ] - + def __init__( self, config, @@ -134,50 +145,121 @@ def __init__( self.onMarketUpdate += self._callbackPlaceFillOrders self.config = config - self.bot = config["bots"][name] + self.worker = config["workers"][name] self._account = Account( - self.bot["account"], + self.worker["account"], full=True, bitshares_instance=self.bitshares ) self._market = Market( - config["bots"][name]["market"], + config["workers"][name]["market"], bitshares_instance=self.bitshares ) + # Recheck flag - Tell the strategy to check for updated orders + self.recheck_orders = False + # Settings for bitshares instance - self.bitshares.bundle = bool(self.bot.get("bundle", False)) + self.bitshares.bundle = bool(self.worker.get("bundle", False)) - # disabled flag - this flag can be flipped to True by a bot and + # Disabled flag - this flag can be flipped to True by a worker and # will be reset to False after reset only self.disabled = False - # a private logger that adds bot identify data to the LogRecord - self.log = logging.LoggerAdapter(logging.getLogger('dexbot.per_bot'), {'botname': name, - 'account': self.bot['account'], - 'market': self.bot['market'], - 'is_disabled': lambda: self.disabled}) - + # A private logger that adds worker identify data to the LogRecord + self.log = logging.LoggerAdapter( + logging.getLogger('dexbot.per_worker'), + {'worker_name': name, + 'account': self.worker['account'], + 'market': self.worker['market'], + 'is_disabled': lambda: self.disabled} + ) + + def calculate_center_price(self, suppress_errors=False): + ticker = self.market.ticker() + highest_bid = ticker.get("highestBid") + lowest_ask = ticker.get("lowestAsk") + if not float(highest_bid): + if not suppress_errors: + self.log.critical( + "Cannot estimate center price, there is no highest bid." + ) + self.disabled = True + return None + elif lowest_ask is None or lowest_ask == 0.0: + if not suppress_errors: + self.log.critical( + "Cannot estimate center price, there is no lowest ask." + ) + self.disabled = True + return None + + center_price = highest_bid['price'] * math.sqrt(lowest_ask['price'] / highest_bid['price']) + return center_price + + def calculate_offset_center_price(self, spread, center_price=None, order_ids=None): + """ Calculate center price which shifts based on available funds + """ + if center_price is None: + # No center price was given so we simply calculate the center price + calculated_center_price = self.calculate_center_price() + center_price = calculated_center_price + else: + # Center price was given so we only use the calculated center price + # for quote to base asset conversion + calculated_center_price = self.calculate_center_price(True) + if not calculated_center_price: + calculated_center_price = center_price + + total_balance = self.total_balance(order_ids) + total = (total_balance['quote'] * calculated_center_price) + total_balance['base'] + + if not total: # Prevent division by zero + percentage = 0 + else: + percentage = (total_balance['base'] / total) + lowest_price = center_price / math.sqrt(1 + spread) + highest_price = center_price * math.sqrt(1 + spread) + offset_center_price = ((highest_price - lowest_price) * percentage) + lowest_price + return offset_center_price + @property def orders(self): - """ Return the bot's open accounts in the current market + """ Return the worker's open accounts in the current market """ self.account.refresh() - return [o for o in self.account.openorders if self.bot["market"] == o.market and self.account.openorders] + return [o for o in self.account.openorders if self.worker["market"] == o.market and self.account.openorders] + + @staticmethod + def get_order(order_id, return_none=True): + """ Returns the Order object for the order_id - def get_order(self, order_id): - for order in self.orders: - if order['id'] == order_id: - return order - return False + :param str|dict order_id: blockchain object id of the order + can be a dict with the id key in it + :param bool return_none: return None instead of an empty + Order object when the order doesn't exist + """ + if not order_id: + return None + if 'id' in order_id: + order_id = order_id['id'] + order = Order(order_id) + if return_none and order['deleted']: + return None + return order def get_updated_order(self, order): + """ Tries to get the updated order from the API + returns None if the order doesn't exist + """ if not order: - return False + return None + if isinstance(order, str): + order = {'id': order} for updated_order in self.updated_open_orders: if updated_order['id'] == order['id']: return updated_order - return False + return None @property def updated_open_orders(self): @@ -190,8 +272,8 @@ def updated_open_orders(self): limit_orders = self.account['limit_orders'][:] for o in limit_orders: - base_amount = o['for_sale'] - price = o['sell_price']['base']['amount'] / o['sell_price']['quote']['amount'] + base_amount = float(o['for_sale']) + price = float(o['sell_price']['base']['amount']) / float(o['sell_price']['quote']['amount']) quote_amount = base_amount / price o['sell_price']['base']['amount'] = base_amount o['sell_price']['quote']['amount'] = quote_amount @@ -201,7 +283,7 @@ def updated_open_orders(self): for o in limit_orders ] - return [o for o in orders if self.bot["market"] == o.market] + return [o for o in orders if self.worker["market"] == o.market] @property def market(self): @@ -218,34 +300,22 @@ def account(self): return self._account def balance(self, asset): - """ Return the balance of your bot's account for a specific asset + """ Return the balance of your worker's account for a specific asset """ return self._account.balance(asset) - def get_converted_asset_amount(self, asset): - """ - Returns asset amount converted to base asset amount - """ - base_asset = self.market['base'] - quote_asset = Asset(asset['symbol'], bitshares_instance=self.bitshares) - if base_asset['symbol'] == quote_asset['symbol']: - return asset['amount'] - else: - market = Market(base=base_asset, quote=quote_asset, bitshares_instance=self.bitshares) - return market.ticker()['latest']['price'] * asset['amount'] - @property def test_mode(self): return self.config['node'] == "wss://node.testnet.bitshares.eu" @property def balances(self): - """ Return the balances of your bot's account + """ Return the balances of your worker's account """ return self._account.balances def _callbackPlaceFillOrders(self, d): - """ This method distringuishes notifications caused by Matched orders + """ This method distinguishes notifications caused by Matched orders from those caused by placed orders """ if isinstance(d, FilledOrder): @@ -265,28 +335,227 @@ def execute(self): self.bitshares.blocking = False return r + def _cancel(self, orders): + try: + self.retry_action(self.bitshares.cancel, orders, account=self.account) + except bitsharesapi.exceptions.UnhandledRPCError as e: + if str(e) == 'Assert Exception: maybe_found != nullptr: Unable to find Object': + # The order(s) we tried to cancel doesn't exist + self.bitshares.txbuffer.clear() + return False + else: + self.log.exception("Unable to cancel order") + return True + def cancel(self, orders): - """ Cancel specific orders + """ Cancel specific order(s) """ - if not isinstance(orders, list): + if not isinstance(orders, (list, set, tuple)): orders = [orders] - return self.bitshares.cancel( - [o["id"] for o in orders if "id" in o], - account=self.account - ) + + orders = [order['id'] for order in orders if 'id' in order] + + success = self._cancel(orders) + if not success and len(orders) > 1: + # One of the order cancels failed, cancel the orders one by one + for order in orders: + self._cancel(order) def cancel_all(self): - """ Cancel all orders of this bot + """ Cancel all orders of the worker's account """ + self.log.info('Canceling all orders') if self.orders: - return self.bitshares.cancel( - [o["id"] for o in self.orders], - account=self.account + self.cancel(self.orders) + self.log.info("Orders canceled") + + def market_buy(self, amount, price, return_none=False): + symbol = self.market['base']['symbol'] + precision = self.market['base']['precision'] + base_amount = self.truncate(price * amount, precision) + + # Make sure we have enough balance for the order + if self.balance(self.market['base']) < base_amount: + self.log.critical( + "Insufficient buy balance, needed {} {}".format( + base_amount, symbol) + ) + self.disabled = True + return None + + self.log.info( + 'Placing a buy order for {} {} @ {}'.format( + base_amount, symbol, round(price, 8)) + ) + + # Place the order + buy_transaction = self.retry_action( + self.market.buy, + price, + Amount(amount=amount, asset=self.market["quote"]), + account=self.account.name, + returnOrderId="head" + ) + self.log.debug('Placed buy order {}'.format(buy_transaction)) + buy_order = self.get_order(buy_transaction['orderid'], return_none=return_none) + if buy_order and buy_order['deleted']: + # The API doesn't return data on orders that don't exist + # We need to calculate the data on our own + buy_order = self.calculate_order_data(buy_order, amount, price) + self.recheck_orders = True + + return buy_order + + def market_sell(self, amount, price, return_none=False): + symbol = self.market['quote']['symbol'] + precision = self.market['quote']['precision'] + quote_amount = self.truncate(amount, precision) + + # Make sure we have enough balance for the order + if self.balance(self.market['quote']) < quote_amount: + self.log.critical( + "Insufficient sell balance, needed {} {}".format( + amount, symbol) ) + self.disabled = True + return None + + self.log.info( + 'Placing a sell order for {} {} @ {}'.format( + quote_amount, symbol, round(price, 8)) + ) + + # Place the order + sell_transaction = self.retry_action( + self.market.sell, + price, + Amount(amount=amount, asset=self.market["quote"]), + account=self.account.name, + returnOrderId="head" + ) + self.log.debug('Placed sell order {}'.format(sell_transaction)) + sell_order = self.get_order(sell_transaction['orderid'], return_none=return_none) + if sell_order and sell_order['deleted']: + # The API doesn't return data on orders that don't exist + # We need to calculate the data on our own + sell_order = self.calculate_order_data(sell_order, amount, price) + sell_order.invert() + self.recheck_orders = True + + return sell_order + + def calculate_order_data(self, order, amount, price): + quote_asset = Amount(amount, self.market['quote']['symbol']) + order['quote'] = quote_asset + order['price'] = price + base_asset = Amount(amount * price, self.market['base']['symbol']) + order['base'] = base_asset + return order def purge(self): - """ - Clear all the bot data from the database and cancel all orders + """ Clear all the worker data from the database and cancel all orders """ self.cancel_all() + self.clear_orders() self.clear() + + @staticmethod + def get_order_amount(order, asset_type): + try: + order_amount = order[asset_type]['amount'] + except (KeyError, TypeError): + order_amount = 0 + return order_amount + + def total_balance(self, order_ids=None, return_asset=False): + """ Returns the combined balance of the given order ids and the account balance + The amounts are returned in quote and base assets of the market + + :param order_ids: list of order ids to be added to the balance + :param return_asset: true if returned values should be Amount instances + :return: dict with keys quote and base + """ + quote = 0 + base = 0 + quote_asset = self.market['quote']['id'] + base_asset = self.market['base']['id'] + + for balance in self.balances: + if balance.asset['id'] == quote_asset: + quote += balance['amount'] + elif balance.asset['id'] == base_asset: + base += balance['amount'] + + orders_balance = self.orders_balance(order_ids) + quote += orders_balance['quote'] + base += orders_balance['base'] + + if return_asset: + quote = Amount(quote, quote_asset) + base = Amount(base, base_asset) + + return {'quote': quote, 'base': base} + + def orders_balance(self, order_ids, return_asset=False): + if not order_ids: + order_ids = [] + elif isinstance(order_ids, str): + order_ids = [order_ids] + + quote = 0 + base = 0 + quote_asset = self.market['quote']['id'] + base_asset = self.market['base']['id'] + + for order_id in order_ids: + order = self.get_updated_order(order_id) + if not order: + continue + asset_id = order['base']['asset']['id'] + if asset_id == quote_asset: + quote += order['base']['amount'] + elif asset_id == base_asset: + base += order['base']['amount'] + + if return_asset: + quote = Amount(quote, quote_asset) + base = Amount(base, base_asset) + + return {'quote': quote, 'base': base} + + def retry_action(self, action, *args, **kwargs): + """ + Perform an action, and if certain suspected-to-be-spurious graphene bugs occur, + instead of bubbling the exception, it is quietly logged (level WARN), and try again + tries a fixed number of times (MAX_TRIES) before failing + """ + tries = 0 + while True: + try: + return action(*args, **kwargs) + except bitsharesapi.exceptions.UnhandledRPCError as e: + if "Assert Exception: amount_to_sell.amount > 0" in str(e): + if tries > MAX_TRIES: + raise + else: + tries += 1 + self.log.warning("Ignoring: '{}'".format(str(e))) + self.bitshares.txbuffer.clear() + self.account.refresh() + time.sleep(2) + elif "now <= trx.expiration" in str(e): # Usually loss of sync to blockchain + if tries > MAX_TRIES: + raise + else: + tries += 1 + self.log.warning("retrying on '{}'".format(str(e))) + self.bitshares.txbuffer.clear() + time.sleep(6) # Wait at least a BitShares block + else: + raise + + @staticmethod + def truncate(number, decimals): + """ Change the decimal point of a number without rounding + """ + return math.floor(number * 10 ** decimals) / 10 ** decimals diff --git a/dexbot/bot.py b/dexbot/bot.py deleted file mode 100644 index 4c0012e67..000000000 --- a/dexbot/bot.py +++ /dev/null @@ -1,154 +0,0 @@ -import importlib -import sys -import logging -import os.path -import threading - -from dexbot.basestrategy import BaseStrategy - -from bitshares.notify import Notify -from bitshares.instance import shared_bitshares_instance - -import dexbot.errors as errors - -log = logging.getLogger(__name__) - - -# FIXME: currently static list of bot strategies: ? how to enumerate bots available and deploy new bot strategies. -STRATEGIES=[('dexbot.strategies.echo','Echo Test'), - ('dexbot.strategies.follow_orders',"Haywood's Follow Orders")] - -log_bots = logging.getLogger('dexbot.per_bot') -# NOTE this is the special logger for per-bot events -# it returns LogRecords with extra fields: botname, account, market and is_disabled -# is_disabled is a callable returning True if the bot is currently disabled. -# GUIs can add a handler to this logger to get a stream of events re the running bots. - - -class BotInfrastructure(threading.Thread): - - bots = dict() - - def __init__( - self, - config, - bitshares_instance=None, - view=None - ): - super().__init__() - - # BitShares instance - self.bitshares = bitshares_instance or shared_bitshares_instance() - self.config = config - self.view = view - - def init_bots(self): - """Do the actual initialisation of bots - Potentially quite slow (tens of seconds) - So called as part of run() - """ - # set the module search path - user_bot_path = os.path.expanduser("~/bots") - if os.path.exists(user_bot_path): - sys.path.append(user_bot_path) - - # Load all accounts and markets in use to subscribe to them - accounts = set() - markets = set() - - # Initialize bots: - for botname, bot in self.config["bots"].items(): - if "account" not in bot: - log_bots.critical("Bot has no account",extra={'botname':botname,'account':'unknown','market':'unknown','is_dsabled':(lambda: True)}) - continue - if "market" not in bot: - log_bots.critical("Bot has no market",extra={'botname':botname,'account':bot['account'],'market':'unknown','is_disabled':(lambda: True)}) - continue - try: - klass = getattr( - importlib.import_module(bot["module"]), - 'Strategy' - ) - self.bots[botname] = klass( - config=self.config, - name=botname, - bitshares_instance=self.bitshares, - view=self.view - ) - markets.add(bot['market']) - accounts.add(bot['account']) - except: - log_bots.exception("Bot initialisation",extra={'botname':botname,'account':bot['account'],'market':'unknown','is_disabled':(lambda: True)}) - - if len(markets) == 0: - log.critical("No bots to launch, exiting") - raise errors.NoBotsAvailable() - - # Create notification instance - # Technically, this will multiplex markets and accounts and - # we need to demultiplex the events after we have received them - self.notify = Notify( - markets=list(markets), - accounts=list(accounts), - on_market=self.on_market, - on_account=self.on_account, - on_block=self.on_block, - bitshares_instance=self.bitshares - ) - - # Events - def on_block(self, data): - for botname, bot in self.config["bots"].items(): - if botname not in self.bots or self.bots[botname].disabled: - continue - try: - self.bots[botname].ontick(data) - except Exception as e: - self.bots[botname].error_ontick(e) - self.bots[botname].log.exception("in .tick()") - - def on_market(self, data): - if data.get("deleted", False): # no info available on deleted orders - return - for botname, bot in self.config["bots"].items(): - if self.bots[botname].disabled: - self.bots[botname].log.warning("disabled") - continue - if bot["market"] == data.market: - try: - self.bots[botname].onMarketUpdate(data) - except Exception as e: - self.bots[botname].error_onMarketUpdate(e) - self.bots[botname].log.exception(".onMarketUpdate()") - - def on_account(self, accountupdate): - account = accountupdate.account - for botname, bot in self.config["bots"].items(): - if self.bots[botname].disabled: - self.bots[botname].log.info("The bot %s has been disabled" % botname) - continue - if bot["account"] == account["name"]: - try: - self.bots[botname].onAccount(accountupdate) - except Exception as e: - self.bots[botname].error_onAccount(e) - self.bots[botname].log.exception(".onAccountUpdate()") - - def run(self): - self.init_bots() - self.notify.listen() - - def stop(self): - for bot in self.bots: - self.bots[bot].cancel_all() - self.notify.websocket.close() - - def remove_bot(self): - for bot in self.bots: - self.bots[bot].purge() - - @staticmethod - def remove_offline_bot(config, bot_name): - # Initialize the base strategy to get control over the data - strategy = BaseStrategy(config, bot_name) - strategy.purge() diff --git a/dexbot/cli.py b/dexbot/cli.py index e3f996ea7..610f765d6 100644 --- a/dexbot/cli.py +++ b/dexbot/cli.py @@ -1,31 +1,29 @@ #!/usr/bin/env python3 import logging import os +import os.path +import sys +import signal + # we need to do this before importing click if not "LANG" in os.environ: os.environ['LANG'] = 'C.UTF-8' import click -import os.path -import os -import sys import appdirs from ruamel import yaml -from .ui import ( +from dexbot import config_file +from dexbot.ui import ( verbose, chain, unlock, - configfile, - confirmwarning, - confirmalert, - warning, - alert, + configfile ) +from dexbot.cli_conf import configure_dexbot +from dexbot import storage +from dexbot.worker import WorkerInfrastructure +import dexbot.errors as errors -from .bot import BotInfrastructure -from .cli_conf import configure_dexbot -from . import errors -from . import storage log = logging.getLogger(__name__) @@ -39,7 +37,7 @@ @click.group() @click.option( "--configfile", - default=os.path.join(appdirs.user_config_dir("dexbot"),"config.yml"), + default=config_file, ) @click.option( '--verbose', @@ -72,24 +70,42 @@ def main(ctx, **kwargs): @unlock @verbose def run(ctx): - """ Continuously run the bot + """ Continuously run the worker """ if ctx.obj['pidfile']: - with open(ctx.obj['pidfile'],'w') as fd: + with open(ctx.obj['pidfile'], 'w') as fd: fd.write(str(os.getpid())) try: - bot = BotInfrastructure(ctx.config) - bot.init_bots() - if ctx.obj['systemd']: + try: + worker = WorkerInfrastructure(ctx.config) + # Set up signalling. do it here as of no relevance to GUI + kill_workers = worker_job(worker, worker.stop) + # These first two UNIX & Windows + signal.signal(signal.SIGTERM, kill_workers) + signal.signal(signal.SIGINT, kill_workers) try: - import sdnotify # a soft dependency on sdnotify -- don't crash on non-systemd systems - n = sdnotify.SystemdNotifier() - n.notify("READY=1") - except BaseException: - log.debug("sdnotify not available") - bot.notify.listen() - except errors.NoBotsAvailable: + # These signals are UNIX-only territory, will ValueError here on Windows + signal.signal(signal.SIGHUP, kill_workers) + # TODO: reload config on SIGUSR1 + # signal.signal(signal.SIGUSR1, lambda x, y: worker.do_next_tick(worker.reread_config)) + except AttributeError: + log.debug("Cannot set all signals -- not available on this platform") + worker.init_workers(ctx.config) + if ctx.obj['systemd']: # tell systemd we are running + try: + import sdnotify # a soft dependency on sdnotify -- don't crash on non-systemd systems + n = sdnotify.SystemdNotifier() + n.notify("READY=1") + except BaseException: + log.debug("sdnotify not available") + worker.update_notify() + worker.notify.listen() + finally: + if ctx.obj['pidfile']: + os.unlink(ctx.obj['pidfile']) + except errors.NoWorkersAvailable: sys.exit(70) # 70= "Software error" in /usr/include/sysexts.h + # this needs to be in an outside try otherwise we will exit before the finally clause @main.command() @@ -117,5 +133,10 @@ def configure(ctx): click.echo("starting dexbot daemon") os.system("systemctl --user start dexbot") + +def worker_job(worker, job): + return lambda x, y: worker.do_next_tick(job) + + if __name__ == '__main__': main() diff --git a/dexbot/controllers/create_bot_controller.py b/dexbot/controllers/create_bot_controller.py deleted file mode 100644 index 5854413a8..000000000 --- a/dexbot/controllers/create_bot_controller.py +++ /dev/null @@ -1,139 +0,0 @@ -from dexbot.controllers.main_controller import MainController - -import bitshares -from bitshares.instance import shared_bitshares_instance -from bitshares.asset import Asset -from bitshares.account import Account -from bitsharesbase.account import PrivateKey -from ruamel.yaml import YAML - - -class CreateBotController: - - def __init__(self, main_ctrl): - self.main_ctrl = main_ctrl - self.bitshares = main_ctrl.bitshares_instance or shared_bitshares_instance() - - @property - def strategies(self): - strategies = { - 'Simple Strategy': 'dexbot.strategies.simple' - } - return strategies - - def get_strategy_module(self, strategy): - return self.strategies[strategy] - - @property - def base_assets(self): - assets = [ - 'USD', 'OPEN.BTC', 'CNY', 'BTS', 'BTC' - ] - return assets - - def remove_bot(self, bot_name): - self.main_ctrl.remove_bot(bot_name) - - def is_bot_name_valid(self, bot_name, old_bot_name=None): - bot_names = self.main_ctrl.get_bots_data().keys() - # and old_bot_name not in bot_names - if bot_name in bot_names and old_bot_name not in bot_names: - is_name_valid = False - else: - is_name_valid = True - return is_name_valid - - def is_asset_valid(self, asset): - try: - Asset(asset, bitshares_instance=self.bitshares) - return True - except bitshares.exceptions.AssetDoesNotExistsException: - return False - - def account_exists(self, account): - try: - Account(account, bitshares_instance=self.bitshares) - return True - except bitshares.exceptions.AccountDoesNotExistsException: - return False - - def is_account_valid(self, account, private_key): - wallet = self.bitshares.wallet - try: - pubkey = format(PrivateKey(private_key).pubkey, self.bitshares.prefix) - except ValueError: - return False - - accounts = wallet.getAllAccounts(pubkey) - account_names = [account['name'] for account in accounts] - - if account in account_names: - return True - else: - return False - - def add_private_key(self, private_key): - wallet = self.bitshares.wallet - try: - wallet.addPrivateKey(private_key) - except ValueError: - # Private key already added - pass - - @staticmethod - def get_unique_bot_name(): - """ - Returns unique bot name "Bot %n", where %n is the next available index - """ - index = 1 - bots = MainController.get_bots_data().keys() - botname = "Bot {0}".format(index) - while botname in bots: - botname = "Bot {0}".format(index) - index += 1 - - return botname - - @staticmethod - def add_bot_config(botname, bot_data): - yaml = YAML() - with open('config.yml', 'r') as f: - config = yaml.load(f) - - config['bots'][botname] = bot_data - - with open("config.yml", "w") as f: - yaml.dump(config, f) - - @staticmethod - def get_bot_current_strategy(bot_data): - strategies = { - bot_data['strategy']: bot_data['module'] - } - return strategies - - @staticmethod - def get_assets(bot_data): - return bot_data['market'].split('/') - - def get_base_asset(self, bot_data): - return self.get_assets(bot_data)[1] - - def get_quote_asset(self, bot_data): - return self.get_assets(bot_data)[0] - - @staticmethod - def get_account(bot_data): - return bot_data['account'] - - @staticmethod - def get_target_amount(bot_data): - return bot_data['target']['amount'] - - @staticmethod - def get_target_center_price(bot_data): - return bot_data['target']['center_price'] - - @staticmethod - def get_target_spread(bot_data): - return bot_data['target']['spread'] diff --git a/dexbot/controllers/create_worker_controller.py b/dexbot/controllers/create_worker_controller.py new file mode 100644 index 000000000..b6da2b708 --- /dev/null +++ b/dexbot/controllers/create_worker_controller.py @@ -0,0 +1,247 @@ +import collections + +from dexbot.views.errors import gui_error +from dexbot.controllers.main_controller import MainController +from dexbot.views.notice import NoticeDialog +from dexbot.views.confirmation import ConfirmationDialog +from dexbot.views.strategy_form import StrategyFormWidget + +import bitshares +from bitshares.instance import shared_bitshares_instance +from bitshares.asset import Asset +from bitshares.account import Account +from bitsharesbase.account import PrivateKey + + +class CreateWorkerController: + + def __init__(self, view, bitshares_instance, mode): + self.view = view + self.bitshares = bitshares_instance or shared_bitshares_instance() + self.mode = mode + + @property + def strategies(self): + strategies = collections.OrderedDict() + strategies['dexbot.strategies.relative_orders'] = { + 'name': 'Relative Orders', + 'form_module': 'dexbot.views.ui.forms.relative_orders_widget_ui' + } + strategies['dexbot.strategies.staggered_orders'] = { + 'name': 'Staggered Orders', + 'form_module': 'dexbot.views.ui.forms.staggered_orders_widget_ui' + } + return strategies + + @staticmethod + def get_strategies(): + """ Static method for getting the strategies + """ + controller = CreateWorkerController(None, None, None) + return controller.strategies + + @property + def base_assets(self): + assets = [ + 'USD', 'OPEN.BTC', 'CNY', 'BTS', 'BTC' + ] + return assets + + @staticmethod + def is_worker_name_valid(worker_name): + worker_names = MainController.get_workers_data().keys() + # Check that the name is unique + if worker_name in worker_names: + return False + return True + + def is_asset_valid(self, asset): + try: + Asset(asset, bitshares_instance=self.bitshares) + return True + except bitshares.exceptions.AssetDoesNotExistsException: + return False + + def account_exists(self, account): + try: + Account(account, bitshares_instance=self.bitshares) + return True + except bitshares.exceptions.AccountDoesNotExistsException: + return False + + def is_account_valid(self, account, private_key): + if not private_key or not account: + return False + + wallet = self.bitshares.wallet + try: + pubkey = format(PrivateKey(private_key).pubkey, self.bitshares.prefix) + except ValueError: + return False + + accounts = wallet.getAllAccounts(pubkey) + account_names = [account['name'] for account in accounts] + + if account in account_names: + return True + else: + return False + + @staticmethod + def is_account_in_use(account): + workers = MainController.get_workers_data() + for worker_name, worker in workers.items(): + if worker['account'] == account: + return True + return False + + def add_private_key(self, private_key): + wallet = self.bitshares.wallet + try: + wallet.addPrivateKey(private_key) + except ValueError: + # Private key already added + pass + + @staticmethod + def get_unique_worker_name(): + """ Returns unique worker name "Worker %n", where %n is the next available index + """ + index = 1 + workers = MainController.get_workers_data().keys() + worker_name = "Worker {0}".format(index) + while worker_name in workers: + worker_name = "Worker {0}".format(index) + index += 1 + + return worker_name + + def get_strategy_name(self, module): + return self.strategies[module]['name'] + + @staticmethod + def get_strategy_module(worker_data): + return worker_data['module'] + + @staticmethod + def get_assets(worker_data): + return worker_data['market'].split('/') + + def get_base_asset(self, worker_data): + return self.get_assets(worker_data)[1] + + def get_quote_asset(self, worker_data): + return self.get_assets(worker_data)[0] + + @staticmethod + def get_account(worker_data): + return worker_data['account'] + + @staticmethod + def handle_save_dialog(): + dialog = ConfirmationDialog('Saving the worker will cancel all the current orders.\n' + 'Are you sure you want to do this?') + return dialog.exec_() + + @gui_error + def change_strategy_form(self, worker_data=None): + # Make sure the container is empty + for index in reversed(range(self.view.strategy_container.count())): + self.view.strategy_container.itemAt(index).widget().setParent(None) + + strategy_module = self.view.strategy_input.currentData() + self.view.strategy_widget = StrategyFormWidget(self, strategy_module, worker_data) + self.view.strategy_container.addWidget(self.view.strategy_widget) + + # Resize the dialog to be minimum possible height + width = self.view.geometry().width() + self.view.setMinimumSize(width, 0) + self.view.resize(width, 1) + + def validate_worker_name(self, worker_name, old_worker_name=None): + if self.mode == 'add': + return self.is_worker_name_valid(worker_name) + elif self.mode == 'edit': + if old_worker_name != worker_name: + return self.is_worker_name_valid(worker_name) + return True + + def validate_asset(self, asset): + return self.is_asset_valid(asset) + + def validate_market(self, base_asset, quote_asset): + return base_asset.lower() != quote_asset.lower() + + def validate_account_name(self, account): + return self.account_exists(account) + + def validate_account(self, account, private_key): + return self.is_account_valid(account, private_key) + + def validate_account_not_in_use(self, account): + return not self.is_account_in_use(account) + + @gui_error + def validate_form(self): + error_texts = [] + base_asset = self.view.base_asset_input.currentText() + quote_asset = self.view.quote_asset_input.text() + worker_name = self.view.worker_name_input.text() + + if not self.validate_asset(base_asset): + error_texts.append('Field "Base Asset" does not have a valid asset.') + if not self.validate_asset(quote_asset): + error_texts.append('Field "Quote Asset" does not have a valid asset.') + if not self.validate_market(base_asset, quote_asset): + error_texts.append("Market {}/{} doesn't exist.".format(base_asset, quote_asset)) + if self.mode == 'add': + account = self.view.account_input.text() + private_key = self.view.private_key_input.text() + if not self.validate_worker_name(worker_name): + error_texts.append('Worker name needs to be unique. "{}" is already in use.'.format(worker_name)) + if not self.validate_account_name(account): + error_texts.append("Account doesn't exist.") + if not self.validate_account(account, private_key): + error_texts.append('Private key is invalid.') + if not self.validate_account_not_in_use(account): + error_texts.append('Use a different account. "{}" is already in use.'.format(account)) + elif self.mode == 'edit': + if not self.validate_worker_name(worker_name, self.view.worker_name): + error_texts.append('Worker name needs to be unique. "{}" is already in use.'.format(worker_name)) + + error_texts.extend(self.view.strategy_widget.strategy_controller.validation_errors()) + error_text = '\n'.join(error_texts) + + if error_text: + dialog = NoticeDialog(error_text) + dialog.exec_() + return False + else: + return True + + @gui_error + def handle_save(self): + if not self.validate_form(): + return + + if self.mode == 'add': + # Add the private key to the database + private_key = self.view.private_key_input.text() + self.add_private_key(private_key) + + account = self.view.account_input.text() + else: + account = self.view.account_name.text() + + base_asset = self.view.base_asset_input.currentText() + quote_asset = self.view.quote_asset_input.text() + strategy_module = self.view.strategy_input.currentData() + + self.view.worker_data = { + 'account': account, + 'market': '{}/{}'.format(quote_asset, base_asset), + 'module': strategy_module, + **self.view.strategy_widget.values + } + self.view.worker_name = self.view.worker_name_input.text() + self.view.accept() diff --git a/dexbot/controllers/main_controller.py b/dexbot/controllers/main_controller.py index f2dc065e6..17f67de7f 100644 --- a/dexbot/controllers/main_controller.py +++ b/dexbot/controllers/main_controller.py @@ -1,4 +1,10 @@ -from dexbot.bot import BotInfrastructure +import logging +import sys + +from dexbot import config_file, VERSION +from dexbot.worker import WorkerInfrastructure + +from dexbot.views.errors import PyQtHandler from ruamel.yaml import YAML from bitshares.instance import set_shared_bitshares_instance @@ -6,83 +12,118 @@ class MainController: - bots = dict() - def __init__(self, bitshares_instance): self.bitshares_instance = bitshares_instance set_shared_bitshares_instance(bitshares_instance) - self.bot_template = BotInfrastructure - - def create_bot(self, botname, config, view): + self.worker_manager = None + + # Configure logging + formatter = logging.Formatter( + '%(asctime)s - %(worker_name)s using account %(account)s on %(market)s - %(levelname)s - %(message)s') + logger = logging.getLogger("dexbot.per_worker") + fh = logging.FileHandler('dexbot.log') + fh.setFormatter(formatter) + logger.addHandler(fh) + logger.setLevel(logging.INFO) + self.pyqt_handler = PyQtHandler() + self.pyqt_handler.setLevel(logging.INFO) + logger.addHandler(self.pyqt_handler) + logger.info("DEXBot {} on python {} {}".format(VERSION, sys.version[:6], sys.platform), extra={ + 'worker_name': 'NONE', 'account': 'NONE', 'market': 'NONE'}) + + def set_info_handler(self, handler): + self.pyqt_handler.set_info_handler(handler) + + def create_worker(self, worker_name, config, view): # Todo: Add some threading here so that the GUI doesn't freeze - bot = self.bot_template(config, self.bitshares_instance, view) - bot.daemon = True - bot.start() - self.bots[botname] = bot + if self.worker_manager and self.worker_manager.is_alive(): + self.worker_manager.add_worker(worker_name, config) + else: + self.worker_manager = WorkerInfrastructure(config, self.bitshares_instance, view) + self.worker_manager.daemon = True + self.worker_manager.start() - def stop_bot(self, bot_name): - self.bots[bot_name].stop() - self.bots.pop(bot_name, None) + def stop_worker(self, worker_name): + self.worker_manager.stop(worker_name) - def remove_bot(self, bot_name): + def remove_worker(self, worker_name): # Todo: Add some threading here so that the GUI doesn't freeze - if bot_name in self.bots: - # Bot currently running - self.bots[bot_name].remove_bot() - self.bots[bot_name].stop() - self.bots.pop(bot_name, None) + if self.worker_manager and self.worker_manager.is_alive(): + # Worker manager currently running + if worker_name in self.worker_manager.workers: + self.worker_manager.remove_worker(worker_name) + self.worker_manager.stop(worker_name) + else: + # Worker not running + config = self.get_worker_config(worker_name) + WorkerInfrastructure.remove_offline_worker(config, worker_name) else: - # Bot not running - config = self.get_bot_config(bot_name) - self.bot_template.remove_offline_bot(config, bot_name) - - self.remove_bot_config(bot_name) + # Worker manager not running + config = self.get_worker_config(worker_name) + WorkerInfrastructure.remove_offline_worker(config, worker_name) @staticmethod def load_config(): yaml = YAML() - with open('config.yml', 'r') as f: + with open(config_file, 'r') as f: return yaml.load(f) @staticmethod - def get_bots_data(): + def get_workers_data(): """ - Returns dict of all the bots data + Returns dict of all the workers data """ - with open('config.yml', 'r') as f: + with open(config_file, 'r') as f: yaml = YAML() - return yaml.load(f)['bots'] + return yaml.load(f)['workers'] @staticmethod - def get_latest_bot_config(): + def get_worker_config(worker_name): """ - Returns config file data with only the latest bot data + Returns config file data with only the data from a specific worker """ - with open('config.yml', 'r') as f: + with open(config_file, 'r') as f: yaml = YAML() config = yaml.load(f) - latest_bot = list(config['bots'].keys())[-1] - config['bots'] = {latest_bot: config['bots'][latest_bot]} + config['workers'] = {worker_name: config['workers'][worker_name]} return config @staticmethod - def get_bot_config(botname): - """ - Returns config file data with only the data from a specific bot - """ - with open('config.yml', 'r') as f: - yaml = YAML() + def remove_worker_config(worker_name): + yaml = YAML() + with open(config_file, 'r') as f: config = yaml.load(f) - config['bots'] = {botname: config['bots'][botname]} - return config + + config['workers'].pop(worker_name, None) + + with open(config_file, "w") as f: + yaml.dump(config, f) + + @staticmethod + def add_worker_config(worker_name, worker_data): + yaml = YAML() + with open(config_file, 'r') as f: + config = yaml.load(f) + + config['workers'][worker_name] = worker_data + + with open(config_file, "w") as f: + yaml.dump(config, f) @staticmethod - def remove_bot_config(bot_name): + def replace_worker_config(worker_name, new_worker_name, worker_data): yaml = YAML() - with open('config.yml', 'r') as f: + with open(config_file, 'r') as f: config = yaml.load(f) - config['bots'].pop(bot_name, None) + workers = config['workers'] + # Rotate the dict keys to keep order + for _ in range(len(workers)): + key, value = workers.popitem(False) + if worker_name == key: + workers[new_worker_name] = worker_data + else: + workers[key] = value - with open("config.yml", "w") as f: + with open(config_file, "w") as f: yaml.dump(config, f) diff --git a/dexbot/controllers/strategy_controller.py b/dexbot/controllers/strategy_controller.py new file mode 100644 index 000000000..3988168f9 --- /dev/null +++ b/dexbot/controllers/strategy_controller.py @@ -0,0 +1,178 @@ +from dexbot.queue.idle_queue import idle_add +from dexbot.views.errors import gui_error +from dexbot.strategies.staggered_orders import Strategy as StaggeredOrdersStrategy + +from bitshares.market import Market +from bitshares.asset import AssetDoesNotExistsException + + +class RelativeOrdersController: + + def __init__(self, view, worker_controller, worker_data): + self.view = view + self.worker_controller = worker_controller + self.view.strategy_widget.relative_order_size_checkbox.toggled.connect( + self.onchange_relative_order_size_checkbox + ) + + if worker_data: + self.set_config_values(worker_data) + + @gui_error + def onchange_relative_order_size_checkbox(self, checked): + if checked: + self.order_size_input_to_relative() + else: + self.order_size_input_to_static() + + @gui_error + def order_size_input_to_relative(self): + self.view.strategy_widget.amount_input.setSuffix('%') + self.view.strategy_widget.amount_input.setDecimals(2) + self.view.strategy_widget.amount_input.setMaximum(100.00) + self.view.strategy_widget.amount_input.setMinimumWidth(151) + self.view.strategy_widget.amount_input.setValue(10.00) + + @gui_error + def order_size_input_to_static(self): + self.view.strategy_widget.amount_input.setSuffix('') + self.view.strategy_widget.amount_input.setDecimals(8) + self.view.strategy_widget.amount_input.setMaximum(1000000000.000000) + self.view.strategy_widget.amount_input.setValue(0.000000) + + @gui_error + def set_config_values(self, worker_data): + if worker_data.get('amount_relative', False): + self.order_size_input_to_relative() + self.view.strategy_widget.relative_order_size_checkbox.setChecked(True) + else: + self.order_size_input_to_static() + self.view.strategy_widget.relative_order_size_checkbox.setChecked(False) + + self.view.strategy_widget.amount_input.setValue(float(worker_data.get('amount', 0))) + self.view.strategy_widget.center_price_input.setValue(worker_data.get('center_price', 0)) + self.view.strategy_widget.spread_input.setValue(worker_data.get('spread', 5)) + + if worker_data.get('center_price_dynamic', True): + self.view.strategy_widget.center_price_dynamic_checkbox.setChecked(True) + else: + self.view.strategy_widget.center_price_dynamic_checkbox.setChecked(False) + self.view.strategy_widget.center_price_input.setDisabled(False) + + if worker_data.get('center_price_offset', True): + self.view.strategy_widget.center_price_offset_checkbox.setChecked(True) + else: + self.view.strategy_widget.center_price_offset_checkbox.setChecked(False) + + def validation_errors(self): + error_texts = [] + if not self.view.strategy_widget.amount_input.value(): + error_texts.append("Amount can't be 0") + if not self.view.strategy_widget.spread_input.value(): + error_texts.append("Spread can't be 0") + return error_texts + + @property + def values(self): + data = { + 'amount': self.view.strategy_widget.amount_input.value(), + 'amount_relative': self.view.strategy_widget.relative_order_size_checkbox.isChecked(), + 'center_price': self.view.strategy_widget.center_price_input.value(), + 'center_price_dynamic': self.view.strategy_widget.center_price_dynamic_checkbox.isChecked(), + 'center_price_offset': self.view.strategy_widget.center_price_offset_checkbox.isChecked(), + 'spread': self.view.strategy_widget.spread_input.value() + } + return data + + +class StaggeredOrdersController: + + def __init__(self, view, worker_controller, worker_data): + self.view = view + self.worker_controller = worker_controller + + if worker_data: + self.set_config_values(worker_data) + + worker_controller.view.base_asset_input.editTextChanged.connect(lambda: self.on_value_change()) + worker_controller.view.quote_asset_input.textChanged.connect(lambda: self.on_value_change()) + widget = self.view.strategy_widget + widget.amount_input.valueChanged.connect(lambda: self.on_value_change()) + widget.spread_input.valueChanged.connect(lambda: self.on_value_change()) + widget.increment_input.valueChanged.connect(lambda: self.on_value_change()) + widget.lower_bound_input.valueChanged.connect(lambda: self.on_value_change()) + widget.upper_bound_input.valueChanged.connect(lambda: self.on_value_change()) + self.on_value_change() + + @gui_error + def set_config_values(self, worker_data): + widget = self.view.strategy_widget + widget.amount_input.setValue(worker_data.get('amount', 0)) + widget.increment_input.setValue(worker_data.get('increment', 4)) + widget.spread_input.setValue(worker_data.get('spread', 6)) + widget.lower_bound_input.setValue(worker_data.get('lower_bound', 0.000001)) + widget.upper_bound_input.setValue(worker_data.get('upper_bound', 1000000)) + + @gui_error + def on_value_change(self): + base_asset = self.worker_controller.view.base_asset_input.currentText() + quote_asset = self.worker_controller.view.quote_asset_input.text() + try: + market = Market('{}:{}'.format(quote_asset, base_asset)) + except AssetDoesNotExistsException: + idle_add(self.set_required_base, 'N/A') + idle_add(self.set_required_quote, 'N/A') + return + + amount = self.view.strategy_widget.amount_input.value() + spread = self.view.strategy_widget.spread_input.value() / 100 + increment = self.view.strategy_widget.increment_input.value() / 100 + lower_bound = self.view.strategy_widget.lower_bound_input.value() + upper_bound = self.view.strategy_widget.upper_bound_input.value() + + if not (market or amount or spread or increment or lower_bound or upper_bound): + idle_add(self.set_required_base, 'N/A') + idle_add(self.set_required_quote, 'N/A') + return + + strategy = StaggeredOrdersStrategy + result = strategy.get_required_assets(market, amount, spread, increment, lower_bound, upper_bound) + if not result: + idle_add(self.set_required_base, 'N/A') + idle_add(self.set_required_quote, 'N/A') + return + + base, quote = result + text = '{:.8f} {}'.format(base, base_asset) + idle_add(self.set_required_base, text) + text = '{:.8f} {}'.format(quote, quote_asset) + idle_add(self.set_required_quote, text) + + def set_required_base(self, text): + self.view.strategy_widget.required_base_text.setText(text) + + def set_required_quote(self, text): + self.view.strategy_widget.required_quote_text.setText(text) + + def validation_errors(self): + error_texts = [] + if not self.view.strategy_widget.amount_input.value(): + error_texts.append("Amount can't be 0") + if not self.view.strategy_widget.spread_input.value(): + error_texts.append("Spread can't be 0") + if not self.view.strategy_widget.increment_input.value(): + error_texts.append("Increment can't be 0") + if not self.view.strategy_widget.lower_bound_input.value(): + error_texts.append("Lower bound can't be 0") + return error_texts + + @property + def values(self): + data = { + 'amount': self.view.strategy_widget.amount_input.value(), + 'spread': self.view.strategy_widget.spread_input.value(), + 'increment': self.view.strategy_widget.increment_input.value(), + 'lower_bound': self.view.strategy_widget.lower_bound_input.value(), + 'upper_bound': self.view.strategy_widget.upper_bound_input.value() + } + return data diff --git a/dexbot/errors.py b/dexbot/errors.py index b56af00ee..8cd19736e 100644 --- a/dexbot/errors.py +++ b/dexbot/errors.py @@ -7,4 +7,6 @@ def InsufficientFundsError(amount): "[InsufficientFunds] Need {}".format(str(amount)) ) -class NoBotsAvailable(Exception): pass + +class NoWorkersAvailable(Exception): + pass diff --git a/app.py b/dexbot/gui.py similarity index 93% rename from app.py rename to dexbot/gui.py index 4fa494e20..2f4de84a1 100644 --- a/app.py +++ b/dexbot/gui.py @@ -4,7 +4,7 @@ from bitshares import BitShares from dexbot.controllers.main_controller import MainController -from dexbot.views.bot_list import MainView +from dexbot.views.worker_list import MainView from dexbot.controllers.wallet_controller import WalletController from dexbot.views.unlock_wallet import UnlockWalletView from dexbot.views.create_wallet import CreateWalletView @@ -32,6 +32,11 @@ def __init__(self, sys_argv): else: sys.exit() -if __name__ == '__main__': + +def main(): app = App(sys.argv) sys.exit(app.exec_()) + + +if __name__ == '__main__': + main() diff --git a/dexbot/queue/__init__.py b/dexbot/queue/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/dexbot/statemachine.py b/dexbot/statemachine.py index 56c97751a..8a2dd26c0 100644 --- a/dexbot/statemachine.py +++ b/dexbot/statemachine.py @@ -1,6 +1,7 @@ -class StateMachine(): +class StateMachine: """ Generic state machine """ + def __init__(self, *args, **kwargs): self.states = set() self.state = None diff --git a/dexbot/storage.py b/dexbot/storage.py index cfbec7bb6..0edff77f3 100644 --- a/dexbot/storage.py +++ b/dexbot/storage.py @@ -1,19 +1,19 @@ -import sqlalchemy import os import json import threading import queue import uuid -import time -from sqlalchemy import create_engine, Table, Column, String, Integer, MetaData +from appdirs import user_data_dir + +from sqlalchemy import create_engine, Column, String, Integer from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker -from appdirs import user_data_dir + Base = declarative_base() # For dexbot.sqlite file appname = "dexbot" -appauthor = "ChainSquad GmbH" +appauthor = "Codaone Oy" storageDatabase = "dexbot.sqlite" @@ -43,37 +43,75 @@ def __init__(self, c, k, v): self.value = v +class Orders(Base): + __tablename__ = 'orders' + + id = Column(Integer, primary_key=True) + worker = Column(String) + order_id = Column(String) + order = Column(String) + + def __init__(self, worker, order_id, order): + self.worker = worker + self.order_id = order_id + self.order = order + + class Storage(dict): """ Storage class :param string category: The category to distinguish different storage namespaces """ + def __init__(self, category): self.category = category def __setitem__(self, key, value): - worker.execute_noreturn(worker.set_item, self.category, key, value) + db_worker.set_item(self.category, key, value) def __getitem__(self, key): - return worker.execute(worker.get_item, self.category, key) + return db_worker.get_item(self.category, key) def __delitem__(self, key): - worker.execute_noreturn(worker.del_item, self.category, key) + db_worker.del_item(self.category, key) def __contains__(self, key): - return worker.execute(worker.contains, self.category, key) + return db_worker.contains(self.category, key) def items(self): - return worker.execute(worker.get_items, self.category) + return db_worker.get_items(self.category) def clear(self): - worker.execute_noreturn(worker.clear, self.category) + db_worker.clear(self.category) + + def save_order(self, order): + """ Save the order to the database + """ + order_id = order['id'] + db_worker.save_order(self.category, order_id, order) + + def remove_order(self, order): + """ Removes an order from the database + """ + order_id = order['id'] + db_worker.remove_order(self.category, order_id) + + def clear_orders(self): + """ Removes all worker's orders from the database + """ + db_worker.clear_orders(self.category) + + def fetch_orders(self, worker=None): + """ Get all the orders (or just specific worker's orders) from the database + """ + if not worker: + worker = self.category + return db_worker.fetch_orders(worker) class DatabaseWorker(threading.Thread): - """ - Thread safe database worker + """ Thread safe database worker """ def __init__(self): @@ -100,7 +138,7 @@ def run(self): args = args+(token,) func(*args) - def get_result(self, token): + def _get_result(self, token): while True: with self.lock: if token in self.results: @@ -111,7 +149,7 @@ def get_result(self, token): self.event.clear() self.event.wait() - def set_result(self, token, result): + def _set_result(self, token, result): with self.lock: self.results[token] = result self.event.set() @@ -119,12 +157,15 @@ def set_result(self, token, result): def execute(self, func, *args): token = str(uuid.uuid4) self.task_queue.put((func, args, token)) - return self.get_result(token) + return self._get_result(token) def execute_noreturn(self, func, *args): self.task_queue.put((func, args, None)) - + def set_item(self, category, key, value): + self.execute_noreturn(self._set_item, category, key, value) + + def _set_item(self, category, key, value): value = json.dumps(value) e = self.session.query(Config).filter_by( category=category, @@ -137,7 +178,10 @@ def set_item(self, category, key, value): self.session.add(e) self.session.commit() - def get_item(self, category, key, token): + def get_item(self, category, key): + return self.execute(self._get_item, category, key) + + def _get_item(self, category, key, token): e = self.session.query(Config).filter_by( category=category, key=key @@ -146,9 +190,12 @@ def get_item(self, category, key, token): result = None else: result = json.loads(e.value) - self.set_result(token, result) + self._set_result(token, result) def del_item(self, category, key): + self.execute_noreturn(self._del_item, category, key) + + def _del_item(self, category, key): e = self.session.query(Config).filter_by( category=category, key=key @@ -156,21 +203,30 @@ def del_item(self, category, key): self.session.delete(e) self.session.commit() - def contains(self, category, key, token): + def contains(self, category, key): + return self.execute(self._contains, category, key) + + def _contains(self, category, key, token): e = self.session.query(Config).filter_by( category=category, key=key ).first() - self.set_result(token, bool(e)) + self._set_result(token, bool(e)) + + def get_items(self, category): + return self.execute(self._get_items, category) - def get_items(self, category, token): + def _get_items(self, category, token): es = self.session.query(Config).filter_by( category=category ).all() result = [(e.key, e.value) for e in es] - self.set_result(token, result) + self._set_result(token, result) def clear(self, category): + self.execute_noreturn(self._clear, category) + + def _clear(self, category): rows = self.session.query(Config).filter_by( category=category ) @@ -178,6 +234,58 @@ def clear(self, category): self.session.delete(row) self.session.commit() + def save_order(self, worker, order_id, order): + self.execute_noreturn(self._save_order, worker, order_id, order) + + def _save_order(self, worker, order_id, order): + value = json.dumps(order) + e = self.session.query(Orders).filter_by( + order_id=order_id + ).first() + if e: + e.value = value + else: + e = Orders(worker, order_id, value) + self.session.add(e) + self.session.commit() + + def remove_order(self, worker, order_id): + self.execute_noreturn(self._remove_order, worker, order_id) + + def _remove_order(self, worker, order_id): + e = self.session.query(Orders).filter_by( + worker=worker, + order_id=order_id + ).first() + self.session.delete(e) + self.session.commit() + + def clear_orders(self, worker): + self.execute_noreturn(self._clear_orders, worker) + + def _clear_orders(self, worker): + rows = self.session.query(Orders).filter_by( + worker=worker + ) + for row in rows: + self.session.delete(row) + self.session.commit() + + def fetch_orders(self, category): + return self.execute(self._fetch_orders, category) + + def _fetch_orders(self, worker, token): + results = self.session.query(Orders).filter_by( + worker=worker, + ).all() + if not results: + result = None + else: + result = {} + for row in results: + result[row.order_id] = json.loads(row.order) + self._set_result(token, result) + # Derive sqlite file directory data_dir = user_data_dir(appname, appauthor) @@ -186,4 +294,4 @@ def clear(self, category): # Create directory for sqlite file mkdir_p(data_dir) -worker = DatabaseWorker() +db_worker = DatabaseWorker() diff --git a/dexbot/strategies/echo.py b/dexbot/strategies/echo.py index e940145ed..87c685c38 100644 --- a/dexbot/strategies/echo.py +++ b/dexbot/strategies/echo.py @@ -6,7 +6,7 @@ class Strategy(BaseStrategy): Echo strategy Strategy that logs all events within the blockchain """ - + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -83,7 +83,7 @@ def print_newBlock(self, i): # raise ValueError("Testing disabling") def print_accountUpdate(self, i): - """ This method is called when the bot's account name receives + """ This method is called when the worker's account name receives any update. This includes anything that changes ``2.6.xxxx``, e.g., any operation that affects your account. """ diff --git a/dexbot/strategies/relative_orders.py b/dexbot/strategies/relative_orders.py new file mode 100644 index 000000000..64a38b93a --- /dev/null +++ b/dexbot/strategies/relative_orders.py @@ -0,0 +1,159 @@ +import math + +from dexbot.basestrategy import BaseStrategy +from dexbot.queue.idle_queue import idle_add + + +class Strategy(BaseStrategy): + """ Relative Orders strategy + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.log.info("Initializing Relative Orders") + + # Define Callbacks + self.onMarketUpdate += self.check_orders + self.onAccount += self.check_orders + + self.error_ontick = self.error + self.error_onMarketUpdate = self.error + self.error_onAccount = self.error + + self.is_center_price_dynamic = self.worker["center_price_dynamic"] + if self.is_center_price_dynamic: + self.center_price = None + else: + self.center_price = self.worker["center_price"] + + self.is_relative_order_size = self.worker['amount_relative'] + self.is_center_price_offset = self.worker.get('center_price_offset', False) + self.order_size = float(self.worker['amount']) + self.spread = self.worker.get('spread') / 100 + + self.buy_price = None + self.sell_price = None + + self.initial_balance = self['initial_balance'] or 0 + self.worker_name = kwargs.get('name') + self.view = kwargs.get('view') + self.check_orders() + + def error(self, *args, **kwargs): + self.cancel_all() + self.disabled = True + + @property + def amount_quote(self): + """ Get quote amount, calculate if order size is relative + """ + if self.is_relative_order_size: + quote_balance = float(self.balance(self.market["quote"])) + return quote_balance * (self.order_size / 100) + else: + return self.order_size + + @property + def amount_base(self): + """ Get base amount, calculate if order size is relative + """ + if self.is_relative_order_size: + base_balance = float(self.balance(self.market["base"])) + # amount = % of balance / buy_price = amount combined with calculated price to give % of balance + return base_balance * (self.order_size / 100) / self.buy_price + else: + return self.order_size + + def calculate_order_prices(self): + if self.is_center_price_dynamic: + if self.is_center_price_offset: + self.center_price = self.calculate_offset_center_price( + self.spread, order_ids=self['order_ids']) + else: + self.center_price = self.calculate_center_price() + else: + if self.is_center_price_offset: + self.center_price = self.calculate_offset_center_price( + self.spread, self.center_price, self['order_ids']) + + self.buy_price = self.center_price / math.sqrt(1 + self.spread) + self.sell_price = self.center_price * math.sqrt(1 + self.spread) + + def update_orders(self): + self.log.info('Change detected, updating orders') + + # Recalculate buy and sell order prices + self.calculate_order_prices() + + # Cancel the orders before redoing them + self.cancel_all() + + # Mark the orders empty + self['buy_order'] = {} + self['sell_order'] = {} + + order_ids = [] + + amount_base = self.amount_base + amount_quote = self.amount_quote + + # Buy Side + buy_order = self.market_buy(amount_base, self.buy_price, True) + if buy_order: + self['buy_order'] = buy_order + order_ids.append(buy_order['id']) + + # Sell Side + sell_order = self.market_sell(amount_quote, self.sell_price, True) + if sell_order: + self['sell_order'] = sell_order + order_ids.append(sell_order['id']) + + self['order_ids'] = order_ids + + self.log.info("Done placing orders") + + # Some orders weren't successfully created, redo them + if len(order_ids) < 2 and not self.disabled: + self.update_orders() + + def check_orders(self, *args, **kwargs): + """ Tests if the orders need updating + """ + stored_sell_order = self['sell_order'] + stored_buy_order = self['buy_order'] + current_sell_order = self.get_order(stored_sell_order) + current_buy_order = self.get_order(stored_buy_order) + + if not current_sell_order or not current_buy_order: + # Either buy or sell order is missing, update both orders + self.update_orders() + else: + self.log.info("Orders correct on market") + + if self.view: + self.update_gui_profit() + self.update_gui_slider() + + # GUI updaters + def update_gui_profit(self): + # Fixme: profit calculation doesn't work this way, figure out a better way to do this. + if self.initial_balance: + profit = round((self.orders_balance(None) - self.initial_balance) / self.initial_balance, 3) + else: + profit = 0 + idle_add(self.view.set_worker_profit, self.worker_name, float(profit)) + self['profit'] = profit + + def update_gui_slider(self): + ticker = self.market.ticker() + latest_price = ticker.get('latest').get('price') + total_balance = self.total_balance(self['order_ids']) + total = (total_balance['quote'] * latest_price) + total_balance['base'] + + if not total: # Prevent division by zero + percentage = 50 + else: + percentage = (total_balance['base'] / total) * 100 + idle_add(self.view.set_worker_slider, self.worker_name, percentage) + self['slider'] = percentage diff --git a/dexbot/strategies/simple.py b/dexbot/strategies/simple.py deleted file mode 100644 index 535d245df..000000000 --- a/dexbot/strategies/simple.py +++ /dev/null @@ -1,256 +0,0 @@ -from collections import Counter - -from bitshares.amount import Amount -from bitshares.price import Price -from bitshares.price import Order - -from dexbot.basestrategy import BaseStrategy -from dexbot.queue.idle_queue import idle_add - - -class Strategy(BaseStrategy): - """ - Simple strategy - This strategy places a buy and a sell wall that change height over time - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Define Callbacks - self.onMarketUpdate += self.test - self.ontick += self.tick - - self.error_ontick = self.error - self.error_onMarketUpdate = self.error - self.error_onAccount = self.error - - # Counter for blocks - self.counter = Counter() - - self.price = self.bot.get("target", {}).get("center_price", 0) - target = self.bot.get("target", {}) - self.buy_price = self.price * (1 - (target["spread"] / 2) / 100) - self.sell_price = self.price * (1 + (target["spread"] / 2) / 100) - self.initial_balance = self['initial_balance'] or 0 - self.bot_name = kwargs.get('name') - self.view = kwargs.get('view') - - def error(self, *args, **kwargs): - self.disabled = True - self.log.info(self.execute()) - - def init_strategy(self): - # Target - target = self.bot.get("target", {}) - amount = target['amount'] / 2 - - # Buy Side - if float(self.balance(self.market["base"])) < self.buy_price * amount: - self.log.critical( - 'Insufficient buy balance, needed {} {}'.format(self.buy_price * amount, self.market['base']['symbol']) - ) - self.disabled = True - else: - buy_transaction = self.market.buy( - self.buy_price, - Amount(amount=amount, asset=self.market["quote"]), - account=self.account, - returnOrderId="head" - ) - buy_order = self.get_order(buy_transaction['orderid']) - self.log.info('Placed a buy order for {} {} @ {}'.format(amount, self.market["quote"], self.buy_price)) - if buy_order: - self['buy_order'] = buy_order - - # Sell Side - if float(self.balance(self.market["quote"])) < amount: - self.log.critical( - "Insufficient sell balance, needed {} {}".format(amount, self.market['quote']['symbol']) - ) - self.disabled = True - else: - sell_transaction = self.market.sell( - self.sell_price, - Amount(amount=amount, asset=self.market["quote"]), - account=self.account, - returnOrderId="head" - ) - sell_order = self.get_order(sell_transaction['orderid']) - self.log.info('Placed a sell order for {} {} @ {}'.format(amount, self.market["quote"], self.buy_price)) - if sell_order: - self['sell_order'] = sell_order - - order_balance = self.orders_balance() - self['initial_balance'] = order_balance # Save to database - self.initial_balance = order_balance - - def update_orders(self, new_sell_order, new_buy_order): - """ - Update the orders - """ - # Stored orders - sell_order = self['sell_order'] - buy_order = self['buy_order'] - - sell_price = self.sell_price - buy_price = self.buy_price - - sold_amount = 0 - if new_sell_order and new_sell_order['base']['amount'] < sell_order['base']['amount']: - # Some of the sell order was sold - sold_amount = sell_order['base']['amount'] - new_sell_order['base']['amount'] - elif not new_sell_order and sell_order: - # All of the sell order was sold - sold_amount = sell_order['base']['amount'] - - bought_amount = 0 - if new_buy_order and new_buy_order['quote']['amount'] < buy_order['quote']['amount']: - # Some of the buy order was bought - bought_amount = buy_order['quote']['amount'] - new_buy_order['quote']['amount'] - elif not new_buy_order and buy_order: - # All of the buy order was bought - bought_amount = buy_order['quote']['amount'] - - if sold_amount: - # We sold something, place updated buy order - try: - buy_order_amount = buy_order['quote']['amount'] - except KeyError: - buy_order_amount = 0 - new_buy_amount = buy_order_amount - bought_amount + sold_amount - if float(self.balance(self.market["base"])) < new_buy_amount: - self.log.critical( - 'Insufficient buy balance, needed {} {}'.format(buy_price * new_buy_amount, - self.market['base']['symbol']) - ) - self.disabled = True - else: - if buy_order and not Order(buy_order['id'])['deleted']: - # Cancel the old order - self.cancel(buy_order) - - buy_transaction = self.market.buy( - buy_price, - Amount(amount=new_buy_amount, asset=self.market["quote"]), - account=self.account, - returnOrderId="head" - ) - buy_order = self.get_order(buy_transaction['orderid']) - self.log.info( - 'Placed a buy order for {} {} @ {}'.format(new_buy_amount, self.market["quote"], buy_price) - ) - if buy_order: - self['buy_order'] = buy_order - else: - # Update the buy order - self['buy_order'] = new_buy_order or {} - - if bought_amount: - # We bought something, place updated sell order - try: - sell_order_amount = sell_order['base']['amount'] - except KeyError: - sell_order_amount = 0 - new_sell_amount = sell_order_amount + bought_amount - sold_amount - if float(self.balance(self.market["quote"])) < new_sell_amount: - self.log.critical( - "Insufficient sell balance, needed {} {}".format(new_sell_amount, self.market["quote"]['symbol']) - ) - self.disabled = True - else: - if sell_order and not Order(sell_order['id'])['deleted']: - # Cancel the old order - self.cancel(sell_order) - - sell_transaction = self.market.sell( - sell_price, - Amount(amount=new_sell_amount, asset=self.market["quote"]), - account=self.account, - returnOrderId="head" - ) - sell_order = self.get_order(sell_transaction['orderid']) - self.log.info( - 'Placed a sell order for {} {} @ {}'.format(new_sell_amount, self.market["quote"], buy_price) - ) - if sell_order: - self['sell_order'] = sell_order - else: - # Update the sell order - self['sell_order'] = new_sell_order or {} - - def orders_balance(self): - balance = 0 - orders = [o for o in [self['buy_order'], self['sell_order']] if o] # Strip empty orders - for order in orders: - if order['base']['symbol'] != self.market['base']['symbol']: - # Invert the market for easier calculation - if not isinstance(order, Price): - order = self.get_order(order['id']) - if order: - order.invert() - if order: - balance += order['base']['amount'] - - return balance - - def tick(self, d): - """ - Test orders every 10th block - """ - if not (self.counter["blocks"] or 0) % 10: - self.test() - self.counter["blocks"] += 1 - - def test(self, *args, **kwargs): - """ - Tests if the orders need updating - """ - if 'sell_order' not in self or 'buy_order' not in self: - self.init_strategy() - else: - current_sell_order = self.get_updated_order(self['sell_order']) - current_buy_order = self.get_updated_order(self['buy_order']) - - # Update checks - sell_order_updated = not current_sell_order or \ - current_sell_order['base']['amount'] != self['sell_order']['base']['amount'] - buy_order_updated = not current_buy_order or \ - current_buy_order['quote']['amount'] != self['buy_order']['quote']['amount'] - - if (self['sell_order'] and sell_order_updated) or (self['buy_order'] and buy_order_updated): - # Either buy or sell order was changed, update both orders - self.update_orders(current_sell_order, current_buy_order) - - if self.view: - self.update_gui_profit() - self.update_gui_slider() - - # GUI updaters - def update_gui_profit(self): - if self.initial_balance: - profit = round((self.orders_balance() - self.initial_balance) / self.initial_balance, 3) - else: - profit = 0 - idle_add(self.view.set_bot_profit, self.bot_name, float(profit)) - self['profit'] = profit - - def update_gui_slider(self): - buy_order = self['buy_order'] - if buy_order: - buy_amount = buy_order['quote']['amount'] - else: - buy_amount = 0 - sell_order = self['sell_order'] - if sell_order: - sell_amount = sell_order['base']['amount'] - else: - sell_amount = 0 - - total = buy_amount + sell_amount - if not total: # Prevent division by zero - percentage = 0 - else: - percentage = (buy_amount / total) * 100 - idle_add(self.view.set_bot_slider, self.bot_name, percentage) - self['slider'] = percentage diff --git a/dexbot/strategies/staggered_orders.py b/dexbot/strategies/staggered_orders.py new file mode 100644 index 000000000..574acee56 --- /dev/null +++ b/dexbot/strategies/staggered_orders.py @@ -0,0 +1,271 @@ +import math + +from dexbot.basestrategy import BaseStrategy +from dexbot.queue.idle_queue import idle_add + + +class Strategy(BaseStrategy): + """ Staggered Orders strategy + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.log.info("Initializing Staggered Orders") + + # Define Callbacks + self.onMarketUpdate += self.check_orders + self.onAccount += self.check_orders + self.ontick += self.tick + + self.error_ontick = self.error + self.error_onMarketUpdate = self.error + self.error_onAccount = self.error + + self.worker_name = kwargs.get('name') + self.view = kwargs.get('view') + self.amount = self.worker['amount'] + self.spread = self.worker['spread'] / 100 + self.increment = self.worker['increment'] / 100 + self.upper_bound = self.worker['upper_bound'] + self.lower_bound = self.worker['lower_bound'] + + if self['setup_done']: + self.place_orders() + else: + self.init_strategy() + + if self.view: + self.update_gui_profit() + self.update_gui_slider() + + def error(self, *args, **kwargs): + self.cancel_all() + self.disabled = True + + def init_strategy(self): + # Make sure no orders remain + self.cancel_all() + self.clear_orders() + + center_price = self.calculate_center_price() + amount = self.amount + spread = self.spread + increment = self.increment + lower_bound = self.lower_bound + upper_bound = self.upper_bound + + # Calculate buy prices + buy_prices = self.calculate_buy_prices(center_price, spread, increment, lower_bound) + + # Calculate sell prices + sell_prices = self.calculate_sell_prices(center_price, spread, increment, upper_bound) + + # Calculate buy and sell amounts + buy_orders, sell_orders = self.calculate_amounts(buy_prices, sell_prices, amount, spread, increment) + + # Make sure there is enough balance for the buy orders + needed_buy_asset = 0 + for buy_order in buy_orders: + needed_buy_asset += buy_order['amount'] * buy_order['price'] + if self.balance(self.market["base"]) < needed_buy_asset: + self.log.critical( + "Insufficient buy balance, needed {} {}".format(needed_buy_asset, self.market['base']['symbol']) + ) + self.disabled = True + return + + # Make sure there is enough balance for the sell orders + needed_sell_asset = 0 + for sell_order in sell_orders: + needed_sell_asset += sell_order['amount'] + if self.balance(self.market["quote"]) < needed_sell_asset: + self.log.critical( + "Insufficient sell balance, needed {} {}".format(needed_sell_asset, self.market['quote']['symbol']) + ) + self.disabled = True + return + + # Place the buy orders + for buy_order in buy_orders: + order = self.market_buy(buy_order['amount'], buy_order['price']) + if order: + self.save_order(order) + + # Place the sell orders + for sell_order in sell_orders: + order = self.market_sell(sell_order['amount'], sell_order['price']) + if order: + self.save_order(order) + + self['setup_done'] = True + self.log.info("Done placing orders") + + def place_reverse_order(self, order): + """ Replaces an order with a reverse order + buy orders become sell orders and sell orders become buy orders + """ + if order['base']['symbol'] == self.market['base']['symbol']: # Buy order + price = order['price'] * (1 + self.spread) + amount = order['quote']['amount'] + new_order = self.market_sell(amount, price) + else: # Sell order + price = (order['price'] ** -1) / (1 + self.spread) + amount = order['base']['amount'] + new_order = self.market_buy(amount, price) + + if new_order: + self.remove_order(order) + self.save_order(new_order) + + def place_order(self, order): + self.remove_order(order) + + if order['base']['symbol'] == self.market['base']['symbol']: # Buy order + price = order['price'] + amount = order['quote']['amount'] + new_order = self.market_buy(amount, price) + else: # Sell order + price = order['price'] ** -1 + amount = order['base']['amount'] + new_order = self.market_sell(amount, price) + + self.save_order(new_order) + + def place_orders(self): + """ Place all the orders found in the database + """ + self.cancel_all() + orders = self.fetch_orders() + for order_id, order in orders.items(): + if not self.get_order(order_id): + self.place_order(order) + + self.log.info("Done placing orders") + + def check_orders(self, *args, **kwargs): + """ Tests if the orders need updating + """ + order_placed = False + orders = self.fetch_orders() + for order_id, order in orders.items(): + current_order = self.get_order(order_id) + if not current_order: + self.place_reverse_order(order) + order_placed = True + + if order_placed: + self.log.info("Done placing orders") + + if self.view: + self.update_gui_profit() + self.update_gui_slider() + + @staticmethod + def calculate_buy_prices(center_price, spread, increment, lower_bound): + buy_prices = [] + if lower_bound > center_price / math.sqrt(1 + spread): + return buy_prices + + buy_price = center_price / math.sqrt(1 + spread) + while buy_price > lower_bound: + buy_prices.append(buy_price) + buy_price = buy_price / (1 + increment) + return buy_prices + + @staticmethod + def calculate_sell_prices(center_price, spread, increment, upper_bound): + sell_prices = [] + if upper_bound < center_price * math.sqrt(1 + spread): + return sell_prices + + sell_price = center_price * math.sqrt(1 + spread) + while sell_price < upper_bound: + sell_prices.append(sell_price) + sell_price = sell_price * (1 + increment) + return sell_prices + + @staticmethod + def calculate_amounts(buy_prices, sell_prices, amount, spread, increment): + # Calculate buy amounts + buy_orders = [] + if buy_prices: + highest_buy_price = buy_prices.pop(0) + buy_orders.append({'amount': amount, 'price': highest_buy_price}) + for buy_price in buy_prices: + last_amount = buy_orders[-1]['amount'] + current_amount = last_amount * math.sqrt(1 + increment) + buy_orders.append({'amount': current_amount, 'price': buy_price}) + + # Calculate sell amounts + sell_orders = [] + if sell_prices: + lowest_sell_price = sell_prices.pop(0) + current_amount = amount * math.sqrt(1 + spread + increment) + sell_orders.append({'amount': current_amount, 'price': lowest_sell_price}) + for sell_price in sell_prices: + last_amount = sell_orders[-1]['amount'] + current_amount = last_amount / math.sqrt(1 + increment) + sell_orders.append({'amount': current_amount, 'price': sell_price}) + + return [buy_orders, sell_orders] + + @staticmethod + def get_required_assets(market, amount, spread, increment, lower_bound, upper_bound): + if not amount or not lower_bound or not increment: + return None + + ticker = market.ticker() + highest_bid = ticker.get("highestBid") + lowest_ask = ticker.get("lowestAsk") + if not float(highest_bid): + return None + elif not float(lowest_ask): + return None + else: + center_price = highest_bid['price'] * math.sqrt(lowest_ask['price'] / highest_bid['price']) + + # Calculate buy prices + buy_prices = Strategy.calculate_buy_prices(center_price, spread, increment, lower_bound) + + # Calculate sell prices + sell_prices = Strategy.calculate_sell_prices(center_price, spread, increment, upper_bound) + + # Calculate buy and sell amounts + buy_orders, sell_orders = Strategy.calculate_amounts( + buy_prices, sell_prices, amount, spread, increment + ) + + needed_buy_asset = 0 + for buy_order in buy_orders: + needed_buy_asset += buy_order['amount'] * buy_order['price'] + + needed_sell_asset = 0 + for sell_order in sell_orders: + needed_sell_asset += sell_order['amount'] + + return [needed_buy_asset, needed_sell_asset] + + def tick(self, d): + """ ticks come in on every block + """ + if self.recheck_orders: + self.check_orders() + self.recheck_orders = False + + # GUI updaters + def update_gui_profit(self): + pass + + def update_gui_slider(self): + ticker = self.market.ticker() + latest_price = ticker.get('latest').get('price') + order_ids = self.fetch_orders().keys() + total_balance = self.total_balance(order_ids) + total = (total_balance['quote'] * latest_price) + total_balance['base'] + + if not total: # Prevent division by zero + percentage = 50 + else: + percentage = (total_balance['base'] / total) * 100 + idle_add(self.view.set_worker_slider, self.worker_name, percentage) + self['slider'] = percentage diff --git a/dexbot/strategies/walls.py b/dexbot/strategies/walls.py index b8a3cdc6e..6ae16f47f 100644 --- a/dexbot/strategies/walls.py +++ b/dexbot/strategies/walls.py @@ -10,19 +10,19 @@ class Walls(BaseStrategy): Walls strategy This strategy simply places a buy and a sell wall """ - + @classmethod def configure(cls): return BaseStrategy.configure()+[ - ConfigElement("spread","int",5,"the spread between sell and buy as percentage",(0,100)), - ConfigElement("threshold","int",5,"percentage the feed has to move before we change orders",(0,100)), - ConfigElement("buy","float",0.0,"the default amount to buy",(0.0,None)), - ConfigElement("sell","float",0.0,"the default amount to sell",(0.0,None)), - ConfigElement("blocks","int",20,"number of blocks to wait before re-calculating",(0,10000)), - ConfigElement("dry_run","bool",False,"Dry Run Mode\nIf Yes the bot won't buy or sell anything, just log what it would do.\nIf No, the bot will buy and sell for real.",None) + ConfigElement("spread", "int", 5, "the spread between sell and buy as percentage", (0, 100)), + ConfigElement("threshold", "int", 5, "percentage the feed has to move before we change orders", (0, 100)), + ConfigElement("buy", "float", 0.0, "the default amount to buy", (0.0, None)), + ConfigElement("sell", "float", 0.0, "the default amount to sell", (0.0, None)), + ConfigElement("blocks", "int", 20, "number of blocks to wait before re-calculating", (0, 10000)), + ConfigElement("dry_run", "bool", False, + "Dry Run Mode\nIf Yes the bot won't buy or sell anything, just log what it would do.\nIf No, the bot will buy and sell for real.", None) ] - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -39,7 +39,7 @@ def __init__(self, *args, **kwargs): self.counter = Counter() # Tests for actions - self.test_blocks = self.bot.get("test", {}).get("blocks", 0) + self.test_blocks = self.worker.get("test", {}).get("blocks", 0) def error(self, *args, **kwargs): self.disabled = True @@ -55,7 +55,7 @@ def updateorders(self): self.cancelall() # Target - target = self.bot.get("target", {}) + target = self.worker.get("target", {}) price = self.getprice() # prices @@ -95,7 +95,7 @@ def getprice(self): """ Here we obtain the price for the quote and make sure it has a feed price """ - target = self.bot.get("target", {}) + target = self.worker.get("target", {}) if target.get("reference") == "feed": assert self.market == self.market.core_quote_market(), "Wrong market for 'feed' reference!" ticker = self.market.ticker() @@ -130,7 +130,7 @@ def test(self, *args, **kwargs): # Test if price feed has moved more than the threshold if ( self["feed_price"] and - fabs(1 - float(self.getprice()) / self["feed_price"]) > self.bot["threshold"] / 100.0 + fabs(1 - float(self.getprice()) / self["feed_price"]) > self.worker["threshold"] / 100.0 ): self.log.info("Price feed moved by more than the threshold. Updating orders!") self.updateorders() diff --git a/dexbot/ui.py b/dexbot/ui.py index 3d5e0fa23..75dcbeff6 100644 --- a/dexbot/ui.py +++ b/dexbot/ui.py @@ -1,66 +1,78 @@ import os import sys -import click -import logging, logging.config +import logging +import logging.config from datetime import datetime -from bitshares.price import Price from prettytable import PrettyTable -from ruamel import yaml from functools import update_wrapper + +import click +from bitshares.price import Price +from ruamel import yaml from bitshares import BitShares from bitshares.instance import set_shared_bitshares_instance + log = logging.getLogger(__name__) + def verbose(f): @click.pass_context def new_func(ctx, *args, **kwargs): verbosity = [ "critical", "error", "warn", "info", "debug" ][int(min(ctx.obj.get("verbose", 0), 4))] - if ctx.obj.get("systemd",False): - # dont print the timestamps: systemd will log it for us + if ctx.obj.get("systemd", False): + # Don't print the timestamps: systemd will log it for us formatter1 = logging.Formatter('%(name)s - %(levelname)s - %(message)s') - formatter2 = logging.Formatter('bot %(botname)s using account %(account)s on %(market)s - %(levelname)s - %(message)s') + formatter2 = logging.Formatter( + '%(worker_name)s using account %(account)s on %(market)s - %(levelname)s - %(message)s') elif verbosity == "debug": - # when debugging log where the log call came from + # When debugging: log where the log call came from formatter1 = logging.Formatter('%(asctime)s (%(module)s:%(lineno)d) - %(levelname)s - %(message)s') - formatter2 = logging.Formatter('%(asctime)s (%(module)s:%(lineno)d) - bot %(botname)s - %(levelname)s - %(message)s') + formatter2 = logging.Formatter( + '%(asctime)s (%(module)s:%(lineno)d) - %(worker_name)s - %(levelname)s - %(message)s') else: formatter1 = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') - formatter2 = logging.Formatter('%(asctime)s - bot %(botname)s using account %(account)s on %(market)s - %(levelname)s - %(message)s') + formatter2 = logging.Formatter( + '%(asctime)s - %(worker_name)s using account %(account)s on %(market)s - %(levelname)s - %(message)s') - # use special format for special bots logger + # Use special format for special workers logger + logger = logging.getLogger("dexbot.per_worker") ch = logging.StreamHandler() ch.setLevel(getattr(logging, verbosity.upper())) ch.setFormatter(formatter2) - logging.getLogger("dexbot.per_bot").addHandler(ch) - logging.getLogger("dexbot.per_bot").propagate = False # don't double up with root logger - # set the root logger with basic format + logger.addHandler(ch) + + # Logging to a file + fh = logging.FileHandler('dexbot.log') + fh.setFormatter(formatter2) + logger.addHandler(fh) + + logger.propagate = False # Don't double up with root logger + # Set the root logger with basic format ch = logging.StreamHandler() ch.setLevel(getattr(logging, verbosity.upper())) ch.setFormatter(formatter1) logging.getLogger("dexbot").addHandler(ch) logging.getLogger("").handlers = [] - + # GrapheneAPI logging if ctx.obj["verbose"] > 4: verbosity = [ "critical", "error", "warn", "info", "debug" ][int(min(ctx.obj.get("verbose", 4) - 4, 4))] - log = logging.getLogger("grapheneapi") - log.setLevel(getattr(logging, verbosity.upper())) - log.addHandler(ch) + logger = logging.getLogger("grapheneapi") + logger.setLevel(getattr(logging, verbosity.upper())) + logger.addHandler(ch) + if ctx.obj["verbose"] > 8: verbosity = [ "critical", "error", "warn", "info", "debug" ][int(min(ctx.obj.get("verbose", 8) - 8, 4))] - log = logging.getLogger("graphenebase") - log.setLevel(getattr(logging, verbosity.upper())) - log.addHandler(ch) - # has the user set logging in the config - if "logging" in ctx.config: - # this is defined in https://docs.python.org/3.4/library/logging.config.html#logging-config-dictschema - logging.config.dictConfig(ctx.config['logging']) + logger = logging.getLogger("graphenebase") + logger.setLevel(getattr(logging, verbosity.upper())) + logger.addHandler(ch) + return ctx.invoke(f, *args, **kwargs) return update_wrapper(new_func, f) @@ -114,11 +126,13 @@ def new_func(ctx, *args, **kwargs): try: ctx.config = yaml.safe_load(open(ctx.obj["configfile"])) except FileNotFoundError: - alert("Looking for the config file in %s\nNot found!\nTry running 'dexbot configure' to generate\n" % ctx.obj['configfile']) - sys.exit(78) # 'configuation error' in sysexits.h + alert("Looking for the config file in %s\nNot found!\nTry running 'dexbot configure' to generate\n" % + ctx.obj['configfile']) + sys.exit(78) # 'configuation error' in sysexits.h return ctx.invoke(f, *args, **kwargs) return update_wrapper(new_func, f) + def priceChange(new, old): if float(old) == 0.0: return -1 @@ -161,9 +175,27 @@ def alert(msg): "] " + msg ) + def confirmalert(msg): return click.confirm( "[" + click.style("Alert", fg="red") + "] " + msg ) + +# error message "translation" +# here we convert some of the cryptic Graphene API error messages into a longer sentence +# particularly whe the problem is something the user themselves can fix (such as not enough +# money in account) +# it's here because both GUI and CLI might use it + + +TRANSLATIONS = {'amount_to_sell.amount > 0': "You need to have sufficient buy and sell amounts in your account", + 'now <= trx.expiration': "Your node has difficulty syncing to the blockchain, consider changing nodes"} + + +def translate_error(err): + for k, v in TRANSLATIONS.items(): + if k in err: + return v + return None diff --git a/dexbot/views/bot_item.py b/dexbot/views/bot_item.py deleted file mode 100644 index 7816f14ad..000000000 --- a/dexbot/views/bot_item.py +++ /dev/null @@ -1,103 +0,0 @@ -from .ui.bot_item_widget_ui import Ui_widget -from .confirmation import ConfirmationDialog -from .edit_bot import EditBotView -from dexbot.storage import worker -from dexbot.controllers.create_bot_controller import CreateBotController - -from PyQt5 import QtWidgets - - -class BotItemWidget(QtWidgets.QWidget, Ui_widget): - - def __init__(self, botname, config, main_ctrl, view): - super(BotItemWidget, self).__init__() - - self.main_ctrl = main_ctrl - self.running = False - self.botname = botname - self.config = config - self.controller = main_ctrl - self.view = view - - self.setupUi(self) - self.pause_button.hide() - - self.pause_button.clicked.connect(self.pause_bot) - self.play_button.clicked.connect(self.start_bot) - self.remove_button.clicked.connect(self.remove_widget_dialog) - self.edit_button.clicked.connect(self.handle_edit_bot) - - self.setup_ui_data(config) - - def setup_ui_data(self, config): - botname = list(config['bots'].keys())[0] - self.set_bot_name(botname) - - market = config['bots'][botname]['market'] - self.set_bot_market(market) - - profit = worker.execute(worker.get_item, botname, 'profit') - if profit: - self.set_bot_profit(profit) - - percentage = worker.execute(worker.get_item, botname, 'slider') - if percentage: - self.set_bot_slider(percentage) - - def start_bot(self): - self.running = True - self.pause_button.show() - self.play_button.hide() - - self.controller.create_bot(self.botname, self.config, self.view) - - def pause_bot(self): - self.running = False - self.pause_button.hide() - self.play_button.show() - - self.controller.stop_bot(self.botname) - - def set_bot_name(self, value): - self.botname_label.setText(value) - - def set_bot_account(self, value): - pass - - def set_bot_market(self, value): - self.currency_label.setText(value) - - def set_bot_profit(self, value): - if value >= 0: - value = '+' + str(value) - - value = str(value) + '%' - self.profit_label.setText(value) - - def set_bot_slider(self, value): - self.order_slider.setSliderPosition(value) - - def remove_widget_dialog(self): - dialog = ConfirmationDialog('Are you sure you want to remove bot "{}"?'.format(self.botname)) - return_value = dialog.exec_() - if return_value: - self.remove_widget() - - def remove_widget(self): - self.controller.remove_bot(self.botname) - self.deleteLater() - - # Todo: Remove the line below this after multi-bot support is added - self.view.ui.add_bot_button.setEnabled(True) - - def handle_edit_bot(self): - controller = CreateBotController(self.main_ctrl) - edit_bot_dialog = EditBotView(controller, self.botname, self.config) - return_value = edit_bot_dialog.exec_() - - # User clicked save - if return_value == 1: - bot_name = edit_bot_dialog.bot_name - config = self.main_ctrl.get_bot_config(bot_name) - self.remove_widget() - self.view.add_bot_widget(bot_name, config) diff --git a/dexbot/views/bot_list.py b/dexbot/views/bot_list.py deleted file mode 100644 index 1fb51c1ed..000000000 --- a/dexbot/views/bot_list.py +++ /dev/null @@ -1,78 +0,0 @@ -from .ui.bot_list_window_ui import Ui_MainWindow -from .create_bot import CreateBotView -from .bot_item import BotItemWidget -from dexbot.controllers.create_bot_controller import CreateBotController -from dexbot.queue.queue_dispatcher import ThreadDispatcher - -from PyQt5 import QtWidgets - - -class MainView(QtWidgets.QMainWindow): - - bot_widgets = dict() - - def __init__(self, main_ctrl): - self.main_ctrl = main_ctrl - super(MainView, self).__init__() - self.ui = Ui_MainWindow() - self.ui.setupUi(self) - self.bot_container = self.ui.verticalLayout - - self.ui.add_bot_button.clicked.connect(self.handle_add_bot) - - # Load bot widgets from config file - bots = main_ctrl.get_bots_data() - for botname in bots: - config = self.main_ctrl.get_bot_config(botname) - self.add_bot_widget(botname, config) - - # Artificially limit the number of bots to 1 until it's officially supported - # Todo: Remove the 2 lines below this after multi-bot support is added - self.ui.add_bot_button.setEnabled(False) - break - - # Dispatcher polls for events from the bots that are used to change the ui - self.dispatcher = ThreadDispatcher(self) - self.dispatcher.start() - - def add_bot_widget(self, botname, config): - widget = BotItemWidget(botname, config, self.main_ctrl, self) - widget.setFixedSize(widget.frameSize()) - self.bot_container.addWidget(widget) - self.bot_widgets[botname] = widget - - # Todo: Remove the line below this after multi-bot support is added - self.ui.add_bot_button.setEnabled(False) - - def handle_add_bot(self): - controller = CreateBotController(self.main_ctrl) - create_bot_dialog = CreateBotView(controller) - return_value = create_bot_dialog.exec_() - - # User clicked save - if return_value == 1: - botname = create_bot_dialog.bot_name - config = self.main_ctrl.get_bot_config(botname) - self.add_bot_widget(botname, config) - - def refresh_bot_list(self): - pass - - def set_bot_name(self, bot_name, value): - self.bot_widgets[bot_name].set_bot_name(value) - - def set_bot_account(self, bot_name, value): - self.bot_widgets[bot_name].set_bot_account(value) - - def set_bot_profit(self, bot_name, value): - self.bot_widgets[bot_name].set_bot_profit(value) - - def set_bot_market(self, bot_name, value): - self.bot_widgets[bot_name].set_bot_market(value) - - def set_bot_slider(self, bot_name, value): - self.bot_widgets[bot_name].set_bot_slider(value) - - def customEvent(self, event): - # Process idle_queue_dispatcher events - event.callback() diff --git a/dexbot/views/create_bot.py b/dexbot/views/create_bot.py deleted file mode 100644 index 2c88541f4..000000000 --- a/dexbot/views/create_bot.py +++ /dev/null @@ -1,101 +0,0 @@ -from .notice import NoticeDialog -from .ui.create_bot_window_ui import Ui_Dialog - -from PyQt5 import QtWidgets - - -class CreateBotView(QtWidgets.QDialog): - - def __init__(self, controller): - super().__init__() - self.controller = controller - - self.ui = Ui_Dialog() - self.ui.setupUi(self) - - # Todo: Using a model here would be more Qt like - self.ui.strategy_input.addItems(self.controller.strategies) - self.ui.base_asset_input.addItems(self.controller.base_assets) - - self.bot_name = controller.get_unique_bot_name() - self.ui.bot_name_input.setText(self.bot_name) - - self.ui.save_button.clicked.connect(self.handle_save) - self.ui.cancel_button.clicked.connect(self.reject) - - def validate_bot_name(self): - bot_name = self.ui.bot_name_input.text() - return self.controller.is_bot_name_valid(bot_name) - - def validate_asset(self, asset): - return self.controller.is_asset_valid(asset) - - def validate_market(self): - base_asset = self.ui.base_asset_input.currentText() - quote_asset = self.ui.quote_asset_input.text() - return base_asset.lower() != quote_asset.lower() - - def validate_account_name(self): - account = self.ui.account_input.text() - return self.controller.account_exists(account) - - def validate_account(self): - account = self.ui.account_input.text() - private_key = self.ui.private_key_input.text() - return self.controller.is_account_valid(account, private_key) - - def validate_form(self): - error_text = '' - base_asset = self.ui.base_asset_input.currentText() - quote_asset = self.ui.quote_asset_input.text() - if not self.validate_bot_name(): - bot_name = self.ui.bot_name_input.text() - error_text += 'Bot name needs to be unique. "{}" is already in use.'.format(bot_name) + '\n' - if not self.validate_asset(base_asset): - error_text += 'Field "Base Asset" does not have a valid asset.' + '\n' - if not self.validate_asset(quote_asset): - error_text += 'Field "Quote Asset" does not have a valid asset.' + '\n' - if not self.validate_market(): - error_text += "Market {}/{} doesn't exist.".format(base_asset, quote_asset) + '\n' - if not self.validate_account_name(): - error_text += "Account doesn't exist." + '\n' - if not self.validate_account(): - error_text += 'Private key is invalid.' + '\n' - - if error_text: - dialog = NoticeDialog(error_text) - dialog.exec_() - return False - else: - return True - - def handle_save(self): - if not self.validate_form(): - return - - # Add the private key to the database - private_key = self.ui.private_key_input.text() - self.controller.add_private_key(private_key) - - ui = self.ui - spread = float(ui.spread_input.text()[:-1]) # Remove the percentage character from the end - target = { - 'amount': float(ui.amount_input.text()), - 'center_price': float(ui.center_price_input.text()), - 'spread': spread - } - - base_asset = ui.base_asset_input.currentText() - quote_asset = ui.quote_asset_input.text() - strategy = ui.strategy_input.currentText() - bot_module = self.controller.get_strategy_module(strategy) - bot_data = { - 'account': ui.account_input.text(), - 'market': '{}/{}'.format(quote_asset, base_asset), - 'module': bot_module, - 'strategy': strategy, - 'target': target - } - self.bot_name = ui.bot_name_input.text() - self.controller.add_bot_config(self.bot_name, bot_data) - self.accept() diff --git a/dexbot/views/create_wallet.py b/dexbot/views/create_wallet.py index e8ef06025..7ae192837 100644 --- a/dexbot/views/create_wallet.py +++ b/dexbot/views/create_wallet.py @@ -1,21 +1,22 @@ from .ui.create_wallet_window_ui import Ui_Dialog from .notice import NoticeDialog +from .errors import gui_error from PyQt5 import QtWidgets -class CreateWalletView(QtWidgets.QDialog): +class CreateWalletView(QtWidgets.QDialog, Ui_Dialog): def __init__(self, controller): self.controller = controller super().__init__() - self.ui = Ui_Dialog() - self.ui.setupUi(self) - self.ui.ok_button.clicked.connect(self.validate_form) + self.setupUi(self) + self.ok_button.clicked.connect(lambda: self.validate_form()) + @gui_error def validate_form(self): - password = self.ui.password_input.text() - confirm_password = self.ui.confirm_password_input.text() + password = self.password_input.text() + confirm_password = self.confirm_password_input.text() if not self.controller.create_wallet(password, confirm_password): dialog = NoticeDialog('Passwords do not match!') dialog.exec_() diff --git a/dexbot/views/create_worker.py b/dexbot/views/create_worker.py new file mode 100644 index 000000000..fa5f6d40c --- /dev/null +++ b/dexbot/views/create_worker.py @@ -0,0 +1,34 @@ +from .ui.create_worker_window_ui import Ui_Dialog +from dexbot.controllers.create_worker_controller import CreateWorkerController + +from PyQt5 import QtWidgets + + +class CreateWorkerView(QtWidgets.QDialog, Ui_Dialog): + + def __init__(self, bitshares_instance): + super().__init__() + self.strategy_widget = None + controller = CreateWorkerController(self, bitshares_instance, 'add') + self.controller = controller + + self.setupUi(self) + + # Todo: Using a model here would be more Qt like + # Populate the comboboxes + strategies = self.controller.strategies + for strategy in strategies: + self.strategy_input.addItem(strategies[strategy]['name'], strategy) + self.base_asset_input.addItems(self.controller.base_assets) + + # Generate a name for the worker + self.worker_name = controller.get_unique_worker_name() + self.worker_name_input.setText(self.worker_name) + + # Set signals + self.strategy_input.currentTextChanged.connect(lambda: controller.change_strategy_form()) + self.save_button.clicked.connect(lambda: controller.handle_save()) + self.cancel_button.clicked.connect(lambda: self.reject()) + + self.controller.change_strategy_form() + self.worker_data = {} diff --git a/dexbot/views/edit_bot.py b/dexbot/views/edit_bot.py deleted file mode 100644 index 3246c30d7..000000000 --- a/dexbot/views/edit_bot.py +++ /dev/null @@ -1,97 +0,0 @@ -from .ui.edit_bot_window_ui import Ui_Dialog -from .confirmation import ConfirmationDialog -from .notice import NoticeDialog - -from PyQt5 import QtWidgets - - -class EditBotView(QtWidgets.QDialog, Ui_Dialog): - def __init__(self, controller, botname, config): - super().__init__() - self.controller = controller - - self.setupUi(self) - bot_data = config['bots'][botname] - self.strategy_input.addItems(self.controller.get_bot_current_strategy(bot_data)) - self.bot_name = botname - self.bot_name_input.setText(botname) - self.base_asset_input.addItem(self.controller.get_base_asset(bot_data)) - self.base_asset_input.addItems(self.controller.base_assets) - self.quote_asset_input.setText(self.controller.get_quote_asset(bot_data)) - self.account_name.setText(self.controller.get_account(bot_data)) - self.amount_input.setValue(self.controller.get_target_amount(bot_data)) - self.center_price_input.setValue(self.controller.get_target_center_price(bot_data)) - self.spread_input.setValue(self.controller.get_target_spread(bot_data)) - - self.save_button.clicked.connect(self.handle_save) - self.cancel_button.clicked.connect(self.reject) - - def validate_bot_name(self): - old_bot_name = self.bot_name - bot_name = self.bot_name_input.text() - return self.controller.is_bot_name_valid(bot_name, old_bot_name) - - def validate_asset(self, asset): - return self.controller.is_asset_valid(asset) - - def validate_market(self): - base_asset = self.base_asset_input.currentText() - quote_asset = self.quote_asset_input.text() - return base_asset.lower() != quote_asset.lower() - - def validate_form(self): - error_text = '' - base_asset = self.base_asset_input.currentText() - quote_asset = self.quote_asset_input.text() - - if not self.validate_bot_name(): - bot_name = self.bot_name_input.text() - error_text += 'Bot name needs to be unique. "{}" is already in use.\n'.format(bot_name) - if not self.validate_asset(base_asset): - error_text += 'Field "Base Asset" does not have a valid asset.\n' - if not self.validate_asset(quote_asset): - error_text += 'Field "Quote Asset" does not have a valid asset.\n' - if not self.validate_market(): - error_text += "Market {}/{} doesn't exist.\n".format(base_asset, quote_asset) - - if error_text: - dialog = NoticeDialog(error_text) - dialog.exec_() - return False - else: - return True - - @staticmethod - def handle_save_dialog(): - dialog = ConfirmationDialog('Saving the bot will cancel all the current orders.\n' - 'Are you sure you want to save the bot?') - return dialog.exec_() - - def handle_save(self): - if not self.validate_form(): - return - - if not self.handle_save_dialog(): - return - - spread = float(self.spread_input.text()[:-1]) # Remove the percentage character from the end - target = { - 'amount': float(self.amount_input.text()), - 'center_price': float(self.center_price_input.text()), - 'spread': spread - } - - base_asset = self.base_asset_input.currentText() - quote_asset = self.quote_asset_input.text() - strategy = self.strategy_input.currentText() - bot_module = self.controller.get_strategy_module(strategy) - bot_data = { - 'account': self.account_name.text(), - 'market': '{}/{}'.format(quote_asset, base_asset), - 'module': bot_module, - 'strategy': strategy, - 'target': target - } - self.bot_name = self.bot_name_input.text() - self.controller.add_bot_config(self.bot_name, bot_data) - self.accept() diff --git a/dexbot/views/edit_worker.py b/dexbot/views/edit_worker.py new file mode 100644 index 000000000..0df431576 --- /dev/null +++ b/dexbot/views/edit_worker.py @@ -0,0 +1,40 @@ +from .ui.edit_worker_window_ui import Ui_Dialog +from dexbot.controllers.create_worker_controller import CreateWorkerController + +from PyQt5 import QtWidgets + + +class EditWorkerView(QtWidgets.QDialog, Ui_Dialog): + + def __init__(self, bitshares_instance, worker_name, config): + super().__init__() + self.worker_name = worker_name + self.strategy_widget = None + controller = CreateWorkerController(self, bitshares_instance, 'edit') + self.controller = controller + + self.setupUi(self) + worker_data = config['workers'][worker_name] + + # Todo: Using a model here would be more Qt like + # Populate the comboboxes + strategies = self.controller.strategies + for strategy in strategies: + self.strategy_input.addItem(strategies[strategy]['name'], strategy) + + # Set values from config + index = self.strategy_input.findData(self.controller.get_strategy_module(worker_data)) + self.strategy_input.setCurrentIndex(index) + self.worker_name_input.setText(worker_name) + self.base_asset_input.addItem(self.controller.get_base_asset(worker_data)) + self.base_asset_input.addItems(self.controller.base_assets) + self.quote_asset_input.setText(self.controller.get_quote_asset(worker_data)) + self.account_name.setText(self.controller.get_account(worker_data)) + + # Set signals + self.strategy_input.currentTextChanged.connect(lambda: controller.change_strategy_form()) + self.save_button.clicked.connect(lambda: self.controller.handle_save()) + self.cancel_button.clicked.connect(lambda: self.reject()) + + self.controller.change_strategy_form(worker_data) + self.worker_data = {} diff --git a/dexbot/views/errors.py b/dexbot/views/errors.py new file mode 100644 index 000000000..4d497dfa9 --- /dev/null +++ b/dexbot/views/errors.py @@ -0,0 +1,68 @@ +import logging +import traceback + +from dexbot.ui import translate_error +from dexbot.queue.idle_queue import idle_add + +from PyQt5 import QtWidgets + + +class PyQtHandler(logging.Handler): + """ + Logging handler for Py Qt events. + Based on Vinay Sajip's DBHandler class (http://www.red-dove.com/python_logging.html) + """ + + def __init__(self): + logging.Handler.__init__(self) + self.info_handler = None + + def emit(self, record): + # Use default formatting: + self.format(record) + message = record.msg + if record.levelno > logging.WARNING: + extra = translate_error(message) + if record.exc_info: + if not extra: + extra = translate_error(repr(record.exc_info[1])) + detail = logging._defaultFormatter.formatException(record.exc_info) + else: + detail = None + if hasattr(record, "worker_name"): + title = "Error on {}".format(record.worker_name) + else: + title = "DEXBot Error" + idle_add(show_dialog, title, message, extra, detail) + else: + if self.info_handler and hasattr(record, "worker_name"): + idle_add(self.info_handler, record.worker_name, record.levelno, message) + + def set_info_handler(self, info_handler): + self.info_handler = info_handler + + +def gui_error(func): + """ A decorator for GUI handler functions - traps all exceptions and displays the dialog + """ + def func_wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except BaseException as exc: + show_dialog("DEXBot Error", "An error occurred with DEXBot: \n"+repr(exc), None, traceback.format_exc()) + + return func_wrapper + + +def show_dialog(title, message, extra=None, detail=None): + msg = QtWidgets.QMessageBox() + msg.setIcon(QtWidgets.QMessageBox.Critical) + msg.setText(message) + if extra: + msg.setInformativeText(extra) + msg.setWindowTitle(title) + if detail: + msg.setDetailedText(detail) + msg.setStandardButtons(QtWidgets.QMessageBox.Ok) + + msg.exec_() diff --git a/dexbot/views/notice.py b/dexbot/views/notice.py index b04fa1d02..8e48a7a39 100644 --- a/dexbot/views/notice.py +++ b/dexbot/views/notice.py @@ -11,4 +11,4 @@ def __init__(self, text): self.ui.setupUi(self) self.ui.notice_label.setText(text) - self.ui.ok_button.clicked.connect(self.accept) + self.ui.ok_button.clicked.connect(lambda: self.accept()) diff --git a/dexbot/views/strategy_form.py b/dexbot/views/strategy_form.py new file mode 100644 index 000000000..e1cd7adbc --- /dev/null +++ b/dexbot/views/strategy_form.py @@ -0,0 +1,40 @@ +import importlib + +import dexbot.controllers.strategy_controller + +from PyQt5 import QtWidgets + + +class StrategyFormWidget(QtWidgets.QWidget): + + def __init__(self, controller, strategy_module, config=None): + super().__init__() + self.controller = controller + self.module_name = strategy_module.split('.')[-1] + + form_module = controller.strategies[strategy_module]['form_module'] + widget = getattr( + importlib.import_module(form_module), + 'Ui_Form' + ) + self.strategy_widget = widget() + self.strategy_widget.setupUi(self) + + # Invoke the correct controller + class_name = '' + if self.module_name == 'relative_orders': + class_name = 'RelativeOrdersController' + elif self.module_name == 'staggered_orders': + class_name = 'StaggeredOrdersController' + + strategy_controller = getattr( + dexbot.controllers.strategy_controller, + class_name + ) + self.strategy_controller = strategy_controller(self, controller, config) + + @property + def values(self): + """ Returns values all the form values based on selected strategy + """ + return self.strategy_controller.values diff --git a/dexbot/views/ui/create_bot_window.ui b/dexbot/views/ui/create_worker_window.ui similarity index 61% rename from dexbot/views/ui/create_bot_window.ui rename to dexbot/views/ui/create_worker_window.ui index 1d2bc26bc..9e6e9eb1a 100644 --- a/dexbot/views/ui/create_bot_window.ui +++ b/dexbot/views/ui/create_worker_window.ui @@ -6,12 +6,15 @@ 0 0 - 418 - 474 + 428 + 345 - DEXBot - Create Bot + DEXBot - Create Worker + + + true @@ -180,190 +183,10 @@ - - - - Bot Parameters - - - - - - - 0 - 0 - - - - - 110 - 0 - - - - - 110 - 16777215 - - - - Amount - - - amount_input - - - - - - - - 0 - 0 - - - - - 140 - 0 - - - - - - - 8 - - - 999999999.998999953269958 - - - - - - - - 0 - 0 - - - - - 110 - 0 - - - - - 110 - 16777215 - - - - Center Price - - - center_price_input - - - - - - - - 0 - 0 - - - - - 140 - 0 - - - - - - - false - - - false - - - 8 - - - -999999999.998999953269958 - - - 999999999.998999953269958 - - - - - - - - 0 - 0 - - - - - 110 - 0 - - - - - 110 - 16777215 - - - - Spread - - - spread_input - - - - - - - - 0 - 0 - - - - - 151 - 0 - - - - - - - % - - - 100000.000000000000000 - - - 5.000000000000000 - - - - - - - Bot Details + Worker Details @@ -396,7 +219,7 @@ - + 110 @@ -410,15 +233,15 @@ - Bot Name + Worker Name - bot_name_input + worker_name_input - + @@ -499,18 +322,33 @@ + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + strategy_input - bot_name_input + worker_name_input base_asset_input quote_asset_input account_input private_key_input - amount_input - center_price_input - spread_input save_button cancel_button diff --git a/dexbot/views/ui/edit_worker_window.ui b/dexbot/views/ui/edit_worker_window.ui new file mode 100644 index 000000000..bbdf9c44c --- /dev/null +++ b/dexbot/views/ui/edit_worker_window.ui @@ -0,0 +1,292 @@ + + + Dialog + + + + 0 + 0 + 428 + 302 + + + + DEXBot - Edit Worker + + + + + + Worker Details + + + + + + + 110 + 0 + + + + + 110 + 16777215 + + + + Strategy + + + strategy_input + + + + + + + -1 + + + + + + + + 110 + 0 + + + + + 110 + 16777215 + + + + Worker Name + + + worker_name_input + + + + + + + + + + + 110 + 0 + + + + + 110 + 16777215 + + + + Base Asset + + + + + + + + 110 + 0 + + + + + 110 + 16777215 + + + + Quote Asset + + + quote_asset_input + + + + + + + + 0 + 0 + + + + + 80 + 16777215 + + + + + + + + + 0 + 0 + + + + + 105 + 0 + + + + true + + + + + + + + + + Bitshares Account Details + + + + QFormLayout::WrapLongRows + + + + + + 0 + 0 + + + + + 110 + 0 + + + + + 110 + 16777215 + + + + Account + + + + + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + Qt::Vertical + + + + 20 + 1 + + + + + + + + + 0 + 0 + + + + + + + Qt::Horizontal + + + + 179 + 20 + + + + + + + + + 0 + 0 + + + + PointingHandCursor + + + Cancel + + + + + + + + 0 + 0 + + + + PointingHandCursor + + + Save + + + + + + + + + + + diff --git a/dexbot/views/ui/forms/__init__.py b/dexbot/views/ui/forms/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/dexbot/views/ui/forms/relative_orders_widget.ui b/dexbot/views/ui/forms/relative_orders_widget.ui new file mode 100644 index 000000000..e43a8db81 --- /dev/null +++ b/dexbot/views/ui/forms/relative_orders_widget.ui @@ -0,0 +1,272 @@ + + + Form + + + + 0 + 0 + 446 + 225 + + + + Form + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Worker Parameters + + + + 6 + + + 9 + + + 9 + + + 9 + + + 9 + + + + + + 0 + 0 + + + + + 110 + 0 + + + + + 110 + 16777215 + + + + Amount + + + amount_input + + + + + + + + 0 + 0 + + + + + 140 + 0 + + + + + + + 8 + + + 1000000000.000000000000000 + + + + + + + + 0 + 0 + + + + + 110 + 0 + + + + + 110 + 16777215 + + + + Center Price + + + center_price_input + + + + + + + false + + + + 0 + 0 + + + + + 140 + 0 + + + + + + + false + + + false + + + 8 + + + -999999999.998999953269958 + + + 999999999.998999953269958 + + + + + + + + 0 + 0 + + + + + 110 + 0 + + + + + 110 + 16777215 + + + + Spread + + + spread_input + + + + + + + + 0 + 0 + + + + + 151 + 0 + + + + + + + % + + + 100000.000000000000000 + + + 5.000000000000000 + + + + + + + Calculate center price dynamically + + + true + + + + + + + Relative order size + + + + + + + Center price offset based on asset balances + + + + + + + + + + + + center_price_dynamic_checkbox + clicked(bool) + center_price_input + setDisabled(bool) + + + 284 + 129 + + + 208 + 99 + + + + + diff --git a/dexbot/views/ui/edit_bot_window.ui b/dexbot/views/ui/forms/staggered_orders_widget.ui similarity index 62% rename from dexbot/views/ui/edit_bot_window.ui rename to dexbot/views/ui/forms/staggered_orders_widget.ui index b986bf84a..33ad529da 100644 --- a/dexbot/views/ui/edit_bot_window.ui +++ b/dexbot/views/ui/forms/staggered_orders_widget.ui @@ -1,27 +1,45 @@ - Dialog - + Form + 0 0 - 400 - 430 + 382 + 283 - DEXBot - Edit Bot + Form + + 0 + + + 0 + + + 0 + + + 0 + - + - Bot Details + Worker Parameters - - - + + + + + + 0 + 0 + + 110 @@ -35,66 +53,49 @@ - Strategy + Spread - strategy_input + spread_input - - - - -1 + + + + + 0 + 0 + - - - - - 110 + 151 0 - - - 110 - 16777215 - + + - - Bot Name + + % - - bot_name_input + + 100000.000000000000000 + + + 6.000000000000000 - - - - - - - 110 - 0 - - - - - 110 - 16777215 - - - - Base Asset + + + + 0 + 0 + - - - - 110 @@ -108,31 +109,15 @@ - Quote Asset + Increment - quote_asset_input - - - - - - - - 0 - 0 - - - - - 80 - 16777215 - + spread_input - + 0 @@ -141,70 +126,26 @@ - 105 + 151 0 - - true - - - - - - - - - - Bitshares Account Details - - - - QFormLayout::WrapLongRows - - - - - - 0 - 0 - - - - - 110 - 0 - + + - - - 110 - 16777215 - + + % - - Account + + 100000.000000000000000 - - - - - - + + 4.000000000000000 - - - - - - - Bot Parameters - - - - + + 0 @@ -224,15 +165,15 @@ - Amount + Lower bound - amount_input + spread_input - - + + 0 @@ -252,12 +193,15 @@ 8 - 999999999.998999953269958 + 1000000000.000000000000000 + + + 0.000001000000000 - - + + 0 @@ -277,15 +221,15 @@ - Center Price + Upper bound - center_price_input + spread_input - - + + 0 @@ -301,76 +245,73 @@ - - false - - - false - 8 - - -999999999.998999953269958 - - 999999999.998999953269958 + 1000000000.000000000000000 + + + 1000000.000000000000000 - - + + - + 0 0 - 110 + 151 0 - - - 110 - 16777215 - + + - - Spread + + - - spread_input + + 8 + + + 100000.000000000000000 + + + 0.000000000000000 - - + + - + 0 0 - 151 + 110 0 - - - - - % + + + 110 + 16777215 + - - 100000.000000000000000 + + Amount - - 5.000000000000000 + + spread_input @@ -378,69 +319,54 @@ - - - Qt::Vertical - - - - 20 - 1 - - - - - - - - - 0 - 0 - + + + Worker info - - - - - Qt::Horizontal + + + 3 + + + + + + 110 + 0 + - + - 179 - 20 + 110 + 16777215 - - - - - - - 0 - 0 - + + Required quote - - PointingHandCursor + + true + + + + - Cancel + N/A - - - - - 0 - 0 - - - - PointingHandCursor + + + + Required base + + + + - Save + N/A diff --git a/dexbot/views/ui/bot_item_widget.ui b/dexbot/views/ui/worker_item_widget.ui similarity index 97% rename from dexbot/views/ui/bot_item_widget.ui rename to dexbot/views/ui/worker_item_widget.ui index 4afc52378..d59b61fd5 100644 --- a/dexbot/views/ui/bot_item_widget.ui +++ b/dexbot/views/ui/worker_item_widget.ui @@ -10,7 +10,7 @@ 0 0 480 - 138 + 179 @@ -92,7 +92,7 @@ 1 - + 12 @@ -104,7 +104,7 @@ color: #005B78; - Botname + Worker name Qt::AlignBottom|Qt::AlignLeading|Qt::AlignLeft @@ -136,7 +136,7 @@ color: #005B78; - SIMPLE STRATEGY + RELATIVE ORDERS Qt::AlignBottom|Qt::AlignRight|Qt::AlignTrailing @@ -332,7 +332,7 @@ color: #005B78; - Buy + Base @@ -361,7 +361,7 @@ color: #005B78; - Sell + Quote @@ -548,6 +548,13 @@ border-right: 2px solid #005B78; + + + + + + + diff --git a/dexbot/views/ui/bot_list_window.ui b/dexbot/views/ui/worker_list_window.ui similarity index 93% rename from dexbot/views/ui/bot_list_window.ui rename to dexbot/views/ui/worker_list_window.ui index 142133653..8d90a9691 100644 --- a/dexbot/views/ui/bot_list_window.ui +++ b/dexbot/views/ui/worker_list_window.ui @@ -27,52 +27,11 @@ - - - - - 1 - 1 - - - - false - - - QFrame::NoFrame - - - Qt::ScrollBarAsNeeded - - - QAbstractScrollArea::AdjustToContents - - - true - - - Qt::AlignHCenter|Qt::AlignTop + + + + Qt::Horizontal - - - - 389 - 0 - 18 - 18 - - - - - 0 - 0 - - - - -7 - - - @@ -107,7 +66,7 @@ - + PointingHandCursor @@ -115,7 +74,7 @@ -1 - Add bot + Add worker @@ -137,18 +96,60 @@ - - - - Qt::Horizontal + + + + + 1 + 1 + + + + false + + + QFrame::NoFrame + + + Qt::ScrollBarAsNeeded + + + QAbstractScrollArea::AdjustToContents + + + true + + Qt::AlignHCenter|Qt::AlignTop + + + + + 389 + 0 + 18 + 18 + + + + + 0 + 0 + + + + -7 + + + - - scrollArea - widget - line + + + + ArrowCursor + diff --git a/dexbot/views/unlock_wallet.py b/dexbot/views/unlock_wallet.py index f67efecc4..4eb892691 100644 --- a/dexbot/views/unlock_wallet.py +++ b/dexbot/views/unlock_wallet.py @@ -1,23 +1,24 @@ from .ui.unlock_wallet_window_ui import Ui_Dialog from .notice import NoticeDialog +from .errors import gui_error from PyQt5 import QtWidgets -class UnlockWalletView(QtWidgets.QDialog): +class UnlockWalletView(QtWidgets.QDialog, Ui_Dialog): def __init__(self, controller): self.controller = controller super().__init__() - self.ui = Ui_Dialog() - self.ui.setupUi(self) - self.ui.ok_button.clicked.connect(self.validate_form) + self.setupUi(self) + self.ok_button.clicked.connect(lambda: self.validate_form()) + @gui_error def validate_form(self): - password = self.ui.password_input.text() + password = self.password_input.text() if not self.controller.unlock_wallet(password): dialog = NoticeDialog('Invalid password!') dialog.exec_() - self.ui.password_input.setText('') + self.password_input.setText('') else: self.accept() diff --git a/dexbot/views/worker_item.py b/dexbot/views/worker_item.py new file mode 100644 index 000000000..8544f7455 --- /dev/null +++ b/dexbot/views/worker_item.py @@ -0,0 +1,135 @@ +from .ui.worker_item_widget_ui import Ui_widget +from .confirmation import ConfirmationDialog +from .edit_worker import EditWorkerView +from dexbot.storage import db_worker +from dexbot.controllers.create_worker_controller import CreateWorkerController + +from dexbot.views.errors import gui_error + +from PyQt5 import QtWidgets + + +class WorkerItemWidget(QtWidgets.QWidget, Ui_widget): + + def __init__(self, worker_name, config, main_ctrl, view): + super().__init__() + + self.main_ctrl = main_ctrl + self.running = False + self.worker_name = worker_name + self.worker_config = config + self.view = view + + self.setupUi(self) + self.pause_button.hide() + + self.pause_button.clicked.connect(lambda: self.pause_worker()) + self.play_button.clicked.connect(lambda: self.start_worker()) + self.remove_button.clicked.connect(lambda: self.remove_widget_dialog()) + self.edit_button.clicked.connect(lambda: self.handle_edit_worker()) + + self.setup_ui_data(config) + + def setup_ui_data(self, config): + worker_name = list(config['workers'].keys())[0] + self.set_worker_name(worker_name) + + market = config['workers'][worker_name]['market'] + self.set_worker_market(market) + + module = config['workers'][worker_name]['module'] + strategies = CreateWorkerController.get_strategies() + self.set_worker_strategy(strategies[module]['name']) + + profit = db_worker.get_item(worker_name, 'profit') + if profit: + self.set_worker_profit(profit) + else: + self.set_worker_profit(0) + + percentage = db_worker.get_item(worker_name, 'slider') + if percentage: + self.set_worker_slider(percentage) + else: + self.set_worker_slider(50) + + @gui_error + def start_worker(self): + self.set_status("Starting worker") + self._start_worker() + self.main_ctrl.create_worker(self.worker_name, self.worker_config, self.view) + + def _start_worker(self): + self.running = True + self.pause_button.show() + self.play_button.hide() + + @gui_error + def pause_worker(self): + self.set_status("Pausing worker") + self._pause_worker() + self.main_ctrl.stop_worker(self.worker_name) + + def _pause_worker(self): + self.running = False + self.pause_button.hide() + self.play_button.show() + + def set_worker_name(self, value): + self.worker_name_label.setText(value) + + def set_worker_strategy(self, value): + value = value.upper() + self.strategy_label.setText(value) + + def set_worker_market(self, value): + self.currency_label.setText(value) + + def set_worker_profit(self, value): + value = float(value) + if value >= 0: + value = '+' + str(value) + + value = str(value) + '%' + self.profit_label.setText(value) + + def set_worker_slider(self, value): + self.order_slider.setSliderPosition(value) + + @gui_error + def remove_widget_dialog(self): + dialog = ConfirmationDialog('Are you sure you want to remove worker "{}"?'.format(self.worker_name)) + return_value = dialog.exec_() + if return_value: + self.remove_widget() + self.main_ctrl.remove_worker_config(self.worker_name) + + def remove_widget(self): + self.main_ctrl.remove_worker(self.worker_name) + self.deleteLater() + self.view.remove_worker_widget(self.worker_name) + self.view.ui.add_worker_button.setEnabled(True) + + def reload_widget(self, worker_name): + """ Reload the data of the widget + """ + self.worker_config = self.main_ctrl.get_worker_config(worker_name) + self.setup_ui_data(self.worker_config) + self._pause_worker() + + @gui_error + def handle_edit_worker(self): + edit_worker_dialog = EditWorkerView(self.main_ctrl.bitshares_instance, + self.worker_name, self.worker_config) + return_value = edit_worker_dialog.exec_() + + # User clicked save + if return_value: + new_worker_name = edit_worker_dialog.worker_name + self.main_ctrl.remove_worker(self.worker_name) + self.main_ctrl.replace_worker_config(self.worker_name, new_worker_name, edit_worker_dialog.worker_data) + self.reload_widget(new_worker_name) + self.worker_name = new_worker_name + + def set_status(self, status): + self.worker_status.setText(status) diff --git a/dexbot/views/worker_list.py b/dexbot/views/worker_list.py new file mode 100644 index 000000000..63a04e173 --- /dev/null +++ b/dexbot/views/worker_list.py @@ -0,0 +1,141 @@ +import time +from threading import Thread + +from dexbot import __version__ +from .ui.worker_list_window_ui import Ui_MainWindow +from .create_worker import CreateWorkerView +from .worker_item import WorkerItemWidget +from dexbot.queue.queue_dispatcher import ThreadDispatcher +from dexbot.queue.idle_queue import idle_add +from .errors import gui_error + +from PyQt5 import QtWidgets +from bitsharesapi.bitsharesnoderpc import BitSharesNodeRPC + + +class MainView(QtWidgets.QMainWindow): + + def __init__(self, main_ctrl): + self.main_ctrl = main_ctrl + super(MainView, self).__init__() + self.ui = Ui_MainWindow() + self.ui.setupUi(self) + self.worker_container = self.ui.verticalLayout + self.max_workers = 10 + self.num_of_workers = 0 + self.worker_widgets = {} + self.closing = False + self.statusbar_updater = None + self.statusbar_updater_first_run = True + self.main_ctrl.set_info_handler(self.set_worker_status) + + self.ui.add_worker_button.clicked.connect(lambda: self.handle_add_worker()) + + # Load worker widgets from config file + workers = main_ctrl.get_workers_data() + for worker_name in workers: + self.add_worker_widget(worker_name) + + if self.num_of_workers >= self.max_workers: + self.ui.add_worker_button.setEnabled(False) + break + + # Dispatcher polls for events from the workers that are used to change the ui + self.dispatcher = ThreadDispatcher(self) + self.dispatcher.start() + + self.ui.status_bar.showMessage("ver {} - Node delay: - ms".format(__version__)) + self.statusbar_updater = Thread( + target=self._update_statusbar_message + ) + self.statusbar_updater.start() + + def add_worker_widget(self, worker_name): + config = self.main_ctrl.get_worker_config(worker_name) + widget = WorkerItemWidget(worker_name, config, self.main_ctrl, self) + widget.setFixedSize(widget.frameSize()) + self.worker_container.addWidget(widget) + self.worker_widgets[worker_name] = widget + + # Limit the max amount of workers so that the performance isn't greatly affected + self.num_of_workers += 1 + if self.num_of_workers >= self.max_workers: + self.ui.add_worker_button.setEnabled(False) + + def remove_worker_widget(self, worker_name): + self.worker_widgets.pop(worker_name, None) + + self.num_of_workers -= 1 + if self.num_of_workers < self.max_workers: + self.ui.add_worker_button.setEnabled(True) + + @gui_error + def handle_add_worker(self): + create_worker_dialog = CreateWorkerView(self.main_ctrl.bitshares_instance) + return_value = create_worker_dialog.exec_() + + # User clicked save + if return_value == 1: + worker_name = create_worker_dialog.worker_name + self.main_ctrl.add_worker_config(worker_name, create_worker_dialog.worker_data) + self.add_worker_widget(worker_name) + + def set_worker_name(self, worker_name, value): + self.worker_widgets[worker_name].set_worker_name(value) + + def set_worker_account(self, worker_name, value): + self.worker_widgets[worker_name].set_worker_account(value) + + def set_worker_profit(self, worker_name, value): + self.worker_widgets[worker_name].set_worker_profit(value) + + def set_worker_market(self, worker_name, value): + self.worker_widgets[worker_name].set_worker_market(value) + + def set_worker_slider(self, worker_name, value): + self.worker_widgets[worker_name].set_worker_slider(value) + + def customEvent(self, event): + # Process idle_queue_dispatcher events + event.callback() + + def closeEvent(self, event): + self.closing = True + self.ui.status_bar.showMessage("Closing app...") + if self.statusbar_updater and self.statusbar_updater.is_alive(): + self.statusbar_updater.join() + + def _update_statusbar_message(self): + while not self.closing: + # When running first time the workers are also interrupting with the connection + # so we delay the first time to get correct information + if self.statusbar_updater_first_run: + self.statusbar_updater_first_run = False + time.sleep(1) + + idle_add(self.set_statusbar_message) + runner_count = 0 + # Wait for 30s but do it in 0.5s pieces to not prevent closing the app + while not self.closing and runner_count < 60: + runner_count += 1 + time.sleep(0.5) + + def set_statusbar_message(self): + config = self.main_ctrl.load_config() + node = config['node'] + + try: + start = time.time() + BitSharesNodeRPC(node, num_retries=1) + latency = (time.time() - start) * 1000 + except BaseException: + latency = -1 + + if latency != -1: + self.ui.status_bar.showMessage("ver {} - Node delay: {:.2f}ms".format(__version__, latency)) + else: + self.ui.status_bar.showMessage("ver {} - Node disconnected".format(__version__)) + + def set_worker_status(self, worker_name, level, status): + if worker_name != 'NONE': + self.worker_widgets[worker_name].set_status(status) diff --git a/dexbot/worker.py b/dexbot/worker.py new file mode 100644 index 000000000..66de545df --- /dev/null +++ b/dexbot/worker.py @@ -0,0 +1,223 @@ +import importlib +import sys +import logging +import os.path +import threading +import copy + +import dexbot.errors as errors +from dexbot.basestrategy import BaseStrategy + +from bitshares.notify import Notify +from bitshares.instance import shared_bitshares_instance + +log = logging.getLogger(__name__) +log_workers = logging.getLogger('dexbot.per_worker') +# NOTE this is the special logger for per-worker events +# it returns LogRecords with extra fields: worker_name, account, market and is_disabled +# is_disabled is a callable returning True if the worker is currently disabled. +# GUIs can add a handler to this logger to get a stream of events of the running workers. + + +class WorkerInfrastructure(threading.Thread): + + def __init__( + self, + config, + bitshares_instance=None, + view=None + ): + super().__init__() + + # BitShares instance + self.bitshares = bitshares_instance or shared_bitshares_instance() + self.config = copy.deepcopy(config) + self.view = view + self.jobs = set() + self.notify = None + self.config_lock = threading.RLock() + self.workers = {} + + self.accounts = set() + self.markets = set() + + # Set the module search path + user_worker_path = os.path.expanduser("~/bots") + if os.path.exists(user_worker_path): + sys.path.append(user_worker_path) + + def init_workers(self, config): + """ Initialize the workers + """ + self.config_lock.acquire() + for worker_name, worker in config["workers"].items(): + if "account" not in worker: + log_workers.critical("Worker has no account", extra={ + 'worker_name': worker_name, 'account': 'unknown', + 'market': 'unknown', 'is_disabled': (lambda: True) + }) + continue + if "market" not in worker: + log_workers.critical("Worker has no market", extra={ + 'worker_name': worker_name, 'account': worker['account'], + 'market': 'unknown', 'is_disabled': (lambda: True) + }) + continue + try: + strategy_class = getattr( + importlib.import_module(worker["module"]), + 'Strategy' + ) + self.workers[worker_name] = strategy_class( + config=config, + name=worker_name, + bitshares_instance=self.bitshares, + view=self.view + ) + self.markets.add(worker['market']) + self.accounts.add(worker['account']) + except BaseException: + log_workers.exception("Worker initialisation", extra={ + 'worker_name': worker_name, 'account': worker['account'], + 'market': 'unknown', 'is_disabled': (lambda: True) + }) + self.config_lock.release() + + def update_notify(self): + if not self.config['workers']: + log.critical("No workers to launch, exiting") + raise errors.NoWorkersAvailable() + + if self.notify: + # Update the notification instance + self.notify.reset_subscriptions(list(self.accounts), list(self.markets)) + else: + # Initialize the notification instance + self.notify = Notify( + markets=list(self.markets), + accounts=list(self.accounts), + on_market=self.on_market, + on_account=self.on_account, + on_block=self.on_block, + bitshares_instance=self.bitshares + ) + + # Events + def on_block(self, data): + if self.jobs: + try: + for job in self.jobs: + job() + finally: + self.jobs = set() + + self.config_lock.acquire() + for worker_name, worker in self.config["workers"].items(): + if worker_name not in self.workers or self.workers[worker_name].disabled: + continue + try: + self.workers[worker_name].ontick(data) + except Exception as e: + self.workers[worker_name].log.exception("in ontick()") + try: + self.workers[worker_name].error_ontick(e) + except Exception: + self.workers[worker_name].log.exception("in error_ontick()") + self.config_lock.release() + + def on_market(self, data): + if data.get("deleted", False): # No info available on deleted orders + return + + self.config_lock.acquire() + for worker_name, worker in self.config["workers"].items(): + if self.workers[worker_name].disabled: + self.workers[worker_name].log.debug('Worker "{}" is disabled'.format(worker_name)) + continue + if worker["market"] == data.market: + try: + self.workers[worker_name].onMarketUpdate(data) + except Exception as e: + self.workers[worker_name].log.exception("in onMarketUpdate()") + try: + self.workers[worker_name].error_onMarketUpdate(e) + except Exception: + self.workers[worker_name].log.exception("in error_onMarketUpdate()") + self.config_lock.release() + + def on_account(self, account_update): + self.config_lock.acquire() + account = account_update.account + for worker_name, worker in self.config["workers"].items(): + if self.workers[worker_name].disabled: + self.workers[worker_name].log.info('Worker "{}" is disabled'.format(worker_name)) + continue + if worker["account"] == account["name"]: + try: + self.workers[worker_name].onAccount(account_update) + except Exception as e: + self.workers[worker_name].log.exception("in onAccountUpdate()") + try: + self.workers[worker_name].error_onAccount(e) + except Exception: + self.workers[worker_name].log.exception("in error_onAccountUpdate()") + self.config_lock.release() + + def add_worker(self, worker_name, config): + with self.config_lock: + self.config['workers'][worker_name] = config['workers'][worker_name] + self.init_workers(config) + self.update_notify() + + def run(self): + self.init_workers(self.config) + self.update_notify() + self.notify.listen() + + def stop(self, worker_name=None): + if worker_name and len(self.workers) > 1: + # Kill only the specified worker + self.remove_market(worker_name) + with self.config_lock: + account = self.config['workers'][worker_name]['account'] + self.config['workers'].pop(worker_name) + + self.accounts.remove(account) + self.workers[worker_name].cancel_all() + self.workers.pop(worker_name, None) + self.update_notify() + else: + # Kill all of the workers + for worker in self.workers: + self.workers[worker].cancel_all() + if self.notify: + self.notify.websocket.close() + + def remove_worker(self, worker_name=None): + if worker_name: + self.workers[worker_name].purge() + else: + for worker in self.workers: + self.workers[worker].purge() + + def remove_market(self, worker_name): + """ Remove the market only if the worker is the only one using it + """ + with self.config_lock: + market = self.config['workers'][worker_name]['market'] + for name, worker in self.config['workers'].items(): + if market == worker['market']: + break # Found the same market, do nothing + else: + # No markets found, safe to remove + self.markets.remove(market) + + @staticmethod + def remove_offline_worker(config, worker_name): + # Initialize the base strategy to get control over the data + strategy = BaseStrategy(config, worker_name) + strategy.purge() + + def do_next_tick(self, job): + """ Add a callable to be executed on the next tick """ + self.jobs.add(job) diff --git a/docs/issue_template.md b/docs/issue_template.md new file mode 100644 index 000000000..4054600ea --- /dev/null +++ b/docs/issue_template.md @@ -0,0 +1,16 @@ +## Expected Behavior + + +## Actual Behavior + + +## Steps to Reproduce the Problem + + 1. + 2. + 3. + +## Specifications + + - Version: + - OS: diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index 8b1378917..000000000 --- a/docs/requirements.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/gui.py b/gui.py new file mode 100755 index 000000000..ff5b8d7a1 --- /dev/null +++ b/gui.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 + +from dexbot import gui + +if __name__ == '__main__': + gui.main() diff --git a/gui.spec b/gui.spec new file mode 100644 index 000000000..753169ffe --- /dev/null +++ b/gui.spec @@ -0,0 +1,57 @@ +# -*- mode: python -*- + +import os +import sys +block_cipher = None + +hiddenimports_strategies = [ + 'dexbot', + 'dexbot.strategies', + 'dexbot.strategies.echo', + 'dexbot.strategies.relative_orders', + 'dexbot.strategies.staggered_orders', + 'dexbot.strategies.storagedemo', + 'dexbot.strategies.walls', + 'dexbot.views.ui.forms', + 'dexbot.views.ui.forms.relative_orders_widget_ui', + 'dexbot.views.ui.forms.staggered_orders_widget_ui', +] + +hiddenimports_packaging = [ + 'packaging', 'packaging.version', 'packaging.specifiers', 'packaging.requirements' +] + +a = Analysis(['dexbot/gui.py'], + binaries=[], + datas=[], + hiddenimports=hiddenimports_packaging + hiddenimports_strategies + ['_scrypt'], + hookspath=['hooks'], + runtime_hooks=['hooks/rthook-Crypto.py'], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher) + +pyz = PYZ(a.pure, a.zipped_data, + cipher=block_cipher) + +a.binaries = [b for b in a.binaries if "libdrm.so.2" not in b[0]] + +exe = EXE(pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + name=os.path.join('dist', 'DEXBot-gui' + ('.exe' if sys.platform == 'win32' else '')), + debug=True, + strip=False, + icon=None, + upx=True, + runtime_tmpdir=None, + console=True) + +if sys.platform == 'darwin': + app = BUNDLE(exe, + name='DEXBot-gui.app', + icon=None) + diff --git a/hooks/hook-Crypto.py b/hooks/hook-Crypto.py index 5048d1c78..f7f2e684a 100644 --- a/hooks/hook-Crypto.py +++ b/hooks/hook-Crypto.py @@ -1,10 +1,11 @@ # Hook for pycryptodome extensions hiddenimports = [ - 'Crypto.Cipher._chacha20', - 'Crypto.Cipher._raw_aes', - 'Crypto.Cipher._raw_ecb', - 'Crypto.Hash._SHA256', - 'Crypto.Util._cpuid', - 'Crypto.Util._strxor', + 'Crypto.Cipher._chacha20', + 'Crypto.Cipher._raw_aes', + 'Crypto.Cipher._raw_ecb', + 'Crypto.Cipher._raw_cbc', + 'Crypto.Hash._SHA256', + 'Crypto.Util._cpuid', + 'Crypto.Util._strxor', ] diff --git a/pyuic.json b/pyuic.json index 1bf28f2a6..09b83e80e 100644 --- a/pyuic.json +++ b/pyuic.json @@ -1,6 +1,7 @@ { "files": [ ["dexbot/views/ui/*.ui", "dexbot/views/ui/"], + ["dexbot/views/ui/forms/*.ui", "dexbot/views/ui/forms"], ["dexbot/resources/*.qrc", "dexbot/resources/"] ], "hooks": [], diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..ed0bde7db --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +pyqt5==5.10 +pyqt-distutils==0.7.3 +click-datetime==0.2 +pyinstaller==3.3.1 +appdirs==1.4.3 \ No newline at end of file diff --git a/setup.py b/setup.py index 9a9332c9e..701716022 100755 --- a/setup.py +++ b/setup.py @@ -1,24 +1,20 @@ -#!/usr/bin/env python - -from setuptools import setup -from setuptools.command.install import install +#!/usr/bin/env python3 +from setuptools import setup, find_packages +from distutils.command import build as build_module from pyqt_distutils.build_ui import build_ui -VERSION = '0.1.0' +from dexbot import VERSION, APP_NAME -class InstallCommand(install): - """Customized setuptools install command - converts .ui and .qrc files to .py files - """ +class BuildCommand(build_module.build): def run(self): - # Workaround for https://github.com/pypa/setuptools/issues/456 - self.do_egg_install() self.run_command('build_ui') + build_module.build.run(self) setup( - name='dexbot', + name=APP_NAME, version=VERSION, description='Trading bot for the DEX (BitShares)', long_description=open('README.md').read(), @@ -28,10 +24,7 @@ def run(self): maintainer_email='support@codaone.com', url='http://www.github.com/codaone/dexbot', keywords=['DEX', 'bot', 'trading', 'api', 'blockchain'], - packages=[ - "dexbot", - "dexbot.strategies", - ], + packages=find_packages(), classifiers=[ 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', @@ -41,24 +34,24 @@ def run(self): ], cmdclass={ 'build_ui': build_ui, - 'install': InstallCommand, + 'build': BuildCommand }, entry_points={ 'console_scripts': [ - 'dexbot = dexbot.cli:main', + 'dexbot-cli = dexbot.cli:main', + 'dexbot-gui = dexbot.gui:main', ], }, install_requires=[ - "bitshares==0.1.11.beta", + "bitshares==0.1.16", "uptick>=0.1.4", "click", - "click-datetime", "sqlalchemy", "appdirs", "pyqt5", - "sdnotify" + "sdnotify", 'pyqt-distutils', - "ruamel.yaml" + "ruamel.yaml>=0.15.37" ], dependency_links=[ # Temporally force downloads from a different repo, change this once the websocket fix has been merged diff --git a/tests/test.py b/tests/test.py new file mode 100755 index 000000000..a4c0a88e9 --- /dev/null +++ b/tests/test.py @@ -0,0 +1,52 @@ +#!/usr/bin/python3 +import threading +import unittest +import logging +import time +import os + +from dexbot.worker import WorkerInfrastructure + +from bitshares.bitshares import BitShares + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s %(levelname)s %(message)s' +) + + +TEST_CONFIG = { + 'node': 'wss://node.testnet.bitshares.eu', + 'bots': { + 'echo': + { + 'account': 'aud.bot.test4', + 'market': 'TESTUSD:TEST', + 'module': 'dexbot.strategies.echo' + } + } +} + +# User needs to put a key in +KEYS = [os.environ['DEXBOT_TEST_WIF']] + + +class TestDexbot(unittest.TestCase): + + def test_dexbot(self): + bitshares_instance = BitShares(node=TEST_CONFIG['node'], keys=KEYS) + worker_infrastructure = WorkerInfrastructure(config=TEST_CONFIG, + bitshares_instance=bitshares_instance) + + def wait_then_stop(): + time.sleep(20) + worker_infrastructure.do_next_tick(worker_infrastructure.stop) + + stopper = threading.Thread(target=wait_then_stop) + stopper.start() + worker_infrastructure.run() + stopper.join() + + +if __name__ == '__main__': + unittest.main()