quantglide-ibkr 1.0.0__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.
@@ -0,0 +1,7 @@
1
+ dist/
2
+ build/
3
+ *.egg-info/
4
+ __pycache__/
5
+ *.pyc
6
+ .env
7
+ *.log
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: quantglide-ibkr
3
+ Version: 1.0.0
4
+ Summary: QuantGlide IBKR relay — polls for trade signals and places orders via IB Gateway
5
+ License: Proprietary
6
+ Requires-Python: >=3.9
7
+ Requires-Dist: ib-insync>=0.9.86
8
+ Requires-Dist: python-dotenv>=1.0.0
9
+ Requires-Dist: supabase>=2.0.0
10
+ Description-Content-Type: text/markdown
11
+
12
+ # quantglide-ibkr
13
+
14
+ QuantGlide IBKR relay agent. Polls for trade signals from QuantGlide and places orders via IB Gateway.
15
+
16
+ ## Requirements
17
+
18
+ - Python 3.9+
19
+ - IB Gateway running locally ([download](https://www.interactivebrokers.com/en/trading/ibgateway-stable.php))
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install quantglide-ibkr
25
+ ```
26
+
27
+ ## Setup (one time)
28
+
29
+ 1. Go to the QuantGlide dashboard → Settings → IBKR
30
+ 2. Copy your setup token
31
+ 3. Run:
32
+
33
+ ```bash
34
+ quantglide-ibkr --token <paste_token_here>
35
+ ```
36
+
37
+ ## Run
38
+
39
+ ```bash
40
+ quantglide-ibkr
41
+ ```
42
+
43
+ For paper trading (IB Gateway on port 4002):
44
+
45
+ ```bash
46
+ quantglide-ibkr --port 4002
47
+ ```
48
+
49
+ Test without placing real orders:
50
+
51
+ ```bash
52
+ quantglide-ibkr --dry-run
53
+ ```
54
+
55
+ ## How it works
56
+
57
+ The agent connects to IB Gateway on your machine and polls QuantGlide for trade signals. When a signal arrives, it places the order via IB Gateway. Keep the agent running during trading hours (10:25–13:00 ET).
58
+
59
+ Config is stored in `~/.quantglide/.env`. Refresh tokens are automatically rotated.
@@ -0,0 +1,48 @@
1
+ # quantglide-ibkr
2
+
3
+ QuantGlide IBKR relay agent. Polls for trade signals from QuantGlide and places orders via IB Gateway.
4
+
5
+ ## Requirements
6
+
7
+ - Python 3.9+
8
+ - IB Gateway running locally ([download](https://www.interactivebrokers.com/en/trading/ibgateway-stable.php))
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install quantglide-ibkr
14
+ ```
15
+
16
+ ## Setup (one time)
17
+
18
+ 1. Go to the QuantGlide dashboard → Settings → IBKR
19
+ 2. Copy your setup token
20
+ 3. Run:
21
+
22
+ ```bash
23
+ quantglide-ibkr --token <paste_token_here>
24
+ ```
25
+
26
+ ## Run
27
+
28
+ ```bash
29
+ quantglide-ibkr
30
+ ```
31
+
32
+ For paper trading (IB Gateway on port 4002):
33
+
34
+ ```bash
35
+ quantglide-ibkr --port 4002
36
+ ```
37
+
38
+ Test without placing real orders:
39
+
40
+ ```bash
41
+ quantglide-ibkr --dry-run
42
+ ```
43
+
44
+ ## How it works
45
+
46
+ The agent connects to IB Gateway on your machine and polls QuantGlide for trade signals. When a signal arrives, it places the order via IB Gateway. Keep the agent running during trading hours (10:25–13:00 ET).
47
+
48
+ Config is stored in `~/.quantglide/.env`. Refresh tokens are automatically rotated.
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "quantglide-ibkr"
7
+ version = "1.0.0"
8
+ description = "QuantGlide IBKR relay — polls for trade signals and places orders via IB Gateway"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "Proprietary" }
12
+ dependencies = [
13
+ "supabase>=2.0.0",
14
+ "python-dotenv>=1.0.0",
15
+ "ib_insync>=0.9.86",
16
+ ]
17
+
18
+ [project.scripts]
19
+ quantglide-ibkr = "quantglide_ibkr.cli:main"
20
+
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["src/quantglide_ibkr"]
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,370 @@
1
+ # ibkr_adapter_gw.py
2
+ """
3
+ IBKR order adapter using the IB Gateway socket API (ib_insync).
4
+
5
+ Requires IB Gateway or TWS running:
6
+ - Port 4001: IB Gateway live
7
+ - Port 4002: IB Gateway paper
8
+ - Port 7496: TWS live
9
+ - Port 7497: TWS paper
10
+ """
11
+ import logging
12
+ import math
13
+ import time
14
+ from ib_insync import IB, Option, Contract, ComboLeg, LimitOrder, MarketOrder
15
+
16
+ logger = logging.getLogger('quantglide_ibkr.adapter')
17
+
18
+ TICK_SIZE = 0.05
19
+ MIN_CREDIT_DEFAULT = 0.10 # IBKR rejects < $0.10 as "guaranteed-to-lose"
20
+ POLL_INTERVAL_S = 3
21
+ MAX_REPRICE_STEPS = 6
22
+ STEP_WAIT_S = 8 # seconds to wait at each price level before repricing
23
+
24
+ RECONNECT_BACKOFF_S = [1, 2, 5, 10, 30, 60]
25
+ MAX_RECONNECT_ATTEMPTS = 10
26
+
27
+
28
+ class IBKRGatewayAdapter:
29
+ """IBKR order adapter using IB Gateway socket API (ib_insync)."""
30
+
31
+ def __init__(self, host='127.0.0.1', port=4001, client_id=1, account=None):
32
+ self.ib = IB()
33
+ self.host = host
34
+ self.port = port
35
+ self.client_id = client_id
36
+ self._connected = False
37
+ self._intentional_disconnect = False
38
+ self.acct_id = None
39
+ self._account_override = account # explicit sub-account for FA accounts
40
+ self.ib.disconnectedEvent += self._on_disconnect
41
+
42
+ @property
43
+ def connected(self) -> bool:
44
+ """True only if the underlying socket is actually connected."""
45
+ return self._connected and self.ib.isConnected()
46
+
47
+ # ── Connection ──────────────────────────────────────────────────────
48
+
49
+ def _on_disconnect(self):
50
+ """Callback fired by ib_insync when the socket drops."""
51
+ self._connected = False
52
+ if not self._intentional_disconnect:
53
+ logger.warning("[IBKR] Disconnected from gateway (disconnectedEvent fired)")
54
+
55
+ def connect(self):
56
+ """Connect to IB Gateway via socket."""
57
+ if self.ib.isConnected():
58
+ self.ib.disconnect()
59
+ self.ib.connect(
60
+ host=self.host,
61
+ port=self.port,
62
+ clientId=self.client_id,
63
+ timeout=20,
64
+ )
65
+ accounts = self.ib.managedAccounts()
66
+ if not accounts:
67
+ raise RuntimeError("No IBKR accounts found")
68
+ if self._account_override:
69
+ if self._account_override not in accounts:
70
+ raise RuntimeError(
71
+ f"Specified ibkr_account '{self._account_override}' not in "
72
+ f"managed accounts: {accounts}"
73
+ )
74
+ self.acct_id = self._account_override
75
+ else:
76
+ self.acct_id = accounts[0]
77
+ self._connected = True
78
+ logger.info(
79
+ f"[IBKR] Connected via Gateway socket "
80
+ f"{self.host}:{self.port}, account={self.acct_id} "
81
+ f"(managed: {accounts})"
82
+ )
83
+
84
+ def ensure_connected(self) -> bool:
85
+ """Check connection and reconnect if needed. Returns True if connected."""
86
+ if self.connected:
87
+ return True
88
+ logger.warning("[IBKR] Connection lost, attempting to reconnect...")
89
+ for attempt in range(1, MAX_RECONNECT_ATTEMPTS + 1):
90
+ backoff = RECONNECT_BACKOFF_S[min(attempt - 1, len(RECONNECT_BACKOFF_S) - 1)]
91
+ try:
92
+ self.connect()
93
+ logger.info(f"[IBKR] Reconnected on attempt {attempt}")
94
+ return True
95
+ except Exception as e:
96
+ logger.warning(
97
+ f"[IBKR] Reconnect attempt {attempt}/{MAX_RECONNECT_ATTEMPTS} "
98
+ f"failed: {e} (retry in {backoff}s)"
99
+ )
100
+ time.sleep(backoff)
101
+ logger.error(
102
+ f"[IBKR] Failed to reconnect after {MAX_RECONNECT_ATTEMPTS} attempts"
103
+ )
104
+ return False
105
+
106
+ def disconnect(self):
107
+ if self._connected or self.ib.isConnected():
108
+ self._intentional_disconnect = True
109
+ self.ib.disconnect()
110
+ self._connected = False
111
+ self._intentional_disconnect = False
112
+ logger.info("[IBKR] Disconnected")
113
+
114
+ # ── Market data helpers ────────────────────────────────────────────
115
+
116
+ def _get_option_mid(self, contract) -> float | None:
117
+ """Request snapshot market data and return mid price.
118
+ Uses regulatory snapshot ($0.03/req for options) — no subscription needed."""
119
+ self.ib.reqMarketDataType(1) # 1 = live
120
+ self.ib.reqMktData(contract, snapshot=False, regulatorySnapshot=True)
121
+ self.ib.sleep(3)
122
+ ticker = self.ib.ticker(contract)
123
+ if ticker is None:
124
+ return None
125
+
126
+ bid = ticker.bid
127
+ ask = ticker.ask
128
+
129
+ # bid/ask can be nan or -1 when unavailable
130
+ bid_ok = bid is not None and math.isfinite(bid) and bid > 0
131
+ ask_ok = ask is not None and math.isfinite(ask) and ask > 0
132
+
133
+ if bid_ok and ask_ok:
134
+ return (bid + ask) / 2.0
135
+ if bid_ok:
136
+ return bid
137
+ if ask_ok:
138
+ return ask
139
+ return None
140
+
141
+ def _get_spread_mid_credit(self, short_contract, long_contract) -> float | None:
142
+ """Compute mid-price credit for a vertical spread from IBKR market data."""
143
+ short_mid = self._get_option_mid(short_contract)
144
+ long_mid = self._get_option_mid(long_contract)
145
+
146
+ logger.info(
147
+ f"[IBKR] Leg prices: short_mid={short_mid} long_mid={long_mid}"
148
+ )
149
+
150
+ if short_mid is None or long_mid is None:
151
+ return None
152
+
153
+ credit = short_mid - long_mid
154
+ return credit if credit > 0 else None
155
+
156
+ @staticmethod
157
+ def _round_down_tick(price: float) -> float:
158
+ """Round price DOWN to nearest tick (float-safe)."""
159
+ return round(math.floor(round(price, 4) / TICK_SIZE) * TICK_SIZE, 2)
160
+
161
+ @staticmethod
162
+ def _round_tick(price: float) -> float:
163
+ """Round price to nearest tick."""
164
+ return round(round(price / TICK_SIZE) * TICK_SIZE, 2)
165
+
166
+ def _cancel_and_wait(self, trade, timeout_s=5) -> bool:
167
+ """Cancel an order and wait for acknowledgement. Returns True if confirmed cancelled."""
168
+ try:
169
+ self.ib.cancelOrder(trade.order)
170
+ except Exception as e:
171
+ logger.warning(f"[IBKR] Cancel request failed: {e}")
172
+ return False
173
+ elapsed = 0
174
+ while elapsed < timeout_s:
175
+ self.ib.sleep(1)
176
+ elapsed += 1
177
+ status = trade.orderStatus.status
178
+ if status in ('Cancelled', 'ApiCancelled'):
179
+ return True
180
+ if status == 'Filled':
181
+ return False # filled during cancel
182
+ logger.warning(
183
+ f"[IBKR] Cancel not confirmed after {timeout_s}s, "
184
+ f"status={trade.orderStatus.status}"
185
+ )
186
+ return False
187
+
188
+ # ── Place orders ────────────────────────────────────────────────────
189
+
190
+ def place_credit_spread(
191
+ self,
192
+ symbol,
193
+ expiration, # "YYYYMMDD"
194
+ short_strike, # float
195
+ long_strike, # float
196
+ is_put, # bool
197
+ qty, # int
198
+ limit_price=None, # optional override; if None, adapter fetches mid
199
+ min_credit=MIN_CREDIT_DEFAULT,
200
+ ):
201
+ """
202
+ Place a vertical credit spread with linear reprice.
203
+
204
+ 1. Qualify contracts & fetch IBKR market data for mid credit.
205
+ 2. Place limit order at mid (rounded to tick).
206
+ 3. Poll for fill; step price down by one tick each step.
207
+ 4. If price hits min_credit, final poll then cancel if unfilled.
208
+
209
+ Returns ib_insync Trade object if filled, None otherwise.
210
+ """
211
+ if not self.ensure_connected():
212
+ logger.error("[IBKR] Not connected and reconnect failed")
213
+ return None
214
+
215
+ right = "P" if is_put else "C"
216
+
217
+ short_contract = Option(symbol, expiration, short_strike, right, 'CBOE')
218
+ long_contract = Option(symbol, expiration, long_strike, right, 'CBOE')
219
+
220
+ try:
221
+ self.ib.qualifyContracts(short_contract, long_contract)
222
+ except Exception as e:
223
+ logger.error(f"[IBKR] Contract qualification failed: {e}")
224
+ return None
225
+
226
+ if not short_contract.conId or not long_contract.conId:
227
+ logger.error(
228
+ f"[IBKR] Missing conIds after qualify: "
229
+ f"short={short_contract.conId} long={long_contract.conId}"
230
+ )
231
+ return None
232
+
233
+ # ── Get mid credit from IBKR market data ──
234
+ mid_credit = self._get_spread_mid_credit(short_contract, long_contract)
235
+
236
+ if mid_credit is not None and mid_credit > 0:
237
+ start_price = self._round_down_tick(mid_credit)
238
+ elif limit_price is not None and limit_price > 0:
239
+ logger.warning(
240
+ f"[IBKR] Could not get mid from IBKR, using caller price: {limit_price:.2f}"
241
+ )
242
+ start_price = self._round_tick(limit_price)
243
+ else:
244
+ logger.error("[IBKR] No valid price available for spread")
245
+ return None
246
+
247
+ start_price = max(start_price, min_credit)
248
+
249
+ logger.info(
250
+ f"[IBKR] Spread pricing: mid_credit={mid_credit} "
251
+ f"start_price={start_price:.2f} min_credit={min_credit:.2f}"
252
+ )
253
+
254
+ # ── Build combo (BAG) contract ──
255
+ combo = Contract()
256
+ combo.symbol = symbol
257
+ combo.secType = 'BAG'
258
+ combo.currency = 'USD'
259
+ combo.exchange = 'SMART'
260
+
261
+ leg1 = ComboLeg()
262
+ leg1.conId = short_contract.conId
263
+ leg1.ratio = 1
264
+ leg1.action = 'SELL'
265
+ leg1.exchange = 'CBOE'
266
+
267
+ leg2 = ComboLeg()
268
+ leg2.conId = long_contract.conId
269
+ leg2.ratio = 1
270
+ leg2.action = 'BUY'
271
+ leg2.exchange = 'CBOE'
272
+
273
+ combo.comboLegs = [leg1, leg2]
274
+
275
+ # ── Linear reprice loop ──
276
+ price = start_price
277
+ last_trade = None
278
+
279
+ for step in range(MAX_REPRICE_STEPS):
280
+ price = round(price, 2)
281
+
282
+ order = LimitOrder(
283
+ action='BUY',
284
+ totalQuantity=qty,
285
+ lmtPrice=-price,
286
+ tif='DAY',
287
+ account=self.acct_id,
288
+ )
289
+
290
+ logger.info(
291
+ f"[IBKR] Step {step}: placing credit spread {symbol} "
292
+ f"{short_strike}/{long_strike} {right} qty={qty} "
293
+ f"@ ${price:.2f} credit | "
294
+ f"short_conId={short_contract.conId} long_conId={long_contract.conId}"
295
+ )
296
+
297
+ try:
298
+ trade = self.ib.placeOrder(combo, order)
299
+ last_trade = trade
300
+ logger.info(
301
+ f"[IBKR] Order submitted: orderId={trade.order.orderId} "
302
+ f"status={trade.orderStatus.status}"
303
+ )
304
+ except Exception as e:
305
+ logger.error(f"[IBKR] Failed to place spread: {e}")
306
+ return None
307
+
308
+ # Poll for fill
309
+ elapsed = 0
310
+ while elapsed < STEP_WAIT_S:
311
+ self.ib.sleep(POLL_INTERVAL_S)
312
+ elapsed += POLL_INTERVAL_S
313
+ status = trade.orderStatus.status
314
+ logger.info(
315
+ f"[IBKR] Poll orderId={trade.order.orderId} "
316
+ f"status={status} filled={trade.orderStatus.filled}"
317
+ )
318
+ if status == 'Filled':
319
+ logger.info(
320
+ f"[IBKR] FILLED @ ${price:.2f} credit "
321
+ f"(step={step}, orderId={trade.order.orderId})"
322
+ )
323
+ return trade
324
+ if status in ('Cancelled', 'ApiCancelled', 'Inactive'):
325
+ logger.warning(f"[IBKR] Order rejected: {status}")
326
+ return None
327
+
328
+ # Not filled — check if we can reprice lower
329
+ next_price = round(price - TICK_SIZE, 2)
330
+ if next_price < min_credit:
331
+ logger.info(f"[IBKR] At credit floor ${price:.2f}, final poll...")
332
+ extra = 0
333
+ while extra < STEP_WAIT_S:
334
+ self.ib.sleep(POLL_INTERVAL_S)
335
+ extra += POLL_INTERVAL_S
336
+ if trade.orderStatus.status == 'Filled':
337
+ logger.info(f"[IBKR] FILLED at floor @ ${price:.2f}")
338
+ return trade
339
+ self._cancel_and_wait(trade)
340
+ if trade.orderStatus.status == 'Filled':
341
+ logger.info(f"[IBKR] Filled during cancel @ ${price:.2f}")
342
+ return trade
343
+ logger.info("[IBKR] Cancelled unfilled order at credit floor")
344
+ return None
345
+
346
+ self._cancel_and_wait(trade)
347
+ if trade.orderStatus.status == 'Filled':
348
+ logger.info(f"[IBKR] Filled during cancel @ ${price:.2f}")
349
+ return trade
350
+ if trade.orderStatus.status not in ('Cancelled', 'ApiCancelled'):
351
+ logger.warning(
352
+ f"[IBKR] Cancel not confirmed (status={trade.orderStatus.status}), "
353
+ f"aborting reprice to avoid duplicate orders"
354
+ )
355
+ return None
356
+
357
+ logger.info(
358
+ f"[IBKR] Cancelled orderId={trade.order.orderId} "
359
+ f"for reprice {price:.2f} -> {next_price:.2f}"
360
+ )
361
+ price = next_price
362
+
363
+ if last_trade and last_trade.orderStatus.status not in (
364
+ 'Filled', 'Cancelled', 'ApiCancelled', 'Inactive'
365
+ ):
366
+ self._cancel_and_wait(last_trade)
367
+ if last_trade.orderStatus.status == 'Filled':
368
+ return last_trade
369
+ logger.info(f"[IBKR] Exhausted {MAX_REPRICE_STEPS} reprice steps, no fill")
370
+ return None
@@ -0,0 +1,322 @@
1
+ """
2
+ IBKR Signal Agent — polls Supabase for trade signals and places orders via IB Gateway.
3
+ """
4
+ import logging
5
+ import os
6
+ import time
7
+ import urllib.request
8
+ import urllib.error
9
+ import json
10
+ from datetime import datetime, timezone
11
+ from logging.handlers import RotatingFileHandler
12
+
13
+ from dotenv import dotenv_values
14
+ from supabase import create_client
15
+ from zoneinfo import ZoneInfo
16
+
17
+ from quantglide_ibkr.adapter import IBKRGatewayAdapter
18
+
19
+ EASTERN = ZoneInfo("America/New_York")
20
+
21
+ POLL_INTERVAL_S = 2
22
+ MIN_CREDIT = 0.10
23
+
24
+ EXCHANGE_URL = "https://app.quantglide.com/api/ibkr/exchange"
25
+
26
+
27
+ # ── Config ───────────────────────────────────────────────────────────────────
28
+
29
+ def load_config(env_path: str) -> dict:
30
+ vals = dotenv_values(env_path)
31
+ def get(key, default=""):
32
+ return (vals.get(key) or os.environ.get(key) or default).strip()
33
+ return {
34
+ "QUANTGLIDE_TOKEN": get("QUANTGLIDE_TOKEN"),
35
+ "IBKR_HOST": get("IBKR_HOST", "127.0.0.1"),
36
+ "IBKR_PORT": int(get("IBKR_PORT", "4001")),
37
+ "IBKR_CLIENT_ID": int(get("IBKR_CLIENT_ID", "10")),
38
+ }
39
+
40
+
41
+ # ── Logger ───────────────────────────────────────────────────────────────────
42
+
43
+ def setup_logger() -> logging.Logger:
44
+ lgr = logging.getLogger("quantglide_ibkr")
45
+ if lgr.handlers:
46
+ return lgr # already configured
47
+ lgr.setLevel(logging.INFO)
48
+ fmt = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
49
+ fh = RotatingFileHandler("ibkr_agent.log", maxBytes=5 * 1024 * 1024, backupCount=2)
50
+ fh.setFormatter(fmt)
51
+ ch = logging.StreamHandler()
52
+ ch.setFormatter(fmt)
53
+ lgr.addHandler(fh)
54
+ lgr.addHandler(ch)
55
+ return lgr
56
+
57
+
58
+ # ── Credential exchange ───────────────────────────────────────────────────────
59
+
60
+ def exchange_token(token: str, logger: logging.Logger) -> dict:
61
+ """
62
+ Exchange the permanent UUID token for fresh credentials from QuantGlide.
63
+ Called on every startup — no local credential storage needed.
64
+ """
65
+ logger.info("[AUTH] Fetching credentials from QuantGlide...")
66
+ body = json.dumps({"token": token}).encode()
67
+ req = urllib.request.Request(
68
+ EXCHANGE_URL,
69
+ data=body,
70
+ headers={"Content-Type": "application/json"},
71
+ method="POST",
72
+ )
73
+ try:
74
+ with urllib.request.urlopen(req, timeout=15) as resp:
75
+ payload = json.loads(resp.read().decode())
76
+ except urllib.error.HTTPError as e:
77
+ body = e.read().decode()
78
+ if e.code == 401 or e.code == 404:
79
+ raise RuntimeError(
80
+ "Setup token not recognised.\n"
81
+ "Re-copy your token from the QuantGlide dashboard and re-run:\n"
82
+ " quantglide-ibkr --token <token>"
83
+ )
84
+ raise RuntimeError(f"Credential exchange failed ({e.code}): {body}")
85
+ except urllib.error.URLError as e:
86
+ raise RuntimeError(
87
+ f"Could not reach QuantGlide ({e.reason}).\n"
88
+ "Check your internet connection and try again."
89
+ )
90
+
91
+ required = {"supabase_url", "supabase_key", "refresh_token", "user_id"}
92
+ missing = required - payload.keys()
93
+ if missing:
94
+ raise RuntimeError(f"Incomplete response from exchange endpoint: missing {missing}")
95
+
96
+ logger.info(f"[AUTH] Credentials received user={payload['user_id']}")
97
+ return payload
98
+
99
+
100
+ # ── Supabase auth ─────────────────────────────────────────────────────────────
101
+
102
+ def get_supabase(creds: dict, logger: logging.Logger):
103
+ sb = create_client(creds["supabase_url"], creds["supabase_key"])
104
+ result = sb.auth.refresh_session(creds["refresh_token"])
105
+ if not result.session:
106
+ raise RuntimeError(
107
+ "Supabase session could not be established.\n"
108
+ "This is unusual — try re-running the agent. If it persists, "
109
+ "re-copy your token from the QuantGlide dashboard."
110
+ )
111
+ logger.info("[AUTH] Authenticated with QuantGlide")
112
+ return sb
113
+
114
+
115
+ # ── Active check ──────────────────────────────────────────────────────────────
116
+
117
+ def is_agent_active(sb, user_id: str, logger: logging.Logger) -> bool:
118
+ try:
119
+ resp = (
120
+ sb.table("trading_config")
121
+ .select("active")
122
+ .eq("user_id", user_id)
123
+ .execute()
124
+ )
125
+ if resp.data and not resp.data[0].get("active", True):
126
+ return False
127
+ except Exception as e:
128
+ logger.warning(f"[ACTIVE] Check failed, proceeding: {e}")
129
+ return True
130
+
131
+
132
+ # ── Order execution ───────────────────────────────────────────────────────────
133
+
134
+ def execute_signal(signal: dict, ibkr: IBKRGatewayAdapter, logger: logging.Logger) -> tuple[str, float | None]:
135
+ direction = signal["direction"]
136
+ is_put = direction == "bull_put"
137
+ short_strike = float(signal["short_strike"])
138
+ long_strike = float(signal["long_strike"])
139
+ qty = int(signal["qty"])
140
+ expiry_ibkr = signal["expiration_date"].replace("-", "")
141
+ limit_credit = signal.get("limit_credit")
142
+
143
+ logger.info(
144
+ f"[EXEC] {direction} {short_strike}/{long_strike} qty={qty} expiry={expiry_ibkr}"
145
+ + (f" hint_credit={limit_credit:.2f}" if limit_credit else "")
146
+ )
147
+
148
+ try:
149
+ trade = ibkr.place_credit_spread(
150
+ symbol="SPX",
151
+ expiration=expiry_ibkr,
152
+ short_strike=short_strike,
153
+ long_strike=long_strike,
154
+ is_put=is_put,
155
+ qty=qty,
156
+ limit_price=limit_credit,
157
+ min_credit=MIN_CREDIT,
158
+ )
159
+ except Exception as e:
160
+ logger.error(f"[EXEC] Error: {e}")
161
+ return "error", None
162
+
163
+ if trade and trade.orderStatus.status == "Filled":
164
+ fill_price = abs(trade.orderStatus.avgFillPrice)
165
+ logger.info(f"[EXEC] FILLED @ {fill_price:.2f}")
166
+ return "filled", fill_price
167
+
168
+ logger.info("[EXEC] Not filled")
169
+ return "not_filled", None
170
+
171
+
172
+ def update_signal(sb, signal_id: str, status: str, fill_price=None, error=None, logger=None):
173
+ update = {
174
+ "status": status,
175
+ "agent_result": status,
176
+ "agent_completed_at": datetime.now(timezone.utc).isoformat(),
177
+ }
178
+ if fill_price is not None:
179
+ update["agent_fill_price"] = fill_price
180
+ if error:
181
+ update["agent_error"] = error
182
+ try:
183
+ sb.table("trade_signals").update(update).eq("id", signal_id).execute()
184
+ except Exception as e:
185
+ if logger:
186
+ logger.error(f"[RELAY] Failed to update signal {signal_id}: {e}")
187
+
188
+
189
+ # ── Main loop ─────────────────────────────────────────────────────────────────
190
+
191
+ def run(env_path: str, no_time_gate: bool = False, dry_run: bool = False):
192
+ cfg = load_config(env_path)
193
+ logger = setup_logger()
194
+
195
+ if not cfg["QUANTGLIDE_TOKEN"]:
196
+ print("No setup token found. Run:")
197
+ print(" quantglide-ibkr --token <paste_token_from_dashboard>")
198
+ raise SystemExit(1)
199
+
200
+ # Exchange UUID for fresh credentials on every startup — nothing to rotate locally
201
+ creds = exchange_token(cfg["QUANTGLIDE_TOKEN"], logger)
202
+ user_id = creds["user_id"]
203
+
204
+ port = cfg["IBKR_PORT"]
205
+ mode = "paper" if port == 4002 else "live"
206
+ logger.info(f"[BOOT] QuantGlide IBKR Agent user={user_id} gateway={cfg['IBKR_HOST']}:{port} ({mode})")
207
+ if no_time_gate:
208
+ logger.info("[BOOT] Time gate disabled")
209
+ if dry_run:
210
+ logger.info("[BOOT] Dry run — signals will be claimed but no orders placed")
211
+
212
+ sb = get_supabase(creds, logger)
213
+
214
+ logger.info("[DB] Checking Supabase connection...")
215
+ try:
216
+ sb.table("trade_signals").select("id").eq("target_user_id", user_id).limit(1).execute()
217
+ logger.info("[DB] Connected OK")
218
+ except Exception as e:
219
+ logger.warning(f"[DB] Could not read trade_signals: {e}")
220
+
221
+ ibkr = IBKRGatewayAdapter(
222
+ host=cfg["IBKR_HOST"],
223
+ port=port,
224
+ client_id=cfg["IBKR_CLIENT_ID"],
225
+ account=None,
226
+ )
227
+
228
+ logger.info(f"[GW] Connecting to IB Gateway at {cfg['IBKR_HOST']}:{port}...")
229
+ try:
230
+ ibkr.connect()
231
+ logger.info(f"[GW] Connected account={ibkr.acct_id}")
232
+ ibkr.disconnect()
233
+ except Exception as e:
234
+ logger.warning(
235
+ f"[GW] Could not reach IB Gateway — {e}\n"
236
+ f" Make sure IB Gateway is running with API connections enabled.\n"
237
+ f" The agent will retry when a signal arrives."
238
+ )
239
+
240
+ processed_today: set[str] = set()
241
+ last_date = None
242
+
243
+ while True:
244
+ now = datetime.now(EASTERN)
245
+ today = now.date()
246
+
247
+ if last_date != today:
248
+ processed_today.clear()
249
+ last_date = today
250
+ logger.info(f"[DAY] {today}")
251
+
252
+ if not no_time_gate:
253
+ h, m = now.hour, now.minute
254
+ if today.weekday() >= 5 or h < 10 or (h == 10 and m < 25) or h >= 13:
255
+ time.sleep(30 if h < 13 else 3600)
256
+ continue
257
+
258
+ try:
259
+ resp = (
260
+ sb.table("trade_signals")
261
+ .select("*")
262
+ .eq("status", "pending")
263
+ .eq("signal_date", str(today))
264
+ .eq("target_user_id", user_id)
265
+ .execute()
266
+ )
267
+ except Exception as e:
268
+ logger.error(f"[POLL] Query failed: {e}")
269
+ time.sleep(10)
270
+ continue
271
+
272
+ for signal in (resp.data or []):
273
+ sig_id = signal["id"]
274
+ if sig_id in processed_today:
275
+ continue
276
+
277
+ logger.info(f"[SIGNAL] {signal['direction']} {signal['short_strike']}/{signal['long_strike']} qty={signal['qty']}")
278
+
279
+ try:
280
+ claim = sb.table("trade_signals").update({
281
+ "status": "claimed",
282
+ "agent_claimed_at": datetime.now(timezone.utc).isoformat(),
283
+ }).eq("id", sig_id).eq("status", "pending").execute()
284
+ if not claim.data:
285
+ processed_today.add(sig_id)
286
+ continue
287
+ except Exception as e:
288
+ logger.error(f"[SIGNAL] Claim failed: {e}")
289
+ continue
290
+
291
+ processed_today.add(sig_id)
292
+
293
+ try:
294
+ check = sb.table("trade_signals").select("status").eq("id", sig_id).execute()
295
+ if check.data and check.data[0]["status"] == "cancelled":
296
+ logger.info(f"[SIGNAL] {sig_id} cancelled before execution")
297
+ continue
298
+ except Exception:
299
+ pass
300
+
301
+ if not is_agent_active(sb, user_id, logger):
302
+ logger.info("[SKIP] Bot is deactivated in dashboard")
303
+ update_signal(sb, sig_id, "skip", logger=logger)
304
+ continue
305
+
306
+ if dry_run:
307
+ logger.info(f"[DRY RUN] Would place {signal['direction']} {signal['short_strike']}/{signal['long_strike']} qty={signal['qty']}")
308
+ update_signal(sb, sig_id, "filled", fill_price=signal.get("limit_credit"), logger=logger)
309
+ continue
310
+
311
+ if not ibkr.connected:
312
+ try:
313
+ ibkr.connect()
314
+ except Exception as e:
315
+ logger.error(f"[IBKR] Connection failed: {e}")
316
+ update_signal(sb, sig_id, "error", error=str(e), logger=logger)
317
+ continue
318
+
319
+ result, fill_price = execute_signal(signal, ibkr, logger)
320
+ update_signal(sb, sig_id, result, fill_price=fill_price, logger=logger)
321
+
322
+ time.sleep(POLL_INTERVAL_S)
@@ -0,0 +1,108 @@
1
+ """
2
+ quantglide-ibkr CLI entry point.
3
+
4
+ First-time setup:
5
+ quantglide-ibkr --token <token_from_dashboard>
6
+
7
+ Subsequent runs:
8
+ quantglide-ibkr
9
+ quantglide-ibkr --port 4002 # switch to paper trading
10
+ quantglide-ibkr --dry-run # test without placing orders
11
+ """
12
+ import argparse
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ CONFIG_DIR = Path.home() / ".quantglide"
17
+ CONFIG_FILE = CONFIG_DIR / ".env"
18
+
19
+
20
+ def _read_config() -> dict:
21
+ from dotenv import dotenv_values
22
+ return dotenv_values(str(CONFIG_FILE))
23
+
24
+
25
+ def _write_config(data: dict):
26
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
27
+ lines = []
28
+ if CONFIG_FILE.exists():
29
+ with open(CONFIG_FILE) as f:
30
+ lines = f.readlines()
31
+ for key, value in data.items():
32
+ found = False
33
+ for i, line in enumerate(lines):
34
+ if line.startswith(f"{key}="):
35
+ lines[i] = f"{key}={value}\n"
36
+ found = True
37
+ break
38
+ if not found:
39
+ lines.append(f"{key}={value}\n")
40
+ with open(CONFIG_FILE, "w") as f:
41
+ f.writelines(lines)
42
+
43
+
44
+ def main():
45
+ parser = argparse.ArgumentParser(
46
+ prog="quantglide-ibkr",
47
+ description="QuantGlide IBKR relay agent",
48
+ formatter_class=argparse.RawDescriptionHelpFormatter,
49
+ epilog="""
50
+ Examples:
51
+ First-time setup:
52
+ quantglide-ibkr --token <paste_token_from_dashboard>
53
+
54
+ Start the agent:
55
+ quantglide-ibkr
56
+
57
+ Paper trading:
58
+ quantglide-ibkr --port 4002
59
+
60
+ Test without placing real orders:
61
+ quantglide-ibkr --dry-run
62
+ """,
63
+ )
64
+ parser.add_argument("--token", metavar="TOKEN",
65
+ help="Setup token from the QuantGlide dashboard (first-time only)")
66
+ parser.add_argument("--port", type=int, choices=[4001, 4002],
67
+ help="IB Gateway port: 4001=live (default), 4002=paper")
68
+ parser.add_argument("--no-time-gate", action="store_true",
69
+ help="Skip trading hours check (useful for testing)")
70
+ parser.add_argument("--dry-run", action="store_true",
71
+ help="Claim signals but don't place real orders")
72
+ args = parser.parse_args()
73
+
74
+ # ── First-time setup: just save the token UUID ────────────────────────────
75
+ if args.token:
76
+ _write_config({
77
+ "QUANTGLIDE_TOKEN": args.token.strip(),
78
+ "IBKR_HOST": "127.0.0.1",
79
+ "IBKR_PORT": str(args.port or 4001),
80
+ "IBKR_CLIENT_ID": "10",
81
+ })
82
+ print(f"Setup complete. Config saved to {CONFIG_FILE}")
83
+ print()
84
+ print("Make sure IB Gateway is running, then start the agent:")
85
+ print(" quantglide-ibkr")
86
+ return
87
+
88
+ # ── Config must exist ─────────────────────────────────────────────────────
89
+ if not CONFIG_FILE.exists():
90
+ print("No config found. Run setup first:")
91
+ print()
92
+ print(" quantglide-ibkr --token <paste_token_from_dashboard>")
93
+ print()
94
+ print("Get your token from the QuantGlide dashboard under Settings → IBKR.")
95
+ sys.exit(1)
96
+
97
+ # ── Apply port override ───────────────────────────────────────────────────
98
+ if args.port:
99
+ _write_config({"IBKR_PORT": str(args.port)})
100
+ print(f"Port set to {args.port} ({'paper' if args.port == 4002 else 'live'} trading)")
101
+
102
+ # ── Launch agent ──────────────────────────────────────────────────────────
103
+ from quantglide_ibkr.agent import run
104
+ run(
105
+ env_path=str(CONFIG_FILE),
106
+ no_time_gate=args.no_time_gate,
107
+ dry_run=args.dry_run,
108
+ )