mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 15:44:18 +08:00
fix: 策略库「重跑到今天」补齐组合分析 — 多策略组合级WF/一条龙/AI解读
策略库(/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)
This commit is contained in:
@@ -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.scoring import score_strategy
|
||||||
from easy_tdx.backtest.strategy import Strategy
|
from easy_tdx.backtest.strategy import Strategy
|
||||||
from easy_tdx.backtest.types import to_json_native
|
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:
|
if TYPE_CHECKING:
|
||||||
import numpy.typing as npt
|
import numpy.typing as npt
|
||||||
@@ -54,6 +58,7 @@ else:
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"evaluate_strategy",
|
"evaluate_strategy",
|
||||||
"evaluate_portfolio",
|
"evaluate_portfolio",
|
||||||
|
"evaluate_multi",
|
||||||
"run_buy_hold_benchmark",
|
"run_buy_hold_benchmark",
|
||||||
"compute_benchmark_comparison",
|
"compute_benchmark_comparison",
|
||||||
]
|
]
|
||||||
@@ -453,3 +458,119 @@ def _aggregate_fitness(
|
|||||||
)
|
)
|
||||||
aggregated.high_fitness = aggregated.pass_ratio >= 0.75
|
aggregated.high_fitness = aggregated.pass_ratio >= 0.75
|
||||||
return aggregated
|
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),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ __all__ = [
|
|||||||
"WalkForwardResult",
|
"WalkForwardResult",
|
||||||
"WalkForwardEngine",
|
"WalkForwardEngine",
|
||||||
"PortfolioWalkForwardEngine",
|
"PortfolioWalkForwardEngine",
|
||||||
|
"MultiStrategyWalkForwardEngine",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -273,7 +274,204 @@ class WalkForwardEngine:
|
|||||||
result.total_trades = int(sum(w.total_trades for w in ws))
|
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:一个策略 × 多只标的,逐窗独立回测并合成组合净值。
|
"""组合级 Walk-Forward:一个策略 × 多只标的,逐窗独立回测并合成组合净值。
|
||||||
|
|
||||||
与 :class:`WalkForwardEngine`(单标的)共用切窗语义与
|
与 :class:`WalkForwardEngine`(单标的)共用切窗语义与
|
||||||
@@ -323,163 +521,94 @@ class PortfolioWalkForwardEngine:
|
|||||||
total_cash: 组合总资金(各标的等权分 1/N)。
|
total_cash: 组合总资金(各标的等权分 1/N)。
|
||||||
其余参数: 透传给各窗各标的的 :class:`BacktestEngine`。
|
其余参数: 透传给各窗各标的的 :class:`BacktestEngine`。
|
||||||
"""
|
"""
|
||||||
self._strategy = strategy
|
n = max(len(list(stocks)), 1)
|
||||||
self._stocks = list(stocks)
|
slots = [
|
||||||
self._n_windows = max(int(n_windows), 2)
|
_ComboSlot(
|
||||||
self._warmup_ratio = min(max(float(warmup_ratio), 0.0), 0.8)
|
key=f"{s.market}{s.code}",
|
||||||
self._context_bars = max(int(context_bars), 0)
|
strategy=strategy,
|
||||||
self._total_cash = float(total_cash)
|
df=s.df,
|
||||||
self._engine_kwargs: dict[str, Any] = {
|
cash=total_cash / n,
|
||||||
"commission": commission,
|
symbol=f"{s.market}{s.code}",
|
||||||
"min_commission": min_commission,
|
auto_fees=auto_fees,
|
||||||
"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:
|
for s in stocks
|
||||||
bt = engine.run(sub)
|
]
|
||||||
except Exception: # noqa: BLE001 — 单标的失败不拖垮整窗
|
super().__init__(
|
||||||
continue
|
slots,
|
||||||
|
n_windows=n_windows,
|
||||||
# 只取窗内净值点(上下文区恒为现金,不参与窗指标,避免稀释波动率)
|
warmup_ratio=warmup_ratio,
|
||||||
ec = bt.equity_curve
|
context_bars=context_bars,
|
||||||
if len(ec) > lead:
|
engine_kwargs={
|
||||||
eq = ec.iloc[lead:]
|
"commission": commission,
|
||||||
equity_series.append(
|
"min_commission": min_commission,
|
||||||
pd.Series(eq["total"].to_numpy(), index=self._dt_series(eq), name=key)
|
"stamp_tax": stamp_tax,
|
||||||
)
|
"slippage": slippage,
|
||||||
if len(bt.trades) > 0:
|
"execution": execution,
|
||||||
t = bt.trades.copy()
|
"chanlun_level": chanlun_level,
|
||||||
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()
|
class MultiStrategyWalkForwardEngine(_ComboWalkForwardBase):
|
||||||
|
"""多策略组合级 Walk-Forward:N 个策略各跑各自的原标的,逐窗独立回测。
|
||||||
|
|
||||||
return WalkForwardWindow(
|
与 :class:`PortfolioWalkForwardEngine` 共用切窗语义、组合窗内净值合成与
|
||||||
index=index,
|
:class:`WalkForwardResult` 输出结构(前端 WalkForwardPanel 直接复用),
|
||||||
start=window_start.strftime("%Y-%m-%d"),
|
唯一差异是槽位划分:每个槽位是「一个策略 × 它自己的标的」
|
||||||
end=window_end.strftime("%Y-%m-%d"),
|
(key 形如 ``"{label}@{symbol}"``,与
|
||||||
bars=int(e - s),
|
:class:`~easy_tdx.backtest.multi_strategy_engine.MultiStrategyEngine` 的
|
||||||
total_return=float(perf.get("total_return", 0.0)),
|
``individual_results`` key 一致)。
|
||||||
sharpe=float(perf.get("sharpe", 0.0)),
|
|
||||||
max_drawdown=float(perf.get("max_drawdown", 0.0)),
|
Example:
|
||||||
total_trades=int(perf.get("total_trades", 0)),
|
>>> wf = MultiStrategyWalkForwardEngine(strategies=slots, n_windows=5)
|
||||||
win_rate=float(perf.get("win_rate", 0.0)),
|
>>> result = wf.run()
|
||||||
performance={k: v for k, v in perf.items()},
|
>>> 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,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -289,6 +289,69 @@ async def run_multi_strategy_backtest_async(
|
|||||||
return TaskSubmitResponse(task_id=task_id, status=status)
|
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)
|
@router.post("/backtest/optimize/run/async", response_model=TaskSubmitResponse, status_code=202)
|
||||||
async def run_optimize_async(
|
async def run_optimize_async(
|
||||||
req: OptimizeBacktestRequest,
|
req: OptimizeBacktestRequest,
|
||||||
@@ -1054,8 +1117,14 @@ async def _fetch_multi_strategy_bars(
|
|||||||
def _run_multi_strategy_backtest(
|
def _run_multi_strategy_backtest(
|
||||||
slots: list[Any], req: MultiStrategyBacktestRequest
|
slots: list[Any], req: MultiStrategyBacktestRequest
|
||||||
) -> dict[str, Any]:
|
) -> 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.multi_strategy_engine import MultiStrategyEngine
|
||||||
|
from easy_tdx.backtest.scoring import score_strategy
|
||||||
|
|
||||||
engine = MultiStrategyEngine(
|
engine = MultiStrategyEngine(
|
||||||
strategies=slots,
|
strategies=slots,
|
||||||
@@ -1067,7 +1136,50 @@ def _run_multi_strategy_backtest(
|
|||||||
execution=req.execution,
|
execution=req.execution,
|
||||||
)
|
)
|
||||||
result = engine.run()
|
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]:
|
def _run_optimize(df: pd.DataFrame, req: OptimizeBacktestRequest) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -251,3 +251,59 @@ def test_evaluate_portfolio_serializable():
|
|||||||
)
|
)
|
||||||
text = json.dumps(report, default=str)
|
text = json.dumps(report, default=str)
|
||||||
assert "excess_return" in text
|
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
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -131,3 +132,53 @@ class TestPortfolioWalkForward:
|
|||||||
strategy=PeriodicStrategy, stocks=_stocks(), n_windows=0
|
strategy=PeriodicStrategy, stocks=_stocks(), n_windows=0
|
||||||
).run()
|
).run()
|
||||||
assert wf.n_windows == 2
|
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 == []
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -1196,3 +1197,171 @@ def test_portfolio_evaluate_endpoint(client, monkeypatch):
|
|||||||
assert report["grade"]["scenario"] == "portfolio"
|
assert report["grade"]["scenario"] == "portfolio"
|
||||||
assert report["fitness"]["total_checks"] == 8
|
assert report["fitness"]["total_checks"] == 8
|
||||||
assert report["config"]["stocks"] == ["SZ000001", "SH600519"]
|
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"]
|
||||||
|
|||||||
@@ -330,3 +330,35 @@ test('组合版:WF / 一条龙 / 评级按需拼接', () => {
|
|||||||
assert.match(p, /# 评级(不看收益率,面向「普通人拿不拿得住」)/)
|
assert.match(p, /# 评级(不看收益率,面向「普通人拿不拿得住」)/)
|
||||||
assert.match(p, /档位:\*\*D\*\*/)
|
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, /# 各策略槽位表现(按收益降序;全部)/)
|
||||||
|
})
|
||||||
|
|||||||
+63
-32
@@ -49,7 +49,8 @@ export interface AiPromptInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PortfolioAiPromptInput {
|
export interface PortfolioAiPromptInput {
|
||||||
/** 完整标的代码列表(带市场前缀,如 ["SZ:000001", "SH:600519"]) */
|
/** 完整标的代码列表(带市场前缀,如 ["SZ:000001", "SH:600519"]);
|
||||||
|
* multi 模式下传「策略@标的」明细列表(如 ["双均线@SH:601088", …]) */
|
||||||
stocks: string[]
|
stocks: string[]
|
||||||
category: Category
|
category: Category
|
||||||
startDate: string
|
startDate: string
|
||||||
@@ -61,6 +62,9 @@ export interface PortfolioAiPromptInput {
|
|||||||
slippage: number
|
slippage: number
|
||||||
execution: ExecutionMode
|
execution: ExecutionMode
|
||||||
result: PortfolioResult
|
result: PortfolioResult
|
||||||
|
/** 组合形态:portfolio = 一个策略 × 多只标的(默认);
|
||||||
|
* multi = 多个策略各跑各自的原标的(策略库「重跑到今天」) */
|
||||||
|
mode?: 'portfolio' | 'multi'
|
||||||
/** 附加分析(未勾选/未跑完时传 null,对应段落自动省略) */
|
/** 附加分析(未勾选/未跑完时传 null,对应段落自动省略) */
|
||||||
wf?: WalkForwardResult | null
|
wf?: WalkForwardResult | null
|
||||||
evaluate?: EvaluateReport | null
|
evaluate?: EvaluateReport | null
|
||||||
@@ -163,15 +167,22 @@ function n(v: number | string | null | undefined): number | undefined {
|
|||||||
|
|
||||||
// ── 各段落构建 ───────────────────────────────────────────────────────────────
|
// ── 各段落构建 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function sectionRole(kind: 'single' | 'portfolio' = 'single'): string {
|
function sectionRole(kind: 'single' | 'portfolio' | 'multi' = 'single'): string {
|
||||||
const intro =
|
let intro =
|
||||||
kind === 'portfolio'
|
'下面是我跑出来的回测报告,帮我看看这个策略到底行不行。内容上要说到这六件事,顺序随意,用你自然的说话方式组织:'
|
||||||
? '下面是我跑出来的组合回测报告(同一个策略分别跑在一篮子标的上,资金均分、各标的独立回测后净值加总),帮我看看这个组合策略到底行不行。内容上要说到这六件事,顺序随意,用你自然的说话方式组织:'
|
let step5 =
|
||||||
: '下面是我跑出来的回测报告,帮我看看这个策略到底行不行。内容上要说到这六件事,顺序随意,用你自然的说话方式组织:'
|
'5. **给可执行的下一步**:几条我马上能做的事(改什么参数、加什么过滤、先做什么测试再谈实盘),别空谈;'
|
||||||
const step5 =
|
if (kind === 'portfolio') {
|
||||||
kind === 'portfolio'
|
intro =
|
||||||
? '5. **给可执行的下一步**:几条我马上能做的事(改什么参数、加什么过滤、换哪些标的、先做什么测试再谈实盘),别空谈;'
|
'下面是我跑出来的组合回测报告(同一个策略分别跑在一篮子标的上,资金均分、各标的独立回测后净值加总),帮我看看这个组合策略到底行不行。内容上要说到这六件事,顺序随意,用你自然的说话方式组织:'
|
||||||
: '5. **给可执行的下一步**:几条我马上能做的事(改什么参数、加什么过滤、先做什么测试再谈实盘),别空谈;'
|
step5 =
|
||||||
|
'5. **给可执行的下一步**:几条我马上能做的事(改什么参数、加什么过滤、换哪些标的、先做什么测试再谈实盘),别空谈;'
|
||||||
|
} else if (kind === 'multi') {
|
||||||
|
intro =
|
||||||
|
'下面是我跑出来的组合回测报告(N 个策略各跑各自的原标的,资金均分、各策略独立回测后净值加总),帮我看看这个策略组合到底行不行。内容上要说到这六件事,顺序随意,用你自然的说话方式组织:'
|
||||||
|
step5 =
|
||||||
|
'5. **给可执行的下一步**:几条我马上能做的事(改哪些策略的参数、换掉拖后腿的策略、加什么过滤、先做什么测试再谈实盘),别空谈;'
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
'# 角色设定',
|
'# 角色设定',
|
||||||
'',
|
'',
|
||||||
@@ -370,28 +381,46 @@ function sectionFooter(): string {
|
|||||||
// ── 组合版段落 ───────────────────────────────────────────────────────────────
|
// ── 组合版段落 ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function sectionPortfolioConfig(i: PortfolioAiPromptInput): string {
|
function sectionPortfolioConfig(i: PortfolioAiPromptInput): string {
|
||||||
const stockList =
|
const multi = i.mode === 'multi'
|
||||||
i.stocks.length <= 12
|
const lines = ['# 组合回测配置', '']
|
||||||
? i.stocks.join('、')
|
if (multi) {
|
||||||
: `${i.stocks.slice(0, 12).join('、')} 等 ${i.stocks.length} 只`
|
const detail =
|
||||||
const lines = [
|
i.stocks.length <= 12
|
||||||
'# 组合回测配置',
|
? i.stocks.join('、')
|
||||||
'',
|
: `${i.stocks.slice(0, 12).join('、')} 等 ${i.stocks.length} 个策略槽位`
|
||||||
`- 组合形式:同一个策略分别跑在 ${i.stocks.length} 只标的上,资金均分(各拿总额的 ${(
|
lines.push(
|
||||||
100 / i.stocks.length
|
`- 组合形式:${i.stocks.length} 个策略各跑各自的原标的,资金均分(各拿总额的 ${(
|
||||||
).toFixed(1)}%),标的间独立回测、净值按日加总`,
|
100 / i.stocks.length
|
||||||
`- 标的列表:${stockList}`,
|
).toFixed(1)}%),策略间独立回测、净值按日加总`,
|
||||||
`- 回测区间:${i.startDate} ~ ${i.endDate}(${CATEGORY_LABELS[i.category] ?? i.category})`,
|
)
|
||||||
`- 策略:${i.strategyLabel}`,
|
lines.push(`- 策略明细:${detail}`)
|
||||||
`- 参数:${fmtParams(i.params)}`,
|
} 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}`,
|
`- 组合总资金:${fmtMoney(i.cash)} 元;佣金 ${i.commission};滑点 ${i.slippage};成交价:${EXECUTION_LABELS[i.execution] ?? i.execution}`,
|
||||||
'',
|
)
|
||||||
]
|
lines.push('')
|
||||||
return lines.join('\n')
|
return lines.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 各标的表现摘要:按收益降序,超过 12 只时只列最好 6 只 + 最差 6 只。 */
|
/** 各槽位表现摘要:按收益降序,超过 12 个时只列最好 6 个 + 最差 6 个。 */
|
||||||
function sectionStocksSummary(result: PortfolioResult): string {
|
function sectionStocksSummary(result: PortfolioResult, multi = false): string {
|
||||||
const entries = Object.entries(result.individual_results)
|
const entries = Object.entries(result.individual_results)
|
||||||
if (entries.length === 0) return ''
|
if (entries.length === 0) return ''
|
||||||
const sorted = entries
|
const sorted = entries
|
||||||
@@ -401,9 +430,10 @@ function sectionStocksSummary(result: PortfolioResult): string {
|
|||||||
sorted.length <= 12
|
sorted.length <= 12
|
||||||
? sorted
|
? sorted
|
||||||
: [...sorted.slice(0, 6), ...sorted.slice(sorted.length - 6)]
|
: [...sorted.slice(0, 6), ...sorted.slice(sorted.length - 6)]
|
||||||
|
const what = multi ? '各策略槽位' : '各标的'
|
||||||
const lines = [
|
const lines = [
|
||||||
'# 各标的表现(按收益降序;' +
|
`# ${what}表现(按收益降序;` +
|
||||||
(sorted.length <= 12 ? '全部' : `省略中间 ${sorted.length - 12} 只,其余为最好/最差各 6 只`) +
|
(sorted.length <= 12 ? '全部' : `省略中间 ${sorted.length - 12} 个,其余为最好/最差各 6 个`) +
|
||||||
')',
|
')',
|
||||||
'',
|
'',
|
||||||
]
|
]
|
||||||
@@ -451,13 +481,14 @@ export function buildAiPrompt(input: AiPromptInput): string {
|
|||||||
|
|
||||||
/** 组装组合回测的 AI 解读 Prompt(与单标的同构,段落随附加分析增减)。 */
|
/** 组装组合回测的 AI 解读 Prompt(与单标的同构,段落随附加分析增减)。 */
|
||||||
export function buildPortfolioAiPrompt(input: PortfolioAiPromptInput): string {
|
export function buildPortfolioAiPrompt(input: PortfolioAiPromptInput): string {
|
||||||
|
const multi = input.mode === 'multi'
|
||||||
const parts: string[] = [
|
const parts: string[] = [
|
||||||
sectionRole('portfolio'),
|
sectionRole(multi ? 'multi' : 'portfolio'),
|
||||||
sectionPortfolioConfig(input),
|
sectionPortfolioConfig(input),
|
||||||
sectionMetrics(input.result.total_performance),
|
sectionMetrics(input.result.total_performance),
|
||||||
sectionEquityPoints(input.result.combined_equity),
|
sectionEquityPoints(input.result.combined_equity),
|
||||||
]
|
]
|
||||||
const stocksSummary = sectionStocksSummary(input.result)
|
const stocksSummary = sectionStocksSummary(input.result, multi)
|
||||||
if (stocksSummary) parts.push(stocksSummary)
|
if (stocksSummary) parts.push(stocksSummary)
|
||||||
if (input.wf) parts.push(sectionWf(input.wf))
|
if (input.wf) parts.push(sectionWf(input.wf))
|
||||||
if (input.evaluate) parts.push(sectionEvaluate(input.evaluate))
|
if (input.evaluate) parts.push(sectionEvaluate(input.evaluate))
|
||||||
|
|||||||
@@ -241,6 +241,33 @@ export async function submitMultiStrategyTask(
|
|||||||
return (await resp.json()) as TaskSubmitResponse
|
return (await resp.json()) as TaskSubmitResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 提交多策略组合级 Walk-Forward 样本外验证后台任务(n_windows 默认 7)。 */
|
||||||
|
export async function submitMultiStrategyWalkforwardTask(
|
||||||
|
req: MultiStrategyBacktestRequest,
|
||||||
|
nWindows = 7,
|
||||||
|
): Promise<TaskSubmitResponse> {
|
||||||
|
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<TaskSubmitResponse> {
|
||||||
|
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。 */
|
/** 提交参数网格寻优后台任务,返回 task_id。 */
|
||||||
export async function submitOptimizeTask(
|
export async function submitOptimizeTask(
|
||||||
req: OptimizeBacktestRequest,
|
req: OptimizeBacktestRequest,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
submitOptimizeAllTask,
|
submitOptimizeAllTask,
|
||||||
submitOptimizeTask,
|
submitOptimizeTask,
|
||||||
submitMultiStrategyTask,
|
submitMultiStrategyTask,
|
||||||
|
submitMultiStrategyEvaluateTask,
|
||||||
|
submitMultiStrategyWalkforwardTask,
|
||||||
submitWalkforwardTask,
|
submitWalkforwardTask,
|
||||||
submitEvaluateTask,
|
submitEvaluateTask,
|
||||||
fetchTask,
|
fetchTask,
|
||||||
@@ -255,6 +257,8 @@ export const useBacktestStore = defineStore('backtest', () => {
|
|||||||
multiStrategyRunning.value = true
|
multiStrategyRunning.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
multiStrategyResult.value = null
|
multiStrategyResult.value = null
|
||||||
|
// 新一次组合回测开始时清掉上一轮的附加分析(WF/一条龙面板随主结果一起刷新)
|
||||||
|
clearMultiStrategyExtraAnalysis()
|
||||||
try {
|
try {
|
||||||
const { task_id } = await submitMultiStrategyTask(req)
|
const { task_id } = await submitMultiStrategyTask(req)
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
@@ -281,9 +285,66 @@ export const useBacktestStore = defineStore('backtest', () => {
|
|||||||
|
|
||||||
function clearMultiStrategy() {
|
function clearMultiStrategy() {
|
||||||
multiStrategyResult.value = null
|
multiStrategyResult.value = null
|
||||||
|
clearMultiStrategyExtraAnalysis()
|
||||||
error.value = ''
|
error.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 多策略组合附加分析:组合级 Walk-Forward / 一条龙评估 ──────────────────
|
||||||
|
const multiWfResult = ref<WalkForwardResult | null>(null)
|
||||||
|
const multiWfRunning = ref(false)
|
||||||
|
const multiWfError = ref<string>('')
|
||||||
|
const multiEvaluateResult = ref<EvaluateReport | null>(null)
|
||||||
|
const multiEvaluateRunning = ref(false)
|
||||||
|
const multiEvaluateError = ref<string>('')
|
||||||
|
|
||||||
|
/** 提交多策略组合级 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<EvaluateReport>(
|
||||||
|
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) ─────────────────────────────────────────────
|
// ── 参数网格寻优(Phase 4) ─────────────────────────────────────────────
|
||||||
const optimizeResult = ref<OptimizeResult | null>(null)
|
const optimizeResult = ref<OptimizeResult | null>(null)
|
||||||
const optimizeRunning = ref(false)
|
const optimizeRunning = ref(false)
|
||||||
@@ -385,6 +446,12 @@ export const useBacktestStore = defineStore('backtest', () => {
|
|||||||
portfolioEvaluateError,
|
portfolioEvaluateError,
|
||||||
multiStrategyResult,
|
multiStrategyResult,
|
||||||
multiStrategyRunning,
|
multiStrategyRunning,
|
||||||
|
multiWfResult,
|
||||||
|
multiWfRunning,
|
||||||
|
multiWfError,
|
||||||
|
multiEvaluateResult,
|
||||||
|
multiEvaluateRunning,
|
||||||
|
multiEvaluateError,
|
||||||
optimizeResult,
|
optimizeResult,
|
||||||
optimizeRunning,
|
optimizeRunning,
|
||||||
optimizeContext,
|
optimizeContext,
|
||||||
@@ -413,6 +480,9 @@ export const useBacktestStore = defineStore('backtest', () => {
|
|||||||
clearPortfolioExtraAnalysis,
|
clearPortfolioExtraAnalysis,
|
||||||
runMultiStrategy,
|
runMultiStrategy,
|
||||||
clearMultiStrategy,
|
clearMultiStrategy,
|
||||||
|
runMultiStrategyWalkforward,
|
||||||
|
runMultiStrategyEvaluate,
|
||||||
|
clearMultiStrategyExtraAnalysis,
|
||||||
runOptimize,
|
runOptimize,
|
||||||
runOptimizeAll,
|
runOptimizeAll,
|
||||||
setOptimizeContext,
|
setOptimizeContext,
|
||||||
|
|||||||
@@ -6,20 +6,24 @@
|
|||||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import AiInterpretModal from '../components/AiInterpretModal.vue'
|
||||||
import EquityChart from '../components/EquityChart.vue'
|
import EquityChart from '../components/EquityChart.vue'
|
||||||
|
import EvaluatePanel from '../components/EvaluatePanel.vue'
|
||||||
import GradeDetails from '../components/GradeDetails.vue'
|
import GradeDetails from '../components/GradeDetails.vue'
|
||||||
import MetricTable from '../components/MetricTable.vue'
|
import MetricTable from '../components/MetricTable.vue'
|
||||||
import PortfolioCompareChart from '../components/PortfolioCompareChart.vue'
|
import PortfolioCompareChart from '../components/PortfolioCompareChart.vue'
|
||||||
import PortfolioSummaryTable from '../components/PortfolioSummaryTable.vue'
|
import PortfolioSummaryTable from '../components/PortfolioSummaryTable.vue'
|
||||||
|
import WalkForwardPanel from '../components/WalkForwardPanel.vue'
|
||||||
import {
|
import {
|
||||||
deleteSavedStrategy,
|
deleteSavedStrategy,
|
||||||
fetchSavedStrategies,
|
fetchSavedStrategies,
|
||||||
formatError,
|
formatError,
|
||||||
saveStrategy,
|
saveStrategy,
|
||||||
} from '../api'
|
} from '../api'
|
||||||
import { gradePortfolio } from '../grading'
|
import { buildPortfolioAiPrompt } from '../aiPrompt'
|
||||||
|
import { GRADE_META, gradePortfolio } from '../grading'
|
||||||
import { detectMarket } from '../market'
|
import { detectMarket } from '../market'
|
||||||
import type { MultiStrategyItem, Performance, SavedStrategy } from '../types'
|
import type { Category, MultiStrategyItem, Performance, SavedStrategy } from '../types'
|
||||||
import { useBacktestStore } from '../stores/backtest'
|
import { useBacktestStore } from '../stores/backtest'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -380,8 +384,9 @@ const holdingViews = computed<HoldingView[]>(() =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 组合整体绩效(19 项指标)。后端 total_performance 现含完整指标,转成
|
// 组合整体绩效(25 项指标,与单标的 MetricTable 同口径)。后端
|
||||||
// MetricTable 需要的 Performance 类型(缺失字段补 0 兜底,保证渲染不崩)。
|
// total_performance 含完整指标,转成 MetricTable 需要的 Performance 类型
|
||||||
|
// (缺失字段补 0 兜底,保证老结果渲染不崩)。
|
||||||
const comboPerf = computed<Performance | null>(() => {
|
const comboPerf = computed<Performance | null>(() => {
|
||||||
const tp = store.multiStrategyResult?.total_performance
|
const tp = store.multiStrategyResult?.total_performance
|
||||||
if (!tp) return null
|
if (!tp) return null
|
||||||
@@ -409,6 +414,12 @@ const comboPerf = computed<Performance | null>(() => {
|
|||||||
max_loss: get('max_loss'),
|
max_loss: get('max_loss'),
|
||||||
avg_holding_days: get('avg_holding_days'),
|
avg_holding_days: get('avg_holding_days'),
|
||||||
volatility: get('volatility'),
|
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<Performance | null>(() => {
|
|||||||
const comboGrade = computed(() =>
|
const comboGrade = computed(() =>
|
||||||
store.multiStrategyResult ? gradePortfolio(store.multiStrategyResult) : null,
|
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(),
|
||||||
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -631,10 +705,30 @@ const comboGrade = computed(() =>
|
|||||||
· {{ store.multiStrategyResult.total_performance.total_stocks }} 个策略 ·
|
· {{ store.multiStrategyResult.total_performance.total_stocks }} 个策略 ·
|
||||||
总资金 {{ store.multiStrategyResult.total_performance.total_cash.toFixed(0) }}
|
总资金 {{ store.multiStrategyResult.total_performance.total_cash.toFixed(0) }}
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="store.multiStrategyResult && !store.multiStrategyRunning" class="combo-extra-actions">
|
||||||
|
<button
|
||||||
|
class="save-combo-btn"
|
||||||
|
:disabled="store.multiWfRunning"
|
||||||
|
title="按全部槽位日期并集切窗,每窗各策略独立回测后合成组合净值,检验跨时段稳定性"
|
||||||
|
@click="onComboWf"
|
||||||
|
>
|
||||||
|
{{ store.multiWfRunning ? 'WF 验证中…' : '🔬 WF 样本外验证' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="save-combo-btn"
|
||||||
|
:disabled="store.multiEvaluateRunning"
|
||||||
|
title="组合回测+组合WF+跨策略适配性体检+综合评分+等权买入持有基准对比,一份报告"
|
||||||
|
@click="onComboEvaluate"
|
||||||
|
>
|
||||||
|
{{ store.multiEvaluateRunning ? '评估中…' : '📋 一条龙评估' }}
|
||||||
|
</button>
|
||||||
|
<button class="save-combo-btn" @click="showAiModal = true">🤖 AI 解读</button>
|
||||||
|
<button class="save-combo-btn" @click="openSaveCombo">💾 保存为组合</button>
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
v-if="store.multiStrategyResult && !store.multiStrategyRunning"
|
v-else-if="store.multiStrategyResult && store.multiStrategyRunning"
|
||||||
class="save-combo-btn"
|
class="save-combo-btn"
|
||||||
@click="openSaveCombo"
|
disabled
|
||||||
>
|
>
|
||||||
💾 保存为组合
|
💾 保存为组合
|
||||||
</button>
|
</button>
|
||||||
@@ -674,6 +768,42 @@ const comboGrade = computed(() =>
|
|||||||
<EquityChart :equity="store.multiStrategyResult.combined_equity" />
|
<EquityChart :equity="store.multiStrategyResult.combined_equity" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 附加分析:组合级 Walk-Forward(与单标的/组合回测页同构面板) -->
|
||||||
|
<div
|
||||||
|
v-if="store.multiWfRunning || store.multiWfResult || store.multiWfError"
|
||||||
|
class="combo-chart-block"
|
||||||
|
>
|
||||||
|
<h4>Walk-Forward 样本外验证</h4>
|
||||||
|
<p v-if="store.multiWfRunning" class="empty-text">
|
||||||
|
验证中…(策略数 × 窗口数 次回测,约需十几秒)
|
||||||
|
</p>
|
||||||
|
<div v-else-if="store.multiWfError" class="warn-box">
|
||||||
|
⚠ {{ store.multiWfError }}
|
||||||
|
</div>
|
||||||
|
<WalkForwardPanel v-else-if="store.multiWfResult" :wf="store.multiWfResult" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 附加分析:组合级一条龙评估(与单标的/组合回测页同构面板) -->
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
store.multiEvaluateRunning || store.multiEvaluateResult || store.multiEvaluateError
|
||||||
|
"
|
||||||
|
class="combo-chart-block"
|
||||||
|
>
|
||||||
|
<h4>一条龙评估</h4>
|
||||||
|
<p v-if="store.multiEvaluateRunning" class="empty-text">
|
||||||
|
评估中…(组合回测 + 组合WF + 跨策略适配性 + 基准对比,可能需要一两分钟)
|
||||||
|
</p>
|
||||||
|
<div v-else-if="store.multiEvaluateError" class="warn-box">
|
||||||
|
⚠ {{ store.multiEvaluateError }}
|
||||||
|
</div>
|
||||||
|
<EvaluatePanel
|
||||||
|
v-else-if="store.multiEvaluateResult"
|
||||||
|
:report="store.multiEvaluateResult"
|
||||||
|
:grade-override="comboGrade"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="comboPerf" class="combo-chart-block">
|
<div v-if="comboPerf" class="combo-chart-block">
|
||||||
<h4>绩效指标</h4>
|
<h4>绩效指标</h4>
|
||||||
<MetricTable :perf="comboPerf" />
|
<MetricTable :perf="comboPerf" />
|
||||||
@@ -747,6 +877,16 @@ const comboGrade = computed(() =>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- AI 解读 Prompt 对话框(单标的/组合/策略组合通用组件) -->
|
||||||
|
<AiInterpretModal
|
||||||
|
v-if="showAiModal && store.multiStrategyResult"
|
||||||
|
:prompt="aiPromptText"
|
||||||
|
:filename="`AI解读_${comboStrategyLabel}.md`"
|
||||||
|
:context="aiContext"
|
||||||
|
tip="建议先点「WF 样本外验证」「一条龙评估」,跑完再打开此弹窗,数据会一并打包。"
|
||||||
|
@close="showAiModal = false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1184,6 +1324,22 @@ const comboGrade = computed(() =>
|
|||||||
.save-combo-btn:hover {
|
.save-combo-btn:hover {
|
||||||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
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 {
|
.warn-box {
|
||||||
|
|||||||
Reference in New Issue
Block a user