From 7a1e900e5b593cbcd6aef2348c4f911a29418584 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Sat, 26 Sep 2026 21:59:56 -0400 Subject: [PATCH 1/3] feat(sim): accumulation-policy harness -- four allocators, allowance-aware account, joint stationary bootstrap, D6 rule (#831) Implements the approved design (docs/superpowers/specs/2026-09-27-accumulation-policy-design.md, revision 2) as a pure module, apart from portfolio_sim: static DCA, bounded buy-only value averaging, shortfall-steered DCA and DCA with selective upper-band trimming; an account loop with next-open fills, the monthly fee-free buy allowance (sells always taxed; a flat-taker mode), per-asset slippage and average-cost lots; a deposit-neutral TWR index; a stationary block bootstrap of joint daily rows; and the D6 verdict over both block lengths. Arm A reproduces benchmark.dca_into_allowlist to the cent under zero fees on gapless candles; the one divergence (next open vs same-day close) is pinned by its own test. Co-Authored-By: Claude Opus 5.5 --- keel/sim/accumulation_policy.py | 700 ++++++++++++++++++++++++++ tests/sim/test_accumulation_policy.py | 534 ++++++++++++++++++++ 2 files changed, 1234 insertions(+) create mode 100644 keel/sim/accumulation_policy.py create mode 100644 tests/sim/test_accumulation_policy.py diff --git a/keel/sim/accumulation_policy.py b/keel/sim/accumulation_policy.py new file mode 100644 index 00000000..bb5c97e7 --- /dev/null +++ b/keel/sim/accumulation_policy.py @@ -0,0 +1,700 @@ +"""Accumulation policy (#831): does HOW a monthly deposit is deployed beat static DCA, net of fees? + +The harness for `docs/superpowers/specs/2026-09-27-accumulation-policy-design.md` (revision 2, +D1-D6 settled). This is an ALLOCATOR, not a rule engine: deposits in, orders out. It is kept +apart from `sim.portfolio_sim` on purpose (spec §7) -- that module drives rules, rails, entries +and exits; nothing here opens a risk-defined position, and no rail runs (spec §6). + +**Four arms, one pure function each** (spec §5), all sharing the `Allocator` signature +`(t, prices, holdings, cash, deposit, month_buy_notional, weights) -> list[Order]`, where `t` is +the 1-based month index, `prices` the decision-day closes and `holdings` the units held: + +* `static_dca` (A, the baseline): buy `w_i * D`. +* `value_averaging` (B): target path `V = w_i * D * t` (g = 0); buy + `clamp(V - H_i, C_min * w_i * D, C_max * w_i * D)` with C_min 0, C_max 3; scaled down + proportionally when cash is short. Buy-only: a surplus is never sold. +* `shortfall_steered` (C): `s_i = max(0, w_i (H + D) - H_i)`, buy `D * s_i / sum s`. +* `dca_with_trimming` (D): A's buy, then a SELECTIVE trim of assets strictly above their upper + band (`h_i / H > w_i + b_i`, `b_i = max(0.15 w_i, 0.015)`) down to `w_i`, then the trim's net + proceeds redeployed to the assets below `w_i` by shortfall. A lower-band breach alone is no trade. + +**The account** (`run_account`, spec §4) is common to every arm: + +* **Deposits** land on the FIRST DAILY BAR OF EACH UTC CALENDAR MONTH in the panel -- the 1st + whenever that bar exists. This is `benchmark.dca_into_allowlist`'s convention exactly + (`execution.guards._utc_month_bounds`), so a window that opens mid-month deposits on its first + day, as the benchmark does. Undeployed cash earns nothing (fiqh: no interest). +* **Decisions and fills:** decided on the completed bar of the deposit day, filled at the NEXT + bar's open -- `report.accumulation_table`'s convention (#821). A decision on the panel's last + bar has no next bar and is dropped, as there; the deposit stays as cash. + + ⚠️ This is the ONE place arm A parts from `benchmark.dca_into_allowlist`, which fills at the + deposit day's close. The two agree to the cent whenever each open equals the prior close + (`tests/sim/test_accumulation_policy.py` pins both the agreement and the divergence). Neither + convention is bent to match the other. +* **Costs** (`FeeModel`): slippage worsens every fill price, per asset (buy `open * (1 + s)`, + sell `open * (1 - s)`). A buy's fee is taken off the top of its dollar amount before it is + converted to units -- the benchmark's convention -- so a buy costs exactly its amount in cash. + In `ALLOWANCE` mode buys are free until the calendar month's gross buy notional reaches the + allowance A, and only the excess pays taker; the month is the FILL's month, and buys consume + the allowance in order (A's DCA leg first, D's redeploy last). Sells ALWAYS pay taker. `FLAT` + mode (the sensitivity run) charges taker on every leg. +* **Execution order within a fill is the order list's order**: D's DCA buys, then its trims, then + the redeploys, which share out the trims' ACTUAL net proceeds (the plan sized them at the + decision close; the fill is at the next open). So a trim realises against an average cost that + already includes that day's DCA buy. +* **Holdings:** one `Lot` per asset at average cost; a sale removes units at the average cost and + realises the difference. No leverage, no shorting: a sale is capped at the units held, and a + buy the cash cannot cover raises (beyond a 1e-12 USD rounding allowance for proportional + splits) rather than letting cash go negative. + +**Returns** (spec §7-§8). `twr_returns` is the daily time-weighted return with deposits +neutralised by `metrics.bar_pnl` (a deposit counts as landing at the close of its day, where it +first becomes decision cash), so Sharpe, Sortino and max drawdown -- all `sim.metrics`, rf = 0, +365 periods -- measure policy, not cash timing. `summarize` reports the money-weighted figures +beside it: `metrics.irr` (its own convention: a per-DEPOSIT-period, i.e. monthly, rate) and +`metrics.cagr_money_weighted` (annualised on the calendar span). + +**Inference** (spec §8). `stationary_bootstrap_indices` is Politis-Romano's stationary block +bootstrap (geometric block lengths with the given mean, circular wrap). `resample_panel` applies +ONE index sequence to every asset, so each resampled day is a JOINT historical row and +cross-correlation survives. A row is the pair `(open_t / close_{t-1}, close_t / close_{t-1})`, +so a rebuilt path keeps an open for the next-open fill; the path is anchored at the historical +first bar and keeps the historical timestamps, so every arm sees the same deposit calendar. +`bootstrap_deltas` runs every arm on the same paths and returns `(delta Sortino, delta max +drawdown)` against A per path. `passes`/`verdict` are decision D6: better only if, under BOTH +block lengths, `P(delta Sortino > 0) >= 0.95` (a tie is not an improvement) AND the median delta +max drawdown is not worse (`<= 0`, drawdowns being positive fractions). + +`Decimal` for every money quantity; the only floats are the bootstrap's `random` draws, which +choose indices and never touch money. No dependency on `keel.research` (the direction is +research -> sim, see `report.py`). +""" + +from __future__ import annotations + +import random +import statistics +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass, field +from decimal import Decimal + +from keel.execution.guards import _utc_month_bounds +from keel.sim import metrics + +ZERO = Decimal(0) +ONE = Decimal(1) + +#: Spec §4, decisions D2/D3. +DEPOSIT_USD = Decimal(500) +ALLOWANCE_USD = Decimal(500) +TAKER_PCT = Decimal("0.012") +#: Spec §5, decision D4. +VA_C_MIN = Decimal(0) +VA_C_MAX = Decimal(3) +#: Spec §5, decision D5. +BAND_RELATIVE = Decimal("0.15") +BAND_FLOOR = Decimal("0.015") +#: Spec §8, decision D6. +BLOCK_LENGTHS = (20, 60) +N_PATHS = 2000 +P_SORTINO_THRESHOLD = Decimal("0.95") + +#: A buy may exceed cash by at most this much (proportional splits round at 28 digits); the +#: buy is then trimmed to the cash. Anything larger is an allocator bug and raises. +CASH_ROUNDING_USD = Decimal("1e-12") + +ALLOWANCE = "allowance" +FLAT = "flat" +FEE_MODES = (ALLOWANCE, FLAT) + +BUY = "buy" +SELL = "sell" +REDEPLOY = "redeploy" + +BETTER = "better than static DCA" +BLOCK_DEPENDENT = "block-length-dependent" +NOT_BETTER = "not better than static DCA after fees" + + +@dataclass(frozen=True) +class Order: + """One leg of an allocation. + + `amount` depends on `side`: a BUY is US dollars to spend, a SELL is UNITS to sell, and a + REDEPLOY is the FRACTION of this fill's realised net sale proceeds to spend on the asset. + """ + + asset: str + side: str + amount: Decimal + + +@dataclass(frozen=True) +class PricePanel: + """Aligned daily bars: one timestamp list and, per asset, an open and a close per timestamp.""" + + ts: list[int] + opens: dict[str, list[Decimal]] + closes: dict[str, list[Decimal]] + + def __post_init__(self) -> None: + n = len(self.ts) + if any(b <= a for a, b in zip(self.ts, self.ts[1:], strict=False)): + raise ValueError("panel timestamps must be strictly ascending") + if set(self.opens) != set(self.closes): + raise ValueError("panel opens and closes must name the same assets") + for asset in self.closes: + if len(self.opens[asset]) != n or len(self.closes[asset]) != n: + raise ValueError(f"panel series for {asset} is not aligned to the timestamps") + + +@dataclass(frozen=True) +class FeeModel: + """How a fill is billed (spec §4, D3). `slippage` is per asset; an absent asset slips 0.""" + + mode: str + taker_pct: Decimal = TAKER_PCT + allowance_usd: Decimal = ALLOWANCE_USD + slippage: Mapping[str, Decimal] = field(default_factory=dict) + + def __post_init__(self) -> None: + if self.mode not in FEE_MODES: + raise ValueError(f"fee mode {self.mode!r} not in {FEE_MODES}") + + def buy_fee(self, notional: Decimal, month_used: Decimal) -> Decimal: + """The fee on a buy of `notional` when `month_used` of this month's allowance is gone.""" + if self.mode == FLAT: + return notional * self.taker_pct + free_left = max(ZERO, self.allowance_usd - month_used) + return max(ZERO, notional - free_left) * self.taker_pct + + def sell_fee(self, notional: Decimal) -> Decimal: + """Sells always pay taker: rail 14's allowance covers buys only.""" + return notional * self.taker_pct + + def slip(self, asset: str) -> Decimal: + return self.slippage.get(asset, ZERO) + + +@dataclass +class Lot: + """One asset's holding at average cost. `cost` is the cash paid for the units still held.""" + + qty: Decimal = ZERO + cost: Decimal = ZERO + + @property + def avg_cost(self) -> Decimal: + return self.cost / self.qty if self.qty > 0 else ZERO + + def buy(self, qty: Decimal, cost: Decimal) -> None: + self.qty += qty + self.cost += cost + + def sell(self, qty: Decimal, proceeds: Decimal) -> Decimal: + """Remove `qty` units at the average cost; return the realised P&L.""" + if qty > self.qty: + raise ValueError(f"cannot sell {qty} units from a lot of {self.qty}: no shorting") + cost_out = qty * self.avg_cost + self.qty -= qty + self.cost -= cost_out + return proceeds - cost_out + + +Allocator = Callable[ + [ + int, + Mapping[str, Decimal], + Mapping[str, Decimal], + Decimal, + Decimal, + Decimal, + Mapping[str, Decimal], + ], + list[Order], +] + + +def band(weight: Decimal) -> Decimal: + """Arm D's half-width: 15% of the weight, never under 1.5 points (spec §5, D5).""" + return max(BAND_RELATIVE * weight, BAND_FLOOR) + + +def renormalise(weights: Mapping[str, Decimal], exclude: Sequence[str] = ()) -> dict[str, Decimal]: + """`weights` without `exclude` (and without zero weights), rescaled to sum to 1.""" + kept = {a: w for a, w in weights.items() if a not in exclude and w > 0} + total = sum(kept.values(), ZERO) + if total <= 0: + raise ValueError("no positive weight left to renormalise") + return {a: w / total for a, w in kept.items()} + + +def _values( + prices: Mapping[str, Decimal], holdings: Mapping[str, Decimal], weights: Mapping[str, Decimal] +) -> dict[str, Decimal]: + return {a: holdings.get(a, ZERO) * prices[a] for a in weights} + + +def static_dca( + t: int, + prices: Mapping[str, Decimal], + holdings: Mapping[str, Decimal], + cash: Decimal, + deposit: Decimal, + month_buy_notional: Decimal, + weights: Mapping[str, Decimal], +) -> list[Order]: + """Arm A: buy `w_i * D` of every asset.""" + return [Order(a, BUY, w * deposit) for a, w in weights.items() if w * deposit > 0] + + +def value_averaging( + t: int, + prices: Mapping[str, Decimal], + holdings: Mapping[str, Decimal], + cash: Decimal, + deposit: Decimal, + month_buy_notional: Decimal, + weights: Mapping[str, Decimal], +) -> list[Order]: + """Arm B: bounded, buy-only value averaging on the zero-growth path `V = w_i * D * t`. + + `H_i` is valued at the decision close; `t` counts deposits so far, this one included. When + the wanted buys exceed cash, every buy is scaled by the same factor `cash / wanted`. + """ + values = _values(prices, holdings, weights) + wants: dict[str, Decimal] = {} + for a, w in weights.items(): + slice_ = w * deposit + target = slice_ * t + wants[a] = min(max(target - values[a], VA_C_MIN * slice_), VA_C_MAX * slice_) + wanted = sum(wants.values(), ZERO) + if wanted <= 0 or cash <= 0: + return [] + if wanted > cash: + wants = {a: want * cash / wanted for a, want in wants.items()} + return [Order(a, BUY, want) for a, want in wants.items() if want > 0] + + +def _shortfall_shares( + values: Mapping[str, Decimal], weights: Mapping[str, Decimal], total: Decimal +) -> dict[str, Decimal]: + """`max(0, w_i * total - value_i)` per asset, for the assets with a positive shortfall.""" + shortfalls = {a: max(ZERO, w * total - values[a]) for a, w in weights.items()} + return {a: s for a, s in shortfalls.items() if s > 0} + + +def shortfall_steered( + t: int, + prices: Mapping[str, Decimal], + holdings: Mapping[str, Decimal], + cash: Decimal, + deposit: Decimal, + month_buy_notional: Decimal, + weights: Mapping[str, Decimal], +) -> list[Order]: + """Arm C: deploy D, split by shortfall `s_i = max(0, w_i (H + D) - H_i)`; buy-only. + + When every `s_i` is 0 the deposit is split by weight (spec §5). With weights summing to 1 + and D > 0 that cannot happen -- `sum s_i >= sum (w_i (H + D) - H_i) = D` -- but the spec + states the fallback, so it is kept rather than assumed away. The budget is `min(D, cash)`, + which is D whenever the arm has deployed no more than it was given. + """ + budget = min(deposit, cash) + if budget <= 0: + return [] + values = _values(prices, holdings, weights) + holding_value = sum(values.values(), ZERO) + shortfalls = _shortfall_shares(values, weights, holding_value + deposit) + total = sum(shortfalls.values(), ZERO) + if total <= 0: + return [Order(a, BUY, w * budget) for a, w in weights.items() if w > 0] + return [Order(a, BUY, budget * s / total) for a, s in shortfalls.items()] + + +def dca_with_trimming( + t: int, + prices: Mapping[str, Decimal], + holdings: Mapping[str, Decimal], + cash: Decimal, + deposit: Decimal, + month_buy_notional: Decimal, + weights: Mapping[str, Decimal], +) -> list[Order]: + """Arm D: A's buy, then a selective upper-band trim to target, proceeds to underweights. + + The spec orders the steps buy -> trim -> redeploy, so the band is checked on the POST-BUY + book valued at the decision close: `h_i = H_i + w_i D`, `H = sum h_i`. An asset is trimmed + only when `h_i > (w_i + b_i) H` (strictly: sitting on the edge is inside), and only by + `h_i - w_i H`, sized in units at the decision close. The redeploy shares are the + shortfalls of the assets below `w_i`, against the same `H`. Nothing is traded for a + lower-band breach alone: an underweight is filled only by the regular buy or by trim + proceeds. + """ + orders = static_dca(t, prices, holdings, cash, deposit, month_buy_notional, weights) + values = _values(prices, holdings, weights) + post = {a: values[a] + w * deposit for a, w in weights.items()} + total = sum(post.values(), ZERO) + if total <= 0: + return orders + trims = [ + Order(a, SELL, (post[a] - w * total) / prices[a]) + for a, w in weights.items() + if post[a] > (w + band(w)) * total + ] + if not trims: + return orders + # A trimmed asset sits above `w_i H` by definition, so its shortfall is already 0: the + # proceeds can only reach assets below target. + shortfalls = _shortfall_shares(post, weights, total) + short_total = sum(shortfalls.values(), ZERO) + if short_total <= 0: + # Unreachable with weights summing to 1 (a trim implies an equal total shortfall); + # conservatively hold the proceeds as cash rather than invent a split. + return orders + trims + redeploys = [Order(a, REDEPLOY, s / short_total) for a, s in shortfalls.items()] + return orders + trims + redeploys + + +ARMS: dict[str, Allocator] = { + "A": static_dca, + "B": value_averaging, + "C": shortfall_steered, + "D": dca_with_trimming, +} + + +@dataclass +class AccountResult: + """One arm's account path over one panel.""" + + equity_curve: list[tuple[int, Decimal]] + cash_curve: list[tuple[int, Decimal]] + deposits: list[tuple[int, Decimal]] + lots: dict[str, Lot] + buy_fees: Decimal = ZERO + sell_fees: Decimal = ZERO + buy_notional: Decimal = ZERO + sell_notional: Decimal = ZERO + realized_pnl: Decimal = ZERO + + +class _Book: + """The mutable account `run_account` drives; see the module docstring for its conventions.""" + + def __init__(self, assets: Sequence[str], fees: FeeModel) -> None: + self.fees = fees + self.lots = {a: Lot() for a in assets} + self.cash = ZERO + self.month_used: dict[int, Decimal] = {} + self.result = AccountResult(equity_curve=[], cash_curve=[], deposits=[], lots=self.lots) + + def _spend(self, amount: Decimal) -> Decimal: + if amount > self.cash: + if amount - self.cash > CASH_ROUNDING_USD: + raise ValueError( + f"an allocation spends {amount} with {self.cash} cash: cash may never go " + "negative (no leverage, no borrowing)" + ) + amount = self.cash + return amount + + def fill(self, orders: Sequence[Order], opens: Mapping[str, Decimal], month: int) -> None: + used = self.month_used.get(month, ZERO) + proceeds = ZERO + r = self.result + for order in orders: + lot = self.lots[order.asset] + slip = self.fees.slip(order.asset) + if order.side == SELL: + qty = min(order.amount, lot.qty) + if qty <= 0: + continue + gross = qty * (opens[order.asset] * (ONE - slip)) + fee = self.fees.sell_fee(gross) + net = gross - fee + r.realized_pnl += lot.sell(qty, net) + self.cash += net + proceeds += net + r.sell_fees += fee + r.sell_notional += gross + continue + if order.side == BUY: + amount = order.amount + elif order.side == REDEPLOY: + amount = proceeds * order.amount + else: + raise ValueError(f"unknown order side {order.side!r}") + if amount <= 0: + continue + amount = self._spend(amount) + fee = self.fees.buy_fee(amount, used) + used += amount + lot.buy((amount - fee) / (opens[order.asset] * (ONE + slip)), amount) + self.cash -= amount + r.buy_fees += fee + r.buy_notional += amount + self.month_used[month] = used + + +def run_account( + panel: PricePanel, + weights: Mapping[str, Decimal], + allocator: Allocator, + fees: FeeModel, + deposit: Decimal = DEPOSIT_USD, +) -> AccountResult: + """Drive one arm over `panel`: deposits, next-open fills, fees, average cost, daily marks. + + Every positively weighted asset must be in the panel -- a silently missing asset would + change the universe under the arm, so it raises instead. + """ + live = {a: w for a, w in weights.items() if w > 0} + missing = [a for a in live if a not in panel.closes] + if missing: + raise ValueError(f"weighted assets missing from the panel: {missing}") + book = _Book(list(live), fees) + result = book.result + n = len(panel.ts) + t = 0 + current_month: int | None = None + pending: list[Order] = [] + for i, ts in enumerate(panel.ts): + if pending: + opens = {a: panel.opens[a][i] for a in live} + book.fill(pending, opens, _utc_month_bounds(ts)[0]) + pending = [] + + month, _ = _utc_month_bounds(ts) + decide = month != current_month + if decide: + current_month = month + t += 1 + book.cash += deposit + result.deposits.append((ts, deposit)) + + closes = {a: panel.closes[a][i] for a in live} + held = sum((book.lots[a].qty * closes[a] for a in live), ZERO) + result.equity_curve.append((ts, held + book.cash)) + result.cash_curve.append((ts, book.cash)) + + if decide and i + 1 < n: + fill_month, _ = _utc_month_bounds(panel.ts[i + 1]) + pending = allocator( + t, + closes, + {a: book.lots[a].qty for a in live}, + book.cash, + deposit, + book.month_used.get(fill_month, ZERO), + live, + ) + return result + + +def twr_returns(result: AccountResult) -> list[Decimal]: + """Daily time-weighted returns, deposits neutralised, from the first day with equity.""" + curve = result.equity_curve + start = next((k for k, (_, v) in enumerate(curve) if v > 0), None) + if start is None: + return [] + curve = curve[start:] + pnl = metrics.bar_pnl(curve, result.deposits) + return [p / prev if prev > 0 else ZERO for (_, prev), p in zip(curve, pnl, strict=False)] + + +def twr_index(result: AccountResult) -> list[tuple[int, Decimal]]: + """The TWR index: 1 on the first day with equity, compounded by `twr_returns` after it.""" + curve = result.equity_curve + start = next((k for k, (_, v) in enumerate(curve) if v > 0), None) + if start is None: + return [] + level = ONE + index = [(curve[start][0], level)] + for (ts, _), r in zip(curve[start + 1 :], twr_returns(result), strict=True): + level = level * (ONE + r) + index.append((ts, level)) + return index + + +def summarize(result: AccountResult) -> dict[str, Decimal]: + """The spec §8 per-arm figures, all `Decimal`. + + `turnover` is traded notional (buys + sells) over deposits: the spec names the metric but + not its denominator, and deposits are the one base identical across arms (static DCA reads + ~1). `irr` is `metrics.irr`'s per-deposit-period (monthly) rate; `cagr_money_weighted` is its + calendar-annualised sibling. `avg_cash_share` is the mean over days with equity of cash / + account value. + """ + curve = result.equity_curve + terminal = curve[-1][1] if curve else ZERO + deposited = sum((amount for _, amount in result.deposits), ZERO) + returns = twr_returns(result) + index = twr_index(result) + cashflows = [(ts, -amount) for ts, amount in result.deposits] + shares = [ + cash / value + for (_, value), (_, cash) in zip(curve, result.cash_curve, strict=True) + if value > 0 + ] + traded = result.buy_notional + result.sell_notional + return { + "terminal_value": terminal, + "deposited": deposited, + "irr": metrics.irr(cashflows, terminal), + "cagr_money_weighted": ( + metrics.cagr_money_weighted(cashflows, terminal, curve[0][0], curve[-1][0]) + if curve + else ZERO + ), + "twr_total": index[-1][1] - ONE if index else ZERO, + "sharpe": metrics.sharpe(returns), + "sortino": metrics.sortino(returns), + "max_drawdown": metrics.max_drawdown_pct(index), + "buy_fees": result.buy_fees, + "sell_fees": result.sell_fees, + "buy_notional": result.buy_notional, + "sell_notional": result.sell_notional, + "realized_pnl": result.realized_pnl, + "turnover": traded / deposited if deposited > 0 else ZERO, + "avg_cash_share": sum(shares, ZERO) / len(shares) if shares else ZERO, + } + + +def stationary_bootstrap_indices(n: int, mean_block: int, rng: random.Random) -> list[int]: + """`n` row indices from Politis-Romano's stationary bootstrap with mean block `mean_block`. + + Each step starts a new block at a uniform index with probability `1 / mean_block`, else + continues to the next row, wrapping circularly. Deterministic in `rng`. + """ + if n < 1: + raise ValueError(f"n must be >= 1, got {n}") + if mean_block < 1: + raise ValueError(f"mean_block must be >= 1, got {mean_block}") + p = 1.0 / mean_block + out = [rng.randrange(n)] + while len(out) < n: + out.append(rng.randrange(n) if rng.random() < p else (out[-1] + 1) % n) + return out + + +@dataclass(frozen=True) +class _Rows: + """Per asset, the joint daily rows `open_t / close_{t-1}` and `close_t / close_{t-1}`.""" + + gaps: dict[str, list[Decimal]] + rets: dict[str, list[Decimal]] + + +def _joint_rows(panel: PricePanel) -> _Rows: + gaps: dict[str, list[Decimal]] = {} + rets: dict[str, list[Decimal]] = {} + for a, closes in panel.closes.items(): + opens = panel.opens[a] + gaps[a] = [opens[k] / closes[k - 1] for k in range(1, len(closes))] + rets[a] = [closes[k] / closes[k - 1] for k in range(1, len(closes))] + return _Rows(gaps=gaps, rets=rets) + + +def _rebuild(panel: PricePanel, rows: _Rows, indices: Sequence[int]) -> PricePanel: + n_rows = len(panel.ts) - 1 + if len(indices) != n_rows: + raise ValueError(f"need {n_rows} row indices, got {len(indices)}") + if any(not 0 <= i < n_rows for i in indices): + raise ValueError("a row index is out of range") + opens: dict[str, list[Decimal]] = {} + closes: dict[str, list[Decimal]] = {} + for a in panel.closes: + gaps, rets = rows.gaps[a], rows.rets[a] + prev = panel.closes[a][0] + o, c = [panel.opens[a][0]], [prev] + for i in indices: + o.append(prev * gaps[i]) + prev = prev * rets[i] + c.append(prev) + opens[a], closes[a] = o, c + return PricePanel(ts=list(panel.ts), opens=opens, closes=closes) + + +def resample_panel(panel: PricePanel, indices: Sequence[int]) -> PricePanel: + """A path rebuilt from `panel`'s joint daily rows in the order `indices` gives. + + Day 0 is the historical first bar; day `k >= 1` takes row `indices[k - 1]` for EVERY asset + (row `r` is historical day `r + 1` relative to day `r`), so cross-asset correlation is kept. + Timestamps are the historical ones, so the deposit calendar is unchanged. + """ + return _rebuild(panel, _joint_rows(panel), indices) + + +def _path_stats(result: AccountResult) -> tuple[Decimal, Decimal]: + return metrics.sortino(twr_returns(result)), metrics.max_drawdown_pct(twr_index(result)) + + +def bootstrap_deltas( + panel: PricePanel, + weights: Mapping[str, Decimal], + fees: FeeModel, + *, + mean_block: int, + n_paths: int, + seed: int, + arms: Mapping[str, Allocator] = ARMS, + baseline: str = "A", + deposit: Decimal = DEPOSIT_USD, +) -> dict[str, list[tuple[Decimal, Decimal]]]: + """Per arm, `(Sortino - baseline's, max drawdown - baseline's)` on each resampled path. + + Every arm runs on the SAME `n_paths` paths, drawn from one `random.Random(seed)`, so the same + seed gives the same paths under any fee model -- the fee modes are compared on identical + markets. The baseline's own deltas are zero by construction. + """ + if baseline not in arms: + raise ValueError(f"baseline {baseline!r} is not one of the arms") + rng = random.Random(seed) + rows = _joint_rows(panel) + out: dict[str, list[tuple[Decimal, Decimal]]] = {name: [] for name in arms} + for _ in range(n_paths): + indices = stationary_bootstrap_indices(len(panel.ts) - 1, mean_block, rng) + path = _rebuild(panel, rows, indices) + stats = { + name: _path_stats(run_account(path, weights, fn, fees, deposit)) + for name, fn in arms.items() + } + base_sortino, base_mdd = stats[baseline] + for name, (sortino, mdd) in stats.items(): + out[name].append((sortino - base_sortino, mdd - base_mdd)) + return out + + +def p_sortino_positive(deltas: Sequence[tuple[Decimal, Decimal]]) -> Decimal: + """The share of paths whose Sortino delta is strictly positive (a tie is no improvement).""" + if not deltas: + raise ValueError("no bootstrap deltas to judge") + return Decimal(sum(1 for d_sortino, _ in deltas if d_sortino > 0)) / Decimal(len(deltas)) + + +def median_delta_mdd(deltas: Sequence[tuple[Decimal, Decimal]]) -> Decimal: + if not deltas: + raise ValueError("no bootstrap deltas to judge") + return Decimal(statistics.median([d_mdd for _, d_mdd in deltas])) + + +def passes(deltas: Sequence[tuple[Decimal, Decimal]]) -> bool: + """D6 at one block length: `P(delta Sortino > 0) >= 0.95` and median delta max DD `<= 0`.""" + return p_sortino_positive(deltas) >= P_SORTINO_THRESHOLD and median_delta_mdd(deltas) <= ZERO + + +def verdict(by_block: Mapping[int, Sequence[tuple[Decimal, Decimal]]]) -> str: + """D6: better only when BOTH block lengths pass; one of two is block-length-dependent.""" + if set(by_block) != set(BLOCK_LENGTHS): + raise ValueError( + f"the decision rule needs exactly the block lengths {BLOCK_LENGTHS}, got " + f"{sorted(by_block)}: judging one length would let it be chosen after the fact" + ) + passed = [passes(by_block[length]) for length in BLOCK_LENGTHS] + if all(passed): + return BETTER + if any(passed): + return BLOCK_DEPENDENT + return NOT_BETTER diff --git a/tests/sim/test_accumulation_policy.py b/tests/sim/test_accumulation_policy.py new file mode 100644 index 00000000..be52c4d6 --- /dev/null +++ b/tests/sim/test_accumulation_policy.py @@ -0,0 +1,534 @@ +"""Tests for `keel.sim.accumulation_policy` (#831): the accumulation-policy harness. + +Every expected order and fee below is computed by hand in the test, from the spec's formulas +(`docs/superpowers/specs/2026-09-27-accumulation-policy-design.md` §4-§8), never read back from +the module. Price paths are tiny and synthetic. "Gapless" candles have each day's open equal to +the previous day's close, which is the one case where the spec's next-open fill and +`benchmark.dca_into_allowlist`'s same-day-close fill coincide. +""" + +from __future__ import annotations + +import random +from datetime import UTC, datetime, timedelta +from decimal import Decimal + +import pytest + +from keel.sim import accumulation_policy as ap +from keel.sim.benchmark import dca_into_allowlist + +D = Decimal +CENT = D("0.01") + + +def _panel( + start: tuple[int, int, int], + closes: dict[str, list[Decimal]], + opens: dict[str, list[Decimal]] | None = None, +) -> ap.PricePanel: + """A daily panel from `start`; opens default to gapless (open[k] == close[k-1]).""" + n = len(next(iter(closes.values()))) + base = datetime(*start, tzinfo=UTC) + ts = [int((base + timedelta(days=k)).timestamp()) for k in range(n)] + if opens is None: + opens = {a: [c[0]] + c[:-1] for a, c in closes.items()} + return ap.PricePanel(ts=ts, opens=opens, closes=closes) + + +def _zero_fees(mode: str = ap.ALLOWANCE) -> ap.FeeModel: + return ap.FeeModel(mode=mode, taker_pct=D(0), allowance_usd=D(500), slippage={}) + + +def _fees(mode: str = ap.ALLOWANCE) -> ap.FeeModel: + return ap.FeeModel(mode=mode, taker_pct=D("0.012"), allowance_usd=D(500), slippage={}) + + +def _wiggly(n: int, base: int, step: int, mod: int) -> list[Decimal]: + return [D(base) + D((k * step) % mod) / D(4) for k in range(n)] + + +# -- (a) arm A is the trusted benchmark under zero fees ------------------------------------------ + + +def test_arm_a_zero_fee_reproduces_dca_into_allowlist_to_the_cent(): + n = 100 # 2024-01-01 .. 2024-04-09: four deposit months, the last one still fillable + closes = { + "BTC": _wiggly(n, 40_000, 37, 23), + "ETH": _wiggly(n, 2_000, 11, 17), + "SOL": _wiggly(n, 100, 5, 13), + } + panel = _panel((2024, 1, 1), closes) + weights = {"BTC": D("0.5"), "ETH": D("0.3"), "SOL": D("0.2")} + + ours = ap.run_account(panel, weights, ap.static_dca, _zero_fees(), deposit=D(500)) + bench = dca_into_allowlist( + prices_by_asset={a: list(zip(panel.ts, c, strict=True)) for a, c in closes.items()}, + target_weights=weights, + monthly_contribution=D(500), + months=4, + fee_pct=D(0), + slippage_pct=D(0), + ) + + assert ours.deposits == bench.contributions + assert len(ours.deposits) == 4 + assert [ts for ts, _ in ours.equity_curve] == [ts for ts, _ in bench.equity_curve] + assert [v.quantize(CENT) for _, v in ours.equity_curve] == [ + v.quantize(CENT) for _, v in bench.equity_curve + ] + + +def test_arm_a_fills_at_the_next_open_not_the_benchmark_close(): + # The one place the two conventions part: a gap between close[d] and open[d+1]. Arm A buys at + # the next open (spec §4, as `report.accumulation_table`); the benchmark at the close. + closes = {"BTC": [D(100)] * 5} + opens = {"BTC": [D(100), D(125), D(100), D(100), D(100)]} + panel = _panel((2024, 1, 1), closes, opens) + + ours = ap.run_account(panel, {"BTC": D(1)}, ap.static_dca, _zero_fees()) + + assert ours.lots["BTC"].qty == D(500) / D(125) + assert ours.equity_curve[-1][1] == D(400) + + +# -- (b) each arm's formula on hand-built states ------------------------------------------------- + + +def test_arm_a_buys_weight_times_deposit(): + weights = {"X": D("0.5"), "Y": D("0.3"), "Z": D("0.2")} + prices = {"X": D(1), "Y": D(1), "Z": D(1)} + holdings = {"X": D(0), "Y": D(0), "Z": D(0)} + + orders = ap.static_dca(1, prices, holdings, D(500), D(500), D(0), weights) + + assert orders == [ + ap.Order("X", ap.BUY, D(250)), + ap.Order("Y", ap.BUY, D(150)), + ap.Order("Z", ap.BUY, D(100)), + ] + + +def test_arm_b_buys_up_to_the_value_path_and_never_sells_a_surplus(): + # t=3, D=100, w=.5/.5 -> V = 150 each. X holds $100 (buy 50), Y holds $200 (surplus: 0). + weights = {"X": D("0.5"), "Y": D("0.5")} + prices = {"X": D(100), "Y": D(50)} + holdings = {"X": D(1), "Y": D(4)} + + orders = ap.value_averaging(3, prices, holdings, D(500), D(100), D(0), weights) + + assert orders == [ap.Order("X", ap.BUY, D(50))] + + +def test_arm_b_caps_each_buy_at_three_times_its_dca_slice(): + # t=10, empty book: V = 500 each, capped at C_max * w * D = 3 * .5 * 100 = 150. + weights = {"X": D("0.5"), "Y": D("0.5")} + prices = {"X": D(1), "Y": D(1)} + holdings = {"X": D(0), "Y": D(0)} + + orders = ap.value_averaging(10, prices, holdings, D(10_000), D(100), D(0), weights) + + assert orders == [ap.Order("X", ap.BUY, D(150)), ap.Order("Y", ap.BUY, D(150))] + + +def test_arm_c_splits_the_deposit_by_shortfall(): + # H = 200, D = 100 -> targets .4/.4/.2 of 300 = 120/120/60; shortfalls 20/20/60 (sum 100). + weights = {"X": D("0.4"), "Y": D("0.4"), "Z": D("0.2")} + prices = {"X": D(1), "Y": D(1), "Z": D(1)} + holdings = {"X": D(100), "Y": D(100), "Z": D(0)} + + orders = ap.shortfall_steered(2, prices, holdings, D(100), D(100), D(0), weights) + + assert orders == [ + ap.Order("X", ap.BUY, D(20)), + ap.Order("Y", ap.BUY, D(20)), + ap.Order("Z", ap.BUY, D(60)), + ] + + +def test_arm_c_sends_everything_to_the_only_underweight(): + # X 300, Y 100, D 200 -> targets 300/300; s_X = 0, s_Y = 200: all of D to Y, none to X. + weights = {"X": D("0.5"), "Y": D("0.5")} + prices = {"X": D(1), "Y": D(1)} + holdings = {"X": D(300), "Y": D(100)} + + orders = ap.shortfall_steered(2, prices, holdings, D(200), D(200), D(0), weights) + + assert orders == [ap.Order("Y", ap.BUY, D(200))] + + +def test_arm_d_buys_like_a_then_trims_the_upper_breach_and_redeploys(): + # Post-buy: X 400+50 = 450, Y 100+50 = 150, H = 600. X share .75 > .5 + .075 -> trim to 300: + # sell $150 = 15 units at $10. Y is the only underweight (shortfall 150): redeploy share 1. + weights = {"X": D("0.5"), "Y": D("0.5")} + prices = {"X": D(10), "Y": D(1)} + holdings = {"X": D(40), "Y": D(100)} + + orders = ap.dca_with_trimming(2, prices, holdings, D(100), D(100), D(0), weights) + + assert orders == [ + ap.Order("X", ap.BUY, D(50)), + ap.Order("Y", ap.BUY, D(50)), + ap.Order("X", ap.SELL, D(15)), + ap.Order("Y", ap.REDEPLOY, D(1)), + ] + + +# -- (c) the allowance split --------------------------------------------------------------------- + + +def test_buys_within_the_allowance_are_free_and_the_excess_pays_taker(): + fees = _fees(ap.ALLOWANCE) + assert fees.buy_fee(D(100), month_used=D(0)) == D(0) + assert fees.buy_fee(D(100), month_used=D(400)) == D(0) # lands exactly on A: still free + assert fees.buy_fee(D(300), month_used=D(400)) == D(200) * D("0.012") + assert fees.buy_fee(D(100), month_used=D(500)) == D(100) * D("0.012") + assert fees.buy_fee(D(100), month_used=D(900)) == D(100) * D("0.012") + + +def test_sells_always_pay_taker_and_flat_mode_taxes_every_leg(): + allowance, flat = _fees(ap.ALLOWANCE), _fees(ap.FLAT) + assert allowance.sell_fee(D(1000)) == D(12) + assert flat.sell_fee(D(1000)) == D(12) + assert flat.buy_fee(D(100), month_used=D(0)) == D("1.2") + assert flat.buy_fee(D(300), month_used=D(400)) == D("3.6") + + +def _trim_path() -> ap.PricePanel: + # Jan: X and Y flat at 100. From Feb 1: X at 300. Gapless, so Feb's fill opens at 300. + n = 34 # 2024-01-01 .. 2024-02-03 + x = [D(100)] * 31 + [D(300)] * 3 + return _panel((2024, 1, 1), {"X": x, "Y": [D(100)] * n}) + + +def test_arm_d_run_charges_the_sale_and_the_redeploy_beyond_the_allowance(): + weights = {"X": D("0.5"), "Y": D("0.5")} + result = ap.run_account(_trim_path(), weights, ap.dca_with_trimming, _fees(ap.ALLOWANCE)) + + # Feb decision: X 2.5 units * 300 = 750 (+250 buy = 1000), Y 250 (+250 = 500), H = 1500. + # X share .667 > .575: sell down to 750 -> $250 = 250/300 units, filled at the 300 open. + sold = D(250) / D(300) + gross = sold * D(300) + sell_fee = gross * D("0.012") + proceeds = gross - sell_fee + # Two $500 DCA buys used the allowance; the whole redeploy is beyond it. + assert result.sell_fees == sell_fee + assert result.buy_fees == proceeds * D("0.012") + assert result.sell_notional == gross + assert result.buy_notional == D(1000) + proceeds + + +def test_flat_mode_taxes_the_dca_buys_as_well(): + weights = {"X": D("0.5"), "Y": D("0.5")} + result = ap.run_account(_trim_path(), weights, ap.dca_with_trimming, _fees(ap.FLAT)) + + # Jan buys 250 each at fee .012: X qty = 247/100. Feb buy 250 at 300: X qty += 247/300. + x_qty = D(247) / D(100) + x_value = x_qty * D(300) + D(250) # post-buy X at the decision close + y_value = D(247) / D(100) * D(100) + D(250) + total = x_value + y_value + sell_value = x_value - D("0.5") * total + sold = sell_value / D(300) + gross = sold * D(300) + proceeds = gross - gross * D("0.012") + assert result.buy_fees == D(1000) * D("0.012") + proceeds * D("0.012") + assert result.sell_fees == gross * D("0.012") + + +def test_arm_a_is_free_under_the_allowance_and_pays_on_every_buy_when_flat(): + closes = {"X": [D(100)] * 70} # 2024-01-01 .. 2024-03-10: three deposits + panel = _panel((2024, 1, 1), closes) + allowance = ap.run_account(panel, {"X": D(1)}, ap.static_dca, _fees(ap.ALLOWANCE)) + flat = ap.run_account(panel, {"X": D(1)}, ap.static_dca, _fees(ap.FLAT)) + assert allowance.buy_fees == D(0) + assert flat.buy_fees == D(1500) * D("0.012") + assert allowance.sell_fees == flat.sell_fees == D(0) + + +def test_slippage_worsens_every_leg_per_asset(): + fees = ap.FeeModel( + mode=ap.ALLOWANCE, taker_pct=D(0), allowance_usd=D(500), slippage={"X": D("0.01")} + ) + closes = {"X": [D(100)] * 5, "Y": [D(100)] * 5} + result = ap.run_account( + _panel((2024, 1, 1), closes), {"X": D("0.5"), "Y": D("0.5")}, ap.static_dca, fees + ) + assert result.lots["X"].qty == D(250) / D(101) + assert result.lots["Y"].qty == D(250) / D(100) + + +# -- (d) cash never goes negative ---------------------------------------------------------------- + + +def test_arm_b_scales_down_proportionally_when_cash_is_short(): + # Wants 150 + 150 = 300 but holds 200: each buy scaled by 2/3 -> 100 each. + weights = {"X": D("0.5"), "Y": D("0.5")} + prices = {"X": D(1), "Y": D(1)} + holdings = {"X": D(0), "Y": D(0)} + + orders = ap.value_averaging(10, prices, holdings, D(200), D(100), D(0), weights) + + assert orders == [ap.Order("X", ap.BUY, D(100)), ap.Order("Y", ap.BUY, D(100))] + + +def test_the_account_refuses_an_allocator_that_overspends(): + def greedy(t, prices, holdings, cash, deposit, month_buy_notional, weights): + return [ap.Order("X", ap.BUY, cash + D(1))] + + panel = _panel((2024, 1, 1), {"X": [D(100)] * 5}) + with pytest.raises(ValueError, match="cash"): + ap.run_account(panel, {"X": D(1)}, greedy, _zero_fees()) + + +@pytest.mark.parametrize("arm", sorted(ap.ARMS)) +def test_no_arm_ever_holds_negative_cash(arm): + rng = random.Random(831) + n = 200 + closes: dict[str, list[Decimal]] = {} + for asset in ("X", "Y", "Z"): + price, series = D(100), [] + for _ in range(n): + price = price * (D(1) + D(rng.randint(-80, 80)) / D(1000)) + series.append(price) + closes[asset] = series + weights = {"X": D("0.5"), "Y": D("0.3"), "Z": D("0.2")} + + result = ap.run_account(_panel((2024, 1, 1), closes), weights, ap.ARMS[arm], _fees()) + + assert len(result.cash_curve) == n + assert min(cash for _, cash in result.cash_curve) >= 0 + + +# -- (e) average-cost accounting ----------------------------------------------------------------- + + +def test_lot_sells_at_average_cost(): + lot = ap.Lot() + lot.buy(D(1), D(100)) + lot.buy(D(1), D(200)) + assert (lot.qty, lot.cost, lot.avg_cost) == (D(2), D(300), D(150)) + + realized = lot.sell(D("0.5"), proceeds=D(100)) + + assert realized == D(25) + assert (lot.qty, lot.cost, lot.avg_cost) == (D("1.5"), D(225), D(150)) + + +def test_lot_refuses_to_sell_more_than_it_holds(): + lot = ap.Lot() + lot.buy(D(1), D(100)) + with pytest.raises(ValueError): + lot.sell(D(2), proceeds=D(1)) + + +def test_arm_d_realizes_the_trim_against_average_cost(): + weights = {"X": D("0.5"), "Y": D("0.5")} + result = ap.run_account(_trim_path(), weights, ap.dca_with_trimming, _zero_fees()) + + # X: 2.5 units for $250, then 250/300 units for $250 -> avg cost = 500 / (2.5 + 250/300). + held = D("2.5") + D(250) / D(300) + avg = D(500) / held + sold = D(250) / D(300) + assert result.realized_pnl == sold * D(300) - sold * avg + assert result.lots["X"].qty == held - sold + + +# -- (f) arm D trims only upper-band breaches ---------------------------------------------------- + + +@pytest.mark.parametrize( + ("weight", "expected"), + [ + (D("0.30"), D("0.045")), + (D("0.20"), D("0.030")), + (D("0.06"), D("0.015")), + (D("0.075"), D("0.015")), + (D("0.375"), D("0.05625")), + ], +) +def test_band_is_fifteen_percent_relative_with_a_one_and_a_half_point_floor(weight, expected): + assert ap.band(weight) == expected + + +def _d_orders(values: dict[str, Decimal], weights: dict[str, Decimal]) -> list[ap.Order]: + # Zero deposit so the band check sees exactly `values`; prices of 1 make value == units. + prices = {a: D(1) for a in values} + return ap.dca_with_trimming(2, prices, values, D(0), D(0), D(0), weights) + + +def test_floor_band_six_percent_weight_has_a_four_and_a_half_to_seven_and_a_half_corridor(): + weights = {"S": D("0.06"), "B": D("0.94")} + assert _d_orders({"S": D("7.4"), "B": D("92.6")}, weights) == [] + assert _d_orders({"S": D("7.5"), "B": D("92.5")}, weights) == [] # on the edge: not above + assert _d_orders({"S": D("7.6"), "B": D("92.4")}, weights) == [ + ap.Order("S", ap.SELL, D("1.6")), + ap.Order("B", ap.REDEPLOY, D(1)), + ] + + +def test_a_lower_band_breach_alone_triggers_no_trade(): + weights = {"S": D("0.06"), "B": D("0.94")} + assert _d_orders({"S": D("4.4"), "B": D("95.6")}, weights) == [] + + +def test_in_band_overweights_are_left_alone_and_proceeds_go_by_shortfall(): + # X .36 > .345 is trimmed by 6. Y .22 is overweight but inside .23: untouched, and gets + # nothing. Z (.42 vs .50) is the only underweight. + weights = {"X": D("0.3"), "Y": D("0.2"), "Z": D("0.5")} + assert _d_orders({"X": D(36), "Y": D(22), "Z": D(42)}, weights) == [ + ap.Order("X", ap.SELL, D(6)), + ap.Order("Z", ap.REDEPLOY, D(1)), + ] + + +def test_trim_proceeds_split_across_underweights_by_shortfall(): + # X trimmed by 6; Y short 4, Z short 2 -> shares 4/6 and 2/6. + weights = {"X": D("0.3"), "Y": D("0.2"), "Z": D("0.5")} + assert _d_orders({"X": D(36), "Y": D(16), "Z": D(48)}, weights) == [ + ap.Order("X", ap.SELL, D(6)), + ap.Order("Y", ap.REDEPLOY, D(4) / D(6)), + ap.Order("Z", ap.REDEPLOY, D(2) / D(6)), + ] + + +# -- (g) the time-weighted return neutralises deposits ------------------------------------------- + + +@pytest.mark.parametrize("arm", sorted(ap.ARMS)) +def test_flat_prices_give_zero_twr_despite_deposits(arm): + panel = _panel((2024, 1, 1), {"X": [D(100)] * 70, "Y": [D(50)] * 70}) + result = ap.run_account(panel, {"X": D("0.5"), "Y": D("0.5")}, ap.ARMS[arm], _fees()) + + assert result.equity_curve[-1][1] == D(1500) + returns = ap.twr_returns(result) + assert len(returns) == 69 + assert set(returns) == {D(0)} + assert {v for _, v in ap.twr_index(result)} == {D(1)} + + +def test_twr_is_the_policy_return_not_the_money_weighted_one(): + # 5 units bought at 100; the price doubles mid-January; a Feb deposit adds cash, not return. + closes = [D(100)] * 14 + [D(200)] * 19 # 2024-01-01 .. 2024-02-02 + result = ap.run_account( + _panel((2024, 1, 1), {"X": closes}), {"X": D(1)}, ap.static_dca, _zero_fees() + ) + + index = ap.twr_index(result) + assert index[0] == (result.equity_curve[0][0], D(1)) + assert index[13][1] == D(1) + assert index[14][1] == D(2) + assert index[-1][1] == D(2) + assert result.equity_curve[-1][1] == D(1500) + + +def test_summarize_reports_the_account_on_a_hand_computed_flat_path(): + panel = _panel((2024, 1, 1), {"X": [D(100)] * 63}) # 2024-01-01 .. 2024-03-03 + result = ap.run_account(panel, {"X": D(1)}, ap.static_dca, _fees()) + + s = ap.summarize(result) + + assert s["terminal_value"] == D(1500) + assert s["deposited"] == D(1500) + assert s["buy_fees"] == D(0) + assert s["sell_fees"] == D(0) + assert s["turnover"] == D(1) + assert s["twr_total"] == D(0) + assert s["sortino"] == D(0) + assert s["max_drawdown"] == D(0) + # Cash share: 1 on Jan 1 (all cash), 1/2 on Feb 1, 1/3 on Mar 1, 0 on every other day. + assert s["avg_cash_share"] == (D(1) + D(500) / D(1000) + D(500) / D(1500)) / D(63) + assert abs(s["irr"]) < D("0.000001") + + +# -- (h) the stationary block bootstrap ---------------------------------------------------------- + + +def test_bootstrap_indices_are_deterministic_under_a_seed(): + a = ap.stationary_bootstrap_indices(500, 20, random.Random(7)) + b = ap.stationary_bootstrap_indices(500, 20, random.Random(7)) + c = ap.stationary_bootstrap_indices(500, 20, random.Random(8)) + assert a == b + assert a != c + assert len(a) == 500 + assert all(0 <= i < 500 for i in a) + + +@pytest.mark.parametrize("mean_block", [20, 60]) +def test_bootstrap_mean_block_length_is_on_target(mean_block): + n = 200_000 + idx = ap.stationary_bootstrap_indices(n, mean_block, random.Random(1)) + starts = 1 + sum(1 for prev, cur in zip(idx, idx[1:]) if cur != (prev + 1) % n) + mean = n / starts + assert abs(mean - mean_block) / mean_block < 0.05 + + +def test_resample_panel_moves_joint_rows_with_the_same_index_for_every_asset(): + panel = _panel( + (2024, 1, 1), + closes={ + "X": [D(100), D(200), D(100), D(300)], + "Y": [D(10), D(5), D(20), D(20)], + }, + opens={ + "X": [D(100), D(110), D(190), D(105)], + "Y": [D(10), D(11), D(6), D(19)], + }, + ) + + path = ap.resample_panel(panel, [2, 0, 1]) + + assert path.ts == panel.ts + # Day 1 takes row 2 (X x3 gap 1.05; Y x1 gap .95), day 2 row 0, day 3 row 1 -- both assets. + assert path.closes["X"] == [D(100), D(300), D(600), D(300)] + assert path.opens["X"] == [D(100), D(105), D(330), D(570)] + assert path.closes["Y"] == [D(10), D(10), D(5), D(20)] + assert path.opens["Y"] == [D(10), D("9.5"), D(11), D(6)] + + +def test_bootstrap_deltas_are_deterministic_and_zero_for_the_baseline(): + rng = random.Random(3) + closes = {a: [D(100) + D(rng.randint(-20, 20)) for _ in range(70)] for a in ("X", "Y")} + panel = _panel((2024, 1, 1), closes) + weights = {"X": D("0.5"), "Y": D("0.5")} + + first = ap.bootstrap_deltas(panel, weights, _fees(), mean_block=5, n_paths=3, seed=11) + again = ap.bootstrap_deltas(panel, weights, _fees(), mean_block=5, n_paths=3, seed=11) + + assert first == again + assert sorted(first) == ["A", "B", "C", "D"] + assert all(len(v) == 3 for v in first.values()) + assert first["A"] == [(D(0), D(0))] * 3 + + +# -- (i) the decision rule ----------------------------------------------------------------------- + + +def _deltas(n_positive: int, n: int = 20, mdd: Decimal = D("-0.01")) -> list[tuple[D, D]]: + return [(D("0.1") if k < n_positive else D("-0.1"), mdd) for k in range(n)] + + +def test_passes_at_exactly_ninety_five_percent_with_a_drawdown_no_worse(): + assert ap.passes(_deltas(19)) is True + assert ap.passes(_deltas(18)) is False + assert ap.passes(_deltas(20, mdd=D(0))) is True + assert ap.passes(_deltas(20, mdd=D("0.001"))) is False + + +def test_a_zero_sortino_difference_is_not_an_improvement(): + tied = [(D(0), D(0))] * 20 + assert ap.passes(tied) is False + + +def test_verdict_branches(): + good, bad = _deltas(20), _deltas(10) + assert ap.verdict({20: good, 60: good}) == ap.BETTER + assert ap.verdict({20: good, 60: bad}) == ap.BLOCK_DEPENDENT + assert ap.verdict({20: bad, 60: good}) == ap.BLOCK_DEPENDENT + assert ap.verdict({20: bad, 60: bad}) == ap.NOT_BETTER + + +def test_verdict_refuses_a_missing_block_length(): + with pytest.raises(ValueError): + ap.verdict({20: _deltas(20)}) + with pytest.raises(ValueError): + ap.passes([]) From 4113ed08a233386b5bb8ed61a456f5c298fca33d Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Sat, 26 Sep 2026 22:03:41 -0400 Subject: [PATCH 2/3] docs(experiments): pre-register the accumulation-policy experiment -- driver written, NOT yet run (#831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The driver's docstring is the pre-registration: spec §3-§8 frozen with D1-D6 settled, the expectation recorded, both runs (5-year PAXG-free primary to now_ts=1790455996; ~1.4-year with-PAXG secondary from PAXG's first cached daily bar), both fee modes, both bootstrap block lengths (20, 60; 2,000 paths; seed 831) and the D6 decision rule. No real candle has been run through it; it was smoke-tested only on synthetic candles in a throwaway database. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-27-accumulation-policy.py | 412 ++++++++++++++++++ 1 file changed, 412 insertions(+) create mode 100644 docs/experiments/2026-09-27-accumulation-policy.py diff --git a/docs/experiments/2026-09-27-accumulation-policy.py b/docs/experiments/2026-09-27-accumulation-policy.py new file mode 100644 index 00000000..90f8dad5 --- /dev/null +++ b/docs/experiments/2026-09-27-accumulation-policy.py @@ -0,0 +1,412 @@ +"""PRE-REGISTERED BEFORE RUNNING: accumulation policy vs static DCA after fees (#831), the driver. + +This docstring is the pre-registration. It freezes §3-§8 of the approved design, +`docs/superpowers/specs/2026-09-27-accumulation-policy-design.md` (revision 2, #834), with +decisions D1-D6 settled as that spec proposes them. It was committed before the driver had run +against any real candle, so no number existed when it was written. The harness is +`keel/sim/accumulation_policy.py`; its module docstring states the account conventions in full. + +## The question +Given the same monthly deposits, does *how* the money is deployed (value averaging, steering +contributions to underweight assets, or band trimming) give better risk-adjusted accumulation +than static monthly DCA, net of the fees this account actually pays? This is accumulation +policy, not an edge claim: ADR 0006's trade-count floor does not apply, and nothing here changes +the live DCA rule or any rail. + +## Universe, weights, windows (spec §3, decision D1) +- **Weights:** `target_weights` from `config.paperforward.yaml`: BTC 0.30, ETH 0.20, PAXG 0.20, + SOL, XLM, LTC, ADA, LINK 0.06 each. The driver refuses to run if the file's weights differ. +- **Primary run, PAXG-free, 5 years:** the 7 non-PAXG assets, weights renormalised to sum to 1 + (BTC 0.375, ETH 0.25, the other five 0.075). The window is 5 x 365 days ending + `now_ts = 1790455996` (2026-09-26 20:53:16 UTC), i.e. from 2021-09-27 20:53 UTC. This is the + #830 window. +- **Secondary run, with PAXG, ~1.4 years:** all 8 assets at the config weights, from PAXG-USD's + first cached daily bar (Coinbase's history starts 2025-05-08) to the same `now_ts`. It is + short and covers one regime, and it is labelled as such. It is the only test of the PAXG + prediction below. +- **Data:** daily candles from the `--db` cache via `commands.simulate.load_sim_candles`. Only + COMPLETED daily bars are used (`ts + 86400 <= now_ts`), so the still-forming bar of 2026-09-26 + is excluded. Each run's panel is the intersection of its assets' daily timestamps; any day + missing for one asset is dropped for all, and the count is written to the output. + +## The account, common to every arm (spec §4, decisions D2, D3) +- **Deposits:** D = $500 on the first daily bar of each UTC calendar month in the panel (the 1st + whenever that bar exists), identical for every arm. This is `sim.benchmark. + dca_into_allowlist`'s convention, so a window that opens mid-month, as the primary one does, + deposits on its first day. Undeployed cash stays cash and earns nothing. +- **Fills:** decided on the completed bar of the deposit day, filled at the next bar's open, as + in `report.accumulation_table` (#821). A decision with no next bar is dropped. +- **Fees, allowance-aware (primary):** buys are fee-free until the calendar month's buy notional + reaches A = $500 (Coinbase One Basic, `subscription.assumed_free_volume_usd`); the excess + pays the 1.20% taker fee. Sells always pay 1.20%. A buy's fee comes off the top of its dollar + amount. +- **Fees, flat taker (sensitivity):** 1.20% on every leg, as for an account with no subscription. +- **Slippage:** per product on every leg, from `commands.simulate.slippage_assumptions` (#259), + computed from each run's own loaded daily bars, with `SIM_SLIPPAGE_PCT` as the fallback. +- **Holdings:** one lot per asset at average cost. No leverage, no shorting, no borrowing. Cash + never goes negative. + +## Arms, all parameters declared (spec §5, decisions D4, D5) +Let `w_i` be the weights, `H_i` the value held in asset i at the decision close before month t's +trades, `H = sum H_i`, and `t = 1, 2, ...` the month index. +- **A. Static DCA (baseline):** buy `w_i * D`. Under a zero-fee model on gapless candles it + reproduces `dca_into_allowlist` to the cent (a harness test). +- **B. Bounded value averaging, buy-only:** `V = w_i * D * t` (g = 0); + `buy_i = clamp(V - H_i, 0, 3 * w_i * D)`, every buy scaled by the same factor if cash is + short. A surplus is never sold, and cash left idle is reported as cash drag. +- **C. Deposit steered by shortfall, buy-only:** `s_i = max(0, w_i (H + D) - H_i)`, + `buy_i = D * s_i / sum s`, falling back to `w_i` if `sum s = 0`. +- **D. DCA plus selective trimming:** A's buy; then sell only assets strictly above the upper band + `h_i / H > w_i + b_i`, down to `w_i`, with the band checked on the post-buy book; then + redeploy the trims' net proceeds to the assets below `w_i`, by shortfall. + `b_i = max(0.15 w_i, 0.015)`. A lower-band breach alone triggers no trade. + +Three treatments against one baseline: three comparisons, stated beside every difference. + +## Metrics (spec §7-§8) +Per arm, run and fee mode, on the historical path: terminal account value (holdings at the close +plus cash), IRR on deposits (`metrics.irr`, a per-deposit-period rate) and the money-weighted +CAGR beside it, a daily time-weighted return index with deposits neutralised, Sharpe, **Sortino** +and **max drawdown** on that index (`sim.metrics`, rf = 0, 365 periods), fees paid (buy and +sell), turnover (buy plus sell notional over deposits), and average cash share. + +## Inference (spec §8) +A stationary block bootstrap (Politis-Romano) of JOINT daily rows: every asset takes the same +row indices, so cross-correlation survives. A row is `(open_t / close_{t-1}, close_t / +close_{t-1})`; each path is rebuilt from the historical first bar on the historical calendar. +Mean block lengths **20 and 60 days**, **2,000 paths** each, **seed 831** for every (run, fee +mode, block length), so both fee modes see identical paths. Every arm runs on the same paths. +Reported per arm and block length: the distribution of delta(arm - A) in Sortino and in max +drawdown, meaning `P(delta Sortino > 0)`, the median deltas, and the 5th and 95th percentiles of +delta Sortino. The historical path is shown beside both block lengths. + +## Decision rule (decision D6, pre-registered) +For each of B, C and D, separately for each run and fee mode: +- **"better than static DCA"** only if, under BOTH the 20-day AND the 60-day blocks, + `P(delta Sortino > 0) >= 0.95` (a tie is not an improvement) AND the median delta max drawdown + is not worse (`<= 0`, drawdowns being positive fractions); +- **"block-length-dependent"** if it passes at exactly one block length; +- otherwise **"not better than static DCA after fees"**, which is a complete and useful result. + +The headline verdict is the **primary run under the allowance-aware fees**. The flat-taker run +and the secondary (with-PAXG) run are reported beside it, labelled as sensitivity and short- +window evidence respectively, and cannot overturn it. + +## Expectation, recorded before running (spec §8) +Arm C roughly equals A: it is nearly free and the effect is small. Arm B trails in rising +markets (cash drag) and leads after drawdowns, with no robust Sortino edge. Arm D is hurt on +the 5-year PAXG-free run (sell fees, correlated assets) and has its best case on the short run +with PAXG, because PAXG is the only low-correlation asset in `target_weights` and the +rebalancing premium lives in dispersion between weakly correlated assets (KB §51). If band +trimming helps anywhere, it should show through the PAXG leg and should not help a PAXG-free +universe much. + +## Multiple testing and the ledger +Three treatment arms across two runs and two fee modes. With `--ledger`, one row per arm, run +and fee mode (16 rows, A included) goes to the trials ledger as `kind="ablation"`, +`provenance="a_priori"` (every parameter is declared, none fitted), `decision= +"diagnostic_only"`. Summary values are ints, bools or numeric strings only (#830: a float +breaks the hash chain and a word breaks the read-back); words go in `params`. `per_trade_pnl` +carries the arm's MONTHLY deposit-neutral account P&L on the historical path (value change +minus deposits, per calendar month), and `params` says so. + +## Provenance and safety +READ-ONLY against the candle cache (`mode=ro`). The only writes are `--out` (JSONL), the +`--ledger` append and stdout. `--paths` exists only for plumbing smoke tests on synthetic +data. Any value other than 2,000 is not the pre-registered run: it is marked so in the output, +and the driver refuses to write the ledger with it. +""" + +from __future__ import annotations + +import argparse +import json +import sqlite3 +import statistics +from collections.abc import Sequence +from decimal import Decimal +from typing import Any + +from keel.commands.fetch import DAYS_PER_YEAR +from keel.commands.simulate import SIM_SLIPPAGE_PCT, load_sim_candles, slippage_assumptions +from keel.config import load_config +from keel.data.repository import Repository +from keel.execution.guards import _utc_month_bounds +from keel.research.ledger import append_trial +from keel.sim import accumulation_policy as ap +from keel.sim import metrics +from keel.types import Granularity + +NOW_TS = 1790455996 +PRIMARY_YEARS = 5 +DAY = 86_400 +SEED = 831 +SESSION = "accumulation-policy-2026-09-27" +QUOTE = "USD" +FROZEN_WEIGHTS = { + "BTC": Decimal("0.30"), + "ETH": Decimal("0.20"), + "PAXG": Decimal("0.20"), + "SOL": Decimal("0.06"), + "XLM": Decimal("0.06"), + "LTC": Decimal("0.06"), + "ADA": Decimal("0.06"), + "LINK": Decimal("0.06"), +} +PRIMARY = "primary" +SECONDARY = "secondary" +HEADLINE = (PRIMARY, ap.ALLOWANCE) +ARM_LABELS = { + "A": "static DCA", + "B": "bounded value averaging, buy-only", + "C": "deposit steered by shortfall", + "D": "DCA plus selective trimming", +} +PLACES = Decimal("0.000001") + + +def _repo(db_path: str) -> Repository: + conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + conn.row_factory = sqlite3.Row + return Repository(conn) + + +def _num(value: Decimal) -> str: + """A numeric string for JSON and the ledger: never a float, never a word.""" + return str(value.quantize(PLACES)) + + +def _weights(config_path: str) -> dict[str, Decimal]: + weights = dict(load_config(config_path).target_weights) + if weights != FROZEN_WEIGHTS: + raise SystemExit( + f"{config_path} target_weights {weights} differ from the pre-registered " + f"{FROZEN_WEIGHTS}; this is not the pre-registered run" + ) + return weights + + +def _first_daily_ts(repo: Repository, asset: str) -> int: + daily = repo.get_candles(f"{asset}-{QUOTE}", Granularity.ONE_DAY, None, NOW_TS) + if not daily: + raise SystemExit(f"no cached daily bars for {asset}-{QUOTE}") + return daily[0].ts + + +def _panel( + repo: Repository, assets: Sequence[str], start_ts: int, end_ts: int +) -> tuple[ap.PricePanel, dict[str, Decimal], dict[str, Any]]: + """The run's aligned panel of COMPLETED daily bars, its slippage, and what was dropped.""" + products = [f"{a}-{QUOTE}" for a in assets] + candles, _prices = load_sim_candles(repo, products, start_ts, end_ts) + _rows, resolve = slippage_assumptions(candles, products, products, SIM_SLIPPAGE_PCT) + slippage = {a: resolve(f"{a}-{QUOTE}") for a in assets} + + daily = { + a: {c.ts: c for c in candles[a][Granularity.ONE_DAY] if c.ts + DAY <= end_ts} + for a in assets + } + common = sorted(set.intersection(*(set(bars) for bars in daily.values()))) + if len(common) < 2: + raise SystemExit(f"fewer than two common daily bars for {list(assets)}") + panel = ap.PricePanel( + ts=common, + opens={a: [daily[a][ts].open for ts in common] for a in assets}, + closes={a: [daily[a][ts].close for ts in common] for a in assets}, + ) + meta = { + "start_ts": start_ts, + "end_ts": end_ts, + "panel_days": len(common), + "first_ts": common[0], + "last_ts": common[-1], + "dropped_days": {a: len(daily[a]) - len(common) for a in assets}, + "slippage_pct": {a: str(s) for a, s in slippage.items()}, + } + return panel, slippage, meta + + +def _monthly_pnl(result: ap.AccountResult) -> list[Decimal]: + """Deposit-neutral account P&L per calendar month on the path, in dollars.""" + pnl = metrics.bar_pnl(result.equity_curve, result.deposits) + months: dict[int, Decimal] = {} + for (ts, _), value in zip(result.equity_curve[1:], pnl, strict=True): + key, _ = _utc_month_bounds(ts) + months[key] = months.get(key, Decimal(0)) + value + return [months[key] for key in sorted(months)] + + +def _quantile(sorted_values: Sequence[Decimal], q: Decimal) -> Decimal: + index = min(len(sorted_values) - 1, max(0, int(q * len(sorted_values)))) + return sorted_values[index] + + +def _block_stats(deltas: Sequence[tuple[Decimal, Decimal]]) -> dict[str, Any]: + ds = sorted(d for d, _ in deltas) + return { + "p_sortino_positive": _num(ap.p_sortino_positive(deltas)), + "median_d_sortino": _num(Decimal(statistics.median(ds))), + "median_d_mdd": _num(ap.median_delta_mdd(deltas)), + "d_sortino_p05": _num(_quantile(ds, Decimal("0.05"))), + "d_sortino_p95": _num(_quantile(ds, Decimal("0.95"))), + "passes": ap.passes(deltas), + } + + +def _run_rows( + run: str, + fee_mode: str, + panel: ap.PricePanel, + weights: dict[str, Decimal], + slippage: dict[str, Decimal], + n_paths: int, +) -> list[dict[str, Any]]: + fees = ap.FeeModel( + mode=fee_mode, taker_pct=ap.TAKER_PCT, allowance_usd=ap.ALLOWANCE_USD, slippage=slippage + ) + results = {arm: ap.run_account(panel, weights, fn, fees) for arm, fn in ap.ARMS.items()} + history = {arm: ap.summarize(result) for arm, result in results.items()} + boot = { + block: ap.bootstrap_deltas( + panel, weights, fees, mean_block=block, n_paths=n_paths, seed=SEED + ) + for block in ap.BLOCK_LENGTHS + } + rows = [] + for arm in ap.ARMS: + row: dict[str, Any] = { + "row": "arm", + "run": run, + "fee_mode": fee_mode, + "arm": arm, + "arm_label": ARM_LABELS[arm], + "headline": (run, fee_mode) == HEADLINE, + "n_paths": n_paths, + **{f"hist_{k}": _num(v) for k, v in history[arm].items()}, + "hist_d_sortino": _num(history[arm]["sortino"] - history["A"]["sortino"]), + "hist_d_mdd": _num(history[arm]["max_drawdown"] - history["A"]["max_drawdown"]), + "monthly_pnl": [_num(v) for v in _monthly_pnl(results[arm])], + } + if arm != "A": + for block in ap.BLOCK_LENGTHS: + for key, value in _block_stats(boot[block][arm]).items(): + row[f"{key}_b{block}"] = value + row["verdict"] = ap.verdict({block: boot[block][arm] for block in ap.BLOCK_LENGTHS}) + else: + row["verdict"] = "baseline" + rows.append(row) + return rows + + +def _append_ledger(ledger: str, row: dict[str, Any], meta: dict[str, Any]) -> None: + words = ("row", "run", "fee_mode", "arm", "arm_label", "verdict", "monthly_pnl") + summary = {k: v for k, v in row.items() if k not in words} + series = [Decimal(v) for v in row["monthly_pnl"]] + append_trial( + ledger, + trial_id=f"{SESSION}-{row['run']}-{row['fee_mode']}-{row['arm']}", + session=SESSION, + rule="accumulation_policy", + params={ + "arm": row["arm"], + "arm_label": row["arm_label"], + "run": row["run"], + "fee_mode": row["fee_mode"], + "verdict": row["verdict"], + "headline": row["headline"], + "window_start_ts": meta["start_ts"], + "window_end_ts": meta["end_ts"], + "panel_days": meta["panel_days"], + "weights": {a: str(w) for a, w in meta["weights"].items()}, + "deposit_usd": str(ap.DEPOSIT_USD), + "allowance_usd": str(ap.ALLOWANCE_USD), + "taker_pct": str(ap.TAKER_PCT), + "block_lengths": list(ap.BLOCK_LENGTHS), + "seed": SEED, + "units": ( + "per_trade_pnl is the arm's deposit-neutral account P&L per calendar month on " + "the historical path, in USD; not trades" + ), + }, + provenance="a_priori", + kind="ablation", + decision="diagnostic_only", + per_trade_pnl=series, + series_missing=not series, + summary=summary, + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=(__doc__ or "").splitlines()[0]) + parser.add_argument("--db", required=True, help="a COPY of the candle cache (opened mode=ro)") + parser.add_argument("--out", required=True, help="JSONL results") + parser.add_argument("--ledger", help="trials ledger to append to (omit to skip)") + parser.add_argument("--config", default="config.paperforward.yaml") + parser.add_argument( + "--paths", + type=int, + default=ap.N_PATHS, + help="plumbing smoke tests only: any value but 2000 is not the pre-registered run", + ) + args = parser.parse_args() + pre_registered = args.paths == ap.N_PATHS + if args.ledger and not pre_registered: + parser.error("--ledger is refused unless --paths is the pre-registered 2000") + + repo = _repo(args.db) + config_weights = _weights(args.config) + runs = { + PRIMARY: ( + ap.renormalise(config_weights, exclude=["PAXG"]), + NOW_TS - PRIMARY_YEARS * DAYS_PER_YEAR * DAY, + ), + SECONDARY: (ap.renormalise(config_weights), _first_daily_ts(repo, "PAXG")), + } + + with open(args.out, "w", encoding="utf-8") as fh: + fh.write( + json.dumps( + { + "row": "meta", + "session": SESSION, + "pre_registered_run": pre_registered, + "now_ts": NOW_TS, + "seed": SEED, + "n_paths": args.paths, + "block_lengths": list(ap.BLOCK_LENGTHS), + "deposit_usd": str(ap.DEPOSIT_USD), + "allowance_usd": str(ap.ALLOWANCE_USD), + "taker_pct": str(ap.TAKER_PCT), + "fee_modes": list(ap.FEE_MODES), + "headline": list(HEADLINE), + } + ) + + "\n" + ) + for run, (weights, start_ts) in runs.items(): + panel, slippage, meta = _panel(repo, list(weights), start_ts, NOW_TS) + meta["weights"] = weights + fh.write( + json.dumps( + { + "row": "run", + "run": run, + **{k: v for k, v in meta.items() if k != "weights"}, + "weights": {a: str(w) for a, w in weights.items()}, + } + ) + + "\n" + ) + for fee_mode in ap.FEE_MODES: + print(f"{run} / {fee_mode}: {meta['panel_days']} days, {args.paths} paths x 2") + for row in _run_rows(run, fee_mode, panel, weights, slippage, args.paths): + fh.write(json.dumps(row) + "\n") + fh.flush() + if args.ledger: + _append_ledger(args.ledger, row, meta) + print(f"wrote {args.out}") + + +if __name__ == "__main__": + main() From bd18cae1d42b17f69c5b8f3256ff1cf16f1f15f9 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Sat, 26 Sep 2026 22:18:16 -0400 Subject: [PATCH 3/3] docs(experiments): accumulation policy after fees -- no arm beats static DCA (#831) Pre-registered (design #834; driver 4113ed0 pushed before the run, and unchanged since). On equal $500 monthly deposits with allowance-aware fees, over 5 years without PAXG and 1.4 years with it: no arm clears P(dSortino>0) >= 0.95 under both bootstrap block lengths. Band trimming leads the historical path by 13.5% but improves Sortino in only 36-43% of paths; value averaging is the most consistent at 58-61%. 16 ablation rows appended to the trials ledger. Co-Authored-By: Claude Opus 5.5 --- .../2026-09-27-accumulation-policy.jsonl | 19 ++++ .../2026-09-27-accumulation-policy.md | 106 ++++++++++++++++++ docs/experiments/README.md | 7 ++ docs/experiments/trials-ledger.jsonl | 16 +++ 4 files changed, 148 insertions(+) create mode 100644 docs/experiments/2026-09-27-accumulation-policy.jsonl create mode 100644 docs/experiments/2026-09-27-accumulation-policy.md diff --git a/docs/experiments/2026-09-27-accumulation-policy.jsonl b/docs/experiments/2026-09-27-accumulation-policy.jsonl new file mode 100644 index 00000000..a9c42f9a --- /dev/null +++ b/docs/experiments/2026-09-27-accumulation-policy.jsonl @@ -0,0 +1,19 @@ +{"row": "meta", "session": "accumulation-policy-2026-09-27", "pre_registered_run": true, "now_ts": 1790455996, "seed": 831, "n_paths": 2000, "block_lengths": [20, 60], "deposit_usd": "500", "allowance_usd": "500", "taker_pct": "0.012", "fee_modes": ["allowance", "flat"], "headline": ["primary", "allowance"]} +{"row": "run", "run": "primary", "start_ts": 1632775996, "end_ts": 1790455996, "panel_days": 1824, "first_ts": 1632787200, "last_ts": 1790294400, "dropped_days": {"BTC": 0, "ETH": 0, "SOL": 0, "XLM": 0, "LTC": 0, "ADA": 0, "LINK": 0}, "slippage_pct": {"BTC": "0.0005", "ETH": "0.0006277748074906363081326117425", "SOL": "0.001084794514199345299806128906", "XLM": "0.003588595643753523886104211287", "LTC": "0.002973408136530397362025130082", "ADA": "0.002488423095281624353558503168", "LINK": "0.002478525119657678382142102999"}, "weights": {"BTC": "0.375", "ETH": "0.25", "SOL": "0.075", "XLM": "0.075", "LTC": "0.075", "ADA": "0.075", "LINK": "0.075"}} +{"row": "arm", "run": "primary", "fee_mode": "allowance", "arm": "A", "arm_label": "static DCA", "headline": true, "n_paths": 2000, "hist_terminal_value": "46628.897521", "hist_deposited": "30500.000000", "hist_irr": "0.012590", "hist_cagr_money_weighted": "0.088708", "hist_twr_total": "0.236618", "hist_sharpe": "0.368893", "hist_sortino": "0.523146", "hist_max_drawdown": "0.795007", "hist_buy_fees": "0.000000", "hist_sell_fees": "0.000000", "hist_buy_notional": "30500.000000", "hist_sell_notional": "0.000000", "hist_realized_pnl": "0.000000", "hist_turnover": "1.000000", "hist_avg_cash_share": "0.002686", "hist_d_sortino": "0.000000", "hist_d_mdd": "0.000000", "monthly_pnl": ["34.647614", "298.645942", "-55.691201", "-468.292054", "-534.119431", "120.810453", "292.889899", "-802.892462", "-780.127534", "-1052.913708", "645.249393", "-454.155149", "-137.471244", "263.016212", "-685.488972", "-511.297373", "1851.585325", "-71.695859", "1089.436008", "42.306513", "-372.841117", "656.989821", "69.166602", "-1753.253516", "506.320049", "2617.331088", "2128.293919", "3301.897011", "-533.188532", "7702.572517", "5620.348494", "-7865.575607", "4950.352260", "-3263.610803", "669.070490", "-4527.093641", "2034.695147", "1695.726352", "20192.500943", "-6267.805254", "6010.622585", "-13757.241892", "-3524.383241", "3715.637213", "4600.627917", "-443.155381", "9653.462110", "1941.136811", "792.532004", "-5233.720429", "-11609.683455", "-2308.226152", "-5650.385400", "-5818.790256", "611.459345", "2507.848462", "-179.084982", "-7766.448763", "2463.081188", "8184.226700", "5293.044540"], "verdict": "baseline"} +{"row": "arm", "run": "primary", "fee_mode": "allowance", "arm": "B", "arm_label": "bounded value averaging, buy-only", "headline": true, "n_paths": 2000, "hist_terminal_value": "48839.973651", "hist_deposited": "30500.000000", "hist_irr": "0.013892", "hist_cagr_money_weighted": "0.098854", "hist_twr_total": "0.326258", "hist_sharpe": "0.382729", "hist_sortino": "0.542169", "hist_max_drawdown": "0.785254", "hist_buy_fees": "33.295544", "hist_sell_fees": "0.000000", "hist_buy_notional": "23616.385067", "hist_sell_notional": "0.000000", "hist_realized_pnl": "0.000000", "hist_turnover": "0.774308", "hist_avg_cash_share": "0.083230", "hist_d_sortino": "0.019023", "hist_d_mdd": "-0.009753", "monthly_pnl": ["34.647614", "278.112581", "-50.984005", "-407.471500", "-546.837246", "112.817735", "306.185425", "-825.884929", "-786.404971", "-1042.469118", "639.457806", "-457.441444", "-130.057062", "257.013080", "-679.447575", "-521.151668", "1856.287055", "-72.859963", "1101.825885", "34.508690", "-376.316156", "615.481175", "202.248349", "-1791.810611", "539.669557", "2705.968808", "2302.582396", "3583.763061", "-613.848028", "7306.232731", "5658.716270", "-7831.843554", "4642.602174", "-3066.797494", "748.497475", "-4179.265539", "1887.888821", "1344.342002", "20785.700303", "-6960.405595", "5970.645418", "-12810.953177", "-3465.063617", "2991.862695", "3825.475909", "-740.311223", "9271.861234", "2056.961869", "496.355805", "-4867.769688", "-9851.859082", "-2196.865768", "-4738.354695", "-4681.283341", "442.436814", "1754.106142", "165.869398", "-6720.085965", "2153.840058", "7258.818331", "5421.034000"], "p_sortino_positive_b20": "0.583000", "median_d_sortino_b20": "0.009563", "median_d_mdd_b20": "-0.021310", "d_sortino_p05_b20": "-0.081599", "d_sortino_p95_b20": "0.109273", "passes_b20": false, "p_sortino_positive_b60": "0.599000", "median_d_sortino_b60": "0.011247", "median_d_mdd_b60": "-0.022475", "d_sortino_p05_b60": "-0.078932", "d_sortino_p95_b60": "0.112171", "passes_b60": false, "verdict": "not better than static DCA after fees"} +{"row": "arm", "run": "primary", "fee_mode": "allowance", "arm": "C", "arm_label": "deposit steered by shortfall", "headline": true, "n_paths": 2000, "hist_terminal_value": "47966.743670", "hist_deposited": "30500.000000", "hist_irr": "0.013386", "hist_cagr_money_weighted": "0.094892", "hist_twr_total": "0.316337", "hist_sharpe": "0.396626", "hist_sortino": "0.564221", "hist_max_drawdown": "0.791254", "hist_buy_fees": "0.000000", "hist_sell_fees": "0.000000", "hist_buy_notional": "30500.000000", "hist_sell_notional": "0.000000", "hist_realized_pnl": "0.000000", "hist_turnover": "1.000000", "hist_avg_cash_share": "0.002668", "hist_d_sortino": "0.041075", "hist_d_mdd": "-0.003753", "monthly_pnl": ["34.647614", "297.693883", "-63.364753", "-462.846192", "-526.318438", "108.392812", "311.388408", "-819.779495", "-791.149280", "-1011.900625", "685.143887", "-472.211370", "-112.733538", "264.632258", "-723.675606", "-542.367927", "2047.647505", "-116.052674", "1084.295020", "24.400275", "-392.402548", "554.023951", "308.732379", "-1832.671382", "556.991766", "2756.940675", "2414.239182", "3859.758399", "-716.395041", "7845.042633", "6159.891999", "-8690.618300", "5166.826199", "-3485.378219", "777.299993", "-4782.194028", "2105.685338", "1449.574651", "24362.271263", "-8170.529062", "6916.782277", "-15256.882111", "-4248.959262", "3373.728978", "4632.011838", "-953.884404", "11774.216771", "3091.491777", "298.919758", "-6223.904404", "-12420.363938", "-2714.490079", "-6159.588147", "-5842.169897", "542.701604", "2133.243394", "122.631611", "-8173.990819", "2573.480089", "8139.821455", "6399.015567"], "p_sortino_positive_b20": "0.365000", "median_d_sortino_b20": "-0.016209", "median_d_mdd_b20": "0.005560", "d_sortino_p05_b20": "-0.109321", "d_sortino_p95_b20": "0.048797", "passes_b20": false, "p_sortino_positive_b60": "0.407500", "median_d_sortino_b60": "-0.010752", "median_d_mdd_b60": "0.004204", "d_sortino_p05_b60": "-0.103930", "d_sortino_p95_b60": "0.050821", "passes_b60": false, "verdict": "not better than static DCA after fees"} +{"row": "arm", "run": "primary", "fee_mode": "allowance", "arm": "D", "arm_label": "DCA plus selective trimming", "headline": true, "n_paths": 2000, "hist_terminal_value": "52918.938151", "hist_deposited": "30500.000000", "hist_irr": "0.016119", "hist_cagr_money_weighted": "0.116644", "hist_twr_total": "0.460490", "hist_sharpe": "0.428050", "hist_sortino": "0.610728", "hist_max_drawdown": "0.792843", "hist_buy_fees": "462.352457", "hist_sell_fees": "467.968074", "hist_buy_notional": "69029.371418", "hist_sell_notional": "38997.339492", "hist_realized_pnl": "18279.319636", "hist_turnover": "3.541859", "hist_avg_cash_share": "0.002665", "hist_d_sortino": "0.087582", "hist_d_mdd": "-0.002164", "monthly_pnl": ["34.647614", "298.645942", "-55.691201", "-468.292054", "-534.119431", "120.810453", "292.889899", "-802.892462", "-780.127534", "-1021.309653", "656.092545", "-467.662044", "-124.013309", "254.355869", "-705.915308", "-544.773813", "1958.109941", "-92.450752", "1128.669006", "39.546844", "-396.478894", "597.718477", "274.144499", "-1800.617359", "576.658328", "2672.021762", "2151.310700", "3183.234376", "-608.963321", "7763.188916", "4641.242019", "-7063.872605", "4447.909719", "-3239.077932", "158.358485", "-4164.723722", "1774.162698", "973.295451", "26325.894191", "-5042.333833", "5819.166377", "-14614.959340", "-4797.363930", "3111.074639", "6152.062975", "-831.798576", "13917.011787", "3995.305868", "-105.782821", "-6607.217419", "-13406.875843", "-2641.036477", "-6879.199619", "-6472.202809", "703.595915", "2676.677199", "-270.221142", "-9099.026924", "3271.011706", "9182.168563", "6906.955517"], "p_sortino_positive_b20": "0.363000", "median_d_sortino_b20": "-0.022862", "median_d_mdd_b20": "0.003045", "d_sortino_p05_b20": "-0.173931", "d_sortino_p95_b20": "0.073275", "passes_b20": false, "p_sortino_positive_b60": "0.434000", "median_d_sortino_b60": "-0.010552", "median_d_mdd_b60": "0.001413", "d_sortino_p05_b60": "-0.158211", "d_sortino_p95_b60": "0.078540", "passes_b60": false, "verdict": "not better than static DCA after fees"} +{"row": "arm", "run": "primary", "fee_mode": "flat", "arm": "A", "arm_label": "static DCA", "headline": false, "n_paths": 2000, "hist_terminal_value": "46069.350751", "hist_deposited": "30500.000000", "hist_irr": "0.012248", "hist_cagr_money_weighted": "0.086080", "hist_twr_total": "0.165326", "hist_sharpe": "0.348773", "hist_sortino": "0.494344", "hist_max_drawdown": "0.799815", "hist_buy_fees": "366.000000", "hist_sell_fees": "0.000000", "hist_buy_notional": "30500.000000", "hist_sell_notional": "0.000000", "hist_realized_pnl": "0.000000", "hist_turnover": "1.000000", "hist_avg_cash_share": "0.002707", "hist_d_sortino": "0.000000", "hist_d_mdd": "0.000000", "monthly_pnl": ["28.231843", "289.062190", "-61.022907", "-468.672549", "-533.709998", "113.360728", "283.375220", "-799.257753", "-776.766004", "-1046.278744", "631.506401", "-454.705287", "-141.821589", "253.860018", "-683.263104", "-511.161805", "1823.366301", "-76.835509", "1070.362776", "35.798834", "-374.367024", "643.105943", "62.336603", "-1738.214474", "494.244208", "2579.923115", "2096.754392", "3256.274247", "-532.790270", "7604.141647", "5546.904313", "-7777.188699", "4884.948033", "-3230.447474", "655.041644", "-4478.768518", "2004.278805", "1669.377636", "19944.190932", "-6198.591591", "5932.495114", "-13598.154989", "-3488.090642", "3665.049567", "4539.420382", "-443.837516", "9531.620564", "1911.843169", "777.021620", "-5176.915783", "-11476.367254", "-2286.527439", "-5588.580775", "-5754.964773", "598.121833", "2471.754281", "-182.935962", "-7679.251377", "2427.524214", "8080.015979", "5223.528005"], "verdict": "baseline"} +{"row": "arm", "run": "primary", "fee_mode": "flat", "arm": "B", "arm_label": "bounded value averaging, buy-only", "headline": false, "n_paths": 2000, "hist_terminal_value": "48387.006853", "hist_deposited": "30500.000000", "hist_irr": "0.013631", "hist_cagr_money_weighted": "0.096806", "hist_twr_total": "0.255803", "hist_sharpe": "0.363369", "hist_sortino": "0.514491", "hist_max_drawdown": "0.790741", "hist_buy_fees": "286.348860", "hist_sell_fees": "0.000000", "hist_buy_notional": "23862.405006", "hist_sell_notional": "0.000000", "hist_realized_pnl": "0.000000", "hist_turnover": "0.782374", "hist_avg_cash_share": "0.081980", "hist_d_sortino": "0.020148", "hist_d_mdd": "-0.009074", "monthly_pnl": ["28.231843", "271.311805", "-54.118375", "-412.315883", "-546.011693", "105.533095", "296.168350", "-822.287260", "-782.955005", "-1036.699504", "626.160407", "-457.921120", "-134.822210", "248.178266", "-677.590991", "-520.607356", "1827.922999", "-77.905474", "1082.363727", "29.142565", "-377.325315", "607.263974", "180.555809", "-1771.828548", "525.845396", "2657.879032", "2252.272132", "3497.892391", "-600.644308", "7221.796695", "5543.465014", "-7688.113068", "4572.449841", "-3028.418278", "710.450058", "-4121.771026", "1851.074783", "1313.268088", "20648.105515", "-6891.184845", "5894.789636", "-12663.202812", "-3432.430390", "2942.624380", "3792.750765", "-739.542546", "9228.617104", "2022.141714", "480.765840", "-4830.660629", "-9751.505621", "-2184.719172", "-4701.716398", "-4641.309334", "435.439613", "1737.047510", "163.456339", "-6692.082603", "2139.846089", "7207.191704", "5384.694139"], "p_sortino_positive_b20": "0.599000", "median_d_sortino_b20": "0.011247", "median_d_mdd_b20": "-0.020165", "d_sortino_p05_b20": "-0.079938", "d_sortino_p95_b20": "0.111326", "passes_b20": false, "p_sortino_positive_b60": "0.612500", "median_d_sortino_b60": "0.012247", "median_d_mdd_b60": "-0.021839", "d_sortino_p05_b60": "-0.077427", "d_sortino_p95_b60": "0.113607", "passes_b60": false, "verdict": "not better than static DCA after fees"} +{"row": "arm", "run": "primary", "fee_mode": "flat", "arm": "C", "arm_label": "deposit steered by shortfall", "headline": false, "n_paths": 2000, "hist_terminal_value": "47388.950613", "hist_deposited": "30500.000000", "hist_irr": "0.013046", "hist_cagr_money_weighted": "0.092238", "hist_twr_total": "0.240676", "hist_sharpe": "0.377183", "hist_sortino": "0.536283", "hist_max_drawdown": "0.796171", "hist_buy_fees": "366.000000", "hist_sell_fees": "0.000000", "hist_buy_notional": "30500.000000", "hist_sell_notional": "0.000000", "hist_realized_pnl": "0.000000", "hist_turnover": "1.000000", "hist_avg_cash_share": "0.002689", "hist_d_sortino": "0.041940", "hist_d_mdd": "-0.003644", "monthly_pnl": ["28.231843", "288.132844", "-68.519669", "-463.347348", "-525.993340", "101.143822", "301.519912", "-815.955458", "-787.533229", "-1005.892563", "670.569508", "-472.581328", "-117.584331", "255.280381", "-720.776855", "-541.640723", "2016.160078", "-120.491325", "1065.405362", "18.049830", "-393.597352", "541.374272", "298.958173", "-1816.495660", "544.286657", "2717.684698", "2378.991196", "3806.873370", "-713.635131", "7744.437907", "6079.004735", "-8591.242304", "5098.366756", "-3449.254143", "761.739891", "-4730.326374", "2074.220610", "1426.147876", "24061.096014", "-8077.114258", "6826.929587", "-15078.255716", "-4203.408674", "3327.100656", "4570.329645", "-948.210001", "11625.829496", "3047.939406", "289.333116", "-6154.613313", "-12276.426831", "-2687.593489", "-6091.307399", "-5777.809662", "530.237126", "2101.747425", "114.796263", "-8081.476493", "2536.641006", "8035.888635", "6315.585483"], "p_sortino_positive_b20": "0.365000", "median_d_sortino_b20": "-0.015654", "median_d_mdd_b20": "0.005438", "d_sortino_p05_b20": "-0.109221", "d_sortino_p95_b20": "0.049206", "passes_b20": false, "p_sortino_positive_b60": "0.412000", "median_d_sortino_b60": "-0.010318", "median_d_mdd_b60": "0.004023", "d_sortino_p05_b60": "-0.102997", "d_sortino_p95_b60": "0.051559", "passes_b60": false, "verdict": "not better than static DCA after fees"} +{"row": "arm", "run": "primary", "fee_mode": "flat", "arm": "D", "arm_label": "DCA plus selective trimming", "headline": false, "n_paths": 2000, "hist_terminal_value": "52283.910894", "hist_deposited": "30500.000000", "hist_irr": "0.015787", "hist_cagr_money_weighted": "0.113948", "hist_twr_total": "0.376927", "hist_sharpe": "0.408441", "hist_sortino": "0.582447", "hist_max_drawdown": "0.797685", "hist_buy_fees": "822.804228", "hist_sell_fees": "462.352457", "hist_buy_notional": "68567.018961", "hist_sell_notional": "38529.371418", "hist_realized_pnl": "17929.375404", "hist_turnover": "3.511357", "hist_avg_cash_share": "0.002686", "hist_d_sortino": "0.088103", "hist_d_mdd": "-0.002130", "monthly_pnl": ["28.231843", "289.062190", "-61.022907", "-468.672549", "-533.709998", "113.360728", "283.375220", "-799.257753", "-776.766004", "-1015.053938", "642.219435", "-468.050099", "-128.525149", "245.303598", "-703.444324", "-544.236527", "1928.612621", "-97.341343", "1109.124978", "33.072282", "-397.721147", "584.545855", "264.854765", "-1785.009951", "563.738428", "2633.957501", "2119.494971", "3139.035563", "-607.655762", "7664.030649", "4579.547114", "-6985.106134", "4388.534803", "-3206.208997", "150.458183", "-4120.747038", "1746.872746", "955.615906", "26003.983461", "-4987.825827", "5743.336380", "-14445.579828", "-4745.795563", "3067.741743", "6072.238219", "-827.816993", "13744.007645", "3941.362197", "-110.513427", "-6533.930810", "-13251.993333", "-2615.344039", "-6802.649223", "-6400.536375", "689.152764", "2638.557072", "-272.978488", "-8995.838601", "3225.759566", "9065.982540", "6818.072051"], "p_sortino_positive_b20": "0.365000", "median_d_sortino_b20": "-0.022628", "median_d_mdd_b20": "0.002976", "d_sortino_p05_b20": "-0.173355", "d_sortino_p95_b20": "0.073492", "passes_b20": false, "p_sortino_positive_b60": "0.433000", "median_d_sortino_b60": "-0.010589", "median_d_mdd_b60": "0.001349", "d_sortino_p05_b60": "-0.159577", "d_sortino_p95_b60": "0.078674", "passes_b60": false, "verdict": "not better than static DCA after fees"} +{"row": "run", "run": "secondary", "start_ts": 1746662400, "end_ts": 1790455996, "panel_days": 506, "first_ts": 1746662400, "last_ts": 1790294400, "dropped_days": {"BTC": 0, "ETH": 0, "PAXG": 0, "SOL": 0, "XLM": 0, "LTC": 0, "ADA": 0, "LINK": 0}, "slippage_pct": {"BTC": "0.0005", "ETH": "0.0006144767918666252920850553345", "PAXG": "0.009994135045451556368765221120", "SOL": "0.001019870723618163341372408301", "XLM": "0.003383293436529462429339446505", "LTC": "0.003780507295984341328819752729", "ADA": "0.002726376761710615482145362158", "LINK": "0.002538032131408057339552626568"}, "weights": {"BTC": "0.3", "ETH": "0.2", "PAXG": "0.2", "SOL": "0.06", "XLM": "0.06", "LTC": "0.06", "ADA": "0.06", "LINK": "0.06"}} +{"row": "arm", "run": "secondary", "fee_mode": "allowance", "arm": "A", "arm_label": "static DCA", "headline": false, "n_paths": 2000, "hist_terminal_value": "8833.599417", "hist_deposited": "8500.000000", "hist_irr": "0.003839", "hist_cagr_money_weighted": "0.028215", "hist_twr_total": "-0.028628", "hist_sharpe": "0.167152", "hist_sortino": "0.239696", "hist_max_drawdown": "0.521195", "hist_buy_fees": "0.000000", "hist_sell_fees": "0.000000", "hist_buy_notional": "8500.000000", "hist_sell_notional": "0.000000", "hist_realized_pnl": "0.000000", "hist_turnover": "1.000000", "hist_avg_cash_share": "0.006937", "hist_d_sortino": "0.000000", "hist_d_mdd": "0.000000", "monthly_pnl": ["0.189449", "-19.475066", "329.768156", "166.003402", "48.984957", "-271.847923", "-578.779642", "-105.287202", "-314.149037", "-293.913705", "-137.170717", "203.432741", "-72.012792", "-1136.910068", "373.874449", "1303.093120", "837.799294"], "verdict": "baseline"} +{"row": "arm", "run": "secondary", "fee_mode": "allowance", "arm": "B", "arm_label": "bounded value averaging, buy-only", "headline": false, "n_paths": 2000, "hist_terminal_value": "8995.578756", "hist_deposited": "8500.000000", "hist_irr": "0.005644", "hist_cagr_money_weighted": "0.041808", "hist_twr_total": "-0.005956", "hist_sharpe": "0.202823", "hist_sortino": "0.294082", "hist_max_drawdown": "0.508800", "hist_buy_fees": "7.436753", "hist_sell_fees": "0.000000", "hist_buy_notional": "8500.000000", "hist_sell_notional": "0.000000", "hist_realized_pnl": "0.000000", "hist_turnover": "1.000000", "hist_avg_cash_share": "0.036674", "hist_d_sortino": "0.054386", "hist_d_mdd": "-0.012395", "monthly_pnl": ["0.189449", "-20.325714", "336.210635", "130.504279", "59.907081", "-206.869610", "-550.439411", "-113.408684", "-369.924604", "-359.926170", "-83.615937", "229.102751", "-44.183752", "-1191.965654", "396.671734", "1353.287386", "930.364978"], "p_sortino_positive_b20": "0.595000", "median_d_sortino_b20": "0.017030", "median_d_mdd_b20": "-0.006775", "d_sortino_p05_b20": "-0.132056", "d_sortino_p95_b20": "0.191332", "passes_b20": false, "p_sortino_positive_b60": "0.582000", "median_d_sortino_b60": "0.018271", "median_d_mdd_b60": "-0.006214", "d_sortino_p05_b60": "-0.169901", "d_sortino_p95_b60": "0.239267", "passes_b60": false, "verdict": "not better than static DCA after fees"} +{"row": "arm", "run": "secondary", "fee_mode": "allowance", "arm": "C", "arm_label": "deposit steered by shortfall", "headline": false, "n_paths": 2000, "hist_terminal_value": "8992.199834", "hist_deposited": "8500.000000", "hist_irr": "0.005607", "hist_cagr_money_weighted": "0.041525", "hist_twr_total": "-0.018663", "hist_sharpe": "0.190944", "hist_sortino": "0.275246", "hist_max_drawdown": "0.530756", "hist_buy_fees": "0.000000", "hist_sell_fees": "0.000000", "hist_buy_notional": "8500.000000", "hist_sell_notional": "0.000000", "hist_realized_pnl": "0.000000", "hist_turnover": "1.000000", "hist_avg_cash_share": "0.006951", "hist_d_sortino": "0.035549", "hist_d_mdd": "0.009561", "monthly_pnl": ["0.189449", "-20.433090", "336.896978", "148.073843", "72.131740", "-259.487676", "-591.085301", "-121.158393", "-369.208652", "-362.857416", "-73.101244", "229.294980", "-29.642989", "-1191.945792", "413.001401", "1351.601561", "959.930436"], "p_sortino_positive_b20": "0.453000", "median_d_sortino_b20": "-0.006232", "median_d_mdd_b20": "0.002912", "d_sortino_p05_b20": "-0.141552", "d_sortino_p95_b20": "0.074354", "passes_b20": false, "p_sortino_positive_b60": "0.501500", "median_d_sortino_b60": "0.000475", "median_d_mdd_b60": "0.002668", "d_sortino_p05_b60": "-0.161126", "d_sortino_p95_b60": "0.079320", "passes_b60": false, "verdict": "not better than static DCA after fees"} +{"row": "arm", "run": "secondary", "fee_mode": "allowance", "arm": "D", "arm_label": "DCA plus selective trimming", "headline": false, "n_paths": 2000, "hist_terminal_value": "8974.485956", "hist_deposited": "8500.000000", "hist_irr": "0.005412", "hist_cagr_money_weighted": "0.040042", "hist_twr_total": "-0.027212", "hist_sharpe": "0.178688", "hist_sortino": "0.257471", "hist_max_drawdown": "0.532221", "hist_buy_fees": "10.572445", "hist_sell_fees": "10.700855", "hist_buy_notional": "9381.037096", "hist_sell_notional": "891.737951", "hist_realized_pnl": "141.279886", "hist_turnover": "1.208562", "hist_avg_cash_share": "0.006962", "hist_d_sortino": "0.017775", "hist_d_mdd": "0.011026", "monthly_pnl": ["0.189449", "-19.475066", "329.768156", "144.250611", "61.575401", "-267.518203", "-564.764487", "-129.149235", "-367.671190", "-391.592564", "-52.787918", "236.702838", "-50.698671", "-1188.599934", "419.693202", "1373.877552", "940.686016"], "p_sortino_positive_b20": "0.367000", "median_d_sortino_b20": "-0.018448", "median_d_mdd_b20": "0.004900", "d_sortino_p05_b20": "-0.173609", "d_sortino_p95_b20": "0.066122", "passes_b20": false, "p_sortino_positive_b60": "0.423000", "median_d_sortino_b60": "-0.010358", "median_d_mdd_b60": "0.005628", "d_sortino_p05_b60": "-0.189463", "d_sortino_p95_b60": "0.079121", "passes_b60": false, "verdict": "not better than static DCA after fees"} +{"row": "arm", "run": "secondary", "fee_mode": "flat", "arm": "A", "arm_label": "static DCA", "headline": false, "n_paths": 2000, "hist_terminal_value": "8727.596224", "hist_deposited": "8500.000000", "hist_irr": "0.002638", "hist_cagr_money_weighted": "0.019282", "hist_twr_total": "-0.069064", "hist_sharpe": "0.095302", "hist_sortino": "0.136240", "hist_max_drawdown": "0.526433", "hist_buy_fees": "102.000000", "hist_sell_fees": "0.000000", "hist_buy_notional": "8500.000000", "hist_sell_notional": "0.000000", "hist_realized_pnl": "0.000000", "hist_turnover": "1.000000", "hist_avg_cash_share": "0.006983", "hist_d_sortino": "0.000000", "hist_d_mdd": "0.000000", "monthly_pnl": ["-5.812824", "-25.241365", "319.810938", "158.011361", "42.397137", "-274.585748", "-577.834287", "-110.023755", "-316.379249", "-296.386740", "-141.524668", "194.991548", "-77.148639", "-1129.267147", "363.387956", "1281.456003", "821.745703"], "verdict": "baseline"} +{"row": "arm", "run": "secondary", "fee_mode": "flat", "arm": "B", "arm_label": "bounded value averaging, buy-only", "headline": false, "n_paths": 2000, "hist_terminal_value": "8884.381564", "hist_deposited": "8500.000000", "hist_irr": "0.004409", "hist_cagr_money_weighted": "0.032484", "hist_twr_total": "-0.046928", "hist_sharpe": "0.131206", "hist_sortino": "0.189558", "hist_max_drawdown": "0.515197", "hist_buy_fees": "102.000000", "hist_sell_fees": "0.000000", "hist_buy_notional": "8500.000000", "hist_sell_notional": "0.000000", "hist_realized_pnl": "0.000000", "hist_turnover": "1.000000", "hist_avg_cash_share": "0.034886", "hist_d_sortino": "0.053318", "hist_d_mdd": "-0.011235", "monthly_pnl": ["-5.812824", "-26.173048", "325.821219", "126.238846", "56.169394", "-210.484701", "-555.498844", "-118.935502", "-369.904245", "-360.083363", "-89.430718", "220.134285", "-50.841462", "-1182.453841", "385.412088", "1330.236649", "909.987632"], "p_sortino_positive_b20": "0.610500", "median_d_sortino_b20": "0.018740", "median_d_mdd_b20": "-0.006070", "d_sortino_p05_b20": "-0.122861", "d_sortino_p95_b20": "0.190866", "passes_b20": false, "p_sortino_positive_b60": "0.595000", "median_d_sortino_b60": "0.019451", "median_d_mdd_b60": "-0.005324", "d_sortino_p05_b60": "-0.161852", "d_sortino_p95_b60": "0.236486", "passes_b60": false, "verdict": "not better than static DCA after fees"} +{"row": "arm", "run": "secondary", "fee_mode": "flat", "arm": "C", "arm_label": "deposit steered by shortfall", "headline": false, "n_paths": 2000, "hist_terminal_value": "8884.930318", "hist_deposited": "8500.000000", "hist_irr": "0.004415", "hist_cagr_money_weighted": "0.032530", "hist_twr_total": "-0.059355", "hist_sharpe": "0.121290", "hist_sortino": "0.174309", "hist_max_drawdown": "0.535817", "hist_buy_fees": "102.000000", "hist_sell_fees": "0.000000", "hist_buy_notional": "8500.000000", "hist_sell_notional": "0.000000", "hist_realized_pnl": "0.000000", "hist_turnover": "1.000000", "hist_avg_cash_share": "0.006996", "hist_d_sortino": "0.038069", "hist_d_mdd": "0.009384", "monthly_pnl": ["-5.812824", "-26.176535", "326.740214", "140.579199", "65.147842", "-262.341661", "-589.625857", "-125.587846", "-370.545887", "-364.280993", "-78.465265", "220.559896", "-35.612351", "-1183.695936", "402.064733", "1329.538636", "942.444952"], "p_sortino_positive_b20": "0.456500", "median_d_sortino_b20": "-0.006198", "median_d_mdd_b20": "0.002924", "d_sortino_p05_b20": "-0.140039", "d_sortino_p95_b20": "0.074437", "passes_b20": false, "p_sortino_positive_b60": "0.501500", "median_d_sortino_b60": "0.000055", "median_d_mdd_b60": "0.002817", "d_sortino_p05_b60": "-0.159990", "d_sortino_p95_b60": "0.076158", "passes_b60": false, "verdict": "not better than static DCA after fees"} +{"row": "arm", "run": "secondary", "fee_mode": "flat", "arm": "D", "arm_label": "DCA plus selective trimming", "headline": false, "n_paths": 2000, "hist_terminal_value": "8866.792124", "hist_deposited": "8500.000000", "hist_irr": "0.004212", "hist_cagr_money_weighted": "0.031006", "hist_twr_total": "-0.067851", "hist_sharpe": "0.109043", "hist_sortino": "0.156644", "hist_max_drawdown": "0.537393", "hist_buy_fees": "112.445576", "hist_sell_fees": "10.572445", "hist_buy_notional": "9370.464651", "hist_sell_notional": "881.037096", "hist_realized_pnl": "131.195421", "hist_turnover": "1.206059", "hist_avg_cash_share": "0.007009", "hist_d_sortino": "0.020404", "hist_d_mdd": "0.010960", "monthly_pnl": ["-5.812824", "-25.241365", "319.810938", "136.519603", "54.836496", "-270.307984", "-563.987313", "-133.599444", "-369.259136", "-392.893454", "-58.154463", "227.862404", "-56.090287", "-1180.336734", "408.656883", "1351.391021", "923.397783"], "p_sortino_positive_b20": "0.369500", "median_d_sortino_b20": "-0.017106", "median_d_mdd_b20": "0.004909", "d_sortino_p05_b20": "-0.170957", "d_sortino_p95_b20": "0.064950", "passes_b20": false, "p_sortino_positive_b60": "0.428500", "median_d_sortino_b60": "-0.009961", "median_d_mdd_b60": "0.005592", "d_sortino_p05_b60": "-0.184919", "d_sortino_p95_b60": "0.077218", "passes_b60": false, "verdict": "not better than static DCA after fees"} diff --git a/docs/experiments/2026-09-27-accumulation-policy.md b/docs/experiments/2026-09-27-accumulation-policy.md new file mode 100644 index 00000000..44518d69 --- /dev/null +++ b/docs/experiments/2026-09-27-accumulation-policy.md @@ -0,0 +1,106 @@ +# Accumulation policy after fees: no arm beats static DCA + +**Date:** 2026-09-27 +**Issue:** #831 · **Design:** [`2026-09-27-accumulation-policy-design.md`](../superpowers/specs/2026-09-27-accumulation-policy-design.md) +(revision 2, approved as #834) +**Pre-registration:** the driver's docstring, +[`2026-09-27-accumulation-policy.py`](2026-09-27-accumulation-policy.py), committed and pushed +as `4113ed0` (2026-09-27 02:04 UTC) **before the run**. No line of the driver has changed +since. +**Harness:** `keel/sim/accumulation_policy.py` (47 tests, each seen failing first; 15 mutants +killed). Arm A reproduces `sim.benchmark.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. +**Data:** [`2026-09-27-accumulation-policy.jsonl`](2026-09-27-accumulation-policy.jsonl). +**Ledger:** 16 `ablation` rows (4 arms × 2 runs × 2 fee models), `a_priori`, +`diagnostic_only`, session `accumulation-policy-2026-09-27`. + +## 1. Setup (as pre-registered) + +- **Deposits:** $500 on the first UTC day of every month, identical for every arm. Idle cash + earns nothing. +- **Fills:** decided on the completed daily bar, filled at the next open. +- **Fees, allowance-aware (headline):** buys fee-free up to $500 a month; buy notional beyond + that pays the 1.20% taker, and sells always pay it. Per-product slippage on every leg. + The flat-taker model charges 1.20% on every leg. +- **Runs:** + - **Primary:** 5 years, 2021-09-28 → 2026-09-25, the 7 non-PAXG assets with weights + renormalised. 1,824 days, 61 deposits. + - **Secondary:** about 1.4 years, 2025-05-08 → 2026-09-25, all 8 assets including PAXG. + 506 days, 17 deposits. +- **Arms:** + - **A:** static DCA. + - **B:** bounded value averaging, buy-only, C_max = 3. + - **C:** deposit steered to underweights by shortfall. + - **D:** DCA plus selective trimming, band `max(0.15·w, 0.015)`. +- **Inference:** a joint stationary bootstrap, 2,000 paths at mean blocks of 20 and 60 days, + seed 831. +- **Rule D6:** an arm is "better" only if **P(ΔSortino > 0) ≥ 0.95 and median Δmax-drawdown + ≤ 0 under both block lengths**. + +## 2. Result: headline (primary run, allowance-aware fees) + +| Arm | Terminal value | TWR | Sortino | Max DD | Fees (buy / sell) | Cash share | P(ΔSortino > 0) b20 / b60 | Median ΔmaxDD b20 / b60 | Verdict | +|---|---|---|---|---|---|---|---|---|---| +| A | $46,629 | +23.7% | 0.523 | 79.5% | $0 / $0 | 0.3% | — | — | baseline | +| B | $48,840 | +32.6% | 0.542 | 78.5% | $33 / $0 | 8.3% | 0.583 / 0.599 | −2.1 / −2.3 pts | not better | +| C | $47,967 | +31.6% | 0.564 | 79.1% | $0 / $0 | 0.3% | 0.365 / 0.407 | +0.6 / +0.4 pts | not better | +| D | $52,919 | +46.0% | 0.611 | 79.3% | $462 / $468 | 0.3% | 0.363 / 0.434 | +0.3 / +0.1 pts | not better | + +Deposits total $30,500 in every arm. + +**Verdict under D6: no arm is better than static DCA after fees.** It is the same in all four +combinations of run and fee model. No arm reaches even P = 0.62, against a bar of 0.95. + +## 3. The other runs + +| Run / fees | A | B | C | D | Verdict | +|---|---|---|---|---|---| +| Primary / flat taker | $46,069 · Sortino 0.494 | $48,387 · 0.514 | $47,389 · 0.536 | $52,284 · 0.582 | none better | +| Secondary / allowance | $8,834 · 0.240 | $8,996 · 0.294 | $8,992 · 0.275 | $8,974 · 0.257 | none better | +| Secondary / flat taker | $8,728 · 0.136 | $8,884 · 0.190 | $8,885 · 0.174 | $8,867 · 0.157 | none better | + +Each cell is terminal value on the historical path · Sortino of the time-weighted index. +Bootstrap probabilities for every combination are in the dataset. The highest anywhere is B on +the primary run with flat fees: 0.6125 at 60-day blocks. + +## 4. What it says + +- **The historical path flatters band trimming, and the bootstrap takes it back.** On the one + path that happened, arm D ends $6,290 (13.5%) ahead of A, with the best Sortino. Across + 2,000 resampled paths, D improves on A's Sortino only **36–43% of the time**. Its lead + belongs to the ordering of this one path, not to a property the resampled paths share. Why + this path favoured it was not examined here. That is exactly the result a single backtest + would have reported as a win, and this record declines to. +- **Value averaging is the most consistent arm, and still nowhere near the bar.** B is ahead + of A in about 58–61% of paths at both block lengths, and it lowers the median drawdown by + about 2 points. It pays for that with an 8% average cash share, the cash drag the design + predicted. It is modest and consistent, but not robust. +- **Steering contributions (C) is effectively A.** It costs nothing extra and changes little, + as predicted. +- **Fees are not the story at these amounts.** Flat taker lowers each arm's terminal value + by $453–$635 over five years. The ranking and the verdict don't change. +- **All four arms lived through the same crash.** The maximum drawdown of the time-weighted + index is 78–80% in every arm on the primary run (2022). No deployment policy tested here + changes the fact that the portfolio is almost entirely correlated crypto. + +## 5. Prediction versus outcome + +| Pre-registered expectation | Outcome | +|---|---| +| C ≈ A, a small effect | **Held.** | +| B trails in rising markets, leads after drawdowns, no robust Sortino edge | **Held**, for no robust edge. B also finished ahead on both historical paths. | +| D is hurt on the 5-year PAXG-free run (sell fees, correlated assets) | **Wrong on the historical path** (D finished first) and **right under the rule**: the bootstrap gives D no robust edge. | +| D has its best case on the short run with PAXG | **Wrong.** On the secondary run D was the weakest of the three alternatives. PAXG's slippage is about 100 bp per leg, the highest in the universe, so trimming into and out of it is expensive, and 1.4 years is one regime. | + +The KB §51 prior ("fixed-weight rebalancing = wrong paradigm") is **not contradicted**: the +one leg that was supposed to rescue rebalancing did not. + +## 6. What follows + +Under the pre-registered rule, nothing changes. The live weekly $50 BTC DCA (rule 6) remains +the accumulation policy. Nothing here is a reason to add rebalancing sells, which would also +need the `sell_only_on_rule` decision the design flagged. If the question is re-opened, the +data points to value averaging's drawdown reduction as the only effect with a consistent +sign. A re-test should be pre-registered on drawdown, not Sortino, and counted against the 3 +trials already spent here. diff --git a/docs/experiments/README.md b/docs/experiments/README.md index a1dc555e..9d90f7f3 100644 --- a/docs/experiments/README.md +++ b/docs/experiments/README.md @@ -25,6 +25,13 @@ Index is **newest first**, by the date each document carries in its filename. ## 2026-09 +- [`2026-09-27-accumulation-policy.md`](2026-09-27-accumulation-policy.md) — Static DCA vs + bounded value averaging, deposits steered to underweights, and selective band trimming, on + equal $500 monthly deposits with allowance-aware fees. **Pre-registered** (design #834, + driver pushed before the run). **No arm beats static DCA** under the decision rule, in either + run or fee model. Band trimming leads on the historical 5-year path (+13.5%) but improves + Sortino in only 36–43% of 2,000 bootstrap paths; value averaging is the most consistent + (58–61%) and still far from 0.95. Driver `2026-09-27-accumulation-policy.py`, data `.jsonl`. - [`2026-09-27-turtle-sma200-filter.md`](2026-09-27-turtle-sma200-filter.md) — A 200-day SMA trend filter on the daily `turtle_breakout`, **pre-registered** (driver committed before the run): `above`, `slope` and `both` arms against the unfiltered baseline on a common diff --git a/docs/experiments/trials-ledger.jsonl b/docs/experiments/trials-ledger.jsonl index ae57f78d..f4cbb578 100644 --- a/docs/experiments/trials-ledger.jsonl +++ b/docs/experiments/trials-ledger.jsonl @@ -96,3 +96,19 @@ {"decision":"diagnostic_only","kind":"ablation","params":{"trend_filter":"above","trend_slope_lookback":5,"trend_sma_period":200,"units":"per_trade_pnl is R, not dollars","verdict":"no improvement distinguishable from the baseline"},"per_bar_pnl":[],"per_trade_pnl":["1.292148086974900141034908216","-1.376522086104324129707675681","-1.004370921023420863167429861","-0.7718713698114655561104454677","5.487170764056078383764899962","5.493664764750066872915912537","-1.299690231648594099144316316","1.785747660760297998360903885","0.4085455393963267418523947947","0.6523413546901512780818913355","-1.031197158425031829389798502","5.449226759550496583622967309","5.502521068077155671375529692","-1.275331898160541569921941879","-1.291222326887620023795142400","-0.9353339277160885326520271417","5.564759175377587348497785643","-1.238295027475741349601029412","3.264129262820485021583199482","-1.609310596730684773954941731","-1.742755441101091883819509982","-1.201671811002749542032264133","5.589787907045651543731066488","5.661338319607414320018035642","-1.172603430258700601274637530","-1.188243195613420001666257105","-1.251618985975241181932772389","-1.222306056116387201636581789","-1.222714239767558290652595563","-1.256773763166714161581309906","-1.041438927173170441567936113","-1.290844640972520547612357855","-0.6979693952241269560394188548","-1.367001175686148659782174997","-1.216966009678026955018584496","-1.185595601330898020471993522","-1.281358518437332914626847925","-1.337456446839613019376554851","5.410587140417745437983677532","5.507083305038216011719715426","-0.1264895652952541177690146447","-0.7064960272910583424305993955","-1.231913953360633913130207035","-1.244756565913745447789845540","-1.203694966538309045790725213","-0.6810792537319196751317532369","-1.233880666704040378685529770","-1.257758170880544324830115888","5.323391080936292855843732546","0.5916218731158806167814979758","-1.227988069412323218548238378","5.581184227460517172362939405","-1.147583946640052749598797667","-1.221639500995219228038574337","-1.223607196849596306794217141","-1.218026449970101319911501800","-1.248042221459613779682104513","5.432265599267714288015511929","0.3721561981966611058603056874","-1.215652746372685467335586364","-1.231498037788113013029157337","-1.226846335014515514050752680","5.584881856312180205723411540","-1.241529632723700505223230387","-0.4098855657145455679345851622","0.02615681626801874585898427896","-1.390368472302519821228579362","-1.314200235613775990355800490","-1.240513390324864350709477586","-1.244163994778885712036608541","-1.183635729985173300716044753","-1.233816628738071483289033667","-0.3189887925447340638323405106","-0.6790763688915749551671504224","5.135940927848700029977821552","5.433959990368987073375621070","-1.145346892237493453743478771","-1.195106255935717199349928868","0.4489549667683345825148424608","-1.189782124293966130656678216","-1.195632720527757737116898441","-1.231377252246935556760924509","-1.341572688452216252511459184","-1.313792948059957653521572020","-1.263680324035083528141242787","-1.239773813839844929698029767","-1.260733480410325162630853668","5.489777605588644141157323092","-1.131825914782156916925168072","-1.219971732684665488140123770","5.616002533957632519573393544","-1.201499160076091882346590586","-1.308699067259459052882732417","-1.202595582503312798867709105","5.411676783079823890805598874","-1.175184384738606216440151708","-0.3207530983306938150034591523","-1.180838147491307734099564122","5.196638075122606248954568210","-1.235818964299967906523607597","5.377562152961955305281788265","-1.196009707594184898852082210","5.424113220286212744680656040","5.568677125893870279003434310","-1.159424329637310106576677191","-1.194412099269006553252697508","-1.234555807616695321679150549","-1.157280422043950879252122554","-1.280901334789467991155456966","-1.227722529249636513493367578","-1.291562764621049893422828068","5.280443581684204985442667474","-1.237767142166843104343134667","-1.161152453384780176896137705","-1.183512034448321938975866417","-1.173558821072350083810407598","-1.180654371091019496704231797","-1.195409641111178355209552949","-1.168982826045336174422605325","-1.243401727911904640599239608","5.451506597083003264426719038","-1.189286830920358936151086870","-1.161648826547384513177314967","5.539458018351884364187857795","0.9006694602109121694531127235","5.231765470000697985022302055","-0.6487392703946249321342160979","5.368325501971480328564369458","-1.196549982610472632423318898","-1.158708430339273606624725211","-1.151786003684574923920567824","-1.181443140619646075925126764","-1.203031766522364494024121423","-1.200768957280433729677255265","-1.271920830531581980987632196","5.372719827045794442975062199","-1.185453446943265618695707083","-1.152840021822224207915158330","-0.9640341139411848930553264909","-1.143899141190035047495123762","-1.253825240432750183323247178","-1.309145155980422565480432203","-1.249361218988967007827277209","-1.258738824640275286834370120","-1.229481138072890335176775613","-1.308329413954474304078950294","5.271765050123315563346679925","-1.193487777949478817730590845","-1.248040352547708868340343490","-1.194335115243085157179799084","5.453432591853407940871466544","-1.259511833164055200969661146","-1.047826864120045039670274990","-1.166032908224816408094599918","-1.228591524286868083226594138","5.470598581939795942355655909","-1.174497321081851286178195203","-0.8015767908941090340421152604","-1.078865571368224980279129744","-1.289996894642511429159249924","-1.257526582032900401643430867","-1.205996994502027875512488776","-1.252634771038423653177145998","-1.236476665774456249229611013","-1.307561185711953909514529982","5.207415526052777467498179006","-1.184804186109972412382417524","-1.243673653347884086688024949","5.336845288474578881433300381","-1.180227297397513444852130396","-1.219211550766279775072315677","-1.240491515412467749411824247","-1.277421091908469052534784351","5.180351203695749485168809659","-0.08453576755520204712395934131","-1.270135739018584468157658834","-1.267468335535270539995610787","-1.260995743091046368732804280","-1.163388648619721085612197134","0.4954012584758866086407883462","1.685973633422451115868775142"],"prev_hash":"dcf0a0aec96b78dfddf38989f0cf4dc8d19563a1f29e0a0ad8d77a8e259a1942","provenance":"a_priori","row_hash":"7eff3ea4b7119c256cad57989f653441f775f8a832f0694edd93808fae7c4d20","rule":"turtle_breakout","series_missing":false,"session":"turtle-sma200-filter-2026-09-27","summary":{"avg_loss_r":"-1.1615","avg_win_r":"4.2681","breakeven_win_rate":"0.2139","detectable_edge":"0.1483","diff_expectancy_r_ci95_high":"0.5397","diff_expectancy_r_ci95_low":"-0.6410","edge_over_breakeven":"0.0402","evaluated":true,"expectancy_r":"0.2184","n":181,"n_eff":"70.3","profit_factor_r":"1.2520","win_rate":"0.2541"},"timestamp":1790469741,"trial_id":"turtle-sma200-filter-2026-09-27-above"} {"decision":"diagnostic_only","kind":"ablation","params":{"trend_filter":"slope","trend_slope_lookback":5,"trend_sma_period":200,"units":"per_trade_pnl is R, not dollars","verdict":"no improvement distinguishable from the baseline"},"per_bar_pnl":[],"per_trade_pnl":["-1.241245958550353540129722424","-1.004370921023420863167429861","-0.7718713698114655561104454677","5.487170764056078383764899962","5.493664764750066872915912537","-1.299690231648594099144316316","1.785747660760297998360903885","0.4085455393963267418523947947","0.6523413546901512780818913355","-1.031197158425031829389798502","-1.387167603782142265366038721","5.502141579706489594539158670","-0.4588021471691066693524730196","-0.9353339277160885326520271417","-1.288239925923764676545487387","0.2460945060624967123966774733","3.264129262820485021583199482","-1.609310596730684773954941731","-1.201671811002749542032264133","5.589787907045651543731066488","5.661338319607414320018035642","-1.172603430258700601274637530","-1.188243195613420001666257105","-1.251618985975241181932772389","-1.222306056116387201636581789","-1.222714239767558290652595563","-1.041438927173170441567936113","-1.290844640972520547612357855","-1.367001175686148659782174997","-1.216966009678026955018584496","-1.185595601330898020471993522","-1.281358518437332914626847925","5.410587140417745437983677532","5.507083305038216011719715426","-0.7064960272910583424305993955","-1.231913953360633913130207035","-1.203694966538309045790725213","-0.6810792537319196751317532369","5.402461528152645729378636713","-1.227988069412323218548238378","5.581184227460517172362939405","-1.147583946640052749598797667","-1.223607196849596306794217141","-1.248042221459613779682104513","5.432265599267714288015511929","0.3721561981966611058603056874","-1.215652746372685467335586364","-1.231498037788113013029157337","-1.226846335014515514050752680","5.584881856312180205723411540","-1.336712834654472077257673808","-1.272058768146113115220848141","-1.183635729985173300716044753","-1.233816628738071483289033667","-0.3189887925447340638323405106","-0.6790763688915749551671504224","5.135940927848700029977821552","5.433959990368987073375621070","-1.145346892237493453743478771","-1.195106255935717199349928868","0.4489549667683345825148424608","-1.195632720527757737116898441","-1.231377252246935556760924509","-1.260733480410325162630853668","5.489777605588644141157323092","-1.131825914782156916925168072","5.616002533957632519573393544","5.335189434094858364489053846","-1.172214898782804103122629416","-0.3207530983306938150034591523","-1.235818964299967906523607597","5.377562152961955305281788265","5.579361739612056265169368626","-1.184615506749192693680489720","-1.194412099269006553252697508","-1.234555807616695321679150549","-1.280901334789467991155456966","-1.291562764621049893422828068","-1.212678607166546162223055419","-1.237767142166843104343134667","-1.161152453384780176896137705","-1.183512034448321938975866417","-1.173558821072350083810407598","-1.168982826045336174422605325","5.451506597083003264426719038","-1.189286830920358936151086870","5.539458018351884364187857795","0.9006694602109121694531127235","-1.184623334245156098175315735","0.4850432409603459827393866322","5.368325501971480328564369458","-1.196549982610472632423318898","-1.158708430339273606624725211","5.356412735383928913719350248","-1.152840021822224207915158330","-1.178759522737595293838863098","-1.150076100886395101969425008","-1.249361218988967007827277209","-0.1870805882988304826130244598","-1.248040352547708868340343490","-1.194335115243085157179799084","0.1268110057510902454823510767","-1.213201333919988166281777670","-1.212183797991150779081799465","-1.168010177888181547928101032","5.437133392437201735570808777","-0.8015767908941090340421152604","-1.205996994502027875512488776","-1.228542653469393763443983481","-1.184804186109972412382417524","-1.243673653347884086688024949","5.386779429538590313163963948","-1.168773441221553935399796292","5.180351203695749485168809659","-0.08453576755520204712395934131","-1.270135739018584468157658834","-1.267468335535270539995610787","-1.260995743091046368732804280","-1.163388648619721085612197134","-1.186307564910810433194974640","1.685973633422451115868775142"],"prev_hash":"7eff3ea4b7119c256cad57989f653441f775f8a832f0694edd93808fae7c4d20","provenance":"a_priori","row_hash":"a2968bdd11421fc1437220230b6524e2a4709e3253b4c09dc4ae104d5d586cfe","rule":"turtle_breakout","series_missing":false,"session":"turtle-sma200-filter-2026-09-27","summary":{"avg_loss_r":"-1.1294","avg_win_r":"4.0755","breakeven_win_rate":"0.2170","detectable_edge":"0.1814","diff_expectancy_r_ci95_high":"0.8289","diff_expectancy_r_ci95_low":"-0.5151","edge_over_breakeven":"0.0805","evaluated":true,"expectancy_r":"0.4191","n":121,"n_eff":"47.0","profit_factor_r":"1.5283","win_rate":"0.2975"},"timestamp":1790469742,"trial_id":"turtle-sma200-filter-2026-09-27-slope"} {"decision":"diagnostic_only","kind":"ablation","params":{"trend_filter":"both","trend_slope_lookback":5,"trend_sma_period":200,"units":"per_trade_pnl is R, not dollars","verdict":"no improvement distinguishable from the baseline"},"per_bar_pnl":[],"per_trade_pnl":["-1.241245958550353540129722424","-1.004370921023420863167429861","-0.7718713698114655561104454677","5.487170764056078383764899962","5.493664764750066872915912537","-1.299690231648594099144316316","1.785747660760297998360903885","0.4085455393963267418523947947","0.6523413546901512780818913355","-1.031197158425031829389798502","-1.387167603782142265366038721","5.502141579706489594539158670","-0.4588021471691066693524730196","-0.9353339277160885326520271417","-1.288239925923764676545487387","0.2460945060624967123966774733","3.264129262820485021583199482","-1.609310596730684773954941731","-1.201671811002749542032264133","5.589787907045651543731066488","5.661338319607414320018035642","-1.172603430258700601274637530","-1.188243195613420001666257105","-1.251618985975241181932772389","-1.222306056116387201636581789","-1.222714239767558290652595563","-1.041438927173170441567936113","-1.290844640972520547612357855","-1.367001175686148659782174997","-1.216966009678026955018584496","-1.185595601330898020471993522","-1.281358518437332914626847925","5.410587140417745437983677532","5.507083305038216011719715426","-0.7064960272910583424305993955","-1.231913953360633913130207035","-1.203694966538309045790725213","-0.6810792537319196751317532369","5.402461528152645729378636713","-1.227988069412323218548238378","5.581184227460517172362939405","-1.147583946640052749598797667","-1.223607196849596306794217141","-1.248042221459613779682104513","5.432265599267714288015511929","0.3721561981966611058603056874","-1.215652746372685467335586364","-1.231498037788113013029157337","-1.226846335014515514050752680","5.584881856312180205723411540","-1.336712834654472077257673808","-1.272058768146113115220848141","-1.183635729985173300716044753","-1.233816628738071483289033667","-0.3189887925447340638323405106","-0.6790763688915749551671504224","5.135940927848700029977821552","5.433959990368987073375621070","-1.145346892237493453743478771","-1.195106255935717199349928868","0.4489549667683345825148424608","-1.195632720527757737116898441","-1.231377252246935556760924509","-1.260733480410325162630853668","5.489777605588644141157323092","-1.131825914782156916925168072","5.616002533957632519573393544","5.335189434094858364489053846","-1.172214898782804103122629416","-0.3207530983306938150034591523","-1.235818964299967906523607597","5.377562152961955305281788265","5.579361739612056265169368626","-1.184615506749192693680489720","-1.194412099269006553252697508","-1.234555807616695321679150549","-1.280901334789467991155456966","-1.291562764621049893422828068","-1.212678607166546162223055419","-1.237767142166843104343134667","-1.161152453384780176896137705","-1.183512034448321938975866417","-1.173558821072350083810407598","-1.168982826045336174422605325","5.451506597083003264426719038","-1.189286830920358936151086870","5.539458018351884364187857795","0.9006694602109121694531127235","-1.184623334245156098175315735","0.4850432409603459827393866322","5.368325501971480328564369458","-1.196549982610472632423318898","-1.158708430339273606624725211","5.356412735383928913719350248","-1.152840021822224207915158330","-1.178759522737595293838863098","-1.150076100886395101969425008","-1.249361218988967007827277209","-0.1870805882988304826130244598","-1.248040352547708868340343490","-1.194335115243085157179799084","-1.047826864120045039670274990","-1.213201333919988166281777670","-1.212183797991150779081799465","-1.168010177888181547928101032","5.437133392437201735570808777","-0.8015767908941090340421152604","-1.205996994502027875512488776","-1.228542653469393763443983481","-1.184804186109972412382417524","-1.243673653347884086688024949","5.386779429538590313163963948","-1.168773441221553935399796292","5.180351203695749485168809659","-0.08453576755520204712395934131","-1.270135739018584468157658834","-1.267468335535270539995610787","-1.260995743091046368732804280","-1.163388648619721085612197134","-1.186307564910810433194974640","1.685973633422451115868775142"],"prev_hash":"a2968bdd11421fc1437220230b6524e2a4709e3253b4c09dc4ae104d5d586cfe","provenance":"a_priori","row_hash":"aae6ace934798acdfca05c7ead2fb7b98e20d7201373a20f0e4db33f481cf608","rule":"turtle_breakout","series_missing":false,"session":"turtle-sma200-filter-2026-09-27","summary":{"avg_loss_r":"-1.1285","avg_win_r":"4.1883","breakeven_win_rate":"0.2122","detectable_edge":"0.1814","diff_expectancy_r_ci95_high":"0.8236","diff_expectancy_r_ci95_low":"-0.5241","edge_over_breakeven":"0.0770","evaluated":true,"expectancy_r":"0.4094","n":121,"n_eff":"47.0","profit_factor_r":"1.5105","win_rate":"0.2893"},"timestamp":1790469743,"trial_id":"turtle-sma200-filter-2026-09-27-both"} +{"decision":"diagnostic_only","kind":"ablation","params":{"allowance_usd":"500","arm":"A","arm_label":"static DCA","block_lengths":[20,60],"deposit_usd":"500","fee_mode":"allowance","headline":true,"panel_days":1824,"run":"primary","seed":831,"taker_pct":"0.012","units":"per_trade_pnl is the arm's deposit-neutral account P&L per calendar month on the historical path, in USD; not trades","verdict":"baseline","weights":{"ADA":"0.075","BTC":"0.375","ETH":"0.25","LINK":"0.075","LTC":"0.075","SOL":"0.075","XLM":"0.075"},"window_end_ts":1790455996,"window_start_ts":1632775996},"per_bar_pnl":[],"per_trade_pnl":["34.647614","298.645942","-55.691201","-468.292054","-534.119431","120.810453","292.889899","-802.892462","-780.127534","-1052.913708","645.249393","-454.155149","-137.471244","263.016212","-685.488972","-511.297373","1851.585325","-71.695859","1089.436008","42.306513","-372.841117","656.989821","69.166602","-1753.253516","506.320049","2617.331088","2128.293919","3301.897011","-533.188532","7702.572517","5620.348494","-7865.575607","4950.352260","-3263.610803","669.070490","-4527.093641","2034.695147","1695.726352","20192.500943","-6267.805254","6010.622585","-13757.241892","-3524.383241","3715.637213","4600.627917","-443.155381","9653.462110","1941.136811","792.532004","-5233.720429","-11609.683455","-2308.226152","-5650.385400","-5818.790256","611.459345","2507.848462","-179.084982","-7766.448763","2463.081188","8184.226700","5293.044540"],"prev_hash":"aae6ace934798acdfca05c7ead2fb7b98e20d7201373a20f0e4db33f481cf608","provenance":"a_priori","row_hash":"aa786849732a743b9c82f29b09e2c9b0eaca7e10818c2523ef329ccaa7b13c76","rule":"accumulation_policy","series_missing":false,"session":"accumulation-policy-2026-09-27","summary":{"headline":true,"hist_avg_cash_share":"0.002686","hist_buy_fees":"0.000000","hist_buy_notional":"30500.000000","hist_cagr_money_weighted":"0.088708","hist_d_mdd":"0.000000","hist_d_sortino":"0.000000","hist_deposited":"30500.000000","hist_irr":"0.012590","hist_max_drawdown":"0.795007","hist_realized_pnl":"0.000000","hist_sell_fees":"0.000000","hist_sell_notional":"0.000000","hist_sharpe":"0.368893","hist_sortino":"0.523146","hist_terminal_value":"46628.897521","hist_turnover":"1.000000","hist_twr_total":"0.236618","n_paths":2000},"timestamp":1790474873,"trial_id":"accumulation-policy-2026-09-27-primary-allowance-A"} +{"decision":"diagnostic_only","kind":"ablation","params":{"allowance_usd":"500","arm":"B","arm_label":"bounded value averaging, buy-only","block_lengths":[20,60],"deposit_usd":"500","fee_mode":"allowance","headline":true,"panel_days":1824,"run":"primary","seed":831,"taker_pct":"0.012","units":"per_trade_pnl is the arm's deposit-neutral account P&L per calendar month on the historical path, in USD; not trades","verdict":"not better than static DCA after fees","weights":{"ADA":"0.075","BTC":"0.375","ETH":"0.25","LINK":"0.075","LTC":"0.075","SOL":"0.075","XLM":"0.075"},"window_end_ts":1790455996,"window_start_ts":1632775996},"per_bar_pnl":[],"per_trade_pnl":["34.647614","278.112581","-50.984005","-407.471500","-546.837246","112.817735","306.185425","-825.884929","-786.404971","-1042.469118","639.457806","-457.441444","-130.057062","257.013080","-679.447575","-521.151668","1856.287055","-72.859963","1101.825885","34.508690","-376.316156","615.481175","202.248349","-1791.810611","539.669557","2705.968808","2302.582396","3583.763061","-613.848028","7306.232731","5658.716270","-7831.843554","4642.602174","-3066.797494","748.497475","-4179.265539","1887.888821","1344.342002","20785.700303","-6960.405595","5970.645418","-12810.953177","-3465.063617","2991.862695","3825.475909","-740.311223","9271.861234","2056.961869","496.355805","-4867.769688","-9851.859082","-2196.865768","-4738.354695","-4681.283341","442.436814","1754.106142","165.869398","-6720.085965","2153.840058","7258.818331","5421.034000"],"prev_hash":"aa786849732a743b9c82f29b09e2c9b0eaca7e10818c2523ef329ccaa7b13c76","provenance":"a_priori","row_hash":"cac71eb7ffb3a8225a7f249848441494115800b1768095cd5fe4ab0fc10e4356","rule":"accumulation_policy","series_missing":false,"session":"accumulation-policy-2026-09-27","summary":{"d_sortino_p05_b20":"-0.081599","d_sortino_p05_b60":"-0.078932","d_sortino_p95_b20":"0.109273","d_sortino_p95_b60":"0.112171","headline":true,"hist_avg_cash_share":"0.083230","hist_buy_fees":"33.295544","hist_buy_notional":"23616.385067","hist_cagr_money_weighted":"0.098854","hist_d_mdd":"-0.009753","hist_d_sortino":"0.019023","hist_deposited":"30500.000000","hist_irr":"0.013892","hist_max_drawdown":"0.785254","hist_realized_pnl":"0.000000","hist_sell_fees":"0.000000","hist_sell_notional":"0.000000","hist_sharpe":"0.382729","hist_sortino":"0.542169","hist_terminal_value":"48839.973651","hist_turnover":"0.774308","hist_twr_total":"0.326258","median_d_mdd_b20":"-0.021310","median_d_mdd_b60":"-0.022475","median_d_sortino_b20":"0.009563","median_d_sortino_b60":"0.011247","n_paths":2000,"p_sortino_positive_b20":"0.583000","p_sortino_positive_b60":"0.599000","passes_b20":false,"passes_b60":false},"timestamp":1790474873,"trial_id":"accumulation-policy-2026-09-27-primary-allowance-B"} +{"decision":"diagnostic_only","kind":"ablation","params":{"allowance_usd":"500","arm":"C","arm_label":"deposit steered by shortfall","block_lengths":[20,60],"deposit_usd":"500","fee_mode":"allowance","headline":true,"panel_days":1824,"run":"primary","seed":831,"taker_pct":"0.012","units":"per_trade_pnl is the arm's deposit-neutral account P&L per calendar month on the historical path, in USD; not trades","verdict":"not better than static DCA after fees","weights":{"ADA":"0.075","BTC":"0.375","ETH":"0.25","LINK":"0.075","LTC":"0.075","SOL":"0.075","XLM":"0.075"},"window_end_ts":1790455996,"window_start_ts":1632775996},"per_bar_pnl":[],"per_trade_pnl":["34.647614","297.693883","-63.364753","-462.846192","-526.318438","108.392812","311.388408","-819.779495","-791.149280","-1011.900625","685.143887","-472.211370","-112.733538","264.632258","-723.675606","-542.367927","2047.647505","-116.052674","1084.295020","24.400275","-392.402548","554.023951","308.732379","-1832.671382","556.991766","2756.940675","2414.239182","3859.758399","-716.395041","7845.042633","6159.891999","-8690.618300","5166.826199","-3485.378219","777.299993","-4782.194028","2105.685338","1449.574651","24362.271263","-8170.529062","6916.782277","-15256.882111","-4248.959262","3373.728978","4632.011838","-953.884404","11774.216771","3091.491777","298.919758","-6223.904404","-12420.363938","-2714.490079","-6159.588147","-5842.169897","542.701604","2133.243394","122.631611","-8173.990819","2573.480089","8139.821455","6399.015567"],"prev_hash":"cac71eb7ffb3a8225a7f249848441494115800b1768095cd5fe4ab0fc10e4356","provenance":"a_priori","row_hash":"5fc5020928deebb206c0b3d48378a12cbc286b1bd9e7e4961c04571131ea0fd8","rule":"accumulation_policy","series_missing":false,"session":"accumulation-policy-2026-09-27","summary":{"d_sortino_p05_b20":"-0.109321","d_sortino_p05_b60":"-0.103930","d_sortino_p95_b20":"0.048797","d_sortino_p95_b60":"0.050821","headline":true,"hist_avg_cash_share":"0.002668","hist_buy_fees":"0.000000","hist_buy_notional":"30500.000000","hist_cagr_money_weighted":"0.094892","hist_d_mdd":"-0.003753","hist_d_sortino":"0.041075","hist_deposited":"30500.000000","hist_irr":"0.013386","hist_max_drawdown":"0.791254","hist_realized_pnl":"0.000000","hist_sell_fees":"0.000000","hist_sell_notional":"0.000000","hist_sharpe":"0.396626","hist_sortino":"0.564221","hist_terminal_value":"47966.743670","hist_turnover":"1.000000","hist_twr_total":"0.316337","median_d_mdd_b20":"0.005560","median_d_mdd_b60":"0.004204","median_d_sortino_b20":"-0.016209","median_d_sortino_b60":"-0.010752","n_paths":2000,"p_sortino_positive_b20":"0.365000","p_sortino_positive_b60":"0.407500","passes_b20":false,"passes_b60":false},"timestamp":1790474873,"trial_id":"accumulation-policy-2026-09-27-primary-allowance-C"} +{"decision":"diagnostic_only","kind":"ablation","params":{"allowance_usd":"500","arm":"D","arm_label":"DCA plus selective trimming","block_lengths":[20,60],"deposit_usd":"500","fee_mode":"allowance","headline":true,"panel_days":1824,"run":"primary","seed":831,"taker_pct":"0.012","units":"per_trade_pnl is the arm's deposit-neutral account P&L per calendar month on the historical path, in USD; not trades","verdict":"not better than static DCA after fees","weights":{"ADA":"0.075","BTC":"0.375","ETH":"0.25","LINK":"0.075","LTC":"0.075","SOL":"0.075","XLM":"0.075"},"window_end_ts":1790455996,"window_start_ts":1632775996},"per_bar_pnl":[],"per_trade_pnl":["34.647614","298.645942","-55.691201","-468.292054","-534.119431","120.810453","292.889899","-802.892462","-780.127534","-1021.309653","656.092545","-467.662044","-124.013309","254.355869","-705.915308","-544.773813","1958.109941","-92.450752","1128.669006","39.546844","-396.478894","597.718477","274.144499","-1800.617359","576.658328","2672.021762","2151.310700","3183.234376","-608.963321","7763.188916","4641.242019","-7063.872605","4447.909719","-3239.077932","158.358485","-4164.723722","1774.162698","973.295451","26325.894191","-5042.333833","5819.166377","-14614.959340","-4797.363930","3111.074639","6152.062975","-831.798576","13917.011787","3995.305868","-105.782821","-6607.217419","-13406.875843","-2641.036477","-6879.199619","-6472.202809","703.595915","2676.677199","-270.221142","-9099.026924","3271.011706","9182.168563","6906.955517"],"prev_hash":"5fc5020928deebb206c0b3d48378a12cbc286b1bd9e7e4961c04571131ea0fd8","provenance":"a_priori","row_hash":"1a1638dc35787d050a618d4f771deb23d71fa02a02b21980baa3721e07013721","rule":"accumulation_policy","series_missing":false,"session":"accumulation-policy-2026-09-27","summary":{"d_sortino_p05_b20":"-0.173931","d_sortino_p05_b60":"-0.158211","d_sortino_p95_b20":"0.073275","d_sortino_p95_b60":"0.078540","headline":true,"hist_avg_cash_share":"0.002665","hist_buy_fees":"462.352457","hist_buy_notional":"69029.371418","hist_cagr_money_weighted":"0.116644","hist_d_mdd":"-0.002164","hist_d_sortino":"0.087582","hist_deposited":"30500.000000","hist_irr":"0.016119","hist_max_drawdown":"0.792843","hist_realized_pnl":"18279.319636","hist_sell_fees":"467.968074","hist_sell_notional":"38997.339492","hist_sharpe":"0.428050","hist_sortino":"0.610728","hist_terminal_value":"52918.938151","hist_turnover":"3.541859","hist_twr_total":"0.460490","median_d_mdd_b20":"0.003045","median_d_mdd_b60":"0.001413","median_d_sortino_b20":"-0.022862","median_d_sortino_b60":"-0.010552","n_paths":2000,"p_sortino_positive_b20":"0.363000","p_sortino_positive_b60":"0.434000","passes_b20":false,"passes_b60":false},"timestamp":1790474873,"trial_id":"accumulation-policy-2026-09-27-primary-allowance-D"} +{"decision":"diagnostic_only","kind":"ablation","params":{"allowance_usd":"500","arm":"A","arm_label":"static DCA","block_lengths":[20,60],"deposit_usd":"500","fee_mode":"flat","headline":false,"panel_days":1824,"run":"primary","seed":831,"taker_pct":"0.012","units":"per_trade_pnl is the arm's deposit-neutral account P&L per calendar month on the historical path, in USD; not trades","verdict":"baseline","weights":{"ADA":"0.075","BTC":"0.375","ETH":"0.25","LINK":"0.075","LTC":"0.075","SOL":"0.075","XLM":"0.075"},"window_end_ts":1790455996,"window_start_ts":1632775996},"per_bar_pnl":[],"per_trade_pnl":["28.231843","289.062190","-61.022907","-468.672549","-533.709998","113.360728","283.375220","-799.257753","-776.766004","-1046.278744","631.506401","-454.705287","-141.821589","253.860018","-683.263104","-511.161805","1823.366301","-76.835509","1070.362776","35.798834","-374.367024","643.105943","62.336603","-1738.214474","494.244208","2579.923115","2096.754392","3256.274247","-532.790270","7604.141647","5546.904313","-7777.188699","4884.948033","-3230.447474","655.041644","-4478.768518","2004.278805","1669.377636","19944.190932","-6198.591591","5932.495114","-13598.154989","-3488.090642","3665.049567","4539.420382","-443.837516","9531.620564","1911.843169","777.021620","-5176.915783","-11476.367254","-2286.527439","-5588.580775","-5754.964773","598.121833","2471.754281","-182.935962","-7679.251377","2427.524214","8080.015979","5223.528005"],"prev_hash":"1a1638dc35787d050a618d4f771deb23d71fa02a02b21980baa3721e07013721","provenance":"a_priori","row_hash":"6b4bd558d838ab1c97e4e001eed0fe6c80ea57674cedb46e5e6424815ed90ca7","rule":"accumulation_policy","series_missing":false,"session":"accumulation-policy-2026-09-27","summary":{"headline":false,"hist_avg_cash_share":"0.002707","hist_buy_fees":"366.000000","hist_buy_notional":"30500.000000","hist_cagr_money_weighted":"0.086080","hist_d_mdd":"0.000000","hist_d_sortino":"0.000000","hist_deposited":"30500.000000","hist_irr":"0.012248","hist_max_drawdown":"0.799815","hist_realized_pnl":"0.000000","hist_sell_fees":"0.000000","hist_sell_notional":"0.000000","hist_sharpe":"0.348773","hist_sortino":"0.494344","hist_terminal_value":"46069.350751","hist_turnover":"1.000000","hist_twr_total":"0.165326","n_paths":2000},"timestamp":1790475063,"trial_id":"accumulation-policy-2026-09-27-primary-flat-A"} +{"decision":"diagnostic_only","kind":"ablation","params":{"allowance_usd":"500","arm":"B","arm_label":"bounded value averaging, buy-only","block_lengths":[20,60],"deposit_usd":"500","fee_mode":"flat","headline":false,"panel_days":1824,"run":"primary","seed":831,"taker_pct":"0.012","units":"per_trade_pnl is the arm's deposit-neutral account P&L per calendar month on the historical path, in USD; not trades","verdict":"not better than static DCA after fees","weights":{"ADA":"0.075","BTC":"0.375","ETH":"0.25","LINK":"0.075","LTC":"0.075","SOL":"0.075","XLM":"0.075"},"window_end_ts":1790455996,"window_start_ts":1632775996},"per_bar_pnl":[],"per_trade_pnl":["28.231843","271.311805","-54.118375","-412.315883","-546.011693","105.533095","296.168350","-822.287260","-782.955005","-1036.699504","626.160407","-457.921120","-134.822210","248.178266","-677.590991","-520.607356","1827.922999","-77.905474","1082.363727","29.142565","-377.325315","607.263974","180.555809","-1771.828548","525.845396","2657.879032","2252.272132","3497.892391","-600.644308","7221.796695","5543.465014","-7688.113068","4572.449841","-3028.418278","710.450058","-4121.771026","1851.074783","1313.268088","20648.105515","-6891.184845","5894.789636","-12663.202812","-3432.430390","2942.624380","3792.750765","-739.542546","9228.617104","2022.141714","480.765840","-4830.660629","-9751.505621","-2184.719172","-4701.716398","-4641.309334","435.439613","1737.047510","163.456339","-6692.082603","2139.846089","7207.191704","5384.694139"],"prev_hash":"6b4bd558d838ab1c97e4e001eed0fe6c80ea57674cedb46e5e6424815ed90ca7","provenance":"a_priori","row_hash":"e27d557e149069a93e54ef371cbc249286e956672064ec63967038f1e360a3c0","rule":"accumulation_policy","series_missing":false,"session":"accumulation-policy-2026-09-27","summary":{"d_sortino_p05_b20":"-0.079938","d_sortino_p05_b60":"-0.077427","d_sortino_p95_b20":"0.111326","d_sortino_p95_b60":"0.113607","headline":false,"hist_avg_cash_share":"0.081980","hist_buy_fees":"286.348860","hist_buy_notional":"23862.405006","hist_cagr_money_weighted":"0.096806","hist_d_mdd":"-0.009074","hist_d_sortino":"0.020148","hist_deposited":"30500.000000","hist_irr":"0.013631","hist_max_drawdown":"0.790741","hist_realized_pnl":"0.000000","hist_sell_fees":"0.000000","hist_sell_notional":"0.000000","hist_sharpe":"0.363369","hist_sortino":"0.514491","hist_terminal_value":"48387.006853","hist_turnover":"0.782374","hist_twr_total":"0.255803","median_d_mdd_b20":"-0.020165","median_d_mdd_b60":"-0.021839","median_d_sortino_b20":"0.011247","median_d_sortino_b60":"0.012247","n_paths":2000,"p_sortino_positive_b20":"0.599000","p_sortino_positive_b60":"0.612500","passes_b20":false,"passes_b60":false},"timestamp":1790475063,"trial_id":"accumulation-policy-2026-09-27-primary-flat-B"} +{"decision":"diagnostic_only","kind":"ablation","params":{"allowance_usd":"500","arm":"C","arm_label":"deposit steered by shortfall","block_lengths":[20,60],"deposit_usd":"500","fee_mode":"flat","headline":false,"panel_days":1824,"run":"primary","seed":831,"taker_pct":"0.012","units":"per_trade_pnl is the arm's deposit-neutral account P&L per calendar month on the historical path, in USD; not trades","verdict":"not better than static DCA after fees","weights":{"ADA":"0.075","BTC":"0.375","ETH":"0.25","LINK":"0.075","LTC":"0.075","SOL":"0.075","XLM":"0.075"},"window_end_ts":1790455996,"window_start_ts":1632775996},"per_bar_pnl":[],"per_trade_pnl":["28.231843","288.132844","-68.519669","-463.347348","-525.993340","101.143822","301.519912","-815.955458","-787.533229","-1005.892563","670.569508","-472.581328","-117.584331","255.280381","-720.776855","-541.640723","2016.160078","-120.491325","1065.405362","18.049830","-393.597352","541.374272","298.958173","-1816.495660","544.286657","2717.684698","2378.991196","3806.873370","-713.635131","7744.437907","6079.004735","-8591.242304","5098.366756","-3449.254143","761.739891","-4730.326374","2074.220610","1426.147876","24061.096014","-8077.114258","6826.929587","-15078.255716","-4203.408674","3327.100656","4570.329645","-948.210001","11625.829496","3047.939406","289.333116","-6154.613313","-12276.426831","-2687.593489","-6091.307399","-5777.809662","530.237126","2101.747425","114.796263","-8081.476493","2536.641006","8035.888635","6315.585483"],"prev_hash":"e27d557e149069a93e54ef371cbc249286e956672064ec63967038f1e360a3c0","provenance":"a_priori","row_hash":"565ca1736541fdde26f4c691c7ac6bf20493327fbe454fedefff2216bf635a80","rule":"accumulation_policy","series_missing":false,"session":"accumulation-policy-2026-09-27","summary":{"d_sortino_p05_b20":"-0.109221","d_sortino_p05_b60":"-0.102997","d_sortino_p95_b20":"0.049206","d_sortino_p95_b60":"0.051559","headline":false,"hist_avg_cash_share":"0.002689","hist_buy_fees":"366.000000","hist_buy_notional":"30500.000000","hist_cagr_money_weighted":"0.092238","hist_d_mdd":"-0.003644","hist_d_sortino":"0.041940","hist_deposited":"30500.000000","hist_irr":"0.013046","hist_max_drawdown":"0.796171","hist_realized_pnl":"0.000000","hist_sell_fees":"0.000000","hist_sell_notional":"0.000000","hist_sharpe":"0.377183","hist_sortino":"0.536283","hist_terminal_value":"47388.950613","hist_turnover":"1.000000","hist_twr_total":"0.240676","median_d_mdd_b20":"0.005438","median_d_mdd_b60":"0.004023","median_d_sortino_b20":"-0.015654","median_d_sortino_b60":"-0.010318","n_paths":2000,"p_sortino_positive_b20":"0.365000","p_sortino_positive_b60":"0.412000","passes_b20":false,"passes_b60":false},"timestamp":1790475063,"trial_id":"accumulation-policy-2026-09-27-primary-flat-C"} +{"decision":"diagnostic_only","kind":"ablation","params":{"allowance_usd":"500","arm":"D","arm_label":"DCA plus selective trimming","block_lengths":[20,60],"deposit_usd":"500","fee_mode":"flat","headline":false,"panel_days":1824,"run":"primary","seed":831,"taker_pct":"0.012","units":"per_trade_pnl is the arm's deposit-neutral account P&L per calendar month on the historical path, in USD; not trades","verdict":"not better than static DCA after fees","weights":{"ADA":"0.075","BTC":"0.375","ETH":"0.25","LINK":"0.075","LTC":"0.075","SOL":"0.075","XLM":"0.075"},"window_end_ts":1790455996,"window_start_ts":1632775996},"per_bar_pnl":[],"per_trade_pnl":["28.231843","289.062190","-61.022907","-468.672549","-533.709998","113.360728","283.375220","-799.257753","-776.766004","-1015.053938","642.219435","-468.050099","-128.525149","245.303598","-703.444324","-544.236527","1928.612621","-97.341343","1109.124978","33.072282","-397.721147","584.545855","264.854765","-1785.009951","563.738428","2633.957501","2119.494971","3139.035563","-607.655762","7664.030649","4579.547114","-6985.106134","4388.534803","-3206.208997","150.458183","-4120.747038","1746.872746","955.615906","26003.983461","-4987.825827","5743.336380","-14445.579828","-4745.795563","3067.741743","6072.238219","-827.816993","13744.007645","3941.362197","-110.513427","-6533.930810","-13251.993333","-2615.344039","-6802.649223","-6400.536375","689.152764","2638.557072","-272.978488","-8995.838601","3225.759566","9065.982540","6818.072051"],"prev_hash":"565ca1736541fdde26f4c691c7ac6bf20493327fbe454fedefff2216bf635a80","provenance":"a_priori","row_hash":"bff66b184ea67bc007c2bb7fb4086e3de8e19429cbdd3d0504760e99c082c60c","rule":"accumulation_policy","series_missing":false,"session":"accumulation-policy-2026-09-27","summary":{"d_sortino_p05_b20":"-0.173355","d_sortino_p05_b60":"-0.159577","d_sortino_p95_b20":"0.073492","d_sortino_p95_b60":"0.078674","headline":false,"hist_avg_cash_share":"0.002686","hist_buy_fees":"822.804228","hist_buy_notional":"68567.018961","hist_cagr_money_weighted":"0.113948","hist_d_mdd":"-0.002130","hist_d_sortino":"0.088103","hist_deposited":"30500.000000","hist_irr":"0.015787","hist_max_drawdown":"0.797685","hist_realized_pnl":"17929.375404","hist_sell_fees":"462.352457","hist_sell_notional":"38529.371418","hist_sharpe":"0.408441","hist_sortino":"0.582447","hist_terminal_value":"52283.910894","hist_turnover":"3.511357","hist_twr_total":"0.376927","median_d_mdd_b20":"0.002976","median_d_mdd_b60":"0.001349","median_d_sortino_b20":"-0.022628","median_d_sortino_b60":"-0.010589","n_paths":2000,"p_sortino_positive_b20":"0.365000","p_sortino_positive_b60":"0.433000","passes_b20":false,"passes_b60":false},"timestamp":1790475063,"trial_id":"accumulation-policy-2026-09-27-primary-flat-D"} +{"decision":"diagnostic_only","kind":"ablation","params":{"allowance_usd":"500","arm":"A","arm_label":"static DCA","block_lengths":[20,60],"deposit_usd":"500","fee_mode":"allowance","headline":false,"panel_days":506,"run":"secondary","seed":831,"taker_pct":"0.012","units":"per_trade_pnl is the arm's deposit-neutral account P&L per calendar month on the historical path, in USD; not trades","verdict":"baseline","weights":{"ADA":"0.06","BTC":"0.3","ETH":"0.2","LINK":"0.06","LTC":"0.06","PAXG":"0.2","SOL":"0.06","XLM":"0.06"},"window_end_ts":1790455996,"window_start_ts":1746662400},"per_bar_pnl":[],"per_trade_pnl":["0.189449","-19.475066","329.768156","166.003402","48.984957","-271.847923","-578.779642","-105.287202","-314.149037","-293.913705","-137.170717","203.432741","-72.012792","-1136.910068","373.874449","1303.093120","837.799294"],"prev_hash":"bff66b184ea67bc007c2bb7fb4086e3de8e19429cbdd3d0504760e99c082c60c","provenance":"a_priori","row_hash":"1f04210327af41e38b8962555e133ab687c0702ffc98e8553af2f6d2d93b594b","rule":"accumulation_policy","series_missing":false,"session":"accumulation-policy-2026-09-27","summary":{"headline":false,"hist_avg_cash_share":"0.006937","hist_buy_fees":"0.000000","hist_buy_notional":"8500.000000","hist_cagr_money_weighted":"0.028215","hist_d_mdd":"0.000000","hist_d_sortino":"0.000000","hist_deposited":"8500.000000","hist_irr":"0.003839","hist_max_drawdown":"0.521195","hist_realized_pnl":"0.000000","hist_sell_fees":"0.000000","hist_sell_notional":"0.000000","hist_sharpe":"0.167152","hist_sortino":"0.239696","hist_terminal_value":"8833.599417","hist_turnover":"1.000000","hist_twr_total":"-0.028628","n_paths":2000},"timestamp":1790475115,"trial_id":"accumulation-policy-2026-09-27-secondary-allowance-A"} +{"decision":"diagnostic_only","kind":"ablation","params":{"allowance_usd":"500","arm":"B","arm_label":"bounded value averaging, buy-only","block_lengths":[20,60],"deposit_usd":"500","fee_mode":"allowance","headline":false,"panel_days":506,"run":"secondary","seed":831,"taker_pct":"0.012","units":"per_trade_pnl is the arm's deposit-neutral account P&L per calendar month on the historical path, in USD; not trades","verdict":"not better than static DCA after fees","weights":{"ADA":"0.06","BTC":"0.3","ETH":"0.2","LINK":"0.06","LTC":"0.06","PAXG":"0.2","SOL":"0.06","XLM":"0.06"},"window_end_ts":1790455996,"window_start_ts":1746662400},"per_bar_pnl":[],"per_trade_pnl":["0.189449","-20.325714","336.210635","130.504279","59.907081","-206.869610","-550.439411","-113.408684","-369.924604","-359.926170","-83.615937","229.102751","-44.183752","-1191.965654","396.671734","1353.287386","930.364978"],"prev_hash":"1f04210327af41e38b8962555e133ab687c0702ffc98e8553af2f6d2d93b594b","provenance":"a_priori","row_hash":"c953510fc6ed040fda2cd86deed95843d95f3fc86bfb2d4e4db81c96229718dc","rule":"accumulation_policy","series_missing":false,"session":"accumulation-policy-2026-09-27","summary":{"d_sortino_p05_b20":"-0.132056","d_sortino_p05_b60":"-0.169901","d_sortino_p95_b20":"0.191332","d_sortino_p95_b60":"0.239267","headline":false,"hist_avg_cash_share":"0.036674","hist_buy_fees":"7.436753","hist_buy_notional":"8500.000000","hist_cagr_money_weighted":"0.041808","hist_d_mdd":"-0.012395","hist_d_sortino":"0.054386","hist_deposited":"8500.000000","hist_irr":"0.005644","hist_max_drawdown":"0.508800","hist_realized_pnl":"0.000000","hist_sell_fees":"0.000000","hist_sell_notional":"0.000000","hist_sharpe":"0.202823","hist_sortino":"0.294082","hist_terminal_value":"8995.578756","hist_turnover":"1.000000","hist_twr_total":"-0.005956","median_d_mdd_b20":"-0.006775","median_d_mdd_b60":"-0.006214","median_d_sortino_b20":"0.017030","median_d_sortino_b60":"0.018271","n_paths":2000,"p_sortino_positive_b20":"0.595000","p_sortino_positive_b60":"0.582000","passes_b20":false,"passes_b60":false},"timestamp":1790475115,"trial_id":"accumulation-policy-2026-09-27-secondary-allowance-B"} +{"decision":"diagnostic_only","kind":"ablation","params":{"allowance_usd":"500","arm":"C","arm_label":"deposit steered by shortfall","block_lengths":[20,60],"deposit_usd":"500","fee_mode":"allowance","headline":false,"panel_days":506,"run":"secondary","seed":831,"taker_pct":"0.012","units":"per_trade_pnl is the arm's deposit-neutral account P&L per calendar month on the historical path, in USD; not trades","verdict":"not better than static DCA after fees","weights":{"ADA":"0.06","BTC":"0.3","ETH":"0.2","LINK":"0.06","LTC":"0.06","PAXG":"0.2","SOL":"0.06","XLM":"0.06"},"window_end_ts":1790455996,"window_start_ts":1746662400},"per_bar_pnl":[],"per_trade_pnl":["0.189449","-20.433090","336.896978","148.073843","72.131740","-259.487676","-591.085301","-121.158393","-369.208652","-362.857416","-73.101244","229.294980","-29.642989","-1191.945792","413.001401","1351.601561","959.930436"],"prev_hash":"c953510fc6ed040fda2cd86deed95843d95f3fc86bfb2d4e4db81c96229718dc","provenance":"a_priori","row_hash":"4caf55eea515e8cf22a5707cadc66fa42d268a8d68bc2299235d6b7020e2a785","rule":"accumulation_policy","series_missing":false,"session":"accumulation-policy-2026-09-27","summary":{"d_sortino_p05_b20":"-0.141552","d_sortino_p05_b60":"-0.161126","d_sortino_p95_b20":"0.074354","d_sortino_p95_b60":"0.079320","headline":false,"hist_avg_cash_share":"0.006951","hist_buy_fees":"0.000000","hist_buy_notional":"8500.000000","hist_cagr_money_weighted":"0.041525","hist_d_mdd":"0.009561","hist_d_sortino":"0.035549","hist_deposited":"8500.000000","hist_irr":"0.005607","hist_max_drawdown":"0.530756","hist_realized_pnl":"0.000000","hist_sell_fees":"0.000000","hist_sell_notional":"0.000000","hist_sharpe":"0.190944","hist_sortino":"0.275246","hist_terminal_value":"8992.199834","hist_turnover":"1.000000","hist_twr_total":"-0.018663","median_d_mdd_b20":"0.002912","median_d_mdd_b60":"0.002668","median_d_sortino_b20":"-0.006232","median_d_sortino_b60":"0.000475","n_paths":2000,"p_sortino_positive_b20":"0.453000","p_sortino_positive_b60":"0.501500","passes_b20":false,"passes_b60":false},"timestamp":1790475115,"trial_id":"accumulation-policy-2026-09-27-secondary-allowance-C"} +{"decision":"diagnostic_only","kind":"ablation","params":{"allowance_usd":"500","arm":"D","arm_label":"DCA plus selective trimming","block_lengths":[20,60],"deposit_usd":"500","fee_mode":"allowance","headline":false,"panel_days":506,"run":"secondary","seed":831,"taker_pct":"0.012","units":"per_trade_pnl is the arm's deposit-neutral account P&L per calendar month on the historical path, in USD; not trades","verdict":"not better than static DCA after fees","weights":{"ADA":"0.06","BTC":"0.3","ETH":"0.2","LINK":"0.06","LTC":"0.06","PAXG":"0.2","SOL":"0.06","XLM":"0.06"},"window_end_ts":1790455996,"window_start_ts":1746662400},"per_bar_pnl":[],"per_trade_pnl":["0.189449","-19.475066","329.768156","144.250611","61.575401","-267.518203","-564.764487","-129.149235","-367.671190","-391.592564","-52.787918","236.702838","-50.698671","-1188.599934","419.693202","1373.877552","940.686016"],"prev_hash":"4caf55eea515e8cf22a5707cadc66fa42d268a8d68bc2299235d6b7020e2a785","provenance":"a_priori","row_hash":"678873c0e5739ac4b0f235ce4eb4a762c2fa457c581e49ad508df55380c79fbc","rule":"accumulation_policy","series_missing":false,"session":"accumulation-policy-2026-09-27","summary":{"d_sortino_p05_b20":"-0.173609","d_sortino_p05_b60":"-0.189463","d_sortino_p95_b20":"0.066122","d_sortino_p95_b60":"0.079121","headline":false,"hist_avg_cash_share":"0.006962","hist_buy_fees":"10.572445","hist_buy_notional":"9381.037096","hist_cagr_money_weighted":"0.040042","hist_d_mdd":"0.011026","hist_d_sortino":"0.017775","hist_deposited":"8500.000000","hist_irr":"0.005412","hist_max_drawdown":"0.532221","hist_realized_pnl":"141.279886","hist_sell_fees":"10.700855","hist_sell_notional":"891.737951","hist_sharpe":"0.178688","hist_sortino":"0.257471","hist_terminal_value":"8974.485956","hist_turnover":"1.208562","hist_twr_total":"-0.027212","median_d_mdd_b20":"0.004900","median_d_mdd_b60":"0.005628","median_d_sortino_b20":"-0.018448","median_d_sortino_b60":"-0.010358","n_paths":2000,"p_sortino_positive_b20":"0.367000","p_sortino_positive_b60":"0.423000","passes_b20":false,"passes_b60":false},"timestamp":1790475115,"trial_id":"accumulation-policy-2026-09-27-secondary-allowance-D"} +{"decision":"diagnostic_only","kind":"ablation","params":{"allowance_usd":"500","arm":"A","arm_label":"static DCA","block_lengths":[20,60],"deposit_usd":"500","fee_mode":"flat","headline":false,"panel_days":506,"run":"secondary","seed":831,"taker_pct":"0.012","units":"per_trade_pnl is the arm's deposit-neutral account P&L per calendar month on the historical path, in USD; not trades","verdict":"baseline","weights":{"ADA":"0.06","BTC":"0.3","ETH":"0.2","LINK":"0.06","LTC":"0.06","PAXG":"0.2","SOL":"0.06","XLM":"0.06"},"window_end_ts":1790455996,"window_start_ts":1746662400},"per_bar_pnl":[],"per_trade_pnl":["-5.812824","-25.241365","319.810938","158.011361","42.397137","-274.585748","-577.834287","-110.023755","-316.379249","-296.386740","-141.524668","194.991548","-77.148639","-1129.267147","363.387956","1281.456003","821.745703"],"prev_hash":"678873c0e5739ac4b0f235ce4eb4a762c2fa457c581e49ad508df55380c79fbc","provenance":"a_priori","row_hash":"fa5927ff39c38f8dff59d4b361a8f01319695a73019f0905f84bad9a50f5fadd","rule":"accumulation_policy","series_missing":false,"session":"accumulation-policy-2026-09-27","summary":{"headline":false,"hist_avg_cash_share":"0.006983","hist_buy_fees":"102.000000","hist_buy_notional":"8500.000000","hist_cagr_money_weighted":"0.019282","hist_d_mdd":"0.000000","hist_d_sortino":"0.000000","hist_deposited":"8500.000000","hist_irr":"0.002638","hist_max_drawdown":"0.526433","hist_realized_pnl":"0.000000","hist_sell_fees":"0.000000","hist_sell_notional":"0.000000","hist_sharpe":"0.095302","hist_sortino":"0.136240","hist_terminal_value":"8727.596224","hist_turnover":"1.000000","hist_twr_total":"-0.069064","n_paths":2000},"timestamp":1790475167,"trial_id":"accumulation-policy-2026-09-27-secondary-flat-A"} +{"decision":"diagnostic_only","kind":"ablation","params":{"allowance_usd":"500","arm":"B","arm_label":"bounded value averaging, buy-only","block_lengths":[20,60],"deposit_usd":"500","fee_mode":"flat","headline":false,"panel_days":506,"run":"secondary","seed":831,"taker_pct":"0.012","units":"per_trade_pnl is the arm's deposit-neutral account P&L per calendar month on the historical path, in USD; not trades","verdict":"not better than static DCA after fees","weights":{"ADA":"0.06","BTC":"0.3","ETH":"0.2","LINK":"0.06","LTC":"0.06","PAXG":"0.2","SOL":"0.06","XLM":"0.06"},"window_end_ts":1790455996,"window_start_ts":1746662400},"per_bar_pnl":[],"per_trade_pnl":["-5.812824","-26.173048","325.821219","126.238846","56.169394","-210.484701","-555.498844","-118.935502","-369.904245","-360.083363","-89.430718","220.134285","-50.841462","-1182.453841","385.412088","1330.236649","909.987632"],"prev_hash":"fa5927ff39c38f8dff59d4b361a8f01319695a73019f0905f84bad9a50f5fadd","provenance":"a_priori","row_hash":"a6ef33bf9707f5acb68a30244c45cefb60c11469b72893062a73926522059875","rule":"accumulation_policy","series_missing":false,"session":"accumulation-policy-2026-09-27","summary":{"d_sortino_p05_b20":"-0.122861","d_sortino_p05_b60":"-0.161852","d_sortino_p95_b20":"0.190866","d_sortino_p95_b60":"0.236486","headline":false,"hist_avg_cash_share":"0.034886","hist_buy_fees":"102.000000","hist_buy_notional":"8500.000000","hist_cagr_money_weighted":"0.032484","hist_d_mdd":"-0.011235","hist_d_sortino":"0.053318","hist_deposited":"8500.000000","hist_irr":"0.004409","hist_max_drawdown":"0.515197","hist_realized_pnl":"0.000000","hist_sell_fees":"0.000000","hist_sell_notional":"0.000000","hist_sharpe":"0.131206","hist_sortino":"0.189558","hist_terminal_value":"8884.381564","hist_turnover":"1.000000","hist_twr_total":"-0.046928","median_d_mdd_b20":"-0.006070","median_d_mdd_b60":"-0.005324","median_d_sortino_b20":"0.018740","median_d_sortino_b60":"0.019451","n_paths":2000,"p_sortino_positive_b20":"0.610500","p_sortino_positive_b60":"0.595000","passes_b20":false,"passes_b60":false},"timestamp":1790475167,"trial_id":"accumulation-policy-2026-09-27-secondary-flat-B"} +{"decision":"diagnostic_only","kind":"ablation","params":{"allowance_usd":"500","arm":"C","arm_label":"deposit steered by shortfall","block_lengths":[20,60],"deposit_usd":"500","fee_mode":"flat","headline":false,"panel_days":506,"run":"secondary","seed":831,"taker_pct":"0.012","units":"per_trade_pnl is the arm's deposit-neutral account P&L per calendar month on the historical path, in USD; not trades","verdict":"not better than static DCA after fees","weights":{"ADA":"0.06","BTC":"0.3","ETH":"0.2","LINK":"0.06","LTC":"0.06","PAXG":"0.2","SOL":"0.06","XLM":"0.06"},"window_end_ts":1790455996,"window_start_ts":1746662400},"per_bar_pnl":[],"per_trade_pnl":["-5.812824","-26.176535","326.740214","140.579199","65.147842","-262.341661","-589.625857","-125.587846","-370.545887","-364.280993","-78.465265","220.559896","-35.612351","-1183.695936","402.064733","1329.538636","942.444952"],"prev_hash":"a6ef33bf9707f5acb68a30244c45cefb60c11469b72893062a73926522059875","provenance":"a_priori","row_hash":"abd40cb04cd4ef1594a2ae6dbcb42af9d80b52457bbd987deeb257be969c6085","rule":"accumulation_policy","series_missing":false,"session":"accumulation-policy-2026-09-27","summary":{"d_sortino_p05_b20":"-0.140039","d_sortino_p05_b60":"-0.159990","d_sortino_p95_b20":"0.074437","d_sortino_p95_b60":"0.076158","headline":false,"hist_avg_cash_share":"0.006996","hist_buy_fees":"102.000000","hist_buy_notional":"8500.000000","hist_cagr_money_weighted":"0.032530","hist_d_mdd":"0.009384","hist_d_sortino":"0.038069","hist_deposited":"8500.000000","hist_irr":"0.004415","hist_max_drawdown":"0.535817","hist_realized_pnl":"0.000000","hist_sell_fees":"0.000000","hist_sell_notional":"0.000000","hist_sharpe":"0.121290","hist_sortino":"0.174309","hist_terminal_value":"8884.930318","hist_turnover":"1.000000","hist_twr_total":"-0.059355","median_d_mdd_b20":"0.002924","median_d_mdd_b60":"0.002817","median_d_sortino_b20":"-0.006198","median_d_sortino_b60":"0.000055","n_paths":2000,"p_sortino_positive_b20":"0.456500","p_sortino_positive_b60":"0.501500","passes_b20":false,"passes_b60":false},"timestamp":1790475167,"trial_id":"accumulation-policy-2026-09-27-secondary-flat-C"} +{"decision":"diagnostic_only","kind":"ablation","params":{"allowance_usd":"500","arm":"D","arm_label":"DCA plus selective trimming","block_lengths":[20,60],"deposit_usd":"500","fee_mode":"flat","headline":false,"panel_days":506,"run":"secondary","seed":831,"taker_pct":"0.012","units":"per_trade_pnl is the arm's deposit-neutral account P&L per calendar month on the historical path, in USD; not trades","verdict":"not better than static DCA after fees","weights":{"ADA":"0.06","BTC":"0.3","ETH":"0.2","LINK":"0.06","LTC":"0.06","PAXG":"0.2","SOL":"0.06","XLM":"0.06"},"window_end_ts":1790455996,"window_start_ts":1746662400},"per_bar_pnl":[],"per_trade_pnl":["-5.812824","-25.241365","319.810938","136.519603","54.836496","-270.307984","-563.987313","-133.599444","-369.259136","-392.893454","-58.154463","227.862404","-56.090287","-1180.336734","408.656883","1351.391021","923.397783"],"prev_hash":"abd40cb04cd4ef1594a2ae6dbcb42af9d80b52457bbd987deeb257be969c6085","provenance":"a_priori","row_hash":"698071609fc647baac03a9d983ee62f84c30379b322bd37a08e1b48d8e3fa24e","rule":"accumulation_policy","series_missing":false,"session":"accumulation-policy-2026-09-27","summary":{"d_sortino_p05_b20":"-0.170957","d_sortino_p05_b60":"-0.184919","d_sortino_p95_b20":"0.064950","d_sortino_p95_b60":"0.077218","headline":false,"hist_avg_cash_share":"0.007009","hist_buy_fees":"112.445576","hist_buy_notional":"9370.464651","hist_cagr_money_weighted":"0.031006","hist_d_mdd":"0.010960","hist_d_sortino":"0.020404","hist_deposited":"8500.000000","hist_irr":"0.004212","hist_max_drawdown":"0.537393","hist_realized_pnl":"131.195421","hist_sell_fees":"10.572445","hist_sell_notional":"881.037096","hist_sharpe":"0.109043","hist_sortino":"0.156644","hist_terminal_value":"8866.792124","hist_turnover":"1.206059","hist_twr_total":"-0.067851","median_d_mdd_b20":"0.004909","median_d_mdd_b60":"0.005592","median_d_sortino_b20":"-0.017106","median_d_sortino_b60":"-0.009961","n_paths":2000,"p_sortino_positive_b20":"0.369500","p_sortino_positive_b60":"0.428500","passes_b20":false,"passes_b60":false},"timestamp":1790475167,"trial_id":"accumulation-policy-2026-09-27-secondary-flat-D"}