From 0a00ca3f709a254c315f1f61942119f55d64fce0 Mon Sep 17 00:00:00 2001 From: Justin Gu <97915@qq.com> Date: Wed, 2 Sep 2026 11:53:53 +0800 Subject: [PATCH] =?UTF-8?q?release:=20v1.28.0=20=E2=80=94=20=E6=B7=B1?= =?UTF-8?q?=E5=BA=A6=E9=A3=8E=E9=99=A9=E6=8A=A5=E5=91=8A+=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E6=AD=A2=E6=8D=9F+=E9=BB=84=E9=87=91=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=EF=BC=88=E5=80=9F=E9=89=B4=20akquant=EF=BC=89?= =?UTF-8?q?=EF=BC=9A25=20=E9=A1=B9=E7=BB=A9=E6=95=88=20/=20=CE=B1=C2=B7?= =?UTF-8?q?=CE=B2=C2=B7IR=C2=B7TE=20=E5=9F=BA=E5=87=86=E5=AF=B9=E6=AF=94?= =?UTF-8?q?=20/=20trail=5Fstop=20OCO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 +- README.md | 10 +- pyproject.toml | 2 +- src/easy_tdx/backtest/benchmark.py | 136 ++++++++-- src/easy_tdx/backtest/cli.py | 10 + src/easy_tdx/backtest/engine.py | 53 +++- src/easy_tdx/backtest/performance.py | 71 ++++- src/easy_tdx/backtest/strategy.py | 31 ++- src/easy_tdx/backtest/types.py | 6 + tests/golden/backtest_metrics.json | 341 ++++++++++++++++++++++++ tests/unit/test_golden_backtest.py | 292 ++++++++++++++++++++ web-ui/src/components/EvaluatePanel.vue | 35 +++ web-ui/src/components/MetricTable.vue | 13 +- web-ui/src/types.ts | 22 ++ 14 files changed, 989 insertions(+), 41 deletions(-) create mode 100644 tests/golden/backtest_metrics.json create mode 100644 tests/unit/test_golden_backtest.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 03e37e3..0c09b04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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=20260902,400 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` 可调轮询间隔。 diff --git a/README.md b/README.md index a2bfd07..72c375e 100644 --- a/README.md +++ b/README.md @@ -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。展示层设计对标专业终端(暗色、红涨绿跌、高信息密度),数据全部来自通达信协议直连——不花一分钱。 行情终端 Web UI(v1.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,重启不丢。 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) +}