This commit is contained in:
GitHub
2026-09-02 13:16:41 +08:00
21 changed files with 1378 additions and 55 deletions
+28 -1
View File
@@ -2,7 +2,9 @@
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 本文件记录 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)。
### 性能 ### 性能
@@ -15,6 +17,10 @@
### 新增 ### 新增
- **绩效指标 19 → 25 项**`backtest/performance.py`)——新增 Ulcer 指数(回撤深度×持续时间综合,与 S-D 评级「持有体验」定位同频)、95% 日 VaR / CVaR(历史分位数法,尾部风险)、SQN 系统质量数(√N×单笔收益均值/标准差,>2 可用 / >4 优秀 / >6 极佳)、最大连胜 / 最大连亏(散户心理最敏感的数字)。JSON / CSV 输出自动透传;`--table` 增加「深度风险」块;Web UI 绩效表「风险」组 +3 行、「交易」组 +3 行(老结果缺键显示 `-`)。
- **基准对比从 1 个数升级为 5 个数**(`backtest/benchmark.py`)——`evaluate_strategy``benchmark` 段在 `excess_return` 之外新增 `alpha`(年化 CAPM α,剔除基准影响后的真实超额)、`beta`(对基准敏感度,1=同涨同跌)、`information_ratio`(年化信息比率)、`tracking_error`(年化跟踪误差)。新公开函数 `compute_benchmark_comparison(strategy_curve, benchmark_curve)`。Web UI 一条龙评估卡新增 4 格对比行(α/信息比率按正负着色,β/跟踪误差中性);CLI `--evaluate` JSON 自动携带。
- **移动止损 + 百分比 bracket**`backtest/engine.py` / `strategy.py`)——`buy()` 新增 akquant `place_bracket` 风格参数:`trail_stop`(自持仓期间最高收盘价回撤 N% 触发;水印在检查后更新 → 只可能次根起触发,与 next_open 语义一致、无前视)、`stop_loss_pct` / `take_profit_pct`(按信号根收盘自动换算绝对价)。止损/止盈/移动止损构成 OCO(任一触发全部失效),触发单 `source="stop"` 延迟下一根成交。
- **黄金测试(golden tests**`tests/unit/test_golden_backtest.py` + `tests/golden/backtest_metrics.json`)——借鉴 akquant 的 golden 机制:19 个内置策略在固定种子(seed=20260902400 bar)合成数据上的 11 项指标 + 4 个规则场景(固定止损 / 止盈 / 移动止损 / 百分比 bracket)的成交价与时点 + 买入持有基准 + Alpha/Beta/IR/TE,全部锁定为 JSON 基线,容差 rel=abs=1e-6(紧到抓住费率/成交时点级别的逻辑漂移,松到容忍跨平台浮点尾数)。引擎任何撮合/费率/信号逻辑的静默改动都会在此爆出。更新基线:`EASY_TDX_REGEN_GOLDEN=1 python -m pytest tests/unit/test_golden_backtest.py`。**26 例新增**。
- **Playwright E2E 前端测试基建**(升级计划 P4-1)——web-ui 引入 `@playwright/test``e2e/` + `playwright.config.ts``npm run test:e2e`)。**mock 方案选后端合成数据而非 page.route 拦截**`EASY_TDX_E2E_MOCK=1` 时 serve 的 lifespan 把 TDX/MAC 客户端替换为合成数据客户端(`web/e2e_mock.py`,按 (market, code) CRC32 播种的确定性随机游走,分页语义与真实 /bars 一致),回测/WF/一条龙评估/自选/策略库继续走**真实后端代码**(它们本就不依赖行情连接),SSE 由 QuoteStreamer 真轮询合成数据全链路覆盖(mock 模式下轮询降到 2s 一拍,不受交易时段限制)。用例覆盖:看板五大指数区块+SSE 价格渲染、自选增删、回测全流程(净值图/绩效表/成交记录)、「附加分析」开关(WF 逐窗柱状图+一条龙评估卡)、策略库保存;`EASY_TDX_CONFIG_DIR` 指向每轮独立临时目录(断言可写死、不污染真实 `~/.easy_tdx`)。CI frontend job 追加 E2E 步骤;`verify_ci.sh``--no-frontend` 与前端 typecheck+build+E2E 段。新增 `tests/unit/test_e2e_mock.py`(11 例)守护 mock 与真实客户端的契约。 - **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` 可调轮询间隔。 - **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` 可调轮询间隔。
@@ -22,6 +28,27 @@
- `RealtimeDataFeed` stop-before-start 竞态:`run_async`/`run_sync` 首行会把 `_running` 重置为 True,若 `stop()` 在任务首次调度前调用,停止请求被覆盖、任务永不退出(RealtimeStreamHub 换标的重建 feed 时必现死锁)。引入独立 `_stop_requested` 标志,启动前已请求停止则直接返回;`tests/unit/test_realtime_feed.py` 补 2 个回归用例。 - `RealtimeDataFeed` stop-before-start 竞态:`run_async`/`run_sync` 首行会把 `_running` 重置为 True,若 `stop()` 在任务首次调度前调用,停止请求被覆盖、任务永不退出(RealtimeStreamHub 换标的重建 feed 时必现死锁)。引入独立 `_stop_requested` 标志,启动前已请求停止则直接返回;`tests/unit/test_realtime_feed.py` 补 2 个回归用例。
## [1.27.2] — 2026-09-02
**市场异动(0x1237)类型解析补齐与修正**Issue #62)——`_describe_unusual` 此前仅覆盖 15 种类型,0x15/0x16/0x1D/0x1E(占全天异动 23%)落入兜底分支,显示「异动类型0x16、数值为空」;0x13 方向语义亦有误。语义由 2026-09-01/09-02 两个交易日实测锚定(全天跟踪采样,收盘累计 17848 条)。
### 新增
- **0x16 盘中强势/弱势**——09:25 撮合样本 v2 与当日开盘涨幅(open/pre_close-1**49/49 精确一致**v1 为带符号 ±1~3 级强弱等级(六组 v2 区间互不重叠且单调)。issue 作者猜的「大笔买入/卖出」「竞价试卖」据此排除。
- **0x15 竞价/尾盘异动(双时刻信号)**——开盘竞价 09:251191 条)与收盘 15:00:01~0486 条)都触发;desc 按记录小时区分「竞价拉升/尾盘拉升」前缀;v1 为方向档(±0.5% 分档)、v2 为时段尾段价格变动、v3 为成交量(手)。同源实现 pytdx2 标的「尾盘」只对了一半。
- **0x1D/0x1E 急速拉升/急速下跌**——阈值下限恰 ±0.6%,与既有 0x04/0x05(加速拉升/下跌)构成不同短窗信号。
- **公开常量 `UNUSUAL_TYPE_NAMES`**19 种类型码→名称),顶层 `easy_tdx``easy_tdx.mac.commands` 均可导入,配合 `df["unusual_type"].map(UNUSUAL_TYPE_NAMES)` 使用。
- **协议探索结论文档化**`docs/protocol-unknown-fields.md` §3.4)——全天普查确认 PC 推送协议(0x40080cd1+ 体系)特有的「大笔买入/主力急入/急速上涨」等信号在 0x1237 拉取协议中**无对应类型码**;0x14 另有竞价试盘子族(09:15 撮合参考价触板即触发,现有解析器直接可用);请求监控参数(尾部 6×H)经单维扫描确认不是类型开关;北交所(Market.BJ)同样支持 0x1237。
### 修复
- **0x13 竞价试盘方向修正**——v1=0x00 试买(申报价高于昨收)/ 0x01 试卖(低于昨收),552 条对照昨收 549 条一致;旧实现把约一半的试卖方向记录也显示成「竞价试买」,且数值无单位,现按方向显示「竞价试买/竞价试卖」并带 `申报价/竞价量手`
- `examples/17_mac_monitor/unusual.py`:修正完全错误的类型码文档(旧注释「1=5分钟涨幅, 2=5分钟跌幅」实为杜撰)与示例输出;README「监控」小节补充类型映射用法。
### 测试
- 新增 `tests/unit/test_unusual.py`(21 例,全部真实抓包字节 fixture600551/600127 竞价异动、600123/600221 收盘「尾盘」前缀、603980/603900 试买/试卖方向);既有类型解析不回归;`UNUSUAL_TYPE_NAMES` 覆盖度与兜底分支互斥性校验;`test_public_api.py` 契约表登记新导出。异动相关测试全绿;全套 1278 通过、1 例 optimizer cache 既有失败与本次无关(干净 HEAD 同样失败)。
## [1.27.1] — 2026-09-01 ## [1.27.1] — 2026-09-01
**v1.27.0 的维护版**——一项 UI 修复 + WebSocket 实时推送落地(随独立排期提交收录)。 **v1.27.0 的维护版**——一项 UI 修复 + WebSocket 实时推送落地(随独立排期提交收录)。
+15 -5
View File
@@ -17,13 +17,13 @@ easy-tdx 要做的事很简单:**把机构的数据锁砸开,扔到每个普
**34个技术指标**MACD、KDJ、RSI、BOLL……连”捉妖大师”和”30日乖离率信号”都给你算好)开箱即用。 **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 重大升级)——`easy-tdx serve` 一条命令,浏览器秒变专业看盘终端:**市场看板**(五大指数实时推送 + 迷你分时、涨跌统计、四维情绪雷达、全市场涨跌分布直方图、涨停雷达、行业/概念热冷榜、涨幅/跌幅/成交额/换手四联排行榜、两市异动雷达)、**自选行情**(输入 6 位代码即加,全表 SSE 实时刷新、行内迷你分时)、**个股详情弹窗**(五档盘口 + 1/3/5 日分时 + 带 MA/BOLL/MACD/KDJ/RSI 指标切换的日 K,一键加自选、一键全策略寻优)、**板块下钻**(行业/概念弹窗看板块走势 + 成分股涨跌榜直达个股)。实时推送采用 SSE 单循环轮询 fan-out 架构(盘中 8 秒一拍,无人订阅自动休眠),自选持久化 SQLite。展示层设计对标专业终端(暗色、红涨绿跌、高信息密度),数据全部来自通达信协议直连——不花一分钱。
<img src="./docs/web-ui-page-4.png" alt="行情终端 Web UIv1.23):市场看板 / 自选行情 / 个股与板块详情" /> <img src="./docs/web-ui-page-4.png" alt="行情终端 Web UIv1.23):市场看板 / 自选行情 / 个股与板块详情" />
**回测可视化 Web UI**v1.17 新增)——Vue3 + ECharts 单页应用,浏览器里选标的、挑策略、调参数,K 线买卖点、净值回撤、19 项绩效指标一目了然。支持组合回测、参数网格寻优、多策略结果对比,**还能把好策略存进策略库(SQLite 持久化),勾选多个策略做资金分仓组合回测看综合表现**,全程零代码。v1.27 起新增「附加分析」开关:勾选后随回测自动跑 Walk-Forward 逐窗柱状图与一条龙评估报告(评分分项 / 高适配徽标 / 买入持有对比)。 **回测可视化 Web UI**v1.17 新增)——Vue3 + ECharts 单页应用,浏览器里选标的、挑策略、调参数,K 线买卖点、净值回撤、25 项绩效指标一目了然。支持组合回测、参数网格寻优、多策略结果对比,**还能把好策略存进策略库(SQLite 持久化),勾选多个策略做资金分仓组合回测看综合表现**,全程零代码。v1.27 起新增「附加分析」开关:勾选后随回测自动跑 Walk-Forward 逐窗柱状图与一条龙评估报告(评分分项 / 高适配徽标 / 买入持有对比)。
**数据评级系统**(v1.17.14 新增)——回测结果顶部直接显示 **S/A/B/C/D 五档评级徽章**,1 秒判断「这个品种适不适合经常参与」。评级**不看收益率**(避免被近期大涨误导),只看风险调整后的持有体验:卡玛比率、最大回撤、胜率、利润因子、夏普、波动率六维加权 + 一票否决(系统亏损/深回撤/低胜率直接低评)。京东方那种「收益 126% 但胜率 35%、回撤 41%」的案例会评 **D 档**——明确告诉普通人「别碰,套牢后回本极难」。三个入口(单标的/组合/寻优)都有评级,长线低频策略不会被冤枉(交易少时只降权胜率维度,不否决整个评级)。 **数据评级系统**(v1.17.14 新增)——回测结果顶部直接显示 **S/A/B/C/D 五档评级徽章**,1 秒判断「这个品种适不适合经常参与」。评级**不看收益率**(避免被近期大涨误导),只看风险调整后的持有体验:卡玛比率、最大回撤、胜率、利润因子、夏普、波动率六维加权 + 一票否决(系统亏损/深回撤/低胜率直接低评)。京东方那种「收益 126% 但胜率 35%、回撤 41%」的案例会评 **D 档**——明确告诉普通人「别碰,套牢后回本极难」。三个入口(单标的/组合/寻优)都有评级,长线低频策略不会被冤枉(交易少时只降权胜率维度,不否决整个评级)。
@@ -508,7 +508,7 @@ Web UI 包含两大模块:
- **市场看板**:五大指数实时行情(SSE 推送)、全市场涨跌统计(涨/跌/平/涨停/跌停 + 堆叠条)、行业/概念板块热度榜、涨幅榜/跌幅榜、两市异动雷达(加速拉升/封涨停板/大单托盘等),点击个股打开五档盘口 + 分时/日K 对话框; - **市场看板**:五大指数实时行情(SSE 推送)、全市场涨跌统计(涨/跌/平/涨停/跌停 + 堆叠条)、行业/概念板块热度榜、涨幅榜/跌幅榜、两市异动雷达(加速拉升/封涨停板/大单托盘等),点击个股打开五档盘口 + 分时/日K 对话框;
- **自选行情**:输入 6 位代码一键加自选(SQLite 持久化),全表实时刷新(SSE),行内迷你分时图,点击行看个股详情; - **自选行情**:输入 6 位代码一键加自选(SQLite 持久化),全表实时刷新(SSE),行内迷你分时图,点击行看个股详情;
- **实时推送架构**:后端单条轮询循环 fan-out 到所有 SSE 连接(交易时段 ~8s 一拍,盘外降频 60s,无人订阅自动休眠),前端指数退避重连。 - **实时推送架构**:后端单条轮询循环 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 根会自动翻页拼接 - **取行情**:选市场(深/沪/北),填 6 位代码,选周期(日线/周线/分钟线),设日期范围(默认最近 3 年),点「取行情」。超过 800 根会自动翻页拼接
- **选策略**:下拉选 18 个内置策略之一(双均线交叉、MACD、布林带、RSI、KDJ、唐安奇通道、CCI 等),选中后参数表单自动出现,按推荐范围调参 - **选策略**:下拉选 18 个内置策略之一(双均线交叉、MACD、布林带、RSI、KDJ、唐安奇通道、CCI 等),选中后参数表单自动出现,按推荐范围调参
- **资金与成本**:初始资金、佣金率、滑点、成交模式(默认 next_open 下一根开盘成交) - **资金与成本**:初始资金、佣金率、滑点、成交模式(默认 next_open 下一根开盘成交)
- 点「开始回测」,右侧依次出:K 线主图(红三角=买入、绿钉=卖出)、净值曲线与回撤双轴图、19 项绩效指标表(总收益/夏普/最大回撤/胜率/盈亏比等)、成交记录明细 - 点「开始回测」,右侧依次出:K 线主图(红三角=买入、绿钉=卖出)、净值曲线与回撤双轴图、25 项绩效指标表(总收益/夏普/最大回撤/胜率/盈亏比/Ulcer/VaR/SQN 等)、成交记录明细
- 结果区右上角有「💾 保存策略」按钮,把当前策略 + 标的 + 成绩快照存进策略库,下次直接载入或参与组合回测 - 结果区右上角有「💾 保存策略」按钮,把当前策略 + 标的 + 成绩快照存进策略库,下次直接载入或参与组合回测
**2. 组合回测**`/portfolio` **2. 组合回测**`/portfolio`
@@ -575,7 +575,7 @@ EXE 打包方法见 [`docs/packaging.md`](./docs/packaging.md)。
- 保存你觉得不错的策略,下次直接载入或重跑。数据存在本地 SQLite 单文件(`~/.easy_tdx/strategies.db`,重启不丢) - 保存你觉得不错的策略,下次直接载入或重跑。数据存在本地 SQLite 单文件(`~/.easy_tdx/strategies.db`,重启不丢)
- 每张卡片展示策略名、标的、保存时的成绩快照(总收益/夏普/回撤)、标签、备注、创建时间 - 每张卡片展示策略名、标的、保存时的成绩快照(总收益/夏普/回撤)、标签、备注、创建时间
- **载入**:点「载入」跳转对应回测页(单标的/组合),自动回填标的、日期、策略参数,可直接重跑 - **载入**:点「载入」跳转对应回测页(单标的/组合),自动回填标的、日期、策略参数,可直接重跑
- **多策略组合回测**:勾选多个单标的策略(卡片左上角复选框),点顶部「组合回测(N)」——每个策略各拿 1/N 资金、各跑在它保存时的原标的上(取最新行情),净值曲线按日期对齐求和,看综合表现。结果区展示:组合净值曲线、19 项完整绩效指标(与单标的同口径)、各策略绩效对比表、净值叠加图、各策略当前持仓表(回测结束时谁还套着票) - **多策略组合回测**:勾选多个单标的策略(卡片左上角复选框),点顶部「组合回测(N)」——每个策略各拿 1/N 资金、各跑在它保存时的原标的上(取最新行情),净值曲线按日期对齐求和,看综合表现。结果区展示:组合净值曲线、25 项完整绩效指标(与单标的同口径)、各策略绩效对比表、净值叠加图、各策略当前持仓表(回测结束时谁还套着票)
> ⚠️ **任务不持久化**:回测结果存在后端进程内存,重启 `easy-tdx serve` 后清空。对比页只能选当前运行期间产生的任务。**策略库除外**——保存到策略库的策略持久存在 SQLite,重启不丢。 > ⚠️ **任务不持久化**:回测结果存在后端进程内存,重启 `easy-tdx serve` 后清空。对比页只能选当前运行期间产生的任务。**策略库除外**——保存到策略库的策略持久存在 SQLite,重启不丢。
@@ -1474,6 +1474,16 @@ with MacClient.from_best_host() as c:
df = c.get_server_info() # 服务器交易时段 df = c.get_server_info() # 服务器交易时段
``` ```
`get_unusual` 返回列含 `unusual_type`(类型码)与 `desc`(中文描述),共 19 种类型
(主力买卖/加速拉升/急速拉升/盘中强弱/竞价异动/涨跌停/大单盘口等)。类型码→名称
可用顶层常量映射:
```python
from easy_tdx import UNUSUAL_TYPE_NAMES
df["type_name"] = df["unusual_type"].map(UNUSUAL_TYPE_NAMES)
```
### 扩展市场 ### 扩展市场
```python ```python
+50
View File
@@ -206,6 +206,56 @@ u1 范围与价格非线性质相关(茅台价格 140x → u1 范围 300x
`commands/transaction.py:73,97``unknown_last`pytdx 原来直接丢弃。 `commands/transaction.py:73,97``unknown_last`pytdx 原来直接丢弃。
### 3.4 市场异动(0x1237)异动类型与数据区(已确认,Issue #62)
`mac/commands/unusual.py`:每条 32 字节记录 = `<H6sBBBHH>` 头(15B+ 13B 类型数据区
`<B2fI`v1, v2, v3, v4+ offset 28 保留字节(全类型实测恒 0x00)+ `<BH>` 时间。
**2026-09-01 全天跟踪实测**09:25~15:05 每 10 分钟一轮全量拉取 SH/SZ/BJ,收盘 17848 条)
补齐 4 个缺失类型:
| 类型 | 语义 | 数据区语义 | 证据 |
|------|------|-----------|------|
| 0x13 | 竞价试盘(试买/试卖) | v1=0x00 试买(申报价高于昨收)/ 0x01 试卖(低于昨收);v2=申报价;v3=竞价量(手) | 2026-09-02 全量 552 条对照昨收:v1 方向规律 549 条一致(2 条恰等于昨收 + 1 条异常);触发窗口 09:15~09:20。旧实现一律显示「竞价试买」,试卖方向描述错误 |
| 0x15 | 竞价/尾盘异动(拉升/下跌/平稳),**双时刻信号** | v1=0x02/0x03/0x01 方向档(±0.5% 分档);v2=时段尾段价格变动(相对昨收 pp);v3=该时段成交量(手) | 开盘竞价:早间 1191 条全部落在 09:25:00~09:25:02;收盘:86 条全部落在 15:00:01~15:00:04SH 52 / SZ 29 / BJ 5),v1 分档规律与早间一致、幅度更小(±0.5%~±2.6%)。v2 参考时刻扫描收敛于 09:23:30~09:24:0060 样本 MAE 0.11pp,受竞价 3 秒采样粒度限制);v3 略小于最终撮合量(如 600551 v3=40254 vs 终值 44865)。desc 按记录小时区分「竞价/尾盘」前缀 |
| 0x16 | 盘中强势/弱势 | v2=触发时涨跌幅;v1=带符号强弱等级(0x01~0x03 强势 1~3 级,0xFD~0xFF 弱势 1~3 级,补码);v3≈v2(差值 mean 0.83pp,语义未定) | 09:25 的 49 条样本 v2 与当日开盘涨幅(open/pre_close-1**49/49 精确一致**;全时段与收盘涨跌幅符号一致率 97%~100%;v1 六组对应 v2 区间互不重叠且单调(0x03: +7.1%~+245.9%、0xFD: -10.0%~-9.0% 等) |
| 0x1D | 急速拉升 | v1 恒 0x00(方向);v2=短窗涨幅(阈值下限 +0.6%);v3 恒 0 | 全天 1127 条,v2 恒正(+0.6%~+3.9% |
| 0x1E | 急速下跌 | v1 恒 0x01(方向);v2 恒负(-0.6%~-4.0%);v3 恒 0 | 全天 586 条 |
**0x14 的竞价试盘子族**09:15:00~09:25 期间撮合参考价触板/近板即触发(SH 早间 531 条),
与 PC 客户端推送协议的「涨停试盘/跌停试盘」对应。数据区布局与盘中 0x14 相同
v1 方向、data[1] sub_type、float 价/量),现有解析器无需改动——如 600551 09:15:00
竞价参考价 9.08(+10.06%,恰在涨停价)→「封涨停板」;600127 09:15:00 参考价 10.84
-9.97%)→「逼近跌停」。
**全天类型普查结论**:收盘全量 17848 条中类型集恒为 17 种(0x03~0x0B、0x10、0x11、
0x13~0x16、0x1D、0x1E),午间、下午、收盘竞价各窗口均无新类型码出现;2026-09-02
盘中(10:27)复查亦为同一 17 种。已知但两个交易日零触发的 2 种:0x0C 区间缩量、
0x12 大单锁盘(语义仍承自 pytdx2 一系,待采样验证)。PC 推送协议特有的「大笔买入/卖出、
主力急入/急出、急速上涨/猛烈下跌、强势封涨停/跌停、涨幅超 10% 整数倍」等信号
PigQuant 逆向的 0x40080cd1+ 消息 ID 体系)在 0x1237 拉取协议中**无对应类型码**,
两套协议的信号空间不同构:PC 端「逼近/封/打开涨跌停」对应 0x14 子类型、「买一/卖一
剩余大」对应 0x10/0x11、「急速拉升/猛烈打压」对应 0x1D/0x1E。
**排除项**
- pytdx2QuantJia/pytdx2 `parser/stock.py`)把 0x15 标作"尾盘(对倒/拉升)"——只对了一半:
0x15 在开盘竞价(09:25)与收盘(15:00)双时刻触发,他们大概只观测过下午的记录;
其 0x16"盘中弱势/强势"方向语义(v2 符号)与实测一致。
- 0x00~0x02、0x0D、0x0E、0x0F、0x17~0x1C、0x1F+ 全天零出现(未实现或极罕见)。
- 0x16 的 v1 等级公式未完全破解:等级与 v2 非纯函数关系(L2/L3 区间在 +7.1%~+9.0% 重叠),
疑与涨停幅度(ST/创业/科创)相关,待后续验证。
**请求监控参数**`<HH2xH2xH5H` 尾部 6×H,默认 1,200,30,40,50,200)单维扫描
(每位置 15 个候选值):位置 1~4 完全惰性;位置 0 仅低字节有效(≥0x400 返回空);
位置 5 在 1~127 区间致命(bit7 或 0 才有效)。所有有效变体返回类型集完全一致——
**这 6 个参数不是信号类型开关**,真实字段划分可能并非 6 个独立 H。
**其他观察**
- 北交所(Market.BJ=2)同样支持 0x1237(当日 1030 条,类型集相同)。
- 异动列表非静态快照:午间休市期间计数仍在漂移(0x07: 499→604),服务端持续重算/改写记录。
- 数据区第 4 槽(offset 25~28)本库按 uint32 解(`<B2fI`),pytdx2 按 float 解
`<Bfff`);对既有类型仅 0x10 大单托盘使用该槽,两种解法在该场景下显示等价,语义归属待定。
## 四、硬跳过的字节块 ## 四、硬跳过的字节块
### 4.1 xdxr_info 9字节响应头部(已确认:请求回显) ### 4.1 xdxr_info 9字节响应头部(已确认:请求回显)
+20 -7
View File
@@ -13,9 +13,21 @@ UnusualItem dataclass 字段:
code str 证券代码 code str 证券代码
name str 证券名称 name str 证券名称
time time 异动时间 time time 异动时间
desc str 异动描述(如 "5分钟涨幅>3%""快速拉升""大笔买入" desc str 异动描述(如 "盘中强势""竞价拉升""急速下跌"
value str 异动数值(如 "3.52%""5000手" value str 异动数值(如 "5.82%""10.05%/64310手"
unusual_type int 异动类型代码(1=5分钟涨幅, 2=5分钟跌幅, 3=快速拉升, 4=大笔成交等 unusual_type int 异动类型代码(见下表
异动类型代码表(0x1237,2026-09-01 实测锚定,可用 easy_tdx.UNUSUAL_TYPE_NAMES 映射):
0x03 主力买入/卖出 0x10 大单托盘
0x04 加速拉升 0x11 大单压盘
0x05 加速下跌 0x12 大单锁盘
0x06 低位反弹 0x13 竞价试盘(试买/试卖,09:15~09:20 触发)
0x07 高位回落 0x14 涨跌停(逼近/封板/封大减/打开)
0x08 撑杆跳高 0x15 竞价/尾盘异动(拉升/下跌/平稳,09:25 与 15:00 双时刻触发)
0x09 平台跳水 0x16 盘中强势/弱势(v1 为 1~3 级强弱等级)
0x0A 单笔冲涨/冲跌 0x1D 急速拉升
0x0B 区间放量涨/跌/平 0x1E 急速下跌
0x0C 区间缩量
返回 DataFrame 列说明: 返回 DataFrame 列说明:
index int 异动序号 index int 异动序号
@@ -59,9 +71,10 @@ with MacClient.from_best_host() as c:
print("暂无异动数据。") print("暂无异动数据。")
# 示例输出: # 示例输出:
# 共获取 1843 条异动(4 页) # 共获取 12871 条异动(22 页)
# index market code name time desc value unusual_type # index market code name time desc value unusual_type
# 1 1 600XXX XX科技 09:45:00 5分钟涨幅>3% 3.52% 1 # 1 1 600551 时代出版 09:25:00 竞价下跌 -1.21%/40254手 21
# 2 1 601XXX XX银行 09:52:00 5分钟涨幅>3% 3.15% 1 # 2 1 600551 时代出版 09:25:00 盘中强势 5.82% 22
# 3 1 600XXX XX能源 10:05:00 5分钟跌幅>3% -3.28% 2 # 3 1 600295 鄂尔多斯 09:25:00 竞价拉升 2.88%/533手 21
# 4 1 605365 立达信 09:35:08 急速拉升 1.62% 29
# ... # ...
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "easy-tdx" name = "easy-tdx"
version = "1.27.1" version = "1.28.0"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
+2
View File
@@ -27,6 +27,7 @@ from .ex.mac_client import AsyncMacExClient, MacExClient
from .ex.models import KNOWN_EX_HOSTS from .ex.models import KNOWN_EX_HOSTS
from .exceptions import TdxCommandError, TdxConnectionError, TdxDecodeError, TdxError from .exceptions import TdxCommandError, TdxConnectionError, TdxDecodeError, TdxError
from .mac.client import AsyncMacClient, MacClient from .mac.client import AsyncMacClient, MacClient
from .mac.commands import UNUSUAL_TYPE_NAMES
from .mac.enums import ( from .mac.enums import (
Adjust, Adjust,
BoardSortColumn, BoardSortColumn,
@@ -90,6 +91,7 @@ __all__ = [
"TransactionRecord", "TransactionRecord",
"XdxrRecord", "XdxrRecord",
"XDXR_CATEGORY_NAMES", "XDXR_CATEGORY_NAMES",
"UNUSUAL_TYPE_NAMES",
"FinanceInfo", "FinanceInfo",
"CompanyInfoCategory", "CompanyInfoCategory",
"FinancialFileInfo", "FinancialFileInfo",
+120 -16
View File
@@ -15,7 +15,9 @@
"fitness": {"pass_ratio": 0.875, "high_fitness": true, "checks": [...]}, "fitness": {"pass_ratio": 0.875, "high_fitness": true, "checks": [...]},
"benchmark": { "benchmark": {
"buy_hold": {"total_return": 0.32, ...}, "buy_hold": {"total_return": 0.32, ...},
"excess_return": 0.18 # 策略 - 买入持有 "excess_return": 0.18, # 策略 - 买入持有
"alpha": 0.09, "beta": 0.72, # v1.28CAPM 对比
"information_ratio": 0.85, "tracking_error": 0.12 # v1.28:主动管理指标
}, },
"config": {...} "config": {...}
} }
@@ -27,8 +29,9 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import TYPE_CHECKING, Any
import numpy as np
import pandas as pd import pandas as pd
from easy_tdx.backtest.engine import BacktestEngine 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.types import to_json_native
from easy_tdx.backtest.walkforward import WalkForwardEngine 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): class _BuyAndHold(Strategy):
@@ -54,6 +66,32 @@ class _BuyAndHold(Strategy):
self._bought = True 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( def run_buy_hold_benchmark(
df: pd.DataFrame, df: pd.DataFrame,
cash: float = 100000.0, cash: float = 100000.0,
@@ -66,18 +104,9 @@ def run_buy_hold_benchmark(
auto_fees: bool = False, auto_fees: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""买入持有基准回测(与策略回测同区间、同费率、同资金)。""" """买入持有基准回测(与策略回测同区间、同费率、同资金)。"""
engine = BacktestEngine( result = _run_buy_hold_result(
strategy=_BuyAndHold, df, cash, commission, min_commission, stamp_tax, slippage, execution, symbol, auto_fees
cash=cash,
commission=commission,
min_commission=min_commission,
stamp_tax=stamp_tax,
slippage=slippage,
execution=execution,
symbol=symbol,
auto_fees=auto_fees,
) )
result = engine.run(df)
keys = ( keys = (
"total_return", "total_return",
"annual_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})) return dict(to_json_native({k: result.performance.get(k, 0.0) for k in keys}))
def compute_benchmark_comparison(
strategy_curve: pd.DataFrame,
benchmark_curve: pd.DataFrame,
annual_days: int = 252,
) -> dict[str, float]:
"""策略 vs 基准的 CAPM / 主动管理对比指标(v1.28 新增)。
从两条资金曲线的日收益率序列计算:
- ``beta``: 协方差/基准方差,策略对基准的敏感度(1 = 与基准同涨跌)
- ``alpha``: 年化 CAPM α ≈ (策略日均收益 − β×基准日均收益) × 年化天数,
简化版(无风险利率并入截距),>0 说明剔除基准影响后仍有超额
- ``information_ratio``: 年化信息比率 = mean(策略−基准)/std(策略−基准)×√N,
每 1 单位跟踪误差换来多少超额收益
- ``tracking_error``: 年化跟踪误差 = std(策略−基准)×√N
两条曲线按 bar 对齐(截取较短长度);基准方差为 0(曲线恒定)时
beta/alpha 记 0IR 在差值恒正且无波动时沿用 999 上限约定。
Args:
strategy_curve: 策略资金曲线(含 total 列)
benchmark_curve: 基准资金曲线(含 total 列)
annual_days: 年化交易日数
Returns:
{alpha, beta, information_ratio, tracking_error}
"""
s_total = strategy_curve["total"].to_numpy(dtype=np.float64)
b_total = benchmark_curve["total"].to_numpy(dtype=np.float64)
n = min(len(s_total), len(b_total))
if n < 3:
return {"alpha": 0.0, "beta": 0.0, "information_ratio": 0.0, "tracking_error": 0.0}
def _daily_ret(total: NDArray) -> NDArray:
safe_prev = np.where(total[:-1] != 0, total[:-1], np.nan)
ret = np.diff(total) / safe_prev
return ret[np.isfinite(ret)]
s_ret = _daily_ret(s_total[:n])
b_ret = _daily_ret(b_total[:n])
m = min(len(s_ret), len(b_ret))
if m < 2:
return {"alpha": 0.0, "beta": 0.0, "information_ratio": 0.0, "tracking_error": 0.0}
s_ret, b_ret = s_ret[:m], b_ret[:m]
b_var = float(np.var(b_ret))
if b_var > 1e-18:
beta = float(np.cov(s_ret, b_ret)[0, 1] / b_var)
alpha = float((np.mean(s_ret) - beta * np.mean(b_ret)) * annual_days)
else:
beta = 0.0
alpha = float(np.mean(s_ret) * annual_days)
diff = s_ret - b_ret
diff_std = float(np.std(diff))
if diff_std > 1e-12:
information_ratio = float(np.mean(diff) / diff_std * np.sqrt(annual_days))
elif np.mean(diff) > 0:
information_ratio = 999.0
else:
information_ratio = 0.0
tracking_error = diff_std * np.sqrt(annual_days)
return {
"alpha": alpha,
"beta": beta,
"information_ratio": information_ratio,
"tracking_error": tracking_error,
}
def evaluate_strategy( def evaluate_strategy(
strategy: type[Strategy] | Strategy, strategy: type[Strategy] | Strategy,
df: pd.DataFrame, df: pd.DataFrame,
@@ -153,8 +253,11 @@ def evaluate_strategy(
score = score_strategy(perf, wf=wf) score = score_strategy(perf, wf=wf)
grade = grade_performance(perf) grade = grade_performance(perf)
# 5. 基准对比(买入持有,同区间同费率) # 5. 基准对比(买入持有,同区间同费率):超额收益 + Alpha/Beta/IR/TE
bh = run_buy_hold_benchmark(df, **engine_kwargs) 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 { return {
"performance": to_json_native(dict(perf)), "performance": to_json_native(dict(perf)),
@@ -166,6 +269,7 @@ def evaluate_strategy(
"buy_hold": bh, "buy_hold": bh,
"excess_return": float(perf.get("total_return", 0.0)) "excess_return": float(perf.get("total_return", 0.0))
- float(bh.get("total_return", 0.0)), - float(bh.get("total_return", 0.0)),
**comparison,
}, },
"config": { "config": {
"symbol": symbol, "symbol": symbol,
+10
View File
@@ -318,6 +318,16 @@ def _print_table(result: Any) -> None:
click.echo(f"夏普比率: {perf.get('sharpe', 0):.2f}") click.echo(f"夏普比率: {perf.get('sharpe', 0):.2f}")
click.echo(f"胜率: {perf.get('win_rate', 0):.2%}") click.echo(f"胜率: {perf.get('win_rate', 0):.2%}")
click.echo(f"交易次数: {perf.get('total_trades', 0)}") 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() click.echo()
if getattr(result, "diagnostic", None): if getattr(result, "diagnostic", None):
+43 -10
View File
@@ -37,13 +37,32 @@ if TYPE_CHECKING:
class _StopCondition: class _StopCondition:
"""Active stop-loss / take-profit condition tied to an open position. """Active stop-loss / take-profit condition tied to an open position.
三条退出线构成 OCO:任一触发即整体失效(见 ``_check_stop_conditions``)。
Attributes: Attributes:
stop_loss: Price below which a SELL is triggered (None = disabled) stop_loss: Price below which a SELL is triggered (None = disabled)
take_profit: Price above 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 stop_loss: float | None
take_profit: 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: class BacktestEngine:
@@ -375,10 +394,17 @@ class BacktestEngine:
# activate on the NEXT bar — consistent with next_open execution) # activate on the NEXT bar — consistent with next_open execution)
for sig in bar_signals: for sig in bar_signals:
if sig.direction == "BUY" and ( 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( 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) # Clear conditions when a SELL occurs (strategy or SL/TP triggered)
@@ -488,8 +514,11 @@ class BacktestEngine:
"""Check active SL/TP conditions against current bar's price range. """Check active SL/TP conditions against current bar's price range.
If triggered, generates a SELL signal at the trigger price and removes If triggered, generates a SELL signal at the trigger price and removes
the condition. Stop-loss is checked first (conservative: assume the the condition (OCO: all remaining legs of the same condition die too).
worst case for the holder). 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: Args:
active_stops: List of active stop conditions active_stops: List of active stop conditions
@@ -512,16 +541,22 @@ class BacktestEngine:
triggered = False triggered = False
trigger_price = 0.0 trigger_price = 0.0
# Check stop-loss first (worst case for holder) # Check stop-loss first (worst case for holder); trailing resolves
if cond.stop_loss is not None and bar_low <= cond.stop_loss: # 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 triggered = True
trigger_price = cond.stop_loss trigger_price = eff_stop
# Then check take-profit # Then check take-profit
elif cond.take_profit is not None and bar_high >= cond.take_profit: elif cond.take_profit is not None and bar_high >= cond.take_profit:
triggered = True triggered = True
trigger_price = cond.take_profit 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 # Get datetime for this bar
dt_val = df["datetime"].iloc[bar_index] dt_val = df["datetime"].iloc[bar_index]
if hasattr(dt_val, "strftime"): if hasattr(dt_val, "strftime"):
@@ -538,8 +573,6 @@ class BacktestEngine:
source="stop", # 标记为止损/止盈触发,延迟到下一根成交 source="stop", # 标记为止损/止盈触发,延迟到下一根成交
) )
) )
else:
remaining.append(cond)
active_stops.clear() active_stops.clear()
active_stops.extend(remaining) active_stops.extend(remaining)
+69 -2
View File
@@ -22,7 +22,8 @@ else:
class PerformanceAnalyzer: class PerformanceAnalyzer:
"""绩效分析器。 """绩效分析器。
从资金曲线和交易记录计算 19 项绩效指标 从资金曲线和交易记录计算 25 项绩效指标19 项经典指标 + 6 项
深度风险指标:Ulcer / VaR / CVaR / SQN / 最大连胜连亏,v1.28 新增)。
Attributes: Attributes:
ANNUAL_DAYS: 年化交易日数(默认 252) ANNUAL_DAYS: 年化交易日数(默认 252)
@@ -55,7 +56,7 @@ class PerformanceAnalyzer:
"""计算绩效指标。 """计算绩效指标。
Returns: Returns:
包含 19 项指标的字典: 包含 25 项指标的字典:
- total_return: 总收益率 - total_return: 总收益率
- annual_return: 年化收益率 - annual_return: 年化收益率
- max_drawdown: 最大回撤 - max_drawdown: 最大回撤
@@ -75,6 +76,14 @@ class PerformanceAnalyzer:
- max_loss: 最大亏损 - max_loss: 最大亏损
- avg_holding_days: 平均持仓天数(FIFO 配对、按 size 加权,日历日口径) - avg_holding_days: 平均持仓天数(FIFO 配对、按 size 加权,日历日口径)
- volatility: 年化波动率 - 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: if len(self._equity_curve) < 2:
@@ -211,6 +220,28 @@ class PerformanceAnalyzer:
# 19. 年化波动率 # 19. 年化波动率
volatility = np.std(daily_ret) * np.sqrt(self.ANNUAL_DAYS) 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 { return {
"total_return": total_return, "total_return": total_return,
"annual_return": annual_return, "annual_return": annual_return,
@@ -231,6 +262,12 @@ class PerformanceAnalyzer:
"max_loss": max_loss, "max_loss": max_loss,
"avg_holding_days": avg_holding_days, "avg_holding_days": avg_holding_days,
"volatility": volatility, "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 # 别名键(兼容常见叫法,避免 .get('sharpe_ratio') 等误用返回 0
"sharpe_ratio": sharpe, "sharpe_ratio": sharpe,
"start_cash": float(total[0]), "start_cash": float(total[0]),
@@ -371,7 +408,37 @@ class PerformanceAnalyzer:
"max_loss": 0.0, "max_loss": 0.0,
"avg_holding_days": 0.0, "avg_holding_days": 0.0,
"volatility": 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, "sharpe_ratio": 0.0,
"start_cash": 0.0, "start_cash": 0.0,
"end_value": 0.0, "end_value": 0.0,
} }
@staticmethod
def _max_win_lose_streaks(pnl_seq: NDArray) -> tuple[int, int]:
"""按成交顺序统计最大连胜/连亏笔数。
pnl > 0 记为胜,pnl <= 0 记为负(与 win_rate 的胜/负口径一致)。
Args:
pnl_seq: SELL 成交的 pnl 序列(时间升序)
Returns:
(最大连胜笔数, 最大连亏笔数)
"""
max_wins = max_losses = cur_wins = cur_losses = 0
for pnl in pnl_seq:
if pnl > 0:
cur_wins += 1
cur_losses = 0
max_wins = max(max_wins, cur_wins)
else:
cur_losses += 1
cur_wins = 0
max_losses = max(max_losses, cur_losses)
return int(max_wins), int(max_losses)
+28 -3
View File
@@ -336,20 +336,44 @@ class Strategy(ABC):
price: float | None = None, price: float | None = None,
stop_loss: float | None = None, stop_loss: float | None = None,
take_profit: 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: ) -> None:
"""生成买入信号。 """生成买入信号(可携带 bracket 止损/止盈/移动止损,OCO 联动)
akquant ``place_bracket`` 风格:进出场一体化,不必再手写止损监控。
三条退出线任一触发即全部失效(OCO),由引擎逐 bar 监控并自动
生成 SELL``source="stop"``,延迟到下一根开盘成交,消除前视偏差)。
Args: Args:
size: 交易数量(0 = 全仓,由引擎计算) size: 交易数量(0 = 全仓,由引擎计算)
price: 限价(None = 市价单) price: 限价(None = 市价单)
stop_loss: 止损价(None = 不设置 stop_loss: 止损价(绝对价;与 stop_loss_pct 同时给时绝对价优先
take_profit: 止盈价(None = 不设置 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: if self._data_proxy is None:
raise RuntimeError("策略未绑定数据,请先调用 _bind_data()") raise RuntimeError("策略未绑定数据,请先调用 _bind_data()")
if self._datetime_array is None: if self._datetime_array is None:
raise RuntimeError("数据未正确初始化") 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( signal = Signal(
datetime=int(self._datetime_array[self._bar_index]), datetime=int(self._datetime_array[self._bar_index]),
direction="BUY", direction="BUY",
@@ -357,6 +381,7 @@ class Strategy(ABC):
price=price, price=price,
stop_loss=stop_loss, stop_loss=stop_loss,
take_profit=take_profit, take_profit=take_profit,
trail_stop=trail_stop,
) )
self._signals.append(signal) self._signals.append(signal)
+6
View File
@@ -25,9 +25,14 @@ class Signal:
price: 限价(None = 市价单) price: 限价(None = 市价单)
stop_loss: 止损价(None = 不设置) stop_loss: 止损价(None = 不设置)
take_profit: 止盈价(None = 不设置) take_profit: 止盈价(None = 不设置)
trail_stop: 移动止损百分比(如 0.08 = 自持仓期间最高收盘价回撤
8% 触发,None = 不设置)。与 ``stop_loss`` 同时设置时固定价优先。
source: 信号来源。"strategy"=策略产生(默认); source: 信号来源。"strategy"=策略产生(默认);
"stop"=止损/止盈触发。stop 来源的信号不在信号 bar 当根成交, "stop"=止损/止盈触发。stop 来源的信号不在信号 bar 当根成交,
而是延迟到下一根开盘(消除用当根 intrabar 触发价成交的前视偏差)。 而是延迟到下一根开盘(消除用当根 intrabar 触发价成交的前视偏差)。
``stop_loss`` / ``take_profit`` / ``trail_stop`` 三者构成 OCO
one-cancels-other):任一触发即全部失效,由引擎逐 bar 监控。
""" """
datetime: int datetime: int
@@ -36,6 +41,7 @@ class Signal:
price: float | None = None price: float | None = None
stop_loss: float | None = None stop_loss: float | None = None
take_profit: float | None = None take_profit: float | None = None
trail_stop: float | None = None
source: str = "strategy" source: str = "strategy"
+2 -1
View File
@@ -13,7 +13,7 @@ from .symbol_quotes import SymbolQuotesCmd
from .symbol_tick_chart import SymbolTickChartCmd from .symbol_tick_chart import SymbolTickChartCmd
from .symbol_transaction import SymbolTransactionCmd from .symbol_transaction import SymbolTransactionCmd
from .tick_charts import TickChartsCmd from .tick_charts import TickChartsCmd
from .unusual import UnusualCmd from .unusual import UNUSUAL_TYPE_NAMES, UnusualCmd
__all__ = [ __all__ = [
"BoardListCmd", "BoardListCmd",
@@ -29,5 +29,6 @@ __all__ = [
"SymbolTickChartCmd", "SymbolTickChartCmd",
"SymbolTransactionCmd", "SymbolTransactionCmd",
"TickChartsCmd", "TickChartsCmd",
"UNUSUAL_TYPE_NAMES",
"UnusualCmd", "UnusualCmd",
] ]
+62 -6
View File
@@ -8,9 +8,34 @@ from ...codec.mac_frame import build_mac_request
from ...commands.base import BaseCommand from ...commands.base import BaseCommand
from ..models import UnusualItem from ..models import UnusualItem
# 异动类型 → 粗粒度名称映射(Issue #62)。
# 0x15/0x16/0x1D/0x1E 语义由 2026-09-01 全市场 12871 条实测锚定,详见
# docs/protocol-unknown-fields.md「市场异动(0x1237)异动类型」一节。
UNUSUAL_TYPE_NAMES: dict[int, str] = {
0x03: "主力买入卖出",
0x04: "加速拉升",
0x05: "加速下跌",
0x06: "低位反弹",
0x07: "高位回落",
0x08: "撑杆跳高",
0x09: "平台跳水",
0x0A: "单笔冲涨跌",
0x0B: "区间放量",
0x0C: "区间缩量",
0x10: "大单托盘",
0x11: "大单压盘",
0x12: "大单锁盘",
0x13: "竞价试盘",
0x14: "涨跌停",
0x15: "竞价/尾盘异动",
0x16: "盘中强势弱势",
0x1D: "急速拉升",
0x1E: "急速下跌",
}
def _describe_unusual(unusual_type: int, data: bytes) -> tuple[str, str]:
"""根据异动类型解析描述和数值。""" def _describe_unusual(unusual_type: int, data: bytes, hour: int = 9) -> tuple[str, str]:
"""根据异动类型解析描述和数值。hour 用于区分竞价/尾盘双时刻信号(0x15)。"""
if len(data) < 13: if len(data) < 13:
return "", "" return "", ""
v1, v2, v3, v4 = struct.unpack_from("<B2fI", data) v1, v2, v3, v4 = struct.unpack_from("<B2fI", data)
@@ -56,8 +81,14 @@ def _describe_unusual(unusual_type: int, data: bytes) -> tuple[str, str]:
desc = "大单锁盘" desc = "大单锁盘"
val = "" val = ""
elif unusual_type == 0x13: elif unusual_type == 0x13:
desc = "竞价试买" # 竞价试盘(09:15~09:20 触发):v1=0x00 试买(申报价高于昨收)/ 0x01 试卖
val = f"{v2:.2f}/{v3:.2f}" # (低于昨收);v2 为申报价,v3 为竞价量(手)。方向规律 2026-09-02
# 全量 552 条对照昨收 549 条一致(2 条恰等于昨收的边界 + 1 条异常)。
if v1 == 0x01:
desc = "竞价试卖"
else:
desc = "竞价试买"
val = f"{v2:.2f}/{v3:.0f}"
elif unusual_type == 0x14: elif unusual_type == 0x14:
direction = "" if v1 == 0x00 else "" direction = "" if v1 == 0x00 else ""
if len(data) >= 10: if len(data) >= 10:
@@ -75,6 +106,31 @@ def _describe_unusual(unusual_type: int, data: bytes) -> tuple[str, str]:
else: else:
desc = f"涨跌停({direction})" desc = f"涨跌停({direction})"
val = f"{v2_alt:.2f}/{v3_alt:.2f}" val = f"{v2_alt:.2f}/{v3_alt:.2f}"
elif unusual_type == 0x15:
# 竞价/尾盘异动:开盘竞价(09:25)与收盘(15:00)两个撮合时刻都会触发。
# v1=0x02 拉升 / 0x03 下跌 / 0x01 平稳(±0.5% 分档);v2 为时段尾段价格
# 变动(相对昨收),v3 为该时段成交量(手)。
stage = "竞价" if hour < 12 else "尾盘"
if v1 == 0x02:
desc = f"{stage}拉升"
elif v1 == 0x03:
desc = f"{stage}下跌"
elif v1 == 0x01:
desc = f"{stage}平稳"
else:
desc = f"{stage}异动"
val = f"{v2 * 100:.2f}%/{v3:.0f}"
elif unusual_type == 0x16:
# 盘中强势/弱势:v2 = 触发时涨跌幅(09:25 样本与开盘涨幅 49/49 精确一致),
# v1 为带符号强弱等级(0x01~0x03 强势 1~3 级,0xFD~0xFF 弱势 1~3 级)。
desc = "盘中强势" if v2 >= 0 else "盘中弱势"
val = f"{v2 * 100:.2f}%"
elif unusual_type == 0x1D:
desc = "急速拉升"
val = f"{v2 * 100:.2f}%"
elif unusual_type == 0x1E:
desc = "急速下跌"
val = f"{v2 * 100:.2f}%"
else: else:
desc = f"异动类型{unusual_type:#04x}" desc = f"异动类型{unusual_type:#04x}"
val = "" val = ""
@@ -129,10 +185,10 @@ class UnusualCmd(BaseCommand[list[UnusualItem]]):
"<H6sBBBHH", body, offset, f"unusual record[{i}]" "<H6sBBBHH", body, offset, f"unusual record[{i}]"
) )
desc, value = _describe_unusual(unusual_type, body[offset + 15 : offset + 28])
hour, minute_sec = unpack_from("<BH", body, offset + 29, f"unusual time[{i}]") hour, minute_sec = unpack_from("<BH", body, offset + 29, f"unusual time[{i}]")
desc, value = _describe_unusual(unusual_type, body[offset + 15 : offset + 28], hour)
results.append( results.append(
UnusualItem( UnusualItem(
index=index, index=index,
+341
View File
@@ -0,0 +1,341 @@
{
"benchmark_comparison": {
"alpha": -0.07509968942791662,
"beta": 0.52345815483129,
"information_ratio": -0.4168372094924506,
"tracking_error": 0.1915582998133785
},
"buy_hold": {
"annual_return": -0.02751052343019722,
"calmar": -0.10790264140303352,
"max_drawdown": 0.2549569044138692,
"sharpe": -0.07276274597183686,
"total_return": -0.043207472350363485,
"volatility": 0.2753412305613209
},
"meta": {
"bars": 400,
"cash": 100000.0,
"note": "regen: EASY_TDX_REGEN_GOLDEN=1 pytest tests/unit/test_golden_backtest.py",
"seed": 20260902,
"tolerance": {
"abs": 1e-06,
"rel": 1e-06
}
},
"rules": {
"bracket_pct": {
"total_return": 0.06621316999999993,
"total_trades": 1,
"trades": [
[
"BUY",
10.3,
20240102
],
[
"SELL",
11.0,
20240105
]
]
},
"stop_loss": {
"total_return": -0.11904648000000007,
"total_trades": 1,
"trades": [
[
"BUY",
10.2,
20240102
],
[
"SELL",
9.0,
20240108
]
]
},
"take_profit": {
"total_return": 0.06621316999999993,
"total_trades": 1,
"trades": [
[
"BUY",
10.3,
20240102
],
[
"SELL",
11.0,
20240105
]
]
},
"trailing_stop": {
"total_return": 0.027762419999999954,
"total_trades": 1,
"trades": [
[
"BUY",
10.2,
20240102
],
[
"SELL",
10.5,
20240112
]
]
}
},
"strategies": {
"atr_breakout": {
"cvar_95": 0.029816014464276553,
"max_consecutive_losses": 2,
"max_consecutive_wins": 1,
"max_drawdown": 0.2338437267295321,
"sharpe": -0.1980068530470557,
"sqn": -0.2643143297795612,
"total_return": -0.051365472018247815,
"total_trades": 5,
"ulcer_index": 0.11610199605370222,
"var_95": 0.023167064579450905,
"win_rate": 0.2
},
"bbi": {
"cvar_95": 0.028403780640227493,
"max_consecutive_losses": 11,
"max_consecutive_wins": 6,
"max_drawdown": 0.17316305538486726,
"sharpe": -0.047920869432565044,
"sqn": 0.2034947102900509,
"total_return": 0.0015367014340572638,
"total_trades": 37,
"ulcer_index": 0.09154821119005127,
"var_95": 0.02185464362709669,
"win_rate": 0.43243243243243246
},
"bias_reversal": {
"cvar_95": 0.031800618199536355,
"max_consecutive_losses": 4,
"max_consecutive_wins": 6,
"max_drawdown": 0.28538313496863427,
"sharpe": -0.8098413586757496,
"sqn": -0.6927111806707453,
"total_return": -0.22014868093324402,
"total_trades": 45,
"ulcer_index": 0.1949515199887598,
"var_95": 0.02439437484336824,
"win_rate": 0.5111111111111111
},
"boll_breakout": {
"cvar_95": 0.025123143703972416,
"max_consecutive_losses": 1,
"max_consecutive_wins": 2,
"max_drawdown": 0.12529045393048568,
"sharpe": -0.08270429911478913,
"sqn": 0.984709712259246,
"total_return": 0.00949379410277551,
"total_trades": 3,
"ulcer_index": 0.039203351555811915,
"var_95": 0.015350573492733575,
"win_rate": 0.6666666666666666
},
"cci": {
"cvar_95": 0.028065506761634905,
"max_consecutive_losses": 1,
"max_consecutive_wins": 3,
"max_drawdown": 0.17514368495494725,
"sharpe": -0.4733373193054346,
"sqn": -0.33416732892726264,
"total_return": -0.1047789450922677,
"total_trades": 11,
"ulcer_index": 0.0895732932132124,
"var_95": 0.019114854583397116,
"win_rate": 0.6363636363636364
},
"dmi": {
"cvar_95": 0.029255190955404537,
"max_consecutive_losses": 5,
"max_consecutive_wins": 3,
"max_drawdown": 0.26277279842089457,
"sharpe": -0.538224272020581,
"sqn": -0.6542541127994779,
"total_return": -0.144868582491816,
"total_trades": 22,
"ulcer_index": 0.14141379126490972,
"var_95": 0.022084559354451774,
"win_rate": 0.45454545454545453
},
"donchian": {
"cvar_95": -0.0,
"max_consecutive_losses": 0,
"max_consecutive_wins": 0,
"max_drawdown": 0.0,
"sharpe": 0.0,
"sqn": 0.0,
"total_return": 0.0,
"total_trades": 0,
"ulcer_index": 0.0,
"var_95": -0.0,
"win_rate": 0.0
},
"dpo": {
"cvar_95": 0.02721297612445824,
"max_consecutive_losses": 6,
"max_consecutive_wins": 4,
"max_drawdown": 0.19787887049273278,
"sharpe": 0.08973761311003028,
"sqn": 0.4060312738886599,
"total_return": 0.04626258446140685,
"total_trades": 40,
"ulcer_index": 0.09456618993344078,
"var_95": 0.019685727992254924,
"win_rate": 0.45
},
"ema_cross": {
"cvar_95": 0.030512787117540286,
"max_consecutive_losses": 5,
"max_consecutive_wins": 1,
"max_drawdown": 0.2959272480843976,
"sharpe": -0.7875446987996052,
"sqn": -1.4136352678571986,
"total_return": -0.21673831411102973,
"total_trades": 8,
"ulcer_index": 0.17424109127336226,
"var_95": 0.02416619544856333,
"win_rate": 0.125
},
"emv": {
"cvar_95": 0.029127493175167347,
"max_consecutive_losses": 6,
"max_consecutive_wins": 3,
"max_drawdown": 0.3223214933637952,
"sharpe": -1.0544258453115651,
"sqn": -1.6556723755607416,
"total_return": -0.2575566534714613,
"total_trades": 28,
"ulcer_index": 0.21540763583933592,
"var_95": 0.02157076164724241,
"win_rate": 0.32142857142857145
},
"fsl": {
"cvar_95": 0.028932815510588083,
"max_consecutive_losses": 5,
"max_consecutive_wins": 2,
"max_drawdown": 0.16041976356729326,
"sharpe": -0.12011672214054765,
"sqn": -0.07772875586670183,
"total_return": -0.026169388111291436,
"total_trades": 14,
"ulcer_index": 0.08345129720689942,
"var_95": 0.022744109351729155,
"win_rate": 0.35714285714285715
},
"kdj_cross": {
"cvar_95": 0.027898201822013535,
"max_consecutive_losses": 3,
"max_consecutive_wins": 6,
"max_drawdown": 0.10762922092720545,
"sharpe": 0.8589847738542853,
"sqn": 1.2700892113941968,
"total_return": 0.33234070489431433,
"total_trades": 28,
"ulcer_index": 0.05288447475839552,
"var_95": 0.019645713150630486,
"win_rate": 0.5357142857142857
},
"keltner": {
"cvar_95": 0.02977493291904796,
"max_consecutive_losses": 2,
"max_consecutive_wins": 1,
"max_drawdown": 0.22976199965457847,
"sharpe": -0.1732213286120034,
"sqn": -0.22326773643387887,
"total_return": -0.04444609288676726,
"total_trades": 4,
"ulcer_index": 0.11754644293259853,
"var_95": 0.02351295669073764,
"win_rate": 0.25
},
"ma_cross": {
"cvar_95": 0.02953584331940908,
"max_consecutive_losses": 3,
"max_consecutive_wins": 4,
"max_drawdown": 0.22131421204998106,
"sharpe": -0.4990885286170613,
"sqn": -1.0839091744378302,
"total_return": -0.1326834663173594,
"total_trades": 11,
"ulcer_index": 0.11891561252900337,
"var_95": 0.022064133071800284,
"win_rate": 0.5454545454545454
},
"macd": {
"cvar_95": 0.02839428517023986,
"max_consecutive_losses": 6,
"max_consecutive_wins": 5,
"max_drawdown": 0.18473872916346823,
"sharpe": -0.38338812036224335,
"sqn": -0.5295484249243184,
"total_return": -0.10413146619958658,
"total_trades": 17,
"ulcer_index": 0.10417476558778298,
"var_95": 0.02189769434012649,
"win_rate": 0.35294117647058826
},
"rsi_reversal": {
"cvar_95": 0.030776659581515254,
"max_consecutive_losses": 1,
"max_consecutive_wins": 1,
"max_drawdown": 0.23599475582323245,
"sharpe": 0.4065196788206701,
"sqn": 0.6504806756551088,
"total_return": 0.16567827342668773,
"total_trades": 2,
"ulcer_index": 0.09450845521195947,
"var_95": 0.024060510820245292,
"win_rate": 0.5
},
"triple_ma": {
"cvar_95": 0.030748053405195853,
"max_consecutive_losses": 2,
"max_consecutive_wins": 1,
"max_drawdown": 0.26736051511671544,
"sharpe": -0.2867281890227494,
"sqn": -2.2739244432234624,
"total_return": -0.07872900419821216,
"total_trades": 4,
"ulcer_index": 0.13253815816514244,
"var_95": 0.023068752460779107,
"win_rate": 0.25
},
"trix": {
"cvar_95": 0.027724285540264466,
"max_consecutive_losses": 3,
"max_consecutive_wins": 4,
"max_drawdown": 0.29063024861099856,
"sharpe": -0.7642482349371779,
"sqn": -0.9463810886485136,
"total_return": -0.1947048613134198,
"total_trades": 12,
"ulcer_index": 0.17538728786286353,
"var_95": 0.021929213323307422,
"win_rate": 0.5
},
"wr_reversal": {
"cvar_95": -0.0,
"max_consecutive_losses": 0,
"max_consecutive_wins": 0,
"max_drawdown": 0.0,
"sharpe": 0.0,
"sqn": 0.0,
"total_return": 0.0,
"total_trades": 0,
"ulcer_index": 0.0,
"var_95": -0.0,
"win_rate": 0.0
}
}
}
+292
View File
@@ -0,0 +1,292 @@
"""黄金测试(golden tests):回测引擎指标快照回归(v1.28 新增)。
借鉴 akquant 的 golden 测试机制:把「内置策略在固定随机种子合成数据上的
全部绩效指标」与「交易规则场景(止损/止盈/移动止损/OCO/费率)的成交明细」
锁定为 JSON 基线(``tests/golden/backtest_metrics.json``),每次引擎改动后
跑一遍比对——撮合、费率、信号时序任何静默漂移都会在这里爆出来。
生成/更新基线::
EASY_TDX_REGEN_GOLDEN=1 python -m pytest tests/unit/test_golden_backtest.py
比对容差:rel=1e-6 / abs=1e-6——紧到能抓住费率或成交时点级别的逻辑漂移
(通常引起 >0.001 的变动),松到容忍跨平台浮点求和顺序的尾数噪声。
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
import pytest
from easy_tdx.backtest.benchmark import (
compute_benchmark_comparison,
run_buy_hold_benchmark,
)
from easy_tdx.backtest.engine import BacktestEngine
from easy_tdx.backtest.strategies import builtin # noqa: F401 # 触发注册
from easy_tdx.backtest.strategies.registry import _REGISTRY
from easy_tdx.backtest.strategy import Strategy
GOLDEN_PATH = Path(__file__).resolve().parents[1] / "golden" / "backtest_metrics.json"
REGEN = os.environ.get("EASY_TDX_REGEN_GOLDEN", "") == "1"
# 与基线 meta 一致的固定参数
SEED = 20260902
BARS = 400
CASH = 100000.0
# 内置策略锁定的指标子集(全部为确定性数值;int 与 float 分开比对)
STRATEGY_METRICS_FLOAT = (
"total_return",
"max_drawdown",
"sharpe",
"win_rate",
"ulcer_index",
"var_95",
"cvar_95",
"sqn",
)
STRATEGY_METRICS_INT = (
"total_trades",
"max_consecutive_wins",
"max_consecutive_losses",
)
def _golden_df() -> pd.DataFrame:
"""固定种子的合成日线(几何随机游走 + 温和上行漂移)。"""
rng = np.random.default_rng(SEED)
close = 20.0 * np.exp(np.cumsum(rng.normal(0.0004, 0.018, BARS)))
high = close * (1 + np.abs(rng.normal(0, 0.008, BARS)))
low = close * (1 - np.abs(rng.normal(0, 0.008, BARS)))
open_ = low + (high - low) * rng.uniform(0, 1, BARS)
vol = rng.uniform(5e5, 5e6, BARS)
return pd.DataFrame(
{
"datetime": pd.date_range("2023-01-02", periods=BARS, freq="B"),
"open": open_,
"high": high,
"low": low,
"close": close,
"vol": vol,
"amount": close * vol,
}
)
# ── 规则场景策略(手工构造行情路径,锁定触发语义本身) ───────────────────────
class _BuyOnce(Strategy):
"""首根买入(可携带 bracket 参数),不再主动交易;无参数时即买入持有。"""
def __init__(self, **bracket: Any) -> None:
super().__init__()
self._bracket: dict[str, Any] = bracket
self._bought = False
def init(self) -> None:
pass
def next(self) -> None:
if not self._bought:
self.buy(**self._bracket)
self._bought = True
def _rule_df(closes: list[float]) -> pd.DataFrame:
"""按收盘价序列构造无随机因素的 OHLChigh/low = close ±1%)。"""
arr = np.asarray(closes, dtype=float)
n = len(arr)
return pd.DataFrame(
{
"datetime": pd.date_range("2024-01-01", periods=n, freq="B"),
"open": arr,
"high": arr * 1.01,
"low": arr * 0.99,
"close": arr,
"vol": [1000.0] * n,
"amount": arr * 1000,
}
)
def _run_rule(closes: list[float], **bracket: Any) -> dict[str, Any]:
"""跑规则场景,返回待锁定的摘要(成交明细 + 关键指标)。"""
result = BacktestEngine(_BuyOnce(**bracket), cash=CASH).run(_rule_df(closes))
trades = [
[t.direction, round(float(t.price), 4), int(pd.Timestamp(t.datetime).strftime("%Y%m%d"))]
for t in result.trades.itertuples()
if not t.rejected
]
return {
"trades": trades,
"total_return": float(result.performance["total_return"]),
"total_trades": int(result.performance["total_trades"]),
}
RULE_SCENARIOS: dict[str, dict[str, Any]] = {
# 跌破固定止损 9.5 → 触发 SELL@9.5,延迟下一根成交
"stop_loss": {
"closes": [10, 10.2, 10.1, 9.8, 9.3, 9.0, 8.8, 8.6, 8.4, 8.2],
"bracket": {"stop_loss": 9.5},
},
# 触及固定止盈 11.0 → OCO 使止损线失效
"take_profit": {
"closes": [10, 10.3, 10.8, 11.2, 11.5, 11.8, 12.0, 12.2, 12.4, 12.6],
"bracket": {"stop_loss": 9.0, "take_profit": 11.0},
},
# 自最高收盘 12 回撤 8% → 11.04 触发移动止损
"trailing_stop": {
"closes": [10, 10.2, 10.5, 11, 11.5, 12, 11.9, 11.5, 11.0, 10.5, 10.0, 9.5],
"bracket": {"trail_stop": 0.08},
},
# 百分比 bracket5% 止损 / 10% 止盈(基准价 = 信号根收盘 10)
"bracket_pct": {
"closes": [10, 10.3, 10.8, 11.2, 11.5, 11.8, 12.0, 12.2, 12.4, 12.6],
"bracket": {"stop_loss_pct": 0.05, "take_profit_pct": 0.10},
},
}
def _build_golden() -> dict[str, Any]:
"""重新计算并返回完整黄金基线。"""
df = _golden_df()
strategies: dict[str, dict[str, Any]] = {}
for name in sorted(_REGISTRY.names()):
reg = _REGISTRY.get(name)
cls = reg.strategy_cls
perf = BacktestEngine(cls, cash=CASH).run(df).performance
entry: dict[str, Any] = {k: float(perf[k]) for k in STRATEGY_METRICS_FLOAT}
entry.update({k: int(perf[k]) for k in STRATEGY_METRICS_INT})
strategies[name] = entry
rules = {
key: _run_rule(spec["closes"], **spec["bracket"]) for key, spec in RULE_SCENARIOS.items()
}
# 买入持有基准 + CAPM 对比(用 ma_cross 做策略侧)
bh = run_buy_hold_benchmark(df, cash=CASH)
ma = _REGISTRY.get("ma_cross").strategy_cls
ma_result = BacktestEngine(ma, cash=CASH).run(df)
comparison = compute_benchmark_comparison(
ma_result.equity_curve,
BacktestEngine(_BuyOnce(), cash=CASH).run(df).equity_curve,
)
return {
"meta": {
"seed": SEED,
"bars": BARS,
"cash": CASH,
"tolerance": {"rel": 1e-6, "abs": 1e-6},
"note": "regen: EASY_TDX_REGEN_GOLDEN=1 pytest tests/unit/test_golden_backtest.py",
},
"strategies": strategies,
"rules": rules,
"buy_hold": {k: float(v) for k, v in bh.items()},
"benchmark_comparison": {k: float(v) for k, v in comparison.items()},
}
def _load_golden() -> dict[str, Any]:
if not GOLDEN_PATH.exists():
pytest.fail(
f"黄金基线缺失: {GOLDEN_PATH}\n"
"首次生成请运行: EASY_TDX_REGEN_GOLDEN=1 python -m pytest "
"tests/unit/test_golden_backtest.py"
)
data = json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
assert isinstance(data, dict)
return data
def _save_golden(data: dict[str, Any]) -> None:
GOLDEN_PATH.parent.mkdir(parents=True, exist_ok=True)
GOLDEN_PATH.write_text(
json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
@pytest.fixture(scope="module")
def golden() -> dict[str, Any]:
"""加载基线;REGEN=1 时重新计算并写盘后返回。"""
if REGEN:
data = _build_golden()
_save_golden(data)
return data
return _load_golden()
def _assert_metric(actual: Any, expected: Any, label: str) -> None:
"""int 精确比对;float 按 rel=abs=1e-6 容差比对。"""
if isinstance(expected, int) and not isinstance(expected, bool):
assert actual == expected, f"{label}: {actual} != {expected}"
else:
assert float(actual) == pytest.approx(float(expected), rel=1e-6, abs=1e-6), (
f"{label}: {actual} != {expected}"
)
# ── 测试入口 ─────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("name", sorted(_REGISTRY.names()))
def test_golden_builtin_strategies(golden: dict[str, Any], name: str) -> None:
"""全部内置策略在固定数据上的绩效指标与基线一致。"""
perf = BacktestEngine(_REGISTRY.get(name).strategy_cls, cash=CASH).run(_golden_df()).performance
baseline = golden["strategies"][name]
for key in STRATEGY_METRICS_FLOAT:
_assert_metric(perf[key], baseline[key], f"{name}.{key}")
for key in STRATEGY_METRICS_INT:
_assert_metric(perf[key], baseline[key], f"{name}.{key}")
@pytest.mark.parametrize("scenario", sorted(RULE_SCENARIOS))
def test_golden_rule_scenarios(golden: dict[str, Any], scenario: str) -> None:
"""止损/止盈/移动止损/OCO 触发语义(成交价与时点)与基线一致。"""
spec = RULE_SCENARIOS[scenario]
actual = _run_rule(spec["closes"], **spec["bracket"])
baseline = golden["rules"][scenario]
assert actual["total_trades"] == baseline["total_trades"], scenario
assert len(actual["trades"]) == len(baseline["trades"]), f"{scenario}: 成交笔数漂移"
for i, (a, b) in enumerate(zip(actual["trades"], baseline["trades"])):
assert a[0] == b[0], f"{scenario}{i}笔方向漂移: {a} vs {b}"
_assert_metric(a[1], b[1], f"{scenario}.trades[{i}].price")
assert a[2] == b[2], f"{scenario}{i}笔成交日漂移: {a} vs {b}"
_assert_metric(actual["total_return"], baseline["total_return"], f"{scenario}.total_return")
def test_golden_buy_hold(golden: dict[str, Any]) -> None:
"""买入持有基准指标与基线一致。"""
bh = run_buy_hold_benchmark(_golden_df(), cash=CASH)
for key, expected in golden["buy_hold"].items():
_assert_metric(bh[key], expected, f"buy_hold.{key}")
def test_golden_benchmark_comparison(golden: dict[str, Any]) -> None:
"""Alpha/Beta/IR/TE 基准对比指标与基线一致。"""
df = _golden_df()
ma = _REGISTRY.get("ma_cross").strategy_cls
strategy_curve = BacktestEngine(ma, cash=CASH).run(df).equity_curve
bh_curve = BacktestEngine(_BuyOnce(), cash=CASH).run(df).equity_curve
comparison = compute_benchmark_comparison(strategy_curve, bh_curve)
for key, expected in golden["benchmark_comparison"].items():
_assert_metric(comparison[key], expected, f"benchmark.{key}")
def test_golden_meta_frozen(golden: dict[str, Any]) -> None:
"""基线 meta 与测试常量一致(防止改数据参数后忘记重建基线)。"""
meta = golden["meta"]
assert meta["seed"] == SEED
assert meta["bars"] == BARS
assert meta["cash"] == CASH
+1
View File
@@ -72,6 +72,7 @@ _EXPECTED_KIND: dict[str, str] = {
"CALC_HOSTS": "constant", "CALC_HOSTS": "constant",
"MAC_HOSTS": "constant", "MAC_HOSTS": "constant",
"XDXR_CATEGORY_NAMES": "constant", "XDXR_CATEGORY_NAMES": "constant",
"UNUSUAL_TYPE_NAMES": "constant",
} }
+221
View File
@@ -0,0 +1,221 @@
"""市场异动(0x1237)异动类型解析测试(Issue #62)。
背景实测(2026-09-01 午间,全市场 SH+SZ 共 12871 条):
- 0x15 仅出现在 09:25:00~09:25:02(竞价撮合时刻),v1 为方向档
0x02 拉升 / 0x03 下跌 / 0x01 平稳,±0.5% 分档),v2 为竞价尾段
价格变动(相对昨收,参考时刻收敛于 09:23:30~09:24:00),v3 为竞价
匹配量(手,略小于最终撮合量)。pytdx2 把 0x15 标作"尾盘"与实测矛盾,
系对 PC 推送协议枚举的错误类推。
- 0x16 全天分布,v2 为触发时涨跌幅:09:25 的 49 条样本与当日开盘涨幅
open/pre_close-149/49 精确一致;全时段与收盘涨跌幅符号一致率
97%~100%。v1 为带符号强弱等级(0x01~0x03 强势、0xFD~0xFF 弱势,
六组 v2 区间互不重叠且单调)。
- 0x1D / 0x1E 全天分布,v1 恒为 0x00 / 0x01(方向),v2 恒正 / 恒负
(阈值下限 ±0.6%),v3 恒 0。
"""
import struct
from datetime import time
from easy_tdx import UNUSUAL_TYPE_NAMES
from easy_tdx.mac.commands.unusual import UnusualCmd, _describe_unusual
def _record(
utype: int,
data_hex: str,
hour: int = 9,
minute_sec: int = 2500,
market: int = 1,
code: str = "600551",
) -> bytes:
"""构造一条 32 字节异动记录(<H6sBBBHH> 头 + 13B 数据区 + 保留 + <BH> 时间)。"""
return (
struct.pack("<H6sBBBHH", market, code.encode("gbk"), 0, utype, 0, 1, 0)
+ bytes.fromhex(data_hex)
+ b"\x00" # offset 28:全类型实测恒 0
+ struct.pack("<BH", hour, minute_sec)
)
def _body(records: list[bytes]) -> bytes:
text = ",".join("测试股" for _ in records)
return struct.pack("<H", len(records)) + b"".join(records) + text.encode("gbk")
class TestDescribeUnusualKnownTypes:
"""既有 15 种类型(0x03~0x0C、0x10~0x14)解析不回归。"""
def test_type_0x04(self):
# 真实样本:605365 立达信 2026-09-01 09:35:11
desc, val = _describe_unusual(0x04, bytes.fromhex("00b8d73d3d0000000000000000"))
assert desc == "加速拉升"
assert val == "4.63%"
def test_unknown_type_fallback(self):
desc, val = _describe_unusual(0x42, bytes.fromhex("00" * 13))
assert desc == "异动类型0x42"
assert val == ""
class TestType0x15:
"""0x15 竞价/尾盘异动(Issue #62)。
双时刻信号:开盘竞价 09:25(当日 1191 条)与收盘 15:00:01~04(当日 86 条,
SH 52 / SZ 29 / BJ 5)都触发;desc 按记录小时区分「竞价/尾盘」前缀。
"""
def test_auction_drop(self):
# 真实样本:600551 时代出版 09:25:00v1=0x03 竞价下跌
desc, val = _describe_unusual(0x15, bytes.fromhex("030c9846bc003e1d4700000000"))
assert desc == "竞价下跌"
assert val == "-1.21%/40254手"
def test_auction_rise(self):
# 真实样本:600127 金健米业 09:25:00v1=0x02 竞价拉升(尾段自 10.84 冲至 12.05
desc, val = _describe_unusual(0x15, bytes.fromhex("0213d2cd3d00367b4700000000"))
assert desc == "竞价拉升"
assert val == "10.05%/64310手"
def test_auction_flat(self):
# 真实样本:600410 华胜天成 09:25:01v1=0x01 竞价平稳(尾段价格未动)
desc, val = _describe_unusual(0x15, bytes.fromhex("01000000000098a54500000000"))
assert desc == "竞价平稳"
assert val == "0.00%/5299手"
def test_close_rise_uses_tail_prefix(self):
# 真实样本:600123 15:00:01(收盘撮合时刻),v1=0x02 尾盘拉升
desc, val = _describe_unusual(0x15, bytes.fromhex("027bb4dd3b0004a84500000000"), 15)
assert desc == "尾盘拉升"
assert val == "0.68%/5376手"
def test_close_drop_uses_tail_prefix(self):
# 真实样本:600221 15:00:01v1=0x03 尾盘下跌
desc, val = _describe_unusual(0x15, bytes.fromhex("03c10ffcbb839c274800000000"), 15)
assert desc == "尾盘下跌"
assert val == "-0.77%/171634手"
def test_unknown_sub_type_falls_back(self):
desc, _ = _describe_unusual(0x15, struct.pack("<B2fI", 0x77, 0.0, 100.0, 0))
assert desc == "竞价异动"
desc, _ = _describe_unusual(0x15, struct.pack("<B2fI", 0x77, 0.0, 100.0, 0), 15)
assert desc == "尾盘异动"
class TestType0x16:
"""0x16 盘中强势/弱势(Issue #62 主体)。"""
def test_strong_at_auction(self):
# 真实样本:600551 时代出版 09:25:00v2=+5.82% 与当日开盘涨幅精确一致
desc, val = _describe_unusual(0x16, bytes.fromhex("010f506e3dcb846e3d00000000"))
assert desc == "盘中强势"
assert val == "5.82%"
def test_weak_at_auction(self):
# 真实样本:600683 京投发展 09:25:01v1=0xFF(弱势 1 级),v2=-6.40%
desc, val = _describe_unusual(0x16, bytes.fromhex("ffc71d83bd690383bd00000000"))
assert desc == "盘中弱势"
assert val == "-6.40%"
def test_new_stock_no_limit(self):
# 真实样本:601123 N马矿 09:25:00,新股无涨跌幅限制,v2=+245.86%
desc, val = _describe_unusual(0x16, bytes.fromhex("03775a1d404a5b1d4000000000"))
assert desc == "盘中强势"
assert val == "245.86%"
class TestType0x13:
"""0x13 竞价试盘(2026-09-02 破译)。
v1 为方向:0x00 试买(申报价高于昨收)/ 0x01 试卖(低于昨收)——552 条对照
昨收 549 条一致;v2 为申报价、v3 为竞价量(手)。旧实现一律显示「竞价试买」,
方向相反的一半记录描述错误。
"""
def test_auction_test_buy(self):
# 真实样本:603980 09:15:14,申报价 8.71 高于昨收 7.92(往上试)
desc, val = _describe_unusual(0x13, bytes.fromhex("00295c0b41006c354600000000"))
assert desc == "竞价试买"
assert val == "8.71/11611手"
def test_auction_test_sell(self):
# 真实样本:603900 09:15:17,申报价 6.46 低于昨收 7.17(往下试)
desc, val = _describe_unusual(0x13, bytes.fromhex("0152b8ce400000c94300000000"))
assert desc == "竞价试卖"
assert val == "6.46/402手"
class TestType0x1D0x1E:
"""0x1D 急速拉升 / 0x1E 急速下跌(Issue #62 顺带补齐)。"""
def test_fast_rise(self):
# 真实样本:605365 立达信 09:35:08
desc, val = _describe_unusual(0x1D, bytes.fromhex("009d50843c0000000000000000"))
assert desc == "急速拉升"
assert val == "1.62%"
def test_fast_fall(self):
# 真实样本:601123 N马矿 09:35:03
desc, val = _describe_unusual(0x1E, bytes.fromhex("019cd393bc0000000000000000"))
assert desc == "急速下跌"
assert val == "-1.80%"
class TestUnusualCmdParseResponse:
def test_parse_new_types_end_to_end(self):
body = _body(
[
_record(0x16, "010f506e3dcb846e3d00000000", 9, 2500),
_record(0x15, "030c9846bc003e1d4700000000", 9, 2500),
_record(0x1D, "009d50843c0000000000000000", 9, 3508),
_record(0x1E, "019cd393bc0000000000000000", 9, 3503),
]
)
items = UnusualCmd(1, 0, 600).parse_response(body)
assert len(items) == 4
assert [i.desc for i in items] == ["盘中强势", "竞价下跌", "急速拉升", "急速下跌"]
assert items[0].value == "5.82%"
assert items[1].value == "-1.21%/40254手"
assert items[0].time == time(9, 25, 0)
assert items[2].time == time(9, 35, 8)
assert items[0].unusual_type == 0x16
assert all(i.name == "测试股" for i in items)
def test_parse_close_record_names_tail_prefix(self):
"""15:00 的 0x15 记录端到端应得到「尾盘拉升」(真实收盘样本 600123)。"""
rec = _record(0x15, "027bb4dd3b0004a84500000000", 15, 1)
items = UnusualCmd(1, 0, 600).parse_response(_body([rec]))
assert items[0].desc == "尾盘拉升"
assert items[0].value == "0.68%/5376手"
assert items[0].time == time(15, 0, 1)
def test_record_layout_unchanged(self):
"""记录仍为 32 字节定长,时间槽位于 offset 29。"""
rec = _record(0x16, "010f506e3dcb846e3d00000000", 14, 5701)
assert len(rec) == 32
items = UnusualCmd(1, 0, 600).parse_response(_body([rec]))
assert items[0].time == time(14, 57, 1)
class TestTypeNames:
def test_names_cover_all_described_types(self):
"""映射表应覆盖 _describe_unusual 的全部分支(0x03~0x0C、0x10~0x16、0x1D、0x1E)。"""
assert set(UNUSUAL_TYPE_NAMES) == {
*range(0x03, 0x0D),
*range(0x10, 0x17),
0x1D,
0x1E,
}
def test_mapped_types_produce_named_desc(self):
"""映射表中的类型不应落入"异动类型0x??"兜底分支。"""
zeros = bytes.fromhex("00" * 13)
for utype in UNUSUAL_TYPE_NAMES:
desc, _ = _describe_unusual(utype, zeros)
assert not desc.startswith("异动类型"), f"0x{utype:02X} 未实现解析分支"
def test_top_level_export(self):
import easy_tdx
assert easy_tdx.UNUSUAL_TYPE_NAMES is UNUSUAL_TYPE_NAMES
assert UNUSUAL_TYPE_NAMES[0x16] == "盘中强势弱势"
+35
View File
@@ -32,6 +32,29 @@ const scoreComponents = computed(() => {
const grade = computed(() => gradePerformance(props.report.performance)) const grade = computed(() => gradePerformance(props.report.performance))
const excess = computed(() => props.report.benchmark.excess_return) 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> </script>
<template> <template>
@@ -88,6 +111,15 @@ const excess = computed(() => props.report.benchmark.excess_return)
</span> </span>
</div> </div>
</div> </div>
<!-- v1.28CAPM / 主动管理对比α/β/信息比率/跟踪误差 -->
<div v-if="capm" class="bench-row bench-row-4">
<div v-for="c in capm" :key="c.label" class="bench-cell">
<span class="stat-label">{{ c.label }}</span>
<span class="mono" :class="c.good === null ? '' : c.good ? 'pos' : 'neg'">
{{ fmtCapm(c.value, c.fmt) }}
</span>
</div>
</div>
<p v-if="excess < 0" class="bench-warn"> 策略跑输同区间买入持有研发阶段的一票否决信号</p> <p v-if="excess < 0" class="bench-warn"> 策略跑输同区间买入持有研发阶段的一票否决信号</p>
<!-- 适配性检查8 项可解释 --> <!-- 适配性检查8 项可解释 -->
@@ -201,6 +233,9 @@ const excess = computed(() => props.report.benchmark.excess_return)
gap: 8px; gap: 8px;
margin-bottom: 6px; margin-bottom: 6px;
} }
.bench-row-4 {
grid-template-columns: repeat(4, 1fr);
}
.bench-cell { .bench-cell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+10 -3
View File
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
// 19 项绩效指标表。按金融惯例格式化:比率类→百分比,保留小数。 // 25 项绩效指标表。按金融惯例格式化:比率类→百分比,保留小数。
// v1.28 新增深度风险指标(Ulcer/VaR/CVaR/SQN/连胜连亏),老结果缺键时显示 '-'。
import { computed } from 'vue' import { computed } from 'vue'
@@ -27,6 +28,9 @@ const METRICS: MetricRow[] = [
{ key: 'max_drawdown', label: '最大回撤', format: 'percent', group: '风险' }, { key: 'max_drawdown', label: '最大回撤', format: 'percent', group: '风险' },
{ key: 'max_dd_duration', label: '回撤持续', format: 'days', group: '风险' }, { key: 'max_dd_duration', label: '回撤持续', format: 'days', group: '风险' },
{ key: 'volatility', label: '波动率', format: 'percent', 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: 'total_trades', label: '总交易数', format: 'int', group: '交易' },
{ key: 'win_trades', label: '盈利次数', format: 'int', group: '交易' }, { key: 'win_trades', label: '盈利次数', format: 'int', group: '交易' },
{ key: 'lose_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_win', label: '最大盈利', format: 'percent', group: '交易' },
{ key: 'max_loss', label: '最大亏损', format: 'percent', group: '交易' }, { key: 'max_loss', label: '最大亏损', format: 'percent', group: '交易' },
{ key: 'avg_holding_days', label: '平均持仓天数', format: 'ratio', 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: '交易' }, { key: 'rejected_trades', label: '拒单数', format: 'int', group: '交易' },
] ]
function formatVal(row: MetricRow, v: number): string { function formatVal(row: MetricRow, v: number | undefined): string {
if (!Number.isFinite(v)) return '-' if (v === undefined || v === null || !Number.isFinite(v)) return '-'
if (row.format === 'percent') return `${(v * 100).toFixed(2)}%` if (row.format === 'percent') return `${(v * 100).toFixed(2)}%`
if (row.format === 'int') return String(Math.round(v)) if (row.format === 'int') return String(Math.round(v))
if (row.format === 'days') return `${v.toFixed(0)}` if (row.format === 'days') return `${v.toFixed(0)}`
+22
View File
@@ -89,6 +89,19 @@ export interface Performance {
max_loss: number max_loss: number
avg_holding_days: number avg_holding_days: number
volatility: 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 { export interface EquityPoint {
@@ -603,6 +616,15 @@ export interface EvaluateBenchmarkReport {
} }
/** 策略总收益 - 买入持有总收益 */ /** 策略总收益 - 买入持有总收益 */
excess_return: number excess_return: number
// v1.28 CAPM / 主动管理对比指标(老版本保存的报告可能缺省)
/** 年化 CAPM α:剔除基准影响后的超额收益,>0 仍有真实超额 */
alpha?: number
/** β:对基准的敏感度(1 = 与基准同涨跌) */
beta?: number
/** 年化信息比率:每 1 单位跟踪误差换来的超额收益 */
information_ratio?: number
/** 年化跟踪误差 */
tracking_error?: number
} }
export interface EvaluateReport { export interface EvaluateReport {