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
+19
View File
@@ -2,6 +2,25 @@
本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。
## [1.17.2] — 2026-07-03
**QFQ 深层历史负价修复** —— 修复通达信服务端在前复权(QFQ)模式下对长期重度除权股票(如 601088 中远海控)深层历史页直接返回**负价格**的上游缺陷,导致回测出现总收益 -3087%、最大回撤 326.85%、年化 nan%、`bollinger_breakout` 崩溃(`ZeroDivisionError`)、10 个策略报 `invalid value in scalar power``MyTT``divide by zero` 等一连串症状。**844 单测全绿**+20),ruff / mypy strict 通过。
### 修复
- **QFQ 深层历史返回负价**`mac/commands/symbol_bar.py``mac/client.py``mac/adjust.py`)— 根因:通达信 MAC 服务端在 QFQ 模式下,对 601088 这类长期重度除权股票的深层历史页(`start` 偏移 > ~2100)直接返回负价格(如 2013-11-18 QFQ close=-3.80,而 NONE=16.58、HFQ=27.17 均正常)。`SymbolBarCmd` 原样解析,污染下游全部计算:负 close → `position_value = size*close < 0` → 总权益为负 → 回撤 >100%、`total_return < -1``(1+total_return)` 为负 → 分数次幂 = nan;同时 BOLL 指标在零价处触发 `cash/0` 崩溃。**非 easy_tdx 代码 bug,是上游数据缺陷。**
- 修复:客户端兜底——`MacClient.get_stock_kline` 检测到 QFQ 结果含 `<=0` / NaN / inf 时,用 `fq=NONE` 重抓原始价,再经 `TdxClient.get_xdxr_info`(连 `get_known_hosts` 主机池,按 `(market,code)` 缓存)拉除权除息记录,本地重算前复权。同步 + 异步(`AsyncMacClient`)双路径一致修复。
- 公式(经实证验证):以**除权日前一交易日收盘价**(含权价 `P_cum`)为基准,前复权因子 `f = (P_cum - fenhong + peigujia×peigu) / (P_cum×(1+songzhuangu+peigu))`,乘到该日及之前所有 bar 的 OHLC。该约定保证除权日前后价格连续(验证 jump≈0%),若误用除权日收盘价则 jump 达 -8%~-13%。
- 降级:XDXR 取不到或重算后仍含非法价格时,打 warning 返回原值(不比现状更坏)。
- 验证:重跑 `run_all_strategies.py SH 601088 --count 3000 --adjust QFQ`,16 策略全绿,总收益落 [-33%, +430%],最大回撤 [25%, 67%],年化全有限,无任何 warning/nan/崩溃。
- 新增纯函数模块 `mac/adjust.py``compute_forward_factor` / `apply_forward_adjust` / `has_bad_prices`),无网络依赖便于单测。
### 新增
- **QFQ 本地重算纯函数**`mac/adjust.py`)— `compute_forward_factor`(单次除权因子)、`apply_forward_adjust`(OHLC 同比缩放,最新价锚定不动,多次事件累乘)、`has_bad_prices`(检测 <=0/NaN/inf)。纯 pandas/numpy,无 easy_tdx 内部依赖。
- **QFQ 重算单测**`tests/unit/test_mac_qfq_adjust.py`,16 例)— 覆盖纯现金分红、送转股、多次事件累乘、无事件原样返回、非法因子跳过、输入不可变、最新价锚定、`has_bad_prices` 各分支。
- **QFQ 重算集成测试**`tests/unit/test_mac_qfq_integration.py`4 例)— monkeypatch `MacClient._execute` 返回含负价的 QFQ + mock `TdxClient.get_xdxr_info` 返回 XDXR,验证触发重算、干净 QFQ 不触发、XDXR 失败降级、NONE 跳过重算。无 live server。
## [1.17.0] — 2026-07-03
**回测可视化 Web UI 大版本** —— 从命令行回测升级到浏览器可视化。Vue3 + ECharts 单页应用,零代码完成单标的回测、组合回测、参数寻优、结果对比四大场景。后端新增回测 REST API + 策略注册表 + 后台任务执行器,内置策略从 5 个扩充到 18 个。**823 单测全绿**ruff / mypy strict / vue-tsc 全部通过。
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "easy-tdx"
version = "1.17.0"
version = "1.17.2"
description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步"
readme = "README.md"
requires-python = ">=3.10"
+174
View File
@@ -0,0 +1,174 @@
"""本地前复权(QFQ)重算。
通达信 MAC 服务端在 QFQ 模式下,对长期重度除权股票的深层历史页会返回
负价格(上游缺陷)。本模块用 NONE(未复权)K 线 + XDXR(除权除息)记录
在客户端本地重算前复权序列,作为服务端 QFQ 异常时的兜底。
公式(见 ``examples/06_finance/xdxr_info.py``::
复权价 = (原价 - 每股分红 + 每股配股价 × 每股配股比例) /
(1 + 每股送转股比例 + 每股配股比例)
约定:以除权日**前一交易日**的收盘价(含权价 ``P_cum``)作为基准,前复权
因子把该日及之前的价格乘以::
f = (P_cum - fenhong + peigujia × peigu) / (P_cum × (1 + songzhuangu + peigu))
这样调整后的价格在除权日前后连续(除权日开盘价 ≈ 含权收盘价 - 分红)。
最新价不动(锚定最新)。fenhong/songzhuangu/peigu 为每股单位,peigujia 为元/股。
"""
from __future__ import annotations
import logging
import numpy as np
import pandas as pd
_logger = logging.getLogger(__name__)
# 前复权会同比缩放的 OHLC 列名(vol/amount 不动)
_OHLC_COLS = ("open", "high", "low", "close")
def compute_forward_factor(
cum_close: float,
fenhong: float,
peigujia: float,
songzhuangu: float,
peigu: float,
) -> float:
"""计算单次除权除息事件的前复权乘子。
Args:
cum_close: 除权日前一交易日的 NONE 收盘价(含权价)。
fenhong: 每股分红(元)。
peigujia: 每股配股价(元/股)。
songzhuangu: 每股送转股比例(如 0.1 = 10 送/转 1)。
peigu: 每股配股比例。
Returns:
前复权因子。若输入非法(cum_close<=0、分母为 0、结果非有限)返回 NaN。
"""
if cum_close <= 0:
return float("nan")
denom = cum_close * (1.0 + songzhuangu + peigu)
if denom == 0:
return float("nan")
factor = (cum_close - fenhong + peigujia * peigu) / denom
if not np.isfinite(factor):
return float("nan")
return float(factor)
def apply_forward_adjust(
df: pd.DataFrame,
xdxr_df: pd.DataFrame,
) -> pd.DataFrame:
"""对 NONE K 线应用前复权,返回新的 DataFrame。
遍历 XDXR 中 category==1(除权除息)的事件,按日期升序,把每个事件
的因子累乘到该除权日**前一交易日及之前**所有 bar 的 OHLC。最新价锚定不动。
Args:
df: NONE K 线,必须含 ``datetime`` 列与 OHLC 列。
xdxr_df: ``get_xdxr_info`` 返回的 DataFrame,含 ``date``、``category``、
``fenhong``、``peigujia``、``songzhuangu``、``peigu`` 列。
Returns:
前复权后的 DataFrame(与输入同形状、同列、同索引)。无事件或异常时
原样返回。
"""
out = df.copy()
ohlc_cols = [c for c in _OHLC_COLS if c in out.columns]
if not ohlc_cols or "datetime" not in out.columns or xdxr_df is None or xdxr_df.empty:
return out
# 统一 datetime 为 pandas Timestamp(升序)
dt = pd.to_datetime(out["datetime"])
if not dt.is_monotonic_increasing:
order = np.argsort(dt.to_numpy())
out = out.iloc[order].reset_index(drop=True)
dt = pd.to_datetime(out["datetime"])
dt_arr = dt.to_numpy()
# 筛选 category==1 且至少有一个非空除权字段的事件
if "category" not in xdxr_df.columns or "date" not in xdxr_df.columns:
return out
cat1 = xdxr_df[xdxr_df["category"] == 1]
events: list[tuple[pd.Timestamp, float, float, float, float]] = []
for _, r in cat1.iterrows():
fh = _to_float(r.get("fenhong"))
pjk = _to_float(r.get("peigujia"))
sz = _to_float(r.get("songzhuangu"))
pg = _to_float(r.get("peigu"))
if fh is None and pjk is None and sz is None and pg is None:
continue
try:
ed = pd.Timestamp(str(r["date"]))
except (ValueError, TypeError):
continue
events.append((ed, fh or 0.0, pjk or 0.0, sz or 0.0, pg or 0.0))
if not events:
return out
events.sort(key=lambda e: e[0])
# 取除权日前一交易日的 NONE 收盘价(cum-div close)作为基准
none_close = out["close"].to_numpy(dtype=float) if "close" in out.columns else None
for col in ohlc_cols:
arr = out[col].to_numpy(dtype=float).copy()
for ed, fh, pjk, sz, pg in events:
# searchsorted(>=): 第一个 >= ex-date 的位置;其前一根即为含权收盘
idx = int(np.searchsorted(dt_arr, np.datetime64(ed), side="left"))
cum_idx = idx - 1
if cum_idx < 0 or cum_idx >= len(arr):
continue
if none_close is not None:
cum_close = float(none_close[cum_idx])
else:
cum_close = float(arr[cum_idx])
factor = compute_forward_factor(cum_close, fh, pjk, sz, pg)
if not np.isfinite(factor):
_logger.warning(
"QFQ 本地重算:事件 %s 因子非法(cum_close=%s fh=%s sz=%s pg=%s),跳过",
ed.date(), cum_close, fh, sz, pg,
)
continue
arr[: cum_idx + 1] *= factor
out[col] = arr
return out
def _to_float(v: object) -> float | None:
"""安全转 floatNone/NaN 返回 None。"""
if v is None:
return None
try:
f = float(v) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
if not np.isfinite(f):
return None
return f
def has_bad_prices(df: pd.DataFrame) -> bool:
"""检测 QFQ 结果是否含非法价格(<=0 或非有限值)。
Args:
df: 待检测的 K 线 DataFrame。
Returns:
任一 OHLC 列含 <=0 或 NaN/inf 时返回 True。
"""
for col in _OHLC_COLS:
if col not in df.columns:
continue
arr = df[col].to_numpy(dtype=float)
if not np.all(np.isfinite(arr)):
return True
if np.any(arr <= 0):
return True
return False
+191 -25
View File
@@ -153,6 +153,9 @@ class MacClient:
self._auto_reconnect = auto_reconnect
self._heartbeat_interval = heartbeat_interval
self._conn = TdxConnection(self._host, self._port, self._timeout)
# XDXR(除权除息)记录缓存:(market, code) -> DataFrame。
# 仅在服务端 QFQ 返回异常(负价)时用于本地前复权重算。
self._xdxr_cache: dict[tuple[int, str], pd.DataFrame] = {}
# ------------------------------------------------------------------ #
# 工厂方法
@@ -327,6 +330,102 @@ class MacClient:
return _quotes_to_df(all_quotes)
# ------------------------------------------------------------------ #
# QFQ 本地重算(服务端 QFQ 对深层历史返回负价时的兜底)
# ------------------------------------------------------------------ #
def _fetch_kline_pages(
self,
market: int,
code: str,
period: Period,
start: int,
count: int,
times: int,
fq: Adjust,
) -> list[MacBar]:
"""分页拉取指定复权类型的 K 线(返回 oldest→newest 的 MacBar 列表)。"""
all_bars: list[MacBar] = []
fetched = 0
offset = start
while fetched < count:
page_size = min(count - fetched, _KLINE_PAGE_SIZE)
bars = self._execute(
SymbolBarCmd(
market=market,
code=code,
period=period,
times=times,
start=offset,
count=page_size,
fq=fq,
)
)
if not bars:
break
all_bars = bars + all_bars
fetched += len(bars)
offset += len(bars)
if len(bars) < page_size:
break
return all_bars
def _fetch_xdxr_records(self, market: int, code: str) -> pd.DataFrame | None:
"""通过主协议客户端(TdxClient)拉取除权除息记录。
MAC 主机池不响应 XDXR0x0c1f),需连 get_known_hosts 主机池。
结果按 (market, code) 缓存。失败返回 None(调用方降级)。
"""
key = (market, code)
if key in self._xdxr_cache:
return self._xdxr_cache[key]
try:
# 函数内 import 避免循环依赖(client 依赖 macmac 不应依赖 client
from .. import Market
from ..client import TdxClient
with TdxClient.from_best_host(timeout=self._timeout) as tc:
xd = tc.get_xdxr_info(Market(market), code)
except Exception as exc: # noqa: BLE001 - 降级,不中断 kline 获取
_logger.warning(
"QFQ 本地重算:获取 %s %s XDXR 失败,降级返回服务端 QFQ:%s", market, code, exc,
)
return None
if xd is None or xd.empty:
return None
self._xdxr_cache[key] = xd
return xd
def _local_recompute_qfq(
self,
df: pd.DataFrame,
market: int,
code: str,
) -> pd.DataFrame:
"""对 QFQ 异常的 K 线用 NONE + XDXR 本地重算前复权。
Args:
df: 服务端 QFQ 结果(含异常)。
market: 市场代码。
code: 股票代码。
Returns:
重算后的 DataFrame;XDXR 取不到或重算仍异常时原样返回 df。
"""
from .adjust import apply_forward_adjust, has_bad_prices
xd = self._fetch_xdxr_records(market, code)
if xd is None:
return df
out = apply_forward_adjust(df, xd)
if has_bad_prices(out):
_logger.warning("QFQ 本地重算后 %s %s 仍含非法价格,降级返回服务端 QFQ", market, code)
return df
_logger.warning(
"QFQ 本地重算:%s %s 服务端深层历史返回负价,已用 NONE+XDXR 重算前复权", market, code,
)
return out
# ------------------------------------------------------------------ #
# K 线(支持复权)
# ------------------------------------------------------------------ #
@@ -358,32 +457,21 @@ class MacClient:
(= 开始 + 周期时长,与 Tushare/同花顺对齐,上午最后一根标 11:30)。
仅对分钟级周期生效;日线及以上不受影响。
"""
all_bars: list[MacBar] = []
fetched = 0
offset = start
while fetched < count:
page_size = min(count - fetched, _KLINE_PAGE_SIZE)
bars = self._execute(
SymbolBarCmd(
market=market,
code=code,
period=period,
times=times,
start=offset,
count=page_size,
fq=adjust,
)
)
if not bars:
break
all_bars = bars + all_bars
fetched += len(bars)
offset += len(bars)
if len(bars) < page_size:
break
all_bars = self._fetch_kline_pages(market, code, period, start, count, times, adjust)
df = _to_df(all_bars)
# QFQ 兜底:服务端对深层历史可能返回负价/零价,此时用 NONE+XDXR 本地重算。
if adjust == Adjust.QFQ and not df.empty:
from .adjust import has_bad_prices
if has_bad_prices(df):
none_bars = self._fetch_kline_pages(
market, code, period, start, count, times, Adjust.NONE
)
df = _to_df(none_bars) if none_bars else df
if not df.empty:
df = self._local_recompute_qfq(df, market, code)
delta = _period_to_minutes(period, times)
is_intraday = delta is not None
return _apply_bar_time_align_df(
@@ -1071,6 +1159,9 @@ class AsyncMacClient(AsyncHeartbeatMixin):
self._conn = AsyncTdxConnection(self._host, self._port, self._timeout)
self._execute_lock = asyncio.Lock()
self._heartbeat_task: asyncio.Task[None] | None = None
# XDXR(除权除息)记录缓存:(market, code) -> DataFrame。
# 仅在服务端 QFQ 返回异常(负价)时用于本地前复权重算。
self._xdxr_cache: dict[tuple[int, str], pd.DataFrame] = {}
# ------------------------------------------------------------------ #
# 工厂方法
@@ -1233,6 +1324,50 @@ class AsyncMacClient(AsyncHeartbeatMixin):
return _quotes_to_df(all_quotes)
# ------------------------------------------------------------------ #
# QFQ 本地重算(服务端 QFQ 对深层历史返回负价时的兜底)
# 同步方法:XDXR 经 TdxClient(同步主协议)获取,由 asyncio.to_thread 调用。
# ------------------------------------------------------------------ #
def _fetch_xdxr_records(self, market: int, code: str) -> pd.DataFrame | None:
"""通过主协议客户端(TdxClient)拉取除权除息记录(同 MacClient)。"""
key = (market, code)
if key in self._xdxr_cache:
return self._xdxr_cache[key]
try:
from .. import Market
from ..client import TdxClient
with TdxClient.from_best_host(timeout=self._timeout) as tc:
xd = tc.get_xdxr_info(Market(market), code)
except Exception as exc: # noqa: BLE001 - 降级,不中断 kline 获取
_logger.warning(
"QFQ 本地重算:获取 %s %s XDXR 失败,降级返回服务端 QFQ:%s", market, code, exc,
)
return None
if xd is None or xd.empty:
return None
self._xdxr_cache[key] = xd
return xd
def _local_recompute_qfq(
self, df: pd.DataFrame, market: int, code: str,
) -> pd.DataFrame:
"""对 QFQ 异常的 K 线用 NONE+XDXR 本地重算前复权(同 MacClient)。"""
from .adjust import apply_forward_adjust, has_bad_prices
xd = self._fetch_xdxr_records(market, code)
if xd is None:
return df
out = apply_forward_adjust(df, xd)
if has_bad_prices(out):
_logger.warning("QFQ 本地重算后 %s %s 仍含非法价格,降级返回服务端 QFQ", market, code)
return df
_logger.warning(
"QFQ 本地重算:%s %s 服务端深层历史返回负价,已用 NONE+XDXR 重算前复权", market, code,
)
return out
# ------------------------------------------------------------------ #
# K 线
# ------------------------------------------------------------------ #
@@ -1276,6 +1411,37 @@ class AsyncMacClient(AsyncHeartbeatMixin):
break
df = _to_df(all_bars)
# QFQ 兜底:服务端对深层历史可能返回负价/零价,此时用 NONE+XDXR 本地重算。
if adjust == Adjust.QFQ and not df.empty:
from .adjust import has_bad_prices
if has_bad_prices(df):
# 异步重抓 NONE
none_bars: list[MacBar] = []
nfetched = 0
noffset = start
while nfetched < count:
nps = min(count - nfetched, _KLINE_PAGE_SIZE)
nb = await self._execute(
SymbolBarCmd(
market=market, code=code, period=period, times=times,
start=noffset, count=nps, fq=Adjust.NONE,
)
)
if not nb:
break
none_bars = nb + none_bars
nfetched += len(nb)
noffset += len(nb)
if len(nb) < nps:
break
if none_bars:
# XDXR 获取涉及同步网络 IO,放线程执行
df = await asyncio.to_thread(
self._local_recompute_qfq, _to_df(none_bars), market, code,
)
delta = _period_to_minutes(period, times)
is_intraday = delta is not None
return _apply_bar_time_align_df(
+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