Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <t>`.
Expand Down
70 changes: 62 additions & 8 deletions docs/experiments/2026-08-05-coinbase-asset-class-feasibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion keel/execution/executor.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
66 changes: 60 additions & 6 deletions keel/execution/guards.py
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions keel/templates/config.live.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <t>`.
Expand Down
18 changes: 18 additions & 0 deletions keel/templates/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <t>`.
Expand Down
Loading
Loading