Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled
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.
251 lines
9.1 KiB
Python
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
|