""" contracts.py — Cross-module typed interfaces. Every inter-module call MUST use these types. No raw dicts between modules. """ from __future__ import annotations import datetime from typing import Literal import pandas as pd from pydantic import BaseModel, Field, field_validator # ── Exceptions ─────────────────────────────────────────────────────────────── class InsufficientDataError(Exception): """Raised when fewer than n_bars are available for a symbol/timeframe.""" class ConfigError(Exception): """Raised when configuration validation fails.""" class BrokerError(Exception): """Raised on unrecoverable broker API errors.""" class SafeModeError(Exception): """Raised when an operation is attempted while SAFE MODE is active.""" # ── Signal Contracts ───────────────────────────────────────────────────────── class TechnicalScore(BaseModel): """Output of signals/technical.py for a single symbol at a point in time.""" model_config = {"arbitrary_types_allowed": True} symbol: str timestamp: datetime.datetime n_signals_computed: int = 0 score: float = Field(ge=-1.0, le=1.0) sub_signals: dict[str, float] = Field(default_factory=dict) skip_reason: str | None = None @field_validator("score", mode="before") @classmethod def force_zero_if_insufficient(cls, v: float, info) -> float: n = info.data.get("n_signals_computed", 0) if n < 3: return 0.0 return v class SentimentScore(BaseModel): """Output of signals/sentiment.py for a single symbol.""" symbol: str score: float = Field(ge=-1.0, le=1.0) cached_at: datetime.datetime source_count: int = 0 stale: bool = False @field_validator("stale", mode="before") @classmethod def compute_stale(cls, v: bool, info) -> bool: cached = info.data.get("cached_at") if cached is None: return True now = datetime.datetime.now(datetime.timezone.utc) if cached.tzinfo is None: cached = cached.replace(tzinfo=datetime.timezone.utc) delta = (now - cached).total_seconds() return delta > 35 * 60 # stale if > 35 minutes class FinalScore(BaseModel): """Combined signal output from signals/combiner.py.""" symbol: str timestamp: datetime.datetime technical: float sentiment: float final: float decision: Literal["BUY", "SELL", "HOLD"] skip_reason: str | None = None class PriceTarget(BaseModel): """Predicted price targets for dashboard display.""" symbol: str current_price: float predicted_direction: Literal["UP", "DOWN", "NEUTRAL"] entry_price: float | None = None stop_loss_price: float | None = None take_profit_price: float | None = None expected_profit_usd: float = 0.0 confidence: float = Field(ge=0.0, le=1.0, default=0.0) # ── Order Contracts ────────────────────────────────────────────────────────── class OrderRequest(BaseModel): """Internal contract for placing orders.""" symbol: str side: Literal["buy", "sell"] qty: float limit_price: float stop_price: float | None = None tp_price: float | None = None trail_percent: float | None = None is_fractional: bool = False dry_run: bool = False class LinkedOrderGroup(BaseModel): """Tracks entry + stop + TP legs for fractional orders.""" symbol: str entry_order_id: str stop_order_id: str | None = None tp_order_id: str | None = None entry_filled: bool = False stop_submitted: bool = False tp_submitted: bool = False is_fractional: bool = False entry_price: float | None = None orphaned: bool = False # ── Data Contract ──────────────────────────────────────────────────────────── # get_bars(symbol, timeframe, n_bars, adjusted=True) → pd.DataFrame # Columns: timestamp (UTC index), open, high, low, close, volume, vwap, adjusted # Raises InsufficientDataError if fewer than n_bars available. BAR_COLUMNS = ["open", "high", "low", "close", "volume", "vwap", "adjusted"] def validate_bar_dataframe(df: pd.DataFrame, symbol: str, n_bars: int) -> pd.DataFrame: """Validate that a bars DataFrame matches the contract.""" if len(df) < n_bars: raise InsufficientDataError( f"{symbol}: requested {n_bars} bars, got {len(df)}" ) missing = [c for c in BAR_COLUMNS if c not in df.columns] if missing: raise ValueError(f"{symbol}: missing columns {missing}") return df