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