diff --git a/docs/experiments/2026-09-03-equities-dca-benchmark.jsonl b/docs/experiments/2026-09-03-equities-dca-benchmark.jsonl new file mode 100644 index 0000000..6a67690 --- /dev/null +++ b/docs/experiments/2026-09-03-equities-dca-benchmark.jsonl @@ -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"} diff --git a/docs/experiments/2026-09-03-equities-dca-benchmark.md b/docs/experiments/2026-09-03-equities-dca-benchmark.md new file mode 100644 index 0000000..821604f --- /dev/null +++ b/docs/experiments/2026-09-03-equities-dca-benchmark.md @@ -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". diff --git a/docs/experiments/2026-09-03-equities-dca-benchmark.py b/docs/experiments/2026-09-03-equities-dca-benchmark.py new file mode 100644 index 0000000..319a560 --- /dev/null +++ b/docs/experiments/2026-09-03-equities-dca-benchmark.py @@ -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() diff --git a/tests/test_dca_benchmark_is_read_per_ticker.py b/tests/test_dca_benchmark_is_read_per_ticker.py new file mode 100644 index 0000000..a90d976 --- /dev/null +++ b/tests/test_dca_benchmark_is_read_per_ticker.py @@ -0,0 +1,83 @@ +"""A benchmark carried by one name is not a benchmark -- issue #371. + +The equities DCA run pre-declared its own refutation condition: "a sleeve whose terminal value +is dominated by one ticker, making 'beat DCA' a statement about NVDA rather than about +equities". It fired. NVDA is 44.8% of the terminal sleeve and its +449% drags the pooled figure +to +145.10% while the median ticker returned +70.62% and the four-name sleeve without it +returned +69.03%. + +So the number a future equity claim is measured against is the MEDIAN TICKER, not the pooled +sleeve, and the document has to say so where a reader will see it. Quoting +145% as "what DCA +did" would set a bar that one exceptional name built and then attribute clearing it -- or +failing to -- to a strategy. + +These pins are on the prose because that is where the damage would be done. The numbers +themselves live in the `.jsonl` beside the record and are reproducible from the driver. +""" + +from __future__ import annotations + +import json +import re +from decimal import Decimal +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent.parent +_RECORD = _ROOT / "docs/experiments/2026-09-03-equities-dca-benchmark.md" +_DATA = _ROOT / "docs/experiments/2026-09-03-equities-dca-benchmark.jsonl" + + +def _measured() -> list[dict]: + rows = [json.loads(line) for line in _DATA.read_text().splitlines() if line.strip()] + return [r for r in rows if r["arm"] == "measured"] + + +def test_the_record_and_its_data_both_exist() -> None: + assert _RECORD.is_file(), "the benchmark record is missing" + assert _measured(), "the benchmark's measured arm produced no rows" + + +def test_one_ticker_really_does_dominate_the_sleeve() -> None: + """The premise of every other pin here. If a re-run ever makes the sleeve balanced, these + tests should fail loudly rather than keep enforcing a caveat that no longer applies.""" + rows = _measured() + values = {r["product_id"]: Decimal(r["market_value"]) for r in rows} + total = sum(values.values()) + top = max(values.values()) + assert top / total > Decimal("0.4"), f"no ticker dominates any more: {values}" + + +def test_the_headline_benchmark_is_the_median_ticker_not_the_pooled_sleeve() -> None: + """The pooled figure may appear -- suppressing it would be its own dishonesty -- but the + sentence naming what a strategy must beat has to name the median.""" + text = _RECORD.read_text(encoding="utf-8") + headline = re.search(r"(?im)^.*benchmark to beat.*$", text) + assert headline, "the record never says what the benchmark to beat is" + assert "median" in headline.group(0).lower(), headline.group(0) + + +def test_the_concentration_caveat_is_stated_with_its_number() -> None: + """In the OPENING, not just in the table. A reader who takes the headline and stops must + still have been told that one name carries it -- the table row alone lets the prose be + gutted while this pin stays green, which is exactly what a mutation run showed.""" + text = _RECORD.read_text(encoding="utf-8") + opening = text[: text.index("## What was measured")] + assert "44.8" in opening, "the concentration is not quantified before the first heading" + assert re.search(r"(?i)without NVDA|excluding NVDA|ex-NVDA", text), ( + "the record does not report the sleeve with the dominant name removed" + ) + + +def test_every_cost_arm_is_named_with_its_result() -> None: + """Three arms were declared before the run; a record that quietly drops one is a record + that chose its comparison after seeing it.""" + text = _RECORD.read_text(encoding="utf-8") + for arm in ("measured", "keel_today", "crypto_regime"): + assert f"`{arm}`" in text, f"cost arm {arm} is not named in the record" + + +def test_the_record_refuses_to_read_as_a_recommendation() -> None: + """DCA outperforming here is a property of a five-year window containing a historic + mega-cap run, not evidence that accumulation works.""" + text = _RECORD.read_text(encoding="utf-8").lower() + assert "not a recommendation" in text or "not advice" in text