PyAlgoEngine 0.7.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. PyAlgoEngine-0.7.4.dist-info/LICENSE +21 -0
  2. PyAlgoEngine-0.7.4.dist-info/METADATA +27 -0
  3. PyAlgoEngine-0.7.4.dist-info/RECORD +43 -0
  4. PyAlgoEngine-0.7.4.dist-info/WHEEL +5 -0
  5. PyAlgoEngine-0.7.4.dist-info/top_level.txt +1 -0
  6. algo_engine/__init__.py +41 -0
  7. algo_engine/apps/__init__.py +17 -0
  8. algo_engine/apps/backtest/__init__.py +20 -0
  9. algo_engine/apps/backtest/doc_server.py +331 -0
  10. algo_engine/apps/backtest/tester.py +254 -0
  11. algo_engine/apps/backtest/web_app.py +127 -0
  12. algo_engine/apps/bokeh_server.py +205 -0
  13. algo_engine/apps/demo/__init__.py +0 -0
  14. algo_engine/apps/demo/test.py +39 -0
  15. algo_engine/backtest/__init__.py +19 -0
  16. algo_engine/backtest/__main__.py +51 -0
  17. algo_engine/backtest/metrics.py +179 -0
  18. algo_engine/backtest/replay.py +261 -0
  19. algo_engine/backtest/sim_match.py +295 -0
  20. algo_engine/base/__init__.py +40 -0
  21. algo_engine/base/console_utils.py +1070 -0
  22. algo_engine/base/finance_decimal.py +258 -0
  23. algo_engine/base/market_buffer.py +571 -0
  24. algo_engine/base/market_utils.py +3092 -0
  25. algo_engine/base/market_utils_nt.py +188 -0
  26. algo_engine/base/market_utils_posix.py +3004 -0
  27. algo_engine/base/technical_analysis.py +406 -0
  28. algo_engine/base/telemetrics.py +78 -0
  29. algo_engine/base/trade_utils.py +709 -0
  30. algo_engine/engine/__init__.py +28 -0
  31. algo_engine/engine/algo_engine.py +901 -0
  32. algo_engine/engine/event_engine.py +53 -0
  33. algo_engine/engine/market_engine.py +370 -0
  34. algo_engine/engine/trade_engine.py +2037 -0
  35. algo_engine/monitor/__init__.py +15 -0
  36. algo_engine/monitor/advanced_data_interface.py +239 -0
  37. algo_engine/profile/__init__.py +121 -0
  38. algo_engine/profile/cn.py +175 -0
  39. algo_engine/strategy/__init__.py +44 -0
  40. algo_engine/strategy/strategy_engine.py +440 -0
  41. algo_engine/utils/__init__.py +3 -0
  42. algo_engine/utils/commit_regularizer.py +49 -0
  43. algo_engine/utils/data_utils.py +251 -0
@@ -0,0 +1,901 @@
1
+ import abc
2
+ import datetime
3
+ import enum
4
+ import functools
5
+ import json
6
+ import threading
7
+ import uuid
8
+ from typing import Type, TYPE_CHECKING
9
+
10
+ import numpy as np
11
+
12
+ from . import LOGGER
13
+ from .market_engine import MDS
14
+ from ..base import TransactionSide, TradeInstruction, MarketData, TradeReport, OrderState, OrderType
15
+
16
+ LOGGER = LOGGER.getChild('AlgoEngine')
17
+ if TYPE_CHECKING:
18
+ from .trade_engine import DirectMarketAccess
19
+
20
+ __all__ = ['AlgoTemplate', 'AlgoRegistry', 'AlgoEngine', 'ALGO_ENGINE', 'ALGO_REGISTRY']
21
+
22
+
23
+ class AlgoStatus(enum.Enum):
24
+ idle = 'idle' # init state
25
+ preparing = 'preparing' # preparing
26
+ ready = 'ready' # ready to launch order
27
+ working = 'working' # order launched
28
+ done = 'done' # transaction complete!
29
+ closed = 'closed' # transaction failed and close
30
+ stopping = 'stopping' # trying to stop transaction
31
+ rejected = 'rejected' # internal / external rejected
32
+ error = 'error' # internal / external error
33
+
34
+
35
+ class AlgoTemplate(object, metaclass=abc.ABCMeta):
36
+ Status = AlgoStatus
37
+
38
+ def __init__(self, dma: 'DirectMarketAccess', ticker: str, target_volume: float, side: TransactionSide, **kwargs):
39
+ """ Template for trading algorithm
40
+ an abstract class to create a trading algorithm
41
+
42
+ :param dma: direct market access
43
+ :param ticker: the given symbol of the underlying to trade
44
+ :param target_volume: the given volume to trade
45
+ :param side: the given TransactionSide
46
+ :keyword algo_engine: the algo_engine instance, default is ALGO_ENGINE
47
+ :keyword logger: the logger instance, default is LOGGER
48
+ :keyword algo_id: the id of the algo, default is uuid4()
49
+ """
50
+ self.dma = dma
51
+ self.ticker = ticker
52
+ self.side = side
53
+ self.target_volume = target_volume
54
+ self.algo_engine = kwargs.pop('algo_engine', ALGO_ENGINE)
55
+ self.algo_type = kwargs.get('algo_type', self.algo_engine.registry.reversed_registry[self.__class__.__name__])
56
+ self.logger = kwargs.pop('logger', LOGGER)
57
+ self.algo_id = kwargs.pop('algo_id', uuid.uuid4().hex)
58
+
59
+ self.status: AlgoStatus = self.Status.idle
60
+ self._target_progress = 0
61
+ self._lock = threading.Lock()
62
+ self._thread = threading.Thread(target=self.work)
63
+
64
+ self.working_order: dict[str, TradeInstruction] = {}
65
+ self.order: dict[str, TradeInstruction] = {}
66
+
67
+ self.is_active = False
68
+
69
+ self.ts_started = None
70
+ self.ts_finished = None
71
+
72
+ def __repr__(self):
73
+ return f'<TradeAlgo>(ticker={self.ticker}, target={self.side.sign * self.target_volume}, done={self.side.sign * self.exposure_volume}, algo={self.__class__.__name__}, status={self.status.value}, id={id(self)})'
74
+
75
+ def on_sync_progress(self, progress: float, **kwargs):
76
+ self._target_progress = max(min(progress, 1), 0)
77
+ self._sync(progress=progress, **kwargs)
78
+
79
+ def on_market_data(self, market_data: MarketData, **kwargs):
80
+ pass
81
+
82
+ def on_filled(self, report: TradeReport, **kwargs):
83
+ if report.order_id in self.working_order:
84
+ self._filled(order=self.working_order[report.order_id], report=report, **kwargs)
85
+ return 1
86
+ else:
87
+ self.logger.warning(f'[Failed to fill] {self} has no matching for working order {report.order_id}')
88
+ return 0
89
+
90
+ def on_canceled(self, order_id: str = None, **kwargs):
91
+ if order_id in self.working_order:
92
+ self._canceled(order=self.working_order[order_id], **kwargs)
93
+ return 1
94
+ else:
95
+ self.logger.warning(f'[Failed to cancel] {self} has no matching for working order {order_id}')
96
+ return 0
97
+
98
+ def on_rejected(self, order: TradeInstruction, **kwargs):
99
+ if order.order_id in self.working_order:
100
+ self._rejected(order=order, **kwargs)
101
+ return 1
102
+ else:
103
+ self.logger.warning(f'[Failed to reject] {self} has no matching for working order {order.order_id}')
104
+ return 0
105
+
106
+ def recover(self):
107
+ self._update_working_order()
108
+
109
+ if not self.working_volume:
110
+ if self.exposure_volume:
111
+ self._assign_status(status=self.Status.done)
112
+ else:
113
+ self._assign_status(status=self.Status.closed)
114
+ LOGGER.info(f'{self} recovery successful! status {self.status}')
115
+ else:
116
+ LOGGER.warning(f'Caution! Recovering WORKING trade handler {self} may cause unexpected error!')
117
+ self._assign_status(status=self.Status.working)
118
+
119
+ def _update_working_order(self):
120
+ """
121
+ refresh working order, to remove the finished orders
122
+ :return: a dict of working orders
123
+ """
124
+ for order_id in list(self.working_order):
125
+ order = self.working_order.get(order_id)
126
+
127
+ if order is None:
128
+ continue
129
+
130
+ if order.is_done:
131
+ self.working_order.pop(order_id, None)
132
+
133
+ return self.working_order
134
+
135
+ def _assign_status(self, status: Status, timestamp: float = None, **kwargs):
136
+ if not isinstance(status, self.Status):
137
+ raise TypeError(f'Invalid status {status}! Expect {self.Status}, got {type(status)}.')
138
+
139
+ state_0, state_1 = self.status, status
140
+
141
+ if timestamp is None:
142
+ timestamp = self.timestamp
143
+
144
+ if 'market_time' in kwargs:
145
+ LOGGER.warning(DeprecationWarning('Assigning market_time deprecated, use timestamp instead!'))
146
+
147
+ if state_0 == self.Status.idle and state_1 == self.Status.working:
148
+ self.ts_started = timestamp
149
+ elif state_1 == self.Status.done or state_1 == self.Status.closed:
150
+ self.ts_finished = timestamp
151
+
152
+ self.status = state_1
153
+
154
+ def _update_status(self, status=None, sync_pos=True, **kwargs):
155
+ """
156
+ ._update_status provides a method to clear working orders and auto assign status.
157
+ ._update_status DOES NOT call .on_filled, .on_rejected nor .on_canceled, these method is triggered by position management service.
158
+ ._update_status should be called in .on_filled .on_rejected and .on_canceled.
159
+
160
+ as the result of concurrency, assigning status while sync_pos may cause unexpected result, use with caution
161
+
162
+ :param status: the given status
163
+ :param sync_pos: whether to auto-clear working orders
164
+ :param kwargs: market_time to assign the exact time when status is changed, used in backtesting
165
+ """
166
+ if sync_pos:
167
+ self._update_working_order()
168
+
169
+ # if 'market_time' in kwargs:
170
+ # market_time: datetime.datetime = kwargs['market_time']
171
+ # self.ts_started = market_time.timestamp()
172
+
173
+ # assign status with given status
174
+ if status is not None:
175
+ return self._assign_status(status=status)
176
+
177
+ # update status with self info
178
+ if self.working_order:
179
+ if self.status == self.Status.idle:
180
+ self.status = self.Status.working
181
+ self.ts_started = self.timestamp
182
+ else:
183
+ if self.filled_volume == self.target_volume:
184
+ self.status = self.Status.done
185
+ self.ts_finished = self.timestamp
186
+
187
+ return self.status
188
+
189
+ def _launch(self, order, **kwargs):
190
+ self.dma.launch_order(order=order, **kwargs)
191
+ # order launched, order state can be pending, placed, or rejected (by internal on_order risk control)
192
+ # DO NOT assume order state is_working, it may be rejected!
193
+ # DO NOT assume order is in .working_order, it may be rejected!
194
+ # DO NOT assume algo state is working, it may be rejected!
195
+ # therefor calling _update_status is recommended but still optional.
196
+ # self._update_status(sync_pos=False)
197
+
198
+ def _cancel_order(self, order, **kwargs):
199
+ self.dma.cancel_order(order=order, **kwargs)
200
+ # self._update_status(sync_pos=False)
201
+
202
+ def _filled(self, order: TradeInstruction, report: TradeReport, **kwargs):
203
+ """
204
+ callback on order filled / part-filled
205
+
206
+ this callback will REMOVE filled order from working order dict and update algo status
207
+ :param order: the given filled order
208
+ :param kwargs: keyword args for updating status. e.g. timestamp
209
+ """
210
+ if report.trade_id not in order.trades:
211
+ order.fill(trade_report=report)
212
+
213
+ kwargs['sync_pos'] = True
214
+ self._update_status(**kwargs)
215
+
216
+ def _canceled(self, order: TradeInstruction, **kwargs):
217
+ """
218
+ callback on order canceled
219
+
220
+ this callback will REMOVE cancelled order from working order dict and update algo status
221
+ :param order: the given canceled order
222
+ :param kwargs: keyword args for updating status. e.g. timestamp
223
+ """
224
+ self._update_working_order()
225
+
226
+ if self.working_order:
227
+ self._assign_status(status=self.Status.working, **kwargs)
228
+ elif self.exposure_volume:
229
+ self._assign_status(status=self.Status.done, **kwargs)
230
+ else:
231
+ self._assign_status(status=self.Status.closed, **kwargs)
232
+
233
+ def _rejected(self, order: TradeInstruction, **kwargs):
234
+ self._assign_status(status=self.Status.rejected, **kwargs)
235
+
236
+ def _sync(self, progress, **kwargs):
237
+ ...
238
+
239
+ def to_json(self, fmt='str') -> str | dict:
240
+ json_dict = {
241
+ 'algo_type': self.algo_type,
242
+ 'ticker': self.ticker,
243
+ 'side': self.side.name,
244
+ 'target_volume': self.target_volume,
245
+ 'algo_id': self.algo_id,
246
+ 'status': self.status.name,
247
+ 'target_progress': self._target_progress,
248
+ 'ts_started': self.ts_started,
249
+ 'ts_finished': self.ts_finished,
250
+ 'order': {_: self.order[_].to_json(fmt='dict') for _ in self.order},
251
+ }
252
+
253
+ if fmt == 'dict':
254
+ return json_dict
255
+ else:
256
+ return json.dumps(json_dict)
257
+
258
+ def from_json(self, json_str: str | dict):
259
+ if isinstance(json_str, (str, bytes)):
260
+ json_dict = json.loads(json_str)
261
+ elif isinstance(json_str, dict):
262
+ json_dict = json_str
263
+ else:
264
+ raise TypeError(f'Invalid type {type(json_str)}, expect [str, bytes, dict]')
265
+
266
+ self.ticker = json_dict['ticker']
267
+ self.side = TransactionSide(json_dict['side'])
268
+ self.target_volume = json_dict['target_volume']
269
+ self.algo_id = json_dict['algo_id']
270
+ self.status = self.Status[json_dict['status']]
271
+ self._target_progress = json_dict['target_progress']
272
+ self.ts_started = json_dict['ts_started']
273
+ self.ts_finished = json_dict['ts_finished']
274
+ self.order = {_: TradeInstruction.from_json(json_dict['order'][_]) for _ in json_dict['order']}
275
+ self.working_order = {order_id: order for order_id, order in self.order.items() if not order.is_done}
276
+
277
+ return self
278
+
279
+ @abc.abstractmethod
280
+ def work(self):
281
+ ...
282
+
283
+ @abc.abstractmethod
284
+ def launch(self, **kwargs) -> list[TradeInstruction]:
285
+ """
286
+ launch is a method to initiate the algo and launching orders.
287
+ this method will set the algo is_active = true
288
+ this method will set a new algo state, usually idle -> working
289
+ launch method is designed to be called by strategy / position management service.
290
+
291
+ :param kwargs: other keywords needed to launch an algo
292
+ :return: a list of working orders. Noted, that not all working order is returned by this method, for example, TWAP algo will init a sequence of order and return later.
293
+ """
294
+ ...
295
+
296
+ @abc.abstractmethod
297
+ def cancel(self, **kwargs):
298
+ """
299
+ cancel is a method to cancel / stop ALL working orders
300
+ this method will set the algo is_active = false
301
+ this method may set a new algo state, usually working -> stopping
302
+ launch method is designed to be called by strategy / position management service.
303
+
304
+ :param kwargs: other keywords needed to cancel an algo
305
+ :return: None
306
+ """
307
+ ...
308
+
309
+ @property
310
+ def trades(self) -> dict[str, TradeReport]:
311
+ trades = {}
312
+
313
+ for order in list(self.order.values()):
314
+ for trade_id in list(order.trades):
315
+ trade_report = order.trades.get(trade_id)
316
+
317
+ if trade_report is None:
318
+ continue
319
+
320
+ trades[trade_report.trade_id] = trade_report
321
+
322
+ return trades
323
+
324
+ @property
325
+ def average_price(self) -> float:
326
+ adjust_volume = 0.
327
+ notional = 0.
328
+
329
+ for report in list(self.trades.values()):
330
+ if report.price == 0:
331
+ adjust_volume += report.volume
332
+ else:
333
+ adjust_volume += report.notional / report.price
334
+ notional += report.notional
335
+
336
+ if adjust_volume == 0:
337
+ return np.nan
338
+ else:
339
+ return notional / adjust_volume
340
+
341
+ @property
342
+ def exposure_volume(self) -> float:
343
+ """
344
+ <WITH SIGN> net exposed VOLUME indicating the exposure of the pos
345
+ :return: float
346
+ """
347
+ exposure = 0.
348
+
349
+ for report in list(self.trades.values()):
350
+ exposure += report.volume * report.side.sign
351
+
352
+ return exposure
353
+
354
+ @property
355
+ def working_volume(self) -> float:
356
+ """
357
+ <WITHOUT SIGN> net working VOLUME indicating the working status of the pos
358
+ :return: float
359
+ """
360
+ working = 0.
361
+
362
+ for order_id in list(self.working_order):
363
+ working_order = self.working_order.get(order_id)
364
+
365
+ if working_order is None:
366
+ continue
367
+
368
+ working += working_order.working_volume # should be all positive
369
+
370
+ return working
371
+
372
+ @property
373
+ def filled_volume(self) -> float:
374
+ """
375
+ <WITHOUT SIGN> filled VOLUME
376
+ :return: float
377
+ """
378
+ volume = 0.
379
+
380
+ for report in list(self.trades.values()):
381
+ volume += report.volume
382
+
383
+ return volume
384
+
385
+ @property
386
+ def filled_notional(self) -> float:
387
+ """
388
+ <POSSIBLY WITH SIGN> total filled Notional
389
+ :return: float
390
+ """
391
+ notional = 0.
392
+
393
+ for report in list(self.trades.values()):
394
+ notional += report.notional # which should be a POSITIVE number in normal cases.
395
+
396
+ return notional
397
+
398
+ @property
399
+ def fee(self) -> float:
400
+ """
401
+ <POSSIBLY WITH SIGN> total transaction fee
402
+ :return: float
403
+ """
404
+ total_fee = 0.
405
+
406
+ for report in list(self.trades.values()):
407
+ total_fee += report.fee
408
+
409
+ return total_fee
410
+
411
+ @property
412
+ def cash_flow(self) -> float:
413
+ """
414
+ <WITH SIGN> total cash flow
415
+ :return: float
416
+ """
417
+ cash_flow = -self.filled_notional * self.side.sign
418
+ return cash_flow
419
+
420
+ @property
421
+ def multiplier(self) -> float:
422
+ if self.order:
423
+ return self.order[list(self.order)[0]].multiplier
424
+ else:
425
+ return 1.0
426
+
427
+ @property
428
+ def filled_progress(self):
429
+ return self.filled_volume / self.target_volume
430
+
431
+ @property
432
+ def placed_progress(self):
433
+ return abs(self.working_volume / self.target_volume) + self.filled_progress
434
+
435
+ @property
436
+ def target_progress(self):
437
+ return self._target_progress
438
+
439
+ @property
440
+ def market_price(self):
441
+ return self.algo_engine.mds.market_price.get(self.ticker)
442
+
443
+ @property
444
+ def market_time(self) -> datetime.datetime:
445
+ return self.algo_engine.mds.market_time
446
+
447
+ @property
448
+ def timestamp(self) -> float:
449
+ return self.algo_engine.mds.timestamp
450
+
451
+ @property
452
+ def start_time(self) -> datetime.datetime | None:
453
+ if self.ts_started is None:
454
+ return None
455
+
456
+ return datetime.datetime.fromtimestamp(self.ts_started, tz=self.algo_engine.mds.profile.time_zone)
457
+
458
+ @property
459
+ def finish_time(self) -> datetime.datetime | None:
460
+ if self.ts_finished is None:
461
+ return None
462
+
463
+ return datetime.datetime.fromtimestamp(self.ts_finished, tz=self.algo_engine.mds.profile.time_zone)
464
+
465
+
466
+ class Passive(AlgoTemplate):
467
+ """ Passive trading algorithm
468
+ Passive is a basic trading algo which trades all target volume into one single LIMIT order.
469
+ Algo will stop after order get filled or canceled.
470
+ no additional order will be launched except the initial one
471
+
472
+ a limit price can be set by keyword arguments, see also in doc: algo_engine.calculate_limit
473
+
474
+ """
475
+
476
+ def __init__(self, **kwargs):
477
+ """
478
+ init a Passive trade algo
479
+
480
+ requires all params from AlgoTemplate and additional following 4
481
+ :keyword limit_price: the absolute limit price of the order
482
+ :keyword limit_adjust_factor: limit price = market_price * (1 + factor) for long order else limit price = market_price * (1 - factor) for short order
483
+ :keyword limit_adjust_level: for long order, limit price = bid[lvl] if lvl > 0 else ask[lvl] for lvl < 0.
484
+ :keyword limit_mode: if multiple limit price standard is provided, use "strict" to select strictest limit price or "loose" to select loosest one. Default is None, which is "strict".
485
+ """
486
+ self.limit_price = kwargs.pop('limit_price', None)
487
+ self.limit_adjust_factor = kwargs.pop('limit_adjust_factor', None)
488
+ self.limit_adjust_level = kwargs.pop('limit_adjust_level', None)
489
+ self.limit_mode = kwargs.pop('limit_mode', None)
490
+
491
+ super().__init__(**kwargs)
492
+
493
+ def work(self):
494
+ pass
495
+
496
+ def launch(self, **kwargs):
497
+ if self.is_active:
498
+ raise RuntimeError(f'{self} is working already')
499
+
500
+ self.is_active = True
501
+
502
+ limit_price = kwargs.pop('limit_price', self.limit_price)
503
+ limit_adjust_factor = kwargs.pop('limit_adjust_factor', self.limit_adjust_factor)
504
+ limit_adjust_level = kwargs.pop('limit_adjust_level', self.limit_adjust_level)
505
+ limit_mode = kwargs.pop('limit_mode', self.limit_mode)
506
+
507
+ limit = self.algo_engine.calculate_limit(
508
+ algo=self,
509
+ limit_price=limit_price,
510
+ limit_adjust_factor=limit_adjust_factor,
511
+ limit_adjust_level=limit_adjust_level,
512
+ mode=limit_mode
513
+ )
514
+ order_type = OrderType.LimitOrder
515
+ volume = self.target_volume - self.filled_volume - self.working_volume
516
+
517
+ LOGGER.info(f'{self} launching {order_type} {self.ticker} {self.side.name} {volume}')
518
+
519
+ if volume:
520
+ order = TradeInstruction(
521
+ ticker=self.ticker,
522
+ side=self.side,
523
+ order_type=order_type,
524
+ volume=volume,
525
+ limit_price=limit,
526
+ order_id=f'{self.__class__.__name__}.{self.ticker}.{self.side.side_name}.{uuid.uuid4().hex}',
527
+ timestamp=self.dma.timestamp
528
+ )
529
+
530
+ self.working_order[order.order_id] = order
531
+ self.order[order.order_id] = order
532
+ self.ts_started = self.dma.timestamp
533
+ self._launch(order=order, **kwargs)
534
+
535
+ def cancel(self, **kwargs):
536
+ self.status = self.Status.stopping
537
+ self.is_active = False
538
+ self._cancel_all_order(**kwargs)
539
+
540
+ def _cancel_all_order(self, **kwargs):
541
+ for order_id in list(self.working_order):
542
+ order = self.working_order.get(order_id)
543
+
544
+ if order is None:
545
+ continue
546
+
547
+ if order.order_state in [OrderState.Pending, OrderState.Placed, OrderState.PartFilled]:
548
+ LOGGER.info(f'{self} canceling {order}')
549
+ self.dma.cancel_order(order=order, **kwargs)
550
+
551
+ def _rejected(self, order: TradeInstruction, **kwargs):
552
+ super()._rejected(order=order)
553
+
554
+ if not self.exposure_volume:
555
+ self._assign_status(status=self.Status.closed)
556
+ else:
557
+ self._assign_status(status=self.Status.done)
558
+
559
+ def _filled(self, order: TradeInstruction, report: TradeReport, **kwargs):
560
+ super()._filled(order=order, report=report, **kwargs)
561
+
562
+ if order.order_id not in self.working_order:
563
+ if self.status == self.Status.working:
564
+ if self.filled_volume:
565
+ self._assign_status(status=self.Status.done)
566
+ else:
567
+ self._assign_status(status=self.Status.closed)
568
+
569
+ def _canceled(self, order: TradeInstruction, **kwargs):
570
+ super()._canceled(order=order, **kwargs)
571
+
572
+ if order.order_id not in self.working_order:
573
+ if self.status == self.Status.working:
574
+ if self.filled_volume:
575
+ self._assign_status(status=self.Status.done)
576
+ else:
577
+ self._assign_status(status=self.Status.closed)
578
+
579
+ if not self.is_active:
580
+ self._assign_status(status=self.Status.done)
581
+
582
+ def to_json(self, fmt='str') -> str | dict:
583
+ json_dict = super().to_json(fmt='dict')
584
+
585
+ additional_dict = dict(
586
+ limit_price=self.limit_price,
587
+ limit_adjust_factor=self.limit_adjust_factor,
588
+ limit_adjust_level=self.limit_adjust_level,
589
+ limit_mode=self.limit_mode
590
+ )
591
+
592
+ json_dict.update(additional_dict)
593
+
594
+ if fmt == 'dict':
595
+ return json_dict
596
+ else:
597
+ return json.dumps(json_dict)
598
+
599
+ def from_json(self, json_str: str | dict):
600
+ if isinstance(json_str, (str, bytes)):
601
+ json_dict = json.loads(json_str)
602
+ elif isinstance(json_str, dict):
603
+ json_dict = json_str
604
+ else:
605
+ raise TypeError(f'Invalid type {type(json_str)}, expect [str, bytes, dict]')
606
+
607
+ super().from_json(json_dict)
608
+
609
+ self.limit_price = json_dict['limit_price']
610
+ self.limit_adjust_factor = json_dict['limit_adjust_factor']
611
+ self.limit_adjust_level = json_dict['limit_adjust_level']
612
+ self.limit_mode = json_dict['limit_mode']
613
+
614
+ return self
615
+
616
+
617
+ class PassiveTimeout(Passive):
618
+ """ Passive handler with timeout function
619
+ PassiveTimeout is similar to Passive, with a timeout value (in seconds) and cancel working order after that
620
+
621
+ Default timeout is 0, which is no timeout (same as passive).
622
+ """
623
+
624
+ def __init__(self, **kwargs):
625
+ self.timeout = kwargs.pop('timeout', 0)
626
+
627
+ super().__init__(**kwargs)
628
+
629
+ def on_market_data(self, market_data: MarketData, **kwargs):
630
+ if self.is_active:
631
+ self.work()
632
+
633
+ def work(self):
634
+ ts = self.algo_engine.mds.trade_time_between(start_time=self.ts_started, end_time=self.timestamp).total_seconds()
635
+ if self.status == self.Status.working and self.timeout and ts > self.timeout:
636
+ self.cancel()
637
+ self.logger.debug(f'{self} canceling. status={self.status}, ts={ts:.3f}s')
638
+ else:
639
+ self.logger.debug(f'{self} working. status={self.status}, ts={ts:.3f}s, timeout={self.timeout:.3f}s')
640
+
641
+ def to_json(self, fmt='str') -> str | dict:
642
+ json_dict = super().to_json(fmt='dict')
643
+
644
+ additional_dict = dict(
645
+ timeout=self.timeout
646
+ )
647
+
648
+ json_dict.update(additional_dict)
649
+
650
+ if fmt == 'dict':
651
+ return json_dict
652
+ else:
653
+ return json.dumps(json_dict)
654
+
655
+ def from_json(self, json_str: str | dict):
656
+ if isinstance(json_str, (str, bytes)):
657
+ json_dict = json.loads(json_str)
658
+ elif isinstance(json_str, dict):
659
+ json_dict = json_str
660
+ else:
661
+ raise TypeError(f'Invalid type {type(json_str)}, expect [str, bytes, dict]')
662
+
663
+ super().from_json(json_dict)
664
+
665
+ self.timeout = json_dict['timeout']
666
+
667
+ return self
668
+
669
+
670
+ class Aggressive(Passive):
671
+ """ Aggressive trading algorithm
672
+ Aggressive is similar as Passive.
673
+ Aggressive will re-launch a "fixing" order immediately
674
+ after working order got canceled or filled, if there is any un-filled volume.
675
+
676
+ USE WITH CAUTION
677
+ """
678
+
679
+ def __init__(self, **kwargs):
680
+ super().__init__(**kwargs)
681
+
682
+ def _filled(self, order: TradeInstruction, report: TradeReport, **kwargs):
683
+ super()._filled(order=order, report=report, **kwargs)
684
+
685
+ if not self.is_active:
686
+ self._assign_status(status=self.Status.done)
687
+ elif order.order_id not in self.working_order:
688
+ if self.status == self.Status.working:
689
+ self.launch()
690
+
691
+ def _canceled(self, order: TradeInstruction, **kwargs):
692
+ super()._canceled(order=order, **kwargs)
693
+
694
+ if not self.is_active:
695
+ self._assign_status(status=self.Status.done)
696
+ elif order.order_id not in self.working_order:
697
+ if self.status == self.Status.working:
698
+ self.launch()
699
+
700
+
701
+ class AggressiveTimeout(PassiveTimeout, Aggressive):
702
+ """ Similar to PassiveTimeout, AggressiveTimeout cancel working order after timeout and re-launch "fixing" order after canceled or filled.
703
+ """
704
+
705
+ def __init__(self, **kwargs):
706
+ super().__init__(**kwargs)
707
+
708
+ def _filled(self, order: TradeInstruction, report: TradeReport, **kwargs):
709
+ return Aggressive._filled(self=self, order=order, report=report, **kwargs)
710
+
711
+ def _canceled(self, order: TradeInstruction, **kwargs):
712
+ return Aggressive._canceled(self=self, order=order, **kwargs)
713
+
714
+
715
+ class AlgoEngine(object):
716
+ def __init__(self, mds=None, registry=None):
717
+ self.mds = mds if mds is not None else MDS
718
+ self.registry = registry if registry is not None else ALGO_REGISTRY
719
+
720
+ @classmethod
721
+ def _compare_price(cls, side: TransactionSide, limit_price: float = None, original_limit: float = None, mode='strict') -> float:
722
+ calculated_limit = original_limit
723
+
724
+ if limit_price is None:
725
+ return calculated_limit
726
+ elif calculated_limit is None:
727
+ return limit_price
728
+ if mode is None or mode == 'strict':
729
+ if side.sign > 0:
730
+ calculated_limit = min(calculated_limit, limit_price)
731
+ else:
732
+ calculated_limit = max(calculated_limit, limit_price)
733
+ elif mode == 'loose':
734
+ if side.sign > 0:
735
+ calculated_limit = max(calculated_limit, limit_price)
736
+ else:
737
+ calculated_limit = min(calculated_limit, limit_price)
738
+ else:
739
+ LOGGER.error(f'Invalid compare mode {mode}!')
740
+ return limit_price
741
+
742
+ return calculated_limit
743
+
744
+ def get_algo(self, name: str):
745
+ algo = self.registry.to_algo(name=name.lower(), algo_engine=self)
746
+ return algo
747
+
748
+ def calculate_limit(
749
+ self,
750
+ algo: AlgoTemplate,
751
+ limit_price: float = None,
752
+ limit_adjust_factor: float = None,
753
+ limit_adjust_level: float = None,
754
+ mode: str = 'loose'
755
+ ) -> float | None:
756
+ """Calculate limit price
757
+
758
+ :param algo: given algo
759
+ :param limit_price: absolute limit_price
760
+ :param limit_adjust_factor: limit_price = market_price * (1 + factor) for long order else limit price = market_price * (1 - factor) for short order
761
+ :param limit_adjust_level: for long order, limit price = bid[lvl] if lvl > 0 else ask[lvl] for lvl < 0.
762
+ :param mode: "strict" to select strictest limit price or "loose" to select loosest one. Default is None, which is "strict".
763
+ :return: the calculated limit price, if there is any
764
+ """
765
+ ticker = algo.ticker
766
+ side = algo.side
767
+ market_price = self.mds.market_price.get(ticker)
768
+
769
+ # validate side
770
+ if side.sign == 0:
771
+ LOGGER.error(f'Invalid side {side}')
772
+ return None
773
+
774
+ # market data not available
775
+ if market_price is None:
776
+ LOGGER.error(f'{ticker} market data not available')
777
+ return None
778
+
779
+ calculated_limit: float | None = None
780
+ limit_abs = None
781
+ limit_adj = None
782
+ limit_lvl = None
783
+
784
+ # compare with absolute limit_price
785
+ if limit_price is not None:
786
+ limit_abs = limit_price
787
+
788
+ if limit_adjust_factor is not None:
789
+ limit_adj = market_price * (1 + limit_adjust_factor * side.sign)
790
+
791
+ if limit_adjust_level is not None:
792
+ order_book = self.mds.get_order_book(ticker=ticker)
793
+
794
+ if order_book is not None:
795
+ lvl = abs(limit_adjust_level)
796
+
797
+ if limit_adjust_level > 0:
798
+ if side.sign > 0:
799
+ book = order_book.bid.price
800
+ else:
801
+ book = order_book.ask.price
802
+
803
+ limit_lvl = book[min(lvl, len(book) - 1)]
804
+ elif limit_adjust_level < 0:
805
+ if side.sign > 0:
806
+ book = order_book.ask.price
807
+ else:
808
+ book = order_book.bid.price
809
+
810
+ limit_lvl = book[min(lvl, len(book) - 1)]
811
+
812
+ calculated_limit = self._compare_price(limit_price=limit_abs, original_limit=calculated_limit, side=side, mode=mode)
813
+ calculated_limit = self._compare_price(limit_price=limit_adj, original_limit=calculated_limit, side=side, mode=mode)
814
+ calculated_limit = self._compare_price(limit_price=limit_lvl, original_limit=calculated_limit, side=side, mode=mode)
815
+ calculated_limit = self._compare_price(limit_price=market_price, original_limit=calculated_limit, side=side, mode=mode)
816
+
817
+ LOGGER.info(f'BBA limits {ticker} market_price={market_price}, lmt_abs={limit_price}, lmt_adj={limit_adj}, lmt_lvl={limit_lvl}, mode={mode}, cal_lmt={calculated_limit}')
818
+ return calculated_limit
819
+
820
+ def from_json(self, json_str, dma) -> AlgoTemplate:
821
+ if isinstance(json_str, (str, bytes)):
822
+ json_dict = json.loads(json_str)
823
+ elif isinstance(json_str, dict):
824
+ json_dict = json_str
825
+ else:
826
+ raise TypeError(f'Invalid type {type(json_str)}, expect [str, bytes, dict]')
827
+
828
+ algo: AlgoTemplate = self.get_algo(json_dict['algo_type'])(
829
+ ticker=json_dict['ticker'],
830
+ side=TransactionSide(json_dict['side']),
831
+ target_volume=json_dict['target_volume'],
832
+ dma=dma,
833
+ algo_id=json_dict['algo_id']
834
+ )
835
+ algo.from_json(json_dict)
836
+
837
+ return algo
838
+
839
+
840
+ class AlgoRegistry(object):
841
+ """
842
+ registry for trade algos
843
+
844
+ to add a new algo, add name to __init__ method, add handler to .cast() method
845
+
846
+ DO NOT add any other value to __init__.
847
+ """
848
+
849
+ def __init__(self):
850
+ super().__init__()
851
+
852
+ self.alias = {}
853
+ self.registry = {}
854
+
855
+ # pre-defined algo name for easy access
856
+ self.aggressive = 'aggressive'
857
+ self.passive = 'passive'
858
+ self.aggressive_timeout = 'aggressive_timeout'
859
+ self.passive_timeout = 'passive_timeout'
860
+ self.limit_range = 'limit_range'
861
+
862
+ def add_algo(self, name: str, *alias, handler: Type[AlgoTemplate]):
863
+ self.registry[name] = handler
864
+
865
+ for _alias in alias:
866
+ self.alias[_alias] = name
867
+
868
+ def cast(self, value: str):
869
+ name = value.lower()
870
+
871
+ # check alias
872
+ if name in self.alias:
873
+ name = self.alias[name]
874
+
875
+ # init from storage
876
+ if name in self.registry:
877
+ return self.registry[name]
878
+ else:
879
+ raise ValueError(f'Invalid name {value}')
880
+
881
+ @property
882
+ def reversed_registry(self) -> dict[str, str]:
883
+ reversed_registry = {algo.__name__: name for name, algo in self.registry.items()}
884
+ return reversed_registry
885
+
886
+ def to_algo(self, name: str, algo_engine: AlgoEngine = None):
887
+ if algo_engine is None:
888
+ algo_engine = ALGO_ENGINE
889
+
890
+ algo = self.registry.get(name.lower())
891
+ return functools.partial(algo, algo_engine=algo_engine)
892
+
893
+
894
+ ALGO_REGISTRY = AlgoRegistry()
895
+
896
+ ALGO_REGISTRY.add_algo('aggressive', 'aggr', handler=Aggressive)
897
+ ALGO_REGISTRY.add_algo('passive', 'pass', handler=Passive)
898
+ ALGO_REGISTRY.add_algo('aggressive_timeout', 'aggr_timeout', handler=AggressiveTimeout)
899
+ ALGO_REGISTRY.add_algo('passive_timeout', 'pass_timeout', handler=PassiveTimeout)
900
+
901
+ ALGO_ENGINE = AlgoEngine(mds=MDS, registry=ALGO_REGISTRY)