import streamlit as st import streamlit.components.v1 as components import pandas as pd import sys import os import plotly.graph_objects as go from plotly.subplots import make_subplots import numpy as np sys.path.append(os.path.join(os.path.dirname(__file__), '..')) from Analytics.VortexOutFlux import VortexOutFlux from Analytics.DataSyncForecasting import actualiser_forecasting_depuis_prets, forecasting_needs_sync # ============================================================================ # CSS — STYLE TACTIQUE / MILITAIRE # Seules les règles SCOPED sous #forecasting-module sont conservées ici. # Les règles globales (.stApp h1/h2/h3, metrics, boutons, etc.) sont # centralisées dans streamlit_app.py pour garantir l'uniformité entre modules. # ============================================================================ def apply_forecasting_styles(): st.markdown(""" """, unsafe_allow_html=True) # ============================================================================ # HELPERS DE RENDU # ============================================================================ def _fmt(n): return f"{int(round(n)):,}".replace(",", " ") def render_signal_block(signal_data): css_cls = { 'REDUIRE': ('reduce', 'ORDRE : REDUIRE LES ENGAGEMENTS'), 'MAINTENIR': ('maintain', 'ORDRE : MAINTENIR LES ENGAGEMENTS'), 'AUGMENTER': ('increase', 'ORDRE : AUGMENTER LES ENGAGEMENTS'), } cls, titre = css_cls.get(signal_data['signal'], ('maintain', 'ORDRE : MAINTENIR')) return f"""
Analyse predictive — {signal_data['mois_predit']}
{titre}
{signal_data['message_signal']}
""" def render_next_month_cards(signal_data): evo = signal_data['evolution_pct'] if evo > 2: evo_class, evo_sign = 'up', '+' elif evo < -2: evo_class, evo_sign = 'down', '' else: evo_class, evo_sign = 'flat', '' signal_cls = { 'REDUIRE': 'signal-reduce', 'MAINTENIR': 'signal-maintain', 'AUGMENTER': 'signal-increase', }.get(signal_data['signal'], 'signal-maintain') card_predit = f"""
Capital predit — {signal_data['mois_predit']}
{_fmt(signal_data['capital_predit'])} XOF
Modele de regression lineaire
""" card_evo = f"""
Evolution predictible vs {signal_data['mois_actuel']}
{_fmt(signal_data['capital_reel_actuel'])} XOF
Capital reel — {signal_data['mois_actuel']}
{evo_sign}{evo:.1f}%
""" return card_predit, card_evo def render_forecast_month_card(row): montant = row['Montant_Predit'] lower = row['Borne_Inf'] upper = row['Borne_Sup'] margin = ((upper - lower) / 2) / montant * 100 if montant > 0 else 0 mois = row['Date'].strftime('%B %Y').upper() return f"""
{mois}
{_fmt(montant)} XOF
Intervalle 95% : {_fmt(lower)} — {_fmt(upper)} XOF
Marge d erreur : +/- {margin:.1f}%
""" def render_confidence_block(confidence_data): score = confidence_data['score'] if score >= 70: css_cls = 'high' elif score >= 40: css_cls = 'medium' else: css_cls = 'low' bar_width = min(score, 100) return f"""
Indice de confiance — Prediction mois suivant
{score:.0f}%
Ecart moyen absolu : {_fmt(confidence_data['mae_moy'])} XOF  |  Ecart relatif moyen : {confidence_data['mape']:.1f}%
Stabilite des ecarts (CV) : {confidence_data['cv_residus']:.1f}%  |  R² du modele : {confidence_data['r_squared']:.3f}
{confidence_data['interpretation']}
""" def render_comparison_table_html(df_cmp): rows_html = "" for _, row in df_cmp.iterrows(): evo = row['Evolution_Pct'] if evo is None or (isinstance(evo, float) and np.isnan(evo)): evo_str = "—" evo_color = "#5a6a7a" elif evo > 0: evo_str = f"+{evo:.1f}%" evo_color = "#c0392b" elif evo < 0: evo_str = f"{evo:.1f}%" evo_color = "#58a6ff" else: evo_str = "0.0%" evo_color = "#5a6a7a" rows_html += f""" {row['Mois']} {_fmt(row['Capital_Reel'])} XOF {evo_str} {_fmt(row['Capital_Predit'])} XOF """ # La couleur "bleu" du theme BLEU devient ambre en theme NOIR (core/theme.py). from core import theme couleur_baisse = "Ambre" if theme.est_noir() else "Bleu" return f""" {rows_html}
Mois Capital Reel Evolution Reelle Capital Predit
EVOLUTION REELLE : variation du capital sorti vs mois precedent. Rouge = hausse des sorties. {couleur_baisse} = baisse des sorties.
""" # ============================================================================ # MODULE PRINCIPAL # ============================================================================ def _stats_residus(valeurs): """Resume numerique de la distribution des residus (graphique H), pour le chat JASMINE.""" if not valeurs: return {} import statistics return { "n": len(valeurs), "moyenne": round(statistics.fmean(valeurs), 2), "ecart_type": round(statistics.pstdev(valeurs), 2) if len(valeurs) > 1 else 0.0, "min": round(min(valeurs), 2), "max": round(max(valeurs), 2), } def show_forecasting_module(client, sheet_name): apply_forecasting_styles() st.markdown('
', unsafe_allow_html=True) st.header("JASMINE — PREVISION DES FLUX DE SORTIE") st.caption("ANALYSE PREDICTIVE / REGRESSION LINEAIRE / TEMPS REEL") st.divider() col_sync1, col_sync2 = st.columns([4, 1]) with col_sync1: st.markdown("""
SOURCE : Prets_Master / Date_Deblocage + Montant_Capital. Cliquez sur SYNC pour actualiser les donnees de la feuille Forecasting.
""", unsafe_allow_html=True) with col_sync2: if st.button("SYNC", use_container_width=True): with st.spinner("Synchronisation..."): result = actualiser_forecasting_depuis_prets(client, sheet_name) if result['success']: stats = result['stats'] st.success(f"OK — {stats['nb_conserves']} conserves, " f"{stats['nb_mis_a_jour']} mis a jour, " f"{stats['nb_ajoutes']} ajoutes.") with st.expander("DETAILS SYNCHRONISATION", expanded=False): c1, c2, c3 = st.columns(3) c1.metric("Conserves", stats['nb_conserves']) c2.metric("Mis a jour", stats['nb_mis_a_jour']) c3.metric("Ajoutes", stats['nb_ajoutes']) st.rerun() else: st.error(f"ERREUR : {result['message']}") try: if forecasting_needs_sync(client, sheet_name): with st.spinner("Actualisation automatique des previsions..."): actualiser_forecasting_depuis_prets(client, sheet_name) sh = client.open(sheet_name) ws_forecasting = sh.worksheet("Forecasting") df_forecasting = pd.DataFrame(ws_forecasting.get_all_records()) if df_forecasting.empty or len(df_forecasting) < 2: st.warning("Donnees insuffisantes — minimum 2 points requis.") st.markdown('
', unsafe_allow_html=True) return if 'Date' not in df_forecasting.columns or 'Montant_Total_Sortie' not in df_forecasting.columns: st.error("Structure invalide. Colonnes requises : Date, Montant_Total_Sortie") st.markdown('', unsafe_allow_html=True) return analyzer = VortexOutFlux(df_forecasting) except Exception as e: st.error(f"Erreur de connexion : {e}") st.markdown('', unsafe_allow_html=True) return try: predictions_result = analyzer.predict_next_months(n_months=3) if 'error' in predictions_result: st.error(f"Erreur modele : {predictions_result['error']}") st.markdown('', unsafe_allow_html=True) return predictions_df = predictions_result['predictions'] models = predictions_result['models'] viz_data = analyzer.generate_visualization_data(predictions_result) interpretations = viz_data['interpretations'] except Exception as e: st.error(f"Erreur lors du calcul predictif : {e}") st.exception(e) st.markdown('', unsafe_allow_html=True) return # Chat JASMINE scoppé à cette page : questions sur les prévisions, l'historique et le signal. from modules.jasmine_widget import render_scoped_widget render_scoped_widget( client, sheet_name, scope_label="Prévision des flux de sortie (historique Forecasting, prévisions 3 mois, régression linéaire)", context_data={ "historique_recent": df_forecasting.tail(24).to_dict("records"), "previsions_3_mois": predictions_df.to_dict("records") if hasattr(predictions_df, "to_dict") else predictions_df, "interpretations": interpretations, "nb_points_historique": int(len(df_forecasting)), "metriques_modele": viz_data["metrics"], # Graphique C : historique reel vs ajuste par le modele, et prevision "graphique_C_historique": [ {"date": d, "reel": round(v, 2), "ajuste_modele": round(p, 2)} for d, v, p in zip(viz_data["historical"]["dates"], viz_data["historical"]["values"], viz_data["historical"]["predictions_hist"]) ], "graphique_C_prevision": viz_data["future"], # Graphique H (section « H — ANALYSE DES RESIDUS ») : un point par mois = (valeur predite, residu = reel - predit) "graphique_H_residus_vs_predits": [ {"valeur_predite": round(p, 2), "residu": round(r, 2)} for p, r in zip(viz_data["residuals"]["predictions"], viz_data["residuals"]["values"]) ], "graphique_H_stats_residus": _stats_residus(viz_data["residuals"]["values"]), "graphique_H_lecture": ("Axe X = valeur predite par le modele, axe Y = residu (reel - predit). " "Un bon modele a des residus epars autour de 0 sans structure ; " "une tendance ou une forme en eventail indique un biais ou une heteroscedasticite. " "Second panneau : distribution des residus (histogramme)."), }, key_prefix="forecasting", ) # ========================================================================= # SECTION A — SIGNAL OPÉRATIONNEL # ========================================================================= st.divider() st.markdown('
A — Signal operationnel
', unsafe_allow_html=True) try: signal_data = analyzer.compute_next_month_signal(models) card_predit, card_evo = render_next_month_cards(signal_data) col_c1, col_c2 = st.columns(2) with col_c1: st.markdown(card_predit, unsafe_allow_html=True) with col_c2: st.markdown(card_evo, unsafe_allow_html=True) st.markdown(render_signal_block(signal_data), unsafe_allow_html=True) except Exception as e: st.warning(f"Signal non disponible : {e}") # ========================================================================= # SECTION B — KPIs HISTORIQUES # ========================================================================= st.divider() st.markdown('
B — Donnees historiques
', unsafe_allow_html=True) nb_points = len(analyzer.df) flux_moy = analyzer.df['Montant_Total_Sortie'].mean() flux_total = analyzer.df['Montant_Total_Sortie'].sum() periode = (f"{analyzer.df['Date'].min().strftime('%m/%Y')} — " f"{analyzer.df['Date'].max().strftime('%m/%Y')}") st.markdown(f"""
Points
{nb_points}
Flux moyen
{_fmt(flux_moy)} XOF
Flux total
{_fmt(flux_total)} XOF
Periode
{periode}
""", unsafe_allow_html=True) # ========================================================================= # SECTION C — GRAPHIQUE PRINCIPAL # ========================================================================= st.divider() st.markdown('
C — Visualisation historique et prediction
', unsafe_allow_html=True) fig = go.Figure() fig.add_trace(go.Scatter( x=viz_data['historical']['dates'], y=viz_data['historical']['values'], mode='lines+markers', name='Capital reel sorti', line=dict(color='#a8b8c8', width=1.5), marker=dict(size=5, symbol='circle', color='#a8b8c8'), )) fig.add_trace(go.Scatter( x=viz_data['historical']['dates'], y=viz_data['historical']['predictions_hist'], mode='lines', name='Regression (historique)', line=dict(color='#3a5a8a', width=1, dash='dot'), )) fig.add_trace(go.Scatter( x=viz_data['future']['dates'], y=viz_data['future']['predictions'], mode='lines+markers', name='Prediction', line=dict(color='#58a6ff', width=1.5, dash='dash'), marker=dict(size=7, symbol='diamond', color='#58a6ff'), )) fig.add_trace(go.Scatter( x=viz_data['future']['dates'] + viz_data['future']['dates'][::-1], y=viz_data['future']['upper_bound'] + viz_data['future']['lower_bound'][::-1], fill='toself', fillcolor='rgba(88,166,255,0.07)', line=dict(color='rgba(88,166,255,0)'), name='Intervalle 95%', showlegend=True, )) last_hist_date = pd.Timestamp(viz_data['historical']['dates'][-1]) fig.add_vline( x=last_hist_date.timestamp() * 1000, line_width=1, line_dash='dot', line_color='rgba(100,120,140,0.4)', annotation_text='HISTORIQUE / PREDICTION', annotation_position='top left', annotation_font_size=9, annotation_font_color='#4a5a6a', ) fig.update_layout( height=440, margin=dict(l=0, r=0, t=30, b=0), plot_bgcolor='rgba(10,14,18,0.9)', paper_bgcolor='rgba(15,20,26,0.0)', font=dict(family='JetBrains Mono, Courier New, monospace', color='#5a6a7a', size=10), xaxis=dict(gridcolor='rgba(50,65,80,0.25)', linecolor='rgba(80,100,120,0.3)', tickcolor='rgba(80,100,120,0.3)', title_text=''), yaxis=dict(gridcolor='rgba(50,65,80,0.25)', linecolor='rgba(80,100,120,0.3)', tickcolor='rgba(80,100,120,0.3)', title_text='XOF'), legend=dict(bgcolor='rgba(15,20,26,0.85)', bordercolor='rgba(80,100,120,0.2)', borderwidth=1, font=dict(size=10, family='JetBrains Mono, monospace')), hovermode='x unified', ) st.plotly_chart(fig, use_container_width=True) # ========================================================================= # SECTION D — PRÉDICTIONS 3 MOIS # ========================================================================= st.divider() st.markdown('
D — Predictions 3 prochains mois
', unsafe_allow_html=True) cols_pred = st.columns(3) for idx, (_, row) in enumerate(predictions_df.iterrows()): with cols_pred[idx]: st.markdown(render_forecast_month_card(row), unsafe_allow_html=True) # ========================================================================= # SECTION E — TABLEAU COMPARATIF # ========================================================================= st.divider() from core import exercice as ex annee_cmp = ex.exercice_selectionne() debut_cmp, fin_cmp = ex.bornes(annee_cmp) if annee_cmp else (pd.Timestamp("2000-01-01"), None) titre_cmp = f"exercice {annee_cmp}" if annee_cmp else "toutes annees" st.markdown(f'
E — Comparatif reel vs predit ({titre_cmp})
', unsafe_allow_html=True) try: df_cmp = analyzer.build_comparison_table(models, cutoff_date=debut_cmp, end_date=fin_cmp) if df_cmp.empty: st.info(f"Aucune donnee disponible pour l'{titre_cmp}.") else: nb_lignes = len(df_cmp) hauteur_table = 45 + nb_lignes * 38 + 40 components.html(render_comparison_table_html(df_cmp), height=hauteur_table, scrolling=False) except Exception as e: st.warning(f"Tableau non disponible : {e}") # ========================================================================= # SECTION F — PERFORMANCE DU MODÈLE # ========================================================================= st.divider() st.markdown('
F — Performance du modele
', unsafe_allow_html=True) r2 = models['r_squared'] r = models['r_coefficient'] mae = models['mae'] rmse = models['rmse'] pente = models['pente'] st.markdown(f"""
R²
{r2:.3f}
R
{r:.3f}
MAE
{_fmt(mae)} XOF
RMSE
{_fmt(rmse)} XOF
Pente
{pente:.2f} XOF/j
""", unsafe_allow_html=True) st.markdown(f"""
TENDANCE : {interpretations['tendance']}
{interpretations['message_principal']}
{interpretations['message_fiabilite']}
{interpretations['message_precision']}
""", unsafe_allow_html=True) # ========================================================================= # SECTION G — INDICE DE CONFIANCE # ========================================================================= st.divider() st.markdown('
G — Indice de confiance de la prediction
', unsafe_allow_html=True) try: confidence_data = analyzer.compute_confidence_score(models) st.markdown(render_confidence_block(confidence_data), unsafe_allow_html=True) except Exception as e: st.warning(f"Indice de confiance non disponible : {e}") # ========================================================================= # SECTION H — RÉSIDUS # ========================================================================= st.divider() with st.expander("H — ANALYSE DES RESIDUS (DIAGNOSTIC DU MODELE)", expanded=False): fig_res = make_subplots( rows=1, cols=2, subplot_titles=("Residus vs Valeurs Predites", "Distribution des Residus"), specs=[[{"type": "scatter"}, {"type": "histogram"}]], ) fig_res.add_trace(go.Scatter( x=viz_data['residuals']['predictions'], y=viz_data['residuals']['values'], mode='markers', marker=dict(color='#58a6ff', size=6, opacity=0.7), name='Residus', ), row=1, col=1) fig_res.add_hline(y=0, line_dash='dot', line_color='rgba(100,120,140,0.5)', row=1, col=1) fig_res.add_trace(go.Histogram( x=viz_data['residuals']['values'], marker=dict(color='#3a5a8a', line=dict(color='#5a7aaa', width=0.5)), nbinsx=12, name='Distribution', ), row=1, col=2) _ax = dict(gridcolor='rgba(50,65,80,0.25)', linecolor='rgba(80,100,120,0.3)', tickcolor='rgba(80,100,120,0.3)') fig_res.update_xaxes(**_ax) fig_res.update_yaxes(**_ax) fig_res.update_layout( height=320, margin=dict(l=0, r=0, t=30, b=0), plot_bgcolor='rgba(10,14,18,0.9)', paper_bgcolor='rgba(15,20,26,0.0)', font=dict(family='JetBrains Mono, monospace', color='#5a6a7a', size=9), showlegend=False, ) st.plotly_chart(fig_res, use_container_width=True) st.markdown(""" Un bon modele presente des residus aleatoirement distribues autour de zero, sans structure apparente. """, unsafe_allow_html=True) # ========================================================================= # SECTION I — NOTE MÉTHODOLOGIQUE # ========================================================================= st.divider() st.markdown("""
NOTE METHODOLOGIQUE — Modele : regression lineaire simple (sklearn). Intervalles de confiance : 95% par methode d erreur standard des residus. Signal operationnel : seuil de reduction a -10%, seuil d augmentation a +10%. Indice de confiance : base sur MAPE, CV des residus et R² du modele. Mise a jour des donnees : automatique si perimee (+ de 12h) ou absente, ou manuellement via bouton SYNC. Pour des patterns saisonniers complexes, envisager SARIMA ou Prophet.
""", unsafe_allow_html=True) st.markdown('', unsafe_allow_html=True)