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