Files
Kalshi_Polymarket/arb/ibkr.py
T
Casey Judice b1f18ca9f1 IBKR × Kalshi daily-BTC scanner (local-only)
- arb/ibkr.py: Client Portal Gateway client (localhost:5000 over self-
  signed SSL; auth_status, tickle, snapshot, secdef/search). Distinct
  NotConnected vs NotAuthed exceptions for graceful UI fallbacks.
- arb/daily.py: pairs each user-configured IBKR ForecastEx daily-BTC
  contract against the Kalshi KXBTCD market closing at the same UTC
  instant with nearest strike. Reuses arb.calc.evaluate for the worst-
  case arb math; IBKR slots into the 'pq' position. NO KALSHI status
  when no matching Kalshi market exists.
- data/ibkr_contracts.example.json: template (real file gitignored).
- /api/scan/daily route + 'IBKR × Kalshi' tab in the local UI. Tab
  shows a setup banner explaining what to do when Gateway/contracts
  aren't configured (vs crashing).
- Public Pages dashboard unchanged (positions + IBKR are local-only).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 16:16:53 -04:00

126 lines
4.4 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 (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():
"""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