diff --git a/docs/experiments/2026-09-01-triple-barrier-first-measurement.md b/docs/experiments/2026-09-01-triple-barrier-first-measurement.md new file mode 100644 index 0000000..f964138 --- /dev/null +++ b/docs/experiments/2026-09-01-triple-barrier-first-measurement.md @@ -0,0 +1,105 @@ +# Triple-barrier exits: a real gross improvement, worth exactly nothing after friction + +**Date:** 2026-09-01 · **Issue:** [#342](https://github.com/CodeGateSoftware/keel/issues/342) · +**Rule:** `triple_barrier` · **Control:** `cusum_event` +([measured the same day](2026-09-01-cusum-event-first-measurement.md)) · +**Driver:** `2026-09-01-triple-barrier-first-measurement.py` · **192 trials disclosed** + +## Why this is an A/B and not another level reading + +`cusum_event` and `triple_barrier` **share an entry** — the same CUSUM filter at the same +threshold, on the same universe over the same window. What differs is the exit: ATR barriers with +a signal exit, against friction-sized barriers with a vertical time stop. Holding the entry fixed +makes the exit the only thing that changed, so the difference is attributable. + +**Declared before the run:** the primary metric is the **delta in profit factor against the +control at the taker rate** — not the level. The level was already known to be a null, and asking +"does it clear 1.0" invites reading a 0.4 as encouraging. The question is how much a better exit +moves a rule whose entry has no gross edge. + +## The answer, in two numbers + +| | zero fee | 1.2% taker | +| :-- | --: | --: | +| control (`cusum_event`) median PF | 0.925 | 0.343 | +| `triple_barrier` median PF | **1.001** | **0.338** | +| median delta | **+0.033** | **−0.004** | +| assets improved | **17 of 24** | 11 of 24 (a coin flip) | + +**The exit genuinely works.** At zero fee it lifts the median profit factor across break-even — +from 0.925 to 1.001 — and improves 17 of 24 assets. That is a real, measurable gross improvement +from a better exit, and it is the first thing in this series of documents to move a number in the +right direction. + +**And it is worth nothing.** At the rate the account actually pays, the median delta is −0.004 +and the sign of the improvement is a coin flip. The gross gain the exit produces is smaller than +the friction it must be harvested through. + +Zero of 24 clear PF 1.0 at the taker rate, and zero at the 0.6% maker rate either. n ≥ 100 on 20 +of 24, median 431 — the vertical barrier closes positions the signal exit would have let run, so +trade count falls slightly against the control (median 553) while staying well clear of the floor. + +## The vertical barrier alone + +Arm B, `max_holding_bars` at the taker rate — the one leg no other rule has, and the only knob +the source grid-searched: + +| bars | median n | PF median | PF max | above 1.0 | +| --: | --: | --: | --: | --: | +| 6 | 520 | 0.161 | 0.276 | 0 | +| 12 | 486 | 0.243 | 0.412 | 0 | +| 24 | 431 | 0.338 | 0.527 | 0 | +| 48 | 374 | 0.392 | 1.270 | 1 | +| 72 | 338 | 0.464 | 1.063 | 1 | + +Monotone: holding longer is better, and the source's own 24-bar barrier is mid-range rather than +optimal. The direction agrees with the paper's "wide barriers beat next-bar labeling" finding — +what disagrees is the magnitude, because a 1.2% taker rate is twelve times the 0.1% that paper +priced. + +The two cells above 1.0 are both **TON-USD, at n=17 and n=16** — a sixth of the admission floor. +They are maxima of five draws on the thinnest asset in the universe and are not evidence of +anything. **The intersection of n ≥ 100 and PF > 1.0 is empty across all 192 trials.** + +## What this settles about the source + +The paper's method is CUSUM sampling plus wide triple barriers. Both halves are now implemented +on keel's cost structure and measured on the same universe: + +* the **entry** half has essentially no gross edge (median 0.925 at zero cost); +* the **exit** half is a real improvement (+0.033 gross, 17 of 24) that friction consumes entirely. + +The paper is not wrong about its own venue. At 0.1% per leg its round trip is 0.2% and a +0.033 +gross improvement is worth keeping. At 2.5% it is not. **This is the clearest measurement in the +series of the difference between a result and a result at a price.** + +## Honesty + +**Selection bias.** Arm A is one pre-declared configuration and carries none. Arm B's per-asset +best is a maximum of five draws — the best cell overall is TON-USD at 48 bars, PF 1.270, n=17, +and quoting it as anything but an artefact would be exactly the error this section exists to +prevent. + +**Validation.** Screening result only: no walk-forward, no out-of-sample split, no CSCV/PBO +(`series_missing`). Same cached candles and ~5-year window as every other document here. +`slippage_pct` held at 0.0005 in every backtest cell, so the zero column is zero *fee*, not zero +cost. + +**A unit trap worth recording.** `median_daily_quote_volume` returns a **per-bar** median despite +its name. `slippage_for_quote_volume` is anchored on $500M *daily*, so feeding it the hourly +figure unscaled reports every asset as maximally thin, clamps the universe to the 183.8bp cap and +makes every barrier four times too wide — silently, with no error. `per_product_round_trip` scales +by bars-per-day and a test asserts the consequence. + +**Changed nothing.** A document, a driver, a ledger row. No rule row added, nothing promoted, no +config or allowlist touched. `triple_barrier` is registered and untraded. + +## Recommended next + +1. **Not a barrier sweep.** The horizontal barriers are already friction-sized and the vertical + one is monotone across a twelvefold range for 0.30 of profit factor — all of it below 1.0. +2. **The source is now fully tested and fully answered.** Both halves are measured; neither + survives this venue's cost. There is no third half. +3. **The null grows again: 0 of 90 → 0 of 114 → 0 of 138.** The most useful thing this pair of + documents produced is not a rule but a number: **a better exit bought +0.033 gross and −0.004 + net.** That is what "cost is the binding constraint" means, stated as a measurement. diff --git a/docs/experiments/2026-09-01-triple-barrier-first-measurement.py b/docs/experiments/2026-09-01-triple-barrier-first-measurement.py new file mode 100644 index 0000000..97ab17b --- /dev/null +++ b/docs/experiments/2026-09-01-triple-barrier-first-measurement.py @@ -0,0 +1,138 @@ +"""First measurement of `triple_barrier` (#342). Pre-declared, then run. + +**This is an A/B against a control, which is what makes it worth running at all.** `cusum_event` +(measured 2026-09-01, same universe, same window) and `triple_barrier` share an entry: the same +CUSUM filter at the same threshold. What differs is the EXIT -- ATR barriers with a Donchian-free +signal exit, against friction-sized barriers with a vertical time stop. Holding the entry fixed +makes the exit the only thing that changed, so the difference is attributable. + +DECLARED BEFORE THE RUN: + +* **Primary metric: the DELTA in profit factor against `cusum_event` at the taker rate.** Not + the level -- the level is already known to be a null, and asking "does it clear 1.0" invites + reading a 0.4 as encouraging. The question is how much a better exit moves a rule whose entry + has no gross edge, and the honest expected answer is "a little, and not enough". +* **Secondary: `n_trades`** (a vertical barrier closes positions that would otherwise run, so + trade count should RISE) and profit factor at fee 0 / 0.006 / 0.012. +* **Arm A is ONE configuration** -- the shipped default (entry mult 2, target 4, stop 2, 24-bar + vertical). No argmax. +* **Arm B is the vertical barrier alone**, `max_holding_bars` in {6, 12, 24, 48, 72} at the + taker rate: the one leg no other rule has, and the only knob the source grid-searched. + Disclosed as a sweep, 5 x 24 = 120 trials; its per-asset best is a maximum of five draws. + +Trials disclosed: 24 x 3 = 72 (arm A) + 120 (arm B) = 192. + +Re-run: + KEEL_EXPERIMENT_DB=~/keel/keel.db \ + python docs/experiments/2026-09-01-triple-barrier-first-measurement.py +""" + +import json +import os +import time +from concurrent.futures import ProcessPoolExecutor, as_completed +from decimal import Decimal +from pathlib import Path + +DB = os.environ.get("KEEL_EXPERIMENT_DB") or str(Path.home() / "keel" / "keel.db") +OUT_DIR = os.environ.get("KEEL_EXPERIMENT_OUT") or str(Path(__file__).resolve().parent / "_out") +Path(OUT_DIR).mkdir(parents=True, exist_ok=True) +JSONL_PATH = f"{OUT_DIR}/triple_barrier_first.jsonl" + +UNIVERSE = [ + "BTC-USD", "ETH-USD", "ADA-USD", "LINK-USD", "LTC-USD", "SOL-USD", + "XLM-USD", "PAXG-USDT", "BCH-USD", "AAVE-USD", "DOGE-USD", "DOT-USD", + "UNI-USD", "ZEC-USD", "ALGO-USD", "FET-USD", "CRV-USD", "ICP-USD", + "AVAX-USD", "NEAR-USD", "XRP-USD", "PAXG-USD", "WLD-USD", "TON-USD", +] + +FEES = ["0", "0.006", "0.012"] +SLIPPAGE = Decimal("0.0005") +TAKER = "0.012" +HOLDINGS = ["6", "12", "24", "48", "72"] + + +def build_jobs(): + return [("A", asset, "24") for asset in UNIVERSE] + [ + ("B", asset, bars) for asset in UNIVERSE for bars in HOLDINGS + ] + + +def run_job(job): + arm, asset, bars = job + from keel_core.types import Granularity + + from keel.data.db import connect + from keel.data.repository import Repository + from keel.strategy import backtest as bt + from keel.strategy.rules.triple_barrier import TripleBarrier + + fees = FEES if arm == "A" else [TAKER] + rows = [] + try: + repo = Repository(connect(DB)) + candles = repo.get_candles(asset, Granularity.ONE_HOUR) + except Exception as exc: + return [{ + "arm": arm, "product": asset, "bars": bars, + "error": f"{type(exc).__name__}: {exc}", + }] + if not candles: + return [{"arm": arm, "product": asset, "bars": bars, "error": "no hourly candles"}] + + for fee in fees: + try: + rule = TripleBarrier(product_id=asset, max_holding_bars=int(bars)) + result = bt.backtest(rule, candles, fee_pct=Decimal(fee), slippage_pct=SLIPPAGE) + rows.append({ + "arm": arm, + "product": asset, + "bars": bars, + "fee": fee, + "candles": len(candles), + "n_trades": int(result.n_trades), + "win_rate": float(result.win_rate), + "profit_factor": float(result.profit_factor), + "expectancy": float(result.expectancy), + "max_drawdown": float(result.max_drawdown), + }) + except Exception as exc: + rows.append({ + "arm": arm, "product": asset, "bars": bars, "fee": fee, + "error": f"{type(exc).__name__}: {exc}", + }) + return rows + + +def done_combos(): + if not os.path.exists(JSONL_PATH): + return set() + done = set() + for line in open(JSONL_PATH): + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + except json.JSONDecodeError: + continue # torn final line from a kill mid-write + done.add((row["arm"], row["product"], row["bars"])) + return done + + +def main(): + jobs = [job for job in build_jobs() if job not in done_combos()] + print(f"{len(jobs)} combos to run -> {JSONL_PATH}", flush=True) + started = time.time() + with open(JSONL_PATH, "a") as sink, ProcessPoolExecutor() as pool: + futures = {pool.submit(run_job, job): job for job in jobs} + for index, future in enumerate(as_completed(futures), start=1): + for row in future.result(): + sink.write(json.dumps(row) + "\n") + sink.flush() + print(f" {index}/{len(jobs)} {futures[future]}", flush=True) + print(f"done in {time.time() - started:.0f}s", flush=True) + + +if __name__ == "__main__": + main() diff --git a/docs/experiments/trials-ledger.jsonl b/docs/experiments/trials-ledger.jsonl index ab31b92..f22757c 100644 --- a/docs/experiments/trials-ledger.jsonl +++ b/docs/experiments/trials-ledger.jsonl @@ -89,3 +89,4 @@ {"decision":"diagnostic_only","kind":"sweep_node","params":{"atr_mult":2.4959403868013204,"fixed_rr":1,"overbought":80.7242988859836,"oversold":15.722910550982721,"product_id":"ETH-USD","rsi_period":18},"per_bar_pnl":[],"per_trade_pnl":[],"prev_hash":"35f1e28f7523d0ac5c56b9d137bac59419693647e36c2643a3f87aec7c702d00","provenance":"fitted","row_hash":"787c3934700c34645fc77e137f9df8927031cc07f146ec82f476322203e0f24a","rule":"rsi_meanrev","series_missing":true,"session":"optuna-parameter-study-2026-08-22","summary":{"bars":17520,"fee_pct":"0.012","gate_passed":0,"held_out_expectancy":"0","n_trials":60,"pbo":"0.5714285714285714285714285714","pbo_available":1,"seed":476,"slippage_pct":"0.0005","train_expectancy":"220.9799098276924967971328305"},"timestamp":1787368446,"trial_id":"476-optuna-rsi_meanrev"} {"decision":"diagnostic_only","kind":"sweep_node","params":{"buffer_ticks":0.049704400803443,"ema_periods":[11,17,40],"product_id":"SOL-USD"},"per_bar_pnl":[],"per_trade_pnl":["-4.0173355540198047831580","-3.5027104940198047831580","-2.9839631340198047831580","-2.5405856740198047831580","-2.6313359140198047831580","-2.4288351940198047831580","-2.7519619340198047831580","-2.5525855540198047831580","-2.7715863940198047831580","-2.3049602540198047831580","-2.3478355540198047831580","-1.3243723579405856505260","-2.0557094140198047831580","-2.0342092940198047831580","-2.1982098940198047831580"],"prev_hash":"787c3934700c34645fc77e137f9df8927031cc07f146ec82f476322203e0f24a","provenance":"fitted","row_hash":"2a8c802b036024d6913519e2d842d7f036e0572e34cb63c3fb374bc1f243296c","rule":"pullback_continuation","series_missing":false,"session":"optuna-parameter-study-2026-08-22","summary":{"bars":17520,"fee_pct":"0.012","gate_passed":0,"held_out_expectancy":"-2.563079774281190174315866667","n_trials":60,"pbo":"0.8428571428571428571428571429","pbo_available":1,"seed":476,"slippage_pct":"0.0005","train_expectancy":"-4.7947464365621181060780"},"timestamp":1787368446,"trial_id":"476-optuna-pullback_continuation"} {"decision":"rejected","kind":"ablation","params":{"arms":"A: shipped default (threshold_friction_mult=2 -> 5.0% threshold, lookback=168, atr_period=20, atr_stop_mult=2, target_rr=3) x 24 assets x 3 fees = 72 trials. B: threshold_friction_mult in {1,2,3,4} at the taker fee x 24 assets = 96 trials. 168 trials disclosed.","changed_nothing":"A document and one driver script. No rule row was added, nothing was promoted, no config was touched, no allowlist changed. cusum_event remains registered and untraded.","declared_before_the_run":"PRIMARY METRIC n_trades (feasibility -- an event filter trades less by construction, and the rho=-0.77 edge-vs-sample bind says a rule that fires rarely cannot be admitted whatever its profit factor). Secondary: profit factor at fee 0/0.006/0.012. Arm A is ONE configuration, the shipped default, so nothing in it is an argmax. Arm B sweeps the rule's own headline knob and its per-asset best is a maximum of four draws.","document":"docs/experiments/2026-09-01-cusum-event-first-measurement.md","feasibility_answer":"YES, and it CONTRADICTS the pre-registered worry. The concern filed with #341 was that gating cuts n on rules already below the admission floor. It does not: at the shipped default n>=100 on 21 of 24 assets, median 553, max 884. Event sampling on hourly bars fires often because a 5% cumulative move in an hourly crypto series is a common occurrence, not a rare one. The feasibility objection to this rule is answered and should not be repeated.","issue":"#341","profitability_answer":"NO, and not marginally. At the 1.2% taker rate ZERO of 24 assets clear PF 1.0 -- median 0.343, best 0.479 (AVAX-USD, n=601). At the 0.6% maker rate this account cannot reach, still zero of 24, median 0.553. The intersection of n>=100 and PF>1.0 is EMPTY across both arms, all 168 trials.","recommended_next":"(a) NOT further cusum_event tuning: it is negative at zero cost on 16 of 24 assets and its own headline knob moves median PF by 0.15 across a fourfold range. (b) #342's triple-barrier exits are the source's other half and are now the only untested part of that paper's claim -- but the honest prior tightened here, since the entry half has no gross edge for a better exit to harvest. (c) The result stands as the fifth family in the null: 0 of 90 becomes 0 of 114.","script":"docs/experiments/2026-09-01-cusum-event-first-measurement.py","selection_bias":"Arm A is ONE pre-declared configuration and carries none. Arm B's per-asset best is a maximum of FOUR draws and must never be quoted as an edge estimate; its best cell overall is CRV-USD at mult=4, PF 0.593, n=322 -- still 0.4 short of 1.0, so the bias does not change the verdict in either direction.","the_diagnostic":"AT ZERO FEE ONLY 8 OF 24 CLEAR 1.0, median 0.925, max 1.238. That is the finding. turtle_breakout at zero fee was profitable on 4 of 4 with a max of 2.713 -- a real gross edge that cost destroyed. cusum_event has ESSENTIALLY NO GROSS EDGE for cost to destroy: its median is below 1.0 at zero cost. Same symptom as turtle at 1.2%, the rsi_meanrev disease. Cheaper execution cannot rescue it, because zero fee bounds from above everything an execution fix could ever buy and zero fee is already a median loss.","the_knob_works_and_does_not_rescue_it":"threshold_friction_mult behaves exactly as designed and converges to nothing. 1 -> 4 raises median PF monotonically 0.226/0.343/0.361/0.379 while cutting median n 1252/553/330/224. Trading less does lose less per toll -- the mechanism is real -- but the trend is toward a ceiling below 1.0, and n falls toward the admission floor as it climbs. There is no multiple at which both hold.","the_sources_own_setting_is_the_worst_cell":"mult=1 is the paper's 2.0-2.5% threshold and is exactly one round trip here. It is the worst arm-B cell on every axis: median PF 0.226, the lowest of the four, with median n 1252 -- the most trading at the least edge. This is the friction-scaling argument of #341 measured rather than asserted.","universe":"the same 24 assets as the restated intersection, ONE_HOUR, ~5y cached","validation":"Screening result only. No walk-forward, no out-of-sample split, no CSCV/PBO and no deflated Sharpe: backtest emits aggregates and no per-trade series (series_missing). Same cached candles and same ~5-year window as every other document here. slippage_pct held at 0.0005 in every cell, so 'zero fee' is zero FEE and not zero cost."},"per_bar_pnl":[],"per_trade_pnl":[],"prev_hash":"2a8c802b036024d6913519e2d842d7f036e0572e34cb63c3fb374bc1f243296c","provenance":"a_priori","row_hash":"ef473b236321c2f081054fb2012860a0e34f5f195a02518eb807a88978c8b6af","rule":"cusum_event","series_missing":true,"session":"cusum-event-first-measurement-2026-09-01","summary":{"arm_a_assets":24,"arm_a_n_above_floor":21,"arm_a_n_median":553,"arm_a_pf_above_one_taker":0,"arm_a_pf_above_one_zero_fee":8,"arm_a_pf_max_taker":"0.479","arm_a_pf_median_taker":"0.343","arm_a_pf_median_zero_fee":"0.925","fee_pct":"0.012","gate_passed":0,"intersection_n100_and_pf1":0,"min_trades_floor":100,"n_trials":168,"pbo_available":0,"slippage_pct":"0.0005"},"timestamp":1788305412,"trial_id":"cusum-event-first-measurement-2026-09-01"} +{"decision":"rejected","kind":"ablation","params":{"changed_nothing":"A document, a driver and a ledger row. No rule row added, nothing promoted, no config or allowlist touched. triple_barrier is registered and untraded.","control":"cusum_event, measured the same day on the SAME universe and window (cusum-event-first-measurement-2026-09-01). Both rules share an entry -- the same CUSUM filter at the same threshold -- so the exit is the only thing that changed and the difference is attributable. This is an A/B, not another level reading.","declared_before_the_run":"PRIMARY METRIC: the DELTA in profit factor against the control at the taker rate, NOT the level. The level was already a known null and asking 'does it clear 1.0' invites reading a 0.4 as encouraging. The question is how much a better exit moves a rule whose entry has no gross edge. Arm A is ONE configuration (no argmax); arm B sweeps max_holding_bars over {6,12,24,48,72} and its best is a max of 5 draws.","document":"docs/experiments/2026-09-01-triple-barrier-first-measurement.md","issue":"#342","levels":"Zero of 24 clear PF 1.0 at the taker rate (median 0.338, max 0.527) and zero at the 0.6% maker rate. n>=100 on 20 of 24, median 431 -- the vertical barrier closes positions the signal exit let run, so trade count falls against the control's 553 while staying clear of the floor.","recommended_next":"(a) NOT a barrier sweep: the horizontals are already friction-sized and the vertical is monotone across a twelvefold range for 0.30 of profit factor, all of it below 1.0. (b) The source is fully tested and fully answered -- there is no third half. (c) The null grows: 0 of 90 -> 0 of 114 -> 0 of 138.","script":"docs/experiments/2026-09-01-triple-barrier-first-measurement.py","selection_bias":"Arm A is one pre-declared configuration and carries none. Arm B's per-asset best is a maximum of five draws; its best cell overall is the n=17 TON-USD artefact above, and quoting it as anything else would be the error this field exists for.","the_answer":"THE EXIT GENUINELY WORKS AND IS WORTH NOTHING. At ZERO fee it lifts the median profit factor ACROSS BREAK-EVEN, 0.925 -> 1.001, improving 17 of 24 assets: a real, measurable gross improvement from a better exit, and the first number in this series to move the right way. At the 1.2% taker rate the median delta is -0.004 and the sign is a coin flip (11 of 24). The gross gain is smaller than the friction it has to be harvested through.","the_two_cells_above_one":"TON-USD at 48 bars (PF 1.270, n=17) and 72 bars (PF 1.063, n=16) -- a sixth of the admission floor, maxima of five draws on the thinnest asset in the universe. Not evidence of anything. The intersection of n>=100 and PF>1.0 is EMPTY across all 192 trials.","unit_trap_recorded":"`median_daily_quote_volume` returns a PER-BAR median despite its name, and `slippage_for_quote_volume` is anchored on $500M DAILY -- so feeding it the hourly figure unscaled reports every asset as maximally thin, clamps the universe to the 183.8bp cap and makes every barrier four times too wide, silently and with no error. `per_product_round_trip` scales by bars-per-day; a test asserts the consequence rather than the call.","validation":"Screening result only. No walk-forward, no out-of-sample split, no CSCV/PBO and no deflated Sharpe (series_missing). Same cached candles and ~5-year window as every other document here. slippage_pct held at 0.0005 in every cell, so 'zero fee' is zero FEE and not zero cost.","vertical_barrier_axis":"Monotone: 6/12/24/48/72 bars give median PF 0.161/0.243/0.338/0.392/0.464 with median n 520/486/431/374/338. Holding longer is better and the source's own 24-bar barrier is mid-range, not optimal -- the direction agrees with the paper's 'wide barriers beat next-bar labeling'. The magnitude does not, because 1.2% per leg is twelve times the 0.1% that paper priced.","what_this_settles_about_the_source":"Both halves of Gradzki et al. are now implemented on keel's cost structure and measured on one universe: the ENTRY half has essentially no gross edge (median 0.925 at zero cost) and the EXIT half is a real improvement (+0.033 gross, 17 of 24) that friction consumes entirely. The paper is not wrong about its own venue -- at 0.1% per leg a +0.033 gross improvement is worth keeping; at 2.5% it is not. This is the clearest measurement in the series of the difference between a result and a result AT A PRICE."},"per_bar_pnl":[],"per_trade_pnl":[],"prev_hash":"ef473b236321c2f081054fb2012860a0e34f5f195a02518eb807a88978c8b6af","provenance":"a_priori","row_hash":"c8ad6ec7e0e5eb524bd89038b841301ce7c6214707cec5609537658f2a952993","rule":"triple_barrier","series_missing":true,"session":"triple-barrier-first-measurement-2026-09-01","summary":{"arm_a_assets":24,"arm_a_n_above_floor":20,"arm_a_n_median":431,"arm_a_pf_above_one_taker":0,"arm_a_pf_above_one_zero_fee":13,"arm_a_pf_max_taker":"0.527","arm_a_pf_median_taker":"0.338","arm_a_pf_median_zero_fee":"1.001","delta_improved_taker":11,"delta_improved_zero_fee":17,"delta_vs_control_median_taker":"-0.004","delta_vs_control_median_zero_fee":"0.033","fee_pct":"0.012","gate_passed":0,"intersection_n100_and_pf1":0,"min_trades_floor":100,"n_trials":192,"pbo_available":0,"slippage_pct":"0.0005"},"timestamp":1788306964,"trial_id":"triple-barrier-first-measurement-2026-09-01"} diff --git a/keel/agent.py b/keel/agent.py index 7d9235f..1aaf6f2 100644 --- a/keel/agent.py +++ b/keel/agent.py @@ -94,6 +94,7 @@ from keel.strategy.rules.dca import Dca from keel.strategy.rules.pullback_continuation import PullbackContinuation from keel.strategy.rules.rsi_meanrev import RsiMeanReversion +from keel.strategy.rules.triple_barrier import TripleBarrier from keel.strategy.rules.turtle_breakout import TurtleBreakout from keel.types import Granularity, Side @@ -136,6 +137,12 @@ # does here. Registered with the honest prior on the record: at 0 of 90 measured, it is # expected to join the null, and it ships to BE measured rather than to be believed. "cusum_event": CusumEvent, + # #342. The other half of the same source: `cusum_event`'s entry with triple-barrier exits + # and a VERTICAL time stop no other kind has. Registered with a prior that #341's own + # measurement tightened rather than inherited -- the entry half loses at a median before + # any fee is charged, so a better exit has no gross edge to harvest. It ships to measure + # how much a better exit moves that, not to rescue it. + "triple_barrier": TripleBarrier, } # The per-kind coercion tables that used to live here -- `_DECIMAL_PARAMS`, `_GRANULARITY_PARAMS` diff --git a/keel/strategy/rules/triple_barrier.py b/keel/strategy/rules/triple_barrier.py new file mode 100644 index 0000000..98eb167 --- /dev/null +++ b/keel/strategy/rules/triple_barrier.py @@ -0,0 +1,221 @@ +"""Triple-barrier exits with a vertical time stop, barriers sized to friction (#342). + +Research brief B-2, and the other half of the source `cusum_event` implements the entry half of. +Three barriers close a position: an upper (take-profit), a lower (stop), and a **vertical** one +-- sell at the close after N bars, whatever price has done. No shipped rule has the third. + +Source: Grądzki et al., *Financial Innovation*, 2025-12-15 (24-period vertical barrier with +±2.5-5% horizontal barriers beat next-bar labeling; vol-adjusted barriers did NOT help); +arXiv 2504.02249 (vertical barrier grid-searched for LABEL BALANCE, with no costs modelled -- +label balance is not P&L, and that paper is not evidence about profit); Alvarez 2019 (an +indicator-or-time-stop exit is the industry baseline; the limit-order half that helped there is +unavailable to keel, which fills at market). + +**THE BARRIERS ARE MULTIPLES OF PER-PRODUCT FRICTION, and that is #342's design ask.** The +source's ±2.5-5% barriers sit at or below one round trip on this venue, so transplanted as +percentages they are mechanically dead -- a target that pays for the trade and nothing else, and +a stop inside the noise the fee already imposes. Worse, a FIXED percentage is wrong per asset: +since #259 the backtest prices thin books up to 183.8bp per leg, so the same 5% barrier is four +round trips on BTC and barely one on the corpus tail. So the barriers are sized from the +PRODUCT'S OWN friction, computed from its own candles. + +⚠️ **THE HONEST PRIOR, TIGHTENED BY MEASUREMENT RATHER THAN INHERITED.** #341 filed this with +the null as its prior. `docs/experiments/2026-09-01-cusum-event-first-measurement.md` then +measured the entry half and found something sharper: at ZERO fee `cusum_event` clears PF 1.0 on +only 8 of 24 assets, median 0.925. The entry has no gross edge for any exit to harvest. A better +exit redistributes P&L across trades; it cannot manufacture an edge that is absent before costs. +This ships to be measured, and the measurement's job is to say by how much a better exit moves a +rule that is losing at zero cost -- not whether it rescues it. +""" + +from __future__ import annotations + +from decimal import Decimal + +from keel.compliance.screen import median_daily_quote_volume +from keel.data.history import GRANULARITY_SECONDS +from keel.strategy.backtest import TAKER_FEE_PCT, slippage_for_quote_volume +from keel.strategy.rules.base import ParamSpec, Rule, Setup +from keel.strategy.rules.cusum_event import cusum_read +from keel.types import Candle, Granularity + +#: Seconds in a day, for scaling a per-bar volume statistic to the daily one the slippage model +#: is anchored on. +_SECONDS_PER_DAY = 86_400 + + +def per_product_round_trip(candles: list[Candle], granularity: Granularity) -> Decimal: + """One round trip's cost for THIS product: two taker legs plus two slippage legs. + + ⚠️ **`median_daily_quote_volume` returns a PER-BAR median despite its name**, so on hourly + candles it is an hourly figure. `slippage_for_quote_volume` is anchored on a $500M DAILY + volume, so handing it the hourly number unscaled reports every asset as maximally thin and + clamps the whole universe to the 183.8bp cap -- every barrier four times too wide, silently, + with no error anywhere. The bars-per-day scaling below is the fix, and `GRANULARITY_SECONDS` + is the one duration table rather than a second one written here. + + A bounded tail, not the full history: this runs once per bar in a sim, and the statistic is + a liquidity proxy whose whole purpose is to be approximately right. The window is declared + (`liquidity_bars`) for the same reason the CUSUM window is -- an estimate computed over a + different span is a different estimate, and a hidden one would make backtest and live + disagree for reasons nobody could see. + """ + bars_per_day = Decimal(_SECONDS_PER_DAY) / Decimal(GRANULARITY_SECONDS[granularity]) + daily_quote_volume = median_daily_quote_volume(candles) * bars_per_day + return 2 * TAKER_FEE_PCT + 2 * slippage_for_quote_volume(daily_quote_volume) + + +class TripleBarrier(Rule): + """CUSUM entry, friction-sized horizontal barriers, and a vertical time stop. + + The entry is `cusum_event`'s filter, reused rather than reimplemented: it is the sampling + method this source pairs with these barriers, and it is already measured, so holding it + fixed makes the exit the only thing that changed. `cusum_event` is the control. + """ + + decimal_params = ("entry_friction_mult", "target_friction_mult", "stop_friction_mult") + granularity_param = "granularity" + + PARAM_DOCS = { + "granularity": "Bar size for the filter, the barriers and the time stop.", + "lookback": "Bars the CUSUM filter is replayed across. Defines the state, not a budget.", + "liquidity_bars": "Bars the per-product volume statistic is estimated over.", + "entry_friction_mult": "Event threshold as a multiple of this product's round trip.", + "target_friction_mult": "Upper barrier, in round trips above the entry.", + "stop_friction_mult": "Lower barrier, in round trips below the entry.", + "max_holding_bars": ( + "The VERTICAL barrier: sell at the close once the position is this many bars old, " + "whatever price has done. Executable under market fills, unlike a resting order." + ), + } + + def __init__( + self, + product_id: str, + granularity: Granularity = Granularity.ONE_HOUR, + lookback: int = 168, + liquidity_bars: int = 720, + entry_friction_mult: Decimal = Decimal("2"), + target_friction_mult: Decimal = Decimal("4"), + stop_friction_mult: Decimal = Decimal("2"), + max_holding_bars: int = 24, + name: str = "triple_barrier", + ) -> None: + if lookback <= 1: + raise ValueError("lookback must be greater than 1") + if liquidity_bars <= 0: + raise ValueError("liquidity_bars must be positive") + if entry_friction_mult <= 0: + raise ValueError("entry_friction_mult must be positive") + if target_friction_mult <= 0: + raise ValueError("target_friction_mult must be positive") + if stop_friction_mult <= 0: + raise ValueError("stop_friction_mult must be positive") + if max_holding_bars <= 0: + raise ValueError("max_holding_bars must be positive") + + self.name = name + self.product_id = product_id + self.granularity = granularity + self.params: dict = { + "granularity": granularity.value, + "lookback": lookback, + "liquidity_bars": liquidity_bars, + "entry_friction_mult": entry_friction_mult, + "target_friction_mult": target_friction_mult, + "stop_friction_mult": stop_friction_mult, + "max_holding_bars": max_holding_bars, + } + + def param_space(self) -> tuple[ParamSpec, ...]: + return ( + ParamSpec("entry_friction_mult", "decimal", 1.0, 4.0, Decimal("0.5")), + ParamSpec("target_friction_mult", "decimal", 2.0, 8.0, Decimal("1")), + ParamSpec("stop_friction_mult", "decimal", 1.0, 4.0, Decimal("0.5")), + ParamSpec("max_holding_bars", "int", 6, 72, Decimal(6)), + ) + + def _decline(self, gate: str, **numbers: object) -> Setup | None: + self.last_rejection = {"gate": gate, **numbers} + return None + + def _series(self, candles_by_tf: dict[Granularity, list[Candle]]) -> list[Candle]: + return candles_by_tf.get(self.granularity, []) + + def detect(self, candles_by_tf: dict[Granularity, list[Candle]]) -> Setup | None: + """A CUSUM event, then barriers placed at multiples of this product's own round trip.""" + series = self._series(candles_by_tf) + lookback = self.params["lookback"] + liquidity_bars = self.params["liquidity_bars"] + + needed = max(lookback, liquidity_bars) + 1 + if len(series) < needed: + return self._decline("insufficient_history", bars=len(series), bars_needed=needed) + + friction = per_product_round_trip(series[-liquidity_bars:], self.granularity) + threshold = self.params["entry_friction_mult"] * friction + reading = cusum_read([c.close for c in series[-lookback:]], threshold) + event = { + "s_plus": float(reading.s_plus), + "threshold_pct": float(threshold), + "round_trip_pct": float(friction), + } + if not reading.fired_up: + return self._decline("cusum_threshold", **event) + + current = series[-1] + entry = current.close + target = entry * (1 + self.params["target_friction_mult"] * friction) + stop = entry * (1 - self.params["stop_friction_mult"] * friction) + # `0 < stop`, not merely `stop < entry`. A large enough `stop_friction_mult` drives + # the barrier NEGATIVE, and a negative price still satisfies the ordering -- so an + # ordering-only guard passes it, and `risk = entry - stop` then exceeds the entry + # itself, which sizes a position off a loss larger than the whole holding. Caught by + # the test written for a mutation that survived the first version of this line. + if not 0 < stop < entry < target: + return self._decline("barriers_degenerate", stop=float(stop), **event) + + self.last_rejection = None + return Setup( + product_id=self.product_id, + direction="long", + entry=entry, + stop=stop, + target=target, + context={ + "rule_class": "event_gated", + "round_trip_pct": float(friction), + "target_friction_mult": self.params["target_friction_mult"], + "stop_friction_mult": self.params["stop_friction_mult"], + "max_holding_bars": self.params["max_holding_bars"], + "s_plus": float(reading.s_plus), + }, + ts=current.ts, + ) + + def exit_signal(self, held: Setup, candles_by_tf: dict[Granularity, list[Candle]]) -> bool: + """The VERTICAL barrier, and only that: has the position been held long enough? + + The horizontal barriers are `Setup.stop`/`Setup.target`, which the backtester and the + account sim enforce -- restating them here would be two mechanisms deciding one exit. + What no other rule has is this one: a holding-duration limit that closes at the bar's + CLOSE regardless of price. That is executable under keel's market fills, which is why it + is the leg of the source's method that transfers; a resting limit at a barrier is not. + + Counted in BARS ELAPSED SINCE `held.ts`, not in wall-clock time. The series is a rolling + prefix in the backtest and the live cycle alike, so counting bars whose timestamp is + after the entry's is the same question in both -- and it stays correct across a gap in + the candle history, where a wall-clock subtraction would silently exit early. + """ + series = self._series(candles_by_tf) + if not series: + return False + elapsed = sum(1 for candle in series if candle.ts > held.ts) + return elapsed >= self.params["max_holding_bars"] + + def describe(self) -> dict: + return { + "name": self.name, + "params": self.params, + "param_space": [spec.plain() for spec in self.param_space()], + } diff --git a/tests/research/test_tuning.py b/tests/research/test_tuning.py index dd38281..f3d6e18 100644 --- a/tests/research/test_tuning.py +++ b/tests/research/test_tuning.py @@ -155,6 +155,7 @@ def test_search_spaces_pin_exactly_the_families_with_a_stop() -> None: "rsi_meanrev", "pullback_continuation", "cusum_event", + "triple_barrier", } for family, space in tuning.SEARCH_SPACES.items(): assert 4 <= len(space) <= 7, family diff --git a/tests/strategy/test_triple_barrier.py b/tests/strategy/test_triple_barrier.py new file mode 100644 index 0000000..92c09c5 --- /dev/null +++ b/tests/strategy/test_triple_barrier.py @@ -0,0 +1,265 @@ +"""#342 -- triple-barrier exits with a vertical time stop, barriers sized to friction. + +Two things are new here and everything below holds one of them: + +* **The vertical barrier.** No other shipped rule has a holding-duration exit. It closes at the + bar's CLOSE regardless of price, which is executable under keel's market fills — a resting + order at a horizontal barrier is not, which is why that half of the source's method does not + transfer and this half does. +* **Barriers sized to the PRODUCT'S OWN round trip.** The source's ±2.5–5% sit at or below one + round trip on this venue, so transplanted as percentages they are mechanically dead. A fixed + percentage is also wrong per asset: since #259 the backtest prices thin books up to 183.8bp + per leg, so the same 5% is four round trips on BTC and barely one on the corpus tail. +""" + +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from keel.strategy.backtest import SLIPPAGE_CAP_PCT, SLIPPAGE_FLOOR_PCT, TAKER_FEE_PCT +from keel.strategy.rules.base import Setup +from keel.strategy.rules.triple_barrier import TripleBarrier, per_product_round_trip +from keel.types import Candle, Granularity +from tests.strategy.rule_conformance import RuleConformanceTests + +_HOUR = 3600 + + +def _candles(closes: list[float], *, volume: float = 25_000_000.0) -> list[Candle]: + out: list[Candle] = [] + for index, close in enumerate(closes): + price = Decimal(str(close)) + out.append( + Candle( + ts=1_700_000_000 + index * _HOUR, + open=price, + high=price * Decimal("1.004"), + low=price * Decimal("0.996"), + close=price, + volume=Decimal(str(volume)) / price, + ) + ) + return out + + +def _flat_then(rise_pct: float, *, bars: int, flat: int = 760, volume: float = 25_000_000.0): + """`flat` bars at 100 then `bars` moving by `rise_pct` — crossing on the FINAL bar, which is + what the CUSUM reset requires (see `tests/strategy/test_cusum_event.py`).""" + closes = [100.0] * flat + price = 100.0 + for _ in range(bars): + price *= 1 + rise_pct / 100 + closes.append(price) + return _candles(closes, volume=volume) + + +def _firing() -> dict[Granularity, list[Candle]]: + """A liquid product. The slippage model is anchored at $500M DAILY, so the floor needs + ≥ 500M/day — 25M per hourly bar is 600M/day. One round trip is then 2 × 1.2% + 2 × 0.05% = + 2.5%, the default entry multiple of 2 makes the threshold 5%, and five 1.2% steps cross it + on the final bar. (A first draft used 500k/bar = 12M/day and priced at 30.5bp, not the + floor — the anchor is a daily figure and 12M/day is not liquid against it.)""" + return {Granularity.ONE_HOUR: _flat_then(1.2, bars=5)} + + +class TestTripleBarrierConformance(RuleConformanceTests): + def rule(self) -> TripleBarrier: + return TripleBarrier(product_id="BTC-USD") + + def firing_candles(self) -> dict[Granularity, list[Candle]]: + return _firing() + + +# -- per-product friction ----------------------------------------------------------------------- + + +def test_a_liquid_product_prices_at_the_slippage_floor() -> None: + liquid = _candles([100.0] * 720, volume=25_000_000.0) + + friction = per_product_round_trip(liquid, Granularity.ONE_HOUR) + + assert friction == 2 * TAKER_FEE_PCT + 2 * SLIPPAGE_FLOOR_PCT + assert friction == Decimal("0.025") + + +def test_a_thin_product_prices_wider_than_a_liquid_one() -> None: + """The whole reason the barrier is per-product: the same percentage is four round trips on + BTC and barely one on the corpus tail.""" + liquid = per_product_round_trip( + _candles([100.0] * 720, volume=25_000_000.0), Granularity.ONE_HOUR + ) + thin = per_product_round_trip(_candles([100.0] * 720, volume=100.0), Granularity.ONE_HOUR) + + assert thin > liquid + assert thin == 2 * TAKER_FEE_PCT + 2 * SLIPPAGE_CAP_PCT + + +def test_the_per_bar_statistic_is_scaled_to_a_daily_one() -> None: + """**The unit bug this guards, which produces no error at all.** + + `median_daily_quote_volume` returns a PER-BAR median despite its name. `slippage_for_quote_ + volume` is anchored on $500M DAILY, so handing it the hourly figure unscaled reports every + asset as maximally thin, clamps the whole universe to the 183.8bp cap, and makes every + barrier four times too wide — silently. + + Asserted by the scaling's own consequence: the same bars read as ONE_DAY carry 1/24th the + daily volume and must therefore price WIDER. + """ + bars = _candles([100.0] * 720, volume=25_000_000.0) + + hourly = per_product_round_trip(bars, Granularity.ONE_HOUR) + daily = per_product_round_trip(bars, Granularity.ONE_DAY) + + assert hourly < daily, "the per-bar statistic is not being scaled by bars-per-day" + assert hourly == Decimal("0.025") + + +# -- the barriers ------------------------------------------------------------------------------- + + +def test_the_barriers_are_multiples_of_the_products_round_trip() -> None: + rule = TripleBarrier(product_id="BTC-USD") + + setup = rule.detect(_firing()) + + assert setup is not None + friction = Decimal("0.025") + assert setup.target == setup.entry * (1 + Decimal("4") * friction) + assert setup.stop == setup.entry * (1 - Decimal("2") * friction) + assert setup.context["round_trip_pct"] == 0.025 + + +def test_the_sources_own_barrier_would_be_inside_one_round_trip() -> None: + """The finding that motivates friction-sizing. The paper's ±2.5–5% is one to two round + trips here, so a 2.5% target pays for the trade and leaves nothing — a barrier that is + mechanically dead rather than merely tight.""" + rule = TripleBarrier(product_id="BTC-USD") + setup = rule.detect(_firing()) + + assert setup is not None + paper_target = setup.entry * Decimal("1.025") + assert paper_target < setup.entry * (1 + Decimal("0.025")) + Decimal("0.0001") + assert setup.target > paper_target, "the shipped barrier must be scaled several-fold" + + +def test_a_thin_product_gets_wider_barriers_than_a_liquid_one() -> None: + liquid = TripleBarrier(product_id="BTC-USD").detect( + {Granularity.ONE_HOUR: _flat_then(1.2, bars=5, volume=25_000_000.0)} + ) + # A thin product's round trip is 2 x 1.2% + 2 x 183.8bp = 6.08%, so at entry_mult=1 the + # threshold is 6.08% and three 3% steps (9%) cross it ON THE FINAL BAR. Four steps would + # cross at the third and reset, and the last bar would decline -- the same reset property + # `test_cusum_event.py` documents, and the same way it bites when writing a fixture. + thin_rule = TripleBarrier(product_id="TON-USD", entry_friction_mult=Decimal("1")) + thin = thin_rule.detect({Granularity.ONE_HOUR: _flat_then(3.0, bars=3, volume=100.0)}) + + assert liquid is not None and thin is not None + liquid_width = (liquid.target - liquid.entry) / liquid.entry + thin_width = (thin.target - thin.entry) / thin.entry + assert thin_width > liquid_width + + +# -- the vertical barrier ----------------------------------------------------------------------- + + +def _held(ts: int) -> Setup: + return Setup( + product_id="BTC-USD", + direction="long", + entry=Decimal("100"), + stop=Decimal("95"), + target=Decimal("110"), + context={}, + ts=ts, + ) + + +def test_the_vertical_barrier_closes_the_position_after_n_bars() -> None: + rule = TripleBarrier(product_id="BTC-USD", max_holding_bars=24) + series = _candles([100.0] * 100) + entry_ts = series[50].ts + + assert rule.exit_signal(_held(entry_ts), {Granularity.ONE_HOUR: series[:75]}) is True + + +def test_the_vertical_barrier_does_not_fire_early() -> None: + rule = TripleBarrier(product_id="BTC-USD", max_holding_bars=24) + series = _candles([100.0] * 100) + entry_ts = series[50].ts + + assert rule.exit_signal(_held(entry_ts), {Granularity.ONE_HOUR: series[:70]}) is False + + +def test_the_vertical_barrier_counts_BARS_not_wall_clock() -> None: + """A gap in the candle history must not exit early. + + Counting elapsed seconds would fire the moment the clock passed N × bar-duration even if the + venue produced no bars — an exit triggered by missing data rather than by elapsed trading. + """ + rule = TripleBarrier(product_id="BTC-USD", max_holding_bars=24) + dense = _candles([100.0] * 60) + entry_ts = dense[10].ts + # Twelve bars, spread across a month of wall-clock: far past 24 hours, only 12 bars. + sparse = [ + Candle( + ts=entry_ts + (index + 1) * 86_400 * 3, + open=Decimal("100"), + high=Decimal("101"), + low=Decimal("99"), + close=Decimal("100"), + volume=Decimal("100"), + ) + for index in range(12) + ] + + assert rule.exit_signal(_held(entry_ts), {Granularity.ONE_HOUR: sparse}) is False + + +def test_the_horizontal_barriers_are_not_restated_by_the_exit_signal() -> None: + """`exit_signal` owns the VERTICAL barrier alone. The stop and target ride on the `Setup` + and are the backtester's to enforce; restating them here would be two mechanisms deciding + one exit, and they would drift.""" + rule = TripleBarrier(product_id="BTC-USD", max_holding_bars=24) + series = _candles([100.0] * 60) + collapsed = [*series, *_candles([1.0] * 5)] + + assert rule.exit_signal(_held(series[50].ts), {Granularity.ONE_HOUR: collapsed[:56]}) is False + + +@pytest.mark.parametrize( + "kwargs", + [ + {"lookback": 1}, + {"liquidity_bars": 0}, + {"entry_friction_mult": Decimal("0")}, + {"target_friction_mult": Decimal("0")}, + {"stop_friction_mult": Decimal("0")}, + {"max_holding_bars": 0}, + ], +) +def test_a_nonsensical_parameter_is_refused_at_construction(kwargs) -> None: + with pytest.raises(ValueError): + TripleBarrier(product_id="BTC-USD", **kwargs) + + +def test_a_stop_multiple_large_enough_to_invert_the_barriers_is_declined() -> None: + """Unreachable inside the DECLARED space, reachable from a stored row. + + `param_space` caps `stop_friction_mult` at 4, and 4 × the widest friction (6.08% on a + capped-slippage product) is 0.243 — comfortably above zero. But `rules add --params` and a + persisted row accept any positive Decimal, so a multiple of 20 puts the stop at or below + zero and inverts the barriers. A mutation deleting the guard survived until this test + existed, and the honest reading was that the guard is not dead code — the declared space is + just narrower than the constructor. + """ + rule = TripleBarrier( + product_id="TON-USD", + entry_friction_mult=Decimal("1"), + stop_friction_mult=Decimal("20"), + ) + + assert rule.detect({Granularity.ONE_HOUR: _flat_then(3.0, bars=3, volume=100.0)}) is None + assert rule.last_rejection is not None + assert rule.last_rejection["gate"] == "barriers_degenerate"