diff --git a/CHANGELOG.md b/CHANGELOG.md index beee068..ad3f017 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### 变更 diff --git a/pyproject.toml b/pyproject.toml index 83445e0..6e9449f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/easy_tdx/commands/security_quotes.py b/src/easy_tdx/commands/security_quotes.py index c628fb1..6f30fbc 100644 --- a/src/easy_tdx/commands/security_quotes.py +++ b/src/easy_tdx/commands/security_quotes.py @@ -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, diff --git a/src/easy_tdx/models/quote.py b/src/easy_tdx/models/quote.py index d30a0bf..edc8dd1 100644 --- a/src/easy_tdx/models/quote.py +++ b/src/easy_tdx/models/quote.py @@ -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; 指数: 负值 diff --git a/tests/unit/test_commands_offline.py b/tests/unit/test_commands_offline.py index cc26041..e8b8213 100644 --- a/tests/unit/test_commands_offline.py +++ b/tests/unit/test_commands_offline.py @@ -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(" bytes: + return b"\xb1\xcb" + struct.pack(" 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