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.
- quantglide_ibkr-1.0.0/.gitignore +7 -0
- quantglide_ibkr-1.0.0/PKG-INFO +59 -0
- quantglide_ibkr-1.0.0/README.md +48 -0
- quantglide_ibkr-1.0.0/pyproject.toml +22 -0
- quantglide_ibkr-1.0.0/src/quantglide_ibkr/__init__.py +1 -0
- quantglide_ibkr-1.0.0/src/quantglide_ibkr/adapter.py +370 -0
- quantglide_ibkr-1.0.0/src/quantglide_ibkr/agent.py +322 -0
- quantglide_ibkr-1.0.0/src/quantglide_ibkr/cli.py +108 -0
|
@@ -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
|
+
)
|