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.
703 lines
26 KiB
Python
703 lines
26 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 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
|
|
if candidate.atr_14 and candidate.atr_14 > 0:
|
|
stop_distance = candidate.atr_14 * config.stop_atr_multiplier
|
|
else:
|
|
# Fallback: 2% of price
|
|
stop_distance = price * 0.02
|
|
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 = 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 = 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 _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 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)
|
|
if config.risk.veto_bearish_direction and event_dir is not None and str(event_dir).lower() == "bearish":
|
|
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 or 2.0
|
|
if 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
|
|
|
|
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))
|
|
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,
|
|
)
|