You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1297 lines
48 KiB
Python
1297 lines
48 KiB
Python
"""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
|