Source code for trader.brokers.alpaca

"""Alpaca implementation of `BrokerAdapter` — read-only in Slice 1.

Converts SDK responses into domain objects so no `alpaca-py` type escapes
this module. Retry/backoff resilience is a Slice 2 concern (requirements §5);
here a failure surfaces immediately as `BrokerError`.
"""

import logging
from collections.abc import Callable
from datetime import UTC, datetime
from decimal import Decimal, InvalidOperation

from trader.config.loader import EffectiveTradingConfig
from trader.domain import (
    Account,
    MarketClock,
    OrderSide,
    Position,
    ProtectedEntry,
    SubmittedOrder,
    TradableAsset,
)
from trader.errors import BrokerError, OrderError, UnsafeConfigError

_logger = logging.getLogger("trader.brokers")

__all__ = ["AlpacaBroker"]

# The one HTTP status that means "the broker denies knowing this order", as
# opposed to "the broker could not answer". `get_order_by_id` turns the first
# into `None` and the second into an `OrderError`, and its caller settles a
# money-tracking row on neither — so the distinction has to be explicit rather
# than a bare literal inside an `except`.
_NOT_FOUND = 404


def _default_client_factory(api_key: str, api_secret: str, paper: bool) -> object:
    """Build a real Alpaca client. Imported lazily so unit tests need neither
    network nor credentials."""
    from alpaca.trading.client import TradingClient

    return TradingClient(api_key, api_secret, paper=paper)


def _to_decimal(value: object, field: str) -> Decimal:
    """Convert an SDK numeric (usually a string) to a finite `Decimal`.

    No quantizing here, deliberately: these are account/position values that
    Alpaca already reports at its own precision, and rounding them at the
    boundary would silently alter reported money.

    The finiteness check is not optional. `Decimal('NaN')` is a quiet NaN, so
    an Alpaca field of `"NaN"` or `"inf"` would otherwise become a money value
    that compares equal to nothing and raises `decimal.InvalidOperation` on any
    ordering comparison — and `InvalidOperation` is not a `TraderError`, so it
    escapes the CLI's handler as a traceback far from the cause. This is the
    money-*writing* path (`trader account` persists a snapshot), so a non-finite
    value would also land in a money column and be reloaded as NaN forever.
    """
    try:
        parsed = Decimal(str(value))
    except (InvalidOperation, TypeError, ValueError) as exc:
        raise BrokerError(f"Alpaca returned an unparsable {field}: {value!r}") from exc

    if not parsed.is_finite():
        raise BrokerError(f"Alpaca returned a non-finite {field}: {value!r}")

    return parsed


def _to_utc(value: object, field: str) -> datetime:
    """Normalise an SDK timestamp to tz-aware UTC, refusing naive input.

    Naive is refused rather than assumed-UTC: the daemon subtracts these from
    `datetime.now(UTC)`, and a naive value raises `TypeError` from inside a
    sleep calculation, far from the adapter that produced it. Assuming UTC
    would be worse — it would be silently wrong by four or five hours, which is
    long enough to skip a whole session.
    """
    if not isinstance(value, datetime):
        raise BrokerError(f"Alpaca returned a non-datetime {field}: {value!r}")
    if value.tzinfo is None or value.utcoffset() is None:
        raise BrokerError(f"Alpaca returned a naive {field}: {value!r}")
    return value.astimezone(UTC)


[docs] class AlpacaBroker: """Read-only access to an Alpaca account. Args: api_key: Alpaca key for the selected environment. api_secret: Alpaca secret for the selected environment. paper: Whether to talk to the paper endpoint. client: Injected client, used by tests. When `None`, a real `TradingClient` is constructed. config: The resolved config this broker was built from, retained so Slice 2's order path can re-check the live-trading gate without having to thread the config separately. client_factory: Builds the real client from `(api_key, api_secret, paper)`. Exists as a seam so tests can prove what actually reaches the SDK. Injecting `client=` skips construction entirely, so before this seam existed the single line that chooses the paper-vs-live endpoint had *zero* coverage: mutating it to `TradingClient(api_secret, api_key, paper=not paper)` — transposed credentials AND an inverted endpoint — left the whole suite green. The integration test cannot cover it either, because `get_account` reports `is_paper=self._paper`, a self-report that would agree with itself against a live endpoint. """ def __init__( self, api_key: str, api_secret: str, *, paper: bool = True, client: object | None = None, config: EffectiveTradingConfig | None = None, client_factory: Callable[[str, str, bool], object] | None = None, ) -> None: # Slice 1 left this path ungated, which was safe only because no order # method existed. One does now, so a live-endpoint broker must prove it # came from a gate-passing config. `from_config` checks too; this # catches every other construction path, tests included. if not paper: if config is None: raise UnsafeConfigError( "Refusing to build a live-endpoint broker without a " "configuration that passed the live-trading gate." ) config.assert_live_orders_allowed() self._paper = paper self._config = config if client is None: factory = client_factory or _default_client_factory client = factory(api_key, api_secret, paper) self._client = client
[docs] @classmethod def from_config( cls, config: EffectiveTradingConfig, *, client: object | None = None, client_factory: Callable[[str, str, bool], object] | None = None, ) -> "AlpacaBroker": """Build a broker for the resolved trading config. Re-checks the live-trading gate here so it is structurally impossible to construct a live-endpoint client from a config that never passed it. Slice 2's order path must call `config.assert_live_orders_allowed()` again immediately before submitting — `is_live` is a mode signal, not a permission. """ config.assert_live_orders_allowed() return cls( config.api_key, config.api_secret, paper=config.use_paper_endpoint, client=client, config=config, client_factory=client_factory, )
@property def is_paper(self) -> bool: """Whether this adapter points at the paper endpoint.""" return self._paper @property def config(self) -> EffectiveTradingConfig | None: """The config this broker was built from, if any.""" return self._config
[docs] def get_account(self) -> Account: """Return the current account summary.""" try: raw = self._client.get_account() except Exception as exc: # noqa: BLE001 - SDK raises many types raise BrokerError(f"Failed to fetch Alpaca account: {exc}") from exc return Account( account_id=str(raw.id), cash=_to_decimal(raw.cash, "cash"), equity=_to_decimal(raw.equity, "equity"), buying_power=_to_decimal(raw.buying_power, "buying_power"), portfolio_value=_to_decimal(raw.portfolio_value, "portfolio_value"), is_paper=self._paper, last_equity=_to_decimal(raw.last_equity, "last_equity"), )
[docs] def get_clock(self) -> MarketClock: """Return the exchange's open state and its next transitions. A read, so it does not call the submission gate: the daemon asks this on every wake-up, including when nothing is permitted to trade. """ try: raw = self._client.get_clock() except Exception as exc: # noqa: BLE001 - SDK raises many types raise BrokerError(f"Failed to fetch the Alpaca market clock: {exc}") from exc return MarketClock( is_open=bool(raw.is_open), next_open=_to_utc(raw.next_open, "next_open"), next_close=_to_utc(raw.next_close, "next_close"), )
[docs] def get_positions(self) -> list[Position]: """Return all open positions.""" try: raw_positions = self._client.get_all_positions() except Exception as exc: # noqa: BLE001 - SDK raises many types raise BrokerError(f"Failed to fetch Alpaca positions: {exc}") from exc return [ Position( symbol=str(raw.symbol), quantity=_to_decimal(raw.qty, "qty"), avg_entry_price=_to_decimal(raw.avg_entry_price, "avg_entry_price"), current_price=_to_decimal(raw.current_price, "current_price"), market_value=_to_decimal(raw.market_value, "market_value"), unrealized_pl=_to_decimal(raw.unrealized_pl, "unrealized_pl"), ) for raw in raw_positions ]
[docs] def get_asset(self, symbol: str) -> TradableAsset | None: """Look up one symbol. `None` when Alpaca does not list it. An unknown symbol is the ordinary case here, not an exceptional one: discovery routinely surfaces foreign listings that Alpaca has never heard of, so this returns `None` rather than raising and making the happy path run through an exception handler. """ from alpaca.common.exceptions import APIError ticker = symbol.strip().upper() try: raw = self._client.get_asset(ticker) except APIError: return None except Exception as exc: # noqa: BLE001 - SDK raises many types raise BrokerError( f"Failed to fetch the Alpaca asset for {ticker}: {exc}" ) from exc # `name` is read with `getattr` and normalised to `None` when blank: # it is the one field here nothing depends on, and an older SDK asset # model that lacks it should degrade news relevance to bare-ticker # matching rather than raise inside a cycle. A whitespace-only name is # not a name — passing it through would make `derive_aliases` decide # that, one layer further from the evidence. raw_name = getattr(raw, "name", None) name = str(raw_name).strip() if raw_name else "" return TradableAsset( symbol=str(raw.symbol), tradable=bool(raw.tradable), asset_class=str(getattr(raw.asset_class, "value", raw.asset_class)), exchange=str(getattr(raw.exchange, "value", raw.exchange)), name=name or None, )
# --- Order placement ------------------------------------------------------ def _assert_may_submit(self) -> None: """Re-check the live-trading gate immediately before any submission. A broker with no config cannot demonstrate it passed the gate, so it is refused rather than allowed by default — "nothing to check" must not read as "permitted". That is precisely the failure the carried-debt doc names: `if self._config: self._config.assert_live_orders_allowed()` silently skips for any broker not built via `from_config`. Called at submission and not only at construction because a config can be replaced on an existing broker, and `is_live` is a mode signal rather than a permission. """ if self._config is None: raise UnsafeConfigError( "Refusing to submit an order: this broker was built with no " "configuration, so the live-trading gate cannot be verified." ) self._config.assert_live_orders_allowed() @staticmethod def _whole_shares(quantity: object, symbol: str) -> int: """Validate a share count. `bool` is excluded explicitly because it is an `int` subclass, so `True` would otherwise silently become an order for one share. """ if isinstance(quantity, bool) or not isinstance(quantity, int) or quantity < 1: raise OrderError( f"Refusing to order {quantity!r} shares of {symbol}: this app " "trades whole shares only, and the quantity must be at least 1." ) return quantity def _to_submitted(self, raw: object) -> SubmittedOrder: """Convert an SDK order into a domain object, so no SDK type escapes.""" limit_price = getattr(raw, "limit_price", None) trail_percent = getattr(raw, "trail_percent", None) stop_price = getattr(raw, "stop_price", None) filled_qty = getattr(raw, "filled_qty", None) filled_avg_price = getattr(raw, "filled_avg_price", None) filled_at = getattr(raw, "filled_at", None) return SubmittedOrder( order_id=str(raw.id), symbol=str(raw.symbol), side=OrderSide(str(getattr(raw.side, "value", raw.side))), quantity=_to_decimal(raw.qty, "qty"), order_type=str(getattr(raw.order_type, "value", raw.order_type)), status=str(getattr(raw.status, "value", raw.status)), submitted_at=raw.submitted_at, limit_price=( None if limit_price is None else _to_decimal(limit_price, "limit_price") ), trail_percent=( None if trail_percent is None else _to_decimal(trail_percent, "trail_percent") ), stop_price=( None if stop_price is None else _to_decimal(stop_price, "stop_price") ), # Alpaca reports "0" (not null) for `filled_qty` on an order that # has not filled at all, which is a legitimate zero rather than a # missing value, so it is not special-cased here. filled_qty=( None if filled_qty is None else _to_decimal(filled_qty, "filled_qty") ), filled_avg_price=( None if filled_avg_price is None else _to_decimal(filled_avg_price, "filled_avg_price") ), # Not money, so it does not go through `_to_decimal`; passed # through as-is like `submitted_at`. filled_at=filled_at, )
[docs] def submit_limit_buy( self, symbol: str, quantity: int, limit_price: Decimal ) -> SubmittedOrder: """Submit a DAY limit buy. DAY rather than GTC: an unfilled entry should expire at the close, not linger into a day whose thesis no longer holds. """ self._assert_may_submit() qty = self._whole_shares(quantity, symbol) from alpaca.trading.enums import OrderSide as SdkSide from alpaca.trading.enums import TimeInForce from alpaca.trading.requests import LimitOrderRequest request = LimitOrderRequest( symbol=symbol, qty=qty, side=SdkSide.BUY, time_in_force=TimeInForce.DAY, limit_price=limit_price, ) try: raw = self._client.submit_order(order_data=request) except Exception as exc: # noqa: BLE001 - SDK raises many types raise OrderError(f"Failed to submit limit buy for {symbol}: {exc}") from exc return self._to_submitted(raw)
[docs] def submit_limit_buy_with_stop( self, symbol: str, quantity: int, limit_price: Decimal, stop_price: Decimal ) -> ProtectedEntry: """Submit a GTC limit buy with its protective stop attached atomically. One request, so the broker holds protection from the instant of fill and nothing depends on this app running again. Requirements §8. Returns a `ProtectedEntry` — the parent **and** the protective leg the SDK reports under `raw.legs`. One request creates two orders at the broker, and this method used to return `_to_submitted(raw)`: the parent alone, with `raw.legs` discarded here at the boundary. Because `reconcile_fills` skips any fill with no `trades` row behind it as "not ours", that made a stop-driven exit — this app's dominant exit path — permanently unattributable: the leg fired, the position went flat, the round trip stayed open, `total_realized_pl()` read 0 against a real loss, and the symbol then wedged on "a round trip is already open". The leg's `submitted_at` equals the parent's, which is exactly the case `_fill_poll_since` is anchored on outstanding *submissions* to cover. GTC, not DAY: measured 2026-08-10, the OTO's protective leg inherits the parent's time-in-force exactly, so a DAY parent produces a DAY stop that expires at the close and leaves the position naked overnight. The DAY intent for the *entry* is enforced by the app instead — see `OrderExecutor.cancel_stale_entries`. The leg is a FIXED stop because Alpaca cannot attach a trailing one (`StopLossRequest` takes `stop_price`/`limit_price` only). It is ratcheted upward each cycle by `replace_stop_price`, which this app calls with a single request rather than a cancel followed by a new submission. Whether Alpaca's replace is gapless on the broker's own side is unverified — see `replace_stop_price`'s docstring. """ self._assert_may_submit() qty = self._whole_shares(quantity, symbol) from alpaca.trading.enums import OrderClass, TimeInForce from alpaca.trading.enums import OrderSide as SdkSide from alpaca.trading.requests import LimitOrderRequest, StopLossRequest request = LimitOrderRequest( symbol=symbol, qty=qty, side=SdkSide.BUY, time_in_force=TimeInForce.GTC, limit_price=limit_price, order_class=OrderClass.OTO, stop_loss=StopLossRequest(stop_price=stop_price), ) try: raw = self._client.submit_order(order_data=request) except Exception as exc: # noqa: BLE001 - SDK raises many types raise OrderError( f"Failed to submit protected limit buy for {symbol}: {exc}" ) from exc return ProtectedEntry( entry=self._to_submitted(raw), protective_leg=self._protective_leg(raw) )
def _protective_leg(self, raw: object) -> SubmittedOrder | None: """The SELL leg of an OTO response, as a domain object. Filtered on *side*, not on position in the list: `legs` is a list on the SDK's `Order` and its ordering is not documented, so indexing `legs[0]` would be a guess. An OTO with a `stop_loss` carries exactly one SELL leg; if a future request shape carries more, the first is taken and the rest are named in a warning rather than silently dropped. `None` — no legs at all — is not routine. It means the broker accepted an entry without the protection this app asked for, so it is logged at `error`: the entry is already at the broker and this app never retries an order, so the honest response is to make it loud and let the end-of-cycle §8 check report the resulting position as unprotected. """ legs = getattr(raw, "legs", None) or [] sells = [ leg for leg in (self._to_submitted(raw_leg) for raw_leg in legs) if leg.side is OrderSide.SELL ] if not sells: _logger.error( "%s: the broker accepted entry %s but reported no protective " "leg; this entry may be UNPROTECTED if it fills", getattr(raw, "symbol", "?"), getattr(raw, "id", "?"), ) return None if len(sells) > 1: _logger.warning( "%s: entry %s came back with %d protective legs; recording the " "first (%s) and leaving the rest unrecorded", sells[0].symbol, getattr(raw, "id", "?"), len(sells), sells[0].order_id, ) return sells[0]
[docs] def replace_stop_price(self, order_id: str, stop_price: Decimal) -> SubmittedOrder: """Raise (or set) a stop order's trigger price with one request. Only `stop_price` is passed to `ReplaceOrderRequest`, which also accepts `qty`, `time_in_force`, `limit_price`, `trail` and `client_order_id` — sending any of those would silently change it too. This is one request from this app's side, never a cancel followed by a new submission — but per Alpaca's own docs, the SDK's `replace_order_by_id` is a cancel-and-resubmit *under the hood* that returns a distinct new order id (the old order transitions to status `replaced`). Whether that is observably gapless at the broker is unverified; see `OrderPlacingBroker.replace_stop_price`'s docstring. """ self._assert_may_submit() from alpaca.trading.requests import ReplaceOrderRequest request = ReplaceOrderRequest(stop_price=stop_price) try: raw = self._client.replace_order_by_id(order_id, order_data=request) except Exception as exc: # noqa: BLE001 - SDK raises many types raise OrderError( f"Failed to replace stop price for order {order_id}: {exc}" ) from exc return self._to_submitted(raw)
[docs] def submit_market_sell(self, symbol: str, quantity: int) -> SubmittedOrder: """Submit a market sell to close a long.""" self._assert_may_submit() qty = self._whole_shares(quantity, symbol) from alpaca.trading.enums import OrderSide as SdkSide from alpaca.trading.enums import TimeInForce from alpaca.trading.requests import MarketOrderRequest request = MarketOrderRequest( symbol=symbol, qty=qty, side=SdkSide.SELL, time_in_force=TimeInForce.DAY ) try: raw = self._client.submit_order(order_data=request) except Exception as exc: # noqa: BLE001 - SDK raises many types raise OrderError(f"Failed to submit market sell for {symbol}: {exc}") from exc return self._to_submitted(raw)
[docs] def submit_trailing_stop_sell( self, symbol: str, quantity: int, trail_percent: Decimal ) -> SubmittedOrder: """Submit a standalone GTC trailing-stop sell. GTC, not DAY: protection must outlive the session that placed it. A DAY trailing stop expires at the close and leaves the position naked overnight, which is the failure this whole design exists to prevent. Standalone rather than a bracket leg because `StopLossRequest` carries only `limit_price` and `stop_price` — verified against alpaca-py 0.43.5, so a trailing stop cannot be attached to the entry. """ self._assert_may_submit() qty = self._whole_shares(quantity, symbol) from alpaca.trading.enums import OrderSide as SdkSide from alpaca.trading.enums import TimeInForce from alpaca.trading.requests import TrailingStopOrderRequest request = TrailingStopOrderRequest( symbol=symbol, qty=qty, side=SdkSide.SELL, time_in_force=TimeInForce.GTC, trail_percent=trail_percent, ) try: raw = self._client.submit_order(order_data=request) except Exception as exc: # noqa: BLE001 - SDK raises many types raise OrderError( f"Failed to submit trailing stop for {symbol}: {exc}" ) from exc return self._to_submitted(raw)
[docs] def cancel_order(self, order_id: str) -> None: """Cancel an open order by broker id. Gated like a submission: cancelling is how a protective stop is removed before an exit, so it changes the account's risk just as an order does. """ self._assert_may_submit() try: self._client.cancel_order_by_id(order_id) except Exception as exc: # noqa: BLE001 - SDK raises many types raise OrderError(f"Failed to cancel order {order_id}: {exc}") from exc
[docs] def get_open_orders(self, symbol: str | None = None) -> list[SubmittedOrder]: """Open orders, optionally filtered to one symbol. A read, so it does not call the submission gate: reconciliation must be able to see what exists even on an account that may not write. """ from alpaca.trading.enums import QueryOrderStatus from alpaca.trading.requests import GetOrdersRequest request = GetOrdersRequest( status=QueryOrderStatus.OPEN, symbols=[symbol] if symbol else None ) try: raw_orders = self._client.get_orders(filter=request) except Exception as exc: # noqa: BLE001 - SDK raises many types raise OrderError(f"Failed to fetch open orders: {exc}") from exc return [self._to_submitted(raw) for raw in raw_orders]
[docs] def get_filled_orders(self, since: datetime | None = None) -> list[SubmittedOrder]: """Recently closed orders, most recent first. A read, so it does not call the submission gate. `QueryOrderStatus.CLOSED` covers filled, cancelled, and expired; the caller decides which matter, because "expired unfilled" is information too. """ from alpaca.trading.enums import QueryOrderStatus from alpaca.trading.requests import GetOrdersRequest request = GetOrdersRequest(status=QueryOrderStatus.CLOSED, after=since, limit=200) try: raw_orders = self._client.get_orders(filter=request) except Exception as exc: # noqa: BLE001 - SDK raises many types raise OrderError(f"Failed to fetch filled orders: {exc}") from exc return [self._to_submitted(raw) for raw in raw_orders]
[docs] def get_order_by_id(self, order_id: str) -> SubmittedOrder | None: """One order's current state, by broker id. `None` if there is no such order. A read, so it does not call the submission gate. Unlike `get_filled_orders`, this asks about **one** order and is bounded by neither a time window nor the 200-order response cap — which is the whole reason it exists. Issue #1 needs the true status of an order submitted weeks ago, and that is exactly the order a capped, windowed list query silently drops. Three outcomes, and keeping them distinct is the point: - the order exists -> a `SubmittedOrder` carrying its real status and `filled_qty`; - the broker says there is no such order (404) -> `None`; - the question could not be answered at all -> `OrderError`. Collapsing the last two into `None` would let a network blip read as "the broker has never heard of it", and the caller settles rows on the strength of this answer. `None` is not evidence of anything either — `settle_terminal_orders` settles on a terminal status with a zero fill and on nothing else — but an outage must still be loud rather than silently indistinguishable from an answer. """ from alpaca.common.exceptions import APIError try: raw = self._client.get_order_by_id(order_id) except APIError as exc: # `status_code` is `None` when the SDK built the error without an # HTTP response behind it, so this cannot be simplified to "not a # 404 means transient": an unknown-shaped APIError raises, which is # the safe direction — a raised error settles nothing. if getattr(exc, "status_code", None) == _NOT_FOUND: return None raise OrderError(f"Failed to fetch order {order_id}: {exc}") from exc except Exception as exc: # noqa: BLE001 - SDK raises many types raise OrderError(f"Failed to fetch order {order_id}: {exc}") from exc return self._to_submitted(raw)