Fix transport and fund flow protocol edge cases

This commit is contained in:
minionszyw
2026-04-22 17:54:13 +08:00
parent 4cb48daabb
commit aa3b1ac319
8 changed files with 127 additions and 31 deletions
+3 -3
View File
@@ -75,11 +75,11 @@ def _classify_fund_flow(records: list[TransactionRecord]) -> FundFlow:
if not direction:
continue
if amount >= 1_000_000:
if amount > 1_000_000:
stats[f"super_{direction}"] += amount
elif amount >= 200_000:
elif amount > 200_000:
stats[f"large_{direction}"] += amount
elif amount >= 40_000:
elif amount > 40_000:
stats[f"medium_{direction}"] += amount
else:
stats[f"small_{direction}"] += amount
+1 -2
View File
@@ -52,8 +52,7 @@ class GetHistoryFundFlowCmd(BaseCommand[list[HistoricalFundFlow]]):
# 记录格式:4字节日期 + 8个4字节自定义浮点金额
# [0]日期, [1..4]流入(超/大/中/小), [5..8]流出(超/大/中/小)
# 这里的 i 是 uint32 原始字节,随后需用 _decode_volume 解码
raw_data = struct.unpack("<Iiiiiiiii", body[pos:pos+36])
raw_data = struct.unpack("<IIIIIIIII", body[pos:pos+36])
raw_date = raw_data[0]
year = raw_date // 10000
+7 -15
View File
@@ -17,22 +17,14 @@ from .base import BaseCommand
def _format_server_time(raw: int) -> str:
"""将 reversed_bytes0 整数转换为 HH:MM:SS.mmm 字符串。
方法来自 pytdx issue #187。raw 为 14999212 → "14:59:57.163"
该字段编码为“小时 + 百万分之一小时的小数部分”。
例如:14999212 → "14:59:57.163"
"""
s = str(raw)
if len(s) < 6:
return s
# 最后6位:前两位=秒,后四位=毫秒的某种编码
time_part = s[:-6] + ":"
last6 = int(s[-6:])
if int(s[-6:-4]) < 60:
time_part += s[-6:-4] + ":"
time_part += f"{last6 % 10000 * 60 / 10000.0:06.3f}"
else:
mins = last6 * 60 // 1000000
secs = (last6 * 60 % 1000000) * 60 / 1000000.0
time_part += f"{mins:02d}:{secs:06.3f}"
return time_part
hours, fractional_hour = divmod(raw, 1_000_000)
total_millis = fractional_hour * 3600 // 1000
minutes, remainder = divmod(total_millis, 60_000)
seconds, millis = divmod(remainder, 1000)
return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{millis:03d}"
class GetSecurityQuotesCmd(BaseCommand[list[SecurityQuote]]):
+3 -3
View File
@@ -20,9 +20,9 @@ class FundFlow:
# 流入项 (Buy)
super_in: float # 超大单流入 (>100万)
large_in: float # 大单流入 (20万-100万)
medium_in: float # 中单流入 (4万-20万)
small_in: float # 小单流入 (<4万)
large_in: float # 大单流入 (>20万 且 <=100万)
medium_in: float # 中单流入 (>4万 且 <=20万)
small_in: float # 小单流入 (<=4万)
# 流出项 (Sell)
super_out: float
+9 -1
View File
@@ -126,7 +126,15 @@ class TdxConnection:
sock.close()
raise TdxConnectionError(f"无法连接 {self.host}:{self.port}: {e}") from e
self._sock = sock
self._send_setup()
try:
self._send_setup()
except Exception:
try:
sock.close()
except OSError:
pass
self._sock = None
raise
def close(self) -> None:
"""关闭连接。"""
+22 -7
View File
@@ -4,6 +4,7 @@ import struct
from unittest.mock import patch
from xmtdx import 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
@@ -18,7 +19,7 @@ def test_get_fund_flow_logic(_mock_conn_cls):
# 构造模拟 Tick 数据
mock_recs = [
TransactionRecord(10, 0, 100.0, 100, 0, 0), # super_in (100*100*100 = 100w)
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)
]
@@ -26,10 +27,24 @@ def test_get_fund_flow_logic(_mock_conn_cls):
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.super_in == 1010000.0
assert flow.large_out == 250000.0
assert flow.small_in == 10000.0
assert flow.main_net_inflow == 1000000.0 - 250000.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):
@@ -103,11 +118,11 @@ def test_get_history_fund_flow_parsing():
body = bytearray(9)
body.extend(struct.pack("<H", 1)) # 1 record
# Record: Date(I) + 8 * custom_float(i)
# 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)
record = struct.pack("<IIIIIIIII", date, 100, 200, 300, 400, 500, 600, 700, 800)
body.extend(record)
cmd = GetHistoryFundFlowCmd(Market.SH, "600000", 0, 1)
@@ -130,7 +145,7 @@ def test_get_history_fund_flow_fallback(_mock_conn_cls):
]
txn_map = {
20250108: [
TransactionRecord(10, 0, 100.0, 100, 0, 0),
TransactionRecord(10, 0, 100.0, 101, 0, 0),
TransactionRecord(10, 1, 10.0, 250, 1, 0),
],
20250109: [
@@ -155,7 +170,7 @@ def test_get_history_fund_flow_fallback(_mock_conn_cls):
year=2025,
month=1,
day=8,
super_in=1000000.0,
super_in=1010000.0,
super_out=0.0,
large_in=0.0,
large_out=250000.0,
+38
View File
@@ -1,6 +1,7 @@
"""协议底层修复验证(针对 2026-04-15 审查结论)。"""
import struct
from unittest.mock import patch
from xmtdx.codec.price_rules import compute_price_limits
from xmtdx.commands.fund_flow import GetHistoryFundFlowCmd
@@ -93,6 +94,14 @@ def test_security_quotes_limit_mapping():
assert q.pre_close == 10.05
def test_security_quotes_server_time_format():
"""服务器时间应按“小时 + 百万分之一小时”统一解码。"""
from xmtdx.commands.security_quotes import _format_server_time
assert _format_server_time(9500000) == "09:30:00.000"
assert _format_server_time(14999212) == "14:59:57.163"
def test_compute_price_limits_for_stocks():
"""普通股票 / ST / 创业板 / 科创板 / 北交所规则应可正确计算。"""
assert compute_price_limits(Market.SH, "600000", "浦发银行", 10.05) == (11.06, 9.05)
@@ -123,3 +132,32 @@ def test_compute_price_limits_for_newly_listed_stocks():
assert compute_price_limits(
Market.BJ, "920002", "北交所新股", 84.36, listed_days=2
) == (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("xmtdx.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])
+44
View File
@@ -0,0 +1,44 @@
"""同步 transport 回归测试。"""
from unittest.mock import patch
from xmtdx.exceptions import TdxConnectionError
from xmtdx.transport.sync import TdxConnection
class _FakeSocket:
def __init__(self) -> None:
self.timeout: float | None = None
self.connected_to: tuple[str, int] | None = None
self.closed = False
def settimeout(self, timeout: float) -> None:
self.timeout = timeout
def connect(self, address: tuple[str, int]) -> None:
self.connected_to = address
def close(self) -> None:
self.closed = True
def test_sync_connection_closes_socket_when_setup_fails() -> None:
sock = _FakeSocket()
conn = TdxConnection("127.0.0.1", port=7709, timeout=0.2)
with patch("xmtdx.transport.sync.socket.socket", return_value=sock), patch.object(
TdxConnection,
"_send_setup",
side_effect=TdxConnectionError("setup failed"),
):
try:
conn.connect()
except TdxConnectionError as exc:
assert "setup failed" in str(exc)
else: # pragma: no cover - 防御性断言
raise AssertionError("expected setup failure")
assert sock.timeout == 0.2
assert sock.connected_to == ("127.0.0.1", 7709)
assert sock.closed is True
assert conn._sock is None