diff --git a/CHANGELOG.md b/CHANGELOG.md index 119d2a4..a8c4feb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 实时推送落地(随独立排期提交收录)。 diff --git a/README.md b/README.md index a625193..72c375e 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,13 @@ easy-tdx 要做的事很简单:**把机构的数据锁砸开,扔到每个普 **34个技术指标**(MACD、KDJ、RSI、BOLL……连”捉妖大师”和”30日乖离率信号”都给你算好)开箱即用。 **缠论分析**(笔、中枢、买卖点、背驰)一键出结果——你不再需要手画分型、猜线段。 -**内置回测引擎**——写个策略文件,一行命令跑回测,18 个经典策略自带,多因子组合、策略选股扫描,批量对比哪个最赚钱一目了然。**防过拟合验证链**(v1.25 新增)——Walk-Forward 七窗样本外验证(每窗独立开仓)、训练/验证/测试三段适配性体检(8 项可解释检查 + 「高适配」标记)、0-100 综合评分、多 seed 晋级门槛、买入持有基准对比,回测页勾选即出报告——「回测好」升级为「样本外也好」。 +**内置回测引擎**——写个策略文件,一行命令跑回测,18 个经典策略自带,多因子组合、策略选股扫描,批量对比哪个最赚钱一目了然。**防过拟合验证链**(v1.25 新增)——Walk-Forward 七窗样本外验证(每窗独立开仓)、训练/验证/测试三段适配性体检(8 项可解释检查 + 「高适配」标记)、0-100 综合评分、多 seed 晋级门槛、买入持有基准对比,回测页勾选即出报告——「回测好」升级为「样本外也好」。**深度风险报告**(v1.28 新增)——绩效指标扩到 25 项(新增 Ulcer 指数、95% 日 VaR/CVaR、SQN 系统质量数、最大连胜/连亏);基准对比从超额收益一个数升级为 α/β/信息比率/跟踪误差四件套(「涨的时候跟不跟得上大盘、跌的时候抗不抗跌」一眼可读);策略一行带移动止损与百分比止损止盈(`self.buy(trail_stop=0.08)` 涨得越高止损线跟得越高,OCO 任一触发即失效);引擎行为由黄金测试锁定——19 个内置策略的指标基线进 CI,撮合/费率逻辑任何静默漂移当场爆红。 **行情终端 Web UI**(v1.23 重大升级)——`easy-tdx serve` 一条命令,浏览器秒变专业看盘终端:**市场看板**(五大指数实时推送 + 迷你分时、涨跌统计、四维情绪雷达、全市场涨跌分布直方图、涨停雷达、行业/概念热冷榜、涨幅/跌幅/成交额/换手四联排行榜、两市异动雷达)、**自选行情**(输入 6 位代码即加,全表 SSE 实时刷新、行内迷你分时)、**个股详情弹窗**(五档盘口 + 1/3/5 日分时 + 带 MA/BOLL/MACD/KDJ/RSI 指标切换的日 K,一键加自选、一键全策略寻优)、**板块下钻**(行业/概念弹窗看板块走势 + 成分股涨跌榜直达个股)。实时推送采用 SSE 单循环轮询 fan-out 架构(盘中 8 秒一拍,无人订阅自动休眠),自选持久化 SQLite。展示层设计对标专业终端(暗色、红涨绿跌、高信息密度),数据全部来自通达信协议直连——不花一分钱。 行情终端 Web UI(v1.23):市场看板 / 自选行情 / 个股与板块详情 -**回测可视化 Web UI**(v1.17 新增)——Vue3 + ECharts 单页应用,浏览器里选标的、挑策略、调参数,K 线买卖点、净值回撤、19 项绩效指标一目了然。支持组合回测、参数网格寻优、多策略结果对比,**还能把好策略存进策略库(SQLite 持久化),勾选多个策略做资金分仓组合回测看综合表现**,全程零代码。v1.27 起新增「附加分析」开关:勾选后随回测自动跑 Walk-Forward 逐窗柱状图与一条龙评估报告(评分分项 / 高适配徽标 / 买入持有对比)。 +**回测可视化 Web UI**(v1.17 新增)——Vue3 + ECharts 单页应用,浏览器里选标的、挑策略、调参数,K 线买卖点、净值回撤、25 项绩效指标一目了然。支持组合回测、参数网格寻优、多策略结果对比,**还能把好策略存进策略库(SQLite 持久化),勾选多个策略做资金分仓组合回测看综合表现**,全程零代码。v1.27 起新增「附加分析」开关:勾选后随回测自动跑 Walk-Forward 逐窗柱状图与一条龙评估报告(评分分项 / 高适配徽标 / 买入持有对比)。 **数据评级系统**(v1.17.14 新增)——回测结果顶部直接显示 **S/A/B/C/D 五档评级徽章**,1 秒判断「这个品种适不适合经常参与」。评级**不看收益率**(避免被近期大涨误导),只看风险调整后的持有体验:卡玛比率、最大回撤、胜率、利润因子、夏普、波动率六维加权 + 一票否决(系统亏损/深回撤/低胜率直接低评)。京东方那种「收益 126% 但胜率 35%、回撤 41%」的案例会评 **D 档**——明确告诉普通人「别碰,套牢后回本极难」。三个入口(单标的/组合/寻优)都有评级,长线低频策略不会被冤枉(交易少时只降权胜率维度,不否决整个评级)。 @@ -508,7 +508,7 @@ Web UI 包含两大模块: - **市场看板**:五大指数实时行情(SSE 推送)、全市场涨跌统计(涨/跌/平/涨停/跌停 + 堆叠条)、行业/概念板块热度榜、涨幅榜/跌幅榜、两市异动雷达(加速拉升/封涨停板/大单托盘等),点击个股打开五档盘口 + 分时/日K 对话框; - **自选行情**:输入 6 位代码一键加自选(SQLite 持久化),全表实时刷新(SSE),行内迷你分时图,点击行看个股详情; - **实时推送架构**:后端单条轮询循环 fan-out 到所有 SSE 连接(交易时段 ~8s 一拍,盘外降频 60s,无人订阅自动休眠),前端指数退避重连。 -- **回测工作台(v1.17 起)**——浏览器里选标的、挑策略、调参数,K 线买卖点、净值回撤、19 项绩效指标一目了然。支持组合回测、参数网格寻优、多策略结果对比,还能把好策略存进策略库(SQLite 持久化),勾选多个策略做资金分仓组合回测看综合表现,全程零代码。 +- **回测工作台(v1.17 起)**——浏览器里选标的、挑策略、调参数,K 线买卖点、净值回撤、25 项绩效指标一目了然。支持组合回测、参数网格寻优、多策略结果对比,还能把好策略存进策略库(SQLite 持久化),勾选多个策略做资金分仓组合回测看综合表现,全程零代码。 **前置条件:** @@ -549,7 +549,7 @@ EXE 打包方法见 [`docs/packaging.md`](./docs/packaging.md)。 - **取行情**:选市场(深/沪/北),填 6 位代码,选周期(日线/周线/分钟线),设日期范围(默认最近 3 年),点「取行情」。超过 800 根会自动翻页拼接 - **选策略**:下拉选 18 个内置策略之一(双均线交叉、MACD、布林带、RSI、KDJ、唐安奇通道、CCI 等),选中后参数表单自动出现,按推荐范围调参 - **资金与成本**:初始资金、佣金率、滑点、成交模式(默认 next_open 下一根开盘成交) -- 点「开始回测」,右侧依次出:K 线主图(红三角=买入、绿钉=卖出)、净值曲线与回撤双轴图、19 项绩效指标表(总收益/夏普/最大回撤/胜率/盈亏比等)、成交记录明细 +- 点「开始回测」,右侧依次出:K 线主图(红三角=买入、绿钉=卖出)、净值曲线与回撤双轴图、25 项绩效指标表(总收益/夏普/最大回撤/胜率/盈亏比/Ulcer/VaR/SQN 等)、成交记录明细 - 结果区右上角有「💾 保存策略」按钮,把当前策略 + 标的 + 成绩快照存进策略库,下次直接载入或参与组合回测 **2. 组合回测**(`/portfolio`) @@ -575,7 +575,7 @@ EXE 打包方法见 [`docs/packaging.md`](./docs/packaging.md)。 - 保存你觉得不错的策略,下次直接载入或重跑。数据存在本地 SQLite 单文件(`~/.easy_tdx/strategies.db`,重启不丢) - 每张卡片展示策略名、标的、保存时的成绩快照(总收益/夏普/回撤)、标签、备注、创建时间 - **载入**:点「载入」跳转对应回测页(单标的/组合),自动回填标的、日期、策略参数,可直接重跑 -- **多策略组合回测**:勾选多个单标的策略(卡片左上角复选框),点顶部「组合回测(N)」——每个策略各拿 1/N 资金、各跑在它保存时的原标的上(取最新行情),净值曲线按日期对齐求和,看综合表现。结果区展示:组合净值曲线、19 项完整绩效指标(与单标的同口径)、各策略绩效对比表、净值叠加图、各策略当前持仓表(回测结束时谁还套着票) +- **多策略组合回测**:勾选多个单标的策略(卡片左上角复选框),点顶部「组合回测(N)」——每个策略各拿 1/N 资金、各跑在它保存时的原标的上(取最新行情),净值曲线按日期对齐求和,看综合表现。结果区展示:组合净值曲线、25 项完整绩效指标(与单标的同口径)、各策略绩效对比表、净值叠加图、各策略当前持仓表(回测结束时谁还套着票) > ⚠️ **任务不持久化**:回测结果存在后端进程内存,重启 `easy-tdx serve` 后清空。对比页只能选当前运行期间产生的任务。**策略库除外**——保存到策略库的策略持久存在 SQLite,重启不丢。 @@ -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 diff --git a/docs/protocol-unknown-fields.md b/docs/protocol-unknown-fields.md index 61a60f7..884d40e 100644 --- a/docs/protocol-unknown-fields.md +++ b/docs/protocol-unknown-fields.md @@ -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 字节记录 = `` 头(15B)+ 13B 类型数据区 +(`` 时间。 + +**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/创业/科创)相关,待后续验证。 + +**请求监控参数**(`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 # ... diff --git a/pyproject.toml b/pyproject.toml index 8c4963d..ae9d98d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/easy_tdx/__init__.py b/src/easy_tdx/__init__.py index ed10cef..5e39e6e 100644 --- a/src/easy_tdx/__init__.py +++ b/src/easy_tdx/__init__.py @@ -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", diff --git a/src/easy_tdx/backtest/benchmark.py b/src/easy_tdx/backtest/benchmark.py index 0f3268c..367d4ae 100644 --- a/src/easy_tdx/backtest/benchmark.py +++ b/src/easy_tdx/backtest/benchmark.py @@ -15,7 +15,9 @@ "fitness": {"pass_ratio": 0.875, "high_fitness": true, "checks": [...]}, "benchmark": { "buy_hold": {"total_return": 0.32, ...}, - "excess_return": 0.18 # 策略 - 买入持有 + "excess_return": 0.18, # 策略 - 买入持有 + "alpha": 0.09, "beta": 0.72, # v1.28:CAPM 对比 + "information_ratio": 0.85, "tracking_error": 0.12 # v1.28:主动管理指标 }, "config": {...} } @@ -27,8 +29,9 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any +import numpy as np import pandas as pd from easy_tdx.backtest.engine import BacktestEngine @@ -39,7 +42,16 @@ from easy_tdx.backtest.strategy import Strategy from easy_tdx.backtest.types import to_json_native from easy_tdx.backtest.walkforward import WalkForwardEngine -__all__ = ["evaluate_strategy", "run_buy_hold_benchmark"] +if TYPE_CHECKING: + import numpy.typing as npt + + from easy_tdx.backtest.types import BacktestResult + + NDArray = npt.NDArray[np.float64] +else: + NDArray = np.ndarray + +__all__ = ["evaluate_strategy", "run_buy_hold_benchmark", "compute_benchmark_comparison"] class _BuyAndHold(Strategy): @@ -54,6 +66,32 @@ class _BuyAndHold(Strategy): self._bought = True +def _run_buy_hold_result( + df: pd.DataFrame, + cash: float = 100000.0, + commission: float = 0.0003, + min_commission: float = 5.0, + stamp_tax: float = 0.001, + slippage: float = 0.0, + execution: str = "next_open", + symbol: str | None = None, + auto_fees: bool = False, +) -> BacktestResult: + """买入持有基准完整回测(内部用,返回 BacktestResult 以取资金曲线)。""" + engine = BacktestEngine( + strategy=_BuyAndHold, + cash=cash, + commission=commission, + min_commission=min_commission, + stamp_tax=stamp_tax, + slippage=slippage, + execution=execution, + symbol=symbol, + auto_fees=auto_fees, + ) + return engine.run(df) + + def run_buy_hold_benchmark( df: pd.DataFrame, cash: float = 100000.0, @@ -66,18 +104,9 @@ def run_buy_hold_benchmark( auto_fees: bool = False, ) -> dict[str, Any]: """买入持有基准回测(与策略回测同区间、同费率、同资金)。""" - engine = BacktestEngine( - strategy=_BuyAndHold, - cash=cash, - commission=commission, - min_commission=min_commission, - stamp_tax=stamp_tax, - slippage=slippage, - execution=execution, - symbol=symbol, - auto_fees=auto_fees, + result = _run_buy_hold_result( + df, cash, commission, min_commission, stamp_tax, slippage, execution, symbol, auto_fees ) - result = engine.run(df) keys = ( "total_return", "annual_return", @@ -89,6 +118,77 @@ def run_buy_hold_benchmark( return dict(to_json_native({k: result.performance.get(k, 0.0) for k in keys})) +def compute_benchmark_comparison( + strategy_curve: pd.DataFrame, + benchmark_curve: pd.DataFrame, + annual_days: int = 252, +) -> dict[str, float]: + """策略 vs 基准的 CAPM / 主动管理对比指标(v1.28 新增)。 + + 从两条资金曲线的日收益率序列计算: + + - ``beta``: 协方差/基准方差,策略对基准的敏感度(1 = 与基准同涨跌) + - ``alpha``: 年化 CAPM α ≈ (策略日均收益 − β×基准日均收益) × 年化天数, + 简化版(无风险利率并入截距),>0 说明剔除基准影响后仍有超额 + - ``information_ratio``: 年化信息比率 = mean(策略−基准)/std(策略−基准)×√N, + 每 1 单位跟踪误差换来多少超额收益 + - ``tracking_error``: 年化跟踪误差 = std(策略−基准)×√N + + 两条曲线按 bar 对齐(截取较短长度);基准方差为 0(曲线恒定)时 + beta/alpha 记 0,IR 在差值恒正且无波动时沿用 999 上限约定。 + + Args: + strategy_curve: 策略资金曲线(含 total 列) + benchmark_curve: 基准资金曲线(含 total 列) + annual_days: 年化交易日数 + + Returns: + {alpha, beta, information_ratio, tracking_error} + """ + s_total = strategy_curve["total"].to_numpy(dtype=np.float64) + b_total = benchmark_curve["total"].to_numpy(dtype=np.float64) + n = min(len(s_total), len(b_total)) + if n < 3: + return {"alpha": 0.0, "beta": 0.0, "information_ratio": 0.0, "tracking_error": 0.0} + + def _daily_ret(total: NDArray) -> NDArray: + safe_prev = np.where(total[:-1] != 0, total[:-1], np.nan) + ret = np.diff(total) / safe_prev + return ret[np.isfinite(ret)] + + s_ret = _daily_ret(s_total[:n]) + b_ret = _daily_ret(b_total[:n]) + m = min(len(s_ret), len(b_ret)) + if m < 2: + return {"alpha": 0.0, "beta": 0.0, "information_ratio": 0.0, "tracking_error": 0.0} + s_ret, b_ret = s_ret[:m], b_ret[:m] + + b_var = float(np.var(b_ret)) + if b_var > 1e-18: + beta = float(np.cov(s_ret, b_ret)[0, 1] / b_var) + alpha = float((np.mean(s_ret) - beta * np.mean(b_ret)) * annual_days) + else: + beta = 0.0 + alpha = float(np.mean(s_ret) * annual_days) + + diff = s_ret - b_ret + diff_std = float(np.std(diff)) + if diff_std > 1e-12: + information_ratio = float(np.mean(diff) / diff_std * np.sqrt(annual_days)) + elif np.mean(diff) > 0: + information_ratio = 999.0 + else: + information_ratio = 0.0 + tracking_error = diff_std * np.sqrt(annual_days) + + return { + "alpha": alpha, + "beta": beta, + "information_ratio": information_ratio, + "tracking_error": tracking_error, + } + + def evaluate_strategy( strategy: type[Strategy] | Strategy, df: pd.DataFrame, @@ -153,8 +253,11 @@ def evaluate_strategy( score = score_strategy(perf, wf=wf) grade = grade_performance(perf) - # 5. 基准对比(买入持有,同区间同费率) - bh = run_buy_hold_benchmark(df, **engine_kwargs) + # 5. 基准对比(买入持有,同区间同费率):超额收益 + Alpha/Beta/IR/TE + bh_result = _run_buy_hold_result(df, **engine_kwargs) + bh_keys = ("total_return", "annual_return", "max_drawdown", "sharpe", "calmar", "volatility") + bh = dict(to_json_native({k: bh_result.performance.get(k, 0.0) for k in bh_keys})) + comparison = compute_benchmark_comparison(bt.equity_curve, bh_result.equity_curve) return { "performance": to_json_native(dict(perf)), @@ -166,6 +269,7 @@ def evaluate_strategy( "buy_hold": bh, "excess_return": float(perf.get("total_return", 0.0)) - float(bh.get("total_return", 0.0)), + **comparison, }, "config": { "symbol": symbol, diff --git a/src/easy_tdx/backtest/cli.py b/src/easy_tdx/backtest/cli.py index ff0e846..7f3b2bf 100644 --- a/src/easy_tdx/backtest/cli.py +++ b/src/easy_tdx/backtest/cli.py @@ -318,6 +318,16 @@ def _print_table(result: Any) -> None: click.echo(f"夏普比率: {perf.get('sharpe', 0):.2f}") click.echo(f"胜率: {perf.get('win_rate', 0):.2%}") click.echo(f"交易次数: {perf.get('total_trades', 0)}") + # 深度风险指标(v1.28 新增;老结果缺键时跳过,不输出 0 假值) + if perf.get("ulcer_index") is not None: + click.echo(f"Ulcer 指数: {perf.get('ulcer_index', 0):.4f}") + click.echo(f"日 VaR(95%): {perf.get('var_95', 0):.2%}") + click.echo(f"日 CVaR(95%): {perf.get('cvar_95', 0):.2%}") + click.echo(f"SQN 系统质量: {perf.get('sqn', 0):.2f}") + click.echo( + f"最大连胜/连亏: {perf.get('max_consecutive_wins', 0)} / " + f"{perf.get('max_consecutive_losses', 0)}" + ) click.echo() if getattr(result, "diagnostic", None): diff --git a/src/easy_tdx/backtest/engine.py b/src/easy_tdx/backtest/engine.py index aaedb46..2c2698a 100644 --- a/src/easy_tdx/backtest/engine.py +++ b/src/easy_tdx/backtest/engine.py @@ -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) diff --git a/src/easy_tdx/backtest/performance.py b/src/easy_tdx/backtest/performance.py index 5bf05ac..8eb2827 100644 --- a/src/easy_tdx/backtest/performance.py +++ b/src/easy_tdx/backtest/performance.py @@ -22,7 +22,8 @@ else: class PerformanceAnalyzer: """绩效分析器。 - 从资金曲线和交易记录计算 19 项绩效指标。 + 从资金曲线和交易记录计算 25 项绩效指标(19 项经典指标 + 6 项 + 深度风险指标:Ulcer / VaR / CVaR / SQN / 最大连胜连亏,v1.28 新增)。 Attributes: ANNUAL_DAYS: 年化交易日数(默认 252) @@ -55,7 +56,7 @@ class PerformanceAnalyzer: """计算绩效指标。 Returns: - 包含 19 项指标的字典: + 包含 25 项指标的字典: - total_return: 总收益率 - annual_return: 年化收益率 - max_drawdown: 最大回撤 @@ -75,6 +76,14 @@ class PerformanceAnalyzer: - max_loss: 最大亏损 - avg_holding_days: 平均持仓天数(FIFO 配对、按 size 加权,日历日口径) - volatility: 年化波动率 + - ulcer_index: Ulcer 指数(回撤深度平方均值的开方,综合反映 + 回撤深度与持续时间,越小持有体验越好) + - var_95: 95% 日 VaR(历史分位数法,正数表示单日最大损失幅度) + - cvar_95: 95% 日 CVaR / 期望损失(尾部 5% 日收益均值,正数) + - sqn: 系统质量数(Van Tharp SQN = √N × 单笔收益率均值/标准差, + >2 可用、>4 优秀、>6 极佳的经验分档) + - max_consecutive_wins: 最大连胜笔数(按 SELL 成交顺序统计) + - max_consecutive_losses: 最大连亏笔数 """ # 边界检查 if len(self._equity_curve) < 2: @@ -211,6 +220,28 @@ class PerformanceAnalyzer: # 19. 年化波动率 volatility = np.std(daily_ret) * np.sqrt(self.ANNUAL_DAYS) + # 20. Ulcer 指数(Martin:√(mean(回撤幅度²)),深度与持续时间加权) + ulcer_index = float(np.sqrt(np.mean(drawdown_pct**2))) + + # 21. 95% 日 VaR(历史分位数法;正数表示损失幅度,便于直觉解读) + var_95 = float(-np.percentile(daily_ret, 5)) + + # 22. 95% 日 CVaR(VaR 之外尾部收益的均值;样本不足时退化为 VaR) + tail = daily_ret[daily_ret <= -var_95] + cvar_95 = float(-np.mean(tail)) if len(tail) > 0 else var_95 + + # 23. SQN 系统质量数(√N × 单笔收益率均值 / 标准差) + valid_tr = trade_returns[np.isfinite(trade_returns)] + if len(valid_tr) >= 2 and np.std(valid_tr) > 1e-12: + sqn = float(np.sqrt(len(valid_tr)) * np.mean(valid_tr) / np.std(valid_tr)) + else: + sqn = 0.0 + + # 24/25. 最大连胜/连亏(与 win_rate 同口径:按 SELL 成交顺序) + max_consecutive_wins, max_consecutive_losses = self._max_win_lose_streaks( + sell_trades["pnl"].to_numpy(dtype=np.float64) + ) + return { "total_return": total_return, "annual_return": annual_return, @@ -231,6 +262,12 @@ class PerformanceAnalyzer: "max_loss": max_loss, "avg_holding_days": avg_holding_days, "volatility": volatility, + "ulcer_index": ulcer_index, + "var_95": var_95, + "cvar_95": cvar_95, + "sqn": sqn, + "max_consecutive_wins": max_consecutive_wins, + "max_consecutive_losses": max_consecutive_losses, # 别名键(兼容常见叫法,避免 .get('sharpe_ratio') 等误用返回 0) "sharpe_ratio": sharpe, "start_cash": float(total[0]), @@ -371,7 +408,37 @@ class PerformanceAnalyzer: "max_loss": 0.0, "avg_holding_days": 0.0, "volatility": 0.0, + "ulcer_index": 0.0, + "var_95": 0.0, + "cvar_95": 0.0, + "sqn": 0.0, + "max_consecutive_wins": 0, + "max_consecutive_losses": 0, "sharpe_ratio": 0.0, "start_cash": 0.0, "end_value": 0.0, } + + @staticmethod + def _max_win_lose_streaks(pnl_seq: NDArray) -> tuple[int, int]: + """按成交顺序统计最大连胜/连亏笔数。 + + pnl > 0 记为胜,pnl <= 0 记为负(与 win_rate 的胜/负口径一致)。 + + Args: + pnl_seq: SELL 成交的 pnl 序列(时间升序) + + Returns: + (最大连胜笔数, 最大连亏笔数) + """ + max_wins = max_losses = cur_wins = cur_losses = 0 + for pnl in pnl_seq: + if pnl > 0: + cur_wins += 1 + cur_losses = 0 + max_wins = max(max_wins, cur_wins) + else: + cur_losses += 1 + cur_wins = 0 + max_losses = max(max_losses, cur_losses) + return int(max_wins), int(max_losses) diff --git a/src/easy_tdx/backtest/strategy.py b/src/easy_tdx/backtest/strategy.py index 16f3008..65cd229 100644 --- a/src/easy_tdx/backtest/strategy.py +++ b/src/easy_tdx/backtest/strategy.py @@ -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) diff --git a/src/easy_tdx/backtest/types.py b/src/easy_tdx/backtest/types.py index fcbb9df..816c485 100644 --- a/src/easy_tdx/backtest/types.py +++ b/src/easy_tdx/backtest/types.py @@ -25,9 +25,14 @@ class Signal: price: 限价(None = 市价单) stop_loss: 止损价(None = 不设置) take_profit: 止盈价(None = 不设置) + trail_stop: 移动止损百分比(如 0.08 = 自持仓期间最高收盘价回撤 + 8% 触发,None = 不设置)。与 ``stop_loss`` 同时设置时固定价优先。 source: 信号来源。"strategy"=策略产生(默认); "stop"=止损/止盈触发。stop 来源的信号不在信号 bar 当根成交, 而是延迟到下一根开盘(消除用当根 intrabar 触发价成交的前视偏差)。 + + ``stop_loss`` / ``take_profit`` / ``trail_stop`` 三者构成 OCO + (one-cancels-other):任一触发即全部失效,由引擎逐 bar 监控。 """ datetime: int @@ -36,6 +41,7 @@ class Signal: price: float | None = None stop_loss: float | None = None take_profit: float | None = None + trail_stop: float | None = None source: str = "strategy" diff --git a/src/easy_tdx/mac/commands/__init__.py b/src/easy_tdx/mac/commands/__init__.py index b067bce..994820c 100644 --- a/src/easy_tdx/mac/commands/__init__.py +++ b/src/easy_tdx/mac/commands/__init__.py @@ -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", ] diff --git a/src/easy_tdx/mac/commands/unusual.py b/src/easy_tdx/mac/commands/unusual.py index 56e4d2e..572648b 100644 --- a/src/easy_tdx/mac/commands/unusual.py +++ b/src/easy_tdx/mac/commands/unusual.py @@ -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(" 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]]): "0.001 的变动),松到容忍跨平台浮点求和顺序的尾数噪声。 +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +import numpy as np +import pandas as pd +import pytest + +from easy_tdx.backtest.benchmark import ( + compute_benchmark_comparison, + run_buy_hold_benchmark, +) +from easy_tdx.backtest.engine import BacktestEngine +from easy_tdx.backtest.strategies import builtin # noqa: F401 # 触发注册 +from easy_tdx.backtest.strategies.registry import _REGISTRY +from easy_tdx.backtest.strategy import Strategy + +GOLDEN_PATH = Path(__file__).resolve().parents[1] / "golden" / "backtest_metrics.json" +REGEN = os.environ.get("EASY_TDX_REGEN_GOLDEN", "") == "1" + +# 与基线 meta 一致的固定参数 +SEED = 20260902 +BARS = 400 +CASH = 100000.0 + +# 内置策略锁定的指标子集(全部为确定性数值;int 与 float 分开比对) +STRATEGY_METRICS_FLOAT = ( + "total_return", + "max_drawdown", + "sharpe", + "win_rate", + "ulcer_index", + "var_95", + "cvar_95", + "sqn", +) +STRATEGY_METRICS_INT = ( + "total_trades", + "max_consecutive_wins", + "max_consecutive_losses", +) + + +def _golden_df() -> pd.DataFrame: + """固定种子的合成日线(几何随机游走 + 温和上行漂移)。""" + rng = np.random.default_rng(SEED) + close = 20.0 * np.exp(np.cumsum(rng.normal(0.0004, 0.018, BARS))) + high = close * (1 + np.abs(rng.normal(0, 0.008, BARS))) + low = close * (1 - np.abs(rng.normal(0, 0.008, BARS))) + open_ = low + (high - low) * rng.uniform(0, 1, BARS) + vol = rng.uniform(5e5, 5e6, BARS) + return pd.DataFrame( + { + "datetime": pd.date_range("2023-01-02", periods=BARS, freq="B"), + "open": open_, + "high": high, + "low": low, + "close": close, + "vol": vol, + "amount": close * vol, + } + ) + + +# ── 规则场景策略(手工构造行情路径,锁定触发语义本身) ─────────────────────── + + +class _BuyOnce(Strategy): + """首根买入(可携带 bracket 参数),不再主动交易;无参数时即买入持有。""" + + def __init__(self, **bracket: Any) -> None: + super().__init__() + self._bracket: dict[str, Any] = bracket + self._bought = False + + def init(self) -> None: + pass + + def next(self) -> None: + if not self._bought: + self.buy(**self._bracket) + self._bought = True + + +def _rule_df(closes: list[float]) -> pd.DataFrame: + """按收盘价序列构造无随机因素的 OHLC(high/low = close ±1%)。""" + arr = np.asarray(closes, dtype=float) + n = len(arr) + return pd.DataFrame( + { + "datetime": pd.date_range("2024-01-01", periods=n, freq="B"), + "open": arr, + "high": arr * 1.01, + "low": arr * 0.99, + "close": arr, + "vol": [1000.0] * n, + "amount": arr * 1000, + } + ) + + +def _run_rule(closes: list[float], **bracket: Any) -> dict[str, Any]: + """跑规则场景,返回待锁定的摘要(成交明细 + 关键指标)。""" + result = BacktestEngine(_BuyOnce(**bracket), cash=CASH).run(_rule_df(closes)) + trades = [ + [t.direction, round(float(t.price), 4), int(pd.Timestamp(t.datetime).strftime("%Y%m%d"))] + for t in result.trades.itertuples() + if not t.rejected + ] + return { + "trades": trades, + "total_return": float(result.performance["total_return"]), + "total_trades": int(result.performance["total_trades"]), + } + + +RULE_SCENARIOS: dict[str, dict[str, Any]] = { + # 跌破固定止损 9.5 → 触发 SELL@9.5,延迟下一根成交 + "stop_loss": { + "closes": [10, 10.2, 10.1, 9.8, 9.3, 9.0, 8.8, 8.6, 8.4, 8.2], + "bracket": {"stop_loss": 9.5}, + }, + # 触及固定止盈 11.0 → OCO 使止损线失效 + "take_profit": { + "closes": [10, 10.3, 10.8, 11.2, 11.5, 11.8, 12.0, 12.2, 12.4, 12.6], + "bracket": {"stop_loss": 9.0, "take_profit": 11.0}, + }, + # 自最高收盘 12 回撤 8% → 11.04 触发移动止损 + "trailing_stop": { + "closes": [10, 10.2, 10.5, 11, 11.5, 12, 11.9, 11.5, 11.0, 10.5, 10.0, 9.5], + "bracket": {"trail_stop": 0.08}, + }, + # 百分比 bracket:5% 止损 / 10% 止盈(基准价 = 信号根收盘 10) + "bracket_pct": { + "closes": [10, 10.3, 10.8, 11.2, 11.5, 11.8, 12.0, 12.2, 12.4, 12.6], + "bracket": {"stop_loss_pct": 0.05, "take_profit_pct": 0.10}, + }, +} + + +def _build_golden() -> dict[str, Any]: + """重新计算并返回完整黄金基线。""" + df = _golden_df() + + strategies: dict[str, dict[str, Any]] = {} + for name in sorted(_REGISTRY.names()): + reg = _REGISTRY.get(name) + cls = reg.strategy_cls + perf = BacktestEngine(cls, cash=CASH).run(df).performance + entry: dict[str, Any] = {k: float(perf[k]) for k in STRATEGY_METRICS_FLOAT} + entry.update({k: int(perf[k]) for k in STRATEGY_METRICS_INT}) + strategies[name] = entry + + rules = { + key: _run_rule(spec["closes"], **spec["bracket"]) for key, spec in RULE_SCENARIOS.items() + } + + # 买入持有基准 + CAPM 对比(用 ma_cross 做策略侧) + bh = run_buy_hold_benchmark(df, cash=CASH) + ma = _REGISTRY.get("ma_cross").strategy_cls + ma_result = BacktestEngine(ma, cash=CASH).run(df) + comparison = compute_benchmark_comparison( + ma_result.equity_curve, + BacktestEngine(_BuyOnce(), cash=CASH).run(df).equity_curve, + ) + + return { + "meta": { + "seed": SEED, + "bars": BARS, + "cash": CASH, + "tolerance": {"rel": 1e-6, "abs": 1e-6}, + "note": "regen: EASY_TDX_REGEN_GOLDEN=1 pytest tests/unit/test_golden_backtest.py", + }, + "strategies": strategies, + "rules": rules, + "buy_hold": {k: float(v) for k, v in bh.items()}, + "benchmark_comparison": {k: float(v) for k, v in comparison.items()}, + } + + +def _load_golden() -> dict[str, Any]: + if not GOLDEN_PATH.exists(): + pytest.fail( + f"黄金基线缺失: {GOLDEN_PATH}\n" + "首次生成请运行: EASY_TDX_REGEN_GOLDEN=1 python -m pytest " + "tests/unit/test_golden_backtest.py" + ) + data = json.loads(GOLDEN_PATH.read_text(encoding="utf-8")) + assert isinstance(data, dict) + return data + + +def _save_golden(data: dict[str, Any]) -> None: + GOLDEN_PATH.parent.mkdir(parents=True, exist_ok=True) + GOLDEN_PATH.write_text( + json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +@pytest.fixture(scope="module") +def golden() -> dict[str, Any]: + """加载基线;REGEN=1 时重新计算并写盘后返回。""" + if REGEN: + data = _build_golden() + _save_golden(data) + return data + return _load_golden() + + +def _assert_metric(actual: Any, expected: Any, label: str) -> None: + """int 精确比对;float 按 rel=abs=1e-6 容差比对。""" + if isinstance(expected, int) and not isinstance(expected, bool): + assert actual == expected, f"{label}: {actual} != {expected}" + else: + assert float(actual) == pytest.approx(float(expected), rel=1e-6, abs=1e-6), ( + f"{label}: {actual} != {expected}" + ) + + +# ── 测试入口 ───────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("name", sorted(_REGISTRY.names())) +def test_golden_builtin_strategies(golden: dict[str, Any], name: str) -> None: + """全部内置策略在固定数据上的绩效指标与基线一致。""" + perf = BacktestEngine(_REGISTRY.get(name).strategy_cls, cash=CASH).run(_golden_df()).performance + baseline = golden["strategies"][name] + for key in STRATEGY_METRICS_FLOAT: + _assert_metric(perf[key], baseline[key], f"{name}.{key}") + for key in STRATEGY_METRICS_INT: + _assert_metric(perf[key], baseline[key], f"{name}.{key}") + + +@pytest.mark.parametrize("scenario", sorted(RULE_SCENARIOS)) +def test_golden_rule_scenarios(golden: dict[str, Any], scenario: str) -> None: + """止损/止盈/移动止损/OCO 触发语义(成交价与时点)与基线一致。""" + spec = RULE_SCENARIOS[scenario] + actual = _run_rule(spec["closes"], **spec["bracket"]) + baseline = golden["rules"][scenario] + assert actual["total_trades"] == baseline["total_trades"], scenario + assert len(actual["trades"]) == len(baseline["trades"]), f"{scenario}: 成交笔数漂移" + for i, (a, b) in enumerate(zip(actual["trades"], baseline["trades"])): + assert a[0] == b[0], f"{scenario} 第{i}笔方向漂移: {a} vs {b}" + _assert_metric(a[1], b[1], f"{scenario}.trades[{i}].price") + assert a[2] == b[2], f"{scenario} 第{i}笔成交日漂移: {a} vs {b}" + _assert_metric(actual["total_return"], baseline["total_return"], f"{scenario}.total_return") + + +def test_golden_buy_hold(golden: dict[str, Any]) -> None: + """买入持有基准指标与基线一致。""" + bh = run_buy_hold_benchmark(_golden_df(), cash=CASH) + for key, expected in golden["buy_hold"].items(): + _assert_metric(bh[key], expected, f"buy_hold.{key}") + + +def test_golden_benchmark_comparison(golden: dict[str, Any]) -> None: + """Alpha/Beta/IR/TE 基准对比指标与基线一致。""" + df = _golden_df() + ma = _REGISTRY.get("ma_cross").strategy_cls + strategy_curve = BacktestEngine(ma, cash=CASH).run(df).equity_curve + bh_curve = BacktestEngine(_BuyOnce(), cash=CASH).run(df).equity_curve + comparison = compute_benchmark_comparison(strategy_curve, bh_curve) + for key, expected in golden["benchmark_comparison"].items(): + _assert_metric(comparison[key], expected, f"benchmark.{key}") + + +def test_golden_meta_frozen(golden: dict[str, Any]) -> None: + """基线 meta 与测试常量一致(防止改数据参数后忘记重建基线)。""" + meta = golden["meta"] + assert meta["seed"] == SEED + assert meta["bars"] == BARS + assert meta["cash"] == CASH diff --git a/tests/unit/test_public_api.py b/tests/unit/test_public_api.py index e4506a1..00bf63c 100644 --- a/tests/unit/test_public_api.py +++ b/tests/unit/test_public_api.py @@ -72,6 +72,7 @@ _EXPECTED_KIND: dict[str, str] = { "CALC_HOSTS": "constant", "MAC_HOSTS": "constant", "XDXR_CATEGORY_NAMES": "constant", + "UNUSUAL_TYPE_NAMES": "constant", } diff --git a/tests/unit/test_unusual.py b/tests/unit/test_unusual.py new file mode 100644 index 0000000..03538e9 --- /dev/null +++ b/tests/unit/test_unusual.py @@ -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 字节异动记录( 头 + 13B 数据区 + 保留 + 时间)。""" + return ( + struct.pack(" bytes: + text = ",".join("测试股" for _ in records) + return struct.pack(" { 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) +}