onesecondtrader 0.43.0__py3-none-any.whl → 0.44.0__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.
- onesecondtrader/__init__.py +0 -60
- onesecondtrader/models/__init__.py +11 -0
- onesecondtrader/models/bar_fields.py +23 -0
- onesecondtrader/models/bar_period.py +21 -0
- onesecondtrader/models/order_types.py +21 -0
- onesecondtrader/models/trade_sides.py +20 -0
- {onesecondtrader-0.43.0.dist-info → onesecondtrader-0.44.0.dist-info}/METADATA +2 -2
- onesecondtrader-0.44.0.dist-info/RECORD +10 -0
- onesecondtrader/connectors/__init__.py +0 -3
- onesecondtrader/connectors/brokers/__init__.py +0 -4
- onesecondtrader/connectors/brokers/ib.py +0 -418
- onesecondtrader/connectors/brokers/simulated.py +0 -349
- onesecondtrader/connectors/datafeeds/__init__.py +0 -4
- onesecondtrader/connectors/datafeeds/ib.py +0 -286
- onesecondtrader/connectors/datafeeds/simulated.py +0 -198
- onesecondtrader/connectors/gateways/__init__.py +0 -3
- onesecondtrader/connectors/gateways/ib.py +0 -314
- onesecondtrader/core/__init__.py +0 -7
- onesecondtrader/core/brokers/__init__.py +0 -3
- onesecondtrader/core/brokers/base.py +0 -46
- onesecondtrader/core/datafeeds/__init__.py +0 -3
- onesecondtrader/core/datafeeds/base.py +0 -32
- onesecondtrader/core/events/__init__.py +0 -33
- onesecondtrader/core/events/bases.py +0 -29
- onesecondtrader/core/events/market.py +0 -22
- onesecondtrader/core/events/requests.py +0 -33
- onesecondtrader/core/events/responses.py +0 -54
- onesecondtrader/core/indicators/__init__.py +0 -13
- onesecondtrader/core/indicators/averages.py +0 -56
- onesecondtrader/core/indicators/bar.py +0 -47
- onesecondtrader/core/indicators/base.py +0 -60
- onesecondtrader/core/messaging/__init__.py +0 -7
- onesecondtrader/core/messaging/eventbus.py +0 -47
- onesecondtrader/core/messaging/subscriber.py +0 -69
- onesecondtrader/core/models/__init__.py +0 -15
- onesecondtrader/core/models/data.py +0 -18
- onesecondtrader/core/models/orders.py +0 -27
- onesecondtrader/core/models/params.py +0 -21
- onesecondtrader/core/models/records.py +0 -34
- onesecondtrader/core/strategies/__init__.py +0 -7
- onesecondtrader/core/strategies/base.py +0 -331
- onesecondtrader/core/strategies/examples.py +0 -47
- onesecondtrader/dashboard/__init__.py +0 -3
- onesecondtrader/dashboard/app.py +0 -2972
- onesecondtrader/dashboard/registry.py +0 -100
- onesecondtrader/orchestrator/__init__.py +0 -7
- onesecondtrader/orchestrator/orchestrator.py +0 -105
- onesecondtrader/orchestrator/recorder.py +0 -199
- onesecondtrader/orchestrator/schema.sql +0 -212
- onesecondtrader/secmaster/__init__.py +0 -6
- onesecondtrader/secmaster/schema.sql +0 -740
- onesecondtrader/secmaster/utils.py +0 -737
- onesecondtrader-0.43.0.dist-info/RECORD +0 -49
- {onesecondtrader-0.43.0.dist-info → onesecondtrader-0.44.0.dist-info}/WHEEL +0 -0
- {onesecondtrader-0.43.0.dist-info → onesecondtrader-0.44.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,349 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import dataclasses
|
|
4
|
-
import uuid
|
|
5
|
-
|
|
6
|
-
from onesecondtrader.core import events, messaging, models
|
|
7
|
-
from onesecondtrader.core.brokers import BrokerBase
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@dataclasses.dataclass
|
|
11
|
-
class _PendingOrder:
|
|
12
|
-
# Order state tracked by the broker, distinct from the OrderSubmission event
|
|
13
|
-
order_id: uuid.UUID
|
|
14
|
-
symbol: str
|
|
15
|
-
order_type: models.OrderType
|
|
16
|
-
side: models.OrderSide
|
|
17
|
-
quantity: float
|
|
18
|
-
limit_price: float | None = None
|
|
19
|
-
stop_price: float | None = None
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
class SimulatedBroker(BrokerBase):
|
|
23
|
-
commission_per_unit: float = 0.0
|
|
24
|
-
minimum_commission_per_order: float = 0.0
|
|
25
|
-
|
|
26
|
-
def __init__(self, event_bus: messaging.EventBus) -> None:
|
|
27
|
-
self._pending_market_orders: dict[uuid.UUID, _PendingOrder] = {}
|
|
28
|
-
self._pending_limit_orders: dict[uuid.UUID, _PendingOrder] = {}
|
|
29
|
-
self._pending_stop_orders: dict[uuid.UUID, _PendingOrder] = {}
|
|
30
|
-
self._pending_stop_limit_orders: dict[uuid.UUID, _PendingOrder] = {}
|
|
31
|
-
|
|
32
|
-
super().__init__(event_bus)
|
|
33
|
-
self._subscribe(events.BarReceived)
|
|
34
|
-
|
|
35
|
-
def connect(self) -> None:
|
|
36
|
-
pass
|
|
37
|
-
|
|
38
|
-
def _on_event(self, event: events.EventBase) -> None:
|
|
39
|
-
match event:
|
|
40
|
-
case events.BarReceived() as bar:
|
|
41
|
-
self._on_bar(bar)
|
|
42
|
-
case _:
|
|
43
|
-
super()._on_event(event)
|
|
44
|
-
|
|
45
|
-
def _on_bar(self, event: events.BarReceived) -> None:
|
|
46
|
-
self._process_market_orders(event)
|
|
47
|
-
self._process_stop_orders(event)
|
|
48
|
-
self._process_stop_limit_orders(event)
|
|
49
|
-
self._process_limit_orders(event)
|
|
50
|
-
|
|
51
|
-
def _process_market_orders(self, event: events.BarReceived) -> None:
|
|
52
|
-
for order_id, order in list(self._pending_market_orders.items()):
|
|
53
|
-
if order.symbol != event.symbol:
|
|
54
|
-
continue
|
|
55
|
-
|
|
56
|
-
self._publish(
|
|
57
|
-
events.OrderFilled(
|
|
58
|
-
ts_event=event.ts_event,
|
|
59
|
-
ts_broker=event.ts_event,
|
|
60
|
-
associated_order_id=order.order_id,
|
|
61
|
-
symbol=order.symbol,
|
|
62
|
-
side=order.side,
|
|
63
|
-
quantity_filled=order.quantity,
|
|
64
|
-
fill_price=event.open,
|
|
65
|
-
commission=max(
|
|
66
|
-
order.quantity * self.commission_per_unit,
|
|
67
|
-
self.minimum_commission_per_order,
|
|
68
|
-
),
|
|
69
|
-
)
|
|
70
|
-
)
|
|
71
|
-
del self._pending_market_orders[order_id]
|
|
72
|
-
|
|
73
|
-
def _process_stop_orders(self, event: events.BarReceived) -> None:
|
|
74
|
-
for order_id, order in list(self._pending_stop_orders.items()):
|
|
75
|
-
if order.symbol != event.symbol:
|
|
76
|
-
continue
|
|
77
|
-
|
|
78
|
-
# This is for mypy, it has already been validated on submission
|
|
79
|
-
assert order.stop_price is not None
|
|
80
|
-
|
|
81
|
-
triggered = False
|
|
82
|
-
match order.side:
|
|
83
|
-
case models.OrderSide.BUY:
|
|
84
|
-
triggered = event.high >= order.stop_price
|
|
85
|
-
case models.OrderSide.SELL:
|
|
86
|
-
triggered = event.low <= order.stop_price
|
|
87
|
-
|
|
88
|
-
if not triggered:
|
|
89
|
-
continue
|
|
90
|
-
|
|
91
|
-
fill_price = 0.0
|
|
92
|
-
match order.side:
|
|
93
|
-
case models.OrderSide.BUY:
|
|
94
|
-
fill_price = max(order.stop_price, event.open)
|
|
95
|
-
case models.OrderSide.SELL:
|
|
96
|
-
fill_price = min(order.stop_price, event.open)
|
|
97
|
-
|
|
98
|
-
self._publish(
|
|
99
|
-
events.OrderFilled(
|
|
100
|
-
ts_event=event.ts_event,
|
|
101
|
-
ts_broker=event.ts_event,
|
|
102
|
-
associated_order_id=order.order_id,
|
|
103
|
-
symbol=order.symbol,
|
|
104
|
-
side=order.side,
|
|
105
|
-
quantity_filled=order.quantity,
|
|
106
|
-
fill_price=fill_price,
|
|
107
|
-
commission=max(
|
|
108
|
-
order.quantity * self.commission_per_unit,
|
|
109
|
-
self.minimum_commission_per_order,
|
|
110
|
-
),
|
|
111
|
-
)
|
|
112
|
-
)
|
|
113
|
-
del self._pending_stop_orders[order_id]
|
|
114
|
-
|
|
115
|
-
def _process_stop_limit_orders(self, event: events.BarReceived) -> None:
|
|
116
|
-
for order_id, order in list(self._pending_stop_limit_orders.items()):
|
|
117
|
-
if order.symbol != event.symbol:
|
|
118
|
-
continue
|
|
119
|
-
|
|
120
|
-
# This is for mypy, it has already been validated on submission
|
|
121
|
-
assert order.stop_price is not None
|
|
122
|
-
|
|
123
|
-
triggered = False
|
|
124
|
-
match order.side:
|
|
125
|
-
case models.OrderSide.BUY:
|
|
126
|
-
triggered = event.high >= order.stop_price
|
|
127
|
-
case models.OrderSide.SELL:
|
|
128
|
-
triggered = event.low <= order.stop_price
|
|
129
|
-
|
|
130
|
-
if not triggered:
|
|
131
|
-
continue
|
|
132
|
-
|
|
133
|
-
limit_order = dataclasses.replace(order, order_type=models.OrderType.LIMIT)
|
|
134
|
-
self._pending_limit_orders[order_id] = limit_order
|
|
135
|
-
del self._pending_stop_limit_orders[order_id]
|
|
136
|
-
|
|
137
|
-
def _process_limit_orders(self, event: events.BarReceived) -> None:
|
|
138
|
-
for order_id, order in list(self._pending_limit_orders.items()):
|
|
139
|
-
if order.symbol != event.symbol:
|
|
140
|
-
continue
|
|
141
|
-
|
|
142
|
-
# This is for mypy, it has already been validated on submission
|
|
143
|
-
assert order.limit_price is not None
|
|
144
|
-
|
|
145
|
-
triggered = False
|
|
146
|
-
match order.side:
|
|
147
|
-
case models.OrderSide.BUY:
|
|
148
|
-
triggered = event.low <= order.limit_price
|
|
149
|
-
case models.OrderSide.SELL:
|
|
150
|
-
triggered = event.high >= order.limit_price
|
|
151
|
-
|
|
152
|
-
if not triggered:
|
|
153
|
-
continue
|
|
154
|
-
|
|
155
|
-
fill_price = 0.0
|
|
156
|
-
match order.side:
|
|
157
|
-
case models.OrderSide.BUY:
|
|
158
|
-
fill_price = min(order.limit_price, event.open)
|
|
159
|
-
case models.OrderSide.SELL:
|
|
160
|
-
fill_price = max(order.limit_price, event.open)
|
|
161
|
-
|
|
162
|
-
self._publish(
|
|
163
|
-
events.OrderFilled(
|
|
164
|
-
ts_event=event.ts_event,
|
|
165
|
-
ts_broker=event.ts_event,
|
|
166
|
-
associated_order_id=order.order_id,
|
|
167
|
-
symbol=order.symbol,
|
|
168
|
-
side=order.side,
|
|
169
|
-
quantity_filled=order.quantity,
|
|
170
|
-
fill_price=fill_price,
|
|
171
|
-
commission=max(
|
|
172
|
-
order.quantity * self.commission_per_unit,
|
|
173
|
-
self.minimum_commission_per_order,
|
|
174
|
-
),
|
|
175
|
-
)
|
|
176
|
-
)
|
|
177
|
-
del self._pending_limit_orders[order_id]
|
|
178
|
-
|
|
179
|
-
def _reject_if_invalid_submission(self, event: events.OrderSubmission) -> bool:
|
|
180
|
-
is_invalid = event.quantity <= 0
|
|
181
|
-
|
|
182
|
-
match event.order_type:
|
|
183
|
-
case models.OrderType.LIMIT:
|
|
184
|
-
is_invalid = (
|
|
185
|
-
is_invalid or event.limit_price is None or event.limit_price <= 0
|
|
186
|
-
)
|
|
187
|
-
case models.OrderType.STOP:
|
|
188
|
-
is_invalid = (
|
|
189
|
-
is_invalid or event.stop_price is None or event.stop_price <= 0
|
|
190
|
-
)
|
|
191
|
-
case models.OrderType.STOP_LIMIT:
|
|
192
|
-
is_invalid = is_invalid or (
|
|
193
|
-
event.limit_price is None
|
|
194
|
-
or event.limit_price <= 0
|
|
195
|
-
or event.stop_price is None
|
|
196
|
-
or event.stop_price <= 0
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
if is_invalid:
|
|
200
|
-
# Use event timestamp to maintain simulated time consistency in backtesting
|
|
201
|
-
self._publish(
|
|
202
|
-
events.OrderSubmissionRejected(
|
|
203
|
-
ts_event=event.ts_event,
|
|
204
|
-
ts_broker=event.ts_event,
|
|
205
|
-
associated_order_id=event.system_order_id,
|
|
206
|
-
)
|
|
207
|
-
)
|
|
208
|
-
|
|
209
|
-
return is_invalid
|
|
210
|
-
|
|
211
|
-
def _on_submit_order(self, event: events.OrderSubmission) -> None:
|
|
212
|
-
if self._reject_if_invalid_submission(event):
|
|
213
|
-
return
|
|
214
|
-
|
|
215
|
-
order = _PendingOrder(
|
|
216
|
-
order_id=event.system_order_id,
|
|
217
|
-
symbol=event.symbol,
|
|
218
|
-
order_type=event.order_type,
|
|
219
|
-
side=event.side,
|
|
220
|
-
quantity=event.quantity,
|
|
221
|
-
limit_price=event.limit_price,
|
|
222
|
-
stop_price=event.stop_price,
|
|
223
|
-
)
|
|
224
|
-
|
|
225
|
-
match order.order_type:
|
|
226
|
-
case models.OrderType.MARKET:
|
|
227
|
-
self._pending_market_orders[order.order_id] = order
|
|
228
|
-
case models.OrderType.LIMIT:
|
|
229
|
-
self._pending_limit_orders[order.order_id] = order
|
|
230
|
-
case models.OrderType.STOP:
|
|
231
|
-
self._pending_stop_orders[order.order_id] = order
|
|
232
|
-
case models.OrderType.STOP_LIMIT:
|
|
233
|
-
self._pending_stop_limit_orders[order.order_id] = order
|
|
234
|
-
|
|
235
|
-
# Use event timestamp to maintain simulated time consistency in backtesting
|
|
236
|
-
self._publish(
|
|
237
|
-
events.OrderSubmissionAccepted(
|
|
238
|
-
ts_event=event.ts_event,
|
|
239
|
-
ts_broker=event.ts_event,
|
|
240
|
-
associated_order_id=order.order_id,
|
|
241
|
-
)
|
|
242
|
-
)
|
|
243
|
-
|
|
244
|
-
def _on_cancel_order(self, event: events.OrderCancellation) -> None:
|
|
245
|
-
order_id = event.system_order_id
|
|
246
|
-
|
|
247
|
-
removed = False
|
|
248
|
-
for pending_orders in (
|
|
249
|
-
self._pending_market_orders,
|
|
250
|
-
self._pending_limit_orders,
|
|
251
|
-
self._pending_stop_orders,
|
|
252
|
-
self._pending_stop_limit_orders,
|
|
253
|
-
):
|
|
254
|
-
if order_id in pending_orders:
|
|
255
|
-
del pending_orders[order_id]
|
|
256
|
-
removed = True
|
|
257
|
-
break
|
|
258
|
-
|
|
259
|
-
# Use event timestamp to maintain simulated time consistency in backtesting
|
|
260
|
-
if removed:
|
|
261
|
-
self._publish(
|
|
262
|
-
events.OrderCancellationAccepted(
|
|
263
|
-
ts_event=event.ts_event,
|
|
264
|
-
ts_broker=event.ts_event,
|
|
265
|
-
associated_order_id=order_id,
|
|
266
|
-
)
|
|
267
|
-
)
|
|
268
|
-
else:
|
|
269
|
-
self._publish(
|
|
270
|
-
events.OrderCancellationRejected(
|
|
271
|
-
ts_event=event.ts_event,
|
|
272
|
-
ts_broker=event.ts_event,
|
|
273
|
-
associated_order_id=order_id,
|
|
274
|
-
)
|
|
275
|
-
)
|
|
276
|
-
|
|
277
|
-
def _reject_if_invalid_modification(self, event: events.OrderModification) -> bool:
|
|
278
|
-
is_invalid = (
|
|
279
|
-
(event.quantity is not None and event.quantity <= 0)
|
|
280
|
-
or (event.limit_price is not None and event.limit_price <= 0)
|
|
281
|
-
or (event.stop_price is not None and event.stop_price <= 0)
|
|
282
|
-
)
|
|
283
|
-
|
|
284
|
-
if is_invalid:
|
|
285
|
-
# Use event timestamp to maintain simulated time consistency in backtesting
|
|
286
|
-
self._publish(
|
|
287
|
-
events.OrderModificationRejected(
|
|
288
|
-
ts_event=event.ts_event,
|
|
289
|
-
ts_broker=event.ts_event,
|
|
290
|
-
associated_order_id=event.system_order_id,
|
|
291
|
-
)
|
|
292
|
-
)
|
|
293
|
-
|
|
294
|
-
return is_invalid
|
|
295
|
-
|
|
296
|
-
def _on_modify_order(self, event: events.OrderModification) -> None:
|
|
297
|
-
if self._reject_if_invalid_modification(event):
|
|
298
|
-
return
|
|
299
|
-
|
|
300
|
-
order_id = event.system_order_id
|
|
301
|
-
|
|
302
|
-
for pending_orders in (
|
|
303
|
-
self._pending_market_orders,
|
|
304
|
-
self._pending_limit_orders,
|
|
305
|
-
self._pending_stop_orders,
|
|
306
|
-
self._pending_stop_limit_orders,
|
|
307
|
-
):
|
|
308
|
-
if order_id in pending_orders:
|
|
309
|
-
order = pending_orders[order_id]
|
|
310
|
-
|
|
311
|
-
new_quantity = (
|
|
312
|
-
event.quantity if event.quantity is not None else order.quantity
|
|
313
|
-
)
|
|
314
|
-
new_limit_price = (
|
|
315
|
-
event.limit_price
|
|
316
|
-
if event.limit_price is not None
|
|
317
|
-
else order.limit_price
|
|
318
|
-
)
|
|
319
|
-
new_stop_price = (
|
|
320
|
-
event.stop_price
|
|
321
|
-
if event.stop_price is not None
|
|
322
|
-
else order.stop_price
|
|
323
|
-
)
|
|
324
|
-
|
|
325
|
-
pending_orders[order_id] = dataclasses.replace(
|
|
326
|
-
order,
|
|
327
|
-
quantity=new_quantity,
|
|
328
|
-
limit_price=new_limit_price,
|
|
329
|
-
stop_price=new_stop_price,
|
|
330
|
-
)
|
|
331
|
-
|
|
332
|
-
# Use event timestamp to maintain simulated time consistency in backtesting
|
|
333
|
-
self._publish(
|
|
334
|
-
events.OrderModificationAccepted(
|
|
335
|
-
ts_event=event.ts_event,
|
|
336
|
-
ts_broker=event.ts_event,
|
|
337
|
-
associated_order_id=order_id,
|
|
338
|
-
)
|
|
339
|
-
)
|
|
340
|
-
return
|
|
341
|
-
|
|
342
|
-
# Use event timestamp to maintain simulated time consistency in backtesting
|
|
343
|
-
self._publish(
|
|
344
|
-
events.OrderModificationRejected(
|
|
345
|
-
ts_event=event.ts_event,
|
|
346
|
-
ts_broker=event.ts_event,
|
|
347
|
-
associated_order_id=order_id,
|
|
348
|
-
)
|
|
349
|
-
)
|
|
@@ -1,286 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import logging
|
|
4
|
-
import os
|
|
5
|
-
import sqlite3
|
|
6
|
-
|
|
7
|
-
import pandas as pd
|
|
8
|
-
from ib_async import Contract
|
|
9
|
-
|
|
10
|
-
from onesecondtrader.connectors.gateways.ib import _get_gateway, make_contract
|
|
11
|
-
from onesecondtrader.core import events, messaging, models
|
|
12
|
-
from onesecondtrader.core.datafeeds import DatafeedBase
|
|
13
|
-
|
|
14
|
-
_logger = logging.getLogger(__name__)
|
|
15
|
-
|
|
16
|
-
_BAR_SIZE_MAP = {
|
|
17
|
-
models.BarPeriod.MINUTE: "1 min",
|
|
18
|
-
models.BarPeriod.HOUR: "1 hour",
|
|
19
|
-
models.BarPeriod.DAY: "1 day",
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
class IBDatafeed(DatafeedBase):
|
|
24
|
-
"""
|
|
25
|
-
Live market data feed from Interactive Brokers.
|
|
26
|
-
|
|
27
|
-
Subscribes to real-time bar data from IB and publishes BarReceived events.
|
|
28
|
-
|
|
29
|
-
Symbol Resolution:
|
|
30
|
-
Symbols are resolved to IB Contracts using a priority system:
|
|
31
|
-
|
|
32
|
-
1. Explicit format: Use colon-separated qualifiers for full control.
|
|
33
|
-
Format: ``SYMBOL:SECTYPE:CURRENCY:EXCHANGE[:EXPIRY[:STRIKE[:RIGHT]]]``
|
|
34
|
-
|
|
35
|
-
Examples:
|
|
36
|
-
- ``"AAPL:STK:USD:SMART"`` - Apple stock
|
|
37
|
-
- ``"EUR:CASH:USD:IDEALPRO"`` - EUR/USD forex
|
|
38
|
-
- ``"ES:FUT:USD:CME:202503"`` - ES futures March 2025
|
|
39
|
-
- ``"AAPL:OPT:USD:SMART:20250321:150:C"`` - AAPL call option
|
|
40
|
-
|
|
41
|
-
2. Secmaster lookup: If ``db_path`` is configured and the symbol exists
|
|
42
|
-
in the instruments table, contract details are read from there.
|
|
43
|
-
|
|
44
|
-
3. Default: Simple symbols like ``"AAPL"`` are treated as US stocks
|
|
45
|
-
traded on SMART routing with USD currency.
|
|
46
|
-
|
|
47
|
-
Bar Periods:
|
|
48
|
-
- ``BarPeriod.SECOND``: Uses tick-by-tick data aggregated into 1-second
|
|
49
|
-
bars. Limited to 5 simultaneous subscriptions by IB.
|
|
50
|
-
- ``BarPeriod.MINUTE``, ``HOUR``, ``DAY``: Uses historical data with
|
|
51
|
-
live updates. Limited to 50 simultaneous subscriptions by IB.
|
|
52
|
-
|
|
53
|
-
Attributes:
|
|
54
|
-
db_path: Optional path to secmaster database for symbol resolution.
|
|
55
|
-
If empty, uses SECMASTER_DB_PATH environment variable.
|
|
56
|
-
If neither is set, secmaster lookup is disabled.
|
|
57
|
-
"""
|
|
58
|
-
|
|
59
|
-
db_path: str = ""
|
|
60
|
-
|
|
61
|
-
def __init__(self, event_bus: messaging.EventBus) -> None:
|
|
62
|
-
super().__init__(event_bus)
|
|
63
|
-
self._gateway = _get_gateway()
|
|
64
|
-
self._connected = False
|
|
65
|
-
self._subscriptions: set[tuple[str, models.BarPeriod]] = set()
|
|
66
|
-
self._active_bars: dict[tuple[str, models.BarPeriod], object] = {}
|
|
67
|
-
self._db_connection: sqlite3.Connection | None = None
|
|
68
|
-
self._tick_aggregators: dict[str, _TickAggregator] = {}
|
|
69
|
-
self._lock = __import__("threading").Lock()
|
|
70
|
-
|
|
71
|
-
def connect(self) -> None:
|
|
72
|
-
if self._connected:
|
|
73
|
-
return
|
|
74
|
-
_logger.info("Connecting to IB datafeed")
|
|
75
|
-
self._gateway.acquire()
|
|
76
|
-
self._gateway.register_reconnect_callback(self._on_reconnect)
|
|
77
|
-
self._gateway.register_disconnect_callback(self._on_disconnect)
|
|
78
|
-
db_path = self.db_path or os.environ.get("SECMASTER_DB_PATH", "")
|
|
79
|
-
if db_path and os.path.exists(db_path):
|
|
80
|
-
self._db_connection = sqlite3.connect(db_path, check_same_thread=False)
|
|
81
|
-
self._connected = True
|
|
82
|
-
_logger.info("Connected to IB datafeed")
|
|
83
|
-
|
|
84
|
-
def disconnect(self) -> None:
|
|
85
|
-
if not self._connected:
|
|
86
|
-
return
|
|
87
|
-
_logger.info("Disconnecting from IB datafeed")
|
|
88
|
-
self._gateway.unregister_reconnect_callback(self._on_reconnect)
|
|
89
|
-
self._gateway.unregister_disconnect_callback(self._on_disconnect)
|
|
90
|
-
with self._lock:
|
|
91
|
-
for symbol, bar_period in list(self._subscriptions):
|
|
92
|
-
self.unsubscribe(symbol, bar_period)
|
|
93
|
-
if self._db_connection:
|
|
94
|
-
self._db_connection.close()
|
|
95
|
-
self._db_connection = None
|
|
96
|
-
self._gateway.release()
|
|
97
|
-
self._connected = False
|
|
98
|
-
_logger.info("Disconnected from IB datafeed")
|
|
99
|
-
|
|
100
|
-
def _on_disconnect(self) -> None:
|
|
101
|
-
_logger.warning("IB connection lost, clearing stale state")
|
|
102
|
-
with self._lock:
|
|
103
|
-
self._active_bars.clear()
|
|
104
|
-
self._tick_aggregators.clear()
|
|
105
|
-
|
|
106
|
-
def _on_reconnect(self) -> None:
|
|
107
|
-
with self._lock:
|
|
108
|
-
subscriptions_to_restore = list(self._subscriptions)
|
|
109
|
-
_logger.info(
|
|
110
|
-
"Restoring %d subscriptions after reconnect", len(subscriptions_to_restore)
|
|
111
|
-
)
|
|
112
|
-
with self._lock:
|
|
113
|
-
self._active_bars.clear()
|
|
114
|
-
self._tick_aggregators.clear()
|
|
115
|
-
for symbol, bar_period in subscriptions_to_restore:
|
|
116
|
-
with self._lock:
|
|
117
|
-
self._subscriptions.discard((symbol, bar_period))
|
|
118
|
-
self.subscribe(symbol, bar_period)
|
|
119
|
-
|
|
120
|
-
def subscribe(self, symbol: str, bar_period: models.BarPeriod) -> None:
|
|
121
|
-
with self._lock:
|
|
122
|
-
if (symbol, bar_period) in self._subscriptions:
|
|
123
|
-
return
|
|
124
|
-
_logger.info("Subscribing to %s %s", symbol, bar_period.name)
|
|
125
|
-
self._subscriptions.add((symbol, bar_period))
|
|
126
|
-
contract = self._make_contract(symbol)
|
|
127
|
-
|
|
128
|
-
if bar_period == models.BarPeriod.SECOND:
|
|
129
|
-
self._subscribe_tick(symbol, contract, bar_period)
|
|
130
|
-
else:
|
|
131
|
-
self._subscribe_historical(symbol, contract, bar_period)
|
|
132
|
-
|
|
133
|
-
def unsubscribe(self, symbol: str, bar_period: models.BarPeriod) -> None:
|
|
134
|
-
with self._lock:
|
|
135
|
-
if (symbol, bar_period) not in self._subscriptions:
|
|
136
|
-
return
|
|
137
|
-
_logger.info("Unsubscribing from %s %s", symbol, bar_period.name)
|
|
138
|
-
self._subscriptions.discard((symbol, bar_period))
|
|
139
|
-
bars = self._active_bars.pop((symbol, bar_period), None)
|
|
140
|
-
if bars is None:
|
|
141
|
-
return
|
|
142
|
-
|
|
143
|
-
if bar_period == models.BarPeriod.SECOND:
|
|
144
|
-
self._gateway.run_coro(self._cancel_tick_async(bars))
|
|
145
|
-
with self._lock:
|
|
146
|
-
self._tick_aggregators.pop(symbol, None)
|
|
147
|
-
else:
|
|
148
|
-
self._gateway.run_coro(self._cancel_historical_async(bars))
|
|
149
|
-
|
|
150
|
-
def _subscribe_historical(
|
|
151
|
-
self, symbol: str, contract: Contract, bar_period: models.BarPeriod
|
|
152
|
-
) -> None:
|
|
153
|
-
bar_size = _BAR_SIZE_MAP[bar_period]
|
|
154
|
-
|
|
155
|
-
async def _subscribe():
|
|
156
|
-
bars = await self._gateway.ib.reqHistoricalDataAsync(
|
|
157
|
-
contract,
|
|
158
|
-
endDateTime="",
|
|
159
|
-
durationStr="1 D",
|
|
160
|
-
barSizeSetting=bar_size,
|
|
161
|
-
whatToShow="TRADES",
|
|
162
|
-
useRTH=False,
|
|
163
|
-
keepUpToDate=True,
|
|
164
|
-
)
|
|
165
|
-
bars.updateEvent += lambda b, has_new: self._on_historical_update(
|
|
166
|
-
symbol, bar_period, b, has_new
|
|
167
|
-
)
|
|
168
|
-
return bars
|
|
169
|
-
|
|
170
|
-
bars = self._gateway.run_coro(_subscribe())
|
|
171
|
-
self._active_bars[(symbol, bar_period)] = bars
|
|
172
|
-
|
|
173
|
-
def _subscribe_tick(
|
|
174
|
-
self, symbol: str, contract: Contract, bar_period: models.BarPeriod
|
|
175
|
-
) -> None:
|
|
176
|
-
aggregator = _TickAggregator(symbol, bar_period, self._publish)
|
|
177
|
-
self._tick_aggregators[symbol] = aggregator
|
|
178
|
-
|
|
179
|
-
async def _subscribe():
|
|
180
|
-
ticker = await self._gateway.ib.reqTickByTickDataAsync(
|
|
181
|
-
contract, tickType="AllLast"
|
|
182
|
-
)
|
|
183
|
-
ticker.updateEvent += lambda t: self._on_tick_update(symbol, t)
|
|
184
|
-
return ticker
|
|
185
|
-
|
|
186
|
-
ticker = self._gateway.run_coro(_subscribe())
|
|
187
|
-
self._active_bars[(symbol, bar_period)] = ticker
|
|
188
|
-
|
|
189
|
-
async def _cancel_historical_async(self, bars) -> None:
|
|
190
|
-
self._gateway.ib.cancelHistoricalData(bars)
|
|
191
|
-
|
|
192
|
-
async def _cancel_tick_async(self, ticker) -> None:
|
|
193
|
-
self._gateway.ib.cancelTickByTickData(ticker.contract, "AllLast")
|
|
194
|
-
|
|
195
|
-
def _on_historical_update(
|
|
196
|
-
self, symbol: str, bar_period: models.BarPeriod, bars, has_new_bar: bool
|
|
197
|
-
) -> None:
|
|
198
|
-
if not has_new_bar or not bars:
|
|
199
|
-
return
|
|
200
|
-
bar = bars[-1]
|
|
201
|
-
self._publish(
|
|
202
|
-
events.BarReceived(
|
|
203
|
-
ts_event=pd.Timestamp(bar.date, tz="UTC"),
|
|
204
|
-
symbol=symbol,
|
|
205
|
-
bar_period=bar_period,
|
|
206
|
-
open=bar.open,
|
|
207
|
-
high=bar.high,
|
|
208
|
-
low=bar.low,
|
|
209
|
-
close=bar.close,
|
|
210
|
-
volume=int(bar.volume),
|
|
211
|
-
)
|
|
212
|
-
)
|
|
213
|
-
|
|
214
|
-
def _on_tick_update(self, symbol: str, ticker) -> None:
|
|
215
|
-
aggregator = self._tick_aggregators.get(symbol)
|
|
216
|
-
if aggregator is None:
|
|
217
|
-
return
|
|
218
|
-
for tick in ticker.tickByTicks:
|
|
219
|
-
if tick is not None:
|
|
220
|
-
aggregator.on_tick(tick.time, tick.price, tick.size)
|
|
221
|
-
|
|
222
|
-
def _make_contract(self, symbol: str):
|
|
223
|
-
return make_contract(symbol, self._db_connection)
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
class _TickAggregator:
|
|
227
|
-
def __init__(
|
|
228
|
-
self,
|
|
229
|
-
symbol: str,
|
|
230
|
-
bar_period: models.BarPeriod,
|
|
231
|
-
publish_fn,
|
|
232
|
-
) -> None:
|
|
233
|
-
self._symbol = symbol
|
|
234
|
-
self._bar_period = bar_period
|
|
235
|
-
self._publish = publish_fn
|
|
236
|
-
self._current_second: pd.Timestamp | None = None
|
|
237
|
-
self._open: float = 0.0
|
|
238
|
-
self._high: float = 0.0
|
|
239
|
-
self._low: float = 0.0
|
|
240
|
-
self._close: float = 0.0
|
|
241
|
-
self._volume: int = 0
|
|
242
|
-
|
|
243
|
-
def on_tick(self, time: pd.Timestamp, price: float, size: int) -> None:
|
|
244
|
-
tick_second = time.floor("s")
|
|
245
|
-
|
|
246
|
-
if self._current_second is None:
|
|
247
|
-
self._start_new_bar(tick_second, price, size)
|
|
248
|
-
return
|
|
249
|
-
|
|
250
|
-
if tick_second > self._current_second:
|
|
251
|
-
self._emit_bar()
|
|
252
|
-
self._start_new_bar(tick_second, price, size)
|
|
253
|
-
else:
|
|
254
|
-
self._update_bar(price, size)
|
|
255
|
-
|
|
256
|
-
def _start_new_bar(
|
|
257
|
-
self, tick_second: pd.Timestamp, price: float, size: int
|
|
258
|
-
) -> None:
|
|
259
|
-
self._current_second = tick_second
|
|
260
|
-
self._open = price
|
|
261
|
-
self._high = price
|
|
262
|
-
self._low = price
|
|
263
|
-
self._close = price
|
|
264
|
-
self._volume = size
|
|
265
|
-
|
|
266
|
-
def _update_bar(self, price: float, size: int) -> None:
|
|
267
|
-
self._high = max(self._high, price)
|
|
268
|
-
self._low = min(self._low, price)
|
|
269
|
-
self._close = price
|
|
270
|
-
self._volume += size
|
|
271
|
-
|
|
272
|
-
def _emit_bar(self) -> None:
|
|
273
|
-
if self._current_second is None:
|
|
274
|
-
return
|
|
275
|
-
self._publish(
|
|
276
|
-
events.BarReceived(
|
|
277
|
-
ts_event=self._current_second,
|
|
278
|
-
symbol=self._symbol,
|
|
279
|
-
bar_period=self._bar_period,
|
|
280
|
-
open=self._open,
|
|
281
|
-
high=self._high,
|
|
282
|
-
low=self._low,
|
|
283
|
-
close=self._close,
|
|
284
|
-
volume=self._volume,
|
|
285
|
-
)
|
|
286
|
-
)
|