TradingAgents/tests/portfolio/test_report_store.py

177 lines
6.8 KiB
Python

"""Tests for tradingagents/portfolio/report_store.py.
Tests filesystem save/load operations for all report types.
All tests use a temporary directory (``tmp_reports`` fixture) and do not
require Supabase or network access.
Run::
pytest tests/portfolio/test_report_store.py -v
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from tradingagents.portfolio.exceptions import ReportStoreError
from tradingagents.portfolio.report_store import ReportStore
# ---------------------------------------------------------------------------
# Macro scan
# ---------------------------------------------------------------------------
def test_save_and_load_scan(report_store, tmp_reports):
"""save_scan() then load_scan() must return the original data."""
data = {"watchlist": ["AAPL", "MSFT"], "date": "2026-03-20"}
path = report_store.save_scan("2026-03-20", data)
assert path.exists()
loaded = report_store.load_scan("2026-03-20")
assert loaded == data
def test_load_scan_returns_none_for_missing_file(report_store):
"""load_scan() must return None when the file does not exist."""
result = report_store.load_scan("1900-01-01")
assert result is None
# ---------------------------------------------------------------------------
# Per-ticker analysis
# ---------------------------------------------------------------------------
def test_save_and_load_analysis(report_store):
"""save_analysis() then load_analysis() must return the original data."""
data = {"ticker": "AAPL", "recommendation": "BUY", "score": 0.92}
report_store.save_analysis("2026-03-20", "AAPL", data)
loaded = report_store.load_analysis("2026-03-20", "AAPL")
assert loaded == data
def test_analysis_ticker_stored_as_uppercase(report_store, tmp_reports):
"""Ticker symbol must be stored as uppercase in the directory path."""
data = {"ticker": "aapl"}
report_store.save_analysis("2026-03-20", "aapl", data)
expected = tmp_reports / "daily" / "2026-03-20" / "AAPL" / "complete_report.json"
assert expected.exists()
# load with lowercase should still work
loaded = report_store.load_analysis("2026-03-20", "aapl")
assert loaded == data
# ---------------------------------------------------------------------------
# Holding reviews
# ---------------------------------------------------------------------------
def test_save_and_load_holding_review(report_store):
"""save_holding_review() then load_holding_review() must round-trip."""
data = {"ticker": "MSFT", "verdict": "HOLD", "price_target": 420.0}
report_store.save_holding_review("2026-03-20", "MSFT", data)
loaded = report_store.load_holding_review("2026-03-20", "MSFT")
assert loaded == data
def test_load_holding_review_returns_none_for_missing(report_store):
"""load_holding_review() must return None when the file does not exist."""
result = report_store.load_holding_review("1900-01-01", "ZZZZ")
assert result is None
# ---------------------------------------------------------------------------
# Risk metrics
# ---------------------------------------------------------------------------
def test_save_and_load_risk_metrics(report_store):
"""save_risk_metrics() then load_risk_metrics() must round-trip."""
data = {"sharpe": 1.35, "sortino": 1.8, "max_drawdown": -0.12}
report_store.save_risk_metrics("2026-03-20", "pid-123", data)
loaded = report_store.load_risk_metrics("2026-03-20", "pid-123")
assert loaded == data
# ---------------------------------------------------------------------------
# PM decisions
# ---------------------------------------------------------------------------
def test_save_and_load_pm_decision_json(report_store):
"""save_pm_decision() then load_pm_decision() must round-trip JSON."""
decision = {"sells": [], "buys": [{"ticker": "AAPL", "shares": 10}]}
report_store.save_pm_decision("2026-03-20", "pid-123", decision)
loaded = report_store.load_pm_decision("2026-03-20", "pid-123")
assert loaded == decision
def test_save_pm_decision_writes_markdown_when_provided(report_store, tmp_reports):
"""When markdown is passed to save_pm_decision(), .md file must be written."""
decision = {"sells": [], "buys": []}
md_text = "# Decision\n\nHold everything."
report_store.save_pm_decision("2026-03-20", "pid-123", decision, markdown=md_text)
md_path = tmp_reports / "daily" / "2026-03-20" / "portfolio" / "pid-123_pm_decision.md"
assert md_path.exists()
assert md_path.read_text(encoding="utf-8") == md_text
def test_save_pm_decision_no_markdown_file_when_not_provided(report_store, tmp_reports):
"""When markdown=None, no .md file should be written."""
decision = {"sells": [], "buys": []}
report_store.save_pm_decision("2026-03-20", "pid-123", decision, markdown=None)
md_path = tmp_reports / "daily" / "2026-03-20" / "portfolio" / "pid-123_pm_decision.md"
assert not md_path.exists()
def test_load_pm_decision_returns_none_for_missing(report_store):
"""load_pm_decision() must return None when the file does not exist."""
result = report_store.load_pm_decision("1900-01-01", "pid-none")
assert result is None
def test_list_pm_decisions(report_store):
"""list_pm_decisions() must return all saved decision paths, newest first."""
dates = ["2026-03-18", "2026-03-19", "2026-03-20"]
for d in dates:
report_store.save_pm_decision(d, "pid-abc", {"date": d})
paths = report_store.list_pm_decisions("pid-abc")
assert len(paths) == 3
# Sorted newest first by ISO date string ordering
date_parts = [p.parent.parent.name for p in paths]
assert date_parts == sorted(dates, reverse=True)
# ---------------------------------------------------------------------------
# Filesystem behaviour
# ---------------------------------------------------------------------------
def test_directories_created_on_write(report_store, tmp_reports):
"""Directories must be created automatically on first write."""
target_dir = tmp_reports / "daily" / "2026-03-20" / "portfolio"
assert not target_dir.exists()
report_store.save_risk_metrics("2026-03-20", "pid-123", {"sharpe": 1.2})
assert target_dir.is_dir()
def test_json_formatted_with_indent(report_store, tmp_reports):
"""Written JSON files must use indent=2 for human readability."""
data = {"key": "value", "nested": {"a": 1}}
path = report_store.save_scan("2026-03-20", data)
raw = path.read_text(encoding="utf-8")
# indent=2 means lines like ' "key": ...'
assert ' "key"' in raw
def test_read_json_raises_on_corrupt_file(report_store, tmp_reports):
"""_read_json must raise ReportStoreError for corrupt JSON."""
corrupt = tmp_reports / "corrupt.json"
corrupt.write_text("not valid json{{{", encoding="utf-8")
with pytest.raises(ReportStoreError):
report_store._read_json(corrupt)