[verified] add frozen research runner and durable paper ledger

This commit is contained in:
Kunthawat Greethong
2026-08-23 14:42:02 +07:00
parent c9932954c2
commit f55ff69c31
19 changed files with 1063 additions and 33 deletions

View File

@@ -41,9 +41,9 @@ npm run dev -- --host 127.0.0.1
Open `http://127.0.0.1:5173`. Open `http://127.0.0.1:5173`.
The frontend reads the live API through Vite's `/api` proxy. Paper writes require the operator to unlock an HttpOnly browser session using the backend `PAPER_WRITE_TOKEN`; the token is never embedded in the frontend bundle. The paper-entry action records an assumed fill in the in-memory paper ledger only. The frontend reads the live API through Vite's `/api` proxy. Paper writes require the operator to unlock an HttpOnly browser session using the backend `PAPER_WRITE_TOKEN`; the token is never embedded in the frontend bundle. The paper-entry action records an assumed fill in the local paper ledger only; it never sends an order.
For HTTPS/non-local deployment, set `PAPER_COOKIE_SECURE=1`. The M0 session store is intentionally in-memory and single-process; use a shared session store before running multiple workers or replicas. For HTTPS/non-local deployment, set `PAPER_COOKIE_SECURE=1`. Paper entries persist atomically under `backend/data/paper/ledger.json` by default. Sessions remain in-memory and single-process; use a shared session store before running multiple workers or replicas.
## Run with the real BOT Tourism source ## Run with the real BOT Tourism source
@@ -63,6 +63,8 @@ GET /api/v1/vintages?as_of=<ISO-8601 timestamp>
GET /api/v1/replay/tourism?vintage_id=<vintage_id> GET /api/v1/replay/tourism?vintage_id=<vintage_id>
GET /api/v1/backtest/tourism?min_events=12 GET /api/v1/backtest/tourism?min_events=12
GET /api/v1/prices/health GET /api/v1/prices/health
POST /api/v1/research/tourism/run
GET /api/v1/research/tourism/latest
``` ```
Source: `https://app.bot.or.th/BTWS_STAT/statistics/ReportPage.aspx?reportID=875&language=eng` Source: `https://app.bot.or.th/BTWS_STAT/statistics/ReportPage.aspx?reportID=875&language=eng`
@@ -95,7 +97,10 @@ cd frontend && npm run build
- Read-only data-health, vintage timeline and vintage replay endpoints - Read-only data-health, vintage timeline and vintage replay endpoints
- Yahoo-backed daily price snapshot contract with SET symbol mapping - Yahoo-backed daily price snapshot contract with SET symbol mapping
- Deterministic event-study engine with benchmark and cost inputs - Deterministic event-study engine with benchmark and cost inputs
- Next-trading-session execution anchor by default; revisions are deduplicated by source and publication timestamp
- Backtest readiness gate that blocks without independent vintages and prices - Backtest readiness gate that blocks without independent vintages and prices
- Frozen, replayable Tourism research-run reports with immutable input manifests
- Durable local paper ledger across backend restarts
- Deterministic surprise × exposure × confidence score - Deterministic surprise × exposure × confidence score
- Paper ledger endpoint - Paper ledger endpoint
- No LLM call yet; the deterministic result is the source of truth - No LLM call yet; the deterministic result is the source of truth
@@ -106,6 +111,30 @@ The next implementation step is replacing or supplementing revised vendor histor
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. 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.
## Run a frozen research check
The research runner persists either a deterministic event-study result or a blocked report. A blocked report is still useful: it records the exact vintage IDs, hashes, price snapshot, configuration, and gate reason that prevented the study.
From the CLI:
```bash
PYTHONPATH=backend .venv/bin/python backend/scripts/run_tourism_research.py \
--data-root backend/data \
--min-events 12 \
--windows 1 3 5 20 \
--cost-bps 20 \
--execution-lag-sessions 1
```
From the API:
```text
POST /api/v1/research/tourism/run
GET /api/v1/research/tourism/latest
```
The default live state intentionally returns `status=blocked`: the current archive has one independent BOT release and the Yahoo price snapshot is marked `point_in_time=false`. The runner never converts that data into a backtest by inference.
## Collect daily price snapshots ## Collect daily price snapshots
The initial research price provider uses Yahoo Finance Chart API with SET ticker mappings. It stores OHLCV plus adjusted close for the eight exposure names and `^SET.BK` as `SET50`. This is **revised vendor history**, not point-in-time market data, so the snapshot is visible for data plumbing but cannot unlock the backtest gate: The initial research price provider uses Yahoo Finance Chart API with SET ticker mappings. It stores OHLCV plus adjusted close for the eight exposure names and `^SET.BK` as `SET50`. This is **revised vendor history**, not point-in-time market data, so the snapshot is visible for data plumbing but cannot unlock the backtest gate:

View File

@@ -17,10 +17,11 @@ from .bot_tourism import BotTourismSource, TourismSourceError
from .event_study import EventStudyError, assess_backtest_readiness from .event_study import EventStudyError, assess_backtest_readiness
from .paper import PaperLedger from .paper import PaperLedger
from .prices import PriceSnapshotStore, PriceSourceError from .prices import PriceSnapshotStore, PriceSourceError
from .research import ResearchRunError, ResearchRunStore, run_tourism_research
from .tourism import compute_tourism_signal from .tourism import compute_tourism_signal
from .vintages import VintageStore, VintageStoreError from .vintages import VintageStore, VintageStoreError
APP_VERSION = "0.4.0" APP_VERSION = "0.5.0"
def _load_default_snapshot() -> dict[str, Any]: def _load_default_snapshot() -> dict[str, Any]:
@@ -47,6 +48,7 @@ def create_app(config: dict[str, Any] | None = None) -> Flask:
PAPER_WRITE_TOKEN=os.getenv("PAPER_WRITE_TOKEN", ""), PAPER_WRITE_TOKEN=os.getenv("PAPER_WRITE_TOKEN", ""),
PAPER_COOKIE_SECURE=os.getenv("PAPER_COOKIE_SECURE", "0") == "1", PAPER_COOKIE_SECURE=os.getenv("PAPER_COOKIE_SECURE", "0") == "1",
PAPER_SESSION_SECONDS=int(os.getenv("PAPER_SESSION_SECONDS", "3600")), PAPER_SESSION_SECONDS=int(os.getenv("PAPER_SESSION_SECONDS", "3600")),
PAPER_LEDGER_PATH=Path(os.getenv("PAPER_LEDGER_PATH", str(data_root / "paper" / "ledger.json"))),
TOURISM_SOURCE=os.getenv("TOURISM_SOURCE", "fixture"), TOURISM_SOURCE=os.getenv("TOURISM_SOURCE", "fixture"),
TOURISM_ADAPTER=None, TOURISM_ADAPTER=None,
TOURISM_DATA_ROOT=Path(os.getenv("TOURISM_DATA_ROOT", str(data_root))), TOURISM_DATA_ROOT=Path(os.getenv("TOURISM_DATA_ROOT", str(data_root))),
@@ -55,6 +57,8 @@ def create_app(config: dict[str, Any] | None = None) -> Flask:
VINTAGE_STORE=None, VINTAGE_STORE=None,
PRICE_DATA_ROOT=Path(os.getenv("PRICE_DATA_ROOT", str(data_root / "prices"))), PRICE_DATA_ROOT=Path(os.getenv("PRICE_DATA_ROOT", str(data_root / "prices"))),
PRICE_STORE=None, PRICE_STORE=None,
RESEARCH_DATA_ROOT=Path(os.getenv("RESEARCH_DATA_ROOT", str(data_root / "research"))),
RESEARCH_RUN_STORE=None,
SNAPSHOT=_load_default_snapshot(), SNAPSHOT=_load_default_snapshot(),
) )
if config: if config:
@@ -64,6 +68,8 @@ def create_app(config: dict[str, Any] | None = None) -> Flask:
app.extensions["vintage_store"] = vintage_store app.extensions["vintage_store"] = vintage_store
price_store = app.config.get("PRICE_STORE") or PriceSnapshotStore(app.config["PRICE_DATA_ROOT"]) price_store = app.config.get("PRICE_STORE") or PriceSnapshotStore(app.config["PRICE_DATA_ROOT"])
app.extensions["price_store"] = price_store app.extensions["price_store"] = price_store
research_run_store = app.config.get("RESEARCH_RUN_STORE") or ResearchRunStore(app.config["RESEARCH_DATA_ROOT"])
app.extensions["research_run_store"] = research_run_store
source_snapshot = app.config["SNAPSHOT"] source_snapshot = app.config["SNAPSHOT"]
source_mode = str(app.config["TOURISM_SOURCE"]).lower() source_mode = str(app.config["TOURISM_SOURCE"]).lower()
if source_mode == "bot": if source_mode == "bot":
@@ -76,7 +82,10 @@ def create_app(config: dict[str, Any] | None = None) -> Flask:
raise ValueError(f"unsupported TOURISM_SOURCE: {source_mode}") raise ValueError(f"unsupported TOURISM_SOURCE: {source_mode}")
result = compute_tourism_signal(source_snapshot) result = compute_tourism_signal(source_snapshot)
ledger = PaperLedger() ledger_path = app.config["PAPER_LEDGER_PATH"]
if app.config["TESTING"] and (not config or "PAPER_LEDGER_PATH" not in config):
ledger_path = None
ledger = PaperLedger(ledger_path)
paper_sessions: dict[str, float] = {} paper_sessions: dict[str, float] = {}
allowed_symbols = {item["symbol"] for item in result["signals"]} allowed_symbols = {item["symbol"] for item in result["signals"]}
app.extensions["tourism_result"] = result app.extensions["tourism_result"] = result
@@ -176,6 +185,35 @@ def create_app(config: dict[str, Any] | None = None) -> Flask:
def prices_health(): def prices_health():
return jsonify(_price_health_payload()) return jsonify(_price_health_payload())
@app.post("/api/v1/research/tourism/run")
def run_tourism_research_endpoint():
payload = request.get_json(silent=True) or {}
if not isinstance(payload, dict):
return jsonify({"error": "JSON object required"}), 400
try:
report = run_tourism_research(
app.extensions["vintage_store"],
app.extensions["price_store"],
app.extensions["research_run_store"],
min_events=payload.get("min_events", 12),
windows=payload.get("windows", (1, 3, 5, 20)),
cost_bps=payload.get("cost_bps", 20.0),
execution_lag_sessions=payload.get("execution_lag_sessions", 1),
)
except (ResearchRunError, TypeError, ValueError) as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(report)
@app.get("/api/v1/research/tourism/latest")
def latest_tourism_research():
try:
report = app.extensions["research_run_store"].latest()
except FileNotFoundError:
return jsonify({"error": "no research runs"}), 404
except ResearchRunError as exc:
return jsonify({"error": str(exc)}), 422
return jsonify(report)
@app.get("/api/v1/data-health") @app.get("/api/v1/data-health")
def data_health(): def data_health():
current = app.extensions["tourism_result"] current = app.extensions["tourism_result"]

View File

@@ -24,12 +24,30 @@ def _parse_date(value: str) -> date:
raise EventStudyError("date must be ISO-8601") from exc raise EventStudyError("date must be ISO-8601") from exc
def _canonical_timestamp(value: str) -> str:
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except (TypeError, ValueError):
return value
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc).isoformat()
def assess_backtest_readiness(vintages: Sequence[Mapping[str, Any]], min_events: int = 12) -> dict[str, Any]: def assess_backtest_readiness(vintages: Sequence[Mapping[str, Any]], min_events: int = 12) -> dict[str, Any]:
if min_events < 1: if isinstance(min_events, bool) or min_events < 1:
raise EventStudyError("min_events must be positive") raise EventStudyError("min_events must be positive")
ids = {str(item.get("vintage_id") or item.get("event_id") or "") for item in vintages} independent_keys: set[tuple[str, str, str]] = set()
ids.discard("") for item in vintages:
available = len(ids) source_id = str(item.get("source_id") or "")
published_at = _canonical_timestamp(str(item.get("published_at") or ""))
if source_id and published_at:
independent_keys.add(("release", source_id, published_at))
continue
identifier = str(item.get("vintage_id") or item.get("event_id") or "")
if identifier:
independent_keys.add(("id", identifier, ""))
available = len(independent_keys)
if available < min_events: if available < min_events:
return { return {
"status": "blocked", "status": "blocked",
@@ -63,12 +81,18 @@ def _price_map(symbol: str, rows: Sequence[Mapping[str, Any]]) -> dict[date, flo
return dict(sorted(values.items())) return dict(sorted(values.items()))
def _window_return(series: dict[date, float], event_date: date, window: int, symbol: str) -> float: def _window_return(series: dict[date, float], event_date: date, window: int, symbol: str, execution_lag_sessions: int) -> float:
dates = list(series) dates = list(series)
anchor_candidates = [index for index, trading_day in enumerate(dates) if trading_day >= event_date] anchor_candidates = [index for index, trading_day in enumerate(dates) if trading_day >= event_date]
if not anchor_candidates: if not anchor_candidates:
raise EventStudyError(f"missing post-event prices for {symbol}") raise EventStudyError(f"missing post-event prices for {symbol}")
anchor = anchor_candidates[0] first_session = anchor_candidates[0]
if execution_lag_sessions == 0:
anchor = first_session
elif dates[first_session] > event_date:
anchor = first_session + execution_lag_sessions - 1
else:
anchor = first_session + execution_lag_sessions
end = anchor + window end = anchor + window
if end >= len(dates): if end >= len(dates):
raise EventStudyError(f"insufficient price history for {symbol} window {window}") raise EventStudyError(f"insufficient price history for {symbol} window {window}")
@@ -83,6 +107,7 @@ def run_event_study(
windows: Sequence[int] = (1, 3, 5, 20), windows: Sequence[int] = (1, 3, 5, 20),
cost_bps: float = 0.0, cost_bps: float = 0.0,
min_events: int = 12, min_events: int = 12,
execution_lag_sessions: int = 1,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Calculate weighted post-publication returns from point-in-time events.""" """Calculate weighted post-publication returns from point-in-time events."""
@@ -93,6 +118,9 @@ def run_event_study(
) )
if not windows or any(isinstance(window, bool) or int(window) != window or window <= 0 for window in windows): 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") raise EventStudyError("windows must contain positive integers")
if isinstance(execution_lag_sessions, bool) or int(execution_lag_sessions) != execution_lag_sessions or execution_lag_sessions < 0:
raise EventStudyError("execution_lag_sessions must be a non-negative integer")
execution_lag_sessions = int(execution_lag_sessions)
try: try:
cost_bps = float(cost_bps) cost_bps = float(cost_bps)
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
@@ -133,13 +161,13 @@ def run_event_study(
raise EventStudyError(f"invalid target weight for {symbol}") raise EventStudyError(f"invalid target weight for {symbol}")
if symbol not in normalized_prices: if symbol not in normalized_prices:
raise EventStudyError(f"missing prices for {symbol}") raise EventStudyError(f"missing prices for {symbol}")
gross += weight * _window_return(normalized_prices[symbol], event["event_date"], int(window), symbol) gross += weight * _window_return(normalized_prices[symbol], event["event_date"], int(window), symbol, execution_lag_sessions)
turnover += abs(weight) turnover += abs(weight)
cost = turnover * cost_bps / 10000.0 cost = turnover * cost_bps / 10000.0
gross_returns.append(gross) gross_returns.append(gross)
net_returns.append(gross - cost) net_returns.append(gross - cost)
if normalized_benchmark is not None: if normalized_benchmark is not None:
benchmark_returns.append(_window_return(normalized_benchmark, event["event_date"], int(window), "benchmark")) benchmark_returns.append(_window_return(normalized_benchmark, event["event_date"], int(window), "benchmark", execution_lag_sessions))
average_gross = fmean(gross_returns) average_gross = fmean(gross_returns)
average_net = fmean(net_returns) average_net = fmean(net_returns)
average_benchmark = fmean(benchmark_returns) if benchmark_returns else None average_benchmark = fmean(benchmark_returns) if benchmark_returns else None
@@ -152,6 +180,7 @@ def run_event_study(
"active_return": round(average_net - 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), "hit_rate": round(sum(value > 0 for value in net_returns) / len(net_returns), 8),
"cost_bps": cost_bps, "cost_bps": cost_bps,
"execution_lag_sessions": execution_lag_sessions,
"event_returns": [round(value, 8) for value in net_returns], "event_returns": [round(value, 8) for value in net_returns],
} }

View File

@@ -2,14 +2,48 @@
from __future__ import annotations from __future__ import annotations
import json
from copy import deepcopy
from datetime import datetime, timezone from datetime import datetime, timezone
from math import isfinite from math import isfinite
from pathlib import Path
from uuid import uuid4 from uuid import uuid4
PAPER_LEDGER_SCHEMA_VERSION = 1
class PaperLedgerError(ValueError):
"""Raised when a persistent paper ledger is invalid or unreadable."""
class PaperLedger: class PaperLedger:
def __init__(self) -> None: def __init__(self, path: Path | str | None = None) -> None:
self.path = Path(path).resolve() if path else None
self._entries: list[dict] = [] self._entries: list[dict] = []
if self.path and self.path.is_file():
self._entries = self._load()
def _load(self) -> list[dict]:
if self.path is None:
raise PaperLedgerError("paper ledger path is not configured")
try:
payload = json.loads(self.path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise PaperLedgerError("paper ledger is unreadable") from exc
if not isinstance(payload, dict) or payload.get("schema_version") != PAPER_LEDGER_SCHEMA_VERSION or not isinstance(payload.get("entries"), list):
raise PaperLedgerError("unsupported paper ledger schema")
if not all(isinstance(entry, dict) for entry in payload["entries"]):
raise PaperLedgerError("paper ledger entries are invalid")
return deepcopy(payload["entries"])
def _persist(self, entries: list[dict]) -> None:
if not self.path:
return
self.path.parent.mkdir(parents=True, exist_ok=True)
payload = {"schema_version": PAPER_LEDGER_SCHEMA_VERSION, "entries": entries}
temporary = self.path.with_name(f".{self.path.name}.tmp")
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
temporary.replace(self.path)
def record(self, payload: dict, allowed_symbols: set[str]) -> dict: def record(self, payload: dict, allowed_symbols: set[str]) -> dict:
symbol = str(payload.get("symbol", "")).strip().upper() symbol = str(payload.get("symbol", "")).strip().upper()
@@ -34,8 +68,10 @@ class PaperLedger:
"assumed_price": assumed_price, "assumed_price": assumed_price,
"status": "PAPER_RECORDED", "status": "PAPER_RECORDED",
} }
self._entries.append(entry) candidate_entries = [*self._entries, entry]
return entry self._persist(candidate_entries)
self._entries = candidate_entries
return deepcopy(entry)
def entries(self) -> list[dict]: def entries(self) -> list[dict]:
return list(self._entries) return deepcopy(self._entries)

View File

@@ -230,6 +230,55 @@ class PriceSnapshotStore:
raise PriceSourceError("unsupported price manifest schema") raise PriceSourceError("unsupported price manifest schema")
return manifest return manifest
def _snapshot_path(self, snapshot_id: str) -> Path:
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", snapshot_id):
raise PriceSourceError("invalid price snapshot id")
candidate = (self.snapshot_dir / f"{snapshot_id}.json").resolve()
if candidate.parent != self.snapshot_dir.resolve():
raise PriceSourceError("price snapshot path escaped store root")
return candidate
def load_snapshot(self, snapshot_id: str) -> dict[str, Any]:
snapshot_path = self._snapshot_path(snapshot_id)
if not snapshot_path.is_file():
raise FileNotFoundError(snapshot_id)
try:
snapshot = json.loads(snapshot_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise PriceSourceError("price snapshot is unreadable") from exc
source = snapshot.get("source")
if not isinstance(source, dict) or source.get("snapshot_id") != snapshot_id:
raise PriceSourceError("price snapshot identity mismatch")
raw_files = source.get("raw_payload_files")
if not isinstance(raw_files, dict) or not raw_files:
raise PriceSourceError("price snapshot raw payload manifest is missing")
raw_payloads: dict[str, bytes] = {}
raw_root = (self.raw_dir / snapshot_id).resolve()
if raw_root.parent != self.raw_dir.resolve():
raise PriceSourceError("price raw path escaped store root")
for provider_symbol, filename in raw_files.items():
if _raw_filename(str(provider_symbol)) != filename:
raise PriceSourceError("price raw filename mismatch")
raw_path = (raw_root / filename).resolve()
if raw_path.parent != raw_root or not raw_path.is_file():
raise PriceSourceError("price raw payload is missing")
raw_payloads[str(provider_symbol)] = raw_path.read_bytes()
actual_hash = _combined_hash(raw_payloads)
if actual_hash != source.get("raw_payload_hash"):
raise PriceSourceError("price raw payload hash mismatch")
manifest_entry = self.load_manifest().get("snapshots", {}).get(snapshot_id)
if isinstance(manifest_entry, dict) and manifest_entry.get("raw_payload_hash") != actual_hash:
raise PriceSourceError("price manifest hash mismatch")
return snapshot
def list_snapshots(self) -> list[dict[str, Any]]:
entries = list(self.load_manifest().get("snapshots", {}).values())
return sorted(entries, key=lambda item: (str(item.get("retrieved_at", "")), str(item.get("snapshot_id", ""))))
def latest_snapshot_entry(self) -> dict[str, Any] | None:
entries = self.list_snapshots()
return entries[-1] if entries else None
def persist(self, snapshot: dict[str, Any], raw_payloads: Mapping[str, bytes]) -> dict[str, Any]: def persist(self, snapshot: dict[str, Any], raw_payloads: Mapping[str, bytes]) -> dict[str, Any]:
source = snapshot.get("source") source = snapshot.get("source")
if not isinstance(source, dict) or not source.get("snapshot_id"): if not isinstance(source, dict) or not source.get("snapshot_id"):
@@ -258,8 +307,11 @@ class PriceSnapshotStore:
"period_start": stored_source.get("period_start"), "period_start": stored_source.get("period_start"),
"period_end": stored_source.get("period_end"), "period_end": stored_source.get("period_end"),
"raw_payload_hash": raw_hash, "raw_payload_hash": raw_hash,
"parser_version": stored_source.get("parser_version"),
"quality": stored_source.get("quality"), "quality": stored_source.get("quality"),
"point_in_time": stored_source.get("point_in_time"), "point_in_time": stored_source.get("point_in_time"),
"adjusted_prices": stored_source.get("adjusted_prices"),
"return_price_field": stored_source.get("return_price_field"),
"symbols": sorted(stored.get("series", {}).keys()), "symbols": sorted(stored.get("series", {}).keys()),
"snapshot_file": stored_source["snapshot_file"], "snapshot_file": stored_source["snapshot_file"],
} }
@@ -299,6 +351,7 @@ def collect_price_snapshot(
"quality": "revised_vendor_history", "quality": "revised_vendor_history",
"point_in_time": False, "point_in_time": False,
"adjusted_prices": True, "adjusted_prices": True,
"return_price_field": "close",
"snapshot_id": snapshot_id, "snapshot_id": snapshot_id,
"bar_counts": {symbol: len(series["bars"]) for symbol, series in normalized.items()}, "bar_counts": {symbol: len(series["bars"]) for symbol, series in normalized.items()},
} }

296
backend/app/research.py Normal file
View File

@@ -0,0 +1,296 @@
"""Frozen, replayable Tourism research runs.
A run either produces a deterministic event-study result from point-in-time
inputs or persists a blocked report explaining the exact missing gate. It never
falls back to revised vendor history.
"""
from __future__ import annotations
import copy
import hashlib
import json
import math
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Mapping, Sequence
from .event_study import EventStudyError, _canonical_timestamp, assess_backtest_readiness, run_event_study
from .prices import PriceSnapshotStore, PriceSourceError
from .tourism import compute_tourism_signal
from .vintages import VintageStore, VintageStoreError
RUN_SCHEMA_VERSION = 1
_RUN_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}")
class ResearchRunError(ValueError):
"""Raised when a research run cannot be safely created or replayed."""
def _canonical_hash(payload: Mapping[str, Any]) -> str:
encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _atomic_write(path: Path, payload: Mapping[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.tmp")
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
temporary.replace(path)
class ResearchRunStore:
"""Immutable JSON reports with a compact run manifest."""
def __init__(self, root: Path | str) -> None:
self.root = Path(root).resolve()
self.report_dir = self.root / "reports"
self.manifest_path = self.root / "manifest.json"
def load_manifest(self) -> dict[str, Any]:
if not self.manifest_path.is_file():
return {"schema_version": RUN_SCHEMA_VERSION, "runs": {}}
try:
manifest = json.loads(self.manifest_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ResearchRunError("research run manifest is unreadable") from exc
if manifest.get("schema_version") != RUN_SCHEMA_VERSION or not isinstance(manifest.get("runs"), dict):
raise ResearchRunError("unsupported research run manifest schema")
return manifest
def _report_path(self, run_id: str) -> Path:
if not _RUN_ID_RE.fullmatch(run_id):
raise ResearchRunError("invalid research run id")
candidate = (self.report_dir / f"{run_id}.json").resolve()
if candidate.parent != self.report_dir.resolve():
raise ResearchRunError("research report path escaped store root")
return candidate
def load(self, run_id: str) -> dict[str, Any]:
path = self._report_path(run_id)
if not path.is_file():
raise FileNotFoundError(run_id)
try:
report = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ResearchRunError("research report is unreadable") from exc
if report.get("run_id") != run_id or report.get("schema_version") != RUN_SCHEMA_VERSION:
raise ResearchRunError("research report identity mismatch")
return report
def persist(self, report: dict[str, Any]) -> dict[str, Any]:
run_id = str(report.get("run_id", ""))
path = self._report_path(run_id)
manifest = self.load_manifest()
existing = manifest["runs"].get(run_id)
if existing:
stored = self.load(run_id)
if stored != report:
raise ResearchRunError("research run id already contains a different report")
return stored
stored = copy.deepcopy(report)
_atomic_write(path, stored)
manifest["runs"][run_id] = {
"run_id": run_id,
"status": stored.get("status"),
"reason": stored.get("reason"),
"generated_at": stored.get("generated_at"),
"report_file": path.name,
}
_atomic_write(self.manifest_path, manifest)
return stored
def latest(self) -> dict[str, Any]:
entries = list(self.load_manifest().get("runs", {}).values())
if not entries:
raise FileNotFoundError("no research runs")
latest = max(entries, key=lambda item: (str(item.get("generated_at", "")), str(item.get("run_id", ""))))
return self.load(str(latest["run_id"]))
def list_runs(self) -> list[dict[str, Any]]:
entries = list(self.load_manifest().get("runs", {}).values())
return sorted(entries, key=lambda item: (str(item.get("generated_at", "")), str(item.get("run_id", ""))), reverse=True)
def _validate_config(windows: Sequence[int], cost_bps: float, min_events: int, execution_lag_sessions: int) -> tuple[list[int], float, int, int]:
if not windows or any(isinstance(window, bool) or int(window) != window or int(window) <= 0 for window in windows):
raise ResearchRunError("windows must contain positive integers")
try:
parsed_cost = float(cost_bps)
except (TypeError, ValueError) as exc:
raise ResearchRunError("cost_bps must be finite and non-negative") from exc
if not math.isfinite(parsed_cost) or parsed_cost < 0:
raise ResearchRunError("cost_bps must be finite and non-negative")
if isinstance(min_events, bool) or int(min_events) != min_events or int(min_events) < 1:
raise ResearchRunError("min_events must be positive")
if isinstance(execution_lag_sessions, bool) or int(execution_lag_sessions) != execution_lag_sessions or int(execution_lag_sessions) < 0:
raise ResearchRunError("execution_lag_sessions must be a non-negative integer")
return [int(window) for window in windows], parsed_cost, int(min_events), int(execution_lag_sessions)
def _price_gate(price_entry: Mapping[str, Any] | None) -> dict[str, Any]:
if not price_entry:
return {"status": "blocked", "reason": "price_snapshot_missing", "snapshot_count": 0}
if not bool(price_entry.get("point_in_time")):
return {
"status": "blocked",
"reason": "price_series_not_point_in_time",
"snapshot_id": price_entry.get("snapshot_id"),
"quality": price_entry.get("quality"),
"point_in_time": False,
}
return {
"status": "ready",
"reason": None,
"snapshot_id": price_entry.get("snapshot_id"),
"quality": price_entry.get("quality"),
"point_in_time": True,
}
def _revision_key(entry: Mapping[str, Any]) -> tuple[str, str, str]:
source_id = str(entry.get("source_id") or "")
published_at = _canonical_timestamp(str(entry.get("published_at") or ""))
if source_id and published_at:
return ("release", source_id, published_at)
return ("id", str(entry.get("vintage_id") or ""), "")
def _select_independent_vintages(entries: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]:
selected: dict[tuple[str, str, str], dict[str, Any]] = {}
for raw_entry in entries:
entry = dict(raw_entry)
key = _revision_key(entry)
current = selected.get(key)
candidate_rank = (str(entry.get("first_seen_at", "")), str(entry.get("last_seen_at", "")), str(entry.get("vintage_id", "")))
current_rank = (str(current.get("first_seen_at", "")), str(current.get("last_seen_at", "")), str(current.get("vintage_id", ""))) if current else ("", "", "")
if current is None or candidate_rank > current_rank:
selected[key] = entry
return sorted(selected.values(), key=lambda item: (str(item.get("published_at", "")), str(item.get("vintage_id", ""))))
def _series_rows(series: Mapping[str, Any], price_field: str, symbol: str) -> list[dict[str, Any]]:
bars = series.get("bars")
if not isinstance(bars, list) or not bars:
raise ResearchRunError(f"missing price bars for {symbol}")
rows: list[dict[str, Any]] = []
for bar in bars:
if not isinstance(bar, dict) or price_field not in bar:
raise ResearchRunError(f"price field {price_field} missing for {symbol}")
rows.append({"date": bar.get("date"), "close": bar.get(price_field)})
return rows
def run_tourism_research(
vintage_store: VintageStore,
price_store: PriceSnapshotStore,
run_store: ResearchRunStore,
*,
min_events: int = 12,
windows: Sequence[int] = (1, 3, 5, 20),
cost_bps: float = 20.0,
execution_lag_sessions: int = 1,
) -> dict[str, Any]:
windows, cost_bps, min_events, execution_lag_sessions = _validate_config(windows, cost_bps, min_events, execution_lag_sessions)
try:
vintages = _select_independent_vintages(vintage_store.list_vintages())
price_entry = price_store.latest_snapshot_entry()
except (VintageStoreError, PriceSourceError) as exc:
raise ResearchRunError(str(exc)) from exc
vintage_gate = assess_backtest_readiness(vintages, min_events=min_events)
price_gate = _price_gate(price_entry)
input_vintages = [
{
"vintage_id": entry.get("vintage_id"),
"source_id": entry.get("source_id"),
"as_of": entry.get("as_of"),
"published_at": entry.get("published_at"),
"raw_payload_hash": entry.get("raw_payload_hash"),
"parser_version": entry.get("parser_version"),
"revision_status": entry.get("revision_status"),
}
for entry in vintages
]
input_price = copy.deepcopy(price_entry) if price_entry else None
config = {
"min_events": min_events,
"windows": windows,
"cost_bps": cost_bps,
"execution_lag_sessions": execution_lag_sessions,
}
input_fingerprint = _canonical_hash({"theme": "tourism", "config": config, "vintages": input_vintages, "price_snapshot": input_price})
run_id = f"tourism-run-{input_fingerprint[:16]}"
try:
return run_store.load(run_id)
except FileNotFoundError:
pass
generated_at = datetime.now(timezone.utc).isoformat()
report: dict[str, Any] = {
"schema_version": RUN_SCHEMA_VERSION,
"run_id": run_id,
"theme": "tourism",
"status": "blocked",
"reason": None,
"generated_at": generated_at,
"config": config,
"gates": {"vintages": vintage_gate, "prices": price_gate},
"inputs": {"vintages": input_vintages, "price_snapshot": input_price},
}
if vintage_gate["status"] != "ready":
report["reason"] = vintage_gate["reason"]
return run_store.persist(report)
if price_gate["status"] != "ready":
report["reason"] = price_gate["reason"]
return run_store.persist(report)
if not price_entry:
raise ResearchRunError("price snapshot disappeared after readiness check")
try:
price_snapshot = price_store.load_snapshot(str(price_entry["snapshot_id"]))
except (FileNotFoundError, PriceSourceError) as exc:
report["reason"] = "price_snapshot_unreadable"
report["gates"]["prices"] = {**price_gate, "status": "blocked", "reason": report["reason"], "error": str(exc)}
return run_store.persist(report)
price_source = price_snapshot.get("source", {})
price_field = str(price_source.get("return_price_field", "close"))
series = price_snapshot.get("series")
if not isinstance(series, dict):
report["reason"] = "price_snapshot_invalid"
return run_store.persist(report)
events: list[dict[str, Any]] = []
try:
for entry in vintages:
snapshot = vintage_store.load_snapshot(str(entry["vintage_id"]))
result = compute_tourism_signal(snapshot)
events.append({
"event_id": entry["vintage_id"],
"published_at": snapshot["source"]["published_at"],
"signals": result["signals"],
})
price_rows = {symbol: _series_rows(value, price_field, symbol) for symbol, value in series.items() if symbol != price_snapshot.get("benchmark_symbol")}
benchmark_symbol = price_snapshot.get("benchmark_symbol")
benchmark_rows = _series_rows(series[benchmark_symbol], price_field, str(benchmark_symbol)) if benchmark_symbol else None
result = run_event_study(
events,
price_rows,
benchmark_prices=benchmark_rows,
windows=windows,
cost_bps=cost_bps,
min_events=min_events,
execution_lag_sessions=execution_lag_sessions,
)
except (FileNotFoundError, VintageStoreError, EventStudyError, ResearchRunError, KeyError, TypeError, ValueError) as exc:
report["reason"] = "research_inputs_invalid"
report["error"] = str(exc)
return run_store.persist(report)
report["status"] = "ready"
report["reason"] = None
report["result"] = result
report["input_summary"] = {
"event_count": len(events),
"price_symbols": sorted(price_rows),
"benchmark_symbol": benchmark_symbol,
"return_price_field": price_field,
}
return run_store.persist(report)

View File

@@ -116,7 +116,7 @@ class VintageStore:
existing = manifest["vintages"].get(vintage_id) existing = manifest["vintages"].get(vintage_id)
same_release = any( same_release = any(
item.get("source_id") == source.get("source_id") item.get("source_id") == source.get("source_id")
and item.get("published_at") == published_at and _parse_timestamp(str(item.get("published_at"))) == _parse_timestamp(published_at)
and item.get("vintage_id") != vintage_id and item.get("vintage_id") != vintage_id
for item in manifest["vintages"].values() for item in manifest["vintages"].values()
) )

View File

@@ -0,0 +1,36 @@
"""Run the frozen Tourism research check against local snapshots."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from app.prices import PriceSnapshotStore
from app.research import ResearchRunStore, run_tourism_research
from app.vintages import VintageStore
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--data-root", type=Path, default=Path(__file__).resolve().parents[1] / "data")
parser.add_argument("--min-events", type=int, default=12)
parser.add_argument("--windows", type=int, nargs="+", default=[1, 3, 5, 20])
parser.add_argument("--cost-bps", type=float, default=20.0)
parser.add_argument("--execution-lag-sessions", type=int, default=1)
args = parser.parse_args()
report = run_tourism_research(
VintageStore(args.data_root),
PriceSnapshotStore(args.data_root / "prices"),
ResearchRunStore(args.data_root / "research"),
min_events=args.min_events,
windows=args.windows,
cost_bps=args.cost_bps,
execution_lag_sessions=args.execution_lag_sessions,
)
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -5,6 +5,7 @@ from pathlib import Path
from app import create_app from app import create_app
from app.prices import PriceSnapshotStore from app.prices import PriceSnapshotStore
from app.research import ResearchRunStore
from app.vintages import VintageStore from app.vintages import VintageStore
@@ -137,6 +138,34 @@ class ApiTests(unittest.TestCase):
self.assertFalse(body["available"]) self.assertFalse(body["available"])
self.assertEqual(body["status"], "missing") self.assertEqual(body["status"], "missing")
def test_research_run_persists_blocked_report_and_latest_endpoint(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
vintage_store = VintageStore(root / "tourism")
vintage_store.persist(b"fixture raw", self.snapshot)
app = create_app(
{
"TESTING": True,
"SNAPSHOT": self.snapshot,
"VINTAGE_STORE": vintage_store,
"PRICE_STORE": PriceSnapshotStore(root / "prices"),
"RESEARCH_RUN_STORE": ResearchRunStore(root / "runs"),
}
)
client = app.test_client()
response = client.post("/api/v1/research/tourism/run", json={"min_events": 2, "windows": [1]})
body = response.get_json()
self.assertEqual(response.status_code, 200)
self.assertEqual(body["status"], "blocked")
self.assertEqual(body["reason"], "insufficient_vintages")
latest = client.get("/api/v1/research/tourism/latest")
self.assertEqual(latest.status_code, 200)
self.assertEqual(latest.get_json()["run_id"], body["run_id"])
def test_research_run_rejects_invalid_configuration(self):
response = self.client.post("/api/v1/research/tourism/run", json={"min_events": 0})
self.assertEqual(response.status_code, 400)
def test_backtest_readiness_rejects_invalid_min_events(self): def test_backtest_readiness_rejects_invalid_min_events(self):
response = self.client.get("/api/v1/backtest/tourism?min_events=bad") response = self.client.get("/api/v1/backtest/tourism?min_events=bad")
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
@@ -192,10 +221,23 @@ class ApiTests(unittest.TestCase):
json={"symbol": "AOT", "target_weight": 0.1, "assumed_price": 60}, json={"symbol": "AOT", "target_weight": 0.1, "assumed_price": 60},
) )
self.assertEqual(response.status_code, 201) self.assertEqual(response.status_code, 201)
self.assertEqual(response.get_json()["entry"]["symbol"], "AOT") self.assertEqual(response.get_json()["entry"]["status"], "PAPER_RECORDED")
ledger = self.client.get("/api/v1/paper/ledger").get_json()["entries"] ledger = self.client.get("/api/v1/paper/ledger").get_json()["entries"]
self.assertEqual(len(ledger), 1) self.assertEqual(len(ledger), 1)
def test_paper_ledger_survives_app_restart_when_path_is_configured(self):
with tempfile.TemporaryDirectory() as temp_dir:
ledger_path = Path(temp_dir) / "paper" / "ledger.json"
config = {"TESTING": True, "SNAPSHOT": self.snapshot, "PAPER_WRITE_TOKEN": "test-token", "PAPER_LEDGER_PATH": ledger_path}
first_app = create_app(config)
first_client = first_app.test_client()
response = first_client.post("/api/v1/auth/paper", json={"token": "test-token"})
self.assertEqual(response.status_code, 200)
response = first_client.post("/api/v1/paper/ledger", json={"symbol": "AOT", "target_weight": 0.25, "assumed_price": 60})
self.assertEqual(response.status_code, 201)
entry_id = response.get_json()["entry"]["entry_id"]
second_app = create_app(config)
entries = second_app.test_client().get("/api/v1/paper/ledger").get_json()["entries"]
self.assertEqual([entry["entry_id"] for entry in entries], [entry_id])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -11,6 +11,29 @@ class EventStudyTests(unittest.TestCase):
self.assertEqual(result["available_events"], 1) self.assertEqual(result["available_events"], 1)
self.assertEqual(result["required_events"], 12) self.assertEqual(result["required_events"], 12)
def test_readiness_rejects_boolean_min_events(self):
with self.assertRaisesRegex(EventStudyError, "min_events"):
assess_backtest_readiness([], min_events=True)
def test_readiness_does_not_count_revisions_as_independent_events(self):
entries = [
{"vintage_id": "v1", "source_id": "bot", "published_at": "2026-01-01T08:00:00+07:00"},
{"vintage_id": "v1-revised", "source_id": "bot", "published_at": "2026-01-01T08:00:00+07:00"},
{"vintage_id": "v2", "source_id": "bot", "published_at": "2026-02-01T08:00:00+07:00"},
]
result = assess_backtest_readiness(entries, min_events=3)
self.assertEqual(result["status"], "blocked")
self.assertEqual(result["available_events"], 2)
def test_readiness_canonicalizes_release_timezone_before_deduplication(self):
entries = [
{"vintage_id": "v1", "source_id": "bot", "published_at": "2026-01-01T08:00:00+07:00"},
{"vintage_id": "v1-revised", "source_id": "bot", "published_at": "2026-01-01T01:00:00Z"},
]
result = assess_backtest_readiness(entries, min_events=2)
self.assertEqual(result["available_events"], 1)
self.assertEqual(result["status"], "blocked")
def test_event_study_applies_windowed_returns_and_costs(self): def test_event_study_applies_windowed_returns_and_costs(self):
events = [ events = [
{ {
@@ -31,10 +54,10 @@ class EventStudyTests(unittest.TestCase):
}, },
] ]
prices = { prices = {
"AOT": [{"date": "2026-01-01", "close": 100}, {"date": "2026-01-02", "close": 102}], "AOT": [{"date": "2026-01-01", "close": 100}, {"date": "2026-01-02", "close": 102}, {"date": "2026-01-05", "close": 104}],
"PTT": [{"date": "2026-01-01", "close": 100}, {"date": "2026-01-02", "close": 99}], "PTT": [{"date": "2026-01-01", "close": 100}, {"date": "2026-01-02", "close": 99}, {"date": "2026-01-05", "close": 98}],
} }
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) result = run_event_study(events, prices, benchmark_prices=[{"date": "2026-01-01", "close": 100}, {"date": "2026-01-02", "close": 101}, {"date": "2026-01-05", "close": 102}], windows=(1,), cost_bps=100, min_events=2)
window = result["windows"]["1"] window = result["windows"]["1"]
self.assertEqual(result["status"], "ready") self.assertEqual(result["status"], "ready")
self.assertEqual(result["event_count"], 2) self.assertEqual(result["event_count"], 2)
@@ -47,6 +70,30 @@ class EventStudyTests(unittest.TestCase):
with self.assertRaisesRegex(EventStudyError, "missing prices"): with self.assertRaisesRegex(EventStudyError, "missing prices"):
run_event_study(events, {}, windows=(1,), min_events=1) run_event_study(events, {}, windows=(1,), min_events=1)
def test_event_study_uses_next_trading_session_as_default_anchor(self):
events = [{"event_id": "v1", "published_at": "2026-01-01T08:00:00+07:00", "signals": [{"symbol": "AOT", "target_weight": 1.0}]}]
prices = {
"AOT": [
{"date": "2026-01-01", "close": 100},
{"date": "2026-01-02", "close": 105},
{"date": "2026-01-05", "close": 110},
]
}
result = run_event_study(events, prices, windows=(1,), min_events=1)
self.assertAlmostEqual(result["windows"]["1"]["gross_return"], 110 / 105 - 1, places=8)
def test_event_study_does_not_skip_first_session_after_non_trading_event_date(self):
events = [{"event_id": "weekend", "published_at": "2026-01-03T08:00:00+07:00", "signals": [{"symbol": "AOT", "target_weight": 1.0}]}]
prices = {
"AOT": [
{"date": "2026-01-05", "close": 100},
{"date": "2026-01-06", "close": 101},
{"date": "2026-01-07", "close": 102},
]
}
result = run_event_study(events, prices, windows=(1,), min_events=1)
self.assertAlmostEqual(result["windows"]["1"]["gross_return"], 101 / 100 - 1, places=8)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -0,0 +1,31 @@
import tempfile
import unittest
from pathlib import Path
from app.paper import PaperLedger, PaperLedgerError
class PaperLedgerTests(unittest.TestCase):
def test_ledger_persists_entries_and_reloads_after_restart(self):
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "ledger.json"
first = PaperLedger(path)
entry = first.record({"symbol": "AOT", "target_weight": 0.25, "assumed_price": 60}, {"AOT"})
restarted = PaperLedger(path)
self.assertEqual(restarted.entries(), [entry])
def test_ledger_rejects_corrupt_persistent_file(self):
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "ledger.json"
path.write_text("not-json", encoding="utf-8")
with self.assertRaisesRegex(PaperLedgerError, "unreadable"):
PaperLedger(path)
def test_ledger_defaults_to_in_memory_mode(self):
ledger = PaperLedger()
ledger.record({"symbol": "AOT", "target_weight": 0.25, "assumed_price": 60}, {"AOT"})
self.assertEqual(len(ledger.entries()), 1)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,130 @@
import copy
import json
import tempfile
import unittest
from pathlib import Path
from app.prices import PriceSnapshotStore
from app.research import ResearchRunStore, run_tourism_research
from app.vintages import VintageStore
def tourism_snapshot(vintage_id: str, published_at: str) -> dict:
return {
"as_of": "2026-01-01",
"data_quality": "provisional",
"theme": "tourism",
"strategy_version": "tourism-v0.2-bot",
"observations": [
{"metric_key": "foreign_arrivals_yoy", "value": 1.0, "expected": 0.0, "scale": 1.0, "period": "2025-12", "history_points": 12, "unit": "percent", "provisional": True}
],
"exposures": [
{"symbol": "AOT", "coefficient": 1.0, "confidence": 1.0, "evidence": "airport"},
{"symbol": "PTT", "coefficient": -0.5, "confidence": 1.0, "evidence": "control"},
],
"source": {
"source_id": "test.bot",
"source_url": "https://example.test/bot",
"vintage_id": vintage_id,
"published_at": published_at,
"retrieved_at": "2026-02-01T00:00:00+00:00",
"release_status": "provisional",
"parser_version": "test-v1",
},
}
def price_snapshot(point_in_time: bool) -> tuple[dict, dict[str, bytes]]:
return (
{
"schema_version": 1,
"source": {
"source_id": "test.prices",
"snapshot_id": "prices-test",
"retrieved_at": "2026-02-01T00:00:00+00:00",
"period_start": "2026-01-01",
"period_end": "2026-01-06",
"quality": "forward_market_archive" if point_in_time else "revised_vendor_history",
"point_in_time": point_in_time,
"adjusted_prices": False,
},
"series": {
"AOT": {"bars": [{"date": "2026-01-01", "close": 100.0}, {"date": "2026-01-02", "close": 102.0}, {"date": "2026-01-05", "close": 104.0}, {"date": "2026-01-06", "close": 106.0}]},
"PTT": {"bars": [{"date": "2026-01-01", "close": 100.0}, {"date": "2026-01-02", "close": 99.0}, {"date": "2026-01-05", "close": 98.0}, {"date": "2026-01-06", "close": 97.0}]},
"SET50": {"bars": [{"date": "2026-01-01", "close": 100.0}, {"date": "2026-01-02", "close": 101.0}, {"date": "2026-01-05", "close": 102.0}, {"date": "2026-01-06", "close": 103.0}]},
},
"benchmark_symbol": "SET50",
},
{"AOT.BK": b"aot", "PTT.BK": b"ptt", "^SET.BK": b"set"},
)
class ResearchRunTests(unittest.TestCase):
def test_price_store_loads_snapshot_and_verifies_raw_payload(self):
with tempfile.TemporaryDirectory() as temp_dir:
store = PriceSnapshotStore(Path(temp_dir))
snapshot, raw = price_snapshot(point_in_time=False)
store.persist(snapshot, raw)
loaded = store.load_snapshot("prices-test")
self.assertEqual(loaded["source"]["snapshot_id"], "prices-test")
self.assertEqual(loaded["source"]["raw_payload_hash"], store.load_manifest()["snapshots"]["prices-test"]["raw_payload_hash"])
def test_runner_persists_blocked_report_when_vintage_count_is_insufficient(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
vintage_store = VintageStore(root / "tourism")
vintage_store.persist(b"tourism", tourism_snapshot("v1", "2026-01-01T08:00:00+07:00"))
price_store = PriceSnapshotStore(root / "prices")
snapshot, raw = price_snapshot(point_in_time=False)
price_store.persist(snapshot, raw)
run_store = ResearchRunStore(root / "runs")
report = run_tourism_research(vintage_store, price_store, run_store, min_events=2, windows=(1,))
self.assertEqual(report["status"], "blocked")
self.assertEqual(report["reason"], "insufficient_vintages")
self.assertEqual(report["gates"]["vintages"]["available_events"], 1)
self.assertNotIn("result", report)
self.assertEqual(run_store.latest()["run_id"], report["run_id"])
def test_runner_computes_replayable_result_when_both_gates_pass(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
vintage_store = VintageStore(root / "tourism")
for index, published_at in ((1, "2026-01-01T08:00:00+07:00"), (2, "2026-01-02T08:00:00+07:00")):
snapshot = tourism_snapshot(f"v{index}", published_at)
vintage_store.persist(f"tourism-{index}".encode(), snapshot)
price_store = PriceSnapshotStore(root / "prices")
snapshot, raw = price_snapshot(point_in_time=True)
price_store.persist(snapshot, raw)
run_store = ResearchRunStore(root / "runs")
report = run_tourism_research(vintage_store, price_store, run_store, min_events=2, windows=(1,), cost_bps=20)
replay = run_tourism_research(vintage_store, price_store, run_store, min_events=2, windows=(1,), cost_bps=20)
self.assertEqual(report["status"], "ready")
self.assertEqual(report["result"]["event_count"], 2)
self.assertEqual(report["result"]["windows"]["1"]["cost_bps"], 20.0)
self.assertEqual(replay, report)
self.assertEqual(report["inputs"]["price_snapshot"]["point_in_time"], True)
def test_runner_uses_latest_revision_once(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
vintage_store = VintageStore(root / "tourism")
vintage_store.persist(b"initial", tourism_snapshot("v1", "2026-01-01T08:00:00+07:00"))
revised = tourism_snapshot("v1-revised", "2026-01-01T01:00:00Z")
revised["source"]["source_id"] = "test.bot"
vintage_store.persist(b"revised", revised)
vintage_store.persist(b"next", tourism_snapshot("v2", "2026-01-02T08:00:00+07:00"))
price_store = PriceSnapshotStore(root / "prices")
snapshot, raw = price_snapshot(point_in_time=True)
price_store.persist(snapshot, raw)
report = run_tourism_research(vintage_store, price_store, ResearchRunStore(root / "runs"), min_events=2, windows=(1,))
self.assertEqual(report["status"], "ready")
self.assertEqual(len(report["inputs"]["vintages"]), 2)
self.assertIn("v1-revised", {entry["vintage_id"] for entry in report["inputs"]["vintages"]})
if __name__ == "__main__":
unittest.main()

View File

@@ -83,6 +83,24 @@ class VintageStoreTests(unittest.TestCase):
self.assertEqual(len(entries), 2) self.assertEqual(len(entries), 2)
self.assertEqual(sorted(entry["revision_status"] for entry in entries), ["initial", "revised"]) self.assertEqual(sorted(entry["revision_status"] for entry in entries), ["initial", "revised"])
def test_manifest_marks_timezone_equivalent_release_as_revision(self):
with tempfile.TemporaryDirectory() as temp_dir:
store = VintageStore(Path(temp_dir))
first = sample_snapshot()
store.persist(REPORT_HTML.encode("utf-8"), first)
revised = copy.deepcopy(first)
revised_raw = b"timezone-equivalent revised raw"
revised_hash = hashlib.sha256(revised_raw).hexdigest()
revised["source"]["published_at"] = "2026-07-31T07:30:00Z"
revised["source"]["raw_payload_hash"] = revised_hash
revised["source"]["vintage_id"] = revised["source"]["vintage_id"][:-12] + revised_hash[:12]
revised["source"]["raw_snapshot_file"] = revised["source"]["vintage_id"] + ".html"
revised["source"]["snapshot_file"] = revised["source"]["vintage_id"] + ".json"
store.persist(revised_raw, revised)
entries = list(store.load_manifest()["vintages"].values())
self.assertEqual(len(entries), 2)
self.assertEqual(sorted(entry["revision_status"] for entry in entries), ["initial", "revised"])
def test_point_in_time_filter_excludes_future_publications(self): def test_point_in_time_filter_excludes_future_publications(self):
entries = [ entries = [
{"vintage_id": "old", "published_at": "2026-07-31T14:30:00+07:00"}, {"vintage_id": "old", "published_at": "2026-07-31T14:30:00+07:00"},

View File

@@ -4,11 +4,11 @@
- Path: `/Users/kunthawat/Gitea/set50-alternative-data-platform` - Path: `/Users/kunthawat/Gitea/set50-alternative-data-platform`
- Branch: `main` - Branch: `main`
- Verified code commit: `7f7a614``[verified] add SET price snapshot adapter` - Verified code commit: `7f7a614``[verified] add SET price snapshot adapter` (M2.5 pending commit)
- Current milestone: M2.4 price snapshot foundation complete; real backtest blocked - Current milestone: M2.5 research runner and durable paper workflow complete; validated backtest blocked
- Mode: research + paper only - Mode: research + paper only
- Frontend: Vue 3 + Vite - Frontend: Vue 3 + Vite
- Backend: Flask `0.4.0` - Backend: Flask `0.5.0`
- Current runtime source: BOT Tourism Indicators (`TOURISM_SOURCE=bot`) - Current runtime source: BOT Tourism Indicators (`TOURISM_SOURCE=bot`)
## Completed ## Completed
@@ -26,6 +26,9 @@
- Backtest readiness gate: `GET /api/v1/backtest/tourism?min_events=12`. - Backtest readiness gate: `GET /api/v1/backtest/tourism?min_events=12`.
- Yahoo-backed daily price snapshot with SET symbol mapping and adjusted-close bars. - Yahoo-backed daily price snapshot with SET symbol mapping and adjusted-close bars.
- Price health: `GET /api/v1/prices/health`. - Price health: `GET /api/v1/prices/health`.
- Frozen research runner: `POST /api/v1/research/tourism/run` and `GET /api/v1/research/tourism/latest`.
- Headless runner: `backend/scripts/run_tourism_research.py`.
- Durable atomic paper ledger under `backend/data/paper/ledger.json` when configured.
- Ranked target weights and LONG/SHORT/NEUTRAL classification. - Ranked target weights and LONG/SHORT/NEUTRAL classification.
- English dashboard with live/provisional source label, sign-aware surprise copy and lineage fields. - English dashboard with live/provisional source label, sign-aware surprise copy and lineage fields.
- HttpOnly paper session and internal paper ledger. - HttpOnly paper session and internal paper ledger.
@@ -57,7 +60,7 @@ npm run build
Vite build completed successfully. Vite build completed successfully.
GET /api/v1/health GET /api/v1/health
HTTP 200; {"mode":"research","status":"ok","version":"0.4.0"} HTTP 200; {"mode":"research","status":"ok","version":"0.5.0"}
GET /api/v1/data-health GET /api/v1/data-health
HTTP 200; source_mode=bot, status=provisional, replayable=true HTTP 200; source_mode=bot, status=provisional, replayable=true
@@ -76,6 +79,12 @@ HTTP 409; status=blocked, available_events=1, required_events=12, price_series_r
GET /api/v1/prices/health GET /api/v1/prices/health
HTTP 200; available=true, quality=revised_vendor_history, point_in_time=false, symbols=9 HTTP 200; available=true, quality=revised_vendor_history, point_in_time=false, symbols=9
POST /api/v1/research/tourism/run
HTTP 200; report status=blocked, reason=insufficient_vintages, immutable run_id persisted
GET /api/v1/research/tourism/latest
HTTP 200; same run_id returned on replay
``` ```
Paper writes use a server-side token exchange and HttpOnly `paper_session` cookie; the token is not embedded in the frontend bundle. Paper writes use a server-side token exchange and HttpOnly `paper_session` cookie; the token is not embedded in the frontend bundle.
@@ -88,8 +97,11 @@ Paper writes use a server-side token exchange and HttpOnly `paper_session` cooki
- No investment edge, transaction-cost result, or backtest conclusion has been established. - 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. - 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. Yahoo price history is connected for plumbing, but it is revised vendor history, not point-in-time data. - The event-study engine is deterministic and tested. Yahoo price history is connected for plumbing, but it is revised vendor history, not point-in-time data.
- Research runner is operational and replayable, but correctly emits a blocked report until both evidence gates pass.
- Revisions are deduplicated by canonical `(source_id, published_at)`; the latest observed revision is selected once.
- Event-study default execution is the next trading session, including weekend/holiday event dates.
- 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. - 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 ## Exact next action
Collect independent BOT releases over time and replace/supplement revised vendor history with a point-in-time daily price source. Only then raise the readiness gate and run the event study. Add another metric only when its historical release coverage is real. Collect independent BOT releases over time and replace/supplement revised vendor history with a point-in-time daily price source. The operator can run the frozen research check now; only raise the gate when both requirements pass. Add another metric only when its historical release coverage is real.

View File

@@ -9,8 +9,9 @@
| M2 vintage collector | complete | 25 tests, manifest idempotency, live collector and point-in-time API | collect independent releases | | 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 | | M2.3 event-study gate | complete/blocked | 30 tests, pure engine and truthful 409 readiness API | add point-in-time price provider |
| M2.4 price snapshot adapter | complete/blocked | 38 tests, live 9-symbol Yahoo snapshot, revised-history gate | evaluate point-in-time price source | | M2.4 price snapshot adapter | complete/blocked | 38 tests, live 9-symbol Yahoo snapshot, revised-history gate | evaluate point-in-time price source |
| M2.5 research runner + durable paper ledger | complete/blocked | 54 tests, immutable blocked report, restart-safe paper path, live API/UI workflow | collect independent releases and point-in-time prices |
| Tourism deterministic signal | complete | live foreign-arrivals YoY surprise | add occupancy/airport metric | | 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 | | Internal paper ledger | complete | atomic local JSON persistence and restart test | shared store before multi-worker deployment |
| Dashboard | complete | Vite build + served source check with live-sign copy | visual browser capture after permission is available | | Dashboard | complete | Vite build + served source check with live-sign copy | visual browser capture after permission is available |
| LLM analysis | deferred | intentionally no LLM dependency in M0 | add after signal lineage is stable | | LLM analysis | deferred | intentionally no LLM dependency in M0 | add after signal lineage is stable |
| Webhook receiver | deferred | contract only, no external receiver | choose after core app is usable | | Webhook receiver | deferred | contract only, no external receiver | choose after core app is usable |
@@ -26,7 +27,7 @@
## Verification ## Verification
- Backend: 38 unittest tests pass. - Backend: 54 unittest tests pass.
- Independent M1 review: **PASSED**; no concrete security or logic blockers. - 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. - 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. - M1 reviewer backlog: add schema-drift, duplicate/reordered-row, and malformed-vintage regression fixtures.
@@ -44,4 +45,10 @@
- Price snapshot normalized 9 symbols with adjusted close and provider-derived trading dates; quality is explicitly `revised_vendor_history` and `point_in_time=false`. - Price snapshot normalized 9 symbols with adjusted close and provider-derived trading dates; quality is explicitly `revised_vendor_history` and `point_in_time=false`.
- Backtest gate requires both independent vintages and point-in-time prices. - Backtest gate requires both independent vintages and point-in-time prices.
- Independent M2.4 review: **PASSED**; no concrete security or logic blockers. - Independent M2.4 review: **PASSED**; no concrete security or logic blockers.
- Research runner persists blocked/ready reports with frozen input IDs, hashes, configuration and gate reasons.
- Revision-aware readiness counts `(source_id, published_at)` once and runner selects the latest revision only.
- Event-study defaults to next trading-session execution and handles non-trading event dates without skipping the first session.
- Paper ledger persists atomically under ignored `backend/data/paper/ledger.json` when configured.
- Live API `0.5.0` created and replayed the same blocked Tourism research report; UI served the research-run panel and human-readable gate reason.
- Independent M2.5 review: **PASSED**; no concrete security or logic blockers.
- Browser visual capture was blocked by Chrome remote-debugging permission; no permission dialog was clicked. - Browser visual capture was blocked by Chrome remote-debugging permission; no permission dialog was clicked.

View File

@@ -0,0 +1,83 @@
# 2026-08-23 — research runner and durable paper workflow
## Plan status
- Frozen Tourism research runner: complete.
- Immutable blocked/ready report store: complete.
- Revision-aware independent-event selection: complete.
- Next-trading-session event execution: complete.
- Durable local paper ledger: complete.
- Real validated event study: blocked by evidence gates, by design.
## Changed files
- `backend/app/research.py` — immutable research reports, gate evaluation, frozen input manifest, deterministic event-study runner.
- `backend/app/prices.py` — verified snapshot loading and raw-hash integrity checks.
- `backend/app/event_study.py` — revision-aware readiness, UTC release-key canonicalization, next-session execution anchor.
- `backend/app/vintages.py` — timezone-equivalent publication timestamps now classify as the same release revision.
- `backend/app/paper.py` — atomic JSON persistence and restart-safe loading.
- `backend/app/__init__.py` — research endpoints, durable ledger configuration, API version `0.5.0`.
- `backend/scripts/run_tourism_research.py` — headless research check CLI.
- `backend/tests/test_research.py`, `test_paper.py`, `test_event_study.py`, `test_api.py` — report, persistence, revision and calendar edge coverage.
- `frontend/src/App.vue`, `frontend/src/style.css` — Research Run panel, human-readable gate reason, result cards and navigation.
- `README.md` — operator workflow and current boundary.
## Live workflow evidence
```text
GET /api/v1/health
HTTP 200; mode=research, version=0.5.0
POST /api/v1/research/tourism/run
HTTP 200; report status=blocked; run_id persisted
GET /api/v1/research/tourism/latest
HTTP 200; same run_id returned
GET /api/v1/prices/health
HTTP 200; symbols=9, quality=revised_vendor_history, point_in_time=false
GET /api/v1/backtest/tourism?min_events=12
HTTP 409; 1 independent vintage / 12 required
```
The stored report contains the single BOT vintage ID/hash, the Yahoo price snapshot ID/hash, configuration, separate vintage and price gates, and the exact blocking reason. Re-running the same inputs returns the same immutable report rather than creating a second result.
## Verification
```text
PYTHONPATH=backend .venv/bin/python -W error -m unittest discover -s backend/tests -v
Ran 54 tests ... OK
npm run build
Vite build completed successfully.
npm audit --omit=dev --audit-level=high
found 0 vulnerabilities
python /tmp/set50_platform_security_scan.py
{}
python -m compileall -q backend
git diff --check
```
## Safety boundary
The app is operationally usable for live BOT ingestion, lineage/replay inspection, deterministic signal review, frozen research-run checks, and paper-only recording. It is **not** yet allowed to claim Tourism alpha or run a validated backtest because the archive has one independent BOT release and the available price history is revised vendor history.
No LLM, webhook, MT5 bridge or live order path was added.
## Independent review
```text
passed: true
security_concerns: []
logic_errors: []
```
Non-blocking backlog: add explicit concurrent-writer tests for atomic JSON stores and retain integration coverage for timezone-equivalent revisions and holiday/weekend execution scheduling.
## Exact next action
Continue collecting independent BOT releases. Evaluate an official/contracted price source with defensible availability timestamps or maintain a forward market-observation archive. Raise the research gate only after both requirements are true: enough independent releases and point-in-time price coverage for every event window.

View File

@@ -0,0 +1,57 @@
# Test evidence — 2026-08-23 research runner
## Automated
```text
PYTHONPATH=backend .venv/bin/python -W error -m unittest discover -s backend/tests -v
Ran 54 tests ... OK
npm run build
Vite build completed successfully.
npm audit --omit=dev --audit-level=high
found 0 vulnerabilities
python /tmp/set50_platform_security_scan.py
{}
python -m compileall -q backend
git diff --check
```
## Live API
```text
GET /api/v1/health
HTTP 200; version=0.5.0
POST /api/v1/research/tourism/run
HTTP 200; status=blocked, reason=insufficient_vintages
GET /api/v1/research/tourism/latest
HTTP 200; same immutable run_id returned
```
## Live source gates
```text
independent releases: 1 / 12
price snapshot: available
price quality: revised_vendor_history
point_in_time: false
backtest: HTTP 409 blocked
```
## Frontend served verification
The served Vite source contains the Research Run panel, `Run research check` action, human-readable gate reason, `researchReason` formatter, and the `Tourism v0.5` label. The browser screenshot permission remains unavailable; no permission dialog was clicked.
## Independent review
```text
passed: true
security_concerns: []
logic_errors: []
```
Non-blocking backlog: add explicit concurrent-writer tests for atomic JSON stores and retain integration coverage for timezone-equivalent revisions and holiday/weekend execution scheduling.

View File

@@ -6,6 +6,7 @@ const observations = ref(null)
const signalData = ref(null) const signalData = ref(null)
const ledger = ref({ entries: [] }) const ledger = ref({ entries: [] })
const backtest = ref(null) const backtest = ref(null)
const researchRun = ref(null)
const loading = ref(true) const loading = ref(true)
const error = ref('') const error = ref('')
const notice = ref('') const notice = ref('')
@@ -15,6 +16,7 @@ const submitting = ref(false)
const paperToken = ref('') const paperToken = ref('')
const paperAuthenticated = ref(false) const paperAuthenticated = ref(false)
const unlocking = ref(false) const unlocking = ref(false)
const researchRunning = ref(false)
const signals = computed(() => signalData.value?.signals ?? []) const signals = computed(() => signalData.value?.signals ?? [])
const observationRows = computed(() => observations.value?.observations ?? []) const observationRows = computed(() => observations.value?.observations ?? [])
@@ -39,6 +41,17 @@ function formatDate(value) {
}) })
} }
function researchReason(report) {
const reasons = {
insufficient_vintages: `Only ${report?.gates?.vintages?.available_events ?? 0} independent releases; ${report?.gates?.vintages?.required_events ?? 0} required.`,
price_series_not_point_in_time: 'Price history is available, but it is revised vendor history rather than point-in-time data.',
price_snapshot_missing: 'No price snapshot is available.',
price_snapshot_unreadable: 'The price snapshot failed integrity validation.',
research_inputs_invalid: 'One or more frozen inputs failed validation.',
}
return reasons[report?.reason] || report?.reason || 'Event study result is available.'
}
async function fetchJson(url, options) { async function fetchJson(url, options) {
const response = await fetch(url, options) const response = await fetch(url, options)
if (!response.ok) { if (!response.ok) {
@@ -57,17 +70,42 @@ async function fetchBacktestReadiness() {
return body return body
} }
async function fetchLatestResearch() {
const response = await fetch('/api/v1/research/tourism/latest')
if (response.status === 404) return null
const body = await response.json().catch(() => ({}))
if (!response.ok) throw new Error(body.error || `Request failed: ${response.status}`)
return body
}
async function runResearch() {
researchRunning.value = true
notice.value = ''
try {
researchRun.value = await fetchJson('/api/v1/research/tourism/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ min_events: 12, windows: [1, 3, 5, 20], cost_bps: 20, execution_lag_sessions: 1 }),
})
} catch (caught) {
notice.value = caught.message
} finally {
researchRunning.value = false
}
}
async function loadDashboard() { async function loadDashboard() {
loading.value = true loading.value = true
error.value = '' error.value = ''
try { try {
const [summaryBody, observationBody, signalBody, ledgerBody, sessionBody, backtestBody] = await Promise.all([ const [summaryBody, observationBody, signalBody, ledgerBody, sessionBody, backtestBody, researchBody] = await Promise.all([
fetchJson('/api/v1/dashboard/summary'), fetchJson('/api/v1/dashboard/summary'),
fetchJson('/api/v1/factors/tourism/observations'), fetchJson('/api/v1/factors/tourism/observations'),
fetchJson('/api/v1/signals'), fetchJson('/api/v1/signals'),
fetchJson('/api/v1/paper/ledger'), fetchJson('/api/v1/paper/ledger'),
fetchJson('/api/v1/auth/paper', { credentials: 'include' }), fetchJson('/api/v1/auth/paper', { credentials: 'include' }),
fetchBacktestReadiness(), fetchBacktestReadiness(),
fetchLatestResearch(),
]) ])
summary.value = summaryBody summary.value = summaryBody
observations.value = observationBody observations.value = observationBody
@@ -75,6 +113,7 @@ async function loadDashboard() {
ledger.value = ledgerBody ledger.value = ledgerBody
paperAuthenticated.value = Boolean(sessionBody.authenticated) paperAuthenticated.value = Boolean(sessionBody.authenticated)
backtest.value = backtestBody backtest.value = backtestBody
researchRun.value = researchBody
} catch (caught) { } catch (caught) {
error.value = caught.message error.value = caught.message
} finally { } finally {
@@ -165,6 +204,7 @@ onMounted(loadDashboard)
<a class="nav-item" href="#signals"><span class="nav-glyph"></span>Signal board</a> <a class="nav-item" href="#signals"><span class="nav-glyph"></span>Signal board</a>
<a class="nav-item" href="#factors"><span class="nav-glyph"></span>Factor explorer</a> <a class="nav-item" href="#factors"><span class="nav-glyph"></span>Factor explorer</a>
<a class="nav-item" href="#lineage"><span class="nav-glyph"></span>Data lineage</a> <a class="nav-item" href="#lineage"><span class="nav-glyph"></span>Data lineage</a>
<a class="nav-item" href="#research-run"><span class="nav-glyph"></span>Research run</a>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
@@ -175,7 +215,7 @@ onMounted(loadDashboard)
<div class="mode-detail">Paper execution only</div> <div class="mode-detail">Paper execution only</div>
</div> </div>
</div> </div>
<div class="version-line">Tourism v0.1 · API online</div> <div class="version-line">Tourism v0.5 · research + paper</div>
</div> </div>
</aside> </aside>
@@ -295,16 +335,45 @@ onMounted(loadDashboard)
</div> </div>
</section> </section>
<section class="panel research-panel" id="research-run">
<div class="panel-header">
<div>
<div class="section-kicker">04 / Frozen research run</div>
<h2>Can the evidence support a study?</h2>
</div>
<button class="primary-button" :disabled="researchRunning" @click="runResearch">{{ researchRunning ? 'Running' : 'Run research check' }}</button>
</div>
<div v-if="researchRun" class="research-grid">
<div>
<div class="research-status" :class="researchRun.status === 'ready' ? 'status-ready' : 'status-blocked'">{{ researchRun.status }}</div>
<div class="research-reason">{{ researchReason(researchRun) }}</div>
<div class="research-meta">Run {{ researchRun.run_id }} · {{ formatDate(researchRun.generated_at) }}</div>
</div>
<div class="gate-list">
<div class="gate-row"><span>Independent vintages</span><strong>{{ researchRun.gates.vintages.available_events }} / {{ researchRun.gates.vintages.required_events }} · {{ researchRun.gates.vintages.status }}</strong></div>
<div class="gate-row"><span>Price source</span><strong>{{ researchRun.gates.prices.quality || 'missing' }} · {{ researchRun.gates.prices.status }}</strong></div>
</div>
</div>
<div v-if="researchRun?.result?.windows" class="research-results">
<div v-for="(windowResult, windowKey) in researchRun.result.windows" :key="windowKey" class="research-result-row">
<span>+{{ windowKey }} sessions</span>
<strong :class="windowResult.net_return >= 0 ? 'positive-text' : 'negative-text'">{{ (windowResult.net_return * 100).toFixed(2) }}% net</strong>
<span>{{ (windowResult.hit_rate * 100).toFixed(0) }}% hit · {{ windowResult.event_count }} events</span>
</div>
</div>
<div v-if="!researchRun" class="empty-research">No frozen research run yet. Run the check to persist the current inputs and gate decision.</div>
</section>
<section class="bottom-grid"> <section class="bottom-grid">
<article class="panel thesis-panel"> <article class="panel thesis-panel">
<div class="section-kicker">04 / Research note</div> <div class="section-kicker">05 / Research note</div>
<h2>Read the signal as a thesis.</h2> <h2>Read the signal as a thesis.</h2>
<p>Tourism observations are {{ summary.theme_surprise >= 0 ? 'above' : 'below' }} expectation, so exposure determines which names receive positive or negative scores. The system deliberately stops before execution: a human still needs to review valuation, price-in, liquidity and risk.</p> <p>Tourism observations are {{ summary.theme_surprise >= 0 ? 'above' : 'below' }} expectation, so exposure determines which names receive positive or negative scores. The system deliberately stops before execution: a human still needs to review valuation, price-in, liquidity and risk.</p>
<div class="thesis-rule"><span></span>Surprise × Exposure × Confidence</div> <div class="thesis-rule"><span></span>Surprise × Exposure × Confidence</div>
</article> </article>
<article class="panel ledger-panel"> <article class="panel ledger-panel">
<div class="panel-header"> <div class="panel-header">
<div><div class="section-kicker">05 / Simulation</div><h2>Paper ledger</h2></div> <div><div class="section-kicker">06 / Simulation</div><h2>Paper ledger</h2></div>
<span class="status-tag neutral-tag">Internal only</span> <span class="status-tag neutral-tag">Internal only</span>
</div> </div>
<p>Record an assumed fill to test portfolio behavior. This does not send a webhook or order.</p> <p>Record an assumed fill to test portfolio behavior. This does not send a webhook or order.</p>

View File

@@ -112,6 +112,21 @@ tbody tr:hover { background: rgba(255,255,255,.025); }
.row-action, .primary-button { padding: 8px 10px; border: 1px solid var(--line-bright); border-radius: 5px; color: var(--text); background: transparent; font-size: 10px; white-space: nowrap; transition: .2s ease; } .row-action, .primary-button { padding: 8px 10px; border: 1px solid var(--line-bright); border-radius: 5px; color: var(--text); background: transparent; font-size: 10px; white-space: nowrap; transition: .2s ease; }
.row-action:hover { color: var(--mint); border-color: var(--mint); } .row-action:hover { color: var(--mint); border-color: var(--mint); }
.research-panel { margin-bottom: 12px; }
.research-grid { display: grid; grid-template-columns: 1fr 1.4fr; gap: 24px; align-items: center; margin-top: 22px; }
.research-status { display: inline-block; padding: 7px 9px; border-radius: 5px; font: 11px 'DM Mono', monospace; text-transform: uppercase; }
.status-ready { color: var(--mint); background: var(--mint-soft); }
.status-blocked { color: var(--amber); background: var(--amber-soft); }
.research-reason { margin-top: 12px; color: var(--muted); font-size: 12px; }
.research-meta { margin-top: 10px; color: var(--faint); font: 9px 'DM Mono', monospace; overflow-wrap: anywhere; }
.gate-list { display: grid; gap: 0; border-top: 1px solid var(--line); }
.gate-row { display: flex; justify-content: space-between; gap: 15px; padding: 12px 0; border-bottom: 1px solid var(--line); color: var(--muted); font-size: 11px; }
.gate-row strong { color: var(--text); font: 10px 'DM Mono', monospace; text-align: right; }
.research-results { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-top: 18px; }
.research-result-row { display: grid; gap: 8px; padding: 12px; border: 1px solid var(--line); border-radius: 6px; color: var(--faint); font: 9px 'DM Mono', monospace; }
.research-result-row strong { font-size: 14px; }
.empty-research { margin-top: 18px; padding: 14px; border: 1px dashed var(--line-bright); border-radius: 6px; color: var(--faint); font: 10px 'DM Mono', monospace; }
.bottom-grid { grid-template-columns: 1fr 1fr; } .bottom-grid { grid-template-columns: 1fr 1fr; }
.thesis-panel { background: linear-gradient(145deg, rgba(36,48,64,.9), rgba(15,23,34,.95)); } .thesis-panel { background: linear-gradient(145deg, rgba(36,48,64,.9), rgba(15,23,34,.95)); }
.thesis-panel p, .ledger-panel p { margin-top: 14px; color: var(--muted); font-size: 12px; line-height: 1.8; } .thesis-panel p, .ledger-panel p { margin-top: 14px; color: var(--muted); font-size: 12px; line-height: 1.8; }
@@ -153,6 +168,8 @@ input:focus { border-color: var(--mint); box-shadow: 0 0 0 3px rgba(82,214,189,.
.topbar-meta { display: flex; justify-content: space-between; align-items: center; margin-top: 18px; text-align: left; } .topbar-meta { display: flex; justify-content: space-between; align-items: center; margin-top: 18px; text-align: left; }
.hero-grid, .bottom-grid { grid-template-columns: 1fr; } .hero-grid, .bottom-grid { grid-template-columns: 1fr; }
.panel { padding: 18px; } .panel { padding: 18px; }
.research-grid { grid-template-columns: 1fr; gap: 18px; }
.research-results { grid-template-columns: repeat(2, 1fr); }
} }
@media (max-width: 500px) { @media (max-width: 500px) {