mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 15:44:18 +08:00
release: v1.24.0 — QFQ 对拍验证体系 + 回测任务持久化 + 品种感知费率
升级计划 P0(docs/upgrade-plan-2026H2.md)。源自 backtest-system / indicator-lab 两个下游项目的逆向调研。
- QFQ 对拍验证:公式法(NONE+XDXR)与跳空检测法(板块感知涨跌停阈值)双证据链互检,
检出负价/残留跳空/方向反演/XDXR 缺记录四类问题,接入 MAC 同步/异步客户端(mac/qfq_check.py);
含茅台式多重分红、浦发式送转方向合成案例回归(13 用例)
- 回测任务 SQLite 持久化:~/.easy_tdx/tasks.db 双写内存 LRU + 磁盘(保留 500 条),serve 重启不丢;
重启恢复中断任务标记;GET /backtest/tasks/{id}/export?format=json|csv 导出端点
- 品种感知费率:ETF/可转债免印花税等法定差异(backtest/fees.py),CLI --auto-fees、
REST auto_fees 字段、组合引擎逐标的解析(34 用例)
- 修正 avg_holding_days 过时注释(实现早已是 FIFO 真实口径)
- tests/conftest.py 默认 EASY_TDX_NO_TASK_DB=1 防止单测污染用户任务库
- 注:engine/cli/routers/schemas 为跨版本累积态,后续版本提交继续演进
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
"""品种感知费率模型测试(fees.py + 引擎 auto_fees 集成)。
|
||||
|
||||
覆盖:
|
||||
- 品种推断:沪深股票 / ETF / LOF / 可转债 / B 股 / 指数 / 北交所
|
||||
- 费率解析:ETF/债券免印花税、B 股印花税保留、最低佣金差异
|
||||
- 引擎集成:auto_fees 覆盖默认费率、显式费率优先、关闭时行为不变
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from easy_tdx.backtest.engine import BacktestEngine
|
||||
from easy_tdx.backtest.fees import (
|
||||
InstrumentKind,
|
||||
detect_instrument_kind,
|
||||
resolve_fee_model,
|
||||
)
|
||||
from easy_tdx.backtest.strategy import Strategy
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 品种推断
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("symbol", "market", "expected"),
|
||||
[
|
||||
("600519", "SH", InstrumentKind.STOCK), # 贵州茅台
|
||||
("601398", "SH", InstrumentKind.STOCK), # 工商银行
|
||||
("000001", "SZ", InstrumentKind.STOCK), # 平安银行
|
||||
("300750", "SZ", InstrumentKind.STOCK), # 宁德时代(创业板)
|
||||
("688981", "SH", InstrumentKind.STOCK), # 中芯国际(科创板)
|
||||
("832000", "BJ", InstrumentKind.STOCK), # 北交所
|
||||
("510300", "SH", InstrumentKind.ETF), # 沪深300ETF
|
||||
("588000", "SH", InstrumentKind.ETF), # 科创50ETF
|
||||
("159915", "SZ", InstrumentKind.ETF), # 创业板ETF
|
||||
("501018", "SH", InstrumentKind.LOF), # 南方原油 LOF
|
||||
("160632", "SZ", InstrumentKind.LOF), # 深 LOF
|
||||
("113050", "SH", InstrumentKind.BOND), # 沪可转债
|
||||
("123456", "SZ", InstrumentKind.BOND), # 深可转债
|
||||
("900901", "SH", InstrumentKind.B_SHARE), # 沪 B
|
||||
("200002", "SZ", InstrumentKind.B_SHARE), # 深 B
|
||||
("000001", "SH", InstrumentKind.INDEX), # 上证指数(同码不同市!)
|
||||
("399001", "SZ", InstrumentKind.INDEX), # 深证成指
|
||||
("SH:510300", None, InstrumentKind.ETF), # 带前缀 symbol
|
||||
("SZ:159915", None, InstrumentKind.ETF),
|
||||
("510300", 1, InstrumentKind.ETF), # 通达信 int 市场
|
||||
("000001", 0, InstrumentKind.STOCK),
|
||||
("510300", None, InstrumentKind.ETF), # 无市场,仅代码粗判
|
||||
("600519", None, InstrumentKind.STOCK),
|
||||
],
|
||||
)
|
||||
def test_detect_instrument_kind(symbol, market, expected):
|
||||
assert detect_instrument_kind(symbol, market) == expected
|
||||
|
||||
|
||||
def test_detect_kind_case_insensitive_and_spacing():
|
||||
assert detect_instrument_kind(" sh:510300 ") == InstrumentKind.ETF
|
||||
assert detect_instrument_kind("sh510300") == InstrumentKind.ETF
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 费率解析
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_stock_fees_keep_stamp_tax():
|
||||
fee = resolve_fee_model("SH:600519")
|
||||
assert fee.kind is InstrumentKind.STOCK
|
||||
assert fee.stamp_tax == pytest.approx(0.001)
|
||||
assert fee.commission == pytest.approx(0.0003)
|
||||
assert fee.min_commission == pytest.approx(5.0)
|
||||
|
||||
|
||||
def test_etf_fees_exempt_stamp_tax():
|
||||
"""核心法定差异:ETF 免印花税。"""
|
||||
fee = resolve_fee_model("SH:510300")
|
||||
assert fee.kind is InstrumentKind.ETF
|
||||
assert fee.stamp_tax == 0.0
|
||||
|
||||
|
||||
def test_bond_fees_exempt_stamp_tax_and_lower_min():
|
||||
fee = resolve_fee_model("SZ:123456")
|
||||
assert fee.kind is InstrumentKind.BOND
|
||||
assert fee.stamp_tax == 0.0
|
||||
assert fee.min_commission < 5.0
|
||||
|
||||
|
||||
def test_b_share_fees_keep_stamp_tax():
|
||||
fee = resolve_fee_model("SH:900901")
|
||||
assert fee.kind is InstrumentKind.B_SHARE
|
||||
assert fee.stamp_tax == pytest.approx(0.001)
|
||||
|
||||
|
||||
def test_fee_model_frozen():
|
||||
fee = resolve_fee_model("SH:510300")
|
||||
with pytest.raises(AttributeError):
|
||||
fee.commission = 0.0 # type: ignore[misc]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 引擎集成
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class _AlwaysBuy(Strategy):
|
||||
"""首根 K 线全仓买入、持有到末尾的极简策略(保证产生 BUY 交易)。"""
|
||||
|
||||
def init(self) -> None:
|
||||
self._bought = False
|
||||
|
||||
def next(self) -> None:
|
||||
if not self._bought:
|
||||
self.buy()
|
||||
self._bought = True
|
||||
|
||||
|
||||
def _df(n: int = 50) -> pd.DataFrame:
|
||||
dates = pd.date_range("2023-01-01", periods=n, freq="B")
|
||||
close = 10.0 + np.linspace(0, 2, n)
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"datetime": dates,
|
||||
"open": close,
|
||||
"high": close * 1.01,
|
||||
"low": close * 0.99,
|
||||
"close": close,
|
||||
"vol": [1000.0] * n,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_engine_auto_fees_overrides_stamp_tax_for_etf():
|
||||
"""auto_fees=True 时 ETF 不收印花税(对比股票默认收)。"""
|
||||
# 股票(默认费率,stamp_tax=0.001)
|
||||
eng_stock = BacktestEngine(_AlwaysBuy, cash=100000.0)
|
||||
assert eng_stock._stamp_tax == pytest.approx(0.001)
|
||||
|
||||
# ETF + auto_fees → stamp_tax 归零
|
||||
eng_etf = BacktestEngine(_AlwaysBuy, cash=100000.0, symbol="SH:510300", auto_fees=True)
|
||||
assert eng_etf._stamp_tax == 0.0
|
||||
assert eng_etf._commission == pytest.approx(0.0003)
|
||||
|
||||
# 结果 config 里带 symbol 与解析后的费率
|
||||
result = eng_etf.run(_df())
|
||||
assert result.config["symbol"] == "SH:510300"
|
||||
assert result.config["stamp_tax"] == 0.0
|
||||
assert result.config["min_commission"] == pytest.approx(5.0)
|
||||
|
||||
|
||||
def test_engine_explicit_fees_win_over_auto():
|
||||
"""显式传入非默认费率时,auto_fees 不覆盖用户意图。"""
|
||||
eng = BacktestEngine(
|
||||
_AlwaysBuy,
|
||||
cash=100000.0,
|
||||
commission=0.0001,
|
||||
min_commission=1.0,
|
||||
stamp_tax=0.0005,
|
||||
symbol="SH:510300",
|
||||
auto_fees=True,
|
||||
)
|
||||
assert eng._commission == pytest.approx(0.0001)
|
||||
assert eng._min_commission == pytest.approx(1.0)
|
||||
assert eng._stamp_tax == pytest.approx(0.0005)
|
||||
|
||||
|
||||
def test_engine_auto_fees_without_symbol_is_noop():
|
||||
"""auto_fees=True 但没给 symbol → 保持默认(不报错)。"""
|
||||
eng = BacktestEngine(_AlwaysBuy, cash=100000.0, auto_fees=True)
|
||||
assert eng._commission == pytest.approx(0.0003)
|
||||
assert eng._stamp_tax == pytest.approx(0.001)
|
||||
|
||||
|
||||
def test_engine_default_behavior_unchanged():
|
||||
"""不传新参数时行为与旧版完全一致(向后兼容)。"""
|
||||
eng = BacktestEngine(_AlwaysBuy, cash=100000.0)
|
||||
assert eng._commission == pytest.approx(0.0003)
|
||||
assert eng._min_commission == pytest.approx(5.0)
|
||||
assert eng._stamp_tax == pytest.approx(0.001)
|
||||
assert eng._symbol is None
|
||||
|
||||
|
||||
def test_portfolio_engine_auto_fees_per_symbol():
|
||||
"""组合引擎按各标的逐只解析费率(股票收印花税、ETF 不收)。"""
|
||||
from easy_tdx.backtest.portfolio_engine import PortfolioBacktestEngine, StockData
|
||||
|
||||
df = _df(60)
|
||||
stocks = [
|
||||
StockData(code="600519", market="SH", df=df),
|
||||
StockData(code="510300", market="SH", df=df),
|
||||
]
|
||||
engine = PortfolioBacktestEngine(
|
||||
strategy=_AlwaysBuy,
|
||||
stocks=stocks,
|
||||
total_cash=200000.0,
|
||||
auto_fees=True,
|
||||
)
|
||||
result = engine.run()
|
||||
# 两只标的结果的 config 中费率不同
|
||||
stock_cfg = result.individual_results["SH600519"].config
|
||||
etf_cfg = result.individual_results["SH510300"].config
|
||||
assert stock_cfg["stamp_tax"] == pytest.approx(0.001)
|
||||
assert etf_cfg["stamp_tax"] == 0.0
|
||||
@@ -0,0 +1,278 @@
|
||||
"""QFQ 对拍校验(公式法 vs 跳空检测法)的单元测试。
|
||||
|
||||
覆盖 ``easy_tdx.mac.qfq_check``:
|
||||
|
||||
- 已知除权案例回归(合成数据复现两类历史上被下游反馈过的场景):
|
||||
* 「茅台式」——长期多重现金分红叠加深层历史,本地重算后应全正且事件处连续;
|
||||
* 「浦发式」——送转股事件,前复权方向应为「旧价向下缩放」。
|
||||
- 反例检测:复权方向算反 → ``wrong_direction``;漏事件 → ``residual_gap``;
|
||||
NONE 跳空但 XDXR 缺记录 → ``unexplained_gap``;负价 → ``bad_price``。
|
||||
- 涨跌停幅度推断(主板/双创/北交所)与跳空检测阈值。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from easy_tdx.mac.adjust import apply_forward_adjust, has_bad_prices
|
||||
from easy_tdx.mac.qfq_check import (
|
||||
board_limit_ratio,
|
||||
crosscheck_qfq,
|
||||
detect_ex_dividend_gaps,
|
||||
)
|
||||
|
||||
|
||||
def _kline(
|
||||
closes: list[float],
|
||||
start: str = "2010-01-01",
|
||||
opens: list[float] | None = None,
|
||||
) -> pd.DataFrame:
|
||||
"""构造最小 NONE K 线。默认 open=close;可显式给 opens 制造除权跳空。"""
|
||||
n = len(closes)
|
||||
dates = pd.date_range(start, periods=n, freq="D")
|
||||
arr = np.array(closes, dtype=float)
|
||||
o = np.array(opens, dtype=float) if opens is not None else arr.copy()
|
||||
return pd.DataFrame(
|
||||
{
|
||||
"datetime": dates,
|
||||
"open": o,
|
||||
"high": np.maximum(o, arr) * 1.01,
|
||||
"low": np.minimum(o, arr) * 0.99,
|
||||
"close": arr,
|
||||
"vol": [100.0] * n,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _xdxr(
|
||||
events: list[tuple[str, float, float, float, float]],
|
||||
) -> pd.DataFrame:
|
||||
"""构造 XDXR 记录:list of (date, fenhong, peigujia, songzhuangu, peigu)。"""
|
||||
return pd.DataFrame(
|
||||
[
|
||||
{
|
||||
"date": d,
|
||||
"category": 1,
|
||||
"fenhong": fh,
|
||||
"peigujia": pj,
|
||||
"songzhuangu": sz,
|
||||
"peigu": pg,
|
||||
}
|
||||
for d, fh, pj, sz, pg in events
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 涨跌停幅度推断
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_board_limit_ratio_by_code() -> None:
|
||||
"""主板 10%、双创 20%、北交所 30%。"""
|
||||
assert board_limit_ratio("600519") == 0.10 # 沪主板(茅台)
|
||||
assert board_limit_ratio("600000") == 0.10 # 沪主板(浦发)
|
||||
assert board_limit_ratio("000001") == 0.10 # 深主板
|
||||
assert board_limit_ratio("300750") == 0.20 # 创业板
|
||||
assert board_limit_ratio("688981") == 0.20 # 科创板
|
||||
assert board_limit_ratio("832000") == 0.30 # 北交所
|
||||
assert board_limit_ratio("430047") == 0.30 # 北交所
|
||||
assert board_limit_ratio("600000", market=2) == 0.30 # market 显式指定优先
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 跳空检测(detect_ex_dividend_gaps)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_detect_gap_finds_ex_dividend_drop() -> None:
|
||||
"""主板股票开盘相对昨收跌超 10.5% → 判定为除权跳空。"""
|
||||
# 10 根平稳 + 除权日开盘腰斩(-50%)
|
||||
closes = [10.0] * 5 + [5.0] * 5
|
||||
opens = [10.0] * 5 + [2.5] + [5.0] * 4
|
||||
df = _kline(closes, opens=opens)
|
||||
gaps = detect_ex_dividend_gaps(df, "600000")
|
||||
assert gaps == ["2010-01-06"] # 第 6 根(index 5)为除权日
|
||||
|
||||
|
||||
def test_detect_gap_ignores_normal_limit_down() -> None:
|
||||
"""恰好跌停(-10.0%)不算除权跳空(被 0.5% 余量排除)。"""
|
||||
closes = [10.0] * 5 + [9.0] * 5
|
||||
opens = [10.0] * 5 + [9.0] + [9.0] * 4 # ex 开盘恰好 -10%
|
||||
df = _kline(closes, opens=opens)
|
||||
assert detect_ex_dividend_gaps(df, "600000") == []
|
||||
|
||||
|
||||
def test_detect_gap_uses_chinext_threshold() -> None:
|
||||
"""创业板(20% 涨跌停):-15% 的跳空不报警,-25% 报警。"""
|
||||
closes = [10.0] * 5 + [7.5] * 5
|
||||
opens = [10.0] * 5 + [8.5] + [7.5] * 4 # -15% → 不报
|
||||
df = _kline(closes, opens=opens)
|
||||
assert detect_ex_dividend_gaps(df, "300750") == []
|
||||
|
||||
opens2 = [10.0] * 5 + [7.0] + [7.5] * 4 # -30% → 报
|
||||
df2 = _kline(closes, opens=opens2)
|
||||
assert detect_ex_dividend_gaps(df2, "300750") == ["2010-01-06"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 已知案例回归(合成)
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_maotai_style_multi_dividend_case() -> None:
|
||||
"""「茅台式」:多笔大额现金分红叠加深层历史。
|
||||
|
||||
场景:高价股(1700 元)历经 3 次每笔 40~60 元分红,NONE 价格在除权日
|
||||
出现 -3% 左右的真实跳空(小额,低于跌停阈值),深层历史经公式法
|
||||
前复权后应全正、除权日前后连续、对拍通过。
|
||||
"""
|
||||
# 构造 300 根:价格在 1700 附近随机游走,3 个除权日各扣一次分红
|
||||
rng = np.random.default_rng(42)
|
||||
n = 300
|
||||
prices = 1700.0 + np.cumsum(rng.normal(0, 8, n))
|
||||
events = [(50, "2010-04-20"), (60, "2010-07-20"), (40, "2010-09-20")]
|
||||
closes = prices.copy()
|
||||
opens = prices.copy()
|
||||
dates = pd.date_range("2010-01-01", periods=n, freq="D")
|
||||
for fh, ex in events:
|
||||
ex_ts = pd.Timestamp(ex)
|
||||
idx = int(np.searchsorted(dates.to_numpy(), np.datetime64(ex_ts)))
|
||||
opens[idx] = closes[idx - 1] - fh # 除权日开盘 = 昨收 - 分红
|
||||
closes[idx:] -= fh # 之后价格整体降一档(简化)
|
||||
none_df = _kline(list(closes), opens=list(opens))
|
||||
xd = _xdxr([(ex, fh, 0.0, 0.0, 0.0) for fh, ex in events])
|
||||
|
||||
qfq = apply_forward_adjust(none_df, xd)
|
||||
# 1. 全正(茅台负价问题的回归断言)
|
||||
assert not has_bad_prices(qfq)
|
||||
# 2. 对拍通过:无 bad_price / residual_gap / wrong_direction
|
||||
report = crosscheck_qfq(none_df, qfq, xd, "600519", 1)
|
||||
assert report.ok, [i.to_dict() for i in report.issues]
|
||||
assert report.events_checked == 3
|
||||
|
||||
|
||||
def test_pufa_style_songzhuangu_direction() -> None:
|
||||
"""「浦发式」:送转股事件的前复权方向。
|
||||
|
||||
10 送 3(songzhuangu=0.3):除权日理论价格 = 昨收 / 1.3。正确的前复权
|
||||
应把除权日**之前**的价格向下缩放(factor = 1/1.3),而不是抬升之后的价格。
|
||||
"""
|
||||
# 20 根 10 元平稳,除权日后理论价 10/1.3 ≈ 7.69
|
||||
closes = [10.0] * 10 + [7.69] * 10
|
||||
opens = [10.0] * 10 + [7.69] + [7.69] * 9
|
||||
none_df = _kline(closes, opens=opens)
|
||||
xd = _xdxr([("2010-01-11", 0.0, 0.0, 0.3, 0.0)])
|
||||
|
||||
qfq = apply_forward_adjust(none_df, xd)
|
||||
# 旧价被向下缩放:前 10 根 ≈ 10/1.3 ≈ 7.69,与除权后持平(连续);
|
||||
# 最新价锚定不动
|
||||
assert abs(qfq["close"].iloc[0] - 7.69) <= 0.02
|
||||
assert abs(qfq["close"].iloc[-1] - 7.69) <= 1e-9
|
||||
# 方向正确 → 对拍通过(除权日 open=7.69 ≈ 复权后昨收 7.69)
|
||||
report = crosscheck_qfq(none_df, qfq, xd, "600000", 1)
|
||||
assert report.ok, [i.to_dict() for i in report.issues]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 反例检测
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_wrong_direction_adjustment_detected() -> None:
|
||||
"""复权方向算反 → 除权日残留大幅跳空。
|
||||
|
||||
方向反演(因子取倒数):把除权日**之前**的价格放大 ×1.3 而非缩放
|
||||
÷1.3,除权日 open=7.69 对上「复权后」昨收 13.0 → 残差 -41% →
|
||||
``residual_gap``。
|
||||
"""
|
||||
# NONE:10 送 3 场景,除权日开盘 7.69(-23%,超主板阈值 → 会被跳空检测捕获)
|
||||
closes = [10.0] * 10 + [7.69] * 10
|
||||
opens = [10.0] * 10 + [7.69] + [7.69] * 9
|
||||
none_df = _kline(closes, opens=opens)
|
||||
xd = _xdxr([("2010-01-11", 0.0, 0.0, 0.3, 0.0)])
|
||||
|
||||
reversed_df = none_df.copy()
|
||||
reversed_df.loc[reversed_df.index <= 9, ["open", "high", "low", "close"]] *= 1.3
|
||||
report = crosscheck_qfq(none_df, reversed_df, xd, "600000", 1)
|
||||
assert not report.ok
|
||||
assert any(i.kind == "residual_gap" for i in report.issues)
|
||||
|
||||
|
||||
def test_over_adjustment_detected() -> None:
|
||||
"""过度复权(旧价缩得过低)→ 除权日向上跳空 → wrong_direction。"""
|
||||
closes = [10.0] * 10 + [7.69] * 10
|
||||
opens = [10.0] * 10 + [7.69] + [7.69] * 9
|
||||
none_df = _kline(closes, opens=opens)
|
||||
xd = _xdxr([("2010-01-11", 0.0, 0.0, 0.3, 0.0)])
|
||||
|
||||
over = none_df.copy()
|
||||
over.loc[over.index <= 9, ["open", "high", "low", "close"]] *= 0.5 # 应 ÷1.3 却 ×0.5
|
||||
report = crosscheck_qfq(none_df, over, xd, "600000", 1)
|
||||
assert not report.ok
|
||||
assert any(i.kind == "wrong_direction" for i in report.issues)
|
||||
|
||||
|
||||
def test_missed_event_residual_gap_detected() -> None:
|
||||
"""漏算事件(复权结果等于 NONE 原始序列)→ residual_gap。"""
|
||||
closes = [10.0] * 10 + [7.69] * 10
|
||||
opens = [10.0] * 10 + [7.69] + [7.69] * 9
|
||||
none_df = _kline(closes, opens=opens)
|
||||
xd = _xdxr([("2010-01-11", 0.0, 0.0, 0.3, 0.0)])
|
||||
|
||||
# 「复权结果」其实是未复权的 NONE(公式法漏调)→ 除权日残留 -23% 跳空
|
||||
report = crosscheck_qfq(none_df, none_df.copy(), xd, "600000", 1)
|
||||
assert not report.ok
|
||||
assert any(i.kind == "residual_gap" for i in report.issues)
|
||||
|
||||
|
||||
def test_unexplained_gap_without_xdxr_record() -> None:
|
||||
"""NONE 存在除权跳空但 XDXR 无对应记录 → unexplained_gap(不影响 ok)。"""
|
||||
closes = [10.0] * 10 + [7.69] * 10
|
||||
opens = [10.0] * 10 + [7.69] + [7.69] * 9
|
||||
none_df = _kline(closes, opens=opens)
|
||||
|
||||
# XDXR 为空(数据源缺记录),公式法无从调整 → 序列本身「连续性」检查通过,
|
||||
# 但跳空检测应报 unexplained_gap 提示人工核查
|
||||
report = crosscheck_qfq(none_df, none_df.copy(), None, "600000", 1)
|
||||
assert report.gaps_detected == 1
|
||||
assert any(i.kind == "unexplained_gap" for i in report.issues)
|
||||
# unexplained_gap 属于「证据链不一致」而非「复权结果错误」,ok 保持 True
|
||||
assert report.ok
|
||||
|
||||
|
||||
def test_bad_price_reported() -> None:
|
||||
"""复权结果含负价 → bad_price(ok=False)。"""
|
||||
closes = [10.0] * 5
|
||||
none_df = _kline(closes)
|
||||
bad = none_df.copy()
|
||||
bad.loc[0, ["open", "high", "low", "close"]] = -1.0
|
||||
report = crosscheck_qfq(none_df, bad, None, "600000", 1)
|
||||
assert not report.ok
|
||||
assert any(i.kind == "bad_price" for i in report.issues)
|
||||
|
||||
|
||||
def test_clean_series_passes() -> None:
|
||||
"""无事件、无跳空的干净序列 → ok=True、零问题。"""
|
||||
closes = [10.0 + 0.1 * i for i in range(20)]
|
||||
none_df = _kline(closes)
|
||||
report = crosscheck_qfq(none_df, none_df.copy(), None, "600000", 1)
|
||||
assert report.ok
|
||||
assert report.issues == []
|
||||
assert report.events_checked == 0
|
||||
assert report.gaps_detected == 0
|
||||
|
||||
|
||||
def test_report_to_dict_roundtrip() -> None:
|
||||
"""报告可序列化为 JSON 兼容字典。"""
|
||||
closes = [10.0] * 10 + [7.69] * 10
|
||||
opens = [10.0] * 10 + [7.69] + [7.69] * 9
|
||||
none_df = _kline(closes, opens=opens)
|
||||
xd = _xdxr([("2010-01-11", 0.0, 0.0, 0.3, 0.0)])
|
||||
report = crosscheck_qfq(none_df, none_df.copy(), xd, "600000", 1)
|
||||
d = report.to_dict()
|
||||
assert d["symbol"] == "1:600000"
|
||||
assert d["ok"] is False
|
||||
assert isinstance(d["issues"], list) and d["issues"]
|
||||
assert {"kind", "date", "detail"} == set(d["issues"][0].keys())
|
||||
@@ -0,0 +1,259 @@
|
||||
"""回测任务 SQLite 持久化测试(task_store + task_runner 集成 + REST 导出)。
|
||||
|
||||
覆盖:
|
||||
- ``TaskStore``:save/load/list_recent/delete 往返、淘汰、重启恢复中断任务
|
||||
- ``BacktestTaskRunner`` 集成:任务完成后落盘、内存淘汰后磁盘兜底、
|
||||
「重启」(新建 runner)后仍可查历史任务
|
||||
- REST 导出端点:JSON 全量 / CSV 主表 / 未完成任务拒绝导出
|
||||
|
||||
持久化默认被 ``tests/conftest.py`` 关闭(``EASY_TDX_NO_TASK_DB=1``),
|
||||
本文件的测试显式删除该变量并把 ``EASY_TDX_CONFIG_DIR`` 指向 ``tmp_path``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("fastapi")
|
||||
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from easy_tdx.web import task_store as ts_mod # noqa: E402
|
||||
from easy_tdx.web.task_runner import BacktestTaskRunner # noqa: E402
|
||||
from easy_tdx.web.task_store import TaskStore # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def persisted_env(tmp_path, monkeypatch):
|
||||
"""开启持久化并指向临时目录;隔离全局单例。"""
|
||||
monkeypatch.delenv("EASY_TDX_NO_TASK_DB", raising=False)
|
||||
monkeypatch.setenv("EASY_TDX_CONFIG_DIR", str(tmp_path))
|
||||
ts_mod.reset_task_store()
|
||||
yield tmp_path
|
||||
ts_mod.reset_task_store()
|
||||
|
||||
|
||||
def _wait_done(runner: BacktestTaskRunner, task_id: str, timeout: float = 5.0) -> None:
|
||||
"""轮询等待任务进入终态。"""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
state = runner.peek(task_id)
|
||||
assert state is not None
|
||||
if state.status in ("done", "failed"):
|
||||
return
|
||||
time.sleep(0.02)
|
||||
raise AssertionError(f"任务 {task_id} 超时未完成")
|
||||
|
||||
|
||||
# ── TaskStore 单元 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_store_save_load_roundtrip(persisted_env):
|
||||
store = TaskStore(db_path=persisted_env / "t.db")
|
||||
store.save(
|
||||
task_id="abc",
|
||||
status="done",
|
||||
description="ma_cross | 300根",
|
||||
created_at=1000.0,
|
||||
started_at=1001.0,
|
||||
finished_at=1002.0,
|
||||
result={"performance": {"total_return": 0.25}, "trades": [{"pnl": 1.0}]},
|
||||
)
|
||||
d = store.load("abc")
|
||||
assert d is not None
|
||||
assert d["status"] == "done"
|
||||
assert d["result"]["performance"]["total_return"] == 0.25
|
||||
assert d["created_at"] == 1000.0
|
||||
assert store.load("missing") is None
|
||||
|
||||
|
||||
def test_store_list_recent_order_and_delete(persisted_env):
|
||||
store = TaskStore(db_path=persisted_env / "t.db")
|
||||
for i in range(5):
|
||||
store.save(task_id=f"t{i}", status="done", created_at=1000.0 + i)
|
||||
rows = store.list_recent(limit=3)
|
||||
assert [r["task_id"] for r in rows] == ["t4", "t3", "t2"] # created_at 倒序
|
||||
assert store.delete("t4") is True
|
||||
assert store.delete("t4") is False
|
||||
assert store.load("t4") is None
|
||||
|
||||
|
||||
def test_store_upsert_replaces(persisted_env):
|
||||
"""同 task_id 二次 save 是覆盖而非追加。"""
|
||||
store = TaskStore(db_path=persisted_env / "t.db")
|
||||
store.save(task_id="x", status="running", created_at=1.0)
|
||||
store.save(task_id="x", status="done", created_at=1.0, finished_at=2.0, result={"a": 1})
|
||||
d = store.load("x")
|
||||
assert d["status"] == "done"
|
||||
assert d["result"] == {"a": 1}
|
||||
assert len(store.list_recent(limit=10)) == 1
|
||||
|
||||
|
||||
def test_store_recovers_interrupted_tasks(persisted_env):
|
||||
"""新连接(模拟进程重启)把遗留 pending/running 标记为 failed。"""
|
||||
store = TaskStore(db_path=persisted_env / "t.db")
|
||||
store.save(task_id="p1", status="pending", created_at=1.0)
|
||||
store.save(task_id="r1", status="running", created_at=1.0)
|
||||
store.save(task_id="d1", status="done", created_at=1.0, result={"ok": True})
|
||||
|
||||
# 模拟重启:新实例初始化时触发恢复
|
||||
store2 = TaskStore(db_path=persisted_env / "t.db")
|
||||
_ = store2.list_recent(limit=10)
|
||||
|
||||
assert store2.load("p1")["status"] == "failed"
|
||||
assert "重启" in store2.load("p1")["error"]
|
||||
assert store2.load("r1")["status"] == "failed"
|
||||
assert store2.load("d1")["status"] == "done" # 已完成任务不受影响
|
||||
|
||||
|
||||
def test_store_result_json_corruption_degrades(persisted_env):
|
||||
"""result_json 损坏时 load 降级返回 result=None,不抛异常。"""
|
||||
import sqlite3
|
||||
|
||||
path = persisted_env / "t.db"
|
||||
store = TaskStore(db_path=path)
|
||||
store.save(task_id="bad", status="done", created_at=1.0, result={"a": 1})
|
||||
conn = sqlite3.connect(path)
|
||||
conn.execute("UPDATE backtest_tasks SET result_json = '{not-json' WHERE task_id='bad'")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
store2 = TaskStore(db_path=path)
|
||||
d = store2.load("bad")
|
||||
assert d is not None
|
||||
assert d["result"] is None
|
||||
|
||||
|
||||
# ── Runner 集成 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_runner_persists_done_task_and_survives_memory_eviction(persisted_env):
|
||||
runner = BacktestTaskRunner(max_workers=2, max_results=2)
|
||||
task_id = runner.submit(lambda: {"performance": {"total_return": 0.5}}, description="d")
|
||||
_wait_done(runner, task_id)
|
||||
|
||||
# 磁盘上能查到 done + 完整结果
|
||||
d = ts_mod.get_task_store().load(task_id)
|
||||
assert d is not None
|
||||
assert d["status"] == "done"
|
||||
assert d["result"]["performance"]["total_return"] == 0.5
|
||||
|
||||
# 内存淘汰(提交 3 个新任务挤掉 LRU)后 peek 仍能从磁盘兜底
|
||||
for _ in range(3):
|
||||
_wait_done(runner, runner.submit(lambda: {"x": 1}))
|
||||
state = runner.peek(task_id)
|
||||
assert state is not None
|
||||
assert state.status == "done"
|
||||
assert state.result["performance"]["total_return"] == 0.5
|
||||
runner.shutdown()
|
||||
|
||||
|
||||
def test_runner_new_instance_sees_history(persisted_env):
|
||||
"""「重启」:全新 runner/store 仍能列出并查询历史任务。"""
|
||||
runner1 = BacktestTaskRunner(max_workers=1)
|
||||
task_id = runner1.submit(lambda: {"performance": {"sharpe": 1.2}}, description="hist")
|
||||
_wait_done(runner1, task_id)
|
||||
runner1.shutdown()
|
||||
|
||||
runner2 = BacktestTaskRunner(max_workers=1)
|
||||
state = runner2.peek(task_id)
|
||||
assert state is not None
|
||||
assert state.status == "done"
|
||||
assert state.result["performance"]["sharpe"] == 1.2
|
||||
listed = runner2.list_recent(limit=10)
|
||||
assert any(s.task_id == task_id for s in listed)
|
||||
runner2.shutdown()
|
||||
|
||||
|
||||
def test_runner_persists_failed_task(persisted_env):
|
||||
def _boom():
|
||||
raise RuntimeError("炸了")
|
||||
|
||||
runner = BacktestTaskRunner(max_workers=1)
|
||||
task_id = runner.submit(_boom, description="bad")
|
||||
_wait_done(runner, task_id)
|
||||
d = ts_mod.get_task_store().load(task_id)
|
||||
assert d is not None
|
||||
assert d["status"] == "failed"
|
||||
assert "RuntimeError" in d["error"]
|
||||
runner.shutdown()
|
||||
|
||||
|
||||
# ── REST 导出端点 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _client() -> TestClient:
|
||||
from easy_tdx.web import create_app
|
||||
|
||||
app = create_app()
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_export_json_and_csv(persisted_env):
|
||||
client = _client()
|
||||
# 提交一个内联数据回测任务并等待完成
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
np.random.seed(7)
|
||||
n = 200
|
||||
close = 10 + np.cumsum(np.random.randn(n) * 0.2 + 0.05)
|
||||
dates = pd.date_range("2023-01-01", periods=n, freq="B")
|
||||
ohlcv = [
|
||||
{
|
||||
"datetime": d.strftime("%Y-%m-%d"),
|
||||
"open": float(c - 0.05),
|
||||
"high": float(c + 0.1),
|
||||
"low": float(c - 0.1),
|
||||
"close": float(c),
|
||||
"vol": 5000.0,
|
||||
"amount": float(c * 5000),
|
||||
}
|
||||
for d, c in zip(dates, close, strict=True)
|
||||
]
|
||||
resp = client.post(
|
||||
"/api/v1/backtest/run/async",
|
||||
json={"strategy": "ma_cross", "params": {"fast": 5, "slow": 20}, "ohlcv": ohlcv},
|
||||
)
|
||||
assert resp.status_code == 202, resp.text
|
||||
task_id = resp.json()["task_id"]
|
||||
for _ in range(200):
|
||||
st = client.get(f"/api/v1/backtest/tasks/{task_id}").json()
|
||||
if st["status"] in ("done", "failed"):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
assert st["status"] == "done", st.get("error")
|
||||
|
||||
# JSON 导出:完整 result
|
||||
rj = client.get(f"/api/v1/backtest/tasks/{task_id}/export?format=json")
|
||||
assert rj.status_code == 200
|
||||
assert "attachment" in rj.headers["content-disposition"]
|
||||
assert "performance" in rj.json()
|
||||
|
||||
# CSV 导出:主表(trades 或 performance)
|
||||
rc = client.get(f"/api/v1/backtest/tasks/{task_id}/export?format=csv")
|
||||
assert rc.status_code == 200
|
||||
assert rc.headers["content-type"].startswith("text/csv")
|
||||
assert len(rc.text.splitlines()) >= 2
|
||||
|
||||
|
||||
def test_export_rejects_unknown_and_unfinished(persisted_env):
|
||||
client = _client()
|
||||
assert client.get("/api/v1/backtest/tasks/nope/export").status_code == 400
|
||||
resp = client.get("/api/v1/backtest/tasks/nope/export?format=xml")
|
||||
# 未知任务先报「未知任务」
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_task_list_includes_persisted_history_after_new_app(persisted_env):
|
||||
"""应用层重启(同 DB)后 /backtest/tasks 仍列出历史任务。"""
|
||||
runner = BacktestTaskRunner(max_workers=1)
|
||||
task_id = runner.submit(lambda: {"performance": {"total_return": 0.1}}, description="旧任务")
|
||||
_wait_done(runner, task_id)
|
||||
runner.shutdown()
|
||||
|
||||
client = _client()
|
||||
tasks = client.get("/api/v1/backtest/tasks?limit=50").json()["tasks"]
|
||||
assert any(t["task_id"] == task_id for t in tasks)
|
||||
Reference in New Issue
Block a user