fix(tdxquant): 区分除权空结果与服务端失败

在 SDK 丢失错误前检查原生除权响应,保留脱敏错误与重试语义;正常无事件返回空结果。事件或配价失败时中止同步并保留旧因子文件,避免被报告为成功的零行结果。补充 SDK 离线回放、数值与落盘回归测试。

Signed-off-by: shy3130 <shy3130@qq.com>
This commit is contained in:
shy3130
2026-11-02 00:23:15 +08:00
parent 7bd290289a
commit 8796f2cd7c
4 changed files with 219 additions and 27 deletions
+38 -25
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import hashlib
import json
import logging
import math
import os
import re
import shutil
@@ -282,29 +283,41 @@ class TdxQuantClient:
"""单标的除权事件, 每行 {Date 'YYYYMMDD', Type, Bonus, ShareBonus,
AllotPrice, Allotment} — 金额/比例均为**每 10 股**口径(Bonus=10派X元)。
"""
start = f"{start_d:%Y-%m-%d}" if start_d else ""
end = f"{end_d:%Y-%m-%d}" if end_d else ""
try:
out = self._tqs.get_divid_factors(code, start, end)
except Exception as exc: # SDK 内部异常类型不可枚举
raise TdxQuantError(f"get_divid_factors({code}): {exc}") from exc
if isinstance(out, list):
return out
# 环境装有 pandas 时 SDK 返回 DataFrame, 归一为 list[dict]。
# 注意 Date 会成为 DataFrame 索引(同 get_market_data 的日期索引惯例),
# reset_index 后若列名漂移, 把首列恢复为 'Date'。
to_dict = getattr(out, "to_dict", None)
reset_index = getattr(out, "reset_index", None)
if callable(to_dict) and callable(reset_index):
start = f"{start_d:%Y-%m-%d} 00:00:00" if start_d else ""
end = f"{end_d:%Y-%m-%d} 00:00:00" if end_d else ""
# SDK get_divid_factors 会把 _clean 拒绝的服务端错误和正常无事件
# 都变成空 DataFrame。与日K共用原生重试/错误检查,避免丢失原因;
# 使用同一 get_cw_data 端点及 SDK 1.2.2 的四列顺序,单位不变。
out = self._raw("get_cw_data", str(code).strip(), start, end)
if not isinstance(out, dict):
raise TdxQuantError(f"get_cw_data({code}) 返回异常结构: {type(out)!r}")
if not out:
return []
dates, values, types = out.get("Date"), out.get("Value"), out.get("Type", [])
if (not all(isinstance(v, list) for v in (dates, values, types))
or len(dates) != len(values)):
raise TdxQuantError(f"get_cw_data({code}) Date/Value/Type 列结构异常")
records = []
fields = ("Bonus", "AllotPrice", "ShareBonus", "Allotment")
for i, item in enumerate(values):
date_key = "".join(c for c in str(dates[i]).strip() if c.isdigit())[:8]
try:
records = reset_index().to_dict("records")
if isinstance(records, list) and records:
if "Date" not in records[0]:
first_key = next(iter(records[0]))
if first_key not in ("Type", "Bonus", "AllotPrice", "ShareBonus", "Allotment"):
for row in records:
row["Date"] = row.pop(first_key)
return records
except Exception: # noqa: BLE001 — 转换失败按结构异常处理
pass
raise TdxQuantError(f"get_divid_factors({code}) 返回异常结构: {type(out)!r}")
event_date = datetime.strptime(date_key, "%Y%m%d").date() if len(date_key) == 8 else None
except ValueError:
event_date = None
if event_date is None or (start_d and event_date < start_d) or (end_d and event_date > end_d):
continue # SDK 同样过滤无效日期及服务端未裁剪的窗口外事件。
if item is None:
item = []
if not isinstance(item, list):
raise TdxQuantError(f"get_cw_data({code}) Value[{i}] 结构异常")
padded = item + [0] * max(0, 4 - len(item))
try:
amounts = [float(v or 0) for v in padded[:4]]
except (TypeError, ValueError, OverflowError) as exc:
raise TdxQuantError(f"get_cw_data({code}) Value[{i}] 含无效数值") from exc
if not all(math.isfinite(v) for v in amounts):
raise TdxQuantError(f"get_cw_data({code}) Value[{i}] 含非有限数值")
records.append({"Date": date_key, "Type": types[i] if i < len(types) else "",
**dict(zip(fields, amounts, strict=True))})
return records
+4 -2
View File
@@ -586,7 +586,9 @@ class TdxQuantProvider:
events = client.divid_factors(symbol, start_d, end_d + timedelta(days=_ADJ_FUTURE_DAYS))
except TdxQuantError as e:
logger.warning("TdxQuant 除权事件 %s 拉取失败: %s", symbol, e)
events = []
# 失败不能当作「该股无除权」;由任务层标记失败,整轮不写入
# 不完整因子,也避免权限/额度异常后继续请求其余数千只。
raise
events = [ev for ev in events if _compact_date(ev.get("Date")) is not None]
if events:
events.sort(key=lambda ev: str(ev.get("Date")))
@@ -610,7 +612,7 @@ class TdxQuantProvider:
raw = client.his_dats(symbol, end_d, _bars_for_window((end_d - start_d).days))
except TdxQuantError as e:
logger.warning("TdxQuant 除权配价 %s 日K失败: %s", symbol, e)
return {}
raise
out: dict[date, float] = {}
dates = raw.get("Date") or []
closes = raw.get("Close") or []
+175
View File
@@ -0,0 +1,175 @@
"""Replay native responses through the installed SDK without loading its DLL.
The SDK turns both a valid empty result and a server error into empty pandas
frames. Exercise the actual SDK boundary, then provider math and persistence.
"""
from __future__ import annotations
import json
from datetime import date, datetime
from types import SimpleNamespace
import polars as pl
import pytest
from app.plugins.tdxquant import client as tc
from app.plugins.tdxquant import provider as tp
from app.services import kline_sync
sdk = pytest.importorskip("tdxaidata.tdxaidata")
EVENT = {"ErrorId": 0, "Date": ["20240619"], "Type": ["1"],
"Value": [["2", "5", "1", "2"]]}
AUTH_ERROR = {"ErrorId": -1, "Error": "TokenKey Insufficient Or Key Error TDX-test-secret"}
@pytest.fixture
def make_client(monkeypatch):
monkeypatch.setattr(tc.time, "sleep", lambda _: None)
monkeypatch.setattr(tp, "_HIST_INTERVAL_S", 0)
def make(responses, *, close_response=None):
responses = list(responses)
calls = []
def events(*args):
calls.append(args)
value = responses.pop(0) if len(responses) > 1 else responses[0]
return json.dumps(value)
closes = close_response if close_response is not None else {
"ErrorId": 0, "Date": ["20240618", "20240619"], "Close": ["10", "8.35"],
}
native = SimpleNamespace(get_cw_data=events, get_his_dats=lambda *a: json.dumps(closes))
class ReplaySDK(sdk.tqs):
@classmethod
def _tdx(cls):
return native
client = object.__new__(tc.TdxQuantClient)
client.api_key = "TDX-test-secret"
client._tqs = ReplaySDK
client._native_cls = SimpleNamespace(get_instance=lambda: native)
return client, calls
return make
def test_event_values_and_dates_match_sdk_dataframe(make_client):
client, calls = make_client([EVENT])
expected = client._tqs.get_divid_factors("600000.SH", "2024-01-01", "2024-12-31")
assert not expected.empty # The normal nonempty DataFrame was already supported.
actual = client.divid_factors("600000.SH", date(2024, 1, 1), date(2024, 12, 31))
row = expected.reset_index().to_dict("records")[0]
row["Date"] = tp._compact_date(row["Date"])
actual[0]["Date"] = tp._compact_date(actual[0]["Date"])
assert actual == [row]
assert calls[-1] == ("600000.SH", "2024-01-01 00:00:00", "2024-12-31 00:00:00")
@pytest.mark.parametrize("payload", [{"ErrorId": 0}, {"ErrorId": 0, "Date": [], "Type": [], "Value": []}])
def test_valid_empty_sdk_frame_means_no_events(make_client, payload):
client, _ = make_client([payload])
assert client._tqs.get_divid_factors("000002.SZ").empty
assert client.divid_factors("000002.SZ") == []
def test_auth_failure_is_preserved_without_retry_or_secret_output(make_client, capsys):
client, calls = make_client([AUTH_ERROR])
with pytest.raises(tc.TdxQuantAuthError, match=r"凭据|额度") as caught:
client.divid_factors("000002.SZ")
assert len(calls) == 1
assert "TDX-test-secret" not in str(caught.value)
assert "TDX-test-secret" not in capsys.readouterr().out
def test_transient_server_error_retries_and_recovers(make_client):
client, calls = make_client([{"ErrorId": 500, "Error": "server busy"}, EVENT])
assert len(client.divid_factors("600000.SH")) == 1
assert len(calls) == 2
@pytest.mark.parametrize("payload", [None, [], {"unexpected": 1},
{"Date": ["20240619"], "Type": ["1"], "Value": []},
{"Date": ["20240619"], "Type": ["1"], "Value": [["invalid", 0, 0, 0]]},
{"Date": ["20240619"], "Type": ["1"], "Value": [["NaN", 0, 0, 0]]},
{"Date": ["20240619"], "Type": ["1"], "Value": [["Infinity", 0, 0, 0]]},
])
def test_malformed_response_remains_a_failure(make_client, payload):
client, _ = make_client([payload])
with pytest.raises(tc.TdxQuantError):
client.divid_factors("600000.SH")
def test_date_window_is_inclusive_and_empty_window_is_valid(make_client):
client, _ = make_client([EVENT])
assert len(client.divid_factors("600000.SH", date(2024, 6, 19), date(2024, 6, 19))) == 1
assert client.divid_factors("600000.SH", date(2024, 6, 20)) == []
def test_normalization_matches_sdk_without_pandas(make_client, monkeypatch):
monkeypatch.setattr(sdk, "pd", None)
client, _ = make_client([EVENT])
assert client.divid_factors("600000.SH") == client._tqs.get_divid_factors("600000.SH")
def route_to(client, monkeypatch):
from app.data_providers import custom
provider = tp.TdxQuantProvider()
provider._client = client
monkeypatch.setattr(kline_sync.preferences, "get_adj_factor_provider", lambda: "tdxquant")
monkeypatch.setattr(custom, "provider_has_dataset", lambda name, dataset: True)
monkeypatch.setattr(custom, "get_provider", lambda name: provider)
monkeypatch.setattr(kline_sync, "get_client", lambda: pytest.fail("不得跨源请求 TickFlow"))
return provider
def test_events_flow_to_parquet_with_existing_per_share_formula(make_client, monkeypatch, tmp_path):
client, _ = make_client([EVENT])
route_to(client, monkeypatch)
repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
added, affected = kline_sync.sync_adj_factor(
["600000.SH"], repo, SimpleNamespace(has=lambda _: False),
start_time=datetime(2024, 1, 1), end_time=datetime(2024, 12, 31),
)
assert added == 1 and affected == ["600000.SH"]
row = pl.read_parquet(tmp_path / "adj_factor/all.parquet").row(0, named=True)
assert row["trade_date"] == date(2024, 6, 19)
assert row["dividend"] == pytest.approx(0.2)
assert row["bonus"] == pytest.approx(0.1)
assert row["allot"] == pytest.approx(0.2)
assert row["allot_price"] == 5.0
assert row["prev_close"] == 10.0
assert row["ex_factor"] == pytest.approx(10.0 / 8.31)
def test_partial_failure_keeps_existing_factors_and_stops_remaining_symbols(make_client, monkeypatch, tmp_path):
client, calls = make_client([EVENT, AUTH_ERROR])
route_to(client, monkeypatch)
path = tmp_path / "adj_factor/all.parquet"
path.parent.mkdir()
pl.DataFrame({"symbol": ["600000.SH"], "trade_date": [date(2023, 1, 1)], "ex_factor": [1.02]}).write_parquet(path)
before = path.read_bytes()
repo = SimpleNamespace(store=SimpleNamespace(data_dir=tmp_path))
with pytest.raises(tc.TdxQuantAuthError):
kline_sync.sync_adj_factor(["600000.SH", "000002.SZ", "600519.SH"], repo, None,
end_time=datetime(2024, 12, 31))
assert len(calls) == 2
assert path.read_bytes() == before
def test_adj_dataset_probe_exposes_server_failure(make_client, monkeypatch):
client, calls = make_client([AUTH_ERROR])
provider = route_to(client, monkeypatch)
result = provider.test_dataset("adj_factor", ["000002.SZ"])
assert result["rows"] == 0 and "凭据" in result["error"]
assert len(calls) == 1
def test_price_lookup_failure_is_not_reported_as_successful_zero_rows(make_client, monkeypatch):
client, _ = make_client([EVENT], close_response=AUTH_ERROR)
provider = route_to(client, monkeypatch)
with pytest.raises(tc.TdxQuantAuthError):
provider.get_adj_factors(["600000.SH"], None, datetime(2024, 12, 31))