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