mirror of
https://github.com/cjudice-commits/prediction-market-arb.git
synced 2026-07-27 13:37:46 +00:00
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>
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
# Secrets — never commit. Provide your own data/secrets.json locally.
|
||||
data/secrets.json
|
||||
|
||||
# IBKR contract watchlist (user-editable; conids/dates change frequently)
|
||||
data/ibkr_contracts.json
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
"""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(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
|
||||
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"),
|
||||
"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
|
||||
|
||||
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
|
||||
|
||||
rows = []
|
||||
for entry in cfg["contracts"]:
|
||||
cid = str(entry.get("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):
|
||||
continue
|
||||
snap = snaps.get(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": 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"),
|
||||
"status": "NO KALSHI",
|
||||
"note": "no matching Kalshi KXBTCD event/strike",
|
||||
})
|
||||
continue
|
||||
|
||||
dist, kstrike, kmkt = pick
|
||||
kq = _kq_from_market(kmkt)
|
||||
pq = _pq_from_ibkr(snap, label)
|
||||
pair = {
|
||||
"asset": entry.get("asset", "BTC"),
|
||||
"kalshi_ticker": kmkt.get("ticker"),
|
||||
"kalshi_strike": kstrike,
|
||||
"poly_slug": 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_label"] = label
|
||||
row["ibkr_strike"] = float(strike)
|
||||
row["ibkr_ask"] = snap.get("ask")
|
||||
row["ibkr_bid"] = snap.get("bid")
|
||||
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())}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
"""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
|
||||
+17
@@ -16,6 +16,9 @@ _HLOCK = threading.Lock()
|
||||
_PCACHE = {"ts": 0.0, "payload": None}
|
||||
_PLOCK = threading.Lock()
|
||||
_POS_TTL = 12
|
||||
_DCACHE = {"ts": 0.0, "payload": None}
|
||||
_DLOCK = threading.Lock()
|
||||
_DAILY_TTL = 6
|
||||
_CACHE_TTL = 4 # seconds; just enough to dedupe rapid refreshes
|
||||
|
||||
|
||||
@@ -146,3 +149,17 @@ def run_positions(force=False):
|
||||
_PCACHE["ts"] = now
|
||||
_PCACHE["payload"] = payload
|
||||
return payload
|
||||
|
||||
|
||||
def run_daily(force=False):
|
||||
"""IBKR × Kalshi daily-BTC cross-reference. Cached for _DAILY_TTL."""
|
||||
from . import daily
|
||||
with _DLOCK:
|
||||
now = time.time()
|
||||
if (not force) and _DCACHE["payload"] and \
|
||||
(now - _DCACHE["ts"] < _DAILY_TTL):
|
||||
return _DCACHE["payload"]
|
||||
payload = daily.run(load_settings())
|
||||
_DCACHE["ts"] = now
|
||||
_DCACHE["payload"] = payload
|
||||
return payload
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"_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.",
|
||||
"contracts": [
|
||||
{
|
||||
"asset": "BTC",
|
||||
"conid": 12345678,
|
||||
"strike": 85000,
|
||||
"close_iso": "2026-05-23T21:00:00Z",
|
||||
"label": "BTC > $85,000 @ 5pm ET, 5/23"
|
||||
},
|
||||
{
|
||||
"asset": "BTC",
|
||||
"conid": 12345679,
|
||||
"strike": 86000,
|
||||
"close_iso": "2026-05-23T21:00:00Z",
|
||||
"label": "BTC > $86,000 @ 5pm ET, 5/23"
|
||||
}
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"kalshi_fee_rate": 0.07,
|
||||
"poly_fee_rate": 0.072,
|
||||
"min_net_return": 0.002,
|
||||
"min_net_return": 0.001,
|
||||
"min_poly_volume": 100.0,
|
||||
"min_contracts": 1.0
|
||||
}
|
||||
@@ -55,9 +55,10 @@ class Handler(BaseHTTPRequestHandler):
|
||||
u = urlparse(self.path)
|
||||
if u.path in _STATIC:
|
||||
return self._static(u.path)
|
||||
if u.path in ("/api/scan", "/api/scan/hourly"):
|
||||
if u.path in ("/api/scan", "/api/scan/hourly", "/api/scan/daily"):
|
||||
force = parse_qs(u.query).get("force", ["0"])[0] == "1"
|
||||
runner = (scan.run_hourly if u.path.endswith("hourly")
|
||||
runner = (scan.run_daily if u.path.endswith("daily")
|
||||
else scan.run_hourly if u.path.endswith("hourly")
|
||||
else scan.run_scan)
|
||||
try:
|
||||
return self._send(200, runner(force=force))
|
||||
|
||||
+85
-9
@@ -34,10 +34,25 @@ const COLS_HOURLY = [
|
||||
{ k: "poly_volume", t: "P Vol", f: "money" },
|
||||
{ k: "status", t: "Status", align: "l", f: "status" },
|
||||
];
|
||||
const COLS_DAILY = [
|
||||
{ k: "_mkt", t: "Market", align: "l", sort: "ibkr_label" },
|
||||
{ k: "ibkr_strike", t: "IBKR Strike",f: "strike" },
|
||||
{ k: "kalshi_strike", t: "K Strike", f: "strike" },
|
||||
{ k: "basis_pct", t: "Basis", f: "pct" },
|
||||
{ k: "best_side", t: "Side", align: "l" },
|
||||
{ k: "_px", t: "K / I px", align: "l", sort: "combined_cost" },
|
||||
{ k: "combined_cost", t: "Comb $", f: "px" },
|
||||
{ k: "worst_pnl", t: "Min $/ct", f: "s4" },
|
||||
{ k: "net_return", t: "Net Ret", f: "pctBig" },
|
||||
{ k: "_settle", t: "Settle", align: "l" },
|
||||
{ k: "status", t: "Status", align: "l", f: "status" },
|
||||
];
|
||||
|
||||
let MODE = "monthly", POS_VIEW = "venue";
|
||||
const COLS = () => (MODE === "hourly" ? COLS_HOURLY : COLS_MONTHLY);
|
||||
const COLS = () => MODE === "daily" ? COLS_DAILY
|
||||
: MODE === "hourly" ? COLS_HOURLY : COLS_MONTHLY;
|
||||
const endpoint = () => MODE === "positions" ? "/api/positions"
|
||||
: MODE === "daily" ? "/api/scan/daily"
|
||||
: MODE === "hourly" ? "/api/scan/hourly" : "/api/scan";
|
||||
|
||||
let RAW = [], sortKey = "net_return", sortAsc = false, openKey = null;
|
||||
@@ -84,26 +99,39 @@ function thumb(r, sz) {
|
||||
}
|
||||
|
||||
function cellMarket(r) {
|
||||
const title = r.kalshi_title || r.poly_question ||
|
||||
const title = r.ibkr_label || r.kalshi_title || r.poly_question ||
|
||||
`${r.asset} ${r.direction} ${r.kalshi_strike ?? ""}`;
|
||||
const strikes = `K $${r.kalshi_strike ?? "—"} · P $${r.poly_strike ?? "—"}`;
|
||||
const secondLabel = (MODE === "daily")
|
||||
? `K $${r.kalshi_strike ?? "—"} · I $${r.ibkr_strike ?? "—"}`
|
||||
: `K $${r.kalshi_strike ?? "—"} · P $${r.poly_strike ?? "—"}`;
|
||||
return `<div class="mkt">${thumb(r)}
|
||||
<div class="info">
|
||||
<div class="qa">
|
||||
<span class="achip" style="background:${ac(r.asset)}22;color:${ac(r.asset)}">${esc(r.asset || "?")}</span>
|
||||
<span class="q" title="${esc(title)}">${esc(title)}</span>
|
||||
</div>
|
||||
<div class="sub"><span class="dir">${esc(r.direction || "")}</span> ${esc(strikes)}</div>
|
||||
<div class="sub"><span class="dir">${esc(r.direction || "")}</span> ${esc(secondLabel)}</div>
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
function cellSettle(r) {
|
||||
const iso = r.kalshi_close_iso || r.ibkr_close_iso;
|
||||
if (!iso) return '<span class="dimv">—</span>';
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
const opts = { month: "short", day: "numeric", hour: "numeric" };
|
||||
return `<span class="mono">${d.toLocaleString(undefined, opts)}</span>`;
|
||||
} catch (e) { return esc(iso); }
|
||||
}
|
||||
|
||||
function cellPx(r) {
|
||||
const k = r.kalshi_price, p = r.poly_price, c = r.combined_cost;
|
||||
if (k == null || p == null) return '<span class="dimv">—</span>';
|
||||
const pct = Math.min(100, (c / 1) * 100);
|
||||
const col = c < 1 ? "var(--good)" : "var(--bad)";
|
||||
const otherL = MODE === "daily" ? "I" : "P";
|
||||
return `<div class="pricebar">
|
||||
<div class="lbl"><span>K ${k.toFixed(3)}</span><span>P ${p.toFixed(3)}</span></div>
|
||||
<div class="lbl"><span>K ${k.toFixed(3)}</span><span>${otherL} ${p.toFixed(3)}</span></div>
|
||||
<div class="track"><div class="fill" style="width:${pct}%;background:${col}"></div></div>
|
||||
<div class="lbl"><span class="dimv">cost vs $1</span><span class="${c < 1 ? "pos" : "neg"}">${c.toFixed(3)}</span></div>
|
||||
</div>`;
|
||||
@@ -243,10 +271,13 @@ function renderBody() {
|
||||
if (c.k === "_mkt") return `<td class="${cls}">${cellMarket(r)}</td>`;
|
||||
if (c.k === "_px") return `<td class="${cls}">${cellPx(r)}</td>`;
|
||||
if (c.k === "_window") return `<td class="${cls}">${cellWindow(r)}</td>`;
|
||||
if (c.k === "_settle") return `<td class="${cls}">${cellSettle(r)}</td>`;
|
||||
if (c.k === "best_side") {
|
||||
let bs = r.best_side || "—";
|
||||
if (MODE === "hourly" && bs !== "—")
|
||||
bs = bs === "YES+NO" ? "K-Yes · P-Down" : "K-No · P-Up";
|
||||
else if (MODE === "daily" && bs !== "—")
|
||||
bs = bs === "YES+NO" ? "K-Yes · I-No" : "K-No · I-Yes";
|
||||
return `<td class="${cls}"><span class="mono">${esc(bs)}</span></td>`;
|
||||
}
|
||||
return `<td class="${cls}">${fmt(r[c.k], c.f)}</td>`;
|
||||
@@ -267,6 +298,10 @@ function renderSummary(s) {
|
||||
["ARB", "Edges", "s-arb"], ["NO ARB", "No edge", ""],
|
||||
["BAD BASIS", "Bad basis", "s-bad"], ["NO DATA", "No data", ""],
|
||||
["total", "Assets", ""],
|
||||
] : MODE === "daily" ? [
|
||||
["ARB", "Live arbs", "s-arb"], ["NO ARB", "No edge", ""],
|
||||
["BAD BASIS", "Bad basis", "s-bad"], ["NO KALSHI", "No K match", ""],
|
||||
["total", "Contracts", ""],
|
||||
] : [
|
||||
["ARB", "Live arbs", "s-arb"], ["NO ARB", "No edge", ""],
|
||||
["BAD BASIS", "Bad basis", "s-bad"], ["LOW SIZE", "Low size", "s-warn"],
|
||||
@@ -516,6 +551,31 @@ function applyChrome() {
|
||||
document.querySelector("main").hidden = !scanner;
|
||||
$("positions").hidden = scanner;
|
||||
if (!scanner) { $("hero").hidden = true; $("hbanner").hidden = true; }
|
||||
if (MODE !== "daily") $("ibkrSetup").hidden = true;
|
||||
}
|
||||
|
||||
function showIbkrSetup(payload) {
|
||||
const reason = payload.reason || "";
|
||||
const gw = payload.gateway || {};
|
||||
const titleEl = $("ibkrSetupTitle");
|
||||
const msgEl = $("ibkrSetupMsg");
|
||||
if (reason === "no_contracts_file") {
|
||||
titleEl.textContent = "No IBKR contracts configured.";
|
||||
msgEl.innerHTML = ` Copy <code>data/ibkr_contracts.example.json</code> → <code>data/ibkr_contracts.json</code> and fill in your daily-BTC conids. `;
|
||||
} else if (reason === "gateway_not_running") {
|
||||
titleEl.textContent = "IBKR Gateway not running.";
|
||||
msgEl.innerHTML = ` Start the Client Portal Gateway on this Mac (default port 5000). Detail: <code>${esc(gw.message || "no connection")}</code>. `;
|
||||
} else if (payload.error) {
|
||||
titleEl.textContent = "IBKR session needs re-auth.";
|
||||
msgEl.innerHTML = ` ${esc(payload.error)} `;
|
||||
} else if (reason && reason.startsWith("bad_json")) {
|
||||
titleEl.textContent = "data/ibkr_contracts.json is invalid JSON.";
|
||||
msgEl.innerHTML = ` <code>${esc(reason)}</code> `;
|
||||
} else {
|
||||
titleEl.textContent = "IBKR not connected.";
|
||||
msgEl.textContent = "";
|
||||
}
|
||||
$("ibkrSetup").hidden = false;
|
||||
}
|
||||
|
||||
async function load(force) {
|
||||
@@ -532,15 +592,31 @@ async function load(force) {
|
||||
renderPositions(d);
|
||||
$("meta").textContent =
|
||||
`${d.generated_at} · ${d.totals.open_positions} open`;
|
||||
} else if (MODE === "daily" && d.configured === false) {
|
||||
RAW = [];
|
||||
renderSummary({ total: 0 });
|
||||
buildAssetChips();
|
||||
renderHero(RAW);
|
||||
renderBody();
|
||||
showIbkrSetup(d);
|
||||
$("meta").textContent = "IBKR not connected";
|
||||
} else {
|
||||
RAW = d.rows;
|
||||
renderSummary(d.summary);
|
||||
RAW = d.rows || [];
|
||||
renderSummary(d.summary || { total: RAW.length });
|
||||
buildAssetChips();
|
||||
renderHero(RAW);
|
||||
renderBody();
|
||||
updateBanner(d.summary);
|
||||
const unit = MODE === "hourly" ? "assets" : "pairs";
|
||||
$("meta").textContent = `${d.generated_at} · ${d.summary.total} ${unit}`;
|
||||
if (MODE === "daily") {
|
||||
$("ibkrSetup").hidden = false;
|
||||
$("ibkrSetupTitle").textContent = "IBKR connected.";
|
||||
$("ibkrSetupMsg").innerHTML =
|
||||
' Local-only feature; updates as long as the Client Portal Gateway is running and authenticated. ';
|
||||
}
|
||||
const unit = MODE === "hourly" ? "assets"
|
||||
: MODE === "daily" ? "pairs" : "pairs";
|
||||
const ga = d.generated_at || "—";
|
||||
$("meta").textContent = `${ga} · ${RAW.length} ${unit}`;
|
||||
}
|
||||
} catch (e) {
|
||||
toast((MODE === "positions" ? "Load" : "Scan") + " failed: " + e.message, "err");
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<div class="tabs" id="tabs">
|
||||
<button data-m="monthly" class="on">Monthly arb</button>
|
||||
<button data-m="hourly">Hourly <span class="spec">spec</span></button>
|
||||
<button data-m="daily">IBKR × Kalshi</button>
|
||||
<button data-m="positions">Positions</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,6 +81,15 @@
|
||||
|
||||
<section id="positions" class="positions" hidden></section>
|
||||
|
||||
<div id="ibkrSetup" class="hbanner" hidden>
|
||||
<div class="hb-ico">⚙︎</div>
|
||||
<div class="hb-txt">
|
||||
<b id="ibkrSetupTitle">IBKR not connected.</b>
|
||||
<span id="ibkrSetupMsg"></span>
|
||||
<span class="muted">Local-only feature — needs the IBKR Client Portal Gateway running on this Mac and a populated <code>data/ibkr_contracts.json</code>.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="settingsModal" class="modal" hidden>
|
||||
<div class="card">
|
||||
<h2>Scanner settings</h2>
|
||||
|
||||
Reference in New Issue
Block a user