-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathBSE_improved.py
3658 lines (3027 loc) · 160 KB
/
BSE_improved.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
# -*- coding: utf-8 -*-
#
# BSE: The Bristol Stock Exchange
#
# Version 1.7; September 2022 added PRDE
# Version 1.6; September 2021 added PRSH
# Version 1.5; 02 Jan 2021 -- was meant to be the final version before switch to BSE2.x, but that didn't happen :-)
# Version 1.4; 26 Oct 2020 -- change to Python 3.x
# Version 1.3; July 21st, 2018 (Python 2.x)
# Version 1.2; November 17th, 2012 (Python 2.x)
#
# Copyright (c) 2012-2022, Dave Cliff
#
#
# ------------------------
#
# MIT Open-Source License:
# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
# associated documentation files (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
# and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all copies or substantial
# portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
# LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
#
# ------------------------
#
#
#
# BSE is a very simple simulation of automated execution traders
# operating on a very simple model of a limit order book (LOB) exchange
#
# major simplifications in this version:
# (a) only one financial instrument being traded
# (b) traders can only trade contracts of size 1 (will add variable quantities later)
# (c) each trader can have max of one order per single orderbook.
# (d) traders can replace/overwrite earlier orders, and/or can cancel
# (d) simply processes each order in sequence and republishes LOB to all traders
# => no issues with exchange processing latency/delays or simultaneously issued orders.
#
# NB this code has been written to be readable/intelligible, not efficient!
import sys
import math
import random
import time as chrono
import numpy as np
from scipy.stats import cauchy
# a bunch of system constants (globals)
bse_sys_minprice = 1 # minimum price in the system, in cents/pennies
bse_sys_maxprice = 500 # maximum price in the system, in cents/pennies
# ticksize should be a param of an exchange (so different exchanges have different ticksizes)
# todo: change this, so ticksize no longer global.
ticksize = 1 # minimum change in price, in cents/pennies
# an Order/quote has a trader id, a type (buy/sell) price, quantity, timestamp, and unique i.d.
class Order:
def __init__(self, tid, otype, price, qty, time, qid):
self.tid = tid # trader i.d.
self.otype = otype # order type
self.price = price # price
self.qty = qty # quantity
self.time = time # timestamp
self.qid = qid # quote i.d. (unique to each quote)
def __str__(self):
return '[%s %s P=%03d Q=%s T=%5.2f QID:%d]' % \
(self.tid, self.otype, self.price, self.qty, self.time, self.qid)
# Orderbook_half is one side of the book: a list of bids or a list of asks, each sorted best-first
class Orderbook_half:
def __init__(self, booktype, worstprice):
# booktype: bids or asks?
self.booktype = booktype
# dictionary of orders received, indexed by Trader ID
self.orders = {}
# limit order book, dictionary indexed by price, with order info
self.lob = {}
# anonymized LOB, lists, with only price/qty info
self.lob_anon = []
# summary stats
self.best_price = None
self.best_tid = None
self.worstprice = worstprice
self.session_extreme = None # most extreme price quoted in this session
self.n_orders = 0 # how many orders?
self.lob_depth = 0 # how many different prices on lob?
def anonymize_lob(self):
# anonymize a lob, strip out order details, format as a sorted list
# NB for asks, the sorting should be reversed
self.lob_anon = []
for price in sorted(self.lob):
qty = self.lob[price][0]
self.lob_anon.append([price, qty])
def build_lob(self):
lob_verbose = False
# take a list of orders and build a limit-order-book (lob) from it
# NB the exchange needs to know arrival times and trader-id associated with each order
# returns lob as a dictionary (i.e., unsorted)
# also builds anonymized version (just price/quantity, sorted, as a list) for publishing to traders
self.lob = {}
for tid in self.orders:
order = self.orders.get(tid)
price = order.price
if price in self.lob:
# update existing entry
qty = self.lob[price][0]
orderlist = self.lob[price][1]
orderlist.append([order.time, order.qty, order.tid, order.qid])
self.lob[price] = [qty + order.qty, orderlist]
else:
# create a new dictionary entry
self.lob[price] = [order.qty, [[order.time, order.qty, order.tid, order.qid]]]
# create anonymized version
self.anonymize_lob()
# record best price and associated trader-id
if len(self.lob) > 0:
if self.booktype == 'Bid':
self.best_price = self.lob_anon[-1][0]
else:
self.best_price = self.lob_anon[0][0]
self.best_tid = self.lob[self.best_price][1][0][2]
else:
self.best_price = None
self.best_tid = None
if lob_verbose:
print(self.lob)
def book_add(self, order):
# add order to the dictionary holding the list of orders
# either overwrites old order from this trader
# or dynamically creates new entry in the dictionary
# so, max of one order per trader per list
# checks whether length or order list has changed, to distinguish addition/overwrite
# print('book_add > %s %s' % (order, self.orders))
# if this is an ask, does the price set a new extreme-high record?
if (self.booktype == 'Ask') and ((self.session_extreme is None) or (order.price > self.session_extreme)):
self.session_extreme = int(order.price)
# add the order to the book
n_orders = self.n_orders
self.orders[order.tid] = order
self.n_orders = len(self.orders)
self.build_lob()
# print('book_add < %s %s' % (order, self.orders))
if n_orders != self.n_orders:
return 'Addition'
else:
return 'Overwrite'
def book_del(self, order):
# delete order from the dictionary holding the orders
# assumes max of one order per trader per list
# checks that the Trader ID does actually exist in the dict before deletion
# print('book_del %s',self.orders)
if self.orders.get(order.tid) is not None:
del (self.orders[order.tid])
self.n_orders = len(self.orders)
self.build_lob()
# print('book_del %s', self.orders)
def delete_best(self):
# delete order: when the best bid/ask has been hit, delete it from the book
# the TraderID of the deleted order is return-value, as counterparty to the trade
best_price_orders = self.lob[self.best_price]
best_price_qty = best_price_orders[0]
best_price_counterparty = best_price_orders[1][0][2]
if best_price_qty == 1:
# here the order deletes the best price
del (self.lob[self.best_price])
del (self.orders[best_price_counterparty])
self.n_orders = self.n_orders - 1
if self.n_orders > 0:
if self.booktype == 'Bid':
self.best_price = max(self.lob.keys())
else:
self.best_price = min(self.lob.keys())
self.lob_depth = len(self.lob.keys())
else:
self.best_price = self.worstprice
self.lob_depth = 0
else:
# best_bid_qty>1 so the order decrements the quantity of the best bid
# update the lob with the decremented order data
self.lob[self.best_price] = [best_price_qty - 1, best_price_orders[1][1:]]
# update the bid list: counterparty's bid has been deleted
del (self.orders[best_price_counterparty])
self.n_orders = self.n_orders - 1
self.build_lob()
return best_price_counterparty
# Orderbook for a single instrument: list of bids and list of asks
class Orderbook(Orderbook_half):
def __init__(self):
self.bids = Orderbook_half('Bid', bse_sys_minprice)
self.asks = Orderbook_half('Ask', bse_sys_maxprice)
self.tape = []
self.tape_length = 10000 # max number of events on tape (so we can do millions of orders without crashing)
self.quote_id = 0 # unique ID code for each quote accepted onto the book
self.lob_string = '' # character-string linearization of public lob items with nonzero quantities
# Exchange's internal orderbook
class Exchange(Orderbook):
def add_order(self, order, verbose):
# add a quote/order to the exchange and update all internal records; return unique i.d.
order.qid = self.quote_id
self.quote_id = order.qid + 1
# if verbose : print('QUID: order.quid=%d self.quote.id=%d' % (order.qid, self.quote_id))
if order.otype == 'Bid':
response = self.bids.book_add(order)
best_price = self.bids.lob_anon[-1][0]
self.bids.best_price = best_price
self.bids.best_tid = self.bids.lob[best_price][1][0][2]
else:
response = self.asks.book_add(order)
best_price = self.asks.lob_anon[0][0]
self.asks.best_price = best_price
self.asks.best_tid = self.asks.lob[best_price][1][0][2]
return [order.qid, response]
def del_order(self, time, order, verbose):
# delete a trader's quot/order from the exchange, update all internal records
if order.otype == 'Bid':
self.bids.book_del(order)
if self.bids.n_orders > 0:
best_price = self.bids.lob_anon[-1][0]
self.bids.best_price = best_price
self.bids.best_tid = self.bids.lob[best_price][1][0][2]
else: # this side of book is empty
self.bids.best_price = None
self.bids.best_tid = None
cancel_record = {'type': 'Cancel', 'time': time, 'order': order}
self.tape.append(cancel_record)
# NB this just throws away the older items on the tape -- could instead dump to disk
# right-truncate the tape so it keeps only the most recent items
self.tape = self.tape[-self.tape_length:]
elif order.otype == 'Ask':
self.asks.book_del(order)
if self.asks.n_orders > 0:
best_price = self.asks.lob_anon[0][0]
self.asks.best_price = best_price
self.asks.best_tid = self.asks.lob[best_price][1][0][2]
else: # this side of book is empty
self.asks.best_price = None
self.asks.best_tid = None
cancel_record = {'type': 'Cancel', 'time': time, 'order': order}
self.tape.append(cancel_record)
# NB this just throws away the older items on the tape -- could instead dump to disk
# right-truncate the tape so it keeps only the most recent items
self.tape = self.tape[-self.tape_length:]
else:
# neither bid nor ask?
sys.exit('bad order type in del_quote()')
def process_order2(self, time, order, verbose):
# receive an order and either add it to the relevant LOB (ie treat as limit order)
# or if it crosses the best counterparty offer, execute it (treat as a market order)
oprice = order.price
counterparty = None
[qid, response] = self.add_order(order, verbose) # add it to the order lists -- overwriting any previous order
order.qid = qid
if verbose:
print('QUID: order.quid=%d' % order.qid)
print('RESPONSE: %s' % response)
best_ask = self.asks.best_price
best_ask_tid = self.asks.best_tid
best_bid = self.bids.best_price
best_bid_tid = self.bids.best_tid
if order.otype == 'Bid':
if self.asks.n_orders > 0 and best_bid >= best_ask:
# bid lifts the best ask
if verbose:
print("Bid $%s lifts best ask" % oprice)
counterparty = best_ask_tid
price = best_ask # bid crossed ask, so use ask price
if verbose:
print('counterparty, price', counterparty, price)
# delete the ask just crossed
self.asks.delete_best()
# delete the bid that was the latest order
self.bids.delete_best()
elif order.otype == 'Ask':
if self.bids.n_orders > 0 and best_ask <= best_bid:
# ask hits the best bid
if verbose:
print("Ask $%s hits best bid" % oprice)
# remove the best bid
counterparty = best_bid_tid
price = best_bid # ask crossed bid, so use bid price
if verbose:
print('counterparty, price', counterparty, price)
# delete the bid just crossed, from the exchange's records
self.bids.delete_best()
# delete the ask that was the latest order, from the exchange's records
self.asks.delete_best()
else:
# we should never get here
sys.exit('process_order() given neither Bid nor Ask')
# NB at this point we have deleted the order from the exchange's records
# but the two traders concerned still have to be notified
if verbose:
print('counterparty %s' % counterparty)
if counterparty is not None:
# process the trade
if verbose: print('>>>>>>>>>>>>>>>>>TRADE t=%010.3f $%d %s %s' % (time, price, counterparty, order.tid))
transaction_record = {'type': 'Trade',
'time': time,
'price': price,
'party1': counterparty,
'party2': order.tid,
'qty': order.qty
}
self.tape.append(transaction_record)
# NB this just throws away the older items on the tape -- could instead dump to disk
# right-truncate the tape so it keeps only the most recent items
self.tape = self.tape[-self.tape_length:]
return transaction_record
else:
return None
# Currently tape_dump only writes a list of transactions (ignores cancellations)
def tape_dump(self, fname, fmode, tmode):
dumpfile = open(fname, fmode)
# dumpfile.write('type, time, price\n')
for tapeitem in self.tape:
if tapeitem['type'] == 'Trade':
dumpfile.write('Trd, %010.3f, %s\n' % (tapeitem['time'], tapeitem['price']))
dumpfile.close()
if tmode == 'wipe':
self.tape = []
# this returns the LOB data "published" by the exchange,
# i.e., what is accessible to the traders
def publish_lob(self, time, lob_file, verbose):
public_data = {}
public_data['time'] = time
public_data['bids'] = {'best': self.bids.best_price,
'worst': self.bids.worstprice,
'n': self.bids.n_orders,
'lob': self.bids.lob_anon}
public_data['asks'] = {'best': self.asks.best_price,
'worst': self.asks.worstprice,
'sess_hi': self.asks.session_extreme,
'n': self.asks.n_orders,
'lob': self.asks.lob_anon}
public_data['QID'] = self.quote_id
public_data['tape'] = self.tape
if lob_file is not None:
# build a linear character-string summary of only those prices on LOB with nonzero quantities
lobstring ='Bid:,'
n_bids = len(self.bids.lob_anon)
if n_bids > 0:
lobstring += '%d,' % n_bids
for lobitem in self.bids.lob_anon:
price_str = '%d,' % lobitem[0]
qty_str = '%d,' % lobitem[1]
lobstring = lobstring + price_str + qty_str
else:
lobstring += '0,'
lobstring += 'Ask:,'
n_asks = len(self.asks.lob_anon)
if n_asks > 0:
lobstring += '%d,' % n_asks
for lobitem in self.asks.lob_anon:
price_str = '%d,' % lobitem[0]
qty_str = '%d,' % lobitem[1]
lobstring = lobstring + price_str + qty_str
else:
lobstring += '0,'
# is this different to the last lob_string?
if lobstring != self.lob_string:
# write it
lob_file.write('%.3f, %s\n' % (time, lobstring))
# remember it
self.lob_string = lobstring
if verbose:
print('publish_lob: t=%d' % time)
print('BID_lob=%s' % public_data['bids']['lob'])
# print('best=%s; worst=%s; n=%s ' % (self.bids.best_price, self.bids.worstprice, self.bids.n_orders))
print('ASK_lob=%s' % public_data['asks']['lob'])
# print('qid=%d' % self.quote_id)
return public_data
##################--Traders below here--#############
# Trader superclass
# all Traders have a trader id, bank balance, blotter, and list of orders to execute
class Trader:
def __init__(self, ttype, tid, balance, params, time):
self.ttype = ttype # what type / strategy this trader is
self.tid = tid # trader unique ID code
self.balance = balance # money in the bank
self.params = params # parameters/extras associated with this trader-type or individual trader.
self.blotter = [] # record of trades executed
self.blotter_length = 100 # maximum length of blotter
self.orders = [] # customer orders currently being worked (fixed at 1)
self.n_quotes = 0 # number of quotes live on LOB
self.birthtime = time # used when calculating age of a trader/strategy
self.profitpertime = 0 # profit per unit time
self.n_trades = 0 # how many trades has this trader done?
self.lastquote = None # record of what its last quote was
def __str__(self):
return '[TID %s type %s balance %s blotter %s orders %s n_trades %s profitpertime %s]' \
% (self.tid, self.ttype, self.balance, self.blotter, self.orders, self.n_trades, self.profitpertime)
def add_order(self, order, verbose):
# in this version, trader has at most one order,
# if allow more than one, this needs to be self.orders.append(order)
if self.n_quotes > 0:
# this trader has a live quote on the LOB, from a previous customer order
# need response to signal cancellation/withdrawal of that quote
response = 'LOB_Cancel'
else:
response = 'Proceed'
self.orders = [order]
if verbose:
print('add_order < response=%s' % response)
return response
def del_order(self, order):
# this is lazy: assumes each trader has only one customer order with quantity=1, so deleting sole order
self.orders = []
def bookkeep(self, trade, order, verbose, time):
outstr = ""
for order in self.orders:
outstr = outstr + str(order)
self.blotter.append(trade) # add trade record to trader's blotter
self.blotter = self.blotter[-self.blotter_length:] # right-truncate to keep to length
# NB What follows is **LAZY** -- assumes all orders are quantity=1
transactionprice = trade['price']
if self.orders[0].otype == 'Bid':
profit = self.orders[0].price - transactionprice
else:
profit = transactionprice - self.orders[0].price
self.balance += profit
self.n_trades += 1
self.profitpertime = self.balance / (time - self.birthtime)
if profit < 0:
print(profit)
print(trade)
print(order)
sys.exit('FAIL: negative profit')
if verbose: print('%s profit=%d balance=%d profit/time=%d' % (outstr, profit, self.balance, self.profitpertime))
self.del_order(order) # delete the order
# specify how trader responds to events in the market
# this is a null action, expect it to be overloaded by specific algos
def respond(self, time, lob, trade, verbose):
return None
# specify how trader mutates its parameter values
# this is a null action, expect it to be overloaded by specific algos
def mutate(self, time, lob, trade, verbose):
return None
# Trader subclass Giveaway
# even dumber than a ZI-U: just give the deal away
# (but never makes a loss)
class Trader_Giveaway(Trader):
def getorder(self, time, countdown, lob):
if len(self.orders) < 1:
order = None
else:
quoteprice = self.orders[0].price
order = Order(self.tid,
self.orders[0].otype,
quoteprice,
self.orders[0].qty,
time, lob['QID'])
self.lastquote = order
return order
# Trader subclass ZI-C
# After Gode & Sunder 1993
class Trader_ZIC(Trader):
def getorder(self, time, countdown, lob):
if len(self.orders) < 1:
# no orders: return NULL
order = None
else:
minprice = lob['bids']['worst']
maxprice = lob['asks']['worst']
qid = lob['QID']
limit = self.orders[0].price
otype = self.orders[0].otype
if otype == 'Bid':
quoteprice = random.randint(minprice, limit)
else:
quoteprice = random.randint(limit, maxprice)
# NB should check it == 'Ask' and barf if not
order = Order(self.tid, otype, quoteprice, self.orders[0].qty, time, qid)
self.lastquote = order
return order
# Trader subclass Shaver
# shaves a penny off the best price
# if there is no best price, creates "stub quote" at system max/min
class Trader_Shaver(Trader):
def getorder(self, time, countdown, lob):
if len(self.orders) < 1:
order = None
else:
limitprice = self.orders[0].price
otype = self.orders[0].otype
if otype == 'Bid':
if lob['bids']['n'] > 0:
quoteprice = lob['bids']['best'] + 1
if quoteprice > limitprice:
quoteprice = limitprice
else:
quoteprice = lob['bids']['worst']
else:
if lob['asks']['n'] > 0:
quoteprice = lob['asks']['best'] - 1
if quoteprice < limitprice:
quoteprice = limitprice
else:
quoteprice = lob['asks']['worst']
order = Order(self.tid, otype, quoteprice, self.orders[0].qty, time, lob['QID'])
self.lastquote = order
return order
# Trader subclass Sniper
# Based on Shaver,
# "lurks" until time remaining < threshold% of the trading session
# then gets increasing aggressive, increasing "shave thickness" as time runs out
class Trader_Sniper(Trader):
def getorder(self, time, countdown, lob):
lurk_threshold = 0.2
shavegrowthrate = 3
shave = int(1.0 / (0.01 + countdown / (shavegrowthrate * lurk_threshold)))
if (len(self.orders) < 1) or (countdown > lurk_threshold):
order = None
else:
limitprice = self.orders[0].price
otype = self.orders[0].otype
if otype == 'Bid':
if lob['bids']['n'] > 0:
quoteprice = lob['bids']['best'] + shave
if quoteprice > limitprice:
quoteprice = limitprice
else:
quoteprice = lob['bids']['worst']
else:
if lob['asks']['n'] > 0:
quoteprice = lob['asks']['best'] - shave
if quoteprice < limitprice:
quoteprice = limitprice
else:
quoteprice = lob['asks']['worst']
order = Order(self.tid, otype, quoteprice, self.orders[0].qty, time, lob['QID'])
self.lastquote = order
return order
# Trader subclass PRZI (ticker: PRSH)
# added 6 Sep 2022 -- replaces old PRZI and PRZI_SHC, unifying them into one function and also adding PRDE
#
# Dave Cliff's Parameterized-Response Zero-Intelligence (PRZI) trader -- pronounced "prezzie"
# but with added adaptive strategies, currently either...
# ++ a k-point Stochastic Hill-Climber (SHC) hence PRZI-SHC,
# PRZI-SHC pronounced "prezzy-shuck". Ticker symbol PRSH pronounced "purrsh";
# or
# ++ a simple differential evolution (DE) optimizer with pop_size=k, hence PRZE-DE or PRDE ('purdy")
#
# when optimizer == None then it implements plain-vanilla non-adaptive PRZI, with a fixed strategy-value.
class Trader_PRZI(Trader):
# how to mutate the strategy values when evolving / hill-climbing
def mutate_strat(self, s, mode):
s_min = self.strat_range_min
s_max = self.strat_range_max
if mode == 'gauss':
sdev = 0.05
newstrat = s
while newstrat == s:
newstrat = s + random.gauss(0.0, sdev)
# truncate to keep within range
newstrat = max(-1.0, min(1.0, newstrat))
elif mode == 'uniform_whole_range':
# draw uniformly from whole range
newstrat = random.uniform(-1.0, +1.0)
elif mode == 'uniform_bounded_range':
# draw uniformly from bounded range
newstrat = random.uniform(s_min, s_max)
else:
sys.exit('FAIL: bad mode in mutate_strat')
return newstrat
def strat_str(self):
# pretty-print a string summarising this trader's strategies
string = '%s: %s active_strat=[%d]:\n' % (self.tid, self.ttype, self.active_strat)
for s in range(0, self.k):
strat = self.strats[s]
stratstr = '[%d]: s=%+f, start=%f, $=%f, pps=%f\n' % \
(s, strat['stratval'], strat['start_t'], strat['profit'], strat['pps'])
string = string + stratstr
return string
def __init__(self, ttype, tid, balance, params, time):
# if params == "landscape-mapper" then it generates data for mapping the fitness landscape
verbose = False
Trader.__init__(self, ttype, tid, balance, params, time)
# unpack the params
# for all three of PRZI, PRSH, and PRDE params can include strat_min and strat_max
# for PRSH and PRDE params should include values for optimizer and k
# if no params specified then defaults to PRZI with strat values in [-1.0,+1.0]
# default parameter values
k = 1
f_value = 0.8
optimizer = None # no optimizer => plain non-adaptive PRZI
s_min = -1.0
s_max = +1.0
# did call provide different params?
if type(params) is dict:
if 'k' in params:
k = params['k']
if 'optimizer' in params:
optimizer = params['optimizer']
if 'f_value' in params:
f_value = params['f_value']
s_min = params['strat_min']
s_max = params['strat_max']
self.optmzr = optimizer # this determines whether it's PRZI, PRSH, or PRDE
self.k = k # number of sampling points (cf number of arms on a multi-armed-bandit, or pop-size)
self.f_value = f_value
self.theta0 = 100 # threshold-function limit value
self.m = 4 # tangent-function multiplier
self.strat_wait_time = 7200 # how many secs do we give any one strat before switching?
self.strat_range_min = s_min # lower-bound on randomly-assigned strategy-value
self.strat_range_max = s_max # upper-bound on randomly-assigned strategy-value
self.active_strat = 0 # which of the k strategies are we currently playing? -- start with 0
self.prev_qid = None # previous order i.d.
self.strat_eval_time = self.k * self.strat_wait_time # time to cycle through evaluating all k strategies
self.last_strat_change_time = time # what time did we last change strategies?
self.profit_epsilon = 0.0 * random.random() # minimum profit-per-sec difference between strategies that counts
self.strats = [] # strategies awaiting initialization
self.pmax = None # this trader's estimate of the maximum price the market will bear
self.pmax_c_i = math.sqrt(random.randint(1,10)) # multiplier coefficient when estimating p_max
self.mapper_outfile = None
# differential evolution parameters all in one dictionary
self.diffevol = {'de_state': 'active_s0', # initial state: strategy 0 is active (being evaluated)
's0_index': self.active_strat, # s0 starts out as active strat
'snew_index': self.k, # (k+1)th item of strategy list is DE's new strategy
'snew_stratval': None, # assigned later
'F': self.f_value # differential weight -- usually between 0 and 2
}
start_time = time
profit = 0.0
profit_per_second = 0
lut_bid = None
lut_ask = None
for s in range(self.k + 1):
# initialise each of the strategies in sequence:
# for PRZI: only one strategy is needed
# for PRSH, one random initial strategy, then k-1 mutants of that initial strategy
# for PRDE, use draws from uniform distbn over whole range and a (k+1)th strategy is needed to hold s_new
if s == 0:
strategy = random.uniform(self.strat_range_min, self.strat_range_max)
else:
if self.optmzr == 'PRSH':
# simple stochastic hill climber: cluster other strats around strat_0
strategy = self.mutate_strat(self.strats[0]['stratval'], 'gauss') # mutant of strats[0]
elif self.optmzr == 'PRDE':
# differential evolution: seed initial strategies across whole space
strategy = self.mutate_strat(self.strats[0]['stratval'], 'uniform_bounded_range')
else:
# PRZI -- do nothing
pass
self.strats.append({'stratval': strategy, 'start_t': start_time,
'profit': profit, 'pps': profit_per_second, 'lut_bid': lut_bid, 'lut_ask': lut_ask})
if self.optmzr is None:
# PRZI -- so we stop after one iteration
break
elif self.optmzr == 'PRSH' and s == self.k - 1:
# PRSH -- doesn't need the (k+1)th strategy
break
if self.params == 'landscape-mapper':
# replace seed+mutants set of strats with regularly-spaced strategy values over the whole range
self.strats = []
strategy_delta = 0.01
strategy = -1.0
k = 0
self.strats = []
while strategy <= +1.0:
self.strats.append({'stratval': strategy, 'start_t': start_time,
'profit': profit, 'pps': profit_per_second, 'lut_bid': lut_bid, 'lut_ask': lut_ask})
k += 1
strategy += strategy_delta
self.mapper_outfile = open('landscape_map.csv', 'w')
self.k = k
self.strat_eval_time = self.k * self.strat_wait_time
if verbose:
print("%s\n" % self.strat_str())
def getorder(self, time, countdown, lob):
# shvr_price tells us what price a SHVR would quote in these circs
def shvr_price(otype, limit, lob):
if otype == 'Bid':
if lob['bids']['n'] > 0:
shvr_p = lob['bids']['best'] + ticksize # BSE ticksize is global var
if shvr_p > limit:
shvr_p = limit
else:
shvr_p = lob['bids']['worst']
else:
if lob['asks']['n'] > 0:
shvr_p = lob['asks']['best'] - ticksize # BSE ticksize is global var
if shvr_p < limit:
shvr_p = limit
else:
shvr_p = lob['asks']['worst']
# print('shvr_p=%f; ' % shvr_p)
return shvr_p
# calculate cumulative distribution function (CDF) look-up table (LUT)
def calc_cdf_lut(strat, t0, m, dirn, pmin, pmax):
# set parameter values and calculate CDF LUT
# strat is strategy-value in [-1,+1]
# t0 and m are constants used in the threshold function
# dirn is direction: 'buy' or 'sell'
# pmin and pmax are bounds on discrete-valued price-range
# the threshold function used to clip
def threshold(theta0, x):
t = max(-1*theta0, min(theta0, x))
return t
epsilon = 0.000001 #used to catch DIV0 errors
verbose = False
if (strat > 1.0) or (strat < -1.0):
# out of range
sys.exit('PRSH FAIL: strat=%f out of range\n' % strat)
if (dirn != 'buy') and (dirn != 'sell'):
# out of range
sys.exit('PRSH FAIL: bad dirn=%s\n' % dirn)
if pmax < pmin:
# screwed
sys.exit('PRSH FAIL: pmax %f < pmin %f \n' % (pmax, pmin))
if verbose:
print('PRSH calc_cdf_lut: strat=%f dirn=%d pmin=%d pmax=%d\n' % (strat, dirn, pmin, pmax))
p_range = float(pmax - pmin)
if p_range < 1:
# special case: the SHVR-style strategy has shaved all the way to the limit price
# the lower and upper bounds on the interval are adjacent prices;
# so cdf is simply the limit-price with probability 1
if dirn == 'buy':
cdf = [{'price':pmax, 'cum_prob': 1.0}]
else: # must be a sell
cdf = [{'price': pmin, 'cum_prob': 1.0}]
if verbose:
print('\n\ncdf:', cdf)
return {'strat': strat, 'dirn': dirn, 'pmin': pmin, 'pmax': pmax, 'cdf_lut': cdf}
c = threshold(t0, m * math.tan(math.pi * (strat + 0.5)))
# catch div0 errors here
if abs(c) < epsilon:
if c > 0:
c = epsilon
else:
c = -epsilon
e2cm1 = math.exp(c) - 1
# calculate the discrete calligraphic-P function over interval [pmin, pmax]
# (i.e., this is Equation 8 in the PRZI Technical Note)
calp_interval = []
calp_sum = 0
for p in range(pmin, pmax + 1):
# normalize the price to proportion of its range
p_r = (p - pmin) / (p_range) # p_r in [0.0, 1.0]
if strat == 0.0:
# special case: this is just ZIC
cal_p = 1 / (p_range + 1)
elif strat > 0:
if dirn == 'buy':
cal_p = (math.exp(c * p_r) - 1.0) / e2cm1
else: # dirn == 'sell'
cal_p = (math.exp(c * (1 - p_r)) - 1.0) / e2cm1
else: # self.strat < 0
if dirn == 'buy':
cal_p = 1.0 - ((math.exp(c * p_r) - 1.0) / e2cm1)
else: # dirn == 'sell'
cal_p = 1.0 - ((math.exp(c * (1 - p_r)) - 1.0) / e2cm1)
if cal_p < 0:
cal_p = 0 # just in case
calp_interval.append({'price':p, "cal_p":cal_p})
calp_sum += cal_p
if calp_sum <= 0:
print('calp_interval:', calp_interval)
print('pmin=%f, pmax=%f, calp_sum=%f' % (pmin, pmax, calp_sum))
cdf = []
cum_prob = 0
# now go thru interval summing and normalizing to give the CDF
for p in range(pmin, pmax + 1):
cal_p = calp_interval[p-pmin]['cal_p']
prob = cal_p / calp_sum
cum_prob += prob
cdf.append({'price': p, 'cum_prob': cum_prob})
if verbose:
print('\n\ncdf:', cdf)
return {'strat':strat, 'dirn':dirn, 'pmin':pmin, 'pmax':pmax, 'cdf_lut':cdf}
verbose = False
if verbose:
print('t=%.1f PRSH getorder: %s, %s' % (time, self.tid, self.strat_str()))
if len(self.orders) < 1:
# no orders: return NULL
order = None
else:
# unpack the assignment-order
limit = self.orders[0].price
otype = self.orders[0].otype
qid = self.orders[0].qid
if self.prev_qid is None:
self.prev_qid = qid
if qid != self.prev_qid:
# customer-order i.d. has changed, so we're working a new customer-order now
# this is the time to switch arms
# print("New order! (how does it feel?)")
dummy = 1
# get extreme limits on price interval
# lowest price the market will bear
minprice = int(lob['bids']['worst']) # default assumption: worst bid price possible as defined by exchange
# trader's individual estimate highest price the market will bear
maxprice = self.pmax # default assumption
if self.pmax is None:
maxprice = int(limit * self.pmax_c_i + 0.5) # in the absence of any other info, guess
self.pmax = maxprice
elif lob['asks']['sess_hi'] is not None:
if self.pmax < lob['asks']['sess_hi']: # some other trader has quoted higher than I expected
maxprice = lob['asks']['sess_hi'] # so use that as my new estimate of highest
self.pmax = maxprice
# use the cdf look-up table
# cdf_lut is a list of little dictionaries
# each dictionary has form: {'cum_prob':nnn, 'price':nnn}
# generate u=U(0,1) uniform disrtibution
# starting with the lowest nonzero cdf value at cdf_lut[0],
# walk up the lut (i.e., examine higher cumulative probabilities),
# until we're in the range of u; then return the relevant price
strat = self.strats[self.active_strat]['stratval']
# what price would a SHVR quote?
p_shvr = shvr_price(otype, limit, lob)
if otype == 'Bid':
p_max = int(limit)
if strat > 0.0:
p_min = minprice
else:
# shade the lower bound on the interval
# away from minprice and toward shvr_price
p_min = int(0.5 + (-strat * p_shvr) + ((1.0 + strat) * minprice))
lut_bid = self.strats[self.active_strat]['lut_bid']
if (lut_bid is None) or \
(lut_bid['strat'] != strat) or\
(lut_bid['pmin'] != p_min) or \
(lut_bid['pmax'] != p_max):
# need to compute a new LUT
if verbose:
print('New bid LUT')
self.strats[self.active_strat]['lut_bid'] = calc_cdf_lut(strat, self.theta0, self.m, 'buy', p_min, p_max)
lut = self.strats[self.active_strat]['lut_bid']
else: # otype == 'Ask'
p_min = int(limit)
if strat > 0.0:
p_max = maxprice
else:
# shade the upper bound on the interval
# away from maxprice and toward shvr_price
p_max = int(0.5 + (-strat * p_shvr) + ((1.0 + strat) * maxprice))
if p_max < p_min:
# this should never happen, but just in case it does...
p_max = p_min
lut_ask = self.strats[self.active_strat]['lut_ask']
if (lut_ask is None) or \
(lut_ask['strat'] != strat) or \
(lut_ask['pmin'] != p_min) or \
(lut_ask['pmax'] != p_max):
# need to compute a new LUT
if verbose:
print('New ask LUT')
self.strats[self.active_strat]['lut_ask'] = calc_cdf_lut(strat, self.theta0, self.m, 'sell', p_min, p_max)
lut = self.strats[self.active_strat]['lut_ask']
verbose = False
if verbose:
print('PRZI strat=%f LUT=%s \n \n' % (strat, lut))
# useful in debugging: print a table of lut: price and cum_prob, with the discrete derivative (gives PMF).
last_cprob = 0.0
for lut_entry in lut['cdf_lut']: