mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 14:34:18 +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:
@@ -35,12 +35,12 @@ import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from easy_tdx.backtest.engine import BacktestEngine
|
||||
from easy_tdx.backtest.fitness import FitnessEngine
|
||||
from easy_tdx.backtest.grading import grade_performance
|
||||
from easy_tdx.backtest.fitness import FitnessCheck, FitnessEngine, FitnessReport, FitnessSegment
|
||||
from easy_tdx.backtest.grading import grade_performance, grade_portfolio_equity
|
||||
from easy_tdx.backtest.scoring import score_strategy
|
||||
from easy_tdx.backtest.strategy import Strategy
|
||||
from easy_tdx.backtest.types import to_json_native
|
||||
from easy_tdx.backtest.walkforward import WalkForwardEngine
|
||||
from easy_tdx.backtest.walkforward import PortfolioWalkForwardEngine, WalkForwardEngine
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import numpy.typing as npt
|
||||
@@ -51,7 +51,12 @@ if TYPE_CHECKING:
|
||||
else:
|
||||
NDArray = np.ndarray
|
||||
|
||||
__all__ = ["evaluate_strategy", "run_buy_hold_benchmark", "compute_benchmark_comparison"]
|
||||
__all__ = [
|
||||
"evaluate_strategy",
|
||||
"evaluate_portfolio",
|
||||
"run_buy_hold_benchmark",
|
||||
"compute_benchmark_comparison",
|
||||
]
|
||||
|
||||
|
||||
class _BuyAndHold(Strategy):
|
||||
@@ -280,3 +285,171 @@ def evaluate_strategy(
|
||||
"split": list(split),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def evaluate_portfolio(
|
||||
strategy: type[Strategy] | Strategy,
|
||||
stocks: list[Any],
|
||||
total_cash: float = 1_000_000.0,
|
||||
commission: float = 0.0003,
|
||||
min_commission: float = 5.0,
|
||||
stamp_tax: float = 0.001,
|
||||
slippage: float = 0.0,
|
||||
execution: str = "next_open",
|
||||
chanlun_level: str | None = None,
|
||||
auto_fees: bool = False,
|
||||
n_windows: int = 7,
|
||||
warmup_ratio: float = 0.3,
|
||||
context_bars: int = 60,
|
||||
split: tuple[float, float, float] = (0.6, 0.2, 0.2),
|
||||
) -> dict[str, Any]:
|
||||
"""一条龙组合评估:组合回测 + 组合 WF + 适配性体检 + 综合评分 + 组合评级
|
||||
+ 等权买入持有基准对比。
|
||||
|
||||
与 :func:`evaluate_strategy`(单标的)同构的报告结构,前端 EvaluatePanel
|
||||
可直接复用;差异点:
|
||||
|
||||
- ``performance`` 来自组合引擎(完整 25 项指标,含 SQN/最大连胜连亏);
|
||||
- ``walkforward`` 来自 :class:`~easy_tdx.backtest.walkforward.PortfolioWalkForwardEngine`;
|
||||
- ``fitness`` 为**跨标的聚合**:逐标的跑三段体检,检查项按「≥60% 标的
|
||||
通过」的多数口径合成,段指标取截面均值——诚实反映组合整体适配性;
|
||||
- ``grade`` 用组合净值口径 :func:`~easy_tdx.backtest.grading.grade_portfolio_equity`;
|
||||
- ``benchmark`` 为**等权买入持有组合**(每只标的分 1/N 资金首根买入持有
|
||||
到末根,同费率同区间),α/β/信息比率/跟踪误差基于两条组合净值曲线。
|
||||
|
||||
Args:
|
||||
strategy: 策略类或实例。
|
||||
stocks: :class:`~easy_tdx.backtest.portfolio_engine.StockData` 列表。
|
||||
其余参数: 透传给组合回测 / 组合 WF / 适配性(同口径费率与执行)。
|
||||
|
||||
Returns:
|
||||
完整评估报告字典(结构同 evaluate_strategy,config 记录标的清单)。
|
||||
"""
|
||||
from easy_tdx.backtest.portfolio_engine import PortfolioBacktestEngine
|
||||
|
||||
engine_kwargs: dict[str, Any] = {
|
||||
"total_cash": total_cash,
|
||||
"commission": commission,
|
||||
"min_commission": min_commission,
|
||||
"stamp_tax": stamp_tax,
|
||||
"slippage": slippage,
|
||||
"execution": execution,
|
||||
"chanlun_level": chanlun_level,
|
||||
"auto_fees": auto_fees,
|
||||
}
|
||||
|
||||
# 1. 全样本组合回测(完整 25 项指标 + 合并净值曲线)
|
||||
bt = PortfolioBacktestEngine(strategy=strategy, stocks=stocks, **engine_kwargs).run()
|
||||
perf = bt.total_performance
|
||||
|
||||
# 2. 组合 Walk-Forward 样本外
|
||||
wf = PortfolioWalkForwardEngine(
|
||||
strategy=strategy,
|
||||
stocks=stocks,
|
||||
n_windows=n_windows,
|
||||
warmup_ratio=warmup_ratio,
|
||||
context_bars=context_bars,
|
||||
**engine_kwargs,
|
||||
).run()
|
||||
|
||||
# 3. 适配性体检:逐标的跑三段体检,跨标的多数口径聚合
|
||||
fitness_kwargs: dict[str, Any] = {
|
||||
k: v for k, v in engine_kwargs.items() if k not in ("total_cash", "chanlun_level")
|
||||
}
|
||||
per_stock_fitness = [
|
||||
FitnessEngine(
|
||||
strategy=strategy, split=split, context_bars=context_bars, **fitness_kwargs
|
||||
).evaluate(stock.df)
|
||||
for stock in stocks
|
||||
]
|
||||
fitness = _aggregate_fitness(per_stock_fitness, split)
|
||||
|
||||
# 4. 综合评分(叠加组合 WF 一致性)+ 组合评级(净值曲线口径)
|
||||
score = score_strategy(dict(perf), wf=wf)
|
||||
grade = grade_portfolio_equity(bt.combined_equity.to_dict(orient="records"))
|
||||
|
||||
# 5. 基准对比:等权买入持有组合(每只标的 1/N 首根买入持有到末根,同费率)
|
||||
bh_bt = PortfolioBacktestEngine(strategy=_BuyAndHold, stocks=stocks, **engine_kwargs).run()
|
||||
bh_keys = ("total_return", "annual_return", "max_drawdown", "sharpe", "calmar", "volatility")
|
||||
bh = dict(to_json_native({k: bh_bt.total_performance.get(k, 0.0) for k in bh_keys}))
|
||||
comparison = compute_benchmark_comparison(bt.combined_equity, bh_bt.combined_equity)
|
||||
|
||||
return {
|
||||
"performance": to_json_native(dict(perf)),
|
||||
"score": score.to_dict(),
|
||||
"grade": grade.to_dict(),
|
||||
"walkforward": wf.to_dict(),
|
||||
"fitness": fitness.to_dict(),
|
||||
"benchmark": {
|
||||
"buy_hold": bh,
|
||||
"excess_return": float(perf.get("total_return", 0.0))
|
||||
- float(bh.get("total_return", 0.0)),
|
||||
**comparison,
|
||||
},
|
||||
"config": {
|
||||
"stocks": [f"{s.market}{s.code}" for s in stocks],
|
||||
"total_cash": total_cash,
|
||||
"auto_fees": auto_fees,
|
||||
"execution": execution,
|
||||
"n_windows": n_windows,
|
||||
"warmup_ratio": warmup_ratio,
|
||||
"split": list(split),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _aggregate_fitness(
|
||||
reports: list[FitnessReport],
|
||||
split: tuple[float, float, float],
|
||||
pass_ratio_threshold: float = 0.6,
|
||||
) -> FitnessReport:
|
||||
"""把逐标的的适配性体检报告聚合为组合级报告(多数口径)。
|
||||
|
||||
- 检查项:同名检查项跨标的计通过率,≥ ``pass_ratio_threshold``(默认
|
||||
60%)标的通过则组合级该项通过,detail 记「x/y 只标的通过」;
|
||||
- 段摘要:段起止取各标的的最早/最晚,收益/夏普/胜率取截面均值,
|
||||
最大回撤取最深(max),交易数取合计——回答「组合整体在三段的形态」。
|
||||
"""
|
||||
aggregated = FitnessReport(split=split)
|
||||
valid = [r for r in reports if r.checks]
|
||||
if not valid:
|
||||
return aggregated
|
||||
|
||||
# 检查项:按首份报告的检查顺序(FitnessEngine 的 8 项固定顺序)
|
||||
n = len(valid)
|
||||
for check in valid[0].checks:
|
||||
passed_n = sum(1 for r in valid for c in r.checks if c.name == check.name and c.passed)
|
||||
aggregated.checks.append(
|
||||
FitnessCheck(
|
||||
name=check.name,
|
||||
passed=passed_n >= max(1, int(np.ceil(pass_ratio_threshold * n))),
|
||||
detail=f"{passed_n}/{n} 只标的通过(组合多数口径)",
|
||||
)
|
||||
)
|
||||
|
||||
# 段摘要:train/valid/test 逐段截面聚合
|
||||
for seg in valid[0].segments:
|
||||
same = [s for s in (r.segment_by_name(seg.name) for r in valid) if s is not None]
|
||||
if not same:
|
||||
continue
|
||||
aggregated.segments.append(
|
||||
FitnessSegment(
|
||||
name=seg.name,
|
||||
start=min(s.start for s in same),
|
||||
end=max(s.end for s in same),
|
||||
bars=int(round(float(np.mean([s.bars for s in same])))),
|
||||
total_return=float(np.mean([s.total_return for s in same])),
|
||||
sharpe=float(np.mean([s.sharpe for s in same])),
|
||||
max_drawdown=float(max(s.max_drawdown for s in same)),
|
||||
total_trades=int(sum(s.total_trades for s in same)),
|
||||
win_rate=float(np.mean([s.win_rate for s in same])),
|
||||
)
|
||||
)
|
||||
|
||||
aggregated.pass_ratio = (
|
||||
sum(1 for c in aggregated.checks if c.passed) / len(aggregated.checks)
|
||||
if aggregated.checks
|
||||
else 0.0
|
||||
)
|
||||
aggregated.high_fitness = aggregated.pass_ratio >= 0.75
|
||||
return aggregated
|
||||
|
||||
@@ -98,6 +98,10 @@ class FitnessReport:
|
||||
def passed_count(self) -> int:
|
||||
return sum(1 for c in self.checks if c.passed)
|
||||
|
||||
def segment_by_name(self, name: str) -> FitnessSegment | None:
|
||||
"""按段名(train/valid/test)取段摘要,无该段时返回 None。"""
|
||||
return next((s for s in self.segments if s.name == name), None)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return dict(
|
||||
to_json_native(
|
||||
|
||||
@@ -294,6 +294,27 @@ class PerformanceAnalyzer:
|
||||
if len(valid) == 0:
|
||||
return 0.0
|
||||
|
||||
# 组合成交表带 symbol 列时按标的分组配对(避免 A 股的买入被 B 股的
|
||||
# 卖出错误配对);单标的成交表无该列,走原路径。
|
||||
groups: list[pd.DataFrame]
|
||||
if "symbol" in valid.columns:
|
||||
groups = [g for _, g in valid.groupby("symbol", sort=False)]
|
||||
else:
|
||||
groups = [valid]
|
||||
|
||||
total_days = 0.0
|
||||
total_size = 0.0
|
||||
for group in groups:
|
||||
days, size = self._fifo_holding_days(group)
|
||||
total_days += days
|
||||
total_size += size
|
||||
|
||||
if total_size == 0:
|
||||
return 0.0
|
||||
return total_days / total_size
|
||||
|
||||
def _fifo_holding_days(self, valid: pd.DataFrame) -> tuple[float, float]:
|
||||
"""对单组(单标的)成交做 FIFO 配对,返回 (加权持仓天数和, 加权数量和)。"""
|
||||
buy_queue: deque[tuple[_dt.date, float]] = deque() # (date, size)
|
||||
total_days = 0.0
|
||||
total_size = 0.0
|
||||
@@ -343,9 +364,7 @@ class PerformanceAnalyzer:
|
||||
else:
|
||||
buy_queue[0] = (buy_d, buy_size)
|
||||
|
||||
if total_size == 0:
|
||||
return 0.0
|
||||
return total_days / total_size
|
||||
return total_days, total_size
|
||||
|
||||
def _compute_max_dd_duration(self, total: NDArray, drawdown: NDArray) -> int:
|
||||
"""计算最大回撤持续时间。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
@@ -36,18 +36,24 @@ class PortfolioResult:
|
||||
"""组合回测结果。
|
||||
|
||||
Attributes:
|
||||
total_performance: 组合整体绩效指标
|
||||
total_performance: 组合整体绩效指标——与单标的回测同口径的完整
|
||||
25 项(夏普/回撤/胜率/盈亏比/SQN/最大连胜连亏等,由合并净值
|
||||
曲线 + 汇总成交喂 :class:`PerformanceAnalyzer` 计算),另附
|
||||
``total_stocks`` / ``total_cash`` 两个组合字段。
|
||||
individual_results: 每只标的的独立回测结果
|
||||
equity_allocation: 每只标的的资金分配比例
|
||||
combined_equity: 组合整体净值曲线(按日期对齐各标的求和),
|
||||
列: datetime/total/drawdown/drawdown_pct。各标的独立回测日期范围
|
||||
可能不同,此处按日期并集 forward-fill 对齐后求和。
|
||||
trades: 组合层汇总成交(各标的 concat + ``symbol`` 列标注来源标的),
|
||||
供组合级绩效统计(逐标的 FIFO 配对持仓天数)与前端明细表使用。
|
||||
"""
|
||||
|
||||
total_performance: dict[str, float]
|
||||
individual_results: dict[str, BacktestResult]
|
||||
equity_allocation: dict[str, float]
|
||||
combined_equity: pd.DataFrame
|
||||
trades: pd.DataFrame = field(default_factory=pd.DataFrame)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""转为可序列化字典。"""
|
||||
@@ -56,6 +62,7 @@ class PortfolioResult:
|
||||
"individual_results": {k: v.to_dict() for k, v in self.individual_results.items()},
|
||||
"equity_allocation": self.equity_allocation,
|
||||
"combined_equity": self.combined_equity.to_dict(orient="records"),
|
||||
"trades": self.trades.to_dict(orient="records"),
|
||||
}
|
||||
|
||||
|
||||
@@ -171,57 +178,84 @@ class PortfolioBacktestEngine:
|
||||
result = engine.run(stock.df)
|
||||
individual_results[key] = result
|
||||
|
||||
# 汇总整体绩效
|
||||
total_perf = self._aggregate_performance(individual_results, allocations)
|
||||
# 组合整体净值曲线(各标的按日期对齐求和)——绩效指标依赖它,先算
|
||||
combined_equity = self._build_combined_equity(individual_results, allocations)
|
||||
|
||||
# 汇总整体绩效(合并净值 + 汇总成交 → PerformanceAnalyzer 完整指标)
|
||||
all_trades = self._merge_trades(individual_results)
|
||||
total_perf = self._aggregate_performance(
|
||||
individual_results, allocations, combined_equity, all_trades
|
||||
)
|
||||
|
||||
# 计算资金占比
|
||||
total_alloc = sum(allocations.values())
|
||||
equity_pct = {k: v / total_alloc if total_alloc > 0 else 0 for k, v in allocations.items()}
|
||||
|
||||
# 生成组合整体净值曲线(各标的按日期对齐求和)
|
||||
combined_equity = self._build_combined_equity(individual_results, allocations)
|
||||
|
||||
return PortfolioResult(
|
||||
total_performance=total_perf,
|
||||
individual_results=individual_results,
|
||||
equity_allocation=equity_pct,
|
||||
combined_equity=combined_equity,
|
||||
trades=all_trades,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _merge_trades(results: dict[str, BacktestResult]) -> pd.DataFrame:
|
||||
"""把各标的成交 concat 成组合层成交表,附 ``symbol`` 列标注来源标的。
|
||||
|
||||
``symbol`` 列让 PerformanceAnalyzer 的 FIFO 持仓天数配对按标的分组
|
||||
(避免 A 股的买入被 B 股的卖出错误配对);无成交时返回空表。
|
||||
"""
|
||||
frames: list[pd.DataFrame] = []
|
||||
for key, result in results.items():
|
||||
if len(result.trades) > 0:
|
||||
t = result.trades.copy()
|
||||
t["symbol"] = key
|
||||
frames.append(t)
|
||||
if not frames:
|
||||
return pd.DataFrame(columns=["symbol", "direction", "pnl", "rejected"])
|
||||
return pd.concat(frames, ignore_index=True)
|
||||
|
||||
def _aggregate_performance(
|
||||
self,
|
||||
results: dict[str, BacktestResult],
|
||||
allocations: dict[str, float],
|
||||
combined_equity: pd.DataFrame,
|
||||
all_trades: pd.DataFrame,
|
||||
) -> dict[str, float]:
|
||||
"""汇总所有标的的绩效为组合整体绩效。
|
||||
|
||||
使用资金加权方式计算组合收益率。
|
||||
与多策略引擎 ``MultiStrategyEngine._aggregate_performance`` 同口径:
|
||||
合并净值曲线 + 汇总成交喂 :class:`PerformanceAnalyzer`,得到
|
||||
与单标的回测一致的完整指标(夏普/回撤/胜率/盈亏比/SQN/最大连胜连亏
|
||||
等 25 项),便于前端复用 MetricTable 展示。合并曲线的首个值即总投入
|
||||
资金,因此 ``total_return`` 天然等于资金加权收益率。
|
||||
|
||||
Args:
|
||||
results: 各标的回测结果
|
||||
allocations: 各标的资金分配
|
||||
combined_equity: 组合整体净值曲线(_build_combined_equity 产物)
|
||||
all_trades: 组合层汇总成交(_merge_trades 产物,含 symbol 列)
|
||||
|
||||
Returns:
|
||||
组合整体绩效指标
|
||||
组合整体绩效指标(另附 total_stocks / total_cash 组合字段)
|
||||
"""
|
||||
from easy_tdx.backtest.performance import PerformanceAnalyzer
|
||||
|
||||
total_cash = sum(allocations.values())
|
||||
if total_cash == 0:
|
||||
return {"total_return": 0.0, "annual_return": 0.0}
|
||||
if not results or len(combined_equity) < 2:
|
||||
return {
|
||||
"total_return": 0.0,
|
||||
"annual_return": 0.0,
|
||||
"total_stocks": float(len(results)),
|
||||
"total_cash": total_cash,
|
||||
}
|
||||
|
||||
# 资金加权收益率
|
||||
weighted_return = 0.0
|
||||
for key, result in results.items():
|
||||
alloc = allocations.get(key, 0)
|
||||
weight = alloc / total_cash
|
||||
ret = result.performance.get("total_return", 0.0)
|
||||
weighted_return += weight * ret
|
||||
|
||||
return {
|
||||
"total_return": weighted_return,
|
||||
"annual_return": weighted_return, # 简化,实际应根据周期年化
|
||||
"total_stocks": len(results),
|
||||
"total_cash": total_cash,
|
||||
}
|
||||
analyzer = PerformanceAnalyzer(equity_curve=combined_equity, trades=all_trades)
|
||||
metrics = analyzer.compute()
|
||||
metrics["total_stocks"] = float(len(results))
|
||||
metrics["total_cash"] = total_cash
|
||||
return metrics
|
||||
|
||||
def _build_combined_equity(
|
||||
self,
|
||||
@@ -265,12 +299,16 @@ class PortfolioBacktestEngine:
|
||||
aligned = aligned.ffill().fillna(0)
|
||||
total = aligned.sum(axis=1)
|
||||
|
||||
# 计算回撤
|
||||
# 回撤:drawdown 为绝对回撤额(峰值-当前,正值),drawdown_pct 为相对
|
||||
# 当时峰值的回撤比例(drawdown / peak,0~1)。分母必须用逐点 peak 而非
|
||||
# 固定初始值:净值大涨后 peak 是初始值的好几倍,若除以 initial 会把回撤
|
||||
# 百分比严重放大。与单标的 PortfolioTracker.equity_curve、
|
||||
# MultiStrategyEngine._build_combined_equity 的定义保持一致,
|
||||
# PerformanceAnalyzer 直接读 drawdown_pct 列算 max_drawdown。
|
||||
peak = total.cummax()
|
||||
drawdown = total - peak
|
||||
# drawdown_pct:以初始总资金为基准(peak 的首个值),避免除零
|
||||
initial = peak.iloc[0] if len(peak) > 0 and peak.iloc[0] != 0 else 1.0
|
||||
drawdown_pct = drawdown / initial
|
||||
drawdown = peak - total
|
||||
peak_safe = peak.where(peak != 0, 1.0)
|
||||
drawdown_pct = drawdown / peak_safe
|
||||
|
||||
return pd.DataFrame(
|
||||
{
|
||||
|
||||
@@ -40,7 +40,12 @@ from easy_tdx.backtest.engine import BacktestEngine
|
||||
from easy_tdx.backtest.strategy import Strategy
|
||||
from easy_tdx.backtest.types import to_json_native
|
||||
|
||||
__all__ = ["WalkForwardWindow", "WalkForwardResult", "WalkForwardEngine"]
|
||||
__all__ = [
|
||||
"WalkForwardWindow",
|
||||
"WalkForwardResult",
|
||||
"WalkForwardEngine",
|
||||
"PortfolioWalkForwardEngine",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -266,3 +271,215 @@ class WalkForwardEngine:
|
||||
result.mean_sharpe = float(np.mean([w.sharpe for w in ws]))
|
||||
result.worst_drawdown = float(min(w.max_drawdown for w in ws))
|
||||
result.total_trades = int(sum(w.total_trades for w in ws))
|
||||
|
||||
|
||||
class PortfolioWalkForwardEngine:
|
||||
"""组合级 Walk-Forward:一个策略 × 多只标的,逐窗独立回测并合成组合净值。
|
||||
|
||||
与 :class:`WalkForwardEngine`(单标的)共用切窗语义与
|
||||
:class:`WalkForwardWindow` / :class:`WalkForwardResult` 结构——前端
|
||||
WalkForwardPanel 无需改动即可渲染组合 WF:
|
||||
|
||||
1. **参考时间轴**:取全部标的 datetime 的并集(升序),按单标的同样的
|
||||
规则切预热区 + ``n_windows`` 个连续测试窗;
|
||||
2. **每窗独立开仓**:窗内每只标的带 ``context_bars`` 前置上下文
|
||||
(``warmup_bars`` 压制上下文信号),从空仓开始、窗口结束强制了结,
|
||||
持仓不跨窗;
|
||||
3. **组合净值合成**:各标的窗内净值按等权资金(``total_cash / N``)
|
||||
对齐求合成组合窗内净值,再喂 :class:`~easy_tdx.backtest.performance.PerformanceAnalyzer`
|
||||
(汇总成交附 symbol 列)得到与单标的同口径的窗指标;
|
||||
4. **容错**:某标的数据不足(如晚上市)则该窗跳过该标的;某窗所有
|
||||
标的都跑不了则跳过该窗。
|
||||
|
||||
Example:
|
||||
>>> wf = PortfolioWalkForwardEngine(strategy=MyStrategy, stocks=stocks, n_windows=7)
|
||||
>>> result = wf.run()
|
||||
>>> result.consistency # 组合盈利窗占比
|
||||
0.71
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
strategy: type[Strategy] | Strategy,
|
||||
stocks: list[Any],
|
||||
n_windows: int = 7,
|
||||
warmup_ratio: float = 0.3,
|
||||
context_bars: int = 60,
|
||||
total_cash: float = 1_000_000.0,
|
||||
commission: float = 0.0003,
|
||||
min_commission: float = 5.0,
|
||||
stamp_tax: float = 0.001,
|
||||
slippage: float = 0.0,
|
||||
execution: str = "next_open",
|
||||
chanlun_level: str | None = None,
|
||||
auto_fees: bool = False,
|
||||
) -> None:
|
||||
"""Initialize.
|
||||
|
||||
Args:
|
||||
strategy: 策略类或实例(各窗各标的共用同一策略与参数)。
|
||||
stocks: :class:`~easy_tdx.backtest.portfolio_engine.StockData` 列表。
|
||||
n_windows / warmup_ratio / context_bars: 切窗参数(同单标的 WF)。
|
||||
total_cash: 组合总资金(各标的等权分 1/N)。
|
||||
其余参数: 透传给各窗各标的的 :class:`BacktestEngine`。
|
||||
"""
|
||||
self._strategy = strategy
|
||||
self._stocks = list(stocks)
|
||||
self._n_windows = max(int(n_windows), 2)
|
||||
self._warmup_ratio = min(max(float(warmup_ratio), 0.0), 0.8)
|
||||
self._context_bars = max(int(context_bars), 0)
|
||||
self._total_cash = float(total_cash)
|
||||
self._engine_kwargs: dict[str, Any] = {
|
||||
"commission": commission,
|
||||
"min_commission": min_commission,
|
||||
"stamp_tax": stamp_tax,
|
||||
"slippage": slippage,
|
||||
"execution": execution,
|
||||
"chanlun_level": chanlun_level,
|
||||
"auto_fees": auto_fees,
|
||||
}
|
||||
|
||||
def run(self) -> WalkForwardResult:
|
||||
"""执行组合 Walk-Forward 验证。
|
||||
|
||||
Returns:
|
||||
:class:`WalkForwardResult`。数据不足以切窗时返回空结果
|
||||
(``windows`` 为空,聚合指标为 0)。
|
||||
"""
|
||||
result = WalkForwardResult(n_windows=self._n_windows, warmup_ratio=self._warmup_ratio)
|
||||
if not self._stocks:
|
||||
return result
|
||||
|
||||
# 参考时间轴:全部标的 datetime 的并集(升序)
|
||||
timeline = self._reference_timeline()
|
||||
n = len(timeline)
|
||||
# 最少数据:每窗 ≥ 20 根 + 预热区 ≥ 20 根
|
||||
min_bars = 20 * (1 + self._n_windows)
|
||||
if n < min_bars:
|
||||
return result
|
||||
|
||||
eval_start = int(n * self._warmup_ratio)
|
||||
eval_len = n - eval_start
|
||||
window_len = eval_len // self._n_windows
|
||||
|
||||
for i in range(self._n_windows):
|
||||
s = eval_start + i * window_len
|
||||
e = s + window_len if i < self._n_windows - 1 else n # 末窗吃到尾部
|
||||
if e - s < 5:
|
||||
continue
|
||||
win = self._run_window(timeline, s, e, i)
|
||||
if win is not None:
|
||||
result.windows.append(win)
|
||||
|
||||
WalkForwardEngine._aggregate(result)
|
||||
return result
|
||||
|
||||
def _reference_timeline(self) -> pd.DatetimeIndex:
|
||||
"""全部标的 datetime 的并集(升序,Timestamp 化)。"""
|
||||
all_dt: list[pd.Timestamp] = []
|
||||
for stock in self._stocks:
|
||||
s = self._dt_series(stock.df)
|
||||
if len(s) > 0:
|
||||
all_dt.append(s)
|
||||
if not all_dt:
|
||||
return pd.DatetimeIndex([])
|
||||
return pd.DatetimeIndex(sorted(pd.unique(pd.concat(all_dt))))
|
||||
|
||||
@staticmethod
|
||||
def _dt_series(df: pd.DataFrame) -> pd.Series:
|
||||
"""标的 K 线的 datetime 列统一转 Timestamp(int YYYYMMDD 兼容)。"""
|
||||
col = "datetime" if "datetime" in df.columns else "date"
|
||||
dt = df[col]
|
||||
if dt.dtype.kind in "iu":
|
||||
return pd.to_datetime(dt.astype(str), format="%Y%m%d")
|
||||
if not pd.api.types.is_datetime64_any_dtype(dt):
|
||||
return pd.to_datetime(dt)
|
||||
return pd.Series(pd.to_datetime(dt), index=df.index)
|
||||
|
||||
def _run_window(
|
||||
self, timeline: pd.DatetimeIndex, s: int, e: int, index: int
|
||||
) -> WalkForwardWindow | None:
|
||||
"""独立回测单个窗口 [s, e)(参考时间轴下标),合成组合窗内净值。"""
|
||||
window_start = timeline[s]
|
||||
window_end = timeline[e - 1]
|
||||
ctx_start = timeline[max(0, s - self._context_bars)]
|
||||
|
||||
per_cash = self._total_cash / len(self._stocks)
|
||||
equity_series: list[pd.Series] = []
|
||||
trade_frames: list[pd.DataFrame] = []
|
||||
for stock in self._stocks:
|
||||
key = f"{stock.market}{stock.code}"
|
||||
dt = self._dt_series(stock.df)
|
||||
mask = (dt >= ctx_start) & (dt <= window_end)
|
||||
sub = stock.df.loc[mask].reset_index(drop=True)
|
||||
dt_sub = dt.loc[mask].reset_index(drop=True)
|
||||
# 上下文 bar 数 = 窗口起点之前保留的 bar 数(warmup 压制其信号)
|
||||
lead = int((dt_sub < window_start).sum())
|
||||
if len(sub) < lead + 5:
|
||||
continue # 该标的数据不足(晚上市/停牌过多),本窗跳过
|
||||
|
||||
engine = BacktestEngine(
|
||||
strategy=self._strategy,
|
||||
cash=per_cash,
|
||||
warmup_bars=lead,
|
||||
symbol=key,
|
||||
**self._engine_kwargs,
|
||||
)
|
||||
try:
|
||||
bt = engine.run(sub)
|
||||
except Exception: # noqa: BLE001 — 单标的失败不拖垮整窗
|
||||
continue
|
||||
|
||||
# 只取窗内净值点(上下文区恒为现金,不参与窗指标,避免稀释波动率)
|
||||
ec = bt.equity_curve
|
||||
if len(ec) > lead:
|
||||
eq = ec.iloc[lead:]
|
||||
equity_series.append(
|
||||
pd.Series(eq["total"].to_numpy(), index=self._dt_series(eq), name=key)
|
||||
)
|
||||
if len(bt.trades) > 0:
|
||||
t = bt.trades.copy()
|
||||
t["symbol"] = key
|
||||
trade_frames.append(t)
|
||||
|
||||
if not equity_series:
|
||||
return None # 所有标的都跑不了,跳过该窗
|
||||
|
||||
# 合成组合窗内净值:日期并集对齐,ffill 持有不动,上市晚于窗口起点的
|
||||
# 标的其前导缺口用首值回填(首值即其初始资金——还没开仓,持有现金)
|
||||
aligned = pd.concat(equity_series, axis=1).sort_index()
|
||||
aligned = aligned.ffill().bfill()
|
||||
total = aligned.sum(axis=1)
|
||||
peak = total.cummax()
|
||||
drawdown = peak - total
|
||||
peak_safe = peak.where(peak != 0, 1.0)
|
||||
window_equity = pd.DataFrame(
|
||||
{
|
||||
"datetime": total.index,
|
||||
"total": total.to_numpy(),
|
||||
"drawdown": drawdown.to_numpy(),
|
||||
"drawdown_pct": (drawdown / peak_safe).to_numpy(),
|
||||
}
|
||||
)
|
||||
|
||||
all_trades = (
|
||||
pd.concat(trade_frames, ignore_index=True)
|
||||
if trade_frames
|
||||
else pd.DataFrame(columns=["symbol", "direction", "pnl", "rejected"])
|
||||
)
|
||||
from easy_tdx.backtest.performance import PerformanceAnalyzer
|
||||
|
||||
perf = PerformanceAnalyzer(equity_curve=window_equity, trades=all_trades).compute()
|
||||
|
||||
return WalkForwardWindow(
|
||||
index=index,
|
||||
start=window_start.strftime("%Y-%m-%d"),
|
||||
end=window_end.strftime("%Y-%m-%d"),
|
||||
bars=int(e - s),
|
||||
total_return=float(perf.get("total_return", 0.0)),
|
||||
sharpe=float(perf.get("sharpe", 0.0)),
|
||||
max_drawdown=float(perf.get("max_drawdown", 0.0)),
|
||||
total_trades=int(perf.get("total_trades", 0)),
|
||||
win_rate=float(perf.get("win_rate", 0.0)),
|
||||
performance={k: v for k, v in perf.items()},
|
||||
)
|
||||
|
||||
@@ -457,6 +457,71 @@ async def run_evaluate_async(
|
||||
return TaskSubmitResponse(task_id=task_id, status=status)
|
||||
|
||||
|
||||
# ── 组合级 Walk-Forward / 一条龙评估(对齐单标的防过拟合链)──────────────────
|
||||
|
||||
|
||||
@router.post("/backtest/portfolio/wf/run/async", response_model=TaskSubmitResponse, status_code=202)
|
||||
async def run_portfolio_walkforward_async(
|
||||
req: PortfolioBacktestRequest,
|
||||
n_windows: int = 7,
|
||||
client: Any = Depends(get_client),
|
||||
) -> TaskSubmitResponse:
|
||||
"""提交组合级 Walk-Forward 样本外验证后台任务。
|
||||
|
||||
逐个标的取行情后,按全部标的日期并集切窗(预热区 + N 个连续测试窗),
|
||||
每窗各标的独立回测并合成组合窗内净值。结果为
|
||||
``{"walkforward": {...}}``(与单标的 WF 同构),通过
|
||||
GET /backtest/tasks/{task_id} 轮询。
|
||||
"""
|
||||
stock_data_list = await _fetch_portfolio_bars(
|
||||
client, req.stocks, req.category, req.start_date, req.end_date
|
||||
)
|
||||
if not stock_data_list:
|
||||
raise ValueError("所有标的均未取到有效行情数据")
|
||||
|
||||
snapshot = req.model_copy()
|
||||
description = f"{snapshot.strategy} 组合WF | {len(stock_data_list)}只标的 × {n_windows}窗"
|
||||
runner = get_runner()
|
||||
task_id = runner.submit(
|
||||
lambda: _run_portfolio_walkforward(stock_data_list, snapshot, n_windows),
|
||||
description=description,
|
||||
)
|
||||
state = runner.get(task_id)
|
||||
status: Any = state.status if state.status in ("pending", "running") else "running"
|
||||
return TaskSubmitResponse(task_id=task_id, status=status)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/backtest/portfolio/evaluate/run/async", response_model=TaskSubmitResponse, status_code=202
|
||||
)
|
||||
async def run_portfolio_evaluate_async(
|
||||
req: PortfolioBacktestRequest,
|
||||
client: Any = Depends(get_client),
|
||||
) -> TaskSubmitResponse:
|
||||
"""提交组合级一条龙评估后台任务:组合回测 + 组合 WF + 跨标的适配性体检
|
||||
+ 综合评分 + 组合评级 + 等权买入持有基准对比。
|
||||
|
||||
结果结构见 ``easy_tdx.backtest.benchmark.evaluate_portfolio`` 文档(与
|
||||
单标的 evaluate_strategy 同构),通过 GET /backtest/tasks/{task_id} 轮询。
|
||||
"""
|
||||
stock_data_list = await _fetch_portfolio_bars(
|
||||
client, req.stocks, req.category, req.start_date, req.end_date
|
||||
)
|
||||
if not stock_data_list:
|
||||
raise ValueError("所有标的均未取到有效行情数据")
|
||||
|
||||
snapshot = req.model_copy()
|
||||
description = f"{snapshot.strategy} 组合一条龙 | {len(stock_data_list)}只标的"
|
||||
runner = get_runner()
|
||||
task_id = runner.submit(
|
||||
lambda: _run_portfolio_evaluate(stock_data_list, snapshot),
|
||||
description=description,
|
||||
)
|
||||
state = runner.get(task_id)
|
||||
status: Any = state.status if state.status in ("pending", "running") else "running"
|
||||
return TaskSubmitResponse(task_id=task_id, status=status)
|
||||
|
||||
|
||||
async def _resolve_df(client: Any, req: BacktestRequest) -> pd.DataFrame:
|
||||
"""内联 ohlcv 或按 symbol 取行情(/backtest/run/async 同逻辑的复用封装)。"""
|
||||
if req.ohlcv is not None:
|
||||
@@ -587,6 +652,58 @@ def _run_evaluate(df: pd.DataFrame, req: BacktestRequest) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
|
||||
def _run_portfolio_walkforward(
|
||||
stock_data_list: list[Any], req: PortfolioBacktestRequest, n_windows: int = 7
|
||||
) -> dict[str, Any]:
|
||||
"""执行组合级 Walk-Forward 验证(后台线程内调用)。"""
|
||||
from easy_tdx.backtest.strategies import get_registry
|
||||
from easy_tdx.backtest.walkforward import PortfolioWalkForwardEngine
|
||||
|
||||
try:
|
||||
entry = get_registry().get(req.strategy)
|
||||
except KeyError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
||||
wf = PortfolioWalkForwardEngine(
|
||||
strategy=entry.build(req.params),
|
||||
stocks=stock_data_list,
|
||||
n_windows=n_windows,
|
||||
total_cash=req.cash,
|
||||
commission=req.commission,
|
||||
min_commission=req.min_commission,
|
||||
stamp_tax=req.stamp_tax,
|
||||
slippage=req.slippage,
|
||||
execution=req.execution,
|
||||
auto_fees=req.auto_fees,
|
||||
).run()
|
||||
return {"walkforward": wf.to_dict()}
|
||||
|
||||
|
||||
def _run_portfolio_evaluate(
|
||||
stock_data_list: list[Any], req: PortfolioBacktestRequest
|
||||
) -> dict[str, Any]:
|
||||
"""执行组合级一条龙评估(后台线程内调用)。"""
|
||||
from easy_tdx.backtest.benchmark import evaluate_portfolio
|
||||
from easy_tdx.backtest.strategies import get_registry
|
||||
|
||||
try:
|
||||
entry = get_registry().get(req.strategy)
|
||||
except KeyError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
||||
return evaluate_portfolio(
|
||||
strategy=entry.build(req.params),
|
||||
stocks=stock_data_list,
|
||||
total_cash=req.cash,
|
||||
commission=req.commission,
|
||||
min_commission=req.min_commission,
|
||||
stamp_tax=req.stamp_tax,
|
||||
slippage=req.slippage,
|
||||
execution=req.execution,
|
||||
auto_fees=req.auto_fees,
|
||||
)
|
||||
|
||||
|
||||
# ── 轮动组合回测(v1.27)─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -715,6 +832,27 @@ def _ohlcv_to_df(records: list[dict[str, Any]]) -> pd.DataFrame:
|
||||
return df
|
||||
|
||||
|
||||
def _normalize_bars_dt(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""把取到的 K 线规范化为引擎可直接消费的列布局(返回新 df 或原 df)。
|
||||
|
||||
引擎(StrategyDataProxy / PortfolioTracker):时间列必须叫 ``datetime``
|
||||
(``date`` 列会被当成数值列强转 float 而报错),类型接受 int YYYYMMDD
|
||||
或 datetime64。真实 TDX 日线返回 int ``date`` 列、分钟线返回 ``datetime``,
|
||||
而 E2E mock 返回字符串 ``date``——这里统一:改名 ``date``→``datetime``、
|
||||
字符串/对象类型 coerce 成 datetime64、删除遗留的 ``date`` 冗余列。
|
||||
"""
|
||||
if "datetime" not in df.columns and "date" in df.columns:
|
||||
df = df.copy()
|
||||
df["datetime"] = df["date"]
|
||||
dt = df["datetime"]
|
||||
if dt.dtype.kind not in "iu" and not pd.api.types.is_datetime64_any_dtype(dt):
|
||||
df = df.copy()
|
||||
df["datetime"] = pd.to_datetime(df["datetime"], errors="coerce")
|
||||
if "date" in df.columns:
|
||||
df = df.drop(columns=["date"])
|
||||
return df
|
||||
|
||||
|
||||
async def _fetch_bars(client: Any, symbol: str, category: str, count: int) -> pd.DataFrame:
|
||||
"""按标的取 K 线(async,必须在 event loop 内调用)。"""
|
||||
from easy_tdx.web.convert import category_from_str, market_from_str
|
||||
@@ -729,14 +867,21 @@ async def _fetch_bars(client: Any, symbol: str, category: str, count: int) -> pd
|
||||
)
|
||||
if len(df) == 0:
|
||||
raise ValueError(f"标的 {symbol} 未取到任何 K 线数据")
|
||||
return df
|
||||
return _normalize_bars_dt(df)
|
||||
|
||||
|
||||
def _run_portfolio_backtest(
|
||||
stock_data_list: list[Any], req: PortfolioBacktestRequest
|
||||
) -> dict[str, Any]:
|
||||
"""执行组合回测并返回清洗后的结果字典(后台线程内调用)。"""
|
||||
"""执行组合回测并返回清洗后的结果字典(后台线程内调用)。
|
||||
|
||||
与单标的 ``_run_backtest`` 对齐:附带组合评级(``grade_portfolio_equity``,
|
||||
净值曲线 5 维度口径)与综合评分(``score_strategy``,无 WF 时权重自动
|
||||
归一化),供前端/REST 直接消费。
|
||||
"""
|
||||
from easy_tdx.backtest.grading import grade_portfolio_equity
|
||||
from easy_tdx.backtest.portfolio_engine import PortfolioBacktestEngine
|
||||
from easy_tdx.backtest.scoring import score_strategy
|
||||
from easy_tdx.backtest.strategies import get_registry
|
||||
|
||||
try:
|
||||
@@ -757,7 +902,14 @@ def _run_portfolio_backtest(
|
||||
auto_fees=req.auto_fees,
|
||||
)
|
||||
result = engine.run()
|
||||
return serialize_result(result)
|
||||
out = serialize_result(result)
|
||||
# 组合评级(净值曲线口径)+ 综合评分——与单标的回测响应同构
|
||||
if len(result.combined_equity) >= 2:
|
||||
out["grade"] = grade_portfolio_equity(
|
||||
result.combined_equity.to_dict(orient="records")
|
||||
).to_dict()
|
||||
out["score"] = score_strategy(dict(result.total_performance)).to_dict()
|
||||
return out
|
||||
|
||||
|
||||
async def _fetch_portfolio_bars(
|
||||
@@ -812,6 +964,7 @@ async def _fetch_portfolio_bars(
|
||||
df["datetime"] = df["date"]
|
||||
# 翻页拼接后按时间正序排序(页间逆序)
|
||||
df = df.sort_values("datetime").reset_index(drop=True)
|
||||
df = _normalize_bars_dt(df)
|
||||
# 日期范围过滤
|
||||
if start_date or end_date:
|
||||
dt_str = df["datetime"].astype(str).str.slice(0, 10)
|
||||
@@ -882,6 +1035,7 @@ async def _fetch_multi_strategy_bars(
|
||||
df = df.copy()
|
||||
df["datetime"] = df["date"]
|
||||
df = df.sort_values("datetime").reset_index(drop=True)
|
||||
df = _normalize_bars_dt(df)
|
||||
# 日期范围过滤
|
||||
if item.start_date or item.end_date:
|
||||
df = _filter_df_by_date(df, item.start_date, item.end_date)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
@@ -194,3 +195,59 @@ def test_evaluate_strategy_auto_fees_for_etf():
|
||||
report = evaluate_strategy(_BuyFirstBar, _df(300), symbol="SH:510300", auto_fees=True)
|
||||
assert report["config"]["symbol"] == "SH:510300"
|
||||
assert report["config"]["auto_fees"] is True
|
||||
|
||||
|
||||
# ── evaluate_portfolio(v1.31 组合级一条龙)───────────────────────────────────
|
||||
def _stocks_for_portfolio() -> list[Any]:
|
||||
from easy_tdx.backtest.portfolio_engine import StockData
|
||||
|
||||
return [
|
||||
StockData("000001", "SZ", _df(400, drift=0.002)),
|
||||
StockData("600000", "SH", _df(400, drift=0.003)),
|
||||
]
|
||||
|
||||
|
||||
def test_evaluate_portfolio_full_report_structure():
|
||||
"""组合一条龙报告与单标的 evaluate_strategy 同构(前端面板可复用)。"""
|
||||
from easy_tdx.backtest.benchmark import evaluate_portfolio
|
||||
|
||||
report = evaluate_portfolio(_CycleTrader(), _stocks_for_portfolio(), total_cash=500_000)
|
||||
for key in ("performance", "score", "grade", "walkforward", "fitness", "benchmark", "config"):
|
||||
assert key in report
|
||||
# 组合绩效:完整指标 + 组合字段
|
||||
assert "sqn" in report["performance"]
|
||||
assert "max_consecutive_losses" in report["performance"]
|
||||
assert report["performance"]["total_stocks"] == 2
|
||||
# 评分/评级
|
||||
assert 0 <= report["score"]["total"] <= 100
|
||||
assert report["score"]["wf_provided"] is True
|
||||
assert report["grade"]["grade"] in ("S", "A", "B", "C", "D")
|
||||
assert report["grade"]["scenario"] == "portfolio"
|
||||
# 组合 WF
|
||||
assert len(report["walkforward"]["windows"]) > 0
|
||||
# 适配性(跨标的聚合)
|
||||
assert report["fitness"]["total_checks"] == 8
|
||||
assert "只标的通过" in report["fitness"]["checks"][0]["detail"]
|
||||
# 基准
|
||||
assert "buy_hold" in report["benchmark"]
|
||||
assert "excess_return" in report["benchmark"]
|
||||
# config 记录标的清单
|
||||
assert report["config"]["stocks"] == ["SZ000001", "SH600000"]
|
||||
|
||||
|
||||
def test_evaluate_portfolio_buy_hold_excess_near_zero():
|
||||
"""首根买入持有策略 ≈ 等权买入持有基准,excess_return 接近 0(扣费差异)。"""
|
||||
from easy_tdx.backtest.benchmark import evaluate_portfolio
|
||||
|
||||
report = evaluate_portfolio(_BuyFirstBar(), _stocks_for_portfolio(), total_cash=500_000)
|
||||
assert abs(report["benchmark"]["excess_return"]) < 0.05
|
||||
|
||||
|
||||
def test_evaluate_portfolio_serializable():
|
||||
from easy_tdx.backtest.benchmark import evaluate_portfolio
|
||||
|
||||
report = evaluate_portfolio(
|
||||
_CycleTrader(), _stocks_for_portfolio(), total_cash=500_000, n_windows=3
|
||||
)
|
||||
text = json.dumps(report, default=str)
|
||||
assert "excess_return" in text
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""单元测试:组合级 Walk-Forward 引擎(PortfolioWalkForwardEngine,v1.31)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from easy_tdx.backtest.portfolio_engine import StockData
|
||||
from easy_tdx.backtest.strategy import Strategy
|
||||
from easy_tdx.backtest.walkforward import PortfolioWalkForwardEngine
|
||||
|
||||
|
||||
class PeriodicStrategy(Strategy):
|
||||
"""每 10 根切换一次持仓,保证窗口内有成交(与单标的 WF 测试同思路)。"""
|
||||
|
||||
def init(self) -> None:
|
||||
self._holding = False
|
||||
|
||||
def next(self) -> None:
|
||||
if self._bar_index % 10 == 0 and not self._holding:
|
||||
self.buy(size=0)
|
||||
self._holding = True
|
||||
elif self._bar_index % 10 == 5 and self._holding:
|
||||
self.sell(size=0)
|
||||
self._holding = False
|
||||
|
||||
|
||||
def _make_df(n: int = 400, seed: int = 42, start: str = "2023-01-01") -> pd.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(1_000_000, 10_000_000, n).astype(float)
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"datetime": pd.date_range(start, periods=n, freq="D"),
|
||||
"open": open_,
|
||||
"high": high,
|
||||
"low": low,
|
||||
"close": close,
|
||||
"vol": vol,
|
||||
"amount": vol * close,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _stocks() -> list[StockData]:
|
||||
return [
|
||||
StockData("000001", "SZ", _make_df(400, seed=42)),
|
||||
StockData("600000", "SH", _make_df(400, seed=99)),
|
||||
]
|
||||
|
||||
|
||||
class TestPortfolioWalkForward:
|
||||
def test_basic_structure(self) -> None:
|
||||
"""切窗数量、窗口字段与聚合指标齐全。"""
|
||||
wf = PortfolioWalkForwardEngine(
|
||||
strategy=PeriodicStrategy, stocks=_stocks(), n_windows=4, total_cash=200_000
|
||||
).run()
|
||||
assert len(wf.windows) == 4
|
||||
for i, w in enumerate(wf.windows):
|
||||
assert w.index == i
|
||||
assert w.start <= w.end
|
||||
assert w.bars > 0
|
||||
# 窗口时间升序且不重叠
|
||||
starts = [pd.Timestamp(w.start) for w in wf.windows]
|
||||
assert starts == sorted(starts)
|
||||
assert wf.total_trades > 0
|
||||
|
||||
def test_aggregates_consistency_and_chained(self) -> None:
|
||||
"""consistency = 盈利窗占比,chained = 各窗连乘 - 1。"""
|
||||
wf = PortfolioWalkForwardEngine(
|
||||
strategy=PeriodicStrategy, stocks=_stocks(), n_windows=5
|
||||
).run()
|
||||
rets = [w.total_return for w in wf.windows]
|
||||
assert wf.consistency == sum(1 for r in rets if r > 0) / len(rets)
|
||||
chained = float(np.prod([1.0 + r for r in rets]) - 1.0)
|
||||
assert wf.chained_return == pd.Series([chained]).iloc[0]
|
||||
|
||||
def test_insufficient_data_returns_empty(self) -> None:
|
||||
"""数据不足以切窗时返回空结果(windows 为空、聚合指标为 0)。"""
|
||||
stocks = [StockData("000001", "SZ", _make_df(50, seed=1))]
|
||||
wf = PortfolioWalkForwardEngine(strategy=PeriodicStrategy, stocks=stocks, n_windows=7).run()
|
||||
assert wf.windows == []
|
||||
assert wf.consistency == 0.0
|
||||
|
||||
def test_empty_stocks_returns_empty(self) -> None:
|
||||
wf = PortfolioWalkForwardEngine(strategy=PeriodicStrategy, stocks=[], n_windows=3).run()
|
||||
assert wf.windows == []
|
||||
|
||||
def test_late_listing_stock_tolerated(self) -> None:
|
||||
"""晚上市的标的不该拖垮整窗(该窗跳过它,其余照常)。"""
|
||||
stocks = [
|
||||
StockData("000001", "SZ", _make_df(400, seed=42)),
|
||||
StockData("688981", "SH", _make_df(100, seed=7, start="2024-02-01")),
|
||||
]
|
||||
wf = PortfolioWalkForwardEngine(strategy=PeriodicStrategy, stocks=stocks, n_windows=4).run()
|
||||
assert len(wf.windows) == 4
|
||||
assert all(w.total_trades > 0 for w in wf.windows)
|
||||
|
||||
def test_window_independent_opening(self) -> None:
|
||||
"""每窗独立开仓:窗口总交易数应等于窗内各标的回合数(无跨窗结转)。"""
|
||||
stocks = _stocks()
|
||||
n_windows = 4
|
||||
wf = PortfolioWalkForwardEngine(
|
||||
strategy=PeriodicStrategy, stocks=stocks, n_windows=n_windows
|
||||
).run()
|
||||
# PeriodicStrategy 每 10 根一个回合,窗长约 56 根 → 每标的每窗 5 回合上下,
|
||||
# 总交易数应为正且与窗口长度量级一致(防止持仓跨窗导致的重复/丢失计数)。
|
||||
assert wf.total_trades > 0
|
||||
assert wf.total_trades == sum(w.total_trades for w in wf.windows)
|
||||
|
||||
def test_to_dict_serializable(self) -> None:
|
||||
wf = PortfolioWalkForwardEngine(
|
||||
strategy=PeriodicStrategy, stocks=_stocks(), n_windows=3
|
||||
).run()
|
||||
d = wf.to_dict()
|
||||
assert len(d["windows"]) == len(wf.windows)
|
||||
# JSON 兼容(numpy 标量已清洗)
|
||||
json.dumps(d)
|
||||
# 每窗 performance 为完整指标 dict(含 SQN 等深度指标)
|
||||
assert "sqn" in d["windows"][0]["performance"]
|
||||
assert "max_consecutive_wins" in d["windows"][0]["performance"]
|
||||
|
||||
def test_min_windows_guard(self) -> None:
|
||||
"""n_windows < 2 至少取 2(与单标的 WF 同保护)。"""
|
||||
wf = PortfolioWalkForwardEngine(
|
||||
strategy=PeriodicStrategy, stocks=_stocks(), n_windows=0
|
||||
).run()
|
||||
assert wf.n_windows == 2
|
||||
@@ -1048,3 +1048,151 @@ def test_list_tasks_limit(client, sample_ohlcv):
|
||||
|
||||
resp = client.get("/api/v1/backtest/tasks?limit=2")
|
||||
assert resp.json()["count"] <= 2
|
||||
|
||||
|
||||
# ── 组合级 WF / 一条龙评估端点(v1.31)────────────────────────────────────────
|
||||
def _wait_task(client, task_id: str, rounds: int = 400):
|
||||
import time as _time
|
||||
|
||||
final = None
|
||||
for _ in range(rounds):
|
||||
poll = client.get(f"/api/v1/backtest/tasks/{task_id}")
|
||||
final = poll.json()
|
||||
if final["status"] in ("done", "failed"):
|
||||
break
|
||||
_time.sleep(0.05)
|
||||
return final
|
||||
|
||||
|
||||
def test_portfolio_backtest_includes_grade_score_trades(client, monkeypatch):
|
||||
"""组合回测响应附带 grade/score(对齐单标的)与组合层 trades。"""
|
||||
import pandas as pd
|
||||
|
||||
import easy_tdx.web.routers.backtest as bt_router
|
||||
from easy_tdx.backtest.portfolio_engine import StockData
|
||||
|
||||
async def fake_fetch(client_arg, stocks, category, start, end): # noqa: ANN001
|
||||
result = []
|
||||
for sym in stocks:
|
||||
mkt, code = sym.split(":")
|
||||
n = 100
|
||||
close = 10 + np.cumsum(np.random.randn(n) * 0.3 + 0.05)
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"datetime": pd.date_range("2024-01-01", periods=n, freq="B"),
|
||||
"open": close - 0.1,
|
||||
"high": close + 0.2,
|
||||
"low": close - 0.2,
|
||||
"close": close,
|
||||
"vol": np.full(n, 5000.0),
|
||||
"amount": close * 5000,
|
||||
}
|
||||
)
|
||||
result.append(StockData(code=code, market=mkt, df=df))
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(bt_router, "_fetch_portfolio_bars", fake_fetch)
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/backtest/portfolio/run/async",
|
||||
json={"strategy": "ma_cross", "cash": 200000, "stocks": ["SZ:000001", "SH:600519"]},
|
||||
)
|
||||
assert resp.status_code == 202, resp.text
|
||||
final = _wait_task(client, resp.json()["task_id"])
|
||||
assert final["status"] == "done", final
|
||||
result = final["result"]
|
||||
# 评级 + 评分(与单标的响应同构)
|
||||
assert result["grade"]["grade"] in ("S", "A", "B", "C", "D")
|
||||
assert result["grade"]["scenario"] == "portfolio"
|
||||
assert 0 <= result["score"]["total"] <= 100
|
||||
# 完整绩效指标(含 SQN/连胜连亏)+ 组合层成交
|
||||
assert "sqn" in result["total_performance"]
|
||||
assert "max_consecutive_losses" in result["total_performance"]
|
||||
assert isinstance(result["trades"], list)
|
||||
|
||||
|
||||
def test_portfolio_walkforward_endpoint(client, monkeypatch):
|
||||
"""POST /backtest/portfolio/wf/run/async 端到端(mock 行情取数)。"""
|
||||
import pandas as pd
|
||||
|
||||
import easy_tdx.web.routers.backtest as bt_router
|
||||
from easy_tdx.backtest.portfolio_engine import StockData
|
||||
|
||||
async def fake_fetch(client_arg, stocks, category, start, end): # noqa: ANN001
|
||||
result = []
|
||||
for sym in stocks:
|
||||
mkt, code = sym.split(":")
|
||||
n = 400
|
||||
close = 10 + np.cumsum(np.random.randn(n) * 0.3 + 0.02)
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"datetime": pd.date_range("2023-01-02", periods=n, freq="B"),
|
||||
"open": close - 0.1,
|
||||
"high": close + 0.2,
|
||||
"low": close - 0.2,
|
||||
"close": close,
|
||||
"vol": np.full(n, 5000.0),
|
||||
"amount": close * 5000,
|
||||
}
|
||||
)
|
||||
result.append(StockData(code=code, market=mkt, df=df))
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(bt_router, "_fetch_portfolio_bars", fake_fetch)
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/backtest/portfolio/wf/run/async?n_windows=3",
|
||||
json={"strategy": "ma_cross", "cash": 200000, "stocks": ["SZ:000001", "SH:600519"]},
|
||||
)
|
||||
assert resp.status_code == 202, resp.text
|
||||
final = _wait_task(client, resp.json()["task_id"])
|
||||
assert final["status"] == "done", final
|
||||
wf = final["result"]["walkforward"]
|
||||
assert wf["n_windows"] == 3
|
||||
assert len(wf["windows"]) == 3
|
||||
assert "consistency" in wf
|
||||
assert "sqn" in wf["windows"][0]["performance"]
|
||||
|
||||
|
||||
def test_portfolio_evaluate_endpoint(client, monkeypatch):
|
||||
"""POST /backtest/portfolio/evaluate/run/async 端到端(mock 行情取数)。"""
|
||||
import pandas as pd
|
||||
|
||||
import easy_tdx.web.routers.backtest as bt_router
|
||||
from easy_tdx.backtest.portfolio_engine import StockData
|
||||
|
||||
async def fake_fetch(client_arg, stocks, category, start, end): # noqa: ANN001
|
||||
result = []
|
||||
for sym in stocks:
|
||||
mkt, code = sym.split(":")
|
||||
n = 400
|
||||
close = 10 + np.cumsum(np.random.randn(n) * 0.3 + 0.02)
|
||||
df = pd.DataFrame(
|
||||
{
|
||||
"datetime": pd.date_range("2023-01-02", periods=n, freq="B"),
|
||||
"open": close - 0.1,
|
||||
"high": close + 0.2,
|
||||
"low": close - 0.2,
|
||||
"close": close,
|
||||
"vol": np.full(n, 5000.0),
|
||||
"amount": close * 5000,
|
||||
}
|
||||
)
|
||||
result.append(StockData(code=code, market=mkt, df=df))
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(bt_router, "_fetch_portfolio_bars", fake_fetch)
|
||||
|
||||
resp = client.post(
|
||||
"/api/v1/backtest/portfolio/evaluate/run/async",
|
||||
json={"strategy": "ma_cross", "cash": 200000, "stocks": ["SZ:000001", "SH:600519"]},
|
||||
)
|
||||
assert resp.status_code == 202, resp.text
|
||||
final = _wait_task(client, resp.json()["task_id"])
|
||||
assert final["status"] == "done", final
|
||||
report = final["result"]
|
||||
for key in ("performance", "score", "grade", "walkforward", "fitness", "benchmark", "config"):
|
||||
assert key in report
|
||||
assert report["grade"]["scenario"] == "portfolio"
|
||||
assert report["fitness"]["total_checks"] == 8
|
||||
assert report["config"]["stocks"] == ["SZ000001", "SH600519"]
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// 组合回测页 E2E:多标的 + 策略 → 开始组合回测 → 完整绩效指标 + 各标的对比
|
||||
// + 附加分析(组合 WF / 组合一条龙)+ 组合成交明细 + AI 解读弹窗。
|
||||
//
|
||||
// 行情来自 mock /bars(确定性合成 OHLCV,2600 根/标的),组合回测/WF/一条龙
|
||||
// 走真实后端引擎。
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('组合回测全流程:评级 + 净值 + 完整指标 + 对比 + 成交明细', async ({ page }) => {
|
||||
await page.goto('/portfolio?startDate=2023-01-01&endDate=2025-12-31')
|
||||
|
||||
// 默认两只标的(SZ:000001 / SH:600519),默认策略 ma_cross
|
||||
await expect(page.getByRole('button', { name: '开始组合回测' })).toBeEnabled()
|
||||
await page.getByRole('button', { name: '开始组合回测' }).click()
|
||||
|
||||
// 组合评级 + 组合整体绩效(含年化收益)
|
||||
await expect(page.getByRole('heading', { name: '组合评级' })).toBeVisible({ timeout: 60_000 })
|
||||
const perfSummary = page.locator('.report-section', { hasText: '组合整体绩效' })
|
||||
await expect(perfSummary.getByText('年化收益', { exact: true })).toBeVisible()
|
||||
|
||||
// 组合净值曲线(echarts canvas)
|
||||
await expect(page.getByRole('heading', { name: '组合净值曲线' })).toBeVisible()
|
||||
await expect(page.locator('.report-section canvas').first()).toBeVisible()
|
||||
|
||||
// 完整绩效指标(v1.31 与单标的同口径,含 SQN/最大连胜)
|
||||
const perfSection = page.locator('.report-section', { hasText: '组合绩效指标' })
|
||||
await expect(perfSection.locator('.metric-label', { hasText: 'SQN 系统质量' })).toBeVisible()
|
||||
await expect(perfSection.locator('.metric-label', { hasText: '最大连胜' })).toBeVisible()
|
||||
await expect(perfSection.locator('.metric-label', { hasText: '最大连亏' })).toBeVisible()
|
||||
|
||||
// 各标的对比 + 组合成交明细(带标的列)
|
||||
await expect(page.getByRole('heading', { name: '各标的绩效对比' })).toBeVisible()
|
||||
await expect(page.getByRole('heading', { name: /组合成交明细(\d+ 笔/ })).toBeVisible()
|
||||
const tradeSection = page.locator('.report-section', { hasText: '组合成交明细' })
|
||||
await expect(tradeSection.locator('th', { hasText: '标的' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('勾选附加分析后出现组合 WF 面板、一条龙评估与 AI 组合 Prompt', async ({ page }) => {
|
||||
await page.goto('/portfolio?startDate=2023-01-01&endDate=2025-12-31')
|
||||
|
||||
await page.getByLabel('Walk-Forward 样本外验证').check()
|
||||
await expect(page.getByLabel('一条龙评估')).toBeVisible()
|
||||
await page.getByLabel('一条龙评估').check()
|
||||
|
||||
await page.getByRole('button', { name: '开始组合回测' }).click()
|
||||
|
||||
// 组合 WF:与单标的同构面板(逐窗柱状图 + 6 项汇总)
|
||||
await expect(page.getByRole('heading', { name: 'Walk-Forward 样本外验证' })).toBeVisible({
|
||||
timeout: 60_000,
|
||||
})
|
||||
await expect(page.locator('.wf-chart canvas')).toBeVisible({ timeout: 120_000 })
|
||||
await expect(page.locator('.wf-summary .stat')).toHaveCount(6)
|
||||
|
||||
// 组合一条龙:综合评分 + 基准对比(等权买入持有组合)
|
||||
await expect(page.locator('.eval-panel')).toBeVisible({ timeout: 180_000 })
|
||||
await expect(page.locator('.eval-header').getByText('综合评分', { exact: true })).toBeVisible()
|
||||
await expect(page.locator('.eval-header').getByText('对比买入持有')).toBeVisible()
|
||||
|
||||
// AI 解读弹窗:组合版 Prompt 打包(组合配置 + 各标的表现 + WF + 一条龙)
|
||||
await page.getByRole('button', { name: '🤖 AI 解读' }).click()
|
||||
const area = page.locator('.ai-prompt-area')
|
||||
await expect(area).toBeVisible()
|
||||
await expect(area).toHaveValue(/组合回测报告(同一个策略分别跑在一篮子标的上/)
|
||||
await expect(area).toHaveValue(/# 组合回测配置/)
|
||||
await expect(area).toHaveValue(/SZ:000001、SH:600519/)
|
||||
await expect(area).toHaveValue(/# 各标的表现(按收益降序/)
|
||||
await expect(area).toHaveValue(/Walk-Forward 样本外验证/)
|
||||
await expect(area).toHaveValue(/# 一条龙评估/)
|
||||
await expect(area).toHaveValue(/# 背景与免责/)
|
||||
|
||||
await page.getByRole('button', { name: '关闭' }).click()
|
||||
await expect(area).toBeHidden()
|
||||
})
|
||||
@@ -217,3 +217,116 @@ test('可选段:WF / 一条龙评估 / 评级按需拼接', () => {
|
||||
assert.match(p, /档位:\*\*D\*\*(总分 31\.2\/100)——持有体验差或系统亏损,不建议参与/)
|
||||
assert.match(p, /一票否决:最大回撤 41\.7%/)
|
||||
})
|
||||
|
||||
// ── 组合版 Prompt(buildPortfolioAiPrompt,v1.31)────────────────────────────
|
||||
|
||||
import { buildPortfolioAiPrompt } from '../aiPrompt.ts'
|
||||
import type { PortfolioResult } from '../types.ts'
|
||||
|
||||
const PORTFOLIO_RESULT: PortfolioResult = {
|
||||
total_performance: {
|
||||
...PERF,
|
||||
total_return: 0.42,
|
||||
annual_return: 0.098,
|
||||
max_drawdown: 0.18,
|
||||
total_stocks: 2,
|
||||
total_cash: 1000000,
|
||||
},
|
||||
individual_results: {
|
||||
'SZ:000001': RESULT,
|
||||
'SH:600519': RESULT,
|
||||
},
|
||||
equity_allocation: { 'SZ:000001': 0.5, 'SH:600519': 0.5 },
|
||||
combined_equity: [
|
||||
{ datetime: '2020-01-06', cash: 1000000, position_value: 0, total: 1000000, drawdown: 0, drawdown_pct: 0 },
|
||||
{ datetime: '2022-04-26', cash: 0, position_value: 1420000, total: 1420000, drawdown: 0, drawdown_pct: 0 },
|
||||
],
|
||||
trades: [
|
||||
{ symbol: 'SZ:000001', datetime: '2020-02-03', direction: 'BUY', size: 1000, price: 4.52, commission: 5, slippage: 0, pnl: 0, rejected: false },
|
||||
{ symbol: 'SH:600519', datetime: '2020-03-10', direction: 'SELL', size: 500, price: 4.71, commission: 5, slippage: 0, pnl: 90, rejected: false },
|
||||
],
|
||||
}
|
||||
|
||||
test('组合版:组合配置/标的清单/完整指标/各标的表现/组合成交齐全', () => {
|
||||
const p = buildPortfolioAiPrompt({
|
||||
stocks: ['SZ:000001', 'SH:600519'],
|
||||
category: 'DAY',
|
||||
startDate: '2020-01-06',
|
||||
endDate: '2026-09-02',
|
||||
strategyLabel: '双均线交叉',
|
||||
params: { fast: 5, slow: 20 },
|
||||
cash: 1000000,
|
||||
commission: 0.0003,
|
||||
slippage: 0,
|
||||
execution: 'next_open',
|
||||
result: PORTFOLIO_RESULT,
|
||||
})
|
||||
|
||||
// 组合角色设定(明确「一篮子标的、资金均分」语境)
|
||||
assert.match(p, /# 角色设定/)
|
||||
assert.match(p, /组合回测报告(同一个策略分别跑在一篮子标的上/)
|
||||
// 配置段
|
||||
assert.match(p, /# 组合回测配置/)
|
||||
assert.match(p, /2 只标的上,资金均分(各拿总额的 50\.0%)/)
|
||||
assert.match(p, /SZ:000001、SH:600519/)
|
||||
assert.match(p, /组合总资金:1,000,000 元/)
|
||||
// 完整 25 项指标(含 SQN/连胜连亏)
|
||||
for (const label of ['SQN 系统质量', '最大连胜', '最大连亏', 'Ulcer 指数']) {
|
||||
assert.ok(p.includes(`- ${label}:`), `缺少指标行:${label}`)
|
||||
}
|
||||
assert.match(p, /- 总收益率:42\.00%/)
|
||||
// 净值概览 + 各标的表现(降序)
|
||||
assert.match(p, /# 净值概览/)
|
||||
assert.match(p, /# 各标的表现(按收益降序;全部)/)
|
||||
assert.match(p, /- SZ:000001:总收益 \+126\.43%,最大回撤 -41\.65%,夏普 0\.53,90 笔(胜率 \+35\.56%)/)
|
||||
// 组合成交(带标的)
|
||||
assert.match(p, /# 最近成交(组合合计的最后 8 笔)/)
|
||||
assert.match(p, /SH:600519 2020-03-10 卖出 500 股 @ 4\.71,本笔盈亏 \+90 元/)
|
||||
assert.match(p, /# 背景与免责/)
|
||||
|
||||
// 未提供可选数据时,对应段落不出现
|
||||
assert.ok(!p.includes('Walk-Forward 样本外验证'))
|
||||
assert.ok(!p.includes('一条龙评估'))
|
||||
assert.ok(!p.includes('评级(不看收益率)'))
|
||||
})
|
||||
|
||||
test('组合版:WF / 一条龙 / 评级按需拼接', () => {
|
||||
const p = buildPortfolioAiPrompt({
|
||||
stocks: ['SZ:000001', 'SH:600519'],
|
||||
category: 'DAY',
|
||||
startDate: '2020-01-06',
|
||||
endDate: '2026-09-02',
|
||||
strategyLabel: '双均线交叉',
|
||||
params: {},
|
||||
cash: 1000000,
|
||||
commission: 0.0003,
|
||||
slippage: 0,
|
||||
execution: 'next_open',
|
||||
result: PORTFOLIO_RESULT,
|
||||
wf: {
|
||||
n_windows: 5,
|
||||
warmup_ratio: 0.3,
|
||||
windows: [
|
||||
{ index: 0, start: '2021-01-01', end: '2021-12-31', bars: 240, total_return: 0.03, sharpe: 0.6, max_drawdown: -0.05, total_trades: 30, win_rate: 0.53 },
|
||||
{ index: 1, start: '2022-01-01', end: '2022-12-31', bars: 240, total_return: -0.01, sharpe: -0.2, max_drawdown: -0.09, total_trades: 26, win_rate: 0.46 },
|
||||
],
|
||||
consistency: 0.5,
|
||||
chained_return: 0.0197,
|
||||
mean_window_return: 0.01,
|
||||
median_window_return: 0.01,
|
||||
worst_window: -0.01,
|
||||
best_window: 0.03,
|
||||
mean_sharpe: 0.2,
|
||||
worst_drawdown: -0.09,
|
||||
total_trades: 56,
|
||||
},
|
||||
grade: { ...GRADE, scenario: 'portfolio' },
|
||||
gradeHint: '持有体验差或系统亏损,不建议参与',
|
||||
})
|
||||
|
||||
assert.match(p, /Walk-Forward 样本外验证(同参数跨时段稳定性)/)
|
||||
assert.match(p, /窗口数:5/)
|
||||
assert.match(p, /窗1(2021-01-01 ~ 2021-12-31):\+3\.00%,夏普 0\.60,最大回撤 -5\.00%,30 笔(胜率 \+53\.00%)/)
|
||||
assert.match(p, /# 评级(不看收益率,面向「普通人拿不拿得住」)/)
|
||||
assert.match(p, /档位:\*\*D\*\*/)
|
||||
})
|
||||
|
||||
+123
-4
@@ -11,9 +11,12 @@
|
||||
import type {
|
||||
BacktestResult,
|
||||
Category,
|
||||
EquityPoint,
|
||||
EvaluateReport,
|
||||
ExecutionMode,
|
||||
Performance,
|
||||
PortfolioResult,
|
||||
PortfolioTrade,
|
||||
Trade,
|
||||
WalkForwardResult,
|
||||
} from './types'
|
||||
@@ -45,6 +48,27 @@ export interface AiPromptInput {
|
||||
gradeHint?: string
|
||||
}
|
||||
|
||||
export interface PortfolioAiPromptInput {
|
||||
/** 完整标的代码列表(带市场前缀,如 ["SZ:000001", "SH:600519"]) */
|
||||
stocks: string[]
|
||||
category: Category
|
||||
startDate: string
|
||||
endDate: string
|
||||
strategyLabel: string
|
||||
params: Record<string, number | string | boolean>
|
||||
cash: number
|
||||
commission: number
|
||||
slippage: number
|
||||
execution: ExecutionMode
|
||||
result: PortfolioResult
|
||||
/** 附加分析(未勾选/未跑完时传 null,对应段落自动省略) */
|
||||
wf?: WalkForwardResult | null
|
||||
evaluate?: EvaluateReport | null
|
||||
grade?: GradeResult | null
|
||||
/** 评级档位的一句话含义(GRADE_META[grade].hint,由组件传入) */
|
||||
gradeHint?: string
|
||||
}
|
||||
|
||||
// ── 展示辅助(自包含,避免运行时依赖其他模块)────────────────────────────────
|
||||
|
||||
const CATEGORY_LABELS: Record<Category, string> = {
|
||||
@@ -139,7 +163,15 @@ function n(v: number | string | null | undefined): number | undefined {
|
||||
|
||||
// ── 各段落构建 ───────────────────────────────────────────────────────────────
|
||||
|
||||
function sectionRole(): string {
|
||||
function sectionRole(kind: 'single' | 'portfolio' = 'single'): string {
|
||||
const intro =
|
||||
kind === 'portfolio'
|
||||
? '下面是我跑出来的组合回测报告(同一个策略分别跑在一篮子标的上,资金均分、各标的独立回测后净值加总),帮我看看这个组合策略到底行不行。内容上要说到这六件事,顺序随意,用你自然的说话方式组织:'
|
||||
: '下面是我跑出来的回测报告,帮我看看这个策略到底行不行。内容上要说到这六件事,顺序随意,用你自然的说话方式组织:'
|
||||
const step5 =
|
||||
kind === 'portfolio'
|
||||
? '5. **给可执行的下一步**:几条我马上能做的事(改什么参数、加什么过滤、换哪些标的、先做什么测试再谈实盘),别空谈;'
|
||||
: '5. **给可执行的下一步**:几条我马上能做的事(改什么参数、加什么过滤、先做什么测试再谈实盘),别空谈;'
|
||||
return [
|
||||
'# 角色设定',
|
||||
'',
|
||||
@@ -147,13 +179,13 @@ function sectionRole(): string {
|
||||
'',
|
||||
'# 任务',
|
||||
'',
|
||||
'下面是我跑出来的回测报告,帮我看看这个策略到底行不行。内容上要说到这六件事,顺序随意,用你自然的说话方式组织:',
|
||||
intro,
|
||||
'',
|
||||
'1. **先给结论**:这策略现在处于什么状态——「可以继续往下走」「底子不错但还差几步」还是「问题不小,得大改」?一句话说清,再讲理由;',
|
||||
'2. **优点和毛病都要讲**:先说说它强在哪(哪些数字是真的好看、说明策略做对了什么),再讲你担心什么。别只挑刺,也别光报喜——我是想知道这策略能不能用,不是来听审判也不是来听表扬的。挑最有说服力的几组数字讲,不用面面俱到;',
|
||||
'3. **说说持有体验**:真拿钱跑这个策略,过程大概什么感受——多久交易一次、最惨的时候有多惨、普通人拿不拿得住;',
|
||||
'4. **判断是规律还是运气**:从分时段数据(Walk-Forward 各窗收益、训练/验证/测试三段、和死拿不动的对比)找证据。有担心就直说,但像朋友提醒那样说,别像下判决书;',
|
||||
'5. **给可执行的下一步**:几条我马上能做的事(改什么参数、加什么过滤、先做什么测试再谈实盘),别空谈;',
|
||||
step5,
|
||||
'6. **最后打个分**:给这个策略一个 0-10 的「信心分」,代表你现在有多大把握它值得继续投入。打分要和前面说的话一致(前面夸的多就别打低分,反过来也一样),再用一两句话说说为什么是这个分、到几分你会建议我拿小仓位试试。参考刻度:0-3 建议放弃,4-6 值得继续改(说清往哪改),7-8 可以小仓位试错,9 以上才谈逐步加仓。',
|
||||
'',
|
||||
'# 说话方式(很重要)',
|
||||
@@ -198,7 +230,10 @@ function sectionMetrics(perf: Performance): string {
|
||||
}
|
||||
|
||||
function sectionEquity(result: BacktestResult): string {
|
||||
const eq = result.equity_curve
|
||||
return sectionEquityPoints(result.equity_curve)
|
||||
}
|
||||
|
||||
function sectionEquityPoints(eq: EquityPoint[] | undefined): string {
|
||||
if (!eq || eq.length === 0) return ''
|
||||
let peak = eq[0]
|
||||
let trough = eq[0]
|
||||
@@ -327,9 +362,74 @@ function sectionFooter(): string {
|
||||
'以上数据来自 easy-tdx 的历史 K 线回测(已计入佣金与滑点)。历史回测存在幸存者偏差与未来不确定性,不构成投资建议,你的解读也以研究学习为目的。',
|
||||
'数据里缺失的项(显示 - 或整段没有的)直接跳过,不用专门解释局限。',
|
||||
'好了,开始吧。',
|
||||
'# 重要提醒',
|
||||
'禁止使用状语',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
// ── 组合版段落 ───────────────────────────────────────────────────────────────
|
||||
|
||||
function sectionPortfolioConfig(i: PortfolioAiPromptInput): string {
|
||||
const stockList =
|
||||
i.stocks.length <= 12
|
||||
? i.stocks.join('、')
|
||||
: `${i.stocks.slice(0, 12).join('、')} 等 ${i.stocks.length} 只`
|
||||
const lines = [
|
||||
'# 组合回测配置',
|
||||
'',
|
||||
`- 组合形式:同一个策略分别跑在 ${i.stocks.length} 只标的上,资金均分(各拿总额的 ${(
|
||||
100 / i.stocks.length
|
||||
).toFixed(1)}%),标的间独立回测、净值按日加总`,
|
||||
`- 标的列表:${stockList}`,
|
||||
`- 回测区间:${i.startDate} ~ ${i.endDate}(${CATEGORY_LABELS[i.category] ?? i.category})`,
|
||||
`- 策略:${i.strategyLabel}`,
|
||||
`- 参数:${fmtParams(i.params)}`,
|
||||
`- 组合总资金:${fmtMoney(i.cash)} 元;佣金 ${i.commission};滑点 ${i.slippage};成交价:${EXECUTION_LABELS[i.execution] ?? i.execution}`,
|
||||
'',
|
||||
]
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/** 各标的表现摘要:按收益降序,超过 12 只时只列最好 6 只 + 最差 6 只。 */
|
||||
function sectionStocksSummary(result: PortfolioResult): string {
|
||||
const entries = Object.entries(result.individual_results)
|
||||
if (entries.length === 0) return ''
|
||||
const sorted = entries
|
||||
.map(([symbol, r]) => ({ symbol, perf: r.performance }))
|
||||
.sort((a, b) => (b.perf.total_return ?? 0) - (a.perf.total_return ?? 0))
|
||||
const shown =
|
||||
sorted.length <= 12
|
||||
? sorted
|
||||
: [...sorted.slice(0, 6), ...sorted.slice(sorted.length - 6)]
|
||||
const lines = [
|
||||
'# 各标的表现(按收益降序;' +
|
||||
(sorted.length <= 12 ? '全部' : `省略中间 ${sorted.length - 12} 只,其余为最好/最差各 6 只`) +
|
||||
')',
|
||||
'',
|
||||
]
|
||||
for (const { symbol, perf } of shown) {
|
||||
lines.push(
|
||||
`- ${symbol}:总收益 ${pct(perf.total_return)},最大回撤 ${pct(perf.max_drawdown)},夏普 ${ratio(perf.sharpe)},${Math.round(perf.total_trades ?? 0)} 笔(胜率 ${pct(perf.win_rate)})`,
|
||||
)
|
||||
}
|
||||
lines.push('')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function sectionPortfolioTrades(trades: PortfolioTrade[] | undefined): string {
|
||||
if (!trades || trades.length === 0) return ''
|
||||
const recent = trades.slice(-8)
|
||||
const lines = ['# 最近成交(组合合计的最后 8 笔)', '']
|
||||
for (const t of recent) {
|
||||
const dir = t.direction === 'BUY' ? '买入' : '卖出'
|
||||
const pnl =
|
||||
t.direction === 'SELL' && t.pnl !== 0 ? `,本笔盈亏 ${t.pnl >= 0 ? '+' : ''}${fmtMoney(t.pnl)} 元` : ''
|
||||
lines.push(`- ${t.symbol} ${fmtDate(t.datetime)} ${dir} ${Math.round(t.size)} 股 @ ${t.price.toFixed(2)}${pnl}`)
|
||||
}
|
||||
lines.push('')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// ── 主函数 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 组装 AI 解读 Prompt(markdown 结构,任意 LLM 可直接消费)。 */
|
||||
@@ -348,3 +448,22 @@ export function buildAiPrompt(input: AiPromptInput): string {
|
||||
parts.push(sectionFooter())
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
/** 组装组合回测的 AI 解读 Prompt(与单标的同构,段落随附加分析增减)。 */
|
||||
export function buildPortfolioAiPrompt(input: PortfolioAiPromptInput): string {
|
||||
const parts: string[] = [
|
||||
sectionRole('portfolio'),
|
||||
sectionPortfolioConfig(input),
|
||||
sectionMetrics(input.result.total_performance),
|
||||
sectionEquityPoints(input.result.combined_equity),
|
||||
]
|
||||
const stocksSummary = sectionStocksSummary(input.result)
|
||||
if (stocksSummary) parts.push(stocksSummary)
|
||||
if (input.wf) parts.push(sectionWf(input.wf))
|
||||
if (input.evaluate) parts.push(sectionEvaluate(input.evaluate))
|
||||
if (input.grade) parts.push(sectionGrade(input.grade, input.gradeHint))
|
||||
const trades = sectionPortfolioTrades(input.result.trades)
|
||||
if (trades) parts.push(trades)
|
||||
parts.push(sectionFooter())
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
@@ -201,6 +201,33 @@ export async function submitPortfolioTask(
|
||||
return (await resp.json()) as TaskSubmitResponse
|
||||
}
|
||||
|
||||
/** 提交组合级 Walk-Forward 样本外验证后台任务(n_windows 默认 7)。 */
|
||||
export async function submitPortfolioWalkforwardTask(
|
||||
req: PortfolioBacktestRequest,
|
||||
nWindows = 7,
|
||||
): Promise<TaskSubmitResponse> {
|
||||
const resp = await fetch(`${BASE}/backtest/portfolio/wf/run/async?n_windows=${nWindows}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(req),
|
||||
})
|
||||
if (!resp.ok) await throwError(resp)
|
||||
return (await resp.json()) as TaskSubmitResponse
|
||||
}
|
||||
|
||||
/** 提交组合级一条龙评估后台任务(组合回测+WF+适配性+评分+基准对比)。 */
|
||||
export async function submitPortfolioEvaluateTask(
|
||||
req: PortfolioBacktestRequest,
|
||||
): Promise<TaskSubmitResponse> {
|
||||
const resp = await fetch(`${BASE}/backtest/portfolio/evaluate/run/async`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(req),
|
||||
})
|
||||
if (!resp.ok) await throwError(resp)
|
||||
return (await resp.json()) as TaskSubmitResponse
|
||||
}
|
||||
|
||||
/** 提交多策略组合回测后台任务(资金分仓),返回 task_id。 */
|
||||
export async function submitMultiStrategyTask(
|
||||
req: MultiStrategyBacktestRequest,
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
<script setup lang="ts">
|
||||
// AI 解读弹窗(单标的/组合回测通用):Prompt 预览 + 复制/下载 + 一键直接解读。
|
||||
// Prompt 由父组件实时组装传入(附加分析跑完内容自动变全),本组件只管交互;
|
||||
// 直接解读走后端 LLM 后台任务(配置见「AI 设置」页),解读记录旁路落历史库。
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { formatError, fetchLlmConfig, runLlmChatWithPolling } from '../api'
|
||||
import type { LlmChatContext, LlmChatResult } from '../types'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 已组装好的 Prompt 全文(computed 传入,实时更新) */
|
||||
prompt: string
|
||||
/** 下载文件名(如 AI解读_SZ000001_ma_cross.md) */
|
||||
filename: string
|
||||
/** 直接解读时随 Prompt 落历史库的策略上下文(历史页「去回测」引导用) */
|
||||
context?: LlmChatContext
|
||||
/** 弹窗描述里的附加提示(如「建议等附加分析跑完再发」) */
|
||||
tip?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const aiMsg = ref('')
|
||||
// 直接解读(服务端 LLM 已配置时可用,配置见「AI 设置」页)
|
||||
const llmReady = ref(false)
|
||||
const llmLabel = ref('')
|
||||
const aiRunning = ref(false)
|
||||
const aiElapsed = ref(0)
|
||||
const aiReply = ref('')
|
||||
let aiTimer = 0
|
||||
|
||||
onMounted(() => {
|
||||
// 打开时探测 LLM 是否已配置(失败静默——导出 Prompt 的老路径不依赖后端)
|
||||
fetchLlmConfig()
|
||||
.then((resp) => {
|
||||
llmReady.value = resp.configured
|
||||
const p = resp.providers.find((x) => x.id === resp.config.provider)
|
||||
llmLabel.value = p ? `${p.label} · ${resp.resolved.model}` : resp.resolved.model
|
||||
})
|
||||
.catch(() => {
|
||||
llmReady.value = false
|
||||
})
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.prompt,
|
||||
() => {
|
||||
// 配置更新后重置旧的失败/成功消息之外的回复?保持回复不动,仅清错误提示
|
||||
if (aiMsg.value.startsWith('解读失败')) aiMsg.value = ''
|
||||
},
|
||||
)
|
||||
|
||||
/** 直接解读:把组装好的 Prompt 提交为后台任务并轮询(不占 HTTP 连接)。 */
|
||||
async function runAiInterpret() {
|
||||
if (!props.prompt || aiRunning.value) return
|
||||
aiRunning.value = true
|
||||
aiMsg.value = ''
|
||||
aiReply.value = ''
|
||||
// 后台任务模式:模型生成 1-3 分钟正常——显示已耗时防误判卡死
|
||||
aiElapsed.value = 0
|
||||
aiTimer = window.setInterval(() => {
|
||||
aiElapsed.value += 1
|
||||
}, 1000)
|
||||
try {
|
||||
const state = await runLlmChatWithPolling(props.prompt, props.context)
|
||||
// TaskState.result 是多任务类型联合,按 LLM 任务结构收窄
|
||||
const r = state.result as LlmChatResult | null
|
||||
// 后端已保证非空正文(空白正文会以 failed 上浮),前端再拦一道纯空白
|
||||
if (state.status === 'done' && r?.reply?.trim()) {
|
||||
aiReply.value = r.reply
|
||||
aiMsg.value = `✓ ${r.provider} · ${r.model} 已解读(${aiElapsed.value}s)`
|
||||
} else if (state.status === 'done') {
|
||||
aiMsg.value = '解读失败:模型返回了空正文(可能被 Max Tokens 截断),可在「AI 设置」调大后重试'
|
||||
} else {
|
||||
aiMsg.value = `解读失败:${state.error ?? '未知错误'}(可在「AI 设置」检查配置,或复制 Prompt 手动使用)`
|
||||
}
|
||||
} catch (e) {
|
||||
aiMsg.value = `解读失败:${formatError(e)}(可在「AI 设置」检查配置,或复制 Prompt 手动使用)`
|
||||
} finally {
|
||||
window.clearInterval(aiTimer)
|
||||
aiRunning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAiPrompt() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.prompt)
|
||||
aiMsg.value = '✓ 已复制,粘贴给任意 AI 助手即可'
|
||||
} catch {
|
||||
// 剪贴板 API 不可用时退回选中文本,让用户手动 Ctrl+C
|
||||
const el = document.querySelector<HTMLTextAreaElement>('.ai-prompt-area')
|
||||
el?.focus()
|
||||
el?.select()
|
||||
aiMsg.value = document.execCommand('copy') ? '✓ 已复制' : '已全选文本,请按 Ctrl+C 复制'
|
||||
}
|
||||
}
|
||||
|
||||
function downloadAiPrompt() {
|
||||
const blob = new Blob([props.prompt], { type: 'text/markdown;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = props.filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
aiMsg.value = '✓ 已下载 .md 文件'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal-overlay" @click.self="emit('close')">
|
||||
<div class="modal modal-wide">
|
||||
<h3>🤖 AI 解读</h3>
|
||||
<p class="modal-desc">
|
||||
已把当前回测报告组装成提示词。
|
||||
<template v-if="llmReady">
|
||||
点击「直接解读」发送给已配置的模型({{ llmLabel }}),
|
||||
</template>
|
||||
<template v-else>
|
||||
在「AI 设置」页配置模型后可一键直接解读;也可
|
||||
</template>
|
||||
复制后发给任意 AI 助手(ChatGPT / Claude / DeepSeek / 豆包…)。
|
||||
<template v-if="tip"> {{ tip }}</template>
|
||||
</p>
|
||||
<textarea
|
||||
:value="prompt"
|
||||
class="ai-prompt-area"
|
||||
:class="{ collapsed: !!aiReply }"
|
||||
readonly
|
||||
:rows="aiReply ? 6 : 16"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<div v-if="aiReply" class="ai-reply">{{ aiReply }}</div>
|
||||
<div v-if="aiReply" class="ai-note">
|
||||
以上解读由 AI 模型生成,可能存在错误或过时信息,仅供参考,不构成投资建议。
|
||||
</div>
|
||||
<span v-if="aiMsg" class="ai-msg">{{ aiMsg }}</span>
|
||||
<div class="modal-actions">
|
||||
<button class="ghost" @click="emit('close')">关闭</button>
|
||||
<button class="ghost" @click="downloadAiPrompt">⬇ 下载 .md</button>
|
||||
<button class="ghost" @click="copyAiPrompt">复制 Prompt</button>
|
||||
<button
|
||||
v-if="llmReady"
|
||||
class="primary"
|
||||
:disabled="aiRunning || !prompt"
|
||||
@click="runAiInterpret"
|
||||
>
|
||||
{{ aiRunning ? `解读中… ${aiElapsed}s` : '✨ 直接解读' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.modal {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
width: 380px;
|
||||
max-width: 90vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.modal h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.modal-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.modal-actions .ghost {
|
||||
font-size: 13px;
|
||||
padding: 7px 16px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal-actions .primary {
|
||||
font-size: 13px;
|
||||
padding: 7px 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.modal-actions .primary:disabled,
|
||||
.modal-actions .ghost:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* AI 解读 Prompt 对话框(比保存对话框更宽,内容等宽小字可滚动) */
|
||||
.modal-wide {
|
||||
width: 640px;
|
||||
}
|
||||
.ai-prompt-area {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
max-height: 55vh;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 12px;
|
||||
color: var(--text-muted);
|
||||
resize: vertical;
|
||||
}
|
||||
/* 直接解读出结果后 Prompt 区收窄,把版面让给回复 */
|
||||
.ai-prompt-area.collapsed {
|
||||
max-height: 18vh;
|
||||
}
|
||||
.ai-reply {
|
||||
margin-top: 8px;
|
||||
max-height: 38vh;
|
||||
overflow: auto;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.ai-msg {
|
||||
font-size: 12px;
|
||||
color: var(--up);
|
||||
}
|
||||
.ai-note {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--warn, #ffc107);
|
||||
}
|
||||
</style>
|
||||
@@ -10,9 +10,13 @@ import HelpCollapse from './HelpCollapse.vue'
|
||||
import { gradePerformance } from '../grading'
|
||||
import { evaluateGlossary } from '../data/glossary'
|
||||
import type { EvaluateReport } from '../types'
|
||||
import type { GradeResult } from '../grading/types'
|
||||
|
||||
const props = defineProps<{
|
||||
report: EvaluateReport
|
||||
/** 评级覆盖:组合级报告传入组合口径评级(gradePortfolio / 后端
|
||||
* grade_portfolio_equity),缺省时按单标的 6 维度本地重算。 */
|
||||
gradeOverride?: GradeResult | null
|
||||
}>()
|
||||
|
||||
/** 综合评分分项(含权重,展示顺序固定) */
|
||||
@@ -32,7 +36,7 @@ const scoreComponents = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
const grade = computed(() => gradePerformance(props.report.performance))
|
||||
const grade = computed(() => props.gradeOverride ?? gradePerformance(props.report.performance))
|
||||
|
||||
const excess = computed(() => props.report.benchmark.excess_return)
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
// 成交记录表。展示每笔成交的方向/数量/价格/费用/盈亏。
|
||||
// showSymbol 时多一列来源标的(组合页展示各标的汇总成交用)。
|
||||
|
||||
import type { Trade } from '../types'
|
||||
|
||||
defineProps<{
|
||||
trades: Trade[]
|
||||
/** 行类型兼容组合交易明细(PortfolioTrade = Trade & { symbol }) */
|
||||
trades: (Trade & { symbol?: string })[]
|
||||
showSymbol?: boolean
|
||||
}>()
|
||||
|
||||
function fmtDate(s: string): string {
|
||||
@@ -21,6 +24,7 @@ function fmtNum(v: number, digits = 2): string {
|
||||
<table v-else class="trade-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-if="showSymbol">标的</th>
|
||||
<th>日期</th>
|
||||
<th>方向</th>
|
||||
<th class="num">数量</th>
|
||||
@@ -31,6 +35,7 @@ function fmtNum(v: number, digits = 2): string {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(t, i) in trades" :key="i" :class="{ rejected: t.rejected }">
|
||||
<td v-if="showSymbol" class="muted">{{ t.symbol }}</td>
|
||||
<td>{{ fmtDate(t.datetime) }}</td>
|
||||
<td :class="t.direction">{{ t.direction }}</td>
|
||||
<td class="num">{{ fmtNum(t.size, 0) }}</td>
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
formatError,
|
||||
runBacktest,
|
||||
submitPortfolioTask,
|
||||
submitPortfolioEvaluateTask,
|
||||
submitPortfolioWalkforwardTask,
|
||||
submitOptimizeAllTask,
|
||||
submitOptimizeTask,
|
||||
submitMultiStrategyTask,
|
||||
@@ -187,6 +189,62 @@ export const useBacktestStore = defineStore('backtest', () => {
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
// ── 组合附加分析:组合级 Walk-Forward / 一条龙评估 ────────────────────────
|
||||
const portfolioWfResult = ref<WalkForwardResult | null>(null)
|
||||
const portfolioWfRunning = ref(false)
|
||||
const portfolioWfError = ref<string>('')
|
||||
const portfolioEvaluateResult = ref<EvaluateReport | null>(null)
|
||||
const portfolioEvaluateRunning = ref(false)
|
||||
const portfolioEvaluateError = ref<string>('')
|
||||
|
||||
/** 提交组合级 WF 样本外验证后台任务并轮询(N 标的 × N 窗,比单标的慢)。 */
|
||||
async function runPortfolioWalkforward(req: PortfolioBacktestRequest, nWindows = 7) {
|
||||
portfolioWfRunning.value = true
|
||||
portfolioWfError.value = ''
|
||||
portfolioWfResult.value = null
|
||||
try {
|
||||
const { task_id } = await submitPortfolioWalkforwardTask(req, nWindows)
|
||||
const body = await pollTask<{ walkforward: WalkForwardResult }>(
|
||||
task_id,
|
||||
300_000,
|
||||
'组合 WF 验证',
|
||||
)
|
||||
portfolioWfResult.value = body.walkforward
|
||||
} catch (e) {
|
||||
portfolioWfError.value = formatError(e)
|
||||
portfolioWfResult.value = null
|
||||
} finally {
|
||||
portfolioWfRunning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交组合级一条龙评估后台任务并轮询(组合回测+WF+适配性+评分+基准对比)。 */
|
||||
async function runPortfolioEvaluate(req: PortfolioBacktestRequest) {
|
||||
portfolioEvaluateRunning.value = true
|
||||
portfolioEvaluateError.value = ''
|
||||
portfolioEvaluateResult.value = null
|
||||
try {
|
||||
const { task_id } = await submitPortfolioEvaluateTask(req)
|
||||
portfolioEvaluateResult.value = await pollTask<EvaluateReport>(
|
||||
task_id,
|
||||
600_000,
|
||||
'组合一条龙评估',
|
||||
)
|
||||
} catch (e) {
|
||||
portfolioEvaluateError.value = formatError(e)
|
||||
portfolioEvaluateResult.value = null
|
||||
} finally {
|
||||
portfolioEvaluateRunning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearPortfolioExtraAnalysis() {
|
||||
portfolioWfResult.value = null
|
||||
portfolioWfError.value = ''
|
||||
portfolioEvaluateResult.value = null
|
||||
portfolioEvaluateError.value = ''
|
||||
}
|
||||
|
||||
// ── 多策略组合回测(资金分仓) ─────────────────────────────────────────
|
||||
const multiStrategyResult = ref<PortfolioResult | null>(null)
|
||||
const multiStrategyRunning = ref(false)
|
||||
@@ -319,6 +377,12 @@ export const useBacktestStore = defineStore('backtest', () => {
|
||||
error,
|
||||
portfolioResult,
|
||||
portfolioRunning,
|
||||
portfolioWfResult,
|
||||
portfolioWfRunning,
|
||||
portfolioWfError,
|
||||
portfolioEvaluateResult,
|
||||
portfolioEvaluateRunning,
|
||||
portfolioEvaluateError,
|
||||
multiStrategyResult,
|
||||
multiStrategyRunning,
|
||||
optimizeResult,
|
||||
@@ -344,6 +408,9 @@ export const useBacktestStore = defineStore('backtest', () => {
|
||||
clearExtraAnalysis,
|
||||
runPortfolio,
|
||||
clearPortfolio,
|
||||
runPortfolioWalkforward,
|
||||
runPortfolioEvaluate,
|
||||
clearPortfolioExtraAnalysis,
|
||||
runMultiStrategy,
|
||||
clearMultiStrategy,
|
||||
runOptimize,
|
||||
|
||||
+19
-6
@@ -2,6 +2,8 @@
|
||||
// 与 src/easy_tdx/web/backtest_schemas.py 及 backtest router 的响应保持一致。
|
||||
// 后端是唯一事实源;这里只做类型契约。
|
||||
|
||||
import type { GradeResult } from './grading/types'
|
||||
|
||||
// ── 策略 schema(GET /api/v1/backtest/strategies) ───────────────────────────
|
||||
|
||||
export type ParamType = 'int' | 'float' | 'bool' | 'str'
|
||||
@@ -197,16 +199,27 @@ export interface PortfolioBacktestRequest {
|
||||
end_date?: string
|
||||
}
|
||||
|
||||
/** 组合整体绩效:与单标的同口径的完整指标(PerformanceAnalyzer 算出,
|
||||
* 含 SQN/最大连胜连亏等)+ 组合专属的标的数与总资金。 */
|
||||
export type PortfolioTotalPerformance = Performance & {
|
||||
total_stocks: number
|
||||
total_cash: number
|
||||
}
|
||||
|
||||
/** 组合交易明细行:单标的 Trade 附来源标的(组合层汇总成交表)。 */
|
||||
export type PortfolioTrade = Trade & { symbol: string }
|
||||
|
||||
export interface PortfolioResult {
|
||||
total_performance: {
|
||||
total_return: number
|
||||
annual_return: number
|
||||
total_stocks: number
|
||||
total_cash: number
|
||||
}
|
||||
total_performance: PortfolioTotalPerformance
|
||||
individual_results: Record<string, BacktestResult>
|
||||
equity_allocation: Record<string, number>
|
||||
combined_equity: EquityPoint[]
|
||||
/** 组合层汇总成交(各标的 concat + symbol 列;v1.31 起返回,老结果缺省) */
|
||||
trades?: PortfolioTrade[]
|
||||
/** 后端组合评级(净值曲线 5 维度口径,v1.31 起返回,老结果缺省) */
|
||||
grade?: GradeResult
|
||||
/** 后端综合评分(v1.31 起返回,老结果缺省) */
|
||||
score?: StrategyScoreReport
|
||||
}
|
||||
|
||||
// ── 参数网格寻优(Phase 4) ──────────────────────────────────────────────────
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import AiInterpretModal from '../components/AiInterpretModal.vue'
|
||||
import EquityChart from '../components/EquityChart.vue'
|
||||
import EvaluatePanel from '../components/EvaluatePanel.vue'
|
||||
import GradeDetails from '../components/GradeDetails.vue'
|
||||
@@ -15,11 +16,11 @@ import StrategyPicker from '../components/StrategyPicker.vue'
|
||||
import SymbolPicker from '../components/SymbolPicker.vue'
|
||||
import TradeTable from '../components/TradeTable.vue'
|
||||
import WalkForwardPanel from '../components/WalkForwardPanel.vue'
|
||||
import { formatError, saveStrategy, fetchLlmConfig, runLlmChatWithPolling } from '../api'
|
||||
import { formatError, saveStrategy } from '../api'
|
||||
import { detectMarket } from '../market'
|
||||
import { GRADE_META, gradePerformance } from '../grading'
|
||||
import { buildAiPrompt } from '../aiPrompt'
|
||||
import type { Category, ExecutionMode, LlmChatResult } from '../types'
|
||||
import type { Category, ExecutionMode } from '../types'
|
||||
import { useBacktestStore } from '../stores/backtest'
|
||||
|
||||
const store = useBacktestStore()
|
||||
@@ -202,15 +203,9 @@ async function onSave() {
|
||||
}
|
||||
|
||||
// ── AI 解读 Prompt(把当前报告组装成提示词,发给任意 LLM 解读)──────────────
|
||||
// 弹窗交互(复制/下载/直接解读)抽在 AiInterpretModal 通用组件里,
|
||||
// 与组合回测页共用;这里只负责实时组装 Prompt 与策略上下文。
|
||||
const showAiModal = ref(false)
|
||||
const aiMsg = ref('')
|
||||
// 直接解读(服务端 LLM 已配置时可用,配置见「AI 设置」页)
|
||||
const llmReady = ref(false)
|
||||
const llmLabel = ref('')
|
||||
const aiRunning = ref(false)
|
||||
const aiElapsed = ref(0)
|
||||
const aiReply = ref('')
|
||||
let aiTimer = 0
|
||||
|
||||
/** 实时组装:附加分析(WF/评估)跑完后内容自动变全 */
|
||||
const aiPromptText = computed(() => {
|
||||
@@ -235,87 +230,22 @@ const aiPromptText = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
function openAiModal() {
|
||||
aiMsg.value = ''
|
||||
aiReply.value = ''
|
||||
showAiModal.value = true
|
||||
// 打开时探测 LLM 是否已配置(失败静默——导出 Prompt 的老路径不依赖后端)
|
||||
fetchLlmConfig()
|
||||
.then((resp) => {
|
||||
llmReady.value = resp.configured
|
||||
const p = resp.providers.find((x) => x.id === resp.config.provider)
|
||||
llmLabel.value = p ? `${p.label} · ${resp.resolved.model}` : resp.resolved.model
|
||||
})
|
||||
.catch(() => {
|
||||
llmReady.value = false
|
||||
})
|
||||
}
|
||||
/** 随解读落历史库的策略上下文(AI 解读历史页「去回测」引导用) */
|
||||
const aiContext = computed(() => ({
|
||||
strategy: strategy.value,
|
||||
strategy_label: strategyLabel.value,
|
||||
symbol: code.value,
|
||||
category: category.value,
|
||||
params: { ...params.value },
|
||||
start_date: startDate.value,
|
||||
end_date: endDate.value,
|
||||
}))
|
||||
|
||||
/** 直接解读:把组装好的 Prompt 提交为后台任务并轮询(不占 HTTP 连接)。 */
|
||||
async function runAiInterpret() {
|
||||
if (!aiPromptText.value || aiRunning.value) return
|
||||
aiRunning.value = true
|
||||
aiMsg.value = ''
|
||||
aiReply.value = ''
|
||||
// 后台任务模式:模型生成 1-3 分钟正常——显示已耗时防误判卡死
|
||||
aiElapsed.value = 0
|
||||
aiTimer = window.setInterval(() => {
|
||||
aiElapsed.value += 1
|
||||
}, 1000)
|
||||
try {
|
||||
// 策略上下文随解读落历史库(AI 解读历史页「去回测」引导用)
|
||||
const ctx = {
|
||||
strategy: strategy.value,
|
||||
strategy_label: strategyLabel.value,
|
||||
symbol: code.value,
|
||||
category: category.value,
|
||||
params: { ...params.value },
|
||||
start_date: startDate.value,
|
||||
end_date: endDate.value,
|
||||
}
|
||||
const state = await runLlmChatWithPolling(aiPromptText.value, ctx)
|
||||
// TaskState.result 是多任务类型联合,按 LLM 任务结构收窄
|
||||
const r = state.result as LlmChatResult | null
|
||||
// 后端已保证非空正文(空白正文会以 failed 上浮),前端再拦一道纯空白
|
||||
if (state.status === 'done' && r?.reply?.trim()) {
|
||||
aiReply.value = r.reply
|
||||
aiMsg.value = `✓ ${r.provider} · ${r.model} 已解读(${aiElapsed.value}s)`
|
||||
} else if (state.status === 'done') {
|
||||
aiMsg.value = '解读失败:模型返回了空正文(可能被 Max Tokens 截断),可在「AI 设置」调大后重试'
|
||||
} else {
|
||||
aiMsg.value = `解读失败:${state.error ?? '未知错误'}(可在「AI 设置」检查配置,或复制 Prompt 手动使用)`
|
||||
}
|
||||
} catch (e) {
|
||||
aiMsg.value = `解读失败:${formatError(e)}(可在「AI 设置」检查配置,或复制 Prompt 手动使用)`
|
||||
} finally {
|
||||
window.clearInterval(aiTimer)
|
||||
aiRunning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyAiPrompt() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(aiPromptText.value)
|
||||
aiMsg.value = '✓ 已复制,粘贴给任意 AI 助手即可'
|
||||
} catch {
|
||||
// 剪贴板 API 不可用时退回选中文本,让用户手动 Ctrl+C
|
||||
const el = document.querySelector<HTMLTextAreaElement>('.ai-prompt-area')
|
||||
el?.focus()
|
||||
el?.select()
|
||||
aiMsg.value = document.execCommand('copy') ? '✓ 已复制' : '已全选文本,请按 Ctrl+C 复制'
|
||||
}
|
||||
}
|
||||
|
||||
function downloadAiPrompt() {
|
||||
const blob = new Blob([aiPromptText.value], { type: 'text/markdown;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `AI解读_${code.value}_${strategy.value}.md`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
aiMsg.value = '✓ 已下载 .md 文件'
|
||||
}
|
||||
const aiTip = computed(() =>
|
||||
wfEnabled.value || evaluateEnabled.value
|
||||
? '建议等附加分析跑完再发,Walk-Forward / 一条龙评估的数据会一并打包。'
|
||||
: undefined,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -415,7 +345,7 @@ function downloadAiPrompt() {
|
||||
<div v-if="store.result" class="report-content">
|
||||
<div class="result-toolbar">
|
||||
<button class="ghost" @click="openSaveForm">💾 保存策略</button>
|
||||
<button class="ghost" @click="openAiModal">🤖 AI 解读</button>
|
||||
<button class="ghost" @click="showAiModal = true">🤖 AI 解读</button>
|
||||
<span v-if="saveMsg" class="save-msg">{{ saveMsg }}</span>
|
||||
</div>
|
||||
|
||||
@@ -502,51 +432,15 @@ function downloadAiPrompt() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 解读 Prompt 对话框 -->
|
||||
<div v-if="showAiModal" class="modal-overlay" @click.self="showAiModal = false">
|
||||
<div class="modal modal-wide">
|
||||
<h3>🤖 AI 解读</h3>
|
||||
<p class="modal-desc">
|
||||
已把当前回测报告组装成提示词。
|
||||
<template v-if="llmReady">
|
||||
点击「直接解读」发送给已配置的模型({{ llmLabel }}),
|
||||
</template>
|
||||
<template v-else>
|
||||
在「AI 设置」页配置模型后可一键直接解读;也可
|
||||
</template>
|
||||
复制后发给任意 AI 助手(ChatGPT / Claude / DeepSeek / 豆包…)。
|
||||
<template v-if="wfEnabled || evaluateEnabled">
|
||||
建议等附加分析跑完再发,Walk-Forward / 一条龙评估的数据会一并打包。
|
||||
</template>
|
||||
</p>
|
||||
<textarea
|
||||
:value="aiPromptText"
|
||||
class="ai-prompt-area"
|
||||
:class="{ collapsed: !!aiReply }"
|
||||
readonly
|
||||
:rows="aiReply ? 6 : 16"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<div v-if="aiReply" class="ai-reply">{{ aiReply }}</div>
|
||||
<div v-if="aiReply" class="ai-note">
|
||||
以上解读由 AI 模型生成,可能存在错误或过时信息,仅供参考,不构成投资建议。
|
||||
</div>
|
||||
<span v-if="aiMsg" class="ai-msg">{{ aiMsg }}</span>
|
||||
<div class="modal-actions">
|
||||
<button class="ghost" @click="showAiModal = false">关闭</button>
|
||||
<button class="ghost" @click="downloadAiPrompt">⬇ 下载 .md</button>
|
||||
<button class="ghost" @click="copyAiPrompt">复制 Prompt</button>
|
||||
<button
|
||||
v-if="llmReady"
|
||||
class="primary"
|
||||
:disabled="aiRunning || !aiPromptText"
|
||||
@click="runAiInterpret"
|
||||
>
|
||||
{{ aiRunning ? `解读中… ${aiElapsed}s` : '✨ 直接解读' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- AI 解读 Prompt 对话框(单标的/组合通用组件) -->
|
||||
<AiInterpretModal
|
||||
v-if="showAiModal && store.result"
|
||||
:prompt="aiPromptText"
|
||||
:filename="`AI解读_${code}_${strategy}.md`"
|
||||
:context="aiContext"
|
||||
:tip="aiTip"
|
||||
@close="showAiModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -784,50 +678,4 @@ function downloadAiPrompt() {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* AI 解读 Prompt 对话框(比保存对话框更宽,内容等宽小字可滚动) */
|
||||
.modal-wide {
|
||||
width: 640px;
|
||||
}
|
||||
.ai-prompt-area {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.6;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
max-height: 55vh;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 12px;
|
||||
color: var(--text-muted);
|
||||
resize: vertical;
|
||||
}
|
||||
/* 直接解读出结果后 Prompt 区收窄,把版面让给回复 */
|
||||
.ai-prompt-area.collapsed {
|
||||
max-height: 18vh;
|
||||
}
|
||||
.ai-reply {
|
||||
margin-top: 8px;
|
||||
max-height: 38vh;
|
||||
overflow: auto;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.ai-msg {
|
||||
font-size: 12px;
|
||||
color: var(--up);
|
||||
}
|
||||
.ai-note {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--warn, #ffc107);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
// 组合回测主页面:左配置(多标的 + 策略 + 日期)/ 右报告(组合净值 + 各标的对比)。
|
||||
// 组合回测主页面:左配置(多标的 + 策略 + 日期 + 附加分析)/ 右报告
|
||||
// (组合净值 + 完整绩效指标 + 各标的对比 + 附加分析 WF/一条龙 + 成交明细 + AI 解读)。
|
||||
// 附加分析与单标的回测页(BacktestView)同构:勾选后随「开始组合回测」并行运行。
|
||||
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import AiInterpretModal from '../components/AiInterpretModal.vue'
|
||||
import EquityChart from '../components/EquityChart.vue'
|
||||
import EvaluatePanel from '../components/EvaluatePanel.vue'
|
||||
import GradeDetails from '../components/GradeDetails.vue'
|
||||
import MetricTable from '../components/MetricTable.vue'
|
||||
import PortfolioCompareChart from '../components/PortfolioCompareChart.vue'
|
||||
import PortfolioSummaryTable from '../components/PortfolioSummaryTable.vue'
|
||||
import StocksPicker from '../components/StocksPicker.vue'
|
||||
import StrategyPicker from '../components/StrategyPicker.vue'
|
||||
import TradeTable from '../components/TradeTable.vue'
|
||||
import WalkForwardPanel from '../components/WalkForwardPanel.vue'
|
||||
import { formatError, saveStrategy } from '../api'
|
||||
import { gradePortfolio } from '../grading'
|
||||
import type { Category, ExecutionMode } from '../types'
|
||||
import { buildPortfolioAiPrompt } from '../aiPrompt'
|
||||
import { GRADE_META, gradePortfolio } from '../grading'
|
||||
import type { Category, ExecutionMode, PortfolioTrade } from '../types'
|
||||
import { useBacktestStore } from '../stores/backtest'
|
||||
|
||||
const store = useBacktestStore()
|
||||
@@ -41,6 +49,11 @@ function isoDaysFromNow(days: number): string {
|
||||
const startDate = ref('2020-01-06')
|
||||
const endDate = ref(isoDaysFromNow(0))
|
||||
|
||||
// 附加分析开关(与单标的回测页同构):组合级 WF / 一条龙评估
|
||||
const wfEnabled = ref(false)
|
||||
const wfWindows = ref(7)
|
||||
const evaluateEnabled = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
store.loadStrategies().catch((e) => {
|
||||
store.error = `加载策略列表失败:${e instanceof Error ? e.message : e}`
|
||||
@@ -75,8 +88,8 @@ onMounted(async () => {
|
||||
if (qCategory) category.value = qCategory
|
||||
})
|
||||
|
||||
async function onRun() {
|
||||
await store.runPortfolio({
|
||||
function currentRequest() {
|
||||
return {
|
||||
strategy: strategy.value,
|
||||
params: params.value,
|
||||
cash: cash.value,
|
||||
@@ -85,7 +98,20 @@ async function onRun() {
|
||||
category: category.value,
|
||||
start_date: startDate.value,
|
||||
end_date: endDate.value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function onRun() {
|
||||
store.error = ''
|
||||
store.clearPortfolioExtraAnalysis()
|
||||
// 1. 主组合回测(后端返回完整 25 项指标 + grade/score)
|
||||
await store.runPortfolio(currentRequest())
|
||||
// 2. 附加分析:勾选的组合级 WF / 一条龙并行跑(互不阻塞,各自独立错误提示)
|
||||
if (!store.portfolioResult) return
|
||||
const jobs: Promise<void>[] = []
|
||||
if (wfEnabled.value) jobs.push(store.runPortfolioWalkforward(currentRequest(), wfWindows.value))
|
||||
if (evaluateEnabled.value) jobs.push(store.runPortfolioEvaluate(currentRequest()))
|
||||
await Promise.allSettled(jobs)
|
||||
}
|
||||
|
||||
// ── 保存策略(把当前组合结果 + 配置 + 上下文存进策略库)──────────────────────
|
||||
@@ -100,8 +126,8 @@ const strategyLabel = computed(
|
||||
() => store.strategies.find((s) => s.name === strategy.value)?.label ?? strategy.value,
|
||||
)
|
||||
|
||||
// 组合评级:从 combined_equity 重算夏普/卡玛/波动率等(组合级净值算不出胜率/利润因子),
|
||||
// 用 5 维度评分。净值点数过少(< 60 个交易日)视为样本不足。
|
||||
// 组合评级:从 combined_equity 重算夏普/卡玛/波动率等(组合级 5 维度口径,
|
||||
// 与后端 grade_portfolio_equity 一致)。净值点数过少(< 60 个交易日)视为样本不足。
|
||||
const grade = computed(() =>
|
||||
store.portfolioResult ? gradePortfolio(store.portfolioResult) : null,
|
||||
)
|
||||
@@ -155,6 +181,62 @@ async function onSave() {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── AI 解读(与单标的回测页共用 AiInterpretModal)────────────────────────────
|
||||
const showAiModal = ref(false)
|
||||
|
||||
const aiPromptText = computed(() => {
|
||||
if (!store.portfolioResult) return ''
|
||||
return buildPortfolioAiPrompt({
|
||||
stocks: stocks.value,
|
||||
category: category.value,
|
||||
startDate: startDate.value,
|
||||
endDate: endDate.value,
|
||||
strategyLabel: strategyLabel.value,
|
||||
params: params.value,
|
||||
cash: cash.value,
|
||||
commission: 0.0003,
|
||||
slippage: 0,
|
||||
execution: execution.value,
|
||||
result: store.portfolioResult,
|
||||
wf: store.portfolioWfResult,
|
||||
evaluate: store.portfolioEvaluateResult,
|
||||
grade: grade.value,
|
||||
gradeHint: grade.value ? GRADE_META[grade.value.grade].hint : undefined,
|
||||
})
|
||||
})
|
||||
|
||||
/** 随解读落历史库的策略上下文(历史页「去回测」引导用) */
|
||||
const aiContext = computed(() => ({
|
||||
strategy: strategy.value,
|
||||
strategy_label: strategyLabel.value,
|
||||
kind: 'portfolio',
|
||||
symbol: stocks.value.join(','),
|
||||
category: category.value,
|
||||
params: { ...params.value },
|
||||
start_date: startDate.value,
|
||||
end_date: endDate.value,
|
||||
}))
|
||||
|
||||
const aiTip = computed(() =>
|
||||
wfEnabled.value || evaluateEnabled.value
|
||||
? '建议等附加分析跑完再发,Walk-Forward / 一条龙评估的数据会一并打包。'
|
||||
: undefined,
|
||||
)
|
||||
|
||||
// ── 组合成交明细(各标的汇总,按时间倒序)────────────────────────────────────
|
||||
const TRADES_SHOW_LIMIT = 200
|
||||
const portfolioTrades = computed<PortfolioTrade[]>(() => {
|
||||
const r = store.portfolioResult
|
||||
if (!r) return []
|
||||
// 优先用后端汇总的成交表;老结果无该字段时从 individual_results 客户端汇总
|
||||
const rows: PortfolioTrade[] = r.trades
|
||||
? [...r.trades]
|
||||
: Object.entries(r.individual_results).flatMap(([symbol, res]) =>
|
||||
res.trades.map((t) => ({ ...t, symbol })),
|
||||
)
|
||||
return rows.sort((a, b) => String(b.datetime).localeCompare(String(a.datetime)))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -210,12 +292,45 @@ async function onSave() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel-section">
|
||||
<h3>附加分析</h3>
|
||||
<div class="check-row">
|
||||
<label
|
||||
class="check-label"
|
||||
title="按全部标的日期并集切窗,每窗各标的独立回测后合成组合净值,检验跨时段稳定性"
|
||||
>
|
||||
<input v-model="wfEnabled" type="checkbox" />
|
||||
<span>Walk-Forward 样本外验证</span>
|
||||
</label>
|
||||
<span v-if="wfEnabled" class="wf-windows">
|
||||
窗口数
|
||||
<input v-model.number="wfWindows" type="number" min="2" max="12" step="1" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="check-row">
|
||||
<label
|
||||
class="check-label"
|
||||
title="组合回测+组合WF+跨标的适配性体检+综合评分+等权买入持有基准对比,一份报告"
|
||||
>
|
||||
<input v-model="evaluateEnabled" type="checkbox" />
|
||||
<span>一条龙评估</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="extra-hint">勾选后随「开始组合回测」自动附加运行(标的越多越慢)</p>
|
||||
</section>
|
||||
|
||||
<button
|
||||
class="primary run-btn"
|
||||
:disabled="store.portfolioRunning || stocks.length === 0"
|
||||
:disabled="
|
||||
store.portfolioRunning || store.portfolioWfRunning || store.portfolioEvaluateRunning || stocks.length === 0
|
||||
"
|
||||
@click="onRun"
|
||||
>
|
||||
{{ store.portfolioRunning ? '组合回测中…' : '开始组合回测' }}
|
||||
{{
|
||||
store.portfolioRunning || store.portfolioWfRunning || store.portfolioEvaluateRunning
|
||||
? '组合回测中…'
|
||||
: '开始组合回测'
|
||||
}}
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
@@ -232,6 +347,7 @@ async function onSave() {
|
||||
<div v-if="store.portfolioResult" class="report-content">
|
||||
<div class="result-toolbar">
|
||||
<button class="ghost" @click="openSaveForm">💾 保存策略</button>
|
||||
<button class="ghost" @click="showAiModal = true">🤖 AI 解读</button>
|
||||
<span v-if="saveMsg" class="save-msg">{{ saveMsg }}</span>
|
||||
</div>
|
||||
|
||||
@@ -252,6 +368,15 @@ async function onSave() {
|
||||
{{ (store.portfolioResult.total_performance.total_return * 100).toFixed(2) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="perf-item">
|
||||
<span class="label">年化收益</span>
|
||||
<span
|
||||
class="value"
|
||||
:class="store.portfolioResult.total_performance.annual_return > 0 ? 'pos' : 'neg'"
|
||||
>
|
||||
{{ (store.portfolioResult.total_performance.annual_return * 100).toFixed(2) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="perf-item">
|
||||
<span class="label">标的数量</span>
|
||||
<span class="value">{{ store.portfolioResult.total_performance.total_stocks }}</span>
|
||||
@@ -263,11 +388,52 @@ async function onSave() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 附加分析:组合级 Walk-Forward(v1.31,与单标的同构面板) -->
|
||||
<section
|
||||
v-if="store.portfolioWfRunning || store.portfolioWfResult || store.portfolioWfError"
|
||||
class="report-section"
|
||||
>
|
||||
<h3>Walk-Forward 样本外验证</h3>
|
||||
<p v-if="store.portfolioWfRunning" class="loading-text">
|
||||
验证中…(标的数 × 窗口数 次回测,约需十几秒)
|
||||
</p>
|
||||
<div v-else-if="store.portfolioWfError" class="error-banner">
|
||||
⚠ {{ store.portfolioWfError }}
|
||||
</div>
|
||||
<WalkForwardPanel v-else-if="store.portfolioWfResult" :wf="store.portfolioWfResult" />
|
||||
</section>
|
||||
|
||||
<!-- 附加分析:组合级一条龙评估(v1.31,与单标的同构面板) -->
|
||||
<section
|
||||
v-if="
|
||||
store.portfolioEvaluateRunning || store.portfolioEvaluateResult || store.portfolioEvaluateError
|
||||
"
|
||||
class="report-section"
|
||||
>
|
||||
<h3>一条龙评估</h3>
|
||||
<p v-if="store.portfolioEvaluateRunning" class="loading-text">
|
||||
评估中…(组合回测 + 组合WF + 跨标的适配性 + 基准对比,可能需要一两分钟)
|
||||
</p>
|
||||
<div v-else-if="store.portfolioEvaluateError" class="error-banner">
|
||||
⚠ {{ store.portfolioEvaluateError }}
|
||||
</div>
|
||||
<EvaluatePanel
|
||||
v-else-if="store.portfolioEvaluateResult"
|
||||
:report="store.portfolioEvaluateResult"
|
||||
:grade-override="grade"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="report-section">
|
||||
<h3>组合净值曲线</h3>
|
||||
<EquityChart :equity="store.portfolioResult.combined_equity" />
|
||||
</section>
|
||||
|
||||
<section class="report-section">
|
||||
<h3>组合绩效指标</h3>
|
||||
<MetricTable :perf="store.portfolioResult.total_performance" />
|
||||
</section>
|
||||
|
||||
<section class="report-section">
|
||||
<h3>各标的绩效对比</h3>
|
||||
<PortfolioSummaryTable
|
||||
@@ -280,6 +446,17 @@ async function onSave() {
|
||||
<h3>各标的净值叠加(归一化)</h3>
|
||||
<PortfolioCompareChart :results="store.portfolioResult.individual_results" />
|
||||
</section>
|
||||
|
||||
<section class="report-section">
|
||||
<h3>组合成交明细({{ portfolioTrades.length }} 笔,按时间倒序)</h3>
|
||||
<p v-if="portfolioTrades.length > TRADES_SHOW_LIMIT" class="loading-text">
|
||||
仅显示最近 {{ TRADES_SHOW_LIMIT }} 笔,导出请用「对比分析」页的任务导出
|
||||
</p>
|
||||
<TradeTable
|
||||
:trades="portfolioTrades.slice(0, TRADES_SHOW_LIMIT)"
|
||||
show-symbol
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -318,6 +495,16 @@ async function onSave() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 解读 Prompt 对话框(单标的/组合通用组件) -->
|
||||
<AiInterpretModal
|
||||
v-if="showAiModal && store.portfolioResult"
|
||||
:prompt="aiPromptText"
|
||||
:filename="`AI解读_组合${stocks.length}只_${strategy}.md`"
|
||||
:context="aiContext"
|
||||
:tip="aiTip"
|
||||
@close="showAiModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -351,6 +538,53 @@ async function onSave() {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
/* 附加分析开关:勾选框靠左、文字单行不折行,窗口数同行跟排(与回测页一致) */
|
||||
.check-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.check-label {
|
||||
display: inline-flex; /* 覆盖全局 label { display: block } */
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.check-label input[type='checkbox'] {
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
accent-color: var(--accent, #4a9eff);
|
||||
}
|
||||
.wf-windows {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.wf-windows input {
|
||||
width: 44px;
|
||||
padding: 3px 6px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
.extra-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
.run-btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
@@ -393,6 +627,7 @@ async function onSave() {
|
||||
.perf-summary {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.perf-item {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user