mirror of
https://github.com/cjudice-commits/prediction-market-arb.git
synced 2026-07-27 21:47:46 +00:00
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:
+12
-1
@@ -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
@@ -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
@@ -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> ${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>`;
|
||||
@@ -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
@@ -17,7 +17,7 @@
|
||||
<div class="logo">◆</div>
|
||||
<div>
|
||||
<h1>Arb Scanner</h1>
|
||||
<span class="sub">Kalshi × Polymarket · crypto · <em>snapshot</em></span>
|
||||
<span class="sub">Kalshi × Polymarket · crypto</span>
|
||||
</div>
|
||||
<div class="tabs" id="tabs">
|
||||
<button data-m="monthly" class="on">Monthly 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 arb</button>
|
||||
<button data-s="NO POLY">No Poly</button>
|
||||
<button data-s="BAD BASIS">Bad basis</button>
|
||||
<button data-s="LOW SIZE">Low size</button>
|
||||
</div>
|
||||
@@ -75,6 +76,9 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast" hidden></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
<button data-s="" class="on">All</button>
|
||||
<button data-s="ARB">Arb</button>
|
||||
<button data-s="NO ARB">No arb</button>
|
||||
<button data-s="NO POLY">No Poly</button>
|
||||
<button data-s="BAD BASIS">Bad basis</button>
|
||||
<button data-s="LOW SIZE">Low size</button>
|
||||
</div>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user