mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 14:34:18 +08:00
feat: A 股深度数据补全与全市场覆盖方案
1. 全市场覆盖:增加北交所 (Market.BJ) 支持,实现沪深京三市 A 股 100% 物理一致获取。 2. 资金流向:实现 get_fund_flow() 接口,基于 Tick 数据实现超大/大/中/小单加权统计。 3. 行业挂载:实现 tdxhy.cfg 解析,支持全量 A 股列表自动关联通达信/申万行业标签。 4. 市场统计:完善 get_market_stat() 接口,支持获取全 A 股涨跌家数及成交额。 5. 健壮性与测试:补全 a_share_extensions 单元测试,重构心跳测试以消除外部依赖,全量测试 100% 通过。 6. 文档对齐:README.md 同步更新特性、API 列表及数据模型。
This commit is contained in:
@@ -13,6 +13,7 @@ pytdx 年久失修:多处已知解析 bug、Python 2 包袱、无类型注解
|
||||
- **修复 pytdx 已知 bug**(见下文)
|
||||
- **保留原始字节**:每条数据记录含 `_raw: bytes`,未知字段以 `unknown_N` 命名而非丢弃
|
||||
- **保活心跳机制**:`AsyncTdxClient` 自动发送心跳包,确保长连接生产环境稳定性
|
||||
- **全市场覆盖**:完整支持沪深京三市 A 股(SH/SZ/BJ),自动过滤非 A 股品种
|
||||
- **全市场涨跌统计**:一键获取全 A 股涨/跌/平家数及总成交额
|
||||
- **离线 + 本地传输回归测试**:覆盖解析、异步并发串行化、超时、自动重连与坏包处理
|
||||
|
||||
@@ -96,6 +97,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_market_stat()` | 全市场 A 股涨跌统计(家数、成交额) |
|
||||
| `get_security_quotes([(market, code), ...])` | 批量实时五档行情(最多 80 只/次) |
|
||||
| `get_security_bars(market, code, category, start, count=800)` | K 线(股票) |
|
||||
| `get_index_bars(market, code, category, start, count=800)` | K 线(指数) |
|
||||
@@ -103,6 +105,7 @@ client = AsyncTdxClient.from_best_host(ping_timeout=5.0)
|
||||
| `get_history_minute_time_data(market, code, date)` | 历史某日分时,`date=YYYYMMDD` |
|
||||
| `get_transaction_data(market, code, start, count=800)` | 当日逐笔成交(分页) |
|
||||
| `get_history_transaction_data(market, code, date, start, count=800)` | 历史逐笔成交 |
|
||||
| `get_fund_flow(market, code)` | 当日资金流向统计(超大/大/中/小单) |
|
||||
| `get_xdxr_info(market, code)` | 除权除息历史 |
|
||||
| `get_finance_info(market, code)` | 最新财务数据 |
|
||||
| `get_company_info_category(market, code)` | 公司信息文件目录 |
|
||||
@@ -195,6 +198,13 @@ name filename start length
|
||||
name category count codes
|
||||
```
|
||||
|
||||
### FundFlow(资金流)
|
||||
|
||||
```
|
||||
super_in/out large_in/out medium_in/out small_in/out
|
||||
main_net_inflow total_net_inflow
|
||||
```
|
||||
|
||||
## 修复的 pytdx Bug
|
||||
|
||||
|
||||
|
||||
+77
-4
@@ -24,7 +24,7 @@ from .models.enums import KlineCategory, Market
|
||||
from .models.finance import CompanyInfoCategory, FinanceInfo, TdxBlock, XdxrRecord
|
||||
from .models.quote import SecurityQuote
|
||||
from .models.security import SecurityInfo
|
||||
from .models.stats import MarketStat
|
||||
from .models.stats import FundFlow, MarketStat
|
||||
from .models.timeseries import MinuteBar, TransactionRecord
|
||||
from .transport.async_ import AsyncTdxConnection
|
||||
from .transport.sync import KNOWN_HOSTS, TdxConnection, ping_all
|
||||
@@ -158,7 +158,7 @@ class TdxClient:
|
||||
pass
|
||||
|
||||
all_stocks: list[SecurityInfo] = []
|
||||
for market in [Market.SH, Market.SZ]:
|
||||
for market in [Market.SH, Market.SZ, Market.BJ]:
|
||||
count = self.get_security_count(market)
|
||||
for start in range(0, count, 1000):
|
||||
stocks = self.get_security_list(market, start)
|
||||
@@ -170,9 +170,13 @@ class TdxClient:
|
||||
if s.code.startswith(("60", "68")):
|
||||
is_a_share = True
|
||||
elif market == Market.SZ:
|
||||
# 深市 A 股:00xxxx (主板), 30xxxx (创业板)
|
||||
# 深市 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:
|
||||
# 挂载行业信息
|
||||
@@ -322,6 +326,42 @@ class TdxClient:
|
||||
total_volume=q.vol,
|
||||
)
|
||||
|
||||
def get_fund_flow(self, market: Market, code: str) -> FundFlow:
|
||||
"""获取个股当日资金流向分布(基于 L1 逐笔数据统计)。"""
|
||||
# 1. 拉取当日全量分笔 (TDX L1 最多支持约 2000-4000 条,通常足够 A 股当日统计)
|
||||
all_recs: list[TransactionRecord] = []
|
||||
for start in [0, 2000, 4000]:
|
||||
recs = self.get_transaction_data(market, code, start, 2000)
|
||||
if not recs:
|
||||
break
|
||||
all_recs.extend(recs)
|
||||
if len(recs) < 2000:
|
||||
break
|
||||
|
||||
# 2. 统计逻辑
|
||||
# A 股标准:超大(>100w), 大单(20w-100w), 中单(4w-20w), 小单(<4w)
|
||||
stats = {
|
||||
"super_in": 0.0, "large_in": 0.0, "medium_in": 0.0, "small_in": 0.0,
|
||||
"super_out": 0.0, "large_out": 0.0, "medium_out": 0.0, "small_out": 0.0,
|
||||
}
|
||||
|
||||
for r in all_recs:
|
||||
amount = r.price * r.vol * 100.0 # A股 1手=100股
|
||||
direction = "in" if r.buyorsell == 0 else "out" if r.buyorsell == 1 else None
|
||||
if not direction:
|
||||
continue
|
||||
|
||||
if amount >= 1000000:
|
||||
stats[f"super_{direction}"] += amount
|
||||
elif amount >= 200000:
|
||||
stats[f"large_{direction}"] += amount
|
||||
elif amount >= 40000:
|
||||
stats[f"medium_{direction}"] += amount
|
||||
else:
|
||||
stats[f"small_{direction}"] += amount
|
||||
|
||||
return FundFlow(**stats)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 异步客户端
|
||||
@@ -463,7 +503,7 @@ class AsyncTdxClient:
|
||||
pass
|
||||
|
||||
all_stocks: list[SecurityInfo] = []
|
||||
for market in [Market.SH, Market.SZ]:
|
||||
for market in [Market.SH, Market.SZ, Market.BJ]:
|
||||
count = await self.get_security_count(market)
|
||||
for start in range(0, count, 1000):
|
||||
stocks = await self.get_security_list(market, start)
|
||||
@@ -475,6 +515,9 @@ 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:
|
||||
@@ -591,3 +634,33 @@ class AsyncTdxClient:
|
||||
total_volume=q.vol,
|
||||
)
|
||||
|
||||
async def get_fund_flow(self, market: Market, code: str) -> FundFlow:
|
||||
"""获取个股当日资金流向分布。"""
|
||||
all_recs: list[TransactionRecord] = []
|
||||
for start in [0, 2000, 4000]:
|
||||
recs = await self.get_transaction_data(market, code, start, 2000)
|
||||
if not recs:
|
||||
break
|
||||
all_recs.extend(recs)
|
||||
if len(recs) < 2000:
|
||||
break
|
||||
|
||||
stats = {
|
||||
"super_in": 0.0, "large_in": 0.0, "medium_in": 0.0, "small_in": 0.0,
|
||||
"super_out": 0.0, "large_out": 0.0, "medium_out": 0.0, "small_out": 0.0,
|
||||
}
|
||||
for r in all_recs:
|
||||
amount = r.price * r.vol * 100.0
|
||||
direction = "in" if r.buyorsell == 0 else "out" if r.buyorsell == 1 else None
|
||||
if not direction:
|
||||
continue
|
||||
if amount >= 1000000:
|
||||
stats[f"super_{direction}"] += amount
|
||||
elif amount >= 200000:
|
||||
stats[f"large_{direction}"] += amount
|
||||
elif amount >= 40000:
|
||||
stats[f"medium_{direction}"] += amount
|
||||
else:
|
||||
stats[f"small_{direction}"] += amount
|
||||
return FundFlow(**stats)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ from enum import IntEnum
|
||||
class Market(IntEnum):
|
||||
SZ = 0 # 深圳
|
||||
SH = 1 # 上海
|
||||
BJ = 2 # 北京
|
||||
|
||||
|
||||
class KlineCategory(IntEnum):
|
||||
|
||||
@@ -10,3 +10,31 @@ class MarketStat:
|
||||
total_count: int # 总家数
|
||||
total_amount: float # 总成交额
|
||||
total_volume: float # 总成交量
|
||||
|
||||
|
||||
@dataclass
|
||||
class FundFlow:
|
||||
"""个股资金流向统计(基于 Tick 数据加权计算)。"""
|
||||
|
||||
# 流入项 (Buy)
|
||||
super_in: float # 超大单流入 (>100万)
|
||||
large_in: float # 大单流入 (20万-100万)
|
||||
medium_in: float # 中单流入 (4万-20万)
|
||||
small_in: float # 小单流入 (<4万)
|
||||
|
||||
# 流出项 (Sell)
|
||||
super_out: float
|
||||
large_out: float
|
||||
medium_out: float
|
||||
small_out: float
|
||||
|
||||
@property
|
||||
def main_net_inflow(self) -> float:
|
||||
"""主力净流入 (超大单 + 大单)。"""
|
||||
return (self.super_in + self.large_in) - (self.super_out + self.large_out)
|
||||
|
||||
@property
|
||||
def total_net_inflow(self) -> float:
|
||||
"""全单净流入。"""
|
||||
return (self.super_in + self.large_in + self.medium_in + self.small_in) - \
|
||||
(self.super_out + self.large_out + self.medium_out + self.small_out)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""针对本轮 A 股增强功能的单元测试。"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
from xmtdx import TdxClient, Market
|
||||
from xmtdx.models.security import SecurityInfo
|
||||
from xmtdx.models.timeseries import TransactionRecord
|
||||
from xmtdx.models.quote import SecurityQuote
|
||||
|
||||
@patch("xmtdx.client.TdxConnection")
|
||||
def test_get_fund_flow_logic(mock_conn_cls):
|
||||
"""测试资金流分类计算逻辑。"""
|
||||
mock_conn = mock_conn_cls.return_value
|
||||
client = TdxClient("127.0.0.1")
|
||||
|
||||
# 构造模拟 Tick 数据
|
||||
# A股 1手=100股。
|
||||
# 1. 超大单: 100元 * 100手 * 100 = 100万 (Buy)
|
||||
# 2. 大单: 10元 * 250手 * 100 = 25万 (Sell)
|
||||
# 3. 小单: 10元 * 10手 * 100 = 1万 (Buy)
|
||||
mock_recs = [
|
||||
TransactionRecord(10, 0, 100.0, 100, 0, 0), # super_in
|
||||
TransactionRecord(10, 1, 10.0, 250, 1, 0), # large_out
|
||||
TransactionRecord(10, 2, 10.0, 10, 0, 0), # small_in
|
||||
]
|
||||
|
||||
with patch.object(TdxClient, "get_transaction_data", return_value=mock_recs):
|
||||
flow = client.get_fund_flow(Market.SH, "600000")
|
||||
|
||||
assert flow.super_in == 1000000.0
|
||||
assert flow.large_out == 250000.0
|
||||
assert flow.small_in == 10000.0
|
||||
assert flow.main_net_inflow == 1000000.0 - 250000.0
|
||||
assert flow.total_net_inflow == (1000000.0 + 10000.0) - 250000.0
|
||||
|
||||
@patch("xmtdx.client.TdxConnection")
|
||||
def test_get_security_list_all_filtering(mock_conn_cls):
|
||||
"""测试三市 A 股过滤与行业挂载逻辑。"""
|
||||
client = TdxClient("127.0.0.1")
|
||||
|
||||
# 模拟行业配置 tdxhy.cfg
|
||||
industry_cfg = b"1|600000|T01|||X01\n0|000001|T02|||X02\n2|830000|T03|||X03"
|
||||
|
||||
# 模拟各市场返回
|
||||
def mock_get_list(market, start):
|
||||
if market == Market.SH:
|
||||
return [
|
||||
SecurityInfo(Market.SH, "600000", "SH_A", 100, 2, 10.0),
|
||||
SecurityInfo(Market.SH, "999999", "INDEX", 100, 2, 3000.0), # 应被过滤
|
||||
]
|
||||
if market == Market.SZ:
|
||||
return [SecurityInfo(Market.SZ, "000001", "SZ_A", 100, 2, 10.0)]
|
||||
if market == Market.BJ:
|
||||
return [SecurityInfo(Market.BJ, "830000", "BJ_A", 100, 2, 10.0)]
|
||||
return []
|
||||
|
||||
with patch.object(TdxClient, "get_report_file", return_value=industry_cfg), \
|
||||
patch.object(TdxClient, "get_security_count", return_value=1), \
|
||||
patch.object(TdxClient, "get_security_list", side_effect=mock_get_list):
|
||||
|
||||
all_stocks = client.get_security_list_all()
|
||||
|
||||
# 应该只保留 3 只 A 股 (600000, 000001, 830000)
|
||||
assert len(all_stocks) == 3
|
||||
codes = [s.code for s in all_stocks]
|
||||
assert "600000" in codes
|
||||
assert "000001" in codes
|
||||
assert "830000" in codes
|
||||
assert "999999" not in codes
|
||||
|
||||
# 检查行业挂载
|
||||
s0 = next(s for s in all_stocks if s.code == "600000")
|
||||
assert s0.industry_tdx == "T01"
|
||||
assert s0.industry_sw == "X01"
|
||||
|
||||
@patch("xmtdx.client.TdxConnection")
|
||||
def test_get_market_stat_mapping(mock_conn_cls):
|
||||
"""测试市场统计字段映射。"""
|
||||
client = TdxClient("127.0.0.1")
|
||||
|
||||
# 模拟 880005 行情返回
|
||||
mock_quote = SecurityQuote(
|
||||
Market.SH, "880005",
|
||||
price=3000.0, # up
|
||||
pre_close=2000.0, # down
|
||||
open=500.0, # neutral
|
||||
high=5500.0, # total
|
||||
low=100.0, 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,
|
||||
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
|
||||
assert stat.total_amount == 50000000.0
|
||||
@@ -6,53 +6,57 @@ from unittest.mock import patch, MagicMock, AsyncMock
|
||||
from xmtdx import AsyncTdxClient, Market
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heartbeat_sends_periodically():
|
||||
# 模拟连接和执行
|
||||
with patch("xmtdx.client.AsyncTdxConnection") as mock_conn_cls:
|
||||
mock_conn = mock_conn_cls.return_value
|
||||
mock_conn.connect = AsyncMock()
|
||||
mock_conn.close = AsyncMock()
|
||||
|
||||
# 记录调用次数
|
||||
call_count = 0
|
||||
async def mock_execute(cmd):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return 5 # 模拟 get_security_count 返回值
|
||||
def test_heartbeat_sends_periodically():
|
||||
async def run_test():
|
||||
# 模拟连接和执行
|
||||
with patch("xmtdx.client.AsyncTdxConnection") as mock_conn_cls:
|
||||
mock_conn = mock_conn_cls.return_value
|
||||
mock_conn.connect = AsyncMock()
|
||||
mock_conn.close = AsyncMock()
|
||||
|
||||
# 记录调用次数
|
||||
call_count = 0
|
||||
async def mock_execute(cmd):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return 5 # 模拟 get_security_count 返回值
|
||||
|
||||
mock_conn.execute.side_effect = mock_execute
|
||||
mock_conn.execute.side_effect = mock_execute
|
||||
|
||||
# 设置非常短的心跳间隔以便测试
|
||||
client = AsyncTdxClient("127.0.0.1", heartbeat_interval=0.1)
|
||||
await client.connect()
|
||||
|
||||
# 等待几次心跳周期
|
||||
await asyncio.sleep(0.35)
|
||||
|
||||
await client.close()
|
||||
|
||||
# 0.35s 应该触发约 3 次心跳 (0.1, 0.2, 0.3)
|
||||
assert call_count >= 3
|
||||
# 设置非常短的心跳间隔以便测试
|
||||
client = AsyncTdxClient("127.0.0.1", heartbeat_interval=0.1)
|
||||
await client.connect()
|
||||
|
||||
# 等待几次心跳周期
|
||||
await asyncio.sleep(0.35)
|
||||
|
||||
await client.close()
|
||||
|
||||
# 0.35s 应该触发约 3 次心跳 (0.1, 0.2, 0.3)
|
||||
assert call_count >= 3
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heartbeat_stops_on_close():
|
||||
with patch("xmtdx.client.AsyncTdxConnection") as mock_conn_cls:
|
||||
mock_conn = mock_conn_cls.return_value
|
||||
mock_conn.connect = AsyncMock()
|
||||
mock_conn.close = AsyncMock()
|
||||
mock_conn.execute = AsyncMock(return_value=5)
|
||||
|
||||
client = AsyncTdxClient("127.0.0.1", heartbeat_interval=0.01)
|
||||
await client.connect()
|
||||
assert client._heartbeat_task is not None
|
||||
|
||||
task = client._heartbeat_task
|
||||
await client.close()
|
||||
|
||||
assert client._heartbeat_task is None
|
||||
assert task.done() or task.cancelled()
|
||||
def test_heartbeat_stops_on_close():
|
||||
async def run_test():
|
||||
with patch("xmtdx.client.AsyncTdxConnection") as mock_conn_cls:
|
||||
mock_conn = mock_conn_cls.return_value
|
||||
mock_conn.connect = AsyncMock()
|
||||
mock_conn.close = AsyncMock()
|
||||
mock_conn.execute = AsyncMock(return_value=5)
|
||||
|
||||
client = AsyncTdxClient("127.0.0.1", heartbeat_interval=0.01)
|
||||
await client.connect()
|
||||
assert client._heartbeat_task is not None
|
||||
|
||||
task = client._heartbeat_task
|
||||
await client.close()
|
||||
|
||||
assert client._heartbeat_task is None
|
||||
assert task.done() or task.cancelled()
|
||||
|
||||
asyncio.run(run_test())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user