mirror of
https://github.com/cjudice-commits/prediction-market-arb.git
synced 2026-07-27 21:47: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>
127 lines
4.5 KiB
Python
127 lines
4.5 KiB
Python
"""Interactive Brokers Client Portal Gateway client.
|
|
|
|
The Gateway is a Java app you run locally (`bin/run.sh root/conf.yaml` in the
|
|
clientportal.gw distribution). It listens on https://localhost:5000 with a
|
|
self-signed cert; we ignore cert verification because we're talking to
|
|
localhost. Authentication is interactive (SSO via the user's browser, every
|
|
few hours) — the Gateway maintains the session for us; we just call endpoints.
|
|
|
|
Untestable in this environment until the user installs/auths the Gateway.
|
|
Designed to fail loud-but-clear (graceful "not configured" / "not authed")
|
|
so the UI can render a setup banner rather than crashing.
|
|
|
|
Reference: https://www.interactivebrokers.com/campus/ibkr-api-page/cpapi-v1/
|
|
Event-contract specifics: ForecastEx contracts are modeled as options;
|
|
snapshot fields use numeric IDs (31=Last, 84=Bid, 86=Ask, 7295=close, etc.).
|
|
"""
|
|
import json
|
|
import ssl
|
|
import urllib.request
|
|
import urllib.error
|
|
|
|
GATEWAY = "https://localhost:5000/v1/api"
|
|
|
|
# Numeric field IDs for marketdata/snapshot. These are the standard IB codes.
|
|
F_LAST = "31"
|
|
F_BID = "84"
|
|
F_ASK = "86"
|
|
F_BID_SZ = "88"
|
|
F_ASK_SZ = "85"
|
|
|
|
# Self-signed cert on localhost: don't verify (we're talking to our own box).
|
|
_CTX = ssl.create_default_context()
|
|
_CTX.check_hostname = False
|
|
_CTX.verify_mode = ssl.CERT_NONE
|
|
|
|
|
|
class NotConnected(Exception):
|
|
"""Gateway isn't running on localhost:5000."""
|
|
|
|
|
|
class NotAuthed(Exception):
|
|
"""Gateway is up but the user's SSO session isn't active."""
|
|
|
|
|
|
def _req(method, path, body=None, timeout=8):
|
|
url = GATEWAY + path
|
|
data = json.dumps(body).encode() if body is not None else None
|
|
headers = {"Accept": "application/json", "User-Agent": "arb-scanner"}
|
|
if data:
|
|
headers["Content-Type"] = "application/json"
|
|
rq = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
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))
|
|
|
|
|
|
def auth_status():
|
|
"""Returns dict {connected, authenticated, competing, message}."""
|
|
try:
|
|
s = _req("GET", "/iserver/auth/status") or {}
|
|
return {
|
|
"connected": True,
|
|
"authenticated": bool(s.get("authenticated")),
|
|
"competing": bool(s.get("competing")),
|
|
"message": s.get("message"),
|
|
}
|
|
except NotConnected as e:
|
|
return {"connected": False, "authenticated": False, "message": str(e)}
|
|
except NotAuthed as e:
|
|
return {"connected": True, "authenticated": False, "message": str(e)}
|
|
|
|
|
|
def tickle():
|
|
"""Pings the Gateway to keep the session alive (~1 hour idle timeout)."""
|
|
try:
|
|
_req("POST", "/tickle", body={})
|
|
return True
|
|
except (NotConnected, NotAuthed, urllib.error.HTTPError):
|
|
return False
|
|
|
|
|
|
def snapshot(conids, fields=None):
|
|
"""conids: iterable of int/str. Returns {conid_str: {bid, ask, last, ...}}.
|
|
First call to /marketdata/snapshot often returns partial data; IB warms up
|
|
its market-data subscription. Caller should retry once after ~1s."""
|
|
if not conids:
|
|
return {}
|
|
fields = fields or (F_LAST, F_BID, F_ASK, F_BID_SZ, F_ASK_SZ)
|
|
qs = "conids=%s&fields=%s" % (
|
|
",".join(str(c) for c in conids), ",".join(fields))
|
|
rows = _req("GET", "/iserver/marketdata/snapshot?" + qs) or []
|
|
out = {}
|
|
def fnum(v):
|
|
try:
|
|
return float(v)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
for r in rows:
|
|
cid = str(r.get("conid"))
|
|
out[cid] = {
|
|
"last": fnum(r.get(F_LAST)),
|
|
"bid": fnum(r.get(F_BID)),
|
|
"ask": fnum(r.get(F_ASK)),
|
|
"bid_size": fnum(r.get(F_BID_SZ)),
|
|
"ask_size": fnum(r.get(F_ASK_SZ)),
|
|
"raw": r,
|
|
}
|
|
return out
|
|
|
|
|
|
def search(symbol, sec_type=None):
|
|
"""Find conids by symbol (e.g. 'BTC'). Returns list of contract candidates.
|
|
For ForecastEx event contracts, secType may be 'OPT' (modeled as options)
|
|
or 'EVENT' depending on Gateway version — check the result."""
|
|
qs = "symbol=" + symbol
|
|
if sec_type:
|
|
qs += "&secType=" + sec_type
|
|
res = _req("GET", "/iserver/secdef/search?" + qs) or []
|
|
return res
|