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:
+[](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()