Vortex-Flux / src /Analytics /AnalyseFinance.py
klydekushy's picture
Update src/Analytics/AnalyseFinance.py
64719e1 verified
#Ce fichier est pour les initiations aux prêts et pour les modifications des prêts
#Il est utiliser dans loans et dans check up
import pandas as pd
from datetime import date, timedelta
# ============================================================================
# CONSTANTES ET SEUILS
# ============================================================================
SEUILS = {
"taux_endettement_mensuel": {
"excellent": 25, "bon": 33, "acceptable": 40, "critique": 50
},
"taux_effort_epargne": {
"excellent": 20, "bon": 30, "acceptable": 40, "critique": 50
},
"reste_a_vivre_min": {
"excellent": 50000, "bon": 30000, "acceptable": 20000, "critique": 15000
},
"liquidite_min_ratio": 1.2,
"duree_courte_semaines": 6
}
# ============================================================================
# FONCTIONS DE NETTOYAGE
# ============================================================================
def clean_currency_value(val):
"""Convertit une valeur monétaire (string ou float) en float propre"""
try:
if isinstance(val, (int, float)):
return float(val)
cleaned = str(val).upper().replace("XOF", "").replace("FCFA", "").replace(" ", "").replace(",", "")
return float(cleaned) if cleaned else 0.0
except (ValueError, AttributeError):
return 0.0
def clean_taux_value(val):
"""
Convertit un taux D'INTÉRÊT correctement.
Gère : "5,3%" → 5.3 | "5.3%" → 5.3 | "5.3" → 5.3 | 53 → 5.3
⚠️ ATTENTION : Cette fonction divise par 10 si > 20%, donc à utiliser UNIQUEMENT pour les taux d'intérêt !
Pour les taux d'endettement, utiliser clean_percentage_value()
"""
try:
if isinstance(val, str):
val = val.replace('%', '').replace(' ', '').replace(',', '.')
taux = float(val)
# Division par 10 si > 20% (logique spécifique aux taux d'intérêt)
if taux > 20:
taux = taux / 10
return round(taux, 2)
except (ValueError, AttributeError, TypeError):
return 0.0
def clean_percentage_value(val):
"""
Convertit un POURCENTAGE (taux d'endettement, etc.) SANS division par 10.
Gère : "54,94%" → 54.94 | "54.94" → 54.94 | "54,94" → 54.94 | 0.5494 → 54.94 | 5494 → 54.94
"""
import numpy as np # ✅ Import numpy pour gérer les types numpy
try:
# ✅ GESTION DES TYPES NUMPY (int64, float64, etc.)
if isinstance(val, (int, float, np.integer, np.floating)):
taux = float(val)
# ✅ Si > 100, c'est probablement stocké comme 5494 au lieu de 54.94
# On divise par 100
if taux > 100:
taux = taux / 100
# Si entre 0 et 1, c'est une fraction (0.5494 → 54.94%)
elif 0 < taux < 1:
taux = taux * 100
return round(taux, 2)
# ✅ GESTION DES STRINGS
elif isinstance(val, str):
# Retirer les symboles et normaliser les séparateurs décimaux
val = val.replace('%', '').replace(' ', '').replace(',', '.')
taux = float(val)
# Même logique
if taux > 100:
taux = taux / 100
elif 0 < taux < 1:
taux = taux * 100
return round(taux, 2)
else:
return 0.0
except (ValueError, AttributeError, TypeError):
return 0.0
# ============================================================================
# ✅ NOUVEAU — IPF : INDICE DE PRESSION FAMILIALE
# ============================================================================
def calculer_ipf(revenus_mensuels, pers_charge):
"""
Calcule l'IPF (Indice de Pression Familiale) = Revenus / (1 + Pers_Charge).
L'IPF reflète le revenu réellement disponible par unité de ménage.
Activé uniquement si le client a au moins 1 personne à charge
(colonne Pers_Charge du CSV KYC Vortex-Flux).
Args:
revenus_mensuels (float | str) : Revenus mensuels bruts du client.
pers_charge (int) : Nombre de personnes à charge (0 = pas d'ajustement).
Returns:
float : IPF arrondi à 2 décimales. Identique aux revenus si pers_charge == 0.
"""
revenus_mensuels = clean_currency_value(revenus_mensuels)
try:
pers_charge = int(pers_charge)
except (ValueError, TypeError):
pers_charge = 0
if pers_charge <= 0:
return round(revenus_mensuels, 2)
return round(revenus_mensuels / (1 + pers_charge), 2)
def get_revenus_ajustes(revenus_mensuels, pers_charge):
"""
Retourne la base de revenus à utiliser pour le calcul du taux d'endettement.
- pers_charge >= 1 → IPF = Revenus / (1 + Pers_Charge)
- pers_charge == 0 → revenus bruts (aucun ajustement)
Args:
revenus_mensuels (float | str) : Revenus mensuels du client.
pers_charge (int) : Nombre de personnes à charge (colonne Pers_Charge du KYC).
Returns:
tuple(float, bool) : (revenus_base, ipf_applique)
- revenus_base : montant à utiliser comme dénominateur du taux d'endettement
- ipf_applique : True si l'IPF a été appliqué (pour affichage dans l'UI)
"""
try:
pers_charge = int(pers_charge)
except (ValueError, TypeError):
pers_charge = 0
ipf = calculer_ipf(revenus_mensuels, pers_charge)
ipf_applique = pers_charge >= 1
return ipf, ipf_applique
# ============================================================================
# FONCTIONS DE CALCUL DU TAUX D'ENDETTEMENT
# ============================================================================
def calculer_taux_endettement(type_code, montant_versement, nb_versements, duree_semaines, revenus_mensuels, pers_charge=0):
"""
Calcule le taux d'endettement mensuel selon le type de prêt.
Retourne un float arrondi à 2 décimales.
✅ IPF : Si pers_charge >= 1 (colonne Pers_Charge du KYC), le calcul utilise
l'IPF = Revenus / (1 + Pers_Charge) comme base au lieu des revenus bruts.
"""
# ✅ Base de revenus ajustée par l'IPF si nécessaire
revenus_base, _ = get_revenus_ajustes(revenus_mensuels, pers_charge)
if revenus_base == 0:
return 0.0
# Calcul de la charge mensuelle selon le type
if type_code == "IN_FINE":
duree_mois = duree_semaines / 4.33
if duree_mois < 2:
charge_mensuelle = montant_versement / duree_mois
else:
charge_mensuelle = 0
elif type_code == "MENSUEL_INTERETS":
charge_mensuelle = montant_versement
elif type_code == "MENSUEL_CONSTANT":
charge_mensuelle = montant_versement
elif type_code == "HEBDOMADAIRE":
charge_mensuelle = montant_versement * 4.33
elif type_code == "PERSONNALISE":
charge_mensuelle = montant_versement * (nb_versements / (duree_semaines / 4.33)) if duree_semaines > 0 else montant_versement
else:
charge_mensuelle = 0
taux = (charge_mensuelle / revenus_base) * 100
return round(taux, 2)
# ============================================================================
# LOGIQUE D'AMORTISSEMENT
# ============================================================================
def generer_tableau_amortissement(type_code, montant, taux_hebdo, duree_semaines, montant_versement, nb_versements, date_debut, dates_versements=None):
tableau = []
if type_code == "IN_FINE":
date_fin = date_debut + timedelta(weeks=duree_semaines)
montant_total = montant * (1 + (taux_hebdo / 100) * duree_semaines)
interets = montant_total - montant
tableau.append({
"Periode": 1, "Date": date_fin.strftime("%d/%m/%Y"),
"Capital": round(montant), "Interets": round(interets),
"Versement": round(montant_total), "Solde_Restant": 0
})
elif type_code == "MENSUEL_INTERETS":
duree_mois = nb_versements
taux_mensuel = (taux_hebdo / 100) * 4.33
interet_mensuel = montant * taux_mensuel
solde = montant
for mois in range(1, duree_mois + 1):
date_echeance = date_debut + timedelta(days=30 * mois)
if mois == duree_mois:
capital_paye = montant
versement = montant + interet_mensuel
solde = 0
else:
capital_paye = 0
versement = interet_mensuel
tableau.append({
"Periode": mois, "Date": date_echeance.strftime("%d/%m/%Y"),
"Capital": round(capital_paye), "Interets": round(interet_mensuel),
"Versement": round(versement), "Solde_Restant": round(solde)
})
elif type_code == "MENSUEL_CONSTANT":
duree_mois = nb_versements
taux_mensuel = (taux_hebdo / 100) * 4.33
solde = montant
for mois in range(1, duree_mois + 1):
date_echeance = date_debut + timedelta(days=30 * mois)
interets = solde * taux_mensuel
capital_paye = montant_versement - interets
solde -= capital_paye
tableau.append({
"Periode": mois, "Date": date_echeance.strftime("%d/%m/%Y"),
"Capital": round(capital_paye), "Interets": round(interets),
"Versement": round(montant_versement), "Solde_Restant": max(0, round(solde))
})
elif type_code == "HEBDOMADAIRE":
taux_hebdo_decimal = taux_hebdo / 100
solde = montant
for semaine in range(1, int(duree_semaines) + 1):
date_echeance = date_debut + timedelta(weeks=semaine)
interets = solde * taux_hebdo_decimal
capital_paye = montant_versement - interets
solde -= capital_paye
tableau.append({
"Periode": semaine, "Date": date_echeance.strftime("%d/%m/%Y"),
"Capital": round(capital_paye), "Interets": round(interets),
"Versement": round(montant_versement), "Solde_Restant": max(0, round(solde))
})
elif type_code == "PERSONNALISE" and dates_versements:
# ✅ CORRECTION : Pour type PERSONNALISÉ, le solde doit partir du MONTANT TOTAL
# Car les intérêts sont répartis sur toute la durée
montant_total_pret = montant * (1 + (taux_hebdo / 100) * duree_semaines)
solde = montant_total_pret # ✅ On part du montant TOTAL (capital + intérêts)
for idx, date_v in enumerate(dates_versements):
solde_apres = max(0, solde - montant_versement)
# Répartition capital/intérêts proportionnelle
ratio_interets = (taux_hebdo / 100) * duree_semaines
interets_part = (montant_versement * ratio_interets) / (1 + ratio_interets)
capital_part = montant_versement - interets_part
tableau.append({
"Periode": idx + 1,
"Date": date_v.strftime("%d/%m/%Y"),
"Capital": round(capital_part),
"Interets": round(interets_part),
"Versement": round(montant_versement),
"Solde_Restant": round(solde_apres)
})
solde = solde_apres
return pd.DataFrame(tableau)
# ============================================================================
# LOGIQUE D'ANALYSE (CORRIGÉE - TOUS LES TYPES)
# ============================================================================
def analyser_capacite(type_code, montant, taux_hebdo, duree_semaines, montant_versement, nb_versements, revenus_mensuels, charges_mensuelles, montant_total=0, cout_credit=0, pers_charge=0):
"""
Analyse la capacité de remboursement du client.
✅ Nouveau paramètre : pers_charge (int) — colonne Pers_Charge du KYC CSV.
Si >= 1, tous les calculs de taux d'endettement et reste à vivre utilisent
l'IPF = Revenus / (1 + Pers_Charge) au lieu des revenus bruts, afin de
refléter la pression financière réelle du foyer.
"""
revenus_mensuels = clean_currency_value(revenus_mensuels)
charges_mensuelles = clean_currency_value(charges_mensuelles)
# ✅ Calcul de la base de revenus ajustée par l'IPF
revenus_base, ipf_applique = get_revenus_ajustes(revenus_mensuels, pers_charge)
ipf_note = (
f"\n📊 IPF appliqué ({int(pers_charge)} pers. à charge) : "
f"base revenus = {int(revenus_base):,} XOF "
f"(au lieu de {int(revenus_mensuels):,} XOF bruts)"
) if ipf_applique else ""
# --- LOGIQUE IN FINE ---
if type_code == "IN_FINE":
if duree_semaines < SEUILS["duree_courte_semaines"]:
duree_mois = duree_semaines / 4.33
revenus_cumules = revenus_base * duree_mois # ✅ IPF
charges_cumules = charges_mensuelles * duree_mois
disponible_echeance = revenus_cumules - charges_cumules
marge_securite = disponible_echeance - montant_total
ratio_couverture = disponible_echeance / montant_total if montant_total > 0 else 0
if ratio_couverture >= 1.5: statut, couleur, msg = "EXCELLENT", "green", "Le client dispose de liquidités très confortables."
elif ratio_couverture >= SEUILS["liquidite_min_ratio"]: statut, couleur, msg = "BON", "blue", "Liquidités suffisantes."
elif ratio_couverture >= 1.0: statut, couleur, msg = "ACCEPTABLE", "orange", "⚠️ Liquidités justes sans marge d'erreur."
else: statut, couleur, msg = "INSUFFISANT", "red", "❌ ATTENTION : Liquidités insuffisantes."
details = f"**Analyse Liquidité Directe**\nRatio couverture: {ratio_couverture:.2f}x\nDisponible: {int(disponible_echeance):,} XOF{ipf_note}"
return {"statut": statut, "couleur": couleur, "message": msg, "details": details, "metriques": {"ratio": ratio_couverture, "ipf_applique": ipf_applique, "revenus_base": revenus_base}}
else: # Épargne progressive
epargne_hebdo = montant_total / duree_semaines
revenus_hebdo = revenus_base / 4.33 # ✅ IPF
taux_effort = (epargne_hebdo / revenus_hebdo * 100) if revenus_hebdo > 0 else 0
if taux_effort <= SEUILS["taux_effort_epargne"]["excellent"]: statut, couleur, msg = "EXCELLENT", "green", "Capacité d'épargne très confortable."
elif taux_effort <= SEUILS["taux_effort_epargne"]["acceptable"]: statut, couleur, msg = "ACCEPTABLE", "orange", "⚠️ Capacité d'épargne limitée."
else: statut, couleur, msg = "INSUFFISANT", "red", "❌ Capacité insuffisante."
details = f"**Analyse Épargne**\nTaux effort: {taux_effort:.1f}%\nEpargne req/sem: {int(epargne_hebdo):,} XOF{ipf_note}"
return {"statut": statut, "couleur": couleur, "message": msg, "details": details, "metriques": {"taux_effort": taux_effort, "ipf_applique": ipf_applique, "revenus_base": revenus_base}}
# --- LOGIQUE MENSUEL - INTÉRÊTS ---
elif type_code == "MENSUEL_INTERETS":
duree_mois = nb_versements
taux_mensuel = (taux_hebdo / 100) * 4.33
interet_mensuel = montant * taux_mensuel
# ✅ IPF
taux_endettement = (interet_mensuel / revenus_base * 100) if revenus_base > 0 else 0
reste_vivre = revenus_base - charges_mensuelles - interet_mensuel
epargne_necessaire = montant / duree_mois
ratio_epargne = (epargne_necessaire / reste_vivre * 100) if reste_vivre > 0 else 0
if taux_endettement <= SEUILS["taux_endettement_mensuel"]["excellent"] and reste_vivre >= SEUILS["reste_a_vivre_min"]["bon"]:
statut, couleur, msg = "EXCELLENT", "green", "Capacité très confortable pour les intérêts mensuels."
elif taux_endettement <= SEUILS["taux_endettement_mensuel"]["bon"] and reste_vivre >= SEUILS["reste_a_vivre_min"]["acceptable"]:
statut, couleur, msg = "BON", "blue", "Bonne capacité de paiement des intérêts."
elif taux_endettement <= SEUILS["taux_endettement_mensuel"]["acceptable"]:
statut, couleur, msg = "ACCEPTABLE", "orange", "⚠️ Charge acceptable mais attention au capital final."
else:
statut, couleur, msg = "INSUFFISANT", "red", "🚨 Risque élevé : intérêts mensuels trop lourds."
if ratio_epargne > 50:
msg += f"\n⚠️ ATTENTION : Le capital final ({int(montant):,} XOF) nécessiterait une épargne de {int(epargne_necessaire):,} XOF/mois."
details = f"""**Analyse Mensuel - Intérêts**
Taux endettement (intérêts seuls): {taux_endettement:.1f}%
Intérêts mensuels: {int(interet_mensuel):,} XOF
Reste à vivre: {int(reste_vivre):,} XOF
Capital final à prévoir: {int(montant):,} XOF au mois {duree_mois}{ipf_note}"""
return {"statut": statut, "couleur": couleur, "message": msg, "details": details,
"metriques": {"taux_endettement": taux_endettement, "reste_vivre": reste_vivre, "ipf_applique": ipf_applique, "revenus_base": revenus_base}}
# --- LOGIQUE MENSUEL CONSTANT ---
elif type_code == "MENSUEL_CONSTANT":
# ✅ IPF
taux_endettement = (montant_versement / revenus_base * 100) if revenus_base > 0 else 0
reste_vivre = revenus_base - charges_mensuelles - montant_versement
if taux_endettement <= SEUILS["taux_endettement_mensuel"]["excellent"] and reste_vivre >= SEUILS["reste_a_vivre_min"]["excellent"]:
statut, couleur, msg = "EXCELLENT", "green", "Capacité très confortable."
elif taux_endettement <= SEUILS["taux_endettement_mensuel"]["acceptable"]:
statut, couleur, msg = "ACCEPTABLE", "orange", "⚠️ Marge limitée."
else:
statut, couleur, msg = "INSUFFISANT", "red", "🚨 Risque élevé."
details = f"**Analyse Endettement**\nTaux: {taux_endettement:.1f}%\nReste à vivre: {int(reste_vivre):,} XOF{ipf_note}"
return {"statut": statut, "couleur": couleur, "message": msg, "details": details, "metriques": {"taux_endettement": taux_endettement, "ipf_applique": ipf_applique, "revenus_base": revenus_base}}
# --- LOGIQUE HEBDOMADAIRE ---
elif type_code == "HEBDOMADAIRE":
charge_mensuelle = montant_versement * 4.33
# ✅ IPF
taux_endettement = (charge_mensuelle / revenus_base * 100) if revenus_base > 0 else 0
if taux_endettement <= SEUILS["taux_endettement_mensuel"]["bon"]: statut, couleur, msg = "BON", "blue", "Bonne capacité hebdo."
elif taux_endettement <= SEUILS["taux_endettement_mensuel"]["critique"]: statut, couleur, msg = "LIMITE", "red", "🚨 Charge hebdo élevée."
else: statut, couleur, msg = "INSUFFISANT", "darkred", "❌ Trop lourd."
details = f"**Analyse Rythme Hebdo**\nCharge mensuelle équivalente: {int(charge_mensuelle):,} XOF\nTaux: {taux_endettement:.1f}%{ipf_note}"
return {"statut": statut, "couleur": couleur, "message": msg, "details": details, "metriques": {"taux_endettement": taux_endettement, "ipf_applique": ipf_applique, "revenus_base": revenus_base}}
# --- LOGIQUE PERSONNALISÉ ---
elif type_code == "PERSONNALISE":
charge_mensuelle = montant_versement * (nb_versements / (duree_semaines / 4.33)) if duree_semaines > 0 else montant_versement
# ✅ IPF
taux_endettement = (charge_mensuelle / revenus_base * 100) if revenus_base > 0 else 0
if taux_endettement <= SEUILS["taux_endettement_mensuel"]["bon"]:
statut, couleur, msg = "BON", "blue", "Rythme personnalisé acceptable."
elif taux_endettement <= SEUILS["taux_endettement_mensuel"]["acceptable"]:
statut, couleur, msg = "ACCEPTABLE", "orange", "⚠️ Rythme à surveiller."
else:
statut, couleur, msg = "INSUFFISANT", "red", "🚨 Rythme trop exigeant."
details = f"**Analyse Rythme Personnalisé**\nCharge mensuelle moyenne: {int(charge_mensuelle):,} XOF\nTaux: {taux_endettement:.1f}%{ipf_note}"
return {"statut": statut, "couleur": couleur, "message": msg, "details": details, "metriques": {"taux_endettement": taux_endettement, "ipf_applique": ipf_applique, "revenus_base": revenus_base}}
# --- PAR DEFAUT (Ne devrait plus arriver) ---
return {"statut": "INCONNU", "couleur": "grey", "message": "Type non analysé", "details": "", "metriques": {}}