mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 15:44:18 +08:00
release: v1.28.0 — 深度风险报告+移动止损+黄金测试(借鉴 akquant):25 项绩效 / α·β·IR·TE 基准对比 / trail_stop OCO
This commit is contained in:
+7
-1
@@ -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` 可调轮询间隔。
|
||||
|
||||
|
||||
@@ -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 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,重启不丢。
|
||||
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.28:CAPM / 主动管理对比(α/β/信息比率/跟踪误差) -->
|
||||
<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;
|
||||
|
||||
@@ -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)} 天`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user