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.

259 lines
8.8 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,
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.
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
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,
) -> float:
"""Compute target price using fixed R-multiple or ATR-based model.
Models:
- "fixed_r": target = entry + risk * target_r (original)
- "atr_multiple": target = entry + atr_14 * target_atr_multiplier
"""
if target_model == "atr_multiple" and atr_14 and atr_14 > 0:
return entry_price_est + atr_14 * target_atr_multiplier
# Default: fixed R-multiple
risk = entry_price_est - stop_price
if risk <= 0:
return entry_price_est * 1.10 # 10% default target
return entry_price_est + 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 = 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,
) -> str | None:
"""Run entry gates. Returns skip_reason string or None (pass).
Gates (in order):
0. Macro regime (SPY below SMA — bearish market)
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. SUE gate (earnings: positive surprise required)
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
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:
return "macro_regime_unfavorable"
# Gate 1: Kill switch
if portfolio_state.current_drawdown_pct >= _KILL_SWITCH_DRAWDOWN_PCT:
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"
# 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 8: SUE gate — for earnings_release events, require positive surprise
if candidate.event_type == "earnings_release":
eps_growth = candidate.features.get("eps_growth_qoq")
if eps_growth is not None and float(eps_growth) <= 0:
return "negative_earnings_surprise"
# Gate 9: Event-type direction filter
profile = config.get_event_profile(candidate.event_type)
if profile and profile.direction_filter == "bullish_only":
reaction = candidate.features.get("reaction_day_return")
if reaction is not None and float(reaction) < 0:
return "direction_filter_bearish"
# --- 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,
cooldown_remaining: int = 0,
macro_data: dict[str, Any] | None = None,
) -> 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,
)
# 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 = (
profile.target_atr_multiplier_override
if profile and profile.target_atr_multiplier_override is not None
else config.execution.target_atr_multiplier
)
stop_price = compute_stop_price(
candidate, RiskConfig(**{**config.risk.model_dump(), "stop_atr_multiplier": stop_atr_mult})
)
target_r = config.execution.target_1_r or 2.0
target_price = compute_target_price(
candidate.entry_price_est,
stop_price,
target_r,
target_model=config.execution.target_model,
target_atr_multiplier=target_atr_mult,
atr_14=candidate.atr_14,
)
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:
risk_dollars = (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,
skip_reason=skip_reason,
)