feat(phase2): BRACKET orders + Kevin risk caps (Tasks 18, 19)
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.
This commit is contained in:
Viktor Barzin 2026-05-26 21:03:59 +00:00
parent fdc2a60257
commit f7ca671bf3
8 changed files with 458 additions and 2 deletions

View file

@ -15,4 +15,11 @@ class TradeExecutorConfig(BaseConfig):
alpaca_secret_key: str = ""
paper_trading: bool = True
# Kevin v2 risk caps — only applied when TradeSignal.strategy_id ==
# KEVIN_STRATEGY_UUID.
kevin_daily_trade_cap: int = 10
kevin_daily_alloc_cap_usd: float = 20_000.0
kevin_equity_drawdown_halt_pct: float = 0.20 # 20% drawdown → permanent pause
kevin_daily_loss_circuit_pct: float = 0.05 # 5% daily loss → 24h pause
model_config = {"env_prefix": "TRADING_"}

View file

@ -8,12 +8,14 @@ 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__)
@ -28,6 +30,13 @@ _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.
@ -98,6 +107,75 @@ class RiskManager:
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(

View file

@ -14,6 +14,7 @@ from decimal import Decimal
from alpaca.common.exceptions import APIError
from alpaca.trading.client import TradingClient
from alpaca.trading.enums import OrderClass as AlpacaOrderClass
from alpaca.trading.enums import OrderSide as AlpacaOrderSide
from alpaca.trading.enums import OrderStatus as AlpacaOrderStatus
from alpaca.trading.enums import TimeInForce
@ -23,7 +24,9 @@ from alpaca.trading.models import TradeAccount
from alpaca.trading.requests import (
LimitOrderRequest,
MarketOrderRequest,
StopLossRequest,
StopOrderRequest,
TakeProfitRequest,
)
from shared.broker.base import BaseBroker
@ -110,6 +113,23 @@ class AlpacaBroker(BaseBroker):
"""Convert our ``OrderRequest`` into the appropriate Alpaca request."""
side = AlpacaOrderSide.BUY if order.side == OrderSide.BUY else AlpacaOrderSide.SELL
if order.order_class == "bracket":
# Bracket only attaches to MARKET parent legs in the Kevin path
# (entry on signal, stop + take-profit live on the broker).
return MarketOrderRequest(
symbol=order.ticker,
qty=order.qty,
side=side,
time_in_force=TimeInForce.GTC,
order_class=AlpacaOrderClass.BRACKET,
take_profit=TakeProfitRequest(
limit_price=order.take_profit_price,
),
stop_loss=StopLossRequest(
stop_price=order.stop_loss_price,
),
)
if order.order_type == OrderType.LIMIT:
if order.limit_price is None:
raise ValueError("limit_price is required for limit orders")

View file

@ -3,10 +3,10 @@
from datetime import UTC, datetime
from decimal import Decimal
from enum import Enum
from typing import Any
from typing import Any, Literal
from uuid import UUID, uuid4
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
class OrderType(str, Enum):
@ -49,8 +49,22 @@ class OrderRequest(BaseModel):
limit_price: float | None = None
stop_price: float | None = None
order_class: Literal["simple", "bracket"] = "simple"
take_profit_price: float | None = None
stop_loss_price: float | None = None
model_config = {"from_attributes": True}
@model_validator(mode="after")
def _bracket_requires_legs(self) -> "OrderRequest":
if self.order_class == "bracket" and (
self.take_profit_price is None or self.stop_loss_price is None
):
raise ValueError(
"bracket orders require take_profit_price + stop_loss_price"
)
return self
class OrderResult(BaseModel):
"""Returned after order submission or status query."""

View file

@ -0,0 +1,248 @@
"""Tests for the Kevin-specific risk caps (Task 19).
Daily trade-count cap, daily allocation cap, drawdown halt, daily-loss
circuit breaker. The Kevin caps are namespaced inside `check_risk` so
they only apply when the incoming `TradeSignal.strategy_id ==
KEVIN_STRATEGY_UUID`.
"""
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from unittest.mock import AsyncMock, patch
import pytest
from services.trade_executor.config import TradeExecutorConfig
from services.trade_executor.risk_manager import RiskManager
from shared.constants.kevin import KEVIN_STRATEGY_UUID
from shared.schemas.trading import (
AccountInfo,
PositionInfo,
SignalDirection,
TradeSignal,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _kevin_config(**overrides) -> TradeExecutorConfig:
defaults = dict(
max_position_pct=0.05,
max_total_exposure_pct=0.80,
max_positions=20,
default_stop_loss_pct=0.03,
cooldown_minutes=30,
alpaca_api_key="test",
alpaca_secret_key="test",
paper_trading=True,
kevin_daily_trade_cap=5,
kevin_daily_alloc_cap_usd=10000.0,
kevin_equity_drawdown_halt_pct=0.20,
kevin_daily_loss_circuit_pct=0.05,
)
defaults.update(overrides)
return TradeExecutorConfig(**defaults)
def _kevin_signal(target_dollars: float = 2000.0) -> TradeSignal:
return TradeSignal(
ticker="NVDA",
direction=SignalDirection.LONG,
strength=0.8,
strategy_sources=["meet_kevin"],
sentiment_context={"current_price": 150.0},
timestamp=datetime.now(timezone.utc),
strategy_id=KEVIN_STRATEGY_UUID,
target_dollars=Decimal(str(target_dollars)),
)
def _non_kevin_signal() -> TradeSignal:
return TradeSignal(
ticker="AAPL",
direction=SignalDirection.LONG,
strength=0.8,
strategy_sources=["news_sentiment"],
sentiment_context={"current_price": 150.0},
timestamp=datetime.now(timezone.utc),
# no strategy_id → not Kevin
)
def _mock_broker(equity: float = 100_000.0) -> AsyncMock:
broker = AsyncMock()
broker.get_positions = AsyncMock(return_value=[])
broker.get_account = AsyncMock(
return_value=AccountInfo(
equity=equity, cash=equity, buying_power=equity * 2, portfolio_value=equity
)
)
return broker
def _redis_mock(
daily_trades: int = 0,
daily_alloc: float = 0.0,
starting_equity: float | None = 100_000.0,
daily_pnl_usd: float = 0.0,
paused: bool = False,
) -> AsyncMock:
"""Returns an AsyncMock Redis that answers the specific keys the
RiskManager queries in the Kevin caps path.
"""
redis = AsyncMock()
async def _get(key):
if isinstance(key, bytes):
key = key.decode()
# global pause
if key == "trading:paused":
return b"1" if paused else None
# kevin counters (today)
if key.startswith("kevin:daily_trades:"):
return str(daily_trades).encode()
if key.startswith("kevin:daily_alloc_usd:"):
return str(daily_alloc).encode()
if key == "kevin:starting_equity_usd":
return None if starting_equity is None else str(starting_equity).encode()
if key.startswith("kevin:daily_pnl_usd:"):
return str(daily_pnl_usd).encode()
return None
redis.get = AsyncMock(side_effect=_get)
redis.set = AsyncMock()
redis.setex = AsyncMock()
return redis
# ---------------------------------------------------------------------------
# Tests — daily trade-count cap
# ---------------------------------------------------------------------------
class TestKevinDailyTradeCap:
@pytest.mark.asyncio
async def test_below_cap_passes(self):
config = _kevin_config(kevin_daily_trade_cap=5)
broker = _mock_broker()
rm = RiskManager(config, broker, redis=_redis_mock(daily_trades=3))
with patch.object(RiskManager, "_is_market_hours", return_value=True):
approved, _ = await rm.check_risk(_kevin_signal())
assert approved is True
@pytest.mark.asyncio
async def test_at_cap_rejects(self):
config = _kevin_config(kevin_daily_trade_cap=5)
broker = _mock_broker()
rm = RiskManager(config, broker, redis=_redis_mock(daily_trades=5))
with patch.object(RiskManager, "_is_market_hours", return_value=True):
approved, reason = await rm.check_risk(_kevin_signal())
assert approved is False
assert reason == "kevin_daily_trade_cap"
@pytest.mark.asyncio
async def test_cap_does_not_apply_to_non_kevin_signal(self):
config = _kevin_config(kevin_daily_trade_cap=5)
broker = _mock_broker()
rm = RiskManager(config, broker, redis=_redis_mock(daily_trades=999))
with patch.object(RiskManager, "_is_market_hours", return_value=True):
approved, _ = await rm.check_risk(_non_kevin_signal())
assert approved is True
# ---------------------------------------------------------------------------
# Tests — daily allocation cap
# ---------------------------------------------------------------------------
class TestKevinDailyAllocCap:
@pytest.mark.asyncio
async def test_within_alloc_cap_passes(self):
config = _kevin_config(kevin_daily_alloc_cap_usd=10_000)
broker = _mock_broker()
rm = RiskManager(config, broker, redis=_redis_mock(daily_alloc=6000))
with patch.object(RiskManager, "_is_market_hours", return_value=True):
approved, _ = await rm.check_risk(_kevin_signal(target_dollars=2000))
assert approved is True
@pytest.mark.asyncio
async def test_alloc_cap_rejects(self):
config = _kevin_config(kevin_daily_alloc_cap_usd=10_000)
broker = _mock_broker()
rm = RiskManager(config, broker, redis=_redis_mock(daily_alloc=9000))
with patch.object(RiskManager, "_is_market_hours", return_value=True):
# 9000 + 2000 = 11000 > 10000
approved, reason = await rm.check_risk(_kevin_signal(target_dollars=2000))
assert approved is False
assert reason == "kevin_daily_alloc_cap"
# ---------------------------------------------------------------------------
# Tests — drawdown halt (permanent pause)
# ---------------------------------------------------------------------------
class TestKevinDrawdownHalt:
@pytest.mark.asyncio
async def test_drawdown_below_threshold_sets_permanent_pause(self):
config = _kevin_config(kevin_equity_drawdown_halt_pct=0.20)
broker = _mock_broker(equity=70_000) # 30% drawdown from 100k
redis = _redis_mock(starting_equity=100_000)
rm = RiskManager(config, broker, redis=redis)
with patch.object(RiskManager, "_is_market_hours", return_value=True):
approved, reason = await rm.check_risk(_kevin_signal())
assert approved is False
assert reason == "kevin_drawdown_halt"
# permanent pause was set (no TTL)
redis.set.assert_any_call("trading:paused", "1")
@pytest.mark.asyncio
async def test_drawdown_above_threshold_passes(self):
config = _kevin_config(kevin_equity_drawdown_halt_pct=0.20)
broker = _mock_broker(equity=85_000) # 15% drawdown → above threshold
rm = RiskManager(
config, broker, redis=_redis_mock(starting_equity=100_000)
)
with patch.object(RiskManager, "_is_market_hours", return_value=True):
approved, _ = await rm.check_risk(_kevin_signal())
assert approved is True
# ---------------------------------------------------------------------------
# Tests — daily-loss circuit breaker (24h pause)
# ---------------------------------------------------------------------------
class TestKevinDailyLossCircuit:
@pytest.mark.asyncio
async def test_daily_loss_exceeds_circuit_sets_24h_pause(self):
config = _kevin_config(kevin_daily_loss_circuit_pct=0.05)
broker = _mock_broker(equity=100_000)
# -6% daily loss on 100k equity = -6000
redis = _redis_mock(daily_pnl_usd=-6000)
rm = RiskManager(config, broker, redis=redis)
with patch.object(RiskManager, "_is_market_hours", return_value=True):
approved, reason = await rm.check_risk(_kevin_signal())
assert approved is False
assert reason == "kevin_daily_loss_circuit"
# 24h pause set via setex
assert redis.setex.called
# called with ttl ~= 86400
args, _kw = redis.setex.call_args
assert args[0] == "trading:paused"
assert int(args[1]) == 86400
@pytest.mark.asyncio
async def test_daily_loss_within_circuit_passes(self):
config = _kevin_config(kevin_daily_loss_circuit_pct=0.05)
broker = _mock_broker(equity=100_000)
# -3% daily loss → below circuit
rm = RiskManager(config, broker, redis=_redis_mock(daily_pnl_usd=-3000))
with patch.object(RiskManager, "_is_market_hours", return_value=True):
approved, _ = await rm.check_risk(_kevin_signal())
assert approved is True

View file

View file

@ -0,0 +1,89 @@
"""Unit tests for BRACKET order support in OrderRequest + AlpacaBroker.
No network exercises the request-building path only.
"""
from __future__ import annotations
import pytest
from alpaca.trading.enums import OrderClass as AlpacaOrderClass
from alpaca.trading.requests import MarketOrderRequest
from pydantic import ValidationError
from shared.broker.alpaca_broker import AlpacaBroker
from shared.schemas.trading import OrderRequest, OrderSide, OrderType
def _broker() -> AlpacaBroker:
return AlpacaBroker(api_key="test", secret_key="test", paper=True)
def test_order_request_defaults_to_simple_class():
o = OrderRequest(ticker="NVDA", side=OrderSide.BUY, qty=10)
assert o.order_class == "simple"
assert o.take_profit_price is None
assert o.stop_loss_price is None
def test_order_request_bracket_requires_both_legs():
with pytest.raises(ValidationError, match="bracket orders require"):
OrderRequest(
ticker="NVDA",
side=OrderSide.BUY,
qty=10,
order_class="bracket",
take_profit_price=200.0,
# stop_loss_price missing
)
with pytest.raises(ValidationError, match="bracket orders require"):
OrderRequest(
ticker="NVDA",
side=OrderSide.BUY,
qty=10,
order_class="bracket",
stop_loss_price=150.0,
# take_profit_price missing
)
def test_order_request_bracket_with_both_legs_validates():
o = OrderRequest(
ticker="NVDA",
side=OrderSide.BUY,
qty=10,
order_class="bracket",
take_profit_price=200.0,
stop_loss_price=150.0,
)
assert o.order_class == "bracket"
assert o.take_profit_price == 200.0
assert o.stop_loss_price == 150.0
def test_build_order_request_simple_market():
o = OrderRequest(ticker="NVDA", side=OrderSide.BUY, qty=10)
req = _broker()._build_order_request(o)
assert isinstance(req, MarketOrderRequest)
assert req.symbol == "NVDA"
assert req.qty == 10
# default MarketOrderRequest has order_class=None or SIMPLE
assert getattr(req, "order_class", None) in (None, AlpacaOrderClass.SIMPLE)
def test_build_order_request_bracket_attaches_legs():
o = OrderRequest(
ticker="NVDA",
side=OrderSide.BUY,
qty=10,
order_class="bracket",
take_profit_price=200.0,
stop_loss_price=150.0,
)
req = _broker()._build_order_request(o)
assert isinstance(req, MarketOrderRequest)
assert req.order_class == AlpacaOrderClass.BRACKET
assert req.take_profit is not None
assert float(req.take_profit.limit_price) == 200.0
assert req.stop_loss is not None
assert float(req.stop_loss.stop_price) == 150.0