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.

304 lines
11 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 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
return max(0.01, price - stop_distance)
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,
) -> 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 * config.per_trade_risk_pct
raw_shares = risk_dollars / stop_distance
return max(0, math.floor(raw_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 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 (hard block only when size_scaler >= 1.0)
if config.risk.macro_regime_enabled and macro_data:
spy_close = macro_data.get("spy_close")
spy_sma = macro_data.get("spy_sma_20")
if spy_close is not None and spy_sma is not None and spy_close < spy_sma:
if config.risk.macro_regime_size_scaler >= 1.0:
return "macro_regime_unfavorable"
# else: size scaler applied in build_planned_order
# 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):
return "duplicate_symbol"
# Gate 4: Sector concentration
sector_count = _count_sector_positions(open_positions, candidate.sector)
if sector_count >= config.risk.max_positions_per_sector:
return "sector_limit"
# Gate 5: Daily new risk budget
trade_risk = portfolio_state.equity * config.risk.per_trade_risk_pct
daily_budget = portfolio_state.equity * config.risk.max_daily_new_risk_pct
if portfolio_state.daily_new_risk_used + trade_risk > daily_budget:
return "daily_risk_budget"
engine_budget = daily_budget * candidate.engine_risk_budget_pct
if engine_budget <= 0:
return "engine_daily_risk_budget"
if engine_daily_new_risk_used + trade_risk > engine_budget:
return "engine_daily_risk_budget"
# Gate 6: Cash available (estimate position cost)
stop_price = compute_stop_price(candidate, config.risk)
est_shares = compute_shares(
portfolio_state.equity,
candidate.entry_price_est,
stop_price,
config.risk,
)
est_cost = est_shares * candidate.entry_price_est
if est_cost > portfolio_state.cash_available:
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")
if oneoff is not None and float(oneoff) >= config.risk.veto_oneoff_penalty:
return "high_oneoff_risk"
# Gate 11: Low parse confidence
parse_conf = candidate.features.get("parse_confidence_overall")
if parse_conf is not None and float(parse_conf) < config.risk.veto_parse_confidence_min:
return "low_parse_confidence"
# Gate 12: Unknown direction
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":
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
# Apply event-type-specific overrides for stop/target ATR multipliers
profile = config.get_event_profile(candidate.event_type)
stop_atr_mult = (
profile.stop_atr_multiplier_override
if profile and profile.stop_atr_multiplier_override is not None
else config.risk.stop_atr_multiplier
)
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, RiskConfig(**{**config.risk.model_dump(), "stop_atr_multiplier": stop_atr_mult})
)
target_r = exec_cfg.target_1_r or 2.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,
)
shares = 0
risk_dollars = 0.0
if skip_reason is None:
shares = compute_shares(
portfolio_state.equity,
candidate.entry_price_est,
stop_price,
config.risk,
)
if shares == 0:
skip_reason = "zero_shares"
else:
# Apply macro size scaler when SPY < SMA and scaler < 1.0
if (
config.risk.macro_regime_enabled
and config.risk.macro_regime_size_scaler < 1.0
and macro_data
):
spy_close = macro_data.get("spy_close")
spy_sma = macro_data.get("spy_sma_20")
if (
spy_close is not None
and spy_sma is not None
and spy_close < spy_sma
):
shares = max(1, math.floor(shares * config.risk.macro_regime_size_scaler))
risk_dollars = abs(candidate.entry_price_est - stop_price) * shares
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,
skip_reason=skip_reason,
)