Files
Casey Judice dfdfe8414d 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>
2026-05-24 10:19:26 -04:00

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