TradingAgents/tests/portfolio/test_risk_metrics.py

508 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for tradingagents/portfolio/risk_metrics.py.
Coverage:
- Happy-path: all metrics computed from sufficient data
- Sharpe / Sortino: correct annualisation, sign, edge cases
- VaR: 5th-percentile logic
- Max drawdown: correct peak-to-trough
- Beta: covariance / variance calculation
- Sector concentration: weighted from holdings_snapshot
- Insufficient data: returns None gracefully
- Single snapshot: n_days = 0, all None
- Type validation: raises TypeError for non-PortfolioSnapshot input
Run::
pytest tests/portfolio/test_risk_metrics.py -v
"""
from __future__ import annotations
import math
import pytest
from tradingagents.portfolio.models import PortfolioSnapshot
from tradingagents.portfolio.risk_metrics import (
_daily_returns,
_mean,
_percentile,
_std,
compute_risk_metrics,
)
# ---------------------------------------------------------------------------
# Helper factories
# ---------------------------------------------------------------------------
def make_snapshot(
total_value: float,
date: str = "2026-01-01",
holdings: list[dict] | None = None,
portfolio_id: str = "pid",
) -> PortfolioSnapshot:
"""Create a minimal PortfolioSnapshot for testing."""
return PortfolioSnapshot(
snapshot_id="snap-1",
portfolio_id=portfolio_id,
snapshot_date=date,
total_value=total_value,
cash=0.0,
equity_value=total_value,
num_positions=len(holdings) if holdings else 0,
holdings_snapshot=holdings or [],
)
def nav_snapshots(nav_values: list[float]) -> list[PortfolioSnapshot]:
"""Build a list of snapshots from NAV values, one per day."""
return [
make_snapshot(v, date=f"2026-01-{i + 1:02d}")
for i, v in enumerate(nav_values)
]
# ---------------------------------------------------------------------------
# Internal helper tests
# ---------------------------------------------------------------------------
class TestDailyReturns:
def test_single_step(self):
assert _daily_returns([100.0, 110.0]) == pytest.approx([0.1])
def test_multi_step(self):
r = _daily_returns([100.0, 110.0, 99.0])
assert r[0] == pytest.approx(0.1)
assert r[1] == pytest.approx((99.0 - 110.0) / 110.0)
def test_zero_previous_returns_zero(self):
r = _daily_returns([0.0, 100.0])
assert r == [0.0]
def test_empty_list(self):
assert _daily_returns([]) == []
def test_one_element(self):
assert _daily_returns([100.0]) == []
class TestMean:
def test_basic(self):
assert _mean([1.0, 2.0, 3.0]) == pytest.approx(2.0)
def test_single(self):
assert _mean([5.0]) == pytest.approx(5.0)
def test_empty_raises(self):
with pytest.raises(ValueError, match="empty"):
_mean([])
class TestStd:
def test_sample_std(self):
# [1, 2, 3] sample std = sqrt(1) = 1
assert _std([1.0, 2.0, 3.0]) == pytest.approx(1.0)
def test_zero_variance(self):
assert _std([5.0, 5.0, 5.0]) == pytest.approx(0.0)
def test_insufficient_data_returns_zero(self):
assert _std([1.0], ddof=1) == 0.0
class TestPercentile:
def test_median(self):
assert _percentile([1.0, 2.0, 3.0, 4.0, 5.0], 50) == pytest.approx(3.0)
def test_5th_percentile_all_equal(self):
assert _percentile([0.01] * 10, 5) == pytest.approx(0.01)
def test_0th_percentile_is_min(self):
assert _percentile([3.0, 1.0, 2.0], 0) == pytest.approx(1.0)
def test_100th_percentile_is_max(self):
assert _percentile([3.0, 1.0, 2.0], 100) == pytest.approx(3.0)
def test_empty_raises(self):
with pytest.raises(ValueError):
_percentile([], 50)
# ---------------------------------------------------------------------------
# compute_risk_metrics — type validation
# ---------------------------------------------------------------------------
class TestTypeValidation:
def test_non_snapshot_raises_type_error(self):
with pytest.raises(TypeError, match="PortfolioSnapshot"):
compute_risk_metrics([{"total_value": 100.0}]) # type: ignore[list-item]
def test_mixed_list_raises(self):
snap = make_snapshot(100.0)
with pytest.raises(TypeError):
compute_risk_metrics([snap, "not-a-snapshot"]) # type: ignore[list-item]
# ---------------------------------------------------------------------------
# compute_risk_metrics — insufficient data
# ---------------------------------------------------------------------------
class TestInsufficientData:
def test_empty_list(self):
result = compute_risk_metrics([])
assert result["sharpe"] is None
assert result["sortino"] is None
assert result["var_95"] is None
assert result["max_drawdown"] is None
assert result["beta"] is None
assert result["sector_concentration"] == {}
assert result["return_stats"]["n_days"] == 0
def test_single_snapshot(self):
result = compute_risk_metrics([make_snapshot(100_000.0)])
assert result["sharpe"] is None
assert result["sortino"] is None
assert result["var_95"] is None
assert result["max_drawdown"] is None
assert result["return_stats"]["n_days"] == 0
# ---------------------------------------------------------------------------
# compute_risk_metrics — Sharpe ratio
# ---------------------------------------------------------------------------
class TestSharpe:
def test_zero_std_returns_none(self):
# All identical NAV → zero std → Sharpe cannot be computed
snaps = nav_snapshots([100.0, 100.0, 100.0, 100.0])
result = compute_risk_metrics(snaps)
assert result["sharpe"] is None
def test_positive_trend_positive_sharpe(self):
# Uniformly rising NAV → positive mean return, positive Sharpe
nav = [100.0 * (1.001 ** i) for i in range(30)]
snaps = nav_snapshots(nav)
result = compute_risk_metrics(snaps)
assert result["sharpe"] is not None
assert result["sharpe"] > 0.0
def test_negative_trend_negative_sharpe(self):
nav = [100.0 * (0.999 ** i) for i in range(30)]
snaps = nav_snapshots(nav)
result = compute_risk_metrics(snaps)
assert result["sharpe"] is not None
assert result["sharpe"] < 0.0
def test_annualisation_factor(self):
# Manually verify: r = [0.01, 0.01, -0.005]
# mean = 0.005, std = std([0.01, 0.01, -0.005], ddof=1)
# sharpe = mean / std * sqrt(252)
returns = [0.01, 0.01, -0.005]
mu = sum(returns) / len(returns)
variance = sum((r - mu) ** 2 for r in returns) / (len(returns) - 1)
sigma = math.sqrt(variance)
expected = mu / sigma * math.sqrt(252)
# Build snapshots that produce exactly these returns
navs = [100.0]
for r in returns:
navs.append(navs[-1] * (1 + r))
snaps = nav_snapshots(navs)
result = compute_risk_metrics(snaps)
assert result["sharpe"] == pytest.approx(expected, rel=1e-4)
# ---------------------------------------------------------------------------
# compute_risk_metrics — Sortino ratio
# ---------------------------------------------------------------------------
class TestSortino:
def test_no_downside_returns_none(self):
# All positive returns → no downside → Sortino = None
nav = [100.0, 101.0, 102.5, 104.0, 106.0]
snaps = nav_snapshots(nav)
result = compute_risk_metrics(snaps)
# No negative returns in this series → sortino is None
assert result["sortino"] is None
def test_mixed_returns_yields_sortino(self):
# Volatile up/down series
nav = [100.0, 105.0, 98.0, 103.0, 101.0, 107.0, 99.0, 104.0]
snaps = nav_snapshots(nav)
result = compute_risk_metrics(snaps)
# Should compute when there are downside returns
assert result["sortino"] is not None
def test_sortino_greater_than_sharpe_for_skewed_up_distribution(self):
# Many small up returns, few large down returns
# In a right-skewed return series, Sortino > Sharpe
nav = [100.0]
for _ in range(25):
nav.append(nav[-1] * 1.003) # small daily gain
# Add a few moderate losses
for _ in range(5):
nav.append(nav[-1] * 0.988)
snaps = nav_snapshots(nav)
result = compute_risk_metrics(snaps)
if result["sharpe"] is not None and result["sortino"] is not None:
assert result["sortino"] > result["sharpe"]
# ---------------------------------------------------------------------------
# compute_risk_metrics — VaR
# ---------------------------------------------------------------------------
class TestVaR:
def test_insufficient_data_returns_none(self):
snaps = nav_snapshots([100.0, 101.0, 102.0, 103.0]) # only 3 returns
result = compute_risk_metrics(snaps)
assert result["var_95"] is None
def test_var_is_non_negative(self):
nav = [100.0 + i * 0.5 + ((-1) ** i) * 3 for i in range(40)]
snaps = nav_snapshots(nav)
result = compute_risk_metrics(snaps)
assert result["var_95"] is not None
assert result["var_95"] >= 0.0
def test_var_near_zero_for_stable_portfolio(self):
# Very low volatility → VaR close to 0
nav = [100_000.0 + i * 10 for i in range(30)]
snaps = nav_snapshots(nav)
result = compute_risk_metrics(snaps)
# Returns are essentially constant positive → VaR ≈ 0
assert result["var_95"] is not None
assert result["var_95"] < 0.001
# ---------------------------------------------------------------------------
# compute_risk_metrics — Max drawdown
# ---------------------------------------------------------------------------
class TestMaxDrawdown:
def test_no_drawdown(self):
# Monotonically increasing NAV
nav = [100.0 + i for i in range(10)]
snaps = nav_snapshots(nav)
result = compute_risk_metrics(snaps)
assert result["max_drawdown"] == pytest.approx(0.0)
def test_simple_drawdown(self):
# Peak=200, trough=100 → drawdown = (100-200)/200 = -0.5
nav = [100.0, 150.0, 200.0, 150.0, 100.0]
snaps = nav_snapshots(nav)
result = compute_risk_metrics(snaps)
assert result["max_drawdown"] == pytest.approx(-0.5, rel=1e-4)
def test_recovery_still_records_worst(self):
# Goes down then recovers
nav = [100.0, 80.0, 90.0, 110.0]
snaps = nav_snapshots(nav)
result = compute_risk_metrics(snaps)
# Worst trough: 80/100 - 1 = -0.2
assert result["max_drawdown"] == pytest.approx(-0.2, rel=1e-4)
def test_two_snapshots(self):
snaps = nav_snapshots([100.0, 90.0])
result = compute_risk_metrics(snaps)
assert result["max_drawdown"] == pytest.approx(-0.1, rel=1e-4)
# ---------------------------------------------------------------------------
# compute_risk_metrics — Beta
# ---------------------------------------------------------------------------
class TestBeta:
def test_no_benchmark_returns_none(self):
snaps = nav_snapshots([100.0, 102.0, 101.0, 103.0, 104.0])
result = compute_risk_metrics(snaps, benchmark_returns=None)
assert result["beta"] is None
def test_empty_benchmark_returns_none(self):
snaps = nav_snapshots([100.0, 102.0, 101.0, 103.0, 104.0])
result = compute_risk_metrics(snaps, benchmark_returns=[])
assert result["beta"] is None
def test_perfect_correlation_beta_one(self):
# Portfolio returns = benchmark returns → beta = 1
nav = [100.0, 102.0, 101.0, 103.5, 105.0]
snaps = nav_snapshots(nav)
returns = [(nav[i] - nav[i - 1]) / nav[i - 1] for i in range(1, len(nav))]
result = compute_risk_metrics(snaps, benchmark_returns=returns)
assert result["beta"] == pytest.approx(1.0, rel=1e-4)
def test_double_beta(self):
# Portfolio returns = 2 × benchmark → beta ≈ 2
bench = [0.01, -0.005, 0.008, 0.002, -0.003]
port_returns = [r * 2 for r in bench]
# Build NAV from port_returns
nav = [100.0]
for r in port_returns:
nav.append(nav[-1] * (1 + r))
snaps = nav_snapshots(nav)
result = compute_risk_metrics(snaps, benchmark_returns=bench)
assert result["beta"] == pytest.approx(2.0, rel=1e-3)
def test_zero_variance_benchmark_returns_none(self):
bench = [0.0, 0.0, 0.0, 0.0]
snaps = nav_snapshots([100.0, 101.0, 102.0, 103.0, 104.0])
result = compute_risk_metrics(snaps, benchmark_returns=bench)
assert result["beta"] is None
# ---------------------------------------------------------------------------
# compute_risk_metrics — Sector concentration
# ---------------------------------------------------------------------------
class TestSectorConcentration:
def test_empty_holdings(self):
snap = make_snapshot(100_000.0, holdings=[])
result = compute_risk_metrics([snap, make_snapshot(105_000.0)])
assert result["sector_concentration"] == {}
def test_single_sector(self):
holdings = [
{"ticker": "AAPL", "shares": 100, "avg_cost": 150.0, "sector": "Technology"},
]
# Sector concentration reads from the LAST snapshot
last_snap = make_snapshot(100_000.0, holdings=holdings)
result = compute_risk_metrics([make_snapshot(98_000.0), last_snap])
# Technology weight = (100 * 150) / 100_000 * 100 = 15 %
assert "Technology" in result["sector_concentration"]
assert result["sector_concentration"]["Technology"] == pytest.approx(15.0, rel=0.01)
def test_multiple_sectors_sum_to_equity_fraction(self):
holdings = [
{"ticker": "AAPL", "shares": 100, "avg_cost": 100.0, "sector": "Technology"},
{"ticker": "JPM", "shares": 50, "avg_cost": 200.0, "sector": "Financials"},
]
total_nav = 100_000.0
last_snap = make_snapshot(total_nav, holdings=holdings)
result = compute_risk_metrics([make_snapshot(99_000.0), last_snap])
conc = result["sector_concentration"]
assert "Technology" in conc
assert "Financials" in conc
# Technology: 10_000 / 100_000 = 10 %
assert conc["Technology"] == pytest.approx(10.0, rel=0.01)
# Financials: 10_000 / 100_000 = 10 %
assert conc["Financials"] == pytest.approx(10.0, rel=0.01)
def test_uses_current_value_when_available(self):
holdings = [
{
"ticker": "AAPL",
"shares": 100,
"avg_cost": 100.0,
"current_value": 20_000.0, # current, not cost
"sector": "Technology",
},
]
last_snap = make_snapshot(100_000.0, holdings=holdings)
result = compute_risk_metrics([make_snapshot(98_000.0), last_snap])
# current_value preferred: 20_000 / 100_000 * 100 = 20 %
assert result["sector_concentration"]["Technology"] == pytest.approx(20.0, rel=0.01)
def test_missing_sector_defaults_to_unknown(self):
holdings = [
{"ticker": "AAPL", "shares": 100, "avg_cost": 100.0}, # no sector key
]
last_snap = make_snapshot(100_000.0, holdings=holdings)
result = compute_risk_metrics([make_snapshot(100_000.0), last_snap])
assert "Unknown" in result["sector_concentration"]
# ---------------------------------------------------------------------------
# compute_risk_metrics — return_stats
# ---------------------------------------------------------------------------
class TestReturnStats:
def test_n_days_matches_returns_length(self):
snaps = nav_snapshots([100.0, 102.0, 101.0])
result = compute_risk_metrics(snaps)
assert result["return_stats"]["n_days"] == 2
def test_mean_and_std_present(self):
snaps = nav_snapshots([100.0, 102.0, 101.0, 103.5])
result = compute_risk_metrics(snaps)
stats = result["return_stats"]
assert stats["mean_daily"] is not None
assert stats["std_daily"] is not None
def test_empty_stats(self):
result = compute_risk_metrics([])
stats = result["return_stats"]
assert stats["mean_daily"] is None
assert stats["std_daily"] is None
assert stats["n_days"] == 0
# ---------------------------------------------------------------------------
# compute_risk_metrics — full integration scenario
# ---------------------------------------------------------------------------
class TestFullScenario:
def test_90_day_realistic_portfolio(self):
"""90-day NAV series with realistic up/down patterns."""
import random
random.seed(42)
nav = [100_000.0]
for _ in range(89):
daily_r = random.gauss(0.0005, 0.01) # ~12.5 % annual, 10 % vol
nav.append(nav[-1] * (1 + daily_r))
bench_returns = [random.gauss(0.0004, 0.009) for _ in range(89)]
holdings = [
{"ticker": "AAPL", "shares": 100, "avg_cost": 175.0, "sector": "Technology"},
{"ticker": "JPM", "shares": 50, "avg_cost": 200.0, "sector": "Financials"},
]
snaps = []
for i, v in enumerate(nav):
h = holdings if i == len(nav) - 1 else []
snaps.append(
PortfolioSnapshot(
snapshot_id=f"snap-{i}",
portfolio_id="pid",
snapshot_date=f"2026-01-{i + 1:02d}" if i < 31 else f"2026-02-{i - 30:02d}" if i < 59 else f"2026-03-{i - 58:02d}",
total_value=v,
cash=0.0,
equity_value=v,
num_positions=2,
holdings_snapshot=h,
)
)
result = compute_risk_metrics(snaps, benchmark_returns=bench_returns)
# All key metrics should be present
assert result["sharpe"] is not None
assert result["sortino"] is not None
assert result["var_95"] is not None
assert result["max_drawdown"] is not None
assert result["beta"] is not None
assert result["return_stats"]["n_days"] == 89
# Sector concentration from last snapshot
assert "Technology" in result["sector_concentration"]
assert "Financials" in result["sector_concentration"]
# Sanity bounds
assert -10.0 < result["sharpe"] < 10.0
assert result["max_drawdown"] <= 0.0
assert result["var_95"] >= 0.0
assert result["beta"] > 0.0 # should be positive for realistic market data