Files
easy_tdx_max/tests/unit/test_bars_vol_semantics.py
GitHub eeed45b171 fix(bars): 指数/个股 K 线 vol 字段协议语义修正(#64)
通达信服务端 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)。
2026-09-02 17:12:00 +08:00

113 lines
4.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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:00zipday=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("<HH", _ZIPDAY, _TMIN)
else:
dt = struct.pack("<I", 20260902)
rec = (
dt
+ put_price(100)
+ put_price(50)
+ put_price(80)
+ put_price(-40)
+ struct.pack("<I", _IVOL_F1)
+ struct.pack("<I", _IVOL_F2)
)
if index:
rec += struct.pack("<HH", 535, 1782) # 上涨/下跌家数
return struct.pack("<H", n_bars) + rec * n_bars
class TestIndexBarsVol:
"""GetIndexBarsCmd vol 语义。"""
def test_minute_vol_is_nan(self):
"""指数分钟线:协议不提供成交量,vol=NaN 而非成交额/100。"""
for cat in (0, 1, 2, 3, 7, 8):
cmd = GetIndexBarsCmd(Market.SH, "000001", KlineCategory(cat), 0, 1)
bars = cmd.parse_response(_make_body(cat))
assert len(bars) == 1
assert math.isnan(bars[0].vol), f"cat={cat} 分钟线 vol 应为 NaN"
assert bars[0].amount == _decode_volume(_IVOL_F2)
def test_week_plus_vol_restored_x100(self):
"""指数周/月/季/年线:vol ×100 还原为真实成交量(手)。"""
for cat in (5, 6, 10, 11):
cmd = GetIndexBarsCmd(Market.SH, "000001", KlineCategory(cat), 0, 1)
bars = cmd.parse_response(_make_body(cat))
assert bars[0].vol == _decode_volume(_IVOL_F1) * 100.0, f"cat={cat}"
def test_daily_and_daily_alt_vol_unchanged(self):
"""指数日线(4)与日线变体(9)vol 原样(cat 9 虽枚举名 YEAR,实为日线)。"""
for cat in (4, 9):
cmd = GetIndexBarsCmd(Market.SH, "000001", KlineCategory(cat), 0, 1)
bars = cmd.parse_response(_make_body(cat))
assert bars[0].vol == _decode_volume(_IVOL_F1), f"cat={cat}"
def test_minute_multi_bar_alignment(self):
"""多条分钟记录解析不错位(涨跌家数 4 字节跳过逻辑完好)。"""
cmd = GetIndexBarsCmd(Market.SH, "000001", KlineCategory.MIN_5, 0, 2)
bars = cmd.parse_response(_make_body(0, n_bars=2))
assert len(bars) == 2
assert all(math.isnan(b.vol) for b in bars)
assert bars[0].hour == 15 and bars[0].minute == 0
class TestSecurityBarsVol:
"""GetSecurityBarsCmd vol 语义。"""
def test_minute_and_daily_vol_unchanged(self):
"""股票分钟/日线:vol 原样(成交量,股)。"""
for cat in (0, 4, 7):
cmd = GetSecurityBarsCmd(Market.SH, "600000", KlineCategory(cat), 0, 1)
bars = cmd.parse_response(_make_body(cat, index=False))
assert bars[0].vol == _decode_volume(_IVOL_F1), f"cat={cat}"
def test_week_plus_vol_restored_x100(self):
"""股票周/月/季/年线:vol ×100 还原为股(与日线单位一致)。"""
for cat in (5, 6, 10, 11):
cmd = GetSecurityBarsCmd(Market.SH, "600000", KlineCategory(cat), 0, 1)
bars = cmd.parse_response(_make_body(cat, index=False))
assert bars[0].vol == _decode_volume(_IVOL_F1) * 100.0, f"cat={cat}"
class TestDataFrameResponseNan:
"""NaN → nullWeb 层不得向 Starletteallow_nan=False)透传 NaN。"""
def test_nan_serialized_as_none(self):
import pandas as pd
from easy_tdx.web.schemas import DataFrameResponse
df = pd.DataFrame({"vol": [float("nan"), 1.0], "amount": [2.0, 3.0]})
resp = DataFrameResponse.from_dataframe(df)
assert resp.data[0]["vol"] is None
assert resp.data[1]["vol"] == 1.0
assert resp.count == 2