"""离线 fixture 测试:将录制的原始 body 字节喂给各命令 parse_response,验证解析结果。 fixtures/ 目录下每个 .hex 文件是一次真实服务器响应的 body(已解压), 对应的 .json 文件记录关键预期值,供手工核对。 此测试文件直接断言解析结果,无需网络连接。 """ from __future__ import annotations import pathlib import struct FIXTURES = pathlib.Path(__file__).parent.parent / "fixtures" def load_hex(name: str) -> bytes: return bytes.fromhex((FIXTURES / f"{name}.hex").read_text(encoding="utf-8").strip()) # --------------------------------------------------------------------------- # security_count # --------------------------------------------------------------------------- def test_security_count_parse(): from easy_tdx.commands.security_count import GetSecurityCountCmd from easy_tdx.models.enums import Market body = load_hex("security_count") cmd = GetSecurityCountCmd(Market.SH) count = cmd.parse_response(body) assert isinstance(count, int) assert count > 0 # 体积固定为 2 字节,结果与录制时完全一致 assert count == 26885 # --------------------------------------------------------------------------- # security_list # --------------------------------------------------------------------------- def test_security_list_parse(): from easy_tdx.commands.security_list import GetSecurityListCmd from easy_tdx.models.enums import Market body = load_hex("security_list") cmd = GetSecurityListCmd(Market.SH, 0) records = cmd.parse_response(body) assert len(records) == 1000 r0 = records[0] assert r0.code == "999999" assert r0.name == "上证指数" assert abs(r0.pre_close - 3966.171142578125) < 0.01 # _raw present and non-empty for every record assert all(len(r._raw) > 0 for r in records) def test_security_list_pre_close_uses_tdx_float_for_a_share(): from easy_tdx.commands.security_list import GetSecurityListCmd from easy_tdx.models.enums import Market body = struct.pack(" 0 # OHLC sanity: high ≥ open,close,low; low ≤ open,close for bar in bars: assert bar.high >= bar.open - 0.001 assert bar.high >= bar.close - 0.001 assert bar.low <= bar.open + 0.001 assert bar.low <= bar.close + 0.001 assert bar.vol > 0 assert len(bar._raw) > 0 # --------------------------------------------------------------------------- # security_quotes # --------------------------------------------------------------------------- def test_security_quotes_parse(): from easy_tdx.commands.security_quotes import GetSecurityQuotesCmd from easy_tdx.models.enums import Market body = load_hex("security_quotes") cmd = GetSecurityQuotesCmd([(Market.SH, "600000")]) quotes = cmd.parse_response(body) assert len(quotes) == 1 q = quotes[0] assert q.code == "600000" assert abs(q.pre_close - 9.93) < 0.01 # unknown fields are captured (not discarded) assert hasattr(q, "unknown_2") assert hasattr(q, "unknown_3") assert hasattr(q, "unknown_5") assert hasattr(q, "unknown_6") assert hasattr(q, "unknown_7") assert hasattr(q, "unknown_8") assert hasattr(q, "rise_speed") assert len(q._raw) > 0 # fixed values from frozen fixture assert q.unknown_2 == -1 assert q.unknown_3 == 22694 # confirmed semantic fields assert isinstance(q.trading_status, int) 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 # --------------------------------------------------------------------------- def test_minute_time_parse(): from easy_tdx.commands.minute_time import GetMinuteTimeDataCmd from easy_tdx.models.enums import Market body = load_hex("minute_time") cmd = GetMinuteTimeDataCmd(Market.SH, "600000") bars = cmd.parse_response(body) assert len(bars) == 240 b0 = bars[0] assert isinstance(b0.price, float) assert isinstance(b0.vol, int) # Bug #5 fix: _unknown_1 is preserved, not discarded assert hasattr(b0, "_unknown_1") assert isinstance(b0._unknown_1, int) assert len(b0._raw) > 0 # fixed values assert abs(b0.price - 0.01) < 0.001 assert b0.vol == 48 assert b0._unknown_1 == 54 # --------------------------------------------------------------------------- # history_minute_time # --------------------------------------------------------------------------- def test_history_minute_time_parse(): from easy_tdx.commands.minute_time import GetHistoryMinuteTimeDataCmd from easy_tdx.models.enums import Market body = load_hex("history_minute_time") cmd = GetHistoryMinuteTimeDataCmd(Market.SH, "600000", 20250108) bars = cmd.parse_response(body) assert len(bars) == 240 b0 = bars[0] assert abs(b0.price - 10.29) < 0.01 assert b0.vol == 10044 assert hasattr(b0, "_unknown_1") assert len(b0._raw) > 0 # --------------------------------------------------------------------------- # transaction (current day) # --------------------------------------------------------------------------- def test_transaction_parse(): from easy_tdx.commands.transaction import GetTransactionDataCmd from easy_tdx.models.enums import Market body = load_hex("transaction") cmd = GetTransactionDataCmd(Market.SH, "600000", 0, 10) recs = cmd.parse_response(body) assert len(recs) == 10 r0 = recs[0] assert r0.hour == 14 assert r0.minute == 59 assert abs(r0.price - 9.9) < 0.01 assert r0.vol == 0 # Bug #4 fix: unknown_last captured assert hasattr(r0, "unknown_last") assert len(r0._raw) > 0 # buyorsell: 0=buy, 1=sell, 2=neutral, 8=auction — field is an int for r in recs: assert isinstance(r.buyorsell, int) # --------------------------------------------------------------------------- # history_transaction # --------------------------------------------------------------------------- def test_history_transaction_parse(): from easy_tdx.commands.transaction import GetHistoryTransactionDataCmd from easy_tdx.models.enums import Market body = load_hex("history_transaction") cmd = GetHistoryTransactionDataCmd(Market.SH, "600000", 20250108, 0, 10) recs = cmd.parse_response(body) assert len(recs) == 10 r0 = recs[0] assert r0.hour == 14 assert r0.minute == 56 assert abs(r0.price - 10.3) < 0.01 assert r0.vol == 50 assert hasattr(r0, "unknown_last") assert len(r0._raw) > 0 for r in recs: assert isinstance(r.buyorsell, int) # --------------------------------------------------------------------------- # xdxr_info # --------------------------------------------------------------------------- def test_xdxr_info_parse(): from easy_tdx.commands.xdxr_info import GetXdxrInfoCmd from easy_tdx.models.enums import Market body = load_hex("xdxr_info") cmd = GetXdxrInfoCmd(Market.SH, "600000") recs = cmd.parse_response(body) assert len(recs) == 87 r0 = recs[0] assert r0.year == 1999 assert r0.month == 11 assert r0.day == 10 assert r0.category == 5 # Bug #1 fix: each record has a unique date (not all reading from body[:7]) dates = {(r.year, r.month, r.day) for r in recs} assert len(dates) > 1, "All records have the same date — Bug #1 not fixed!" # category == 1 字段应已从“每10股”归一化为“每股” cash = next(r for r in recs if (r.year, r.month, r.day, r.category) == (2000, 7, 6, 1)) assert abs(cash.fenhong - 0.15) < 1e-6 bonus = next(r for r in recs if (r.year, r.month, r.day, r.category) == (2002, 8, 22, 1)) assert abs(bonus.fenhong - 0.2) < 1e-6 assert abs(bonus.songzhuangu - 0.5) < 1e-6 assert all(len(r._raw) > 0 for r in recs) # share count decode: 通达信自定义浮点,单位万股,与 FinanceInfo.zong_guben/10000 一致 stock_recs = [r for r in recs if 2 <= r.category <= 10] last = stock_recs[-1] # 最近一条 hou_zongguben ≈ 3_330_583.75 万股 # 与 FinanceInfo.zong_guben 33_305_837_500 ÷ 10000 完全吻合 assert last.hou_zongguben is not None assert abs(last.hou_zongguben - 3_330_583.75) < 1.0 def test_xdxr_info_category_1_normalizes_per_10_share_fields(): from easy_tdx.commands.xdxr_info import GetXdxrInfoCmd from easy_tdx.models.enums import Market body = bytearray(b"\x00" * 9) body.extend(struct.pack(" 0 assert info.zong_guben > 0 assert info.meigujing_zichan > 0 # Fixed values from frozen fixture assert abs(info.liutong_guben - 33305837500.0) < 1e6 assert abs(info.zong_guben - 33305837500.0) < 1e6 assert abs(info.meigujing_zichan - 22.13) < 0.1 assert len(info._raw) > 0 # --------------------------------------------------------------------------- # company_info_category # --------------------------------------------------------------------------- def test_company_info_category_parse(): from easy_tdx.commands.company_info import GetCompanyInfoCategoryCmd from easy_tdx.models.enums import Market body = load_hex("company_info_category") cmd = GetCompanyInfoCategoryCmd(Market.SH, "600000") cats = cmd.parse_response(body) assert len(cats) == 16 c0 = cats[0] assert c0.name == "最新提示" assert c0.filename == "600000.txt" assert c0.start == 0 assert c0.length == 11426 # --------------------------------------------------------------------------- # company_info_content # --------------------------------------------------------------------------- def test_company_info_content_parse(): from easy_tdx.commands.company_info import GetCompanyInfoContentCmd from easy_tdx.models.enums import Market body = load_hex("company_info_content") cmd = GetCompanyInfoContentCmd(Market.SH, "600000", "600000.txt", 0, 11426) text = cmd.parse_response(body) assert isinstance(text, str) assert len(text) == 8070 assert "600000" in text assert "浦发银行" in text