From b06cabf3aa9c971b5b460c057f49b125d95ad026 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 26 Jul 2026 23:15:37 +0000 Subject: [PATCH] fix: add health checks and repair dashboard contracts --- dashboard/server.py | 76 ++++++++++++++++++-------------- tests/test_server_reliability.py | 29 ++++++++++++ 2 files changed, 73 insertions(+), 32 deletions(-) diff --git a/dashboard/server.py b/dashboard/server.py index e6109ae..8ab16a8 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -118,6 +118,26 @@ def save_cache(data): atomic_write_json(CACHE_PATH, data) +@app.get("/health/live") +def health_live(): + """Report that the API process is responsive.""" + return {"status": "ok"} + + +@app.get("/health/ready") +def health_ready(): + """Report readiness only after a usable score has been persisted.""" + scored = load_cache().get("_scored", {}) + score = scored.get("composite_score") + count = scored.get("scored_count", 0) + if score is None or count < 1: + return JSONResponse( + {"status": "not_ready", "reason": "no usable persisted score"}, + status_code=503, + ) + return {"status": "ready", "score": score, "scored_metrics": count} + + def append_history(score_data): """Append a daily score entry to history.""" entry = { @@ -982,8 +1002,8 @@ function highlightMetricPeriods(metricKey, currentRaw, margin) { // Build an array of {date, rawValue} for the selected metric const metricSeries = fullDailyScores - .filter(d => d.metrics && d.metrics[metricKey] != null) - .map(d => ({ date: d.date, value: d.metrics[metricKey], isSimilar: Math.abs(d.metrics[metricKey] - currentRaw) <= margin })); + .filter(d => d.metric_values && d.metric_values[metricKey] != null) + .map(d => ({ date: d.date, value: d.metric_values[metricKey], isSimilar: Math.abs(d.metric_values[metricKey] - currentRaw) <= margin })); // Store for use in chart rendering window._highlightMetric = { key: metricKey, series: metricSeries, currentRaw, margin }; @@ -1206,7 +1226,27 @@ function renderHistoryFromData(history) { }); } -// Load backtest daily scores for the chart +// Load backtest daily scores and historical context with one request. +function renderHistoricalContext(ctx) { + if (!ctx) return; + const el = document.getElementById('histContext'); + const txt = document.getElementById('histContextText'); + let html = 'Score ' + ctx.current_score + ' is in the top ' + (100 - ctx.percentile).toFixed(1) + '% historically.
'; + const fmtR = (v) => v == null ? null : (v >= 0 ? '+' : '') + v.toFixed(1) + '%'; + const cR = (v) => v >= 0 ? '#22c55e' : '#ef4444'; + const periods = [ + ['30d', ctx.avg_30d_return], ['90d', ctx.avg_90d_return], + ['180d', ctx.avg_180d_return], ['1yr', ctx.avg_1yr_return] + ]; + const parts = []; + for (const [label, val] of periods) { + if (val != null) parts.push('' + label + ': ' + fmtR(val) + ''); + } + if (parts.length) html += 'Average returns from this level: ' + parts.join(' · '); + txt.innerHTML = html; + el.style.display = 'block'; +} + async function loadBacktestChart() { try { const r = await fetch('/api/backtest?mode=' + currentMode); @@ -1215,6 +1255,7 @@ async function loadBacktestChart() { fullDailyScores = data.chart_data; applyChartRange(currentRange); } + renderHistoricalContext(data.current_context); } catch(e) { console.error('Backtest chart load failed:', e); } } @@ -1343,35 +1384,6 @@ function setMode(mode) { drawScoreRing(0); poll(); setInterval(poll, 30000); - -// Load historical context from backtest -(async function() { - try { - const r = await fetch('/api/backtest/status'); - const s = await r.json(); - if (!s.exists) return; - const br = await fetch('/api/backtest'); - const bt = await br.json(); - if (bt.error || !bt.current_context) return; - const ctx = bt.current_context; - const el = document.getElementById('histContext'); - const txt = document.getElementById('histContextText'); - let html = 'Score ' + ctx.current_score + ' is in the top ' + (100 - ctx.percentile).toFixed(1) + '% historically.
'; - const fmtR = (v) => v == null ? null : (v >= 0 ? '+' : '') + v.toFixed(1) + '%'; - const cR = (v) => v >= 0 ? '#22c55e' : '#ef4444'; - const periods = [ - ['30d', ctx.avg_30d_return], ['90d', ctx.avg_90d_return], - ['180d', ctx.avg_180d_return], ['1yr', ctx.avg_1yr_return] - ]; - let parts = []; - for (const [label, val] of periods) { - if (val != null) parts.push('' + label + ': ' + fmtR(val) + ''); - } - if (parts.length) html += 'Average returns from this level: ' + parts.join(' · '); - txt.innerHTML = html; - el.style.display = 'block'; - } catch(e) { /* backtest data not available yet */ } -})(); """ diff --git a/tests/test_server_reliability.py b/tests/test_server_reliability.py index 93c4065..548c002 100644 --- a/tests/test_server_reliability.py +++ b/tests/test_server_reliability.py @@ -21,6 +21,35 @@ def test_server_import_does_not_start_scheduler_threads(server): assert server._threads_started_during_import == [] +def test_frontend_uses_backtest_metric_values_contract(server): + assert ".filter(d => d.metric_values && d.metric_values[metricKey] != null)" in server.DASHBOARD_HTML + assert ".map(d => ({ date: d.date, value: d.metric_values[metricKey]" in server.DASHBOARD_HTML + + +def test_dashboard_does_not_issue_duplicate_initial_backtest_request(server): + assert "const br = await fetch('/api/backtest');" not in server.DASHBOARD_HTML + assert server.DASHBOARD_HTML.count("fetch('/api/backtest?mode=' + currentMode)") == 1 + + +def test_health_endpoints_distinguish_process_liveness_from_data_readiness(server, monkeypatch): + assert server.health_live() == {"status": "ok"} + + monkeypatch.setattr(server, "load_cache", lambda: {}) + unavailable = server.health_ready() + assert unavailable.status_code == 503 + + monkeypatch.setattr( + server, + "load_cache", + lambda: {"_scored": {"composite_score": 72, "scored_count": 8}}, + ) + assert server.health_ready() == { + "status": "ready", + "score": 72, + "scored_metrics": 8, + } + + def test_server_cache_and_history_use_reliable_persistence(server, monkeypatch, tmp_path): monkeypatch.setattr(server, "CACHE_PATH", str(tmp_path / "cache.json")) monkeypatch.setattr(server, "HISTORY_PATH", str(tmp_path / "scores.jsonl"))