"""
Statistically-honest league-pruning toolkit for the live Kalshi CLV strategy.
Reconstructs the signal set (no exclusions) and runs four analyses:

  A. Empirical-Bayes shrinkage  -- pulls each league's estimate toward the global
     mean by an amount set by its noise, so small-n leagues can't look extreme.
       A1 beta-binomial on WIN RATE
       A2 normal-normal (James-Stein) on per-bet NET ROI  -> P(true edge < 0)
  B. Benjamini-Hochberg FDR     -- per-league test vs breakeven, corrected for
     testing many leagues at once.
  C. Minimum-n eligibility gate -- a league isn't judged until it has >= MIN_N bets.
  D. Walk-forward validation of "drop leagues below an ROI threshold": tune the
     drop-set on TRAIN, lock it, score on the untouched TEST. Plus the key
     diagnostic: does TRAIN league-ROI even predict TEST league-ROI?

Pure analysis -- nothing here touches the live pipeline.
"""
import os, sys
import numpy as np, pandas as pd
from scipy import stats
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
os.chdir(r'E:\soccer')
pd.set_option('display.width', 170)

EDGE_LO, EDGE_HI = 0.05, 0.12
SIDES = ['home', 'away']
GAME = ['game_date', 'home', 'away']
SIDE_LETTER = {'home': 'H', 'away': 'A'}
MIN_N = 40            # part C eligibility gate
SPLIT = 0.60          # part D train fraction

# ---------- reconstruct the exact signal set (mirrors strategy.ipynb), NO league exclusions ----------
dfs = [pd.read_csv('live_data/' + f) for f in os.listdir('live_data')
       if f.endswith('.csv') and f != 'test.csv']
df = pd.concat(dfs, ignore_index=True)
df['ts'] = pd.to_datetime(df['timestamp'], format='mixed', errors='coerce')
df = df[df['ts'].notna()]
pre = df[(df['status'] == 'PRE') & (df['elapsed'] < 0)].copy()
entries = []
for side in SIDES:
    c = f'clv_kalshi_{side}'
    q = pre[(pre[c] >= EDGE_LO) & (pre[c] < EDGE_HI)].copy()
    q = q.sort_values('ts').drop_duplicates(GAME, keep='first')
    q['side'] = side; q['clv'] = q[c]
    q['ask'] = q[f'kalshi_{side}_ask']; q['p_win'] = q[f'pin_fair_{side}']
    entries.append(q)
sig = pd.concat(entries, ignore_index=True)
sig = sig.sort_values('clv', ascending=False).drop_duplicates(GAME, keep='first')
sig = sig.sort_values('ts').reset_index(drop=True)
sig['odds'] = np.where(sig['ask'] > 0, 1.0 / sig['ask'], np.nan)
sig['won'] = (sig['ftr'] == sig['side'].map(SIDE_LETTER)).astype(int)
sig = sig.dropna(subset=['odds', 'p_win', 'ftr']).reset_index(drop=True)
sig['net'] = np.where(sig['won'] == 1, sig['odds'] - 1, -1.0)

N = len(sig); K = sig['league'].nunique()
print(f"reconstructed {N} bets across {K} leagues | overall WR {sig.won.mean():.1%} | ROI {sig.net.mean():+.1%}\n")

# ============================================================ A1  BETA-BINOMIAL EB (win rate)
print("="*110)
print("A1  EMPIRICAL-BAYES SHRINKAGE on WIN RATE (beta-binomial prior fit by method of moments)")
g = sig.groupby('league')
per = pd.DataFrame({'n': g.size(), 'wins': g['won'].sum(),
                    'wr': g['won'].mean(), 'roi': g['net'].mean(), 'net': g['net'].sum()})
m = sig['won'].mean()
# MoM Beta prior: match grand mean m and the between-league dispersion beyond binomial noise
var_obs = np.average((per['wr'] - m)**2, weights=per['n'])
samp_var = m*(1-m)*np.average(1.0/per['n'], weights=per['n'])
tau2_wr = max(var_obs - samp_var, 1e-6)
kappa = max(m*(1-m)/tau2_wr - 1, 1.0)          # prior "strength" (pseudo-sample size)
a0, b0 = m*kappa, (1-m)*kappa
print(f"  prior: Beta(a0={a0:.1f}, b0={b0:.1f})  -> prior mean {m:.1%}, strength ~{kappa:.0f} pseudo-bets")
per['wr_shrunk'] = (a0 + per['wins']) / (a0 + b0 + per['n'])
# 90% posterior credible interval on win rate
per['wr_lo'] = stats.beta.ppf(0.05, a0 + per['wins'], b0 + per['n'] - per['wins'])
per['wr_hi'] = stats.beta.ppf(0.95, a0 + per['wins'], b0 + per['n'] - per['wins'])
show = per.sort_values('wr_shrunk')
print(f"  {'league':<8} {'n':>4} {'raw WR':>7} {'shrunk WR':>10} {'90% CI':>18}")
for lg, r in show.iterrows():
    print(f"  {lg:<8} {int(r.n):>4} {r.wr:>7.1%} {r.wr_shrunk:>10.1%}   [{r.wr_lo:>5.1%},{r.wr_hi:>5.1%}]")

# ============================================================ A2  NORMAL EB (net ROI)
print("\n" + "="*110)
print("A2  EMPIRICAL-BAYES SHRINKAGE on per-bet NET ROI (James-Stein normal model) -> P(true edge<0)")
mu = sig['net'].mean()
# pooled within-league variance of net
ss = sum(((sig.loc[sig.league == lg, 'net'] - per.loc[lg, 'roi'])**2).sum() for lg in per.index)
sigma2 = ss / (N - K)
per['s2'] = sigma2 / per['n']                              # sampling variance of each league mean
tau2 = max(per['roi'].var(ddof=1) - per['s2'].mean(), 0.0) # between-league variance of TRUE edges
per['w'] = tau2 / (tau2 + per['s2']) if tau2 > 0 else 0.0
per['roi_shrunk'] = mu + per['w'] * (per['roi'] - mu)
per['post_sd'] = np.sqrt(1.0 / (1.0/max(tau2,1e-12) + 1.0/per['s2'])) if tau2 > 0 else 0.0
per['p_neg'] = stats.norm.cdf((0 - per['roi_shrunk']) / per['post_sd'].replace(0, np.nan))
print(f"  pooled within-league sd(net)={np.sqrt(sigma2):.3f} | between-league sd(edge)={np.sqrt(tau2):.3f} "
      f"({'leagues DO differ' if tau2>0 else 'tau2=0 -> no detectable between-league signal; all shrink to global'})")
print(f"  {'league':<8} {'n':>4} {'raw ROI':>8} {'shrunk ROI':>11} {'shrink w':>9} {'P(edge<0)':>10}")
for lg, r in per.sort_values('roi_shrunk').iterrows():
    pn = '   n/a' if pd.isna(r.p_neg) else f'{r.p_neg:>9.1%}'
    print(f"  {lg:<8} {int(r.n):>4} {r.roi:>8.1%} {r.roi_shrunk:>11.1%} {r.w:>9.2f} {pn}")

# ============================================================ B  BH-FDR
print("\n" + "="*110)
print("B  BENJAMINI-HOCHBERG FDR  -- per-league two-sided test vs breakeven (mean net = 0)")
rows = []
for lg, r in per.iterrows():
    s = sig.loc[sig.league == lg, 'net']
    if len(s) < 2 or s.std(ddof=1) == 0:
        continue
    z = s.mean() / (s.std(ddof=1) / np.sqrt(len(s)))
    p = 2 * (1 - stats.norm.cdf(abs(z)))
    rows.append((lg, len(s), s.mean(), z, p))
fdr = pd.DataFrame(rows, columns=['league', 'n', 'roi', 'z', 'p']).sort_values('p').reset_index(drop=True)
mm = len(fdr)
fdr['rank'] = fdr.index + 1
fdr['bh_q'] = (fdr['p'] * mm / fdr['rank']).iloc[::-1].cummin().iloc[::-1]   # monotone BH q-values
print(f"  testing {mm} leagues; flag = BH q<0.05 AND losing")
print(f"  {'league':<8} {'n':>4} {'ROI':>8} {'z':>7} {'raw p':>8} {'BH q':>8}  flag")
for _, r in fdr.iterrows():
    flag = 'LOSE*' if (r.bh_q < 0.05 and r.roi < 0) else ''
    print(f"  {r.league:<8} {int(r.n):>4} {r.roi:>8.1%} {r.z:>+7.2f} {r.p:>8.4f} {r.bh_q:>8.4f}  {flag}")
sig_lose = fdr[(fdr.bh_q < 0.05) & (fdr.roi < 0)]['league'].tolist()
print(f"  --> survive FDR as genuinely unprofitable: {sig_lose if sig_lose else 'NONE'}")

# ============================================================ C  MIN-N GATE
print("\n" + "="*110)
print(f"C  MINIMUM-N ELIGIBILITY GATE (MIN_N={MIN_N}) -- below this, no power; pool the league")
elig = per[per.n >= MIN_N].index.tolist()
print(f"  eligible for judgement: {elig if elig else 'NONE'}  ({len(elig)}/{K} leagues, "
      f"{int(per.loc[elig,'n'].sum()) if elig else 0}/{N} bets)")
print(f"  everything else is statistically indistinguishable from the pooled rate at current n.")

# ============================================================ D  WALK-FORWARD drop-rule
print("\n" + "="*110)
print(f"D  WALK-FORWARD: tune a 'drop leagues below ROI threshold' rule on TRAIN (first {SPLIT:.0%}), score TEST")
so = sig.sort_values('ts').reset_index(drop=True)
cut = int(len(so) * SPLIT)
tr, te = so.iloc[:cut], so.iloc[cut:]

def zp(net):
    n = len(net)
    if n < 2: return float('nan'), float('nan')
    z = net.mean() / (net.std(ddof=1) / np.sqrt(n))
    return z, 1 - stats.norm.cdf(z)            # one-sided (edge>0)

# D0 -- the crux: does TRAIN league-ROI predict TEST league-ROI at all?
tr_roi = tr.groupby('league')['net'].agg(['mean', 'size'])
te_roi = te.groupby('league')['net'].mean()
both = tr_roi.join(te_roi.rename('te_roi'), how='inner').dropna()
both = both[both['size'] >= 5]
if len(both) >= 3:
    pr = stats.pearsonr(both['mean'], both['te_roi'])
    sp = stats.spearmanr(both['mean'], both['te_roi'])
    print(f"  D0 PERSISTENCE (leagues with >=5 TRAIN bets, n={len(both)} leagues):")
    print(f"     corr(TRAIN ROI, TEST ROI):  Pearson r={pr[0]:+.2f} (p={pr[1]:.2f}) | Spearman rho={sp[0]:+.2f} (p={sp[1]:.2f})")
    print(f"     --> if ~0, a league being bad in TRAIN tells you nothing about TEST, and dropping CANNOT work.")
else:
    print("  D0: too few overlapping leagues to assess persistence.")

zb, pb = zp(te['net'].values)
print(f"\n  TEST baseline (drop nothing): n={len(te):>3} | ROI {te.net.mean():>+7.1%} | z={zb:+.2f} | p={pb:.4f}")
print(f"  {'rule (drop if TRAIN ROI < thr & TRAIN league-n >= minL)':<52} {'dropped':<22} {'TESTn':>5} {'TESTroi':>8} {'p':>7}")
best = None
for minL in (1, 5, 10):
    for thr in (0.0, -0.10, -0.25, -0.50):
        drop = tr_roi[(tr_roi['mean'] < thr) & (tr_roi['size'] >= minL)].index.tolist()
        kept = te[~te.league.isin(drop)]
        if len(kept) < 2: continue
        z, p = zp(kept['net'].values)
        tag = f"thr<{thr:+.0%}, minL>={minL}"
        dd = ','.join(drop) if drop else '(none)'
        print(f"  {tag:<52} {dd[:22]:<22} {len(kept):>5} {kept.net.mean():>+8.1%} {p:>7.4f}")
        # lock the rule that maximises TRAIN gated ROI
        tr_kept = tr[~tr.league.isin(drop)]
        if len(tr_kept) > 0:
            troi = tr_kept.net.mean()
            if best is None or troi > best[0]:
                best = (troi, thr, minL, drop)
if best is not None:
    _, thr, minL, drop = best
    kept = te[~te.league.isin(drop)]
    z, p = zp(kept['net'].values)
    print(f"\n  LOCKED rule (max TRAIN ROI): thr<{thr:+.0%}, minL>={minL}  drops {drop if drop else '(none)'}")
    print(f"  -> TEST: n={len(kept)} | ROI {kept.net.mean():+.1%} | p={p:.4f}   vs baseline p={pb:.4f}")
    print(f"  Honest read: if the locked rule's TEST p is not clearly below {pb:.4f}, league-dropping is in-sample noise.")
