mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 18:04:20 +08:00
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 绿)
This commit is contained in:
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from easy_tdx.backtest.portfolio_engine import (
|
||||
PortfolioBacktestEngine,
|
||||
@@ -195,3 +196,106 @@ class TestCombinedEquity:
|
||||
"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)
|
||||
|
||||
Reference in New Issue
Block a user