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:
Casey Judice
2026-05-23 16:16:52 -04:00
parent 81c553d4de
commit b1f18ca9f1
9 changed files with 490 additions and 12 deletions
+3
View File
@@ -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
View File
@@ -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 (MarNov)
_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
View File
@@ -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
View File
@@ -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
+19
View File
@@ -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
View File
@@ -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
}
+3 -2
View File
@@ -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
View File
@@ -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> &nbsp;${esc(strikes)}</div>
<div class="sub"><span class="dir">${esc(r.direction || "")}</span> &nbsp;${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");
+10
View File
@@ -22,6 +22,7 @@
<div class="tabs" id="tabs">
<button data-m="monthly" class="on">Monthly&nbsp;arb</button>
<button data-m="hourly">Hourly&nbsp;<span class="spec">spec</span></button>
<button data-m="daily">IBKR&nbsp;×&nbsp;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>