Skip to content

fix(executor): size each DCA buy from the rule's own amount (#840) - #843

Merged
eaitbrahim merged 3 commits into
mainfrom
fix/live-dca-per-rule-amount
Sep 28, 2026
Merged

eaitbrahim merged 3 commits into
mainfrom
fix/live-dca-per-rule-amount

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Closes #840

Milestone: DCA plan — proposal CLI and read-only card

What changed

keel/execution/executor.py _build_intent now sizes a DCA ENTER from the rule's amount, setup.context["size_usd"] (what Dca.detect computes: budget_usd × (1 + dip bonus)), instead of the config's single dca.budget_usd.

  • A new helper, _dca_budget(context, fallback), returns (amount, "rule" | "config"). It falls back to config.dca.budget_usd only when size_usd is absent or is not a positive, finite number. A bool is refused too. Decimal('Infinity') > 0 is true, which is why the finite check is there: commands/rules.py documents how an infinite budget_usd gets through.
  • The source is logged as executor.dca_sized, at INFO for rule and WARNING for config, with product, rule, rule_id and amount.
  • Paper: agent._paper_enter sizes through the same executor._build_intent and fills intent.qty, so paper is fixed by the same change. A test pins it. The account sim (sim/portfolio_sim.py) already preferred size_usd, so live, paper and the sim now agree.
  • No rail, cap, guard or veto changed, and max_exposure_usd is untouched. intent.notional is still spend(qty, entry) from the new qty, so rail 14 and every other guard see the real order, which is now smaller.
  • Docs: the executor's "CAREFUL" comment is rewritten. It records that the old "config is the operator-facing dial" rationale was reversed by the operator on 2026-09-27. Also updated:
    • the executor module docstring and the _order_spec docstring;
    • a new DcaConfig docstring (in packages/keel-core);
    • the dca: comment in config.yaml, keel/templates/config.yaml and keel/templates/config.live.yaml;
    • equity.sizing_equity's docstring;
    • docs/operator-runbook.md and docs/RELEASING.md.

Before and after

rule asset amount cadence buys/month (30.44 d) before ($50 each) after (rule's amount)
6 BTC $40 7d 4.35 $217.4 $173.9
8 ETH $25 7d 4.35 $217.4 $108.7
9 PAXG $25 14d 2.17 $108.7 $54.4
10–13 ADA/XLM/DOGE/FET $15 each 14d 2.17 each $434.9 (4 × 108.7) $130.5 (4 × 32.6)
≈ $978 ≈ $467

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 what it pins red before the fix
(a) test_live_dca_sizes_from_the_rules_size_usd_not_the_config_budget through the real execute() path, size_usd=25 at 50,000 places qty 0.0005 with quote_size 25, and logs source=rule 0.001 != 0.0005
(b) test_live_dca_falls_back_to_the_config_budget_without_a_usable_size_usd ×8 absent, None, 0, −25, Infinity, NaN, "25" and True all size from the config's 50 and log source=config no executor.dca_sized record (fallback sizing already held; recording the source is new)
(c) test_a_dca_rules_detect_feeds_the_live_order_size_end_to_end Dca(budget_usd=15).detect(...) → execute() → qty 0.0005 at 30,000 0.001666… != 0.0005
(c′) test_a_dip_scaled_dca_buy_spends_size_usd_not_the_rules_base_budget with a dip, size_usd 18.75 ≠ budget_usd 15, and the order spends 18.75 added after the fix; red against the old code via mutant M1 below
(d) test_paper_dca_fill_is_sized_from_the_rules_amount_not_the_config_budget _paper_enter with a real Dca(budget_usd=15) fills 0.5 at 30 1.666… != 0.5
(e) test_rail_14_sees_the_rules_true_notional_and_admits_a_25_dollar_buy_that_fits $460 spent + $25 = $485 against a $485 cap is placed intent notional 50.000 != 25
(e) test_rail_14_still_vetoes_a_25_dollar_dca_buy_that_would_cross_the_cap $460 + $25 = $485 against a $480 cap is vetoed, and the veto names 460 + 25 = 485 the veto named 50.000

Mutants (each proven applied by a changed sha256, restored from a saved copy, never git checkout)

mutant result killed by
M1: revert to config.dca.budget_usd KILLED (6 fail) (a), (c), (c′), (d), both (e)
M2: drop the fallback (use size_usd unconditionally) KILLED (10 fail) all 8 (b) cases plus 2 existing DCA tests
M3: read budget_usd instead of size_usd KILLED (4 fail) (a), (c′), both (e)
M4: accept size_usd <= 0 (< 0) KILLED (1 fail) (b)[zero]
M5: drop the finite check KILLED (2 fail) (b)[infinity], (b)[nan]
M6: drop the bool guard KILLED (1 fail) (b)[bool]

Checks

  • uv run pytest -q: 6854 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 (bare): no issues in 473 source files, exit 0

Not done here

  • deploy/live-rules.json still records a single BTC DCA rule at budget_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.
  • The sim's own fallback (size_usd or config.dca.budget_usd) still accepts a negative size_usd. It is not touched here, and Dca refuses a non-positive budget_usd at construction.
  • The root config.*.yaml profiles (config.live-sandbox.yaml, the paper profiles) keep their uncommented dca: blocks.

🤖 Generated with Claude Code

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 eaitbrahim added fix Bug fix (groups under Fixes) rails Un-overridable safety rail / guard (Compliance & rails) labels Sep 27, 2026
Comment thread keel/execution/executor.py
Comment thread docs/RELEASING.md
Comment thread packages/keel-core/keel_core/config.py Outdated
eaitbrahim and others added 2 commits September 27, 2026 19:50
…#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
eaitbrahim merged commit 7ab2db3 into main Sep 28, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the fix/live-dca-per-rule-amount branch September 28, 2026 00:11
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(executor): live DCA spends config dca.budget_usd ($50) instead of each rule's own amount

1 participant