|
|
|
@ -81,9 +81,21 @@ class TestComputeStopPrice:
|
|
|
|
per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03,
|
|
|
|
per_trade_risk_pct=0.01, max_daily_new_risk_pct=0.03,
|
|
|
|
max_positions=10, max_positions_per_sector=3
|
|
|
|
max_positions=10, max_positions_per_sector=3
|
|
|
|
))
|
|
|
|
))
|
|
|
|
# 1.5 * ATR below price
|
|
|
|
# 1.5 * ATR below price (default multiplier)
|
|
|
|
assert stop == pytest.approx(100.0 - 1.5 * 2.0)
|
|
|
|
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):
|
|
|
|
def test_fallback_stop_when_no_atr(self):
|
|
|
|
from libs.backtest.allocator import compute_stop_price
|
|
|
|
from libs.backtest.allocator import compute_stop_price
|
|
|
|
|
|
|
|
|
|
|
|
@ -216,6 +228,184 @@ class TestRunEntryGates:
|
|
|
|
assert result == "cooldown"
|
|
|
|
assert result == "cooldown"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestMacroRegimeGate:
|
|
|
|
|
|
|
|
"""Macro regime filter gate tests."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_blocks_when_spy_below_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": 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_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(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
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 TestSUEEntryGate:
|
|
|
|
|
|
|
|
def test_negative_eps_growth_blocked(self):
|
|
|
|
|
|
|
|
from libs.backtest.allocator import run_entry_gates
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
c = _make_candidate(
|
|
|
|
|
|
|
|
event_type="earnings_release",
|
|
|
|
|
|
|
|
features={"eps_growth_qoq": -0.05},
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
ps = _make_portfolio_state()
|
|
|
|
|
|
|
|
result = run_entry_gates(c, ps, [], _make_config())
|
|
|
|
|
|
|
|
assert result == "negative_earnings_surprise"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_positive_eps_growth_passes(self):
|
|
|
|
|
|
|
|
from libs.backtest.allocator import run_entry_gates
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
c = _make_candidate(
|
|
|
|
|
|
|
|
event_type="earnings_release",
|
|
|
|
|
|
|
|
features={"eps_growth_qoq": 0.10},
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
ps = _make_portfolio_state()
|
|
|
|
|
|
|
|
result = run_entry_gates(c, ps, [], _make_config())
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_non_earnings_not_checked(self):
|
|
|
|
|
|
|
|
from libs.backtest.allocator import run_entry_gates
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
c = _make_candidate(
|
|
|
|
|
|
|
|
event_type="guidance_update",
|
|
|
|
|
|
|
|
features={"eps_growth_qoq": -0.50},
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
ps = _make_portfolio_state()
|
|
|
|
|
|
|
|
result = run_entry_gates(c, ps, [], _make_config())
|
|
|
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 TestBuildPlannedOrder:
|
|
|
|
class TestBuildPlannedOrder:
|
|
|
|
def test_valid_order(self):
|
|
|
|
def test_valid_order(self):
|
|
|
|
from libs.backtest.allocator import build_planned_order
|
|
|
|
from libs.backtest.allocator import build_planned_order
|
|
|
|
@ -236,3 +426,15 @@ class TestBuildPlannedOrder:
|
|
|
|
order = build_planned_order(c, ps, [], _make_config())
|
|
|
|
order = build_planned_order(c, ps, [], _make_config())
|
|
|
|
assert order.skip_reason == "kill_switch_drawdown"
|
|
|
|
assert order.skip_reason == "kill_switch_drawdown"
|
|
|
|
assert order.shares == 0
|
|
|
|
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
|
|
|
|
|