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

@ -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."""