PyAlgoEngine 0.8.0a10__tar.gz → 0.8.0a11__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/PKG-INFO +1 -1
  2. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/PyAlgoEngine.egg-info/PKG-INFO +1 -1
  3. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/__init__.py +1 -1
  4. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/backtest/replay.py +46 -28
  5. pyalgoengine-0.8.0a11/algo_engine/backtest/sim_match.py +505 -0
  6. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/base/__init__.py +4 -4
  7. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/base/candlestick.pyi +4 -0
  8. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/base/tick.pyi +17 -0
  9. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/base/trade_utils.pyi +15 -10
  10. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/base/transaction.pyi +35 -20
  11. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/engine/market_engine.py +36 -55
  12. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/monitor/advanced_data_interface.py +134 -39
  13. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/setup.py +6 -0
  14. pyalgoengine-0.8.0a10/algo_engine/backtest/sim_match.py +0 -333
  15. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/LICENSE +0 -0
  16. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/PyAlgoEngine.egg-info/SOURCES.txt +0 -0
  17. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/PyAlgoEngine.egg-info/dependency_links.txt +0 -0
  18. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/PyAlgoEngine.egg-info/requires.txt +0 -0
  19. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/PyAlgoEngine.egg-info/top_level.txt +0 -0
  20. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/README.md +0 -0
  21. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/apps/__init__.py +0 -0
  22. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/apps/backtest/__init__.py +0 -0
  23. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/apps/backtest/doc_server.py +0 -0
  24. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/apps/backtest/tester.py +0 -0
  25. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/apps/backtest/web_app.py +0 -0
  26. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/apps/bokeh_server.py +0 -0
  27. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/apps/demo/__init__.py +0 -0
  28. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/apps/demo/test.py +0 -0
  29. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/apps/sim_input/__init__.py +0 -0
  30. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/apps/sim_input/client.py +0 -0
  31. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/apps/sim_input/sim_keyboard.py +0 -0
  32. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/apps/sim_input/sim_mouse.py +0 -0
  33. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/apps/sim_input/window.py +0 -0
  34. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/backtest/__init__.py +0 -0
  35. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/backtest/__main__.py +0 -0
  36. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/backtest/metrics.py +0 -0
  37. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/base/console_utils.py +0 -0
  38. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/base/finance_decimal.py +0 -0
  39. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/base/market_data.pyi +0 -0
  40. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/base/market_data_buffer.pyi +0 -0
  41. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/base/market_utils_nt.py +0 -0
  42. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/base/market_utils_posix.py +0 -0
  43. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/base/technical_analysis.py +0 -0
  44. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/base/telemetrics.py +0 -0
  45. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/engine/__init__.py +0 -0
  46. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/engine/algo_engine.py +0 -0
  47. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/engine/event_engine.py +0 -0
  48. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/engine/trade_engine.py +0 -0
  49. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/monitor/__init__.py +0 -0
  50. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/profile/__init__.py +0 -0
  51. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/profile/cn.py +0 -0
  52. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/strategy/__init__.py +0 -0
  53. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/strategy/strategy_engine.py +0 -0
  54. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/utils/__init__.py +0 -0
  55. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/utils/commit_regularizer.py +0 -0
  56. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/algo_engine/utils/data_utils.py +0 -0
  57. {pyalgoengine-0.8.0a10 → pyalgoengine-0.8.0a11}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyAlgoEngine
3
- Version: 0.8.0a10
3
+ Version: 0.8.0a11
4
4
  Summary: Basic algo engine
5
5
  Home-page: https://github.com/BolunHan/PyAlgoEngine
6
6
  Author: Bolun.Han
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyAlgoEngine
3
- Version: 0.8.0a10
3
+ Version: 0.8.0a11
4
4
  Summary: Basic algo engine
5
5
  Home-page: https://github.com/BolunHan/PyAlgoEngine
6
6
  Author: Bolun.Han
@@ -1,4 +1,4 @@
1
- __version__ = "0.8.0.alpha10"
1
+ __version__ = "0.8.0.alpha11"
2
2
 
3
3
  import logging
4
4
  import os
@@ -2,10 +2,11 @@ import abc
2
2
  import datetime
3
3
  import inspect
4
4
  import operator
5
- from typing import Iterable
5
+ from collections.abc import Mapping, Sequence, Iterator
6
+ from typing import Iterable, Protocol
6
7
 
7
8
  from . import LOGGER
8
- from ..base import Progress, TickData, TransactionData, TradeData, OrderBook, MarketData
9
+ from ..base import Progress, TickData, TransactionData, TradeData, OrderData, MarketData, MarketDataBuffer
9
10
 
10
11
  LOGGER = LOGGER.getChild('Replay')
11
12
 
@@ -86,6 +87,11 @@ class SimpleReplay(Replay):
86
87
  return self
87
88
 
88
89
 
90
+ class DataLoader(Protocol):
91
+ def __call__(self, market_date: datetime.date, ticker: str, dtype: str) -> Mapping[float, MarketData] | Sequence[MarketData] | MarketDataBuffer:
92
+ ...
93
+
94
+
89
95
  class ProgressiveReplay(Replay):
90
96
  """
91
97
  progressively loading and replaying market data
@@ -103,7 +109,7 @@ class ProgressiveReplay(Replay):
103
109
 
104
110
  def __init__(
105
111
  self,
106
- loader,
112
+ loader: DataLoader,
107
113
  **kwargs
108
114
  ):
109
115
  self.loader = loader
@@ -117,7 +123,8 @@ class ProgressiveReplay(Replay):
117
123
 
118
124
  self.replay_subscription = {}
119
125
  self.replay_calendar = []
120
- self.replay_task = []
126
+ self.replay_task: Iterator | None = None
127
+ self.replay_task_length: int = 0
121
128
  self.replay_status = {}
122
129
 
123
130
  self.date_progress = 0
@@ -125,7 +132,7 @@ class ProgressiveReplay(Replay):
125
132
  self.progress = Progress(tasks=1, **kwargs)
126
133
 
127
134
  tickers: list[str] = kwargs.pop('ticker', kwargs.pop('tickers', []))
128
- dtypes: list[str | type] = kwargs.pop('dtype', kwargs.pop('dtypes', [TradeData, TransactionData, OrderBook, TickData]))
135
+ dtypes: list[str | type] = kwargs.pop('dtype', kwargs.pop('dtypes', [TradeData, TransactionData, OrderData, TickData]))
129
136
 
130
137
  if not all([arg_name in inspect.getfullargspec(loader).args for arg_name in ['market_date', 'ticker', 'dtype']]):
131
138
  raise TypeError('loader function has 3 requires args, market_date, ticker and dtype.')
@@ -193,6 +200,8 @@ class ProgressiveReplay(Replay):
193
200
  self.replay_status = {market_date: 'skipped' if market_date < self.market_date else 'idle' for market_date in self.replay_calendar}
194
201
 
195
202
  self.task_progress = 0
203
+ self.replay_task_length = 0
204
+ self.replay_task = None
196
205
  self.date_progress = sum([1 for _ in self.replay_calendar if _ < self.market_date])
197
206
  self.progress.reset()
198
207
 
@@ -200,36 +209,45 @@ class ProgressiveReplay(Replay):
200
209
  self.progress.done_tasks = self.date_progress / len(self.replay_calendar)
201
210
 
202
211
  def next_trade_day(self):
203
- if self.date_progress < len(self.replay_calendar):
204
- market_date = self.market_date = self.replay_calendar[self.date_progress]
205
- self.replay_status[market_date] = 'started'
206
- self.progress.prompt = f'Replay {market_date:%Y-%m-%d} ({self.date_progress + 1} / {len(self.replay_calendar)}):'
207
- for topic in self.replay_subscription:
208
- ticker, dtype = self.replay_subscription[topic]
209
- LOGGER.info(f'{self} loading {market_date} {ticker} {dtype}')
210
- data = self.loader(market_date=market_date, ticker=ticker, dtype=dtype)
211
- if isinstance(data, dict):
212
- self.replay_task.extend(list(data.values()))
213
- elif isinstance(data, (list, tuple)):
214
- self.replay_task.extend(data)
215
-
216
- LOGGER.info(f'{market_date} data loaded! {len(self.replay_task):,} entries.')
217
- self.date_progress += 1
218
- else:
212
+ if self.date_progress >= len(self.replay_calendar):
219
213
  raise StopIteration()
220
214
 
221
- self.replay_task.sort(key=operator.attrgetter('timestamp', 'ticker', '__class__.__name__'))
215
+ self.market_date = market_date = self.replay_calendar[self.date_progress]
216
+ self.replay_status[market_date] = 'started'
217
+ self.progress.prompt = f'Replay {market_date:%Y-%m-%d} ({self.date_progress + 1} / {len(self.replay_calendar)}):'
218
+
219
+ for topic in self.replay_subscription:
220
+ ticker, dtype = self.replay_subscription[topic]
221
+ LOGGER.info(f'{self} loading {market_date} {ticker} {dtype}...')
222
+ data = self.loader(market_date=market_date, ticker=ticker, dtype=dtype)
223
+ if isinstance(data, Mapping):
224
+ data = [data[ts] for ts in sorted(data)] # expect to be a mapping of ts and data
225
+ self.replay_task = iter(data)
226
+ self.replay_task_length = len(data)
227
+ elif isinstance(data, Sequence):
228
+ data = sorted(data, key=operator.attrgetter('timestamp', 'ticker', '__class__.__name__'))
229
+ self.replay_task = iter(data)
230
+ self.replay_task_length = len(data)
231
+ elif isinstance(data, MarketDataBuffer):
232
+ data.sort()
233
+ self.replay_task = iter(data)
234
+ self.replay_task_length = len(data)
235
+ else:
236
+ raise TypeError(f'Invalid return type of dataloader, expect list, tuple, dict or MarketDataBuffer, got {type(data)}.')
237
+
238
+ LOGGER.info(f'{market_date} data loaded! {self.replay_task_length:,} entries.')
239
+ self.date_progress += 1
222
240
 
223
241
  def next_task(self):
224
- if self.task_progress < len(self.replay_task):
225
- data = self.replay_task[self.task_progress]
242
+ try:
243
+ data = next(self.replay_task)
226
244
  self.task_progress += 1
227
- else:
245
+ except StopIteration:
228
246
  if self.eod is not None and self.replay_status[self.market_date] == 'started':
229
247
  self.eod(market_date=self.market_date, replay=self)
230
248
  self.replay_status[self.market_date] = 'done'
231
249
 
232
- self.replay_task.clear()
250
+ self.replay_task = None
233
251
  self.task_progress = 0
234
252
 
235
253
  if self.bod is not None and self.date_progress < len(self.replay_calendar):
@@ -242,8 +260,8 @@ class ProgressiveReplay(Replay):
242
260
 
243
261
  data = self.next_task()
244
262
 
245
- if self.replay_task and self.replay_calendar:
246
- current_progress = (self.date_progress - 1 + (self.task_progress / len(self.replay_task))) / len(self.replay_calendar)
263
+ if self.replay_task_length and self.replay_calendar:
264
+ current_progress = (self.date_progress - 1 + (self.task_progress / self.replay_task_length)) / len(self.replay_calendar)
247
265
  self.progress.done_tasks = current_progress
248
266
  else:
249
267
  self.progress.done_tasks = 1
@@ -0,0 +1,505 @@
1
+ import datetime
2
+ import random
3
+
4
+ import numpy as np
5
+
6
+ from . import LOGGER
7
+ from ..base import OrderType, MarketData, BarData, TransactionData, TradeData, TickData, TickDataLite, OrderState, OrderData, TradeReport, TradeInstruction, TransactionSide, TransactionDirection
8
+ from ..engine.event_engine import TOPIC, EVENT_ENGINE
9
+ from ..profile import PROFILE
10
+
11
+ LOGGER = LOGGER.getChild('SimMatch')
12
+
13
+
14
+ class SimMatch(object):
15
+ def __init__(self, ticker: str, event_engine=None, topic_set=None, seed: int = None, **kwargs):
16
+ self.ticker = ticker
17
+ self.event_engine = event_engine if event_engine is not None else EVENT_ENGINE
18
+ self.topic_set = topic_set if topic_set is not None else TOPIC
19
+
20
+ self.working: dict[str, TradeInstruction] = {}
21
+ self.history: dict[str, TradeInstruction] = {}
22
+
23
+ self.timestamp: float = 0.
24
+ self.last_price: float | None = None
25
+ self.last_transaction_count: int = 0
26
+ self.seed = seed
27
+ self.random = random.Random(self.seed)
28
+
29
+ self.matching_config = {
30
+ 'fee_rate': kwargs.get('fee_rate', 0.),
31
+ 'instant_fill': kwargs.get('instant_fill', False),
32
+ 'lag': {
33
+ 'ts': kwargs.get('lag_ts', 0.), # time lag in seconds
34
+ 'n_transaction': kwargs.get('lag_n_transaction', 0) # number of transactions lag
35
+ },
36
+ 'hit': {
37
+ 'prob': kwargs.get('hit_prob', 1.), # probability of order being filled
38
+ 'slippery': kwargs.get('slippery_rate', 0.0001) # slippage rate
39
+ }
40
+ }
41
+
42
+ def __call__(self, **kwargs):
43
+ order: TradeInstruction | None = kwargs.pop('order', None)
44
+ market_data: MarketData | None = kwargs.pop('market_data', None)
45
+
46
+ if order is not None:
47
+ if order.order_type == OrderType.ORDER_LIMIT:
48
+ self.launch_order(order=order)
49
+ elif order.order_type == OrderType.ORDER_CANCEL:
50
+ self.cancel_order(order=order)
51
+ else:
52
+ raise ValueError(f'Invalid order {order}')
53
+
54
+ if market_data is not None:
55
+ self.timestamp = market_data.timestamp
56
+ self.last_price = market_data.market_price
57
+
58
+ # Update transaction count if this is a transaction
59
+ if isinstance(market_data, (TransactionData, TradeData)):
60
+ self.last_transaction_count += 1
61
+
62
+ if isinstance(market_data, BarData):
63
+ self._check_bar_data(market_data=market_data)
64
+ elif isinstance(market_data, TickData):
65
+ self._check_tick_data(market_data=market_data)
66
+ elif isinstance(market_data, TickDataLite):
67
+ self._check_tick_data_lite(market_data=market_data)
68
+ elif isinstance(market_data, OrderData):
69
+ self._check_order_data(market_data=market_data)
70
+ elif isinstance(market_data, (TransactionData, TradeData)):
71
+ self._check_trade_data(market_data=market_data)
72
+
73
+ @staticmethod
74
+ def best_price(*price: float, side: TransactionSide | TransactionDirection) -> float:
75
+ """Get best price for the given side."""
76
+ valid_prices = [p for p in price if p is not None and np.isfinite(p)]
77
+ sign = side.sign
78
+
79
+ if not valid_prices:
80
+ raise ValueError("No valid prices provided")
81
+
82
+ if sign == 1:
83
+ return min(valid_prices)
84
+ elif sign == -1:
85
+ return max(valid_prices)
86
+ else:
87
+ raise ValueError(f'Invalid side {side}!')
88
+
89
+ @staticmethod
90
+ def worst_price(*price: float, side: TransactionSide | TransactionDirection) -> float:
91
+ """Get worst price for the given side."""
92
+ valid_prices = [p for p in price if p is not None and np.isfinite(p)]
93
+ sign = side.sign
94
+
95
+ if not valid_prices:
96
+ raise ValueError("No valid prices provided")
97
+
98
+ if sign == 1:
99
+ return max(valid_prices)
100
+ elif sign == -1:
101
+ return min(valid_prices)
102
+ else:
103
+ raise ValueError(f'Invalid side {side}!')
104
+
105
+ def _apply_lag(self, order: TradeInstruction) -> bool:
106
+ """Check if order should be processed considering lag settings."""
107
+ lag_config = self.matching_config['lag']
108
+ lag_ts = lag_config['ts']
109
+ lag_n_transaction = lag_config['n_transaction']
110
+
111
+ # No lag configured
112
+ if not lag_ts and not lag_n_transaction:
113
+ return True
114
+
115
+ # Check time lag
116
+ time_elapsed = self.timestamp - order.timestamp
117
+ if lag_ts > 0 and time_elapsed < lag_ts:
118
+ return False
119
+
120
+ # Check transaction lag
121
+ transactions_since_order = self.last_transaction_count - order._additional.get('transaction_count_at_placement', 0)
122
+ if lag_n_transaction > 0 and transactions_since_order < lag_n_transaction:
123
+ return False
124
+
125
+ return True
126
+
127
+ def _apply_hit_probability(self, order: TradeInstruction) -> bool:
128
+ """Determine if order should be filled based on hit probability."""
129
+ hit_config = self.matching_config['hit']
130
+ hit_prob = hit_config['prob']
131
+
132
+ if hit_prob >= 1.0:
133
+ return True
134
+
135
+ return random.random() < hit_prob
136
+
137
+ def _apply_slippage(self, price: float, side: TransactionSide | TransactionDirection) -> float:
138
+ """Apply slippage to the execution price."""
139
+ hit_config = self.matching_config['hit']
140
+ slippery_rate = hit_config['slippery']
141
+
142
+ slippage = price * slippery_rate
143
+ sign = side.sign
144
+
145
+ if sign == 1:
146
+ # For buy-orders, slippage increases the price
147
+ return price + slippage
148
+ elif sign == -1:
149
+ # For sell-orders, slippage decreases the price
150
+ return price - slippage
151
+ else:
152
+ return price
153
+
154
+ def _check_short_circuit(self, order: TradeInstruction) -> bool:
155
+ """Check if order should be filled immediately."""
156
+ if order.limit_price is None and self.last_price is None:
157
+ return False
158
+
159
+ # Check if instant fill is enabled and no lag is configured
160
+ if self.matching_config['instant_fill'] and all(not _ for _ in self.matching_config['lag'].values()):
161
+ return True
162
+
163
+ return False
164
+
165
+ def register(self, topic_set=None, event_engine=None):
166
+ if topic_set is not None:
167
+ self.topic_set = topic_set
168
+
169
+ if event_engine is not None:
170
+ self.event_engine = event_engine
171
+
172
+ self.event_engine.register_handler(topic=self.topic_set.launch_order(ticker=self.ticker), handler=self.launch_order)
173
+ self.event_engine.register_handler(topic=self.topic_set.cancel_order(ticker=self.ticker), handler=self.cancel_order)
174
+ self.event_engine.register_handler(topic=self.topic_set.realtime(ticker=self.ticker), handler=self)
175
+
176
+ def unregister(self):
177
+ self.event_engine.unregister_handler(topic=self.topic_set.launch_order(ticker=self.ticker), handler=self.launch_order)
178
+ self.event_engine.unregister_handler(topic=self.topic_set.cancel_order(ticker=self.ticker), handler=self.cancel_order)
179
+ self.event_engine.unregister_handler(topic=self.topic_set.realtime(ticker=self.ticker), handler=self)
180
+
181
+ def launch_order(self, order: TradeInstruction, **kwargs):
182
+ if order.order_id in self.working or order.order_id in self.history:
183
+ raise ValueError(f'Invalid instruction {order}, OrderId already in working or history')
184
+
185
+ if order.limit_price is None and order.order_type == OrderType.ORDER_LIMIT:
186
+ LOGGER.warning(f'order {order} does not have a valid limit price!')
187
+
188
+ order.set_order_state(order_state=OrderState.STATE_PLACED, timestamp=self.timestamp)
189
+ order.transaction_count_at_placement = self.last_transaction_count
190
+
191
+ # Check for immediate fill conditions
192
+ if self._check_short_circuit(order=order):
193
+ self.on_order(order=order, **kwargs)
194
+ worst_price = self.worst_price(
195
+ order.limit_price if order.limit_price is not None else self.last_price,
196
+ self.last_price,
197
+ side=order.side
198
+ )
199
+ self._match(order=order, match_price=worst_price)
200
+
201
+ self.working[order.order_id] = order
202
+ self.on_order(order=order, **kwargs)
203
+
204
+ def cancel_order(self, order: TradeInstruction = None, order_id: str = None, **kwargs):
205
+ if order is None and order_id is None:
206
+ raise ValueError('Must assign a order or order_id to cancel order')
207
+ elif order_id is None:
208
+ order_id = order.order_id
209
+
210
+ # if order_id not in self.working:
211
+ # raise ValueError(f'Invalid cancel order {order}, OrderId not found')
212
+
213
+ order: TradeInstruction = self.working.pop(order_id, None)
214
+ if order is None:
215
+ LOGGER.info(f'[{self.market_time:%Y-%m-%d %H:%M:%S}] failed to cancel {order_id} order!')
216
+ return
217
+
218
+ if order.order_state == OrderState.STATE_FILLED:
219
+ pass
220
+ else:
221
+ order.set_order_state(order_state=OrderState.STATE_CANCELED, timestamp=self.timestamp)
222
+ LOGGER.info(f'[{self.market_time:%Y-%m-%d %H:%M:%S}] Sim-canceled {order.side.name} {order.ticker} order!')
223
+
224
+ self.history[order_id] = order
225
+ self.on_order(order=order, **kwargs)
226
+
227
+ def _check_bar_data(self, market_data: BarData):
228
+ for order_id in list(self.working):
229
+ order = self.working.get(order_id)
230
+ if order is None:
231
+ continue
232
+
233
+ if not order.is_working:
234
+ continue
235
+
236
+ if order.start_time > market_data.market_time:
237
+ continue
238
+
239
+ if order.side.sign > 0:
240
+ # match order based on worst offer
241
+ if order.limit_price is None:
242
+ self._match(order=order, match_price=market_data.vwap)
243
+ elif market_data.high_price < order.limit_price:
244
+ self._match(order=order, match_price=market_data.high_price)
245
+ # match order based on limit price
246
+ elif market_data.low_price < order.limit_price:
247
+ self._match(order=order, match_price=order.limit_price)
248
+ # no match
249
+ else:
250
+ pass
251
+ elif order.side.sign < 0:
252
+ # match order based on worst offer
253
+ if order.limit_price is None:
254
+ self._match(order=order, match_price=market_data.vwap)
255
+ elif market_data.low_price > order.limit_price:
256
+ self._match(order=order, match_price=market_data.low_price)
257
+ # match order based on limit price
258
+ elif market_data.high_price > order.limit_price:
259
+ self._match(order=order, match_price=order.limit_price)
260
+ # no match
261
+ else:
262
+ pass
263
+
264
+ def _check_trade_data(self, market_data: TransactionData | TradeData):
265
+ for order_id in list(self.working):
266
+ order = self.working.get(order_id)
267
+ if order is None:
268
+ continue
269
+
270
+ if not order.is_working:
271
+ continue
272
+
273
+ if order.start_time > market_data.market_time:
274
+ continue
275
+
276
+ if order.limit_price is None:
277
+ if order.side.sign * market_data.side.sign > 0: # copy the next transaction info
278
+ self._match(order=order, match_volume=market_data.volume, match_price=market_data.price)
279
+ elif order.side.sign > 0 and market_data.market_price < order.limit_price:
280
+ self._match(order=order, match_volume=market_data.volume, match_price=market_data.price)
281
+ elif order.side.sign < 0 and market_data.market_price > order.limit_price:
282
+ self._match(order=order, match_volume=market_data.volume, match_price=market_data.price)
283
+
284
+ def _check_tick_data(self, market_data: TickData):
285
+ for order_id in list(self.working):
286
+ order = self.working.get(order_id)
287
+ if order is None:
288
+ continue
289
+
290
+ if not order.is_working:
291
+ continue
292
+
293
+ if order.start_time > market_data.market_time:
294
+ continue
295
+
296
+ match_volume = 0.
297
+ match_notional = 0.
298
+
299
+ if order.limit_price is None:
300
+ if order.side.sign > 0:
301
+ for entry in market_data.ask:
302
+ price, volume, _ = entry
303
+
304
+ if match_volume < order.working_volume:
305
+ addition_volume = min(volume, order.working_volume - match_volume)
306
+ match_volume += addition_volume
307
+ match_notional += addition_volume * price
308
+ else:
309
+ break
310
+ else:
311
+ for entry in market_data.bid:
312
+ price, volume, _ = entry
313
+
314
+ if match_volume < order.working_volume:
315
+ addition_volume = min(volume, order.working_volume - match_volume)
316
+ match_volume += addition_volume
317
+ match_notional += addition_volume * price
318
+ else:
319
+ break
320
+ elif order.side.sign > 0 and market_data.best_ask_price <= order.limit_price:
321
+ for entry in market_data.ask:
322
+ price, volume, _ = entry
323
+
324
+ if price <= order.limit_price:
325
+ if match_volume < order.working_volume:
326
+ addition_volume = min(volume, order.working_volume - match_volume)
327
+ match_volume += addition_volume
328
+ match_notional += addition_volume * price
329
+ else:
330
+ break
331
+ else:
332
+ break
333
+ elif order.side.sign < 0 and market_data.best_bid_price >= order.limit_price:
334
+ for entry in market_data.bid:
335
+ price, volume, _ = entry
336
+
337
+ if price >= order.limit_price:
338
+ if match_volume < order.working_volume:
339
+ addition_volume = min(volume, order.working_volume - match_volume)
340
+ match_volume += addition_volume
341
+ match_notional += addition_volume * price
342
+ else:
343
+ break
344
+ else:
345
+ break
346
+
347
+ if match_volume:
348
+ self._match(order=order, match_volume=match_volume, match_price=match_notional / match_volume)
349
+
350
+ def _check_order_data(self, market_data: OrderData) -> None:
351
+ """Process order data from the market.
352
+
353
+ Args:
354
+ market_data: The incoming order data from the market.
355
+ """
356
+ for order_id in list(self.working):
357
+ order = self.working.get(order_id)
358
+ if order is None:
359
+ continue
360
+
361
+ if not order.is_working:
362
+ continue
363
+
364
+ if order.start_time > market_data.market_time:
365
+ continue
366
+
367
+ # Check if this order matches our working order
368
+ match_volume = 0.
369
+ match_notional = 0.
370
+
371
+ if order.limit_price is None:
372
+ if order.side.sign > 0 and market_data.side.sign < 0:
373
+ match_volume = market_data.volume
374
+ match_notional = market_data.price * market_data.volume
375
+ elif order.side.sign < 0 and market_data.side.sign > 0:
376
+ match_volume = market_data.volume
377
+ match_notional = market_data.price * market_data.volume
378
+ elif order.side.sign > 0 and market_data.price <= order.limit_price:
379
+ match_volume = market_data.volume
380
+ match_notional = market_data.price * market_data.volume
381
+ elif order.side.sign < 0 and market_data.price >= order.limit_price:
382
+ match_volume = market_data.volume
383
+ match_notional = market_data.price * market_data.volume
384
+
385
+ if match_volume:
386
+ self._match(order=order, match_volume=match_volume, match_price=match_notional / match_volume)
387
+
388
+ def _check_tick_data_lite(self, market_data: TickDataLite) -> None:
389
+ """Process simplified tick data from the market.
390
+
391
+ Args:
392
+ market_data: The incoming tick data (lite version) from the market.
393
+ """
394
+ for order_id in list(self.working):
395
+ order = self.working.get(order_id)
396
+
397
+ if order is None:
398
+ continue
399
+
400
+ if not order.is_working:
401
+ continue
402
+
403
+ if order.start_time > market_data.market_time:
404
+ continue
405
+
406
+ # Check if this order matches our working order
407
+ match_volume = 0.
408
+ match_notional = 0.
409
+
410
+ if order.limit_price is None:
411
+ if order.side.sign > 0:
412
+ match_volume = market_data.ask_volume
413
+ match_notional = market_data.ask_price * market_data.ask_volume
414
+ elif order.side.sign < 0:
415
+ match_volume = market_data.bid_volume
416
+ match_notional = market_data.bid_price * market_data.bid_volume
417
+ elif order.side.sign > 0 and market_data.ask_price <= order.limit_price:
418
+ match_volume = market_data.ask_volume
419
+ match_notional = market_data.ask_price * market_data.ask_volume
420
+ elif order.side.sign < 0 and market_data.bid_price >= order.limit_price:
421
+ match_volume = market_data.bid_volume
422
+ match_notional = market_data.bid_price * market_data.bid_volume
423
+
424
+ if match_volume:
425
+ self._match(order=order, match_volume=match_volume, match_price=match_notional / match_volume)
426
+
427
+ def _match(self, order: TradeInstruction, match_volume: float = None, match_price: float = None) -> TradeReport | None:
428
+ """Attempt to match an order with the given volume and price."""
429
+ # Apply lag check
430
+ if not self._apply_lag(order):
431
+ return None
432
+
433
+ # Apply hit probability
434
+ if not self._apply_hit_probability(order):
435
+ return None
436
+
437
+ # Determine match volume
438
+ if match_volume is None:
439
+ match_volume = order.working_volume
440
+ else:
441
+ match_volume = min(match_volume, order.working_volume)
442
+
443
+ # Determine match price with slippage
444
+ if match_price is None and order.limit_price is not None:
445
+ match_price = order.limit_price
446
+ elif match_price is not None:
447
+ match_price = self._apply_slippage(match_price, order.side)
448
+
449
+ # Validate price against limit
450
+ if order.limit_price is not None:
451
+ if order.side.sign > 0 and match_price > order.limit_price:
452
+ LOGGER.warning(f'match price greater than limit price for bid order {order}')
453
+ match_price = order.limit_price
454
+ elif order.side.sign < 0 and match_price < order.limit_price:
455
+ LOGGER.warning(f'match price less than limit price for ask order {order}')
456
+ match_price = order.limit_price
457
+
458
+ if match_volume:
459
+ report = TradeReport(
460
+ ticker=order.ticker,
461
+ side=order.side,
462
+ volume=match_volume,
463
+ notional=match_volume * match_price * order.multiplier,
464
+ timestamp=self.timestamp,
465
+ order_id=order.order_id,
466
+ price=match_price,
467
+ multiplier=order.multiplier,
468
+ fee=self.matching_config['fee_rate'] * match_volume * match_price * order.multiplier
469
+ )
470
+
471
+ LOGGER.info(f'[{self.market_time:%Y-%m-%d %H:%M:%S}] Sim-filled {order.ticker} {order.side.side_name} {report.volume:,.2f} @ {report.price:.2f}')
472
+ order.fill(trade_report=report)
473
+
474
+ if order.order_state == OrderState.STATE_FILLED:
475
+ self.working.pop(order.order_id, None)
476
+ self.history[order.order_id] = order
477
+
478
+ self.on_report(report=report)
479
+ self.on_order(order=order)
480
+ return report
481
+
482
+ return None
483
+
484
+ def on_order(self, order, **kwargs):
485
+ self.event_engine.put(topic=self.topic_set.on_order, order=order)
486
+
487
+ def on_report(self, report, **kwargs):
488
+ self.event_engine.put(topic=self.topic_set.on_report, report=report, **kwargs)
489
+
490
+ def eod(self):
491
+ for order_id in list(self.working):
492
+ self.cancel_order(order_id=order_id)
493
+
494
+ def clear(self):
495
+ self.working.clear()
496
+ self.history.clear()
497
+
498
+ self.timestamp = 0.
499
+ self.last_price = None
500
+ self.last_transaction_count = 0
501
+ self.random = random.Random(self.seed)
502
+
503
+ @property
504
+ def market_time(self) -> datetime.datetime:
505
+ return datetime.datetime.fromtimestamp(self.timestamp, tz=PROFILE.time_zone)