From cef6518d8a2c0b6899c94efece441ef1bc6c7d6a Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Wed, 5 Aug 2026 11:18:00 -0400 Subject: [PATCH 1/3] feat(guards): add rail 18, the settlement-currency hard rail The 2026-08-05 Coinbase asset-class feasibility study (R1) verified by execution that a SELL of `ADA-28AUG26-CDE` -- a Coinbase futures contract -- passed EVERY rail on the real live config. `guards._asset` reduces it to `ADA`, which is allowlisted, so rail 1 waves it through; the only rail that stopped the BUY (rail 13) is BUY-only and skipped entirely in paper. Rail 18 vetoes any intent whose `quote_currency_of(product_id)` is not in `config.settlement_currencies`. It runs on BOTH sides and in EVERY mode -- deliberately NOT in `LIVE_STATE_RAILS`, since it needs no broker and no live account state, which is exactly what made the compensating rail insufficient. It fails closed on an unresolvable id (every 64-hex equity product) and returns a violation string rather than raising, so a malformed id can never turn a veto into a crashed agent cycle. The allowed set is config, not a hardcode: `settlement_currencies` (default {USD, USDC}, non-empty, uppercased at parse) is the operator's escape hatch. `executor._reconcile_settlement_currencies` runs at the top of `_run_order` and raises `ConfigError` if that set is not a subset of the adapter's declared `BrokerCapabilities.quote_currencies`, so config cannot widen the rail past what the venue settles in; it is a no-op with no broker (the paper path) or a broker that declares nothing. Accepted behaviour change: the default set also rejects the ~120 non-USD/USDC SPOT pairs Coinbase lists (BTC-EUR, *-USDT, ...). Blast radius on the deployment is nil -- every live rule is BASE-USD and all three configs set quote_currency: USD. Co-Authored-By: Claude Opus 5 (1M context) --- config.yaml | 12 ++ ...-08-05-coinbase-asset-class-feasibility.md | 55 ++++++-- keel/execution/executor.py | 70 +++++++++- keel/execution/guards.py | 66 ++++++++- keel/templates/config.live.yaml | 12 ++ keel/templates/config.yaml | 12 ++ packages/keel-core/keel_core/config.py | 51 +++++++ tests/baseline/config_serialize.py | 4 + tests/execution/test_executor.py | 92 +++++++++++++ tests/execution/test_guards.py | 127 ++++++++++++++++++ tests/fixtures/config_golden_defaults.json | 4 + tests/fixtures/config_golden_full.json | 3 + tests/fixtures/config_golden_full.yaml | 5 + tests/test_config.py | 48 +++++++ 14 files changed, 545 insertions(+), 16 deletions(-) diff --git a/config.yaml b/config.yaml index 713facc1..17310eb6 100644 --- a/config.yaml +++ b/config.yaml @@ -83,6 +83,18 @@ paper: # `keel assets holdings`. quote_currency: USD +# Rail 18 (settlement-currency): the settlement legs an order is ALLOWED to have. Every intent +# is vetoed unless `quote_currency_of(product_id)` -- the quote leg of the product itself -- is +# in this set, on BOTH sides and in every mode including paper. This is the class gate: it is +# what stops a Coinbase futures contract (`ADA-28AUG26-CDE`, whose quote leg parses as the venue +# suffix `CDE`) or an equity product (a 64-char hash with no quote leg at all) from ever being +# ordered, even though `ADA` is allowlisted. It deliberately also excludes non-USD/USDC SPOT +# pairs (`BTC-EUR`, `*-USDT`); widen this list if you actually trade one -- it is the escape +# hatch, and the venue must declare it settles in whatever you add here. +settlement_currencies: + - USD + - USDC + subscription: # The SIMULATOR's assumed fee-free monthly volume. The LIVE rail-14 cap is not set here -- # it comes from the attested record: `keel subscription attest --venue coinbase --tier `. diff --git a/docs/experiments/2026-08-05-coinbase-asset-class-feasibility.md b/docs/experiments/2026-08-05-coinbase-asset-class-feasibility.md index 4655c79d..8f9082c5 100644 --- a/docs/experiments/2026-08-05-coinbase-asset-class-feasibility.md +++ b/docs/experiments/2026-08-05-coinbase-asset-class-feasibility.md @@ -337,6 +337,10 @@ live path reads it today. ### ⚠️ A live fragility this probe surfaced, worth fixing regardless of every decision below +**This section records the state on 2026-08-05, before R1. R1 has since shipped and closes it — +rail 18 (`settlement_currency`) vetoes every id below on both sides and in every mode. Read what +follows as the measurement that motivated the fix, not as current behaviour; see R1.** + keel refuses these products today **by accident, not by design** — and the accident is thinner than it looks. Both legs of it were probed: the rails let a live SELL through outright, and the "keel can never name one" leg holds only for the paths keel drives itself. @@ -580,8 +584,8 @@ crypto). Gold and silver are named prohibited in the KB. Do not start here. **Ranked.** -1. **R1 — Reject any intent whose settlement leg the adapter does not declare. (~1 hour. Do this - first.)** +1. ✅ **R1 — Reject any intent whose settlement leg the adapter does not declare. (~1 hour. Do this + first.) — SHIPPED 2026-08-05, see "What R1 actually shipped" below.** The whole class closes today, with no new instrument model, using machinery the rails already trust. `guards.check` already calls `quote_currency_of(intent.product_id)` at `guards.py:423`; the Coinbase adapter already declares @@ -612,6 +616,39 @@ crypto). Gold and silver are named prohibited in the KB. Do not start here. `ADA-28AUG26-CDE` is vetoed (`tests/execution/test_guards.py`). That test is half the deliverable — without it this silently regresses the next time the rails are edited, and the failure mode is a live short. + + **What R1 actually shipped.** **Rail 18, `settlement_currency`** (`keel/execution/guards.py`), + an un-overridable hard rail like every other: it vetoes any intent whose + `quote_currency_of(product_id)` is not in `config.settlement_currencies`. Three properties are + the whole point, and each has a test: + - **Every mode.** It is deliberately NOT in `LIVE_STATE_RAILS`, so `offline=True` does not skip + it. It needs no broker and no account state — which is precisely the defect in the rail it + replaces as last line of defence, since rail 13 is paper-exempt. + - **Both sides.** BUY and SELL. The SELL of `ADA-28AUG26-CDE` this document verified passing + every rail on the live config is now vetoed on that config, live and offline + (`tests/execution/test_guards.py`, + `test_rail18_a_SELL_of_a_futures_contract_on_an_allowlisted_asset_is_vetoed`, which also + asserts rail 1 still passes it — the hole, stated as a test). + - **Fails closed, never raises.** A 64-hex equity id resolves to `None` and is vetoed, not + admitted; a malformed id returns a violation string rather than an exception, so the + historical-order walk in `_open_exposure_by_asset` cannot turn one bad audit row into a + crashed cycle. R2's separate `_asset` item is untouched — `_asset` still does not validate. + + The allowed set is **config, not a hardcode**: `settlement_currencies` in + `packages/keel-core/keel_core/config.py` (default `{USD, USDC}`, non-empty, uppercased at + parse, documented in both shipped templates). The adapter's declaration stays the source of + truth in the direction that matters: `executor._reconcile_settlement_currencies` runs at the + top of `_run_order` — every order path funnels through it — and raises `ConfigError` if the + configured set is not a subset of `BrokerCapabilities.quote_currencies`, so config cannot widen + rail 18 past what the venue settles in. It is a no-op when there is no broker + (`agent.py:475`'s paper path) or when the broker declares no capabilities, which today includes + the live `data.cb_client.CoinbaseClient` — the executor is not yet wired to the port adapter, + so that reconciliation is armed for R2/the port migration rather than firing now. Rail 18 + itself does not depend on it. + + The non-USD/USDC **spot** rejection predicted above is real and was accepted: `BTC-EUR`, + `ETH-GBP`, `*-USDT` and friends are vetoed by the shipped default. Blast radius on the + deployment is nil, as measured here, and `settlement_currencies` is the escape hatch. 2. **R2 — Then make the spot-only assumption structural. (~2–4 days.)** R1 is a settlement-leg check, not an instrument model; it would not stop a hypothetical future product that happens to settle in USD. Turn `BrokerCapabilities.asset_classes` @@ -653,15 +690,17 @@ crypto). Gold and silver are named prohibited in the KB. Do not start here. - ❌ **Do not complete CFM futures onboarding "just to have the option."** It is the one blocker removable without code or a ruling, which makes it the one most likely to be removed - prematurely. It converts a hard 403 into an open order path while R1's and R2's gates do not yet - exist. + prematurely. It converts a hard 403 into an open order path. R1 has since shipped, so this is no + longer the last line of defence it was when this was written — but R2's gate still does not + exist, and R3's ruling is still unanswered. - ❌ **Do not fund or create a `CDE`-denominated balance on the account.** Rail 13's veto of a live futures BUY is **entirely** an artefact of `quote_currency_of` returning `"CDE"` and the broker having no such balance to report (`executor.py:336`, `guards.py:423`). A positive `CDE` balance - would satisfy rail 13 on its own terms and silently disarm the only rail standing between the - live config and a futures entry — no code change, no config change, and nothing in the logs - saying a defence was removed. Pair this with the onboarding warning above: those two account - actions are the whole distance between today and a live futures position. + would satisfy rail 13 on its own terms — no code change, no config change, and nothing in the + logs saying a defence was removed. Since R1, rail 18 vetoes that same `"CDE"` leg outright on + both sides and in every mode, so this no longer disarms the last rail; it remains a bad idea + for exactly the reason it was one, and the veto it would remove is a rail nobody should be + relying on twice. - ❌ **Do not add any futures product id to the `allowlist` of any config.** `guards._asset` reduces `SOL-28AUG26-CDE` to `SOL` and `GOL-25NOV26-CDE` to `GOL`; the allowlist cannot distinguish a contract from a coin. diff --git a/keel/execution/executor.py b/keel/execution/executor.py index 802c18db..296a0faf 100644 --- a/keel/execution/executor.py +++ b/keel/execution/executor.py @@ -1,7 +1,7 @@ """The order executor (P3 Task 4) -- turns a `Signal` into a guarded live order. `execute()` is the only path from a strategy `Signal` to a real order: it sizes the candidate -(`execution.sizing`), runs the twelve un-overridable §14 hard rails (`execution.guards.check`) +(`execution.sizing`), runs the seventeen un-overridable §14 hard rails (`execution.guards.check`) **before** anything reaches the broker, previews the order, honors the confirm/autonomous mode gate, places it, and writes a full audit trail to the `orders` table both before and after the broker call (so a crash mid-placement, or a broker-side rejection, still leaves a record). No path in @@ -51,6 +51,14 @@ gate; `_fetch_available_quote` swallows any exception from the call and returns `None`, which rail 13 then treats as fail-closed (vetoes the BUY) exactly like an unknown balance from a missing quote-currency account. SELL intents never fetch a balance (the rail exempts them). + +**Settlement-currency reconciliation (rail 18).** Rail 18 enforces the operator's configured +`settlement_currencies`; `_reconcile_settlement_currencies` enforces that the operator did not +configure a currency the VENUE does not settle in, by checking the set against the adapter's own +`BrokerCapabilities.quote_currencies`. It runs at the top of `_run_order` -- ahead of the rails, +because it is a statement about the config rather than about the intent -- and raises rather than +vetoing: an impossible settlement currency is a misconfiguration to fix, not a trade to skip. It +is a no-op when there is no broker (paper) or the broker declares no capabilities. """ from __future__ import annotations @@ -66,7 +74,7 @@ from keel_core.products import quote_currency_of from keel_core.telemetry import log_event, log_exception -from keel.config import Config +from keel.config import Config, ConfigError from keel.data.repository import Repository from keel.execution import guards, sizing from keel.execution.guards import OrderIntent @@ -300,6 +308,58 @@ def _fetch_available_quote(broker: Any, quote_currency: str | None) -> Decimal | return None +def _reconcile_settlement_currencies(broker: Any, config: Config) -> None: + """Refuse to trade a venue that does not settle in every currency rail 18 was told to allow. + + Rail 18 enforces `config.settlement_currencies`, which is the OPERATOR's set. This is the + other direction: the adapter's `BrokerCapabilities.quote_currencies` is the VENUE's own + declaration, and a config that names a currency outside it is asking the rail to admit + orders the venue cannot settle. Raises `ConfigError` -- loudly, before any order is placed -- + rather than degrading quietly, because there is no safe interpretation of the request and the + fix is a one-line config edit. + + Runs on the LIVE path only, which is where a broker exists to ask: + + - `broker is None` (`agent._paper_enter` passes it deliberately) -> nothing to reconcile + against, and a rehearsal that places nothing must not be stopped by a venue's declaration. + - a broker with no `capabilities()` -> also nothing to reconcile against. This is the live + broker today: `data.cb_client.CoinbaseClient` declares nothing (the declaration lives on + `keel_broker_coinbase.CoinbaseAdapter`, which the executor is not yet wired to). Refusing + to trade on a check that cannot be made would take the live path down to close a hole that + rail 18 already closes on every order regardless. + """ + if broker is None: + return + capabilities = getattr(broker, "capabilities", None) + if not callable(capabilities): + return + try: + declared = frozenset(capabilities().quote_currencies) + except Exception: + # An unreadable declaration is not the same claim as a narrow one, and rail 18 still + # enforces the configured set on this order. Log it and carry on rather than inventing a + # veto out of a broken adapter. + log_exception(logger, "executor.capabilities_read_failed") + return + + unsupported = frozenset(config.settlement_currencies) - declared + if unsupported: + log_event( + logger, + logging.ERROR, + "executor.settlement_currency_unsupported", + configured=sorted(config.settlement_currencies), + declared=sorted(declared), + unsupported=sorted(unsupported), + ) + raise ConfigError( + f"settlement_currencies: {sorted(unsupported)} is not settled by this venue, which " + f"declares {sorted(declared)}. Configuring a settlement currency the venue does not " + f"support would widen rail 18 past what can actually be traded -- narrow " + f"settlement_currencies in config.yaml." + ) + + def _build_intent( signal: Signal, broker: Any, @@ -404,6 +464,12 @@ def _run_order( now_ts: int, order_configuration: dict[str, Any] | None = None, ) -> ExecutionResult: + # Ahead of the rails, because it is a statement about the CONFIG the rails are about to be + # run with, not about this intent: rail 18's allowed set must not exceed what the venue + # declares it settles in. Every order path funnels through here, so this is the one place it + # has to live. + _reconcile_settlement_currencies(broker, config) + guard_result = guards.check(intent, repo, config, now_ts) if not guard_result.ok: log_event( diff --git a/keel/execution/guards.py b/keel/execution/guards.py index f1429a34..9399d8bf 100644 --- a/keel/execution/guards.py +++ b/keel/execution/guards.py @@ -1,10 +1,11 @@ """THE HARD RAILS (§14) — enforced before every order, un-overridable. -`check()` runs the twelve safety rails from the main spec's §14, plus three later, equally +`check()` runs the twelve safety rails from the main spec's §14, plus five later, equally un-overridable safety-critical rails: 13/14 added by Issue #59 (USDC-funding + monthly-allowance), -and 16, the consecutive-loss circuit breaker (Task 4), before any order is placed, in every -`auto_trade` mode (confirm *and* autonomous) and for both rule-trading and DCA order -classes. It never +16, the consecutive-loss circuit breaker (Task 4), 17, the withdrawal/`qabd` rail, and 18, the +settlement-currency rail — seventeen in all, since there is no rail 15. They run before any order +is placed, in every `auto_trade` mode (confirm *and* autonomous) and for both rule-trading and DCA +order classes. It never short-circuits: every violated rail is collected and reported so an operator (or the executor, Task 4) sees the full picture, not just the first trip-wire. @@ -70,6 +71,16 @@ and cannot disagree with itself. ENTRIES ONLY, and DCA-exempt like rail 11 (§12.6) -- a breaker that blocked exits would trap capital in a losing position, inverting its own purpose. Ships DISABLED (`config.money_mgmt.max_consecutive_losses` defaults to 0). + +Rail 18 (settlement-currency, safety-critical, un-overridable) is the ONLY rail that gates the +instrument CLASS rather than the trade: it vetoes any intent whose `quote_currency_of(product_id)` +is not in `config.settlement_currencies` (default `{USD, USDC}`). It closes the hole the +2026-08-05 Coinbase asset-class feasibility study found by execution -- rail 1 reduces +`ADA-28AUG26-CDE` to the allowlisted `ADA` and passes a futures contract, and the only rail that +incidentally stopped it (13) is BUY-only and skipped in paper. Unlike rails 13/17 this one runs in +EVERY mode and on BOTH sides, because it needs no broker and no live account state -- see the +rail's own comment for why that is the whole point, and for the deliberate spot pairs it also +excludes. """ from __future__ import annotations @@ -147,7 +158,7 @@ class OrderIntent: @dataclass(frozen=True) class GuardResult: - """The outcome of running all fifteen rails: `ok` iff `violations` is empty.""" + """The outcome of running all seventeen rails: `ok` iff `violations` is empty.""" ok: bool violations: list[str] @@ -248,7 +259,8 @@ def check( now_ts: int, offline: bool = False, ) -> GuardResult: - """Run all fifteen §14 (+ Issue #59, Task 4) hard rails against `intent`. Never short-circuits. + """Run all seventeen §14 (+ Issue #59, Task 4) hard rails against `intent`. Never + short-circuits. Called before every order in every `auto_trade` mode (confirm *and* autonomous) -- un-overridable. @@ -568,6 +580,48 @@ def check( "deliberately unaffected." ) + # 18. Settlement currency — the order's settlement leg must be one the operator configured + # (`config.settlement_currencies`, default USD/USDC). This is an INSTRUMENT-CLASS gate + # wearing a currency's clothes: `quote_currency_of` returns `"CDE"` for every Coinbase + # futures contract (`ADA-28AUG26-CDE`) and `None` for every equity product (a 64-char + # hash with no separator), so one comparison rejects both classes without keel needing an + # instrument model it does not have (feasibility study R1, + # `docs/experiments/2026-08-05-coinbase-asset-class-feasibility.md`). + # + # BOTH SIDES, and in EVERY MODE — deliberately not in `LIVE_STATE_RAILS`. That is the + # entire point: rail 13 incidentally vetoed a live futures BUY (no `CDE` balance exists, + # so it failed closed), but it is BUY-only and skipped offline, and the study verified by + # execution that a live SELL of `ADA-28AUG26-CDE` passed every rail on the real live + # config. This rail needs no broker and no account state precisely so paper cannot skip + # it. + # + # ⚠️ ACCEPTED BEHAVIOUR CHANGE, not an oversight: with the default `{USD, USDC}` this + # also rejects the ~120 non-USD/USDC SPOT pairs Coinbase lists (`BTC-EUR`, `ETH-GBP`, + # `SOL-INR`, and crypto-quoted pairs like `*-BTC`/`*-USDT`) -- which is what the Coinbase + # adapter's own `quote_currencies` declaration already says should happen. Nothing in the + # live deployment reaches one: every rule is `BASE-USD`, and all three deployment configs + # set `quote_currency: USD`, so `_history_product` can only construct `-USD` ids. An + # operator who wants a different set widens `settlement_currencies` in config.yaml -- + # that field is the escape hatch, which is why the set is not hardcoded here. + # + # Returns a VIOLATION, never raises, on an unparseable id. `_asset` and the rail + # machinery also run over historical filled orders (`_open_exposure_by_asset`), and an + # exception on one bad audit row would turn a veto into a crashed agent cycle -- strictly + # worse than the hole it closes. + settlement = quote_currency_of(intent.product_id) + if settlement is None: + violations.append( + f"settlement_currency: cannot resolve a settlement currency from " + f"{intent.product_id!r} -- failing closed. Allowed settlement currencies: " + f"{sorted(config.settlement_currencies)}" + ) + elif settlement not in config.settlement_currencies: + violations.append( + f"settlement_currency: {intent.product_id} settles in {settlement}, which is not one " + f"of the configured settlement_currencies {sorted(config.settlement_currencies)}. " + f"Only spot products quoted in a configured currency may be traded." + ) + for violation in violations: log_event( logger, diff --git a/keel/templates/config.live.yaml b/keel/templates/config.live.yaml index 9b7526e3..90d8d65a 100644 --- a/keel/templates/config.live.yaml +++ b/keel/templates/config.live.yaml @@ -94,6 +94,18 @@ paper: # `keel assets holdings`. quote_currency: USD +# Rail 18 (settlement-currency): the settlement legs an order is ALLOWED to have. Every intent +# is vetoed unless `quote_currency_of(product_id)` -- the quote leg of the product itself -- is +# in this set, on BOTH sides and in every mode including paper. This is the class gate: it is +# what stops a Coinbase futures contract (`ADA-28AUG26-CDE`, whose quote leg parses as the venue +# suffix `CDE`) or an equity product (a 64-char hash with no quote leg at all) from ever being +# ordered, even though `ADA` is allowlisted. It deliberately also excludes non-USD/USDC SPOT +# pairs (`BTC-EUR`, `*-USDT`); widen this list if you actually trade one -- it is the escape +# hatch, and the venue must declare it settles in whatever you add here. +settlement_currencies: + - USD + - USDC + subscription: # The SIMULATOR's assumed fee-free monthly volume. The LIVE rail-14 cap is not set here -- # it comes from the attested record: `keel subscription attest --venue coinbase --tier `. diff --git a/keel/templates/config.yaml b/keel/templates/config.yaml index 713facc1..17310eb6 100644 --- a/keel/templates/config.yaml +++ b/keel/templates/config.yaml @@ -83,6 +83,18 @@ paper: # `keel assets holdings`. quote_currency: USD +# Rail 18 (settlement-currency): the settlement legs an order is ALLOWED to have. Every intent +# is vetoed unless `quote_currency_of(product_id)` -- the quote leg of the product itself -- is +# in this set, on BOTH sides and in every mode including paper. This is the class gate: it is +# what stops a Coinbase futures contract (`ADA-28AUG26-CDE`, whose quote leg parses as the venue +# suffix `CDE`) or an equity product (a 64-char hash with no quote leg at all) from ever being +# ordered, even though `ADA` is allowlisted. It deliberately also excludes non-USD/USDC SPOT +# pairs (`BTC-EUR`, `*-USDT`); widen this list if you actually trade one -- it is the escape +# hatch, and the venue must declare it settles in whatever you add here. +settlement_currencies: + - USD + - USDC + subscription: # The SIMULATOR's assumed fee-free monthly volume. The LIVE rail-14 cap is not set here -- # it comes from the attested record: `keel subscription attest --venue coinbase --tier `. diff --git a/packages/keel-core/keel_core/config.py b/packages/keel-core/keel_core/config.py index 89c2d68d..6a5d0409 100644 --- a/packages/keel-core/keel_core/config.py +++ b/packages/keel-core/keel_core/config.py @@ -76,6 +76,14 @@ def _non_negative_int(value: Any, key: str) -> int: # caps ARE configured; only the shipped defaults stop being a silent, fabricated blocker. NON_BINDING_CAP_USD = Decimal("1000000000") # $1B -- not a real limit, just "don't bind" +# Rail 18 (settlement-currency): the settlement legs an order is allowed to spend/receive. +# Defaults to exactly what the Coinbase adapter declares it settles in +# (`keel_broker_coinbase.adapter._CAPABILITIES.quote_currencies`), so the shipped default and the +# venue's own statement agree without either being derived from the other. It is a FIELD rather +# than a constant in `guards.py` so an operator whose venue settles elsewhere has an escape +# hatch that is not a code edit -- see `Config.settlement_currencies`. +DEFAULT_SETTLEMENT_CURRENCIES = frozenset({"USD", "USDC"}) + @dataclass(frozen=True) class Caps: @@ -278,6 +286,13 @@ class Config: tiers: tuple[TierConfig, ...] = field(default_factory=_default_tiers) fees: FeesConfig = field(default_factory=FeesConfig) quote_currency: str = "USD" + # Rail 18's allowed settlement legs -- the currencies an order may settle in, matched against + # `quote_currency_of(product_id)`. NOT the same field as `quote_currency` above, which names + # the ONE currency this deployment trades in (it screens candidates and excludes the + # settlement balance from `keel assets holdings`); this is the SET a product's own quote leg + # must belong to for the order to be admitted at all. A frozenset, so it is a safe dataclass + # default without a factory and cannot be mutated out from under a rail. + settlement_currencies: frozenset[str] = DEFAULT_SETTLEMENT_CURRENCIES logging: LoggingConfig = field(default_factory=LoggingConfig) research: ResearchConfig = field(default_factory=ResearchConfig) @@ -323,6 +338,40 @@ def _parse_allowlist(raw: dict[str, Any]) -> list[str]: return list(allowlist) +def _parse_settlement_currencies(raw: dict[str, Any]) -> frozenset[str]: + """`settlement_currencies:`, uppercased -- rail 18's allowed set. Optional; absent falls back + to `DEFAULT_SETTLEMENT_CURRENCIES`. + + Uppercased at parse because `quote_currency_of` uppercases what it resolves, so the rail + compares like with like by construction rather than by every call site remembering to fold + case (the same normalisation `_history_product` applies when it CONSTRUCTS an id). + + A bare string is rejected rather than iterated: `settlement_currencies: USD` is a plausible + typo, and taking it as a sequence would silently configure `{"U", "S", "D"}` -- a set that + admits nothing and would veto every order with a message naming three letters. + """ + value = raw.get("settlement_currencies") + if value is None: + return DEFAULT_SETTLEMENT_CURRENCIES + if isinstance(value, str) or not isinstance(value, (list, tuple, set, frozenset)): + raise ConfigError( + f"settlement_currencies: must be a non-empty list of currency codes, got {value!r}" + ) + codes = set() + for entry in value: + if not isinstance(entry, str) or not entry.strip(): + raise ConfigError( + f"settlement_currencies: invalid entry {entry!r}; must be non-empty strings" + ) + codes.add(entry.strip().upper()) + if not codes: + raise ConfigError( + "settlement_currencies: empty; must be a non-empty list of currency codes. An empty " + "set would veto every order, since no product's quote leg could be in it." + ) + return frozenset(codes) + + def _parse_caps(raw: dict[str, Any]) -> Caps: caps_raw = raw.get("caps") if not caps_raw or not isinstance(caps_raw, dict): @@ -615,6 +664,7 @@ def load_config(path: str | Path) -> Config: tiers=_parse_tiers(raw), fees=_parse_fees(raw), quote_currency=quote_currency, + settlement_currencies=_parse_settlement_currencies(raw), logging=_parse_logging(raw), research=_parse_research(raw), ) @@ -641,6 +691,7 @@ def load_secrets(env_path: str | Path = ".env") -> dict: __all__ = [ "ConfigError", "NON_BINDING_CAP_USD", + "DEFAULT_SETTLEMENT_CURRENCIES", "Caps", "MarketDataConfig", "AutoTradeConfig", diff --git a/tests/baseline/config_serialize.py b/tests/baseline/config_serialize.py index 7aa97bb1..e287c06b 100644 --- a/tests/baseline/config_serialize.py +++ b/tests/baseline/config_serialize.py @@ -49,6 +49,10 @@ def _canonical(value: Any) -> Any: return {str(k): _canonical(value[k]) for k in sorted(value, key=str)} if isinstance(value, (list, tuple)): return [_canonical(v) for v in value] + # Sets are SORTED, not just listed: `Config.settlement_currencies` is a frozenset, whose + # iteration order is not stable across runs, and an unsorted golden would diff at random. + if isinstance(value, (set, frozenset)): + return sorted(_canonical(v) for v in value) if isinstance(value, (str, int, float, bool)) or value is None: return value # Enums and anything else with a meaningful str() -- e.g. Granularity. diff --git a/tests/execution/test_executor.py b/tests/execution/test_executor.py index e131b2da..7f8c3d37 100644 --- a/tests/execution/test_executor.py +++ b/tests/execution/test_executor.py @@ -18,12 +18,14 @@ from typing import Any import pytest +from keel_broker_api.capabilities import BrokerCapabilities from keel_core.subscription import SubscriptionStatus from keel.config import ( AutoTradeConfig, Caps, Config, + ConfigError, DcaConfig, MarketDataConfig, MoneyMgmtConfig, @@ -33,6 +35,7 @@ from keel.execution.executor import ( CancelUnavailable, ExecutionResult, + _reconcile_settlement_currencies, execute, place_bracket, roll_to_break_even, @@ -1520,3 +1523,92 @@ def test_no_account_at_all_for_the_required_currency_fails_closed(repo): assert result.placed is False assert result.preview is None assert any("unknown/unavailable" in v for v in result.vetoed_by) + + +# -- config cannot widen rail 18 past what the venue actually settles in ------------------------ + + +class DeclaringBroker(FakeBroker): + """A `FakeBroker` that also declares `BrokerCapabilities`, like the broker-port adapters do. + + The live broker today (`data.cb_client.CoinbaseClient`) declares nothing -- the declaration + lives on `keel_broker_coinbase.CoinbaseAdapter` -- so this fake models the adapter-shaped + broker the reconciliation actually has something to check against. + """ + + def __init__(self, quote_currencies: frozenset[str], **kwargs: Any) -> None: + super().__init__(**kwargs) + self._quote_currencies = quote_currencies + + def capabilities(self) -> BrokerCapabilities: + return BrokerCapabilities( + venue="fake", + supported_orders=frozenset({"market_ioc_quote", "limit_gtc"}), + supports_native_preview=True, + synthesizes_preview=False, + supports_fee_summary=False, + quote_currencies=self._quote_currencies, + asset_classes=frozenset({"spot"}), + ) + + +def test_a_settlement_currency_the_venue_does_not_declare_fails_loudly(repo): + """Rail 18's allowed set is the operator's, but the venue's declaration is the ceiling: a + config naming a currency Coinbase does not settle in is a misconfiguration, not a trade.""" + broker = DeclaringBroker(frozenset({"USD", "USDC"})) + signal = _enter_signal() + + with pytest.raises(ConfigError, match="EUR"): + execute( + signal, + broker, + repo, + _config(settlement_currencies=frozenset({"USD", "USDC", "EUR"})), + mode="autonomous", + now_ts=NOW_TS, + ) + + assert broker.preview_calls == [] + assert broker.place_calls == [] + assert repo.get_orders() == [] + + +def test_a_settlement_currency_set_within_the_venues_declaration_places(repo): + broker = DeclaringBroker(frozenset({"USD", "USDC"})) + + result = execute( + _enter_signal(), + broker, + repo, + _config(settlement_currencies=frozenset({"USD"})), + mode="autonomous", + now_ts=NOW_TS, + ) + + assert result.placed is True, result.vetoed_by + + +def test_a_broker_that_declares_no_capabilities_is_not_treated_as_a_veto(repo): + """`CoinbaseClient` -- the broker the live path actually constructs -- has no + `capabilities()`. There is nothing to reconcile against, and refusing to trade on that basis + would take the live path down for a check that cannot be made. Rail 18 still enforces the + configured set on every order regardless. + """ + broker = FakeBroker() + + result = execute( + _enter_signal(), + broker, + repo, + _config(settlement_currencies=frozenset({"USD", "EUR"})), + mode="autonomous", + now_ts=NOW_TS, + ) + + assert result.placed is True, result.vetoed_by + + +def test_the_venue_reconciliation_is_skipped_when_there_is_no_broker(): + """`agent._paper_enter` passes `broker=None` on purpose. Paper has no venue to reconcile + against, and raising there would stop a rehearsal that places nothing.""" + _reconcile_settlement_currencies(None, _config(settlement_currencies=frozenset({"EUR"}))) diff --git a/tests/execution/test_guards.py b/tests/execution/test_guards.py index 0cb1017a..f145d6b6 100644 --- a/tests/execution/test_guards.py +++ b/tests/execution/test_guards.py @@ -16,6 +16,7 @@ from keel_core.subscription import SubscriptionStatus from keel.config import ( + DEFAULT_SETTLEMENT_CURRENCIES, AutoTradeConfig, Caps, Config, @@ -66,6 +67,7 @@ def _config( unsubscribed_allowance_usd: Decimal = Decimal("0"), pacing: str = "opportunistic", max_consecutive_losses: int = 0, + settlement_currencies: frozenset[str] = DEFAULT_SETTLEMENT_CURRENCIES, ) -> Config: return Config( allowlist=list(allowlist), @@ -88,6 +90,7 @@ def _config( unsubscribed_allowance_usd=unsubscribed_allowance_usd, pacing=pacing, ), + settlement_currencies=settlement_currencies, ) @@ -994,6 +997,130 @@ def test_rail17_is_ENTRIES_ONLY_sells_are_never_blocked(repo: Repository) -> Non assert "withdrawal_capability" not in _keys(result), state +# -- rail 18: settlement currency (instrument admission, every mode, both sides) ---------------- +# +# These exist because of `docs/experiments/2026-08-05-coinbase-asset-class-feasibility.md` (R1), +# which established by execution that a SELL of `ADA-28AUG26-CDE` -- a Coinbase futures contract +# -- passed EVERY rail on the real live config. `_asset` reduces it to `ADA`, which is +# allowlisted, so rail 1 waves it through; the only rail that stopped the BUY (rail 13) is +# BUY-only and skipped in paper. Rail 18 is the class gate that was missing. + +#: The futures contract from the study. `_asset` -> "ADA" (allowlisted), `quote_currency_of` -> +#: "CDE", which is a Coinbase venue suffix, not a settlement leg. +FUTURES_PRODUCT_ID = "ADA-28AUG26-CDE" +#: A Coinbase EQUITY product id: an opaque 64-char hash with no separator at all, so +#: `quote_currency_of` returns None and the rail must fail CLOSED rather than pass it. +EQUITY_PRODUCT_ID = "ac568fb9e6c5a67da94f065a49fb7b0c59b7b258cfdf0a3b1560849071c3b05e" + +#: The live deployment's allowlist verbatim (`~/keel/config.live-sandbox.yaml`) -- the point of +#: the regression tests is that this allowlist does NOT stop the contract, and rail 18 does. +LIVE_ALLOWLIST = ("BTC", "ETH", "PAXG", "ADA", "XLM") + + +def test_rail18_a_SELL_of_a_futures_contract_on_an_allowlisted_asset_is_vetoed( + repo: Repository, +) -> None: + """The exact hole the feasibility study found: SELL `ADA-28AUG26-CDE`, live config, no veto. + + Rail 1 is not the defence here and never was -- assert that too, so a future reader cannot + mistake this for a duplicate allowlist test. + """ + intent = _intent(product_id=FUTURES_PRODUCT_ID, side=Side.SELL) + result = check(intent, repo, _config(allowlist=LIVE_ALLOWLIST), NOW_TS) + + assert "settlement_currency" in _keys(result) + assert "halal_allowlist" not in _keys(result), "rail 1 passes the contract -- that is the hole" + assert result.ok is False + + +def test_rail18_a_futures_contract_is_vetoed_offline_too(repo: Repository) -> None: + """Paper/offline is where the compensating rail (13) is skipped, so rail 18 must NOT be one + of `LIVE_STATE_RAILS`: it needs no broker and no live account state.""" + intent = _intent( + product_id=FUTURES_PRODUCT_ID, + side=Side.SELL, + available_quote=None, + withdrawals_enabled=None, + ) + result = check(intent, repo, _config(allowlist=LIVE_ALLOWLIST), NOW_TS, offline=True) + + assert "settlement_currency" in _keys(result) + assert "settlement_currency" not in result.skipped_rails + assert "settlement_currency" not in LIVE_STATE_RAILS + + +def test_rail18_a_futures_contract_is_vetoed_on_a_BUY(repo: Repository) -> None: + result = check( + _intent(product_id=FUTURES_PRODUCT_ID), repo, _config(allowlist=LIVE_ALLOWLIST), NOW_TS + ) + assert "settlement_currency" in _keys(result) + + +def test_rail18_an_equity_hash_product_id_fails_CLOSED(repo: Repository) -> None: + """`quote_currency_of` returns None for a 64-hex equity id. Unknown is not permission.""" + for side in (Side.BUY, Side.SELL): + for offline in (False, True): + intent = _intent(product_id=EQUITY_PRODUCT_ID, side=side) + result = check(intent, repo, _config(), NOW_TS, offline=offline) + assert "settlement_currency" in _keys(result), (side, offline) + + +def test_rail18_never_raises_on_a_malformed_product_id(repo: Repository) -> None: + """A veto, never an exception: the rail machinery also runs over historical filled orders + (`_open_exposure_by_asset`), where one bad audit row must not crash the agent cycle.""" + for product_id in ("", "-", "BTC-", "-USD", " ", "BTCUSD"): + result = check(_intent(product_id=product_id), repo, _config(), NOW_TS) + assert "settlement_currency" in _keys(result), product_id + + +def test_rail18_passes_ordinary_usd_and_usdc_spot(repo: Repository) -> None: + for product_id in ("ADA-USD", "BTC-USDC"): + result = check( + _intent(product_id=product_id), repo, _config(allowlist=LIVE_ALLOWLIST), NOW_TS + ) + assert "settlement_currency" not in _keys(result), product_id + + +def test_rail18_passes_a_lowercase_settlement_leg(repo: Repository) -> None: + """`quote_currency_of` uppercases, and the configured set is uppercased at parse, so the + comparison is case-insensitive by construction rather than by luck.""" + result = check(_intent(product_id="BTC-usdc"), repo, _config(), NOW_TS) + assert "settlement_currency" not in _keys(result) + + +def test_rail18_reads_the_allowed_set_from_config_not_a_hardcode(repo: Repository) -> None: + """The configured set is the operator's escape hatch -- widening it admits `-EUR` spot, and + narrowing it below the default takes `-USDC` away. Neither is hardcoded in guards.""" + config = _config(settlement_currencies=frozenset({"EUR"})) + + admitted = check(_intent(product_id="BTC-EUR"), repo, config, NOW_TS) + rejected = check(_intent(product_id="BTC-USD"), repo, config, NOW_TS) + + assert "settlement_currency" not in _keys(admitted) + assert "settlement_currency" in _keys(rejected) + + +def test_rail18_default_rejects_non_usd_usdc_spot(repo: Repository) -> None: + """A DELIBERATE, accepted behaviour change: ~120 non-USD/USDC spot pairs Coinbase lists are + now rejected by default. Nothing in the live deployment trades one.""" + for product_id in ("BTC-EUR", "ETH-GBP", "BTC-USDT"): + result = check(_intent(product_id=product_id), repo, _config(), NOW_TS) + assert "settlement_currency" in _keys(result), product_id + + +def test_rail18_violation_names_the_product_the_currency_and_the_allowed_set( + repo: Repository, +) -> None: + """An operator must be able to act on the message without reading this module.""" + result = check( + _intent(product_id=FUTURES_PRODUCT_ID), repo, _config(allowlist=LIVE_ALLOWLIST), NOW_TS + ) + violation = next(v for v in result.violations if v.startswith("settlement_currency")) + assert FUTURES_PRODUCT_ID in violation + assert "CDE" in violation + assert "USD" in violation and "USDC" in violation + + # -- offline mode (paper trading only) ----------------------------------------- diff --git a/tests/fixtures/config_golden_defaults.json b/tests/fixtures/config_golden_defaults.json index ac3223b0..41c3c722 100644 --- a/tests/fixtures/config_golden_defaults.json +++ b/tests/fixtures/config_golden_defaults.json @@ -55,6 +55,10 @@ "slope_floor": "-0.5" }, "risk_pct": "0.01", + "settlement_currencies": [ + "USD", + "USDC" + ], "subscription": { "assumed_free_volume_usd": "500", "pacing": "opportunistic", diff --git a/tests/fixtures/config_golden_full.json b/tests/fixtures/config_golden_full.json index fe4ccf73..1211700f 100644 --- a/tests/fixtures/config_golden_full.json +++ b/tests/fixtures/config_golden_full.json @@ -60,6 +60,9 @@ "slope_floor": "-0.75" }, "risk_pct": "0.0125", + "settlement_currencies": [ + "USDC" + ], "subscription": { "assumed_free_volume_usd": "1234.5", "pacing": "even_daily", diff --git a/tests/fixtures/config_golden_full.yaml b/tests/fixtures/config_golden_full.yaml index 8e384c1a..cbeccedb 100644 --- a/tests/fixtures/config_golden_full.yaml +++ b/tests/fixtures/config_golden_full.yaml @@ -83,6 +83,11 @@ fees: # Non-default on purpose (the default is USD) -- see the `tiers` note above. quote_currency: USDC +# Rail 18's allowed settlement legs. Non-default on purpose (the default is [USD, USDC]) and +# lowercase on purpose, so the golden pins the case-folding too. +settlement_currencies: + - usdc + logging: verbose: true file: keel-golden.log diff --git a/tests/test_config.py b/tests/test_config.py index c7d28e94..2d478de1 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -177,6 +177,54 @@ def test_load_config_quote_currency_empty_raises_configerror(write_config): load_config(path) +# -- settlement_currencies (rail 18's allowed set) ------------------------------------------ + + +def test_load_config_settlement_currencies_defaults_to_usd_and_usdc(valid_config_path): + """Absent from `config.yaml` -- the venue's own declared set, which is what rail 18 should + enforce when the operator has expressed no opinion.""" + config = load_config(valid_config_path) + + assert config.settlement_currencies == frozenset({"USD", "USDC"}) + + +def test_load_config_settlement_currencies_are_uppercased(write_config): + """`quote_currency_of` uppercases what it resolves, so the configured set must be + uppercased too or rail 18 would compare `'USD'` against `'usd'` and veto a funded order.""" + text = VALID_CONFIG_YAML + "\nsettlement_currencies:\n - usd\n - UsDc\n" + path = write_config(text) + + config = load_config(path) + + assert config.settlement_currencies == frozenset({"USD", "USDC"}) + + +def test_load_config_settlement_currencies_empty_raises_configerror(write_config): + """An empty set would veto every order ever placed -- almost certainly a typo, never intent.""" + text = VALID_CONFIG_YAML + "\nsettlement_currencies: []\n" + path = write_config(text) + + with pytest.raises(ConfigError, match="settlement_currencies"): + load_config(path) + + +def test_load_config_settlement_currencies_bare_string_raises_configerror(write_config): + """`settlement_currencies: USD` iterates into `{'U', 'S', 'D'}` if taken as a sequence.""" + text = VALID_CONFIG_YAML + "\nsettlement_currencies: USD\n" + path = write_config(text) + + with pytest.raises(ConfigError, match="settlement_currencies"): + load_config(path) + + +def test_load_config_settlement_currencies_invalid_entry_raises_configerror(write_config): + text = VALID_CONFIG_YAML + "\nsettlement_currencies:\n - USD\n - ''\n" + path = write_config(text) + + with pytest.raises(ConfigError, match="settlement_currencies"): + load_config(path) + + def test_load_config_promotion_defaults_are_canonical_proving_floors(write_config): """Phase-1 placeholders (30 trades / 40% win) were replaced with the canonical proving-gate floors from spec §6.2/§11 (Issue #66): 100 trades / 55% win rate. From 7260a337e2d1565f21c3b686a34401e25d5fb075 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Wed, 5 Aug 2026 11:38:13 -0400 Subject: [PATCH 2/3] fix(config): cross-validate quote_currency against rail 18, and drop the venue reconciliation Independent-review findings on the rail-18 branch. **quote_currency and settlement_currencies are now cross-validated at load.** They describe the same leg from two directions and nothing made them agree. `_history_product` builds every id keel can name as `f"{asset}-{quote_currency}"`, and rail 18 vetoes any id whose quote leg is outside `settlement_currencies` -- so `quote_currency: EUR` against the shipped default `{USD, USDC}` loaded cleanly and then had EVERY order the deployment could construct vetoed, forever, with a rail message naming a currency the operator never typed. That is the "silent unfixable rejection" `_history_product`'s own docstring was written to prevent. `load_config` now raises `ConfigError` naming both values and both fixes. All three deployment configs set `quote_currency: USD` with no `settlement_currencies` key, so they are unaffected. **`_reconcile_settlement_currencies` is removed, not patched.** It checked the configured set against the adapter's `BrokerCapabilities.quote_currencies` and raised `ConfigError` inside `_run_order`. Three verified reasons: - It cannot fire in production. The only broker the live path constructs is `data.cb_client.CoinbaseClient`, which has no `capabilities()`; the declaration lives on the not-yet-wired `CoinbaseAdapter`. `broker=None` (paper) is a no-op too. Dead code that reads as a defence. - It carried a latent position-trap. `agent.run_once` has no `except` -- only `try:`/`finally:` -- and `agent.loop` does not catch either, so a `ConfigError` out of `_run_order` kills the cycle. `_handle_exits` runs BEFORE entries, so the first EXIT order would raise and the cycle would die with the position unmanaged: no bracket, no stop roll, no scale-out. Rail 16's docstring condemns exactly this ("a breaker that blocked exits would trap capital in a losing position, inverting its own purpose"). - It broke the fake adapter out of the box: `keel_broker_fake` declares `quote_currencies={"USD"}`, which reconciles against the shipped `{USD, USDC}` default into an immediate `ConfigError`. The check belongs with the broker-port migration that makes `capabilities()` reachable, and as a LOAD-time check rather than a per-order raise. The feasibility doc's R1 block is corrected to say so instead of describing it as shipped. Rail 18 is untouched and never depended on it. Also: the operator-facing comment claimed rail 18 stops futures/equity products full stop; it does so only while Coinbase keeps suffixing them with a non-currency venue code (`BTC-PERP-USD` would parse as `USD` and pass, verified), so the qualifier is now in all three shipped configs next to R2's residual. And `_parse_settlement_currencies` treats an explicit YAML null the same as `[]` -- an absent key still defaults -- and shape-checks each code, so `['US D']` fails at load rather than sitting in the set admitting nothing. Regression proof: deleting rail 18's body fails all 8 `test_rail18_*` tests; restored, 1698 pass. Co-Authored-By: Claude Opus 5 (1M context) --- config.yaml | 10 +- ...-08-05-coinbase-asset-class-feasibility.md | 35 +++++-- keel/execution/executor.py | 68 +------------- keel/templates/config.live.yaml | 10 +- keel/templates/config.yaml | 10 +- packages/keel-core/keel_core/config.py | 68 ++++++++++++-- tests/execution/test_executor.py | 92 ------------------- tests/test_config.py | 72 +++++++++++++++ 8 files changed, 181 insertions(+), 184 deletions(-) diff --git a/config.yaml b/config.yaml index 17310eb6..ee7a39f2 100644 --- a/config.yaml +++ b/config.yaml @@ -88,9 +88,13 @@ quote_currency: USD # in this set, on BOTH sides and in every mode including paper. This is the class gate: it is # what stops a Coinbase futures contract (`ADA-28AUG26-CDE`, whose quote leg parses as the venue # suffix `CDE`) or an equity product (a 64-char hash with no quote leg at all) from ever being -# ordered, even though `ADA` is allowlisted. It deliberately also excludes non-USD/USDC SPOT -# pairs (`BTC-EUR`, `*-USDT`); widen this list if you actually trade one -- it is the escape -# hatch, and the venue must declare it settles in whatever you add here. +# ordered, even though `ADA` is allowlisted. Note the bound: this is a check on the SETTLEMENT +# LEG, not an instrument model, so it closes those classes only while Coinbase keeps suffixing +# them with a non-currency venue code -- a hypothetical `BTC-PERP-USD` parses as `USD` and would +# pass. It deliberately also excludes non-USD/USDC SPOT pairs (`BTC-EUR`, `*-USDT`); widen this +# list if you actually trade one -- it is the escape hatch, and the venue must settle in +# whatever you add here. `quote_currency` above must be one of these, or every id keel builds +# would be vetoed by this rail; the config fails to load if the two disagree. settlement_currencies: - USD - USDC diff --git a/docs/experiments/2026-08-05-coinbase-asset-class-feasibility.md b/docs/experiments/2026-08-05-coinbase-asset-class-feasibility.md index 8f9082c5..eaa6ceac 100644 --- a/docs/experiments/2026-08-05-coinbase-asset-class-feasibility.md +++ b/docs/experiments/2026-08-05-coinbase-asset-class-feasibility.md @@ -635,16 +635,31 @@ crypto). Gold and silver are named prohibited in the KB. Do not start here. crashed cycle. R2's separate `_asset` item is untouched — `_asset` still does not validate. The allowed set is **config, not a hardcode**: `settlement_currencies` in - `packages/keel-core/keel_core/config.py` (default `{USD, USDC}`, non-empty, uppercased at - parse, documented in both shipped templates). The adapter's declaration stays the source of - truth in the direction that matters: `executor._reconcile_settlement_currencies` runs at the - top of `_run_order` — every order path funnels through it — and raises `ConfigError` if the - configured set is not a subset of `BrokerCapabilities.quote_currencies`, so config cannot widen - rail 18 past what the venue settles in. It is a no-op when there is no broker - (`agent.py:475`'s paper path) or when the broker declares no capabilities, which today includes - the live `data.cb_client.CoinbaseClient` — the executor is not yet wired to the port adapter, - so that reconciliation is armed for R2/the port migration rather than firing now. Rail 18 - itself does not depend on it. + `packages/keel-core/keel_core/config.py` (default `{USD, USDC}`, non-empty, uppercased and + shape-checked at parse, documented in both shipped templates). `load_config` also refuses a + config whose `quote_currency` is outside that set: `_history_product` builds every id keel can + name as `f"{asset}-{quote_currency}"`, so the two disagreeing means every order the deployment + can construct is vetoed by rail 18 forever, with nothing at load time saying why. + + **What was deliberately NOT shipped: the venue-declaration check.** The obvious companion — + reconcile the configured set against the adapter's own `BrokerCapabilities.quote_currencies` + and refuse to trade if config is wider than the venue — was written, then removed before merge. + Two reasons, both verified: + - **It cannot fire today.** The only broker the live path constructs is + `data.cb_client.CoinbaseClient`, which has no `capabilities()` at all; the declaration lives + on `keel_broker_coinbase.CoinbaseAdapter`, which the executor is not yet wired to. `broker=None` + (paper) is a no-op too. So on every real path it is dead code that reads as a defence. + - **A per-order raise on the exit path can trap a position.** It ran inside `_run_order` and + raised `ConfigError`. `agent.run_once` has no `except` — only `try:`/`finally:` — and + `agent.loop` does not catch either, so the raise would kill the cycle; and `_handle_exits` + runs BEFORE entries, so the first exit order would be the one to die. No bracket, no stop + roll, no scale-out, position left unmanaged. That is exactly what rail 16's docstring + condemns: "a breaker that blocked exits would trap capital in a losing position, inverting + its own purpose." A misconfiguration check paid for in trapped capital is the wrong trade. + + It belongs with the broker-port migration that makes `capabilities()` reachable — at which + point it should be a **load-time** check against the adapter's declaration, not a per-order + raise. Rail 18 does not depend on it and never did. The non-USD/USDC **spot** rejection predicted above is real and was accepted: `BTC-EUR`, `ETH-GBP`, `*-USDT` and friends are vetoed by the shipped default. Blast radius on the diff --git a/keel/execution/executor.py b/keel/execution/executor.py index 296a0faf..6c2cbb50 100644 --- a/keel/execution/executor.py +++ b/keel/execution/executor.py @@ -51,14 +51,6 @@ gate; `_fetch_available_quote` swallows any exception from the call and returns `None`, which rail 13 then treats as fail-closed (vetoes the BUY) exactly like an unknown balance from a missing quote-currency account. SELL intents never fetch a balance (the rail exempts them). - -**Settlement-currency reconciliation (rail 18).** Rail 18 enforces the operator's configured -`settlement_currencies`; `_reconcile_settlement_currencies` enforces that the operator did not -configure a currency the VENUE does not settle in, by checking the set against the adapter's own -`BrokerCapabilities.quote_currencies`. It runs at the top of `_run_order` -- ahead of the rails, -because it is a statement about the config rather than about the intent -- and raises rather than -vetoing: an impossible settlement currency is a misconfiguration to fix, not a trade to skip. It -is a no-op when there is no broker (paper) or the broker declares no capabilities. """ from __future__ import annotations @@ -74,7 +66,7 @@ from keel_core.products import quote_currency_of from keel_core.telemetry import log_event, log_exception -from keel.config import Config, ConfigError +from keel.config import Config from keel.data.repository import Repository from keel.execution import guards, sizing from keel.execution.guards import OrderIntent @@ -308,58 +300,6 @@ def _fetch_available_quote(broker: Any, quote_currency: str | None) -> Decimal | return None -def _reconcile_settlement_currencies(broker: Any, config: Config) -> None: - """Refuse to trade a venue that does not settle in every currency rail 18 was told to allow. - - Rail 18 enforces `config.settlement_currencies`, which is the OPERATOR's set. This is the - other direction: the adapter's `BrokerCapabilities.quote_currencies` is the VENUE's own - declaration, and a config that names a currency outside it is asking the rail to admit - orders the venue cannot settle. Raises `ConfigError` -- loudly, before any order is placed -- - rather than degrading quietly, because there is no safe interpretation of the request and the - fix is a one-line config edit. - - Runs on the LIVE path only, which is where a broker exists to ask: - - - `broker is None` (`agent._paper_enter` passes it deliberately) -> nothing to reconcile - against, and a rehearsal that places nothing must not be stopped by a venue's declaration. - - a broker with no `capabilities()` -> also nothing to reconcile against. This is the live - broker today: `data.cb_client.CoinbaseClient` declares nothing (the declaration lives on - `keel_broker_coinbase.CoinbaseAdapter`, which the executor is not yet wired to). Refusing - to trade on a check that cannot be made would take the live path down to close a hole that - rail 18 already closes on every order regardless. - """ - if broker is None: - return - capabilities = getattr(broker, "capabilities", None) - if not callable(capabilities): - return - try: - declared = frozenset(capabilities().quote_currencies) - except Exception: - # An unreadable declaration is not the same claim as a narrow one, and rail 18 still - # enforces the configured set on this order. Log it and carry on rather than inventing a - # veto out of a broken adapter. - log_exception(logger, "executor.capabilities_read_failed") - return - - unsupported = frozenset(config.settlement_currencies) - declared - if unsupported: - log_event( - logger, - logging.ERROR, - "executor.settlement_currency_unsupported", - configured=sorted(config.settlement_currencies), - declared=sorted(declared), - unsupported=sorted(unsupported), - ) - raise ConfigError( - f"settlement_currencies: {sorted(unsupported)} is not settled by this venue, which " - f"declares {sorted(declared)}. Configuring a settlement currency the venue does not " - f"support would widen rail 18 past what can actually be traded -- narrow " - f"settlement_currencies in config.yaml." - ) - - def _build_intent( signal: Signal, broker: Any, @@ -464,12 +404,6 @@ def _run_order( now_ts: int, order_configuration: dict[str, Any] | None = None, ) -> ExecutionResult: - # Ahead of the rails, because it is a statement about the CONFIG the rails are about to be - # run with, not about this intent: rail 18's allowed set must not exceed what the venue - # declares it settles in. Every order path funnels through here, so this is the one place it - # has to live. - _reconcile_settlement_currencies(broker, config) - guard_result = guards.check(intent, repo, config, now_ts) if not guard_result.ok: log_event( diff --git a/keel/templates/config.live.yaml b/keel/templates/config.live.yaml index 90d8d65a..9a1ccccd 100644 --- a/keel/templates/config.live.yaml +++ b/keel/templates/config.live.yaml @@ -99,9 +99,13 @@ quote_currency: USD # in this set, on BOTH sides and in every mode including paper. This is the class gate: it is # what stops a Coinbase futures contract (`ADA-28AUG26-CDE`, whose quote leg parses as the venue # suffix `CDE`) or an equity product (a 64-char hash with no quote leg at all) from ever being -# ordered, even though `ADA` is allowlisted. It deliberately also excludes non-USD/USDC SPOT -# pairs (`BTC-EUR`, `*-USDT`); widen this list if you actually trade one -- it is the escape -# hatch, and the venue must declare it settles in whatever you add here. +# ordered, even though `ADA` is allowlisted. Note the bound: this is a check on the SETTLEMENT +# LEG, not an instrument model, so it closes those classes only while Coinbase keeps suffixing +# them with a non-currency venue code -- a hypothetical `BTC-PERP-USD` parses as `USD` and would +# pass. It deliberately also excludes non-USD/USDC SPOT pairs (`BTC-EUR`, `*-USDT`); widen this +# list if you actually trade one -- it is the escape hatch, and the venue must settle in +# whatever you add here. `quote_currency` above must be one of these, or every id keel builds +# would be vetoed by this rail; the config fails to load if the two disagree. settlement_currencies: - USD - USDC diff --git a/keel/templates/config.yaml b/keel/templates/config.yaml index 17310eb6..ee7a39f2 100644 --- a/keel/templates/config.yaml +++ b/keel/templates/config.yaml @@ -88,9 +88,13 @@ quote_currency: USD # in this set, on BOTH sides and in every mode including paper. This is the class gate: it is # what stops a Coinbase futures contract (`ADA-28AUG26-CDE`, whose quote leg parses as the venue # suffix `CDE`) or an equity product (a 64-char hash with no quote leg at all) from ever being -# ordered, even though `ADA` is allowlisted. It deliberately also excludes non-USD/USDC SPOT -# pairs (`BTC-EUR`, `*-USDT`); widen this list if you actually trade one -- it is the escape -# hatch, and the venue must declare it settles in whatever you add here. +# ordered, even though `ADA` is allowlisted. Note the bound: this is a check on the SETTLEMENT +# LEG, not an instrument model, so it closes those classes only while Coinbase keeps suffixing +# them with a non-currency venue code -- a hypothetical `BTC-PERP-USD` parses as `USD` and would +# pass. It deliberately also excludes non-USD/USDC SPOT pairs (`BTC-EUR`, `*-USDT`); widen this +# list if you actually trade one -- it is the escape hatch, and the venue must settle in +# whatever you add here. `quote_currency` above must be one of these, or every id keel builds +# would be vetoed by this rail; the config fails to load if the two disagree. settlement_currencies: - USD - USDC diff --git a/packages/keel-core/keel_core/config.py b/packages/keel-core/keel_core/config.py index 6a5d0409..bb408a55 100644 --- a/packages/keel-core/keel_core/config.py +++ b/packages/keel-core/keel_core/config.py @@ -8,6 +8,7 @@ from __future__ import annotations +import re from dataclasses import dataclass, field from decimal import Decimal, InvalidOperation from pathlib import Path @@ -84,6 +85,15 @@ def _non_negative_int(value: Any, key: str) -> int: # hatch that is not a code edit -- see `Config.settlement_currencies`. DEFAULT_SETTLEMENT_CURRENCIES = frozenset({"USD", "USDC"}) +# The shape a settlement/quote currency code may take, applied after case-folding. Deliberately +# loose about WHICH codes exist (keel does not carry an ISO-4217 table, and venue codes like +# `USDC` are not in one anyway) and strict only about the shape a code can possibly have: rail 18 +# compares these against `quote_currency_of`'s output, which is a single dash-delimited token, so +# anything with a space or punctuation in it is a typo that would sit in the set admitting +# nothing. 2 chars minimum because no real code is one letter; 10 maximum to catch a sentence +# pasted into the list. +_CURRENCY_CODE_RE = re.compile(r"[A-Z0-9]{2,10}") + @dataclass(frozen=True) class Caps: @@ -339,8 +349,8 @@ def _parse_allowlist(raw: dict[str, Any]) -> list[str]: def _parse_settlement_currencies(raw: dict[str, Any]) -> frozenset[str]: - """`settlement_currencies:`, uppercased -- rail 18's allowed set. Optional; absent falls back - to `DEFAULT_SETTLEMENT_CURRENCIES`. + """`settlement_currencies:`, uppercased -- rail 18's allowed set. Optional; an ABSENT key + falls back to `DEFAULT_SETTLEMENT_CURRENCIES`. Uppercased at parse because `quote_currency_of` uppercases what it resolves, so the rail compares like with like by construction rather than by every call site remembering to fold @@ -349,10 +359,28 @@ def _parse_settlement_currencies(raw: dict[str, Any]) -> frozenset[str]: A bare string is rejected rather than iterated: `settlement_currencies: USD` is a plausible typo, and taking it as a sequence would silently configure `{"U", "S", "D"}` -- a set that admits nothing and would veto every order with a message naming three letters. + + "Key absent" and "key present but null" are deliberately NOT the same thing: `raw.get` cannot + tell them apart, so membership is tested instead. An operator who typed `settlement_currencies:` + and left it bare was trying to change the rail's set; silently handing back the default would + answer a deliberate edit with no feedback at all, and the currency they meant to add would + still be vetoed on every order. Explicit null is rejected exactly like `[]`. + + Entries are shape-checked, not just non-empty: `quote_currency_of` can only ever return an + uppercase alphanumeric token, so a code that is not one (`'US D'`, `'U'`, a sentence) is a + member that admits nothing. Failing at load names the typo; admitting it would show up as a + rail-18 veto on an order the operator believed they had just enabled. """ - value = raw.get("settlement_currencies") - if value is None: + if "settlement_currencies" not in raw: return DEFAULT_SETTLEMENT_CURRENCIES + value = raw["settlement_currencies"] + if value is None: + raise ConfigError( + "settlement_currencies: present but empty. Remove the key to accept the default " + f"{sorted(DEFAULT_SETTLEMENT_CURRENCIES)}, or list the currency codes rail 18 should " + "admit -- an empty set would veto every order, since no product's quote leg could " + "be in it." + ) if isinstance(value, str) or not isinstance(value, (list, tuple, set, frozenset)): raise ConfigError( f"settlement_currencies: must be a non-empty list of currency codes, got {value!r}" @@ -363,7 +391,15 @@ def _parse_settlement_currencies(raw: dict[str, Any]) -> frozenset[str]: raise ConfigError( f"settlement_currencies: invalid entry {entry!r}; must be non-empty strings" ) - codes.add(entry.strip().upper()) + code = entry.strip().upper() + if not _CURRENCY_CODE_RE.fullmatch(code): + raise ConfigError( + f"settlement_currencies: invalid entry {entry!r}; a currency code is 2-10 " + "alphanumeric characters (USD, USDC, EUR). Rail 18 matches this against the " + "quote leg parsed out of a product id, which can never contain a space or " + "punctuation, so this entry would admit nothing." + ) + codes.add(code) if not codes: raise ConfigError( "settlement_currencies: empty; must be a non-empty list of currency codes. An empty " @@ -581,6 +617,26 @@ def load_config(path: str | Path) -> Config: if not isinstance(quote_currency, str) or not quote_currency: raise ConfigError(f"quote_currency: must be a non-empty string, got {quote_currency!r}") + settlement_currencies = _parse_settlement_currencies(raw) + + # The two settings are independent knobs that describe the SAME leg, and a config that moves + # one without the other is dead on arrival: `_history_product` builds every id keel can name + # as `f"{asset}-{quote_currency}"`, and rail 18 vetoes any id whose quote leg is not in + # `settlement_currencies`. So `quote_currency: EUR` against the default `{USD, USDC}` means + # every order the deployment is capable of constructing is vetoed -- forever, on every cycle, + # with a rail message about a currency the operator never typed. That is precisely the + # "silent unfixable rejection" `_history_product`'s docstring was written to prevent, and it + # is invisible until an order is attempted, so it is caught here instead. + if quote_currency.upper() not in settlement_currencies: + raise ConfigError( + f"quote_currency: {quote_currency!r} is not in settlement_currencies " + f"{sorted(settlement_currencies)}. Every product id keel builds is quoted in " + f"quote_currency, and rail 18 vetoes any order whose settlement leg is outside " + f"settlement_currencies -- as written, every order this deployment could place " + f"would be rejected. Add {quote_currency.upper()!r} to settlement_currencies, or " + f"set quote_currency to one of {sorted(settlement_currencies)}." + ) + # Rail 16's two knobs are only meaningful together. The breaker arms # `halt_until = now_ts + streak_cooloff_days * 86400` and the rail tests `now_ts < halt_until`, # so a cooloff of 0 makes them equal: the breaker logs `streak.breaker_tripped` at WARNING, @@ -664,7 +720,7 @@ def load_config(path: str | Path) -> Config: tiers=_parse_tiers(raw), fees=_parse_fees(raw), quote_currency=quote_currency, - settlement_currencies=_parse_settlement_currencies(raw), + settlement_currencies=settlement_currencies, logging=_parse_logging(raw), research=_parse_research(raw), ) diff --git a/tests/execution/test_executor.py b/tests/execution/test_executor.py index 7f8c3d37..e131b2da 100644 --- a/tests/execution/test_executor.py +++ b/tests/execution/test_executor.py @@ -18,14 +18,12 @@ from typing import Any import pytest -from keel_broker_api.capabilities import BrokerCapabilities from keel_core.subscription import SubscriptionStatus from keel.config import ( AutoTradeConfig, Caps, Config, - ConfigError, DcaConfig, MarketDataConfig, MoneyMgmtConfig, @@ -35,7 +33,6 @@ from keel.execution.executor import ( CancelUnavailable, ExecutionResult, - _reconcile_settlement_currencies, execute, place_bracket, roll_to_break_even, @@ -1523,92 +1520,3 @@ def test_no_account_at_all_for_the_required_currency_fails_closed(repo): assert result.placed is False assert result.preview is None assert any("unknown/unavailable" in v for v in result.vetoed_by) - - -# -- config cannot widen rail 18 past what the venue actually settles in ------------------------ - - -class DeclaringBroker(FakeBroker): - """A `FakeBroker` that also declares `BrokerCapabilities`, like the broker-port adapters do. - - The live broker today (`data.cb_client.CoinbaseClient`) declares nothing -- the declaration - lives on `keel_broker_coinbase.CoinbaseAdapter` -- so this fake models the adapter-shaped - broker the reconciliation actually has something to check against. - """ - - def __init__(self, quote_currencies: frozenset[str], **kwargs: Any) -> None: - super().__init__(**kwargs) - self._quote_currencies = quote_currencies - - def capabilities(self) -> BrokerCapabilities: - return BrokerCapabilities( - venue="fake", - supported_orders=frozenset({"market_ioc_quote", "limit_gtc"}), - supports_native_preview=True, - synthesizes_preview=False, - supports_fee_summary=False, - quote_currencies=self._quote_currencies, - asset_classes=frozenset({"spot"}), - ) - - -def test_a_settlement_currency_the_venue_does_not_declare_fails_loudly(repo): - """Rail 18's allowed set is the operator's, but the venue's declaration is the ceiling: a - config naming a currency Coinbase does not settle in is a misconfiguration, not a trade.""" - broker = DeclaringBroker(frozenset({"USD", "USDC"})) - signal = _enter_signal() - - with pytest.raises(ConfigError, match="EUR"): - execute( - signal, - broker, - repo, - _config(settlement_currencies=frozenset({"USD", "USDC", "EUR"})), - mode="autonomous", - now_ts=NOW_TS, - ) - - assert broker.preview_calls == [] - assert broker.place_calls == [] - assert repo.get_orders() == [] - - -def test_a_settlement_currency_set_within_the_venues_declaration_places(repo): - broker = DeclaringBroker(frozenset({"USD", "USDC"})) - - result = execute( - _enter_signal(), - broker, - repo, - _config(settlement_currencies=frozenset({"USD"})), - mode="autonomous", - now_ts=NOW_TS, - ) - - assert result.placed is True, result.vetoed_by - - -def test_a_broker_that_declares_no_capabilities_is_not_treated_as_a_veto(repo): - """`CoinbaseClient` -- the broker the live path actually constructs -- has no - `capabilities()`. There is nothing to reconcile against, and refusing to trade on that basis - would take the live path down for a check that cannot be made. Rail 18 still enforces the - configured set on every order regardless. - """ - broker = FakeBroker() - - result = execute( - _enter_signal(), - broker, - repo, - _config(settlement_currencies=frozenset({"USD", "EUR"})), - mode="autonomous", - now_ts=NOW_TS, - ) - - assert result.placed is True, result.vetoed_by - - -def test_the_venue_reconciliation_is_skipped_when_there_is_no_broker(): - """`agent._paper_enter` passes `broker=None` on purpose. Paper has no venue to reconcile - against, and raising there would stop a rehearsal that places nothing.""" - _reconcile_settlement_currencies(None, _config(settlement_currencies=frozenset({"EUR"}))) diff --git a/tests/test_config.py b/tests/test_config.py index 2d478de1..1cb6a9d0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -225,6 +225,78 @@ def test_load_config_settlement_currencies_invalid_entry_raises_configerror(writ load_config(path) +def test_load_config_settlement_currencies_explicit_null_raises_configerror(write_config): + """`settlement_currencies:` with nothing under it is YAML `None`, not an absent key. An + operator who typed the key was trying to change something; falling back to the default would + answer that edit with silence -- the same reason `[]` is rejected rather than defaulted.""" + text = VALID_CONFIG_YAML + "\nsettlement_currencies:\n" + path = write_config(text) + + with pytest.raises(ConfigError, match="settlement_currencies"): + load_config(path) + + +def test_load_config_settlement_currencies_malformed_code_raises_configerror(write_config): + """`US D` is not a currency code, and rail 18 compares against `quote_currency_of`'s output, + which can never contain a space -- so a typo like this configures a member that admits + nothing. Caught at load, where the message can name it.""" + text = VALID_CONFIG_YAML + "\nsettlement_currencies:\n - 'US D'\n" + path = write_config(text) + + with pytest.raises(ConfigError, match="settlement_currencies"): + load_config(path) + + +def test_load_config_quote_currency_outside_settlement_currencies_raises_configerror( + write_config, +): + """The deployment-wide veto this cross-check exists to prevent. + + `_history_product` builds every id keel names as `f"{asset}-{quote_currency}"`, and rail 18 + vetoes any id whose quote leg is not in `settlement_currencies`. A config that changes one + and not the other therefore has EVERY order it can construct vetoed, forever, with nothing + at load time saying why. + """ + text = VALID_CONFIG_YAML.replace("quote_currency: USD", "quote_currency: EUR") + path = write_config(text) + + with pytest.raises(ConfigError, match="quote_currency") as excinfo: + load_config(path) + + message = str(excinfo.value) + assert "EUR" in message + assert "USDC" in message + assert "settlement_currencies" in message + + +def test_load_config_quote_currency_changed_together_with_settlement_currencies_loads( + write_config, +): + """The cross-check constrains the two settings to agree; it does not pin them to USD. An + operator who moves the deployment to a currency rail 18 also admits is a supported config.""" + text = ( + VALID_CONFIG_YAML.replace("quote_currency: USD", "quote_currency: EUR") + + "\nsettlement_currencies:\n - EUR\n" + ) + path = write_config(text) + + config = load_config(path) + + assert config.quote_currency == "EUR" + assert config.settlement_currencies == frozenset({"EUR"}) + + +def test_load_config_quote_currency_is_case_folded_before_the_cross_check(write_config): + """`settlement_currencies` is uppercased at parse, so a lowercase `quote_currency` must be + folded before the comparison or a consistent config would be rejected as a mismatch.""" + text = VALID_CONFIG_YAML.replace("quote_currency: USD", "quote_currency: usd") + path = write_config(text) + + config = load_config(path) + + assert config.settlement_currencies == frozenset({"USD", "USDC"}) + + def test_load_config_promotion_defaults_are_canonical_proving_floors(write_config): """Phase-1 placeholders (30 trades / 40% win) were replaced with the canonical proving-gate floors from spec §6.2/§11 (Issue #66): 100 trades / 55% win rate. From 0aa930700a4ce86405168e20a33f8fb143b93eb9 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Wed, 5 Aug 2026 11:40:24 -0400 Subject: [PATCH 3/3] docs(config): say plainly that nothing verifies the venue's settlement set The shipped comment told operators "the venue must settle in whatever you add here", which read as a guarantee. The check that would have enforced it was removed in 7260a33 for being unreachable and for risking a mid-cycle raise on the exit path, so nothing verifies it until the broker-port migration exposes capabilities() on the live client. Say so. Co-Authored-By: Claude Opus 5 (1M context) --- config.yaml | 8 +++++--- keel/templates/config.live.yaml | 8 +++++--- keel/templates/config.yaml | 8 +++++--- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/config.yaml b/config.yaml index ee7a39f2..9f1d1843 100644 --- a/config.yaml +++ b/config.yaml @@ -92,9 +92,11 @@ quote_currency: USD # LEG, not an instrument model, so it closes those classes only while Coinbase keeps suffixing # them with a non-currency venue code -- a hypothetical `BTC-PERP-USD` parses as `USD` and would # pass. It deliberately also excludes non-USD/USDC SPOT pairs (`BTC-EUR`, `*-USDT`); widen this -# list if you actually trade one -- it is the escape hatch, and the venue must settle in -# whatever you add here. `quote_currency` above must be one of these, or every id keel builds -# would be vetoed by this rail; the config fails to load if the two disagree. +# list if you actually trade one -- it is the escape hatch. NOTHING VERIFIES that the venue +# actually settles in what you add: the adapter declares its own set, but the live client does +# not expose that declaration yet, so widening this is on you until it does. `quote_currency` +# above must be one of these, or every id keel builds would be vetoed by this rail; the config +# fails to load if the two disagree. settlement_currencies: - USD - USDC diff --git a/keel/templates/config.live.yaml b/keel/templates/config.live.yaml index 9a1ccccd..a19b2ff4 100644 --- a/keel/templates/config.live.yaml +++ b/keel/templates/config.live.yaml @@ -103,9 +103,11 @@ quote_currency: USD # LEG, not an instrument model, so it closes those classes only while Coinbase keeps suffixing # them with a non-currency venue code -- a hypothetical `BTC-PERP-USD` parses as `USD` and would # pass. It deliberately also excludes non-USD/USDC SPOT pairs (`BTC-EUR`, `*-USDT`); widen this -# list if you actually trade one -- it is the escape hatch, and the venue must settle in -# whatever you add here. `quote_currency` above must be one of these, or every id keel builds -# would be vetoed by this rail; the config fails to load if the two disagree. +# list if you actually trade one -- it is the escape hatch. NOTHING VERIFIES that the venue +# actually settles in what you add: the adapter declares its own set, but the live client does +# not expose that declaration yet, so widening this is on you until it does. `quote_currency` +# above must be one of these, or every id keel builds would be vetoed by this rail; the config +# fails to load if the two disagree. settlement_currencies: - USD - USDC diff --git a/keel/templates/config.yaml b/keel/templates/config.yaml index ee7a39f2..9f1d1843 100644 --- a/keel/templates/config.yaml +++ b/keel/templates/config.yaml @@ -92,9 +92,11 @@ quote_currency: USD # LEG, not an instrument model, so it closes those classes only while Coinbase keeps suffixing # them with a non-currency venue code -- a hypothetical `BTC-PERP-USD` parses as `USD` and would # pass. It deliberately also excludes non-USD/USDC SPOT pairs (`BTC-EUR`, `*-USDT`); widen this -# list if you actually trade one -- it is the escape hatch, and the venue must settle in -# whatever you add here. `quote_currency` above must be one of these, or every id keel builds -# would be vetoed by this rail; the config fails to load if the two disagree. +# list if you actually trade one -- it is the escape hatch. NOTHING VERIFIES that the venue +# actually settles in what you add: the adapter declares its own set, but the live client does +# not expose that declaration yet, so widening this is on you until it does. `quote_currency` +# above must be one of these, or every id keel builds would be vetoed by this rail; the config +# fails to load if the two disagree. settlement_currencies: - USD - USDC