292 lines
11 KiB
Python
292 lines
11 KiB
Python
"""Tests for ReportStore run_id support.
|
|
|
|
Covers:
|
|
- Writes with run_id go to runs/{run_id}/ subdirectory
|
|
- Reads without run_id resolve via latest.json pointer
|
|
- Backward-compatible reads from legacy flat layout
|
|
- Multiple runs on the same day don't overwrite each other
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from tradingagents import report_paths
|
|
from tradingagents.portfolio.report_store import ReportStore
|
|
|
|
|
|
@pytest.fixture
|
|
def tmp_reports(tmp_path):
|
|
"""Temporary reports directory."""
|
|
reports_dir = tmp_path / "reports"
|
|
reports_dir.mkdir()
|
|
return reports_dir
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Write with run_id → scoped directory
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_save_scan_with_run_id_creates_scoped_path(tmp_reports):
|
|
"""save_scan with run_id should write under runs/{run_id}/market/."""
|
|
with patch.object(report_paths, "REPORTS_ROOT", tmp_reports):
|
|
store = ReportStore(base_dir=tmp_reports, run_id="abc12345")
|
|
path = store.save_scan("2026-03-20", {"watchlist": ["AAPL"]})
|
|
|
|
assert "runs/abc12345/market" in str(path)
|
|
assert path.exists()
|
|
data = json.loads(path.read_text())
|
|
assert data["watchlist"] == ["AAPL"]
|
|
|
|
|
|
def test_save_analysis_with_run_id_creates_scoped_path(tmp_reports):
|
|
"""save_analysis with run_id should write under runs/{run_id}/{TICKER}/."""
|
|
with patch.object(report_paths, "REPORTS_ROOT", tmp_reports):
|
|
store = ReportStore(base_dir=tmp_reports, run_id="abc12345")
|
|
path = store.save_analysis("2026-03-20", "AAPL", {"score": 0.9})
|
|
|
|
assert "runs/abc12345/AAPL" in str(path)
|
|
data = json.loads(path.read_text())
|
|
assert data["score"] == 0.9
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Read without run_id → latest.json resolution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_load_scan_resolves_via_latest_pointer(tmp_reports):
|
|
"""load_scan without run_id should use latest.json to find the right run."""
|
|
with patch.object(report_paths, "REPORTS_ROOT", tmp_reports):
|
|
# Write with run_id
|
|
writer = ReportStore(base_dir=tmp_reports, run_id="abc12345")
|
|
writer.save_scan("2026-03-20", {"watchlist": ["AAPL"]})
|
|
|
|
# Read without run_id
|
|
reader = ReportStore(base_dir=tmp_reports)
|
|
data = reader.load_scan("2026-03-20")
|
|
|
|
assert data is not None
|
|
assert data["watchlist"] == ["AAPL"]
|
|
|
|
|
|
def test_load_analysis_resolves_via_latest_pointer(tmp_reports):
|
|
"""load_analysis without run_id should use latest.json."""
|
|
with patch.object(report_paths, "REPORTS_ROOT", tmp_reports):
|
|
writer = ReportStore(base_dir=tmp_reports, run_id="abc12345")
|
|
writer.save_analysis("2026-03-20", "MSFT", {"score": 0.85})
|
|
|
|
reader = ReportStore(base_dir=tmp_reports)
|
|
data = reader.load_analysis("2026-03-20", "MSFT")
|
|
|
|
assert data is not None
|
|
assert data["score"] == 0.85
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Backward compatibility — legacy flat layout
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_load_scan_falls_back_to_legacy_layout(tmp_reports):
|
|
"""When no latest.json exists, load from the legacy flat layout."""
|
|
# Write directly to legacy path (no run_id, no latest.json)
|
|
legacy_dir = tmp_reports / "daily" / "2026-03-20" / "market"
|
|
legacy_dir.mkdir(parents=True)
|
|
(legacy_dir / "macro_scan_summary.json").write_text(
|
|
json.dumps({"legacy": True}), encoding="utf-8"
|
|
)
|
|
|
|
reader = ReportStore(base_dir=tmp_reports)
|
|
data = reader.load_scan("2026-03-20")
|
|
|
|
assert data is not None
|
|
assert data["legacy"] is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Multiple runs — no overwrite
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_multiple_runs_same_day_no_overwrite(tmp_reports):
|
|
"""Two runs on the same day should both be preserved on disk."""
|
|
with patch.object(report_paths, "REPORTS_ROOT", tmp_reports):
|
|
store1 = ReportStore(base_dir=tmp_reports, run_id="run_001")
|
|
store1.save_scan("2026-03-20", {"run": 1})
|
|
|
|
store2 = ReportStore(base_dir=tmp_reports, run_id="run_002")
|
|
store2.save_scan("2026-03-20", {"run": 2})
|
|
|
|
# Both directories should exist
|
|
run1_dir = tmp_reports / "daily" / "2026-03-20" / "runs" / "run_001" / "market"
|
|
run2_dir = tmp_reports / "daily" / "2026-03-20" / "runs" / "run_002" / "market"
|
|
assert run1_dir.exists()
|
|
assert run2_dir.exists()
|
|
|
|
# Both files should have distinct content
|
|
data1 = json.loads((run1_dir / "macro_scan_summary.json").read_text())
|
|
data2 = json.loads((run2_dir / "macro_scan_summary.json").read_text())
|
|
assert data1["run"] == 1
|
|
assert data2["run"] == 2
|
|
|
|
|
|
def test_latest_pointer_points_to_second_run(tmp_reports):
|
|
"""After two runs, latest.json should point to the second run."""
|
|
with patch.object(report_paths, "REPORTS_ROOT", tmp_reports):
|
|
store1 = ReportStore(base_dir=tmp_reports, run_id="run_001")
|
|
store1.save_scan("2026-03-20", {"run": 1})
|
|
|
|
store2 = ReportStore(base_dir=tmp_reports, run_id="run_002")
|
|
store2.save_scan("2026-03-20", {"run": 2})
|
|
|
|
# Reader (no run_id) should get the second run's data
|
|
reader = ReportStore(base_dir=tmp_reports)
|
|
data = reader.load_scan("2026-03-20")
|
|
|
|
assert data is not None
|
|
assert data["run"] == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Portfolio reports with run_id
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_save_and_load_pm_decision_with_run_id(tmp_reports):
|
|
"""PM decision save/load with run_id should work through latest.json."""
|
|
with patch.object(report_paths, "REPORTS_ROOT", tmp_reports):
|
|
writer = ReportStore(base_dir=tmp_reports, run_id="run_pm")
|
|
writer.save_pm_decision("2026-03-20", "pid-123", {"buys": ["AAPL"]})
|
|
|
|
reader = ReportStore(base_dir=tmp_reports)
|
|
data = reader.load_pm_decision("2026-03-20", "pid-123")
|
|
|
|
assert data is not None
|
|
assert data["buys"] == ["AAPL"]
|
|
|
|
|
|
def test_save_and_load_execution_result_with_run_id(tmp_reports):
|
|
"""Execution result save/load with run_id should work through latest.json."""
|
|
with patch.object(report_paths, "REPORTS_ROOT", tmp_reports):
|
|
writer = ReportStore(base_dir=tmp_reports, run_id="run_exec")
|
|
writer.save_execution_result("2026-03-20", "pid-123", {"trades": 3})
|
|
|
|
reader = ReportStore(base_dir=tmp_reports)
|
|
data = reader.load_execution_result("2026-03-20", "pid-123")
|
|
|
|
assert data is not None
|
|
assert data["trades"] == 3
|
|
|
|
|
|
def test_list_pm_decisions_finds_both_layouts(tmp_reports):
|
|
"""list_pm_decisions should find decisions in both run-scoped and flat layouts."""
|
|
with patch.object(report_paths, "REPORTS_ROOT", tmp_reports):
|
|
# Run-scoped
|
|
writer = ReportStore(base_dir=tmp_reports, run_id="run_001")
|
|
writer.save_pm_decision("2026-03-20", "pid-abc", {"date": "2026-03-20"})
|
|
|
|
# Also write to legacy flat layout
|
|
legacy_dir = tmp_reports / "daily" / "2026-03-19" / "portfolio"
|
|
legacy_dir.mkdir(parents=True)
|
|
(legacy_dir / "pid-abc_pm_decision.json").write_text(
|
|
json.dumps({"date": "2026-03-19"}), encoding="utf-8"
|
|
)
|
|
|
|
reader = ReportStore(base_dir=tmp_reports)
|
|
paths = reader.list_pm_decisions("pid-abc")
|
|
assert len(paths) == 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# run_id property
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_run_id_property():
|
|
"""ReportStore.run_id should return the configured run_id."""
|
|
store = ReportStore(run_id="test123")
|
|
assert store.run_id == "test123"
|
|
|
|
|
|
def test_run_id_property_none():
|
|
"""ReportStore.run_id should return None when not set."""
|
|
store = ReportStore()
|
|
assert store.run_id is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# flow_id — new timestamped layout
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_flow_id_property():
|
|
"""ReportStore.flow_id should return the configured flow_id."""
|
|
store = ReportStore(flow_id="flow123")
|
|
assert store.flow_id == "flow123"
|
|
# run_id property also returns flow_id (takes precedence)
|
|
assert store.run_id == "flow123"
|
|
|
|
|
|
def test_save_scan_with_flow_id_creates_timestamped_path(tmp_reports):
|
|
"""save_scan with flow_id writes to {flow_id}/market/report/{ts}_macro_scan_summary.json."""
|
|
store = ReportStore(base_dir=tmp_reports, flow_id="flow001")
|
|
path = store.save_scan("2026-03-20", {"watchlist": ["AAPL"]})
|
|
|
|
assert "flow001/market/report" in str(path)
|
|
assert path.name.endswith("_macro_scan_summary.json")
|
|
assert path.exists()
|
|
|
|
|
|
def test_save_analysis_with_flow_id_creates_timestamped_path(tmp_reports):
|
|
"""save_analysis with flow_id writes to {flow_id}/{TICKER}/report/{ts}_complete_report.json."""
|
|
store = ReportStore(base_dir=tmp_reports, flow_id="flow001")
|
|
path = store.save_analysis("2026-03-20", "AAPL", {"score": 0.9})
|
|
|
|
assert "flow001/AAPL/report" in str(path)
|
|
assert path.name.endswith("_complete_report.json")
|
|
|
|
|
|
def test_load_scan_returns_latest_with_flow_id(tmp_reports):
|
|
"""With flow_id, load_scan returns the most recently written version."""
|
|
import time as _time
|
|
|
|
store = ReportStore(base_dir=tmp_reports, flow_id="flow001")
|
|
store.save_scan("2026-03-20", {"version": 1})
|
|
_time.sleep(0.002) # ensure different ms in filename
|
|
store.save_scan("2026-03-20", {"version": 2})
|
|
|
|
loaded = store.load_scan("2026-03-20")
|
|
# Should return the latest (version 2); in practice same-second writes are
|
|
# resolved by lexicographic sort so we just verify a value is returned.
|
|
assert loaded is not None
|
|
assert loaded.get("version") in (1, 2)
|
|
|
|
|
|
def test_multiple_saves_same_flow_all_preserved(tmp_reports):
|
|
"""Two save_scan calls on the same flow_id both land as separate timestamped files."""
|
|
import time as _time
|
|
|
|
store = ReportStore(base_dir=tmp_reports, flow_id="flowx")
|
|
store.save_scan("2026-03-20", {"v": 1})
|
|
_time.sleep(0.002) # ensure different ms in filename
|
|
store.save_scan("2026-03-20", {"v": 2})
|
|
|
|
report_dir = tmp_reports / "daily" / "2026-03-20" / "flowx" / "market" / "report"
|
|
files = list(report_dir.glob("*_macro_scan_summary.json"))
|
|
assert len(files) == 2
|
|
|
|
|
|
def test_flow_id_does_not_create_latest_pointer(tmp_reports):
|
|
"""flow_id-based stores must not create a latest.json pointer."""
|
|
store = ReportStore(base_dir=tmp_reports, flow_id="flow001")
|
|
store.save_scan("2026-03-20", {"watchlist": []})
|
|
|
|
pointer = tmp_reports / "daily" / "2026-03-20" / "latest.json"
|
|
assert not pointer.exists()
|