fix: add health checks and repair dashboard contracts
This commit is contained in:
+44
-32
@@ -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>"""
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
Reference in New Issue
Block a user