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.
@@ -0,0 +1,286 @@
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
+ )
@@ -1,10 +1,13 @@
1
- import pathlib
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sqlite3
2
5
  import threading
3
6
 
4
7
  import pandas as pd
5
8
 
6
9
  from onesecondtrader.core import events, messaging, models
7
- from .base import Datafeed
10
+ from onesecondtrader.core.datafeeds import DatafeedBase
8
11
 
9
12
  _RTYPE_MAP = {
10
13
  models.BarPeriod.SECOND: 32,
@@ -13,87 +16,137 @@ _RTYPE_MAP = {
13
16
  models.BarPeriod.DAY: 35,
14
17
  }
15
18
 
19
+ _PRICE_SCALE = 1e9
20
+
16
21
 
17
- class SimulatedDatafeed(Datafeed):
18
- csv_path: str = ""
22
+ class SimulatedDatafeed(DatafeedBase):
23
+ db_path: str = ""
19
24
 
20
25
  def __init__(self, event_bus: messaging.EventBus) -> None:
21
26
  super().__init__(event_bus)
27
+ self._db_path = self.db_path or os.environ.get(
28
+ "SECMASTER_DB_PATH", "secmaster.db"
29
+ )
30
+ self._connected = False
31
+ self._subscriptions: set[tuple[str, models.BarPeriod]] = set()
32
+ self._connection: sqlite3.Connection | None = None
22
33
  self._thread: threading.Thread | None = None
23
34
  self._stop_event = threading.Event()
24
35
 
25
- def stream(self, symbols: list[str], bar_period: models.BarPeriod) -> None:
26
- csv_path = pathlib.Path(self.csv_path)
27
- if self._thread and self._thread.is_alive():
28
- raise RuntimeError("Already streaming")
29
- if not csv_path.exists():
30
- raise FileNotFoundError(f"CSV file not found: {csv_path}")
31
- if not symbols:
32
- raise ValueError("symbols list cannot be empty")
33
-
34
- self._stop_event.clear()
35
- self._thread = threading.Thread(
36
- target=self._stream,
37
- args=(symbols, bar_period),
38
- name=self.__class__.__name__,
39
- daemon=False,
40
- )
41
- self._thread.start()
42
-
43
- def wait(self) -> None:
44
- if self._thread:
45
- self._thread.join()
36
+ def connect(self) -> None:
37
+ if self._connected:
38
+ return
39
+ self._connection = sqlite3.connect(self._db_path, check_same_thread=False)
40
+ self._connected = True
46
41
 
47
- def shutdown(self) -> None:
42
+ def disconnect(self) -> None:
43
+ if not self._connected:
44
+ return
48
45
  self._stop_event.set()
49
46
  if self._thread and self._thread.is_alive():
50
47
  self._thread.join()
48
+ if self._connection:
49
+ self._connection.close()
50
+ self._connection = None
51
+ self._connected = False
52
+
53
+ def subscribe(self, symbol: str, bar_period: models.BarPeriod) -> None:
54
+ if (symbol, bar_period) in self._subscriptions:
55
+ return
56
+ self._subscriptions.add((symbol, bar_period))
57
+ if self._thread is None or not self._thread.is_alive():
58
+ self._stop_event.clear()
59
+ self._thread = threading.Thread(
60
+ target=self._stream,
61
+ name=self.__class__.__name__,
62
+ daemon=False,
63
+ )
64
+ self._thread.start()
65
+
66
+ def unsubscribe(self, symbol: str, bar_period: models.BarPeriod) -> None:
67
+ self._subscriptions.discard((symbol, bar_period))
68
+
69
+ def _stream(self) -> None:
70
+ if not self._connection:
71
+ return
72
+
73
+ subscriptions = list(self._subscriptions)
74
+ if not subscriptions:
75
+ return
76
+
77
+ symbol_rtype_pairs = [
78
+ (symbol, _RTYPE_MAP[bar_period]) for symbol, bar_period in subscriptions
79
+ ]
80
+ rtype_by_symbol = {symbol: rtype for symbol, rtype in symbol_rtype_pairs}
81
+ bar_period_by_symbol = {symbol: bp for symbol, bp in subscriptions}
82
+ symbols = list(rtype_by_symbol.keys())
51
83
 
52
- def _stream(self, symbols: list[str], bar_period: models.BarPeriod) -> None:
53
- symbols_set = set(symbols)
54
- rtype = _RTYPE_MAP[bar_period]
55
-
56
- for chunk in pd.read_csv(
57
- self.csv_path,
58
- usecols=[
59
- "ts_event",
60
- "rtype",
61
- "open",
62
- "high",
63
- "low",
64
- "close",
65
- "volume",
66
- "symbol",
67
- ],
68
- dtype={
69
- "ts_event": int,
70
- "rtype": int,
71
- "open": int,
72
- "high": int,
73
- "low": int,
74
- "close": int,
75
- "volume": int,
76
- "symbol": str,
77
- },
78
- chunksize=10_000,
79
- ):
80
- for row in chunk.itertuples():
81
- if self._stop_event.is_set():
82
- return
83
-
84
- if row.symbol not in symbols_set or row.rtype != rtype:
85
- continue
86
-
87
- self._publish(
88
- events.BarReceived(
89
- ts_event=pd.Timestamp(row.ts_event, unit="ns", tz="UTC"),
90
- symbol=row.symbol,
91
- bar_period=bar_period,
92
- open=row.open / 1e9,
93
- high=row.high / 1e9,
94
- low=row.low / 1e9,
95
- close=row.close / 1e9,
96
- volume=row.volume,
97
- )
84
+ cursor = self._connection.cursor()
85
+
86
+ instrument_ids = self._resolve_instrument_ids(cursor, symbols)
87
+ if not instrument_ids:
88
+ return
89
+
90
+ instrument_to_symbol = {v: k for k, v in instrument_ids.items()}
91
+ id_list = list(instrument_ids.values())
92
+ rtype_list = list(set(rtype_by_symbol.values()))
93
+
94
+ placeholders_ids = ",".join("?" * len(id_list))
95
+ placeholders_rtypes = ",".join("?" * len(rtype_list))
96
+
97
+ query = f"""
98
+ SELECT instrument_id, rtype, ts_event, open, high, low, close, volume
99
+ FROM ohlcv
100
+ WHERE instrument_id IN ({placeholders_ids})
101
+ AND rtype IN ({placeholders_rtypes})
102
+ ORDER BY ts_event
103
+ """
104
+
105
+ cursor.execute(query, id_list + rtype_list)
106
+
107
+ while True:
108
+ if self._stop_event.is_set():
109
+ return
110
+
111
+ row = cursor.fetchone()
112
+ if row is None:
113
+ break
114
+
115
+ instrument_id, rtype, ts_event, open_, high, low, close, volume = row
116
+ symbol = instrument_to_symbol.get(instrument_id)
117
+ if symbol is None:
118
+ continue
119
+
120
+ expected_rtype = rtype_by_symbol.get(symbol)
121
+ if rtype != expected_rtype:
122
+ continue
123
+
124
+ bar_period = bar_period_by_symbol[symbol]
125
+
126
+ self._publish(
127
+ events.BarReceived(
128
+ ts_event=pd.Timestamp(ts_event, unit="ns", tz="UTC"),
129
+ symbol=symbol,
130
+ bar_period=bar_period,
131
+ open=open_ / _PRICE_SCALE,
132
+ high=high / _PRICE_SCALE,
133
+ low=low / _PRICE_SCALE,
134
+ close=close / _PRICE_SCALE,
135
+ volume=volume,
98
136
  )
99
- self._event_bus.wait_until_system_idle()
137
+ )
138
+ self._event_bus.wait_until_system_idle()
139
+
140
+ def _resolve_instrument_ids(
141
+ self, cursor: sqlite3.Cursor, symbols: list[str]
142
+ ) -> dict[str, int]:
143
+ placeholders = ",".join("?" * len(symbols))
144
+ query = f"""
145
+ SELECT symbol, instrument_id
146
+ FROM symbology
147
+ WHERE symbol IN ({placeholders})
148
+ GROUP BY symbol
149
+ HAVING start_date = MAX(start_date)
150
+ """
151
+ cursor.execute(query, symbols)
152
+ return {row[0]: row[1] for row in cursor.fetchall()}
@@ -0,0 +1,3 @@
1
+ __all__ = ["IBGateway", "make_contract"]
2
+
3
+ from .ib import IBGateway, make_contract