mirror of
https://github.com/cjudice-commits/prediction-market-arb.git
synced 2026-07-27 21:47:46 +00:00
IBKR daily scanner: correct YES+NO conid schema + discover helper
Earlier ibkr.py assumed each strike = one conid (buy=Yes / sell=No). The IBKR ForecastEx CP API actually exposes each strike as TWO separate conids (YES = right=CALL, NO = right=PUT). Local symbol shape is CFBTC_MMDDYYHH_<strike>_<YES|NO> e.g. CFBTC_05242616_71000_YES means 4pm ET May 24 2026, $71,000 YES side. - data/ibkr_contracts.example.json: per entry now has yes_conid + no_conid + strike + close_iso + label (one row per strike). - arb/daily.py: snapshots both conids in one batch; yes_ask/no_ask come directly from each side's ask (no more 1-bid derivation). - scripts/discover_ibkr.py: walks /iserver/secdef/strikes + secdef/info to print the full YES/NO ladder for a given underlying + month + maturity. Filters by maturityDate so it doesn't mix expiries. End-to-end verified: 3 sample strikes pair cleanly to KXBTCD-26MAY2416 (IBKR $71K -> Kalshi T70999.99 etc.). Live prices still pending the user's ForecastEx market-data subscription / strikes closer to spot. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+42
-26
@@ -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)
|
||||
|
||||
+5
-4
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user