trader.brokers.alpaca module¶
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.
- class trader.brokers.alpaca.AlpacaBroker(api_key, api_secret, *, paper=True, client=None, config=None, client_factory=None)[source]¶
Bases:
objectRead-only access to an Alpaca account.
- Parameters:
api_key (str) – Alpaca key for the selected environment.
api_secret (str) – Alpaca secret for the selected environment.
paper (bool) – Whether to talk to the paper endpoint.
client (object | None) – Injected client, used by tests. When None, a real TradingClient is constructed.
config (EffectiveTradingConfig | None) – 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 (Callable[[str, str, bool], object] | None) – 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.
- classmethod from_config(config, *, client=None, client_factory=None)[source]¶
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.
- Parameters:
config (EffectiveTradingConfig)
client (object | None)
client_factory (Callable[[str, str, bool], object] | None)
- Return type:
- property is_paper: bool¶
Whether this adapter points at the paper endpoint.
- property config: EffectiveTradingConfig | None¶
The config this broker was built from, if any.
- get_clock()[source]¶
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.
- Return type:
- get_asset(symbol)[source]¶
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.
- Parameters:
symbol (str)
- Return type:
TradableAsset | None
- submit_limit_buy(symbol, quantity, limit_price)[source]¶
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.
- Parameters:
symbol (str)
quantity (int)
limit_price (Decimal)
- Return type:
- submit_limit_buy_with_stop(symbol, quantity, limit_price, stop_price)[source]¶
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.
- Parameters:
symbol (str)
quantity (int)
limit_price (Decimal)
stop_price (Decimal)
- Return type:
- replace_stop_price(order_id, stop_price)[source]¶
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.
- Parameters:
order_id (str)
stop_price (Decimal)
- Return type:
- submit_market_sell(symbol, quantity)[source]¶
Submit a market sell to close a long.
- Parameters:
symbol (str)
quantity (int)
- Return type:
- submit_trailing_stop_sell(symbol, quantity, trail_percent)[source]¶
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.
- Parameters:
symbol (str)
quantity (int)
trail_percent (Decimal)
- Return type:
- cancel_order(order_id)[source]¶
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.
- Parameters:
order_id (str)
- Return type:
None
- get_open_orders(symbol=None)[source]¶
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.
- Parameters:
symbol (str | None)
- Return type:
list[SubmittedOrder]
- get_filled_orders(since=None)[source]¶
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.
- Parameters:
since (datetime | None)
- Return type:
list[SubmittedOrder]
- get_order_by_id(order_id)[source]¶
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.
- Parameters:
order_id (str)
- Return type:
SubmittedOrder | None