PyAlgoEngine 0.3.2__tar.gz → 0.3.6__tar.gz

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