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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,6 @@ transactions/

# Generated by the release workflow at build time; never committed.
keel/_build_info.py

# operator's own working config (never the shipped template)
config.local.yaml
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,7 @@ Runtime settings (allowlist, target weights, risk caps, market data granularitie
## Before trading live

Read `docs/operator-runbook.md`. It lists the compliance obligations **no rail can enforce** — chiefly
that **interest/rewards on idle balances must be disabled** (Coinbase pays USDC rewards, and buys are
routed through USDC, so riba can accrue with no order placed). Every guard in `keel/execution/guards.py`
that **interest/rewards on idle balances must be disabled** (Coinbase pays USDC rewards on idle balances, so riba can accrue with no order placed). Every guard in `keel/execution/guards.py`
inspects an order, so account-level obligations are invisible to all of them and are yours to verify.

Note keel ships **inert**: rail 14 refuses live BUYs until a subscription is attested with
Expand Down
9 changes: 7 additions & 2 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,13 @@ dca:
budget_usd: 50
cadence_days: 7

# quote-currency the executor draws BUY notional from (rail 13, USDC-funding) -- never bank/ACH.
quote_currency: USDC
# The settlement currency this deployment TRADES IN. It must match the quote leg of the
# products you actually trade: everything here is `-USD` (see `_default_sim_products` /
# `_history_product`), so this is USD. It is NOT used to decide which balance funds a given
# order -- that comes from the product itself (`quote_currency_of`), because BTC-USD spends USD
# whatever this says. Used to screen candidates and to exclude the settlement balance from
# `keel assets holdings`.
quote_currency: USD

subscription:
# The SIMULATOR's assumed fee-free monthly volume. The LIVE rail-14 cap is not set here --
Expand Down
2 changes: 1 addition & 1 deletion docs/go-live-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ exchange, correctly, and we have a record of it".
| ☐ | you have a **trade-enabled** CDP key (the read-only one cannot place orders) | |
| ☐ | `.env` holds `CDP_API_KEY` / `CDP_API_SECRET`, and `.env` is git-ignored | credentials live only here — there is no vault |
| ☐ | you are at a real terminal | confirmation and every halt-releasing command fail closed off a TTY |
| ☐ | funds are in **USDC**, not USD | rail 13 vetoes a BUY that is not funded from `quote_currency`, and never draws from a bank/ACH |
| ☐ | settled funds are in the **quote leg of the product you trade** (`BTC-USD` → **USD**) | rail 13 checks the balance of the currency the ORDER spends, derived from the product — not `config.quote_currency` and never draws from a bank/ACH |

## 1. Bring the deployment up

Expand Down
7 changes: 5 additions & 2 deletions docs/operator-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@ Coinbase account.

### 1. ⛔ Disable interest / rewards on idle balances — **required**

**Why.** Coinbase pays **USDC Rewards** on idle USDC balances. Rail 13 routes buys through USDC, so the
account holds a USDC quote balance between trades. Interest accruing on that balance is **riba**
**Why.** Coinbase pays **USDC Rewards** on idle USDC balances. This applies whenever you hold idle
USDC — which is the case if you settle in USDC, and remains the case for any USDC you keep aside.
(Since 2026-07-22 rail 13 checks the quote leg of the product being traded rather than a single
configured currency, so a `-USD` deployment holds USD between trades; the rewards concern still
applies to any USDC balance you do hold.) Interest accruing on that balance is **riba**
(KB §56.3, grounded in §28.1 / §30.1) — and it accrues **with no order placed**, so no rail sees it.
This is not a trading decision the system can veto; it is an account setting only you can change.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# The `-USD` / `USDC` quote-currency mismatch — design

**Date:** 2026-07-22
**Status:** Approved design
**Trigger:** found during the first supervised live-order attempt.

## The defect

The codebase conflates two different things:

- the **product's quote leg** — what an order actually spends (`BTC-USD` spends **USD**);
- **`config.quote_currency`** — a single global setting (default `USDC`).

Nothing derives the first from the product, so three places disagree with reality:

1. **Rail 13 (`usdc_funding`) guards the wrong balance.** `executor` fetches
`_fetch_available_quote(broker, config.quote_currency)` and the rail compares *that* to the
order notional — but the order settles in the product's quote leg.
- **False veto** (observed): $49 USD available, $0.25 USDC, `BTC-USD` $5 order → vetoed.
- **False pass** (the serious one): ample USDC, no USD → the rail **approves** an order that
spends USD the account does not have. That is exactly the "never draw from a linked
bank/ACH source" case rail 13 exists to prevent. A safety rail that can pass when it should
veto is worse than no rail, because it is trusted.
2. **The screen's settlement check is vacuous.** `quotable_in_settlement_currency =
product.endswith(f"-{quote}") or bool(candles)`. Every screened product is `-USD` while
`quote` is `USDC`, so it always falls through to `bool(candles)` — i.e.
`ScreenPolicy.require_settlement_quote` re-checks "do we have bars", which the history rail
already covers. One of four admission criteria does nothing.
3. **`config.quote_currency` never matched this deployment.** Products, cached history, rules and
the simulator are all `-USD` (`_default_sim_products`, `_history_product`, `keel fetch`).

## Fix

**One rule: the currency an order spends is a property of the PRODUCT, never of global config.**

1. **`keel_core.products.quote_currency_of(product_id) -> str | None`** — the leg after the last
`-`, uppercased. `None` for a malformed id (no `-`, empty leg), so callers fail closed.
2. **Rail 13 guards the product's quote leg.** The executor fetches the balance of
`quote_currency_of(intent.product_id)`; an unresolvable product id yields `None`, which the
rail already treats as unknown and vetoes. The violation message names the actual currency
instead of hardcoding "USDC". The rail key stays `usdc_funding` (it is an identifier other
code and `LIVE_STATE_RAILS` match on).
3. **The screen's settlement check becomes real:** `quotable_in_settlement_currency =
quote_currency_of(product) == policy settlement currency`. The `or bool(candles)` escape is
removed — that clause is what made the criterion vacuous.
4. **`config.quote_currency: USD`** in the repo config and both templates, matching the products
actually traded and cached. With (3) real, leaving it at `USDC` would reject every `-USD`
asset — the setting has to describe reality, and reality is `-USD`.

`config.quote_currency` keeps a clear, narrower meaning: **the settlement currency this
deployment trades in**, used to screen candidates and to exclude the settlement balance from
holdings. It is no longer used to decide which balance funds a given order.

## Non-goals

- No change to *which* products are traded, and no re-fetching history under a different quote.
- Rail 13's fail-closed semantics are unchanged; only the balance it reads changes.
- No new veto for "product quoted in a currency you did not configure" — that is a real gap
(§ below) but adding a veto the night after a live test is the wrong sequencing.

## Known remaining gap (recorded, not fixed)

Nothing yet *rejects an order* whose product quote leg differs from `config.quote_currency`; the
screen rejects such an asset at admission, but a live-seeded rule bypasses admission. Worth a
follow-up rail once this lands and the live path is proven.

## Testing

- `quote_currency_of`: `BTC-USD`→`USD`, `BTC-USDC`→`USDC`, `eth-usd`→`USD`, `BTC`→`None`,
`""`→`None`, `BTC-`→`None`.
- **The false-pass hole**: ample `config.quote_currency` balance but an empty product-quote
balance must VETO. This is the headline regression test.
- The false veto: sufficient product-quote balance passes even when the configured currency
balance is 0.
- A malformed product id fails closed.
- Screen: `-USD` product with settlement `USD` passes; `-USDC` product with settlement `USD`
fails **even with candles present** (proving the vacuous clause is gone).
- Both config templates stay byte-identical to the repo config where the existing test requires.
34 changes: 29 additions & 5 deletions keel/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
from decimal import Decimal
from typing import Any

from keel_core.products import quote_currency_of
from keel_core.telemetry import bind_cycle, log_event, new_cycle_id, unbind_cycle

from keel.config import Config
Expand Down Expand Up @@ -277,13 +278,36 @@ def _mark_to_market_equity(
than on a loss. That is why this iterates `products` and not `price_by_product` -- a product
missing from the price map is exactly the case the fallback exists for.

`None`, rather than a partial total, when the quote balance is unavailable: equity is simply
unknowable then, and a wrong one corrupts the high-water mark PERMANENTLY (an HWM never
falls, so an under-read arms the breaker on a phantom drawdown from then on).
⚠️ **Balances are summed at face value, with NO FX conversion.** That is correct for the
supported case -- one settlement currency, whose products all share that quote leg -- and it
is why `config.quote_currency` and the products' legs should agree. An account mixing, say,
USD and EUR would have them added 1:1 and over-read equity, which (the HWM being monotonic)
would arm rail 11 permanently. Reaching that state needs a product whose quote leg differs
from the settlement currency, which the admission screen rejects; a live-seeded rule can
bypass admission, so this is recorded as a known bound rather than claimed impossible.

Settled cash is summed across EVERY currency in play: `quote_currency` plus the quote leg of
each product being valued. Counting only the configured currency under-reads an account whose
cash sits in what its products actually settle in (a `BTC-USD` deployment configured for
USDC) -- and because the HWM is monotonic, that under-read arms rail 11 on a phantom
drawdown permanently. Currencies with no account contribute nothing rather than failing.

`None`, rather than a partial total, when NO quote balance could be read at all: equity is
simply unknowable then, and a wrong one corrupts the high-water mark PERMANENTLY (an HWM
never falls, so an under-read arms the breaker on a phantom drawdown from then on).
"""
quote = _fetch_available_quote(broker, quote_currency)
if quote is None:
currencies: list[str] = []
scanned = (*products, *repo.held_products())
for candidate in (quote_currency, *(quote_currency_of(p) for p in scanned)):
upper = (candidate or "").upper()
if upper and upper not in currencies:
currencies.append(upper)

balances = [(c, _fetch_available_quote(broker, c)) for c in currencies]
if all(balance is None for _, balance in balances):
# Nothing readable at all -- not "zero cash", genuinely unknown.
return None
quote = sum((balance for _, balance in balances if balance is not None), Decimal("0"))

# Quantities come from `_held_position` (the filled-orders audit log), NEVER from
# `position_rule:<product>`. That key is exit-rule OWNERSHIP state: it is overwritten on
Expand Down
72 changes: 50 additions & 22 deletions keel/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
from typing import Any

import click
from keel_core.products import quote_currency_of
from keel_core.subscription import BrokerSubscription, SubscriptionStatus
from keel_core.telemetry import bind_venue

Expand Down Expand Up @@ -619,7 +620,11 @@ def _market_facts(repo: Repository, product: str, quote: str) -> screen_mod.Mark
asset=asset,
daily_bars=len(candles),
median_daily_volume=median,
quotable_in_settlement_currency=product.endswith(f"-{quote}") or bool(candles),
# A REAL check: does this product settle in the currency this deployment trades in?
# The former `or bool(candles)` fallback made it vacuous -- every screened product is
# `-USD`, so it always fell through to "do we have bars", which the history criterion
# already covers. One of four admission criteria was doing nothing.
quotable_in_settlement_currency=quote_currency_of(product) == quote.upper(),
)


Expand Down Expand Up @@ -652,18 +657,24 @@ def _screen_product(
return facts, screen_mod.screen_asset(facts, attestation)


#: The product id this codebase uses for an asset's daily history. `assets screen`,
#: `keel simulate` and `keel fetch` all key on `-USD` (see `_default_sim_products`), so holdings
#: must too -- screening `{asset}-{quote_currency}` instead would find zero cached bars for every
#: asset and report "no local history" forever, which is worse than useless: it looks like a
#: verdict about the asset.
def _history_product(asset: str) -> str:
return f"{asset}-USD"
def _history_product(asset: str, quote: str) -> str:
"""The product id for an asset, in the deployment's settlement currency.

ONE source of truth, shared with `_default_sim_products`. Hardcoding `-USD` here while the
screen compared against `config.quote_currency` is what let a `quote_currency: USDC` config
reject every asset on a settlement failure it could never fix -- a default change does not
change configs already on disk. Deriving both from the same setting means the worst case is
an honest "no local history, run `keel fetch`", not a silent unfixable rejection.
"""
return f"{asset}-{quote.upper()}"


# Failure classes that are DOWNSTREAM of having no cached history: with zero bars they report
# on our data, not on the asset, so `assets holdings` must not print them as verdicts.
_DATA_DERIVED_FAILURES = frozenset({"liquidity", "settlement"})
# Failure classes that are DOWNSTREAM of having no cached history: with zero bars `liquidity`
# reports on our data (median volume is 0 *because* there are no bars), not on the asset, so
# `assets holdings` must not print it as a verdict. `settlement` is deliberately NOT here -- it
# compares the product's quote leg to the settlement currency and never touches candles, so it
# stays a real, assessable verdict even with zero bars.
_DATA_DERIVED_FAILURES = frozenset({"liquidity"})

# Never candidates: you cannot trade the currency you settle in, and fiat is funding rather than
# a position. Coinbase quotes many fiats, so the list is deliberately broad -- a missing one is
Expand All @@ -672,6 +683,12 @@ def _history_product(asset: str) -> str:
{"USD", "EUR", "GBP", "CAD", "AUD", "JPY", "CHF", "SGD", "BRL", "MXN", "TRY", "INR", "KRW"}
)

# Stablecoins are cash EQUIVALENTS -- funding you hold between positions, not positions. They are
# excluded for the same reason fiat is: proposing the money as something to buy with the money is
# noise. (They would be REJECTED anyway -- unattested, and a yield-bearing one fails §28.4 -- so
# this only removes a redundant row, never an admission.)
_CASH_EQUIVALENTS = frozenset({"USDC", "USDT", "DAI", "PYUSD", "TUSD", "USDP", "GUSD"})


@assets_group.command("holdings")
@click.option(
Expand Down Expand Up @@ -720,7 +737,7 @@ def assets_holdings(ctx: click.Context, min_balance: str, run_screen: bool) -> N
" If this is an authentication error, check CDP_API_KEY/CDP_API_SECRET in .env."
) from exc

excluded = _FIAT_CURRENCIES | {quote.upper()}
excluded = _FIAT_CURRENCIES | _CASH_EQUIVALENTS | {quote.upper()}
# Currency codes are compared UPPERCASED: a `usdc` balance is still the settlement currency,
# and must not be presented as something tradable on a casing accident.
holdings = sorted(
Expand Down Expand Up @@ -753,17 +770,21 @@ def assets_holdings(ctx: click.Context, min_balance: str, run_screen: bool) -> N
if not run_screen:
continue

product = _history_product(asset)
product = _history_product(asset, quote)
facts, result = _screen_product(repo, product, quote)
click.echo(f" {result.summary} ({facts.daily_bars} daily bars cached)")

failures = result.failures
if facts.daily_bars == 0:
# The likeliest misreading of this whole feature. With no cached bars the liquidity
# and settlement checks CANNOT say anything about the asset -- median volume is 0
# because there are no bars, and `quotable_in_settlement_currency` degenerates to
# `bool(candles)`. Printing them as findings would assert about the asset exactly
# what this message exists to deny, so they are shown as derived, not as verdicts.
# The likeliest misreading of this whole feature. With no cached bars `liquidity`
# cannot say anything about the asset -- median volume is 0 *because* there are no
# bars -- so printing it as a finding would assert about the asset exactly what this
# message exists to deny. It is shown as derived, not as a verdict.
# NOTE: `settlement` is deliberately NOT suppressed. It compares the product's quote
# leg to the settlement currency and never reads candles, so it stays assessable at
# zero bars. Do not add it to `_DATA_DERIVED_FAILURES` -- no test would catch that
# here (a derived product can never fail settlement), and it would hide a real
# verdict on any externally supplied product.
derived = [f for f in failures if f.split(":")[0] in _DATA_DERIVED_FAILURES]
failures = [f for f in failures if f not in derived]
click.echo(
Expand Down Expand Up @@ -1300,7 +1321,7 @@ def monitor(
repo = _open_repo(ctx)
config = _load_cfg(ctx)
broker = _build_broker(config)
products = [f"{asset}-USD" for asset in config.allowlist]
products = _default_sim_products(config)
granularities = list(config.market_data.granularities)
interval = interval_sec if interval_sec is not None else config.auto_trade.interval_sec

Expand Down Expand Up @@ -1665,7 +1686,8 @@ def _json_plain(value: Any) -> Any:
@click.option(
"--products",
default=None,
help="Comma-separated product ids (default: config.yaml's allowlist mapped to -USD pairs).",
help="Comma-separated product ids (default: the allowlist, in the configured "
"settlement currency).",
)
@click.option(
"--kinds",
Expand Down Expand Up @@ -1831,7 +1853,12 @@ def pnl(ctx: click.Context, asset: str | None, raw_marks: tuple[str, ...]) -> No


def _default_sim_products(config: Config) -> list[str]:
return [f"{asset}-USD" for asset in config.allowlist]
"""Allowlist assets as product ids, in the configured settlement currency.

Shares `_history_product`'s derivation so `fetch`, `simulate`, `screen` and `holdings`
cannot disagree about which product an asset means.
"""
return [_history_product(asset, config.quote_currency) for asset in config.allowlist]


def _parse_products_option(products: str | None, config: Config) -> list[str]:
Expand Down Expand Up @@ -2029,7 +2056,8 @@ def _default_report_path(now_ts: int) -> Path:
@click.option(
"--products",
default=None,
help="Comma-separated product ids (default: config.yaml's allowlist mapped to -USD pairs).",
help="Comma-separated product ids (default: the allowlist, in the configured "
"settlement currency).",
)
@click.option(
"--contribution",
Expand Down
4 changes: 2 additions & 2 deletions keel/compliance/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ class DiscoveryPolicy:
five years of candles for. Everything that decides admission lives in `screen_asset`.
"""

quote_currency: str = "USDC"
quote_currency: str = "USD"
min_quote_24h_volume: Decimal = Decimal("5000000")


Expand All @@ -213,7 +213,7 @@ def discover_candidates(

for product in products:
product_id = product.get("product_id") or ""
if product.get("quote_currency_id") != policy.quote_currency:
if (product.get("quote_currency_id") or "").upper() != policy.quote_currency.upper():
continue
if product.get("status") != "online":
continue
Expand Down
Loading
Loading