Files
easy_tdx_max/tests/unit/test_portfolio_engine.py
T
GitHub c49ba4c4b5 feat: 组合回测分析体系对齐单标的 — 组合级WF/一条龙/完整25项绩效/AI解读
组合回测(一策略×多标的)此前只能看 4 个数字,本轮把单标的的整条
分析链路在组合端补齐(WebUI/REST 双端):

- portfolio_engine:合并净值+汇总成交喂 PerformanceAnalyzer,输出
  完整 25 项指标(SQN/最大连胜连亏/Ulcer/VaR/CVaR 等)+ 组合层
  trades(symbol 列);修复假年化与回撤口径(负值+固定分母 →
  逐点峰值,与单标的/多策略一致)
- walkforward:新增 PortfolioWalkForwardEngine,按标的日期并集切窗、
  每窗独立开仓、合成组合窗内净值,复用 WalkForwardResult 结构
- benchmark:新增 evaluate_portfolio 一条龙(组合回测+组合WF+
  跨标的多数口径适配性体检+综合评分+组合评级+等权买入持有基准对比),
  报告结构与单标的 evaluate_strategy 同构
- performance:FIFO 持仓天数配对支持 symbol 分组
- Web:新增 POST /backtest/portfolio/wf/run/async 与
  /backtest/portfolio/evaluate/run/async;组合回测响应附带
  grade(组合净值口径)与 score;新增 _normalize_bars_dt 修复
  按标的取数路径的字符串日期/遗留 date 列崩溃(E2E 揭露)
- 前端:组合页新增附加分析勾选区与组合绩效指标/WF/一条龙/成交明细
  区块;buildPortfolioAiPrompt 组合版 Prompt;抽通用
  AiInterpretModal(回测页迁移共用,行为不变);TradeTable 支持
  showSymbol;EvaluatePanel 支持 gradeOverride
- 测试:后端 +17 例(pytest 1603 绿)、aiPrompt 组合版 2 例、
  Playwright 组合页 E2E 2 例(9/9 绿)
2026-09-03 23:22:48 +08:00

302 lines
11 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.
"""单元测试:多标的组合回测引擎."""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from easy_tdx.backtest.portfolio_engine import (
PortfolioBacktestEngine,
StockData,
)
from easy_tdx.backtest.strategy import Strategy
class SimpleBuyStrategy(Strategy):
"""简单策略:bar 5 买入,bar 30 卖出."""
def init(self) -> None:
pass
def next(self) -> None:
if self._bar_index == 5 and self.position["size"] == 0:
self.buy(size=0)
elif self._bar_index == 30 and self.position["size"] > 0:
self.sell(size=0)
def _make_df(n: int = 100, seed: int = 42) -> pd.DataFrame:
"""生成随机 OHLCV DataFrame."""
rng = np.random.default_rng(seed)
close = 100.0 + np.cumsum(rng.normal(0, 1, n))
high = close + rng.uniform(0, 1, n)
low = close - rng.uniform(0, 1, n)
open_ = low + rng.uniform(0, high - low, n)
vol = rng.integers(1000000, 10000000, n).astype(float)
return pd.DataFrame(
{
"datetime": pd.date_range("2024-01-01", periods=n, freq="D"),
"open": open_,
"high": high,
"low": low,
"close": close,
"vol": vol,
"amount": vol * close,
}
)
class TestPortfolioBacktest:
"""测试组合回测引擎."""
def test_basic_portfolio_run(self) -> None:
"""基本组合回测应正常完成."""
stocks = [
StockData("000001", "SZ", _make_df(100, seed=42)),
StockData("600000", "SH", _make_df(100, seed=99)),
]
engine = PortfolioBacktestEngine(
strategy=SimpleBuyStrategy,
stocks=stocks,
total_cash=200000,
)
result = engine.run()
assert result.total_performance is not None
assert "total_return" in result.total_performance
assert len(result.individual_results) == 2
assert result.total_performance["total_stocks"] == 2
def test_equal_allocation(self) -> None:
"""均等分配:每只标的资金应为总资金/标的数."""
stocks = [
StockData("000001", "SZ", _make_df(100, seed=42)),
StockData("000002", "SZ", _make_df(100, seed=99)),
]
engine = PortfolioBacktestEngine(
strategy=SimpleBuyStrategy,
stocks=stocks,
total_cash=100000,
allocation="equal",
)
result = engine.run()
# 每只标的分配 50000
assert result.equity_allocation["SZ000001"] == 0.5
assert result.equity_allocation["SZ000002"] == 0.5
def test_empty_stocks(self) -> None:
"""空标的列表应返回零绩效."""
engine = PortfolioBacktestEngine(
strategy=SimpleBuyStrategy,
stocks=[],
total_cash=100000,
)
result = engine.run()
assert result.total_performance["total_return"] == 0.0
assert len(result.individual_results) == 0
def test_to_dict_serializable(self) -> None:
"""结果应可序列化为字典."""
stocks = [StockData("000001", "SZ", _make_df(100))]
engine = PortfolioBacktestEngine(
strategy=SimpleBuyStrategy,
stocks=stocks,
total_cash=100000,
)
result = engine.run()
d = result.to_dict()
assert "total_performance" in d
assert "individual_results" in d
assert "equity_allocation" in d
assert "combined_equity" in d
class TestStrategyInstanceParams:
"""测试策略实例(带参数)的透传——Phase 3 引擎改造的核心."""
def test_strategy_instance_params_passed_through(self) -> None:
"""传策略实例时,参数应透传到每个标的(而非用默认值)."""
from easy_tdx.backtest.strategies import get_registry
entry = get_registry().get("ma_cross")
strategy_instance = entry.build({"fast": 10, "slow": 30})
stocks = [
StockData("000001", "SZ", _make_df(120, seed=1)),
StockData("000002", "SZ", _make_df(120, seed=2)),
]
engine = PortfolioBacktestEngine(
strategy=strategy_instance,
stocks=stocks,
total_cash=200000,
)
result = engine.run()
assert len(result.individual_results) == 2
for res in result.individual_results.values():
assert res.performance is not None
class TestCombinedEquity:
"""测试组合净值曲线生成."""
def test_combined_equity_generated(self) -> None:
"""组合净值曲线应生成且含 total/drawdown/drawdown_pct 列."""
stocks = [
StockData("000001", "SZ", _make_df(100, seed=42)),
StockData("600000", "SH", _make_df(100, seed=99)),
]
engine = PortfolioBacktestEngine(
strategy=SimpleBuyStrategy,
stocks=stocks,
total_cash=200000,
)
result = engine.run()
assert len(result.combined_equity) > 0
cols = set(result.combined_equity.columns)
assert {"datetime", "total", "drawdown", "drawdown_pct"} <= cols
assert result.combined_equity["total"].iloc[0] > 0
def test_combined_equity_date_alignment(self) -> None:
"""日期范围不同的标的应正确对齐(forward-fill."""
stocks = [
StockData("000001", "SZ", _make_df(80, seed=1)),
StockData("000002", "SZ", _make_df(100, seed=2)),
]
engine = PortfolioBacktestEngine(
strategy=SimpleBuyStrategy,
stocks=stocks,
total_cash=200000,
)
result = engine.run()
assert len(result.combined_equity) >= 100
def test_combined_equity_empty(self) -> None:
"""空标的列表应返回空净值曲线(带表头)."""
engine = PortfolioBacktestEngine(
strategy=SimpleBuyStrategy,
stocks=[],
total_cash=100000,
)
result = engine.run()
assert len(result.combined_equity) == 0
assert set(result.combined_equity.columns) == {
"datetime",
"total",
"drawdown",
"drawdown_pct",
}
class TestPortfolioFullMetrics:
"""v1.31:组合级完整绩效指标(合并净值 + 汇总成交喂 PerformanceAnalyzer)。"""
def test_total_performance_has_full_metrics(self) -> None:
"""组合整体绩效应含与单标的同口径的完整指标(SQN/连胜连亏等)。"""
stocks = [
StockData("000001", "SZ", _make_df(100, seed=42)),
StockData("600000", "SH", _make_df(100, seed=99)),
]
result = PortfolioBacktestEngine(
strategy=SimpleBuyStrategy, stocks=stocks, total_cash=200000
).run()
perf = result.total_performance
# 单标的 PerformanceAnalyzer 的全部关键键 + 组合字段
for key in (
"total_return",
"annual_return",
"max_drawdown",
"sharpe",
"sortino",
"calmar",
"volatility",
"win_rate",
"profit_factor",
"sqn",
"max_consecutive_wins",
"max_consecutive_losses",
"total_stocks",
"total_cash",
):
assert key in perf, f"缺少指标 {key}"
assert perf["total_stocks"] == 2
assert perf["total_cash"] == 200000
def test_annual_return_is_annualized(self) -> None:
"""年化收益应基于时间长度换算,不再等于总收益(旧版直接赋值的简化)。"""
stocks = [StockData("000001", "SZ", _make_df(400, seed=42))]
result = PortfolioBacktestEngine(
strategy=SimpleBuyStrategy, stocks=stocks, total_cash=100000
).run()
perf = result.total_performance
assert perf["annual_return"] != perf["total_return"]
def test_drawdown_pct_positive_and_relative_to_peak(self) -> None:
"""drawdown/drawdown_pct 应为正值且相对逐点峰值(与单标的/多策略口径一致)。"""
stocks = [
StockData("000001", "SZ", _make_df(100, seed=42)),
StockData("600000", "SH", _make_df(100, seed=7)),
]
result = PortfolioBacktestEngine(
strategy=SimpleBuyStrategy, stocks=stocks, total_cash=200000
).run()
ce = result.combined_equity
assert (ce["drawdown_pct"] >= 0).all()
assert (ce["drawdown"] >= 0).all()
# 回撤比例 = 回撤额 / 当时峰值
peak = ce["total"].cummax()
expected = (peak - ce["total"]) / peak.where(peak != 0, 1.0)
np.testing.assert_allclose(ce["drawdown_pct"], expected, rtol=1e-9)
def test_combined_trades_have_symbol_column(self) -> None:
"""组合层汇总成交应附 symbol 列(FIFO 按标的分组 + 前端明细表用)。"""
stocks = [
StockData("000001", "SZ", _make_df(100, seed=42)),
StockData("600000", "SH", _make_df(100, seed=99)),
]
result = PortfolioBacktestEngine(
strategy=SimpleBuyStrategy, stocks=stocks, total_cash=200000
).run()
assert "symbol" in result.trades.columns
assert set(result.trades["symbol"]) == {"SZ000001", "SH600000"}
# 每个标的的成交数 == 该标的独立回测的成交数
for key, res in result.individual_results.items():
n = (result.trades["symbol"] == key).sum()
assert n == len(res.trades)
def test_total_return_matches_capital_weighted(self) -> None:
"""组合 total_return 应等于各标的资金加权收益(合并曲线首值=总资金)。"""
stocks = [
StockData("000001", "SZ", _make_df(100, seed=42)),
StockData("600000", "SH", _make_df(100, seed=99)),
]
result = PortfolioBacktestEngine(
strategy=SimpleBuyStrategy, stocks=stocks, total_cash=200000
).run()
weighted = sum(
0.5 * res.performance.get("total_return", 0.0)
for res in result.individual_results.values()
)
assert result.total_performance["total_return"] == pytest.approx(weighted, abs=1e-9)
def test_to_dict_contains_trades(self) -> None:
"""to_dict 应包含组合层成交表(REST/AI 解读消费)。"""
stocks = [StockData("000001", "SZ", _make_df(100, seed=42))]
result = PortfolioBacktestEngine(
strategy=SimpleBuyStrategy, stocks=stocks, total_cash=100000
).run()
d = result.to_dict()
assert "trades" in d
assert isinstance(d["trades"], list)