fix(brokers): close the review findings on the robinhood adapter - #194
Merged
Conversation
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
Closed
This was referenced Aug 9, 2026
eaitbrahim
added a commit
that referenced
this pull request
Aug 9, 2026
…ery fixture (#218) * fix(brokers): read robinhood's real estimated_price shape, correct every fixture First live run against a real Robinhood credential (#216) proved the response shapes wrong in four places and the probe script wrong in a fifth (#217). F1 (blocker). `_estimated_price` read `price`, which this venue does not send; the unit price is in the column named after the side asked for (`ask`/`bid`). Every market preview against the live venue came back `est_quote_size = 0.000` with `errors` populated. There is deliberately no fallback to the other side's column -- pricing a sell off an ask overstates the proceeds of an exit -- so a row missing the requested side stays unpriced. `preview_order` now also reads the venue's own `est_fee` instead of deriving one from the account tier, and reconciles `est_total_cost` against `price x quantity` and `est_fee` per response rather than assuming either reading. All three self-consistent readings recover the same fee-exclusive notional, which is what `Preview.est_quote_size` is defined to carry; a total matching none of them is priced as sent AND reported through `Preview.errors`. `synthetic=True` and `supports_native_preview=False` are unchanged and now argued for explicitly: `/estimated_price/` prices a quantity, it does not validate an order. F2. Every money value arrives unquoted, so every fixture is now an unquoted JSON number and both fixture loaders decode with `parse_float=Decimal`, the way the transport does. This makes #194's parser change exercised rather than merely present. F3. `trading_pairs` publishes no `min_order_amount` and no `min_order_size`. Removed from the fixture; the transport docstring and README now record that a pre-flight minimum-size check (#198) has no source on this endpoint. F4. `best_bid_ask` rewritten to the observed `symbol`/`bid`/`ask`; the five invented keys are gone. F5. The probe compared a post-pagination aggregate against a raw single page, so it reported `next`/`previous` MISSING AT VENUE on all five probes and buried the real findings. `fixture_shape` normalizes the fixture to what a probe can observe -- envelope stripped, numbers parsed as `Decimal` -- and a test runs the real comparison for every probe. The three order fixtures remain unverified against the venue: observing an order object requires placing a real one. Closes #217 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(brokers): match robinhood's mixed quoting, ask-only total, and quote timestamp Second live probe run against the branch (#217 F6-F8). F6. The venue is NOT uniformly unquoted -- it mixes, and `accounts` mixes within one object: `buying_power` is a quoted string beside an unquoted `fee_tier_status.fee_ratio`. `trading_pairs` and `best_bid_ask` quote all their money; `estimated_price` and `holdings` quote none. Reverted the six over-converted fields to strings so every fixture matches the venue field for field, and recorded the mixed quoting where it can be acted on: the transport's parser comment, `_decimal_or_none`, the README, and an executable test. The `parse_float=Decimal` + `Decimal(str(x))` pairing is what makes both forms land on the same exact number and neither half is optional. F7. `est_total_cost` is sent on the ask side only; a bid row carries `bid`, `quantity`, `fee_ratio` and `est_fee` and no total. The code already priced this correctly by falling through the reconciliation -- now it is intentional: a new `rh_estimated_price_bid.json` (a verbatim live bid row) and a test pinning that a sell prices from `bid x quantity` with the venue's `est_fee`, empty `errors`, and no `/accounts/` round trip. An error on every sell preview is an error nobody reads, so this must stay a clean answer rather than a degraded one. F8. `best_bid_ask` rows carry `timestamp`; added. Also: `rh_estimated_price.json` is now the verbatim live ask row, which settles the reconciliation empirically -- `64975.78 * 0.001 + 0.61726991 == 65.59304991`, i.e. fee-INCLUSIVE, exactly the reading that would have double-counted the fee. The relation is still derived per response, since that is one symbol, one side, one moment, and the bid side omits the field entirely. Preview tests size their specs from the fixture's echoed quantity rather than a literal, so the two cannot drift apart. Since the three order fixtures remain unverified on a venue proven to mix quoting, `get_order` is now tested against both the quoted and unquoted form of the same order and required to produce identical `Decimal`s. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Follow-ups to #192, which merged before these review fixes landed. Every finding below was
raised against that PR; the adapter shipped to
mainwith all four blockers live, so this is thecatch-up. Diff is fix-only — the package itself is already on
mainvia the squash.Not merging — that is the user's call.
Blockers
B1 —
str(Decimal)emitted scientific notation into the order bodystr(Decimal("0.00000001")) == "1E-8", and BTC'sasset_incrementis exactly0.00000001perthis repo's own
tests/fixtures/rh_trading_pairs.json. One satoshi is therefore the smallestorder this venue accepts, not an edge case — it is the size a dust-sized exit produces. Robinhood's
asset_quantity/limit_price/stop_pricehave no exponent form, so that body is malformed.The failure mode is the quiet kind: a rejected exit leaves a position open while the engine
records it closed, and a rejected stop-limit leaves a position unprotected while local state
says there is a stop. Every money and size field now renders through
translate._render(
format(d, "f")), which is positional at every magnitude and neither rounds nor truncates.The test that should have caught this asserted the property over
0.123456789and64000.10—two values that cannot trigger the exponent form. It is now parametrized over values that do
(
0.00000001,0.000000012345,1E+2→100), plus a structural assertion that no renderedfield ever contains an
e. Confirmed failing against the old implementation before fixing.B2 —
place_orderreported success for orders the venue rejectedThe old code checked only that an
idcame back, on the stated reasoning that "a placement thatcomes back at all came back as an order." That is false here: Robinhood answers a rejected order
on the happy HTTP path — 200, with a real order object whose
statereadsfailed.A
StopLimitGTCanswered{"id": "...", "state": "failed"}was recorded as a protective stop thatdoes not exist at the venue.
failedandcancelednow returnPlaceResult(success=False, ...)with the state named in
reason, andbroker_order_id=NonematchingCoinbaseAdapter's failurepath.
An unrecognised state still reports success, deliberately — the asymmetry with
get_orderisargued in the docstring: reporting failure for an order that is actually live invites the caller to
place it again, and a duplicate live order has no recovery, whereas success hands back the id and
lets reconciliation poll (where an unknown state maps to
PENDINGand stays under observation).B3 —
estimated_pricenamespace: the reviewer was wrong, the code was rightResolved from the primary source, not by guessing: https://docs.robinhood.com/crypto/trading/.
The v2 API genuinely splits these two neighbouring reads across namespaces —
— which is exactly what
transport.pyalready did. The instinct to "fix" it is understandablebecause v1 is the consistent one (
/api/v1/crypto/marketdata/estimated_price/). Requiredquery params confirmed as
symbol,side(bid/ask/both),quantity, all three required.No behaviour change. The path is now pinned by a test and anchored to the doc URL with the verbatim
quoted paths in a comment, so this cannot be re-litigated.
B4 —
get_orderomittedaccount_number;cancel_ordercorrectly does notget_ordernow sends the sameaccount_numberquery paramcreate_ordersends, per Robinhood'sown v2 sample client. Omitting it risks a 404 — and a 404 here is quiet corruption, not a loud
failure:
_requestturns it intoNone, andadapter.get_orderturns that into a terminalFAILEDwith zeroed money for an order still resting at the venue.Partially rejecting the finding: the review asked to make all three consistent. Robinhood
documents
post/api/v2/crypto/trading/orders/{id}/cancel/with a path parameter only and noquery-parameter section, and their own v2 sample omits it there while passing it for place/fetch.
Adding an undocumented param would be a guess, and every query byte is signed — so a guess the
venue rejects is a 401 on the cancel path. Left as-is, with the reasoning written down.
Should-fix
Preview.errorspopulated on every unpriced path;_estimated_pricereturnsNonerather thanDecimal("0")so a pricing failure is distinguishable from a free order at the confirm gatepreview_orderapproved symbolsplace_orderrefusesto_symbolvalidated on every preview path —ETH-USDCno longer previews cleanly then raises after the human approvedjson.loads(..., parse_float=Decimal); test uses unquoted numeric money, which all eight existing fixtures quote — the reason this was untestable before_MAX_PAGES,_accountcaching, and the literal endpoint path of every methodurlencode(..., quote_via=quote, safe=""), with signed and sent bytes still byte-identicalcancel_ordercould raise on the exit pathFalse. This codebase's own principle — "a raise on the exit path can trap a position" — with why in the docstringNits
_fee_ratiotakes the account as a parameter, soget_fee_summaryandpreview_ordereach make oneGET /accounts/instead of two-plus. Deliberately notmemoized on the instance —
get_balancesreadsbuying_poweroff the same payload and a staleone would misreport available capital.
_paginatecursor hardening: a non-stringnextno longer raisesAttributeErrorout of a readthe adapter cannot expect to fail that way, and an absolute URL on another host stops pagination
rather than being concatenated onto the base URL.
get_trading_pairs/get_best_bid_askdocumented as deliberately uncalled, with the reasonpre-flight validation is a follow-up rather than a nit fix (a check that runs before every
placement also runs before every exit). Their fixtures are now asserted against.
README
Added a "Must fix BEFORE wiring this to the live path" section so the Phase B migration trips
over it: the
fees_usdhole makes subscription-lapse detection not merely inert butalways-passing against this venue; the per-call
client_order_idpermits duplicate orders onretry; and
keel/cli.py's_interactive_confirmtakes a rawdictand iterates.items(), so ithas nowhere to display
Preview.synthetic(orPreview.errors) — this adapter's synthetic-previewflag is invisible at the confirm gate until that path is migrated.
Gates
Baseline before these changes was
2206 passed, 1 skipped. Skip count unchanged and it is the sameskip — the conformance candles probe. mypy strict still covers
keel_broker_robinhood.*(
pyproject.tomluntouched). No.github/workflows/,config*.yaml, or.dbtouched.🤖 Generated with Claude Code
Closes #196.
Follow-ups deliberately NOT in this PR, tracked separately: #197 (
fees_usdalways 0), #198 (prerequisites before wiring to the live path), #199 (confirm gate cannot displayPreview.synthetic).Context: follow-up to #192, which merged before these fixes landed.