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