stock-scraper / api /dashboard.py
sbasu2512's picture
fix dashboard bugs introduced by codex
c720b8d
Raw
History Blame Contribute Delete
36.5 kB
from __future__ import annotations
import html
import os
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
from app.config import settings
from database.connection import connect
from database.metrics_repository import fetch_resolved_rows
from database.resolution_repository import fetch_future_ohlcv
from evaluation.metrics import calculate_metrics
router = APIRouter(tags=["dashboard"])
@router.get("/resolution-status")
def resolution_status():
"""Return the latest durable resolution progress for dashboard polling."""
with connect(settings.DATABASE_URL) as conn, conn.cursor() as cur:
cur.execute(
"""
SELECT COUNT(*) AS total,
SUM(CASE WHEN resolved_at IS NOT NULL THEN 1 ELSE 0 END) AS resolved,
SUM(CASE WHEN resolved_at IS NULL THEN 1 ELSE 0 END) AS pending
FROM predictions
"""
)
counts = cur.fetchone()
cur.execute(
"""
SELECT status,
CAST(started_at AS TEXT) AS started_at,
CAST(finished_at AS TEXT) AS finished_at,
CAST(last_updated_at AS TEXT) AS last_updated_at,
total_rows, rows_processed, resolved_rows, failed_rows
FROM job_runs
WHERE job_name = 'resolution'
ORDER BY id DESC
LIMIT 1
"""
)
run = cur.fetchone()
cur.execute(
"""
SELECT MAX(CAST(value AS TEXT)) AS revision FROM (
SELECT MAX(CAST(prediction_timestamp AS TEXT)) AS value FROM predictions
UNION ALL SELECT MAX(CAST(resolved_at AS TEXT)) FROM predictions
UNION ALL SELECT MAX(CAST(calculated_at AS TEXT)) FROM prediction_metrics
UNION ALL SELECT MAX(CAST(COALESCE(last_updated_at, finished_at, started_at) AS TEXT)) FROM job_runs
)
"""
)
revision = cur.fetchone()["revision"]
total = int(counts["total"] or 0)
resolved = int(counts["resolved"] or 0)
pending = int(counts["pending"] or 0)
if run is None:
return {
"status": "IDLE",
"processed": 0,
"total": total,
"resolved": resolved,
"pending": pending,
"failed": 0,
"started_at": None,
"last_updated_at": None,
"revision": revision,
}
return {
"status": run["status"],
"processed": int(run["rows_processed"] or 0),
"total": int(run["total_rows"] or total),
"resolved": resolved,
"pending": pending,
"failed": int(run["failed_rows"] or 0),
"started_at": run["started_at"],
"last_updated_at": run["last_updated_at"] or run["finished_at"] or run["started_at"],
"revision": revision,
}
PREDICTION_COLUMNS = """
id,
prediction_date,
symbol,
predicted_probability,
rank,
prediction_close,
target_threshold,
target_horizon_days,
entry_date,
entry_open,
max_close_5d,
evaluation_end_date,
actual_return,
actual_label,
resolved_at
"""
# ─────────────────────────────────────────────
# Data access
# ─────────────────────────────────────────────
def _fetch_predictions(
conn,
*,
resolved: bool | None = None,
limit: int = 1000,
):
where = ""
if resolved is True:
where = "WHERE actual_label IS NOT NULL"
elif resolved is False:
where = "WHERE actual_label IS NULL"
with conn.cursor() as cur:
cur.execute(
f"""
SELECT {PREDICTION_COLUMNS}
FROM predictions
{where}
ORDER BY prediction_date DESC, rank ASC
LIMIT ?
""",
(limit,),
)
return cur.fetchall()
def _fetch_prediction(conn, prediction_id: int):
with conn.cursor() as cur:
cur.execute(
f"""
SELECT {PREDICTION_COLUMNS}
FROM predictions
WHERE id = ?
""",
(prediction_id,),
)
return cur.fetchone()
def _fetch_default_prediction(conn):
with conn.cursor() as cur:
cur.execute(
f"""
SELECT {PREDICTION_COLUMNS}
FROM predictions
ORDER BY prediction_date DESC, rank ASC
LIMIT 1
"""
)
return cur.fetchone()
def _select_prediction(conn, prediction_id: int | None):
if prediction_id is not None:
row = _fetch_prediction(conn, prediction_id)
if row is not None:
return row
return _fetch_default_prediction(conn)
# ─────────────────────────────────────────────
# Small SVG primitives
# ─────────────────────────────────────────────
def _rounded_top_rect(
x: float,
y: float,
w: float,
h: float,
r: float = 4,
) -> str:
if h <= 0:
return ""
r = min(r, h, w / 2)
bottom = y + h
return (
f'<path d="M{x},{y + r} Q{x},{y} {x + r},{y} '
f'L{x + w - r},{y} Q{x + w},{y} {x + w},{y + r} '
f'L{x + w},{bottom} L{x},{bottom} Z" '
f'fill="var(--series-1)" />'
)
def _empty_chart_message(
width: int,
height: int,
message: str,
) -> str:
return (
f'<svg viewBox="0 0 {width} {height}" '
f'width="{width}" height="{height}" '
f'role="img" aria-label="{html.escape(message)}">'
f'<rect x="0" y="0" width="{width}" height="{height}" '
f'fill="var(--surface-1)" />'
f'<text x="{width / 2}" y="{height / 2}" '
f'text-anchor="middle" fill="var(--text-muted)" '
f'font-size="13">{html.escape(message)}</text>'
f'</svg>'
)
# ─────────────────────────────────────────────
# Calibration
# ─────────────────────────────────────────────
def _build_calibration_buckets(rows) -> list[dict]:
"""
Build 10 probability buckets.
D1 = 0-10%
D2 = 10-20%
...
D10 = 90-100%
For each bucket:
count
mean predicted probability
actual positive rate
"""
buckets = [
{
"decile": i + 1,
"count": 0,
"predicted_probability": None,
"positive_rate": None,
}
for i in range(10)
]
for row in rows:
probability = row["predicted_probability"]
actual_label = row["actual_label"]
if probability is None or actual_label is None:
continue
probability = float(probability)
# Protect against numerical values slightly outside [0, 1].
probability = max(0.0, min(1.0, probability))
index = min(int(probability * 10), 9)
bucket = buckets[index]
bucket["count"] += 1
if bucket["predicted_probability"] is None:
bucket["predicted_probability"] = []
bucket["predicted_probability"].append(probability)
if "positives" not in bucket:
bucket["positives"] = 0
bucket["positives"] += int(actual_label)
for bucket in buckets:
count = bucket["count"]
if count:
probabilities = bucket["predicted_probability"]
bucket["predicted_probability"] = (
sum(probabilities) / len(probabilities)
)
bucket["positive_rate"] = (
bucket["positives"] / count
)
else:
bucket["predicted_probability"] = None
bucket["positive_rate"] = None
bucket.pop("positives", None)
return buckets
def _svg_calibration_chart(
deciles: list[dict],
overall_hit_rate: float | None,
) -> str:
if not deciles or not any(d["count"] for d in deciles):
return _empty_chart_message(
720,
300,
"No resolved predictions available for calibration.",
)
width, height = 720, 300
margin_left = 50
margin_right = 20
margin_top = 30
margin_bottom = 55
plot_w = width - margin_left - margin_right
plot_h = height - margin_top - margin_bottom
gap = 8
bar_w = (
plot_w - gap * (len(deciles) - 1)
) / len(deciles)
def y_of(rate: float) -> float:
return (
margin_top
+ plot_h
- rate * plot_h
)
parts = [
f'<svg viewBox="0 0 {width} {height}" '
f'width="{width}" height="{height}" '
f'role="img" '
f'aria-label="Prediction calibration chart">'
]
parts.append(
f'<rect x="0" y="0" width="{width}" height="{height}" '
f'fill="var(--surface-1)" />'
)
# Gridlines.
for pct in (0, 25, 50, 75, 100):
y = y_of(pct / 100)
parts.append(
f'<line x1="{margin_left}" y1="{y}" '
f'x2="{width - margin_right}" y2="{y}" '
f'stroke="var(--gridline)" stroke-width="1" />'
)
parts.append(
f'<text x="{margin_left - 8}" y="{y + 4}" '
f'text-anchor="end" fill="var(--text-muted)" '
f'font-size="11">{pct}%</text>'
)
# Overall hit-rate baseline.
if overall_hit_rate is not None:
y = y_of(overall_hit_rate)
parts.append(
f'<line x1="{margin_left}" y1="{y}" '
f'x2="{width - margin_right}" y2="{y}" '
f'stroke="var(--baseline)" stroke-width="1.5" '
f'stroke-dasharray="5 4" />'
)
parts.append(
f'<text x="{width - margin_right}" y="{y - 7}" '
f'text-anchor="end" fill="var(--text-secondary)" '
f'font-size="11">'
f'overall {overall_hit_rate * 100:.1f}%'
f'</text>'
)
for i, bucket in enumerate(deciles):
x = margin_left + i * (bar_w + gap)
rate = bucket["positive_rate"]
if rate is not None:
bar_h = rate * plot_h
y = margin_top + plot_h - bar_h
parts.append(
_rounded_top_rect(
x,
y,
bar_w,
bar_h,
)
)
parts.append(
f'<text x="{x + bar_w / 2}" '
f'y="{max(y - 7, margin_top + 12)}" '
f'text-anchor="middle" '
f'fill="var(--text-secondary)" '
f'font-size="10">'
f'{rate * 100:.0f}%'
f'</text>'
)
count_text = str(bucket["count"])
else:
count_text = "0"
parts.append(
f'<text x="{x + bar_w / 2}" '
f'y="{margin_top + plot_h - 8}" '
f'text-anchor="middle" '
f'fill="var(--text-muted)" '
f'font-size="11">—</text>'
)
parts.append(
f'<text x="{x + bar_w / 2}" '
f'y="{height - margin_bottom + 18}" '
f'text-anchor="middle" '
f'fill="var(--text-muted)" '
f'font-size="11">'
f'{bucket["decile"] * 10 - 10}–'
f'{bucket["decile"] * 10}%'
f'</text>'
)
parts.append(
f'<text x="{x + bar_w / 2}" '
f'y="{height - margin_bottom + 34}" '
f'text-anchor="middle" '
f'fill="var(--text-muted)" '
f'font-size="9">'
f'n={count_text}'
f'</text>'
)
parts.append(
f'<line x1="{margin_left}" '
f'y1="{margin_top + plot_h}" '
f'x2="{width - margin_right}" '
f'y2="{margin_top + plot_h}" '
f'stroke="var(--axis)" stroke-width="1.5" />'
)
parts.append("</svg>")
return "".join(parts)
# ─────────────────────────────────────────────
# Price trajectory
# ─────────────────────────────────────────────
def _svg_price_trajectory(
prediction: dict,
ohlcv_rows: list[dict],
) -> str:
if not ohlcv_rows:
return _empty_chart_message(
640,
220,
"Awaiting D+1 market data for this prediction.",
)
width, height = 640, 220
margin_left = 48
margin_right = 16
margin_top = 20
margin_bottom = 32
plot_w = width - margin_left - margin_right
plot_h = height - margin_top - margin_bottom
entry_open = ohlcv_rows[0]["open"]
target_close = (
entry_open
* (1 + prediction["target_threshold"])
)
labels = (
["D"]
+ [
f"D+{i + 1}"
for i in range(len(ohlcv_rows))
]
)
values = (
[prediction["prediction_close"]]
+ [row["close"] for row in ohlcv_rows]
)
lo = min(values + [target_close, entry_open])
hi = max(values + [target_close, entry_open])
pad = (hi - lo) * 0.12 or 1.0
lo -= pad
hi += pad
n = len(labels)
step = plot_w / max(n - 1, 1)
def x_of(i: int) -> float:
return margin_left + i * step
def y_of(v: float) -> float:
return (
margin_top
+ plot_h
- (v - lo) / (hi - lo) * plot_h
)
parts = [
f'<svg viewBox="0 0 {width} {height}" '
f'width="{width}" height="{height}" '
f'role="img" '
f'aria-label="Price trajectory for '
f'{html.escape(prediction["symbol"])}">'
]
parts.append(
f'<rect x="0" y="0" width="{width}" height="{height}" '
f'fill="var(--surface-1)" />'
)
# Target.
ty = y_of(target_close)
parts.append(
f'<line x1="{margin_left}" y1="{ty}" '
f'x2="{width - margin_right}" y2="{ty}" '
f'stroke="var(--baseline)" stroke-width="1.5" '
f'stroke-dasharray="4 3" />'
)
parts.append(
f'<text x="{width - margin_right}" y="{ty - 6}" '
f'text-anchor="end" fill="var(--text-secondary)" '
f'font-size="11">'
f'target +{prediction["target_threshold"] * 100:.1f}% '
f'({target_close:.2f})'
f'</text>'
)
# Price line.
points = " ".join(
f"{x_of(i):.1f},{y_of(v):.1f}"
for i, v in enumerate(values)
)
parts.append(
f'<polyline points="{points}" fill="none" '
f'stroke="var(--series-1)" stroke-width="2" '
f'stroke-linecap="round" '
f'stroke-linejoin="round" />'
)
max_idx = max(
range(len(values)),
key=lambda i: values[i],
)
actual_label = prediction["actual_label"]
if actual_label is None:
outcome_color = "var(--text-muted)"
outcome_text = "pending"
elif actual_label == 1:
outcome_color = "var(--good)"
outcome_text = "hit"
else:
outcome_color = "var(--critical)"
outcome_text = "miss"
for i, value in enumerate(values):
is_max = i == max_idx
radius = 6 if is_max else 4
color = (
outcome_color
if is_max
else "var(--series-1)"
)
parts.append(
f'<circle cx="{x_of(i):.1f}" '
f'cy="{y_of(value):.1f}" '
f'r="{radius}" fill="{color}" />'
)
parts.append(
f'<text x="{x_of(i):.1f}" '
f'y="{y_of(value) - 10:.1f}" '
f'text-anchor="middle" '
f'fill="var(--text-secondary)" '
f'font-size="10">'
f'{value:.2f}'
f'</text>'
)
parts.append(
f'<text x="{x_of(i):.1f}" '
f'y="{height - margin_bottom + 16}" '
f'text-anchor="middle" '
f'fill="var(--text-muted)" '
f'font-size="11">'
f'{labels[i]}'
f'</text>'
)
# Entry open.
if len(values) > 1:
ex = x_of(1)
ey = y_of(entry_open)
parts.append(
f'<circle cx="{ex:.1f}" cy="{ey:.1f}" r="5" '
f'fill="none" stroke="var(--text-secondary)" '
f'stroke-width="1.5" />'
)
parts.append(
f'<text x="{ex:.1f}" y="{ey + 18:.1f}" '
f'text-anchor="middle" '
f'fill="var(--text-secondary)" '
f'font-size="10">'
f'entry {entry_open:.2f}'
f'</text>'
)
parts.append(
f'<line x1="{margin_left}" '
f'y1="{margin_top + plot_h}" '
f'x2="{width - margin_right}" '
f'y2="{margin_top + plot_h}" '
f'stroke="var(--axis)" stroke-width="1.5" />'
)
parts.append(
f'<text x="{margin_left}" y="14" '
f'fill="{outcome_color}" font-size="12" '
f'font-weight="600">'
f'{html.escape(outcome_text)}'
f'</text>'
)
parts.append("</svg>")
return "".join(parts)
# ─────────────────────────────────────────────
# Table rendering
# ─────────────────────────────────────────────
def _outcome_style(actual_label) -> str:
if actual_label is None:
return "color:var(--text-muted)"
if int(actual_label) == 1:
return "color:var(--good);font-weight:600"
return "color:var(--critical);font-weight:600"
def _format_value(value, digits: int | None = None):
if value is None:
return "—"
if digits is not None:
try:
return f"{float(value):.{digits}f}"
except (TypeError, ValueError):
pass
return str(value)
def _render_table(
rows,
*,
title: str,
empty_message: str,
) -> str:
columns = [
("prediction_date", "Date"),
("symbol", "Symbol"),
("predicted_probability", "Probability"),
("rank", "Rank"),
("prediction_close", "Prediction close"),
("entry_date", "Entry date"),
("entry_open", "Entry open"),
("target_close", "Target close"),
("max_close_5d", "Max close 5D"),
("actual_return", "Actual return"),
("actual_label", "Outcome"),
("resolved_at", "Resolved"),
]
head = "".join(
f"<th>{html.escape(label)}</th>"
for _, label in columns
)
if not rows:
return f"""
<section class="table-card">
<div class="table-header">
<h2>{html.escape(title)}</h2>
<span class="count-badge">0</span>
</div>
<div class="empty-table">
{html.escape(empty_message)}
</div>
</section>
"""
body_rows = []
for row in rows:
target_close = None
if row["entry_open"] is not None:
target_close = (
float(row["entry_open"])
* (1 + float(row["target_threshold"]))
)
cells = []
for col, _label in columns:
if col == "prediction_date":
value = (
f'<a href="/dashboard?prediction_id='
f'{row["id"]}">'
f'{html.escape(str(row["prediction_date"]))}'
f'</a>'
)
elif col == "symbol":
value = html.escape(str(row["symbol"]))
elif col == "predicted_probability":
value = (
_format_value(
float(row[col]) * 100,
1,
)
+ "%"
if row[col] is not None
else "—"
)
elif col in {
"prediction_close",
"entry_open",
"target_close",
"max_close_5d",
}:
value = _format_value(
target_close
if col == "target_close"
else row[col],
2,
)
elif col == "actual_return":
value = (
_format_value(
float(row[col]) * 100,
2,
)
+ "%"
if row[col] is not None
else "—"
)
elif col == "actual_label":
if row[col] is None:
label = "pending"
elif int(row[col]) == 1:
label = "hit"
else:
label = "miss"
value = (
f'<span style="{_outcome_style(row[col])}">'
f'{label}'
f'</span>'
)
else:
value = html.escape(
_format_value(row[col])
)
cells.append(f"<td>{value}</td>")
body_rows.append(
"<tr>" + "".join(cells) + "</tr>"
)
return f"""
<section class="table-card">
<div class="table-header">
<h2>{html.escape(title)}</h2>
<span class="count-badge">{len(rows)}</span>
</div>
<div class="table-scroll">
<table>
<thead>
<tr>{head}</tr>
</thead>
<tbody>
{"".join(body_rows)}
</tbody>
</table>
</div>
</section>
"""
# ─────────────────────────────────────────────
# Route
# ─────────────────────────────────────────────
@router.get(
"/dashboard",
response_class=HTMLResponse,
)
def dashboard(prediction_id: int | None = None):
threshold = float(
os.getenv("PREDICTION_THRESHOLD", "0.5")
)
with connect(settings.DATABASE_URL) as conn:
# Selected prediction.
selected = _select_prediction(
conn,
prediction_id,
)
trajectory_rows = []
if selected is not None:
trajectory_rows = fetch_future_ohlcv(
conn,
selected["symbol"],
selected["prediction_date"],
selected["target_horizon_days"],
)
# ALL resolved predictions.
resolved_rows = _fetch_predictions(
conn,
resolved=True,
limit=5000,
)
# ALL unresolved/current predictions.
pending_rows = _fetch_predictions(
conn,
resolved=False,
limit=1000,
)
# Metrics/calibration source.
metrics_rows = fetch_resolved_rows(
conn,
days=None,
)
# ─────────────────────────────────────────
# Calibration
# ─────────────────────────────────────────
calibration_rows = [
row
for row in resolved_rows
if row["predicted_probability"] is not None
and row["actual_label"] is not None
]
deciles = _build_calibration_buckets(
calibration_rows
)
if calibration_rows:
overall_hit_rate = sum(
int(row["actual_label"])
for row in calibration_rows
) / len(calibration_rows)
else:
overall_hit_rate = None
calibration_svg = _svg_calibration_chart(
deciles,
overall_hit_rate,
)
# ─────────────────────────────────────────
# Selected prediction chart
# ─────────────────────────────────────────
if selected is not None:
trajectory_svg = _svg_price_trajectory(
selected,
trajectory_rows,
)
trajectory_heading = (
f'{html.escape(selected["symbol"])} '
f'— predicted '
f'{html.escape(str(selected["prediction_date"]))} '
f'(probability '
f'{float(selected["predicted_probability"]) * 100:.1f}%)'
)
else:
trajectory_svg = _empty_chart_message(
640,
220,
"No predictions yet.",
)
trajectory_heading = (
"No prediction selected"
)
# ─────────────────────────────────────────
# Metrics
# ─────────────────────────────────────────
if metrics_rows:
metrics_result = calculate_metrics(
metrics_rows,
threshold=threshold,
)
else:
metrics_result = None
if metrics_result is not None:
total_resolved = len(metrics_rows)
hit_rate = (
metrics_result.hit_rate
if metrics_result.hit_rate is not None
else 0
)
metrics_html = f"""
<div class="metric-grid">
<div class="metric-card">
<div class="metric-label">
Resolved
</div>
<div class="metric-value">
{total_resolved}
</div>
</div>
<div class="metric-card">
<div class="metric-label">
Hit rate
</div>
<div class="metric-value">
{hit_rate * 100:.1f}%
</div>
</div>
<div class="metric-card">
<div class="metric-label">
Prediction threshold
</div>
<div class="metric-value">
{threshold * 100:.0f}%
</div>
</div>
<div class="metric-card">
<div class="metric-label">
Pending
</div>
<div class="metric-value">
{len(pending_rows)}
</div>
</div>
</div>
"""
else:
metrics_html = """
<div class="metric-grid">
<div class="metric-card">
<div class="metric-label">Resolved</div>
<div class="metric-value">0</div>
</div>
<div class="metric-card">
<div class="metric-label">Pending</div>
<div class="metric-value">
0
</div>
</div>
</div>
"""
# ─────────────────────────────────────────
# Tables
# ─────────────────────────────────────────
resolved_table = _render_table(
resolved_rows,
title="Resolved predictions",
empty_message=(
"No predictions have been resolved yet."
),
)
pending_table = _render_table(
pending_rows,
title="Current predictions",
empty_message=(
"There are no unresolved predictions."
),
)
return HTMLResponse(
f"""<!doctype html>
<html>
<head>
<title>Stock signal dashboard</title>
<style>
:root {{
color-scheme: light;
--page-plane: #f9f9f7;
--surface-1: #fcfcfb;
--text-primary: #0b0b0b;
--text-secondary: #52514e;
--text-muted: #898781;
--gridline: #e1e0d9;
--baseline: #c3c2b7;
--axis: #c3c2b7;
--series-1: #2a78d6;
--good: #0ca30c;
--critical: #d03b3b;
--border: rgba(11,11,11,0.10);
}}
@media (prefers-color-scheme: dark) {{
:root:not([data-theme="light"]) {{
color-scheme: dark;
--page-plane: #0d0d0d;
--surface-1: #1a1a19;
--text-primary: #ffffff;
--text-secondary: #c3c2b7;
--text-muted: #898781;
--gridline: #2c2c2a;
--baseline: #383835;
--axis: #383835;
--series-1: #3987e5;
--good: #0ca30c;
--critical: #e66767;
--border: rgba(255,255,255,0.10);
}}
}}
:root[data-theme="dark"] {{
color-scheme: dark;
--page-plane: #0d0d0d;
--surface-1: #1a1a19;
--text-primary: #ffffff;
--text-secondary: #c3c2b7;
--text-muted: #898781;
--gridline: #2c2c2a;
--baseline: #383835;
--axis: #383835;
--series-1: #3987e5;
--good: #0ca30c;
--critical: #e66767;
--border: rgba(255,255,255,0.10);
}}
* {{
box-sizing: border-box;
}}
body {{
font-family:
system-ui,
-apple-system,
"Segoe UI",
sans-serif;
margin: 2rem;
background: var(--page-plane);
color: var(--text-primary);
}}
h1,
h2 {{
color: var(--text-primary);
}}
p {{
color: var(--text-secondary);
}}
a {{
color: var(--series-1);
text-decoration: none;
}}
a:hover {{
text-decoration: underline;
}}
.chart-card {{
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
}}
.chart-card svg {{
display: block;
width: 100%;
height: auto;
}}
.metric-grid {{
display: grid;
grid-template-columns:
repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}}
.metric-card {{
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
}}
.metric-label {{
color: var(--text-muted);
font-size: .8rem;
margin-bottom: .4rem;
}}
.metric-value {{
color: var(--text-primary);
font-size: 1.5rem;
font-weight: 650;
}}
.resolution-card {{
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
max-width: 520px;
}}
.resolution-status {{
color: var(--series-1);
font-size: 1.15rem;
font-weight: 700;
letter-spacing: .04em;
}}
.resolution-progress {{
height: 10px;
margin: .75rem 0;
border-radius: 99px;
overflow: hidden;
background: rgba(128,128,128,.18);
}}
.resolution-progress > div {{
height: 100%;
width: 0;
background: var(--series-1);
transition: width .25s ease;
}}
.resolution-details {{
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: .35rem 1rem;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}}
.table-card {{
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 2rem;
overflow: hidden;
}}
.table-header {{
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
}}
.table-header h2 {{
margin: 0;
}}
.count-badge {{
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 26px;
padding: 0 .6rem;
border-radius: 99px;
background: rgba(128,128,128,.12);
color: var(--text-secondary);
font-size: .8rem;
}}
.table-scroll {{
width: 100%;
overflow-x: auto;
}}
table {{
border-collapse: collapse;
width: 100%;
min-width: 1100px;
background: var(--surface-1);
}}
th,
td {{
padding: .55rem .65rem;
border-top: 1px solid var(--border);
text-align: left;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}}
th {{
background: var(--text-primary);
color: var(--surface-1);
position: sticky;
top: 0;
z-index: 1;
font-size: .8rem;
}}
td {{
color: var(--text-secondary);
font-size: .85rem;
}}
tr:nth-child(even) {{
background: rgba(128,128,128,0.06);
}}
.empty-table {{
padding: 2rem;
color: var(--text-muted);
text-align: center;
}}
.section-description {{
color: var(--text-secondary);
margin-top: -.7rem;
margin-bottom: 1rem;
}}
</style>
</head>
<body>
<h1>Stock signal dashboard</h1>
<p>
Predictions, five-session outcomes, calibration,
and currently unresolved signals.
</p>
<section class="resolution-card" aria-live="polite">
<h2>Prediction resolution</h2>
<div class="resolution-status" id="resolution-status">Loading</div>
<div id="resolution-count">0 / 0 processed</div>
<div class="resolution-progress"><div id="resolution-progress-bar"></div></div>
<div id="resolution-percent">0.00%</div>
<div class="resolution-details">
<div>Resolved <strong id="resolution-resolved">0</strong></div>
<div>Pending <strong id="resolution-pending">0</strong></div>
<div>Failed <strong id="resolution-failed">0</strong></div>
<div>Started <strong id="resolution-started">—</strong></div>
<div>Last update <strong id="resolution-updated">—</strong></div>
</div>
</section>
{metrics_html}
<h2>
Calibration
</h2>
<p class="section-description">
Does a higher predicted probability actually correspond
to a higher probability of hitting the target?
</p>
<div class="chart-card">
{calibration_svg}
</div>
<h2>
{trajectory_heading}
</h2>
<div class="chart-card">
{trajectory_svg}
</div>
{resolved_table}
{pending_table}
<script>
let dashboardRevision = null;
function formatResolutionTime(value) {{
if (!value) return "—";
return new Intl.DateTimeFormat("en-IN", {{
timeZone: "Asia/Kolkata",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}}).format(new Date(value));
}}
function setResolutionText(id, value) {{
document.getElementById(id).textContent = value;
}}
async function refreshDashboardState() {{
try {{
const response = await fetch("/resolution-status", {{ cache: "no-store" }});
if (!response.ok) return;
const state = await response.json();
const total = Math.max(Number(state.total) || 0, 0);
const processed = Math.min(Math.max(Number(state.processed) || 0, 0), total);
const percent = total ? (processed / total) * 100 : 0;
setResolutionText("resolution-status", state.status);
setResolutionText("resolution-count", `${{processed.toLocaleString()}} / ${{total.toLocaleString()}} processed`);
setResolutionText("resolution-percent", `${{percent.toFixed(2)}}%`);
setResolutionText("resolution-resolved", Number(state.resolved || 0).toLocaleString());
setResolutionText("resolution-pending", Number(state.pending || 0).toLocaleString());
setResolutionText("resolution-failed", Number(state.failed || 0).toLocaleString());
setResolutionText("resolution-started", formatResolutionTime(state.started_at));
setResolutionText("resolution-updated", formatResolutionTime(state.last_updated_at));
document.getElementById("resolution-progress-bar").style.width = `${{percent}}%`;
if (dashboardRevision !== null && state.revision && state.revision !== dashboardRevision) {{
window.location.reload();
return;
}}
dashboardRevision = state.revision;
}} catch (_) {{
// Keep the last rendered data visible while a transient poll fails.
}}
}}
refreshDashboardState();
setInterval(refreshDashboardState, 3000);
</script>
</body>
</html>
"""
)