From 497ac21e5aa101b85d49a3b21c931038f3bcb60e Mon Sep 17 00:00:00 2001 From: GitHub Date: Thu, 3 Sep 2026 23:54:29 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=AD=96=E7=95=A5=E5=BA=93=E3=80=8C?= =?UTF-8?q?=E9=87=8D=E8=B7=91=E5=88=B0=E4=BB=8A=E5=A4=A9=E3=80=8D=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E7=BB=84=E5=90=88=E5=88=86=E6=9E=90=20=E2=80=94=20?= =?UTF-8?q?=E5=A4=9A=E7=AD=96=E7=95=A5=E7=BB=84=E5=90=88=E7=BA=A7WF/?= =?UTF-8?q?=E4=B8=80=E6=9D=A1=E9=BE=99/AI=E8=A7=A3=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 策略库(/strategies)多策略组合卡片此前只有主回测:本轮把 v1.31.0 的组合分析链路延伸到多策略组合(N 策略 × 各自原标的): - walkforward:组合 WF 泛化为槽位模型(_ComboSlot/_ComboWalkForwardBase), PortfolioWalkForwardEngine 行为不变;新增 MultiStrategyWalkForwardEngine (N 个策略各跑各自原标的,key 形如 label@symbol),复用切窗语义与 WalkForwardResult 结构(前端 WalkForwardPanel 直接渲染) - benchmark:新增 evaluate_multi 一条龙(MultiStrategyEngine 回测 + 多策略 组合 WF + 逐槽位三段体检多数口径聚合 + 综合评分 + 组合评级 + 各槽位标的 等权买入持有基准对比),报告结构与单标的 evaluate_strategy 同构 - Web:新增 POST /backtest/multi-strategy/wf/run/async 与 /backtest/multi-strategy/evaluate/run/async;多策略组合回测响应附带 grade/score(与单标的/多标的组合响应同构) - 前端 StrategiesView:组合结果区新增「WF 样本外验证 / 一条龙评估 / AI 解读」 按钮与同构面板(按需触发,复用最近一次组合回测的 items/cash); 绩效指标表补齐 v1.28 深度 6 项(SQN/最大连胜连亏/Ulcer/VaR/CVaR); aiPrompt 新增 multi 模式(策略明细语境 + 槽位表现段) - 测试:多策略 WF 引擎 3 例、evaluate_multi 3 例、新端点 Web 级 2 例、 aiPrompt multi 模式 1 例(pytest 1611 绿、node --test 5/5、E2E 9/9) --- src/easy_tdx/backtest/benchmark.py | 123 ++++- src/easy_tdx/backtest/walkforward.py | 439 +++++++++++------- src/easy_tdx/web/routers/backtest.py | 116 ++++- tests/unit/test_backtest_fitness_benchmark.py | 56 +++ tests/unit/test_portfolio_walkforward.py | 51 ++ tests/unit/test_web_backtest.py | 169 +++++++ web-ui/src/__tests__/aiPrompt.test.ts | 32 ++ web-ui/src/aiPrompt.ts | 95 ++-- web-ui/src/api.ts | 27 ++ web-ui/src/stores/backtest.ts | 70 +++ web-ui/src/views/StrategiesView.vue | 168 ++++++- 11 files changed, 1150 insertions(+), 196 deletions(-) diff --git a/src/easy_tdx/backtest/benchmark.py b/src/easy_tdx/backtest/benchmark.py index 046c6e7..c711f10 100644 --- a/src/easy_tdx/backtest/benchmark.py +++ b/src/easy_tdx/backtest/benchmark.py @@ -40,7 +40,11 @@ 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 PortfolioWalkForwardEngine, WalkForwardEngine +from easy_tdx.backtest.walkforward import ( + MultiStrategyWalkForwardEngine, + PortfolioWalkForwardEngine, + WalkForwardEngine, +) if TYPE_CHECKING: import numpy.typing as npt @@ -54,6 +58,7 @@ else: __all__ = [ "evaluate_strategy", "evaluate_portfolio", + "evaluate_multi", "run_buy_hold_benchmark", "compute_benchmark_comparison", ] @@ -453,3 +458,119 @@ def _aggregate_fitness( ) aggregated.high_fitness = aggregated.pass_ratio >= 0.75 return aggregated + + +def evaluate_multi( + strategies: 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", + 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_portfolio`(一个策略 × 多标的)同构,报告结构一致; + 差异点仅在槽位划分——每个槽位是「一个策略 × 它自己的标的」 + (:class:`~easy_tdx.backtest.multi_strategy_engine.StrategySlot`): + + - 全样本回测走 :class:`~easy_tdx.backtest.multi_strategy_engine.MultiStrategyEngine`; + - Walk-Forward 走 + :class:`~easy_tdx.backtest.walkforward.MultiStrategyWalkForwardEngine`; + - 适配性体检逐槽位(各自策略 × 各自标的)跑三段后按多数口径聚合; + - 买入持有基准 = 各槽位标的的等权买入持有组合(策略换成 _BuyAndHold, + 其余不变),α/β/信息比率/跟踪误差基于两条组合净值曲线。 + + Args: + strategies: StrategySlot 列表(每个槽位已绑定策略实例与 K 线)。 + 其余参数: 透传给组合回测 / 组合 WF / 适配性(同口径费率与执行)。 + + Returns: + 完整评估报告字典(结构同 evaluate_strategy,config 记录槽位清单)。 + """ + from easy_tdx.backtest.multi_strategy_engine import MultiStrategyEngine, StrategySlot + + engine_kwargs: dict[str, Any] = { + "total_cash": total_cash, + "commission": commission, + "min_commission": min_commission, + "stamp_tax": stamp_tax, + "slippage": slippage, + "execution": execution, + } + + # 1. 全样本组合回测(完整 25 项指标 + 合并净值曲线) + bt = MultiStrategyEngine(strategies=list(strategies), **engine_kwargs).run() + perf = bt.total_performance + + # 2. 组合 Walk-Forward 样本外 + wf = MultiStrategyWalkForwardEngine( + strategies=list(strategies), + n_windows=n_windows, + warmup_ratio=warmup_ratio, + context_bars=context_bars, + **engine_kwargs, + ).run() + + # 3. 适配性体检:逐槽位(各自策略 × 各自标的)跑三段体检,多数口径聚合 + per_cash = total_cash / max(len(strategies), 1) + fitness_kwargs: dict[str, Any] = { + "commission": commission, + "min_commission": min_commission, + "stamp_tax": stamp_tax, + "slippage": slippage, + "execution": execution, + } + per_slot_fitness = [ + FitnessEngine( + strategy=slot.strategy, + split=split, + context_bars=context_bars, + cash=per_cash, + **fitness_kwargs, + ).evaluate(slot.df) + for slot in strategies + ] + fitness = _aggregate_fitness(per_slot_fitness, split) + + # 4. 综合评分(叠加组合 WF 一致性)+ 组合评级(净值曲线口径) + score = score_strategy(dict(perf), wf=wf) + grade = grade_portfolio_equity(bt.combined_equity.to_dict(orient="records")) + + # 5. 基准对比:各槽位标的的等权买入持有组合(策略换成 _BuyAndHold,其余不变) + bh_slots = [ + StrategySlot(label=s.label, symbol=s.symbol, strategy=_BuyAndHold(), df=s.df) + for s in strategies + ] + bh_bt = MultiStrategyEngine(strategies=bh_slots, **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": { + "slots": [f"{s.label}@{s.symbol}" for s in strategies], + "total_cash": total_cash, + "execution": execution, + "n_windows": n_windows, + "warmup_ratio": warmup_ratio, + "split": list(split), + }, + } diff --git a/src/easy_tdx/backtest/walkforward.py b/src/easy_tdx/backtest/walkforward.py index 2edba23..623fe71 100644 --- a/src/easy_tdx/backtest/walkforward.py +++ b/src/easy_tdx/backtest/walkforward.py @@ -45,6 +45,7 @@ __all__ = [ "WalkForwardResult", "WalkForwardEngine", "PortfolioWalkForwardEngine", + "MultiStrategyWalkForwardEngine", ] @@ -273,7 +274,204 @@ class WalkForwardEngine: result.total_trades = int(sum(w.total_trades for w in ws)) -class PortfolioWalkForwardEngine: +class _ComboSlot: + """组合 WF 的一个回测槽位(内部结构,由各公开引擎组装)。 + + Attributes: + key: 槽位标识(组合成交表的 symbol 列值)。 + strategy: 策略类或实例。 + df: 该槽位的 K 线。 + cash: 等权分配到的资金。 + symbol: 品种感知费率标识(auto_fees 用;不感知则 None)。 + auto_fees: 是否按品种解析费率。 + """ + + __slots__ = ("key", "strategy", "df", "cash", "symbol", "auto_fees") + + def __init__( + self, + key: str, + strategy: type[Strategy] | Strategy, + df: pd.DataFrame, + cash: float, + symbol: str | None = None, + auto_fees: bool = False, + ) -> None: + self.key = key + self.strategy = strategy + self.df = df + self.cash = cash + self.symbol = symbol + self.auto_fees = auto_fees + + +class _ComboWalkForwardBase: + """组合级 WF 共用实现:按参考时间轴切窗,逐槽位独立回测后合成组合净值。 + + 切窗语义与 :class:`WalkForwardEngine`(单标的)一致,差异仅在「回测单元」 + 从单只标的换成 N 个槽位(标的或策略×标的)。 + """ + + def __init__( + self, + slots: list[_ComboSlot], + n_windows: int, + warmup_ratio: float, + context_bars: int, + engine_kwargs: dict[str, Any], + ) -> None: + self._slots = list(slots) + 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._engine_kwargs = dict(engine_kwargs) + + 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._slots: + 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 slot in self._slots: + s = self._dt_series(slot.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)] + + equity_series: list[pd.Series] = [] + trade_frames: list[pd.DataFrame] = [] + for slot in self._slots: + dt = self._dt_series(slot.df) + mask = (dt >= ctx_start) & (dt <= window_end) + sub = slot.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=slot.strategy, + cash=slot.cash, + warmup_bars=lead, + symbol=slot.symbol, + auto_fees=slot.auto_fees, + **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=slot.key) + ) + if len(bt.trades) > 0: + t = bt.trades.copy() + t["symbol"] = slot.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()}, + ) + + +class PortfolioWalkForwardEngine(_ComboWalkForwardBase): """组合级 Walk-Forward:一个策略 × 多只标的,逐窗独立回测并合成组合净值。 与 :class:`WalkForwardEngine`(单标的)共用切窗语义与 @@ -323,163 +521,94 @@ class PortfolioWalkForwardEngine: 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, + n = max(len(list(stocks)), 1) + slots = [ + _ComboSlot( + key=f"{s.market}{s.code}", + strategy=strategy, + df=s.df, + cash=total_cash / n, + symbol=f"{s.market}{s.code}", + auto_fees=auto_fees, ) - 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(), - } + for s in stocks + ] + super().__init__( + slots, + n_windows=n_windows, + warmup_ratio=warmup_ratio, + context_bars=context_bars, + engine_kwargs={ + "commission": commission, + "min_commission": min_commission, + "stamp_tax": stamp_tax, + "slippage": slippage, + "execution": execution, + "chanlun_level": chanlun_level, + }, ) - 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() +class MultiStrategyWalkForwardEngine(_ComboWalkForwardBase): + """多策略组合级 Walk-Forward:N 个策略各跑各自的原标的,逐窗独立回测。 - 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()}, + 与 :class:`PortfolioWalkForwardEngine` 共用切窗语义、组合窗内净值合成与 + :class:`WalkForwardResult` 输出结构(前端 WalkForwardPanel 直接复用), + 唯一差异是槽位划分:每个槽位是「一个策略 × 它自己的标的」 + (key 形如 ``"{label}@{symbol}"``,与 + :class:`~easy_tdx.backtest.multi_strategy_engine.MultiStrategyEngine` 的 + ``individual_results`` key 一致)。 + + Example: + >>> wf = MultiStrategyWalkForwardEngine(strategies=slots, n_windows=5) + >>> result = wf.run() + >>> result.consistency # 组合盈利窗占比 + 0.6 + """ + + def __init__( + self, + strategies: 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", + ) -> None: + """Initialize. + + Args: + strategies: :class:`~easy_tdx.backtest.multi_strategy_engine.StrategySlot` + 列表(每个槽位已绑定策略实例与 K 线)。 + n_windows / warmup_ratio / context_bars: 切窗参数(同单标的 WF)。 + total_cash: 组合总资金(各槽位等权分 1/N)。 + 其余参数: 透传给各窗各槽位的 :class:`BacktestEngine` + (与 MultiStrategyEngine 同口径,不含 auto_fees/chanlun_level)。 + """ + n = max(len(list(strategies)), 1) + slots = [ + _ComboSlot( + key=f"{s.label}@{s.symbol}", + strategy=s.strategy, + df=s.df, + cash=total_cash / n, + ) + for s in strategies + ] + super().__init__( + slots, + n_windows=n_windows, + warmup_ratio=warmup_ratio, + context_bars=context_bars, + engine_kwargs={ + "commission": commission, + "min_commission": min_commission, + "stamp_tax": stamp_tax, + "slippage": slippage, + "execution": execution, + }, ) diff --git a/src/easy_tdx/web/routers/backtest.py b/src/easy_tdx/web/routers/backtest.py index 428d4f9..57c7aff 100644 --- a/src/easy_tdx/web/routers/backtest.py +++ b/src/easy_tdx/web/routers/backtest.py @@ -289,6 +289,69 @@ async def run_multi_strategy_backtest_async( return TaskSubmitResponse(task_id=task_id, status=status) +@router.post( + "/backtest/multi-strategy/wf/run/async", response_model=TaskSubmitResponse, status_code=202 +) +async def run_multi_strategy_walkforward_async( + req: MultiStrategyBacktestRequest, + n_windows: int = 7, + client: Any = Depends(get_client), +) -> TaskSubmitResponse: + """提交多策略组合级 Walk-Forward 样本外验证后台任务。 + + 逐槽位取行情后,按全部槽位日期并集切窗(预热区 + N 个连续测试窗), + 每窗各槽位独立回测并合成组合窗内净值。结果为 ``{"walkforward": {...}}`` + (与单标的 WF 同构),通过 GET /backtest/tasks/{task_id} 轮询。 + """ + slots = await _fetch_multi_strategy_bars(client, req.items) + if not slots: + raise ValueError("所有策略槽位均未取到有效行情数据") + + snapshot = req.model_copy() + description = f"多策略组合WF | {len(slots)}个策略 × {n_windows}窗" + + runner = get_runner() + task_id = runner.submit( + lambda: _run_multi_strategy_walkforward(slots, 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/multi-strategy/evaluate/run/async", + response_model=TaskSubmitResponse, + status_code=202, +) +async def run_multi_strategy_evaluate_async( + req: MultiStrategyBacktestRequest, + client: Any = Depends(get_client), +) -> TaskSubmitResponse: + """提交多策略组合级一条龙评估后台任务:组合回测 + 组合 WF + 跨槽位适配性 + 体检 + 综合评分 + 组合评级 + 等权买入持有基准对比。 + + 结果结构见 ``easy_tdx.backtest.benchmark.evaluate_multi`` 文档(与 + 单标的 evaluate_strategy 同构),通过 GET /backtest/tasks/{task_id} 轮询。 + """ + slots = await _fetch_multi_strategy_bars(client, req.items) + if not slots: + raise ValueError("所有策略槽位均未取到有效行情数据") + + snapshot = req.model_copy() + description = f"多策略组合一条龙 | {len(slots)}个策略" + + runner = get_runner() + task_id = runner.submit( + lambda: _run_multi_strategy_evaluate(slots, 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) + + @router.post("/backtest/optimize/run/async", response_model=TaskSubmitResponse, status_code=202) async def run_optimize_async( req: OptimizeBacktestRequest, @@ -1054,8 +1117,14 @@ async def _fetch_multi_strategy_bars( def _run_multi_strategy_backtest( slots: list[Any], req: MultiStrategyBacktestRequest ) -> dict[str, Any]: - """执行多策略组合回测并返回清洗后的结果字典(后台线程内调用)。""" + """执行多策略组合回测并返回清洗后的结果字典(后台线程内调用)。 + + 与组合回测 ``_run_portfolio_backtest`` 同构:附带组合评级(净值口径) + 与综合评分,供前端/REST 直接消费。 + """ + from easy_tdx.backtest.grading import grade_portfolio_equity from easy_tdx.backtest.multi_strategy_engine import MultiStrategyEngine + from easy_tdx.backtest.scoring import score_strategy engine = MultiStrategyEngine( strategies=slots, @@ -1067,7 +1136,50 @@ def _run_multi_strategy_backtest( execution=req.execution, ) 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 + + +def _run_multi_strategy_walkforward( + slots: list[Any], req: MultiStrategyBacktestRequest, n_windows: int = 7 +) -> dict[str, Any]: + """执行多策略组合级 Walk-Forward 验证(后台线程内调用)。""" + from easy_tdx.backtest.walkforward import MultiStrategyWalkForwardEngine + + wf = MultiStrategyWalkForwardEngine( + strategies=slots, + 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, + ).run() + return {"walkforward": wf.to_dict()} + + +def _run_multi_strategy_evaluate( + slots: list[Any], req: MultiStrategyBacktestRequest +) -> dict[str, Any]: + """执行多策略组合级一条龙评估(后台线程内调用)。""" + from easy_tdx.backtest.benchmark import evaluate_multi + + return evaluate_multi( + strategies=slots, + total_cash=req.cash, + commission=req.commission, + min_commission=req.min_commission, + stamp_tax=req.stamp_tax, + slippage=req.slippage, + execution=req.execution, + ) def _run_optimize(df: pd.DataFrame, req: OptimizeBacktestRequest) -> dict[str, Any]: diff --git a/tests/unit/test_backtest_fitness_benchmark.py b/tests/unit/test_backtest_fitness_benchmark.py index ab20343..b813f73 100644 --- a/tests/unit/test_backtest_fitness_benchmark.py +++ b/tests/unit/test_backtest_fitness_benchmark.py @@ -251,3 +251,59 @@ def test_evaluate_portfolio_serializable(): ) text = json.dumps(report, default=str) assert "excess_return" in text + + +# ── evaluate_multi(v1.31.1:多策略组合一条龙)──────────────────────────────── +def _slots_for_multi() -> list[Any]: + from easy_tdx.backtest.multi_strategy_engine import StrategySlot + + return [ + StrategySlot( + label="动量", symbol="SZ:000001", strategy=_CycleTrader(), df=_df(400, drift=0.002) + ), + StrategySlot( + label="反转", symbol="SH:600000", strategy=_CycleTrader(), df=_df(400, drift=0.003) + ), + ] + + +def test_evaluate_multi_full_report_structure(): + """多策略组合一条龙报告与 evaluate_portfolio 同构(前端面板可复用)。""" + from easy_tdx.backtest.benchmark import evaluate_multi + + report = evaluate_multi(_slots_for_multi(), 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" + 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 report["config"]["slots"] == ["动量@SZ:000001", "反转@SH:600000"] + + +def test_evaluate_multi_buy_hold_excess_near_zero(): + """各槽位换成买入持有后,组合收益 ≈ 等权买入持有基准(excess 近 0)。""" + from easy_tdx.backtest.benchmark import evaluate_multi + from easy_tdx.backtest.multi_strategy_engine import StrategySlot + + bh_slots = [ + StrategySlot(label=s.label, symbol=s.symbol, strategy=_BuyFirstBar(), df=s.df) + for s in _slots_for_multi() + ] + report = evaluate_multi(bh_slots, total_cash=500_000) + assert abs(report["benchmark"]["excess_return"]) < 0.05 + + +def test_evaluate_multi_serializable(): + from easy_tdx.backtest.benchmark import evaluate_multi + + report = evaluate_multi(_slots_for_multi(), 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_walkforward.py b/tests/unit/test_portfolio_walkforward.py index 3f9ba49..c9bd5ef 100644 --- a/tests/unit/test_portfolio_walkforward.py +++ b/tests/unit/test_portfolio_walkforward.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from typing import Any import numpy as np import pandas as pd @@ -131,3 +132,53 @@ class TestPortfolioWalkForward: strategy=PeriodicStrategy, stocks=_stocks(), n_windows=0 ).run() assert wf.n_windows == 2 + + +# ── MultiStrategyWalkForwardEngine(v1.31.1:多策略组合槽位 WF)─────────────── +def _slots() -> list[Any]: + from easy_tdx.backtest.multi_strategy_engine import StrategySlot + + return [ + StrategySlot( + label="双均线交叉", + symbol="SH:601088", + strategy=PeriodicStrategy(), + df=_make_df(400, seed=42), + ), + StrategySlot( + label="RSI反转", + symbol="SZ:000001", + strategy=PeriodicStrategy(), + df=_make_df(400, seed=99), + ), + ] + + +def test_multi_strategy_wf_basic_structure() -> None: + from easy_tdx.backtest.walkforward import MultiStrategyWalkForwardEngine + + wf = MultiStrategyWalkForwardEngine(strategies=_slots(), n_windows=4, total_cash=200_000).run() + assert len(wf.windows) == 4 + assert wf.total_trades > 0 + assert wf.total_trades == sum(w.total_trades for w in wf.windows) + # 窗口时间升序 + starts = [pd.Timestamp(w.start) for w in wf.windows] + assert starts == sorted(starts) + + +def test_multi_strategy_wf_matches_portfolio_structure() -> None: + """与 PortfolioWalkForwardEngine 输出同构(前端面板可复用)。""" + from easy_tdx.backtest.walkforward import MultiStrategyWalkForwardEngine + + wf = MultiStrategyWalkForwardEngine(strategies=_slots(), n_windows=3).run() + d = wf.to_dict() + json.dumps(d) + assert "sqn" in d["windows"][0]["performance"] + assert "max_consecutive_wins" in d["windows"][0]["performance"] + + +def test_multi_strategy_wf_empty_slots() -> None: + from easy_tdx.backtest.walkforward import MultiStrategyWalkForwardEngine + + wf = MultiStrategyWalkForwardEngine(strategies=[], n_windows=3).run() + assert wf.windows == [] diff --git a/tests/unit/test_web_backtest.py b/tests/unit/test_web_backtest.py index 1aa58b6..1781276 100644 --- a/tests/unit/test_web_backtest.py +++ b/tests/unit/test_web_backtest.py @@ -11,6 +11,7 @@ from __future__ import annotations import time +from typing import Any import numpy as np import pandas as pd @@ -1196,3 +1197,171 @@ def test_portfolio_evaluate_endpoint(client, monkeypatch): assert report["grade"]["scenario"] == "portfolio" assert report["fitness"]["total_checks"] == 8 assert report["config"]["stocks"] == ["SZ000001", "SH600519"] + + +# ── 多策略组合级 WF / 一条龙评估端点(v1.31.1)──────────────────────────────── +def _fake_multi_slots(n: int = 400): + """构造 _fetch_multi_strategy_bars 的 mock 替身(两槽位合成行情)。""" + import pandas as pd + + from easy_tdx.backtest.multi_strategy_engine import StrategySlot + + async def fake_fetch(client_arg, items): # noqa: ANN001 + slots = [] + for item in items: + mkt, code = item.symbol.split(":") + 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, + } + ) + slots.append( + StrategySlot(label=item.strategy, symbol=item.symbol, strategy=None, df=df) + ) + return slots + + return fake_fetch + + +def _multi_request() -> dict[str, Any]: + from datetime import date as _date + + start = f"{_date.today().year - 3}-01-02" + return { + "items": [ + { + "strategy": "ma_cross", + "strategy_label": "双均线交叉", + "params": {"fast": 5, "slow": 20}, + "symbol": "SH:601088", + "category": "DAY", + "start_date": start, + }, + { + "strategy": "macd", + "strategy_label": "MACD 金叉", + "params": {}, + "symbol": "SZ:000001", + "category": "DAY", + "start_date": start, + }, + ], + "cash": 200000, + } + + +def test_multi_strategy_wf_endpoint(client, monkeypatch): + """POST /backtest/multi-strategy/wf/run/async 端到端(mock 取数 + 真实引擎)。 + + StrategySlot 由取数阶段绑定策略实例(_build 时替换 mock 的 None), + 这里用 router 内的真实 _fetch_multi_strategy_bars 不可行(需 client), + 故 fake_fetch 直接构造策略实例。 + """ + import pandas as pd + + import easy_tdx.web.routers.backtest as bt_router + from easy_tdx.backtest.multi_strategy_engine import StrategySlot + from easy_tdx.backtest.strategies import get_registry + + async def fake_fetch(client_arg, items): # noqa: ANN001 + registry = get_registry() + slots = [] + for item in items: + entry = registry.get(item.strategy) + strategy = entry.build(item.params) + close = 10 + np.cumsum(np.random.randn(400) * 0.3 + 0.02) + df = pd.DataFrame( + { + "datetime": pd.date_range("2023-01-02", periods=400, freq="B"), + "open": close - 0.1, + "high": close + 0.2, + "low": close - 0.2, + "close": close, + "vol": np.full(400, 5000.0), + "amount": close * 5000, + } + ) + slots.append( + StrategySlot( + label=item.strategy_label or item.strategy, + symbol=item.symbol, + strategy=strategy, + df=df, + ) + ) + return slots + + monkeypatch.setattr(bt_router, "_fetch_multi_strategy_bars", fake_fetch) + + resp = client.post( + "/api/v1/backtest/multi-strategy/wf/run/async?n_windows=3", + json=_multi_request(), + ) + 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_multi_strategy_evaluate_endpoint(client, monkeypatch): + """POST /backtest/multi-strategy/evaluate/run/async 端到端。""" + import pandas as pd + + import easy_tdx.web.routers.backtest as bt_router + from easy_tdx.backtest.multi_strategy_engine import StrategySlot + from easy_tdx.backtest.strategies import get_registry + + async def fake_fetch(client_arg, items): # noqa: ANN001 + registry = get_registry() + slots = [] + for item in items: + entry = registry.get(item.strategy) + strategy = entry.build(item.params) + close = 10 + np.cumsum(np.random.randn(400) * 0.3 + 0.02) + df = pd.DataFrame( + { + "datetime": pd.date_range("2023-01-02", periods=400, freq="B"), + "open": close - 0.1, + "high": close + 0.2, + "low": close - 0.2, + "close": close, + "vol": np.full(400, 5000.0), + "amount": close * 5000, + } + ) + slots.append( + StrategySlot( + label=item.strategy_label or item.strategy, + symbol=item.symbol, + strategy=strategy, + df=df, + ) + ) + return slots + + monkeypatch.setattr(bt_router, "_fetch_multi_strategy_bars", fake_fetch) + + resp = client.post( + "/api/v1/backtest/multi-strategy/evaluate/run/async", + json=_multi_request(), + ) + 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"]["slots"] == ["双均线交叉@SH:601088", "MACD 金叉@SZ:000001"] diff --git a/web-ui/src/__tests__/aiPrompt.test.ts b/web-ui/src/__tests__/aiPrompt.test.ts index 408900f..8010d17 100644 --- a/web-ui/src/__tests__/aiPrompt.test.ts +++ b/web-ui/src/__tests__/aiPrompt.test.ts @@ -330,3 +330,35 @@ test('组合版:WF / 一条龙 / 评级按需拼接', () => { assert.match(p, /# 评级(不看收益率,面向「普通人拿不拿得住」)/) assert.match(p, /档位:\*\*D\*\*/) }) + +test('组合版 multi 模式:N 个策略各跑原标的的语境与槽位明细', () => { + const p = buildPortfolioAiPrompt({ + stocks: ['双均线交叉@SH:601088', 'MACD 金叉@SZ:000001'], + category: 'DAY', + startDate: '2023-01-02', + endDate: '2026-09-03', + strategyLabel: '2 策略组合', + params: {}, + cash: 500000, + commission: 0.0003, + slippage: 0, + execution: 'next_open', + mode: 'multi', + result: { + ...PORTFOLIO_RESULT, + individual_results: { + '双均线交叉@SH:601088': RESULT, + 'MACD 金叉@SZ:000001': RESULT, + }, + }, + }) + + // 角色设定为多策略语境(N 个策略各跑各自的原标的) + assert.match(p, /N 个策略各跑各自的原标的,资金均分/) + // 配置段列策略明细而非标的清单,不再出现单一策略/参数行 + assert.match(p, /2 个策略各跑各自的原标的,资金均分(各拿总额的 50\.0%)/) + assert.match(p, /双均线交叉@SH:601088、MACD 金叉@SZ:000001/) + assert.ok(!p.includes('- 策略:2 策略组合')) + // 槽位表现段标题为「各策略槽位」 + assert.match(p, /# 各策略槽位表现(按收益降序;全部)/) +}) diff --git a/web-ui/src/aiPrompt.ts b/web-ui/src/aiPrompt.ts index 5f57541..1763d32 100644 --- a/web-ui/src/aiPrompt.ts +++ b/web-ui/src/aiPrompt.ts @@ -49,7 +49,8 @@ export interface AiPromptInput { } export interface PortfolioAiPromptInput { - /** 完整标的代码列表(带市场前缀,如 ["SZ:000001", "SH:600519"]) */ + /** 完整标的代码列表(带市场前缀,如 ["SZ:000001", "SH:600519"]); + * multi 模式下传「策略@标的」明细列表(如 ["双均线@SH:601088", …]) */ stocks: string[] category: Category startDate: string @@ -61,6 +62,9 @@ export interface PortfolioAiPromptInput { slippage: number execution: ExecutionMode result: PortfolioResult + /** 组合形态:portfolio = 一个策略 × 多只标的(默认); + * multi = 多个策略各跑各自的原标的(策略库「重跑到今天」) */ + mode?: 'portfolio' | 'multi' /** 附加分析(未勾选/未跑完时传 null,对应段落自动省略) */ wf?: WalkForwardResult | null evaluate?: EvaluateReport | null @@ -163,15 +167,22 @@ function n(v: number | string | null | undefined): number | undefined { // ── 各段落构建 ─────────────────────────────────────────────────────────────── -function sectionRole(kind: 'single' | 'portfolio' = 'single'): string { - const intro = - kind === 'portfolio' - ? '下面是我跑出来的组合回测报告(同一个策略分别跑在一篮子标的上,资金均分、各标的独立回测后净值加总),帮我看看这个组合策略到底行不行。内容上要说到这六件事,顺序随意,用你自然的说话方式组织:' - : '下面是我跑出来的回测报告,帮我看看这个策略到底行不行。内容上要说到这六件事,顺序随意,用你自然的说话方式组织:' - const step5 = - kind === 'portfolio' - ? '5. **给可执行的下一步**:几条我马上能做的事(改什么参数、加什么过滤、换哪些标的、先做什么测试再谈实盘),别空谈;' - : '5. **给可执行的下一步**:几条我马上能做的事(改什么参数、加什么过滤、先做什么测试再谈实盘),别空谈;' +function sectionRole(kind: 'single' | 'portfolio' | 'multi' = 'single'): string { + let intro = + '下面是我跑出来的回测报告,帮我看看这个策略到底行不行。内容上要说到这六件事,顺序随意,用你自然的说话方式组织:' + let step5 = + '5. **给可执行的下一步**:几条我马上能做的事(改什么参数、加什么过滤、先做什么测试再谈实盘),别空谈;' + if (kind === 'portfolio') { + intro = + '下面是我跑出来的组合回测报告(同一个策略分别跑在一篮子标的上,资金均分、各标的独立回测后净值加总),帮我看看这个组合策略到底行不行。内容上要说到这六件事,顺序随意,用你自然的说话方式组织:' + step5 = + '5. **给可执行的下一步**:几条我马上能做的事(改什么参数、加什么过滤、换哪些标的、先做什么测试再谈实盘),别空谈;' + } else if (kind === 'multi') { + intro = + '下面是我跑出来的组合回测报告(N 个策略各跑各自的原标的,资金均分、各策略独立回测后净值加总),帮我看看这个策略组合到底行不行。内容上要说到这六件事,顺序随意,用你自然的说话方式组织:' + step5 = + '5. **给可执行的下一步**:几条我马上能做的事(改哪些策略的参数、换掉拖后腿的策略、加什么过滤、先做什么测试再谈实盘),别空谈;' + } return [ '# 角色设定', '', @@ -370,28 +381,46 @@ function sectionFooter(): string { // ── 组合版段落 ─────────────────────────────────────────────────────────────── 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)}`, + const multi = i.mode === 'multi' + const lines = ['# 组合回测配置', ''] + if (multi) { + const detail = + i.stocks.length <= 12 + ? i.stocks.join('、') + : `${i.stocks.slice(0, 12).join('、')} 等 ${i.stocks.length} 个策略槽位` + lines.push( + `- 组合形式:${i.stocks.length} 个策略各跑各自的原标的,资金均分(各拿总额的 ${( + 100 / i.stocks.length + ).toFixed(1)}%),策略间独立回测、净值按日加总`, + ) + lines.push(`- 策略明细:${detail}`) + } else { + const stockList = + i.stocks.length <= 12 + ? i.stocks.join('、') + : `${i.stocks.slice(0, 12).join('、')} 等 ${i.stocks.length} 只` + lines.push( + `- 组合形式:同一个策略分别跑在 ${i.stocks.length} 只标的上,资金均分(各拿总额的 ${( + 100 / i.stocks.length + ).toFixed(1)}%),标的间独立回测、净值按日加总`, + ) + lines.push(`- 标的列表:${stockList}`) + lines.push(`- 策略:${i.strategyLabel}`) + lines.push(`- 参数:${fmtParams(i.params)}`) + } + if (multi) { + lines.push('- (各策略的参数见各策略在策略库中保存的配置,此处不逐一展开)') + } + lines.push(`- 回测区间:${i.startDate} ~ ${i.endDate}(${CATEGORY_LABELS[i.category] ?? i.category})`) + lines.push( `- 组合总资金:${fmtMoney(i.cash)} 元;佣金 ${i.commission};滑点 ${i.slippage};成交价:${EXECUTION_LABELS[i.execution] ?? i.execution}`, - '', - ] + ) + lines.push('') return lines.join('\n') } -/** 各标的表现摘要:按收益降序,超过 12 只时只列最好 6 只 + 最差 6 只。 */ -function sectionStocksSummary(result: PortfolioResult): string { +/** 各槽位表现摘要:按收益降序,超过 12 个时只列最好 6 个 + 最差 6 个。 */ +function sectionStocksSummary(result: PortfolioResult, multi = false): string { const entries = Object.entries(result.individual_results) if (entries.length === 0) return '' const sorted = entries @@ -401,9 +430,10 @@ function sectionStocksSummary(result: PortfolioResult): string { sorted.length <= 12 ? sorted : [...sorted.slice(0, 6), ...sorted.slice(sorted.length - 6)] + const what = multi ? '各策略槽位' : '各标的' const lines = [ - '# 各标的表现(按收益降序;' + - (sorted.length <= 12 ? '全部' : `省略中间 ${sorted.length - 12} 只,其余为最好/最差各 6 只`) + + `# ${what}表现(按收益降序;` + + (sorted.length <= 12 ? '全部' : `省略中间 ${sorted.length - 12} 个,其余为最好/最差各 6 个`) + ')', '', ] @@ -451,13 +481,14 @@ export function buildAiPrompt(input: AiPromptInput): string { /** 组装组合回测的 AI 解读 Prompt(与单标的同构,段落随附加分析增减)。 */ export function buildPortfolioAiPrompt(input: PortfolioAiPromptInput): string { + const multi = input.mode === 'multi' const parts: string[] = [ - sectionRole('portfolio'), + sectionRole(multi ? 'multi' : 'portfolio'), sectionPortfolioConfig(input), sectionMetrics(input.result.total_performance), sectionEquityPoints(input.result.combined_equity), ] - const stocksSummary = sectionStocksSummary(input.result) + const stocksSummary = sectionStocksSummary(input.result, multi) if (stocksSummary) parts.push(stocksSummary) if (input.wf) parts.push(sectionWf(input.wf)) if (input.evaluate) parts.push(sectionEvaluate(input.evaluate)) diff --git a/web-ui/src/api.ts b/web-ui/src/api.ts index e279c12..34d4a14 100644 --- a/web-ui/src/api.ts +++ b/web-ui/src/api.ts @@ -241,6 +241,33 @@ export async function submitMultiStrategyTask( return (await resp.json()) as TaskSubmitResponse } +/** 提交多策略组合级 Walk-Forward 样本外验证后台任务(n_windows 默认 7)。 */ +export async function submitMultiStrategyWalkforwardTask( + req: MultiStrategyBacktestRequest, + nWindows = 7, +): Promise { + const resp = await fetch(`${BASE}/backtest/multi-strategy/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 submitMultiStrategyEvaluateTask( + req: MultiStrategyBacktestRequest, +): Promise { + const resp = await fetch(`${BASE}/backtest/multi-strategy/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 submitOptimizeTask( req: OptimizeBacktestRequest, diff --git a/web-ui/src/stores/backtest.ts b/web-ui/src/stores/backtest.ts index e545121..267c489 100644 --- a/web-ui/src/stores/backtest.ts +++ b/web-ui/src/stores/backtest.ts @@ -14,6 +14,8 @@ import { submitOptimizeAllTask, submitOptimizeTask, submitMultiStrategyTask, + submitMultiStrategyEvaluateTask, + submitMultiStrategyWalkforwardTask, submitWalkforwardTask, submitEvaluateTask, fetchTask, @@ -255,6 +257,8 @@ export const useBacktestStore = defineStore('backtest', () => { multiStrategyRunning.value = true error.value = '' multiStrategyResult.value = null + // 新一次组合回测开始时清掉上一轮的附加分析(WF/一条龙面板随主结果一起刷新) + clearMultiStrategyExtraAnalysis() try { const { task_id } = await submitMultiStrategyTask(req) const start = Date.now() @@ -281,9 +285,66 @@ export const useBacktestStore = defineStore('backtest', () => { function clearMultiStrategy() { multiStrategyResult.value = null + clearMultiStrategyExtraAnalysis() error.value = '' } + // ── 多策略组合附加分析:组合级 Walk-Forward / 一条龙评估 ────────────────── + const multiWfResult = ref(null) + const multiWfRunning = ref(false) + const multiWfError = ref('') + const multiEvaluateResult = ref(null) + const multiEvaluateRunning = ref(false) + const multiEvaluateError = ref('') + + /** 提交多策略组合级 WF 样本外验证后台任务并轮询(N 槽位 × N 窗,较慢)。 */ + async function runMultiStrategyWalkforward(req: MultiStrategyBacktestRequest, nWindows = 7) { + multiWfRunning.value = true + multiWfError.value = '' + multiWfResult.value = null + try { + const { task_id } = await submitMultiStrategyWalkforwardTask(req, nWindows) + const body = await pollTask<{ walkforward: WalkForwardResult }>( + task_id, + 300_000, + '组合 WF 验证', + ) + multiWfResult.value = body.walkforward + } catch (e) { + multiWfError.value = formatError(e) + multiWfResult.value = null + } finally { + multiWfRunning.value = false + } + } + + /** 提交多策略组合级一条龙评估后台任务并轮询(组合回测+WF+适配性+评分+基准对比)。 */ + async function runMultiStrategyEvaluate(req: MultiStrategyBacktestRequest) { + multiEvaluateRunning.value = true + multiEvaluateError.value = '' + multiEvaluateResult.value = null + try { + const { task_id } = await submitMultiStrategyEvaluateTask(req) + multiEvaluateResult.value = await pollTask( + task_id, + 600_000, + '组合一条龙评估', + ) + } catch (e) { + multiEvaluateError.value = formatError(e) + multiEvaluateResult.value = null + } finally { + multiEvaluateRunning.value = false + } + } + + function clearMultiStrategyExtraAnalysis() { + multiWfResult.value = null + multiWfError.value = '' + multiEvaluateResult.value = null + multiEvaluateError.value = '' + } + // ── 参数网格寻优(Phase 4) ───────────────────────────────────────────── const optimizeResult = ref(null) const optimizeRunning = ref(false) @@ -385,6 +446,12 @@ export const useBacktestStore = defineStore('backtest', () => { portfolioEvaluateError, multiStrategyResult, multiStrategyRunning, + multiWfResult, + multiWfRunning, + multiWfError, + multiEvaluateResult, + multiEvaluateRunning, + multiEvaluateError, optimizeResult, optimizeRunning, optimizeContext, @@ -413,6 +480,9 @@ export const useBacktestStore = defineStore('backtest', () => { clearPortfolioExtraAnalysis, runMultiStrategy, clearMultiStrategy, + runMultiStrategyWalkforward, + runMultiStrategyEvaluate, + clearMultiStrategyExtraAnalysis, runOptimize, runOptimizeAll, setOptimizeContext, diff --git a/web-ui/src/views/StrategiesView.vue b/web-ui/src/views/StrategiesView.vue index a3fe114..709272f 100644 --- a/web-ui/src/views/StrategiesView.vue +++ b/web-ui/src/views/StrategiesView.vue @@ -6,20 +6,24 @@ import { computed, nextTick, onMounted, ref } from 'vue' import { useRouter } 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 WalkForwardPanel from '../components/WalkForwardPanel.vue' import { deleteSavedStrategy, fetchSavedStrategies, formatError, saveStrategy, } from '../api' -import { gradePortfolio } from '../grading' +import { buildPortfolioAiPrompt } from '../aiPrompt' +import { GRADE_META, gradePortfolio } from '../grading' import { detectMarket } from '../market' -import type { MultiStrategyItem, Performance, SavedStrategy } from '../types' +import type { Category, MultiStrategyItem, Performance, SavedStrategy } from '../types' import { useBacktestStore } from '../stores/backtest' const router = useRouter() @@ -380,8 +384,9 @@ const holdingViews = computed(() => }), ) -// 组合整体绩效(19 项指标)。后端 total_performance 现含完整指标,转成 -// MetricTable 需要的 Performance 类型(缺失字段补 0 兜底,保证渲染不崩)。 +// 组合整体绩效(25 项指标,与单标的 MetricTable 同口径)。后端 +// total_performance 含完整指标,转成 MetricTable 需要的 Performance 类型 +// (缺失字段补 0 兜底,保证老结果渲染不崩)。 const comboPerf = computed(() => { const tp = store.multiStrategyResult?.total_performance if (!tp) return null @@ -409,6 +414,12 @@ const comboPerf = computed(() => { max_loss: get('max_loss'), avg_holding_days: get('avg_holding_days'), volatility: get('volatility'), + ulcer_index: get('ulcer_index'), + var_95: get('var_95'), + cvar_95: get('cvar_95'), + sqn: get('sqn'), + max_consecutive_wins: get('max_consecutive_wins'), + max_consecutive_losses: get('max_consecutive_losses'), } }) @@ -417,6 +428,69 @@ const comboPerf = computed(() => { const comboGrade = computed(() => store.multiStrategyResult ? gradePortfolio(store.multiStrategyResult) : null, ) + +// ── 组合附加分析:组合级 Walk-Forward / 一条龙 / AI 解读 ───────────────────── +// 与单标的/组合回测页同构;按钮在组合结果区按需触发(复用最近一次 +// 组合回测的 items/cash,区间保持不变——检验的是"这组配置"的稳定性)。 + +function multiComboRequest() { + return { items: lastComboItems.value, cash: lastComboCash.value } +} + +async function onComboWf() { + if (lastComboItems.value.length === 0) return + await store.runMultiStrategyWalkforward(multiComboRequest()) +} + +async function onComboEvaluate() { + if (lastComboItems.value.length === 0) return + await store.runMultiStrategyEvaluate(multiComboRequest()) +} + +// AI 解读:组合版 Prompt(multi 模式:N 个策略各跑各自的原标的) +const showAiModal = ref(false) + +const comboStrategyLabel = computed(() => + lastComboItems.value.length > 0 + ? `${lastComboItems.value.length} 策略组合` + : '策略组合', +) + +const aiPromptText = computed(() => { + if (!store.multiStrategyResult) return '' + return buildPortfolioAiPrompt({ + stocks: lastComboItems.value.map( + (it) => `${it.strategy_label || it.strategy}@${it.symbol}`, + ), + category: (lastComboItems.value[0]?.category as Category) ?? 'DAY', + startDate: (lastComboItems.value[0]?.start_date as string) || '', + endDate: (lastComboItems.value[0]?.end_date as string) || isoToday(), + strategyLabel: comboStrategyLabel.value, + params: {}, + cash: lastComboCash.value, + commission: 0.0003, + slippage: 0, + execution: 'next_open', + result: store.multiStrategyResult, + mode: 'multi', + wf: store.multiWfResult, + evaluate: store.multiEvaluateResult, + grade: comboGrade.value, + gradeHint: comboGrade.value ? GRADE_META[comboGrade.value.grade].hint : undefined, + }) +}) + +/** 随解读落历史库的策略上下文(历史页「去回测」引导用) */ +const aiContext = computed(() => ({ + strategy: 'multi', + strategy_label: comboStrategyLabel.value, + kind: 'multi', + symbol: lastComboItems.value.map((it) => it.symbol).join(','), + category: (lastComboItems.value[0]?.category as string) || 'DAY', + params: {}, + start_date: (lastComboItems.value[0]?.start_date as string) || '', + end_date: isoToday(), +})) @@ -1184,6 +1324,22 @@ const comboGrade = computed(() => .save-combo-btn:hover { background: linear-gradient(135deg, #fbbf24, #f59e0b); } +.save-combo-btn:disabled { + opacity: 0.5; + cursor: default; +} +/* 组合结果区附加分析按钮组(WF/一条龙/AI 解读/保存):内联排列,窄屏可换行 */ +.combo-extra-actions { + display: inline-flex; + align-items: center; + gap: 8px; + margin-left: 10px; + flex-wrap: wrap; + vertical-align: middle; +} +.combo-extra-actions .save-combo-btn { + margin-left: 0; +} /* 警示条基础类(过拟合 / 免责共享) */ .warn-box {