Vortex-Flux / src /modules /jasmine_optimizer.py
jaimalklyde's picture
Claude Haiku 5.5
style(ui): emojis remplaces par des balises tactiques sobres
495d60d
Raw History Blame Contribute Delete
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
# ============================================================================
@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('<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)