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)。
This commit is contained in:
GitHub
2026-09-02 17:12:00 +08:00
parent 8a5ed52a1f
commit eeed45b171
8 changed files with 282 additions and 2 deletions
+16
View File
@@ -2,6 +2,22 @@
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 本文件记录 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` 路由 docstringOpenAPI 文档)写明。附带发现(本轮未改行为,仅记录):指数分时接口 `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 ## [1.28.1] — 2026-09-02
**Web UI 新手友好化 + AI 解读导出**——回测报告的两个「看不懂」出口:名词解释折叠帮助(新手向)与 AI 解读 Prompt 一键导出(LLM 辅助解读),另修复 Walk-Forward 窗口数据被序列化成字符串的后端 bug。 **Web UI 新手友好化 + AI 解读导出**——回测报告的两个「看不懂」出口:名词解释折叠帮助(新手向)与 AI 解读 Prompt 一键导出(LLM 辅助解读),另修复 Walk-Forward 窗口数据被序列化成字符串的后端 bug。
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "easy-tdx" name = "easy-tdx"
version = "1.28.1" version = "1.28.2"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
+69
View File
@@ -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()
+8
View File
@@ -540,6 +540,9 @@ class TdxClient:
上午最后一根 5min 标 11:25、下午第一根标 13:00);``"end"`` = bar 右端点 上午最后一根 5min 标 11:25、下午第一根标 13:00);``"end"`` = bar 右端点
(= 开始 + 周期时长,与 Tushare/同花顺对齐,上午最后一根标 11:30)。 (= 开始 + 周期时长,与 Tushare/同花顺对齐,上午最后一根标 11:30)。
仅对分钟级周期生效;日线及以上不受影响。 仅对分钟级周期生效;日线及以上不受影响。
vol 单位:分钟线/日线为成交量(股);周/月/季/年线服务端原样返回的是
真实成交量/100,解析层已 ×100 还原为股。
""" """
cmd = GetSecurityBarsCmd(market, code, category, start, count) cmd = GetSecurityBarsCmd(market, code, category, start, count)
bars = self._execute(cmd) bars = self._execute(cmd)
@@ -575,6 +578,11 @@ class TdxClient:
Args: Args:
bar_time: 见 :meth:`get_security_bars`,分钟级周期时间戳可对齐 Tushare 右端点。 bar_time: 见 :meth:`get_security_bars`,分钟级周期时间戳可对齐 Tushare 右端点。
vol 单位:日线与周/月/季/年线为成交量(手)(周及以上周期服务端原样
返回真实成交量/100,解析层已 ×100 还原);**分钟线协议不提供成交量**
(报文中该字段实为成交额/100,与 amount 冗余),vol 为 NaN——请勿将
其当作成交量使用,Web API 中序列化为 ``null``。
""" """
cmd = GetIndexBarsCmd(market, code, category, start, count) cmd = GetIndexBarsCmd(market, code, category, start, count)
bars = self._execute(cmd) bars = self._execute(cmd)
+63
View File
@@ -14,6 +14,48 @@ from .base import BaseCommand
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
# --------------------------------------------------------------------------- #
# vol 字段语义修正(issue #642026-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 9cat 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]]): class GetSecurityBarsCmd(BaseCommand[list[SecurityBar]]):
"""获取指定股票的 K 线数据。 """获取指定股票的 K 线数据。
@@ -24,6 +66,9 @@ class GetSecurityBarsCmd(BaseCommand[list[SecurityBar]]):
category: K线周期 category: K线周期
start: 起始行(0 = 最新;分页时递增) start: 起始行(0 = 最新;分页时递增)
count: 返回条数(最多 800) count: 返回条数(最多 800)
vol 字段语义:分钟线/日线为成交量(股);周/月/季/年线服务端返回的
是真实成交量/100,解析层已 ×100 还原为股(见模块头部注释)。
""" """
def __init__( def __init__(
@@ -114,6 +159,10 @@ class GetSecurityBarsCmd(BaseCommand[list[SecurityBar]]):
low_abs = open_abs + low_diff low_abs = open_abs + low_diff
pre_diff_base = open_abs + close_diff pre_diff_base = open_abs + close_diff
# 周/月/季/年线:服务端 vol 字段为真实成交量/100,×100 还原
if cat in _WEEK_PLUS_CATS:
vol *= 100.0
bars.append( bars.append(
SecurityBar( SecurityBar(
open=open_abs / 1000.0, open=open_abs / 1000.0,
@@ -139,6 +188,11 @@ class GetIndexBarsCmd(GetSecurityBarsCmd):
请求格式与股票 K 线相同,但响应每条记录在 vol+amt 后多 4 字节 请求格式与股票 K 线相同,但响应每条记录在 vol+amt 后多 4 字节
(上涨家数 uint16 + 下跌家数 uint16),必须跳过否则后续记录错位。 (上涨家数 uint16 + 下跌家数 uint16),必须跳过否则后续记录错位。
vol 字段语义(与服务端行为对齐,见模块头部注释):
- 日线:成交量(手)
- 周/月/季/年线:解析层已 ×100 还原为成交量(手);
- 分钟线:协议不提供成交量(f1 实为成交额百元),vol 为 NaN。
""" """
def parse_response(self, body: bytes) -> list[SecurityBar]: def parse_response(self, body: bytes) -> list[SecurityBar]:
@@ -188,6 +242,15 @@ class GetIndexBarsCmd(GetSecurityBarsCmd):
low_abs = open_abs + low_diff low_abs = open_abs + low_diff
pre_diff_base = open_abs + close_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( bars.append(
SecurityBar( SecurityBar(
open=open_abs / 1000.0, open=open_abs / 1000.0,
+9 -1
View File
@@ -95,6 +95,9 @@ async def security_bars(
优先走 AsyncMacClient.get_stock_kline(支持 NONE/QFQ/HFQ 复权 + QFQ 负价兜底); 优先走 AsyncMacClient.get_stock_kline(支持 NONE/QFQ/HFQ 复权 + QFQ 负价兜底);
MAC 主机未连接时自动回退 AsyncTdxClient.get_security_bars(无复权,adjust 参数忽略)。 MAC 主机未连接时自动回退 AsyncTdxClient.get_security_bars(无复权,adjust 参数忽略)。
输出契约与旧版一致:日线返回 ``date`` 列,分钟线返回 ``datetime`` 列。 输出契约与旧版一致:日线返回 ``date`` 列,分钟线返回 ``datetime`` 列。
vol 单位:分钟线/日线 = 成交量(股);周/月/季/年线服务端原样返回真实
成交量/100,回退路径(标准 TdxClient)已 ×100 还原为股。
""" """
cat = category_from_str(category) cat = category_from_str(category)
if mac_client is not None: if mac_client is not None:
@@ -135,7 +138,12 @@ async def index_bars(
), ),
client: Any = Depends(get_client), client: Any = Depends(get_client),
) -> DataFrameResponse: ) -> DataFrameResponse:
"""获取指数K线数据。""" """获取指数K线数据。
vol 单位:日线/周线/月线/季线/年线 = 成交量(手)(周及以上周期服务端
原样返回真实成交量/100,已 ×100 还原);**分钟线协议不提供成交量**
(报文中该字段实为成交额/100),vol 为 ``null``,请勿当作成交量使用。
"""
df = await client.get_index_bars( df = await client.get_index_bars(
market_from_str(market), code, category_from_str(category), start, count, bar_time=bar_time market_from_str(market), code, category_from_str(category), start, count, bar_time=bar_time
) )
+4
View File
@@ -110,6 +110,10 @@ class DataFrameResponse(BaseModel):
assert isinstance(k, str) assert isinstance(k, str)
if hasattr(v, "isoformat"): if hasattr(v, "isoformat"):
clean_row[k] = 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"): elif hasattr(v, "item"):
# numpy scalar → Python native # numpy scalar → Python native
clean_row[k] = v.item() clean_row[k] = v.item()
+112
View File
@@ -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: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