diff --git a/README.md b/README.md index b51a3e4..3807aee 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ client = AsyncTdxClient.from_best_host(ping_timeout=5.0) |------|------| | `get_security_count(market)` | 市场证券总数 | | `get_security_list(market, start)` | 证券列表(每页 ~1000 条) | -| `get_security_list_all()` | 全市场 A 股列表(自动挂载行业信息) | +| `get_security_list_all()` | 沪深 A 股列表(自动挂载行业信息;BJ 暂未纳入) | | `get_market_stat()` | 全市场 A 股涨跌统计(家数、成交额) | | `get_security_quotes([(market, code), ...])` | 批量实时五档行情(最多 80 只/次) | | `get_security_bars(market, code, category, start, count=800)` | K 线(股票) | @@ -106,7 +106,7 @@ client = AsyncTdxClient.from_best_host(ping_timeout=5.0) | `get_transaction_data(market, code, start, count=800)` | 当日逐笔成交(分页) | | `get_history_transaction_data(market, code, date, start, count=800)` | 历史逐笔成交 | | `get_fund_flow(market, code)` | 当日资金流向统计(超大/大/中/小单) | -| `get_history_fund_flow(market, code, start, count)` | 历史日线资金流向序列(Category 22) | +| `get_history_fund_flow(market, code, start, count)` | 历史日线资金流向序列(Category 22,实验性) | | `get_xdxr_info(market, code)` | 除权除息历史 | | `get_finance_info(market, code)` | 最新财务数据 | | `get_company_info_category(market, code)` | 公司信息文件目录 | @@ -148,6 +148,9 @@ unknown_2..unknown_3 unknown_5..unknown_8 _raw ``` +`limit_up` / `limit_down` 当前不再直接由协议字段映射,默认保留为 `None`; +建议通过 `xmtdx.codec.price_rules.compute_price_limits(...)` 按业务规则计算。 + ### MinuteBar(分时) ``` @@ -225,7 +228,7 @@ main_net_inflow | 4 | `transaction` | 最后一个字段被 `_` 丢弃 | 保留为 `unknown_last` | | 5 | `minute_time` | `reversed1` 字段被丢弃 | 保留为 `unknown_1` | | 6 | `xdxr_info` | 股本字段用 `float(uint32)` 直解,差约 374 倍 | 改用 `_decode_volume`(通达信自定义浮点),单位万股,与 `FinanceInfo` 完全吻合 | -| 7 | `security_quotes` | 涨停/跌停价映射错误或缺失 | 解析 `unknown_2/3` 为绝对价格字段 `limit_up/down` | +| 7 | `security_quotes` | 涨停/跌停价映射错误或缺失 | 停止使用不可信协议位,改由业务规则计算 | ## 架构 diff --git a/scripts/probe_unknowns.py b/scripts/probe_unknowns.py index 8ef9b6d..bee355b 100644 --- a/scripts/probe_unknowns.py +++ b/scripts/probe_unknowns.py @@ -1,188 +1,106 @@ -"""未知字段探测脚本:通过批量拉取多只股票数据,尝试推断各 unknown_N 字段的含义。 - -用法: - cd /home/m/xmtdx - python3 scripts/probe_unknowns.py - -输出: - 1. MinuteBar.unknown_1 vs 分钟均价(累计成交额 / 累计成交量) - 2. SecurityQuote.unknown_2/3/5/6/7/8 与已知行情指标的相关关系 -""" -from __future__ import annotations +"""探测未知字段含义的辅助脚本。""" import sys -import pathlib -sys.path.insert(0, str(pathlib.Path(__file__).parent.parent / "src")) - -from xmtdx import TdxClient, Market, KlineCategory - -HOST = "180.153.18.170" - -# 沪深各取若干活跃股票 -SH_CODES = ["600000", "600036", "601318", "600519", "601628"] -SZ_CODES = ["000001", "000002", "000858", "002415", "300750"] - -SEP = "-" * 72 +from xmtdx import Market, TdxClient -# --------------------------------------------------------------------------- -# Part 1: MinuteBar.unknown_1 — 是否为分钟均价? -# --------------------------------------------------------------------------- +def probe_minute_averages(client, market, code): + """探测分时数据中 unknown_1 的含义(疑似均价)。""" + print(f"\nProbing {code} Minute Time unknown_1:") + bars = client.get_minute_time_data(market, code) + if not bars: + return -def probe_minute_unknown_1(c: TdxClient) -> None: - print(SEP) - print("Part 1: MinuteBar.unknown_1 vs 分钟均价 (历史某日)") - print(SEP) - - # 使用历史分时,数据确定(不随时间变化) - DATE = 20250108 - code, market = "600000", Market.SH - - bars = c.get_history_minute_time_data(market, code, DATE) - print(f" {market.name} {code} 日期={DATE} 共 {len(bars)} 条分时\n") - - # 同时拉取当日日线 K 作为参考(含 amount/vol 可算均价) # 分时数据无直接成交额,需要用 price × vol 近似 # 若 unknown_1 == round(price × 100) 则为原始价格单位均价 - print(f" {'分钟':>6} {'price':>8} {'vol':>8} {'unknown_1':>12} {'price*100':>10} {'diff':>8}") - print(f" {'':-<6} {'':-<8} {'':-<8} {'':-<12} {'':-<10} {'':-<8}") + print(f" {'分钟':>6} {'price':>8} {'vol':>8} " + f"{'unknown_1':>12} {'price*100':>10} {'diff':>8}") + print(f" {'':-<6} {'':-<8} {'':-<8} " + f"{'':-<12} {'':-<10} {'':-<8}") - exact_match = 0 - close_match = 0 - - for i, b in enumerate(bars[:30]): # 只打印前30条 - price_x100 = round(b.price * 100) + all_exact = 0 + all_close = 0 + for i, b in enumerate(bars): + price_x100 = int(round(b.price * 100)) diff = b.unknown_1 - price_x100 - exact = b.unknown_1 == price_x100 - close = abs(diff) <= 2 - + exact = (diff == 0) + close = (abs(diff) <= 2) if exact: - exact_match += 1 + all_exact += 1 if close: - close_match += 1 + all_close += 1 flag = " <<< exact" if exact else (" ≈" if close else "") - print(f" {i+1:>6} {b.price:>8.2f} {b.vol:>8} {b.unknown_1:>12} {price_x100:>10} {diff:>+8}{flag}") + print(f" {i+1:>6} {b.price:>8.2f} {b.vol:>8} " + f"{b.unknown_1:>12} {price_x100:>10} {diff:>+8}{flag}") # Count across all bars - all_exact = sum(1 for b in bars if b.unknown_1 == round(b.price * 100)) - all_close = sum(1 for b in bars if abs(b.unknown_1 - round(b.price * 100)) <= 2) - print(f"\n 全部 {len(bars)} 条:") - print(f" unknown_1 == price*100 (精确): {all_exact}/{len(bars)} ({100*all_exact/len(bars):.1f}%)") - print(f" unknown_1 ≈ price*100 (±2): {all_close}/{len(bars)} ({100*all_close/len(bars):.1f}%)") + print(f" unknown_1 == price*100 (精确): " + f"{all_exact}/{len(bars)} ({100*all_exact/len(bars):.1f}%)") + print(f" unknown_1 ≈ price*100 (±2): " + f"{all_close}/{len(bars)} ({100*all_close/len(bars):.1f}%)") # Try another hypothesis: unknown_1 is a cumulative average price (均价) - # Compute running avg: sum(price*vol)/sum(vol) - print(f"\n 另一假设:unknown_1 = 当日累计均价×100") - cum_pv = 0.0 - cum_v = 0 + total_vol = 0 + total_amount = 0.0 correct_avg = 0 for b in bars: - cum_pv += b.price * b.vol - cum_v += b.vol - if cum_v > 0: - avg = cum_pv / cum_v - expected = round(avg * 100) - if abs(b.unknown_1 - expected) <= 2: + total_vol += b.vol + total_amount += b.price * b.vol + if total_vol > 0: + avg_x100 = int(round((total_amount / total_vol) * 100)) + if abs(b.unknown_1 - avg_x100) <= 2: correct_avg += 1 - print(f" unknown_1 ≈ 累计均价×100 (±2): {correct_avg}/{len(bars)} ({100*correct_avg/len(bars):.1f}%)") + print(f" unknown_1 ≈ 累计均价×100 (±2): " + f"{correct_avg}/{len(bars)} ({100*correct_avg/len(bars):.1f}%)") -# --------------------------------------------------------------------------- -# Part 2: SecurityQuote.unknown_N fields -# --------------------------------------------------------------------------- - -def probe_quote_unknowns(c: TdxClient) -> None: - print(f"\n{SEP}") - print("Part 2: SecurityQuote.unknown_2/3/5/6/7/8 — 与已知字段的关系") - print(SEP) - - pairs = [(Market.SH, code) for code in SH_CODES] + [(Market.SZ, code) for code in SZ_CODES] - quotes = c.get_security_quotes(pairs) - - print(f" {'market':>6} {'code':>8} {'pre_close':>10} {'price':>8} " - f"{'u2':>6} {'u3':>8} {'u5':>6} {'u6':>6} {'u7':>6} {'u8':>6} {'rise_spd':>10}") - print(f" {'':-<6} {'':-<8} {'':-<10} {'':-<8} " - f"{'':-<6} {'':-<8} {'':-<6} {'':-<6} {'':-<6} {'':-<6} {'':-<10}") - +def probe_quote_limits(client, market, code): + """探测实时行情中 unknown_5/6 的含义(疑似涨跌停)。""" + print(f"\nProbing {code} Quote unknown_5/6:") + quotes = client.get_security_quotes([(market, code)]) + if not quotes: + return for q in quotes: - pct = (q.price - q.pre_close) / q.pre_close * 100 if q.pre_close else 0 print( f" {q.market.name:>6} {q.code:>8} {q.pre_close:>10.2f} {q.price:>8.2f} " - f"{q.unknown_2:>6} {q.unknown_3:>8} {q.unknown_5:>6} " - f"{q.unknown_6:>6} {q.unknown_7:>6} {q.unknown_8:>6} {q.rise_speed:>10.4f}" + f"u5:{q.unknown_5:>6} u6:{q.unknown_6:>6}" ) - print(f"\n 注:rise_speed = reversed_bytes9/100(已确认 = 涨速)") - - # Hypothesis: unknown_3 might relate to 涨停/跌停 price - # 涨停 = pre_close * 1.10 (rounded to 2 decimal) - print(f"\n 假设 unknown_3 = 涨停价×100:") - print(f" {'code':>8} {'涨停价×100 预期':>16} {'unknown_3':>10} {'diff':>6}") - for q in quotes: - if q.pre_close > 0: - limit_up = round(q.pre_close * 1.10 * 100) - diff = q.unknown_3 - limit_up - print(f" {q.code:>8} {limit_up:>16} {q.unknown_3:>10} {diff:>+6}") - - print(f"\n 假设 unknown_3 = 跌停价×100:") - print(f" {'code':>8} {'跌停价×100 预期':>16} {'unknown_3':>10} {'diff':>6}") - for q in quotes: - if q.pre_close > 0: - limit_dn = round(q.pre_close * 0.90 * 100) - diff = q.unknown_3 - limit_dn - print(f" {q.code:>8} {limit_dn:>16} {q.unknown_3:>10} {diff:>+6}") - - # unknown_2: often -1 or small value — check if it's 换手率×10000 or similar - print(f"\n unknown_2 raw values: {[q.unknown_2 for q in quotes]}") - print(f" unknown_5 raw values: {[q.unknown_5 for q in quotes]}") - print(f" unknown_6 raw values: {[q.unknown_6 for q in quotes]}") - print(f" unknown_7 raw values: {[q.unknown_7 for q in quotes]}") - print(f" unknown_8 raw values: {[q.unknown_8 for q in quotes]}") - - # Print raw bytes for manual inspection - print(f"\n 原始字节(前20字节 hex):") - for q in quotes: - print(f" {q.code}: {q._raw[:20].hex()}") - - -# --------------------------------------------------------------------------- -# Part 3: TransactionRecord.unknown_last — 是否为秒数? -# --------------------------------------------------------------------------- - -def probe_transaction_unknown_last(c: TdxClient) -> None: - print(f"\n{SEP}") - print("Part 3: TransactionRecord.unknown_last — 是否为秒或序号?") - print(SEP) - - recs = c.get_history_transaction_data(Market.SH, "600000", 20250108, 0, 30) - print(f" {'序号':>4} {'时间':>6} {'price':>8} {'vol':>6} {'buy':>4} {'unknown_last':>14}") - print(f" {'':-<4} {'':-<6} {'':-<8} {'':-<6} {'':-<4} {'':-<14}") +def probe_fund_flow_raw(client, market, code): + """探测资金流原始数据分布。""" + print(f"\nProbing {code} Transaction raw unknown_last:") + # 直接用 get_transaction_data 获取原始记录 + recs = client.get_transaction_data(market, code, 0, 50) + print(f" {'idx':>4} {'time':>5} {'price':>8} {'vol':>6} {'b/s':>4} {'unknown_last':>14}") for i, r in enumerate(recs): - print(f" {i+1:>4} {r.hour:02d}:{r.minute:02d} {r.price:>8.2f} {r.vol:>6} {r.buyorsell:>4} {r.unknown_last:>14}") + print(f" {i+1:>4} {r.hour:02d}:{r.minute:02d} {r.price:>8.2f} " + f"{r.vol:>6} {r.buyorsell:>4} {r.unknown_last:>14}") unique = len({r.unknown_last for r in recs}) - print(f"\n unknown_last 唯一值数量: {unique}/{len(recs)}") - print(f" 值分布: {sorted({r.unknown_last for r in recs})}") + print(f"\n Unique unknown_last in 50 recs: {unique}") -# --------------------------------------------------------------------------- -# main -# --------------------------------------------------------------------------- +def main(): + host = "180.153.18.170" + if len(sys.argv) > 1: + host = sys.argv[1] -def main() -> None: - print(f"连接 {HOST}:7709 ...") - with TdxClient(HOST) as c: - probe_minute_unknown_1(c) - probe_quote_unknowns(c) - probe_transaction_unknown_last(c) + with TdxClient(host) as client: + # 1. 均价探测 + probe_minute_averages(client, Market.SH, "600000") + probe_minute_averages(client, Market.SZ, "000001") - print(f"\n{SEP}") - print("探测完成。根据以上输出可判断各字段含义,更新 models/ 文档注释。") + # 2. 涨跌停探测 + probe_quote_limits(client, Market.SH, "600000") + probe_quote_limits(client, Market.SZ, "000001") + + # 3. 资金流探测 + probe_fund_flow_raw(client, Market.SH, "600000") if __name__ == "__main__": diff --git a/scripts/verify_fixes_20260415.py b/scripts/verify_fixes_20260415.py new file mode 100644 index 0000000..29a42c5 --- /dev/null +++ b/scripts/verify_fixes_20260415.py @@ -0,0 +1,127 @@ +"""实测验证脚本 (2026-04-15 修复验证)。""" + +import sys + +from xmtdx import Market, TdxClient +from xmtdx.codec.price_rules import compute_price_limits +from xmtdx.models.enums import KlineCategory + + +def main(): + hosts = ["115.238.56.198", "180.153.18.170", "124.71.187.122"] + host = hosts[0] + if len(sys.argv) > 1: + host = sys.argv[1] + + print(f"Connecting to {host}...") + success = True + + with TdxClient(host) as client: + # 1. 验证 K 线请求已恢复 + print("\n[1] Security/Index Bars:") + try: + bars = client.get_security_bars(Market.SH, "600000", KlineCategory.DAY, 0, 3) + ibars = client.get_index_bars(Market.SH, "999999", KlineCategory.DAY, 0, 3) + print(f" 600000 bars: {len(bars)}") + print(f" 999999 index bars: {len(ibars)}") + if not bars or not ibars: + print(" Result: FAIL (Bars request returned empty)") + success = False + else: + print(" Result: SUCCESS") + except Exception as e: + print(f" Error: {e}") + success = False + + # 2. 验证 get_market_stat (880005) + print("\n[2] Market Stat (880005):") + try: + stat = client.get_market_stat() + print( + f" Up: {stat.up_count}, Down: {stat.down_count}, " + f"Neutral: {stat.neutral_count}, Suspended: {stat.suspended_count}, " + f"Total: {stat.total_count}" + ) + stat_sum = ( + stat.up_count + + stat.down_count + + stat.neutral_count + + stat.suspended_count + ) + print(f" Sum (U+D+N+S): {stat_sum}") + if stat_sum == stat.total_count: + print(" Result: SUCCESS (residual-balanced total)") + else: + print(" Result: FAIL (Sum != Total)") + success = False + except Exception as e: + print(f" Error: {e}") + success = False + + # 3. 验证价格规则引擎 + print("\n[3] Price Limits (Rule Engine):") + samples = [ + ("600000", Market.SH, "浦发银行"), + ("300750", Market.SZ, "宁德时代"), + ("688981", Market.SH, "中芯国际"), + ("999999", Market.SH, "上证指数"), + ] + try: + quotes = client.get_security_quotes([(market, code) for code, market, _name in samples]) + for q, (_code, _market, name) in zip(quotes, samples, strict=True): + lu, ld = compute_price_limits(q.market, q.code, name, q.pre_close) + print( + f" {q.code}: Price={q.price:.2f}, PreClose={q.pre_close:.2f}, " + f"LimitUp={lu}, LimitDown={ld}" + ) + if q.code == "999999": + if lu is not None or ld is not None: + print(" Result: FAIL (Index should not have price limits)") + success = False + elif lu is None or ld is None: + print(f" Result: FAIL (Limit calculation returned None for {q.code})") + success = False + except Exception as e: + print(f" Error: {e}") + success = False + + # 4. 验证 get_history_fund_flow (Category 22) + print("\n[4] History Fund Flow (Category 22, experimental):") + try: + h_flow = client.get_history_fund_flow(Market.SH, "600000", 0, 1) + if h_flow: + f = h_flow[0] + print(f" Date: {f.year}-{f.month}-{f.day}, SuperIn: {f.super_in:.2f}") + print(" Result: SUCCESS") + else: + print(" Result: INFO (No data returned; interface remains experimental)") + except Exception as e: + print(f" Error: {e} (Experimental interface; not counted as hard failure)") + + # 5. 验证 get_fund_flow 分页 + print("\n[5] Fund Flow Pagination (600000):") + try: + flow = client.get_fund_flow(Market.SH, "600000") + total_in = flow.super_in + flow.large_in + flow.medium_in + flow.small_in + total_out = flow.super_out + flow.large_out + flow.medium_out + flow.small_out + print(f" 600000 Classified Total: {total_in + total_out:.2f}") + # 获取实时成交额对比 + q = client.get_security_quotes([(Market.SH, "600000")])[0] + print(f" 600000 Real Amount: {q.amount:.2f}") + coverage = (total_in + total_out) / q.amount if q.amount > 0 else 0 + print(f" Coverage: {coverage * 100:.1f}%") + if coverage < 0.90: + print(" Result: FAIL (Coverage too low)") + success = False + else: + print(" Result: SUCCESS") + except Exception as e: + print(f" Error: {e}") + success = False + + if not success: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/xmtdx/client.py b/src/xmtdx/client.py index 6c2fa4f..a7d2a5b 100644 --- a/src/xmtdx/client.py +++ b/src/xmtdx/client.py @@ -4,21 +4,21 @@ import asyncio from types import TracebackType from typing import TypeVar +from .codec.block import parse_block_dat +from .codec.industry import parse_tdxhy_cfg from .commands.base import BaseCommand from .commands.block_info import GetBlockInfoCmd, GetBlockInfoMetaCmd from .commands.company_info import GetCompanyInfoCategoryCmd, GetCompanyInfoContentCmd from .commands.finance_info import GetFinanceInfoCmd from .commands.fund_flow import GetHistoryFundFlowCmd -from .commands.report_file import GetReportFileCmd from .commands.minute_time import GetHistoryMinuteTimeDataCmd, GetMinuteTimeDataCmd +from .commands.report_file import GetReportFileCmd from .commands.security_bars import GetIndexBarsCmd, GetSecurityBarsCmd from .commands.security_count import GetSecurityCountCmd from .commands.security_list import GetSecurityListCmd from .commands.security_quotes import GetSecurityQuotesCmd from .commands.transaction import GetHistoryTransactionDataCmd, GetTransactionDataCmd from .commands.xdxr_info import GetXdxrInfoCmd -from .codec.block import parse_block_dat -from .codec.industry import parse_tdxhy_cfg from .exceptions import TdxConnectionError from .models.bar import SecurityBar from .models.enums import KlineCategory, Market @@ -148,7 +148,12 @@ class TdxClient: return self._execute(GetSecurityListCmd(market, start)) def get_security_list_all(self) -> list[SecurityInfo]: - """获取全市场(沪深 A 股)完整证券列表,并自动挂载行业信息。""" + """获取沪深 A 股完整证券列表,并自动挂载行业信息。 + + 注意: + `Market.BJ` 的证券列表请求长期存在服务器超时问题,当前版本暂不纳入此方法。 + 若需 BJ 名单,应改由 `base_info.zip` 等文件离线解析获得。 + """ # 1. 尝试获取行业配置 industry_map = {} try: @@ -159,7 +164,9 @@ class TdxClient: pass all_stocks: list[SecurityInfo] = [] - for market in [Market.SH, Market.SZ, Market.BJ]: + # 注意:Market.BJ 证券列表请求常年超时,短期降级为仅 SH/SZ; + # BJ 列表需解析 base_info.zip 获得(待实现)。 + for market in [Market.SH, Market.SZ]: count = self.get_security_count(market) for start in range(0, count, 1000): stocks = self.get_security_list(market, start) @@ -174,10 +181,6 @@ class TdxClient: # 深市 A 股:00xxxx, 30xxxx if s.code.startswith(("00", "30")): is_a_share = True - elif market == Market.BJ: - # 京市 A 股:8xxxxx, 43xxxx, 92xxxx - if s.code.startswith(("8", "43", "92")): - is_a_share = True if is_a_share: # 挂载行业信息 @@ -312,31 +315,72 @@ class TdxClient: return bytes(full_data) def get_market_stat(self) -> MarketStat: - """获取 A 股全市场涨跌统计概况。""" - # 通达信中 880005 是行情统计代码 + """获取 A 股全市场涨跌统计概况(基于 880005 行情统计)。 + + 注意: + `suspended_count` 是 `total - up - down - neutral` 的残差估算值, + 用于保证计数守恒,不应视为协议已明确验证的停牌字段。 + """ + # 通达信中 880005 是全市场行情统计代码 quotes = self.get_security_quotes([(Market.SH, "880005")]) if not quotes: raise RuntimeError("无法获取市场统计数据") q = quotes[0] + up = int(q.price) + down = int(q.pre_close) + neutral = int(q.low) + total = int(q.high) return MarketStat( - up_count=int(q.price), - down_count=int(q.pre_close), - neutral_count=int(q.open), - total_count=int(q.high), + up_count=up, + down_count=down, + neutral_count=neutral, + suspended_count=max(0, total - up - down - neutral), + total_count=total, total_amount=q.amount, total_volume=q.vol, ) def get_fund_flow(self, market: Market, code: str) -> FundFlow: """获取个股当日资金流向分布(基于 L1 逐笔数据统计)。""" - # 1. 拉取当日全量分笔 (TDX L1 最多支持约 2000-4000 条,通常足够 A 股当日统计) + # 1. 分页拉取当日分笔并去重 all_recs: list[TransactionRecord] = [] - for start in [0, 2000, 4000]: + seen_sig = set() + seen_page_sigs = set() + start = 0 + + while start < 10000: recs = self.get_transaction_data(market, code, start, 2000) if not recs: break - all_recs.extend(recs) - if len(recs) < 2000: + + # 页签名判断:首尾记录组合 + page_sig = ( + ( + recs[0].hour, recs[0].minute, recs[0].price, + recs[0].vol, recs[0].buyorsell, recs[0].unknown_last + ), + ( + recs[-1].hour, recs[-1].minute, recs[-1].price, + recs[-1].vol, recs[-1].buyorsell, recs[-1].unknown_last + ), + ) + if page_sig in seen_page_sigs: + break + seen_page_sigs.add(page_sig) + + new_count = 0 + for r in recs: + sig = (r.hour, r.minute, r.price, r.vol, r.buyorsell, r.unknown_last) + if sig not in seen_sig: + seen_sig.add(sig) + all_recs.append(r) + new_count += 1 + + if new_count == 0: + break + + start += len(recs) + if len(recs) < 100: break # 2. 统计逻辑 @@ -366,7 +410,10 @@ class TdxClient: def get_history_fund_flow( self, market: Market, code: str, start: int, count: int ) -> list[HistoricalFundFlow]: - """获取个股历史日线资金流向序列(Category 22)。""" + """获取个股历史日线资金流向序列(Category 22)。 + + [EXPERIMENTAL] 当前多台公开主机对该请求仍可能返回空列表。 + """ return self._execute(GetHistoryFundFlowCmd(market, code, start, count)) @@ -500,7 +547,12 @@ class AsyncTdxClient: return await self._execute(GetSecurityListCmd(market, start)) async def get_security_list_all(self) -> list[SecurityInfo]: - """获取全市场完整证券列表,并自动挂载行业信息。""" + """获取沪深 A 股完整证券列表,并自动挂载行业信息。 + + 注意: + `Market.BJ` 的证券列表请求长期存在服务器超时问题,当前版本暂不纳入此方法。 + 若需 BJ 名单,应改由 `base_info.zip` 等文件离线解析获得。 + """ industry_map = {} try: cfg_data = await self.get_report_file("tdxhy.cfg") @@ -510,7 +562,9 @@ class AsyncTdxClient: pass all_stocks: list[SecurityInfo] = [] - for market in [Market.SH, Market.SZ, Market.BJ]: + # 注意:Market.BJ 证券列表请求常年超时,短期降级为仅 SH/SZ; + # BJ 列表需解析 base_info.zip 获得(待实现)。 + for market in [Market.SH, Market.SZ]: count = await self.get_security_count(market) for start in range(0, count, 1000): stocks = await self.get_security_list(market, start) @@ -522,9 +576,6 @@ class AsyncTdxClient: elif market == Market.SZ: if s.code.startswith(("00", "30")): is_a_share = True - elif market == Market.BJ: - if s.code.startswith(("8", "43", "92")): - is_a_share = True if is_a_share: if s.code in industry_map: @@ -627,29 +678,72 @@ class AsyncTdxClient: return bytes(full_data) async def get_market_stat(self) -> MarketStat: - """获取 A 股全市场涨跌统计概况。""" + """获取 A 股全市场涨跌统计概况(基于 880005 行情统计)。 + + 注意: + `suspended_count` 是 `total - up - down - neutral` 的残差估算值, + 用于保证计数守恒,不应视为协议已明确验证的停牌字段。 + """ + # 通达信中 880005 是全市场行情统计代码 quotes = await self.get_security_quotes([(Market.SH, "880005")]) if not quotes: raise RuntimeError("无法获取市场统计数据") q = quotes[0] + up = int(q.price) + down = int(q.pre_close) + neutral = int(q.low) + total = int(q.high) return MarketStat( - up_count=int(q.price), - down_count=int(q.pre_close), - neutral_count=int(q.open), - total_count=int(q.high), + up_count=up, + down_count=down, + neutral_count=neutral, + suspended_count=max(0, total - up - down - neutral), + total_count=total, total_amount=q.amount, total_volume=q.vol, ) async def get_fund_flow(self, market: Market, code: str) -> FundFlow: - """获取个股当日资金流向分布。""" + """获取个股当日资金流向分布(基于 L1 逐笔数据统计)。""" + # 1. 分页拉取当日分笔并去重 all_recs: list[TransactionRecord] = [] - for start in [0, 2000, 4000]: + seen_sig = set() + seen_page_sigs = set() + start = 0 + + while start < 10000: recs = await self.get_transaction_data(market, code, start, 2000) if not recs: break - all_recs.extend(recs) - if len(recs) < 2000: + + # 页签名判断:首尾记录组合 + page_sig = ( + ( + recs[0].hour, recs[0].minute, recs[0].price, + recs[0].vol, recs[0].buyorsell, recs[0].unknown_last + ), + ( + recs[-1].hour, recs[-1].minute, recs[-1].price, + recs[-1].vol, recs[-1].buyorsell, recs[-1].unknown_last + ), + ) + if page_sig in seen_page_sigs: + break + seen_page_sigs.add(page_sig) + + new_count = 0 + for r in recs: + sig = (r.hour, r.minute, r.price, r.vol, r.buyorsell, r.unknown_last) + if sig not in seen_sig: + seen_sig.add(sig) + all_recs.append(r) + new_count += 1 + + if new_count == 0: + break + + start += len(recs) + if len(recs) < 100: break stats = { @@ -674,6 +768,8 @@ class AsyncTdxClient: async def get_history_fund_flow( self, market: Market, code: str, start: int, count: int ) -> list[HistoricalFundFlow]: - """获取个股历史日线资金流向序列。""" - return await self._execute(GetHistoryFundFlowCmd(market, code, start, count)) + """获取个股历史日线资金流向序列(Category 22)。 + [EXPERIMENTAL] 当前多台公开主机对该请求仍可能返回空列表。 + """ + return await self._execute(GetHistoryFundFlowCmd(market, code, start, count)) diff --git a/src/xmtdx/codec/price_rules.py b/src/xmtdx/codec/price_rules.py new file mode 100644 index 0000000..bebc19b --- /dev/null +++ b/src/xmtdx/codec/price_rules.py @@ -0,0 +1,62 @@ +"""A 股价格限制规则引擎。""" + +from ..models.enums import Market +from ..models.finance import FinanceInfo + + +def compute_price_limits( + market: Market, + code: str, + name: str, + pre_close: float, + finance_info: FinanceInfo | None = None, +) -> tuple[float | None, float | None]: + """根据板块规则计算涨跌停价。 + + Returns: + (limit_up, limit_down) + + 无涨跌幅限制或当前规则无法可靠判断时返回 ``(None, None)``。 + """ + if pre_close <= 0: + return None, None + + upper_name = name.upper() + + # 指数/板块类代码通常无涨跌停。 + # 这里优先用明确的指数代码段判断,再用名称兜底,避免把真实股票误判成指数。 + is_index = False + if market == Market.SH and code.startswith( + ("000", "880", "881", "882", "883", "884", "885", "999") + ): + is_index = True + elif market == Market.SZ and code.startswith(("395", "399")): + is_index = True + elif "指数" in name or "板块" in name: + is_index = True + + if is_index: + return None, None + + limit_pct = 0.10 # 默认 10% + + # 2. ST / *ST 判断 + if "ST" in upper_name: + limit_pct = 0.05 + # 3. 科创板 (688) / 创业板 (300, 301) + elif code.startswith("688") or code.startswith("300") or code.startswith("301"): + limit_pct = 0.20 + # 4. 北交所 (43, 83, 87, 92) + elif code.startswith(("43", "83", "87", "92")): + limit_pct = 0.30 + + # TODO: 上市前 5 日无涨跌幅限制判断(需要 ipo_date 或更明确的上市状态标识)。 + _ = finance_info + + def _round_price(p: float) -> float: + return round(p + 0.00001, 2) + + limit_up = _round_price(pre_close * (1 + limit_pct)) + limit_down = _round_price(pre_close * (1 - limit_pct)) + + return limit_up, limit_down diff --git a/src/xmtdx/commands/block_info.py b/src/xmtdx/commands/block_info.py index ed02e90..9a87d37 100644 --- a/src/xmtdx/commands/block_info.py +++ b/src/xmtdx/commands/block_info.py @@ -5,7 +5,6 @@ import struct -from .._binary import slice_bytes, unpack_from from ..exceptions import TdxDecodeError from .base import BaseCommand diff --git a/src/xmtdx/commands/fund_flow.py b/src/xmtdx/commands/fund_flow.py index b69321e..b0437db 100644 --- a/src/xmtdx/commands/fund_flow.py +++ b/src/xmtdx/commands/fund_flow.py @@ -2,9 +2,7 @@ import struct -from .._binary import slice_bytes, unpack_from from ..codec.volume import _decode_volume -from ..exceptions import TdxDecodeError from ..models.enums import Market from ..models.stats import HistoricalFundFlow from .base import BaseCommand @@ -20,29 +18,24 @@ class GetHistoryFundFlowCmd(BaseCommand[list[HistoricalFundFlow]]): self.count = count def build_request(self) -> bytes: - # 使用 0x052d 指令(K 线类指令) - # 负载长度固定为 28 字节 (0x1c) - payload_len = 0x1c - header = struct.pack( - " list[HistoricalFundFlow]: # 响应格式:9字节头 + 2字节数量 + 每条记录 36 字节 diff --git a/src/xmtdx/commands/report_file.py b/src/xmtdx/commands/report_file.py index 43c4a88..242aa70 100644 --- a/src/xmtdx/commands/report_file.py +++ b/src/xmtdx/commands/report_file.py @@ -2,8 +2,6 @@ import struct -from .._binary import slice_bytes, unpack_from -from ..exceptions import TdxDecodeError from .base import BaseCommand diff --git a/src/xmtdx/commands/security_bars.py b/src/xmtdx/commands/security_bars.py index cd46df7..74d62e5 100644 --- a/src/xmtdx/commands/security_bars.py +++ b/src/xmtdx/commands/security_bars.py @@ -37,20 +37,23 @@ class GetSecurityBarsCmd(BaseCommand[list[SecurityBar]]): self.count = count def build_request(self) -> bytes: + # Header (12 bytes) + Payload (28 bytes) = 40 bytes return struct.pack( " list[SecurityBar]: diff --git a/src/xmtdx/commands/security_list.py b/src/xmtdx/commands/security_list.py index 872688f..edbff31 100644 --- a/src/xmtdx/commands/security_list.py +++ b/src/xmtdx/commands/security_list.py @@ -22,8 +22,10 @@ class GetSecurityListCmd(BaseCommand[list[SecurityInfo]]): self.start = start def build_request(self) -> bytes: - header = bytes.fromhex("0c01186401010600060050 04".replace(" ", "")) - return header + struct.pack(" list[SecurityInfo]: (num,) = unpack_from(" neutral_count=500) + vol=1000000.0, cur_vol=0, amount=50000000.0, s_vol=0, b_vol=0, active1=0, active2=0, - bid1=0, bid_vol1=0, bid2=0, bid_vol2=0, bid3=0, bid_vol3=0, bid4=0, bid_vol4=0, bid5=0, bid_vol5=0, - ask1=0, ask_vol1=0, ask2=0, ask_vol2=0, ask3=0, ask_vol3=0, ask4=0, ask_vol4=0, ask5=0, ask_vol5=0, + bid1=0, bid_vol1=0, bid2=0, bid_vol2=0, bid3=0, bid_vol3=0, + bid4=0, bid_vol4=0, bid5=0, bid_vol5=0, + ask1=0, ask_vol1=0, ask2=0, ask_vol2=0, ask3=0, ask_vol3=0, + ask4=0, ask_vol4=0, ask5=0, ask_vol5=0, rise_speed=0, limit_up=0, limit_down=0 ) with patch.object(TdxClient, "get_security_quotes", return_value=[mock_quote]): stat = client.get_market_stat() assert stat.up_count == 3000 + assert stat.down_count == 2000 + assert stat.neutral_count == 500 assert stat.total_count == 5500 def test_get_history_fund_flow_parsing(): diff --git a/tests/unit/test_block_info.py b/tests/unit/test_block_info.py index 8e89a6c..adca2e6 100644 --- a/tests/unit/test_block_info.py +++ b/tests/unit/test_block_info.py @@ -1,10 +1,12 @@ """板块信息单元测试。""" -import pytest +import asyncio import struct -from unittest.mock import MagicMock, patch +from unittest.mock import patch from xmtdx.client import AsyncTdxClient, TdxClient +from xmtdx.codec.block import parse_block_dat +from xmtdx.models.finance import TdxBlock @patch("xmtdx.client.AsyncTdxConnection") @@ -14,7 +16,7 @@ def test_async_get_block_info_logic(mock_conn_cls): # 模拟异步 execute async def mock_execute(cmd): - from xmtdx.commands.block_info import GetBlockInfoMetaCmd, GetBlockInfoCmd + from xmtdx.commands.block_info import GetBlockInfoCmd, GetBlockInfoMetaCmd if isinstance(cmd, GetBlockInfoMetaCmd): return 100, "hash" if isinstance(cmd, GetBlockInfoCmd): @@ -34,10 +36,7 @@ def test_async_get_block_info_logic(mock_conn_cls): assert isinstance(res, list) assert mock_conn.execute.call_count == 2 # 1 meta + 1 data - import asyncio asyncio.run(main()) -from xmtdx.codec.block import parse_block_dat -from xmtdx.models.finance import TdxBlock def test_parse_block_dat_empty(): @@ -81,7 +80,7 @@ def test_get_block_info_logic(mock_conn_cls): # 模拟 GetBlockInfoMeta 响应:size=35000 (需要2次拉取) def mock_execute(cmd): - from xmtdx.commands.block_info import GetBlockInfoMetaCmd, GetBlockInfoCmd + from xmtdx.commands.block_info import GetBlockInfoCmd, GetBlockInfoMetaCmd if isinstance(cmd, GetBlockInfoMetaCmd): return 35000, "dummy_hash" if isinstance(cmd, GetBlockInfoCmd): diff --git a/tests/unit/test_commands_offline.py b/tests/unit/test_commands_offline.py index cea9a01..cb0d3a7 100644 --- a/tests/unit/test_commands_offline.py +++ b/tests/unit/test_commands_offline.py @@ -266,7 +266,8 @@ def test_xdxr_info_parse(): # 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 完全吻合) + # 最近一条 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 diff --git a/tests/unit/test_heartbeat.py b/tests/unit/test_heartbeat.py index 9721b5d..20b88e8 100644 --- a/tests/unit/test_heartbeat.py +++ b/tests/unit/test_heartbeat.py @@ -1,9 +1,9 @@ """心跳机制单元测试。""" import asyncio -import pytest -from unittest.mock import patch, MagicMock, AsyncMock -from xmtdx import AsyncTdxClient, Market +from unittest.mock import AsyncMock, patch + +from xmtdx import AsyncTdxClient def test_heartbeat_sends_periodically(): diff --git a/tests/unit/test_protocol_fixes.py b/tests/unit/test_protocol_fixes.py new file mode 100644 index 0000000..54dbe28 --- /dev/null +++ b/tests/unit/test_protocol_fixes.py @@ -0,0 +1,109 @@ +"""协议底层修复验证(针对 2026-04-15 审查结论)。""" + +import struct + +from xmtdx.codec.price_rules import compute_price_limits +from xmtdx.commands.fund_flow import GetHistoryFundFlowCmd +from xmtdx.commands.security_bars import GetSecurityBarsCmd +from xmtdx.commands.security_list import GetSecurityListCmd +from xmtdx.commands.security_quotes import GetSecurityQuotesCmd +from xmtdx.models.enums import KlineCategory, Market + + +def test_security_bars_exact_layout(): + """验证 K 线请求包布局与旧版 working bytes 完全一致。""" + cmd = GetSecurityBarsCmd(Market.SH, "600000", KlineCategory.DAY, 0, 10) + req = cmd.build_request() + # Header: 0x010C, 0x01016408, 0x1C, 0x1C + # Payload: 0x052D, 1 (Market.SH), "600000", 4 (KlineCategory.DAY), 1, 0 (start), 10, 0, 0, 0 + expected = struct.pack( + "