mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 15:44:18 +08:00
Merge branch 'main' of https://github.com/handsomejustin/easy_tdx
This commit is contained in:
+28
-1
@@ -2,7 +2,9 @@
|
||||
|
||||
本文件记录 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=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` 可调轮询间隔。
|
||||
|
||||
@@ -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 个回归用例。
|
||||
|
||||
## [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:25(1191 条)与收盘 15:00:01~04(86 条)都触发;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 例,全部真实抓包字节 fixture:600551/600127 竞价异动、600123/600221 收盘「尾盘」前缀、603980/603900 试买/试卖方向);既有类型解析不回归;`UNUSUAL_TYPE_NAMES` 覆盖度与兜底分支互斥性校验;`test_public_api.py` 契约表登记新导出。异动相关测试全绿;全套 1278 通过、1 例 optimizer cache 既有失败与本次无关(干净 HEAD 同样失败)。
|
||||
|
||||
## [1.27.1] — 2026-09-01
|
||||
|
||||
**v1.27.0 的维护版**——一项 UI 修复 + WebSocket 实时推送落地(随独立排期提交收录)。
|
||||
|
||||
@@ -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,重启不丢。
|
||||
|
||||
@@ -1474,6 +1474,16 @@ with MacClient.from_best_host() as c:
|
||||
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
|
||||
|
||||
@@ -206,6 +206,56 @@ u1 范围与价格非线性质相关(茅台价格 140x → u1 范围 300x)
|
||||
|
||||
`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:04(SH 52 / SZ 29 / BJ 5),v1 分档规律与早间一致、幅度更小(±0.5%~±2.6%)。v2 参考时刻扫描收敛于 09:23:30~09:24:00(60 样本 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。
|
||||
|
||||
**排除项**:
|
||||
- pytdx2(QuantJia/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字节响应头部(已确认:请求回显)
|
||||
|
||||
@@ -13,9 +13,21 @@ UnusualItem dataclass 字段:
|
||||
code str 证券代码
|
||||
name str 证券名称
|
||||
time time 异动时间
|
||||
desc str 异动描述(如 "5分钟涨幅>3%"、"快速拉升"、"大笔买入")
|
||||
value str 异动数值(如 "3.52%"、"5000手")
|
||||
unusual_type int 异动类型代码(1=5分钟涨幅, 2=5分钟跌幅, 3=快速拉升, 4=大笔成交等)
|
||||
desc str 异动描述(如 "盘中强势"、"竞价拉升"、"急速下跌")
|
||||
value str 异动数值(如 "5.82%"、"10.05%/64310手")
|
||||
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 列说明:
|
||||
index int 异动序号
|
||||
@@ -59,9 +71,10 @@ with MacClient.from_best_host() as c:
|
||||
print("暂无异动数据。")
|
||||
|
||||
# 示例输出:
|
||||
# 共获取 1843 条异动(4 页)
|
||||
# 共获取 12871 条异动(22 页)
|
||||
# index market code name time desc value unusual_type
|
||||
# 1 1 600XXX XX科技 09:45:00 5分钟涨幅>3% 3.52% 1
|
||||
# 2 1 601XXX XX银行 09:52:00 5分钟涨幅>3% 3.15% 1
|
||||
# 3 1 600XXX XX能源 10:05:00 5分钟跌幅>3% -3.28% 2
|
||||
# 1 1 600551 时代出版 09:25:00 竞价下跌 -1.21%/40254手 21
|
||||
# 2 1 600551 时代出版 09:25:00 盘中强势 5.82% 22
|
||||
# 3 1 600295 鄂尔多斯 09:25:00 竞价拉升 2.88%/533手 21
|
||||
# 4 1 605365 立达信 09:35:08 急速拉升 1.62% 29
|
||||
# ...
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "easy-tdx"
|
||||
version = "1.27.1"
|
||||
version = "1.28.0"
|
||||
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -27,6 +27,7 @@ from .ex.mac_client import AsyncMacExClient, MacExClient
|
||||
from .ex.models import KNOWN_EX_HOSTS
|
||||
from .exceptions import TdxCommandError, TdxConnectionError, TdxDecodeError, TdxError
|
||||
from .mac.client import AsyncMacClient, MacClient
|
||||
from .mac.commands import UNUSUAL_TYPE_NAMES
|
||||
from .mac.enums import (
|
||||
Adjust,
|
||||
BoardSortColumn,
|
||||
@@ -90,6 +91,7 @@ __all__ = [
|
||||
"TransactionRecord",
|
||||
"XdxrRecord",
|
||||
"XDXR_CATEGORY_NAMES",
|
||||
"UNUSUAL_TYPE_NAMES",
|
||||
"FinanceInfo",
|
||||
"CompanyInfoCategory",
|
||||
"FinancialFileInfo",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -37,13 +37,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:
|
||||
@@ -375,10 +394,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)
|
||||
@@ -488,8 +514,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
|
||||
@@ -512,16 +541,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"):
|
||||
@@ -538,8 +573,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)
|
||||
|
||||
@@ -336,20 +336,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",
|
||||
@@ -357,6 +381,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"
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from .symbol_quotes import SymbolQuotesCmd
|
||||
from .symbol_tick_chart import SymbolTickChartCmd
|
||||
from .symbol_transaction import SymbolTransactionCmd
|
||||
from .tick_charts import TickChartsCmd
|
||||
from .unusual import UnusualCmd
|
||||
from .unusual import UNUSUAL_TYPE_NAMES, UnusualCmd
|
||||
|
||||
__all__ = [
|
||||
"BoardListCmd",
|
||||
@@ -29,5 +29,6 @@ __all__ = [
|
||||
"SymbolTickChartCmd",
|
||||
"SymbolTransactionCmd",
|
||||
"TickChartsCmd",
|
||||
"UNUSUAL_TYPE_NAMES",
|
||||
"UnusualCmd",
|
||||
]
|
||||
|
||||
@@ -8,9 +8,34 @@ from ...codec.mac_frame import build_mac_request
|
||||
from ...commands.base import BaseCommand
|
||||
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:
|
||||
return "", ""
|
||||
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 = "大单锁盘"
|
||||
val = ""
|
||||
elif unusual_type == 0x13:
|
||||
desc = "竞价试买"
|
||||
val = f"{v2:.2f}/{v3:.2f}"
|
||||
# 竞价试盘(09:15~09:20 触发):v1=0x00 试买(申报价高于昨收)/ 0x01 试卖
|
||||
# (低于昨收);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:
|
||||
direction = "涨" if v1 == 0x00 else "跌"
|
||||
if len(data) >= 10:
|
||||
@@ -75,6 +106,31 @@ def _describe_unusual(unusual_type: int, data: bytes) -> tuple[str, str]:
|
||||
else:
|
||||
desc = f"涨跌停({direction})"
|
||||
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:
|
||||
desc = f"异动类型{unusual_type:#04x}"
|
||||
val = ""
|
||||
@@ -129,10 +185,10 @@ class UnusualCmd(BaseCommand[list[UnusualItem]]):
|
||||
"<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}]")
|
||||
|
||||
desc, value = _describe_unusual(unusual_type, body[offset + 15 : offset + 28], hour)
|
||||
|
||||
results.append(
|
||||
UnusualItem(
|
||||
index=index,
|
||||
|
||||
@@ -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
|
||||
@@ -72,6 +72,7 @@ _EXPECTED_KIND: dict[str, str] = {
|
||||
"CALC_HOSTS": "constant",
|
||||
"MAC_HOSTS": "constant",
|
||||
"XDXR_CATEGORY_NAMES": "constant",
|
||||
"UNUSUAL_TYPE_NAMES": "constant",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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-1)49/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:00,v1=0x03 竞价下跌
|
||||
desc, val = _describe_unusual(0x15, bytes.fromhex("030c9846bc003e1d4700000000"))
|
||||
assert desc == "竞价下跌"
|
||||
assert val == "-1.21%/40254手"
|
||||
|
||||
def test_auction_rise(self):
|
||||
# 真实样本:600127 金健米业 09:25:00,v1=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:01,v1=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:01,v1=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:00,v2=+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:01,v1=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] == "盘中强势弱势"
|
||||
@@ -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