From eeed45b17109fca4d135c39fdb6b5f5a811656de Mon Sep 17 00:00:00 2001 From: GitHub Date: Wed, 2 Sep 2026 17:12:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(bars):=20=E6=8C=87=E6=95=B0/=E4=B8=AA?= =?UTF-8?q?=E8=82=A1=20K=20=E7=BA=BF=20vol=20=E5=AD=97=E6=AE=B5=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E8=AF=AD=E4=B9=89=E4=BF=AE=E6=AD=A3=EF=BC=88#64?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通达信服务端 K 线记录第一个 4 字节字段的语义随周期/品种变化,此前原样 透传错误数据(逐字节拆包 + 新浪实时行情/东方财富三方交叉验证锁定): - 指数分钟线(MIN_1/3/5/15/30/60,含 880xxx 板块指数):f1 实为 成交额(百元),与 amount 恒差 100 倍,真实分钟成交量不在报文中 (15:00 上证 5min 真值 13,954,814 手 vs 返回 208,748,512≈amount/100) → vol 置 NaN,不拿成交额冒充成交量; - 指数与个股周/月/季/年线(cat 5/6/10/11):f1 = 真实成交量/100 (上证本周三日日线 vol 合计 1,666,668,288 手 vs 周线 16,666,683) → ×100 还原,与日线单位对齐(指数=手、个股=股); - 日线(cat 4)与 cat 9(日线变体,枚举名误标 YEAR,真年线是 cat 11) 不受影响,cat 9 明确不套 ×100 并由测试锁定。 配套:DataFrameResponse NaN→null(Starlette allow_nan=False 透传会 500); client/路由 docstring 写明各单位;回归测试 7 例(实抓原始字节构造报文); 验收脚本 scripts/verify_issue64.py 连真实服务器复测。附带发现仅记录: 指数分时 vol=成交额(万元)、/bars?category=YEAR 实际返回日线(cat 9)。 --- CHANGELOG.md | 16 ++++ pyproject.toml | 2 +- scripts/verify_issue64.py | 69 +++++++++++++++ src/easy_tdx/client.py | 8 ++ src/easy_tdx/commands/security_bars.py | 63 ++++++++++++++ src/easy_tdx/web/routers/bars.py | 10 ++- src/easy_tdx/web/schemas.py | 4 + tests/unit/test_bars_vol_semantics.py | 112 +++++++++++++++++++++++++ 8 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 scripts/verify_issue64.py create mode 100644 tests/unit/test_bars_vol_semantics.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e9cb504..015e66b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 +## [1.28.2] — 2026-09-02 + +**修复指数/个股 K 线 vol 字段的三类协议语义错误**([#64](https://github.com/handsomejustin/easy_tdx/issues/64))——通达信服务端 K 线记录的第一个 4 字节字段(一直被当作成交量透传)的语义随周期/品种变化,此前原样返回错误数据。本轮通过逐字节拆包原始报文 + 新浪实时行情/东方财富分钟 K 三方交叉验证锁定规律后,在协议解析层(`GetIndexBarsCmd` / `GetSecurityBarsCmd` 的 `parse_response`,同步/异步客户端共用)统一修正。 + +### 修复 + +- **指数分钟线(MIN_1/3/5/15/30/60,含 880xxx 板块指数)vol 实为成交额/100**——报文中两个字段分别是「成交额(百元)」与「成交额(元)」,恒差 100 倍(实测比值 0.9999996~0.9999999,剩余偏差仅为 4 字节自定义浮点的解码噪声),**真实的分钟成交量根本不在报文中**(对照:上证指数 2026-09-02 15:00 的 5min bar,东财真实成交量 13,954,814 手 / 成交额 208.75 亿元,协议 f1 返回的 208,748,512 ≈ amount/100,即 issue 反馈的现场)。修复后 vol 置 **NaN**(Web API 序列化为 `null`)而非拿成交额冒充成交量;`amount` 保持成交额(元)不变。 +- **指数与个股的周/月/季/年线 vol 恰好少 100 倍**——服务端该字段为真实成交量/100(铁证:上证指数本周 8/31+9/1+9/2 三个日线 vol 合计 1,666,668,288 手,周线 f1 返回 16,666,683;浦发银行周线 2,693,885×100 = 269,388,500 vs 三日日线合计 269,388,528 股,均精确到解码噪声)。修复后 ×100 还原,与日线单位对齐(指数=手、个股=股)。日线(cat 4)与 cat 9("日线变体"——枚举名误标为 YEAR,实测返回日线粒度数据,真年线是 cat 11)不受影响。 +- **`DataFrameResponse` 对 NaN 透传导致潜在 500**(`src/easy_tdx/web/schemas.py`)——Starlette `JSONResponse` 为 `allow_nan=False`,DataFrame 中任何 NaN(含本次指数分钟线 vol)直接抛异常返回 500。序列化统一 NaN → `null`。 +- 语义与单位已在 `get_index_bars` / `get_security_bars`(含异步版)与 `/bars`、`/bars/index` 路由 docstring(OpenAPI 文档)写明。附带发现(本轮未改行为,仅记录):指数分时接口 `get_minute_time_data` 的 vol 列为成交额(万元)(全日合计 ≈ 日成交额/10000,个股分时则正常为股);`KlineCategory.YEAR=9` 实为日线变体、真年线是 `YEAR_ALT=11`,`/bars?category=YEAR` 目前实际返回日线数据。 + +### 测试 + +- 新增 `tests/unit/test_bars_vol_semantics.py`(7 例):指数分钟线(6 个分钟周期 ×单条/多条对齐)vol=NaN 且 amount 不变、指数与个股周/月/季/年 ×100、日线与 cat 9 原样、`DataFrameResponse` NaN→null;报文用实抓原始字节构造(`0x4D4713FE`/`0x509B87A0` 为 2026-09-02 真实字段值)。 +- 新增 `scripts/verify_issue64.py`:连真实服务器的验收脚本,输出各周期 vol/amount 及 amt/vol 比值、分钟线 f1 vs amount/100 偏差、指数分时 vol 全日合计对照,供回归复测。 + ## [1.28.1] — 2026-09-02 **Web UI 新手友好化 + AI 解读导出**——回测报告的两个「看不懂」出口:名词解释折叠帮助(新手向)与 AI 解读 Prompt 一键导出(LLM 辅助解读),另修复 Walk-Forward 窗口数据被序列化成字符串的后端 bug。 diff --git a/pyproject.toml b/pyproject.toml index 3bb95c9..a62355d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easy-tdx" -version = "1.28.1" +version = "1.28.2" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/verify_issue64.py b/scripts/verify_issue64.py new file mode 100644 index 0000000..1b4489b --- /dev/null +++ b/scripts/verify_issue64.py @@ -0,0 +1,69 @@ +"""issue #64 验证脚本:指数 K 线 vol 字段语义核查(连真实服务器)。 + +结论(2026-09-02 实测,对照新浪实时行情与东方财富分钟K): + - 指数分钟线(MIN_1/3/5/15/30/60):协议每条记录的两个 4 字节字段 + f1 ≈ f2/100、f2 = 成交额(元)。f1 是"成交额(百元)"而非成交量, + 真实分钟成交量不在报文中(东财 15:00 5min bar 实测 13,954,814 手, + 而协议 f1 返回 208,748,512 ≈ amount/100)。 + - 指数日线:f1 = 成交量(手),与新浪实时行情一致(差值仅为自定义 + 浮点解码噪声 ~1e-7)。日线路径正确。 + - 指数周/月/季/年:f1 = 真实成交量/100(本周三交易日日线 vol 合计 + 1,666,668,288 手,周线 f1 = 16,666,683,恰好 ÷100)。 + - 附带发现:股票周/月线 f1 同样 = 真实vol/100(浦发 8/31-9/2 三日 + 日线 vol 合计 269,388,528 股,周线 f1×100 = 269,388,500); + 指数分时接口 vol 列 = 成交额(万元)(全日合计 ≈ 日成交额/10000)。 + +用法:python scripts/verify_issue64.py +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from easy_tdx.client import TdxClient # noqa: E402 +from easy_tdx.models.enums import KlineCategory, Market # noqa: E402 + + +def main() -> None: + cl = TdxClient() + cl.connect() + try: + print(f"{'调用':<44s} {'vol(返回)':>16s} {'amount(返回)':>18s} amt/vol") + cases = [ + ("SH000001 MIN_5 (指数分钟, issue场景)", Market.SH, "000001", KlineCategory.MIN_5), + ("SH000001 MIN_1 (指数分钟)", Market.SH, "000001", KlineCategory.MIN_1), + ("SH000001 DAY (指数日线, 正确)", Market.SH, "000001", KlineCategory.DAY), + ("SH000001 WEEK (指数周线, /100)", Market.SH, "000001", KlineCategory.WEEK), + ("SH000001 MONTH (指数月线, /100)", Market.SH, "000001", KlineCategory.MONTH), + ("SZ399001 MIN_5 (深成指分钟)", Market.SZ, "399001", KlineCategory.MIN_5), + ("SZ399006 MIN_5 (创业板指分钟)", Market.SZ, "399006", KlineCategory.MIN_5), + ] + for label, mkt, code, cat in cases: + df = cl.get_index_bars(mkt, code, cat, 0, 1) + r = df.iloc[-1] + ratio = r["amount"] / r["vol"] if r["vol"] else float("nan") + print(f"{label:<44s} {r['vol']:>16,.0f} {r['amount']:>18,.0f} {ratio:>10.2f}") + + # 分钟线 f1 与 amount/100 的偏差(仅剩解码噪声) + df = cl.get_index_bars(Market.SH, "000001", KlineCategory.MIN_5, 0, 5) + print("\n分钟线 f1 vs amount/100(应≈1.0,偏差为解码噪声):") + for _, r in df.iterrows(): + print( + f" {r['datetime']} vol={r['vol']:>13,.0f} amount/100={r['amount'] / 100:>13,.0f}" + f" 比值={r['vol'] / (r['amount'] / 100):.8f}" + ) + + # 分时接口 vol 全日合计 ≈ 日成交额/10000(万元) + mt = cl.get_minute_time_data(Market.SH, "000001") + day = cl.get_index_bars(Market.SH, "000001", KlineCategory.DAY, 0, 1).iloc[-1] + print( + f"\n指数分时 vol 全日合计 = {mt['vol'].sum():,.0f}" + f" 日成交额/10000 = {day['amount'] / 10000:,.0f}(≈成交额万元,非成交量)" + ) + finally: + cl.close() + + +if __name__ == "__main__": + main() diff --git a/src/easy_tdx/client.py b/src/easy_tdx/client.py index bbd36e5..a82e7de 100644 --- a/src/easy_tdx/client.py +++ b/src/easy_tdx/client.py @@ -540,6 +540,9 @@ class TdxClient: 上午最后一根 5min 标 11:25、下午第一根标 13:00);``"end"`` = bar 右端点 (= 开始 + 周期时长,与 Tushare/同花顺对齐,上午最后一根标 11:30)。 仅对分钟级周期生效;日线及以上不受影响。 + + vol 单位:分钟线/日线为成交量(股);周/月/季/年线服务端原样返回的是 + 真实成交量/100,解析层已 ×100 还原为股。 """ cmd = GetSecurityBarsCmd(market, code, category, start, count) bars = self._execute(cmd) @@ -575,6 +578,11 @@ class TdxClient: Args: bar_time: 见 :meth:`get_security_bars`,分钟级周期时间戳可对齐 Tushare 右端点。 + + vol 单位:日线与周/月/季/年线为成交量(手)(周及以上周期服务端原样 + 返回真实成交量/100,解析层已 ×100 还原);**分钟线协议不提供成交量** + (报文中该字段实为成交额/100,与 amount 冗余),vol 为 NaN——请勿将 + 其当作成交量使用,Web API 中序列化为 ``null``。 """ cmd = GetIndexBarsCmd(market, code, category, start, count) bars = self._execute(cmd) diff --git a/src/easy_tdx/commands/security_bars.py b/src/easy_tdx/commands/security_bars.py index 5a19675..ef8f6ad 100644 --- a/src/easy_tdx/commands/security_bars.py +++ b/src/easy_tdx/commands/security_bars.py @@ -14,6 +14,48 @@ from .base import BaseCommand _log = logging.getLogger(__name__) +# --------------------------------------------------------------------------- # +# vol 字段语义修正(issue #64,2026-09-02 实测并对照新浪实时行情/东方财富验证) +# +# 通达信服务端对 K 线记录里第一个 4 字节字段(下称 f1, universally 被当作 +# 成交量)的语义随周期/品种变化,直接透传会返回错误数据: +# +# 指数分钟线(MIN_1/3/5/15/30/60,含 880xxx 板块指数): +# f1 ≈ amount/100(成交额百元),f2 = 成交额(元) —— 两个字段都是成交额, +# 真实的分钟成交量不在报文中(对照东财:15:00 上证指数 5min bar 真实 +# 成交量 13,954,814 手,协议 f1 返回 208,748,512 ≈ amount/100)。 +# → vol 置 NaN,不拿成交额冒充成交量。 +# +# 指数与个股的周/月/季/年线(5/6/10/11): +# f1 = 真实成交量/100(上证指数本周 3 个交易日日线 vol 合计 1,666,668,288 +# 手,周线 f1 返回 16,666,683,恰好 ÷100;浦发周线/月线同理精确对账)。 +# → ×100 还原,与日线单位对齐(指数=手、个股=股)。 +# +# 日线(4)与 cat 9:cat 9 实为"日线变体"(实测返回日线粒度,非年线, +# 尽管枚举名误标为 YEAR),f1 = 真实成交量,无需修正。 +# --------------------------------------------------------------------------- # + +_MINUTE_CATS = frozenset( + int(c) + for c in ( + KlineCategory.MIN_1, + KlineCategory.MIN_3, + KlineCategory.MIN_5, + KlineCategory.MIN_15, + KlineCategory.MIN_30, + KlineCategory.MIN_60, + ) +) +_WEEK_PLUS_CATS = frozenset( + int(c) + for c in ( + KlineCategory.WEEK, + KlineCategory.MONTH, + KlineCategory.SEASON, + KlineCategory.YEAR_ALT, # cat 11 = 真年线;cat 9(枚举名 YEAR)是日线变体 + ) +) + class GetSecurityBarsCmd(BaseCommand[list[SecurityBar]]): """获取指定股票的 K 线数据。 @@ -24,6 +66,9 @@ class GetSecurityBarsCmd(BaseCommand[list[SecurityBar]]): category: K线周期 start: 起始行(0 = 最新;分页时递增) count: 返回条数(最多 800) + + vol 字段语义:分钟线/日线为成交量(股);周/月/季/年线服务端返回的 + 是真实成交量/100,解析层已 ×100 还原为股(见模块头部注释)。 """ def __init__( @@ -114,6 +159,10 @@ class GetSecurityBarsCmd(BaseCommand[list[SecurityBar]]): low_abs = open_abs + low_diff pre_diff_base = open_abs + close_diff + # 周/月/季/年线:服务端 vol 字段为真实成交量/100,×100 还原 + if cat in _WEEK_PLUS_CATS: + vol *= 100.0 + bars.append( SecurityBar( open=open_abs / 1000.0, @@ -139,6 +188,11 @@ class GetIndexBarsCmd(GetSecurityBarsCmd): 请求格式与股票 K 线相同,但响应每条记录在 vol+amt 后多 4 字节 (上涨家数 uint16 + 下跌家数 uint16),必须跳过否则后续记录错位。 + + vol 字段语义(与服务端行为对齐,见模块头部注释): + - 日线:成交量(手); + - 周/月/季/年线:解析层已 ×100 还原为成交量(手); + - 分钟线:协议不提供成交量(f1 实为成交额百元),vol 为 NaN。 """ def parse_response(self, body: bytes) -> list[SecurityBar]: @@ -188,6 +242,15 @@ class GetIndexBarsCmd(GetSecurityBarsCmd): low_abs = open_abs + low_diff pre_diff_base = open_abs + close_diff + # 指数 vol 语义修正: + # 周/月/季/年线:服务端 vol 字段为真实成交量/100,×100 还原; + # 分钟线:f1 实为成交额(百元)(与 amount 冗余),真实分钟成交量 + # 协议不提供,置 NaN 而非拿成交额冒充成交量。 + if cat in _WEEK_PLUS_CATS: + vol *= 100.0 + elif cat in _MINUTE_CATS: + vol = float("nan") + bars.append( SecurityBar( open=open_abs / 1000.0, diff --git a/src/easy_tdx/web/routers/bars.py b/src/easy_tdx/web/routers/bars.py index da04f07..65d33b2 100644 --- a/src/easy_tdx/web/routers/bars.py +++ b/src/easy_tdx/web/routers/bars.py @@ -95,6 +95,9 @@ async def security_bars( 优先走 AsyncMacClient.get_stock_kline(支持 NONE/QFQ/HFQ 复权 + QFQ 负价兜底); MAC 主机未连接时自动回退 AsyncTdxClient.get_security_bars(无复权,adjust 参数忽略)。 输出契约与旧版一致:日线返回 ``date`` 列,分钟线返回 ``datetime`` 列。 + + vol 单位:分钟线/日线 = 成交量(股);周/月/季/年线服务端原样返回真实 + 成交量/100,回退路径(标准 TdxClient)已 ×100 还原为股。 """ cat = category_from_str(category) if mac_client is not None: @@ -135,7 +138,12 @@ async def index_bars( ), client: Any = Depends(get_client), ) -> DataFrameResponse: - """获取指数K线数据。""" + """获取指数K线数据。 + + vol 单位:日线/周线/月线/季线/年线 = 成交量(手)(周及以上周期服务端 + 原样返回真实成交量/100,已 ×100 还原);**分钟线协议不提供成交量** + (报文中该字段实为成交额/100),vol 为 ``null``,请勿当作成交量使用。 + """ df = await client.get_index_bars( market_from_str(market), code, category_from_str(category), start, count, bar_time=bar_time ) diff --git a/src/easy_tdx/web/schemas.py b/src/easy_tdx/web/schemas.py index 4e5d14f..d3d3752 100644 --- a/src/easy_tdx/web/schemas.py +++ b/src/easy_tdx/web/schemas.py @@ -110,6 +110,10 @@ class DataFrameResponse(BaseModel): assert isinstance(k, str) if hasattr(v, "isoformat"): clean_row[k] = v.isoformat() + elif isinstance(v, float) and v != v: + # NaN → null:缺失值(如指数分钟线 vol,pandas 惯例 NaN), + # 而 Starlette JSONResponse 为 allow_nan=False,透传会 500 + clean_row[k] = None elif hasattr(v, "item"): # numpy scalar → Python native clean_row[k] = v.item() diff --git a/tests/unit/test_bars_vol_semantics.py b/tests/unit/test_bars_vol_semantics.py new file mode 100644 index 0000000..a3404bf --- /dev/null +++ b/tests/unit/test_bars_vol_semantics.py @@ -0,0 +1,112 @@ +"""K 线 vol 字段语义修正回归测试(issue #64)。 + +背景(2026-09-02 逐字节拆包 + 新浪实时行情/东方财富交叉验证): +通达信服务端 K 线记录第一个 4 字节字段(f1)的语义随周期/品种变化: + - 指数分钟线:f1 ≈ amount/100(成交额百元),真实分钟成交量不在报文中; + - 指数与个股的周/月/季/年线(cat 5/6/10/11):f1 = 真实成交量/100; + - 日线(cat 4)与 cat 9("日线变体",枚举名误标 YEAR):f1 = 真实成交量。 +解析层据此修正:指数分钟线 vol=NaN,周月季年 ×100,其余原样。 +""" + +import math +import struct + +from easy_tdx.codec.price import put_price +from easy_tdx.codec.volume import _decode_volume +from easy_tdx.commands.security_bars import GetIndexBarsCmd, GetSecurityBarsCmd +from easy_tdx.models.enums import KlineCategory, Market + +# 实抓报文中的两个 4 字节字段原始值(2026-09-02 上证指数 5min 15:00 bar) +_IVOL_F1 = 0x4D4713FE # 解码 ≈ 208,748,512(协议里实为 amount/100) +_IVOL_F2 = 0x509B87A0 # 解码 ≈ 20,874,854,400(真实成交额,元) + +# 分钟级时间戳 2026-09-02 15:00(zipday=45958, tminutes=900) +_ZIPDAY, _TMIN = 45958, 900 + + +def _make_body(cat: int, n_bars: int = 1, index: bool = True) -> bytes: + """构造 n_bars 条 K 线响应报文(OHLC 差分取小值,不影响 vol 断言)。""" + if cat in (0, 1, 2, 3, 7, 8): + dt = struct.pack("