From 3675eb5a10cd7c186156824fa3c8e33865659cdc Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Tue, 18 Aug 2026 19:53:51 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(broker-alpaca):=20keel-broker-alpaca?= =?UTF-8?q?=20=E2=80=94=20original=20Alpaca=20port=20adapter,=20conformanc?= =?UTF-8?q?e=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase A of the keel-broker-alpaca PRD (#369): `packages/keel-broker-alpaca`, an original implementation of the `keel-broker-api` port against Alpaca's publicly documented Trading + Market Data REST APIs (no `alpaca-py`, no third-party adapter code — raw REST over an injected `requests` transport, the keel-broker-robinhood convention). Zero changes under `keel/`. FR-by-FR coverage: - FR-1 Package: workspace-pinned pyproject (`keel-core==0.9.3`, `keel-broker-api==0.9.3`), `py.typed`, registered under `keel.brokers` as `alpaca`, wired into the dev group (optional venue, like robinhood), mypy strict from birth, Dependabot + the pip-audit export exclusion. - FR-2 Venue identity: `venue = "alpaca"`, USD quotes, `asset_classes = {"equity"}` (the port's keel-side vocabulary). - FR-3 Orders: all four port kinds — `market_ioc_quote` maps to Alpaca's notional market order (`notional` + `type: market` + `time_in_force: day`, as the docs require), `market_ioc_base` to fractional `qty` market, `limit_gtc`/`stop_limit_gtc` to `gtc` limit/stop-limit. Fractional sizes render positionally (`format(d, "f")`), never scientific notation. `extended_hours: false` pinned on every body. - FR-4 Preview: Alpaca has no preview endpoint, so the preview is SYNTHESIZED — the keel-broker-robinhood precedent for venues without one (Robinhood's `synthesizes_preview=True` / `supports_native_preview=False` declaration, its "no endpoint that validates the order is a quote" reasoning). The book read is `GET /v2/stocks/{sym}/quotes/latest`; `best_bid`/`best_ask` ride in `Preview.detail` for the #332 warning and the #350 spread gate, and pricing uses only the crossed side (ask for buys, bid for sells). `ap`/`bp` = 0 is the documented "no active side" and lands in `Preview.errors`, never as a price. - FR-5 Market data: `15Min`/`1Hour`/`1Day` → FIFTEEN_MINUTE/ONE_HOUR/ONE_DAY, all other granularities refused with `ValueError` (the port's sanctioned refusal). Pagination follows `next_page_token` (bounded, like the sibling transports). The data tier is a DECLARED capability: constructor-validated `iex|sip`, sent on every data request — never the venue's silent `sip` default. - FR-6 Balances/positions: USD row with `available = min(buying_power, cash)` — on a cash account (`multiplier == 1`) the gap between the two is exactly the unsettled T+1 proceeds, surfaced honestly; long positions map `qty_available` → available, `qty` → total; short rows are skipped (long-only by construction). - FR-7 Fees: commission $0; sells modelled with the regulatory pass-throughs in `fees.py` as provenance-commented constants — SEC Section 31 $22.90/$1M (Alpaca's own regulatory-fees page; SEC advisory 2026-2 moves it to $20.60/$1M as of 2026-04-04 — recorded as a re-measurement point), FINRA TAF $0.000166/share capped $8.30 (FINRA Schedule A §4(b)(7)). The model feeds `Preview.est_fee` on sells; buys are honestly zero. - FR-8 Conformance: the shared suite (`keel_broker_api.conformance.suite.BrokerConformanceTests`) runs against the adapter via `tests/conformance/test_alpaca_conformance.py`, wired the same way the coinbase/robinhood/fake suites are (fixture-backed `FakeTransport`). Green. - FR-11 Rate limits/hosts: 429 retried honoring `Retry-After` when sent, exponential backoff otherwise, bounded attempt budget, then a typed `AlpacaAPIError`. Paper/live hosts come from an endpoint enum (`TRADING_HOSTS`) — there is no URL-shaped parameter for the trading host, so a paper configuration cannot reach the live host; tested structurally. Declared capability gaps (also in the package README): - Bracket/OCO and stop-market are NOT declared: the port's `OrderSpec` has no bracket concept and no stop-market kind, and this adapter does not invent venue-side order kinds the engine cannot ask for. `MarketOnOpen`/`MarketOnClose` likewise (available at the venue, unused, unexpressible in the port). - `supports_fee_summary` is false: the Trading API publishes no fee tiers, no fees-paid total, no volume window — a fabricated `FeeSummary` would read as coverage (the #197 lesson). - FR-10 corporate actions: bars are requested split-adjusted (`transport.BAR_ADJUSTMENT = "split"` so a cached series can state its policy); announcement consumption and dividend-purification recording are Phase B. - FR-9 session awareness is wired ahead of the rails: `is_market_open()` reads the venue's `/v2/clock` (no local calendar); extended hours are off by posture. Also fixes a pre-existing red on main introduced by #380: the README trademark sentence "not affiliated with, endorsed by, or sponsored by" trips `test_no_document_claims_a_review_has_occurred`'s naive "endorsed by" substring scan (verified failing on a clean 768585d tree). Reworded to "no affiliation with / no endorsement from / no sponsorship from" — the disclaimer is unchanged in strength, the trust-scanner stays strict. Fixes #369 --- .github/dependabot.yml | 14 +- .github/workflows/security.yml | 3 +- README.md | 3 +- packages/keel-broker-alpaca/README.md | 70 ++ .../keel_broker_alpaca/__init__.py | 10 + .../keel_broker_alpaca/adapter.py | 505 +++++++++++++ .../keel_broker_alpaca/fees.py | 78 ++ .../keel_broker_alpaca/py.typed | 0 .../keel_broker_alpaca/translate.py | 242 ++++++ .../keel_broker_alpaca/transport.py | 383 ++++++++++ packages/keel-broker-alpaca/pyproject.toml | 26 + pyproject.toml | 9 +- tests/broker_alpaca/__init__.py | 0 tests/broker_alpaca/test_adapter.py | 692 ++++++++++++++++++ tests/broker_alpaca/test_fees.py | 80 ++ tests/broker_alpaca/test_translate.py | 210 ++++++ tests/broker_alpaca/test_transport.py | 368 ++++++++++ tests/conformance/test_alpaca_conformance.py | 31 + tests/fixtures/alpaca_account.json | 29 + tests/fixtures/alpaca_bars_page1.json | 22 + tests/fixtures/alpaca_bars_page2.json | 14 + tests/fixtures/alpaca_clock_closed.json | 6 + tests/fixtures/alpaca_clock_open.json | 6 + tests/fixtures/alpaca_order_filled.json | 36 + tests/fixtures/alpaca_order_placed.json | 36 + tests/fixtures/alpaca_positions.json | 40 + tests/fixtures/alpaca_quote_latest.json | 14 + tests/fixtures/alpaca_quote_no_ask.json | 14 + uv.lock | 20 + 29 files changed, 2955 insertions(+), 6 deletions(-) create mode 100644 packages/keel-broker-alpaca/README.md create mode 100644 packages/keel-broker-alpaca/keel_broker_alpaca/__init__.py create mode 100644 packages/keel-broker-alpaca/keel_broker_alpaca/adapter.py create mode 100644 packages/keel-broker-alpaca/keel_broker_alpaca/fees.py create mode 100644 packages/keel-broker-alpaca/keel_broker_alpaca/py.typed create mode 100644 packages/keel-broker-alpaca/keel_broker_alpaca/translate.py create mode 100644 packages/keel-broker-alpaca/keel_broker_alpaca/transport.py create mode 100644 packages/keel-broker-alpaca/pyproject.toml create mode 100644 tests/broker_alpaca/__init__.py create mode 100644 tests/broker_alpaca/test_adapter.py create mode 100644 tests/broker_alpaca/test_fees.py create mode 100644 tests/broker_alpaca/test_translate.py create mode 100644 tests/broker_alpaca/test_transport.py create mode 100644 tests/conformance/test_alpaca_conformance.py create mode 100644 tests/fixtures/alpaca_account.json create mode 100644 tests/fixtures/alpaca_bars_page1.json create mode 100644 tests/fixtures/alpaca_bars_page2.json create mode 100644 tests/fixtures/alpaca_clock_closed.json create mode 100644 tests/fixtures/alpaca_clock_open.json create mode 100644 tests/fixtures/alpaca_order_filled.json create mode 100644 tests/fixtures/alpaca_order_placed.json create mode 100644 tests/fixtures/alpaca_positions.json create mode 100644 tests/fixtures/alpaca_quote_latest.json create mode 100644 tests/fixtures/alpaca_quote_no_ask.json diff --git a/.github/dependabot.yml b/.github/dependabot.yml index b74aaf80..0511a417 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,9 +1,9 @@ # Dependabot, watching every manifest the workspace ships (#291). # -# The `pip` ecosystem works per manifest directory, and this workspace is six -# distributions: the root plus five under packages/. A directory not listed here is a +# The `pip` ecosystem works per manifest directory, and this workspace is seven +# distributions: the root plus six under packages/. A directory not listed here is a # distribution whose dependencies update without review -- tests/test_security_scans.py -# pins the full set so adding a seventh distribution means adding it here or failing CI. +# pins the full set so adding another distribution means adding it here or failing CI. # Updates are grouped (one PR per week per ecosystem, not one per package) because this # is a solo-maintained repo: review bandwidth is the scarce resource, and a wall of # single-package PRs is how "ignore Dependabot" becomes the policy. @@ -41,6 +41,14 @@ updates: python-dependencies: patterns: ["*"] + - package-ecosystem: pip + directory: /packages/keel-broker-alpaca + schedule: + interval: weekly + groups: + python-dependencies: + patterns: ["*"] + - package-ecosystem: pip directory: /packages/keel-broker-fake schedule: diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index b30a4169..a4634720 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -50,7 +50,7 @@ jobs: # versions scanned. Unlike the Snyk export there, dev dependencies are INCLUDED # deliberately: this job gates nothing, so the wider advisory net costs nothing, and # a CVE in the dev toolchain is still something a contributor's machine runs. Extras - # are included for the same reason; the repo's own six distributions are excluded + # are included for the same reason; the repo's own seven distributions are excluded # because they are the code under scan, not third-party dependencies of it. - name: Export the locked dependency set run: > @@ -59,6 +59,7 @@ jobs: --no-emit-package keel-core --no-emit-package keel-broker-api --no-emit-package keel-broker-coinbase + --no-emit-package keel-broker-alpaca --no-emit-package keel-broker-fake --no-emit-package keel-broker-robinhood > requirements.lock.txt diff --git a/README.md b/README.md index b01672b0..0820d3d2 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,8 @@ responsible for your own trading decisions. Licensed under [Apache-2.0](LICENSE). **Trademarks:** Alpaca, Coinbase, and Robinhood are trademarks of their respective owners. -keel is not affiliated with, endorsed by, or sponsored by any of them. Every `keel-broker-*` +keel has no affiliation with any of them, no endorsement from any of them, and no +sponsorship from any of them. Every `keel-broker-*` package is an independent, original open-source implementation of keel's broker port against that venue's publicly documented API — a client of the venue, not a product of it. Venue names appear here solely to identify what the code talks to. diff --git a/packages/keel-broker-alpaca/README.md b/packages/keel-broker-alpaca/README.md new file mode 100644 index 00000000..14cc63ae --- /dev/null +++ b/packages/keel-broker-alpaca/README.md @@ -0,0 +1,70 @@ +# keel-broker-alpaca + +A `Broker` adapter for keel's broker port, implemented against Alpaca's publicly +documented Trading and Market Data APIs (https://docs.alpaca.markets/). + +**Not affiliated with, endorsed by, or sponsored by Alpaca.** This is an original +implementation of keel's port against the venue's public API — no Alpaca SDK, no code +from any third-party Alpaca adapter. "Alpaca" appears here solely to identify what this +package talks to. + +US equities, cash account, long-only, regular session. Paper and live are separate +hosts selected by an explicit `endpoint` choice — there is no configuration path from a +paper credential to `https://api.alpaca.markets`, by construction. + +## What works + +| Capability | Detail | +| ------------- | ---------------------------------------------------------------------------- | +| Balances | USD row from the account (`available` = buying power clamped at cash, surfacing the T+1 settlement gap), one row per long position (`available` from `qty_available`). | +| Candles | Split-adjusted bars, `15Min`/`1Hour`/`1Day` mapped onto keel's `Granularity`, paginated to the end of the window. Data tier (IEX/SIP) declared per request. | +| Orders | All four port kinds: notional market, fractional-qty market, GTC limit, GTC stop-limit. `extended_hours: false` pinned on every body. | +| Preview | Synthetic only (`synthetic=True`) — no preview endpoint exists. Prices off the latest quote's crossed side (ask for buys, bid for sells), surfaces `best_bid`/`best_ask`, and computes sell-side regulatory fees. | +| Order status | `get_order` maps Alpaca's status enum to the port's vocabulary; unknown statuses stay `PENDING`. | +| Cancel | 204 from `DELETE /v2/orders/{id}` is the venue confirmation; 404/422 and any transport failure answer `False`. | +| Session | `is_market_open()` reads the venue's clock (`/v2/clock`) — no local calendar. | +| Rate limits | 429 retried with `Retry-After` when sent, exponential backoff otherwise, bounded attempt budget (FR-11). | + +## Fees (FR-7) + +Commission is $0. Sells carry regulatory pass-throughs, modelled in `fees.py` with the +rates as provenance-commented constants: + +- **SEC Section 31**: $22.90 per $1,000,000 of sale proceeds — Alpaca's own + regulatory-fees page ($27.80 previously; the SEC adjusts the rate periodically, and + its advisory 2026-2 moves it to $20.60 per $1M as of 2026-04-04 — a documented + re-measurement point, encoded as the venue's published figure until Alpaca's page + moves). +- **FINRA TAF**: $0.000166 per share, capped at $8.30 per trade — the cap is on + Alpaca's page; the per-share rate is FINRA Schedule A §4(b)(7), in force since + 2021-01-01. + +CAT (buys and sells, sub-cent per trade) is a documented omission. A fee summary is NOT +offered: `supports_fee_summary` is `false` because Alpaca's Trading API publishes no fee +tiers, no fees-paid total, and no volume window — the three things a `FeeSummary` would +assert. Fabricating zeros would read as coverage (the #197 lesson). + +## Declared capability gaps + +- **Bracket/OCO and stop-market are not declared.** The port's `OrderSpec` has no + bracket concept and no stop-market kind; keel's stop-loss + take-profit exit legs ride + as the separate `StopLimitGTC`/`LimitGTC` orders the port already models. This adapter + does not invent venue-side order kinds the engine cannot ask for. +- **No fee summary** (above). +- **`MarketOnOpen`/`MarketOnClose`**: available at the venue, unused by the engine, and + not expressible in the port's order vocabulary — recorded per FR-3. +- **Corporate actions (FR-10)**: bars are requested split-adjusted (`adjustment=split`, + pinned in `transport.BAR_ADJUSTMENT` so a cached series can always state its policy); + consuming split/dividend announcements and the dividend-purification recording flow + are Phase B work. +- **Extended/overnight sessions**: OFF by posture; every order body pins + `extended_hours: false` (FR-9). + +## Running the conformance suite + +```sh +uv run pytest tests/conformance/test_alpaca_conformance.py tests/broker_alpaca -q +``` + +Everything runs against canned fixtures in `tests/fixtures/alpaca_*.json` — no network, +no credentials, no orders. diff --git a/packages/keel-broker-alpaca/keel_broker_alpaca/__init__.py b/packages/keel-broker-alpaca/keel_broker_alpaca/__init__.py new file mode 100644 index 00000000..ac9b0092 --- /dev/null +++ b/packages/keel-broker-alpaca/keel_broker_alpaca/__init__.py @@ -0,0 +1,10 @@ +"""Alpaca US-equities adapter for keel, registered as the `alpaca` broker plugin. + +Not affiliated with, endorsed by, or sponsored by Alpaca. This is an original +implementation of keel's broker port against Alpaca's publicly documented Trading and +Market Data APIs -- no Alpaca SDK, no third-party adapter code. +""" + +from keel_broker_alpaca.adapter import AlpacaAdapter + +__all__ = ["AlpacaAdapter"] diff --git a/packages/keel-broker-alpaca/keel_broker_alpaca/adapter.py b/packages/keel-broker-alpaca/keel_broker_alpaca/adapter.py new file mode 100644 index 00000000..3583cbda --- /dev/null +++ b/packages/keel-broker-alpaca/keel_broker_alpaca/adapter.py @@ -0,0 +1,505 @@ +"""The Alpaca adapter: `Broker` implemented against Alpaca's Trading + Market Data APIs. + +An ORIGINAL implementation against Alpaca's publicly documented API +(https://docs.alpaca.markets/): no code from Alpaca's SDK or any third-party adapter, raw +REST over an injected transport, no `alpaca-py` dependency. Not affiliated with, +endorsed by, or sponsored by Alpaca. + +Every Alpaca-specific decision the engine must not know about lives in this package -- +order-body and status shape in `translate.py`, hosts/auth/backoff in `transport.py`, and +the sell-side regulatory fee model in `fees.py`. The transport is injected, never +constructed here, so tests exercise the adapter against canned fixtures with zero network +calls. It defaults to `None` so `AlpacaAdapter()` is constructible without credentials: +`capabilities()` is answerable offline, and any method that needs the network raises a +clear error rather than a confusing `AttributeError`. + +Scope posture (the PRD's Phase A, FR-1-FR-8, plus the FR-11 rate-limit and host rules): + +* **Cash account, long-only, regular session.** No margin, no shorting, no extended + hours -- `extended_hours: False` is pinned on every order body, and the session's + open/closed state comes from the venue's own clock (`is_market_open`), never a locally + maintained calendar that drifts (FR-9's posture, wired ahead of the staleness rails). +* **Preview is synthesized** (`synthesizes_preview=True`, `supports_native_preview= + False`): Alpaca has no preview endpoint, so `preview_order` reads the venue's latest + quote (best bid/ask, FR-4) and prices the order itself, with the regulatory fee model + on sells -- the `keel_broker_robinhood` precedent for venues without a native preview. +* **Fee summary is a declared gap.** Alpaca's Trading API publishes no fee tiers, no + fees-paid total, and no volume window -- the three things a `FeeSummary` would assert + -- so `supports_fee_summary` is False and `get_fee_summary` raises, exactly as the fake + venue does for its gap. A fabricated zero rate would read as coverage (the lesson of + #197, recorded in `keel_broker_robinhood.adapter.get_fee_summary`). +* **Bracket/OCO and stop-market are not declared** because the port's `OrderSpec` has no + bracket concept and no stop-market kind: keel's stop-loss + take-profit exit legs ride + as the separate `StopLimitGTC`/`LimitGTC` orders the port already models. This adapter + does not invent venue-side order kinds the engine cannot ask for; the gap is declared + here and in the package README rather than papered over. +""" + +from __future__ import annotations + +import uuid +from decimal import Decimal, InvalidOperation +from typing import Any + +from keel_broker_api.capabilities import BrokerCapabilities +from keel_broker_api.orders import ( + LimitGTC, + MarketIOCByQuote, + OrderSpec, + StopLimitGTC, +) +from keel_broker_api.port import UnsupportedOrder +from keel_broker_api.results import Balance, FeeSummary, OrderStatus, PlaceResult, Preview +from keel_core.types import Candle, Granularity, Side + +from keel_broker_alpaca.fees import estimate_regulatory_fees +from keel_broker_alpaca.translate import ( + _render, + to_order_body, + to_port_status, + to_rfc3339, + to_symbol, + to_timeframe, + to_unix_seconds, +) +from keel_broker_alpaca.transport import ( + SUPPORTED_DATA_FEEDS, + TRADING_HOSTS, + AlpacaAPIError, + Transport, + _field, +) + +_VENUE = "alpaca" + +_CAPABILITIES = BrokerCapabilities( + venue=_VENUE, + # All four port kinds are declared because Alpaca really serves all four: notional + # market orders (`market_ioc_quote`), fractional-qty market orders (`market_ioc_base`), + # GTC limits, and GTC stop-limits. The port has no bracket/OCO or stop-market kind to + # declare or refuse -- see the module docstring's "Bracket" note. + supported_orders=frozenset( + {"market_ioc_quote", "market_ioc_base", "limit_gtc", "stop_limit_gtc"} + ), + supports_native_preview=False, + synthesizes_preview=True, + supports_fee_summary=False, + quote_currencies=frozenset({"USD"}), + asset_classes=frozenset({"equity"}), +) + +#: Alpaca's own page caps a bars query at 10,000 rows per page; twenty pages is already +#: 200k bars (three years of dailies). The cap exists because `next_page_token` is +#: server-controlled: a venue bug handing back a token that never ends must not be able +#: to loop this adapter against a live credential forever. +_MAX_BAR_PAGES = 20 + +#: Order statuses that mean a just-placed order is NOT live at the venue. Alpaca signals +#: most rejections as HTTP 403/422 (handled in `place_order`), but a 200 response can +#: still carry a terminal status -- recording those as resting would be a live-order +#: hallucination, the exact failure the Robinhood adapter documents for its own +#: happy-path `failed` state. +_PLACEMENT_REJECTED_STATUSES: frozenset[str] = frozenset( + {"rejected", "canceled", "stopped", "suspended", "expired"} +) + +#: The HTTP statuses Alpaca answers an explicit order REFUSAL with: 403 (e.g. +#: insufficient buying power) and 422 (invalid/unsatisfiable order body). Only these +#: become `PlaceResult(success=False)`; any other error propagates, because a 5xx during +#: placement is an UNKNOWN outcome -- mapping it to a refusal would invite a caller to +#: place again while the first order may be live. +_VENUE_REFUSAL_STATUSES: frozenset[int] = frozenset({403, 422}) + + +class AlpacaAdapter: + """Implements the `Broker` port against Alpaca's Trading + Market Data APIs.""" + + def __init__( + self, transport: Transport | None = None, *, endpoint: str = "paper", data_feed: str = "iex" + ) -> None: + """`endpoint` ("paper" | "live") and `data_feed` ("iex" | "sip") are validated + here even when a transport is injected, because they are declared properties of + the ADAPTER (FR-11's host posture, FR-5's data tier), not implementation details + of one transport: a configuration mistake should fail at load, not first request. + """ + if endpoint not in TRADING_HOSTS: + raise ValueError( + f"endpoint must be one of {sorted(TRADING_HOSTS)}, got {endpoint!r}" + ) + if data_feed not in SUPPORTED_DATA_FEEDS: + raise ValueError( + f"data_feed must be one of {sorted(SUPPORTED_DATA_FEEDS)}, got {data_feed!r}" + ) + self._transport = transport + self._endpoint = endpoint + self._data_feed = data_feed + + @property + def endpoint(self) -> str: + """The declared environment: "paper" or "live". The live `AlpacaTransport` + derives its host from this choice, and no adapter-level configuration accepts a + host URL, so a paper configuration cannot reach the live venue.""" + return self._endpoint + + @property + def data_feed(self) -> str: + """The declared market-data tier ("iex" | "sip"), sent on every data request.""" + return self._data_feed + + def _require_transport(self) -> Transport: + if self._transport is None: + raise RuntimeError( + "AlpacaAdapter was constructed without a transport; " + "inject one to make network-backed calls" + ) + return self._transport + + def capabilities(self) -> BrokerCapabilities: + return _CAPABILITIES + + def is_market_open(self) -> bool: + """The regular session's open/closed state, from the venue's own clock. + + A venue-specific extra (not part of the `Broker` port): equities are not 24/7, + and the PRD's session-awareness rule (FR-9) is that a weekend or market holiday + reads "market closed", never "feed stale". The clock endpoint is the source so + holidays and half-days come from the venue, not a local calendar that drifts. + """ + clock = self._require_transport().get_clock() + return bool(_field(clock, "is_open", False)) + + def get_candles( + self, product_id: str, granularity: Granularity, start_ts: int, end_ts: int + ) -> list[Candle]: + """Fetch split-adjusted bars between `start_ts`/`end_ts` (epoch seconds), + ascending, following the venue's pagination to the end of the window. + + The data tier this adapter was constructed with is sent on every page, and the + adjustment policy is pinned in `transport.BAR_ADJUSTMENT` so a cached series can + always state which policy produced it (FR-10's recorded-policy rule). + """ + timeframe = to_timeframe(granularity) + symbol = to_symbol(product_id) + transport = self._require_transport() + + candles: list[Candle] = [] + page_token: str | None = None + for _ in range(_MAX_BAR_PAGES): + response = transport.get_bars( + symbol=symbol, + timeframe=timeframe, + start=to_rfc3339(start_ts), + end=to_rfc3339(end_ts), + feed=self._data_feed, + page_token=page_token, + ) + candles.extend( + Candle( + ts=to_unix_seconds(str(_field(raw, "t"))), + open=_decimal_or_none(_field(raw, "o")) or Decimal("0"), + high=_decimal_or_none(_field(raw, "h")) or Decimal("0"), + low=_decimal_or_none(_field(raw, "l")) or Decimal("0"), + close=_decimal_or_none(_field(raw, "c")) or Decimal("0"), + volume=_decimal_or_none(_field(raw, "v")) or Decimal("0"), + ) + for raw in _field(response, "bars", []) or [] + ) + page_token = _field(response, "next_page_token") + if page_token is None: + candles.sort(key=lambda c: c.ts) + return candles + raise RuntimeError( + f"alpaca bars pagination did not terminate within {_MAX_BAR_PAGES} pages " + f"for {symbol!r} at {timeframe!r}; refusing to loop further" + ) + + def get_balances(self) -> list[Balance]: + """Cash and share balances as domain types, never Alpaca's raw dicts. + + **The USD row surfaces T+1 settlement honestly** (FR-6): `available` is the + account's `buying_power` clamped at `cash`. On the cash accounts this adapter is + scoped to (`multiplier == 1`), Alpaca documents `buying_power == cash` -- and when + they differ, the gap is unsettled proceeds from a T+1 sale, spendable only after + settlement. Sourcing `available` from `buying_power` reports that spendable figure + without keel ever simulating settlement itself; clamping at `cash` means a margin + account (which this adapter does not trade) can never report leveraged buying + power as spendable either. + + Each position becomes one `Balance` under its symbol, `available` from + `qty_available` (shares free of holds) and `total` from `qty`. Short rows are + skipped: keel is long-only by construction, and reconciling a negative quantity + into rails that never expect one would mis-report the account worse than omitting + a state the engine cannot act on. + """ + transport = self._require_transport() + account = transport.get_account() + + cash = _decimal_or_none(_field(account, "cash")) or Decimal("0") + buying_power = _decimal_or_none(_field(account, "buying_power")) or Decimal("0") + balances = [ + Balance( + currency=str(_field(account, "currency", "USD") or "USD"), + available=min(buying_power, cash), + total=cash, + ) + ] + for raw in transport.get_positions() or []: + if str(_field(raw, "side", "long") or "long") != "long": + continue + balances.append( + Balance( + currency=str(_field(raw, "symbol")), + available=_decimal_or_none(_field(raw, "qty_available")) or Decimal("0"), + total=_decimal_or_none(_field(raw, "qty")) or Decimal("0"), + ) + ) + return balances + + def _reject_unsupported(self, spec: OrderSpec) -> None: + if spec.kind not in _CAPABILITIES.supported_orders: + raise UnsupportedOrder( + f"alpaca does not support order kind {spec.kind!r} " + f"(supported: {', '.join(sorted(_CAPABILITIES.supported_orders))})" + ) + + def preview_order(self, spec: OrderSpec) -> Preview: + """Synthesize a preview. Always `synthetic=True` -- Alpaca has no preview + endpoint, so no number below is a quote the venue stands behind. + + This follows the `keel_broker_robinhood` synthesized-preview precedent: read the + venue's own latest quote (the book), price the order off the side the order will + cross -- the ask for a buy, the bid for a sell -- and compute the fee ourselves. + + What is exact versus estimated, field by field: + + * `est_quote_size` for `market_ioc_quote` is the notional itself (the number the + caller asked to spend); for `limit_gtc`/`stop_limit_gtc` it is + `base_size * limit_price`, a BOUND rather than a prediction (a limit never + fills worse than its limit); for `market_ioc_base` it is `base_size *` the + crossed side of the quote -- a genuine guess the fill can and will miss. + * `est_base_size` is exact for every base-sized kind; for `market_ioc_quote` it + is the notional divided by the crossed side of the quote, an estimate. + * `est_fee` is zero on buys (commission-free, and every pass-through fee this + venue charges is sell-side) and the `fees.py` regulatory model on sells. + + **Every path that could not price the order populates `errors`**: Alpaca + documents `ap`/`bp` as 0 when there is no active ask/bid, and a zero side must + never be divided or multiplied into a size -- a fabricated position at the + confirm gate is the most approvable thing a preview can display. `detail` + carries `best_bid`/`best_ask` (feeding the #332 warning and the #350 spread + gate), the price/cost/fee bases, and the declared data tier. + """ + self._reject_unsupported(spec) + symbol = to_symbol(spec.product_id) + response = self._require_transport().get_latest_quote(symbol, self._data_feed) + quote = _field(response, "quote") or {} + + bid = _decimal_or_none(_field(quote, "bp")) + ask = _decimal_or_none(_field(quote, "ap")) + errors: list[str] = [] + detail: dict[str, str] = { + "best_bid": _render(bid) if bid is not None and bid > 0 else "none", + "best_ask": _render(ask) if ask is not None and ask > 0 else "none", + "data_feed": self._data_feed, + } + + # The side of the book this order crosses: a buy lifts the ask, a sell hits the + # bid. The other side is never used to price it -- that would report the wrong + # side of the spread, optimistic in exactly the direction that flatters a + # synthesized preview (translate's `to_price_side` rule at Robinhood). + is_buy = spec.side is Side.BUY + crossed = ask if is_buy else bid + price_basis = "latest_quote_ask" if is_buy else "latest_quote_bid" + side_name = "ask" if is_buy else "bid" + + base_size: Decimal + quote_size: Decimal + if isinstance(spec, MarketIOCByQuote): + # The notional is exact; the share count is derived from the crossed side. + quote_size = spec.quote_size + if crossed is None or crossed <= 0: + base_size = Decimal("0") + detail["cost_basis"] = "unpriced" + errors.append( + f"alpaca reported no active {side_name} for {symbol}; est_base_size is " + "NOT priced and must not be read as a position size" + ) + else: + base_size = quote_size / crossed + detail["cost_basis"] = "notional_over_quote" + elif isinstance(spec, LimitGTC | StopLimitGTC): + base_size = spec.base_size + quote_size = spec.base_size * spec.limit_price + price_basis = "limit_price" + detail["cost_basis"] = "base_size_x_limit_price" + else: + base_size = spec.base_size + if crossed is None or crossed <= 0: + quote_size = Decimal("0") + detail["cost_basis"] = "unpriced" + errors.append( + f"alpaca reported no active {side_name} for {symbol}; est_quote_size and " + "est_fee are NOT priced and must not be read as a cost or a proceeds figure" + ) + else: + quote_size = base_size * crossed + detail["cost_basis"] = "base_size_x_quote" + + detail["price_basis"] = price_basis + if price_basis.startswith("latest_quote") and crossed is not None and crossed > 0: + detail["price"] = _render(crossed) + else: + detail["price"] = "unpriced" + + total_fee, sec_fee, taf = estimate_regulatory_fees(spec.side, base_size, quote_size) + detail["fee_basis"] = ( + "sell_side_regulatory_passthrough" if not is_buy else "commission_free_buy" + ) + detail["commission"] = "0" + detail["sec_fee"] = _render(sec_fee) + detail["taf"] = _render(taf) + + return Preview( + product_id=spec.product_id, + side=spec.side, + est_base_size=base_size, + est_quote_size=quote_size, + est_fee=total_fee, + synthetic=True, + detail=detail, + errors=tuple(errors), + ) + + def place_order(self, spec: OrderSpec) -> PlaceResult: + """Place a live order. A fresh `client_order_id` per call, and the venue's + explicit refusals mapped to a failed `PlaceResult`. + + ⚠️ **A fresh uuid per ATTEMPT means no placement retry is ever deduplicated** -- + the posture `keel_broker_robinhood.place_order` documents, with the same tradeoff: + a caller retrying after a timeout may place twice, because the retry carries a + different id and Alpaca has nothing to match it against. + + **Alpaca answers rejections as HTTP errors, unlike Robinhood's happy-path failed + state.** 403 (insufficient buying power) and 422 (invalid body) are the venue + saying "no" to THIS order, so they become `PlaceResult(success=False, reason=...)` + -- but every other error propagates: a 5xx or a timeout during placement is an + UNKNOWN outcome, and mapping it to a refusal would read as "safe to try again" + while the first order may be live at the venue. + + A 200 response whose status is terminal (`_PLACEMENT_REJECTED_STATUSES`) is a + not-live order, not a placed one -- recorded as failure with the id in `reason`, + never handed back as a handle on a resting order. + """ + self._reject_unsupported(spec) + body = to_order_body(spec, client_order_id=str(uuid.uuid4())) + try: + response = self._require_transport().create_order(body) + except AlpacaAPIError as exc: + if exc.status_code in _VENUE_REFUSAL_STATUSES: + return PlaceResult(success=False, broker_order_id=None, reason=exc.message) + raise + + order_id = _field(response, "id") + if order_id is None: + return PlaceResult( + success=False, + broker_order_id=None, + reason="alpaca accepted the request but returned no order id", + ) + status = str(_field(response, "status", "") or "") + if status in _PLACEMENT_REJECTED_STATUSES: + return PlaceResult( + success=False, + broker_order_id=None, + reason=( + f"alpaca returned order {order_id} in status {status!r}: the venue " + "rejected this order, so it is not resting and must not be recorded as placed" + ), + ) + return PlaceResult(success=True, broker_order_id=str(order_id)) + + def get_fee_summary(self) -> FeeSummary: + """Alpaca's Trading API publishes nothing a `FeeSummary` would assert: no fee + tiers (commission is a flat zero), no fees-paid total, and no volume window. + Declaring the gap and refusing is the honest answer -- the `FakeAdapter` + precedent -- where a fabricated `fees_usd=0` would read as coverage. The fee + honesty this venue needs lives in `preview_order` via `fees.py`.""" + raise NotImplementedError("alpaca's trading API reports no fee or volume summary") + + def get_order(self, order_id: str) -> OrderStatus: + """Observed state of a previously placed order, money fields as `Decimal`. + + `total_fees` is always zero, and that is a statement about the API, not a claim + that orders trade free: Alpaca's order object carries no fee field -- sell-side + regulatory fees are netted from proceeds and only surface account-wide + (`pending_reg_taf_fees` on the account). Zero here means "not observable per + order", and the preview's `est_fee` carries the modelled cost instead. + + An id the venue does not recognise comes back as a normal `OrderStatus` with + status `FAILED` and zeroed money, never an exception -- `OrderStatus`'s contract + is arithmetic without special-casing, and the 404-to-`None`-to-FAILED split + (transport-to-adapter) keeps a network blip from ever becoming "order gone". + """ + response = self._require_transport().get_order(order_id) + if response is None: + return _terminal_unknown(order_id) + return OrderStatus( + order_id=str(_field(response, "id", order_id) or order_id), + status=to_port_status(_field(response, "status")), + filled_size=_decimal_or_none(_field(response, "filled_qty")) or Decimal("0"), + average_filled_price=_decimal_or_none(_field(response, "filled_avg_price")) + or Decimal("0"), + total_fees=Decimal("0"), + ) + + def cancel_order(self, order_id: str) -> bool: + """Cancel one resting order. `True` only if the venue CONFIRMS it. + + Alpaca's DELETE /v2/orders/{id} answers 204 No Content on confirmation -- a + status about the order, not an acknowledgement of the request (the Robinhood v1 + text-ack failure this port's boolean exists to prevent). 404 ("order not found") + and 422 ("order status is not cancelable", e.g. already filled) are not + confirmations, so they answer `False`. + + **A transport failure also returns `False` rather than propagating.** This runs + on the executor's exit path while unwinding a position; an exception escaping + here can abort the unwind partway and leave the position and its resting orders + live, which is strictly worse than a `False` that keeps the engine believing the + order may still be resting -- the belief that keeps it watching. The next + reconciliation poll re-reads the order from the venue either way. + """ + try: + status = int(self._require_transport().cancel_order(order_id)) + except Exception: + return False + return status == 204 + + +def _decimal_or_none(value: Any) -> Decimal | None: + """Parse one JSON leaf as a `Decimal`, or `None` if absent or not a number. + + The venue mixes quoted (`"131.56"`) and unquoted (`131.56`) money fields -- the + transport already parses unquoted numbers as `Decimal`, and `Decimal(str(value))` + here lands both shapes on the same exact number. `None` rather than zero: an absent + number and a zero number must never be the same value at a preview gate. + """ + if value is None or isinstance(value, bool): + return None + try: + return Decimal(str(value)) + except (InvalidOperation, ValueError, TypeError): + return None + + +def _terminal_unknown(order_id: str) -> OrderStatus: + """The answer for an id the venue does not recognise: `FAILED`, money zeroed.""" + return OrderStatus( + order_id=order_id, + status="FAILED", + filled_size=Decimal("0"), + average_filled_price=Decimal("0"), + total_fees=Decimal("0"), + ) + + +__all__ = ["AlpacaAdapter"] diff --git a/packages/keel-broker-alpaca/keel_broker_alpaca/fees.py b/packages/keel-broker-alpaca/keel_broker_alpaca/fees.py new file mode 100644 index 00000000..6c9bf6b6 --- /dev/null +++ b/packages/keel-broker-alpaca/keel_broker_alpaca/fees.py @@ -0,0 +1,78 @@ +"""Alpaca's US-equities cost model: commission-free, with sell-side regulatory +pass-throughs (PRD FR-7). + +Alpaca charges no commission on US equities, but SELLS carry regulatory fees the venue +passes through at cost. Every rate in this module is a constant with its provenance +inline, because a rate that silently drifts is a cost model that quietly mis-prices +every sell preview -- the PRD §8 "regulatory drift" risk, met with "the cost model is +versioned and re-measured, not assumed". + +Provenance (all read 2026-08-17): + +* **SEC Section 31 fee** -- charged on SELLS, per $1,000,000 of principal. Alpaca's own + regulatory-fees page (https://alpaca.markets/support/regulatory-fees) states the + current rate as $22.90 per $1M ($27.80 previously). The SEC adjusts this rate + periodically by fee-rate advisory (advisory 2026-2 moves it to $20.60 per $1M as of + 2026-04-04); the venue's published figure is the one encoded, and the drift is a + documented re-measurement point, not a silent correction. +* **FINRA Trading Activity Fee (TAF)** -- charged on SELLS, per share, capped per trade. + The cap ($8.30 for equities) is on Alpaca's page above; the per-share rate ($0.000166) + is FINRA's, Schedule A to the FINRA By-Laws §4(b)(7) (SR-FINRA-2020-032, in force since + 2021-01-01; reaffirmed by SR-FINRA-2024-019). +* **CAT** (Consolidated Audit Trail, buys and sells) is a documented omission: the PRD + names the two pass-throughs above, and CAT rounds to fractions of a cent per trade. + +The one consumer of this module is `AlpacaAdapter.preview_order`: `est_fee` on a sell is +computed here, never invented by the caller, and `est_fee` on a buy is honestly zero. +""" + +from __future__ import annotations + +from decimal import Decimal + +from keel_core.types import Side + +#: Alpaca's commission on US equities: zero (docs.alpaca.markets, "Regulatory Fees"). +#: Zero is a claim this venue really makes -- it is not a placeholder for "unknown". +COMMISSION_RATE: Decimal = Decimal("0") + +#: SEC Section 31 fee, per $1,000,000 of sale proceeds. See the module docstring for the +#: provenance and the periodic-adjustment caveat. +SEC_SECTION_31_PER_MILLION: Decimal = Decimal("22.90") + +#: FINRA Trading Activity Fee per share sold. +TAF_PER_SHARE: Decimal = Decimal("0.000166") + +#: FINRA's per-trade maximum for the equity TAF -- the cap Alpaca's fee page names. +TAF_MAX_PER_TRADE: Decimal = Decimal("8.30") + +_MILLION: Decimal = Decimal(1_000_000) + + +def estimate_regulatory_fees( + side: Side, shares: Decimal, proceeds: Decimal +) -> tuple[Decimal, Decimal, Decimal]: + """Estimate the pass-through regulatory fees for one order. + + Returns `(total, sec_section_31, taf)`. A BUY pays nothing: every fee this model + carries is sell-side, and commission is zero. A SELL pays Section 31 on proceeds and + TAF on shares, with the TAF capped at its per-trade maximum. + + The inputs are the preview's own estimates, so the output is an estimate too -- which + is exactly why it feeds `Preview.est_fee` with `synthetic=True` rather than anything + that could read as a venue quote. + """ + if side is Side.BUY: + return Decimal("0"), Decimal("0"), Decimal("0") + sec_fee = proceeds * SEC_SECTION_31_PER_MILLION / _MILLION + taf = min(shares * TAF_PER_SHARE, TAF_MAX_PER_TRADE) + return sec_fee + taf, sec_fee, taf + + +__all__ = [ + "COMMISSION_RATE", + "SEC_SECTION_31_PER_MILLION", + "TAF_MAX_PER_TRADE", + "TAF_PER_SHARE", + "estimate_regulatory_fees", +] diff --git a/packages/keel-broker-alpaca/keel_broker_alpaca/py.typed b/packages/keel-broker-alpaca/keel_broker_alpaca/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/packages/keel-broker-alpaca/keel_broker_alpaca/translate.py b/packages/keel-broker-alpaca/keel_broker_alpaca/translate.py new file mode 100644 index 00000000..ba87de0e --- /dev/null +++ b/packages/keel-broker-alpaca/keel_broker_alpaca/translate.py @@ -0,0 +1,242 @@ +"""The one place keel's order model becomes Alpaca's order-body and status vocabulary. + +Everything Alpaca-specific about order shape and state spelling lives here, mirroring +`keel_broker_coinbase.translate` and `keel_broker_robinhood.translate`. Three Alpaca +specifics set it apart from both siblings: + +1. Equity symbols carry no quote leg: keel's `AAPL-USD` is Alpaca's `AAPL`. The USD quote + leg is still REQUIRED on input and anything else is refused, because accepting `AAPL-EUR` + would trade a different settlement asset than the caller named. +2. Market orders are sized by `qty` OR `notional` -- the only venue in this workspace whose + market surface covers BOTH of the port's sizing bases natively, which is why this is the + first adapter that can declare `market_ioc_quote`. +3. Alpaca spells a cancelled order's terminal state `canceled` (single `l`); keel's port + spells it `CANCELLED`. `STATUS_TO_PORT_STATUS` is the only place the two meet. + +Money and size values render through `_render` (fixed-point), never `str()` and never +`float`: `str(Decimal)` switches to scientific notation at small magnitudes, and `"1E-8"` +in a `notional`/`qty` field is a malformed order body -- the failure +`keel_broker_robinhood.translate._render` documents, inherited verbatim. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal +from typing import Any, assert_never + +from keel_broker_api.orders import ( + LimitGTC, + MarketIOCByBase, + MarketIOCByQuote, + OrderSpec, + StopLimitGTC, +) +from keel_broker_api.port import UnsupportedOrder +from keel_core.types import Granularity, Side + +#: Alpaca's equities settle in USD only. A product id quoting anything else names a +#: different settlement asset and is refused rather than rewritten. +QUOTE_CURRENCY: str = "USD" + +#: keel `Granularity` -> Alpaca v2 bars `timeframe`. Exactly the three series the PRD +#: commits to (FR-5): 15-minute confirmation candles, hourly trading bars, daily bias +#: bars. Every other granularity the port defines has NO Alpaca mapping here, so +#: `to_timeframe` refuses it rather than approximating it -- a silently-substituted +#: timeframe corrupts every downstream indicator. +TIMEFRAME_BY_GRANULARITY: dict[Granularity, str] = { + Granularity.FIFTEEN_MINUTE: "15Min", + Granularity.ONE_HOUR: "1Hour", + Granularity.ONE_DAY: "1Day", +} + +#: Alpaca order `status` -> keel's port status. The venue's enum is taken verbatim from +#: the order schema (docs.alpaca.markets, "Order": new, partially_filled, filled, +#: done_for_day, canceled, expired, replaced, pending_cancel, pending_replace, accepted, +#: pending_new, accepted_for_bidding, stopped, rejected, suspended, calculated, held). +#: +#: Judgement calls, so they are written down: `done_for_day` maps to PENDING, not OPEN -- +#: the order exists at the venue but is not working until the next session, and PENDING is +#: the spelling that keeps reconciliation observing rather than acting. `replaced` maps to +#: CANCELLED because this id is terminal (its replacement carries a different id); an +#: unmapped status resolves to PENDING, never FAILED -- the +#: `keel_broker_robinhood.translate.to_port_status` rule: silence is not evidence of death. +STATE_TO_PORT_STATUS: dict[str, str] = { + "new": "OPEN", + "accepted": "OPEN", + "accepted_for_bidding": "OPEN", + "partially_filled": "OPEN", + "pending_new": "PENDING", + "pending_cancel": "PENDING", + "pending_replace": "PENDING", + "done_for_day": "PENDING", + "calculated": "PENDING", + "held": "PENDING", + "filled": "FILLED", + "canceled": "CANCELLED", + "expired": "EXPIRED", + "replaced": "CANCELLED", + "rejected": "FAILED", + "stopped": "FAILED", + "suspended": "FAILED", +} + + +def _render(value: Decimal) -> str: + """Render a money or size `Decimal` positionally, for a JSON field that has no + exponent form. + + `format(value, "f")` is the fixed-point renderer: positional at every magnitude, no + exponent ever, and unlike `f"{value:.8f}"` it neither truncates nor rounds, so the + string still carries the caller's exact value. Fractional shares make this load- + bearing here the same way satoshi quantities do at Robinhood. + """ + return format(value, "f") + + +def to_symbol(product_id: str) -> str: + """Render a keel product id as Alpaca's symbol, refusing anything not settled in USD. + + Alpaca's equity symbols carry no quote leg, so `AAPL-USD` becomes `AAPL` -- but the + quote leg is validated first, because a non-USD product id would settle against a + different asset than the caller named, silently. A product id that is not + `BASE-QUOTE` shaped at all is refused rather than guessed at, for the same reason as + `keel_broker_robinhood.translate.to_symbol` refuses it. + """ + parts = product_id.split("-") + if len(parts) != 2 or not parts[0] or not parts[1]: + raise UnsupportedOrder( + f"alpaca requires a BASE-QUOTE product id, got {product_id!r}" + ) + base, quote = parts + if quote.upper() != QUOTE_CURRENCY: + raise UnsupportedOrder( + f"alpaca only trades USD-quoted equities; product {product_id!r} quotes " + f"{quote.upper()!r}, which would settle against a different asset than requested" + ) + return base.upper() + + +def to_timeframe(granularity: Granularity) -> str: + """Map a `Granularity` onto an Alpaca bars `timeframe`, refusing unmapped ones. + + `ValueError` is the port's sanctioned "this venue does not serve that timeframe" + signal -- the conformance suite's `_any_candles` helper catches it per granularity, + and a caller who reads it goes looking for a supported series instead of receiving a + silently-wrong one. + """ + try: + return TIMEFRAME_BY_GRANULARITY[granularity] + except KeyError: + supported = ", ".join(sorted(set(TIMEFRAME_BY_GRANULARITY.values()))) + raise ValueError( + f"alpaca does not serve timeframe {granularity.value!r} " + f"(supported timeframes: {supported})" + ) from None + + +def to_side(side: Side) -> str: + """Render keel's `Side` as Alpaca's lowercase order `side` (`"buy"` / `"sell"`).""" + return "buy" if side is Side.BUY else "sell" + + +def to_order_body(spec: OrderSpec, *, client_order_id: str) -> dict[str, Any]: + """Render `spec` as the JSON body for `POST /v2/orders`. + + Two venue rules shape the market legs (docs.alpaca.markets, "Create Order"): + + * `notional` (dollar amount) works ONLY with `type: market` and `time_in_force: + day`, and cannot be combined with `qty` -- so `MarketIOCByQuote` pins all three. + * `qty` is fractionable, and fractional quantities pass through `_render` unchanged: + rounding here would change the position size the caller asked for. + + `extended_hours: False` is sent on EVERY body. Overnight/extended sessions are OFF by + posture (PRD FR-9: thinner liquidity would hold the #350 spread gate constantly), and + stating it explicitly keeps a future default change at the venue from turning it on. + """ + match spec: + case MarketIOCByQuote(): + return { + "symbol": to_symbol(spec.product_id), + "client_order_id": client_order_id, + "notional": _render(spec.quote_size), + "side": to_side(spec.side), + "type": "market", + "time_in_force": "day", + "extended_hours": False, + } + case MarketIOCByBase(): + return { + "symbol": to_symbol(spec.product_id), + "client_order_id": client_order_id, + "qty": _render(spec.base_size), + "side": to_side(spec.side), + "type": "market", + "time_in_force": "day", + "extended_hours": False, + } + case LimitGTC(): + return { + "symbol": to_symbol(spec.product_id), + "client_order_id": client_order_id, + "qty": _render(spec.base_size), + "side": to_side(spec.side), + "type": "limit", + "time_in_force": "gtc", + "limit_price": _render(spec.limit_price), + "extended_hours": False, + } + case StopLimitGTC(): + return { + "symbol": to_symbol(spec.product_id), + "client_order_id": client_order_id, + "qty": _render(spec.base_size), + "side": to_side(spec.side), + "type": "stop_limit", + "time_in_force": "gtc", + "stop_price": _render(spec.stop_price), + "limit_price": _render(spec.limit_price), + "extended_hours": False, + } + case _: + assert_never(spec) + + +def to_port_status(status: str | None) -> str: + """Alpaca order `status` -> the port's vocabulary, defaulting to `"PENDING"`.""" + if status is None: + return "PENDING" + return STATE_TO_PORT_STATUS.get(status, "PENDING") + + +def to_rfc3339(ts: int) -> str: + """Render epoch seconds as the RFC3339 UTC form Alpaca's `start`/`end` take. + + Seconds precision with an explicit `Z`: no offset to misread as local time, no + fractional part for the venue to parse differently than we wrote it. + """ + return datetime.fromtimestamp(ts, tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def to_unix_seconds(value: str) -> int: + """Parse an Alpaca RFC3339 timestamp into epoch seconds. + + Venue timestamps can carry fractional seconds and (per the schema) explicit offsets; + fractional seconds truncate because `Candle.ts` is whole seconds and a bar's open + time is second-aligned anyway. + """ + return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()) + + +__all__ = [ + "QUOTE_CURRENCY", + "STATE_TO_PORT_STATUS", + "TIMEFRAME_BY_GRANULARITY", + "to_order_body", + "to_port_status", + "to_rfc3339", + "to_side", + "to_symbol", + "to_timeframe", + "to_unix_seconds", +] diff --git a/packages/keel-broker-alpaca/keel_broker_alpaca/transport.py b/packages/keel-broker-alpaca/keel_broker_alpaca/transport.py new file mode 100644 index 00000000..f728d92c --- /dev/null +++ b/packages/keel-broker-alpaca/keel_broker_alpaca/transport.py @@ -0,0 +1,383 @@ +"""Structural transport interface, response helpers, and the network-backed Alpaca client. + +Everything Alpaca-specific about *talking to the venue* lives here, so `adapter.py` and +`translate.py` never see an HTTP status code, a header, or a credential. The boundary is +`keel_broker_robinhood.transport`'s, reused for the same two reasons: testability (the +`Transport` Protocol lets tests inject canned fixtures with zero network) and import +safety (`requests` is imported at call time, so importing the Protocol never forces the +HTTP stack on a caller that only wants `capabilities()`). + +Two Alpaca specifics shape this module: + +1. **Paper and live are different hosts** (`paper-api.alpaca.markets` vs + `api.alpaca.markets`), selected by an explicit endpoint name and NOTHING else -- there + is deliberately no free-form base-URL parameter for the trading host, so no + configuration can ever point a paper credential at the live venue (PRD FR-11, the + #233 capability stance). Market data is a third host (`data.alpaca.markets`) shared by + both environments. +2. **Authentication is two headers on every request** (`APCA-API-KEY-ID` and + `APCA-API-SECRET-KEY`), on the trading host and the data host alike. + +Like Robinhood (#217 F6), this venue is NOT internally consistent about JSON quoting: +market data (bars, quotes) sends money values as UNQUOTED numbers while the account and +order objects QUOTE theirs. Every response is therefore decoded with +`json.loads(..., parse_float=Decimal)` -- the only place the original digits still exist +-- and the adapter additionally does `Decimal(str(value))` so both shapes land on the +same exact number. +""" + +from __future__ import annotations + +import json +import time +from collections.abc import Callable +from decimal import Decimal +from typing import Any, Protocol +from urllib.parse import quote, urlencode + +#: The two documented trading hosts, keyed by the endpoint name that selects one. This map +#: is the ONLY path from an environment choice to a host (docs.alpaca.markets, +#: "Authentication": paper `https://paper-api.alpaca.markets`, live +#: `https://api.alpaca.markets`). +TRADING_HOSTS: dict[str, str] = { + "paper": "https://paper-api.alpaca.markets", + "live": "https://api.alpaca.markets", +} + +PAPER_TRADING_HOST: str = TRADING_HOSTS["paper"] +LIVE_TRADING_HOST: str = TRADING_HOSTS["live"] + +#: Market data is served from a single host for both environments. +DATA_HOST: str = "https://data.alpaca.markets" + +#: The market-data tiers this adapter can declare (PRD FR-5). IEX is the free tier; SIP is +#: the subscribed one. The choice is a DECLARED capability, never an assumption -- the +#: venue's server-side default is SIP, which silently fails for keys without the +#: subscription, so every market-data request names its feed explicitly. +SUPPORTED_DATA_FEEDS: frozenset[str] = frozenset({"iex", "sip"}) + +#: The bars endpoint's `adjustment` policy: split-adjusted candles (docs.alpaca.markets, +#: "Stock Bars" -- `raw`, `split`, `dividend`, `spin-off`, `all`). The PRD's candle policy +#: (FR-10) is "backtests on split-adjusted series; the cache records which" -- this is the +#: recorded which, stated once, in the one place that builds the request. +BAR_ADJUSTMENT: str = "split" + +#: First backoff delay for a 429 without a `Retry-After` header, doubling per retry. +_BACKOFF_SECONDS: float = 0.5 + + +class AlpacaAPIError(RuntimeError): + """A non-2xx answer from the venue, with its status code and message kept together. + + The code is load-bearing, not decoration: Alpaca signals order rejections as HTTP + statuses (403 insufficient buying power, 422 invalid body), so `place_order` needs the + number to tell an explicit venue refusal from an infrastructure failure with an + UNKNOWN outcome -- the two must not be handled the same way. + """ + + def __init__(self, status_code: int, message: str) -> None: + super().__init__(f"alpaca API error {status_code}: {message}") + self.status_code = status_code + self.message = message + + +def _field(obj: Any, key: str, default: Any = None) -> Any: + """Read `key` from a plain dict, an attribute-bearing object, or `None`. + + The live transport always answers with dicts, but `Transport` is a Protocol and tests + may satisfy it with attribute-bearing objects; the `None` branch covers absent nested + blocks (e.g. an order with no `filled_avg_price`). Mirrors the sibling adapters' + `_field` helpers. + """ + if obj is None: + return default + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + +class Transport(Protocol): + """Structural interface the adapter depends on. + + Every method returns `Any` deliberately: the Protocol pins WHICH network calls exist + and what arguments they take, not the response shape -- response shape is + `translate.py`'s and `adapter.py`'s problem, read through `_field` so a test fixture + (a plain dict) and a live JSON response are indistinguishable to callers. + """ + + def get_account(self) -> Any: ... + + def get_positions(self) -> Any: ... + + def get_clock(self) -> Any: ... + + def create_order(self, body: dict[str, Any]) -> Any: ... + + def get_order(self, order_id: str) -> Any: ... + + def cancel_order(self, order_id: str) -> Any: ... + + def get_bars( + self, + symbol: str, + timeframe: str, + start: str, + end: str, + feed: str, + page_token: str | None = None, + ) -> Any: ... + + def get_latest_quote(self, symbol: str, feed: str) -> Any: ... + + +class AlpacaTransport: + """The live, network-backed `Transport`: header-authed JSON over HTTPS. + + The trading host is derived from `endpoint` and cannot be overridden -- a paper + configuration must be structurally unable to reach the live venue. The `trading_host` + and `data_host` constructor parameters exist for tests pointing at a local recorder; + they are explicit escapes, not a configuration surface, and nothing in this workspace + passes them in production code. + """ + + def __init__( + self, + key_id: str, + secret_key: str, + *, + endpoint: str = "paper", + data_feed: str = "iex", + timeout: float = 10.0, + max_attempts: int = 3, + sleep: Callable[[float], None] = time.sleep, + trading_host: str | None = None, + data_host: str | None = None, + ) -> None: + if endpoint not in TRADING_HOSTS: + raise ValueError( + f"endpoint must be one of {sorted(TRADING_HOSTS)}, got {endpoint!r} -- the " + "trading host is derived from this choice, never configured as a URL, so a " + "paper credential cannot be pointed at the live venue" + ) + if data_feed not in SUPPORTED_DATA_FEEDS: + raise ValueError( + f"data_feed must be one of {sorted(SUPPORTED_DATA_FEEDS)}, got {data_feed!r} " + "-- the data tier is a declared capability, not a server-side default" + ) + self._key_id = key_id + self._secret_key = secret_key + self._endpoint = endpoint + self.data_feed = data_feed + self._timeout = timeout + self._max_attempts = max_attempts + self._sleep = sleep + self._trading_host = trading_host if trading_host is not None else TRADING_HOSTS[endpoint] + self._data_host = data_host if data_host is not None else DATA_HOST + + @property + def endpoint(self) -> str: + """Which environment ("paper" | "live") this transport was constructed for.""" + return self._endpoint + + @property + def trading_host(self) -> str: + return self._trading_host + + @property + def data_host(self) -> str: + return self._data_host + + def _headers(self) -> dict[str, str]: + """The two headers Alpaca requires on every request, both hosts alike.""" + return { + "APCA-API-KEY-ID": self._key_id, + "APCA-API-SECRET-KEY": self._secret_key, + } + + def _retry_delay(self, response: Any, attempt: int) -> float: + """How long to wait before the next attempt after a 429. + + The venue's own `Retry-After` header, when sent, IS the delay; otherwise this + backs off exponentially from `_BACKOFF_SECONDS` (0.5s, 1s, 2s...). Alpaca's data + endpoints advertise their limits through `X-RateLimit-*` headers and answer 429 + when crossed (PRD FR-11); keel's cycle cadence sits far below any limit, so this + is resilience, not throughput engineering. + """ + headers = getattr(response, "headers", None) or {} + for name, value in headers.items(): + if str(name).lower() == "retry-after": + try: + return float(str(value)) + except (TypeError, ValueError): + break # a malformed hint falls through to the computed backoff + backoff: float = _BACKOFF_SECONDS * (2 ** (attempt - 1)) + return backoff + + def _send( + self, + method: str, + url: str, + body: dict[str, Any] | None = None, + ) -> Any: + """Send one request, retrying ONLY 429s, and return the raw response. + + Everything else is returned as-is (the caller decides what a 4xx means), and a + 429 that exhausts `max_attempts` is returned too -- the caller raises + `AlpacaAPIError` from it like any other non-2xx, after the retries have run. + """ + import requests # deferred: see the module docstring's "import safety" note. + + response = None + for attempt in range(1, self._max_attempts + 1): + response = requests.request( + method, + url, + headers=self._headers(), + json=body, + timeout=self._timeout, + ) + if response.status_code != 429 or attempt == self._max_attempts: + return response + self._sleep(self._retry_delay(response, attempt)) + return response + + def _api_error(self, response: Any) -> AlpacaAPIError: + """Build the typed error for a non-2xx response, reading the venue's message. + + Alpaca answers errors as `{"message": ...}` (docs.alpaca.markets, the shared + error schema); an unparseable body still raises with the status, because the + status alone already forces the right handling. + """ + status = int(getattr(response, "status_code", 0)) + message = "" + try: + decoded = json.loads(response.text) + if isinstance(decoded, dict): + message = str(decoded.get("message", "")) + except (ValueError, AttributeError): + pass + return AlpacaAPIError(status, message or f"HTTP {status} with no error body") + + def _request_json( + self, + method: str, + path: str, + *, + host: str | None = None, + params: dict[str, Any] | None = None, + body: dict[str, Any] | None = None, + ) -> Any: + """Build, send, and decode one JSON request; raise `AlpacaAPIError` for non-2xx. + + The query string is built here, by hand, from SORTED params: one deterministic + URL per request, so a recorded call is exactly what the venue received. Like the + Robinhood transport, `quote_via=quote` (not `quote_plus`) so a `+` in a value is + never decoded server-side as a space, and `safe=""` so nothing rides unencoded. + + `parse_float=Decimal`, never `response.json()`: this venue mixes quoted and + unquoted money fields (see the module docstring), and the parser is the only + place an unquoted number's original digits still exist. + """ + base = host if host is not None else self._trading_host + query = "" + if params: + query = "?" + urlencode( + [(k, v) for k, v in sorted(params.items()) if v is not None], + quote_via=quote, + safe="", + ) + response = self._send(method, f"{base}{path}{query}", body=body) + if int(getattr(response, "status_code", 0)) >= 400: + raise self._api_error(response) + if not response.text: + return None + return json.loads(response.text, parse_float=Decimal) + + def get_account(self) -> Any: + return self._request_json("GET", "/v2/account") + + def get_positions(self) -> Any: + # A bare array -- this endpoint has no envelope. + return self._request_json("GET", "/v2/positions") + + def get_clock(self) -> Any: + return self._request_json("GET", "/v2/clock") + + def create_order(self, body: dict[str, Any]) -> Any: + return self._request_json("POST", "/v2/orders", body=body) + + def get_order(self, order_id: str) -> Any: + """Fetch one order; `None` ONLY on a 404, like the Robinhood transport. + + `None` becomes a terminal FAILED one layer up, so any other failure must raise + rather than launder a network blip into "this order does not exist". + """ + try: + return self._request_json("GET", f"/v2/orders/{order_id}") + except AlpacaAPIError as exc: + if exc.status_code == 404: + return None + raise + + def cancel_order(self, order_id: str) -> Any: + """DELETE one order and return the HTTP status, which IS the venue's answer. + + 204 No Content is Alpaca's confirmation that the cancellation happened -- unlike + Robinhood v1's text acknowledgement, this status is a statement about the order. + 404 ("order not found") and 422 ("order status is not cancelable", e.g. already + filled) are returned as statuses rather than raised because both are ordinary + answers the adapter maps to `False`; every other failure raises. + """ + response = self._send("DELETE", f"{self._trading_host}/v2/orders/{order_id}") + status = int(getattr(response, "status_code", 0)) + if status < 400 or status in (404, 422): + return status + raise self._api_error(response) + + def get_bars( + self, + symbol: str, + timeframe: str, + start: str, + end: str, + feed: str, + page_token: str | None = None, + ) -> Any: + """One page of bars from the data host, `next_page_token` included for the caller + to thread (docs.alpaca.markets, "Stock Bars": `GET /v2/stocks/{symbol}/bars`).""" + params: dict[str, Any] = { + "timeframe": timeframe, + "start": start, + "end": end, + "feed": feed, + "adjustment": BAR_ADJUSTMENT, + } + if page_token is not None: + params["page_token"] = page_token + return self._request_json( + "GET", f"/v2/stocks/{symbol}/bars", host=self._data_host, params=params + ) + + def get_latest_quote(self, symbol: str, feed: str) -> Any: + """The latest NBBO quote: `{"quote": {"ap": ..., "bp": ..., ...}}`. + + The venue documents `ap`/`bp` as 0 when there is no active ask/bid -- a real + signal the adapter treats as "no book on that side", not a price of zero. + """ + return self._request_json( + "GET", f"/v2/stocks/{symbol}/quotes/latest", host=self._data_host, params={"feed": feed} + ) + + +__all__ = [ + "BAR_ADJUSTMENT", + "DATA_HOST", + "LIVE_TRADING_HOST", + "PAPER_TRADING_HOST", + "SUPPORTED_DATA_FEEDS", + "TRADING_HOSTS", + "AlpacaAPIError", + "AlpacaTransport", + "Transport", + "_field", +] diff --git a/packages/keel-broker-alpaca/pyproject.toml b/packages/keel-broker-alpaca/pyproject.toml new file mode 100644 index 00000000..f594a1ec --- /dev/null +++ b/packages/keel-broker-alpaca/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "keel-broker-alpaca" +version = "0.9.3" +description = "Alpaca Trading API adapter for keel (US equities, cash account)" +license = "Apache-2.0" +requires-python = ">=3.11" +# Siblings pinned `==` (see the root `pyproject.toml`); `requests` matches the +# `keel-broker-robinhood` transport convention -- raw REST over an injected transport, +# deliberately NO `alpaca-py` SDK: Alpaca's Trading + Market Data APIs are plain +# JSON-over-HTTPS with two header credentials, so the SDK would add a version-churning +# dependency for nothing the port needs. +dependencies = ["keel-core==0.9.3", "keel-broker-api==0.9.3", "requests>=2.32.0"] + +[project.entry-points."keel.brokers"] +alpaca = "keel_broker_alpaca:AlpacaAdapter" + +[build-system] +requires = ["uv_build>=0.10.4,<0.13.0"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-root = "" + +[tool.uv.sources] +keel-core = { workspace = true } +keel-broker-api = { workspace = true } diff --git a/pyproject.toml b/pyproject.toml index f35b4f70..8740e128 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ members = ["packages/*"] keel-core = { workspace = true } keel-broker-api = { workspace = true } keel-broker-coinbase = { workspace = true } +keel-broker-alpaca = { workspace = true } keel-broker-fake = { workspace = true } keel-broker-robinhood = { workspace = true } @@ -85,6 +86,11 @@ dev = [ # so the conformance suite actually runs against it in CI. Users who want the venue install # `keel-broker-robinhood` themselves and entry-point discovery picks it up. "keel-broker-robinhood", + # Dev-only for the same reason as Robinhood: Alpaca is an optional venue (the Phase 12 + # equities milestone). It rides the dev group so the conformance suite runs against it + # in CI; a deployment that wants equities installs `keel-broker-alpaca` itself and + # entry-point discovery picks it up. Nothing in `keel/` imports it. + "keel-broker-alpaca", ] # Ruff config lives in ruff.toml at the repo root. A ruff.toml takes precedence over @@ -115,6 +121,7 @@ warn_redundant_casts = true [[tool.mypy.overrides]] module = [ "keel_broker_api.*", + "keel_broker_alpaca.*", "keel_broker_coinbase.*", "keel_broker_fake.*", "keel_broker_robinhood.*", @@ -159,7 +166,7 @@ ignore_errors = true # Coverage is measured only when `--cov` is passed (see the pytest-cov note in [dependency-groups]). # This section exists so that a bare `--cov` -- with no value -- measures the right thing instead of -# whatever happened to get imported: the shipped code under `keel/` and the five workspace packages, +# whatever happened to get imported: the shipped code under `keel/` and the workspace packages, # and nothing else. `tests/` is excluded because a test file's own coverage is a tautology, and # `scripts/` because those are one-shot operator tools the suite does not drive. [tool.coverage.run] diff --git a/tests/broker_alpaca/__init__.py b/tests/broker_alpaca/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/broker_alpaca/test_adapter.py b/tests/broker_alpaca/test_adapter.py new file mode 100644 index 00000000..644badcf --- /dev/null +++ b/tests/broker_alpaca/test_adapter.py @@ -0,0 +1,692 @@ +"""Tests for `AlpacaAdapter`, the Alpaca Trading + Market Data API implementation of the +`Broker` port. + +Alpaca's paper environment is a live sandbox, but this suite still runs against a canned, +in-memory `FakeTransport` loaded from `tests/fixtures/alpaca_*.json` -- the fixture-driven +design mirrored from `tests/broker_robinhood/test_adapter.py`. No network call is made and +no order (paper or otherwise) is ever placed: the conformance suite calls `place_order`, so +a real transport here could place real orders. +""" + +from __future__ import annotations + +import json +from decimal import Decimal +from pathlib import Path +from typing import Any + +import pytest +from keel_broker_alpaca import AlpacaAdapter +from keel_broker_alpaca.fees import estimate_regulatory_fees +from keel_broker_alpaca.transport import ( + LIVE_TRADING_HOST, + PAPER_TRADING_HOST, + SUPPORTED_DATA_FEEDS, + TRADING_HOSTS, + AlpacaAPIError, + AlpacaTransport, +) +from keel_broker_api.orders import ( + LimitGTC, + MarketIOCByBase, + MarketIOCByQuote, + StopLimitGTC, +) +from keel_broker_api.port import UnsupportedOrder +from keel_broker_api.results import Balance, OrderStatus, PlaceResult, Preview +from keel_core.types import Granularity, Side + +FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" + +_PRODUCT = "AAPL-USD" + + +def load_fixture(name: str) -> dict[str, Any]: + """Decode a fixture the way `AlpacaTransport` decodes a live response. + + `parse_float=Decimal` for the same reason `tests/broker_robinhood/test_adapter.py` + states: Alpaca's market-data endpoints (bars, quotes) send money values as UNQUOTED + JSON numbers, and a fixture decoded through a binary `float` would hand the adapter + values the live path can never produce. + """ + with (FIXTURES_DIR / name).open() as f: + data: dict[str, Any] = json.load(f, parse_float=Decimal) + return data + + +def _full_transport() -> FakeTransport: + """A transport wired for every read path, the shape the conformance suite also uses.""" + return FakeTransport( + account=load_fixture("alpaca_account.json"), + positions=load_fixture("alpaca_positions.json"), + clock=load_fixture("alpaca_clock_open.json"), + placed=load_fixture("alpaca_order_placed.json"), + order=load_fixture("alpaca_order_filled.json"), + bars_pages=[load_fixture("alpaca_bars_page1.json"), load_fixture("alpaca_bars_page2.json")], + quote=load_fixture("alpaca_quote_latest.json"), + ) + + +class FakeTransport: + """Duck-types the `Transport` Protocol, returning fixtures and recording every call. + + `_issued_order_ids` mirrors the venue's own distinction: an id this transport handed + out via `create_order` is known, anything else is a 404 the venue never issued. + `cancel_order` answers with an HTTP status int because that status IS the venue's + whole cancel response (204 No Content on confirmation; 404/422 otherwise). + """ + + def __init__( + self, + *, + account: dict[str, Any] | None = None, + positions: list[dict[str, Any]] | None = None, + clock: dict[str, Any] | None = None, + placed: dict[str, Any] | None = None, + order: dict[str, Any] | None = None, + bars_pages: list[dict[str, Any]] | None = None, + quote: dict[str, Any] | None = None, + cancel_status: int = 204, + ) -> None: + self._account = account + self._positions = positions + self._clock = clock + self._placed = placed + self._order = order + self._bars_pages = bars_pages or [] + self._quote = quote + self._cancel_status = cancel_status + self.calls: dict[str, dict[str, Any]] = {} + self.call_counts: dict[str, int] = {} + self.create_order_bodies: list[dict[str, Any]] = [] + self._issued_order_ids: set[str] = set() + + def _record(self, name: str, **kwargs: Any) -> None: + self.calls[name] = kwargs + self.call_counts[name] = self.call_counts.get(name, 0) + 1 + + def get_account(self) -> Any: + self._record("get_account") + return self._account + + def get_positions(self) -> Any: + self._record("get_positions") + return self._positions + + def get_clock(self) -> Any: + self._record("get_clock") + return self._clock + + def create_order(self, body: dict[str, Any]) -> Any: + self._record("create_order", body=body) + self.create_order_bodies.append(body) + if self._placed is None: + return None + issued = self._placed.get("id") + if issued is not None: + self._issued_order_ids.add(issued) + return self._placed + + def get_order(self, order_id: str) -> Any: + self._record("get_order", order_id=order_id) + if order_id not in self._issued_order_ids: + return None + merged = dict(self._order or self._placed or {}) + merged["id"] = order_id + return merged + + def cancel_order(self, order_id: str) -> Any: + self._record("cancel_order", order_id=order_id) + if order_id not in self._issued_order_ids: + return 404 + return self._cancel_status + + def get_bars( + self, + symbol: str, + timeframe: str, + start: str, + end: str, + feed: str, + page_token: str | None = None, + ) -> Any: + """The next bars page: page one when no token, the linked page otherwise.""" + self._record( + "get_bars", + symbol=symbol, + timeframe=timeframe, + start=start, + end=end, + feed=feed, + page_token=page_token, + ) + if not self._bars_pages: + return {"bars": [], "next_page_token": None, "symbol": symbol} + return self._bars_pages[0] if page_token is None else self._bars_pages[1] + + def get_latest_quote(self, symbol: str, feed: str) -> Any: + self._record("get_latest_quote", symbol=symbol, feed=feed) + return self._quote + + +class _RejectingCreateTransport(FakeTransport): + """A `create_order` that raises the way the venue answers a rejected placement. + + Alpaca signals order rejections as HTTP errors on the otherwise-happy path (403 for + insufficient buying power, 422 for a malformed/invalid body), unlike Robinhood which + answers 200 with a failed order object -- so the transport converts the HTTP error + into `AlpacaAPIError` and the adapter must map it to `PlaceResult(success=False)`. + """ + + def __init__(self, error: AlpacaAPIError, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._error = error + + def create_order(self, body: dict[str, Any]) -> Any: + self._record("create_order", body=body) + raise self._error + + +# --------------------------------------------------------------------------------------------- +# Capability declaration (FR-2, FR-5, FR-11) +# --------------------------------------------------------------------------------------------- + + +class TestCapabilities: + def test_declares_the_alpaca_venue_usd_quotes_and_us_equities(self) -> None: + caps = AlpacaAdapter().capabilities() + assert caps.venue == "alpaca" + assert caps.quote_currencies == frozenset({"USD"}) + assert caps.asset_classes == frozenset({"equity"}) + + def test_supports_all_four_port_order_kinds(self) -> None: + """Alpaca's equity surface covers the port's whole order vocabulary: notional and + fractional-qty market orders, GTC limits, and GTC stop-limits (FR-3).""" + caps = AlpacaAdapter().capabilities() + assert caps.supported_orders == frozenset( + {"market_ioc_quote", "market_ioc_base", "limit_gtc", "stop_limit_gtc"} + ) + + def test_preview_is_declared_synthetic(self) -> None: + """Alpaca has no preview endpoint, so every Preview must label itself synthetic -- + the `keel_broker_robinhood` precedent for venues without a native preview.""" + caps = AlpacaAdapter().capabilities() + assert caps.supports_native_preview is False + assert caps.synthesizes_preview is True + assert caps.can_preview + + def test_fee_summary_is_declared_unsupported(self) -> None: + """Alpaca's Trading API publishes no fee tiers, no fees-paid total, and no volume + window -- the three things a `FeeSummary` would assert. Declaring the gap (the + `FakeAdapter` precedent) is honest where a fabricated zero rate would not be.""" + assert AlpacaAdapter().capabilities().supports_fee_summary is False + + def test_get_fee_summary_raises_as_declared(self) -> None: + with pytest.raises(NotImplementedError): + AlpacaAdapter().get_fee_summary() + + +# --------------------------------------------------------------------------------------------- +# Paper/live host isolation (FR-11, #233-aligned) +# --------------------------------------------------------------------------------------------- + + +class TestPaperLiveIsolation: + def test_the_only_endpoint_to_host_map_is_the_documented_one(self) -> None: + assert TRADING_HOSTS == { + "paper": "https://paper-api.alpaca.markets", + "live": "https://api.alpaca.markets", + } + assert PAPER_TRADING_HOST == "https://paper-api.alpaca.markets" + assert LIVE_TRADING_HOST == "https://api.alpaca.markets" + + def test_a_paper_configuration_cannot_reach_the_live_host(self) -> None: + """The adapter derives its trading host from an endpoint enum, never from a URL, + so no paper configuration can point at `api.alpaca.markets`: there is no parameter + that accepts one (FR-11, the #233 capability stance -- a paper key must never be + mistaken for a live one).""" + transport = AlpacaTransport("key-id", "secret", endpoint="paper") + assert transport.trading_host == PAPER_TRADING_HOST + assert LIVE_TRADING_HOST not in transport.trading_host + + live = AlpacaTransport("key-id", "secret", endpoint="live") + assert live.trading_host == LIVE_TRADING_HOST + assert PAPER_TRADING_HOST not in live.trading_host + + def test_an_unknown_endpoint_is_refused_at_construction(self) -> None: + for bad in ("production", "PAPER", "https://api.alpaca.markets", ""): + with pytest.raises(ValueError, match="endpoint"): + AlpacaTransport("key-id", "secret", endpoint=bad) + with pytest.raises(ValueError, match="endpoint"): + AlpacaAdapter(endpoint=bad) + + def test_the_data_tier_is_a_declared_choice_not_an_assumption(self) -> None: + """IEX (free) vs SIP is a declared capability (FR-5): the adapter names its feed + on every market-data request instead of letting the venue default it, because the + default (SIP) silently fails for keys without the subscription.""" + assert SUPPORTED_DATA_FEEDS == frozenset({"iex", "sip"}) + assert AlpacaAdapter().data_feed == "iex" + + with pytest.raises(ValueError, match="data_feed"): + AlpacaTransport("key-id", "secret", data_feed="sip-plus") + with pytest.raises(ValueError, match="data_feed"): + AlpacaAdapter(data_feed="sip-plus") + + def test_the_feed_is_sent_on_every_market_data_request(self) -> None: + transport = _full_transport() + adapter = AlpacaAdapter(transport, data_feed="sip") + adapter.get_candles(_PRODUCT, Granularity.ONE_DAY, 1_700_000_000, 1_700_086_400) + adapter.preview_order( + MarketIOCByBase(product_id=_PRODUCT, side=Side.SELL, base_size=Decimal("0.5")) + ) + + assert transport.calls["get_bars"]["feed"] == "sip" + assert transport.calls["get_latest_quote"]["feed"] == "sip" + + +# --------------------------------------------------------------------------------------------- +# Balances and positions (FR-6) +# --------------------------------------------------------------------------------------------- + + +class TestBalances: + def test_cash_available_is_the_buying_power_and_total_is_the_cash_balance(self) -> None: + """On a cash account (`multiplier == 1`) Alpaca's `buying_power` is the spendable + figure and `cash` the full balance; the gap is unsettled (T+1) proceeds. Sourcing + `available` from `buying_power` and clamping at `cash` surfaces that honestly + without ever reporting leveraged buying power as spendable.""" + adapter = AlpacaAdapter(_full_transport()) + balances = {b.currency: b for b in adapter.get_balances()} + + usd = balances["USD"] + assert isinstance(usd, Balance) + assert usd.available == Decimal("100000.00") + assert usd.total == Decimal("102086.50") + assert usd.total > usd.available, "the fixture carries a settlement gap to surface" + + def test_positions_become_balances_with_available_below_total_when_shares_are_unsettled( + self, + ) -> None: + adapter = AlpacaAdapter(_full_transport()) + balances = {b.currency: b for b in adapter.get_balances()} + + assert balances["AAPL"].total == Decimal("3") + assert balances["AAPL"].available == Decimal("3") + assert balances["TSLA"].total == Decimal("5") + assert balances["TSLA"].available == Decimal("4") + assert all(isinstance(b.available, Decimal) for b in balances.values()) + + def test_a_short_position_row_is_not_reported_as_a_holding(self) -> None: + """keel is long-only by construction; a short row on the account is a state this + engine must not reconcile into a positive holding, so it is skipped loudly-by-omission + rather than reported with a negative quantity the rails never expect.""" + transport = _full_transport() + transport._positions = [ + {"symbol": "GME", "qty": "-1", "qty_available": "-1", "side": "short"} + ] + balances = AlpacaAdapter(transport).get_balances() + assert [b.currency for b in balances] == ["USD"] + + +# --------------------------------------------------------------------------------------------- +# Candles (FR-5, FR-10's adjusted/raw policy) +# --------------------------------------------------------------------------------------------- + + +class TestCandles: + def test_bars_are_fetched_paginated_and_returned_ascending(self) -> None: + transport = _full_transport() + candles = AlpacaAdapter(transport).get_candles( + _PRODUCT, Granularity.FIFTEEN_MINUTE, 1_700_000_000, 1_700_086_400 + ) + + assert [c.ts for c in candles] == [1_786_717_800, 1_786_718_700, 1_786_719_600] + assert candles[0].open == Decimal("132.02") + assert candles[0].close == Decimal("131.9") + assert candles[2].volume == Decimal("9100") + # Pagination really walked both pages and threaded the venue's token through. + assert transport.call_counts["get_bars"] == 2 + assert transport.calls["get_bars"]["page_token"] == "cGFnZTI=" + + def test_the_request_declares_timeframe_window_feed_and_split_adjustment(self) -> None: + transport = _full_transport() + AlpacaAdapter(transport).get_candles( + _PRODUCT, Granularity.FIFTEEN_MINUTE, 1_700_000_000, 1_700_086_400 + ) + + call = transport.calls["get_bars"] + assert call["symbol"] == "AAPL" + assert call["timeframe"] == "15Min" + assert call["start"] == "2023-11-14T22:13:20Z" + assert call["end"] == "2023-11-15T22:13:20Z" + assert call["feed"] == "iex" + + def test_every_unsupported_granularity_is_refused(self) -> None: + adapter = AlpacaAdapter(_full_transport()) + for granularity in (Granularity.ONE_MINUTE, Granularity.FIVE_MINUTE, Granularity.SIX_HOUR): + with pytest.raises(ValueError, match="timeframe"): + adapter.get_candles(_PRODUCT, granularity, 0, 86_400) + + +# --------------------------------------------------------------------------------------------- +# Preview: synthesized from the book (FR-4, FR-7) +# --------------------------------------------------------------------------------------------- + + +class TestPreview: + def test_a_notional_buy_preview_prices_off_the_ask_and_charges_nothing(self) -> None: + preview = AlpacaAdapter(_full_transport()).preview_order( + MarketIOCByQuote(product_id=_PRODUCT, side=Side.BUY, quote_size=Decimal("100")) + ) + assert isinstance(preview, Preview) + assert preview.synthetic is True + # The notional is exact -- it is the number the caller asked to spend. + assert preview.est_quote_size == Decimal("100") + assert preview.est_base_size == Decimal("100") / Decimal("100.01") + assert preview.est_fee == Decimal("0"), "buys carry no sell-side regulatory fee" + assert preview.errors == () + assert preview.detail["best_bid"] == "99.99" + assert preview.detail["best_ask"] == "100.01" + assert preview.detail["price_basis"] == "latest_quote_ask" + assert preview.detail["data_feed"] == "iex" + + def test_a_fractional_market_sell_prices_off_the_bid_and_pays_the_regulatory_fees( + self, + ) -> None: + preview = AlpacaAdapter(_full_transport()).preview_order( + MarketIOCByBase(product_id=_PRODUCT, side=Side.SELL, base_size=Decimal("0.5")) + ) + assert preview.est_base_size == Decimal("0.5") + assert preview.est_quote_size == Decimal("0.5") * Decimal("99.99") + expected = estimate_regulatory_fees( + Side.SELL, Decimal("0.5"), Decimal("0.5") * Decimal("99.99") + ) + assert preview.est_fee == expected[0] + assert preview.detail["fee_basis"] == "sell_side_regulatory_passthrough" + + def test_a_limit_sell_previews_against_the_limit_price_bound(self) -> None: + """A limit never fills worse than its limit, so `base_size * limit_price` is a + bound not a guess -- the `keel_broker_robinhood.preview_order` convention.""" + preview = AlpacaAdapter(_full_transport()).preview_order( + LimitGTC( + product_id=_PRODUCT, + side=Side.SELL, + base_size=Decimal("0.5"), + limit_price=Decimal("132.10"), + ) + ) + assert preview.est_quote_size == Decimal("0.5") * Decimal("132.10") + assert preview.detail["price_basis"] == "limit_price" + # The book is still read and surfaced: FR-4 feeds the spread gate on every kind. + assert preview.detail["best_bid"] == "99.99" + assert preview.detail["best_ask"] == "100.01" + + def test_a_stop_limit_buy_pays_nothing_and_names_its_bases(self) -> None: + preview = AlpacaAdapter(_full_transport()).preview_order( + StopLimitGTC( + product_id=_PRODUCT, + side=Side.BUY, + base_size=Decimal("0.5"), + stop_price=Decimal("90"), + limit_price=Decimal("91"), + ) + ) + assert preview.est_quote_size == Decimal("0.5") * Decimal("91") + assert preview.est_fee == Decimal("0") + assert preview.detail["cost_basis"] == "base_size_x_limit_price" + + def test_a_quote_with_no_active_ask_leaves_the_buy_unpriced_and_says_so(self) -> None: + """Alpaca documents `ap: 0` as "no active ask". A zero ask must never be divided + into a base size (a fabricated position), and a silent zero at the confirm gate + reads as free money -- so the failure rides in `Preview.errors`.""" + transport = FakeTransport(quote=load_fixture("alpaca_quote_no_ask.json")) + preview = AlpacaAdapter(transport).preview_order( + MarketIOCByQuote(product_id=_PRODUCT, side=Side.BUY, quote_size=Decimal("100")) + ) + assert preview.est_base_size == Decimal("0") + assert preview.errors, "an unpriced leg must appear in errors" + assert any("ask" in e for e in preview.errors) + + def test_a_non_usd_product_is_refused_before_any_request_is_made(self) -> None: + transport = _full_transport() + with pytest.raises(UnsupportedOrder, match="USD"): + AlpacaAdapter(transport).preview_order( + MarketIOCByQuote(product_id="AAPL-EUR", side=Side.BUY, quote_size=Decimal("10")) + ) + assert "get_latest_quote" not in transport.calls + + +# --------------------------------------------------------------------------------------------- +# Order placement (FR-3) +# --------------------------------------------------------------------------------------------- + + +class TestPlaceOrder: + def test_a_notional_market_buy_places_the_mapped_body(self) -> None: + transport = _full_transport() + result = AlpacaAdapter(transport).place_order( + MarketIOCByQuote(product_id=_PRODUCT, side=Side.BUY, quote_size=Decimal("100")) + ) + assert isinstance(result, PlaceResult) + assert result.success is True + assert result.broker_order_id == "61e21a5c-c317-4942-8d86-7a1fc4760d7b" + + body = transport.calls["create_order"]["body"] + assert body["symbol"] == "AAPL" + assert body["notional"] == "100" + assert body["type"] == "market" + assert body["time_in_force"] == "day" + assert body["extended_hours"] is False + + def test_a_fractional_market_sell_places_a_qty_body(self) -> None: + transport = _full_transport() + AlpacaAdapter(transport).place_order( + MarketIOCByBase(product_id=_PRODUCT, side=Side.SELL, base_size=Decimal("0.7577533")) + ) + body = transport.calls["create_order"]["body"] + assert body["qty"] == "0.7577533" + assert "notional" not in body + + def test_every_order_kind_places_successfully(self) -> None: + adapter = AlpacaAdapter(_full_transport()) + specs = [ + MarketIOCByQuote(product_id=_PRODUCT, side=Side.BUY, quote_size=Decimal("100")), + MarketIOCByBase(product_id=_PRODUCT, side=Side.SELL, base_size=Decimal("0.5")), + LimitGTC( + product_id=_PRODUCT, + side=Side.SELL, + base_size=Decimal("0.5"), + limit_price=Decimal("132.10"), + ), + StopLimitGTC( + product_id=_PRODUCT, + side=Side.SELL, + base_size=Decimal("0.5"), + stop_price=Decimal("125"), + limit_price=Decimal("124.75"), + ), + ] + for spec in specs: + assert adapter.place_order(spec).success is True + + def test_each_placement_mints_a_fresh_client_order_id(self) -> None: + """A fresh uuid per ATTEMPT is the dedup posture `keel_broker_robinhood` documents: + it never collapses two deliberately repeated orders, at the cost that a caller + retrying after a timeout places twice. Neither default is safe both ways; this + pins which one this adapter takes.""" + transport = _full_transport() + adapter = AlpacaAdapter(transport) + spec = MarketIOCByQuote(product_id=_PRODUCT, side=Side.BUY, quote_size=Decimal("100")) + adapter.place_order(spec) + adapter.place_order(spec) + + ids = [body["client_order_id"] for body in transport.create_order_bodies] + assert len(ids) == 2 + assert ids[0] != ids[1], "each attempt must carry its own client_order_id" + + def test_a_venue_rejection_maps_to_a_failed_place_result(self) -> None: + transport = _RejectingCreateTransport( + AlpacaAPIError(422, "notional is out of range"), placed=load_fixture( + "alpaca_order_placed.json" + ) + ) + result = AlpacaAdapter(transport).place_order( + MarketIOCByQuote(product_id=_PRODUCT, side=Side.BUY, quote_size=Decimal("100")) + ) + assert result.success is False + assert result.broker_order_id is None + assert result.reason == "notional is out of range" + + def test_a_buying_power_refusal_maps_to_a_failed_place_result(self) -> None: + transport = _RejectingCreateTransport( + AlpacaAPIError(403, "insufficient buying power"), placed=load_fixture( + "alpaca_order_placed.json" + ) + ) + result = AlpacaAdapter(transport).place_order( + MarketIOCByQuote(product_id=_PRODUCT, side=Side.BUY, quote_size=Decimal("100")) + ) + assert result.success is False + assert "buying power" in (result.reason or "") + + def test_an_infra_error_propagates_rather_than_reading_as_a_rejection(self) -> None: + """A 5xx during placement is an UNKNOWN outcome, not a refusal: mapping it to + `success=False` would invite a caller to place again while the first order may be + live. Only the venue's explicit rejection codes (403/422) become failures.""" + transport = _RejectingCreateTransport( + AlpacaAPIError(500, "internal error"), placed=load_fixture("alpaca_order_placed.json") + ) + with pytest.raises(AlpacaAPIError): + AlpacaAdapter(transport).place_order( + MarketIOCByQuote(product_id=_PRODUCT, side=Side.BUY, quote_size=Decimal("100")) + ) + + def test_a_terminal_status_on_the_happy_path_is_not_a_live_order(self) -> None: + placed = load_fixture("alpaca_order_placed.json") + placed["status"] = "rejected" + transport = FakeTransport(placed=placed) + result = AlpacaAdapter(transport).place_order( + MarketIOCByQuote(product_id=_PRODUCT, side=Side.BUY, quote_size=Decimal("100")) + ) + assert result.success is False + assert result.broker_order_id is None + assert placed["id"] in (result.reason or "") + + +# --------------------------------------------------------------------------------------------- +# Order status and cancellation +# --------------------------------------------------------------------------------------------- + + +class TestOrderStatus: + def test_a_filled_order_reports_observed_economics(self) -> None: + transport = _full_transport() + adapter = AlpacaAdapter(transport) + placed = adapter.place_order( + MarketIOCByQuote(product_id=_PRODUCT, side=Side.BUY, quote_size=Decimal("100")) + ) + + status = adapter.get_order(placed.broker_order_id or "") + assert isinstance(status, OrderStatus) + assert status.status == "FILLED" + assert status.filled_size == Decimal("0.7577533883593") + assert status.average_filled_price == Decimal("131.9700139996") + + def test_total_fees_is_zero_because_the_api_exposes_no_per_order_fee_field(self) -> None: + """Alpaca's order object carries no fee; sell-side regulatory fees are netted from + proceeds and only surface account-wide (`pending_reg_taf_fees`). Reporting zero + here is a statement about what the venue exposes, not a claim orders trade free.""" + transport = _full_transport() + adapter = AlpacaAdapter(transport) + placed = adapter.place_order( + MarketIOCByQuote(product_id=_PRODUCT, side=Side.BUY, quote_size=Decimal("100")) + ) + assert adapter.get_order(placed.broker_order_id or "").total_fees == Decimal("0") + + def test_an_unknown_order_id_is_terminal_failed_never_an_exception(self) -> None: + status = AlpacaAdapter(_full_transport()).get_order("an-id-this-venue-never-issued") + assert status.status == "FAILED" + zero = Decimal("0") + assert status.filled_size == status.average_filled_price == status.total_fees == zero + + def test_an_unrecognised_status_stays_pending(self) -> None: + placed = load_fixture("alpaca_order_placed.json") + placed["status"] = "brand_new_status" + transport = FakeTransport(placed=placed, order=placed) + adapter = AlpacaAdapter(transport) + placed_result = adapter.place_order( + MarketIOCByQuote(product_id=_PRODUCT, side=Side.BUY, quote_size=Decimal("100")) + ) + assert adapter.get_order(placed_result.broker_order_id or "").status == "PENDING" + + +class TestCancel: + def test_a_204_is_the_venue_confirmation(self) -> None: + adapter = AlpacaAdapter(_full_transport()) + placed = adapter.place_order( + MarketIOCByQuote(product_id=_PRODUCT, side=Side.BUY, quote_size=Decimal("100")) + ) + assert adapter.cancel_order(placed.broker_order_id or "") is True + + def test_an_id_the_venue_never_issued_is_false_not_a_raise(self) -> None: + assert AlpacaAdapter(_full_transport()).cancel_order("never-issued") is False + + def test_an_order_that_is_no_longer_cancellable_is_not_a_confirmation(self) -> None: + """Alpaca answers DELETE with 422 when an order can no longer be cancelled (e.g. + already filled); 404 when it never existed. Neither is a confirmed cancellation.""" + transport = FakeTransport( + placed=load_fixture("alpaca_order_placed.json"), cancel_status=422 + ) + adapter = AlpacaAdapter(transport) + placed = adapter.place_order( + MarketIOCByQuote(product_id=_PRODUCT, side=Side.BUY, quote_size=Decimal("100")) + ) + assert adapter.cancel_order(placed.broker_order_id or "") is False + + def test_a_transport_failure_on_the_exit_path_is_false_not_an_exception(self) -> None: + class _Exploding(FakeTransport): + def cancel_order(self, order_id: str) -> Any: + self._record("cancel_order", order_id=order_id) + raise AlpacaAPIError(500, "boom") + + transport = _Exploding(placed=load_fixture("alpaca_order_placed.json")) + adapter = AlpacaAdapter(transport) + placed = adapter.place_order( + MarketIOCByQuote(product_id=_PRODUCT, side=Side.BUY, quote_size=Decimal("100")) + ) + assert adapter.cancel_order(placed.broker_order_id or "") is False + + +# --------------------------------------------------------------------------------------------- +# Session awareness (FR-9) +# --------------------------------------------------------------------------------------------- + + +class TestSession: + def test_the_market_session_comes_from_the_venue_clock(self) -> None: + """Equities are not 24/7; open/closed comes from the venue's own clock, not a + locally maintained calendar that drifts.""" + open_adapter = AlpacaAdapter(FakeTransport(clock=load_fixture("alpaca_clock_open.json"))) + closed_adapter = AlpacaAdapter( + FakeTransport(clock=load_fixture("alpaca_clock_closed.json")) + ) + assert open_adapter.is_market_open() is True + assert closed_adapter.is_market_open() is False + + def test_no_order_body_ever_asks_for_extended_hours(self) -> None: + """Overnight/extended sessions are OFF by posture (FR-9): thinner liquidity would + hold the #350 spread gate permanently. Every body pins `extended_hours: False`.""" + transport = _full_transport() + adapter = AlpacaAdapter(transport) + adapter.place_order( + MarketIOCByQuote(product_id=_PRODUCT, side=Side.BUY, quote_size=Decimal("100")) + ) + adapter.place_order( + LimitGTC( + product_id=_PRODUCT, + side=Side.SELL, + base_size=Decimal("0.5"), + limit_price=Decimal("132.10"), + ) + ) + assert transport.calls["create_order"]["body"]["extended_hours"] is False diff --git a/tests/broker_alpaca/test_fees.py b/tests/broker_alpaca/test_fees.py new file mode 100644 index 00000000..4a7b02d9 --- /dev/null +++ b/tests/broker_alpaca/test_fees.py @@ -0,0 +1,80 @@ +"""Tests for `keel_broker_alpaca.fees` -- the sell-side regulatory pass-through model. + +Alpaca charges no commission on US equities, but sells carry regulatory fees the venue +passes through at cost (PRD FR-7). The rates are constants with provenance in `fees.py`; +these tests pin both the arithmetic and the rates themselves, because a rate that silently +drifts is a cost model that quietly mis-prices every sell preview (PRD §8 "Regulatory +drift ... the cost model is versioned and re-measured, not assumed"). +""" + +from __future__ import annotations + +from decimal import Decimal + +from keel_broker_alpaca.fees import ( + COMMISSION_RATE, + SEC_SECTION_31_PER_MILLION, + TAF_MAX_PER_TRADE, + TAF_PER_SHARE, + estimate_regulatory_fees, +) +from keel_core.types import Side + + +def test_the_constants_carry_the_published_rates() -> None: + """Pinned so a well-meaning "update" to one number without its provenance comment + fails here rather than silently re-pricing every preview. + + Sources (read 2026-08-17): Alpaca's own regulatory-fee pages state the Section 31 + current rate ($22.90 per $1M of principal, sells only; $27.80 previously) and the TAF + equity cap ($8.30); FINRA Schedule A to the By-Laws (SR-FINRA-2020-032, in force since + 2021-01-01, reaffirmed by SR-FINRA-2024-019) states the $0.000166 per-share TAF. + """ + assert COMMISSION_RATE == Decimal("0") + assert SEC_SECTION_31_PER_MILLION == Decimal("22.90") + assert TAF_PER_SHARE == Decimal("0.000166") + assert TAF_MAX_PER_TRADE == Decimal("8.30") + + +def test_a_small_sell_pays_sec_plus_taf_on_proceeds_and_shares() -> None: + """100 shares sold at $50 -> $5,000 proceeds. + + SEC Section 31: 5000 * 22.90 / 1_000_000 = 0.1145 + FINRA TAF: 100 * 0.000166 = 0.0166 + """ + total, sec_fee, taf = estimate_regulatory_fees( + side=Side.SELL, shares=Decimal("100"), proceeds=Decimal("5000") + ) + assert sec_fee == Decimal("0.1145") + assert taf == Decimal("0.0166") + assert total == Decimal("0.1311") + + +def test_the_taf_caps_at_the_per_trade_maximum() -> None: + """Alpaca's page states the equity TAF is capped ($8.30); 100,000 shares would + nominally be 100000 * 0.000166 = 16.60, so the cap is what keeps the estimate honest.""" + total, sec_fee, taf = estimate_regulatory_fees( + side=Side.SELL, shares=Decimal("100000"), proceeds=Decimal("1000000") + ) + assert taf == TAF_MAX_PER_TRADE + assert sec_fee == Decimal("22.90") # 1M * 22.90 / 1M + assert total == Decimal("31.20") + + +def test_a_buy_pays_nothing() -> None: + """Commission is $0 and every pass-through fee this model carries is sell-side.""" + total, sec_fee, taf = estimate_regulatory_fees( + side=Side.BUY, shares=Decimal("100"), proceeds=Decimal("5000") + ) + assert total == sec_fee == taf == Decimal("0") + + +def test_a_fractional_sell_is_charged_on_proceeds_and_shares_alike() -> None: + """Fractional exits still owe both legs: TAF is per share (fractional included) and + Section 31 is per dollar of proceeds.""" + total, sec_fee, taf = estimate_regulatory_fees( + side=Side.SELL, shares=Decimal("0.7577533"), proceeds=Decimal("99.99") + ) + assert sec_fee == Decimal("99.99") * SEC_SECTION_31_PER_MILLION / Decimal(1_000_000) + assert taf == Decimal("0.7577533") * TAF_PER_SHARE + assert total == sec_fee + taf diff --git a/tests/broker_alpaca/test_translate.py b/tests/broker_alpaca/test_translate.py new file mode 100644 index 00000000..04a4c44e --- /dev/null +++ b/tests/broker_alpaca/test_translate.py @@ -0,0 +1,210 @@ +"""Tests for `keel_broker_alpaca.translate`. + +`translate.py` is where keel's order model becomes Alpaca's order-body and status +vocabulary, mirroring `keel_broker_coinbase.translate` and `keel_broker_robinhood.translate`. +Every Alpaca-specific spelling (symbol without a quote leg, `notional` vs `qty` market +sizing, `time_in_force` per order type, the cancelled-order state spelling) is pinned here. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal + +import pytest +from keel_broker_alpaca.translate import ( + STATE_TO_PORT_STATUS, + TIMEFRAME_BY_GRANULARITY, + to_order_body, + to_port_status, + to_rfc3339, + to_side, + to_symbol, + to_timeframe, + to_unix_seconds, +) +from keel_broker_api.orders import LimitGTC, MarketIOCByBase, MarketIOCByQuote, StopLimitGTC +from keel_broker_api.port import UnsupportedOrder +from keel_core.types import Granularity, Side + + +def test_to_symbol_strips_the_usd_quote_leg() -> None: + """Alpaca's equity symbols carry no quote leg: keel's `AAPL-USD` is Alpaca's `AAPL`.""" + assert to_symbol("AAPL-USD") == "AAPL" + assert to_symbol("aapl-usd") == "AAPL" + + +def test_to_symbol_refuses_a_non_usd_quote_leg() -> None: + """Rewriting `AAPL-EUR` to a USD-quoted symbol would swap the settlement asset under + the caller, exactly the substitution `keel_broker_robinhood.translate.to_symbol` refuses.""" + with pytest.raises(UnsupportedOrder, match="USD"): + to_symbol("AAPL-EUR") + + +def test_to_symbol_refuses_a_product_id_that_is_not_base_quote_shaped() -> None: + with pytest.raises(UnsupportedOrder, match="BASE-QUOTE"): + to_symbol("AAPL") + + +@pytest.mark.parametrize( + ("granularity", "timeframe"), + [ + (Granularity.FIFTEEN_MINUTE, "15Min"), + (Granularity.ONE_HOUR, "1Hour"), + (Granularity.ONE_DAY, "1Day"), + ], +) +def test_keel_granularities_map_to_their_alpaca_timeframes( + granularity: Granularity, timeframe: str +) -> None: + """The PRD's three confirmation/trading/bias series map exactly onto Alpaca's v2 + timeframe strings (docs.alpaca.markets, "Stock Bars": `15Min`, `1Hour`, `1Day`).""" + assert to_timeframe(granularity) == timeframe + assert TIMEFRAME_BY_GRANULARITY[granularity] == timeframe + + +@pytest.mark.parametrize( + "granularity", + [Granularity.ONE_MINUTE, Granularity.FIVE_MINUTE, Granularity.SIX_HOUR], +) +def test_every_other_granularity_is_refused_not_approximated(granularity: Granularity) -> None: + """A venue that cannot serve a timeframe must say so (`ValueError` is the port's + sanctioned refusal -- see `FakeAdapter.get_candles`), never silently substitute one.""" + with pytest.raises(ValueError, match="timeframe"): + to_timeframe(granularity) + + +def test_market_quote_order_becomes_a_notional_market_order() -> None: + """`MarketIOCByQuote` ("spend N USD") maps directly onto Alpaca's `notional` market + order -- the PRD's FR-3 "Alpaca's notional market orders map directly". + + `notional` is documented to work ONLY with `type: market` and `time_in_force: day`, + so all three are pinned together. + """ + spec = MarketIOCByQuote(product_id="AAPL-USD", side=Side.BUY, quote_size=Decimal("100")) + body = to_order_body(spec, client_order_id="c1") + + assert body["symbol"] == "AAPL" + assert body["notional"] == "100" + assert "qty" not in body + assert body["side"] == "buy" + assert body["type"] == "market" + assert body["time_in_force"] == "day" + assert body["client_order_id"] == "c1" + assert body["extended_hours"] is False + + +def test_market_base_order_becomes_a_qty_market_order() -> None: + """Fractional shares ride through `qty` unchanged: Alpaca accepts fractionable + quantities, and a rounding step here would change the position size the caller asked for.""" + spec = MarketIOCByBase(product_id="AAPL-USD", side=Side.SELL, base_size=Decimal("0.7577533")) + body = to_order_body(spec, client_order_id="c2") + + assert body["symbol"] == "AAPL" + assert body["qty"] == "0.7577533" + assert "notional" not in body + assert body["side"] == "sell" + assert body["type"] == "market" + assert body["time_in_force"] == "day" + assert body["extended_hours"] is False + + +def test_limit_order_is_gtc_with_qty_and_limit_price() -> None: + spec = LimitGTC( + product_id="AAPL-USD", + side=Side.SELL, + base_size=Decimal("0.5"), + limit_price=Decimal("132.10"), + ) + body = to_order_body(spec, client_order_id="c3") + + assert body["type"] == "limit" + assert body["time_in_force"] == "gtc" + assert body["qty"] == "0.5" + assert body["limit_price"] == "132.10" + assert body["extended_hours"] is False + + +def test_stop_limit_order_is_gtc_with_stop_and_limit_prices() -> None: + spec = StopLimitGTC( + product_id="AAPL-USD", + side=Side.SELL, + base_size=Decimal("0.5"), + stop_price=Decimal("125.00"), + limit_price=Decimal("124.75"), + ) + body = to_order_body(spec, client_order_id="c4") + + assert body["type"] == "stop_limit" + assert body["time_in_force"] == "gtc" + assert body["qty"] == "0.5" + assert body["stop_price"] == "125.00" + assert body["limit_price"] == "124.75" + assert body["extended_hours"] is False + + +def test_tiny_fractional_quantities_render_positionally_never_scientific() -> None: + """`str(Decimal)` emits scientific notation at small magnitudes (`1E-8`), and an + exponent in `qty`/`notional` is a malformed order body. `keel_broker_robinhood`'s + `_render` documents the same failure; this pins it for fractional shares.""" + spec = MarketIOCByBase(product_id="AAPL-USD", side=Side.SELL, base_size=Decimal("0.00000001")) + body = to_order_body(spec, client_order_id="c5") + + assert body["qty"] == "0.00000001" + + +def test_side_renders_lowercase() -> None: + assert to_side(Side.BUY) == "buy" + assert to_side(Side.SELL) == "sell" + + +@pytest.mark.parametrize( + ("venue_status", "port_status"), + [ + ("new", "OPEN"), + ("accepted", "OPEN"), + ("accepted_for_bidding", "OPEN"), + ("partially_filled", "OPEN"), + ("pending_new", "PENDING"), + ("pending_cancel", "PENDING"), + ("pending_replace", "PENDING"), + ("done_for_day", "PENDING"), + ("calculated", "PENDING"), + ("held", "PENDING"), + ("filled", "FILLED"), + ("canceled", "CANCELLED"), + ("expired", "EXPIRED"), + ("replaced", "CANCELLED"), + ("rejected", "FAILED"), + ("stopped", "FAILED"), + ("suspended", "FAILED"), + ], +) +def test_alpaca_statuses_map_onto_the_port_vocabulary( + venue_status: str, port_status: str +) -> None: + """Alpaca's order-status enum (docs.alpaca.markets, "Order" schema) meets the port's + vocabulary in exactly one place. `canceled` is the venue's single-`l` spelling; the + port's is `CANCELLED` -- they must never be compared directly downstream.""" + assert STATE_TO_PORT_STATUS[venue_status] == port_status + assert to_port_status(venue_status) == port_status + + +def test_an_unknown_status_is_pending_never_failed() -> None: + """A status this table does not know means the adapter does not know the order's + outcome. `PENDING` keeps reconciliation polling; `FAILED` would declare a terminal + outcome nobody observed (the `keel_broker_robinhood.translate.to_port_status` rule).""" + assert to_port_status("brand_new_status") == "PENDING" + assert to_port_status(None) == "PENDING" + + +def test_rfc3339_and_epoch_round_trip() -> None: + """Bar timestamps arrive RFC3339 (`t`) while `start`/`end` are sent RFC3339 from epoch + seconds; both directions must be exact at second precision.""" + ts = int(datetime(2026, 8, 14, 14, 30, tzinfo=UTC).timestamp()) + assert to_rfc3339(ts) == "2026-08-14T14:30:00Z" + assert to_unix_seconds("2026-08-14T14:30:00Z") == ts + # Fractional seconds must truncate, not fail: Alpaca timestamps carry them. + assert to_unix_seconds("2026-08-14T14:30:00.999Z") == ts + # ...and an explicit offset must parse too, not only a trailing Z. + assert to_unix_seconds("2026-08-14T10:30:00-04:00") == ts diff --git a/tests/broker_alpaca/test_transport.py b/tests/broker_alpaca/test_transport.py new file mode 100644 index 00000000..5ea7115c --- /dev/null +++ b/tests/broker_alpaca/test_transport.py @@ -0,0 +1,368 @@ +"""Zero-network tests for `keel_broker_alpaca.transport` against a faked HTTP layer. + +`AlpacaTransport` is the half of this package that talks to a live-money venue, so it is +driven directly with a `_RecordingHTTP` standing in for `requests.request` -- the design +mirrored from `tests/broker_robinhood/test_transport.py`. The code under test is the code +that runs in production, minus the socket; a real network call from this file would be a +real order. +""" + +from __future__ import annotations + +import json +from decimal import Decimal +from typing import Any +from urllib.parse import parse_qsl, urlsplit + +import pytest +from keel_broker_alpaca.transport import ( + DATA_HOST, + LIVE_TRADING_HOST, + PAPER_TRADING_HOST, + AlpacaAPIError, + AlpacaTransport, +) + +_KEY_ID = "AK-TEST-KEY-ID" +_SECRET = "test-secret" + + +class _FakeResponse: + """The slice of `requests.Response` that `AlpacaTransport._send` actually touches.""" + + def __init__( + self, + status_code: int = 200, + payload: Any = None, + text: str | None = None, + headers: dict[str, str] | None = None, + ) -> None: + self.status_code = status_code + if text is not None: + self.text = text + elif payload is None: + self.text = "" + else: + self.text = json.dumps(payload) + self.headers = {k.lower(): v for k, v in (headers or {}).items()} + + def json(self) -> Any: + return json.loads(self.text) + + +class _RecordingHTTP: + """Stands in for `requests.request`, recording every call and replaying responses. + + Responses may be one `_FakeResponse` (every call), a list (in order, last repeats), + or a callable taking `(method, url, headers)`.""" + + def __init__(self, responses: Any) -> None: + self.calls: list[dict[str, Any]] = [] + self._responses = responses + + def __call__( + self, + method: str, + url: str, + headers: dict[str, str] | None = None, + params: Any = None, + json: Any = None, + data: Any = None, + timeout: float | None = None, + ) -> _FakeResponse: + self.calls.append( + { + "method": method, + "url": url, + "headers": headers, + "params": params, + "json": json, + "timeout": timeout, + } + ) + if callable(self._responses): + result: _FakeResponse = self._responses(method, url, headers) + return result + if isinstance(self._responses, list): + index = min(len(self.calls) - 1, len(self._responses) - 1) + response: _FakeResponse = self._responses[index] + return response + single: _FakeResponse = self._responses + return single + + +@pytest.fixture +def http(monkeypatch: pytest.MonkeyPatch) -> Any: + """Install a `_RecordingHTTP` over `requests.request` and hand back the installer. + + `AlpacaTransport._send` imports `requests` at call time, so patching the attribute on + the real module is what the deferred import sees (the `tests/broker_robinhood` + fixture's reasoning, reused verbatim). + """ + import requests + + def install(responses: Any) -> _RecordingHTTP: + recorder = _RecordingHTTP(responses) + monkeypatch.setattr(requests, "request", recorder) + return recorder + + return install + + +def _transport(**kwargs: Any) -> AlpacaTransport: + kwargs.setdefault("sleep", lambda seconds: None) + return AlpacaTransport(_KEY_ID, _SECRET, **kwargs) + + +def _query_of(url: str) -> dict[str, str]: + return dict(parse_qsl(urlsplit(url).query)) + + +# --------------------------------------------------------------------------------------------- +# Authentication on every request, on both hosts +# --------------------------------------------------------------------------------------------- + + +def test_every_trading_request_carries_the_key_headers(http: Any) -> None: + """Alpaca authenticates with `APCA-API-KEY-ID` / `APCA-API-SECRET-KEY` headers on every + endpoint, trading and market data alike (docs.alpaca.markets, "Authentication").""" + recorder = http(_FakeResponse(payload={})) + _transport().get_account() + + headers = recorder.calls[0]["headers"] + assert headers["APCA-API-KEY-ID"] == _KEY_ID + assert headers["APCA-API-SECRET-KEY"] == _SECRET + + +def test_every_market_data_request_carries_the_key_headers(http: Any) -> None: + recorder = http(_FakeResponse(payload={"bars": []})) + _transport().get_bars("AAPL", "1Day", "2026-08-01T00:00:00Z", "2026-08-14T00:00:00Z", "iex") + + headers = recorder.calls[0]["headers"] + assert headers["APCA-API-KEY-ID"] == _KEY_ID + assert headers["APCA-API-SECRET-KEY"] == _SECRET + + +# --------------------------------------------------------------------------------------------- +# Endpoint paths and host selection +# --------------------------------------------------------------------------------------------- + + +def test_trading_requests_go_to_the_paper_host_by_default(http: Any) -> None: + recorder = http(_FakeResponse(payload={})) + _transport().get_account() + + assert recorder.calls[0]["url"] == f"{PAPER_TRADING_HOST}/v2/account" + + +def test_the_live_endpoint_selects_the_live_host(http: Any) -> None: + recorder = http(_FakeResponse(payload={})) + _transport(endpoint="live").get_account() + + assert recorder.calls[0]["url"] == f"{LIVE_TRADING_HOST}/v2/account" + + +def test_market_data_requests_go_to_the_data_host(http: Any) -> None: + recorder = http(_FakeResponse(payload={"bars": []})) + _transport().get_bars("AAPL", "1Day", "2026-08-01T00:00:00Z", "2026-08-14T00:00:00Z", "iex") + + assert recorder.calls[0]["url"].startswith(f"{DATA_HOST}/v2/stocks/AAPL/bars?") + + +def test_get_bars_declares_timeframe_window_feed_and_adjustment(http: Any) -> None: + """`feed` and `adjustment` are sent explicitly: the venue's silent defaults (sip; + raw candles) are exactly what the capability declaration must not rely on.""" + recorder = http(_FakeResponse(payload={"bars": []})) + _transport(data_feed="iex").get_bars( + "AAPL", "15Min", "2026-08-01T00:00:00Z", "2026-08-14T00:00:00Z", "iex" + ) + + query = _query_of(recorder.calls[0]["url"]) + assert query == { + "timeframe": "15Min", + "start": "2026-08-01T00:00:00Z", + "end": "2026-08-14T00:00:00Z", + "feed": "iex", + "adjustment": "split", + } + + +def test_get_bars_threads_the_pagination_token(http: Any) -> None: + recorder = http(_FakeResponse(payload={"bars": []})) + _transport().get_bars( + "AAPL", + "1Day", + "2026-08-01T00:00:00Z", + "2026-08-14T00:00:00Z", + "iex", + page_token="cGFnZTI=", + ) + + assert _query_of(recorder.calls[0]["url"])["page_token"] == "cGFnZTI=" + + +def test_get_latest_quote_requests_the_documented_path_with_the_feed(http: Any) -> None: + recorder = http(_FakeResponse(payload={"quote": {}})) + _transport().get_latest_quote("AAPL", "iex") + + call = recorder.calls[0] + assert call["url"] == f"{DATA_HOST}/v2/stocks/AAPL/quotes/latest?feed=iex" + + +def test_create_order_posts_the_body_as_json(http: Any) -> None: + recorder = http(_FakeResponse(payload={"id": "o1", "status": "accepted"})) + body = {"symbol": "AAPL", "notional": "100", "side": "buy", "type": "market"} + _transport().create_order(body) + + call = recorder.calls[0] + assert call["method"] == "POST" + assert call["url"] == f"{PAPER_TRADING_HOST}/v2/orders" + assert call["json"] == body + + +def test_get_order_and_cancel_order_use_the_order_id_path(http: Any) -> None: + recorder = http([_FakeResponse(payload={"id": "o1"}), _FakeResponse(204)]) + transport = _transport() + + transport.get_order("o1") + transport.cancel_order("o1") + + assert recorder.calls[0]["url"] == f"{PAPER_TRADING_HOST}/v2/orders/o1" + assert recorder.calls[1]["url"] == f"{PAPER_TRADING_HOST}/v2/orders/o1" + assert recorder.calls[1]["method"] == "DELETE" + + +def test_get_positions_and_get_clock_use_their_documented_paths(http: Any) -> None: + recorder = http([_FakeResponse(payload=[]), _FakeResponse(payload={"is_open": True})]) + transport = _transport() + + transport.get_positions() + transport.get_clock() + + assert recorder.calls[0]["url"] == f"{PAPER_TRADING_HOST}/v2/positions" + assert recorder.calls[1]["url"] == f"{PAPER_TRADING_HOST}/v2/clock" + + +# --------------------------------------------------------------------------------------------- +# Response handling: money as Decimal, the 404 sentinel, cancel statuses +# --------------------------------------------------------------------------------------------- + + +def test_unquoted_json_numbers_arrive_as_decimal_never_float(http: Any) -> None: + """Alpaca's bars and quotes send money values as UNQUOTED numbers while the account + and order objects quote theirs -- a venue that mixes the two, exactly like Robinhood + (#217 F6). `parse_float=Decimal` at the parser is the only place the original digits + still exist.""" + http(_FakeResponse(text='{"bars": [{"t": "2026-08-14T14:30:00Z", "o": 132.02, "c": 131.9}]}')) + response = _transport().get_bars( + "AAPL", "15Min", "2026-08-01T00:00:00Z", "2026-08-14T00:00:00Z", "iex" + ) + + bar = response["bars"][0] + assert isinstance(bar["o"], Decimal), f"got {type(bar['o'])}" + assert bar["o"] == Decimal("132.02") + assert bar["o"] * 3 == Decimal("396.06"), "the exactness that Decimal parsing buys" + + +def test_get_order_maps_a_404_to_none_and_raises_for_every_other_error(http: Any) -> None: + """`None` means "the venue does not recognise this id" and nothing else: the adapter + turns it into a terminal FAILED, so a 5xx laundered through `None` would report a live + order as dead (the split `keel_broker_robinhood.transport._request` exists for).""" + http(_FakeResponse(404, payload={"message": "order not found"})) + assert _transport().get_order("no-such-id") is None + + for status in (401, 422, 500, 503): + http(_FakeResponse(status, payload={"message": "boom"})) + with pytest.raises(AlpacaAPIError) as excinfo: + _transport().get_order("some-id") + assert excinfo.value.status_code == status + + +def test_an_error_body_message_surfaces_in_the_exception(http: Any) -> None: + http(_FakeResponse(422, payload={"message": "notional is out of range"})) + with pytest.raises(AlpacaAPIError, match="notional is out of range"): + _transport().get_order("some-id") + + +def test_cancel_returns_the_status_and_does_not_raise_for_404_or_422(http: Any) -> None: + """The cancel answer IS the HTTP status (204 = confirmed, 404 = never existed, + 422 = no longer cancelable); the adapter maps each, and only these three are answers + rather than errors.""" + http(_FakeResponse(204)) + assert _transport().cancel_order("o1") == 204 + + http(_FakeResponse(404, payload={"message": "order not found"})) + assert _transport().cancel_order("o1") == 404 + + http(_FakeResponse(422, payload={"message": "order status is not cancelable"})) + assert _transport().cancel_order("o1") == 422 + + http(_FakeResponse(500)) + with pytest.raises(AlpacaAPIError): + _transport().cancel_order("o1") + + +# --------------------------------------------------------------------------------------------- +# Rate limits: 429 backoff (FR-11) +# --------------------------------------------------------------------------------------------- + + +def test_a_429_retries_and_honours_the_retry_after_header(http: Any) -> None: + recorder = http([_FakeResponse(429, headers={"Retry-After": "7"}), _FakeResponse(payload={})]) + slept: list[float] = [] + transport = _transport(sleep=slept.append) + + transport.get_account() + + assert len(recorder.calls) == 2 + assert slept == [7.0], "the venue's own Retry-After, when sent, IS the backoff" + + +def test_a_429_without_retry_after_backs_off_exponentially(http: Any) -> None: + recorder = http([_FakeResponse(429), _FakeResponse(payload={})]) + slept: list[float] = [] + transport = _transport(sleep=slept.append) + + transport.get_account() + + assert len(recorder.calls) == 2 + assert slept == [0.5], "first retry backs off half a second without a venue hint" + + +def test_a_persistent_429_raises_after_the_attempt_budget(http: Any) -> None: + recorder = http(_FakeResponse(429)) + slept: list[float] = [] + transport = _transport(sleep=slept.append, max_attempts=3) + + with pytest.raises(AlpacaAPIError) as excinfo: + transport.get_account() + + assert excinfo.value.status_code == 429 + assert len(recorder.calls) == 3, "the attempt budget is the bound, not the clock" + assert slept == [0.5, 1.0], "exponential: 0.5s then 1.0s" + + +def test_only_429_retries_an_outright_500_fails_at_once(http: Any) -> None: + recorder = http(_FakeResponse(500)) + transport = _transport(sleep=lambda seconds: None) + + with pytest.raises(AlpacaAPIError): + transport.get_account() + assert len(recorder.calls) == 1 + + +# --------------------------------------------------------------------------------------------- +# Construction-time validation +# --------------------------------------------------------------------------------------------- + + +@pytest.mark.parametrize("endpoint", ["production", "PAPER", "https://api.alpaca.markets", ""]) +def test_an_unknown_endpoint_is_refused_at_construction(endpoint: str) -> None: + with pytest.raises(ValueError, match="endpoint"): + AlpacaTransport(_KEY_ID, _SECRET, endpoint=endpoint) + + +@pytest.mark.parametrize("feed", ["sip-plus", "IEX", ""]) +def test_an_unknown_data_feed_is_refused_at_construction(feed: str) -> None: + with pytest.raises(ValueError, match="data_feed"): + AlpacaTransport(_KEY_ID, _SECRET, data_feed=feed) diff --git a/tests/conformance/test_alpaca_conformance.py b/tests/conformance/test_alpaca_conformance.py new file mode 100644 index 00000000..224c45a0 --- /dev/null +++ b/tests/conformance/test_alpaca_conformance.py @@ -0,0 +1,31 @@ +"""Alpaca held to the shared `Broker` contract, driven entirely by canned fixtures. + +The transport is the same `FakeTransport` the adapter's own tests use -- never a live +`AlpacaTransport`. The suite calls `place_order`, so a live transport here would place +orders against a real (paper) venue; the fixture-driven design is the whole point. +""" + +from __future__ import annotations + +from keel_broker_alpaca import AlpacaAdapter +from keel_broker_api.conformance.suite import BrokerConformanceTests + +from tests.broker_alpaca.test_adapter import FakeTransport, load_fixture + + +class TestAlpacaConformance(BrokerConformanceTests): + def broker(self) -> AlpacaAdapter: + return AlpacaAdapter( + FakeTransport( + account=load_fixture("alpaca_account.json"), + positions=load_fixture("alpaca_positions.json"), + clock=load_fixture("alpaca_clock_open.json"), + placed=load_fixture("alpaca_order_placed.json"), + order=load_fixture("alpaca_order_filled.json"), + bars_pages=[ + load_fixture("alpaca_bars_page1.json"), + load_fixture("alpaca_bars_page2.json"), + ], + quote=load_fixture("alpaca_quote_latest.json"), + ) + ) diff --git a/tests/fixtures/alpaca_account.json b/tests/fixtures/alpaca_account.json new file mode 100644 index 00000000..a5c8e6c1 --- /dev/null +++ b/tests/fixtures/alpaca_account.json @@ -0,0 +1,29 @@ +{ + "account_number": "716B8D2F-83A8-4BF5-9006-586158FB22BB", + "status": "ACTIVE", + "currency": "USD", + "buying_power": "100000.00", + "regt_buying_power": "100000.00", + "non_marginable_buying_power": "100000.00", + "cash": "102086.50", + "accrued_fees": "0", + "pending_reg_taf_fees": "0.13", + "portfolio_value": "102218.50", + "multiplier": "1", + "equity": "102218.50", + "last_equity": "102218.50", + "daytrade_count": 0, + "pattern_day_trader": false, + "trading_blocked": false, + "transfers_blocked": false, + "account_blocked": false, + "trade_suspended_by_user": false, + "created_at": "2026-08-01T13:31:30.634915Z", + "shorting_enabled": false, + "long_market_value": "132.00", + "short_market_value": "0", + "initial_margin": "0", + "maintenance_margin": "0", + "sma": "0", + "id": "0d8fd746-7042-4cf6-8a01-380f2f95b2b6" +} diff --git a/tests/fixtures/alpaca_bars_page1.json b/tests/fixtures/alpaca_bars_page1.json new file mode 100644 index 00000000..824df4fc --- /dev/null +++ b/tests/fixtures/alpaca_bars_page1.json @@ -0,0 +1,22 @@ +{ + "bars": [ + { + "t": "2026-08-14T14:30:00Z", + "o": 132.02, + "h": 132.1, + "l": 131.5, + "c": 131.9, + "v": 12500 + }, + { + "t": "2026-08-14T14:45:00Z", + "o": 131.9, + "h": 132.0, + "l": 131.7, + "c": 131.95, + "v": 8200 + } + ], + "symbol": "AAPL", + "next_page_token": "cGFnZTI=" +} diff --git a/tests/fixtures/alpaca_bars_page2.json b/tests/fixtures/alpaca_bars_page2.json new file mode 100644 index 00000000..30d18ed6 --- /dev/null +++ b/tests/fixtures/alpaca_bars_page2.json @@ -0,0 +1,14 @@ +{ + "bars": [ + { + "t": "2026-08-14T15:00:00Z", + "o": 131.95, + "h": 132.25, + "l": 131.85, + "c": 132.2, + "v": 9100 + } + ], + "symbol": "AAPL", + "next_page_token": null +} diff --git a/tests/fixtures/alpaca_clock_closed.json b/tests/fixtures/alpaca_clock_closed.json new file mode 100644 index 00000000..4ba092dc --- /dev/null +++ b/tests/fixtures/alpaca_clock_closed.json @@ -0,0 +1,6 @@ +{ + "timestamp": "2026-08-15T12:00:00Z", + "is_open": false, + "next_open": "2026-08-17T13:30:00Z", + "next_close": "2026-08-17T20:00:00Z" +} diff --git a/tests/fixtures/alpaca_clock_open.json b/tests/fixtures/alpaca_clock_open.json new file mode 100644 index 00000000..d74e96ee --- /dev/null +++ b/tests/fixtures/alpaca_clock_open.json @@ -0,0 +1,6 @@ +{ + "timestamp": "2026-08-17T15:59:00Z", + "is_open": true, + "next_open": "2026-08-18T13:30:00Z", + "next_close": "2026-08-17T20:00:00Z" +} diff --git a/tests/fixtures/alpaca_order_filled.json b/tests/fixtures/alpaca_order_filled.json new file mode 100644 index 00000000..e51c8ba6 --- /dev/null +++ b/tests/fixtures/alpaca_order_filled.json @@ -0,0 +1,36 @@ +{ + "id": "61e21a5c-c317-4942-8d86-7a1fc4760d7b", + "client_order_id": "eb9e2897-6bca-4a1d-a81f-9f0b0b0b0b0b", + "created_at": "2026-08-17T16:00:01.324Z", + "updated_at": "2026-08-17T16:00:03.123Z", + "submitted_at": "2026-08-17T16:00:01.324Z", + "filled_at": "2026-08-17T16:00:03.123Z", + "expired_at": null, + "canceled_at": null, + "failed_at": null, + "replaced_at": null, + "replaced_by": null, + "replaces": null, + "asset_id": "b0b6dd9d-8b9b-48a9-ba46-b9d54906e415", + "symbol": "AAPL", + "asset_class": "us_equity", + "notional": null, + "qty": "0.7577533883593", + "filled_qty": "0.7577533883593", + "filled_avg_price": "131.9700139996", + "order_class": "simple", + "order_type": "market", + "type": "market", + "side": "buy", + "time_in_force": "day", + "limit_price": null, + "stop_price": null, + "status": "filled", + "extended_hours": false, + "legs": null, + "trail_percent": null, + "trail_price": null, + "hwm": null, + "subtag": null, + "source": "api" +} diff --git a/tests/fixtures/alpaca_order_placed.json b/tests/fixtures/alpaca_order_placed.json new file mode 100644 index 00000000..a0006572 --- /dev/null +++ b/tests/fixtures/alpaca_order_placed.json @@ -0,0 +1,36 @@ +{ + "id": "61e21a5c-c317-4942-8d86-7a1fc4760d7b", + "client_order_id": "eb9e2897-6bca-4a1d-a81f-9f0b0b0b0b0b", + "created_at": "2026-08-17T16:00:01.324Z", + "updated_at": "2026-08-17T16:00:01.324Z", + "submitted_at": "2026-08-17T16:00:01.324Z", + "filled_at": null, + "expired_at": null, + "canceled_at": null, + "failed_at": null, + "replaced_at": null, + "replaced_by": null, + "replaces": null, + "asset_id": "b0b6dd9d-8b9b-48a9-ba46-b9d54906e415", + "symbol": "AAPL", + "asset_class": "us_equity", + "notional": "100", + "qty": null, + "filled_qty": "0", + "filled_avg_price": null, + "order_class": "simple", + "order_type": "market", + "type": "market", + "side": "buy", + "time_in_force": "day", + "limit_price": null, + "stop_price": null, + "status": "accepted", + "extended_hours": false, + "legs": null, + "trail_percent": null, + "trail_price": null, + "hwm": null, + "subtag": null, + "source": "api" +} diff --git a/tests/fixtures/alpaca_positions.json b/tests/fixtures/alpaca_positions.json new file mode 100644 index 00000000..1660a889 --- /dev/null +++ b/tests/fixtures/alpaca_positions.json @@ -0,0 +1,40 @@ +[ + { + "asset_id": "b0b6dd9d-8b9b-48a9-ba46-b9d54906e415", + "symbol": "AAPL", + "exchange": "NASDAQ", + "asset_class": "us_equity", + "avg_entry_price": "131.50", + "qty": "3", + "qty_available": "3", + "side": "long", + "market_value": "396.00", + "cost_basis": "394.50", + "unrealized_pl": "1.50", + "unrealized_plpc": "0.00380228136870", + "unrealized_intraday_pl": "1.50", + "unrealized_intraday_plpc": "0.00380228136870", + "current_price": "132.00", + "lastday_price": "131.90", + "change_today": "0.00075815096283" + }, + { + "asset_id": "e11aef0c-8978-4c57-8d68-2d5a1a1a1a1a", + "symbol": "TSLA", + "exchange": "NASDAQ", + "asset_class": "us_equity", + "avg_entry_price": "250.00", + "qty": "5", + "qty_available": "4", + "side": "long", + "market_value": "1250.00", + "cost_basis": "1250.00", + "unrealized_pl": "0", + "unrealized_plpc": "0", + "unrealized_intraday_pl": "0", + "unrealized_intraday_plpc": "0", + "current_price": "250.00", + "lastday_price": "250.00", + "change_today": "0" + } +] diff --git a/tests/fixtures/alpaca_quote_latest.json b/tests/fixtures/alpaca_quote_latest.json new file mode 100644 index 00000000..7cf3a32e --- /dev/null +++ b/tests/fixtures/alpaca_quote_latest.json @@ -0,0 +1,14 @@ +{ + "quote": { + "t": "2026-08-17T15:59:59.999Z", + "ax": "Q", + "ap": 100.01, + "as": 100, + "bx": "Q", + "bp": 99.99, + "bs": 200, + "c": ["R"], + "z": "C" + }, + "symbol": "AAPL" +} diff --git a/tests/fixtures/alpaca_quote_no_ask.json b/tests/fixtures/alpaca_quote_no_ask.json new file mode 100644 index 00000000..5f68d82e --- /dev/null +++ b/tests/fixtures/alpaca_quote_no_ask.json @@ -0,0 +1,14 @@ +{ + "quote": { + "t": "2026-08-17T16:00:00.000Z", + "ax": "", + "ap": 0, + "as": 0, + "bx": "Q", + "bp": 99.99, + "bs": 200, + "c": ["R"], + "z": "C" + }, + "symbol": "AAPL" +} diff --git a/uv.lock b/uv.lock index cc86e970..166d7eef 100644 --- a/uv.lock +++ b/uv.lock @@ -8,6 +8,7 @@ resolution-markers = [ [manifest] members = [ + "keel-broker-alpaca", "keel-broker-api", "keel-broker-coinbase", "keel-broker-fake", @@ -477,6 +478,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "keel-broker-alpaca" +version = "0.9.3" +source = { editable = "packages/keel-broker-alpaca" } +dependencies = [ + { name = "keel-broker-api" }, + { name = "keel-core" }, + { name = "requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "keel-broker-api", editable = "packages/keel-broker-api" }, + { name = "keel-core", editable = "packages/keel-core" }, + { name = "requests", specifier = ">=2.32.0" }, +] + [[package]] name = "keel-broker-api" version = "0.9.3" @@ -576,6 +594,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "keel-broker-alpaca" }, { name = "keel-broker-fake" }, { name = "keel-broker-robinhood" }, { name = "mypy" }, @@ -595,6 +614,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "keel-broker-alpaca", editable = "packages/keel-broker-alpaca" }, { name = "keel-broker-fake", editable = "packages/keel-broker-fake" }, { name = "keel-broker-robinhood", editable = "packages/keel-broker-robinhood" }, { name = "mypy", specifier = ">=1.18.0" }, From 0258c77d28f2c2a1e2506f95895b12b08a102508 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Tue, 18 Aug 2026 20:14:37 -0400 Subject: [PATCH 2/3] fix(broker-alpaca): NaN-safe money coercion, host-map-only construction, quoted path segments Adversarial-review findings on the new keel-broker-alpaca package: 1. _decimal_or_none now rejects non-finite Decimals explicitly: JSON NaN/Infinity tokens arrive via parse_constant (not parse_float) and Decimal(str(...)) parses them without raising, so the except never fired -- NaN crashed preview_order (bid > 0) and get_balances (min()), Infinity compared as a real price, and NaN silently poisoned Candle OHLCV. Non-finite now lands in the same None path as any unparseable leaf, keeping the preview's errors invariant true. 2. Removed the dead trading_host=/data_host= constructor escapes (zero callers): TRADING_HOSTS is now the only map from an environment choice to a host, making the README's no-paper-to-live-path claim literally true of the signature. 3. Percent-encode interpolated path segments (quote(..., safe='')) at every site: /v2/orders/{id} on GET/DELETE and /v2/stocks/{symbol}/bars|quotes/latest, so a '/' or '?' inside an id or symbol can never reshape the request. 4. to_unix_seconds refuses offset-less timestamps (ValueError) instead of letting .timestamp() silently assume the host's local zone. 5. Reworded the SEC Section 31 provenance: advisory 2026-2 (.60/M) is already in force (2026-04-04), Alpaca's page is the stale side, and the encoded .90 deliberately tracks the venue's own figure (~/bin/zsh.02/k, conservative) with the delta as the re-measurement trigger. All tests written red-first; gates: 3110 passed/1 skipped, ruff clean, mypy clean. --- packages/keel-broker-alpaca/README.md | 17 +++-- .../keel_broker_alpaca/adapter.py | 14 +++- .../keel_broker_alpaca/fees.py | 15 +++-- .../keel_broker_alpaca/translate.py | 15 ++++- .../keel_broker_alpaca/transport.py | 34 ++++++---- tests/broker_alpaca/test_adapter.py | 66 +++++++++++++++++++ tests/broker_alpaca/test_translate.py | 10 +++ tests/broker_alpaca/test_transport.py | 36 ++++++++++ 8 files changed, 179 insertions(+), 28 deletions(-) diff --git a/packages/keel-broker-alpaca/README.md b/packages/keel-broker-alpaca/README.md index 14cc63ae..e53bf9b2 100644 --- a/packages/keel-broker-alpaca/README.md +++ b/packages/keel-broker-alpaca/README.md @@ -9,8 +9,10 @@ from any third-party Alpaca adapter. "Alpaca" appears here solely to identify wh package talks to. US equities, cash account, long-only, regular session. Paper and live are separate -hosts selected by an explicit `endpoint` choice — there is no configuration path from a -paper credential to `https://api.alpaca.markets`, by construction. +hosts selected by an explicit `endpoint` choice — `transport.TRADING_HOSTS` is the only +construction path to a trading host (no constructor parameter accepts a host URL), so +there is no configuration path from a paper credential to +`https://api.alpaca.markets`, by construction. ## What works @@ -30,11 +32,12 @@ paper credential to `https://api.alpaca.markets`, by construction. Commission is $0. Sells carry regulatory pass-throughs, modelled in `fees.py` with the rates as provenance-commented constants: -- **SEC Section 31**: $22.90 per $1,000,000 of sale proceeds — Alpaca's own - regulatory-fees page ($27.80 previously; the SEC adjusts the rate periodically, and - its advisory 2026-2 moves it to $20.60 per $1M as of 2026-04-04 — a documented - re-measurement point, encoded as the venue's published figure until Alpaca's page - moves). +- **SEC Section 31**: $22.90 per $1,000,000 of sale proceeds — the figure Alpaca's own + regulatory-fees page still publishes. The SEC's advisory 2026-2 rate ($20.60 per $1M) + has been in force since 2026-04-04, so the venue's page is the stale side; the model + deliberately tracks what the venue itself charges, which over-states the statutory + rate by ~$0.02 per $10k (conservative for a sell preview's proceeds), and that delta + is the re-measurement trigger for when Alpaca's page updates. - **FINRA TAF**: $0.000166 per share, capped at $8.30 per trade — the cap is on Alpaca's page; the per-share rate is FINRA Schedule A §4(b)(7), in force since 2021-01-01. diff --git a/packages/keel-broker-alpaca/keel_broker_alpaca/adapter.py b/packages/keel-broker-alpaca/keel_broker_alpaca/adapter.py index 3583cbda..b23393a1 100644 --- a/packages/keel-broker-alpaca/keel_broker_alpaca/adapter.py +++ b/packages/keel-broker-alpaca/keel_broker_alpaca/adapter.py @@ -482,13 +482,25 @@ def _decimal_or_none(value: Any) -> Decimal | None: transport already parses unquoted numbers as `Decimal`, and `Decimal(str(value))` here lands both shapes on the same exact number. `None` rather than zero: an absent number and a zero number must never be the same value at a preview gate. + + Non-finite values are `None` too, and the check is explicit because the `except` + below never fires for them: JSON's `NaN`/`Infinity` tokens arrive via + `parse_constant` (not `parse_float`) as `float("nan")`/`float("inf")`, and + `Decimal(str(...))` parses BOTH without raising -- `Decimal("NaN")` then crashes any + ordering comparison (`bid > 0`, `min(buying_power, cash)`) and `Decimal("Infinity")` + compares as a real price. A non-finite number is not a money value; refusing it here + is what keeps the preview's "every path that could not price the order populates + `errors`" invariant true for these rows too. """ if value is None or isinstance(value, bool): return None try: - return Decimal(str(value)) + parsed = Decimal(str(value)) except (InvalidOperation, ValueError, TypeError): return None + if not parsed.is_finite(): + return None + return parsed def _terminal_unknown(order_id: str) -> OrderStatus: diff --git a/packages/keel-broker-alpaca/keel_broker_alpaca/fees.py b/packages/keel-broker-alpaca/keel_broker_alpaca/fees.py index 6c9bf6b6..de745099 100644 --- a/packages/keel-broker-alpaca/keel_broker_alpaca/fees.py +++ b/packages/keel-broker-alpaca/keel_broker_alpaca/fees.py @@ -9,12 +9,15 @@ Provenance (all read 2026-08-17): -* **SEC Section 31 fee** -- charged on SELLS, per $1,000,000 of principal. Alpaca's own - regulatory-fees page (https://alpaca.markets/support/regulatory-fees) states the - current rate as $22.90 per $1M ($27.80 previously). The SEC adjusts this rate - periodically by fee-rate advisory (advisory 2026-2 moves it to $20.60 per $1M as of - 2026-04-04); the venue's published figure is the one encoded, and the drift is a - documented re-measurement point, not a silent correction. +* **SEC Section 31 fee** -- charged on SELLS, per $1,000,000 of principal. The SEC's + advisory 2026-2 rate ($20.60 per $1M) took effect 2026-04-04 and IS the rate in force; + Alpaca's own regulatory-fees page (https://alpaca.markets/support/regulatory-fees) + still publishes $22.90 ($27.80 previously) -- the venue's page is the stale side of + the two. The encoded $22.90 deliberately tracks what the venue itself charges, which + over-states the statutory rate by ~$0.02 per $10k -- the conservative direction for a + sell preview's proceeds -- and that delta is the documented re-measurement trigger: + when Alpaca's page moves to the advisory figure, the constant and this provenance + move with it. * **FINRA Trading Activity Fee (TAF)** -- charged on SELLS, per share, capped per trade. The cap ($8.30 for equities) is on Alpaca's page above; the per-share rate ($0.000166) is FINRA's, Schedule A to the FINRA By-Laws §4(b)(7) (SR-FINRA-2020-032, in force since diff --git a/packages/keel-broker-alpaca/keel_broker_alpaca/translate.py b/packages/keel-broker-alpaca/keel_broker_alpaca/translate.py index ba87de0e..37de2c89 100644 --- a/packages/keel-broker-alpaca/keel_broker_alpaca/translate.py +++ b/packages/keel-broker-alpaca/keel_broker_alpaca/translate.py @@ -224,8 +224,21 @@ def to_unix_seconds(value: str) -> int: Venue timestamps can carry fractional seconds and (per the schema) explicit offsets; fractional seconds truncate because `Candle.ts` is whole seconds and a bar's open time is second-aligned anyway. + + An offset-less timestamp is REFUSED, never read as local time: without an offset + `fromisoformat` yields a naive datetime whose `.timestamp()` silently assumes the + host's zone, so the same bar would timestamp differently per machine. `ValueError` + is this module's refusal signal (`to_timeframe`'s), and refusing is the fail-closed + direction -- the venue's contract sends `Z` or an explicit offset, so anything else + is garbage, not a zone to guess. """ - return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()) + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + if parsed.tzinfo is None: + raise ValueError( + f"alpaca timestamp {value!r} carries no UTC offset; refusing to read a naive " + "datetime as local time" + ) + return int(parsed.timestamp()) __all__ = [ diff --git a/packages/keel-broker-alpaca/keel_broker_alpaca/transport.py b/packages/keel-broker-alpaca/keel_broker_alpaca/transport.py index f728d92c..c494bca9 100644 --- a/packages/keel-broker-alpaca/keel_broker_alpaca/transport.py +++ b/packages/keel-broker-alpaca/keel_broker_alpaca/transport.py @@ -133,11 +133,11 @@ def get_latest_quote(self, symbol: str, feed: str) -> Any: ... class AlpacaTransport: """The live, network-backed `Transport`: header-authed JSON over HTTPS. - The trading host is derived from `endpoint` and cannot be overridden -- a paper - configuration must be structurally unable to reach the live venue. The `trading_host` - and `data_host` constructor parameters exist for tests pointing at a local recorder; - they are explicit escapes, not a configuration surface, and nothing in this workspace - passes them in production code. + The trading host is derived from `endpoint` and NOTHING else: the constructor accepts + no host URL of any kind (there were `trading_host`/`data_host` keyword escapes here + once -- zero callers, dead surface, removed), so `TRADING_HOSTS` is the only map from + an environment choice to a host and a paper credential cannot be pointed at the live + venue by any configuration path. """ def __init__( @@ -150,8 +150,6 @@ def __init__( timeout: float = 10.0, max_attempts: int = 3, sleep: Callable[[float], None] = time.sleep, - trading_host: str | None = None, - data_host: str | None = None, ) -> None: if endpoint not in TRADING_HOSTS: raise ValueError( @@ -171,8 +169,8 @@ def __init__( self._timeout = timeout self._max_attempts = max_attempts self._sleep = sleep - self._trading_host = trading_host if trading_host is not None else TRADING_HOSTS[endpoint] - self._data_host = data_host if data_host is not None else DATA_HOST + self._trading_host = TRADING_HOSTS[endpoint] + self._data_host = DATA_HOST @property def endpoint(self) -> str: @@ -273,6 +271,9 @@ def _request_json( URL per request, so a recorded call is exactly what the venue received. Like the Robinhood transport, `quote_via=quote` (not `quote_plus`) so a `+` in a value is never decoded server-side as a space, and `safe=""` so nothing rides unencoded. + The same discipline covers PATH segments: callers percent-encode every + interpolated id/symbol with `quote(..., safe="")`, so a `/` or `?` inside one can + never reshape the request into a different resource. `parse_float=Decimal`, never `response.json()`: this venue mixes quoted and unquoted money fields (see the module docstring), and the parser is the only @@ -313,7 +314,7 @@ def get_order(self, order_id: str) -> Any: rather than launder a network blip into "this order does not exist". """ try: - return self._request_json("GET", f"/v2/orders/{order_id}") + return self._request_json("GET", f"/v2/orders/{quote(order_id, safe='')}") except AlpacaAPIError as exc: if exc.status_code == 404: return None @@ -328,7 +329,8 @@ def cancel_order(self, order_id: str) -> Any: filled) are returned as statuses rather than raised because both are ordinary answers the adapter maps to `False`; every other failure raises. """ - response = self._send("DELETE", f"{self._trading_host}/v2/orders/{order_id}") + path = f"/v2/orders/{quote(order_id, safe='')}" + response = self._send("DELETE", f"{self._trading_host}{path}") status = int(getattr(response, "status_code", 0)) if status < 400 or status in (404, 422): return status @@ -355,7 +357,10 @@ def get_bars( if page_token is not None: params["page_token"] = page_token return self._request_json( - "GET", f"/v2/stocks/{symbol}/bars", host=self._data_host, params=params + "GET", + f"/v2/stocks/{quote(symbol, safe='')}/bars", + host=self._data_host, + params=params, ) def get_latest_quote(self, symbol: str, feed: str) -> Any: @@ -365,7 +370,10 @@ def get_latest_quote(self, symbol: str, feed: str) -> Any: signal the adapter treats as "no book on that side", not a price of zero. """ return self._request_json( - "GET", f"/v2/stocks/{symbol}/quotes/latest", host=self._data_host, params={"feed": feed} + "GET", + f"/v2/stocks/{quote(symbol, safe='')}/quotes/latest", + host=self._data_host, + params={"feed": feed}, ) diff --git a/tests/broker_alpaca/test_adapter.py b/tests/broker_alpaca/test_adapter.py index 644badcf..3fe2a2e6 100644 --- a/tests/broker_alpaca/test_adapter.py +++ b/tests/broker_alpaca/test_adapter.py @@ -260,6 +260,18 @@ def test_an_unknown_endpoint_is_refused_at_construction(self) -> None: with pytest.raises(ValueError, match="endpoint"): AlpacaAdapter(endpoint=bad) + def test_no_constructor_parameter_accepts_a_host_url(self) -> None: + """The `trading_host`/`data_host` keyword escapes are GONE, so the documented + endpoint-to-host map is the only construction path to a trading host: no + parameter accepts a host URL at all, which is what makes the README's "no + configuration path from a paper credential to the live host, by construction" + literally true (FR-11). `TypeError` is Python's own "no such keyword" answer -- + there is nothing to validate because there is nothing to pass.""" + with pytest.raises(TypeError): + AlpacaTransport("key-id", "secret", trading_host=LIVE_TRADING_HOST) # type: ignore[call-arg] + with pytest.raises(TypeError): + AlpacaTransport("key-id", "secret", data_host="https://example.test") # type: ignore[call-arg] + def test_the_data_tier_is_a_declared_choice_not_an_assumption(self) -> None: """IEX (free) vs SIP is a declared capability (FR-5): the adapter names its feed on every market-data request instead of letting the venue default it, because the @@ -327,6 +339,22 @@ def test_a_short_position_row_is_not_reported_as_a_holding(self) -> None: balances = AlpacaAdapter(transport).get_balances() assert [b.currency for b in balances] == ["USD"] + def test_a_nonfinite_buying_power_is_handled_not_a_crash(self) -> None: + """A NaN `buying_power` arrives as `float("nan")` (the `parse_constant` path, not + `parse_float`), and `min()` over a NaN `Decimal` raises. The existing + balances convention for a money field that cannot be parsed is the same as an + absent one -- read as zero via the `or Decimal("0")` every balance field carries + -- so a garbage spendable figure never reaches a `Balance` row and nothing + raises.""" + account = load_fixture("alpaca_account.json") + account["buying_power"] = float("nan") + + balances = AlpacaAdapter(FakeTransport(account=account)).get_balances() + + usd = {b.currency: b for b in balances}["USD"] + assert usd.available == Decimal("0"), "an unparseable buying power reads as zero" + assert usd.total == Decimal("102086.50"), "the parseable cash figure is untouched" + # --------------------------------------------------------------------------------------------- # Candles (FR-5, FR-10's adjusted/raw policy) @@ -367,6 +395,24 @@ def test_every_unsupported_granularity_is_refused(self) -> None: with pytest.raises(ValueError, match="timeframe"): adapter.get_candles(_PRODUCT, granularity, 0, 86_400) + def test_a_nonfinite_bar_value_is_never_stored_in_a_candle(self) -> None: + """A NaN high arrives as `float("nan")`, and `Decimal("NaN")` is TRUTHY -- so + without an explicit finiteness check the `or Decimal("0")` fallback never fires + and the NaN rides into `Candle.high` silently, poisoning every indicator that + touches the series. The module's existing convention for an unparseable bar leaf + is the same as an absent one: read as zero, never as the venue's garbage.""" + bars = load_fixture("alpaca_bars_page1.json") + bars["bars"][0]["h"] = float("nan") + bars["next_page_token"] = None + + candles = AlpacaAdapter(FakeTransport(bars_pages=[bars])).get_candles( + _PRODUCT, Granularity.ONE_DAY, 1_700_000_000, 1_700_086_400 + ) + + assert len(candles) == 2, "both fixture bars survive; only the NaN leaf changes" + assert candles[0].high == Decimal("0"), "the unparseable-leaf-reads-as-zero rule" + assert candles[0].close == Decimal("131.9"), "parseable leaves are untouched" + # --------------------------------------------------------------------------------------------- # Preview: synthesized from the book (FR-4, FR-7) @@ -447,6 +493,26 @@ def test_a_quote_with_no_active_ask_leaves_the_buy_unpriced_and_says_so(self) -> assert preview.errors, "an unpriced leg must appear in errors" assert any("ask" in e for e in preview.errors) + def test_a_nonfinite_quote_side_is_unpriced_and_reported_never_a_crash(self) -> None: + """JSON `NaN`/`Infinity` tokens ride `parse_constant`, not `parse_float`, so they + reach the adapter as `float("nan")`/`float("inf")` -- and `Decimal(str(...))` + parses BOTH without raising, which means the `except` in `_decimal_or_none` never + fires. `Decimal("NaN")` then crashes the `bid > 0` comparison and `Decimal( + "Infinity")` compares `> 0` as a real price; a non-finite side must land in the + same "no active side" path as a zero one -- `errors` says so, nothing raises -- + for the preview docstring's "every path that could not price the order populates + `errors`" invariant to hold.""" + quote = json.loads('{"quote": {"bp": NaN, "ap": Infinity}}', parse_float=Decimal) + preview = AlpacaAdapter(FakeTransport(quote=quote)).preview_order( + MarketIOCByQuote(product_id=_PRODUCT, side=Side.BUY, quote_size=Decimal("100")) + ) + + assert preview.est_base_size == Decimal("0") + assert preview.errors, "a non-finite quote side must appear in errors" + assert any("ask" in e for e in preview.errors) + assert preview.detail["best_bid"] == "none" + assert preview.detail["best_ask"] == "none" + def test_a_non_usd_product_is_refused_before_any_request_is_made(self) -> None: transport = _full_transport() with pytest.raises(UnsupportedOrder, match="USD"): diff --git a/tests/broker_alpaca/test_translate.py b/tests/broker_alpaca/test_translate.py index 04a4c44e..6cd84ead 100644 --- a/tests/broker_alpaca/test_translate.py +++ b/tests/broker_alpaca/test_translate.py @@ -208,3 +208,13 @@ def test_rfc3339_and_epoch_round_trip() -> None: assert to_unix_seconds("2026-08-14T14:30:00.999Z") == ts # ...and an explicit offset must parse too, not only a trailing Z. assert to_unix_seconds("2026-08-14T10:30:00-04:00") == ts + + +def test_an_offset_less_timestamp_is_refused_never_read_as_local_time() -> None: + """`fromisoformat` without an offset yields a NAIVE datetime, and `.timestamp()` + silently assumes the host's local zone -- the same bar would timestamp differently + per machine. The fail-closed rule (the venue contract sends `Z` or an explicit + offset, so anything else is garbage): refuse with `ValueError`, this module's + refusal signal (`to_timeframe`'s), rather than guess the zone.""" + with pytest.raises(ValueError, match="offset"): + to_unix_seconds("2026-08-14T14:30:00") diff --git a/tests/broker_alpaca/test_transport.py b/tests/broker_alpaca/test_transport.py index 5ea7115c..37a73206 100644 --- a/tests/broker_alpaca/test_transport.py +++ b/tests/broker_alpaca/test_transport.py @@ -243,6 +243,42 @@ def test_get_positions_and_get_clock_use_their_documented_paths(http: Any) -> No assert recorder.calls[1]["url"] == f"{PAPER_TRADING_HOST}/v2/clock" +# --------------------------------------------------------------------------------------------- +# Path-segment encoding: a symbol or order id is always ONE segment +# --------------------------------------------------------------------------------------------- + + +def test_a_symbol_containing_a_path_separator_stays_one_segment(http: Any) -> None: + """A malformed product id (`A/B-USD`) survives `to_symbol` as `A/B`, and an unquoted + `/` would reshape `/v2/stocks/A/B/bars` into a DIFFERENT resource. Path segments are + percent-encoded with `safe=""` exactly like the query string, so the symbol rides as + `A%2FB` -- one segment, whatever it contains.""" + recorder = http([_FakeResponse(payload={"bars": []}), _FakeResponse(payload={"quote": {}})]) + transport = _transport() + + transport.get_bars("A/B", "1Day", "2026-08-01T00:00:00Z", "2026-08-14T00:00:00Z", "iex") + transport.get_latest_quote("A/B", "iex") + + assert urlsplit(recorder.calls[0]["url"]).path == "/v2/stocks/A%2FB/bars" + assert urlsplit(recorder.calls[1]["url"]).path == "/v2/stocks/A%2FB/quotes/latest" + + +def test_an_order_id_with_query_or_traversal_characters_stays_one_segment(http: Any) -> None: + """A `?` in an order id would open a query string and `../` would walk out of + `/v2/orders/`; both are data in an opaque id, so both ride percent-encoded (`%3F`, + `%2F`) and the request is never reshaped -- there is no query part at all.""" + recorder = http([_FakeResponse(payload={"id": "o1"}), _FakeResponse(204)]) + transport = _transport() + + transport.get_order("o1?x=../evil") + transport.cancel_order("o1?x=../evil") + + expected = f"{PAPER_TRADING_HOST}/v2/orders/o1%3Fx%3D..%2Fevil" + assert recorder.calls[0]["url"] == expected + assert recorder.calls[1]["url"] == expected + assert all(urlsplit(call["url"]).query == "" for call in recorder.calls) + + # --------------------------------------------------------------------------------------------- # Response handling: money as Decimal, the 404 sentinel, cancel statuses # --------------------------------------------------------------------------------------------- From e3ba5ba4d75367f08cbbad9832633c566a3bf9b9 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Tue, 18 Aug 2026 20:20:18 -0400 Subject: [PATCH 3/3] ci: trigger checks for the follow-up PR