"""metrics.py 纯函数单测。 H4: compute_metrics 从 daily_df["balance"] 自算 simple return(不再用 vnpy log return 列), 所以测试需构造含 "balance" 列的 daily_df。 """ import sys, os _VNPY_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "vnpy_v4.4.0")) sys.path.insert(0, _VNPY_SRC) import pandas as pd import numpy as np import empyrical from sanguo_backtest.metrics import compute_metrics, MetricsResult, BENCHMARK_SYMBOL def _make_daily_balance(returns: np.ndarray) -> pd.DataFrame: """从收益率数组构造含 balance 列的 daily_df。 compute_metrics 内部算 simple return = balance.pct_change().fillna(0), 所以实际传入 metrics 的 strat = [0, r0, r1, ...](首项 NaN→0)。 """ idx = pd.date_range("2024-01-01", periods=len(returns), freq="B") balance = (1 + pd.Series(returns, index=idx)).cumprod() return pd.DataFrame({"balance": balance}, index=idx) def _actual_strat(returns: np.ndarray) -> pd.Series: """compute_metrics 从 balance 推导出的实际 strat 序列(首项=0)。""" idx = pd.date_range("2024-01-01", periods=len(returns), freq="B") balance = (1 + pd.Series(returns, index=idx)).cumprod() return balance.pct_change().fillna(0) def test_compute_metrics_scalars_match_empyrical(): np.random.seed(42) raw_returns = np.random.normal(0.001, 0.02, 100) strat = _actual_strat(raw_returns) bench = pd.Series(np.random.normal(0.0005, 0.015, 100), index=pd.date_range("2024-01-01", periods=100, freq="B")) daily_df = _make_daily_balance(raw_returns) res = compute_metrics(daily_df, bench) assert isinstance(res, MetricsResult) # 标量口径与 empyrical 直接计算一致(用推导出的 strat) assert abs(res.scalars["alpha"] - empyrical.alpha(strat, bench)) < 1e-9 assert abs(res.scalars["beta"] - empyrical.beta(strat, bench)) < 1e-9 assert abs(res.scalars["sharpe_ratio"] - empyrical.sharpe_ratio(strat)) < 1e-9 assert abs(res.scalars["sortino_ratio"] - empyrical.sortino_ratio(strat)) < 1e-9 assert abs(res.scalars["max_drawdown"] - empyrical.max_drawdown(strat)) < 1e-9 assert abs(res.scalars["annual_volatility"] - empyrical.annual_volatility(strat)) < 1e-9 def test_compute_metrics_has_all_required_scalars(): daily_df = _make_daily_balance([0.01, -0.005, 0.02, 0.0]) bench = pd.Series([0.005, 0.001, 0.01, -0.002], index=pd.date_range("2024-01-01", periods=4, freq="B")) res = compute_metrics(daily_df, bench) required = {"total_return", "annual_return", "alpha", "beta", "sharpe_ratio", "sortino_ratio", "information_ratio", "annual_volatility", "max_drawdown", "benchmark_return", "benchmark_volatility"} assert required.issubset(res.scalars.keys()) def test_compute_metrics_series_keys_and_length(): raw_returns = np.random.normal(0, 0.01, 50) daily_df = _make_daily_balance(raw_returns) bench = pd.Series(np.random.normal(0, 0.01, 50), index=pd.date_range("2024-01-01", periods=50, freq="B")) res = compute_metrics(daily_df, bench) for key in ["equity_curve", "benchmark_curve", "alpha", "beta", "drawdown"]: assert key in res.series assert len(res.series[key]) == 50 assert res.series["drawdown"].max() <= 1e-9 # 回撤 <= 0 def test_benchmark_symbol_map(): assert BENCHMARK_SYMBOL["hs300"] == "sh000300" assert BENCHMARK_SYMBOL["zz500"] == "sz000905" def test_compute_metrics_ffill_aligns_benchmark(): """H5: benchmark 有缺失日期时 ffill 对齐,不丢策略交易日。""" raw_returns = np.random.normal(0, 0.01, 10) daily_df = _make_daily_balance(raw_returns) # benchmark 只有部分日期(模拟停盘日缺失) partial_dates = daily_df.index[::2] # 隔日取一个 bench = pd.Series([0.001] * len(partial_dates), index=partial_dates) res = compute_metrics(daily_df, bench) # 不应崩溃,且 strat 长度 = daily_df 行数(没有被 dropna 削短) assert len(res.series["equity_curve"]) == 10 def test_compute_metrics_raises_without_balance(): """缺少 balance 列时应抛 ValueError。""" idx = pd.date_range("2024-01-01", periods=5, freq="B") daily_df = pd.DataFrame({"net_pnl": [1, 2, 3, 4, 5]}, index=idx) bench = pd.Series([0.01] * 5, index=idx) try: compute_metrics(daily_df, bench) assert False, "应抛 ValueError" except ValueError as e: assert "balance" in str(e)