943 lines
48 KiB
Markdown
943 lines
48 KiB
Markdown
# S/R levels: detection quality, the target exit, and the entry gate
|
||
|
||
**Date:** 2026-07-12
|
||
**Question that started it:** are our support/resistance levels built the way best
|
||
practice says they should be, and do we actually use them that way?
|
||
|
||
Final answer: one level model should not serve two different jobs. The clean
|
||
**Structural S/R** detector now supplies persisted chart and alert structure.
|
||
The transient **Gate Target Ladder** preserves the broad historical-price
|
||
traffic proposals that the setup screen depends on. Its headline target affects
|
||
entry qualification only; honoring it as a take-profit is decisively worse.
|
||
The final volume-free implementation reproduced the production candidate set
|
||
and portfolio exactly. The sections below retain the investigation that led to
|
||
that split.
|
||
|
||
---
|
||
|
||
## 1. How the levels are built today
|
||
|
||
> **Update (2026-07-12 detector rewrite):** several gaps below were addressed in
|
||
> `sr_service` / `indicator_service` — close-bin VP, local-peak HVN, POC/VAH/VAL
|
||
> as candidates, LVN dropped from S/R, pivot prominence + lookbacks, rejection-
|
||
> weighted recency strength, ATR-adaptive merge, hard cap, round numbers. The
|
||
> table documents the *pre-rewrite* failure modes measured on the snapshot; keep
|
||
> it for historical context. Re-measure density on prod after deploy if gate
|
||
> rates shift.
|
||
|
||
`app/services/sr_service.py::detect_sr_levels` (post-rewrite):
|
||
|
||
1. Candidates = VP **POC / VAH / VAL / local HVN peaks** (lookback 252) +
|
||
**prominent** swing pivots (lookback 504) + nearby **round numbers**.
|
||
2. Strength = rejection-weighted touches on last 252 bars with recency decay
|
||
(pass-throughs down-weighted); method base + confluence on merge.
|
||
3. Nearby levels merged with **ATR-adaptive** tolerance (clamped ~0.4–1.5%);
|
||
capped (~16, interleaved S/R); tagged `support` if below spot, else `resistance`.
|
||
|
||
### Where the pre-rewrite detector departed from best practice
|
||
|
||
Measured on `backtest_snapshots/prod.sqlite` (AAPL, 1261 bars, spot $308.63):
|
||
|
||
| Gap | Evidence |
|
||
|---|---|
|
||
| **POC / VAH / VAL are computed then discarded.** `sr_service` reads only `hvn`/`lvn`. The canonical volume-profile levels never become S/R. | POC $148.32, VAH $230.45 — unused |
|
||
| **HVN = "any bin above the mean"**, so nearly every bin is a candidate. A real HVN is a *local peak* in the histogram. | 8 of 20 bins HVN, other 12 LVN → 73 levels, median spacing $2.44 (0.79% of spot) — a price grid, not detected structure |
|
||
| **HVN and LVN are scored and used identically**, though they encode opposite dynamics (acceptance vs. rejection). | both appended as plain candidates |
|
||
| **Volume is double-counted**: a bar's full volume is added to *every* bin it spans rather than distributed. | binned total = 1.48× true volume |
|
||
| **"Touch" = level fell inside the bar's range** — a pass-through counts the same as a rejection. Strength therefore measures *how central a price is in the 5-year range*, not how often price reversed there. | 21 of 73 levels pin at exactly 100 → the `sr_strength` magnet in the probability model is near-constant |
|
||
| **No recency decay, unbounded lookback.** | 35 AAPL "support" levels sit >35% below spot |
|
||
| **Round-number levels absent** — the mechanism with the best empirical support ([Osler 2000](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=888805)). | not implemented |
|
||
|
||
Not a defect: the pivot `window=2` is the standard 5-bar Williams fractal. What it
|
||
lacked pre-rewrite is a **prominence filter** — AAPL yielded 338 pivots over 1261
|
||
bars, one every ~3.7 bars.
|
||
|
||
### The structural problem: resistance famine
|
||
|
||
Levels are tagged relative to spot, so a stock near its highs has almost nothing
|
||
above it. Across all 504 tickers in the snapshot, grouped by proximity to the
|
||
52-week high:
|
||
|
||
| Population | Median resistance levels | % with <3 |
|
||
|---|---|---|
|
||
| **≥98% of 52w high — what the momentum gate buys** | **1** | **64%** |
|
||
| 90–98% | 7 | 12% |
|
||
| <90% | 22 | 0% |
|
||
|
||
AAPL at $308: **71 support levels, 2 resistance levels.** 26 tickers have *zero*.
|
||
|
||
This matters because `_build_setups_at` does `if not targets: continue` — **no
|
||
resistance above ⇒ no long setup at all**, and `<3 targets` adds a
|
||
`target-availability` conflict. The detector is structurally most blind exactly
|
||
where the momentum edge is strongest. `recommendation_service.py:521` already says
|
||
so out loud: *"price is extended near highs (no resistance target above), so no
|
||
high-conviction long setup is available."*
|
||
|
||
---
|
||
|
||
## 2. Does the target even act as an exit? No.
|
||
|
||
Production runs `paper_exit_mode = "atr_trailing"` (`DEFAULT_EXIT_MODE`), and
|
||
`_atr_trailing_close(direction, entry, init_stop, atr_multiplier, hold_days, ...)`
|
||
**does not take `target` as a parameter**. Only the non-default `mode == "target"`
|
||
branch consults it.
|
||
|
||
So the S/R target's real causal role is:
|
||
|
||
1. the **entry gate** — `rr ≥ 1.5`, primary-target `prob ≥ 20%`, `≥3 targets`, and
|
||
no-resistance-above ⇒ no setup;
|
||
2. the **displayed** target table.
|
||
|
||
It decides *whether you enter* and plays no part in *how you exit*.
|
||
|
||
---
|
||
|
||
## 3. Experiment: should the target be honored as a take-profit? — REJECTED
|
||
|
||
Dennis's proposal: keep the 3× ATR trail, but also take profit when the S/R target
|
||
is hit. This was not representable in the simulator (`exit_policy` is one string;
|
||
the existing `"target"` policy *replaces* the trail). Added `atr_trail3_target`,
|
||
which runs both — trade ends at whichever comes first.
|
||
|
||
Identical 80/20 momentum entry and qualification for every row — the exit is the only
|
||
policy that changes. (Trade counts still vary 303–427, because exits free portfolio
|
||
slots at different times; the conclusion is robust to that.)
|
||
Report: `reports/backtest-20260712-sr-target-exit.json`
|
||
|
||
| exit policy | Sharpe | CAGR | MaxDD | trades | win% |
|
||
|---|---|---|---|---|---|
|
||
| `low20` | 2.04 | 53.2% | 21.9% | 305 | 37.7 |
|
||
| **`atr_trail3` (production)** | **2.04** | **50.4%** | **21.4%** | 320 | 37.5 |
|
||
| `hold` | 2.00 | 51.9% | 22.2% | 303 | 38.6 |
|
||
| `technical40` | 2.00 | 51.9% | 22.2% | 303 | 38.6 |
|
||
| `sma50` | 1.76 | 40.8% | 24.4% | 385 | 33.8 |
|
||
| `target` (take-profit, no trail) | 1.59 | 32.4% | 25.0% | 415 | 41.9 |
|
||
| **`atr_trail3_target` (trail + take-profit)** | **1.47** | **28.9%** | 23.5% | 427 | 40.0 |
|
||
|
||
**Decision: do not ship. The target must not become an exit.**
|
||
|
||
- Adding the take-profit to the trail: Sharpe **2.04 → 1.47**, CAGR **halved**
|
||
(50.4% → 28.9%), and drawdown got *worse* (21.4% → 23.5%). No risk compensation.
|
||
- **Win rate rose** (37.5% → 40.0%) — the tell. You win more often and earn far
|
||
less: the take-profit converts the few 5R/8R/15R runners into 1.5R wins while
|
||
every loser still costs a full −1R. Momentum's edge is that right tail.
|
||
- The combination (1.47) is worse than the take-profit alone (1.59): once upside is
|
||
capped at the target, the trail's benefit (riding a winner far past any target)
|
||
is gone but its cost (shakeouts on pullbacks) remains. Worst of both.
|
||
|
||
This confirms and extends the note at `backtest_service.py:450` — swept *fixed*
|
||
take-profits never found an interior optimum; the S/R target is no better.
|
||
|
||
Caveat: single in-sample run over full history. The effect is large (CAGR halved),
|
||
not marginal.
|
||
|
||
---
|
||
|
||
## 4. Open: is the S/R entry gate net-positive?
|
||
|
||
The target is now proven useless as an exit, so the gate is its **only**
|
||
justification — and the gate is what starves the momentum names.
|
||
|
||
Evidence *for* keeping it (`gate_ablation`, prod baseline): dropping the R:R floor
|
||
**halves per-setup expectancy**, 0.583 → 0.301 net avg R (`momentum_only` = 0.345).
|
||
Inside the tradeable pool the S/R-derived R:R floor is doing real selection — it
|
||
favors names whose nearest resistance is far away, i.e. clear air above.
|
||
|
||
But that ablation **cannot see the famine**: it re-qualifies candidates that already
|
||
exist, and starved names never enter the candidate set (`if not targets: continue`).
|
||
So "remove the floors" is the wrong test — it just reproduces the rows above.
|
||
|
||
**The right test changes target *generation***: when a direction has no S/R target,
|
||
synthesize one at k×ATR so the name becomes a candidate. One variable moves; the
|
||
previously-vetoed names now trade. Implemented behind
|
||
`BACKTEST_ATR_TARGET_FALLBACK=<k>` (k=3 matches the trail; rr = 3/1.5 = 2.0, which
|
||
clears the 1.5 floor, and an aligned momentum name lands ~34% probability, clearing
|
||
the 20% floor).
|
||
|
||
Read the result against the production baseline (`atr_trail3`, Sharpe **2.04**):
|
||
|
||
- **> 2.04** → the veto costs money; let the breakouts in.
|
||
- **≈ 2.04** → famine is a wash; a detector rewrite is cosmetic.
|
||
- **< 2.04** → the veto earns its keep by keeping us out of over-extended names, and
|
||
S/R gating is vindicated.
|
||
|
||
### Result: the veto EARNS ITS KEEP. Keep S/R in the gate.
|
||
|
||
Treatment `reports/backtest-20260712-sr-gate-ablation-treatment.json` vs control
|
||
`reports/backtest-20260711-prod-baseline.json`. Admitting the vetoed names is a big
|
||
change: **qualified setups go 1089 → 4230 (~4×)**.
|
||
|
||
Production exit (`atr_trail3`), full history:
|
||
|
||
| | Sharpe | CAGR | MaxDD | Calmar | trades |
|
||
|---|---|---|---|---|---|
|
||
| control (veto ON) | **2.04** | 50.4% | 21.4% | 2.36 | 320 |
|
||
| treatment (veto OFF) | **1.82** | **58.6%** | 21.0% | **2.79** | 404 |
|
||
|
||
Full history alone looks like a genuine trade-off — more return, more volatility,
|
||
Sharpe down but Calmar up. **The lookback split is what settles it:**
|
||
|
||
| window | control (veto ON) | treatment (veto OFF) |
|
||
|---|---|---|
|
||
| **6m** | **Sharpe 2.87, CAGR 76.6%, DD 8.1%** | Sharpe 1.50, CAGR 53.6%, **DD 15.8%** |
|
||
| **1y** | **Sharpe 2.47, CAGR 66.8%, DD 8.8%** | Sharpe 1.69, CAGR 66.0%, **DD 15.8%** |
|
||
| 3y | Sharpe 2.12, CAGR 52.3%, DD 17.7% | Sharpe 2.11, CAGR **75.9%**, DD 21.0% |
|
||
| 5y | Sharpe 1.83, CAGR 38.8%, DD 21.4% | Sharpe 1.62, CAGR 44.9%, DD 21.0% |
|
||
| all | Sharpe 2.04, CAGR 50.4%, DD 21.4% | Sharpe 1.82, CAGR 58.6%, DD 21.0% |
|
||
|
||
Per-setup expectancy: `all_floors` net avg R **0.583 → 0.280**.
|
||
|
||
**Decision: do not ship the fallback. Keep the gate as it is.** The flat 3×ATR
|
||
fallback is worse on Sharpe and on per-setup expectancy, and production needs no
|
||
further defense than that.
|
||
|
||
### But be careful what this run does and does not prove
|
||
|
||
**It does not isolate the famine hypothesis.** The fallback fires on *any* empty
|
||
`generate_targets` result — and that includes the ATR/R:R distance filters in
|
||
`TargetGenerator` (target closer than 1 ATR, or beyond `max_atr_multiple`), not just
|
||
"no resistance above." Measured at the last bar across 502 tickers, of the long
|
||
setups the fallback admits:
|
||
|
||
- **26 (35%)** have genuinely *no resistance above* — the clear-air famine case
|
||
- **49 (65%)** *do* have resistance above; the ATR/R:R filters rejected it — **a
|
||
different population entirely**
|
||
|
||
That matches the report's own tell: qualified setups exploded **1089 → 4230 (~4×)**
|
||
while candidates rose only ~16%. So the degradation may be driven mostly by that
|
||
65%, and the clear-air breakouts this investigation was *about* are a minority of
|
||
what was admitted.
|
||
|
||
**What the run actually supports:** *"a flat 3×ATR fallback for all S/R-starved
|
||
setups degrades performance."* It does **not** support the stronger claim that a
|
||
stock in clear air is a worse risk-adjusted buy, or that the veto is functioning as
|
||
an over-extension filter. That mechanism is unproven.
|
||
|
||
**The window split is also less clean than it first looks.** The verdict rests on the
|
||
two *smallest* samples — 6m (n=30) and 1y (n=72) — where Sharpe 2.87 is
|
||
noise-dominated. The statistically sturdier 3y window (n=230 → 303) shows
|
||
**equal Sharpe (2.12 vs 2.11) with substantially higher treatment CAGR (52.3% →
|
||
75.9%)**. Full-history Calmar also favors the treatment (2.79 vs 2.36). So the result
|
||
is metric- and window-dependent; only the flat-fallback rejection is solid.
|
||
|
||
**Second contamination (by design):** the fallback gives every admitted name the same
|
||
`rr = 3/1.5 = 2.0`, so there is no R:R discrimination *within* the admitted set.
|
||
|
||
**To actually test the famine**, the fallback must fire *only* when there is no
|
||
resistance above (not on ATR/R:R filter misses). That is the clear-air run below.
|
||
|
||
---
|
||
|
||
## 4b. The clean test: fire the fallback ONLY in clear air — **the veto DOES cost money**
|
||
|
||
`BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1` restricts the fallback to setups with no S/R
|
||
level ahead at all, excluding the 65% that were merely ATR/R:R distance-filter
|
||
misses. Same whole-portfolio simulation, same 10-slot book, same momentum ranking.
|
||
|
||
Report: `reports/backtest-20260712-sr-gate-ablation-clearair.json`
|
||
|
||
**Production exit (`atr_trail3`), full history:**
|
||
|
||
| arm | Sharpe | CAGR | MaxDD | Calmar | trades |
|
||
|---|---|---|---|---|---|
|
||
| control — veto ON (production) | 2.04 | 50.4% | 21.4% | 2.36 | 320 |
|
||
| blanket fallback (contaminated) | 1.82 | 58.6% | 21.0% | 2.79 | 404 |
|
||
| **clear-air-only fallback** | **2.07** | **62.3%** | **20.1%** | **3.10** | 363 |
|
||
|
||
**Strictly better than production on all three headline metrics at once** — higher
|
||
Sharpe, ~12 points more CAGR, *and* lower drawdown. Qualified setups 1089 → 2072
|
||
(vs. 4230 for the blanket version).
|
||
|
||
**By lookback** (production strategy):
|
||
|
||
| window | control (veto ON) | clear-air fallback |
|
||
|---|---|---|
|
||
| 6m (n=30→38) | Sharpe 2.87, CAGR 76.6%, DD **8.1%** | Sharpe 2.21, CAGR 78.8%, DD 13.0% |
|
||
| 1y (n=72→86) | Sharpe 2.47, CAGR 66.8%, DD **8.8%** | Sharpe 2.28, CAGR **87.6%**, DD 12.9% |
|
||
| **3y** (n=230→270) | Sharpe 2.12, CAGR 52.3%, DD 17.7% | **Sharpe 2.18, CAGR 68.6%, DD 14.3%** |
|
||
| **5y** (n=320→363) | Sharpe 1.83, CAGR 38.8%, DD 21.4% | **Sharpe 1.85, CAGR 47.6%, DD 20.1%** |
|
||
| **all** | Sharpe 2.04, CAGR 50.4%, DD 21.4% | **Sharpe 2.07, CAGR 62.3%, DD 20.1%** |
|
||
|
||
In every statistically sturdy window (3y, 5y, all) the clear-air fallback wins on
|
||
Sharpe, CAGR **and** drawdown. The short windows (6m n=30, 1y n=72 — noise-dominated)
|
||
favor control on Sharpe/DD while the treatment still earns more (1y CAGR 66.8% →
|
||
87.6%).
|
||
|
||
**This reverses §4 and confirms the hypothesis that opened the investigation.** The
|
||
S/R veto on clear-air names *was* costing money; the blanket run masked it because
|
||
the 65% loophole population (ATR/R:R filter misses) is genuinely bad and dominated
|
||
the result. Isolate the two, and they pull in opposite directions:
|
||
|
||
- clear-air names (no resistance above): **portfolio-accretive**
|
||
- ATR/R:R distance-filter misses: **portfolio-destructive**
|
||
|
||
Per-setup expectancy is consistent with this: net avg R `all_floors` — control 0.583,
|
||
clear-air 0.402, blanket 0.280. The admitted clear-air setups are individually a bit
|
||
weaker, but they carry the highest momentum ranks, so they win book slots and deliver
|
||
outsized portfolio returns.
|
||
|
||
**Caveats before shipping:** in-sample, single snapshot; the fallback still assigns a
|
||
constant `rr = 2.0` to every admitted name (no discrimination within the set). Needs
|
||
an out-of-sample run before production — see §4c, which is where it comes undone.
|
||
|
||
---
|
||
|
||
## 4c. Out-of-sample holdout — **the §4b result does NOT survive**
|
||
|
||
Everything in §4b is in-sample: the rule was chosen by looking at the same 5 years it
|
||
was then graded on. The `portfolio_monitor` lookbacks (6m/1y/3y/5y) are **not** a
|
||
holdout — they are nested windows all ending today, so each one overlaps the data the
|
||
idea came from.
|
||
|
||
Real split (`BACKTEST_HOLDOUT_SPLIT=2024-07-01`, production strategy, disjoint books):
|
||
|
||
- **train** = entries before 2024-07-01 (~3y)
|
||
- **test** = entries on/after 2024-07-01 (~2y, never informed the rule)
|
||
|
||
Reports: `reports/backtest-20260712-holdout-control.json`,
|
||
`reports/backtest-20260712-holdout-clearair.json`
|
||
|
||
| window | arm | Sharpe | CAGR | MaxDD | trades |
|
||
|---|---|---|---|---|---|
|
||
| train (2022-06 → 2024-08) | control | 1.31 | 29.6% | 21.4% | 174 |
|
||
| train | **clear-air** | **1.63** | **42.6%** | **20.1%** | 191 |
|
||
| **test** (2024-07 → 2026-07) | **control** | **2.78** | 73.3% | **11.7%** | 150 |
|
||
| **test** | clear-air | 2.45 | **83.0%** | 14.3% | 176 |
|
||
|
||
> **Harness bug, found and fixed 2026-07-12.** The train row first reported Sharpe 0.95 /
|
||
> CAGR 14.6% — wrong. Its equity curve ran to the *end of the data* while its entries
|
||
> stopped at the split, so the book sat in flat cash for two years and deflated its own
|
||
> metrics. `_simulate_portfolio` now truncates the calendar to `hold_days` after the last
|
||
> entry whenever `end_date` is set. **The verdict is unaffected** — it rests on the test
|
||
> row, whose entries and curve both start at the split and were always clean. But the
|
||
> broken numbers *looked* like a result, and nearly produced a false conclusion ("the
|
||
> first half of the sample was mediocre"). Corrected numbers above.
|
||
|
||
**In train the clear-air rule wins on every metric. Out of sample it does not.** On
|
||
the held-out two years it delivers **more raw return (+9.7pp CAGR)** but at
|
||
**lower Sharpe (2.78 → 2.45)** and **higher drawdown (11.7% → 14.3%)**.
|
||
|
||
So the §4b headline — *"strictly better on all three metrics"* — was **an in-sample
|
||
artifact.** Out of sample the rule is not a free win; it is a **risk/return trade**:
|
||
it buys extra return by taking more risk, and on a risk-adjusted basis it is slightly
|
||
*worse* than production.
|
||
|
||
**Decision: do NOT ship the clear-air fallback.** This project's decision metric is
|
||
Sharpe throughout (every ranking in the report, and the 2026-07-10 primary-target A/B
|
||
was accepted on Sharpe 1.51 → 2.00). By that standard the honest read of the only
|
||
uncontaminated evidence is *no improvement*.
|
||
|
||
Notes for anyone revisiting:
|
||
- Both arms show a large regime shift (train Sharpe ~1.3–1.6, test Sharpe ~2.5–2.8) —
|
||
the test window was simply a much better market. That is why *relative* comparison
|
||
within a window is the only valid read.
|
||
- n = 150/176 in test is decent but not large; the Sharpe gap (0.33) is not
|
||
overwhelming. This is "not confirmed," not "definitively refuted."
|
||
- The famine hypothesis is therefore **real but not exploitable as tried**: the
|
||
clear-air names do add return (consistently, in both train and test), but the flat
|
||
3×ATR target admits them at a risk cost that eats the risk-adjusted benefit. A
|
||
better target model for those names (§ next runs) is the remaining avenue.
|
||
|
||
A wholesale "ATR target for everyone" variant was deliberately *not* run as the
|
||
headline: with a fixed k×ATR target and a 1.5×ATR stop, `rr = k/1.5` is constant
|
||
across every name, which erases the very selection the 0.301 credits. A loss there
|
||
would be uninterpretable.
|
||
|
||
---
|
||
|
||
## 5. Reproducing
|
||
|
||
Both research paths are **off by default** — the default report is byte-identical to
|
||
the shipped baseline (5 exit rows, no fallback), and the full unit suite including
|
||
the backtest↔prod parity guard passes.
|
||
|
||
```bash
|
||
# Exit book incl. the rejected take-profit rows
|
||
BACKTEST_RESEARCH_EXITS=1 python scripts/run_backtest_snapshot.py \
|
||
backtest_snapshots/prod.sqlite --workers 7 --allow-spawn
|
||
|
||
# S/R gate ablation: synthesize a 3xATR target where S/R offers none
|
||
BACKTEST_ATR_TARGET_FALLBACK=3 python scripts/run_backtest_snapshot.py \
|
||
backtest_snapshots/prod.sqlite --workers 7 --allow-spawn
|
||
```
|
||
|
||
Note `--allow-spawn` is required on Windows: `_mp_context()` has no `fork`/
|
||
`forkserver` there and silently falls back to a single thread without it.
|
||
|
||
---
|
||
|
||
## 6. Standing decisions
|
||
|
||
**Measured:**
|
||
|
||
1. **The target must not be an exit.** Tested, rejected, decisively — Sharpe
|
||
2.04 → 1.47, CAGR halved. Momentum's edge is the right tail; a take-profit
|
||
truncates it. (§3)
|
||
2. **Do NOT ship the clear-air fallback — it failed out-of-sample.** In-sample it
|
||
looked strictly better (Sharpe 2.04 → 2.07, CAGR 50.4% → 62.3%, DD 21.4% → 20.1%),
|
||
but on a genuine holdout (entries after 2024-07-01, never seen by the rule) it is
|
||
**worse on Sharpe (2.78 → 2.45) and Calmar, better only on raw CAGR (+9.7pp)**. The
|
||
in-sample "free win" was an artifact. **Production gate stays as-is.** (§4b, §4c)
|
||
3. **The famine is real, but not exploitable as tried.** Clear-air names *do* add
|
||
return consistently (train and test) — the veto genuinely leaves money on the
|
||
table. But a flat 3×ATR target admits them at a risk cost that cancels the
|
||
risk-adjusted benefit. (§4c)
|
||
4. **Do NOT relax the veto indiscriminately.** The ATR/R:R distance-filter misses
|
||
(65% of a blanket fallback) are portfolio-destructive and swamp everything —
|
||
Sharpe 1.82, net avg R 0.280. The two populations pull in opposite directions and
|
||
must be separated. (§4)
|
||
|
||
**Reasoned, not measured — treat as hypotheses:**
|
||
|
||
5. **The detector's flaws probably don't reach P&L directly.** *No run ever varied
|
||
detection quality* — "good S/R vs bad S/R → P&L" has never been measured. Fix the
|
||
§1 gaps for the *displayed* levels and the UX; do not promise a return improvement.
|
||
|
||
**Method note (the expensive lesson):** the in-sample result in §4b was clean,
|
||
large, consistent across five nested windows — and still didn't survive a holdout.
|
||
Nested lookbacks are not out-of-sample. Split by entry date before believing anything.
|
||
|
||
## 7. S/R v2 research harness (implementation started 2026-07-12)
|
||
|
||
The detector rewrite is decomposed into causal, research-only arms. The live
|
||
scanner does not read `BACKTEST_SR_VARIANT`; these switches exist only in the
|
||
offline snapshot harness:
|
||
|
||
| arm | behavior |
|
||
|---|---|
|
||
| `production_control` | deployed detector plus legacy 1.5 primary selection |
|
||
| `rr_aligned_control` | deployed detector; primary selection uses activation `min_rr` |
|
||
| `rewrite` | rewritten detector with activation-aligned primary selection |
|
||
| `soft_zones` | rewrite plus max-strength/confluence zone aggregation |
|
||
| `confirmed_rounds` | soft zones; standalone rounds need two rejection clusters |
|
||
| `gate_v2` | confirmed rounds plus uncapped gate evidence |
|
||
|
||
Detector evidence (`sources`, rejection count, last rejection age) stays in the
|
||
pure backtest objects. It is deliberately not migrated into the production DB
|
||
schema until a variant passes validation.
|
||
|
||
The cross-platform matrix runner is only an orchestrator around the existing
|
||
`run_backtest_snapshot.py`; it contains no duplicate backtest logic. On macOS:
|
||
|
||
```bash
|
||
.venv/bin/python scripts/run_sr_v2_matrix.py train --workers 14
|
||
```
|
||
|
||
Choose one arm and record that lock before running exactly control and that arm:
|
||
|
||
```bash
|
||
.venv/bin/python scripts/run_sr_v2_matrix.py validate \
|
||
--locked-arm confirmed_rounds --workers 14
|
||
```
|
||
|
||
Replace `confirmed_rounds` with the recorded winner. The validation command also
|
||
calls `scripts/compare_sr_variants.py` to produce the paired cohort CSV and JSON.
|
||
|
||
> Validation result: `confirmed_rounds` is rejected and is no longer a lockable
|
||
> arm. It remains in the corrected training matrix only to preserve the causal
|
||
> experiment record. The first training reports used an entry end bound without
|
||
> forwarding it to the portfolio calendar, leaving each book in flat cash through
|
||
> the test period. The simulator now treats `BACKTEST_ENTRY_END` as an inclusive
|
||
> entry bound and truncates the calendar after the final position can resolve.
|
||
|
||
The isolated `rr_aligned_control` validation also failed (Sharpe 2.78 to 1.32,
|
||
CAGR 73.3% to 33.1%, drawdown 11.7% to 18.4%). The live 1.5 primary-selection
|
||
behavior is therefore frozen: although it predates the 2.0 activation gate, it
|
||
acts as a useful selectivity mechanism. The final detector matrix holds that
|
||
behavior constant and varies only detection/zone policy:
|
||
|
||
- `rewrite_legacy_primary`
|
||
- `soft_zones_legacy_primary`
|
||
- `confirmed_rounds_legacy_primary`
|
||
- `gate_v2_legacy_primary`
|
||
|
||
The post-2024 interval has informed earlier research, so this is validation rather
|
||
than a pristine holdout; do not sweep variants on it. No deployment follows
|
||
automatically. A lower validation Sharpe or higher drawdown remains a no-ship
|
||
result even when CAGR rises.
|
||
|
||
## 8. Final detector-only result: no rewritten gate arm advances
|
||
|
||
The last matrix froze production's effective gate (`primary min_rr=1.5`,
|
||
activation `min_rr=2.0`) and varied only level detection/zone policy on entries
|
||
through 2024-06-30. Corrected portfolio calendars end after the last position can
|
||
resolve; there is no flat-cash tail.
|
||
|
||
| arm | Sharpe | CAGR | MaxDD | qualified | net avg R | ex-top-5% |
|
||
|---|---:|---:|---:|---:|---:|---:|
|
||
| production control | **1.28** | **28.8%** | 21.4% | 676 | **0.230** | **0.066** |
|
||
| rewrite + legacy primary | 0.96 | 21.0% | 22.2% | 1,200 | 0.037 | -0.102 |
|
||
| soft zones + legacy primary | 1.08 | 25.1% | **17.8%** | 1,161 | 0.045 | -0.096 |
|
||
| confirmed rounds + legacy primary | 1.14 | 22.1% | 20.2% | 570 | 0.189 | 0.045 |
|
||
| gate v2 + legacy primary | 0.87 | 16.7% | 20.7% | 604 | 0.187 | 0.041 |
|
||
|
||
No arm advances to validation. The raw rewrite retains only 249 of 676 production
|
||
setups, removes 427 good setups, and adds 951 setups with negative expectancy.
|
||
Round confirmation repairs the added cohort but still removes 430 production
|
||
setups whose 30-day average (+0.680R) exceeds the additions (+0.511R). Uncapping
|
||
recovers only 16 of those missing setups. The old detector averages 43.3 gate
|
||
levels versus 15.0 rewritten and 24.0 rewritten-uncapped levels.
|
||
|
||
**Standing no-ship decision:** keep both the deployed detector and the legacy 1.5
|
||
primary-selection behavior in the trading path. The rewritten structure may only
|
||
proceed as a separately computed display model. Do not merge this research branch
|
||
into production as-is.
|
||
|
||
### Hidden-feature isolation
|
||
|
||
The deployed detector accidentally measures long-memory historical price traffic
|
||
rather than genuine S/R. A dedicated matrix holds the complete gate fixed and
|
||
changes one legacy component at a time:
|
||
|
||
- `legacy_geometry_neutral`: old locations, every merged strength fixed at 50;
|
||
- `legacy_pivots_only`: unfiltered full-history pivots, no VP grid;
|
||
- `legacy_traffic_grid_only`: deployed HVN+LVN grid, no pivots.
|
||
|
||
Run on macOS:
|
||
|
||
```bash
|
||
.venv/bin/python scripts/run_sr_v2_matrix.py traffic --workers 14
|
||
```
|
||
|
||
Do not validate any traffic arm yet. First establish whether geometry, pivots, or
|
||
the range-occupancy grid reproduces production on pre-2024 training data.
|
||
|
||
Corrected training result:
|
||
|
||
| arm | qualified | Sharpe | CAGR | MaxDD | net avg R | ex-top-5% |
|
||
|---|---:|---:|---:|---:|---:|---:|
|
||
| production control | 676 | 1.28 | 28.8% | 21.4% | 0.230 | 0.066 |
|
||
| corrected neutral geometry | 505 | 1.48 | 33.5% | 18.2% | 0.230 | 0.087 |
|
||
| pivots only | 717 | 1.38 | 32.9% | 25.2% | 0.236 | 0.076 |
|
||
| traffic grid only | 504 | **1.72** | **41.8%** | **16.8%** | **0.271** | **0.136** |
|
||
|
||
The traffic grid is the first research arm to beat control simultaneously on
|
||
Sharpe, CAGR, drawdown, and robust expectancy. It retains 264 production setups
|
||
at +0.214R ex-top-5%, adds 240 at +0.051R, and removes 412 at only +0.010R.
|
||
Against corrected neutral geometry, only 239 qualified setups overlap; the 265
|
||
traffic-only setups return +0.140R ex-top-5% versus +0.073R for the 266
|
||
neutral-only setups. This is different selection, not merely a lower trade count.
|
||
|
||
The old `volume_profile` name is misleading. Its helper returns both HVN and LVN
|
||
bins, so their union retains almost every one of the 20 evenly spaced centers over
|
||
the expanding historical high-low range (19.987 levels on average in the audit).
|
||
The later strength calculation does not use volume; it counts bars whose ranges
|
||
cross each center. The candidate feature is therefore:
|
||
|
||
1. a normalized, expanding 20-bin price-range grid;
|
||
2. range-touch occupancy strength;
|
||
3. no full-history pivot ladder or pivot/grid strength saturation.
|
||
|
||
Two final training arms isolate the first two items explicitly:
|
||
|
||
- `legacy_range_grid_touch`: all 20 range centers, no volume calculation, legacy
|
||
touch strength;
|
||
- `legacy_range_grid_neutral`: identical centers, strength fixed at 50 after
|
||
clustering.
|
||
|
||
Run only these new arms on macOS:
|
||
|
||
```bash
|
||
.venv/bin/python scripts/run_sr_v2_matrix.py traffic \
|
||
--only-arm legacy_range_grid_touch --workers 14
|
||
|
||
.venv/bin/python scripts/run_sr_v2_matrix.py traffic \
|
||
--only-arm legacy_range_grid_neutral --workers 14
|
||
```
|
||
|
||
The touch arm reproduced `legacy_traffic_grid_only` exactly: all 121,464
|
||
candidates, 504 qualified setups, cohort membership, expectancy, and portfolio
|
||
metrics match. Volume contributes nothing. Neutral strength won the training
|
||
portfolio comparison (Sharpe 1.98 versus 1.72), but failed the locked validation:
|
||
|
||
| validation arm | Sharpe | CAGR | MaxDD | net avg R | ex-top-5% |
|
||
|---|---:|---:|---:|---:|---:|
|
||
| production control | **2.78** | **73.3%** | **11.7%** | 0.174 | 0.022 |
|
||
| neutral range grid | 1.85 | 43.2% | 15.2% | **0.178** | **0.039** |
|
||
|
||
The neutral grid is a no-ship. The validation failure prompted a causal audit of
|
||
the control rather than another detector sweep. One relationship survives both
|
||
periods: dense legacy ladders are a proxy for a wide multiplicative price range.
|
||
|
||
| control cohort | training ex-top-5% | validation ex-top-5% |
|
||
|---|---:|---:|
|
||
| at least 70 legacy levels | +0.165R | +0.185R |
|
||
| fewer than 70 levels | +0.004R | -0.379R |
|
||
|
||
Level count is not independently useful after controlling for the last 504
|
||
trading days' range. For `log(max(high) / min(low)) >= 1.0315` (about a 2.8x
|
||
high/low ratio), the overlap cohort returns +0.292R training and +0.322R
|
||
validation ex-top-5%. High density without high range returns -0.096R and
|
||
+0.029R. Correlation between the explicit range and legacy level count is 0.864
|
||
training and 0.822 validation.
|
||
|
||
This isolates the hidden feature as a two-year realized price-excursion factor,
|
||
accidentally encoded by how many full-history pivots survive a 0.5% merge. It is
|
||
not evidence that the arbitrary lines are structural. A rounded threshold of
|
||
`log range >= 1.0` remains positive across a 0.9/1.0/1.1 sensitivity plateau.
|
||
|
||
Two diagnostic-only arms now test whether the explicit scalar replaces the side
|
||
effect:
|
||
|
||
- `production_range504`: deployed targets plus the explicit range gate;
|
||
- `rewrite_range504_legacy_primary`: clean targets, frozen primary selection,
|
||
plus the identical range gate.
|
||
|
||
Run on pre-2024 training data only:
|
||
|
||
```bash
|
||
.venv/bin/python scripts/run_sr_v2_matrix.py factor --workers 14
|
||
```
|
||
|
||
Result:
|
||
|
||
| training arm | qualified | Sharpe | CAGR | MaxDD | net avg R | ex-top-5% |
|
||
|---|---:|---:|---:|---:|---:|---:|
|
||
| production control | 676 | 1.28 | 28.8% | 21.4% | 0.230 | 0.066 |
|
||
| production + range504 | 180 | **1.65** | 31.4% | **15.9%** | **0.476** | **0.250** |
|
||
| clean rewrite | 1,200 | 0.96 | 21.0% | 22.2% | 0.037 | -0.102 |
|
||
| clean rewrite + range504 | 291 | 1.46 | **31.5%** | 18.4% | 0.292 | 0.158 |
|
||
|
||
The scalar recovers most of the detector rewrite's regression. The clean arm now
|
||
beats the original production baseline on Sharpe, CAGR, drawdown, and robust
|
||
expectancy. Old target geometry retains a smaller edge over the equally filtered
|
||
clean arm.
|
||
|
||
The residual cohort is target selection, not another range feature. The clean arm
|
||
retains 63 exceptionally strong common setups (+0.665R ex-top-5%), adds 228 weak
|
||
setups (+0.018R), and misses 117 strong old-geometry setups (+0.175R). Of the
|
||
clean-only additions, 156 have a standalone round-number primary; all 162
|
||
round-only qualified setups have fewer than two observed rejections and return
|
||
-0.024R ex-top-5%. Structural-only and round-confluent primaries return +0.394R
|
||
and +0.369R.
|
||
|
||
For 113 of the 117 missed setups, the clean detector does produce a target, but
|
||
its selected primary averages 1.56R; 77 land in the 1.5-2R veto band. A final
|
||
two-arm residual matrix therefore holds the clean detector and range factor fixed:
|
||
|
||
- `rewrite_range504_structural_legacy_primary`: exclude standalone round targets,
|
||
retain the deployed 1.5 primary floor;
|
||
- `rewrite_range504_structural_primary2`: identical, but select the primary from
|
||
targets clearing 2.0R.
|
||
|
||
```bash
|
||
.venv/bin/python scripts/run_sr_v2_matrix.py residual --workers 14
|
||
```
|
||
|
||
### Full-period production comparison
|
||
|
||
After freezing `rewrite_range504_structural_legacy_primary`, run it beside a
|
||
fresh `production_control` over the complete snapshot with identical portfolio
|
||
and exit settings:
|
||
|
||
```bash
|
||
.venv/bin/python scripts/run_sr_v2_matrix.py full --workers 14
|
||
```
|
||
|
||
The command deliberately supplies neither `--entry-start` nor `--entry-end`.
|
||
It writes both audited reports plus a paired cohort comparison:
|
||
|
||
- `reports/backtest-sr-full-production_control.json`
|
||
- `reports/backtest-sr-full-rewrite_range504_structural_legacy_primary.json`
|
||
- `reports/sr-full-production-vs-candidate-cohorts.csv`
|
||
- `reports/sr-full-production-vs-candidate-comparison.json`
|
||
|
||
This is an apples-to-apples full-history diagnostic against the current live
|
||
production path. It is not a new untouched holdout because the post-2024 data
|
||
was already inspected while isolating the range factor.
|
||
|
||
Full-period results reject the clean range-gated candidate as a replacement:
|
||
it improves robust setup expectancy and drawdown, but cuts the qualified set
|
||
from 1,086 to 290 and the live-path book from 321 to 170 trades. Its 73 unique
|
||
qualified symbols are also concentrated (36.2% of setups in the top ten names),
|
||
so overlapping setup expectancy does not translate into independent portfolio
|
||
opportunity.
|
||
|
||
### Structural confirmation as a ranking overlay
|
||
|
||
The next experiment preserves the production detector, setup geometry,
|
||
qualified universe, activation gate, and live exit. For each production setup,
|
||
the clean detector is evaluated point-in-time only to attach a binary feature:
|
||
whether `rewrite_range504_structural_legacy_primary` also clears its core gate.
|
||
That confirmation receives a single pre-registered 5% weight:
|
||
|
||
```text
|
||
overlay_rank = 95% * production_80_20_rank + 5% * structural_confirmation
|
||
```
|
||
|
||
There is deliberately no weight sweep and no union with clean-only setups. The
|
||
report must first reproduce the production qualified count and production book;
|
||
otherwise the comparison is invalid. Run the one-arm full-period diagnostic:
|
||
|
||
```bash
|
||
.venv/bin/python scripts/run_sr_v2_matrix.py overlay --workers 14
|
||
```
|
||
|
||
Output: `reports/backtest-sr-overlay-full.json`. The candidate advances only if
|
||
the overlay improves full-period Sharpe, does not worsen drawdown, and retains
|
||
at least 90% of production CAGR. The 1-year and 6-month rows must not both
|
||
deteriorate. This remains contaminated full-history research, not promotion
|
||
validation.
|
||
|
||
Result: **reject the overlay**. Production parity is exact, but the overlay
|
||
reduces full-period Sharpe from 2.03 to 2.00 and CAGR from 50.0% to 48.4%; max
|
||
drawdown improves from 21.4% to 20.0%. Confirmation has real standalone quality
|
||
(+0.263R ex-top-5% versus +0.024R), but little marginal ranking value: confirmed
|
||
setups already average production rank 92.3 and 46.1% come from ten symbols. The
|
||
5% boost changes ordering on only 45 active dates and overweights the same
|
||
concentrated names.
|
||
|
||
### Explicit gate target ladder
|
||
|
||
The legacy detector's volume-profile label is misleading. It returns both HVN
|
||
and LVN bins, whose union is the complete 20-bin price-range grid, then adds
|
||
unfiltered pivots and touch strength. For the gate this behaves as a broad
|
||
target-proposal ladder, not human-facing support/resistance.
|
||
|
||
**Component name: Gate Target Ladder (GTL).** "Structural S/R" names the
|
||
separate, persisted human-facing model. "Gate Target Ladder" names this
|
||
transient screening component and avoids implying that its dense proposals are
|
||
real support/resistance.
|
||
|
||
#### Runtime decision flow
|
||
|
||
```mermaid
|
||
flowchart TD
|
||
O["Ticker OHLCV history"] --> STRUCT["Structural S/R<br/>clean detector"]
|
||
STRUCT --> STORE[("Persist SRLevel")]
|
||
STORE --> HUMAN["Charts and alerts"]
|
||
|
||
O --> LADDER["Gate Target Ladder<br/>20 range centers + 5-bar pivots"]
|
||
LADDER --> SCORE["Count historical price traffic<br/>strength + 0.5% merge + side tag"]
|
||
SCORE --> SCAN{"Directional proposal<br/>with R:R ≥ 1.5?"}
|
||
SCAN -->|no| NOSETUP["No setup for that direction"]
|
||
SCAN -->|yes| ZONES["Cluster 2% target zones<br/>use reachable near edge"]
|
||
ZONES --> FILTER["ATR-distance filter<br/>retain up to 5 near-to-far candidates"]
|
||
FILTER --> PROB["Estimate target-before-stop<br/>reach probability"]
|
||
PROB --> PRIMARY["Headline = most likely candidate<br/>clearing R:R ≥ 1.5 and probability ≥ 20%"]
|
||
PRIMARY --> ACTIVATE{"Activation gate<br/>headline R:R ≥ 2.0<br/>probability ≥ 20%<br/>momentum/direction pass?"}
|
||
ACTIVATE -->|no| OBS["Store as unqualified observation"]
|
||
ACTIVATE -->|yes| QUAL["Eligible for production book"]
|
||
QUAL --> EXIT["ATR stop/trail or max hold<br/>target is never an exit"]
|
||
```
|
||
|
||
Step by step:
|
||
|
||
1. `scan_ticker` loads the ticker's OHLCV history and computes the 1.5× ATR
|
||
initial stop. It does not query persisted `SRLevel` rows for targets.
|
||
2. `detect_gate_target_ladder` creates 20 evenly spaced centers over the
|
||
observed low/high range and adds unfiltered five-bar swing highs/lows. The
|
||
implementation performs no volume calculation.
|
||
3. Each proposal is scored by the share of historical bars whose range crosses
|
||
it. Proposals within 0.5% are merged, their traffic strengths combine, and
|
||
they are tagged support/resistance relative to the latest close. The scanner
|
||
materializes them with negative transient IDs; they are never persisted.
|
||
4. A direction exists only when at least one proposal clears the scanner's 1.5
|
||
R:R floor. This is setup construction, not the later live activation gate.
|
||
5. The recommendation layer clusters proposals into 2% target zones, uses each
|
||
zone's reachable near edge, removes unsuitable ATR distances, and keeps up
|
||
to five candidates spanning near, moderate and far distances.
|
||
6. Each retained candidate gets a target-before-stop reach probability based on
|
||
distance, R:R, traffic strength and signal alignment.
|
||
7. The headline target is the most likely candidate clearing both R:R ≥ 1.5
|
||
and probability ≥ 20%. If none does, the most likely target overall remains
|
||
headline so a distant high-R:R lottery target cannot game qualification.
|
||
8. The separate live gate then requires headline R:R ≥ 2.0 and probability ≥
|
||
20%, plus the residual-momentum and direction rules. A traded setup still
|
||
exits only through the ATR stop/trail or maximum hold.
|
||
|
||
#### Ticker-chart diagnostic
|
||
|
||
The optional **GTL traffic** overlay uses a volume-profile-like layout because
|
||
price-axis bars make the ladder's density easy to read. The comparison stops at
|
||
the layout:
|
||
|
||
| Profile | Bar width measures | Valid interpretation |
|
||
|---|---|---|
|
||
| Volume profile | Traded volume assigned to a price bin | Where trading activity was accepted |
|
||
| GTL price traffic | Relative count of historical OHLCV bars crossing a GTL proposal | How strongly the legacy gate geometry revisited that price |
|
||
|
||
The chart renders GTL traffic as violet bars extending left from the current
|
||
price axis. It includes only proposals inside the displayed price range, so old
|
||
far-away ladder levels do not compress the candles. Hover reveals price,
|
||
crossings, capped strength, side and source. The overlay is off by default,
|
||
loaded only on demand from `GET /gate-target-ladder/{symbol}`, and must remain
|
||
visually distinct from persisted Structural S/R.
|
||
|
||
The `explicit_target_ladder` arm therefore replaces only the irrelevant volume
|
||
pass with the complete range grid. It retains pivots, touch strength, merge
|
||
geometry, primary selection, qualification, ranking, and exit behavior. Grid
|
||
levels are labelled `range_grid`, making the internal purpose explicit.
|
||
|
||
```bash
|
||
.venv/bin/python scripts/run_sr_v2_matrix.py ladder --workers 14
|
||
```
|
||
|
||
The arm advances only on exact parity with the full-period production control:
|
||
no added or removed qualified setups and identical production-book Sharpe,
|
||
CAGR, drawdown, and trade count. Passing parity supports a dual-purpose design:
|
||
clean structure for charts and alerts, explicit target ladder for the gate.
|
||
Failing parity means the supposedly irrelevant volume pass still affects an
|
||
edge case and must be located before any architectural change.
|
||
|
||
Result: **exact parity**. Both arms produce 1,086 qualified setups from 202,765
|
||
candidates, with 1,086 retained, zero added, and zero removed. Both production
|
||
books have Sharpe 2.03, CAGR 50.0%, max drawdown 21.4%, and 321 trades. The
|
||
retained cohort is +0.2086R net average and +0.0492R after removing the top 5%.
|
||
This proves that neither volume nor the `volume_profile` interpretation is part
|
||
of the deployed edge.
|
||
|
||
Implementation decision: keep the clean `detect_sr_levels` output persisted as
|
||
human-facing S/R for charts and alerts. The scanner instead builds
|
||
`detect_gate_target_ladder` directly from its OHLCV window, materializes it only
|
||
for the current scan, and never writes those proposal levels to `SRLevel`. The
|
||
primary-target selector retains its independently researched 1.5 floor; the
|
||
later live activation gate remains 2.0, and the ATR-trailing exit is unchanged.
|
||
The `explicit_target_ladder` backtest arm calls the same pure helper as the live
|
||
scanner, so the final full-period rerun is an implementation-parity check rather
|
||
than another detector experiment.
|
||
|
||
Final implementation-parity result after commit `8161c35`: **pass**. The
|
||
regenerated full-period report differs from the pre-integration parity report
|
||
only in `generated_at`; all 202,765 candidates, 1,086 qualified setups, cohort
|
||
statistics, and portfolio results are unchanged. This closes the local
|
||
backtest gate for the dual-purpose implementation. It does not itself authorize
|
||
or perform a production deployment.
|
||
|
||
#### GTL tuning matrix
|
||
|
||
Exact parity establishes a safe, explicit control, but it does not prove that
|
||
the inherited GTL constants are optimal. The `gtl_tuning` backtest arm exposes
|
||
only those constants to an offline configuration; the live scanner continues
|
||
to call the frozen default helper and cannot read this research configuration.
|
||
|
||
The single-command matrix contains 20 full-period arms. Each non-control arm
|
||
changes exactly one input:
|
||
|
||
| Knob | Frozen control | Alternatives | Question isolated |
|
||
|---|---:|---|---|
|
||
| History | All available bars | 252 / 504 / 756 bars | Is recent or long-cycle range geometry useful? |
|
||
| Candidate cap | 5 | 8 / unlimited | Does early pruning discard the useful headline? |
|
||
| Max target distance | Existing volatility-dependent rule | 5.5 / 8 ATR universally | Is the medium-volatility unlimited branch the hidden edge? |
|
||
| Traffic touch padding | 0.5% | 0 / 0.25% | Does padded price traffic carry information? |
|
||
| Proposal merge | 0.5% | 0.25% / 1% | Is proposal density or consolidation important? |
|
||
| Target zones | 2% | 1% / 3% | Does the reachable near edge manufacture the gate geometry? |
|
||
| Range centers | 20 | 12 / 32 | Is coarse ladder density the useful feature? |
|
||
| Pivots | Five-bar swings | None / eleven-bar swings | Do pivots add anything beyond the range ladder? |
|
||
| Traffic strength scale | 500 | 250 / 1000 | Does strength saturation affect probability/selection? |
|
||
|
||
```bash
|
||
.venv/bin/python scripts/run_gtl_tuning_matrix.py \
|
||
backtest_snapshots/prod.sqlite --workers 14
|
||
```
|
||
|
||
Arms execute sequentially so multiprocessing pools never compete. Each arm
|
||
produces full-period production metrics, train/test books split at 2024-07-01,
|
||
robust expectancy after removing the top 5% of setups, and retained/added/
|
||
removed cohorts against control. The consolidated JSON and Markdown table are
|
||
checkpointed after every arm; successful runs delete temporary per-arm reports
|
||
unless `--keep-arm-reports` is set.
|
||
|
||
The pre-registered screen requires all of the following versus control:
|
||
full/train/test Sharpe not worse, full-period drawdown not worse, at least 80%
|
||
of production trades retained, and positive qualified expectancy after removing
|
||
the top 5%. Passing identifies a candidate for forward paper validation, not an
|
||
automatic deployment. The post-2024 interval has already influenced this
|
||
research, so the split is a robustness check rather than a pristine holdout.
|
||
|
||
Result on the 2026-07-13 snapshot: **20/20 arms completed; no replacement arm
|
||
passed all six checks.** The frozen control remained best on full-period Sharpe
|
||
(2.03), CAGR (50.0%), and post-2024 Sharpe (2.78), with 321 production trades
|
||
and 21.4% drawdown. The closest replacement, 0.25% touch padding, still fell to
|
||
Sharpe 1.94 / CAGR 47.9%. This rejects direct constant replacement; the inherited
|
||
behavior is not explained by one obvious GTL knob.
|
||
|
||
The paired cohorts do expose a narrower mechanism worth testing:
|
||
|
||
| Variant | Retained control setups | Added by variant | Removed from control |
|
||
|---|---|---|---|
|
||
| 0.25% touch | 1,049 at +0.214R (+0.058 ex-top-5%) | 18 at -0.235R | 37 at -0.056R |
|
||
| Strength 1000 | 1,037 at +0.225R (+0.069) | 122 at +0.301R (+0.152) | 49 at +0.142R (-0.044) |
|
||
| 0.25% merge | 791 at +0.238R (+0.078) | 365 at +0.161R (+0.023) | 295 at +0.129R (-0.008) |
|
||
| Grid without pivots | 428 at +0.232R (+0.100) | 392 at +0.176R (+0.046) | 658 at +0.208R (+0.041) |
|
||
|
||
Replacement mixes the retained and added cohorts and also discards the removed
|
||
cohort, so its portfolio result cannot say which part helped. The follow-up
|
||
`gtl_confirmation` matrix therefore preserves frozen control geometry and
|
||
decomposes each selected variant into:
|
||
|
||
- **intersection** — only control setups also core-qualified by the variant;
|
||
- **union** — all core-qualified control setups plus genuinely added variant
|
||
setups, using tuned geometry only for those additions.
|
||
|
||
It also tests pre-registered intersections among the three high-breadth
|
||
confirmers. Its control path must exactly reproduce the completed tuning
|
||
matrix before any research arm is accepted.
|
||
|
||
```bash
|
||
.venv/bin/python scripts/run_gtl_confirmation_matrix.py \
|
||
backtest_snapshots/prod.sqlite --workers 12
|
||
```
|
||
|
||
Result: **13/13 arms completed with exact control parity; no arm passed all six
|
||
guardrails.** The decomposition does identify one near-hit:
|
||
|
||
| Arm | Full Sharpe | Train | Post-2024 | CAGR | Max DD | Trades |
|
||
|---|---:|---:|---:|---:|---:|---:|
|
||
| Control | 2.03 | 1.28 | 2.78 | 50.0% | 21.4% | 321 |
|
||
| Strength-1000 intersection | **2.06** | **1.30** | **2.82** | **50.7%** | 21.7% | 316 |
|
||
|
||
Strength confirmation removes only 49 of 1,086 qualified control setups. Those
|
||
removed setups average +0.142R, but turn negative after removing their largest
|
||
5% of outcomes (-0.044R); the retained 1,037 average +0.212R and +0.054R
|
||
ex-top-5%. This is consistent with a weak tail-dependence filter. It is not yet
|
||
a winner: the original drawdown guardrail remains fixed, and 21.7% is worse
|
||
than 21.4% even though the difference is small.
|
||
|
||
The final parameter test is therefore deliberately one-dimensional. It sweeps
|
||
coarse strength scales around 1000 (625, 750, 875, 1000, 1125, 1250, 1500,
|
||
2000), using intersection only. It must reproduce both the frozen control and
|
||
the completed strength-1000 result exactly. Promotion requires at least two
|
||
adjacent non-control scales to pass all six original checks; an isolated winner
|
||
is rejected as sensitivity.
|
||
|
||
```bash
|
||
.venv/bin/python scripts/run_gtl_strength_sensitivity.py \
|
||
backtest_snapshots/prod.sqlite --workers 12
|
||
```
|
||
|
||
Final result: **9/9 arms completed, control parity passed, and the
|
||
strength-1000 replication passed.** Scale 1500 was the only arm to clear all
|
||
six original checks, but neither adjacent scale (1250 or 2000) cleared them, so
|
||
the pre-registered stable-plateau requirement failed. It also traded away
|
||
return and setup quality despite its screen pass: CAGR fell from 50.0% to 48.8%
|
||
and qualified expectancy from +0.209R to +0.188R.
|
||
|
||
The sensitivity curve shows a real but non-dominating trade-off. Scales
|
||
750–1000 produce small Sharpe improvements in parts of the sample but each
|
||
misses a different unchanged guardrail; higher scales eventually reduce
|
||
drawdown by filtering more setups, while CAGR, expectancy, and then Sharpe
|
||
decline. There is no robust parameter neighborhood that improves the whole
|
||
book.
|
||
|
||
**Final decision: keep the frozen Gate Target Ladder and do not deploy a
|
||
strength-confirmation gate.** The GTL remains the explicit, volume-free
|
||
compatibility component that exactly reproduces the validated production
|
||
screen. Clean Structural S/R remains the separate human-facing chart/alert
|
||
model. This snapshot is now exhausted for GTL fitting; any future challenger
|
||
must be pre-registered and evaluated on genuinely new forward data rather than
|
||
another iteration over the same history.
|
||
|
||
The post-2024 window has been opened and is now analysis data, not a valid final
|
||
promotion holdout. These arms can isolate mechanism, but neither may ship without
|
||
new future data or a separately pre-registered walk-forward protocol.
|
||
|
||
**Next runs, if picked back up:**
|
||
|
||
- A **per-name target model** for clear-air setups instead of a constant k×ATR. This
|
||
is the one avenue left: the return is demonstrably there (§4c), it's the flat target
|
||
that makes it too expensive in risk. Grade on the §4c holdout, not full history.
|
||
- Sweep **k** (fallback distance); only k=3 was tried. Grade on the holdout.
|
||
- A **volatility-aware** admission rule for clear-air names — the OOS failure is a
|
||
drawdown/vol story (11.7% → 14.3%), so sizing them down may recover the Sharpe.
|