PyAlgoEngine 0.3.5__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.
- {PyAlgoEngine-0.3.5 → PyAlgoEngine-0.3.6}/AlgoEngine/Engine/AlgoEngine.py +864 -864
- {PyAlgoEngine-0.3.5 → PyAlgoEngine-0.3.6}/AlgoEngine/Engine/EventEngine.py +52 -52
- {PyAlgoEngine-0.3.5 → PyAlgoEngine-0.3.6}/AlgoEngine/Engine/MarketEngine.py +888 -888
- {PyAlgoEngine-0.3.5 → PyAlgoEngine-0.3.6}/AlgoEngine/Engine/TradeEngine.py +2222 -2222
- {PyAlgoEngine-0.3.5 → PyAlgoEngine-0.3.6}/AlgoEngine/Engine/__init__.py +101 -101
- {PyAlgoEngine-0.3.5 → PyAlgoEngine-0.3.6}/AlgoEngine/Strategies/BackTest.py +52 -52
- {PyAlgoEngine-0.3.5 → PyAlgoEngine-0.3.6}/AlgoEngine/Strategies/_StrategyEngine.py +325 -325
- {PyAlgoEngine-0.3.5 → PyAlgoEngine-0.3.6}/AlgoEngine/Strategies/__init__.py +40 -40
- {PyAlgoEngine-0.3.5 → PyAlgoEngine-0.3.6}/AlgoEngine/__init__.py +16 -16
- {PyAlgoEngine-0.3.5 → PyAlgoEngine-0.3.6}/LICENSE +21 -21
- {PyAlgoEngine-0.3.5 → PyAlgoEngine-0.3.6}/PKG-INFO +1 -1
- {PyAlgoEngine-0.3.5 → PyAlgoEngine-0.3.6}/PyAlgoEngine.egg-info/PKG-INFO +1 -1
- {PyAlgoEngine-0.3.5 → PyAlgoEngine-0.3.6}/PyAlgoEngine.egg-info/SOURCES.txt +0 -0
- {PyAlgoEngine-0.3.5 → PyAlgoEngine-0.3.6}/PyAlgoEngine.egg-info/dependency_links.txt +0 -0
- {PyAlgoEngine-0.3.5 → PyAlgoEngine-0.3.6}/PyAlgoEngine.egg-info/requires.txt +1 -1
- {PyAlgoEngine-0.3.5 → PyAlgoEngine-0.3.6}/PyAlgoEngine.egg-info/top_level.txt +0 -0
- {PyAlgoEngine-0.3.5 → PyAlgoEngine-0.3.6}/README.md +2 -2
- {PyAlgoEngine-0.3.5 → PyAlgoEngine-0.3.6}/setup.cfg +0 -0
- {PyAlgoEngine-0.3.5 → PyAlgoEngine-0.3.6}/setup.py +52 -52
|
@@ -1,864 +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', '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)
|
|
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)
|