dashboard: cut page sync latency to ~scan-time + ≤5 s
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<POLL_MS guard would have been dead code at 5 s polling) New Module 20 test locks the snapshot-before-news ordering. 352 tests. Claude-Session: https://claude.ai/code/session_017mbXs9HRawZhD1uZ3ze7zQ
This commit is contained in:
+16
-5
@@ -371,18 +371,29 @@ class DashboardPublisher:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("news refresh failed — keeping last payload")
|
logger.exception("news refresh failed — keeping last payload")
|
||||||
|
|
||||||
def publish_all(self) -> None:
|
def publish_snapshot(self) -> None:
|
||||||
now = datetime.now(timezone.utc)
|
publish(build_snapshot_payload(
|
||||||
publish(build_snapshot_payload(self.snapshots, now),
|
self.snapshots, datetime.now(timezone.utc)),
|
||||||
os.path.join(self.out_dir, SNAPSHOT_FILE))
|
os.path.join(self.out_dir, SNAPSHOT_FILE))
|
||||||
|
|
||||||
|
def publish_news(self) -> None:
|
||||||
if self.news_payload is not None:
|
if self.news_payload is not None:
|
||||||
publish(self.news_payload, os.path.join(self.out_dir, NEWS_FILE))
|
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:
|
def run_cycle(self, tfs: List[Timeframe]) -> None:
|
||||||
for tf in tfs: # sequential — see module docstring (cache dedup)
|
for tf in tfs: # sequential — see module docstring (cache dedup)
|
||||||
self.scan_tf(tf)
|
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.refresh_news()
|
||||||
self.publish_all()
|
self.publish_news()
|
||||||
|
|
||||||
def run_forever(self) -> None:
|
def run_forever(self) -> None:
|
||||||
self.run_cycle(DASHBOARD_TFS) # populate the page at boot
|
self.run_cycle(DASHBOARD_TFS) # populate the page at boot
|
||||||
|
|||||||
+12
-5
@@ -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 ARROW = {STRONG_BUY:"⇈", BUY:"↑", NEUTRAL:"→", SELL:"↓", STRONG_SELL:"⇊"};
|
||||||
const TF_LABEL = {"5min":"M5", "15min":"M15", "1h":"H1", "4h":"H4"};
|
const TF_LABEL = {"5min":"M5", "15min":"M15", "1h":"H1", "4h":"H4"};
|
||||||
const TF_ORDER = ["5min","15min","1h","4h"];
|
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 snapshot = null, news = null;
|
||||||
let clockOffset = 0; // serverEpoch - localEpoch
|
let clockOffset = 0; // serverEpoch - localEpoch
|
||||||
@@ -699,7 +701,10 @@ function renderWire() {
|
|||||||
|
|
||||||
/* ── polling ──────────────────────────────────────────────────── */
|
/* ── polling ──────────────────────────────────────────────────── */
|
||||||
async function fetchJSON(name) {
|
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}`);
|
if (!res.ok) throw new Error(`${name}: HTTP ${res.status}`);
|
||||||
/* The Date header is stamped at RESPONSE time — snapshot.server_time_epoch
|
/* The Date header is stamped at RESPONSE time — snapshot.server_time_epoch
|
||||||
is stamped at publish time, minutes stale between candle closes. */
|
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
|
tick(); // countdowns appear immediately, not after the first 1 s tick
|
||||||
|
|
||||||
/* one-shot refetch shortly after the next expected publish, so a
|
/* 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 =>
|
const nextScan = Math.min(...TF_ORDER.map(tf =>
|
||||||
snapshot.timeframes[tf]?.next_scan_epoch || Infinity));
|
snapshot.timeframes[tf]?.next_scan_epoch || Infinity));
|
||||||
if (isFinite(nextScan)) {
|
if (isFinite(nextScan)) {
|
||||||
clearTimeout(refetchTimer);
|
clearTimeout(refetchTimer);
|
||||||
const delay = (nextScan - nowServer() + 6) * 1000;
|
const delay = (nextScan - nowServer() + 2) * 1000;
|
||||||
if (delay > 0 && delay < POLL_MS) refetchTimer = setTimeout(poll, delay);
|
if (delay > 0) refetchTimer = setTimeout(poll, delay);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const last = lastPollOk ? ` Last update ${lastPollOk.toLocaleTimeString()}.` : "";
|
const last = lastPollOk ? ` Last update ${lastPollOk.toLocaleTimeString()}.` : "";
|
||||||
|
|||||||
+15
@@ -5469,6 +5469,21 @@ def _():
|
|||||||
assert os.listdir(tmp) == ["snapshot.json"] # no .tmp left
|
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")
|
@test("dashboard: seconds_until_boundary bounds and constants")
|
||||||
def _():
|
def _():
|
||||||
for interval in (5, 15, 60, 240):
|
for interval in (5, 15, 60, 240):
|
||||||
|
|||||||
Reference in New Issue
Block a user