mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 23:54:21 +08:00
对 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 全绿。
347 lines
12 KiB
Python
347 lines
12 KiB
Python
"""单元测试:多标的组合回测引擎."""
|
||
|
||
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)
|