mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 22:44:17 +08:00
Fix protocol regressions and clarify experimental APIs
This commit is contained in:
+132
-36
@@ -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))
|
||||
|
||||
@@ -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
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
import struct
|
||||
|
||||
from .._binary import slice_bytes, unpack_from
|
||||
from ..exceptions import TdxDecodeError
|
||||
from .base import BaseCommand
|
||||
|
||||
|
||||
@@ -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,8 +2,6 @@
|
||||
|
||||
import struct
|
||||
|
||||
from .._binary import slice_bytes, unpack_from
|
||||
from ..exceptions import TdxDecodeError
|
||||
from .base import BaseCommand
|
||||
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user