fix: add health checks and repair dashboard contracts

This commit is contained in:
Hermes Agent
2026-07-26 23:15:37 +00:00
parent f9e992c2b4
commit b06cabf3aa
2 changed files with 73 additions and 32 deletions
+44 -32
View File
@@ -118,6 +118,26 @@ def save_cache(data):
atomic_write_json(CACHE_PATH, 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): def append_history(score_data):
"""Append a daily score entry to history.""" """Append a daily score entry to history."""
entry = { entry = {
@@ -982,8 +1002,8 @@ function highlightMetricPeriods(metricKey, currentRaw, margin) {
// Build an array of {date, rawValue} for the selected metric // Build an array of {date, rawValue} for the selected metric
const metricSeries = fullDailyScores const metricSeries = fullDailyScores
.filter(d => d.metrics && d.metrics[metricKey] != null) .filter(d => d.metric_values && d.metric_values[metricKey] != null)
.map(d => ({ date: d.date, value: d.metrics[metricKey], isSimilar: Math.abs(d.metrics[metricKey] - currentRaw) <= margin })); .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 // Store for use in chart rendering
window._highlightMetric = { key: metricKey, series: metricSeries, currentRaw, margin }; 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 <strong>' + ctx.current_score + '</strong> is in the <strong style="color:#22d3ee">top ' + (100 - ctx.percentile).toFixed(1) + '%</strong> historically.<br>';
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('<strong style="color:' + cR(val) + '">' + label + ': ' + fmtR(val) + '</strong>');
}
if (parts.length) html += 'Average returns from this level: ' + parts.join(' · ');
txt.innerHTML = html;
el.style.display = 'block';
}
async function loadBacktestChart() { async function loadBacktestChart() {
try { try {
const r = await fetch('/api/backtest?mode=' + currentMode); const r = await fetch('/api/backtest?mode=' + currentMode);
@@ -1215,6 +1255,7 @@ async function loadBacktestChart() {
fullDailyScores = data.chart_data; fullDailyScores = data.chart_data;
applyChartRange(currentRange); applyChartRange(currentRange);
} }
renderHistoricalContext(data.current_context);
} catch(e) { console.error('Backtest chart load failed:', e); } } catch(e) { console.error('Backtest chart load failed:', e); }
} }
@@ -1343,35 +1384,6 @@ function setMode(mode) {
drawScoreRing(0); drawScoreRing(0);
poll(); poll();
setInterval(poll, 30000); 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 <strong>' + ctx.current_score + '</strong> is in the <strong style="color:#22d3ee">top ' + (100 - ctx.percentile).toFixed(1) + '%</strong> historically.<br>';
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('<strong style="color:' + cR(val) + '">' + label + ': ' + fmtR(val) + '</strong>');
}
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 */ }
})();
</script> </script>
</body> </body>
</html>""" </html>"""
+29
View File
@@ -21,6 +21,35 @@ def test_server_import_does_not_start_scheduler_threads(server):
assert server._threads_started_during_import == [] 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): 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, "CACHE_PATH", str(tmp_path / "cache.json"))
monkeypatch.setattr(server, "HISTORY_PATH", str(tmp_path / "scores.jsonl")) monkeypatch.setattr(server, "HISTORY_PATH", str(tmp_path / "scores.jsonl"))