Spaces:
Running
Running
| 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"]) | |
| 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 | |
| # ───────────────────────────────────────────── | |
| 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> | |
| """ | |
| ) | |