diff --git a/arb/calc.py b/arb/calc.py index 577430232..93906d865 100644 --- a/arb/calc.py +++ b/arb/calc.py @@ -114,9 +114,20 @@ def evaluate(pair, kq, pq, settings, today=None): if not pair.get("poly_slug"): row["status"] = "NO PAIR" return row - if not kq or not pq or not ks or not ps: + # Surface which side is missing — bare "NO DATA" hid the real cause + # (commonly: Kalshi side liquid but the Polymarket slug doesn't resolve). + if not ks or not ps: row["status"] = "NO DATA" return row + if not kq and not pq: + row["status"] = "NO DATA" + return row + if not pq: + row["status"] = "NO POLY" + return row + if not kq: + row["status"] = "NO KALSHI" + return row kfee_rate = settings["kalshi_fee_rate"] pfee_rate = settings["poly_fee_rate"] diff --git a/arb/scan.py b/arb/scan.py index afe9b2383..d50f80fe1 100644 --- a/arb/scan.py +++ b/arb/scan.py @@ -87,7 +87,7 @@ def run_scan(force=False): summary = {"total": len(rows), "fetch_ms": fetch_ms} for st in ("ARB", "NO ARB", "BAD BASIS", "LOW SIZE", - "NO DATA", "NO PAIR"): + "NO DATA", "NO POLY", "NO KALSHI", "NO PAIR"): summary[st] = sum(1 for r in rows if r["status"] == st) payload = { diff --git a/docs/app.js b/docs/app.js index 86addccd6..16b15f2e1 100644 --- a/docs/app.js +++ b/docs/app.js @@ -34,9 +34,23 @@ 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; // Static snapshot URLs — no backend; cache-bust on each load so returning // users see fresh data after a GHA cron commit. const endpoint = () => { @@ -52,19 +66,6 @@ const esc = (s) => String(s == null ? "" : s).replace(/[&<>"]/g, const rid = (r) => (r.asset || "") + "|" + (r.kalshi_ticker || "") + "|" + (r.poly_slug || ""); -// "2026-05-21 16:15:07" (UTC, from GHA runner) -> "3 min ago" etc. -function ago(stamp) { - if (!stamp) return "—"; - const t = Date.parse(stamp.replace(" ", "T") + "Z"); - if (isNaN(t)) return stamp; - const s = Math.max(0, Math.round((Date.now() - t) / 1000)); - if (s < 45) return "just now"; - const m = Math.round(s / 60); - if (m < 60) return `${m} min ago`; - const h = Math.round(m / 60); - return `${h}h ago`; -} - function fmt(v, kind) { if (v === null || v === undefined || v === "") return ''; @@ -102,26 +103,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 `
${thumb(r)}
${esc(r.asset || "?")} ${esc(title)}
-
${esc(r.direction || "")}  ${esc(strikes)}
+
${esc(r.direction || "")}  ${esc(secondLabel)}
`; } +function cellSettle(r) { + const iso = r.kalshi_close_iso || r.ibkr_close_iso; + if (!iso) return ''; + try { + const d = new Date(iso); + const opts = { month: "short", day: "numeric", hour: "numeric" }; + return `${d.toLocaleString(undefined, opts)}`; + } 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 ''; const pct = Math.min(100, (c / 1) * 100); const col = c < 1 ? "var(--good)" : "var(--bad)"; + const otherL = MODE === "daily" ? "I" : "P"; return `
-
K ${k.toFixed(3)}P ${p.toFixed(3)}
+
K ${k.toFixed(3)}${otherL} ${p.toFixed(3)}
cost vs $1${c.toFixed(3)}
`; @@ -261,10 +275,13 @@ function renderBody() { if (c.k === "_mkt") return `${cellMarket(r)}`; if (c.k === "_px") return `${cellPx(r)}`; if (c.k === "_window") return `${cellWindow(r)}`; + if (c.k === "_settle") return `${cellSettle(r)}`; 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 `${esc(bs)}`; } return `${fmt(r[c.k], c.f)}`; @@ -285,10 +302,14 @@ 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"], - ["NO DATA", "No data", ""], ["total", "Scanned", ""], + ["NO POLY", "No Poly", ""], ["total", "Scanned", ""], ]; $("summary").innerHTML = cards.map(([k, l, cls]) => `
${s[k] ?? 0}
@@ -532,8 +553,33 @@ function applyChrome() { $("summary").hidden = !scanner; document.querySelector(".toolbar").hidden = !scanner; document.querySelector("main").hidden = !scanner; - if ($("positions")) $("positions").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 data/ibkr_contracts.example.jsondata/ibkr_contracts.json 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: ${esc(gw.message || "no connection")}. `; + } 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 = ` ${esc(reason)} `; + } else { + titleEl.textContent = "IBKR not connected."; + msgEl.textContent = ""; + } + $("ibkrSetup").hidden = false; } async function load(force) { @@ -550,15 +596,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 = `${ago(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"); @@ -622,33 +684,28 @@ function wire() { document.querySelector("#statusSeg button.on")?.classList.remove("on"); b.classList.add("on"); renderBody(); }); - // STATIC_MODE: no auto-refresh checkbox, no settings panel (DOM absent). - if ($("auto")) { - let timer = null; - $("auto").onchange = (e) => { - clearInterval(timer); - if (e.target.checked) { load(true); timer = setInterval(() => load(true), 8000); } + let timer = null; + $("auto").onchange = (e) => { + clearInterval(timer); + if (e.target.checked) { load(true); timer = setInterval(() => load(true), 8000); } + }; + $("settingsBtn").onclick = async () => { + await loadSettings(); $("settingsModal").hidden = false; + }; + $("closeSettings").onclick = () => ($("settingsModal").hidden = true); + $("saveSettings").onclick = async () => { + const body = { + kalshi_fee_rate: +s_kfee.value, poly_fee_rate: +s_pfee.value, + min_net_return: +s_minret.value, min_poly_volume: +s_minvol.value, + min_contracts: +s_minct.value, }; - } - if ($("settingsBtn")) { - $("settingsBtn").onclick = async () => { - await loadSettings(); $("settingsModal").hidden = false; - }; - $("closeSettings").onclick = () => ($("settingsModal").hidden = true); - $("saveSettings").onclick = async () => { - const body = { - kalshi_fee_rate: +s_kfee.value, poly_fee_rate: +s_pfee.value, - min_net_return: +s_minret.value, min_poly_volume: +s_minvol.value, - min_contracts: +s_minct.value, - }; - const r = await fetch("/api/settings", { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (r.ok) { $("settingsModal").hidden = true; toast("Saved — rescanning", "ok"); load(true); } - else toast("Save failed", "err"); - }; - } + const r = await fetch("/api/settings", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (r.ok) { $("settingsModal").hidden = true; toast("Saved — rescanning", "ok"); load(true); } + else toast("Save failed", "err"); + }; } renderHead(); diff --git a/docs/index.html b/docs/index.html index f187d5604..e6627dc36 100644 --- a/docs/index.html +++ b/docs/index.html @@ -17,7 +17,7 @@

Arb Scanner

- Kalshi × Polymarket · crypto · snapshot + Kalshi × Polymarket · crypto
@@ -25,8 +25,8 @@
-
loading…
- +
connecting…
+
@@ -57,6 +57,7 @@ + @@ -75,6 +76,9 @@ + + + diff --git a/docs/style.css b/docs/style.css index 2191fb180..8d8177eb0 100644 --- a/docs/style.css +++ b/docs/style.css @@ -263,6 +263,7 @@ tbody tr.r.open { background: rgba(108,140,255,.07); } .pill.BADBASIS { color: var(--bad); background: var(--bad-d); } .pill.LOWSIZE { color: var(--info); background: rgba(76,201,240,.12); } .pill.NODATA, .pill.NOPAIR { color: var(--faint); background: rgba(255,255,255,.05); } +.pill.NOPOLY, .pill.NOKALSHI { color: var(--warn); background: var(--warn-d); } /* ---------- detail drawer ---------- */ tr.detail td { padding: 0; border-bottom: 1px solid var(--line); } diff --git a/web/app.js b/web/app.js index cefcb63a1..21e64a4be 100644 --- a/web/app.js +++ b/web/app.js @@ -305,7 +305,7 @@ function renderSummary(s) { ] : [ ["ARB", "Live arbs", "s-arb"], ["NO ARB", "No edge", ""], ["BAD BASIS", "Bad basis", "s-bad"], ["LOW SIZE", "Low size", "s-warn"], - ["NO DATA", "No data", ""], ["total", "Scanned", ""], + ["NO POLY", "No Poly", ""], ["total", "Scanned", ""], ]; $("summary").innerHTML = cards.map(([k, l, cls]) => `
${s[k] ?? 0}
diff --git a/web/index.html b/web/index.html index 008053d25..6598bc4c0 100644 --- a/web/index.html +++ b/web/index.html @@ -61,6 +61,7 @@ +
diff --git a/web/style.css b/web/style.css index 2191fb180..8d8177eb0 100644 --- a/web/style.css +++ b/web/style.css @@ -263,6 +263,7 @@ tbody tr.r.open { background: rgba(108,140,255,.07); } .pill.BADBASIS { color: var(--bad); background: var(--bad-d); } .pill.LOWSIZE { color: var(--info); background: rgba(76,201,240,.12); } .pill.NODATA, .pill.NOPAIR { color: var(--faint); background: rgba(255,255,255,.05); } +.pill.NOPOLY, .pill.NOKALSHI { color: var(--warn); background: var(--warn-d); } /* ---------- detail drawer ---------- */ tr.detail td { padding: 0; border-bottom: 1px solid var(--line); }