From 01da6b2c4bbdaac3c9f87b910cb15761ea70344e Mon Sep 17 00:00:00 2001 From: sora Date: Tue, 21 Jul 2026 13:51:51 +0000 Subject: [PATCH 1/5] fix: Download models registry on Hugging face --- src/models/model_loader.py | 68 +++++++++++++++++++------------------- src/models/train.py | 18 ++++++++++ 2 files changed, 52 insertions(+), 34 deletions(-) diff --git a/src/models/model_loader.py b/src/models/model_loader.py index 3db3306..df4e2fd 100644 --- a/src/models/model_loader.py +++ b/src/models/model_loader.py @@ -19,10 +19,8 @@ from pathlib import Path from typing import Any, Optional -import mlflow from dotenv import load_dotenv -from mlflow.exceptions import MlflowException -from mlflow.tracking import MlflowClient +from huggingface_hub import hf_hub_download from const import MODEL_DIR from src.utils.logger import setup_logger @@ -32,50 +30,56 @@ # ============================================================================= -# CONFIGURATION +# CONFIG # ============================================================================= -MLFLOW_TRACKING_URI = "https://soradata-alphaedge-registry.hf.space" -MLFLOW_USERNAME = "SORADATA" -CHAMPION_ALIAS = "champion" +HF_REPO_ID = "soradata/alphaedge-data" LOCAL_MODEL_FILENAME = "ensemble_model.pkl" + HF_TOKEN = os.getenv("HF_TOKEN") -USE_MLFLOW = bool(HF_TOKEN) +USE_HF_HUB = bool(HF_TOKEN) -if USE_MLFLOW: - os.environ["MLFLOW_TRACKING_USERNAME"] = MLFLOW_USERNAME - os.environ["MLFLOW_TRACKING_PASSWORD"] = HF_TOKEN - mlflow.set_tracking_uri(MLFLOW_TRACKING_URI) - logger.info(f"MLflow activé pour le chargement du champion — tracking URI : {MLFLOW_TRACKING_URI}") +if USE_HF_HUB: + logger.info(f"Hugging Face activé pour le chargement du champion depuis : {HF_REPO_ID}") else: logger.warning("HF_TOKEN absent — chargement en mode local uniquement.") - # ============================================================================= -# CHARGEMENT DEPUIS MLFLOW +# CHARGEMENT DEPUIS HUGGING FACE HUB # ============================================================================= -def _load_champion_from_mlflow(market_name: str) -> Optional[Any]: + +def _load_champion_from_hf_hub(market_name: str) -> Optional[Any]: """ - Charge le modèle aliasé 'champion' depuis le MLflow Model Registry. + Charge le modèle 'champion' depuis le dataset persistant Hugging Face. 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}" + hf_filename = f"models/{market_name}/champion.pkl" try: - model = mlflow.pyfunc.load_model(model_uri) - logger.info(f"[{market_name}] Champion chargé depuis MLflow : {model_uri}") + local_path = hf_hub_download( + repo_id=HF_REPO_ID, + repo_type="dataset", + filename=hf_filename, + token=HF_TOKEN + ) + + with open(local_path, "rb") as f: + model = pickle.load(f) + + logger.info(f"[{market_name}] Champion chargé depuis Hugging Face Hub : {hf_filename}") return model - except MlflowException as exc: - logger.warning(f"[{market_name}] Impossible de charger le champion MLflow ({model_uri}) : {exc}") + except Exception as exc: + logger.warning( + f"[{market_name}] Impossible de charger le champion depuis HF Hub ({hf_filename}) : {exc}" + ) return None - # ============================================================================= # CHARGEMENT DEPUIS LE FALLBACK LOCAL # ============================================================================= + def _local_model_path(market_name: str) -> Path: return MODEL_DIR / market_name / LOCAL_MODEL_FILENAME @@ -97,9 +101,6 @@ def _load_champion_from_local(market_name: str) -> Optional[Any]: logger.info(f"[{market_name}] Modèle chargé depuis le fallback local : {local_path}") return model 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 @@ -121,7 +122,7 @@ def load_champion(market_name: str) -> Any: Lève une exception si aucun modèle n'est disponible : le pipeline ne doit jamais tourner sans modèle. """ - model = _load_champion_from_mlflow(market_name) if USE_MLFLOW else None + model = _load_champion_from_hf_hub(market_name) if USE_HF_HUB else None if model is None: model = _load_champion_from_local(market_name) @@ -129,7 +130,7 @@ def load_champion(market_name: str) -> Any: if model is None: raise RuntimeError( f"[{market_name}] Aucun modèle champion disponible " - "(ni MLflow, ni local). Impossible de générer les signaux." + "(ni SUR hf hub, ni local). Impossible de générer les signaux." ) return model @@ -139,13 +140,12 @@ 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. + Utile après un nouvel entraînement local (le modèle vient de changer) + ou dans les tests, pour forcer un rechargement. + """ 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.") \ No newline at end of file + logger.info("Cache du champion invalidé pour tous les marchés.") diff --git a/src/models/train.py b/src/models/train.py index 4e7e3b2..2a66f1d 100644 --- a/src/models/train.py +++ b/src/models/train.py @@ -30,6 +30,7 @@ from src.models.ensemble import AlphaEdgeEnsemble from src.utils.logger import setup_logger from src.utils.metrics import calculate_financial_metrics +from huggingface_hub import HfApi load_dotenv() @@ -307,9 +308,26 @@ def _log_and_promote_to_mlflow( ) if promote: + # Promote Mlflow ui client.set_registered_model_alias(registered_model_name, "champion", model_version.version) result.promoted = True logger.info(f"[{market_name}] PROMOTION v{model_version.version} — {reason}") + try: + api = HfApi() + # Save on HF + local_model_path = MODEL_DIR / market_name /"ensemble_model.pkl" + + api.upload_file( + path_pr_fileobj=str(local_model_path), + path_in_repo=f"models/{market_name}/champion.pkl", + repo_id="soradata/alphaedge-data", + repo_type="dataset", + token=HF_TOKEN + ) + logger.info(f"[{market_name}] Modèle persistant sauvegardé sur HF Hub ( soradata/alphaedge-data) ") + except Exception as e: + logger.error(f"[{market_name}] Sync faillure on Hf Hub : {e}") + else: logger.warning(f"[{market_name}] CHALLENGER REJETÉ — {reason}") From 601e6acd33174e6966bae76fdab039c20ce2ec89 Mon Sep 17 00:00:00 2001 From: sora Date: Tue, 21 Jul 2026 14:38:00 +0000 Subject: [PATCH 2/5] refactor: HfApi --- src/models/train.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/models/train.py b/src/models/train.py index 2a66f1d..69324f9 100644 --- a/src/models/train.py +++ b/src/models/train.py @@ -315,10 +315,10 @@ def _log_and_promote_to_mlflow( try: api = HfApi() # Save on HF - local_model_path = MODEL_DIR / market_name /"ensemble_model.pkl" + local_model_path = MODEL_DIR / market_name / "ensemble_model.pkl" api.upload_file( - path_pr_fileobj=str(local_model_path), + path_or_fileobj=str(local_model_path), path_in_repo=f"models/{market_name}/champion.pkl", repo_id="soradata/alphaedge-data", repo_type="dataset", From 80376968d649f6bb6beb19d141bcc656d2618d9c Mon Sep 17 00:00:00 2001 From: sora Date: Tue, 21 Jul 2026 15:39:36 +0000 Subject: [PATCH 3/5] feat: add Black-litterman & EfficientCvaR --- src/utils/metrics.py | 46 +++++++++++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/src/utils/metrics.py b/src/utils/metrics.py index adee8e3..a4f7f36 100644 --- a/src/utils/metrics.py +++ b/src/utils/metrics.py @@ -29,10 +29,18 @@ def calculate_financial_metrics(df_test: pd.DataFrame, probas: np.ndarray, thres portfolio_returns = strategy_returns.groupby(level="date").mean() if portfolio_returns.std() == 0: - return {"sharpe": 0.0, "max_drawdown": 0.0, "total_return": 0.0} + return {"sharpe": 0.0, "sortino": 0.0, "max_drawdown": 0.0, "total_return": 0.0} annualization_factor = np.sqrt(12) - sharpe_ratio = (portfolio_returns.mean() / portfolio_returns.std()) * annualization_factor + mean_ret = portfolio_returns.mean() + std_ret = portfolio_returns.std() + + sharpe_ratio = (mean_ret / std_ret) * annualization_factor + + negative_returns = portfolio_returns[portfolio_returns < 0] + downside_std = negative_returns.std() if len(negative_returns) > 0 else 0.0 + # sortino = semi-ecart-type sur les rendements négatifs only + sortino_ratio = (mean_ret / downside_std) * annualization_factor if downside_std != 0 else 0.0 cumulative_returns = (1 + portfolio_returns).cumprod() rolling_max = cumulative_returns.cummax() @@ -42,6 +50,7 @@ def calculate_financial_metrics(df_test: pd.DataFrame, probas: np.ndarray, thres return { "sharpe": round(sharpe_ratio, 4), + "sortino": round(sortino_ratio, 4), "max_drawdown": round(max_drawdown, 4), "total_return": round(total_return, 4), } @@ -54,34 +63,49 @@ def calculate_financial_metrics(df_test: pd.DataFrame, probas: np.ndarray, thres def calculate_metrics(df: pd.DataFrame) -> tuple: """ Calcule les KPI principaux du dashboard à partir de l'historique - Strategy/Benchmark : total_return, alpha, sharpe, max_drawdown, recovery_time. + Strategy/Benchmark : total_return, alpha, sharpe, sortino, max_dd, calmar, recovery_time. Returns ------- - tuple — (total_return, alpha, sharpe, max_dd, recovery_days) + tuple — (total_return, alpha, sharpe, sortino, max_dd, calmar, recovery_days) """ if df.empty or len(df) < 2: - return 0, 0, 0, 0, 0 + return 0, 0, 0, 0, 0, 0, 0 try: total_ret = (df["Strategy"].iloc[-1] / df["Strategy"].iloc[0]) - 1 bench_ret = (df["Benchmark"].iloc[-1] / df["Benchmark"].iloc[0]) - 1 alpha = total_ret - bench_ret strategy_returns = df["Strategy"].pct_change().dropna() - sharpe = ( - (strategy_returns.mean() / strategy_returns.std()) * np.sqrt(252) - if strategy_returns.std() != 0 else 0 - ) + if strategy_returns.empty: + return total_ret, alpha, 0, 0, 0, 0, 0 + + mean_ret = strategy_returns.mean() + std_ret = strategy_returns.std() + + sharpe = (mean_ret / std_ret) * np.sqrt(252) if std_ret != 0 else 0 + + # Calcul du Sortino de production (downside risk) + neg_rets = strategy_returns[strategy_returns < 0] + downside_std = neg_rets.std() if len(neg_rets) > 0 else 0 + sortino = (mean_ret / downside_std) * np.sqrt(252) if downside_std != 0 else 0 cum_ret = (1 + strategy_returns).cumprod() running_max = cum_ret.cummax() dd_series = (cum_ret - running_max) / running_max max_dd = dd_series.min() + + # Calcul du Ratio de Calmar (Rendement annualisé / Max Drawdown) + # Approximation du rendement annualisé basée sur la durée totale + n_years = len(strategy_returns) / 252 + ann_return = ((1 + total_ret) ** (1 / n_years) - 1) if n_years > 0 else 0 + calmar = (ann_return / abs(max_dd)) if max_dd != 0 else 0 + recovery_time = _compute_recovery_time(dd_series) - return total_ret, alpha, sharpe, max_dd, recovery_time + return total_ret, alpha, sharpe, sortino, max_dd, calmar, recovery_time except Exception: - return 0, 0, 0, 0, 0 + return 0, 0, 0, 0, 0, 0, 0 def _compute_recovery_time(dd_series: pd.Series) -> int: From 1db6e0150e0d31b1fae6c45a0b34166bd60eb127 Mon Sep 17 00:00:00 2001 From: sora Date: Tue, 21 Jul 2026 15:40:52 +0000 Subject: [PATCH 4/5] feat: add black_litterman --- src/pipeline/backtest.py | 127 ++++++++++++++++++++++++++------------- 1 file changed, 85 insertions(+), 42 deletions(-) diff --git a/src/pipeline/backtest.py b/src/pipeline/backtest.py index 4b6ba15..89a372f 100644 --- a/src/pipeline/backtest.py +++ b/src/pipeline/backtest.py @@ -4,17 +4,17 @@ from __future__ import annotations -import logging from typing import Any, Dict, List, Optional, Tuple import numpy as np import pandas as pd -from pypfopt import EfficientFrontier, risk_models, expected_returns, objective_functions +from pypfopt import risk_models, expected_returns, EfficientCVaR, black_litterman from const import ( TRADING_DAYS_YEAR, RISK_FREE_RATE, TRANSACTION_COST, + MANAGEMENT_FEE_ANNUAL, MIN_STOCKS_OPTIM, MAX_STOCKS_SELECT, PROBA_MIN, @@ -28,13 +28,6 @@ MAX_PRICE_FFILL_DAYS = 5 MAX_ACCEPTABLE_MISSING_RATIO = 0.05 - -# Nombre minimum de mois d'historique requis par ticker avant que les features -# a fenetre glissante (jusqu'a 12 mois pour realized_vol_12m, sharpe_6m, -# calmar_proxy, hist_var_5pct, cvar_5pct...) soient jugees fiables. Les mois -# en-dessous de ce seuil sont scores sur des features encore incompletes, -# masquees a zero par le fillna(0) interne d'add_all_features -> cela produit -# une periode plate artificielle en debut de backtest si on ne filtre pas. MIN_FEATURE_HISTORY = 12 @@ -42,26 +35,63 @@ # 1. CONSTRUCTION DE PORTEFEUILLE & HELPERS # ============================================================================= -def get_optimal_weights(prices_df: pd.DataFrame, risk_free_rate: float = RISK_FREE_RATE) -> Tuple[Dict[str, float], str]: +def get_optimal_weights(prices_df: pd.DataFrame, probas_subset: Optional[pd.Series] = None, risk_free_rate: float = RISK_FREE_RATE) -> Tuple[Dict[str, float], str]: + """ + Optimisation des poids combinant Black-Litterman (via les probabilités du ML) + et EfficientCVaR (Expected Shortfall) pour maîtriser les risques extrêmes. + """ n_assets = prices_df.shape[1] if n_assets < MIN_STOCKS_OPTIM: return {t: 1.0 / n_assets for t in prices_df.columns}, "equal_weight" + try: - mu = expected_returns.ema_historical_return(prices_df, frequency=TRADING_DAYS_YEAR, span=252) + # Matrice de covariance (Ledoit-Wolf) cov = risk_models.CovarianceShrinkage(prices_df, frequency=TRADING_DAYS_YEAR).ledoit_wolf() - ef = EfficientFrontier(mu, cov, weight_bounds=WEIGHT_BOUNDS) - ef.add_objective(objective_functions.L2_reg, gamma=0.1) - ef.max_sharpe(risk_free_rate=risk_free_rate) - return dict(ef.clean_weights()), "max_sharpe" + prior = expected_returns.mean_historical_return(prices_df, frequency=TRADING_DAYS_YEAR) + + # Si le modèle ML fournit des probabilités, on applique Black-Litterman + if probas_subset is not None and not probas_subset.empty: + valid_tickers = [t for t in prices_df.columns if t in probas_subset.index] + if len(valid_tickers) > 0: + p_matrix = np.eye(len(valid_tickers)) + # Transformation de la proba de hausse [0, 1] en vue de rendement espéré + q_views = np.array([(probas_subset[t] - 0.5) * 0.5 for t in valid_tickers]) + omega = np.diag(np.diag(cov.loc[valid_tickers, valid_tickers]) * 0.1) + + bl = black_litterman.BlackLittermanModel( + cov, pi=prior, absolute_views=dict(zip(valid_tickers, q_views)), + P=p_matrix, Q=q_views, omega=omega + ) + ret_bl = bl.bl_returns() + cov_bl = bl.bl_cov() + + # Optimisation CVaR (Expected Shortfall à 95%) avec les retours Black-Litterman + ef = EfficientCVaR( + ret_bl, prices_df[valid_tickers].pct_change().dropna(), + beta=0.95, + weight_bounds=WEIGHT_BOUNDS + ) + ef.max_quadratic_utility() + return dict(ef.clean_weights()), "black_litterman_cvar" + + # Fallback CVaR pur sur l'historique si aucune probabilité ML n'est dispo + ef = EfficientCVaR( + prior, prices_df.pct_change().dropna(), + beta=0.95, + weight_bounds=WEIGHT_BOUNDS) + ef.max_quadratic_utility() + return dict(ef.clean_weights()), "min_cvar" + except Exception as exc: - logger.warning(f"Max Sharpe a échoué ({exc}) -> fallback.") + logger.warning( + f"Optimisation CVaR/Black-Litterman a échoué ({exc}) -> fallback equal_weight." + ) return {t: 1.0 / n_assets for t in prices_df.columns}, "equal_weight" def _score_with_model(model: Any, features: pd.DataFrame) -> np.ndarray: """Scoring robuste avec injection automatique des features manquantes.""" expected_cols = _resolve_expected_features(model) - # On force l'alignement : si features manquantes, elles sont créées à 0 ici x_input = features.reindex(columns=expected_cols).fillna(0) if hasattr(model, "predict_proba"): @@ -73,12 +103,16 @@ def _resolve_expected_features(model: Any) -> Optional[List[str]]: """Résout la liste de features réellement attendue par le modèle (v5 ou v6).""" try: inner = model._model_impl.python_model - if hasattr(inner, "features_"): return list(inner.features_) - except Exception: pass + if hasattr(inner, "features_"): + return list(inner.features_) + except Exception: + pass try: input_schema = model.metadata.get_input_schema() - if input_schema: return [c.name for c in input_schema.inputs] - except Exception: pass + if input_schema: + return [c.name for c in input_schema.inputs] + except Exception: + pass return None @@ -95,14 +129,6 @@ def _build_daily_snapshot(df_daily: pd.DataFrame, market_config: dict) -> Tuple[ def _filter_warmup_period(df_monthly: pd.DataFrame, min_history: int = MIN_FEATURE_HISTORY) -> pd.Series: - """ - Calcule un masque booleen (indexe comme df_monthly) qui exclut, pour - chaque ticker, les tout premiers mois n'ayant pas assez d'historique pour - que les features a fenetre glissante soient completes. Le calcul se base - sur le nombre de lignes deja observees par ticker (donnees brutes, avant - tout calcul de features), donc independant du fillna(0) applique plus - tard par add_all_features. - """ history_count = df_monthly.groupby(level="ticker").cumcount() + 1 return history_count > min_history @@ -114,22 +140,30 @@ def _filter_warmup_period(df_monthly: pd.DataFrame, min_history: int = MIN_FEATU def _simulate_period(allocation, drifted_allocation, trading_days, daily_returns, benchmark_returns, portfolio_value, benchmark_value): turnover = sum(abs(allocation.get(t, 0.0) - drifted_allocation.get(t, 0.0)) for t in set(allocation)|set(drifted_allocation)) / 2.0 portfolio_value -= (portfolio_value * turnover * TRANSACTION_COST) + tickers = list(allocation.keys()) if not tickers: return pd.DataFrame({"Strategy": portfolio_value, "Benchmark": benchmark_value, "N_Stocks": 0}, index=trading_days), portfolio_value, benchmark_value, {} + weights = np.array([allocation.get(t, 0) for t in tickers]) rets = daily_returns.reindex(index=trading_days, columns=tickers).fillna(0.0).to_numpy() growth = np.cumprod(1.0 + rets, axis=0) - strategy_values = (portfolio_value * weights[np.newaxis, :] * growth).sum(axis=1) + (portfolio_value * (1 - sum(weights))) + + strategy_values = ( + portfolio_value * weights[np.newaxis, :] * growth).sum(axis=1) + (portfolio_value * (1 - sum(weights))) + + # Application des frais de gestion au prorata journalier + fee_daily_factor = (1.0 - MANAGEMENT_FEE_ANNUAL / 252) + strategy_values = strategy_values * np.cumprod([fee_daily_factor] * len(strategy_values)) + bench_values = benchmark_value * np.cumprod(1.0 + benchmark_returns.reindex(trading_days).fillna(0.0)) + final_total = float(strategy_values[-1]) new_drifted = dict(zip(tickers, (portfolio_value * weights * growth[-1, :] / final_total))) return pd.DataFrame({"Strategy": strategy_values, "Benchmark": bench_values, "N_Stocks": len(tickers)}, index=trading_days), final_total, float(bench_values[-1]), new_drifted def backtest_strategy_with_rebalancing(df_daily, df_monthly, model, benchmark_ticker, market_config): - # Masque de warm-up calcule AVANT le calcul des features (sur les donnees - # brutes), pour ne pas dependre du fillna(0) interne a add_all_features. valid_history_mask = _filter_warmup_period(df_monthly) df_monthly_feat = add_all_features(df_monthly.copy(), market_config) @@ -149,45 +183,54 @@ def backtest_strategy_with_rebalancing(df_daily, df_monthly, model, benchmark_ti if len(monthly_dates) < 2: raise ValueError( f"Pas assez de mois exploitables après filtrage du warm-up " - f"({len(monthly_dates)} mois restants, {MIN_FEATURE_HISTORY} mois requis). " - f"Augmentez la fenêtre de backtest (BACKTEST_YEARS) ou réduisez MIN_FEATURE_HISTORY." + f"({len(monthly_dates)} mois restants, {MIN_FEATURE_HISTORY} mois requis)." ) for i, month_date in enumerate(monthly_dates[:-1]): month_data = df_monthly_feat.xs(month_date, level="date").copy() month_data["proba_upside"] = _score_with_model(model, month_data) - tickers = month_data[month_data["proba_upside"] >= PROBA_MIN].sort_values("proba_upside", ascending=False).head(MAX_STOCKS_SELECT).index.tolist() + + # Sélection des meilleurs tickers selon le seuil de probabilité + selected_subset = month_data[month_data["proba_upside"] >= PROBA_MIN].sort_values("proba_upside", ascending=False).head(MAX_STOCKS_SELECT) + tickers = selected_subset.index.tolist() + allocation = {} if tickers: prices_subset = daily_prices[tickers].loc[:month_date].iloc[-TRADING_DAYS_YEAR:].dropna(axis=1, thresh=int(TRADING_DAYS_YEAR * 0.8)) if not prices_subset.empty: - weights, _ = get_optimal_weights(prices_subset) + # On extrait les probabilités associées à ces tickers pour les passer à Black-Litterman + probas_subset = selected_subset.loc[prices_subset.columns, "proba_upside"] + weights, _ = get_optimal_weights(prices_subset, probas_subset=probas_subset) allocation = {t: w for t, w in weights.items() if w > 1e-4} + trading_days = daily_prices.index[(daily_prices.index >= month_date) & (daily_prices.index < monthly_dates[i + 1])] - # Le benchmark_value retourne par _simulate_period est reinjecte a - # l'iteration suivante (au lieu d'etre code en dur a 100.0), afin que - # la courbe Benchmark compose reellement ses rendements mois apres mois. period_df, portfolio_value, benchmark_value, drifted_allocation = _simulate_period( allocation, drifted_allocation, trading_days, daily_returns, bench_rets, portfolio_value, benchmark_value ) period_frames.append(period_df) rebalance_log.append({"Date": month_date, "Allocation": allocation}) + return pd.concat(period_frames), pd.DataFrame(rebalance_log).set_index("Date"), {} def generate_live_signals(df_daily, daily_prices, model, rebalance_history, market_config): snapshot, last_date = _build_daily_snapshot(df_daily, market_config) snapshot["proba_upside"] = _score_with_model(model, snapshot) - tickers = snapshot[snapshot["proba_upside"] >= PROBA_MIN].sort_values("proba_upside", ascending=False).head(MAX_STOCKS_SELECT).index.tolist() + + selected_subset = snapshot[snapshot["proba_upside"] >= PROBA_MIN].sort_values("proba_upside", ascending=False).head(MAX_STOCKS_SELECT) + tickers = selected_subset.index.tolist() + allocation = {} if tickers: prices_subset = daily_prices[tickers].loc[:last_date].iloc[-TRADING_DAYS_YEAR:].dropna(axis=1, thresh=int(TRADING_DAYS_YEAR * 0.8)) if not prices_subset.empty: - weights, _ = get_optimal_weights(prices_subset) + probas_subset = selected_subset.loc[prices_subset.columns, "proba_upside"] + weights, _ = get_optimal_weights(prices_subset, probas_subset=probas_subset) allocation = {t: w for t, w in weights.items() if w > 1e-4} + out = snapshot.reset_index().rename(columns={"ticker": "Ticker"}) out["Allocation"] = out["Ticker"].map(allocation).fillna(0.0) out["Signal"] = np.where(out["Allocation"] > 0, "BUY", "NEUTRAL") out["Proba_Hausse"] = (out["proba_upside"] * 100).round(1) - return out[["Ticker", "Signal", "Allocation", "Proba_Hausse"]], rebalance_history \ No newline at end of file + return out[["Ticker", "Signal", "Allocation", "Proba_Hausse"]], rebalance_history From 8d259b9989cd2cdae5ca45bcc7e13db018e0d490 Mon Sep 17 00:00:00 2001 From: sora Date: Tue, 21 Jul 2026 15:41:33 +0000 Subject: [PATCH 5/5] chore: reorg constants --- const.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/const.py b/const.py index 93c68ac..5d69f08 100644 --- a/const.py +++ b/const.py @@ -20,6 +20,23 @@ PROBA_THRESHOLD: float = 0.51 PROBA_MIN: float = 0.50 + +# MANAGEMENT COST + +TRANSACTION_COST: float = 0.0010 +MANAGEMENT_FEE_ANNUAL: float = 0.02 +MIN_STOCKS_OPTIM: int = 3 +MAX_STOCKS_SELECT: int = 10 +WEIGHT_BOUNDS: tuple = (0.03, 0.20) + +SHARPE_THRESHOLD: float = 0.30 +MAX_DD_THRESHOLD: float = -0.40 + +BACKTEST_YEARS: int = 2 + + +# FEATURES + FEATURE_COLS: list[str] = [ "rsi_lag1", "macd_lag1", "bb_low_lag1", "bb_mid_lag1", "bb_high_lag1", "atr_lag1", "cluster_lag1", @@ -64,15 +81,6 @@ "euro_volume", "volume", "open", "high", "low", "close", ] -TRANSACTION_COST: float = 0.0010 -MIN_STOCKS_OPTIM: int = 3 -MAX_STOCKS_SELECT: int = 10 -WEIGHT_BOUNDS: tuple = (0.03, 0.20) - -SHARPE_THRESHOLD: float = 0.30 -MAX_DD_THRESHOLD: float = -0.40 - -BACKTEST_YEARS: int = 2 FEATURE_GROUPS = { "momentum": [