Distinguish NO POLY / NO KALSHI from generic NO DATA

calc.evaluate now reports NO POLY when the Kalshi side has data but the
Polymarket slug doesn't resolve (the common case: Polymarket renamed/
dropped a market while Kalshi still trades it). NO KALSHI is the reverse,
NO DATA only when both are missing. User caught this looking at BTC <70k
May: 20 of 118 pairs were silently NO-DATA when the real cause was Poly-
side absence — now visible as a dedicated NO POLY chip + filter button.

🤖 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-29 17:06:42 -04:00
parent 75352215ba
commit 757d0cb55d
8 changed files with 131 additions and 56 deletions
+12 -1
View File
@@ -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"]
+1 -1
View File
@@ -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 = {
+86 -29
View File
@@ -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 '<span class="dimv">—</span>';
@@ -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 `<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>`;
@@ -261,10 +275,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>`;
@@ -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]) =>
`<div class="stat ${cls}"><div class="n">${s[k] ?? 0}</div>
@@ -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 <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) {
@@ -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,15 +684,11 @@ 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); }
};
}
if ($("settingsBtn")) {
$("settingsBtn").onclick = async () => {
await loadSettings(); $("settingsModal").hidden = false;
};
@@ -649,7 +707,6 @@ function wire() {
else toast("Save failed", "err");
};
}
}
renderHead();
wire();
+7 -3
View File
@@ -17,7 +17,7 @@
<div class="logo"></div>
<div>
<h1>Arb&nbsp;Scanner</h1>
<span class="sub">Kalshi&nbsp;×&nbsp;Polymarket · crypto · <em>snapshot</em></span>
<span class="sub">Kalshi&nbsp;×&nbsp;Polymarket · crypto</span>
</div>
<div class="tabs" id="tabs">
<button data-m="monthly" class="on">Monthly&nbsp;arb</button>
@@ -25,8 +25,8 @@
</div>
</div>
<div class="actions">
<div class="live"><span class="dot"></span><span id="meta">loading…</span></div>
<button id="refresh" class="primary"><span class="bspin"></span><span class="blabel">Reload</span></button>
<div class="live"><span class="dot"></span><span id="meta">connecting…</span></div>
<button id="refresh" class="primary"><span class="bspin"></span><span class="blabel">Refresh</span></button>
</div>
</header>
@@ -57,6 +57,7 @@
<button data-s="" class="on">All</button>
<button data-s="ARB">Arb</button>
<button data-s="NO ARB">No&nbsp;arb</button>
<button data-s="NO POLY">No&nbsp;Poly</button>
<button data-s="BAD BASIS">Bad&nbsp;basis</button>
<button data-s="LOW SIZE">Low&nbsp;size</button>
</div>
@@ -75,6 +76,9 @@
</div>
</main>
</div>
<div id="toast" class="toast" hidden></div>
<script src="app.js"></script>
</body>
+1
View File
@@ -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); }
+1 -1
View File
@@ -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]) =>
`<div class="stat ${cls}"><div class="n">${s[k] ?? 0}</div>
+1
View File
@@ -61,6 +61,7 @@
<button data-s="" class="on">All</button>
<button data-s="ARB">Arb</button>
<button data-s="NO ARB">No&nbsp;arb</button>
<button data-s="NO POLY">No&nbsp;Poly</button>
<button data-s="BAD BASIS">Bad&nbsp;basis</button>
<button data-s="LOW SIZE">Low&nbsp;size</button>
</div>
+1
View File
@@ -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); }