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,314 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ import sqlite3
7
+ import threading
8
+ import time
9
+
10
+ from ib_async import Contract, Forex, Future, IB, Option, Stock
11
+
12
+ _logger = logging.getLogger(__name__)
13
+
14
+ _INSTRUMENT_CLASS_MAP = {
15
+ "K": "STK",
16
+ "F": "FUT",
17
+ "C": "OPT",
18
+ "P": "OPT",
19
+ "X": "CASH",
20
+ }
21
+
22
+
23
+ def make_contract(
24
+ symbol: str, db_connection: sqlite3.Connection | None = None
25
+ ) -> Contract:
26
+ """
27
+ Resolve a symbol string to an IB Contract.
28
+
29
+ Resolution priority:
30
+ 1. Explicit format with colons (e.g., ``"AAPL:STK:USD:SMART"``)
31
+ 2. Secmaster database lookup (if db_connection provided)
32
+ 3. Default: US stock on SMART routing
33
+
34
+ Args:
35
+ symbol: Symbol string, either simple (``"AAPL"``) or qualified
36
+ (``"AAPL:STK:USD:SMART"``).
37
+ db_connection: Optional SQLite connection to secmaster database.
38
+
39
+ Returns:
40
+ An ib_async Contract object.
41
+ """
42
+ if ":" in symbol:
43
+ return _parse_qualified_symbol(symbol)
44
+
45
+ if db_connection:
46
+ row = _query_instrument(symbol, db_connection)
47
+ if row:
48
+ return _row_to_contract(row)
49
+
50
+ return Stock(symbol, "SMART", "USD")
51
+
52
+
53
+ def _parse_qualified_symbol(symbol: str) -> Contract:
54
+ parts = symbol.split(":")
55
+ sec_type = parts[1].upper() if len(parts) > 1 else "STK"
56
+ currency = parts[2] if len(parts) > 2 else "USD"
57
+ exchange = parts[3] if len(parts) > 3 else "SMART"
58
+
59
+ if sec_type == "STK":
60
+ return Stock(parts[0], exchange, currency)
61
+ elif sec_type == "CASH":
62
+ return Forex(pair=f"{parts[0]}{currency}")
63
+ elif sec_type == "FUT":
64
+ expiry = parts[4] if len(parts) > 4 else ""
65
+ return Future(parts[0], expiry, exchange, currency)
66
+ elif sec_type == "OPT":
67
+ expiry = parts[4] if len(parts) > 4 else ""
68
+ strike = float(parts[5]) if len(parts) > 5 else 0.0
69
+ right = parts[6] if len(parts) > 6 else "C"
70
+ return Option(parts[0], expiry, strike, right, exchange, currency)
71
+ else:
72
+ return Stock(parts[0], exchange, currency)
73
+
74
+
75
+ def _query_instrument(symbol: str, db_connection: sqlite3.Connection) -> tuple | None:
76
+ cursor = db_connection.cursor()
77
+ cursor.execute(
78
+ """
79
+ SELECT raw_symbol, instrument_class, currency, exchange,
80
+ expiration, strike_price
81
+ FROM instruments
82
+ WHERE raw_symbol = ?
83
+ ORDER BY expiration DESC
84
+ LIMIT 1
85
+ """,
86
+ (symbol,),
87
+ )
88
+ return cursor.fetchone()
89
+
90
+
91
+ def _row_to_contract(row: tuple) -> Contract:
92
+ raw_symbol, instrument_class, currency, exchange, expiration, strike = row
93
+ currency = currency or "USD"
94
+ exchange = exchange or "SMART"
95
+ sec_type = _INSTRUMENT_CLASS_MAP.get(instrument_class, "STK")
96
+
97
+ if sec_type == "STK":
98
+ return Stock(raw_symbol, exchange, currency)
99
+ elif sec_type == "CASH":
100
+ return Forex(pair=f"{raw_symbol}{currency}")
101
+ elif sec_type == "FUT":
102
+ expiry = str(expiration) if expiration else ""
103
+ return Future(raw_symbol, expiry, exchange, currency)
104
+ elif sec_type == "OPT":
105
+ expiry = str(expiration) if expiration else ""
106
+ strike_val = float(strike) / 1e9 if strike else 0.0
107
+ right = "C" if instrument_class == "C" else "P"
108
+ return Option(raw_symbol, expiry, strike_val, right, exchange, currency)
109
+ else:
110
+ return Stock(raw_symbol, exchange, currency)
111
+
112
+
113
+ class IBGateway:
114
+ """
115
+ Shared gateway for IB connectivity with automatic reconnection.
116
+
117
+ The gateway manages a single IB connection shared by multiple components
118
+ (datafeed, broker). It handles:
119
+
120
+ - Reference counting for connect/disconnect
121
+ - Automatic reconnection on disconnect
122
+ - Running an asyncio event loop in a background thread
123
+
124
+ Reconnection Behavior:
125
+ When the connection is lost, the gateway will automatically attempt
126
+ to reconnect after a configurable delay. Components that registered
127
+ reconnect callbacks will be notified after successful reconnection
128
+ so they can restore their subscriptions.
129
+
130
+ Environment Variables:
131
+ - ``IB_HOST``: Host address (default: 127.0.0.1)
132
+ - ``IB_PORT``: Port number (default: 4001 for gateway, 7497 for TWS)
133
+ - ``IB_CLIENT_ID``: Client ID (default: 1)
134
+ - ``IB_RECONNECT_DELAY``: Seconds to wait before reconnecting (default: 5)
135
+ - ``IB_MAX_RECONNECT_ATTEMPTS``: Max reconnect attempts, 0=infinite (default: 0)
136
+ """
137
+
138
+ def __init__(self) -> None:
139
+ self._host = os.environ.get("IB_HOST", "127.0.0.1")
140
+ self._port = int(os.environ.get("IB_PORT", "4001"))
141
+ self._client_id = int(os.environ.get("IB_CLIENT_ID", "1"))
142
+ self._reconnect_delay = float(os.environ.get("IB_RECONNECT_DELAY", "5"))
143
+ self._max_reconnect_attempts = int(
144
+ os.environ.get("IB_MAX_RECONNECT_ATTEMPTS", "0")
145
+ )
146
+ self._ib = IB()
147
+ self._ref_count = 0
148
+ self._lock = threading.Lock()
149
+ self._loop: asyncio.AbstractEventLoop | None = None
150
+ self._loop_thread: threading.Thread | None = None
151
+ self._reconnect_callbacks: list = []
152
+ self._disconnect_callbacks: list = []
153
+ self._reconnecting = False
154
+ self._should_reconnect = True
155
+ self._reconnect_attempts = 0
156
+
157
+ def _run_loop(self) -> None:
158
+ self._loop = asyncio.new_event_loop()
159
+ asyncio.set_event_loop(self._loop)
160
+ self._loop.run_forever()
161
+
162
+ def run_coro(self, coro):
163
+ if self._loop is None:
164
+ raise RuntimeError("Gateway not connected")
165
+ future = asyncio.run_coroutine_threadsafe(coro, self._loop)
166
+ return future.result()
167
+
168
+ def acquire(self) -> None:
169
+ with self._lock:
170
+ if self._ref_count == 0:
171
+ _logger.info("Connecting to IB at %s:%d", self._host, self._port)
172
+ self._should_reconnect = True
173
+ self._reconnect_attempts = 0
174
+ self._loop_thread = threading.Thread(
175
+ target=self._run_loop, daemon=True, name="IBGatewayLoop"
176
+ )
177
+ self._loop_thread.start()
178
+ while self._loop is None:
179
+ time.sleep(0.01)
180
+ self._ib.disconnectedEvent += self._on_disconnected
181
+ self.run_coro(
182
+ self._ib.connectAsync(
183
+ self._host, self._port, clientId=self._client_id
184
+ )
185
+ )
186
+ _logger.info("Connected to IB")
187
+ self._ref_count += 1
188
+
189
+ def release(self) -> None:
190
+ with self._lock:
191
+ self._ref_count -= 1
192
+ if self._ref_count == 0:
193
+ _logger.info("Disconnecting from IB")
194
+ self._should_reconnect = False
195
+ self._ib.disconnectedEvent -= self._on_disconnected
196
+ self._ib.disconnect()
197
+ if self._loop:
198
+ self._loop.call_soon_threadsafe(self._loop.stop)
199
+ if self._loop_thread:
200
+ self._loop_thread.join()
201
+ self._loop = None
202
+ self._loop_thread = None
203
+ self._reconnect_callbacks.clear()
204
+ self._disconnect_callbacks.clear()
205
+ _logger.info("Disconnected from IB")
206
+
207
+ def register_reconnect_callback(self, callback) -> None:
208
+ """
209
+ Register a callback to be called after successful reconnection.
210
+
211
+ The callback should restore any subscriptions or state that was lost
212
+ during the disconnect.
213
+ """
214
+ if callback not in self._reconnect_callbacks:
215
+ self._reconnect_callbacks.append(callback)
216
+
217
+ def unregister_reconnect_callback(self, callback) -> None:
218
+ if callback in self._reconnect_callbacks:
219
+ self._reconnect_callbacks.remove(callback)
220
+
221
+ def register_disconnect_callback(self, callback) -> None:
222
+ """
223
+ Register a callback to be called when disconnection is detected.
224
+
225
+ The callback can be used to pause operations or notify the system.
226
+ """
227
+ if callback not in self._disconnect_callbacks:
228
+ self._disconnect_callbacks.append(callback)
229
+
230
+ def unregister_disconnect_callback(self, callback) -> None:
231
+ if callback in self._disconnect_callbacks:
232
+ self._disconnect_callbacks.remove(callback)
233
+
234
+ def _on_disconnected(self) -> None:
235
+ if not self._should_reconnect or self._reconnecting:
236
+ return
237
+
238
+ _logger.warning("IB connection lost")
239
+ for callback in self._disconnect_callbacks:
240
+ try:
241
+ callback()
242
+ except Exception:
243
+ _logger.exception("Error in disconnect callback")
244
+
245
+ if self._loop:
246
+ asyncio.run_coroutine_threadsafe(self._reconnect_async(), self._loop)
247
+
248
+ async def _reconnect_async(self) -> None:
249
+ if self._reconnecting:
250
+ return
251
+ self._reconnecting = True
252
+
253
+ while self._should_reconnect:
254
+ self._reconnect_attempts += 1
255
+ if (
256
+ self._max_reconnect_attempts > 0
257
+ and self._reconnect_attempts > self._max_reconnect_attempts
258
+ ):
259
+ _logger.error(
260
+ "Max reconnect attempts (%d) reached, giving up",
261
+ self._max_reconnect_attempts,
262
+ )
263
+ self._reconnecting = False
264
+ return
265
+
266
+ _logger.info(
267
+ "Reconnecting to IB (attempt %d) in %.1fs",
268
+ self._reconnect_attempts,
269
+ self._reconnect_delay,
270
+ )
271
+ await asyncio.sleep(self._reconnect_delay)
272
+
273
+ if not self._should_reconnect:
274
+ break
275
+
276
+ try:
277
+ await self._ib.connectAsync(
278
+ self._host, self._port, clientId=self._client_id
279
+ )
280
+ _logger.info("Reconnected to IB")
281
+ self._reconnect_attempts = 0
282
+ self._reconnecting = False
283
+
284
+ for callback in self._reconnect_callbacks:
285
+ try:
286
+ callback()
287
+ except Exception:
288
+ _logger.exception("Error in reconnect callback")
289
+ return
290
+ except Exception as e:
291
+ _logger.warning("Reconnect failed: %s", e)
292
+ continue
293
+
294
+ self._reconnecting = False
295
+
296
+ @property
297
+ def ib(self) -> IB:
298
+ return self._ib
299
+
300
+ @property
301
+ def is_connected(self) -> bool:
302
+ return self._ib.isConnected()
303
+
304
+
305
+ _gateway: IBGateway | None = None
306
+ _gateway_lock = threading.Lock()
307
+
308
+
309
+ def _get_gateway() -> IBGateway:
310
+ global _gateway
311
+ with _gateway_lock:
312
+ if _gateway is None:
313
+ _gateway = IBGateway()
314
+ return _gateway
@@ -1,4 +1,5 @@
1
1
  from onesecondtrader.core import brokers as brokers
2
+ from onesecondtrader.core import datafeeds as datafeeds
2
3
  from onesecondtrader.core import events as events
3
4
  from onesecondtrader.core import indicators as indicators
4
5
  from onesecondtrader.core import messaging as messaging
@@ -12,6 +12,13 @@ class BrokerBase(messaging.Subscriber):
12
12
  events.requests.OrderModification,
13
13
  )
14
14
 
15
+ @abc.abstractmethod
16
+ def connect(self) -> None:
17
+ pass
18
+
19
+ def disconnect(self) -> None:
20
+ self.shutdown()
21
+
15
22
  def _on_event(self, event: events.bases.EventBase) -> None:
16
23
  match event:
17
24
  case events.requests.OrderSubmission() as submit_order:
@@ -0,0 +1,3 @@
1
+ __all__ = ["DatafeedBase"]
2
+
3
+ from .base import DatafeedBase
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+
5
+ from onesecondtrader.core import events, messaging, models
6
+
7
+
8
+ class DatafeedBase(abc.ABC):
9
+ def __init__(self, event_bus: messaging.EventBus) -> None:
10
+ self._event_bus = event_bus
11
+
12
+ def _publish(self, event: events.EventBase) -> None:
13
+ self._event_bus.publish(event)
14
+
15
+ @abc.abstractmethod
16
+ def connect(self) -> None:
17
+ pass
18
+
19
+ @abc.abstractmethod
20
+ def disconnect(self) -> None:
21
+ pass
22
+
23
+ @abc.abstractmethod
24
+ def subscribe(self, symbol: str, bar_period: models.BarPeriod) -> None:
25
+ pass
26
+
27
+ @abc.abstractmethod
28
+ def unsubscribe(self, symbol: str, bar_period: models.BarPeriod) -> None:
29
+ pass
@@ -1,5 +1,6 @@
1
1
  __all__ = [
2
- "init_secmaster",
2
+ "create_secmaster_db",
3
+ "ingest_dbn",
3
4
  ]
4
5
 
5
- from onesecondtrader.secmaster.utils import init_secmaster
6
+ from onesecondtrader.secmaster.utils import create_secmaster_db, ingest_dbn