Skip to content

Commit bebdc93

Browse files
eaitbrahimclaude
andauthored
fix(rails): exempt DCA buys from rail 6; dca plan checks rails 3 and 5 (#853) (#869)
* fix(rails): exempt DCA buys from the per-asset concentration rail too (#853) The operator's decision (2026-09-28): rail 6 (per_asset_concentration_cap, max_per_asset_pct x max_exposure_usd, $300 live) measures at cost and still vetoed DCA buys after #841 exempted DCA from rail 4. Live BTC DCA was on track to be refused from around end October 2026. Mirroring #841's shape: - guards.check: rail 6 now gates on `is_buy and not intent.is_dca`. DCA holdings still count in the exposure figure a rule-trading entry sees; the exemption is from being gated by the rail, not a blind spot in what it measures. Module docstring updated everywhere it claimed rail 6 binds DCA. - SimAccount.can_open: the same exemption, so `keel simulate` models live. - docs/rails: new rail-6 page with the DCA exemption table; rail-4 and rail-14 pages, the operator runbook, and the dca-plan planning doc's #842 note updated so nothing still claims rail 6 binds DCA. Config templates' caps comment says the same (repo config.yaml re-synced with its template). - Rails 2 (per order), 3 (per day), 5 (correlated) and 13 (cash) still bind a DCA buy; rail 14 (the venue plan's attested monthly buy cap) and available cash are now DCA's aggregate bounds on both axes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(dca-plan): warn on rail 5 and block on rail 3 for DCA's new aggregate bounds (#853) With rails 4 and 6 now exempting DCA (#841/#853), rail 14 (the monthly buy cap) and available cash are DCA's real aggregate bounds -- but rails 2, 3, 5 and 13 still bind a DCA buy, and `keel dca plan` said nothing about the two of those it can evaluate offline. - New BLOCKER (rail 3, per-day cap): the busiest single day every DCA rule's cadence can coincide on. `Dca.detect`'s scheduling rule (`epoch_day % cadence_days == 0`) carries no per-rule phase, so a day exists where every rule -- this plan's own buys and every existing live DCA rule -- buys at once; the worst day is simply the sum of every planned per-buy plus every live rule's own per-buy. Carried on `DcaPlan` as `worst_day_cycle_usd`/`existing_live_daily_usd`/`worst_day_spend_usd`, mirroring the `worst_month_*` fields. The text says "DCA buys alone", since rail 3 counts every BUY that day, not just DCA. - New WARNING (rail 5, correlated sizing): a per-buy above `max_per_order_usd x CORRELATED_SIZE_SCALE` for an asset not in `UNCORRELATED_ASSETS` (both imported from guards.py, never re-typed), naming the asset(s). It is a warning, not a blocker, because it only binds while another correlated asset already has exposure open. - Both flow through `plan.blockers`/`plan.warnings`, which the CLI (`render_dca_plan`) and the /rules card payload already render unmodified -- no payload.py change needed. - docs/superpowers/plans/2026-09-27-dca-plan.md's #842 note amended: rail 6 is exempt from DCA too now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(dca-plan): rail 3 and rail 5 sentences come from one service function each (#853) per_day_cap_text and correlated_size_text build the two new sentences, and plan.blockers / plan.warnings carry them verbatim to the CLI and the card. The rulings are renumbered R21/R22 (R20 is taken by #854) and recorded in the plan doc. Tests now assert the exact blocker tuple and the exact rail-5 warnings instead of substrings, and the rail-6 sell test asserts an empty violation set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(dca-plan): rail 5's warning no longer claims the rail rarely binds (#871) DCA never sells, so once another correlated asset is held every buy above the correlated-size cap is vetoed. The sentence and R22 now say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(dca): the rule's docstring lists rail 6 among DCA's exemptions (#853) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a78f681 commit bebdc93

15 files changed

Lines changed: 542 additions & 63 deletions

File tree

‎config.yaml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@ caps:
3535
# than the exposure/concentration caps below. Left at their non-binding default here so
3636
# risk-sized rule orders ($400-24k typical) aren't silently rejected.
3737
# max_exposure_usd caps total open notional for RULE-trading buys only; DCA buys are exempt
38-
# (#841) and bounded by rail 14's attested monthly buy cap instead. max_per_asset_pct (a
39-
# fraction of max_exposure_usd) binds both.
38+
# (#841) and bounded by rail 14's attested monthly buy cap and available cash instead.
39+
# max_per_asset_pct (a fraction of max_exposure_usd) is likewise RULE-trading only -- DCA is
40+
# exempt from it too (#853), for the same reason.
4041
max_exposure_usd: 5000
4142
max_per_asset_pct: 0.50
4243

‎docs/operator-runbook.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -992,10 +992,12 @@ fourth does not, which is most of why they drift apart.
992992
monthly, and every position size below grows with it.
993993
- **`caps.max_exposure_usd`** — has **two jobs at once**. It caps rule-trading entries' total
994994
notional held at any one moment (rail 4; rail 6's concentration cap is a percentage of it).
995-
DCA buys are exempt from rail 4 (#841), bounded instead by rail 14's monthly buy cap and by
996-
rail 6's per-asset cap — but DCA holdings still count toward the total, so a growing DCA sleeve
997-
can consume the room rule-trading entries have left under it (`docs/rails/rail-4-total-exposure.md`).
998-
*And* it is the **equity proxy that sizes orders** on the live path
995+
DCA buys are exempt from both rail 4 and rail 6 (#841/#853), bounded instead by rail 14's
996+
monthly buy cap and available cash (rail 13) — but DCA holdings still count toward the total
997+
and the per-asset figure, so a growing DCA sleeve can consume the room rule-trading entries
998+
have left under it (`docs/rails/rail-4-total-exposure.md`,
999+
`docs/rails/rail-6-per-asset-concentration.md`). *And* it is the **equity proxy that sizes
1000+
orders** on the live path
9991001
(`keel/execution/executor.py::_build_intent`). So live `risk_pct` is a fraction of THIS number,
10001002
not of real account equity — raising the cap raises the real dollars risked per trade. Set above
10011003
actual equity it stops binding before available cash does, and the refusal comes later and less

‎docs/rails/rail-14-subscription-allowance.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ month, from the orders audit log, plus the order being placed — at the venue's
1111
The cap is a limit keel puts on its **own** buying. It is not a fee waiver, and nothing inside
1212
it is free: keel places its orders through **Coinbase Advanced Trade**, which charges its
1313
maker/taker schedule on every order whatever the cap says (#836). DCA is **not** exempt —
14-
recurring buys are exactly the spend this rail exists to cap. Since #841 this is the rail that
15-
**bounds DCA**: DCA BUYs are exempt from the total-exposure cap (rail 4,
16-
[rail-4-total-exposure.md](rail-4-total-exposure.md)), so the monthly buy cap here, together
17-
with per-asset concentration (rail 6), is what limits accumulation.
14+
recurring buys are exactly the spend this rail exists to cap. Since #841/#853 this is the rail
15+
that **bounds DCA's aggregate spend**: DCA BUYs are exempt from both the total-exposure cap
16+
(rail 4, [rail-4-total-exposure.md](rail-4-total-exposure.md)) and the per-asset concentration
17+
cap (rail 6, [rail-6-per-asset-concentration.md](rail-6-per-asset-concentration.md)), so the
18+
monthly buy cap here — together with available cash (rail 13's USDC-funding floor) — is what
19+
limits accumulation.
1820

1921
> **Correction (2026-09-27, #836).** This page used to describe the cap as volume the venue
2022
> waives the taker fee on, and as *"the profitability boundary"*. That was wrong for keel's

‎docs/rails/rail-4-total-exposure.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ What the exemption does and does not change:
2323
| Rail | Rule-trading BUY | DCA BUY |
2424
|---|---|---|
2525
| 4 — total open exposure (`total_exposure_cap`) | binds | **exempt** (#841) |
26-
| 6 — per-asset concentration (`per_asset_concentration_cap`) | binds | binds |
26+
| 6 — per-asset concentration (`per_asset_concentration_cap`) | binds | **exempt** (#853, [rail-6-per-asset-concentration.md](rail-6-per-asset-concentration.md)) |
2727
| 14 — monthly buy cap (`monthly_subscription_allowance`) | binds | binds — the limit that bounds DCA |
2828
| 8 — no averaging into losers | binds | exempt (§8/§12.1) |
2929
| 11 — account-drawdown breaker | binds | exempt (§12.6) |
@@ -35,9 +35,11 @@ Two consequences worth knowing:
3535
- **DCA holdings still count toward the total.** A rule-trading entry sees the whole book,
3636
DCA lots included, so a growing DCA sleeve shrinks the room rule trades have under
3737
`max_exposure_usd`.
38-
- **Rail 6 still uses `max_exposure_usd`.** The per-asset limit is
39-
`max_per_asset_pct × max_exposure_usd`, so `max_exposure_usd` still bounds DCA per asset,
40-
just not in total.
38+
- **Rail 6 is exempt too, since #853.** A fixed per-asset ceiling would halt a single-asset
39+
accumulation sleeve the same way a fixed total ceiling would (#841's reasoning, applied to the
40+
per-asset case) — see [rail-6-per-asset-concentration.md](rail-6-per-asset-concentration.md).
41+
Rail 14 (the venue plan's attested monthly buy cap) and available cash (rail 13) are DCA's
42+
aggregate bounds now.
4143

4244
`keel simulate` applies the same exemption (`SimAccount.can_open`), so a backtest models what
4345
live does.
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Rail 6 — the per-asset concentration cap
2+
3+
Rail 6 is one of keel's hard rails: checks in `keel/execution/guards.py` that run before every
4+
order, in every mode, and cannot be switched off or widened from outside that module. This one
5+
caps the **summed open notional in one asset** — everything bought and not yet sold, from the
6+
orders audit log — plus the order being placed, at `max_per_asset_pct × caps.max_exposure_usd`
7+
in config.yaml. keel has no equity oracle, so the configured total-exposure ceiling stands in for
8+
funded trading capital, and `max_per_asset_pct` is a fraction of it (§10.3).
9+
10+
Like rail 4, it is a **notional** cap, not an at-risk cap.
11+
12+
## Who it binds: rule-trading BUYs, not DCA
13+
14+
Since #853 (the operator's decision, 2026-09-28), **DCA BUYs are exempt from this rail too**,
15+
mirroring rail 4's #841 exemption:
16+
17+
> DCA is bounded by the venue plan's cap (rail 14, the attested monthly buy cap) and available
18+
> cash, not by `max_per_asset_pct` (a fraction of `max_exposure_usd`).
19+
20+
A fixed per-asset ceiling stops a single-asset accumulation sleeve within weeks whatever the plan
21+
is, for the same reason a fixed total-holdings ceiling does (#841): live BTC DCA was on track to
22+
be refused by this rail from around end October 2026.
23+
24+
What the exemption does and does not change:
25+
26+
| Rail | Rule-trading BUY | DCA BUY |
27+
|---|---|---|
28+
| 4 — total open exposure (`total_exposure_cap`) | binds | **exempt** (#841, [rail-4-total-exposure.md](rail-4-total-exposure.md)) |
29+
| 6 — per-asset concentration (`per_asset_concentration_cap`) | binds | **exempt** (#853) |
30+
| 14 — monthly buy cap (`monthly_subscription_allowance`) | binds | binds — the limit that bounds DCA |
31+
| 13 — USDC-funding (cash floor) | binds | binds |
32+
| 2/3 — per-order / per-day caps | binds | binds |
33+
| 5 — correlation-adjusted sizing | binds | binds |
34+
| 8 — no averaging into losers | binds | exempt (§8/§12.1) |
35+
| 11 — account-drawdown breaker | binds | exempt (§12.6) |
36+
| 16 — consecutive-loss breaker | binds | exempt (§12.6) |
37+
| every other rail | binds | binds |
38+
39+
Two consequences worth knowing:
40+
41+
- **DCA holdings still count toward the per-asset total.** A rule-trading entry in the same asset
42+
sees the whole book, DCA lots included, so a growing DCA position in BTC shrinks the room a
43+
rule-trading BTC entry has left under `max_per_asset_pct × max_exposure_usd`. The exemption is
44+
from being *gated* by rail 6 as a DCA candidate, not a blind spot in what the rail measures.
45+
- **Rail 4 is exempt too, since #841.** DCA is bounded by rail 14 and available cash on both the
46+
total and the per-asset axis now — see
47+
[rail-4-total-exposure.md](rail-4-total-exposure.md).
48+
49+
`keel simulate` applies the same exemption (`SimAccount.can_open`), so a backtest models what
50+
live does.
51+
52+
## What happens when it is exceeded
53+
54+
A rule-trading BUY is **vetoed** before it reaches the venue:
55+
56+
- `per_asset_concentration_cap: <asset> exposure … exceeds … of max_exposure_usd (…)`
57+
58+
SELLs are never checked by this rail — they reduce exposure.

‎docs/superpowers/plans/2026-09-27-dca-plan.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
- Why: a warning that states something false about the money path is worse than none.
6767
- Cost if wrong: none.
6868
- Note (2026-09-28, after #842): rail 4 (`max_exposure_usd`) no longer vetoes DCA buys; rails 14 and 6 still bind them. The plan's output must not describe `max_exposure_usd` as a limit on DCA.
69+
- **Amended 2026-09-28 (#853):** rail 6 (`per_asset_concentration_cap`, itself a fraction of `max_exposure_usd`) is now ALSO exempt from DCA, mirroring rail 4's #841 exemption -- the operator's decision was that a fixed per-asset ceiling would halt a single-asset accumulation sleeve the same way a fixed total ceiling would. Rail 14 (the venue plan's attested monthly buy cap) and available cash (rail 13) are DCA's aggregate bounds now; rails 2, 3, 5 and 13 still bind it. The plan's output must not describe `max_per_asset_pct`/`max_exposure_usd` as a limit on DCA either.
6970
- Ruling R10: `--buffer-pct` is **required**, not defaulted.
7071
- Why: the spec's signature names it. A silent default of 0 turns "budget" into "spend" without the operator saying so.
7172
- Cost if wrong: one extra flag to type.
@@ -97,6 +98,13 @@
9798
- Why: writing `candidate` rules is not a capability increase. `keel rules add` writes the same rows ungated and has no row there. A `candidate` rule cannot place an order until `rules promote` clears it.
9899
- Cost if wrong: `tests/test_capabilities.py` scans only `_require_interactive_confirmation` call sites, and this feature adds none, so no test would object. The only cost would be an inventory row the user wanted.
99100

101+
- Ruling R21 (2026-09-28, #853): rail 3 (per-day cap) is a **blocker**. The plan computes the busiest single day as the sum of every planned per-buy plus every existing `live` DCA rule's own `budget_usd`. It compares that sum against `caps.max_per_day_usd`. `per_day_cap_text` builds the sentence, and both front-ends show it verbatim.
102+
- Why: `Dca.detect` buys when `epoch_day % cadence_days == 0`, and no rule has a phase offset. So some day (a multiple of the lcm of every cadence) has every rule buying at once. If that total is over the cap, every such day would be vetoed partway through.
103+
- Cost if wrong: if `Dca` ever gains a phase, the blocker overstates the risk. It would block conservatively, never let too much through.
104+
- Ruling R22 (2026-09-28, #853): rail 5 (correlated-size) is a **warning**. It fires when a planned per-buy for an asset outside `UNCORRELATED_ASSETS` exceeds `max_per_order_usd x CORRELATED_SIZE_SCALE`. Both constants are imported from `guards.py`. `correlated_size_text` builds the sentence.
105+
- Why: the rail binds only while another correlated asset already has exposure open, and a plan cannot see that.
106+
- Cost if wrong: buys vetoed that the plan approved. DCA never sells, so once another correlated asset is held (a live DCA rule, or a second planned correlated asset after its first cycle) rail 5 vetoes every buy above the cap. Whether that case should block instead of warn is held as an open question on #871.
107+
100108
## Review Focus
101109

102110
1. **The per-buy amount rounds to $0.00** for a small budget or weight, for example `--budget 1` with a 1% weight. A person expects a named refusal ("BTC's per-buy rounds to $0.00"), not a traceback from `Dca.__init__` and not a silently dropped asset. Pinned in Task 3.

‎keel/commands/dca_plan.py‎

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@
1212
exactly as `keel/execution/guards.py` rail 14 does; `tests/commands/test_dca_plan.py` drives
1313
`guards.check` itself to prove the two agree.
1414
15+
**DCA's aggregate bounds are rail 14 and available cash, not `max_exposure_usd`/
16+
`max_per_asset_pct` (#841/#853).** Rails 2 (per order), 3 (per day), 5 (correlated) and 13 (cash)
17+
still bind a DCA buy, and this plan checks the two rails it CAN evaluate offline: rail 3 (a
18+
BLOCKER: the busiest day every rule's cadence can coincide on, since `Dca.detect`'s scheduling
19+
carries no per-rule phase) and rail 5 (a WARNING: a per-buy above the correlated-size cap, using
20+
`guards.py`'s own `CORRELATED_SIZE_SCALE`/`UNCORRELATED_ASSETS`). Neither text may describe
21+
`max_exposure_usd` or `max_per_asset_pct` as a limit on DCA.
22+
1523
**What this module never does:** promote, touch a `paper`/`live` row, or modify any existing rule.
1624
It writes `candidate` rows through `keel.commands.rules.add_rule_row` and nothing else.
1725
"""
@@ -37,7 +45,10 @@
3745
from keel.data.repository import Repository
3846

3947
# Rail 1's own key function, as `keel/commands/rules.py` imports it: an existing rule's asset is
40-
# read the way the rails read it, so "already has a DCA rule" cannot disagree with them.
48+
# read the way the rails read it, so "already has a DCA rule" cannot disagree with them. Rail 5's
49+
# own constants (#853) so the correlated-size warning below computes the SAME cap the rail does,
50+
# never a re-typed copy that could drift from it.
51+
from keel.execution.guards import CORRELATED_SIZE_SCALE, UNCORRELATED_ASSETS
4152
from keel.execution.guards import _asset as _asset_of
4253
from keel.strategy.rules.dca import Dca
4354

@@ -452,6 +463,22 @@ class DcaPlan:
452463
#: renderer reuses it verbatim rather than recomputing, so the display can never show a
453464
#: total larger than what was actually checked (R5's rule, extended to this figure by #847).
454465
worst_month_spend_usd: Decimal
466+
#: R21 (#853): the busiest SINGLE DAY this plan's own buys can land on -- `Dca.detect`'s
467+
#: scheduling rule (`epoch_day % cadence_days == 0`) has no per-rule phase/offset, so every
468+
#: buy this plan schedules shares one cadence and therefore lands on the SAME days; the
469+
#: worst single day for this plan alone is simply the sum of every planned `per_buy_usd`.
470+
worst_day_cycle_usd: Decimal
471+
#: R21: the total of every EXISTING `live` DCA rule's own per-buy amount (`budget_usd`, not
472+
#: the average-month figure `existing_live_monthly_usd` shows). Because no rule's cadence
473+
#: carries a phase either, a day exists where `epoch_day` is a multiple of every cadence
474+
#: involved (e.g. their lcm) -- on it, this plan's rules AND every existing live rule buy at
475+
#: once, whatever their individual cadences are.
476+
existing_live_daily_usd: Decimal
477+
#: `worst_day_cycle_usd + existing_live_daily_usd` -- the figure the rail 3 (per-day cap)
478+
#: blocker (R21) compares against `caps.max_per_day_usd`. Rail 3 counts EVERY BUY placed
479+
#: that day, not just DCA, but this plan only knows about DCA rules -- so this is a DCA-only
480+
#: FLOOR on that day's total, and the blocker text says so rather than overclaiming.
481+
worst_day_spend_usd: Decimal
455482
blockers: tuple[str, ...]
456483
warnings: tuple[str, ...]
457484

@@ -486,6 +513,44 @@ def _worst_month_buy_days(cadence_days: int) -> int:
486513
return worst
487514

488515

516+
def per_day_cap_text(
517+
*,
518+
worst_day_spend_usd: Decimal,
519+
worst_day_cycle_usd: Decimal,
520+
existing_live_daily_usd: Decimal,
521+
live_rule_count: int,
522+
max_per_day_usd: Decimal,
523+
) -> str:
524+
"""Rail 3's blocker (R21, #853), as ONE sentence both front-ends show: the CLI prints
525+
`plan.blockers` under "Cannot approve" and `/api/dca-plan` sends them as the card's
526+
blockers, verbatim. Phrased as a DCA-only floor, because rail 3 counts every BUY placed that
527+
day and the plan knows only DCA rules."""
528+
return (
529+
"the busiest day every rule's cadence can coincide on -- no cadence carries a "
530+
"phase, so a day exists where every rule buys at once -- would spend "
531+
f"{_usd(worst_day_spend_usd)} in DCA buys alone ({_usd(worst_day_cycle_usd)} from this "
532+
f"plan's own buys plus {_usd(existing_live_daily_usd)} from {live_rule_count} existing "
533+
f"live DCA rule(s)); that exceeds caps.max_per_day_usd "
534+
f"{_usd(max_per_day_usd)} (rail 3), which counts every BUY placed that "
535+
"day, not just DCA"
536+
)
537+
538+
539+
def correlated_size_text(buy: PlannedBuy, correlated_cap: Decimal) -> str:
540+
"""Rail 5's warning (R22, #853) for one planned buy, as ONE sentence both front-ends show
541+
(`plan.warnings`, verbatim). A warning, not a blocker: the rail binds only while another
542+
correlated asset already has exposure open, which a plan cannot see. It must not say the
543+
rail rarely binds: DCA never sells, so once any other correlated asset is held (a live DCA
544+
rule's holding, or another planned buy after its first cycle) every buy this size is vetoed
545+
(#871)."""
546+
return (
547+
f"{buy.asset}'s per-buy {_usd(buy.per_buy_usd)} exceeds the correlated-size cap "
548+
f"{_usd(correlated_cap)} (rail 5: max_per_order_usd x {CORRELATED_SIZE_SCALE}) -- "
549+
"rail 5 vetoes a buy this size whenever another correlated asset has exposure open, "
550+
"and DCA never sells, so once another correlated asset is held every such buy is vetoed"
551+
)
552+
553+
489554
def build_dca_plan(
490555
repo: Repository,
491556
config: Config,
@@ -582,6 +647,30 @@ def build_dca_plan(
582647
)
583648

584649
live_rules = [rule for rule in universe.existing if rule.status == "live"]
650+
651+
# R21 (#853): rail 3 (`guards.py`'s per-day cap) counts EVERY BUY placed that day, and
652+
# `Dca.detect`'s scheduling rule (`epoch_day % cadence_days == 0`) carries no per-rule phase
653+
# or offset -- so a day exists where `epoch_day` is a multiple of every cadence involved (at
654+
# worst, their lcm), and on it EVERY rule -- this plan's own buys and every existing LIVE DCA
655+
# rule -- lands at once, regardless of how their individual cadences differ. The worst single
656+
# day is therefore simply the sum of every planned per-buy plus every live rule's own per-buy
657+
# (`budget_usd`, its per-hit amount -- not the average-month figure `live_monthly` computes
658+
# below). This plan only knows about DCA rules, so the blocker is phrased as a DCA-only floor
659+
# on that day's total, never a claim about the whole day's spend.
660+
worst_day_cycle = sum((b.per_buy_usd for b in buys), Decimal("0"))
661+
existing_live_daily = sum((rule.budget_usd for rule in live_rules), Decimal("0"))
662+
worst_day_spend = worst_day_cycle + existing_live_daily
663+
if worst_day_spend > config.caps.max_per_day_usd:
664+
blockers.append(
665+
per_day_cap_text(
666+
worst_day_spend_usd=worst_day_spend,
667+
worst_day_cycle_usd=worst_day_cycle,
668+
existing_live_daily_usd=existing_live_daily,
669+
live_rule_count=len(live_rules),
670+
max_per_day_usd=config.caps.max_per_day_usd,
671+
)
672+
)
673+
585674
live_monthly = sum(
586675
(_cents_down(rule.budget_usd * MONTH_DAYS / rule.cadence_days) for rule in live_rules),
587676
Decimal("0"),
@@ -626,6 +715,17 @@ def build_dca_plan(
626715
"month buy can be vetoed below the full-month figure shown"
627716
)
628717

718+
# R22 (#853): rail 5 (correlation-adjusted sizing) halves the per-order cap for a correlated
719+
# asset WHILE another correlated asset already has exposure open (`guards.py`'s
720+
# `CORRELATED_SIZE_SCALE`/`UNCORRELATED_ASSETS` -- imported, never re-typed, so this cannot
721+
# drift from the rail). A plan has no live exposure to check, so a per-buy above that cap is
722+
# a WARNING, not a blocker: it only ever binds conditionally, and names the asset(s) it would
723+
# bind for.
724+
correlated_cap = config.caps.max_per_order_usd * CORRELATED_SIZE_SCALE
725+
for buy in buys:
726+
if buy.asset not in UNCORRELATED_ASSETS and buy.per_buy_usd > correlated_cap:
727+
warnings.append(correlated_size_text(buy, correlated_cap))
728+
629729
return DcaPlan(
630730
inputs=inputs,
631731
spend_usd=spend,
@@ -644,6 +744,9 @@ def build_dca_plan(
644744
worst_month_buy_days=worst_days,
645745
worst_month_cycle_usd=worst_month_cycle,
646746
worst_month_spend_usd=worst_month_spend,
747+
worst_day_cycle_usd=worst_day_cycle,
748+
existing_live_daily_usd=existing_live_daily,
749+
worst_day_spend_usd=worst_day_spend,
647750
blockers=tuple(blockers),
648751
warnings=tuple(warnings),
649752
)

0 commit comments

Comments
 (0)