Files
easy_tdx_max/tests/unit/test_portfolio_engine.py
Justin Gu e374a0da28 release: v1.32.6 — 两周改动深度审查全面修复(回测口径三件套/LLM 安全加固/涨停价舍入/时区统一/缓存与竞态等 58 处)
对 v1.21→v1.32.5 的 249 文件 4.2 万行改动做六路专项审查,本轮落地全部发现:

回测正确性:组合收益 fillna(0) 虚增、轮动停牌日过期价成交、单标的 WF 逐窗指标
被预热区稀释(三件套均带先红后绿回归);worst_drawdown 方向、grading 容错、
组合体检品种费率、寻优端点费率透传。

安全:LLM api_url 仅 http/https 且禁 userinfo(封死 file:// 读取与 Key 外送链)、
错误响应不回显原始 body、响应体 2MB 上限、配置原子写、坏配置字段级防御。

数据:涨跌停价整数分币舍入(67/318/90 个价位错 1 分漏判清零)、交易时段/采样/
provisional 统一沪时区、warehouse 增量缺口自动全量重拉、provisional 定点转正、
baostock 真故障抛错 + W/M 去 tradestatus(实测服务端报错,周月兜底此前从未工作)
+ 指数 vol 股→手(实测锚定)、ccpm 结构变更抛错。

Web API:缓存键补 count/vipdoc、NaN 清洗先于缓存、count>800 分页取全量、
submit 透传真实状态、pending 不再被淘汰成幽灵、watchlist/server 入参约束。

公式:FILTER 去副作用、0-1 值域误判收严、递归深度上限、REF 负移位显式禁止。

前端:4 处请求竞态序号守卫、Sparkline viewBox、北交所 market=2 映射、
空数据缓存死角、AI 弹窗卸载中止轮询、量能/资金日历口径修正。

CLI/CI:warehouse sync 失败 exit 1、参数校验干净报错、release 真实发布 SHA256、
CI 超时与缓存、spec 补 baostock 前提。

约 60 条回归测试先红后绿;pytest 1820 全过,ruff/mypy/vue-tsc/node --test 全绿。
2026-09-06 22:16:48 +08:00

347 lines
12 KiB
Python
Raw Permalink 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_total_return_capital_weighted_with_uneven_start_dates(self) -> None:
"""晚上市标的建仓前应按初始资金趴账(合并曲线首值=总投入资金)。
回归:旧实现 ``_build_combined_equity`` 对日期并集的前导缺口填 0——
晚上市标的上市前贡献 0 而非其分得的初始资金,合并曲线首值 < 总投入,
total_return 被系统性虚增(100 根 + 晚 60 根起步的组合实测虚增约 10 倍)。
正确口径(与组合 Walk-Forward 的 ffill().bfill() 一致):前导缺口用
每列首个有效值回填——资金在组合起点即已分配,建仓前趴账。
"""
n = 100
close = np.linspace(10.0, 12.0, n)
dates = pd.bdate_range("2024-01-02", periods=n)
def _mk(cnt: int) -> pd.DataFrame:
c = close[-cnt:]
return pd.DataFrame(
{
"datetime": dates[-cnt:],
"open": c,
"high": c,
"low": c,
"close": c,
"vol": 1e6,
"amount": c * 1e6,
}
)
stocks = [
StockData("000001", "SZ", _mk(n)), # 全程 100 根
StockData("600000", "SH", _mk(40)), # 同涨势、晚 60 根起步
]
result = PortfolioBacktestEngine(
strategy=SimpleBuyStrategy, stocks=stocks, total_cash=200000
).run()
# 1) 合并曲线首值 = 总投入资金(旧实现 = 100000,缺晚上市标的的资金)
assert result.combined_equity["total"].iloc[0] == pytest.approx(200000.0)
# 2) total_return == 各标的资金加权真实收益
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)