Skip to content

fix(broker-alpaca): NaN-safe money coercion, host-map-only construction, quoted path segments - #383

Closed
eaitbrahim wants to merge 3 commits into
mainfrom
feat/369-keel-broker-alpaca
Closed

fix(broker-alpaca): NaN-safe money coercion, host-map-only construction, quoted path segments#383
eaitbrahim wants to merge 3 commits into
mainfrom
feat/369-keel-broker-alpaca

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Follow-up to #382 (merged pre-review): the adversarial review's five findings, applied TDD red-first.

  1. [major] NaN/Infinity coercion hole_decimal_or_none parsed 'nan'/'Infinity' successfully (JSON NaN arrives via parse_constant), crashing preview_order at bid > 0 and quote_size / crossed, get_balances at min(buying_power, cash), and silently poisoning Candle OHLCV — contradicting the preview docstring's populate-errors invariant. Now is_finite()-guarded; three red-first tests cover quote sides, balances, and bar leaves.
  2. [minor] Host-map-only construction — the dead trading_host=/data_host= overrides (zero callers anywhere) are REMOVED; TRADING_HOSTS is the only endpoint→host map and the README's "no configuration path from a paper credential to the live host, by construction" is now literally true (pinned by test).
  3. [minor] Quoted URL path segments — symbol/order-id interpolations percent-encoded (safe="") at all four sites; A/B-USD and a crafted order id can no longer reshape the request (asserted on the outgoing URL).
  4. [nit] Naive timestamps refused — offset-less RFC3339 no longer silently read as local time; ValueError per the module's refusal convention.
  5. [nit] SEC fee framing — the provenance note now states the advisory rate (.60/$M) is already in force and Alpaca's page is the stale side; the encoded venue-published .90/$M stays deliberate (conservative direction, ~$0.02/$10k) with the delta as the re-measurement trigger.

Gates: uv run pytest -q → 3110 passed, 1 skipped; ruff clean; mypy clean (249 files).

Refs #369 (Phase A hardening).

…r, conformance green

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
…on, 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.
@eaitbrahim eaitbrahim closed this Aug 19, 2026
@eaitbrahim eaitbrahim reopened this Aug 19, 2026
@eaitbrahim

Copy link
Copy Markdown
Contributor Author

Superseded: the squash-merge of #382 left this branch's base conflicting (mergeable_state: dirty). The identical fix commit (0258c77) is cherry-picked onto main as a clean branch — follow-up PR incoming. CI verified green on the fix head via dispatched run 32201108321.

@eaitbrahim eaitbrahim closed this Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant