"""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 _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, 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)) 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, )