Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/experiments/2026-09-03-equities-dca-benchmark.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{"buys": 250, "qty": "35.70395860", "deployed": "12500.00", "fees_paid": "0.00", "market_value": "17788.07", "gain_pct": "42.30", "product_id": "MSFT-USD", "arm": "measured", "bars": 1253, "fee_pct": "0", "slippage_bp": "0.97"}
{"buys": 250, "qty": "35.67820608", "deployed": "12500.00", "fees_paid": "0.00", "market_value": "17775.24", "gain_pct": "42.20", "product_id": "MSFT-USD", "arm": "keel_today", "bars": 1253, "fee_pct": "0", "slippage_bp": "8.19"}
{"buys": 250, "qty": "35.67820608", "deployed": "12650.00", "fees_paid": "150.00", "market_value": "17775.24", "gain_pct": "40.52", "product_id": "MSFT-USD", "arm": "crypto_regime", "bars": 1253, "fee_pct": "0.012", "slippage_bp": "8.19"}
{"buys": 250, "qty": "65.65725200", "deployed": "12500.00", "fees_paid": "0.00", "market_value": "21327.77", "gain_pct": "70.62", "product_id": "AAPL-USD", "arm": "measured", "bars": 1253, "fee_pct": "0", "slippage_bp": "1.42"}
{"buys": 250, "qty": "65.61663601", "deployed": "12500.00", "fees_paid": "0.00", "market_value": "21314.58", "gain_pct": "70.52", "product_id": "AAPL-USD", "arm": "keel_today", "bars": 1253, "fee_pct": "0", "slippage_bp": "7.61"}
{"buys": 250, "qty": "65.61663601", "deployed": "12650.00", "fees_paid": "150.00", "market_value": "21314.58", "gain_pct": "68.49", "product_id": "AAPL-USD", "arm": "crypto_regime", "bars": 1253, "fee_pct": "0.012", "slippage_bp": "7.61"}
{"buys": 250, "qty": "82.91063513", "deployed": "12500.00", "fees_paid": "0.00", "market_value": "27862.12", "gain_pct": "122.90", "product_id": "GOOGL-USD", "arm": "measured", "bars": 1253, "fee_pct": "0", "slippage_bp": "0.64"}
{"buys": 250, "qty": "82.83952785", "deployed": "12500.00", "fees_paid": "0.00", "market_value": "27838.22", "gain_pct": "122.71", "product_id": "GOOGL-USD", "arm": "keel_today", "bars": 1253, "fee_pct": "0", "slippage_bp": "9.23"}
{"buys": 250, "qty": "82.83952785", "deployed": "12650.00", "fees_paid": "150.00", "market_value": "27838.22", "gain_pct": "120.07", "product_id": "GOOGL-USD", "arm": "crypto_regime", "bars": 1253, "fee_pct": "0.012", "slippage_bp": "9.23"}
{"buys": 250, "qty": "305.61595578", "deployed": "12500.00", "fees_paid": "0.00", "market_value": "68668.85", "gain_pct": "449.35", "product_id": "NVDA-USD", "arm": "measured", "bars": 1253, "fee_pct": "0", "slippage_bp": "2.25"}
{"buys": 250, "qty": "305.48764946", "deployed": "12500.00", "fees_paid": "0.00", "market_value": "68640.02", "gain_pct": "449.12", "product_id": "NVDA-USD", "arm": "keel_today", "bars": 1253, "fee_pct": "0", "slippage_bp": "6.45"}
{"buys": 250, "qty": "305.48764946", "deployed": "12650.00", "fees_paid": "150.00", "market_value": "68640.02", "gain_pct": "442.61", "product_id": "NVDA-USD", "arm": "crypto_regime", "bars": 1253, "fee_pct": "0.012", "slippage_bp": "6.45"}
{"buys": 250, "qty": "18.78296641", "deployed": "12500.00", "fees_paid": "0.00", "market_value": "17537.84", "gain_pct": "40.30", "product_id": "COST-USD", "arm": "measured", "bars": 1253, "fee_pct": "0", "slippage_bp": "0.68"}
{"buys": 250, "qty": "18.75277028", "deployed": "12500.00", "fees_paid": "0.00", "market_value": "17509.65", "gain_pct": "40.08", "product_id": "COST-USD", "arm": "keel_today", "bars": 1253, "fee_pct": "0", "slippage_bp": "16.78"}
{"buys": 250, "qty": "18.75277028", "deployed": "12650.00", "fees_paid": "150.00", "market_value": "17509.65", "gain_pct": "38.42", "product_id": "COST-USD", "arm": "crypto_regime", "bars": 1253, "fee_pct": "0.012", "slippage_bp": "16.78"}
96 changes: 96 additions & 0 deletions docs/experiments/2026-09-03-equities-dca-benchmark.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# 2026-09-03: The equities DCA benchmark (#371, Alpaca Phase C)

**The benchmark to beat is the MEDIAN TICKER at +70.62%, not the pooled sleeve at +145.10%.**
The pre-declared refutation condition fired: NVDA returned +449.35% and is 44.8% of the
terminal sleeve, so the pooled figure is very largely one name. Excluding it, the remaining
four returned +69.03% — within 1.6 percentage points of the median, which is why the median is
the honest summary.

Any future equity strategy claim is read against that number over these bars. A rule that
returns less than simply buying $50 of each name every Thursday has not earned the complexity
it costs.

**This is a benchmark, not a recommendation and not advice.** It says what accumulation did on
five liquid US large caps over one five-year window that happened to contain a historic
mega-cap run. It is not evidence that accumulation works, and the window is short enough and
the universe narrow enough that the number is a yardstick, nothing more.

## What was measured, on what

- Data: `keel-equities.db`, 5 tickers × 1,253 ONE_DAY bars (Alpaca IEX feed).
- Rule: the **shipped** `keel.strategy.rules.dca.Dca` at constructor defaults —
`cadence_days=7, budget_usd=50, dip_bonus_pct=0, lookback_days=90`. Not a reimplementation,
and not tuned: a benchmark that has been swept is not a benchmark.
- Fills: **next bar's open plus one-way slippage**, keel's market-order convention since #258
and what `execution/executor.py` actually places. The deciding bar is always strictly before
the filling bar, so there is no lookahead.
- 250 buys per ticker (weekly cadence lands on Thursdays — `Dca.detect` tests epoch-day
divisibility, not bar count), $12,500 deployed per ticker.
- Driver: [`2026-09-03-equities-dca-benchmark.py`](2026-09-03-equities-dca-benchmark.py).

**Why not `sim/portfolio_sim`, which the crypto DCA measurement used.** That harness iterates
ONE_HOUR bars — `_window_bars` is `history_days * 24`, mirroring the live agent's hourly account
pass — and the equities profile is ONE_DAY only, by configuration, because Alpaca mints hourly
bars only inside a session and the daily turtle rules never read them. Handing it daily bars
labelled as hourly would have produced numbers that looked right and meant nothing. The
accumulation loop is therefore in the driver, and is the smallest thing that can be faithful.

## Result — the `measured` arm

| ticker | buys | deployed | terminal value | gain | share of sleeve |
| --- | --- | --- | --- | --- | --- |
| NVDA | 250 | $12,500.00 | $68,668.85 | **+449.35%** | 44.8% |
| GOOGL | 250 | $12,500.00 | $27,862.12 | +122.90% | 18.2% |
| AAPL | 250 | $12,500.00 | $21,327.77 | +70.62% | 13.9% |
| MSFT | 250 | $12,500.00 | $17,788.07 | +42.30% | 11.6% |
| COST | 250 | $12,500.00 | $17,537.84 | +40.30% | 11.4% |
| **median ticker** | | | | **+70.62%** | |
| pooled | 1,250 | $62,500.00 | $153,184.65 | +145.10% | |
| pooled, excluding NVDA | 1,000 | $50,000.00 | $84,515.80 | +69.03% | |

The spread between +40% and +449% across five liquid mega-caps over identical bars is itself
worth registering. Whatever a strategy on this universe is measuring, ticker selection is a
larger term than anything a rule is likely to add.

## The three cost arms — and why they barely differ

| arm | commission | slippage | pooled gain | vs measured |
| --- | --- | --- | --- | --- |
| `measured` | 0% (Alpaca's real rate) | measured half-spread, 0.64–2.25bp | +145.10% | — |
| `keel_today` | 0% | `slippage_for_quote_volume`, 6.45–16.78bp | +144.92% | −0.18pp |
| `crypto_regime` | 1.2%/leg | as above | +142.02% | −3.08pp |

**keel's 7.5× equities cost error is worth 0.18 percentage points over five years here.** That
is the pre-declared expectation, confirmed, and it is the most useful thing in this document.
A DCA sleeve pays its spread 250 times, one way, and never exits. The cost regime that
annihilated every crypto strategy result barely scratches it — even the fully counterfactual
`crypto_regime` arm, charging Coinbase's 1.2% per leg on equity bars, costs only 3.08pp.

This is the same finding the crypto work reached from the other side, and it is worth stating
plainly because it is easy to get backwards: **cost is levied on the SEARCH, not on the edge.**
An active rule pays the toll on every one of its round trips, and there is no edge in the
measured library large enough to survive ~241 of them at 2.5% each. Accumulation pays it 250
times too — but one way, on a position it never closes, so the toll is a rounding error against
a five-year hold. Cheap execution does not make a strategy good; expensive execution makes a
mediocre one impossible.

## What this does and does not license

It licenses the comparison: future equity results are read against **+70.62% median ticker**
over these bars, at the `measured` cost regime.

It licenses nothing about DCA. The sleeve's return is what these five names did in this window,
levered by nothing and skilled at nothing. A different five names, or the same five over
2000–2005, would produce a different number, and the concentration above shows how little it
takes to move it.

It says nothing about whether any keel rule can beat it, because no keel rule has yet been
measured on equities at all. That measurement is the next step and is deliberately not in this
document — running it here, against a benchmark computed in the same file, is precisely how a
result gets chosen after the fact.

One caveat inherited whole from [the cost-fidelity measurement](2026-09-02-equities-cost-fidelity.md):
the `keel_today` arm's slippage is computed from the cached IEX-only volume statistic, which is
structurally unable to report consolidated volume (#696). That arm is therefore "what keel
charges today", which is what it is labelled — not "what a correct volume-keyed model would
charge".
200 changes: 200 additions & 0 deletions docs/experiments/2026-09-03-equities-dca-benchmark.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
"""The DCA benchmark on equities -- issue #371 (Alpaca Phase C), second half.

**What a benchmark is for.** The crypto side has one: every strategy claim keel has ever made
is read against what simply accumulating would have done over the same bars, because a rule
that underperforms scheduled buying has not earned the complexity it costs. Equities had no
such baseline, so any future equity result would have had nothing to be compared against and
would inevitably have been compared against zero. This run supplies it.

It follows [the cost-fidelity measurement](2026-09-02-equities-cost-fidelity.md), in that
order, deliberately: the PRD (§6.3, O4) makes the cost document a precondition -- "no strategy
claim is believed before it" -- and a DCA sleeve priced wrong is exactly the kind of claim it
was protecting against.

DECLARED BEFORE THE RUN:

* **Metric: terminal sleeve mark-to-market against deployed cost basis.** NEVER a profit
factor. The DCA sleeve never closes (`sim/portfolio_sim` accumulates into `dca_positions`
and never exits them), so there are no round trips to factor, and DCA is exempt from the
promotion gate by design. This matches the crypto DCA measurement of 2026-08-17 exactly, so
the two are readable side by side.
* **Constructor defaults, no sweep**: `cadence_days=7, budget_usd=50, dip_bonus_pct=0,
lookback_days=90`. A benchmark that has been tuned is not a benchmark.
* **Three cost arms, same bars**, because the point of the preceding measurement was that the
price you assume decides what you conclude:
- `measured` -- the 2026-09-02 figure: 0% commission (Alpaca's real rate) and the
per-ticker measured half-spread as one-way slippage.
- `keel_today` -- what keel charges this profile right now: 0% commission and
`slippage_for_quote_volume` on the cached (IEX-only) volume, ~7.5x the measured cost.
- `crypto_regime` -- the SAME equity bars priced at Coinbase's cost structure (1.2%/leg,
per-product slippage). Counterfactual by construction and labelled as such: it isolates
how much of the crypto null is the venue rather than the series.
* **The sell-side regulatory pass-throughs do not appear, and that is not an omission.** SEC
Section 31 and FINRA TAF are levied on SELLS; a DCA sleeve only ever buys. Its entire cost
is spread.
* **Pre-declared expectation**: the three arms differ by very little in the `measured` vs
`keel_today` comparison (a low-turnover sleeve pays the spread ~260 times over five years,
not thousands), and by a lot under `crypto_regime`. If the first pair DOES differ materially,
the cost model's equities error is not confined to strategy evaluation and reaches the
passive baseline too.
* **What would refute the benchmark's usefulness**: a sleeve whose terminal value is dominated
by one ticker, making "beat DCA" a statement about NVDA rather than about equities. Reported
per ticker for exactly that reason.

**This is a benchmark, not a recommendation.** It says what accumulation did on five liquid
US large caps over one particular five-year window that contained a historic mega-cap run. It
is not evidence that accumulation works, is not advice to accumulate, and its window is short
enough and its universe narrow enough that the number is a yardstick, not a finding.

Re-run:
KEEL_EQUITIES_DB=~/keel/keel-equities.db \
python docs/experiments/2026-09-03-equities-dca-benchmark.py
"""

from __future__ import annotations

import json
import os
import sqlite3
import statistics
from decimal import Decimal
from pathlib import Path

from keel_core.types import Candle, Granularity

from keel.research.spread import corwin_schultz_spread
from keel.strategy.backtest import TAKER_FEE_PCT, slippage_for_quote_volume
from keel.strategy.rules.dca import Dca

DB = os.environ.get("KEEL_EQUITIES_DB") or str(Path.home() / "keel" / "keel-equities.db")
OUT_DIR = Path(os.environ.get("KEEL_EXPERIMENT_OUT") or Path(__file__).resolve().parent / "_out")
OUT_DIR.mkdir(parents=True, exist_ok=True)
JSONL_PATH = OUT_DIR / "equities_dca_benchmark.jsonl"

TICKERS = ["MSFT-USD", "AAPL-USD", "GOOGL-USD", "NVDA-USD", "COST-USD"]

#: WHY NOT `sim/portfolio_sim`, WHICH THE CRYPTO DCA MEASUREMENT USED. That harness iterates
#: ONE_HOUR bars (`_window_bars` is `history_days * 24`, mirroring the live agent's hourly
#: account pass), and the equities profile is ONE_DAY only -- by configuration, because Alpaca
#: mints hourly bars only inside a session and the daily turtle rules never read them. Handing
#: it daily bars labelled as hourly would produce numbers that look right and mean nothing.
#:
#: So the loop is here, and it is deliberately the smallest thing that can be faithful: walk
#: the daily series, ask the SHIPPED `Dca` rule (not a reimplementation of it) whether this bar
#: is a cadence hit, and fill any Setup at the NEXT bar's open plus one-way slippage -- keel's
#: own market-order convention since #258, and what `execution/executor.py` really places.
#: There is no exit path to model: DCA never sells, which is also why the sell-side regulatory
#: fees never enter.
_FILL_CONVENTION = "next bar open + one-way slippage, market order (#258)"


def load(product_id: str) -> list[Candle]:
con = sqlite3.connect(f"file:{DB}?mode=ro", uri=True)
try:
rows = con.execute(
"SELECT ts, o, h, l, c, v FROM candles WHERE product_id=? AND granularity='ONE_DAY'"
" ORDER BY ts",
(product_id,),
).fetchall()
finally:
con.close()
return [
Candle(ts=r[0], open=Decimal(r[1]), high=Decimal(r[2]), low=Decimal(r[3]),
close=Decimal(r[4]), volume=Decimal(r[5]))
for r in rows
]


def arm_costs(product_id: str, candles: list[Candle]) -> dict[str, tuple[Decimal, Decimal]]:
"""`(fee_pct, slippage_pct)` per arm, per ticker. Slippage is a ONE-WAY number both here and
in `slippage_for_quote_volume`, so the measured HALF-spread -- the cost of crossing from the
mid to the touch on one leg -- is the like-for-like quantity."""
est = corwin_schultz_spread(candles)
assert est is not None, product_id
adv = Decimal(str(statistics.median(float(c.volume * c.close) for c in candles)))
modelled = slippage_for_quote_volume(adv)
return {
"measured": (Decimal("0"), est.half_spread_pct),
"keel_today": (Decimal("0"), modelled),
"crypto_regime": (TAKER_FEE_PCT, modelled),
}


def run_arm(product_id: str, candles: list[Candle], fee: Decimal, slip: Decimal) -> dict:
"""Accumulate the shipped `Dca` rule over the series and mark the sleeve at the last close.

No cash constraint is imposed. The benchmark measures what the SCHEDULE accumulates; an
account that ran dry would be measuring the contribution rate instead, which is a different
question and not one a benchmark should silently answer.
"""
rule = Dca(product_id=product_id)
qty = Decimal("0")
deployed = Decimal("0")
fees_paid = Decimal("0")
buys = 0
# `i` is the DECIDING bar; the fill lands on `i + 1`, so the last bar can never be a
# decision -- there is no next open to fill against. No lookahead anywhere.
for i in range(len(candles) - 1):
setup = rule.detect({Granularity.ONE_DAY: candles[: i + 1]})
if setup is None:
continue
size_usd = setup.context["size_usd"]
fill_price = candles[i + 1].open * (Decimal("1") + slip)
if fill_price <= 0:
continue
fee_usd = size_usd * fee
qty += size_usd / fill_price
deployed += size_usd + fee_usd
fees_paid += fee_usd
buys += 1

last_close = candles[-1].close
market_value = qty * last_close
gain = ((market_value / deployed - 1) * 100) if deployed > 0 else Decimal("0")
return {
"buys": buys,
"qty": str(qty.quantize(Decimal("0.00000001"))),
"deployed": str(deployed.quantize(Decimal("0.01"))),
"fees_paid": str(fees_paid.quantize(Decimal("0.01"))),
"market_value": str(market_value.quantize(Decimal("0.01"))),
"gain_pct": str(gain.quantize(Decimal("0.01"))),
}


def main() -> None:
rows: list[dict] = []
for product_id in TICKERS:
candles = load(product_id)
costs = arm_costs(product_id, candles)
for arm, (fee, slip) in costs.items():
row = run_arm(product_id, candles, fee, slip)
row |= {
"product_id": product_id,
"arm": arm,
"bars": len(candles),
"fee_pct": str(fee),
"slippage_bp": str((slip * 10000).quantize(Decimal("0.01"))),
}
rows.append(row)

with JSONL_PATH.open("w") as fh:
for row in rows:
fh.write(json.dumps(row) + "\n")

for arm in ("measured", "keel_today", "crypto_regime"):
sub = [r for r in rows if r["arm"] == arm]
print(f"\n=== {arm} ===")
print(f"{'ticker':<12}{'buys':>6}{'slip bp':>9}{'deployed':>13}{'value':>13}{'gain %':>10}")
for r in sub:
print(f"{r['product_id']:<12}{r['buys']:>6}{r['slippage_bp']:>9}{r['deployed']:>13}"
f"{r['market_value']:>13}{r['gain_pct']:>10}")
dep = sum(Decimal(r["deployed"]) for r in sub)
val = sum(Decimal(r["market_value"]) for r in sub)
pooled = ((val / dep - 1) * 100) if dep > 0 else Decimal("0")
print(f"{'POOLED':<12}{'':>6}{'':>9}{dep.quantize(Decimal('0.01')):>13}"
f"{val.quantize(Decimal('0.01')):>13}{pooled.quantize(Decimal('0.01')):>10}")
print(f"\nwrote {JSONL_PATH}")


if __name__ == "__main__":
main()
Loading
Loading