Spaces:
Sleeping
Sleeping
| # app.py | |
| # ============================================ | |
| # Simulatore Monte Carlo di un PAC e di Azioni/ETF/Cripto (dati Yahoo) - single file Streamlit app | |
| # Autori: Giovanni Vignola e GPT-5 Plus | |
| # | |
| # Requisiti: | |
| # pip install streamlit yfinance numpy pandas matplotlib | |
| # Avvio: | |
| # streamlit run app.py | |
| # | |
| # Note importanti: | |
| # - Questo strumento è SOLO a scopo didattico, non è consulenza finanziaria. | |
| # - Utilizziamo rendimenti LOG-normali: i log-rendimenti sono gaussiani (Normali), non i rendimenti semplici. | |
| # - La tassazione è semplificata e applicata a fine periodo sui soli guadagni (non gestisce compensazioni/minusvalenze). | |
| # - I dati Yahoo possono essere ritardati; l'aggiornamento dipende dal fuso orario del mercato di riferimento. | |
| # === ENV HOTFIX per Hugging Face Spaces (da mettere PRIMA di importare streamlit/matplotlib) === | |
| import os, tempfile | |
| _tmp = tempfile.gettempdir() | |
| def _first_writable(paths): | |
| for p in paths: | |
| if not p: | |
| continue | |
| try: | |
| os.makedirs(p, exist_ok=True) | |
| # test scrittura | |
| testfile = os.path.join(p, ".write_test") | |
| with open(testfile, "w") as f: | |
| f.write("ok") | |
| os.remove(testfile) | |
| return p | |
| except Exception: | |
| continue | |
| return None | |
| # 1) Directory di config Streamlit (dove Streamlit prova a scrivere ~/.streamlit/*) | |
| # Ordine di tentativi: variabile già impostata -> $HF_HOME/.streamlit -> $HOME/.streamlit -> /tmp/.streamlit | |
| if not os.environ.get("STREAMLIT_CONFIG_DIR"): | |
| candidates = [ | |
| os.environ.get("STREAMLIT_CONFIG_DIR"), | |
| os.path.join(os.environ.get("HF_HOME",""), ".streamlit"), | |
| os.path.join(os.environ.get("HOME",""), ".streamlit"), | |
| os.path.join(_tmp, ".streamlit"), | |
| ] | |
| cfg = _first_writable(candidates) | |
| if cfg is None: | |
| # fallback finale | |
| cfg = os.path.join(_tmp, ".streamlit") | |
| os.makedirs(cfg, exist_ok=True) | |
| os.environ["STREAMLIT_CONFIG_DIR"] = cfg | |
| # 2) MPLCONFIGDIR per Matplotlib | |
| if not os.environ.get("MPLCONFIGDIR"): | |
| mplcfg = _first_writable([ | |
| os.path.join(os.environ.get("XDG_CONFIG_HOME",""), "matplotlib"), | |
| os.path.join(os.environ.get("HOME",""), ".config", "matplotlib"), | |
| os.path.join(_tmp, "mplconfig"), | |
| ]) or os.path.join(_tmp, "mplconfig") | |
| os.makedirs(mplcfg, exist_ok=True) | |
| os.environ["MPLCONFIGDIR"] = mplcfg | |
| # 3) XDG dirs (evita scritture in /) | |
| os.environ.setdefault("XDG_CONFIG_HOME", os.path.join(_tmp, "xdgconfig")) | |
| os.environ.setdefault("XDG_CACHE_HOME", os.path.join(_tmp, "xdgcache")) | |
| # 4) opzionale: niente usage stats (non influisce sul path ma evita altre scritture) | |
| os.environ.setdefault("STREAMLIT_BROWSER_GATHER_USAGE_STATS", "false") | |
| import math | |
| from typing import Tuple, Optional, List, Dict | |
| import numpy as np | |
| import pandas as pd | |
| import matplotlib.pyplot as plt | |
| import streamlit as st | |
| # === PRESETS === | |
| # Preset tickers (indici, ETF, cripto) | |
| PRESETS = { | |
| "—": "", | |
| "Indici (non investibili)": { | |
| "S&P 500 (Index)": "^GSPC", | |
| "NASDAQ-100 (Index)": "^NDX", | |
| "Nasdaq Composite (Index)": "^IXIC", | |
| "Dow Jones (Index)": "^DJI", | |
| "EURO STOXX 50 (Index)": "^STOXX50E", | |
| }, | |
| "ETF (US)": { | |
| "SPDR S&P 500 ETF (SPY)": "SPY", | |
| "iShares Core S&P 500 (IVV)": "IVV", | |
| "Vanguard S&P 500 (VOO)": "VOO", | |
| }, | |
| "ETF (UCITS EU)": { | |
| "Vanguard S&P 500 UCITS (VUSA.L)": "VUSA.L", | |
| "iShares Core MSCI World (IWDA.AS)": "IWDA.AS", | |
| "iShares Core MSCI EM IMI (EIMI.L)": "EIMI.L", | |
| "iShares Core MSCI World (SWDA.L)": "SWDA.L", | |
| }, | |
| "Cripto": { | |
| "Bitcoin (BTC-USD)": "BTC-USD", | |
| "Ethereum (ETH-USD)": "ETH-USD", | |
| "Solana (SOL-USD)": "SOL-USD" | |
| } | |
| } | |
| def preset_selector(label, key_out): | |
| grp = st.selectbox(label + " – gruppo", list(PRESETS.keys()), index=0, key=label+"_grp") | |
| if grp == "—": | |
| return | |
| name = st.selectbox(label + " – preset", list(PRESETS[grp].keys()), key=label+"_name") | |
| ticker_val = PRESETS[grp][name] | |
| st.write(f"Ticker selezionato: **{ticker_val}**") | |
| if st.button("Usa questo ticker", key=label+"_apply"): | |
| st.session_state[key_out] = ticker_val | |
| # yfinance è opzionale: gestiamo un errore amichevole se non presente | |
| try: | |
| import yfinance as yf | |
| YF_OK = True | |
| except Exception: | |
| YF_OK = False | |
| # ----------------------------- | |
| # Stili UI ad alto contrasto | |
| # ----------------------------- | |
| def inject_css(): | |
| st.markdown( | |
| """ | |
| <style> | |
| :root { | |
| --bg: #0b0f14; | |
| --card: #121821; | |
| --text: #f2f5f7; | |
| --muted: #cbd5e1; | |
| --accent: #2dd4bf; | |
| --accent-2: #60a5fa; | |
| --danger: #f87171; | |
| } | |
| .stApp { | |
| background: linear-gradient(180deg, #0b0f14 0%, #0d1117 100%); | |
| color: var(--text) !important; | |
| } | |
| .block-container { | |
| padding-top: 1.2rem; | |
| padding-bottom: 4rem; | |
| } | |
| .stMarkdown, .stText, .stSelectbox, .stNumberInput, .stTextInput, .stButton > button, .stDateInput, .stRadio, .stSlider { | |
| color: var(--text) !important; | |
| } | |
| .stButton > button { | |
| background: linear-gradient(90deg, var(--accent), var(--accent-2)); | |
| border: none; | |
| color: #0b0f14; | |
| font-weight: 700; | |
| border-radius: 12px; | |
| } | |
| .stTabs [data-baseweb="tab-list"] { | |
| gap: 0.5rem; | |
| } | |
| .stTabs [data-baseweb="tab"] { | |
| background-color: var(--card); | |
| border-radius: 10px; | |
| padding: 0.5rem 0.75rem; | |
| color: var(--muted); | |
| } | |
| .stTabs [aria-selected="true"] { | |
| background: linear-gradient(90deg, rgba(45,212,191,0.15), rgba(96,165,250,0.15)); | |
| color: var(--text) !important; | |
| border: 1px solid rgba(96,165,250,0.35); | |
| } | |
| .metric-card { | |
| background: var(--card); | |
| border: 1px solid rgba(148,163,184,0.25); | |
| border-radius: 14px; | |
| padding: 1rem 1.25rem; | |
| } | |
| .note { | |
| font-size: 0.92rem; | |
| color: var(--muted); | |
| background: rgba(148,163,184,0.08); | |
| border: 1px dashed rgba(148,163,184,0.35); | |
| padding: 0.85rem 1rem; | |
| border-radius: 12px; | |
| } | |
| hr { border: none; border-top: 1px solid rgba(148,163,184,0.25); } | |
| .credits { | |
| color: var(--muted); | |
| text-align: center; | |
| margin-top: 1rem; | |
| } | |
| </style> | |
| """, | |
| unsafe_allow_html=True | |
| ) | |
| # ----------------------------- | |
| # Helpers Yahoo Finance | |
| # ----------------------------- | |
| def get_ticker_fast_price(ticker: "yf.Ticker") -> Optional[float]: | |
| """Prova ad ottenere un 'last price' intraday/near-RT da yfinance in modo robusto.""" | |
| try: | |
| fi = getattr(ticker, "fast_info", None) | |
| if fi: | |
| # fast_info può essere dict-like o oggetto | |
| for key in ("last_price", "lastPrice", "regularMarketPrice"): | |
| try: | |
| val = fi[key] if isinstance(fi, dict) else getattr(fi, key, None) | |
| except Exception: | |
| val = None | |
| if val is not None and not math.isnan(val): | |
| return float(val) | |
| except Exception: | |
| pass | |
| # fallback su prezzo più recente disponibile | |
| try: | |
| h = ticker.history(period="5d", interval="1m") | |
| if isinstance(h, pd.DataFrame) and not h.empty: | |
| return float(h["Close"].dropna().iloc[-1]) | |
| except Exception: | |
| pass | |
| try: | |
| h = ticker.history(period="1d") | |
| if isinstance(h, pd.DataFrame) and not h.empty: | |
| return float(h["Close"].dropna().iloc[-1]) | |
| except Exception: | |
| pass | |
| return None | |
| def load_yahoo_monthly(ticker_str: str, years: int = 10) -> Tuple[pd.DataFrame, Optional[float], Optional[str]]: | |
| """Scarica dati giornalieri (auto-adjust) e crea mensili (ultimo close per mese) + log-ret mensili. | |
| Ritorna: (df_mensile, last_price, currency)""" | |
| if not YF_OK: | |
| raise RuntimeError("yfinance non è installato. Esegui: pip install yfinance") | |
| ticker = yf.Ticker(ticker_str) | |
| df = ticker.history(period=f"{max(years,1)}y", interval="1d", auto_adjust=True) | |
| if df is None or df.empty: | |
| raise ValueError("Nessun dato disponibile per il ticker specificato.") | |
| df = df.rename(columns=str.title) # Close, Open, etc. | |
| # >>> FIX: rendiamo l'indice timezone-naive per evitare errori di confronto <<< | |
| if df.index.tz is not None: | |
| df.index = df.index.tz_localize(None) | |
| # pandas future-proof: usare "ME" (month-end) invece di "M" | |
| df_m = df.resample("ME").agg({"Close": "last"}) | |
| df_m["LogRet_M"] = np.log(df_m["Close"] / df_m["Close"].shift(1)) | |
| df_m = df_m.dropna() | |
| last_price = get_ticker_fast_price(ticker) | |
| currency = None | |
| try: | |
| info = getattr(ticker, "fast_info", None) or {} | |
| currency = info.get("currency") if isinstance(info, dict) else getattr(info, "currency", None) | |
| currency = currency or (info.get("Currency") if isinstance(info, dict) else getattr(info, "Currency", None)) | |
| except Exception: | |
| pass | |
| return df_m, last_price, currency | |
| def load_multiple_monthly(tickers: List[str], years: int = 10) -> Tuple[pd.DataFrame, pd.DataFrame, Dict[str, Optional[str]]]: | |
| """Carica più ticker in formato mensile. | |
| Ritorna: (Close_df, LogRet_df, currency_map) con indici timezone-naive.""" | |
| closes = {} | |
| rets = {} | |
| ccys = {} | |
| for t in tickers: | |
| try: | |
| df_m, _, ccy = load_yahoo_monthly(t, years) | |
| closes[t.upper()] = df_m["Close"] | |
| rets[t.upper()] = df_m["LogRet_M"] | |
| ccys[t.upper()] = ccy | |
| except Exception: | |
| continue | |
| if not closes: | |
| raise ValueError("Nessun dato scaricato.") | |
| close_df = pd.DataFrame(closes).dropna() | |
| ret_df = pd.DataFrame(rets).loc[close_df.index].dropna() | |
| return close_df, ret_df, ccys | |
| # ----------------------------- | |
| # Monte Carlo core | |
| # ----------------------------- | |
| def simulate_pac_montecarlo( | |
| months: int, | |
| n_sims: int, | |
| mu_log_annual: float, | |
| sigma_log_annual: float, | |
| initial: float, | |
| monthly: float, | |
| inflation_rate: float, | |
| tax_rate: float, | |
| seed: Optional[int] = None, | |
| contrib_at_end_of_month: bool = True, | |
| ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, float]: | |
| """ | |
| Simula l'evoluzione di un investimento con versamento iniziale + PAC mensile. | |
| Log-rendimenti ~ N(mu_m, sigma_m). | |
| Restituisce: | |
| - paths: matrice (n_sims, months+1) con i valori del portafoglio (lordi) nel tempo | |
| - final_net: vettore (n_sims,) valori finali netti (dopo imposta semplificata) | |
| - max_drawdowns: vettore (n_sims,) massimi drawdown realizzati (0..1) | |
| - invested_total: totale versato (nominale) | |
| """ | |
| if seed is not None: | |
| np.random.seed(seed) | |
| # Convertiamo annuale -> mensile | |
| mu_m = mu_log_annual / 12.0 | |
| sigma_m = sigma_log_annual / math.sqrt(12.0) | |
| # Generiamo log-rendimenti | |
| shocks = np.random.normal(loc=mu_m, scale=sigma_m, size=(n_sims, months)) | |
| factors = np.exp(shocks) # (1+r_m) | |
| # Sequenza dei contributi (aggiustati per inflazione una volta l'anno) | |
| contrib_schedule = np.zeros(months, dtype=float) | |
| for t in range(months): | |
| year_idx = (t // 12) | |
| contrib_schedule[t] = monthly * ((1.0 + inflation_rate) ** year_idx) | |
| invested_total = initial + float(contrib_schedule.sum()) | |
| # Evoluzione portafoglio | |
| paths = np.zeros((n_sims, months + 1), dtype=float) | |
| values = np.full(shape=(n_sims,), fill_value=initial, dtype=float) | |
| paths[:, 0] = values | |
| max_peaks = values.copy() | |
| max_dd = np.zeros(n_sims, dtype=float) | |
| for t in range(months): | |
| values *= factors[:, t] # rendimento mese | |
| if contrib_at_end_of_month and contrib_schedule[t] > 0.0: | |
| values += contrib_schedule[t] | |
| paths[:, t + 1] = values | |
| # drawdown update | |
| max_peaks = np.maximum(max_peaks, values) | |
| dd = 1.0 - (values / np.maximum(max_peaks, 1e-12)) | |
| max_dd = np.maximum(max_dd, dd) | |
| # Tassazione semplificata a fine periodo sui guadagni (se positivi) | |
| gains = values - invested_total | |
| tax = np.where(gains > 0, gains * tax_rate, 0.0) | |
| final_net = values - tax | |
| return paths, final_net, max_dd, invested_total | |
| def percentile_band(arr: np.ndarray, lower=5, upper=95) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: | |
| med = np.percentile(arr, 50, axis=0) | |
| lo = np.percentile(arr, lower, axis=0) | |
| hi = np.percentile(arr, upper, axis=0) | |
| return med, lo, hi | |
| # ----------------------------- | |
| # Backtest PAC storico (singolo ticker) | |
| # ----------------------------- | |
| def backtest_pac_storico( | |
| df_m: pd.DataFrame, | |
| start_date: Optional[pd.Timestamp], | |
| initial: float, | |
| monthly: float, | |
| inflation_rate: float, | |
| ) -> pd.DataFrame: | |
| """ | |
| Simula un PAC storico acquistando a fine mese al prezzo di chiusura mensile. | |
| Restituisce DataFrame con colonne: Close, Contrib, ContribCum, Shares, Value, Invested, PnL, Return. | |
| """ | |
| if df_m is None or df_m.empty: | |
| raise ValueError("Serie storica non disponibile.") | |
| # Assicuriamoci che l'indice sia timezone-naive | |
| if df_m.index.tz is not None: | |
| df_m = df_m.copy() | |
| df_m.index = df_m.index.tz_localize(None) | |
| # filtro finestra con start_date timezone-naive | |
| if start_date is not None: | |
| start_date = pd.Timestamp(start_date).tz_localize(None) | |
| df = df_m[df_m.index >= start_date].copy() | |
| else: | |
| df = df_m.copy() | |
| if df.empty: | |
| raise ValueError("Finestra selezionata vuota.") | |
| # inizializzazioni | |
| df["Contrib"] = 0.0 | |
| df["ContribCum"] = 0.0 | |
| df["Shares"] = 0.0 | |
| df["Value"] = 0.0 | |
| df["Invested"] = 0.0 | |
| df["PnL"] = 0.0 | |
| df["Return"] = 0.0 | |
| shares = 0.0 | |
| invested = 0.0 | |
| # versamento iniziale al primo mese | |
| first_price = float(df["Close"].iloc[0]) | |
| shares += initial / first_price if first_price > 0 else 0.0 | |
| invested += initial | |
| df.iloc[0, df.columns.get_loc("Contrib")] = initial | |
| for i, (date, row) in enumerate(df.iterrows()): | |
| price = float(row["Close"]) | |
| if i > 0: | |
| years_since_start = (date.year - df.index[0].year) | |
| adj_monthly = monthly * ((1.0 + inflation_rate) ** years_since_start) | |
| if price > 0 and adj_monthly > 0: | |
| add_sh = adj_monthly / price | |
| shares += add_sh | |
| invested += adj_monthly | |
| df.iloc[i, df.columns.get_loc("Contrib")] = adj_monthly | |
| value = shares * price | |
| df.iloc[i, df.columns.get_loc("Shares")] = shares | |
| df.iloc[i, df.columns.get_loc("Value")] = value | |
| df.iloc[i, df.columns.get_loc("Invested")] = invested | |
| df.iloc[i, df.columns.get_loc("ContribCum")] = df["Contrib"].iloc[: i + 1].sum() | |
| df.iloc[i, df.columns.get_loc("PnL")] = value - invested | |
| df.iloc[i, df.columns.get_loc("Return")] = (value / invested - 1.0) if invested > 0 else 0.0 | |
| return df | |
| # ----------------------------- | |
| # Backtest PAC storico (multi-titolo) | |
| # ----------------------------- | |
| def backtest_pac_portafoglio( | |
| close_df: pd.DataFrame, | |
| weights: Dict[str, float], | |
| start_date: Optional[pd.Timestamp], | |
| initial: float, | |
| monthly: float, | |
| inflation_rate: float, | |
| ) -> pd.DataFrame: | |
| """ | |
| Backtest di un PAC su più titoli con acquisti a fine mese, ripartizione dei versamenti secondo i pesi. | |
| Ritorna DataFrame con colonne: Value, Invested, PnL, Return e colonne Shares_<T> per titoli. | |
| """ | |
| if close_df is None or close_df.empty: | |
| raise ValueError("Serie storica non disponibile.") | |
| df = close_df.copy() | |
| if df.index.tz is not None: | |
| df.index = df.index.tz_localize(None) | |
| if start_date is not None: | |
| start_date = pd.Timestamp(start_date).tz_localize(None) | |
| df = df[df.index >= start_date] | |
| tickers = [t for t in weights.keys() if t in df.columns] | |
| if not tickers: | |
| raise ValueError("Nessun ticker dei pesi è presente nella serie caricata.") | |
| w = np.array([weights[t] for t in tickers], dtype=float) | |
| w = w / w.sum() | |
| # inizializza | |
| for t in tickers: | |
| df[f"Shares_{t}"] = 0.0 | |
| df["Invested"] = 0.0 | |
| df["Value"] = 0.0 | |
| df["PnL"] = 0.0 | |
| df["Return"] = 0.0 | |
| shares = {t: 0.0 for t in tickers} | |
| invested = 0.0 | |
| # versamento iniziale | |
| first_row = df.iloc[0] | |
| for j, t in enumerate(tickers): | |
| px = float(first_row[t]) | |
| alloc = initial * w[j] | |
| if px > 0: | |
| shares[t] += alloc / px | |
| invested += initial | |
| for t in tickers: | |
| df.iloc[0, df.columns.get_loc(f"Shares_{t}")] = shares[t] | |
| # loop mensile | |
| for i, (date, row) in enumerate(df.iterrows()): | |
| if i > 0: | |
| years_since_start = (date.year - df.index[0].year) | |
| adj_monthly = monthly * ((1.0 + inflation_rate) ** years_since_start) | |
| for j, t in enumerate(tickers): | |
| px = float(row[t]) | |
| alloc = adj_monthly * w[j] | |
| if px > 0 and alloc > 0: | |
| shares[t] += alloc / px | |
| invested += adj_monthly | |
| for t in tickers: | |
| df.iloc[i, df.columns.get_loc(f"Shares_{t}")] = shares[t] | |
| # valore portafoglio corrente | |
| value = 0.0 | |
| for t in tickers: | |
| value += shares[t] * float(row[t]) | |
| df.iloc[i, df.columns.get_loc("Value")] = value | |
| df.iloc[i, df.columns.get_loc("Invested")] = invested | |
| df.iloc[i, df.columns.get_loc("PnL")] = value - invested | |
| df.iloc[i, df.columns.get_loc("Return")] = (value / invested - 1.0) if invested > 0 else 0.0 | |
| return df | |
| # ----------------------------- | |
| # Charting helpers (matplotlib ONLY) | |
| # ----------------------------- | |
| def plot_paths_with_band(paths: np.ndarray, months: int, invested_total: float, sample_paths: int = 50): | |
| fig, ax = plt.subplots(figsize=(9, 4.8)) | |
| p50, p10, p90 = percentile_band(paths[:, 1:], 10, 90) | |
| x = np.arange(1, months + 1) | |
| ax.fill_between(x, p10, p90, alpha=0.25, label="Banda 10–90°p") | |
| ax.plot(x, p50, linewidth=2.2, label="Mediana") | |
| ns = min(sample_paths, paths.shape[0]) | |
| idx = np.random.choice(paths.shape[0], ns, replace=False) | |
| for i in idx: | |
| ax.plot(x, paths[i, 1:], linewidth=0.8, alpha=0.25) | |
| ax.axhline(invested_total, linestyle="--", linewidth=1.2, label="Capitale versato") | |
| ax.set_xlabel("Mesi") | |
| ax.set_ylabel("Valore portafoglio (€)") | |
| ax.set_title("Evoluzione simulata del capitale") | |
| ax.legend() | |
| fig.tight_layout() | |
| return fig | |
| def plot_hist_final(vals: np.ndarray, invested_total: float): | |
| fig, ax = plt.subplots(figsize=(9, 4.8)) | |
| ax.hist(vals, bins=60, alpha=0.85) | |
| med = np.median(vals) | |
| q_lo, q_hi = np.percentile(vals, [2.5, 97.5]) | |
| ax.axvline(med, linestyle="-", linewidth=2.0, label=f"Mediana: {med:,.0f}€") | |
| ax.axvline(invested_total, linestyle="--", linewidth=1.5, label="Capitale versato") | |
| ax.axvline(q_lo, linestyle=":", linewidth=1.2, label=f"2.5°p: {q_lo:,.0f}€") | |
| ax.axvline(q_hi, linestyle=":", linewidth=1.2, label=f"97.5°p: {q_hi:,.0f}€") | |
| ax.set_xlabel("Valore finale netto (€)") | |
| ax.set_ylabel("Frequenza") | |
| ax.set_title("Distribuzione del capitale finale (netto)") | |
| ax.legend() | |
| fig.tight_layout() | |
| return fig | |
| def plot_hist_drawdown(dd: np.ndarray): | |
| fig, ax = plt.subplots(figsize=(9, 4.8)) | |
| ax.hist(dd * 100.0, bins=50, alpha=0.85) | |
| med = np.median(dd) * 100.0 | |
| q_lo, q_hi = np.percentile(dd, [2.5, 97.5]) | |
| ax.axvline(med, linestyle="-", linewidth=2.0, label=f"Mediana: {med:.1f}%") | |
| ax.axvline(q_lo * 100.0, linestyle=":", linewidth=1.2, label=f"2.5°p: {q_lo*100:.1f}%") | |
| ax.axvline(q_hi * 100.0, linestyle=":", linewidth=1.2, label=f"97.5°p: {q_hi*100:.1f}%") | |
| ax.set_xlabel("Massimo Drawdown (%)") | |
| ax.set_ylabel("Frequenza") | |
| ax.set_title("Distribuzione dei massimi drawdown (lordi)") | |
| ax.legend() | |
| fig.tight_layout() | |
| return fig | |
| def plot_backtest(df_bt: pd.DataFrame, title: str): | |
| fig, ax = plt.subplots(figsize=(9, 4.6)) | |
| ax.plot(df_bt.index, df_bt["Value"], linewidth=2.0, label="Valore PAC") | |
| ax.plot(df_bt.index, df_bt["Invested"], linestyle="--", linewidth=1.2, label="Capitale versato") | |
| ax.set_title(title) | |
| ax.set_ylabel("Valore (€)") | |
| ax.legend() | |
| fig.tight_layout() | |
| return fig | |
| def plot_bar_last_month(chg_map: Dict[str, float]): | |
| tickers = list(chg_map.keys()) | |
| vals = [chg_map[t] for t in tickers] | |
| x = np.arange(len(tickers)) | |
| fig, ax = plt.subplots(figsize=(9, 4.6)) | |
| ax.bar(x, vals) | |
| ax.set_xticks(x) | |
| ax.set_xticklabels(tickers) | |
| ax.set_ylabel("Rendimento ultimo mese (%)") | |
| ax.set_title("Variazione % ultimo mese per ticker") | |
| fig.tight_layout() | |
| return fig | |
| # ----------------------------- | |
| # App | |
| # ----------------------------- | |
| def main(): | |
| st.set_page_config(page_title="Simulatore Monte Carlo PAC & Azioni/ETF/Cripto (mensile)", page_icon="📈", layout="wide") | |
| inject_css() | |
| st.title("📈 Simulatore Monte Carlo di un PAC e di Azioni/ETF/Cripto (mensile)") | |
| st.caption("Dati storici/near real-time da Yahoo Finance • I risultati sono a fini didattici.") | |
| # Tabs principali (aggiunta tab Cripto) | |
| tab_sim, tab_backtest, tab_multi, tab_pf, tab_crypto = st.tabs([ | |
| "🎲 Simulazione Monte Carlo", | |
| "⏱️ Backtest PAC Storico", | |
| "📊 Overview Multi-Ticker", | |
| "📦 Portafoglio PAC multi-titolo", | |
| "💱 Cripto" | |
| ]) | |
| # =============== TAB SIMULAZIONE =============== | |
| with tab_sim: | |
| left, right = st.columns([0.9, 1.1], gap="large") | |
| with left: | |
| st.subheader("🔧 Parametri di simulazione") | |
| with st.container(): | |
| data_mode = st.radio( | |
| "Fonte parametri di rendimento", | |
| options=["Manuale (log-rendimenti annualizzati)", "Storico Yahoo (stima automatica)"], | |
| index=1, | |
| help="Nel caso Manuale, inserisci media e deviazione standard dei LOG-rendimenti annuali.\n" | |
| "Nel caso Storico, stimiamo i parametri dai log-rendimenti mensili del ticker scelto." | |
| ) | |
| preset_selector("Ticker (Simulazione)", "sim_ticker") | |
| ticker_str = st.text_input( | |
| "Ticker Yahoo (es. AAPL, MSFT, ^GSPC, VUSA.L, BTC-USD)", | |
| value=st.session_state.get("sim_ticker", "AAPL"), | |
| key="sim_ticker" | |
| ) | |
| est_years = st.slider("Anni storici per la stima", min_value=3, max_value=20, value=10, key="sim_years") | |
| get_data = st.button("📥 Carica dati dal ticker", key="sim_getdata") | |
| if data_mode == "Manuale (log-rendimenti annualizzati)": | |
| mu_a = st.number_input("Media annua dei log-rendimenti (es. 0.07 = 7%)", value=0.07, step=0.005, format="%.4f") | |
| sigma_a = st.number_input("Deviazione standard annua dei log-rendimenti (es. 0.20 = 20%)", value=0.20, step=0.01, format="%.4f") | |
| else: | |
| mu_a = None | |
| sigma_a = None | |
| st.markdown("---") | |
| st.subheader("💶 Piano di versamenti") | |
| initial = st.number_input("Versamento iniziale (€)", value=1000.0, step=100.0, min_value=0.0, key="sim_initial") | |
| monthly = st.number_input("Versamento mensile (€)", value=300.0, step=50.0, min_value=0.0, key="sim_monthly") | |
| years = st.slider("Orizzonte d'investimento (anni)", min_value=1, max_value=40, value=15, key="sim_horizon") | |
| inflation = st.number_input("Adeguamento annuo % della quota mensile (inflazione attesa)", value=0.0, step=0.25, format="%.2f", key="sim_infl") | |
| inflation_rate = inflation / 100.0 | |
| st.markdown("---") | |
| st.subheader("⚙️ Opzioni Monte Carlo") | |
| n_sims = st.slider("Numero di simulazioni", min_value=500, max_value=20000, value=5000, step=500, key="sim_nsims") | |
| tax_choice = st.selectbox("Tassazione su plusvalenza a fine periodo", options=["0%", "12.5%", "26%"], index=2, key="sim_tax") | |
| tax_rate = {"0%": 0.0, "12.5%": 0.125, "26%": 0.26}[tax_choice] | |
| seed = st.number_input("Seed casuale (opzionale, per replicare i risultati)", value=0, step=1, key="sim_seed") | |
| seed = int(seed) if seed != 0 else None | |
| run = st.button("🚀 Esegui simulazione", use_container_width=True, key="sim_run") | |
| # Stato dati Yahoo | |
| df_m = None | |
| last_price = None | |
| currency = None | |
| mu_est_m, sigma_est_m = None, None | |
| if data_mode == "Storico Yahoo (stima automatica)": | |
| if get_data: | |
| with st.spinner("Scarico e preparo i dati..."): | |
| try: | |
| df_m, last_price, currency = load_yahoo_monthly(ticker_str, est_years) | |
| mu_est_m = float(df_m["LogRet_M"].mean()) | |
| sigma_est_m = float(df_m["LogRet_M"].std(ddof=1)) | |
| except Exception as e: | |
| st.error(f"Errore nel caricamento dati: {e}") | |
| elif YF_OK and ticker_str.strip(): | |
| try: | |
| df_m, last_price, currency = load_yahoo_monthly(ticker_str, est_years) | |
| mu_est_m = float(df_m["LogRet_M"].mean()) | |
| sigma_est_m = float(df_m["LogRet_M"].std(ddof=1)) | |
| except Exception: | |
| pass | |
| with right: | |
| st.subheader("📊 Dati del titolo (Yahoo) e parametri") | |
| with st.container(): | |
| if YF_OK and df_m is not None and not df_m.empty: | |
| col1, col2, col3, col4 = st.columns(4) | |
| last_close = float(df_m["Close"].iloc[-1]) | |
| prev_close = float(df_m["Close"].iloc[-2]) if len(df_m) > 1 else last_close | |
| m_ret = (last_close / prev_close - 1.0) * 100.0 | |
| with col1: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.metric("Ultimo close mensile", f"{last_close:,.2f} {currency or ''}", delta=f"{m_ret:+.2f}% vs mese prec.") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| with col2: | |
| if last_price is not None: | |
| delta_vs_last_close = (last_price / last_close - 1.0) * 100.0 | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.metric("Prezzo attuale (near RT)", f"{last_price:,.2f} {currency or ''}", delta=f"{delta_vs_last_close:+.2f}% vs ultimo close") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| else: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.metric("Prezzo attuale", "n/d", delta="") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| with col3: | |
| if mu_est_m is not None and sigma_est_m is not None: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.write("Media log-ret. mensile stimata") | |
| st.write(f"**{mu_est_m*100:.2f}%**") | |
| st.write("Dev. std log-ret. mensile stimata") | |
| st.write(f"**{sigma_est_m*100:.2f}%**") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| with col4: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.write("Campione storico (mesi)") | |
| st.write(f"**{len(df_m)}**") | |
| st.write("Finestra (anni)") | |
| st.write(f"**{est_years}**") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| fig_price, axp = plt.subplots(figsize=(9, 3.2)) | |
| axp.plot(df_m.index, df_m["Close"], linewidth=2.0) | |
| axp.set_title(f"Andamento mensile di {ticker_str.upper()} (ultimi {est_years} anni)") | |
| axp.set_ylabel(f"Prezzo ({currency or ''})") | |
| axp.grid(alpha=0.2) | |
| st.pyplot(fig_price, use_container_width=True) | |
| elif not YF_OK: | |
| st.warning("Il pacchetto 'yfinance' non è installato. Esegui `pip install yfinance` per usare i dati Yahoo.") | |
| else: | |
| st.info("Inserisci un ticker valido e premi **Carica dati** per ottenere i parametri storici.") | |
| st.markdown("---") | |
| # Determinazione parametri finali per la simulazione | |
| months = years * 12 | |
| if data_mode == "Manuale (log-rendimenti annualizzati)": | |
| mu_log_annual = float(mu_a) | |
| sigma_log_annual = float(sigma_a) | |
| else: | |
| if mu_est_m is not None and sigma_est_m is not None: | |
| mu_log_annual = mu_est_m * 12.0 | |
| sigma_log_annual = sigma_est_m * math.sqrt(12.0) | |
| else: | |
| mu_log_annual = 0.07 | |
| sigma_log_annual = 0.20 | |
| st.subheader("🎲 Simulazione Monte Carlo") | |
| st.write(f"Parametri usati (annuali, log): **μ = {mu_log_annual:.4f}**, **σ = {sigma_log_annual:.4f}**") | |
| if run: | |
| with st.spinner("Eseguo la simulazione..."): | |
| paths, final_net, max_dd, invested_total = simulate_pac_montecarlo( | |
| months=months, | |
| n_sims=n_sims, | |
| mu_log_annual=mu_log_annual, | |
| sigma_log_annual=sigma_log_annual, | |
| initial=initial, | |
| monthly=monthly, | |
| inflation_rate=inflation_rate, | |
| tax_rate=tax_rate, | |
| seed=seed, | |
| contrib_at_end_of_month=True, | |
| ) | |
| # Risultati testuali | |
| med_final = float(np.median(final_net)) | |
| lo_final, hi_final = np.percentile(final_net, [2.5, 97.5]) | |
| med_dd = float(np.median(max_dd)) | |
| lo_dd, hi_dd = np.percentile(max_dd, [2.5, 97.5]) | |
| p_loss = float(np.mean(final_net < invested_total)) * 100.0 | |
| c1, c2, c3 = st.columns(3) | |
| with c1: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.write("Capitale versato (nominale)") | |
| st.write(f"**{invested_total:,.0f} €**") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| with c2: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.write("Valore finale netto (mediana; 95% CI)") | |
| st.write(f"**{med_final:,.0f} €**") | |
| st.write(f"_Intervallo 95%: {lo_final:,.0f} – {hi_final:,.0f} €_") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| with c3: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.write("Max drawdown lordo (mediana; 95% CI)") | |
| st.write(f"**{med_dd*100:.1f}%**") | |
| st.write(f"_Intervallo 95%: {lo_dd*100:.1f}% – {hi_dd*100:.1f}%_") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| st.markdown( | |
| f"**Probabilità di recuperare meno di quanto investito:** {p_loss:.1f}% " | |
| f"(criterio: _valore finale netto < capitale versato_)." | |
| ) | |
| # Grafici | |
| st.markdown("### 📈 Evoluzione del capitale") | |
| fig1 = plot_paths_with_band(paths, months, invested_total, sample_paths=50) | |
| st.pyplot(fig1, use_container_width=True) | |
| st.markdown("### 📦 Distribuzione del capitale finale (netto)") | |
| fig2 = plot_hist_final(final_net, invested_total) | |
| st.pyplot(fig2, use_container_width=True) | |
| st.markdown("### 📉 Distribuzione dei massimi drawdown (lordi)") | |
| fig3 = plot_hist_drawdown(max_dd) | |
| st.pyplot(fig3, use_container_width=True) | |
| st.markdown("---") | |
| st.subheader("ℹ️ Come viene effettuata la simulazione Monte Carlo") | |
| st.markdown( | |
| """ | |
| <div class="note"> | |
| <ul> | |
| <li>Modelliamo i <b>log-rendimenti mensili</b> come variabili Normali indipendenti e identicamente distribuite, | |
| con media e deviazione standard impostate (o stimate dai dati Yahoo). Il prezzo/progresso del portafoglio | |
| evolve moltiplicando per <code>exp(r_t)</code>, dove <code>r_t</code> è il log-rendimento del mese <i>t</i>.</li> | |
| <li>I <b>versamenti</b> consistono in un importo iniziale e in una quota mensile, che può essere adeguata annualmente | |
| all'inflazione attesa. La quota mensile viene aggiunta a <i>fine mese</i> dopo l'applicazione del rendimento.</li> | |
| <li>Il <b>massimo drawdown</b> è calcolato come il massimo calo percentuale dal picco al valore successivo | |
| all'interno di ciascun percorso simulato.</li> | |
| <li>La <b>tassazione</b> è applicata in modo semplificato a fine periodo, come percentuale fissa (0%, 12.5% o 26%) | |
| sulle sole <i>plusvalenze</i> (se presenti). Non consideriamo commissioni, costi, dividendi o altre imposte.</li> | |
| <li>Gli esiti sono distribuzioni: riportiamo <b>mediana</b>, <b>intervallo al 95%</b> e la <b>probabilità di chiudere in perdita</b>.</li> | |
| </ul> | |
| </div> | |
| """, | |
| unsafe_allow_html=True | |
| ) | |
| # =============== TAB BACKTEST =============== | |
| with tab_backtest: | |
| st.subheader("⏱️ Backtest PAC Storico (ticker singolo)") | |
| preset_selector("Ticker (Backtest)", "bt_ticker") | |
| ticker_bt = st.text_input("Ticker Yahoo (es. AAPL, ^GSPC, VUSA.L, BTC-USD)", | |
| value=st.session_state.get("bt_ticker", "AAPL"), | |
| key="bt_ticker") | |
| years_bt = st.slider("Anni storici da scaricare", 3, 30, 15, key="bt_years") | |
| initial_bt = st.number_input("Versamento iniziale (€)", value=1000.0, step=100.0, min_value=0.0, key="bt_initial") | |
| monthly_bt = st.number_input("Versamento mensile (€)", value=300.0, step=50.0, min_value=0.0, key="bt_monthly") | |
| infl_bt = st.number_input("Adeguamento annuo % della quota mensile", value=0.0, step=0.25, format="%.2f", key="bt_infl") | |
| infl_rate_bt = infl_bt / 100.0 | |
| df_bt = None | |
| if YF_OK and ticker_bt.strip(): | |
| try: | |
| df_m_bt, last_price_bt, ccy_bt = load_yahoo_monthly(ticker_bt, years_bt) | |
| min_date = df_m_bt.index.min() | |
| max_date = df_m_bt.index.max() | |
| start_date = st.date_input("Data di inizio PAC", min_value=min_date.date(), max_value=max_date.date(), value=min_date.date(), key="bt_start") | |
| run_bt = st.button("▶️ Calcola backtest", key="bt_run") | |
| if run_bt: | |
| df_bt = backtest_pac_storico(df_m_bt, pd.to_datetime(start_date), initial_bt, monthly_bt, infl_rate_bt) | |
| except Exception as e: | |
| st.error(f"Errore nel caricamento dati: {e}") | |
| else: | |
| st.info("Inserisci un ticker valido.") | |
| if df_bt is not None: | |
| latest_row = df_bt.iloc[-1] | |
| invested = float(latest_row["Invested"]) | |
| value = float(latest_row["Value"]) | |
| pnl = float(latest_row["PnL"]) | |
| ret = float(latest_row["Return"]) * 100.0 | |
| c1, c2, c3, c4 = st.columns(4) | |
| with c1: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.write("Investito totale") | |
| st.write(f"**{invested:,.0f} €**") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| with c2: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.write("Valore PAC attuale") | |
| st.write(f"**{value:,.0f} €**") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| with c3: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.write("P&L assoluto") | |
| st.write(f"**{pnl:,.0f} €**") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| with c4: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.write("Rendimento su investito") | |
| st.write(f"**{ret:.2f}%**") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| st.markdown("### 📈 Andamento PAC vs Capitale versato") | |
| fig_bt = plot_backtest(df_bt, f"Backtest PAC storico su {ticker_bt.upper()}") | |
| st.pyplot(fig_bt, use_container_width=True) | |
| st.markdown("### 📋 Dettaglio mensile (ultimi 24 mesi)") | |
| st.dataframe(df_bt.tail(24)[["Close", "Contrib", "ContribCum", "Shares", "Value", "Invested", "PnL", "Return"]]) | |
| st.markdown( | |
| """ | |
| *Metodo:* acquisti al **close di fine mese**; la rata cresce ogni anno del tasso d'inflazione impostato. | |
| Non considera commissioni, dividendi e tasse. | |
| """ | |
| ) | |
| # =============== TAB MULTI-TICKER =============== | |
| with tab_multi: | |
| st.subheader("📊 Overview Multi-Ticker (up/down & performance)") | |
| tickers_raw = st.text_input("Lista ticker separati da virgola (es. AAPL, MSFT, NVDA, ^GSPC, VUSA.L, BTC-USD)", | |
| value="AAPL, MSFT, NVDA, ^GSPC, VUSA.L, BTC-USD", key="mt_tickers") | |
| years_mt = st.slider("Anni storici da scaricare", 1, 15, 5, key="mt_years") | |
| run_mt = st.button("📥 Carica e calcola overview", key="mt_run") | |
| if run_mt: | |
| tickers = [t.strip() for t in tickers_raw.split(",") if t.strip()] | |
| summary_rows = [] | |
| last_month_map = {} | |
| if not YF_OK: | |
| st.warning("Per la overview è necessario 'yfinance'. Esegui `pip install yfinance`.") | |
| else: | |
| for t in tickers: | |
| try: | |
| df_m_t, lp, ccy = load_yahoo_monthly(t, years_mt) | |
| if df_m_t is None or df_m_t.empty or len(df_m_t) < 2: | |
| continue | |
| # statistiche | |
| df_m_t["Chg%"] = df_m_t["Close"].pct_change() * 100.0 | |
| last_month = float(df_m_t["Chg%"].iloc[-1]) | |
| pos_12 = int((df_m_t["Chg%"].tail(12) > 0).sum()) | |
| neg_12 = int((df_m_t["Chg%"].tail(12) < 0).sum()) | |
| # YTD e 12M | |
| cur_year = df_m_t.index[-1].year | |
| ytd_mask = df_m_t.index.year == cur_year | |
| ytd = float((df_m_t["Close"].iloc[-1] / df_m_t.loc[ytd_mask, "Close"].iloc[0] - 1.0) * 100.0) if ytd_mask.any() else float("nan") | |
| ret_12m = float((df_m_t["Close"].iloc[-1] / df_m_t["Close"].iloc[-13] - 1.0) * 100.0) if len(df_m_t) > 13 else float("nan") | |
| summary_rows.append({ | |
| "Ticker": t.upper(), | |
| "Ultimo close": float(df_m_t["Close"].iloc[-1]), | |
| "Ultimo mese %": last_month, | |
| "YTD %": ytd, | |
| "12M %": ret_12m, | |
| "Mesi ↑ (12)": pos_12, | |
| "Mesi ↓ (12)": neg_12, | |
| }) | |
| last_month_map[t.upper()] = last_month | |
| except Exception: | |
| pass | |
| if len(summary_rows) == 0: | |
| st.error("Nessun dato disponibile per i ticker indicati.") | |
| else: | |
| df_sum = pd.DataFrame(summary_rows).set_index("Ticker") | |
| st.markdown("### 🧾 Riepilogo performance e up/down (ultimi 12 mesi)") | |
| st.dataframe(df_sum) | |
| st.markdown("### 📊 Variazione % ultimo mese per ticker") | |
| fig_bar = plot_bar_last_month(last_month_map) | |
| st.pyplot(fig_bar, use_container_width=True) | |
| # =============== TAB PORTAFOGLIO MULTI-TITOLO =============== | |
| with tab_pf: | |
| st.subheader("📦 PAC multi-titolo: backtest storico e Monte Carlo del portafoglio") | |
| st.markdown("Suggerimento: puoi combinare **indici** (es. ^GSPC), **ETF** (es. VUSA.L, IWDA.AS) e **cripto** (es. BTC-USD, ETH-USD) insieme alle azioni.") | |
| tickers_pf_raw = st.text_input("Ticker (es. AAPL, ^GSPC, VUSA.L, BTC-USD)", | |
| value="AAPL, ^GSPC, VUSA.L, BTC-USD", key="pf_tickers_str") | |
| weights_pf_raw = st.text_input("Pesi (%) corrispondenti (es. 40, 30, 20, 10)", value="40, 30, 20, 10", key="pf_weights_str") | |
| years_pf = st.slider("Anni storici da scaricare", 3, 20, 10, key="pf_years") | |
| initial_pf = st.number_input("Versamento iniziale (€)", value=2000.0, step=100.0, min_value=0.0, key="pf_initial") | |
| monthly_pf = st.number_input("Versamento mensile (€)", value=600.0, step=50.0, min_value=0.0, key="pf_monthly") | |
| infl_pf = st.number_input("Adeguamento annuo % della quota mensile", value=0.0, step=0.25, format="%.2f", key="pf_infl") | |
| infl_rate_pf = infl_pf / 100.0 | |
| tax_choice_pf = st.selectbox("Tassazione su plusvalenza a fine periodo", options=["0%", "12.5%", "26%"], index=2, key="pf_tax") | |
| tax_rate_pf = {"0%": 0.0, "12.5%": 0.125, "26%": 0.26}[tax_choice_pf] | |
| run_pf_load = st.button("📥 Carica prezzi e prepara portafoglio", key="pf_load") | |
| # Session state per mantenere dati fra i rerun | |
| st.session_state.setdefault("pf_close_df", None) | |
| st.session_state.setdefault("pf_ret_df", None) | |
| st.session_state.setdefault("pf_ccys", None) | |
| st.session_state.setdefault("pf_weights_map", None) | |
| st.session_state.setdefault("pf_tickers_list", None) | |
| tickers_pf = [t.strip().upper() for t in tickers_pf_raw.split(",") if t.strip()] | |
| try: | |
| weights_list = [float(x.strip().replace(",", ".")) for x in weights_pf_raw.split(",")] | |
| except Exception: | |
| weights_list = [] | |
| if not YF_OK: | |
| st.error("Per il portafoglio è necessario 'yfinance' (pip install yfinance).") | |
| elif run_pf_load: | |
| if len(tickers_pf) == 0 or len(weights_list) != len(tickers_pf): | |
| st.error("Numero di pesi non coerente con il numero di ticker.") | |
| else: | |
| weights_arr = np.array(weights_list, dtype=float) | |
| if weights_arr.sum() <= 0: | |
| st.error("La somma dei pesi deve essere positiva.") | |
| else: | |
| weights_arr = weights_arr / weights_arr.sum() | |
| weights_pf_map = {t: w for t, w in zip(tickers_pf, weights_arr)} | |
| try: | |
| close_df, ret_df, ccys = load_multiple_monthly(tickers_pf, years_pf) | |
| st.session_state["pf_close_df"] = close_df | |
| st.session_state["pf_ret_df"] = ret_df | |
| st.session_state["pf_ccys"] = ccys | |
| st.session_state["pf_weights_map"] = weights_pf_map | |
| st.session_state["pf_tickers_list"] = tickers_pf | |
| except Exception as e: | |
| st.error(f"Errore durante la preparazione del portafoglio: {e}") | |
| # --- Usa sempre i dati dalla sessione --- | |
| close_df = st.session_state.get("pf_close_df", None) | |
| ret_df = st.session_state.get("pf_ret_df", None) | |
| weights_pf = st.session_state.get("pf_weights_map", None) | |
| tickers_pf = st.session_state.get("pf_tickers_list", tickers_pf) | |
| if close_df is None or weights_pf is None: | |
| st.info("Imposta i ticker/pesi e premi **Carica prezzi e prepara portafoglio**.") | |
| else: | |
| min_date = close_df.index.min() | |
| max_date = close_df.index.max() | |
| start_date_pf = st.date_input( | |
| "Data di inizio PAC portafoglio", | |
| min_value=min_date.date(), | |
| max_value=max_date.date(), | |
| value=min_date.date(), | |
| key="pf_start" | |
| ) | |
| # Backtest | |
| if st.button("▶️ Backtest portafoglio", key="pf_run_bt"): | |
| df_pf_bt = backtest_pac_portafoglio(close_df, weights_pf, pd.to_datetime(start_date_pf), initial_pf, monthly_pf, infl_rate_pf) | |
| last = df_pf_bt.iloc[-1] | |
| invested = float(last["Invested"]); value = float(last["Value"]) | |
| pnl = float(last["PnL"]); ret = float(last["Return"]) * 100.0 | |
| c1, c2, c3, c4 = st.columns(4) | |
| with c1: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.write("Investito totale"); st.write(f"**{invested:,.0f} €**") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| with c2: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.write("Valore portafoglio"); st.write(f"**{value:,.0f} €**") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| with c3: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.write("P&L assoluto"); st.write(f"**{pnl:,.0f} €**") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| with c4: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.write("Rendimento su investito"); st.write(f"**{ret:.2f}%**") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| st.markdown("### 📈 Andamento PAC portafoglio vs Capitale versato") | |
| fig_pfb = plot_backtest(df_pf_bt, "Backtest PAC storico del portafoglio") | |
| st.pyplot(fig_pfb, use_container_width=True) | |
| st.markdown("### 📋 Dettaglio mensile (ultimi 24 mesi)") | |
| show_cols = [c for c in df_pf_bt.columns if c.startswith("Shares_")] + ["Value", "Invested", "PnL", "Return"] | |
| st.dataframe(df_pf_bt.tail(24)[show_cols]) | |
| # Monte Carlo (parametri stimati dal portafoglio) | |
| if ret_df is not None and not ret_df.empty: | |
| ret_df2 = ret_df[[t for t in tickers_pf if t in ret_df.columns]].dropna() | |
| w_vec = np.array([weights_pf[t] for t in ret_df2.columns], dtype=float) | |
| w_vec = w_vec / w_vec.sum() | |
| mu_m_vec = ret_df2.mean().values | |
| cov_m = ret_df2.cov().values | |
| mu_p_m = float(np.dot(w_vec, mu_m_vec)) | |
| sigma_p_m = float(np.sqrt(np.dot(w_vec, np.dot(cov_m, w_vec)))) | |
| mu_p_a = mu_p_m * 12.0 | |
| sigma_p_a = sigma_p_m * math.sqrt(12.0) | |
| st.markdown("### 🎲 Monte Carlo sul portafoglio (parametri stimati)") | |
| st.write(f"Parametri portafoglio (annuali, log): **μ = {mu_p_a:.4f}**, **σ = {sigma_p_a:.4f}**") | |
| years_pf_h = st.slider("Orizzonte d'investimento (anni) per la simulazione", 1, 40, 15, key="pf_horizon") | |
| n_sims_pf = st.slider("Numero di simulazioni", 500, 20000, 5000, 500, key="pf_nsims") | |
| seed_pf = st.number_input("Seed casuale (opzionale)", value=0, step=1, key="pf_seed") | |
| seed_pf = int(seed_pf) if seed_pf != 0 else None | |
| if st.button("🚀 Esegui simulazione portafoglio", key="pf_run_mc"): | |
| months_pf = years_pf_h * 12 | |
| paths_pf, final_pf, dd_pf, invested_pf = simulate_pac_montecarlo( | |
| months=months_pf, | |
| n_sims=n_sims_pf, | |
| mu_log_annual=mu_p_a, | |
| sigma_log_annual=sigma_p_a, | |
| initial=initial_pf, | |
| monthly=monthly_pf, | |
| inflation_rate=infl_rate_pf, | |
| tax_rate=tax_rate_pf, | |
| seed=seed_pf, | |
| ) | |
| med_final = float(np.median(final_pf)) | |
| lo_final, hi_final = np.percentile(final_pf, [2.5, 97.5]) | |
| med_dd = float(np.median(dd_pf)) | |
| lo_dd, hi_dd = np.percentile(dd_pf, [2.5, 97.5]) | |
| p_loss = float(np.mean(final_pf < invested_pf)) * 100.0 | |
| c1, c2, c3 = st.columns(3) | |
| with c1: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.write("Capitale versato (nominale)"); st.write(f"**{invested_pf:,.0f} €**") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| with c2: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.write("Valore finale netto (mediana; 95% CI)") | |
| st.write(f"**{med_final:,.0f} €**") | |
| st.write(f"_Intervallo 95%: {lo_final:,.0f} – {hi_final:,.0f} €_") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| with c3: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.write("Max drawdown lordo (mediana; 95% CI)") | |
| st.write(f"**{med_dd*100:.1f}%**") | |
| st.write(f"_Intervallo 95%: {lo_dd*100:.1f}% – {hi_dd*100:.1f}%_") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| st.markdown( | |
| f"**Probabilità di recuperare meno di quanto investito:** {p_loss:.1f}% " | |
| f"(criterio: _valore finale netto < capitale versato_)." | |
| ) | |
| st.markdown("### 📈 Evoluzione del capitale (portafoglio)") | |
| st.pyplot(plot_paths_with_band(paths_pf, months_pf, invested_pf, sample_paths=50), use_container_width=True) | |
| st.markdown("### 📦 Distribuzione del capitale finale (netto)") | |
| st.pyplot(plot_hist_final(final_pf, invested_pf), use_container_width=True) | |
| st.markdown("### 📉 Distribuzione dei massimi drawdown (lordi)") | |
| st.pyplot(plot_hist_drawdown(dd_pf), use_container_width=True) | |
| # =============== TAB CRIPTO =============== | |
| with tab_crypto: | |
| st.subheader("💱 Cripto: Backtest PAC e Simulazione") | |
| preset_selector("Ticker (Cripto)", "crypto_ticker") | |
| ticker_c = st.text_input("Ticker cripto (es. BTC-USD, ETH-USD, SOL-USD)", | |
| value=st.session_state.get("crypto_ticker", "BTC-USD"), | |
| key="crypto_ticker") | |
| years_c = st.slider("Anni storici da scaricare", 1, 15, 7, key="c_years") | |
| initial_c = st.number_input("Versamento iniziale (€)", value=500.0, step=100.0, min_value=0.0, key="c_initial") | |
| monthly_c = st.number_input("Versamento mensile (€)", value=200.0, step=50.0, min_value=0.0, key="c_monthly") | |
| infl_c = st.number_input("Adeguamento annuo % della quota mensile", value=0.0, step=0.25, format="%.2f", key="c_infl") | |
| infl_rate_c = infl_c / 100.0 | |
| tax_choice_c = st.selectbox("Tassazione su plusvalenza a fine periodo", options=["0%", "12.5%", "26%"], index=2, key="c_tax") | |
| tax_rate_c = {"0%": 0.0, "12.5%": 0.125, "26%": 0.26}[tax_choice_c] | |
| run_c_load = st.button("📥 Carica dati cripto", key="c_load") | |
| # Inizializza contenitori in sessione | |
| st.session_state.setdefault("crypto_df", None) | |
| st.session_state.setdefault("crypto_meta", {}) | |
| if not YF_OK: | |
| st.warning("Per i dati cripto è necessario 'yfinance'. Esegui `pip install yfinance`.") | |
| elif run_c_load and ticker_c.strip(): | |
| try: | |
| df_m_c, last_price_c, ccy_c = load_yahoo_monthly(ticker_c, years_c) | |
| st.session_state["crypto_df"] = df_m_c | |
| st.session_state["crypto_meta"] = {"last_price": last_price_c, "ccy": ccy_c, "ticker": ticker_c, "years": years_c} | |
| except Exception as e: | |
| st.error(f"Errore nel caricamento dati cripto: {e}") | |
| # --- Da qui in poi usiamo SEMPRE la session_state --- | |
| df_m_c = st.session_state.get("crypto_df", None) | |
| meta_c = st.session_state.get("crypto_meta", {}) | |
| if df_m_c is None: | |
| st.info("Scegli un ticker e premi **Carica dati cripto**.") | |
| else: | |
| ccy_c = meta_c.get("ccy") | |
| years_c_eff = meta_c.get("years", years_c) | |
| ticker_c_eff = meta_c.get("ticker", ticker_c) | |
| # metriche veloci | |
| last_close_c = float(df_m_c["Close"].iloc[-1]) | |
| prev_close_c = float(df_m_c["Close"].iloc[-2]) if len(df_m_c) > 1 else last_close_c | |
| m_ret_c = (last_close_c / prev_close_c - 1.0) * 100.0 | |
| mu_c_m = float(df_m_c["LogRet_M"].mean()) | |
| sigma_c_m = float(df_m_c["LogRet_M"].std(ddof=1)) | |
| col1, col2, col3 = st.columns(3) | |
| with col1: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.metric("Ultimo close mensile", f"{last_close_c:,.2f} {ccy_c or ''}", delta=f"{m_ret_c:+.2f}% vs mese prec.") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| with col2: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.write("Media log-ret. mensile (stima)") | |
| st.write(f"**{mu_c_m*100:.2f}%**") | |
| st.write("Dev. std log-ret. mensile (stima)") | |
| st.write(f"**{sigma_c_m*100:.2f}%**") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| with col3: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.write("Campione storico (mesi)") | |
| st.write(f"**{len(df_m_c)}**") | |
| st.write("Finestra (anni)") | |
| st.write(f"**{years_c_eff}**") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| # grafico prezzo | |
| fig_price_c, axpc = plt.subplots(figsize=(9, 3.2)) | |
| axpc.plot(df_m_c.index, df_m_c["Close"], linewidth=2.0) | |
| axpc.set_title(f"Andamento mensile di {ticker_c_eff.upper()} (ultimi {years_c_eff} anni)") | |
| axpc.set_ylabel(f"Prezzo ({ccy_c or ''})") | |
| axpc.grid(alpha=0.2) | |
| st.pyplot(fig_price_c, use_container_width=True) | |
| st.markdown("---") | |
| # Backtest PAC storico cripto | |
| min_date_c = df_m_c.index.min(); max_date_c = df_m_c.index.max() | |
| start_date_c = st.date_input("Data di inizio PAC (cripto)", min_value=min_date_c.date(), max_value=max_date_c.date(), value=min_date_c.date(), key="c_start") | |
| if st.button("▶️ Backtest PAC cripto", key="c_run_bt"): | |
| df_bt_c = backtest_pac_storico(df_m_c, pd.to_datetime(start_date_c), initial_c, monthly_c, infl_rate_c) | |
| last_c = df_bt_c.iloc[-1] | |
| invested_c = float(last_c["Invested"]); value_c = float(last_c["Value"]) | |
| pnl_c = float(last_c["PnL"]); ret_c = float(last_c["Return"]) * 100.0 | |
| c1, c2, c3, c4 = st.columns(4) | |
| for lbl, val in [("Investito totale", f"{invested_c:,.0f} €"), | |
| ("Valore PAC attuale", f"{value_c:,.0f} €"), | |
| ("P&L assoluto", f"{pnl_c:,.0f} €"), | |
| ("Rendimento su investito", f"{ret_c:.2f}%")]: | |
| col = c1 if lbl=="Investito totale" else c2 if lbl=="Valore PAC attuale" else c3 if lbl=="P&L assoluto" else c4 | |
| with col: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.write(lbl); st.write(f"**{val}**") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| st.markdown("### 📈 Andamento PAC cripto vs Capitale versato") | |
| fig_bt_c = plot_backtest(df_bt_c, f"Backtest PAC storico su {ticker_c_eff.upper()}") | |
| st.pyplot(fig_bt_c, use_container_width=True) | |
| st.markdown("---") | |
| # Monte Carlo su cripto (parametri stimati) | |
| mu_c_a = mu_c_m * 12.0 | |
| sigma_c_a = sigma_c_m * math.sqrt(12.0) | |
| st.write(f"Parametri (annuali, log) stimati: **μ = {mu_c_a:.4f}**, **σ = {sigma_c_a:.4f}**") | |
| years_c_h = st.slider("Orizzonte d'investimento (anni) – Simulazione cripto", 1, 20, 10, key="c_horizon") | |
| n_sims_c = st.slider("Numero di simulazioni (cripto)", 500, 20000, 5000, 500, key="c_nsims") | |
| seed_c = st.number_input("Seed casuale (opzionale)", value=0, step=1, key="c_seed") | |
| seed_c = int(seed_c) if seed_c != 0 else None | |
| if st.button("🚀 Esegui simulazione cripto", key="c_run_mc"): | |
| months_c = years_c_h * 12 | |
| paths_c, final_c, dd_c, invested_c2 = simulate_pac_montecarlo( | |
| months=months_c, | |
| n_sims=n_sims_c, | |
| mu_log_annual=mu_c_a, | |
| sigma_log_annual=sigma_c_a, | |
| initial=initial_c, | |
| monthly=monthly_c, | |
| inflation_rate=infl_rate_c, | |
| tax_rate=tax_rate_c, | |
| seed=seed_c, | |
| ) | |
| med_final_c = float(np.median(final_c)) | |
| lo_final_c, hi_final_c = np.percentile(final_c, [2.5, 97.5]) | |
| med_dd_c = float(np.median(dd_c)) | |
| lo_dd_c, hi_dd_c = np.percentile(dd_c, [2.5, 97.5]) | |
| p_loss_c = float(np.mean(final_c < invested_c2)) * 100.0 | |
| c1, c2, c3 = st.columns(3) | |
| with c1: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.write("Capitale versato (nominale)") | |
| st.write(f"**{invested_c2:,.0f} €**") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| with c2: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.write("Valore finale netto (mediana; 95% CI)") | |
| st.write(f"**{med_final_c:,.0f} €**") | |
| st.write(f"_Intervallo 95%: {lo_final_c:,.0f} – {hi_final_c:,.0f} €_") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| with c3: | |
| st.markdown('<div class="metric-card">', unsafe_allow_html=True) | |
| st.write("Max drawdown lordo (mediana; 95% CI)") | |
| st.write(f"**{med_dd_c*100:.1f}%**") | |
| st.write(f"_Intervallo 95%: {lo_dd_c*100:.1f}% – {hi_dd_c*100:.1f}%_") | |
| st.markdown('</div>', unsafe_allow_html=True) | |
| st.markdown(f"**Probabilità di recuperare meno di quanto investito:** {p_loss_c:.1f}%.") | |
| st.markdown("### 📈 Evoluzione del capitale (cripto)") | |
| st.pyplot(plot_paths_with_band(paths_c, months_c, invested_c2, sample_paths=50), use_container_width=True) | |
| st.markdown("### 📦 Distribuzione del capitale finale (netto)") | |
| st.pyplot(plot_hist_final(final_c, invested_c2), use_container_width=True) | |
| st.markdown("### 📉 Distribuzione dei massimi drawdown (lordi)") | |
| st.pyplot(plot_hist_drawdown(dd_c), use_container_width=True) | |
| # Footer | |
| st.markdown("<hr/>", unsafe_allow_html=True) | |
| st.markdown('<div class="credits">Credit: <b>Giovanni Vignola</b> e <b>GPT-5 Plus</b></div>', unsafe_allow_html=True) | |
| # if __name__ == "__main__": | |
| main() |