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
+132 -36
View File
@@ -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))
+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
from .._binary import slice_bytes, unpack_from
from ..exceptions import TdxDecodeError
from .base import BaseCommand
+13 -20
View File
@@ -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(
"<HIHHH",
0x010c,
0x01016408, # 注意此处标志位与普通行情略有不同
payload_len,
payload_len,
0x052d,
)
# 参数包:Market(B), Code(6s), Category(H=22), Unknown(H=1), Start(I), Count(I), 3个Unknown(H)
params = struct.pack(
"<B6sHHIIHHH",
# Header (12 bytes) + Payload (28 bytes) = 40 bytes
return struct.pack(
"<HIHHHH6sHHHHIIH",
0x010C,
0x01016408,
0x001C,
0x001C,
0x052D,
int(self.market),
self.code,
22, # Category 22
1, # Unknown
22,
1,
self.start,
self.count,
0, 0, 0
0,
0,
0,
)
return header + params
def parse_response(self, body: bytes) -> list[HistoricalFundFlow]:
# 响应格式:9字节头 + 2字节数量 + 每条记录 36 字节
-2
View File
@@ -2,8 +2,6 @@
import struct
from .._binary import slice_bytes, unpack_from
from ..exceptions import TdxDecodeError
from .base import BaseCommand
+10 -7
View File
@@ -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(
"<HIHHHH6sHHHHIIH",
0x010C, # 固定
0x01016408, # 固定
0x001C, # 固定(payload 长度)
0x001C, # 固定(payload 长度)
0x052D, # 命令码:K线
0x010C,
0x01016408,
0x001C,
0x001C,
0x052D,
int(self.market),
self.code,
int(self.category),
1, # 固定
1,
self.start,
self.count,
0, 0, 0, # 填充
0,
0,
0,
)
def parse_response(self, body: bytes) -> list[SecurityBar]:
+4 -2
View File
@@ -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("<HH", int(self.market), self.start)
# Header (12 bytes) + Payload (6 bytes) = 18 bytes
# 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]:
(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,
ask_vol5=float(av5),
rise_speed=rise_speed_raw / 100.0,
limit_up=(price_raw + unknown_2) / 100.0,
limit_down=(price_raw + unknown_3) / 100.0,
limit_up=None,
limit_down=None,
unknown_2=unknown_2,
unknown_3=unknown_3,
unknown_5=unknown_5,
+4 -4
View File
@@ -60,12 +60,12 @@ class SecurityQuote:
# 价格指标
rise_speed: float # 涨速(原 reversed_bytes9 / 100
limit_up: float # 涨停价(由 unknown_2 / 100 转换
limit_down: float # 跌停价(由 unknown_3 / 100 转换
limit_up: float | None # 涨停价(业务规则计算
limit_down: float | None # 跌停价(业务规则计算
# 未知字段:买卖量之后的两个变长整数(保留供进一步分析)
unknown_2: int = field(default=0, repr=False) # 原始涨停价整数(price_raw + diff
unknown_3: 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) # 未知变长整数 3
# 未知字段:尾部四个变长整数
unknown_5: int = field(default=0, repr=False) # 原 reversed_bytes5
+8 -6
View File
@@ -1,15 +1,17 @@
"""验证市场概况模型。"""
from dataclasses import dataclass
@dataclass
class MarketStat:
"""全市场涨跌统计概况。"""
up_count: int # 上涨家数
down_count: int # 下跌家数
neutral_count: int # 平盘家数
total_count: int # 家数
total_amount: float # 总成交额
total_volume: float # 总成交
up_count: int # 上涨家数
down_count: int # 下跌家数
neutral_count: int # 平盘家数
suspended_count: int # 由 total-(up+down+neutral) 得到的残差项,近似表示停牌/未参与统计家数
total_count: int # 总计(包含停牌)
total_amount: float # 总成交
total_volume: float # 总成交量
@dataclass