-**回测可视化 Web UI**(v1.17 新增)——Vue3 + ECharts 单页应用,浏览器里选标的、挑策略、调参数,K 线买卖点、净值回撤、19 项绩效指标一目了然。支持组合回测、参数网格寻优、多策略结果对比,**还能把好策略存进策略库(SQLite 持久化),勾选多个策略做资金分仓组合回测看综合表现**,全程零代码。v1.27 起新增「附加分析」开关:勾选后随回测自动跑 Walk-Forward 逐窗柱状图与一条龙评估报告(评分分项 / 高适配徽标 / 买入持有对比)。
+**回测可视化 Web UI**(v1.17 新增)——Vue3 + ECharts 单页应用,浏览器里选标的、挑策略、调参数,K 线买卖点、净值回撤、25 项绩效指标一目了然。支持组合回测、参数网格寻优、多策略结果对比,**还能把好策略存进策略库(SQLite 持久化),勾选多个策略做资金分仓组合回测看综合表现**,全程零代码。v1.27 起新增「附加分析」开关:勾选后随回测自动跑 Walk-Forward 逐窗柱状图与一条龙评估报告(评分分项 / 高适配徽标 / 买入持有对比)。
**数据评级系统**(v1.17.14 新增)——回测结果顶部直接显示 **S/A/B/C/D 五档评级徽章**,1 秒判断「这个品种适不适合经常参与」。评级**不看收益率**(避免被近期大涨误导),只看风险调整后的持有体验:卡玛比率、最大回撤、胜率、利润因子、夏普、波动率六维加权 + 一票否决(系统亏损/深回撤/低胜率直接低评)。京东方那种「收益 126% 但胜率 35%、回撤 41%」的案例会评 **D 档**——明确告诉普通人「别碰,套牢后回本极难」。三个入口(单标的/组合/寻优)都有评级,长线低频策略不会被冤枉(交易少时只降权胜率维度,不否决整个评级)。
@@ -508,7 +508,7 @@ Web UI 包含两大模块:
- **市场看板**:五大指数实时行情(SSE 推送)、全市场涨跌统计(涨/跌/平/涨停/跌停 + 堆叠条)、行业/概念板块热度榜、涨幅榜/跌幅榜、两市异动雷达(加速拉升/封涨停板/大单托盘等),点击个股打开五档盘口 + 分时/日K 对话框;
- **自选行情**:输入 6 位代码一键加自选(SQLite 持久化),全表实时刷新(SSE),行内迷你分时图,点击行看个股详情;
- **实时推送架构**:后端单条轮询循环 fan-out 到所有 SSE 连接(交易时段 ~8s 一拍,盘外降频 60s,无人订阅自动休眠),前端指数退避重连。
-- **回测工作台(v1.17 起)**——浏览器里选标的、挑策略、调参数,K 线买卖点、净值回撤、19 项绩效指标一目了然。支持组合回测、参数网格寻优、多策略结果对比,还能把好策略存进策略库(SQLite 持久化),勾选多个策略做资金分仓组合回测看综合表现,全程零代码。
+- **回测工作台(v1.17 起)**——浏览器里选标的、挑策略、调参数,K 线买卖点、净值回撤、25 项绩效指标一目了然。支持组合回测、参数网格寻优、多策略结果对比,还能把好策略存进策略库(SQLite 持久化),勾选多个策略做资金分仓组合回测看综合表现,全程零代码。
**前置条件:**
@@ -549,7 +549,7 @@ EXE 打包方法见 [`docs/packaging.md`](./docs/packaging.md)。
- **取行情**:选市场(深/沪/北),填 6 位代码,选周期(日线/周线/分钟线),设日期范围(默认最近 3 年),点「取行情」。超过 800 根会自动翻页拼接
- **选策略**:下拉选 18 个内置策略之一(双均线交叉、MACD、布林带、RSI、KDJ、唐安奇通道、CCI 等),选中后参数表单自动出现,按推荐范围调参
- **资金与成本**:初始资金、佣金率、滑点、成交模式(默认 next_open 下一根开盘成交)
-- 点「开始回测」,右侧依次出:K 线主图(红三角=买入、绿钉=卖出)、净值曲线与回撤双轴图、19 项绩效指标表(总收益/夏普/最大回撤/胜率/盈亏比等)、成交记录明细
+- 点「开始回测」,右侧依次出:K 线主图(红三角=买入、绿钉=卖出)、净值曲线与回撤双轴图、25 项绩效指标表(总收益/夏普/最大回撤/胜率/盈亏比/Ulcer/VaR/SQN 等)、成交记录明细
- 结果区右上角有「💾 保存策略」按钮,把当前策略 + 标的 + 成绩快照存进策略库,下次直接载入或参与组合回测
**2. 组合回测**(`/portfolio`)
@@ -575,7 +575,7 @@ EXE 打包方法见 [`docs/packaging.md`](./docs/packaging.md)。
- 保存你觉得不错的策略,下次直接载入或重跑。数据存在本地 SQLite 单文件(`~/.easy_tdx/strategies.db`,重启不丢)
- 每张卡片展示策略名、标的、保存时的成绩快照(总收益/夏普/回撤)、标签、备注、创建时间
- **载入**:点「载入」跳转对应回测页(单标的/组合),自动回填标的、日期、策略参数,可直接重跑
-- **多策略组合回测**:勾选多个单标的策略(卡片左上角复选框),点顶部「组合回测(N)」——每个策略各拿 1/N 资金、各跑在它保存时的原标的上(取最新行情),净值曲线按日期对齐求和,看综合表现。结果区展示:组合净值曲线、19 项完整绩效指标(与单标的同口径)、各策略绩效对比表、净值叠加图、各策略当前持仓表(回测结束时谁还套着票)
+- **多策略组合回测**:勾选多个单标的策略(卡片左上角复选框),点顶部「组合回测(N)」——每个策略各拿 1/N 资金、各跑在它保存时的原标的上(取最新行情),净值曲线按日期对齐求和,看综合表现。结果区展示:组合净值曲线、25 项完整绩效指标(与单标的同口径)、各策略绩效对比表、净值叠加图、各策略当前持仓表(回测结束时谁还套着票)
> ⚠️ **任务不持久化**:回测结果存在后端进程内存,重启 `easy-tdx serve` 后清空。对比页只能选当前运行期间产生的任务。**策略库除外**——保存到策略库的策略持久存在 SQLite,重启不丢。
diff --git a/pyproject.toml b/pyproject.toml
index ae07c1b..ae9d98d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "easy-tdx"
-version = "1.27.2"
+version = "1.28.0"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md"
requires-python = ">=3.10"
diff --git a/src/easy_tdx/backtest/benchmark.py b/src/easy_tdx/backtest/benchmark.py
index 0f3268c..367d4ae 100644
--- a/src/easy_tdx/backtest/benchmark.py
+++ b/src/easy_tdx/backtest/benchmark.py
@@ -15,7 +15,9 @@
"fitness": {"pass_ratio": 0.875, "high_fitness": true, "checks": [...]},
"benchmark": {
"buy_hold": {"total_return": 0.32, ...},
- "excess_return": 0.18 # 策略 - 买入持有
+ "excess_return": 0.18, # 策略 - 买入持有
+ "alpha": 0.09, "beta": 0.72, # v1.28:CAPM 对比
+ "information_ratio": 0.85, "tracking_error": 0.12 # v1.28:主动管理指标
},
"config": {...}
}
@@ -27,8 +29,9 @@
from __future__ import annotations
-from typing import Any
+from typing import TYPE_CHECKING, Any
+import numpy as np
import pandas as pd
from easy_tdx.backtest.engine import BacktestEngine
@@ -39,7 +42,16 @@ from easy_tdx.backtest.strategy import Strategy
from easy_tdx.backtest.types import to_json_native
from easy_tdx.backtest.walkforward import WalkForwardEngine
-__all__ = ["evaluate_strategy", "run_buy_hold_benchmark"]
+if TYPE_CHECKING:
+ import numpy.typing as npt
+
+ from easy_tdx.backtest.types import BacktestResult
+
+ NDArray = npt.NDArray[np.float64]
+else:
+ NDArray = np.ndarray
+
+__all__ = ["evaluate_strategy", "run_buy_hold_benchmark", "compute_benchmark_comparison"]
class _BuyAndHold(Strategy):
@@ -54,6 +66,32 @@ class _BuyAndHold(Strategy):
self._bought = True
+def _run_buy_hold_result(
+ df: pd.DataFrame,
+ cash: float = 100000.0,
+ commission: float = 0.0003,
+ min_commission: float = 5.0,
+ stamp_tax: float = 0.001,
+ slippage: float = 0.0,
+ execution: str = "next_open",
+ symbol: str | None = None,
+ auto_fees: bool = False,
+) -> BacktestResult:
+ """买入持有基准完整回测(内部用,返回 BacktestResult 以取资金曲线)。"""
+ engine = BacktestEngine(
+ strategy=_BuyAndHold,
+ cash=cash,
+ commission=commission,
+ min_commission=min_commission,
+ stamp_tax=stamp_tax,
+ slippage=slippage,
+ execution=execution,
+ symbol=symbol,
+ auto_fees=auto_fees,
+ )
+ return engine.run(df)
+
+
def run_buy_hold_benchmark(
df: pd.DataFrame,
cash: float = 100000.0,
@@ -66,18 +104,9 @@ def run_buy_hold_benchmark(
auto_fees: bool = False,
) -> dict[str, Any]:
"""买入持有基准回测(与策略回测同区间、同费率、同资金)。"""
- engine = BacktestEngine(
- strategy=_BuyAndHold,
- cash=cash,
- commission=commission,
- min_commission=min_commission,
- stamp_tax=stamp_tax,
- slippage=slippage,
- execution=execution,
- symbol=symbol,
- auto_fees=auto_fees,
+ result = _run_buy_hold_result(
+ df, cash, commission, min_commission, stamp_tax, slippage, execution, symbol, auto_fees
)
- result = engine.run(df)
keys = (
"total_return",
"annual_return",
@@ -89,6 +118,77 @@ def run_buy_hold_benchmark(
return dict(to_json_native({k: result.performance.get(k, 0.0) for k in keys}))
+def compute_benchmark_comparison(
+ strategy_curve: pd.DataFrame,
+ benchmark_curve: pd.DataFrame,
+ annual_days: int = 252,
+) -> dict[str, float]:
+ """策略 vs 基准的 CAPM / 主动管理对比指标(v1.28 新增)。
+
+ 从两条资金曲线的日收益率序列计算:
+
+ - ``beta``: 协方差/基准方差,策略对基准的敏感度(1 = 与基准同涨跌)
+ - ``alpha``: 年化 CAPM α ≈ (策略日均收益 − β×基准日均收益) × 年化天数,
+ 简化版(无风险利率并入截距),>0 说明剔除基准影响后仍有超额
+ - ``information_ratio``: 年化信息比率 = mean(策略−基准)/std(策略−基准)×√N,
+ 每 1 单位跟踪误差换来多少超额收益
+ - ``tracking_error``: 年化跟踪误差 = std(策略−基准)×√N
+
+ 两条曲线按 bar 对齐(截取较短长度);基准方差为 0(曲线恒定)时
+ beta/alpha 记 0,IR 在差值恒正且无波动时沿用 999 上限约定。
+
+ Args:
+ strategy_curve: 策略资金曲线(含 total 列)
+ benchmark_curve: 基准资金曲线(含 total 列)
+ annual_days: 年化交易日数
+
+ Returns:
+ {alpha, beta, information_ratio, tracking_error}
+ """
+ s_total = strategy_curve["total"].to_numpy(dtype=np.float64)
+ b_total = benchmark_curve["total"].to_numpy(dtype=np.float64)
+ n = min(len(s_total), len(b_total))
+ if n < 3:
+ return {"alpha": 0.0, "beta": 0.0, "information_ratio": 0.0, "tracking_error": 0.0}
+
+ def _daily_ret(total: NDArray) -> NDArray:
+ safe_prev = np.where(total[:-1] != 0, total[:-1], np.nan)
+ ret = np.diff(total) / safe_prev
+ return ret[np.isfinite(ret)]
+
+ s_ret = _daily_ret(s_total[:n])
+ b_ret = _daily_ret(b_total[:n])
+ m = min(len(s_ret), len(b_ret))
+ if m < 2:
+ return {"alpha": 0.0, "beta": 0.0, "information_ratio": 0.0, "tracking_error": 0.0}
+ s_ret, b_ret = s_ret[:m], b_ret[:m]
+
+ b_var = float(np.var(b_ret))
+ if b_var > 1e-18:
+ beta = float(np.cov(s_ret, b_ret)[0, 1] / b_var)
+ alpha = float((np.mean(s_ret) - beta * np.mean(b_ret)) * annual_days)
+ else:
+ beta = 0.0
+ alpha = float(np.mean(s_ret) * annual_days)
+
+ diff = s_ret - b_ret
+ diff_std = float(np.std(diff))
+ if diff_std > 1e-12:
+ information_ratio = float(np.mean(diff) / diff_std * np.sqrt(annual_days))
+ elif np.mean(diff) > 0:
+ information_ratio = 999.0
+ else:
+ information_ratio = 0.0
+ tracking_error = diff_std * np.sqrt(annual_days)
+
+ return {
+ "alpha": alpha,
+ "beta": beta,
+ "information_ratio": information_ratio,
+ "tracking_error": tracking_error,
+ }
+
+
def evaluate_strategy(
strategy: type[Strategy] | Strategy,
df: pd.DataFrame,
@@ -153,8 +253,11 @@ def evaluate_strategy(
score = score_strategy(perf, wf=wf)
grade = grade_performance(perf)
- # 5. 基准对比(买入持有,同区间同费率)
- bh = run_buy_hold_benchmark(df, **engine_kwargs)
+ # 5. 基准对比(买入持有,同区间同费率):超额收益 + Alpha/Beta/IR/TE
+ bh_result = _run_buy_hold_result(df, **engine_kwargs)
+ bh_keys = ("total_return", "annual_return", "max_drawdown", "sharpe", "calmar", "volatility")
+ bh = dict(to_json_native({k: bh_result.performance.get(k, 0.0) for k in bh_keys}))
+ comparison = compute_benchmark_comparison(bt.equity_curve, bh_result.equity_curve)
return {
"performance": to_json_native(dict(perf)),
@@ -166,6 +269,7 @@ def evaluate_strategy(
"buy_hold": bh,
"excess_return": float(perf.get("total_return", 0.0))
- float(bh.get("total_return", 0.0)),
+ **comparison,
},
"config": {
"symbol": symbol,
diff --git a/src/easy_tdx/backtest/cli.py b/src/easy_tdx/backtest/cli.py
index ff0e846..7f3b2bf 100644
--- a/src/easy_tdx/backtest/cli.py
+++ b/src/easy_tdx/backtest/cli.py
@@ -318,6 +318,16 @@ def _print_table(result: Any) -> None:
click.echo(f"夏普比率: {perf.get('sharpe', 0):.2f}")
click.echo(f"胜率: {perf.get('win_rate', 0):.2%}")
click.echo(f"交易次数: {perf.get('total_trades', 0)}")
+ # 深度风险指标(v1.28 新增;老结果缺键时跳过,不输出 0 假值)
+ if perf.get("ulcer_index") is not None:
+ click.echo(f"Ulcer 指数: {perf.get('ulcer_index', 0):.4f}")
+ click.echo(f"日 VaR(95%): {perf.get('var_95', 0):.2%}")
+ click.echo(f"日 CVaR(95%): {perf.get('cvar_95', 0):.2%}")
+ click.echo(f"SQN 系统质量: {perf.get('sqn', 0):.2f}")
+ click.echo(
+ f"最大连胜/连亏: {perf.get('max_consecutive_wins', 0)} / "
+ f"{perf.get('max_consecutive_losses', 0)}"
+ )
click.echo()
if getattr(result, "diagnostic", None):
diff --git a/src/easy_tdx/backtest/engine.py b/src/easy_tdx/backtest/engine.py
index 2d5847c..3e70f71 100644
--- a/src/easy_tdx/backtest/engine.py
+++ b/src/easy_tdx/backtest/engine.py
@@ -25,13 +25,32 @@ if TYPE_CHECKING:
class _StopCondition:
"""Active stop-loss / take-profit condition tied to an open position.
+ 三条退出线构成 OCO:任一触发即整体失效(见 ``_check_stop_conditions``)。
+
Attributes:
stop_loss: Price below which a SELL is triggered (None = disabled)
take_profit: Price above which a SELL is triggered (None = disabled)
+ trail_stop: Trailing stop percent (e.g. 0.08 = 8% below the highest
+ close since entry, None = disabled). Fixed ``stop_loss`` wins when
+ both are set.
+ high_watermark: Highest close seen since the BUY (trailing reference).
+ Updated at the END of each bar (after the trigger check), so a
+ trailing stop can only fire from the NEXT bar onward — consistent
+ with next_open execution semantics.
"""
stop_loss: float | None
take_profit: float | None
+ trail_stop: float | None = None
+ high_watermark: float = 0.0
+
+ def effective_stop(self) -> float | None:
+ """当前生效的止损价(固定价优先,其次移动止损;均无则 None)。"""
+ if self.stop_loss is not None:
+ return self.stop_loss
+ if self.trail_stop is not None:
+ return self.high_watermark * (1.0 - self.trail_stop)
+ return None
class BacktestEngine:
@@ -340,10 +359,17 @@ class BacktestEngine:
# activate on the NEXT bar — consistent with next_open execution)
for sig in bar_signals:
if sig.direction == "BUY" and (
- sig.stop_loss is not None or sig.take_profit is not None
+ sig.stop_loss is not None
+ or sig.take_profit is not None
+ or sig.trail_stop is not None
):
active_stops.append(
- _StopCondition(stop_loss=sig.stop_loss, take_profit=sig.take_profit)
+ _StopCondition(
+ stop_loss=sig.stop_loss,
+ take_profit=sig.take_profit,
+ trail_stop=sig.trail_stop,
+ high_watermark=close_arr[i],
+ )
)
# Clear conditions when a SELL occurs (strategy or SL/TP triggered)
@@ -370,8 +396,11 @@ class BacktestEngine:
"""Check active SL/TP conditions against current bar's price range.
If triggered, generates a SELL signal at the trigger price and removes
- the condition. Stop-loss is checked first (conservative: assume the
- worst case for the holder).
+ the condition (OCO: all remaining legs of the same condition die too).
+ Stop-loss is checked first (conservative: assume the worst case for
+ the holder). Trailing stops reference the highest close seen through
+ the PREVIOUS bar (watermark is updated after the check), so they can
+ never fire on the same bar that sets a new high.
Args:
active_stops: List of active stop conditions
@@ -394,16 +423,22 @@ class BacktestEngine:
triggered = False
trigger_price = 0.0
- # Check stop-loss first (worst case for holder)
- if cond.stop_loss is not None and bar_low <= cond.stop_loss:
+ # Check stop-loss first (worst case for holder); trailing resolves
+ # to its effective price, fixed stop_loss wins if both set
+ eff_stop = cond.effective_stop()
+ if eff_stop is not None and bar_low <= eff_stop:
triggered = True
- trigger_price = cond.stop_loss
+ trigger_price = eff_stop
# Then check take-profit
elif cond.take_profit is not None and bar_high >= cond.take_profit:
triggered = True
trigger_price = cond.take_profit
- if triggered:
+ if not triggered:
+ # Trailing watermark update AFTER the check (close-based)
+ cond.high_watermark = max(cond.high_watermark, bar_close)
+ remaining.append(cond)
+ else:
# Get datetime for this bar
dt_val = df["datetime"].iloc[bar_index]
if hasattr(dt_val, "strftime"):
@@ -420,8 +455,6 @@ class BacktestEngine:
source="stop", # 标记为止损/止盈触发,延迟到下一根成交
)
)
- else:
- remaining.append(cond)
active_stops.clear()
active_stops.extend(remaining)
diff --git a/src/easy_tdx/backtest/performance.py b/src/easy_tdx/backtest/performance.py
index 5bf05ac..8eb2827 100644
--- a/src/easy_tdx/backtest/performance.py
+++ b/src/easy_tdx/backtest/performance.py
@@ -22,7 +22,8 @@ else:
class PerformanceAnalyzer:
"""绩效分析器。
- 从资金曲线和交易记录计算 19 项绩效指标。
+ 从资金曲线和交易记录计算 25 项绩效指标(19 项经典指标 + 6 项
+ 深度风险指标:Ulcer / VaR / CVaR / SQN / 最大连胜连亏,v1.28 新增)。
Attributes:
ANNUAL_DAYS: 年化交易日数(默认 252)
@@ -55,7 +56,7 @@ class PerformanceAnalyzer:
"""计算绩效指标。
Returns:
- 包含 19 项指标的字典:
+ 包含 25 项指标的字典:
- total_return: 总收益率
- annual_return: 年化收益率
- max_drawdown: 最大回撤
@@ -75,6 +76,14 @@ class PerformanceAnalyzer:
- max_loss: 最大亏损
- avg_holding_days: 平均持仓天数(FIFO 配对、按 size 加权,日历日口径)
- volatility: 年化波动率
+ - ulcer_index: Ulcer 指数(回撤深度平方均值的开方,综合反映
+ 回撤深度与持续时间,越小持有体验越好)
+ - var_95: 95% 日 VaR(历史分位数法,正数表示单日最大损失幅度)
+ - cvar_95: 95% 日 CVaR / 期望损失(尾部 5% 日收益均值,正数)
+ - sqn: 系统质量数(Van Tharp SQN = √N × 单笔收益率均值/标准差,
+ >2 可用、>4 优秀、>6 极佳的经验分档)
+ - max_consecutive_wins: 最大连胜笔数(按 SELL 成交顺序统计)
+ - max_consecutive_losses: 最大连亏笔数
"""
# 边界检查
if len(self._equity_curve) < 2:
@@ -211,6 +220,28 @@ class PerformanceAnalyzer:
# 19. 年化波动率
volatility = np.std(daily_ret) * np.sqrt(self.ANNUAL_DAYS)
+ # 20. Ulcer 指数(Martin:√(mean(回撤幅度²)),深度与持续时间加权)
+ ulcer_index = float(np.sqrt(np.mean(drawdown_pct**2)))
+
+ # 21. 95% 日 VaR(历史分位数法;正数表示损失幅度,便于直觉解读)
+ var_95 = float(-np.percentile(daily_ret, 5))
+
+ # 22. 95% 日 CVaR(VaR 之外尾部收益的均值;样本不足时退化为 VaR)
+ tail = daily_ret[daily_ret <= -var_95]
+ cvar_95 = float(-np.mean(tail)) if len(tail) > 0 else var_95
+
+ # 23. SQN 系统质量数(√N × 单笔收益率均值 / 标准差)
+ valid_tr = trade_returns[np.isfinite(trade_returns)]
+ if len(valid_tr) >= 2 and np.std(valid_tr) > 1e-12:
+ sqn = float(np.sqrt(len(valid_tr)) * np.mean(valid_tr) / np.std(valid_tr))
+ else:
+ sqn = 0.0
+
+ # 24/25. 最大连胜/连亏(与 win_rate 同口径:按 SELL 成交顺序)
+ max_consecutive_wins, max_consecutive_losses = self._max_win_lose_streaks(
+ sell_trades["pnl"].to_numpy(dtype=np.float64)
+ )
+
return {
"total_return": total_return,
"annual_return": annual_return,
@@ -231,6 +262,12 @@ class PerformanceAnalyzer:
"max_loss": max_loss,
"avg_holding_days": avg_holding_days,
"volatility": volatility,
+ "ulcer_index": ulcer_index,
+ "var_95": var_95,
+ "cvar_95": cvar_95,
+ "sqn": sqn,
+ "max_consecutive_wins": max_consecutive_wins,
+ "max_consecutive_losses": max_consecutive_losses,
# 别名键(兼容常见叫法,避免 .get('sharpe_ratio') 等误用返回 0)
"sharpe_ratio": sharpe,
"start_cash": float(total[0]),
@@ -371,7 +408,37 @@ class PerformanceAnalyzer:
"max_loss": 0.0,
"avg_holding_days": 0.0,
"volatility": 0.0,
+ "ulcer_index": 0.0,
+ "var_95": 0.0,
+ "cvar_95": 0.0,
+ "sqn": 0.0,
+ "max_consecutive_wins": 0,
+ "max_consecutive_losses": 0,
"sharpe_ratio": 0.0,
"start_cash": 0.0,
"end_value": 0.0,
}
+
+ @staticmethod
+ def _max_win_lose_streaks(pnl_seq: NDArray) -> tuple[int, int]:
+ """按成交顺序统计最大连胜/连亏笔数。
+
+ pnl > 0 记为胜,pnl <= 0 记为负(与 win_rate 的胜/负口径一致)。
+
+ Args:
+ pnl_seq: SELL 成交的 pnl 序列(时间升序)
+
+ Returns:
+ (最大连胜笔数, 最大连亏笔数)
+ """
+ max_wins = max_losses = cur_wins = cur_losses = 0
+ for pnl in pnl_seq:
+ if pnl > 0:
+ cur_wins += 1
+ cur_losses = 0
+ max_wins = max(max_wins, cur_wins)
+ else:
+ cur_losses += 1
+ cur_wins = 0
+ max_losses = max(max_losses, cur_losses)
+ return int(max_wins), int(max_losses)
diff --git a/src/easy_tdx/backtest/strategy.py b/src/easy_tdx/backtest/strategy.py
index dbb36d0..b18dd1c 100644
--- a/src/easy_tdx/backtest/strategy.py
+++ b/src/easy_tdx/backtest/strategy.py
@@ -307,20 +307,44 @@ class Strategy(ABC):
price: float | None = None,
stop_loss: float | None = None,
take_profit: float | None = None,
+ trail_stop: float | None = None,
+ stop_loss_pct: float | None = None,
+ take_profit_pct: float | None = None,
) -> None:
- """生成买入信号。
+ """生成买入信号(可携带 bracket 止损/止盈/移动止损,OCO 联动)。
+
+ akquant ``place_bracket`` 风格:进出场一体化,不必再手写止损监控。
+ 三条退出线任一触发即全部失效(OCO),由引擎逐 bar 监控并自动
+ 生成 SELL(``source="stop"``,延迟到下一根开盘成交,消除前视偏差)。
Args:
size: 交易数量(0 = 全仓,由引擎计算)
price: 限价(None = 市价单)
- stop_loss: 止损价(None = 不设置)
- take_profit: 止盈价(None = 不设置)
+ stop_loss: 止损价(绝对价;与 stop_loss_pct 同时给时绝对价优先)
+ take_profit: 止盈价(绝对价;与 take_profit_pct 同时给时绝对价优先)
+ trail_stop: 移动止损百分比(如 0.08 = 自持仓期间最高收盘价
+ 回撤 8% 触发)。固定 stop_loss 优先于移动止损。
+ stop_loss_pct: 止损百分比(相对当前收盘价,如 0.05 = 跌 5% 止损)
+ take_profit_pct: 止盈百分比(相对当前收盘价,如 0.10 = 涨 10% 止盈)
+
+ Examples:
+ >>> # 买入并带 5% 止损 / 10% 止盈(自动换算价格)
+ ... self.buy(stop_loss_pct=0.05, take_profit_pct=0.10)
+ >>> # 买入并带 8% 移动止损(涨得越多止损线跟得越高)
+ ... self.buy(trail_stop=0.08)
"""
if self._data_proxy is None:
raise RuntimeError("策略未绑定数据,请先调用 _bind_data()")
if self._datetime_array is None:
raise RuntimeError("数据未正确初始化")
+ # 百分比便捷参数 → 绝对价(显式绝对价优先)
+ ref_price = price if price is not None else float(self.data.close[0])
+ if stop_loss is None and stop_loss_pct is not None:
+ stop_loss = ref_price * (1.0 - stop_loss_pct)
+ if take_profit is None and take_profit_pct is not None:
+ take_profit = ref_price * (1.0 + take_profit_pct)
+
signal = Signal(
datetime=int(self._datetime_array[self._bar_index]),
direction="BUY",
@@ -328,6 +352,7 @@ class Strategy(ABC):
price=price,
stop_loss=stop_loss,
take_profit=take_profit,
+ trail_stop=trail_stop,
)
self._signals.append(signal)
diff --git a/src/easy_tdx/backtest/types.py b/src/easy_tdx/backtest/types.py
index fcbb9df..816c485 100644
--- a/src/easy_tdx/backtest/types.py
+++ b/src/easy_tdx/backtest/types.py
@@ -25,9 +25,14 @@ class Signal:
price: 限价(None = 市价单)
stop_loss: 止损价(None = 不设置)
take_profit: 止盈价(None = 不设置)
+ trail_stop: 移动止损百分比(如 0.08 = 自持仓期间最高收盘价回撤
+ 8% 触发,None = 不设置)。与 ``stop_loss`` 同时设置时固定价优先。
source: 信号来源。"strategy"=策略产生(默认);
"stop"=止损/止盈触发。stop 来源的信号不在信号 bar 当根成交,
而是延迟到下一根开盘(消除用当根 intrabar 触发价成交的前视偏差)。
+
+ ``stop_loss`` / ``take_profit`` / ``trail_stop`` 三者构成 OCO
+ (one-cancels-other):任一触发即全部失效,由引擎逐 bar 监控。
"""
datetime: int
@@ -36,6 +41,7 @@ class Signal:
price: float | None = None
stop_loss: float | None = None
take_profit: float | None = None
+ trail_stop: float | None = None
source: str = "strategy"
diff --git a/tests/golden/backtest_metrics.json b/tests/golden/backtest_metrics.json
new file mode 100644
index 0000000..088cd88
--- /dev/null
+++ b/tests/golden/backtest_metrics.json
@@ -0,0 +1,341 @@
+{
+ "benchmark_comparison": {
+ "alpha": -0.07509968942791662,
+ "beta": 0.52345815483129,
+ "information_ratio": -0.4168372094924506,
+ "tracking_error": 0.1915582998133785
+ },
+ "buy_hold": {
+ "annual_return": -0.02751052343019722,
+ "calmar": -0.10790264140303352,
+ "max_drawdown": 0.2549569044138692,
+ "sharpe": -0.07276274597183686,
+ "total_return": -0.043207472350363485,
+ "volatility": 0.2753412305613209
+ },
+ "meta": {
+ "bars": 400,
+ "cash": 100000.0,
+ "note": "regen: EASY_TDX_REGEN_GOLDEN=1 pytest tests/unit/test_golden_backtest.py",
+ "seed": 20260902,
+ "tolerance": {
+ "abs": 1e-06,
+ "rel": 1e-06
+ }
+ },
+ "rules": {
+ "bracket_pct": {
+ "total_return": 0.06621316999999993,
+ "total_trades": 1,
+ "trades": [
+ [
+ "BUY",
+ 10.3,
+ 20240102
+ ],
+ [
+ "SELL",
+ 11.0,
+ 20240105
+ ]
+ ]
+ },
+ "stop_loss": {
+ "total_return": -0.11904648000000007,
+ "total_trades": 1,
+ "trades": [
+ [
+ "BUY",
+ 10.2,
+ 20240102
+ ],
+ [
+ "SELL",
+ 9.0,
+ 20240108
+ ]
+ ]
+ },
+ "take_profit": {
+ "total_return": 0.06621316999999993,
+ "total_trades": 1,
+ "trades": [
+ [
+ "BUY",
+ 10.3,
+ 20240102
+ ],
+ [
+ "SELL",
+ 11.0,
+ 20240105
+ ]
+ ]
+ },
+ "trailing_stop": {
+ "total_return": 0.027762419999999954,
+ "total_trades": 1,
+ "trades": [
+ [
+ "BUY",
+ 10.2,
+ 20240102
+ ],
+ [
+ "SELL",
+ 10.5,
+ 20240112
+ ]
+ ]
+ }
+ },
+ "strategies": {
+ "atr_breakout": {
+ "cvar_95": 0.029816014464276553,
+ "max_consecutive_losses": 2,
+ "max_consecutive_wins": 1,
+ "max_drawdown": 0.2338437267295321,
+ "sharpe": -0.1980068530470557,
+ "sqn": -0.2643143297795612,
+ "total_return": -0.051365472018247815,
+ "total_trades": 5,
+ "ulcer_index": 0.11610199605370222,
+ "var_95": 0.023167064579450905,
+ "win_rate": 0.2
+ },
+ "bbi": {
+ "cvar_95": 0.028403780640227493,
+ "max_consecutive_losses": 11,
+ "max_consecutive_wins": 6,
+ "max_drawdown": 0.17316305538486726,
+ "sharpe": -0.047920869432565044,
+ "sqn": 0.2034947102900509,
+ "total_return": 0.0015367014340572638,
+ "total_trades": 37,
+ "ulcer_index": 0.09154821119005127,
+ "var_95": 0.02185464362709669,
+ "win_rate": 0.43243243243243246
+ },
+ "bias_reversal": {
+ "cvar_95": 0.031800618199536355,
+ "max_consecutive_losses": 4,
+ "max_consecutive_wins": 6,
+ "max_drawdown": 0.28538313496863427,
+ "sharpe": -0.8098413586757496,
+ "sqn": -0.6927111806707453,
+ "total_return": -0.22014868093324402,
+ "total_trades": 45,
+ "ulcer_index": 0.1949515199887598,
+ "var_95": 0.02439437484336824,
+ "win_rate": 0.5111111111111111
+ },
+ "boll_breakout": {
+ "cvar_95": 0.025123143703972416,
+ "max_consecutive_losses": 1,
+ "max_consecutive_wins": 2,
+ "max_drawdown": 0.12529045393048568,
+ "sharpe": -0.08270429911478913,
+ "sqn": 0.984709712259246,
+ "total_return": 0.00949379410277551,
+ "total_trades": 3,
+ "ulcer_index": 0.039203351555811915,
+ "var_95": 0.015350573492733575,
+ "win_rate": 0.6666666666666666
+ },
+ "cci": {
+ "cvar_95": 0.028065506761634905,
+ "max_consecutive_losses": 1,
+ "max_consecutive_wins": 3,
+ "max_drawdown": 0.17514368495494725,
+ "sharpe": -0.4733373193054346,
+ "sqn": -0.33416732892726264,
+ "total_return": -0.1047789450922677,
+ "total_trades": 11,
+ "ulcer_index": 0.0895732932132124,
+ "var_95": 0.019114854583397116,
+ "win_rate": 0.6363636363636364
+ },
+ "dmi": {
+ "cvar_95": 0.029255190955404537,
+ "max_consecutive_losses": 5,
+ "max_consecutive_wins": 3,
+ "max_drawdown": 0.26277279842089457,
+ "sharpe": -0.538224272020581,
+ "sqn": -0.6542541127994779,
+ "total_return": -0.144868582491816,
+ "total_trades": 22,
+ "ulcer_index": 0.14141379126490972,
+ "var_95": 0.022084559354451774,
+ "win_rate": 0.45454545454545453
+ },
+ "donchian": {
+ "cvar_95": -0.0,
+ "max_consecutive_losses": 0,
+ "max_consecutive_wins": 0,
+ "max_drawdown": 0.0,
+ "sharpe": 0.0,
+ "sqn": 0.0,
+ "total_return": 0.0,
+ "total_trades": 0,
+ "ulcer_index": 0.0,
+ "var_95": -0.0,
+ "win_rate": 0.0
+ },
+ "dpo": {
+ "cvar_95": 0.02721297612445824,
+ "max_consecutive_losses": 6,
+ "max_consecutive_wins": 4,
+ "max_drawdown": 0.19787887049273278,
+ "sharpe": 0.08973761311003028,
+ "sqn": 0.4060312738886599,
+ "total_return": 0.04626258446140685,
+ "total_trades": 40,
+ "ulcer_index": 0.09456618993344078,
+ "var_95": 0.019685727992254924,
+ "win_rate": 0.45
+ },
+ "ema_cross": {
+ "cvar_95": 0.030512787117540286,
+ "max_consecutive_losses": 5,
+ "max_consecutive_wins": 1,
+ "max_drawdown": 0.2959272480843976,
+ "sharpe": -0.7875446987996052,
+ "sqn": -1.4136352678571986,
+ "total_return": -0.21673831411102973,
+ "total_trades": 8,
+ "ulcer_index": 0.17424109127336226,
+ "var_95": 0.02416619544856333,
+ "win_rate": 0.125
+ },
+ "emv": {
+ "cvar_95": 0.029127493175167347,
+ "max_consecutive_losses": 6,
+ "max_consecutive_wins": 3,
+ "max_drawdown": 0.3223214933637952,
+ "sharpe": -1.0544258453115651,
+ "sqn": -1.6556723755607416,
+ "total_return": -0.2575566534714613,
+ "total_trades": 28,
+ "ulcer_index": 0.21540763583933592,
+ "var_95": 0.02157076164724241,
+ "win_rate": 0.32142857142857145
+ },
+ "fsl": {
+ "cvar_95": 0.028932815510588083,
+ "max_consecutive_losses": 5,
+ "max_consecutive_wins": 2,
+ "max_drawdown": 0.16041976356729326,
+ "sharpe": -0.12011672214054765,
+ "sqn": -0.07772875586670183,
+ "total_return": -0.026169388111291436,
+ "total_trades": 14,
+ "ulcer_index": 0.08345129720689942,
+ "var_95": 0.022744109351729155,
+ "win_rate": 0.35714285714285715
+ },
+ "kdj_cross": {
+ "cvar_95": 0.027898201822013535,
+ "max_consecutive_losses": 3,
+ "max_consecutive_wins": 6,
+ "max_drawdown": 0.10762922092720545,
+ "sharpe": 0.8589847738542853,
+ "sqn": 1.2700892113941968,
+ "total_return": 0.33234070489431433,
+ "total_trades": 28,
+ "ulcer_index": 0.05288447475839552,
+ "var_95": 0.019645713150630486,
+ "win_rate": 0.5357142857142857
+ },
+ "keltner": {
+ "cvar_95": 0.02977493291904796,
+ "max_consecutive_losses": 2,
+ "max_consecutive_wins": 1,
+ "max_drawdown": 0.22976199965457847,
+ "sharpe": -0.1732213286120034,
+ "sqn": -0.22326773643387887,
+ "total_return": -0.04444609288676726,
+ "total_trades": 4,
+ "ulcer_index": 0.11754644293259853,
+ "var_95": 0.02351295669073764,
+ "win_rate": 0.25
+ },
+ "ma_cross": {
+ "cvar_95": 0.02953584331940908,
+ "max_consecutive_losses": 3,
+ "max_consecutive_wins": 4,
+ "max_drawdown": 0.22131421204998106,
+ "sharpe": -0.4990885286170613,
+ "sqn": -1.0839091744378302,
+ "total_return": -0.1326834663173594,
+ "total_trades": 11,
+ "ulcer_index": 0.11891561252900337,
+ "var_95": 0.022064133071800284,
+ "win_rate": 0.5454545454545454
+ },
+ "macd": {
+ "cvar_95": 0.02839428517023986,
+ "max_consecutive_losses": 6,
+ "max_consecutive_wins": 5,
+ "max_drawdown": 0.18473872916346823,
+ "sharpe": -0.38338812036224335,
+ "sqn": -0.5295484249243184,
+ "total_return": -0.10413146619958658,
+ "total_trades": 17,
+ "ulcer_index": 0.10417476558778298,
+ "var_95": 0.02189769434012649,
+ "win_rate": 0.35294117647058826
+ },
+ "rsi_reversal": {
+ "cvar_95": 0.030776659581515254,
+ "max_consecutive_losses": 1,
+ "max_consecutive_wins": 1,
+ "max_drawdown": 0.23599475582323245,
+ "sharpe": 0.4065196788206701,
+ "sqn": 0.6504806756551088,
+ "total_return": 0.16567827342668773,
+ "total_trades": 2,
+ "ulcer_index": 0.09450845521195947,
+ "var_95": 0.024060510820245292,
+ "win_rate": 0.5
+ },
+ "triple_ma": {
+ "cvar_95": 0.030748053405195853,
+ "max_consecutive_losses": 2,
+ "max_consecutive_wins": 1,
+ "max_drawdown": 0.26736051511671544,
+ "sharpe": -0.2867281890227494,
+ "sqn": -2.2739244432234624,
+ "total_return": -0.07872900419821216,
+ "total_trades": 4,
+ "ulcer_index": 0.13253815816514244,
+ "var_95": 0.023068752460779107,
+ "win_rate": 0.25
+ },
+ "trix": {
+ "cvar_95": 0.027724285540264466,
+ "max_consecutive_losses": 3,
+ "max_consecutive_wins": 4,
+ "max_drawdown": 0.29063024861099856,
+ "sharpe": -0.7642482349371779,
+ "sqn": -0.9463810886485136,
+ "total_return": -0.1947048613134198,
+ "total_trades": 12,
+ "ulcer_index": 0.17538728786286353,
+ "var_95": 0.021929213323307422,
+ "win_rate": 0.5
+ },
+ "wr_reversal": {
+ "cvar_95": -0.0,
+ "max_consecutive_losses": 0,
+ "max_consecutive_wins": 0,
+ "max_drawdown": 0.0,
+ "sharpe": 0.0,
+ "sqn": 0.0,
+ "total_return": 0.0,
+ "total_trades": 0,
+ "ulcer_index": 0.0,
+ "var_95": -0.0,
+ "win_rate": 0.0
+ }
+ }
+}
diff --git a/tests/unit/test_golden_backtest.py b/tests/unit/test_golden_backtest.py
new file mode 100644
index 0000000..d674255
--- /dev/null
+++ b/tests/unit/test_golden_backtest.py
@@ -0,0 +1,292 @@
+"""黄金测试(golden tests):回测引擎指标快照回归(v1.28 新增)。
+
+借鉴 akquant 的 golden 测试机制:把「内置策略在固定随机种子合成数据上的
+全部绩效指标」与「交易规则场景(止损/止盈/移动止损/OCO/费率)的成交明细」
+锁定为 JSON 基线(``tests/golden/backtest_metrics.json``),每次引擎改动后
+跑一遍比对——撮合、费率、信号时序任何静默漂移都会在这里爆出来。
+
+生成/更新基线::
+
+ EASY_TDX_REGEN_GOLDEN=1 python -m pytest tests/unit/test_golden_backtest.py
+
+比对容差:rel=1e-6 / abs=1e-6——紧到能抓住费率或成交时点级别的逻辑漂移
+(通常引起 >0.001 的变动),松到容忍跨平台浮点求和顺序的尾数噪声。
+"""
+
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+import pandas as pd
+import pytest
+
+from easy_tdx.backtest.benchmark import (
+ compute_benchmark_comparison,
+ run_buy_hold_benchmark,
+)
+from easy_tdx.backtest.engine import BacktestEngine
+from easy_tdx.backtest.strategies import builtin # noqa: F401 # 触发注册
+from easy_tdx.backtest.strategies.registry import _REGISTRY
+from easy_tdx.backtest.strategy import Strategy
+
+GOLDEN_PATH = Path(__file__).resolve().parents[1] / "golden" / "backtest_metrics.json"
+REGEN = os.environ.get("EASY_TDX_REGEN_GOLDEN", "") == "1"
+
+# 与基线 meta 一致的固定参数
+SEED = 20260902
+BARS = 400
+CASH = 100000.0
+
+# 内置策略锁定的指标子集(全部为确定性数值;int 与 float 分开比对)
+STRATEGY_METRICS_FLOAT = (
+ "total_return",
+ "max_drawdown",
+ "sharpe",
+ "win_rate",
+ "ulcer_index",
+ "var_95",
+ "cvar_95",
+ "sqn",
+)
+STRATEGY_METRICS_INT = (
+ "total_trades",
+ "max_consecutive_wins",
+ "max_consecutive_losses",
+)
+
+
+def _golden_df() -> pd.DataFrame:
+ """固定种子的合成日线(几何随机游走 + 温和上行漂移)。"""
+ rng = np.random.default_rng(SEED)
+ close = 20.0 * np.exp(np.cumsum(rng.normal(0.0004, 0.018, BARS)))
+ high = close * (1 + np.abs(rng.normal(0, 0.008, BARS)))
+ low = close * (1 - np.abs(rng.normal(0, 0.008, BARS)))
+ open_ = low + (high - low) * rng.uniform(0, 1, BARS)
+ vol = rng.uniform(5e5, 5e6, BARS)
+ return pd.DataFrame(
+ {
+ "datetime": pd.date_range("2023-01-02", periods=BARS, freq="B"),
+ "open": open_,
+ "high": high,
+ "low": low,
+ "close": close,
+ "vol": vol,
+ "amount": close * vol,
+ }
+ )
+
+
+# ── 规则场景策略(手工构造行情路径,锁定触发语义本身) ───────────────────────
+
+
+class _BuyOnce(Strategy):
+ """首根买入(可携带 bracket 参数),不再主动交易;无参数时即买入持有。"""
+
+ def __init__(self, **bracket: Any) -> None:
+ super().__init__()
+ self._bracket: dict[str, Any] = bracket
+ self._bought = False
+
+ def init(self) -> None:
+ pass
+
+ def next(self) -> None:
+ if not self._bought:
+ self.buy(**self._bracket)
+ self._bought = True
+
+
+def _rule_df(closes: list[float]) -> pd.DataFrame:
+ """按收盘价序列构造无随机因素的 OHLC(high/low = close ±1%)。"""
+ arr = np.asarray(closes, dtype=float)
+ n = len(arr)
+ return pd.DataFrame(
+ {
+ "datetime": pd.date_range("2024-01-01", periods=n, freq="B"),
+ "open": arr,
+ "high": arr * 1.01,
+ "low": arr * 0.99,
+ "close": arr,
+ "vol": [1000.0] * n,
+ "amount": arr * 1000,
+ }
+ )
+
+
+def _run_rule(closes: list[float], **bracket: Any) -> dict[str, Any]:
+ """跑规则场景,返回待锁定的摘要(成交明细 + 关键指标)。"""
+ result = BacktestEngine(_BuyOnce(**bracket), cash=CASH).run(_rule_df(closes))
+ trades = [
+ [t.direction, round(float(t.price), 4), int(pd.Timestamp(t.datetime).strftime("%Y%m%d"))]
+ for t in result.trades.itertuples()
+ if not t.rejected
+ ]
+ return {
+ "trades": trades,
+ "total_return": float(result.performance["total_return"]),
+ "total_trades": int(result.performance["total_trades"]),
+ }
+
+
+RULE_SCENARIOS: dict[str, dict[str, Any]] = {
+ # 跌破固定止损 9.5 → 触发 SELL@9.5,延迟下一根成交
+ "stop_loss": {
+ "closes": [10, 10.2, 10.1, 9.8, 9.3, 9.0, 8.8, 8.6, 8.4, 8.2],
+ "bracket": {"stop_loss": 9.5},
+ },
+ # 触及固定止盈 11.0 → OCO 使止损线失效
+ "take_profit": {
+ "closes": [10, 10.3, 10.8, 11.2, 11.5, 11.8, 12.0, 12.2, 12.4, 12.6],
+ "bracket": {"stop_loss": 9.0, "take_profit": 11.0},
+ },
+ # 自最高收盘 12 回撤 8% → 11.04 触发移动止损
+ "trailing_stop": {
+ "closes": [10, 10.2, 10.5, 11, 11.5, 12, 11.9, 11.5, 11.0, 10.5, 10.0, 9.5],
+ "bracket": {"trail_stop": 0.08},
+ },
+ # 百分比 bracket:5% 止损 / 10% 止盈(基准价 = 信号根收盘 10)
+ "bracket_pct": {
+ "closes": [10, 10.3, 10.8, 11.2, 11.5, 11.8, 12.0, 12.2, 12.4, 12.6],
+ "bracket": {"stop_loss_pct": 0.05, "take_profit_pct": 0.10},
+ },
+}
+
+
+def _build_golden() -> dict[str, Any]:
+ """重新计算并返回完整黄金基线。"""
+ df = _golden_df()
+
+ strategies: dict[str, dict[str, Any]] = {}
+ for name in sorted(_REGISTRY.names()):
+ reg = _REGISTRY.get(name)
+ cls = reg.strategy_cls
+ perf = BacktestEngine(cls, cash=CASH).run(df).performance
+ entry: dict[str, Any] = {k: float(perf[k]) for k in STRATEGY_METRICS_FLOAT}
+ entry.update({k: int(perf[k]) for k in STRATEGY_METRICS_INT})
+ strategies[name] = entry
+
+ rules = {
+ key: _run_rule(spec["closes"], **spec["bracket"]) for key, spec in RULE_SCENARIOS.items()
+ }
+
+ # 买入持有基准 + CAPM 对比(用 ma_cross 做策略侧)
+ bh = run_buy_hold_benchmark(df, cash=CASH)
+ ma = _REGISTRY.get("ma_cross").strategy_cls
+ ma_result = BacktestEngine(ma, cash=CASH).run(df)
+ comparison = compute_benchmark_comparison(
+ ma_result.equity_curve,
+ BacktestEngine(_BuyOnce(), cash=CASH).run(df).equity_curve,
+ )
+
+ return {
+ "meta": {
+ "seed": SEED,
+ "bars": BARS,
+ "cash": CASH,
+ "tolerance": {"rel": 1e-6, "abs": 1e-6},
+ "note": "regen: EASY_TDX_REGEN_GOLDEN=1 pytest tests/unit/test_golden_backtest.py",
+ },
+ "strategies": strategies,
+ "rules": rules,
+ "buy_hold": {k: float(v) for k, v in bh.items()},
+ "benchmark_comparison": {k: float(v) for k, v in comparison.items()},
+ }
+
+
+def _load_golden() -> dict[str, Any]:
+ if not GOLDEN_PATH.exists():
+ pytest.fail(
+ f"黄金基线缺失: {GOLDEN_PATH}\n"
+ "首次生成请运行: EASY_TDX_REGEN_GOLDEN=1 python -m pytest "
+ "tests/unit/test_golden_backtest.py"
+ )
+ data = json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
+ assert isinstance(data, dict)
+ return data
+
+
+def _save_golden(data: dict[str, Any]) -> None:
+ GOLDEN_PATH.parent.mkdir(parents=True, exist_ok=True)
+ GOLDEN_PATH.write_text(
+ json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+
+
+@pytest.fixture(scope="module")
+def golden() -> dict[str, Any]:
+ """加载基线;REGEN=1 时重新计算并写盘后返回。"""
+ if REGEN:
+ data = _build_golden()
+ _save_golden(data)
+ return data
+ return _load_golden()
+
+
+def _assert_metric(actual: Any, expected: Any, label: str) -> None:
+ """int 精确比对;float 按 rel=abs=1e-6 容差比对。"""
+ if isinstance(expected, int) and not isinstance(expected, bool):
+ assert actual == expected, f"{label}: {actual} != {expected}"
+ else:
+ assert float(actual) == pytest.approx(float(expected), rel=1e-6, abs=1e-6), (
+ f"{label}: {actual} != {expected}"
+ )
+
+
+# ── 测试入口 ─────────────────────────────────────────────────────────────────
+
+
+@pytest.mark.parametrize("name", sorted(_REGISTRY.names()))
+def test_golden_builtin_strategies(golden: dict[str, Any], name: str) -> None:
+ """全部内置策略在固定数据上的绩效指标与基线一致。"""
+ perf = BacktestEngine(_REGISTRY.get(name).strategy_cls, cash=CASH).run(_golden_df()).performance
+ baseline = golden["strategies"][name]
+ for key in STRATEGY_METRICS_FLOAT:
+ _assert_metric(perf[key], baseline[key], f"{name}.{key}")
+ for key in STRATEGY_METRICS_INT:
+ _assert_metric(perf[key], baseline[key], f"{name}.{key}")
+
+
+@pytest.mark.parametrize("scenario", sorted(RULE_SCENARIOS))
+def test_golden_rule_scenarios(golden: dict[str, Any], scenario: str) -> None:
+ """止损/止盈/移动止损/OCO 触发语义(成交价与时点)与基线一致。"""
+ spec = RULE_SCENARIOS[scenario]
+ actual = _run_rule(spec["closes"], **spec["bracket"])
+ baseline = golden["rules"][scenario]
+ assert actual["total_trades"] == baseline["total_trades"], scenario
+ assert len(actual["trades"]) == len(baseline["trades"]), f"{scenario}: 成交笔数漂移"
+ for i, (a, b) in enumerate(zip(actual["trades"], baseline["trades"])):
+ assert a[0] == b[0], f"{scenario} 第{i}笔方向漂移: {a} vs {b}"
+ _assert_metric(a[1], b[1], f"{scenario}.trades[{i}].price")
+ assert a[2] == b[2], f"{scenario} 第{i}笔成交日漂移: {a} vs {b}"
+ _assert_metric(actual["total_return"], baseline["total_return"], f"{scenario}.total_return")
+
+
+def test_golden_buy_hold(golden: dict[str, Any]) -> None:
+ """买入持有基准指标与基线一致。"""
+ bh = run_buy_hold_benchmark(_golden_df(), cash=CASH)
+ for key, expected in golden["buy_hold"].items():
+ _assert_metric(bh[key], expected, f"buy_hold.{key}")
+
+
+def test_golden_benchmark_comparison(golden: dict[str, Any]) -> None:
+ """Alpha/Beta/IR/TE 基准对比指标与基线一致。"""
+ df = _golden_df()
+ ma = _REGISTRY.get("ma_cross").strategy_cls
+ strategy_curve = BacktestEngine(ma, cash=CASH).run(df).equity_curve
+ bh_curve = BacktestEngine(_BuyOnce(), cash=CASH).run(df).equity_curve
+ comparison = compute_benchmark_comparison(strategy_curve, bh_curve)
+ for key, expected in golden["benchmark_comparison"].items():
+ _assert_metric(comparison[key], expected, f"benchmark.{key}")
+
+
+def test_golden_meta_frozen(golden: dict[str, Any]) -> None:
+ """基线 meta 与测试常量一致(防止改数据参数后忘记重建基线)。"""
+ meta = golden["meta"]
+ assert meta["seed"] == SEED
+ assert meta["bars"] == BARS
+ assert meta["cash"] == CASH
diff --git a/web-ui/src/components/EvaluatePanel.vue b/web-ui/src/components/EvaluatePanel.vue
index 8a78e20..48de01a 100644
--- a/web-ui/src/components/EvaluatePanel.vue
+++ b/web-ui/src/components/EvaluatePanel.vue
@@ -32,6 +32,29 @@ const scoreComponents = computed(() => {
const grade = computed(() => gradePerformance(props.report.performance))
const excess = computed(() => props.report.benchmark.excess_return)
+
+/** v1.28 CAPM/主动管理指标(老报告缺省时不渲染该行;good=null 为中性不着色) */
+const capm = computed(() => {
+ const b = props.report.benchmark
+ if (b.alpha === undefined || b.beta === undefined) return null
+ return [
+ { label: 'α 年化超额', value: b.alpha, fmt: 'percent', good: (b.alpha ?? 0) >= 0 },
+ { label: 'β 敏感度', value: b.beta, fmt: 'ratio', good: null },
+ {
+ label: '信息比率',
+ value: b.information_ratio ?? 0,
+ fmt: 'ratio',
+ good: (b.information_ratio ?? 0) >= 0,
+ },
+ { label: '跟踪误差', value: b.tracking_error ?? 0, fmt: 'percent', good: null },
+ ]
+})
+
+function fmtCapm(v: number, fmt: string): string {
+ if (!Number.isFinite(v)) return '-'
+ if (fmt === 'percent') return `${v >= 0 ? '+' : ''}${(v * 100).toFixed(2)}%`
+ return v.toFixed(2)
+}
@@ -88,6 +111,15 @@ const excess = computed(() => props.report.benchmark.excess_return)
+
+ ⚠ 策略跑输同区间买入持有——研发阶段的一票否决信号。
@@ -201,6 +233,9 @@ const excess = computed(() => props.report.benchmark.excess_return) gap: 8px; margin-bottom: 6px; } +.bench-row-4 { + grid-template-columns: repeat(4, 1fr); +} .bench-cell { display: flex; flex-direction: column; diff --git a/web-ui/src/components/MetricTable.vue b/web-ui/src/components/MetricTable.vue index ff7eee8..ccafa16 100644 --- a/web-ui/src/components/MetricTable.vue +++ b/web-ui/src/components/MetricTable.vue @@ -1,5 +1,6 @@