Spaces:
Running
Running
Download src/modules/jasmine_optimizer.py from klydekushy/Vortex-Flux: direct link, hf CLI and curl.
- Browser
- Download file 29.8 kB
-
https://huggingface.co/spaces/klydekushy/Vortex-Flux/resolve/main/src/modules/jasmine_optimizer.py
- Command line
-
hf download hf://spaces/klydekushy/Vortex-Flux/src/modules/jasmine_optimizer.py
-
curl -L -o jasmine_optimizer.py https://huggingface.co/spaces/klydekushy/Vortex-Flux/resolve/main/src/modules/jasmine_optimizer.py
29.8 kB
| import streamlit as st | |
| import pandas as pd | |
| from datetime import datetime | |
| import os | |
| import json | |
| import sys | |
| sys.path.append(os.path.join(os.path.dirname(__file__), '..', 'Analytics')) | |
| try: | |
| from ModelML import build_and_train_xgb, predire_taux_xgboost | |
| ml_disponible = True | |
| except ImportError: | |
| ml_disponible = False | |
| from vortex_params import ( | |
| get_objectif_annuel, | |
| set_objectif_annuel, | |
| get_regles_actuelles_depuis_df, | |
| MARGE_OPTIMIZER_VARIABLE, | |
| ) | |
| from core.llm_client import call_llm, LLMUnavailableError | |
| from core import exercice as ex | |
| from core.ui_guards import rendre_bouton_protege, liberer | |
| from jasmine_widget import render_scoped_widget | |
| # ============================================================================ | |
| # CSS — STYLE TACTIQUE / FINANCE | |
| # ============================================================================ | |
| def apply_optimizer_styles(): | |
| st.markdown(""" | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700;800&display=swap'); | |
| .opt-kpi-container { display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin-bottom: 25px; } | |
| .opt-kpi-card { background: rgba(10, 14, 18, 0.9); border: 1px solid rgba(100, 120, 140, 0.3); border-top: 3px solid #58a6ff; padding: 15px; } | |
| .opt-kpi-card.alert { border-top: 3px solid #e74c3c; background: linear-gradient(180deg, rgba(231, 76, 60, 0.1) 0%, rgba(10, 14, 18, 0.9) 100%); } | |
| .opt-kpi-card.success { border-top: 3px solid #2ecc71; } | |
| .opt-kpi-label { font-size: 0.65rem; font-weight: 700; color: #7a8a9a; letter-spacing: 1.5px; text-transform: uppercase; margin-bottom: 5px; } | |
| .opt-kpi-value { font-size: 1.5rem; font-weight: 800; color: #c9d1d9; } | |
| .opt-scenario-container { display: grid; grid-template-columns: repeat(3, 1fr); gap: 15px; margin-top: 20px; } | |
| .opt-scenario-card { background: rgba(15, 20, 26, 0.85); border: 1px solid rgba(100, 120, 140, 0.2); padding: 15px; text-align: center; } | |
| .opt-scenario-card.active { border: 1px solid #f39c12; background: rgba(243, 156, 18, 0.05); box-shadow: 0 0 15px rgba(243, 156, 18, 0.1); } | |
| .opt-scen-title { font-size: 0.8rem; font-weight: 700; color: #a8b8c8; letter-spacing: 2px; text-transform: uppercase; margin-bottom: 10px; } | |
| .opt-scen-active-title { color: #f39c12; font-weight: 800; } | |
| .opt-scen-rate { font-size: 2.2rem; font-weight: 800; color: #58a6ff; margin: 10px 0; } | |
| .opt-scen-rate.active { color: #f39c12; } | |
| .opt-scen-desc { font-size: 0.7rem; color: #7a8a9a; line-height: 1.5; } | |
| .opt-diagnostic-box { background: rgba(10, 14, 18, 0.8); border: 1px dashed #e74c3c; padding: 15px; margin: 20px 0; font-size: 0.85rem; color: #c9d1d9; } | |
| .opt-diagnostic-box h4 { color: #e74c3c; margin-top: 0; font-size: 0.9rem; text-transform: uppercase; letter-spacing: 2px; } | |
| .opt-jasmine-report { background: rgba(10, 14, 18, 0.95); border-left: 4px solid #f39c12; padding: 20px; margin: 25px 0; font-size: 0.85rem; color: #a8b8c8; line-height: 1.7; } | |
| .opt-report-header { font-size: 0.65rem; color: #f39c12; letter-spacing: 3px; margin-bottom: 10px; font-weight: 700; text-transform: uppercase; } | |
| .opt-btn-apply > button { background: rgba(243, 156, 18, 0.1) !important; border: 1px solid #f39c12 !important; color: #f39c12 !important; font-weight: 800 !important; width: 100%; padding: 15px !important; letter-spacing: 3px !important; text-transform: uppercase !important; } | |
| .opt-btn-apply > button:hover { background: rgba(243, 156, 18, 0.25) !important; box-shadow: 0 0 15px rgba(243, 156, 18, 0.3) !important; } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # ============================================================================ | |
| # GESTION CENTRALISEE DU MODELE ML | |
| # ============================================================================ | |
| def get_trained_model_cached(df_m, df_u, df_c, df_g, df_r): | |
| """Fonction globale pour entraîner le modèle et le stocker en mémoire. | |
| Renvoie (modele, dossier_type) ou (None, None) si l'entraînement est impossible.""" | |
| if not ml_disponible: return None, None | |
| return build_and_train_xgb(df_m, df_u, df_c, df_g, df_r) | |
| # ============================================================================ | |
| # LLM - EXPLICABILITE DE LA MARGE | |
| # ============================================================================ | |
| def generer_rapport_optimisation(context): | |
| system_prompt = f""" | |
| ROLE: Système d'Analyse Financière pour VORTEX. | |
| MISSION: Rédiger un rapport analytique ultra-concis (3-4 phrases) pour l'administrateur. | |
| CONTEXTE MATHEMATIQUE (ANALYSE STRUCTURELLE): | |
| - Objectif annuel : {context['objectif']:,.0f} XOF | |
| - Bénéfice Projeté Final avec marge plafonnée : {context['benefice_projet_final']:,.0f} XOF | |
| - Marge Brute réellement nécessaire (Mathématique) : {context['marge_brute_requise']}% | |
| - Marge Appliquée (Plafond sécurité) : +{context['taux_recommande']}% | |
| ANALYSE EXIGEE: | |
| Explique la réalité mathématique de manière neutre : si la marge brute nécessaire dépasse {context['plafond_marge']:.2f}%, l'objectif final ne sera pas atteint. | |
| Alerte formellement qu'augmenter les prix ne suffit plus à cause du plafond. Recommande une stratégie d'acquisition client (augmenter le volume de prêts) pour combler ce déficit structurel. | |
| FORMAT JSON STRICT: | |
| {{ "rapport": "Ton texte ici" }} | |
| """ | |
| try: | |
| texte = call_llm( | |
| [{"role": "system", "content": system_prompt}, {"role": "user", "content": "Génère le rapport JSON."}], | |
| json_mode=True, temperature=0.2, | |
| ) | |
| return json.loads(texte).get("rapport", "Erreur de génération.") | |
| except (LLMUnavailableError, json.JSONDecodeError): | |
| return f"Analyse algorithmique : Marge requise de {context['marge_brute_requise']}%, limitée au plafond de {context['plafond_marge']:.2f}%. L'objectif ne pourra être atteint sans une hausse du volume de clients." | |
| # ============================================================================ | |
| # FONCTION DE JOURNALISATION DANS GOOGLE SHEETS | |
| # ============================================================================ | |
| def sauvegarder_marge_dans_sheets(client, sheet_name, nouvelle_marge, raison="Ajustement des parametres"): | |
| sh = client.open(sheet_name) | |
| try: | |
| ws = sh.worksheet("Parametres_Vortex") | |
| except Exception: | |
| ws = sh.add_worksheet("Parametres_Vortex", 100, 4) | |
| ws.append_row(["Timestamp", "Variable", "Marge_Globale", "Raison"]) | |
| date_now = datetime.now().strftime("%d/%m/%Y %H:%M:%S") | |
| ws.append_row([date_now, "Marge_Globale_Optimizer", str(nouvelle_marge), raison]) | |
| return True | |
| # ============================================================================ | |
| # UTILITAIRE DE NETTOYAGE NUMERIQUE ROBUSTE | |
| # ============================================================================ | |
| def safe_to_numeric(series, default_val=0.0): | |
| return pd.to_numeric( | |
| series.astype(str) | |
| .str.replace('XOF', '', regex=False) | |
| .str.replace('FCFA', '', regex=False) | |
| .str.replace(' ', '', regex=False) | |
| .str.replace(',', '.', regex=False), | |
| errors='coerce' | |
| ).fillna(default_val) | |
| def clamper_marge_optimizer(marge_brute, plafond_marge): | |
| return max(0.0, min(float(plafond_marge), float(marge_brute))) | |
| # ============================================================================ | |
| # MODULE PRINCIPAL | |
| # ============================================================================ | |
| def show_optimizer_module(gspread_client, sheet_name): | |
| apply_optimizer_styles() | |
| st.markdown('<div id="optimizer-module">', unsafe_allow_html=True) | |
| st.markdown("<h1>OPTIMISATEUR // CONTROLE DES MARGES</h1>", unsafe_allow_html=True) | |
| # 1. PARAMETRES DE BASE | |
| # Exercice choisi dans la barre laterale (Toutes annees -> exercice courant : | |
| # un objectif et une marge se pilotent toujours sur UNE annee). | |
| ANNEE_ACTUELLE = ex.exercice_selectionne() or ex.exercice_courant() | |
| STATUT_EXERCICE = ex.statut_exercice(ANNEE_ACTUELLE) | |
| OBJECTIF_ANNUEL = get_objectif_annuel(gspread_client, sheet_name, annee=ANNEE_ACTUELLE) | |
| MOIS_ECOULES_REELS = ex.mois_ecoules(ANNEE_ACTUELLE) | |
| MOIS_ECOULES = max(1, MOIS_ECOULES_REELS) | |
| MOIS_RESTANTS = 12 - MOIS_ECOULES_REELS if STATUT_EXERCICE != "EN_COURS" else max(1, 12 - MOIS_ECOULES_REELS) | |
| if STATUT_EXERCICE == "CLOTURE": | |
| st.info(f"Exercice {ANNEE_ACTUELLE} clôturé : bilan uniquement, aucune marge à recommander.") | |
| elif STATUT_EXERCICE == "A_VENIR": | |
| st.info(f"Exercice {ANNEE_ACTUELLE} à venir : projection à partir du rythme de l'exercice {ANNEE_ACTUELLE - 1} " | |
| f"et des échéances {ANNEE_ACTUELLE} des prêts déjà accordés.") | |
| # 1bis. OBJECTIF ANNUEL EDITABLE | |
| with st.expander(f"OBJECTIF ANNUEL {ANNEE_ACTUELLE} — MODIFIER", expanded=False): | |
| col_obj1, col_obj2 = st.columns([3, 1]) | |
| with col_obj1: | |
| nouvel_objectif = st.number_input( | |
| "Objectif de profit annuel (XOF)", min_value=0.0, | |
| value=float(OBJECTIF_ANNUEL), step=50000.0, format="%.0f", | |
| ) | |
| with col_obj2: | |
| st.markdown("<div style='height: 28px;'></div>", unsafe_allow_html=True) | |
| if rendre_bouton_protege("ENREGISTRER L'OBJECTIF", cle="obj_annuel", use_container_width=True): | |
| if set_objectif_annuel(gspread_client, sheet_name, nouvel_objectif, annee=ANNEE_ACTUELLE): | |
| liberer("obj_annuel") | |
| st.success("Objectif enregistre.") | |
| st.rerun() | |
| else: | |
| liberer("obj_annuel") | |
| st.error("Echec de l'enregistrement de l'objectif.") | |
| # 2. COLLECTE DES DONNEES | |
| try: | |
| sh = gspread_client.open(sheet_name) | |
| df_prets = pd.DataFrame(sh.worksheet("Prets_Master").get_all_records()) | |
| try: | |
| df_remb = pd.DataFrame(sh.worksheet("Remboursements").get_all_records()) | |
| except Exception as e: | |
| from core.errors import log_swallowed | |
| log_swallowed("Lecture Remboursements (benefice encaisse de l'Optimizer) echouee", e) | |
| df_remb = pd.DataFrame() | |
| try: | |
| ws_param = sh.worksheet("Parametres_Vortex") | |
| df_param = pd.DataFrame(ws_param.get_all_records()) | |
| regles_optimizer = get_regles_actuelles_depuis_df(df_param) | |
| PLAFOND_MARGE = regles_optimizer["Regle_Plafond_Marge_Optimizer"] | |
| lignes_marge = df_param[ | |
| df_param.get("Variable", pd.Series(dtype=str)) == MARGE_OPTIMIZER_VARIABLE | |
| ].copy() | |
| if lignes_marge.empty or "Marge_Globale" not in lignes_marge.columns: | |
| marge_actuelle = 0.0 | |
| else: | |
| if "Timestamp" in lignes_marge.columns: | |
| lignes_marge["Timestamp_Dt"] = pd.to_datetime( | |
| lignes_marge["Timestamp"], | |
| format="%d/%m/%Y %H:%M:%S", | |
| errors="coerce", | |
| ) | |
| lignes_marge = lignes_marge.sort_values( | |
| "Timestamp_Dt", na_position="first" | |
| ) | |
| marge_actuelle = float(lignes_marge["Marge_Globale"].iloc[-1]) | |
| except Exception as e: | |
| from core.errors import log_swallowed | |
| log_swallowed("Lecture de la marge strategique (Parametres_Vortex) echouee, marge traitee comme 0", e) | |
| marge_actuelle = 0.0 | |
| st.warning("[ATTENTION] Impossible de lire la marge stratégique enregistrée — affichée comme 0% ci-dessous, à vérifier avant tout ajustement.") | |
| except Exception as e: | |
| st.error(f"Erreur de connexion à la base de données : {e}") | |
| return | |
| if df_prets.empty or 'Cout_Credit' not in df_prets.columns: | |
| st.warning("Données de prêts insuffisantes pour lancer l'optimisation.") | |
| return | |
| # Tendance suggérée par le modèle XGBoost (si un entraînement a déjà été effectué | |
| # cette session via RETRAIN XGBOOST) — comparaison optionnelle, désactivée par défaut. | |
| ml_taux_predit = st.session_state.get('ml_taux_predit') | |
| use_ml_trend = False | |
| if ml_taux_predit is not None: | |
| use_ml_trend = st.checkbox( | |
| f"Utiliser la projection XGBoost comme base de tendance (taux suggéré : +{ml_taux_predit:.2f}%/semaine)", | |
| value=False, key="opt_use_ml_trend", | |
| ) | |
| with st.spinner("Analyse des flux et simulation des trajectoires de rentabilité..."): | |
| # 3. CALCULS HISTORIQUES ROBUSTES | |
| df_prets['Cout_Credit_Num'] = safe_to_numeric(df_prets['Cout_Credit']) | |
| df_prets['Montant_Capital_Num'] = safe_to_numeric(df_prets['Montant_Capital'], default_val=50000.0) | |
| df_prets['Duree_Semaines_Num'] = safe_to_numeric(df_prets['Duree_Semaines'], default_val=4.0) | |
| # Regle A (voir core/exercice.py) : chaque flux dans l'exercice de SA date. | |
| # Base du benefice choisie dans la barre laterale : | |
| # - ENCAISSE : realise = interets + penalites recus dans l'exercice ; | |
| # engage_restant = interets des prets deja accordes qui echoient dans | |
| # le reste de l'exercice (un pret de decembre 2026 apporte ses | |
| # echeances 2027 a l'exercice 2027) ; | |
| # - ACCORDE : realise = cout du credit des prets debloques dans | |
| # l'exercice ; rien a ajouter pour les prets existants (deja comptes | |
| # au deblocage). | |
| # Rythme des nouveaux prets : millesime de l'exercice (ou du precedent | |
| # pour un exercice a venir). | |
| BASE = ex.base_benefice() | |
| BASE_ACCORDEE = ex.est_base_accordee(BASE) | |
| df_prets_annee = ex.filtrer_annee(df_prets, 'Date_Deblocage', ANNEE_ACTUELLE) | |
| realise_ytd = ex.benefice_exercice(df_prets, df_remb, ANNEE_ACTUELLE, BASE) | |
| if BASE_ACCORDEE: | |
| engage_restant = 0.0 | |
| else: | |
| echeancier = ex.echeancier_contractuel(df_prets[df_prets.get('Statut', pd.Series('', index=df_prets.index)) != 'TERMINE']) | |
| debut_reste = max(pd.Timestamp.today().normalize(), ex.bornes(ANNEE_ACTUELLE)[0]) | |
| engage_restant = float(echeancier.loc[(echeancier['Echeance'] >= debut_reste) | |
| & (echeancier['Echeance'] < ex.bornes(ANNEE_ACTUELLE)[1]), 'Interets'].sum()) if not echeancier.empty else 0.0 | |
| if STATUT_EXERCICE == "A_VENIR": | |
| df_rythme = ex.filtrer_annee(df_prets, 'Date_Deblocage', ANNEE_ACTUELLE - 1) | |
| mois_rythme = 12 | |
| else: | |
| df_rythme, mois_rythme = df_prets_annee, MOIS_ECOULES | |
| if df_rythme.empty and STATUT_EXERCICE == "EN_COURS" and MOIS_ECOULES_REELS == 0: | |
| # Janvier : aucun mois ecoule, on prend le rythme de l'exercice precedent. | |
| df_rythme, mois_rythme = ex.filtrer_annee(df_prets, 'Date_Deblocage', ANNEE_ACTUELLE - 1), 12 | |
| nb_prets_total = len(df_prets_annee) | |
| avg_prets_par_mois = len(df_rythme) / mois_rythme | |
| median_capital = df_prets['Montant_Capital_Num'].median() | |
| median_duree = df_prets['Duree_Semaines_Num'].median() | |
| if median_capital == 0: median_capital = 50000.0 | |
| if median_duree == 0: median_duree = 4.0 | |
| # 4. LE SOLVEUR MATHEMATHIQUE | |
| # Benefice moyen apporte chaque mois par les nouveaux prets (cout du credit du rythme retenu). | |
| tendance_naturelle_mensuelle = float(df_rythme['Cout_Credit_Num'].sum()) / mois_rythme if 'Cout_Credit_Num' in df_rythme.columns else 0.0 | |
| tendance_ml_mensuelle = None | |
| if ml_taux_predit is not None: | |
| tendance_ml_mensuelle = avg_prets_par_mois * median_capital * median_duree * (ml_taux_predit / 100) | |
| tendance_utilisee = ( | |
| tendance_ml_mensuelle if (use_ml_trend and tendance_ml_mensuelle is not None) | |
| else tendance_naturelle_mensuelle | |
| ) | |
| projection_base_restante = engage_restant + tendance_utilisee * MOIS_RESTANTS | |
| gap_a_combler = OBJECTIF_ANNUEL - (realise_ytd + projection_base_restante) | |
| def calculer_marge_requise(multiplicateur_volume): | |
| if gap_a_combler <= 0: return 0.0, 0.0, 0 | |
| volume_futur = avg_prets_par_mois * MOIS_RESTANTS * multiplicateur_volume | |
| if volume_futur == 0: return PLAFOND_MARGE, PLAFOND_MARGE, 0 | |
| marge_brute = (gap_a_combler / (volume_futur * median_capital * median_duree)) * 100 | |
| marge_clamp = clamper_marge_optimizer(marge_brute, PLAFOND_MARGE) | |
| return marge_brute, marge_clamp, volume_futur | |
| # Scénarios (marge_brute, marge_clampee, volume) | |
| mb_pes, taux_pessimiste, vol_pes = calculer_marge_requise(0.8) | |
| mb_real, taux_realiste, vol_real = calculer_marge_requise(1.0) | |
| mb_opt, taux_optimiste, vol_opt = calculer_marge_requise(1.2) | |
| # Calcul du bénéfice final réellement projeté avec le taux plafonné | |
| benefice_additionnel_genere = (taux_realiste / 100) * vol_real * median_capital * median_duree | |
| benefice_projet_final = realise_ytd + projection_base_restante + benefice_additionnel_genere | |
| # 5. AFFICHAGE DES 4 KPIs | |
| gap_brut_total = OBJECTIF_ANNUEL - realise_ytd | |
| if tendance_ml_mensuelle is not None: | |
| st.caption( | |
| f"Tendance historique : {tendance_naturelle_mensuelle:,.0f} XOF/mois — " | |
| f"Tendance suggérée par XGBoost : {tendance_ml_mensuelle:,.0f} XOF/mois" | |
| + (" (utilisée dans le calcul ci-dessous)" if use_ml_trend else "") | |
| ) | |
| st.markdown('<div class="opt-kpi-container">', unsafe_allow_html=True) | |
| st.markdown(f""" | |
| <div class="opt-kpi-card success"> | |
| <div class="opt-kpi-label">Bénéfice {ex.mot_base(BASE).lower()} {ANNEE_ACTUELLE}</div> | |
| <div class="opt-kpi-value">{int(realise_ytd):,} XOF</div> | |
| </div> | |
| <div class="opt-kpi-card"> | |
| <div class="opt-kpi-label">Objectif {ANNEE_ACTUELLE}</div> | |
| <div class="opt-kpi-value">{int(OBJECTIF_ANNUEL):,} XOF</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| alert_class = "alert" if gap_brut_total > 0 else "success" | |
| st.markdown(f""" | |
| <div class="opt-kpi-card {alert_class}"> | |
| <div class="opt-kpi-label">Reste à générer (GAP)</div> | |
| <div class="opt-kpi-value">{int(max(0, gap_brut_total)):,} XOF</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # LE NOUVEAU KPI DE PROJECTION FINALE | |
| alert_proj = "alert" if benefice_projet_final < OBJECTIF_ANNUEL else "success" | |
| st.markdown(f""" | |
| <div class="opt-kpi-card {alert_proj}"> | |
| <div class="opt-kpi-label">Projeté fin {ANNEE_ACTUELLE} (+{taux_realiste:.2f}%)</div> | |
| <div class="opt-kpi-value">{int(benefice_projet_final):,} XOF</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| # DIAGNOSTIC STRUCTUREL (La vérité mathématique) | |
| if mb_real > PLAFOND_MARGE: | |
| st.markdown(f""" | |
| <div class="opt-diagnostic-box"> | |
| <h4>ALERTE STRUCTURELLE : LE PLAFOND EST ATTEINT</h4> | |
| La marge brute mathématiquement requise pour atteindre {int(OBJECTIF_ANNUEL):,} XOF avec les volumes actuels est de <b>{mb_real:.2f}%</b>.<br> | |
| Le système l'a clampée au plafond de sécurité de <b>+{PLAFOND_MARGE:.2f}%</b> pour rester compétitif.<br><br> | |
| <b>La réalité :</b> | |
| gap estimé = {int(gap_a_combler):,} XOF / (Volume futur {int(vol_real)} prêts × {int(median_capital):,} XOF × {int(median_duree)} sem) × 100 ≈ {mb_real:.2f}%<br><br> | |
| <i>Conclusion : Augmenter les taux ne suffit plus. Il faut impérativement augmenter le volume d'acquisition client.</i> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # 6. AFFICHAGE DES SCENARIOS | |
| st.markdown(f"### SIMULATION DES TRAJECTOIRES DE MARGE (PLAFOND: {PLAFOND_MARGE:.2f}%)") | |
| st.markdown('<div class="opt-scenario-container">', unsafe_allow_html=True) | |
| st.markdown(f""" | |
| <div class="opt-scenario-card"> | |
| <div class="opt-scen-title">Scénario Pessimiste</div> | |
| <div class="opt-scen-desc">Baisse de 20% des volumes.</div> | |
| <div class="opt-scen-rate">+{taux_pessimiste:.2f}%</div> | |
| <div class="opt-scen-desc">Marge requise réelle: {mb_pes:.2f}%</div> | |
| </div> | |
| <div class="opt-scenario-card active"> | |
| <div class="opt-scen-title opt-scen-active-title">Scénario Réaliste</div> | |
| <div class="opt-scen-desc">Maintien de la tendance ({avg_prets_par_mois:.1f} prêts/mois).</div> | |
| <div class="opt-scen-rate active">+{taux_realiste:.2f}%</div> | |
| <div class="opt-scen-desc">Marge requise réelle: {mb_real:.2f}%</div> | |
| </div> | |
| <div class="opt-scenario-card"> | |
| <div class="opt-scen-title">Scénario Optimiste</div> | |
| <div class="opt-scen-desc">Hausse de 20% de la demande au Q4.</div> | |
| <div class="opt-scen-rate">+{taux_optimiste:.2f}%</div> | |
| <div class="opt-scen-desc">Marge requise réelle: {mb_opt:.2f}%</div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| # 7. RAPPORT LLM | |
| context_ia = { | |
| "objectif": OBJECTIF_ANNUEL, | |
| "realise": realise_ytd, | |
| "gap": gap_brut_total, | |
| "taux_recommande": round(taux_realiste, 2), | |
| "marge_brute_requise": round(mb_real, 2), | |
| "plafond_marge": PLAFOND_MARGE, | |
| "benefice_projet_final": benefice_projet_final | |
| } | |
| if ( | |
| 'rapport_opti' not in st.session_state | |
| or st.session_state.get('last_opti_gap') != gap_brut_total | |
| or st.session_state.get('last_opti_plafond_marge') != PLAFOND_MARGE | |
| ): | |
| with st.spinner("Le système génère son rapport..."): | |
| st.session_state.rapport_opti = generer_rapport_optimisation(context_ia) | |
| st.session_state.last_opti_gap = gap_brut_total | |
| st.session_state.last_opti_plafond_marge = PLAFOND_MARGE | |
| st.markdown(f""" | |
| <div class="opt-jasmine-report"> | |
| <div class="opt-report-header">// SYNTHESE DE L'ANALYSE</div> | |
| {st.session_state.rapport_opti} | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # 8. MAINTENANCE XGBOOST (Corrigé : On entraîne, on cache, on force l'actualisation du texte) | |
| st.divider() | |
| col_maint1, col_maint2 = st.columns([3, 1]) | |
| with col_maint1: | |
| st.markdown("<div style='font-size: 0.75rem; color: #7a8a9a;'>Le modèle Gradient Boosting évalue le risque de crédit. Cliquez pour relancer l'apprentissage sur la base des derniers prêts.</div>", unsafe_allow_html=True) | |
| with col_maint2: | |
| if st.button("RETRAIN XGBOOST", use_container_width=True): | |
| if ml_disponible: | |
| with st.spinner("Réentraînement en cours..."): | |
| try: | |
| sh = gspread_client.open(sheet_name) | |
| # 1. On nettoie tout le cache | |
| st.cache_resource.clear() | |
| st.cache_data.clear() | |
| # 2. On supprime les variables de session pour forcer l'actualisation du texte IA | |
| st.session_state.pop('rapport_opti', None) | |
| st.session_state.pop('last_opti_gap', None) | |
| st.session_state.pop('last_opti_plafond_marge', None) | |
| # 3. Récupération des données | |
| df_pm_retrain = pd.DataFrame(sh.worksheet("Prets_Master").get_all_records()) | |
| df_c_retrain = pd.DataFrame(sh.worksheet("Clients_KYC").get_all_records()) | |
| df_g_retrain = pd.DataFrame(sh.worksheet("Garants_KYC").get_all_records()) | |
| try: | |
| df_pu_retrain = pd.DataFrame(sh.worksheet("Prets_Update").get_all_records()) | |
| except Exception: | |
| df_pu_retrain = pd.DataFrame() | |
| try: | |
| df_r_retrain = pd.DataFrame(sh.worksheet("Remboursements").get_all_records()) | |
| except Exception: | |
| df_r_retrain = pd.DataFrame() | |
| # 4. On appelle la fonction cachée globale avec les 5 DataFrames | |
| modele_retrain, dossier_type_retrain = get_trained_model_cached( | |
| df_pm_retrain, df_pu_retrain, df_c_retrain, df_g_retrain, df_r_retrain | |
| ) | |
| # 5. Tendance de sanite suggeree par le modele (comparaison optionnelle, | |
| # voir la case a cocher plus haut) — non utilisee par defaut. | |
| st.session_state.pop('ml_taux_predit', None) | |
| if modele_retrain is not None and dossier_type_retrain is not None: | |
| try: | |
| st.session_state['ml_taux_predit'] = predire_taux_xgboost(modele_retrain, dossier_type_retrain) | |
| except Exception as exc: | |
| from core.errors import log_swallowed | |
| log_swallowed("Prediction du taux suggere ML apres reentrainement echouee", exc) | |
| st.success("Modèle réentraîné et mis en cache avec succès.") | |
| st.rerun() # Indispensable pour forcer le rafraîchissement immédiat de l'interface | |
| except Exception as e: | |
| st.error(f"Erreur : {e}") | |
| # 9. APPLIQUER LA MARGE | |
| st.divider() | |
| st.markdown(f"**Marge uniformisée actuellement en production :** `+{marge_actuelle:.2f}%`") | |
| st.markdown('<div class="opt-btn-apply">', unsafe_allow_html=True) | |
| if rendre_bouton_protege(f"APPLIQUER LA MARGE GLOBALE DE +{taux_realiste:.2f}% SUR TOUS LES NOUVEAUX PRETS", cle="marge_globale"): | |
| try: | |
| with st.spinner("Synchronisation avec le noyau central..."): | |
| if sauvegarder_marge_dans_sheets(gspread_client, sheet_name, round(taux_realiste, 2)): | |
| st.success(f"Marge de +{taux_realiste:.2f}% enregistrée en production.") | |
| finally: | |
| liberer("marge_globale") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| # --- WIDGET JASMINE (SCOPE AUX KPIS DE L'OPTIMIZER) --- | |
| render_scoped_widget( | |
| gspread_client, sheet_name, | |
| scope_label=f"KPIs de l'Optimizer pour l'année {ANNEE_ACTUELLE}", | |
| context_data={ | |
| "exercice_analyse": ANNEE_ACTUELLE, | |
| "statut_exercice": STATUT_EXERCICE, | |
| "base_benefice": BASE, | |
| "objectif_annuel_xof": OBJECTIF_ANNUEL, | |
| "benefice_engage_ytd_xof": round(realise_ytd), | |
| "interets_contractuels_restant_a_echoir_xof": round(engage_restant), | |
| "gap_a_generer_xof": round(max(0, gap_brut_total)), | |
| "marge_realiste_requise_pct": round(taux_realiste, 2), | |
| "marge_actuellement_en_production_pct": marge_actuelle, | |
| "projete_fin_annee_xof": round(benefice_projet_final), | |
| "guide_page": ( | |
| "L'Optimizer répond à : à quel niveau faut-il fixer la marge d'intérêt sur les " | |
| "nouveaux prêts pour atteindre l'objectif de bénéfice annuel, compte tenu du volume " | |
| "déjà réalisé ? Il compare le bénéfice déjà engagé (YTD) à l'objectif, calcule la " | |
| "marge requise pour combler l'écart restant, et projette le bénéfice de fin d'année. " | |
| "C'est un calcul de PROFIT, à distinguer du proxy de trésorerie du Digital Twin. " | |
| "Tout est calcule pour UN exercice (annee civile) : chaque flux compte dans l'annee de sa date." | |
| ), | |
| "definitions_champs": { | |
| "objectif_annuel_xof": "Objectif de bénéfice annuel fixé manuellement par le gérant (modifiable sur cette page).", | |
| "exercice_analyse": "Exercice (annee civile) pilote sur cette page.", | |
| "statut_exercice": "CLOTURE (passe), EN_COURS ou A_VENIR.", | |
| "base_benefice": "Base du benefice choisie par l'utilisateur (barre laterale) : " + ex.definition_base(BASE), | |
| "benefice_engage_ytd_xof": "Benefice de l'exercice depuis le 1er janvier selon base_benefice.", | |
| "interets_contractuels_restant_a_echoir_xof": "Base encaissee uniquement : interets des prets deja accordes dont l'echeance tombe dans le reste de l'exercice (inclus dans la projection). 0 en base accordee (deja comptes au deblocage).", | |
| "gap_a_generer_xof": "Objectif annuel moins bénéfice déjà réalisé (0 si l'objectif est déjà atteint).", | |
| "marge_realiste_requise_pct": "Marge d'intérêt additionnelle (%) à appliquer aux nouveaux prêts pour combler l'écart restant d'ici la fin de l'année, à volume maintenu.", | |
| "marge_actuellement_en_production_pct": "Marge globale réellement appliquée aujourd'hui sur les nouveaux prêts (à comparer à la marge requise).", | |
| "projete_fin_annee_xof": "Bénéfice total projeté en fin d'année si la marge réaliste recommandée est appliquée sur le volume futur estimé.", | |
| }, | |
| }, | |
| key_prefix="optimizer", | |
| ) | |
| st.markdown('</div>', unsafe_allow_html=True) |