diff --git a/src/easy_tdx/backtest/benchmark.py b/src/easy_tdx/backtest/benchmark.py index 367d4ae..046c6e7 100644 --- a/src/easy_tdx/backtest/benchmark.py +++ b/src/easy_tdx/backtest/benchmark.py @@ -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 diff --git a/src/easy_tdx/backtest/fitness.py b/src/easy_tdx/backtest/fitness.py index 6c36cbe..c6b3c0b 100644 --- a/src/easy_tdx/backtest/fitness.py +++ b/src/easy_tdx/backtest/fitness.py @@ -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( diff --git a/src/easy_tdx/backtest/performance.py b/src/easy_tdx/backtest/performance.py index 8eb2827..e9ca5fe 100644 --- a/src/easy_tdx/backtest/performance.py +++ b/src/easy_tdx/backtest/performance.py @@ -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: """计算最大回撤持续时间。 diff --git a/src/easy_tdx/backtest/portfolio_engine.py b/src/easy_tdx/backtest/portfolio_engine.py index 7b46976..7107755 100644 --- a/src/easy_tdx/backtest/portfolio_engine.py +++ b/src/easy_tdx/backtest/portfolio_engine.py @@ -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( { diff --git a/src/easy_tdx/backtest/walkforward.py b/src/easy_tdx/backtest/walkforward.py index 3b3fc2c..2edba23 100644 --- a/src/easy_tdx/backtest/walkforward.py +++ b/src/easy_tdx/backtest/walkforward.py @@ -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()}, + ) diff --git a/src/easy_tdx/web/routers/backtest.py b/src/easy_tdx/web/routers/backtest.py index ef55f70..428d4f9 100644 --- a/src/easy_tdx/web/routers/backtest.py +++ b/src/easy_tdx/web/routers/backtest.py @@ -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) diff --git a/tests/unit/test_backtest_fitness_benchmark.py b/tests/unit/test_backtest_fitness_benchmark.py index 52e5e34..ab20343 100644 --- a/tests/unit/test_backtest_fitness_benchmark.py +++ b/tests/unit/test_backtest_fitness_benchmark.py @@ -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 diff --git a/tests/unit/test_portfolio_engine.py b/tests/unit/test_portfolio_engine.py index 372f398..440290d 100644 --- a/tests/unit/test_portfolio_engine.py +++ b/tests/unit/test_portfolio_engine.py @@ -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) diff --git a/tests/unit/test_portfolio_walkforward.py b/tests/unit/test_portfolio_walkforward.py new file mode 100644 index 0000000..3f9ba49 --- /dev/null +++ b/tests/unit/test_portfolio_walkforward.py @@ -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 diff --git a/tests/unit/test_web_backtest.py b/tests/unit/test_web_backtest.py index 45edc5c..1aa58b6 100644 --- a/tests/unit/test_web_backtest.py +++ b/tests/unit/test_web_backtest.py @@ -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"] diff --git a/web-ui/e2e/portfolio.spec.ts b/web-ui/e2e/portfolio.spec.ts new file mode 100644 index 0000000..3733092 --- /dev/null +++ b/web-ui/e2e/portfolio.spec.ts @@ -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() +}) diff --git a/web-ui/src/__tests__/aiPrompt.test.ts b/web-ui/src/__tests__/aiPrompt.test.ts index f19b42c..408900f 100644 --- a/web-ui/src/__tests__/aiPrompt.test.ts +++ b/web-ui/src/__tests__/aiPrompt.test.ts @@ -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\*\*/) +}) diff --git a/web-ui/src/aiPrompt.ts b/web-ui/src/aiPrompt.ts index 33cbdb6..5f57541 100644 --- a/web-ui/src/aiPrompt.ts +++ b/web-ui/src/aiPrompt.ts @@ -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 + 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 = { @@ -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') +} diff --git a/web-ui/src/api.ts b/web-ui/src/api.ts index 5fa8bd0..e279c12 100644 --- a/web-ui/src/api.ts +++ b/web-ui/src/api.ts @@ -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 { + 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 { + 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, diff --git a/web-ui/src/components/AiInterpretModal.vue b/web-ui/src/components/AiInterpretModal.vue new file mode 100644 index 0000000..10b1a60 --- /dev/null +++ b/web-ui/src/components/AiInterpretModal.vue @@ -0,0 +1,256 @@ + + + + + diff --git a/web-ui/src/components/EvaluatePanel.vue b/web-ui/src/components/EvaluatePanel.vue index f9a2291..dddb7ce 100644 --- a/web-ui/src/components/EvaluatePanel.vue +++ b/web-ui/src/components/EvaluatePanel.vue @@ -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) diff --git a/web-ui/src/components/TradeTable.vue b/web-ui/src/components/TradeTable.vue index dd3bd36..eaab5f9 100644 --- a/web-ui/src/components/TradeTable.vue +++ b/web-ui/src/components/TradeTable.vue @@ -1,10 +1,13 @@ @@ -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); -} diff --git a/web-ui/src/views/PortfolioView.vue b/web-ui/src/views/PortfolioView.vue index fb4f038..241656d 100644 --- a/web-ui/src/views/PortfolioView.vue +++ b/web-ui/src/views/PortfolioView.vue @@ -1,18 +1,26 @@ @@ -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;