From c70f58a572f70a4e6325139afd33e9d1b682f12f Mon Sep 17 00:00:00 2001 From: Harwinder Sekhon Date: Thu, 6 Aug 2026 22:35:13 -0400 Subject: [PATCH] =?UTF-8?q?dashboard:=20cut=20page=20sync=20latency=20to?= =?UTF-8?q?=20~scan-time=20+=20=E2=89=A45=20s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three display-side fixes, no signal-logic changes: - run_cycle publishes snapshot.json BEFORE the news refresh — news I/O (FF + 4 RSS + possible DeepSeek calls) took 20-30 s on TTL-expired cycles and kept fresh signals off the page (publish_all split into publish_snapshot/publish_news, kept for boot) - page polls every 5 s (was 20 s) using conditional GETs: dropped the ?t= cache-buster + no-store so Apache answers unchanged files with bodyless 304s - boundary refetch sniper re-armed: fires 2 s after next_scan_epoch regardless of distance (the old delay Claude-Session: https://claude.ai/code/session_017mbXs9HRawZhD1uZ3ze7zQ --- dashboard_publisher.py | 21 ++++++++++++++++----- static/dashboard.html | 17 ++++++++++++----- test_all.py | 15 +++++++++++++++ 3 files changed, 43 insertions(+), 10 deletions(-) diff --git a/dashboard_publisher.py b/dashboard_publisher.py index 17723d6..20b4cd4 100644 --- a/dashboard_publisher.py +++ b/dashboard_publisher.py @@ -371,18 +371,29 @@ class DashboardPublisher: except Exception: logger.exception("news refresh failed — keeping last payload") - def publish_all(self) -> None: - now = datetime.now(timezone.utc) - publish(build_snapshot_payload(self.snapshots, now), - os.path.join(self.out_dir, SNAPSHOT_FILE)) + def publish_snapshot(self) -> None: + publish(build_snapshot_payload( + self.snapshots, datetime.now(timezone.utc)), + os.path.join(self.out_dir, SNAPSHOT_FILE)) + + def publish_news(self) -> None: if self.news_payload is not None: publish(self.news_payload, os.path.join(self.out_dir, NEWS_FILE)) + def publish_all(self) -> None: + self.publish_snapshot() + self.publish_news() + def run_cycle(self, tfs: List[Timeframe]) -> None: for tf in tfs: # sequential — see module docstring (cache dedup) self.scan_tf(tf) + # Snapshot lands BEFORE the news refresh: when the news TTL has + # expired, refresh_news does FF + 4 RSS fetches (and possibly + # DeepSeek calls) — 20-30 s that must not keep fresh signals off + # the page. + self.publish_snapshot() self.refresh_news() - self.publish_all() + self.publish_news() def run_forever(self) -> None: self.run_cycle(DASHBOARD_TFS) # populate the page at boot diff --git a/static/dashboard.html b/static/dashboard.html index cd0e476..e84b582 100644 --- a/static/dashboard.html +++ b/static/dashboard.html @@ -400,7 +400,9 @@ const IND_SHORT = {ema_trend:"EMA", rsi:"RSI", macd:"MACD", bollinger:"BB", const ARROW = {STRONG_BUY:"⇈", BUY:"↑", NEUTRAL:"→", SELL:"↓", STRONG_SELL:"⇊"}; const TF_LABEL = {"5min":"M5", "15min":"M15", "1h":"H1", "4h":"H4"}; const TF_ORDER = ["5min","15min","1h","4h"]; -const POLL_MS = 20000; +/* 5 s polling is cheap because fetchJSON uses conditional GETs — Apache + answers unchanged files with 304, so most polls transfer no body. */ +const POLL_MS = 5000; let snapshot = null, news = null; let clockOffset = 0; // serverEpoch - localEpoch @@ -699,7 +701,10 @@ function renderWire() { /* ── polling ──────────────────────────────────────────────────── */ async function fetchJSON(name) { - const res = await fetch(`${name}?t=${Date.now()}`, {cache: "no-store"}); + /* no-cache = revalidate with the server every time (If-Modified-Since); + unchanged files come back 304 with no body, so aggressive polling + stays nearly free. The old ?t= cache-buster forced full downloads. */ + const res = await fetch(name, {cache: "no-cache"}); if (!res.ok) throw new Error(`${name}: HTTP ${res.status}`); /* The Date header is stamped at RESPONSE time — snapshot.server_time_epoch is stamped at publish time, minutes stale between candle closes. */ @@ -724,13 +729,15 @@ async function poll() { tick(); // countdowns appear immediately, not after the first 1 s tick /* one-shot refetch shortly after the next expected publish, so a - fresh scan appears within seconds of the candle close */ + fast scan appears ~2 s after the publisher wakes instead of + waiting out the poll interval (the regular POLL_MS loop covers + slower scans) */ const nextScan = Math.min(...TF_ORDER.map(tf => snapshot.timeframes[tf]?.next_scan_epoch || Infinity)); if (isFinite(nextScan)) { clearTimeout(refetchTimer); - const delay = (nextScan - nowServer() + 6) * 1000; - if (delay > 0 && delay < POLL_MS) refetchTimer = setTimeout(poll, delay); + const delay = (nextScan - nowServer() + 2) * 1000; + if (delay > 0) refetchTimer = setTimeout(poll, delay); } } catch (err) { const last = lastPollOk ? ` Last update ${lastPollOk.toLocaleTimeString()}.` : ""; diff --git a/test_all.py b/test_all.py index 59f0986..594118f 100644 --- a/test_all.py +++ b/test_all.py @@ -5469,6 +5469,21 @@ def _(): assert os.listdir(tmp) == ["snapshot.json"] # no .tmp left +@test("dashboard: run_cycle publishes snapshot BEFORE news refresh") +def _(): + # News I/O (FF + RSS + DeepSeek) can take 20-30 s — fresh signals + # must reach the page first (2026-08-06 sync-latency fix). + pub = dpub.DashboardPublisher(out_dir="/nonexistent") + order = [] + pub.scan_tf = lambda tf: order.append(f"scan:{tf.value}") + pub.publish_snapshot = lambda: order.append("snapshot") + pub.refresh_news = lambda: order.append("news_refresh") + pub.publish_news = lambda: order.append("news_publish") + pub.run_cycle([Timeframe.M5]) + assert order == ["scan:5min", "snapshot", "news_refresh", + "news_publish"], order + + @test("dashboard: seconds_until_boundary bounds and constants") def _(): for interval in (5, 15, 60, 240):