File size: 12,260 Bytes
02d230e
 
 
21eb9a2
 
02d230e
21eb9a2
 
 
 
 
 
02d230e
21eb9a2
 
 
 
 
02d230e
 
21eb9a2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
02d230e
21eb9a2
 
 
 
 
 
 
 
02d230e
21eb9a2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
02d230e
21eb9a2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21fb8a0
21eb9a2
21fb8a0
7635bf7
21fb8a0
21eb9a2
 
7635bf7
21eb9a2
7635bf7
 
 
 
 
21eb9a2
 
 
 
 
7635bf7
21eb9a2
 
 
 
 
7635bf7
21eb9a2
 
 
 
 
 
 
 
 
 
7635bf7
21eb9a2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
02d230e
21eb9a2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
02d230e
21eb9a2
 
7635bf7
21eb9a2
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
import numpy as np
import pandas as pd
import streamlit as st
import datetime as dt
from typing import List, Tuple

# Dependências opcionais (se instaladas) para gráficos e dados
try:
    import yfinance as yf
    YF_AVAILABLE = True
except Exception:
    YF_AVAILABLE = False

try:
    import altair as alt
    ALTAIR_AVAILABLE = True
except Exception:
    ALTAIR_AVAILABLE = False


# Utilidades
def carregar_precos(
    tickers: List[str],
    start: str,
    end: str,
    progress: bool = False
) -> pd.DataFrame:
    """
    Baixa preços via yfinance. Prioriza 'Adj Close'; se não houver, usa 'Close'.
    Retorna DataFrame com colunas = tickers e índice = datas.
    """
    if not YF_AVAILABLE:
        raise ImportError(
            "yfinance não está disponível neste ambiente. "
            "Instale com: pip install yfinance"
        )

    df = yf.download(
        tickers,
        start=start,
        end=end,
        progress=progress,
        auto_adjust=False,
        group_by='ticker'
    )

    # Normalizar para um formato (colunas simples por ticker)
    if isinstance(df.columns, pd.MultiIndex):
        # tentar pegar Adj Close > Close
        if ("Adj Close" in df.columns.get_level_values(1)):
            prices = df.xs("Adj Close", axis=1, level=1, drop_level=True)
        elif ("Close" in df.columns.get_level_values(1)):
            prices = df.xs("Close", axis=1, level=1, drop_level=True)
        else:
            raise KeyError("Nem 'Adj Close' nem 'Close' encontrados no retorno do yfinance.")
    else:
        # dataset simples (um único ticker)
        if "Adj Close" in df.columns:
            prices = df[["Adj Close"]].rename(columns={"Adj Close": tickers[0]})
        elif "Close" in df.columns:
            prices = df[["Close"]].rename(columns={"Close": tickers[0]})
        else:
            raise KeyError("Nem 'Adj Close' nem 'Close' encontrados no retorno do yfinance.")

    # manter apenas os tickers solicitados (quando baixar lista)
    missing = [t for t in tickers if t not in prices.columns]
    if missing:
        # Alguns tickers podem vir faltando; vamos remover os inexistentes
        st.warning(f"Atenção: sem dados para: {', '.join(missing)}. Eles serão ignorados.")
        tickers = [t for t in tickers if t in prices.columns]
        prices = prices[tickers]

    prices = prices.dropna(how="all")
    prices = prices.dropna(axis=0)  # remove datas com NaN
    return prices


def log_retorno(precos: pd.DataFrame) -> pd.DataFrame:
    """Retorno logarítmico diário."""
    return np.log(precos / precos.shift(1)).dropna()


def max_sharpe_random(mu: pd.Series, cov: pd.DataFrame, n_sims: int = 50_000, rf: float = 0.0, seed: int = 42) -> np.ndarray:
    """
    Busca pesos de Máximo Sharpe por amostragem aleatória (Dirichlet).
    mu: média dos retornos (por período)
    cov: matriz de covariância (por período)
    rf: taxa livre de risco no mesmo período (ex.: diária)
    Retorna vetor de pesos ótimos.
    """
    rng = np.random.default_rng(seed)
    k = len(mu)
    W = rng.dirichlet(np.ones(k), size=n_sims)
    # retorno e risco da carteira
    ret = W @ mu.values
    # risco: sqrt(w' Σ w) eficiente com einsum
    risco = np.sqrt(np.einsum('ij,jk,ik->i', W, cov.values, W))
    # Sharpe
    excesso = ret - rf
    sharpe = np.divide(excesso, risco, out=np.full_like(excesso, -np.inf), where=risco > 0)
    idx = int(np.nanargmax(sharpe))
    return W[idx]


def simular_mc_multivariado(
    mu: pd.Series,
    cov: pd.DataFrame,
    pesos: np.ndarray,
    n_sim: int = 50_000,
    horizonte_dias: int = 1,
    seed: int = 42
) -> np.ndarray:
    """
    Monte Carlo multivariado (Normal) para retornos da carteira.
    - Gera amostras multivariadas ~ N(mu, cov)
    - Para horizonte > 1, soma retornos log (aprox. soma normal) ou compõe geometricamente.
    Retorna array de retornos (log) simulados da carteira no horizonte.
    """
    rng = np.random.default_rng(seed)
    k = len(mu)
    # Cholesky para gerar multivariada
    L = np.linalg.cholesky(cov.values)

    # Se horizonte > 1, simulamos soma de passos (log-returns ~ soma)
    # Gera (n_sim, horizonte, k)
    Z = rng.standard_normal(size=(n_sim, horizonte_dias, k))
    # passo diário: mu + L @ z
    # broadcasting: (n_sim, horizonte, k) -> (n_sim, horizonte, k)
    passos = mu.values + (Z @ L.T)
    # soma ao longo do horizonte (log-returns)
    ret_log_multi = passos.sum(axis=1)  # (n_sim, k)

    # retorno log da carteira = soma_i (w_i * ret_i) em log-approx -> carteira em log via combinação linear
    # (n_sim, k) @ (k,) -> (n_sim,)
    ret_log_port = ret_log_multi @ pesos
    return ret_log_port


def var_percentual(simulacoes_ret_log: np.ndarray, alpha: float = 0.95) -> float:
    """
    VaR (como perda positiva) a partir da distribuição de retornos (log).
    Retorna percentual (ex.: 0.042 = 4,2%).
    Por convenção: VaR_α = - quantil_{1-α}(retorno)  => perda mínima no pior (1-α)
    """
    # converter log-return -> return simples aproximado (e^r - 1)
    ret_simple = np.expm1(simulacoes_ret_log)
    # quantil no tail esquerdo (ex.: 5% para 95%)
    q = np.quantile(ret_simple, 1 - alpha)
    return -q  # perda positiva


def format_percent(x: float) -> str:
    return f"{x*100:.2f}%"


# Interface Streamlit
st.set_page_config(page_title="VaR Monte Carlo - Carteira (B3)", page_icon="=>", layout="wide")

st.title("VaR da Carteira via Simulação de Monte Carlo")
st.caption( 
    "Baseado na carteira de ações (ITUB4.SA, MGLU3.SA, COGN3.SA, PETR4.SA, ABEV3.SA)."
)

with st.expander("Funcionamento deste app"):
    st.write("""
- Download dos preços diários dos tickers selecionados (por padrão, cinco ativos).
- Calculo dos retornos logarítmicos diários, suas médias e covariâncias.
- Opcionalmente se busca pesos de **Máximo Sharpe** (amostragem) ou você define pesos manualmente.
- Roda-se uma simulação **Monte Carlo multivariada Normal** dos retornos no horizonte escolhido.
- Calculo do **VaR** (percentual e em R$) para os níveis de confiança (ex.: 95% e 99%).
""")

# Parâmetros
colA, colB = st.columns([2, 1])

default_tickers = ["ITUB4.SA", "MGLU3.SA", "COGN3.SA", "PETR4.SA", "ABEV3.SA"]  
with colA:
    tickers = st.multiselect(
        "Tickers (B3, sufixo .SA)",
        options=default_tickers,
        default=default_tickers,
        help="Por padrão, os 5 ativos."
    )

with colB:
    hoje = dt.date.today()
    start = st.date_input("Início", value=dt.date(2015, 1, 1))
    end = st.date_input("Fim", value=hoje)

col1, col2, col3, col4 = st.columns([1, 1, 1, 1])

with col1:
    aporte = st.number_input("Investimento inicial (R$)", min_value=1000.0, value=50_000.0, step=1000.0, format="%.2f")

with col2:
    horizonte = st.number_input("Horizonte (dias úteis)", min_value=1, value=1, step=1)

with col3:
    n_sim = st.number_input("Nº simulações Monte Carlo", min_value=1000, value=50_000, step=1000, format="%d")

with col4:
    seed = st.number_input("Seed aleatória", min_value=0, value=42, step=1, format="%d")

col5, col6, col7 = st.columns([1, 1, 1])
with col5:
    nivel_1 = st.slider("Confiança 1 (VaR α)", min_value=0.80, max_value=0.999, value=0.95, step=0.01)
with col6:
    nivel_2 = st.slider("Confiança 2 (opcional)", min_value=0.80, max_value=0.999, value=0.99, step=0.01)
with col7:
    usar_max_sharpe = st.toggle("Usar pesos de Máx. Sharpe", value=True, help="Se desligado, você define os pesos abaixo.")

st.divider()

# ------------------------
# Dados
# ------------------------
if not tickers:
    st.error("Escolha pelo menos um ticker.")
    st.stop()

try:
    precos = carregar_precos([t.strip() for t in tickers], start.isoformat(), end.isoformat(), progress=False)
except Exception as e:
    st.error(f"Falha ao carregar dados: {e}")
    st.stop()

ret = log_retorno(precos)
mu = ret.mean()                 # média diária
cov = ret.cov()                 # covariância diária
cols = list(ret.columns)

st.subheader("Série de preços (últimas 10 linhas)")
st.dataframe(precos.tail(10))

st.subheader("Estatísticas diárias dos retornos")
stats_df = pd.DataFrame({
    "média": mu,
    "desvio": ret.std(),
    "assimetria": ret.skew(),
    "curtose (excesso)": ret.kurt()
})
st.dataframe(stats_df.style.format({"média": "{:.5f}", "desvio": "{:.5f}", "assimetria": "{:.2f}", "curtose (excesso)": "{:.2f}"}))

# ------------------------
# Pesos
# ------------------------
if usar_max_sharpe:
    pesos = max_sharpe_random(mu, cov, n_sims=50_000, rf=0.0, seed=seed)
    fonte_pesos = "Máximo Sharpe (amostragem aleatória)"
else:
    st.markdown("#### Pesos manuais (devem somar 100%)")
    pesos_inputs = []
    colpesos = st.columns(len(cols))
    for i, c in enumerate(cols):
        with colpesos[i]:
            w = st.number_input(f"{c}", min_value=0.0, max_value=100.0, value=100.0/len(cols), step=1.0, format="%.2f")
            pesos_inputs.append(w)
    soma = sum(pesos_inputs)
    if soma <= 0:
        st.error("A soma dos pesos deve ser > 0.")
        st.stop()
    pesos = np.array(pesos_inputs) / soma
    fonte_pesos = "Definidos manualmente pelo usuário"

pesos_df = pd.DataFrame({"Ativo": cols, "Peso": pesos})
st.subheader("Pesos da carteira")
st.dataframe(pesos_df.style.format({"Peso": "{:.2%}"}))
st.caption(f"Origem dos pesos: **{fonte_pesos}**.")

# Simulação Monte Carlo
rng = np.random.default_rng(seed)
sim_ret_log = simular_mc_multivariado(
    mu=mu,
    cov=cov,
    pesos=pesos,
    n_sim=n_sim,
    horizonte_dias=horizonte,
    seed=seed
)

# VaR em percentual e em moeda
var1_pct = var_percentual(sim_ret_log, alpha=nivel_1)
var2_pct = var_percentual(sim_ret_log, alpha=nivel_2)

# patrimônio atual simulado (a partir do aporte e do retorno da carteira no horizonte)
# retorno simples da carteira (e^r - 1)
sim_ret_simple = np.expm1(sim_ret_log)
valor_futuro = aporte * (1.0 + sim_ret_simple)
# perda em R$ no quantil
var1_rs = aporte * var1_pct
var2_rs = aporte * var2_pct

colM1, colM2, colM3 = st.columns(3)
with colM1:
    st.metric(f"VaR {int(nivel_1*100)}% (percentual)", format_percent(var1_pct))
with colM2:
    st.metric(f"VaR {int(nivel_1*100)}% (R$)", f"R$ {var1_rs:,.2f}".replace(",", "X").replace(".", ",").replace("X", "."))
with colM3:
    st.metric(f"VaR {int(nivel_2*100)}% (R$)", f"R$ {var2_rs:,.2f}".replace(",", "X").replace(".", ",").replace("X", "."))

st.caption(
    f"Interpretação: com {int(nivel_1*100)}% de confiança, a **perda mínima** esperada em {horizonte} dia(s) é de **{format_percent(var1_pct)}** (≈ R$ {var1_rs:,.2f}). "
    "Ou seja, em ~1 a cada {round(1/(1-nivel_1))} dia(s), a perda pode ser **pior** do que esse valor."
)

# ------------------------
# Visualização da distribuição
# ------------------------
st.subheader("Distribuição das simulações de retorno da carteira")

hist_df = pd.DataFrame({
    "retorno_simples": sim_ret_simple
})
hist_df["retorno_%"] = 100 * hist_df["retorno_simples"]

if ALTAIR_AVAILABLE:
    base = alt.Chart(hist_df).mark_bar().encode(
        alt.X("retorno_%:Q", bin=alt.Bin(maxbins=60), title="Retorno (%)"),
        alt.Y("count():Q", title="Frequência")
    ).properties(height=320)

    rule1 = alt.Chart(pd.DataFrame({"x": [(-var1_pct)*100]})).mark_rule(color="red", strokeDash=[6,3]).encode(x="x:Q")
    rule2 = alt.Chart(pd.DataFrame({"x": [(-var2_pct)*100]})).mark_rule(color="orange", strokeDash=[6,3]).encode(x="x:Q")

    st.altair_chart(base + rule1 + rule2, use_container_width=True)
else:
    st.line_chart(hist_df["retorno_%"].value_counts().sort_index())

# ------------------------
# Série acumulada (cenários)
# ------------------------
st.subheader("Cenários simulados (valor do aporte após o horizonte)")
cen_df = pd.DataFrame({"Valor final (R$)": valor_futuro})
st.dataframe(cen_df.describe().T.style.format({"mean": "R$ {:,.2f}".format}).format_index(lambda s: s.replace(",", "X").replace(".", ",").replace("X", ".")))

st.success("Simulação concluída!")

st.divider()
st.caption(
    "Carteira base (ITUB4.SA, MGLU3.SA, COGN3.SA, PETR4.SA, ABEV3.SA)  — "
    "parametrizável acima. O método de Monte Carlo segue a abordagem Normal multivariada com média e covariância empíricas diárias."
)