Skip to content

keel v0.19.0

Choose a tag to compare

@github-actions github-actions released this 28 Sep 00:56
· 72 commits to main since this release
71ddba2

Built from 71ddba2. Version binds to this hash:
keel --version reports keel 0.19.0+71ddba24c1f1 [release].

Install

Download all wheels from this release into one directory, then install the
keel_trader wheel by path:

pip install --find-links . ./keel_trader-0.19.0-py3-none-any.whl
keel versions

keel versions — not keel --version — is the check: it reports every
keel distribution in the venv and exits non-zero if a sibling was left behind at
an older version, which --version cannot see. Upgrading an existing
deployment: see "Deploying a new version" in the README.

⚠️ Never install by bare name. The distribution is keel-trader; the name
keel on PyPI belongs to an unrelated project, so pip install keel fetches
someone else's package. A build reporting DIRTY or [checkout] is not this
release and must not be run against live funds.

Configure

config.yaml is attached to this release: the production config, in
auto_trade.mode: confirm — keel previews every order and waits for your
approval. Drop it beside the install (or run keel init-config --live), put
your CDP key in a git-ignored .env, then:

keel migrate     # existing database: apply schema migrations
keel init        # fresh deployment: write config + seed candidate rules

Seeded rules start as candidate and trade nothing until you promote them.

Features

research: a 200-day SMA filter on the daily turtle -- no arm distinguishable from the baseline (#830) (#833)

Closes #830.

Four commits, in the order that matters:

  1. d5e1485 feat(strategy): an optional trend_filter on turtle_breakout (off | above | slope | both, trend_sma_period 200, trend_slope_lookback 5).

    • Default off, so every existing rule behaves exactly as before.
    • Too little history to compute the average declines the entry (trend_filter_history) rather than letting it through.
    • 8 tests, each failing first, using fixtures that assert their own premise. 5 of 5 mutants killed.
  2. 696bc5b The pre-registration. The driver's docstring holds the arms, data, window, statistics, decision rule and my expectation. It was pushed at 00:39 UTC before any arm ran.

  3. 37cc6ac fix(research): the trials ledger now refuses two kinds of summary value it could never read back:

    • A non-numeric string raises on read, making the append-only ledger unreadable from that row on.
    • A float breaks the row's hash, so verify_chain reports it as tampered forever.

    Both were found when the first run's four rows failed to read back. Those rows were never committed. One test each.

  4. 8604e80 The record, data and four ledger rows (ablation, a_priori, diagnostic_only, with R series).

Result (1.20% taker plus per-product slippage, in R, common window)

Arm N Win % E[R] Δ vs off, 95% CI (cluster bootstrap by entry day)
off 202 26.7% +0.270 —
above 181 25.4% +0.218 [−0.641, +0.540]
slope 121 29.8% +0.419 [−0.515, +0.829]
both 121 28.9% +0.409 [−0.524, +0.824]

Pre-registered verdict: no arm is promising. Every arm clears the 100-trade floor, and no lower bound is above zero, so nothing goes to walk-forward.

  • slope and both point the hypothesised way, but inside an interval about 1.3 R wide.
  • above barely binds and does slightly worse.
  • The largest effect in the data is the excluded first ~205 days (the 2022 bear-market onset): 18 trades averaging about −1.0 R.
  • The baseline itself remains descriptive under ADR 0006: +5 win-rate points over break-even against a 14-point detectable edge.

Honesty notes

  • Edits since the pre-registration: the driver changed only in how figures are stored (numeric strings; words moved to params). git diff 696bc5b -- docs/experiments/2026-09-27-turtle-sma200-filter.py shows every line.
  • Reproducibility: the run is deterministic (seed 830), and the committed figures equal the first run's.
  • Prediction miss: 90% of entries were retained for above, not the 40–60% I predicted.

Verification

  • Full suite: 6785 passed, 3 skipped.
  • mypy, ruff check and ruff format --check are clean.
  • verify_chain on the tracked ledger: 98 rows, no problems.

research: accumulation policy after fees -- no arm beats static DCA (#831) (#835)

Closes #831. Implements the approved design (#834, revision 2).

Three commits, in the order that matters:

  1. 7a1e900 feat(sim): keel/sim/accumulation_policy.py. Four allocators; an account with allowance-aware fees and average-cost lots; a joint stationary bootstrap; the D6 decision rule.
    • 47 tests, each seen failing first. 15 of 15 mutants killed; a 16th was equivalent, and its redundant code was removed.
    • Arm A reproduces dca_into_allowlist to the cent under zero fees on gapless candles. It fills at the next open where the benchmark fills at the close, as the design requires, and a second test pins that difference.
  2. 4113ed0 The pre-registration. The driver's docstring is the design's §3–§8, frozen. It was pushed at 02:04 UTC before the run. git diff 4113ed0 -- docs/experiments/2026-09-27-accumulation-policy.py is empty: the driver hasn't changed since.
  3. bd18cae The record, data and 16 ledger rows (ablation, a_priori, diagnostic_only; chain verified, 114 rows).

Result (headline: 5 years without PAXG, allowance-aware fees, $30,500 deposited)

Arm Terminal Sortino Max DD P(ΔSortino > 0), 20 / 60-day blocks Verdict
A static DCA $46,629 0.523 79.5% — baseline
B value averaging $48,840 0.542 78.5% 0.583 / 0.599 not better
C steered deposits $47,967 0.564 79.1% 0.365 / 0.407 not better
D selective trimming $52,919 0.611 79.3% 0.363 / 0.434 not better

No arm is better than static DCA after fees, in either run or fee model.

  • Trimming (D) wins the historical path by 13.5% but improves Sortino in only 36–43% of 2,000 resampled paths. That's a path-specific result, and the record declines to call it a win.
  • Value averaging (B) is the most consistent arm: 58–61% of paths, and about 2 points less drawdown. It is still far from the 0.95 bar.
  • Prediction misses are recorded. D was expected to do best on the with-PAXG run and was instead the weakest alternative there. PAXG's slippage is about 100 bp per leg.

Verification

  • Full suite: 6832 passed, 3 skipped.
  • mypy and ruff are clean.
  • verify_chain on the ledger: no problems.
  • The record's computed claims were re-checked against the data. Three were corrected before committing, and a causal story I hadn't tested was removed.

Fixes

fix(sim): two rules of one kind on one asset get a row each (#829) (#832)

Closes #829.

What was wrong

report.edge_table and report.accumulation_table (#825) keyed each row "{rule.name}:{asset}". When two rules of the same kind sit on the same asset, the later rule's row overwrote the earlier one's.

  • On the deployment: ~/keel/keel.db has two paper BTC DCA rules, rule 19 at $50/week and rule 20 at $5/week. keel simulate printed a single row, the $5 rule's, which looked like a ~$5/week DCA. The $50 sleeve was invisible.
  • A second, quieter effect: group_trades_by_class looked both rules up by that shared key. G2's per-class pool counted the surviving rule's trades twice and the other rule's not at all. The test below shows a class pool of 0 trades where the pooled row has 1. The pooled row itself was always right, because it extends every rule's trades before keying.

The fix

One function, report.rule_keys(rules), now keys all three tables:

  • A key that is already unique is unchanged ("turtle_breakout:BTC"), so existing readers and tests are untouched.
  • Rules that share a key are told apart by their rules.id ("dca#19:BTC", "dca#20:BTC"), or by position when a hand-built rule has no id. No row is ever lost.
  • All three tables must be given the same rules list, which simulate already does. The docstrings say so.

Verification

  • tests/sim/test_rule_keys.py has 6 tests. Five failed first on the old code; the "unique key unchanged" test is a regression pin that passes throughout. They cover:

    • two DCA rules, each at its own budget
    • two edge rules on one asset, with correct pooled N
    • the class pool counting each rule once (it was 0, now 1)
    • unique keys unchanged
    • colliding rules without ids
    • the rendered section showing both rows
  • Five mutants were each checked to have changed the source, and each was killed:

    Mutant Tests failed
    never disambiguate 5
    class pool uses the old key 1
    accumulation uses the old key 2
    always disambiguate 12
    ignore rule_id 3
  • Real data, on a copy of the paper database over the 5-year window:

    Row Buys Cost Value
    dca#19:BTC 261 $13,213.14 ($50.63/buy) $25,715.00
    dca#20:BTC 261 $1,321.31 ($5.06/buy) $2,571.50

    Before the fix only the second row printed.

  • Full suite: 6775 passed, 3 skipped. mypy, ruff check and ruff format --check are clean.

fix(rails): rail 14 is a monthly buy cap, not a fee-free allowance (#836) (#837)

Closes #836.

What was wrong

keel described rail 14's cap as the venue's fee-free allowance, and its research treated "inside the allowance" as a fee-free regime. keel's orders go through Coinbase Advanced Trade, which has no fee-waiver tier:

  • On 2026-09-27 the account reported the Intro tier: 0.9% taker, 0.5% maker, has_promo_fee: false.
  • Every live fill had paid a fee: 1.19%, 1.14%, 1.19% and 0.89%.
  • Coinbase One's "zero trading fees up to $500/month" applies on the main Coinbase platform, not Advanced Trade.

What changed (wording only; enforcement untouched)

  • keel/execution/guards.py has zero changed lines. The 29 rail 14 guard tests pass unchanged, which proves the cap, its fail-closed behaviour and every number it enforces are the same.
  • No schema rename. The free_volume_usd field keeps its historical name. A rename would need a migration and would change no behaviour.
  • Operator-facing text:
    • keel subscription set --help now says "Monthly BUY cap … not a fee waiver".
    • The throughput report says "route WITHIN each venue's attested buy cap … trades inside it still pay the venue's fee".
    • The significance verdict's zero-fee phrase reads "a zero fee (hypothetical: Advanced Trade orders are never fee-free, #836)".
    • The simulator's tier section adds a paragraph saying its within-cap rows are hypothetical for keel's orders.
  • Docs:
    • docs/rails/rail-14-subscription-allowance.md is retitled "the monthly buy cap". Its "What it bounds" section no longer claims a fee waiver or a "profitability boundary", and it carries a dated correction. The enforcement, veto and inspection sections are unchanged.
    • The runbook and the research toolkit are corrected in place.
  • Records are appended to, never rewritten. Five records that described a fee-free regime each gain a dated Fee note under the title: the 2026-08-21 significance, 2026-08-22 trailing exits and 2026-09-05 restatement records, the quant-lab note cross-verification, and the 2026-09-27 accumulation-policy record. The accumulation note says its flat-taker run is the one matching live, and the verdict is unchanged.
  • Code comments and docstrings now call the fee-free tier model hypothetical for keel's orders.

Tests

tests/test_rail14_is_a_buy_cap.py has 6 tests, all seen failing first:

  • the help text,
  • the throughput report,
  • the significance label,
  • the tier section,
  • the rail 14 doc making no fee-waiver claim,
  • every record that describes a fee-free regime carrying the #836 note.

One existing test that pinned the old "fee-free" wording is rewritten to pin the corrected label.

Not in scope, deliberately

  • The keel doctor fee-rate readout (#836 item 4, marked optional there) is left for a follow-up.
  • Switching the DCA to limit orders was decided against on 2026-09-27.

Verification

  • Full suite: 6838 passed, 3 skipped.
  • mypy, ruff check and ruff format --check are clean.

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

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

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

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.

Research & validation

docs(spec): accumulation policy -- value averaging and band rebalancing vs static DCA (#831) (#834)

Refs #831. A draft for review. No code and no simulation until it's approved. Merging this PR is the approval; comments or edits here are the review.

docs/superpowers/specs/2026-09-27-accumulation-policy-design.md covers:

  • The formulas.
    • Bounded value averaging: ΔC = clamp(w·D·t − H, C_min·w·D, C_max·w·D), with a zero-growth target path, buy-only, and C_max = 3.
    • Contribution steering: the monthly deposit goes to underweight assets first; it never sells.
    • Band rebalancing: a ±15% band relative to each target weight, checked monthly, trimming and topping up back to target.
  • The account model every arm shares.
    • Deposits: identical $500 monthly deposits. Idle cash earns nothing, consistent with the fiqh constraints.
    • Fees: modelled the way this account is actually billed. Buys are fee-free up to the $500/month Basic allowance; sells always pay the 1.20% taker fee. A flat-taker sensitivity run sits beside it.
  • Harness requirements.
    • A new pure allocator module, separate from portfolio_sim.
    • Static DCA must reproduce dca_into_allowlist exactly, as the oracle for the whole harness.
    • Tests come first.
  • Rails. This phase is simulation only. The spec records what a live version would hit. Most notably, sell_only_on_rule requires a rule_kind on every sale, so rebalancing trims need a policy decision. The two buy-only arms avoid that question entirely.
  • The prior it has to answer. The knowledge base (§51) calls fixed-weight rebalancing the wrong paradigm here, because correlated crypto offers little dispersion to harvest. The spec sharpens the hypothesis: any rebalancing benefit should come through PAXG, the one low-correlation leg.

Revision 2 (from review): selective trimming in arm D, a ±1.5-point band floor for the small sleeves, and 20- and 60-day bootstrap blocks reported side by side, with the decision rule required to hold under both.

Decisions for you (§9)

# Decision Proposed
D1 Universe and window PAXG's Coinbase history starts in May 2025, so: a 5-year PAXG-free primary run plus a 1.4-year with-PAXG secondary run
D2 Monthly deposit $500
D3 Fee model allowance-aware, plus a flat-taker sensitivity run
D4 Value averaging C_min 0, C_max 3, g = 0
D5 Band and trimming max(0.15·w_i, 0.015), monthly; selective: trim only upper-band breaches, proceeds to underweights
D6 Decision rule P(ΔSortino > 0) ≥ 0.95 and median Δmax-drawdown not worse, under both 20- and 60-day bootstrap blocks

Once D1–D6 are settled, §3–§8 become the driver's pre-registered docstring, committed before any run, exactly as for #830.

Other changes

chore(release): 0.19.0 -- DCA sized per rule, bounded by the venue plan's cap (#845)

Version bump only: all 8 pyprojects, their pins, and uv.lock go from 0.18.0 to 0.19.0. Same shape as #828.

MINOR because live DCA sizing changes (#843) and a rail changes scope (#842). No schema change.

  • #843 (#840): each DCA buy is sized from its rule's size_usd
  • #842 (#841): DCA buys are exempt from rail 4; rails 14 and 6 still bind them
  • #837 (#836): rail 14 relabelled as a monthly buy cap

Local: pytest 6882 passed / 3 skipped, ruff and mypy clean.