Promote production portfolio strategy
This commit is contained in:
@@ -130,6 +130,45 @@ def test_activation_percentile_prefers_residual_with_raw_fallback():
|
||||
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_strategy_variants_keep_only_current_research_candidates():
|
||||
variants = {cfg["variant"]: cfg for cfg in bt.STRATEGY_VARIANTS}
|
||||
|
||||
@@ -142,9 +181,43 @@ def test_strategy_variants_keep_only_current_research_candidates():
|
||||
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,
|
||||
@@ -153,6 +226,13 @@ def test_strategy_variant_sims_emit_fixed_variants_without_mutating_qualified(mo
|
||||
"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 = []
|
||||
|
||||
@@ -187,10 +267,57 @@ def test_strategy_variant_sims_emit_fixed_variants_without_mutating_qualified(mo
|
||||
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": [
|
||||
@@ -200,6 +327,10 @@ def test_build_research_recommendation_applies_promotion_rules():
|
||||
"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},
|
||||
]},
|
||||
}
|
||||
|
||||
@@ -210,6 +341,8 @@ def test_build_research_recommendation_applies_promotion_rules():
|
||||
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:
|
||||
@@ -442,6 +575,32 @@ class TestSimulatePortfolio:
|
||||
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
|
||||
|
||||
@@ -547,6 +706,26 @@ def test_build_recommendation_flags_outlier_dependence():
|
||||
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([], {}, {}) == []
|
||||
|
||||
@@ -607,7 +786,7 @@ async def test_run_backtest_smoke(session):
|
||||
for key in (
|
||||
"overall_qualified", "overall_all", "by_direction", "sweep",
|
||||
"gate_ablation", "time_exit_sweep", "portfolio_sim", "strategy_variants",
|
||||
"recommendation", "research_recommendation",
|
||||
"exit_policy_variants", "portfolio_monitor", "recommendation", "research_recommendation",
|
||||
):
|
||||
assert key in report
|
||||
# the oscillating series should yield at least some resolved setups
|
||||
@@ -633,6 +812,8 @@ async def test_run_backtest_smoke(session):
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user