trading/services/trade_executor/risk_manager.py
Viktor Barzin f7ca671bf3
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled
feat(phase2): BRACKET orders + Kevin risk caps (Tasks 18, 19)
Task 18 — OrderRequest + AlpacaBroker BRACKET support:
- OrderRequest gains order_class ("simple" | "bracket"),
  take_profit_price, stop_loss_price + model_validator that requires
  both legs when order_class == "bracket".
- AlpacaBroker._build_order_request branches to a MarketOrderRequest
  with OrderClass.BRACKET + TakeProfitRequest + StopLossRequest legs,
  TimeInForce.GTC so the bracket survives day boundaries.

Task 19 — RiskManager Kevin caps + circuit-breaker:
- TradeExecutorConfig gains 4 fields: kevin_daily_trade_cap,
  kevin_daily_alloc_cap_usd, kevin_equity_drawdown_halt_pct,
  kevin_daily_loss_circuit_pct.
- check_risk() applies the caps only when
  signal.strategy_id == KEVIN_STRATEGY_UUID; non-Kevin signals pass
  through the existing path unchanged.
- 4 new checks in order: drawdown halt (sets permanent
  trading:paused), daily-loss circuit (setex 24h), daily trade-count
  cap, daily allocation cap (rolling today's $ + this trade's
  notional).
- Counter keys: kevin:daily_trades:YYYY-MM-DD,
  kevin:daily_alloc_usd:YYYY-MM-DD, kevin:daily_pnl_usd:YYYY-MM-DD,
  kevin:starting_equity_usd. All read-only here; bridge + executor
  write them.

Tests: 5 bracket + 9 kevin-caps + 28 regression-safe. Total 67 + 14
new = 81 passing (excluding -m integration). No DB needed.
2026-05-26 21:03:59 +00:00

251 lines
9.1 KiB
Python

"""Pre-trade risk management checks and position sizing.
Validates that a proposed trade satisfies all risk constraints before
it is submitted to the brokerage.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from decimal import Decimal
from zoneinfo import ZoneInfo
from redis.asyncio import Redis
from services.trade_executor.config import TradeExecutorConfig
from shared.broker.base import BaseBroker
from shared.constants.kevin import KEVIN_STRATEGY_UUID
from shared.schemas.trading import AccountInfo, PositionInfo, SignalDirection, TradeSignal
logger = logging.getLogger(__name__)
_ET = ZoneInfo("America/New_York")
# Market hours in Eastern Time
_MARKET_OPEN_HOUR = 9
_MARKET_OPEN_MINUTE = 30
_MARKET_CLOSE_HOUR = 16
_MARKET_CLOSE_MINUTE = 0
TRADING_PAUSED_KEY = "trading:paused"
# Kevin counter keys — _today_key() suffixes with YYYY-MM-DD in ET.
_KEVIN_DAILY_TRADES_PREFIX = "kevin:daily_trades:"
_KEVIN_DAILY_ALLOC_PREFIX = "kevin:daily_alloc_usd:"
_KEVIN_DAILY_PNL_PREFIX = "kevin:daily_pnl_usd:"
_KEVIN_STARTING_EQUITY_KEY = "kevin:starting_equity_usd"
_PAUSE_24H_SECONDS = 86400
class RiskManager:
"""Performs pre-trade risk checks and calculates position sizes.
Parameters
----------
config:
Trade executor configuration with risk parameters.
broker:
Broker instance for querying current positions and account info.
redis:
Redis client for checking the trading pause flag.
"""
def __init__(
self,
config: TradeExecutorConfig,
broker: BaseBroker,
redis: Redis | None = None,
) -> None:
self.config = config
self.broker = broker
self.redis = redis
# ticker -> last exit timestamp
self._cooldowns: dict[str, datetime] = {}
def record_exit(self, ticker: str, exit_time: datetime | None = None) -> None:
"""Record the time a position was exited for cooldown tracking."""
self._cooldowns[ticker] = exit_time or datetime.now(tz=_ET)
async def check_risk(self, signal: TradeSignal) -> tuple[bool, str]:
"""Run all pre-trade risk checks.
Returns
-------
tuple[bool, str]
``(approved, reason)`` — ``approved`` is ``True`` when
all checks pass, otherwise ``reason`` explains the failure.
"""
# 0. Trading pause flag
if self.redis is not None:
paused = await self.redis.get(TRADING_PAUSED_KEY)
if paused:
return False, "trading_paused"
# 1. Market hours
now_et = datetime.now(tz=_ET)
if not self._is_market_hours(now_et):
return False, "outside_market_hours"
# 2. Cooldown
if signal.ticker in self._cooldowns:
last_exit = self._cooldowns[signal.ticker]
cooldown_end = last_exit + timedelta(minutes=self.config.cooldown_minutes)
if now_et < cooldown_end:
remaining = (cooldown_end - now_et).total_seconds() / 60
return False, f"cooldown_active ({remaining:.1f}m remaining)"
# 3. Max positions
positions = await self.broker.get_positions()
if len(positions) >= self.config.max_positions:
return False, "max_positions_exceeded"
# 4. Max total exposure
account = await self.broker.get_account()
total_exposure = sum(abs(p.market_value) for p in positions)
max_exposure = account.equity * self.config.max_total_exposure_pct
if total_exposure >= max_exposure:
return False, "max_exposure_exceeded"
# 5. Kevin-specific caps (only when the signal carries the Kevin
# strategy ID; no-op for other strategies).
if signal.strategy_id == KEVIN_STRATEGY_UUID and self.redis is not None:
kevin_ok, kevin_reason = await self._check_kevin_caps(
signal, account, now_et
)
if not kevin_ok:
return False, kevin_reason
return True, "approved"
async def _check_kevin_caps(
self,
signal: TradeSignal,
account: AccountInfo,
now_et: datetime,
) -> tuple[bool, str]:
"""Apply the 4 Kevin v2 risk caps. Caller already verified
``signal.strategy_id == KEVIN_STRATEGY_UUID`` and ``self.redis``
is not None.
"""
today = now_et.strftime("%Y-%m-%d")
# 5a. Drawdown halt (permanent) — checks BEFORE the daily caps so a
# blown-up account stops trading immediately.
starting_equity_raw = await self.redis.get(_KEVIN_STARTING_EQUITY_KEY)
if starting_equity_raw is not None:
starting_equity = float(starting_equity_raw)
if starting_equity > 0:
drawdown = (starting_equity - account.equity) / starting_equity
if drawdown >= self.config.kevin_equity_drawdown_halt_pct:
logger.warning(
"Kevin drawdown halt: equity %.2f vs starting %.2f (%.1f%%)",
account.equity,
starting_equity,
drawdown * 100,
)
await self.redis.set(TRADING_PAUSED_KEY, "1")
return False, "kevin_drawdown_halt"
# 5b. Daily-loss circuit breaker (24h pause).
daily_pnl_raw = await self.redis.get(_KEVIN_DAILY_PNL_PREFIX + today)
if daily_pnl_raw is not None:
daily_pnl_usd = float(daily_pnl_raw)
loss_threshold_usd = -account.equity * self.config.kevin_daily_loss_circuit_pct
if daily_pnl_usd <= loss_threshold_usd:
logger.warning(
"Kevin daily-loss circuit: today P&L %.2f <= threshold %.2f",
daily_pnl_usd,
loss_threshold_usd,
)
await self.redis.setex(TRADING_PAUSED_KEY, _PAUSE_24H_SECONDS, "1")
return False, "kevin_daily_loss_circuit"
# 5c. Daily trade count cap.
daily_trades_raw = await self.redis.get(_KEVIN_DAILY_TRADES_PREFIX + today)
daily_trades = int(daily_trades_raw) if daily_trades_raw is not None else 0
if daily_trades >= self.config.kevin_daily_trade_cap:
return False, "kevin_daily_trade_cap"
# 5d. Daily allocation cap (allocated $ today + this trade's notional).
daily_alloc_raw = await self.redis.get(_KEVIN_DAILY_ALLOC_PREFIX + today)
daily_alloc = float(daily_alloc_raw) if daily_alloc_raw is not None else 0.0
this_trade_usd = (
float(signal.target_dollars) if signal.target_dollars is not None else 0.0
)
if daily_alloc + this_trade_usd > self.config.kevin_daily_alloc_cap_usd:
return False, "kevin_daily_alloc_cap"
return True, "approved"
def calculate_position_size(
self,
signal: TradeSignal,
account: AccountInfo,
) -> float:
"""Calculate the number of shares to buy/sell.
Uses fixed-fractional sizing: ``equity * max_position_pct``
gives the maximum dollar value per position, then scales by
signal strength.
Parameters
----------
signal:
The trade signal (includes current price via strength).
account:
Current account info (equity, buying power).
Returns
-------
float
Number of shares (whole shares).
"""
if signal.strength <= 0 or account.equity <= 0:
return 0.0
position_value = account.equity * self.config.max_position_pct
position_value *= signal.strength
# Need a price to compute qty — use the signal's embedded price
# or fall back to getting it from the snapshot. For simplicity
# the executor will pass the current price through the signal's
# sentiment_context or fetch it directly.
current_price = 0.0
if signal.sentiment_context and "current_price" in signal.sentiment_context:
current_price = float(signal.sentiment_context["current_price"])
if current_price <= 0:
logger.warning("No current price for %s, cannot size position", signal.ticker)
return 0.0
qty = position_value / current_price
return max(int(qty), 0)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
@staticmethod
def _is_market_hours(now_et: datetime) -> bool:
"""Return ``True`` if *now_et* falls within regular US market hours.
Market hours: Monday--Friday, 9:30 AM -- 4:00 PM ET.
"""
# Weekday check (0=Monday ... 6=Sunday)
if now_et.weekday() >= 5:
return False
market_open = now_et.replace(
hour=_MARKET_OPEN_HOUR,
minute=_MARKET_OPEN_MINUTE,
second=0,
microsecond=0,
)
market_close = now_et.replace(
hour=_MARKET_CLOSE_HOUR,
minute=_MARKET_CLOSE_MINUTE,
second=0,
microsecond=0,
)
return market_open <= now_et < market_close