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:
M
2026-04-15 12:57:28 +08:00
parent 0352229fa3
commit 4dabbf6d15
6 changed files with 264 additions and 47 deletions
+101
View File
@@ -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
+47 -43
View File
@@ -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__":