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 config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ caps:
# set explicit values here only if you want an extra per-order/per-day risk ceiling tighter
# than the exposure/concentration caps below. Left at their non-binding default here so
# risk-sized rule orders ($400-24k typical) aren't silently rejected.
# max_exposure_usd caps total open notional for RULE-trading buys only; DCA buys are exempt
# (#841) and bounded by rail 14's attested monthly buy cap instead. max_per_asset_pct (a
# fraction of max_exposure_usd) binds both.
max_exposure_usd: 5000
max_per_asset_pct: 0.50

Expand Down
9 changes: 6 additions & 3 deletions docs/operator-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -990,9 +990,12 @@ fourth does not, which is most of why they drift apart.
- **`paper.monthly_contribution_usd`** — a recurring top-up, applied once per UTC calendar month.
It compounds, and the base is small: a contribution comparable to the seed doubles the account
monthly, and every position size below grows with it.
- **`caps.max_exposure_usd`** — has **two jobs at once**. It is the ceiling on total notional held
at any one moment (rail 4, and rail 6's concentration cap is a percentage of it), *and* it is the
**equity proxy that sizes orders** on the live path
- **`caps.max_exposure_usd`** — has **two jobs at once**. It caps rule-trading entries' total
notional held at any one moment (rail 4; rail 6's concentration cap is a percentage of it).
DCA buys are exempt from rail 4 (#841), bounded instead by rail 14's monthly buy cap and by
rail 6's per-asset cap — but DCA holdings still count toward the total, so a growing DCA sleeve
can consume the room rule-trading entries have left under it (`docs/rails/rail-4-total-exposure.md`).
*And* it is the **equity proxy that sizes orders** on the live path
(`keel/execution/executor.py::_build_intent`). So live `risk_pct` is a fraction of THIS number,
not of real account equity — raising the cap raises the real dollars risked per trade. Set above
actual equity it stops binding before available cash does, and the refusal comes later and less
Expand Down
5 changes: 4 additions & 1 deletion docs/rails/rail-14-subscription-allowance.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ month, from the orders audit log, plus the order being placed — at the venue's
The cap is a limit keel puts on its **own** buying. It is not a fee waiver, and nothing inside
it is free: keel places its orders through **Coinbase Advanced Trade**, which charges its
maker/taker schedule on every order whatever the cap says (#836). DCA is **not** exempt —
recurring buys are exactly the spend this rail exists to cap.
recurring buys are exactly the spend this rail exists to cap. Since #841 this is the rail that
**bounds DCA**: DCA BUYs are exempt from the total-exposure cap (rail 4,
[rail-4-total-exposure.md](rail-4-total-exposure.md)), so the monthly buy cap here, together
with per-asset concentration (rail 6), is what limits accumulation.

> **Correction (2026-09-27, #836).** This page used to describe the cap as volume the venue
> waives the taker fee on, and as *"the profitability boundary"*. That was wrong for keel's
Expand Down
51 changes: 51 additions & 0 deletions docs/rails/rail-4-total-exposure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Rail 4 — the total open-exposure cap

Rail 4 is one of keel's hard rails: checks in `keel/execution/guards.py` that run before every
order, in every mode, and cannot be switched off or widened from outside that module. This one
caps the **summed open notional** across every asset — everything bought and not yet sold, from
the orders audit log — plus the order being placed, at `caps.max_exposure_usd` in config.yaml.

It is a **notional** cap, not an at-risk cap: $5k behind a 2% stop and $5k behind a 20% stop
count the same (KB §83.3).

## Who it binds: rule-trading BUYs, not DCA

Since #841 (the operator's decision, 2026-09-27), **DCA BUYs are exempt from this rail**:

> DCA is bounded by the venue plan's cap (rail 14, the attested monthly buy cap), not by
> `caps.max_exposure_usd`.

A fixed total-holdings cap stops a multi-rule accumulation sleeve within weeks whatever the plan
is: with 7 DCA rules, about $213 held against a $400 cap left room for about one more buy.

What the exemption does and does not change:

| Rail | Rule-trading BUY | DCA BUY |
|---|---|---|
| 4 — total open exposure (`total_exposure_cap`) | binds | **exempt** (#841) |
| 6 — per-asset concentration (`per_asset_concentration_cap`) | binds | binds |
| 14 — monthly buy cap (`monthly_subscription_allowance`) | binds | binds — the limit that bounds DCA |
| 8 — no averaging into losers | binds | exempt (§8/§12.1) |
| 11 — account-drawdown breaker | binds | exempt (§12.6) |
| 16 — consecutive-loss breaker | binds | exempt (§12.6) |
| every other rail | binds | binds |

Two consequences worth knowing:

- **DCA holdings still count toward the total.** A rule-trading entry sees the whole book,
Comment thread
eaitbrahim marked this conversation as resolved.
DCA lots included, so a growing DCA sleeve shrinks the room rule trades have under
`max_exposure_usd`.
- **Rail 6 still uses `max_exposure_usd`.** The per-asset limit is
`max_per_asset_pct × max_exposure_usd`, so `max_exposure_usd` still bounds DCA per asset,
just not in total.

`keel simulate` applies the same exemption (`SimAccount.can_open`), so a backtest models what
live does.

## What happens when it is exceeded

A rule-trading BUY is **vetoed** before it reaches the venue:

- `total_exposure_cap: open exposure … + … = … exceeds max_exposure_usd …`

SELLs are never checked by this rail — they reduce exposure.
23 changes: 18 additions & 5 deletions keel/execution/guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,14 @@
through drawdowns on a fixed small budget/cadence, not a rule-trading signal. It is exempt from
rail 11 (the DD breaker — stated explicitly in the plan) and, for the same functional reason,
rail 8 (no-averaging-into-losers would otherwise block the exact buying-the-dip behavior DCA
exists to do). DCA remains bound by every other rail, explicitly including the allowlist, the
per-asset cap, and the kill-switch (§12.6) -- and, explicitly, rails 13/14 below: DCA orders are
themselves the recurring "subscription" spend rail 14 exists to cap.
exists to do), and from rail 16 (the consecutive-loss breaker, below). Since #841 (the operator's
decision, 2026-09-27) it is also exempt from rail 4, the total-exposure cap: DCA is bounded by
rail 14 -- the venue plan's attested monthly buy cap -- not by `max_exposure_usd`, because a fixed
total-holdings cap halts a multi-rule accumulation sleeve within weeks whatever the plan. DCA
remains bound by every other rail, explicitly including the allowlist, the per-asset cap
(rail 6), and the kill-switch (§12.6) -- and, explicitly, rails 13/14 below: DCA orders are
themselves the recurring "subscription" spend rail 14 exists to cap, and rail 14 is now the
binding limit on them.

Rails 13/14 (Issue #59, safety-critical, un-overridable like every rail above):

Expand Down Expand Up @@ -543,7 +548,14 @@ def check(
# -- so it belongs with concurrent slots / pyramiding, not before them. Fixing the COMMENT
# matters regardless: a comment that overstates what a safety rail enforces is worse than
# no comment.
if is_buy:
#
# DCA exempt (#841, the operator's decision of 2026-09-27): DCA is bounded by rail 14 -- the
# venue plan's attested monthly buy cap -- not by `max_exposure_usd`. A fixed total-holdings
# cap stops a multi-rule accumulation sleeve within weeks whatever the plan is, which is the
# opposite of what DCA is for. The exemption is from THIS rail only: rail 6 (per-asset
# concentration) and rail 14 still bind DCA, and DCA holdings still count in
# `total_exposure`, so they still consume the headroom a rule-trading entry sees here.
if is_buy and not intent.is_dca:
projected_exposure = total_exposure + intent.notional
if projected_exposure > config.caps.max_exposure_usd:
violations.append(
Expand Down Expand Up @@ -706,7 +718,8 @@ def check(
# with advice that sends the operator to attest the wrong venue. Fails closed:
# unattested, suspect, lapsed, or overdue all fall back to `unsubscribed_allowance_usd`
# (default 0). DCA is NOT exempt -- it is exactly the recurring spend this rail exists
# to cap (Issue #59).
# to cap (Issue #59) -- and since #841, with DCA exempt from rail 4, this is the rail
# that bounds DCA's total spend.
if is_buy:
venue = current_venue() or DEFAULT_VENUE
record = repo.get_broker_subscription(venue)
Expand Down
23 changes: 15 additions & 8 deletions keel/sim/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,11 @@
Deliberately NOT enforced here (outside the spend-cap subset, per the plan): the halal allowlist,
min-move/anti-scalping, no-averaging-into-losers, no-stop-widening, sell-only-on-rule,
correlation-adjusted sizing, and stale-data/kill-switch -- those rails need state (an audit log,
`agent_state`, a live feed) this pure ledger doesn't model. DCA is NOT exempt from any cap
enforced here, matching rail 14's explicit non-exemption for DCA spend.
`agent_state`, a live feed) this pure ledger doesn't model. DCA is exempt from exactly the caps
`guards.check` exempts it from among those enforced here: the total-exposure cap (#841 -- rail 14,
the monthly allowance, bounds DCA instead) and the rail 11/16 breakers. Every other cap here --
per-order, per-day, per-asset concentration, USDC-funding and the monthly allowance -- binds DCA,
matching rail 14's explicit non-exemption for DCA spend.

Two separate position slots (Issue #85): a single per-asset RULE-trade slot (`positions`, one
open/close position per asset -- risk-defined entries from `pullback_continuation`,
Expand Down Expand Up @@ -291,10 +294,11 @@ def _monthly_allowance_cap(self, config: Config, now_ts: int) -> Decimal:

def can_open(self, intent: OpenIntent, config: Config, now_ts: int) -> tuple[bool, list[str]]:
"""Check `intent` against every spend cap, collecting *all* violations (no
short-circuit), mirroring `execution.guards.check`. DCA is not exempt from any of these
(matches rail 14). This is the hard safety veto -- callers (e.g. `sim.portfolio_sim`)
that want to avoid tripping it should CLAMP a candidate notional down to
`max_affordable_notional` first, not weaken this check."""
short-circuit), mirroring `execution.guards.check`. DCA is exempt from the total-exposure
cap and the rail 11/16 breakers, exactly as in `guards.check` (#841), and bound by every
other cap here (matches rail 14). This is the hard safety veto -- callers (e.g.
`sim.portfolio_sim`) that want to avoid tripping it should CLAMP a candidate notional down
to `max_affordable_notional` first, not weaken this check."""
reasons: list[str] = []

# per-order $ cap (optional -- non-binding by default, see keel.config.Caps)
Expand All @@ -313,10 +317,13 @@ def can_open(self, intent: OpenIntent, config: Config, now_ts: int) -> tuple[boo
f"{projected_day} exceeds max_per_day_usd {config.caps.max_per_day_usd}"
)

# total open-exposure cap (rule + DCA combined)
# total open-exposure cap (rule + DCA combined) -- DCA exempt, matching `guards.check`'s
# `is_buy and not intent.is_dca` gate on rail 4 (#841): rail 14 bounds DCA, not
# `max_exposure_usd`. DCA lots still count in `_total_notional`, so they still consume
# the headroom a RULE entry sees here.
total_exposure = self._total_notional()
projected_exposure = total_exposure + intent.notional
if projected_exposure > config.caps.max_exposure_usd:
if not intent.is_dca and projected_exposure > config.caps.max_exposure_usd:
reasons.append(
f"total_exposure_cap: open exposure {total_exposure} + {intent.notional} = "
f"{projected_exposure} exceeds max_exposure_usd {config.caps.max_exposure_usd}"
Expand Down
3 changes: 3 additions & 0 deletions keel/templates/config.live.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ caps:
# set explicit values here only if you want an extra per-order/per-day risk ceiling tighter
# than the exposure/concentration caps below. Left at their non-binding default here so
# risk-sized rule orders ($400-24k typical) aren't silently rejected.
# max_exposure_usd caps total open notional for RULE-trading buys only; DCA buys are exempt
# (#841) and bounded by rail 14's attested monthly buy cap instead. max_per_asset_pct (a
# fraction of max_exposure_usd) binds both.
max_exposure_usd: 5000
max_per_asset_pct: 0.50

Expand Down
3 changes: 3 additions & 0 deletions keel/templates/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ caps:
# set explicit values here only if you want an extra per-order/per-day risk ceiling tighter
# than the exposure/concentration caps below. Left at their non-binding default here so
# risk-sized rule orders ($400-24k typical) aren't silently rejected.
# max_exposure_usd caps total open notional for RULE-trading buys only; DCA buys are exempt
# (#841) and bounded by rail 14's attested monthly buy cap instead. max_per_asset_pct (a
# fraction of max_exposure_usd) binds both.
max_exposure_usd: 5000
max_per_asset_pct: 0.50

Expand Down
100 changes: 100 additions & 0 deletions tests/execution/test_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,106 @@ def test_rail4_total_exposure_cap_rejects_over_cap(repo):
assert _keys(result) == {"total_exposure_cap"}


# -- rail 4: DCA is exempt (#841) -- rail 14 bounds DCA, not max_exposure_usd ----------------------


def _seed_open_exposure(repo: Repository, *, product_id: str, notional: Decimal) -> None:
"""Open `notional` of `product_id`, filled well before this UTC month (so it counts toward
rails 4/6 but not toward rails 3/14's day or month spend)."""
_seed_filled_order(
repo,
product_id=product_id,
side=Side.BUY,
qty=notional / Decimal("100"),
price=Decimal("100"),
created_at=NOW_TS - 40 * 86_400,
)


def _dca_intent(**overrides: Any) -> OrderIntent:
base: dict[str, Any] = dict(is_dca=True, rule_kind="dca", stop=None)
base.update(overrides)
return _intent(**base)


def test_rail4_dca_buy_over_max_exposure_is_not_vetoed_by_total_exposure(repo):
"""The operator's decision (#841): DCA is bounded by rail 14's attested monthly buy cap, not
by `max_exposure_usd`. 960 open + 45 = 1005 > 1000, and nothing else binds."""
_seed_open_exposure(repo, product_id="ETH-USD", notional=Decimal("960"))
config = _config(max_exposure_usd=Decimal("1000"), max_per_asset_pct=Decimal("0.6"))

result = check(_dca_intent(product_id="BTC-USD", notional=Decimal("45")), repo, config, NOW_TS)

assert result.violations == []
assert result.ok is True


def test_rail4_the_same_buy_without_is_dca_is_still_vetoed_by_total_exposure(repo):
"""The pairing to the test above: flip `is_dca` and nothing else, and rail 4 binds again."""
_seed_open_exposure(repo, product_id="ETH-USD", notional=Decimal("960"))
config = _config(max_exposure_usd=Decimal("1000"), max_per_asset_pct=Decimal("0.6"))

result = check(
_dca_intent(product_id="BTC-USD", notional=Decimal("45"), is_dca=False),
repo,
config,
NOW_TS,
)

assert result.ok is False
assert _keys(result) == {"total_exposure_cap"}


def test_rail6_dca_buy_over_the_per_asset_cap_is_still_vetoed_by_concentration(repo):
"""Rail 6 still binds DCA (#841) -- and ONLY rail 6 fires here, though the same buy also
crosses `max_exposure_usd` (960 + 45 = 1005 > 1000; BTC 1005 > 0.6 * 1000)."""
_seed_open_exposure(repo, product_id="BTC-USD", notional=Decimal("960"))
config = _config(max_exposure_usd=Decimal("1000"), max_per_asset_pct=Decimal("0.6"))

result = check(_dca_intent(product_id="BTC-USD", notional=Decimal("45")), repo, config, NOW_TS)

assert result.ok is False
assert _keys(result) == {"per_asset_concentration_cap"}


def test_rail14_dca_buy_over_the_monthly_cap_is_still_vetoed_when_exposure_is_also_over(repo):
"""Rail 14 is now the binding DCA limit (#841): it still fires, and rail 4 no longer fires
beside it. PAXG 960 open (a prior month) + BTC 600 = 1560 > 1000; 600 > the attested 500."""
_attest(repo, free_volume_usd=Decimal("500"))
_seed_open_exposure(repo, product_id="PAXG-USD", notional=Decimal("960"))
config = _config(
max_per_order_usd=Decimal("100000"),
max_per_day_usd=Decimal("100000"),
max_exposure_usd=Decimal("1000"),
max_per_asset_pct=Decimal("0.6"), # BTC 600 <= 600: rail 6 stays out of the way
)

result = check(_dca_intent(product_id="BTC-USD", notional=Decimal("600")), repo, config, NOW_TS)

assert result.ok is False
assert _keys(result) == {"monthly_subscription_allowance"}


@pytest.mark.parametrize("is_dca", [False, True])
def test_rail4_never_gates_a_sell_whatever_the_order_class(repo, is_dca):
"""A SELL reduces exposure; rail 4 has never read one, and the DCA exemption changes that
for neither class."""
_seed_open_exposure(repo, product_id="BTC-USD", notional=Decimal("2000"))
config = _config(max_exposure_usd=Decimal("1000"), max_per_asset_pct=Decimal("1"))
intent = _intent(
side=Side.SELL,
stop=None,
notional=Decimal("50"),
is_dca=is_dca,
rule_kind="target_harvest",
)

result = check(intent, repo, config, NOW_TS)

assert "total_exposure_cap" not in _keys(result)
assert result.ok is True


# -- rail 5: correlation-adjusted sizing -----------------------------------------------------------


Expand Down
Loading
Loading