sbasu2512 commited on
Commit
a88e030
·
1 Parent(s): c4717f1

fix dashboard bugs and add resolution progress

Browse files
api/dashboard.py CHANGED
@@ -14,6 +14,71 @@ from evaluation.metrics import calculate_metrics
14
 
15
  router = APIRouter(tags=["dashboard"])
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  PREDICTION_COLUMNS = """
18
  id,
19
  prediction_date,
@@ -1122,6 +1187,45 @@ a:hover {{
1122
  font-weight: 650;
1123
  }}
1124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1125
  .table-card {{
1126
  background: var(--surface-1);
1127
  border: 1px solid var(--border);
@@ -1236,6 +1340,21 @@ Predictions, five-session outcomes, calibration,
1236
  and currently unresolved signals.
1237
  </p>
1238
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1239
  {metrics_html}
1240
 
1241
  <h2>
@@ -1263,7 +1382,58 @@ to a higher probability of hitting the target?
1263
 
1264
  {pending_table}
1265
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1266
  </body>
1267
  </html>
1268
  """
1269
- )
 
14
 
15
  router = APIRouter(tags=["dashboard"])
16
 
17
+
18
+ @router.get("/resolution-status")
19
+ def resolution_status():
20
+ """Return the latest durable resolution progress for dashboard polling."""
21
+ with connect(settings.DATABASE_URL) as conn, conn.cursor() as cur:
22
+ cur.execute(
23
+ """
24
+ SELECT COUNT(*) AS total,
25
+ SUM(CASE WHEN resolved_at IS NOT NULL THEN 1 ELSE 0 END) AS resolved,
26
+ SUM(CASE WHEN resolved_at IS NULL THEN 1 ELSE 0 END) AS pending
27
+ FROM predictions
28
+ """
29
+ )
30
+ counts = cur.fetchone()
31
+ cur.execute(
32
+ """
33
+ SELECT status, started_at, finished_at, last_updated_at,
34
+ total_rows, rows_processed, resolved_rows, failed_rows
35
+ FROM job_runs
36
+ WHERE job_name = 'resolution'
37
+ ORDER BY id DESC
38
+ LIMIT 1
39
+ """
40
+ )
41
+ run = cur.fetchone()
42
+ cur.execute(
43
+ """
44
+ SELECT MAX(value) AS revision FROM (
45
+ SELECT MAX(prediction_timestamp) AS value FROM predictions
46
+ UNION ALL SELECT MAX(resolved_at) FROM predictions
47
+ UNION ALL SELECT MAX(calculated_at) FROM prediction_metrics
48
+ UNION ALL SELECT MAX(COALESCE(last_updated_at, finished_at, started_at)) FROM job_runs
49
+ )
50
+ """
51
+ )
52
+ revision = cur.fetchone()["revision"]
53
+
54
+ total = int(counts["total"] or 0)
55
+ resolved = int(counts["resolved"] or 0)
56
+ pending = int(counts["pending"] or 0)
57
+ if run is None:
58
+ return {
59
+ "status": "IDLE",
60
+ "processed": 0,
61
+ "total": total,
62
+ "resolved": resolved,
63
+ "pending": pending,
64
+ "failed": 0,
65
+ "started_at": None,
66
+ "last_updated_at": None,
67
+ "revision": revision,
68
+ }
69
+
70
+ return {
71
+ "status": run["status"],
72
+ "processed": int(run["rows_processed"] or 0),
73
+ "total": int(run["total_rows"] or total),
74
+ "resolved": resolved,
75
+ "pending": pending,
76
+ "failed": int(run["failed_rows"] or 0),
77
+ "started_at": run["started_at"],
78
+ "last_updated_at": run["last_updated_at"] or run["finished_at"] or run["started_at"],
79
+ "revision": revision,
80
+ }
81
+
82
  PREDICTION_COLUMNS = """
83
  id,
84
  prediction_date,
 
1187
  font-weight: 650;
1188
  }}
1189
 
1190
+ .resolution-card {{
1191
+ background: var(--surface-1);
1192
+ border: 1px solid var(--border);
1193
+ border-radius: 8px;
1194
+ padding: 1rem;
1195
+ margin-bottom: 1.5rem;
1196
+ max-width: 520px;
1197
+ }}
1198
+
1199
+ .resolution-status {{
1200
+ color: var(--series-1);
1201
+ font-size: 1.15rem;
1202
+ font-weight: 700;
1203
+ letter-spacing: .04em;
1204
+ }}
1205
+
1206
+ .resolution-progress {{
1207
+ height: 10px;
1208
+ margin: .75rem 0;
1209
+ border-radius: 99px;
1210
+ overflow: hidden;
1211
+ background: rgba(128,128,128,.18);
1212
+ }}
1213
+
1214
+ .resolution-progress > div {{
1215
+ height: 100%;
1216
+ width: 0;
1217
+ background: var(--series-1);
1218
+ transition: width .25s ease;
1219
+ }}
1220
+
1221
+ .resolution-details {{
1222
+ display: grid;
1223
+ grid-template-columns: repeat(2, minmax(0, 1fr));
1224
+ gap: .35rem 1rem;
1225
+ color: var(--text-secondary);
1226
+ font-variant-numeric: tabular-nums;
1227
+ }}
1228
+
1229
  .table-card {{
1230
  background: var(--surface-1);
1231
  border: 1px solid var(--border);
 
1340
  and currently unresolved signals.
1341
  </p>
1342
 
1343
+ <section class="resolution-card" aria-live="polite">
1344
+ <h2>Prediction resolution</h2>
1345
+ <div class="resolution-status" id="resolution-status">Loading</div>
1346
+ <div id="resolution-count">0 / 0 processed</div>
1347
+ <div class="resolution-progress"><div id="resolution-progress-bar"></div></div>
1348
+ <div id="resolution-percent">0.00%</div>
1349
+ <div class="resolution-details">
1350
+ <div>Resolved <strong id="resolution-resolved">0</strong></div>
1351
+ <div>Pending <strong id="resolution-pending">0</strong></div>
1352
+ <div>Failed <strong id="resolution-failed">0</strong></div>
1353
+ <div>Started <strong id="resolution-started">—</strong></div>
1354
+ <div>Last update <strong id="resolution-updated">—</strong></div>
1355
+ </div>
1356
+ </section>
1357
+
1358
  {metrics_html}
1359
 
1360
  <h2>
 
1382
 
1383
  {pending_table}
1384
 
1385
+ <script>
1386
+ let dashboardRevision = null;
1387
+
1388
+ function formatResolutionTime(value) {{
1389
+ if (!value) return "—";
1390
+ return new Intl.DateTimeFormat("en-IN", {{
1391
+ timeZone: "Asia/Kolkata",
1392
+ hour: "2-digit",
1393
+ minute: "2-digit",
1394
+ second: "2-digit",
1395
+ hour12: false,
1396
+ }}).format(new Date(value));
1397
+ }}
1398
+
1399
+ function setResolutionText(id, value) {{
1400
+ document.getElementById(id).textContent = value;
1401
+ }}
1402
+
1403
+ async function refreshDashboardState() {{
1404
+ try {{
1405
+ const response = await fetch("/resolution-status", {{ cache: "no-store" }});
1406
+ if (!response.ok) return;
1407
+ const state = await response.json();
1408
+ const total = Math.max(Number(state.total) || 0, 0);
1409
+ const processed = Math.min(Math.max(Number(state.processed) || 0, 0), total);
1410
+ const percent = total ? (processed / total) * 100 : 0;
1411
+
1412
+ setResolutionText("resolution-status", state.status);
1413
+ setResolutionText("resolution-count", `${{processed.toLocaleString()}} / ${{total.toLocaleString()}} processed`);
1414
+ setResolutionText("resolution-percent", `${{percent.toFixed(2)}}%`);
1415
+ setResolutionText("resolution-resolved", Number(state.resolved || 0).toLocaleString());
1416
+ setResolutionText("resolution-pending", Number(state.pending || 0).toLocaleString());
1417
+ setResolutionText("resolution-failed", Number(state.failed || 0).toLocaleString());
1418
+ setResolutionText("resolution-started", formatResolutionTime(state.started_at));
1419
+ setResolutionText("resolution-updated", formatResolutionTime(state.last_updated_at));
1420
+ document.getElementById("resolution-progress-bar").style.width = `${{percent}}%`;
1421
+
1422
+ if (dashboardRevision !== null && state.revision && state.revision !== dashboardRevision) {{
1423
+ window.location.reload();
1424
+ return;
1425
+ }}
1426
+ dashboardRevision = state.revision;
1427
+ }} catch (_) {{
1428
+ // Keep the last rendered data visible while a transient poll fails.
1429
+ }}
1430
+ }}
1431
+
1432
+ refreshDashboardState();
1433
+ setInterval(refreshDashboardState, 3000);
1434
+ </script>
1435
+
1436
  </body>
1437
  </html>
1438
  """
1439
+ )
database/init_db.py CHANGED
@@ -13,6 +13,21 @@ from database.connection import connect
13
 
14
  SCHEMA_PATH = Path(__file__).parent / "schema.sql"
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
  def init_db(database_url: str | None = None) -> None:
18
  from app.config import settings
@@ -20,6 +35,7 @@ def init_db(database_url: str | None = None) -> None:
20
  conn = connect(database_url or settings.DATABASE_URL)
21
  try:
22
  conn.executescript(SCHEMA_PATH.read_text())
 
23
  conn.commit()
24
  finally:
25
  conn.close()
 
13
 
14
  SCHEMA_PATH = Path(__file__).parent / "schema.sql"
15
 
16
+ JOB_RUN_PROGRESS_COLUMNS = {
17
+ "total_rows": "INTEGER NOT NULL DEFAULT 0",
18
+ "resolved_rows": "INTEGER NOT NULL DEFAULT 0",
19
+ "failed_rows": "INTEGER NOT NULL DEFAULT 0",
20
+ "last_updated_at": "TIMESTAMP",
21
+ }
22
+
23
+
24
+ def _migrate_job_runs(conn) -> None:
25
+ """Add progress columns for databases created before resolution tracking."""
26
+ existing = {row["name"] for row in conn.execute("PRAGMA table_info(job_runs)")}
27
+ for name, definition in JOB_RUN_PROGRESS_COLUMNS.items():
28
+ if name not in existing:
29
+ conn.execute(f"ALTER TABLE job_runs ADD COLUMN {name} {definition}")
30
+
31
 
32
  def init_db(database_url: str | None = None) -> None:
33
  from app.config import settings
 
35
  conn = connect(database_url or settings.DATABASE_URL)
36
  try:
37
  conn.executescript(SCHEMA_PATH.read_text())
38
+ _migrate_job_runs(conn)
39
  conn.commit()
40
  finally:
41
  conn.close()
database/job_runs_repository.py CHANGED
@@ -22,3 +22,24 @@ def finish_job_run(database_url: str, job_run_id: int, *, success: bool, error_m
22
  "UPDATE job_runs SET status = ?, finished_at = STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'now'), error_message = ? WHERE id = ?",
23
  ("SUCCESS" if success else "FAILED", error_message, job_run_id),
24
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  "UPDATE job_runs SET status = ?, finished_at = STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'now'), error_message = ? WHERE id = ?",
23
  ("SUCCESS" if success else "FAILED", error_message, job_run_id),
24
  )
25
+
26
+
27
+ def update_job_progress(
28
+ database_url: str,
29
+ job_run_id: int,
30
+ *,
31
+ total_rows: int,
32
+ rows_processed: int,
33
+ resolved_rows: int,
34
+ failed_rows: int,
35
+ ) -> None:
36
+ with connect(database_url) as conn, conn.cursor() as cur:
37
+ cur.execute(
38
+ """
39
+ UPDATE job_runs
40
+ SET total_rows = ?, rows_processed = ?, resolved_rows = ?,
41
+ failed_rows = ?, last_updated_at = STRFTIME('%Y-%m-%dT%H:%M:%fZ', 'now')
42
+ WHERE id = ?
43
+ """,
44
+ (total_rows, rows_processed, resolved_rows, failed_rows, job_run_id),
45
+ )
database/metrics_repository.py CHANGED
@@ -11,8 +11,9 @@ def get_database_url() -> str:
11
  return value
12
 
13
 
14
- def fetch_resolved_rows(conn, days: int = 20):
15
- query = """
 
16
  WITH latest_model AS (
17
  SELECT model_version FROM predictions
18
  WHERE resolved_at IS NOT NULL
@@ -20,7 +21,7 @@ def fetch_resolved_rows(conn, days: int = 20):
20
  ), latest_dates AS (
21
  SELECT prediction_date FROM predictions p JOIN latest_model m USING (model_version)
22
  WHERE p.actual_label IS NOT NULL AND p.actual_return IS NOT NULL
23
- GROUP BY prediction_date ORDER BY prediction_date DESC LIMIT :days
24
  )
25
  SELECT p.prediction_date, p.symbol, p.model_version, p.predicted_probability,
26
  p.rank, p.actual_return, p.actual_label
@@ -30,7 +31,7 @@ def fetch_resolved_rows(conn, days: int = 20):
30
  ORDER BY p.prediction_date, p.predicted_probability DESC, p.symbol
31
  """
32
  with conn.cursor() as cur:
33
- cur.execute(query, {"days": days})
34
  return cur.fetchall()
35
 
36
 
 
11
  return value
12
 
13
 
14
+ def fetch_resolved_rows(conn, days: int | None = 20):
15
+ limit_clause = "" if days is None else "LIMIT :days"
16
+ query = f"""
17
  WITH latest_model AS (
18
  SELECT model_version FROM predictions
19
  WHERE resolved_at IS NOT NULL
 
21
  ), latest_dates AS (
22
  SELECT prediction_date FROM predictions p JOIN latest_model m USING (model_version)
23
  WHERE p.actual_label IS NOT NULL AND p.actual_return IS NOT NULL
24
+ GROUP BY prediction_date ORDER BY prediction_date DESC {limit_clause}
25
  )
26
  SELECT p.prediction_date, p.symbol, p.model_version, p.predicted_probability,
27
  p.rank, p.actual_return, p.actual_label
 
31
  ORDER BY p.prediction_date, p.predicted_probability DESC, p.symbol
32
  """
33
  with conn.cursor() as cur:
34
+ cur.execute(query, {} if days is None else {"days": days})
35
  return cur.fetchall()
36
 
37
 
database/resolution_repository.py CHANGED
@@ -3,7 +3,7 @@
3
  from __future__ import annotations
4
 
5
  from datetime import date, datetime, timezone
6
- from typing import Any
7
 
8
  from jobs.resolver import resolve_prediction_rows
9
 
@@ -28,25 +28,46 @@ def fetch_future_ohlcv(conn, symbol: str, prediction_date: date, forward_days: i
28
  return cur.fetchall()
29
 
30
 
31
- def resolve_pending_predictions(conn) -> int:
 
 
 
 
 
32
  resolved_count = 0
 
 
 
 
 
 
 
 
 
33
  for prediction in fetch_unresolved_predictions(conn):
34
  rows = fetch_future_ohlcv(conn, prediction["symbol"], prediction["prediction_date"], prediction["target_horizon_days"])
35
- resolution = resolve_prediction_rows(
36
- prediction_date=prediction["prediction_date"], symbol=prediction["symbol"],
37
- future_rows=[{"date": row["timestamp"], "open": row["open"], "close": row["close"]} for row in rows],
38
- target_return=prediction["target_threshold"], forward_days=prediction["target_horizon_days"],
39
- )
40
- if resolution is None:
41
- continue
42
- with conn.cursor() as cur:
43
- cur.execute("""
44
- UPDATE predictions SET entry_date = ?, entry_open = ?, max_close_5d = ?,
45
- evaluation_end_date = ?, actual_return = ?, actual_label = ?, resolved_at = ?
46
- WHERE id = ? AND resolved_at IS NULL
47
- """, (resolution.entry_date, resolution.entry_open, resolution.max_close,
48
- resolution.evaluation_dates[-1], resolution.actual_return, resolution.actual_label,
49
- datetime.now(timezone.utc), prediction["id"]))
50
- resolved_count += cur.rowcount
 
 
 
 
 
51
  conn.commit()
 
 
52
  return resolved_count
 
3
  from __future__ import annotations
4
 
5
  from datetime import date, datetime, timezone
6
+ from typing import Any, Callable
7
 
8
  from jobs.resolver import resolve_prediction_rows
9
 
 
28
  return cur.fetchall()
29
 
30
 
31
+ def resolve_pending_predictions(
32
+ conn,
33
+ *,
34
+ on_progress: Callable[[int, int, int], None] | None = None,
35
+ commit_interval: int = 25,
36
+ ) -> int:
37
  resolved_count = 0
38
+ failed_count = 0
39
+ processed_count = 0
40
+
41
+ def checkpoint() -> None:
42
+ if processed_count % commit_interval == 0:
43
+ conn.commit()
44
+ if on_progress is not None:
45
+ on_progress(processed_count, resolved_count, failed_count)
46
+
47
  for prediction in fetch_unresolved_predictions(conn):
48
  rows = fetch_future_ohlcv(conn, prediction["symbol"], prediction["prediction_date"], prediction["target_horizon_days"])
49
+ try:
50
+ resolution = resolve_prediction_rows(
51
+ prediction_date=prediction["prediction_date"], symbol=prediction["symbol"],
52
+ future_rows=[{"date": row["timestamp"], "open": row["open"], "close": row["close"]} for row in rows],
53
+ target_return=prediction["target_threshold"], forward_days=prediction["target_horizon_days"],
54
+ )
55
+ except ValueError:
56
+ failed_count += 1
57
+ else:
58
+ if resolution is not None:
59
+ with conn.cursor() as cur:
60
+ cur.execute("""
61
+ UPDATE predictions SET entry_date = ?, entry_open = ?, max_close_5d = ?,
62
+ evaluation_end_date = ?, actual_return = ?, actual_label = ?, resolved_at = ?
63
+ WHERE id = ? AND resolved_at IS NULL
64
+ """, (resolution.entry_date, resolution.entry_open, resolution.max_close,
65
+ resolution.evaluation_dates[-1], resolution.actual_return, resolution.actual_label,
66
+ datetime.now(timezone.utc), prediction["id"]))
67
+ resolved_count += cur.rowcount
68
+ processed_count += 1
69
+ checkpoint()
70
  conn.commit()
71
+ if on_progress is not None:
72
+ on_progress(processed_count, resolved_count, failed_count)
73
  return resolved_count
database/schema.sql CHANGED
@@ -121,6 +121,10 @@ CREATE TABLE IF NOT EXISTS job_runs (
121
  ),
122
 
123
  rows_processed INTEGER NOT NULL DEFAULT 0,
 
 
 
 
124
 
125
  error_message TEXT
126
  );
 
121
  ),
122
 
123
  rows_processed INTEGER NOT NULL DEFAULT 0,
124
+ total_rows INTEGER NOT NULL DEFAULT 0,
125
+ resolved_rows INTEGER NOT NULL DEFAULT 0,
126
+ failed_rows INTEGER NOT NULL DEFAULT 0,
127
+ last_updated_at TIMESTAMP,
128
 
129
  error_message TEXT
130
  );
jobs/resolve_predictions.py CHANGED
@@ -11,8 +11,8 @@ from dotenv import load_dotenv
11
  from app.config import settings
12
  from database.connection import connect
13
  from database.job_lock_repository import acquire_job_lock, release_job_lock
14
- from database.job_runs_repository import finish_job_run, start_job_run
15
- from database.resolution_repository import resolve_pending_predictions
16
 
17
  load_dotenv()
18
 
@@ -35,7 +35,20 @@ def run_resolution_job() -> int | None:
35
  settings.DATABASE_URL, "resolution", datetime.now(IST).date()
36
  )
37
  with connect(settings.DATABASE_URL) as conn:
38
- resolved = resolve_pending_predictions(conn)
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  finish_job_run(settings.DATABASE_URL, job_run_id, success=True)
40
  log.info("Resolution complete: %s predictions resolved", resolved)
41
  return resolved
 
11
  from app.config import settings
12
  from database.connection import connect
13
  from database.job_lock_repository import acquire_job_lock, release_job_lock
14
+ from database.job_runs_repository import finish_job_run, start_job_run, update_job_progress
15
+ from database.resolution_repository import fetch_unresolved_predictions, resolve_pending_predictions
16
 
17
  load_dotenv()
18
 
 
35
  settings.DATABASE_URL, "resolution", datetime.now(IST).date()
36
  )
37
  with connect(settings.DATABASE_URL) as conn:
38
+ total_rows = len(fetch_unresolved_predictions(conn))
39
+
40
+ def report_progress(processed: int, resolved: int, failed: int) -> None:
41
+ update_job_progress(
42
+ settings.DATABASE_URL,
43
+ job_run_id,
44
+ total_rows=total_rows,
45
+ rows_processed=processed,
46
+ resolved_rows=resolved,
47
+ failed_rows=failed,
48
+ )
49
+
50
+ report_progress(0, 0, 0)
51
+ resolved = resolve_pending_predictions(conn, on_progress=report_progress)
52
  finish_job_run(settings.DATABASE_URL, job_run_id, success=True)
53
  log.info("Resolution complete: %s predictions resolved", resolved)
54
  return resolved