"""
FORWARD TEST (read-only, trades nothing): the cross-book "exclusive-low" rule
(PRIMARY) plus the older "unique vs overlap" baseline, on out-of-sample fixtures.

PRIMARY RULE — "exclusive low", STRICT (user's formulation; spec corrected
2026-07-05 — see fire_xlow docstring for the correction history):
  bet the CHEAPER book's side when (1) its mid is BELOW the other book, (2) its
  pin-CLV is in [0.05,0.12), AND (3) the other book is NOT in band — one book in
  band while the other sits near Pinnacle, confirming the fair.
  Backtest on poly_backfill (975 shared-price fixtures):
    plain Kalshi pin-CLV [5,12)        n=421  +11.0%  z+1.26  p=0.105   (current live)
    exclusive-low STRICT [5,12)        n=286  +24.7%  z+2.59  p=0.005
      -> OOS TAIL (last 40%) +43.7% z+2.81
  (Under below-other, "other not in band" == "other CLV < 5%" — the cheaper book
  always carries the higher CLV, so the formulations coincide; verified bet-for-
  bet.) Still pin-anchored (pure two-book deviation with no Pinnacle is dead —
  see project_pure_xbook_deviation).

SECONDARY — "unique vs overlap" (2026-06-28): bets where exactly ONE book fires
  [0.05,0.12) ("unique") beat bets where BOTH fire ("overlap"): full UNIQUE n=117
  +61.9% z+3.52 vs OVERLAP n=267 +10.8% z+1.51; held across OOS splits. The exclusive-
  low rule is the cleaner, stronger productionizable form of the same edge — kept here
  for continuity.

This FREEZES the rules as of FREEZE_DATE and tracks them on LATER live_data fixtures
for clean OOS. NOTHING traded.

Selection (NON-look-ahead, mirrors live): per book, first pre-KO snapshot whose mid-CLV
(multiplicative pin_fair) qualifies. Kalshi settles @ ask; Poly @ real poly_*_ask when
present (live_data) else est-ask (mid + near-term half-spread, for backfill). One
bet/fixture (max CLV). Append one row/run.

BLOCKER (honest): needs live_data rows carrying BOTH kalshi_* AND poly_* prices. The
live Poly feed has been dark since 2026-06-18 (club off-season) and live_monitor must
be RESTARTED with Poly wired for poly_* to populate. Until then OOS shared-price
fixtures = 0 and this logs n=0 -- expected, not a bug.
"""
import csv, glob, os, math
from datetime import datetime, date, timedelta
from collections import defaultdict

LIVE_DIR    = r"E:\soccer\live_data"
LOG_CSV     = r"E:\soccer\research\forward_test_unique_book_log.csv"
FREEZE_DATE = "2026-07-05"     # re-frozen on STRICT spec (see fire_xlow); game_date > this = OOS
BAND = (0.05, 0.12)

_FMTS = ['%Y-%m-%d %H:%M:%S', '%m/%d/%Y %H:%M', '%m/%d/%Y %H:%M:%S', '%Y-%m-%d %H:%M']
def _pts(s):
    for f in _FMTS:
        try: return datetime.strptime(s, f)
        except (ValueError, TypeError): pass
    return None
def _f(x):
    try: return float(x)
    except (TypeError, ValueError): return None
def _result(rows):
    for r in reversed(rows):
        v = (r.get('ftr') or '').upper().strip()
        if v in ('H', 'D', 'A'): return v
    return None


# Books whose settled prices can stand in for a result, best first.
_SETTLE_BOOKS = ('kalshi', 'polyus', 'poly')
_SETTLE_HI = 0.97      # winning leg must be all but paid out
_SETTLE_LO = 0.03      # and the other two all but worthless


def _result_from_market(rows):
    """Result inferred from a settled market, or None. DISPLAY ONLY.

    `ftr` is backfilled from API-Football, so a match that finishes at 15:00
    has no result until that backfill runs -- and when the API quota is spent,
    not until the next day. The dashboard then shows finished matches as "open"
    for hours, which is what it looked like on 2026-08-15.

    The result is however already sitting in the same row: once a market settles
    the winning leg goes to ~1.00 and the others to ~0.00, which is exactly how
    resolve_bets derives its "D via kalshi". Requires status FT AND an
    unambiguous split, so an in-play blowout at 85' cannot be mistaken for a
    settled book.

    Deliberately NOT used by the scoring paths: results that a strategy is
    graded against must come from the match record, never from the prices that
    same strategy trades. This exists so a human can see a finished game as
    finished, and `load_fixtures` tags it ftr_src='market' so it is never
    mistaken for the real thing.
    """
    for r in reversed(rows):
        if (r.get('status') or '').upper() != 'FT':
            continue
        for book in _SETTLE_BOOKS:
            legs = [_f(r.get(f'{book}_{s}_mid')) for s in ('home', 'draw', 'away')]
            if any(v is None for v in legs):
                continue
            hi = [i for i, v in enumerate(legs) if v >= _SETTLE_HI]
            lo = [i for i, v in enumerate(legs) if v <= _SETTLE_LO]
            if len(hi) == 1 and len(lo) == 2:
                return 'HDA'[hi[0]]
    return None
def fair_mult(o):
    r = [1.0/x for x in o]; s = sum(r); return [ri/s for ri in r]
def half_spread(mid):   # near-term Poly sample (research/poly_spread_nearterm.py)
    if   mid < 0.05: return 0.003
    elif mid < 0.25: return 0.005
    elif mid < 0.35: return 0.025
    return 0.020

def book_quote(r, book, side):
    """(mid, ask) for a book/side. Works on live_data (poly_*_mid/ask) AND backfill
    (poly_*_price, no ask -> est-ask). Kalshi: mid + real ask.

    2026-07-27: `book` is now actually honoured for the non-Kalshi branch. It
    previously fell through to poly_* for ANY value, so book_quote(r,'polyus',..)
    silently returned Polymarket.COM prices — a wrong-but-plausible read of
    exactly the kind that has bitten this project before. Prefix is now derived
    from `book`, and an unknown book raises rather than guessing."""
    if book == 'kalshi':
        m = _f(r.get(f'kalshi_{side}_mid')); a = _f(r.get(f'kalshi_{side}_ask'))
        return m, (a if a and 0 < a < 1 else m)
    if book not in ('poly', 'polyus'):
        raise ValueError(f'book_quote: unknown book {book!r}')
    m = _f(r.get(f'{book}_{side}_mid'))
    if m is None:
        m = _f(r.get(f'{book}_{side}_price'))        # backfill column name
    a = _f(r.get(f'{book}_{side}_ask'))               # real ask (live_data)
    if not (a and 0 < a < 1):
        a = (m + half_spread(m)) if m is not None else None   # est-ask (backfill)
    return m, a

def _ko_from(rows):
    """Kickoff for a fixture, derived from any pre-match snapshot, or None.

    live_data carries no kickoff column, but `elapsed` is minutes relative to
    kickoff (negative before it), so KO = timestamp - elapsed falls out of any
    row. `elapsed` is whole minutes while the timestamp carries seconds, which
    lands the answer up to a minute early -- 05:59 for an 06:00 kick. Real
    kickoffs sit on 5-minute boundaries (:00 :05 :15 :30 :45), so rounding to
    the nearest 5 removes the artifact exactly rather than papering over it.
    """
    for r in rows:
        ts = _pts(r.get('timestamp'))
        el = r.get('elapsed')
        if ts is None or el in (None, ''):
            continue
        try:
            t = ts - timedelta(minutes=float(el))
        except (TypeError, ValueError):
            continue
        midnight = t.replace(hour=0, minute=0, second=0, microsecond=0)
        secs = round((t - midnight).total_seconds() / 300.0) * 300
        return midnight + timedelta(seconds=secs)
    return None


def load_fixtures(require_result=True):
    """Fixtures keyed to their pre-kickoff snapshots.

    require_result=True (the default, and what every SCORING path uses) drops
    fixtures with no final result: a bet that cannot be settled is not evidence
    and must never reach stat().

    Pass False to see matches that have not been played yet -- the dashboard's
    live xhigh panel does, because "what has the rule taken on tonight's games"
    is exactly the question it answers. Those rows have ftr=None, and a caller
    showing them MUST render that as open rather than letting the `won` flag
    (which reads False on an unplayed fixture) print as a loss.
    """
    fixtures = defaultdict(list)
    for path in glob.glob(os.path.join(LIVE_DIR, "*.csv")):
        with open(path, newline='', encoding='utf-8') as fh:
            for r in csv.DictReader(fh):
                fixtures[(r.get('game_date'), r.get('league'),
                          r.get('home'), r.get('away'))].append(r)
    prepped = []
    for (gd, league, home, away), rows in fixtures.items():
        if not gd: continue
        pre = sorted([(_pts(r['timestamp']), r) for r in rows
                      if (r.get('status') or '').upper() == 'PRE' and _pts(r['timestamp'])],
                     key=lambda t: t[0])
        if not pre: continue
        ftr = _result(rows)
        if ftr is None and require_result: continue
        # `ftr` MEANS "confirmed by the match record" and nothing else. Callers
        # all over this project use `fx['ftr'] is not None` as their definition
        # of settled, so putting a market-inferred result in it -- which is what
        # this did on first writing -- silently promoted inferences into every
        # scoring path. It moved the xlow forward ROI from -1.22% to -0.01% by
        # adding 7 bets, i.e. it made a losing rule look break-even. A separate
        # field cannot do that: code that has never heard of ftr_display keeps
        # the old, correct behaviour.
        ftr_display, ftr_src = ftr, ('api' if ftr else None)
        if ftr is None and not require_result:
            ftr_display = _result_from_market(rows)
            ftr_src = 'market' if ftr_display else None
        prepped.append(dict(gd=gd, league=league, ftr=ftr,
                            ftr_display=ftr_display, ftr_src=ftr_src,
                            ko=_ko_from(r for _, r in pre),
                            pre=[r for _, r in pre]))
    prepped.sort(key=lambda x: x['gd'])
    return prepped

SIDX = {'home': 0, 'away': 2}

# ── PRIMARY: exclusive-low (STRICT) ──────────────────────────────────────
def fire_xlow(fx):
    """Cheaper book BELOW the other book AND below Pinnacle (pin-CLV in BAND),
    AND the other book NOT in band — one book in band while the other isn't.

    SPEC CORRECTION 2026-07-05 (re-freeze): the user's original formulation was
    always the strict version; the loose 'below-other-only' variant wired on
    06-30 let half-cent mid gaps qualify when BOTH books were cheap (Gwangju
    07-05: Kalshi mid 0.120 vs Poly 0.125, both ~7% under fair, identical asks
    — an overlap case in xlow clothing). Strict backtest: [5,12) n=286 +24.7%
    z+2.59 (vs loose n=313 +23.1% z+2.87 — statistically equivalent; this is
    intent-alignment, not results-chasing: correction disclosed same day as a
    1W-6L shadow day, and strict loses that day identically 1W-6L — Gwangju
    still qualified via a LATER snapshot where Poly was the exclusive low.
    Known open nuance: on a 1c grid with flip-flopping books, first-qualifying-
    snapshot scanning finds transient strict configurations; a persistence
    requirement (config holds N consecutive snapshots) is a candidate
    refinement for the n>=100 review — NOT wired now).
    Equivalence note (verified 2026-07-05): 'other NOT in band' == 'other CLV
    < 5%' under the below-other condition — the cheaper book always has the
    HIGHER CLV, so the other book can never sit above band while we're in it.
    Backtest difference set is empty; the n=286 numbers apply to this rule
    exactly as written.
    Shared-price only. One bet/fixture (first qualifying pre-KO snapshot, max CLV)."""
    for r in fx['pre']:
        ph, pd_, pa = _f(r.get('pin_home')), _f(r.get('pin_draw')), _f(r.get('pin_away'))
        if None in (ph, pd_, pa) or not (ph > 1 and pd_ > 1 and pa > 1): continue
        pf = fair_mult((ph, pd_, pa)); cands = []
        for side, si in SIDX.items():
            km, ka = book_quote(r, 'kalshi', side)
            pm, pa_ = book_quote(r, 'poly', side)
            if km is None or pm is None or not (0 < km < 1 and 0 < pm < 1): continue  # shared-price
            if km <= pm: mid, px, other, book = km, ka, pm, 'kalshi'
            else:        mid, px, other, book = pm, pa_, km, 'poly'
            if px is None or not (0 < px < 1): continue
            clv = (pf[si] - mid) / pf[si]
            other_clv = (pf[si] - other) / pf[si]
            if not (mid < other and BAND[0] <= clv < BAND[1]): continue
            if BAND[0] <= other_clv < BAND[1]: continue   # other book ALSO in band -> overlap, skip
            won = (fx['ftr'] == 'H' and side == 'home') or (fx['ftr'] == 'A' and side == 'away')
            # CONTEXT ADDED 2026-08-23, for the dashboard's last-24h panel.
            # clv/px/won are computed exactly as before and every scoring path
            # (stat, classify_*, log_snapshot, the equity curves) reads only
            # those three -- extra keys cannot move a number. The alternative
            # was re-deriving the side in the dashboard, which is the drift
            # fire_xhigh_detail's docstring exists to warn against.
            cands.append(dict(clv=clv, px=px, won=won,
                              side=side, book=book, mid=mid, ask=px,
                              fair=pf[si], ts=r.get('timestamp'),
                              gd=fx['gd'], ko=fx.get('ko'), league=fx['league'],
                              home=r.get('home'), away=r.get('away'),
                              ftr=fx['ftr'],
                              # display-only, same contract as fire_xhigh_detail:
                              # `won` stays keyed to the CONFIRMED result so
                              # nothing scoring off this dict picks up an
                              # inference.
                              ftr_display=fx.get('ftr_display'),
                              won_display=(fx.get('ftr_display')
                                           == ('H' if side == 'home' else 'A')),
                              ftr_src=fx.get('ftr_src')))
        if cands: return max(cands, key=lambda d: d['clv'])
    return None

def classify_xlow(prepped):
    return [b for b in (fire_xlow(fx) for fx in prepped) if b]


# ── GAP-LEAGUE VARIANT: xlow_polyus (Poly-US substituted for Poly.com) ────
# Some leagues have Kalshi + Polymarket-US but NO Polymarket.com coverage at
# all, so fire_xlow (shared-price: Kalshi AND Poly.com) can never fire there and
# they were invisible in this forward test — while live_monitor DOES trade them
# via live_monitor.GAP_LEAGUES, tagging entries strategy='xlow_polyus'.
#
# The live tag exists so these never mix into the frozen Kalshi-vs-Poly.com
# ledger, and that separation is correct and preserved: this is a SEPARATE row,
# never pooled into the primary. The gap it closes is that the quarantine was
# built but no counterpart ledger ever was, so these bets were not measured
# anywhere. Rule is byte-for-byte fire_xlow with one venue substituted.
#
# NOT a validated edge: the n=286 +24.7% backtest was measured on Poly.com as
# the confirming book. Whether Poly-US confirms Pinnacle as well is untested.
# Read this row as instrumentation, not as evidence.
try:                                    # source of truth is live_monitor
    import sys as _sys, os as _os
    _sys.path.insert(0, _os.path.join(_os.path.dirname(_os.path.dirname(
        _os.path.abspath(__file__))), 'main'))
    from live_monitor import GAP_LEAGUES
except Exception:                       # keep the forward test importable standalone
    GAP_LEAGUES = {'USL', 'PL1'}

def fire_xlow_polyus(fx):
    """fire_xlow with Polymarket-US standing in for Polymarket.com, GAP_LEAGUES only."""
    if fx['league'] not in GAP_LEAGUES: return None
    for r in fx['pre']:
        ph, pd_, pa = _f(r.get('pin_home')), _f(r.get('pin_draw')), _f(r.get('pin_away'))
        if None in (ph, pd_, pa) or not (ph > 1 and pd_ > 1 and pa > 1): continue
        pf = fair_mult((ph, pd_, pa)); cands = []
        for side, si in SIDX.items():
            km, ka = book_quote(r, 'kalshi', side)
            pm, pa_ = book_quote(r, 'polyus', side)
            if km is None or pm is None or not (0 < km < 1 and 0 < pm < 1): continue
            if km <= pm: mid, px, other, book = km, ka, pm, 'kalshi'
            else:        mid, px, other, book = pm, pa_, km, 'polyus'
            if px is None or not (0 < px < 1): continue
            clv = (pf[si] - mid) / pf[si]
            other_clv = (pf[si] - other) / pf[si]
            if not (mid < other and BAND[0] <= clv < BAND[1]): continue
            if BAND[0] <= other_clv < BAND[1]: continue   # overlap, skip
            won = (fx['ftr'] == 'H' and side == 'home') or (fx['ftr'] == 'A' and side == 'away')
            # Same context payload as fire_xlow, so a caller that renders bets
            # does not have to care which confirming book produced them.
            cands.append(dict(clv=clv, px=px, won=won,
                              side=side, book=book, mid=mid, ask=px,
                              fair=pf[si], ts=r.get('timestamp'),
                              gd=fx['gd'], ko=fx.get('ko'), league=fx['league'],
                              home=r.get('home'), away=r.get('away'),
                              ftr=fx['ftr'],
                              ftr_display=fx.get('ftr_display'),
                              won_display=(fx.get('ftr_display')
                                           == ('H' if side == 'home' else 'A')),
                              ftr_src=fx.get('ftr_src')))
        if cands: return max(cands, key=lambda d: d['clv'])
    return None

def classify_xlow_polyus(prepped):
    return [b for b in (fire_xlow_polyus(fx) for fx in prepped) if b]


def fire_xlow_primary(fx):
    """THE forward-test firing function: xlow with whichever confirming book
    the league actually has.

    Poly.com where it exists; Poly-US in GAP_LEAGUES (USL, PL1), which have
    poly_tag=None and therefore no Poly.com quotes at all. Without this the two
    are permanently invisible to the forward test while being actively traded --
    which is why they were put in the scope (user, 2026-08-23).

    HONEST NOTE. The n=286 backtest confirmed against Poly.com only, so Poly-US
    as a confirming book is UNVALIDATED -- it is a thinner, newer venue. The
    primary is now a mix of two confirmations. Those bets are a small minority
    (USL 50, PL1 21 vs ~190 Poly.com) and the row `xlow_polyus` still reports
    them alone, so the mix can always be decomposed.

    No double counting: the two paths are mutually exclusive by league, because
    fire_xlow_polyus returns None outside GAP_LEAGUES and fire_xlow cannot fire
    inside them (no Poly.com prices to compare against).
    """
    if fx.get('league') in GAP_LEAGUES:
        return fire_xlow_polyus(fx)
    return fire_xlow(fx)


# ── SCOPE: the leagues the BACKTEST was measured on ──────────────────────
# The n=286 backtest ran on poly_backfill/ (2026-04-18 .. 2026-06-17) and fired
# in these 20 leagues. Everything the rule was validated on is here; anything
# else is a league we started COLLECTING later, on purpose, because most of
# this set is out of season across July-August (D1 has zero post-freeze
# fixtures, E0/F1/I1/I2/G1 have single digits) and a forward test that waited
# for them would have stood still all summer.
#
# So the honest primary is this scope: it is the universe fixed BEFORE the
# forward test started, and the extra leagues are the addition. Reporting the
# all-leagues pooled number as the headline silently redefines the rule's
# universe mid-test. The added leagues are still scored, separately, as
# `xlow_newlg` -- data collection, not evidence for the frozen rule.
#
# FROZEN, and hardcoded rather than re-derived from poly_backfill/ each run: a
# frozen scope must not be able to change because a directory did.
# Provenance and the numbers behind it: research/xlow_backtest_leagues.py and
# memory/project_xlow_backtest_league_slice.md.
# The backtest's own 20, kept verbatim as the audit trail. Do not edit: this is
# what the n=286 result was measured on.
BACKTEST_LEAGUES = frozenset({
    'AR1', 'BSA', 'CL1', 'D1', 'D2', 'E0', 'E1', 'E2', 'F1', 'I1',
    'I2', 'MLS', 'N1', 'NO1', 'P1', 'SC0', 'SE1', 'SP1', 'SP2', 'T1',
})

# ── SCOPE EDIT 2026-08-23 (user) ─────────────────────────────────────────
# REMOVED : BSA, CL1  -- "I don't trust them".
# ADDED   : NWSL, PL1, USL.
#
# READ THIS BEFORE QUOTING THE PRIMARY NUMBER. The two removals were the two
# worst leagues in the scope (BSA -40.2% on n=35, CL1 -75.2% on n=13) and they
# were removed AFTER those numbers were visible. That is post-hoc selection --
# unlike the original backtest-league scope, which was fixed before the forward
# test started and is why that one was legitimate.
#
# What it does to the headline:
#     backtest 20      n=237  ROI +18.75%  z+1.37
#     this scope       n=212  ROI +34.46%  z+2.55
#     (removals alone) n=189  ROI +36.14%  z+2.32
# Nearly all of the move is the two removals, and it carries z across 1.96.
# That apparent significance is MANUFACTURED by the selection; it is not
# evidence the rule got better. Treat everything before SCOPE_FROZEN as
# IN-SAMPLE for this scope -- the same discipline XHIGH_FREEZE exists for.
SCOPE_FROZEN = "2026-08-23"

# PL1 and USL have poly_tag=None -- no Polymarket.com coverage at all -- and
# fire_xlow is shared-price-only, so they contribute ZERO bets on the primary
# path (verified: n=0 each). They are listed so they count automatically if
# Poly.com coverage ever appears. Today they score only via fire_xlow_polyus,
# which stays in its own row on purpose: that path uses Poly-US as the
# confirming book, not Poly.com, so pooling it would change what the rule means
# (see project_kalshi_only_execution).
# PL1 and USL REMOVED 2026-08-27 (user). They were added on 08-23 and never
# contributed anything the rule was validated on: neither has Polymarket.com
# coverage, so fire_xlow cannot fire there at all, and they only entered the
# primary through fire_xlow_primary's Poly-US substitution -- a confirming book
# the n=286 backtest never used. That slice was also running flat (+0.81% on
# n=71 against +34.46% for the Poly.com-confirmed legs), so it was diluting the
# headline with bets struck on an unvalidated confirmation.
#
# They keep scoring in `xlow_newlg` (outside scope, tracking) and in the
# separate xlow_polyus row, so nothing stops being measured -- it just stops
# counting toward the frozen rule.
# KO1 ADDED 2026-09-01 (user). Unlike PL1/USL it has real Polymarket.com
# coverage, so it scores on the ordinary fire_xlow path -- the same confirming
# book the n=286 backtest used. Its out-of-scope record at the time of adding:
# n=41, 36.6% win, ROI +32.8%, z+1.62, the best of any league outside the
# scope with n>15.
#
# THIS IS POST-HOC SELECTION, exactly like the BSA/CL1 removals above. KO1 was
# added AFTER its forward record was visible, and the headline moves
# n=309 ROI +27.6% z+2.29  ->  n=350 ROI +28.2% z+2.71
# on that basis. The ROI barely moves; almost all of the z gain is just the
# extra n on an already-positive rule. Do not read z+2.71 as the rule having
# got stronger, and treat everything before the date below as IN-SAMPLE for
# this scope.
SCOPE_FROZEN_KO1 = "2026-09-01"

PRIMARY_LEAGUES = frozenset(
    (BACKTEST_LEAGUES - {'BSA', 'CL1'}) | {'NWSL', 'KO1'}
)


def in_primary_leagues(fx):
    return fx.get('league') in PRIMARY_LEAGUES


# Back-compat alias: callers ask "is this fixture in the primary scope", and the
# answer is PRIMARY_LEAGUES now that the scope has been edited away from the
# backtest set. Kept so nothing silently reverts to the unedited 20.
in_backtest_leagues = in_primary_leagues


def classify_xlow_bt(prepped):
    """xlow over PRIMARY_LEAGUES -- the primary forward scope. Uses
    fire_xlow_primary, so USL/PL1 are included via the Poly-US confirm."""
    return [b for b in (fire_xlow_primary(fx) for fx in prepped
                        if in_primary_leagues(fx)) if b]


def classify_xlow_newlg(prepped):
    """xlow over everything outside the primary scope. Tracking only, never
    pooled into the primary -- see PRIMARY_LEAGUES. Note this now includes the
    two leagues REMOVED from the scope (BSA, CL1), so they stay measured rather
    than disappearing from the page."""
    return [b for b in (fire_xlow_primary(fx) for fx in prepped
                        if not in_primary_leagues(fx)) if b]


# ── PASSIVE VARIANT: xlow_f10 — price floor 0.10 (added 2026-07-11) ───────
# Same stamped bets, minus entries priced under 10c. Backtest: the sub-10c
# bucket went 0W-12L (-12u); floor >=0.10 improved every metric (ROI +24.7
# -> +30.2%, z 2.59 -> 2.74, tail +43.7 -> +53.0%, bootstrap CI floor +8.3
# -> +12.9, P>0 100%). But n=12 at ~8% implied is weak evidence alone
# (P(0/12) ~ 37% even at fair odds) — the case rests on convergent priors
# (the >=12% CLV cap, the [25,100) CLV wipeout, profit living at 15-25%
# win-prob). LOGGED PASSIVELY for the n>=100 review; the frozen primary is
# unchanged and unfiltered.
XLOW_PRICE_FLOOR = 0.10

def classify_xlow_f10(prepped):
    return [b for b in classify_xlow(prepped) if b['px'] >= XLOW_PRICE_FLOOR]

# ── PASSIVE VARIANT: league z-gate — EXCLUDE cold leagues (SHADOW) ───────
# (tested 2026-09-20, from a user question: "backtest only betting on leagues
# with a z-score over 1".)
#
# SELECTING on z>1 does NOT work and is deliberately not implemented. Walk-
# forward it took n=184 at +9.0% where betting everything took n=957 at +2.6%
# -- an ROI/bet lift that is really a sample cut, since TOTAL net FELL,
# +25.0u -> +16.6u. A random same-size draw beat it 31% of the time. Inside
# PRIMARY_LEAGUES it was outright harmful: +8.9% on the bets it took vs +17.3%
# on the bets it skipped. The frozen scope has already removed the
# systematically bad leagues, so re-cutting inside it cuts on noise.
#
# EXCLUDING on z<-1 is the variant that survived, because it is the cheap
# direction: selecting winners needs the TOP tail to persist and costs 80% of
# the sample; excluding losers needs only the BOTTOM tail and costs ~5%.
# Primary, walk-forward: n=631 +15.0% +94.38u z+1.86 -> n=598 +19.0% +113.78u
# z+2.45, by dropping 33 bets worth -19.40u across P1, SP2 and F1. All six
# warmup(5/8/10) x floor(-0.5/-1.0) combinations improved both ROI and net;
# excluding those particular 33 beats excluding a RANDOM 33 at p~0.011.
#
# PERSISTENCE is what makes it possible at all: splitting each league in half
# by date, corr(first-half z, second-half ROI) = +0.352 over 29 leagues,
# permutation p=0.030. Real but weak -- enough to find the worst leagues,
# nowhere near enough to rank the best, which is why the DIRECTION matters.
# NOTE this contradicts memory/project_league_filter_study.md (r=-0.05, "do not
# prune on realized returns"): that study gated on ROI, on a different dataset.
# For a longshot rule raw ROI is variance-dominated so an ROI gate cuts on
# noise; z divides by sqrt(sum p(1-p)). The old verdict stands for ROI gates
# and does not extend to this one.
#
# DISCOVERY, NOT VALIDATION. Every number above was found by looking at the
# same forward sample it scores -- the exact error XHIGH_FREEZE's docstring
# warns about, and what the BSA/CL1 scope edit did. So the gate carries its OWN
# freeze date and only fixtures after it are evidence. Everything before
# ZGATE_FREEZE is in-sample for this variant, permanently.
#
# WHY IT ONLY DROPS AND NEVER ADMITS (tested 2026-09-20, user asked). The rule
# is deliberately asymmetric: it excludes in-scope leagues below the floor and
# never admits an out-of-scope league however good its z looks. Adding one was
# measured and it is worse in every cell. The 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%. Bolted onto this
# shadow it drags it down monotonically: +19.1%/+115.27u alone -> +18.4% at
# z>+1.5 -> +17.2% at z>+1.0 -> +14.3% at z>+0.5.
#
# Worse than that, the admitted slice is worse than the pool it is drawn from:
# out-of-scope bets run -14.9% overall, the z>+1 pick runs -27.1%, and a RANDOM
# same-size draw from that pool beats the pick 63% of the time. Selecting on
# high z chose worse than chance.
#
# The mechanism is reactive lag, the mirror of what project_league_filter_study
# found for dropping ("by the time a league PROVES negative the damage is
# done"). CO1 is the case: z+1.90 and +56% ROI overall, but its z only crossed
# +1 on 2026-09-16, so admission catches 2 bets AFTER the run that earned it.
# The bottom tail persists enough to act on; the top tail does not. That is the
# same finding that killed the SELECT direction above, in a second form.
#
# SHADOW ONLY. Nothing here gates a real order or touches the frozen primary,
# which stays unfiltered. It renders on the dashboard and logs daily so the
# post-freeze series can accrue; promoting it would be a separate decision made
# on the post-freeze number alone.
ZGATE_FREEZE = "2026-09-20"
ZGATE_MIN_N  = 5           # warmup: never judge a league on fewer than 5 bets
ZGATE_FLOOR  = -1.0

def zgate_walk(bets, min_n=ZGATE_MIN_N, floor=ZGATE_FLOOR):
    """Point-in-time exclusion. Returns (kept, dropped).

    A bet is dropped when its league's z -- computed over that league's bets
    settled on STRICTLY EARLIER game dates -- is below `floor`, once there are
    at least `min_n` of them. Same-day bets cannot inform each other: they wait
    in `pending` and fold in when the date rolls, because a bet struck at 13:00
    cannot know the result of one kicking off at 15:00.
    """
    hist, pending, cur = defaultdict(list), defaultdict(list), None
    kept, dropped = [], []
    for b in sorted(bets, key=lambda b: (b['gd'], b.get('ko') or '')):
        if b['gd'] != cur:
            for lg, bs in pending.items():
                hist[lg].extend(bs)
            pending, cur = defaultdict(list), b['gd']
        s = stat(hist[b['league']]) if hist[b['league']] else None
        bad = bool(s) and s['n'] >= min_n and s['z'] < floor
        (dropped if bad else kept).append(b)
        pending[b['league']].append(b)
    return kept, dropped

def zgate_state(bets, min_n=ZGATE_MIN_N, floor=ZGATE_FLOOR):
    """Which leagues stand in DROP state now, over every settled bet given.

    CURRENT state, for display -- what the gate would do to the next fixture --
    not the walk-forward record. Leagues still under the warmup come back
    flagged `warm` so the panel can show what it is waiting on rather than
    silently treating them as fine.
    """
    by = defaultdict(list)
    for b in bets:
        by[b['league']].append(b)
    out = {}
    for lg, bs in by.items():
        s = stat(bs)
        if not s:
            continue
        out[lg] = dict(s, dropped=(s['n'] >= min_n and s['z'] < floor),
                       warm=(s['n'] < min_n))
    return out

def classify_xlow_zgate(prepped):
    """Primary scope, z-gate applied, POST-ZGATE_FREEZE bets only.

    The walk runs over the whole OOS history so the gate's state is warm, then
    the result is cut to post-freeze fixtures: state may use the history,
    evidence may not.
    """
    oos = [fx for fx in prepped if fx['gd'] > FREEZE_DATE]
    kept, _ = zgate_walk(classify_xlow_bt(oos))
    return [b for b in kept if b['gd'] > ZGATE_FREEZE]

def classify_xlow_postz(prepped):
    """The UNGATED primary over the same post-freeze window -- the control.

    Without it the gated number has nothing to be read against and would get
    compared to the all-history primary, which is a different window.
    """
    oos = [fx for fx in prepped if fx['gd'] > FREEZE_DATE]
    return [b for b in classify_xlow_bt(oos) if b['gd'] > ZGATE_FREEZE]

# ── PASSIVE VARIANT: t60 — one book in band at the T-60min snapshot ───────
# (added 2026-07-19, user idea). Evaluate ONLY the snapshot closest to 60 min
# pre-KO (within +/-45): fire when exactly ONE exchange's pin-CLV is in band.
# Point-in-time (executable, no look-ahead) cousin of the persistence idea in
# fire_xlow's docstring: a book still cheap an hour out, against Pinnacle at
# its sharpest, hasn't been arbed away all day. Backtest (poly_backfill):
# n=70 WR 32.9% (impl 23.0) ROI +45.3% z+2.02, TRAIN +49.3/TEST +39.2; same-
# subset xlow control +15.1% z+1.50 (n=206) — improvement is timing, not the
# subset. CAVEAT: variant designed after peeking at forward results; z is
# unadjusted for selection. LOGGED PASSIVELY for the n>=100 review; the
# frozen primary is unchanged.
T60_TARGET, T60_TOL = -60, 45

def fire_t60(fx):
    best, bd = None, 1e9
    for r in fx['pre']:
        el = _f(r.get('elapsed'))
        if el is None or el >= 0: continue
        d = abs(el - T60_TARGET)
        if d < bd: bd, best = d, r
    if best is None or bd > T60_TOL: return None
    r = best
    ph, pd_, pa = _f(r.get('pin_home')), _f(r.get('pin_draw')), _f(r.get('pin_away'))
    if None in (ph, pd_, pa) or not (ph > 1 and pd_ > 1 and pa > 1): return None
    pf = fair_mult((ph, pd_, pa)); cands = []
    for side, si in SIDX.items():
        km, ka = book_quote(r, 'kalshi', side)
        pm, pa_ = book_quote(r, 'poly', side)
        if km is None or pm is None or not (0 < km < 1 and 0 < pm < 1): continue
        ck = (pf[si] - km) / pf[si]; cp = (pf[si] - pm) / pf[si]
        k_in, p_in = BAND[0] <= ck < BAND[1], BAND[0] <= cp < BAND[1]
        if k_in == p_in: continue              # need exactly ONE book in band
        px = (ka if ka and 0 < ka < 1 else km) if k_in else pa_
        if px is None or not (0 < px < 1): continue
        won = (fx['ftr'] == 'H' and side == 'home') or (fx['ftr'] == 'A' and side == 'away')
        cands.append(dict(clv=max(ck, cp), px=px, won=won))
    return max(cands, key=lambda d: d['clv']) if cands else None

def classify_t60(prepped):
    return [b for b in (fire_t60(fx) for fx in prepped) if b]

# ── SECONDARY: unique vs overlap ──────────────────────────────────────────
def fire(fx, book):
    """book='kalshi' / 'poly'. Returns bet dict + have_price."""
    cands = []; have = False
    for side, sidx in (('home', 0), ('away', 2)):
        for r in fx['pre']:
            ph, pd_, pa = _f(r.get('pin_home')), _f(r.get('pin_draw')), _f(r.get('pin_away'))
            if None in (ph, pd_, pa) or not (ph > 1 and pd_ > 1 and pa > 1): continue
            mid, px = book_quote(r, book, side)
            if mid is None or px is None or not (0 < mid < 1) or not (0 < px < 1): continue
            have = True
            pf = fair_mult((ph, pd_, pa))[sidx]
            clv = (pf - mid) / pf if pf else None
            if clv is None or not (BAND[0] <= clv < BAND[1]): continue
            won = (fx['ftr'] == 'H' and side == 'home') or (fx['ftr'] == 'A' and side == 'away')
            cands.append(dict(clv=clv, px=px, won=won)); break
    if not cands: return (None, have)
    return (max(cands, key=lambda d: d['clv']), have)

def classify(prepped):
    """Return (unique_bets, overlap_bets) over shared-price fixtures."""
    uniq, overlap = [], []
    for fx in prepped:
        k, kh = fire(fx, 'kalshi'); p, ph = fire(fx, 'poly')
        if not (kh and ph): continue          # shared-price only
        if k and p:           overlap.append(k)
        elif k and not p:     uniq.append(k)
        elif p and not k:     uniq.append(p)
    return uniq, overlap

def stat(bets):
    n = len(bets)
    if not n: return None
    wins = sum(b['won'] for b in bets)
    net = sum(((1-b['px'])/b['px']) if b['won'] else -1.0 for b in bets)
    mu = sum(b['px'] for b in bets); var = sum(b['px']*(1-b['px']) for b in bets)
    z = (wins-mu)/math.sqrt(var) if var > 0 else 0.0
    return dict(n=n, wr=wins/n*100, iwr=mu/n*100, roi=net/n*100, z=z)

# ── xhigh: Kalshi-only, price-floored (FROZEN 2026-08-09) ─────────────────
# WHY. xlow is unmeasurable, not merely unproven: at +0.8% ROI with a 1.94u
# per-bet stdev it needs ~100,000 bets (~46 years at 6/day) to confirm its own
# observed effect. The binding constraint is VARIANCE, and variance of a unit
# bet scales as (1-p)/p -- so excluding long shots is what makes the rule
# testable at all. research/rule_sweep.py measured the required sample dropping
# from ~201,000 bets to ~480.
#
# Two books are dropped for the same reason: only 411 of 1,717 fixtures carry
# both Kalshi and Poly, while 1,364 carry Kalshi+Pinnacle. When the whole
# problem is power, 3x the universe beats rule elegance.
#
# HONEST PROVENANCE. The sweep's "test" split was post-2026-07-05 data -- the
# same fixtures the xlow forward test runs on. Choosing this rule by looking at
# that split means those numbers (+15.1%, z+1.33) are DISCOVERY, not validation:
# for this rule that data is now in-sample. Hence a NEW freeze date. Only
# fixtures after XHIGH_FREEZE are evidence. Nothing before it counts, ever.
#
# 17 candidates were swept and NONE reached z>1.96. What earned the freeze is
# not any single cell but that the price floor improved both rule families at
# every persistence level (+13% to +23% with the floor, +0.8% and -4.9% without)
# -- and, mainly, that it makes the question answerable in weeks.
XHIGH_FREEZE      = "2026-08-09"
XHIGH_PRICE_FLOOR = 0.20


def fire_xhigh_detail(fx):
    """As fire_xhigh, but returns the FULL context of the qualifying snapshot.

    THE RULE LIVES HERE, ONCE. fire_xhigh() is a thin wrapper over this. The
    dashboard's live xhigh panel needs the side, price and CLV that fired, not
    just the settled bet, and re-implementing the conditions there is exactly
    how a displayed "signal" drifts into disagreeing with the forward test that
    scores it. Anything that wants to know what xhigh does asks this function.

    `won` is meaningless for a fixture with no result yet (ftr is None, so it
    reads False); a caller showing live fixtures must ignore it rather than
    render it as a loss.
    """
    for r in fx['pre']:
        ph, pd_, pa = _f(r.get('pin_home')), _f(r.get('pin_draw')), _f(r.get('pin_away'))
        if None in (ph, pd_, pa) or not (ph > 1 and pd_ > 1 and pa > 1):
            continue
        pf = fair_mult((ph, pd_, pa))
        for side in ('home', 'away'):
            m, a = book_quote(r, 'kalshi', side)
            if m is None or a is None or not (0 < m < 1 and 0 < a < 1):
                continue
            if m < XHIGH_PRICE_FLOOR:
                continue
            fair = pf[SIDX[side]]
            if fair <= 0:
                continue
            clv = (fair - m) / fair
            if BAND[0] <= clv < BAND[1]:
                return {'px': a, 'won': fx['ftr'] == ('H' if side == 'home' else 'A'),
                        'side': side, 'mid': m, 'ask': a, 'fair': fair, 'clv': clv,
                        'ts': r.get('timestamp'), 'gd': fx['gd'],
                        'ko': fx.get('ko'),
                        'league': fx['league'], 'ftr': fx['ftr'],
                        # display-only view of the result; see load_fixtures.
                        # `won` stays keyed to the CONFIRMED result so nothing
                        # that scores off this dict can pick up an inference.
                        'ftr_display': fx.get('ftr_display'),
                        'won_display': (fx.get('ftr_display')
                                        == ('H' if side == 'home' else 'A')),
                        'ftr_src': fx.get('ftr_src'),
                        'home': r.get('home'), 'away': r.get('away')}
    return None


def fire_xhigh(fx):
    """Kalshi mid-CLV in BAND on a side priced at/above the floor.

    No second book required. One bet per fixture: FIRST qualifying pre-KO
    snapshot, settled at the real Kalshi ask -- same non-look-ahead selection
    as fire_xlow, so the two are comparable bet-for-bet.

    Returns only what the scoring path needs, so adding fields to
    fire_xhigh_detail can never quietly change what stat() averages.
    """
    d = fire_xhigh_detail(fx)
    return {'px': d['px'], 'won': d['won']} if d else None


def classify_xhigh(prepped):
    """Bets for fixtures AFTER XHIGH_FREEZE only -- see the provenance note."""
    out = []
    for fx in prepped:
        if fx['gd'] <= XHIGH_FREEZE:
            continue
        b = fire_xhigh(fx)
        if b:
            out.append(b)
    return out


def _ensure_log_schema(fieldnames):
    """Migrate the log header if columns changed (preserve old rows, blank-fill new cols)."""
    if not os.path.exists(LOG_CSV): return
    with open(LOG_CSV, newline='', encoding='utf-8') as fh:
        rd = csv.DictReader(fh); old = rd.fieldnames or []; rows = list(rd)
    if old == fieldnames: return
    with open(LOG_CSV, 'w', newline='', encoding='utf-8') as fh:
        w = csv.DictWriter(fh, fieldnames=fieldnames); w.writeheader()
        for row in rows: w.writerow({k: row.get(k, '') for k in fieldnames})

def log_snapshot(prepped=None):
    if prepped is None: prepped = load_fixtures()
    oos = [fx for fx in prepped if fx['gd'] > FREEZE_DATE]
    # xlow_bt is the PRIMARY scope (backtest leagues); xlow stays logged
    # unscoped so the pooled series is never lost, and xlow_newlg tracks the
    # leagues added for collection. _ensure_log_schema back-fills the new
    # columns on existing rows.
    xlow = classify_xlow(oos) if oos else []
    xlow_bt = classify_xlow_bt(oos) if oos else []
    xlow_newlg = classify_xlow_newlg(oos) if oos else []
    # f10 is a variant OF THE PRIMARY, so it follows the primary's scope --
    # otherwise the dashboard (which scopes it) and this log disagree.
    xlow_f10 = [b for b in xlow_bt if b['px'] >= XLOW_PRICE_FLOOR]
    uniq, overlap = classify(oos) if oos else ([], [])
    t60 = classify_t60(oos) if oos else []
    xhigh = classify_xhigh(prepped)          # own freeze date, applied inside
    # z-gate: SHADOW, own freeze date, logged next to its own control so the
    # pair is always read over the same window. Both are 0 until ZGATE_FREEZE
    # passes, which is the point -- an empty column is an honest one here.
    xlow_zgate = classify_xlow_zgate(prepped) if oos else []
    xlow_postz = classify_xlow_postz(prepped) if oos else []
    row = {'run_date': date.today().isoformat(), 'freeze_date': FREEZE_DATE,
           'xhigh_freeze': XHIGH_FREEZE, 'zgate_freeze': ZGATE_FREEZE}
    for name, bets in (('xlow_bt', xlow_bt), ('xlow', xlow),
                       ('xlow_newlg', xlow_newlg),
                       ('xlow_f10', xlow_f10), ('xhigh', xhigh),
                       ('xlow_zgate', xlow_zgate), ('xlow_postz', xlow_postz),
                       ('t60', t60), ('unique', uniq), ('overlap', overlap)):
        s = stat(bets)
        row[f'{name}_n']   = s['n'] if s else 0
        row[f'{name}_wr']  = round(s['wr'], 1)  if s else ''
        row[f'{name}_roi'] = round(s['roi'], 1) if s else ''
        row[f'{name}_z']   = round(s['z'], 2)   if s else ''
    fieldnames = list(row.keys())
    _ensure_log_schema(fieldnames)
    new = not os.path.exists(LOG_CSV)
    with open(LOG_CSV, 'a', newline='', encoding='utf-8') as fh:
        w = csv.DictWriter(fh, fieldnames=fieldnames)
        if new: w.writeheader()
        w.writerow(row)
    return row

def main():
    prepped = load_fixtures()
    print(f"FREEZE_DATE={FREEZE_DATE}   total resolved fixtures={len(prepped)}\n")
    for label, sub in (("IN-SAMPLE (<=freeze, reference)", [f for f in prepped if f['gd'] <= FREEZE_DATE]),
                       ("FORWARD (OOS, >freeze)",          [f for f in prepped if f['gd'] >  FREEZE_DATE])):
        print(f"--- {label} ---")
        if not sub:
            print("  (no fixtures yet)\n"); continue
        xlow = classify_xlow(sub)
        xlow_f10 = [b for b in xlow if b['px'] >= XLOW_PRICE_FLOOR]
        uniq, overlap = classify(sub)
        t60 = classify_t60(sub)
        for name, bets in (('XLOW*  ', xlow), ('XLOWf10', xlow_f10), ('t60    ', t60), ('unique ', uniq), ('overlap', overlap)):
            s = stat(bets)
            if s:
                print(f"  {name}  n={s['n']:4d}  WR={s['wr']:4.1f}%  impl={s['iwr']:4.1f}%  "
                      f"ROIturn={s['roi']:+6.1f}%  z={s['z']:+.2f}")
            else:
                print(f"  {name}  (no shared-price bets -- Poly feed off / off-season)")
        print()
    # xhigh reported separately: it has its OWN freeze date, so mixing it into
    # the blocks above would silently credit it with pre-freeze fixtures it must
    # never be judged on.
    xh = classify_xhigh(prepped)
    sh = stat(xh)
    print(f"--- XHIGH (frozen {XHIGH_FREEZE}, Kalshi-only, mid>={XHIGH_PRICE_FLOOR}) ---")
    if sh:
        print(f"  XHIGH    n={sh['n']:4d}  WR={sh['wr']:4.1f}%  impl={sh['iwr']:4.1f}%  "
              f"ROIturn={sh['roi']:+6.1f}%  z={sh['z']:+.2f}")
    else:
        print("  XHIGH    n=   0  (no fixtures after the freeze yet -- expected)")
    print(f"  needs ~480 bets to resolve; pre-freeze data is EXCLUDED by design.\n")
    print("  * XLOW = PRIMARY rule (cheaper book, below other book AND below Pinnacle)")
    log_snapshot(prepped)
    print(f"logged snapshot -> {LOG_CSV}")

if __name__ == '__main__':
    main()
