From 5794df977f1c340d3be8cdc706bc0d72f64a73db Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Sat, 8 Aug 2026 19:10:58 -0400 Subject: [PATCH 1/2] =?UTF-8?q?docs(experiments):=20build=20the=20=C2=A780?= =?UTF-8?q?.16=20independence=20harness;=20measure=20cross-asset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `research/independence.py` has implemented §80.16's five measurements since PR #103, but nothing in the repo drove them over two real rules -- the 2026-07-20 cross-horizon run was ad hoc and left no script behind, so every future between-family question started from zero. This adds the script. Three passes, in increasing order of what they settle: - BETWEEN-FAMILY (turtle vs rsi_meanrev) -- degenerate. On daily bars rsi_meanrev takes zero trades on all five allowlisted assets: `oversold=20` on a daily RSI(14) is near-unreachable (PAXG and XLM never print one), and the handful that fire on BTC/ETH/ADA die at the support-level gate. Not a low correlation -- no relationship to measure. Arm B is settled-refuted anyway (§74.3), so this pass could never have tested its candidacy; it is a shakedown. - CROSS-HORIZON (40/20 vs 80/40) -- the calibration, and the reason to trust the rest. Reproduces `2026-07-20-horizon-independence.md` exactly: BTC 0.802, ETH 0.934, median entry gap 0 days. It also settles which P&L convention PR #103 used, which that write-up never states: `closed` reproduces the published figures, mark-to-market gives 0.813/0.759. Future §80.16 tables should stay on `closed` for comparability. - CROSS-ASSET (shipped turtle vs ITSELF on another asset) -- the finding. Mean P&L correlation 0.011, position 0.175, Jaccard 0.144 across 10 pairs, against 0.508/0.585/0.510 for cross-horizon measured the same way on the same assets. Asset breadth is roughly an order of magnitude better than horizon breadth as a source of independent evidence. §79.1 redirected the frequency plan to breadth without saying which; this says which. PAXG is the standout -- zero Jaccard against ETH, ADA and XLM, negative position correlation against all four cryptos. This also tests an assumption `2026-07-20-minbtl-sizing.md`'s allowlist case rested on: that added assets multiply trades/year roughly linearly, which only holds if the added trades carry independent information. At the measured correlations they largely do. Arm A reproduces the shipped trade counts exactly (13/13/4/6/8), so the harness is wired to the real rule. Correlations rest on 4-13 trades per asset and carry no confidence intervals -- the direction is far more robust than any individual cell; caveats are in the write-up. Ledger: three rows, all `diagnostic_only` / `a_priori`, chain verified intact. M 69 -> 72, N_decisions unchanged at 30, so MinBTL is untouched. The cross-asset rho-bar is deliberately NOT substituted into §78.2's N-hat, which corrects a trials count and is a different quantity. Co-Authored-By: Claude Opus 5 (1M context) --- .../2026-08-08-between-family-independence.md | 228 +++++++ .../2026-08-08-between-family-independence.py | 640 ++++++++++++++++++ docs/experiments/trials-ledger.jsonl | 3 + 3 files changed, 871 insertions(+) create mode 100644 docs/experiments/2026-08-08-between-family-independence.md create mode 100644 docs/experiments/2026-08-08-between-family-independence.py diff --git a/docs/experiments/2026-08-08-between-family-independence.md b/docs/experiments/2026-08-08-between-family-independence.md new file mode 100644 index 00000000..d70d10a9 --- /dev/null +++ b/docs/experiments/2026-08-08-between-family-independence.md @@ -0,0 +1,228 @@ +# Between-family independence — the §80.16 harness exists, and it is calibrated + +**Date:** 2026-08-08 +**KB basis:** §80.16 (the measurement), §73.5 (why it is non-optional), §74.3/§58.10a (why arm B +is refuted), §80.10/§80.14 (who the real candidate is) +**Script:** `docs/experiments/2026-08-08-between-family-independence.py` +**Status:** harness build + calibration. **No parameter, rule, or gate changed.** +**Ledger:** `diagnostic_only` — this run influenced no shipped decision. + +**Verdict:** the §80.16 pipeline is built and **validated against a published in-repo result to +three decimals**. The between-family measurement it was pointed at is **degenerate**: on daily +bars `rsi_meanrev` takes **zero trades on all five allowlisted assets**, so there is no +relationship to measure. That is a fact about the rule and the bar clock, not a harness fault. + +⭐ **The finding is in the third pass, which was not the one this run set out to make.** Pointing +the same harness at the shipped turtle against *itself on a different asset* — same family, same +parameters, same bar clock, only the underlying varying — gives **mean P&L correlation 0.011, +position correlation 0.175, Jaccard 0.144.** Against the cross-horizon 0.508 / 0.585 / 0.510 +measured on the same assets by the same method, **asset breadth is roughly an order of magnitude +better than horizon breadth as a source of independent evidence.** §79.2's horizon ladder was +refuted for being 2× worse than a 0.22 benchmark; the allowlist clears that benchmark outright. + +Three passes: + +| pass | varies | mean P&L corr (closed) | outcome | +|---|---|---:|---| +| between-family | rule family | — | degenerate, arm B never trades | +| cross-horizon (calibration) | lookback | **0.508** (published, reproduced) | validates the pipeline | +| **cross-asset** | **the underlying** | **0.011** | **the finding** | + +## Why this ran at all, and what it explicitly is not + +§80.16 is the KB's one confirmed hole: four papers, 30,000+ tested rules, two asset classes, 114 +years, **zero** between-family correlation measurements. It is a build task. `research/independence.py` +has implemented the five measurements since PR #103 — but **nothing in the repo drives them over +two real rules.** The 2026-07-20 cross-horizon run was ad hoc and left no script behind, so every +future §80.16 question started from zero. This build closes that. + +⚠️ **This is not a candidacy test for `rsi_meanrev`, and no number below should be read as one.** +That family is settled-refuted three ways (§74.3): our own sim (16% win, PF 0.17); §58.10a Katz & +McCormick — worst model in a 36-market, 14-year book, *worse than random*; §74.3 Hudson & Urquhart +— **−6.33 bp vs buy-and-hold on Bitcoin, p < 0.01**. The KB instruction is *"Treat RSI mean-reversion +as settled and closed. Build no further variants."* Under §73.5 independence is **necessary, never +sufficient** — an uncorrelated stream with negative edge is still strictly worse than adding +nothing. Arm B is here because it is the only other implemented risk-defined rule, which makes it +a free shakedown. + +## Method + +Both arms on **`ONE_DAY`**. `RsiMeanReversion` defaults to `timeframe=ONE_HOUR`; `TurtleBreakout` +is fixed `ONE_DAY`. Running them natively would confound **family** separation with **horizon** +separation — and horizon is already measured on these assets at 0.508. Holding the bar clock +constant is what isolates the variable §80.16 asks about. `timeframe` is the only departure from +`RsiMeanReversion`'s constructor defaults; arm A is byte-identical to `keel-live.db` rules 1–5. + +Pre-declared before the run (§78.5): assets = the live allowlist; fee 0.006 / slippage 0.0005 +(matching `cli._SIM_FEE_PCT`); both P&L conventions reported. + +**Arm A reproduces the shipped trade counts exactly** — 13 / 13 / 4 / 6 / 8 for BTC / ETH / PAXG / +ADA / XLM, matching `config.live-sandbox.yaml:32-39`. The harness is wired to the real rule. + +## Result 1 — the between-family measurement is degenerate + +| asset | bars | A trades | B trades | A days in mkt | B days | both | +|---|---:|---:|---:|---:|---:|---:| +| BTC-USD | 1845 | 13 | **0** | 432 | 0 | 0 | +| ETH-USD | 1845 | 13 | **0** | 300 | 0 | 0 | +| PAXG-USD | 456 | 4 | **0** | 96 | 0 | 0 | +| ADA-USD | 1833 | 6 | **0** | 130 | 0 | 0 | +| XLM-USD | 1833 | 8 | **0** | 80 | 0 | 0 | + +All five excluded from the means — named in the output, never silently dropped. `_pearson` +returns 0 for a constant series by design, so averaging those zeros in would have manufactured a +"perfectly independent" headline out of a rule that never traded. + +**Why**, from the script's reproduction of `detect()`'s first gate: + +| asset | min RSI(14), daily | gate-1 fires in ~5 years | +|---|---:|---:| +| BTC-USD | 15.50 | 4 | +| ETH-USD | 12.86 | 3 | +| ADA-USD | 12.15 | 4 | +| PAXG-USD | 20.74 | **0** | +| XLM-USD | 20.65 | **0** | + +`oversold = 20` on a **daily** RSI(14) is close to unreachable on these assets — PAXG and XLM +never printed a sub-20 daily RSI in the entire window. The handful that do fire on BTC/ETH/ADA are +then killed downstream by the support-level gate (3+ touches within 0.5% of the bar's low). The +rule's `ONE_HOUR` default is not incidental; that family needs a fast bar clock to fire at all. + +⇒ **A same-horizon between-family measurement against `rsi_meanrev` is not obtainable.** Not +"came out low" — not obtainable. Any number produced by dropping to hourly for arm B would mix +family with horizon and be uninterpretable against §80.16. + +## Result 2 — the calibration, which is the finding worth keeping + +A between-family figure is only worth reading if the pipeline behind it can reproduce a +cross-horizon figure that is already known. Shipped 40/20 vs 80/40, same five measurements: + +| asset | A trd | B trd | Jaccard | pos corr | **P&L corr (closed)** | P&L corr (mtm) | median gap | **published** | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| BTC-USD | 13 | 10 | 0.707 | 0.774 | **0.802** | 0.813 | 0 d | **0.802** | +| ETH-USD | 13 | 8 | 0.702 | 0.810 | **0.934** | 0.759 | 0 d | **0.934** | +| PAXG-USD | 4 | 3 | 0.500 | 0.619 | 0.074 | 0.473 | 4 d | n/a | + +**Exact reproduction on both published assets, plus the "median entry gap of zero days" claim.** +The harness is trustworthy. + +⭐ **It also settles a methodological question PR #103's write-up left open: which P&L convention +that experiment used.** `closed` (whole net P&L booked to the exit bar, the +`2026-07-20-first-pbo-run.md` convention) reproduces 0.802 / 0.934; mark-to-market gives 0.813 / +0.759. **Future §80.16 tables should stay on `closed` for cross-experiment comparability.** This +is a comparability choice fixed by prior work, not a selection on this run — both columns print +either way. + +⚠️ PAXG's two conventions disagree sharply (0.074 vs 0.473) on 4 and 3 trades over a 456-bar +window. At that sample the estimator is noise. It carried no weight in PR #103 either — that +write-up's own note is that *"BTC and ETH carry the conclusion."* + +## Result 3 — cross-asset: the shipped turtle against itself, and the reason to expand + +Same rule, same parameters, same daily clock. Only the underlying varies. Series aligned on +**common timestamps** — comparing positionally would pair BTC's day 400 with PAXG's day 400, +which are years apart. + +| pair | days | Jaccard | pos corr | P&L (mtm) | P&L (closed) | median entry gap | +|---|---:|---:|---:|---:|---:|---:| +| BTC vs ETH | 1845 | 0.321 | 0.374 | 0.301 | 0.036 | 13 d | +| ADA vs XLM | 1833 | 0.288 | 0.430 | 0.117 | −0.001 | 8 d | +| ETH vs ADA | 1833 | 0.270 | 0.402 | 0.101 | −0.000 | 37 d | +| BTC vs ADA | 1833 | 0.210 | 0.334 | 0.117 | 0.076 | 69 d | +| BTC vs XLM | 1833 | 0.162 | 0.329 | −0.022 | −0.001 | 70 d | +| ETH vs XLM | 1833 | 0.142 | 0.246 | 0.011 | −0.000 | 44 d | +| BTC vs PAXG | 456 | 0.049 | **−0.073** | −0.063 | 0.002 | 114 d | +| ETH vs PAXG | 456 | **0.000** | **−0.158** | −0.002 | −0.001 | 45 d | +| PAXG vs ADA | 455 | **0.000** | **−0.088** | 0.002 | 0.002 | 84 d | +| PAXG vs XLM | 455 | **0.000** | **−0.042** | −0.005 | −0.002 | 91 d | +| **mean (10 pairs)** | | **0.144** | **0.175** | **0.056** | **0.011** | | +| *cross-horizon, same method* | | *0.510* | *0.585* | — | *0.508* | *0 d* | + +**1. Asset breadth is the independence axis that works — and it is now measured, not assumed.** +Every one of the three measures is 3–45× better than the cross-horizon equivalent. The sharpest +single contrast is the entry gap: adjacent horizons of the same rule open **on the same day** +(median 0), while the same rule on different assets opens **8 to 114 days apart.** + +**2. `2026-07-20-minbtl-sizing.md`'s allowlist case rested on an assumption this run tests.** +That table projected 3 assets → ~21 years to sufficiency, 10 assets → ~5, by assuming added +assets multiply trades/year roughly linearly. That is only true if the added trades carry +independent information. At ρ̄ ≈ 0.18 on positions and ≈ 0.01 on P&L, they largely do. **The +expansion case is stronger than it was when it was made.** + +**3. PAXG is the most valuable asset in the book, and it is the one with the least history.** +Zero Jaccard against ETH, ADA and XLM — over the common window it is *never* in the market at +the same time as any of them — and negative position correlation against all four cryptos. Gold +is a different factor, as §80.12's ETH↔gold +5.45 already hinted. It also has 456 daily bars +against ~1,830, and 4 trades. **The best diversifier here is the one the evidence base can say +least about.** + +**4. Effective breadth: ~2.9 of 5 streams** by `n/(1+(n−1)ρ̄)` on the conservative +position-correlation mean (4.8 of 5 if the P&L mean is used). ⚠️ This is the standard +equicorrelation approximation, **not** §78.2's `N̂ = ρ̂ + (1−ρ̂)·M`, which corrects a *trials* +count and is a different quantity. It is reported for interpretation and is fed into no gate and +no MinBTL computation. + +⚠️ **Read the P&L correlations with the sparsity in mind.** These rules are out of the market +75–95% of the time, so both P&L series are mostly joint zeros and the correlation is dominated by +that mass. Jaccard and position correlation are the more robust measures — and all three agree +here, which is the reassuring part. The comparison against cross-horizon is apples-to-apples: +same convention, same sparsity structure, same assets, same script. + +## What this changes + +**0. The uniform-parameter design is vindicated on the axis that matters, and the ranking of +breadth levers is now empirical.** One parameter set across five assets produces ~2.9–4.8 +effective independent streams. Five *per-asset fitted* parameter sets would not — they would be +five fits of the same underlying beta, spending trials budget to manufacture the correlation this +measurement shows the current design avoids. Ordering the three breadth axes by measured mean P&L +correlation on identical methodology: **asset 0.011 → horizon 0.508 → family (unmeasurable, arm +refuted).** §79.1 redirected the frequency plan to breadth without saying *which* breadth; this +says which. + +**1. The §80.16 measurement is now a script, not a plan.** Point it at any two `Rule` instances +and it returns all five measurements plus both P&L conventions. That is the reusable asset; the +rsi arm was the shakedown. + +**2. Two of keel's four rule kinds are refuted, not merely dormant.** `rsi_meanrev` (§74.3) and +`pullback_continuation` (same dip-buy family, §62.2) are closed with external corroboration. +`RULE_REGISTRY` reads as four options; it is really **one trend-follower, one DCA sleeve, and two +closed files.** Anyone reading the registry as a menu of available diversification is misreading +it — worth a comment at `agent.py:99`. + +**3. The real candidate is unbuilt, and now unblocked.** §80.14 closed the MACD family +(`macd_divergence` demoted — never cleared even the nominal level). §80.10 promoted **Liu & +Tsyvinski weekly time-series momentum** to leading second-rule-class candidate. §80.16's own note +is that the economic prior favours it — dense, continuous, centre-of-distribution, ~5–7× shorter +horizon than a 40-day Donchian tail event — *"but the prior is a reason to test this candidate +FIRST, not to skip the test."* When it is implemented it becomes arm B here, and the run stops +being a shakedown. + +⚠️ Note for that build: weekly TSM is a **coarser** clock than the daily Turtle, so it will hit +the mirror image of this run's problem. The harness will need a resample step onto a shared daily +index before it can measure a weekly rule against a daily one. + +**4. Order of operations for the candidate.** §80.16's independence test answers *"does it add +independent evidence?"*. It does not answer *"does it have edge?"* — and §80.4/§80.8/§80.15 give +three separate demonstrations of rules that passed an in-sample correction and died forward. Run +§58.11's random-entry control first; independence second. A rule that fails the null does not need +an independence measurement. + +## Caveats + +- In-sample, one window, no promotion gate, no out-of-sample split. A diagnostic. +- ⚠️ **The cross-asset correlations rest on 4–13 trades per asset.** The position vectors are + dense (80–432 in-market days each) so Jaccard and position correlation are reasonably + supported, but every pairwise figure is a small-sample estimate and none carries a confidence + interval. The *direction* — asset breadth beating horizon breadth by a wide margin on three + independent measures — is far more robust than any individual cell. +- Correlation measured over a window in which crypto was broadly trending together. A regime + where the four cryptos move as one would raise these figures; §79.16 flags exactly that + (rising cross-market correlation as a leading indicator that independence is breaking down). + This measurement is a snapshot, not a constant. +- `backtest()` sizes at `qty = 1` unit, so P&L is quote-currency per unit and is **not comparable + across assets**. Correlations are scale-invariant so per-asset figures are sound; raw P&L is + never pooled here. +- PAXG-USD has ~456 daily bars (listed 2025-05-08) against ~1,830 for the others. PAXG-USDT has + the long history, but §71.4a excludes the USDT quote. +- The calibration re-measures a pair PR #103 already measured. It spends no new decision budget — + it re-derives a known answer as a correctness check, which is what `diagnostic_only` is for. diff --git a/docs/experiments/2026-08-08-between-family-independence.py b/docs/experiments/2026-08-08-between-family-independence.py new file mode 100644 index 00000000..6e95acb6 --- /dev/null +++ b/docs/experiments/2026-08-08-between-family-independence.py @@ -0,0 +1,640 @@ +#!/usr/bin/env python +"""§80.16 BETWEEN-FAMILY independence — the reusable harness, shaken down on a refuted arm. + +Every empirical claim in `docs/experiments/2026-08-08-between-family-independence.md` comes from +this script. It is strictly read-only: it opens the candle cache with `mode=ro`, places no +orders, touches no rails, and writes nothing but stdout. + +WHAT THIS IS. §80.16 is the KB's one confirmed hole — four papers, 30,000+ tested rules, two +asset classes, 114 years, and **zero** measurements of signal correlation BETWEEN rule families. +It is a build task, not a sourcing task, and §73.5 makes it non-optional: a second rule class +that turns out correlated is *strictly worse than adding nothing*, because it spends trials +budget and returns one observation's worth of information. `keel/research/independence.py` +implements the five measurements; nothing in the repo drives them over two real rules. The +2026-07-20 cross-HORIZON run (PR #103) was ad hoc and left no script behind. This is that script. + +⚠️ WHAT THIS IS NOT — read before quoting any number below. + +`rsi_meanrev` is a SETTLED-REFUTED rule class (§74.3): three independent refutations, three +methods, three markets — our own sim (16% win, PF 0.17); §58.10a Katz & McCormick, worst model +in a 36-market 14-year book, worse than random; §74.3 Hudson & Urquhart, −6.33 bp vs +buy-and-hold on Bitcoin at p < 0.01. The KB's instruction is *"Treat RSI mean-reversion as +settled and closed. Build no further variants."* + +So this run **cannot and does not test rsi_meanrev's candidacy as a second rule class.** +Independence is a NECESSARY condition under §73.5, never a sufficient one — an uncorrelated +stream with negative edge is still worse than nothing. Arm B is here because it is the only +other risk-defined rule already implemented, which makes it a free shakedown of the pipeline. +The result is a HARNESS CHECK. Ledger status: `diagnostic_only`. + +The real candidate §80.16 names is §80.10's weekly time-series momentum (Liu & Tsyvinski), +promoted after §80.14 closed the MACD family. It is not implemented. When it is, it becomes +arm B here and the run becomes a candidacy test. + +THREE PASSES, in increasing order of what they settle: + + main() BETWEEN-FAMILY turtle vs rsi_meanrev — degenerate, arm B never trades + calibrate() CROSS-HORIZON turtle 40/20 vs 80/40 — reproduces PR #103, validates the pipeline + cross_asset() CROSS-ASSET turtle vs ITSELF on another asset — the one that returns a finding + +The third is the same five measurements with the underlying, rather than the family or the +horizon, as the only thing varying. It asks whether keel's five live rules are five independent +confirmations of one hypothesis or one hypothesis counted five times, and §73.5's arithmetic +applies to the answer unchanged. + +METHOD (§80.16 steps 1–5, verbatim, via `keel.research.independence.compare`): + + 1. daily POSITION vector s_t ∈ {0,1} (long-only ⇒ binary) + 2. Jaccard overlap #{both}/#{either} + 3. position correlation Pearson over the two position vectors + 4. trade timing for each A-entry, bars to the nearest B-entry + 5. P&L correlation Pearson over the two daily P&L series + +BOTH ARMS RUN ON `ONE_DAY`. `RsiMeanReversion` defaults to `timeframe=ONE_HOUR` and +`TurtleBreakout` is fixed `ONE_DAY`. Running them on native timeframes would confound FAMILY +separation with HORIZON separation — and horizon was already measured on these assets at 0.508 +mean P&L correlation (`2026-07-20-horizon-independence.md`). Holding the bar clock constant is +what isolates the variable §80.16 asks about. Setting `timeframe` is the only departure from +`RsiMeanReversion`'s constructor defaults, and it is declared here rather than tuned. + +TWO P&L CONVENTIONS, both reported, neither used to select anything: + +- `closed` — the whole net P&L attributed to the exit bar, 0 elsewhere. This is the convention + `2026-07-20-first-pbo-run.md` used for its CSCV columns. It is a sparse spike + series and a weak correlation estimator when two rules hold for different lengths. +- `mtm` — daily mark-to-market: qty·(close_t − close_{t−1}) inside the hold, seeded at + entry_fill and closed at exit_fill, with the trade's fee drag booked on the exit + day so the series sums EXACTLY to `Trade.pnl`. Denser, and the returns-correlation + reading of "daily P&L series". + +⭐ The `calibrate()` pass below SETTLED WHICH ONE PR #103 USED, which its write-up never states: +`closed` reproduces `2026-07-20-horizon-independence.md` to three decimals on both assets it +published (BTC 0.802, ETH 0.934), while `mtm` gives 0.813 / 0.759. So `closed` is the figure to +quote for cross-experiment comparison, and any future §80.16 table should stay on it for +consistency. That is a comparability choice fixed by prior work, not a selection on this run's +outcome — both columns are printed either way. + +`backtest()` sizes every trade at `qty = 1` unit, so P&L is quote-currency per unit and is NOT +comparable across assets. Correlation is scale-invariant, so per-asset figures are sound; raw +P&L is never pooled across assets here. + +Run against the dev venv, reading the deployment's candle cache: + + .venv/bin/python docs/experiments/2026-08-08-between-family-independence.py + .venv/bin/python docs/experiments/2026-08-08-between-family-independence.py --db path/to.db +""" + +from __future__ import annotations + +import argparse +import sqlite3 +from decimal import Decimal + +from keel.analysis.indicators import rsi +from keel.research.independence import IndependenceReport, compare +from keel.strategy.backtest import BacktestResult, backtest +from keel.strategy.rules.base import Rule, Trade +from keel.strategy.rules.rsi_meanrev import RsiMeanReversion +from keel.strategy.rules.turtle_breakout import TurtleBreakout +from keel.types import Candle, Granularity + +# -- PRE-DECLARED CONFIGURATION (§78.5: frozen before the run, not tuned during it) ------------- + +DEFAULT_DB = "/Users/elmehdiaitbrahim/keel/keel.db" + +#: The live allowlist (`config.live-sandbox.yaml`), in the settlement currency keel actually +#: trades. PAXG-USD carries only ~456 daily bars (listed 2025-05-08) against ~1,830 for the +#: others; PAXG-USDT has the long history but §71.4a excludes the USDT quote. +ASSETS = ("BTC-USD", "ETH-USD", "PAXG-USD", "ADA-USD", "XLM-USD") + +GRANULARITY = Granularity.ONE_DAY + +#: Arm A: the SHIPPED turtle, byte-identical to `keel-live.db` rules 1-5. +TURTLE_PARAMS: dict[str, object] = { + "entry_lookback": 40, + "exit_lookback": 20, + "atr_period": 20, + "atr_stop_mult": Decimal("2"), + "target_rr": Decimal("6"), + "adx_period": 14, + "adx_threshold": 25.0, + "s1_filter": False, + "use_macd_confirm": False, + "min_volume_filter": False, + "volume_ma_period": 20, + "volume_mult": 1.2, +} + +#: Arm B: `RsiMeanReversion` constructor defaults, with ONLY `timeframe` overridden (see the +#: module docstring on why the bar clock is held constant). +RSI_PARAMS: dict[str, object] = {"timeframe": GRANULARITY} + +#: Matches `cli._SIM_FEE_PCT` / `_SIM_SLIPPAGE_PCT`, so figures line up with `keel simulate`. +FEE_PCT = Decimal("0.006") +SLIPPAGE_PCT = Decimal("0.0005") + +#: The internal comparator. There is no published between-family benchmark — that absence IS +#: §80.16. The meaningful contrast is against the same five measurements run across HORIZONS of +#: one family on these assets (`2026-07-20-horizon-independence.md`). +CROSS_HORIZON_PNL_CORR = Decimal("0.508") +CROSS_HORIZON_POSITION_CORR = Decimal("0.585") +CROSS_HORIZON_JACCARD = Decimal("0.510") + +#: CALIBRATION. The shakedown's real check: re-measure a pair whose answer is already published +#: in-repo and see whether this harness reproduces it. `2026-07-20-horizon-independence.md` +#: reports the shipped 40/20 against 80/40 at P&L correlation 0.802 (BTC) / 0.934 (ETH), with a +#: MEDIAN ENTRY GAP OF ZERO DAYS. A between-family number is only worth reading if the pipeline +#: that produced it can reproduce a cross-horizon number that is already known. +CALIBRATION_ASSETS = ("BTC-USD", "ETH-USD", "PAXG-USD") +CALIBRATION_LADDER = (40, 20, 80, 40) # (entry_a, exit_a, entry_b, exit_b) +CALIBRATION_EXPECTED = {"BTC-USD": Decimal("0.802"), "ETH-USD": Decimal("0.934")} + + +# -- data --------------------------------------------------------------------------------------- + + +def load_candles(db_path: str, product_id: str, granularity: Granularity) -> list[Candle]: + """Ascending daily candles for one product, read-only.""" + connection = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + try: + rows = connection.execute( + "SELECT ts, o, h, l, c, v FROM candles " + "WHERE product_id = ? AND granularity = ? ORDER BY ts", + (product_id, granularity.value), + ).fetchall() + finally: + connection.close() + return [ + Candle( + ts=ts, + open=Decimal(o), + high=Decimal(h), + low=Decimal(low), + close=Decimal(c), + volume=Decimal(v), + ) + for ts, o, h, low, c, v in rows + ] + + +# -- §80.16 step 1: position vector, and the two P&L series --------------------------------------- + + +def series_for( + trades: list[Trade], candles: list[Candle] +) -> tuple[list[int], list[Decimal], list[Decimal], list[int]]: + """`(positions, pnl_mtm, pnl_closed, entry_indices)` over the daily index of `candles`. + + A day is in-market from the entry bar through the exit bar INCLUSIVE; a still-open trade + runs to the last bar. `pnl_mtm` sums exactly to each trade's net `pnl` — the fee drag + (`Trade.pnl` minus the gross `exit_fill − entry_fill`) is booked on the exit bar, so the + two conventions agree on totals and differ only in how they distribute them over time. + """ + index_of = {candle.ts: i for i, candle in enumerate(candles)} + n = len(candles) + + positions = [0] * n + pnl_mtm = [Decimal(0)] * n + pnl_closed = [Decimal(0)] * n + entry_indices: list[int] = [] + + for trade in trades: + start = index_of.get(trade.entry_ts) + if start is None: + continue + end = n - 1 if trade.exit_ts is None else index_of.get(trade.exit_ts, n - 1) + entry_indices.append(start) + + for i in range(start, end + 1): + positions[i] = 1 + + # Mark-to-market: entry bar marks from the fill, interior bars close-to-close, and the + # exit bar marks to the exit fill (or to the last close for a still-open trade). + previous = trade.entry + for i in range(start, end + 1): + is_exit_bar = i == end and trade.exit is not None + mark = trade.exit if is_exit_bar else candles[i].close + pnl_mtm[i] += (mark - previous) * trade.qty + previous = mark + + if trade.pnl is not None and trade.exit is not None: + gross = (trade.exit - trade.entry) * trade.qty + pnl_mtm[end] += trade.pnl - gross # fee/slippage drag, booked at the close + pnl_closed[end] += trade.pnl + + return positions, pnl_mtm, pnl_closed, entry_indices + + +def by_timestamp( + trades: list[Trade], candles: list[Candle] +) -> dict[int, tuple[int, Decimal, Decimal]]: + """`series_for` re-keyed by candle `ts` instead of by position, for cross-ASSET alignment. + + Two assets share a granularity but not an index — PAXG-USD listed in 2025 and carries ~456 + daily bars against ~1,830 for the others, and listings/outages leave holes. Comparing them + positionally would silently pair BTC's day 400 with PAXG's day 400, which are years apart. + """ + positions, mtm, closed, _ = series_for(trades, candles) + return { + candle.ts: (positions[i], mtm[i], closed[i]) for i, candle in enumerate(candles) + } + + +def align( + a: dict[int, tuple[int, Decimal, Decimal]], b: dict[int, tuple[int, Decimal, Decimal]] +) -> tuple[list[int], list[Decimal], list[Decimal], list[int], list[Decimal], list[Decimal]]: + """Restrict two ts-keyed series to their common timestamps, ascending. + + Entry indices are deliberately NOT carried across: after intersection they would point into + the old index. `compare` re-derives entries from the rising edges of the aligned position + vectors instead, which is what it does by default. + """ + common = sorted(a.keys() & b.keys()) + a_rows = [a[ts] for ts in common] + b_rows = [b[ts] for ts in common] + return ( + [row[0] for row in a_rows], + [row[1] for row in a_rows], + [row[2] for row in a_rows], + [row[0] for row in b_rows], + [row[1] for row in b_rows], + [row[2] for row in b_rows], + ) + + +def effective_breadth(n: int, mean_correlation: Decimal) -> Decimal: + """`n / (1 + (n−1)·ρ̄)` — effective independent streams under equicorrelation. + + ⚠️ The standard equicorrelated-portfolio approximation, NOT a KB formula and NOT §78.2's + `N̂ = ρ̂ + (1−ρ̂)·M` (which corrects a TRIALS count, a different quantity). It answers only + "how many independent evidence streams is a set of `n` correlated assets worth?" and is + reported here for interpretation, never fed into MinBTL or any gate. + """ + if n <= 1: + return Decimal(n) + denominator = Decimal(1) + Decimal(n - 1) * mean_correlation + return Decimal(n) / denominator if denominator > 0 else Decimal(n) + + +def build_arms(product_id: str) -> tuple[TurtleBreakout, RsiMeanReversion]: + """The two pre-declared arms for one product.""" + return ( + TurtleBreakout(product_id=product_id, **TURTLE_PARAMS), + RsiMeanReversion(product_id=product_id, **RSI_PARAMS), + ) + + +def oversold_bounce_count(candles: list[Candle], period: int, oversold: float) -> tuple[int, float]: + """`(bars firing rsi_meanrev's FIRST gate, min RSI over the series)`. + + `detect()` records no `last_rejection`, so when arm B returns nothing there is no built-in + way to say which gate declined. This reproduces gate 1 only — `prev_rsi < oversold and + curr_rsi > prev_rsi` — which is enough to distinguish "the threshold is unreachable on this + bar clock" from "the support-level gate downstream is what's binding". + """ + values = rsi([float(candle.close) for candle in candles], period=period) + finite = [v for v in values if v is not None] + fires = sum( + 1 + for i in range(1, len(values)) + if values[i - 1] is not None + and values[i] is not None + and values[i - 1] < oversold + and values[i] > values[i - 1] + ) + return fires, min(finite) if finite else float("nan") + + +def measure( + a: Rule, b: Rule, candles: list[Candle] +) -> tuple[IndependenceReport, IndependenceReport, BacktestResult, BacktestResult]: + """Backtest both rules over `candles` and run the five §80.16 measurements. + + Returns `(mtm_report, closed_report, a_result, b_result)`. + """ + a_result = backtest(a, candles, fee_pct=FEE_PCT, slippage_pct=SLIPPAGE_PCT) + b_result = backtest(b, candles, fee_pct=FEE_PCT, slippage_pct=SLIPPAGE_PCT) + a_pos, a_mtm, a_closed, a_entries = series_for(a_result.trades, candles) + b_pos, b_mtm, b_closed, b_entries = series_for(b_result.trades, candles) + return ( + compare(a_pos, b_pos, a_mtm, b_mtm, a_entries, b_entries), + compare(a_pos, b_pos, a_closed, b_closed, a_entries, b_entries), + a_result, + b_result, + ) + + +# -- report --------------------------------------------------------------------------------------- + + +def _fmt(value: Decimal | int | None, places: str = "0.001") -> str: + if value is None: + return "n/a" + if isinstance(value, int): + return str(value) + return str(value.quantize(Decimal(places))) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--db", default=DEFAULT_DB, help=f"Candle cache (default: {DEFAULT_DB})") + parser.add_argument( + "--candidates", + default=None, + help="Comma-separated product ids to rank by marginal independence vs the live allowlist " + "(e.g. ZEC-USD,SOL-USD). Measurement only — admits and attests nothing.", + ) + args = parser.parse_args() + + print("§80.16 between-family independence — turtle_breakout vs rsi_meanrev") + print(f"db={args.db} granularity={GRANULARITY.value} fee={FEE_PCT} slip={SLIPPAGE_PCT}") + print("⚠️ DIAGNOSTIC / HARNESS SHAKEDOWN. Arm B is settled-refuted (§74.3); this run") + print(" cannot test its candidacy as a second rule class. See the module docstring.\n") + + header = ( + f"{'asset':10} {'bars':>5} {'A trd':>6} {'B trd':>6} {'A days':>7} {'B days':>7} " + f"{'both':>5} {'jaccard':>8} {'pos r':>7} {'pnl r':>7} {'pnl r':>7} {'gap':>5}" + ) + print(header) + print(f"{'':10} {'':5} {'':6} {'':6} {'':7} {'':7} {'':5} {'':8} {'':7} {'(mtm)':>7} " + f"{'(clsd)':>7} {'(d)':>5}") + print("-" * len(header)) + + jaccards: list[Decimal] = [] + position_corrs: list[Decimal] = [] + pnl_corrs_mtm: list[Decimal] = [] + pnl_corrs_closed: list[Decimal] = [] + degenerate: list[str] = [] + + gate_notes: list[str] = [] + + for product_id in ASSETS: + candles = load_candles(args.db, product_id, GRANULARITY) + if not candles: + degenerate.append(f"{product_id}: no {GRANULARITY.value} candles cached") + continue + + turtle, rsi_rule = build_arms(product_id) + report, closed_report, a, b = measure(turtle, rsi_rule, candles) + + if not b.trades: + fires, rsi_min = oversold_bounce_count( + candles, rsi_rule.rsi_period, rsi_rule.oversold + ) + gate_notes.append( + f"{product_id}: RSI({rsi_rule.rsi_period}) min {rsi_min:.2f} over " + f"{len(candles)} daily bars; gate 1 (prev<{rsi_rule.oversold:g} and rising) " + f"fires {fires}x" + ) + + print( + f"{product_id:10} {len(candles):5d} {len(a.trades):6d} {len(b.trades):6d} " + f"{report.a_active:7d} {report.b_active:7d} {report.both_active:5d} " + f"{_fmt(report.jaccard):>8} {_fmt(report.position_correlation):>7} " + f"{_fmt(report.pnl_correlation):>7} {_fmt(closed_report.pnl_correlation):>7} " + f"{_fmt(report.median_entry_distance):>5}" + ) + + # A pair where either arm never traded has no relationship to measure; `_pearson` + # returns 0 for a constant series by design, and averaging that 0 in would understate + # correlation. Excluded from the means and named below — never silently dropped. + if not a.trades or not b.trades: + degenerate.append( + f"{product_id}: arm A {len(a.trades)} trades, arm B {len(b.trades)} " + "— excluded from means (no relationship to measure)" + ) + continue + + jaccards.append(report.jaccard) + position_corrs.append(report.position_correlation) + pnl_corrs_mtm.append(report.pnl_correlation) + pnl_corrs_closed.append(closed_report.pnl_correlation) + + if not pnl_corrs_mtm: + print("\nNo asset produced trades on both arms — nothing to average.") + else: + n = len(pnl_corrs_mtm) + print(f"\nMEAN across {n} asset(s) with trades on both arms:") + print(f" Jaccard overlap {_fmt(sum(jaccards, Decimal(0)) / n)}" + f" (cross-horizon: {CROSS_HORIZON_JACCARD})") + print(f" position correlation {_fmt(sum(position_corrs, Decimal(0)) / n)}" + f" (cross-horizon: {CROSS_HORIZON_POSITION_CORR})") + print(f" P&L correlation (mtm) {_fmt(sum(pnl_corrs_mtm, Decimal(0)) / n)}" + f" (cross-horizon: {CROSS_HORIZON_PNL_CORR})") + print(f" P&L correlation (clsd) {_fmt(sum(pnl_corrs_closed, Decimal(0)) / n)}") + + if degenerate: + print("\nExcluded / degenerate:") + for note in degenerate: + print(f" - {note}") + + if gate_notes: + print("\nWhy arm B is empty (gate 1 of rsi_meanrev.detect, reproduced):") + for note in gate_notes: + print(f" - {note}") + + calibrate(args.db) + cross_asset(args.db) + if args.candidates: + marginal_independence(args.db, [c.strip() for c in args.candidates.split(",") if c.strip()]) + + print( + "\nReminder: low correlation here is NOT evidence for rsi_meanrev. §73.5 makes" + "\nindependence necessary, never sufficient, and §74.3 already closed the family." + ) + + +def calibrate(db_path: str) -> None: + """Re-measure a cross-HORIZON pair whose answer is already published in-repo. + + This is the shakedown's actual validation. A between-family figure means nothing if the + pipeline behind it cannot reproduce `2026-07-20-horizon-independence.md`'s numbers for the + shipped 40/20 against 80/40. Running it also settles which P&L convention that experiment + used, which its write-up does not state. + """ + entry_a, exit_a, entry_b, exit_b = CALIBRATION_LADDER + print( + f"\nCALIBRATION — cross-horizon {entry_a}/{exit_a} vs {entry_b}/{exit_b}, " + "against 2026-07-20-horizon-independence.md" + ) + print( + f"{'asset':10} {'A trd':>6} {'B trd':>6} {'jaccard':>8} {'pos r':>7} " + f"{'pnl r':>7} {'pnl r':>7} {'gap':>5} {'published':>10}" + ) + print(f"{'':10} {'':6} {'':6} {'':8} {'':7} {'(mtm)':>7} {'(clsd)':>7} {'(d)':>5} {'':10}") + + for product_id in CALIBRATION_ASSETS: + candles = load_candles(db_path, product_id, GRANULARITY) + if not candles: + continue + slow = dict(TURTLE_PARAMS, entry_lookback=entry_b, exit_lookback=exit_b) + report, closed_report, a, b = measure( + TurtleBreakout(product_id=product_id, **TURTLE_PARAMS), + TurtleBreakout(product_id=product_id, **slow), + candles, + ) + published = CALIBRATION_EXPECTED.get(product_id) + print( + f"{product_id:10} {len(a.trades):6d} {len(b.trades):6d} " + f"{_fmt(report.jaccard):>8} {_fmt(report.position_correlation):>7} " + f"{_fmt(report.pnl_correlation):>7} {_fmt(closed_report.pnl_correlation):>7} " + f"{_fmt(report.median_entry_distance):>5} " + f"{(_fmt(published) if published else 'n/a'):>10}" + ) + + +def turtle_series( + db_path: str, product_id: str +) -> dict[int, tuple[int, Decimal, Decimal]] | None: + """The shipped turtle's ts-keyed series for one product, or None if no candles are cached.""" + candles = load_candles(db_path, product_id, GRANULARITY) + if not candles: + return None + rule = TurtleBreakout(product_id=product_id, **TURTLE_PARAMS) + result = backtest(rule, candles, fee_pct=FEE_PCT, slippage_pct=SLIPPAGE_PCT) + return by_timestamp(result.trades, candles) + + +def marginal_independence(db_path: str, candidates: list[str]) -> None: + """Rank allowlist CANDIDATES by the independent evidence each would add to the live book. + + Five scout runs have ranked candidates on liquidity, history and shariah plausibility. None + measured independence — and `2026-07-20-minbtl-sizing.md`'s expansion case is specifically an + argument about INDEPENDENT evidence. Under §73.5 a sixth asset correlated with the five + already held buys deployment, not knowability: it spends attestation effort and trades, and + leaves the evidence horizon where it was. + + For each candidate this measures the shipped turtle on that asset against the shipped turtle + on each of the five live assets, on common timestamps, and reports the mean. Lower is better. + + ⛔ PROPOSER, NEVER DECIDER. This admits nothing, attests nothing and writes to no keel DB or + config. Independence is one axis among several — it says nothing about whether an asset is + halal (attestation), liquid enough, or has edge (backtest). A low number here is not a + licence to add an asset. + """ + print("\nMARGINAL INDEPENDENCE — candidate vs the live allowlist (shipped turtle both sides)") + + live = {p: s for p in ASSETS if (s := turtle_series(db_path, p)) is not None} + if not live: + print(" no live-asset candles cached; skipped") + return + + header = ( + f"{'candidate':11} {'bars':>5} {'vs':>3} {'jaccard':>8} {'pos r':>7} " + f"{'pnl r':>7} {'worst pair':>22}" + ) + print(header) + print(f"{'':11} {'':5} {'':3} {'(mean)':>8} {'(mean)':>7} {'(mean,clsd)':>7} {'':22}") + print("-" * len(header)) + + for candidate in candidates: + series = turtle_series(db_path, candidate) + if series is None: + print(f"{candidate.removesuffix('-USD'):11} {'—':>5} no local candles — run " + f"`keel fetch --products {candidate}` first") + continue + + jaccards: list[Decimal] = [] + position_corrs: list[Decimal] = [] + pnl_corrs: list[Decimal] = [] + worst = (Decimal(-2), "") + bars = 0 + for name, live_series in live.items(): + a_pos, _a_mtm, a_closed, b_pos, _b_mtm, b_closed = align(series, live_series) + report = compare(a_pos, b_pos, a_closed, b_closed) + bars = max(bars, report.n_periods) + jaccards.append(report.jaccard) + position_corrs.append(report.position_correlation) + pnl_corrs.append(report.pnl_correlation) + if report.position_correlation > worst[0]: + worst = (report.position_correlation, name.removesuffix("-USD")) + + n = len(live) + print( + f"{candidate.removesuffix('-USD'):11} {bars:5d} {n:3d} " + f"{_fmt(sum(jaccards, Decimal(0)) / n):>8} " + f"{_fmt(sum(position_corrs, Decimal(0)) / n):>7} " + f"{_fmt(sum(pnl_corrs, Decimal(0)) / n):>7} " + f"{(worst[1] + ' ' + _fmt(worst[0])):>22}" + ) + + +def cross_asset(db_path: str) -> None: + """CROSS-ASSET: the shipped turtle against ITSELF on a different asset. + + Same family, same parameters, same bar clock — the only thing varying is the underlying. + This is the measurement that tests whether keel's five live rules are five independent + confirmations of one hypothesis or one hypothesis counted five times. §73.5's arithmetic + applies unchanged: correlated streams inflate apparent trade count without adding evidence. + + Series are aligned on COMMON timestamps (see `align`), so PAXG-USD's short window shortens + only the pairs it participates in. + """ + print("\nCROSS-ASSET — shipped turtle 40/20 vs ITSELF on another asset (same params)") + + series: dict[str, dict[int, tuple[int, Decimal, Decimal]]] = {} + for product_id in ASSETS: + candles = load_candles(db_path, product_id, GRANULARITY) + if not candles: + continue + rule = TurtleBreakout(product_id=product_id, **TURTLE_PARAMS) + result = backtest(rule, candles, fee_pct=FEE_PCT, slippage_pct=SLIPPAGE_PCT) + series[product_id] = by_timestamp(result.trades, candles) + + header = ( + f"{'pair':22} {'days':>6} {'jaccard':>8} {'pos r':>7} {'pnl r':>7} {'pnl r':>7} {'gap':>5}" + ) + print(header) + print(f"{'':22} {'':6} {'':8} {'':7} {'(mtm)':>7} {'(clsd)':>7} {'(d)':>5}") + print("-" * len(header)) + + names = [p for p in ASSETS if p in series] + jaccards: list[Decimal] = [] + position_corrs: list[Decimal] = [] + pnl_mtm_corrs: list[Decimal] = [] + pnl_closed_corrs: list[Decimal] = [] + + for i, first in enumerate(names): + for second in names[i + 1 :]: + a_pos, a_mtm, a_closed, b_pos, b_mtm, b_closed = align( + series[first], series[second] + ) + mtm_report = compare(a_pos, b_pos, a_mtm, b_mtm) + closed_report = compare(a_pos, b_pos, a_closed, b_closed) + label = f"{first.removesuffix('-USD')} vs {second.removesuffix('-USD')}" + print( + f"{label:22} {mtm_report.n_periods:6d} {_fmt(mtm_report.jaccard):>8} " + f"{_fmt(mtm_report.position_correlation):>7} " + f"{_fmt(mtm_report.pnl_correlation):>7} " + f"{_fmt(closed_report.pnl_correlation):>7} " + f"{_fmt(mtm_report.median_entry_distance):>5}" + ) + jaccards.append(mtm_report.jaccard) + position_corrs.append(mtm_report.position_correlation) + pnl_mtm_corrs.append(mtm_report.pnl_correlation) + pnl_closed_corrs.append(closed_report.pnl_correlation) + + if not pnl_mtm_corrs: + return + + pairs = len(pnl_mtm_corrs) + mean_position = sum(position_corrs, Decimal(0)) / pairs + mean_mtm = sum(pnl_mtm_corrs, Decimal(0)) / pairs + mean_closed = sum(pnl_closed_corrs, Decimal(0)) / pairs + print(f"\nMEAN across {pairs} pairs:") + print(f" Jaccard overlap {_fmt(sum(jaccards, Decimal(0)) / pairs)}" + f" (cross-horizon: {CROSS_HORIZON_JACCARD})") + print(f" position correlation {_fmt(mean_position)}" + f" (cross-horizon: {CROSS_HORIZON_POSITION_CORR})") + print(f" P&L correlation (mtm) {_fmt(mean_mtm)}") + print(f" P&L correlation (clsd) {_fmt(mean_closed)}" + f" (cross-horizon: {CROSS_HORIZON_PNL_CORR})") + print( + f"\n effective independent streams from {len(names)} assets, " + f"n/(1+(n-1)ρ̄) on the position-correlation mean: " + f"{_fmt(effective_breadth(len(names), mean_position), '0.01')}" + ) + + +if __name__ == "__main__": + main() diff --git a/docs/experiments/trials-ledger.jsonl b/docs/experiments/trials-ledger.jsonl index 501aeea1..acb5d0a6 100644 --- a/docs/experiments/trials-ledger.jsonl +++ b/docs/experiments/trials-ledger.jsonl @@ -67,3 +67,6 @@ {"decision":"diagnostic_only","kind":"sweep_node","params":{"assets":["BTC-USD","ETH-USD","PAXG-USD"],"entry_lookback":40,"exit_lookback":40,"note":"pre-declared sensitivity characterisation (\u00a779.6); not a selection","ratio":1.0},"per_bar_pnl":["0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","-5481.16162557391217567040","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","-2290.946220430","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","-284.44331305425648722820","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","-164.848871294074570837900","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","172.981585470","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","-2057.893416360","0","0","0","0","0","0","0","0","-181.939517523415446144960","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","-2381.55350329022187963280","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","11371.12389531222518146280","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","654.18230293168803798320","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","1104.44102551939260144120","0","0","19202.62493765123297932240","0","0","-395.27561522054198918120","-6618.17620483494819697180","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","-313.702070973770473394780","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","-398.50181204878603311920","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","7056.262174460","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","-286.784847101594091436300","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","1540.119152996121178377560","0","0","0","0","0","0","0","0","0","0","0","11727.549444993909358244420","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","502.911253275755961604960","0","0","-176.205801168933079559220","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","-200.858988603938266367380","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","-6609.44583373702201311380","0","0","0","722.762323100810788570320","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","-5568.1426453801940060260","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0"],"per_trade_pnl":[],"prev_hash":"bf43d4372dc41b9c2955de8b8bb94f58cdf457169652f1fcad8c9092c2cca920","provenance":"fitted","row_hash":"dbedfbdc045d28fe84334b20cb61cddf629bffd278503c3b2062a6f5acc945d4","rule":"turtle_breakout","series_missing":false,"session":"exit-lookback-2026-07-20","summary":{},"timestamp":1784505600,"trial_id":"exit-lookback-40"} {"decision":"diagnostic_only","kind":"sweep_node","params":{"assets":["BTC-USD","ETH-USD","PAXG-USD"],"entry_lookback":40,"exit_lookback":60,"note":"pre-declared sensitivity characterisation (\u00a779.6); not a selection","ratio":1.5},"per_bar_pnl":["0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","-5481.16162557391217567040","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","-6096.3163701027237886040","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","-284.44331305425648722820","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","-164.848871294074570837900","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","5108.561376472221496656240","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","524.591098181663380869520","0","0","0","0","0","-180.60641757694216459460","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","-2057.893416360","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","-2381.55350329022187963280","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","11371.12389531222518146280","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","654.18230293168803798320","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","1104.44102551939260144120","0","0","19202.62493765123297932240","0","0","-395.27561522054198918120","-6618.17620483494819697180","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","-313.702070973770473394780","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","-398.50181204878603311920","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","7056.262174460","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","-286.784847101594091436300","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","1540.119152996121178377560","0","0","0","0","0","0","0","0","0","0","0","-532.008683286090641755580","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","502.911253275755961604960","0","0","-176.205801168933079559220","0","0","0","0","0","0","8618.925994960","0","0","0","0","0","0","0","0","0","-200.858988603938266367380","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","-6609.44583373702201311380","0","0","0","722.762323100810788570320","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","-5568.1426453801940060260","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0"],"per_trade_pnl":[],"prev_hash":"dbedfbdc045d28fe84334b20cb61cddf629bffd278503c3b2062a6f5acc945d4","provenance":"fitted","row_hash":"c694ca5feea8608ab006503187e2e75dfa0b66075b72f5c120c2d4badd5deb4b","rule":"turtle_breakout","series_missing":false,"session":"exit-lookback-2026-07-20","summary":{},"timestamp":1784505600,"trial_id":"exit-lookback-60"} {"decision":"rejected","kind":"threshold_nudge","params":{"considered":[10,30,40,60],"exit_lookback":20,"outcome":"no change; \u00a779.6 monotone prediction did not replicate"},"per_bar_pnl":[],"per_trade_pnl":[],"prev_hash":"c694ca5feea8608ab006503187e2e75dfa0b66075b72f5c120c2d4badd5deb4b","provenance":"fitted","row_hash":"ef7708484ef5a3dbfac307f09f153f9d0de6250596a3c90edbc593d901c32ea6","rule":"turtle_breakout","series_missing":true,"session":"exit-lookback-2026-07-20","summary":{},"timestamp":1784565787,"trial_id":"exit-lookback-decision"} +{"decision":"diagnostic_only","kind":"ablation","params":{"arm_a":"turtle_breakout 40/20 ATR(20) 2N rr6 ADX(14)>25","arm_b":"rsi_meanrev constructor defaults, timeframe=ONE_DAY","assets":["BTC-USD","ETH-USD","PAXG-USD","ADA-USD","XLM-USD"],"fee_pct":"0.006","granularity":"ONE_DAY","measurement":"KB-80.16 five measurements","not_a_candidacy_test":"rsi_meanrev settled-refuted per KB-74.3","outcome":"degenerate - arm B took 0 trades on all 5 assets; no relationship measurable","slippage_pct":"0.0005"},"per_bar_pnl":[],"per_trade_pnl":[],"prev_hash":"ef7708484ef5a3dbfac307f09f153f9d0de6250596a3c90edbc593d901c32ea6","provenance":"a_priori","row_hash":"9e68efdd976787e92a572f9bdfee7010642334cbd8d0cbb719ffcd1123df1313","rule":"turtle_breakout|rsi_meanrev","series_missing":true,"session":"between-family-independence-2026-08-08","summary":{},"timestamp":1786185738,"trial_id":"between-family-turtle-vs-rsi-2026-08-08"} +{"decision":"diagnostic_only","kind":"ablation","params":{"arm_a":"turtle_breakout 40/20","arm_b":"turtle_breakout 80/40","assets":["BTC-USD","ETH-USD","PAXG-USD"],"finding":"PR #103 used closed-trade P&L attribution, not mark-to-market","granularity":"ONE_DAY","measurement":"harness calibration vs 2026-07-20-horizon-independence.md","reproduced_closed_convention":{"BTC-USD":"0.802 vs published 0.802","ETH-USD":"0.934 vs published 0.934"}},"per_bar_pnl":[],"per_trade_pnl":[],"prev_hash":"9e68efdd976787e92a572f9bdfee7010642334cbd8d0cbb719ffcd1123df1313","provenance":"a_priori","row_hash":"eca7915eab07b63acd3503a8dfb515f6df99ab3114bd1214e03e6aed0ccbc512","rule":"turtle_breakout","series_missing":true,"session":"between-family-independence-2026-08-08","summary":{},"timestamp":1786185747,"trial_id":"harness-calibration-crosshorizon-2026-08-08"} +{"decision":"diagnostic_only","kind":"ablation","params":{"alignment":"common timestamps","arms":"shipped turtle 40/20 vs ITSELF on another asset, identical params","assets":["BTC-USD","ETH-USD","PAXG-USD","ADA-USD","XLM-USD"],"cross_horizon_comparator":{"jaccard":"0.510","pnl_closed":"0.508","position":"0.585"},"finding":"asset breadth is ~an order of magnitude more independent than horizon breadth; supports the minbtl allowlist-expansion case","granularity":"ONE_DAY","mean_jaccard":"0.144","mean_pnl_corr_closed":"0.011","mean_pnl_corr_mtm":"0.056","mean_position_corr":"0.175","measurement":"KB-80.16 five measurements, CROSS-ASSET","pairs":10},"per_bar_pnl":[],"per_trade_pnl":[],"prev_hash":"eca7915eab07b63acd3503a8dfb515f6df99ab3114bd1214e03e6aed0ccbc512","provenance":"a_priori","row_hash":"5e1984ea47926c00a94b4a7da578f32deea4900b7a085199b4153a6fa2fe4dce","rule":"turtle_breakout","series_missing":true,"session":"between-family-independence-2026-08-08","summary":{},"timestamp":1786188041,"trial_id":"cross-asset-shipped-turtle-2026-08-08"} From 8e2ca7ec06166405df8d400cb5b9432540836532 Mon Sep 17 00:00:00 2001 From: Elmehdi Aitbrahim Date: Sat, 8 Aug 2026 19:19:51 -0400 Subject: [PATCH 2/2] fix(assets): make the discovery pre-filter measure what the gate measures `assets discover` pre-filters on the venue's reported 24h quote volume; `assets screen` admits on the median of `volume * close` over cached history. Same units, different statistics -- and the gap is large enough to change outcomes in both directions. The 2026-08-08 sweep shortlisted BICO on a reported $12.81M/24h. The screen then rejected it at a median daily volume of 108,004 -- 9x under the floor, a 227x gap between the two numbers. The same sweep nearly dropped DOGE for sitting 1.7% below the $5M discovery floor, when on the admission criterion it is ~30x ABOVE it. The pre-filter could not see either, because it was measuring a one-day snapshot of a quantity the gate medians over five years. - `screen.median_daily_quote_volume` is now the ONE definition of the statistic. `cli._market_facts` had it inline; a second copy next to `discover` would drift, and the symptom of that drift is exactly the bug being fixed here. - `assets discover --probe-liquidity` samples 180 days of daily candles per candidate (one extra request, same cost model as `--probe-history`), computes that same statistic, and marks it LOW/ok against `ScreenPolicy.min_median_daily_volume` -- the same floor `screen_asset` applies. Against the live venue it catches BICO (36,137, LOW) plus two the old filter would have passed through: IMU (71,713) and GWEI (440,864). Three of the top fourteen by 24h volume are sub-floor on the real criterion. DOGE probes 14,643,369 -> ok. It is an ESTIMATOR, not a verdict, and the output says so: 180 days recent vs the screen's full cached history. ZEC probes 33.1M against a full-history median of 1.23M -- a 27x spread from a recent surge. `ok` means "worth pulling candles for", never "will be admitted"; the gate still decides. A recent window also makes a false LOW unlikely for a currently-active asset, which is the safe direction for a pre-filter. No behaviour change to the gate itself: same criterion, same floor, same verdicts. Co-Authored-By: Claude Opus 5 (1M context) --- keel/cli.py | 59 ++++++++++++++++++++++++++--- keel/compliance/screen.py | 23 +++++++++++ tests/compliance/test_assets_cli.py | 55 +++++++++++++++++++++++++++ tests/compliance/test_screen.py | 40 +++++++++++++++++++ 4 files changed, 172 insertions(+), 5 deletions(-) diff --git a/keel/cli.py b/keel/cli.py index 075b58d6..fa263e62 100644 --- a/keel/cli.py +++ b/keel/cli.py @@ -500,16 +500,24 @@ def assets_group() -> None: """Allowlist admission screening (KB §28.4/§65.5) -- a CURATION gate, not a per-trade rail.""" +#: Days of recent daily candles `--probe-liquidity` samples per candidate. Deliberately a RECENT +#: window rather than the full history the screen medians over: the probe's job is to be one cheap +#: request that tracks the criterion, not to reproduce it exactly. Recent is also the conservative +#: direction for a *pre-filter* — it reflects the liquidity a new position would actually meet. +_LIQUIDITY_PROBE_DAYS = 180 + + def _market_facts(repo: Repository, product: str, quote: str) -> screen_mod.MarketFacts: """Everything the screen can compute for itself from data we already hold.""" asset = product.split("-")[0] candles = repo.get_candles(product, Granularity.ONE_DAY) - volumes = sorted(c.volume * c.close for c in candles) - median = volumes[len(volumes) // 2] if volumes else Decimal(0) return screen_mod.MarketFacts( asset=asset, daily_bars=len(candles), - median_daily_volume=median, + # `screen_mod.median_daily_quote_volume` is the ONE definition of this statistic -- + # `assets discover --probe-liquidity` pre-filters on the same call, so the sweep and the + # gate cannot disagree about what "liquid enough" means. + median_daily_volume=screen_mod.median_daily_quote_volume(candles), # A REAL check: does this product settle in the currency this deployment trades in? # The former `or bool(candles)` fallback made it vacuous -- every screened product is # `-USD`, so it always fell through to "do we have bars", which the history criterion @@ -703,10 +711,25 @@ def assets_holdings(ctx: click.Context, min_balance: str, run_screen: bool) -> N "candidate that fails this can never clear the screen, so probing first avoids spending " "attestation effort on it.", ) +@click.option( + "--probe-liquidity", + is_flag=True, + default=False, + help="One extra request per candidate: sample recent daily candles and compute the SAME " + "median-quote-volume statistic the admission screen applies. The default 24h filter is a " + "one-day snapshot and can sit 100x above or 6x below an asset's typical day, so a candidate " + "can clear the sweep and then fail the gate on liquidity (or nearly be dropped when it would " + "have passed comfortably). Estimator, not a verdict -- see the note below the table.", +) @click.pass_context @with_disclaimer def assets_discover( - ctx: click.Context, quote: str | None, min_volume_24h: str, limit: int, probe_history: bool + ctx: click.Context, + quote: str | None, + min_volume_24h: str, + limit: int, + probe_history: bool, + probe_liquidity: bool, ) -> None: """PROPOSE allowlist candidates from venue metadata. Admits nothing. @@ -731,11 +754,17 @@ def assets_discover( f"(quote={policy.quote_currency}, 24h volume >= {policy.min_quote_24h_volume:,.0f}, " f"excluding the current allowlist)\n" ) + screen_policy = screen_mod.ScreenPolicy() header = f"{'#':>3} {'product':<14} {'asset':<8} {'24h quote volume':>18}" - click.echo(header + (" 4yr? name" if probe_history else " name")) + if probe_history: + header += " 4yr?" + if probe_liquidity: + header += f" {'median daily (probe)':>21}" + click.echo(header + " name") now_ts = int(time.time()) four_years_ago = now_ts - 4 * _DAYS_PER_YEAR * 86400 + liquidity_window_start = now_ts - _LIQUIDITY_PROBE_DAYS * 86400 for index, candidate in enumerate(candidates[:limit], start=1): line = ( f"{index:>3} {candidate.product_id:<14} {candidate.asset:<8} " @@ -753,6 +782,18 @@ def assets_discover( except Exception: # noqa: BLE001 -- a probe failure is unknown, not a verdict marker = "? " line += f" {marker}" + if probe_liquidity: + try: + sampled = client.get_candles( + candidate.product_id, Granularity.ONE_DAY, liquidity_window_start, now_ts + ) + median = screen_mod.median_daily_quote_volume(sampled) + # Same comparison `screen_asset` will make, against the same floor -- that is the + # entire point. A candidate marked LOW here is one the gate would reject. + verdict = "LOW" if median < screen_policy.min_median_daily_volume else "ok " + line += f" {median:>17,.0f} {verdict}" + except Exception: # noqa: BLE001 -- same rule as the history probe: unknown, not a no + line += f" {'?':>17} ? " click.echo(line + f" {candidate.base_name}") click.echo( @@ -760,6 +801,14 @@ def assets_discover( "or backing -- those cannot be derived from market data. Each one needs " "`keel assets attest` with a source before `keel assets screen` can admit it." ) + if probe_liquidity: + click.echo( + f"\n'median daily (probe)' is the SAME statistic the screen applies (median of " + f"volume x close), floor {screen_policy.min_median_daily_volume:,.0f} -- but sampled " + f"over the last {_LIQUIDITY_PROBE_DAYS} days, where the screen medians over all " + "cached history. Treat LOW as 'the gate will reject this' and ok as 'worth pulling " + "candles for', never as the verdict itself. Run `keel assets screen` for that." + ) @assets_group.command("screen") diff --git a/keel/compliance/screen.py b/keel/compliance/screen.py index 89577bae..4b7f20e7 100644 --- a/keel/compliance/screen.py +++ b/keel/compliance/screen.py @@ -25,6 +25,7 @@ from collections.abc import Mapping, Sequence from dataclasses import dataclass, field from decimal import Decimal +from typing import Any from keel_core.products import parse_spot_product_id @@ -364,6 +365,28 @@ class DiscoveryPolicy: min_quote_24h_volume: Decimal = Decimal("5000000") +def median_daily_quote_volume(candles: Sequence[Any]) -> Decimal: + """Median of `volume * close` over `candles` -- the liquidity statistic, defined ONCE. + + QUOTE volume, not base: `Candle.volume` is in base units, so a bar's contribution is scaled + by its own close. That makes the number comparable across assets and comparable to the + venue's reported quote volume. + + **Both callers must use this.** `cli._market_facts` feeds it to `screen_asset`'s liquidity + criterion; `assets discover --probe-liquidity` uses it to pre-filter on the SAME statistic + the gate will later apply. A second copy next to whichever caller needed it would drift, and + the symptom of that drift is the one this function exists to prevent: a sweep that proposes + an asset the screen then rejects on liquidity, or quietly drops one it would have admitted. + + Empty input is `Decimal(0)` -- no bars is no evidence of liquidity, and the criterion treats + 0 as failing, which is the fail-closed direction. + """ + if not candles: + return Decimal(0) + volumes = sorted(candle.volume * candle.close for candle in candles) + return volumes[len(volumes) // 2] + + def discover_candidates( products: list[dict], policy: DiscoveryPolicy | None = None, diff --git a/tests/compliance/test_assets_cli.py b/tests/compliance/test_assets_cli.py index 7e57d21f..cccc98db 100644 --- a/tests/compliance/test_assets_cli.py +++ b/tests/compliance/test_assets_cli.py @@ -1181,3 +1181,58 @@ def test_propose_human_output_ends_with_the_disclaimer(tmp_path, valid_config_pa "assets", "propose", "--from", str(shortlist)], ) assert DISCLAIMER in result.output + + +class _VolumeVenue(_FakeVenue): + """Serves canned daily candles per product so the liquidity probe has something to measure.""" + + def __init__(self, products, quote_volume_for): + super().__init__(products) + self._quote_volume_for = quote_volume_for + + def get_candles(self, product_id, granularity, start, end): + self.probe_calls.append(product_id) + per_bar = self._quote_volume_for.get(product_id) + if per_bar is None: + return [] + return [ + Candle( + ts=i * _DAY, + open=Decimal("1"), + high=Decimal("1"), + low=Decimal("1"), + close=Decimal("1"), + volume=Decimal(per_bar), + ) + for i in range(30) + ] + + +def test_probe_liquidity_flags_a_candidate_whose_24h_snapshot_beats_its_median( + tmp_path, valid_config_path, monkeypatch +): + """The BICO case: one spike day clears the sweep's floor while the typical day fails the gate. + + BICO was shortlisted 2026-08-08 on a reported $12.81M/24h and then rejected by the screen at + a median daily volume of 108,004 -- 9x under the floor. The sweep and the gate were measuring + different statistics, so the pre-filter could not see it. + """ + db_path = tmp_path / "t.db" + _repo_at(db_path) + venue = _VolumeVenue( + [_venue_product("BICO-USD", "12810000"), _venue_product("SOL-USD", "50000000")], + quote_volume_for={"BICO-USD": "108004", "SOL-USD": "40000000"}, + ) + monkeypatch.setattr(cli_module, "_build_broker", lambda config: venue) + + result = CliRunner().invoke( + cli, + ["--db", str(db_path), "--config", str(valid_config_path), + "assets", "discover", "--probe-liquidity"], + ) + + assert result.exit_code == 0, result.output + bico_line = next(ln for ln in result.output.splitlines() if "BICO-USD" in ln) + sol_line = next(ln for ln in result.output.splitlines() if "SOL-USD" in ln) + assert "LOW" in bico_line, bico_line + assert "LOW" not in sol_line, sol_line diff --git a/tests/compliance/test_screen.py b/tests/compliance/test_screen.py index 32c28b75..60762788 100644 --- a/tests/compliance/test_screen.py +++ b/tests/compliance/test_screen.py @@ -533,3 +533,43 @@ def test_missing_history_lines_dedupes_and_sorts_tags(): ], ) assert lines[2] == "not assessable until then: history, liquidity" + + +# -- median_daily_quote_volume: the ONE definition of the liquidity statistic ------------------ + + +def _vol_candle(volume: str, close: str): + from keel.types import Candle + + return Candle( + ts=0, + open=Decimal(close), + high=Decimal(close), + low=Decimal(close), + close=Decimal(close), + volume=Decimal(volume), + ) + + +def test_median_daily_quote_volume_is_the_median_of_volume_times_close(): + """Quote volume, not base: a bar's contribution is `volume * close`. + + `discover`'s pre-filter and `screen`'s criterion must compute the SAME statistic or the + sweep silently proposes assets the gate then rejects. This is that one definition. + """ + from keel.compliance.screen import median_daily_quote_volume + + candles = [ + _vol_candle(volume="10", close="1"), # 10 + _vol_candle(volume="10", close="100"), # 1000 + _vol_candle(volume="10", close="10"), # 100 <- median + ] + + assert median_daily_quote_volume(candles) == Decimal("100") + + +def test_median_daily_quote_volume_of_no_candles_is_zero(): + """No bars is not high liquidity -- it is no evidence, and the screen treats 0 as failing.""" + from keel.compliance.screen import median_daily_quote_volume + + assert median_daily_quote_volume([]) == Decimal(0)