diff --git a/.gitignore b/.gitignore index 0528d9faa..9f4272100 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Secrets — never commit. Provide your own data/secrets.json locally. data/secrets.json +# IBKR contract watchlist (user-editable; conids/dates change frequently) +data/ibkr_contracts.json + # Python __pycache__/ *.pyc diff --git a/arb/daily.py b/arb/daily.py new file mode 100644 index 000000000..c01af0ac9 --- /dev/null +++ b/arb/daily.py @@ -0,0 +1,227 @@ +"""IBKR ForecastEx daily BTC × Kalshi KXBTCD hourly cross-reference. + +Pairing model: each IBKR ForecastEx daily BTC contract settles at a specific +clock-time on a specific date. Kalshi's hourly KXBTCD ladder has a contract +closing at exactly that same instant. Same strike + same close = same product +across two venues, so the existing same-strike arb math applies. + +The user supplies IBKR contract conids in data/ibkr_contracts.json (one-time; +they grab them from the IBKR UI after the Client Portal Gateway is auth'd). +Each entry: {conid, strike (dollars), close_iso (UTC), label}. We then: + + 1. Snapshot prices for all configured IBKR conids via the local Gateway. + 2. For each entry, derive the Kalshi event ticker from close_iso (ET hour), + fetch the event's strike ladder, pick the market with the nearest strike. + 3. Run the standard worst-case arb math (arb.calc.evaluate) treating IBKR + as the "second venue" (drop into the pq slot). + +ForecastEx contract semantics (assumed; verify on first Gateway test): + - One conid per "above $X" daily contract. Buying = long YES. + - yes_ask = snapshot.ask (price to BUY YES = go long). + - no_ask = 1 - snapshot.bid (price to "buy NO" = sell/short; you'd + receive `bid` selling so net cost to take the NO side is 1-bid). + - If this representation turns out wrong, only this file changes. +""" +import json +import time +from datetime import datetime, timezone, timedelta +from pathlib import Path + +from .net import get_json, FetchError +from . import ibkr +from .calc import evaluate + +ROOT = Path(__file__).resolve().parent.parent +IBKR_CONTRACTS = ROOT / "data" / "ibkr_contracts.json" +KALSHI = "https://api.elections.kalshi.com/trade-api/v2" + +try: + from zoneinfo import ZoneInfo + _ET = ZoneInfo("America/New_York") +except Exception: # no tzdata -> EDT fallback (Mar–Nov) + _ET = timezone(timedelta(hours=-4)) + +_MON = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", + "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"] + + +def _event_ticker_for(close_iso, series="KXBTCD"): + """'2026-05-23T21:00:00Z' -> 'KXBTCD-26MAY2317' (ET-hour encoding).""" + if not close_iso: + return None + try: + dt = datetime.fromisoformat(close_iso.replace("Z", "+00:00")) + except ValueError: + return None + et = dt.astimezone(_ET) + return "%s-%s%s%02d%02d" % ( + series, str(et.year)[-2:], _MON[et.month - 1], et.day, et.hour) + + +def _kalshi_ladder(event_ticker): + try: + ev = get_json("%s/events/%s?with_nested_markets=true" % + (KALSHI, event_ticker)) + except FetchError: + return [] + return (ev.get("event") or {}).get("markets") or [] + + +def _pick_kalshi(ladder, target_strike): + """Pick the 'or above' market whose numeric strike is nearest target.""" + best = None + for m in ladder: + sub = (m.get("yes_sub_title") or "").lower() + if "or above" not in sub: + continue + t = (m.get("ticker") or "").rsplit("-T", 1) + if len(t) != 2: + continue + try: + ks = float(t[1]) + except ValueError: + continue + d = abs(ks - target_strike) + if best is None or d < best[0]: + best = (d, ks, m) + return best # (dist, strike, market) | None + + +def _kq_from_market(m): + """Adapt a Kalshi market dict to the kq shape calc.evaluate expects.""" + def f(v): + try: + x = float(v) + return x if x > 0 else None + except (TypeError, ValueError): + return None + return { + "ticker": m.get("ticker"), + "yes_ask": f(m.get("yes_ask_dollars")), + "no_ask": f(m.get("no_ask_dollars")), + "yes_bid": f(m.get("yes_bid_dollars")), + "no_bid": f(m.get("no_bid_dollars")), + "yes_ask_size": None, "no_ask_size": None, + "open_interest": f(m.get("open_interest_fp")) or 0.0, + "status": m.get("status"), + "expiry": (m.get("close_time") or "")[:10], + "title": m.get("title"), + "yes_label": m.get("yes_sub_title"), + "no_label": m.get("no_sub_title"), + "rules": (m.get("rules_primary") or "").strip()[:360], + } + + +def _pq_from_ibkr(snap, label): + """Adapt an IBKR snapshot to the pq shape calc.evaluate expects. + yes_ask = ask (long YES); no_ask = 1 - bid (short YES ~= long NO).""" + bid = snap.get("bid"); ask = snap.get("ask") + yes_ask = ask if (ask and 0 < ask <= 1) else None + no_ask = (1.0 - bid) if (bid and 0 < bid < 1) else None + return { + "slug": None, + "question": label, + "description": None, + "image": None, "icon": None, + "yes_ask": yes_ask, "no_ask": no_ask, + "yes_ask_size": snap.get("ask_size"), + "no_ask_size": snap.get("bid_size"), + "volume": None, + "end_date": None, + "closed": False, + } + + +def _load_cfg(): + """Returns (cfg_dict, error_str_or_None).""" + if not IBKR_CONTRACTS.exists(): + return None, "no_contracts_file" + try: + return json.loads(IBKR_CONTRACTS.read_text()), None + except ValueError as e: + return None, "bad_json: %s" % e + except OSError as e: + return None, "read_error: %s" % e + + +def run(settings): + cfg, err = _load_cfg() + if err or not cfg or not cfg.get("contracts"): + return {"configured": False, + "reason": err or "no_contracts_file", + "rows": [], "gateway": {"connected": False}} + + auth = ibkr.auth_status() + if not auth.get("connected"): + return {"configured": False, "reason": "gateway_not_running", + "rows": [], "gateway": auth} + if not auth.get("authenticated"): + return {"configured": True, "error": "Gateway up but session not " + "authenticated — re-login via the IBKR browser SSO page.", + "rows": [], "gateway": auth} + ibkr.tickle() # extend session + + conids = [c.get("conid") for c in cfg["contracts"] if c.get("conid")] + # IB market data subscription warms up on first call; retry once. + snaps = ibkr.snapshot(conids) + if any(s.get("ask") is None and s.get("bid") is None for s in snaps.values()): + time.sleep(1.0) + snaps = ibkr.snapshot(conids) or snaps + + rows = [] + for entry in cfg["contracts"]: + cid = str(entry.get("conid") or "") + strike = entry.get("strike") + close_iso = entry.get("close_iso") + label = entry.get("label") or ("IBKR conid %s" % cid) + if not (cid and strike and close_iso): + continue + snap = snaps.get(cid) or {} + + event_ticker = _event_ticker_for(close_iso, series="KXBTCD") + ladder = _kalshi_ladder(event_ticker) if event_ticker else [] + pick = _pick_kalshi(ladder, float(strike)) + if not pick: + rows.append({ + "asset": entry.get("asset", "BTC"), + "ibkr_conid": cid, "ibkr_label": label, + "ibkr_strike": strike, "ibkr_close_iso": close_iso, + "kalshi_event": event_ticker, + "ibkr_bid": snap.get("bid"), "ibkr_ask": snap.get("ask"), + "status": "NO KALSHI", + "note": "no matching Kalshi KXBTCD event/strike", + }) + continue + + dist, kstrike, kmkt = pick + kq = _kq_from_market(kmkt) + pq = _pq_from_ibkr(snap, label) + pair = { + "asset": entry.get("asset", "BTC"), + "kalshi_ticker": kmkt.get("ticker"), + "kalshi_strike": kstrike, + "poly_slug": cid, # repurposed slot — pair id + "poly_strike": float(strike), + "active": True, + } + row = evaluate(pair, kq, pq, settings) + # Rebadge poly→IBKR for UI consumption. + row["ibkr_conid"] = cid + row["ibkr_label"] = label + row["ibkr_strike"] = float(strike) + row["ibkr_ask"] = snap.get("ask") + row["ibkr_bid"] = snap.get("bid") + row["kalshi_close_iso"] = (kmkt.get("close_time") or close_iso) + row["kalshi_event"] = event_ticker + rows.append(row) + + summary = {"total": len(rows), "ARB": sum(1 for r in rows if r.get("status") == "ARB"), + "NO ARB": sum(1 for r in rows if r.get("status") == "NO ARB"), + "BAD BASIS": sum(1 for r in rows if r.get("status") == "BAD BASIS"), + "LOW SIZE": sum(1 for r in rows if r.get("status") == "LOW SIZE"), + "NO DATA": sum(1 for r in rows if r.get("status") == "NO DATA"), + "NO KALSHI": sum(1 for r in rows if r.get("status") == "NO KALSHI")} + return {"configured": True, "rows": rows, "summary": summary, + "gateway": auth, + "generated_at": time.strftime("%Y-%m-%d %H:%M:%S", + time.localtime())} diff --git a/arb/ibkr.py b/arb/ibkr.py new file mode 100644 index 000000000..81fe985c4 --- /dev/null +++ b/arb/ibkr.py @@ -0,0 +1,125 @@ +"""Interactive Brokers Client Portal Gateway client. + +The Gateway is a Java app you run locally (`bin/run.sh root/conf.yaml` in the +clientportal.gw distribution). It listens on https://localhost:5000 with a +self-signed cert; we ignore cert verification because we're talking to +localhost. Authentication is interactive (SSO via the user's browser, every +few hours) — the Gateway maintains the session for us; we just call endpoints. + +Untestable in this environment until the user installs/auths the Gateway. +Designed to fail loud-but-clear (graceful "not configured" / "not authed") +so the UI can render a setup banner rather than crashing. + +Reference: https://www.interactivebrokers.com/campus/ibkr-api-page/cpapi-v1/ +Event-contract specifics: ForecastEx contracts are modeled as options; +snapshot fields use numeric IDs (31=Last, 84=Bid, 86=Ask, 7295=close, etc.). +""" +import json +import ssl +import urllib.request +import urllib.error + +GATEWAY = "https://localhost:5000/v1/api" + +# Numeric field IDs for marketdata/snapshot. These are the standard IB codes. +F_LAST = "31" +F_BID = "84" +F_ASK = "86" +F_BID_SZ = "88" +F_ASK_SZ = "85" + +# Self-signed cert on localhost: don't verify (we're talking to our own box). +_CTX = ssl.create_default_context() +_CTX.check_hostname = False +_CTX.verify_mode = ssl.CERT_NONE + + +class NotConnected(Exception): + """Gateway isn't running on localhost:5000.""" + + +class NotAuthed(Exception): + """Gateway is up but the user's SSO session isn't active.""" + + +def _req(method, path, body=None, timeout=8): + url = GATEWAY + path + data = json.dumps(body).encode() if body is not None else None + headers = {"Accept": "application/json", "User-Agent": "arb-scanner"} + if data: + headers["Content-Type"] = "application/json" + rq = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(rq, timeout=timeout, context=_CTX) as r: + return json.loads(r.read().decode() or "null") + except (ConnectionRefusedError, urllib.error.URLError) as e: + raise NotConnected(str(e)) + except urllib.error.HTTPError as e: + if e.code in (401, 403): + raise NotAuthed("HTTP %s — re-auth via Gateway browser SSO" % e.code) + raise + + +def auth_status(): + """Returns dict {connected, authenticated, competing, message}.""" + try: + s = _req("GET", "/iserver/auth/status") or {} + return { + "connected": True, + "authenticated": bool(s.get("authenticated")), + "competing": bool(s.get("competing")), + "message": s.get("message"), + } + except NotConnected as e: + return {"connected": False, "authenticated": False, "message": str(e)} + except NotAuthed as e: + return {"connected": True, "authenticated": False, "message": str(e)} + + +def tickle(): + """Pings the Gateway to keep the session alive (~1 hour idle timeout).""" + try: + _req("POST", "/tickle", body={}) + return True + except (NotConnected, NotAuthed, urllib.error.HTTPError): + return False + + +def snapshot(conids, fields=None): + """conids: iterable of int/str. Returns {conid_str: {bid, ask, last, ...}}. + First call to /marketdata/snapshot often returns partial data; IB warms up + its market-data subscription. Caller should retry once after ~1s.""" + if not conids: + return {} + fields = fields or (F_LAST, F_BID, F_ASK, F_BID_SZ, F_ASK_SZ) + qs = "conids=%s&fields=%s" % ( + ",".join(str(c) for c in conids), ",".join(fields)) + rows = _req("GET", "/iserver/marketdata/snapshot?" + qs) or [] + out = {} + def fnum(v): + try: + return float(v) + except (TypeError, ValueError): + return None + for r in rows: + cid = str(r.get("conid")) + out[cid] = { + "last": fnum(r.get(F_LAST)), + "bid": fnum(r.get(F_BID)), + "ask": fnum(r.get(F_ASK)), + "bid_size": fnum(r.get(F_BID_SZ)), + "ask_size": fnum(r.get(F_ASK_SZ)), + "raw": r, + } + return out + + +def search(symbol, sec_type=None): + """Find conids by symbol (e.g. 'BTC'). Returns list of contract candidates. + For ForecastEx event contracts, secType may be 'OPT' (modeled as options) + or 'EVENT' depending on Gateway version — check the result.""" + qs = "symbol=" + symbol + if sec_type: + qs += "&secType=" + sec_type + res = _req("GET", "/iserver/secdef/search?" + qs) or [] + return res diff --git a/arb/scan.py b/arb/scan.py index 782ce9558..afe9b2383 100644 --- a/arb/scan.py +++ b/arb/scan.py @@ -16,6 +16,9 @@ _HLOCK = threading.Lock() _PCACHE = {"ts": 0.0, "payload": None} _PLOCK = threading.Lock() _POS_TTL = 12 +_DCACHE = {"ts": 0.0, "payload": None} +_DLOCK = threading.Lock() +_DAILY_TTL = 6 _CACHE_TTL = 4 # seconds; just enough to dedupe rapid refreshes @@ -146,3 +149,17 @@ def run_positions(force=False): _PCACHE["ts"] = now _PCACHE["payload"] = payload return payload + + +def run_daily(force=False): + """IBKR × Kalshi daily-BTC cross-reference. Cached for _DAILY_TTL.""" + from . import daily + with _DLOCK: + now = time.time() + if (not force) and _DCACHE["payload"] and \ + (now - _DCACHE["ts"] < _DAILY_TTL): + return _DCACHE["payload"] + payload = daily.run(load_settings()) + _DCACHE["ts"] = now + _DCACHE["payload"] = payload + return payload diff --git a/data/ibkr_contracts.example.json b/data/ibkr_contracts.example.json new file mode 100644 index 000000000..cbdcb59fb --- /dev/null +++ b/data/ibkr_contracts.example.json @@ -0,0 +1,19 @@ +{ + "_comment": "Copy to data/ibkr_contracts.json and fill in your own IBKR ForecastEx daily-BTC conids. data/ibkr_contracts.json is git-ignored. Find conids via the IBKR Client Portal Gateway once it's auth'd (search 'BTC' under ForecastEx / event contracts) or copy them from the IBKR Web UI. `close_iso` MUST be the UTC ISO timestamp the IBKR contract settles at (e.g. 5pm ET = 21:00 UTC during EDT). `strike` is the dollar level the contract is 'or above'. Add one entry per IBKR contract you want to watch; the scanner pairs each to the Kalshi KXBTCD market closing at the same instant with nearest strike.", + "contracts": [ + { + "asset": "BTC", + "conid": 12345678, + "strike": 85000, + "close_iso": "2026-05-23T21:00:00Z", + "label": "BTC > $85,000 @ 5pm ET, 5/23" + }, + { + "asset": "BTC", + "conid": 12345679, + "strike": 86000, + "close_iso": "2026-05-23T21:00:00Z", + "label": "BTC > $86,000 @ 5pm ET, 5/23" + } + ] +} diff --git a/data/settings.json b/data/settings.json index b97955bae..b31e1ffba 100644 --- a/data/settings.json +++ b/data/settings.json @@ -1,7 +1,7 @@ { "kalshi_fee_rate": 0.07, "poly_fee_rate": 0.072, - "min_net_return": 0.002, + "min_net_return": 0.001, "min_poly_volume": 100.0, "min_contracts": 1.0 } \ No newline at end of file diff --git a/server.py b/server.py index 652bbf848..70fa64fc3 100644 --- a/server.py +++ b/server.py @@ -55,9 +55,10 @@ class Handler(BaseHTTPRequestHandler): u = urlparse(self.path) if u.path in _STATIC: return self._static(u.path) - if u.path in ("/api/scan", "/api/scan/hourly"): + if u.path in ("/api/scan", "/api/scan/hourly", "/api/scan/daily"): force = parse_qs(u.query).get("force", ["0"])[0] == "1" - runner = (scan.run_hourly if u.path.endswith("hourly") + runner = (scan.run_daily if u.path.endswith("daily") + else scan.run_hourly if u.path.endswith("hourly") else scan.run_scan) try: return self._send(200, runner(force=force)) diff --git a/web/app.js b/web/app.js index cabefa31b..cefcb63a1 100644 --- a/web/app.js +++ b/web/app.js @@ -34,10 +34,25 @@ const COLS_HOURLY = [ { k: "poly_volume", t: "P Vol", f: "money" }, { k: "status", t: "Status", align: "l", f: "status" }, ]; +const COLS_DAILY = [ + { k: "_mkt", t: "Market", align: "l", sort: "ibkr_label" }, + { k: "ibkr_strike", t: "IBKR Strike",f: "strike" }, + { k: "kalshi_strike", t: "K Strike", f: "strike" }, + { k: "basis_pct", t: "Basis", f: "pct" }, + { k: "best_side", t: "Side", align: "l" }, + { k: "_px", t: "K / I px", align: "l", sort: "combined_cost" }, + { k: "combined_cost", t: "Comb $", f: "px" }, + { k: "worst_pnl", t: "Min $/ct", f: "s4" }, + { k: "net_return", t: "Net Ret", f: "pctBig" }, + { k: "_settle", t: "Settle", align: "l" }, + { k: "status", t: "Status", align: "l", f: "status" }, +]; let MODE = "monthly", POS_VIEW = "venue"; -const COLS = () => (MODE === "hourly" ? COLS_HOURLY : COLS_MONTHLY); +const COLS = () => MODE === "daily" ? COLS_DAILY + : MODE === "hourly" ? COLS_HOURLY : COLS_MONTHLY; const endpoint = () => MODE === "positions" ? "/api/positions" + : MODE === "daily" ? "/api/scan/daily" : MODE === "hourly" ? "/api/scan/hourly" : "/api/scan"; let RAW = [], sortKey = "net_return", sortAsc = false, openKey = null; @@ -84,26 +99,39 @@ function thumb(r, sz) { } function cellMarket(r) { - const title = r.kalshi_title || r.poly_question || + const title = r.ibkr_label || r.kalshi_title || r.poly_question || `${r.asset} ${r.direction} ${r.kalshi_strike ?? ""}`; - const strikes = `K $${r.kalshi_strike ?? "—"} · P $${r.poly_strike ?? "—"}`; + const secondLabel = (MODE === "daily") + ? `K $${r.kalshi_strike ?? "—"} · I $${r.ibkr_strike ?? "—"}` + : `K $${r.kalshi_strike ?? "—"} · P $${r.poly_strike ?? "—"}`; return `
${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)}
`; @@ -243,10 +271,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)}`; @@ -267,6 +298,10 @@ function renderSummary(s) { ["ARB", "Edges", "s-arb"], ["NO ARB", "No edge", ""], ["BAD BASIS", "Bad basis", "s-bad"], ["NO DATA", "No data", ""], ["total", "Assets", ""], + ] : MODE === "daily" ? [ + ["ARB", "Live arbs", "s-arb"], ["NO ARB", "No edge", ""], + ["BAD BASIS", "Bad basis", "s-bad"], ["NO KALSHI", "No K match", ""], + ["total", "Contracts", ""], ] : [ ["ARB", "Live arbs", "s-arb"], ["NO ARB", "No edge", ""], ["BAD BASIS", "Bad basis", "s-bad"], ["LOW SIZE", "Low size", "s-warn"], @@ -516,6 +551,31 @@ function applyChrome() { document.querySelector("main").hidden = !scanner; $("positions").hidden = scanner; if (!scanner) { $("hero").hidden = true; $("hbanner").hidden = true; } + if (MODE !== "daily") $("ibkrSetup").hidden = true; +} + +function showIbkrSetup(payload) { + const reason = payload.reason || ""; + const gw = payload.gateway || {}; + const titleEl = $("ibkrSetupTitle"); + const msgEl = $("ibkrSetupMsg"); + if (reason === "no_contracts_file") { + titleEl.textContent = "No IBKR contracts configured."; + msgEl.innerHTML = ` Copy 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) { @@ -532,15 +592,31 @@ async function load(force) { renderPositions(d); $("meta").textContent = `${d.generated_at} · ${d.totals.open_positions} open`; + } else if (MODE === "daily" && d.configured === false) { + RAW = []; + renderSummary({ total: 0 }); + buildAssetChips(); + renderHero(RAW); + renderBody(); + showIbkrSetup(d); + $("meta").textContent = "IBKR not connected"; } else { - RAW = d.rows; - renderSummary(d.summary); + RAW = d.rows || []; + renderSummary(d.summary || { total: RAW.length }); buildAssetChips(); renderHero(RAW); renderBody(); updateBanner(d.summary); - const unit = MODE === "hourly" ? "assets" : "pairs"; - $("meta").textContent = `${d.generated_at} · ${d.summary.total} ${unit}`; + if (MODE === "daily") { + $("ibkrSetup").hidden = false; + $("ibkrSetupTitle").textContent = "IBKR connected."; + $("ibkrSetupMsg").innerHTML = + ' Local-only feature; updates as long as the Client Portal Gateway is running and authenticated. '; + } + const unit = MODE === "hourly" ? "assets" + : MODE === "daily" ? "pairs" : "pairs"; + const ga = d.generated_at || "—"; + $("meta").textContent = `${ga} · ${RAW.length} ${unit}`; } } catch (e) { toast((MODE === "positions" ? "Load" : "Scan") + " failed: " + e.message, "err"); diff --git a/web/index.html b/web/index.html index 4c9db27a9..008053d25 100644 --- a/web/index.html +++ b/web/index.html @@ -22,6 +22,7 @@
+
@@ -80,6 +81,15 @@ + +