Fix protocol regressions and clarify experimental APIs

This commit is contained in:
M
2026-04-15 21:02:26 +08:00
parent daaba7dc13
commit 96f14f70bc
18 changed files with 574 additions and 258 deletions
+6 -3
View File
@@ -96,7 +96,7 @@ client = AsyncTdxClient.from_best_host(ping_timeout=5.0)
|------|------| |------|------|
| `get_security_count(market)` | 市场证券总数 | | `get_security_count(market)` | 市场证券总数 |
| `get_security_list(market, start)` | 证券列表(每页 ~1000 条) | | `get_security_list(market, start)` | 证券列表(每页 ~1000 条) |
| `get_security_list_all()` | 全市场 A 股列表(自动挂载行业信息) | | `get_security_list_all()` | 沪深 A 股列表(自动挂载行业信息BJ 暂未纳入 |
| `get_market_stat()` | 全市场 A 股涨跌统计(家数、成交额) | | `get_market_stat()` | 全市场 A 股涨跌统计(家数、成交额) |
| `get_security_quotes([(market, code), ...])` | 批量实时五档行情(最多 80 只/次) | | `get_security_quotes([(market, code), ...])` | 批量实时五档行情(最多 80 只/次) |
| `get_security_bars(market, code, category, start, count=800)` | K 线(股票) | | `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_transaction_data(market, code, start, count=800)` | 当日逐笔成交(分页) |
| `get_history_transaction_data(market, code, date, start, count=800)` | 历史逐笔成交 | | `get_history_transaction_data(market, code, date, start, count=800)` | 历史逐笔成交 |
| `get_fund_flow(market, code)` | 当日资金流向统计(超大/大/中/小单) | | `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_xdxr_info(market, code)` | 除权除息历史 |
| `get_finance_info(market, code)` | 最新财务数据 | | `get_finance_info(market, code)` | 最新财务数据 |
| `get_company_info_category(market, code)` | 公司信息文件目录 | | `get_company_info_category(market, code)` | 公司信息文件目录 |
@@ -148,6 +148,9 @@ unknown_2..unknown_3 unknown_5..unknown_8
_raw _raw
``` ```
`limit_up` / `limit_down` 当前不再直接由协议字段映射,默认保留为 `None`
建议通过 `xmtdx.codec.price_rules.compute_price_limits(...)` 按业务规则计算。
### MinuteBar(分时) ### MinuteBar(分时)
``` ```
@@ -225,7 +228,7 @@ main_net_inflow
| 4 | `transaction` | 最后一个字段被 `_` 丢弃 | 保留为 `unknown_last` | | 4 | `transaction` | 最后一个字段被 `_` 丢弃 | 保留为 `unknown_last` |
| 5 | `minute_time` | `reversed1` 字段被丢弃 | 保留为 `unknown_1` | | 5 | `minute_time` | `reversed1` 字段被丢弃 | 保留为 `unknown_1` |
| 6 | `xdxr_info` | 股本字段用 `float(uint32)` 直解,差约 374 倍 | 改用 `_decode_volume`(通达信自定义浮点),单位万股,与 `FinanceInfo` 完全吻合 | | 6 | `xdxr_info` | 股本字段用 `float(uint32)` 直解,差约 374 倍 | 改用 `_decode_volume`(通达信自定义浮点),单位万股,与 `FinanceInfo` 完全吻合 |
| 7 | `security_quotes` | 涨停/跌停价映射错误或缺失 | 解析 `unknown_2/3` 为绝对价格字段 `limit_up/down` | | 7 | `security_quotes` | 涨停/跌停价映射错误或缺失 | 停止使用不可信协议位,改由业务规则计算 |
## 架构 ## 架构
+65 -147
View File
@@ -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 sys
import pathlib
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent / "src")) from xmtdx import Market, TdxClient
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
# --------------------------------------------------------------------------- def probe_minute_averages(client, market, code):
# Part 1: MinuteBar.unknown_1 — 是否为分钟均价? """探测分时数据中 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 近似 # 分时数据无直接成交额,需要用 price × vol 近似
# 若 unknown_1 == round(price × 100) 则为原始价格单位均价 # 若 unknown_1 == round(price × 100) 则为原始价格单位均价
print(f" {'分钟':>6} {'price':>8} {'vol':>8} {'unknown_1':>12} {'price*100':>10} {'diff':>8}") print(f" {'分钟':>6} {'price':>8} {'vol':>8} "
print(f" {'':-<6} {'':-<8} {'':-<8} {'':-<12} {'':-<10} {'':-<8}") f"{'unknown_1':>12} {'price*100':>10} {'diff':>8}")
print(f" {'':-<6} {'':-<8} {'':-<8} "
f"{'':-<12} {'':-<10} {'':-<8}")
exact_match = 0 all_exact = 0
close_match = 0 all_close = 0
for i, b in enumerate(bars):
for i, b in enumerate(bars[:30]): # 只打印前30条 price_x100 = int(round(b.price * 100))
price_x100 = round(b.price * 100)
diff = b.unknown_1 - price_x100 diff = b.unknown_1 - price_x100
exact = b.unknown_1 == price_x100 exact = (diff == 0)
close = abs(diff) <= 2 close = (abs(diff) <= 2)
if exact: if exact:
exact_match += 1 all_exact += 1
if close: if close:
close_match += 1 all_close += 1
flag = " <<< exact" if exact else ("" if close else "") 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 # 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"\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 (精确): "
print(f" unknown_1 ≈ price*100 (±2): {all_close}/{len(bars)} ({100*all_close/len(bars):.1f}%)") 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 (均价) # Try another hypothesis: unknown_1 is a cumulative average price (均价)
# Compute running avg: sum(price*vol)/sum(vol) total_vol = 0
print(f"\n 另一假设:unknown_1 = 当日累计均价×100") total_amount = 0.0
cum_pv = 0.0
cum_v = 0
correct_avg = 0 correct_avg = 0
for b in bars: for b in bars:
cum_pv += b.price * b.vol total_vol += b.vol
cum_v += b.vol total_amount += b.price * b.vol
if cum_v > 0: if total_vol > 0:
avg = cum_pv / cum_v avg_x100 = int(round((total_amount / total_vol) * 100))
expected = round(avg * 100) if abs(b.unknown_1 - avg_x100) <= 2:
if abs(b.unknown_1 - expected) <= 2:
correct_avg += 1 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}%)")
# --------------------------------------------------------------------------- def probe_quote_limits(client, market, code):
# Part 2: SecurityQuote.unknown_N fields """探测实时行情中 unknown_5/6 的含义(疑似涨跌停)。"""
# --------------------------------------------------------------------------- print(f"\nProbing {code} Quote unknown_5/6:")
quotes = client.get_security_quotes([(market, code)])
def probe_quote_unknowns(c: TdxClient) -> None: if not quotes:
print(f"\n{SEP}") return
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}")
for q in quotes: for q in quotes:
pct = (q.price - q.pre_close) / q.pre_close * 100 if q.pre_close else 0
print( print(
f" {q.market.name:>6} {q.code:>8} {q.pre_close:>10.2f} {q.price:>8.2f} " 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"u5:{q.unknown_5:>6} u6:{q.unknown_6:>6}"
f"{q.unknown_6:>6} {q.unknown_7:>6} {q.unknown_8:>6} {q.rise_speed:>10.4f}"
) )
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): 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}) unique = len({r.unknown_last for r in recs})
print(f"\n unknown_last 唯一值数量: {unique}/{len(recs)}") print(f"\n Unique unknown_last in 50 recs: {unique}")
print(f" 值分布: {sorted({r.unknown_last for r in recs})}")
# --------------------------------------------------------------------------- def main():
# main host = "180.153.18.170"
# --------------------------------------------------------------------------- if len(sys.argv) > 1:
host = sys.argv[1]
def main() -> None: with TdxClient(host) as client:
print(f"连接 {HOST}:7709 ...") # 1. 均价探测
with TdxClient(HOST) as c: probe_minute_averages(client, Market.SH, "600000")
probe_minute_unknown_1(c) probe_minute_averages(client, Market.SZ, "000001")
probe_quote_unknowns(c)
probe_transaction_unknown_last(c)
print(f"\n{SEP}") # 2. 涨跌停探测
print("探测完成。根据以上输出可判断各字段含义,更新 models/ 文档注释。") 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__": if __name__ == "__main__":
+127
View File
@@ -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()
+132 -36
View File
@@ -4,21 +4,21 @@ import asyncio
from types import TracebackType from types import TracebackType
from typing import TypeVar 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.base import BaseCommand
from .commands.block_info import GetBlockInfoCmd, GetBlockInfoMetaCmd from .commands.block_info import GetBlockInfoCmd, GetBlockInfoMetaCmd
from .commands.company_info import GetCompanyInfoCategoryCmd, GetCompanyInfoContentCmd from .commands.company_info import GetCompanyInfoCategoryCmd, GetCompanyInfoContentCmd
from .commands.finance_info import GetFinanceInfoCmd from .commands.finance_info import GetFinanceInfoCmd
from .commands.fund_flow import GetHistoryFundFlowCmd from .commands.fund_flow import GetHistoryFundFlowCmd
from .commands.report_file import GetReportFileCmd
from .commands.minute_time import GetHistoryMinuteTimeDataCmd, GetMinuteTimeDataCmd from .commands.minute_time import GetHistoryMinuteTimeDataCmd, GetMinuteTimeDataCmd
from .commands.report_file import GetReportFileCmd
from .commands.security_bars import GetIndexBarsCmd, GetSecurityBarsCmd from .commands.security_bars import GetIndexBarsCmd, GetSecurityBarsCmd
from .commands.security_count import GetSecurityCountCmd from .commands.security_count import GetSecurityCountCmd
from .commands.security_list import GetSecurityListCmd from .commands.security_list import GetSecurityListCmd
from .commands.security_quotes import GetSecurityQuotesCmd from .commands.security_quotes import GetSecurityQuotesCmd
from .commands.transaction import GetHistoryTransactionDataCmd, GetTransactionDataCmd from .commands.transaction import GetHistoryTransactionDataCmd, GetTransactionDataCmd
from .commands.xdxr_info import GetXdxrInfoCmd 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 .exceptions import TdxConnectionError
from .models.bar import SecurityBar from .models.bar import SecurityBar
from .models.enums import KlineCategory, Market from .models.enums import KlineCategory, Market
@@ -148,7 +148,12 @@ class TdxClient:
return self._execute(GetSecurityListCmd(market, start)) return self._execute(GetSecurityListCmd(market, start))
def get_security_list_all(self) -> list[SecurityInfo]: def get_security_list_all(self) -> list[SecurityInfo]:
"""获取全市场(沪深 A 股完整证券列表,并自动挂载行业信息。""" """获取沪深 A 股完整证券列表,并自动挂载行业信息。
注意:
`Market.BJ` 的证券列表请求长期存在服务器超时问题,当前版本暂不纳入此方法。
若需 BJ 名单,应改由 `base_info.zip` 等文件离线解析获得。
"""
# 1. 尝试获取行业配置 # 1. 尝试获取行业配置
industry_map = {} industry_map = {}
try: try:
@@ -159,7 +164,9 @@ class TdxClient:
pass pass
all_stocks: list[SecurityInfo] = [] 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) count = self.get_security_count(market)
for start in range(0, count, 1000): for start in range(0, count, 1000):
stocks = self.get_security_list(market, start) stocks = self.get_security_list(market, start)
@@ -174,10 +181,6 @@ class TdxClient:
# 深市 A 股:00xxxx, 30xxxx # 深市 A 股:00xxxx, 30xxxx
if s.code.startswith(("00", "30")): if s.code.startswith(("00", "30")):
is_a_share = True 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: if is_a_share:
# 挂载行业信息 # 挂载行业信息
@@ -312,31 +315,72 @@ class TdxClient:
return bytes(full_data) return bytes(full_data)
def get_market_stat(self) -> MarketStat: def get_market_stat(self) -> MarketStat:
"""获取 A 股全市场涨跌统计概况""" """获取 A 股全市场涨跌统计概况(基于 880005 行情统计)。
# 通达信中 880005 是行情统计代码
注意:
`suspended_count` 是 `total - up - down - neutral` 的残差估算值,
用于保证计数守恒,不应视为协议已明确验证的停牌字段。
"""
# 通达信中 880005 是全市场行情统计代码
quotes = self.get_security_quotes([(Market.SH, "880005")]) quotes = self.get_security_quotes([(Market.SH, "880005")])
if not quotes: if not quotes:
raise RuntimeError("无法获取市场统计数据") raise RuntimeError("无法获取市场统计数据")
q = quotes[0] q = quotes[0]
up = int(q.price)
down = int(q.pre_close)
neutral = int(q.low)
total = int(q.high)
return MarketStat( return MarketStat(
up_count=int(q.price), up_count=up,
down_count=int(q.pre_close), down_count=down,
neutral_count=int(q.open), neutral_count=neutral,
total_count=int(q.high), suspended_count=max(0, total - up - down - neutral),
total_count=total,
total_amount=q.amount, total_amount=q.amount,
total_volume=q.vol, total_volume=q.vol,
) )
def get_fund_flow(self, market: Market, code: str) -> FundFlow: def get_fund_flow(self, market: Market, code: str) -> FundFlow:
"""获取个股当日资金流向分布(基于 L1 逐笔数据统计)。""" """获取个股当日资金流向分布(基于 L1 逐笔数据统计)。"""
# 1. 拉取当日全量分笔 (TDX L1 最多支持约 2000-4000 条,通常足够 A 股当日统计) # 1. 分页拉取当日分笔并去重
all_recs: list[TransactionRecord] = [] 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) recs = self.get_transaction_data(market, code, start, 2000)
if not recs: if not recs:
break 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 break
# 2. 统计逻辑 # 2. 统计逻辑
@@ -366,7 +410,10 @@ class TdxClient:
def get_history_fund_flow( def get_history_fund_flow(
self, market: Market, code: str, start: int, count: int self, market: Market, code: str, start: int, count: int
) -> list[HistoricalFundFlow]: ) -> list[HistoricalFundFlow]:
"""获取个股历史日线资金流向序列(Category 22)。""" """获取个股历史日线资金流向序列(Category 22)。
[EXPERIMENTAL] 当前多台公开主机对该请求仍可能返回空列表。
"""
return self._execute(GetHistoryFundFlowCmd(market, code, start, count)) return self._execute(GetHistoryFundFlowCmd(market, code, start, count))
@@ -500,7 +547,12 @@ class AsyncTdxClient:
return await self._execute(GetSecurityListCmd(market, start)) return await self._execute(GetSecurityListCmd(market, start))
async def get_security_list_all(self) -> list[SecurityInfo]: async def get_security_list_all(self) -> list[SecurityInfo]:
"""获取全市场完整证券列表,并自动挂载行业信息。""" """获取沪深 A 股完整证券列表,并自动挂载行业信息。
注意:
`Market.BJ` 的证券列表请求长期存在服务器超时问题,当前版本暂不纳入此方法。
若需 BJ 名单,应改由 `base_info.zip` 等文件离线解析获得。
"""
industry_map = {} industry_map = {}
try: try:
cfg_data = await self.get_report_file("tdxhy.cfg") cfg_data = await self.get_report_file("tdxhy.cfg")
@@ -510,7 +562,9 @@ class AsyncTdxClient:
pass pass
all_stocks: list[SecurityInfo] = [] 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) count = await self.get_security_count(market)
for start in range(0, count, 1000): for start in range(0, count, 1000):
stocks = await self.get_security_list(market, start) stocks = await self.get_security_list(market, start)
@@ -522,9 +576,6 @@ class AsyncTdxClient:
elif market == Market.SZ: elif market == Market.SZ:
if s.code.startswith(("00", "30")): if s.code.startswith(("00", "30")):
is_a_share = True is_a_share = True
elif market == Market.BJ:
if s.code.startswith(("8", "43", "92")):
is_a_share = True
if is_a_share: if is_a_share:
if s.code in industry_map: if s.code in industry_map:
@@ -627,29 +678,72 @@ class AsyncTdxClient:
return bytes(full_data) return bytes(full_data)
async def get_market_stat(self) -> MarketStat: 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")]) quotes = await self.get_security_quotes([(Market.SH, "880005")])
if not quotes: if not quotes:
raise RuntimeError("无法获取市场统计数据") raise RuntimeError("无法获取市场统计数据")
q = quotes[0] q = quotes[0]
up = int(q.price)
down = int(q.pre_close)
neutral = int(q.low)
total = int(q.high)
return MarketStat( return MarketStat(
up_count=int(q.price), up_count=up,
down_count=int(q.pre_close), down_count=down,
neutral_count=int(q.open), neutral_count=neutral,
total_count=int(q.high), suspended_count=max(0, total - up - down - neutral),
total_count=total,
total_amount=q.amount, total_amount=q.amount,
total_volume=q.vol, total_volume=q.vol,
) )
async def get_fund_flow(self, market: Market, code: str) -> FundFlow: async def get_fund_flow(self, market: Market, code: str) -> FundFlow:
"""获取个股当日资金流向分布。""" """获取个股当日资金流向分布(基于 L1 逐笔数据统计)"""
# 1. 分页拉取当日分笔并去重
all_recs: list[TransactionRecord] = [] 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) recs = await self.get_transaction_data(market, code, start, 2000)
if not recs: if not recs:
break 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 break
stats = { stats = {
@@ -674,6 +768,8 @@ class AsyncTdxClient:
async def get_history_fund_flow( async def get_history_fund_flow(
self, market: Market, code: str, start: int, count: int self, market: Market, code: str, start: int, count: int
) -> list[HistoricalFundFlow]: ) -> list[HistoricalFundFlow]:
"""获取个股历史日线资金流向序列""" """获取个股历史日线资金流向序列Category 22)。
return await self._execute(GetHistoryFundFlowCmd(market, code, start, count))
[EXPERIMENTAL] 当前多台公开主机对该请求仍可能返回空列表。
"""
return await self._execute(GetHistoryFundFlowCmd(market, code, start, count))
+62
View File
@@ -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
-1
View File
@@ -5,7 +5,6 @@
import struct import struct
from .._binary import slice_bytes, unpack_from
from ..exceptions import TdxDecodeError from ..exceptions import TdxDecodeError
from .base import BaseCommand from .base import BaseCommand
+13 -20
View File
@@ -2,9 +2,7 @@
import struct import struct
from .._binary import slice_bytes, unpack_from
from ..codec.volume import _decode_volume from ..codec.volume import _decode_volume
from ..exceptions import TdxDecodeError
from ..models.enums import Market from ..models.enums import Market
from ..models.stats import HistoricalFundFlow from ..models.stats import HistoricalFundFlow
from .base import BaseCommand from .base import BaseCommand
@@ -20,29 +18,24 @@ class GetHistoryFundFlowCmd(BaseCommand[list[HistoricalFundFlow]]):
self.count = count self.count = count
def build_request(self) -> bytes: def build_request(self) -> bytes:
# 使用 0x052d 指令(K 线类指令) # Header (12 bytes) + Payload (28 bytes) = 40 bytes
# 负载长度固定为 28 字节 (0x1c) return struct.pack(
payload_len = 0x1c "<HIHHHH6sHHHHIIH",
header = struct.pack( 0x010C,
"<HIHHH", 0x01016408,
0x010c, 0x001C,
0x01016408, # 注意此处标志位与普通行情略有不同 0x001C,
payload_len, 0x052D,
payload_len,
0x052d,
)
# 参数包:Market(B), Code(6s), Category(H=22), Unknown(H=1), Start(I), Count(I), 3个Unknown(H)
params = struct.pack(
"<B6sHHIIHHH",
int(self.market), int(self.market),
self.code, self.code,
22, # Category 22 22,
1, # Unknown 1,
self.start, self.start,
self.count, self.count,
0, 0, 0 0,
0,
0,
) )
return header + params
def parse_response(self, body: bytes) -> list[HistoricalFundFlow]: def parse_response(self, body: bytes) -> list[HistoricalFundFlow]:
# 响应格式:9字节头 + 2字节数量 + 每条记录 36 字节 # 响应格式:9字节头 + 2字节数量 + 每条记录 36 字节
-2
View File
@@ -2,8 +2,6 @@
import struct import struct
from .._binary import slice_bytes, unpack_from
from ..exceptions import TdxDecodeError
from .base import BaseCommand from .base import BaseCommand
+10 -7
View File
@@ -37,20 +37,23 @@ class GetSecurityBarsCmd(BaseCommand[list[SecurityBar]]):
self.count = count self.count = count
def build_request(self) -> bytes: def build_request(self) -> bytes:
# Header (12 bytes) + Payload (28 bytes) = 40 bytes
return struct.pack( return struct.pack(
"<HIHHHH6sHHHHIIH", "<HIHHHH6sHHHHIIH",
0x010C, # 固定 0x010C,
0x01016408, # 固定 0x01016408,
0x001C, # 固定(payload 长度) 0x001C,
0x001C, # 固定(payload 长度) 0x001C,
0x052D, # 命令码:K线 0x052D,
int(self.market), int(self.market),
self.code, self.code,
int(self.category), int(self.category),
1, # 固定 1,
self.start, self.start,
self.count, self.count,
0, 0, 0, # 填充 0,
0,
0,
) )
def parse_response(self, body: bytes) -> list[SecurityBar]: def parse_response(self, body: bytes) -> list[SecurityBar]:
+4 -2
View File
@@ -22,8 +22,10 @@ class GetSecurityListCmd(BaseCommand[list[SecurityInfo]]):
self.start = start self.start = start
def build_request(self) -> bytes: def build_request(self) -> bytes:
header = bytes.fromhex("0c01186401010600060050 04".replace(" ", "")) # Header (12 bytes) + Payload (6 bytes) = 18 bytes
return header + struct.pack("<HH", int(self.market), self.start) # Payload: Market(H), Start(H), Unknown(H)=0
header = bytes.fromhex("0c0118640101060006005004".replace(" ", ""))
return header + struct.pack("<HHH", int(self.market), self.start, 0)
def parse_response(self, body: bytes) -> list[SecurityInfo]: def parse_response(self, body: bytes) -> list[SecurityInfo]:
(num,) = unpack_from("<H", body, 0, "security_list header") (num,) = unpack_from("<H", body, 0, "security_list header")
+2 -2
View File
@@ -195,8 +195,8 @@ class GetSecurityQuotesCmd(BaseCommand[list[SecurityQuote]]):
ask5=(price_raw + ask5_d) / 100.0, ask5=(price_raw + ask5_d) / 100.0,
ask_vol5=float(av5), ask_vol5=float(av5),
rise_speed=rise_speed_raw / 100.0, rise_speed=rise_speed_raw / 100.0,
limit_up=(price_raw + unknown_2) / 100.0, limit_up=None,
limit_down=(price_raw + unknown_3) / 100.0, limit_down=None,
unknown_2=unknown_2, unknown_2=unknown_2,
unknown_3=unknown_3, unknown_3=unknown_3,
unknown_5=unknown_5, unknown_5=unknown_5,
+4 -4
View File
@@ -60,12 +60,12 @@ class SecurityQuote:
# 价格指标 # 价格指标
rise_speed: float # 涨速(原 reversed_bytes9 / 100 rise_speed: float # 涨速(原 reversed_bytes9 / 100
limit_up: float # 涨停价(由 unknown_2 / 100 转换 limit_up: float | None # 涨停价(业务规则计算
limit_down: float # 跌停价(由 unknown_3 / 100 转换 limit_down: float | None # 跌停价(业务规则计算
# 未知字段:买卖量之后的两个变长整数(保留供进一步分析) # 未知字段:买卖量之后的两个变长整数(保留供进一步分析)
unknown_2: int = field(default=0, repr=False) # 原始涨停价整数(price_raw + diff unknown_2: int = field(default=0, repr=False) # 未知变长整数 2
unknown_3: int = field(default=0, repr=False) # 原始跌停价整数(price_raw + diff unknown_3: int = field(default=0, repr=False) # 未知变长整数 3
# 未知字段:尾部四个变长整数 # 未知字段:尾部四个变长整数
unknown_5: int = field(default=0, repr=False) # 原 reversed_bytes5 unknown_5: int = field(default=0, repr=False) # 原 reversed_bytes5
+8 -6
View File
@@ -1,15 +1,17 @@
"""验证市场概况模型。""" """验证市场概况模型。"""
from dataclasses import dataclass from dataclasses import dataclass
@dataclass @dataclass
class MarketStat: class MarketStat:
"""全市场涨跌统计概况。""" """全市场涨跌统计概况。"""
up_count: int # 上涨家数 up_count: int # 上涨家数
down_count: int # 下跌家数 down_count: int # 下跌家数
neutral_count: int # 平盘家数 neutral_count: int # 平盘家数
total_count: int # 家数 suspended_count: int # 由 total-(up+down+neutral) 得到的残差项,近似表示停牌/未参与统计家数
total_amount: float # 总成交额 total_count: int # 总计(包含停牌)
total_volume: float # 总成交 total_amount: float # 总成交
total_volume: float # 总成交量
@dataclass @dataclass
+21 -17
View File
@@ -1,18 +1,17 @@
"""针对本轮 A 股增强功能的单元测试。""" """针对本轮 A 股增强功能的单元测试。"""
import pytest
import struct import struct
from unittest.mock import patch, MagicMock, AsyncMock from unittest.mock import patch
from xmtdx import TdxClient, Market
from xmtdx import Market, TdxClient
from xmtdx.models.quote import SecurityQuote
from xmtdx.models.security import SecurityInfo from xmtdx.models.security import SecurityInfo
from xmtdx.models.timeseries import TransactionRecord from xmtdx.models.timeseries import TransactionRecord
from xmtdx.models.quote import SecurityQuote
from xmtdx.models.stats import FundFlow, HistoricalFundFlow, MarketStat
@patch("xmtdx.client.TdxConnection") @patch("xmtdx.client.TdxConnection")
def test_get_fund_flow_logic(mock_conn_cls): def test_get_fund_flow_logic(_mock_conn_cls):
"""测试资金流分类计算逻辑。""" """测试资金流分类计算逻辑。"""
mock_conn = mock_conn_cls.return_value
client = TdxClient("127.0.0.1") client = TdxClient("127.0.0.1")
# 构造模拟 Tick 数据 # 构造模拟 Tick 数据
@@ -31,7 +30,7 @@ def test_get_fund_flow_logic(mock_conn_cls):
assert flow.main_net_inflow == 1000000.0 - 250000.0 assert flow.main_net_inflow == 1000000.0 - 250000.0
@patch("xmtdx.client.TdxConnection") @patch("xmtdx.client.TdxConnection")
def test_get_security_list_all_filtering(mock_conn_cls): def test_get_security_list_all_filtering(_mock_conn_cls):
"""测试三市 A 股过滤与行业挂载逻辑。""" """测试三市 A 股过滤与行业挂载逻辑。"""
client = TdxClient("127.0.0.1") client = TdxClient("127.0.0.1")
@@ -56,18 +55,18 @@ def test_get_security_list_all_filtering(mock_conn_cls):
patch.object(TdxClient, "get_security_list", side_effect=mock_get_list): patch.object(TdxClient, "get_security_list", side_effect=mock_get_list):
all_stocks = client.get_security_list_all() all_stocks = client.get_security_list_all()
assert len(all_stocks) == 3 # 预期只有 SH 和 SZ,BJ 已在扫描中降级移除
assert len(all_stocks) == 2
codes = [s.code for s in all_stocks] codes = [s.code for s in all_stocks]
assert "600000" in codes assert "600000" in codes
assert "000001" in codes assert "000001" in codes
assert "830000" in codes assert "830000" not in codes
s0 = next(s for s in all_stocks if s.code == "600000") s0 = next(s for s in all_stocks if s.code == "600000")
assert s0.industry_tdx == "T01" assert s0.industry_tdx == "T01"
@patch("xmtdx.client.TdxConnection") @patch("xmtdx.client.TdxConnection")
def test_get_market_stat_mapping(mock_conn_cls): def test_get_market_stat_mapping(_mock_conn_cls):
"""测试市场统计字段映射。""" """测试市场统计字段映射。"""
client = TdxClient("127.0.0.1") client = TdxClient("127.0.0.1")
@@ -75,18 +74,23 @@ def test_get_market_stat_mapping(mock_conn_cls):
Market.SH, "880005", Market.SH, "880005",
price=3000.0, # up price=3000.0, # up
pre_close=2000.0, # down pre_close=2000.0, # down
open=500.0, # neutral open=0,
high=5500.0, # total high=5500.0, # total
low=100.0, vol=1000000.0, cur_vol=0, amount=50000000.0, low=500.0, # neutral (low=500 -> neutral_count=500)
vol=1000000.0, cur_vol=0, amount=50000000.0,
s_vol=0, b_vol=0, active1=0, active2=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, bid1=0, bid_vol1=0, bid2=0, bid_vol2=0, bid3=0, bid_vol3=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, 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 rise_speed=0, limit_up=0, limit_down=0
) )
with patch.object(TdxClient, "get_security_quotes", return_value=[mock_quote]): with patch.object(TdxClient, "get_security_quotes", return_value=[mock_quote]):
stat = client.get_market_stat() stat = client.get_market_stat()
assert stat.up_count == 3000 assert stat.up_count == 3000
assert stat.down_count == 2000
assert stat.neutral_count == 500
assert stat.total_count == 5500 assert stat.total_count == 5500
def test_get_history_fund_flow_parsing(): def test_get_history_fund_flow_parsing():
+6 -7
View File
@@ -1,10 +1,12 @@
"""板块信息单元测试。""" """板块信息单元测试。"""
import pytest import asyncio
import struct import struct
from unittest.mock import MagicMock, patch from unittest.mock import patch
from xmtdx.client import AsyncTdxClient, TdxClient from xmtdx.client import AsyncTdxClient, TdxClient
from xmtdx.codec.block import parse_block_dat
from xmtdx.models.finance import TdxBlock
@patch("xmtdx.client.AsyncTdxConnection") @patch("xmtdx.client.AsyncTdxConnection")
@@ -14,7 +16,7 @@ def test_async_get_block_info_logic(mock_conn_cls):
# 模拟异步 execute # 模拟异步 execute
async def mock_execute(cmd): 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): if isinstance(cmd, GetBlockInfoMetaCmd):
return 100, "hash" return 100, "hash"
if isinstance(cmd, GetBlockInfoCmd): if isinstance(cmd, GetBlockInfoCmd):
@@ -34,10 +36,7 @@ def test_async_get_block_info_logic(mock_conn_cls):
assert isinstance(res, list) assert isinstance(res, list)
assert mock_conn.execute.call_count == 2 # 1 meta + 1 data assert mock_conn.execute.call_count == 2 # 1 meta + 1 data
import asyncio
asyncio.run(main()) asyncio.run(main())
from xmtdx.codec.block import parse_block_dat
from xmtdx.models.finance import TdxBlock
def test_parse_block_dat_empty(): def test_parse_block_dat_empty():
@@ -81,7 +80,7 @@ def test_get_block_info_logic(mock_conn_cls):
# 模拟 GetBlockInfoMeta 响应:size=35000 (需要2次拉取) # 模拟 GetBlockInfoMeta 响应:size=35000 (需要2次拉取)
def mock_execute(cmd): def mock_execute(cmd):
from xmtdx.commands.block_info import GetBlockInfoMetaCmd, GetBlockInfoCmd from xmtdx.commands.block_info import GetBlockInfoCmd, GetBlockInfoMetaCmd
if isinstance(cmd, GetBlockInfoMetaCmd): if isinstance(cmd, GetBlockInfoMetaCmd):
return 35000, "dummy_hash" return 35000, "dummy_hash"
if isinstance(cmd, GetBlockInfoCmd): if isinstance(cmd, GetBlockInfoCmd):
+2 -1
View File
@@ -266,7 +266,8 @@ def test_xdxr_info_parse():
# share count decode: 通达信自定义浮点,单位万股,与 FinanceInfo.zong_guben/10000 一致 # share count decode: 通达信自定义浮点,单位万股,与 FinanceInfo.zong_guben/10000 一致
stock_recs = [r for r in recs if 2 <= r.category <= 10] stock_recs = [r for r in recs if 2 <= r.category <= 10]
last = stock_recs[-1] 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 last.hou_zongguben is not None
assert abs(last.hou_zongguben - 3_330_583.75) < 1.0 assert abs(last.hou_zongguben - 3_330_583.75) < 1.0
+3 -3
View File
@@ -1,9 +1,9 @@
"""心跳机制单元测试。""" """心跳机制单元测试。"""
import asyncio import asyncio
import pytest from unittest.mock import AsyncMock, patch
from unittest.mock import patch, MagicMock, AsyncMock
from xmtdx import AsyncTdxClient, Market from xmtdx import AsyncTdxClient
def test_heartbeat_sends_periodically(): def test_heartbeat_sends_periodically():
+109
View File
@@ -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(
"<HIHHHH6sHHHHIIH",
0x010C, 0x01016408, 0x001C, 0x001C,
0x052D, 1, b"600000", 4, 1, 0, 10, 0, 0, 0
)
assert req == expected
assert len(req) == 38
def test_history_fund_flow_exact_layout():
"""验证历史资金流请求包布局与 K 线一致,只差 category=22。"""
cmd = GetHistoryFundFlowCmd(Market.SH, "600000", 0, 10)
req = cmd.build_request()
# Header: 0x010C, 0x01016408, 0x1C, 0x1C
# Payload: 0x052D, 1 (Market.SH), "600000", 22, 1, 0, 10, 0, 0, 0
expected = struct.pack(
"<HIHHHH6sHHHHIIH",
0x010C, 0x01016408, 0x001C, 0x001C,
0x052D, 1, b"600000", 22, 1, 0, 10, 0, 0, 0
)
assert req == expected
assert len(req) == 38
def test_security_list_request_length():
"""验证证券列表请求包载荷长度为 6 字节。"""
cmd = GetSecurityListCmd(Market.SH, 0)
req = cmd.build_request()
# Header 12 + Payload 6 = 18
assert len(req) == 18
payload_len = struct.unpack("<H", req[6:8])[0]
assert payload_len == 6
def test_security_quotes_limit_mapping():
"""验证涨跌停价现在返回 None,且 pre_close 正确。"""
from xmtdx.codec.price import put_price
cmd = GetSecurityQuotesCmd([(Market.SH, "600000")])
# 构造响应报文
body = bytearray(b"\x00\x00")
body.extend(struct.pack("<H", 1))
# Record: Market(B), Code(6s), Active1(H) + ...
body.extend(struct.pack("<B6sH", 1, b"600000", 0))
body.extend(put_price(1010)) # price_raw
body.extend(put_price(-5)) # last_close_diff
body.extend(put_price(0))
body.extend(put_price(0))
body.extend(put_price(0))
body.extend(put_price(12345))
body.extend(put_price(-1010))
body.extend(put_price(100))
body.extend(put_price(10))
body.extend(struct.pack("<I", 10000))
body.extend(put_price(50))
body.extend(put_price(50))
body.extend(put_price(2))
body.extend(put_price(3))
for _ in range(20):
body.extend(put_price(0))
body.extend(struct.pack("<H", 0))
body.extend(put_price(96))
body.extend(put_price(-106))
body.extend(put_price(0))
body.extend(put_price(0))
body.extend(struct.pack("<hH", 0, 0))
quotes = cmd.parse_response(bytes(body))
q = quotes[0]
assert q.limit_up is None
assert q.limit_down is None
assert q.pre_close == 10.05
def test_compute_price_limits_for_stocks():
"""普通股票 / ST / 创业板 / 科创板 / 北交所规则应可正确计算。"""
assert compute_price_limits(Market.SH, "600000", "浦发银行", 10.05) == (11.06, 9.05)
assert compute_price_limits(Market.SH, "603939", "ST益丰", 22.53) == (23.66, 21.4)
assert compute_price_limits(Market.SZ, "301269", "华大九天", 86.36) == (103.63, 69.09)
assert compute_price_limits(Market.SH, "688981", "中芯国际", 101.52) == (121.82, 81.22)
assert compute_price_limits(Market.BJ, "920002", "万达轴承", 84.36) == (109.67, 59.05)
def test_compute_price_limits_for_indices():
"""指数与板块类代码不应计算涨跌停。"""
assert compute_price_limits(Market.SH, "999999", "上证指数", 4026.63) == (None, None)
assert compute_price_limits(Market.SH, "880005", "涨跌家数", 1841.0) == (None, None)
assert compute_price_limits(Market.SZ, "399001", "深证成指", 10412.63) == (None, None)