"""择股/调仓双周期(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} 不应逐日落点"