"""组合回测引擎单测(合成数据,确定性,不依赖数据库/因子注册表)。 验证三件用户确认的语义: 1. Borda 秩和打分:两策略排名不同 → 综合排序可手算预测。 2. 持仓天数区间 [Tmin, Tmax]:超 Tmax 强制了结;未满 Tmin 即使掉出 TopN 也暂留。 3. 调仓时机 daily/weekly/monthly 产生不同的调仓次数。 """ from __future__ import annotations from datetime import date, timedelta import pandas as pd import pytest from app.domain.entities.combo import BacktestCombo from app.domain.entities.research import CostSpec from app.quant.combo_engine import HoldingBandRunner, borda_combine def _business_days(start: date, n: int) -> list[date]: """生成 n 个连续工作日(跳过周末),用作合成行情索引。""" out: list[date] = [] d = start while len(out) < n: if d.weekday() < 5: out.append(d) d += timedelta(days=1) return out def _flat_close(symbols: list[str], days: list[date], price: float = 100.0) -> pd.DataFrame: """所有股票恒定价格的面板(收益为 0,便于隔离「选股/调仓」逻辑)。""" idx = pd.to_datetime(days) return pd.DataFrame(price, index=idx, columns=symbols) # ---------- 1. Borda 秩和 ---------- def test_borda_combine_hand_computed(): """两策略排名不同,综合分 = Σ(1/名次),可手算。""" day = pd.Timestamp("2024-01-02") # 策略1:A > B > C;策略2:C > A > B p1 = pd.DataFrame({"A": [3.0], "B": [2.0], "C": [1.0]}, index=[day]) p2 = pd.DataFrame({"A": [2.0], "B": [1.0], "C": [3.0]}, index=[day]) combined = borda_combine([p1, p2]).loc[day] # A: 1/1 + 1/2 = 1.5;C: 1/3 + 1/1 = 1.333;B: 1/2 + 1/3 = 0.833 assert combined["A"] == pytest.approx(1.5) assert combined["C"] == pytest.approx(1.0 / 3 + 1.0) assert combined["B"] == pytest.approx(1.0 / 2 + 1.0 / 3) order = combined.sort_values(ascending=False).index.tolist() assert order == ["A", "C", "B"] # 并集后统一排序:A、C 进 Top2,B 落选 def test_borda_missing_symbol_contributes_zero(): """某策略面板里没有某股票(NaN)→ 该策略对它贡献 0,但不影响其它策略的贡献。""" day = pd.Timestamp("2024-01-02") p1 = pd.DataFrame({"A": [3.0], "B": [2.0]}, index=[day]) # 策略1 只有 A、B p2 = pd.DataFrame({"A": [1.0], "C": [2.0]}, index=[day]) # 策略2 只有 A、C combined = borda_combine([p1, p2]).loc[day] assert combined["A"] == pytest.approx(1.0 + 1.0 / 2) # 两策略都覆盖 A assert combined["B"] == pytest.approx(1.0 / 2) # 只被策略1 覆盖 assert combined["C"] == pytest.approx(1.0) # 只被策略2 覆盖(在其面板里排第 1) # ---------- 2. 持仓天数区间 ---------- def _make_combo(**overrides) -> BacktestCombo: base = dict( name="t", strategy_ids=["S1"], initial_capital=1_000_000.0, hold_count=1, hold_min_days=0, hold_max_days=None, rebalance_freq="daily", period=(date(2024, 1, 2), date(2024, 1, 31)), ) base.update(overrides) return BacktestCombo(**base) def test_tmax_force_exit_respected(): """恒价 + N=1 + 永远选 A + Tmax=5 + 日频:A 持有超过 5 天即被强制卖出再买回, 任何一笔交易的持有天数都不应明显超过 Tmax。""" symbols = ["A", "B", "C"] days = _business_days(date(2024, 1, 2), 30) close = _flat_close(symbols, days) # A 永远最高分 → 永远 Top1 score = pd.DataFrame( {"A": [3.0] * len(days), "B": [2.0] * len(days), "C": [1.0] * len(days)}, index=pd.to_datetime(days), ) combo = _make_combo(hold_count=1, hold_min_days=0, hold_max_days=5, rebalance_freq="daily") runner = HoldingBandRunner( combo=combo, costs=CostSpec(min_commission=0.0), score=score, close=close, ) result = runner.run() assert result.trades, "应产生交易" # 交易日索引:用引擎同一口径(交易日)验证「任何一笔持仓都不超过 Tmax 个交易日」 tday_pos = {pd.Timestamp(d): i for i, d in enumerate(close.index)} def trading_span(a, b): return tday_pos[pd.Timestamp(b)] - tday_pos[pd.Timestamp(a)] for t in result.trades: span = trading_span(t.entry_date, t.exit_date) # 卖出发生在「held > Tmax」的第一个交易日 → 跨度最多 Tmax+1 个交易日 assert span <= 5 + 1, f"持仓跨 {span} 个交易日 > Tmax+1,Tmax 安全阀失效:{t}" # 确实反复「卖后再买」—— 证明 Tmax 在强制换手,而不是一直死拿 buys = [a for a in result.signal_history if a.signal == "BUY" and a.filled] assert len(buys) >= 4, f"Tmax=5 在 30 个交易日内应触发多次重买,实际仅 {len(buys)} 次" def test_tmin_protects_against_churn(): """N=1,第 2 天起 B 变成最高分(A 掉出 Top1),但 Tmin=10 → A 在满 10 天前不被卖出。""" symbols = ["A", "B"] days = _business_days(date(2024, 1, 2), 20) close = _flat_close(symbols, days) # 第 0 天 A 最高;第 1 天起 B 最高 a_scores = [3.0] + [1.0] * (len(days) - 1) b_scores = [1.0] + [3.0] * (len(days) - 1) score = pd.DataFrame({"A": a_scores, "B": b_scores}, index=pd.to_datetime(days)) combo = _make_combo(hold_count=1, hold_min_days=10, hold_max_days=None, rebalance_freq="daily") runner = HoldingBandRunner( combo=combo, costs=CostSpec(min_commission=0.0), score=score, close=close, ) result = runner.run() # A 应在第 0 天买入 a_buys = [a for a in result.signal_history if a.symbol == "A" and a.signal == "BUY" and a.filled] assert a_buys, "A 应在首日买入" a_sells = [t for t in result.trades if t.symbol == "A"] if a_sells: # 若最终卖出,持有天数必须 ≥ Tmin(不能在满 10 天前因掉出 TopN 被卖) for t in a_sells: assert (t.exit_date - t.entry_date).days >= 10, ( f"A 仅持 {(t.exit_date - t.entry_date).days} 天就被卖,违反 Tmin=10 保护" ) # 关键断言:前 9 个工作日内 A 不应被卖出(Tmin 保护生效) tday_pos = {pd.Timestamp(d): i for i, d in enumerate(close.index)} early_sells = [ a for a in result.signal_history if a.symbol == "A" and a.signal == "SELL" and a.filled and tday_pos[pd.Timestamp(a.date)] - tday_pos[pd.Timestamp(days[0])] < 10 ] assert not early_sells, f"Tmin 保护失效:A 在 10 个交易日内被卖出 {early_sells}" # ---------- 3. 调仓时机 ---------- def test_rebalance_freq_changes_cadence(): """同一份数据,daily 的调仓日数 > weekly > monthly(用 selection_history 的 distinct 日期数衡量)。""" symbols = ["A", "B", "C"] days = _business_days(date(2024, 1, 2), 60) close = _flat_close(symbols, days) score = pd.DataFrame( {"A": [3.0] * len(days), "B": [2.0] * len(days), "C": [1.0] * len(days)}, index=pd.to_datetime(days), ) def run_with(freq: str) -> int: combo = _make_combo( hold_count=2, rebalance_freq=freq, period=(days[0], days[-1]), ) r = HoldingBandRunner( combo=combo, costs=CostSpec(min_commission=0.0), score=score, close=close, ).run() return len({p.date for p in r.selection_history}) daily_n, weekly_n, monthly_n = run_with("daily"), run_with("weekly"), run_with("monthly") assert daily_n > weekly_n > monthly_n, ( f"调仓频次应 daily({daily_n}) > weekly({weekly_n}) > monthly({monthly_n})" ) # ---------- 实体校验 ---------- def test_combo_validates_hold_band_and_freq(): with pytest.raises(ValueError): BacktestCombo( name="x", strategy_ids=["A"], hold_count=1, hold_min_days=20, hold_max_days=5, # Tmax < Tmin rebalance_freq="monthly", period=(date(2024, 1, 1), date(2024, 2, 1)), ) with pytest.raises(ValueError): BacktestCombo( name="x", strategy_ids=["A"], hold_count=1, rebalance_freq="yearly", # 非法频率 period=(date(2024, 1, 1), date(2024, 2, 1)), ) with pytest.raises(ValueError): BacktestCombo( name="x", strategy_ids=["A", "A"], hold_count=1, # 重复策略 rebalance_freq="monthly", period=(date(2024, 1, 1), date(2024, 2, 1)), )