"""
Is Pinnacle structurally mis-calibrated in SOUTH AMERICA? (read-only, trades nothing)

Built 2026-07-25 from the user's hypothesis: "there's something structurally wrong
with South American leagues." Pooling regions is the point -- no single SA league
has the sample to say anything (BSA n=32, PE1 n=12, CL1 n=8), but pooled they do.

Same engine as pin_calibration_by_league.py: EVERY fixture with Pinnacle odds and
a result, not just the ones we bet.

THE THREE CONTROLS THAT MAKE THIS MORE THAN A SCAN:
  1. DROP THE MOTIVATING LEAGUE. The hypothesis came from BSA's 0-for-12, and BSA
     is 44% of the SA sample. If the pattern is really just BSA, removing it kills
     the effect. (It doesn't -- the effect gets STRONGER.)
  2. SIGN CONSISTENCY across independent leagues. pin_calibration_by_league's own
     standing rule: randomly-signed deviations at small n = noise; a real
     structural bias is consistent across comparable leagues. Reported as a sign
     test.
  3. WHICH SIDE WE ACTUALLY BET. A calibration error only costs us money if it
     lands on the side we take. This is the step that stops a real finding from
     being mis-read as an explanation for a bad record.

RESULT ON FIRST RUN (2026-07-25):
  SA n=73: homes implied 48.2% -> actual 60.3% (z+2.06); draws 27.4% -> 16.4%
  (z-2.10); aways 24.4% -> 23.3% (z-0.21). Excluding BSA (n=41) the home/draw
  effect PERSISTS (z+1.87 / -1.55). Home z>0 in 5/5 SA leagues (sign test p=.062).
  Europe/N.America/Asia all flat (|z| <= 1.3).
  BUT: 25 of our 28 SA bets are AWAY, and away is the one side calibrated fine.
  So the finding does NOT explain the -35.3% SA record (which is z=-0.46 on win
  count = noise). If anything the home under-pricing is an OPPORTUNITY.

Usage:  python research\\region_calibration_test.py
"""
import sys
import math

sys.path.insert(0, r'E:\soccer\research')
import pin_calibration_by_league as P
import forward_test_unique_book as F

SA = {'BSA', 'BR2', 'AR1', 'CL1', 'CO1', 'PE1'}
NA = {'MLS', 'USL', 'MX1', 'CAN', 'NWSL'}
ASIA = {'JP1', 'KO1', 'CN1'}
MOTIVATING = 'BSA'      # the league that generated the hypothesis -- control for it


def region(lg):
    if lg in SA:
        return 'S.America'
    if lg in NA:
        return 'N.America'
    if lg in ASIA:
        return 'Asia'
    return 'Europe'


def z_for(n, p, actual):
    sd = math.sqrt(n * p * (1 - p))
    return (actual - p * n) / sd if sd > 0 else float('nan')


def detectable(n, base=0.45):
    """Smallest calibration error (pp) detectable at 95% conf / 80% power."""
    return 2.8 * math.sqrt(base * (1 - base) / n) * 100


def line(s, label):
    n = len(s)
    if n < 5:
        print(f'  {label:26s} n={n:4d}  -- too few')
        return
    out = [f'  {label:26s} n={n:4d}']
    for key, letter in (('pfh', 'H'), ('pfd', 'D'), ('pfa', 'A')):
        imp = sum(x[key] for x in s) / n
        cnt = sum(x['ftr'] == letter for x in s)
        out.append(f'{letter} {imp*100:5.1f}->{cnt/n*100:5.1f} '
                   f'(z{z_for(n, imp, cnt):+5.2f})')
    out.append(f'| detects {detectable(n):.0f}pp')
    print('  '.join(out))


def main():
    fx = P.load_fixtures()

    print('A) PINNACLE CALIBRATION BY REGION (all fixtures with a result)\n')
    for rg in ('S.America', 'Europe', 'N.America', 'Asia'):
        line([f for f in fx if region(f['lg']) == rg], rg)

    print(f'\nB) CONTROL -- does SA survive dropping {MOTIVATING} '
          f'(the league that raised the question)?\n')
    line([f for f in fx if f['lg'] in SA], 'SA all')
    line([f for f in fx if f['lg'] in SA and f['lg'] != MOTIVATING],
         f'SA excluding {MOTIVATING}')
    line([f for f in fx if f['lg'] == MOTIVATING], f'{MOTIVATING} alone')

    print('\nC) SIGN CONSISTENCY across SA leagues (noise is randomly signed)\n')
    rows = []
    for lg in sorted(SA):
        s = [f for f in fx if f['lg'] == lg]
        n = len(s)
        if n < 5:
            continue
        iH = sum(x['pfh'] for x in s) / n
        cH = sum(x['ftr'] == 'H' for x in s)
        iD = sum(x['pfd'] for x in s) / n
        cD = sum(x['ftr'] == 'D' for x in s)
        rows.append((lg, n, z_for(n, iH, cH), z_for(n, iD, cD)))
        print(f'  {lg:6s} n={n:4d}  home z{rows[-1][2]:+5.2f}   draw z{rows[-1][3]:+5.2f}')
    k = len(rows)
    ph = sum(1 for r in rows if r[2] > 0)
    pd_ = sum(1 for r in rows if r[3] < 0)
    print(f'\n  home z>0 in {ph}/{k} leagues, draw z<0 in {pd_}/{k}')
    print(f'  P(all {k} one way by chance) = {2 * 0.5 ** k:.3f}  (two-tailed sign test)')

    print('\nD) DOES IT LAND ON THE SIDE WE BET? (forward bets, post-freeze)\n')
    bets = []
    for f in F.load_fixtures():
        b = fire_with_side(f)
        if b and f['gd'] > F.FREEZE_DATE:
            b['sa'] = f['league'] in SA
            bets.append(b)
    for lbl, sel in (('South America', lambda b: b['sa']),
                     ('rest of world', lambda b: not b['sa'])):
        s = [b for b in bets if sel(b)]
        for side in ('home', 'away'):
            ss = [b for b in s if b['side'] == side]
            if not ss:
                continue
            n = len(ss)
            w = sum(b['won'] for b in ss)
            roi = sum((1 / b['px'] - 1) if b['won'] else -1 for b in ss) / n
            print(f'  {lbl:14s} {side:5s} n={n:3d} W={w:2d} WR={w/n*100:5.1f}% '
                  f'implied={sum(b["px"] for b in ss)/n*100:5.1f}% ROI={roi*100:+7.1f}%')
        print()
    print('  A calibration error only costs money on the side we actually take.')


def fire_with_side(fx):
    """fire_xlow, but keeping which side qualified (the base version drops it)."""
    for r in fx['pre']:
        ph, pd_, pa = (F._f(r.get('pin_home')), F._f(r.get('pin_draw')),
                       F._f(r.get('pin_away')))
        if None in (ph, pd_, pa) or not (ph > 1 and pd_ > 1 and pa > 1):
            continue
        pf = F.fair_mult((ph, pd_, pa))
        cands = []
        for side, si in F.SIDX.items():
            km, ka = F.book_quote(r, 'kalshi', side)
            pm, pa_ = F.book_quote(r, 'poly', 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 = km, ka, pm
            else:
                mid, px, other = pm, pa_, km
            if px is None or not (0 < px < 1):
                continue
            clv = (pf[si] - mid) / pf[si]
            oc = (pf[si] - other) / pf[si]
            if not (mid < other and F.BAND[0] <= clv < F.BAND[1]):
                continue
            if F.BAND[0] <= oc < F.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, side=side))
        if cands:
            return max(cands, key=lambda d: d['clv'])
    return None


if __name__ == '__main__':
    main()
