diff --git a/packages/keel-broker-robinhood/README.md b/packages/keel-broker-robinhood/README.md new file mode 100644 index 00000000..f1389482 --- /dev/null +++ b/packages/keel-broker-robinhood/README.md @@ -0,0 +1,104 @@ +# keel-broker-robinhood + +A `Broker` adapter for keel's `Broker` port, implemented against the Robinhood Crypto Trading +API v2. + +## What works + +| Capability | Detail | +| ------------ | ---------------------------------------------------------------------------- | +| Balances | Per-holding `Balance` plus one for the account's `buying_power`. | +| Order status | `get_order` normalizes a Robinhood order object to `OrderStatus`. | +| Cancel | `cancel_order` confirms from the venue's returned order, with one re-poll. | +| Fee summary | Rates from `fee_tier_status.fee_ratio`, `volume_usd` from `thirty_day_volume`. | +| Preview | Synthetic only (`synthetic=True`) -- there is no native preview endpoint. | + +Three order kinds are supported: `MarketIOCByBase` (market, sized in the asset), `LimitGTC` +(resting limit), and `StopLimitGTC` (resting stop-limit). The two resting kinds carry +`time_in_force: "gtc"` because that is the only value Robinhood documents; a market order carries +no `time_in_force` field at all, which is why `market_ioc_base` is declared despite the port's +name saying IOC. That is a naming impedance, not a capability lie -- a market order is immediate +by construction, and there is no resting-market variant here to confuse it with. + +## What does NOT work + +### No candles + +This API has no OHLC or historical-candles endpoint at all, under any path. `get_candles` raises +`ValueError` for every granularity, unconditionally. Robinhood is an **execution venue only** +in this codebase -- candles for any rule that runs against a Robinhood-listed product must come +from somewhere else (e.g. Coinbase market data for the same pair, if the pair trades on both). + +### No quote-sized market orders + +Robinhood's `market_order_config` accepts only `asset_quantity`. There is no way to place a +market order sized in USD on this API. keel places entries as `MarketIOCByQuote` ("spend $N") -- +so **this adapter cannot open positions under keel's current entry model.** It can size exits +(`MarketIOCByBase`), and it can place resting take-profit limits and protective stop-limits. +Synthesizing a quote-sized market order by dividing an estimated price by the requested spend is +deliberately not done anywhere in this package: it would substitute a different sizing basis -- +an estimate taken moments before placement, instead of the size the caller actually asked for -- +on the live-money path, and it would do so silently. `translate.to_order_body` raises +`UnsupportedOrder` for `MarketIOCByQuote` with this reasoning in the message, as a second gate +behind the adapter's capability declaration. + +### No sandbox + +Robinhood ships no test environment for this API. Every test in this repository's +`tests/broker_robinhood/` suite runs against a canned, in-memory `Transport`. There is no way to +exercise this adapter end to end without placing a real order with real money, which is why the +conformance suite against the fake transport is the only signal this package has before a human +runs it live. + +### `fees_usd` is always zero + +`get_fee_summary().fees_usd` is hardcoded to `Decimal("0")`. The API exposes a fee *rate* +(`fee_tier_status.fee_ratio`) and a trailing volume figure, but no account-level total of fees +actually paid. keel's subscription-lapse detection reads `fees_usd` to notice a fee charged while +the user claims a fee-free allowance; against this venue that check cannot fire, and lapse +detection here has to fall back on the venue subscription attestation alone. + +## Not wired to the live path + +`keel/commands/_common.py` still constructs `CoinbaseClient` directly. The broker-port migration +that would let the engine route orders through any registered `Broker` (Phase B) has not landed. +Installing this package registers `robinhood` as a discoverable broker plugin (see the +`keel.brokers` entry point in `pyproject.toml`) and nothing more -- no command, rule, or rail +currently constructs or calls a `RobinhoodAdapter`. This is deliberate: the adapter is built and +tested ahead of the migration that will use it, not wired in early. + +## Credentials + +Robinhood authenticates with an Ed25519 keypair, not an API secret. Robinhood's API credential +page takes the base64-encoded **public** key; the base64-encoded 32-byte private seed stays +local and is never sent anywhere except into the per-request signature. + +Follow this repository's existing secret convention: a git-ignored `.env` at the repo root, read +via `dotenv_values` (`keel_core.config.load_secrets` does this today for `CDP_API_KEY` / +`CDP_API_SECRET`). This adapter documents two names for that same file -- `ROBINHOOD_API_KEY` +and `ROBINHOOD_PRIVATE_KEY` -- but **`load_secrets` does not read them yet.** Nothing in +`keel_core` wires them today; they are passed straight to +`RobinhoodTransport(api_key=..., private_key_b64=...)` by whatever code eventually constructs +one. + +Generating a keypair with `pynacl`: + +```python +import base64 +import nacl.signing + +signing_key = nacl.signing.SigningKey.generate() +private_key_b64 = base64.b64encode(bytes(signing_key)).decode() +public_key_b64 = base64.b64encode(bytes(signing_key.verify_key)).decode() + +print("ROBINHOOD_PRIVATE_KEY=", private_key_b64) # keep local, put in .env +print("public key for Robinhood's API credential page:", public_key_b64) +``` + +## Terms of service + +Only the official, documented Robinhood Crypto Trading API (`https://trading.robinhood.com`, +per `https://docs.robinhood.com/crypto/trading/`) is used anywhere in this package. No +reverse-engineered or undocumented endpoint, and no equity or options endpoint, is touched -- +ever. Robinhood publishes no public API for equities; anything claiming to be one is unofficial +and permanently out of scope for this package. diff --git a/packages/keel-broker-robinhood/keel_broker_robinhood/__init__.py b/packages/keel-broker-robinhood/keel_broker_robinhood/__init__.py new file mode 100644 index 00000000..e1ec52ad --- /dev/null +++ b/packages/keel-broker-robinhood/keel_broker_robinhood/__init__.py @@ -0,0 +1,5 @@ +"""Robinhood Crypto adapter for keel, registered as the `robinhood` broker plugin.""" + +from keel_broker_robinhood.adapter import RobinhoodAdapter + +__all__ = ["RobinhoodAdapter"] diff --git a/packages/keel-broker-robinhood/keel_broker_robinhood/adapter.py b/packages/keel-broker-robinhood/keel_broker_robinhood/adapter.py new file mode 100644 index 00000000..fee8b408 --- /dev/null +++ b/packages/keel-broker-robinhood/keel_broker_robinhood/adapter.py @@ -0,0 +1,474 @@ +"""The Robinhood Crypto adapter: `Broker` implemented against Robinhood's Crypto Trading API v2. + +Every Robinhood-specific decision the engine must not know about lives in this package -- request +signing and pagination in `transport.py`, order-body and status shape in `translate.py`, and the +capability declaration below. + +The transport is injected, never constructed here, so tests exercise the adapter against canned +fixtures with zero live network calls. It defaults to `None` so `RobinhoodAdapter()` is +constructible without credentials -- `capabilities()` is answerable offline, and any method that +actually needs the network raises a clear error rather than a confusing `AttributeError`. That +matters more here than it does for Coinbase: **Robinhood ships no sandbox**, so there is no +"harmless" configuration of this adapter that talks to a real endpoint. Canned or nothing. + +⚠️ **This adapter cannot open positions under keel's current entry model, and that is not a bug +here -- it is a fact about the venue that this file refuses to paper over.** + +keel places entries as `MarketIOCByQuote` ("spend 100 USD of BTC"). Robinhood's +`market_order_config` accepts `asset_quantity` and nothing else; there is no quote-sized market +order anywhere in the v2 API. The adapter therefore leaves `market_ioc_quote` out of +`supported_orders` and raises `UnsupportedOrder` for it. + +The tempting alternative -- call `estimated_price`, divide the quote size by it, and place the +resulting `asset_quantity` -- is deliberately NOT implemented. It would mean the adapter accepted +an order sized in one basis and placed an order sized in another, on the live-money path, with +the substitution invisible to the caller. `UnsupportedOrder`'s own docstring calls this out: "an +adapter must still refuse rather than substitute a different order type." An estimate that moves +between the quote and the fill is not an implementation detail when the difference is the size of +the position. So this adapter is, for now, an EXIT and RESTING-ORDER venue: it can sell a +holding at market, rest a take-profit limit, and rest a protective stop-limit. + +The other two gaps, stated once here and again in the package README: + +* **No candles.** The v2 API exposes `best_bid_ask` and `estimated_price` and nothing else -- + there is no OHLC, historical, or candles endpoint at all. `get_candles` raises `ValueError` + for every granularity, which is the port's sanctioned way to say "I serve no candles": the + conformance suite's `_any_candles` helper catches `ValueError` per granularity and skips when + none work. Robinhood is an EXECUTION venue as far as keel is concerned; bars come from + elsewhere. +* **No sandbox.** Robinhood publishes no test environment. Every test against this adapter runs + on a canned in-memory transport, and the conformance suite is the only end-to-end signal there + will ever be short of real money. +""" + +from __future__ import annotations + +import time +import uuid +from decimal import Decimal, InvalidOperation + +from keel_broker_api.capabilities import BrokerCapabilities +from keel_broker_api.orders import LimitGTC, MarketIOCByBase, OrderSpec, StopLimitGTC +from keel_broker_api.port import UnsupportedOrder +from keel_broker_api.results import Balance, FeeSummary, OrderStatus, PlaceResult, Preview +from keel_core.types import Candle, Granularity + +from keel_broker_robinhood.translate import ( + to_order_body, + to_port_status, + to_price_side, + to_symbol, +) +from keel_broker_robinhood.transport import Transport, _field, _results + +_VENUE = "robinhood" + +_CAPABILITIES = BrokerCapabilities( + venue=_VENUE, + # `market_ioc_quote` is absent on purpose -- see the module docstring. Its absence is what + # stops the engine from routing an ENTRY here and getting something other than what it + # asked for. + # + # `market_ioc_base` is present despite the port's name saying IOC, and Robinhood accepting no + # `time_in_force` on a market order at all. That is a naming impedance, not a capability lie: + # a market order is by construction immediate -- it either crosses the book now or it is + # rejected -- so "immediate or cancel" describes what Robinhood's market order already does. + # There is no resting-market-order variant to be confused with. The port kind that WOULD be a + # lie is the quote-sized one, and it is not declared. + supported_orders=frozenset({"market_ioc_base", "limit_gtc", "stop_limit_gtc"}), + # No preview endpoint exists on this API, so every Preview this adapter returns is a number + # it computed itself and must label `synthetic=True`. + supports_native_preview=False, + synthesizes_preview=True, + supports_fee_summary=True, + # Robinhood's docs say "Only USD symbols are accepted" -- not USDC, which is what Coinbase + # settles keel's trades in. `translate.to_symbol` refuses a non-USD quote leg by name rather + # than rewriting it, because rewriting `BTC-USDC` to `BTC-USD` would swap the settlement + # asset underneath the caller. + quote_currencies=frozenset({"USD"}), + asset_classes=frozenset({"spot"}), +) + +#: Every `Granularity` the port defines, refused with the same reason. Kept as a single message +#: so the failure reads as a property of the VENUE rather than of the requested timeframe -- a +#: caller who reads "granularity not supported" will go looking for a supported one, and there +#: isn't one. +_NO_CANDLES = ( + "robinhood's crypto trading API v2 exposes no OHLC, candles, or historical endpoint " + "(only best_bid_ask and estimated_price), so no granularity can be served -- this venue is " + "an execution venue for keel, and candle data must come from another source" +) + + +class RobinhoodAdapter: + """Implements the `Broker` port against the Robinhood Crypto Trading API v2. + + v2 exclusively, never v1. Two things force it and both are contract-level, not cosmetic: + v1's cancel endpoint answers `text/plain` "Cancel request was submitted", which is an + acknowledgement of 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` that `get_order` needs for observed economics nor the `fee_tier_status` that + `get_fee_summary` is built from. An adapter written against v1 would have to guess at all + three, and guessing is the thing the port exists to prevent. + """ + + def __init__(self, transport: Transport | None = None) -> None: + self._transport = transport + + def _require_transport(self) -> Transport: + if self._transport is None: + raise RuntimeError( + "RobinhoodAdapter was constructed without a transport; " + "inject one to make network-backed calls" + ) + return self._transport + + def capabilities(self) -> BrokerCapabilities: + return _CAPABILITIES + + def get_candles( + self, product_id: str, granularity: Granularity, start_ts: int, end_ts: int + ) -> list[Candle]: + """Always raises `ValueError`: this API has no candles endpoint of any kind. + + Refusing is the only honest answer, and specifically it must not return `[]`. An empty + list reads downstream as "this market had no trades in the window", which is a statement + about the MARKET; the truth is a statement about the API. A rule evaluated against + silently-empty bars does not error, it just decides nothing -- or worse, decides + something from a window it thinks is flat. + + `ValueError` (rather than `NotImplementedError`) is deliberate: the conformance suite's + `_any_candles` helper catches `ValueError` per granularity and skips when every one of + them refuses, which is the port's sanctioned way for a venue to declare it serves no + bars. `FakeAdapter` uses the same signal for the granularities it does not carry. + """ + raise ValueError(_NO_CANDLES) + + def get_balances(self) -> list[Balance]: + """Return per-currency balances as domain types, never Robinhood's holding dicts. + + Two different endpoints feed this, because Robinhood splits the answer in a way Coinbase + does not. `holdings/` reports crypto positions and gives both a `total_quantity` and a + `quantity_available_for_trading`, which map cleanly onto `total`/`available` -- the gap + between them is the venue's hold, exactly what `available` is for. Cash is not a holding; + it is the account's `buying_power`, so it is emitted separately under whatever + `buying_power_currency` says (USD in practice). + + For the cash balance `total` equals `available`. That is not a shortcut: the v2 accounts + payload exposes one spendable number and no separate "cash on hold" figure, so inventing + a larger `total` would be asserting a number the venue never reported. Equality here says + "nothing is known to be held back", which is what the payload actually supports. + """ + transport = self._require_transport() + + balances: list[Balance] = [] + for raw in _results(transport.get_holdings()): + total = Decimal(str(_field(raw, "total_quantity", "0") or "0")) + available = Decimal(str(_field(raw, "quantity_available_for_trading", "0") or "0")) + balances.append( + Balance( + currency=str(_field(raw, "asset_code", "")), + available=available, + total=total, + ) + ) + + account = self._account() + buying_power = Decimal(str(_field(account, "buying_power", "0") or "0")) + currency = str(_field(account, "buying_power_currency", "USD") or "USD") + balances.append(Balance(currency=currency, available=buying_power, total=buying_power)) + return balances + + def _account(self) -> object: + """The first account from `GET /accounts/`, or `{}` when the response carries none. + + `{}` rather than a raise: the callers that need it (`get_balances`, `get_fee_summary`, + the fee leg of `preview_order`) each have a documented degraded answer for missing data, + and all three of those answers are safer than an exception thrown from a method the + executor may be calling on an EXIT path. A raise on the way out of a position can trap + it; that reasoning is written down in `BrokerCapabilities`' docstring and applies here. + """ + accounts = _results(self._require_transport().get_accounts()) + return accounts[0] if accounts else {} + + def _fee_ratio(self) -> Decimal | None: + """The account's fee ratio, or `None` when the venue did not report one. + + `None` is distinct from `Decimal("0")` on purpose and the two must not be collapsed: + zero is a claim that this account trades free, and nothing in the payload supports that + claim when the field is simply absent. Callers turn `None` into a zero fee ESTIMATE only + where they also label the estimate's basis as unknown. + """ + tier = _field(self._account(), "fee_tier_status") or {} + raw = _field(tier, "fee_ratio") + if raw is None: + return None + try: + return Decimal(str(raw)) + except (InvalidOperation, ValueError): + return None + + def _reject_unsupported(self, spec: OrderSpec) -> None: + """Refuse an undeclared order kind before anything venue-shaped is built for it. + + `translate.to_order_body` refuses `MarketIOCByQuote` a second time. The duplication is + deliberate defence in depth on the one path where a silent substitution would be a + differently-sized live position: this gate is the one the capability declaration is + derived from, and that one is the last statement before a body goes on the wire. + """ + if spec.kind not in _CAPABILITIES.supported_orders: + raise UnsupportedOrder( + f"robinhood does not support order kind {spec.kind!r} " + f"(supported: {', '.join(sorted(_CAPABILITIES.supported_orders))})" + ) + + def preview_order(self, spec: OrderSpec) -> Preview: + """Synthesise a preview. Always `synthetic=True` -- there is no preview endpoint here. + + Coinbase answers `preview_order` with its own quote, so approving it is approving the + venue's arithmetic. Robinhood answers nothing, so every number below is this adapter's + arithmetic, and `Preview`'s docstring is explicit that "approving an estimate must never + look identical to approving a broker's own quote". `synthetic=True` is what carries that + distinction to whatever renders the confirm gate. + + The three fields, and how firm each one actually is: + + * `est_base_size` is exact. All three supported kinds are base-sized, so this is the + number the caller asked for, not an estimate at all. + * `est_quote_size` for `limit_gtc`/`stop_limit_gtc` is `base_size * limit_price`. That is + a BOUND, not a prediction: a limit order does not trade worse than its limit, so this + is the most quote currency a sell can realise or the most a buy can spend. For + `market_ioc_base` there is no bound to quote, so it comes from `estimated_price` and + is a genuine guess that the fill can and will miss. + * `est_fee` is `est_quote_size * fee_tier_status.fee_ratio`. When the account reports no + ratio it is `Decimal("0")` and `detail["fee_ratio"]` reads `"unknown"` -- a made-up + rate would be worse than a visible zero, because a plausible-looking fee is one nobody + checks. `detail["price_basis"]` names which of the two paths above produced the quote + size, so the reader can tell a bound from a guess without inferring it from the kind. + """ + self._reject_unsupported(spec) + base_size = self._base_size(spec) + + if isinstance(spec, LimitGTC | StopLimitGTC): + price, basis = spec.limit_price, "limit_price" + else: + price, basis = self._estimated_price(spec), "estimated_price" + + quote_size = base_size * price + ratio = self._fee_ratio() + return Preview( + product_id=spec.product_id, + side=spec.side, + est_base_size=base_size, + est_quote_size=quote_size, + est_fee=quote_size * ratio if ratio is not None else Decimal("0"), + synthetic=True, + detail={ + "price_basis": basis, + "price": str(price), + "fee_ratio": str(ratio) if ratio is not None else "unknown", + }, + ) + + def _base_size(self, spec: OrderSpec) -> Decimal: + """The spec's base size. Reachable only for the three base-sized kinds. + + `MarketIOCByQuote` has no `base_size` to read, and `_reject_unsupported` has already + raised for it by the time anything calls this -- so this raises rather than returning a + placeholder, on the principle that a size derived from nothing is the one value that must + never reach a preview the human is about to approve. + """ + if isinstance(spec, MarketIOCByBase | LimitGTC | StopLimitGTC): + return spec.base_size + raise UnsupportedOrder(f"robinhood cannot size order kind {spec.kind!r} in base units") + + def _estimated_price(self, spec: OrderSpec) -> Decimal: + """Robinhood's estimated price for this size, or `Decimal("0")` if it reports none. + + `side` is translated, not passed through: this endpoint answers in book terms + (`bid`/`ask`), not order terms (`buy`/`sell`), and a buyer is filled from the ASK. Asking + for the wrong side of the spread would understate the cost of every buy preview -- which + is exactly the direction of error a human at a confirm gate is least likely to catch. + + A zero on a missing price is a visible nonsense that surfaces as a zero-cost preview, + rather than an exception thrown mid-confirm; `errors` on `Preview` is the port's channel + for a soft failure and the price basis in `detail` says where the number came from. + """ + response = self._require_transport().get_estimated_price( + symbol=to_symbol(spec.product_id), + side=to_price_side(spec.side), + quantity=str(self._base_size(spec)), + ) + rows = _results(response) + if not rows: + return Decimal("0") + try: + return Decimal(str(_field(rows[0], "price", "0") or "0")) + except (InvalidOperation, ValueError): + return Decimal("0") + + def place_order(self, spec: OrderSpec) -> PlaceResult: + """Place a live order. A fresh `client_order_id` per call gives Robinhood idempotency. + + The uuid is required by the API, not optional as it is on some venues, and it must be + fresh per ATTEMPT rather than per spec: reusing one across a retry is how a caller asks + the venue to deduplicate, and generating one per spec would silently deduplicate two + orders a strategy genuinely meant to place twice. + + Robinhood signals failure with an HTTP error rather than the success/error envelope + Coinbase returns, so a placement that comes back at all came back as an order. The one + thing still worth checking is that it carries an `id`: a `PlaceResult(success=True, + broker_order_id=None)` would be an order nobody can later reconcile or cancel, which is + worse than a reported failure. + """ + self._reject_unsupported(spec) + body = to_order_body(spec, client_order_id=str(uuid.uuid4())) + response = self._require_transport().create_order(body) + + order_id = _field(response, "id") + if order_id is None: + return PlaceResult( + success=False, + broker_order_id=None, + reason="robinhood accepted the request but returned no order id", + ) + return PlaceResult(success=True, broker_order_id=str(order_id)) + + def get_fee_summary(self) -> FeeSummary: + """Map v2's `fee_tier_status` to a `FeeSummary`. Read the `fees_usd` note below. + + `volume_window` is `"trailing_30d"`, and unlike Coinbase's `"unknown"` that is a + statement the docs actually support: the field is literally named `thirty_day_volume`. + Coinbase's `advanced_trade_only_volume` names no window, so its adapter says so; here the + name IS the window, and declaring `"unknown"` would throw away information the venue gave + us and force reconciliation into a weaker test than it needs. + + `taker_rate` and `maker_rate` both carry the single `fee_ratio`. Robinhood publishes one + ratio and does not split by liquidity role anywhere in the v2 docs, so this is not two + numbers collapsed into one -- it is one number reported in both fields because it applies + to both cases. The alternative, zeroing `maker_rate`, would claim resting orders trade + free, which nothing supports. + + ⚠️ `fees_usd` is always `Decimal("0")`, and this is a REAL GAP, not a formality. The v2 + API exposes per-order `fee_charged` but no account-level fees-paid total, and this method + has no order history to sum. `FeeSummary`'s docstring says subscription lapse detection + leans on `fees_usd` -- specifically, a fee charged while the user claims a fee-free + allowance contradicts the claim. A constant zero can never contradict anything, so + against this venue that test is inert and detection falls back to attestation alone. + Anything consuming this must treat a Robinhood `fees_usd` as "not reported", never as + "no fees were charged". Closing this properly means paging order history and summing + `fee_charged`, which is a follow-up with its own rate-limit design. + """ + account = self._account() + tier = _field(account, "fee_tier_status") or {} + ratio = self._fee_ratio() or Decimal("0") + return FeeSummary( + venue=_VENUE, + taker_rate=ratio, + maker_rate=ratio, + volume_usd=Decimal(str(_field(tier, "thirty_day_volume", "0") or "0")), + fees_usd=Decimal("0"), + volume_window="trailing_30d", + fetched_at=int(time.time()), + ) + + def get_order(self, order_id: str) -> OrderStatus: + """Observed state of a previously placed order, normalized to `Decimal` money fields. + + This is what makes exit reconciliation possible at all. A placement response only says + the order was accepted; nothing in it reveals that a resting bracket later filled, at + what price, or for what fee. Without this the executor records the EXPECTED price and a + previewed commission, so realized P&L is modelled rather than observed -- and against + this venue the modelled number would be worse than usual, since its preview is synthetic + to begin with. + + An id the venue does not recognise comes back as a normal `OrderStatus` with status + `"FAILED"` and zeroed money fields, never an exception. `FakeAdapter` set that precedent + and the reason is the same one: `OrderStatus`'s contract is that callers do arithmetic on + its money fields without special-casing, and making them catch a venue-shaped 404 just + moves the special case one layer up. The transport is what makes this safe -- it returns + `None` ONLY on a genuine 404 for this id and raises on everything else, so a network + blip can never be laundered into "this order failed". + """ + response = self._require_transport().get_order(order_id) + if response is None: + return _terminal_unknown(order_id) + return OrderStatus( + order_id=str(_field(response, "id", order_id) or order_id), + status=to_port_status(_field(response, "state")), + filled_size=Decimal(str(_field(response, "filled_asset_quantity", "0") or "0")), + average_filled_price=Decimal(str(_field(response, "average_price", "0") or "0")), + total_fees=Decimal(str(_field(response, "fee_charged", "0") or "0")), + ) + + def cancel_order(self, order_id: str) -> bool: + """Cancel one resting order. `True` only if the venue CONFIRMS the cancellation. + + v2's cancel endpoint returns the full order object as JSON, which is the whole reason + this adapter targets v2: v1 answers `text/plain` "Cancel request was submitted", an + acknowledgement that the REQUEST arrived and not a statement about the order. Reading + that as success would let `executor._cancel_at_exchange` record a cancel that never + happened -- and the order it believes is gone is still resting, still able to fill. + + So the confirmation is read from the returned object's own `state`, and only + `"canceled"` (Robinhood's spelling) counts. A cancel is asynchronous at this venue: the + response can legitimately still read `open` because the request is queued behind the + matching engine. That is not a failure and not a success -- it is an unanswered question, + so the order is re-polled ONCE via `GET /orders/{id}/` and the answer taken from there. + + Once, not in a loop, and not with a sleep: this runs on the executor's path and a + retry loop here would block an exit while an order it wants gone is still live. A `False` + from a still-pending cancel is the conservative outcome -- the engine keeps believing the + order might be resting, which is the belief that keeps it watching. `True` on a cancel + that had not landed is the outcome with no recovery. + + An id the venue never issued returns `False` rather than raising, per the port docstring: + absence of a refusal is not a confirmation, and neither is a 404. + """ + transport = self._require_transport() + + response = transport.cancel_order(order_id) + if response is None: + return False + if _confirms_cancel(response, order_id): + return True + + polled = transport.get_order(order_id) + if polled is None: + return False + return _confirms_cancel(polled, order_id) + + +#: Robinhood's own spelling of the terminal cancelled state. Compared against raw venue JSON, +#: so it is the venue's single-`l` spelling and NOT the port's `"CANCELLED"` -- the two meet +#: only in `translate.STATE_TO_PORT_STATUS`, and reading the port's spelling here would silently +#: never match, turning every confirmed cancel into a `False`. +_CANCELED = "canceled" + + +def _confirms_cancel(order: object, order_id: str) -> bool: + """Whether `order` is a confirmation that THIS id is cancelled. + + The id is checked, not assumed. A response for a different order would be a venue bug rather + than an expected case, but "the object came back" is not the same claim as "the object I + asked about came back cancelled", and this boolean is the one the executor writes local state + from. + """ + returned_id = _field(order, "id") + if returned_id is not None and str(returned_id) != order_id: + return False + return str(_field(order, "state", "") or "") == _CANCELED + + +def _terminal_unknown(order_id: str) -> OrderStatus: + """The answer for an id the venue does not recognise: `FAILED`, with money fields zeroed.""" + return OrderStatus( + order_id=order_id, + status="FAILED", + filled_size=Decimal("0"), + average_filled_price=Decimal("0"), + total_fees=Decimal("0"), + ) + + +__all__ = ["RobinhoodAdapter"] diff --git a/packages/keel-broker-robinhood/keel_broker_robinhood/translate.py b/packages/keel-broker-robinhood/keel_broker_robinhood/translate.py new file mode 100644 index 00000000..bccb9414 --- /dev/null +++ b/packages/keel-broker-robinhood/keel_broker_robinhood/translate.py @@ -0,0 +1,181 @@ +"""The one place keel's order model becomes Robinhood's order-body and status vocabulary. + +Everything Robinhood-specific about order shape and state spelling lives here, mirroring +`keel_broker_coinbase.translate`. Two things set this venue apart from Coinbase and make this +module worth reading closely rather than skimming as a copy: + +1. Robinhood's `market_order_config` accepts only `asset_quantity` -- there is no quote-sized + market order on this API at all. `MarketIOCByQuote` is therefore refused here, not merely + left unhandled, so a future engine change that starts routing an unsupported kind through this + adapter fails loudly at translation time instead of silently sending a malformed body. +2. Robinhood spells a cancelled order's terminal state `canceled` (American, single `l`); keel's + port spells it `CANCELLED`. `STATE_TO_PORT_STATUS` is the one place those two spellings meet, + so nobody downstream has to remember which venue uses which. + +Decimals render via `str()` everywhere in this module so an order's size, price, or stop can +never be perturbed by a float round-trip on the live-money path. +""" + +from __future__ import annotations + +from typing import Any, assert_never + +from keel_broker_api.orders import ( + LimitGTC, + MarketIOCByBase, + MarketIOCByQuote, + OrderSpec, + StopLimitGTC, +) +from keel_broker_api.port import UnsupportedOrder +from keel_core.types import Side + +#: Robinhood's crypto trading API only ever settles against USD -- there is no USDC, USDT, or any +#: other quote leg. This is the only quote currency `to_symbol` will accept; anything else names +#: a different settlement asset and must be refused rather than silently rewritten. +QUOTE_CURRENCY: str = "USD" + +#: Robinhood's docs show `time_in_force` as `"gtc"` on every resting order example; IOC is +#: undocumented and unavailable on this API (market orders are IOC-by-construction and carry no +#: `time_in_force` field at all -- see `to_order_body`). Hardcoding this constant, rather than +#: threading a parameter through, keeps a caller from ever asking this venue for a time-in-force +#: it cannot honor. +TIME_IN_FORCE: str = "gtc" + +#: Robinhood's order `state` -> keel's port `status`. This table is the only place the American +#: `canceled` (Robinhood) and the doubled-`l` `CANCELLED` (the port) meet; every other value maps +#: by capitalizing the venue's spelling. Keeping the mapping explicit, rather than deriving one +#: from the other programmatically, means a new Robinhood state added by a future API version +#: fails to translate (via `to_port_status`'s `PENDING` fallback) instead of silently guessing. +STATE_TO_PORT_STATUS: dict[str, str] = { + "open": "OPEN", + "canceled": "CANCELLED", + "filled": "FILLED", + "failed": "FAILED", + "pending": "PENDING", +} + + +def to_symbol(product_id: str) -> str: + """Render a keel product id as Robinhood's `symbol`, refusing anything not settled in USD. + + Robinhood's crypto API accepts only USD-quoted symbols. Rewriting `BTC-USDC` to `BTC-USD` + would silently substitute a different settlement asset on the live-money path -- the caller + asked to trade against USDC and would be filled against USD instead, with no error raised + anywhere. Passing `BTC-USDC` through unchanged would fare no better: Robinhood would reject + it, and that rejection looks exactly like an outage to anything watching order placement. + Refusing by name, here, is the only option that is honest about what happened. The same + reasoning applies to a product id that is not `BASE-QUOTE` shaped at all: guessing a split + would risk exactly the same silent substitution. + """ + parts = product_id.split("-") + if len(parts) != 2 or not parts[0] or not parts[1]: + raise UnsupportedOrder( + f"robinhood requires a BASE-QUOTE product id, got {product_id!r}" + ) + base, quote = parts + if quote.upper() != QUOTE_CURRENCY: + raise UnsupportedOrder( + f"robinhood only trades USD-quoted symbols; product {product_id!r} quotes " + f"{quote.upper()!r}, which would settle against a different asset than requested" + ) + return f"{base.upper()}-{quote.upper()}" + + +def to_side(side: Side) -> str: + """Render keel's `Side` as Robinhood's lowercase order `side` (`"buy"` / `"sell"`).""" + return "buy" if side is Side.BUY else "sell" + + +def to_price_side(side: Side) -> str: + """Which quote leg (`"bid"` / `"ask"`) prices a preview for `side`. + + A BUY is filled at the ask (what a seller will take); a SELL is filled at the bid (what a + buyer will pay). Pricing a BUY preview off the bid, or a SELL preview off the ask, would show + the wrong side of the spread -- optimistic in exactly the direction that makes a synthesized + preview look better than the fill it is meant to estimate. + """ + return "ask" if side is Side.BUY else "bid" + + +def to_order_body(spec: OrderSpec, *, client_order_id: str) -> dict[str, Any]: + """Render `spec` as the JSON body for `POST /api/v2/crypto/trading/orders/`. + + Decimals render via `str()`, never float, so no order size, limit price, or stop price can be + perturbed by a float round-trip between here and the wire. + """ + match spec: + case MarketIOCByQuote(): + # Robinhood's `market_order_config` takes only `asset_quantity` -- there is no + # quote-sized market order on this API. Synthesising one by dividing an estimated + # price by the requested quote spend would substitute a different sizing basis (an + # estimate, taken moments before placement) for the one the caller actually asked + # for, on the live-money path, and it would do so silently. The adapter's capability + # declaration already excludes this kind; this is the second gate, so a future bug + # that routes a `MarketIOCByQuote` past the first gate still cannot place an order. + raise UnsupportedOrder( + "robinhood's market_order_config accepts only asset_quantity; there is no " + "quote-sized market order on this API, and synthesizing one by dividing an " + "estimated price would substitute a different sizing basis on the live-money path" + ) + case MarketIOCByBase(): + return { + "symbol": to_symbol(spec.product_id), + "client_order_id": client_order_id, + "side": to_side(spec.side), + "type": "market", + "market_order_config": {"asset_quantity": str(spec.base_size)}, + } + case LimitGTC(): + return { + "symbol": to_symbol(spec.product_id), + "client_order_id": client_order_id, + "side": to_side(spec.side), + "type": "limit", + "limit_order_config": { + "asset_quantity": str(spec.base_size), + "limit_price": str(spec.limit_price), + "time_in_force": TIME_IN_FORCE, + }, + } + case StopLimitGTC(): + return { + "symbol": to_symbol(spec.product_id), + "client_order_id": client_order_id, + "side": to_side(spec.side), + "type": "stop_limit", + "stop_limit_order_config": { + "asset_quantity": str(spec.base_size), + "limit_price": str(spec.limit_price), + "stop_price": str(spec.stop_price), + "time_in_force": TIME_IN_FORCE, + }, + } + case _: + assert_never(spec) + + +def to_port_status(state: str | None) -> str: + """Robinhood order `state` -> the port's status vocabulary, defaulting to `"PENDING"`. + + An unrecognised or missing state means the adapter does not actually know the order's + outcome -- not that the order failed. `PENDING` keeps it under observation so reconciliation + keeps polling; `FAILED` would declare a terminal outcome nobody observed, and the engine could + then re-enter a position whose original order is, for all this adapter knows, still live at + the venue. Silence is not evidence of failure, and this function must not treat it as such. + """ + if state is None: + return "PENDING" + return STATE_TO_PORT_STATUS.get(state, "PENDING") + + +__all__ = [ + "QUOTE_CURRENCY", + "STATE_TO_PORT_STATUS", + "TIME_IN_FORCE", + "to_order_body", + "to_port_status", + "to_price_side", + "to_side", + "to_symbol", +] diff --git a/packages/keel-broker-robinhood/keel_broker_robinhood/transport.py b/packages/keel-broker-robinhood/keel_broker_robinhood/transport.py new file mode 100644 index 00000000..0d63c26f --- /dev/null +++ b/packages/keel-broker-robinhood/keel_broker_robinhood/transport.py @@ -0,0 +1,397 @@ +"""Structural transport interface, response helpers, and the network-backed Robinhood client. + +Everything Robinhood-specific about *talking to the venue* lives here, so `adapter.py` and +`translate.py` never see a `requests.Response`, an HTTP status code, or a signing key. That +boundary exists for two reasons at once: + +1. Testability. `Transport` is a `Protocol`, not a base class, so the adapter's tests inject a + plain object (or a dict-backed fake) that satisfies the same shape a real `RobinhoodTransport` + does, with zero network and zero credentials. `sign_payload`/`build_headers` are free + functions rather than methods for the same reason: the signing rule -- the one piece of this + module that must never silently drift from Robinhood's docs -- is unit-testable against a + known keypair without constructing a transport, a session, or a single HTTP call. +2. Import safety. `pynacl` and `requests` are real, heavy third-party dependencies that a caller + who only wants `capabilities()` or who is running the conformance suite against a fake + transport should never be forced to install. Both imports are therefore deferred to call time + (see the comments at each `import` below) so `from keel_broker_robinhood.transport import + Transport` succeeds in an environment with neither package present. + +Robinhood's v2 API paginates every list endpoint (`{"next": ..., "previous": ..., "results": +[...]}`) and answers with plain JSON dicts, so unlike the Coinbase transport there is no SDK +response-object type to accommodate here -- `_field`/`_results` exist mainly to give the adapter +one shape to depend on regardless of whether a test fixture is a bare list or a paginated dict. +""" + +from __future__ import annotations + +import base64 +import json +import time +from typing import Any, Protocol + + +class Transport(Protocol): + """Structural interface the adapter depends on. + + Every method returns `Any` deliberately: the Protocol's job is to pin down *which network + calls exist and what arguments they take*, not the response shape. Response shape is + `translate.py`'s and `adapter.py`'s problem, read through `_field`/`_results` so that a test + fixture (a plain dict) and a live JSON response (also a plain dict, since this transport does + its own `.json()` decoding rather than wrapping responses in SDK objects) are indistinguishable + to callers. + """ + + def get_accounts(self) -> Any: ... + + def get_holdings(self) -> Any: ... + + def get_trading_pairs(self, symbol: str | None = None) -> Any: ... + + def get_best_bid_ask(self, symbol: str) -> Any: ... + + def get_estimated_price(self, symbol: str, side: str, quantity: str) -> Any: ... + + def create_order(self, body: dict[str, Any]) -> Any: ... + + def get_order(self, order_id: str) -> Any: ... + + def cancel_order(self, order_id: str) -> Any: ... + + +def _field(obj: Any, key: str, default: Any = None) -> Any: + """Read `key` from a plain dict, an attribute-bearing object, or `None`. + + `RobinhoodTransport` decodes every response with `.json()`, so in first-party use `obj` is + always a dict -- but `Transport` is a `Protocol`, and nothing stops a caller from satisfying + it with a client that wraps responses in objects the way `coinbase-advanced-py` does. Reading + both shapes here costs one `isinstance` and means such a transport degrades into a confusing + `AttributeError` at no point. `keel_broker_coinbase._field` makes the same allowance for the + same reason. + + The `None` branch is the one that earns this a function rather than an inline `obj.get(...)`: + `obj` is legitimately `None` for an absent nested config block (a market order has no + `limit_order_config`) and for a 404 that `get_order`/`cancel_order` already turned into that + sentinel, and `None.get(...)` would raise in exactly the place a caller expected a quiet + default. + """ + if obj is None: + return default + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + +def _results(response: Any) -> list[Any]: + """Normalize a paginated response, a bare list, or `None` into a list of result rows. + + Tests inject plain dict fixtures shaped like the real endpoint (`{"results": [...]}`) or, for + the simplest cases, a bare list standing in for "the results, already unwrapped." The live + transport itself never hands this function anything but a page dict -- pagination is resolved + inside `RobinhoodTransport._paginate` before the adapter ever sees a response -- but this + function has to accept all three shapes anyway, because it is also the thing that reads each + individual page while `_paginate` is walking `next` cursors. Silently returning `[]` for a + shape nobody anticipated would hide a real fixture bug as "the venue has no holdings/orders/ + trading pairs today", which is indistinguishable from an empty account until something is + quietly missing. + """ + if response is None: + return [] + if isinstance(response, list): + return response + return list(_field(response, "results", []) or []) + + +def sign_payload( + private_key_b64: str, api_key: str, timestamp: int, path: str, method: str, body: str +) -> str: + """Sign one request per Robinhood's Ed25519 scheme and return the base64 signature. + + The message is `f"{api_key}{timestamp}{path}{method}{body}"` encoded as UTF-8 -- an exact + concatenation, not a JSON envelope or a delimited list, so every argument must already be in + its final on-the-wire form before it reaches this function: `timestamp` as the same string + that goes in the `x-timestamp` header, `path` as the exact request path *including the query + string* (see `RobinhoodTransport`'s request method for why a mismatch there is a silent + 401), `method` uppercase, and `body` as the literal JSON text sent on the wire (or `""` for a + GET with no body -- not `"{}"`, not `"null"`). + + `private_key_b64` is the base64 encoding of the raw 32-byte Ed25519 *seed* Robinhood issues + when a credential is created -- not a PEM, not a hex string, and not the base64 *public* key + Robinhood's own credential page asks for (that one is uploaded to Robinhood, never used here). + `nacl.signing.SigningKey` derives the full keypair from that seed. + + `pynacl` is imported here, at call time, rather than at module load -- see the module + docstring's "Import safety" point. This is the one function in the module that actually + touches the crypto stack, so it is the only place that needs the import to succeed. + """ + import nacl.signing # deferred: see module docstring "Import safety"; keeps this module + # importable (e.g. for `Transport`/`_field`/`_results` in tests) without pynacl installed. + + message = f"{api_key}{timestamp}{path}{method}{body}".encode() + seed = base64.b64decode(private_key_b64) + signing_key = nacl.signing.SigningKey(seed) + signature = signing_key.sign(message).signature + return base64.b64encode(signature).decode() + + +def build_headers(api_key: str, signature: str, timestamp: int) -> dict[str, str]: + """Assemble the four headers Robinhood requires on every authenticated request. + + `timestamp` is taken as an `int` (epoch seconds, matching `sign_payload`'s argument) and + rendered to `str` here, once, so the caller cannot accidentally sign one string + representation (say, with different rounding) and send another -- the header value and the + signed value must be byte-identical, since Robinhood recomputes the signature server-side + over exactly what it receives. + """ + return { + "x-api-key": api_key, + "x-signature": signature, + "x-timestamp": str(timestamp), + "Content-Type": "application/json", + } + + +#: Robinhood's `next` cursor points at another page of the same endpoint. A well-behaved account +#: with a handful of holdings or a day's worth of orders resolves in one page; twenty pages is +#: already an enormous account history by any realistic measure. The cap exists because a `next` +#: cursor is server-controlled: a bug on Robinhood's side that returns a `next` link pointing at +#: itself, or at a page that never terminates, would otherwise turn one `get_holdings()` call into +#: an infinite loop that holds the account's credentials busy against a live-money venue forever. +#: Twenty pages failing to reach the end is itself a signal something is wrong, so this raises +#: rather than silently truncating. +_MAX_PAGES = 20 + + +class RobinhoodTransport: + """The live, network-backed `Transport`: HTTP + Ed25519 signing against `trading.robinhood.com`. + + This class, not `adapter.py`, is where every Robinhood-account concept that is really an + *authentication* detail rather than a *trading* concept gets absorbed. `account_number` is + the clearest example: Robinhood's orders/holdings endpoints are scoped to one account number + per request, but which account number that is is a fact about *this credential*, not about + the order being placed. Resolving and caching it here -- instead of requiring the caller (or + `adapter.py`) to fetch `GET /accounts/` and thread the number through every call -- was the + deliberate choice between two options: (a) push it up to the adapter/port layer, or (b) keep + it entirely inside the transport. Option (a) was rejected because `account_number` is not a + concept the `Broker` port (or any other venue) has any use for; it exists only because this + one venue's REST API happens to require it on the query string. Letting it leak into + `adapter.py` would plant a Robinhood-ism in the one layer that is supposed to stay + venue-agnostic. So the adapter calls `get_holdings()` with no arguments, and never learns that + an account number was involved at all. + """ + + def __init__( + self, + api_key: str, + private_key_b64: str, + base_url: str = "https://trading.robinhood.com", + account_number: str | None = None, + timeout: float = 10.0, + ) -> None: + self._api_key = api_key + self._private_key_b64 = private_key_b64 + # Trailing slash stripped once here so every path builder below can assume "no trailing + # slash on the base, leading slash on the path" and never double or drop a `/`. + self._base_url = base_url.rstrip("/") + self._account_number = account_number + self._timeout = timeout + + def _account(self) -> str: + """Return the cached account number, resolving it from `GET /accounts/` on first use. + + Caching after the first successful resolution means every subsequent call -- however + many holdings/orders/cancel requests happen over this transport's lifetime -- costs zero + extra requests. Resolving lazily (not in `__init__`) means constructing a transport never + performs network I/O by itself, which matters for tests that construct one and monkeypatch + `_request` before anything touches the network. + """ + if self._account_number is not None: + return self._account_number + response = self._request("GET", "/api/v2/crypto/trading/accounts/") + accounts = _results(response) + if not accounts: + raise RuntimeError( + "robinhood account resolution failed: GET /accounts/ returned no accounts for " + "this credential" + ) + account_number = _field(accounts[0], "account_number") + if not account_number: + raise RuntimeError( + "robinhood account resolution failed: the account row has no 'account_number' " + "field" + ) + # `str(...)` here (not just a type hint) matters under mypy --strict: `_field` returns + # `Any` by design, and letting that `Any` flow straight into a `-> str` return would be + # exactly the kind of untyped leak strict mode exists to catch. + resolved = str(account_number) + self._account_number = resolved + return resolved + + def _request( + self, + method: str, + path: str, + params: dict[str, Any] | None = None, + body: dict[str, Any] | None = None, + ) -> Any: + """Sign and send one request; decode 2xx JSON; raise for everything else but a 404. + + The signature is computed over the *exact* path sent on the wire, query string included + -- Robinhood's server recomputes the signature from the request it actually received, so + if the string signed here ever diverges from the string `requests` puts on the wire (a + different query-param order, an extra trailing slash, a param added after signing), the + result is not a helpful error: it is a 401 that looks identical to a bad key or a stale + clock. This is the single easiest way to get this integration wrong, which is why the + query string is built once, by hand, and reused byte-for-byte for both the signature and + the request. + + A body is JSON-encoded once (`json.dumps`) and that exact string is both signed and sent, + for the same reason: `requests`' own `json=` kwarg would re-serialize the dict, and + nothing guarantees byte-for-byte agreement with whatever was signed. + + 404 is special-cased into `None` here so `get_order`/`cancel_order` can treat "the venue + does not recognise this id" as a normal, expected outcome instead of an exception -- + every other non-2xx status (401, 429, 5xx, a connection error) propagates as a raised + exception. Swallowing those into `None` too would be the single most dangerous mistake + available in this module: a transient 5xx or a dropped connection while polling a live + order would then read exactly like "this order does not exist", and the adapter maps a + `None` `get_order` result to a terminal FAILED status -- reporting a live, resting order + as dead because a request timed out is precisely the failure this split prevents. + """ + import requests # deferred: see module docstring "Import safety"; only the live, + # network-backed transport needs the HTTP stack, not the Protocol or the pure signing + # helpers above. + + query = "" + if params: + # Sorted for a deterministic string: dict insertion order is an implementation detail + # that must not silently change what gets signed from one call to the next. + query = "?" + "&".join(f"{k}={v}" for k, v in sorted(params.items()) if v is not None) + full_path = f"{path}{query}" + + body_str = "" if body is None else json.dumps(body) + + # Robinhood's signature is only valid for 30 seconds from this timestamp, so it is taken + # immediately before signing and sending -- computing it earlier (e.g. once per batch of + # requests) would risk a stale-clock 401 on whichever request goes out last. + timestamp = int(time.time()) + signature = sign_payload( + self._private_key_b64, self._api_key, timestamp, full_path, method.upper(), body_str + ) + headers = build_headers(self._api_key, signature, timestamp) + + response = requests.request( + method, + f"{self._base_url}{full_path}", + headers=headers, + data=body_str if body is not None else None, + timeout=self._timeout, + ) + if response.status_code == 404: + return None + response.raise_for_status() + if not response.content: + return None + json_response: Any = response.json() + return json_response + + def _paginate(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + """Follow `next` cursors and concatenate `results`, so callers never see a page boundary. + + Returning a `{"results": [...]}` shaped dict -- rather than the raw last page, or a bare + list -- keeps this transport's paginated methods uniform with the endpoints that never + paginate (`get_order`, `create_order`): every caller reaches into a response with + `_results(response)` and gets the full, already-concatenated answer regardless of how + many pages the venue happened to split it across. See `_MAX_PAGES` for why the follow + loop is bounded rather than trusting `next` to terminate on its own. + """ + results: list[Any] = [] + next_path: str | None = path + next_params: dict[str, Any] | None = params + pages = 0 + while next_path is not None: + pages += 1 + if pages > _MAX_PAGES: + raise RuntimeError( + f"robinhood pagination did not terminate within {_MAX_PAGES} pages " + f"following {path!r}; refusing to loop further" + ) + page = self._request("GET", next_path, params=next_params) + results.extend(_results(page)) + cursor = _field(page, "next") + # After the first page, `next` is already a full absolute URL Robinhood hands back -- + # not a path this transport built -- so params are folded into it already and must + # not be re-appended on the next iteration. + if cursor: + if cursor.startswith(self._base_url): + cursor = cursor[len(self._base_url) :] + next_path = cursor + next_params = None + else: + next_path = None + return {"results": results} + + def get_accounts(self) -> Any: + return self._paginate("/api/v2/crypto/trading/accounts/") + + def get_holdings(self) -> Any: + return self._paginate( + "/api/v2/crypto/trading/holdings/", params={"account_number": self._account()} + ) + + def get_trading_pairs(self, symbol: str | None = None) -> Any: + params = {"symbol": symbol} if symbol is not None else None + return self._paginate("/api/v2/crypto/trading/trading_pairs/", params=params) + + def get_best_bid_ask(self, symbol: str) -> Any: + return self._paginate( + "/api/v2/crypto/marketdata/best_bid_ask/", params={"symbol": symbol} + ) + + def get_estimated_price(self, symbol: str, side: str, quantity: str) -> Any: + # Not paginated in practice (one symbol, one side, one quantity produces at most a + # handful of rows), but routed through `_paginate` anyway so its shape matches every + # other read here and the adapter never has to special-case one endpoint. + return self._paginate( + "/api/v2/crypto/trading/estimated_price/", + params={"symbol": symbol, "side": side, "quantity": quantity}, + ) + + def create_order(self, body: dict[str, Any]) -> Any: + return self._request( + "POST", + "/api/v2/crypto/trading/orders/", + params={"account_number": self._account()}, + body=body, + ) + + def get_order(self, order_id: str) -> Any: + """Fetch one order; `None` only if Robinhood's 404 says this id does not exist. + + See `_request`'s docstring for why every other failure mode raises instead: this is the + method `adapter.get_order` calls to reconcile a live position, and a `None` here becomes + a terminal FAILED status one layer up. Reporting FAILED because of a network blip rather + than because Robinhood actually said "no such order" would tell the engine a resting + order is gone when it is still live on the venue. + """ + return self._request("GET", f"/api/v2/crypto/trading/orders/{order_id}/") + + def cancel_order(self, order_id: str) -> Any: + """Cancel one order; `None` only if Robinhood's 404 says this id does not exist. + + Same reasoning as `get_order`: a `None` here must mean "the venue has never heard of this + id," not "something went wrong while cancelling." `adapter.cancel_order` treats anything + else -- including a successful response whose `state` is not `"canceled"` -- as evidence + to re-poll, precisely because a resting order that failed to cancel is still live money + and must never be reported as gone on the strength of a raised exception it didn't get. + """ + return self._request("POST", f"/api/v2/crypto/trading/orders/{order_id}/cancel/") + + +__all__ = [ + "RobinhoodTransport", + "Transport", + "_field", + "_results", + "build_headers", + "sign_payload", +] diff --git a/packages/keel-broker-robinhood/pyproject.toml b/packages/keel-broker-robinhood/pyproject.toml new file mode 100644 index 00000000..8f4112aa --- /dev/null +++ b/packages/keel-broker-robinhood/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "keel-broker-robinhood" +version = "0.5.7" +description = "Robinhood Crypto Trading API v2 adapter for keel" +requires-python = ">=3.14.4" +# `pynacl` is here and nowhere else in the workspace: Robinhood signs every request with an +# Ed25519 key, which no other venue keel talks to requires. Keeping it a dependency of this +# package alone means an engine that never installs this adapter never installs the crypto stack +# either -- the whole point of adapters being separate distributions. +dependencies = ["keel-core", "keel-broker-api", "pynacl>=1.5.0", "requests>=2.32.0"] + +[project.entry-points."keel.brokers"] +robinhood = "keel_broker_robinhood:RobinhoodAdapter" + +[build-system] +requires = ["uv_build>=0.10.4,<0.11.0"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-root = "" + +[tool.uv.sources] +keel-core = { workspace = true } +keel-broker-api = { workspace = true } diff --git a/pyproject.toml b/pyproject.toml index dce1187c..63bfa217 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ keel-core = { workspace = true } keel-broker-api = { workspace = true } keel-broker-coinbase = { workspace = true } keel-broker-fake = { workspace = true } +keel-broker-robinhood = { workspace = true } [dependency-groups] dev = [ @@ -54,6 +55,16 @@ dev = [ # Dev-only on purpose: the fake venue exists to exert design pressure on the port and to # prove two-plugin discovery. A production engine must never have it installed. "keel-broker-fake", + # Dev-only for a different reason: Robinhood is an OPTIONAL venue, not one keel needs in + # order to run. Making it a runtime dependency of `keel-trader` would put an Ed25519 stack + # (pynacl) into every install for an adapter the live path cannot even reach today -- + # nothing constructs it, `keel/commands/_common.py` still builds `CoinbaseClient` directly, + # and the broker-port migration has not landed. Coinbase is a hard dependency only because + # `keel/` still imports its SDK directly; no such import exists for this one, so the + # workspace entry above plus this line is the whole wiring. It is here rather than nowhere + # so the conformance suite actually runs against it in CI. Users who want the venue install + # `keel-broker-robinhood` themselves and entry-point discovery picks it up. + "keel-broker-robinhood", ] # Ruff config lives in ruff.toml at the repo root. A ruff.toml takes precedence over @@ -69,6 +80,7 @@ module = [ "keel_broker_api.*", "keel_broker_coinbase.*", "keel_broker_fake.*", + "keel_broker_robinhood.*", ] strict = true diff --git a/tests/broker_robinhood/__init__.py b/tests/broker_robinhood/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/broker_robinhood/test_adapter.py b/tests/broker_robinhood/test_adapter.py new file mode 100644 index 00000000..4ce88ccd --- /dev/null +++ b/tests/broker_robinhood/test_adapter.py @@ -0,0 +1,521 @@ +"""Tests for `RobinhoodAdapter`, the Robinhood Crypto Trading API v2 implementation of the +`Broker` port. + +Robinhood ships no sandbox (see `RobinhoodTransport`'s docstring), so every test here injects a +`FakeTransport` returning canned, real-shaped JSON from `tests/fixtures/rh_*.json`. No live +network call is made, and no live order is ever placed -- that is the entire point of the +fixture-driven design this module mirrors from `tests/broker_coinbase/test_adapter.py`. +""" + +from __future__ import annotations + +import json +import uuid +from decimal import Decimal +from pathlib import Path +from typing import Any + +import pytest +from keel_broker_api.orders import LimitGTC, MarketIOCByBase, MarketIOCByQuote, StopLimitGTC +from keel_broker_api.port import UnsupportedOrder +from keel_broker_api.results import Balance, FeeSummary, OrderStatus, PlaceResult, Preview +from keel_broker_robinhood import RobinhoodAdapter +from keel_core.types import Granularity, Side + +FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" + + +def load_fixture(name: str) -> dict[str, Any]: + with (FIXTURES_DIR / name).open() as f: + data: dict[str, Any] = json.load(f) + return data + + +def _contains_key(obj: Any, key: str) -> bool: + """Recursively check `obj` for `key` at any nesting depth. + + Used to assert `time_in_force` is absent from a market-order body entirely -- not merely + absent at the top level -- since a stray copy nested inside `market_order_config` would be + just as wrong as one at the top, and Robinhood's market orders are IOC-by-construction and + document no `time_in_force` field anywhere on that shape. + """ + if isinstance(obj, dict): + if key in obj: + return True + return any(_contains_key(v, key) for v in obj.values()) + return False + + +class FakeTransport: + """Duck-types the `Transport` Protocol from the module contract, returning fixtures and + recording every call's kwargs. + + `_issued_order_ids` mirrors the real venue's own distinction: an id this transport actually + handed out via `create_order` (or that a test seeded directly, the same shortcut + `tests/broker_coinbase/test_adapter.py` takes) is "known"; anything else is a 404 the venue + has never heard of. `get_order`/`cancel_order` return `None` for an unknown id specifically + because that is the contract's signal that the venue does not recognise it -- distinct from + every other failure mode, which must raise instead (see `transport.py`'s contract point 1). + """ + + def __init__( + self, + accounts: dict[str, Any] | None = None, + holdings: dict[str, Any] | None = None, + trading_pairs: dict[str, Any] | None = None, + best_bid_ask: dict[str, Any] | None = None, + estimated_price: dict[str, Any] | None = None, + placed: dict[str, Any] | None = None, + order: dict[str, Any] | None = None, + ) -> None: + self._accounts = accounts + self._holdings = holdings + self._trading_pairs = trading_pairs + self._best_bid_ask = best_bid_ask + self._estimated_price = estimated_price + self._placed = placed + self._order = order + self.calls: dict[str, dict[str, Any]] = {} + #: How many times each method was actually called, keyed by method name. Kept separate + #: from `self.calls` (which only remembers the latest kwargs, matching the Coinbase fake) + #: because the mandatory-single-re-poll test needs a count, not just the last argument. + self.call_counts: dict[str, int] = {} + self._issued_order_ids: set[str] = set() + + def _record(self, name: str, **kwargs: Any) -> None: + self.calls[name] = kwargs + self.call_counts[name] = self.call_counts.get(name, 0) + 1 + + def get_accounts(self) -> Any: + self._record("get_accounts") + return self._accounts + + def get_holdings(self) -> Any: + self._record("get_holdings") + return self._holdings + + def get_trading_pairs(self, symbol: str | None = None) -> Any: + self._record("get_trading_pairs", symbol=symbol) + return self._trading_pairs + + def get_best_bid_ask(self, symbol: str) -> Any: + self._record("get_best_bid_ask", symbol=symbol) + return self._best_bid_ask + + def get_estimated_price(self, symbol: str, side: str, quantity: str) -> Any: + self._record("get_estimated_price", symbol=symbol, side=side, quantity=quantity) + return self._estimated_price + + def create_order(self, body: dict[str, Any]) -> Any: + self._record("create_order", body=body) + if self._placed is None: + return None + issued_id = self._placed.get("id") + if issued_id is not None: + self._issued_order_ids.add(issued_id) + return self._placed + + def get_order(self, order_id: str) -> Any: + self._record("get_order", order_id=order_id) + if order_id not in self._issued_order_ids: + return None + order = dict(self._order) if self._order is not None else dict(self._placed or {}) + order["id"] = order_id + return order + + def cancel_order(self, order_id: str) -> Any: + self._record("cancel_order", order_id=order_id) + if order_id not in self._issued_order_ids: + return None + order = dict(self._order) if self._order is not None else dict(self._placed or {}) + order["id"] = order_id + order["state"] = "canceled" + return order + + +class _ReCancelTransport(FakeTransport): + """A `cancel_order` that answers with a still-`open` order every time, regardless of what + actually happened -- standing in for the real venue's cancel endpoint returning the order as + it stood at the moment cancellation was requested, before the cancellation itself has + settled. This is what makes the adapter's mandatory single re-poll of `get_order` observable: + the FINAL answer must come from that re-poll, not from this method's own return value. + """ + + def cancel_order(self, order_id: str) -> Any: + self._record("cancel_order", order_id=order_id) + if order_id not in self._issued_order_ids: + return None + order = dict(self._order or {}) + order["id"] = order_id + order["state"] = "open" + return order + + +def test_capabilities_declare_robinhood() -> None: + """The engine gates live spend on these declarations, so each clause here is a promise the + rest of the suite must keep: no native preview, a synthesized one instead, fee summaries + available, USD-only, spot-only.""" + caps = RobinhoodAdapter().capabilities() + + assert caps.venue == "robinhood" + assert caps.supports_native_preview is False + assert caps.synthesizes_preview is True + assert caps.supports_fee_summary is True + assert caps.quote_currencies == frozenset({"USD"}) + assert caps.asset_classes == frozenset({"spot"}) + assert caps.can_preview + + +def test_get_candles_refuses_every_granularity_by_name() -> None: + """Robinhood's Crypto Trading API has no OHLC/historical endpoint at all -- there is nothing + to page through, at any resolution. A `ValueError` that does not name this would look like a + caller passed a bad argument rather than the true reason: this venue cannot serve candles, + full stop, and strategy code must source them elsewhere.""" + adapter = RobinhoodAdapter(FakeTransport()) + for granularity in Granularity: + with pytest.raises(ValueError, match="no (candle|OHLC|historical)"): + adapter.get_candles("BTC-USD", granularity, 0, 86_400) + + +def test_get_balances_returns_one_balance_per_holding_plus_buying_power() -> None: + """Buying power is not a holding -- it is the account's own USD balance -- so an adapter that + forgot it would under-report available capital to anything sizing an order off `get_balances` + alone.""" + transport = FakeTransport( + accounts=load_fixture("rh_accounts.json"), holdings=load_fixture("rh_holdings.json") + ) + adapter = RobinhoodAdapter(transport) + + balances = adapter.get_balances() + + assert balances + assert all(isinstance(b, Balance) for b in balances) + holding = load_fixture("rh_holdings.json")["results"][0] + btc = next(b for b in balances if b.currency == holding["asset_code"]) + assert btc.available == Decimal(holding["quantity_available_for_trading"]) + assert btc.total == Decimal(holding["total_quantity"]) + + account = load_fixture("rh_accounts.json")["results"][0] + usd = next(b for b in balances if b.currency == account["buying_power_currency"]) + assert usd.available == Decimal(account["buying_power"]) + assert usd.total == Decimal(account["buying_power"]) + + assert len(balances) == len(load_fixture("rh_holdings.json")["results"]) + 1 + + +def test_preview_order_limit_gtc_prices_off_the_limit_price() -> None: + """A LimitGTC preview needs no live quote at all -- the caller already named the price they + will pay -- so `detail["price_basis"]` must say `"limit_price"` and the estimate must be + exact arithmetic, not a market snapshot that could disagree with the order about to be + placed.""" + transport = FakeTransport(accounts=load_fixture("rh_accounts.json")) + adapter = RobinhoodAdapter(transport) + spec = LimitGTC( + product_id="BTC-USD", side=Side.SELL, base_size=Decimal("0.1"), limit_price=Decimal("65000") + ) + + preview = adapter.preview_order(spec) + + fee_ratio = Decimal( + load_fixture("rh_accounts.json")["results"][0]["fee_tier_status"]["fee_ratio"] + ) + assert isinstance(preview, Preview) + assert preview.synthetic is True + assert preview.est_base_size == Decimal("0.1") + assert preview.est_quote_size == Decimal("0.1") * Decimal("65000") + assert preview.est_fee == preview.est_quote_size * fee_ratio + assert preview.detail["price_basis"] == "limit_price" + + +def test_preview_order_market_ioc_base_prices_off_the_estimated_price_endpoint() -> None: + """Unlike a limit order, a market order names no price of its own -- the only honest estimate + Robinhood can offer is its `estimated_price` endpoint, and `detail["price_basis"]` must say so + plainly rather than let the caller mistake this for a firm quote.""" + transport = FakeTransport( + accounts=load_fixture("rh_accounts.json"), + estimated_price=load_fixture("rh_estimated_price.json"), + ) + adapter = RobinhoodAdapter(transport) + spec = MarketIOCByBase(product_id="BTC-USD", side=Side.SELL, base_size=Decimal("0.1")) + + preview = adapter.preview_order(spec) + + price = Decimal(load_fixture("rh_estimated_price.json")["results"][0]["price"]) + assert preview.synthetic is True + assert preview.est_base_size == Decimal("0.1") + assert preview.est_quote_size == Decimal("0.1") * price + assert preview.detail["price_basis"] == "estimated_price" + assert transport.calls["get_estimated_price"]["symbol"] == "BTC-USD" + + +def test_place_order_market_ioc_base_sends_asset_quantity_and_no_time_in_force() -> None: + """Robinhood's `market_order_config` accepts only `asset_quantity` -- there is no + quote-sized market order and no `time_in_force` on this shape at all (market orders are + IOC-by-construction). A stray `time_in_force` key anywhere in the body would be a sign the + translator leaked a field this order type does not carry.""" + transport = FakeTransport(placed=load_fixture("rh_order_open.json")) + adapter = RobinhoodAdapter(transport) + spec = MarketIOCByBase(product_id="BTC-USD", side=Side.SELL, base_size=Decimal("0.1")) + + adapter.place_order(spec) + + body = transport.calls["create_order"]["body"] + assert body["market_order_config"] == {"asset_quantity": "0.1"} + assert not _contains_key(body, "time_in_force") + + +def test_place_order_limit_gtc_sends_the_full_limit_config() -> None: + transport = FakeTransport(placed=load_fixture("rh_order_open.json")) + adapter = RobinhoodAdapter(transport) + spec = LimitGTC( + product_id="BTC-USD", side=Side.SELL, base_size=Decimal("0.1"), limit_price=Decimal("65000") + ) + + adapter.place_order(spec) + + body = transport.calls["create_order"]["body"] + assert body["limit_order_config"] == { + "asset_quantity": "0.1", + "limit_price": "65000", + "time_in_force": "gtc", + } + + +def test_place_order_stop_limit_gtc_sends_the_full_stop_limit_config() -> None: + transport = FakeTransport(placed=load_fixture("rh_order_open.json")) + adapter = RobinhoodAdapter(transport) + spec = StopLimitGTC( + product_id="BTC-USD", + side=Side.SELL, + base_size=Decimal("0.1"), + stop_price=Decimal("60000"), + limit_price=Decimal("59900"), + ) + + adapter.place_order(spec) + + body = transport.calls["create_order"]["body"] + assert body["stop_limit_order_config"] == { + "asset_quantity": "0.1", + "limit_price": "59900", + "stop_price": "60000", + "time_in_force": "gtc", + } + + +def test_place_order_market_ioc_by_quote_is_refused_as_the_entry_path() -> None: + """`MarketIOCByQuote` is how keel enters positions. Robinhood's `market_order_config` takes + only `asset_quantity`, so there is no quote-sized market order on this API at all -- meaning + this adapter cannot open positions under keel's current entry model, only size exits and rest + limit/stop-limit orders. Synthesising a quote-sized order by dividing an estimated price is + deliberately NOT done: that would substitute a different sizing basis (a snapshot estimate) + for the one the caller actually asked for, on the live-money path, and it would do so + silently. `UnsupportedOrder` here is the honest refusal; a fabricated fill would not be.""" + adapter = RobinhoodAdapter(FakeTransport()) + spec = MarketIOCByQuote(product_id="BTC-USD", side=Side.BUY, quote_size=Decimal("100")) + + with pytest.raises(UnsupportedOrder): + adapter.place_order(spec) + + +def test_place_order_generates_a_fresh_client_order_id_per_call() -> None: + """Idempotency on Robinhood's side depends on this being unique per attempt -- a reused id on + a retried call could be read as a duplicate and silently dropped, or worse, matched to the + wrong attempt's outcome.""" + transport = FakeTransport(placed=load_fixture("rh_order_open.json")) + adapter = RobinhoodAdapter(transport) + spec = MarketIOCByBase(product_id="BTC-USD", side=Side.SELL, base_size=Decimal("0.1")) + + adapter.place_order(spec) + first = transport.calls["create_order"]["body"]["client_order_id"] + adapter.place_order(spec) + second = transport.calls["create_order"]["body"]["client_order_id"] + + assert first != second + uuid.UUID(first) + uuid.UUID(second) + + +def test_get_order_maps_fill_quantity_average_price_and_fee() -> None: + """Reconciliation needs OBSERVED fill data, not the expected price and previewed fee the + executor recorded at placement time -- `filled_asset_quantity`, `average_price`, and + `fee_charged` are the only fields on this venue that can supply it.""" + fixture = load_fixture("rh_order_filled.json") + transport = FakeTransport(order=fixture) + transport._issued_order_ids.add(fixture["id"]) + adapter = RobinhoodAdapter(transport) + + order = adapter.get_order(fixture["id"]) + + assert isinstance(order, OrderStatus) + assert order.status == "FILLED" + assert order.filled_size == Decimal(fixture["filled_asset_quantity"]) + assert order.average_filled_price == Decimal(fixture["average_price"]) + assert order.total_fees == Decimal(fixture["fee_charged"]) + + +def test_get_order_maps_canceled_state_to_the_ports_doubled_l_spelling() -> None: + """Robinhood spells it `canceled` (American, one `l`); the port spells it `CANCELLED`. This + is the one place those two spellings must actually agree, or a genuinely cancelled order + would read as an unrecognised state to every downstream consumer of `OrderStatus.status`.""" + fixture = load_fixture("rh_order_canceled.json") + transport = FakeTransport(order=fixture) + transport._issued_order_ids.add(fixture["id"]) + adapter = RobinhoodAdapter(transport) + + order = adapter.get_order(fixture["id"]) + + assert order.status == "CANCELLED" + + +def test_get_order_on_an_unknown_id_reports_failed_with_zeroed_money_fields_not_a_raise() -> None: + """An id the venue has never heard of is a 404, and `get_order` must turn that into an + ordinary `OrderStatus` the caller can do arithmetic on -- never an exception a reconciliation + loop would have to special-case, and never `None` money fields a caller would have to guard + before every calculation.""" + adapter = RobinhoodAdapter(FakeTransport()) + + order = adapter.get_order("an-id-this-venue-never-issued") + + assert order.status == "FAILED" + assert order.filled_size == Decimal("0") + assert order.average_filled_price == Decimal("0") + assert order.total_fees == Decimal("0") + + +def test_get_order_on_an_unrecognised_venue_state_reports_pending_not_failed() -> None: + """An unrecognised `state` string means the adapter does not actually know the order's + outcome -- not that the order failed. Reporting `FAILED` here would declare a terminal + outcome nobody observed, and the engine could then re-enter a position whose original order + is, for all this adapter knows, still live at the venue. `PENDING` keeps it under + observation instead, which is the only honest answer to "I don't know".""" + fixture = dict(load_fixture("rh_order_open.json")) + fixture["state"] = "a_future_state_this_adapter_predates" + transport = FakeTransport(order=fixture) + transport._issued_order_ids.add(fixture["id"]) + adapter = RobinhoodAdapter(transport) + + order = adapter.get_order(fixture["id"]) + + assert order.status == "PENDING" + + +def test_cancel_order_returns_true_when_the_venue_confirms_immediately() -> None: + fixture = load_fixture("rh_order_open.json") + transport = FakeTransport(order=fixture) + transport._issued_order_ids.add(fixture["id"]) + adapter = RobinhoodAdapter(transport) + + assert adapter.cancel_order(fixture["id"]) is True + + +def test_cancel_order_re_polls_once_and_true_comes_from_the_poll() -> None: + """Robinhood's cancel endpoint can hand back the order as it stood the instant cancellation + was requested, before the cancellation itself has settled -- so a cancel response that is not + yet `canceled` is not evidence of failure either. The adapter must re-poll `get_order` exactly + once and trust THAT answer, not retry forever and not give up after the first ambiguous + response.""" + fixture = load_fixture("rh_order_canceled.json") + transport = _ReCancelTransport(order=fixture) + transport._issued_order_ids.add(fixture["id"]) + adapter = RobinhoodAdapter(transport) + + assert adapter.cancel_order(fixture["id"]) is True + assert transport.call_counts.get("get_order", 0) == 1 + + +def test_cancel_order_re_polls_once_and_false_comes_from_the_poll() -> None: + """The mirror of the case above: if the single re-poll still shows the order resting `open`, + the cancel must be reported as failed rather than optimistically assumed -- a `True` the venue + never actually confirmed would let `executor._cancel_at_exchange` record a cancel that never + happened.""" + fixture = load_fixture("rh_order_open.json") + transport = _ReCancelTransport(order=fixture) + transport._issued_order_ids.add(fixture["id"]) + adapter = RobinhoodAdapter(transport) + + assert adapter.cancel_order(fixture["id"]) is False + assert transport.call_counts.get("get_order", 0) == 1 + + +def test_cancel_order_on_an_unknown_id_returns_false_and_does_not_raise() -> None: + """Absence of a refusal is not a confirmation, and an id the venue never issued is not a + network failure either -- it must fail closed as an ordinary `False`, matching the same + contract Coinbase's adapter is held to.""" + adapter = RobinhoodAdapter(FakeTransport()) + + assert adapter.cancel_order("an-id-this-venue-never-issued") is False + + +def test_get_fee_summary_maps_fee_ratio_to_both_taker_and_maker() -> None: + """Robinhood's `fee_tier_status` publishes a single `fee_ratio`, not separate maker/taker + rates -- so both must be populated from the same field rather than one silently defaulting to + zero, which would understate the venue's true cost on whichever side went unmapped.""" + transport = FakeTransport(accounts=load_fixture("rh_accounts.json")) + adapter = RobinhoodAdapter(transport) + + summary = adapter.get_fee_summary() + + fee_tier = load_fixture("rh_accounts.json")["results"][0]["fee_tier_status"] + assert isinstance(summary, FeeSummary) + assert summary.venue == "robinhood" + assert summary.taker_rate == Decimal(fee_tier["fee_ratio"]) + assert summary.maker_rate == Decimal(fee_tier["fee_ratio"]) + assert summary.volume_usd == Decimal(fee_tier["thirty_day_volume"]) + + +def test_get_fee_summary_declares_a_trailing_30d_window_by_name() -> None: + """Coinbase's adapter declares `"unknown"` here because Coinbase's own docs never state the + window, and guessing would let reconciliation compare a possibly-trailing-30-day volume + against a calendar-month allowance. Robinhood is different: `fee_tier_status.thirty_day_volume` + names its own window in the FIELD NAME itself, so declaring `"trailing_30d"` is not a guess -- + it is reading what the venue already told us, and withholding it as `"unknown"` would be the + less honest choice here, not the more cautious one.""" + transport = FakeTransport(accounts=load_fixture("rh_accounts.json")) + adapter = RobinhoodAdapter(transport) + + assert adapter.get_fee_summary().volume_window == "trailing_30d" + + +def test_get_fee_summary_reports_zero_fees_paid() -> None: + """Robinhood's `fee_tier_status` exposes a rate and a volume, but no account-level + fees-PAID total -- so `fees_usd` must be `Decimal("0")` rather than derived (rate * volume + would be an estimate dressed up as an observation). Subscription lapse detection reads this + field, and a nonzero value here that was never actually charged could mask a real lapse.""" + transport = FakeTransport(accounts=load_fixture("rh_accounts.json")) + adapter = RobinhoodAdapter(transport) + + assert adapter.get_fee_summary().fees_usd == Decimal("0") + + +def test_a_transportless_adapter_refuses_network_calls_clearly() -> None: + """`capabilities()` must work offline -- the engine needs it to decide whether to even wire + this venue up before any credentials exist. Anything that actually needs the network must say + why it cannot, rather than fail with an opaque `AttributeError` on `self._transport`.""" + adapter = RobinhoodAdapter() + + assert adapter.capabilities().venue == "robinhood" + with pytest.raises(RuntimeError, match="without a transport"): + adapter.get_balances() + + +def test_entry_point_discovery_finds_the_robinhood_adapter() -> None: + """Installing this package must be sufficient to make it discoverable -- `keel add + keel-broker-robinhood` and nothing else. A broken entry point here would silently strand the + adapter unreachable by `load_broker`, which is the only path the (future) broker-port + migration uses to find it.""" + from keel_broker_api.registry import load_broker + + assert load_broker("robinhood").__name__ == "RobinhoodAdapter" + + +def test_place_order_returns_a_domain_type() -> None: + """No Robinhood-native order object may cross the port -- a caller must only ever see + `PlaceResult`.""" + transport = FakeTransport(placed=load_fixture("rh_order_open.json")) + adapter = RobinhoodAdapter(transport) + spec = MarketIOCByBase(product_id="BTC-USD", side=Side.SELL, base_size=Decimal("0.1")) + + result = adapter.place_order(spec) + + assert isinstance(result, PlaceResult) diff --git a/tests/broker_robinhood/test_translate.py b/tests/broker_robinhood/test_translate.py new file mode 100644 index 00000000..ab07b378 --- /dev/null +++ b/tests/broker_robinhood/test_translate.py @@ -0,0 +1,161 @@ +"""Unit tests for `keel_broker_robinhood.translate` -- the one place keel's order model becomes +Robinhood's order-body and state vocabulary. + +These are pure-function tests: no transport, no network, no adapter. They exist to pin the exact +shape Robinhood's API demands (and the two-gate refusal of `MarketIOCByQuote`) independently of +whatever the adapter does with the result. +""" + +from __future__ import annotations + +from decimal import Decimal + +import pytest +from keel_broker_api.orders import LimitGTC, MarketIOCByBase, MarketIOCByQuote, StopLimitGTC +from keel_broker_api.port import UnsupportedOrder +from keel_broker_robinhood.translate import ( + STATE_TO_PORT_STATUS, + to_order_body, + to_port_status, + to_price_side, + to_side, + to_symbol, +) +from keel_core.types import Side + + +def test_to_symbol_uppercases_both_legs() -> None: + assert to_symbol("btc-usd") == "BTC-USD" + assert to_symbol("BTC-usd") == "BTC-USD" + + +def test_to_symbol_refuses_a_non_usd_quote_leg_and_names_it() -> None: + """Rewriting `BTC-USDC` to `BTC-USD` would silently settle against a different asset than the + caller asked for, on the live-money path -- and passing it through unchanged would be a + rejection indistinguishable from an outage. Refusing by name, with the offending currency in + the message, is the only option that is honest about what happened.""" + with pytest.raises(UnsupportedOrder, match="USDC"): + to_symbol("BTC-USDC") + + +def test_to_symbol_refuses_a_malformed_product_id() -> None: + """A product id that is not `BASE-QUOTE` shaped must not be guessed at -- guessing how to + split it risks the exact same silent-substitution failure as rewriting the quote leg.""" + with pytest.raises(UnsupportedOrder): + to_symbol("BTCUSD") + + +def test_to_side_renders_lowercase() -> None: + assert to_side(Side.BUY) == "buy" + assert to_side(Side.SELL) == "sell" + + +def test_to_price_side_buy_prices_off_the_ask() -> None: + """A BUY fills at the ask -- what a seller will take. Pricing it off the bid would show the + optimistic side of the spread, making a synthesized preview look better than the fill it is + meant to estimate.""" + assert to_price_side(Side.BUY) == "ask" + + +def test_to_price_side_sell_prices_off_the_bid() -> None: + assert to_price_side(Side.SELL) == "bid" + + +def test_market_ioc_base_body() -> None: + spec = MarketIOCByBase(product_id="BTC-USD", side=Side.SELL, base_size=Decimal("0.1")) + body = to_order_body(spec, client_order_id="c1") + + assert body["symbol"] == "BTC-USD" + assert body["client_order_id"] == "c1" + assert body["side"] == "sell" + assert body["type"] == "market" + assert body["market_order_config"] == {"asset_quantity": "0.1"} + + +def test_limit_gtc_body() -> None: + spec = LimitGTC( + product_id="BTC-USD", side=Side.SELL, base_size=Decimal("1"), limit_price=Decimal("70000") + ) + body = to_order_body(spec, client_order_id="c1") + + assert body["type"] == "limit" + assert body["limit_order_config"] == { + "asset_quantity": "1", + "limit_price": "70000", + "time_in_force": "gtc", + } + + +def test_stop_limit_gtc_body() -> None: + spec = StopLimitGTC( + product_id="BTC-USD", + side=Side.SELL, + base_size=Decimal("1"), + stop_price=Decimal("60000"), + limit_price=Decimal("59900"), + ) + body = to_order_body(spec, client_order_id="c1") + + assert body["type"] == "stop_limit" + assert body["stop_limit_order_config"] == { + "asset_quantity": "1", + "limit_price": "59900", + "stop_price": "60000", + "time_in_force": "gtc", + } + + +def test_decimals_are_rendered_as_exact_strings_not_floats() -> None: + """A float round-trip here would silently change an order's size, price, or stop on the + live-money path.""" + spec = LimitGTC( + product_id="BTC-USD", + side=Side.BUY, + base_size=Decimal("0.123456789"), + limit_price=Decimal("64000.10"), + ) + body = to_order_body(spec, client_order_id="c1") + + assert body["limit_order_config"]["asset_quantity"] == "0.123456789" + assert body["limit_order_config"]["limit_price"] == "64000.10" + + +def test_market_ioc_by_quote_is_refused_with_the_real_reason() -> None: + """This is the second gate: the adapter's capability declaration already excludes this kind, + so a future bug that routes a `MarketIOCByQuote` past the first gate must still be unable to + place an order here. The message must name the actual constraint -- `market_order_config` + accepts only `asset_quantity` -- not a generic "unsupported" with no explanation, since + whoever reads this exception at 2am needs to know it is not a bug to retry.""" + spec = MarketIOCByQuote(product_id="BTC-USD", side=Side.BUY, quote_size=Decimal("100")) + + with pytest.raises(UnsupportedOrder, match="asset_quantity"): + to_order_body(spec, client_order_id="c1") + + +@pytest.mark.parametrize( + ("state", "expected"), + [ + ("open", "OPEN"), + ("canceled", "CANCELLED"), + ("filled", "FILLED"), + ("failed", "FAILED"), + ("pending", "PENDING"), + ], +) +def test_to_port_status_maps_every_known_state(state: str, expected: str) -> None: + assert to_port_status(state) == expected + assert STATE_TO_PORT_STATUS[state] == expected + + +def test_to_port_status_defaults_an_unknown_state_to_pending_not_failed() -> None: + """An unrecognised state means the adapter does not know the outcome -- not that the order + failed. `PENDING` keeps the order under observation; `FAILED` would declare a terminal + outcome nobody observed, and could let the engine re-enter a position that is, for all this + adapter knows, still live at the venue.""" + assert to_port_status("a_future_state_this_adapter_predates") == "PENDING" + + +def test_to_port_status_defaults_none_to_pending() -> None: + """A missing `state` is the same "I don't know" as an unrecognised one, and must resolve the + same way -- under observation, not declared dead.""" + assert to_port_status(None) == "PENDING" diff --git a/tests/broker_robinhood/test_transport.py b/tests/broker_robinhood/test_transport.py new file mode 100644 index 00000000..6697c52f --- /dev/null +++ b/tests/broker_robinhood/test_transport.py @@ -0,0 +1,149 @@ +"""Zero-network tests for `keel_broker_robinhood.transport`'s pure functions. + +`sign_payload` and `build_headers` are free functions rather than methods specifically so the +Ed25519 signing rule is unit-testable against a known keypair with no network call at all -- that +design choice is what these tests exist to cash in on. `_field` and `_results` get their own +coverage here because they are the only thing standing between a malformed or paginated response +shape and a `KeyError`/`AttributeError` surfacing on the live-money path. +""" + +from __future__ import annotations + +import base64 +from typing import Any + +import nacl.signing +from keel_broker_robinhood.transport import _field, _results, build_headers, sign_payload + +#: Throwaway Ed25519 test seed. Generated once for this file with +#: `nacl.signing.SigningKey.generate()` and pasted here as a literal -- it has never been +#: registered with Robinhood, or with anything else; it exists solely so `sign_payload` has a +#: real keypair to sign against, letting these tests assert the exact canonical string without +#: touching the network or a real credential. +_TEST_SEED_B64 = "2+kuoJa6M34OpLTpnd6zR1eYaS+gmybyB40W27Fk7H0=" + +_BASE_KWARGS: dict[str, Any] = { + "private_key_b64": _TEST_SEED_B64, + "api_key": "rh-api-key-1", + "timestamp": 1_754_733_600, + "path": "/api/v2/crypto/trading/orders/", + "method": "POST", + "body": '{"symbol":"BTC-USD"}', +} + + +def _verify_key() -> nacl.signing.VerifyKey: + signing_key = nacl.signing.SigningKey(base64.b64decode(_TEST_SEED_B64)) + return signing_key.verify_key + + +def test_sign_payload_verifies_against_the_exact_canonical_message() -> None: + """The canonical string is `api_key + timestamp + path + method + body`, concatenated with no + separators and no delimiter between fields. Getting this concatenation wrong is invisible + until Robinhood rejects every signed request with a generic auth failure -- there is no + partial-credit response that says which field was misplaced -- so this test pins the exact + bytes that must be signed, not merely that `sign_payload` returns *something*.""" + signature_b64 = sign_payload(**_BASE_KWARGS) + signature = base64.b64decode(signature_b64) + message = ( + f"{_BASE_KWARGS['api_key']}{_BASE_KWARGS['timestamp']}" + f"{_BASE_KWARGS['path']}{_BASE_KWARGS['method']}{_BASE_KWARGS['body']}" + ).encode() + + _verify_key().verify(message, signature) # raises nacl.exceptions.BadSignatureError on fail + + +def test_sign_payload_changes_when_the_api_key_changes() -> None: + """If the signature ignored `api_key`, one account's signed request could be replayed under a + different key -- exactly the property Ed25519-signing every field is meant to prevent.""" + baseline = sign_payload(**_BASE_KWARGS) + varied = sign_payload(**{**_BASE_KWARGS, "api_key": "rh-api-key-2"}) + assert varied != baseline + + +def test_sign_payload_changes_when_the_timestamp_changes() -> None: + """A signature insensitive to `timestamp` would defeat Robinhood's 30-second replay window -- + a captured signed request could be resent indefinitely.""" + baseline = sign_payload(**_BASE_KWARGS) + varied = sign_payload(**{**_BASE_KWARGS, "timestamp": _BASE_KWARGS["timestamp"] + 1}) + assert varied != baseline + + +def test_sign_payload_changes_when_the_path_changes() -> None: + """A signature insensitive to `path` would let a signature minted for one endpoint (or one + order id's cancel URL) authorize a request against another.""" + baseline = sign_payload(**_BASE_KWARGS) + varied = sign_payload(**{**_BASE_KWARGS, "path": "/api/v2/crypto/trading/orders/other-id/"}) + assert varied != baseline + + +def test_sign_payload_changes_when_the_method_changes() -> None: + """A signature insensitive to `method` would let a signed GET authorize a POST against the + same path -- turning a read into a write.""" + baseline = sign_payload(**_BASE_KWARGS) + varied = sign_payload(**{**_BASE_KWARGS, "method": "GET"}) + assert varied != baseline + + +def test_sign_payload_changes_when_the_body_changes() -> None: + """A signature insensitive to `body` would let a signature minted for one order body + authorize placing a different order entirely -- the single most consequential field on this + list, since it is where size, price, and side live.""" + baseline = sign_payload(**_BASE_KWARGS) + varied = sign_payload(**{**_BASE_KWARGS, "body": '{"symbol":"ETH-USD"}'}) + assert varied != baseline + + +def test_build_headers_renders_the_timestamp_as_a_string() -> None: + """Robinhood reads `x-timestamp` off the wire as a header value, which is always a string -- + handing `requests` an int here would be a silent type mismatch nothing except the live API + would ever catch.""" + headers = build_headers("rh-api-key-1", "c2lnbmF0dXJl", 1_754_733_600) + + # `Content-Type` rides along with the three auth headers rather than being added at the + # request site, so there is exactly one place that decides what goes on a Robinhood request. + # Every call this transport makes is JSON or bodiless, so it is never wrong to send it. + assert headers == { + "x-api-key": "rh-api-key-1", + "x-signature": "c2lnbmF0dXJl", + "x-timestamp": "1754733600", + "Content-Type": "application/json", + } + assert isinstance(headers["x-timestamp"], str) + + +def test_field_reads_a_plain_dict() -> None: + assert _field({"a": 1}, "a") == 1 + assert _field({"a": 1}, "b", "default") == "default" + + +class _Obj: + """Stands in for whatever object shape a future JSON client might return instead of a dict -- + `_field` must work against both, since fixtures in this test suite are plain dicts but a real + client library is free to wrap responses in attribute-bearing objects.""" + + def __init__(self, a: int) -> None: + self.a = a + + +def test_field_reads_an_object_via_getattr() -> None: + assert _field(_Obj(a=2), "a") == 2 + assert _field(_Obj(a=2), "missing", "default") == "default" + + +def test_results_reads_the_paginated_results_list() -> None: + response = {"next": None, "previous": None, "results": [1, 2, 3]} + assert _results(response) == [1, 2, 3] + + +def test_results_tolerates_a_bare_list() -> None: + """Tests inject plain, unwrapped list fixtures in places; `_results` must not assume every + caller handed it a paginated envelope.""" + assert _results([1, 2, 3]) == [1, 2, 3] + + +def test_results_tolerates_none() -> None: + """A transport with nothing configured for a given call returns `None`. Treating that as an + empty list rather than raising keeps every caller of `_results` from having to special-case + an unset fixture or a genuinely empty response.""" + assert _results(None) == [] diff --git a/tests/conformance/test_robinhood_conformance.py b/tests/conformance/test_robinhood_conformance.py new file mode 100644 index 00000000..02803252 --- /dev/null +++ b/tests/conformance/test_robinhood_conformance.py @@ -0,0 +1,29 @@ +"""Robinhood held to the shared `Broker` contract, driven entirely by canned fixtures. + +Robinhood ships NO SANDBOX -- there is no test environment to point a real client at, unlike +Coinbase. A canned, in-memory `FakeTransport` is therefore the only safe way to run a suite that +calls `place_order`: pointing this suite at `RobinhoodTransport` with real credentials would place +real orders, and there is no lower-stakes venue to redirect it to first. +""" + +from __future__ import annotations + +from keel_broker_api.conformance.suite import BrokerConformanceTests +from keel_broker_robinhood import RobinhoodAdapter + +from tests.broker_robinhood.test_adapter import FakeTransport, load_fixture + + +class TestRobinhoodConformance(BrokerConformanceTests): + def broker(self) -> RobinhoodAdapter: + return RobinhoodAdapter( + FakeTransport( + accounts=load_fixture("rh_accounts.json"), + holdings=load_fixture("rh_holdings.json"), + trading_pairs=load_fixture("rh_trading_pairs.json"), + best_bid_ask=load_fixture("rh_best_bid_ask.json"), + estimated_price=load_fixture("rh_estimated_price.json"), + placed=load_fixture("rh_order_open.json"), + order=load_fixture("rh_order_open.json"), + ) + ) diff --git a/tests/fixtures/rh_accounts.json b/tests/fixtures/rh_accounts.json new file mode 100644 index 00000000..67b3337b --- /dev/null +++ b/tests/fixtures/rh_accounts.json @@ -0,0 +1,20 @@ +{ + "next": null, + "previous": null, + "results": [ + { + "account_number": "AB1234567890", + "status": "active", + "buying_power": "1042.55", + "buying_power_currency": "USD", + "account_type": "individual", + "is_api_tradable": true, + "fee_tier_status": { + "fee_ratio": "0.0025", + "thirty_day_volume": "12480.55", + "next_fee_tier_ratio": "0.0020", + "next_fee_tier_threshold": "50000.00" + } + } + ] +} diff --git a/tests/fixtures/rh_best_bid_ask.json b/tests/fixtures/rh_best_bid_ask.json new file mode 100644 index 00000000..f5013885 --- /dev/null +++ b/tests/fixtures/rh_best_bid_ask.json @@ -0,0 +1,15 @@ +{ + "next": null, + "previous": null, + "results": [ + { + "symbol": "BTC-USD", + "price": "65430.00", + "bid_inclusive_of_sell_spread": "65380.00", + "sell_spread": "0.05", + "ask_inclusive_of_buy_spread": "65480.00", + "buy_spread": "0.05", + "timestamp": "2026-08-09T12:00:00Z" + } + ] +} diff --git a/tests/fixtures/rh_estimated_price.json b/tests/fixtures/rh_estimated_price.json new file mode 100644 index 00000000..5d5c2f31 --- /dev/null +++ b/tests/fixtures/rh_estimated_price.json @@ -0,0 +1,12 @@ +{ + "next": null, + "previous": null, + "results": [ + { + "symbol": "BTC-USD", + "side": "ask", + "price": "65482.30", + "quantity": "0.1" + } + ] +} diff --git a/tests/fixtures/rh_holdings.json b/tests/fixtures/rh_holdings.json new file mode 100644 index 00000000..0fd6b987 --- /dev/null +++ b/tests/fixtures/rh_holdings.json @@ -0,0 +1,12 @@ +{ + "next": null, + "previous": null, + "results": [ + { + "account_number": "AB1234567890", + "asset_code": "BTC", + "total_quantity": "0.53219871", + "quantity_available_for_trading": "0.50000000" + } + ] +} diff --git a/tests/fixtures/rh_order_canceled.json b/tests/fixtures/rh_order_canceled.json new file mode 100644 index 00000000..c52f8d5b --- /dev/null +++ b/tests/fixtures/rh_order_canceled.json @@ -0,0 +1,21 @@ +{ + "id": "5c6d7e8f-9a0b-1c2d-3e4f-5a6b7c8d9e0f", + "account_number": "AB1234567890", + "symbol": "BTC-USD", + "client_order_id": "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e", + "side": "buy", + "type": "limit", + "state": "canceled", + "average_price": null, + "filled_asset_quantity": "0", + "created_at": "2026-08-09T11:40:00.000000Z", + "updated_at": "2026-08-09T11:45:00.000000Z", + "fee_charged": "0", + "estimated_fee_remaining": "0", + "executions": [], + "limit_order_config": { + "asset_quantity": "0.1", + "limit_price": "64000.00", + "time_in_force": "gtc" + } +} diff --git a/tests/fixtures/rh_order_filled.json b/tests/fixtures/rh_order_filled.json new file mode 100644 index 00000000..18c6859d --- /dev/null +++ b/tests/fixtures/rh_order_filled.json @@ -0,0 +1,25 @@ +{ + "id": "1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d", + "account_number": "AB1234567890", + "symbol": "BTC-USD", + "client_order_id": "9f8e7d6c-5b4a-3c2d-1e0f-9a8b7c6d5e4f", + "side": "sell", + "type": "market", + "state": "filled", + "average_price": "65420.75", + "filled_asset_quantity": "0.1", + "created_at": "2026-08-09T11:55:00.000000Z", + "updated_at": "2026-08-09T11:55:01.000000Z", + "fee_charged": "1.6355", + "estimated_fee_remaining": "0", + "executions": [ + { + "effective_price": "65420.75", + "quantity": "0.1", + "timestamp": "2026-08-09T11:55:01.000000Z" + } + ], + "market_order_config": { + "asset_quantity": "0.1" + } +} diff --git a/tests/fixtures/rh_order_open.json b/tests/fixtures/rh_order_open.json new file mode 100644 index 00000000..290f5984 --- /dev/null +++ b/tests/fixtures/rh_order_open.json @@ -0,0 +1,21 @@ +{ + "id": "8e5f9c2a-1b3d-4e6f-9a2b-7c1d3e5f9a2b", + "account_number": "AB1234567890", + "symbol": "BTC-USD", + "client_order_id": "3f2e1d4c-5b6a-7c8d-9e0f-1a2b3c4d5e6f", + "side": "buy", + "type": "limit", + "state": "open", + "average_price": null, + "filled_asset_quantity": "0", + "created_at": "2026-08-09T12:00:00.000000Z", + "updated_at": "2026-08-09T12:00:00.000000Z", + "fee_charged": "0", + "estimated_fee_remaining": "0.16", + "executions": [], + "limit_order_config": { + "asset_quantity": "0.1", + "limit_price": "64000.00", + "time_in_force": "gtc" + } +} diff --git a/tests/fixtures/rh_trading_pairs.json b/tests/fixtures/rh_trading_pairs.json new file mode 100644 index 00000000..f6f36508 --- /dev/null +++ b/tests/fixtures/rh_trading_pairs.json @@ -0,0 +1,17 @@ +{ + "next": null, + "previous": null, + "results": [ + { + "symbol": "BTC-USD", + "asset_code": "BTC", + "quote_code": "USD", + "asset_increment": "0.00000001", + "quote_increment": "0.01", + "max_order_size": "10.00000000", + "min_order_amount": "1.00", + "status": "tradable", + "is_api_tradable": true + } + ] +} diff --git a/uv.lock b/uv.lock index c38f77ef..1631ccea 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,7 @@ members = [ "keel-broker-api", "keel-broker-coinbase", "keel-broker-fake", + "keel-broker-robinhood", "keel-core", "keel-trader", ] @@ -325,6 +326,25 @@ requires-dist = [ { name = "keel-core", editable = "packages/keel-core" }, ] +[[package]] +name = "keel-broker-robinhood" +version = "0.5.7" +source = { editable = "packages/keel-broker-robinhood" } +dependencies = [ + { name = "keel-broker-api" }, + { name = "keel-core" }, + { name = "pynacl" }, + { name = "requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "keel-broker-api", editable = "packages/keel-broker-api" }, + { name = "keel-core", editable = "packages/keel-core" }, + { name = "pynacl", specifier = ">=1.5.0" }, + { name = "requests", specifier = ">=2.32.0" }, +] + [[package]] name = "keel-core" version = "0.5.7" @@ -354,6 +374,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "keel-broker-fake" }, + { name = "keel-broker-robinhood" }, { name = "mypy" }, { name = "pytest" }, { name = "ruff" }, @@ -370,6 +391,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "keel-broker-fake", editable = "packages/keel-broker-fake" }, + { name = "keel-broker-robinhood", editable = "packages/keel-broker-robinhood" }, { name = "mypy", specifier = ">=1.18.0" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "ruff", specifier = ">=0.15.21" }, @@ -504,6 +526,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, ] +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + [[package]] name = "pytest" version = "9.1.1"