Skip to content
Merged
378 changes: 150 additions & 228 deletions README.md

Large diffs are not rendered by default.

422 changes: 270 additions & 152 deletions app.py

Large diffs are not rendered by default.

39 changes: 17 additions & 22 deletions src/features/alpha_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,35 +13,18 @@
FAMA_FRENCH_FACTORS,
)
from src.utils.feature_utils import compute_atr, compute_macd
from src.utils.math_utils import _safe_div, _rolling_sortino, _rolling_maxdrawdown
from src.utils.logger import setup_logger

logger = setup_logger("alpha_features")

# ══════════════════════════════════════════════════════════════════
# 1. SOUS-FONCTIONS MATHÉMATIQUES (Utils)
# ══════════════════════════════════════════════════════════════════

def _safe_div(a: pd.Series, b: pd.Series) -> pd.Series:
return a.div(b.replace(0, np.nan)).replace([np.inf, -np.inf], np.nan)

def _rolling_sortino(returns: pd.Series, window: int = 6) -> pd.Series:
def _sortino_scalar(r: np.ndarray) -> float:
neg = r[r < 0]
if len(neg) == 0 or np.std(neg) == 0: return np.nan
return (np.mean(r) / np.std(neg)) * np.sqrt(12)
return returns.rolling(window, min_periods=window // 2).apply(_sortino_scalar, raw=True)
logger = setup_logger("alpha_features")

def _rolling_maxdrawdown(returns: pd.Series, window: int = 12) -> pd.Series:
def _mdd(r: np.ndarray) -> float:
cumulative = np.cumprod(1 + r)
peak = np.maximum.accumulate(cumulative)
return ((cumulative - peak) / peak).min()
return returns.rolling(window, min_periods=window // 2).apply(_mdd, raw=True)

# ══════════════════════════════════════════════════════════════════
# 2. CALCULS DES FEATURES (Logique métier)
# 1. CALCULS DES FEATURES (Logique métier)
# ══════════════════════════════════════════════════════════════════


def _add_momentum_factors(df: pd.DataFrame, g) -> pd.DataFrame:
df["return_1m"] = g["adj close"].transform(lambda x: x.pct_change(1))
for lag in [2, 3, 6, 9, 12]:
Expand All @@ -54,6 +37,7 @@ def _add_momentum_factors(df: pd.DataFrame, g) -> pd.DataFrame:
df["mom_3_1"] = g["adj close"].transform(lambda x: x.pct_change(3))
return df


def _add_mean_reversion_factors(df: pd.DataFrame, g) -> pd.DataFrame:
ma12 = g["adj close"].transform(lambda x: x.rolling(12, min_periods=6).mean())
std12 = g["adj close"].transform(lambda x: x.rolling(12, min_periods=6).std())
Expand All @@ -62,6 +46,7 @@ def _add_mean_reversion_factors(df: pd.DataFrame, g) -> pd.DataFrame:
df["nearness_52w_high"] = _safe_div(df["adj close"], high52)
return df


def _add_volatility_factors(df: pd.DataFrame, g) -> pd.DataFrame:
df["realized_vol_3m"] = g["return_1m"].transform(lambda x: x.rolling(3, min_periods=2).std() * np.sqrt(12))
df["realized_vol_12m"] = g["return_1m"].transform(lambda x: x.rolling(12, min_periods=6).std() * np.sqrt(12))
Expand All @@ -70,13 +55,15 @@ def _add_volatility_factors(df: pd.DataFrame, g) -> pd.DataFrame:
df["idio_vol"] = excess.groupby(level=1).transform(lambda x: x.rolling(6, min_periods=3).std() * np.sqrt(12))
return df


def _add_risk_adjusted_factors(df: pd.DataFrame, g) -> pd.DataFrame:
df["sharpe_3m"] = _safe_div(g["return_1m"].transform(lambda x: x.rolling(3, min_periods=2).mean()), g["return_1m"].transform(lambda x: x.rolling(3, min_periods=2).std())) * np.sqrt(12)
df["sharpe_6m"] = _safe_div(g["return_1m"].transform(lambda x: x.rolling(6, min_periods=3).mean()), g["return_1m"].transform(lambda x: x.rolling(6, min_periods=3).std())) * np.sqrt(12)
df["sortino_6m"] = g["return_1m"].transform(lambda x: _rolling_sortino(x, window=6))
df["calmar_proxy"] = _safe_div(g["return_1m"].transform(lambda x: x.rolling(12, min_periods=6).mean() * 12), g["return_1m"].transform(lambda x: _rolling_maxdrawdown(x, window=12)).abs())
return df


def _add_tail_risk_factors(df: pd.DataFrame, g) -> pd.DataFrame:
df["return_skew_6m"] = g["return_1m"].transform(lambda x: x.rolling(6, min_periods=3).skew())
df["return_kurt_6m"] = g["return_1m"].transform(lambda x: x.rolling(6, min_periods=3).kurt())
Expand All @@ -87,6 +74,7 @@ def _cvar(r: np.ndarray) -> float:
df["cvar_5pct"] = g["return_1m"].transform(lambda x: x.rolling(12, min_periods=6).apply(_cvar, raw=True))
return df


def _add_technical_enrichment(df: pd.DataFrame, g) -> pd.DataFrame:
if "rsi" in df.columns:
df["rsi_divergence"] = g["adj close"].transform(lambda x: x.pct_change(3)) - g["rsi"].transform(lambda x: x.pct_change(3))
Expand All @@ -96,24 +84,29 @@ def _add_technical_enrichment(df: pd.DataFrame, g) -> pd.DataFrame:
df["volume_zscore"] = g["euro_volume"].transform(lambda x: _safe_div(x - x.rolling(12, min_periods=6).mean(), x.rolling(12, min_periods=6).std()))
return df


def _add_seasonality_features(df: pd.DataFrame) -> pd.DataFrame:
dates = df.index.get_level_values("date")
df["month_sin"] = np.sin(2 * np.pi * dates.month / 12)
df["month_cos"] = np.cos(2 * np.pi * dates.month / 12)
df["is_q_end"] = dates.month.isin([3, 6, 9, 12]).astype(int)
df["is_jan"] = (dates.month == 1).astype(int)
return df


def add_rank_features(df: pd.DataFrame) -> pd.DataFrame:
features_to_rank = ["mom_12_1", "mom_6_1", "sharpe_6m", "sortino_6m", "realized_vol_3m", "realized_vol_12m", "amihud_illiquidity", "return_skew_6m", "hist_var_5pct"]
for feat in features_to_rank:
if feat in df.columns: df[f"{feat}_rank"] = df.groupby(level="date")[feat].transform(lambda x: x.rank(pct=True))
else: df[f"{feat}_rank"] = 0.5
return df


# ══════════════════════════════════════════════════════════════════
# 3. FONCTIONS PRINCIPALES (Exposées)
# 2. FONCTIONS PRINCIPALES (Exposées)
# ══════════════════════════════════════════════════════════════════


def compute_technical_indicators(df: pd.DataFrame) -> pd.DataFrame:
logger.info("Computing daily technical indicators...")
if all(col in df.columns for col in ["high", "low", "open", "adj close"]):
Expand All @@ -134,6 +127,7 @@ def compute_technical_indicators(df: pd.DataFrame) -> pd.DataFrame:
df["euro_volume"] = (df["adj close"] * df["volume"]) / 1e6 if "volume" in df.columns else 0.0
return df


def get_fama_french_betas(data: pd.DataFrame) -> pd.DataFrame:
logger.info("Retrieving Fama-French factors...")
try:
Expand Down Expand Up @@ -164,6 +158,7 @@ def get_fama_french_betas(data: pd.DataFrame) -> pd.DataFrame:
logger.warning(f"Fama-French retrieval failed ({exc}).")
return data.assign(**{f: 0.0 for f in FAMA_FRENCH_FACTORS})


def add_all_features(df: pd.DataFrame) -> pd.DataFrame:
if not isinstance(df.index, pd.MultiIndex): raise ValueError("MultiIndex requis.")
df = get_fama_french_betas(df.copy())
Expand Down
10 changes: 6 additions & 4 deletions src/models/ensemble.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,18 @@ def _available(df: pd.DataFrame) -> List[str]:

def _prepare_X(df: pd.DataFrame, features: List[str]) -> pd.DataFrame:
df_prepared = df.copy()
for col in features:
if col not in df_prepared.columns:
missing = [col for col in features if col not in df_prepared.columns]
if missing:
logger.warning(f"Features manquantes, remplies à 0.0 : {missing}")
for col in missing:
df_prepared[col] = 0.0
return df[features].fillna(0).replace([np.inf, -np.inf], 0)

return df_prepared[features].fillna(0).replace([np.inf, -np.inf], 0)

# ══════════════════════════════════════════════════════════════════
# OPTUNA OBJECTIVES
# ══════════════════════════════════════════════════════════════════


def _xgb_objective(X: pd.DataFrame, y: pd.Series, n_trials: int) -> xgb.XGBClassifier:
from sklearn.metrics import roc_auc_score
cv = PurgedTimeSeriesSplit(n_splits=5)
Expand Down
112 changes: 91 additions & 21 deletions src/models/model_loader.py
Original file line number Diff line number Diff line change
@@ -1,73 +1,127 @@
"""
Chargement du modèle "champion" pour l'inférence (daily run / backtest).

Stratégie de résolution, par ordre de priorité :
1. MLflow Model Registry (alias "champion") si HF_TOKEN est configuré.
2. Fallback local (pickle) si MLflow est indisponible, désactivé, ou
qu'aucun alias "champion" n'existe encore.

Un cache en mémoire évite de recharger le même modèle plusieurs fois au
sein d'un même processus (ex: backtest + génération de signaux dans le
même run quotidien).
"""

from __future__ import annotations

import os
import pickle
from functools import lru_cache
from pathlib import Path
from typing import Any
from typing import Any, Optional

import mlflow
from mlflow.tracking import MlflowClient
from mlflow.exceptions import MlflowException
from dotenv import load_dotenv
from mlflow.exceptions import MlflowException
from mlflow.tracking import MlflowClient

from const import MODEL_DIR
from src.utils.logger import setup_logger

load_dotenv()
logger = setup_logger("model_loader")


# =============================================================================
# CONFIGURATION
# =============================================================================

MLFLOW_TRACKING_URI = "https://soradata-alphaedge-registry.hf.space"
MLFLOW_USERNAME = "SORADATA"
CHAMPION_ALIAS = "champion"
LOCAL_MODEL_FILENAME = "ensemble_model.pkl"

HF_TOKEN = os.getenv("HF_TOKEN")
USE_MLFLOW = bool(HF_TOKEN)

if USE_MLFLOW:
os.environ["MLFLOW_TRACKING_USERNAME"] = "SORADATA"
os.environ["MLFLOW_TRACKING_USERNAME"] = MLFLOW_USERNAME
os.environ["MLFLOW_TRACKING_PASSWORD"] = HF_TOKEN
mlflow.set_tracking_uri("https://soradata-alphaedge-registry.hf.space")
mlflow.set_tracking_uri(MLFLOW_TRACKING_URI)
logger.info(f"MLflow activé pour le chargement du champion — tracking URI : {MLFLOW_TRACKING_URI}")
else:
logger.warning("HF_TOKEN absent — chargement en mode local uniquement.")


# =============================================================================
# CHARGEMENT DEPUIS MLFLOW
# =============================================================================

def _load_champion_from_mlflow(market_name: str) -> Any | None:
def _load_champion_from_mlflow(market_name: str) -> Optional[Any]:
"""
Charge le modèle aliasé 'champion' depuis le MLflow Model Registry.
Retourne None en cas d'échec (fallback local ensuite).
Retourne None en cas d'échec (le fallback local prend alors le relais).
"""
registered_model_name = f"AlphaEdge_Ensemble_{market_name}"
model_uri = f"models:/{registered_model_name}@{CHAMPION_ALIAS}"
try:
model_uri = f"models:/{registered_model_name}@champion"
model = mlflow.pyfunc.load_model(model_uri)
logger.info(f"[{market_name}] Champion chargé depuis MLflow : {model_uri}")
return model
except MlflowException as e:
logger.warning(f"[{market_name}] Impossible de charger le champion MLflow : {e}")
except MlflowException as exc:
logger.warning(f"[{market_name}] Impossible de charger le champion MLflow ({model_uri}) : {exc}")
return None


def _load_champion_from_local(market_name: str) -> Any | None:
# =============================================================================
# CHARGEMENT DEPUIS LE FALLBACK LOCAL
# =============================================================================

def _local_model_path(market_name: str) -> Path:
return MODEL_DIR / market_name / LOCAL_MODEL_FILENAME


def _load_champion_from_local(market_name: str) -> Optional[Any]:
"""
Fallback : charge le dernier modèle sauvegardé localement (pickle).
Utilisé si MLflow est indisponible ou HF_TOKEN absent.
Utilisé si MLflow est indisponible, désactivé (HF_TOKEN absent),
ou qu'aucun alias 'champion' n'a encore été promu.
"""
local_path = MODEL_DIR / market_name / "ensemble_model.pkl"
local_path = _local_model_path(market_name)
if not local_path.exists():
logger.error(f"[{market_name}] Aucun modèle local trouvé à {local_path}")
return None

try:
with open(local_path, "rb") as f:
model = pickle.load(f)
logger.info(f"[{market_name}] Modèle chargé depuis le fallback local : {local_path}")
return model
except Exception as e:
logger.error(f"[{market_name}] Échec du chargement local : {e}")
except (pickle.UnpicklingError, EOFError, AttributeError, ModuleNotFoundError) as exc:
# Ces erreurs signalent typiquement un fichier corrompu ou une
# incompatibilité de version entre l'environnement d'entraînement
# et celui d'inférence (classe déplacée/renommée, version sklearn...).
logger.error(f"[{market_name}] Fichier pickle illisible ou incompatible ({local_path}) : {exc}")
return None


# =============================================================================
# POINT D'ENTRÉE PUBLIC
# =============================================================================

@lru_cache(maxsize=None)
def load_champion(market_name: str) -> Any:
"""
Point d'entrée unique utilisé par run_pipeline.py (daily run).

Priorité : champion MLflow -> fallback local.
Lève une exception si aucun modèle n'est disponible (le pipeline
ne doit jamais tourner sans modèle).
Le résultat est mis en cache par marché pour la durée du processus,
afin d'éviter des appels réseau MLflow redondants si le pipeline
(backtest + signaux live) charge le champion plusieurs fois.

Lève une exception si aucun modèle n'est disponible : le pipeline
ne doit jamais tourner sans modèle.
"""
model = None
if USE_MLFLOW:
model = _load_champion_from_mlflow(market_name)
model = _load_champion_from_mlflow(market_name) if USE_MLFLOW else None

if model is None:
model = _load_champion_from_local(market_name)
Expand All @@ -78,4 +132,20 @@ def load_champion(market_name: str) -> Any:
"(ni MLflow, ni local). Impossible de générer les signaux."
)

return model
return model


def clear_champion_cache(market_name: Optional[str] = None) -> None:
"""
Vide le cache de load_champion.

Utile après une nouvelle promotion (le champion vient de changer sur
MLflow) ou dans les tests, pour forcer un rechargement.
Note : lru_cache ne permet pas d'invalider une seule clé nativement,
donc on vide tout le cache quel que soit `market_name` fourni.
"""
load_champion.cache_clear()
if market_name:
logger.info(f"[{market_name}] Cache du champion invalidé.")
else:
logger.info("Cache du champion invalidé pour tous les marchés.")
Loading
Loading