mirror of
https://github.com/cjudice-commits/prediction-market-arb.git
synced 2026-07-27 13:37:46 +00:00
dfdfe8414d
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>
244 lines
9.3 KiB
Python
244 lines
9.3 KiB
Python
"""IBKR ForecastEx daily BTC × Kalshi KXBTCD hourly cross-reference.
|
||
|
||
Pairing model: each IBKR ForecastEx daily BTC contract settles at a specific
|
||
clock-time on a specific date. Kalshi's hourly KXBTCD ladder has a contract
|
||
closing at exactly that same instant. Same strike + same close = same product
|
||
across two venues, so the existing same-strike arb math applies.
|
||
|
||
The user supplies IBKR contract conids in data/ibkr_contracts.json (one-time;
|
||
they grab them from the IBKR UI after the Client Portal Gateway is auth'd).
|
||
Each entry: {conid, strike (dollars), close_iso (UTC), label}. We then:
|
||
|
||
1. Snapshot prices for all configured IBKR conids via the local Gateway.
|
||
2. For each entry, derive the Kalshi event ticker from close_iso (ET hour),
|
||
fetch the event's strike ladder, pick the market with the nearest strike.
|
||
3. Run the standard worst-case arb math (arb.calc.evaluate) treating IBKR
|
||
as the "second venue" (drop into the pq slot).
|
||
|
||
ForecastEx contract semantics (assumed; verify on first Gateway test):
|
||
- One conid per "above $X" daily contract. Buying = long YES.
|
||
- yes_ask = snapshot.ask (price to BUY YES = go long).
|
||
- no_ask = 1 - snapshot.bid (price to "buy NO" = sell/short; you'd
|
||
receive `bid` selling so net cost to take the NO side is 1-bid).
|
||
- If this representation turns out wrong, only this file changes.
|
||
"""
|
||
import json
|
||
import time
|
||
from datetime import datetime, timezone, timedelta
|
||
from pathlib import Path
|
||
|
||
from .net import get_json, FetchError
|
||
from . import ibkr
|
||
from .calc import evaluate
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
IBKR_CONTRACTS = ROOT / "data" / "ibkr_contracts.json"
|
||
KALSHI = "https://api.elections.kalshi.com/trade-api/v2"
|
||
|
||
try:
|
||
from zoneinfo import ZoneInfo
|
||
_ET = ZoneInfo("America/New_York")
|
||
except Exception: # no tzdata -> EDT fallback (Mar–Nov)
|
||
_ET = timezone(timedelta(hours=-4))
|
||
|
||
_MON = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN",
|
||
"JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]
|
||
|
||
|
||
def _event_ticker_for(close_iso, series="KXBTCD"):
|
||
"""'2026-05-23T21:00:00Z' -> 'KXBTCD-26MAY2317' (ET-hour encoding)."""
|
||
if not close_iso:
|
||
return None
|
||
try:
|
||
dt = datetime.fromisoformat(close_iso.replace("Z", "+00:00"))
|
||
except ValueError:
|
||
return None
|
||
et = dt.astimezone(_ET)
|
||
return "%s-%s%s%02d%02d" % (
|
||
series, str(et.year)[-2:], _MON[et.month - 1], et.day, et.hour)
|
||
|
||
|
||
def _kalshi_ladder(event_ticker):
|
||
try:
|
||
ev = get_json("%s/events/%s?with_nested_markets=true" %
|
||
(KALSHI, event_ticker))
|
||
except FetchError:
|
||
return []
|
||
return (ev.get("event") or {}).get("markets") or []
|
||
|
||
|
||
def _pick_kalshi(ladder, target_strike):
|
||
"""Pick the 'or above' market whose numeric strike is nearest target."""
|
||
best = None
|
||
for m in ladder:
|
||
sub = (m.get("yes_sub_title") or "").lower()
|
||
if "or above" not in sub:
|
||
continue
|
||
t = (m.get("ticker") or "").rsplit("-T", 1)
|
||
if len(t) != 2:
|
||
continue
|
||
try:
|
||
ks = float(t[1])
|
||
except ValueError:
|
||
continue
|
||
d = abs(ks - target_strike)
|
||
if best is None or d < best[0]:
|
||
best = (d, ks, m)
|
||
return best # (dist, strike, market) | None
|
||
|
||
|
||
def _kq_from_market(m):
|
||
"""Adapt a Kalshi market dict to the kq shape calc.evaluate expects."""
|
||
def f(v):
|
||
try:
|
||
x = float(v)
|
||
return x if x > 0 else None
|
||
except (TypeError, ValueError):
|
||
return None
|
||
return {
|
||
"ticker": m.get("ticker"),
|
||
"yes_ask": f(m.get("yes_ask_dollars")),
|
||
"no_ask": f(m.get("no_ask_dollars")),
|
||
"yes_bid": f(m.get("yes_bid_dollars")),
|
||
"no_bid": f(m.get("no_bid_dollars")),
|
||
"yes_ask_size": None, "no_ask_size": None,
|
||
"open_interest": f(m.get("open_interest_fp")) or 0.0,
|
||
"status": m.get("status"),
|
||
"expiry": (m.get("close_time") or "")[:10],
|
||
"title": m.get("title"),
|
||
"yes_label": m.get("yes_sub_title"),
|
||
"no_label": m.get("no_sub_title"),
|
||
"rules": (m.get("rules_primary") or "").strip()[:360],
|
||
}
|
||
|
||
|
||
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": 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,
|
||
}
|
||
|
||
|
||
def _load_cfg():
|
||
"""Returns (cfg_dict, error_str_or_None)."""
|
||
if not IBKR_CONTRACTS.exists():
|
||
return None, "no_contracts_file"
|
||
try:
|
||
return json.loads(IBKR_CONTRACTS.read_text()), None
|
||
except ValueError as e:
|
||
return None, "bad_json: %s" % e
|
||
except OSError as e:
|
||
return None, "read_error: %s" % e
|
||
|
||
|
||
def run(settings):
|
||
cfg, err = _load_cfg()
|
||
if err or not cfg or not cfg.get("contracts"):
|
||
return {"configured": False,
|
||
"reason": err or "no_contracts_file",
|
||
"rows": [], "gateway": {"connected": False}}
|
||
|
||
auth = ibkr.auth_status()
|
||
if not auth.get("connected"):
|
||
return {"configured": False, "reason": "gateway_not_running",
|
||
"rows": [], "gateway": auth}
|
||
if not auth.get("authenticated"):
|
||
return {"configured": True, "error": "Gateway up but session not "
|
||
"authenticated — re-login via the IBKR browser SSO page.",
|
||
"rows": [], "gateway": auth}
|
||
ibkr.tickle() # extend session
|
||
|
||
# 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"]:
|
||
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 %s" % yes_cid)
|
||
if not (yes_cid and no_cid and strike and close_iso):
|
||
continue
|
||
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 []
|
||
pick = _pick_kalshi(ladder, float(strike))
|
||
if not pick:
|
||
rows.append({
|
||
"asset": entry.get("asset", "BTC"),
|
||
"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_yes_ask": ysnap.get("ask"), "ibkr_no_ask": nsnap.get("ask"),
|
||
"status": "NO KALSHI",
|
||
"note": "no matching Kalshi KXBTCD event/strike",
|
||
})
|
||
continue
|
||
|
||
dist, kstrike, kmkt = pick
|
||
kq = _kq_from_market(kmkt)
|
||
pq = _pq_from_ibkr(ysnap, nsnap, label)
|
||
pair = {
|
||
"asset": entry.get("asset", "BTC"),
|
||
"kalshi_ticker": kmkt.get("ticker"),
|
||
"kalshi_strike": kstrike,
|
||
"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_yes"] = yes_cid
|
||
row["ibkr_conid_no"] = no_cid
|
||
row["ibkr_label"] = label
|
||
row["ibkr_strike"] = float(strike)
|
||
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)
|
||
|
||
summary = {"total": len(rows), "ARB": sum(1 for r in rows if r.get("status") == "ARB"),
|
||
"NO ARB": sum(1 for r in rows if r.get("status") == "NO ARB"),
|
||
"BAD BASIS": sum(1 for r in rows if r.get("status") == "BAD BASIS"),
|
||
"LOW SIZE": sum(1 for r in rows if r.get("status") == "LOW SIZE"),
|
||
"NO DATA": sum(1 for r in rows if r.get("status") == "NO DATA"),
|
||
"NO KALSHI": sum(1 for r in rows if r.get("status") == "NO KALSHI")}
|
||
return {"configured": True, "rows": rows, "summary": summary,
|
||
"gateway": auth,
|
||
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S",
|
||
time.localtime())}
|