From 0b47a06238fc930423bd725a0e1cd31755040279 Mon Sep 17 00:00:00 2001 From: Kunthawat Greethong Date: Sun, 23 Aug 2026 12:02:22 +0700 Subject: [PATCH] [verified] add event-study readiness gate --- README.md | 5 + backend/app/__init__.py | 19 +- backend/app/event_study.py | 163 ++++++++++++++++++ backend/tests/test_api.py | 16 ++ backend/tests/test_event_study.py | 52 ++++++ docs/HANDOFF.md | 14 +- docs/engineering-log.md | 5 +- .../2026-08-23-event-study-gate.md | 68 ++++++++ .../2026-08-23-event-study-gate.md | 42 +++++ frontend/src/App.vue | 19 +- 10 files changed, 396 insertions(+), 7 deletions(-) create mode 100644 backend/app/event_study.py create mode 100644 backend/tests/test_event_study.py create mode 100644 docs/engineering-log/2026-08-23-event-study-gate.md create mode 100644 docs/test-evidence/2026-08-23-event-study-gate.md diff --git a/README.md b/README.md index 8928395..b4e3f3c 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ Data-health and replay endpoints: GET /api/v1/data-health GET /api/v1/vintages?as_of= GET /api/v1/replay/tourism?vintage_id= +GET /api/v1/backtest/tourism?min_events=12 ``` Source: `https://app.bot.or.th/BTWS_STAT/statistics/ReportPage.aspx?reportID=875&language=eng` @@ -91,6 +92,8 @@ cd frontend && npm run build - Raw response hash and normalized snapshot persistence - Immutable vintage manifest with first-seen/revision metadata - Read-only data-health, vintage timeline and vintage replay endpoints +- Deterministic event-study engine with benchmark and cost inputs +- Backtest readiness gate that blocks without independent vintages and prices - Deterministic surprise × exposure × confidence score - Paper ledger endpoint - No LLM call yet; the deterministic result is the source of truth @@ -98,3 +101,5 @@ cd frontend && npm run build - No MT5 bridge yet The next implementation step is the event-study/backtest layer using only vintages whose `published_at` is known at each test date. + +The event-study gate is now exposed through `/api/v1/backtest/tourism`. It returns HTTP `409` with `status=blocked` when the independent-vintage minimum is not met, and it explicitly reports that a point-in-time daily price series is still required. The pure engine accepts events, daily prices, benchmark prices, event windows, and cost assumptions; it does not fetch or invent market prices. diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 0eb0f64..1fb4c0c 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -14,11 +14,12 @@ from typing import Any from flask import Flask, jsonify, request from .bot_tourism import BotTourismSource, TourismSourceError +from .event_study import EventStudyError, assess_backtest_readiness from .paper import PaperLedger from .tourism import compute_tourism_signal from .vintages import VintageStore, VintageStoreError -APP_VERSION = "0.3.0" +APP_VERSION = "0.4.0" def _load_default_snapshot() -> dict[str, Any]: @@ -175,6 +176,22 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: return jsonify({"error": str(exc)}), 400 return jsonify({"as_of": as_of, "count": len(entries), "vintages": entries}) + @app.get("/api/v1/backtest/tourism") + def tourism_backtest_readiness(): + raw_min_events = request.args.get("min_events", "12") + try: + min_events = int(raw_min_events) + readiness = assess_backtest_readiness(app.extensions["vintage_store"].list_vintages(), min_events) + except (ValueError, TypeError, EventStudyError) as exc: + return jsonify({"error": str(exc)}), 400 + body = { + **readiness, + "theme": "tourism", + "price_series_required": True, + "next_action": "collect independent published vintages before running event study" if readiness["status"] == "blocked" else "provide point-in-time daily price series", + } + return jsonify(body), 409 if readiness["status"] == "blocked" else 200 + @app.get("/api/v1/replay/tourism") def replay_tourism(): vintage_id = request.args.get("vintage_id", "") diff --git a/backend/app/event_study.py b/backend/app/event_study.py new file mode 100644 index 0000000..9438b0c --- /dev/null +++ b/backend/app/event_study.py @@ -0,0 +1,163 @@ +"""Deterministic event-study primitives with fail-closed readiness gates.""" + +from __future__ import annotations + +import math +from datetime import date, datetime, timezone +from statistics import fmean +from typing import Any, Mapping, Sequence + + +class EventStudyError(ValueError): + """Raised when an event study cannot be computed safely.""" + + +def _parse_date(value: str) -> date: + try: + if "T" in value: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.date() + return date.fromisoformat(value) + except (TypeError, ValueError) as exc: + raise EventStudyError("date must be ISO-8601") from exc + + +def assess_backtest_readiness(vintages: Sequence[Mapping[str, Any]], min_events: int = 12) -> dict[str, Any]: + if min_events < 1: + raise EventStudyError("min_events must be positive") + ids = {str(item.get("vintage_id") or item.get("event_id") or "") for item in vintages} + ids.discard("") + available = len(ids) + if available < min_events: + return { + "status": "blocked", + "reason": "insufficient_vintages", + "available_events": available, + "required_events": min_events, + } + return { + "status": "ready", + "reason": None, + "available_events": available, + "required_events": min_events, + } + + +def _price_map(symbol: str, rows: Sequence[Mapping[str, Any]]) -> dict[date, float]: + if not rows: + raise EventStudyError(f"missing prices for {symbol}") + values: dict[date, float] = {} + for row in rows: + try: + trading_day = _parse_date(str(row["date"])) + close = float(row["close"]) + except (KeyError, TypeError, ValueError) as exc: + raise EventStudyError(f"invalid price row for {symbol}") from exc + if not math.isfinite(close) or close <= 0: + raise EventStudyError(f"invalid close for {symbol}") + if trading_day in values: + raise EventStudyError(f"duplicate price date for {symbol}") + values[trading_day] = close + return dict(sorted(values.items())) + + +def _window_return(series: dict[date, float], event_date: date, window: int, symbol: str) -> float: + dates = list(series) + anchor_candidates = [index for index, trading_day in enumerate(dates) if trading_day >= event_date] + if not anchor_candidates: + raise EventStudyError(f"missing post-event prices for {symbol}") + anchor = anchor_candidates[0] + end = anchor + window + if end >= len(dates): + raise EventStudyError(f"insufficient price history for {symbol} window {window}") + return series[dates[end]] / series[dates[anchor]] - 1.0 + + +def run_event_study( + events: Sequence[Mapping[str, Any]], + prices: Mapping[str, Sequence[Mapping[str, Any]]], + *, + benchmark_prices: Sequence[Mapping[str, Any]] | None = None, + windows: Sequence[int] = (1, 3, 5, 20), + cost_bps: float = 0.0, + min_events: int = 12, +) -> dict[str, Any]: + """Calculate weighted post-publication returns from point-in-time events.""" + + readiness = assess_backtest_readiness(events, min_events=min_events) + if readiness["status"] != "ready": + raise EventStudyError( + f"insufficient vintages: {readiness['available_events']}/{readiness['required_events']}" + ) + if not windows or any(isinstance(window, bool) or int(window) != window or window <= 0 for window in windows): + raise EventStudyError("windows must contain positive integers") + try: + cost_bps = float(cost_bps) + except (TypeError, ValueError) as exc: + raise EventStudyError("cost_bps must be finite and non-negative") from exc + if not math.isfinite(cost_bps) or cost_bps < 0: + raise EventStudyError("cost_bps must be finite and non-negative") + + normalized_prices = {str(symbol).upper(): _price_map(str(symbol).upper(), rows) for symbol, rows in prices.items()} + normalized_benchmark = _price_map("benchmark", benchmark_prices) if benchmark_prices is not None else None + seen_event_ids: set[str] = set() + event_rows: list[dict[str, Any]] = [] + for event in events: + event_id = str(event.get("event_id", "")) + if not event_id or event_id in seen_event_ids: + raise EventStudyError("event_id must be unique and non-empty") + seen_event_ids.add(event_id) + event_date = _parse_date(str(event.get("published_at", ""))) + signals = event.get("signals") + if not isinstance(signals, Sequence) or isinstance(signals, (str, bytes)) or not signals: + raise EventStudyError(f"event {event_id} has no signals") + event_rows.append({"event_id": event_id, "event_date": event_date, "signals": signals}) + + window_results: dict[str, Any] = {} + for window in windows: + gross_returns: list[float] = [] + net_returns: list[float] = [] + benchmark_returns: list[float] = [] + for event in event_rows: + gross = 0.0 + turnover = 0.0 + for signal in event["signals"]: + symbol = str(signal.get("symbol", "")).upper() + try: + weight = float(signal["target_weight"]) + except (KeyError, TypeError, ValueError) as exc: + raise EventStudyError(f"invalid target weight for {symbol}") from exc + if not symbol or not math.isfinite(weight): + raise EventStudyError(f"invalid target weight for {symbol}") + if symbol not in normalized_prices: + raise EventStudyError(f"missing prices for {symbol}") + gross += weight * _window_return(normalized_prices[symbol], event["event_date"], int(window), symbol) + turnover += abs(weight) + cost = turnover * cost_bps / 10000.0 + gross_returns.append(gross) + net_returns.append(gross - cost) + if normalized_benchmark is not None: + benchmark_returns.append(_window_return(normalized_benchmark, event["event_date"], int(window), "benchmark")) + average_gross = fmean(gross_returns) + average_net = fmean(net_returns) + average_benchmark = fmean(benchmark_returns) if benchmark_returns else None + window_results[str(window)] = { + "window_sessions": int(window), + "event_count": len(event_rows), + "gross_return": round(average_gross, 8), + "net_return": round(average_net, 8), + "benchmark_return": round(average_benchmark, 8) if average_benchmark is not None else None, + "active_return": round(average_net - average_benchmark, 8) if average_benchmark is not None else None, + "hit_rate": round(sum(value > 0 for value in net_returns) / len(net_returns), 8), + "cost_bps": cost_bps, + "event_returns": [round(value, 8) for value in net_returns], + } + + return { + "status": "ready", + "event_count": len(event_rows), + "windows": window_results, + "min_events": min_events, + } diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 6269d0c..6792ddb 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -95,6 +95,22 @@ class ApiTests(unittest.TestCase): self.assertEqual(body["source_id"], "bot.ec_ei_028_s2") self.assertEqual(body["status"], "provisional") + def test_backtest_readiness_blocks_without_enough_vintages(self): + with tempfile.TemporaryDirectory() as temp_dir: + store = VintageStore(Path(temp_dir)) + store.persist(b"fixture raw", self.snapshot) + app = create_app({"TESTING": True, "SNAPSHOT": self.snapshot, "VINTAGE_STORE": store}) + response = app.test_client().get("/api/v1/backtest/tourism?min_events=12") + body = response.get_json() + self.assertEqual(response.status_code, 409) + self.assertEqual(body["status"], "blocked") + self.assertEqual(body["reason"], "insufficient_vintages") + self.assertEqual(body["available_events"], 1) + + def test_backtest_readiness_rejects_invalid_min_events(self): + response = self.client.get("/api/v1/backtest/tourism?min_events=bad") + self.assertEqual(response.status_code, 400) + def test_vintages_endpoint_filters_by_publication_timestamp(self): with tempfile.TemporaryDirectory() as temp_dir: store = VintageStore(Path(temp_dir)) diff --git a/backend/tests/test_event_study.py b/backend/tests/test_event_study.py new file mode 100644 index 0000000..724c3d9 --- /dev/null +++ b/backend/tests/test_event_study.py @@ -0,0 +1,52 @@ +import unittest + +from app.event_study import EventStudyError, assess_backtest_readiness, run_event_study + + +class EventStudyTests(unittest.TestCase): + def test_readiness_blocks_when_independent_events_are_insufficient(self): + result = assess_backtest_readiness([{"vintage_id": "v1"}], min_events=12) + self.assertEqual(result["status"], "blocked") + self.assertEqual(result["reason"], "insufficient_vintages") + self.assertEqual(result["available_events"], 1) + self.assertEqual(result["required_events"], 12) + + def test_event_study_applies_windowed_returns_and_costs(self): + events = [ + { + "event_id": "v1", + "published_at": "2026-01-01T08:00:00+07:00", + "signals": [ + {"symbol": "AOT", "target_weight": 0.5}, + {"symbol": "PTT", "target_weight": -0.5}, + ], + }, + { + "event_id": "v2", + "published_at": "2026-01-01T08:00:00+07:00", + "signals": [ + {"symbol": "AOT", "target_weight": 0.5}, + {"symbol": "PTT", "target_weight": -0.5}, + ], + }, + ] + prices = { + "AOT": [{"date": "2026-01-01", "close": 100}, {"date": "2026-01-02", "close": 102}], + "PTT": [{"date": "2026-01-01", "close": 100}, {"date": "2026-01-02", "close": 99}], + } + result = run_event_study(events, prices, benchmark_prices=[{"date": "2026-01-01", "close": 100}, {"date": "2026-01-02", "close": 101}], windows=(1,), cost_bps=100, min_events=2) + window = result["windows"]["1"] + self.assertEqual(result["status"], "ready") + self.assertEqual(result["event_count"], 2) + self.assertGreater(window["gross_return"], window["net_return"]) + self.assertEqual(window["cost_bps"], 100) + self.assertIsNotNone(window["benchmark_return"]) + + def test_event_study_rejects_missing_price_series(self): + events = [{"event_id": "v1", "published_at": "2026-01-01T08:00:00+07:00", "signals": [{"symbol": "AOT", "target_weight": 1.0}]}] + with self.assertRaisesRegex(EventStudyError, "missing prices"): + run_event_study(events, {}, windows=(1,), min_events=1) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index b872615..3f3d992 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -5,10 +5,10 @@ - Path: `/Users/kunthawat/Gitea/set50-alternative-data-platform` - Branch: `main` - Verified code commit: `d1ba6ef` — `[verified] add vintage collector and point-in-time API` -- Current milestone: M2 vintage foundation complete; event study deferred +- Current milestone: M2.3 event-study foundation complete; real backtest blocked - Mode: research + paper only - Frontend: Vue 3 + Vite -- Backend: Flask `0.3.0` +- Backend: Flask `0.4.0` - Current runtime source: BOT Tourism Indicators (`TOURISM_SOURCE=bot`) ## Completed @@ -22,6 +22,8 @@ - Immutable `VintageStore` manifest with first-seen, last-seen, seen-count and revision metadata. - One-shot collector: `backend/scripts/collect_tourism_vintage.py`. - Point-in-time vintage query: `GET /api/v1/vintages?as_of=`. +- Deterministic event-study engine with window, benchmark and cost calculations. +- Backtest readiness gate: `GET /api/v1/backtest/tourism?min_events=12`. - Ranked target weights and LONG/SHORT/NEUTRAL classification. - English dashboard with live/provisional source label, sign-aware surprise copy and lineage fields. - HttpOnly paper session and internal paper ledger. @@ -53,7 +55,7 @@ npm run build Vite build completed successfully. GET /api/v1/health -HTTP 200; {"mode":"research","status":"ok","version":"0.3.0"} +HTTP 200; {"mode":"research","status":"ok","version":"0.4.0"} GET /api/v1/data-health HTTP 200; source_mode=bot, status=provisional, replayable=true @@ -66,6 +68,9 @@ HTTP 200; count=0 GET /api/v1/vintages?as_of=2026-08-01T00:00:00Z HTTP 200; count=1; manifest seen_count=4 + +GET /api/v1/backtest/tourism?min_events=12 +HTTP 409; status=blocked, available_events=1, required_events=12, price_series_required=true ``` Paper writes use a server-side token exchange and HttpOnly `paper_session` cookie; the token is not embedded in the frontend bundle. @@ -77,8 +82,9 @@ Paper writes use a server-side token exchange and HttpOnly `paper_session` cooki - Snapshot storage is local filesystem and single-process; shared persistence is required before multi-worker deployment. - No investment edge, transaction-cost result, or backtest conclusion has been established. - One independent source release is not enough for a valid event study; current historical rows are not treated as point-in-time vintages. +- The event-study engine is deterministic and tested, but no real price provider is connected yet. - Browser screenshot verification remains blocked by the Chrome remote-debugging permission prompt; served HTML/source, live API, fresh Vite build and replay integrity were verified instead. ## Exact next action -Collect independent BOT releases over time, then implement event-study/backtest checks using only vintages whose `published_at` is known at each test date. Add another metric only when its historical release coverage is real. +Collect independent BOT releases over time and add a point-in-time daily price provider. Only then raise the readiness gate and run the event study. Add another metric only when its historical release coverage is real. diff --git a/docs/engineering-log.md b/docs/engineering-log.md index c8177d8..6ccc4cd 100644 --- a/docs/engineering-log.md +++ b/docs/engineering-log.md @@ -7,6 +7,7 @@ | M0 repo foundation | complete | Flask API, Vue/Vite shell | keep research/paper guardrails | | M1 BOT Tourism adapter | complete | 18 tests, live BOT fetch, raw/snapshot persistence | validate multiple vintages | | M2 vintage collector | complete | 25 tests, manifest idempotency, live collector and point-in-time API | collect independent releases | +| M2.3 event-study gate | complete/blocked | 30 tests, pure engine and truthful 409 readiness API | add point-in-time price provider | | Tourism deterministic signal | complete | live foreign-arrivals YoY surprise | add occupancy/airport metric | | Internal paper ledger | complete | POST/readback through live API | persist in PostgreSQL later | | Dashboard | complete | Vite build + served source check with live-sign copy | visual browser capture after permission is available | @@ -24,7 +25,7 @@ ## Verification -- Backend: 25 unittest tests pass. +- Backend: 30 unittest tests pass. - Independent M1 review: **PASSED**; no concrete security or logic blockers. - Reviewer suggestions: set `PAPER_COOKIE_SECURE=1` outside local HTTP; replace in-memory sessions before multi-worker deployment. - M1 reviewer backlog: add schema-drift, duplicate/reordered-row, and malformed-vintage regression fixtures. @@ -37,4 +38,6 @@ - Live vintage replay returned the same theme surprise as the current dashboard summary. - Vintage collector preserved one live `vintage_id` with `seen_count=4` and point-in-time API excluded it before `published_at`. - Independent M2 review: **PASSED**; no concrete security or logic blockers. +- Event-study readiness gate correctly returns HTTP 409 with 1/12 independent vintages; no backtest result is fabricated. +- Independent M2.3 review: **PASSED**; no concrete security or logic blockers. - Browser visual capture was blocked by Chrome remote-debugging permission; no permission dialog was clicked. diff --git a/docs/engineering-log/2026-08-23-event-study-gate.md b/docs/engineering-log/2026-08-23-event-study-gate.md new file mode 100644 index 0000000..834f7da --- /dev/null +++ b/docs/engineering-log/2026-08-23-event-study-gate.md @@ -0,0 +1,68 @@ +# 2026-08-23 — event-study engine and backtest readiness gate + +## Plan status + +- Deterministic event-study engine: complete. +- Cost-aware window calculations: complete. +- Point-in-time vintage sufficiency gate: complete. +- Market-price provider and real backtest: blocked by design until independent releases and a point-in-time daily price series exist. + +## Changed files + +- `backend/app/event_study.py` — readiness gate, event-window returns, weighted portfolio returns, benchmark comparison, turnover cost and hit-rate calculations. +- `backend/app/__init__.py` — `/api/v1/backtest/tourism` readiness endpoint and API version `0.4.0`. +- `backend/tests/test_event_study.py` — sufficiency, window/cost and missing-price tests. +- `backend/tests/test_api.py` — blocked and invalid-query endpoint tests. +- `frontend/src/App.vue` — Backtest gate KPI that accepts HTTP 409 as a truthful blocked state instead of treating it as a dashboard failure. +- `README.md` — engine contract and readiness behavior. + +## Contract + +The engine accepts point-in-time events, daily symbol prices, an optional benchmark, event windows, and cost assumptions. It does not fetch prices, infer missing closes, or convert current revised historical data into past knowledge. + +When fewer than the configured minimum events exist, the API returns: + +```json +{ + "status": "blocked", + "reason": "insufficient_vintages", + "available_events": 1, + "required_events": 12, + "price_series_required": true +} +``` + +## Live evidence + +```text +GET /api/v1/backtest/tourism?min_events=12 +HTTP 409 +status: blocked +available_events: 1 +required_events: 12 +next_action: collect independent published vintages before running event study +``` + +The dashboard served source includes `fetchBacktestReadiness`, `Backtest gate`, and the blocked-vintage count. HTTP 409 is handled as an expected research state, not a page error. + +## Verification + +- `PYTHONPATH=backend .venv/bin/python -W error -m unittest discover -s backend/tests -v` — **30 tests passed**. +- `npm run build` — passed. +- Live API readiness gate — passed with truthful 409 blocked result. +- Invalid `min_events` — HTTP 400. +- Pure event-study fixture — window return, benchmark and cost calculation passed. + +## Independent review + +```text +passed: true +security_concerns: [] +logic_errors: [] +``` + +Non-blocking backlog: add API boundary cases for zero/negative `min_events` and multi-vintage benchmark/window gaps. + +## Risks and exact next action + +There is only one independent BOT release. No investment backtest result exists. Continue collecting releases and add a point-in-time daily price adapter before raising the readiness threshold or interpreting event-study output. diff --git a/docs/test-evidence/2026-08-23-event-study-gate.md b/docs/test-evidence/2026-08-23-event-study-gate.md new file mode 100644 index 0000000..4c592a3 --- /dev/null +++ b/docs/test-evidence/2026-08-23-event-study-gate.md @@ -0,0 +1,42 @@ +# Test evidence — 2026-08-23 event-study gate + +## Automated + +```text +PYTHONPATH=backend .venv/bin/python -W error -m unittest discover -s backend/tests -v +Ran 30 tests ... OK + +npm run build +Vite build completed successfully. +``` + +## Live readiness + +```text +GET /api/v1/backtest/tourism?min_events=12 +HTTP 409 +status=blocked +available_events=1 +required_events=12 +price_series_required=true +``` + +The 409 is intentional: the API refuses to produce an event study with one independent vintage. + +## Engine fixture + +The pure event-study fixture verified one-session window returns, weighted long/short portfolio aggregation, benchmark comparison, turnover cost deduction, hit rate, and missing-price rejection. + +## Frontend served verification + +The frontend build and served `/src/App.vue` contained the readiness loader and `Backtest gate` KPI. Browser screenshot capture remains unavailable because the Chrome remote-debugging permission prompt has not been approved. + +## Independent review + +```text +passed: true +security_concerns: [] +logic_errors: [] +``` + +Non-blocking backlog: add API boundary cases for zero/negative `min_events` and multi-vintage benchmark/window gaps. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 99ff432..ec43cb5 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -5,6 +5,7 @@ const summary = ref(null) const observations = ref(null) const signalData = ref(null) const ledger = ref({ entries: [] }) +const backtest = ref(null) const loading = ref(true) const error = ref('') const notice = ref('') @@ -47,22 +48,33 @@ async function fetchJson(url, options) { return response.json() } +async function fetchBacktestReadiness() { + const response = await fetch('/api/v1/backtest/tourism?min_events=12') + const body = await response.json().catch(() => ({})) + if (![200, 409].includes(response.status)) { + throw new Error(body.error || `Request failed: ${response.status}`) + } + return body +} + async function loadDashboard() { loading.value = true error.value = '' try { - const [summaryBody, observationBody, signalBody, ledgerBody, sessionBody] = await Promise.all([ + const [summaryBody, observationBody, signalBody, ledgerBody, sessionBody, backtestBody] = await Promise.all([ fetchJson('/api/v1/dashboard/summary'), fetchJson('/api/v1/factors/tourism/observations'), fetchJson('/api/v1/signals'), fetchJson('/api/v1/paper/ledger'), fetchJson('/api/v1/auth/paper', { credentials: 'include' }), + fetchBacktestReadiness(), ]) summary.value = summaryBody observations.value = observationBody signalData.value = signalBody ledger.value = ledgerBody paperAuthenticated.value = Boolean(sessionBody.authenticated) + backtest.value = backtestBody } catch (caught) { error.value = caught.message } finally { @@ -205,6 +217,11 @@ onMounted(loadDashboard)
{{ summary.data_health.status }}
Vintage {{ summary.data_health.vintage_id }}
+
+
Backtest gate
+
{{ backtest?.status || '—' }}
+
{{ backtest?.available_events || 0 }} / {{ backtest?.required_events || 0 }} vintages
+