PyAlgoEngine 0.7.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. PyAlgoEngine-0.7.4.dist-info/LICENSE +21 -0
  2. PyAlgoEngine-0.7.4.dist-info/METADATA +27 -0
  3. PyAlgoEngine-0.7.4.dist-info/RECORD +43 -0
  4. PyAlgoEngine-0.7.4.dist-info/WHEEL +5 -0
  5. PyAlgoEngine-0.7.4.dist-info/top_level.txt +1 -0
  6. algo_engine/__init__.py +41 -0
  7. algo_engine/apps/__init__.py +17 -0
  8. algo_engine/apps/backtest/__init__.py +20 -0
  9. algo_engine/apps/backtest/doc_server.py +331 -0
  10. algo_engine/apps/backtest/tester.py +254 -0
  11. algo_engine/apps/backtest/web_app.py +127 -0
  12. algo_engine/apps/bokeh_server.py +205 -0
  13. algo_engine/apps/demo/__init__.py +0 -0
  14. algo_engine/apps/demo/test.py +39 -0
  15. algo_engine/backtest/__init__.py +19 -0
  16. algo_engine/backtest/__main__.py +51 -0
  17. algo_engine/backtest/metrics.py +179 -0
  18. algo_engine/backtest/replay.py +261 -0
  19. algo_engine/backtest/sim_match.py +295 -0
  20. algo_engine/base/__init__.py +40 -0
  21. algo_engine/base/console_utils.py +1070 -0
  22. algo_engine/base/finance_decimal.py +258 -0
  23. algo_engine/base/market_buffer.py +571 -0
  24. algo_engine/base/market_utils.py +3092 -0
  25. algo_engine/base/market_utils_nt.py +188 -0
  26. algo_engine/base/market_utils_posix.py +3004 -0
  27. algo_engine/base/technical_analysis.py +406 -0
  28. algo_engine/base/telemetrics.py +78 -0
  29. algo_engine/base/trade_utils.py +709 -0
  30. algo_engine/engine/__init__.py +28 -0
  31. algo_engine/engine/algo_engine.py +901 -0
  32. algo_engine/engine/event_engine.py +53 -0
  33. algo_engine/engine/market_engine.py +370 -0
  34. algo_engine/engine/trade_engine.py +2037 -0
  35. algo_engine/monitor/__init__.py +15 -0
  36. algo_engine/monitor/advanced_data_interface.py +239 -0
  37. algo_engine/profile/__init__.py +121 -0
  38. algo_engine/profile/cn.py +175 -0
  39. algo_engine/strategy/__init__.py +44 -0
  40. algo_engine/strategy/strategy_engine.py +440 -0
  41. algo_engine/utils/__init__.py +3 -0
  42. algo_engine/utils/commit_regularizer.py +49 -0
  43. algo_engine/utils/data_utils.py +251 -0
@@ -0,0 +1,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