release: v1.28.0 — 深度风险报告+移动止损+黄金测试(借鉴 akquant):25 项绩效 / α·β·IR·TE 基准对比 / trail_stop OCO

This commit is contained in:
Justin Gu
2026-09-02 11:53:53 +08:00
parent 90f2595d3d
commit 0a00ca3f70
14 changed files with 989 additions and 41 deletions
+7 -1
View File
@@ -2,10 +2,16 @@
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
## [未发布]
## [1.28.0] — 2026-09-02
**深度风险报告 + 移动止损 + 黄金测试**(借鉴 [akquant](https://github.com/akfamily/akquant))——把专业量化框架的「报告深度」与「测试 rigor」搬到散户工具上,三通道(CLI / Web API / Web UI)同步输出。同版本收录 Playwright E2E 前端测试基建与 WebSocket 实时推送联动(升级计划 P4-1 / P4-2)。
### 新增
- **绩效指标 19 → 25 项**`backtest/performance.py`)——新增 Ulcer 指数(回撤深度×持续时间综合,与 S-D 评级「持有体验」定位同频)、95% 日 VaR / CVaR(历史分位数法,尾部风险)、SQN 系统质量数(√N×单笔收益均值/标准差,>2 可用 / >4 优秀 / >6 极佳)、最大连胜 / 最大连亏(散户心理最敏感的数字)。JSON / CSV 输出自动透传;`--table` 增加「深度风险」块;Web UI 绩效表「风险」组 +3 行、「交易」组 +3 行(老结果缺键显示 `-`)。
- **基准对比从 1 个数升级为 5 个数**(`backtest/benchmark.py`)——`evaluate_strategy``benchmark` 段在 `excess_return` 之外新增 `alpha`(年化 CAPM α,剔除基准影响后的真实超额)、`beta`(对基准敏感度,1=同涨同跌)、`information_ratio`(年化信息比率)、`tracking_error`(年化跟踪误差)。新公开函数 `compute_benchmark_comparison(strategy_curve, benchmark_curve)`。Web UI 一条龙评估卡新增 4 格对比行(α/信息比率按正负着色,β/跟踪误差中性);CLI `--evaluate` JSON 自动携带。
- **移动止损 + 百分比 bracket**`backtest/engine.py` / `strategy.py`)——`buy()` 新增 akquant `place_bracket` 风格参数:`trail_stop`(自持仓期间最高收盘价回撤 N% 触发;水印在检查后更新 → 只可能次根起触发,与 next_open 语义一致、无前视)、`stop_loss_pct` / `take_profit_pct`(按信号根收盘自动换算绝对价)。止损/止盈/移动止损构成 OCO(任一触发全部失效),触发单 `source="stop"` 延迟下一根成交。
- **黄金测试(golden tests**`tests/unit/test_golden_backtest.py` + `tests/golden/backtest_metrics.json`)——借鉴 akquant 的 golden 机制:19 个内置策略在固定种子(seed=20260902400 bar)合成数据上的 11 项指标 + 4 个规则场景(固定止损 / 止盈 / 移动止损 / 百分比 bracket)的成交价与时点 + 买入持有基准 + Alpha/Beta/IR/TE,全部锁定为 JSON 基线,容差 rel=abs=1e-6(紧到抓住费率/成交时点级别的逻辑漂移,松到容忍跨平台浮点尾数)。引擎任何撮合/费率/信号逻辑的静默改动都会在此爆出。更新基线:`EASY_TDX_REGEN_GOLDEN=1 python -m pytest tests/unit/test_golden_backtest.py`。**26 例新增**。
- **Playwright E2E 前端测试基建**(升级计划 P4-1)——web-ui 引入 `@playwright/test``e2e/` + `playwright.config.ts``npm run test:e2e`)。**mock 方案选后端合成数据而非 page.route 拦截**`EASY_TDX_E2E_MOCK=1` 时 serve 的 lifespan 把 TDX/MAC 客户端替换为合成数据客户端(`web/e2e_mock.py`,按 (market, code) CRC32 播种的确定性随机游走,分页语义与真实 /bars 一致),回测/WF/一条龙评估/自选/策略库继续走**真实后端代码**(它们本就不依赖行情连接),SSE 由 QuoteStreamer 真轮询合成数据全链路覆盖(mock 模式下轮询降到 2s 一拍,不受交易时段限制)。用例覆盖:看板五大指数区块+SSE 价格渲染、自选增删、回测全流程(净值图/绩效表/成交记录)、「附加分析」开关(WF 逐窗柱状图+一条龙评估卡)、策略库保存;`EASY_TDX_CONFIG_DIR` 指向每轮独立临时目录(断言可写死、不污染真实 `~/.easy_tdx`)。CI frontend job 追加 E2E 步骤;`verify_ci.sh``--no-frontend` 与前端 typecheck+build+E2E 段。新增 `tests/unit/test_e2e_mock.py`(11 例)守护 mock 与真实客户端的契约。
- **WebSocket 实时推送联动 EventBus**(升级计划 P4-2)——`/ws/realtime/{symbol}` 从「不推送数据」变为真链路:新增 `web/realtime_hub.py`RealtimeStreamHub),订阅集合变化时按需启停 `RealtimeDataFeed`(轮询 `get_stock_quotes``EventBus` → 每连接独立队列 fan-out,丢最旧保最新);**无人订阅完全停止轮询**(对齐 QuoteStreamer 节能语义);去重后标的上限 80;推送帧 `{type:"tick", symbol, market, code, price, volume, ts, open, high, low, pre_close, amount, name}`30s 空闲 `ping` 心跳,客户端可 `subscribe`/`unsubscribe` 动态增删。端点重写为「单一写者泵」模型(全部出站帧经队列串行,杜绝并发 send 交错)。**前端接入选择只写文档不上组件**:看板/自选实时刷新已由 SSE `/stream/quotes`(全量快照、单连接共享)承担,WS 定位是按需单标的 tick(实时策略信号预留口),双通道同时拉同样行情属冗余——协议 + 自动重连/心跳容忍代码骨架落 `docs/api_reference.md` 与 README(「未联动」警示已撤)。新增 `scripts/ws_smoke.py` 手动冒烟(mock 模式随时可跑,实测可见 tick 帧与动态订阅确认)。环境变量 `EASY_TDX_WS_INTERVAL` 可调轮询间隔。
+5 -5
View File
@@ -17,13 +17,13 @@ easy-tdx 要做的事很简单:**把机构的数据锁砸开,扔到每个普
**34个技术指标**MACD、KDJ、RSI、BOLL……连”捉妖大师”和”30日乖离率信号”都给你算好)开箱即用。
**缠论分析**(笔、中枢、买卖点、背驰)一键出结果——你不再需要手画分型、猜线段。
**内置回测引擎**——写个策略文件,一行命令跑回测,18 个经典策略自带,多因子组合、策略选股扫描,批量对比哪个最赚钱一目了然。**防过拟合验证链**(v1.25 新增)——Walk-Forward 七窗样本外验证(每窗独立开仓)、训练/验证/测试三段适配性体检(8 项可解释检查 + 「高适配」标记)、0-100 综合评分、多 seed 晋级门槛、买入持有基准对比,回测页勾选即出报告——「回测好」升级为「样本外也好」。
**内置回测引擎**——写个策略文件,一行命令跑回测,18 个经典策略自带,多因子组合、策略选股扫描,批量对比哪个最赚钱一目了然。**防过拟合验证链**(v1.25 新增)——Walk-Forward 七窗样本外验证(每窗独立开仓)、训练/验证/测试三段适配性体检(8 项可解释检查 + 「高适配」标记)、0-100 综合评分、多 seed 晋级门槛、买入持有基准对比,回测页勾选即出报告——「回测好」升级为「样本外也好」。**深度风险报告**(v1.28 新增)——绩效指标扩到 25 项(新增 Ulcer 指数、95% 日 VaR/CVaR、SQN 系统质量数、最大连胜/连亏);基准对比从超额收益一个数升级为 α/β/信息比率/跟踪误差四件套(「涨的时候跟不跟得上大盘、跌的时候抗不抗跌」一眼可读);策略一行带移动止损与百分比止损止盈(`self.buy(trail_stop=0.08)` 涨得越高止损线跟得越高,OCO 任一触发即失效);引擎行为由黄金测试锁定——19 个内置策略的指标基线进 CI,撮合/费率逻辑任何静默漂移当场爆红。
**行情终端 Web UI**v1.23 重大升级)——`easy-tdx serve` 一条命令,浏览器秒变专业看盘终端:**市场看板**(五大指数实时推送 + 迷你分时、涨跌统计、四维情绪雷达、全市场涨跌分布直方图、涨停雷达、行业/概念热冷榜、涨幅/跌幅/成交额/换手四联排行榜、两市异动雷达)、**自选行情**(输入 6 位代码即加,全表 SSE 实时刷新、行内迷你分时)、**个股详情弹窗**(五档盘口 + 1/3/5 日分时 + 带 MA/BOLL/MACD/KDJ/RSI 指标切换的日 K,一键加自选、一键全策略寻优)、**板块下钻**(行业/概念弹窗看板块走势 + 成分股涨跌榜直达个股)。实时推送采用 SSE 单循环轮询 fan-out 架构(盘中 8 秒一拍,无人订阅自动休眠),自选持久化 SQLite。展示层设计对标专业终端(暗色、红涨绿跌、高信息密度),数据全部来自通达信协议直连——不花一分钱。
<img src="./docs/web-ui-page-4.png" alt="行情终端 Web UIv1.23):市场看板 / 自选行情 / 个股与板块详情" />
**回测可视化 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,重启不丢。
+1 -1
View File
@@ -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"
+120 -16
View File
@@ -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.28CAPM 对比
"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 记 0IR 在差值恒正且无波动时沿用 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,
+10
View File
@@ -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):
+43 -10
View File
@@ -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)
+69 -2
View File
@@ -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)
+28 -3
View File
@@ -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)
+6
View File
@@ -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"
+341
View File
@@ -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
}
}
}
+292
View File
@@ -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:
"""按收盘价序列构造无随机因素的 OHLChigh/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},
},
# 百分比 bracket5% 止损 / 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
+35
View File
@@ -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)
}
</script>
<template>
@@ -88,6 +111,15 @@ const excess = computed(() => props.report.benchmark.excess_return)
</span>
</div>
</div>
<!-- v1.28CAPM / 主动管理对比α/β/信息比率/跟踪误差 -->
<div v-if="capm" class="bench-row bench-row-4">
<div v-for="c in capm" :key="c.label" class="bench-cell">
<span class="stat-label">{{ c.label }}</span>
<span class="mono" :class="c.good === null ? '' : c.good ? 'pos' : 'neg'">
{{ fmtCapm(c.value, c.fmt) }}
</span>
</div>
</div>
<p v-if="excess < 0" class="bench-warn"> 策略跑输同区间买入持有研发阶段的一票否决信号</p>
<!-- 适配性检查8 项可解释 -->
@@ -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;
+10 -3
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
// 19 项绩效指标表。按金融惯例格式化:比率类→百分比,保留小数。
// 25 项绩效指标表。按金融惯例格式化:比率类→百分比,保留小数。
// v1.28 新增深度风险指标(Ulcer/VaR/CVaR/SQN/连胜连亏),老结果缺键时显示 '-'。
import { computed } from 'vue'
@@ -27,6 +28,9 @@ const METRICS: MetricRow[] = [
{ key: 'max_drawdown', label: '最大回撤', format: 'percent', group: '风险' },
{ key: 'max_dd_duration', label: '回撤持续', format: 'days', group: '风险' },
{ key: 'volatility', label: '波动率', format: 'percent', group: '风险' },
{ key: 'ulcer_index', label: 'Ulcer 指数', format: 'percent', group: '风险' },
{ key: 'var_95', label: '日 VaR (95%)', format: 'percent', group: '风险' },
{ key: 'cvar_95', label: '日 CVaR (95%)', format: 'percent', group: '风险' },
{ key: 'total_trades', label: '总交易数', format: 'int', group: '交易' },
{ key: 'win_trades', label: '盈利次数', format: 'int', group: '交易' },
{ key: 'lose_trades', label: '亏损次数', format: 'int', group: '交易' },
@@ -37,11 +41,14 @@ const METRICS: MetricRow[] = [
{ key: 'max_win', label: '最大盈利', format: 'percent', group: '交易' },
{ key: 'max_loss', label: '最大亏损', format: 'percent', group: '交易' },
{ key: 'avg_holding_days', label: '平均持仓天数', format: 'ratio', group: '交易' },
{ key: 'sqn', label: 'SQN 系统质量', format: 'ratio', group: '交易' },
{ key: 'max_consecutive_wins', label: '最大连胜', format: 'int', group: '交易' },
{ key: 'max_consecutive_losses', label: '最大连亏', format: 'int', group: '交易' },
{ key: 'rejected_trades', label: '拒单数', format: 'int', group: '交易' },
]
function formatVal(row: MetricRow, v: number): string {
if (!Number.isFinite(v)) return '-'
function formatVal(row: MetricRow, v: number | undefined): string {
if (v === undefined || v === null || !Number.isFinite(v)) return '-'
if (row.format === 'percent') return `${(v * 100).toFixed(2)}%`
if (row.format === 'int') return String(Math.round(v))
if (row.format === 'days') return `${v.toFixed(0)}`
+22
View File
@@ -89,6 +89,19 @@ export interface Performance {
max_loss: number
avg_holding_days: number
volatility: number
// v1.28 深度风险指标(老版本保存的结果可能缺省)
/** Ulcer 指数:回撤深度平方均值开方,越小持有体验越好 */
ulcer_index?: number
/** 95% 日 VaR(历史分位数法,正数 = 单日最大损失幅度) */
var_95?: number
/** 95% 日 CVaR / 期望损失 */
cvar_95?: number
/** SQN 系统质量数(>2 可用、>4 优秀、>6 极佳) */
sqn?: number
/** 最大连胜笔数 */
max_consecutive_wins?: number
/** 最大连亏笔数 */
max_consecutive_losses?: number
}
export interface EquityPoint {
@@ -603,6 +616,15 @@ export interface EvaluateBenchmarkReport {
}
/** 策略总收益 - 买入持有总收益 */
excess_return: number
// v1.28 CAPM / 主动管理对比指标(老版本保存的报告可能缺省)
/** 年化 CAPM α:剔除基准影响后的超额收益,>0 仍有真实超额 */
alpha?: number
/** β:对基准的敏感度(1 = 与基准同涨跌) */
beta?: number
/** 年化信息比率:每 1 单位跟踪误差换来的超额收益 */
information_ratio?: number
/** 年化跟踪误差 */
tracking_error?: number
}
export interface EvaluateReport {