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"""
{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)