Skip to content

Commit

Permalink
Merge remote-tracking branch 'vvk/single-account-multiple-workers' in…
Browse files Browse the repository at this point in the history
…to pr-441-merge
  • Loading branch information
joelvai committed Oct 18, 2019
2 parents f0839ce + 92a1f5f commit f9122aa
Show file tree
Hide file tree
Showing 12 changed files with 834 additions and 69 deletions.
63 changes: 62 additions & 1 deletion dexbot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import appdirs
from ruamel import yaml
from collections import OrderedDict
from collections import OrderedDict, defaultdict

DEFAULT_CONFIG_DIR = appdirs.user_config_dir(APP_NAME, appauthor=AUTHOR)
DEFAULT_CONFIG_FILE = os.path.join(DEFAULT_CONFIG_DIR, 'config.yml')
Expand Down Expand Up @@ -44,6 +44,8 @@ def __init__(self, config=None, path=None):
self._config['node'] = sorted_nodes
self.save_config()

self.intersections_data = None

def __setitem__(self, key, value):
self._config[key] = value

Expand Down Expand Up @@ -168,6 +170,65 @@ def construct_mapping(mapping_loader, node):
construct_mapping)
return yaml.load(stream, OrderedLoader)

@staticmethod
def assets_intersections(config):
""" Collect intersections of assets on the same account across multiple workers
:return: defaultdict instance representing dict with intersections
The goal of calculating assets intersections is to be able to use single account on multiple workers and
trade some common assets. For example, trade BTS/USD, BTC/BTS, ETH/BTC markets on same account.
Configuration variable `operational_percent_xxx` defines what percent of total account balance should be
available for the worker. It may be set or omitted.
The logic of splitting balance is following: workers who define `operational_percent_xxx` will take this
defined percent, and remaining workers will just split the remaining balance between each other. For
example, 3 workers with 30% 30% 30%, and 2 workers with 0. These 2 workers will take the remaining `(100 -
3*30) / 2 = 5`.
Example return as a dict:
{'foo': {'RUBLE': {'sum_pct': 0, 'zero_workers': 0},
'USD': {'sum_pct': 0, 'zero_workers': 0},
'CNY': {'sum_pct': 0, 'zero_workers': 0}
}
}
"""
def update_data(asset, operational_percent):
if isinstance(data[account][asset]['sum_pct'], float):
# Existing dict key
data[account][asset]['sum_pct'] += operational_percent
if not operational_percent:
# Increase count of workers with 0 op percent
data[account][asset]['num_zero_workers'] += 1
else:
# Create new dict key
data[account][asset]['sum_pct'] = operational_percent
if operational_percent:
data[account][asset]['num_zero_workers'] = 0
else:
data[account][asset]['num_zero_workers'] = 1

if data[account][asset]['sum_pct'] > 1:
raise ValueError('Operational percent for asset {} is more than 100%'
.format(asset))

def tree():
return defaultdict(tree)

data = tree()

for _, worker in config['workers'].items():
account = worker['account']
quote_asset = worker['market'].split('/')[0]
base_asset = worker['market'].split('/')[1]
operational_percent_quote = worker.get('operational_percent_quote', 0) / 100
operational_percent_base = worker.get('operational_percent_base', 0) / 100
update_data(quote_asset, operational_percent_quote)
update_data(base_asset, operational_percent_base)

return data

@property
def node_list(self):
""" A pre-defined list of Bitshares nodes. """
Expand Down
6 changes: 4 additions & 2 deletions dexbot/controllers/worker_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,6 @@ def validate_form(self):
private_key = self.view.private_key_input.text()
if not self.validator.validate_account_name(account):
error_texts.append("Account doesn't exist.")
if not self.validator.validate_account_not_in_use(account):
error_texts.append('Use a different account. "{}" is already in use.'.format(account))
if not self.validator.validate_private_key(account, private_key):
error_texts.append('Private key is invalid.')
elif private_key and not self.validator.validate_private_key_type(account, private_key):
Expand Down Expand Up @@ -176,13 +174,17 @@ def handle_save(self):
base_asset = self.view.base_asset_input.text()
quote_asset = self.view.quote_asset_input.text()
fee_asset = self.view.fee_asset_input.text()
operational_percent_quote = self.view.operational_percent_quote_input.value()
operational_percent_base = self.view.operational_percent_base_input.value()
strategy_module = self.view.strategy_input.currentData()

self.view.worker_data = {
'account': account,
'market': '{}/{}'.format(quote_asset, base_asset),
'module': strategy_module,
'fee_asset': fee_asset,
'operational_percent_quote': operational_percent_quote,
'operational_percent_base': operational_percent_base,
**self.view.strategy_widget.values
}
self.view.worker_name = self.view.worker_name_input.text()
Expand Down
20 changes: 15 additions & 5 deletions dexbot/orderengines/bitshares_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,13 +222,23 @@ def calculate_worker_value(self, unit_of_measure):
# Fixme: Make sure that decimal precision is correct.
return base_total + quote_total

def cancel_all_orders(self):
""" Cancel all orders of the worker's account
def cancel_all_orders(self, all_markets=False):
""" Cancel all orders of the worker's market or all markets
:param bool all_markets: True = cancel orders on all markets, False = cancel only own market
"""
self.log.info('Canceling all orders')
orders_to_cancel = []

if all_markets:
self.log.info('Canceling all account orders')
orders_to_cancel = self.all_own_orders
else:
self.log.info('Canceling all orders on market {}/{}'
.format(self.market['quote']['symbol'], self.market['base']['symbol']))
orders_to_cancel = self.own_orders

if self.all_own_orders:
self.cancel_orders(self.all_own_orders)
if orders_to_cancel:
self.cancel_orders(orders_to_cancel)

self.log.info("Orders canceled")

Expand Down
28 changes: 28 additions & 0 deletions dexbot/strategies/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,10 @@ def __init__(self,
# Redirect this event to also call order placed and order matched
self.onMarketUpdate += self._callbackPlaceFillOrders

self.assets_intersections_data = None
if config:
self.config = config
self.assets_intersections_data = Config.assets_intersections(config)
else:
self.config = config = Config.get_worker_config_file(name)

Expand All @@ -143,6 +145,10 @@ def __init__(self,
# Count of orders to be fetched from the API
self.fetch_depth = 8

# What percent of balance the worker should use
self.operational_percent_quote = self.worker.get('operational_percent_quote', 0) / 100
self.operational_percent_base = self.worker.get('operational_percent_base', 0) / 100

# Get Bitshares account and market for this worker
self._account = Account(self.worker["account"], full=True, bitshares_instance=self.bitshares)
self._market = Market(config["workers"][name]["market"], bitshares_instance=self.bitshares)
Expand Down Expand Up @@ -213,6 +219,28 @@ def clear_all_worker_data(self):
# Finally clear all worker data from the database
self.clear()

def get_worker_share_for_asset(self, asset):
""" Returns operational percent of asset available to the worker
:param str asset: Which asset should be checked
:return: a value between 0-1 representing a percent
:rtype: float
"""
intersections_data = self.assets_intersections_data[self.account.name][asset]

if asset == self.market['base']['symbol']:
if self.operational_percent_base:
return self.operational_percent_base
else:
return (1 - intersections_data['sum_pct']) / intersections_data['num_zero_workers']
elif asset == self.market['quote']['symbol']:
if self.operational_percent_quote:
return self.operational_percent_quote
else:
return (1 - intersections_data['sum_pct']) / intersections_data['num_zero_workers']
else:
self.log.error('Got asset which is not used by this worker')

def store_profit_estimation_data(self):
""" Save total quote, total base, center_price, and datetime in to the database
"""
Expand Down
6 changes: 5 additions & 1 deletion dexbot/strategies/config_parts/base_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,11 @@ def configure(cls, return_base_config=True):
r'[A-Z0-9\.]+[:\/][A-Z0-9\.]+'),
ConfigElement('fee_asset', 'string', 'BTS', 'Fee asset',
'Asset to be used to pay transaction fees',
r'[A-Z\.]+')
r'[A-Z\.]+'),
ConfigElement('operational_percent_quote', 'float', 0, 'QUOTE balance %',
'Max % of QUOTE asset available to this worker', (0, None, 2, '%')),
ConfigElement('operational_percent_base', 'float', 0, 'BASE balance %',
'Max % of BASE asset available to this worker', (0, None, 2, '%')),
]

if return_base_config:
Expand Down
109 changes: 71 additions & 38 deletions dexbot/strategies/staggered_orders.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ def maintain_strategy(self, *args, **kwargs):
self.market_center_price = self.center_price
else:
# Still not have market_center_price? Empty market, don't continue
self.log.warning('Cannot calculate center price on empty market, please set is manually')
self.log.warning('Cannot calculate center price on empty market, please set it manually')
return

# Calculate balances, and use orders from previous call of self.refresh_orders() to reduce API calls
Expand Down Expand Up @@ -217,7 +217,7 @@ def maintain_strategy(self, *args, **kwargs):

# Maintain the history of free balances after maintenance runs.
# Save exactly key values instead of full key because it may be modified later on.
self.refresh_balances(total_balances=False)
self.refresh_balances()
self.base_balance_history.append(self.base_balance['amount'])
self.quote_balance_history.append(self.quote_balance['amount'])
if len(self.base_balance_history) > 3:
Expand Down Expand Up @@ -317,60 +317,91 @@ def calculate_asset_thresholds(self):
self.base_asset_threshold = reserve_ratio * 10 ** -self.market['base']['precision']
self.quote_asset_threshold = self.base_asset_threshold / self.market_center_price

def refresh_balances(self, total_balances=True, use_cached_orders=False):
def refresh_balances(self, use_cached_orders=False):
""" This function is used to refresh account balances
:param bool | total_balances: refresh total balance or skip it
:param bool | use_cached_orders: when calculating orders balance, use cached orders from self.cached_orders
:param bool use_cached_orders (optional): when calculating orders
balance, use cached orders from self.cached_orders
This version supports usage of same bitshares account across multiple workers with assets intersections.
"""
# Balances in orders on all related markets
orders = self.get_all_own_orders(refresh=not use_cached_orders)
order_ids = [order['id'] for order in orders]
orders_balance = self.get_allocated_assets(order_ids)

# Balances in own orders
own_orders = self.get_own_orders(refresh=False)
order_ids = [order['id'] for order in own_orders]
own_orders_balance = self.get_allocated_assets(order_ids)

# Get account free balances (not allocated into orders)
account_balances = self.count_asset(order_ids=[], return_asset=True)

# Calculate full asset balance on account
quote_full_balance = account_balances['quote']['amount'] + orders_balance['quote']
base_full_balance = account_balances['base']['amount'] + orders_balance['base']

# Calculate operational balance for current worker
# Operational balance is a part of the whole account balance which should be designated to this worker
op_quote_balance = quote_full_balance
op_base_balance = base_full_balance
op_percent_quote = self.get_worker_share_for_asset(self.market['quote']['symbol'])
op_percent_base = self.get_worker_share_for_asset(self.market['base']['symbol'])
if op_percent_quote < 1:
op_quote_balance *= op_percent_quote
self.log.debug('Using {:.2%} of QUOTE balance ({:.{prec}f} {})'
.format(op_percent_quote, op_quote_balance, self.market['quote']['symbol'],
prec=self.market['quote']['precision']))
if op_percent_base < 1:
op_base_balance *= op_percent_base
self.log.debug('Using {:.2%} of BASE balance ({:.{prec}f} {})'
.format(op_percent_base, op_base_balance, self.market['base']['symbol'],
prec=self.market['base']['precision']))

# Count balances allocated into virtual orders
virtual_orders_base_balance = 0
virtual_orders_quote_balance = 0
if self.virtual_orders:
# Todo: can we use filtered orders from refresh_orders() here?
buy_orders = self.filter_buy_orders(self.virtual_orders)
sell_orders = self.filter_sell_orders(self.virtual_orders, invert=False)
virtual_orders_base_balance = reduce((lambda x, order: x + order['base']['amount']), buy_orders, 0)
virtual_orders_quote_balance = reduce((lambda x, order: x + order['base']['amount']), sell_orders, 0)

# Get current account balances
account_balances = self.count_asset(order_ids=[], return_asset=True)
# Total balance per asset (orders balance and available balance)
# Total balance should be: max(operational, real_orders + virtual_orders)
# Total balance used when increasing least/closest orders
self.quote_total_balance = max(op_quote_balance, own_orders_balance['quote'] + virtual_orders_quote_balance)
self.base_total_balance = max(op_base_balance, own_orders_balance['base'] + virtual_orders_base_balance)

self.base_balance = account_balances['base']
# Prepare variables with free balance available to the worker
self.quote_balance = account_balances['quote']
self.base_balance = account_balances['base']

# Calc avail balance; avail balances used in maintain_strategy to pass into allocate_asset
# avail = total - real_orders - virtual_orders
self.quote_balance['amount'] = (
self.quote_total_balance
- own_orders_balance['quote']
- virtual_orders_quote_balance
)
self.base_balance['amount'] = self.base_total_balance - own_orders_balance['base'] - virtual_orders_base_balance

# Reserve fees for N orders
reserve_num_orders = 200
fee_reserve = reserve_num_orders * self.get_order_creation_fee(self.fee_asset)

# Finally, reserve only required asset
if self.fee_asset['id'] == self.market['base']['id']:
self.base_balance['amount'] = self.base_balance['amount'] - fee_reserve
self.base_balance['amount'] -= fee_reserve
elif self.fee_asset['id'] == self.market['quote']['id']:
self.quote_balance['amount'] = self.quote_balance['amount'] - fee_reserve

# Exclude balances allocated into virtual orders
if self.virtual_orders:
buy_orders = self.filter_buy_orders(self.virtual_orders)
sell_orders = self.filter_sell_orders(self.virtual_orders, invert=False)
virtual_orders_base_balance = reduce((lambda x, order: x + order['base']['amount']), buy_orders, 0)
virtual_orders_quote_balance = reduce((lambda x, order: x + order['base']['amount']), sell_orders, 0)
self.base_balance['amount'] -= virtual_orders_base_balance
self.quote_balance['amount'] -= virtual_orders_quote_balance

if not total_balances:
# Caller doesn't interesting in balances of real orders
return

# Balance per asset from orders
if use_cached_orders and self.cached_orders:
orders = self.cached_orders
else:
orders = self.own_orders
order_ids = [order['id'] for order in orders]
orders_balance = self.get_allocated_assets(order_ids)

# Total balance per asset (orders balance and available balance)
self.quote_total_balance = orders_balance['quote'] + self.quote_balance['amount'] + virtual_orders_quote_balance
self.base_total_balance = orders_balance['base'] + self.base_balance['amount'] + virtual_orders_base_balance
self.quote_balance['amount'] -= fee_reserve

def refresh_orders(self):
""" Updates buy and sell orders
"""
orders = self.own_orders
self.cached_orders = orders
orders = self.get_own_orders()

# Sort virtual orders
self.virtual_buy_orders = self.filter_buy_orders(self.virtual_orders, sort='DESC')
Expand Down Expand Up @@ -628,6 +659,8 @@ def store_profit_estimation_data(self, force=False):
""" Stores balance history entry if center price moved enough
:param bool | force: True = force store data, False = store data only on center price change
Todo: this method is inaccurate when using single account accross multiple workers
"""
need_store = False
account = self.config['workers'][self.worker_name].get('account')
Expand Down Expand Up @@ -1422,7 +1455,7 @@ def replace_partially_filled_order(self, order):
price = order['price'] ** -1
self.place_market_sell_order(order['base']['amount'], price)
if self.returnOrderId:
self.refresh_balances(total_balances=False)
self.refresh_balances()
else:
needed = order['base']['amount'] - order['for_sale']['amount']
self.log.debug('Unable to replace partially filled {} order: avail/needed: {:.{prec}f}/{:.{prec}f} {}'
Expand Down
2 changes: 2 additions & 0 deletions dexbot/views/edit_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ def __init__(self, parent_widget, bitshares_instance, worker_name, config):
self.quote_asset_input.setText(self.controller.get_quote_asset(worker_data))
self.fee_asset_input.setText(worker_data.get('fee_asset', 'BTS'))
self.account_name.setText(self.controller.get_account(worker_data))
self.operational_percent_quote_input.setValue(worker_data.get('operational_percent_quote', 0))
self.operational_percent_base_input.setValue(worker_data.get('operational_percent_base', 0))

# Force uppercase to the assets fields
validator = UppercaseValidator(self)
Expand Down
Loading

0 comments on commit f9122aa

Please sign in to comment.