onesecondtrader 0.38.0__py3-none-any.whl → 0.40.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.
@@ -4,9 +4,11 @@ __all__ = [
4
4
  "BarReceived",
5
5
  "BrokerBase",
6
6
  "Close",
7
- "Datafeed",
7
+ "DatafeedBase",
8
8
  "FillRecord",
9
9
  "High",
10
+ "IBBroker",
11
+ "IBDatafeed",
10
12
  "Indicator",
11
13
  "InputSource",
12
14
  "Low",
@@ -25,8 +27,9 @@ __all__ = [
25
27
  ]
26
28
 
27
29
  from onesecondtrader.core.brokers import BrokerBase
28
- from onesecondtrader.connectors.brokers import SimulatedBroker
29
- from onesecondtrader.connectors.datafeeds import Datafeed, SimulatedDatafeed
30
+ from onesecondtrader.connectors.brokers import IBBroker, SimulatedBroker
31
+ from onesecondtrader.core.datafeeds import DatafeedBase
32
+ from onesecondtrader.connectors.datafeeds import IBDatafeed, SimulatedDatafeed
30
33
  from onesecondtrader.core.events import (
31
34
  BarProcessed,
32
35
  BarReceived,
@@ -1,2 +1,3 @@
1
1
  from onesecondtrader.connectors import brokers as brokers
2
2
  from onesecondtrader.connectors import datafeeds as datafeeds
3
+ from onesecondtrader.connectors import gateways as gateways
@@ -1,3 +1,4 @@
1
- __all__ = ["SimulatedBroker"]
1
+ __all__ = ["IBBroker", "SimulatedBroker"]
2
2
 
3
+ from .ib import IBBroker
3
4
  from .simulated import SimulatedBroker
@@ -0,0 +1,418 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import logging
5
+ import os
6
+ import sqlite3
7
+ import uuid
8
+
9
+ import pandas as pd
10
+ from ib_async import LimitOrder, MarketOrder, StopLimitOrder, StopOrder, Trade
11
+
12
+ from onesecondtrader.connectors.gateways.ib import _get_gateway, make_contract
13
+ from onesecondtrader.core import events, messaging, models
14
+ from onesecondtrader.core.brokers import BrokerBase
15
+
16
+ _logger = logging.getLogger(__name__)
17
+
18
+
19
+ @dataclasses.dataclass
20
+ class _OrderMapping:
21
+ system_order_id: uuid.UUID
22
+ symbol: str
23
+ side: models.OrderSide
24
+ trade: Trade
25
+
26
+
27
+ class IBBroker(BrokerBase):
28
+ """
29
+ Live order execution broker for Interactive Brokers.
30
+
31
+ Submits orders to IB and translates IB order events back to system events.
32
+
33
+ Symbol Resolution:
34
+ Uses the same resolution logic as IBDatafeed:
35
+
36
+ 1. Explicit format: ``SYMBOL:SECTYPE:CURRENCY:EXCHANGE[:EXPIRY[:STRIKE[:RIGHT]]]``
37
+ 2. Secmaster lookup: If ``db_path`` is configured
38
+ 3. Default: US stock on SMART routing
39
+
40
+ Order Types:
41
+ - ``OrderType.MARKET``: Market order
42
+ - ``OrderType.LIMIT``: Limit order
43
+ - ``OrderType.STOP``: Stop order (becomes market when triggered)
44
+ - ``OrderType.STOP_LIMIT``: Stop-limit order
45
+
46
+ Attributes:
47
+ db_path: Optional path to secmaster database for symbol resolution.
48
+ """
49
+
50
+ db_path: str = ""
51
+
52
+ def __init__(self, event_bus: messaging.EventBus) -> None:
53
+ super().__init__(event_bus)
54
+ self._gateway = _get_gateway()
55
+ self._connected = False
56
+ self._db_connection: sqlite3.Connection | None = None
57
+ self._order_mappings: dict[uuid.UUID, _OrderMapping] = {}
58
+ self._ib_to_system_id: dict[int, uuid.UUID] = {}
59
+ self._pending_cancellations: set[uuid.UUID] = set()
60
+ self._pending_modifications: dict[uuid.UUID, events.OrderModification] = {}
61
+ self._filled_quantities: dict[uuid.UUID, float] = {}
62
+
63
+ def connect(self) -> None:
64
+ if self._connected:
65
+ return
66
+ _logger.info("Connecting to IB")
67
+ self._gateway.acquire()
68
+ self._gateway.register_reconnect_callback(self._on_reconnect)
69
+ self._gateway.register_disconnect_callback(self._on_disconnect)
70
+ db_path = self.db_path or os.environ.get("SECMASTER_DB_PATH", "")
71
+ if db_path and os.path.exists(db_path):
72
+ self._db_connection = sqlite3.connect(db_path, check_same_thread=False)
73
+
74
+ self._register_ib_events()
75
+ self._connected = True
76
+ _logger.info("Connected to IB")
77
+
78
+ def disconnect(self) -> None:
79
+ if not self._connected:
80
+ return
81
+ _logger.info("Disconnecting from IB")
82
+
83
+ self._gateway.unregister_reconnect_callback(self._on_reconnect)
84
+ self._gateway.unregister_disconnect_callback(self._on_disconnect)
85
+ self._unregister_ib_events()
86
+
87
+ if self._db_connection:
88
+ self._db_connection.close()
89
+ self._db_connection = None
90
+ self._gateway.release()
91
+ self._connected = False
92
+ self.shutdown()
93
+ _logger.info("Disconnected from IB")
94
+
95
+ def _register_ib_events(self) -> None:
96
+ ib = self._gateway.ib
97
+ ib.orderStatusEvent += self._on_order_status
98
+ ib.execDetailsEvent += self._on_exec_details
99
+ ib.errorEvent += self._on_error
100
+
101
+ def _unregister_ib_events(self) -> None:
102
+ ib = self._gateway.ib
103
+ ib.orderStatusEvent -= self._on_order_status
104
+ ib.execDetailsEvent -= self._on_exec_details
105
+ ib.errorEvent -= self._on_error
106
+
107
+ def _on_disconnect(self) -> None:
108
+ pending_count = len(self._order_mappings)
109
+ _logger.warning("IB disconnected, expiring %d pending orders", pending_count)
110
+ now = pd.Timestamp.now(tz="UTC")
111
+ for order_id in list(self._order_mappings.keys()):
112
+ self._publish(
113
+ events.OrderExpired(
114
+ ts_event=now,
115
+ ts_broker=now,
116
+ associated_order_id=order_id,
117
+ )
118
+ )
119
+ self._order_mappings.clear()
120
+ self._ib_to_system_id.clear()
121
+ self._pending_cancellations.clear()
122
+ self._pending_modifications.clear()
123
+ self._filled_quantities.clear()
124
+
125
+ def _on_reconnect(self) -> None:
126
+ _logger.info("IB reconnected, re-registering event handlers")
127
+ self._register_ib_events()
128
+
129
+ def _on_submit_order(self, event: events.OrderSubmission) -> None:
130
+ _logger.info(
131
+ "Submitting order: %s %s %s %s",
132
+ event.side.name,
133
+ event.quantity,
134
+ event.symbol,
135
+ event.order_type.name,
136
+ )
137
+ contract = self._make_contract(event.symbol)
138
+ action = "BUY" if event.side == models.OrderSide.BUY else "SELL"
139
+
140
+ order = self._create_ib_order(event, action)
141
+ if order is None:
142
+ _logger.warning(
143
+ "Order rejected (invalid params): %s", event.system_order_id
144
+ )
145
+ self._publish(
146
+ events.OrderSubmissionRejected(
147
+ ts_event=pd.Timestamp.now(tz="UTC"),
148
+ ts_broker=pd.Timestamp.now(tz="UTC"),
149
+ associated_order_id=event.system_order_id,
150
+ reason="Invalid order parameters",
151
+ )
152
+ )
153
+ return
154
+
155
+ async def _place():
156
+ return self._gateway.ib.placeOrder(contract, order)
157
+
158
+ trade = self._gateway.run_coro(_place())
159
+
160
+ mapping = _OrderMapping(
161
+ system_order_id=event.system_order_id,
162
+ symbol=event.symbol,
163
+ side=event.side,
164
+ trade=trade,
165
+ )
166
+ self._order_mappings[event.system_order_id] = mapping
167
+ self._ib_to_system_id[trade.order.orderId] = event.system_order_id
168
+ self._filled_quantities[event.system_order_id] = 0.0
169
+
170
+ _logger.info(
171
+ "Order accepted: %s -> IB order %d",
172
+ event.system_order_id,
173
+ trade.order.orderId,
174
+ )
175
+ self._publish(
176
+ events.OrderSubmissionAccepted(
177
+ ts_event=pd.Timestamp.now(tz="UTC"),
178
+ ts_broker=pd.Timestamp.now(tz="UTC"),
179
+ associated_order_id=event.system_order_id,
180
+ broker_order_id=str(trade.order.orderId),
181
+ )
182
+ )
183
+
184
+ def _on_cancel_order(self, event: events.OrderCancellation) -> None:
185
+ order_id = event.system_order_id
186
+ _logger.info("Cancelling order: %s", order_id)
187
+ mapping = self._order_mappings.get(order_id)
188
+
189
+ if mapping is None:
190
+ _logger.warning("Cancel rejected (not found): %s", order_id)
191
+ self._publish(
192
+ events.OrderCancellationRejected(
193
+ ts_event=pd.Timestamp.now(tz="UTC"),
194
+ ts_broker=pd.Timestamp.now(tz="UTC"),
195
+ associated_order_id=order_id,
196
+ reason="Order not found",
197
+ )
198
+ )
199
+ return
200
+
201
+ self._pending_cancellations.add(order_id)
202
+
203
+ async def _cancel():
204
+ self._gateway.ib.cancelOrder(mapping.trade.order)
205
+
206
+ self._gateway.run_coro(_cancel())
207
+
208
+ def _on_modify_order(self, event: events.OrderModification) -> None:
209
+ order_id = event.system_order_id
210
+ _logger.info("Modifying order: %s", order_id)
211
+ mapping = self._order_mappings.get(order_id)
212
+
213
+ if mapping is None:
214
+ _logger.warning("Modify rejected (not found): %s", order_id)
215
+ self._publish(
216
+ events.OrderModificationRejected(
217
+ ts_event=pd.Timestamp.now(tz="UTC"),
218
+ ts_broker=pd.Timestamp.now(tz="UTC"),
219
+ associated_order_id=order_id,
220
+ reason="Order not found",
221
+ )
222
+ )
223
+ return
224
+
225
+ trade = mapping.trade
226
+ order = trade.order
227
+
228
+ if event.quantity is not None:
229
+ order.totalQuantity = event.quantity
230
+ if event.limit_price is not None:
231
+ order.lmtPrice = event.limit_price
232
+ if event.stop_price is not None:
233
+ order.auxPrice = event.stop_price
234
+
235
+ self._pending_modifications[order_id] = event
236
+
237
+ async def _modify():
238
+ self._gateway.ib.placeOrder(trade.contract, order)
239
+
240
+ self._gateway.run_coro(_modify())
241
+
242
+ def _on_order_status(self, trade: Trade) -> None:
243
+ ib_order_id = trade.order.orderId
244
+ system_order_id = self._ib_to_system_id.get(ib_order_id)
245
+ if system_order_id is None:
246
+ return
247
+
248
+ status = trade.orderStatus.status
249
+ now = pd.Timestamp.now(tz="UTC")
250
+
251
+ if system_order_id in self._pending_cancellations:
252
+ if status == "Cancelled":
253
+ _logger.info("Order cancelled: %s", system_order_id)
254
+ self._pending_cancellations.discard(system_order_id)
255
+ self._publish(
256
+ events.OrderCancellationAccepted(
257
+ ts_event=now,
258
+ ts_broker=now,
259
+ associated_order_id=system_order_id,
260
+ )
261
+ )
262
+ self._cleanup_order(system_order_id)
263
+ return
264
+
265
+ if system_order_id in self._pending_modifications:
266
+ if status in ("PreSubmitted", "Submitted"):
267
+ _logger.info("Order modified: %s", system_order_id)
268
+ self._pending_modifications.pop(system_order_id, None)
269
+ self._publish(
270
+ events.OrderModificationAccepted(
271
+ ts_event=now,
272
+ ts_broker=now,
273
+ associated_order_id=system_order_id,
274
+ broker_order_id=str(ib_order_id),
275
+ )
276
+ )
277
+ return
278
+
279
+ if status == "Inactive":
280
+ mapping = self._order_mappings.get(system_order_id)
281
+ if mapping:
282
+ _logger.warning("Order expired (inactive): %s", system_order_id)
283
+ self._publish(
284
+ events.OrderExpired(
285
+ ts_event=now,
286
+ ts_broker=now,
287
+ associated_order_id=system_order_id,
288
+ )
289
+ )
290
+ self._cleanup_order(system_order_id)
291
+
292
+ def _on_exec_details(self, trade: Trade, fill) -> None:
293
+ ib_order_id = trade.order.orderId
294
+ system_order_id = self._ib_to_system_id.get(ib_order_id)
295
+ if system_order_id is None:
296
+ return
297
+
298
+ mapping = self._order_mappings.get(system_order_id)
299
+ if mapping is None:
300
+ return
301
+
302
+ execution = fill.execution
303
+ now = pd.Timestamp.now(tz="UTC")
304
+ exec_time = pd.Timestamp(execution.time, tz="UTC") if execution.time else now
305
+
306
+ commission = 0.0
307
+ if fill.commissionReport and fill.commissionReport.commission:
308
+ commission = fill.commissionReport.commission
309
+
310
+ _logger.info(
311
+ "Order filled: %s %s %s @ %.4f (qty=%.2f)",
312
+ system_order_id,
313
+ mapping.side.name,
314
+ mapping.symbol,
315
+ execution.price,
316
+ execution.shares,
317
+ )
318
+ self._publish(
319
+ events.OrderFilled(
320
+ ts_event=now,
321
+ ts_broker=exec_time,
322
+ associated_order_id=system_order_id,
323
+ broker_fill_id=execution.execId,
324
+ symbol=mapping.symbol,
325
+ side=mapping.side,
326
+ quantity_filled=execution.shares,
327
+ fill_price=execution.price,
328
+ commission=commission,
329
+ exchange=execution.exchange or "IB",
330
+ )
331
+ )
332
+
333
+ self._filled_quantities[system_order_id] = (
334
+ self._filled_quantities.get(system_order_id, 0.0) + execution.shares
335
+ )
336
+
337
+ if trade.orderStatus.status == "Filled":
338
+ self._cleanup_order(system_order_id)
339
+
340
+ def _on_error(self, reqId: int, errorCode: int, errorString: str, contract) -> None:
341
+ system_order_id = self._ib_to_system_id.get(reqId)
342
+ if system_order_id is None:
343
+ return
344
+
345
+ _logger.error(
346
+ "IB error %d for order %s: %s", errorCode, system_order_id, errorString
347
+ )
348
+ now = pd.Timestamp.now(tz="UTC")
349
+
350
+ if system_order_id in self._pending_cancellations:
351
+ _logger.warning("Cancel rejected: %s - %s", system_order_id, errorString)
352
+ self._pending_cancellations.discard(system_order_id)
353
+ self._publish(
354
+ events.OrderCancellationRejected(
355
+ ts_event=now,
356
+ ts_broker=now,
357
+ associated_order_id=system_order_id,
358
+ reason=errorString,
359
+ )
360
+ )
361
+ return
362
+
363
+ if system_order_id in self._pending_modifications:
364
+ _logger.warning("Modify rejected: %s - %s", system_order_id, errorString)
365
+ self._pending_modifications.pop(system_order_id, None)
366
+ self._publish(
367
+ events.OrderModificationRejected(
368
+ ts_event=now,
369
+ ts_broker=now,
370
+ associated_order_id=system_order_id,
371
+ reason=errorString,
372
+ )
373
+ )
374
+ return
375
+
376
+ if errorCode in (201, 202, 203, 321, 322):
377
+ _logger.warning("Order rejected: %s - %s", system_order_id, errorString)
378
+ self._publish(
379
+ events.OrderSubmissionRejected(
380
+ ts_event=now,
381
+ ts_broker=now,
382
+ associated_order_id=system_order_id,
383
+ reason=errorString,
384
+ )
385
+ )
386
+ self._cleanup_order(system_order_id)
387
+
388
+ def _cleanup_order(self, system_order_id: uuid.UUID) -> None:
389
+ mapping = self._order_mappings.pop(system_order_id, None)
390
+ if mapping:
391
+ self._ib_to_system_id.pop(mapping.trade.order.orderId, None)
392
+ self._filled_quantities.pop(system_order_id, None)
393
+ self._pending_cancellations.discard(system_order_id)
394
+ self._pending_modifications.pop(system_order_id, None)
395
+
396
+ def _create_ib_order(self, event: events.OrderSubmission, action: str):
397
+ match event.order_type:
398
+ case models.OrderType.MARKET:
399
+ return MarketOrder(action, event.quantity)
400
+ case models.OrderType.LIMIT:
401
+ if event.limit_price is None:
402
+ return None
403
+ return LimitOrder(action, event.quantity, event.limit_price)
404
+ case models.OrderType.STOP:
405
+ if event.stop_price is None:
406
+ return None
407
+ return StopOrder(action, event.quantity, event.stop_price)
408
+ case models.OrderType.STOP_LIMIT:
409
+ if event.limit_price is None or event.stop_price is None:
410
+ return None
411
+ return StopLimitOrder(
412
+ action, event.quantity, event.limit_price, event.stop_price
413
+ )
414
+ case _:
415
+ return None
416
+
417
+ def _make_contract(self, symbol: str):
418
+ return make_contract(symbol, self._db_connection)
@@ -32,6 +32,9 @@ class SimulatedBroker(BrokerBase):
32
32
  super().__init__(event_bus)
33
33
  self._subscribe(events.BarReceived)
34
34
 
35
+ def connect(self) -> None:
36
+ pass
37
+
35
38
  def _on_event(self, event: events.EventBase) -> None:
36
39
  match event:
37
40
  case events.BarReceived() as bar:
@@ -1,4 +1,4 @@
1
- __all__ = ["Datafeed", "SimulatedDatafeed"]
1
+ __all__ = ["IBDatafeed", "SimulatedDatafeed"]
2
2
 
3
- from .base import Datafeed
3
+ from .ib import IBDatafeed
4
4
  from .simulated import SimulatedDatafeed