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.

1258 lines
48 KiB
Python

"""Position sizing, entry gates, and order planning for the backtester."""
from __future__ import annotations
import math
from typing import Any
from libs.backtest.domain import (
BacktestConfig,
Candidate,
DailyPortfolioState,
ExecutionConfig,
EventTypeProfile,
OpenPosition,
PlannedOrder,
RiskConfig,
)
from libs.common.logging import get_logger
logger = get_logger(__name__)
# Default drawdown kill-switch threshold (not in JSON schema)
_KILL_SWITCH_DRAWDOWN_PCT = 25.0
def _resolve_sizing_equity(portfolio_state: DailyPortfolioState) -> float:
"""Return sizing_equity if set, otherwise fall back to equity."""
if portfolio_state.sizing_equity is not None:
return portfolio_state.sizing_equity
return portfolio_state.equity
def _dynamic_atr_scaler(candidate: Candidate, config: RiskConfig) -> float:
"""Compute a dynamic multiplier for the ATR stop based on reaction size and entropy.
Returns a scaler applied on top of the base stop_atr_multiplier:
- Small reaction + low entropy -> tighter stop (scaler < 1.0)
- Large reaction + high entropy -> wider stop (scaler > 1.0)
- Missing features gracefully fall back to 1.0.
"""
if not config.dynamic_stop_enabled:
return 1.0
reaction = candidate.features.get("reaction_day_return") if candidate.features else None
entropy = candidate.features.get("pre_event_entropy_60d") if candidate.features else None
reaction_scaler = 1.0
if reaction is not None:
try:
r = abs(float(reaction))
if r <= config.dynamic_stop_reaction_low:
reaction_scaler = config.dynamic_stop_reaction_scaler_low
elif r >= config.dynamic_stop_reaction_high:
reaction_scaler = config.dynamic_stop_reaction_scaler_high
else:
frac = (r - config.dynamic_stop_reaction_low) / (
config.dynamic_stop_reaction_high - config.dynamic_stop_reaction_low
)
reaction_scaler = config.dynamic_stop_reaction_scaler_low + frac * (
config.dynamic_stop_reaction_scaler_high - config.dynamic_stop_reaction_scaler_low
)
except (TypeError, ValueError):
pass
entropy_scaler = 1.0
if entropy is not None:
try:
e = float(entropy)
if e <= config.dynamic_stop_entropy_low:
entropy_scaler = config.dynamic_stop_entropy_scaler_low
elif e >= config.dynamic_stop_entropy_high:
entropy_scaler = config.dynamic_stop_entropy_scaler_high
else:
frac = (e - config.dynamic_stop_entropy_low) / (
config.dynamic_stop_entropy_high - config.dynamic_stop_entropy_low
)
entropy_scaler = config.dynamic_stop_entropy_scaler_low + frac * (
config.dynamic_stop_entropy_scaler_high - config.dynamic_stop_entropy_scaler_low
)
except (TypeError, ValueError):
pass
combined = reaction_scaler * entropy_scaler
return max(config.dynamic_stop_combined_floor, min(config.dynamic_stop_combined_ceiling, combined))
def compute_stop_price(candidate: Candidate, config: RiskConfig) -> float:
"""Compute stop price based on ATR-14 or a percentage fallback.
Uses entry_price_est (reaction close) as the price basis.
For long: stop below entry. For short: stop above entry.
Actual fill uses the real open + slippage; R-multiple uses actual fill price.
"""
price = candidate.entry_price_est
dynamic_scaler = _dynamic_atr_scaler(candidate, config)
if candidate.atr_14 and candidate.atr_14 > 0:
stop_distance = candidate.atr_14 * config.stop_atr_multiplier * dynamic_scaler
else:
# Fallback: 2% of price
stop_distance = price * 0.02 * dynamic_scaler
if candidate.trade_direction == "short":
return price + stop_distance
atr_stop = max(0.01, price - stop_distance)
if candidate.engine_use_reaction_day_low_stop is False:
return atr_stop
reaction_day_low = candidate.features.get("reaction_day_low")
if reaction_day_low is None:
return atr_stop
try:
reaction_stop = float(reaction_day_low)
except (TypeError, ValueError):
return atr_stop
if reaction_stop <= 0:
return atr_stop
return max(0.01, max(atr_stop, reaction_stop))
def _resolve_stop_risk_config(candidate: Candidate, config: BacktestConfig) -> RiskConfig:
profile = config.get_event_profile(candidate.event_type)
stop_atr_mult = (
candidate.engine_stop_atr_multiplier
if candidate.engine_stop_atr_multiplier is not None
else (
profile.stop_atr_multiplier_override
if profile and profile.stop_atr_multiplier_override is not None
else config.risk.stop_atr_multiplier
)
)
return RiskConfig(**{**config.risk.model_dump(), "stop_atr_multiplier": stop_atr_mult})
def compute_target_price(
entry_price_est: float,
stop_price: float,
target_r: float = 2.0,
*,
target_model: str = "fixed_r",
target_atr_multiplier: float = 1.5,
atr_14: float | None = None,
trade_direction: str = "long",
) -> float:
"""Compute target price using fixed R-multiple or ATR-based model.
For long: target above entry. For short: target below entry.
Models:
- "fixed_r": target = entry +/- risk * target_r
- "atr_multiple": target = entry +/- atr_14 * target_atr_multiplier
"""
sign = -1.0 if trade_direction == "short" else 1.0
if target_model == "atr_multiple" and atr_14 and atr_14 > 0:
return entry_price_est + sign * atr_14 * target_atr_multiplier
# Default: fixed R-multiple
risk = abs(entry_price_est - stop_price)
if risk <= 0:
return entry_price_est * (0.90 if trade_direction == "short" else 1.10)
return entry_price_est + sign * risk * target_r
def compute_shares(
equity: float,
entry_price: float,
stop_price: float,
config: RiskConfig,
risk_pct_override: float | None = None,
) -> int:
"""Compute integer share count. Always math.floor() -- never round up."""
stop_distance = abs(entry_price - stop_price)
if stop_distance <= 0:
return 0
risk_dollars = equity * (risk_pct_override if risk_pct_override is not None else config.per_trade_risk_pct)
raw_shares = risk_dollars / stop_distance
return max(0, math.floor(raw_shares))
def _cap_shares_to_cash(
shares: int,
candidate: Candidate,
portfolio_state: DailyPortfolioState,
) -> int:
"""Clamp long share count to the available cash budget.
The prior behavior rejected the order entirely when risk-based sizing
implied a notional bigger than current cash. For long-only sleeves with
low participation this discards viable trades unnecessarily. We instead
scale down to the largest whole-share position that the account can fund.
"""
if shares <= 0:
return 0
entry_price = float(candidate.entry_price_est)
if entry_price <= 0:
return 0
if candidate.trade_direction == "long":
max_cash_shares = math.floor(portfolio_state.cash_available / entry_price)
return max(0, min(shares, max_cash_shares))
return shares
def _cap_shares_by_position_limits(
shares: int,
candidate: Candidate,
portfolio_state: DailyPortfolioState,
config: BacktestConfig,
) -> int:
"""Apply notional and liquidity caps before the final cash clamp."""
if shares <= 0:
return 0
entry_price = float(candidate.entry_price_est)
if entry_price <= 0:
return 0
capped = shares
max_position_value_pct = (
candidate.engine_max_position_value_pct
if candidate.engine_max_position_value_pct is not None
else config.risk.max_position_value_pct
)
if max_position_value_pct is not None and max_position_value_pct > 0:
max_position_value = _resolve_sizing_equity(portfolio_state) * max_position_value_pct
capped = min(capped, math.floor(max_position_value / entry_price))
max_adv_fraction = (
candidate.engine_max_adv_fraction
if candidate.engine_max_adv_fraction is not None
else config.risk.max_adv_fraction
)
if max_adv_fraction is not None and max_adv_fraction > 0 and candidate.avg_dollar_volume > 0:
max_adv_notional = candidate.avg_dollar_volume * max_adv_fraction
capped = min(capped, math.floor(max_adv_notional / entry_price))
return max(0, capped)
def _remaining_risk_budget_dollars(
candidate: Candidate,
portfolio_state: DailyPortfolioState,
config: BacktestConfig,
*,
engine_daily_new_risk_used: float = 0.0,
) -> tuple[float, float, float]:
"""Return remaining (effective, daily, engine) risk budget in dollars."""
sizing_equity = _resolve_sizing_equity(portfolio_state)
daily_budget = sizing_equity * config.risk.max_daily_new_risk_pct
daily_remaining = max(0.0, daily_budget - portfolio_state.daily_new_risk_used)
engine_budget = daily_budget * candidate.engine_risk_budget_pct
engine_remaining = max(0.0, engine_budget - engine_daily_new_risk_used)
effective_remaining = min(daily_remaining, engine_remaining)
return effective_remaining, daily_remaining, engine_remaining
def _cap_shares_to_remaining_risk_budget(
shares: int,
entry_price: float,
stop_price: float,
remaining_risk_dollars: float,
) -> int:
"""Clip shares to fit the remaining risk budget."""
if shares <= 0:
return 0
stop_distance = abs(entry_price - stop_price)
if stop_distance <= 0:
return 0
max_budget_shares = math.floor(remaining_risk_dollars / stop_distance)
return max(0, min(shares, max_budget_shares))
def _count_sector_positions(open_positions: list[OpenPosition], sector: str) -> int:
return sum(1 for p in open_positions if p.plan.candidate.sector == sector)
def _open_symbols(open_positions: list[OpenPosition]) -> set[str]:
return {p.plan.candidate.symbol for p in open_positions}
def _is_a_tier_candidate(candidate: Candidate, config: BacktestConfig) -> bool:
threshold = config.signal.a_tier_score_threshold
return threshold is not None and candidate.score >= threshold
def _resolve_per_trade_risk_pct(candidate: Candidate, config: BacktestConfig) -> float:
if candidate.engine_per_trade_risk_pct is not None:
return candidate.engine_per_trade_risk_pct
if _is_a_tier_candidate(candidate, config) and config.risk.per_trade_risk_pct_a_tier is not None:
return config.risk.per_trade_risk_pct_a_tier
return config.risk.per_trade_risk_pct
def _resolve_oneoff_threshold(candidate: Candidate, config: BacktestConfig) -> float:
if candidate.engine_veto_oneoff_penalty is not None:
return candidate.engine_veto_oneoff_penalty
return config.risk.veto_oneoff_penalty
def _allow_oneoff_downsizing(candidate: Candidate, config: BacktestConfig) -> bool:
if candidate.engine_allow_oneoff_downsizing is not None:
return candidate.engine_allow_oneoff_downsizing
return config.risk.allow_oneoff_downsizing
def _resolve_oneoff_downsize_floor(candidate: Candidate, config: BacktestConfig) -> float:
if candidate.engine_oneoff_downsize_floor is not None:
return candidate.engine_oneoff_downsize_floor
return config.risk.oneoff_downsize_floor
def _oneoff_risk_scaler(candidate: Candidate, config: BacktestConfig) -> float:
if not _allow_oneoff_downsizing(candidate, config):
return 1.0
oneoff = candidate.features.get("oneoff_penalty")
if oneoff is None:
return 1.0
threshold = _resolve_oneoff_threshold(candidate, config)
try:
oneoff_value = float(oneoff)
except (TypeError, ValueError):
return 1.0
if oneoff_value < threshold:
return 1.0
span = max(1e-9, 1.0 - threshold)
excess = min(1.0, max(0.0, (oneoff_value - threshold) / span))
floor = min(1.0, max(0.0, _resolve_oneoff_downsize_floor(candidate, config)))
return 1.0 - excess * (1.0 - floor)
def _resolve_effective_per_trade_risk_pct(candidate: Candidate, config: BacktestConfig) -> float:
return _resolve_per_trade_risk_pct(candidate, config) * _oneoff_risk_scaler(candidate, config)
def _macro_regime_state(config: BacktestConfig, macro_data: dict[str, Any] | None) -> str:
if not config.risk.macro_regime_enabled or not macro_data:
return "disabled"
if config.risk.macro_regime_mode == "spy_qqq_scaler":
spy_close = macro_data.get("spy_close")
spy_sma = macro_data.get("spy_sma_20")
qqq_close = macro_data.get("qqq_close")
qqq_sma = macro_data.get("qqq_sma_20")
if None in (spy_close, spy_sma, qqq_close, qqq_sma):
return "unknown"
spy_on = float(spy_close) >= float(spy_sma)
qqq_on = float(qqq_close) >= float(qqq_sma)
if spy_on and qqq_on:
return "risk_on"
if spy_on or qqq_on:
return "neutral"
return "risk_off"
spy_close = macro_data.get("spy_close")
spy_sma = macro_data.get("spy_sma_20")
if spy_close is None or spy_sma is None:
return "unknown"
return "risk_off" if float(spy_close) < float(spy_sma) else "risk_on"
def _macro_size_scaler(config: BacktestConfig, regime_state: str) -> float:
if regime_state in {"disabled", "unknown", "risk_on"}:
return 1.0
if config.risk.macro_regime_mode == "spy_qqq_scaler":
if regime_state == "neutral":
return config.risk.macro_regime_neutral_size_scaler or 0.6
if regime_state == "risk_off":
return config.risk.macro_regime_risk_off_size_scaler or 0.35
if regime_state == "risk_off":
return config.risk.macro_regime_size_scaler
return 1.0
def _vix_continuous_size_scaler(config: BacktestConfig, macro_data: dict[str, Any] | None) -> float:
"""Continuous position sizing scaler based on VIX level.
Uses FRED series VIXCLS from macro_observations table.
Mode "vix_continuous" (default): defensive — scales DOWN as VIX rises.
Linearly interpolates between 1.0 (at vix_low) and vix_min (at vix_high).
Mode "vix_pead": PEAD-optimized — scales UP when VIX > 18 (favorable PEAD
regime with 62.3% WR), penalizes VIX 15-18 complacent zone (48.2% WR).
Uses vix_size_scaler_min as the complacent-zone floor.
Returns 1.0 if VIX data is missing or mode is not vix_continuous/vix_pead.
"""
if config.risk.macro_regime_mode not in ("vix_continuous", "vix_pead"):
return 1.0
if not macro_data:
return 1.0
vix = macro_data.get("VIXCLS")
if vix is None:
return 1.0
try:
vix_val = float(vix)
except (TypeError, ValueError):
return 1.0
if config.risk.macro_regime_mode == "vix_pead":
# PEAD-optimized: high VIX = boost, mid VIX = penalize
if vix_val > 18:
return min(1.3, 1.0 + (vix_val - 18) / 20) # +5% per VIX point above 18, max 1.3
if 15 < vix_val <= 18:
return config.risk.vix_size_scaler_min # complacent zone penalty
return 1.0 # low VIX = normal
# Default: defensive
low = config.risk.vix_size_scaler_low
high = config.risk.vix_size_scaler_high
floor = config.risk.vix_size_scaler_min
if vix_val <= low:
return 1.0
if vix_val >= high:
return floor
frac = (vix_val - low) / (high - low)
return 1.0 - frac * (1.0 - floor)
def _drawdown_size_scaler(
config: BacktestConfig, portfolio_state: DailyPortfolioState
) -> float:
"""Drawdown-aware dynamic position sizing (v9.5.8+).
Kelly-criterion-inspired risk feedback: reduce new position sizing when
portfolio is in drawdown. Linear ramp between threshold_min and
threshold_max, clamped at scale_floor beyond threshold_max so equity
has room to recover.
portfolio_state.current_drawdown_pct is stored as POSITIVE percentage
(e.g. 6.0 == 6% drawdown). Returns 1.0 when disabled or DD <= min threshold.
Regime-agnostic — binds whenever PEAD equity is in drawdown, regardless
of VIX / SPY-SMA / etc. (those failed OOT because windows didn't see those
regimes; drawdowns happen in every regime).
"""
if not getattr(config.risk, "drawdown_scaling_enabled", False):
return 1.0
dd = float(portfolio_state.current_drawdown_pct or 0.0)
th_min = float(config.risk.drawdown_threshold_min)
th_max = float(config.risk.drawdown_threshold_max)
floor = float(config.risk.drawdown_scale_floor)
if dd <= th_min:
return 1.0
if th_max <= th_min:
return 1.0
floor = max(0.0, min(1.0, floor))
if dd >= th_max:
return floor
frac = (dd - th_min) / (th_max - th_min)
return 1.0 - frac * (1.0 - floor)
def _vix_stepped_size_scaler(config: BacktestConfig, macro_data: dict[str, Any] | None) -> float:
"""Stepped position-sizing scaler driven by current VIX level.
Independent of macro_regime_mode (v9.5.5+). Returns 1.0 when disabled,
macro data missing, or VIX below low threshold. Returns scale_mid when
threshold_low < VIX <= threshold_high; scale_low when VIX > threshold_high.
Defaults follow CBOE/literature elevated-vol (25) and crisis (30) bands.
"""
if not getattr(config.risk, "vix_scaling_enabled", False):
return 1.0
if not macro_data:
return 1.0
vix = macro_data.get("VIXCLS")
if vix is None:
vix = macro_data.get("macro_vix")
if vix is None:
return 1.0
try:
vix_val = float(vix)
except (TypeError, ValueError):
return 1.0
th_high = float(config.risk.vix_scaling_threshold_high)
th_low = float(config.risk.vix_scaling_threshold_low)
if vix_val > th_high:
return float(config.risk.vix_scaling_scale_low)
if vix_val > th_low:
return float(config.risk.vix_scaling_scale_mid)
return 1.0
def _credit_spread_size_scaler(candidate: Candidate, config: BacktestConfig) -> float:
"""Scale position size based on HY credit spread regime.
Uses macro_hy_spread (ICE BofA HY OAS) from candidate features (Parquet).
Tight spreads = risk-on, wide/stress spreads = scale down.
Returns 1.0 if feature is missing or scaler is disabled.
"""
if not config.risk.credit_spread_size_scaler_enabled:
return 1.0
spread = candidate.features.get("macro_hy_spread")
if spread is None:
return 1.0
try:
spread_val = float(spread)
except (TypeError, ValueError):
return 1.0
if spread_val >= config.risk.credit_spread_wide_threshold:
return config.risk.credit_spread_stress_scaler
if spread_val >= config.risk.credit_spread_tight_threshold:
return config.risk.credit_spread_wide_scaler
return 1.0
def _yield_curve_size_scaler(candidate: Candidate, config: BacktestConfig) -> float:
"""Scale position size based on yield curve regime (T10Y2Y).
Uses macro_t10y2y from candidate features (Parquet enrichment).
Normal curve = full size, flat/inverted = scale down.
Returns 1.0 if feature is missing or scaler is disabled.
"""
if not config.risk.yield_curve_size_scaler_enabled:
return 1.0
t10y2y = candidate.features.get("macro_t10y2y")
if t10y2y is None:
return 1.0
try:
yc_val = float(t10y2y)
except (TypeError, ValueError):
return 1.0
if yc_val >= config.risk.yield_curve_normal_threshold:
return 1.0
if yc_val < 0:
return config.risk.yield_curve_inverted_scaler
return config.risk.yield_curve_flat_scaler
def _linear_engine_stress_scaler(
value: float | None,
low: float | None,
high: float | None,
floor: float | None,
) -> float:
if value is None or low is None or high is None or floor is None:
return 1.0
try:
value_f = float(value)
low_f = float(low)
high_f = float(high)
floor_f = float(floor)
except (TypeError, ValueError):
return 1.0
if high_f <= low_f:
return 1.0
if value_f <= low_f:
return 1.0
floor_f = max(0.0, min(1.0, floor_f))
if value_f >= high_f:
return floor_f
frac = (value_f - low_f) / (high_f - low_f)
return 1.0 - frac * (1.0 - floor_f)
def _engine_macro_stress_scaler(candidate: Candidate, macro_data: dict[str, Any] | None) -> float:
scalers: list[float] = []
vix_value = None
if macro_data:
vix_value = macro_data.get("VIXCLS")
if vix_value is None:
vix_value = macro_data.get("macro_vix")
vix_scaler = _linear_engine_stress_scaler(
vix_value,
candidate.engine_macro_vix_size_scaler_low,
candidate.engine_macro_vix_size_scaler_high,
candidate.engine_macro_vix_size_scaler_min,
)
if vix_scaler != 1.0:
scalers.append(vix_scaler)
hy_value = candidate.features.get("macro_hy_spread")
hy_scaler = _linear_engine_stress_scaler(
hy_value,
candidate.engine_macro_hy_spread_size_scaler_low,
candidate.engine_macro_hy_spread_size_scaler_high,
candidate.engine_macro_hy_spread_size_scaler_min,
)
if hy_scaler != 1.0:
scalers.append(hy_scaler)
return min(scalers) if scalers else 1.0
def _engine_score_size_scaler(candidate: Candidate) -> float:
low = candidate.engine_score_size_scaler_low
high = candidate.engine_score_size_scaler_high
floor = candidate.engine_score_size_scaler_min
score = candidate.score
if low is None or high is None or floor is None:
return 1.0
try:
low_f = float(low)
high_f = float(high)
floor_f = float(floor)
score_f = float(score)
except (TypeError, ValueError):
return 1.0
if high_f <= low_f:
return 1.0
floor_f = max(0.0, min(1.0, floor_f))
if score_f <= low_f:
return floor_f
if score_f >= high_f:
return 1.0
frac = (score_f - low_f) / (high_f - low_f)
return floor_f + frac * (1.0 - floor_f)
def _engine_entropy_size_scaler(candidate: Candidate) -> float:
low = candidate.engine_entropy_size_scaler_low
high = candidate.engine_entropy_size_scaler_high
floor = candidate.engine_entropy_size_scaler_min
entropy = candidate.features.get("pre_event_entropy_60d")
if low is None or high is None or floor is None or entropy is None:
return 1.0
try:
low_f = float(low)
high_f = float(high)
floor_f = float(floor)
entropy_f = float(entropy)
except (TypeError, ValueError):
return 1.0
if high_f <= low_f:
return 1.0
floor_f = max(0.0, min(1.0, floor_f))
if entropy_f <= low_f:
return 1.0
if entropy_f >= high_f:
return floor_f
frac = (entropy_f - low_f) / (high_f - low_f)
return 1.0 - frac * (1.0 - floor_f)
def _reaction_size_scaler(candidate: Candidate, config: BacktestConfig) -> float:
"""Scale position size inversely with reaction magnitude.
Large reactions (>8%) carry mean-reversion risk. Live paper trading showed:
- Losses avg reaction +10.8% → big positions that reverse
- Wins avg reaction +0.7% → moderate positions that drift
Scaling: full size at reaction_threshold, half at 2x threshold.
Disabled when reaction_size_cap_threshold is None (default).
"""
threshold = config.risk.reaction_size_cap_threshold
if threshold is None:
return 1.0
reaction = abs(candidate.features.get("reaction_day_return", 0.0) or 0.0)
if reaction <= threshold:
return 1.0
# Linear scale-down: threshold → 1.0, 2*threshold → 0.5, 3*threshold → 0.33
return threshold / reaction
def _momentum_size_scaler(candidate: Candidate, config: BacktestConfig) -> float:
"""Scale position size based on pre-event momentum.
High momentum (>threshold): scale DOWN — these have lower forward returns
(corr -0.19, Q5 WR 38%). Linear from 1.0 at threshold to floor at 2x.
Low momentum (contrarian boost, <contrarian_threshold): scale UP — stocks
below SMA20 have WR 55.4% vs 44.4%. Linear from 1.0 to boost_max.
"""
feature_name = config.risk.momentum_size_scaler_feature
mom = candidate.features.get(feature_name)
if mom is None:
return 1.0
mom = float(mom)
# Downscale high momentum
threshold = config.risk.momentum_size_scaler_threshold
if threshold is not None and mom > threshold:
floor = config.risk.momentum_size_scaler_floor
excess = (mom - threshold) / threshold
return max(floor, 1.0 - excess * (1.0 - floor))
# Boost low momentum (contrarian)
ct = config.risk.contrarian_boost_threshold
if ct is not None and mom < ct:
boost_max = config.risk.contrarian_boost_max
# Linear: ct → 1.0, 2*ct (more negative) → boost_max
depth = (ct - mom) / abs(ct) if ct != 0 else 0
return min(boost_max, 1.0 + depth * (boost_max - 1.0))
return 1.0
def _volatility_size_scaler(candidate: Candidate, config: BacktestConfig) -> float:
"""Scale position size inversely with pre-event realized volatility.
Low vol stocks have more predictable PEAD drift → larger positions.
High vol stocks have mean-reversion risk → smaller positions.
Linear interpolation from 1.0 at vol_low to floor at vol_high.
"""
if not config.risk.volatility_size_scaler_enabled:
return 1.0
vol = candidate.features.get("pre_event_volatility_20d")
if vol is None:
return 1.0
vol = float(vol)
low = config.risk.volatility_size_scaler_low
high = config.risk.volatility_size_scaler_high
floor = config.risk.volatility_size_scaler_min
if vol <= low:
return 1.0
if vol >= high:
return floor
frac = (vol - low) / (high - low)
return 1.0 - frac * (1.0 - floor)
def _breadth_crowding_size_scaler(candidate: Candidate, config: BacktestConfig) -> float:
"""Scale size down on crowded slate days.
This is a lightweight approximation of breadth/percolation throttles and
correlation penalties from the research catalog. It relies only on the
selected candidate slate for the day, which is annotated upstream by the
runner.
"""
scaler = 1.0
features = candidate.features
if config.risk.breadth_throttle_enabled:
total_count = features.get("daily_candidate_count_selected")
if total_count is not None:
try:
total_count_val = float(total_count)
except (TypeError, ValueError):
total_count_val = 0.0
threshold = max(1.0, float(config.risk.breadth_throttle_candidate_count_threshold))
if total_count_val > threshold:
floor = min(1.0, max(0.0, config.risk.breadth_throttle_min))
scaler = min(scaler, max(floor, threshold / total_count_val))
if config.risk.sector_crowding_penalty_enabled:
sector_count = features.get("daily_sector_candidate_count_selected")
if sector_count is not None:
try:
sector_count_val = float(sector_count)
except (TypeError, ValueError):
sector_count_val = 0.0
threshold = max(1.0, float(config.risk.sector_crowding_candidate_count_threshold))
if sector_count_val > threshold:
floor = min(1.0, max(0.0, config.risk.sector_crowding_penalty_min))
scaler = min(scaler, max(floor, threshold / sector_count_val))
return scaler
def _tail_risk_adjuster_scaler(candidate: Candidate, config: BacktestConfig) -> float:
"""Scale size down for candidates with a stacked left-tail profile."""
if not config.risk.tail_risk_adjuster_enabled:
return 1.0
components: list[float] = []
features = candidate.features
reaction = features.get("reaction_day_return")
if reaction is not None:
try:
reaction_val = abs(float(reaction))
except (TypeError, ValueError):
reaction_val = None
if reaction_val is not None:
components.append(min(1.0, reaction_val / 0.12))
oneoff = features.get("oneoff_penalty")
if oneoff is not None:
try:
oneoff_val = float(oneoff)
except (TypeError, ValueError):
oneoff_val = None
if oneoff_val is not None:
components.append(min(1.0, max(0.0, oneoff_val)))
market_temperature = features.get("pre_event_market_temperature")
if market_temperature is not None:
try:
temp_val = float(market_temperature)
except (TypeError, ValueError):
temp_val = None
if temp_val is not None:
components.append(min(1.0, max(0.0, (temp_val - 0.6) / 0.9)))
entropy = features.get("pre_event_entropy_60d")
if entropy is not None:
try:
entropy_val = float(entropy)
except (TypeError, ValueError):
entropy_val = None
if entropy_val is not None:
components.append(min(1.0, max(0.0, (entropy_val - 1.5) / 0.7)))
if len(components) < max(1, config.risk.tail_risk_min_signals):
return 1.0
tail_score = sum(components) / len(components)
threshold = min(0.999, max(0.0, config.risk.tail_risk_penalty_threshold))
if tail_score <= threshold:
return 1.0
floor = min(1.0, max(0.0, config.risk.tail_risk_penalty_min))
excess = (tail_score - threshold) / max(1e-9, 1.0 - threshold)
return max(floor, 1.0 - excess * (1.0 - floor))
def _technical_conviction_boost(candidate: Candidate, config: BacktestConfig) -> float:
"""Boost position size when pre-event technicals indicate conviction.
Favorable conditions (size UP):
- Low volatility (< 2% daily) = predictable drift → boost
- Positive OBV slope = institutional accumulation → boost
- RSI < 50 + positive reaction = oversold reversal → boost
- BB %B < 0.5 = below midline, room to run → boost
Each favorable condition adds up to +10% boost, capped at max.
Returns >= 1.0 always (never reduces size).
"""
if not config.risk.technical_conviction_boost_enabled:
return 1.0
boost = 1.0
max_boost = config.risk.technical_conviction_boost_max
features = candidate.features
# Low vol = predictable drift
vol = features.get("pre_event_volatility_20d")
if vol is not None:
vol = float(vol)
if vol < 0.015: # < 1.5% daily vol
boost += 0.10
elif vol < 0.02: # < 2% daily vol
boost += 0.05
# Positive OBV = accumulation
obv = features.get("pre_event_obv_slope_20d")
if obv is not None:
obv = float(obv)
if obv > 0.1: # strong accumulation
boost += 0.10
elif obv > 0: # mild accumulation
boost += 0.05
# RSI below 50 with positive reaction = oversold bounce
rsi = features.get("pre_event_rsi_14")
reaction = features.get("reaction_day_return")
if rsi is not None and reaction is not None:
rsi = float(rsi)
reaction = float(reaction)
if rsi < 40 and reaction > 0:
boost += 0.10
elif rsi < 50 and reaction > 0.03:
boost += 0.05
# BB %B below midline = room to run
bb = features.get("pre_event_bb_position")
if bb is not None:
bb = float(bb)
if bb < 0.3: # well below midline
boost += 0.10
elif bb < 0.5: # below midline
boost += 0.05
return min(max_boost, boost)
def run_entry_gates(
candidate: Candidate,
portfolio_state: DailyPortfolioState,
open_positions: list[OpenPosition],
config: BacktestConfig,
cooldown_remaining: int = 0,
macro_data: dict[str, Any] | None = None,
engine_daily_new_risk_used: float = 0.0,
) -> str | None:
"""Run entry gates. Returns skip_reason string or None (pass).
Gates (in order):
0. Macro regime (SPY below SMA — hard block only if size_scaler >= 1.0)
1. Kill switch (drawdown >= threshold)
2. Max total positions
3. Duplicate symbol already open
4. Sector concentration
5. Daily new risk budget
6. Cash available (estimated position cost)
7. Loss-streak cooldown
8. (removed — SUE gate)
9. Event-type direction filter (bullish_only)
10. High one-off risk (veto: oneoff_penalty >= threshold)
11. Low parse confidence (veto: parse_confidence < threshold)
12. Unknown direction (veto: event_direction == "unknown")
13. Bearish direction (veto: event_direction == "bearish")
"""
# Gate 0: Macro regime filter / tier restriction
regime_state = _macro_regime_state(config, macro_data)
regime_scaler = _macro_size_scaler(config, regime_state)
if config.risk.macro_regime_enabled:
if config.risk.macro_regime_mode == "spy_qqq_scaler":
if regime_state == "risk_off" and config.risk.macro_regime_risk_off_a_tier_only:
if candidate.trade_direction != "long" or not _is_a_tier_candidate(candidate, config):
return "macro_regime_risk_off_non_a_tier"
elif regime_state == "risk_off" and regime_scaler >= 1.0:
return "macro_regime_unfavorable"
# Gate 1: Kill switch (skip if log-only mode)
if portfolio_state.current_drawdown_pct >= _KILL_SWITCH_DRAWDOWN_PCT:
if not config.risk.kill_switch_log_only:
return "kill_switch_drawdown"
# Gate 2: Max positions
if len(open_positions) >= config.risk.max_positions:
return "max_positions_reached"
# Gate 3: Duplicate symbol
if candidate.symbol in _open_symbols(open_positions):
if not candidate.is_add_on:
return "duplicate_symbol"
if candidate.parent_position_id is None:
return "orphan_add_on"
parent = next(
(position for position in open_positions if position.position_id == candidate.parent_position_id),
None,
)
if parent is None:
return "orphan_add_on"
allowed_add_on_count = candidate.engine_add_on_max_count or 1
existing_add_on_count = sum(
position.plan.candidate.symbol == candidate.symbol
and position.parent_position_id == candidate.parent_position_id
and position.is_add_on
for position in open_positions
)
if existing_add_on_count >= allowed_add_on_count:
return "duplicate_add_on"
# Gate 4: Sector concentration
sector_count = _count_sector_positions(open_positions, candidate.sector)
max_positions_per_sector = (
candidate.engine_max_positions_per_sector
if candidate.engine_max_positions_per_sector is not None
else config.risk.max_positions_per_sector
)
if sector_count >= max_positions_per_sector:
return "sector_limit"
# Gate 5: Daily new risk budget
trade_risk_pct = _resolve_effective_per_trade_risk_pct(candidate, config)
trade_risk = _resolve_sizing_equity(portfolio_state) * trade_risk_pct
remaining_risk, daily_remaining, engine_remaining = _remaining_risk_budget_dollars(
candidate,
portfolio_state,
config,
engine_daily_new_risk_used=engine_daily_new_risk_used,
)
daily_budget = _resolve_sizing_equity(portfolio_state) * config.risk.max_daily_new_risk_pct
if daily_remaining <= 0:
return "daily_risk_budget"
engine_budget = daily_budget * candidate.engine_risk_budget_pct
if engine_budget <= 0:
return "engine_daily_risk_budget"
if engine_remaining <= 0:
return "engine_daily_risk_budget"
if not config.risk.allow_budget_downsizing:
if portfolio_state.daily_new_risk_used + trade_risk > daily_budget:
return "daily_risk_budget"
if engine_daily_new_risk_used + trade_risk > engine_budget:
return "engine_daily_risk_budget"
elif remaining_risk <= 0:
return "daily_risk_budget"
# Gate 6: Cash available (estimate position cost)
stop_price = compute_stop_price(candidate, _resolve_stop_risk_config(candidate, config))
est_shares = compute_shares(
_resolve_sizing_equity(portfolio_state),
candidate.entry_price_est,
stop_price,
config.risk,
risk_pct_override=trade_risk_pct,
)
if candidate.forced_shares is not None:
est_shares = candidate.forced_shares
est_shares = _cap_shares_to_cash(est_shares, candidate, portfolio_state)
if est_shares <= 0:
return "insufficient_cash"
# Gate 7: Cooldown
if cooldown_remaining > 0:
return "cooldown"
# Gate 9: Event-type direction filter
profile = config.get_event_profile(candidate.event_type)
if profile:
reaction = candidate.features.get("reaction_day_return")
if profile.direction_filter == "bullish_only" and reaction is not None and float(reaction) < 0:
return "direction_filter_bearish"
if profile.direction_filter == "bearish_only" and reaction is not None and float(reaction) > 0:
return "direction_filter_bullish"
# --- Veto gates: document quality hard filters ---
# Gate 10: High one-off risk
oneoff = candidate.features.get("oneoff_penalty")
oneoff_threshold = _resolve_oneoff_threshold(candidate, config)
if (
oneoff is not None
and float(oneoff) >= oneoff_threshold
and not _allow_oneoff_downsizing(candidate, config)
):
return "high_oneoff_risk"
# Gate 11: Low parse confidence
parse_conf = candidate.features.get("parse_confidence_overall")
parse_threshold = (
candidate.engine_veto_parse_confidence_min
if candidate.engine_veto_parse_confidence_min is not None
else config.risk.veto_parse_confidence_min
)
if parse_conf is not None and float(parse_conf) < parse_threshold:
return "low_parse_confidence"
# Gate 12: Unknown direction
# Engines that explicitly target unknown direction (event_directions includes "unknown")
# are exempt — their selection IS the intent to handle these events.
event_dir = candidate.features.get("event_direction")
if (
config.risk.veto_unknown_direction
and event_dir is not None
and str(event_dir).lower() == "unknown"
and not candidate.engine_allow_unknown_direction
):
return "unknown_direction"
# Gate 13: Bearish direction (all event types, document-based)
forced_trade_direction = str(candidate.engine_forced_trade_direction or "").lower()
if (
config.risk.veto_bearish_direction
and event_dir is not None
and str(event_dir).lower() == "bearish"
and forced_trade_direction != "long"
):
return "bearish_direction"
return None # all gates passed
def build_planned_order(
candidate: Candidate,
portfolio_state: DailyPortfolioState,
open_positions: list[OpenPosition],
config: BacktestConfig,
execution_config: ExecutionConfig | None = None,
cooldown_remaining: int = 0,
macro_data: dict[str, Any] | None = None,
engine_daily_new_risk_used: float = 0.0,
) -> PlannedOrder:
"""Build a PlannedOrder. skip_reason is non-None if any gate rejected it."""
skip_reason = run_entry_gates(
candidate, portfolio_state, open_positions, config, cooldown_remaining,
macro_data=macro_data,
engine_daily_new_risk_used=engine_daily_new_risk_used,
)
exec_cfg = execution_config or config.execution
trade_risk_pct = _resolve_effective_per_trade_risk_pct(candidate, config)
# Apply event-type-specific overrides for stop/target ATR multipliers
profile = config.get_event_profile(candidate.event_type)
target_atr_mult = (
candidate.engine_target_atr_multiplier
if candidate.engine_target_atr_multiplier is not None
else (
profile.target_atr_multiplier_override
if profile and profile.target_atr_multiplier_override is not None
else exec_cfg.target_atr_multiplier
)
)
stop_price = compute_stop_price(candidate, _resolve_stop_risk_config(candidate, config))
target_r = exec_cfg.target_1_r
if target_r is None and exec_cfg.use_tiered_targets:
if _is_a_tier_candidate(candidate, config):
if exec_cfg.a_tier_target_1_r is not None:
target_r = exec_cfg.a_tier_target_1_r
else:
if exec_cfg.non_a_tier_target_1_r is not None:
target_r = exec_cfg.non_a_tier_target_1_r
if target_r is None:
target_r = 2.0
shares = 0
risk_dollars = 0.0
if skip_reason is None:
if candidate.forced_shares is not None:
shares = candidate.forced_shares
else:
shares = compute_shares(
_resolve_sizing_equity(portfolio_state),
candidate.entry_price_est,
stop_price,
config.risk,
risk_pct_override=trade_risk_pct,
)
if shares == 0:
skip_reason = "zero_shares"
else:
regime_state = _macro_regime_state(config, macro_data)
scaler = _macro_size_scaler(config, regime_state)
if config.risk.macro_regime_enabled and scaler < 1.0:
shares = max(1, math.floor(shares * scaler))
vix_scaler = _vix_continuous_size_scaler(config, macro_data)
if vix_scaler != 1.0:
shares = max(1, math.floor(shares * vix_scaler))
reaction_scaler = _reaction_size_scaler(candidate, config)
if reaction_scaler < 1.0:
shares = max(1, math.floor(shares * reaction_scaler))
mom_scaler = _momentum_size_scaler(candidate, config)
if mom_scaler != 1.0:
shares = max(1, math.floor(shares * mom_scaler))
vol_scaler = _volatility_size_scaler(candidate, config)
if vol_scaler != 1.0:
shares = max(1, math.floor(shares * vol_scaler))
breadth_scaler = _breadth_crowding_size_scaler(candidate, config)
if breadth_scaler != 1.0:
shares = max(1, math.floor(shares * breadth_scaler))
tail_scaler = _tail_risk_adjuster_scaler(candidate, config)
if tail_scaler != 1.0:
shares = max(1, math.floor(shares * tail_scaler))
conviction_boost = _technical_conviction_boost(candidate, config)
if conviction_boost > 1.0:
shares = max(1, math.floor(shares * conviction_boost))
cs_scaler = _credit_spread_size_scaler(candidate, config)
if cs_scaler != 1.0:
shares = max(1, math.floor(shares * cs_scaler))
yc_scaler = _yield_curve_size_scaler(candidate, config)
if yc_scaler != 1.0:
shares = max(1, math.floor(shares * yc_scaler))
engine_score_scaler = _engine_score_size_scaler(candidate)
if engine_score_scaler != 1.0:
shares = max(1, math.floor(shares * engine_score_scaler))
engine_entropy_scaler = _engine_entropy_size_scaler(candidate)
if engine_entropy_scaler != 1.0:
shares = max(1, math.floor(shares * engine_entropy_scaler))
engine_macro_scaler = _engine_macro_stress_scaler(candidate, macro_data)
if engine_macro_scaler != 1.0:
shares = max(1, math.floor(shares * engine_macro_scaler))
# v9.5.5+: stepped VIX scaler applied LAST in the multiplicative chain
# (after all other sizing has resolved). Independent of macro_regime_mode.
vix_stepped_scaler = _vix_stepped_size_scaler(config, macro_data)
if vix_stepped_scaler != 1.0:
shares = max(1, math.floor(shares * vix_stepped_scaler))
logger.info(
"vix_scaling_applied",
engine_id=candidate.engine_id,
symbol=candidate.symbol,
vix=(macro_data or {}).get("VIXCLS"),
scaler=vix_stepped_scaler,
)
# v9.5.8+: drawdown-aware dynamic sizing applied AFTER VIX stepped
# scaler. Stacks multiplicatively, but only one of the two typically
# binds at a time (VIX = systemic stress; DD = PEAD-specific stress).
dd_scaler = _drawdown_size_scaler(config, portfolio_state)
if dd_scaler != 1.0:
shares = max(1, math.floor(shares * dd_scaler))
logger.info(
"drawdown_scaling_applied",
engine_id=candidate.engine_id,
symbol=candidate.symbol,
current_drawdown_pct=portfolio_state.current_drawdown_pct,
scaler=dd_scaler,
)
if config.risk.allow_budget_downsizing:
remaining_risk, daily_remaining, engine_remaining = _remaining_risk_budget_dollars(
candidate,
portfolio_state,
config,
engine_daily_new_risk_used=engine_daily_new_risk_used,
)
shares = _cap_shares_to_remaining_risk_budget(
shares,
candidate.entry_price_est,
stop_price,
remaining_risk,
)
shares = _cap_shares_by_position_limits(
shares,
candidate,
portfolio_state,
config,
)
shares = _cap_shares_to_cash(shares, candidate, portfolio_state)
if shares == 0:
if config.risk.allow_budget_downsizing and (
daily_remaining <= 0 or engine_remaining <= 0
):
skip_reason = "daily_risk_budget" if daily_remaining <= 0 else "engine_daily_risk_budget"
else:
skip_reason = "zero_shares"
risk_dollars = 0.0
target_price = compute_target_price(
candidate.entry_price_est,
stop_price,
target_r,
target_model=exec_cfg.target_model,
target_atr_multiplier=target_atr_mult,
atr_14=candidate.atr_14,
trade_direction=candidate.trade_direction,
)
return PlannedOrder(
candidate=candidate,
shares=shares,
entry_price_limit=candidate.entry_price_est,
stop_price=stop_price,
target_price=target_price,
risk_dollars=risk_dollars,
event_date=candidate.event_date,
timing_class=candidate.timing_class,
engine_id=candidate.engine_id,
entry_timing_policy=candidate.entry_timing_policy,
shadow_only=candidate.shadow_only,
parent_position_id=candidate.parent_position_id,
is_add_on=candidate.is_add_on,
skip_reason=skip_reason,
)
risk_dollars = abs(candidate.entry_price_est - stop_price) * shares
target_price = compute_target_price(
candidate.entry_price_est,
stop_price,
target_r,
target_model=exec_cfg.target_model,
target_atr_multiplier=target_atr_mult,
atr_14=candidate.atr_14,
trade_direction=candidate.trade_direction,
)
return PlannedOrder(
candidate=candidate,
shares=shares,
entry_price_limit=candidate.entry_price_est,
stop_price=stop_price,
target_price=target_price,
risk_dollars=risk_dollars,
event_date=candidate.event_date,
timing_class=candidate.timing_class,
engine_id=candidate.engine_id,
entry_timing_policy=candidate.entry_timing_policy,
shadow_only=candidate.shadow_only,
parent_position_id=candidate.parent_position_id,
is_add_on=candidate.is_add_on,
skip_reason=skip_reason,
)