Files
signal-platform/tests/unit/test_backtest_service.py
T

1250 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for the historical backtest harness."""
from __future__ import annotations
import json
import math
from datetime import date, timedelta
from types import SimpleNamespace
import pytest
from app.models.ohlcv import OHLCVRecord
from app.models.ticker import Ticker
from app.services import backtest_service as bt
from app.services.outcome_service import (
OUTCOME_EXPIRED,
OUTCOME_STOP_HIT,
OUTCOME_TARGET_HIT,
)
from tests.conftest import _test_session_factory # type: ignore
@pytest.fixture
async def session():
async with _test_session_factory() as s:
yield s
def _cand(
prob: float,
outcome: str,
rr: float,
qualified: bool = True,
direction: str = "long",
risk_pct: float = 0.05,
hold_days: int = 10,
) -> dict:
target_hit = outcome == OUTCOME_TARGET_HIT
realized = rr if target_hit else (0.0 if outcome == OUTCOME_EXPIRED else -1.0)
return {
"primary_prob": prob,
"outcome": outcome,
"target_hit": target_hit,
"rr": rr,
"realized_r": realized,
"qualified": qualified,
"direction": direction,
"risk_pct": risk_pct,
"hold_days": hold_days,
}
# Round-trip cost in R for the default _cand risk_pct: 2 * 0.001 / 0.05 = 0.04R.
_COST_R_005 = 2 * bt.COST_PER_SIDE / 0.05
def _bar(high: float, low: float, close: float, open_: float | None = None) -> SimpleNamespace:
"""Synthetic daily bar. ``open`` defaults to the high so a stop is pierced
intraday (fill at the stop level); pass an explicit open beyond the stop to
model a gap through it."""
return SimpleNamespace(
high=high, low=low, close=close, open=open_ if open_ is not None else high
)
def _signal_test_series(extra_return: float = 0.0) -> tuple[list[date], list[float], list[float], dict[date, float]]:
base = date(2024, 1, 1)
dates = [base + timedelta(days=i) for i in range(280)]
benchmark = [100.0]
closes = [100.0]
for i in range(1, len(dates)):
market_ret = 0.0004 + 0.002 * math.sin(i / 9.0)
benchmark.append(benchmark[-1] * (1.0 + market_ret))
# Same market beta for both test stocks; only ``extra_return`` is
# idiosyncratic drift, which residual momentum should keep.
stock_ret = 1.4 * market_ret + extra_return
closes.append(closes[-1] * (1.0 + stock_ret))
highs = [c * 1.01 for c in closes]
benchmark_closes = dict(zip(dates, benchmark))
return dates, closes, highs, benchmark_closes
def test_signal_values_emit_residual_momentum_only_with_benchmark():
dates, closes, highs, benchmark = _signal_test_series(extra_return=0.0008)
no_benchmark = bt._signal_values(dates, closes, highs, 260)
with_benchmark = bt._signal_values(dates, closes, highs, 260, benchmark)
assert "mom_12_1" in no_benchmark
assert "mom_12_1_resid" not in no_benchmark
assert "mom_12_1_resid" in with_benchmark
def test_residual_momentum_removes_market_beta_but_keeps_specific_drift():
dates, pure_beta, highs, benchmark = _signal_test_series(extra_return=0.0)
_, drift_stock, drift_highs, _ = _signal_test_series(extra_return=0.0008)
pure = bt._signal_values(dates, pure_beta, highs, 260, benchmark)
drift = bt._signal_values(dates, drift_stock, drift_highs, 260, benchmark)
assert pure["mom_12_1_resid"] == pytest.approx(0.0, abs=0.03)
assert drift["mom_12_1_resid"] > pure["mom_12_1_resid"] + 0.12
def test_assigns_raw_and_residual_percentiles_independently():
cands = [
{"iso_week": (2026, 1), "momentum": 0.10, "residual_momentum": 0.30},
{"iso_week": (2026, 1), "momentum": 0.30, "residual_momentum": 0.10},
{"iso_week": (2026, 1), "momentum": 0.20, "residual_momentum": 0.20},
]
bt._assign_momentum_percentiles(cands)
bt._assign_residual_momentum_percentiles(cands)
by_raw = {c["momentum"]: c["momentum_percentile"] for c in cands}
by_resid = {c["residual_momentum"]: c["residual_momentum_percentile"] for c in cands}
assert by_raw[0.30] == 100.0
assert by_raw[0.10] == 0.0
assert by_resid[0.30] == 100.0
assert by_resid[0.10] == 0.0
def test_activation_percentile_prefers_residual_with_raw_fallback():
cands = [
{"momentum_percentile": 80.0, "residual_momentum_percentile": 95.0},
{"momentum_percentile": 70.0, "residual_momentum_percentile": None},
]
bt._assign_activation_momentum_percentiles(cands)
assert cands[0][bt.PRODUCTION_PERCENTILE_KEY] == 95.0
assert cands[1][bt.PRODUCTION_PERCENTILE_KEY] == 70.0
def test_low_volatility_percentile_prefers_lower_realized_vol():
cands = [
{"iso_week": (2026, 1), "vol_6m": 0.04},
{"iso_week": (2026, 1), "vol_6m": 0.01},
{"iso_week": (2026, 1), "vol_6m": 0.02},
]
bt._assign_low_volatility_percentiles(cands)
assert cands[1][bt.LOW_VOL_PERCENTILE_KEY] == 100.0
assert cands[2][bt.LOW_VOL_PERCENTILE_KEY] == 50.0
assert cands[0][bt.LOW_VOL_PERCENTILE_KEY] == 0.0
def test_residual_low_vol_blend_is_research_only_rank():
cands = [{
bt.PRODUCTION_PERCENTILE_KEY: 80.0,
bt.LOW_VOL_PERCENTILE_KEY: 60.0,
}]
bt._assign_residual_low_vol_blend(cands)
assert cands[0][bt.RESIDUAL_LOW_VOL_BLEND_KEY] == 74.0
def test_residual_high_vol_blend_is_research_only_rank():
cands = [{
bt.PRODUCTION_PERCENTILE_KEY: 80.0,
bt.VOL_PERCENTILE_KEY: 60.0,
}]
bt._assign_residual_high_vol_blend(cands)
assert cands[0][bt.RESIDUAL_HIGH_VOL_BLEND_90_10_KEY] == 78.0
assert cands[0][bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY] == 76.0
assert cands[0][bt.RESIDUAL_HIGH_VOL_BLEND_KEY] == 74.0
assert cands[0][bt.RESIDUAL_HIGH_VOL_BLEND_60_40_KEY] == 72.0
def test_structural_overlay_is_a_frozen_five_percent_rank_nudge():
cands = [
{
bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY: 80.0,
"structural_overlay_pass": True,
},
{
bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY: 80.0,
"structural_overlay_pass": False,
},
{bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY: 80.0},
]
bt._assign_structural_overlay_score(cands)
assert bt.STRUCTURAL_OVERLAY_WEIGHT == 0.05
assert cands[0][bt.STRUCTURAL_OVERLAY_SCORE_KEY] == pytest.approx(81.0)
assert cands[1][bt.STRUCTURAL_OVERLAY_SCORE_KEY] == pytest.approx(76.0)
assert cands[2][bt.STRUCTURAL_OVERLAY_SCORE_KEY] is None
def test_structural_overlay_monitor_strategy_is_opt_in(monkeypatch):
monkeypatch.setenv("BACKTEST_SR_VARIANT", "production_control")
assert bt._portfolio_monitor_strategies() == bt.PORTFOLIO_MONITOR_STRATEGIES
monkeypatch.setenv("BACKTEST_SR_VARIANT", bt.STRUCTURAL_OVERLAY_VARIANT)
strategies = bt._portfolio_monitor_strategies()
overlay = strategies[-1]
assert overlay["strategy"] == bt.STRUCTURAL_OVERLAY_PORTFOLIO_STRATEGY
assert overlay["ranking_key"] == bt.STRUCTURAL_OVERLAY_SCORE_KEY
assert overlay["use_live_config"] is True
assert overlay["is_production"] is False
def test_strategy_variants_keep_only_current_research_candidates():
variants = {cfg["variant"]: cfg for cfg in bt.STRATEGY_VARIANTS}
assert "production_raw_80_fixed10" not in variants
assert "raw_80_regime_scaled" not in variants
assert "residual_80_regime_scaled" not in variants
assert "residual_90_fixed10" not in variants
assert "raw_90_fixed15" not in variants
assert "residual_80_fixed20" not in variants
assert variants["production_residual_80_fixed10"]["percentile_key"] == bt.PRODUCTION_PERCENTILE_KEY
assert variants["legacy_raw_80_fixed10"]["percentile_key"] == bt.RAW_PERCENTILE_KEY
assert variants["residual_80_fixed15"]["max_positions"] == 15
assert variants["residual80_lowvol50_fixed10"]["filters"] == (
(bt.PRODUCTION_PERCENTILE_KEY, 80.0),
(bt.LOW_VOL_PERCENTILE_KEY, 50.0),
)
assert variants["residual80_lowvol_blend_fixed10"]["ranking_key"] == bt.RESIDUAL_LOW_VOL_BLEND_KEY
assert variants["residual80_highvol50_fixed10"]["filters"] == (
(bt.PRODUCTION_PERCENTILE_KEY, 80.0),
(bt.VOL_PERCENTILE_KEY, 50.0),
)
assert variants["residual80_highvol_blend90_10_fixed10"]["ranking_key"] == bt.RESIDUAL_HIGH_VOL_BLEND_90_10_KEY
assert variants["residual80_highvol_blend80_20_fixed10"]["ranking_key"] == bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY
assert variants["residual80_highvol_blend_fixed10"]["ranking_key"] == bt.RESIDUAL_HIGH_VOL_BLEND_KEY
assert variants["residual80_highvol_blend60_40_fixed10"]["ranking_key"] == bt.RESIDUAL_HIGH_VOL_BLEND_60_40_KEY
assert variants["highvol80_fixed10"]["percentile_key"] == bt.VOL_PERCENTILE_KEY
assert variants["lowvol80_fixed10"]["percentile_key"] == bt.LOW_VOL_PERCENTILE_KEY
assert all(cfg["risk_scale"] is None for cfg in bt.STRATEGY_VARIANTS)
def test_low_vol_strategy_variant_applies_secondary_filter():
cfg = {
"percentile_key": bt.PRODUCTION_PERCENTILE_KEY,
"cutoff": 80.0,
"filters": (
(bt.PRODUCTION_PERCENTILE_KEY, 80.0),
(bt.LOW_VOL_PERCENTILE_KEY, 70.0),
),
}
base = {
"meets_core": True,
"direction": "long",
bt.PRODUCTION_PERCENTILE_KEY: 85.0,
}
assert bt._qualifies_strategy_variant({**base, bt.LOW_VOL_PERCENTILE_KEY: 75.0}, cfg)
assert not bt._qualifies_strategy_variant({**base, bt.LOW_VOL_PERCENTILE_KEY: 65.0}, cfg)
def test_strategy_variant_sims_emit_fixed_variants_without_mutating_qualified(monkeypatch):
cands = [{
"qualified": False,
"meets_core": True,
"direction": "long",
"momentum_percentile": 90.0,
"residual_momentum_percentile": 91.0,
"activation_momentum_percentile": 91.0,
"low_vol_6m_percentile": 80.0,
"residual_low_vol_blend_score": 87.7,
"vol_6m_percentile": 20.0,
"residual_high_vol_blend_90_10_score": 83.9,
"residual_high_vol_blend_80_20_score": 76.8,
"residual_high_vol_blend_score": 69.7,
"residual_high_vol_blend_60_40_score": 62.6,
}]
calls = []
def fake_sim(candidates, prices, spy_closes, exit_policy, hold_days, **kwargs):
calls.append({"exit_policy": exit_policy, "hold_days": hold_days, **kwargs})
return {
"starting_capital": bt.SIM_STARTING_CAPITAL,
"final_equity": 11_000.0,
"total_return_pct": 10.0,
"cagr_pct": 9.0,
"max_drawdown_pct": 5.0,
"sharpe": 1.1,
"trades": 1,
"win_rate": 100.0,
"avg_trade_pnl": 100.0,
"best_trade_r": 1.0,
"worst_trade_r": 1.0,
"best_trade_pnl": 100.0,
"worst_trade_pnl": 100.0,
"avg_hold_days": 30.0,
"skipped_book_full": 0,
"spy_return_pct": 1.0,
"yearly_returns": [],
"start_date": "2026-01-01",
"end_date": "2026-02-01",
}
monkeypatch.setattr(bt, "_simulate_portfolio", fake_sim)
rows = bt._strategy_variant_sims(cands, {}, {}, 30)
assert [r["variant"] for r in rows] == [cfg["variant"] for cfg in bt.STRATEGY_VARIANTS]
assert all(call["exit_policy"] == "hold" for call in calls)
assert any(call["ranking_key"] == bt.PRODUCTION_PERCENTILE_KEY for call in calls)
assert any(call["ranking_key"] == bt.RAW_PERCENTILE_KEY for call in calls)
assert any(call["ranking_key"] == bt.RESIDUAL_LOW_VOL_BLEND_KEY for call in calls)
assert any(call["ranking_key"] == bt.RESIDUAL_HIGH_VOL_BLEND_90_10_KEY for call in calls)
assert any(call["ranking_key"] == bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY for call in calls)
assert any(call["ranking_key"] == bt.RESIDUAL_HIGH_VOL_BLEND_KEY for call in calls)
assert any(call["ranking_key"] == bt.RESIDUAL_HIGH_VOL_BLEND_60_40_KEY for call in calls)
assert any(call["ranking_key"] == bt.VOL_PERCENTILE_KEY for call in calls)
assert any(call["ranking_key"] == bt.LOW_VOL_PERCENTILE_KEY for call in calls)
assert any(call["max_positions"] == 15 for call in calls)
assert cands[0]["qualified"] is False
def test_exit_policy_sims_use_80_20_entry_variant(monkeypatch):
calls = []
def fake_sim(candidates, prices, spy_closes, exit_policy, hold_days, **kwargs):
calls.append({"exit_policy": exit_policy, "hold_days": hold_days, **kwargs})
return {
"starting_capital": bt.SIM_STARTING_CAPITAL,
"final_equity": 11_000.0,
"total_return_pct": 10.0,
"cagr_pct": 9.0,
"max_drawdown_pct": 5.0,
"sharpe": 1.1,
"trades": 1,
"win_rate": 100.0,
"avg_trade_pnl": 100.0,
"best_trade_r": 1.0,
"worst_trade_r": 1.0,
"best_trade_pnl": 100.0,
"worst_trade_pnl": 100.0,
"avg_hold_days": 30.0,
"exit_reasons": {exit_policy: 1},
"skipped_book_full": 0,
"spy_return_pct": 1.0,
"yearly_returns": [],
"start_date": "2026-01-01",
"end_date": "2026-02-01",
}
monkeypatch.setattr(bt, "_simulate_portfolio", fake_sim)
rows = bt._exit_policy_sims([], {}, {}, 30)
assert [r["exit_policy"] for r in rows] == [
cfg["exit_policy"] for cfg in bt.EXIT_POLICY_VARIANTS
]
assert all(r["entry_variant"] == bt.EXIT_ENTRY_VARIANT for r in rows)
assert all(call["ranking_key"] == bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY for call in calls)
assert all(call["exit_policy"] != "target" for call in calls)
def test_build_research_recommendation_applies_promotion_rules():
report = {
"strategy_variants": {"variants": [
{"variant": "production_residual_80_fixed10", "label": "Base", "sharpe": 1.40,
"max_drawdown_pct": 20.0, "cagr_pct": 32.0, "skipped_book_full": 7},
{"variant": "residual_80_fixed15", "label": "Capacity", "sharpe": 1.39,
"max_drawdown_pct": 20.0, "cagr_pct": 32.0, "skipped_book_full": 0},
{"variant": "raw_90_fixed10", "label": "Cutoff 90", "sharpe": 1.25,
"max_drawdown_pct": 19.0, "cagr_pct": 28.0},
{"variant": "residual80_highvol_blend_fixed10", "label": "High-vol 70/30",
"sharpe": 1.68, "max_drawdown_pct": 21.0, "cagr_pct": 43.0},
{"variant": "residual80_highvol_blend80_20_fixed10", "label": "High-vol 80/20",
"sharpe": 1.55, "max_drawdown_pct": 19.0, "cagr_pct": 39.0},
]},
}
rec = bt._build_research_recommendation(report)
by_topic = {item["topic"]: item for item in rec["items"]}
assert by_topic["capacity_15"]["candidate"] is False
assert "not needed yet" in by_topic["capacity_15"]["text"]
assert by_topic["cutoff_90"]["candidate"] is False
assert "Cutoff 90" in by_topic["cutoff_90"]["text"]
assert by_topic["high_vol_overlay"]["candidate"] is True
assert "High-vol 80/20" in by_topic["high_vol_overlay"]["text"]
class TestStopFillR:
def test_intraday_fill_at_stop(self):
assert bt._stop_fill_r("long", 100.0, 95.0, _bar(101, 94, 96)) == pytest.approx(-1.0)
def test_gap_fill_at_open(self):
# Opens at 92, below the 95 stop → filled at the open, worse than 1R.
assert bt._stop_fill_r("long", 100.0, 95.0, _bar(93, 90, 91, open_=92)) == pytest.approx(-1.6)
def test_short_gap_fill_at_open(self):
# Short stop 105; opens at 107 above it → fill 107.
assert bt._stop_fill_r("short", 100.0, 105.0, _bar(110, 104, 108, open_=107)) == pytest.approx(-1.4)
class TestRiskAndStopDay:
def test_no_stop(self):
risk, stop_day = bt._risk_and_stop_day("long", 100.0, 95.0, [_bar(109, 101, 108)], 30)
assert risk == pytest.approx(0.05)
assert stop_day is None
def test_stop_day_is_one_based(self):
bars = [_bar(102, 99, 101), _bar(101, 94, 96)]
risk, stop_day = bt._risk_and_stop_day("long", 100.0, 95.0, bars, 30)
assert risk == pytest.approx(0.05)
assert stop_day == 2
def test_short_direction(self):
_, stop_day = bt._risk_and_stop_day("short", 100.0, 105.0, [_bar(106, 101, 104)], 30)
assert stop_day == 1
class TestTimeExits:
def test_long_exits_at_horizon_close(self):
bars = [_bar(103, 99, 102), _bar(105, 101, 104), _bar(107, 103, 106)]
res = bt._time_exits("long", 100.0, 95.0, bars, (2, 5))
assert res[2] == pytest.approx(0.8) # close 104 → +4% / 5% risk
assert res[5] == pytest.approx(1.2) # only 3 bars → last close 106
def test_stop_on_first_bar_loses_everywhere(self):
res = bt._time_exits("long", 100.0, 95.0, [_bar(101, 94, 96), _bar(105, 101, 104)], (1, 5))
assert res[1] == pytest.approx(-1.0)
assert res[5] == pytest.approx(-1.0)
def test_stop_after_short_horizon_only_hits_long_hold(self):
# Day-2 close banked by the 2-day hold; the stop on day 3 only hits n=5.
bars = [_bar(103, 99, 102), _bar(104, 100, 103), _bar(101, 94, 95)]
res = bt._time_exits("long", 100.0, 95.0, bars, (2, 5))
assert res[2] == pytest.approx(0.6) # close 103 → +3% / 5% risk
assert res[5] == pytest.approx(-1.0)
def test_short_direction(self):
res = bt._time_exits("short", 100.0, 105.0, [_bar(101, 95, 96)], (1,))
assert res[1] == pytest.approx(0.8) # close 96 → +4% / 5% risk
def test_zero_risk_returns_zero(self):
res = bt._time_exits("long", 100.0, 100.0, [_bar(103, 99, 102)], (5,))
assert res[5] == 0.0
def test_gap_through_stop_fills_at_open(self):
res = bt._time_exits("long", 100.0, 95.0, [_bar(93, 90, 91, open_=92)], (5,))
assert res[5] == pytest.approx(-1.6)
class TestTimeExitBucket:
def test_bucket(self):
cands = [
{"time_r": {5: 1.4, 21: 0.8}, "risk_pct": 0.10},
{"time_r": {5: -1.0, 21: -1.0}, "risk_pct": 0.10},
{"time_r": {5: 0.5, 21: 0.5}, "risk_pct": 0.10},
]
b = bt._time_exit_bucket(cands, 5)
assert b["hold_days"] == 5
assert b["total"] == 3
assert b["wins"] == 2
assert b["win_rate"] == pytest.approx(66.7, abs=0.1)
assert b["avg_r"] == pytest.approx(0.3, abs=0.01)
assert b["net_avg_r"] == pytest.approx(0.28, abs=0.01)
assert b["best_r"] == pytest.approx(1.4)
assert b["worst_r"] == pytest.approx(-1.0)
# No stop_day on any candidate → every hold runs the full 5 days.
assert b["avg_hold_days"] == 5.0
assert b["net_r_per_day"] == pytest.approx(0.28 / 5.0, abs=0.001)
# robustness on net rs [1.38, -1.02, 0.48]
assert b["median_net_r"] == pytest.approx(0.48, abs=0.001)
assert b["profit_factor"] == pytest.approx(1.86 / 1.02, abs=0.01)
assert b["net_avg_r_ex_top5"] == pytest.approx((0.48 - 1.02) / 2, abs=0.001)
def test_missing_hold_skipped(self):
b = bt._time_exit_bucket([{"time_r": {5: 1.0}}], 21)
assert b["total"] == 0
assert b["avg_r"] is None
def _acand(
rr: float = 2.0,
conf: float = 60.0,
action: str = "LONG_MODERATE",
mp: float | None = 90.0,
direction: str = "long",
) -> dict:
"""Ablation candidate: meets_core mirrors the default floors (min_rr 1.2,
min_confidence 55, exclude_neutral on)."""
action_dir = "long" if action.startswith("LONG") else "short" if action.startswith("SHORT") else "neutral"
meets = rr >= 1.2 and conf >= 55.0 and action_dir != "neutral" and action_dir == direction
return {
"rr": rr,
"confidence": conf,
"action": action,
"momentum_percentile": mp,
"activation_momentum_percentile": mp,
"direction": direction,
"meets_core": meets,
"risk_level": "Low",
"target_hit": True,
"outcome": OUTCOME_TARGET_HIT,
"realized_r": rr,
"risk_pct": 0.05,
"time_r": {d: 0.5 for d in bt.TIME_EXIT_DAYS},
}
class TestGateAblation:
ACTIVATION = {
"min_rr": 1.2,
"min_confidence": 55.0,
"exclude_neutral": True,
"require_high_conviction": False,
"exclude_conflicts": False,
}
def test_variant_counts(self):
cands = [
_acand(), # clears everything
_acand(conf=40.0), # fails confidence floor
_acand(rr=1.0), # fails R:R floor
_acand(action="NEUTRAL"), # fails NEUTRAL exclusion
_acand(mp=50.0), # fails the momentum cutoff
_acand(direction="short", action="SHORT_MODERATE", mp=95.0), # short — gated out
]
rows = {r["variant"]: r for r in bt._gate_ablation(cands, self.ACTIVATION, 80.0)}
assert rows["all_floors"]["total"] == 1
assert rows["no_confidence_floor"]["total"] == 2
assert rows["no_rr_floor"]["total"] == 2
assert rows["no_neutral_exclusion"]["total"] == 2
assert rows["momentum_only"]["total"] == 4
assert rows["all_floors"]["net_avg_r"] is not None
# Every variant is also graded under the hold-to-horizon exit.
assert rows["all_floors"]["hold_days"] == max(bt.TIME_EXIT_DAYS)
assert rows["all_floors"]["hold_avg_r"] == pytest.approx(0.5)
assert rows["all_floors"]["hold_net_avg_r"] is not None
assert rows["momentum_only"]["hold_total_r"] == pytest.approx(4 * 0.5, abs=0.01)
def test_threshold_zero_disables_momentum_gate(self):
# Floors only: the short and the low-momentum long both pass all_floors.
cands = [_acand(mp=50.0), _acand(direction="short", action="SHORT_MODERATE", mp=None)]
rows = {r["variant"]: r for r in bt._gate_ablation(cands, self.ACTIVATION, 0.0)}
assert rows["all_floors"]["total"] == 2
def _sim_prices(start_ord: int, closes: list[float]) -> tuple:
"""Column arrays for consecutive daily bars: open = close (no gaps),
high/low = close ± 1."""
ords = list(range(start_ord, start_ord + len(closes)))
return (
ords,
list(closes),
[c + 1.0 for c in closes],
[c - 1.0 for c in closes],
list(closes),
[1_000_000] * len(closes),
)
def _sim_cand(
sym: str, day_ord: int, entry: float, stop: float, target: float, mp: float = 90.0
) -> dict:
return {
"qualified": True,
"direction": "long",
"symbol": sym,
"date": date.fromordinal(day_ord).isoformat(),
"entry": entry,
"stop": stop,
"target": target,
"momentum_percentile": mp,
"activation_momentum_percentile": mp,
}
class TestSimulatePortfolio:
ORD = date(2025, 1, 6).toordinal()
def test_hold_policy_accounting(self):
closes = [100.0, 102.0, 104.0, 106.0, 108.0, 110.0]
prices = {"AAA": _sim_prices(self.ORD, closes)}
cand = _sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=130.0)
sim = bt._simulate_portfolio([cand], prices, None, "hold", 3)
assert sim is not None
assert sim["trades"] == 1
# 20 shares (1% risk / $5 stop distance), exit at the day-3 close 106:
# pnl = 2120 2000 2.00 entry cost 2.12 exit cost = 115.88
assert sim["final_equity"] == pytest.approx(10_115.88, abs=0.01)
assert sim["win_rate"] == 100.0
assert sim["best_trade_r"] == pytest.approx(1.2)
assert sim["avg_hold_days"] == 3.0
assert sim["max_drawdown_pct"] == 0.0
assert sim["cagr_pct"] is None # window far too short to annualize
assert sim["spy_return_pct"] is None
assert sim["yearly_returns"] == [
{"year": 2025, "return_pct": pytest.approx(1.2, abs=0.05)}
]
def test_target_policy_exits_at_target(self):
closes = [100.0, 102.0, 104.0, 106.0, 108.0, 110.0]
prices = {"AAA": _sim_prices(self.ORD, closes)}
cand = _sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=105.0)
sim = bt._simulate_portfolio([cand], prices, None, "target", 30)
assert sim is not None
assert sim["trades"] == 1
assert sim["best_trade_r"] == pytest.approx(1.0) # filled exactly at 105
def test_stop_gap_fills_at_open(self):
# Day-1 bar gaps to a 90 open, below the 95 stop → fill at the open.
ords = list(range(self.ORD, self.ORD + 2))
prices = {"AAA": (ords, [100.0, 90.0], [101.0, 92.0], [99.0, 88.0], [100.0, 91.0], [1, 1])}
cand = _sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=120.0)
sim = bt._simulate_portfolio([cand], prices, None, "hold", 30)
assert sim is not None
assert sim["trades"] == 1
assert sim["worst_trade_r"] == pytest.approx(-2.0) # (90 100) / 5
def test_sma50_policy_exits_on_close_break(self):
closes = [100.0] * 56 + [90.0, 91.0]
prices = {"AAA": _sim_prices(self.ORD, closes)}
entry_ord = self.ORD + 55
cand = _sim_cand("AAA", entry_ord, entry=100.0, stop=80.0, target=130.0)
sim = bt._simulate_portfolio([cand], prices, None, "sma50", 30)
assert sim is not None
assert sim["trades"] == 1
assert sim["exit_reasons"] == {"sma50": 1}
assert sim["worst_trade_r"] == pytest.approx(-0.5)
def test_low20_policy_exits_on_prior_low_break(self):
closes = [100.0] * 26 + [95.0, 96.0]
prices = {"AAA": _sim_prices(self.ORD, closes)}
entry_ord = self.ORD + 25
cand = _sim_cand("AAA", entry_ord, entry=100.0, stop=80.0, target=130.0)
sim = bt._simulate_portfolio([cand], prices, None, "low20", 30)
assert sim is not None
assert sim["trades"] == 1
assert sim["exit_reasons"] == {"low20": 1}
assert sim["worst_trade_r"] == pytest.approx(-0.25)
def test_nothing_qualified_returns_none(self):
assert bt._simulate_portfolio([], {}, None, "hold", 30) is None
def test_configured_entry_end_truncates_flat_calendar_tail(self, monkeypatch):
closes = [100.0 + i for i in range(100)]
prices = {"AAA": _sim_prices(self.ORD, closes)}
cand = _sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=130.0)
monkeypatch.setenv(
"BACKTEST_ENTRY_END", date.fromordinal(self.ORD).isoformat()
)
sim = bt._simulate_portfolio([cand], prices, None, "hold", 3)
assert sim is not None
assert sim["end_date"] == date.fromordinal(self.ORD + 3).isoformat()
def test_configured_entry_start_aligns_book_calendar(self, monkeypatch):
closes = [100.0 + i for i in range(10)]
prices = {"AAA": _sim_prices(self.ORD, closes)}
cand = _sim_cand(
"AAA", self.ORD + 1, entry=101.0, stop=96.0, target=130.0
)
monkeypatch.setenv(
"BACKTEST_ENTRY_START", date.fromordinal(self.ORD).isoformat()
)
sim = bt._simulate_portfolio([cand], prices, None, "hold", 3)
assert sim is not None
assert sim["start_date"] == date.fromordinal(self.ORD).isoformat()
def test_bucket_stats_counts_and_expectancy():
cands = [
_cand(70, OUTCOME_TARGET_HIT, 3.0), # +3R win
_cand(60, OUTCOME_TARGET_HIT, 2.0), # +2R win
_cand(40, OUTCOME_STOP_HIT, 3.0), # -1R loss
_cand(30, OUTCOME_EXPIRED, 3.0), # 0R expired
]
s = bt._bucket_stats(cands)
assert s["total"] == 4
assert s["wins"] == 2
assert s["losses"] == 1
assert s["expired"] == 1
# hit rate is over decided (wins+losses) only
assert s["hit_rate"] == round(2 / 3 * 100, 1)
# avg R = (3 + 2 - 1 + 0) / 4 = 1.0
assert s["avg_r"] == 1.0
assert s["total_r"] == 4.0
# net = gross minus a 0.04R round trip per candidate (risk_pct 0.05)
assert s["net_avg_r"] == pytest.approx(1.0 - _COST_R_005, abs=0.001)
assert s["net_total_r"] == pytest.approx(4.0 - 4 * _COST_R_005, abs=0.01)
assert s["best_r"] == 3.0
assert s["worst_r"] == -1.0
assert s["avg_hold_days"] == 10.0
assert s["net_r_per_day"] == pytest.approx((1.0 - _COST_R_005) / 10.0, abs=0.001)
# robustness: net rs are [2.96, 1.96, -1.04, -0.04]
assert s["median_net_r"] == pytest.approx(0.96, abs=0.001)
assert s["profit_factor"] == pytest.approx(4.92 / 1.08, abs=0.01)
# ex-top-5%: ceil(4 * 0.05) = 1 winner trimmed → mean of the remaining three
assert s["net_avg_r_ex_top5"] == pytest.approx((1.96 - 1.04 - 0.04) / 3, abs=0.001)
def test_bucket_stats_empty():
s = bt._bucket_stats([])
assert s["total"] == 0
assert s["hit_rate"] is None
assert s["avg_r"] is None
assert s["net_avg_r"] is None
def test_bucket_stats_no_risk_pct_means_no_cost():
c = _cand(50, OUTCOME_TARGET_HIT, 2.0)
del c["risk_pct"]
s = bt._bucket_stats([c])
assert s["net_avg_r"] == s["avg_r"]
assert s["net_total_r"] == s["total_r"]
def test_build_recommendation_reads_the_report():
report = {
"overall_qualified": {"net_avg_r": 0.13, "net_avg_r_ex_top5": 0.05},
"time_exit_sweep": [
{"hold_days": 21, "net_avg_r": 0.38},
{"hold_days": 30, "net_avg_r": 0.50, "net_avg_r_ex_top5": 0.21},
],
"gate_ablation": [
{"variant": "all_floors", "total": 100, "hold_net_avg_r": 0.50},
{"variant": "no_confidence_floor", "total": 130, "hold_net_avg_r": 0.49},
{"variant": "no_rr_floor", "total": 400, "hold_net_avg_r": 0.34},
{"variant": "no_neutral_exclusion", "total": 120, "hold_net_avg_r": 0.46},
],
"sweep": [
{"min_momentum_percentile": 80.0, "net_avg_r": 0.13, "total": 100},
{"min_momentum_percentile": 60.0, "net_avg_r": 0.05, "total": 300},
{"min_momentum_percentile": 0.0, "net_avg_r": -0.12, "total": 1000},
],
"portfolio_sim": {"policies": [
{"policy": "target", "cagr_pct": 23.7, "total_return_pct": 134.8,
"spy_return_pct": 95.9, "max_drawdown_pct": 20.7},
{"policy": "hold", "cagr_pct": 31.9, "total_return_pct": 203.6,
"spy_return_pct": 95.9, "max_drawdown_pct": 21.2},
]},
}
rec = bt._build_recommendation(report)
by_topic: dict[str, list[str]] = {}
for item in rec["items"]:
by_topic.setdefault(item["topic"], []).append(item["text"])
assert rec["headline"] is not None and "hold 30" in rec["headline"]
assert any("hold 30 trading days" in t for t in by_topic["exit"])
gate_texts = " | ".join(by_topic["gate"])
assert "confidence floor adds nothing" in gate_texts
assert "keep the R:R floor" in gate_texts
assert "keep the NEUTRAL exclusion" in gate_texts
assert "80" in by_topic["cutoff"][0]
assert "beats" in by_topic["benchmark"][0]
# robustness is judged under the RECOMMENDED exit (the 30d hold), not the
# target model the recommendation advises abandoning
assert any(
"not a handful of outliers" in t and "under the recommended 30d hold" in t
for t in by_topic["robustness"]
)
def test_build_recommendation_flags_outlier_dependence():
rec = bt._build_recommendation({
"overall_qualified": {"net_avg_r": 0.13, "net_avg_r_ex_top5": -0.02},
})
robustness = [i["text"] for i in rec["items"] if i["topic"] == "robustness"]
assert robustness and "WARNING" in robustness[0]
def test_build_recommendation_prefers_production_monitor_headline():
rec = bt._build_recommendation({
"portfolio_monitor": {
"production_strategy": bt.PRODUCTION_PORTFOLIO_STRATEGY,
"runs": [{
"strategy": bt.PRODUCTION_PORTFOLIO_STRATEGY,
"lookback": "all",
"lookback_label": "All history",
"cagr_pct": 44.4,
"sharpe": 1.72,
"max_drawdown_pct": 23.8,
}],
},
"overall_qualified": {},
})
assert rec["headline"] is not None
assert "3x ATR trailing exit" in rec["headline"]
assert any(item["topic"] == "production" for item in rec["items"])
def test_window_setups_too_short_returns_empty():
assert bt._window_setups([], {}, {}) == []
def test_sr_research_variant_is_explicit_and_validated(monkeypatch):
for variant in (
"production_control",
"legacy_range_grid_touch",
"legacy_range_grid_neutral",
"production_range504",
"rewrite_range504_legacy_primary",
"rewrite_range504_structural_legacy_primary",
"rewrite_range504_structural_primary2",
"production_structural_overlay",
"explicit_target_ladder",
"gtl_tuning",
"gtl_confirmation",
):
monkeypatch.setenv("BACKTEST_SR_VARIANT", variant)
assert bt._sr_research_variant() == variant
monkeypatch.setenv("BACKTEST_SR_VARIANT", "not-a-variant")
with pytest.raises(ValueError, match="Unknown BACKTEST_SR_VARIANT"):
bt._sr_research_variant()
def test_range_factor_detector_mapping_is_explicit():
assert bt._sr_detector_variant("production_range504") == "production_control"
assert bt._sr_detector_variant("rewrite_range504_legacy_primary") == "rewrite"
assert bt._sr_detector_variant(
"rewrite_range504_structural_legacy_primary"
) == "rewrite"
assert bt._sr_detector_variant("rewrite_range504_structural_primary2") == "rewrite"
assert bt._sr_detector_variant("production_structural_overlay") == "production_control"
assert bt._sr_detector_variant("soft_zones_legacy_primary") == "soft_zones"
def test_range_504_log_uses_only_the_bounded_window():
highs = [1_000.0, *([100.0] * bt.RANGE_FACTOR_LOOKBACK)]
lows = [10.0, *([50.0] * bt.RANGE_FACTOR_LOOKBACK)]
assert bt._range_504_log(highs, lows) == pytest.approx(math.log(2.0))
def test_range_factor_gate_is_research_arm_only():
below = bt.RANGE_FACTOR_MIN_LOG - 0.01
assert not bt._range_factor_allows("production_range504", below)
assert not bt._range_factor_allows("rewrite_range504_legacy_primary", below)
assert not bt._range_factor_allows(
"rewrite_range504_structural_primary2",
below,
)
assert bt._range_factor_allows(
"production_range504",
bt.RANGE_FACTOR_MIN_LOG,
)
assert bt._range_factor_allows("production_control", below)
def test_residual_arms_change_only_the_primary_rr_floor():
activation = {"min_rr": 2.0}
assert bt._primary_min_rr_for_variant(
"rewrite_range504_structural_legacy_primary",
activation,
) == 1.5
assert bt._primary_min_rr_for_variant(
"rewrite_range504_structural_primary2",
activation,
) == 2.0
assert bt._primary_min_rr_for_variant(
"production_structural_overlay",
activation,
) == 1.5
assert bt._primary_min_rr_for_variant(
"explicit_target_ladder",
activation,
) == 1.5
assert bt._primary_min_rr_for_variant("gtl_tuning", activation) == 1.5
assert bt._primary_min_rr_for_variant("gtl_confirmation", activation) == 1.5
def test_gtl_research_config_parses_and_rejects_unknown_fields():
config = bt._parse_gtl_research_config(json.dumps({
"name": "lookback_504",
"lookback_bars": 504,
"candidate_limit": None,
}))
assert config.name == "lookback_504"
assert config.lookback_bars == 504
assert config.candidate_limit is None
assert config.ladder_config().grid_bins == 20
with pytest.raises(ValueError, match="Unknown GTL research config fields"):
bt._parse_gtl_research_config('{"mystery_knob": 1}')
with pytest.raises(ValueError, match="valid JSON"):
bt._parse_gtl_research_config("{")
def test_gtl_confirmation_config_parses_and_validates_composition():
config = bt._parse_gtl_confirmation_config(json.dumps({
"name": "touch_strength_intersection",
"mode": "intersection",
"confirmations": [
{"name": "touch", "touch_tolerance": 0.0025},
{"name": "strength", "strength_scale": 1000.0},
],
}))
assert config.name == "touch_strength_intersection"
assert config.mode == "intersection"
assert len(config.confirmations) == 2
assert config.confirmations[0].touch_tolerance == 0.0025
assert config.confirmations[1].strength_scale == 1000.0
with pytest.raises(ValueError, match="exactly one tuned variant"):
bt._parse_gtl_confirmation_config(json.dumps({
"name": "invalid_union",
"mode": "union",
"confirmations": [],
}))
def test_structural_overlay_tags_production_geometry_without_replacing_it(monkeypatch):
production = {
"direction": "long",
"meets_core": True,
"rr": 2.2,
"target": 111.0,
"primary_sources": ["volume_profile"],
"gate_level_count": 53,
}
structural = {
"direction": "long",
"meets_core": True,
"rr": 2.8,
"target": 114.0,
"primary_sources": ["pivot_point"],
"gate_level_count": 14,
}
def fake_window_setups(*args, sr_variant=None, **kwargs):
if sr_variant == "production_control":
return [production]
assert sr_variant == bt.STRUCTURAL_OVERLAY_SOURCE_VARIANT
return [structural]
monkeypatch.setattr(bt, "_window_setups", fake_window_setups)
rows = bt._structural_overlay_window_setups([], {}, {})
assert len(rows) == 1
assert rows[0]["target"] == production["target"]
assert rows[0]["rr"] == production["rr"]
assert rows[0]["structural_overlay_pass"] is True
assert rows[0]["structural_overlay_rr"] == structural["rr"]
assert rows[0]["structural_overlay_sources"] == ["pivot_point"]
assert rows[0]["structural_overlay_gate_level_count"] == 14
assert production.get("structural_overlay_pass") is None
@pytest.mark.parametrize(
("variant", "neutral_strength"),
[
("legacy_range_grid_touch", False),
("legacy_range_grid_neutral", True),
],
)
def test_window_setups_routes_explicit_range_grid(
monkeypatch,
variant,
neutral_strength,
):
captured = {}
def fake_detector(*args, **kwargs):
captured.update(kwargs)
return []
monkeypatch.setenv("BACKTEST_SR_VARIANT", variant)
monkeypatch.setattr(bt, "detect_sr_levels_legacy", fake_detector)
records = [
SimpleNamespace(
date=date(2024, 1, 1) + timedelta(days=i),
open=100.0,
high=101.0,
low=99.0,
close=100.0,
volume=1_000_000,
)
for i in range(bt.MIN_LOOKBACK)
]
assert bt._window_setups(records, {}, {}) == []
assert captured == {
"include_pivots": False,
"neutral_strength": neutral_strength,
"explicit_range_grid": True,
}
def test_window_setups_routes_full_explicit_target_ladder(monkeypatch):
captured = {}
def fake_detector(highs, lows, closes):
captured.update({
"highs": highs,
"lows": lows,
"closes": closes,
})
return []
monkeypatch.setenv("BACKTEST_SR_VARIANT", bt.EXPLICIT_TARGET_LADDER_VARIANT)
monkeypatch.setattr(bt, "detect_gate_target_ladder", fake_detector)
records = [
SimpleNamespace(
date=date(2024, 1, 1) + timedelta(days=i),
open=100.0,
high=101.0,
low=99.0,
close=100.0,
volume=1_000_000,
)
for i in range(bt.MIN_LOOKBACK)
]
assert bt._window_setups(records, {}, {}) == []
assert captured == {
"highs": [101.0] * bt.MIN_LOOKBACK,
"lows": [99.0] * bt.MIN_LOOKBACK,
"closes": [100.0] * bt.MIN_LOOKBACK,
}
def test_window_setups_routes_gtl_tuning_config(monkeypatch):
captured = {}
def fake_detector(highs, lows, closes, *, config):
captured.update({
"highs": highs,
"lows": lows,
"closes": closes,
"config": config,
})
return []
monkeypatch.setenv("BACKTEST_SR_VARIANT", bt.GTL_TUNING_VARIANT)
monkeypatch.setenv("BACKTEST_GTL_CONFIG", json.dumps({
"name": "lookback_252",
"lookback_bars": 252,
"grid_bins": 12,
}))
monkeypatch.setattr(bt, "detect_gate_target_ladder", fake_detector)
records = [
SimpleNamespace(
date=date(2024, 1, 1) + timedelta(days=i),
open=100.0,
high=101.0,
low=99.0,
close=100.0,
volume=1_000_000,
)
for i in range(bt.MIN_LOOKBACK)
]
assert bt._window_setups(records, {}, {}) == []
assert captured["highs"] == [101.0] * bt.MIN_LOOKBACK
assert captured["lows"] == [99.0] * bt.MIN_LOOKBACK
assert captured["closes"] == [100.0] * bt.MIN_LOOKBACK
assert captured["config"].lookback_bars == 252
assert captured["config"].grid_bins == 12
def test_gtl_confirmation_intersection_keeps_control_geometry(monkeypatch):
production = [{
"direction": "long",
"target": 111.0,
"rr": 2.2,
"meets_core": True,
"sr_variant": bt.EXPLICIT_TARGET_LADDER_VARIANT,
}]
def fake_window_setups(*args, sr_variant=None, gtl_research_config=None, **kwargs):
if sr_variant == bt.EXPLICIT_TARGET_LADDER_VARIANT:
return production
assert sr_variant == bt.GTL_TUNING_VARIANT
return [{
"direction": "long",
"target": 115.0,
"rr": 3.0,
"meets_core": gtl_research_config.name == "pass",
}]
monkeypatch.setattr(bt, "_window_setups", fake_window_setups)
research = bt.GTLConfirmationConfig(
name="two_filters",
mode="intersection",
confirmations=(
bt.GTLResearchConfig(name="pass"),
bt.GTLResearchConfig(name="fail"),
),
)
rows = bt._gtl_confirmation_window_setups(
[], {}, {}, confirmation_config=research
)
assert len(rows) == 1
assert rows[0]["target"] == 111.0
assert rows[0]["rr"] == 2.2
assert rows[0]["meets_core"] is False
assert rows[0]["gtl_confirmation_passes"] == [True, False]
assert production[0]["meets_core"] is True
def test_gtl_confirmation_union_uses_tuned_geometry_only_for_addition(monkeypatch):
production = [
{"direction": "long", "target": 111.0, "meets_core": False},
{"direction": "short", "target": 90.0, "meets_core": True},
]
tuned = [
{"direction": "long", "target": 115.0, "meets_core": True},
{"direction": "short", "target": 85.0, "meets_core": False},
]
def fake_window_setups(*args, sr_variant=None, **kwargs):
return production if sr_variant == bt.EXPLICIT_TARGET_LADDER_VARIANT else tuned
monkeypatch.setattr(bt, "_window_setups", fake_window_setups)
research = bt.GTLConfirmationConfig(
name="strength_union",
mode="union",
confirmations=(bt.GTLResearchConfig(name="strength"),),
)
rows = bt._gtl_confirmation_window_setups(
[], {}, {}, confirmation_config=research
)
by_direction = {row["direction"]: row for row in rows}
assert by_direction["long"]["target"] == 115.0
assert by_direction["long"]["gtl_confirmation_source"] == "tuned_addition"
assert by_direction["short"]["target"] == 90.0
assert by_direction["short"]["gtl_confirmation_source"] == "control"
@pytest.mark.parametrize(
"variant",
["legacy_geometry_neutral", "legacy_range_grid_neutral"],
)
def test_neutral_strength_is_enforced_after_zone_clustering(variant):
levels = [
SimpleNamespace(strength=100),
SimpleNamespace(strength=75),
]
result = bt._apply_zone_strength_variant(levels, variant)
assert [level.strength for level in result] == [50, 50]
def test_touch_strength_survives_post_cluster_research_hook():
levels = [SimpleNamespace(strength=82), SimpleNamespace(strength=37)]
result = bt._apply_zone_strength_variant(levels, "legacy_range_grid_touch")
assert [level.strength for level in result] == [82, 37]
def test_backtest_entry_bounds_validate_dates(monkeypatch):
monkeypatch.setenv("BACKTEST_ENTRY_START", "2024-07-01")
monkeypatch.setenv("BACKTEST_ENTRY_END", "2024-12-31")
assert bt._backtest_entry_bounds() == (date(2024, 7, 1), date(2024, 12, 31))
monkeypatch.setenv("BACKTEST_ENTRY_START", "2025-01-01")
with pytest.raises(ValueError, match="on or before"):
bt._backtest_entry_bounds()
def test_replay_ticker_candidates_carry_gate_fields():
"""The ablation recomputes floors from candidate fields — a candidate missing
action/risk_level silently zeroes the ablation rows (July 2026 regression)."""
from app.services.admin_service import ACTIVATION_DEFAULTS
from app.services.recommendation_service import DEFAULT_RECOMMENDATION_CONFIG
base = date(2025, 1, 1)
bars = []
for i in range(160):
close = 100.0 + 8.0 * math.sin(i / 6.0)
bars.append(SimpleNamespace(
date=base + timedelta(days=i),
open=close,
high=close + 1.5,
low=close - 1.5,
close=close,
volume=1_000_000 + (i % 5) * 1000,
))
cands = bt._replay_ticker(
"OSC", bars, dict(DEFAULT_RECOMMENDATION_CONFIG), dict(ACTIVATION_DEFAULTS)
)
assert cands, "expected the oscillating series to produce candidates"
for c in cands:
assert c.get("action") is not None
assert "risk_level" in c
assert c["range_504_log"] >= 0.0
assert c["range_504_ratio"] >= 1.0
assert isinstance(c["range_factor_pass"], bool)
async def _seed_oscillating_ticker(session, symbol: str, n: int = 160) -> None:
t = Ticker(symbol=symbol)
session.add(t)
await session.flush()
base = date(2025, 1, 1)
for i in range(n):
close = 100.0 + 8.0 * math.sin(i / 6.0)
session.add(OHLCVRecord(
ticker_id=t.id,
date=base + timedelta(days=i),
open=close,
high=close + 1.5,
low=close - 1.5,
close=close,
volume=1_000_000 + (i % 5) * 1000,
))
await session.commit()
async def test_run_backtest_smoke(session):
await _seed_oscillating_ticker(session, "OSC")
report = await bt.run_backtest(session)
# well-formed report
assert report["tickers"] == 1
assert isinstance(report["candidates"], int)
for key in (
"overall_qualified", "overall_all", "by_direction", "sweep",
"gate_ablation", "time_exit_sweep", "portfolio_sim", "strategy_variants",
"exit_policy_variants", "portfolio_monitor", "recommendation", "research_recommendation",
):
assert key in report
# the oscillating series should yield at least some resolved setups
assert report["candidates"] >= 1
# cost assumption is reported, and every bucket carries net numbers
assert report["params"]["cost_per_side_pct"] == pytest.approx(bt.COST_PER_SIDE * 100)
assert "net_avg_r" in report["overall_all"]
# ablation baseline reproduces the qualified set exactly, and every row
# carries the hold-to-horizon grading alongside the target model
ablation = {r["variant"]: r for r in report["gate_ablation"]}
assert ablation["all_floors"]["total"] == report["overall_qualified"]["total"]
for row in report["gate_ablation"]:
assert "hold_net_avg_r" in row
# time-exit sweep covers the configured hold lengths
assert [r["hold_days"] for r in report["time_exit_sweep"]] == list(bt.TIME_EXIT_DAYS)
# portfolio simulation section is always present (policies may be empty
# when nothing qualifies)
assert "portfolio_sim" in report
assert isinstance(report["portfolio_sim"]["policies"], list)
assert report["portfolio_sim"]["params"]["max_positions"] == bt.SIM_MAX_POSITIONS
assert isinstance(report["strategy_variants"]["variants"], list)
assert isinstance(report["exit_policy_variants"]["variants"], list)
assert report["portfolio_monitor"] is None or isinstance(report["portfolio_monitor"]["runs"], list)
# sweep: lowering the momentum-percentile cutoff can only add qualifiers
sweep = sorted(report["sweep"], key=lambda r: r["min_momentum_percentile"], reverse=True)
counts = [r["total"] for r in sweep]
assert counts == sorted(counts) # ascending as threshold descends