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(""" """, unsafe_allow_html=True) # ============================================================================ # GESTION CENTRALISEE DU MODELE ML # ============================================================================ @st.cache_resource(ttl=3600) 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('
', unsafe_allow_html=True) st.markdown("

OPTIMISATEUR // CONTROLE DES MARGES

", 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("
", 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('
', unsafe_allow_html=True) st.markdown(f"""
Bénéfice {ex.mot_base(BASE).lower()} {ANNEE_ACTUELLE}
{int(realise_ytd):,} XOF
Objectif {ANNEE_ACTUELLE}
{int(OBJECTIF_ANNUEL):,} XOF
""", unsafe_allow_html=True) alert_class = "alert" if gap_brut_total > 0 else "success" st.markdown(f"""
Reste à générer (GAP)
{int(max(0, gap_brut_total)):,} XOF
""", unsafe_allow_html=True) # LE NOUVEAU KPI DE PROJECTION FINALE alert_proj = "alert" if benefice_projet_final < OBJECTIF_ANNUEL else "success" st.markdown(f"""
Projeté fin {ANNEE_ACTUELLE} (+{taux_realiste:.2f}%)
{int(benefice_projet_final):,} XOF
""", unsafe_allow_html=True) st.markdown('
', unsafe_allow_html=True) # DIAGNOSTIC STRUCTUREL (La vérité mathématique) if mb_real > PLAFOND_MARGE: st.markdown(f"""

ALERTE STRUCTURELLE : LE PLAFOND EST ATTEINT

La marge brute mathématiquement requise pour atteindre {int(OBJECTIF_ANNUEL):,} XOF avec les volumes actuels est de {mb_real:.2f}%.
Le système l'a clampée au plafond de sécurité de +{PLAFOND_MARGE:.2f}% pour rester compétitif.

La réalité : 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}%

Conclusion : Augmenter les taux ne suffit plus. Il faut impérativement augmenter le volume d'acquisition client.
""", unsafe_allow_html=True) # 6. AFFICHAGE DES SCENARIOS st.markdown(f"### SIMULATION DES TRAJECTOIRES DE MARGE (PLAFOND: {PLAFOND_MARGE:.2f}%)") st.markdown('
', unsafe_allow_html=True) st.markdown(f"""
Scénario Pessimiste
Baisse de 20% des volumes.
+{taux_pessimiste:.2f}%
Marge requise réelle: {mb_pes:.2f}%
Scénario Réaliste
Maintien de la tendance ({avg_prets_par_mois:.1f} prêts/mois).
+{taux_realiste:.2f}%
Marge requise réelle: {mb_real:.2f}%
Scénario Optimiste
Hausse de 20% de la demande au Q4.
+{taux_optimiste:.2f}%
Marge requise réelle: {mb_opt:.2f}%
""", unsafe_allow_html=True) st.markdown('
', 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"""
// SYNTHESE DE L'ANALYSE
{st.session_state.rapport_opti}
""", 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("
Le modèle Gradient Boosting évalue le risque de crédit. Cliquez pour relancer l'apprentissage sur la base des derniers prêts.
", 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('
', 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('
', 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('
', unsafe_allow_html=True)