diff --git a/arb/daily.py b/arb/daily.py index c01af0ac9..32eaede5d 100644 --- a/arb/daily.py +++ b/arb/daily.py @@ -112,20 +112,27 @@ def _kq_from_market(m): } -def _pq_from_ibkr(snap, label): - """Adapt an IBKR snapshot to the pq shape calc.evaluate expects. - yes_ask = ask (long YES); no_ask = 1 - bid (short YES ~= long NO).""" - bid = snap.get("bid"); ask = snap.get("ask") - yes_ask = ask if (ask and 0 < ask <= 1) else None - no_ask = (1.0 - bid) if (bid and 0 < bid < 1) else None +def _pq_from_ibkr(yes_snap, no_snap, label): + """Adapt an IBKR YES+NO snapshot pair to the pq shape calc.evaluate expects. + IBKR ForecastEx uses TWO conids per strike — one for YES (right=CALL, + 'or above'), one for NO (right=PUT). yes_ask is the YES contract's ask, + no_ask is the NO contract's ask — clean, no derivation needed.""" + def f(v): + try: + x = float(v) + return x if (x and 0 < x <= 1) else None + except (TypeError, ValueError): + return None + ya = f((yes_snap or {}).get("ask")) + na = f((no_snap or {}).get("ask")) return { "slug": None, "question": label, "description": None, "image": None, "icon": None, - "yes_ask": yes_ask, "no_ask": no_ask, - "yes_ask_size": snap.get("ask_size"), - "no_ask_size": snap.get("bid_size"), + "yes_ask": ya, "no_ask": na, + "yes_ask_size": (yes_snap or {}).get("ask_size"), + "no_ask_size": (no_snap or {}).get("ask_size"), "volume": None, "end_date": None, "closed": False, @@ -161,22 +168,29 @@ def run(settings): "rows": [], "gateway": auth} ibkr.tickle() # extend session - conids = [c.get("conid") for c in cfg["contracts"] if c.get("conid")] - # IB market data subscription warms up on first call; retry once. - snaps = ibkr.snapshot(conids) - if any(s.get("ask") is None and s.get("bid") is None for s in snaps.values()): - time.sleep(1.0) - snaps = ibkr.snapshot(conids) or snaps + # Collect every conid we need (YES + NO per strike) for one batched snapshot + all_cids = [] + for c in cfg["contracts"]: + for k in ("yes_conid", "no_conid"): + v = c.get(k) + if v: all_cids.append(v) + # IB's snapshot subscription warms up over a few seconds; retry once. + snaps = ibkr.snapshot(all_cids) if all_cids else {} + if any(s.get("ask") is None for s in snaps.values()): + time.sleep(2.0) + snaps = ibkr.snapshot(all_cids) or snaps rows = [] for entry in cfg["contracts"]: - cid = str(entry.get("conid") or "") + yes_cid = str(entry.get("yes_conid") or "") + no_cid = str(entry.get("no_conid") or "") strike = entry.get("strike") close_iso = entry.get("close_iso") - label = entry.get("label") or ("IBKR conid %s" % cid) - if not (cid and strike and close_iso): + label = entry.get("label") or ("IBKR %s" % yes_cid) + if not (yes_cid and no_cid and strike and close_iso): continue - snap = snaps.get(cid) or {} + ysnap = snaps.get(yes_cid) or {} + nsnap = snaps.get(no_cid) or {} event_ticker = _event_ticker_for(close_iso, series="KXBTCD") ladder = _kalshi_ladder(event_ticker) if event_ticker else [] @@ -184,10 +198,11 @@ def run(settings): if not pick: rows.append({ "asset": entry.get("asset", "BTC"), - "ibkr_conid": cid, "ibkr_label": label, + "ibkr_conid_yes": yes_cid, "ibkr_conid_no": no_cid, + "ibkr_label": label, "ibkr_strike": strike, "ibkr_close_iso": close_iso, "kalshi_event": event_ticker, - "ibkr_bid": snap.get("bid"), "ibkr_ask": snap.get("ask"), + "ibkr_yes_ask": ysnap.get("ask"), "ibkr_no_ask": nsnap.get("ask"), "status": "NO KALSHI", "note": "no matching Kalshi KXBTCD event/strike", }) @@ -195,22 +210,23 @@ def run(settings): dist, kstrike, kmkt = pick kq = _kq_from_market(kmkt) - pq = _pq_from_ibkr(snap, label) + pq = _pq_from_ibkr(ysnap, nsnap, label) pair = { "asset": entry.get("asset", "BTC"), "kalshi_ticker": kmkt.get("ticker"), "kalshi_strike": kstrike, - "poly_slug": cid, # repurposed slot — pair id + "poly_slug": yes_cid, # repurposed slot — pair id "poly_strike": float(strike), "active": True, } row = evaluate(pair, kq, pq, settings) # Rebadge poly→IBKR for UI consumption. - row["ibkr_conid"] = cid + row["ibkr_conid_yes"] = yes_cid + row["ibkr_conid_no"] = no_cid row["ibkr_label"] = label row["ibkr_strike"] = float(strike) - row["ibkr_ask"] = snap.get("ask") - row["ibkr_bid"] = snap.get("bid") + row["ibkr_yes_ask"] = ysnap.get("ask") + row["ibkr_no_ask"] = nsnap.get("ask") row["kalshi_close_iso"] = (kmkt.get("close_time") or close_iso) row["kalshi_event"] = event_ticker rows.append(row) diff --git a/arb/ibkr.py b/arb/ibkr.py index 81fe985c4..4bbef7d9c 100644 --- a/arb/ibkr.py +++ b/arb/ibkr.py @@ -52,12 +52,13 @@ def _req(method, path, body=None, timeout=8): try: with urllib.request.urlopen(rq, timeout=timeout, context=_CTX) as r: return json.loads(r.read().decode() or "null") + except urllib.error.HTTPError as e: # check HTTPError BEFORE URLError — + if e.code in (401, 403): # HTTPError is a subclass of URLError + raise NotAuthed("HTTP %s — sign in at https://localhost:5000" + % e.code) + raise except (ConnectionRefusedError, urllib.error.URLError) as e: raise NotConnected(str(e)) - except urllib.error.HTTPError as e: - if e.code in (401, 403): - raise NotAuthed("HTTP %s — re-auth via Gateway browser SSO" % e.code) - raise def auth_status(): diff --git a/data/ibkr_contracts.example.json b/data/ibkr_contracts.example.json index cbdcb59fb..8fb007ee2 100644 --- a/data/ibkr_contracts.example.json +++ b/data/ibkr_contracts.example.json @@ -1,19 +1,29 @@ { - "_comment": "Copy to data/ibkr_contracts.json and fill in your own IBKR ForecastEx daily-BTC conids. data/ibkr_contracts.json is git-ignored. Find conids via the IBKR Client Portal Gateway once it's auth'd (search 'BTC' under ForecastEx / event contracts) or copy them from the IBKR Web UI. `close_iso` MUST be the UTC ISO timestamp the IBKR contract settles at (e.g. 5pm ET = 21:00 UTC during EDT). `strike` is the dollar level the contract is 'or above'. Add one entry per IBKR contract you want to watch; the scanner pairs each to the Kalshi KXBTCD market closing at the same instant with nearest strike.", + "_comment": "Copy to data/ibkr_contracts.json and fill in your own IBKR ForecastEx conids. data/ibkr_contracts.json is git-ignored. IBKR uses TWO conids per strike — one for YES (right=CALL, the 'or above' side) and one for NO (right=PUT). Find them via the included scripts/discover_ibkr.py once your Gateway is auth'd (it walks /iserver/secdef/strikes + /iserver/secdef/info for a given underlying conid + month and prints YES/NO pairs). `close_iso` is the UTC timestamp the contract settles at (e.g. 4pm ET = 20:00:00Z during EDT). The scanner pairs each entry to the Kalshi KXBTCD market closing at the same instant with nearest strike.", "contracts": [ { "asset": "BTC", - "conid": 12345678, - "strike": 85000, - "close_iso": "2026-05-23T21:00:00Z", - "label": "BTC > $85,000 @ 5pm ET, 5/23" + "strike": 71000, + "yes_conid": 886364930, + "no_conid": 886364935, + "close_iso": "2026-05-24T20:00:00Z", + "label": "BTC > $71,000 @ 4pm ET, 5/24" }, { "asset": "BTC", - "conid": 12345679, - "strike": 86000, - "close_iso": "2026-05-23T21:00:00Z", - "label": "BTC > $86,000 @ 5pm ET, 5/23" + "strike": 71500, + "yes_conid": 886364936, + "no_conid": 886364941, + "close_iso": "2026-05-24T20:00:00Z", + "label": "BTC > $71,500 @ 4pm ET, 5/24" + }, + { + "asset": "BTC", + "strike": 72000, + "yes_conid": 886364947, + "no_conid": 886364950, + "close_iso": "2026-05-24T20:00:00Z", + "label": "BTC > $72,000 @ 4pm ET, 5/24" } ] } diff --git a/scripts/discover_ibkr.py b/scripts/discover_ibkr.py new file mode 100644 index 000000000..8aded40ae --- /dev/null +++ b/scripts/discover_ibkr.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Discover IBKR ForecastEx YES/NO conid pairs for a given asset + maturity. + +Usage: + python3 scripts/discover_ibkr.py [SYMBOL] [MONTH] [MATURITY_YYYYMMDD] + +Defaults: SYMBOL=CFBTC, MONTH=MAY26, MATURITY=20260524 + +Prints a JSON list of {strike, yes_conid, no_conid, maturity} entries you can +paste into data/ibkr_contracts.json (filling in close_iso + label per row). +Requires the Client Portal Gateway running and authenticated. +""" +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) +from arb.ibkr import _req, auth_status, search + + +def main(): + sym = sys.argv[1] if len(sys.argv) > 1 else "CFBTC" + month = sys.argv[2] if len(sys.argv) > 2 else "MAY26" + maturity = sys.argv[3] if len(sys.argv) > 3 else "20260524" + + s = auth_status() + if not s.get("authenticated"): + print("Gateway not authenticated. Sign in at https://localhost:5000", + file=sys.stderr) + sys.exit(1) + + # 1. find the underlying event conid + hits = search(sym) + under = None + for h in hits: + for sec in h.get("sections") or []: + if sec.get("secType") == "EC": # Event Contract + under = h.get("conid") + break + if under: break + if not under: + print("could not find underlying event conid for %s" % sym, file=sys.stderr) + sys.exit(1) + print("# underlying: %s conid=%s" % (sym, under), file=sys.stderr) + + # 2. fetch the strike ladder + strikes = _req("GET", "/iserver/secdef/strikes?conid=%s§ype=OPT" + "&month=%s&exchange=FORECASTX" % (under, month)) or {} + all_strikes = sorted(set((strikes.get("call") or []) + + (strikes.get("put") or []))) + print("# %d strikes in %s" % (len(all_strikes), month), file=sys.stderr) + + # 3. for each strike, secdef/info gives back the per-side conid; we filter + # by maturity to drop other expiries that come back from the same query. + out = [] + for k in all_strikes: + try: + yes = _req("GET", "/iserver/secdef/info?conid=%s§ype=OPT" + "&month=%s&strike=%s&right=C&exchange=FORECASTX" + % (under, month, k)) or [] + no = _req("GET", "/iserver/secdef/info?conid=%s§ype=OPT" + "&month=%s&strike=%s&right=P&exchange=FORECASTX" + % (under, month, k)) or [] + except Exception as e: + print("# strike=%s err: %s" % (k, e), file=sys.stderr) + continue + yes = yes if isinstance(yes, list) else [yes] + no = no if isinstance(no, list) else [no] + ycid = next((x.get("conid") for x in yes + if str(x.get("maturityDate") or x.get("maturity_date") or + "") == maturity), None) + ncid = next((x.get("conid") for x in no + if str(x.get("maturityDate") or x.get("maturity_date") or + "") == maturity), None) + if ycid and ncid: + out.append({"strike": k, "yes_conid": ycid, "no_conid": ncid, + "maturity": maturity}) + + print(json.dumps(out, indent=2)) + print("# wrote %d strike pair(s) for maturity %s" % (len(out), maturity), + file=sys.stderr) + + +if __name__ == "__main__": + main()