"""
Which book is actually SHARPEST? (read-only, trades nothing)

THE QUESTION: pin_calibration_by_league.py proved Pinnacle is *calibrated* --
implied 25.0% draws really do land 25.5% of the time. That is NOT the same as
proving Pinnacle is *better than the exchanges*. Kalshi and Poly could be equally
calibrated, in which case CLV-vs-Pinnacle measures noise, not information, and
xlow's premise ("when the exchange is cheaper than Pinnacle, the exchange is
wrong") is unfounded.

Calibration is marginal (do 20% shots win 20% of the time). Sharpness is
conditional (does the book separate the 15% games from the 25% games). A book
quoting the league base rate every time is perfectly calibrated and useless. So
we score with PROPER SCORING RULES, which reward both at once.

METHOD -- two controls that the first cut of this script got wrong, both of which
flipped the headline:

  1. SAME-SNAPSHOT PAIRING. Every pairwise comparison uses the last pre-KO row
     where BOTH books have a complete quote. Scoring each book at its own last
     quote silently hands an information edge to whichever book happens to quote
     nearest kickoff -- and the exchanges quote later than Pinnacle far more
     often than the reverse.
  2. OUTCOME-INDEPENDENT DISAGREEMENT. Disagreement is total-variation distance
     over all three outcomes, computed before the result is known. Selecting
     fixtures where the books differed *on the outcome that happened* is
     conditioning on the answer and manufactures a gap out of nothing.

  - Every book devigged identically (proportional normalisation to sum 1) so no
    book is advantaged by the devig choice. Pinnacle/B365 quote decimal odds
    (raw = 1/odds); Kalshi/Poly/Poly-US quote probabilities (raw = mid).
  - Log loss (primary; strictly proper, punishes confident errors) and Brier
    (secondary; bounded, less tail-sensitive), over all 3 outcomes.
  - Paired t-stat on the per-fixture log-loss difference. Fixture difficulty
    varies enormously and cancels in the difference, so paired is far more
    powerful than comparing the two means.

READING THE RESULT
  Lower log loss = sharper. The paired t is the honest test; a raw log-loss gap
  without it is meaningless at these samples. If Pinnacle does NOT beat the
  exchanges, xlow's premise is unsupported -- a far bigger finding than any
  bet-level ROI.

Usage:  python research\\book_accuracy_compare.py [min_n]
"""
import sys
import csv
import glob
import math
from collections import defaultdict

SOURCES = (r'E:\soccer\poly_backfill\*_polybackfill.csv',
           r'E:\soccer\live_data\*.csv')
MIN_N = int(sys.argv[1]) if len(sys.argv) > 1 else 30
DISAGREE_MIN = 0.03     # total-variation distance; ~3pp of probability moved
BAND = (0.05, 0.12)     # xlow's trigger band, for the premise test
FREEZE_DATE = '2026-07-05'  # band was chosen on data up to here; only later rows are evidence
EPS = 1e-6

# Each book lists ALTERNATIVE column triples, tried in order. This matters:
# poly_backfill/*.csv name the columns poly_*_price while live_data/*.csv use
# poly_*_mid. Reading only the _mid form silently dropped 815 backfilled Poly
# fixtures and left every Poly result in this file running on the 181 that came
# from live_data -- a 4.5x under-sample that made Poly look untestable
# (found 2026-07-25). Any new source must be added here, not assumed.
BOOKS = {
    'pinnacle': ('odds', [('pin_home', 'pin_draw', 'pin_away')]),
    'b365':     ('odds', [('b365_home', 'b365_draw', 'b365_away')]),
    'kalshi':   ('prob', [('kalshi_home_mid', 'kalshi_draw_mid', 'kalshi_away_mid')]),
    'poly':     ('prob', [('poly_home_mid', 'poly_draw_mid', 'poly_away_mid'),
                          ('poly_home_price', 'poly_draw_price', 'poly_away_price')]),
    'polyus':   ('prob', [('polyus_home_mid', 'polyus_draw_mid', 'polyus_away_mid')]),
}
ORDER = ['pinnacle', 'b365', 'kalshi', 'poly', 'polyus']
IDX = {'H': 0, 'D': 1, 'A': 2}


def _f(x):
    try:
        return float(x)
    except (TypeError, ValueError):
        return None


def _result(rows):
    for r in rows:
        v = (r.get('ftr') or '').strip().upper()
        if v in ('H', 'D', 'A'):
            return v
    return None


def devig(raw, kind):
    """Proportional devig -> probabilities summing to 1, or None if unusable."""
    if kind == 'odds':
        if any(v is None or v <= 1.0 for v in raw):
            return None
        imp = [1.0 / v for v in raw]
    else:
        if any(v is None or not (0.0 < v < 1.0) for v in raw):
            return None
        imp = list(raw)
    s = sum(imp)
    if not (0.80 < s < 1.60):   # far-off overround = stale/garbage quote
        return None
    return [v / s for v in imp]


def load():
    """One record per fixture: {ftr, snaps:[{book: probs}, ...]} in pre-KO order."""
    out, seen = [], set()
    for pat in SOURCES:
        for path in glob.glob(pat):
            fx = defaultdict(list)
            try:
                with open(path, newline='', encoding='utf-8') as fh:
                    for r in csv.DictReader(fh):
                        fx[(r.get('game_date'), r.get('league'),
                            r.get('home'), r.get('away'))].append(r)
            except Exception:
                continue
            for key, rows in fx.items():
                if key in seen or not key[0]:
                    continue
                ftr = _result(rows)
                if ftr not in IDX:
                    continue
                pre = [r for r in rows if (r.get('status') or '').upper() == 'PRE']
                if not pre:
                    continue
                snaps = []
                for r in pre:
                    d = {}
                    for book, (kind, colsets) in BOOKS.items():
                        for cols in colsets:
                            p = devig([_f(r.get(c)) for c in cols], kind)
                            if p:
                                d[book] = p
                                break
                    if d:
                        snaps.append(d)
                if not snaps:
                    continue
                seen.add(key)
                out.append(dict(gd=key[0], lg=key[1], ftr=ftr, snaps=snaps))
    return out


def last_solo(fx, book):
    for d in reversed(fx['snaps']):
        if book in d:
            return d[book]
    return None


def last_both(fx, a, b):
    """Last pre-KO snapshot where BOTH books quote -- the fair comparison point."""
    for d in reversed(fx['snaps']):
        if a in d and b in d:
            return d[a], d[b]
    return None


def logloss(p, ftr):
    return -math.log(max(p[IDX[ftr]], EPS))


def brier(p, ftr):
    return sum((p[i] - (1.0 if i == IDX[ftr] else 0.0)) ** 2 for i in range(3))


def tvd(p, q):
    """Total-variation distance -- outcome-independent disagreement."""
    return 0.5 * sum(abs(p[i] - q[i]) for i in range(3))


def mean(xs):
    return sum(xs) / len(xs) if xs else float('nan')


def paired_t(diffs):
    n = len(diffs)
    if n < 3:
        return float('nan')
    m = mean(diffs)
    var = sum((d - m) ** 2 for d in diffs) / (n - 1)
    se = math.sqrt(var / n)
    return m / se if se > 0 else float('nan')


def verdict_for(t, book):
    if math.isnan(t):
        return 'n/a'
    if abs(t) < 1.96:
        return 'no detectable difference'
    return 'PINNACLE SHARPER' if t > 0 else f'{book.upper()} SHARPER'


def report_pairs(fixtures, subset_fn, title, note):
    print(f'\n{title}')
    print(f'  {note}')
    print(f'  {"book":9s} {"n":>5s} {"pin_LL":>8s} {"book_LL":>8s} {"diff":>9s} '
          f'{"t":>7s}  {"pin_closer":>10s}  verdict')
    for b in ORDER:
        if b == 'pinnacle':
            continue
        pl, bl = [], []
        for fx in fixtures:
            pair = last_both(fx, 'pinnacle', b)
            if not pair:
                continue
            pp, bp = pair
            if not subset_fn(pp, bp):
                continue
            pl.append(logloss(pp, fx['ftr']))
            bl.append(logloss(bp, fx['ftr']))
        n = len(pl)
        if n < MIN_N:
            print(f'  {b:9s} {n:5d}  (below min_n={MIN_N})')
            continue
        diffs = [x - y for x, y in zip(bl, pl)]
        t = paired_t(diffs)
        wins = sum(1 for x, y in zip(pl, bl) if x < y)
        print(f'  {b:9s} {n:5d} {mean(pl):8.4f} {mean(bl):8.4f} {mean(diffs):+9.4f} '
              f'{t:+7.2f}  {wins/n*100:9.0f}%  {verdict_for(t, b)}')


def main():
    fixtures = load()
    print('Book accuracy comparison  (proportional devig; SAME-SNAPSHOT pairing)')
    print(f'fixtures with a result and >=1 complete quote: {len(fixtures)}\n')

    print('STANDALONE (each book on every fixture it quotes -- samples DIFFER '
          'and quote times differ, so NOT comparable across rows; coverage only)')
    print(f'{"book":10s} {"n":>5s} {"logloss":>9s} {"brier":>8s}')
    for b in ORDER:
        s = [f for f in fixtures if last_solo(f, b)]
        if len(s) < MIN_N:
            print(f'{b:10s} {len(s):5d}  (below min_n={MIN_N})')
            continue
        print(f'{b:10s} {len(s):5d} '
              f'{mean([logloss(last_solo(f, b), f["ftr"]) for f in s]):9.4f} '
              f'{mean([brier(last_solo(f, b), f["ftr"]) for f in s]):8.4f}')
    print(f'{"1/3 each":10s} {"":5s} {math.log(3):9.4f} {2/3:8.4f}   '
          f'uninformative baseline')

    report_pairs(
        fixtures, lambda pp, bp: True,
        'PAIRED vs PINNACLE -- ALL shared fixtures',
        'diff = book_LL - pinnacle_LL;  POSITIVE => Pinnacle sharper. '
        'pin_closer = % of fixtures Pinnacle scored better.')

    report_pairs(
        fixtures, lambda pp, bp: tvd(pp, bp) >= DISAGREE_MIN,
        f'PAIRED vs PINNACLE -- DISAGREEMENT ONLY (TVD >= {DISAGREE_MIN:.0%}, '
        f'computed WITHOUT the result)',
        'xlow only fires when the books disagree, so this is the decision-relevant slice.')

    band_premise(fixtures)

    print('\nSCALE (do not eyeball this -- an early draft of this script guessed '
          'it wrong by ~30x):')
    print('  a 1pp systematic error on a ~22% outcome costs only ~0.0003 log loss.')
    print('  So the paired test above can only resolve errors of roughly 3pp; a book '
          'that is genuinely')
    print('  1pp sharper than another is INVISIBLE at these sample sizes. "No '
          'detectable difference"')
    print('  means "no LARGE difference", not "identical".')


def band_premise(fixtures):
    """THE test of xlow's premise, on all fixtures rather than just our bets.

    xlow bets an outcome when an exchange prices it 5-12% BELOW Pinnacle fair
    (relative). The premise is that Pinnacle is right, so the outcome should
    occur near PINNACLE's rate, not the exchange's. Selection is PRICES ONLY --
    never the result -- and uses every qualifying fixture-outcome, which is
    several times the bet-level sample.

    Caveat: a fixture can contribute more than one outcome, so rows are not
    fully independent; in practice multi-qualifying fixtures are rare.
    """
    print(f'\nXLOW PREMISE TEST -- outcome-blind selection, all fixtures')
    print(f'  exchange prices outcome i {BAND[0]:.0%}-{BAND[1]:.0%} BELOW Pinnacle '
          f'fair. Whose number does reality match?')
    print(f'  SPLIT BY FREEZE_DATE {FREEZE_DATE}: the band was CHOSEN on the '
          f'in-sample rows, so only')
    print(f'  the OOS rows are evidence. Reporting pooled numbers here was a real '
          f'mistake once -- do not repeat it.')
    print(f'  {"book":9s} {"slice":6s} {"n":>5s} {"pin_says":>9s} {"book_says":>10s} '
          f'{"actual":>8s} {"z_vs_book":>10s}  {"ROI@mid":>8s}')
    for book in ORDER:
        if book == 'pinnacle':
            continue
        for label, keep in (('IN', lambda g: g <= FREEZE_DATE),
                            ('OOS', lambda g: g > FREEZE_DATE)):
            sel = []
            for fx in fixtures:
                if not keep(fx['gd'] or ''):
                    continue
                pair = last_both(fx, 'pinnacle', book)
                if not pair:
                    continue
                pp, bp = pair
                for i in range(3):
                    if pp[i] <= 0:
                        continue
                    rel = (pp[i] - bp[i]) / pp[i]
                    if BAND[0] <= rel < BAND[1]:
                        sel.append((pp[i], bp[i], 1 if IDX[fx['ftr']] == i else 0))
            n = len(sel)
            if n < 10:
                print(f'  {book:9s} {label:6s} {n:5d}  (too few)')
                continue
            pin_p = [s[0] for s in sel]
            bk_p = [s[1] for s in sel]
            hits = sum(s[2] for s in sel)
            var = sum(p * (1 - p) for p in bk_p)
            z = (hits - sum(bk_p)) / math.sqrt(var) if var > 0 else float('nan')
            roi = sum((1.0 / px - 1.0) if w else -1.0 for _, px, w in sel) / n
            print(f'  {book:9s} {label:6s} {n:5d} {mean(pin_p)*100:8.2f}% '
                  f'{mean(bk_p)*100:9.2f}% {hits/n*100:7.2f}% {z:+10.2f} '
                  f'{roi*100:+7.1f}%')
    print('  Positive z_vs_book (actual ABOVE the exchange price) = exchange too '
          'cheap = xlow works.')
    print('  ROI is mid-to-mid and ignores the spread; real fills are at the ask, '
          'so live is worse.')


if __name__ == '__main__':
    main()
