汇总三轮未提交的开发(每轮均在本机 MariaDB + 真实浏览器上验证):
1) 股息率案例(全市场股息率最高 n 只,默认 20,每 m 月择股)
- 新增日频估值表 daily_basic + 迁移;股息率因子(dv_ratio / dividend_yield / TTM)
- 名称历史表 stock_name_history:剔除 ST 按**择股日当时名称**判定,消除
「曾高股息后 ST」的股息陷阱(实测 3.70pp 偏差)
- 区间择股/调仓双周期(m 择股 / y 调仓)、指数成分与白名单、停牌近似剔除
- 复权因子口径核对(4,164,742 行、缺失 0.0%)、收盘价成交与涨跌停拦单
- 案例实测:2020-01-01~2026-09-04 总收益 +24.86%(年化 3.52%、回撤 -28.58%)
2) 策略库与前端统一
- strategy 表 + CRUD/PUT 原地更新 + `describe_strategy` 按 spec 真实推导
「一句话说明 + 计算公式 + 执行步骤 + 注意事项」(与引擎实执行规则同源)
- 任何出现股票代码处都成对显示名称且可点击进个股页
- 全站图表基座统一 TradingView Lightweight Charts(ECharts 依赖、
锁文件、组件与文档标注一并清除),买卖点标记只落在真实交易日上
3) 回测存档完整化(可往复查看)
- 同步端点(POST /api/backtests、/api/factor-tests)此前完全不落库 → 现在同样归档,
归档 id 经响应头 X-Experiment-Id 返回(不破坏 response_model)
- data_version 首次真实写入(数据快照指纹:最新交易日 + 各表规模)
- 个股收益曲线默认**全量保存**(此前硬截断 60 只);超出体积预算才裁剪,
并写 archive_meta(机器可读)+ unimplemented(人可读)如实标注
- 列表 kind/q 过滤 + X-Total-Count(此前 limit=50 静默截断)、DELETE 归档
- 只读归档页 /experiments/{id}(Server Component,SSR 直出**选股条件**与
**交易执行依据**);结果视图按 kind 分发(backtest/factor_test/selection),
非回测归档不套用回测口径
- 新增 CLI:prune_experiments(保留策略,默认 dry-run)、
restore_experiment_from_job(从 Job 副本按原 id 重建被删的历史归档,默认 dry-run)
门禁:pytest 388 passed、ruff All checks passed、tsc 0 错误、图表单测 7 passed、
next build 成功、契约脚本 verify_strategy_workspace 59/59(含按 kind 逐类验证归档页)。
329 lines
16 KiB
Python
329 lines
16 KiB
Python
"""择股/调仓双周期(m/y)、两级截断(n/x)与顺延买入(defer_buy)测试。
|
|
|
|
对应用户案例:「全市场股息率最高的 n 只 → 持仓前 x 只;每 m 个月择股一次;
|
|
每 y 个月调仓,默认 y=m;买卖点为收盘价;买不进时顺延到之后不涨停的交易日买入」。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
|
|
import pandas as pd
|
|
import pytest
|
|
from app.domain.entities.research import (
|
|
CostSpec,
|
|
FactorSpec,
|
|
ResearchSpec,
|
|
SelectionSpec,
|
|
UniverseSpec,
|
|
)
|
|
from app.quant.engine import LocalEngine
|
|
from app.quant.local_engine import rebalance_dates
|
|
from pydantic import ValidationError
|
|
|
|
from conftest_quant import synthetic_daily
|
|
|
|
|
|
def _spec(
|
|
top_n: int = 2,
|
|
hold_top_x: int | None = None,
|
|
start: date = date(2024, 3, 1),
|
|
end: date = date(2024, 12, 20),
|
|
m: int | None = None,
|
|
y: int | None = None,
|
|
allow_substitute: bool = False,
|
|
defer_buy: bool = True,
|
|
) -> ResearchSpec:
|
|
"""合成行情自 2024-01-01 起(因子预热),回测自 2024-03-01 起(首个调仓日即有信号)。"""
|
|
return ResearchSpec(
|
|
type="backtest",
|
|
universe=UniverseSpec(exclude_st=False, min_listing_days=0),
|
|
factors=[FactorSpec(name="momentum_20")],
|
|
selection=SelectionSpec(
|
|
top_n=top_n,
|
|
hold_top_x=hold_top_x,
|
|
allow_substitute=allow_substitute,
|
|
defer_buy=defer_buy,
|
|
),
|
|
rebalance="monthly",
|
|
selection_interval_months=m,
|
|
rebalance_interval_months=y,
|
|
period=(start, end),
|
|
costs=CostSpec(commission_rate=0.0, stamp_tax_rate=0.0, slippage_rate=0.0),
|
|
)
|
|
|
|
|
|
class TestIntervalSchedule:
|
|
def test_every_n_months_anchored_at_start_month(self) -> None:
|
|
idx = pd.bdate_range("2020-01-01", "2021-12-31")
|
|
out = rebalance_dates(idx, "monthly", date(2020, 1, 1), every_months=6)
|
|
assert [d.strftime("%Y-%m-%d") for d in out] == [
|
|
"2020-01-01", "2020-07-01", "2021-01-01", "2021-07-01",
|
|
]
|
|
|
|
def test_anchor_moves_with_start_month(self) -> None:
|
|
idx = pd.bdate_range("2020-01-01", "2021-12-31")
|
|
out = rebalance_dates(idx, "monthly", date(2020, 3, 1), every_months=6)
|
|
assert [d.strftime("%Y-%m-%d") for d in out] == [
|
|
"2020-03-02", "2020-09-01", "2021-03-01", "2021-09-01",
|
|
]
|
|
|
|
def test_start_not_first_trading_day_keeps_anchor_month(self) -> None:
|
|
"""起始日非月初时不得跳过锚点月(否则白等 m 个月才首次建仓)。"""
|
|
idx = pd.bdate_range("2024-01-01", "2025-12-31")
|
|
out = rebalance_dates(idx, "monthly", date(2024, 3, 15), every_months=6)
|
|
# 2024-03-15 本身是交易日 → 锚点即当日;后续按 +6 个月推进
|
|
assert [d.strftime("%Y-%m-%d") for d in out] == [
|
|
"2024-03-15", "2024-09-02", "2025-03-03", "2025-09-01",
|
|
]
|
|
# 起始日落在非交易日(2024-03-16/17 为周末)→ 取之后首个交易日,仍属 3 月
|
|
out2 = rebalance_dates(idx, "monthly", date(2024, 3, 16), every_months=6)
|
|
assert out2[0].strftime("%Y-%m-%d") == "2024-03-18"
|
|
|
|
def test_start_after_last_trading_day_of_month_moves_anchor(self) -> None:
|
|
"""起始日晚于该月最后一个交易日时,锚点自然落到下一个月(不产生空区间)。"""
|
|
idx = pd.bdate_range("2024-01-01", "2025-12-31")
|
|
out = rebalance_dates(idx, "monthly", date(2024, 3, 31), every_months=6)
|
|
assert [d.strftime("%Y-%m-%d") for d in out][:2] == ["2024-04-01", "2024-10-01"]
|
|
|
|
def test_non_month_start_does_not_leave_early_cash(self) -> None:
|
|
"""非月初起始 + m=y=6:首个择股/调仓日不应晚于起始月,净值不得长期恒为初始值。"""
|
|
daily = synthetic_daily({"600000.SH": 0.004, "600001.SH": 0.002}, n=260)
|
|
res = LocalEngine().run_backtest(
|
|
daily, _spec(top_n=1, m=6, y=6, start=date(2024, 3, 15), end=date(2024, 12, 20))
|
|
)
|
|
first_pos = min(p.date for p in res.positions)
|
|
assert first_pos == date(2024, 3, 15), first_pos
|
|
# 起始日之后的前 20 个交易日里不应出现「净值恒为初始资金」
|
|
head = res.equity_curve[:20]
|
|
assert len({p.value for p in head}) > 1, "起始月即应建仓,净值不应恒为初始资金"
|
|
|
|
def test_selection_and_rebalance_schedules_are_independent(self) -> None:
|
|
"""m=6、y=3:择股 2 次,调仓 4 次。"""
|
|
daily = synthetic_daily({f"60000{i}.SH": 0.004 - 0.001 * i for i in range(4)}, n=260)
|
|
res = LocalEngine().run_backtest(daily, _spec(top_n=2, m=6, y=3))
|
|
sel_dates = sorted({p.date for p in res.selection_history})
|
|
rebal_dates_ = sorted({p.date for p in res.positions})
|
|
assert len(sel_dates) == 2, sel_dates # 2024-03-01 / 2024-09-02
|
|
assert len(rebal_dates_) == 4, rebal_dates_ # 03/06/09/12 各一次
|
|
assert set(sel_dates) < set(rebal_dates_)
|
|
|
|
def test_default_y_equals_m(self) -> None:
|
|
spec = _spec(m=6)
|
|
assert spec.effective_selection_months == 6
|
|
assert spec.effective_rebalance_months == 6 # y 缺省 → 跟随 m
|
|
|
|
def test_y_without_m_rejected(self) -> None:
|
|
with pytest.raises(ValidationError):
|
|
_spec(m=None, y=3)
|
|
|
|
|
|
class TestTwoLevelTruncation:
|
|
def test_hold_only_top_x_of_pool(self) -> None:
|
|
"""n=4、x=2:候选池记录 4 只,实际只持仓前 2 只。"""
|
|
daily = synthetic_daily({f"60000{i}.SH": 0.006 - 0.0015 * i for i in range(6)}, n=260)
|
|
res = LocalEngine().run_backtest(daily, _spec(top_n=4, hold_top_x=2, m=2, y=2))
|
|
pool_dates = {p.date for p in res.selection_history}
|
|
for d in pool_dates:
|
|
assert len([p for p in res.selection_history if p.date == d]) == 4
|
|
# 持仓数量不超过 x=2
|
|
per_date: dict = {}
|
|
for p in res.positions:
|
|
per_date.setdefault(p.date, []).append(p.symbol)
|
|
assert per_date and all(len(v) <= 2 for v in per_date.values())
|
|
|
|
def test_x_cannot_exceed_n(self) -> None:
|
|
with pytest.raises(ValidationError):
|
|
SelectionSpec(top_n=5, hold_top_x=10)
|
|
|
|
def test_substitute_and_defer_are_mutually_exclusive(self) -> None:
|
|
with pytest.raises(ValidationError):
|
|
SelectionSpec(top_n=5, allow_substitute=True, defer_buy=True)
|
|
|
|
def test_no_substitute_keeps_pool_membership(self) -> None:
|
|
"""defer 模式(不替补):持仓必属候选池,绝不出现池外标的。"""
|
|
daily = synthetic_daily({f"60000{i}.SH": 0.006 - 0.0015 * i for i in range(6)}, n=260)
|
|
res = LocalEngine().run_backtest(daily, _spec(top_n=2, hold_top_x=2, m=6, y=6))
|
|
for pos in res.positions:
|
|
same_day_pool = {p.symbol for p in res.selection_history if p.date == pos.date}
|
|
assert pos.symbol in same_day_pool, f"{pos.symbol} 不在当日候选池 {same_day_pool}"
|
|
|
|
|
|
class TestDeferBuy:
|
|
"""顺延买入:涨停当日不成交,之后首个不涨停交易日按收盘价买入。"""
|
|
|
|
def _limit_up_frame(self) -> tuple[pd.DataFrame, str, date, date]:
|
|
"""构造:600000.SH 在首个调仓日涨停,之后恢复;动量高于 600001.SH。"""
|
|
daily = synthetic_daily({"600000.SH": 0.006, "600001.SH": 0.001}, n=260)
|
|
dates = sorted(pd.to_datetime(daily["trade_date"].unique()))
|
|
d0 = next(d for d in dates if d.date() >= date(2024, 3, 1)) # 首个调仓日
|
|
d1 = dates[dates.index(d0) + 1]
|
|
prev = dates[dates.index(d0) - 1]
|
|
prev_close = float(
|
|
daily[(daily["symbol"] == "600000.SH") & (daily["trade_date"] == prev.date())][
|
|
"close"
|
|
].iloc[0]
|
|
)
|
|
mask = (daily["symbol"] == "600000.SH") & (daily["trade_date"] == d0.date())
|
|
daily.loc[mask, "close"] = prev_close * 1.10 # 主板涨停
|
|
daily.loc[mask, "high"] = prev_close * 1.10
|
|
return daily, "600000.SH", d0.date(), d1.date()
|
|
|
|
def test_buy_deferred_to_next_tradable_day(self) -> None:
|
|
daily, sym, d0, d1 = self._limit_up_frame()
|
|
res = LocalEngine().run_backtest(daily, _spec(top_n=2, hold_top_x=2, m=6, y=6))
|
|
# 调仓日意图登记为未成交,原因含「涨停」与「顺延」
|
|
rejects = [
|
|
a for a in res.signal_history
|
|
if a.date == d0 and a.symbol == sym and a.signal == "BUY" and not a.filled
|
|
]
|
|
assert rejects and "涨停" in (rejects[0].reject_reason or "")
|
|
assert "顺延" in (rejects[0].reject_reason or "")
|
|
# 当日无成交
|
|
assert not [a for a in res.fills if a.date == d0 and a.symbol == sym]
|
|
# 次一交易日按收盘价成交(价格 = 该日 close,滑点为 0)
|
|
fills = [a for a in res.fills if a.symbol == sym and a.signal == "BUY"]
|
|
assert fills, "顺延后应成交"
|
|
assert fills[0].date == d1
|
|
px = float(
|
|
daily[(daily["symbol"] == sym) & (daily["trade_date"] == d1)]["close"].iloc[0]
|
|
)
|
|
assert fills[0].price == pytest.approx(px, rel=1e-6)
|
|
|
|
def test_defer_disabled_gives_up_immediately(self) -> None:
|
|
"""defer_buy=False:涨停当日被拒后直接放弃,不顺延(区间内无下一次调仓)。"""
|
|
daily, sym, d0, d1 = self._limit_up_frame()
|
|
res = LocalEngine().run_backtest(
|
|
daily, _spec(top_n=2, hold_top_x=2, m=6, y=6, end=date(2024, 6, 28), defer_buy=False)
|
|
)
|
|
assert not [a for a in res.fills if a.symbol == sym and a.signal == "BUY"]
|
|
rejects = [
|
|
a for a in res.signal_history
|
|
if a.date == d0 and a.symbol == sym and a.signal == "BUY" and not a.filled
|
|
]
|
|
assert rejects
|
|
assert "顺延" not in (rejects[0].reject_reason or "")
|
|
|
|
def test_pending_order_cleared_at_next_rebalance(self) -> None:
|
|
"""顺延单不跨调仓:3/1 挂的单在 4/1 调仓时作废,4/1 是新的调仓尝试。"""
|
|
daily = synthetic_daily({"600000.SH": 0.006, "600001.SH": 0.001}, n=260)
|
|
dates = sorted(pd.to_datetime(daily["trade_date"].unique()))
|
|
d0 = min(d for d in dates if d.date() >= date(2024, 3, 1))
|
|
d1 = min(d for d in dates if d.date() >= date(2024, 4, 1))
|
|
# 2024-03-01 ~ 04-01 连续每日涨停(链式 +10%)
|
|
seed = dates[dates.index(d0) - 1]
|
|
prev_close = float(
|
|
daily[(daily["symbol"] == "600000.SH") & (daily["trade_date"] == seed.date())][
|
|
"close"
|
|
].iloc[0]
|
|
)
|
|
for d in dates:
|
|
if not (d0.date() <= d.date() <= d1.date()):
|
|
continue
|
|
prev_close = prev_close * 1.10
|
|
mask = (daily["symbol"] == "600000.SH") & (daily["trade_date"] == d.date())
|
|
daily.loc[mask, "close"] = prev_close
|
|
daily.loc[mask, "high"] = prev_close
|
|
res = LocalEngine().run_backtest(
|
|
daily, _spec(top_n=1, hold_top_x=1, end=date(2024, 4, 30), m=12, y=1)
|
|
)
|
|
# 3/1 与 4/1 两次调仓尝试均被涨停拒绝
|
|
reject_dates = {
|
|
a.date for a in res.signal_history
|
|
if a.symbol == "600000.SH" and a.signal == "BUY" and not a.filled
|
|
}
|
|
assert d0.date() in reject_dates and d1.date() in reject_dates
|
|
# 3/1 挂出的顺延单在 4/1 之前一次都没成交(期间每日涨停)
|
|
assert not [a for a in res.fills if a.date < d1.date()]
|
|
# 4/1 之后涨停解除 → 新一次调仓的顺延单成交
|
|
fills = [a for a in res.fills if a.symbol == "600000.SH"]
|
|
assert len(fills) == 1 and fills[0].date > d1.date()
|
|
|
|
|
|
class TestSymbolCurves:
|
|
def test_symbol_curves_and_marks(self) -> None:
|
|
daily = synthetic_daily({"600000.SH": 0.004, "600001.SH": -0.002}, n=260)
|
|
res = LocalEngine().run_backtest(daily, _spec(top_n=1, m=6, y=6))
|
|
assert res.symbol_curves, "应输出个股收益曲线"
|
|
for curve in res.symbol_curves:
|
|
assert curve.points, f"{curve.symbol} 曲线无数据点"
|
|
if curve.marks:
|
|
assert all(m.symbol == curve.symbol for m in curve.marks)
|
|
assert all(m.filled for m in curve.marks)
|
|
# 成交记录中的股票都应有曲线
|
|
traded = {a.symbol for a in res.fills if a.symbol}
|
|
assert traded <= {c.symbol for c in res.symbol_curves}
|
|
|
|
def test_symbol_curve_pct_matches_holding_gain(self) -> None:
|
|
"""单股全程持有:曲线期末收益 ≈ 期末价/建仓日收盘 - 1(零成本下)。"""
|
|
daily = synthetic_daily({"600000.SH": 0.004}, n=130)
|
|
res = LocalEngine().run_backtest(
|
|
daily, _spec(top_n=1, m=12, y=12, end=date(2024, 6, 28))
|
|
)
|
|
curve = res.symbol_curves[0]
|
|
closes = daily[daily["symbol"] == "600000.SH"].sort_values("trade_date")
|
|
entry_close = float(closes[closes["trade_date"] == date(2024, 3, 1)]["close"].iloc[0])
|
|
exit_close = float(closes["close"].iloc[-1])
|
|
expected = (exit_close / entry_close - 1) * 100
|
|
assert curve.final_return_pct == pytest.approx(expected, rel=1e-3)
|
|
# 建仓当日曲线为 0%(当日收盘成交,不计当日涨跌)
|
|
assert curve.points[0].date == date(2024, 3, 1)
|
|
assert curve.points[0].value == pytest.approx(0.0, abs=1e-9)
|
|
|
|
class TestFirstDayWithoutPrevClose:
|
|
"""数据窗口起点无上一有效收盘价时的买入处理(案例 start=2020-01-01 实测命中)。
|
|
|
|
`_buyable` 在无前收时无法判定涨停:若按「不可买」处理,回测首个调仓日会被
|
|
整体放弃(顺延到次日,白付一天空仓);现按「可买」处理并在 unimplemented
|
|
中如实标注次数(AGENT.md §24)。
|
|
"""
|
|
|
|
@staticmethod
|
|
def _runner(daily: pd.DataFrame, spec: ResearchSpec):
|
|
"""用常量 score 直接驱动 Runner:绕开因子预热,隔离「首个交易日」这一场景。"""
|
|
from app.quant.local_engine import TopKBacktestRunner
|
|
|
|
close = daily.pivot(index="trade_date", columns="symbol", values="close")
|
|
close.index = pd.to_datetime(close.index)
|
|
score = pd.DataFrame(1.0, index=close.index, columns=close.columns)
|
|
return TopKBacktestRunner(spec, score, close)
|
|
|
|
def test_buy_on_first_bar_without_prev_close(self) -> None:
|
|
daily = synthetic_daily({"600000.SH": 0.004, "600001.SH": 0.002}, n=20)
|
|
first_day = min(daily["trade_date"])
|
|
res = self._runner(
|
|
daily, _spec(top_n=1, m=1, y=1, start=first_day, end=max(daily["trade_date"]))
|
|
).run()
|
|
buy_days = [a.date for a in res.fills if a.signal == "BUY"]
|
|
assert buy_days and min(buy_days) == first_day, "首个交易日即应成交,不应被整体顺延"
|
|
assert any("无法判定涨停" in n for n in res.unimplemented), "应如实标注无前收的判定降级"
|
|
|
|
def test_no_note_when_prev_close_available(self) -> None:
|
|
daily = synthetic_daily({"600000.SH": 0.004, "600001.SH": 0.002}, n=60)
|
|
res = LocalEngine().run_backtest(daily, _spec(top_n=1, m=1, y=1))
|
|
assert not any("无法判定涨停" in n for n in res.unimplemented)
|
|
|
|
|
|
class TestSymbolCurvePayloadBound:
|
|
def test_curves_capped_with_note(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""曲线数量上限生效并如实标注(结果体积约束:超限会让落库/传输不可用)。"""
|
|
from app.quant import local_engine as le
|
|
|
|
monkeypatch.setattr(le, "_MAX_SYMBOL_CURVES", 2)
|
|
drifts = {f"60000{i}.SH": 0.001 * (i % 3) for i in range(10)}
|
|
daily = synthetic_daily(drifts, n=90)
|
|
res = LocalEngine().run_backtest(daily, _spec(top_n=10, hold_top_x=10, m=6, y=6))
|
|
assert len(res.symbol_curves) == 2
|
|
note = [n for n in res.unimplemented if "个股收益曲线" in n]
|
|
assert note and "共持有" in note[0]
|
|
|
|
def test_no_flat_points_when_not_held(self) -> None:
|
|
"""未持有期间不落点(只落持仓日 + 建仓基准点),显著压缩结果体积。"""
|
|
daily = synthetic_daily({"600000.SH": 0.004, "600001.SH": 0.002}, n=260)
|
|
res = LocalEngine().run_backtest(daily, _spec(top_n=1, m=6, y=6))
|
|
all_days = sorted(daily["trade_date"].unique())
|
|
for curve in res.symbol_curves:
|
|
point_days = {p.date for p in curve.points}
|
|
assert point_days <= set(all_days)
|
|
assert len(point_days) < len(all_days), f"{curve.symbol} 不应逐日落点"
|