Skip to content

fix(rails): exempt DCA buys from the total-exposure rail; rail 14 bounds DCA (#841) - #842

Merged
eaitbrahim merged 2 commits into
mainfrom
fix/dca-exempt-from-total-exposure
Sep 28, 2026
Merged

eaitbrahim merged 2 commits into
mainfrom
fix/dca-exempt-from-total-exposure

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Closes #841

The decision (the operator's, 2026-09-27, final)

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

  • Exempt DCA BUYs from the total-exposure rail (total_exposure_cap) only.
  • Per-asset concentration (per_asset_concentration_cap) still applies to DCA.
  • Rail 14 (monthly_subscription_allowance) still applies to DCA, unchanged. It is now the binding DCA limit.
  • Every other rail's treatment of DCA is unchanged.

The reason: the live account accumulates through 7 DCA rules. About $213 is held against the $400 max_exposure_usd cap, so rail 4 would veto the sleeve after about one buy on Oct 9.

Which rails bind DCA now

Rail Rule-trading BUY DCA BUY
4: total_exposure_cap binds exempt (this PR)
6: per_asset_concentration_cap binds binds
14: monthly_subscription_allowance binds binds. It is the limit that bounds DCA.
8: no averaging into losers binds exempt (unchanged)
11: drawdown breaker binds exempt (unchanged)
16: consecutive-loss breaker binds exempt (unchanged)
every other rail binds binds (unchanged)
  • DCA holdings still count in the total exposure that a rule-trading entry sees, so a growing DCA sleeve shrinks the room left for rule trades.
  • Rail 6's per-asset limit is still max_per_asset_pct × max_exposure_usd. max_exposure_usd still bounds DCA per asset, but no longer in total.
  • SELLs are unaffected. Rail 4 never read a SELL.

What changed

  • keel/execution/guards.py: rail 4 now gates on is_buy and not intent.is_dca. I updated the module docstring's "DCA exemptions" paragraph, rail 4's comment and rail 14's comment. The docstring now also names rail 16, which was already exempt but was missing from that paragraph.
  • keel/sim/account.py: SimAccount.can_open applies the same exemption, so keel simulate models what live does. The sim's DCA path (portfolio_sim._process_dca_signals) goes through can_open, which vetoes and does not clamp. Before this PR it vetoed DCA over max_exposure_usd; now it does not. max_affordable_notional is unchanged because only the rule-slot path calls it.
  • docs/rails/rail-4-total-exposure.md is new and holds the exemption table. docs/rails/rail-14-subscription-allowance.md now says rail 14 is what bounds DCA.
  • The caps: comment in keel/templates/config.yaml and config.live.yaml says the same. The repo-root config.yaml is re-synced with its template, as test_the_template_stays_in_sync_with_the_repo_config requires.
  • keel doctor has no text that lists rails or their DCA exemptions, so nothing changed there.
  • executor.py is not touched.

This reaches live only after a release and deploy. Until then, the deployed rail 4 still vetoes DCA over max_exposure_usd.

Tests (written first; each was seen to fail for the stated reason, or is a pin)

Test Before the fix
(a) test_rail4_dca_buy_over_max_exposure_is_not_vetoed_by_total_exposure FAIL: total_exposure_cap: open exposure 960.0 + 45 = 1005.0 exceeds max_exposure_usd 1000
(b) test_rail4_the_same_buy_without_is_dca_is_still_vetoed_by_total_exposure pass (regression pin; kills M3)
(c) test_rail6_dca_buy_over_the_per_asset_cap_is_still_vetoed_by_concentration FAIL: extra total_exposure_cap beside per_asset_concentration_cap
(d) test_rail14_dca_buy_over_the_monthly_cap_is_still_vetoed_when_exposure_is_also_over FAIL: extra total_exposure_cap beside monthly_subscription_allowance
(e) test_a_dca_buy_over_max_exposure_is_not_vetoed_by_the_exposure_cap (sim) FAIL: total_exposure_cap … 1005 exceeds … 1000
(e) test_a_dca_buy_over_the_per_asset_cap_is_vetoed_by_concentration_only (sim) FAIL: total_exposure_cap listed before per_asset_concentration_cap
(e) test_a_rule_buy_over_max_exposure_is_still_vetoed_by_the_exposure_cap (sim) pass (regression pin; kills M6)
(e) test_parity_with_guards_check_when_total_exposure_is_the_tightest_cap FAIL: sim vetoed a 100 DCA buy that the fixed guards allow
(f) test_rail4_never_gates_a_sell_whatever_the_order_class[False/True] pass (pin)

Two existing sim tests, test_exposure_cap_rejects_over_cap and test_close_frees_up_exposure_for_a_subsequent_open, probed the exposure cap with the sim helper's default is_dca=True intent. They now probe with a rule-trading intent, which is what they test.

Mutants (each proven applied: the source differed from the saved copy; restored from that copy, never git checkout)

Mutant Result
M1: drop the guards exemption killed by (a), (c), (d) and the parity test
M2: exempt DCA from rail 6 instead of rail 4 killed by (a), (c), (d) and the parity test
M3: exempt non-DCA buys too (guards) killed by (b), test_rail4_total_exposure_cap_rejects_over_cap and test_a_malformed_history_row_still_counts_toward_the_exposure_cap
M4: remove the sim exemption killed by both (e) DCA tests and the parity test
M5: sim exempts DCA from concentration instead killed by 5 sim tests
M6: sim exempts rule entries too killed by the (e) rule pin and 2 existing sim exposure tests

Checks

  • uv run pytest -q: 6850 passed, 3 skipped, exit 0
  • uv run ruff check keel tests: exit 0
  • uv run ruff format --check keel tests: exit 0
  • uv run mypy: no issues in 473 files, exit 0

🤖 Generated with Claude Code

@eaitbrahim eaitbrahim added fix Bug fix (groups under Fixes) rails Un-overridable safety rail / guard (Compliance & rails) labels Sep 27, 2026
…nds DCA (#841)

The operator's decision (2026-09-27): DCA is bounded by the venue plan's
cap (rail 14, the attested monthly buy cap), not by caps.max_exposure_usd.
With 7 DCA rules and about $213 held against a $400 cap, rail 4 would
veto the sleeve after about one buy on Oct 9.

- guards.check: rail 4 (total_exposure_cap) now gates on
  `is_buy and not intent.is_dca`. Rail 6 (per-asset concentration) and
  rail 14 still bind DCA; every other rail is unchanged. DCA holdings
  still count toward the total a rule-trading entry sees.
- SimAccount.can_open: the same exemption, so `keel simulate` models live.
  max_affordable_notional is untouched: only the rule path calls it.
- docs/rails: new rail-4 page with the DCA exemption table; rail-14 page
  says it is now the limit that bounds DCA. Config templates' caps comment
  says the same (repo config.yaml re-synced with its template).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@eaitbrahim
eaitbrahim force-pushed the fix/dca-exempt-from-total-exposure branch from dd52c08 to c3522d0 Compare September 28, 2026 00:17
Comment thread docs/rails/rail-4-total-exposure.md
The operator runbook still called caps.max_exposure_usd "the ceiling on
total notional held at any one moment (rail 4)" -- true before #841, no
longer true after: DCA buys are exempt from rail 4 and bounded instead
by rail 14's monthly buy cap and rail 6's per-asset cap. Correct the
bullet to describe what rail 4 now binds, note that DCA holdings still
count toward the total (so a growing DCA sleeve can consume rule-trading
room), and link to docs/rails/rail-4-total-exposure.md for the detail.

Addresses the unresolved review comment on PR #842
(docs/rails/rail-4-total-exposure.md:35).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@eaitbrahim
eaitbrahim merged commit 61ae930 into main Sep 28, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the fix/dca-exempt-from-total-exposure branch September 28, 2026 00:37
eaitbrahim added a commit that referenced this pull request Sep 28, 2026
…an's cap (#845)

MINOR: live and paper DCA now spend each rule's own size_usd, and DCA
buys are exempt from the total-exposure rail. No schema change since 0.18.0.

What lands:
  #843 (#840) -- executor sizes each DCA buy from the rule's size_usd;
  absent falls back to dca.budget_usd, present-but-invalid skips the buy.
  #842 (#841) -- rail 4 (total exposure) no longer vetoes DCA buys;
  rail 14 (monthly buy cap) and rail 6 (per-asset) still bind them.
  #837 (#836) -- rail 14 relabelled a monthly buy cap, not fee-free.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
eaitbrahim added a commit that referenced this pull request Sep 28, 2026
…lan's cap (#846)

* docs(plan): keel dca plan -- service, CLI and read-only web card

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(plan): amend R7 and withdraw R9 after #843; note #842's rail 4 exemption

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(dca): plan inputs and rail 14's monthly buy cap, read as the rail reads it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(dca): the plan's universe -- admitted, weighted, allowlisted, no existing DCA rule

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(dca): per-buy amounts, fees at the configured rate, rail 14 blockers and warnings

R7 as amended after #843: live DCA commitment is each rule's own budget_usd;
R9's executor-sizing warning is withdrawn and its absence pinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(dca): render the plan; rail 14 is stated as a monthly buy cap, never fee-free

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(dca): approve writes one candidate dca rule per asset via rules add, all or nothing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(dca): keel dca plan -- a thin CLI over the plan service, candidate-only on approval

Off a terminal the database is opened read-only, so the preview cannot write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(dca): check the worst calendar month against rail 14; refuse case-colliding weights (#847, #848)

- R6 amended: the blocker compares the worst UTC calendar month for the
  cadence (max cadence days in any month x the per-cycle total) against
  rail 14's cap; per-buy sizing is unchanged. The output says the worst
  month is what was checked.
- target_weights (and [E] edits) whose keys collide once uppercased are
  refused, naming both keys, instead of silently dropping one.
- A live DCA row with no stored budget_usd is counted at Dca's own
  default (read from its signature) and named in a warning, not as $0.
- An absurd --budget is a usage error, not a decimal traceback.
- A DcaPlanError from the plan build reaches the CLI as a clean error;
  [E] re-prompting on a bad weight is covered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(dca): a repeated allowlist entry is one asset; refuse non-finite weights (#849)

- allowlist entries are de-duplicated case-insensitively, first-seen
  order, so [BTC, ETH, btc] gives one BTC allocation, buy and rule
  rather than a double share and two candidates.
- A NaN/inf target_weights value is a DcaPlanError naming the key,
  not an InvalidOperation traceback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Bug fix (groups under Fixes) rails Un-overridable safety rail / guard (Compliance & rails)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(rails): exempt DCA buys from the total-exposure rail; rail 14 bounds DCA

1 participant