#!/usr/bin/env python3
"""
亚盘模型 v4 - Optuna超参搜索 + 校准 + 增强特征
"""
from __future__ import annotations
import os, sys, json, warnings
from collections import defaultdict
from pathlib import Path
from datetime import datetime
import numpy as np
import pandas as pd
import joblib
from sklearn.ensemble import ExtraTreesClassifier, RandomForestClassifier, HistGradientBoostingClassifier
from sklearn.calibration import CalibratedClassifierCV
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import accuracy_score
from sklearn.model_selection import StratifiedKFold

warnings.filterwarnings('ignore')

try:
    import lightgbm as lgb_lib
    HAS_LGB = True
except:
    HAS_LGB = False

try:
    import xgboost as xgb_lib
    HAS_XGB = True
except:
    HAS_XGB = False

try:
    import optuna
    optuna.logging.set_verbosity(optuna.logging.WARNING)
    HAS_OPTUNA = True
except:
    HAS_OPTUNA = False

REPO = Path("/root/.openclaw/workspace-flj_bot1/football-predictor-repro")
HIST = REPO / "historical_data"
OUT = REPO / "models/ah_models_v4"
OUT.mkdir(parents=True, exist_ok=True)

LNAME = {
    "e2":"英冠","e3":"英乙","e1":"英甲","d1":"德甲",
    "f1":"法甲","i1":"意甲","sp1":"西甲","sc1":"苏超",
    "tr1":"土超","p1":"葡超","e2n":"荷乙","cz1":"捷克甲",
}


def pr(odds):
    try:
        v = float(odds)
        return 1.0 / v if v > 1 else np.nan
    except:
        return np.nan


def avg5(v):
    if not v:
        return 2.5
    v = list(v)
    if len(v) >= 5:
        return float(np.mean(v[-5:]))
    return float(np.mean(v)) if v else 2.5


def avg3(v):
    if not v:
        return 2.5
    v = list(v)
    if len(v) >= 3:
        return float(np.mean(v[-3:]))
    return float(np.mean(v)) if v else 2.5


def wr(v):
    if not v:
        return 0.5
    return float(np.mean(list(v)))


def strk(v, t):
    s = 0
    for x in reversed(list(v) if v else []):
        if x == t:
            s += 1
        else:
            break
    return s


def load_data(code):
    hp = HIST / (code + "_history.csv")
    if not hp.exists():
        return pd.DataFrame()
    df = pd.read_csv(hp, low_memory=False)
    df["Date"] = pd.to_datetime(df["Date"], errors="coerce", dayfirst=True)
    df = df[(df["Date"] >= "2023-01-01") & (df["Date"] <= "2025-12-31")]
    needed = ["FTHG", "FTAG", "AHh", "B365AHH", "B365AHA"]
    df = df.dropna(subset=needed)
    df = df[df["B365AHH"].astype(float) > 1].copy().reset_index(drop=True)

    # 目标
    df["gd"] = df["FTHG"].astype(float) - df["FTAG"].astype(float)
    df["gd_ah"] = df["gd"] + df["AHh"].astype(float)
    df = df[df["gd_ah"].abs() >= 0.01].reset_index(drop=True)
    df["target"] = (df["gd_ah"] > 0).astype(int)

    # 庄家AH概率
    bm_ph = df["B365AHH"].apply(pr)
    bm_pa = df["B365AHA"].apply(pr)
    ov_sum = bm_ph + bm_pa
    df["bm_ph_n"] = bm_ph / ov_sum
    df["bm_pa_n"] = bm_pa / ov_sum
    df["bm_dir"] = (df["bm_ph_n"] > 0.5).astype(int)
    df["bm_conf"] = (df["bm_ph_n"] - 0.5).abs() * 2
    df["bm_edge"] = df["bm_ph_n"] - 0.5
    df["bm_bal"] = (df["B365AHH"].astype(float) - df["B365AHA"].astype(float)).abs()
    if "B365H" in df.columns:
        bh = df["B365H"].apply(pr)
        ba = df["B365A"].apply(pr)
        df["bm_3w_dir"] = (bh > ba).astype(int)
    else:
        df["bm_3w_dir"] = 0
    return df


def build_features(df):
    ts = defaultdict(lambda: {
        "gf": [], "ga": [], "tot": [], "ov": [],
        "hm_gf": [], "hm_ga": [],
        "ah_h": [], "ah_a": [],
    })
    lg = {"tot": [], "ov": [], "ah_h": []}

    Xl = []
    yl = []
    info = []
    WARM = 10

    for i, row in df.iterrows():
        hm = str(row["HomeTeam"])
        aw = str(row["AwayTeam"])
        hg = float(row["FTHG"])
        ag = float(row["FTAG"])
        ahh = float(row["AHh"])
        bm_ph = float(row["bm_ph_n"])
        bm_pa = float(row["bm_pa_n"])
        bm_dir = int(row["bm_dir"])
        bm_3w = int(row.get("bm_3w_dir", 0))

        if i < WARM:
            _upd(ts, lg, hm, aw, hg, ag, ahh)
            continue

        h = ts.get(hm, {})
        a = ts.get(aw, {})
        lg_ah = wr(lg["ah_h"][-50:]) if lg["ah_h"] else 0.5
        lg_ov10 = wr(lg["ov"][-10:]) if lg["ov"] else 0.5
        lg_tot = float(np.mean(lg["tot"])) if lg["tot"] else 2.5

        ht_ah10 = wr(h.get("ah_h", [])[-10:] if h.get("ah_h") else [])
        at_ah10 = wr(a.get("ah_a", [])[-10:] if a.get("ah_a") else [])
        ht_ah20 = wr(h.get("ah_h", [])[-20:] if h.get("ah_h") else [])
        at_ah20 = wr(a.get("ah_a", [])[-20:] if a.get("ah_a") else [])
        ht_ga10 = wr(h.get("ga", [])[-10:] if h.get("ga") else [])
        at_gf10 = wr(a.get("gf", [])[-10:] if a.get("gf") else [])

        ht_gf = avg5(h.get("gf", []))
        at_gf = avg5(a.get("gf", []))
        ht_ga = avg5(h.get("ga", []))
        at_ga = avg5(a.get("ga", []))
        eg_diff = ht_gf - at_ga

        f = {}
        f["bm_prob_home"] = bm_ph
        f["bm_prob_away"] = bm_pa
        f["bm_edge"] = bm_ph - 0.5
        f["bm_conf"] = abs(bm_ph - 0.5) * 2
        f["bm_balance"] = float(row.get("bm_bal", 0))
        f["bm_dir"] = bm_dir
        f["ahh"] = ahh
        f["ahh_abs"] = abs(ahh)
        f["ah_strong"] = 1.0 if abs(ahh) >= 1.0 else 0.0
        f["ah_medium"] = 1.0 if 0.25 <= abs(ahh) < 1.0 else 0.0
        f["ah_weak"] = 1.0 if abs(ahh) < 0.25 else 0.0
        f["ah_away"] = 1.0 if ahh > 0 else 0.0
        f["ah_home"] = 1.0 if ahh < 0 else 0.0
        f["bm_vs_ht10"] = float(bm_dir) - ht_ah10
        f["bm_vs_at10"] = float(1 - bm_dir) - at_ah10
        f["bm_vs_lg"] = bm_ph - lg_ah
        f["ht_ah10"] = ht_ah10
        f["at_ah10"] = at_ah10
        f["ht_ah20"] = ht_ah20
        f["at_ah20"] = at_ah20
        f["ht_ah_strk"] = strk(h.get("ah_h", []), 1)
        f["at_ah_strk"] = strk(a.get("ah_a", []), 1)
        f["ht_home_ah"] = wr(h.get("ah_h", [])[-10:] if h.get("ah_h") else [])
        f["at_away_ah"] = wr(a.get("ah_a", [])[-10:] if a.get("ah_a") else [])
        f["ht_gf_avg"] = ht_gf
        f["ht_ga_avg"] = ht_ga
        f["at_gf_avg"] = at_gf
        f["at_ga_avg"] = at_ga
        f["ht_tot_avg"] = avg5(h.get("tot", []))
        f["at_tot_avg"] = avg5(a.get("tot", []))
        f["ht_ov10"] = wr(h.get("ov", [])[-10:] if h.get("ov") else [])
        f["at_ov10"] = wr(a.get("ov", [])[-10:] if a.get("ov") else [])
        f["ht_gf5"] = avg3(h.get("gf", []))
        f["at_gf5"] = avg3(a.get("gf", []))
        f["ht_ov5"] = wr(h.get("ov", [])[-5:] if h.get("ov") else [])
        f["at_ov5"] = wr(a.get("ov", [])[-5:] if a.get("ov") else [])
        f["form_diff"] = wr(h.get("ov", [])[-5:]) - wr(a.get("ov", [])[-5:]) if (h.get("ov") and a.get("ov")) else 0
        f["eg_diff"] = eg_diff
        f["att_diff"] = ht_gf - at_gf
        f["def_diff"] = at_ga - ht_ga
        f["lg_ah"] = lg_ah
        f["lg_tot"] = lg_tot
        f["lg_ov10"] = lg_ov10
        f["bm_3w_dir"] = bm_3w
        f["mkt_align"] = 1.0 if bm_dir == bm_3w else 0.0
        f["dow"] = row["Date"].dayofweek if hasattr(row["Date"], "dayofweek") else 3
        f["month"] = row["Date"].month if hasattr(row["Date"], "month") else 6
        f["bm_ht_val"] = bm_ph - ht_ah10
        f["bm_at_val"] = bm_pa - at_ah10
        f["bm_lg_val"] = bm_ph - lg_ah
        f["conf_ht_interact"] = abs(bm_ph - 0.5) * 2 * ht_ah10
        f["eg_ah_interact"] = eg_diff * ht_ah10
        f["ht_at_ah_diff"] = ht_ah10 - at_ah10
        f["ht_gf_std"] = float(np.std(list(h.get("gf", [])[-10:]), ddof=0)) if len(h.get("gf", [])) >= 2 else 0.0
        f["at_gf_std"] = float(np.std(list(a.get("gf", [])[-10:]), ddof=0)) if len(a.get("gf", [])) >= 2 else 0.0
        f["ht_ah_L3"] = float(sum(list(h.get("ah_h", [])[-3:]) if h.get("ah_h") else []))
        f["at_ah_L3"] = float(sum(list(a.get("ah_a", [])[-3:]) if a.get("ah_a") else []))
        f["ht_ah_L5"] = float(sum(list(h.get("ah_h", [])[-5:]) if h.get("ah_h") else []))
        f["at_ah_L5"] = float(sum(list(a.get("ah_a", [])[-5:]) if a.get("ah_a") else []))

        Xl.append(f)
        yl.append(int(row["target"]))
        info.append({"bm_prob": bm_ph, "bm_dir": bm_dir, "ahh": ahh})
        _upd(ts, lg, hm, aw, hg, ag, ahh)

    cols = list(Xl[0].keys()) if Xl else []
    return pd.DataFrame(Xl)[cols].fillna(0), np.array(yl), info


def _upd(ts, lg, hm, aw, hg, ag, ahh):
    tot = hg + ag
    ov = int(tot > 2.5)
    ahw = int(hg - ag + ahh > 0)
    h = ts[hm]
    a = ts[aw]
    h["gf"].append(hg); h["ga"].append(ag); h["tot"].append(tot); h["ov"].append(ov)
    h["hm_gf"].append(hg); h["hm_ga"].append(ag)
    h["ah_h"].append(ahw)
    a["gf"].append(ag); a["ga"].append(hg); a["tot"].append(tot); a["ov"].append(ov)
    a["ah_a"].append(1 - ahw)
    lg["tot"].append(tot); lg["ov"].append(ov); lg["ah_h"].append(ahw)


def objective_factory(Xtr, ytr):
    def objective(trial):
        mtype = trial.suggest_categorical("mtype", ["lgb", "xgb", "hgb"])
        sc = StandardScaler()
        Xs = sc.fit_transform(Xtr)
        skf = StratifiedKFold(n_splits=3, shuffle=True, random_state=42)
        scores = []
        for tr_i, va_i in skf.split(Xs, ytr):
            if mtype == "lgb" and HAS_LGB:
                clf = lgb_lib.LGBMClassifier(
                    n_estimators=trial.suggest_int("n", 100, 500),
                    learning_rate=trial.suggest_float("lr", 0.005, 0.15, log=True),
                    max_depth=trial.suggest_int("md", 2, 8),
                    num_leaves=trial.suggest_int("nl", 8, 64),
                    min_child_samples=trial.suggest_int("mcs", 5, 50),
                    subsample=trial.suggest_float("ss", 0.5, 1.0),
                    colsample_bytree=trial.suggest_float("cbt", 0.4, 1.0),
                    reg_alpha=trial.suggest_float("ra", 1e-3, 10.0, log=True),
                    reg_lambda=trial.suggest_float("rl", 1e-3, 10.0, log=True),
                    random_state=42, verbosity=-1, n_jobs=-1
                )
            elif mtype == "xgb" and HAS_XGB:
                clf = xgb_lib.XGBClassifier(
                    n_estimators=trial.suggest_int("n", 100, 500),
                    learning_rate=trial.suggest_float("lr", 0.005, 0.15, log=True),
                    max_depth=trial.suggest_int("md", 2, 8),
                    min_child_weight=trial.suggest_int("mcw", 5, 50),
                    subsample=trial.suggest_float("ss", 0.5, 1.0),
                    colsample_bytree=trial.suggest_float("cbt", 0.4, 1.0),
                    reg_alpha=trial.suggest_float("ra", 1e-3, 10.0, log=True),
                    reg_lambda=trial.suggest_float("rl", 1e-3, 10.0, log=True),
                    random_state=42, verbosity=0, n_jobs=-1
                )
            elif mtype == "hgb":
                clf = HistGradientBoostingClassifier(
                    max_iter=trial.suggest_int("mi", 100, 500),
                    learning_rate=trial.suggest_float("lr", 0.005, 0.15, log=True),
                    max_depth=trial.suggest_int("md", 2, 8),
                    min_samples_leaf=trial.suggest_int("msl", 5, 50),
                    l2_regularization=trial.suggest_float("l2", 1e-3, 10.0, log=True),
                    random_state=42
                )
            elif mtype == "et":
                clf = ExtraTreesClassifier(
                    n_estimators=trial.suggest_int("n", 100, 500),
                    max_depth=trial.suggest_int("md", 3, 12),
                    min_samples_leaf=trial.suggest_int("msl", 3, 30),
                    min_samples_split=trial.suggest_int("mss", 3, 30),
                    random_state=42, n_jobs=-1
                )
            else:
                clf = RandomForestClassifier(
                    n_estimators=trial.suggest_int("n", 100, 500),
                    max_depth=trial.suggest_int("md", 3, 12),
                    min_samples_leaf=trial.suggest_int("msl", 5, 50),
                    random_state=42, n_jobs=-1
                )
            clf.fit(Xs[tr_i], ytr[tr_i])
            sc_v = accuracy_score(ytr[va_i], clf.predict(Xs[va_i]))
            scores.append(sc_v)
        return float(np.mean(scores))
    return objective


def train(code):
    print(f"\n{'='*60}\n{code} {LNAME.get(code, '')}\n{'='*60}")
    df = load_data(code)
    if df.empty:
        print("  无数据")
        return None
    print(f"  {len(df)}场, 上盘率 {df['target'].mean():.1%}")

    X, y, info = build_features(df)
    if len(X) < 200:
        print(f"  样本不足 {len(X)}")
        return None

    sp = int(len(X) * 0.8)
    Xtr, Xte = X.iloc[:sp], X.iloc[sp:]
    ytr, yte = y[:sp], y[sp:]
    itr, ite = info[:sp], info[sp:]

    bm_acc = accuracy_score(yte, np.array([x["bm_dir"] for x in ite]))
    print(f"  训练{len(Xtr)} 测试{len(Xte)}, 庄家基准:{bm_acc:.1%}")

    # 标准化
    sc = StandardScaler()
    Xs_tr = sc.fit_transform(Xtr)
    Xs_te = sc.transform(Xte)

    best_acc = 0
    best_clf = None
    best_label = ""
    best_params = {}
    use_calibration = False

    if HAS_OPTUNA:
        print(f"  Optuna搜索 (80 trials)...")
        study = optuna.create_study(direction="maximize", sampler=optuna.samplers.TPESampler(seed=42))
        study.optimize(objective_factory(Xtr.values, ytr), n_trials=30, show_progress_bar=False)
        bp = study.best_params
        mtype = bp["mtype"]

        if mtype == "lgb" and HAS_LGB:
            clf = lgb_lib.LGBMClassifier(
                n_estimators=bp["n"], learning_rate=bp["lr"], max_depth=bp["md"],
                num_leaves=bp["nl"], min_child_samples=bp["mcs"],
                subsample=bp["ss"], colsample_bytree=bp["cbt"],
                reg_alpha=bp["ra"], reg_lambda=bp["rl"],
                random_state=42, verbosity=-1, n_jobs=-1
            )
            best_label = f"LGB(n={bp['n']},lr={bp['lr']:.3f},d={bp['md']})"
        elif mtype == "xgb" and HAS_XGB:
            clf = xgb_lib.XGBClassifier(
                n_estimators=bp["n"], learning_rate=bp["lr"], max_depth=bp["md"],
                min_child_weight=bp["mcw"], subsample=bp["ss"], colsample_bytree=bp["cbt"],
                reg_alpha=bp["ra"], reg_lambda=bp["rl"],
                random_state=42, verbosity=0, n_jobs=-1
            )
            best_label = f"XGB(n={bp['n']},lr={bp['lr']:.3f},d={bp['md']})"
        elif mtype == "hgb":
            clf = HistGradientBoostingClassifier(
                max_iter=bp["mi"], learning_rate=bp["lr"], max_depth=bp["md"],
                min_samples_leaf=bp["msl"], l2_regularization=bp["l2"],
                random_state=42
            )
            best_label = f"HGB(mi={bp['mi']},lr={bp['lr']:.3f},d={bp['md']})"
        elif mtype == "et":
            clf = ExtraTreesClassifier(
                n_estimators=bp["n"], max_depth=bp["md"],
                min_samples_leaf=bp["msl"], min_samples_split=bp.get("mss", 5),
                random_state=42, n_jobs=-1
            )
            best_label = f"ET(n={bp['n']},d={bp['md']})"
        else:
            clf = RandomForestClassifier(
                n_estimators=bp["n"], max_depth=bp["md"],
                min_samples_leaf=bp["msl"],
                random_state=42, n_jobs=-1
            )
            best_label = f"RF(n={bp['n']},d={bp['md']})"

        best_params = bp
    else:
        # Fallback
        print("  Fallback: grid search...")
        configs = []
        if HAS_LGB:
            for lr in [0.01, 0.05, 0.1]:
                for md in [3, 5, 7]:
                    for nl in [15, 31]:
                        clf = lgb_lib.LGBMClassifier(n_estimators=300, learning_rate=lr, max_depth=md,
                            num_leaves=nl, min_child_samples=20, subsample=0.8, colsample_bytree=0.8,
                            reg_alpha=0.1, reg_lambda=0.1, random_state=42, verbosity=-1, n_jobs=-1)
                        clf.fit(Xs_tr, ytr)
                        a = accuracy_score(yte, clf.predict(Xs_te))
                        configs.append((a, clf, f"LGB(lr={lr},d={md},nl={nl})"))
        if HAS_XGB:
            for lr in [0.01, 0.05, 0.1]:
                for md in [3, 5, 7]:
                    clf = xgb_lib.XGBClassifier(n_estimators=300, learning_rate=lr, max_depth=md,
                        min_child_weight=20, subsample=0.8, colsample_bytree=0.8,
                        random_state=42, verbosity=0, n_jobs=-1)
                    clf.fit(Xs_tr, ytr)
                    a = accuracy_score(yte, clf.predict(Xs_te))
                    configs.append((a, clf, f"XGB(lr={lr},d={md})"))
        for lr in [0.01, 0.05, 0.1]:
            for md in [3, 5, 7]:
                clf = HistGradientBoostingClassifier(max_iter=300, learning_rate=lr, max_depth=md,
                    min_samples_leaf=20, l2_regularization=0.1, random_state=42)
                clf.fit(Xs_tr, ytr)
                a = accuracy_score(yte, clf.predict(Xs_te))
                configs.append((a, clf, f"HGB(lr={lr},d={md})"))
        for md in [5, 7, 10]:
            clf = ExtraTreesClassifier(n_estimators=300, max_depth=md, min_samples_leaf=10,
                random_state=42, n_jobs=-1)
            clf.fit(Xs_tr, ytr)
            a = accuracy_score(yte, clf.predict(Xs_te))
            configs.append((a, clf, f"ET(d={md})"))

        configs.sort(key=lambda x: -x[0])
        best_acc, best_clf, best_label = configs[0]
        best_params = {"type": "fallback"}

    if best_clf is None:
        best_clf = lgb_lib.LGBMClassifier(n_estimators=300, learning_rate=0.05, max_depth=5,
            num_leaves=31, random_state=42, verbosity=-1)
        best_clf.fit(Xs_tr, ytr)
        best_label = "LGB(default)"
        best_acc = accuracy_score(yte, best_clf.predict(Xs_te))
    else:
        best_clf.fit(Xs_tr, ytr)
        best_acc = accuracy_score(yte, best_clf.predict(Xs_te))

    # Calibration
    try:
        cal_clf = CalibratedClassifierCV(best_clf, cv=3, method="isotonic")
        cal_clf.fit(Xs_tr, ytr)
        probs_cal = cal_clf.predict_proba(Xs_te)[:, 1]
        acc_cal = accuracy_score(yte, (probs_cal >= 0.5).astype(int))
        if acc_cal >= best_acc:
            best_clf_final = cal_clf
            probs_final = probs_cal
            best_acc_final = acc_cal
            use_calibration = True
        else:
            best_clf_final = best_clf
            probs_final = best_clf.predict_proba(Xs_te)[:, 1]
            best_acc_final = best_acc
    except:
        best_clf_final = best_clf
        probs_final = best_clf.predict_proba(Xs_te)[:, 1]
        best_acc_final = best_acc

    print(f"  最佳: {best_label} → {best_acc_final:.1%} ({(best_acc_final-bm_acc)*100:+.1f}pp)" +
          (" [校准]" if use_calibration else ""))

    # 特征重要性
    fi = None
    if hasattr(best_clf_final, "feature_importances_"):
        fi = pd.Series(best_clf_final.feature_importances_, X.columns).sort_values(ascending=False)
        print(f"  Top5: {list(fi.head(5).index)}")

    # 策略分析
    bm_dirs = np.array([x["bm_dir"] for x in ite])
    mod_dirs = (probs_final >= 0.5).astype(int)

    print(f"\n  {'策略':<45} {'n':>4} {'准确率':>8}")
    print(f"  {'-'*60}")
    print(f"  {'基准-纯庄家方向':<45} {len(yte):>4} {bm_acc:>8.1%}")
    print(f"  {'基准-模型方向':<45} {len(yte):>4} {best_acc_final:>8.1%}")

    for mc in [0.55, 0.60, 0.65, 0.70, 0.75, 0.80]:
        for agree in [False, True]:
            mask = ((probs_final >= mc) | (1 - probs_final >= mc))
            if agree:
                mask = mask & (bm_dirs == mod_dirs)
            n = int(mask.sum())
            if n < 5:
                continue
            a = accuracy_score(yte[mask], mod_dirs[mask])
            lb = f"conf>{mc:.0%}" + ("+一致" if agree else "")
            fl = " ✅" if a >= 0.85 else (" ⚠️" if a >= 0.70 else "")
            print(f"  {lb:<45} {n:>4} {a:>8.1%}{fl}")

    for mp in [0.85, 0.88, 0.90, 0.92]:
        for agree in [False, True]:
            mask = ((probs_final >= mp) | (1 - probs_final >= mp))
            if agree:
                mask = mask & (bm_dirs == mod_dirs)
            n = int(mask.sum())
            if n < 3:
                continue
            a = accuracy_score(yte[mask], mod_dirs[mask])
            lb = f"极端prob>{mp:.0%}" + ("+一致" if agree else "")
            fl = " ✅" if a >= 0.85 else (" ⚠️" if a >= 0.70 else "")
            print(f"  {lb:<45} {n:>4} {a:>8.1%}{fl}")

    joblib.dump(best_clf_final, OUT / (code + "_model.pkl"))
    joblib.dump(sc, OUT / (code + "_scaler.pkl"))

    cfg = {
        "league": code,
        "league_name": LNAME.get(code, code),
        "accuracy": float(best_acc_final),
        "bookmaker_acc": float(bm_acc),
        "model": best_label,
        "calibrated": use_calibration,
        "optuna_params": best_params,
        "warmup": 10,
        "train_size": int(sp),
        "test_size": int(len(Xte)),
        "top_features": list(fi.head(10).index) if fi is not None else [],
        "trained": datetime.now().isoformat(),
    }
    with open(OUT / (code + "_config.json"), "w") as f:
        json.dump(cfg, f, indent=2, ensure_ascii=False)

    print(f"  → {code}_model.pkl ({best_label}, {best_acc_final:.1%})")
    return cfg


def main():
    print("=" * 70)
    print("亚盘模型 v4 | Optuna 80次搜索 | Calibration | 每联赛独立最优模型")
    print("=" * 70)
    print(f"LightGBM: {'YES' if HAS_LGB else 'NO'}, XGBoost: {'YES' if HAS_XGB else 'NO'}, Optuna: {'YES' if HAS_OPTUNA else 'NO'}")

    rs = {}
    for c in sorted(LNAME.keys()):
        r = train(c)
        if r:
            rs[c] = r

    print("\n" + "=" * 70)
    print("汇总")
    print("=" * 70)
    print(f"\n{'联赛':<8} {'模型':<25} {'准确率':>8} {'庄家':>8} {'提升':>8}")
    print("-" * 65)
    for c, cfg in sorted(rs.items(), key=lambda x: x[1]["accuracy"], reverse=True):
        lift = cfg["accuracy"] - cfg["bookmaker_acc"]
        s = "+" if lift > 0 else ""
        print(f"{cfg['league_name']:<8} {cfg['model']:<25} {cfg['accuracy']:.1%} {cfg['bookmaker_acc']:.1%} {s}{lift:.1%}")
    if rs:
        avg = float(np.mean([v["accuracy"] for v in rs.values()]))
        avg_b = float(np.mean([v["bookmaker_acc"] for v in rs.values()]))
        print("-" * 65)
        print(f"{'平均':<8} {'':<25} {avg:.1%} {avg_b:.1%}")


if __name__ == "__main__":
    main()
