diff --git a/config.yaml b/config.yaml index 47508079..11794167 100644 --- a/config.yaml +++ b/config.yaml @@ -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 diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md index 8d9806b7..dad78f1f 100644 --- a/docs/operator-runbook.md +++ b/docs/operator-runbook.md @@ -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 diff --git a/docs/rails/rail-14-subscription-allowance.md b/docs/rails/rail-14-subscription-allowance.md index 5463f7ad..9fbd497f 100644 --- a/docs/rails/rail-14-subscription-allowance.md +++ b/docs/rails/rail-14-subscription-allowance.md @@ -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 diff --git a/docs/rails/rail-4-total-exposure.md b/docs/rails/rail-4-total-exposure.md new file mode 100644 index 00000000..c0a24083 --- /dev/null +++ b/docs/rails/rail-4-total-exposure.md @@ -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, + 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. diff --git a/keel/execution/guards.py b/keel/execution/guards.py index 59ec3f88..ded1a37a 100644 --- a/keel/execution/guards.py +++ b/keel/execution/guards.py @@ -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): @@ -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( @@ -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) diff --git a/keel/sim/account.py b/keel/sim/account.py index 35345127..73cc9a8a 100644 --- a/keel/sim/account.py +++ b/keel/sim/account.py @@ -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`, @@ -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) @@ -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}" diff --git a/keel/templates/config.live.yaml b/keel/templates/config.live.yaml index 31a642c4..14e8b4ba 100644 --- a/keel/templates/config.live.yaml +++ b/keel/templates/config.live.yaml @@ -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 diff --git a/keel/templates/config.yaml b/keel/templates/config.yaml index 47508079..11794167 100644 --- a/keel/templates/config.yaml +++ b/keel/templates/config.yaml @@ -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 diff --git a/tests/execution/test_guards.py b/tests/execution/test_guards.py index 07fc9ad6..4437e367 100644 --- a/tests/execution/test_guards.py +++ b/tests/execution/test_guards.py @@ -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 ----------------------------------------------------------- diff --git a/tests/sim/test_account.py b/tests/sim/test_account.py index 220f6b52..2a16dea0 100644 --- a/tests/sim/test_account.py +++ b/tests/sim/test_account.py @@ -193,14 +193,67 @@ def test_exposure_cap_rejects_over_cap(): ) config = _config(max_exposure_usd=Decimal("1000"), max_per_asset_pct=Decimal("1")) + # A RULE entry: DCA is exempt from this cap (#841), see the tests below. ok, reasons = acc.can_open( - _intent(asset="BTC", notional=Decimal("45")), config, DAY0 + _intent(asset="BTC", notional=Decimal("45"), is_dca=False, rule_kind="pullback"), + config, + DAY0, ) # 960+45=1005>1000 assert ok is False assert any("total_exposure_cap" in r for r in reasons) +def _account_with_open_exposure(asset: str, notional: Decimal) -> SimAccount: + acc = SimAccount(Decimal("0"), Decimal("0")) + acc.deposit(Decimal("100000"), DAY0) + acc.open( + _intent(asset=asset, notional=notional, is_dca=False, rule_kind="pullback_continuation"), + fill_price=Decimal("100"), + now_ts=DAY0 - 1_000_000, + ) + return acc + + +def test_a_dca_buy_over_max_exposure_is_not_vetoed_by_the_exposure_cap(): + """Sim parity with `guards.check` (#841): rail 14 bounds DCA, not `max_exposure_usd`, so + `keel simulate` must not veto the DCA buy that live now places. 960 + 45 = 1005 > 1000.""" + acc = _account_with_open_exposure("ETH", Decimal("960")) + config = _config(max_exposure_usd=Decimal("1000"), max_per_asset_pct=Decimal("0.6")) + + ok, reasons = acc.can_open(_intent(asset="BTC", notional=Decimal("45")), config, DAY0) + + assert reasons == [] + assert ok is True + + +def test_a_rule_buy_over_max_exposure_is_still_vetoed_by_the_exposure_cap(): + """The pairing to the test above: the same buy as a RULE entry still trips the cap.""" + acc = _account_with_open_exposure("ETH", Decimal("960")) + config = _config(max_exposure_usd=Decimal("1000"), max_per_asset_pct=Decimal("0.6")) + + ok, reasons = acc.can_open( + _intent(asset="BTC", notional=Decimal("45"), is_dca=False, rule_kind="pullback"), + config, + DAY0, + ) + + assert ok is False + assert [r.split(":", 1)[0] for r in reasons] == ["total_exposure_cap"] + + +def test_a_dca_buy_over_the_per_asset_cap_is_vetoed_by_concentration_only(): + """Concentration still binds DCA (#841), and it is the ONLY veto though the buy also + crosses `max_exposure_usd` (BTC 960 + 45 = 1005 > 1000 and > 0.6 * 1000).""" + acc = _account_with_open_exposure("BTC", Decimal("960")) + config = _config(max_exposure_usd=Decimal("1000"), max_per_asset_pct=Decimal("0.6")) + + ok, reasons = acc.can_open(_intent(asset="BTC", notional=Decimal("45")), config, DAY0) + + assert ok is False + assert [r.split(":", 1)[0] for r in reasons] == ["per_asset_concentration_cap"] + + # -- per-asset concentration cap ------------------------------------------------------------------- @@ -597,13 +650,22 @@ def test_close_frees_up_exposure_for_a_subsequent_open(sim_config): Decimal("100"), DAY0, ) - # fully deployed against the exposure cap -- a second open should be blocked - blocked, _ = acc.can_open(_intent(asset="ETH", notional=Decimal("1")), config, DAY0) + # fully deployed against the exposure cap -- a second RULE open should be blocked (a DCA + # buy would not be: DCA is exempt from the exposure cap, #841) + blocked, _ = acc.can_open( + _intent(asset="ETH", notional=Decimal("1"), is_dca=False, rule_kind="pullback"), + config, + DAY0, + ) assert blocked is False acc.close("BTC", fill_price=Decimal("100"), now_ts=DAY0) - allowed, reasons = acc.can_open(_intent(asset="ETH", notional=Decimal("50")), config, DAY0) + allowed, reasons = acc.can_open( + _intent(asset="ETH", notional=Decimal("50"), is_dca=False, rule_kind="pullback"), + config, + DAY0, + ) assert allowed is True assert reasons == [] @@ -1034,6 +1096,51 @@ def test_parity_with_guards_check_even_daily_pacing(): _assert_parity(repo, account, config, now_ts, notionals) +def test_parity_with_guards_check_when_total_exposure_is_the_tightest_cap(): + """#841: DCA is exempt from the total-exposure rail on BOTH sides. Here `max_exposure_usd` + leaves 50 of room (400 - 350) while cash leaves 150 and BTC's per-asset room is 200, so a + grid across 50 separates "exposure exempt" from "exposure binds" -- and the two sides must + agree at every point, which a one-sided exemption would break between 51 and 150.""" + now_ts = JAN15 + repo, account = _parity_scenario(now_ts) + config = _config( + max_per_order_usd=Decimal("1000"), + max_per_day_usd=Decimal("1000"), + max_exposure_usd=Decimal("400"), + max_per_asset_pct=Decimal("1"), + assumed_free_volume_usd=Decimal("2000"), + ) + _attest( + repo, + free_volume_usd=config.subscription.assumed_free_volume_usd, + pacing=config.subscription.pacing, + now_ts=now_ts, + ) + # The grid must actually straddle the exposure cap with the DCA buy allowed -- otherwise + # parity would hold by both sides vetoing, and prove nothing about the exemption. + ok, reasons = account.can_open( + OpenIntent( + asset="BTC", + qty=Decimal("1"), + entry=Decimal("100"), + stop=None, + notional=Decimal("100"), + is_dca=True, + rule_kind="dca", + ), + config, + now_ts, + ) + assert (ok, reasons) == (True, []) + + boundaries = (50, 150, 200) + notionals = sorted( + {1} | {b - 1 for b in boundaries} | {b for b in boundaries} | {b + 1 for b in boundaries} + ) + + _assert_parity(repo, account, config, now_ts, notionals) + + # -- rail 16 parity: consecutive-loss circuit breaker -----------------------------------------