PyAlgoEngine 0.8.0a8__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.
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/PKG-INFO +1 -1
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/PyAlgoEngine.egg-info/PKG-INFO +1 -1
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/PyAlgoEngine.egg-info/SOURCES.txt +6 -1
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/__init__.py +1 -1
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/backtest/replay.py +46 -28
- pyalgoengine-0.8.0a11/algo_engine/backtest/sim_match.py +505 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/base/__init__.py +13 -14
- pyalgoengine-0.8.0a11/algo_engine/base/candlestick.pyi +115 -0
- pyalgoengine-0.8.0a11/algo_engine/base/market_data.pyi +47 -0
- pyalgoengine-0.8.0a11/algo_engine/base/market_data_buffer.pyi +87 -0
- pyalgoengine-0.8.0a11/algo_engine/base/tick.pyi +127 -0
- pyalgoengine-0.8.0a11/algo_engine/base/trade_utils.pyi +226 -0
- pyalgoengine-0.8.0a11/algo_engine/base/transaction.pyi +226 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/engine/market_engine.py +36 -55
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/monitor/advanced_data_interface.py +134 -39
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/setup.py +6 -0
- pyalgoengine-0.8.0a8/algo_engine/backtest/sim_match.py +0 -333
- pyalgoengine-0.8.0a8/algo_engine/base/trade_utils.py +0 -693
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/LICENSE +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/PyAlgoEngine.egg-info/dependency_links.txt +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/PyAlgoEngine.egg-info/requires.txt +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/PyAlgoEngine.egg-info/top_level.txt +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/README.md +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/apps/__init__.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/apps/backtest/__init__.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/apps/backtest/doc_server.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/apps/backtest/tester.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/apps/backtest/web_app.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/apps/bokeh_server.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/apps/demo/__init__.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/apps/demo/test.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/apps/sim_input/__init__.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/apps/sim_input/client.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/apps/sim_input/sim_keyboard.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/apps/sim_input/sim_mouse.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/apps/sim_input/window.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/backtest/__init__.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/backtest/__main__.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/backtest/metrics.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/base/console_utils.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/base/finance_decimal.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/base/market_utils_nt.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/base/market_utils_posix.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/base/technical_analysis.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/base/telemetrics.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/engine/__init__.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/engine/algo_engine.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/engine/event_engine.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/engine/trade_engine.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/monitor/__init__.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/profile/__init__.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/profile/cn.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/strategy/__init__.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/strategy/strategy_engine.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/utils/__init__.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/utils/commit_regularizer.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/algo_engine/utils/data_utils.py +0 -0
- {pyalgoengine-0.8.0a8 → pyalgoengine-0.8.0a11}/setup.cfg +0 -0
|
@@ -26,13 +26,18 @@ algo_engine/backtest/metrics.py
|
|
|
26
26
|
algo_engine/backtest/replay.py
|
|
27
27
|
algo_engine/backtest/sim_match.py
|
|
28
28
|
algo_engine/base/__init__.py
|
|
29
|
+
algo_engine/base/candlestick.pyi
|
|
29
30
|
algo_engine/base/console_utils.py
|
|
30
31
|
algo_engine/base/finance_decimal.py
|
|
32
|
+
algo_engine/base/market_data.pyi
|
|
33
|
+
algo_engine/base/market_data_buffer.pyi
|
|
31
34
|
algo_engine/base/market_utils_nt.py
|
|
32
35
|
algo_engine/base/market_utils_posix.py
|
|
33
36
|
algo_engine/base/technical_analysis.py
|
|
34
37
|
algo_engine/base/telemetrics.py
|
|
35
|
-
algo_engine/base/
|
|
38
|
+
algo_engine/base/tick.pyi
|
|
39
|
+
algo_engine/base/trade_utils.pyi
|
|
40
|
+
algo_engine/base/transaction.pyi
|
|
36
41
|
algo_engine/engine/__init__.py
|
|
37
42
|
algo_engine/engine/algo_engine.py
|
|
38
43
|
algo_engine/engine/event_engine.py
|
|
@@ -2,10 +2,11 @@ import abc
|
|
|
2
2
|
import datetime
|
|
3
3
|
import inspect
|
|
4
4
|
import operator
|
|
5
|
-
from
|
|
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,
|
|
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,
|
|
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
|
|
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.
|
|
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
|
-
|
|
225
|
-
data = self.replay_task
|
|
242
|
+
try:
|
|
243
|
+
data = next(self.replay_task)
|
|
226
244
|
self.task_progress += 1
|
|
227
|
-
|
|
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
|
|
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.
|
|
246
|
-
current_progress = (self.date_progress - 1 + (self.task_progress /
|
|
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)
|