fix(client): 历史资金流当日行全零 + 主力净额列缺失(issue #52)

三个根因(全部实测核实):
1. Category 22 直连接口为虚构协议——52 台已知服务器中 46 台可达的
   全部仅回 2 字节空包,从未成功过;移除死代码与臆造解析格式。
2. 历史逐笔接口当日数据要收盘清算后才有,日 K 盘中已含当日 bar,
   故 start=0 的最新一行恒为全 0——当日 bar 改走当日实时逐笔接口。
3. main_net_inflow 此前仅为 dataclass property,asdict 静默丢弃,
   返回 DataFrame 无主力净额列——新增 _fund_flow_df_with_net 物化
   (history 紧随 date 列、当日快照放首列)。

sync + async 双客户端同步修改;更新示例与三份文档;重写/新增回归
测试(当日实时逐笔路径、主力净额列断言)。
This commit is contained in:
GitHub
2026-08-26 15:00:53 +08:00
parent 7c9e19de93
commit 574ffdd2a4
8 changed files with 158 additions and 227 deletions
+59 -26
View File
@@ -1,8 +1,9 @@
"""针对本轮 A 股增强功能的单元测试。"""
import asyncio
import struct
from datetime import datetime, timedelta
from unittest.mock import patch
from zoneinfo import ZoneInfo
import pandas as pd
@@ -46,6 +47,8 @@ def test_get_fund_flow_logic(_mock_conn_cls):
assert flow["super_in"].iloc[0] == 1010000.0
assert flow["large_out"].iloc[0] == 250000.0
assert flow["small_in"].iloc[0] == 10000.0
# 当日资金流同样物化主力净额列(Issue #52)
assert flow["main_net_inflow"].iloc[0] == 1010000.0 - 250000.0
def test_classify_fund_flow_exact_thresholds_use_lower_bucket():
@@ -194,31 +197,9 @@ def test_get_market_stat_mapping(_mock_conn_cls):
assert stat["total_market_cap"].iloc[0] == 1186.579 * 1e10
def test_get_history_fund_flow_parsing():
"""测试历史资金流序列解析逻辑。"""
from easy_tdx.commands.fund_flow import GetHistoryFundFlowCmd
body = bytearray(9)
body.extend(struct.pack("<H", 1))
date = 20250108
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("easy_tdx.client.TdxConnection")
def test_get_history_fund_flow_fallback(_mock_conn_cls):
"""Category 22 空回包时,自动回退到历史逐笔重算"""
from easy_tdx.commands.fund_flow import GetHistoryFundFlowCmd
"""资金流由日K取日期 + 历史逐笔重算;返回含 main_net_inflow 列"""
client = TdxClient("127.0.0.1")
bars = [
@@ -236,8 +217,6 @@ def test_get_history_fund_flow_fallback(_mock_conn_cls):
}
def mock_execute(cmd):
if isinstance(cmd, GetHistoryFundFlowCmd):
return []
if isinstance(cmd, GetSecurityBarsCmd):
return bars
if isinstance(cmd, GetHistoryTransactionDataCmd):
@@ -251,11 +230,65 @@ def test_get_history_fund_flow_fallback(_mock_conn_cls):
assert isinstance(flows, pd.DataFrame)
assert len(flows) == 2
# 主力净额列必须存在(Issue #52asdict 丢弃 property 导致此前无此列)
assert "main_net_inflow" in flows.columns
assert flows.columns[1] == "main_net_inflow"
row0 = flows.iloc[0]
assert row0["super_in"] == 1010000.0
assert row0["large_out"] == 250000.0
assert row0["main_net_inflow"] == (1010000.0 + 0.0) - (0.0 + 250000.0)
row1 = flows.iloc[1]
assert row1["small_in"] == 10000.0
# 仅小单流入,不计入主力净额
assert row1["main_net_inflow"] == 0.0
@patch("easy_tdx.client.TdxConnection")
def test_get_history_fund_flow_today_uses_realtime_ticks(_mock_conn_cls):
"""当日 bar 盘中取当日实时逐笔(Issue #52:历史逐笔当日恒空致整行为 0)。"""
now = datetime.now(ZoneInfo("Asia/Shanghai"))
client = TdxClient("127.0.0.1")
yesterday = now - timedelta(days=1)
bars = [
# 顺序与服务器一致:旧 → 新,最新一根是今天
SecurityBar(10, 10, 10, 10, 0, 0, yesterday.year, yesterday.month, yesterday.day, 15, 0),
SecurityBar(10, 10, 10, 10, 0, 0, now.year, now.month, now.day, 15, 0),
]
history_txn = {
yesterday.year * 10000 + yesterday.month * 100 + yesterday.day: [
TransactionRecord(10, 0, 10.0, 10, 0, 0)
]
}
realtime_txn = [TransactionRecord(13, 0, 100.0, 101, 0, 0)]
seen_cmds = []
def mock_execute(cmd):
seen_cmds.append(type(cmd).__name__)
if isinstance(cmd, GetSecurityBarsCmd):
return bars
if isinstance(cmd, GetTransactionDataCmd):
if cmd.start > 0:
return []
return realtime_txn
if isinstance(cmd, GetHistoryTransactionDataCmd):
if cmd.start > 0:
return []
return history_txn.get(cmd.date, [])
return []
with patch.object(TdxClient, "_execute", side_effect=mock_execute):
flows = client.get_history_fund_flow(Market.SH, "600000", 0, 2)
assert len(flows) == 2
assert "GetTransactionDataCmd" in seen_cmds
today_row = flows.iloc[-1]
# 今日行来自实时逐笔:100 元 × 101 手 × 100 = 超大单流入 1010000
assert today_row["super_in"] == 1010000.0
assert today_row["main_net_inflow"] == 1010000.0
# 昨日行来自历史逐笔:小单流入 10000
assert flows.iloc[0]["small_in"] == 10000.0
@patch("easy_tdx.client.TdxConnection")
-58
View File
@@ -1,10 +1,8 @@
"""协议底层修复验证(针对 2026-04-15 审查结论)。"""
import struct
from unittest.mock import patch
from easy_tdx.codec.price_rules import compute_price_limits
from easy_tdx.commands.fund_flow import GetHistoryFundFlowCmd
from easy_tdx.commands.security_bars import GetSecurityBarsCmd
from easy_tdx.commands.security_list import GetSecurityListCmd
from easy_tdx.commands.security_quotes import GetSecurityQuotesCmd
@@ -38,33 +36,6 @@ def test_security_bars_exact_layout():
assert len(req) == 38
def test_history_fund_flow_exact_layout():
"""验证历史资金流请求包布局与 K 线一致,只差 category=22。"""
cmd = GetHistoryFundFlowCmd(Market.SH, "600000", 0, 10)
req = cmd.build_request()
# Header: 0x010C, 0x01016408, 0x1C, 0x1C
# Payload: 0x052D, 1 (Market.SH), "600000", 22, 1, 0, 10, 0, 0, 0
expected = struct.pack(
"<HIHHHH6sHHHHIIH",
0x010C,
0x01016408,
0x001C,
0x001C,
0x052D,
1,
b"600000",
22,
1,
0,
10,
0,
0,
0,
)
assert req == expected
assert len(req) == 38
def test_security_list_request_length():
"""验证证券列表请求包载荷长度为 6 字节。"""
cmd = GetSecurityListCmd(Market.SH, 0)
@@ -157,32 +128,3 @@ def test_compute_price_limits_for_newly_listed_stocks():
109.67,
59.05,
)
def test_history_fund_flow_uses_uint32_volume_words():
"""历史资金流金额字段必须按 uint32 传给 _decode_volume。"""
raw_words = [
0x80000001,
0xFFFFFFFF,
0x7FFFFFFF,
0x90000000,
0xA0000000,
0xB0000000,
0xC0000000,
0xD0000000,
]
body = bytearray(9)
body.extend(struct.pack("<H", 1))
body.extend(struct.pack("<IIIIIIIII", 20250108, *raw_words))
seen: list[int] = []
def fake_decode(raw: int) -> float:
seen.append(raw)
return float(raw)
with patch("easy_tdx.commands.fund_flow._decode_volume", side_effect=fake_decode):
records = GetHistoryFundFlowCmd(Market.SH, "600000", 0, 1).parse_response(bytes(body))
assert seen == raw_words
assert records[0].small_out == float(raw_words[-1])