PyAlgoEngine 0.7.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- PyAlgoEngine-0.7.4.dist-info/LICENSE +21 -0
- PyAlgoEngine-0.7.4.dist-info/METADATA +27 -0
- PyAlgoEngine-0.7.4.dist-info/RECORD +43 -0
- PyAlgoEngine-0.7.4.dist-info/WHEEL +5 -0
- PyAlgoEngine-0.7.4.dist-info/top_level.txt +1 -0
- algo_engine/__init__.py +41 -0
- algo_engine/apps/__init__.py +17 -0
- algo_engine/apps/backtest/__init__.py +20 -0
- algo_engine/apps/backtest/doc_server.py +331 -0
- algo_engine/apps/backtest/tester.py +254 -0
- algo_engine/apps/backtest/web_app.py +127 -0
- algo_engine/apps/bokeh_server.py +205 -0
- algo_engine/apps/demo/__init__.py +0 -0
- algo_engine/apps/demo/test.py +39 -0
- algo_engine/backtest/__init__.py +19 -0
- algo_engine/backtest/__main__.py +51 -0
- algo_engine/backtest/metrics.py +179 -0
- algo_engine/backtest/replay.py +261 -0
- algo_engine/backtest/sim_match.py +295 -0
- algo_engine/base/__init__.py +40 -0
- algo_engine/base/console_utils.py +1070 -0
- algo_engine/base/finance_decimal.py +258 -0
- algo_engine/base/market_buffer.py +571 -0
- algo_engine/base/market_utils.py +3092 -0
- algo_engine/base/market_utils_nt.py +188 -0
- algo_engine/base/market_utils_posix.py +3004 -0
- algo_engine/base/technical_analysis.py +406 -0
- algo_engine/base/telemetrics.py +78 -0
- algo_engine/base/trade_utils.py +709 -0
- algo_engine/engine/__init__.py +28 -0
- algo_engine/engine/algo_engine.py +901 -0
- algo_engine/engine/event_engine.py +53 -0
- algo_engine/engine/market_engine.py +370 -0
- algo_engine/engine/trade_engine.py +2037 -0
- algo_engine/monitor/__init__.py +15 -0
- algo_engine/monitor/advanced_data_interface.py +239 -0
- algo_engine/profile/__init__.py +121 -0
- algo_engine/profile/cn.py +175 -0
- algo_engine/strategy/__init__.py +44 -0
- algo_engine/strategy/strategy_engine.py +440 -0
- algo_engine/utils/__init__.py +3 -0
- algo_engine/utils/commit_regularizer.py +49 -0
- algo_engine/utils/data_utils.py +251 -0
|
@@ -0,0 +1,2037 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
import datetime
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
import time
|
|
9
|
+
import traceback
|
|
10
|
+
import uuid
|
|
11
|
+
from collections import defaultdict, deque
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from threading import Thread, Semaphore
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
|
|
18
|
+
from . import LOGGER
|
|
19
|
+
from .algo_engine import ALGO_ENGINE, AlgoTemplate
|
|
20
|
+
from .market_engine import MarketDataService, Singleton
|
|
21
|
+
from ..base import TransactionSide, TradeInstruction, MarketData, OrderState, TradeReport
|
|
22
|
+
|
|
23
|
+
LOGGER = LOGGER.getChild('TradeEngine')
|
|
24
|
+
__all__ = ['DirectMarketAccess', 'PositionManagementService', 'Balance', 'Inventory', 'RiskProfile']
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NameSpace(dict):
|
|
28
|
+
def __init__(self, name: str = None, **kwargs):
|
|
29
|
+
self.name = name
|
|
30
|
+
super().__init__(**kwargs)
|
|
31
|
+
|
|
32
|
+
def __getattr__(self, entry):
|
|
33
|
+
if entry in self:
|
|
34
|
+
return self[entry]
|
|
35
|
+
|
|
36
|
+
raise KeyError(f'Entry {entry} not exist!')
|
|
37
|
+
|
|
38
|
+
def __repr__(self):
|
|
39
|
+
if self.name:
|
|
40
|
+
repr_str = f'<{self.name}>'
|
|
41
|
+
else:
|
|
42
|
+
repr_str = f'<NameSpace>'
|
|
43
|
+
|
|
44
|
+
repr_str += f'({super().__repr__()})'
|
|
45
|
+
return repr_str
|
|
46
|
+
|
|
47
|
+
def unpack(self):
|
|
48
|
+
return list(self.values())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DirectMarketAccess(object, metaclass=abc.ABCMeta):
|
|
52
|
+
"""
|
|
53
|
+
Direct Market Access
|
|
54
|
+
|
|
55
|
+
send launch/cancel order direct to market(exchange)
|
|
56
|
+
|
|
57
|
+
also contains an order buff designed to process order and control risk
|
|
58
|
+
|
|
59
|
+
2 ways to implement this api
|
|
60
|
+
- override the abstractmethod _launch_order_handler, _cancel_order_handler, _reject_order_handler to api directly
|
|
61
|
+
- or use event engine
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, mds: MarketDataService, risk_profile: RiskProfile, cool_down: float = None):
|
|
65
|
+
assert cool_down is None or cool_down > 0, 'Order buff cool down must greater than 0.'
|
|
66
|
+
|
|
67
|
+
self.mds = mds
|
|
68
|
+
self.risk_profile = risk_profile
|
|
69
|
+
self.cool_down = cool_down
|
|
70
|
+
|
|
71
|
+
self.order_queue = deque()
|
|
72
|
+
self.worker = Thread(target=self._order_buffer)
|
|
73
|
+
self.lock = Semaphore(0)
|
|
74
|
+
self.enabled = False
|
|
75
|
+
|
|
76
|
+
def __repr__(self):
|
|
77
|
+
return f'<OrderHandler>(cd={self.cool_down}, id={id(self)})'
|
|
78
|
+
|
|
79
|
+
@abc.abstractmethod
|
|
80
|
+
def _launch_order_handler(self, order: TradeInstruction, **kwargs):
|
|
81
|
+
...
|
|
82
|
+
|
|
83
|
+
@abc.abstractmethod
|
|
84
|
+
def _cancel_order_handler(self, order: TradeInstruction, **kwargs):
|
|
85
|
+
...
|
|
86
|
+
|
|
87
|
+
@abc.abstractmethod
|
|
88
|
+
def _reject_order_handler(self, order: TradeInstruction, **kwargs):
|
|
89
|
+
...
|
|
90
|
+
|
|
91
|
+
def _launch_order_buffed(self, order: TradeInstruction, **kwargs):
|
|
92
|
+
self.lock.release()
|
|
93
|
+
self.order_queue.append(('launch', order, kwargs))
|
|
94
|
+
|
|
95
|
+
def _cancel_order_buffed(self, order: TradeInstruction, **kwargs):
|
|
96
|
+
self.lock.release()
|
|
97
|
+
self.order_queue.append(('cancel', order, kwargs))
|
|
98
|
+
|
|
99
|
+
def _launch_order_no_wait(self, order: TradeInstruction, **kwargs):
|
|
100
|
+
LOGGER.info(f'{self} sent a LAUNCH signal of {order}')
|
|
101
|
+
|
|
102
|
+
if not self.enabled:
|
|
103
|
+
LOGGER.warning(f'{order} Rejected by {self}! {self} not enabled!')
|
|
104
|
+
order.set_order_state(order_state=OrderState.Rejected, timestamp=self.mds.timestamp)
|
|
105
|
+
self._reject_order_handler(order=order, **kwargs)
|
|
106
|
+
elif not (is_pass := self.risk_profile.check(order=order)):
|
|
107
|
+
LOGGER.warning(f'{order} Rejected by risk control! Invalid action {order.ticker} {order.side.name} {order.volume}!')
|
|
108
|
+
order.set_order_state(order_state=OrderState.Rejected, timestamp=self.mds.timestamp)
|
|
109
|
+
self._reject_order_handler(order=order, **kwargs)
|
|
110
|
+
else:
|
|
111
|
+
order.set_order_state(order_state=OrderState.Sent, timestamp=self.mds.timestamp)
|
|
112
|
+
self._launch_order_handler(order=order, **kwargs)
|
|
113
|
+
|
|
114
|
+
def _cancel_order_no_wait(self, order: TradeInstruction, **kwargs):
|
|
115
|
+
LOGGER.info(f'{self} sent a CANCEL signal of {order}')
|
|
116
|
+
|
|
117
|
+
order.set_order_state(order_state=OrderState.Canceling, timestamp=self.mds.timestamp)
|
|
118
|
+
self._cancel_order_handler(order=order, **kwargs)
|
|
119
|
+
|
|
120
|
+
def launch_order(self, order: TradeInstruction, **kwargs):
|
|
121
|
+
LOGGER.info(f'{self} launching order {order}')
|
|
122
|
+
if self.cool_down:
|
|
123
|
+
self._launch_order_buffed(order=order, **kwargs)
|
|
124
|
+
else:
|
|
125
|
+
self._launch_order_no_wait(order=order, **kwargs)
|
|
126
|
+
|
|
127
|
+
def cancel_order(self, order: TradeInstruction, **kwargs):
|
|
128
|
+
LOGGER.info(f'{self} canceling order {order}')
|
|
129
|
+
if self.cool_down:
|
|
130
|
+
self._cancel_order_buffed(order=order, **kwargs)
|
|
131
|
+
else:
|
|
132
|
+
self._cancel_order_no_wait(order=order, **kwargs)
|
|
133
|
+
|
|
134
|
+
def _order_buffer(self):
|
|
135
|
+
while True:
|
|
136
|
+
ts = time.time()
|
|
137
|
+
self.lock.acquire(blocking=True)
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
action, order, kwargs = self.order_queue.popleft()
|
|
141
|
+
except IndexError as e:
|
|
142
|
+
if not self.enabled:
|
|
143
|
+
break
|
|
144
|
+
else:
|
|
145
|
+
raise e
|
|
146
|
+
|
|
147
|
+
if action == 'launch':
|
|
148
|
+
self._launch_order_no_wait(order=order, **kwargs)
|
|
149
|
+
elif action == 'cancel':
|
|
150
|
+
self._cancel_order_no_wait(order=order, **kwargs)
|
|
151
|
+
else:
|
|
152
|
+
LOGGER.info(f'Invalid order action {action}!')
|
|
153
|
+
|
|
154
|
+
if self.cool_down and (cool_down := (ts + self.cool_down - time.time())) > 0:
|
|
155
|
+
time.sleep(cool_down)
|
|
156
|
+
|
|
157
|
+
if not self.enabled:
|
|
158
|
+
break
|
|
159
|
+
|
|
160
|
+
def start(self):
|
|
161
|
+
if self.enabled:
|
|
162
|
+
LOGGER.error(f'{self} already started!')
|
|
163
|
+
|
|
164
|
+
self.enabled = True
|
|
165
|
+
|
|
166
|
+
if self.cool_down:
|
|
167
|
+
self.worker.start()
|
|
168
|
+
|
|
169
|
+
def shut_down(self):
|
|
170
|
+
if not self.enabled:
|
|
171
|
+
LOGGER.error(f'{self} already stopped!')
|
|
172
|
+
|
|
173
|
+
self.enabled = False
|
|
174
|
+
|
|
175
|
+
if self.cool_down:
|
|
176
|
+
self.lock.release()
|
|
177
|
+
self.worker = Thread(target=self._order_buffer)
|
|
178
|
+
LOGGER.info(f'Order buff shutting down!')
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def timestamp(self):
|
|
182
|
+
return self.mds.timestamp
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def market_price(self):
|
|
186
|
+
return self.mds.market_price
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def market_time(self):
|
|
190
|
+
return self.mds.market_time
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class PositionManagementService(object):
|
|
194
|
+
"""
|
|
195
|
+
Position Module controls the position of a single strategy,
|
|
196
|
+
|
|
197
|
+
The tracker provides basic tracing of PnL, exposure, holding time and interface with risk monitor module
|
|
198
|
+
The Strategy should interface with Position module, not the algo
|
|
199
|
+
|
|
200
|
+
a range of easy method is provided to facilitate development
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def __init__(
|
|
204
|
+
self,
|
|
205
|
+
dma: DirectMarketAccess,
|
|
206
|
+
algo_engine=None,
|
|
207
|
+
default_algo: str = None,
|
|
208
|
+
no_cache: bool = False,
|
|
209
|
+
**kwargs
|
|
210
|
+
):
|
|
211
|
+
self.dma = dma
|
|
212
|
+
self.algo_engine = algo_engine if algo_engine is not None else ALGO_ENGINE
|
|
213
|
+
self.algo_registry = self.algo_engine.registry
|
|
214
|
+
self.default_algo = self.algo_registry.passive if default_algo is None else default_algo
|
|
215
|
+
self.position_id = kwargs.pop('position_id', uuid.uuid4().hex)
|
|
216
|
+
self.no_cache = no_cache
|
|
217
|
+
|
|
218
|
+
self.algos: dict[str, AlgoTemplate] = {}
|
|
219
|
+
self.working_algos: dict[str, AlgoTemplate] = {}
|
|
220
|
+
|
|
221
|
+
# cache
|
|
222
|
+
self._exposure: dict[str, float] | None = None
|
|
223
|
+
self._working: dict[str, dict[str, float]] | None = None
|
|
224
|
+
|
|
225
|
+
def __call__(self, market_data: MarketData):
|
|
226
|
+
self.on_market_data(market_data=market_data)
|
|
227
|
+
|
|
228
|
+
def on_market_data(self, market_data: MarketData):
|
|
229
|
+
for algo_id in list(self.working_algos):
|
|
230
|
+
algo = self.algos.get(algo_id)
|
|
231
|
+
|
|
232
|
+
if algo is None:
|
|
233
|
+
continue
|
|
234
|
+
|
|
235
|
+
algo.on_market_data(market_data=market_data)
|
|
236
|
+
|
|
237
|
+
def on_filled(self, report: TradeReport, **kwargs):
|
|
238
|
+
order_id = report.order_id
|
|
239
|
+
algo = self.reversed_order_mapping.get(order_id)
|
|
240
|
+
|
|
241
|
+
if algo is None:
|
|
242
|
+
return 0
|
|
243
|
+
|
|
244
|
+
result = algo.on_filled(report=report, **kwargs)
|
|
245
|
+
self._update_status()
|
|
246
|
+
self.clear_cache()
|
|
247
|
+
return result
|
|
248
|
+
|
|
249
|
+
def on_canceled(self, order_id: str, **kwargs):
|
|
250
|
+
algo = self.reversed_order_mapping.get(order_id)
|
|
251
|
+
|
|
252
|
+
if algo is None:
|
|
253
|
+
return 0
|
|
254
|
+
|
|
255
|
+
result = algo.on_canceled(order_id=order_id, **kwargs)
|
|
256
|
+
self._update_status()
|
|
257
|
+
self.clear_cache()
|
|
258
|
+
return result
|
|
259
|
+
|
|
260
|
+
def on_rejected(self, order: TradeInstruction, **kwargs):
|
|
261
|
+
order_id = order.order_id
|
|
262
|
+
algo = self.reversed_order_mapping.get(order_id)
|
|
263
|
+
|
|
264
|
+
if algo is None:
|
|
265
|
+
return 0
|
|
266
|
+
|
|
267
|
+
result = algo.on_rejected(order=order, **kwargs)
|
|
268
|
+
self._update_status()
|
|
269
|
+
self.clear_cache()
|
|
270
|
+
return result
|
|
271
|
+
|
|
272
|
+
def on_algo_done(self, algo: AlgoTemplate):
|
|
273
|
+
self.working_algos.pop(algo.algo_id, None)
|
|
274
|
+
|
|
275
|
+
def on_algo_error(self, algo: AlgoTemplate):
|
|
276
|
+
self.working_algos.pop(algo.algo_id, None)
|
|
277
|
+
LOGGER.warning(f'{algo} encounter error, manual intervention')
|
|
278
|
+
|
|
279
|
+
def open(self, ticker: str, target_volume: float, trade_side: TransactionSide, algo: str = None, **kwargs):
|
|
280
|
+
if algo is None:
|
|
281
|
+
algo = self.default_algo
|
|
282
|
+
|
|
283
|
+
if target_volume:
|
|
284
|
+
algo = self.algo_registry.to_algo(name=algo)(
|
|
285
|
+
handler=self,
|
|
286
|
+
ticker=ticker,
|
|
287
|
+
side=trade_side,
|
|
288
|
+
target_volume=target_volume,
|
|
289
|
+
dma=self.dma,
|
|
290
|
+
**kwargs
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
LOGGER.debug(f'{algo} opening {ticker} {trade_side.side_name} {target_volume} position!')
|
|
294
|
+
self.algos[algo.algo_id] = self.working_algos[algo.algo_id] = algo
|
|
295
|
+
|
|
296
|
+
algo.launch(**kwargs)
|
|
297
|
+
self._update_status()
|
|
298
|
+
return algo
|
|
299
|
+
|
|
300
|
+
def unwind_ticker(self, ticker: str, **kwargs):
|
|
301
|
+
LOGGER.info(f'fully cancel and unwind {ticker} position!')
|
|
302
|
+
|
|
303
|
+
# cancel all
|
|
304
|
+
for algo_id in list(self.algos):
|
|
305
|
+
algo = self.algos.get(algo_id)
|
|
306
|
+
|
|
307
|
+
if algo is not None and ticker == algo.ticker and algo.working_order:
|
|
308
|
+
algo.is_active = False
|
|
309
|
+
algo.cancel(**kwargs)
|
|
310
|
+
|
|
311
|
+
# calculate exposure
|
|
312
|
+
exposure = self.exposure_volume.get(ticker)
|
|
313
|
+
working = self.working_volume.get(ticker, {})
|
|
314
|
+
working_long = working.get('Long', 0)
|
|
315
|
+
working_short = working.get('Short', 0)
|
|
316
|
+
|
|
317
|
+
if not exposure:
|
|
318
|
+
LOGGER.info(f'No exposure for {ticker}, no unwind actions!')
|
|
319
|
+
# no exposure, good!
|
|
320
|
+
return
|
|
321
|
+
elif working_long and working_short:
|
|
322
|
+
# with exposure, and working orders on both side, no action
|
|
323
|
+
LOGGER.info(f'Multiple trade actions for {ticker}, skip unwind actions! Try again later!')
|
|
324
|
+
return
|
|
325
|
+
elif (exposure > 0 and working_short) or (exposure < 0 and working_long):
|
|
326
|
+
# with exposure, and working unwinding orders, no action
|
|
327
|
+
LOGGER.info(f'Unwinding actions exists for {ticker}, skip unwind actions! Try again later!')
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
to_unwind = abs(exposure)
|
|
331
|
+
side = TransactionSide.Sell_to_Unwind if exposure > 0 else TransactionSide.Buy_to_Cover
|
|
332
|
+
self.open(ticker=ticker, target_volume=to_unwind, trade_side=side)
|
|
333
|
+
|
|
334
|
+
def add_exposure(self, ticker: str, volume: float, notional: float, side: TransactionSide, timestamp: float):
|
|
335
|
+
"""
|
|
336
|
+
this is a method to add dummy algo and fills it.
|
|
337
|
+
|
|
338
|
+
the method provides an easy way to amend exposure
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
algo = self.algo_registry.to_algo(name=self.algo_registry.passive)(
|
|
342
|
+
handler=self,
|
|
343
|
+
ticker=ticker,
|
|
344
|
+
side=side,
|
|
345
|
+
target_volume=volume,
|
|
346
|
+
dma=None,
|
|
347
|
+
)
|
|
348
|
+
self.algos[algo.algo_id] = algo
|
|
349
|
+
|
|
350
|
+
order = TradeInstruction(ticker=ticker, order_id=f'Dummy.{uuid.uuid4().int}', volume=volume, side=side, timestamp=timestamp)
|
|
351
|
+
report = TradeReport(ticker=ticker, volume=volume, price=notional / volume if volume else np.nan, notional=notional, side=side, timestamp=timestamp, order_id=order.order_id)
|
|
352
|
+
order.fill(report)
|
|
353
|
+
algo.status = algo.Status.done
|
|
354
|
+
algo.order[order.order_id] = order
|
|
355
|
+
self._update_status()
|
|
356
|
+
|
|
357
|
+
return report
|
|
358
|
+
|
|
359
|
+
def unwind_all(self, **kwargs):
|
|
360
|
+
exposure = self.exposure_volume
|
|
361
|
+
additional_kwargs = kwargs.copy()
|
|
362
|
+
|
|
363
|
+
for ticker in exposure:
|
|
364
|
+
self.unwind_ticker(ticker, **additional_kwargs)
|
|
365
|
+
|
|
366
|
+
return 0.
|
|
367
|
+
|
|
368
|
+
def cancel_all(self, **kwargs):
|
|
369
|
+
# EMERGENCY ONLY
|
|
370
|
+
for algo_id in list(self.working_algos):
|
|
371
|
+
algo = self.algos.get(algo_id)
|
|
372
|
+
|
|
373
|
+
if algo is not None:
|
|
374
|
+
algo.cancel(**kwargs)
|
|
375
|
+
|
|
376
|
+
return 0
|
|
377
|
+
|
|
378
|
+
def to_json(self, fmt='str') -> str | dict:
|
|
379
|
+
json_dict = {}
|
|
380
|
+
map_id = self.position_id
|
|
381
|
+
|
|
382
|
+
json_dict[map_id] = {}
|
|
383
|
+
|
|
384
|
+
# dump algos
|
|
385
|
+
for algo_id in list(self.algos):
|
|
386
|
+
algo = self.algos.get(algo_id)
|
|
387
|
+
|
|
388
|
+
if algo is not None:
|
|
389
|
+
json_dict[map_id][algo_id] = algo.to_json(fmt='dict')
|
|
390
|
+
|
|
391
|
+
if fmt == 'dict':
|
|
392
|
+
return json_dict
|
|
393
|
+
else:
|
|
394
|
+
return json.dumps(json_dict)
|
|
395
|
+
|
|
396
|
+
def _update_status(self):
|
|
397
|
+
for algo_id in list(self.working_algos):
|
|
398
|
+
algo = self.algos.get(algo_id)
|
|
399
|
+
|
|
400
|
+
if algo is None:
|
|
401
|
+
continue
|
|
402
|
+
|
|
403
|
+
if algo.status == algo.Status.closed or algo.status == algo.Status.done:
|
|
404
|
+
self.on_algo_done(algo=algo)
|
|
405
|
+
elif algo.status == algo.Status.rejected or algo.status == algo.Status.error:
|
|
406
|
+
self.on_algo_error(algo=algo)
|
|
407
|
+
|
|
408
|
+
def _algo_pnl(self, algo: AlgoTemplate):
|
|
409
|
+
if algo.exposure_volume:
|
|
410
|
+
if (market_price := self.market_price.get(algo.ticker)) is not None:
|
|
411
|
+
pnl = market_price * algo.exposure_volume * algo.multiplier + algo.cash_flow
|
|
412
|
+
else:
|
|
413
|
+
pnl = np.nan
|
|
414
|
+
else:
|
|
415
|
+
pnl = algo.cash_flow
|
|
416
|
+
return pnl
|
|
417
|
+
|
|
418
|
+
def clear_cache(self):
|
|
419
|
+
self._exposure = None
|
|
420
|
+
self._working = None
|
|
421
|
+
|
|
422
|
+
def clear(self):
|
|
423
|
+
self.algos.clear()
|
|
424
|
+
self.working_algos.clear()
|
|
425
|
+
self.clear_cache()
|
|
426
|
+
|
|
427
|
+
def pnl(self) -> dict[str, float]:
|
|
428
|
+
pnl = {}
|
|
429
|
+
for algo_id in list(self.algos):
|
|
430
|
+
algo = self.algos.get(algo_id)
|
|
431
|
+
|
|
432
|
+
if algo is None:
|
|
433
|
+
continue
|
|
434
|
+
|
|
435
|
+
ticker = algo.ticker
|
|
436
|
+
pnl[ticker] = self._algo_pnl(algo=algo) + pnl.get(ticker, 0)
|
|
437
|
+
|
|
438
|
+
return pnl
|
|
439
|
+
|
|
440
|
+
@property
|
|
441
|
+
def notional(self) -> dict[str, float]:
|
|
442
|
+
notional = {}
|
|
443
|
+
for algo_id in list(self.algos):
|
|
444
|
+
algo = self.algos.get(algo_id)
|
|
445
|
+
|
|
446
|
+
if algo is None:
|
|
447
|
+
continue
|
|
448
|
+
|
|
449
|
+
ticker = algo.ticker
|
|
450
|
+
notional[ticker] = algo.filled_notional + notional.get(ticker, 0)
|
|
451
|
+
|
|
452
|
+
return notional
|
|
453
|
+
|
|
454
|
+
@property
|
|
455
|
+
def working_volume(self) -> dict[str, dict[str, float]]:
|
|
456
|
+
"""
|
|
457
|
+
a dictionary indicating current working volume of all orders
|
|
458
|
+
|
|
459
|
+
{'Long': +float, 'Short': +float}
|
|
460
|
+
|
|
461
|
+
:return: a dict with non-negative numbers
|
|
462
|
+
"""
|
|
463
|
+
|
|
464
|
+
if not self.no_cache and self._working is not None:
|
|
465
|
+
return self._working
|
|
466
|
+
|
|
467
|
+
working_long = {}
|
|
468
|
+
working_short = {}
|
|
469
|
+
working = {'Long': working_long, 'Short': working_short}
|
|
470
|
+
|
|
471
|
+
for algo_id in list(self.working_algos):
|
|
472
|
+
algo = self.algos.get(algo_id)
|
|
473
|
+
ticker = algo.ticker
|
|
474
|
+
|
|
475
|
+
if algo is not None:
|
|
476
|
+
if algo.side.sign > 0:
|
|
477
|
+
working_long[ticker] = working_long.get(ticker, 0.) + algo.working_volume
|
|
478
|
+
elif algo.side.sign < 0:
|
|
479
|
+
working_short[ticker] = working_short.get(ticker, 0.) + algo.working_volume
|
|
480
|
+
|
|
481
|
+
for side in working:
|
|
482
|
+
_ = working[side]
|
|
483
|
+
|
|
484
|
+
for ticker in list(_):
|
|
485
|
+
if not _[ticker]:
|
|
486
|
+
_.pop(ticker)
|
|
487
|
+
|
|
488
|
+
return working
|
|
489
|
+
|
|
490
|
+
@property
|
|
491
|
+
def exposure_volume(self) -> dict[str, float]:
|
|
492
|
+
"""
|
|
493
|
+
a dictionary indicating current net exposed volume of all orders
|
|
494
|
+
|
|
495
|
+
:return: a dict with float numbers (positive and negatives)
|
|
496
|
+
"""
|
|
497
|
+
|
|
498
|
+
if not self.no_cache and self._exposure is not None:
|
|
499
|
+
return self._exposure
|
|
500
|
+
|
|
501
|
+
exposure = {}
|
|
502
|
+
|
|
503
|
+
for algo_id in list(self.algos):
|
|
504
|
+
algo = self.algos.get(algo_id)
|
|
505
|
+
|
|
506
|
+
if algo is not None:
|
|
507
|
+
ticker = algo.ticker
|
|
508
|
+
exposure[ticker] = exposure.get(ticker, 0.) + algo.exposure_volume
|
|
509
|
+
|
|
510
|
+
for ticker in list(exposure):
|
|
511
|
+
if not exposure[ticker]:
|
|
512
|
+
exposure.pop(ticker)
|
|
513
|
+
|
|
514
|
+
return exposure
|
|
515
|
+
|
|
516
|
+
@property
|
|
517
|
+
def working_volume_net(self) -> dict[str, float]:
|
|
518
|
+
"""
|
|
519
|
+
a dictionary indicating current working volume of all orders
|
|
520
|
+
|
|
521
|
+
:return: a dict with summed working volume for each ticker numbers, with positive value as net-long and negative value as net-short
|
|
522
|
+
"""
|
|
523
|
+
working = {}
|
|
524
|
+
|
|
525
|
+
for algo_id in list(self.algos):
|
|
526
|
+
algo = self.algos.get(algo_id)
|
|
527
|
+
|
|
528
|
+
if algo is not None:
|
|
529
|
+
ticker = algo.ticker
|
|
530
|
+
working[ticker] = working.get(ticker, 0.) + algo.working_volume * algo.side.sign
|
|
531
|
+
|
|
532
|
+
for ticker in list(working):
|
|
533
|
+
if not working[ticker]:
|
|
534
|
+
working.pop(ticker)
|
|
535
|
+
|
|
536
|
+
return working
|
|
537
|
+
|
|
538
|
+
@property
|
|
539
|
+
def market_price(self):
|
|
540
|
+
return self.dma.market_price
|
|
541
|
+
|
|
542
|
+
@property
|
|
543
|
+
def market_time(self):
|
|
544
|
+
return self.dma.market_time
|
|
545
|
+
|
|
546
|
+
@property
|
|
547
|
+
def orders(self) -> dict[str, TradeInstruction]:
|
|
548
|
+
orders = {}
|
|
549
|
+
|
|
550
|
+
for algo_id in list(self.algos):
|
|
551
|
+
algo = self.algos.get(algo_id)
|
|
552
|
+
|
|
553
|
+
if algo is None:
|
|
554
|
+
continue
|
|
555
|
+
|
|
556
|
+
orders.update(algo.order)
|
|
557
|
+
|
|
558
|
+
return orders
|
|
559
|
+
|
|
560
|
+
@property
|
|
561
|
+
def working_order(self) -> dict[str, TradeInstruction]:
|
|
562
|
+
working_order = {}
|
|
563
|
+
|
|
564
|
+
for algo_id in list(self.algos):
|
|
565
|
+
algo = self.algos.get(algo_id)
|
|
566
|
+
|
|
567
|
+
if algo is None:
|
|
568
|
+
continue
|
|
569
|
+
|
|
570
|
+
working_order.update(algo.working_order)
|
|
571
|
+
|
|
572
|
+
return working_order
|
|
573
|
+
|
|
574
|
+
@property
|
|
575
|
+
def trades(self) -> dict[str, TradeReport]:
|
|
576
|
+
trades = {}
|
|
577
|
+
|
|
578
|
+
for algo_id in list(self.algos):
|
|
579
|
+
algo = self.algos.get(algo_id)
|
|
580
|
+
|
|
581
|
+
if algo is None:
|
|
582
|
+
continue
|
|
583
|
+
|
|
584
|
+
trades.update(algo.trades)
|
|
585
|
+
|
|
586
|
+
return trades
|
|
587
|
+
|
|
588
|
+
@property
|
|
589
|
+
def order_mapping(self) -> dict[str, dict[str, TradeInstruction]]:
|
|
590
|
+
order_mapping = {}
|
|
591
|
+
|
|
592
|
+
for algo_id in list(self.algos):
|
|
593
|
+
algo = self.algos.get(algo_id)
|
|
594
|
+
|
|
595
|
+
if algo is None:
|
|
596
|
+
continue
|
|
597
|
+
|
|
598
|
+
order_mapping[algo.algo_id] = algo.order
|
|
599
|
+
|
|
600
|
+
return order_mapping
|
|
601
|
+
|
|
602
|
+
@property
|
|
603
|
+
def reversed_order_mapping(self) -> dict[str, AlgoTemplate]:
|
|
604
|
+
reversed_order_mapping = {}
|
|
605
|
+
|
|
606
|
+
for algo_id in list(self.algos):
|
|
607
|
+
algo = self.algos.get(algo_id)
|
|
608
|
+
|
|
609
|
+
if algo is None:
|
|
610
|
+
continue
|
|
611
|
+
|
|
612
|
+
for order_id in list(algo.order):
|
|
613
|
+
reversed_order_mapping[order_id] = algo
|
|
614
|
+
|
|
615
|
+
return reversed_order_mapping
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
class Balance(object, metaclass=Singleton):
|
|
619
|
+
"""
|
|
620
|
+
Balance handles mapping of PositionTracker <-> Strategy
|
|
621
|
+
"""
|
|
622
|
+
|
|
623
|
+
def __init__(self, inventory: Inventory = None):
|
|
624
|
+
self.inventory = inventory if inventory is not None else Inventory()
|
|
625
|
+
|
|
626
|
+
self.strategy = {}
|
|
627
|
+
self.trade_logs: list[TradeReport] = []
|
|
628
|
+
self.position_tracker: dict[str, PositionManagementService] = {}
|
|
629
|
+
|
|
630
|
+
self.last_update_timestamp = None
|
|
631
|
+
|
|
632
|
+
def __repr__(self):
|
|
633
|
+
return f'<Balance>{{id={id(self)}}}'
|
|
634
|
+
|
|
635
|
+
def add(self, map_id: str = None, strategy=None, position_tracker: PositionManagementService = None):
|
|
636
|
+
if strategy is None and position_tracker is None:
|
|
637
|
+
raise ValueError('Must assign ether strategy or position_tracker')
|
|
638
|
+
|
|
639
|
+
if map_id is None:
|
|
640
|
+
map_id = uuid.uuid4().hex
|
|
641
|
+
|
|
642
|
+
if strategy is not None:
|
|
643
|
+
self.strategy[map_id] = strategy
|
|
644
|
+
|
|
645
|
+
if position_tracker is not None:
|
|
646
|
+
self.position_tracker[map_id] = position_tracker
|
|
647
|
+
else:
|
|
648
|
+
try:
|
|
649
|
+
position_tracker = strategy.position_tracker
|
|
650
|
+
self.position_tracker[map_id] = position_tracker
|
|
651
|
+
except Exception as _:
|
|
652
|
+
LOGGER.error(traceback.format_exc())
|
|
653
|
+
|
|
654
|
+
def pop(self, map_id: str):
|
|
655
|
+
self.strategy.pop(map_id, None)
|
|
656
|
+
self.position_tracker.pop(map_id, None)
|
|
657
|
+
|
|
658
|
+
def get(self, **kwargs) -> PositionManagementService | None:
|
|
659
|
+
map_id: str | None = kwargs.pop('map_id', None)
|
|
660
|
+
strategy = kwargs.pop('strategy', None)
|
|
661
|
+
|
|
662
|
+
if map_id is not None:
|
|
663
|
+
map_id: str
|
|
664
|
+
return self.position_tracker.get(map_id)
|
|
665
|
+
elif strategy is not None:
|
|
666
|
+
map_id = self.reversed_strategy_mapping.get(id(strategy))
|
|
667
|
+
|
|
668
|
+
if map_id is None:
|
|
669
|
+
raise KeyError(f'Can not found strategy {strategy}')
|
|
670
|
+
return self.position_tracker.get(map_id)
|
|
671
|
+
else:
|
|
672
|
+
raise TypeError('Must assign one value of map_id, strategy or position_tracker')
|
|
673
|
+
|
|
674
|
+
def get_strategy(self, strategy_name: str = None, strategy_id=None):
|
|
675
|
+
match = None
|
|
676
|
+
|
|
677
|
+
if strategy_name is not None:
|
|
678
|
+
for _ in self.strategy.values():
|
|
679
|
+
if _.name == strategy_name:
|
|
680
|
+
match = _
|
|
681
|
+
break
|
|
682
|
+
elif strategy_id is not None:
|
|
683
|
+
for _ in self.strategy.values():
|
|
684
|
+
if _.strategy_id == strategy_id:
|
|
685
|
+
match = _
|
|
686
|
+
break
|
|
687
|
+
else:
|
|
688
|
+
LOGGER.error(ValueError('Must assign ether a strategy_name or a strategy_id'))
|
|
689
|
+
|
|
690
|
+
return match
|
|
691
|
+
|
|
692
|
+
def get_tracker(self, strategy_name: str = None, strategy_id=None) -> PositionManagementService | None:
|
|
693
|
+
strategy = self.get_strategy(strategy_name=strategy_name, strategy_id=strategy_id)
|
|
694
|
+
|
|
695
|
+
if strategy is None:
|
|
696
|
+
return None
|
|
697
|
+
|
|
698
|
+
map_id = self.reversed_strategy_mapping.get(id(strategy))
|
|
699
|
+
tracker = self.position_tracker.get(map_id)
|
|
700
|
+
return tracker
|
|
701
|
+
|
|
702
|
+
def on_update(self, market_time=None):
|
|
703
|
+
pass
|
|
704
|
+
# step 0: update market time
|
|
705
|
+
# self.last_update_timestamp = time.time() if market_time is None else market_time
|
|
706
|
+
|
|
707
|
+
# step 1: write balance file
|
|
708
|
+
# self.dump(file_path=pathlib.Path(WORKING_DIRECTORY).joinpath('Dumps', 'balance.updated.json'))
|
|
709
|
+
|
|
710
|
+
# step 2: write trade file
|
|
711
|
+
# self.dump_trades(file_path=pathlib.Path(WORKING_DIRECTORY).joinpath('Dumps', 'trades.updated.csv'))
|
|
712
|
+
|
|
713
|
+
def on_order(self, order: TradeInstruction, **kwargs):
|
|
714
|
+
order_id = order.order_id
|
|
715
|
+
order_state = order.order_state
|
|
716
|
+
status_code = 0
|
|
717
|
+
|
|
718
|
+
for position_id in list(self.position_tracker):
|
|
719
|
+
position_tracker = self.position_tracker.get(position_id)
|
|
720
|
+
|
|
721
|
+
if position_tracker is None:
|
|
722
|
+
continue
|
|
723
|
+
|
|
724
|
+
if order_id in position_tracker.working_order:
|
|
725
|
+
if position_tracker.working_order[order_id] is not order:
|
|
726
|
+
LOGGER.error(f'Order object not static! stored id {id(position_tracker.working_order[order_id])}, updated id {id(order)}')
|
|
727
|
+
|
|
728
|
+
if order_state == OrderState.Canceled:
|
|
729
|
+
position_tracker.on_canceled(order_id=order_id, **kwargs)
|
|
730
|
+
elif order_state == OrderState.Rejected:
|
|
731
|
+
position_tracker.on_rejected(order=order, **kwargs)
|
|
732
|
+
|
|
733
|
+
status_code = 1
|
|
734
|
+
break
|
|
735
|
+
|
|
736
|
+
if not status_code:
|
|
737
|
+
if order_state == OrderState.Filled:
|
|
738
|
+
LOGGER.debug(f'No match for filled order {order}, perhaps the Algo.on_filled called before Balance.on_order. This is not an error.')
|
|
739
|
+
else:
|
|
740
|
+
LOGGER.error(f'No match for {order.side} order {order}')
|
|
741
|
+
|
|
742
|
+
self.on_update()
|
|
743
|
+
return status_code
|
|
744
|
+
|
|
745
|
+
def on_report(self, report: TradeReport, **kwargs):
|
|
746
|
+
order_id = report.order_id
|
|
747
|
+
status_code = 0
|
|
748
|
+
|
|
749
|
+
for position_id in list(self.position_tracker):
|
|
750
|
+
position_tracker = self.position_tracker.get(position_id)
|
|
751
|
+
|
|
752
|
+
if position_tracker is None:
|
|
753
|
+
continue
|
|
754
|
+
|
|
755
|
+
if order_id in position_tracker.working_order:
|
|
756
|
+
position_tracker.on_filled(report=report, **kwargs)
|
|
757
|
+
|
|
758
|
+
status_code = 1
|
|
759
|
+
break
|
|
760
|
+
|
|
761
|
+
if not status_code:
|
|
762
|
+
LOGGER.warning(f'No match for report {report}')
|
|
763
|
+
|
|
764
|
+
self.on_update()
|
|
765
|
+
self.trade_logs.append(report)
|
|
766
|
+
return status_code
|
|
767
|
+
|
|
768
|
+
def reset(self):
|
|
769
|
+
self.position_tracker.clear()
|
|
770
|
+
self.strategy.clear()
|
|
771
|
+
self.trade_logs.clear()
|
|
772
|
+
|
|
773
|
+
def to_json(self, fmt='str') -> str | dict:
|
|
774
|
+
json_dict = {}
|
|
775
|
+
|
|
776
|
+
for map_id in self.position_tracker:
|
|
777
|
+
tracker = self.position_tracker.get(map_id)
|
|
778
|
+
|
|
779
|
+
if tracker is not None:
|
|
780
|
+
json_dict.update(tracker.to_json(fmt='dict'))
|
|
781
|
+
|
|
782
|
+
if fmt == 'dict':
|
|
783
|
+
return json_dict
|
|
784
|
+
else:
|
|
785
|
+
return json.dumps(json_dict)
|
|
786
|
+
|
|
787
|
+
def from_json(self, json_str: str | dict):
|
|
788
|
+
if isinstance(json_str, (str, bytes)):
|
|
789
|
+
json_dict = json.loads(json_str)
|
|
790
|
+
elif isinstance(json_str, dict):
|
|
791
|
+
json_dict = json_str
|
|
792
|
+
else:
|
|
793
|
+
raise TypeError(f'Invalid type {type(json_str)}, expect [str, bytes, dict]')
|
|
794
|
+
|
|
795
|
+
for map_id in json_dict:
|
|
796
|
+
if map_id not in self.strategy:
|
|
797
|
+
LOGGER.error(f'No strategy with key {map_id} found! Must register strategy before loading balance!')
|
|
798
|
+
continue
|
|
799
|
+
|
|
800
|
+
pos_tracker = self.position_tracker[map_id]
|
|
801
|
+
algo_json = json_dict[map_id]
|
|
802
|
+
|
|
803
|
+
for algo_id in algo_json:
|
|
804
|
+
algo_dict = algo_json[algo_id]
|
|
805
|
+
algo = pos_tracker.algo_engine.from_json(algo_dict)
|
|
806
|
+
pos_tracker.algos[algo.algo_id] = pos_tracker.working_algos[algo.algo_id] = algo
|
|
807
|
+
|
|
808
|
+
if algo.status == algo.Status.closed or algo.status == algo.Status.done:
|
|
809
|
+
pos_tracker.on_algo_done(algo=algo)
|
|
810
|
+
elif algo.status == algo.Status.rejected or algo.status == algo.Status.error:
|
|
811
|
+
pos_tracker.on_algo_error(algo=algo)
|
|
812
|
+
|
|
813
|
+
return self
|
|
814
|
+
|
|
815
|
+
def dump(self, file_path: str | pathlib.Path):
|
|
816
|
+
file_path = pathlib.Path(file_path)
|
|
817
|
+
dump_dir = file_path.parent
|
|
818
|
+
|
|
819
|
+
os.makedirs(dump_dir, exist_ok=True)
|
|
820
|
+
|
|
821
|
+
with open(file_path, 'w') as f:
|
|
822
|
+
f.write(json.dumps(self.to_json(fmt='dict'), indent=4, sort_keys=True))
|
|
823
|
+
|
|
824
|
+
def dump_trades(self, file_path: pathlib.Path | str = None, ts_from: float = None, ts_to: float = None) -> dict:
|
|
825
|
+
"""
|
|
826
|
+
export all trade monitored by position manager
|
|
827
|
+
|
|
828
|
+
:param file_path: Optional, the exported path, without it, the dict will not be dumped
|
|
829
|
+
:param ts_from: timestamp from
|
|
830
|
+
:param ts_to: timestamp to
|
|
831
|
+
:return: a dict containing all the trades
|
|
832
|
+
"""
|
|
833
|
+
trades_dict = {}
|
|
834
|
+
|
|
835
|
+
for mapping_id in self.position_tracker:
|
|
836
|
+
tracker = self.position_tracker[mapping_id]
|
|
837
|
+
trades = tracker.trades
|
|
838
|
+
|
|
839
|
+
for trade_id in trades:
|
|
840
|
+
report = trades[trade_id]
|
|
841
|
+
trade_time = report.trade_time
|
|
842
|
+
ts = trade_time.timestamp()
|
|
843
|
+
|
|
844
|
+
if ts_from is not None and ts < ts_from:
|
|
845
|
+
continue
|
|
846
|
+
elif ts_to is not None and ts > ts_to:
|
|
847
|
+
continue
|
|
848
|
+
|
|
849
|
+
trades_dict[trade_id] = dict(
|
|
850
|
+
strategy=mapping_id,
|
|
851
|
+
ticker=report.ticker,
|
|
852
|
+
side=report.side.side_name,
|
|
853
|
+
volume=report.volume,
|
|
854
|
+
price=report.price,
|
|
855
|
+
notional=report.notional,
|
|
856
|
+
time=report.trade_time,
|
|
857
|
+
ts=report.timestamp,
|
|
858
|
+
)
|
|
859
|
+
|
|
860
|
+
if file_path and trades_dict:
|
|
861
|
+
trades_df = pd.DataFrame(trades_dict).T
|
|
862
|
+
trades_df.sort_values('ts')
|
|
863
|
+
trades_df.to_csv(file_path)
|
|
864
|
+
|
|
865
|
+
return trades_dict
|
|
866
|
+
|
|
867
|
+
def dump_trades_all(self, file_path: pathlib.Path | str = None, ts_from: float = None, ts_to: float = None) -> list:
|
|
868
|
+
"""
|
|
869
|
+
export all the trades received by Balance module, even if there is no strategy corresponding to it.
|
|
870
|
+
|
|
871
|
+
:param file_path: Optional, the exported path, without it, the dict will not be dumped
|
|
872
|
+
:param ts_from: timestamp from
|
|
873
|
+
:param ts_to: timestamp to
|
|
874
|
+
:return: a list containing all the trades info
|
|
875
|
+
"""
|
|
876
|
+
trade_logs = []
|
|
877
|
+
|
|
878
|
+
for report in self.trade_logs: # type: TradeReport
|
|
879
|
+
trade_time = report.trade_time
|
|
880
|
+
ts = trade_time.timestamp()
|
|
881
|
+
|
|
882
|
+
if ts_from is not None and ts < ts_from:
|
|
883
|
+
continue
|
|
884
|
+
elif ts_to is not None and ts > ts_to:
|
|
885
|
+
continue
|
|
886
|
+
|
|
887
|
+
trade_logs.append(dict(
|
|
888
|
+
trade_id=report.trade_id,
|
|
889
|
+
ticker=report.ticker,
|
|
890
|
+
side=report.side.side_name,
|
|
891
|
+
volume=report.volume,
|
|
892
|
+
price=report.price,
|
|
893
|
+
notional=report.notional,
|
|
894
|
+
time=report.trade_time,
|
|
895
|
+
ts=report.timestamp,
|
|
896
|
+
))
|
|
897
|
+
|
|
898
|
+
if file_path and trade_logs:
|
|
899
|
+
trades_df = pd.DataFrame(trade_logs)
|
|
900
|
+
trades_df.sort_values('ts')
|
|
901
|
+
trades_df.to_csv(file_path)
|
|
902
|
+
|
|
903
|
+
return trade_logs
|
|
904
|
+
|
|
905
|
+
def load(self, file_path: str | pathlib.Path):
|
|
906
|
+
if not os.path.isfile(file_path):
|
|
907
|
+
LOGGER.error(f'No such file {file_path}')
|
|
908
|
+
return
|
|
909
|
+
|
|
910
|
+
with open(file_path, 'r') as f:
|
|
911
|
+
json_str = f.read()
|
|
912
|
+
|
|
913
|
+
self.from_json(json_str)
|
|
914
|
+
|
|
915
|
+
@property
|
|
916
|
+
def tracker_mapping(self) -> dict[str, str]:
|
|
917
|
+
mapping = {}
|
|
918
|
+
|
|
919
|
+
for map_id in self.position_tracker:
|
|
920
|
+
tracker = self.position_tracker.get(map_id)
|
|
921
|
+
|
|
922
|
+
if tracker is None:
|
|
923
|
+
continue
|
|
924
|
+
|
|
925
|
+
mapping[map_id] = tracker.position_id
|
|
926
|
+
|
|
927
|
+
return mapping
|
|
928
|
+
|
|
929
|
+
@property
|
|
930
|
+
def reversed_tracker_mapping(self) -> dict[str, str]:
|
|
931
|
+
mapping = {}
|
|
932
|
+
|
|
933
|
+
for id_0, id_1 in self.tracker_mapping.items():
|
|
934
|
+
mapping[id_1] = id_0
|
|
935
|
+
|
|
936
|
+
return mapping
|
|
937
|
+
|
|
938
|
+
@property
|
|
939
|
+
def strategy_mapping(self) -> dict[str, int]:
|
|
940
|
+
mapping = {}
|
|
941
|
+
|
|
942
|
+
for map_id in self.strategy:
|
|
943
|
+
strategy = self.strategy.get(map_id)
|
|
944
|
+
|
|
945
|
+
if strategy is None:
|
|
946
|
+
continue
|
|
947
|
+
|
|
948
|
+
mapping[map_id] = id(strategy)
|
|
949
|
+
|
|
950
|
+
return mapping
|
|
951
|
+
|
|
952
|
+
@property
|
|
953
|
+
def reversed_strategy_mapping(self) -> dict[int, str]:
|
|
954
|
+
mapping = {}
|
|
955
|
+
|
|
956
|
+
for id_0, id_1 in self.strategy_mapping.items():
|
|
957
|
+
mapping[id_1] = id_0
|
|
958
|
+
|
|
959
|
+
return mapping
|
|
960
|
+
|
|
961
|
+
@property
|
|
962
|
+
def working_volume_summed(self) -> dict[str, float]:
|
|
963
|
+
working_summed = {}
|
|
964
|
+
|
|
965
|
+
for tracker_id in list(self.position_tracker):
|
|
966
|
+
tracker = self.position_tracker.get(tracker_id)
|
|
967
|
+
|
|
968
|
+
if tracker is not None:
|
|
969
|
+
for side in tracker.working_volume:
|
|
970
|
+
working = tracker.working_volume[side]
|
|
971
|
+
|
|
972
|
+
for ticker in working:
|
|
973
|
+
working_summed[ticker] = working_summed.get(ticker, 0.) + abs(working.get(ticker, 0.))
|
|
974
|
+
|
|
975
|
+
for ticker in list(working_summed):
|
|
976
|
+
if not working_summed[ticker]:
|
|
977
|
+
working_summed.pop(ticker)
|
|
978
|
+
|
|
979
|
+
return working_summed
|
|
980
|
+
|
|
981
|
+
@property
|
|
982
|
+
def exposure_volume(self) -> dict[str, float]:
|
|
983
|
+
exposure = {}
|
|
984
|
+
|
|
985
|
+
for tracker_id in list(self.position_tracker):
|
|
986
|
+
tracker = self.position_tracker.get(tracker_id)
|
|
987
|
+
|
|
988
|
+
if tracker is not None:
|
|
989
|
+
for ticker in tracker.exposure_volume:
|
|
990
|
+
exposure[ticker] = exposure.get(ticker, 0.) + tracker.exposure_volume[ticker]
|
|
991
|
+
|
|
992
|
+
if exposure[ticker] == 0:
|
|
993
|
+
exposure.pop(ticker)
|
|
994
|
+
|
|
995
|
+
return exposure
|
|
996
|
+
|
|
997
|
+
@property
|
|
998
|
+
def working_volume(self) -> dict[str, dict[str, float]]:
|
|
999
|
+
|
|
1000
|
+
working_long = {}
|
|
1001
|
+
working_short = {}
|
|
1002
|
+
working = {'Long': working_long, 'Short': working_short}
|
|
1003
|
+
|
|
1004
|
+
for tracker_id in list(self.position_tracker):
|
|
1005
|
+
tracker = self.position_tracker.get(tracker_id)
|
|
1006
|
+
|
|
1007
|
+
if tracker is not None:
|
|
1008
|
+
tracker_working = tracker.working_volume
|
|
1009
|
+
|
|
1010
|
+
for ticker in (_ := tracker_working['Long']):
|
|
1011
|
+
working_long[ticker] = working_long.get(ticker, 0.) + _.get(ticker, 0.)
|
|
1012
|
+
|
|
1013
|
+
for ticker in (_ := tracker_working['Short']):
|
|
1014
|
+
working_short[ticker] = working_short.get(ticker, 0.) + _.get(ticker, 0.)
|
|
1015
|
+
|
|
1016
|
+
for side in working:
|
|
1017
|
+
_ = working[side]
|
|
1018
|
+
|
|
1019
|
+
for ticker in list(_):
|
|
1020
|
+
if not _[ticker]:
|
|
1021
|
+
_.pop(ticker)
|
|
1022
|
+
|
|
1023
|
+
return working
|
|
1024
|
+
|
|
1025
|
+
def exposure_notional(self, mds) -> dict[str, float]:
|
|
1026
|
+
notional = {}
|
|
1027
|
+
|
|
1028
|
+
for ticker in self.exposure_volume:
|
|
1029
|
+
notional[ticker] = self.exposure_volume.get(ticker, 0.) * mds.market_price.get(ticker, 0)
|
|
1030
|
+
|
|
1031
|
+
return notional
|
|
1032
|
+
|
|
1033
|
+
def working_notional(self, mds) -> dict[str, float]:
|
|
1034
|
+
notional = {}
|
|
1035
|
+
|
|
1036
|
+
for ticker in (tracker_working := self.working_volume_summed):
|
|
1037
|
+
notional[ticker] = tracker_working[ticker] * mds.market_price.get(ticker, 0)
|
|
1038
|
+
|
|
1039
|
+
return notional
|
|
1040
|
+
|
|
1041
|
+
@property
|
|
1042
|
+
def orders(self) -> dict[str, TradeInstruction]:
|
|
1043
|
+
orders = {}
|
|
1044
|
+
|
|
1045
|
+
for tracker_id in list(self.position_tracker):
|
|
1046
|
+
tracker = self.position_tracker.get(tracker_id)
|
|
1047
|
+
|
|
1048
|
+
if tracker is None:
|
|
1049
|
+
continue
|
|
1050
|
+
|
|
1051
|
+
orders.update(tracker.orders)
|
|
1052
|
+
|
|
1053
|
+
return orders
|
|
1054
|
+
|
|
1055
|
+
@property
|
|
1056
|
+
def working_order(self) -> dict[str, TradeInstruction]:
|
|
1057
|
+
working_order = {}
|
|
1058
|
+
|
|
1059
|
+
for tracker_id in list(self.position_tracker):
|
|
1060
|
+
tracker = self.position_tracker.get(tracker_id)
|
|
1061
|
+
|
|
1062
|
+
if tracker is None:
|
|
1063
|
+
continue
|
|
1064
|
+
|
|
1065
|
+
working_order.update(tracker.working_order)
|
|
1066
|
+
|
|
1067
|
+
return working_order
|
|
1068
|
+
|
|
1069
|
+
@property
|
|
1070
|
+
def trades_today(self):
|
|
1071
|
+
trades = {}
|
|
1072
|
+
from .market_engine import MDS
|
|
1073
|
+
|
|
1074
|
+
market_date = MDS.market_date
|
|
1075
|
+
if market_date is None:
|
|
1076
|
+
return {}
|
|
1077
|
+
|
|
1078
|
+
for tracker_id in list(self.position_tracker):
|
|
1079
|
+
tracker = self.position_tracker.get(tracker_id)
|
|
1080
|
+
|
|
1081
|
+
if tracker is None:
|
|
1082
|
+
continue
|
|
1083
|
+
|
|
1084
|
+
for trade in tracker.trades.values():
|
|
1085
|
+
if trade.trade_time.date() == market_date:
|
|
1086
|
+
trades[trade.trade_id] = trade
|
|
1087
|
+
|
|
1088
|
+
# trades.update(tracker.trades)
|
|
1089
|
+
|
|
1090
|
+
return trades
|
|
1091
|
+
|
|
1092
|
+
@property
|
|
1093
|
+
def trades_session(self) -> dict[str, TradeReport]:
|
|
1094
|
+
trades = {_.trade_id: _ for _ in self.trade_logs}
|
|
1095
|
+
|
|
1096
|
+
return trades
|
|
1097
|
+
|
|
1098
|
+
@property
|
|
1099
|
+
def trades(self) -> dict[str, TradeReport]:
|
|
1100
|
+
return self.trades_today
|
|
1101
|
+
|
|
1102
|
+
@property
|
|
1103
|
+
def info(self) -> pd.DataFrame:
|
|
1104
|
+
info_dict = {
|
|
1105
|
+
'exposure': self.exposure_volume,
|
|
1106
|
+
'working_lone': self.working_volume['Long'],
|
|
1107
|
+
'working_short': self.working_volume['Short'],
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
return pd.DataFrame(info_dict).fillna(0)
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
class Inventory(object, metaclass=Singleton):
|
|
1114
|
+
"""
|
|
1115
|
+
Inventory stores the info of security lending
|
|
1116
|
+
"""
|
|
1117
|
+
|
|
1118
|
+
class SecurityType(Enum):
|
|
1119
|
+
Commodity = 'Commodity'
|
|
1120
|
+
CurrencySwap = 'CurrencySwap'
|
|
1121
|
+
Crypto = 'Crypto'
|
|
1122
|
+
IndexFuture = 'IndexFuture'
|
|
1123
|
+
Stock = 'Stock'
|
|
1124
|
+
|
|
1125
|
+
class CashDividend(object):
|
|
1126
|
+
def __init__(self, market_date: datetime.date, dividend_per_share: float):
|
|
1127
|
+
self.market_date = market_date
|
|
1128
|
+
self.dividend_per_share = dividend_per_share
|
|
1129
|
+
|
|
1130
|
+
class StockDividend(object):
|
|
1131
|
+
def __init__(self, market_date: datetime.date, dividend_per_share: float):
|
|
1132
|
+
self.market_date = market_date
|
|
1133
|
+
self.dividend_per_share = dividend_per_share
|
|
1134
|
+
|
|
1135
|
+
class StockSplit(object):
|
|
1136
|
+
def __init__(self, market_date: datetime.date, multiplier: float):
|
|
1137
|
+
self.market_date = market_date
|
|
1138
|
+
self.multiplier = multiplier
|
|
1139
|
+
|
|
1140
|
+
class StockConversion(object):
|
|
1141
|
+
def __init__(self, market_date: datetime.date, convert_to: str, multiplier: float):
|
|
1142
|
+
self.convert_to = convert_to
|
|
1143
|
+
self.market_date = market_date
|
|
1144
|
+
self.multiplier = multiplier
|
|
1145
|
+
|
|
1146
|
+
class Entry(object):
|
|
1147
|
+
def __init__(self, ticker: str, volume: float, price: float, security_type: Inventory.SecurityType, direction: TransactionSide, **kwargs):
|
|
1148
|
+
if volume < 0:
|
|
1149
|
+
LOGGER.warning('volume of Inventory.Entry normally should be positive!')
|
|
1150
|
+
|
|
1151
|
+
self.ticker = ticker
|
|
1152
|
+
self.volume = volume
|
|
1153
|
+
self.price = price
|
|
1154
|
+
self.security_type = security_type
|
|
1155
|
+
self.direction = direction
|
|
1156
|
+
|
|
1157
|
+
self.notional = kwargs.pop('notional', volume * price)
|
|
1158
|
+
self.fee = kwargs.pop('fee', 0.)
|
|
1159
|
+
self.recalled = kwargs.pop('recalled', 0.)
|
|
1160
|
+
|
|
1161
|
+
def __repr__(self):
|
|
1162
|
+
return f'<Inventory.Entry>(ticker={self.ticker}, side={self.direction.side_name}, volume={self.volume:,}, fee={self.fee:.2f})'
|
|
1163
|
+
|
|
1164
|
+
def __add__(self, other):
|
|
1165
|
+
if isinstance(other, self.__class__):
|
|
1166
|
+
return self.merge(other)
|
|
1167
|
+
|
|
1168
|
+
raise TypeError(f'Can only merge type {self.__class__.__name__}')
|
|
1169
|
+
|
|
1170
|
+
def __bool__(self):
|
|
1171
|
+
return self.volume.__bool__()
|
|
1172
|
+
|
|
1173
|
+
def apply_cash_dividend(self, dividend: Inventory.CashDividend):
|
|
1174
|
+
raise NotImplementedError()
|
|
1175
|
+
|
|
1176
|
+
def apply_stock_dividend(self, dividend: Inventory.StockDividend):
|
|
1177
|
+
raise NotImplementedError()
|
|
1178
|
+
|
|
1179
|
+
def apply_conversion(self, stock_conversion: Inventory.StockConversion):
|
|
1180
|
+
raise NotImplementedError()
|
|
1181
|
+
|
|
1182
|
+
def apply_split(self, stock_split: Inventory.StockSplit):
|
|
1183
|
+
raise NotImplementedError()
|
|
1184
|
+
|
|
1185
|
+
def merge(self, entry: Inventory.Entry, inplace=False, **kwargs):
|
|
1186
|
+
if entry.ticker != self.ticker:
|
|
1187
|
+
raise ValueError(f'<ticker> not match! Expect {self.ticker}, got {entry.ticker}')
|
|
1188
|
+
|
|
1189
|
+
if entry.direction.sign != self.direction.sign:
|
|
1190
|
+
raise ValueError(f'<direction> not match! Expect {self.direction}, got {entry.direction}')
|
|
1191
|
+
|
|
1192
|
+
if entry.security_type != self.security_type:
|
|
1193
|
+
raise ValueError(f'<security_type> not match! Expect {self.security_type}, got {entry.security_type}')
|
|
1194
|
+
|
|
1195
|
+
volume = kwargs.pop('volume', self.volume + entry.volume)
|
|
1196
|
+
notional = kwargs.pop('notional', self.notional + entry.notional)
|
|
1197
|
+
price = kwargs.pop('price', (self.price * self.volume + entry.price * entry.volume) / (self.volume + entry.volume))
|
|
1198
|
+
fee = kwargs.pop('fee', self.fee + entry.fee)
|
|
1199
|
+
recalled = kwargs.pop('recalled', self.recalled + entry.recalled)
|
|
1200
|
+
|
|
1201
|
+
if inplace:
|
|
1202
|
+
self.volume = volume
|
|
1203
|
+
self.notional = notional
|
|
1204
|
+
self.price = price
|
|
1205
|
+
self.fee = fee
|
|
1206
|
+
self.recalled = recalled
|
|
1207
|
+
|
|
1208
|
+
return self
|
|
1209
|
+
else:
|
|
1210
|
+
new_entry = self.__class__(
|
|
1211
|
+
ticker=self.ticker,
|
|
1212
|
+
volume=volume,
|
|
1213
|
+
price=price,
|
|
1214
|
+
security_type=self.security_type,
|
|
1215
|
+
direction=self.direction,
|
|
1216
|
+
notional=notional,
|
|
1217
|
+
fee=fee,
|
|
1218
|
+
recalled=recalled
|
|
1219
|
+
)
|
|
1220
|
+
|
|
1221
|
+
return new_entry
|
|
1222
|
+
|
|
1223
|
+
def to_json(self, fmt='str') -> str | dict:
|
|
1224
|
+
json_dict = dict(
|
|
1225
|
+
ticker=self.ticker,
|
|
1226
|
+
volume=self.volume,
|
|
1227
|
+
price=self.price,
|
|
1228
|
+
security_type=self.security_type.name,
|
|
1229
|
+
direction=self.direction.side_name,
|
|
1230
|
+
notional=self.notional,
|
|
1231
|
+
fee=self.fee,
|
|
1232
|
+
recalled=self.recalled
|
|
1233
|
+
)
|
|
1234
|
+
|
|
1235
|
+
if fmt == 'dict':
|
|
1236
|
+
return json_dict
|
|
1237
|
+
else:
|
|
1238
|
+
return json.dumps(json_dict)
|
|
1239
|
+
|
|
1240
|
+
@classmethod
|
|
1241
|
+
def from_json(cls, json_str: str | dict):
|
|
1242
|
+
if isinstance(json_str, (str, bytes)):
|
|
1243
|
+
json_dict = json.loads(json_str)
|
|
1244
|
+
elif isinstance(json_str, dict):
|
|
1245
|
+
json_dict = json_str
|
|
1246
|
+
else:
|
|
1247
|
+
raise TypeError(f'Invalid type {type(json_str)}, expect [str, bytes, dict]')
|
|
1248
|
+
|
|
1249
|
+
entry = cls(
|
|
1250
|
+
ticker=json_dict['ticker'],
|
|
1251
|
+
volume=json_dict['volume'],
|
|
1252
|
+
price=json_dict['price'],
|
|
1253
|
+
security_type=Inventory.SecurityType[json_dict['security_type']],
|
|
1254
|
+
direction=TransactionSide(json_dict['direction']),
|
|
1255
|
+
notional=json_dict['notional'],
|
|
1256
|
+
fee=json_dict.get('fee', 0.),
|
|
1257
|
+
recalled=json_dict.get('recalled', 0.),
|
|
1258
|
+
)
|
|
1259
|
+
|
|
1260
|
+
return entry
|
|
1261
|
+
|
|
1262
|
+
@property
|
|
1263
|
+
def available(self):
|
|
1264
|
+
return max(self.volume - self.recalled, 0.)
|
|
1265
|
+
|
|
1266
|
+
def __init__(self):
|
|
1267
|
+
self._inv: dict[str, list[Inventory.Entry]] = {}
|
|
1268
|
+
self._traded: dict[str, float] = {}
|
|
1269
|
+
self._tickers = set()
|
|
1270
|
+
|
|
1271
|
+
def __repr__(self):
|
|
1272
|
+
return f'<Inventory>{{id={id(self)}}}'
|
|
1273
|
+
|
|
1274
|
+
def __call__(self, ticker: str):
|
|
1275
|
+
return dict(
|
|
1276
|
+
Long=self.available_volume(ticker=ticker, direction=TransactionSide.LongOpen),
|
|
1277
|
+
Short=self.available_volume(ticker=ticker, direction=TransactionSide.ShortOpen)
|
|
1278
|
+
)
|
|
1279
|
+
|
|
1280
|
+
def recall(self, ticker: str, volume: float, direction: TransactionSide = TransactionSide.LongOpen):
|
|
1281
|
+
key = f'{ticker}.{direction.side_name}'
|
|
1282
|
+
_ = self._inv.get(key, [])
|
|
1283
|
+
to_recall = volume
|
|
1284
|
+
|
|
1285
|
+
for entry in _[:]:
|
|
1286
|
+
recalled = max(entry.volume, to_recall)
|
|
1287
|
+
entry.recalled += recalled
|
|
1288
|
+
|
|
1289
|
+
if not entry.available:
|
|
1290
|
+
_.remove(entry)
|
|
1291
|
+
LOGGER.info(f'{entry} fully recalled!')
|
|
1292
|
+
else:
|
|
1293
|
+
LOGGER.info(f'{entry} recalled {recalled}, {entry.available} remains!')
|
|
1294
|
+
|
|
1295
|
+
if not to_recall:
|
|
1296
|
+
break
|
|
1297
|
+
|
|
1298
|
+
if not _:
|
|
1299
|
+
self._inv.pop(key)
|
|
1300
|
+
|
|
1301
|
+
def add_inv(self, entry: Entry):
|
|
1302
|
+
self._tickers.add(entry.ticker)
|
|
1303
|
+
key = f'{entry.ticker}.{entry.direction.side_name}'
|
|
1304
|
+
_ = self._inv.get(key, [])
|
|
1305
|
+
|
|
1306
|
+
_.append(entry)
|
|
1307
|
+
|
|
1308
|
+
self._inv[key] = _
|
|
1309
|
+
|
|
1310
|
+
def get_inv(self, ticker: str, direction: TransactionSide = TransactionSide.LongOpen) -> Entry | None:
|
|
1311
|
+
key = f'{ticker}.{direction.side_name}'
|
|
1312
|
+
_ = self._inv.get(key, [])
|
|
1313
|
+
|
|
1314
|
+
merged_entry = None
|
|
1315
|
+
for entry in _:
|
|
1316
|
+
if merged_entry is None:
|
|
1317
|
+
merged_entry = entry
|
|
1318
|
+
else:
|
|
1319
|
+
merged_entry = merged_entry + entry
|
|
1320
|
+
|
|
1321
|
+
return merged_entry
|
|
1322
|
+
|
|
1323
|
+
def use_inv(self, ticker: str, volume: float, direction: TransactionSide = TransactionSide.LongOpen):
|
|
1324
|
+
key = f'{ticker}.{direction.side_name}'
|
|
1325
|
+
|
|
1326
|
+
self._traded[key] = self._traded.get(key, 0.) + volume
|
|
1327
|
+
|
|
1328
|
+
def available_volume(self, ticker: str, direction: TransactionSide = TransactionSide.LongOpen) -> float:
|
|
1329
|
+
inv = self.get_inv(ticker=ticker, direction=direction)
|
|
1330
|
+
|
|
1331
|
+
if inv is None:
|
|
1332
|
+
return 0.
|
|
1333
|
+
|
|
1334
|
+
used = self._traded.get(ticker, 0.)
|
|
1335
|
+
return inv.available - used
|
|
1336
|
+
|
|
1337
|
+
def clear(self):
|
|
1338
|
+
self._inv.clear()
|
|
1339
|
+
self._traded.clear()
|
|
1340
|
+
self._tickers.clear()
|
|
1341
|
+
|
|
1342
|
+
def to_json(self, fmt='str') -> str | dict:
|
|
1343
|
+
json_dict = {}
|
|
1344
|
+
|
|
1345
|
+
for name in self._inv:
|
|
1346
|
+
json_dict[name] = {
|
|
1347
|
+
'used': 0.,
|
|
1348
|
+
'inv': []
|
|
1349
|
+
}
|
|
1350
|
+
_ = self._inv[name]
|
|
1351
|
+
|
|
1352
|
+
for entry in _:
|
|
1353
|
+
json_dict[name]['inv'].append(entry.to_json(fmt=fmt))
|
|
1354
|
+
|
|
1355
|
+
json_dict[name]['used'] = self._traded.get(name, 0.)
|
|
1356
|
+
|
|
1357
|
+
if fmt == 'dict':
|
|
1358
|
+
return json_dict
|
|
1359
|
+
else:
|
|
1360
|
+
return json.dumps(json_dict)
|
|
1361
|
+
|
|
1362
|
+
def from_json(self, json_str: str | dict, with_used=False):
|
|
1363
|
+
if isinstance(json_str, (str, bytes)):
|
|
1364
|
+
json_dict = json.loads(json_str)
|
|
1365
|
+
elif isinstance(json_str, dict):
|
|
1366
|
+
json_dict = json_str
|
|
1367
|
+
else:
|
|
1368
|
+
raise TypeError(f'Invalid type {type(json_str)}, expect [str, bytes, dict]')
|
|
1369
|
+
|
|
1370
|
+
for name in json_dict:
|
|
1371
|
+
inv = json_dict[name]['inv']
|
|
1372
|
+
used = json_dict[name]['used']
|
|
1373
|
+
|
|
1374
|
+
for entry_json in inv:
|
|
1375
|
+
entry = self.Entry.from_json(entry_json)
|
|
1376
|
+
self.add_inv(entry=entry)
|
|
1377
|
+
|
|
1378
|
+
if with_used:
|
|
1379
|
+
self._traded[name] = used
|
|
1380
|
+
|
|
1381
|
+
return self
|
|
1382
|
+
|
|
1383
|
+
def dump(self, file_path: str | pathlib.Path):
|
|
1384
|
+
file_path = pathlib.Path(file_path)
|
|
1385
|
+
dump_dir = file_path.parent
|
|
1386
|
+
|
|
1387
|
+
os.makedirs(dump_dir, exist_ok=True)
|
|
1388
|
+
|
|
1389
|
+
with open(file_path, 'w') as f:
|
|
1390
|
+
f.write(json.dumps(self.to_json(fmt='dict'), indent=4, sort_keys=True))
|
|
1391
|
+
|
|
1392
|
+
def to_csv(self, file_path: str | pathlib.Path):
|
|
1393
|
+
inv_dict = {'inv_l': {}, 'inv_s': {}}
|
|
1394
|
+
|
|
1395
|
+
for ticker in self._inv:
|
|
1396
|
+
if (long_inv := self.get_inv(ticker=ticker, direction=TransactionSide.LongOpen)) is not None:
|
|
1397
|
+
inv_dict['inv_l'][ticker] = long_inv.volume
|
|
1398
|
+
|
|
1399
|
+
if (short_inv := self.get_inv(ticker=ticker, direction=TransactionSide.ShortOpen)) is not None:
|
|
1400
|
+
inv_dict['inv_s'][ticker] = short_inv.volume
|
|
1401
|
+
|
|
1402
|
+
inv_df = pd.DataFrame(inv_dict)
|
|
1403
|
+
inv_df.to_csv(file_path)
|
|
1404
|
+
|
|
1405
|
+
def load(self, file_path: str | pathlib.Path, with_used=False):
|
|
1406
|
+
if not os.path.isfile(file_path):
|
|
1407
|
+
LOGGER.error(f'No such file {file_path}')
|
|
1408
|
+
return
|
|
1409
|
+
|
|
1410
|
+
with open(file_path, 'r') as f:
|
|
1411
|
+
json_str = f.read()
|
|
1412
|
+
|
|
1413
|
+
self.clear()
|
|
1414
|
+
self.from_json(json_str, with_used=with_used)
|
|
1415
|
+
|
|
1416
|
+
@property
|
|
1417
|
+
def tickers(self):
|
|
1418
|
+
return self._tickers
|
|
1419
|
+
|
|
1420
|
+
@property
|
|
1421
|
+
def info(self) -> pd.DataFrame:
|
|
1422
|
+
info_dict = {'inv_l': {}, 'inv_s': {}}
|
|
1423
|
+
|
|
1424
|
+
for ticker in self.tickers:
|
|
1425
|
+
inv_l = self.get_inv(ticker, TransactionSide.LongOpen)
|
|
1426
|
+
inv_s = self.get_inv(ticker, TransactionSide.ShortOpen)
|
|
1427
|
+
|
|
1428
|
+
if inv_l is not None:
|
|
1429
|
+
info_dict['inv_l'][ticker] = inv_l.volume
|
|
1430
|
+
|
|
1431
|
+
if inv_s is not None:
|
|
1432
|
+
info_dict['inv_s'][ticker] = inv_s.volume
|
|
1433
|
+
|
|
1434
|
+
return pd.DataFrame(info_dict)
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
class RiskProfile(object, metaclass=Singleton):
|
|
1438
|
+
class Risk(Exception):
|
|
1439
|
+
def __init__(self, risk_type: str, code: int, msg: str, *args, **kwargs):
|
|
1440
|
+
self.code = code
|
|
1441
|
+
self.type = risk_type
|
|
1442
|
+
self.msg = msg
|
|
1443
|
+
|
|
1444
|
+
super().__init__(msg, *args)
|
|
1445
|
+
|
|
1446
|
+
for kwarg in kwargs:
|
|
1447
|
+
setattr(self, kwarg, kwargs[kwarg])
|
|
1448
|
+
|
|
1449
|
+
def __init__(self, mds: MarketDataService, balance: Balance, **kwargs):
|
|
1450
|
+
self.mds = mds
|
|
1451
|
+
self.balance = balance
|
|
1452
|
+
|
|
1453
|
+
self.rules = NameSpace(
|
|
1454
|
+
entry=set(),
|
|
1455
|
+
# --- individual constrains ---
|
|
1456
|
+
max_percentile={},
|
|
1457
|
+
max_trade_long={},
|
|
1458
|
+
max_trade_short={},
|
|
1459
|
+
max_exposure_long={},
|
|
1460
|
+
max_exposure_short={},
|
|
1461
|
+
max_notional_long={},
|
|
1462
|
+
max_notional_short={},
|
|
1463
|
+
# --- global constrains ---
|
|
1464
|
+
max_ttl_notional_long=None,
|
|
1465
|
+
max_ttl_notional_short=None,
|
|
1466
|
+
max_net_notional_long=None,
|
|
1467
|
+
max_net_notional_short=None,
|
|
1468
|
+
)
|
|
1469
|
+
|
|
1470
|
+
self.rules.update(kwargs)
|
|
1471
|
+
|
|
1472
|
+
def __repr__(self):
|
|
1473
|
+
return f'<RiskProfile>{{id={id(self)}}}'
|
|
1474
|
+
|
|
1475
|
+
def __call__(self, *order: TradeInstruction):
|
|
1476
|
+
if len(order) == 1:
|
|
1477
|
+
return self.check(order=order[0])
|
|
1478
|
+
else:
|
|
1479
|
+
return self.check_basket(*order)
|
|
1480
|
+
|
|
1481
|
+
def set_rule(self, key: str, value: float, ticker: str = None):
|
|
1482
|
+
if key in self.rules:
|
|
1483
|
+
limit_set = self.rules[key]
|
|
1484
|
+
new_limit = value
|
|
1485
|
+
|
|
1486
|
+
# update global constrains
|
|
1487
|
+
if ticker is None:
|
|
1488
|
+
if not isinstance(limit_set, dict):
|
|
1489
|
+
old_limit = limit_set
|
|
1490
|
+
self.rules[key] = new_limit
|
|
1491
|
+
LOGGER.info(f'{self} limit updated: <{key}>: {old_limit} -> {new_limit}')
|
|
1492
|
+
else:
|
|
1493
|
+
LOGGER.error(f'Invalid action: limit <{key}> requires a valid ticker')
|
|
1494
|
+
# update individual constrains
|
|
1495
|
+
else:
|
|
1496
|
+
if isinstance(limit_set, dict):
|
|
1497
|
+
self.rules.entry.add(ticker)
|
|
1498
|
+
old_limit = limit_set.get(ticker, 'null')
|
|
1499
|
+
self.rules[key][ticker] = new_limit
|
|
1500
|
+
LOGGER.info(f'{self} limit updated: <{key}>({ticker}): {old_limit} -> {new_limit}')
|
|
1501
|
+
else:
|
|
1502
|
+
LOGGER.error(f'Invalid action: can not set any ticker for limit <{key}>')
|
|
1503
|
+
else:
|
|
1504
|
+
LOGGER.error(f'Invalid action: limit <{key}> not found!')
|
|
1505
|
+
|
|
1506
|
+
def get(self, ticker: str) -> dict[str, float | dict[str, float]]:
|
|
1507
|
+
limit = NameSpace(name=f'RiskLimit.{ticker}', market_price=self.mds.market_price.get(ticker))
|
|
1508
|
+
|
|
1509
|
+
limit['working'] = self._get_volume(ticker=ticker, flag='working')
|
|
1510
|
+
limit['traded'] = self._get_volume(ticker=ticker, flag='traded')
|
|
1511
|
+
limit['exposure'] = self._get_volume(ticker=ticker, flag='exposure')
|
|
1512
|
+
|
|
1513
|
+
# --- global constrains ---
|
|
1514
|
+
if self.rules.max_ttl_notional_long is not None:
|
|
1515
|
+
limit['max_ttl_notional_long'] = self.rules.max_ttl_notional_long
|
|
1516
|
+
|
|
1517
|
+
if self.rules.max_ttl_notional_short is not None:
|
|
1518
|
+
limit['max_ttl_notional_short'] = self.rules.max_ttl_notional_short
|
|
1519
|
+
|
|
1520
|
+
if self.rules.max_net_notional_long is not None:
|
|
1521
|
+
limit['max_net_notional_long'] = self.rules.max_net_notional_long
|
|
1522
|
+
|
|
1523
|
+
if self.rules.max_net_notional_short is not None:
|
|
1524
|
+
limit['max_net_notional_short'] = self.rules.max_net_notional_short
|
|
1525
|
+
|
|
1526
|
+
# --- individual constrains ---
|
|
1527
|
+
if ticker in self.rules.max_percentile:
|
|
1528
|
+
limit['max_percentile'] = self.rules.max_percentile.get(ticker, 1.)
|
|
1529
|
+
|
|
1530
|
+
if ticker in self.rules.max_trade_long:
|
|
1531
|
+
limit['max_trade_long'] = self.rules.max_trade_long.get(ticker, np.inf)
|
|
1532
|
+
|
|
1533
|
+
if ticker in self.rules.max_trade_short:
|
|
1534
|
+
limit['max_trade_short'] = self.rules.max_trade_short.get(ticker, np.inf)
|
|
1535
|
+
|
|
1536
|
+
if ticker in self.rules.max_exposure_long:
|
|
1537
|
+
limit['max_exposure_long'] = self.rules.max_exposure_long.get(ticker, np.inf)
|
|
1538
|
+
|
|
1539
|
+
if ticker in self.rules.max_exposure_short:
|
|
1540
|
+
limit['max_exposure_short'] = self.rules.max_exposure_short.get(ticker, np.inf)
|
|
1541
|
+
|
|
1542
|
+
if ticker in self.rules.max_notional_long:
|
|
1543
|
+
limit['max_notional_long'] = self.rules.max_notional_long.get(ticker, np.inf)
|
|
1544
|
+
|
|
1545
|
+
if ticker in self.rules.max_notional_short:
|
|
1546
|
+
limit['max_notional_short'] = self.rules.max_notional_short.get(ticker, np.inf)
|
|
1547
|
+
|
|
1548
|
+
return limit
|
|
1549
|
+
|
|
1550
|
+
def check(self, order: TradeInstruction):
|
|
1551
|
+
ticker = order.ticker
|
|
1552
|
+
|
|
1553
|
+
# step 0: get limits
|
|
1554
|
+
limit = self.get(ticker=ticker)
|
|
1555
|
+
LOGGER.info(f'{self} defines {limit}')
|
|
1556
|
+
|
|
1557
|
+
try:
|
|
1558
|
+
# step 0: check validity
|
|
1559
|
+
self._check_validity(order=order, limit=limit)
|
|
1560
|
+
|
|
1561
|
+
# step 1: check inventory limit
|
|
1562
|
+
self._check_max_trade(order=order, limit=limit)
|
|
1563
|
+
|
|
1564
|
+
# step 2: check position limit
|
|
1565
|
+
self._check_max_exposure(order=order, limit=limit)
|
|
1566
|
+
|
|
1567
|
+
# step 3: check percentile limit
|
|
1568
|
+
self._check_max_percentile(order=order, limit=limit)
|
|
1569
|
+
|
|
1570
|
+
# step 4: check notional limit
|
|
1571
|
+
self._check_max_notional(order=order, limit=limit)
|
|
1572
|
+
|
|
1573
|
+
# step 5: check portfolio net limit
|
|
1574
|
+
self._check_net_portfolio(order=order, limit=limit)
|
|
1575
|
+
|
|
1576
|
+
# step 6: check portfolio total limit
|
|
1577
|
+
self._check_ttl_portfolio(order=order, limit=limit)
|
|
1578
|
+
except self.Risk as e:
|
|
1579
|
+
LOGGER.error(f'<{e.type}.{e.code}>: {e.msg}')
|
|
1580
|
+
return False
|
|
1581
|
+
|
|
1582
|
+
return True
|
|
1583
|
+
|
|
1584
|
+
def check_order(self, ticker: str, volume: float, side: TransactionSide):
|
|
1585
|
+
fake_order = TradeInstruction(
|
|
1586
|
+
ticker=ticker,
|
|
1587
|
+
side=side,
|
|
1588
|
+
volume=volume,
|
|
1589
|
+
timestamp=self.mds.timestamp
|
|
1590
|
+
)
|
|
1591
|
+
|
|
1592
|
+
return self.check(order=fake_order)
|
|
1593
|
+
|
|
1594
|
+
def check_basket(self, *order: TradeInstruction):
|
|
1595
|
+
LOGGER.warning('risk control for basket order not implemented, check order individually')
|
|
1596
|
+
|
|
1597
|
+
for _ in order:
|
|
1598
|
+
self.check(_)
|
|
1599
|
+
|
|
1600
|
+
def clear(self):
|
|
1601
|
+
self.rules.entry.clear()
|
|
1602
|
+
|
|
1603
|
+
self.rules.max_percentile.clear()
|
|
1604
|
+
self.rules.max_trade_long.clear()
|
|
1605
|
+
self.rules.max_trade_short.clear()
|
|
1606
|
+
self.rules.max_exposure_long.clear()
|
|
1607
|
+
self.rules.max_exposure_short.clear()
|
|
1608
|
+
self.rules.max_notional_long.clear()
|
|
1609
|
+
self.rules.max_notional_short.clear()
|
|
1610
|
+
|
|
1611
|
+
self.rules.max_ttl_notional_long = np.inf
|
|
1612
|
+
self.rules.max_ttl_notional_short = np.inf
|
|
1613
|
+
self.rules.max_net_notional_long = np.inf
|
|
1614
|
+
self.rules.max_net_notional_short = np.inf
|
|
1615
|
+
|
|
1616
|
+
def to_json(self, fmt='str') -> str | dict:
|
|
1617
|
+
json_dict = dict(self.rules)
|
|
1618
|
+
json_dict['entry'] = list(json_dict['entry'])
|
|
1619
|
+
|
|
1620
|
+
if fmt == 'dict':
|
|
1621
|
+
return json_dict
|
|
1622
|
+
else:
|
|
1623
|
+
return json.dumps(json_dict)
|
|
1624
|
+
|
|
1625
|
+
def from_json(self, json_str: str | dict):
|
|
1626
|
+
if isinstance(json_str, (str, bytes)):
|
|
1627
|
+
json_dict = json.loads(json_str)
|
|
1628
|
+
elif isinstance(json_str, dict):
|
|
1629
|
+
json_dict = json_str
|
|
1630
|
+
else:
|
|
1631
|
+
raise TypeError(f'Invalid type {type(json_str)}, expect [str, bytes, dict]')
|
|
1632
|
+
|
|
1633
|
+
self.rules.update(json_dict)
|
|
1634
|
+
self.rules['entry'] = set(self.rules['entry'])
|
|
1635
|
+
|
|
1636
|
+
return self
|
|
1637
|
+
|
|
1638
|
+
def dump(self, file_path: str | pathlib.Path):
|
|
1639
|
+
file_path = pathlib.Path(file_path)
|
|
1640
|
+
dump_dir = file_path.parent
|
|
1641
|
+
|
|
1642
|
+
os.makedirs(dump_dir, exist_ok=True)
|
|
1643
|
+
|
|
1644
|
+
with open(file_path, 'w') as f:
|
|
1645
|
+
f.write(json.dumps(self.to_json(fmt='dict'), indent=4, sort_keys=True))
|
|
1646
|
+
|
|
1647
|
+
def load(self, file_path: str | pathlib.Path):
|
|
1648
|
+
if not os.path.isfile(file_path):
|
|
1649
|
+
LOGGER.error(f'No such file {file_path}')
|
|
1650
|
+
return
|
|
1651
|
+
|
|
1652
|
+
with open(file_path, 'r') as f:
|
|
1653
|
+
json_str = f.read()
|
|
1654
|
+
|
|
1655
|
+
self.from_json(json_str)
|
|
1656
|
+
|
|
1657
|
+
def _check_validity(self, order: TradeInstruction, limit: dict[str, float | dict[str, float]]):
|
|
1658
|
+
ticker = order.ticker
|
|
1659
|
+
market_price = limit['market_price']
|
|
1660
|
+
|
|
1661
|
+
if market_price is None:
|
|
1662
|
+
raise self.Risk(
|
|
1663
|
+
risk_type='RiskProfile.Internal.Price',
|
|
1664
|
+
code=100,
|
|
1665
|
+
msg=f'no valid market price for ticker {ticker}'
|
|
1666
|
+
)
|
|
1667
|
+
|
|
1668
|
+
return True
|
|
1669
|
+
|
|
1670
|
+
def _check_max_trade(self, order: TradeInstruction, limit: dict[str, float | dict[str, float]]):
|
|
1671
|
+
ticker = order.ticker
|
|
1672
|
+
action = abs(order.volume)
|
|
1673
|
+
side = order.side
|
|
1674
|
+
|
|
1675
|
+
if side.sign > 0:
|
|
1676
|
+
flag = 'long'
|
|
1677
|
+
elif side.sign < 0:
|
|
1678
|
+
flag = 'short'
|
|
1679
|
+
else:
|
|
1680
|
+
return
|
|
1681
|
+
|
|
1682
|
+
if f'max_trade_{flag}' not in limit:
|
|
1683
|
+
raise self.Risk(
|
|
1684
|
+
risk_type='RiskProfile.TradeLimit.Invalid',
|
|
1685
|
+
code=1003,
|
|
1686
|
+
msg=f'{ticker} {side.sign * action} rejected! {ticker} not trade-able!'
|
|
1687
|
+
)
|
|
1688
|
+
|
|
1689
|
+
trade_limit = limit[f'max_trade_{flag}']
|
|
1690
|
+
working = limit['working']
|
|
1691
|
+
traded = limit['traded']
|
|
1692
|
+
trade_count = working[flag] + traded[flag]
|
|
1693
|
+
|
|
1694
|
+
# for long order
|
|
1695
|
+
if side.sign > 0:
|
|
1696
|
+
if trade_count + action > trade_limit:
|
|
1697
|
+
raise self.Risk(
|
|
1698
|
+
risk_type='RiskProfile.TradeLimit.Long',
|
|
1699
|
+
code=1001,
|
|
1700
|
+
msg=f'{ticker} {side.sign * action} rejected! lmt={trade_limit}, ttl={trade_count}, inv={trade_limit - trade_count}, action={action}'
|
|
1701
|
+
)
|
|
1702
|
+
elif side.sign < 0:
|
|
1703
|
+
if trade_count + action > trade_limit:
|
|
1704
|
+
raise self.Risk(
|
|
1705
|
+
risk_type='RiskProfile.TradeLimit.Short',
|
|
1706
|
+
code=1002,
|
|
1707
|
+
msg=f'{ticker} {side.sign * action} rejected! lmt={trade_limit}, ttl={trade_count}, inv={trade_limit - trade_count}, action={-action}'
|
|
1708
|
+
)
|
|
1709
|
+
|
|
1710
|
+
return True
|
|
1711
|
+
|
|
1712
|
+
def _check_max_exposure(self, order: TradeInstruction, limit: dict[str, float | dict[str, float]]):
|
|
1713
|
+
ticker = order.ticker
|
|
1714
|
+
action = abs(order.volume)
|
|
1715
|
+
side = order.side
|
|
1716
|
+
|
|
1717
|
+
if side.sign > 0:
|
|
1718
|
+
flag = 'long'
|
|
1719
|
+
elif side.sign < 0:
|
|
1720
|
+
flag = 'short'
|
|
1721
|
+
else:
|
|
1722
|
+
return
|
|
1723
|
+
|
|
1724
|
+
if f'max_exposure_{flag}' not in limit:
|
|
1725
|
+
return
|
|
1726
|
+
|
|
1727
|
+
working = limit['working']
|
|
1728
|
+
exposure = limit['exposure']
|
|
1729
|
+
max_exposure = limit[f'max_exposure_{flag}']
|
|
1730
|
+
working = working[flag]
|
|
1731
|
+
|
|
1732
|
+
ttl_exposure = exposure['long'] - exposure['short']
|
|
1733
|
+
|
|
1734
|
+
expectation_volume_0 = ttl_exposure + working
|
|
1735
|
+
expectation_volume_1 = ttl_exposure + action * side.sign
|
|
1736
|
+
expectation_volume_2 = ttl_exposure + action * side.sign + working
|
|
1737
|
+
|
|
1738
|
+
if side.sign > 0:
|
|
1739
|
+
if expectation_volume_0 <= max_exposure \
|
|
1740
|
+
and expectation_volume_1 <= max_exposure \
|
|
1741
|
+
and expectation_volume_2 <= max_exposure:
|
|
1742
|
+
return True
|
|
1743
|
+
else:
|
|
1744
|
+
raise self.Risk(
|
|
1745
|
+
risk_type='RiskProfile.ExposureLimit.Long',
|
|
1746
|
+
code=2001,
|
|
1747
|
+
msg=f'{ticker} {side.sign * action} rejected! lmt_exp={max_exposure}, exp={ttl_exposure}, working={working}, action={action}'
|
|
1748
|
+
)
|
|
1749
|
+
elif side.sign < 0:
|
|
1750
|
+
if expectation_volume_0 >= -max_exposure \
|
|
1751
|
+
and expectation_volume_1 >= -max_exposure \
|
|
1752
|
+
and expectation_volume_2 >= -max_exposure:
|
|
1753
|
+
return True
|
|
1754
|
+
else:
|
|
1755
|
+
raise self.Risk(
|
|
1756
|
+
risk_type='RiskProfile.ExposureLimit.Short',
|
|
1757
|
+
code=2002,
|
|
1758
|
+
msg=f'{ticker} {side.sign * action} rejected! lmt_exp={max_exposure}, exp={ttl_exposure}, working={working}, action={-action}'
|
|
1759
|
+
)
|
|
1760
|
+
|
|
1761
|
+
def _check_max_percentile(self, order: TradeInstruction, limit: dict[str, float | dict[str, float]]):
|
|
1762
|
+
ticker = order.ticker
|
|
1763
|
+
action = abs(order.volume)
|
|
1764
|
+
side = order.side
|
|
1765
|
+
|
|
1766
|
+
if 'max_percentile' not in limit:
|
|
1767
|
+
return
|
|
1768
|
+
|
|
1769
|
+
max_percentile = limit['max_percentile']
|
|
1770
|
+
market_price = limit['market_price']
|
|
1771
|
+
total_notional = sum([abs(_) for _ in self.balance.exposure_notional(mds=self.mds).values()])
|
|
1772
|
+
|
|
1773
|
+
if np.isfinite(max_percentile) and max_percentile < 1 and np.isfinite(total_notional):
|
|
1774
|
+
max_notional = np.divide(total_notional, 1 - max_percentile) * max_percentile
|
|
1775
|
+
else:
|
|
1776
|
+
return True
|
|
1777
|
+
|
|
1778
|
+
max_position = np.divide(max_notional, market_price)
|
|
1779
|
+
|
|
1780
|
+
working = limit['working']
|
|
1781
|
+
exposure = limit['exposure']
|
|
1782
|
+
|
|
1783
|
+
ttl_exposure = exposure['long'] - exposure['short']
|
|
1784
|
+
|
|
1785
|
+
if side.sign > 0:
|
|
1786
|
+
working = working['long']
|
|
1787
|
+
elif side.sign < 0:
|
|
1788
|
+
working = working['short']
|
|
1789
|
+
else:
|
|
1790
|
+
return True
|
|
1791
|
+
|
|
1792
|
+
expectation_volume_0 = ttl_exposure + working
|
|
1793
|
+
expectation_volume_1 = ttl_exposure + action * side.sign
|
|
1794
|
+
expectation_volume_2 = ttl_exposure + action * side.sign + working
|
|
1795
|
+
|
|
1796
|
+
if abs(expectation_volume_0) <= max_position \
|
|
1797
|
+
and abs(expectation_volume_1) <= max_position \
|
|
1798
|
+
and abs(expectation_volume_2) <= max_position:
|
|
1799
|
+
return True
|
|
1800
|
+
|
|
1801
|
+
if side.sign > 0:
|
|
1802
|
+
raise self.Risk(
|
|
1803
|
+
risk_type='RiskProfile.PercentileLimit.Long',
|
|
1804
|
+
code=3001,
|
|
1805
|
+
msg=f'{ticker} {side.sign * action} rejected! lmt_pct={max_percentile}, lmt_exp={max_position}, exp={ttl_exposure}, working={working}, action={action}'
|
|
1806
|
+
)
|
|
1807
|
+
elif side.sign < 0:
|
|
1808
|
+
raise self.Risk(
|
|
1809
|
+
risk_type='RiskProfile.PercentileLimit.Short',
|
|
1810
|
+
code=3002,
|
|
1811
|
+
msg=f'{ticker} {side.sign * action} rejected! lmt_pct={max_percentile}, lmt_exp={max_position}, exp={ttl_exposure}, working={working}, action={-action}'
|
|
1812
|
+
)
|
|
1813
|
+
|
|
1814
|
+
def _check_max_notional(self, order: TradeInstruction, limit: dict[str, float | dict[str, float]]):
|
|
1815
|
+
ticker = order.ticker
|
|
1816
|
+
action = abs(order.volume)
|
|
1817
|
+
side = order.side
|
|
1818
|
+
|
|
1819
|
+
if side.sign > 0:
|
|
1820
|
+
flag = 'long'
|
|
1821
|
+
elif side.sign < 0:
|
|
1822
|
+
flag = 'short'
|
|
1823
|
+
else:
|
|
1824
|
+
return
|
|
1825
|
+
|
|
1826
|
+
if f'max_notional_{flag}' not in limit:
|
|
1827
|
+
return
|
|
1828
|
+
|
|
1829
|
+
market_price = limit['market_price']
|
|
1830
|
+
working = limit['working']
|
|
1831
|
+
exposure = limit['exposure']
|
|
1832
|
+
max_notional = limit[f'max_notional_{flag}']
|
|
1833
|
+
working = working[flag]
|
|
1834
|
+
ttl_exposure = exposure['long'] - exposure['short']
|
|
1835
|
+
max_position = np.divide(max_notional, market_price)
|
|
1836
|
+
|
|
1837
|
+
expectation_volume_0 = ttl_exposure + working
|
|
1838
|
+
expectation_volume_1 = ttl_exposure + action * side.sign
|
|
1839
|
+
expectation_volume_2 = ttl_exposure + action * side.sign + working
|
|
1840
|
+
|
|
1841
|
+
if side.sign > 0:
|
|
1842
|
+
if expectation_volume_0 <= max_position \
|
|
1843
|
+
and expectation_volume_1 <= max_position \
|
|
1844
|
+
and expectation_volume_2 <= max_position:
|
|
1845
|
+
return True
|
|
1846
|
+
else:
|
|
1847
|
+
raise self.Risk(
|
|
1848
|
+
risk_type='RiskProfile.NotionalLimit.Long',
|
|
1849
|
+
code=4001,
|
|
1850
|
+
msg=f'{ticker} {side.sign * action} rejected! lmt_ntl={max_notional}, lmt_exp={max_position}, exp={ttl_exposure}, working={working}, action={action}'
|
|
1851
|
+
)
|
|
1852
|
+
elif side.sign < 0:
|
|
1853
|
+
if expectation_volume_0 >= -max_position \
|
|
1854
|
+
and expectation_volume_1 >= -max_position \
|
|
1855
|
+
and expectation_volume_2 >= -max_position:
|
|
1856
|
+
return True
|
|
1857
|
+
else:
|
|
1858
|
+
raise self.Risk(
|
|
1859
|
+
risk_type='RiskProfile.NotionalLimit.Short',
|
|
1860
|
+
code=4002,
|
|
1861
|
+
msg=f'{ticker} {side.sign * action} rejected! lmt_ntl={max_notional}, lmt_exp={max_position}, exp={ttl_exposure}, working={working}, action={-action}'
|
|
1862
|
+
)
|
|
1863
|
+
|
|
1864
|
+
def _check_net_portfolio(self, order: TradeInstruction, limit: dict[str, float | dict[str, float]]):
|
|
1865
|
+
ticker = order.ticker
|
|
1866
|
+
action = abs(order.volume)
|
|
1867
|
+
side = order.side
|
|
1868
|
+
|
|
1869
|
+
if side.sign > 0:
|
|
1870
|
+
flag = 'long'
|
|
1871
|
+
elif side.sign < 0:
|
|
1872
|
+
flag = 'short'
|
|
1873
|
+
else:
|
|
1874
|
+
return
|
|
1875
|
+
|
|
1876
|
+
if f'max_net_notional_{flag}' not in limit:
|
|
1877
|
+
return
|
|
1878
|
+
|
|
1879
|
+
max_net_notional = limit[f'max_net_notional_{flag}']
|
|
1880
|
+
market_price = limit['market_price']
|
|
1881
|
+
portfolio_working_notional = self.balance.working_notional(mds=self.mds)
|
|
1882
|
+
portfolio_exposure_notional = self.balance.exposure_notional(mds=self.mds)
|
|
1883
|
+
|
|
1884
|
+
net_exposure = sum(portfolio_exposure_notional.values())
|
|
1885
|
+
net_working = sum(portfolio_working_notional.values())
|
|
1886
|
+
|
|
1887
|
+
expectation_var_0 = net_exposure + net_working
|
|
1888
|
+
expectation_var_1 = net_exposure + action * side.sign * market_price
|
|
1889
|
+
expectation_var_2 = net_exposure + action * side.sign * market_price + net_working
|
|
1890
|
+
|
|
1891
|
+
if side.sign > 0:
|
|
1892
|
+
if expectation_var_0 <= max_net_notional \
|
|
1893
|
+
and expectation_var_1 <= max_net_notional \
|
|
1894
|
+
and expectation_var_2 <= max_net_notional:
|
|
1895
|
+
return True
|
|
1896
|
+
|
|
1897
|
+
raise self.Risk(
|
|
1898
|
+
risk_type='RiskProfile.NotionalLimit.PortfolioNet.Long',
|
|
1899
|
+
code=5001,
|
|
1900
|
+
msg=f'{ticker} {side.sign * action} rejected! lmt_ntl={max_net_notional}, net_exp={net_exposure}, net_working={net_working}, action={action}'
|
|
1901
|
+
)
|
|
1902
|
+
elif side.sign < 0:
|
|
1903
|
+
if -max_net_notional <= expectation_var_0 \
|
|
1904
|
+
and -max_net_notional <= expectation_var_1 \
|
|
1905
|
+
and -max_net_notional <= expectation_var_2:
|
|
1906
|
+
return True
|
|
1907
|
+
|
|
1908
|
+
raise self.Risk(
|
|
1909
|
+
risk_type='RiskProfile.NotionalLimit.PortfolioNet.Short',
|
|
1910
|
+
code=5002,
|
|
1911
|
+
msg=f'{ticker} {side.sign * action} rejected! lmt_ntl={max_net_notional}, net_exp={net_exposure}, net_working={net_working}, action={action}'
|
|
1912
|
+
)
|
|
1913
|
+
|
|
1914
|
+
def _check_ttl_portfolio(self, order: TradeInstruction, limit: dict[str, float | dict[str, float]]):
|
|
1915
|
+
ticker = order.ticker
|
|
1916
|
+
action = abs(order.volume)
|
|
1917
|
+
side = order.side
|
|
1918
|
+
|
|
1919
|
+
if side.sign > 0:
|
|
1920
|
+
flag = 'long'
|
|
1921
|
+
elif side.sign < 0:
|
|
1922
|
+
flag = 'short'
|
|
1923
|
+
else:
|
|
1924
|
+
return
|
|
1925
|
+
|
|
1926
|
+
if f'max_ttl_notional_{flag}' not in limit:
|
|
1927
|
+
return
|
|
1928
|
+
|
|
1929
|
+
market_price = limit['market_price']
|
|
1930
|
+
max_notional = limit[f'max_ttl_notional_{flag}']
|
|
1931
|
+
working_notional = {'long': 0., 'short': 0.}
|
|
1932
|
+
exposure_notional = {'long': 0., 'short': 0.}
|
|
1933
|
+
|
|
1934
|
+
for order_id in list(self.balance.working_order):
|
|
1935
|
+
order = self.balance.working_order.get(order_id, None)
|
|
1936
|
+
|
|
1937
|
+
if order is None:
|
|
1938
|
+
continue
|
|
1939
|
+
|
|
1940
|
+
if order.side.sign > 0:
|
|
1941
|
+
working_notional['long'] += abs(order.working_volume) * market_price
|
|
1942
|
+
elif order.side.sign < 0:
|
|
1943
|
+
working_notional['short'] += abs(order.working_volume) * market_price
|
|
1944
|
+
|
|
1945
|
+
for ticker, notional in self.balance.exposure_notional(mds=self.mds).items():
|
|
1946
|
+
if notional > 0:
|
|
1947
|
+
exposure_notional['long'] += abs(notional)
|
|
1948
|
+
else:
|
|
1949
|
+
exposure_notional['short'] += abs(notional)
|
|
1950
|
+
|
|
1951
|
+
ttl_exposure = exposure_notional[flag]
|
|
1952
|
+
ttl_working = working_notional[flag]
|
|
1953
|
+
|
|
1954
|
+
expectation_var_0 = ttl_exposure + ttl_working
|
|
1955
|
+
expectation_var_1 = ttl_exposure + action * market_price
|
|
1956
|
+
expectation_var_2 = ttl_exposure + action * market_price + ttl_working
|
|
1957
|
+
|
|
1958
|
+
if expectation_var_0 <= max_notional \
|
|
1959
|
+
and expectation_var_1 <= max_notional \
|
|
1960
|
+
and expectation_var_2 <= max_notional:
|
|
1961
|
+
return True
|
|
1962
|
+
|
|
1963
|
+
if side.sign > 0:
|
|
1964
|
+
raise self.Risk(
|
|
1965
|
+
risk_type='RiskProfile.NotionalLimit.PortfolioTotal.Long',
|
|
1966
|
+
code=5003,
|
|
1967
|
+
msg=f'{ticker} {side.sign * action} rejected! lmt_ntl={max_notional}, ttl_exp={ttl_exposure}, ttl_working={ttl_working}, action={action}'
|
|
1968
|
+
)
|
|
1969
|
+
elif side.sign < 0:
|
|
1970
|
+
raise self.Risk(
|
|
1971
|
+
risk_type='RiskProfile.NotionalLimit.PortfolioTotal.Short',
|
|
1972
|
+
code=5004,
|
|
1973
|
+
msg=f'{ticker} {side.sign * action} rejected! lmt_ntl={max_notional}, ttl_exp={ttl_exposure}, ttl_working={ttl_working}, action={action}'
|
|
1974
|
+
)
|
|
1975
|
+
|
|
1976
|
+
def _get_volume(self, ticker: str, flag: str = 'working') -> dict[str, float]:
|
|
1977
|
+
volume = {'long': 0., 'short': 0.}
|
|
1978
|
+
if flag == 'working':
|
|
1979
|
+
for order_id in list(self.balance.working_order):
|
|
1980
|
+
order = self.balance.working_order.get(order_id, None)
|
|
1981
|
+
|
|
1982
|
+
if order is None or order.ticker != ticker or not order.is_working:
|
|
1983
|
+
continue
|
|
1984
|
+
|
|
1985
|
+
if order.side.sign > 0:
|
|
1986
|
+
volume['long'] += abs(order.working_volume)
|
|
1987
|
+
elif order.side.sign < 0:
|
|
1988
|
+
volume['short'] += abs(order.working_volume)
|
|
1989
|
+
elif flag == 'exposure':
|
|
1990
|
+
for trade_id in list(self.balance.trades):
|
|
1991
|
+
trade = self.balance.trades.get(trade_id, None)
|
|
1992
|
+
|
|
1993
|
+
if trade is None or trade.ticker != ticker:
|
|
1994
|
+
continue
|
|
1995
|
+
|
|
1996
|
+
if trade.side.sign > 0:
|
|
1997
|
+
volume['long'] += abs(trade.volume)
|
|
1998
|
+
elif trade.side.sign < 0:
|
|
1999
|
+
volume['short'] += abs(trade.volume)
|
|
2000
|
+
elif flag == 'traded':
|
|
2001
|
+
for trade_id in list(self.balance.trades):
|
|
2002
|
+
trade = self.balance.trades.get(trade_id, None)
|
|
2003
|
+
|
|
2004
|
+
if trade is None \
|
|
2005
|
+
or trade.ticker != ticker \
|
|
2006
|
+
or trade.trade_time.date() != self.market_time.date(): # apply to A-Stock when daily inventory is limited
|
|
2007
|
+
continue
|
|
2008
|
+
|
|
2009
|
+
if trade.side.sign > 0:
|
|
2010
|
+
volume['long'] += abs(trade.volume)
|
|
2011
|
+
elif trade.side.sign < 0:
|
|
2012
|
+
volume['short'] += abs(trade.volume)
|
|
2013
|
+
else:
|
|
2014
|
+
raise ValueError(f'Invalid flag {flag}')
|
|
2015
|
+
|
|
2016
|
+
return volume
|
|
2017
|
+
|
|
2018
|
+
@property
|
|
2019
|
+
def market_time(self):
|
|
2020
|
+
return self.mds.market_time
|
|
2021
|
+
|
|
2022
|
+
@property
|
|
2023
|
+
def info(self) -> pd.DataFrame:
|
|
2024
|
+
info_dict = defaultdict(dict)
|
|
2025
|
+
|
|
2026
|
+
rules = self.rules.copy()
|
|
2027
|
+
|
|
2028
|
+
for ticker in rules['entry']:
|
|
2029
|
+
for key in ['max_percentile', 'max_trade_long', 'max_trade_short', 'max_exposure_long', 'max_exposure_short', 'max_notional_long', 'max_notional_short']:
|
|
2030
|
+
if ticker in rules[key]:
|
|
2031
|
+
info_dict[ticker][key] = rules[key][ticker]
|
|
2032
|
+
|
|
2033
|
+
for key in ['max_ttl_notional_long', 'max_ttl_notional_short', 'max_net_notional_long', 'max_net_notional_short']:
|
|
2034
|
+
if rules[key] is not None:
|
|
2035
|
+
info_dict['global'][key] = rules[key]
|
|
2036
|
+
|
|
2037
|
+
return pd.DataFrame(info_dict).T
|