mirror of
https://github.com/cjudice-commits/prediction-market-arb.git
synced 2026-07-27 13:37:46 +00:00
Static dashboard via GitHub Pages (docs/) + GHA snapshot writes
Adds a phone-friendly public dashboard served from docs/ via GitHub Pages: - docs/index.html, app.js, style.css: monthly + hourly tabs only, no positions/settings (positions stay on the local Mac UI). Reads ./data/scan.json and ./data/hourly.json instead of /api endpoints. - gha_alerts.py also writes both snapshots to docs/data/ each cron tick. - Workflow now commits docs/data/ alongside alerts_state.json. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -35,14 +35,14 @@ jobs:
|
||||
SMS_CARRIER: ${{ secrets.SMS_CARRIER }}
|
||||
run: python3 scripts/gha_alerts.py
|
||||
|
||||
- name: Persist state if changed
|
||||
- name: Commit state + dashboard snapshots if changed
|
||||
run: |
|
||||
if [ -z "$(git status --porcelain alerts_state.json)" ]; then
|
||||
echo "no state change"
|
||||
git add alerts_state.json docs/data/ 2>/dev/null || true
|
||||
if git diff --cached --quiet; then
|
||||
echo "no changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
git config user.email "actions@users.noreply.github.com"
|
||||
git config user.name "arb-alerts"
|
||||
git add alerts_state.json
|
||||
git commit -m "alerts: update state [skip ci]"
|
||||
git commit -m "alerts+dashboard: refresh [skip ci]"
|
||||
git push
|
||||
|
||||
+656
@@ -0,0 +1,656 @@
|
||||
"use strict";
|
||||
|
||||
const ASSET = {
|
||||
BTC: "#f7931a", ETH: "#7b87ff", SOL: "#19fb9b", XRP: "#3fb6e8",
|
||||
BNB: "#f3ba2f", DOGE: "#c2a633", HYPE: "#22d3a6", ZEC: "#f4b728",
|
||||
};
|
||||
const ac = (a) => ASSET[a] || "#7c8cff";
|
||||
|
||||
const COLS_MONTHLY = [
|
||||
{ k: "_mkt", t: "Market", align: "l", sort: "kalshi_ticker" },
|
||||
{ k: "best_side", t: "Side", align: "l" },
|
||||
{ k: "_px", t: "K / P 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: "annualized", t: "Annual", f: "pct" },
|
||||
{ k: "max_contracts", t: "Max Ct", f: "sz" },
|
||||
{ k: "total_gain", t: "Tot $", f: "s2" },
|
||||
{ k: "poly_volume", t: "P Vol", f: "money" },
|
||||
{ k: "days_to_expiry",t: "Days", f: "int" },
|
||||
{ k: "status", t: "Status", align: "l", f: "status" },
|
||||
];
|
||||
const COLS_HOURLY = [
|
||||
{ k: "_mkt", t: "Market", align: "l", sort: "asset" },
|
||||
{ k: "_window", t: "Window", align: "l", sort: "minutes_to_resolve" },
|
||||
{ k: "kalshi_strike", t: "K Strike", f: "strike" },
|
||||
{ k: "implied_strike",t: "Binance open", f: "strike" },
|
||||
{ k: "best_side", t: "Side", align: "l" },
|
||||
{ k: "_px", t: "K / P 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: "divergence", t: "Feed Δ", f: "div" },
|
||||
{ k: "poly_volume", t: "P Vol", f: "money" },
|
||||
{ k: "status", t: "Status", align: "l", f: "status" },
|
||||
];
|
||||
|
||||
let MODE = "monthly", POS_VIEW = "venue";
|
||||
const COLS = () => (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 = () => {
|
||||
const f = MODE === "hourly" ? "hourly" : "scan";
|
||||
return `./data/${f}.json?t=${Date.now()}`;
|
||||
};
|
||||
const STATIC_MODE = true;
|
||||
|
||||
let RAW = [], sortKey = "net_return", sortAsc = false, openKey = null;
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"]/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
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>';
|
||||
const sign = (n, body, big) => {
|
||||
const c = n > 0 ? "pos" : n < 0 ? "neg" : "dimv";
|
||||
return `<span class="num ${c}${big ? " big" : ""}">${body}</span>`;
|
||||
};
|
||||
switch (kind) {
|
||||
case "int": return String(Math.round(v));
|
||||
case "sz": return (+v).toLocaleString(undefined, { maximumFractionDigits: 0 });
|
||||
case "px": return `<span class="mono">${(+v).toFixed(3)}</span>`;
|
||||
case "money": return "$" + (+v).toLocaleString(undefined, { notation: v >= 1e6 ? "compact" : "standard", maximumFractionDigits: 1 });
|
||||
case "pct": return sign(v, (v * 100).toFixed(1) + "%");
|
||||
case "pctBig":return sign(v, (v > 0 ? "+" : "") + (v * 100).toFixed(2) + "%", true);
|
||||
case "s4": return sign(v, (v > 0 ? "+" : "") + (+v).toFixed(4));
|
||||
case "s2": return sign(v, (v > 0 ? "+" : "") + "$" + (+v).toFixed(2));
|
||||
case "strike":return `<span class="mono">${(+v).toLocaleString(undefined, { maximumFractionDigits: v < 10 ? 4 : 0 })}</span>`;
|
||||
case "div": { const p = (v * 100), hot = Math.abs(p) >= 0.3;
|
||||
return `<span class="mono ${hot ? "neg" : "dimv"}">${p >= 0 ? "+" : ""}${p.toFixed(3)}%</span>`; }
|
||||
case "status":{ const s = String(v).replace(/\s+/g, "");
|
||||
return `<span class="pill ${s}">${v}</span>`; }
|
||||
default: return esc(v);
|
||||
}
|
||||
}
|
||||
|
||||
function thumb(r, sz) {
|
||||
const s = sz || 34;
|
||||
if (r.image)
|
||||
return `<img class="thumb" style="width:${s}px;height:${s}px"
|
||||
src="${esc(r.image)}" loading="lazy"
|
||||
onerror="this.replaceWith(Object.assign(document.createElement('div'),
|
||||
{className:'badge',style:'width:${s}px;height:${s}px;background:${ac(r.asset)}',
|
||||
textContent:'${esc(r.asset || "?").slice(0,4)}'}))">`;
|
||||
return `<div class="badge" style="width:${s}px;height:${s}px;background:${ac(r.asset)}">${esc((r.asset || "?").slice(0, 4))}</div>`;
|
||||
}
|
||||
|
||||
function cellMarket(r) {
|
||||
const title = r.kalshi_title || r.poly_question ||
|
||||
`${r.asset} ${r.direction} ${r.kalshi_strike ?? ""}`;
|
||||
const strikes = `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></div>`;
|
||||
}
|
||||
|
||||
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)";
|
||||
return `<div class="pricebar">
|
||||
<div class="lbl"><span>K ${k.toFixed(3)}</span><span>P ${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>`;
|
||||
}
|
||||
|
||||
function cellWindow(r) {
|
||||
if (!r.window_start) return '<span class="dimv">—</span>';
|
||||
const m = r.minutes_to_resolve;
|
||||
const mc = m != null && m <= 10 ? "neg" : m != null && m <= 25 ? "pos" : "dimv";
|
||||
return `<div class="info">
|
||||
<div class="q mono">${esc(r.window_start)} → ${esc(r.window_close)}</div>
|
||||
<div class="sub ${mc}">${m != null ? "resolves in " + m + "m" : ""}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderHead() {
|
||||
$("head").innerHTML = COLS().map((c) => {
|
||||
const sk = c.sort || c.k;
|
||||
let cls = c.align === "l" ? "l" : "";
|
||||
if (sk === sortKey) cls += sortAsc ? " asc" : " sorted";
|
||||
return `<th class="${cls}" data-sk="${sk}">${c.t}</th>`;
|
||||
}).join("");
|
||||
document.querySelectorAll("#head th").forEach((th) => {
|
||||
th.onclick = () => {
|
||||
const k = th.dataset.sk;
|
||||
if (k === sortKey) sortAsc = !sortAsc; else { sortKey = k; sortAsc = false; }
|
||||
renderHead(); renderBody();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function activeStatus() {
|
||||
const b = document.querySelector("#statusSeg button.on");
|
||||
return b ? b.dataset.s : "";
|
||||
}
|
||||
function activeAssets() {
|
||||
return [...document.querySelectorAll("#assetChips .ac.on")].map((e) => e.dataset.a);
|
||||
}
|
||||
|
||||
function filtered() {
|
||||
const q = $("search").value.trim().toLowerCase();
|
||||
const s = activeStatus(), as = activeAssets(), fav = $("favOnly").checked;
|
||||
return RAW.filter((r) => {
|
||||
if (s && r.status !== s) return false;
|
||||
if (as.length && !as.includes(r.asset)) return false;
|
||||
if (fav && !r.basis_favorable) return false;
|
||||
if (q) {
|
||||
const h = `${r.asset} ${r.kalshi_ticker} ${r.poly_slug || ""} ${r.kalshi_title || ""} ${r.poly_question || ""}`.toLowerCase();
|
||||
if (!h.includes(q)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function sortRows(rows) {
|
||||
return rows.sort((x, y) => {
|
||||
let a = x[sortKey], b = y[sortKey];
|
||||
a = a == null ? -Infinity : a; b = b == null ? -Infinity : b;
|
||||
if (typeof a === "string" || typeof b === "string")
|
||||
return sortAsc ? String(a).localeCompare(b) : String(b).localeCompare(a);
|
||||
return sortAsc ? a - b : b - a;
|
||||
});
|
||||
}
|
||||
|
||||
function drawer(r) {
|
||||
const kv = (k, v) => `<div class="k">${k}</div><div class="v">${v}</div>`;
|
||||
const money = (n, d = 4) => n == null ? "—"
|
||||
: `<span class="${n > 0 ? "pos" : n < 0 ? "neg" : ""}">${n > 0 ? "+" : ""}${(+n).toFixed(d)}</span>`;
|
||||
const kCard = `<div class="vcard">
|
||||
<h4><span class="tag k">Kalshi</span> ${esc(r.kalshi_ticker)}</h4>
|
||||
<div class="title">${esc(r.kalshi_title || "—")}</div>
|
||||
<div class="legs">
|
||||
<div class="leg"><div class="k">Yes ask</div><div class="v">${r.kalshi_price != null && r.best_side === "YES+NO" ? r.kalshi_price.toFixed(3) : "—"}</div></div>
|
||||
<div class="leg"><div class="k">No ask</div><div class="v">${r.kalshi_price != null && r.best_side === "NO+YES" ? r.kalshi_price.toFixed(3) : "—"}</div></div>
|
||||
<div class="leg"><div class="k">Open int</div><div class="v">${r.kalshi_size != null ? Math.round(r.kalshi_size).toLocaleString() : "—"}</div></div>
|
||||
</div>
|
||||
<div class="desc">${esc(r.kalshi_rules || "Resolution rules unavailable.")}</div>
|
||||
</div>`;
|
||||
const pImg = r.image ? `<img class="hero-img" src="${esc(r.image)}" onerror="this.remove()">` : "";
|
||||
const pSideRaw = (r.best_side || "—").split("+")[1] || "—";
|
||||
const pSide = MODE === "hourly"
|
||||
? (pSideRaw === "NO" ? "Down" : pSideRaw === "YES" ? "Up" : pSideRaw)
|
||||
: pSideRaw;
|
||||
const pCard = `<div class="vcard">
|
||||
<h4><span class="tag p">Polymarket</span> ${esc(r.poly_slug || "no paired market")}</h4>
|
||||
${pImg}
|
||||
<div class="title">${esc(r.poly_question || "—")}</div>
|
||||
<div class="legs">
|
||||
<div class="leg"><div class="k">Side held</div><div class="v">${esc(pSide)}</div></div>
|
||||
<div class="leg"><div class="k">Poly px</div><div class="v">${r.poly_price != null ? r.poly_price.toFixed(3) : "—"}</div></div>
|
||||
<div class="leg"><div class="k">Volume</div><div class="v">$${(r.poly_volume || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}</div></div>
|
||||
</div>
|
||||
<div class="desc">${esc(r.poly_description || "Market description unavailable.")}</div>
|
||||
</div>`;
|
||||
const hourly = MODE === "hourly";
|
||||
const scen = `<div class="scen">
|
||||
<div class="b"><div class="t">${hourly ? "Worst case" : "Min / guaranteed"}</div><div class="x">${money(r.worst_pnl)}</div></div>
|
||||
<div class="b"><div class="t">Strike gap</div><div class="x">${r.mid_pnl == null ? '<span class="dimv">none</span>' : money(r.mid_pnl)}</div></div>
|
||||
<div class="b"><div class="t">Best case</div><div class="x">${money(r.best_pnl)}</div></div>
|
||||
</div>`;
|
||||
const hRows = hourly ? `
|
||||
${kv("Window", esc((r.window_start || "—") + " → " + (r.window_close || "—")))}
|
||||
${kv("Resolves in", r.minutes_to_resolve != null ? r.minutes_to_resolve + " min" : "—")}
|
||||
${kv("Implied strike (Binance open)", r.implied_strike != null ? r.implied_strike.toLocaleString() : "—")}
|
||||
${kv("Binance spot / CF spot", `${r.binance_spot != null ? r.binance_spot.toLocaleString() : "—"} / ${r.cf_spot != null ? r.cf_spot.toLocaleString() : "n/a"}`)}
|
||||
${kv("Feed divergence", fmt(r.divergence, "div"))}` : `
|
||||
${kv("Annualized", fmt(r.annualized, "pct"))}
|
||||
${kv("Total guaranteed $", money(r.total_gain, 2))}
|
||||
${kv("Days to expiry", r.days_to_expiry ?? "—")}`;
|
||||
const bd = `<div class="vcard">
|
||||
<h4>${hourly ? "Speculative breakdown" : "Arb breakdown"}</h4>
|
||||
${scen}
|
||||
${hourly ? '<div class="warn-line">Different settlement feeds — legs are not a locked hedge.</div>' : ""}
|
||||
<div class="kv">
|
||||
${kv("Best side", esc(r.best_side || "—"))}
|
||||
${kv("Combined cost", r.combined_cost != null ? "$" + r.combined_cost.toFixed(4) : "—")}
|
||||
${kv("Total fee / ct", r.total_fee != null ? "$" + r.total_fee.toFixed(4) : "—")}
|
||||
${kv("Net return", fmt(r.net_return, "pctBig"))}
|
||||
${kv("Basis %", fmt(r.basis_pct, "pct") + (r.basis_favorable ? ' <span class="pos">✓ favorable</span>' : ' <span class="neg">✗ risk</span>'))}
|
||||
${kv("Max contracts", r.max_contracts != null ? Math.round(r.max_contracts).toLocaleString() : "—")}
|
||||
${hRows}
|
||||
</div></div>`;
|
||||
return `<tr class="detail"><td colspan="${COLS().length}">
|
||||
<div class="drawer">${kCard}${pCard}${bd}</div></td></tr>`;
|
||||
}
|
||||
|
||||
function renderBody() {
|
||||
const rows = sortRows(filtered());
|
||||
$("rowCount").textContent = `${rows.length} of ${RAW.length} markets`;
|
||||
$("empty").hidden = rows.length > 0;
|
||||
const html = [];
|
||||
for (const r of rows) {
|
||||
const id = rid(r), isOpen = id === openKey;
|
||||
html.push(`<tr class="r${isOpen ? " open" : ""}" data-id="${esc(id)}">` +
|
||||
COLS().map((c) => {
|
||||
const cls = c.align === "l" ? "l" : "";
|
||||
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 === "best_side") {
|
||||
let bs = r.best_side || "—";
|
||||
if (MODE === "hourly" && bs !== "—")
|
||||
bs = bs === "YES+NO" ? "K-Yes · P-Down" : "K-No · P-Up";
|
||||
return `<td class="${cls}"><span class="mono">${esc(bs)}</span></td>`;
|
||||
}
|
||||
return `<td class="${cls}">${fmt(r[c.k], c.f)}</td>`;
|
||||
}).join("") + "</tr>");
|
||||
if (isOpen) html.push(drawer(r));
|
||||
}
|
||||
$("body").innerHTML = html.join("");
|
||||
document.querySelectorAll("#body tr.r").forEach((tr) => {
|
||||
tr.onclick = () => {
|
||||
openKey = openKey === tr.dataset.id ? null : tr.dataset.id;
|
||||
renderBody();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderSummary(s) {
|
||||
const cards = MODE === "hourly" ? [
|
||||
["ARB", "Edges", "s-arb"], ["NO ARB", "No edge", ""],
|
||||
["BAD BASIS", "Bad basis", "s-bad"], ["NO DATA", "No data", ""],
|
||||
["total", "Assets", ""],
|
||||
] : [
|
||||
["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", ""],
|
||||
];
|
||||
$("summary").innerHTML = cards.map(([k, l, cls]) =>
|
||||
`<div class="stat ${cls}"><div class="n">${s[k] ?? 0}</div>
|
||||
<div class="l">${l}</div></div>`).join("") +
|
||||
`<div class="stat"><div class="n">${s.fetch_ms ?? "—"}<span style="font-size:13px;color:var(--faint)">ms</span></div>
|
||||
<div class="l">Fetch time</div></div>`;
|
||||
}
|
||||
|
||||
function renderHero(rows) {
|
||||
const hourly = MODE === "hourly";
|
||||
const top = rows.filter((r) => r.status === "ARB" && r.net_return > 0)
|
||||
.sort((a, b) => b.net_return - a.net_return).slice(0, 4);
|
||||
const h = $("hero");
|
||||
h.querySelector("h2").textContent =
|
||||
hourly ? "Top dislocations" : "Top opportunities";
|
||||
h.querySelector(".muted").textContent = hourly
|
||||
? "combined cost < $1 — speculative, basis risk applies"
|
||||
: "positive guaranteed return, ranked";
|
||||
if (!top.length) { h.hidden = true; return; }
|
||||
h.hidden = false;
|
||||
$("heroCards").innerHTML = top.map((r) => `
|
||||
<div class="hcard${hourly ? " spec" : ""}" data-id="${esc(rid(r))}">
|
||||
<div class="glow"></div>
|
||||
<div class="top">${thumb(r, 38)}
|
||||
<div class="q">${esc(r.kalshi_title || r.poly_question || r.asset)}</div></div>
|
||||
<div class="ret">+${(r.net_return * 100).toFixed(2)}%</div>
|
||||
<div class="meta">
|
||||
${hourly
|
||||
? `<span><b>${r.minutes_to_resolve ?? "—"}m</b> left</span>
|
||||
<span><b>${r.divergence != null ? (r.divergence * 100).toFixed(3) + "%" : "—"}</b> feed Δ</span>`
|
||||
: `<span><b>${r.annualized ? (r.annualized * 100).toFixed(0) + "%" : "—"}</b> annual</span>
|
||||
<span><b>$${(r.total_gain || 0).toFixed(2)}</b> locked</span>`}
|
||||
<span><b>${Math.round(r.max_contracts || 0).toLocaleString()}</b> ct</span>
|
||||
<span class="mono">${esc(r.best_side)}</span>
|
||||
</div>
|
||||
</div>`).join("");
|
||||
document.querySelectorAll(".hcard").forEach((c) => c.onclick = () => {
|
||||
openKey = c.dataset.id;
|
||||
document.querySelector("#statusSeg button.on")?.classList.remove("on");
|
||||
document.querySelector('#statusSeg button[data-s=""]').classList.add("on");
|
||||
renderBody();
|
||||
document.querySelector(`#body tr.r[data-id="${CSS.escape(c.dataset.id)}"]`)
|
||||
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
});
|
||||
}
|
||||
|
||||
let POS_DATA = null;
|
||||
|
||||
const usd = (n, d = 2) => n == null ? "—"
|
||||
: "$" + (+n).toLocaleString(undefined, { minimumFractionDigits: d, maximumFractionDigits: d });
|
||||
function pnl(n, pct) {
|
||||
if (n == null) return '<span class="dimv">—</span>';
|
||||
const c = n > 0 ? "pos" : n < 0 ? "neg" : "dimv";
|
||||
const p = pct != null ? ` <span class="pp">(${(pct * 100).toFixed(1)}%)</span>` : "";
|
||||
return `<span class="num ${c}">${n > 0 ? "+" : ""}${usd(n)}${p}</span>`;
|
||||
}
|
||||
|
||||
function posThumb(p) {
|
||||
if (p.icon) return `<img class="thumb" style="width:30px;height:30px"
|
||||
src="${esc(p.icon)}" loading="lazy" onerror="this.remove()">`;
|
||||
const a = p.asset ? p.asset.slice(0, 4) : "?";
|
||||
return `<div class="badge" style="width:30px;height:30px;background:${ac(p.asset)}">${esc(a)}</div>`;
|
||||
}
|
||||
|
||||
function venuePanel(name, tag, v) {
|
||||
const setup = (body) => `<div class="vcard setup">
|
||||
<h4><span class="tag ${tag}">${name}</span> not connected</h4>${body}</div>`;
|
||||
if (!v || v.configured === false) {
|
||||
if (name === "Polymarket")
|
||||
return setup(`<p>Add your wallet to <code>data/secrets.json</code>:</p>
|
||||
<pre>{ "polymarket_wallet": "0xYourProxyWallet" }</pre>
|
||||
<p class="dimv">Public address only — no private key. Copy
|
||||
<code>data/secrets.example.json</code> to start.</p>`);
|
||||
return setup(`<p>Create a read-only API key in Kalshi → Settings → API Keys,
|
||||
save the private-key file locally, then add to
|
||||
<code>data/secrets.json</code>:</p>
|
||||
<pre>{ "kalshi_key_id": "…",
|
||||
"kalshi_private_key_path": "/abs/path/key.pem" }</pre>
|
||||
<p class="dimv">Signed locally via openssl — the key never leaves your
|
||||
machine. ${v && v.reason === "key_file_not_found"
|
||||
? '<span class="neg">Key file not found at that path.</span>' : ""}</p>`);
|
||||
}
|
||||
if (v.error)
|
||||
return `<div class="vcard"><h4><span class="tag ${tag}">${name}</span></h4>
|
||||
<p class="neg">${esc(v.error)}</p></div>`;
|
||||
const ps = v.positions || [];
|
||||
const head = `<div class="vc-head">
|
||||
<h4><span class="tag ${tag}">${name}</span> ${ps.length} open</h4>
|
||||
<span class="vc-tot">${usd(v.total_value)}</span></div>`;
|
||||
if (!ps.length)
|
||||
return `<div class="vcard">${head}<p class="dimv">No open positions.</p></div>`;
|
||||
const rows = ps.sort((a, b) => (b.value || 0) - (a.value || 0)).map((p) => `
|
||||
<tr>
|
||||
<td class="l"><div class="mkt">${posThumb(p)}<div class="info">
|
||||
<div class="q" title="${esc(p.market)}">${esc(p.market)}</div>
|
||||
<div class="sub mono">${esc(p.ref || "")}</div></div></div></td>
|
||||
<td><span class="sidetag ${String(p.side).toLowerCase()}">${esc(p.side)}</span></td>
|
||||
<td class="num">${(p.size || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}</td>
|
||||
<td class="num mono">${p.avg_price != null ? (+p.avg_price).toFixed(3) : "—"} → ${p.cur_price != null ? (+p.cur_price).toFixed(3) : "—"}</td>
|
||||
<td class="num">${usd(p.cost)}</td>
|
||||
<td class="num">${usd(p.value)}</td>
|
||||
<td class="num">${pnl(p.pnl, p.pnl_pct)}</td>
|
||||
</tr>`).join("");
|
||||
return `<div class="vcard">${head}
|
||||
<table class="ptbl"><thead><tr>
|
||||
<th class="l">Market</th><th>Side</th><th>Size</th>
|
||||
<th>Avg → Cur</th><th>Cost</th><th>Value</th><th>P&L</th>
|
||||
</tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
function pairedView(rows) {
|
||||
if (!rows || !rows.length)
|
||||
return `<div class="vcard"><h4>Paired exposure</h4>
|
||||
<p class="dimv">No held positions match a known Kalshi↔Polymarket arb
|
||||
pair (pairs come from the Monthly map). Positions still show under
|
||||
<b>By venue</b>.</p></div>`;
|
||||
const body = rows.map((r) => {
|
||||
const legs = r.legs.map((l) => `<div class="leg2">
|
||||
<span class="tag ${l.venue === "Kalshi" ? "k" : "p"}">${l.venue[0]}</span>
|
||||
<span class="sidetag ${String(l.side).toLowerCase()}">${esc(l.side)}</span>
|
||||
<span class="dimv mono">${esc(l.ref || "")}</span>
|
||||
<span class="num">${(l.size || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}</span>
|
||||
<span>${pnl(l.pnl)}</span></div>`).join("");
|
||||
return `<tr>
|
||||
<td class="l"><span class="achip" style="background:${ac(r.asset)}22;color:${ac(r.asset)}">${esc(r.asset || "?")}</span>
|
||||
${r.complete ? '<span class="okpill">paired</span>' : '<span class="onepill">one leg</span>'}</td>
|
||||
<td class="l">${legs}</td>
|
||||
<td class="num">${usd(r.cost)}</td>
|
||||
<td class="num">${usd(r.value)}</td>
|
||||
<td class="num">${pnl(r.pnl, r.pnl_pct)}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
return `<div class="vcard"><h4>Paired / netted exposure</h4>
|
||||
<table class="ptbl"><thead><tr>
|
||||
<th class="l">Pair</th><th class="l">Legs</th>
|
||||
<th>Cost</th><th>Value</th><th>Net P&L</th>
|
||||
</tr></thead><tbody>${body}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
function historyCard(name, tag, rows, note) {
|
||||
const head = `<div class="vc-head">
|
||||
<h4><span class="tag ${tag}">${name}</span> ${rows.length} settled${note ? ` <span class="dimv">· ${note}</span>` : ""}</h4>
|
||||
<span class="vc-tot">${pnl(rows.reduce((s, r) => s + (r.realized || 0), 0))}</span></div>`;
|
||||
if (!rows.length)
|
||||
return `<div class="vcard">${head}<p class="dimv">No settled markets found.</p></div>`;
|
||||
const body = rows.slice().sort((a, b) =>
|
||||
(b.settled_date || "").localeCompare(a.settled_date || "")).map((r) => `
|
||||
<tr>
|
||||
<td class="l"><div class="mkt">
|
||||
${r.icon ? `<img class="thumb" style="width:26px;height:26px" src="${esc(r.icon)}" loading="lazy" onerror="this.remove()">` : ""}
|
||||
<div class="info"><div class="q" title="${esc(r.market)}">${esc(r.market)}</div>
|
||||
<div class="sub mono">${esc(r.settled_date || "")}</div></div></div></td>
|
||||
<td><span class="sidetag ${String(r.result).toLowerCase()}">${esc(r.result)}</span></td>
|
||||
<td><span class="sidetag ${String(r.side).toLowerCase()}">${esc(r.side || "—")}</span></td>
|
||||
<td class="num">${(r.size || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}</td>
|
||||
<td class="num">${usd(r.cost)}</td>
|
||||
<td class="num">${usd(r.payout)}</td>
|
||||
<td class="num">${pnl(r.realized)}</td>
|
||||
</tr>`).join("");
|
||||
return `<div class="vcard">${head}
|
||||
<table class="ptbl"><thead><tr>
|
||||
<th class="l">Market</th><th>Result</th><th>Side</th><th>Size</th>
|
||||
<th>Cost</th><th>Payout</th><th>Realized</th>
|
||||
</tr></thead><tbody>${body}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
function historyView(h) {
|
||||
h = h || {};
|
||||
return `<div class="vstack">
|
||||
${historyCard("Kalshi", "k", h.kalshi || [], "exact (settlements)")}
|
||||
${historyCard("Polymarket", "p", h.polymarket || [], "reconstructed · payout derived")}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderPositions(d) {
|
||||
if (d) POS_DATA = d;
|
||||
d = POS_DATA;
|
||||
const t = d.totals || {};
|
||||
const stat = (n, l, cls) =>
|
||||
`<div class="stat ${cls || ""}"><div class="n">${n}</div><div class="l">${l}</div></div>`;
|
||||
const pstat = (n, l, big) => {
|
||||
if (n == null)
|
||||
return `<div class="stat"><div class="n">—</div><div class="l">${l}</div></div>`;
|
||||
const cls = n > 0 ? "s-arb" : n < 0 ? "s-bad" : "";
|
||||
const v = (n > 0 ? "+" : n < 0 ? "-" : "") +
|
||||
"$" + Math.abs(n).toLocaleString(undefined,
|
||||
{ minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
return `<div class="stat ${cls}${big ? " stat-lg" : ""}">
|
||||
<div class="n">${v}</div><div class="l">${l}</div></div>`;
|
||||
};
|
||||
const strip = `<div class="summary">
|
||||
${stat(usd(t.poly_value), "Polymarket value")}
|
||||
${stat(t.kalshi_value == null ? "—" : usd(t.kalshi_value), "Kalshi value")}
|
||||
${pstat(t.kalshi_pnl, "Kalshi P&L")}
|
||||
${pstat(t.poly_pnl, "Polymarket P&L")}
|
||||
${(() => {
|
||||
const n = t.total_pnl, r = t.total_return;
|
||||
if (n == null)
|
||||
return `<div class="stat stat-lg"><div class="n">—</div><div class="l">Total P&L</div></div>`;
|
||||
const cls = n > 0 ? "s-arb" : n < 0 ? "s-bad" : "";
|
||||
const v = (n > 0 ? "+" : n < 0 ? "-" : "") + "$" +
|
||||
Math.abs(n).toLocaleString(undefined,
|
||||
{ minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
const sub = r == null ? "" :
|
||||
`<div class="stat-sub ${r > 0 ? "pos" : r < 0 ? "neg" : ""}">${r > 0 ? "+" : ""}${(r * 100).toFixed(2)}% return</div>`;
|
||||
return `<div class="stat stat-lg ${cls}"><div class="n">${v}</div>${sub}<div class="l">Total P&L</div></div>`;
|
||||
})()}
|
||||
${pstat(t.total_realized, "Realized P&L")}
|
||||
${stat(t.open_positions ?? 0, "Open positions")}
|
||||
${stat(t.settled_count ?? 0, "Settled")}
|
||||
${stat(t.paired_count ?? 0, "Complete pairs", "s-arb")}</div>`;
|
||||
const tab = (v, l) =>
|
||||
`<button data-v="${v}" class="${POS_VIEW === v ? "on" : ""}">${l}</button>`;
|
||||
const seg = `<div class="seg posseg">
|
||||
${tab("venue", "By venue")}${tab("paired", "Paired")}${tab("history", "History")}
|
||||
</div>`;
|
||||
const content = POS_VIEW === "paired" ? pairedView(d.paired)
|
||||
: POS_VIEW === "history" ? historyView(d.history)
|
||||
: `<div class="vstack">
|
||||
${venuePanel("Polymarket", "p", d.polymarket)}
|
||||
${venuePanel("Kalshi", "k", d.kalshi)}</div>`;
|
||||
$("positions").innerHTML = strip +
|
||||
`<div class="postoolbar">${seg}
|
||||
<span class="dimv pos-note">Read-only · credentials stay in local
|
||||
data/secrets.json</span></div>` + content;
|
||||
$("positions").querySelectorAll(".posseg button").forEach((b) =>
|
||||
b.onclick = () => {
|
||||
POS_VIEW = b.dataset.v; renderPositions();
|
||||
});
|
||||
}
|
||||
|
||||
function toast(msg, kind) {
|
||||
const t = $("toast");
|
||||
t.textContent = msg; t.className = "toast" + (kind ? " " + kind : "");
|
||||
t.hidden = false; clearTimeout(toast._t);
|
||||
toast._t = setTimeout(() => (t.hidden = true), kind === "err" ? 7000 : 2800);
|
||||
}
|
||||
|
||||
function applyChrome() {
|
||||
const scanner = MODE !== "positions";
|
||||
$("summary").hidden = !scanner;
|
||||
document.querySelector(".toolbar").hidden = !scanner;
|
||||
document.querySelector("main").hidden = !scanner;
|
||||
if ($("positions")) $("positions").hidden = scanner;
|
||||
if (!scanner) { $("hero").hidden = true; $("hbanner").hidden = true; }
|
||||
}
|
||||
|
||||
async function load(force) {
|
||||
const b = $("refresh");
|
||||
b.disabled = true; b.classList.add("loading");
|
||||
b.querySelector(".blabel").textContent =
|
||||
MODE === "positions" ? "Loading…" : "Scanning…";
|
||||
applyChrome();
|
||||
try {
|
||||
const res = await fetch(endpoint() + (force ? "?force=1" : ""));
|
||||
const d = await res.json();
|
||||
if (!res.ok) throw new Error(d.error || res.status);
|
||||
if (MODE === "positions") {
|
||||
renderPositions(d);
|
||||
$("meta").textContent =
|
||||
`${d.generated_at} · ${d.totals.open_positions} open`;
|
||||
} else {
|
||||
RAW = d.rows;
|
||||
renderSummary(d.summary);
|
||||
buildAssetChips();
|
||||
renderHero(RAW);
|
||||
renderBody();
|
||||
updateBanner(d.summary);
|
||||
const unit = MODE === "hourly" ? "assets" : "pairs";
|
||||
$("meta").textContent = `${ago(d.generated_at)} · ${d.summary.total} ${unit}`;
|
||||
}
|
||||
} catch (e) {
|
||||
toast((MODE === "positions" ? "Load" : "Scan") + " failed: " + e.message, "err");
|
||||
$("meta").textContent = "load failed";
|
||||
} finally {
|
||||
b.disabled = false; b.classList.remove("loading");
|
||||
b.querySelector(".blabel").textContent = "Refresh";
|
||||
}
|
||||
}
|
||||
|
||||
function updateBanner(summary) {
|
||||
const b = $("hbanner");
|
||||
if (MODE !== "hourly") { b.hidden = true; return; }
|
||||
b.hidden = false;
|
||||
const d = summary && summary.max_divergence;
|
||||
const el = $("hbDiv");
|
||||
if (d == null) { el.textContent = "n/a"; el.classList.remove("hot"); return; }
|
||||
const pct = (d * 100);
|
||||
el.textContent = "max " + pct.toFixed(3) + "%";
|
||||
el.classList.toggle("hot", pct >= 0.3);
|
||||
}
|
||||
|
||||
function buildAssetChips() {
|
||||
const el = $("assetChips");
|
||||
if (el.dataset.built) return;
|
||||
const assets = [...new Set(RAW.map((r) => r.asset))].filter(Boolean).sort();
|
||||
el.innerHTML = assets.map((a) =>
|
||||
`<span class="ac" data-a="${a}"><span class="blob" style="background:${ac(a)}"></span>${a}</span>`).join("");
|
||||
el.dataset.built = "1";
|
||||
el.querySelectorAll(".ac").forEach((c) => c.onclick = () => {
|
||||
c.classList.toggle("on"); renderBody();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
const s = await (await fetch("/api/settings")).json();
|
||||
s_kfee.value = s.kalshi_fee_rate; s_pfee.value = s.poly_fee_rate;
|
||||
s_minret.value = s.min_net_return; s_minvol.value = s.min_poly_volume;
|
||||
s_minct.value = s.min_contracts;
|
||||
}
|
||||
|
||||
function wire() {
|
||||
document.querySelectorAll("#tabs button").forEach((b) => b.onclick = () => {
|
||||
if (b.classList.contains("on")) return;
|
||||
document.querySelector("#tabs button.on")?.classList.remove("on");
|
||||
b.classList.add("on");
|
||||
MODE = b.dataset.m;
|
||||
openKey = null;
|
||||
sortKey = "net_return"; sortAsc = false;
|
||||
const ac = $("assetChips"); ac.dataset.built = ""; ac.innerHTML = "";
|
||||
document.querySelector("#statusSeg button.on")?.classList.remove("on");
|
||||
document.querySelector('#statusSeg button[data-s=""]').classList.add("on");
|
||||
$("search").value = "";
|
||||
renderHead();
|
||||
load(true);
|
||||
});
|
||||
$("refresh").onclick = () => load(true);
|
||||
$("search").addEventListener("input", renderBody);
|
||||
$("favOnly").addEventListener("change", renderBody);
|
||||
document.querySelectorAll("#statusSeg button").forEach((b) => b.onclick = () => {
|
||||
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;
|
||||
};
|
||||
$("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");
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
renderHead();
|
||||
wire();
|
||||
load(false);
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Arb Scanner · Kalshi × Polymarket</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="aurora"></div>
|
||||
|
||||
<header>
|
||||
<div class="brand">
|
||||
<div class="logo">◆</div>
|
||||
<div>
|
||||
<h1>Arb Scanner</h1>
|
||||
<span class="sub">Kalshi × Polymarket · crypto · <em>snapshot</em></span>
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
</header>
|
||||
|
||||
<div id="hbanner" class="hbanner" hidden>
|
||||
<div class="hb-ico">⚠</div>
|
||||
<div class="hb-txt">
|
||||
<b>Speculative — not a locked arb.</b> Kalshi settles on CF Benchmarks,
|
||||
Polymarket on Binance. Different feeds & the discrete-strike gap mean
|
||||
legs can both lose. Live Binance↔CF divergence:
|
||||
<span id="hbDiv" class="hb-div">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="summary" class="summary"></section>
|
||||
|
||||
<section id="hero" class="hero" hidden>
|
||||
<div class="hero-head"><h2>Top opportunities</h2><span class="muted">positive guaranteed return, ranked</span></div>
|
||||
<div id="heroCards" class="hero-cards"></div>
|
||||
</section>
|
||||
|
||||
<section class="toolbar">
|
||||
<div class="search">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15"><path d="M21 21l-4.3-4.3M11 18a7 7 0 1 1 0-14 7 7 0 0 1 0 14Z" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
<input type="search" id="search" placeholder="Search asset, ticker, market…">
|
||||
</div>
|
||||
<div id="assetChips" class="chips"></div>
|
||||
<div class="seg" id="statusSeg">
|
||||
<button data-s="" class="on">All</button>
|
||||
<button data-s="ARB">Arb</button>
|
||||
<button data-s="NO ARB">No arb</button>
|
||||
<button data-s="BAD BASIS">Bad basis</button>
|
||||
<button data-s="LOW SIZE">Low size</button>
|
||||
</div>
|
||||
<label class="chk"><input type="checkbox" id="favOnly"><span>Favorable basis</span></label>
|
||||
<span class="count" id="rowCount"></span>
|
||||
</section>
|
||||
|
||||
<main>
|
||||
<table id="grid">
|
||||
<thead><tr id="head"></tr></thead>
|
||||
<tbody id="body"></tbody>
|
||||
</table>
|
||||
<div id="empty" class="empty" hidden>
|
||||
<div class="empty-art">◇</div>
|
||||
<p>No markets match the current filters.</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="toast" class="toast" hidden></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
:root {
|
||||
--bg: #0a0c14; --bg2: #0e1120; --panel: rgba(22,26,42,.72);
|
||||
--panel-solid: #161a2a; --line: rgba(255,255,255,.07);
|
||||
--line2: rgba(255,255,255,.12);
|
||||
--txt: #eef1f8; --dim: #9aa3bd; --faint: #6b7290;
|
||||
--accent: #6c8cff; --accent2: #b06cff;
|
||||
--good: #2fe39b; --good-d: #0c2c22; --warn: #ffc24b; --warn-d: #2e2410;
|
||||
--bad: #ff647c; --bad-d: #2c1420; --info: #4cc9f0;
|
||||
--r: 14px; --shadow: 0 14px 40px -18px rgba(0,0,0,.7);
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
/* The HTML `hidden` attribute must always win, even over display:grid/flex. */
|
||||
[hidden] { display: none !important; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
background: var(--bg); color: var(--txt); min-height: 100vh;
|
||||
font: 14px/1.5 "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
-webkit-font-smoothing: antialiased; padding-bottom: 80px;
|
||||
}
|
||||
.mono { font-family: "JetBrains Mono", ui-monospace, Menlo, monospace; }
|
||||
|
||||
/* ambient gradient wash */
|
||||
.aurora {
|
||||
position: fixed; inset: 0; z-index: -1; pointer-events: none;
|
||||
background:
|
||||
radial-gradient(60vw 50vh at 12% -5%, rgba(108,140,255,.20), transparent 60%),
|
||||
radial-gradient(55vw 45vh at 95% 0%, rgba(176,108,255,.16), transparent 55%),
|
||||
radial-gradient(50vw 50vh at 60% 110%, rgba(47,227,155,.10), transparent 60%),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
/* ---------- header ---------- */
|
||||
header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 18px 26px; position: sticky; top: 0; z-index: 20;
|
||||
background: linear-gradient(180deg, rgba(10,12,20,.92), rgba(10,12,20,.6));
|
||||
backdrop-filter: blur(14px); border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 14px; }
|
||||
.logo {
|
||||
width: 42px; height: 42px; display: grid; place-items: center;
|
||||
font-size: 20px; border-radius: 12px; color: #fff;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
box-shadow: 0 8px 24px -8px var(--accent);
|
||||
}
|
||||
h1 { font-size: 19px; font-weight: 800; letter-spacing: -.02em; }
|
||||
.brand .sub { color: var(--dim); font-size: 12px; }
|
||||
.tabs { display: flex; gap: 4px; margin-left: 18px; padding: 4px;
|
||||
background: var(--panel); border: 1px solid var(--line2); border-radius: 12px; }
|
||||
.tabs button { background: transparent; color: var(--dim); font-size: 13px;
|
||||
padding: 7px 16px; border-radius: 9px; display: flex; align-items: center; gap: 6px; }
|
||||
.tabs button.on { color: #fff;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
box-shadow: 0 6px 18px -8px var(--accent); }
|
||||
.tabs .spec { font-size: 9px; font-weight: 800; letter-spacing: .05em;
|
||||
padding: 1px 5px; border-radius: 5px; background: var(--warn-d);
|
||||
color: var(--warn); text-transform: uppercase; }
|
||||
.tabs button.on .spec { background: rgba(255,255,255,.2); color: #fff; }
|
||||
|
||||
.hbanner { display: flex; gap: 14px; align-items: center; margin: 18px 26px 0;
|
||||
padding: 14px 18px; border-radius: var(--r);
|
||||
background: linear-gradient(135deg, rgba(255,194,75,.12), var(--panel));
|
||||
border: 1px solid rgba(255,194,75,.3); }
|
||||
.hb-ico { font-size: 22px; color: var(--warn); }
|
||||
.hb-txt { font-size: 13px; color: var(--dim); line-height: 1.5; }
|
||||
.hb-txt b { color: var(--warn); }
|
||||
.hb-div { font-weight: 700; font-family: "JetBrains Mono", monospace;
|
||||
color: var(--txt); padding: 1px 8px; border-radius: 6px;
|
||||
background: rgba(255,255,255,.06); }
|
||||
.hb-div.hot { color: var(--bad); background: var(--bad-d); }
|
||||
.actions { display: flex; align-items: center; gap: 12px; }
|
||||
.live {
|
||||
display: flex; align-items: center; gap: 7px; color: var(--dim);
|
||||
font-size: 12px; padding: 7px 12px; border: 1px solid var(--line);
|
||||
border-radius: 999px; background: var(--panel);
|
||||
}
|
||||
.live .dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; background: var(--good);
|
||||
box-shadow: 0 0 0 0 rgba(47,227,155,.6); animation: pulse 2s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(47,227,155,.55); }
|
||||
70% { box-shadow: 0 0 0 7px rgba(47,227,155,0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(47,227,155,0); }
|
||||
}
|
||||
.auto { display: flex; align-items: center; gap: 7px; color: var(--dim);
|
||||
font-size: 12px; user-select: none; cursor: pointer; }
|
||||
.auto input { accent-color: var(--accent); }
|
||||
button {
|
||||
font: inherit; font-weight: 600; cursor: pointer; border: 0;
|
||||
border-radius: 10px; padding: 9px 16px; transition: .16s;
|
||||
}
|
||||
.primary {
|
||||
color: #fff; background: linear-gradient(135deg, var(--accent), var(--accent2));
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
box-shadow: 0 8px 22px -10px var(--accent);
|
||||
}
|
||||
.primary:hover { transform: translateY(-1px); filter: brightness(1.08); }
|
||||
.ghost { color: var(--txt); background: var(--panel); border: 1px solid var(--line2); }
|
||||
.ghost:hover { border-color: var(--accent); color: #fff; }
|
||||
button:disabled { opacity: .55; cursor: default; transform: none; }
|
||||
.bspin { width: 13px; height: 13px; border-radius: 50%; display: none;
|
||||
border: 2px solid rgba(255,255,255,.35); border-top-color: #fff;
|
||||
animation: spin .7s linear infinite; }
|
||||
.loading .bspin { display: inline-block; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ---------- summary ---------- */
|
||||
.summary {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 14px; padding: 22px 26px 6px;
|
||||
}
|
||||
.stat {
|
||||
position: relative; overflow: hidden; border-radius: var(--r);
|
||||
border: 1px solid var(--line); background: var(--panel);
|
||||
backdrop-filter: blur(8px); padding: 16px 18px;
|
||||
}
|
||||
.stat::before {
|
||||
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
|
||||
background: var(--accent);
|
||||
}
|
||||
.stat.s-arb::before { background: var(--good); }
|
||||
.stat.s-bad::before { background: var(--bad); }
|
||||
.stat.s-warn::before { background: var(--warn); }
|
||||
.stat .n { font-size: 28px; font-weight: 800; letter-spacing: -.02em; }
|
||||
.stat.s-arb .n { color: var(--good); }
|
||||
.stat.s-bad .n { color: var(--bad); }
|
||||
.stat .l { color: var(--dim); font-size: 11px; font-weight: 600;
|
||||
text-transform: uppercase; letter-spacing: .07em; margin-top: 2px; }
|
||||
.stat .spark { color: var(--faint); font-size: 11px; margin-top: 6px; }
|
||||
|
||||
/* ---------- hero ---------- */
|
||||
.hero { padding: 18px 26px 0; }
|
||||
.hero-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 12px; }
|
||||
.hero-head h2 { font-size: 15px; font-weight: 700; }
|
||||
.muted { color: var(--faint); font-size: 12px; }
|
||||
.hero-cards { display: grid; gap: 14px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
|
||||
.hcard {
|
||||
position: relative; overflow: hidden; border-radius: var(--r);
|
||||
padding: 16px; border: 1px solid var(--line2);
|
||||
background: linear-gradient(150deg, rgba(47,227,155,.10), var(--panel) 55%);
|
||||
box-shadow: var(--shadow); transition: .18s; cursor: pointer;
|
||||
}
|
||||
.hcard:hover { transform: translateY(-3px); border-color: var(--good); }
|
||||
.hcard .glow {
|
||||
position: absolute; right: -40px; top: -40px; width: 130px; height: 130px;
|
||||
background: radial-gradient(circle, rgba(47,227,155,.4), transparent 70%);
|
||||
filter: blur(6px);
|
||||
}
|
||||
.hcard .top { display: flex; align-items: center; gap: 11px; margin-bottom: 14px; }
|
||||
.hcard .thumb { width: 38px; height: 38px; border-radius: 10px; }
|
||||
.hcard .q { font-weight: 600; font-size: 13px; line-height: 1.35; }
|
||||
.hcard .ret { font-size: 30px; font-weight: 800; color: var(--good);
|
||||
letter-spacing: -.02em; }
|
||||
.hcard .meta { display: flex; gap: 16px; margin-top: 8px; color: var(--dim);
|
||||
font-size: 12px; }
|
||||
.hcard .meta b { color: var(--txt); font-weight: 600; }
|
||||
.hcard.spec { background: linear-gradient(150deg, rgba(255,194,75,.12),
|
||||
var(--panel) 55%); }
|
||||
.hcard.spec:hover { border-color: var(--warn); }
|
||||
.hcard.spec .glow { background: radial-gradient(circle,
|
||||
rgba(255,194,75,.34), transparent 70%); }
|
||||
.hcard.spec .ret { color: var(--warn); }
|
||||
.warn-line { font-size: 11.5px; color: var(--warn); background: var(--warn-d);
|
||||
border-radius: 7px; padding: 7px 10px; margin: 10px 0; }
|
||||
|
||||
/* ---------- toolbar ---------- */
|
||||
.toolbar {
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
padding: 20px 26px 14px;
|
||||
}
|
||||
.search { position: relative; display: flex; align-items: center; }
|
||||
.search svg { position: absolute; left: 12px; color: var(--faint); }
|
||||
.search input {
|
||||
background: var(--panel); border: 1px solid var(--line2); color: var(--txt);
|
||||
border-radius: 999px; padding: 9px 16px 9px 34px; min-width: 250px;
|
||||
font: inherit; outline: none;
|
||||
}
|
||||
.search input:focus { border-color: var(--accent); }
|
||||
.chips { display: flex; gap: 7px; flex-wrap: wrap; }
|
||||
.chips .ac {
|
||||
display: flex; align-items: center; gap: 6px; padding: 6px 12px;
|
||||
border-radius: 999px; border: 1px solid var(--line2); background: var(--panel);
|
||||
color: var(--dim); font-size: 12px; font-weight: 600; cursor: pointer;
|
||||
transition: .14s;
|
||||
}
|
||||
.chips .ac .blob { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.chips .ac.on { color: #fff; border-color: transparent;
|
||||
background: rgba(255,255,255,.10); }
|
||||
.seg { display: flex; background: var(--panel); border: 1px solid var(--line2);
|
||||
border-radius: 999px; padding: 3px; }
|
||||
.seg button { background: transparent; color: var(--dim); padding: 6px 13px;
|
||||
border-radius: 999px; font-size: 12px; }
|
||||
.seg button.on { color: #fff;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent2)); }
|
||||
.chk { display: flex; align-items: center; gap: 7px; color: var(--dim);
|
||||
font-size: 12px; cursor: pointer; user-select: none; }
|
||||
.chk input { accent-color: var(--accent); }
|
||||
.toolbar .count { margin-left: auto; color: var(--faint); font-size: 12px; }
|
||||
|
||||
/* ---------- table ---------- */
|
||||
main { padding: 0 26px; }
|
||||
table { border-collapse: separate; border-spacing: 0; width: 100%;
|
||||
font-variant-numeric: tabular-nums; }
|
||||
thead th {
|
||||
position: sticky; top: 79px; z-index: 5; background: var(--bg2);
|
||||
color: var(--faint); font-size: 10.5px; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: .06em; text-align: right;
|
||||
padding: 12px 12px; cursor: pointer; user-select: none; white-space: nowrap;
|
||||
border-bottom: 1px solid var(--line2);
|
||||
}
|
||||
thead th:first-child, thead th.l { text-align: left; }
|
||||
thead th:hover { color: var(--txt); }
|
||||
thead th.sorted, thead th.asc { color: var(--accent); }
|
||||
thead th.sorted::after { content: " ↓"; }
|
||||
thead th.asc::after { content: " ↑"; }
|
||||
tbody td {
|
||||
padding: 11px 12px; text-align: right; white-space: nowrap;
|
||||
border-bottom: 1px solid var(--line); }
|
||||
tbody td.l { text-align: left; }
|
||||
tbody tr.r { cursor: pointer; transition: background .12s; }
|
||||
tbody tr.r:hover { background: rgba(255,255,255,.035); }
|
||||
tbody tr.r.open { background: rgba(108,140,255,.07); }
|
||||
|
||||
.mkt { display: flex; align-items: center; gap: 11px; max-width: 360px; }
|
||||
.thumb, .badge {
|
||||
width: 34px; height: 34px; border-radius: 9px; flex-shrink: 0;
|
||||
object-fit: cover; background: var(--panel-solid);
|
||||
}
|
||||
.badge { display: grid; place-items: center; font-weight: 800; font-size: 12px;
|
||||
color: #fff; letter-spacing: -.02em; }
|
||||
.mkt .info { min-width: 0; }
|
||||
.mkt .qa { display: flex; align-items: center; gap: 7px; }
|
||||
.mkt .q { font-weight: 600; font-size: 13px; overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap; max-width: 270px; }
|
||||
.mkt .sub { color: var(--faint); font-size: 11px; margin-top: 1px; }
|
||||
.achip { font-size: 10px; font-weight: 800; padding: 2px 7px; border-radius: 6px;
|
||||
letter-spacing: .02em; flex-shrink: 0; }
|
||||
.dir { font-size: 10px; font-weight: 700; color: var(--dim);
|
||||
border: 1px solid var(--line2); padding: 1px 6px; border-radius: 5px; }
|
||||
|
||||
.pricebar { display: flex; flex-direction: column; gap: 3px; min-width: 110px; }
|
||||
.pricebar .track { height: 6px; border-radius: 3px; background: rgba(255,255,255,.08);
|
||||
position: relative; overflow: hidden; }
|
||||
.pricebar .fill { position: absolute; inset: 0 auto 0 0; border-radius: 3px;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent2)); }
|
||||
.pricebar .lbl { display: flex; justify-content: space-between;
|
||||
font-size: 10px; color: var(--faint); }
|
||||
|
||||
.num { font-weight: 600; }
|
||||
.pos { color: var(--good); } .neg { color: var(--bad); }
|
||||
.big { font-size: 15px; font-weight: 800; letter-spacing: -.02em; }
|
||||
.dimv { color: var(--faint); }
|
||||
|
||||
.pill { display: inline-flex; align-items: center; gap: 5px; padding: 4px 10px;
|
||||
border-radius: 999px; font-size: 11px; font-weight: 700; }
|
||||
.pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%;
|
||||
background: currentColor; }
|
||||
.pill.ARB { color: var(--good); background: var(--good-d);
|
||||
box-shadow: 0 0 14px -3px rgba(47,227,155,.5); }
|
||||
.pill.NOARB { color: var(--warn); background: var(--warn-d); }
|
||||
.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); }
|
||||
|
||||
/* ---------- detail drawer ---------- */
|
||||
tr.detail td { padding: 0; border-bottom: 1px solid var(--line); }
|
||||
.drawer { display: grid; grid-template-columns: 1fr 1fr 1.1fr; gap: 18px;
|
||||
padding: 20px 22px; background: linear-gradient(180deg,
|
||||
rgba(108,140,255,.06), transparent); animation: slide .22s ease; }
|
||||
@keyframes slide { from { opacity: 0; transform: translateY(-6px); } }
|
||||
.vcard { border: 1px solid var(--line); border-radius: 12px; padding: 16px;
|
||||
background: var(--panel); }
|
||||
.vcard h4 { font-size: 11px; text-transform: uppercase; letter-spacing: .08em;
|
||||
color: var(--dim); margin-bottom: 10px; display: flex; align-items: center;
|
||||
gap: 8px; }
|
||||
.vcard h4 .tag { font-size: 10px; padding: 2px 7px; border-radius: 5px;
|
||||
color: #fff; font-weight: 700; }
|
||||
.tag.k { background: #5b6cff; } .tag.p { background: #00c2a8; }
|
||||
.vcard .title { font-weight: 600; font-size: 13px; line-height: 1.4;
|
||||
margin-bottom: 10px; }
|
||||
.vcard img.hero-img { width: 100%; max-height: 120px; object-fit: cover;
|
||||
border-radius: 9px; margin-bottom: 10px; }
|
||||
.vcard .desc { color: var(--dim); font-size: 12px; line-height: 1.55;
|
||||
max-height: 130px; overflow: auto; }
|
||||
.legs { display: flex; gap: 10px; margin-bottom: 12px; }
|
||||
.leg { flex: 1; background: rgba(255,255,255,.04); border-radius: 9px;
|
||||
padding: 9px 11px; }
|
||||
.leg .k { color: var(--faint); font-size: 10px; text-transform: uppercase;
|
||||
letter-spacing: .05em; }
|
||||
.leg .v { font-weight: 700; font-size: 15px; margin-top: 2px; }
|
||||
.kv { display: grid; grid-template-columns: 1fr auto; gap: 7px 14px;
|
||||
font-size: 12px; }
|
||||
.kv .k { color: var(--dim); }
|
||||
.kv .v { font-weight: 600; text-align: right; }
|
||||
.scen { display: flex; gap: 8px; margin: 12px 0 4px; }
|
||||
.scen .b { flex: 1; text-align: center; border-radius: 9px; padding: 10px 6px;
|
||||
background: rgba(255,255,255,.04); border: 1px solid var(--line); }
|
||||
.scen .b .t { font-size: 10px; color: var(--faint); text-transform: uppercase;
|
||||
letter-spacing: .05em; }
|
||||
.scen .b .x { font-weight: 800; font-size: 15px; margin-top: 3px; }
|
||||
|
||||
.empty { text-align: center; padding: 70px 20px; color: var(--faint); }
|
||||
.empty-art { font-size: 40px; opacity: .4; margin-bottom: 10px; }
|
||||
|
||||
/* ---------- modal / toast ---------- */
|
||||
.modal { position: fixed; inset: 0; z-index: 40; display: grid;
|
||||
place-items: center; background: rgba(4,6,12,.7); backdrop-filter: blur(4px); }
|
||||
.modal .card { width: 360px; background: var(--panel-solid);
|
||||
border: 1px solid var(--line2); border-radius: 16px; padding: 24px;
|
||||
box-shadow: var(--shadow); }
|
||||
.modal h2 { font-size: 17px; }
|
||||
.modal .hint { color: var(--dim); font-size: 12px; margin: 4px 0 16px; }
|
||||
.modal label { display: flex; justify-content: space-between; align-items: center;
|
||||
gap: 12px; margin: 10px 0; color: var(--dim); font-size: 13px; }
|
||||
.modal input { width: 120px; background: var(--bg2); border: 1px solid var(--line2);
|
||||
color: var(--txt); border-radius: 8px; padding: 7px 10px; font: inherit; }
|
||||
.modal input:focus { outline: none; border-color: var(--accent); }
|
||||
.modal .row { display: flex; gap: 10px; margin-top: 20px; }
|
||||
.modal .row button { flex: 1; }
|
||||
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||
z-index: 50; background: var(--panel-solid); border: 1px solid var(--line2);
|
||||
color: var(--txt); padding: 12px 20px; border-radius: 12px;
|
||||
box-shadow: var(--shadow); font-size: 13px; font-weight: 500;
|
||||
animation: slide .2s ease; }
|
||||
.toast.err { border-color: var(--bad); color: var(--bad); }
|
||||
.toast.ok { border-color: var(--good); }
|
||||
|
||||
/* ---------- positions ---------- */
|
||||
.positions { padding: 22px 26px 60px; }
|
||||
.stat.stat-lg { border-width: 1px 1px 1px 4px;
|
||||
background: linear-gradient(135deg, rgba(108,140,255,.10), var(--panel)); }
|
||||
.stat.stat-lg .n { font-size: 32px; }
|
||||
.stat.stat-lg .l { color: var(--txt); }
|
||||
.stat .stat-sub { font-size: 14px; font-weight: 700; margin-top: 2px; }
|
||||
.postoolbar { display: flex; align-items: center; gap: 14px; margin: 16px 0; }
|
||||
.posseg { display: flex; }
|
||||
.pos-note { font-size: 12px; }
|
||||
.vstack { display: flex; flex-direction: column; gap: 16px; }
|
||||
.vcard .vc-head { display: flex; justify-content: space-between;
|
||||
align-items: center; margin-bottom: 12px; }
|
||||
.vc-tot { font-size: 18px; font-weight: 800; letter-spacing: -.02em; }
|
||||
.vcard.setup { border-style: dashed; }
|
||||
.vcard.setup p { color: var(--dim); font-size: 13px; margin: 8px 0; }
|
||||
.vcard.setup pre { background: var(--bg2); border: 1px solid var(--line);
|
||||
border-radius: 8px; padding: 12px; font: 12px/1.5 "JetBrains Mono", monospace;
|
||||
color: var(--txt); overflow-x: auto; }
|
||||
.vcard code { background: var(--bg2); padding: 1px 6px; border-radius: 5px;
|
||||
font: 12px "JetBrains Mono", monospace; color: var(--accent); }
|
||||
.ptbl { width: 100%; border-collapse: separate; border-spacing: 0;
|
||||
font-variant-numeric: tabular-nums; }
|
||||
.ptbl th { text-align: right; color: var(--faint); font-size: 10.5px;
|
||||
font-weight: 700; text-transform: uppercase; letter-spacing: .05em;
|
||||
padding: 8px 10px; border-bottom: 1px solid var(--line2); }
|
||||
.ptbl th.l { text-align: left; }
|
||||
.ptbl td { text-align: right; padding: 10px; border-bottom: 1px solid var(--line);
|
||||
white-space: nowrap; }
|
||||
.ptbl td.l { text-align: left; }
|
||||
.ptbl tr:hover td { background: rgba(255,255,255,.03); }
|
||||
.ptbl .mkt .q { max-width: 340px; }
|
||||
.ptbl .pp { color: var(--faint); font-size: 11px; font-weight: 600; }
|
||||
.sidetag { font-size: 10px; font-weight: 800; padding: 2px 8px;
|
||||
border-radius: 6px; text-transform: uppercase; letter-spacing: .03em; }
|
||||
.sidetag.yes, .sidetag.up { color: var(--good); background: var(--good-d); }
|
||||
.sidetag.no, .sidetag.down { color: var(--bad); background: var(--bad-d); }
|
||||
.okpill { font-size: 10px; font-weight: 700; color: var(--good);
|
||||
background: var(--good-d); padding: 2px 8px; border-radius: 999px;
|
||||
margin-left: 6px; }
|
||||
.onepill { font-size: 10px; font-weight: 700; color: var(--warn);
|
||||
background: var(--warn-d); padding: 2px 8px; border-radius: 999px;
|
||||
margin-left: 6px; }
|
||||
.leg2 { display: flex; align-items: center; gap: 8px; padding: 3px 0;
|
||||
font-size: 12px; }
|
||||
.leg2 .tag { width: 16px; height: 16px; display: grid; place-items: center;
|
||||
font-size: 9px; padding: 0; }
|
||||
|
||||
@media (max-width: 1100px) { .drawer { grid-template-columns: 1fr; } }
|
||||
+24
-3
@@ -75,18 +75,39 @@ def pair_id(r):
|
||||
r.get("poly_slug") or "")
|
||||
|
||||
|
||||
DOCS_DATA = ROOT / "docs" / "data"
|
||||
|
||||
|
||||
def write_snapshot(name, payload):
|
||||
DOCS_DATA.mkdir(parents=True, exist_ok=True)
|
||||
path = DOCS_DATA / (name + ".json")
|
||||
path.write_text(json.dumps(payload, separators=(",", ":")) + "\n")
|
||||
print("snapshot: docs/data/%s.json (%d rows)" %
|
||||
(name, len(payload.get("rows", []) or [])))
|
||||
|
||||
|
||||
def main():
|
||||
# 1) Monthly scan -> snapshot + alert source.
|
||||
payload = scan.run_scan(force=True)
|
||||
write_snapshot("scan", payload)
|
||||
|
||||
# 2) Hourly snapshot for the public dashboard. Failure here doesn't
|
||||
# block the monthly alert path.
|
||||
try:
|
||||
write_snapshot("hourly", scan.run_hourly(force=True))
|
||||
except Exception as e:
|
||||
print("hourly snapshot failed: %s: %s" % (type(e).__name__, e))
|
||||
|
||||
# 3) Monthly alerts (SMS). No-op if SMS env vars aren't configured.
|
||||
webhook = os.environ.get("SMS_WEBHOOK", "").strip()
|
||||
phone = os.environ.get("SMS_PHONE", "").strip()
|
||||
carrier = os.environ.get("SMS_CARRIER", "").strip()
|
||||
if not (webhook and phone and carrier):
|
||||
print("SMS env vars missing — exiting (set SMS_WEBHOOK/SMS_PHONE/SMS_CARRIER)")
|
||||
print("SMS env vars missing — snapshots written, no texts sent")
|
||||
return
|
||||
|
||||
payload = scan.run_scan(force=True)
|
||||
settings = payload.get("settings", {})
|
||||
min_ret = settings.get("min_net_return", 0)
|
||||
|
||||
current = {pair_id(r): r for r in payload.get("rows", [])
|
||||
if r.get("status") == "ARB"
|
||||
and (r.get("net_return") or 0) >= min_ret}
|
||||
|
||||
Reference in New Issue
Block a user