mirror of
https://ghfast.top/https://github.com/aeroxw/easy-tdx.git
synced 2026-09-12 19:14:16 +08:00
291 lines
10 KiB
Python
291 lines
10 KiB
Python
"""针对本轮 A 股增强功能的单元测试。"""
|
|
|
|
import asyncio
|
|
import struct
|
|
from unittest.mock import patch
|
|
|
|
from xmtdx import AsyncTdxClient, Market, TdxClient
|
|
from xmtdx.client import _classify_fund_flow
|
|
from xmtdx.models.bar import SecurityBar
|
|
from xmtdx.models.quote import SecurityQuote
|
|
from xmtdx.models.security import SecurityInfo
|
|
from xmtdx.models.stats import HistoricalFundFlow
|
|
from xmtdx.models.timeseries import MinuteBar
|
|
from xmtdx.models.timeseries import TransactionRecord
|
|
|
|
|
|
@patch("xmtdx.client.TdxConnection")
|
|
def test_get_fund_flow_logic(_mock_conn_cls):
|
|
"""测试资金流分类计算逻辑。"""
|
|
client = TdxClient("127.0.0.1")
|
|
|
|
# 构造模拟 Tick 数据
|
|
mock_recs = [
|
|
TransactionRecord(10, 0, 100.0, 101, 0, 0), # super_in (100*101*100 = 101w)
|
|
TransactionRecord(10, 1, 10.0, 250, 1, 0), # large_out (10*250*100 = 25w)
|
|
TransactionRecord(10, 2, 10.0, 10, 0, 0), # small_in (10*10*100 = 1w)
|
|
]
|
|
|
|
with patch.object(TdxClient, "get_transaction_data", return_value=mock_recs):
|
|
flow = client.get_fund_flow(Market.SH, "600000")
|
|
|
|
assert flow.super_in == 1010000.0
|
|
assert flow.large_out == 250000.0
|
|
assert flow.small_in == 10000.0
|
|
assert flow.main_net_inflow == 1010000.0 - 250000.0
|
|
|
|
|
|
def test_classify_fund_flow_exact_thresholds_use_lower_bucket():
|
|
"""恰好命中阈值时,应落入较低一档。"""
|
|
flow = _classify_fund_flow([
|
|
TransactionRecord(10, 0, 100.0, 100, 0, 0), # 100w -> large
|
|
TransactionRecord(10, 1, 100.0, 20, 0, 0), # 20w -> medium
|
|
TransactionRecord(10, 2, 100.0, 4, 0, 0), # 4w -> small
|
|
])
|
|
|
|
assert flow.super_in == 0.0
|
|
assert flow.large_in == 1000000.0
|
|
assert flow.medium_in == 200000.0
|
|
assert flow.small_in == 40000.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()
|
|
|
|
# 预期只有 SH 和 SZ,BJ 已在扫描中降级移除
|
|
assert len(all_stocks) == 2
|
|
codes = [s.code for s in all_stocks]
|
|
assert "600000" in codes
|
|
assert "000001" in codes
|
|
assert "830000" not in codes
|
|
s0 = next(s for s in all_stocks if s.code == "600000")
|
|
assert s0.industry_tdx == "T01"
|
|
|
|
@patch("xmtdx.client.TdxConnection")
|
|
def test_get_market_stat_mapping(_mock_conn_cls):
|
|
"""测试市场统计字段映射。"""
|
|
client = TdxClient("127.0.0.1")
|
|
|
|
mock_quote = SecurityQuote(
|
|
Market.SH, "880005",
|
|
price=3000.0, # up
|
|
pre_close=2000.0, # down
|
|
open=0,
|
|
high=5500.0, # total
|
|
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,
|
|
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
|
|
|
|
def test_get_history_fund_flow_parsing():
|
|
"""测试历史资金流序列解析逻辑。"""
|
|
from xmtdx.commands.fund_flow import GetHistoryFundFlowCmd
|
|
|
|
# 模拟 Category 22 响应 (Header 9 + Count 2 + Body 36)
|
|
body = bytearray(9)
|
|
body.extend(struct.pack("<H", 1)) # 1 record
|
|
|
|
# Record: Date(I) + 8 * custom_float(uint32)
|
|
# 2025-01-08
|
|
date = 20250108
|
|
# 模拟 8 个流向金额
|
|
record = struct.pack("<IIIIIIIII", date, 100, 200, 300, 400, 500, 600, 700, 800)
|
|
body.extend(record)
|
|
|
|
cmd = GetHistoryFundFlowCmd(Market.SH, "600000", 0, 1)
|
|
res = cmd.parse_response(bytes(body))
|
|
|
|
assert len(res) == 1
|
|
assert res[0].year == 2025
|
|
assert res[0].month == 1
|
|
assert res[0].day == 8
|
|
|
|
|
|
@patch("xmtdx.client.TdxConnection")
|
|
def test_get_history_fund_flow_fallback(_mock_conn_cls):
|
|
"""Category 22 空回包时,自动回退到历史逐笔重算。"""
|
|
client = TdxClient("127.0.0.1")
|
|
|
|
bars = [
|
|
SecurityBar(10, 10, 10, 10, 0, 0, 2025, 1, 8, 15, 0),
|
|
SecurityBar(10, 10, 10, 10, 0, 0, 2025, 1, 9, 15, 0),
|
|
]
|
|
txn_map = {
|
|
20250108: [
|
|
TransactionRecord(10, 0, 100.0, 101, 0, 0),
|
|
TransactionRecord(10, 1, 10.0, 250, 1, 0),
|
|
],
|
|
20250109: [
|
|
TransactionRecord(10, 0, 10.0, 10, 0, 0),
|
|
],
|
|
}
|
|
|
|
def mock_history_txn(_market, _code, date, start, count):
|
|
if start > 0:
|
|
return []
|
|
return txn_map[date]
|
|
|
|
with patch.object(TdxClient, "_execute", return_value=[]), patch.object(
|
|
TdxClient, "get_security_bars", return_value=bars
|
|
), patch.object(
|
|
TdxClient, "get_history_transaction_data", side_effect=mock_history_txn
|
|
):
|
|
flows = client.get_history_fund_flow(Market.SH, "600000", 0, 2)
|
|
|
|
assert flows == [
|
|
HistoricalFundFlow(
|
|
year=2025,
|
|
month=1,
|
|
day=8,
|
|
super_in=1010000.0,
|
|
super_out=0.0,
|
|
large_in=0.0,
|
|
large_out=250000.0,
|
|
medium_in=0.0,
|
|
medium_out=0.0,
|
|
small_in=0.0,
|
|
small_out=0.0,
|
|
),
|
|
HistoricalFundFlow(
|
|
year=2025,
|
|
month=1,
|
|
day=9,
|
|
super_in=0.0,
|
|
super_out=0.0,
|
|
large_in=0.0,
|
|
large_out=0.0,
|
|
medium_in=0.0,
|
|
medium_out=0.0,
|
|
small_in=10000.0,
|
|
small_out=0.0,
|
|
),
|
|
]
|
|
|
|
|
|
@patch("xmtdx.client.TdxConnection")
|
|
def test_get_price_limits_uses_listing_window(_mock_conn_cls):
|
|
"""client.get_price_limits 应结合日 K 条数判断上市初期限价窗口。"""
|
|
client = TdxClient("127.0.0.1")
|
|
|
|
with patch.object(
|
|
TdxClient,
|
|
"get_security_bars",
|
|
return_value=[SecurityBar(0, 0, 0, 0, 0, 0, 2025, 1, 1, 15, 0)] * 5,
|
|
):
|
|
assert client.get_price_limits(Market.SH, "600001", "主板新股", 10.0) == (
|
|
None,
|
|
None,
|
|
)
|
|
|
|
with patch.object(
|
|
TdxClient,
|
|
"get_security_bars",
|
|
return_value=[SecurityBar(0, 0, 0, 0, 0, 0, 2025, 1, 1, 15, 0)] * 6,
|
|
):
|
|
assert client.get_price_limits(Market.SH, "600001", "主板老股", 10.0) == (
|
|
11.0,
|
|
9.0,
|
|
)
|
|
|
|
|
|
@patch("xmtdx.client.TdxConnection")
|
|
def test_get_minute_time_data_prefers_history_endpoint(_mock_conn_cls):
|
|
"""今日分时优先走历史分时接口,规避当前分时协议歧义。"""
|
|
client = TdxClient("127.0.0.1")
|
|
expected = [MinuteBar(price=9.7, vol=13694)]
|
|
|
|
with patch("xmtdx.client._today_in_shanghai", return_value=20260422), patch.object(
|
|
TdxClient,
|
|
"get_history_minute_time_data",
|
|
return_value=expected,
|
|
) as mock_history, patch.object(
|
|
TdxClient,
|
|
"_execute",
|
|
side_effect=AssertionError("should not hit current-minute command"),
|
|
):
|
|
result = client.get_minute_time_data(Market.SH, "600000")
|
|
|
|
mock_history.assert_called_once_with(Market.SH, "600000", 20260422)
|
|
assert result == expected
|
|
|
|
|
|
@patch("xmtdx.client.TdxConnection")
|
|
def test_get_minute_time_data_falls_back_to_current_endpoint(_mock_conn_cls):
|
|
"""历史分时失败时,仍回退到原今日分时命令。"""
|
|
client = TdxClient("127.0.0.1")
|
|
fallback = [MinuteBar(price=9.61, vol=10698)]
|
|
|
|
with patch("xmtdx.client._today_in_shanghai", return_value=20260422), patch.object(
|
|
TdxClient,
|
|
"get_history_minute_time_data",
|
|
side_effect=RuntimeError("history unavailable"),
|
|
) as mock_history, patch.object(
|
|
TdxClient,
|
|
"_execute",
|
|
return_value=fallback,
|
|
) as mock_execute:
|
|
result = client.get_minute_time_data(Market.SH, "600000")
|
|
|
|
mock_history.assert_called_once_with(Market.SH, "600000", 20260422)
|
|
mock_execute.assert_called_once()
|
|
assert result == fallback
|
|
|
|
|
|
def test_async_get_minute_time_data_prefers_history_endpoint():
|
|
"""异步客户端应与同步客户端保持同一回退策略。"""
|
|
expected = [MinuteBar(price=9.7, vol=13694)]
|
|
|
|
async def run_test() -> None:
|
|
with patch("xmtdx.client.AsyncTdxConnection"):
|
|
client = AsyncTdxClient("127.0.0.1")
|
|
with patch("xmtdx.client._today_in_shanghai", return_value=20260422), patch.object(
|
|
AsyncTdxClient,
|
|
"get_history_minute_time_data",
|
|
return_value=expected,
|
|
) as mock_history, patch.object(
|
|
AsyncTdxClient,
|
|
"_execute",
|
|
side_effect=AssertionError("should not hit current-minute command"),
|
|
):
|
|
result = await client.get_minute_time_data(Market.SH, "600000")
|
|
|
|
mock_history.assert_called_once_with(Market.SH, "600000", 20260422)
|
|
assert result == expected
|
|
|
|
asyncio.run(run_test())
|