[verified] add frozen research runner and durable paper ledger
This commit is contained in:
33
README.md
33
README.md
@@ -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:
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
296
backend/app/research.py
Normal 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)
|
||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|||||||
36
backend/scripts/run_tourism_research.py
Normal file
36
backend/scripts/run_tourism_research.py
Normal 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())
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
31
backend/tests/test_paper.py
Normal file
31
backend/tests/test_paper.py
Normal 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()
|
||||||
130
backend/tests/test_research.py
Normal file
130
backend/tests/test_research.py
Normal 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()
|
||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
83
docs/engineering-log/2026-08-23-research-run.md
Normal file
83
docs/engineering-log/2026-08-23-research-run.md
Normal 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.
|
||||||
57
docs/test-evidence/2026-08-23-research-run.md
Normal file
57
docs/test-evidence/2026-08-23-research-run.md
Normal 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.
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user