"""Indicateur composite hybride (gradient boosting).

A partir de la situation COMPLETE a l'instant t - les 4 couches Polymarket avec leur temps restant et leur fenetre
suivante, les notes techniques facon TradingView, le positionnement Binance, les primes, la volatilite implicite,
le sentiment - le modele estime pour un horizon H et un mouvement x :
  P(hausse) = chance que le prix touche +x % avant -x % dans H
  P(baisse) = chance que le prix touche -x % avant +x % dans H
Des centaines de petits arbres de decision captent les combinaisons ("5m a tel niveau compare a 4h avec tant de
temps restant, et note TradingView a tel niveau").

Decision : esperance de gain d'une position (objectif +x %, stop -x %, sortie a H), frais deduits. Le signal
ne part que si elle depasse une marge choisie sur une periode de validation ; la periode de test, jamais vue,
compare ensuite le gain annonce au gain obtenu.
"""
import os
import pickle
import re

import numpy as np
from sklearn.base import BaseEstimator, ClassifierMixin
from sklearn.ensemble import HistGradientBoostingClassifier

import technical

# Arbres peu profonds, feuilles larges, regularisation : on cherche des combinaisons robustes, pas du par-coeur.
# early_stopping=False : l'arret automatique de scikit-learn tire sa validation au hasard, or deux releves
# espaces d'une minute se ressemblent ; on choisit le nombre d'arbres sur une periode POSTERIEURE (fit(val=...)).
PARAMS = {"learning_rate": 0.05, "max_leaf_nodes": 15, "l2_regularization": 1.0, "max_features": 0.5,
          "early_stopping": False, "random_state": 0}
CHECKPOINTS = (10, 25, 50, 100, 200, 300)  # nombres d'arbres essayes
MARGINS = (0, 5, 10, 20, 40)  # marge exigee au-dela des frais (pb)


def matrix(rows, feats):
    return np.array([[np.nan if r.get(f) is None else r[f] for f in feats] for r in rows], dtype=float)


def fit(rows, feats, key, n_iter=None, val=None):
    """Classifieur 3 issues : +1 (hausse de x d'abord), -1 (baisse d'abord), 0 (ni l'un ni l'autre dans H).
    Les valeurs manquantes (indicateur absent a cet instant) sont gerees nativement. Avec `val` (periode
    posterieure), le nombre d'arbres est celui qui predit le mieux cette periode. Retourne (modele, n_arbres)."""
    from sklearn.metrics import log_loss
    y = np.array([r[key] for r in rows], dtype=int)
    if len(set(y.tolist())) < 2:
        return None, 0
    X = matrix(rows, feats)
    # scikit-learn 1.9 plante sur une colonne a valeur unique (frequent sur un petit echantillon) : on l'ecarte
    keep = np.array([len(np.unique(c[~np.isnan(c)])) >= 2 for c in X.T])
    if not keep.any():
        return None, 0
    X = X[:, keep]
    params = dict(PARAMS, min_samples_leaf=max(200, len(rows) // 25))
    if val is not None:
        Xv, yv = matrix(val, feats)[:, keep], np.array([r[key] for r in val], dtype=int)
        m = HistGradientBoostingClassifier(**dict(params, max_iter=CHECKPOINTS[0], warm_start=True))
        best = None
        for it in CHECKPOINTS:
            m.set_params(max_iter=it)
            m.fit(X, y)
            ll = log_loss(yv, m.predict_proba(Xv), labels=m.classes_)
            if best is None or ll < best[0]:
                best = (ll, it)
        n_iter = best[1]
    n_iter = n_iter or CHECKPOINTS[-1]
    return Model(HistGradientBoostingClassifier(**dict(params, max_iter=n_iter)).fit(X, y), keep), n_iter


class Model(ClassifierMixin, BaseEstimator):
    """Classifieur entraine + colonnes qu'il utilise (les colonnes a valeur unique ont ete ecartees)."""

    def __init__(self, clf=None, keep=None):
        self.clf = clf
        self.keep = keep

    def fit(self, X, y):
        self.clf.fit(np.asarray(X, dtype=float)[:, self.keep], y)
        return self

    @property
    def classes_(self):
        return self.clf.classes_

    def predict_proba(self, X):
        return self.clf.predict_proba(np.asarray(X, dtype=float)[:, self.keep])

    def predict(self, X):
        return self.clf.predict(np.asarray(X, dtype=float)[:, self.keep])


def probs(model, X):
    P = model.predict_proba(X)
    cls = list(model.classes_)
    up = P[:, cls.index(1)] if 1 in cls else np.zeros(len(X))
    dn = P[:, cls.index(-1)] if -1 in cls else np.zeros(len(X))
    return up, dn


def expectancy(up, dn, x_bps, neutral_bps, cost_bps):
    """Esperance (pb, frais deduits) d'un achat et d'une vente : +x si l'objectif est touche d'abord, -x si le
    stop, sinon le rendement moyen observe quand aucun des deux n'est touche."""
    mid = 1 - up - dn
    ev_long = (up - dn) * x_bps + mid * neutral_bps - cost_bps
    ev_short = (dn - up) * x_bps - mid * neutral_bps - cost_bps
    return ev_long, ev_short


def decide(ev_long, ev_short, margin, allow_short):
    side = np.zeros(len(ev_long), dtype=int)
    side[(ev_long >= margin) & (ev_long >= ev_short)] = 1
    if allow_short:
        side[(ev_short >= margin) & (ev_short > ev_long)] = -1
    return side


def realized(rows, key, h, x_bps, side, cost_bps):
    """Gain reel (pb) de chaque signal pris isolement : +x, -x, ou le rendement a H."""
    out = []
    for r, s in zip(rows, side):
        if s:
            hit = r[key]
            g = x_bps if hit == s else (-x_bps if hit == -s else s * r[f"y_{h}"])
            out.append(g - cost_bps)
    return out


def attribution(model, vec, feats, medians, score):
    """Contribution de chaque facteur au score (score(p_hausse, p_baisse) -> tableau) : score actuel moins score
    si ce facteur etait a sa valeur habituelle (mediane). Retourne [(nom, valeur, contribution)] tries."""
    base = np.repeat(vec, len(feats) + 1, axis=0)
    for j, f in enumerate(feats):
        base[j + 1, j] = medians.get(f, np.nan)
    sc = score(*probs(model, base))
    contrib = [(f, None if np.isnan(vec[0, j]) else float(vec[0, j]), float(sc[0] - sc[j + 1]))
               for j, f in enumerate(feats)]
    return sorted(contrib, key=lambda c: -abs(c[2]))


def save(model, path):
    tmp = path + ".tmp"
    with open(tmp, "wb") as f:
        pickle.dump(model, f)
    os.replace(tmp, path)


_cache = {}


def load(path):
    mtime = os.path.getmtime(path)
    if _cache.get(path, (None,))[0] != mtime:
        with open(path, "rb") as f:
            _cache[path] = (mtime, pickle.load(f))
    return _cache[path][1]


# ---------------------------------------------------------------- libelles lisibles des facteurs

_LAYER = {"p": "cote Up", "div": "ecart a la juste valeur", "dp60": "variation de cote 60 s", "flow60": "flux 60 s",
          "flowcum": "flux cumule", "imb5": "desequilibre du carnet", "el": "avancement de la fenetre"}
_OTHER = {"ml_div_mean": "consensus des ecarts (4 couches)", "ml_div_agree": "accord des 4 couches",
          "ml_flow_mean": "flux moyen des couches", "ta_r1z": "rendement 1 min", "ta_r15z": "rendement 15 min",
          "ta_r60z": "rendement 60 min", "ta_rsi": "RSI 1 min", "ta_macd": "MACD 1 min", "ta_bb": "Bollinger %b",
          "ta_rv60": "volatilite 60 min", "ta_volratio": "volatilite 15/60 min", "ta_tv_15m": "note TradingView 15 min",
          "ta_tv_1h": "note TradingView 1 h", "ta_tv_4h": "note TradingView 4 h", "x_ls_acc": "Binance long/short",
          "x_ls_acc_chg1h": "Binance long/short, var. 1 h", "x_ls_top": "gros traders long/short",
          "x_taker_5m": "achat/vente preneur 5 min", "x_taker_1h": "achat/vente preneur 1 h",
          "x_oi_chg15m": "interet ouvert, var. 15 min", "x_oi_chg1h": "interet ouvert, var. 1 h",
          "x_funding": "funding", "x_basis": "prime perpetuel", "x_basis_chg15m": "prime perpetuel, var. 15 min",
          "x_cb_prem": "prime Coinbase", "x_cb_prem_chg15m": "prime Coinbase, var. 15 min", "x_dvol": "DVOL",
          "x_dvol_chg1h": "DVOL, var. 1 h", "x_fng": "Fear & Greed", "x_fng_chg1d": "Fear & Greed, var. 1 j",
          "x_st_attn": "Stocktwits, attention", "x_st_bull": "Stocktwits, part haussiere"}


_OTHER.update({"xa_btc_r1z": "BTC, rendement 1 min", "xa_btc_r15z": "BTC, rendement 15 min",
               "xa_btc_r60z": "BTC, rendement 60 min", "xa_rel60": "force relative vs BTC (60 min)",
               "m_rule": "type de declencheur", "m_tf": "echelle du declencheur", "m_dir": "sens du declencheur"})


def label(name):
    if name in _OTHER:
        return _OTHER[name]
    tl = technical.label(name)
    if tl:
        return tl
    m = re.match(r"^(5m|15m|1h|4h|1d)_(nx_)?(\w+)$", name)
    if m and m.group(3) in _LAYER:
        return f"{m.group(1)} {_LAYER[m.group(3)]}" + (" (fenetre suivante)" if m.group(2) else "")
    return name
