Skip to content

fix(sim): DCA buys once per completed day, and the report shows its sleeve (#821) - #825

Merged
eaitbrahim merged 1 commit into
mainfrom
fix/sim-dca-accumulation
Sep 26, 2026
Merged

eaitbrahim merged 1 commit into
mainfrom
fix/sim-dca-accumulation

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

What was wrong

keel simulate got the DCA rule wrong in four ways, and the report hid all of them.

  • The simulator bought DCA about 24 times per cadence day. It evaluates rules on every hourly bar. A DCA rule decides on daily candles, so its answer is the same on all 24 bars of a day, and each of those bars bought. On a synthetic 60-day repro, 9 cadence days at $50 spent about $10,768 instead of $450. Every earlier keel simulate run with a DCA rule has an oversized DCA sleeve. That inflates or distorts its ending value, return, drawdown, caps headroom and rail-14 trading volume.
  • It read the day before the day had happened. The simulator's daily window includes the day still in progress, and that bar is stored with the completed day's OHLC. From 00:00 UTC, Dca.detect priced its buy at that day's close and measured the dip against that day's high. Live only has closed candles, so live DCA sees yesterday, and the simulator's cadence day was one day ahead of live. TurtleBreakout already guarded against this with _completed_days; Dca had no guard.
  • The edge table showed DCA as N 0. Dca declares no timeframe, so backtest() gave it only hourly candles. It needs daily candles, so it never fired. Declaring a daily timeframe would not have fixed it. Its target=entry / stop=0 sentinels would turn each buy into a fee-only round trip closed on its fill bar, which is a fake loss and not an edge.
  • The sleeve was bought but never shown. DCA lots are never closed by design. per_asset_pnl and trade_count come from closed trades only, so the sleeve was inside ending_value and appeared nowhere else in the Markdown report or the HTML artifact.

What changed

  • Completed days only. The forming-day guard now lives in keel/strategy/rules/base.py as completed_days, with its docstring. TurtleBreakout imports it under its old private name, so every existing reference still resolves. Dca.detect now uses it. Live behaviour is unchanged: when the last daily bar is closed and the hourly series is past it, that bar is kept. A test pins this.
  • One DCA decision per UTC day, per (asset, rule) in portfolio_sim. The day is marked when the decision is taken, whether the buy fills or is vetoed. That matches live, where the agent trades once per UTC day. Each buy is logged in SimResult.dca_buys (decision time, fill time, qty, notional, and cash cost including fees). SimResult.final_prices records each asset's last close.
  • Edge table. Rules with the new Rule.accumulates flag, which today means Dca, are left out of edge_table. So they are not in __pooled__ or group_trades_by_class, and G2 gets neither fake round trips nor a zero-trade sample. The new report.accumulation_table runs them over daily candles, using the same completed-day and once-per-day rules. It reports buys, qty, cost basis including fees, last close, marked value and unrealized P&L. The report renders it as its own section, "DCA accumulation (not round trips)", right after the edge table. The edge table's own columns are untouched, so simulate: the Edge table's "unit-less R-multiples" are price units, so the pooled row is BTC #820's R-multiple change should merge cleanly.
  • Account report. build_account_metrics now returns dca_sleeve, built by portfolio_sim.dca_sleeve: per asset, the buys, qty, cost basis, value at the last close, and unrealized P&L. The Markdown report shows it under "DCA sleeve (accumulation, marked to market)". The per-asset list is now labelled "Per-asset realized rule P&L (closed round trips; the DCA sleeve is below)". The HTML artifact gets the same sleeve table, and its chart is retitled "Per-asset realized rule P&L".
  • keel rules promote: nothing changes. Its docstring says a DCA rule produces no backtest trades, so --force is its only path to paper. That is still true. backtest() is unchanged, a test pins N 0, and the promotion path never reads the accumulation row.

Before and after (synthetic repro: 60 days of flat-ish hourly and daily candles, cadence 7, $50)

Before After
Account-sim DCA buys ~24 per cadence day 8, one per cadence day
Account-sim DCA spend ~$10,768 $400.00 notional ($405.95 cash with taker fees and slippage)
Day the buy is decided on the cadence day itself, reading its close from 00:00 the day after the cadence day closes, as live
Edge table dca:BTC row with N 0 no round-trip row; accumulation row with 8 buys, $400.90 cost, $428.93 value, +$28.03 unrealized
Account report sleeve absent; per_asset_pnl {} DCA sleeve table: 8 buys, 3.7572 qty, $405.95 cost, $429.79 value, +$23.84 unrealized

The window has 9 cadence days, but the ninth is its last day. That day's decision falls on the next day, outside the window, so the result is 8 buys, not 9.

Verification

  • TDD: each new test was written first and seen failing for the right reason. The sim test got qty 96, which is 0.5 × 8 cadence days × 24; the forming-day tests got a buy priced off the forming day; the edge test found a dca:BTC round-trip key; the other tests failed on missing names or keys.
    • tests/sim/test_dca_sleeve.py, all using the real Dca: (a) total spend equals budget × cadence days; (d) accumulation row plus a pooled result byte-identical with or without DCA, and group_trades_by_class is empty; (e) sleeve value equals qty × last close and unrealized equals value − cost; (f) the Markdown tables are parsed row by row. Also: the realized-P&L label, and N 0 from backtest().
    • tests/strategy/test_dca.py: (b) at hour 0 the forming day's close and high are ignored, and a forming cadence day does not fire; (c) live-shaped input still fires on the closed day. (c) is a regression pin, so it passes both before and after the change.
    • tests/sim/test_artifact.py: the sleeve table header and cells, and no table when there is no sleeve.
  • Mutation run: each mutant was applied alone, the source was checked to confirm it changed, and the file was restored from a saved copy. All five were killed:
    • once-per-day guard removed (3 tests failed)
    • completed-day drop removed from Dca (5 failed)
    • DCA routed back into edge_table and the pool (2 failed)
    • sleeve marked at cost instead of the last close (1 failed)
    • completed_days drops the last day whenever hourly data exists, the live regression (1 failed)
  • uv run pytest -q: 6735 passed, 3 skipped. uv run ruff check keel tests is clean, uv run ruff format --check keel tests is clean, and uv run mypy reports no issues. No baseline fixtures changed.

Closes #821

🤖 Generated with Claude Code

…leeve (#821)

The account sim evaluated the real Dca rule on every hourly bar, and its
daily window included the still-forming day, so every cadence day bought
24 times at a close that had not happened yet (9 cadence days at $50
spent ~$10,768). The sleeve was then invisible: the edge table gave DCA
N 0 (it never saw daily candles) and per_asset_pnl counted closed trades
only.

- Dca decides on completed days only, via the forming-day guard
  TurtleBreakout already used, now shared as rules.base.completed_days.
  Live input (closed candles only) is kept unchanged.
- portfolio_sim takes at most one DCA decision per (asset, rule, UTC day),
  logs each buy (SimResult.dca_buys) and records final prices.
- edge_table skips accumulating rules (Rule.accumulates); the new
  accumulation_table reports buys, cost, mark-to-market value and
  unrealized P&L, outside __pooled__ and G2.
- The account report and the HTML artifact show the DCA sleeve marked to
  market; per-asset P&L is labelled as realized rule P&L.

Closes #821

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@eaitbrahim eaitbrahim added fix Bug fix (groups under Fixes) research Measurement, validation or KB work (Research & validation) labels Sep 26, 2026
@eaitbrahim
eaitbrahim merged commit 557c257 into main Sep 26, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the fix/sim-dca-accumulation branch September 26, 2026 20:41
eaitbrahim added a commit that referenced this pull request Sep 26, 2026
…oled evaluation as policy (#828)

MINOR: #826 changes what promotion decides (G2 and #338's pooled path
judge in R; min_expectancy is an R threshold; a sample with no R is
refused), and #825 adds report sections. No schema change since 0.17.0.

What lands:
  #826 (#820) -- edge table, G2 and promotion in R; negative-risk flip
  fixed; pooled rows in exit-time order.
  #825 (#821) -- simulate buys DCA once per completed day (was 24x per
  cadence day), no forming-day look-ahead, DCA sleeve reported.
  #824 (#822) -- ADR 0006: daily strategies evaluated on the pooled sample.
  #827 (#823) -- experiment record: hourly turtle, 4,871 trades.
  #819 -- live per-asset ceiling config mirrored (0.50 -> 0.75).

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) research Measurement, validation or KB work (Research & validation)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

simulate: the DCA rule produced 0 trades in a 5-year backtest

1 participant