"""Unit tests for libs/backtest/allocator.py.""" from __future__ import annotations import datetime as dt import math from zoneinfo import ZoneInfo import pytest from libs.backtest.domain import ( BacktestConfig, Candidate, DailyPortfolioState, ExecutionConfig, OpenPosition, PlannedOrder, PositionStatus, RiskConfig, SignalConfig, UniverseConfig, ) _UTC = ZoneInfo("UTC") _NOW = dt.datetime(2026, 1, 5, 21, 0, tzinfo=_UTC) _TODAY = dt.date(2026, 1, 5) _TOMORROW = dt.date(2026, 1, 6) def _make_candidate(**kwargs) -> Candidate: defaults = dict( event_id="EVT::TEST", symbol="AAPL", issuer_id=None, score=0.8, sector="Technology", event_type="earnings", event_timestamp=_NOW, filing_time_bucket="post_market", reaction_date=_TODAY, execution_date=_TOMORROW, entry_price_est=100.0, avg_dollar_volume=5_000_000.0, atr_14=2.0, score_bucket="high", ) defaults.update(kwargs) return Candidate(**defaults) def _make_portfolio_state(**kwargs) -> DailyPortfolioState: defaults = dict( date=_TOMORROW, equity=100_000.0, cash_available=100_000.0, gross_exposure=0.0, net_exposure=0.0, reserved_risk_budget=0.0, unrealized_pnl=0.0, realized_pnl=0.0, open_positions=[], daily_new_risk_used=0.0, peak_equity=100_000.0, current_drawdown_pct=0.0, ) defaults.update(kwargs) return DailyPortfolioState(**defaults) def _make_config(**kwargs) -> BacktestConfig: defaults = dict(strategy_name="test", dataset_snapshot_id="snap_001") defaults.update(kwargs) return BacktestConfig(**defaults) class TestComputeStopPrice: def test_atr_stop(self): from libs.backtest.allocator import compute_stop_price c = _make_candidate(entry_price_est=100.0, atr_14=2.0) stop = compute_stop_price(c, RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03, max_positions=10, max_positions_per_sector=3 )) # 1.5 * ATR below price (default multiplier) assert stop == pytest.approx(100.0 - 1.5 * 2.0) def test_atr_stop_custom_multiplier(self): from libs.backtest.allocator import compute_stop_price c = _make_candidate(entry_price_est=100.0, atr_14=2.0) stop = compute_stop_price(c, RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03, max_positions=10, max_positions_per_sector=3, stop_atr_multiplier=2.0, )) # 2.0 * ATR below price assert stop == pytest.approx(100.0 - 2.0 * 2.0) def test_fallback_stop_when_no_atr(self): from libs.backtest.allocator import compute_stop_price c = _make_candidate(entry_price_est=100.0, atr_14=None) stop = compute_stop_price(c, RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03, max_positions=10, max_positions_per_sector=3 )) # 2% fallback assert stop == pytest.approx(98.0) def test_stop_never_negative(self): from libs.backtest.allocator import compute_stop_price c = _make_candidate(entry_price_est=1.0, atr_14=5.0) stop = compute_stop_price(c, RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03, max_positions=10, max_positions_per_sector=3 )) assert stop >= 0.01 def test_reaction_day_low_can_tighten_long_stop(self): from libs.backtest.allocator import compute_stop_price c = _make_candidate( entry_price_est=100.0, atr_14=4.0, features={"reaction_day_low": 94.0}, ) stop = compute_stop_price( c, RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03, max_positions=10, max_positions_per_sector=3, stop_atr_multiplier=2.25, ), ) # ATR stop would be 91.0, reaction-day low tightens it to 94.0 assert stop == pytest.approx(94.0) def test_engine_can_disable_reaction_day_low_stop(self): from libs.backtest.allocator import compute_stop_price c = _make_candidate( entry_price_est=100.0, atr_14=4.0, engine_use_reaction_day_low_stop=False, features={"reaction_day_low": 94.0}, ) stop = compute_stop_price( c, RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03, max_positions=10, max_positions_per_sector=3, stop_atr_multiplier=2.25, ), ) assert stop == pytest.approx(91.0) def test_engine_stop_atr_multiplier_override_affects_sizing(self): from libs.backtest.allocator import build_planned_order candidate = _make_candidate( entry_price_est=100.0, atr_14=4.0, engine_stop_atr_multiplier=2.5, engine_use_reaction_day_low_stop=False, ) order = build_planned_order( candidate, _make_portfolio_state(), [], _make_config(), ) assert order.stop_price == pytest.approx(90.0) class TestDynamicATRStop: def _make_risk_config(self, **kwargs) -> RiskConfig: defaults = dict( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03, max_positions=10, max_positions_per_sector=3, stop_atr_multiplier=2.0, ) defaults.update(kwargs) return RiskConfig(**defaults) def test_disabled_by_default(self): from libs.backtest.allocator import _dynamic_atr_scaler c = _make_candidate(features={"reaction_day_return": 0.01, "pre_event_entropy_60d": 1.0}) config = self._make_risk_config() # dynamic_stop_enabled defaults to False assert _dynamic_atr_scaler(c, config) == pytest.approx(1.0) def test_low_reaction_low_entropy_tighter_stop(self): from libs.backtest.allocator import _dynamic_atr_scaler # reaction=0.01 (< 0.03 low) -> scaler 0.8; entropy=1.0 (< 1.4 low) -> scaler 0.85 c = _make_candidate(features={"reaction_day_return": 0.01, "pre_event_entropy_60d": 1.0}) config = self._make_risk_config(dynamic_stop_enabled=True) scaler = _dynamic_atr_scaler(c, config) # 0.8 * 0.85 = 0.68 < floor 0.7 -> clamped to floor assert scaler == pytest.approx(0.7) def test_high_reaction_high_entropy_wider_stop(self): from libs.backtest.allocator import _dynamic_atr_scaler # reaction=0.20 (> 0.12 high) -> scaler 1.3; entropy=2.5 (> 2.0 high) -> scaler 1.25 c = _make_candidate(features={"reaction_day_return": 0.20, "pre_event_entropy_60d": 2.5}) config = self._make_risk_config(dynamic_stop_enabled=True) scaler = _dynamic_atr_scaler(c, config) assert scaler == pytest.approx(1.5) # 1.3 * 1.25 = 1.625, clamped to ceiling 1.5 def test_floor_ceiling_clamp(self): from libs.backtest.allocator import _dynamic_atr_scaler c_low = _make_candidate(features={"reaction_day_return": 0.0, "pre_event_entropy_60d": 0.0}) c_high = _make_candidate(features={"reaction_day_return": 1.0, "pre_event_entropy_60d": 10.0}) config = self._make_risk_config(dynamic_stop_enabled=True) assert _dynamic_atr_scaler(c_low, config) >= 0.7 assert _dynamic_atr_scaler(c_high, config) <= 1.5 def test_missing_features_fallback(self): from libs.backtest.allocator import _dynamic_atr_scaler c = _make_candidate(features={}) # no reaction or entropy in features config = self._make_risk_config(dynamic_stop_enabled=True) assert _dynamic_atr_scaler(c, config) == pytest.approx(1.0) def test_missing_features_none(self): from libs.backtest.allocator import _dynamic_atr_scaler c = _make_candidate() # features=None by default config = self._make_risk_config(dynamic_stop_enabled=True) assert _dynamic_atr_scaler(c, config) == pytest.approx(1.0) def test_dynamic_scaler_applied_to_stop_price(self): from libs.backtest.allocator import compute_stop_price # Low reaction + low entropy -> tighter stop -> lower stop price for long c = _make_candidate( entry_price_est=100.0, atr_14=2.0, engine_use_reaction_day_low_stop=False, features={"reaction_day_return": 0.01, "pre_event_entropy_60d": 1.8}, ) config_static = self._make_risk_config() config_dynamic = self._make_risk_config(dynamic_stop_enabled=True) stop_static = compute_stop_price(c, config_static) stop_dynamic = compute_stop_price(c, config_dynamic) # low reaction -> scaler < 1.0 -> tighter stop -> higher stop price assert stop_dynamic > stop_static def test_dynamic_scaler_combines_with_engine_atr_override(self): from libs.backtest.allocator import compute_stop_price # engine overrides ATR multiplier to 3.0; dynamic scaler should still apply on top # via _resolve_stop_risk_config which sets stop_atr_multiplier in the returned RiskConfig c = _make_candidate( entry_price_est=100.0, atr_14=2.0, engine_stop_atr_multiplier=3.0, engine_use_reaction_day_low_stop=False, features={"reaction_day_return": 0.20, "pre_event_entropy_60d": 2.5}, ) # With engine override=3.0 and dynamic scaler=1.5 (max ceiling): # stop_distance = 2.0 * 3.0 * 1.5 = 9.0 -> stop = 91.0 # But _resolve_stop_risk_config only propagates dynamic_stop_* fields if the RiskConfig is passed through # This test verifies that _resolve_stop_risk_config preserves dynamic_stop_enabled from libs.backtest.allocator import _resolve_stop_risk_config from libs.backtest.domain import BacktestConfig bt_config = BacktestConfig( strategy_name="test", dataset_snapshot_id="snap", risk=RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03, max_positions=10, max_positions_per_sector=3, stop_atr_multiplier=1.5, dynamic_stop_enabled=True, ), ) resolved = _resolve_stop_risk_config(c, bt_config) # Engine override replaces stop_atr_multiplier but dynamic_stop_enabled must be preserved assert resolved.stop_atr_multiplier == pytest.approx(3.0) assert resolved.dynamic_stop_enabled is True class TestComputeShares: def test_basic(self): from libs.backtest.allocator import compute_shares # 1% of 100k = 1000 risk, 100-95=5 stop distance → 200 shares shares = compute_shares( 100_000.0, 100.0, 95.0, RiskConfig(per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03, max_positions=10, max_positions_per_sector=3) ) assert shares == 200 def test_always_floor(self): from libs.backtest.allocator import compute_shares # Result should always be floor shares = compute_shares( 100_000.0, 100.0, 96.7, RiskConfig(per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03, max_positions=10, max_positions_per_sector=3) ) # raw = 1000 / 3.3 ≈ 303.03 → floor = 303 assert shares == math.floor(1000.0 / 3.3) def test_stop_above_entry_valid_for_short(self): """Stop above entry is valid for short positions -- compute_shares uses abs distance.""" from libs.backtest.allocator import compute_shares shares = compute_shares(100_000.0, 95.0, 100.0, RiskConfig( per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03, max_positions=10, max_positions_per_sector=3 )) # stop_distance = abs(95 - 100) = 5.0, risk = 1000, shares = floor(1000/5) = 200 assert shares == 200 class TestRunEntryGates: def test_pass_all_gates(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate() ps = _make_portfolio_state() cfg = _make_config() assert run_entry_gates(c, ps, [], cfg) is None def test_gate1_kill_switch(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate() ps = _make_portfolio_state(current_drawdown_pct=30.0) assert run_entry_gates(c, ps, [], _make_config()) == "kill_switch_drawdown" def test_gate2_max_positions(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate() ps = _make_portfolio_state() cfg = _make_config() cfg.risk.max_positions = 0 # impossible to add # Mock 0 positions but max is 0 result = run_entry_gates(c, ps, [], cfg) assert result == "max_positions_reached" def test_gate4_sector_limit(self): from libs.backtest.allocator import run_entry_gates # Create a position in the same sector plan = PlannedOrder( candidate=_make_candidate(symbol="MSFT", sector="Technology"), shares=10, entry_price_limit=100.0, stop_price=95.0, target_price=110.0, risk_dollars=50.0, ) existing_pos = OpenPosition( position_id="p1", plan=plan, entry_date=_TODAY, entry_price=100.0, entry_fill_slippage_bps=10.0, current_stop=95.0, target_price=110.0, peak_price=100.0, shares_open=10, shares_total=10, ) cfg = _make_config() cfg.risk.max_positions_per_sector = 1 # only 1 per sector c = _make_candidate(symbol="AAPL", sector="Technology") result = run_entry_gates(c, _make_portfolio_state(), [existing_pos], cfg) assert result == "sector_limit" def test_gate4_engine_sector_override_can_relax_limit(self): from libs.backtest.allocator import run_entry_gates plan = PlannedOrder( candidate=_make_candidate(symbol="MSFT", sector="Technology"), shares=10, entry_price_limit=100.0, stop_price=95.0, target_price=110.0, risk_dollars=50.0, ) existing_pos = OpenPosition( position_id="p1", plan=plan, entry_date=_TODAY, entry_price=100.0, entry_fill_slippage_bps=10.0, current_stop=95.0, target_price=110.0, peak_price=100.0, shares_open=10, shares_total=10, ) cfg = _make_config() cfg.risk.max_positions_per_sector = 1 c = _make_candidate( symbol="AAPL", sector="Technology", engine_max_positions_per_sector=2, ) result = run_entry_gates(c, _make_portfolio_state(), [existing_pos], cfg) assert result is None def test_gate3_duplicate_symbol(self): from libs.backtest.allocator import run_entry_gates plan = PlannedOrder( candidate=_make_candidate(symbol="AAPL"), shares=10, entry_price_limit=100.0, stop_price=95.0, target_price=110.0, risk_dollars=50.0, ) existing = OpenPosition( position_id="p1", plan=plan, entry_date=_TODAY, entry_price=100.0, entry_fill_slippage_bps=10.0, current_stop=95.0, target_price=110.0, peak_price=100.0, shares_open=10, shares_total=10, ) c = _make_candidate(symbol="AAPL") result = run_entry_gates(c, _make_portfolio_state(), [existing], _make_config()) assert result == "duplicate_symbol" def test_gate7_cooldown(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate() result = run_entry_gates(c, _make_portfolio_state(), [], _make_config(), cooldown_remaining=2) assert result == "cooldown" class TestMacroRegimeGate: """Macro regime filter gate tests.""" def test_blocks_when_spy_below_sma_and_scaler_gte_1(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate() ps = _make_portfolio_state() cfg = _make_config() cfg.risk.macro_regime_enabled = True cfg.risk.macro_regime_size_scaler = 1.0 # default — hard block macro = {"spy_close": 490.0, "spy_sma_20": 500.0} # SPY below SMA result = run_entry_gates(c, ps, [], cfg, macro_data=macro) assert result == "macro_regime_unfavorable" def test_passes_when_spy_below_sma_and_scaler_lt_1(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate() ps = _make_portfolio_state() cfg = _make_config() cfg.risk.macro_regime_enabled = True cfg.risk.macro_regime_size_scaler = 0.5 # size scaler — don't hard block macro = {"spy_close": 490.0, "spy_sma_20": 500.0} result = run_entry_gates(c, ps, [], cfg, macro_data=macro) assert result is None # passes gate, size scaler applied in build_planned_order def test_macro_size_scaler_reduces_shares(self): from libs.backtest.allocator import build_planned_order c = _make_candidate(entry_price_est=100.0, atr_14=2.0) ps = _make_portfolio_state() cfg = _make_config() cfg.risk.macro_regime_enabled = True cfg.risk.macro_regime_size_scaler = 0.5 macro = {"spy_close": 490.0, "spy_sma_20": 500.0} # Without macro scaler order_normal = build_planned_order(c, ps, [], cfg, macro_data=None) # With macro scaler order_scaled = build_planned_order(c, ps, [], cfg, macro_data=macro) assert order_normal.skip_reason is None assert order_scaled.skip_reason is None assert order_scaled.shares < order_normal.shares assert order_scaled.shares >= 1 def test_passes_when_spy_above_sma(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate() ps = _make_portfolio_state() cfg = _make_config() cfg.risk.macro_regime_enabled = True macro = {"spy_close": 510.0, "spy_sma_20": 500.0} # SPY above SMA result = run_entry_gates(c, ps, [], cfg, macro_data=macro) assert result is None def test_passes_when_spy_equals_sma(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate() ps = _make_portfolio_state() cfg = _make_config() cfg.risk.macro_regime_enabled = True macro = {"spy_close": 500.0, "spy_sma_20": 500.0} # Equal — not unfavorable result = run_entry_gates(c, ps, [], cfg, macro_data=macro) assert result is None def test_disabled_by_default(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate() ps = _make_portfolio_state() cfg = _make_config() # macro_regime_enabled defaults to False macro = {"spy_close": 490.0, "spy_sma_20": 500.0} result = run_entry_gates(c, ps, [], cfg, macro_data=macro) assert result is None # Gate is disabled, should pass def test_passes_when_no_macro_data(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate() ps = _make_portfolio_state() cfg = _make_config() cfg.risk.macro_regime_enabled = True result = run_entry_gates(c, ps, [], cfg, macro_data=None) assert result is None # No data available, don't block def test_passes_when_sma_not_computed(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate() ps = _make_portfolio_state() cfg = _make_config() cfg.risk.macro_regime_enabled = True macro = {"spy_close": 490.0, "spy_sma_20": None} # SMA not yet computed result = run_entry_gates(c, ps, [], cfg, macro_data=macro) assert result is None # Can't evaluate, don't block def test_build_planned_order_with_macro_hard_block(self): from libs.backtest.allocator import build_planned_order c = _make_candidate() ps = _make_portfolio_state() cfg = _make_config() cfg.risk.macro_regime_enabled = True cfg.risk.macro_regime_size_scaler = 1.0 # hard block mode macro = {"spy_close": 490.0, "spy_sma_20": 500.0} order = build_planned_order(c, ps, [], cfg, macro_data=macro) assert order.skip_reason == "macro_regime_unfavorable" def test_risk_off_requires_a_tier_in_spy_qqq_scaler_mode(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate(score=0.70, trade_direction="long") ps = _make_portfolio_state() cfg = _make_config() cfg.signal.a_tier_score_threshold = 0.75 cfg.risk.macro_regime_enabled = True cfg.risk.macro_regime_mode = "spy_qqq_scaler" cfg.risk.macro_regime_risk_off_a_tier_only = True macro = { "spy_close": 490.0, "spy_sma_20": 500.0, "qqq_close": 430.0, "qqq_sma_20": 440.0, } result = run_entry_gates(c, ps, [], cfg, macro_data=macro) assert result == "macro_regime_risk_off_non_a_tier" def test_add_on_candidate_can_pass_duplicate_symbol_gate(self): from libs.backtest.allocator import run_entry_gates parent_plan = PlannedOrder( candidate=_make_candidate(symbol="AAPL"), shares=10, entry_price_limit=100.0, stop_price=95.0, target_price=110.0, risk_dollars=50.0, ) parent_pos = OpenPosition( position_id="parent-1", plan=parent_plan, entry_date=_TODAY, entry_price=100.0, entry_fill_slippage_bps=10.0, current_stop=95.0, target_price=110.0, peak_price=100.0, shares_open=10, shares_total=10, ) add_on = _make_candidate( symbol="AAPL", parent_position_id="parent-1", is_add_on=True, forced_shares=5, ) result = run_entry_gates(add_on, _make_portfolio_state(), [parent_pos], _make_config()) assert result is None def test_add_on_candidate_respects_max_add_on_count(self): from libs.backtest.allocator import run_entry_gates parent_plan = PlannedOrder( candidate=_make_candidate(symbol="AAPL"), shares=10, entry_price_limit=100.0, stop_price=95.0, target_price=110.0, risk_dollars=50.0, ) parent_pos = OpenPosition( position_id="parent-1", plan=parent_plan, entry_date=_TODAY, entry_price=100.0, entry_fill_slippage_bps=10.0, current_stop=95.0, target_price=110.0, peak_price=100.0, shares_open=10, shares_total=10, ) add_on_open = OpenPosition( position_id="child-1", plan=PlannedOrder( candidate=_make_candidate( symbol="AAPL", parent_position_id="parent-1", is_add_on=True, forced_shares=5, engine_add_on_max_count=2, ), shares=5, entry_price_limit=105.0, stop_price=100.0, target_price=115.0, risk_dollars=25.0, ), entry_date=_TODAY, entry_price=105.0, entry_fill_slippage_bps=10.0, current_stop=100.0, target_price=115.0, peak_price=105.0, shares_open=5, shares_total=5, parent_position_id="parent-1", is_add_on=True, ) second_add_on = _make_candidate( symbol="AAPL", parent_position_id="parent-1", is_add_on=True, forced_shares=5, engine_add_on_max_count=2, ) assert run_entry_gates(second_add_on, _make_portfolio_state(), [parent_pos, add_on_open], _make_config()) is None third_add_on = _make_candidate( symbol="AAPL", parent_position_id="parent-1", is_add_on=True, forced_shares=5, engine_add_on_max_count=1, ) assert run_entry_gates(third_add_on, _make_portfolio_state(), [parent_pos, add_on_open], _make_config()) == "duplicate_add_on" class TestComputeTargetPrice: def test_fixed_r_default(self): from libs.backtest.allocator import compute_target_price target = compute_target_price(100.0, 95.0, 2.0) assert target == pytest.approx(110.0) # 100 + (100-95)*2 def test_atr_multiple_model(self): from libs.backtest.allocator import compute_target_price target = compute_target_price( 100.0, 95.0, 2.0, target_model="atr_multiple", target_atr_multiplier=1.5, atr_14=3.0, ) assert target == pytest.approx(104.5) # 100 + 3.0*1.5 def test_atr_multiple_falls_back_when_no_atr(self): from libs.backtest.allocator import compute_target_price target = compute_target_price( 100.0, 95.0, 2.0, target_model="atr_multiple", atr_14=None, ) assert target == pytest.approx(110.0) # falls back to fixed_r class TestDirectionFilter: def test_bullish_only_blocks_bearish(self): from libs.backtest.allocator import run_entry_gates from libs.backtest.domain import EventTypeProfile c = _make_candidate( event_type="earnings_release", features={"reaction_day_return": -0.02, "eps_growth_qoq": 0.10}, ) ps = _make_portfolio_state() cfg = _make_config() cfg.event_type_profiles = { "earnings_release": EventTypeProfile(direction_filter="bullish_only"), } result = run_entry_gates(c, ps, [], cfg) assert result == "direction_filter_bearish" def test_bullish_only_passes_positive(self): from libs.backtest.allocator import run_entry_gates from libs.backtest.domain import EventTypeProfile c = _make_candidate( event_type="earnings_release", features={"reaction_day_return": 0.02, "eps_growth_qoq": 0.10}, ) ps = _make_portfolio_state() cfg = _make_config() cfg.event_type_profiles = { "earnings_release": EventTypeProfile(direction_filter="bullish_only"), } result = run_entry_gates(c, ps, [], cfg) assert result is None class TestVetoGates: """Veto gate tests for document quality hard filters (gates 10-13).""" def test_high_oneoff_blocked(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate(features={"oneoff_penalty": 0.6}) cfg = _make_config() cfg.risk.veto_oneoff_penalty = 0.5 result = run_entry_gates(c, _make_portfolio_state(), [], cfg) assert result == "high_oneoff_risk" def test_low_oneoff_passes(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate(features={"oneoff_penalty": 0.3}) cfg = _make_config() cfg.risk.veto_oneoff_penalty = 0.5 result = run_entry_gates(c, _make_portfolio_state(), [], cfg) assert result is None def test_engine_oneoff_override_can_pass(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate( features={"oneoff_penalty": 0.6}, engine_veto_oneoff_penalty=0.95, ) cfg = _make_config() cfg.risk.veto_oneoff_penalty = 0.5 result = run_entry_gates(c, _make_portfolio_state(), [], cfg) assert result is None def test_high_oneoff_can_pass_when_downsizing_enabled(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate(features={"oneoff_penalty": 0.6}) cfg = _make_config() cfg.risk.veto_oneoff_penalty = 0.5 cfg.risk.allow_oneoff_downsizing = True result = run_entry_gates(c, _make_portfolio_state(), [], cfg) assert result is None def test_engine_oneoff_downsizing_override_can_pass_with_global_disabled(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate( features={"oneoff_penalty": 0.6}, engine_allow_oneoff_downsizing=True, ) cfg = _make_config() cfg.risk.veto_oneoff_penalty = 0.5 cfg.risk.allow_oneoff_downsizing = False result = run_entry_gates(c, _make_portfolio_state(), [], cfg) assert result is None def test_low_parse_confidence_blocked(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate(features={"parse_confidence_overall": 0.3}) cfg = _make_config() cfg.risk.veto_parse_confidence_min = 0.4 result = run_entry_gates(c, _make_portfolio_state(), [], cfg) assert result == "low_parse_confidence" def test_engine_parse_confidence_override_can_pass(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate( features={"parse_confidence_overall": 0.3}, engine_veto_parse_confidence_min=0.25, ) cfg = _make_config() cfg.risk.veto_parse_confidence_min = 0.4 result = run_entry_gates(c, _make_portfolio_state(), [], cfg) assert result is None def test_adequate_parse_confidence_passes(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate(features={"parse_confidence_overall": 0.6}) cfg = _make_config() cfg.risk.veto_parse_confidence_min = 0.4 result = run_entry_gates(c, _make_portfolio_state(), [], cfg) assert result is None def test_unknown_direction_blocked(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate(features={"event_direction": "unknown"}) cfg = _make_config() cfg.risk.veto_unknown_direction = True result = run_entry_gates(c, _make_portfolio_state(), [], cfg) assert result == "unknown_direction" def test_bearish_direction_blocked(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate(features={"event_direction": "bearish"}) cfg = _make_config() cfg.risk.veto_bearish_direction = True result = run_entry_gates(c, _make_portfolio_state(), [], cfg) assert result == "bearish_direction" def test_bearish_direction_forced_long_allowed(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate( features={"event_direction": "bearish"}, engine_forced_trade_direction="long", ) cfg = _make_config() cfg.risk.veto_bearish_direction = True result = run_entry_gates(c, _make_portfolio_state(), [], cfg) assert result is None def test_bullish_direction_passes(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate(features={"event_direction": "bullish"}) cfg = _make_config() cfg.risk.veto_unknown_direction = True cfg.risk.veto_bearish_direction = True result = run_entry_gates(c, _make_portfolio_state(), [], cfg) assert result is None def test_missing_features_pass_veto(self): from libs.backtest.allocator import run_entry_gates c = _make_candidate(features={}) cfg = _make_config() cfg.risk.veto_unknown_direction = True cfg.risk.veto_bearish_direction = True result = run_entry_gates(c, _make_portfolio_state(), [], cfg) assert result is None class TestBuildPlannedOrder: def test_valid_order(self): from libs.backtest.allocator import build_planned_order c = _make_candidate(entry_price_est=100.0, atr_14=2.0) ps = _make_portfolio_state() order = build_planned_order(c, ps, [], _make_config()) assert order.skip_reason is None assert order.shares > 0 assert order.stop_price < 100.0 assert order.target_price > 100.0 def test_rejected_order_has_skip_reason(self): from libs.backtest.allocator import build_planned_order c = _make_candidate() ps = _make_portfolio_state(current_drawdown_pct=30.0) order = build_planned_order(c, ps, [], _make_config()) assert order.skip_reason == "kill_switch_drawdown" assert order.shares == 0 def test_atr_target_model_in_order(self): from libs.backtest.allocator import build_planned_order c = _make_candidate(entry_price_est=100.0, atr_14=3.0) ps = _make_portfolio_state() cfg = _make_config() cfg.execution.target_model = "atr_multiple" cfg.execution.target_atr_multiplier = 1.5 order = build_planned_order(c, ps, [], cfg) assert order.skip_reason is None assert order.target_price == pytest.approx(104.5) # 100 + 3.0*1.5 def test_long_order_scales_down_to_available_cash(self): from libs.backtest.allocator import build_planned_order c = _make_candidate(entry_price_est=100.0, atr_14=2.0) ps = _make_portfolio_state(cash_available=15_000.0) cfg = _make_config() cfg.risk.per_trade_risk_pct = 0.02 # risk model would ask for 666 shares order = build_planned_order(c, ps, [], cfg) assert order.skip_reason is None assert order.shares == 150 assert order.risk_dollars == pytest.approx((100.0 - order.stop_price) * 150) def test_engine_level_risk_override_reduces_order_size(self): from libs.backtest.allocator import build_planned_order c = _make_candidate(entry_price_est=100.0, atr_14=2.0, engine_per_trade_risk_pct=0.01) ps = _make_portfolio_state(cash_available=100_000.0) cfg = _make_config() cfg.risk.per_trade_risk_pct = 0.02 order = build_planned_order(c, ps, [], cfg) assert order.skip_reason is None assert order.shares == 333 def test_long_order_rejects_when_cash_cannot_fund_one_share(self): from libs.backtest.allocator import build_planned_order c = _make_candidate(entry_price_est=100.0, atr_14=2.0) ps = _make_portfolio_state(cash_available=99.0) order = build_planned_order(c, ps, [], _make_config()) assert order.skip_reason == "insufficient_cash" assert order.shares == 0 def test_forced_share_order_caps_to_cash(self): from libs.backtest.allocator import build_planned_order c = _make_candidate(entry_price_est=80.0, atr_14=2.0, forced_shares=300) ps = _make_portfolio_state(cash_available=10_000.0) order = build_planned_order(c, ps, [], _make_config()) assert order.skip_reason is None assert order.shares == 125 def test_order_scales_to_remaining_daily_risk_budget_when_enabled(self): from libs.backtest.allocator import build_planned_order c = _make_candidate(entry_price_est=100.0, atr_14=2.0) ps = _make_portfolio_state(cash_available=100_000.0, daily_new_risk_used=2_500.0) cfg = _make_config() cfg.risk.per_trade_risk_pct = 0.02 cfg.risk.max_daily_new_risk_pct = 0.03 cfg.risk.allow_budget_downsizing = True order = build_planned_order(c, ps, [], cfg) assert order.skip_reason is None assert order.shares == 166 assert order.risk_dollars == pytest.approx((100.0 - order.stop_price) * 166) def test_order_rejects_daily_risk_budget_when_downsizing_disabled(self): from libs.backtest.allocator import build_planned_order c = _make_candidate(entry_price_est=100.0, atr_14=2.0) ps = _make_portfolio_state(cash_available=100_000.0, daily_new_risk_used=2_500.0) cfg = _make_config() cfg.risk.per_trade_risk_pct = 0.02 cfg.risk.max_daily_new_risk_pct = 0.03 cfg.risk.allow_budget_downsizing = False order = build_planned_order(c, ps, [], cfg) assert order.skip_reason == "daily_risk_budget" assert order.shares == 0 def test_high_oneoff_downsizing_reduces_order_size(self): from libs.backtest.allocator import build_planned_order c = _make_candidate(entry_price_est=100.0, atr_14=2.0, features={"oneoff_penalty": 0.75}) ps = _make_portfolio_state(cash_available=100_000.0) base_cfg = _make_config() base_cfg.risk.per_trade_risk_pct = 0.02 base_cfg.risk.veto_oneoff_penalty = 0.95 base_order = build_planned_order(c, ps, [], base_cfg) assert base_order.skip_reason is None downsize_cfg = _make_config() downsize_cfg.risk.per_trade_risk_pct = 0.02 downsize_cfg.risk.veto_oneoff_penalty = 0.5 downsize_cfg.risk.allow_oneoff_downsizing = True downsize_cfg.risk.oneoff_downsize_floor = 0.25 downsize_order = build_planned_order(c, ps, [], downsize_cfg) assert downsize_order.skip_reason is None assert downsize_order.shares < base_order.shares assert downsize_order.risk_dollars < base_order.risk_dollars def test_engine_oneoff_downsizing_override_reduces_order_size(self): from libs.backtest.allocator import build_planned_order c = _make_candidate( entry_price_est=100.0, atr_14=2.0, features={"oneoff_penalty": 0.75}, engine_allow_oneoff_downsizing=True, engine_oneoff_downsize_floor=0.5, ) ps = _make_portfolio_state(cash_available=100_000.0) base_cfg = _make_config() base_cfg.risk.per_trade_risk_pct = 0.02 base_cfg.risk.veto_oneoff_penalty = 0.95 base_order = build_planned_order(c, ps, [], base_cfg) assert base_order.skip_reason is None override_cfg = _make_config() override_cfg.risk.per_trade_risk_pct = 0.02 override_cfg.risk.veto_oneoff_penalty = 0.5 override_cfg.risk.allow_oneoff_downsizing = False override_order = build_planned_order(c, ps, [], override_cfg) assert override_order.skip_reason is None assert override_order.shares < base_order.shares assert override_order.risk_dollars < base_order.risk_dollars def test_engine_vix_stress_scaler_reduces_order_size(self): from libs.backtest.allocator import build_planned_order c = _make_candidate( entry_price_est=100.0, atr_14=2.0, engine_macro_vix_size_scaler_low=22.0, engine_macro_vix_size_scaler_high=30.0, engine_macro_vix_size_scaler_min=0.7, ) ps = _make_portfolio_state(cash_available=100_000.0) cfg = _make_config() base_order = build_planned_order(c, ps, [], cfg, macro_data={"VIXCLS": 18.0}) stressed_order = build_planned_order(c, ps, [], cfg, macro_data={"VIXCLS": 30.0}) assert base_order.skip_reason is None assert stressed_order.skip_reason is None assert stressed_order.shares < base_order.shares def test_engine_hy_spread_stress_scaler_reduces_order_size(self): from libs.backtest.allocator import build_planned_order c = _make_candidate( entry_price_est=100.0, atr_14=2.0, engine_macro_hy_spread_size_scaler_low=3.8, engine_macro_hy_spread_size_scaler_high=4.5, engine_macro_hy_spread_size_scaler_min=0.8, features={"macro_hy_spread": 4.5}, ) calm = c.model_copy(update={"features": {"macro_hy_spread": 3.4}}) ps = _make_portfolio_state(cash_available=100_000.0) cfg = _make_config() calm_order = build_planned_order(calm, ps, [], cfg) stressed_order = build_planned_order(c, ps, [], cfg) assert calm_order.skip_reason is None assert stressed_order.skip_reason is None assert stressed_order.shares < calm_order.shares def test_engine_score_size_scaler_reduces_low_score_order_size(self): from libs.backtest.allocator import build_planned_order low = _make_candidate( score=0.45, entry_price_est=100.0, atr_14=2.0, engine_score_size_scaler_low=0.45, engine_score_size_scaler_high=0.80, engine_score_size_scaler_min=0.5, ) high = low.model_copy(update={"score": 0.80}) ps = _make_portfolio_state(cash_available=100_000.0) cfg = _make_config() low_order = build_planned_order(low, ps, [], cfg) high_order = build_planned_order(high, ps, [], cfg) assert low_order.skip_reason is None assert high_order.skip_reason is None assert low_order.shares < high_order.shares def test_engine_entropy_size_scaler_reduces_hot_entropy_order_size(self): from libs.backtest.allocator import build_planned_order calm = _make_candidate( entry_price_est=100.0, atr_14=2.0, engine_entropy_size_scaler_low=1.6, engine_entropy_size_scaler_high=2.0, engine_entropy_size_scaler_min=0.5, features={"pre_event_entropy_60d": 1.5}, ) hot = calm.model_copy(update={"features": {"pre_event_entropy_60d": 2.1}}) ps = _make_portfolio_state(cash_available=100_000.0) cfg = _make_config() calm_order = build_planned_order(calm, ps, [], cfg) hot_order = build_planned_order(hot, ps, [], cfg) assert calm_order.skip_reason is None assert hot_order.skip_reason is None assert hot_order.shares < calm_order.shares def test_tiered_targets_apply_without_effective_execution_config(self): from libs.backtest.allocator import build_planned_order c = _make_candidate( entry_price_est=100.0, atr_14=2.0, engine_use_reaction_day_low_stop=False, ) cfg = _make_config( execution=ExecutionConfig( use_tiered_targets=True, a_tier_target_1_r=4.0, non_a_tier_target_1_r=1.5, ) ) order = build_planned_order(c, _make_portfolio_state(), [], cfg) assert order.target_price == pytest.approx(104.5) def test_effective_execution_target_override_beats_tiered_targets(self): from libs.backtest.allocator import build_planned_order from libs.backtest.execution import build_effective_execution_config c = _make_candidate( entry_price_est=100.0, atr_14=2.0, engine_target_1_r=99.0, engine_use_reaction_day_low_stop=False, ) cfg = _make_config( execution=ExecutionConfig( use_tiered_targets=True, a_tier_target_1_r=4.0, non_a_tier_target_1_r=1.5, ) ) effective_exec = build_effective_execution_config(c, cfg) order = build_planned_order( c, _make_portfolio_state(), [], cfg, execution_config=effective_exec, ) assert effective_exec.target_1_r == pytest.approx(99.0) assert order.target_price == pytest.approx(397.0) def test_order_caps_to_max_position_value_pct(self): from libs.backtest.allocator import build_planned_order c = _make_candidate(entry_price_est=100.0, atr_14=2.0) ps = _make_portfolio_state(cash_available=100_000.0) cfg = _make_config() cfg.risk.per_trade_risk_pct = 0.02 # raw size would be much larger than the cap cfg.risk.max_position_value_pct = 0.25 order = build_planned_order(c, ps, [], cfg) assert order.skip_reason is None assert order.shares == 250 def test_order_caps_to_engine_max_position_value_pct(self): from libs.backtest.allocator import build_planned_order c = _make_candidate( entry_price_est=100.0, atr_14=2.0, engine_max_position_value_pct=0.10, ) ps = _make_portfolio_state(cash_available=100_000.0) cfg = _make_config() cfg.risk.per_trade_risk_pct = 0.02 cfg.risk.max_position_value_pct = 0.25 order = build_planned_order(c, ps, [], cfg) assert order.skip_reason is None assert order.shares == 100 def test_order_caps_to_max_adv_fraction(self): from libs.backtest.allocator import build_planned_order c = _make_candidate(entry_price_est=100.0, atr_14=2.0, avg_dollar_volume=5_000_000.0) ps = _make_portfolio_state(cash_available=100_000.0) cfg = _make_config() cfg.risk.per_trade_risk_pct = 0.02 # raw size > liquidity cap cfg.risk.max_adv_fraction = 0.01 # 50,000 USD notional => 500 shares order = build_planned_order(c, ps, [], cfg) assert order.skip_reason is None assert order.shares == 500 def test_order_caps_to_engine_max_adv_fraction(self): from libs.backtest.allocator import build_planned_order c = _make_candidate( entry_price_est=100.0, atr_14=2.0, avg_dollar_volume=5_000_000.0, engine_max_adv_fraction=0.002, ) ps = _make_portfolio_state(cash_available=100_000.0) cfg = _make_config() cfg.risk.per_trade_risk_pct = 0.02 cfg.risk.max_adv_fraction = 0.01 order = build_planned_order(c, ps, [], cfg) assert order.skip_reason is None assert order.shares == 100 class TestBreadthCrowdingScaler: def test_breadth_throttle_reduces_shares_on_crowded_days(self): from libs.backtest.allocator import build_planned_order cfg = _make_config() cfg.risk.breadth_throttle_enabled = True cfg.risk.breadth_throttle_candidate_count_threshold = 4 cfg.risk.breadth_throttle_min = 0.5 normal = _make_candidate(features={"daily_candidate_count_selected": 4}) crowded = _make_candidate(features={"daily_candidate_count_selected": 8}) normal_order = build_planned_order(normal, _make_portfolio_state(), [], cfg) crowded_order = build_planned_order(crowded, _make_portfolio_state(), [], cfg) assert normal_order.skip_reason is None assert crowded_order.skip_reason is None assert crowded_order.shares < normal_order.shares class TestTailRiskAdjuster: def test_tail_risk_adjuster_reduces_stacked_tail_candidate(self): from libs.backtest.allocator import build_planned_order cfg = _make_config() cfg.risk.tail_risk_adjuster_enabled = True cfg.risk.tail_risk_penalty_threshold = 0.6 cfg.risk.tail_risk_penalty_min = 0.5 cfg.risk.tail_risk_min_signals = 2 calm = _make_candidate( features={ "reaction_day_return": 0.02, "oneoff_penalty": 0.0, "pre_event_market_temperature": 0.5, "pre_event_entropy_60d": 1.5, } ) hot = _make_candidate( features={ "reaction_day_return": 0.14, "oneoff_penalty": 0.4, "pre_event_market_temperature": 1.4, "pre_event_entropy_60d": 2.0, } ) calm_order = build_planned_order(calm, _make_portfolio_state(), [], cfg) hot_order = build_planned_order(hot, _make_portfolio_state(), [], cfg) assert calm_order.skip_reason is None assert hot_order.skip_reason is None assert hot_order.shares < calm_order.shares def test_sector_crowding_penalty_reduces_same_sector_slate_size(self): from libs.backtest.allocator import build_planned_order cfg = _make_config() cfg.risk.sector_crowding_penalty_enabled = True cfg.risk.sector_crowding_candidate_count_threshold = 2 cfg.risk.sector_crowding_penalty_min = 0.5 normal = _make_candidate(features={"daily_sector_candidate_count_selected": 2}) crowded = _make_candidate(features={"daily_sector_candidate_count_selected": 4}) normal_order = build_planned_order(normal, _make_portfolio_state(), [], cfg) crowded_order = build_planned_order(crowded, _make_portfolio_state(), [], cfg) assert normal_order.skip_reason is None assert crowded_order.skip_reason is None assert crowded_order.shares < normal_order.shares