Source code for trader.domain

"""Typed value objects shared across the Core Service Layer.

Adapters convert third-party SDK responses into these objects so no
`alpaca-py` or `pandas` type ever reaches the CLI or persistence layers.
Money is always `Decimal`, never `float`.
"""

from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from enum import StrEnum

__all__ = [
    "Account",
    "Bar",
    "MarketClock",
    "OrderSide",
    "Position",
    "ProtectedEntry",
    "Quote",
    "SubmittedOrder",
    "TradableAsset",
]


[docs] @dataclass(frozen=True, slots=True) class Account: """A broker account snapshot at a point in time.""" account_id: str cash: Decimal equity: Decimal buying_power: Decimal portfolio_value: Decimal is_paper: bool # Yesterday's closing equity, as reported by the broker. The daily loss # limit is `equity - last_equity`, which uses the broker's own figures # rather than reconstructing realized P/L from trade history — that would # have to model partial fills and corporate actions to reach the same # number. # # Deliberately NOT persisted: `account_snapshots` already holds rows and # `create_all` cannot add a column to a populated table. It is only needed # for the duration of a cycle, so carrying it in memory costs nothing. last_equity: Decimal = Decimal(0)
[docs] @dataclass(frozen=True, slots=True) class Position: """A single open position.""" symbol: str quantity: Decimal avg_entry_price: Decimal current_price: Decimal market_value: Decimal unrealized_pl: Decimal
[docs] @dataclass(frozen=True, slots=True) class Quote: """The most recent observed price for a symbol.""" symbol: str price: Decimal as_of: datetime
[docs] @dataclass(frozen=True, slots=True) class Bar: """A single OHLCV candle.""" symbol: str timestamp: datetime open: Decimal high: Decimal low: Decimal close: Decimal volume: int def __post_init__(self) -> None: if self.low > self.high: raise ValueError( f"{self.symbol} bar at {self.timestamp}: low ({self.low}) > " f"high ({self.high})" ) if not (self.low <= self.open <= self.high): raise ValueError( f"{self.symbol} bar at {self.timestamp}: open ({self.open}) " f"outside [low, high] = [{self.low}, {self.high}]" ) if not (self.low <= self.close <= self.high): raise ValueError( f"{self.symbol} bar at {self.timestamp}: close ({self.close}) " f"outside [low, high] = [{self.low}, {self.high}]" ) if self.volume < 0: raise ValueError( f"{self.symbol} bar at {self.timestamp}: negative volume ({self.volume})" )
[docs] @dataclass(frozen=True, slots=True) class MarketClock: """Whether the exchange is open, and when that next changes. The daemon sleeps on these values, so they carry the holiday and half-day schedule the broker knows about and a local `09:30–16:00 weekdays` rule does not. Both timestamps are tz-aware UTC; the adapter converts and refuses naive input rather than letting a naive value reach an arithmetic comparison against an aware `now`. `next_open` is meaningful even while the market is open — it is the *following* session — so the daemon must branch on `is_open` rather than inferring the state from the timestamps. """ is_open: bool next_open: datetime next_close: datetime
[docs] class OrderSide(StrEnum): """Long-only order directions.""" BUY = "buy" SELL = "sell"
[docs] @dataclass(frozen=True, slots=True) class SubmittedOrder: """An order the broker accepted, as a domain object. `quantity` is `Decimal` to match the rest of the money surface even though this app submits whole shares only: the broker reports fractional quantities for positions it acquired other ways, and truncating here would misreport what actually exists at the broker. """ order_id: str symbol: str side: OrderSide quantity: Decimal order_type: str status: str submitted_at: datetime limit_price: Decimal | None = None trail_percent: Decimal | None = None # The broker's current stop trigger. Populated for a FIXED stop leg # (order_type "stop") and, per the real API, also for a `trailing_stop` # order — Alpaca reports the trailing stop's live computed trigger here # too, since it moves with the broker's own high-water mark rather than # anything this app tracks. `None` for every other order type. This app # only ever *reads* it for a fixed `stop` leg: `ratchet_protection` uses # it to decide whether replacing it would raise the trigger, and it # explicitly skips a `trailing_stop` regardless of what this field reads, # because replacing a trailing stop's `stop_price` is not a meaningful # broker operation. stop_price: Decimal | None = None # What actually happened, as against `limit_price`, which is only ever a # ceiling (buy) or floor (sell) the order could have filled at — and a # market order carries no limit at all. `None` until the broker reports a # fill; reconciliation falls back to `limit_price` only when these are # absent. filled_qty: Decimal | None = None filled_avg_price: Decimal | None = None # When the fill actually happened, per the broker — not when this app got # around to noticing it. Needed to replay several fills in the order they # occurred rather than in whatever order the broker's API happens to # return them, and to keep `Trade.filled_at` from drifting forward every # time an already-filled order is reconciled again. filled_at: datetime | None = None
[docs] @dataclass(frozen=True, slots=True) class ProtectedEntry: """An atomic OTO entry, together with the protective leg attached to it. Two orders come back from one `submit_limit_buy_with_stop` request, and **both** have to reach `trades`. The parent's row is what makes the entry fill attributable; the leg's row is what makes the *exit* fill attributable, and a stop-driven exit is this app's dominant exit path. Returning only the parent — which is what this type replaced — meant `raw.legs` was discarded at the adapter boundary and the leg was never recorded, so `reconcile_fills` saw its eventual fill as "not ours", the round trip stayed open forever, `total_realized_pl()` reported 0 against a real loss, and the symbol then wedged on "a round trip is already open". That is why this exists at all. A small result object rather than a `protective_leg` field on `SubmittedOrder`, for two reasons. `SubmittedOrder` is the shape of *one* broker order and is persisted as one row; a nested order inside it would be meaningless for every other order type and silently dropped by every repository that writes one. And a distinct return type makes the caller name the leg to ignore it, so the next caller cannot repeat the original mistake by accident. Same reasoning, and same shape, as `RatchetedStop`. `protective_leg` is `None` only when the broker's response carried no leg at all. That is not a normal outcome — it means the entry may be unprotected — so the adapter logs it loudly rather than treating it as an ordinary absence, and the end-of-cycle §8 check is what catches the resulting position. """ entry: SubmittedOrder protective_leg: SubmittedOrder | None
[docs] @dataclass(frozen=True, slots=True) class TradableAsset: """What the broker knows about a symbol. `tradable=False` and "the broker has never heard of it" are different answers and discovery treats them the same way — but only because both mean "do not order this". Keeping them distinct here means a future caller can tell an OTC halt from a typo. """ symbol: str tradable: bool asset_class: str exchange: str #: The issuer's name as the broker states it — "Apple Inc. Common Stock". #: Optional because a broker may omit it and because nothing depends on it #: being present: `trader.news.aliases` derives news-matching aliases from #: it when it is there, and falls back to bare-ticker matching when it is #: not. It reached this object only because Alpaca already sends it and the #: mapper was discarding it (issue #6) — no extra call was added for it. name: str | None = None