diff --git a/.github/workflows/daily_update.yml b/.github/workflows/daily_update.yml index 99e11bf..fd6d247 100644 --- a/.github/workflows/daily_update.yml +++ b/.github/workflows/daily_update.yml @@ -41,6 +41,22 @@ jobs: MLFLOW_TRACKING_USERNAME: "SORADATA" MLFLOW_TRACKING_PASSWORD: ${{ secrets.HF_TOKEN }} + - name: 📊 Detect processed markets + if: always() + id: markets + run: | + MARKETS=$(python -c " + import json, glob + names = [] + for f in sorted(glob.glob('config/markets/*.json')): + try: + with open(f) as fh: + names.append(json.load(fh).get('market_name', 'UNKNOWN')) + except Exception: + pass + print(' & '.join(names)) + ") + echo "list=$MARKETS" >> "$GITHUB_OUTPUT" - name: 📋 Generate job summary if: always() @@ -48,6 +64,5 @@ jobs: echo "## AlphaEdge Daily Summary" >> $GITHUB_STEP_SUMMARY echo "- **Status**: ${{ job.status }}" >> $GITHUB_STEP_SUMMARY echo "- **Run date**: $(date +'%Y-%m-%d %H:%M %Z')" >> $GITHUB_STEP_SUMMARY - echo "- **Market**: CAC40 & US_TECH" >> $GITHUB_STEP_SUMMARY - # Ajouter un lien vers le rapport si tu génères un artifact + echo "- **Market**: ${{ steps.markets.outputs.list }}" >> $GITHUB_STEP_SUMMARY echo "Check the logs above for detailed performance metrics." >> $GITHUB_STEP_SUMMARY \ No newline at end of file diff --git a/app.py b/app.py index 10243c4..fcf0856 100644 --- a/app.py +++ b/app.py @@ -1,21 +1,34 @@ import os import json import time +from pathlib import Path +from datetime import datetime, timedelta + import numpy as np import pandas as pd import streamlit as st import plotly.express as px import plotly.graph_objects as go -from pathlib import Path -from datetime import datetime, timedelta from plotly.subplots import make_subplots from streamlit_autorefresh import st_autorefresh import yfinance as yf - import mlflow from mlflow.tracking import MlflowClient from mlflow.exceptions import MlflowException + +# ============================================================================= +# IMPORTS DES MODULES UTILITAIRES ET DATA +# ============================================================================= +from src.utils.market_utils import get_live_ticker_data, discover_markets, get_ticker_currency +from src.utils.ui_utils import display_kpi_card, load_css +from src.utils.metrics import calculate_metrics, calculate_period_return +from src.utils.math_utils import trim_flat_start +from src.utils.mlflow_utils import get_champion_metrics +from src.extract.data_loader import load_all_data +from src.utils.config_loader import get_ticker_names, apply_ticker_names + + # ============================================================================= # CONFIGURATION & STYLE # ============================================================================= @@ -40,310 +53,28 @@ MODEL_DIR = BASE_DIR / "models" -@st.cache_data(ttl=1800, show_spinner=False) -def _discover_markets(): - """ - Decouvre dynamiquement les marches disponibles en interrogeant le repo - Hugging Face distant (dataset HF_REPO_ID), sous le prefixe data//. - Fallback sur un scan local (data/processed) si l'API HF echoue, puis sur - une liste par defaut en dernier recours. - """ - try: - from huggingface_hub import HfApi - api = HfApi() - files = api.list_repo_files(repo_id=HF_REPO_ID, repo_type="dataset", token=HF_TOKEN) - markets = sorted({ - f.split("/")[1] for f in files - if f.startswith("data/") and len(f.split("/")) > 2 - }) - if markets: - return markets - except Exception: - pass - - local_dir = BASE_DIR / "data" / "processed" - if local_dir.exists(): - found = sorted([p.name for p in local_dir.iterdir() if p.is_dir()]) - if found: - return found - - return ["CAC40", "BRVM"] - -MARKET_OPTIONS = _discover_markets() +# Chargement du style CSS personnalisé +load_css() -st.markdown(""" - -""", unsafe_allow_html=True) +def _load_fallback_markets(base_dir: Path) -> list: + cfg_path = base_dir / "config" / "markets.json" + if cfg_path.exists(): + with open(cfg_path) as f: + return json.load(f) + return [] -# ============================================================================= -# CHARGEMENT DES DONNEES DEPUIS HUGGING FACE (par marche) - version robuste -# ============================================================================= -@st.cache_data(ttl=600, show_spinner=False) -def load_all_data(market: str): - """ - Charge portfolio_history / latest_signals / rebalance_history pour un marche. - Version durcie : normalisation du nom de marche, chargement defensif - (safe_load) qui isole chaque fichier pour qu'une erreur sur l'un ne - bloque pas les autres, tout en conservant les controles metier - (colonnes manquantes, fraicheur des donnees) de la version precedente. - """ - clean_market = str(market).strip() - base_url = f"https://huggingface.co/datasets/{HF_REPO_ID}/resolve/main/data/{clean_market}" - - errors = [] - - def safe_load(url, key): - try: - df = pd.read_parquet(url) - if key in ["hist", "rebal"]: - df.index = pd.to_datetime(df.index, errors="coerce") - df = df[df.index.notna()].sort_index(ascending=(key == "hist")) - return df - except Exception as e: - errors.append(f"Error loading {key}: {e}") - return pd.DataFrame() - - df_hist = safe_load(f"{base_url}/portfolio_history.parquet", "hist") - df_signals = safe_load(f"{base_url}/latest_signals.parquet", "signals") - df_rebalance = safe_load(f"{base_url}/rebalance_history.parquet", "rebal") - - if not df_hist.empty: - days_old = (datetime.now() - df_hist.index[-1]).days - if days_old > 7: - errors.append(f"Portfolio data is {days_old} days old") - - if not df_signals.empty: - missing = [c for c in ["Ticker", "Signal"] if c not in df_signals.columns] - if missing: - errors.append(f"Missing columns in signals: {missing}") - - return df_hist, df_signals, df_rebalance, errors - - -# ============================================================================= -# FONCTIONS UTILITAIRES - KPIs / MARCHE -# ============================================================================= - -def display_kpi_card(label, value, is_percent=True, color_code=False, prefix="", suffix="", minimal=False): - if pd.isna(value) or np.isinf(value): - html_val = 'N/A' - else: - if is_percent: - formatted_val = f"{prefix}{value:.1%}{suffix}" - elif isinstance(value, (int, np.integer)) or suffix: - formatted_val = f"{prefix}{int(value)}{suffix}" - else: - formatted_val = f"{prefix}{value:.2f}{suffix}" - if color_code: - color_class = "kpi-delta-pos" if value >= 0 else "kpi-delta-neg" - arrow = "▲" if value >= 0 else "▼" - html_val = f'{arrow} {formatted_val}' - else: - html_val = f'{formatted_val}' - css_class = "kpi-minimal" if minimal else "kpi-container" - st.markdown(f""" -
-
{label}
- {html_val} -
- """, unsafe_allow_html=True) - - -def calculate_metrics(df): - if df.empty or len(df) < 2: - return 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 - cum_ret = (1 + strategy_returns).cumprod() - running_max = cum_ret.cummax() - dd_series = (cum_ret - running_max) / running_max - max_dd = dd_series.min() - recovery_time = _compute_recovery_time(dd_series) - return total_ret, alpha, sharpe, max_dd, recovery_time - except Exception: - return 0, 0, 0, 0, 0 - - -def _compute_recovery_time(dd_series: pd.Series) -> int: - """ - Calcule le nombre de jours écoulés depuis le point bas du dernier - drawdown significatif jusqu'au retour au plus haut (0). Si la - stratégie n'a pas encore récupéré, retourne le nombre de jours - depuis le point bas jusqu'à aujourd'hui (recovery en cours). - """ - if dd_series.empty: - return 0 - trough_idx = dd_series.idxmin() - post_trough = dd_series.loc[trough_idx:] - recovered = post_trough[post_trough >= -0.0001] - if len(recovered) > 1: - recovery_date = recovered.index[1] - return (recovery_date - trough_idx).days - return (dd_series.index[-1] - trough_idx).days - - -def calculate_period_return(df, days=None, ytd=False, daily=False): - if df.empty or "Strategy" not in df.columns or len(df) < 2: - return 0.0 - try: - if daily: - return (df["Strategy"].iloc[-1] / df["Strategy"].iloc[-2]) - 1 - last_price, last_date = df["Strategy"].iloc[-1], df.index[-1] - if ytd: - target_date = datetime(last_date.year, 1, 1) - elif days: - target_date = last_date - timedelta(days=days) - else: - target_date = df.index[0] - if target_date < df.index[0]: - start_price = df["Strategy"].iloc[0] - else: - start_price = df["Strategy"].iloc[df.index.get_indexer([target_date], method="nearest")[0]] - return ((last_price / start_price) - 1) if start_price != 0 else 0.0 - except Exception: - return 0.0 - - -def _trim_flat_start(df: pd.DataFrame, tol: float = 1e-6) -> pd.DataFrame: - """ - Supprime la periode initiale "plate" (valeurs constantes, generalement un - placeholder egal a la valeur de base) presente au debut de certains - historiques, avant le premier rebalancement reel de la strategie. - On garde tout l'historique si aucune periode plate n'est detectee. - """ - if df.empty or "Strategy" not in df.columns or len(df) < 3: - return df - changes = df["Strategy"].diff().abs() > tol - if "Benchmark" in df.columns: - changes = changes | (df["Benchmark"].diff().abs() > tol) - first_move = changes[changes].index - if len(first_move) == 0: - return df - start_idx = df.index.get_loc(first_move[0]) - start_idx = max(0, start_idx - 1) - return df.iloc[start_idx:] - - -@st.cache_data(ttl=3600) -def get_live_ticker_data(ticker, period="1y"): - for _ in range(3): - try: - df = yf.download(ticker, period=period, progress=False, timeout=10) - if not df.empty: - df.columns = df.columns.get_level_values(0) if isinstance(df.columns, pd.MultiIndex) else df.columns - df.columns = df.columns.str.lower() - if "adj close" not in df.columns and "close" in df.columns: - df["adj close"] = df["close"] - return df - time.sleep(2) - except Exception: - time.sleep(2) - return pd.DataFrame() - - -# ============================================================================= -# FONCTIONS UTILITAIRES - MLFLOW (aligne sur train.py : alias "champion") -# ============================================================================= +MARKET_OPTIONS = discover_markets( + repo_id=HF_REPO_ID, + token=HF_TOKEN, + local_dir=BASE_DIR / "data" / "processed", + fallback=_load_fallback_markets(BASE_DIR), +) -@st.cache_data(ttl=600, show_spinner=False) -def get_champion_metrics(market: str): - """ - Recupere les metriques du modele 'champion' pour un marche donne. - 1) Essaie MLflow via l'alias 'champion'. - 2) Si MLflow est indisponible ou qu'aucun alias 'champion' n'existe encore, - on retombe sur le model_card.json sauvegarde localement par train.py. - """ - result = { - "source": None, - "metrics": {}, - "version": None, - "run_id": None, - "promoted": None, - "error": None, - } - registered_model_name = f"AlphaEdge_Ensemble_{market}" - - if MLFLOW_ENABLED: - try: - client = MlflowClient() - mv = client.get_model_version_by_alias(registered_model_name, "champion") - run = client.get_run(mv.run_id) - result["source"] = "mlflow" - result["metrics"] = run.data.metrics - result["version"] = mv.version - result["run_id"] = mv.run_id - result["promoted"] = True - return result - except MlflowException as e: - result["error"] = f"MLflow: {e}" - except Exception as e: - result["error"] = f"MLflow: {e}" - - card_path = MODEL_DIR / market / "model_card.json" - if card_path.exists(): - try: - with open(card_path, "r") as f: - card = json.load(f) - result["source"] = "local" - metrics = {} - for k, v in card.get("metrics_ml", {}).items(): - metrics[f"ml_{k}"] = v - for k, v in card.get("metrics_fin", {}).items(): - metrics[f"fin_{k}"] = v - result["metrics"] = metrics - result["promoted"] = card.get("mlflow", {}).get("promoted", False) - result["run_id"] = card.get("mlflow", {}).get("run_id") - except Exception as e: - result["error"] = (result["error"] + " | " if result["error"] else "") + f"model_card.json: {e}" - - return result +if not MARKET_OPTIONS: + st.error("Aucun marché disponible : vérifiez HF_REPO_ID, HF_TOKEN, data/processed, ou config/markets.json.") + st.stop() # ============================================================================= @@ -356,7 +87,7 @@ def get_champion_metrics(market: str): selected_market = st.sidebar.selectbox("Marché", MARKET_OPTIONS, index=0) with st.spinner(f"Loading {selected_market} data..."): - df_hist, df_signals, df_rebalance, load_errors = load_all_data(selected_market) + df_hist, df_signals, df_rebalance, load_errors = load_all_data(selected_market, HF_REPO_ID) if st.sidebar.button("Force Sync Pipeline"): st.cache_data.clear() @@ -466,58 +197,25 @@ def get_champion_metrics(market: str): with col_filter: p_sel = st.radio("Zoom:", ["1M", "3M", "6M", "YTD", "1Y", "ALL"], index=5, horizontal=True, label_visibility="collapsed") - df_c = _trim_flat_start(df_hist) + df_c = trim_flat_start(df_hist) end = df_c.index[-1] - if p_sel == "1M": - start = end - timedelta(days=30) - elif p_sel == "3M": - start = end - timedelta(days=90) - elif p_sel == "6M": - start = end - timedelta(days=180) + + zoom_map = {"1M": 30, "3M": 90, "6M": 180, "1Y": 365} + if p_sel in zoom_map: + start = end - timedelta(days=zoom_map[p_sel]) elif p_sel == "YTD": start = datetime(end.year, 1, 1) - elif p_sel == "1Y": - start = end - timedelta(days=365) else: start = df_c.index[0] - if start < df_c.index[0]: - start = df_c.index[0] + + start = max(start, df_c.index[0]) df_c = df_c[df_c.index >= pd.Timestamp(start)] df_base = df_c.apply(lambda x: x / x.iloc[0] * 100) fig = go.Figure() - fig.add_trace(go.Scatter( - x=df_base.index, y=df_base["Benchmark"], - mode="lines", name="Benchmark", - line=dict(color="#8b92a5", width=1.3, dash="dot"), - hovertemplate="Benchmark: %{y:.1f}" - )) - fig.add_trace(go.Scatter( - x=df_base.index, y=df_base["Strategy"], - mode="lines", name="Strategy", - line=dict(color="#2ED9A0", width=2), - hovertemplate="Strategy: %{y:.1f}" - )) - fig.update_layout( - template="plotly_white", - plot_bgcolor="#11151c", - paper_bgcolor="#11151c", - font=dict(color="#c9ced6", size=12), - margin=dict(l=0, r=0, t=30, b=0), - height=400, - hovermode="x unified", - legend=dict( - orientation="h", y=1.12, x=1, xanchor="right", - bgcolor="rgba(0,0,0,0)", title=None, - font=dict(size=12) - ), - xaxis=dict(showgrid=False, showline=True, linecolor="#2a2f3a", ticks="outside", tickcolor="#2a2f3a"), - yaxis=dict( - title="Indexed Value (Base 100)", title_font=dict(size=11, color="#8b92a5"), - showgrid=True, gridcolor="rgba(255,255,255,0.06)", zeroline=False, - showline=False - ) - ) + fig.add_trace(go.Scatter(x=df_base.index, y=df_base["Benchmark"], mode="lines", name="Benchmark", line=dict(color="#8b92a5", width=1.3, dash="dot"), hovertemplate="Benchmark: %{y:.1f}")) + fig.add_trace(go.Scatter(x=df_base.index, y=df_base["Strategy"], mode="lines", name="Strategy", line=dict(color="#2ED9A0", width=2), hovertemplate="Strategy: %{y:.1f}")) + fig.update_layout(template="plotly_white", plot_bgcolor="#11151c", paper_bgcolor="#11151c", font=dict(color="#c9ced6", size=12), margin=dict(l=0, r=0, t=30, b=0), height=400, hovermode="x unified", legend=dict(orientation="h", y=1.12, x=1, xanchor="right", bgcolor="rgba(0,0,0,0)", title=None, font=dict(size=12)), xaxis=dict(showgrid=False, showline=True, linecolor="#2a2f3a", ticks="outside", tickcolor="#2a2f3a"), yaxis=dict(title="Indexed Value (Base 100)", title_font=dict(size=11, color="#8b92a5"), showgrid=True, gridcolor="rgba(255,255,255,0.06)", zeroline=False, showline=False)) st.plotly_chart(fig, use_container_width=True) st.markdown("---") @@ -528,43 +226,34 @@ def get_champion_metrics(market: str): cum = (1 + s_ret).cumprod() dd = (cum - cum.cummax()) / cum.cummax() fig_dd = go.Figure() - fig_dd.add_trace(go.Scatter( - x=dd.index, y=dd, fill="tozeroy", mode="lines", - line=dict(color="#EF553B", width=1.5), - name="Drawdown", fillcolor="rgba(239, 85, 59, 0.3)" - )) - fig_dd.update_layout( - template="plotly_dark", margin=dict(l=0, r=0, t=10, b=0), - height=320, yaxis_tickformat=".1%", yaxis_title="Drawdown" - ) + fig_dd.add_trace(go.Scatter(x=dd.index, y=dd, fill="tozeroy", mode="lines", line=dict(color="#EF553B", width=1.5), name="Drawdown", fillcolor="rgba(239, 85, 59, 0.3)")) + fig_dd.update_layout(template="plotly_dark", margin=dict(l=0, r=0, t=10, b=0), height=320, yaxis_tickformat=".1%", yaxis_title="Drawdown") st.plotly_chart(fig_dd, use_container_width=True) with c_pie: st.subheader("Current Allocation") if not df_signals.empty and "Allocation" in df_signals.columns: + ticker_names = get_ticker_names(selected_market, BASE_DIR) active = df_signals[df_signals["Allocation"] > 0.001].copy() + active = apply_ticker_names(active, ticker_names) cash = max(0, 1.0 - active["Allocation"].sum()) if cash > 0.001: - final = pd.concat([active, pd.DataFrame([{"Ticker": "CASH", "Allocation": cash}])], ignore_index=True) + final = pd.concat([active, pd.DataFrame([{"Ticker": "CASH", "Name": "CASH", "Allocation": cash}])], ignore_index=True) else: final = active - fig_p = px.pie(final, values="Allocation", names="Ticker", hole=0.5, color_discrete_sequence=px.colors.qualitative.Prism) + fig_p = px.pie(final, values="Allocation", names="Name", hole=0.5, color_discrete_sequence=px.colors.qualitative.Prism) fig_p.update_traces(textposition="outside", textinfo="percent+label") fig_p.update_layout(template="plotly_dark", margin=dict(l=20, r=20, t=0, b=0), showlegend=False, height=370) st.plotly_chart(fig_p, use_container_width=True) else: st.info("Waiting for signals...") - else: - st.warning(f"No data available for {selected_market} from Hugging Face.") - - # ============================================================================= # PAGE 2 : DAILY SIGNALS # ============================================================================= - elif page == "Daily Signals": st.title(f"Daily Trading Signals - {selected_market}") if not df_signals.empty: - d = df_signals.copy() + ticker_names = get_ticker_names(selected_market, BASE_DIR) + d = apply_ticker_names(df_signals, ticker_names) if "Allocation" in d.columns: d = d.sort_values("Allocation", ascending=False) col_filter1, col_filter2 = st.columns([1, 3]) @@ -594,18 +283,25 @@ def get_champion_metrics(market: str): else: st.info(f"No signals available for {selected_market}.") - # ============================================================================= # PAGE 3 : DATA EXPLORER # ============================================================================= - elif page == "Data Explorer": st.title("Market Data Explorer") default_tickers = ["AI.PA", "AIR.PA", "BNP.PA", "MC.PA", "OR.PA", "TTE.PA"] tickers = df_signals["Ticker"].unique().tolist() if not df_signals.empty and "Ticker" in df_signals.columns else default_tickers + + ticker_names = get_ticker_names(selected_market, BASE_DIR) + + def format_ticker(t): + name = ticker_names.get(t) + return f"{t} — {name}" if name else t + col_sel1, col_sel2 = st.columns([1, 3]) with col_sel1: - selected_ticker = st.selectbox("Select Asset", tickers, index=0) + selected_ticker = st.selectbox("Select Asset", tickers, index=0, format_func=format_ticker) + currency_code, currency_symbol = get_ticker_currency(selected_ticker) + st.caption(f"Devise : {currency_code}") with col_sel2: period_exp = st.selectbox("Timeframe", ["1 Month", "3 Months", "6 Months", "1 Year", "5 Years"], index=2) yf_period_map = {"1 Month": "1mo", "3 Months": "3mo", "6 Months": "6mo", "1 Year": "1y", "5 Years": "5y"} @@ -622,7 +318,7 @@ def get_champion_metrics(market: str): last_close = daily_var = total_ret_period = volatility = 0 m1, m2, m3, m4 = st.columns(4) with m1: - display_kpi_card("Last Price", last_close, is_percent=False, prefix="€ ") + display_kpi_card("Last Price", last_close, is_percent=False, prefix=f"{currency_symbol} ") with m2: display_kpi_card("Daily Change", daily_var, color_code=True) with m3: @@ -641,9 +337,8 @@ def get_champion_metrics(market: str): else: st.warning(f"No data for {selected_ticker}") - # ============================================================================= -# PAGE 4 : MODEL DETAILS (MLflow - alias "champion", fallback model_card.json) +# PAGE 4 : MODEL DETAILS # ============================================================================= elif page == "Model Details": @@ -659,7 +354,11 @@ def get_champion_metrics(market: str): """) st.markdown("---") - champ = get_champion_metrics(selected_market) + champ = get_champion_metrics( + selected_market, + model_dir=MODEL_DIR, + mlflow_enabled=MLFLOW_ENABLED, + ) if champ["source"] == "mlflow": st.markdown( @@ -776,4 +475,4 @@ def get_champion_metrics(market: str):

Risques : Tout investissement comporte des risques. Les performances passées ne garantissent pas les résultats futurs.

Responsabilité : Consultez un conseiller financier agréé avant toute décision d'investissement.

-""", unsafe_allow_html=True) +""", unsafe_allow_html=True) \ No newline at end of file diff --git a/config/markets/cac40.json b/config/markets/cac40.json index 21f6879..0673294 100644 --- a/config/markets/cac40.json +++ b/config/markets/cac40.json @@ -9,5 +9,47 @@ "GLE.PA", "STLAP.PA", "STMPA.PA", "TEP.PA", "HO.PA", "TTE.PA", "URW.PA", "VIE.PA", "DG.PA", "VIV.PA", "WLN.PA", "FR.PA" ], + "ticker_names": { + "AI.PA": "Air Liquide", + "AIR.PA": "Airbus", + "ALO.PA": "Alstom", + "MT.AS": "ArcelorMittal", + "ATO.PA": "Atos", + "CS.PA": "AXA", + "BNP.PA": "BNP Paribas", + "EN.PA": "Bouygues", + "CAP.PA": "Capgemini", + "CA.PA": "Carrefour", + "DSY.PA": "Dassault Systèmes", + "EL.PA": "EssilorLuxottica", + "ENGI.PA": "Engie", + "ERF.PA": "Eurofins Scientific", + "RMS.PA": "Hermès International", + "KER.PA": "Kering", + "OR.PA": "L'Oréal", + "LR.PA": "Legrand", + "MC.PA": "LVMH", + "ML.PA": "Michelin", + "ORA.PA": "Orange", + "RI.PA": "Pernod Ricard", + "PUB.PA": "Publicis Groupe", + "RNO.PA": "Renault", + "SAF.PA": "Safran", + "SGO.PA": "Saint-Gobain", + "SAN.PA": "Sanofi", + "SU.PA": "Schneider Electric", + "GLE.PA": "Société Générale", + "STLAP.PA": "Stellantis", + "STMPA.PA": "STMicroelectronics", + "TEP.PA": "Teleperformance", + "HO.PA": "Thales", + "TTE.PA": "TotalEnergies", + "URW.PA": "Unibail-Rodamco-Westfield", + "VIE.PA": "Veolia Environnement", + "DG.PA": "Vinci", + "VIV.PA": "Vivendi", + "WLN.PA": "Worldline", + "FR.PA": "Valeo" + }, "ff_region": "Europe_5_Factors" -} +} \ No newline at end of file diff --git a/config/markets/em_core.json b/config/markets/em_core.json new file mode 100644 index 0000000..1fb48e8 --- /dev/null +++ b/config/markets/em_core.json @@ -0,0 +1,130 @@ +{ + "market_name": "EM_CORE", + "description": "Pays couverts : Chine, Taïwan, Corée du Sud, Inde, Brésil, Turquie, Afrique du Sud, Mexique, Malaisie, Thaïlande.", + "benchmark_ticker": "EEM", + "tickers": [ + "TSM", "005930.KS", "000660.KS", "0700.HK", "9988.HK", + "601398.SS", "601857.SS", "600519.SS", "601288.SS", "601988.SS", + "1299.HK", "0939.HK", "3690.HK", "1810.HK", "2318.HK", + "INFY.NS", "RELIANCE.NS", "TCS.NS", "HDFCBANK.NS", "ICICIBANK.NS", + "BHARTIARTL.NS", "ITC.NS", "SBIN.NS", "LT.NS", "HINDUNILVR.NS", + "VALE3.SA", "PETR4.SA", "ITUB4.SA", "BBDC4.SA", "ABEV3.SA", + "B3SA3.SA", "WEGE3.SA", "BBAS3.SA", "SUZB3.SA", "RENT3.SA", + "005380.KS", "051910.KS", "035420.KS", "006400.KS", "035720.KS", + "2330.TW", "2317.TW", "2454.TW", "2882.TW", "1301.TW", + "PDD", "JD", "BABA", "NTES", "BIDU", + "ASELS.IS", "THYAO.IS", "GARAN.IS", "BIMAS.IS", "AKBNK.IS", + "NPN.JO", "PRX.AS", "SOL.JO", "FSR.JO", "SBK.JO", + "AMXL.MX", "WALMEX.MX", "GFNORTEO.MX", "CEMEXCPO.MX", "FEMSAUBD.MX", + "1211.HK", "2020.HK", "0388.HK", "0016.HK", "0688.HK", + "600036.SS", "600276.SS", "000858.SZ", "300750.SZ", "601601.SS", + "0883.HK", "2628.HK", "1928.HK", "3968.HK", "6098.HK", + "017670.KS", "032830.KS", "066570.KS", "015760.KS", "003550.KS", + "2308.TW", "2412.TW", "3034.TW", "2884.TW", "5871.TW", + "SIME.KL", "MAYBANK.KL", "PBBANK.KL", "PCHEM.KL", "TENAGA.KL", + "SCC.BK", "PTT.BK", "CPALL.BK", "AOT.BK", "KBANK.BK" + ], + "ticker_names": { + "TSM": "Taiwan Semiconductor Manufacturing (ADR)", + "005930.KS": "Samsung Electronics", + "000660.KS": "SK Hynix", + "0700.HK": "Tencent Holdings", + "9988.HK": "Alibaba Group Holding", + "601398.SS": "Industrial and Commercial Bank of China", + "601857.SS": "PetroChina", + "600519.SS": "Kweichow Moutai", + "601288.SS": "Agricultural Bank of China", + "601988.SS": "Bank of China", + "1299.HK": "AIA Group", + "0939.HK": "China Construction Bank", + "3690.HK": "Meituan", + "1810.HK": "Xiaomi", + "2318.HK": "Ping An Insurance Group", + "INFY.NS": "Infosys", + "RELIANCE.NS": "Reliance Industries", + "TCS.NS": "Tata Consultancy Services", + "HDFCBANK.NS": "HDFC Bank", + "ICICIBANK.NS": "ICICI Bank", + "BHARTIARTL.NS": "Bharti Airtel", + "ITC.NS": "ITC Limited", + "SBIN.NS": "State Bank of India", + "LT.NS": "Larsen & Toubro", + "HINDUNILVR.NS": "Hindustan Unilever", + "VALE3.SA": "Vale", + "PETR4.SA": "Petrobras", + "ITUB4.SA": "Itaú Unibanco", + "BBDC4.SA": "Banco Bradesco", + "ABEV3.SA": "Ambev", + "B3SA3.SA": "B3 - Brasil Bolsa Balcão", + "WEGE3.SA": "WEG", + "BBAS3.SA": "Banco do Brasil", + "SUZB3.SA": "Suzano", + "RENT3.SA": "Localiza Rent a Car", + "005380.KS": "Hyundai Motor", + "051910.KS": "LG Chem", + "035420.KS": "NAVER", + "006400.KS": "Samsung SDI", + "035720.KS": "Kakao", + "2330.TW": "Taiwan Semiconductor Manufacturing", + "2317.TW": "Hon Hai Precision Industry (Foxconn)", + "2454.TW": "MediaTek", + "2882.TW": "Cathay Financial Holding", + "1301.TW": "Formosa Plastics", + "PDD": "PDD Holdings", + "JD": "JD.com", + "BABA": "Alibaba Group Holding (ADR)", + "NTES": "NetEase", + "BIDU": "Baidu", + "ASELS.IS": "Aselsan", + "THYAO.IS": "Turkish Airlines", + "GARAN.IS": "Türkiye Garanti Bankası", + "BIMAS.IS": "BİM Birleşik Mağazalar", + "AKBNK.IS": "Akbank", + "NPN.JO": "Naspers", + "PRX.AS": "Prosus", + "SOL.JO": "Sasol", + "FSR.JO": "FirstRand", + "SBK.JO": "Standard Bank Group", + "AMXL.MX": "América Móvil", + "WALMEX.MX": "Walmart de México", + "GFNORTEO.MX": "Grupo Financiero Banorte", + "CEMEXCPO.MX": "CEMEX", + "FEMSAUBD.MX": "FEMSA", + "1211.HK": "BYD Company", + "2020.HK": "ANTA Sports Products", + "0388.HK": "Hong Kong Exchanges and Clearing", + "0016.HK": "Sun Hung Kai Properties", + "0688.HK": "China Overseas Land & Investment", + "600036.SS": "China Merchants Bank", + "600276.SS": "Jiangsu Hengrui Pharmaceuticals", + "000858.SZ": "Wuliangye Yibin", + "300750.SZ": "Contemporary Amperex Technology (CATL)", + "601601.SS": "China Pacific Insurance Group", + "0883.HK": "CNOOC", + "2628.HK": "China Life Insurance", + "1928.HK": "Sands China", + "3968.HK": "China Merchants Bank (H-Shares)", + "6098.HK": "Country Garden Services Holdings", + "017670.KS": "SK Telecom", + "032830.KS": "Samsung Life Insurance", + "066570.KS": "LG Electronics", + "015760.KS": "Korea Electric Power (KEPCO)", + "003550.KS": "LG Corp.", + "2308.TW": "Delta Electronics", + "2412.TW": "Chunghwa Telecom", + "3034.TW": "Novatek Microelectronics", + "2884.TW": "E.Sun Financial Holding", + "5871.TW": "Chailease Holding", + "SIME.KL": "Sime Darby", + "MAYBANK.KL": "Malayan Banking (Maybank)", + "PBBANK.KL": "Public Bank", + "PCHEM.KL": "Petronas Chemicals Group", + "TENAGA.KL": "Tenaga Nasional", + "SCC.BK": "Siam Cement", + "PTT.BK": "PTT Public Company", + "CPALL.BK": "CP All", + "AOT.BK": "Airports of Thailand", + "KBANK.BK": "Kasikornbank" + }, + "ff_region": "Emerging_5_Factors" +} \ No newline at end of file diff --git a/config/markets/nasdaq.json b/config/markets/nasdaq.json new file mode 100644 index 0000000..797d946 --- /dev/null +++ b/config/markets/nasdaq.json @@ -0,0 +1,119 @@ +{ + "market_name": "NASDAQ100", + "benchmark_ticker": "^NDX", + "tickers": [ + "AAPL", "MSFT", "AMZN", "NVDA", "META", "TSLA", "GOOGL", "GOOG", "AVGO", "PEP", + "COST", "ADBE", "CSCO", "NFLX", "AMD", "TMUS", "QCOM", "INTC", "TXN", "INTU", + "AMGN", "AMAT", "HON", "ISRG", "BKNG", "SBUX", "MDLZ", "GILD", "ADP", "VRTX", + "ADI", "REGN", "PANW", "SNPS", "KLAC", "CDNS", "MU", "MELI", "PYPL", "CHTR", + "CSX", "MAR", "ORLY", "MNST", "ABNB", "ASML", "LRCX", "CTAS", "NXPI", "FTNT", + "PCAR", "DXCM", "KDP", "PAYX", "MCHP", "EXC", "KHC", "CRWD", "ROST", "IDXX", + "ODFL", "EA", "CTSH", "FAST", "BIIB", "CEG", "VRSK", "WBD", "ILMN", "SIRI", + "DDOG", "TEAM", "WDAY", "ZS", "PDD", "LULU", "MRVL", "CPRT", "DLTR", "EBAY", + "BKR", "GFS", "ANSS", "TTWO", "FANG", "WBA", "MDB", "ENPH", "ALGN", "BMRN", + "SGEN", "SPLK", "VRSN", "CDW", "CHKP", "PTC", "EXPE", "NTES", "JD", "LNT" + ], + "ticker_names": { + "AAPL": "Apple", + "MSFT": "Microsoft", + "AMZN": "Amazon.com", + "NVDA": "NVIDIA", + "META": "Meta Platforms", + "TSLA": "Tesla", + "GOOGL": "Alphabet (Class A)", + "GOOG": "Alphabet (Class C)", + "AVGO": "Broadcom", + "PEP": "PepsiCo", + "COST": "Costco Wholesale", + "ADBE": "Adobe", + "CSCO": "Cisco Systems", + "NFLX": "Netflix", + "AMD": "Advanced Micro Devices", + "TMUS": "T-Mobile US", + "QCOM": "Qualcomm", + "INTC": "Intel", + "TXN": "Texas Instruments", + "INTU": "Intuit", + "AMGN": "Amgen", + "AMAT": "Applied Materials", + "HON": "Honeywell International", + "ISRG": "Intuitive Surgical", + "BKNG": "Booking Holdings", + "SBUX": "Starbucks", + "MDLZ": "Mondelez International", + "GILD": "Gilead Sciences", + "ADP": "Automatic Data Processing", + "VRTX": "Vertex Pharmaceuticals", + "ADI": "Analog Devices", + "REGN": "Regeneron Pharmaceuticals", + "PANW": "Palo Alto Networks", + "SNPS": "Synopsys", + "KLAC": "KLA Corporation", + "CDNS": "Cadence Design Systems", + "MU": "Micron Technology", + "MELI": "MercadoLibre", + "PYPL": "PayPal Holdings", + "CHTR": "Charter Communications", + "CSX": "CSX Corporation", + "MAR": "Marriott International", + "ORLY": "O'Reilly Automotive", + "MNST": "Monster Beverage", + "ABNB": "Airbnb", + "ASML": "ASML Holding", + "LRCX": "Lam Research", + "CTAS": "Cintas", + "NXPI": "NXP Semiconductors", + "FTNT": "Fortinet", + "PCAR": "PACCAR", + "DXCM": "DexCom", + "KDP": "Keurig Dr Pepper", + "PAYX": "Paychex", + "MCHP": "Microchip Technology", + "EXC": "Exelon Corporation", + "KHC": "The Kraft Heinz Company", + "CRWD": "CrowdStrike Holdings", + "ROST": "Ross Stores", + "IDXX": "IDEXX Laboratories", + "ODFL": "Old Dominion Freight Line", + "EA": "Electronic Arts", + "CTSH": "Cognizant Technology Solutions", + "FAST": "Fastenal", + "BIIB": "Biogen", + "CEG": "Constellation Energy", + "VRSK": "Verisk Analytics", + "WBD": "Warner Bros. Discovery", + "ILMN": "Illumina", + "SIRI": "Sirius XM Holdings", + "DDOG": "Datadog", + "TEAM": "Atlassian", + "WDAY": "Workday", + "ZS": "Zscaler", + "PDD": "PDD Holdings", + "LULU": "Lululemon Athletica", + "MRVL": "Marvell Technology", + "CPRT": "Copart", + "DLTR": "Dollar Tree", + "EBAY": "eBay", + "BKR": "Baker Hughes", + "GFS": "GlobalFoundries", + "ANSS": "Ansys", + "TTWO": "Take-Two Interactive Software", + "FANG": "Diamondback Energy", + "WBA": "Walgreens Boots Alliance", + "MDB": "MongoDB", + "ENPH": "Enphase Energy", + "ALGN": "Align Technology", + "BMRN": "BioMarin Pharmaceutical", + "SGEN": "Seagen", + "SPLK": "Splunk", + "VRSN": "VeriSign", + "CDW": "CDW Corporation", + "CHKP": "Check Point Software Technologies", + "PTC": "PTC Inc.", + "EXPE": "Expedia Group", + "NTES": "NetEase", + "JD": "JD.com", + "LNT": "Alliant Energy" + }, + "ff_region": "North_America_5_Factors" +} \ No newline at end of file diff --git a/src/extract/data_loader.py b/src/extract/data_loader.py new file mode 100644 index 0000000..c7739a0 --- /dev/null +++ b/src/extract/data_loader.py @@ -0,0 +1,70 @@ +""" +Data Loader +============ +Chargement des données portfolio (historique, signaux, rebalancing) +depuis le dataset Hugging Face du dashboard. + +Fonctions : + - load_all_data() : charge portfolio_history / latest_signals / rebalance_history +""" + +from datetime import datetime + +import pandas as pd + +from src.utils.logger import setup_logger + +logger = setup_logger("data_loader") + + +def load_all_data(market: str, hf_repo_id: str) -> tuple: + """ + Charge portfolio_history / latest_signals / rebalance_history pour un marché + depuis le dataset Hugging Face hf_repo_id. + + Chargement défensif (safe_load) : une erreur sur un fichier n'empêche pas + le chargement des autres. Contrôles métier inclus (colonnes manquantes, + fraîcheur des données). + + Parameters + ---------- + market : str — nom du marché (ex: "CAC40") + hf_repo_id : str — repo HF dataset (ex: "soradata/alphaedge-data") + + Returns + ------- + tuple — (df_hist, df_signals, df_rebalance, errors) + """ + clean_market = str(market).strip() + base_url = f"https://huggingface.co/datasets/{hf_repo_id}/resolve/main/data/{clean_market}" + + errors = [] + + def safe_load(url, key): + try: + df = pd.read_parquet(url) + if key in ["hist", "rebal"]: + df.index = pd.to_datetime(df.index, errors="coerce") + df = df[df.index.notna()].sort_index(ascending=(key == "hist")) + return df + except Exception as e: + msg = f"Error loading {key}: {e}" + errors.append(msg) + logger.warning(msg) + return pd.DataFrame() + + df_hist = safe_load(f"{base_url}/portfolio_history.parquet", "hist") + df_signals = safe_load(f"{base_url}/latest_signals.parquet", "signals") + df_rebalance = safe_load(f"{base_url}/rebalance_history.parquet", "rebal") + + if not df_hist.empty: + days_old = (datetime.now() - df_hist.index[-1]).days + if days_old > 7: + errors.append(f"Portfolio data is {days_old} days old") + + if not df_signals.empty: + missing = [c for c in ["Ticker", "Signal"] if c not in df_signals.columns] + if missing: + errors.append(f"Missing columns in signals: {missing}") + + return df_hist, df_signals, df_rebalance, errors \ No newline at end of file diff --git a/src/features/alpha_features.py b/src/features/alpha_features.py index 2297e1a..43529bd 100644 --- a/src/features/alpha_features.py +++ b/src/features/alpha_features.py @@ -21,7 +21,7 @@ # ══════════════════════════════════════════════════════════════════ -# 1. CALCULS DES FEATURES (Logique métier) +# CALCULS DES FEATURES # ══════════════════════════════════════════════════════════════════ @@ -68,6 +68,7 @@ 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()) df["hist_var_5pct"] = g["return_1m"].transform(lambda x: x.rolling(12, min_periods=6).quantile(0.05)) + def _cvar(r: np.ndarray) -> float: t = np.quantile(r, 0.05); tail = r[r <= t] return tail.mean() if len(tail) > 0 else np.nan @@ -78,6 +79,7 @@ def _cvar(r: np.ndarray) -> float: 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)) + if "euro_volume" in df.columns: df["amihud_illiquidity"] = _safe_div(df["return_1m"].abs(), df["euro_volume"]) df["volume_trend_3m"] = g["euro_volume"].transform(lambda x: x.pct_change(3)) @@ -103,7 +105,7 @@ def add_rank_features(df: pd.DataFrame) -> pd.DataFrame: # ══════════════════════════════════════════════════════════════════ -# 2. FONCTIONS PRINCIPALES (Exposées) +# FONCTIONS PRINCIPALES # ══════════════════════════════════════════════════════════════════ @@ -128,12 +130,13 @@ def compute_technical_indicators(df: pd.DataFrame) -> pd.DataFrame: return df -def get_fama_french_betas(data: pd.DataFrame) -> pd.DataFrame: - logger.info("Retrieving Fama-French factors...") +# INJECTION DYNAMIQUE DE LA RÉGION FAMA-FRENCH +def get_fama_french_betas(data: pd.DataFrame, ff_region: str) -> pd.DataFrame: + logger.info(f"Retrieving Fama-French factors for region: {ff_region}...") try: with warnings.catch_warnings(): warnings.filterwarnings("ignore", message=".*date_parser.*") - factor_data = web.DataReader("Europe_5_Factors", "famafrench", start="2010")[0].drop("RF", axis=1) + factor_data = web.DataReader(ff_region, "famafrench", start="2010")[0].drop("RF", axis=1) factor_data.index = pd.to_datetime(factor_data.index.to_timestamp()).tz_localize(None) factor_data = factor_data.resample("BME").last().div(100) factor_data.index.name = "date" @@ -152,16 +155,20 @@ def get_fama_french_betas(data: pd.DataFrame) -> pd.DataFrame: if betas_list: betas_df = pd.concat(betas_list).set_index("ticker", append=True) data = data.join(betas_df.groupby("ticker").shift()) - data[FAMA_FRENCH_FACTORS] = data.groupby(level="ticker", group_keys=False)[FAMA_FRENCH_FACTORS].transform(lambda x: x.fillna(x.mean())) + data[FAMA_FRENCH_FACTORS] = data.groupby( + level="ticker", group_keys=False)[FAMA_FRENCH_FACTORS].transform(lambda x: x.fillna(x.mean())) return data except Exception as exc: 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: +def add_all_features(df: pd.DataFrame, market_config: dict) -> pd.DataFrame: if not isinstance(df.index, pd.MultiIndex): raise ValueError("MultiIndex requis.") - df = get_fama_french_betas(df.copy()) + if "ff_region" not in market_config: + raise ValueError("ff_region manquant dans market_config — vérifiez le fichier de config du marché.") + ff_region = market_config["ff_region"] + df = get_fama_french_betas(df.copy(), ff_region) g = df.groupby(level="ticker") logger.info("Computing alpha features...") df = _add_momentum_factors(df, g) diff --git a/src/models/train.py b/src/models/train.py index dff107d..4e7e3b2 100644 --- a/src/models/train.py +++ b/src/models/train.py @@ -31,6 +31,7 @@ from src.utils.logger import setup_logger from src.utils.metrics import calculate_financial_metrics + load_dotenv() warnings.filterwarnings("ignore") logger = setup_logger("train") @@ -145,12 +146,14 @@ def _load_market_dataset(market_name: str) -> pd.DataFrame: return df.dropna(subset=["target", "future_return"]) -def _train_test_split_by_date(df: pd.DataFrame, test_months: int) -> tuple[pd.DataFrame, pd.DataFrame]: +def _train_test_split_by_date( + df: pd.DataFrame, test_months: int, market_config: dict +) -> tuple[pd.DataFrame, pd.DataFrame]: """Split temporel (pas de shuffle) : les `test_months` derniers mois servent de test set.""" dates = df.index.get_level_values("date") split_date = dates.max() - pd.DateOffset(months=test_months) - df_train = add_all_features(df[dates <= split_date].copy()) - df_test = add_all_features(df[dates > split_date].copy()) + df_train = add_all_features(df[dates <= split_date].copy(), market_config) + df_test = add_all_features(df[dates > split_date].copy(), market_config) return df_train, df_test @@ -318,12 +321,13 @@ def _log_and_promote_to_mlflow( # PIPELINE D'ENTRAÎNEMENT # ============================================================================= -def train_pipeline(market_name: str) -> tuple[AlphaEdgeEnsemble, dict]: +def train_pipeline(market_config: dict) -> tuple[AlphaEdgeEnsemble, dict]: """Entraîne, évalue et (le cas échéant) promeut le modèle d'un marché donné.""" + market_name = market_config["market_name"] logger.info(f"Début de l'entraînement — {market_name}") df = _load_market_dataset(market_name) - df_train, df_test = _train_test_split_by_date(df, TEST_SET_MONTHS) + df_train, df_test = _train_test_split_by_date(df, TEST_SET_MONTHS, market_config) if len(df_train) < MIN_TRAIN_ROWS: raise ValueError(f"Volume de données insuffisant pour {market_name} : {len(df_train)} lignes.") @@ -368,18 +372,20 @@ def train_pipeline(market_name: str) -> tuple[AlphaEdgeEnsemble, dict]: # ORCHESTRATEUR # ============================================================================= -def _load_configured_markets(config_dir: Path) -> list[str]: - """Lit les noms de marchés à partir des fichiers de configuration JSON.""" - markets = [] +def _load_configured_markets(config_dir: Path) -> list[dict]: + """Lit les fichiers de configuration JSON et retourne les configs complètes.""" + configs = [] for config_file in sorted(config_dir.glob("*.json")): with open(config_file, encoding="utf-8") as f: market_cfg = json.load(f) - market = market_cfg.get("market_name") - if market: - markets.append(market) - else: + if not market_cfg.get("market_name"): logger.warning(f"Fichier de config sans 'market_name' ignoré : {config_file}") - return markets + continue + if not market_cfg.get("ff_region"): + logger.warning(f"Fichier de config sans 'ff_region' ignoré : {config_file}") + continue + configs.append(market_cfg) + return configs def main() -> None: @@ -388,15 +394,16 @@ def main() -> None: logger.error(f"Dossier de configs introuvable : {config_dir}") raise SystemExit(1) - markets = _load_configured_markets(config_dir) - if not markets: + market_configs = _load_configured_markets(config_dir) + if not market_configs: logger.error(f"Aucun marché configuré trouvé dans {config_dir}") raise SystemExit(1) failures = [] - for market in markets: + for market_config in market_configs: + market = market_config["market_name"] try: - train_pipeline(market) + train_pipeline(market_config) except Exception: logger.critical(f"[{market}] Échec complet de l'entraînement", exc_info=True) failures.append(market) diff --git a/src/pipeline/backtest.py b/src/pipeline/backtest.py index 4a0b9ae..4b6ba15 100644 --- a/src/pipeline/backtest.py +++ b/src/pipeline/backtest.py @@ -87,10 +87,10 @@ def _build_price_matrix(df_daily: pd.DataFrame, ffill_limit: int = MAX_PRICE_FFI return df_daily[col].unstack().ffill(limit=ffill_limit) -def _build_daily_snapshot(df_daily: pd.DataFrame) -> Tuple[pd.DataFrame, pd.Timestamp]: +def _build_daily_snapshot(df_daily: pd.DataFrame, market_config: dict) -> Tuple[pd.DataFrame, pd.Timestamp]: last_date = df_daily.index.get_level_values("date").max() lookback_df = df_daily.iloc[-252:].copy() - df_feat = add_all_features(lookback_df) + df_feat = add_all_features(lookback_df, market_config) return df_feat.xs(last_date, level="date").copy(), last_date @@ -127,12 +127,12 @@ def _simulate_period(allocation, drifted_allocation, trading_days, daily_returns 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): +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()) + df_monthly_feat = add_all_features(df_monthly.copy(), market_config) df_monthly_feat = df_monthly_feat[valid_history_mask.reindex(df_monthly_feat.index, fill_value=False)] n_dropped = int((~valid_history_mask).sum()) @@ -176,8 +176,8 @@ def backtest_strategy_with_rebalancing(df_daily, df_monthly, model, benchmark_ti return pd.concat(period_frames), pd.DataFrame(rebalance_log).set_index("Date"), {} -def generate_live_signals(df_daily, daily_prices, model, rebalance_history): - snapshot, last_date = _build_daily_snapshot(df_daily) +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() allocation = {} @@ -190,4 +190,4 @@ def generate_live_signals(df_daily, daily_prices, model, rebalance_history): 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 + return out[["Ticker", "Signal", "Allocation", "Proba_Hausse"]], rebalance_history \ No newline at end of file diff --git a/src/pipeline/daily_run.py b/src/pipeline/daily_run.py index 5ef0c2b..3f89b4a 100644 --- a/src/pipeline/daily_run.py +++ b/src/pipeline/daily_run.py @@ -17,6 +17,7 @@ from src.models.model_loader import load_champion from const import BACKTEST_YEARS + # ============================================================================= # CONFIGURATION GLOBALE # ============================================================================= @@ -25,6 +26,7 @@ HF_REPO_ID = os.getenv("HF_REPO_ID", "soradata/alphaedge-data") hf_api = HfApi() + def upload_to_hf(local_path: Path, hf_filename: str, market_name: str) -> bool: """Upload vers le repo Hugging Face, sous data/{market_name}/{hf_filename}.""" if not HF_TOKEN: @@ -42,6 +44,7 @@ def upload_to_hf(local_path: Path, hf_filename: str, market_name: str) -> bool: print(f"Erreur Upload HF ({market_name}/{hf_filename}): {e}") return False + def load_rebalance_history_from_hf(market_name: str, local_fallback: Path) -> pd.DataFrame: """ Récupère l'historique de rebalancing existant (source de vérité pour @@ -75,6 +78,7 @@ def load_rebalance_history_from_hf(market_name: str, local_fallback: Path) -> pd return empty + # ============================================================================= # PIPELINE PRINCIPAL (daily run) # ============================================================================= @@ -124,6 +128,7 @@ def run_pipeline(market_config: dict) -> None: df_monthly_bt, model, benchmark_ticker=bench_ticker, + market_config=market_config, ) # 4. Signaux live (séance N) @@ -135,6 +140,7 @@ def run_pipeline(market_config: dict) -> None: daily_prices, model, rebalance_history, + market_config, ) if signals_df.empty: @@ -183,6 +189,7 @@ def run_pipeline(market_config: dict) -> None: logger.critical(f"CRITICAL FAILURE {market_name}: {e}", exc_info=True) raise + # ============================================================================= # ORCHESTRATEUR # ============================================================================= @@ -201,4 +208,4 @@ def run_pipeline(market_config: dict) -> None: if failures: print(f"Marchés en échec : {failures}") - raise SystemExit(1) + raise SystemExit(1) \ No newline at end of file diff --git a/src/pipeline/etl.py b/src/pipeline/etl.py index cefb4f9..3822e36 100644 --- a/src/pipeline/etl.py +++ b/src/pipeline/etl.py @@ -10,6 +10,7 @@ from src.transform.ticker_manager import handle_ticker_changes from src.utils.logger import setup_logger + logger = setup_logger("etl") @@ -17,6 +18,10 @@ def get_data_pipeline(market_config: dict) -> Tuple[Optional[pd.DataFrame], Opti market_name = market_config["market_name"] tickers = market_config["tickers"] + if "ff_region" not in market_config: + logger.error(f"Abandon du pipeline pour {market_name} : 'ff_region' manquant dans la config.") + return None, None + ticker_changes, delisted = handle_ticker_changes() active_tickers = [ ticker_changes.get(t, t) for t in tickers if t not in delisted @@ -28,7 +33,10 @@ def get_data_pipeline(market_config: dict) -> Tuple[Optional[pd.DataFrame], Opti logger.error(f"Abandon du pipeline pour {market_name} : aucune donnée extraite.") return None, None - processor = MarketDataProcessor(active_tickers=active_tickers) + processor = MarketDataProcessor( + active_tickers=active_tickers, + ff_region=market_config["ff_region"], + ) df_daily, df_monthly, alerts = processor.process(raw) BASE_DIR.mkdir(parents=True, exist_ok=True) diff --git a/src/transform/processor.py b/src/transform/processor.py index dd9d756..2f92eac 100644 --- a/src/transform/processor.py +++ b/src/transform/processor.py @@ -4,7 +4,6 @@ from const import VARS_TO_LAG, RESAMPLE_MEAN_COLS, RESAMPLE_LAST_EXCLUDE from src.features.alpha_features import ( compute_technical_indicators, - get_fama_french_betas, add_all_features, ) from src.transform.ticker_manager import validate_and_clean_tickers @@ -22,10 +21,16 @@ class MarketDataProcessor: ---------- active_tickers : list[str] Liste des tickers actifs sur le marché considéré. + ff_region : str + Région Fama-French à utiliser pour le calcul des betas + (ex: "Europe_5_Factors", "Emerging_5_Factors", "North_America_5_Factors"). """ - def __init__(self, active_tickers: List[str]): + def __init__(self, active_tickers: List[str], ff_region: str): + if not ff_region: + raise ValueError("ff_region est obligatoire — vérifiez le fichier de config du marché.") self.active_tickers = active_tickers + self.ff_region = ff_region # ── Agrégation mensuelle def _resample_to_monthly(self, df: pd.DataFrame) -> pd.DataFrame: @@ -66,20 +71,19 @@ def process(self, raw_df: pd.DataFrame) -> Tuple[pd.DataFrame, pd.DataFrame, dic if "adj close" not in df.columns and "close" in df.columns: df["adj close"] = df["close"] logger.warning("adj close absent — utilisation de close comme proxy.") - + df, valid_tickers, alerts = validate_and_clean_tickers(df, self.active_tickers) df = compute_technical_indicators(df) logger.info("Agrégation à la fréquence mensuelle...") df_monthly = self._resample_to_monthly(df) - # Calcul des features et facteurs - df_monthly = get_fama_french_betas(df_monthly) - df_monthly = add_all_features(df_monthly) + # Calcul des features et facteurs Fama-French (région dynamique) + df_monthly = add_all_features(df_monthly, {"ff_region": self.ff_region}) df_monthly = self._apply_lags(df_monthly) n_features = df_monthly.shape[1] n_obs = len(df_monthly) logger.info(f"Processing terminé. Monthly shape : ({n_obs}, {n_features})") - return df, df_monthly, alerts + return df, df_monthly, alerts \ No newline at end of file diff --git a/src/utils/config_loader.py b/src/utils/config_loader.py index a2318be..943c7b5 100644 --- a/src/utils/config_loader.py +++ b/src/utils/config_loader.py @@ -2,6 +2,8 @@ from pathlib import Path from typing import Dict, Any from src.utils.logger import setup_logger +import pandas as pd + logger = setup_logger("ConfigLoader") @@ -17,4 +19,48 @@ def load_market_config(config_path: Path) -> Dict[str, Any]: return json.load(f) except json.JSONDecodeError as e: logger.error(f"Erreur JSON dans {config_path}: {e}") - return {} \ No newline at end of file + return {} + + +def get_ticker_names(market: str, base_dir: Path) -> dict: + """ + Charge le mapping ticker -> nom complet en scannant config/markets/*.json + et en matchant sur le champ interne "market_name" (insensible à la casse), + plutôt que sur le nom de fichier — évite les soucis d'incohérence de + nommage (ex: NASDAQ100 vs nasdaq.json). + Retourne un dict vide si aucun fichier ne correspond ou si la clé + ticker_names est absente (fallback silencieux : le ticker brut sera + affiché à la place). + """ + markets_dir = base_dir / "config" / "markets" + if not markets_dir.exists(): + logger.warning(f"Dossier de config introuvable : {markets_dir}") + return {} + + for config_path in markets_dir.glob("*.json"): + try: + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + if config.get("market_name", "").strip().upper() == market.strip().upper(): + return config.get("ticker_names", {}) + except Exception as e: + logger.warning(f"Lecture échouée pour {config_path} : {e}") + continue + + logger.warning(f"Aucune config trouvée pour le marché '{market}'") + return {} + +def apply_ticker_names(df: pd.DataFrame, ticker_names: dict, ticker_col: str = "Ticker", name_col: str = "Name") -> pd.DataFrame: + """ + Ajoute une colonne `name_col` juste après `ticker_col` avec le nom complet + de chaque ticker (fallback sur le ticker brut si absent du mapping). + """ + if df.empty or ticker_col not in df.columns: + return df + df = df.copy() + df[name_col] = df[ticker_col].map(ticker_names).fillna(df[ticker_col]) + cols = df.columns.tolist() + cols.remove(name_col) + insert_at = cols.index(ticker_col) + 1 + cols.insert(insert_at, name_col) + return df[cols] \ No newline at end of file diff --git a/src/utils/market_utils.py b/src/utils/market_utils.py index 11e4fc3..004eec6 100644 --- a/src/utils/market_utils.py +++ b/src/utils/market_utils.py @@ -1,3 +1,4 @@ + """ Market Utils ============= @@ -7,7 +8,7 @@ - get_benchmark_returns() : télécharge et reindex les rendements du benchmark - build_export_df() : formate le snapshot journalier pour l'export HF """ - +from pathlib import Path import time import pandas as pd import yfinance as yf @@ -202,3 +203,123 @@ def build_export_df( export = export.sort_values("Proba_Hausse (%)", ascending=False).reset_index(drop=True) return export + +# ══════════════════════════════════════════════════════════════════ +# DONNÉES TEMPS RÉEL (Data Explorer) +# ══════════════════════════════════════════════════════════════════ + + +def get_live_ticker_data(ticker: str, period: str = "1y") -> pd.DataFrame: + """ + Télécharge l'historique OHLCV d'un ticker via yfinance, avec retry + et normalisation des colonnes (lowercase, gestion MultiIndex, + fallback adj close -> close). + """ + for attempt in range(1, 4): + try: + df = yf.download(ticker, period=period, progress=False, timeout=10) + if not df.empty: + df.columns = ( + df.columns.get_level_values(0) + if isinstance(df.columns, pd.MultiIndex) + else df.columns + ) + df.columns = df.columns.str.lower() + if "adj close" not in df.columns and "close" in df.columns: + df["adj close"] = df["close"] + return df + logger.warning(f"Réponse vide pour {ticker} (tentative {attempt}/3)") + time.sleep(2) + except Exception as e: + logger.warning(f"Erreur téléchargement {ticker} (tentative {attempt}/3) : {e}") + time.sleep(2) + + logger.error(f"Impossible de charger {ticker} après 3 tentatives.") + return pd.DataFrame() + + +# ══════════════════════════════════════════════════════════════════ +# DÉCOUVERTE DES MARCHÉS DISPONIBLES +# ══════════════════════════════════════════════════════════════════ + +def discover_markets( + repo_id: str, + token: str = None, + local_dir: Path = None, + fallback: list = None, +) -> list: + """ + Découvre les marchés disponibles en interrogeant le repo HF distant + (dataset repo_id, prefixe data//). Fallback sur un scan local + (local_dir) si l'API HF échoue, puis sur `fallback` en dernier recours. + """ + try: + from huggingface_hub import HfApi + api = HfApi() + files = api.list_repo_files(repo_id=repo_id, repo_type="dataset", token=token) + markets = sorted({ + f.split("/")[1] for f in files + if f.startswith("data/") and len(f.split("/")) > 2 + }) + if markets: + return markets + except Exception as e: + logger.warning(f"Découverte HF échouée pour {repo_id} : {e}") + + if local_dir and local_dir.exists(): + found = sorted([p.name for p in local_dir.iterdir() if p.is_dir()]) + if found: + return found + + return fallback or ["CAC40", "BRVM"] + + +# ══════════════════════════════════════════════════════════════════ +# DEVISE PAR TICKER (suffixe yfinance -> devise / symbole) +# ══════════════════════════════════════════════════════════════════ +_SUFFIX_CURRENCY_MAP = { + "": ("USD", "$"), # pas de suffixe = US (AAPL, TSLA...) + ".PA": ("EUR", "€"), # Paris + ".DE": ("EUR", "€"), # Francfort + ".AS": ("EUR", "€"), # Amsterdam + ".MI": ("EUR", "€"), # Milan + ".KS": ("KRW", "₩"), # Corée (KOSPI) + ".KQ": ("KRW", "₩"), # Corée (KOSDAQ) + ".HK": ("HKD", "HK$"), # Hong Kong + ".SS": ("CNY", "¥"), # Shanghai + ".SZ": ("CNY", "¥"), # Shenzhen + ".NS": ("INR", "₹"), # Inde (NSE) + ".BO": ("INR", "₹"), # Inde (BSE) + ".SA": ("BRL", "R$"), # Brésil + ".IS": ("TRY", "₺"), # Turquie + ".JO": ("ZAR", "R"), # Afrique du Sud + ".MX": ("MXN", "MX$"), # Mexique + ".TW": ("TWD", "NT$"), # Taïwan + ".TWO": ("TWD", "NT$"), # Taïwan (OTC) + ".KL": ("MYR", "RM"), # Malaisie + ".BK": ("THB", "฿"), # Thaïlande +} + + +def get_ticker_currency(ticker: str, default: tuple = ("EUR", "€")) -> tuple: + """ + Déduit la devise d'un ticker à partir de son suffixe yfinance. + + Parameters + ---------- + ticker : str — ex: "AI.PA", "005930.KS", "AAPL" + default : tuple — (code, symbole) utilisé si le suffixe est inconnu + + Returns + ------- + tuple — (code_devise, symbole) ex: ("EUR", "€") + """ + ticker = str(ticker).strip() + if "." in ticker: + suffix = "." + ticker.split(".")[-1] + if suffix in _SUFFIX_CURRENCY_MAP: + return _SUFFIX_CURRENCY_MAP[suffix] + logger.warning(f"Suffixe inconnu pour {ticker} ({suffix}), devise par defaut utilisée") + return default + return _SUFFIX_CURRENCY_MAP[""] + diff --git a/src/utils/math_utils.py b/src/utils/math_utils.py index 724e06a..0a1892a 100644 --- a/src/utils/math_utils.py +++ b/src/utils/math_utils.py @@ -19,4 +19,23 @@ 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) \ No newline at end of file + return returns.rolling(window, min_periods=window // 2).apply(_mdd, raw=True) + + +def trim_flat_start(df: pd.DataFrame, tol: float = 1e-6) -> pd.DataFrame: + """ + Supprime la période initiale "plate" (placeholder constant) présente + au début de certains historiques, avant le premier rebalancement réel. + Renvoie l'historique complet si aucun plateau n'est détecté. + """ + if df.empty or "Strategy" not in df.columns or len(df) < 3: + return df + changes = df["Strategy"].diff().abs() > tol + if "Benchmark" in df.columns: + changes = changes | (df["Benchmark"].diff().abs() > tol) + first_move = changes[changes].index + if len(first_move) == 0: + return df + start_idx = df.index.get_loc(first_move[0]) + start_idx = max(0, start_idx - 1) + return df.iloc[start_idx:] \ No newline at end of file diff --git a/src/utils/metrics.py b/src/utils/metrics.py index b997688..adee8e3 100644 --- a/src/utils/metrics.py +++ b/src/utils/metrics.py @@ -1,22 +1,131 @@ +""" +Metrics Utils +============= +Métriques financières — évaluation modèle (walk-forward) et suivi +de performance du portefeuille en production (dashboard). + +Fonctions : + - calculate_financial_metrics() : métriques d'évaluation ML (sharpe/dd/return sur probas) + - calculate_metrics() : métriques portefeuille prod (Strategy vs Benchmark) + - calculate_period_return() : rendement sur une fenêtre donnée (YTD, 1M, etc.) + - trim_flat_start() : nettoie le plateau initial d'un historique +""" + import numpy as np import pandas as pd +# ══════════════════════════════════════════════════════════════════ +# ÉVALUATION MODÈLE (walk-forward / backtest) +# ══════════════════════════════════════════════════════════════════ + def calculate_financial_metrics(df_test: pd.DataFrame, probas: np.ndarray, threshold: float = 0.5) -> dict: + """ + Calcule sharpe / max_drawdown / total_return à partir des probabilités + prédites par le modèle sur un jeu de test (index multi = ticker/date). + """ signals = (probas > threshold).astype(int) strategy_returns = signals * df_test["future_return"] 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} + annualization_factor = np.sqrt(12) sharpe_ratio = (portfolio_returns.mean() / portfolio_returns.std()) * annualization_factor + cumulative_returns = (1 + portfolio_returns).cumprod() rolling_max = cumulative_returns.cummax() drawdown = (cumulative_returns - rolling_max) / rolling_max max_drawdown = drawdown.min() total_return = cumulative_returns.iloc[-1] - 1 if not cumulative_returns.empty else 0.0 + return { "sharpe": round(sharpe_ratio, 4), "max_drawdown": round(max_drawdown, 4), - "total_return": round(total_return, 4) + "total_return": round(total_return, 4), } + + +# ══════════════════════════════════════════════════════════════════ +# SUIVI PORTEFEUILLE PROD (dashboard) +# ══════════════════════════════════════════════════════════════════ + +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. + + Returns + ------- + tuple — (total_return, alpha, sharpe, max_dd, recovery_days) + """ + if df.empty or len(df) < 2: + return 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 + ) + + cum_ret = (1 + strategy_returns).cumprod() + running_max = cum_ret.cummax() + dd_series = (cum_ret - running_max) / running_max + max_dd = dd_series.min() + recovery_time = _compute_recovery_time(dd_series) + + return total_ret, alpha, sharpe, max_dd, recovery_time + except Exception: + return 0, 0, 0, 0, 0 + + +def _compute_recovery_time(dd_series: pd.Series) -> int: + """ + Nombre de jours écoulés entre le point bas du dernier drawdown + significatif et le retour au plus haut (0). Si pas encore récupéré, + retourne le nombre de jours depuis le point bas jusqu'à aujourd'hui. + """ + if dd_series.empty: + return 0 + trough_idx = dd_series.idxmin() + post_trough = dd_series.loc[trough_idx:] + recovered = post_trough[post_trough >= -0.0001] + if len(recovered) > 1: + recovery_date = recovered.index[1] + return (recovery_date - trough_idx).days + return (dd_series.index[-1] - trough_idx).days + + +def calculate_period_return(df: pd.DataFrame, days: int = None, ytd: bool = False, daily: bool = False) -> float: + """ + Rendement de la stratégie sur une fenêtre donnée (YTD, N derniers jours, + variation journalière, ou depuis le début si aucun paramètre n'est fourni). + """ + if df.empty or "Strategy" not in df.columns or len(df) < 2: + return 0.0 + try: + if daily: + return (df["Strategy"].iloc[-1] / df["Strategy"].iloc[-2]) - 1 + + last_price, last_date = df["Strategy"].iloc[-1], df.index[-1] + + if ytd: + target_date = pd.Timestamp(last_date.year, 1, 1) + elif days: + target_date = last_date - pd.Timedelta(days=days) + else: + target_date = df.index[0] + + if target_date < df.index[0]: + start_price = df["Strategy"].iloc[0] + else: + start_price = df["Strategy"].iloc[df.index.get_indexer([target_date], method="nearest")[0]] + + return ((last_price / start_price) - 1) if start_price != 0 else 0.0 + except Exception: + return 0.0 diff --git a/src/utils/mlflow_utils.py b/src/utils/mlflow_utils.py new file mode 100644 index 0000000..bb923f1 --- /dev/null +++ b/src/utils/mlflow_utils.py @@ -0,0 +1,100 @@ +""" +MLflow Utils +============= +Récupération des métriques du modèle "champion" pour un marché donné. + +Stratégie en cascade (alignée sur train.py) : + 1. MLflow Model Registry via l'alias "champion" + 2. Fallback sur le model_card.json sauvegardé localement par train.py + +Fonctions : + - get_champion_metrics() : renvoie {source, metrics, version, run_id, promoted, error} +""" + +import json +from pathlib import Path + +import mlflow +from mlflow.tracking import MlflowClient +from mlflow.exceptions import MlflowException + +from src.utils.logger import setup_logger + +logger = setup_logger("mlflow_utils") + + +def get_champion_metrics( + market: str, + model_dir: Path, + mlflow_enabled: bool = False, + model_name_prefix: str = "AlphaEdge_Ensemble", +) -> dict: + """ + Récupère les métriques du modèle "champion" pour un marché donné. + + 1) Essaie MLflow via l'alias 'champion' (si mlflow_enabled=True et + mlflow.set_tracking_uri déjà configuré par l'appelant). + 2) Si MLflow est indisponible ou qu'aucun alias 'champion' n'existe + encore, retombe sur model_dir//model_card.json. + + Parameters + ---------- + market : str — marché ciblé (ex: "CAC40") + model_dir : Path — répertoire racine des model_card.json locaux + mlflow_enabled : bool — active la tentative MLflow (nécessite un token) + model_name_prefix : str — préfixe du nom du modèle enregistré + + Returns + ------- + dict — {source, metrics, version, run_id, promoted, error} + """ + result = { + "source": None, + "metrics": {}, + "version": None, + "run_id": None, + "promoted": None, + "error": None, + } + registered_model_name = f"{model_name_prefix}_{market}" + + if mlflow_enabled: + try: + client = MlflowClient() + mv = client.get_model_version_by_alias(registered_model_name, "champion") + run = client.get_run(mv.run_id) + result["source"] = "mlflow" + result["metrics"] = run.data.metrics + result["version"] = mv.version + result["run_id"] = mv.run_id + result["promoted"] = True + logger.info(f"Champion MLflow trouvé pour {market} : v{mv.version}") + return result + except MlflowException as e: + result["error"] = f"MLflow: {e}" + logger.warning(f"Alias 'champion' introuvable pour {registered_model_name} : {e}") + except Exception as e: + result["error"] = f"MLflow: {e}" + logger.warning(f"Erreur MLflow pour {registered_model_name} : {e}") + + card_path = model_dir / market / "model_card.json" + if card_path.exists(): + try: + with open(card_path, "r") as f: + card = json.load(f) + result["source"] = "local" + metrics = {} + for k, v in card.get("metrics_ml", {}).items(): + metrics[f"ml_{k}"] = v + for k, v in card.get("metrics_fin", {}).items(): + metrics[f"fin_{k}"] = v + result["metrics"] = metrics + result["promoted"] = card.get("mlflow", {}).get("promoted", False) + result["run_id"] = card.get("mlflow", {}).get("run_id") + except Exception as e: + result["error"] = (result["error"] + " | " if result["error"] else "") + f"model_card.json: {e}" + logger.error(f"Lecture model_card.json échouée pour {market} : {e}") + elif result["source"] is None: + logger.warning(f"Aucun model_card.json trouvé pour {market} ({card_path})") + + return result \ No newline at end of file diff --git a/src/utils/ui_utils.py b/src/utils/ui_utils.py new file mode 100644 index 0000000..7c69d60 --- /dev/null +++ b/src/utils/ui_utils.py @@ -0,0 +1,110 @@ +""" +UI Utils +========= +Composants d'affichage réutilisables pour le dashboard Streamlit. + +Fonctions : + - display_kpi_card() : carte KPI stylisée (valeur, delta coloré, prefix/suffix) +""" + +import numpy as np +import pandas as pd +import streamlit as st + + +def display_kpi_card( + label: str, + value, + is_percent: bool = True, + color_code: bool = False, + prefix: str = "", + suffix: str = "", + minimal: bool = False, +): + """ + Affiche une carte KPI stylisée. + + Parameters + ---------- + label : str — libellé affiché au-dessus de la valeur + value : float | int — valeur à afficher (N/A si NaN/inf) + is_percent : bool — formate en pourcentage (%.1%) + color_code : bool — colore + ajoute une flèche selon le signe + prefix / suffix : str — texte ajouté avant/après la valeur + minimal : bool — variante sans le cadre "container" (fond + bordure) + """ + if pd.isna(value) or np.isinf(value): + html_val = 'N/A' + else: + if is_percent: + formatted_val = f"{prefix}{value:.1%}{suffix}" + elif isinstance(value, (int, np.integer)) or suffix: + formatted_val = f"{prefix}{int(value)}{suffix}" + else: + formatted_val = f"{prefix}{value:.2f}{suffix}" + + if color_code: + color_class = "kpi-delta-pos" if value >= 0 else "kpi-delta-neg" + arrow = "▲" if value >= 0 else "▼" + html_val = f'{arrow} {formatted_val}' + else: + html_val = f'{formatted_val}' + + css_class = "kpi-minimal" if minimal else "kpi-container" + st.markdown(f""" +
+
{label}
+ {html_val} +
+ """, unsafe_allow_html=True) + + +def load_css(): + """ + Injecte le style CSS custom du dashboard (cartes KPI, disclaimer, + badges MLflow). À appeler une seule fois, juste après les imports. + """ + st.markdown(""" + + """, unsafe_allow_html=True) \ No newline at end of file diff --git a/src/extract/yfinance_downloader_test.py b/tests/yfinance_downloader_test.py similarity index 100% rename from src/extract/yfinance_downloader_test.py rename to tests/yfinance_downloader_test.py