Skip to content

fix(brokers): close the review findings on the robinhood adapter - #194

Merged
eaitbrahim merged 1 commit into
mainfrom
fix/robinhood-review-findings
Aug 9, 2026
Merged

fix(brokers): close the review findings on the robinhood adapter#194
eaitbrahim merged 1 commit into
mainfrom
fix/robinhood-review-findings

Conversation

@eaitbrahim

@eaitbrahim eaitbrahim commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Follow-ups to #192, which merged before these review fixes landed. Every finding below was
raised against that PR; the adapter shipped to main with all four blockers live, so this is the
catch-up. Diff is fix-only — the package itself is already on main via the squash.

Not merging — that is the user's call.

Blockers

B1 — str(Decimal) emitted scientific notation into the order body

str(Decimal("0.00000001")) == "1E-8", and BTC's asset_increment is exactly 0.00000001 per
this repo's own tests/fixtures/rh_trading_pairs.json. One satoshi is therefore the smallest
order this venue accepts
, not an edge case — it is the size a dust-sized exit produces. Robinhood's
asset_quantity / limit_price / stop_price have 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.123456789 and 64000.10
two values that cannot trigger the exponent form. It is now parametrized over values that do
(0.00000001, 0.000000012345, 1E+2100), plus a structural assertion that no rendered
field ever contains an e. Confirmed failing against the old implementation before fixing.

B2 — place_order reported success for orders the venue rejected

The old code checked only that an id came back, on the stated reasoning that "a placement that
comes 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 state reads failed.

A StopLimitGTC answered {"id": "...", "state": "failed"} was recorded as a protective stop that
does not exist at the venue. failed and canceled now return PlaceResult(success=False, ...)
with the state named in reason, and broker_order_id=None matching CoinbaseAdapter's failure
path.

An unrecognised state still reports success, deliberately — the asymmetry with get_order is
argued 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 PENDING and stays under observation).

B3 — estimated_price namespace: the reviewer was wrong, the code was right

Resolved from the primary source, not by guessing: https://docs.robinhood.com/crypto/trading/.
The v2 API genuinely splits these two neighbouring reads across namespaces —

get/api/v2/crypto/trading/estimated_price/
get/api/v2/crypto/marketdata/best_bid_ask/

— which is exactly what transport.py already did. The instinct to "fix" it is understandable
because v1 is the consistent one (/api/v1/crypto/marketdata/estimated_price/). Required
query 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_order omitted account_number; cancel_order correctly does not

get_order now sends the same account_number query param create_order sends, per Robinhood's
own v2 sample client. Omitting it risks a 404 — and a 404 here is quiet corruption, not a loud
failure: _request turns it into None, and adapter.get_order turns that into a terminal
FAILED with 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 no
query-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

# Finding Resolution
S1 Silent-zero previews Preview.errors populated on every unpriced path; _estimated_price returns None rather than Decimal("0") so a pricing failure is distinguishable from a free order at the confirm gate
S2 preview_order approved symbols place_order refuses to_symbol validated on every preview path — ETH-USDC no longer previews cleanly then raises after the human approved
S3 Float laundering json.loads(..., parse_float=Decimal); test uses unquoted numeric money, which all eight existing fixtures quote — the reason this was untestable before
S4 Transport untested Real coverage against a fake HTTP layer: signature/wire byte-identity (verified cryptographically, not by inspection), the 404-vs-raise split, pagination + _MAX_PAGES, _account caching, and the literal endpoint path of every method
S5 Hand-built query string Percent-encoded via urlencode(..., quote_via=quote, safe=""), with signed and sent bytes still byte-identical
S6 cancel_order could raise on the exit path Fails safe to False. This codebase's own principle — "a raise on the exit path can trap a position" — with why in the docstring
S7 Idempotency tradeoff undocumented Behaviour kept; now in the README's must-fix-before-wiring list, not only a docstring

Nits

  • Per-call account caching: _fee_ratio takes the account as a parameter, so get_fee_summary and
    preview_order each make one GET /accounts/ instead of two-plus. Deliberately not
    memoized on the instance — get_balances reads buying_power off the same payload and a stale
    one would misreport available capital.
  • _paginate cursor hardening: a non-string next no longer raises AttributeError out of a read
    the 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_ask documented as deliberately uncalled, with the reason
    pre-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_usd hole makes subscription-lapse detection not merely inert but
always-passing against this venue; the per-call client_order_id permits duplicate orders on
retry; and keel/cli.py's _interactive_confirm takes a raw dict and iterates .items(), so it
has nowhere to display Preview.synthetic (or Preview.errors) — this adapter's synthetic-preview
flag is invisible at the confirm gate until that path is migrated.

Gates

$ uv run ruff check keel tests packages
All checks passed!

$ uv run mypy
Success: no issues found in 212 source files

$ uv run pytest -q -rs
SKIPPED [1] packages/keel-broker-api/keel_broker_api/conformance/suite.py:254: adapter serves no granularity the suite could exercise
2268 passed, 1 skipped in 31.33s

Baseline before these changes was 2206 passed, 1 skipped. Skip count unchanged and it is the same
skip — the conformance candles probe. mypy strict still covers keel_broker_robinhood.*
(pyproject.toml untouched). No .github/workflows/, config*.yaml, or .db touched.

🤖 Generated with Claude Code


Closes #196.

Follow-ups deliberately NOT in this PR, tracked separately: #197 (fees_usd always 0), #198 (prerequisites before wiring to the live path), #199 (confirm gate cannot display Preview.synthetic).

Context: follow-up to #192, which merged before these fixes landed.

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>
@eaitbrahim eaitbrahim added the fix Bug fix (groups under Fixes) label Aug 9, 2026
@eaitbrahim
eaitbrahim merged commit 18ab7f1 into main Aug 9, 2026
1 check passed
@eaitbrahim
eaitbrahim deleted the fix/robinhood-review-findings branch August 9, 2026 12:28
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Bug fix (groups under Fixes)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Robinhood adapter: close the independent review findings

1 participant