diff --git a/README.md b/README.md index 9a0a248..75e4ed0 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,9 @@ npm run dev -- --host 127.0.0.1 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 @@ -63,6 +63,8 @@ GET /api/v1/vintages?as_of= GET /api/v1/replay/tourism?vintage_id= GET /api/v1/backtest/tourism?min_events=12 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` @@ -95,7 +97,10 @@ cd frontend && npm run build - Read-only data-health, vintage timeline and vintage replay endpoints - Yahoo-backed daily price snapshot contract with SET symbol mapping - 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 +- Frozen, replayable Tourism research-run reports with immutable input manifests +- Durable local paper ledger across backend restarts - Deterministic surprise × exposure × confidence score - Paper ledger endpoint - 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. +## 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 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: diff --git a/backend/app/__init__.py b/backend/app/__init__.py index 2cc480d..722f498 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -17,10 +17,11 @@ from .bot_tourism import BotTourismSource, TourismSourceError from .event_study import EventStudyError, assess_backtest_readiness from .paper import PaperLedger from .prices import PriceSnapshotStore, PriceSourceError +from .research import ResearchRunError, ResearchRunStore, run_tourism_research from .tourism import compute_tourism_signal from .vintages import VintageStore, VintageStoreError -APP_VERSION = "0.4.0" +APP_VERSION = "0.5.0" 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_COOKIE_SECURE=os.getenv("PAPER_COOKIE_SECURE", "0") == "1", 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_ADAPTER=None, 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, PRICE_DATA_ROOT=Path(os.getenv("PRICE_DATA_ROOT", str(data_root / "prices"))), PRICE_STORE=None, + RESEARCH_DATA_ROOT=Path(os.getenv("RESEARCH_DATA_ROOT", str(data_root / "research"))), + RESEARCH_RUN_STORE=None, SNAPSHOT=_load_default_snapshot(), ) if config: @@ -64,6 +68,8 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: app.extensions["vintage_store"] = vintage_store price_store = app.config.get("PRICE_STORE") or PriceSnapshotStore(app.config["PRICE_DATA_ROOT"]) 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_mode = str(app.config["TOURISM_SOURCE"]).lower() 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}") 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] = {} allowed_symbols = {item["symbol"] for item in result["signals"]} app.extensions["tourism_result"] = result @@ -176,6 +185,35 @@ def create_app(config: dict[str, Any] | None = None) -> Flask: def prices_health(): 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") def data_health(): current = app.extensions["tourism_result"] diff --git a/backend/app/event_study.py b/backend/app/event_study.py index 9438b0c..b1263f9 100644 --- a/backend/app/event_study.py +++ b/backend/app/event_study.py @@ -24,12 +24,30 @@ def _parse_date(value: str) -> date: 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]: - if min_events < 1: + if isinstance(min_events, bool) or 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) + independent_keys: set[tuple[str, str, str]] = set() + for item in vintages: + 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: return { "status": "blocked", @@ -63,12 +81,18 @@ def _price_map(symbol: str, rows: Sequence[Mapping[str, Any]]) -> dict[date, flo 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) 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] + 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 if end >= len(dates): 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), cost_bps: float = 0.0, min_events: int = 12, + execution_lag_sessions: int = 1, ) -> dict[str, Any]: """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): 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: cost_bps = float(cost_bps) except (TypeError, ValueError) as exc: @@ -133,13 +161,13 @@ def run_event_study( 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) + gross += weight * _window_return(normalized_prices[symbol], event["event_date"], int(window), symbol, execution_lag_sessions) 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")) + benchmark_returns.append(_window_return(normalized_benchmark, event["event_date"], int(window), "benchmark", execution_lag_sessions)) average_gross = fmean(gross_returns) average_net = fmean(net_returns) 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, "hit_rate": round(sum(value > 0 for value in net_returns) / len(net_returns), 8), "cost_bps": cost_bps, + "execution_lag_sessions": execution_lag_sessions, "event_returns": [round(value, 8) for value in net_returns], } diff --git a/backend/app/paper.py b/backend/app/paper.py index e993ede..ee3ea9b 100644 --- a/backend/app/paper.py +++ b/backend/app/paper.py @@ -2,14 +2,48 @@ from __future__ import annotations +import json +from copy import deepcopy from datetime import datetime, timezone from math import isfinite +from pathlib import Path from uuid import uuid4 +PAPER_LEDGER_SCHEMA_VERSION = 1 + + +class PaperLedgerError(ValueError): + """Raised when a persistent paper ledger is invalid or unreadable.""" + 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] = [] + 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: symbol = str(payload.get("symbol", "")).strip().upper() @@ -34,8 +68,10 @@ class PaperLedger: "assumed_price": assumed_price, "status": "PAPER_RECORDED", } - self._entries.append(entry) - return entry + candidate_entries = [*self._entries, entry] + self._persist(candidate_entries) + self._entries = candidate_entries + return deepcopy(entry) def entries(self) -> list[dict]: - return list(self._entries) + return deepcopy(self._entries) diff --git a/backend/app/prices.py b/backend/app/prices.py index 112d8be..af86c14 100644 --- a/backend/app/prices.py +++ b/backend/app/prices.py @@ -230,6 +230,55 @@ class PriceSnapshotStore: raise PriceSourceError("unsupported price manifest schema") 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]: source = snapshot.get("source") 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_end": stored_source.get("period_end"), "raw_payload_hash": raw_hash, + "parser_version": stored_source.get("parser_version"), "quality": stored_source.get("quality"), "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()), "snapshot_file": stored_source["snapshot_file"], } @@ -299,6 +351,7 @@ def collect_price_snapshot( "quality": "revised_vendor_history", "point_in_time": False, "adjusted_prices": True, + "return_price_field": "close", "snapshot_id": snapshot_id, "bar_counts": {symbol: len(series["bars"]) for symbol, series in normalized.items()}, } diff --git a/backend/app/research.py b/backend/app/research.py new file mode 100644 index 0000000..30f4eea --- /dev/null +++ b/backend/app/research.py @@ -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) diff --git a/backend/app/vintages.py b/backend/app/vintages.py index 0990b6f..4873380 100644 --- a/backend/app/vintages.py +++ b/backend/app/vintages.py @@ -116,7 +116,7 @@ class VintageStore: existing = manifest["vintages"].get(vintage_id) same_release = any( 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 for item in manifest["vintages"].values() ) diff --git a/backend/scripts/run_tourism_research.py b/backend/scripts/run_tourism_research.py new file mode 100644 index 0000000..46d4986 --- /dev/null +++ b/backend/scripts/run_tourism_research.py @@ -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()) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 75b15ae..f642ef0 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -5,6 +5,7 @@ from pathlib import Path from app import create_app from app.prices import PriceSnapshotStore +from app.research import ResearchRunStore from app.vintages import VintageStore @@ -137,6 +138,34 @@ class ApiTests(unittest.TestCase): self.assertFalse(body["available"]) 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): response = self.client.get("/api/v1/backtest/tourism?min_events=bad") self.assertEqual(response.status_code, 400) @@ -192,10 +221,23 @@ class ApiTests(unittest.TestCase): json={"symbol": "AOT", "target_weight": 0.1, "assumed_price": 60}, ) 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"] 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__": unittest.main() diff --git a/backend/tests/test_event_study.py b/backend/tests/test_event_study.py index 724c3d9..0b5b86e 100644 --- a/backend/tests/test_event_study.py +++ b/backend/tests/test_event_study.py @@ -11,6 +11,29 @@ class EventStudyTests(unittest.TestCase): self.assertEqual(result["available_events"], 1) 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): events = [ { @@ -31,10 +54,10 @@ class EventStudyTests(unittest.TestCase): }, ] 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}], + "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}, {"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"] self.assertEqual(result["status"], "ready") self.assertEqual(result["event_count"], 2) @@ -47,6 +70,30 @@ class EventStudyTests(unittest.TestCase): with self.assertRaisesRegex(EventStudyError, "missing prices"): 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__": unittest.main() diff --git a/backend/tests/test_paper.py b/backend/tests/test_paper.py new file mode 100644 index 0000000..f37c28a --- /dev/null +++ b/backend/tests/test_paper.py @@ -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() diff --git a/backend/tests/test_research.py b/backend/tests/test_research.py new file mode 100644 index 0000000..d821194 --- /dev/null +++ b/backend/tests/test_research.py @@ -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() diff --git a/backend/tests/test_vintages.py b/backend/tests/test_vintages.py index 6ff674c..3910a9a 100644 --- a/backend/tests/test_vintages.py +++ b/backend/tests/test_vintages.py @@ -83,6 +83,24 @@ class VintageStoreTests(unittest.TestCase): self.assertEqual(len(entries), 2) 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): entries = [ {"vintage_id": "old", "published_at": "2026-07-31T14:30:00+07:00"}, diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 817790a..828633c 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -4,11 +4,11 @@ - Path: `/Users/kunthawat/Gitea/set50-alternative-data-platform` - Branch: `main` -- Verified code commit: `7f7a614` — `[verified] add SET price snapshot adapter` -- Current milestone: M2.4 price snapshot foundation complete; real backtest blocked +- Verified code commit: `7f7a614` — `[verified] add SET price snapshot adapter` (M2.5 pending commit) +- Current milestone: M2.5 research runner and durable paper workflow complete; validated backtest blocked - Mode: research + paper only - Frontend: Vue 3 + Vite -- Backend: Flask `0.4.0` +- Backend: Flask `0.5.0` - Current runtime source: BOT Tourism Indicators (`TOURISM_SOURCE=bot`) ## Completed @@ -26,6 +26,9 @@ - Backtest readiness gate: `GET /api/v1/backtest/tourism?min_events=12`. - Yahoo-backed daily price snapshot with SET symbol mapping and adjusted-close bars. - 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. - English dashboard with live/provisional source label, sign-aware surprise copy and lineage fields. - HttpOnly paper session and internal paper ledger. @@ -57,7 +60,7 @@ npm run build Vite build completed successfully. 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 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 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. @@ -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. - 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. +- 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. ## 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. diff --git a/docs/engineering-log.md b/docs/engineering-log.md index 38191c6..e599d52 100644 --- a/docs/engineering-log.md +++ b/docs/engineering-log.md @@ -9,8 +9,9 @@ | 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.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 | -| 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 | | 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 | @@ -26,7 +27,7 @@ ## Verification -- Backend: 38 unittest tests pass. +- Backend: 54 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. @@ -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`. - Backtest gate requires both independent vintages and point-in-time prices. - 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. diff --git a/docs/engineering-log/2026-08-23-research-run.md b/docs/engineering-log/2026-08-23-research-run.md new file mode 100644 index 0000000..6b0eb21 --- /dev/null +++ b/docs/engineering-log/2026-08-23-research-run.md @@ -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. diff --git a/docs/test-evidence/2026-08-23-research-run.md b/docs/test-evidence/2026-08-23-research-run.md new file mode 100644 index 0000000..3c5968c --- /dev/null +++ b/docs/test-evidence/2026-08-23-research-run.md @@ -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. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 570b39c..571624b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -6,6 +6,7 @@ const observations = ref(null) const signalData = ref(null) const ledger = ref({ entries: [] }) const backtest = ref(null) +const researchRun = ref(null) const loading = ref(true) const error = ref('') const notice = ref('') @@ -15,6 +16,7 @@ const submitting = ref(false) const paperToken = ref('') const paperAuthenticated = ref(false) const unlocking = ref(false) +const researchRunning = ref(false) const signals = computed(() => signalData.value?.signals ?? []) 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) { const response = await fetch(url, options) if (!response.ok) { @@ -57,17 +70,42 @@ async function fetchBacktestReadiness() { 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() { loading.value = true error.value = '' 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/factors/tourism/observations'), fetchJson('/api/v1/signals'), fetchJson('/api/v1/paper/ledger'), fetchJson('/api/v1/auth/paper', { credentials: 'include' }), fetchBacktestReadiness(), + fetchLatestResearch(), ]) summary.value = summaryBody observations.value = observationBody @@ -75,6 +113,7 @@ async function loadDashboard() { ledger.value = ledgerBody paperAuthenticated.value = Boolean(sessionBody.authenticated) backtest.value = backtestBody + researchRun.value = researchBody } catch (caught) { error.value = caught.message } finally { @@ -165,6 +204,7 @@ onMounted(loadDashboard) ↗Signal board ∿Factor explorer ⌁Data lineage + ✓Research run -
Tourism v0.1 · API online
+
Tourism v0.5 · research + paper
@@ -295,16 +335,45 @@ onMounted(loadDashboard) +
+
+
+
04 / Frozen research run
+

Can the evidence support a study?

+
+ +
+
+
+
{{ researchRun.status }}
+
{{ researchReason(researchRun) }}
+
Run {{ researchRun.run_id }} · {{ formatDate(researchRun.generated_at) }}
+
+
+
Independent vintages{{ researchRun.gates.vintages.available_events }} / {{ researchRun.gates.vintages.required_events }} · {{ researchRun.gates.vintages.status }}
+
Price source{{ researchRun.gates.prices.quality || 'missing' }} · {{ researchRun.gates.prices.status }}
+
+
+
+
+ +{{ windowKey }} sessions + {{ (windowResult.net_return * 100).toFixed(2) }}% net + {{ (windowResult.hit_rate * 100).toFixed(0) }}% hit · {{ windowResult.event_count }} events +
+
+
No frozen research run yet. Run the check to persist the current inputs and gate decision.
+
+
-
04 / Research note
+
05 / Research note

Read the signal as a thesis.

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.

Surprise × Exposure × Confidence
-
05 / Simulation

Paper ledger

+
06 / Simulation

Paper ledger

Internal only

Record an assumed fill to test portfolio behavior. This does not send a webhook or order.

diff --git a/frontend/src/style.css b/frontend/src/style.css index 2ae6e92..b13f5b2 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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: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; } .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; } @@ -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; } .hero-grid, .bottom-grid { grid-template-columns: 1fr; } .panel { padding: 18px; } + .research-grid { grid-template-columns: 1fr; gap: 18px; } + .research-results { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 500px) {