Skip to content

feat(tooling): read-only live probe for the robinhood adapter's fixture shapes - #216

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

feat(tooling): read-only live probe for the robinhood adapter's fixture shapes#216
eaitbrahim merged 1 commit into
mainfrom
feat/robinhood-readonly-smoke

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Adds scripts/robinhood_smoke.py — an operator tool that validates the Robinhood adapter's assumptions against the live venue without placing an order.

Why

tests/broker_robinhood/ runs entirely against a canned transport, so it proves the adapter is internally consistent with fixtures we wrote ourselves. It cannot prove:

  1. that Robinhood accepts our Ed25519 signature (the signing tests verify against our own verify-key — circular w.r.t. the venue);
  2. that the endpoint paths are real (pinned against our reading of the docs, and fix(brokers): close the review findings on the robinhood adapter #194 showed that reading can be wrong in both directions);
  3. that the response shapes are real — tests/fixtures/rh_accounts.json's nested fee_tier_status drives every number in get_fee_summary and every Preview.est_fee, and nothing outside this repo corroborates it.

What it does

Five read-only GETs — accounts, trading_pairs, best_bid_ask, estimated_price, holdings — and a structural diff of each against its committed fixture, reported in both directions. A key the fixture invented is the dangerous one; a key the venue sends and the fixture omits is how a capability gets missed (fees_usd, #197, is exactly that shape).

Two safety properties, both tested

Read-only by construction. The guard is installed onto the transport's _request, not wrapped around it. A __getattr__ wrapper looks correct and is not — the transport's own methods call self._request and sail straight past it, so the guarantee would hold for the tests and not for the probes. That bug was written, caught by test_running_every_probe_issues_only_gets, and fixed; the test now pins it.

Shapes, not values. Every leaf is replaced by its type, so balances and holdings never reach a terminal, a CI log, or a pasted bug report.

It also pre-checks the private key's length, because pasting the base64 public key Robinhood's credential page asked for produces a 401 indistinguishable from a signing bug.

Not covered

The order lifecycle — state values, fee_charged, whether a cancel 200 confirms. No sandbox exists, so that needs a real order; tracked under #198.

Gates

ruff:   All checks passed!
mypy:   Success: no issues found in 216 source files
pytest: 2315 passed, 1 skipped   (+47 tests; skip is still the conformance candles probe)

Refs #215. Not shipped in the wheel — operator tooling, like migration_smoke.py.

…re shapes

Every test under `tests/broker_robinhood/` runs against a canned transport, so the
suite proves the adapter is internally consistent with fixtures we wrote ourselves.
It cannot prove that Robinhood accepts our Ed25519 signature, that the endpoint paths
are real, or that the response shapes are real -- and `rh_accounts.json`'s nested
`fee_tier_status` drives every number in `get_fee_summary` and every `Preview.est_fee`.

`scripts/robinhood_smoke.py` closes that gap with read-only GETs: no order, no money
at risk. It reports structural diffs against each committed fixture in both directions,
since a key the venue sends and the fixture omits is how a capability gets missed.

Read-only by construction: the guard is installed ONTO the transport's `_request`
rather than wrapping it. A `__getattr__` wrapper looks correct and is not -- the
transport's methods call their own `self._request` and sail straight past it, so the
guarantee would hold for the tests and not for the probes. A test pins that.

Prints shapes, not values, so balances never reach a terminal or a pasted log.

Refs #215, #197, #198.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eaitbrahim eaitbrahim added the tooling Dev/release tooling (Docs, CI & tooling) label Aug 9, 2026
@eaitbrahim
eaitbrahim merged commit ef9002f into main Aug 9, 2026
1 check passed
@eaitbrahim
eaitbrahim deleted the feat/robinhood-readonly-smoke branch August 9, 2026 22:11
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>
eaitbrahim added a commit that referenced this pull request Aug 9, 2026
#219)

`scripts/robinhood_smoke.py` (#216) has required `ROBINHOOD_API_KEY` and
`ROBINHOOD_PRIVATE_KEY` since it landed, and #218 exercised it against the live
venue twice, but neither PR added the names to `.env.example` -- so the only
record of what to put in `.env` was the script's own failure message, which you
see after you already went looking.

Both are commented as probe-only on purpose. `load_secrets` does not read them,
so a reader who fills them in and expects the adapter to come alive in a run has
misread the state of this package; the README says the same thing at length and
this is the one-line version at the point of use. The seed encoding is called
out because a PEM is the likelier guess and fails with a length error rather
than anything that names the cause.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
eaitbrahim added a commit that referenced this pull request Aug 11, 2026
…the CTS scoring fix (#241)

A minor bump, not a patch, for three reasons that each require operator action or
change behaviour the deployment is currently relying on.

SCHEMA. `SCHEMA_VERSION` goes 9 -> 10 (#223). Both deployed databases are at 9 and
must be migrated before this build can use them.

BEHAVIOUR REQUIRING OPERATOR ACTION. #223 adds a second attested claim -- what
CONTRACT a venue listing is, not only what the underlying asset is. It fails closed
with no backfill, deliberately, so after this lands `keel assets screen` REJECTS
every product with `instrument_wrapper: UNATTESTED` until `keel assets
attest-instrument` is run once per product. Live trading is unaffected: rail 1 gates
buys on `config.allowlist`, not on the screen.

LIVE SCORING CHANGED. #227 fixed `is_round_number`, which returned True for every
2dp-quoted price and so handed BTC/ETH/PAXG a free CTS point on every bar. Scores on
those three assets are genuinely lower under this build than under 0.5.7.

Also ships: the Robinhood crypto adapter behind the broker port (#216/#218/#222/#229,
not wired to the live path), the TUI activity feed (#235/#237), the CTS factor
collinearity study (#224), `Preview.synthetic` at the confirm gate (#221), rail 9
seeing a bracket's own stop (#212), and CI gating merges on the `test` check
(#234/#238).

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

tooling Dev/release tooling (Docs, CI & tooling)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant