release: v1.17.2 — QFQ 深层历史负价修复

通达信服务端 QFQ 模式对长期重度除权股票(如 601088)深层历史页
返回负价格,导致回测总收益 -3087%、回撤 326.85%、年化 nan、
bollinger 崩溃、10 策略 invalid-value-in-scalar-power、MyTT divide-by-zero。

客户端兜底:检测 QFQ 负价时用 NONE+XDXR 本地重算前复权
(因子以除权日前一交易日含权收盘价为基准,保证除权日前后连续)。
同步+异步双路径一致修复,失败降级返回原值。

- 新增 src/easy_tdx/mac/adjust.py(纯函数 compute_forward_factor/
  apply_forward_adjust/has_bad_prices)
- MacClient/AsyncMacClient 触发本地重算,XDXR 按 (market,code) 缓存
- tests: +20 例(16 纯函数 + 4 集成),844 全绿,ruff/mypy 通过
This commit is contained in:
Justin Gu
2026-07-03 22:42:04 +08:00
parent 9245bb2ce9
commit c15bd8232f
6 changed files with 754 additions and 26 deletions
+209
View File
@@ -0,0 +1,209 @@
"""本地前复权(QFQ)重算纯函数的单元测试。
覆盖 ``easy_tdx.mac.adjust`` 的因子计算与 OHLC 缩放,重点验证:
- 除权日前后价格连续(前复权的定义性性质);
- 最新价锚定不动;
- 无事件 / 非法因子时安全降级。
公式见 ``examples/06_finance/xdxr_info.py`` 与 ``src/easy_tdx/mac/adjust.py``。
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from easy_tdx.mac.adjust import (
apply_forward_adjust,
compute_forward_factor,
has_bad_prices,
)
def _kline(closes: list[float], start: str = "2024-01-01") -> pd.DataFrame:
"""构造最小 NONE K 线:OHLC 全等于给定 close 序列,逐日递增。"""
n = len(closes)
dates = pd.date_range(start, periods=n, freq="D")
arr = np.array(closes, dtype=float)
return pd.DataFrame(
{
"datetime": dates,
"open": arr,
"high": arr,
"low": arr,
"close": arr,
"vol": [100.0] * n,
}
)
def _xdxr_one(date: str, fenhong=0.0, peigujia=0.0, songzhuangu=0.0, peigu=0.0) -> pd.DataFrame:
"""构造单条 category==1 除权除息记录。"""
return pd.DataFrame(
[
{
"date": date,
"category": 1,
"fenhong": fenhong,
"peigujia": peigujia,
"songzhuangu": songzhuangu,
"peigu": peigu,
}
]
)
# --------------------------------------------------------------------------- #
# compute_forward_factor
# --------------------------------------------------------------------------- #
def test_factor_pure_cash_dividend():
"""纯现金分红:f = (P - fh) / P。"""
# P=10, fh=2 → f=0.8(前复权把含权价缩到除权后等价)
assert compute_forward_factor(10.0, 2.0, 0.0, 0.0, 0.0) == 0.8
def test_factor_songzhuangu_split():
"""10 送 10songzhuangu=1.0):f = P / (P*2) = 0.5。"""
assert compute_forward_factor(10.0, 0.0, 0.0, 1.0, 0.0) == 0.5
def test_factor_zero_cum_close_is_nan():
"""cum_close<=0 返回 NaN(不抛)。"""
assert np.isnan(compute_forward_factor(0.0, 1.0, 0.0, 0.0, 0.0))
assert np.isnan(compute_forward_factor(-1.0, 1.0, 0.0, 0.0, 0.0))
def test_factor_zero_denominator_is_nan():
"""分母 = P*(1+s+p) 为 0 时返回 NaN。"""
# P=10, s=-1, p=0 → denom=0
assert np.isnan(compute_forward_factor(10.0, 0.0, 0.0, -1.0, 0.0))
# --------------------------------------------------------------------------- #
# apply_forward_adjust
# --------------------------------------------------------------------------- #
def test_apply_pure_cash_dividend_scales_pre_event_only():
"""纯分红:除权日及之前价格乘 f,除权日之后不动。"""
# 3 根:cum-div close=10(含权),ex-date close=8(跌去 2 元分红),之后 9
# 事件在 day2ex-date),cum-div bar 是 day1
df = _kline([10.0, 10.0, 8.0, 9.0])
xd = _xdxr_one("2024-01-03", fenhong=2.0) # ex-date = 第 3 天
out = apply_forward_adjust(df, xd)
# f = (10-2)/10 = 0.8 → day1/day2 (cum 及之前) *= 0.8
assert out["close"].tolist() == [8.0, 8.0, 8.0, 9.0]
def test_apply_latest_price_anchored():
"""最新价(最后一根)不被缩放,保持原值。"""
df = _kline([20.0, 10.0, 11.0])
xd = _xdxr_one("2024-01-02", fenhong=10.0) # ex-date=day2, cum-div=day1 close=20
out = apply_forward_adjust(df, xd)
# f=(20-10)/20=0.5 → day1*=0.5; day2/day3 不动
assert out["close"].iloc[-1] == 11.0
assert out["close"].iloc[0] == 10.0
def test_apply_no_events_returns_unchanged():
"""空 XDXR 或无 category==1 → 原样返回(值相等)。"""
df = _kline([10.0, 11.0, 12.0])
out = apply_forward_adjust(df, pd.DataFrame(columns=["date", "category"]))
assert out["close"].tolist() == [10.0, 11.0, 12.0]
def test_apply_empty_xdxr_df():
"""XDXR 为 None 或 empty → 原样返回。"""
df = _kline([10.0, 11.0])
assert apply_forward_adjust(df, pd.DataFrame()).equals(df)
assert apply_forward_adjust(df, None).equals(df) # type: ignore[arg-type]
def test_apply_nan_factor_event_skipped():
"""事件对应 cum_close<=0(因子非法)→ 跳过该事件,不抛异常。"""
# day1 close=0(非法 cum-div),事件在 day2
df = _kline([0.0, 5.0, 6.0])
xd = _xdxr_one("2024-01-02", fenhong=1.0)
out = apply_forward_adjust(df, xd) # 不应抛
# cum-div bar (day1) close=0 → 因子 NaN → 跳过,close 不变
assert out["close"].tolist() == [0.0, 5.0, 6.0]
def test_apply_multiple_events_cumulative():
"""两次连续分红:因子累乘。"""
# day1=20(cum1), day2 ex-date1, day3=12(cum2), day4 ex-date2, day5=10
df = _kline([20.0, 18.0, 12.0, 10.0, 10.0])
xd = pd.DataFrame(
[
{
"date": "2024-01-02",
"category": 1,
"fenhong": 2.0,
"peigujia": 0.0,
"songzhuangu": 0.0,
"peigu": 0.0,
},
{
"date": "2024-01-04",
"category": 1,
"fenhong": 2.0,
"peigujia": 0.0,
"songzhuangu": 0.0,
"peigu": 0.0,
},
]
)
out = apply_forward_adjust(df, xd)
# event1: cum1=day1=20, f1=(20-2)/20=0.9 → day1*=0.9 → 18.0
# event2: cum2=day3=12, f2=(12-2)/12=0.8333 → day1..day3 *= 0.8333
# day1: 18.0 * 0.8333 = 15.0 ; day3: 12.0*0.8333=10.0
# day4/day5 不动
assert round(out["close"].iloc[0], 4) == 15.0
assert round(out["close"].iloc[2], 4) == 10.0
assert out["close"].iloc[3] == 10.0
assert out["close"].iloc[4] == 10.0
def test_apply_songzhuangu_halves_pre_event_prices():
"""10 送 10:除权日前价格减半。"""
df = _kline([20.0, 20.0, 10.0, 11.0])
xd = _xdxr_one("2024-01-03", songzhuangu=1.0)
out = apply_forward_adjust(df, xd)
# cum-div=day2=20, f=20/(20*2)=0.5 → day1/day2 *= 0.5
assert out["close"].tolist() == [10.0, 10.0, 10.0, 11.0]
def test_apply_does_not_mutate_input():
"""apply_forward_adjust 不就地修改输入 DataFrame。"""
df = _kline([10.0, 10.0, 8.0])
original = df["close"].tolist()
xd = _xdxr_one("2024-01-03", fenhong=2.0)
apply_forward_adjust(df, xd)
assert df["close"].tolist() == original
# --------------------------------------------------------------------------- #
# has_bad_prices
# --------------------------------------------------------------------------- #
def test_has_bad_prices_detects_negative():
df = pd.DataFrame({"open": [1.0], "high": [1.0], "low": [-0.5], "close": [1.0]})
assert has_bad_prices(df) is True
def test_has_bad_prices_detects_zero():
df = pd.DataFrame({"open": [0.0], "high": [1.0], "low": [1.0], "close": [1.0]})
assert has_bad_prices(df) is True
def test_has_bad_prices_detects_nan():
df = pd.DataFrame({"open": [1.0], "high": [1.0], "low": [1.0], "close": [float("nan")]})
assert has_bad_prices(df) is True
def test_has_bad_prices_clean_returns_false():
df = pd.DataFrame({"open": [1.0], "high": [2.0], "low": [0.5], "close": [1.5]})
assert has_bad_prices(df) is False
+160
View File
@@ -0,0 +1,160 @@
"""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