feat(brokers): add the robinhood crypto v2 adapter behind the broker port - #192
Merged
Conversation
…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>
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
Plus the should-fixes (preview error reporting, symbol validation on every preview path, |
This was referenced Aug 9, 2026
Closed
Closed
Contributor
Author
|
Follow-up tracking for this PR:
Labelled |
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>
This was referenced Aug 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this adds
packages/keel-broker-robinhood/— a first-party adapter implementing keel'sBrokerportagainst the official Robinhood Crypto Trading API v2, registered under the
keel.brokersentry-point group and passing the shipped
BrokerConformanceTestsagainst a canned, zero-networktransport.
Structure mirrors
keel-broker-coinbase: an injectedTransportProtocol (defaulting toNoneso
capabilities()is answerable offline),translate.pyas the single place keel's order modelbecomes Robinhood's, and
adapter.pyholding the capability declaration.v2 exclusively, never v1. Two contract-level reasons: v1's cancel returns
text/plain"Cancelrequest was submitted", which acknowledges a request and cannot satisfy
cancel_order's "returnTrueonly when the venue confirms the cancellation for THIS order id"; and v1 carries neitherthe per-order
fee_chargedget_orderneeds nor thefee_tier_statusget_fee_summaryis builtfrom.
The three capability gaps
1. No candles. The v2 API has no OHLC, historical, or candles endpoint at all — only
best_bid_askandestimated_price.get_candlesraisesValueErrorfor every granularity,which is the port's sanctioned way to say "I serve no bars" (
_any_candlescatchesValueErrorper granularity and skips). It must not return
[]: an empty list reads downstream as astatement 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_configaccepts onlyasset_quantity. keel places entries asMarketIOCByQuote, somarket_ioc_quoteis absent fromsupported_ordersand raisesUnsupportedOrder. Synthesisingit 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_bodyrefuses it a second time as defence in depth. What the adapter can dois exits (
market_ioc_base), resting take-profit limits, and protective stop-limits.market_ioc_baseis declared even though Robinhood accepts notime_in_forceon 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()defaultstransport=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_usdis alwaysDecimal("0"), because v2 exposes per-orderfee_chargedbut no account-level fees-paid total.FeeSummary's docstring says subscription-lapse detection leans on that field, so against thisvenue 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.pystill constructsCoinbaseClientdirectly and thebroker-port migration (Phase B) has not landed. Installing this package registers
robinhoodforentry-point discovery and nothing more — no command, rule, or rail constructs a
RobinhoodAdapter. Stated in the README too.keel-broker-robinhoodis a dev-group dependency, not a runtime dependency ofkeel-trader.It is an optional venue; making it a hard dependency would put an Ed25519 stack (
pynacl) intoevery 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-groupentry is what makes the conformance suite run in CI.
pynaclis scoped to this package alone.Design notes
x-api-key/x-signature/x-timestamp, Ed25519 overf"{api_key}{timestamp}{path}{method}{body}") lives entirely intransport.py, withsign_payload/build_headersas free functions so the rule is unit-testable against a knownthrowaway 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_numberis resolved once fromGET /accounts/and cached on the transport. It isan 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 accountnumber was involved.
Nonemeans 404 and nothing else. The transport returnsNoneonly when Robinhood saysthe id does not exist; every other failure raises. Laundering a 5xx or a timeout into
Nonewould make the adapter report a live, resting order as terminally FAILED.
cancel_orderreturnsTrueonly when the returned order'sidmatches and itsstateis
"canceled"(Robinhood's single-lspelling). A cancel is asynchronous here, so a responsestill reading
openis 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.
PENDING, notFAILED. An unrecognised state means theadapter does not know the outcome;
PENDINGkeeps the order under observation, whileFAILEDdeclares a terminal outcome nobody observed and could let the engine re-enter a position that
is actually live.
to_symbolrefuses a non-USD quote leg by name. RewritingBTC-USDCtoBTC-USDwouldswap 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 literallynamed
thirty_day_volume, so declaring the window is honest rather than a guess.Follow-up work this does not do
fee_chargedacross order history to giveget_fee_summarya realfees_usd.trading_pairs'asset_increment/quote_increment/min_order_amountbeforeplacement, so a size is rounded to the venue's tick locally instead of being rejected.
venue that serves none. Until that exists this adapter cannot be a venue's sole broker.
ROBINHOOD_API_KEY/ROBINHOOD_PRIVATE_KEYyet —keel_core.config.load_secretsstill only knows the CDP names.
Not merging — that is the user's call.