Skip to content

feat(brokers): add the robinhood crypto v2 adapter behind the broker port - #192

Merged
eaitbrahim merged 1 commit into
mainfrom
feat/robinhood-broker
Aug 9, 2026
Merged

feat(brokers): add the robinhood crypto v2 adapter behind the broker port#192
eaitbrahim merged 1 commit into
mainfrom
feat/robinhood-broker

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

What this adds

packages/keel-broker-robinhood/ — a first-party adapter implementing keel's Broker port
against the official Robinhood Crypto Trading API v2, registered under the keel.brokers
entry-point group and passing the shipped BrokerConformanceTests against a canned, zero-network
transport.

Structure mirrors keel-broker-coinbase: an injected Transport Protocol (defaulting to None
so capabilities() is answerable offline), translate.py as the single place keel's order model
becomes Robinhood's, and adapter.py holding the capability declaration.

BrokerCapabilities(
    venue="robinhood",
    supported_orders=frozenset({"market_ioc_base", "limit_gtc", "stop_limit_gtc"}),
    supports_native_preview=False,
    synthesizes_preview=True,
    supports_fee_summary=True,
    quote_currencies=frozenset({"USD"}),
    asset_classes=frozenset({"spot"}),
)

v2 exclusively, never v1. Two contract-level reasons: v1's cancel returns text/plain "Cancel
request was submitted", which acknowledges a request and cannot satisfy cancel_order's "return
True only when the venue confirms the cancellation for THIS order id"; and v1 carries neither
the per-order fee_charged get_order needs nor the fee_tier_status get_fee_summary is built
from.

The three capability gaps

1. No candles. The v2 API has no OHLC, historical, or candles endpoint at all — only
best_bid_ask and estimated_price. get_candles raises ValueError for every granularity,
which is the port's sanctioned way to say "I serve no bars" (_any_candles catches ValueError
per granularity and skips). It must not return []: an empty list reads downstream as a
statement about the market when the truth is a statement about the API. Robinhood is an
execution venue here; bars come from elsewhere.

2. No quote-sized market orders — so this adapter cannot open positions. Robinhood's
market_order_config accepts only asset_quantity. keel places entries as MarketIOCByQuote, so
market_ioc_quote is absent from supported_orders and raises UnsupportedOrder. Synthesising
it by dividing an estimated price is deliberately not implemented — it would accept an order
sized in one basis and place an order sized in another, on the live-money path, invisibly.
translate.to_order_body refuses it a second time as defence in depth. What the adapter can do
is exits (market_ioc_base), resting take-profit limits, and protective stop-limits.

market_ioc_base is declared even though Robinhood accepts no time_in_force on market orders.
That is a naming impedance, not a capability lie: a market order is immediate by construction and
there is no resting-market variant to confuse it with. The kind that would be a lie is the
quote-sized one, and it is not declared.

3. No sandbox. Robinhood ships no test environment. Every test runs against a canned
in-memory transport; the conformance suite is the only end-to-end signal short of real money.
This is also why RobinhoodAdapter() defaults transport=None — there is no "harmless"
configuration that talks to a real endpoint.

A fourth, smaller gap worth calling out at review time: get_fee_summary().fees_usd is always
Decimal("0"), because v2 exposes per-order fee_charged but no account-level fees-paid total.
FeeSummary's docstring says subscription-lapse detection leans on that field, so against this
venue the test is inert and detection falls back to attestation alone. Documented loudly in both
the method docstring and the README; closing it means paging order history and summing
fee_charged, which needs its own rate-limit design.

Not wired to the live path

Deliberately. keel/commands/_common.py still constructs CoinbaseClient directly and the
broker-port migration (Phase B) has not landed. Installing this package registers robinhood for
entry-point discovery and nothing more — no command, rule, or rail constructs a
RobinhoodAdapter. Stated in the README too.

keel-broker-robinhood is a dev-group dependency, not a runtime dependency of keel-trader.
It is an optional venue; making it a hard dependency would put an Ed25519 stack (pynacl) into
every install for an adapter the live path cannot reach. Coinbase is a hard dependency only
because keel/ still imports its SDK directly, and no such import exists here. The dev-group
entry is what makes the conformance suite run in CI. pynacl is scoped to this package alone.

Design notes

  • Signing (x-api-key / x-signature / x-timestamp, Ed25519 over
    f"{api_key}{timestamp}{path}{method}{body}") lives entirely in transport.py, with
    sign_payload/build_headers as free functions so the rule is unit-testable against a known
    throwaway keypair with zero network. The query string is built once and reused byte-for-byte
    for both the signature and the wire — a divergence there is a 401 indistinguishable from a bad
    key.
  • account_number is resolved once from GET /accounts/ and cached on the transport. It is
    an authentication-scope detail of this one venue, not a trading concept, so it never reaches
    the adapter — the adapter calls get_holdings() with no arguments and never learns an account
    number was involved.
  • None means 404 and nothing else. The transport returns None only when Robinhood says
    the id does not exist; every other failure raises. Laundering a 5xx or a timeout into None
    would make the adapter report a live, resting order as terminally FAILED.
  • cancel_order returns True only when the returned order's id matches and its state
    is "canceled" (Robinhood's single-l spelling). A cancel is asynchronous here, so a response
    still reading open is an unanswered question, not a failure — the order is re-polled once,
    never in a loop, because a retry loop on the executor's path would block an exit.
  • Unknown venue states map to PENDING, not FAILED. An unrecognised state means the
    adapter does not know the outcome; PENDING keeps the order under observation, while FAILED
    declares a terminal outcome nobody observed and could let the engine re-enter a position that
    is actually live.
  • to_symbol refuses a non-USD quote leg by name. Rewriting BTC-USDC to BTC-USD would
    swap the settlement asset underneath the caller; passing it through would be a rejection that
    looks like an outage.
  • volume_window="trailing_30d", unlike Coinbase's "unknown" — here the field is literally
    named thirty_day_volume, so declaring the window is honest rather than a guess.

Follow-up work this does not do

  1. Sum fee_charged across order history to give get_fee_summary a real fees_usd.
  2. Enforce trading_pairs' asset_increment / quote_increment / min_order_amount before
    placement, so a size is rounded to the venue's tick locally instead of being rejected.
  3. Rate limiting (100 req/min sustained, 300 burst) — the transport does not throttle today.
  4. Decide, at the Phase B migration, how an engine composes a candle source with an execution
    venue that serves none. Until that exists this adapter cannot be a venue's sole broker.
  5. Nothing reads ROBINHOOD_API_KEY / ROBINHOOD_PRIVATE_KEY yet — keel_core.config.load_secrets
    still only knows the CDP names.

Not merging — that is the user's call.

…port

Implements the `Broker` port against the official Robinhood Crypto Trading
API v2, registered under `keel.brokers` and passing `BrokerConformanceTests`
against a canned, zero-network transport.

v2 exclusively: v1's cancel returns text/plain "Cancel request was submitted",
which acknowledges a REQUEST and cannot satisfy cancel_order's per-order
confirmation contract, and v1 carries neither `fee_charged` nor
`fee_tier_status`.

Three capability gaps, declared rather than papered over:

- No candles. This API has no OHLC endpoint at all, so `get_candles` raises
  ValueError for every granularity -- the port's sanctioned "I serve no bars".
  Returning [] would read downstream as a claim about the market.
- No quote-sized market orders. `market_order_config` takes only
  `asset_quantity`, so `market_ioc_quote` is undeclared and refused. keel
  enters via MarketIOCByQuote, so this adapter cannot open positions under the
  current entry model. Synthesising one from an estimated price is
  deliberately not done: it would substitute a different sizing basis on the
  live-money path, silently.
- No sandbox. Every test runs on a canned in-memory transport.

Not wired to the live path: `keel/commands/_common.py` still constructs
CoinbaseClient directly and Phase B has not landed. Installed as a dev-group
dependency, not a runtime dependency of keel-trader -- an optional venue
should not put pynacl into every install.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eaitbrahim
eaitbrahim merged commit 9422c25 into main Aug 9, 2026
1 check passed
@eaitbrahim
eaitbrahim deleted the feat/robinhood-broker branch August 9, 2026 11:56
@eaitbrahim

Copy link
Copy Markdown
Contributor Author

Follow-up: #194 closes the review findings raised against this PR, which merged before those fixes landed.

Headline items, all of which were live on main between the merge and #194:

  • str(Decimal) emitted scientific notation into the order body. str(Decimal("0.00000001")) is "1E-8", and BTC's asset_increment is exactly 0.00000001 — so one satoshi is the smallest order this venue accepts, and a dust-sized exit or stop-limit went out malformed.
  • place_order reported success=True for orders the venue rejected. Robinhood answers a rejection on the happy HTTP path (200, "state": "failed"), so a protective stop could be recorded as resting when it did not exist at the venue.
  • get_order omitted the account_number query param that create_order sends — a 404 there becomes a terminal FAILED with zeroed money for a live resting order.
  • estimated_price's /trading/ path was challenged in review and is correct as written. Confirmed against https://docs.robinhood.com/crypto/trading/: v2 genuinely splits get/api/v2/crypto/trading/estimated_price/ from get/api/v2/crypto/marketdata/best_bid_ask/. v1 is the consistent one, which is where the instinct to change it comes from. No behaviour change — now pinned by a test and anchored to the doc.

Plus the should-fixes (preview error reporting, symbol validation on every preview path, parse_float=Decimal, percent-encoded query strings, cancel_order failing safe on the exit path) and real transport test coverage — the gap that let a wrong endpoint path ship unnoticed in the first place.

@eaitbrahim

Copy link
Copy Markdown
Contributor Author

Follow-up tracking for this PR:

Labelled feature so it groups under Features in the next release's notes rather than falling into the "*" catch-all — .github/release.yml groups by PR label.

eaitbrahim added a commit that referenced this pull request Aug 9, 2026
Follow-ups to #192, which merged before these review fixes landed.

BLOCKERS

* `str(Decimal)` emitted scientific notation into the order body. `str(Decimal("0.00000001"))`
  is `"1E-8"`, and BTC's `asset_increment` is exactly `0.00000001` -- so one satoshi is the
  smallest order this venue accepts and the size a dust-sized exit produces. Every money and
  size field now renders through `translate._render` (`format(d, "f")`). A rejected exit leaves
  a position open while the engine records it closed; a rejected stop-limit leaves a position
  unprotected while local state says otherwise. The test that missed this asserted the property
  over two values that cannot trigger the exponent form; it is now parametrized over values
  that do.

* `place_order` returned `success=True` for orders the venue rejected. Robinhood answers a
  rejected order on the happy HTTP path -- 200, with `"state": "failed"` -- so an `id` alone is
  not evidence the order is live. `failed`/`canceled` now return `success=False`. An
  unrecognised state still reports success, deliberately: reporting failure for a live order
  invites a duplicate placement, which has no recovery.

* `get_order` omitted the `account_number` query param `create_order` sends. A 404 here becomes
  `None`, which the adapter turns into a terminal FAILED with zeroed money for a live resting
  order -- corrupting reconciliation rather than failing loudly. `cancel_order` deliberately
  still sends none: Robinhood documents that endpoint with a path param only.

* The `estimated_price` namespace was challenged in review and is CORRECT as written. Verified
  against https://docs.robinhood.com/crypto/trading/: v2 really does split these two reads,
  `get/api/v2/crypto/trading/estimated_price/` beside
  `get/api/v2/crypto/marketdata/best_bid_ask/`. The asymmetry is real (v1 is the consistent
  one), so it is now pinned by a test and anchored to the doc in a comment.

SHOULD-FIX

* `Preview.errors` is populated on every path that could not price an order. A pricing failure
  previously rendered at the confirm gate as an order that costs nothing.
* `preview_order` validates the symbol on every path, so it can no longer approve a symbol
  `place_order` will refuse with `UnsupportedOrder` after the human has already said yes.
* The transport parses JSON with `parse_float=Decimal`; unquoted numeric money became `float`
  before any `Decimal` saw it. Every fixture quotes its numbers, which is why this was untested.
* Query strings are percent-encoded, with the signed and sent bytes still identical.
* `cancel_order` fails safe to `False` instead of raising -- a raise on the exit path can trap
  a position, which is this codebase's own stated principle.
* `RobinhoodTransport` gains real coverage against a fake HTTP layer: signature/wire
  byte-identity, the 404-vs-raise split, pagination and its `_MAX_PAGES` bound, `_account`
  caching, and the literal endpoint path of every method.

Also: per-call account caching (one `GET /accounts/` per public method), `_paginate` cursor
hardening (non-string cursors, off-host URLs), and a README "must fix before wiring" section
covering the always-passing `fees_usd` lapse check, the un-deduplicated `client_order_id`, and
`Preview.synthetic` having nowhere to render at today's CLI confirm gate.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New capability (groups under Features)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant