---
name: xlow league z-gate — selecting good leagues fails, excluding bad ones works
description: Walk-forward test of gating xlow on per-league z. Selecting z>1 leagues is no better than random and hurts inside the primary scope; EXCLUDING leagues while z<-1 lifts primary from +15.0%/+94u to +19.0%/+114u on 95% of the sample. Discovery, not validation.
type: project
---

Tested 2026-09-20 on the xlow OOS forward sample (live_data, game_date >
FREEZE_DATE 2026-07-05): n=957 bets all-leagues, n=631 in PRIMARY_LEAGUES,
2026-07-06 .. 2026-09-20. Scripts were scratch; the whole thing re-runs off
`forward_test_unique_book.fire_xlow` + a point-in-time loop.

z is `stat()`'s binomial z: (wins - sum px) / sqrt(sum px(1-px)).

**1. In-sample z>1 selection is a mirage.** Picking leagues with z>1 using the
whole sample and then scoring those same bets: n=266, +46.4% ROI, z+4.14. The
selection and the scoring are the same data. Reported only as the gap to beat.

**2. Walk-forward SELECTION (bet a league only while its running z>1) does not
work.**  Each bet decided from bets settled on EARLIER game dates only.

    all leagues, no gate        n=957  +2.6% ROI   +25.04u  z+0.60
    all leagues, n>=5 & z>1     n=184  +9.0% ROI   +16.56u  z+1.30
    PRIMARY, no gate            n=631 +15.0% ROI   +94.38u  z+1.86
    PRIMARY, n>=5 & z>1         n=177  +8.9% ROI   +15.82u  z+1.17
    PRIMARY, the bets it SKIPPED n=454 +17.3% ROI  +78.56u  z+1.46

  * The all-leagues lift (+2.6% -> +9.0%) is the classic trap: ROI/bet rises,
    TOTAL money falls (+25.0u -> +16.6u) because 80% of the sample is cut.
    Same one-liner as [[project_league_filter_study]]: fewer bets -> higher
    average ROI -> less money.
  * Permutation (2,000 same-size random draws): random beats the gate 31% of
    the time all-leagues, 67% inside primary. It is not picking better bets.
  * Inside the primary scope it is actively HARMFUL -- the bets it skipped
    outperformed the bets it took. The frozen scope already removed the
    systematically bad leagues, so re-cutting inside it cuts on noise.
  * Nothing beats simply running the frozen primary scope unfiltered.

**3. The INVERSE works: drop leagues while running z < -1 (warmup n>=5).**

    PRIMARY, no filter          n=631 +15.0% ROI   +94.38u  z+1.86
    PRIMARY, drop z<-1 (n>=5)   n=598 +19.0% ROI  +113.78u  z+2.45
    the dropped bets            n= 33 -58.8% ROI   -19.40u

  * Keeps 95% of the sample, so it raises ROI *and* total net -- unlike every
    selection gate.
  * Robust to the knobs: all six combinations of warmup (5/8/10) x floor
    (-0.5/-1.0) improved both ROI and net over unfiltered, in primary AND
    all-leagues. Not a single lucky cell.
  * Permutation: excluding these 33 beats excluding a RANDOM 33 at p~0.011.
  * It drops exactly three leagues, late: P1 (15 bets, -63.0%), SP2 (14,
    -42.6%), F1 (4, -100.0%), first drops 2026-08-16.

**ASYMMETRY IS DELIBERATE -- it drops, it never admits** (tested 2026-09-20,
user: "this removes but it doesn't include what passes the gate. is that what
the backtest did?"). The backtest was exclusion-only INSIDE the primary scope,
and the dashboard is faithful to it. The admitting variant was then measured
and is worse everywhere. Admitted bets alone, walk-forward: z>+0.5 n=77
-23.0%, z>+1.0 n=25 -27.1%, z>+1.5 n=9 -27.0%. Added to the shadow it drags it
down monotonically: +19.1%/+115.27u -> +18.4% (z>+1.5) -> +17.2% (z>+1.0) ->
+14.3% (z>+0.5). And the admitted slice is worse than its own pool: all
out-of-scope bets -14.9%, the z>+1 pick -27.1%, with a random same-size draw
beating the pick 63% of the time.
  Mechanism is REACTIVE LAG, the mirror of project_league_filter_study's "by
the time a league PROVES negative the damage is done". CO1 is the example:
z+1.90, +56% ROI overall, but its z crossed +1 only on 09-16, so admission
catches 2 bets after the run that earned it. What actually gets admitted at
z>+1 is CH1 (15 bets, -30.1%), BR2 (5, -57.4%), PL1 (2, -100%), CO1 (2,
+180.3%), USL (1, -100%).

**Why selection fails but exclusion works.** Selection needs the TOP tail to
persist and pays 80% of the sample for it; exclusion needs only the BOTTOM tail
and pays 5%. Persistence is real but weak: splitting each league in half by
date, corr(first-half z, second-half ROI) = +0.352 over 29 leagues, permutation
p=0.030 one-sided. Enough to find the worst leagues, nowhere near enough to
rank the best.

**Reconciling with [[project_league_filter_study]]**, which found ZERO
persistence (r=-0.05) and said do not prune on realized returns. Different
dataset (2026-04..05 baseline Kalshi leg, not xlow) and different statistic:
that study gated on ROI, this one on z. For a longshot rule raw ROI is so
variance-dominated that an ROI gate cuts on noise -- which is exactly what it
observed. z divides by sqrt(sum px(1-px)), so it does not mistake one 44c
winner for skill. The old verdict stands for ROI gates; it does not extend to z.

**WIRED AS A SHADOW 2026-09-20** (user asked for it on the dashboard).
`forward_test_unique_book.py` gained `ZGATE_FREEZE = "2026-09-20"`,
`ZGATE_MIN_N = 5`, `ZGATE_FLOOR = -1.0`, `zgate_walk()` (point-in-time,
same-day bets cannot inform each other), `zgate_state()` (what it would do to
the NEXT fixture) and `classify_xlow_zgate()` / `classify_xlow_postz()` -- the
gated series and its own-window control. Both log daily as `xlow_zgate_*` and
`xlow_postz_*`; old rows blank-fill via `_ensure_log_schema`. The dashboard
gained a collapsed "League z-gate" panel under Forward by league, showing the
three things separately: current drop state, the post-freeze record (empty by
design), and the in-sample discovery rows, dimmed and labelled as such.
NOTHING GATES AN ORDER. The frozen primary is untouched and unfiltered.
Current drop state at wiring: P1 (n=20, z-2.00), SP2 (22, -1.98), I2 (5,
-1.51), F1 (10, -1.02).

THE SIGNALS TABLE marks affected upcoming rows with an amber `z-gate` pill and
a slightly dimmed row (same day, user: "make the list of upcoming games reflect
the new rule"). MARKED, NOT REMOVED -- those rows are still live signals still
scored into the frozen forward test, so hiding them would put the table in
disagreement with the number the test accrues. The state is PER DATE
(`zgate_drop_by_date`), not the panel's single "state now": a fixture today is
judged on results known before today, one tomorrow gets today's too. Reusing
one figure for both would leak today's settled results onto today's own
fixtures. First day live it marked 5 rows (SP2 x3, F1, P1) -- and TWO OF THEM
WON (+$27.04 Nice-Lille, +$42.63 Estoril-Casa Pia), which is the useful kind of
counter-evidence to keep visible next to the flattering in-sample curve.

THE EQUITY CHART now carries the shadow as a second, dashed line (same day,
user). `equity_svg()` gained `overlay=` + `keys=`; the second series must be
sampled on the SAME bet sequence as the first, so both curves walk `xlow_bt`
in struck order and the gated line CARRIES ITS LEVEL FORWARD on a bet it sat
out -- a flat stretch means "excluded", not "lost nothing". Rebuilding the
variant from its own shorter bet list would put two timelines on one x-axis,
which is the trap this pairing exists to avoid. Separated by DASH not hue, for
the same reason the sign is encoded by position. Ends $943.04 live vs
$1,110.04 gated at 637 bets, both verified against stat(). The hover reports
both values. The key says "in-sample, not evidence" ON THE CHART, because the
gap between the lines is exactly the thing a reader will otherwise mistake for
money left on the table.

**How to apply:**
- Do NOT wire a z>1 SELECTION gate. It is indistinguishable from random and it
  is negative inside the scope actually traded.
- The exclusion gate is the only candidate worth anything, and it is DISCOVERY,
  NOT VALIDATION: it was chosen by looking at the same forward sample it is
  scored on, which is the error `XHIGH_FREEZE`'s docstring exists to warn
  about. Quoting +19.0% as the rule's performance would repeat the BSA/CL1
  post-hoc scope edit, one level deeper.
- If it is wanted: freeze the parameters (warmup n>=5, floor z<-1), write the
  would-drop leagues to the nightly output as a SHADOW first, and measure only
  fixtures after the freeze date. Everything before it is in-sample forever.
- The honest read today: the frozen primary scope unfiltered is still the
  baseline to beat, and 33 bets across 3 leagues is a thin basis for a rule.
