From 2f6ffa5b54f349d8719593e9f98c8e771925f0c1 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Thu, 27 Aug 2026 20:30:04 -0400 Subject: [PATCH 1/5] refactor(executor): the confirm gate and book reads speak the port's Preview only (#524) The dual-shape probes the issue named are deleted, not dormant: - ConfirmFn takes Preview, not Preview | Mapping -- every broker the live path can construct answers preview_order in the port's type - _preview_book's dict arm is gone; the spread gate (#350) and the entry-override warning (#332) read Preview.detail alone, and garbage falls into the same fail-closed arm as an absent key - _read_preview/_interactive_confirm (keel/commands/confirm.py) lose the legacy Coinbase dict arm and the 'Coinbase order preview' header that existed only to name the shape's one producer - ExecutionResult.preview is Preview | None Tests: a dict preview now renders UNREADABLE and demands the typed phrase; _preview_book reads no book out of a dict; the agent gate tests drive Previews; the direct-dict warning fixtures build Previews. --- keel/commands/confirm.py | 96 ++++------------ keel/execution/executor.py | 72 ++++++------ .../keel_broker_api/results.py | 10 +- tests/execution/test_executor.py | 60 ++++++---- tests/test_agent.py | 29 ++++- tests/test_confirm_gate.py | 103 ++++++------------ 6 files changed, 156 insertions(+), 214 deletions(-) diff --git a/keel/commands/confirm.py b/keel/commands/confirm.py index a6877b74..154f48b2 100644 --- a/keel/commands/confirm.py +++ b/keel/commands/confirm.py @@ -9,7 +9,7 @@ It is click-COUPLED by design: the ask is a terminal prompt (`click.confirm`/`click.prompt`), and the fail-closed TTY check goes through `keel.commands._common._is_interactive` -- the single -patch point every other gate uses. Everything else here (reading either preview shape, deciding +patch point every other gate uses. Everything else here (reading the preview shape, deciding degradedness, rendering the lines) is pure, so a front-end that renders previews itself can still reuse `read_preview`/`preview_lines` and get byte-identical banners. @@ -20,8 +20,6 @@ from __future__ import annotations -from collections.abc import Mapping -from decimal import Decimal, InvalidOperation from typing import Any import click @@ -44,35 +42,16 @@ _RULE_ALARM = " " + "!" * 72 -def _preview_decimal(value: Any) -> Decimal | None: - """Best-effort `Decimal` for a money field that may be a `Decimal`, a string or junk.""" - if value is None or isinstance(value, bool): - return None - try: - return Decimal(str(value)) - except (InvalidOperation, ValueError, TypeError): - return None - - -def _read_preview(preview: Any) -> tuple[bool, dict[str, Any], tuple[str, ...], bool, bool]: - """Normalize either accepted preview shape into `(synthetic, fields, errors, unpriced, ok)`. - - **Why two shapes.** The port's `Preview` is where this is going; the raw dict is where the - live path still is. `executor.py` calls `broker.preview_order(product_id, side, - order_configuration)` and `keel/commands/_common.py` builds a `CoinbaseClient`, so every - preview a human sees TODAY is `cb_client.preview_order`'s dict. Phase B has not landed. - Accepting only `Preview` would break the one venue that actually trades; accepting only - `dict` is the bug this exists to fix. So the gate accepts both and this function is the seam - -- deliberately transitional, and deletable the day `preview_order` returns `Preview`. - - **Why a dict is read as native.** The dict shape *is* the Coinbase native-preview response - (`supports_native_preview=True`), so treating it as a broker quote is accurate rather than - optimistic. But "accurate today" is not "safe forever": if some future adapter returns a dict - while synthesizing, silently labelling it a broker quote is exactly the failure this issue is - about. So a dict that carries a truthy `synthetic` key is believed over the default. Any new - adapter should return `Preview` and not rely on that. - - `ok=False` means neither shape was recognized -- which is itself a warning, not a blank. +def _read_preview( + preview: Preview | None, +) -> tuple[bool, dict[str, Any], tuple[str, ...], bool, bool]: + """Normalize the port's `Preview` into `(synthetic, fields, errors, unpriced, ok)`. + + ONE shape since #524 finished the broker-port migration: every broker the live path can + construct answers `preview_order` with a `Preview`, so the legacy Coinbase dict arm is + deleted, not dormant. Anything else -- including a dict, the pre-port shape -- is + unreadable (`ok=False`), which renders as its own alarm and demands the typed phrase + rather than guessing at numbers this gate cannot vouch for. """ if isinstance(preview, Preview): fields: dict[str, Any] = { @@ -93,42 +72,16 @@ def _read_preview(preview: Any) -> tuple[bool, dict[str, Any], tuple[str, ...], unpriced = preview.est_quote_size <= 0 or preview.est_base_size <= 0 return preview.synthetic, fields, tuple(preview.errors), unpriced, True - if isinstance(preview, Mapping) and preview: - errors = preview.get("errs") or preview.get("errors") or () - sized = next( - ( - _preview_decimal(preview[key]) - for key in ("order_total", "quote_size", "est_quote_size") - if key in preview - ), - None, - ) - # `== 0`, NOT `<= 0`, and the difference is deliberate. A zero size is unambiguous: the - # order has no size and its cost is unknown. A NEGATIVE one is not -- it could just as - # easily be Coinbase reporting a SELL's `order_total` as signed proceeds, and that - # convention has not been verified against a real sell preview. Guessing wrong in the - # `<= 0` direction would demand a typed phrase on EVERY live sell, which trains the - # operator to type it by reflex and destroys the signal on the previews that need it. - # Sign-agnostic here until a live probe settles it; the `Preview` branch above owns its - # own types and can afford to be stricter. - return ( - bool(preview.get("synthetic", False)), - dict(preview), - tuple(str(error) for error in errors), - sized is None or sized == 0, - True, - ) - return False, {}, (), True, False -def _preview_lines(preview: Any) -> tuple[list[str], bool]: +def _preview_lines(preview: Preview | None) -> tuple[list[str], bool]: """Render `preview` for a human; return `(lines, degraded)`. `degraded` is true when the preview is unpriced, carries errors, or could not be read at all -- the three cases where the numbers on screen do not mean what they appear to mean. - Provenance is rendered ABOVE the numbers, not below them, because a footnote under a tidy + Provenance is rendered ABOVE the numbers, not below, because a footnote under a tidy key/value block is read after the decision has already been made. """ synthetic, fields, errors, unpriced, readable = _read_preview(preview) @@ -203,13 +156,13 @@ def _ask_to_place(degraded: bool) -> bool: default="", show_default=False, ) - except (click.Abort, EOFError): + except click.Abort, EOFError: click.echo("aborted -- declining.", err=True) return False return str(typed).strip().lower() == DEGRADED_PREVIEW_PHRASE -def _interactive_confirm(preview: Preview | Mapping[str, Any] | None) -> bool: +def _interactive_confirm(preview: Preview | None) -> bool: """Human-in-the-loop order confirmation for `mode="confirm"`. Called by the executor ONLY after the intent has already passed every hard rail -- this is @@ -219,8 +172,8 @@ def _interactive_confirm(preview: Preview | Mapping[str, Any] | None) -> bool: "approving an estimate must never look identical to approving a broker's own quote." A venue that has a preview endpoint returns numbers it is willing to stand behind; a venue without one gets an estimate keel computed from a price lookup that validated nothing and - reserved nothing. Those two screens used to be byte-identical, because this function took a - raw dict and had nowhere to put `Preview.synthetic`. They are now visually unmistakable, and + reserved nothing. Those two screens used to be byte-identical, because this function took + a raw dict and had nowhere to put `Preview.synthetic`. They are now visually unmistakable, and the distinction sits ABOVE the numbers rather than under them. Three further states get their own alarm block: `Preview.errors` (the adapter or the venue @@ -229,22 +182,13 @@ def _interactive_confirm(preview: Preview | Mapping[str, Any] | None) -> bool: Each of those also upgrades the question from `[y/N]` to a typed phrase -- see `_ask_to_place` for why that is friction and not a refusal. - Accepts both the port's `Preview` and the legacy Coinbase dict; `_read_preview` documents why - both, and which one the live path actually sends today. - Fails closed: a non-TTY invocation (a script, a cron job, a headless run) declines rather than blocking on stdin, so `mode="confirm"` never trades unattended. """ lines, degraded = _preview_lines(preview) - # The legacy header names Coinbase because the dict shape IS the Coinbase client's response. - # A `Preview` can come from any venue, so it gets the venue-neutral header. - # An unreadable preview is not "Coinbase's" either -- naming a venue over a shape this gate - # could not parse asserts a provenance it does not have. - legacy_coinbase_dict = ( - isinstance(preview, Mapping) and bool(preview) and not preview.get("synthetic", False) - ) - header = "Coinbase order preview" if legacy_coinbase_dict else "Order preview" - click.echo(f"\nRails PASSED. {header}:") + # Venue-neutral on purpose: a `Preview` can come from any venue, and an unreadable preview + # is not any venue's either -- naming one would assert a provenance this gate does not have. + click.echo("\nRails PASSED. Order preview:") for line in lines: click.echo(line) if not _common._is_interactive(): diff --git a/keel/execution/executor.py b/keel/execution/executor.py index 7b44eed6..b0686a79 100644 --- a/keel/execution/executor.py +++ b/keel/execution/executor.py @@ -97,7 +97,7 @@ import json import logging import time -from collections.abc import Callable, Mapping +from collections.abc import Callable from dataclasses import dataclass, replace from decimal import Decimal, InvalidOperation from typing import Any, Literal @@ -138,10 +138,10 @@ class ExecutionResult: placed: bool order_id: int | None vetoed_by: list[str] - # Whatever `broker.preview_order` handed back, verbatim. A dict from the pre-port - # `CoinbaseClient` today; a `Preview` once Phase B migrates the call. Anything reading money - # out of this must branch on the shape -- see `ConfirmFn` below and `_run_order`'s `fee`. - preview: Preview | dict[str, Any] | None + # Whatever `broker.preview_order` handed back, verbatim -- the port's `Preview` since #524 + # finished the broker-port migration: every broker the live path can construct answers in + # that type, so there is no second shape to branch on anywhere downstream. + preview: Preview | None reason: str # The local `orders.id` of the exit bracket this entry left resting, when one was placed. # `execute` places the bracket itself, so this is the ONLY way a caller can learn its id -- @@ -152,13 +152,12 @@ class ExecutionResult: bracket_order_id: int | None = None -#: The human confirm gate for `mode="confirm"`. Takes BOTH shapes on purpose: `broker` here is -#: still the pre-port `CoinbaseClient`, whose `preview_order` returns a dict, but the port's -#: `Preview` is what carries `synthetic` -- the flag that tells a human whether they are -#: approving a venue's quote or an estimate keel computed. A gate typed to `dict` alone has -#: nowhere to render that (issue #199), and one typed to `Preview` alone breaks the only venue -#: that trades today. Both, until Phase B makes `preview_order` return `Preview` everywhere. -ConfirmFn = Callable[[Preview | Mapping[str, Any]], bool] +#: The human confirm gate for `mode="confirm"`. One shape: the port's `Preview`, the only thing +#: `broker.preview_order` returns since the migration finished. `Preview` is what carries +#: `synthetic` -- the flag that tells a human whether they are approving a venue's quote or an +#: estimate keel computed (issue #199) -- so a gate typed to anything less has nowhere to +#: render that distinction. +ConfirmFn = Callable[[Preview], bool] # -- main entry point ----------------------------------------------------------------------- @@ -411,7 +410,7 @@ def _coerce_increment(raw: object) -> Decimal | None: return None try: value = Decimal(str(raw)) - except (InvalidOperation, TypeError, ValueError): + except InvalidOperation, TypeError, ValueError: return None return value if value > 0 else None @@ -899,7 +898,7 @@ def _log_intent_divergence(order_id: int, intent: OrderIntent | None, realized: if expected <= 0: return divergence_bps = (actual - expected) / expected * Decimal(10_000) - except (InvalidOperation, TypeError, ValueError): + except InvalidOperation, TypeError, ValueError: log_exception(logger, "executor.intent_divergence_uncomputable", order_id=order_id) return @@ -942,16 +941,14 @@ def _log_intent_divergence(order_id: int, intent: OrderIntent | None, realized: ENTRY_OVERRIDE_WARN_BP = Decimal("50") -def _preview_book(preview: Preview | dict[str, Any]) -> tuple[Decimal | None, Decimal | None]: - """The venue's book out of a preview response, as `(best_bid, best_ask)`, each field read - INDEPENDENTLY and safely, in whichever of the preview's two shapes. +def _preview_book(preview: Preview) -> tuple[Decimal | None, Decimal | None]: + """The venue's book out of a preview, as `(best_bid, best_ask)`, each field read + INDEPENDENTLY and safely. One helper, two consumers (#350): #332's entry-override warning needs only the ask, and - the routing-time max-spread gate needs both sides plus their midpoint. Both shapes - already cross this module (`ConfirmFn`'s docstring explains why they coexist): the - pre-port `CoinbaseClient.preview_order` dict, which maps `best_bid`/`best_ask` to - `Decimal`s, and the port's `Preview`, whose Coinbase adapter carries the same book as - strings inside `detail`. + the routing-time max-spread gate needs both sides plus their midpoint. The book lives in + `Preview.detail` as strings -- the port's one shape since #524 finished the migration, so + there is no dict arm to keep in agreement with it. Each side is `None` when THE VENUE returned no usable value for that field -- absent key, non-numeric string, or a non-finite/non-positive number -- a degraded response, not an @@ -964,13 +961,14 @@ def _preview_book(preview: Preview | dict[str, Any]) -> tuple[Decimal | None, De RAISES InvalidOperation, and a venue string of "nan" parses into exactly that (`cb_client` does `Decimal(value)` on venue strings with no finiteness check, so the input is reachable). A non-finite side is a degraded preview, not a routing failure. + + `getattr` rather than `preview.detail`, because this helper feeds #332's warning whose + contract is NEVER-RAISES: an object without a `detail` -- a contract-violating broker, a + stale dict from a pre-port fake -- reads as no book at all, which is already this + function's answer to "cannot know". No shape is probed back into existence; garbage just + falls into the same fail-closed arm as an absent key. """ - raw: dict[str, Any] = {} - if isinstance(preview, Mapping): - raw = {"best_bid": preview.get("best_bid"), "best_ask": preview.get("best_ask")} - else: - detail = getattr(preview, "detail", None) - raw = detail if detail is not None else {} + raw = getattr(preview, "detail", None) or {} def _side(key: str) -> Decimal | None: value = raw.get(key) @@ -978,17 +976,17 @@ def _side(key: str) -> Decimal | None: return None try: parsed = Decimal(str(value)) - except (InvalidOperation, TypeError, ValueError): + except InvalidOperation, TypeError, ValueError: return None return parsed if parsed.is_finite() and parsed > 0 else None return _side("best_bid"), _side("best_ask") -def _preview_best_ask(preview: Preview | dict[str, Any]) -> Decimal | None: - """The venue's best ask out of a preview response, in whichever of its two shapes. +def _preview_best_ask(preview: Preview) -> Decimal | None: + """The venue's best ask out of a preview. - A thin consumer of `_preview_book` (above): same shapes, same per-field safety, ask only. + A thin consumer of `_preview_book` (above): same read, same per-field safety, ask only. """ return _preview_book(preview)[1] @@ -1048,7 +1046,7 @@ def _warn_if_market_routing_overrides_entry( return try: expected = Decimal(str(intent.entry)) - except (InvalidOperation, TypeError, ValueError): + except InvalidOperation, TypeError, ValueError: return if not expected.is_finite() or expected <= 0: return @@ -1106,7 +1104,7 @@ class _SpreadGateRefusal: def _entry_spread_gate( intent: OrderIntent, - preview: Preview | dict[str, Any] | None, + preview: Preview | None, max_entry_spread_pct: Decimal, ) -> _SpreadGateRefusal | None: """Refuse a live BUY whose previewed book is too wide to enter (#350). `None` = proceed. @@ -1309,9 +1307,7 @@ def _order_spec(intent: OrderIntent) -> OrderSpec: f"{intent.product_id!r} notional {intent.notional} quantizes to {notional} at " f"increment {increment} -- refusing to send a zero-size order" ) - return MarketIOCByQuote( - product_id=intent.product_id, side=Side.BUY, quote_size=notional - ) + return MarketIOCByQuote(product_id=intent.product_id, side=Side.BUY, quote_size=notional) return MarketIOCByBase( product_id=intent.product_id, side=Side.SELL, base_size=_sell_base_size(intent) ) @@ -1475,7 +1471,7 @@ def _native_order_id(order_row: dict[str, Any]) -> str | None: return None try: data = json.loads(raw) - except (TypeError, ValueError): + except TypeError, ValueError: return None return data.get("order_id") diff --git a/packages/keel-broker-api/keel_broker_api/results.py b/packages/keel-broker-api/keel_broker_api/results.py index d5e95a0b..356fbc2d 100644 --- a/packages/keel-broker-api/keel_broker_api/results.py +++ b/packages/keel-broker-api/keel_broker_api/results.py @@ -1,8 +1,9 @@ """Domain types crossing the port in the broker-to-engine direction. -These replace the raw dicts today's `cb_client` returns: `get_accounts() -> list[dict]` probed at -`executor.py:168`, and `place_order`'s dict probed via `place_result.get("success")` at -`executor.py:345`. +These replaced the raw dicts the pre-port `cb_client` returned -- `get_accounts() -> list[dict]` +and `place_order`'s dict probed via `place_result.get("success")` -- and, since #524 finished +the broker-port migration, they are the only shapes the engine reads: every broker the live path +can construct answers in these types, and the probing branches are deleted rather than dormant. """ from __future__ import annotations @@ -166,8 +167,7 @@ class of fact and would sit here naturally, but nothing reads them yet, and a fi def __post_init__(self) -> None: if self.base_increment <= 0: raise ValueError( - f"base_increment must be positive, got {self.base_increment} for " - f"{self.product_id}" + f"base_increment must be positive, got {self.base_increment} for {self.product_id}" ) diff --git a/tests/execution/test_executor.py b/tests/execution/test_executor.py index 863bb0dc..fd4f15ee 100644 --- a/tests/execution/test_executor.py +++ b/tests/execution/test_executor.py @@ -139,9 +139,7 @@ def get_balances(self) -> list[Balance]: """ self.get_balances_calls += 1 if self._balances is not None: - return [ - Balance(currency=c, available=b, total=b) for c, b in self._balances.items() - ] + return [Balance(currency=c, available=b, total=b) for c, b in self._balances.items()] if self._usdc_balance is None: return [] return [ @@ -429,10 +427,7 @@ def test_rule_id_is_purely_additive_metadata_placement_and_guards_are_unchanged( assert result_a.vetoed_by == result_b.vetoed_by == [] assert len(broker_a.preview_calls) == len(broker_b.preview_calls) assert len(broker_a.place_calls) == len(broker_b.place_calls) - assert ( - broker_a.place_calls[0]["spec"] - == broker_b.place_calls[0]["spec"] - ) + assert broker_a.place_calls[0]["spec"] == broker_b.place_calls[0]["spec"] order_a = repo.get_order(result_a.order_id) order_b = repo.get_order(result_b.order_id) @@ -765,10 +760,7 @@ def test_live_execute_sizing_is_immune_to_reward_income_through_the_public_entry == expected * signal.setup.entry ) # The order actually sent to the venue is identical -- same sized base_size. - assert ( - reward_broker.place_calls[0]["spec"] - == clean_broker.place_calls[0]["spec"] - ) + assert reward_broker.place_calls[0]["spec"] == clean_broker.place_calls[0]["spec"] # -- DCA sizing -------------------------------------------------------------------------------- @@ -2500,6 +2492,24 @@ def _quoted_preview(best_ask: str) -> dict[str, Any]: } +def _quoted_quote(intent: Any, best_ask: str) -> Preview: + """`_quoted_preview`'s payload as the port's `Preview` -- the ONE shape the warning and the + spread gate read since #524 deleted their dict arms. Same `detail`-as-strings the real + adapter carries, so the safety paths exercise the same parsing they do live.""" + return _preview_from(intent, _quoted_preview(best_ask)) + + +def test_preview_book_reads_the_ports_shape_only() -> None: + """#524's deletion proof: the dict arm of `_preview_book` is gone. A dict is no longer a + shape anything on the live path produces -- every constructible broker answers + `preview_order` with the port's `Preview` -- so the helper must read NO book out of one, + fail-closed exactly like a preview whose `detail` carries no sides.""" + from keel.execution.executor import _preview_book + + legacy_dict = _quoted_preview("50000") + assert _preview_book(legacy_dict) == (None, None) + + class TestEntryOverrideWarningAtRouting: """#260's minimum viable mitigation, at ROUTING time (the divergence class above reports after the fill; this warns before/at placement). @@ -2581,9 +2591,10 @@ def test_exactly_at_the_threshold_does_not_warn(self, caplog) -> None: market = Decimal("50000") at_the_line = market * (Decimal(1) + ENTRY_OVERRIDE_WARN_BP / Decimal(10_000)) + at_the_line_intent = self._intent(entry=str(at_the_line)) with caplog.at_level(logging.WARNING): _warn_if_market_routing_overrides_entry( - self._intent(entry=str(at_the_line)), _quoted_preview("50000") + at_the_line_intent, _quoted_quote(at_the_line_intent, "50000") ) assert not [r for r in caplog.records if r.getMessage() == _OVERRIDE_EVENT] @@ -2597,10 +2608,9 @@ def test_entry_below_market_warns_with_a_negative_sign(self, caplog) -> None: """ from keel.execution.executor import _warn_if_market_routing_overrides_entry + below = self._intent(entry="49700") with caplog.at_level(logging.WARNING): - _warn_if_market_routing_overrides_entry( - self._intent(entry="49700"), _quoted_preview("50000") - ) + _warn_if_market_routing_overrides_entry(below, _quoted_quote(below, "50000")) fields = _override_fields(caplog) assert fields["deviation_bps"] == "-60.00" @@ -2624,10 +2634,14 @@ def test_a_preview_without_a_book_quote_is_silent_not_fatal(self, caplog) -> Non "warning": [], } with caplog.at_level(logging.WARNING): - _warn_if_market_routing_overrides_entry(self._intent(entry="50300"), bookless) + _warn_if_market_routing_overrides_entry( + self._intent(entry="50300"), _preview_from(self._intent(), bookless) + ) _warn_if_market_routing_overrides_entry( self._intent(entry="50300"), - {**_quoted_preview("50000"), "best_ask": "not-a-number"}, + _preview_from( + self._intent(), {**_quoted_preview("50000"), "best_ask": "not-a-number"} + ), ) # "nan" PARSES -- Decimal('NaN') constructs fine, and NaN > 0 raises # InvalidOperation. cb_client does Decimal(value) on venue strings with no @@ -2636,11 +2650,11 @@ def test_a_preview_without_a_book_quote_is_silent_not_fatal(self, caplog) -> Non # hazard inside its try). _warn_if_market_routing_overrides_entry( self._intent(entry="50300"), - {**_quoted_preview("50000"), "best_ask": "nan"}, + _preview_from(self._intent(), {**_quoted_preview("50000"), "best_ask": "nan"}), ) # A zero/unusable intended entry has no meaningful deviation either. _warn_if_market_routing_overrides_entry( - self._intent(entry="0"), _quoted_preview("50000") + self._intent(entry="0"), _quoted_quote(self._intent(), "50000") ) # An extreme-but-finite exponent (a rule bug, not venue data): parses, is_finite, # and compares fine -- the DIVISION is what raises (Decimal Overflow, an @@ -2693,7 +2707,9 @@ def test_sell_intents_never_warn(self, caplog) -> None: ) with caplog.at_level(logging.WARNING): - _warn_if_market_routing_overrides_entry(sell_intent, _quoted_preview("50000")) + _warn_if_market_routing_overrides_entry( + sell_intent, _quoted_quote(sell_intent, "50000") + ) assert not [r for r in caplog.records if r.getMessage() == _OVERRIDE_EVENT] @@ -2714,7 +2730,9 @@ def test_an_explicitly_non_market_configuration_never_warns(self, caplog) -> Non with caplog.at_level(logging.WARNING): _warn_if_market_routing_overrides_entry( - self._intent(entry="50300"), _quoted_preview("50000"), resting + self._intent(entry="50300"), + _quoted_quote(self._intent(entry="50300"), "50000"), + resting, ) assert not [r for r in caplog.records if r.getMessage() == _OVERRIDE_EVENT] diff --git a/tests/test_agent.py b/tests/test_agent.py index afbab80d..3e1ac43f 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -2518,6 +2518,25 @@ def _count(preview): # -- the CLI wires the interactive prompt -------------------------------------- +def _gate_preview(**overrides): + """The port's `Preview` -- the one shape the confirm gate reads since #524.""" + from decimal import Decimal + + from keel_broker_api.results import Preview + from keel_core.types import Side + + fields: dict = { + "product_id": "BTC-USD", + "side": Side.BUY, + "est_base_size": Decimal("0.0001"), + "est_quote_size": Decimal("5.00"), + "est_fee": Decimal("0.03"), + "synthetic": False, + } + fields.update(overrides) + return Preview(**fields) + + def test_interactive_confirm_places_on_yes_declines_on_no(monkeypatch, capsys): """`_interactive_confirm` renders the preview and returns the human's yes/no.""" import keel.cli as cli_module @@ -2526,13 +2545,13 @@ def test_interactive_confirm_places_on_yes_declines_on_no(monkeypatch, capsys): monkeypatch.setattr("keel.commands._common._is_interactive", lambda: True) monkeypatch.setattr(cli_module.click, "confirm", lambda *a, **k: True) - assert cli_module._interactive_confirm({"order_total": "5.00", "commission_total": "0.03"}) + assert cli_module._interactive_confirm(_gate_preview()) out = capsys.readouterr().out - assert "Coinbase order preview" in out - assert "order_total: 5.00" in out + assert "Rails PASSED. Order preview:" in out + assert "est_quote_size: 5.00" in out monkeypatch.setattr(cli_module.click, "confirm", lambda *a, **k: False) - assert cli_module._interactive_confirm({"order_total": "5.00"}) is False + assert cli_module._interactive_confirm(_gate_preview()) is False def test_interactive_confirm_fails_closed_without_a_tty(monkeypatch): @@ -2540,7 +2559,7 @@ def test_interactive_confirm_fails_closed_without_a_tty(monkeypatch): # The TTY predicate lives in keel.commands._common; _interactive_confirm calls it there. monkeypatch.setattr("keel.commands._common._is_interactive", lambda: False) - assert cli_module._interactive_confirm({"order_total": "5.00"}) is False + assert cli_module._interactive_confirm(_gate_preview()) is False def test_agent_command_passes_interactive_confirm_in_CONFIRM_mode(repo, monkeypatch): diff --git a/tests/test_confirm_gate.py b/tests/test_confirm_gate.py index 28e7145a..9e91f7a9 100644 --- a/tests/test_confirm_gate.py +++ b/tests/test_confirm_gate.py @@ -10,9 +10,9 @@ So these tests assert on the *rendered text a human sees*, not on a return value: the failure mode being defended against is a human misreading the screen, and only the screen can be wrong. -They also pin the two shapes the gate accepts during the port migration -- the legacy Coinbase -dict and the port's `Preview` -- because breaking the dict path breaks the only venue that -actually trades. +The gate reads ONE shape -- the port's `Preview` -- since #524 finished the broker-port +migration; a dict is refused as unreadable, fail-closed, because nothing on the live path +produces one anymore. """ from __future__ import annotations @@ -154,9 +154,7 @@ def test_unpriced_synthetic_preview_cannot_render_as_a_real_quote( assert ( cli_module._interactive_confirm( - _preview( - synthetic=True, est_base_size=Decimal("0"), est_quote_size=Decimal("0") - ) + _preview(synthetic=True, est_base_size=Decimal("0"), est_quote_size=Decimal("0")) ) is False ) @@ -204,90 +202,57 @@ def _abort(*args, **kwargs): assert cli_module._interactive_confirm(_preview(est_quote_size=Decimal("0"))) is False -# -- the legacy Coinbase dict, which is what actually trades today ------------------------------ +# -- a shape the port deleted (#524) ------------------------------------------------------------ -def test_legacy_coinbase_dict_still_renders_and_confirms(monkeypatch, capsys, at_a_terminal): - """The live path passes `cb_client.preview_order`'s dict. Do not break it.""" - _answers(monkeypatch, confirm=True) - - assert cli_module._interactive_confirm( - { - "order_total": Decimal("5.00"), - "commission_total": Decimal("0.03"), - "errs": [], - "warning": [], - } - ) - - out = capsys.readouterr().out - assert "Coinbase order preview" in out - assert "order_total: 5.00" in out - assert cli_module.NATIVE_PREVIEW_MARKER in out - assert cli_module.SYNTHETIC_PREVIEW_MARKER not in out - - -def test_legacy_dict_errs_are_promoted_out_of_the_key_value_list( - monkeypatch, capsys, at_a_terminal -): - """A Coinbase preview that came back with `errs` used to be one quiet line among ten.""" +def test_a_dict_preview_is_refused_as_an_unreadable_shape(monkeypatch, capsys, at_a_terminal): + """#524 deleted the gate's legacy dict arm: every broker the live path can now construct -- + the default venue's registry-resolved adapter included -- answers `preview_order` in the + port's `Preview` type, so a dict is a shape nothing produces anymore. The gate must fail + closed on it rather than render it as a quote: unrecognized means degraded, and degraded + means the typed phrase, never a bare y/n.""" _answers(monkeypatch, prompt="") monkeypatch.setattr( cli_module.click, "confirm", - lambda *a, **k: pytest.fail("an error-carrying preview must not take a bare y/n"), + lambda *a, **k: pytest.fail("an unreadable preview must not take a bare y/n"), ) assert ( cli_module._interactive_confirm( - {"order_total": Decimal("5.00"), "errs": ["INSUFFICIENT_FUND"], "warning": []} + { + "order_total": Decimal("5.00"), + "commission_total": Decimal("0.03"), + "errs": [], + "warning": [], + "best_bid": Decimal("49990"), + "best_ask": Decimal("50000"), + } ) is False ) out = capsys.readouterr().out - assert "PREVIEW ERRORS" in out - assert "INSUFFICIENT_FUND" in out - - -@pytest.mark.parametrize("order_total", [Decimal("5.00"), Decimal("-5.00")]) -def test_a_signed_dict_order_total_does_not_manufacture_friction( - monkeypatch, at_a_terminal, order_total -): - """Whether Coinbase reports a SELL's `order_total` as signed proceeds is UNVERIFIED. If it - does and the gate read `<= 0` as unpriced, every live sell would demand the typed phrase -- - which trains the operator to type it by reflex and destroys the signal on the previews that - actually need it. Only a genuine zero means "no size". Revisit if a live probe settles it.""" - _answers(monkeypatch, confirm=True) - monkeypatch.setattr( - cli_module.click, - "prompt", - lambda *a, **k: pytest.fail("a priced order must not demand a typed phrase"), - ) - assert cli_module._interactive_confirm({"order_total": order_total, "errs": []}) is True - - -def test_a_zero_dict_order_total_is_still_unpriced(monkeypatch, at_a_terminal): - """The sign-agnostic rule above must not soften the case it exists for.""" - _answers(monkeypatch, prompt="") - monkeypatch.setattr( - cli_module.click, - "confirm", - lambda *a, **k: pytest.fail("a zero-sized preview must not take a bare y/n"), - ) - assert cli_module._interactive_confirm({"order_total": Decimal("0"), "errs": []}) is False + assert cli_module.UNREADABLE_PREVIEW_MARKER in out + # The dict renders as NOTHING readable: not the numbers... + assert "order_total: 5.00" not in out + # ...not the broker-quote banner... + assert cli_module.NATIVE_PREVIEW_MARKER not in out + # ...and not the venue-specific header the legacy arm used to draw. + assert "Coinbase order preview" not in out -def test_a_dict_that_declares_itself_synthetic_is_believed(monkeypatch, capsys, at_a_terminal): - """Defense in depth: dicts are assumed to be Coinbase's native shape, but a dict that says - otherwise is taken at its word rather than dressed up as a broker quote.""" +def test_the_header_is_venue_neutral_for_every_readable_preview(monkeypatch, capsys, at_a_terminal): + """The "Coinbase order preview" header existed because the dict shape WAS Coinbase's own + response, and only Coinbase's. A `Preview` can come from any venue, so the header names + none of them.""" _answers(monkeypatch, confirm=True) - cli_module._interactive_confirm({"order_total": Decimal("5.00"), "synthetic": True}) + assert cli_module._interactive_confirm(_preview()) is True out = capsys.readouterr().out - assert cli_module.SYNTHETIC_PREVIEW_MARKER in out - assert cli_module.NATIVE_PREVIEW_MARKER not in out + assert "Rails PASSED. Order preview:" in out + assert "Coinbase order preview" not in out def test_an_unreadable_preview_is_treated_as_degraded(monkeypatch, capsys, at_a_terminal): From 9f5bf365ac1aa8cae999c1bf8a4f493fb26bf448 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Thu, 27 Aug 2026 20:46:04 -0400 Subject: [PATCH 2/5] feat(brokers): the default venue resolves through the registry (#524) _build_broker no longer constructs CoinbaseClient for the coinbase branch: every name, the default included, resolves through the keel.brokers entry points, and the CLI's per-venue knowledge is the TRANSPORT it hands the resolved adapter (CDP secrets into a RESTClient for coinbase; endpoint/ feed/keys for alpaca). An adapter that resolves but has no wiring is still refused by name; an unknown name still fails through the registry's LookupError listing what is installed. The flip surfaced and closed the last pre-port shape on the order path: get_order's consumers probed a dict, and a registry-resolved adapter would have crashed _upgrade_to_observed_economics AFTER placement. The executor and reconcile now read the port's OrderStatus, and the legacy client answers in it too. Consumers moved with it: - keel balance reads get_balances(); gather_holdings takes list[Balance] - list_products moved onto CoinbaseAdapter as a documented Coinbase-extra (the port's catalogue surface stays the per-product get_instrument); the legacy client's copy is deleted, not left beside it - market_feed annotates the port's Broker, not the legacy client Tests: the default-venue pin now asserts the CoinbaseAdapter and its RESTClient wiring; the registry-resolved coinbase adapter serves execute() end-to-end (balances -> instrument -> preview -> place -> bracket) against venue fixtures; the fake venue serves the executor's port reads and refuses preview as the port's honest exception. --- keel/cli.py | 8 +- keel/commands/_common.py | 52 +- keel/commands/assets.py | 63 +- keel/data/cb_client.py | 96 +--- keel/data/market_feed.py | 12 +- keel/execution/executor.py | 48 +- keel/execution/reconcile.py | 32 +- .../keel_broker_coinbase/adapter.py | 40 +- .../keel_broker_coinbase/transport.py | 2 + pyproject.toml | 8 +- tests/broker_coinbase/test_adapter.py | 55 ++ tests/commands/test_service_parity.py | 69 ++- tests/compliance/test_assets_cli.py | 538 +++++++++++++----- tests/data/test_cb_client.py | 51 +- tests/execution/test_executor.py | 158 ++++- tests/execution/test_reconcile.py | 34 +- tests/test_agent.py | 17 +- tests/test_paper_equities_profile.py | 55 +- 18 files changed, 907 insertions(+), 431 deletions(-) diff --git a/keel/cli.py b/keel/cli.py index d6a0e14f..c28d2fc2 100644 --- a/keel/cli.py +++ b/keel/cli.py @@ -52,8 +52,8 @@ commands such as `trials *`, `withdrawals show` and `assets list` deliberately omit it.) **No live network in tests.** `_build_broker` is the one seam that would construct a real, -network-talking broker (a `CoinbaseClient` for the default/absent `broker:` section, or the -configured venue's adapter otherwise — venue selection, #370 B2); tests monkeypatch it to +network-talking broker (the configured venue's registry-resolved adapter — coinbase for the +default/absent `broker:` section, #524; venue selection, #370 B2); tests monkeypatch it to inject a fake broker instead, exactly like `tests/test_agent.py`'s `FakeBroker` (the venue-selection branches themselves are driven against fakes and network-free construction in `tests/test_paper_equities_profile.py`). @@ -554,7 +554,7 @@ def assets_holdings(ctx: click.Context, min_balance: str, run_screen: bool) -> N raise click.BadParameter(f"--min-balance must be finite and >= 0; got {min_balance!r}") try: - accounts = _build_broker(config).get_accounts() + balances = _build_broker(config).get_balances() except Exception as exc: # noqa: BLE001 -- an unreachable venue is an error, not "nothing held" # Includes broker CONSTRUCTION, so a missing/invalid `.env` credential surfaces here # rather than as a raw traceback. Reporting an empty list instead would read as @@ -565,7 +565,7 @@ def assets_holdings(ctx: click.Context, min_balance: str, run_screen: bool) -> N f" If this is an authentication error, check {broker_auth_hint(config)}." ) from exc - report = gather_holdings(repo, config, accounts, floor, run_screen=run_screen) + report = gather_holdings(repo, config, balances, floor, run_screen=run_screen) for line in render_holdings(report): click.echo(line) diff --git a/keel/commands/_common.py b/keel/commands/_common.py index c9b638a0..68d439cd 100644 --- a/keel/commands/_common.py +++ b/keel/commands/_common.py @@ -156,51 +156,51 @@ def _load_cfg(ctx: click.Context) -> Config: return config -def _build_broker( - config: Config, *, timeout: int | None = None -) -> Any: +def _build_broker(config: Config, *, timeout: int | None = None) -> Any: """Construct the real, network-talking broker for the venue `config.broker` selects. - **Venue selection (issue #370 B2).** The `broker:` config section is the one surface: - absent (or `name: coinbase`), this builds exactly what it always built -- a - `CoinbaseClient` over a `coinbase.rest.RESTClient` fed by `load_secrets()` -- so every - pre-existing config, deployment and test is byte-identical. A named venue resolves - through the `keel.brokers` entry points (`keel_broker_api.registry.load_broker`), so - installing an adapter is a package install, not a core change; today the CLI knows how - to construct CREDENTIALS for one non-Coinbase venue (alpaca: paper/live endpoint, iex/ - sip feed, `ALPACA_API_KEY_ID`/`ALPACA_API_SECRET_KEY`), and an adapter that resolves but - has no wiring is refused by name rather than constructed credential-less. + **Every name resolves through the registry (issue #524).** The `broker:` config section + selects a venue; the `keel.brokers` entry points (`keel_broker_api.registry.load_broker`) + decide which adapter class that name means -- coinbase included, so the default venue has + no second, direct construction path to drift against the conformance-tested adapter. The + CLI's per-venue knowledge is the TRANSPORT it hands the resolved adapter: coinbase wiring + is `load_secrets()` from `.env` into a `coinbase.rest.RESTClient`; alpaca wiring is the + paper/live endpoint, the iex/sip feed and `ALPACA_API_KEY_ID`/`ALPACA_API_SECRET_KEY`. An + adapter that resolves but has no wiring is refused by name rather than constructed + credential-less, and a name with no entry point at all fails through the registry's own + LookupError, which lists what IS installed. Tests monkeypatch this function; the branches are additionally driven against fakes and - the real (network-free at construction) Alpaca classes by + the real (network-free at construction) Alpaca and Coinbase classes by `tests/test_paper_equities_profile.py`. `timeout` (seconds) is optional and defaults to `None` -- the SDK's own default (no timeout), matching every existing caller (the agent/executor broker path) exactly. - Callers that cannot tolerate a hung network call (e.g. `keel tui`'s live balance - refresh, which must never freeze the dashboard) pass an explicit bound. + Callers that cannot tolerate a hung network call pass an explicit bound. """ venue = config.broker.name - if venue == "coinbase": + from keel_broker_api.registry import load_broker + + adapter_cls = load_broker(venue) + + module_root = adapter_cls.__module__.split(".")[0] + if module_root == "keel_broker_coinbase": from coinbase.rest import RESTClient from keel.config import load_secrets - from keel.data.cb_client import CoinbaseClient secrets = load_secrets() transport = RESTClient( - api_key=secrets.get("api_key"), api_secret=secrets.get("api_secret"), timeout=timeout + api_key=secrets.get("api_key"), + api_secret=secrets.get("api_secret"), + timeout=timeout, ) - return CoinbaseClient(transport) - - # Every other name resolves through the entry points -- the registry is the authority on - # which adapters exist, and its LookupError already names what is installed. - from keel_broker_api.registry import load_broker - - adapter_cls = load_broker(venue) + # The registry-resolved adapter, not a hand-imported client -- the same + # conformance-tested class every other venue resolves through. + return adapter_cls(transport) - if adapter_cls.__module__.split(".")[0] != "keel_broker_alpaca": + if module_root != "keel_broker_alpaca": raise RuntimeError( f"broker.name {venue!r} resolved to an installed adapter, but the CLI does not " "yet know how to give it credentials -- venue wiring exists for 'coinbase' and " diff --git a/keel/commands/assets.py b/keel/commands/assets.py index aaafea22..24b303e0 100644 --- a/keel/commands/assets.py +++ b/keel/commands/assets.py @@ -33,6 +33,7 @@ from decimal import Decimal from typing import Any +from keel_broker_api.results import Balance from keel_core.products import quote_currency_of from keel.commands._products import _history_product @@ -169,9 +170,7 @@ def market_facts(repo: Repository, product: str, quote: str) -> MarketFacts: ) -def screen_product( - repo: Repository, product: str, quote: str -) -> tuple[MarketFacts, ScreenResult]: +def screen_product(repo: Repository, product: str, quote: str) -> tuple[MarketFacts, ScreenResult]: """THE admission decision, for every candidate source. `assets screen`, `assets holdings --screen`, the proposer and any future front-end (the TUI) @@ -214,9 +213,7 @@ def screen_product( else None ) waived = repo.get_screen_exceptions(asset) - return facts, screen_mod.screen_asset( - facts, attestation, waived=waived, instrument=instrument - ) + return facts, screen_mod.screen_asset(facts, attestation, waived=waived, instrument=instrument) @dataclass(frozen=True) @@ -233,9 +230,7 @@ def admitted(self) -> bool: return self.result.admitted -def screen_products( - repo: Repository, config: Config, products: list[str] -) -> list[ScreenedAsset]: +def screen_products(repo: Repository, config: Config, products: list[str]) -> list[ScreenedAsset]: """Screen an explicit product list through THE gate -- `keel assets screen`'s compute. The caller owns the `--products` semantics (the CLI deliberately passes them UNVALIDATED -- @@ -340,9 +335,7 @@ def gather_attestations_in_force(repo: Repository, config: Config) -> Attestatio unattested.append(asset) continue asset_rows.append(row) - instrument = repo.get_instrument_attestation( - VENUE, _history_product(asset, quote) - ) + instrument = repo.get_instrument_attestation(VENUE, _history_product(asset, quote)) if instrument is not None: instrument_rows.append(instrument) allow_set = {asset.upper() for asset in allowlist} @@ -403,12 +396,12 @@ def broker_auth_hint(config: Config) -> str: def gather_holdings( repo: Repository, config: Config, - accounts: list[dict[str, Any]], + balances: list[Balance], floor: Decimal, *, run_screen: bool = False, ) -> HoldingsReport: - """Turn broker account rows into allowlist CANDIDATES -- a SOURCE, not a gate. + """Turn the port's balance rows into allowlist CANDIDATES -- a SOURCE, not a gate. Holding an asset is not a reason to trade it: this admits nothing and mutates nothing. It answers "what do I already own that this system might trade?" by filtering out the @@ -417,33 +410,32 @@ def gather_holdings( and everything at/below the dust floor, sorting by asset, and optionally screening each survivor through THE gate (unattested assets are REJECTED, because sector and backing cannot be derived from a balance any more than from a price). + + `list[Balance]` -- the port's shape since #524, so the read works against every venue an + adapter exists for, not only the one whose client happened to return dicts. `available` + is the SPENDABLE figure, which is the one a dust floor is asking about. """ quote = config.quote_currency excluded = FIAT_CURRENCIES | CASH_EQUIVALENTS | {quote.upper()} - accounts = sorted( - ( - a - for a in accounts - if (a.get("currency") or "").upper() not in excluded - and a["available_balance"] > floor - ), - key=lambda a: (a.get("currency") or "").upper(), + balances = sorted( + (b for b in balances if b.currency.upper() not in excluded and b.available > floor), + key=lambda b: b.currency.upper(), ) allowlist = {asset.upper() for asset in config.allowlist} rows: list[HoldingRow] = [] - for account in accounts: + for balance in balances: # Uppercase here too, not just for the exclusion set: screening the raw code would look # up `btc` (UNATTESTED) while the allowlist check matched `BTC`, and would hand the # operator `keel fetch --products btc-USD`, a product id that never resolves. - asset = (account.get("currency") or "").upper() + asset = balance.currency.upper() attested = repo.get_asset_attestation(asset) is not None if not run_screen: rows.append( HoldingRow( asset=asset, - balance=account["available_balance"], + balance=balance.available, on_allowlist=asset in allowlist, attested=attested, facts=None, @@ -487,7 +479,7 @@ def gather_holdings( rows.append( HoldingRow( asset=asset, - balance=account["available_balance"], + balance=balance.available, on_allowlist=asset in allowlist, attested=attested, facts=facts, @@ -503,19 +495,14 @@ def gather_holdings( def render_holdings(report: HoldingsReport) -> list[str]: """The exact `keel assets holdings` lines, as a pure function of the report.""" if not report.rows: - return [ - f"no holdings above {report.floor} (excluding {report.quote} and fiat)." - ] + return [f"no holdings above {report.floor} (excluding {report.quote} and fiat)."] lines = [ - f"{len(report.rows)} holding(s) above {report.floor}, excluding " - f"{report.quote} and fiat:\n" + f"{len(report.rows)} holding(s) above {report.floor}, excluding {report.quote} and fiat:\n" ] for row in report.rows: on_allowlist = "on-allowlist" if row.on_allowlist else "not-on-allowlist" attested = "attested" if row.attested else "UNATTESTED" - lines.append( - f" {row.asset:<8} balance={row.balance:<18} {on_allowlist:<16} {attested}" - ) + lines.append(f" {row.asset:<8} balance={row.balance:<18} {on_allowlist:<16} {attested}") if row.result is None or row.facts is None: continue lines.append(f" {row.result.summary} ({row.facts.daily_bars} daily bars cached)") @@ -597,17 +584,13 @@ def run_discovery( """ if now_ts is None: now_ts = int(time.time()) - volume_floor = ( - min_volume_24h if min_volume_24h is not None else DEFAULT_MIN_QUOTE_24H_VOLUME - ) + volume_floor = min_volume_24h if min_volume_24h is not None else DEFAULT_MIN_QUOTE_24H_VOLUME shown = limit if limit is not None else DEFAULT_DISCOVER_LIMIT policy = DiscoveryPolicy( quote_currency=quote or config.quote_currency, min_quote_24h_volume=volume_floor, ) - result = discover_candidates( - products, policy, exclude_assets=frozenset(config.allowlist) - ) + result = discover_candidates(products, policy, exclude_assets=frozenset(config.allowlist)) screen_policy = screen_mod.ScreenPolicy() four_years_ago = now_ts - 4 * DAYS_PER_YEAR * 86400 diff --git a/keel/data/cb_client.py b/keel/data/cb_client.py index f9d8190d..850e1af1 100644 --- a/keel/data/cb_client.py +++ b/keel/data/cb_client.py @@ -1,22 +1,15 @@ """Thin, injectable wrapper around the Coinbase Advanced Trade REST API. -`cb_client` is the **only** module in `keel` that talks to the network. It never -instantiates its own transport -- a `transport` (duck-typed like `coinbase.rest.RESTClient`, -or any fake with matching method signatures) is injected by the caller, so tests exercise it -against canned JSON fixtures with zero live network calls. - -In production, inject the real client: - - from coinbase.rest import RESTClient - from keel.config import load_secrets - - secrets = load_secrets() - transport = RESTClient(api_key=secrets["api_key"], api_secret=secrets["api_secret"]) - client = CoinbaseClient(transport) - -`place_order` talks to the live order-creation endpoint. It performs **no halal/risk checks -itself** -- callers (the Phase-3 executor + guards) must run rails and any confirm-mode gate -before calling it; `cb_client` stays a thin, dumb transport wrapper. +LEGACY SINCE #524: nothing in production constructs this client any more -- `_build_broker` +resolves coinbase through the `keel.brokers` entry points like every other venue, and the +class the registry hands back (`keel_broker_coinbase.CoinbaseAdapter`) is the one on the +live path. This module is retained because its methods answer in the port\'s shapes +(`Preview`, `PlaceResult`, `list[Balance]`, `Instrument`, `OrderStatus`, `CancelOutcome`) +and its tests pin Coinbase response-parsing behaviour against the same fixtures the adapter +suite uses; Phase B deletes it outright. It never instantiates its own transport -- a +`transport` (duck-typed like `coinbase.rest.RESTClient`, or any fake with matching method +signatures) is injected by the caller, so tests exercise it against canned JSON fixtures +with zero live network calls. """ from __future__ import annotations @@ -31,6 +24,7 @@ Balance, CancelOutcome, Instrument, + OrderStatus, PlaceResult, Preview, ) @@ -181,38 +175,6 @@ def get_spot(self, product_id: str) -> Decimal: raise ValueError(f"get_spot({product_id!r}): response has no 'price' field") return Decimal(price) - def list_products(self, product_type: str = "SPOT") -> list[dict]: - """Every tradable product on the venue, as plain dicts. READ-ONLY market metadata. - - Used only by the allowlist DISCOVERY stage (`keel assets discover`), which proposes - candidates for human attestation -- it decides nothing. Per §5's asymmetry, a proposal - may come from anywhere; admission goes through `compliance/screen.py`. - """ - raw = self._transport.get_products(product_type=product_type) - products = raw["products"] if isinstance(raw, dict) else raw.products - out: list[dict] = [] - for product in products: - fields = product if isinstance(product, dict) else vars(product) - out.append( - { - "product_id": fields.get("product_id"), - "base_name": fields.get("base_name"), - "quote_currency_id": fields.get("quote_currency_id"), - "status": fields.get("status"), - "trading_disabled": bool(fields.get("trading_disabled")), - "is_disabled": bool(fields.get("is_disabled")), - "view_only": bool(fields.get("view_only")), - "quote_24h_volume": fields.get("approximate_quote_24h_volume"), - # #516. The venue has always sent these; this projection dropped them, which - # is why `_order_configuration` had nothing to quantize a SELL against and - # emitted `str(Decimal)` -- the defect #513 fixed for the BUY half only. - # Kept as the venue's own strings; the caller decides what is a Decimal. - "base_increment": fields.get("base_increment"), - "quote_increment": fields.get("quote_increment"), - } - ) - return out - def get_accounts(self) -> list[dict]: """Return authenticated account balances, keyed by currency. @@ -247,8 +209,8 @@ def get_instrument(self, product_id: str) -> Instrument | None: The same bridge `get_balances` is: this client predates `keel-broker-api`, and `executor._base_increment_for` had to read `list_products()` and pick through raw dicts because that was the only catalogue read this client offered. Answering `Instrument` here - means the executor asks one question whether it holds this client or a real adapter, and - the flip needs no further change on this path. + meant the executor asked one question whether it held this client or a real adapter, so + #524's flip of `_build_broker` to the registry needed no change on this path. `get_product`, not `get_products`. The caller needs ONE product; `list_products` returns about 900 and stays where it belongs -- `keel assets discover`, which genuinely wants the @@ -264,7 +226,7 @@ def get_instrument(self, product_id: str) -> Instrument | None: return None try: value = Decimal(str(increment)) - except (ArithmeticError, TypeError, ValueError): + except ArithmeticError, TypeError, ValueError: return None if value <= 0: return None @@ -279,10 +241,9 @@ def get_balances(self) -> list[Balance]: had to probe for BOTH shapes, dict key or attribute, because it did not know which kind of broker it held. - Teaching this client the port's shape removes that fork without flipping anything: the - executor now asks one question, and the answer is the same type whether it is talking to - this pre-port client or to a real adapter. When `_build_broker` finally resolves through - `load_broker`, this path needs no further change. + Teaching this client the port's shape removed the executor's fork: one question, one + answer type, on this client and on a real adapter alike -- which is why #524's flip + of `_build_broker` to the registry needed no further change on this path. `total` is `available + hold`, matching `keel_broker_coinbase.adapter.get_balances` exactly -- Coinbase exposes no single "total" field, and the two implementations must not @@ -373,8 +334,8 @@ def place_order(self, spec: OrderSpec, *, idempotency_key: str | None = None) -> ), ) - def get_order(self, order_id: str) -> dict: - """Observed state of a previously placed order, normalized to `Decimal` money fields. + def get_order(self, order_id: str) -> OrderStatus: + """Observed state of a previously placed order, in the PORT's shape (#524). 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 @@ -382,6 +343,9 @@ def get_order(self, order_id: str) -> dict: price and the *previewed* commission, so realized P&L is modelled rather than observed -- and a stop-out closes a position the loop never notices. + The port's `OrderStatus` is the answer since the flip's consumers were migrated; the + dict this used to return was the last pre-port shape on the order path. + `filled_size`/`average_filled_price`/`total_fees` are absent (not zero) on an order with no fills yet, so they are defaulted to `Decimal("0")`: callers do arithmetic on these and should never have to special-case `None`. `status` is passed through verbatim -- see @@ -389,15 +353,13 @@ def get_order(self, order_id: str) -> dict: """ response = self._transport.get_order(order_id=order_id) order = _field(response, "order") or {} - return { - "order_id": _field(order, "order_id", order_id), - "product_id": _field(order, "product_id"), - "side": _field(order, "side"), - "status": _field(order, "status"), - "filled_size": Decimal(_field(order, "filled_size", "0") or "0"), - "average_filled_price": Decimal(_field(order, "average_filled_price", "0") or "0"), - "total_fees": Decimal(_field(order, "total_fees", "0") or "0"), - } + return OrderStatus( + order_id=str(_field(order, "order_id", order_id)), + status=str(_field(order, "status", "")), + filled_size=Decimal(_field(order, "filled_size", "0") or "0"), + average_filled_price=Decimal(_field(order, "average_filled_price", "0") or "0"), + total_fees=Decimal(_field(order, "total_fees", "0") or "0"), + ) def cancel_order(self, order_id: str) -> CancelOutcome: """Cancel one resting order and report what the exchange said about THIS id. diff --git a/keel/data/market_feed.py b/keel/data/market_feed.py index 096e66af..f4f3ddcf 100644 --- a/keel/data/market_feed.py +++ b/keel/data/market_feed.py @@ -1,6 +1,7 @@ """Keep the `candles` table populated: historical backfill + periodic polling. -`market_feed` wires an injected `CoinbaseClient` (#7) to `Repository` (#2) -- it never talks +`market_feed` wires an injected broker (#7 -- the port's `Broker`, resolved by `_build_broker`) +to `Repository` (#2) -- it never talks to the network itself and never opens its own DB connection, so tests exercise it against a fake client and an in-memory `Repository` with zero network calls. @@ -25,7 +26,8 @@ from keel.types import Candle, Granularity if TYPE_CHECKING: - from keel.data.cb_client import CoinbaseClient + from keel_broker_api.port import Broker + from keel.data.repository import Repository _GRANULARITY_SECONDS: dict[Granularity, int] = { @@ -98,7 +100,7 @@ def _missing_ranges(expected: list[int], present: set[int], gran_sec: int) -> li def backfill( - client: CoinbaseClient, + client: Broker, repo: Repository, products: list[str], granularities: list[Granularity], @@ -148,7 +150,7 @@ def backfill( def _poll_catch_up( - client: CoinbaseClient, + client: Broker, repo: Repository, product_id: str, granularity: Granularity, @@ -182,7 +184,7 @@ def _poll_catch_up( def poll_once( - client: CoinbaseClient, + client: Broker, repo: Repository, products: list[str], granularities: list[Granularity], diff --git a/keel/execution/executor.py b/keel/execution/executor.py index b0686a79..d2976e60 100644 --- a/keel/execution/executor.py +++ b/keel/execution/executor.py @@ -50,10 +50,11 @@ #442: (1) ratchet-only is rail-9-safe BY CONSTRUCTION (`_roll_stop` refuses a widening proposal before `guards.check` ever runs; pinned by `tests/execution/ test_executor.py::test_a_ratchet_only_trail_can_never_trip_rail_9`); (2) live wiring needs -a cancel-and-replace of the native bracket, and the broker port has NO bracket/OCO -`OrderSpec` kind -- the bracket reaches the venue only because this module bypasses the -port with a raw configuration dict -- so live stop management is split out to issue #502 -rather than solved here; (3) the exit POLICY those primitives encode (the same +a cancel-and-replace of the native bracket, and that policy -- amending a live protective +order versus cancelling and re-placing it -- is unsettled, so live stop management is split +out to issue #502 rather than solved here (the bracket itself has been an `OrderSpec` +(`BracketGTC`) since #569, reaching the venue through `place_order` like every other order, +so the port is no longer the blocker); (3) the exit POLICY those primitives encode (the same ratchet-only ATR trail and break-even roll) IS wired where exits are driven per bar: the sim/backtest engines, via `strategy/exit_policy.py` and the per-family `trail_atr_mult` / `be_roll_rr` params on `pullback_continuation` and `rsi_meanrev`. `turtle_breakout` @@ -108,7 +109,13 @@ MarketIOCByQuote, OrderSpec, ) -from keel_broker_api.results import CancelOutcome, PlaceResult, Preview, coerce_cancel_outcome +from keel_broker_api.results import ( + CancelOutcome, + OrderStatus, + PlaceResult, + Preview, + coerce_cancel_outcome, +) from keel_core.products import quote_currency_of from keel_core.telemetry import log_event, log_exception, log_venue_failure @@ -441,7 +448,7 @@ def _fetch_available_quote(broker: Any, quote_currency: str | None) -> Decimal | except Exception: # `log_venue_failure`, not `log_exception`: an unreachable venue outside a trade cycle # is a dashboard balance refresh on a sleeping laptop, and this line is the SECOND - # record for that one failure (`cb_client.get_accounts` logs it first) -- two full + # record for that one failure (`cb_client.get_balances` logs it first) -- two full # tracebacks per poll, every 30s, for as long as the machine is offline. Inside a cycle # it escalates back to ERROR on its own: there it means rail 13 failed closed and an # order did not go out. Kept as its own event rather than dropped because it carries @@ -800,8 +807,8 @@ def _upgrade_to_observed_economics( log_exception(logger, "executor.observed_economics_unavailable", order_id=order_id) return - fill = observed.get("average_filled_price") - fees = observed.get("total_fees") + fill = observed.average_filled_price + fees = observed.total_fees if not fill or fill <= 0: return repo.update_order(order_id, actual_fill=fill, fee=fees, updated_at=now_ts) @@ -812,7 +819,7 @@ def _upgrade_to_observed_economics( def _record_observed_fill_quantity( repo: Repository, order_id: int, - observed: dict[str, Any], + observed: OrderStatus, intent: OrderIntent | None, now_ts: int, ) -> None: @@ -830,12 +837,12 @@ def _record_observed_fill_quantity( BOTH sides: `filled_quantity` is what actually executed, whatever the order's direction. DELIBERATELY detect-and-surface only. Resizing the bracket means either amending a live - native trigger-bracket or cancel-and-replace, and the broker port carries no bracket/OCO - kind at all (#502) -- the live bracket already bypasses the port as a raw dict. Auto-cancelling - a protective order on the strength of a snapshot that may still be settling is a wrong - auto-action on live money; a loud warning is the safe half, and it is what this does. + native trigger-bracket or cancel-and-replace, and the resize policy is #502's to settle. + Auto-cancelling a protective order on the strength of a snapshot that may still be + settling is a wrong auto-action on live money; a loud warning is the safe half, and it is + what this does. """ - filled = observed.get("filled_size") + filled = observed.filled_size if not filled or filled <= 0: return row = repo.get_order(order_id) or {} @@ -1144,9 +1151,9 @@ def _entry_spread_gate( than swallowed here because this is a money gate) is refused with the DISTINCT `book_unreadable` token: "cannot know" is a different fact from "too wide", and a gate that guessed a spread from half a book would be a gate that sometimes trades on fiction. - The real venue's preview carries both sides for market orders (`cb_client.preview_order` - maps `best_bid`/`best_ask` to `Decimal`), so an unreadable book on the live path means a - degraded response -- exactly the moment not to spend. + The real venue's preview carries both sides for market orders (the Coinbase adapter maps + `best_bid`/`best_ask` into `Preview.detail`), so an unreadable book on the live path means + a degraded response -- exactly the moment not to spend. Every BUY routes market today (#258), so "every live BUY" and "every market-routed live BUY" are the same set; if #260's remediation ever lands resting BUY orders, revisit the @@ -1377,9 +1384,8 @@ class CancelUnavailable(RuntimeError): SELL on the exchange that our own records said was gone. `CoinbaseClient.cancel_order` exists now, but the guard still matters and now covers a - second case: it returns `False` when the exchange REFUSES a cancel (already filled, unknown - id), and a refusal recorded as a success is the same lie by a different route. The - `keel-broker-api` port and the Coinbase adapter still have no cancel method. + second case: the port's `cancel_order` answers `CancelOutcome`, and a REFUSED (already + filled, unknown id) recorded as a success is the same lie by a different route. Failing loudly is the safe direction: our state must never claim a cancel that did not happen. A caller that cannot tolerate the raise must reconcile with the exchange, not @@ -1488,7 +1494,7 @@ def _bracket_spec( **This used to render Coinbase's wire dict itself.** #502 stage 1 added `BracketGTC` to the port and shipped a test pinning the two byte-identical, because two renderers of one order existed and had to be kept in agreement. #524 removed the second one: the spec goes to - `CoinbaseClient.place_order`, which renders it through the adapter's own + the broker's `place_order`, which renders it through the adapter package's own `to_order_configuration`, and the parity test goes with the duplicate it was pinning. What the port adds beyond a shared renderer is REFUSAL. `BracketGTC.__post_init__` rejects a diff --git a/keel/execution/reconcile.py b/keel/execution/reconcile.py index 284ca9c3..12f8a016 100644 --- a/keel/execution/reconcile.py +++ b/keel/execution/reconcile.py @@ -30,10 +30,9 @@ auto-remediation, not the validity of recognizing the state. What is deliberately NOT done here: resizing or amending the bracket when a partially-filled -entry leaves it oversized for what is held. The broker port has no bracket/OCO kind (#502); the -live bracket bypasses it with a raw dict, and auto-cancelling live protective orders on the -strength of a possibly-still-settling partial snapshot is strictly worse than a loud warning. -This module records and surfaces; the amend-vs-cancel-and-replace policy is #502's. +entry leaves it oversized for what is held. The amend-vs-cancel-and-replace policy is #502's to +settle, and auto-cancelling live protective orders on the strength of a possibly-still-settling +partial snapshot is strictly worse than a loud warning. This module records and surfaces. """ from __future__ import annotations @@ -43,6 +42,7 @@ from decimal import Decimal from typing import Any +from keel_broker_api.results import OrderStatus from keel_core.telemetry import log_event, log_exception from keel.config import Config @@ -108,7 +108,7 @@ def reconcile_open_orders(broker: Any, repo: Repository, config: Config, now_ts: ) continue - status = (observed.get("status") or "").upper() + status = (observed.status or "").upper() if status in _DEAD: # A CANCELLED/EXPIRED order can still have SOLD something: Coinbase reports @@ -117,7 +117,7 @@ def reconcile_open_orders(broker: Any, repo: Repository, config: Config, now_ts: # floor -- `_held_position` sums only `filled` rows, so it would keep reporting the # FULL position held, and the realized P&L on the sold portion would never reach # rails 11 or 16. Record what actually sold, then stop tracking the order. - if (observed.get("filled_size") or Decimal("0")) > 0: + if (observed.filled_size or Decimal("0")) > 0: _try_record_fill(broker, repo, config, row, observed, now_ts) else: repo.update_order(row["id"], status="canceled", updated_at=now_ts) @@ -158,7 +158,7 @@ def _polled_rows(repo: Repository) -> list[dict[str, Any]]: def _record_partial_fill( - repo: Repository, row: dict[str, Any], observed: dict[str, Any], now_ts: int + repo: Repository, row: dict[str, Any], observed: OrderStatus, now_ts: int ) -> bool: """Record `0 < filled_size < ordered qty` as the distinct non-terminal `partially_filled` state. Returns whether anything changed. @@ -175,12 +175,12 @@ def _record_partial_fill( free position in the rail-8 basis. """ ordered = row["qty"] or Decimal("0") - filled = observed.get("filled_size") or Decimal("0") + filled = observed.filled_size or Decimal("0") if not (Decimal("0") < filled < ordered): return False - average = observed.get("average_filled_price") or Decimal("0") - fees = observed.get("total_fees") or Decimal("0") + average = observed.average_filled_price or Decimal("0") + fees = observed.total_fees or Decimal("0") previously = row.get("filled_quantity") fields: dict[str, Any] = { @@ -445,7 +445,7 @@ def _try_record_fill( repo: Repository, config: Config, row: dict[str, Any], - observed: dict[str, Any], + observed: OrderStatus, now_ts: int, ) -> None: """`_record_fill` with the SAME per-order isolation the status fetch gets. @@ -471,13 +471,13 @@ def _record_fill( repo: Repository, config: Config, row: dict[str, Any], - observed: dict[str, Any], + observed: OrderStatus, now_ts: int, ) -> None: """Mark `row` filled from OBSERVED economics and, for an exit, close out the position.""" - exit_fill = observed.get("average_filled_price") or Decimal("0") - fees = observed.get("total_fees") or Decimal("0") - filled_qty = observed.get("filled_size") or row["qty"] + exit_fill = observed.average_filled_price or Decimal("0") + fees = observed.total_fees or Decimal("0") + filled_qty = observed.filled_size or row["qty"] if exit_fill <= 0: # A FILLED order that reports no price. Feeding 0 to the producer computes @@ -588,6 +588,6 @@ def _native_order_id(order_row: dict[str, Any]) -> str | None: return None try: data = json.loads(raw) - except (TypeError, ValueError): + except TypeError, ValueError: return None return data.get("order_id") diff --git a/packages/keel-broker-coinbase/keel_broker_coinbase/adapter.py b/packages/keel-broker-coinbase/keel_broker_coinbase/adapter.py index 865ae455..d4141ad7 100644 --- a/packages/keel-broker-coinbase/keel_broker_coinbase/adapter.py +++ b/packages/keel-broker-coinbase/keel_broker_coinbase/adapter.py @@ -14,6 +14,7 @@ import time from decimal import Decimal +from typing import Any from keel_broker_api.capabilities import BrokerCapabilities from keel_broker_api.orders import OrderSpec @@ -160,12 +161,49 @@ def get_instrument(self, product_id: str) -> Instrument | None: return None try: value = Decimal(str(increment)) - except (ArithmeticError, TypeError, ValueError): + except ArithmeticError, TypeError, ValueError: return None if value <= 0: return None return Instrument(product_id=product_id, base_increment=value) + def list_products(self, product_type: str = "SPOT") -> list[dict[str, Any]]: + """Every tradable product on the venue, as plain dicts. READ-ONLY market metadata. + + A Coinbase-extra, not a port method, on purpose (#524): the port's catalogue surface is + the per-product `get_instrument` above, which is what the ORDER path reads; a ~900-row + catalogue sweep is a DISCOVERY concern (`keel assets discover`), and elevating it to the + port would hand every adapter a bulk endpoint only the discovery tool wants. Used only + by the allowlist DISCOVERY stage, which proposes candidates for human attestation -- it + decides nothing. Per §5's asymmetry, a proposal may come from anywhere; admission goes + through `compliance/screen.py`. + + `base_increment`/`quote_increment` ride along as the venue's own strings because the + sweep surfaces them to the operator; the caller decides what is a Decimal. + """ + raw = self._require_transport().get_products(product_type=product_type) + products = raw["products"] if isinstance(raw, dict) else raw.products + out: list[dict[str, Any]] = [] + for product in products: + fields = product if isinstance(product, dict) else vars(product) + out.append( + { + "product_id": fields.get("product_id"), + "base_name": fields.get("base_name"), + "quote_currency_id": fields.get("quote_currency_id"), + "status": fields.get("status"), + "trading_disabled": bool(fields.get("trading_disabled")), + "is_disabled": bool(fields.get("is_disabled")), + "view_only": bool(fields.get("view_only")), + "quote_24h_volume": fields.get("approximate_quote_24h_volume"), + # #516: the venue has always sent these, and the sweep surfaces them so an + # operator can eyeball a product's granularity before fetching anything. + "base_increment": fields.get("base_increment"), + "quote_increment": fields.get("quote_increment"), + } + ) + return out + def preview_order(self, spec: OrderSpec) -> Preview: """Preview via Coinbase's own endpoint -- hence `synthetic=False`.""" self._reject_unsupported(spec) diff --git a/packages/keel-broker-coinbase/keel_broker_coinbase/transport.py b/packages/keel-broker-coinbase/keel_broker_coinbase/transport.py index 156d1677..bd83e8af 100644 --- a/packages/keel-broker-coinbase/keel_broker_coinbase/transport.py +++ b/packages/keel-broker-coinbase/keel_broker_coinbase/transport.py @@ -23,6 +23,8 @@ def get_candles( def get_product(self, product_id: str, **kwargs: Any) -> Any: ... + def get_products(self, product_type: str = "SPOT", **kwargs: Any) -> Any: ... + def get_accounts(self, **kwargs: Any) -> Any: ... def preview_order( diff --git a/pyproject.toml b/pyproject.toml index c05e0743..9ae19bba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,8 +25,12 @@ dependencies = [ # `version`, and `tests/test_packaging.py` fails the build if a pin is left behind. "keel-core==0.11.2", "keel-broker-api==0.11.2", - # keel/cli.py and keel/data/cb_client.py still import the Coinbase SDK directly; depending - # on the adapter keeps it available transitively. Phase B deletes those imports and this. + # The default venue resolves through the keel.brokers entry points (#524), so the adapter + # is a hard runtime dependency, not a convenience: without it installed, every config -- + # including one with no `broker:` section -- fails at broker construction. The CLI also + # imports the SDK directly to build the adapter's RESTClient transport + # (`keel/commands/_common.py`); moving that construction into the adapter package is + # Phase B's, with cb_client.py's deletion. "keel-broker-coinbase==0.11.2", ] diff --git a/tests/broker_coinbase/test_adapter.py b/tests/broker_coinbase/test_adapter.py index f0a26526..ab9b2200 100644 --- a/tests/broker_coinbase/test_adapter.py +++ b/tests/broker_coinbase/test_adapter.py @@ -47,6 +47,7 @@ def __init__( summary: dict[str, Any] | None = None, order: dict[str, Any] | None = None, product: dict[str, Any] | None = None, + products: dict[str, Any] | None = None, ) -> None: self._candles = candles self._accounts = accounts @@ -55,12 +56,17 @@ def __init__( self._summary = summary self._order = order self._product = product + self._products = products self.calls: dict[str, dict[str, Any]] = {} # Ids this transport has actually issued via `create_order`, so `cancel_orders` can tell # a genuine order apart from one the suite's unknown-id test made up -- the same # distinction the real venue draws, and the whole point of that assertion. self._issued_order_ids: set[str] = set() + def get_products(self, product_type: str = "SPOT", **kwargs: Any) -> Any: + self.calls["get_products"] = {"product_type": product_type} + return self._products + def get_candles( self, product_id: str, start: str, end: str, granularity: str, **kwargs: Any ) -> Any: @@ -459,3 +465,52 @@ def test_get_instrument_answers_none_for_anything_unusable(payload: dict[str, An """ adapter = CoinbaseAdapter(FakeTransport(product=payload)) assert adapter.get_instrument("BTC-USD") is None + + +def test_list_products_serves_the_discovery_sweep() -> None: + """The whole-catalogue read `keel assets discover` needs (#524's move of the last + Coinbase-only client method onto the registry-resolved adapter). + + NOT a port method, on purpose: the port's catalogue surface is the per-product + `get_instrument` the executor reads on the order path, and a ~900-row sweep is a DISCOVERY + concern, not an order-path one. The projection is pinned field-for-field because the + discovery sweep's filters read these keys -- a silently renamed key would quietly narrow + every candidate list. + """ + transport = FakeTransport( + products={ + "products": [ + { + "product_id": "BTC-USD", + "base_name": "Bitcoin", + "quote_currency_id": "USD", + "status": "online", + "trading_disabled": False, + "is_disabled": False, + "view_only": False, + "approximate_quote_24h_volume": "12345.67", + "base_increment": "0.00000001", + "quote_increment": "0.01", + } + ] + } + ) + adapter = CoinbaseAdapter(transport) + + products = adapter.list_products() + + assert products == [ + { + "product_id": "BTC-USD", + "base_name": "Bitcoin", + "quote_currency_id": "USD", + "status": "online", + "trading_disabled": False, + "is_disabled": False, + "view_only": False, + "quote_24h_volume": "12345.67", + "base_increment": "0.00000001", + "quote_increment": "0.01", + } + ] + assert transport.calls["get_products"] == {"product_type": "SPOT"} diff --git a/tests/commands/test_service_parity.py b/tests/commands/test_service_parity.py index 564ca770..f32e31ae 100644 --- a/tests/commands/test_service_parity.py +++ b/tests/commands/test_service_parity.py @@ -21,6 +21,7 @@ import pytest from click.testing import CliRunner +from keel_broker_api.results import Balance import keel.cli as cli_module from keel.cli import cli @@ -137,8 +138,11 @@ def __getattr__(self, name: str) -> Any: raise AssertionError(f"no broker method may be called under --check ({name})") -_GRAN_STEPS = [(Granularity.FIFTEEN_MINUTE, 900), (Granularity.ONE_HOUR, 3600), - (Granularity.ONE_DAY, 86_400)] +_GRAN_STEPS = [ + (Granularity.FIFTEEN_MINUTE, 900), + (Granularity.ONE_HOUR, 3600), + (Granularity.ONE_DAY, 86_400), +] def _seed_current_series(repo: Repository, products: tuple[str, ...]) -> None: @@ -175,8 +179,7 @@ def test_fetch_check_failing_parity(tmp_path, valid_config_path, monkeypatch, ch result = CliRunner().invoke( cli, - ["--db", str(db_cli), "--config", str(valid_config_path), "fetch", "--check", - *check_args], + ["--db", str(db_cli), "--config", str(valid_config_path), "fetch", "--check", *check_args], ) assert result.exit_code != 0 @@ -336,9 +339,19 @@ def test_simulate_report_parity(tmp_path, valid_config_path, monkeypatch): result = CliRunner().invoke( cli, - ["--db", str(db_cli), "--config", str(valid_config_path), - "simulate", "--no-fetch", "--years", "1", - "--out", str(out_cli), "--no-trial-record"], + [ + "--db", + str(db_cli), + "--config", + str(valid_config_path), + "simulate", + "--no-fetch", + "--years", + "1", + "--out", + str(out_cli), + "--no-trial-record", + ], ) assert result.exit_code == 0, result.output @@ -426,8 +439,16 @@ def test_assets_screen_verdicts_match_the_service(tmp_path, valid_config_path): result = CliRunner().invoke( cli, - ["--db", str(db_cli), "--config", str(valid_config_path), - "assets", "screen", "--products", "BTC-USD,ETH-USD"], + [ + "--db", + str(db_cli), + "--config", + str(valid_config_path), + "assets", + "screen", + "--products", + "BTC-USD,ETH-USD", + ], ) assert result.exit_code == 0, result.output @@ -446,11 +467,11 @@ def test_assets_screen_verdicts_match_the_service(tmp_path, valid_config_path): class _AccountsBroker(_FakePollBroker): - def get_accounts(self) -> list[dict[str, Any]]: + def get_balances(self) -> list[Balance]: return [ - {"currency": "BTC", "available_balance": Decimal("1.5")}, - {"currency": "USDC", "available_balance": Decimal("900")}, - {"currency": "SOL", "available_balance": Decimal("10")}, + Balance(currency="BTC", available=Decimal("1.5"), total=Decimal("1.5")), + Balance(currency="USDC", available=Decimal("900"), total=Decimal("900")), + Balance(currency="SOL", available=Decimal("10"), total=Decimal("10")), ] @@ -473,7 +494,7 @@ def test_assets_holdings_render_parity(tmp_path, valid_config_path, monkeypatch) cfg = load_config(str(valid_config_path)) report = assets_service.gather_holdings( - _repo_at(db_svc), cfg, _AccountsBroker().get_accounts(), Decimal("0") + _repo_at(db_svc), cfg, _AccountsBroker().get_balances(), Decimal("0") ) rendered = "".join(line + "\n" for line in assets_service.render_holdings(report)) assert _disclaimerless(result.output) == rendered @@ -504,8 +525,16 @@ def test_assets_discover_render_parity(tmp_path, valid_config_path, monkeypatch) result = CliRunner().invoke( cli, - ["--db", str(tmp_path / "cli.db"), "--config", str(valid_config_path), - "assets", "discover", "--min-volume-24h", "100000"], + [ + "--db", + str(tmp_path / "cli.db"), + "--config", + str(valid_config_path), + "assets", + "discover", + "--min-volume-24h", + "100000", + ], ) assert result.exit_code == 0, result.output @@ -562,9 +591,7 @@ def test_pnl_render_parity(tmp_path, extra_args, asset, marks): result = CliRunner().invoke(cli, ["--db", str(db_cli), "pnl", *extra_args]) assert result.exit_code == 0, result.output - report = pnl_service.build_pnl_report( - _repo_at(db_svc).get_transactions(asset), asset, marks - ) + report = pnl_service.build_pnl_report(_repo_at(db_svc).get_transactions(asset), asset, marks) rendered = "".join(line + "\n" for line in pnl_service.render_pnl_report(report)) assert _disclaimerless(result.output) == rendered @@ -652,6 +679,4 @@ def test_agent_cycle_lines_come_from_the_shared_renderer() -> None: skipped = agent.LoopResult( ts=NOW_TS, skipped=True, skip_reason="market_closed", mode="paper", polled=0 ) - assert trading_service.render_loop_result(skipped) == [ - f"[{NOW_TS}] skipped: market_closed" - ] + assert trading_service.render_loop_result(skipped) == [f"[{NOW_TS}] skipped: market_closed"] diff --git a/tests/compliance/test_assets_cli.py b/tests/compliance/test_assets_cli.py index 0e1a4a1f..91245cd6 100644 --- a/tests/compliance/test_assets_cli.py +++ b/tests/compliance/test_assets_cli.py @@ -70,14 +70,11 @@ def _attest_instrument(runner, db_path, config_path, product, **over): flat = [item for pair in args.items() for item in pair] return runner.invoke( cli, - ["--db", str(db_path), "--config", str(config_path), - "assets", "attest-instrument", *flat], + ["--db", str(db_path), "--config", str(config_path), "assets", "attest-instrument", *flat], ) -def test_an_unattested_asset_is_rejected_even_with_perfect_market_data( - tmp_path, valid_config_path -): +def test_an_unattested_asset_is_rejected_even_with_perfect_market_data(tmp_path, valid_config_path): """The gate's whole point: good candles do not substitute for a classification.""" db_path = tmp_path / "t.db" repo = _repo_at(db_path) @@ -101,9 +98,7 @@ def test_attesting_admits_an_otherwise_clean_asset(tmp_path, valid_config_path): runner = CliRunner() for asset in ("BTC", "ETH", "PAXG"): assert _attest(runner, db_path, valid_config_path, asset).exit_code == 0 - assert _attest_instrument( - runner, db_path, valid_config_path, f"{asset}-USD" - ).exit_code == 0 + assert _attest_instrument(runner, db_path, valid_config_path, f"{asset}-USD").exit_code == 0 result = runner.invoke( cli, ["--db", str(db_path), "--config", str(valid_config_path), "assets", "screen"] @@ -121,8 +116,16 @@ def test_a_haram_sector_attestation_still_rejects(tmp_path, valid_config_path): result = runner.invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "screen", "--products", "BTC-USD"], + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "screen", + "--products", + "BTC-USD", + ], ) assert "0/1 admitted" in result.output assert "haram_sector" in result.output @@ -138,8 +141,16 @@ def test_short_history_rejects_regardless_of_attestation(tmp_path, valid_config_ result = runner.invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "screen", "--products", "PAXG-USD"], + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "screen", + "--products", + "PAXG-USD", + ], ) assert "0/1 admitted" in result.output assert "history" in result.output @@ -192,8 +203,18 @@ def _exempt(runner, db_path, config_path, **over): def _unexempt(runner, db_path, config_path, asset="PAXG", criterion="history"): return runner.invoke( cli, - ["--db", str(db_path), "--config", str(config_path), "assets", "unexempt", - "--asset", asset, "--criterion", criterion], + [ + "--db", + str(db_path), + "--config", + str(config_path), + "assets", + "unexempt", + "--asset", + asset, + "--criterion", + criterion, + ], ) @@ -206,15 +227,21 @@ def test_exempt_admits_a_history_failing_asset_and_screen_prints_WAIVED( runner = CliRunner() attested = _attest(runner, db_path, valid_config_path, "PAXG", **{"--backing": "ayn"}) assert attested.exit_code == 0 - assert _attest_instrument( - runner, db_path, valid_config_path, "PAXG-USD" - ).exit_code == 0 + assert _attest_instrument(runner, db_path, valid_config_path, "PAXG-USD").exit_code == 0 # Before the exception: REJECT on history. before = runner.invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "screen", "--products", "PAXG-USD"], + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "screen", + "--products", + "PAXG-USD", + ], ) assert "0/1 admitted" in before.output assert "history" in before.output @@ -225,16 +252,22 @@ def test_exempt_admits_a_history_failing_asset_and_screen_prints_WAIVED( after = runner.invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "screen", "--products", "PAXG-USD"], + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "screen", + "--products", + "PAXG-USD", + ], ) assert "1/1 admitted" in after.output assert "WAIVED" in after.output -def test_exempt_rejects_a_non_waivable_criterion_at_the_cli_boundary( - tmp_path, valid_config_path -): +def test_exempt_rejects_a_non_waivable_criterion_at_the_cli_boundary(tmp_path, valid_config_path): db_path = tmp_path / "t.db" _repo_at(db_path) result = _exempt(CliRunner(), db_path, valid_config_path, **{"--criterion": "bogus"}) @@ -270,9 +303,7 @@ def test_exempt_normalizes_a_lowercase_asset_so_screening_still_finds_the_waiver runner = CliRunner() attested = _attest(runner, db_path, valid_config_path, "PAXG", **{"--backing": "ayn"}) assert attested.exit_code == 0 - assert _attest_instrument( - runner, db_path, valid_config_path, "PAXG-USD" - ).exit_code == 0 + assert _attest_instrument(runner, db_path, valid_config_path, "PAXG-USD").exit_code == 0 result = _exempt(runner, db_path, valid_config_path, **{"--asset": "paxg"}) assert result.exit_code == 0, result.output @@ -280,8 +311,16 @@ def test_exempt_normalizes_a_lowercase_asset_so_screening_still_finds_the_waiver screened = runner.invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "screen", "--products", "PAXG-USD"], + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "screen", + "--products", + "PAXG-USD", + ], ) assert "1/1 admitted" in screened.output assert "WAIVED" in screened.output @@ -309,15 +348,21 @@ def test_unexempt_revokes_and_screen_rejects_again(tmp_path, valid_config_path): runner = CliRunner() attested = _attest(runner, db_path, valid_config_path, "PAXG", **{"--backing": "ayn"}) assert attested.exit_code == 0 - assert _attest_instrument( - runner, db_path, valid_config_path, "PAXG-USD" - ).exit_code == 0 + assert _attest_instrument(runner, db_path, valid_config_path, "PAXG-USD").exit_code == 0 assert _exempt(runner, db_path, valid_config_path).exit_code == 0 admitted = runner.invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "screen", "--products", "PAXG-USD"], + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "screen", + "--products", + "PAXG-USD", + ], ) assert "1/1 admitted" in admitted.output @@ -327,8 +372,16 @@ def test_unexempt_revokes_and_screen_rejects_again(tmp_path, valid_config_path): rejected = runner.invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "screen", "--products", "PAXG-USD"], + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "screen", + "--products", + "PAXG-USD", + ], ) assert "0/1 admitted" in rejected.output assert "✗" in rejected.output @@ -364,8 +417,16 @@ def test_unexempt_normalizes_a_lowercase_asset(tmp_path, valid_config_path): rejected = runner.invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "screen", "--products", "PAXG-USD"], + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "screen", + "--products", + "PAXG-USD", + ], ) assert "0/1 admitted" in rejected.output @@ -486,8 +547,16 @@ def test_discover_states_total_and_shown_when_limit_truncates( result = CliRunner().invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "discover", "--limit", "3"], + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "discover", + "--limit", + "3", + ], ) assert result.exit_code == 0, result.output @@ -531,8 +600,15 @@ def test_probe_history_marks_candidates_without_a_four_year_series( result = CliRunner().invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "discover", "--probe-history"], + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "discover", + "--probe-history", + ], ) assert result.exit_code == 0, result.output assert set(venue.probe_calls) == {"SOL-USD", "NEW-USD"} @@ -558,8 +634,15 @@ def get_candles(self, *a, **k): result = CliRunner().invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "discover", "--probe-history"], + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "discover", + "--probe-history", + ], ) assert result.exit_code == 0 sol_line = next(ln for ln in result.output.splitlines() if "SOL-USD" in ln) @@ -574,14 +657,14 @@ def get_candles(self, *a, **k): class _FakeBroker: - """Duck-types the bits of CoinbaseClient this command uses.""" + """Duck-types the port read this command uses: `get_balances()` -> `list[Balance]`.""" def __init__(self, accounts, fail=False): self._accounts = accounts self._fail = fail self.calls = 0 - def get_accounts(self): + def get_balances(self): self.calls += 1 if self._fail: raise RuntimeError("venue unreachable") @@ -589,13 +672,9 @@ def get_accounts(self): def _account(currency, balance): - return { - "uuid": f"u-{currency}", - "currency": currency, - "available_balance": Decimal(balance), - "default": False, - "active": True, - } + from keel_broker_api.results import Balance + + return Balance(currency=currency, available=Decimal(balance), total=Decimal(balance)) def _with_broker(monkeypatch, broker): @@ -679,8 +758,16 @@ def test_holdings_screen_agrees_with_assets_screen_for_the_same_asset( screened = runner.invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "screen", "--products", "BTC-USD"], + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "screen", + "--products", + "BTC-USD", + ], ) held = _holdings(db_path, valid_config_path, "--screen") @@ -731,9 +818,7 @@ def test_a_broker_failure_is_an_ERROR_not_an_empty_clean_result( assert "unreachable" in result.output.lower() or "error" in result.output.lower() -def test_holdings_auth_advice_names_the_coinbase_env_vars( - tmp_path, valid_config_path, monkeypatch -): +def test_holdings_auth_advice_names_the_coinbase_env_vars(tmp_path, valid_config_path, monkeypatch): """The auth hint is actionable only if it names the keys THIS deployment reads: on the default (coinbase) config that is the CDP pair -- the historical advice, unchanged.""" db_path = tmp_path / "t.db" @@ -768,9 +853,7 @@ def test_holdings_auth_advice_names_the_alpaca_env_vars(tmp_path, write_config, assert "CDP_API_KEY" not in result.output -def test_holdings_marks_assets_already_on_the_allowlist( - tmp_path, valid_config_path, monkeypatch -): +def test_holdings_marks_assets_already_on_the_allowlist(tmp_path, valid_config_path, monkeypatch): db_path = tmp_path / "t.db" _repo_at(db_path) _with_broker(monkeypatch, _FakeBroker([_account("BTC", "0.5"), _account("SOL", "12")])) @@ -795,15 +878,23 @@ def test_holdings_screen_does_not_DROP_compliance_warnings( repo = _repo_at(db_path) _seed_history(repo, "PAXG-USD") runner = CliRunner() - assert _attest( - runner, db_path, valid_config_path, "PAXG", **{"--backing": "ayn"} - ).exit_code == 0 + assert ( + _attest(runner, db_path, valid_config_path, "PAXG", **{"--backing": "ayn"}).exit_code == 0 + ) _with_broker(monkeypatch, _FakeBroker([_account("PAXG", "3")])) screened = runner.invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "screen", "--products", "PAXG-USD"], + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "screen", + "--products", + "PAXG-USD", + ], ) held = _holdings(db_path, valid_config_path, "--screen") @@ -869,9 +960,9 @@ def test_a_genuinely_young_asset_still_reports_history_as_a_real_verdict_via_hol repo = _repo_at(db_path) _seed_history(repo, "PAXG-USD", bars=400) # real bars, genuinely short of the floor runner = CliRunner() - assert _attest( - runner, db_path, valid_config_path, "PAXG", **{"--backing": "ayn"} - ).exit_code == 0 + assert ( + _attest(runner, db_path, valid_config_path, "PAXG", **{"--backing": "ayn"}).exit_code == 0 + ) _with_broker(monkeypatch, _FakeBroker([_account("PAXG", "3")])) result = _holdings(db_path, valid_config_path, "--screen") @@ -897,8 +988,16 @@ def test_zero_cached_bars_never_prints_a_history_depth_failure_via_assets_screen result = CliRunner().invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "screen", "--products", "SOL-USD"], + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "screen", + "--products", + "SOL-USD", + ], ) assert result.exit_code == 0, result.output @@ -919,8 +1018,16 @@ def test_assets_screen_still_reports_a_genuinely_short_history_as_a_real_verdict result = CliRunner().invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "screen", "--products", "PAXG-USD"], + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "screen", + "--products", + "PAXG-USD", + ], ) assert "✗ history" in result.output @@ -1006,13 +1113,12 @@ def test_a_lowercase_holding_is_screened_as_the_attested_uppercase_asset( assert "ADMIT" in result.output -def test_an_account_with_no_currency_field_does_not_crash( - tmp_path, valid_config_path, monkeypatch -): - """`CoinbaseClient.get_accounts` defaults a missing currency to None.""" +def test_an_account_with_no_currency_field_does_not_crash(tmp_path, valid_config_path, monkeypatch): + """The port's `Balance.currency` is a str, but a venue row with no currency coerces to the + empty string -- an unusable asset code must degrade to a blank row, never a crash.""" db_path = tmp_path / "t.db" _repo_at(db_path) - broken = {"uuid": "u", "currency": None, "available_balance": Decimal("1"), "active": True} + broken = _account("", "1") _with_broker(monkeypatch, _FakeBroker([broken, _account("BTC", "0.5")])) result = _holdings(db_path, valid_config_path) @@ -1059,8 +1165,16 @@ def test_the_settlement_criterion_still_catches_an_EXTERNALLY_supplied_product( result = runner.invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "screen", "--products", "BTC-EUR"], # settlement is USD + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "screen", + "--products", + "BTC-EUR", + ], # settlement is USD ) assert "settlement" in result.output, "a cross-settled product must fail the settlement check" @@ -1087,8 +1201,16 @@ def test_screen_REPORTS_on_a_futures_id_rather_than_refusing_the_option( result = runner.invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "screen", "--products", "ADA-28AUG26-CDE"], + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "screen", + "--products", + "ADA-28AUG26-CDE", + ], ) assert result.exit_code == 0, "screening must report a verdict, not a usage error" @@ -1119,8 +1241,16 @@ def test_screen_REJECTS_the_derivative_shaped_id_rail_19_exists_to_refuse( result = runner.invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "screen", "--products", "BTC-PERP-USD"], + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "screen", + "--products", + "BTC-PERP-USD", + ], ) assert result.exit_code == 0, "screening must report a verdict, not a usage error" @@ -1141,8 +1271,16 @@ def test_screen_still_ADMITS_a_well_formed_spot_pair(tmp_path, valid_config_path result = runner.invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "screen", "--products", "BTC-USD"], + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "screen", + "--products", + "BTC-USD", + ], ) assert result.exit_code == 0, result.output @@ -1173,8 +1311,16 @@ def test_screen_REJECTS_a_fully_asset_attested_product_with_no_instrument_attest result = runner.invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "screen", "--products", "BTC-USD"], + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "screen", + "--products", + "BTC-USD", + ], ) assert result.exit_code == 0, result.output @@ -1197,8 +1343,16 @@ def test_asset_and_spot_instrument_attestation_together_ADMIT(tmp_path, valid_co result = runner.invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "screen", "--products", "BTC-USD"], + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "screen", + "--products", + "BTC-USD", + ], ) assert result.exit_code == 0, result.output @@ -1216,14 +1370,25 @@ def test_a_cfd_wrapper_on_an_admissible_underlying_still_REJECTS(tmp_path, valid _seed_history(repo, "BTC-USD") runner = CliRunner() assert _attest(runner, db_path, valid_config_path, "BTC").exit_code == 0 - assert _attest_instrument( - runner, db_path, valid_config_path, "BTC-USD", **{"--wrapper": "cfd"} - ).exit_code == 0 + assert ( + _attest_instrument( + runner, db_path, valid_config_path, "BTC-USD", **{"--wrapper": "cfd"} + ).exit_code + == 0 + ) result = runner.invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "screen", "--products", "BTC-USD"], + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "screen", + "--products", + "BTC-USD", + ], ) assert result.exit_code == 0, result.output @@ -1292,16 +1457,22 @@ def test_attest_instrument_normalizes_a_lowercase_product_so_screening_still_fin runner = CliRunner() assert _attest(runner, db_path, valid_config_path, "BTC").exit_code == 0 - result = _attest_instrument( - runner, db_path, valid_config_path, "btc-usd" - ) + result = _attest_instrument(runner, db_path, valid_config_path, "btc-usd") assert result.exit_code == 0, result.output assert "BTC-USD" in result.output screened = runner.invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "screen", "--products", "BTC-USD"], + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "screen", + "--products", + "BTC-USD", + ], ) assert "ADMIT" in screened.output @@ -1328,8 +1499,16 @@ def test_propose_rejects_an_unattested_candidate(tmp_path, valid_config_path): shortlist = _write_shortlist(tmp_path, [_SOL]) result = CliRunner().invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "propose", "--from", str(shortlist)], + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "propose", + "--from", + str(shortlist), + ], ) assert result.exit_code == 0 assert "REJECT" in result.output @@ -1349,8 +1528,16 @@ def test_zero_cached_bars_never_prints_a_history_depth_failure_via_propose( result = CliRunner().invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "propose", "--from", str(shortlist)], + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "propose", + "--from", + str(shortlist), + ], ) assert result.exit_code == 0 @@ -1369,15 +1556,34 @@ def test_propose_and_screen_agree_for_the_same_asset(tmp_path, valid_config_path assert _attest(runner, db_path, valid_config_path, "BTC").exit_code == 0 assert _attest_instrument(runner, db_path, valid_config_path, "BTC-USD").exit_code == 0 shortlist = _write_shortlist( - tmp_path, [{"asset": "BTC", "rationale": "reserve asset", "sources": ["https://bitcoin.org"]}] + tmp_path, + [{"asset": "BTC", "rationale": "reserve asset", "sources": ["https://bitcoin.org"]}], ) proposed = runner.invoke( - cli, ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "propose", "--from", str(shortlist)], + cli, + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "propose", + "--from", + str(shortlist), + ], ) screened = runner.invoke( - cli, ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "screen", "--products", "BTC-USD"], + cli, + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "screen", + "--products", + "BTC-USD", + ], ) assert "ADMIT" in proposed.output assert "ADMIT" in screened.output @@ -1389,8 +1595,17 @@ def test_propose_writes_nothing(tmp_path, valid_config_path): _repo_at(db_path) shortlist = _write_shortlist(tmp_path, [_SOL]) CliRunner().invoke( - cli, ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "propose", "--from", str(shortlist)], + cli, + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "propose", + "--from", + str(shortlist), + ], ) # Reopen from the path (not the handle held from before the run) so a stray write to ANY # asset/table would actually be caught, not just the one candidate we happened to propose. @@ -1402,8 +1617,18 @@ def test_propose_json_is_valid_and_has_no_trailing_prose(tmp_path, valid_config_ _repo_at(db_path) shortlist = _write_shortlist(tmp_path, [_SOL]) result = CliRunner().invoke( - cli, ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "propose", "--from", str(shortlist), "--json"], + cli, + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "propose", + "--from", + str(shortlist), + "--json", + ], ) payload = json.loads(result.output) # must parse cleanly assert payload["admitted_count"] == 0 @@ -1422,8 +1647,18 @@ def test_propose_json_tells_the_same_zero_bar_story_the_human_output_tells( shortlist = _write_shortlist(tmp_path, [_SOL]) result = CliRunner().invoke( - cli, ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "propose", "--from", str(shortlist), "--json"], + cli, + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "propose", + "--from", + str(shortlist), + "--json", + ], ) row = json.loads(result.output)["screened"][0] @@ -1437,8 +1672,17 @@ def test_propose_missing_file_is_a_clean_error(tmp_path, valid_config_path): db_path = tmp_path / "t.db" _repo_at(db_path) result = CliRunner().invoke( - cli, ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "propose", "--from", str(tmp_path / "nope.json")], + cli, + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "propose", + "--from", + str(tmp_path / "nope.json"), + ], ) assert result.exit_code != 0 @@ -1456,8 +1700,17 @@ def test_propose_non_utf8_shortlist_is_a_clean_error_not_a_traceback(tmp_path, v shortlist.write_bytes(json.dumps({"candidates": [_SOL]}).encode("utf-16")) result = CliRunner().invoke( - cli, ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "propose", "--from", str(shortlist)], + cli, + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "propose", + "--from", + str(shortlist), + ], ) assert result.exit_code != 0 @@ -1471,12 +1724,27 @@ def test_propose_hypothesis_never_admits(tmp_path, valid_config_path): _repo_at(db_path) shortlist = _write_shortlist( tmp_path, - [{"asset": "SOL", "rationale": "x", "sources": ["https://x.invalid"], - "shariah_hypothesis": "definitely halal"}], + [ + { + "asset": "SOL", + "rationale": "x", + "sources": ["https://x.invalid"], + "shariah_hypothesis": "definitely halal", + } + ], ) result = CliRunner().invoke( - cli, ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "propose", "--from", str(shortlist)], + cli, + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "propose", + "--from", + str(shortlist), + ], ) assert "REJECT" in result.output # unattested + no history => rejected despite the hypothesis assert "UNVERIFIED" in result.output @@ -1487,8 +1755,17 @@ def test_propose_human_output_ends_with_the_disclaimer(tmp_path, valid_config_pa _repo_at(db_path) shortlist = _write_shortlist(tmp_path, [_SOL]) result = CliRunner().invoke( - cli, ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "propose", "--from", str(shortlist)], + cli, + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "propose", + "--from", + str(shortlist), + ], ) assert DISCLAIMER in result.output @@ -1537,8 +1814,15 @@ def test_probe_liquidity_flags_a_candidate_whose_24h_snapshot_beats_its_median( result = CliRunner().invoke( cli, - ["--db", str(db_path), "--config", str(valid_config_path), - "assets", "discover", "--probe-liquidity"], + [ + "--db", + str(db_path), + "--config", + str(valid_config_path), + "assets", + "discover", + "--probe-liquidity", + ], ) assert result.exit_code == 0, result.output diff --git a/tests/data/test_cb_client.py b/tests/data/test_cb_client.py index 4f386c1d..1b33345c 100644 --- a/tests/data/test_cb_client.py +++ b/tests/data/test_cb_client.py @@ -15,7 +15,14 @@ import pytest from keel_broker_api.orders import BracketGTC, MarketIOCByQuote -from keel_broker_api.results import Balance, CancelOutcome, Instrument, PlaceResult, Preview +from keel_broker_api.results import ( + Balance, + CancelOutcome, + Instrument, + OrderStatus, + PlaceResult, + Preview, +) from keel_broker_coinbase.translate import to_order_configuration from keel_core import telemetry @@ -130,9 +137,7 @@ def test_get_candles_maps_json_to_typed_candles() -> None: transport = FakeTransport(candles=_load_fixture("cb_candles.json")) client = CoinbaseClient(transport) - candles = client.get_candles( - "BTC-USD", Granularity.ONE_DAY, start=1720915200, end=1721088000 - ) + candles = client.get_candles("BTC-USD", Granularity.ONE_DAY, start=1720915200, end=1721088000) assert len(candles) == 3 assert all(isinstance(c, Candle) for c in candles) @@ -142,9 +147,7 @@ def test_get_candles_maps_decimal_ohlcv_and_ts_correctly() -> None: transport = FakeTransport(candles=_load_fixture("cb_candles.json")) client = CoinbaseClient(transport) - candles = client.get_candles( - "BTC-USD", Granularity.ONE_DAY, start=1720915200, end=1721088000 - ) + candles = client.get_candles("BTC-USD", Granularity.ONE_DAY, start=1720915200, end=1721088000) oldest = candles[0] assert oldest.ts == 1720915200 @@ -163,9 +166,7 @@ def test_get_candles_sorted_ascending_by_ts() -> None: transport = FakeTransport(candles=_load_fixture("cb_candles.json")) client = CoinbaseClient(transport) - candles = client.get_candles( - "BTC-USD", Granularity.ONE_DAY, start=1720915200, end=1721088000 - ) + candles = client.get_candles("BTC-USD", Granularity.ONE_DAY, start=1720915200, end=1721088000) assert [c.ts for c in candles] == sorted(c.ts for c in candles) @@ -335,9 +336,7 @@ def test_get_accounts_returns_list_of_dicts() -> None: def _buy_spec(quote: str = "100.00") -> MarketIOCByQuote: - return MarketIOCByQuote( - product_id="BTC-USD", side=Side.BUY, quote_size=Decimal(quote) - ) + return MarketIOCByQuote(product_id="BTC-USD", side=Side.BUY, quote_size=Decimal(quote)) def test_preview_order_answers_the_ports_type() -> None: @@ -486,7 +485,8 @@ def test_get_order_normalizes_status_fill_price_and_fees(): """The reconciliation pass needs three things a placement response cannot give: whether the order actually filled, at what price, and for how much in fees. `average_filled_price` and `total_fees` are OBSERVED, replacing the expected-price and previewed-commission estimates - the executor records at placement time.""" + the executor records at placement time. The answer is the port's `OrderStatus` (#524), the + same type the adapter answers in.""" transport = FakeTransport( order={ "order": { @@ -506,11 +506,12 @@ def test_get_order_normalizes_status_fill_price_and_fees(): order = client.get_order("abc-123") assert transport.calls["get_order"] == {"order_id": "abc-123"} - assert order["order_id"] == "abc-123" - assert order["status"] == "FILLED" - assert order["filled_size"] == Decimal("0.01") - assert order["average_filled_price"] == Decimal("49875.42") - assert order["total_fees"] == Decimal("2.9925") + assert isinstance(order, OrderStatus) + assert order.order_id == "abc-123" + assert order.status == "FILLED" + assert order.filled_size == Decimal("0.01") + assert order.average_filled_price == Decimal("49875.42") + assert order.total_fees == Decimal("2.9925") def test_get_order_on_an_unfilled_order_reports_zero_fill_not_none(): @@ -523,16 +524,14 @@ def test_get_order_on_an_unfilled_order_reports_zero_fill_not_none(): order = client.get_order("abc-123") - assert order["status"] == "OPEN" - assert order["filled_size"] == Decimal("0") - assert order["average_filled_price"] == Decimal("0") - assert order["total_fees"] == Decimal("0") + assert order.status == "OPEN" + assert order.filled_size == Decimal("0") + assert order.average_filled_price == Decimal("0") + assert order.total_fees == Decimal("0") def test_cancel_order_is_confirmed_when_the_exchange_confirms(): - transport = FakeTransport( - cancel={"results": [{"success": True, "order_id": "abc-123"}]} - ) + transport = FakeTransport(cancel={"results": [{"success": True, "order_id": "abc-123"}]}) client = CoinbaseClient(transport) assert client.cancel_order("abc-123") is CancelOutcome.CONFIRMED diff --git a/tests/execution/test_executor.py b/tests/execution/test_executor.py index fd4f15ee..e533c40f 100644 --- a/tests/execution/test_executor.py +++ b/tests/execution/test_executor.py @@ -19,7 +19,7 @@ import pytest from keel_broker_api.orders import BracketGTC, LimitGTC, OrderSpec -from keel_broker_api.results import Balance, PlaceResult, Preview +from keel_broker_api.results import Balance, OrderStatus, PlaceResult, Preview from keel_core.subscription import SubscriptionStatus from keel.config import ( @@ -1352,18 +1352,16 @@ class _PartiallyFillingBroker(FakeBroker): def __init__(self, filled_size: Decimal, average_price: Decimal) -> None: super().__init__() - self._observed = { - "order_id": "broker-order-1", - "product_id": "BTC-USD", - "side": "BUY", - "status": "FILLED", - "filled_size": filled_size, - "average_filled_price": average_price, - "total_fees": Decimal("0.18"), - } + self._observed = OrderStatus( + order_id="broker-order-1", + status="FILLED", + filled_size=filled_size, + average_filled_price=average_price, + total_fees=Decimal("0.18"), + ) - def get_order(self, order_id: str) -> dict: - return dict(self._observed) + def get_order(self, order_id: str) -> OrderStatus: + return self._observed def test_a_partially_filled_entry_records_the_filled_quantity_and_warns(repo, caplog): @@ -2055,14 +2053,14 @@ def test_an_immediately_filled_order_upgrades_to_the_OBSERVED_fill_and_fee(repo) """ class _ObservingBroker(FakeBroker): - def get_order(self, order_id: str) -> dict: - return { - "order_id": order_id, - "status": "FILLED", - "filled_size": Decimal("0.001"), - "average_filled_price": Decimal("50123.45"), # not the expected 50000 - "total_fees": Decimal("0.42"), # not the previewed 0.30 - } + def get_order(self, order_id: str) -> OrderStatus: + return OrderStatus( + order_id=order_id, + status="FILLED", + filled_size=Decimal("0.001"), + average_filled_price=Decimal("50123.45"), # not the expected 50000 + total_fees=Decimal("0.42"), # not the previewed 0.30 + ) broker = _ObservingBroker() @@ -2079,7 +2077,7 @@ def test_an_unobservable_immediate_fill_keeps_the_estimate_rather_than_failing(r shipped before this upgrade existed.""" class _BlindBroker(FakeBroker): - def get_order(self, order_id: str) -> dict: + def get_order(self, order_id: str) -> OrderStatus: raise RuntimeError("status endpoint down") broker = _BlindBroker() @@ -3220,3 +3218,121 @@ def place_order(self, spec, *, idempotency_key=None): # noqa: ANN001, ANN202 assert intent, "a naked position with no ledger is the exact #519 hole" assert intent["stop"] == Decimal("50000") assert any("position_unprotected" in r.message for r in caplog.records) + + +# -- #524: the registry serves the executor; the executor speaks only the port ----------------- + + +class _FixtureTransport: + """A `coinbase.rest.RESTClient` duck answering with the canned, real-shaped JSON the + `tests/fixtures/cb_*.json` files hold -- the same fixtures `tests/data/test_cb_client.py` + drives the legacy client with, so the adapter resolved below runs against data captured + from the venue's own response shapes. No network, no credentials.""" + + def __init__(self) -> None: + self.preview_calls: list[dict[str, Any]] = [] + self.create_calls: list[dict[str, Any]] = [] + + def _fixture(self, name: str) -> Any: + from pathlib import Path + + with (Path(__file__).parent.parent / "fixtures" / name).open() as f: + return json.load(f) + + def get_accounts(self, **kwargs: Any) -> Any: + accounts = self._fixture("cb_accounts.json") + # The captured fixture holds USD 1042.55; the order the executor sizes from + # `_enter_signal` needs more, and rail 13 fails closed on the shortfall. Fund the + # account rather than shrink the order -- the point of this test is the full guarded + # path, not rail 13's arithmetic (that rail's own tests cover it). + for row in accounts["accounts"]: + if row["currency"] == "USD": + row["available_balance"]["value"] = "1000000.00" + return accounts + + def get_product(self, product_id: str, **kwargs: Any) -> Any: + return self._fixture("cb_product.json") + + def preview_order( + self, product_id: str, side: str, order_configuration: dict[str, Any], **kwargs: Any + ) -> Any: + self.preview_calls.append( + { + "product_id": product_id, + "side": side, + "order_configuration": order_configuration, + } + ) + return self._fixture("cb_preview_order.json") + + def create_order( + self, + client_order_id: str, + product_id: str, + side: str, + order_configuration: dict[str, Any], + **kwargs: Any, + ) -> Any: + self.create_calls.append( + { + "client_order_id": client_order_id, + "product_id": product_id, + "side": side, + "order_configuration": order_configuration, + } + ) + return self._fixture("cb_place_order_market.json") + + def get_order(self, order_id: str, **kwargs: Any) -> Any: + return { + "order": { + "order_id": order_id, + "status": "FILLED", + "filled_size": "0.001", + "average_filled_price": "65440.00", + "total_fees": "0.60", + } + } + + +def test_the_registry_resolved_coinbase_adapter_serves_execute_end_to_end(repo) -> None: + """#524's headline proof: the default venue's broker, resolved the way `_build_broker` + now resolves it, drives one full guarded BUY through the executor. The balance read feeds + rail 13, the instrument read sizes the order, the preview is the port's `Preview`, the + placement a `PlaceResult`, and the reconciliation read observes the fill -- with no dict + shape probed anywhere on the way through.""" + from keel_broker_api.registry import load_broker + + transport = _FixtureTransport() + broker = load_broker("coinbase")(transport) + + result = execute(_enter_signal(), broker, repo, _config(), "autonomous", now_ts=NOW_TS) + + assert result.placed is True + assert result.preview is not None and result.preview.synthetic is False + assert result.bracket_order_id is not None + # The entry and its bracket both reached the venue through the port's specs + assert [list(c["order_configuration"]) for c in transport.create_calls] == [ + ["market_market_ioc"], + ["trigger_bracket_gtc"], + ] + assert transport.create_calls[0]["product_id"] == "BTC-USD" + + +def test_a_registry_resolved_fake_venue_serves_the_executors_port_reads(repo) -> None: + """The second adapter the registry can hand the executor: the fake venue, whose deliberate + divergences are the port's design pressure. Its balances and instrument reads serve the + executor's two pre-order port calls, and its refusal to preview is the port's honest + exception -- a capability-declined `NotImplementedError`, never a shape mismatch.""" + from keel_broker_api.orders import MarketIOCByQuote + from keel_broker_api.registry import load_broker + + fake = load_broker("fake")() + + assert executor._fetch_available_quote(fake, "USD") == Decimal("1000") + assert executor._base_increment_for(fake, repo, "BTC-USD", NOW_TS) == Decimal("0.00000001") + + with pytest.raises(NotImplementedError, match="no order preview"): + fake.preview_order( + MarketIOCByQuote(product_id="BTC-USD", side=Side.BUY, quote_size=Decimal("50")) + ) diff --git a/tests/execution/test_reconcile.py b/tests/execution/test_reconcile.py index 860cfa61..51d1cf01 100644 --- a/tests/execution/test_reconcile.py +++ b/tests/execution/test_reconcile.py @@ -16,7 +16,7 @@ import pytest from keel_broker_api.orders import OrderSpec -from keel_broker_api.results import Balance, PlaceResult, Preview +from keel_broker_api.results import Balance, OrderStatus, PlaceResult, Preview from keel.config import Caps, Config, MarketDataConfig, MoneyMgmtConfig from keel.data.db import connect, migrate @@ -28,6 +28,24 @@ PRODUCT = "BTC-USD" +def _observed_from(payload: dict[str, Any]) -> OrderStatus: + """An `OrderStatus` from the dict shape these tests have always described an observation in. + + Kept as a dict at the call sites deliberately (the same reasoning as the executor suite's + `_preview_from`): dozens of tests construct a bespoke observation to exercise one field -- + a missing status, a zero fill, a partly-filled size -- and this is the one place the + translation into the port's type happens. The broker's answer has been `OrderStatus` since + #524; the fixtures stay dicts so each test says only the field it means. + """ + return OrderStatus( + order_id=str(payload.get("order_id", "")), + status=str(payload.get("status") or ""), + filled_size=Decimal(str(payload.get("filled_size") or "0")), + average_filled_price=Decimal(str(payload.get("average_filled_price") or "0")), + total_fees=Decimal(str(payload.get("total_fees") or "0")), + ) + + def _config(**overrides: Any) -> Config: base: dict[str, Any] = dict( allowlist=["BTC"], @@ -60,9 +78,9 @@ def __init__(self, orders: dict[str, dict[str, Any]] | None = None) -> None: self._orders = orders or {} self.get_order_calls: list[str] = [] - def get_order(self, order_id: str) -> dict[str, Any]: + def get_order(self, order_id: str) -> OrderStatus: self.get_order_calls.append(order_id) - return self._orders[order_id] + return _observed_from(self._orders[order_id]) class _RebracketingBroker(_Broker): @@ -73,9 +91,7 @@ def __init__(self, orders: dict[str, dict[str, Any]] | None = None) -> None: self.placed: list[dict[str, Any]] = [] def get_balances(self) -> list[Balance]: - return [ - Balance(currency="USDC", available=Decimal("1000000"), total=Decimal("1000000")) - ] + return [Balance(currency="USDC", available=Decimal("1000000"), total=Decimal("1000000"))] def preview_order(self, spec: OrderSpec) -> Preview: return Preview( @@ -429,7 +445,7 @@ def test_a_broker_error_on_one_order_does_not_abandon_the_rest(repo): ) class _PartlyBroken(_Broker): - def get_order(self, order_id: str) -> dict[str, Any]: + def get_order(self, order_id: str) -> OrderStatus: if order_id == "cb-broken": raise RuntimeError("broker blew up") return super().get_order(order_id) @@ -1123,9 +1139,7 @@ class _RejectingRebracketBroker(_RebracketingBroker): def place_order(self, spec: OrderSpec, *, idempotency_key: str | None = None) -> PlaceResult: self.placed.append({"spec": spec}) - return PlaceResult( - success=False, broker_order_id=None, reason="PREVIEW_INVALID_BASE_SIZE" - ) + return PlaceResult(success=False, broker_order_id=None, reason="PREVIEW_INVALID_BASE_SIZE") def _seed_unbracketed_tranche( diff --git a/tests/test_agent.py b/tests/test_agent.py index 3e1ac43f..ef26356e 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -24,6 +24,7 @@ from keel_broker_api.results import ( Balance, MarketSchedule, + OrderStatus, PlaceResult, Preview, SessionState, @@ -1561,14 +1562,14 @@ def test_run_once_reconciles_a_filled_bracket(repo: Repository) -> None: ) class _ReconcilingBroker(FakeBroker): - def get_order(self, order_id: str) -> dict[str, Any]: - return { - "order_id": order_id, - "status": "FILLED", - "filled_size": Decimal("0.01"), - "average_filled_price": Decimal("48900"), - "total_fees": Decimal("2.93"), - } + def get_order(self, order_id: str) -> OrderStatus: + return OrderStatus( + order_id=order_id, + status="FILLED", + filled_size=Decimal("0.01"), + average_filled_price=Decimal("48900"), + total_fees=Decimal("2.93"), + ) broker = _ReconcilingBroker( series={(PRODUCT, Granularity.ONE_DAY): [_candle(1_000 + i * 86_400) for i in range(30)]} diff --git a/tests/test_paper_equities_profile.py b/tests/test_paper_equities_profile.py index 3516e380..814081de 100644 --- a/tests/test_paper_equities_profile.py +++ b/tests/test_paper_equities_profile.py @@ -6,12 +6,11 @@ .plist` + `paper-equities-run.sh` + `keel-equities` -- tracked in-repo exactly like the paperforward/live/paper-hourly ones. Nothing about them is executed by the suite's code paths, so like `tests/test_paper_hourly_profile.py` this file pins them against drift. -2. The MINIMAL engine wiring the profile needs: config-driven venue selection. Today - `_build_broker` constructs a `CoinbaseClient` unconditionally; this profile is the first - that must reach a different adapter (`keel-broker-alpaca`, paper host, IEX feed). The - `broker:` config section is that surface, and its ABSENCE must leave the Coinbase - construction path byte-identical -- pinned here by construction, not by assertion of - intent, because every existing profile and test depends on that default. +2. The MINIMAL engine wiring the profile needs: config-driven venue selection. Every name, + coinbase included (#524), resolves through the `keel.brokers` entry points; the `broker:` + config section selects one, and the CLI's per-venue wiring is the TRANSPORT it hands the + resolved adapter (alpaca: paper/live host, IEX/SIP feed). An adapter that resolves but + has no wiring is refused by name rather than constructed credential-less. The runner tests execute the REAL script verbatim through a harness that shims `date` (so they do not depend on, or wait on, the wall clock) and stubs the deployment's `.venv/bin/keel`. @@ -249,11 +248,7 @@ def _sandbox(tmp_path: Path, keel_exit_code: int) -> tuple[Path, Path, Path, dic stub_dir.mkdir(parents=True, exist_ok=True) invocations = stub_dir / "keel.invocations" stub = stub_dir / "keel" - stub.write_text( - "#!/bin/bash\n" - f'printf "%s\\n" "$*" >> "{invocations}"\n' - f"exit {keel_exit_code}\n" - ) + stub.write_text(f'#!/bin/bash\nprintf "%s\\n" "$*" >> "{invocations}"\nexit {keel_exit_code}\n') stub.chmod(stub.stat().st_mode | stat.S_IEXEC) date_bin = tmp_path / "shim-bin" @@ -267,9 +262,7 @@ def _sandbox(tmp_path: Path, keel_exit_code: int) -> tuple[Path, Path, Path, dic def _run(script: Path, env: dict[str, str], now: datetime) -> subprocess.CompletedProcess[str]: run_env = dict(env) run_env["KEEL_TEST_NOW"] = str(int(now.astimezone(UTC).timestamp())) - return subprocess.run( - ["/bin/bash", str(script)], capture_output=True, text=True, env=run_env - ) + return subprocess.run(["/bin/bash", str(script)], capture_output=True, text=True, env=run_env) def _count_lines(path: Path) -> int: @@ -596,18 +589,14 @@ def test_broker_endpoint_is_validated_at_load(tmp_path): at config load, not at first request -- and live/paper is the whole vocabulary because the trading host is derived from it, never configured as a URL.""" with pytest.raises(ConfigError, match="broker.endpoint"): - load_config( - str(_write_config(tmp_path, "\nbroker:\n name: alpaca\n endpoint: prod\n")) - ) + load_config(str(_write_config(tmp_path, "\nbroker:\n name: alpaca\n endpoint: prod\n"))) def test_broker_data_feed_is_validated_at_load(tmp_path): """The data tier is a DECLARED capability (FR-5): iex or sip, nothing else, refused at load rather than silently falling back to the venue's server-side default.""" with pytest.raises(ConfigError, match="broker.data_feed"): - load_config( - str(_write_config(tmp_path, "\nbroker:\n name: alpaca\n data_feed: cows\n")) - ) + load_config(str(_write_config(tmp_path, "\nbroker:\n name: alpaca\n data_feed: cows\n"))) def test_coinbase_rejects_the_alpaca_only_knobs(tmp_path): @@ -617,17 +606,18 @@ def test_coinbase_rejects_the_alpaca_only_knobs(tmp_path): load_config(str(_write_config(tmp_path, "\nbroker:\n endpoint: live\n"))) -def test_build_broker_default_is_byte_compatible_coinbase( - tmp_path, monkeypatch -): - """THE default pin, by construction: no `broker:` section -> `_build_broker` takes the - unchanged Coinbase path -- `load_secrets()` from `.env`, a `RESTClient` built from those - CDP values, wrapped in `CoinbaseClient`. The kwargs and the wrapping are asserted, so a - refactor that changed any of it for the default config fails here.""" +def test_build_broker_default_resolves_coinbase_through_the_registry(tmp_path, monkeypatch): + """THE default pin, post-flip (#524): no `broker:` section -> the venue name resolves + through the `keel.brokers` entry points exactly like every other name, and the CLI's + coinbase wiring is the TRANSPORT it hands the resolved adapter -- `load_secrets()` from + `.env`, a `RESTClient` built from those CDP values. The resolved class, the kwargs and + the wrapping are all asserted, so a refactor that changed any of it for the default + config fails here. The direct `CoinbaseClient` construction this test used to pin is the + thing #524 deleted; there is no second coinbase path left to drift against.""" import coinbase.rest from keel.commands._common import _build_broker - from keel.data import cb_client + from keel_broker_coinbase import CoinbaseAdapter (tmp_path / ".env").write_text("CDP_API_KEY=cb-key\nCDP_API_SECRET=cb-secret\n") monkeypatch.chdir(tmp_path) @@ -638,23 +628,18 @@ class _FakeRESTClient: def __init__(self, **kwargs: object) -> None: calls["rest_kwargs"] = kwargs - def _fake_coinbase_client(transport: object) -> object: - calls["transport"] = transport - return object() - monkeypatch.setattr(coinbase.rest, "RESTClient", _FakeRESTClient) - monkeypatch.setattr(cb_client, "CoinbaseClient", _fake_coinbase_client) config = load_config(str(_write_config(tmp_path))) broker = _build_broker(config) - assert broker is not None + assert isinstance(broker, CoinbaseAdapter) assert calls["rest_kwargs"] == { "api_key": "cb-key", "api_secret": "cb-secret", "timeout": None, } - assert isinstance(calls["transport"], _FakeRESTClient) + assert isinstance(broker._transport, _FakeRESTClient) def test_build_broker_selects_alpaca_paper_iex(tmp_path, monkeypatch): From 22db102638f800d707a9c163ccf0b7bac187e889 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Thu, 27 Aug 2026 20:47:41 -0400 Subject: [PATCH 3/5] chore(capabilities): the grandfather clause retires with the pre-port client (#524) The documented exception -- 'the only broker the live path constructs is CoinbaseClient, which has no capabilities() at all' -- is no longer true: every broker _build_broker can now construct is a registry-resolved adapter that answers capabilities(). The clause is deleted from BrokerCapabilities' field note, rail 19's comment in guards.py, agent _venue_schedule's docstring (whose fallback now serves paper's None broker and contract violators, not a pre-port client), and assets.py's VENUE constant. What each note still records, honestly: asset_classes remains unread by engine code (guards.check is broker-less by design and paper passes broker=None, so a capabilities gate cannot live there); the venue-session fallback stays for the paper cycle; VENUE stays a constant because the screen is repo-driven -- the capabilities().venue replacement needs a broker handle threaded into it, which is #202/#233 follow-on work. The default-venue pin now asserts the resolved adapter declares itself (venue=coinbase, session_bound=False), so the clause cannot quietly return. --- keel/agent.py | 9 ++++--- keel/commands/assets.py | 26 ++++++++++--------- keel/execution/guards.py | 12 +++++---- .../keel_broker_api/capabilities.py | 21 ++++++++------- tests/test_paper_equities_profile.py | 9 ++++++- 5 files changed, 47 insertions(+), 30 deletions(-) diff --git a/keel/agent.py b/keel/agent.py index 79d954ce..da3d5fb4 100644 --- a/keel/agent.py +++ b/keel/agent.py @@ -910,9 +910,12 @@ def _venue_schedule(broker: Any) -> tuple[str, MarketSchedule, bool]: Returns `(venue, schedule, session_bound)`. `venue` comes from the same `capabilities()` read as `session_bound` (empty for a capabilities-less broker, which never records anyway). `session_bound=False` is also the answer for a broker that does - not implement the broker port at all (`keel/data/cb_client.py`'s `CoinbaseClient`, the - live path until the broker-port migration lands): a 24/7 posture with no clock to - consult, which keeps every existing crypto behavior byte-identical. + not implement the broker port at all -- paper mode's `broker=None`, or a third-party + object violating the port: a 24/7 posture with no clock to consult, which keeps every + existing crypto behavior byte-identical. (Every broker the LIVE path constructs since + #524 finished the migration is a registry-resolved adapter that answers `capabilities()`; + the pre-port client this fallback used to carry is gone from the path, but the fallback + itself stays: paper must never crash on its broker-less cycle.) The schedule read prefers the port's `market_schedule()` (issue #388 C2) and falls back to a DERIVED schedule -- `market_clock()`'s answer with null next open/close -- for a diff --git a/keel/commands/assets.py b/keel/commands/assets.py index 24b303e0..ad86e37e 100644 --- a/keel/commands/assets.py +++ b/keel/commands/assets.py @@ -60,19 +60,21 @@ #: The venue every product screened here is listed on. #: -#: ⚠️ A CONSTANT because it is currently a fact, not a configuration. The live path constructs -#: `keel/data/cb_client.py`'s `CoinbaseClient` directly, so there is exactly one venue these -#: product ids can mean, and an `InstrumentAttestation` is keyed on `(venue, product_id)` -- -#: which means the screen needs a venue id to look one up, and inventing a per-call parameter -#: for a value with one possible answer would be a knob whose only safe setting is its default. +#: ⚠️ A CONSTANT because it is currently a fact, not a configuration. The screen is REPO-DRIVEN +#: and broker-less -- it reads cached candles, and no adapter handle reaches it -- so there is +#: exactly one venue these product ids can mean, and an `InstrumentAttestation` is keyed on +#: `(venue, product_id)` -- which means the screen needs a venue id to look one up, and +#: inventing a per-call parameter for a value with one possible answer would be a knob whose +#: only safe setting is its default. #: -#: The broker-port migration replaces this with the adapter's own `BrokerCapabilities.venue` -#: (`packages/keel-broker-api/keel_broker_api/capabilities.py`), at which point the wrapper -#: statement recorded for `BTC-USD` on Coinbase correctly stops applying to `BTC-USD` somewhere -#: else -- which is issue #202's entire point and the reason the key is a pair. Until an adapter -#: handle actually reaches this function, reading a venue id off one would be reading it off -#: nothing: the same dead-gate pattern `capabilities.py` warns about, where a lookup that cannot -#: fail reads as a defence. +#: The eventual replacement is the adapter's own `BrokerCapabilities.venue` +#: (`packages/keel-broker-api/keel_broker_api/capabilities.py`) -- every broker the live path +#: constructs since #524 finished the broker-port migration answers it -- at which point the +#: wrapper statement recorded for `BTC-USD` on Coinbase correctly stops applying to `BTC-USD` +#: somewhere else, which is issue #202's entire point and the reason the key is a pair. +#: Threading a broker handle into this repo-driven screen (and a per-venue candle cache) is +#: the remaining work; until it lands, reading a venue id off a broker this function does not +#: hold would be reading it off nothing. VENUE = "coinbase" diff --git a/keel/execution/guards.py b/keel/execution/guards.py index f64b9adc..d06ab140 100644 --- a/keel/execution/guards.py +++ b/keel/execution/guards.py @@ -839,11 +839,13 @@ def check( # # Spot-only is this agent's CHARTER, not an operator preference, so there is no config # field here to widen (unlike rail 18's `settlement_currencies`). Nor does this consult - # `BrokerCapabilities.asset_classes`: `guards.check` has no broker handle, the live path - # constructs `data.cb_client.CoinbaseClient` which has no `capabilities()` at all, and - # paper passes `broker=None` -- so such a gate would be dead code that reads as a - # defence. That exact pattern was built and deleted once already (R1's "what was - # deliberately NOT shipped"). It belongs with the broker-port migration. + # `BrokerCapabilities.asset_classes`: `guards.check` is broker-less BY DESIGN (paper + # passes `broker=None`, and the rails must hold identically there), so such a gate + # would be dead code that reads as a defence. Every broker the live path constructs + # since #524 finished the broker-port migration answers `capabilities()` -- the + # pre-port client's grandfather clause retired with it -- but reachable is not read, + # and this rail stays shape-based. That exact pattern was built and deleted once + # already (R1's "what was deliberately NOT shipped"). # # Returns a VIOLATION, never raises, on any input. `parse_spot_product_id` is total. if parse_spot_product_id(intent.product_id) is None: diff --git a/packages/keel-broker-api/keel_broker_api/capabilities.py b/packages/keel-broker-api/keel_broker_api/capabilities.py index cd6499bf..fe18c2a8 100644 --- a/packages/keel-broker-api/keel_broker_api/capabilities.py +++ b/packages/keel-broker-api/keel_broker_api/capabilities.py @@ -28,16 +28,19 @@ class BrokerCapabilities: ⚠️ `asset_classes` is **not** what keeps keel spot-only today, and no engine code reads it. The spot gate on the live path is **rail 19 (`spot_instrument`)** in `keel/execution/guards.py`, which checks the product id's shape and needs no broker handle. - That is deliberate, not an oversight: `guards.check` has no broker, the only broker the live - path constructs is `keel/data/cb_client.py`'s `CoinbaseClient` -- which has no - `capabilities()` at all -- and the paper path passes `broker=None`, so a gate built on this - field would be dead code on every real path while reading as a defence. That exact pattern - was built and deleted once already (R1's "what was deliberately NOT shipped"). + That is deliberate, not an oversight: `guards.check` is broker-less BY DESIGN (its rails + must hold in paper mode, where the executor passes `broker=None`), so a gate built on this + field cannot live there. Every broker the live path constructs since #524 finished the + broker-port migration IS an adapter that answers `capabilities()` -- the grandfather clause + for the pre-port client, which had no `capabilities()` at all, retired with it -- but + reachable is not the same as read, and a capabilities gate that no path consults is still + dead code that reads as a defence. That exact pattern was built and deleted once already + (R1's "what was deliberately NOT shipped"). - This field's job until then is to keep the declaration honest and checkable, so the - broker-port migration that makes `capabilities()` reachable inherits a vocabulary rather - than a free-form set. At that point the reconciliation belongs at LOAD time, not as a - per-order raise -- a raise on the exit path can trap a position. + This field's job until something consumes it is to keep the declaration honest and + checkable, so the first consumer inherits a vocabulary rather than a free-form set. When + that happens the reconciliation belongs at LOAD time, not as a per-order raise -- a raise + on the exit path can trap a position. """ venue: str diff --git a/tests/test_paper_equities_profile.py b/tests/test_paper_equities_profile.py index 814081de..057b13f0 100644 --- a/tests/test_paper_equities_profile.py +++ b/tests/test_paper_equities_profile.py @@ -615,9 +615,9 @@ def test_build_broker_default_resolves_coinbase_through_the_registry(tmp_path, m config fails here. The direct `CoinbaseClient` construction this test used to pin is the thing #524 deleted; there is no second coinbase path left to drift against.""" import coinbase.rest + from keel_broker_coinbase import CoinbaseAdapter from keel.commands._common import _build_broker - from keel_broker_coinbase import CoinbaseAdapter (tmp_path / ".env").write_text("CDP_API_KEY=cb-key\nCDP_API_SECRET=cb-secret\n") monkeypatch.chdir(tmp_path) @@ -640,6 +640,13 @@ def __init__(self, **kwargs: object) -> None: "timeout": None, } assert isinstance(broker._transport, _FakeRESTClient) + # The default venue's broker is capabilities-bearing -- the read the venue-session + # recording already builds on. The pre-port client's grandfather clause (a live-path + # broker with no `capabilities()` at all) retired with #524, and this pins that it + # stays retired. + caps = broker.capabilities() + assert caps.venue == "coinbase" + assert caps.session_bound is False def test_build_broker_selects_alpaca_paper_iex(tmp_path, monkeypatch): From 7bb0e87e5d2d3b57cd786e231128983cd84903b3 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Thu, 27 Aug 2026 20:48:18 -0400 Subject: [PATCH 4/5] docs(deps): robinhood's dev-only status is a choice now, not a leftover (#524) The old comment justified dev-only with 'nothing constructs it -- _common.py still builds CoinbaseClient directly'. That reason died with the flip: the adapter is reachable, and selecting it is refused explicitly at arm time for lacking CLI credential wiring. The comment now states the decision: dev-only until credential wiring AND #233's capability-based venue visibility exist, because runtime availability without wiring ships pynacl to installs that still cannot select the venue. Nothing about what ships changes. --- pyproject.toml | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9ae19bba..e425d22c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,15 +101,17 @@ 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. + # Dev-only -- a DELIBERATE post-#524 choice, not a leftover of the pre-port world. The + # broker-port migration is finished, so a deployment that installs this package and selects + # `broker.name: robinhood` gets all the way to `_build_broker`, which refuses the name + # EXPLICITLY: the CLI has no credential wiring for its Ed25519 keys, and constructing it + # anyway would hand the engine a broker that cannot reach its venue. Runtime availability + # would therefore change nothing for an install until that wiring exists, while putting an + # Ed25519 stack (pynacl) into every install -- so it stays a dependency choice, made here, + # and flipping it is a real decision that also wants #233's capability-based venue + # visibility (an install must not silently widen what a deployment can select). Until + # then: the dev entry runs the conformance suite against it in CI, and users who want the + # venue install `keel-broker-robinhood` themselves -- entry-point discovery picks it up. "keel-broker-robinhood", # Dev-only for the same reason as Robinhood: Alpaca is an optional venue (the Phase 12 # equities milestone). It rides the dev group so the conformance suite runs against it From ab4a619c1f72ecc81009a94b8ba3f010c83cf405 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Thu, 27 Aug 2026 21:29:31 -0400 Subject: [PATCH 5/5] test(brokers): the registry coinbase serves reconcile too; the robinhood refusal names why (#524) --- keel/commands/_common.py | 4 ++- tests/execution/test_executor.py | 39 ++++++++++++++++++++++++++++ tests/test_paper_equities_profile.py | 9 ++++++- 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/keel/commands/_common.py b/keel/commands/_common.py index 68d439cd..b509eda3 100644 --- a/keel/commands/_common.py +++ b/keel/commands/_common.py @@ -204,7 +204,9 @@ def _build_broker(config: Config, *, timeout: int | None = None) -> Any: raise RuntimeError( f"broker.name {venue!r} resolved to an installed adapter, but the CLI does not " "yet know how to give it credentials -- venue wiring exists for 'coinbase' and " - "'alpaca' only. Constructing it anyway would hand the engine a broker that " + "'alpaca' only. For robinhood the missing piece is the Ed25519 credential wiring " + "its transport signs with, which the CLI does not carry yet by choice -- the " + "venue is dev-only. Constructing it anyway would hand the engine a broker that " "cannot reach its venue." ) diff --git a/tests/execution/test_executor.py b/tests/execution/test_executor.py index e533c40f..305e6d49 100644 --- a/tests/execution/test_executor.py +++ b/tests/execution/test_executor.py @@ -3319,6 +3319,45 @@ def test_the_registry_resolved_coinbase_adapter_serves_execute_end_to_end(repo) assert transport.create_calls[0]["product_id"] == "BTC-USD" +def test_the_registry_resolved_coinbase_adapter_serves_the_reconcile_sweep(repo) -> None: + """The third leg of the #524 pin. The same registry-built adapter that served the guarded + place and the preview also serves the sweep that later observes the resting bracket's fill: + the tranche is recorded the way `run_once` records it, the venue's answer arrives as the + port's `OrderStatus`, and the observed economics land on the order row and in the outcome -- + with no dict shape probed anywhere on the way through.""" + from keel_broker_api.registry import load_broker + + from keel.execution.reconcile import reconcile_open_orders + + transport = _FixtureTransport() + broker = load_broker("coinbase")(transport) + + result = execute(_enter_signal(), broker, repo, _config(), "autonomous", now_ts=NOW_TS) + assert result.placed and result.bracket_order_id is not None + # What `run_once` leaves behind after a filled entry: the tranche, pointed at its bracket. + entry = repo.get_order(result.order_id) + position_id = repo.open_position( + product_id="BTC-USD", + rule_name="pullback_continuation", + opened_at=NOW_TS, + qty=entry["qty"], + entry_fee=entry["fee"] or Decimal("0"), + entry_fill=entry["actual_fill"], + ) + repo.set_position_bracket(position_id, result.bracket_order_id) + + changed = reconcile_open_orders(broker, repo, _config(), now_ts=NOW_TS + 900) + + # The market entry filled at placement; the resting bracket is the sweep's one row. + assert changed == [result.bracket_order_id] + bracket = repo.get_order(result.bracket_order_id) + assert bracket["status"] == "filled" + assert bracket["actual_fill"] == Decimal("65440.00") # observed, not the stop it rested at + assert bracket["fee"] == Decimal("0.60") + outcomes = repo.get_trade_outcomes() + assert len(outcomes) == 1 and outcomes[0]["exit_fill"] == Decimal("65440.00") + + def test_a_registry_resolved_fake_venue_serves_the_executors_port_reads(repo) -> None: """The second adapter the registry can hand the executor: the fake venue, whose deliberate divergences are the port's design pressure. Its balances and instrument reads serve the diff --git a/tests/test_paper_equities_profile.py b/tests/test_paper_equities_profile.py index 057b13f0..237cb291 100644 --- a/tests/test_paper_equities_profile.py +++ b/tests/test_paper_equities_profile.py @@ -699,7 +699,9 @@ def test_build_broker_alpaca_missing_secrets_names_the_venue_and_env_vars(tmp_pa def test_build_broker_refuses_a_venue_without_cli_wiring(tmp_path, monkeypatch): """A name that RESOLVES to an adapter but has no credential wiring in the CLI (fake, robinhood -- installed in dev) is refused with the two names that do have wiring, rather - than constructing an adapter that can never reach its venue.""" + than constructing an adapter that can never reach its venue. For robinhood the refusal + also names the WHY: the Ed25519 credential wiring its transport signs with, which the CLI + does not carry by choice.""" from keel.commands._common import _build_broker monkeypatch.chdir(tmp_path) @@ -709,6 +711,11 @@ def test_build_broker_refuses_a_venue_without_cli_wiring(tmp_path, monkeypatch): with pytest.raises(RuntimeError, match="coinbase.*alpaca"): _build_broker(config) + config = load_config(str(_write_config(tmp_path, "\nbroker:\n name: robinhood\n"))) + + with pytest.raises(RuntimeError, match="Ed25519.*dev-only"): + _build_broker(config) + def test_build_broker_unknown_name_surfaces_the_entry_point_list(tmp_path, monkeypatch): """A name with no entry point at all fails through the registry's own error, which