import copy import json import tempfile import unittest from pathlib import Path from app.prices import PriceSnapshotStore, PriceSourceError from app.research import ResearchRunError, 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_price_store_rejects_manifest_metadata_tampering(self): with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) store = PriceSnapshotStore(root) snapshot, raw = price_snapshot(point_in_time=False) store.persist(snapshot, raw) manifest_path = root / "manifest.json" manifest = json.loads(manifest_path.read_text(encoding="utf-8")) manifest["snapshots"]["prices-test"]["point_in_time"] = True manifest_path.write_text(json.dumps(manifest), encoding="utf-8") with self.assertRaisesRegex(PriceSourceError, "metadata"): store.load_snapshot("prices-test") 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"]}) def test_runner_does_not_replay_cached_ready_report_after_price_tampering(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")): vintage_store.persist(f"tourism-{index}".encode(), tourism_snapshot(f"v{index}", published_at)) 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,)) self.assertEqual(report["status"], "ready") manifest_path = root / "prices" / "manifest.json" manifest = json.loads(manifest_path.read_text(encoding="utf-8")) manifest["snapshots"]["prices-test"]["point_in_time"] = False manifest_path.write_text(json.dumps(manifest), encoding="utf-8") with self.assertRaisesRegex(ResearchRunError, "integrity"): run_tourism_research(vintage_store, price_store, run_store, min_events=2, windows=(1,)) if __name__ == "__main__": unittest.main()