fix(security_quotes): 修复 ETF/指数实时行情价格被放大10倍 (Issue #8)

This commit is contained in:
GitHub
2026-06-29 22:15:28 +08:00
parent 3945800728
commit db83e7505d
5 changed files with 175 additions and 17 deletions
+11
View File
@@ -2,6 +2,17 @@
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
## [1.15.4] — 2026-06-29
### 修复
- **ETF / 指数 / 基金 / 可转债 / 国债实时行情价格被放大 10 倍**(`commands/security_quotes.py`[Issue #8](https://github.com/handsomejustin/easy_tdx/issues/8))— `get_security_quotes` 返回的 `price_raw` 及五档差分字段统一以「厘」(0.001 元) 为基本单位编码,但**报价精度按品种而异**:股票 2 位(分),ETF / 指数 / 基金 / 可转债 / 国债 / 国债逆回购 3 位(厘)。此前一律按 `/ 100.0`2 位)解析,导致 ETF 等本应 `/ 1000.0`(3 位)的品种价格被放大 10 倍(如现价 6.123 元的 ETF 错误显示成 61.23)。
- 新增 `_price_decimal_digits(market, code)`,凭 `market + code` 代码段推断有效小数位:沪市 `5`ETF/基金)、`000`(指数)、`8`(行业指数)按 3 位;深市 `1`ETF/LOF/可转债/国债)按 3 位;其余股票按 2 位。
- 同一代码不同市场含义不同,必须结合市场判断:`SZ 000001` = 平安银行(股票,2 位),`SH 000001` = 上证指数(3 位),二者不可混淆。
- 价格字段(现价 / 昨收 / 今开 / 最高 / 最低 / 五档买卖价)除法从硬编码 `/100.0` 改为按 `divisor = 10 ** 位数` 动态除法;`rise_speed` 等非价格字段保持 `/100.0` 不变。
- `SecurityQuote` 新增 `decimal_point` 字段(默认 2,向后兼容),标注该条行情实际采用的小数位数,便于核对。
- `decimal_point` 不在行情响应包内,仅能凭代码段推断(pytdx 把这一步留给用户,本项目做自包含解析)。新增 4 个单元测试覆盖 ETF/股票/指数精度与品种分类,全量 680 单测通过,既有 `600000` fixture 断言不变(股票行为无回归)。
## [1.15.3] — 2026-06-27
### 变更
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "easy-tdx"
version = "1.15.3"
version = "1.15.4"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md"
requires-python = ">=3.10"
+60 -16
View File
@@ -14,6 +14,45 @@ from ..models.quote import SecurityQuote
from .base import BaseCommand
def _price_decimal_digits(market: Market, code: str) -> int:
"""推断某证券报价的有效小数位数。
通达信协议中,price 及各档差分均以「厘」(0.001 元) 为基本单位编码,
但报价精度按品种而异:股票 2 位(分),指数/ETF/基金/可转债/国债/国债逆回购 3 位(厘)。
若一律按 /100 解析,ETF/指数等品种价格会被放大 10 倍(见 Issue #8)。
decimal_point 不在行情响应包内,只能凭 market + code 代码段推断。
注意同一代码不同市场含义不同:SZ 000001=平安银行(股票,2位)
SH 000001=上证指数(3位),故必须结合市场判断。
Returns:
2 或 3
"""
code = (code or "").strip().rstrip("\x00")
# 上海:5 开头为基金/国债,0/3/8/9 开头需看前缀
if market == Market.SH:
if code.startswith("5"): # 51x ETF、55x 货币基金、56x 跨境ETF、58x 科创ETF
return 3
if code.startswith("000"): # 000001 上证指数、000300 沪深300 等
return 3
if code.startswith("8"): # 880xxx 行业指数
return 3
return 2 # 60xxxx / 68xxxx 科创板 A 股
# 深圳:1/3 开头的 15x/16x/18x 为基金,12x 为可转债,11x 为国债
if market == Market.SZ:
if code.startswith("1"): # 159 ETF、163/165/166/167 基金、128 可转债、111/112/113 国债
return 3
if code.startswith("3"): # 300/301 创业板(股票)
return 2
# 000/001/002/003 主板、中小板 A 股
return 2
# 北京:暂按 A 股 2 位处理
return 2
def _format_server_time(raw: int) -> str:
"""将 reversed_bytes0 整数转换为 HH:MM:SS.mmm 字符串。
@@ -144,21 +183,25 @@ class GetSecurityQuotesCmd(BaseCommand[list[SecurityQuote]]):
)
pos += 4
p = price_raw / 100.0
try:
market = Market(market_b)
except ValueError as e:
raise TdxDecodeError(f"security_quotes 非法 market 值: {market_b}") from e
code = code_b.decode("utf-8").rstrip("\x00")
# 价格按品种有效小数位解析:股票/100,指数·ETF·基金/可转债/国债/1000Issue #8
divisor = 10 ** _price_decimal_digits(market, code)
p = price_raw / divisor
results.append(
SecurityQuote(
market=market,
code=code_b.decode("utf-8").rstrip("\x00"),
code=code,
price=p,
pre_close=(price_raw + last_close_diff) / 100.0,
open=(price_raw + open_diff) / 100.0,
high=(price_raw + high_diff) / 100.0,
low=(price_raw + low_diff) / 100.0,
pre_close=(price_raw + last_close_diff) / divisor,
open=(price_raw + open_diff) / divisor,
high=(price_raw + high_diff) / divisor,
low=(price_raw + low_diff) / divisor,
vol=float(vol),
cur_vol=float(cur_vol),
amount=amount,
@@ -166,29 +209,30 @@ class GetSecurityQuotesCmd(BaseCommand[list[SecurityQuote]]):
b_vol=float(b_vol),
active1=active1,
active2=active2,
bid1=(price_raw + bid1_d) / 100.0,
bid1=(price_raw + bid1_d) / divisor,
bid_vol1=float(bv1),
bid2=(price_raw + bid2_d) / 100.0,
bid2=(price_raw + bid2_d) / divisor,
bid_vol2=float(bv2),
bid3=(price_raw + bid3_d) / 100.0,
bid3=(price_raw + bid3_d) / divisor,
bid_vol3=float(bv3),
bid4=(price_raw + bid4_d) / 100.0,
bid4=(price_raw + bid4_d) / divisor,
bid_vol4=float(bv4),
bid5=(price_raw + bid5_d) / 100.0,
bid5=(price_raw + bid5_d) / divisor,
bid_vol5=float(bv5),
ask1=(price_raw + ask1_d) / 100.0,
ask1=(price_raw + ask1_d) / divisor,
ask_vol1=float(av1),
ask2=(price_raw + ask2_d) / 100.0,
ask2=(price_raw + ask2_d) / divisor,
ask_vol2=float(av2),
ask3=(price_raw + ask3_d) / 100.0,
ask3=(price_raw + ask3_d) / divisor,
ask_vol3=float(av3),
ask4=(price_raw + ask4_d) / 100.0,
ask4=(price_raw + ask4_d) / divisor,
ask_vol4=float(av4),
ask5=(price_raw + ask5_d) / 100.0,
ask5=(price_raw + ask5_d) / divisor,
ask_vol5=float(av5),
rise_speed=rise_speed_raw / 100.0,
limit_up=None,
limit_down=None,
decimal_point=_price_decimal_digits(market, code),
unknown_2=unknown_2,
unknown_3=unknown_3,
unknown_5=unknown_5,
+3
View File
@@ -66,6 +66,9 @@ class SecurityQuote:
limit_up: float | None # 涨停价(业务规则计算)
limit_down: float | None # 跌停价(业务规则计算)
# 价格有效小数位(2=股票按分, 3=指数/ETF/基金/可转债/国债按厘,Issue #8)
decimal_point: int = field(default=2, repr=False)
# 协议原始值(含义已确认,保留以供高级分析)
unknown_2: int = field(default=0, repr=False) # 指数: IndexOpenAmount/100; 个股: 舍入残差
unknown_3: int = field(default=0, repr=False) # 个股: StockOpenAmount/100; 指数: 负值
+100
View File
@@ -163,6 +163,106 @@ def test_security_quotes_parse():
assert isinstance(q.open_amount, float)
assert q.open_amount == 22694 * 100.0
# 股票按 2 位小数(分)报价(Issue #8)
assert q.decimal_point == 2
def _build_quote_record(market: int, code: str, price_raw: int) -> bytes:
"""构造一条 security_quotes 记录:仅 price_raw 有值,其余全置 0。
price_raw 单位是「厘」(0.001 元),由调用方按品种精度给出:
股票=分(×100)ETF/指数=厘(×1000)。
"""
from easy_tdx.codec.price import put_price
rec = struct.pack("<B6sH", market, code.encode(), 0) # market, code, active1
rec += put_price(price_raw) # price_raw
rec += put_price(0) * 4 # last_close/open/high/low diffs
rec += put_price(0) * 2 # unknown_0, unknown_1
rec += put_price(0) * 2 # vol, cur_vol
rec += struct.pack("<I", 0) # amount
rec += put_price(0) * 2 # s_vol, b_vol
rec += put_price(0) * 2 # unknown_2, unknown_3
rec += put_price(0) * 20 # 5 档 bid/ask diffs + vols
rec += struct.pack("<H", 0) # trading_status
rec += put_price(0) * 4 # unknown_5-8
rec += struct.pack("<hH", 0, 0) # rise_speed, active2
return rec
def _build_quote_body(market: int, code: str, price_raw: int) -> bytes:
return b"\xb1\xcb" + struct.pack("<H", 1) + _build_quote_record(market, code, price_raw)
def test_security_quotes_decimal_point_classification():
"""Issue #8:价格小数位按 market+code 代码段推断。
同一代码不同市场含义不同:SZ 000001=平安银行(股票,2位)
SH 000001=上证指数(3位),故必须结合市场判断。
"""
from easy_tdx.commands.security_quotes import _price_decimal_digits
from easy_tdx.models.enums import Market
# ETF / 基金 / 可转债 / 国债 / 指数 -> 3 位(厘)
assert _price_decimal_digits(Market.SZ, "159922") == 3 # 深 ETF
assert _price_decimal_digits(Market.SZ, "161725") == 3 # 深 LOF 基金
assert _price_decimal_digits(Market.SZ, "128095") == 3 # 深 可转债
assert _price_decimal_digits(Market.SZ, "111002") == 3 # 深 国债
assert _price_decimal_digits(Market.SH, "510300") == 3 # 沪 ETF
assert _price_decimal_digits(Market.SH, "511990") == 3 # 沪 货币基金
assert _price_decimal_digits(Market.SH, "000001") == 3 # 上证指数
assert _price_decimal_digits(Market.SH, "000300") == 3 # 沪深 300 指数
# 股票 -> 2 位(分)
assert _price_decimal_digits(Market.SZ, "000001") == 2 # 深主板(平安银行)
assert _price_decimal_digits(Market.SZ, "002594") == 2 # 中小板
assert _price_decimal_digits(Market.SZ, "300750") == 2 # 创业板
assert _price_decimal_digits(Market.SH, "600000") == 2 # 沪主板
assert _price_decimal_digits(Market.SH, "688981") == 2 # 科创板
def test_security_quotes_etf_price_not_inflated_10x():
"""Issue #8:ETF 价格必须按 3 位小数解析,不能仍被放大 10 倍。
159922 现价 6.123 元 → price_raw=6123(厘)。错误地按 /100 解析会得到 61.23。
"""
from easy_tdx.commands.security_quotes import GetSecurityQuotesCmd
from easy_tdx.models.enums import Market
body = _build_quote_body(int(Market.SZ), "159922", 6123)
q = GetSecurityQuotesCmd([(Market.SZ, "159922")]).parse_response(body)[0]
assert q.decimal_point == 3
assert abs(q.price - 6.123) < 1e-9
assert q.price < 10.0 # 不能是 61.23 这种被放大 10 倍的值
def test_security_quotes_stock_price_unchanged():
"""Issue #8 回归保护:股票仍按 2 位小数解析,行为不变。
600000 现价 9.89 元 → price_raw=989(分)。
"""
from easy_tdx.commands.security_quotes import GetSecurityQuotesCmd
from easy_tdx.models.enums import Market
body = _build_quote_body(int(Market.SH), "600000", 989)
q = GetSecurityQuotesCmd([(Market.SH, "600000")]).parse_response(body)[0]
assert q.decimal_point == 2
assert abs(q.price - 9.89) < 1e-9
def test_security_quotes_index_price_3_digits():
"""Issue #8:上证指数 SH000001 现价 3123.456 → 按 3 位小数解析。"""
from easy_tdx.commands.security_quotes import GetSecurityQuotesCmd
from easy_tdx.models.enums import Market
body = _build_quote_body(int(Market.SH), "000001", 3123456)
q = GetSecurityQuotesCmd([(Market.SH, "000001")]).parse_response(body)[0]
assert q.decimal_point == 3
assert abs(q.price - 3123.456) < 1e-6
# ---------------------------------------------------------------------------
# minute_time