Source code for trader.brokers.base

"""The broker interfaces the Core Service Layer depends on.

Defining these as Protocols keeps the Core free of any `alpaca-py` import,
so a second broker (or a fake, in tests) can be substituted freely.

Two Protocols rather than one, deliberately. `BrokerAdapter` is the read-only
surface Slice 1 shipped; `OrderPlacingBroker` adds order placement. A
collaborator that only needs to read asks for the first and is then structurally
unable to place an order — which is worth more here than the convenience of a
single interface.
"""

from datetime import datetime
from decimal import Decimal
from typing import Protocol, runtime_checkable

from trader.domain import (
    Account,
    MarketClock,
    Position,
    ProtectedEntry,
    SubmittedOrder,
    TradableAsset,
)

__all__ = ["BrokerAdapter", "OrderPlacingBroker"]


[docs] @runtime_checkable class BrokerAdapter(Protocol): """Read-only broker operations."""
[docs] def get_account(self) -> Account: """Return the current account summary.""" ...
[docs] def get_positions(self) -> list[Position]: """Return all open positions.""" ...
[docs] def get_clock(self) -> MarketClock: """Return the exchange's open state and its next transitions. On the read Protocol deliberately: the daemon needs it on every wake-up, and keeping it here means the retrying read wrapper can cover it without ever holding an order-placing method. """ ...
[docs] def get_asset(self, symbol: str) -> TradableAsset | None: """What the broker knows about `symbol`, or `None` if it does not. A read, so it lives here rather than on `OrderPlacingBroker` — which is also what lets the retrying wrapper cover it. """ ...
[docs] @runtime_checkable class OrderPlacingBroker(BrokerAdapter, Protocol): """Read operations plus order placement. Note that `runtime_checkable` checks method *names* only, never signatures, so `isinstance` against this Protocol proves less than it appears to. The signatures below are enforced by the tests that exercise real callers, not by the runtime. """
[docs] def submit_limit_buy( self, symbol: str, quantity: int, limit_price: Decimal ) -> SubmittedOrder: """Submit a DAY limit buy. Raises `OrderError` on failure.""" ...
[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 a protective stop attached atomically. One OTO request rather than an entry followed by a separate stop, so the broker holds protection from the instant of fill and nothing depends on this app running again to place a second order. Requirements §8. Returns **both** orders (`ProtectedEntry`), not just the parent. One request creates two orders and the caller has to record both: the leg's `trades` row is what makes a stop-driven exit — this app's dominant exit path — attributable at all. Returning a bare `SubmittedOrder` here is what discarded the leg the first time. GTC, not DAY: measured 2026-08-10, an 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* itself is enforced by the app instead — see `OrderExecutor.cancel_stale_entries`. """ ...
[docs] def replace_stop_price(self, order_id: str, stop_price: Decimal) -> SubmittedOrder: """Change an open stop order's trigger price with a single request. Alpaca cannot attach a *trailing* stop to an OTO leg — `StopLossRequest` accepts only `stop_price`/`limit_price` — so the fixed stop this app places is ratcheted upward over time by replacing it in place. This app issues exactly one request and never itself cancels the old order and then places a new one, so no gap is introduced by code on this side. Whether Alpaca's replace is *observably* gapless on the broker's side is unverified: measured 2026-08-10, `TradingClient.replace_order_by_id` is documented as a cancel-and-resubmit under the hood that returns a distinct new order id (the old one transitions to status `replaced`), which is evidence against gaplessness, not for it. Confirming either way needs a live protective order to be replaced and watched — the operator's call, not a claim to assert from the docs. """ ...
[docs] def submit_market_sell(self, symbol: str, quantity: int) -> SubmittedOrder: """Submit a market sell to close a long.""" ...
[docs] def submit_trailing_stop_sell( self, symbol: str, quantity: int, trail_percent: Decimal ) -> SubmittedOrder: """Submit a standalone GTC trailing-stop sell.""" ...
[docs] def cancel_order(self, order_id: str) -> None: """Cancel an open order by broker id.""" ...
[docs] def get_open_orders(self, symbol: str | None = None) -> list[SubmittedOrder]: """Open orders, optionally filtered to one symbol.""" ...
[docs] def get_filled_orders(self, since: datetime | None = None) -> list[SubmittedOrder]: """Recently filled or closed orders. A read; not gated.""" ...
[docs] def get_order_by_id(self, order_id: str) -> SubmittedOrder | None: """One order's current state at the broker, or `None` if it has none. A read, and not gated — but on *this* Protocol rather than `BrokerAdapter`, next to the two order-listing reads that already live here. Two reasons, and the second is load-bearing. An order id only exists because an order was placed, so nothing that merely reads the account has any use for this. And `RetryingBroker` satisfies `BrokerAdapter` and deliberately holds no `submit_*` method: putting a method on the read Protocol obliges that wrapper to grow it, which is why `get_open_orders` and `get_filled_orders` are here too. `None` means the broker denies knowing this order id — a 404, not a failure to ask. Anything that prevented the question from being answered raises `OrderError` instead, because the caller (issue #1's `settle_terminal_orders`) settles a row only on **positive** evidence of a terminal, non-filling outcome, and "I could not tell" must never be able to masquerade as "it never filled". Absence from `get_open_orders` does not mean an order never filled — it usually means it did — which is precisely why this per-id read exists rather than a diff against the open-orders list. """ ...