forked from xeroc/stakemachine
-
Notifications
You must be signed in to change notification settings - Fork 126
/
staggered_orders.py
2105 lines (1801 loc) · 100 KB
/
staggered_orders.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import time
import math
import uuid
import bitsharesapi.exceptions
from datetime import datetime, timedelta
from functools import reduce
from bitshares.dex import Dex
from bitshares.amount import Amount
from dexbot.strategies.base import StrategyBase
from dexbot.strategies.config_parts.staggered_config import StaggeredConfig
class Strategy(StrategyBase):
""" Staggered Orders strategy """
@classmethod
def configure(cls, return_base_config=True):
return StaggeredConfig.configure(return_base_config)
@classmethod
def configure_details(cls, include_default_tabs=True):
return StaggeredConfig.configure_details(include_default_tabs)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Tick counter
self.counter = 0
# Define callbacks
self.onMarketUpdate += self.maintain_strategy
self.onAccount += self.maintain_strategy
self.ontick += self.tick
self.error_ontick = self.error
self.error_onMarketUpdate = self.error
self.error_onAccount = self.error
# Worker parameters
self.worker_name = kwargs.get('name')
self.view = kwargs.get('view')
self.mode = self.worker['mode']
self.target_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']
# This fill threshold prevents too often orders replacements draining fee_asset
self.partial_fill_threshold = 0.15
self.is_instant_fill_enabled = self.worker.get('instant_fill', True)
self.is_center_price_dynamic = self.worker['center_price_dynamic']
self.operational_depth = self.worker.get('operational_depth', 6)
if self.is_center_price_dynamic:
self.center_price = None
else:
self.center_price = self.worker['center_price']
fee_sum = self.market['base'].market_fee_percent + self.market['quote'].market_fee_percent
if self.target_spread - self.increment < fee_sum:
self.log.error('Spread must be greater than increment by at least {}, refusing to work because worker'
' will make losses'.format(fee_sum))
self.disabled = True
if self.operational_depth < 2:
self.log.error('Operational depth should be at least 2 orders')
self.disabled = True
# Strategy variables
self.market_center_price = None
self.old_center_price = None
self.buy_orders = []
self.sell_orders = []
self.real_buy_orders = []
self.real_sell_orders = []
self.virtual_orders = []
self.virtual_buy_orders = []
self.virtual_sell_orders = []
self.virtual_orders_restored = False
self.actual_spread = self.target_spread + 1
self.quote_total_balance = 0
self.base_total_balance = 0
self.quote_balance = None
self.base_balance = None
self.quote_asset_threshold = 0
self.base_asset_threshold = 0
self.min_increase_factor = 1.05
# Initial balance history elements should not be equal to avoid immediate bootstrap turn off
self.quote_balance_history = [1, 2, 3]
self.base_balance_history = [1, 2, 3]
self.cached_orders = None
# Dex instance used to get different fees for the market
self.dex = Dex(self.bitshares)
# Order expiration time
self.expiration = 60 * 60 * 24 * 365 * 5
self.start = datetime.now()
self.last_check = datetime.now()
# We do not waiting for order ids to be able to bundle operations
self.returnOrderId = None
# Minimal order amounts depending on defined increment
self.order_min_base = 0
self.order_min_quote = 0
# Minimal check interval is needed to prevent event queue accumulation
self.min_check_interval = 1
self.max_check_interval = 120
self.current_check_interval = self.min_check_interval
# If no bootstrap state is recorded, assume we're in bootstrap
self.get('bootstrapping', True)
if self.view:
self.update_gui_profit()
self.update_gui_slider()
def maintain_strategy(self, *args, **kwargs):
""" Logic of the strategy
:param args:
:param kwargs:
"""
self.start = datetime.now()
delta = self.start - self.last_check
# Only allow to maintain whether minimal time passed.
if delta < timedelta(seconds=self.current_check_interval):
return
# Get all user's orders on current market
self.refresh_orders()
# Check if market center price is calculated
self.market_center_price = self.get_market_center_price(suppress_errors=True)
# Set center price to manual value if needed. Manual center price works only when there are no orders
if self.center_price and not (self.buy_orders or self.sell_orders):
self.log.debug('Using manual center price because of no sell or buy orders')
self.market_center_price = self.center_price
# On empty market we need manual center price anyway
if not self.market_center_price:
if self.center_price:
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 it manually')
return
# Calculate balances, and use orders from previous call of self.refresh_orders() to reduce API calls
self.refresh_balances(use_cached_orders=True)
# Store balance entry for profit estimation if needed
self.store_profit_estimation_data()
# Calculate asset thresholds once
if not self.quote_asset_threshold or not self.base_asset_threshold:
self.calculate_asset_thresholds()
# Remove orders that exceed boundaries
success = self.remove_outside_orders(self.sell_orders, self.buy_orders)
if not success:
# Return back to beginning
self.log_maintenance_time()
return
# Restore virtual orders on startup if needed
if not self.virtual_orders_restored:
self.restore_virtual_orders()
if self.virtual_orders_restored:
self.log.info('Virtual orders restored')
self.log_maintenance_time()
return
# Ensure proper operational depth
self.check_operational_depth(self.real_buy_orders, self.virtual_buy_orders)
self.check_operational_depth(self.real_sell_orders, self.virtual_sell_orders)
# Remember current boostrapping state before sending transactions
previous_bootstrap_state = self['bootstrapping']
# Prepare to bundle operations into single transaction
self.bitshares.bundle = True
# BASE asset check
if self.base_balance > self.base_asset_threshold:
# Allocate available BASE funds
self.allocate_asset('base', self.base_balance)
# QUOTE asset check
if self.quote_balance > self.quote_asset_threshold:
# Allocate available QUOTE funds
self.allocate_asset('quote', self.quote_balance)
# Send pending operations
trx_executed = False
if not self.bitshares.txbuffer.is_empty():
trx_executed = True
try:
self.execute()
except bitsharesapi.exceptions.RPCError as exception:
""" Handle exception without stopping the worker. The goal is to handle race condition when partially
filled order was further filled before we actually replaced them.
"""
if str(exception).startswith('Assert Exception: maybe_found != nullptr: Unable to find Object'):
self.log.warning(exception)
self.bitshares.txbuffer.clear()
return
else:
raise
self.refresh_orders()
self.sync_current_orders()
self.bitshares.bundle = False
# 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()
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:
del self.base_balance_history[0]
del self.quote_balance_history[0]
# Greatly increase check interval to lower CPU load whether there is no funds to allocate or we cannot
# allocate funds for some reason
if (self.current_check_interval == self.min_check_interval and
self.base_balance_history[1] == self.base_balance_history[2] and
self.quote_balance_history[1] == self.quote_balance_history[2]):
# Balance didn't changed, so we can reduce maintenance frequency
self.log.debug('Raising check interval up to {} seconds to reduce CPU usage'.format(
self.max_check_interval))
self.current_check_interval = self.max_check_interval
elif (self.current_check_interval == self.max_check_interval and
(self.base_balance_history[1] != self.base_balance_history[2] or
self.quote_balance_history[1] != self.quote_balance_history[2])):
# Balance changed, increase maintenance frequency to allocate more quickly
self.log.debug('Reducing check interval to {} seconds because of changed '
'balances'.format(self.min_check_interval))
self.current_check_interval = self.min_check_interval
if previous_bootstrap_state is True and self['bootstrapping'] is False:
# Bootstrap was turned off, dump initial orders
self.dump_initial_orders()
# Do not continue whether balances are changing or bootstrap is on
if (self['bootstrapping'] or
self.base_balance_history[0] != self.base_balance_history[2] or
self.quote_balance_history[0] != self.quote_balance_history[2] or
trx_executed):
self.last_check = datetime.now()
self.log_maintenance_time()
return
# There are no funds and current orders aren't close enough, try to fix the situation by shifting orders.
# This is a fallback logic.
# Get highest buy and lowest sell prices from orders
highest_buy_price = 0
lowest_sell_price = 0
if self.buy_orders:
highest_buy_price = self.buy_orders[0].get('price')
if self.sell_orders:
lowest_sell_price = self.sell_orders[0].get('price')
# Invert the sell price to BASE so it can be used in comparison
lowest_sell_price = lowest_sell_price ** -1
if highest_buy_price and lowest_sell_price:
self.actual_spread = (lowest_sell_price / highest_buy_price) - 1
if self.actual_spread < self.target_spread + self.increment:
# Target spread is reached, no need to cancel anything
self.last_check = datetime.now()
self.log_maintenance_time()
return
elif self.buy_orders:
# If target spread is not reached and no balance to allocate, cancel lowest buy order
self.log.info('Free balances are not changing, bootstrap is off and target spread is not reached. '
'Cancelling lowest buy order as a fallback')
self.cancel_orders_wrapper(self.buy_orders[-1])
self.last_check = datetime.now()
self.log_maintenance_time()
# Update profit estimate
if self.view:
self.update_gui_profit()
def log_maintenance_time(self):
""" Measure time from self.start and print a log message
"""
delta = datetime.now() - self.start
self.log.debug('Maintenance execution took: {:.2f} seconds'.format(delta.total_seconds()))
def calculate_min_amounts(self):
""" Calculate minimal order amounts depending on defined increment
"""
self.order_min_base = 2 * 10 ** -self.market['base']['precision'] / self.increment
self.order_min_quote = 2 * 10 ** -self.market['quote']['precision'] / self.increment
def calculate_asset_thresholds(self):
""" Calculate minimal asset thresholds to allocate.
The goal is to avoid trying to allocate too small amounts which may lead to "Trying to buy/sell 0"
situations.
"""
# Keep at least N of precision
reserve_ratio = 10
if self.market['quote']['precision'] <= self.market['base']['precision']:
self.quote_asset_threshold = reserve_ratio * 10 ** -self.market['quote']['precision']
self.base_asset_threshold = self.quote_asset_threshold * self.market_center_price
else:
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, use_cached_orders=False):
""" This function is used to refresh account balances
: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)
# 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)
# 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'] -= fee_reserve
elif self.fee_asset['id'] == self.market['quote']['id']:
self.quote_balance['amount'] -= fee_reserve
def refresh_orders(self):
""" Updates buy and sell orders
"""
orders = self.get_own_orders()
# Sort virtual orders
self.virtual_buy_orders = self.filter_buy_orders(self.virtual_orders, sort='DESC')
self.virtual_sell_orders = self.filter_sell_orders(self.virtual_orders, sort='DESC', invert=False)
# Sort real orders
# (order with index 0 is closest to the center price and -1 is furthers)
self.real_buy_orders = self.filter_buy_orders(orders, sort='DESC')
self.real_sell_orders = self.filter_sell_orders(orders, sort='DESC', invert=False)
# Concatenate real orders and virtual_orders
self.buy_orders = self.real_buy_orders + self.virtual_buy_orders
self.sell_orders = self.real_sell_orders + self.virtual_sell_orders
def sync_current_orders(self):
""" Sync current orders to the db
"""
current_real_orders = self.real_buy_orders + self.real_sell_orders
current_all_orders = self.buy_orders + self.sell_orders
current_real_ids = set([order['id'] for order in current_real_orders])
current_all_ids = set([order['id'] for order in current_all_orders])
stored_ids = set(self.fetch_orders_extended(custom='current', return_ids_only=True))
# We need to remove both virtual and real orders
to_remove_ids = stored_ids.difference(current_all_ids)
# We need to add only real orders ids because virtual are added in place_virtual_xxx_order()
to_add_ids = current_real_ids.difference(stored_ids)
to_add_orders = [order for order in current_real_orders if order['id'] in to_add_ids]
for _id in to_remove_ids:
self.remove_order(_id)
for order in to_add_orders:
self.save_order_extended(order, custom='current')
def dump_initial_orders(self):
""" Save orders after initial placement for later use (visualization and so on)
"""
self.refresh_orders()
orders = self.buy_orders + self.sell_orders
self.log.info('Dumping initial orders into db')
# Ids should be changed to avoid ids intersection with "current" orders
for order in orders:
order['id'] = str(uuid.uuid4())
if isinstance(order, VirtualOrder):
self.save_order_extended(order, virtual=True, custom='initial')
else:
self.save_order_extended(order, virtual=False, custom='initial')
def drop_initial_orders(self):
""" Drop old "initial" orders from the db
"""
self.log.debug('Removing initial orders from the db')
self.clear_orders_extended(custom='initial')
def remove_outside_orders(self, sell_orders, buy_orders):
""" Remove orders that exceed boundaries
:param list | sell_orders: User's sell orders
:param list | buy_orders: User's buy orders
"""
orders_to_cancel = []
# Remove sell orders that exceed boundaries
for order in sell_orders:
order_price = order['price'] ** -1
if order_price > self.upper_bound:
self.log.info('Cancelling sell order outside range: {:.8f}'.format(order_price))
orders_to_cancel.append(order)
# Remove buy orders that exceed boundaries
for order in buy_orders:
order_price = order['price']
if order_price < self.lower_bound:
self.log.info('Cancelling buy order outside range: {:.8f}'.format(order_price))
orders_to_cancel.append(order)
if orders_to_cancel:
# We are trying to cancel all orders in one try
success = self.cancel_orders_wrapper(orders_to_cancel, batch_only=True)
# Refresh orders to prevent orders outside boundaries being in the future comparisons
self.refresh_orders()
# Batch cancel failed, repeat cancelling only one order
if success:
return True
else:
self.log.debug('Batch cancel failed, failing back to cancelling single order')
self.cancel_orders_wrapper(orders_to_cancel[0])
# To avoid GUI hanging cancel only one order and let switch to another worker
return False
return True
def restore_virtual_orders(self):
""" Create virtual further orders in batch manner. This helps to place further orders quickly on startup.
If we have both buy and sell real orders, restore both. If we have only one type of orders, restore
corresponding virtual orders and purge opposite orders.
"""
def place_further_buy_orders():
furthest_order = self.real_buy_orders[-1]
while furthest_order['price'] > self.lower_bound * (1 + self.increment):
furthest_order = self.place_further_order('base', furthest_order, virtual=True)
if not isinstance(furthest_order, VirtualOrder):
# Failed to place order
break
def place_further_sell_orders():
furthest_order = self.real_sell_orders[-1]
while furthest_order['price'] ** -1 < self.upper_bound / (1 + self.increment):
furthest_order = self.place_further_order('quote', furthest_order, virtual=True)
if not isinstance(furthest_order, VirtualOrder):
# Failed to place order
break
# Load orders from the database
result = self.fetch_orders_extended(only_virtual=True, custom='current')
stored_orders = [entry['order'] for entry in result] if result else []
stored_buy_orders = self.filter_buy_orders(stored_orders)
stored_sell_orders = self.filter_sell_orders(stored_orders, invert=False)
if not self.buy_orders and not self.sell_orders:
# No real orders, assume we need to bootstrap, purge old orders
self.log.info('No real orders, purging old virtual orders')
self.clear_orders_extended(custom='current')
elif self.buy_orders and self.sell_orders:
if stored_orders:
self.log.info('Loading virtual orders from database')
for order in stored_orders:
self.virtual_orders.append(VirtualOrder(order))
else:
self.log.info('Recreating virtual orders')
place_further_buy_orders()
place_further_sell_orders()
elif self.buy_orders and not self.sell_orders:
# Only buy orders, purge stored sell orders
if stored_sell_orders:
self.log.info('Purging virtual sell orders because of no real sell orders')
for order in stored_sell_orders:
self.remove_order(order)
if stored_buy_orders:
self.log.info('Loading virtual buy orders from database')
for order in stored_buy_orders:
self.virtual_orders.append(VirtualOrder(order))
else:
place_further_buy_orders()
elif not self.buy_orders and self.sell_orders:
if stored_buy_orders:
self.log.info('Purging virtual buy orders because of no real buy orders')
for order in stored_buy_orders:
self.remove_order(order)
if stored_sell_orders:
self.log.info('Loading virtual sell orders from database')
for order in stored_sell_orders:
self.virtual_orders.append(VirtualOrder(order))
else:
place_further_sell_orders()
# Set "restored" flag anyway to not break initial bootstrap
self.virtual_orders_restored = True
def check_operational_depth(self, real_orders, virtual_orders):
""" Ensure proper operational depth. Replace excessive real orders or put real orders if needed.
:param list real_orders: list of real orders
:param list virtual_orders: list of virtual orders
"""
num_real_orders = len(real_orders)
num_virtual_orders = len(virtual_orders)
if num_real_orders > self.operational_depth:
for i in range(1, num_real_orders - self.operational_depth + 1):
self.replace_real_order_with_virtual(real_orders[-i])
elif num_real_orders < self.operational_depth and not self['bootstrapping']:
# We need to wait until bootstrap is off because during initial orders placement this would start to place
# real orders without waiting until all range will be covered.
# Note: if boostrap is on and there is nothing to allocate, this check would not work until some orders
# will be filled. This means that changing `operational_depth` config param will not work immediately.
to_replace = min(num_virtual_orders, self.operational_depth - num_real_orders)
for i in range(0, to_replace):
self.replace_virtual_order_with_real(virtual_orders[i])
else:
return
# Orders and balances needs to be refreshed to avoid races
self.refresh_orders()
self.refresh_balances(use_cached_orders=True)
def replace_real_order_with_virtual(self, order):
""" Replace real limit order with virtual order
:param Order | order: market order to replace
:return bool | True = order replace success
False = order replace failed
Logic:
1. Cancel real order
2. Wait until transaction included in head block
3. Place virtual order
"""
success = self.cancel_orders(order)
if success and order['base']['symbol'] == self.market['base']['symbol']:
quote_amount = order['quote']['amount']
price = order['price']
self.log.info('Replacing real buy order with virtual')
self.place_virtual_buy_order(quote_amount, price)
elif success and order['base']['symbol'] == self.market['quote']['symbol']:
quote_amount = order['base']['amount']
price = order['price'] ** -1
self.log.info('Replacing real sell order with virtual')
self.place_virtual_sell_order(quote_amount, price)
else:
return False
def replace_virtual_order_with_real(self, order):
""" Replace virtual order with real one
:param Order | order: market order to replace
:return bool | True = order replace success
False = order replace failed
Logic:
1. Place real order instead of virtual
2. Wait until transaction included in head block
3. Remove existing virtual order
"""
if order['base']['symbol'] == self.market['base']['symbol']:
quote_amount = order['quote']['amount']
price = order['price']
self.log.info('Replacing virtual buy order with real order')
try:
new_order = self.place_market_buy_order(quote_amount, price, returnOrderId=True)
except bitsharesapi.exceptions.RPCError:
self.log.exception('Error broadcasting trx:')
return False
else:
quote_amount = order['base']['amount']
price = order['price'] ** -1
self.log.info('Replacing virtual sell order with real order')
try:
new_order = self.place_market_sell_order(quote_amount, price, returnOrderId=True)
except bitsharesapi.exceptions.RPCError:
self.log.exception('Error broadcasting trx:')
return False
if new_order:
# Cancel virtual order
self.cancel_orders_wrapper(order)
return True
return False
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')
if force:
need_store = True
# If old center price is not set, try fetch from the db
if not self.old_center_price and not force:
old_data = self.get_recent_balance_entry(account, self.worker_name, self.base_asset, self.quote_asset)
if old_data:
self.old_center_price = old_data.center_price
else:
need_store = True
if self.old_center_price and self.market_center_price and not force:
# Check if center price changed more than increment
diff = abs(self.old_center_price - self.market_center_price) / self.old_center_price
if diff > self.increment:
self.log.debug('Center price change is {:.2%}, need to store balance data'.format(diff))
need_store = True
if need_store and self.market_center_price:
timestamp = time.time()
self.log.debug('Storing balance data at center price {:.8f}'.format(self.market_center_price))
self.store_balance_entry(account, self.worker_name, self.base_total_balance, self.base_asset,
self.quote_total_balance, self.quote_asset, self.market_center_price, timestamp)
# Cache center price for later comparisons
self.old_center_price = self.market_center_price
def allocate_asset(self, asset, asset_balance):
""" Allocates available asset balance as buy or sell orders.
:param str | asset: 'base' or 'quote'
:param Amount | asset_balance: Amount of the asset available to use
"""
self.log.debug('Need to allocate {}: {}'.format(asset, asset_balance))
closest_opposite_order = None
closest_opposite_price = 0
opposite_asset_limit = None
opposite_orders = []
order_type = ''
own_asset_limit = None
own_orders = []
own_threshold = 0
own_symbol = ''
own_precision = 0
opposite_precision = 0
opposite_symbol = ''
increase_status = None
if asset == 'base':
order_type = 'buy'
own_symbol = self.base_balance['symbol']
opposite_symbol = self.quote_balance['symbol']
own_orders = self.buy_orders
opposite_orders = self.sell_orders
own_threshold = self.base_asset_threshold
own_precision = self.market['base']['precision']
opposite_precision = self.market['quote']['precision']
elif asset == 'quote':
order_type = 'sell'
own_symbol = self.quote_balance['symbol']
opposite_symbol = self.base_balance['symbol']
own_orders = self.sell_orders
opposite_orders = self.buy_orders
own_threshold = self.quote_asset_threshold
own_precision = self.market['quote']['precision']
opposite_precision = self.market['quote']['precision']
if own_orders:
# Get currently the furthest and closest orders
furthest_own_order = own_orders[-1]
closest_own_order = own_orders[0]
furthest_own_order_price = furthest_own_order['price']
if asset == 'quote':
furthest_own_order_price = furthest_own_order_price ** -1
# Calculate actual spread
if opposite_orders:
closest_opposite_order = opposite_orders[0]
closest_opposite_price = closest_opposite_order['price'] ** -1
elif asset == 'base':
# For one-sided start, calculate closest_opposite_price empirically
closest_opposite_price = self.market_center_price * (1 + self.target_spread / 2)
elif asset == 'quote':
closest_opposite_price = (self.market_center_price / (1 + self.target_spread / 2)) ** -1
closest_own_price = closest_own_order['price']
self.actual_spread = (closest_opposite_price / closest_own_price) - 1
if self.actual_spread >= self.target_spread + self.increment:
if not self.check_partial_fill(closest_own_order, fill_threshold=0):
# Replace closest order if it was partially filled for any %
""" Note on partial filled orders handling: if target spread is not reached and we need to place
closer order, we need to make sure current closest order is 100% unfilled. When target spread is
reached, we are replacing order only if it was filled no less than `self.fill_threshold`. This
helps to avoid too often replacements.
"""
self.replace_partially_filled_order(closest_own_order)
return
if (self['bootstrapping'] and
self.base_balance_history[2] == self.base_balance_history[0] and
self.quote_balance_history[2] == self.quote_balance_history[0] and
opposite_orders):
# Turn off bootstrap mode whether we're didn't allocated assets during previous 3 maintenance
self.log.debug('Turning bootstrapping off: actual_spread > target_spread, we have free '
'balances and cannot allocate them normally 3 times in a row')
self['bootstrapping'] = False
""" Note: because we're using operations batching, there is possible a situation when we will have
both free balances and `self.actual_spread >= self.target_spread + self.increment`. In such case
there will be TWO orders placed, one buy and one sell despite only one would be enough to reach
target spread. Sure, we can add a workaround for that by overriding `closest_opposite_price` for
second call of allocate_asset(). We are not doing this because we're not doing assumption on
which side order (buy or sell) should be placed first. So, when placing two closer orders from
both sides, spread will be no less than `target_spread - increment`, thus not making any loss.
"""
# Place order closer to the center price
self.log.debug('Placing closer {} order; actual spread: {:.4%}, target + increment: {:.4%}'
.format(order_type, self.actual_spread, self.target_spread + self.increment))
if self['bootstrapping']:
self.place_closer_order(asset, closest_own_order)
elif opposite_orders and self.actual_spread - self.increment < self.target_spread + self.increment:
""" Place max-sized closer order if only one order needed to reach target spread (avoid unneeded
increases)
"""
self.place_closer_order(asset, closest_own_order, allow_partial=True)
elif opposite_orders:
# Place order limited by size of the opposite-side order
if self.mode == 'mountain':
opposite_asset_limit = closest_opposite_order['base']['amount'] * (1 + self.increment)
own_asset_limit = None
self.log.debug('Limiting {} order by opposite order: {:.{prec}f} {}'.format(
order_type, opposite_asset_limit, opposite_symbol, prec=opposite_precision))
elif ((self.mode == 'buy_slope' and asset == 'base') or
(self.mode == 'sell_slope' and asset == 'quote')):
opposite_asset_limit = None
own_asset_limit = closest_opposite_order['quote']['amount']
self.log.debug('Limiting {} order by opposite order: {:.{prec}f} {}'
.format(order_type, own_asset_limit, own_symbol, prec=own_precision))
elif self.mode == 'neutral':
opposite_asset_limit = closest_opposite_order['base']['amount'] * \
math.sqrt(1 + self.increment)
own_asset_limit = None
self.log.debug('Limiting {} order by opposite order: {:.{prec}f} {}'.format(
order_type, opposite_asset_limit, opposite_symbol, prec=opposite_precision))
elif (self.mode == 'valley' or
(self.mode == 'buy_slope' and asset == 'quote') or
(self.mode == 'sell_slope' and asset == 'base')):
opposite_asset_limit = closest_opposite_order['base']['amount']
own_asset_limit = None
self.log.debug('Limiting {} order by opposite order: {:.{prec}f} {}'.format(
order_type, opposite_asset_limit, opposite_symbol, prec=opposite_precision))
allow_partial = True if asset == 'quote' else False
self.place_closer_order(asset, closest_own_order, own_asset_limit=own_asset_limit,
opposite_asset_limit=opposite_asset_limit, allow_partial=allow_partial)
else:
# Opposite side probably reached range bound, allow to place partial order
self.place_closer_order(asset, closest_own_order, allow_partial=True)
# Store balance data whether new actual spread will match target spread
if self.actual_spread + self.increment >= self.target_spread and not self.bitshares.txbuffer.is_empty():
# Transactions are not yet sent, so balance refresh is not needed
self.store_profit_estimation_data(force=True)
elif not opposite_orders:
# Do not try to do anything than placing closer order whether there is no opposite orders
return
else:
# Target spread is reached, let's allocate remaining funds
if not self.check_partial_fill(closest_own_order, fill_threshold=0):
""" Detect partially filled order on the own side and reserve funds to replace order in case
opposite order will be fully filled.
"""
funds_to_reserve = closest_own_order['base']['amount']
self.log.debug('Partially filled order on own side, reserving funds to replace: '
'{:.{prec}f} {}'.format(funds_to_reserve, own_symbol, prec=own_precision))
asset_balance -= funds_to_reserve
if not self.check_partial_fill(closest_opposite_order, fill_threshold=0):
""" Detect partially filled order on the opposite side and reserve appropriate amount to place
closer order. We adding some additional reserve to be able to place next order whether
new allocation round will be started, this is mostly for valley-like modes.
"""
funds_to_reserve = 0
additional_reserve = max(1 + self.increment, self.min_increase_factor) * 1.05
closer_own_order = self.place_closer_order(asset, closest_own_order, place_order=False)
if asset == 'base':
funds_to_reserve = closer_own_order['amount'] * closer_own_order['price'] * additional_reserve
elif asset == 'quote':
funds_to_reserve = closer_own_order['amount'] * additional_reserve
self.log.debug('Partially filled order on opposite side, reserving funds for next {} order: '
'{:.{prec}f} {}'.format(order_type, funds_to_reserve, own_symbol,
prec=own_precision))
asset_balance -= funds_to_reserve
if asset_balance > own_threshold:
# Allocate excess funds
if ((asset == 'base' and furthest_own_order_price /
(1 + self.increment) < self.lower_bound) or
(asset == 'quote' and furthest_own_order_price *
(1 + self.increment) > self.upper_bound)):
# Lower/upper bound has been reached and now will start allocating rest of the balance.
self['bootstrapping'] = False
self.log.debug('Increasing sizes of {} orders'.format(order_type))
increase_status = self.increase_order_sizes(asset, asset_balance, own_orders)
else:
# Range bound is not reached, we need to add additional orders at the extremes
self['bootstrapping'] = False
self.log.debug('Placing further order than current furthest {} order'.format(order_type))
self.place_further_order(asset, furthest_own_order, allow_partial=True)
else:
increase_status = 'done'
if (increase_status == 'done' and not self.check_partial_fill(closest_own_order)
and not self.check_partial_fill(closest_opposite_order, fill_threshold=0)):
""" Replace partially filled closest orders only when allocation of excess funds was finished. This
would prevent an abuse case when we are operating inactive market. An attacker can massively dump
the price and then he can buy back the asset cheaper. Similar case may happen on the "normal" market
on significant price drops or spikes.
The logic how it works is following:
1. If we have partially filled closest orders, reserve fuds to replace them later
2. If we have excess funds, allocate them by increasing order sizes or expand bounds if needed
3. When increase is finished, replace partially filled closest orders
Thus we are don't need to precisely count how much was filled on closest orders.
"""
# Refresh balances to make "reserved" funds available
self.refresh_balances(use_cached_orders=True)
self.replace_partially_filled_order(closest_own_order)
elif (increase_status == 'done' and not self.check_partial_fill(closest_opposite_order, fill_threshold=(
1 - self.partial_fill_threshold))):
# Dust order on opposite side, cancel dust order and place closer order
# Require empty txbuffer to avoid rare condition when order may be already canceled from
# replace_partially_filled_order() call.
# Note: we cannot use such check for own side because we will not have the balance to allocate
self.log.info('Cancelling dust order at opposite side, placing closer {} order'.format(order_type))
previous_bundle = self.bitshares.bundle
self.bitshares.bundle = False
self.cancel_orders_wrapper(closest_opposite_order)
self.refresh_balances(use_cached_orders=True)
self.place_closer_order(asset, closest_own_order, allow_partial=True)
self.refresh_orders()
self.bitshares.bundle = previous_bundle
else:
# Place furthest order as close to the bound as possible
if not opposite_orders:
self['bootstrapping'] = True
self.drop_initial_orders()
order = None
self.log.debug('Placing first {} order'.format(order_type))
if asset == 'base':
order = self.place_lowest_buy_order(asset_balance)
elif asset == 'quote':
order = self.place_highest_sell_order(asset_balance)
# Place all virtual orders at once
while isinstance(order, VirtualOrder):
order = self.place_closer_order(asset, order)
# Get latest orders only when we are not bundling operations
if self.returnOrderId:
self.refresh_orders()
def _increase_single_order(self, asset, asset_balance, order, new_order_amount):
""" To avoid code doubling, use this unified function to increase single order
:param str asset: 'base' or 'quote', depending if checking sell or buy
:param Amount asset_balance: asset balance available for increase
:param order order: order needed to be increased
:param float new_order_amount: BASE or QUOTE amount of a new order (depending on asset)
:return: True = available funds were allocated, cannot allocate remainder
False = not all funds were allocated, can increase more orders next time
:rtype: bool
"""
quote_amount = 0
base_amount = 0
price = 0
order_amount = order['base']['amount']
order_type = ''
symbol = ''
precision = 0
if asset == 'quote':
order_type = 'sell'
symbol = self.market['quote']['symbol']
precision = self.market['quote']['precision']
price = order['price'] ** -1
# New order amount must be at least x2 precision bigger
new_order_amount = max(
new_order_amount, order['base']['amount'] + 2 * 10 ** -self.market['quote']['precision']
)
quote_amount = new_order_amount
base_amount = quote_amount * price
elif asset == 'base':
order_type = 'buy'
symbol = self.market['base']['symbol']
precision = self.market['base']['precision']
price = order['price']
# New order amount must be at least x2 precision bigger
new_order_amount = max(
new_order_amount, order['base']['amount'] + 2 * 10 ** -self.market['base']['precision']
)
base_amount = new_order_amount
quote_amount = base_amount / price
needed_balance = new_order_amount - order['for_sale']['amount']
if asset_balance < needed_balance:
# Balance should be enough to replace partially filled order
self.log.debug(
'Not enough balance to increase {} order at price {:.8f}: {:.{prec}f}/{:.{prec}f} {}'.format(
order_type, price, asset_balance['amount'], needed_balance, symbol, prec=precision
)
)
# Increase finished
return True
self.log.debug(
'Pre-increasing {} order at price {:.8f} from {:.{prec}f} to {:.{prec}f} {}'.format(
order_type, price, order_amount, new_order_amount, symbol, prec=precision
)
)
if asset == 'quote':
order['base']['amount'] = quote_amount
order['for_sale']['amount'] += needed_balance
order['quote']['amount'] = base_amount
asset_balance -= quote_amount - order_amount
elif asset == 'base':
order['base']['amount'] = base_amount
order['for_sale']['amount'] += needed_balance
order['quote']['amount'] = quote_amount
asset_balance -= base_amount - order_amount