mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 14:34:15 +08:00
fix(security_quotes): 修复 ETF/指数实时行情价格被放大10倍 (Issue #8)
This commit is contained in:
@@ -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
@@ -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"
|
||||
|
||||
@@ -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·基金/可转债/国债/1000(Issue #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,
|
||||
|
||||
@@ -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; 指数: 负值
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user