"""Can team/player statistics beat the Kalshi price? (ML experiment, 2026-09-29)

Run after ml_features.py, with the isolated env:
    E:\\soccer\\ml_env\\Scripts\\python.exe research\\ml_model.py

SPLIT (by time, never shuffled):
  train      2019/20 -> 2024-06-30
  validate   2024-07-01 -> 2025-04-30   (early stopping only)
  TEST       2025-05-01 -> now, matches with a Kalshi price at kickoff
The test window is the Kalshi era; nothing in it touches training or tuning.

MODELS, 1X2 probabilities:
  market     Kalshi de-vigged mid at KO; bookmaker average closing; Pinnacle
  stats      LightGBM on Elo + team form (+ xG where available, + SoFIFA /
             squad-value features when present) -- NO market inputs
  residual   LightGBM started FROM the bookmaker closing log-probabilities
             (init_score) and allowed to correct them with the stats. If the
             statistics carry anything the market has not priced, this is
             where it shows: trees that find nothing leave the market as is.

BETTING SIM on the test set: buy YES at the Kalshi ask at kickoff, 1 contract,
Kalshi taker fee ceil(7 * p * (1-p)) cents, when model p - ask - fee > edge.
Reports bets, P&L per contract and ROI with a bootstrap 90% interval.
"""
import math
from pathlib import Path

import lightgbm as lgb
import numpy as np
import pandas as pd

ML = Path(r'E:\soccer\research\ml_data')
TRAIN_END, VAL_END, TEST_START = '2024-07-01', '2025-05-01', '2025-05-01'
Y = {'H': 0, 'D': 1, 'A': 2}
SIDES = 'hda'
PARAMS = dict(objective='multiclass', num_class=3, learning_rate=0.02, num_leaves=15,
              min_data_in_leaf=200, feature_fraction=0.7, bagging_fraction=0.8,
              bagging_freq=1, lambda_l2=10.0, verbose=-1, seed=7)


def logloss(p, y):
    return float(-np.mean(np.log(np.clip(p[np.arange(len(y)), y], 1e-9, 1))))


def brier(p, y):
    oh = np.eye(3)[y]
    return float(np.mean(np.sum((p - oh) ** 2, axis=1)))


def stat_cols(f):
    return (['elo_diff', 'elo_home', 'elo_away']
            + [c for c in f if c.startswith(('h_f_', 'a_f_', 'sf_', 'tv_'))]
            + ['div_code'])


def fit(train, val, cols, init=None):
    dtr = lgb.Dataset(train[cols], train.y, categorical_feature=['div_code'],
                      init_score=None if init is None else init(train).ravel(order='F'))
    dva = lgb.Dataset(val[cols], val.y, categorical_feature=['div_code'], reference=dtr,
                      init_score=None if init is None else init(val).ravel(order='F'))
    return lgb.train(PARAMS, dtr, num_boost_round=3000, valid_sets=[dva],
                     callbacks=[lgb.early_stopping(100, verbose=False)])


def predict(model, df, cols, init=None):
    raw = model.predict(df[cols], raw_score=True)
    if init is not None:
        raw = raw + init(df)
    e = np.exp(raw - raw.max(axis=1, keepdims=True))
    return e / e.sum(axis=1, keepdims=True)


def market_logp(prefix):
    return lambda d: np.log(np.clip(d[[f'{prefix}_{s}' for s in SIDES]].to_numpy(float), 1e-6, 1))


def fee(ask):
    return np.ceil(7 * ask * (1 - ask) - 1e-9) / 100


def bet_sim(p, test, edge):
    asks = test[[f'k_ask_{s}' for s in SIDES]].to_numpy(float)
    won = np.eye(3)[test.y.to_numpy()]
    ev = p - asks - fee(asks)
    pick = (ev > edge) & (asks > 0.01) & (asks < 0.99)
    pnl = np.where(won == 1, 1 - asks, -asks) - fee(asks)
    x = pnl[pick]
    stake = (asks + fee(asks))[pick]
    if len(x) == 0:
        return {'bets': 0}
    rng = np.random.default_rng(0)
    boots = [x[i].sum() / stake[i].sum() for i in
             (rng.integers(0, len(x), len(x)) for _ in range(2000))]
    return {'bets': int(len(x)), 'pnl': round(float(x.sum()), 2),
            'roi': round(float(x.sum() / stake.sum()), 4),
            'roi_90ci': tuple(float(x) for x in np.round(np.percentile(boots, [5, 95]), 4))}


def main():
    f = pd.read_csv(ML / 'features.csv', parse_dates=['Date'], low_memory=False)
    f = f[f.FTR.isin(Y) & (f.h_f_n >= 3) & (f.a_f_n >= 3)].copy()
    f['y'] = f.FTR.map(Y)
    f['div_code'] = f.Div.astype('category').cat.codes
    bk_ok = f[[f'bk_avgc_{s}' for s in SIDES]].notna().all(axis=1)
    train = f[(f.Date < TRAIN_END) & bk_ok]
    val = f[(f.Date >= TRAIN_END) & (f.Date < VAL_END) & bk_ok]
    test = f[(f.Date >= TEST_START) & bk_ok
             & f[[f'k_p_ko_{s}' for s in SIDES] + [f'k_ask_{s}' for s in SIDES]].notna().all(axis=1)]
    cols = stat_cols(f)
    print(f'train {len(train)}  validate {len(val)}  TEST (Kalshi-priced) {len(test)} '
          f'across {test.Div.nunique()} divisions, {test.Date.min():%Y-%m-%d} -> '
          f'{test.Date.max():%Y-%m-%d}')
    print(f'{len(cols)} stat features: {", ".join(c for c in cols if not c.startswith(("h_f_", "a_f_")))}'
          f' + {sum(c.startswith(("h_f_", "a_f_")) for c in cols)} form columns')

    y = test.y.to_numpy()
    preds = {'kalshi KO': test[[f'k_p_ko_{s}' for s in SIDES]].to_numpy(float),
             'kalshi KO-1h': test[[f'k_p_1h_{s}' for s in SIDES]].to_numpy(float),
             'bookmaker avg close': test[[f'bk_avgc_{s}' for s in SIDES]].to_numpy(float)}
    m_stats = fit(train, val, cols)
    preds['stats only'] = predict(m_stats, test, cols)
    m_res = fit(train, val, cols, init=market_logp('bk_avgc'))
    preds['bk close + stats'] = predict(m_res, test, cols, init=market_logp('bk_avgc'))
    # the same correction applied on top of Kalshi's own KO price
    kal = lambda d: np.log(np.clip(d[[f'k_p_ko_{s}' for s in SIDES]].to_numpy(float), 1e-6, 1))
    preds['kalshi KO + stats'] = predict(m_res, test, cols, init=kal)
    print(f'\nstats model: {m_stats.best_iteration} trees; residual model: '
          f'{m_res.best_iteration} trees (0-few = the stats found nothing to add)')

    print(f'\n{"model":22} {"log loss":>9} {"brier":>7}   (lower is better; TEST n={len(test)})')
    for name, p in preds.items():
        ok = ~np.isnan(p).any(axis=1)
        print(f'{name:22} {logloss(p[ok], y[ok]):9.4f} {brier(p[ok], y[ok]):7.4f}'
              + ('' if ok.all() else f'   (n={ok.sum()})'))

    print('\nbetting sim, buy YES at the Kalshi ask at KO, 1 contract, fees included:')
    for name in ('stats only', 'bk close + stats', 'kalshi KO + stats', 'bookmaker avg close'):
        for edge in (0.0, 0.02, 0.05):
            print(f'  {name:22} edge>{edge:.2f}  {bet_sim(preds[name], test, edge)}')

    imp = pd.Series(m_stats.feature_importance('gain'), index=cols).sort_values(ascending=False)
    print('\nstats model, top features by gain:')
    print((imp / imp.sum()).head(15).round(3).to_string())
    out = test[['Div', 'Date', 'HomeTeam', 'AwayTeam', 'FTR']].copy()
    for name, p in preds.items():
        for i, s in enumerate(SIDES):
            out[f'{name.replace(" ", "_")}_{s}'] = p[:, i]
    out.to_csv(ML / 'model_test_predictions.csv', index=False)


if __name__ == '__main__':
    main()
