163 lines
8.5 KiB
Python
163 lines
8.5 KiB
Python
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()
|