fix(executor): size each DCA buy from the rule's own amount (#840) - #843
Merged
Merged
Conversation
The live executor sized every DCA buy from the config's single `dca.budget_usd` ($50) and ignored the `size_usd` the rule computed from its own `budget_usd`. With the live rules at $40, $25 and $15, that spent ~$978/month against the rules' ~$467 and rail 14's $500 cap. `_build_intent` now sizes DCA from `setup.context["size_usd"]` and falls back to `config.dca.budget_usd` only when `size_usd` is absent or not a positive, finite number (a bool is refused). The source is logged as `executor.dca_sized` (INFO for rule, WARNING for the config fallback). Paper sizes through the same `_build_intent`, so it is fixed by the same change; the account sim already preferred `size_usd`. No rail, cap, guard or veto changes: the intent's notional is computed from the new qty, so rail 14 and every guard see the real, smaller order. The "config is the operator-facing dial" rationale is recorded as reversed by the operator on 2026-09-27; `dca.budget_usd`'s docs now call it the fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
eaitbrahim
commented
Sep 27, 2026
…#840) Round-1 review on #843 found three issues; this addresses all three. 1. (critical, missing test at executor.py:355) Kept the prior fixer's int/float size_usd test and proved it bites: mutating `Decimal(str(size_usd))` to `Decimal(size_usd)` is killed by the float_inexact (0.1) case. 2. (defect #844, RELEASING.md:90 / test_rule_manifest.py) Fixed the dangling "the value check would catch on its own" reference in assertion (2)'s failure message -- there is no separate value check since the AGREEMENT assertion was removed; the message now says this same per-key comparison catches it. Also corrected the "$40, $25 and $15" claim in test_rule_manifest.py and scripts/rule_manifest.py: deploy/live-rules.json defines a single DCA rule, at $50, so the text no longer asserts a rule mix the repo doesn't show. 3. (suggestion, config.py:156) New orchestrator ruling, implemented with TDD (failing tests first): - `setup.context["size_usd"]` PRESENT but invalid (<=0, NaN, +-Infinity, a bool, or non-numeric) now SKIPS the DCA buy on every path -- live, paper, and the account sim -- instead of falling back to `config.dca.budget_usd`. A rule that computed an invalid amount meant to buy less; falling back would spend more than it asked for. - `config.dca.budget_usd` remains the fallback ONLY when `size_usd` is ABSENT (key missing or `None`), unchanged from #840. - New `executor.DcaSizeInvalid`, raised by `_dca_budget` and caught in `execute()` (live, logs `executor.dca_size_invalid` WARNING) and `agent._paper_enter` (paper, logs `agent.paper_dca_size_invalid`). `_build_intent`'s `None` return keeps its existing "EXIT, nothing open" meaning; the invalid-size_usd case uses a distinct exception instead. - `keel.sim.portfolio_sim._process_dca_signals` now shares `executor._dca_budget` instead of its own looser `setup.context.get("size_usd") or config.dca.budget_usd`, so all three paths agree on what "usable" means. - `Dca.__init__` now raises `ValueError` for `dip_bonus_pct < 0` (0 still allowed) -- a negative value would shrink the budget on a dip, which the executor would then treat as an invalid size_usd and skip. No existing rule, fixture, or `deploy/live-rules.json` used a negative value. - Rewrote `DcaConfig`'s docstring and the three YAML `dca:` comments (config.yaml, keel/templates/config.yaml, keel/templates/config.live.yaml) to describe this behaviour identically. `config.yaml` remains byte-identical to keel/templates/config.yaml. Mutation-proved (diffed against a saved copy before running tests, then restored): present-invalid falling back instead of skipping; None skipping instead of falling back; dropping the dip_bonus_pct check; the sim reverting to its old `or` fallback; and the float str() mutant from item 1. Each is killed by a test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The docstring claimed the size_usd-present-but-invalid skip logs a WARNING "on every path alike" and that the fallback is always logged as executor.dca_sized. Neither is true for the sim: _process_dca_signals calls executor._dca_budget directly and just continue's on DcaSizeInvalid/ValueError, logging nothing either way. Only live (executor.execute/_build_intent) and paper (agent._paper_enter) emit executor.dca_sized (fallback) or a WARNING (executor.dca_size_invalid / agent.paper_dca_size_invalid) for the skip. Also removed a stale "matching this config's value" in tests/test_rule_manifest.py: assertion (2) there pins the manifest value against Dca's constructor default, not against config.dca.budget_usd, which this test no longer reads. Comment/docstring-only; no behaviour change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #840
Milestone: DCA plan — proposal CLI and read-only card
What changed
keel/execution/executor.py_build_intentnow sizes a DCA ENTER from the rule's amount,setup.context["size_usd"](whatDca.detectcomputes:budget_usd × (1 + dip bonus)), instead of the config's singledca.budget_usd._dca_budget(context, fallback), returns(amount, "rule" | "config"). It falls back toconfig.dca.budget_usdonly whensize_usdis absent or is not a positive, finite number. Aboolis refused too.Decimal('Infinity') > 0is true, which is why the finite check is there:commands/rules.pydocuments how an infinitebudget_usdgets through.executor.dca_sized, at INFO forruleand WARNING forconfig, with product, rule, rule_id and amount.agent._paper_entersizes through the sameexecutor._build_intentand fillsintent.qty, so paper is fixed by the same change. A test pins it. The account sim (sim/portfolio_sim.py) already preferredsize_usd, so live, paper and the sim now agree.max_exposure_usdis untouched.intent.notionalis stillspend(qty, entry)from the new qty, so rail 14 and every other guard see the real order, which is now smaller._order_specdocstring;DcaConfigdocstring (inpackages/keel-core);dca:comment inconfig.yaml,keel/templates/config.yamlandkeel/templates/config.live.yaml;equity.sizing_equity's docstring;docs/operator-runbook.mdanddocs/RELEASING.md.Before and after
Before, the book tried to spend about $978 a month against rail 14's $500 cap, so rail 14 vetoed the tail of each month. After, it spends the rules' ~$467, which fits under the cap. Rail 14 is unchanged and still vetoes any buy that would cross it.
This changes live behaviour only after a release and deploy. The live deployment runs installed wheels, not this repo.
Tests (each one seen failing before the implementation)
test_live_dca_sizes_from_the_rules_size_usd_not_the_config_budgetexecute()path,size_usd=25at 50,000 places qty0.0005withquote_size25, and logssource=rule0.001 != 0.0005test_live_dca_falls_back_to_the_config_budget_without_a_usable_size_usd×8"25"andTrueall size from the config's 50 and logsource=configexecutor.dca_sizedrecord (fallback sizing already held; recording the source is new)test_a_dca_rules_detect_feeds_the_live_order_size_end_to_endDca(budget_usd=15).detect(...)→execute()→ qty0.0005at 30,0000.001666… != 0.0005test_a_dip_scaled_dca_buy_spends_size_usd_not_the_rules_base_budgetsize_usd18.75 ≠budget_usd15, and the order spends 18.75test_paper_dca_fill_is_sized_from_the_rules_amount_not_the_config_budget_paper_enterwith a realDca(budget_usd=15)fills 0.5 at 301.666… != 0.5test_rail_14_sees_the_rules_true_notional_and_admits_a_25_dollar_buy_that_fits50.000 != 25test_rail_14_still_vetoes_a_25_dollar_dca_buy_that_would_cross_the_cap460 + 25 = 48550.000Mutants (each proven applied by a changed sha256, restored from a saved copy, never
git checkout)config.dca.budget_usdsize_usdunconditionally)budget_usdinstead ofsize_usdsize_usd <= 0(< 0)Checks
uv run pytest -q: 6854 passed, 3 skipped, exit 0uv run ruff check keel tests: exit 0uv run ruff format --check keel tests: exit 0uv run mypy(bare): no issues in 473 source files, exit 0Not done here
deploy/live-rules.jsonstill records a single BTC DCA rule atbudget_usd: "50". The brief lists seven live DCA rules, with BTC at $40. Now that the rule's amount is what spends, that manifest is worth syncing from the deployment in a separate change.size_usd or config.dca.budget_usd) still accepts a negativesize_usd. It is not touched here, andDcarefuses a non-positivebudget_usdat construction.config.*.yamlprofiles (config.live-sandbox.yaml, the paper profiles) keep their uncommenteddca:blocks.🤖 Generated with Claude Code