diff --git a/config.yaml b/config.yaml index 713facc1..9f1d1843 100644 --- a/config.yaml +++ b/config.yaml @@ -83,6 +83,24 @@ 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. 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. 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 + 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..eaa6ceac 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,54 @@ 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 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 + 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 +705,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..6c2cbb50 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 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..a19b2ff4 100644 --- a/keel/templates/config.live.yaml +++ b/keel/templates/config.live.yaml @@ -94,6 +94,24 @@ 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. 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. 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 + 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..9f1d1843 100644 --- a/keel/templates/config.yaml +++ b/keel/templates/config.yaml @@ -83,6 +83,24 @@ 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. 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. 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 + 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..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 @@ -76,6 +77,23 @@ 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"}) + +# 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: @@ -278,6 +296,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 +348,66 @@ 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; 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 + 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. + + "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. + """ + 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}" + ) + 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" + ) + 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 " + "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): @@ -532,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, @@ -615,6 +720,7 @@ def load_config(path: str | Path) -> Config: tiers=_parse_tiers(raw), fees=_parse_fees(raw), quote_currency=quote_currency, + settlement_currencies=settlement_currencies, logging=_parse_logging(raw), research=_parse_research(raw), ) @@ -641,6 +747,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_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..1cb6a9d0 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -177,6 +177,126 @@ 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_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.