From 12d308e1a4f89e7f7d8777b7238e9a1cc6247340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AF=9B=E5=88=A9=E5=93=A5?= <97915@qq.com> Date: Mon, 31 Aug 2026 20:43:35 +0800 Subject: [PATCH] =?UTF-8?q?fix!:=20=E6=89=A9=E5=B1=95=E6=97=A5=E7=BA=BF?= =?UTF-8?q?=E6=A7=BD=E4=BD=8D=E8=AF=AD=E4=B9=89=E2=80=94=E2=80=94=E7=AC=AC?= =?UTF-8?q?6=E6=A7=BD=E4=B8=BA=20float32=20=E6=88=90=E4=BA=A4=E9=A2=9D?= =?UTF-8?q?=EF=BC=8C=E7=A7=BB=E9=99=A4=E8=AF=AF=E5=AF=BC=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=20hk=5Fstock=5Famount=EF=BC=88#57=EF=BC=89=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit read_ex_daily_bars 原把第7槽(成交量)同时赋给 amount 与 vol(amount=vol), 真实成交额藏在第6槽 float32 重解释值里、以 hk_stock_amount 之名暴露。 实测对照:47#IF300 2023-09-11 第6槽 float32=186871758848(元)、第7槽 uint32=105358016(手),与标准市场 sh000300.day 同日 amount/vol 完全一致。 - _EX_DAILY_FMT: --- CHANGELOG.md | 25 +++++++++++++++ pyproject.toml | 2 +- src/easy_tdx/cli/cmd_offline.py | 1 + src/easy_tdx/offline/ex_daily_bar.py | 23 +++++++------- tests/unit/test_write_ex_daily.py | 46 +++++++++++++++++++++++++--- 5 files changed, 80 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68466db..66e4832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,31 @@ 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 +## [1.21.0] — 2026-08-31 + +**扩展日线(vipdoc/ds `*.day`)解析槽位错误**(Issue #57,含破坏性 API 变更)——`read_ex_daily_bars` 把第 7 槽(成交量)同时赋给 `amount` 与 `vol`(`amount=vol`),真正的成交额藏在第 6 槽 float32 重解释值里、以误导性字段名 `hk_stock_amount` 暴露。实测铁证:扩展市场 `47#IF300`(沪深300)2023-09-11 第 6 槽 float32 = 186,871,758,848、第 7 槽 uint32 = 105,358,016,与标准市场 `sh000300.day` 同日 amount(元)/ vol(手)**完全一致**,证明第 6 槽是 float32 成交额、第 7 槽是 uint32 成交量。`_EX_DAILY_FMT` 旧声明 ` 4295 万股的股票(招商银行 2026-08-31 单日 1.14 亿股等)编码后超出 uint32 上限,`struct.error` 直接失败;低成交量股票不触发,故长期未被发现。 + +### 修复 + +- **`cli/cmd_offline.py` `_sync_one_daily`**——对 `vol_coeff == 0.01` 的证券类型(A/B 股、深市基金等)写入前换算 股→手(`vol /= 100`),与读取端 `read_daily_bars` 的 `vol × 0.01` 方向对称。作者用服务器原生 .day 文件(0x06B9 下载)实测验证:浦发银行 2026-08-31 原始 vol 字段 99,682,464(股)与协议 API 完全一致。 +- 已知边界(未处理):单日成交 > 42.9 亿股的极端天量换算后仍超上限;实测通达信官方 .day 对该 bar 亦降级存储,如需对齐另行讨论。 + ## [1.20.12] — 2026-08-28 **`ex tick --date` 传 YYYYMMDD 整数直接崩溃**(PR #56,社区贡献者 @Harveyliu007)——CLI 的 `--date` 选项传入 `YYYYMMDD` 整数,而 `MacExClient.goods_tick_chart()` 只接受 `datetime.date`(内部直接 `query_date.year` 编码),实跑必抛 `AttributeError: 'int' object has no attribute 'year'`;`cmd_ex.py` 的调用点长期带 `# type: ignore[arg-type]`,类型系统没能拦住。A 股侧 `MacClient.get_tick_chart()` 早已支持 int 日期(内部转换),ex 侧漏了同类处理。 diff --git a/pyproject.toml b/pyproject.toml index 7f0483e..7d9e960 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easy-tdx" -version = "1.20.13" +version = "1.21.0" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" readme = "README.md" requires-python = ">=3.10" diff --git a/src/easy_tdx/cli/cmd_offline.py b/src/easy_tdx/cli/cmd_offline.py index d6961f9..4d1b5dc 100644 --- a/src/easy_tdx/cli/cmd_offline.py +++ b/src/easy_tdx/cli/cmd_offline.py @@ -280,6 +280,7 @@ def ex_daily( "high": b.high, "low": b.low, "close": b.close, + "amount": b.amount, "vol": b.vol, "settlement": b.settlement, } diff --git a/src/easy_tdx/offline/ex_daily_bar.py b/src/easy_tdx/offline/ex_daily_bar.py index dde3b7e..b34d46c 100644 --- a/src/easy_tdx/offline/ex_daily_bar.py +++ b/src/easy_tdx/offline/ex_daily_bar.py @@ -6,22 +6,26 @@ from pathlib import Path from ..exceptions import TdxFileNotFoundError -# 日期(4B) 开盘(4Bf) 最高(4Bf) 最低(4Bf) 收盘(4Bf) 成交额(4B) 成交量(4B) 结算价(4Bf) -_EX_DAILY_FMT = struct.Struct(" list[ExDailyBar]: for offset in range(0, len(data) - record_size + 1, record_size): raw = data[offset : offset + record_size] - date_int, op, hi, lo, cl, amt, vol, settlement = _EX_DAILY_FMT.unpack(raw) - - # 第 5 个字段(成交额位置)重新解释为 float 作为港股量 - hk_bytes = struct.pack(" list[ExDailyBar]: high=hi, low=lo, close=cl, - amount=vol, + amount=amount, vol=vol, settlement=settlement, - hk_stock_amount=hk_stock_amount, year=year, month=month, day=day, diff --git a/tests/unit/test_write_ex_daily.py b/tests/unit/test_write_ex_daily.py index dcdfce5..b7a04d6 100644 --- a/tests/unit/test_write_ex_daily.py +++ b/tests/unit/test_write_ex_daily.py @@ -26,7 +26,7 @@ def _make_ex_bar( high: float = 3550.0, low: float = 3480.0, close: float = 3520.0, - amount: int = 123456, + amount: float = 123456.0, vol: int = 98765, settlement: float = 3510.0, ) -> ExDailyBar: @@ -38,7 +38,6 @@ def _make_ex_bar( amount=amount, vol=vol, settlement=settlement, - hk_stock_amount=0.0, year=year, month=month, day=day, @@ -53,7 +52,7 @@ def _make_raw_ex_bar( high: float = 3550.0, low: float = 3480.0, close: float = 3520.0, - amount: int = 123456, + amount: float = 123456.0, vol: int = 98765, settlement: float = 3510.0, ) -> bytes: @@ -101,7 +100,7 @@ class TestEncodeExDailyBar: class TestExRoundTrip: def test_single_bar_round_trip(self, tmp_path: Path) -> None: - bar = _make_ex_bar(open_=3500.0, close=3520.0, vol=98765) + bar = _make_ex_bar(open_=3500.0, close=3520.0, amount=123456.0, vol=98765) encoded = encode_ex_daily_bar(bar) filepath = tmp_path / "29#A1801.day" @@ -113,6 +112,7 @@ class TestExRoundTrip: assert b.year == 2026 and b.month == 6 and b.day == 6 assert abs(b.open - 3500.0) < 0.01 assert abs(b.close - 3520.0) < 0.01 + assert abs(b.amount - 123456.0) < 0.01 assert b.vol == 98765 def test_multiple_bars_round_trip(self, tmp_path: Path) -> None: @@ -188,3 +188,41 @@ class TestAppendExDailyBars: bars = read_ex_daily_bars(filepath) assert len(bars) == 2 + + +# --------------------------------------------------------------------------- +# 真实样本回归(issue #57):槽位语义 +# --------------------------------------------------------------------------- + +# 47#IF300(扩展市场沪深300 指数)2023-09-11 的真实记录。 +# 与标准市场 sh000300.day 同日对照:vol=105358016(手)、amount=186871758848.0(元)完全一致, +# 证明第 6 槽是 float32 成交额,第 7 槽是 uint32 成交量。 +_REAL_IF300_20230911 = bytes.fromhex( + "ffb2340148c969451fc56c45856b6945a4786b45b3092e52c0a2470600000000" +) + + +class TestRealSampleSlots: + def test_amount_is_float32_turnover(self, tmp_path: Path) -> None: + filepath = tmp_path / "47#IF300.day" + filepath.write_bytes(_REAL_IF300_20230911) + + bars = read_ex_daily_bars(filepath) + assert len(bars) == 1 + b = bars[0] + assert (b.year, b.month, b.day) == (2023, 9, 11) + assert b.amount == 186871758848.0 + assert b.vol == 105358016 + assert abs(b.close - 3767.54) < 0.01 + # amount 与 vol 不得串槽(回归:旧实现 amount=vol) + assert b.amount != b.vol + + def test_large_amount_does_not_overflow(self, tmp_path: Path) -> None: + # 真实成交额动辄超 uint32 上限,第 6 槽必须按 float32 编码 + bar = _make_ex_bar(amount=186871758848.0) + encoded = encode_ex_daily_bar(bar) + filepath = tmp_path / "47#IF300.day" + filepath.write_bytes(encoded) + + (b,) = read_ex_daily_bars(filepath) + assert b.amount == 186871758848.0