trading/shared/schemas/trading.py
Viktor Barzin 82dc622544 feat(kevin): reconcile Alpaca bracket auto-closes + order status
Bracket stop-loss/take-profit legs fill at Alpaca without passing through the executor, so those closes (and their P&L) were invisible locally.

- broker: add get_order(nested) + list_orders to BaseBroker/AlpacaBroker (+ SimulatedBroker); BrokerOrder carries child legs

- Trade gains broker_order_id (migration f6a7b8c9d0e1); executor stamps the entry order id

- new api_gateway trade-reconcile loop: books a closing SELL + realized P&L when a bracket leg fills (idempotent on the leg order id), syncs PENDING->terminal status, logs drift; runs alongside portfolio_sync

[ci skip]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:31:24 +00:00

229 lines
6 KiB
Python

"""Trading-related Pydantic schemas for Redis Streams messages and API payloads."""
from datetime import UTC, datetime
from decimal import Decimal
from enum import Enum
from typing import Any, Literal
from uuid import UUID, uuid4
from pydantic import BaseModel, Field, model_validator
class OrderType(str, Enum):
MARKET = "market"
LIMIT = "limit"
STOP = "stop"
class OrderSide(str, Enum):
BUY = "BUY"
SELL = "SELL"
class OrderStatus(str, Enum):
PENDING = "PENDING"
FILLED = "FILLED"
CANCELLED = "CANCELLED"
REJECTED = "REJECTED"
class SignalDirection(str, Enum):
LONG = "LONG"
SHORT = "SHORT"
NEUTRAL = "NEUTRAL"
EXIT = "EXIT"
# ---------------------------------------------------------------------------
# API request / response schemas
# ---------------------------------------------------------------------------
class OrderRequest(BaseModel):
"""Submitted by the trade executor or the API to place an order."""
ticker: str
side: OrderSide
qty: float = Field(gt=0)
order_type: OrderType = OrderType.MARKET
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."""
order_id: str
ticker: str
side: OrderSide
qty: float
filled_price: float | None = None
status: OrderStatus
timestamp: datetime
model_config = {"from_attributes": True}
class BrokerOrder(OrderResult):
"""An order plus its bracket child legs.
Returned by ``BaseBroker.get_order`` so reconciliation can inspect a
bracket's stop-loss / take-profit legs (each an ``OrderResult``) to learn
which leg filled and at what price. Simple orders carry an empty
``legs`` list.
"""
legs: list[OrderResult] = Field(default_factory=list)
class PositionInfo(BaseModel):
"""Current position state — used in API responses and portfolio views."""
ticker: str
qty: float
avg_entry: float
current_price: float
unrealized_pnl: float
market_value: float
model_config = {"from_attributes": True}
class AccountInfo(BaseModel):
"""Account-level summary from the brokerage."""
equity: float
cash: float
buying_power: float
portfolio_value: float
model_config = {"from_attributes": True}
# ---------------------------------------------------------------------------
# Redis Stream message schemas
# ---------------------------------------------------------------------------
class TradeSignal(BaseModel):
"""Published to ``signals:generated`` by the signal generator."""
signal_id: UUID = Field(default_factory=uuid4)
ticker: str
direction: SignalDirection
strength: float = Field(ge=0.0, le=1.0)
strategy_sources: list[str]
sentiment_context: dict[str, Any] | None = None
timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC))
# --- Kevin v2 extensions (optional) ---
strategy_id: UUID | None = None
target_dollars: Decimal | None = None
stop_loss_pct: Decimal | None = None
take_profit_pct: Decimal | None = None
current_price: Decimal | None = None
model_config = {"from_attributes": True}
class TradeExecution(BaseModel):
"""Published to ``trades:executed`` by the trade executor."""
trade_id: UUID
ticker: str
side: OrderSide
qty: float
price: float
status: OrderStatus
signal_id: UUID | None = None
strategy_id: UUID | None = None
strategy_sources: list[str] = Field(default_factory=list)
timestamp: datetime
model_config = {"from_attributes": True}
class OHLCVBar(BaseModel):
"""Single OHLCV bar."""
timestamp: datetime
open: float
high: float
low: float
close: float
volume: float
class FundamentalsSnapshot(BaseModel):
"""Fundamental financial data for a single ticker — cached daily."""
ticker: str
eps_ttm: float | None = None
pe_ratio: float | None = None
peg_ratio: float | None = None
revenue_growth_yoy: float | None = None
profit_margin: float | None = None
debt_to_equity: float | None = None
market_cap: float | None = None
fetched_at: datetime
model_config = {"from_attributes": True}
class MarketSnapshot(BaseModel):
"""Snapshot of market data for a single ticker — used by strategies."""
ticker: str
current_price: float
open: float
high: float
low: float
close: float
volume: float
sma_20: float | None = None
sma_50: float | None = None
rsi: float | None = None
# Technical indicators — computed by MarketDataManager
ema_9: float | None = None
ema_21: float | None = None
sma_200: float | None = None
macd: float | None = None
macd_signal: float | None = None
macd_histogram: float | None = None
bollinger_upper: float | None = None
bollinger_mid: float | None = None
bollinger_lower: float | None = None
vwap: float | None = None
atr: float | None = None
bars: list[dict[str, Any]] = Field(default_factory=list)
fundamentals: FundamentalsSnapshot | None = None
model_config = {"from_attributes": True}
class SentimentContext(BaseModel):
"""Aggregated sentiment for a ticker — passed to strategies."""
ticker: str
avg_score: float = Field(ge=-1.0, le=1.0)
article_count: int = Field(ge=0)
recent_scores: list[float] = Field(default_factory=list)
avg_confidence: float = Field(ge=0.0, le=1.0)
model_config = {"from_attributes": True}