fix(tick_charts): 多日分时图 minutes>=1440 报 ValueError (Issue #10)

This commit is contained in:
GitHub
2026-07-01 16:22:57 +08:00
parent 615994ad72
commit bc83ffa4ac
4 changed files with 127 additions and 2 deletions
+11
View File
@@ -2,6 +2,17 @@
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
## [1.16.1] — 2026-07-01
### 修复
- **多日分时图 `tick --days N` 命令因 `minutes ≥ 1440``ValueError: hour must be in 0..23`**`mac/commands/tick_charts.py`[Issue #10](https://github.com/handsomejustin/easy_tdx/issues/10))— 执行 `easy-tdx tick SH 600519 --days 5` 时崩溃。多日分时图(MAC 协议 `0x123E`)解析 `time(minutes // 60, minutes % 60)` 缺少对 24 取模的保护,当个别服务器 / 数据状态下返回的 `minutes` 值 ≥ 1440(累计或异常值,用户实测出现 `minutes ≈ 62340``// 60 == 1039`)时,`minutes // 60` 超过 23 触发 `ValueError`
- 修复:改为 `time(minutes // 60 % 24, minutes % 60)`,与单日分时 `SymbolTickChartCmd` 的处理**完全一致**。
- 语义自洽:每条 tick 的**日期**取自 `date_ints[d]`(与 `minutes` 无关),`minutes` 字段只承载「日内时刻」,`% 24` 折算成日内时刻是正确的降级。
- **对正常数据零行为改变**:抓取多只股票 × {2 天, 5 天} 真实响应逐条对比,新公式与旧公式产出 `time` 对象**完全相同**`new_vs_old_diffs=0`),所有时刻落在 09–15 交易时段。
- 对异常 `minutes` 值,无法仅凭该字段恢复真正时刻(需日边界信息),故产出合法占位时刻,避免崩溃、不污染日期列。
- 新增 3 个单元测试(`test_mac_tick_charts.py`:正常分钟 / Issue #10 回归用报错现场原值 62340 / 请求包布局),全量 703 单测通过。
## [1.16.0] — 2026-06-30 ## [1.16.0] — 2026-06-30
### 新增 ### 新增
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "easy-tdx" name = "easy-tdx"
version = "1.16.0" version = "1.16.1"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
+4 -1
View File
@@ -72,7 +72,10 @@ class TickChartsCmd(BaseCommand[MacMultiTickChart]):
) )
ticks.append( ticks.append(
MacTick( MacTick(
time=time(minutes // 60, minutes % 60), # 多日分时里 minutes 在个别服务器/数据状态下可能 ≥ 1440
# (累计或异常值),对 24 取模折算成日内时刻,避免 ValueError。
# 与单日分时 SymbolTickChartCmd 的处理保持一致(Issue #10)。
time=time(minutes // 60 % 24, minutes % 60),
price=price, price=price,
avg=avg, avg=avg,
vol=vol, vol=vol,
+111
View File
@@ -0,0 +1,111 @@
"""MAC 多日分时图(0x123E)解析测试。
包含 Issue #10 回归:当服务器返回的 minutes ≥ 1440(个别服务器累计或异常值)
时,parse_response 不应抛 ValueError,而应按 % 24 折算为日内时刻。
"""
import struct
from easy_tdx.mac.commands.tick_charts import TickChartsCmd
from easy_tdx.models.enums import Market
def _build_multi_tick_body(
days: int,
page_size: int,
minutes_grid: list[list[int]],
) -> bytes:
"""构造一个多日分时图响应 body。
Args:
days: 天数(写入 count)。
page_size: 每天分时点数。
minutes_grid: 长度 days 的列表,每个元素是长度 page_size 的 minutes 值列表。
"""
# 头部: market(2) + code(22)
body = bytearray(struct.pack("<H22s", int(Market.SH), b"600519" + b"\x00" * 16))
# 5 dates + 5 pre_closes(不足 5 天用 0 填充)at offset 24
dates = [20250115, 20250114, 20250113, 20250110, 20250109]
pre_closes = [1509.0, 1512.0, 1510.0, 1508.0, 1511.0]
body += struct.pack("<5I", *dates)
body += struct.pack("<5f", *pre_closes)
# count(2) + send_last(1) + page_size(2) + total(2) at offset 64
body += struct.pack("<HBHH", days, 1, page_size, days * page_size)
# tick records at offset 71, each 14 bytes: <HffHH
for day in minutes_grid:
assert len(day) == page_size
for m in day:
body += struct.pack("<HffHH", m, 1510.0, 1510.0, 100, 0)
# 尾部元数据: <44sBHf5x2I5ffIf12s2fI
body += struct.pack("<44s", "贵州茅台".encode("gbk") + b"\x00" * 32)
body += struct.pack("<BHf", 2, 0, 1.0) # decimal, category, vol_unit
body += b"\x00" * 5
body += struct.pack("<2I", 20250115, 0)
body += struct.pack("<5f", 1509.0, 1510.0, 1520.0, 1500.0, 1515.0)
body += struct.pack("<fI", 0.0, 100000) # momentum, vol
body += struct.pack("<f", 1000000.0) # amount
body += b"\x00" * 12
body += struct.pack("<2f", 0.5, 1512.0) # turnover, avg
body += struct.pack("<I", 0) # industry
return bytes(body)
def test_multi_tick_charts_normal_minutes():
"""常规数据:minutes 每天 570..689, 780..899,解析正常。"""
page_size = 4
grid = [
[570, 571, 572, 573],
[570, 600, 780, 899],
[570, 571, 572, 573],
]
body = _build_multi_tick_body(3, page_size, grid)
chart = TickChartsCmd(int(Market.SH), "600519", None, 3).parse_response(body)
assert len(chart.charts) == 3
# 第一天第一个点 09:30:00
assert str(chart.charts[0].ticks[0].time) == "09:30:00"
# 最后一个点 14:59:00
assert str(chart.charts[1].ticks[3].time) == "14:59:00"
def test_multi_tick_charts_oversized_minutes_no_crash():
"""Issue #10 回归:minutes ≥ 1440(如累计/异常值)不应抛 ValueError。
用户报告 hour must be in 0..23, not 1039(即 minutes // 60 == 1039)。
修复后应按 % 24 折算为日内时刻,保证不崩溃且结果合理。
"""
page_size = 4
# 第一天第 2 个点 minutes=62340= 报错现场,//60=1039),其余正常
grid = [
[570, 62340, 572, 573],
[570, 600, 780, 899],
]
body = _build_multi_tick_body(2, page_size, grid)
chart = TickChartsCmd(int(Market.SH), "600519", None, 2).parse_response(body)
assert len(chart.charts) == 2
# 折算后 62340 -> 62340//60 % 24 = 1039 % 24 = 7 时, 62340 % 60 = 0 分
assert str(chart.charts[0].ticks[1].time) == "07:00:00"
# 正常点不受影响
assert str(chart.charts[0].ticks[0].time) == "09:30:00"
assert str(chart.charts[1].ticks[3].time) == "14:59:00"
def test_multi_tick_charts_request_layout():
"""验证请求包布局与文档一致:market + code(22) + start_ymd + days + 1。"""
from datetime import date as date_cls
cmd = TickChartsCmd(int(Market.SH), "600519", date_cls(2025, 1, 15), 5)
req = cmd.build_request()
# MAC 帧前缀由 build_mac_request 生成,body 在其后;校验 body 嵌入其中
expected_body = struct.pack(
"<H22sIHH6x",
int(Market.SH),
b"600519" + b"\x00" * 16,
20250115,
5,
1,
)
assert expected_body in req