Files
easy-tdx/tests/unit/test_mac_qfq_integration.py
Justin Gu 648c9ccfaf style: ruff format 修复预存格式债(CI ruff format --check 转绿)
v1.17.2 引入的 QFQ 重算代码(adjust.py/client.py/test_mac_qfq_integration.py)
含 magic trailing comma 导致 ruff format 期望多行参数展开,CI 的
'ruff format --check src/ tests/' 自 v1.17.0 起一直失败。纯格式变更,
零逻辑改动(20 例 QFQ 测试全过)。
2026-07-04 00:17:06 +08:00

185 lines
5.9 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""QFQ 本地重算的集成测试(monkeypatch,无 live server)。
验证 ``MacClient.get_stock_kline(adjust=QFQ)`` 在服务端返回负价时:
1. 触发 NONE 重抓 + XDXR 本地重算;
2. 结果全部为正、OHLC 同比缩放;
3. XDXR 取不到时降级返回原始(含负价)数据,不抛异常。
"""
from __future__ import annotations
from datetime import datetime
from unittest.mock import patch
import pandas as pd
from easy_tdx.mac.client import MacClient
from easy_tdx.mac.commands.symbol_bar import SymbolBarCmd
from easy_tdx.mac.enums import Adjust, Period
from easy_tdx.mac.models import MacBar
def _bar(dt: str, close: float, fq: Adjust = Adjust.NONE) -> MacBar:
"""构造单根 MacBarOHLC 全等于 close。"""
d = datetime.fromisoformat(dt)
return MacBar(
datetime=d, open=close, high=close, low=close, close=close, vol=100.0, amount=1000.0
)
def _none_bars() -> list[MacBar]:
"""干净的 NONE 序列:除权日前 close=10,除权日 close=8(跌去 2 元分红),之后 9。"""
return [
_bar("2024-01-01", 10.0),
_bar("2024-01-02", 10.0), # cum-div
_bar("2024-01-03", 8.0), # ex-date
_bar("2024-01-04", 9.0),
]
def _qfq_broken_bars() -> list[MacBar]:
"""模拟服务端 QFQ 异常:除权日及之前返回负价。"""
return [
_bar("2024-01-01", -4.0, Adjust.QFQ),
_bar("2024-01-02", -4.0, Adjust.QFQ),
_bar("2024-01-03", 8.0, Adjust.QFQ),
_bar("2024-01-04", 9.0, Adjust.QFQ),
]
def _xdxr_df() -> pd.DataFrame:
"""单条除权除息记录:fenhong=2.0(除权日 2024-01-03)。"""
return pd.DataFrame(
[
{
"date": "2024-01-03",
"category": 1,
"fenhong": 2.0,
"peigujia": None,
"songzhuangu": None,
"peigu": None,
}
]
)
def _make_client() -> MacClient:
"""构造未连接的 MacClient(仅用于调用 _execute mock 路径)。"""
client = MacClient.__new__(MacClient)
client._xdxr_cache = {}
client._timeout = 10.0
return client
def test_qfq_negative_triggers_local_recompute():
"""服务端 QFQ 返回负价 → 用 NONE+XDXR 重算,结果全正。"""
client = _make_client()
def fake_execute(cmd: SymbolBarCmd) -> list[MacBar]:
return _qfq_broken_bars() if cmd._fq == Adjust.QFQ else _none_bars()
with (
patch.object(client, "_execute", side_effect=fake_execute),
patch("easy_tdx.client.TdxClient") as MockTdx,
):
# 让 TdxClient 上下文返回手构 XDXR
mock_inst = MockTdx.from_best_host.return_value.__enter__.return_value
mock_inst.get_xdxr_info.return_value = _xdxr_df()
df = client.get_stock_kline(
market=1,
code="601088",
period=Period.DAILY,
start=0,
count=4,
adjust=Adjust.QFQ,
)
# 重算后全部为正
assert (df["close"] > 0).all(), df["close"].tolist()
# f=(10-2)/10=0.8 → 除权日前两根 *= 0.8 = 8.0ex-date 及之后不动
assert df["close"].tolist() == [8.0, 8.0, 8.0, 9.0]
# OHLC 同比缩放(open 也应被缩放)
assert df["open"].tolist() == [8.0, 8.0, 8.0, 9.0]
def test_qfq_clean_does_not_trigger_recompute():
"""服务端 QFQ 正常(无负价)→ 不触发重算,原样返回。"""
client = _make_client()
clean_qfq = [
_bar("2024-01-01", 8.0, Adjust.QFQ),
_bar("2024-01-02", 8.0, Adjust.QFQ),
_bar("2024-01-03", 8.0, Adjust.QFQ),
_bar("2024-01-04", 9.0, Adjust.QFQ),
]
with (
patch.object(client, "_execute", return_value=clean_qfq) as mock_exec,
patch("easy_tdx.client.TdxClient") as MockTdx,
):
df = client.get_stock_kline(
market=1,
code="601088",
period=Period.DAILY,
start=0,
count=4,
adjust=Adjust.QFQ,
)
# QFQ 干净时不应再去拉 XDXR
MockTdx.from_best_host.assert_not_called()
assert df["close"].tolist() == [8.0, 8.0, 8.0, 9.0]
# 只拉了一次(QFQ),没有第二次拉 NONE
assert mock_exec.call_count == 1
def test_qfq_recompute_xdxr_failure_degrades_gracefully():
"""XDXR 取不到 → 降级返回 NONE 数据(不再含负价),不抛异常。"""
client = _make_client()
def fake_execute(cmd: SymbolBarCmd) -> list[MacBar]:
return _qfq_broken_bars() if cmd._fq == Adjust.QFQ else _none_bars()
with (
patch.object(client, "_execute", side_effect=fake_execute),
patch("easy_tdx.client.TdxClient") as MockTdx,
):
# XDXR 抛异常 → _fetch_xdxr_records 返回 None → 降级
mock_inst = MockTdx.from_best_host.return_value.__enter__.return_value
mock_inst.get_xdxr_info.side_effect = RuntimeError("host unreachable")
df = client.get_stock_kline(
market=1,
code="601088",
period=Period.DAILY,
start=0,
count=4,
adjust=Adjust.QFQ,
)
# 降级:返回 NONE 数据(apply_forward_adjust 因 xd=None 原样返回 df
# df 是 NONE 重抓结果(全正),但未做前复权
assert (df["close"] > 0).all()
assert df["close"].tolist() == [10.0, 10.0, 8.0, 9.0]
def test_none_adjust_skips_recompute():
"""adjust=NONE 时完全跳过 QFQ 重算逻辑。"""
client = _make_client()
with (
patch.object(client, "_execute", return_value=_none_bars()) as mock_exec,
patch("easy_tdx.client.TdxClient") as MockTdx,
):
df = client.get_stock_kline(
market=1,
code="601088",
period=Period.DAILY,
start=0,
count=4,
adjust=Adjust.NONE,
)
MockTdx.from_best_host.assert_not_called()
assert df["close"].tolist() == [10.0, 10.0, 8.0, 9.0]
assert mock_exec.call_count == 1