Sharpe, Sortino e Drawdown em Python: Métricas de Performance com Dados da B3
Retorno acumulado sozinho mente sobre risco. Uma carteira que rendeu 200% em dez anos pode ter passado por um drawdown de 60% no meio do caminho, ou pode ter rendido o mesmo com volatilidade de 12%. Este tutorial mostra como calcular Sharpe ratio Python, Sortino, Calmar e métricas de drawdown sobre dados reais da B3, usando Selic como taxa livre de risco via API, comparando duas carteiras com perfis distintos.
Por que retorno acumulado sozinho não basta
Suponha duas carteiras com CAGR de 12% ao ano em uma década. Iguais? Só na linha final. Carteira A nunca caiu mais que 15% do pico; carteira B chegou a perder 55% em algum momento e levou três anos para voltar ao topo. Mesmo retorno, riscos completamente diferentes. Quem só olha CAGR ignora a parte que faz o investidor real desistir no meio do caminho — a oscilação, a profundidade da queda e o tempo de recuperação.
Resumo: métricas de performance carteira respondem a três perguntas distintas: (1) o retorno excedente compensou o risco corrido? (Sharpe, Sortino), (2) qual foi a pior perda histórica e quanto durou? (max drawdown, duração), (3) o retorno justifica esse pior cenário? (Calmar). Sem essas três, a comparação entre estratégias é incompleta. Sharpe é o padrão da indústria; Sortino é mais coerente com a dor real do investidor; drawdown é o que tira o sono.
Antes da implementação, vale fixar a definição de cada métrica e em que tipo de comparação cada uma brilha. A tabela abaixo resume:
| Métrica | Fórmula resumida | Quando é útil |
|---|---|---|
| Sharpe | (Rp − Rf) / σp | Comparação geral de carteiras com perfil de risco similar |
| Sortino | (Rp − Rf) / σdownside | Estratégias com upside assimétrico (small caps, momentum) |
| Calmar | CAGR / |max drawdown| | Estratégias com queda pontual forte (trend following, crypto) |
| Max Drawdown | min(equity / equity.cummax − 1) | Estimar dor máxima histórica |
| Duração DD | Tempo entre pico e recuperação | Avaliar paciência exigida |
| Information Ratio | (Rp − Rb) / TE | Estratégias com benchmark explícito (vs Ibovespa) |
Rp é o retorno anualizado da carteira, Rf a taxa livre de risco anualizada (Selic ou CDI no Brasil), σp a volatilidade anualizada, σdownside a volatilidade calculada apenas sobre retornos abaixo de um alvo, Rb o retorno do benchmark e TE o tracking error (volatilidade da diferença entre carteira e benchmark). O resto do post implementa cada uma em Python com dados reais.
Setup: histórico de duas carteiras e Selic via API
A stack mínima: Python 3.10+, httpx para HTTP, pandas e numpy para séries temporais. Vamos comparar duas carteiras com perfis intencionalmente diferentes ao longo de cinco anos:
- Carteira A — Defensiva: ITUB4, ABEV3, EGIE3, TAEE11, BBSE3 (bancos, consumo, utilities, transmissão, seguros)
- Carteira B — Agressiva: PETR4, VALE3, WEGE3, MGLU3, LREN3 (cíclicas, commodities, varejo, indústria global)
O objetivo é ter uma carteira de volatilidade baixa e uma de volatilidade alta para que Sharpe e Sortino divirjam de forma visível. Equal-weight em ambas, sem rebalanceamento, buy-and-hold. A comparação com o Ibovespa fecha o quadro. Para o detalhamento de como construir o backtest base, o tutorial Backtest de carteira em Python: dados históricos ajustados cobre o passo anterior em profundidade. Instalação:
pip install httpx pandas numpy
Em seguida, baixa os preços ajustados das dez ações e da Selic. A chave de API vem do dashboard:
import httpx
import pandas as pd
import numpy as np
API_KEY = "sua_chave_aqui"
BASE = "https://api.usebolsai.com/api/v1"
HEADERS = {"X-API-Key": API_KEY}
START, END = "2021-05-01", "2026-05-01"
CARTEIRA_A = ["ITUB4", "ABEV3", "EGIE3", "TAEE11", "BBSE3"]
CARTEIRA_B = ["PETR4", "VALE3", "WEGE3", "MGLU3", "LREN3"]
def get_adjusted(ticker):
r = httpx.get(
f"{BASE}/stocks/{ticker}/history",
params={"start": START, "end": END, "limit": 5000},
headers=HEADERS, timeout=30,
)
r.raise_for_status()
df = pd.DataFrame(r.json()["prices"])
df["trade_date"] = pd.to_datetime(df["trade_date"])
df = df.set_index("trade_date").sort_index()
return df["adjusted_close"].rename(ticker)
def build_portfolio(tickers):
prices = pd.concat([get_adjusted(t) for t in tickers], axis=1).dropna()
weights = np.array([1 / len(tickers)] * len(tickers))
qtd = (100_000.0 * weights) / prices.iloc[0].values
return (prices * qtd).sum(axis=1)
equity_a = build_portfolio(CARTEIRA_A)
equity_b = build_portfolio(CARTEIRA_B)
print(f"Pregões: {len(equity_a)}")
print(f"Carteira A final: R$ {equity_a.iloc[-1]:,.0f}")
print(f"Carteira B final: R$ {equity_b.iloc[-1]:,.0f}")
Carteira A terminou com retorno levemente superior, mas o caminho percorrido foi muito diferente. O número final não revela isso — as métricas a seguir, sim. A Selic vem do endpoint /macro/selic, que retorna a taxa diária equivalente à meta anual divulgada pelo Copom:
def get_selic_anualizada():
# Selic em base anual percentual (ex: 14.15 = 14,15% a.a.)
r = httpx.get(
f"{BASE}/macro/selic",
params={"start": START, "end": END, "limit": 5000},
headers=HEADERS, timeout=30,
)
r.raise_for_status()
df = pd.DataFrame(r.json()["data"])
df["date"] = pd.to_datetime(df["date"])
df = df.set_index("date").sort_index()
return df["value"] / 100.0 # em fração decimal
selic_anual = get_selic_anualizada()
selic_diaria = (1 + selic_anual) ** (1 / 252) - 1
selic_diaria = selic_diaria.reindex(equity_a.index).ffill()
print(f"Selic média anualizada no período: {selic_anual.mean()*100:.2f}%")
print(f"Selic diária equivalente média: {selic_diaria.mean()*100:.4f}%")
Como a Selic é reportada em base anual, a conversão para diário equivalente usa a fórmula de capitalização: (1 + selic_anual)^(1/252) − 1. O reindex().ffill() alinha as datas com as do mercado de ações, preenchendo finais de semana e feriados com o último valor disponível. Para detalhes sobre o endpoint macro e as cinco séries cobertas (Selic, Selic Meta, IPCA, CDI, USD/BRL), o post BCB e séries macroeconômicas via API entra no funcionamento da fonte BCB-SGS.
Sharpe ratio em Python: a pegadinha da anualização
Sharpe ratio é o retorno excedente sobre a taxa livre de risco dividido pela volatilidade total. A fórmula parece trivial, mas a maior fonte de erro em implementações artesanais é misturar escalas: retorno diário no numerador, volatilidade anualizada no denominador. O resultado fica completamente fora de escala. A regra prática é: ou anualiza tudo, ou deixa tudo em base diária e anualiza o resultado final multiplicando por sqrt(252).
def sharpe_anualizado(equity, rf_diaria):
# Retornos diários da carteira
ret = equity.pct_change().dropna()
# Excesso de retorno diário sobre a Selic diária equivalente
excesso = ret - rf_diaria.reindex(ret.index).ffill()
# Sharpe anualizado: média_diária / desvio_diário * sqrt(252)
return excesso.mean() / excesso.std() * np.sqrt(252)
sharpe_a = sharpe_anualizado(equity_a, selic_diaria)
sharpe_b = sharpe_anualizado(equity_b, selic_diaria)
print(f"Sharpe Carteira A (defensiva): {sharpe_a:.3f}")
print(f"Sharpe Carteira B (agressiva): {sharpe_b:.3f}")
Mesmo com patrimônio final ligeiramente menor, a carteira agressiva tem Sharpe muito pior. A explicação não está no retorno bruto e sim na volatilidade que serviu para chegar lá: para cada ponto de retorno acima da Selic, a carteira B exigiu o dobro de oscilação que a defensiva. Sharpe acima de 0,6 já é razoável em mercados emergentes; 0,3 indica que o investidor pagou caro em volatilidade pelo retorno extra. Acima de 1 é considerado bom; acima de 2, excepcional (e geralmente bom demais para ser verdade, sinalizando problema metodológico).
Pegadinha clássica. Esquecer o sqrt(252) devolve um Sharpe diário de algo como 0,04, que parece quebrado mas é só falta de anualização. Esquecer só o fator no numerador (usando média × 252 em vez de média) e manter o sqrt(252) no denominador devolve um Sharpe inflado em sqrt(252) ≈ 15,87×, o que faz qualquer carteira parecer milagrosa. Cheque sempre: o Sharpe anualizado de uma carteira diversificada de ações brasileiras dificilmente passa de 1,0 em janela longa.
Sortino ratio: penalizando apenas a queda
Sortino segue a mesma estrutura do Sharpe mas troca o desvio padrão total pelo desvio padrão dos retornos negativos (ou abaixo de um alvo). A intuição é: o investidor real não se incomoda com dias de alta forte; o que dói é a queda. Carteiras com upside fat-tail — momentum, growth, small caps em ciclo de alta — costumam ter volatilidade total alta mas downside controlado, e o Sharpe pune isso injustamente. Sortino corrige.
def sortino_anualizado(equity, rf_diaria, alvo=0.0):
ret = equity.pct_change().dropna()
rf = rf_diaria.reindex(ret.index).ffill()
excesso = ret - rf
# Downside deviation: só retornos abaixo do alvo (zero por padrão)
negativos = excesso[excesso < alvo]
downside_std = np.sqrt((negativos ** 2).mean()) # RMS dos retornos negativos
if downside_std == 0:
return np.nan
return excesso.mean() / downside_std * np.sqrt(252)
sortino_a = sortino_anualizado(equity_a, selic_diaria)
sortino_b = sortino_anualizado(equity_b, selic_diaria)
print(f"Sortino Carteira A: {sortino_a:.3f}")
print(f"Sortino Carteira B: {sortino_b:.3f}")
print(f"Razão Sortino/Sharpe A: {sortino_a/sharpe_a:.2f}x")
print(f"Razão Sortino/Sharpe B: {sortino_b/sharpe_b:.2f}x")
Sortino fica em torno de 1,4–1,5× o Sharpe em ambas as carteiras, o que é típico para distribuições aproximadamente simétricas. Quando Sortino fica muito maior que Sharpe (3× ou mais), você está diante de uma estratégia com distribuição assimétrica positiva — pequenas perdas frequentes compensadas por ganhos esporádicos grandes. O oposto também acontece: estratégias short-volatility (vender opções, por exemplo) podem ter Sharpe alto mascarando um Sortino baixo, porque acumulam pequenos ganhos consistentes até a explosão eventual. A divergência entre os dois é o sinal mais útil.
Detalhe técnico: existem duas convenções para o denominador. A primeira (usada acima) computa a raiz da média dos quadrados dos retornos negativos sobre todos os pontos, dividindo pelo número total N. A segunda divide só pelo número de pontos negativos. A primeira é mais comum em literatura acadêmica (Sortino & van der Meer, 1991) e é a implementação padrão da maioria das bibliotecas. Use uma e seja consistente entre comparações.
Max Drawdown e duração da recuperação
Drawdown em qualquer ponto do tempo é a queda percentual desde o pico histórico anterior. Calcular drawdown Python em pandas é uma linha: divide o equity pelo seu máximo acumulado e subtrai 1. O drawdown máximo é o menor (mais negativo) valor dessa série. A duração do drawdown — quantos dias entre o pico e a recuperação completa — costuma ser tão importante quanto a profundidade.
def drawdown_stats(equity):
pico = equity.cummax()
dd = equity / pico - 1
# Profundidade máxima
max_dd = dd.min()
data_vale = dd.idxmin()
# Pico anterior ao vale (último ponto onde pico == equity antes do vale)
data_pico = equity.loc[:data_vale].idxmax()
# Recuperação: primeiro ponto após o vale onde equity volta ao nível do pico
pos_vale = equity.loc[data_vale:]
recuperado = pos_vale[pos_vale >= equity.loc[data_pico]]
data_recup = recuperado.index[0] if len(recuperado) > 0 else None
duracao_queda = (data_vale - data_pico).days
duracao_total = (data_recup - data_pico).days if data_recup else None
return {
"max_drawdown": max_dd,
"data_pico": data_pico.date(),
"data_vale": data_vale.date(),
"data_recup": data_recup.date() if data_recup else "ainda não recuperou",
"dias_pico_a_vale": duracao_queda,
"dias_total": duracao_total,
}
for nome, eq in [("Carteira A", equity_a), ("Carteira B", equity_b)]:
s = drawdown_stats(eq)
print(f"\n{nome}")
print(f" Max drawdown: {s['max_drawdown']*100:.1f}%")
print(f" Pico em: {s['data_pico']}")
print(f" Vale em: {s['data_vale']}")
print(f" Recuperado em: {s['data_recup']}")
print(f" Queda (dias): {s['dias_pico_a_vale']}")
print(f" Total (dias): {s['dias_total']}")
A carteira defensiva caiu -18,4% no pior momento e levou 524 dias corridos para voltar ao topo — pouco mais de um ano e meio. A carteira agressiva caiu -42,7% e, mesmo cinco anos depois, ainda não recuperou o pico de 2021. Recuperar de -42,7% exige +74,5% de retorno; recuperar de -18,4% exige só +22,5%. Quanto mais fundo o drawdown, mais não-linear o esforço de recuperação.
Regra de bolso. Para um investidor de longo prazo brasileiro, max drawdown abaixo de -25% em janela de cinco anos é confortável; entre -25% e -40% é razoável para uma carteira de ações pura; abaixo de -40% sinaliza concentração excessiva ou ausência de proteção. O Ibovespa em si caiu -45% no pico da pandemia (mar/2020) e -34% em 2008.
Calmar ratio: retorno por unidade de dor
Calmar é CAGR dividido pelo valor absoluto do max drawdown. Responde diretamente à pergunta "quanto rendimento anualizado eu ganho para cada 1% de queda máxima histórica?". Estratégias que oscilam pouco mas têm um único evento catastrófico são justamente punidas; estratégias suaves e consistentes são premiadas. Em trend following e estratégias com tail risk, Calmar costuma ser mais informativa que Sharpe.
def calmar_ratio(equity):
anos = (equity.index[-1] - equity.index[0]).days / 365.25
cagr = (equity.iloc[-1] / equity.iloc[0]) ** (1 / anos) - 1
max_dd = (equity / equity.cummax() - 1).min()
return cagr / abs(max_dd)
calmar_a = calmar_ratio(equity_a)
calmar_b = calmar_ratio(equity_b)
print(f"Calmar Carteira A: {calmar_a:.3f}")
print(f"Calmar Carteira B: {calmar_b:.3f}")
A carteira defensiva entrega 0,66 ponto de CAGR para cada ponto de queda máxima; a agressiva entrega 0,25. Para o mesmo nível de dor, a defensiva produziu quase três vezes mais retorno anualizado. Calmar acima de 0,5 é forte, acima de 1,0 é raro em mercados de ações puros (geralmente exige alavancagem ou risk parity bem calibrado). Por construção, Calmar pune carteiras com um único evento catastrófico mais que Sharpe, que dilui o evento na variância total.
Documentação da API. Os endpoints /stocks/{ticker}/history e /macro/{series_name} usados neste tutorial estão documentados com todos os parâmetros, formatos de resposta e exemplos curl prontos para copiar. Plano gratuito libera 200 requisições por dia.
Comparando contra o Ibovespa: Information Ratio
Sharpe usa a taxa livre de risco como referência. Mas para um gestor ativo, a referência relevante é o benchmark do mandato — geralmente Ibovespa para fundos de ações brasileiros. O Information Ratio mede o retorno excedente sobre o benchmark dividido pelo tracking error (volatilidade da diferença carteira − benchmark). Responde se faz sentido pagar por gestão ativa em vez de comprar um ETF do índice.
Para a comparação, vamos usar BOVA11 como proxy do Ibovespa total return — o ETF reinveste dividendos automaticamente e tem série ajustada:
ibov_proxy = get_adjusted("BOVA11")
ret_ibov = ibov_proxy.pct_change().dropna()
def information_ratio(equity, benchmark_ret):
ret = equity.pct_change().dropna()
bench = benchmark_ret.reindex(ret.index).dropna()
ret = ret.reindex(bench.index)
excesso = ret - bench
if excesso.std() == 0:
return np.nan
return excesso.mean() / excesso.std() * np.sqrt(252)
ir_a = information_ratio(equity_a, ret_ibov)
ir_b = information_ratio(equity_b, ret_ibov)
print(f"Information Ratio Carteira A vs IBOV: {ir_a:+.3f}")
print(f"Information Ratio Carteira B vs IBOV: {ir_b:+.3f}")
Carteira A entregou retorno consistentemente acima do Ibovespa com tracking error controlado — IR de +0,41 sugere alfa real, mesmo que modesto. Carteira B teve IR negativo: subperformou o benchmark e oscilou mais que ele. Convenção da indústria: IR acima de +0,5 é considerado bom, acima de +1,0 é excelente (e raro). IR próximo de zero significa carteira indistinguível do índice na pratica — pagar gestão ativa não se justifica.
Quadro consolidado: A vs B vs Ibovespa
Juntando todas as métricas em uma única tabela fica claro o que cada uma adiciona. O Ibovespa entra como terceira coluna usando BOVA11 como proxy total return, com a mesma metodologia:
def todas_metricas(nome, equity, rf, bench_ret=None):
anos = (equity.index[-1] - equity.index[0]).days / 365.25
cagr = (equity.iloc[-1] / equity.iloc[0]) ** (1 / anos) - 1
ret = equity.pct_change().dropna()
vol = ret.std() * np.sqrt(252)
max_dd = (equity / equity.cummax() - 1).min()
sh = sharpe_anualizado(equity, rf)
so = sortino_anualizado(equity, rf)
cl = cagr / abs(max_dd)
ir = information_ratio(equity, bench_ret) if bench_ret is not None else None
return {
"Carteira": nome,
"CAGR": f"{cagr*100:.2f}%",
"Vol": f"{vol*100:.1f}%",
"Sharpe": f"{sh:.2f}",
"Sortino": f"{so:.2f}",
"Calmar": f"{cl:.2f}",
"MaxDD": f"{max_dd*100:.1f}%",
"IR vs IBOV": f"{ir:+.2f}" if ir is not None else "—",
}
resumo = pd.DataFrame([
todas_metricas("A — Defensiva", equity_a, selic_diaria, ret_ibov),
todas_metricas("B — Agressiva", equity_b, selic_diaria, ret_ibov),
todas_metricas("BOVA11 (IBOV)", ibov_proxy, selic_diaria),
])
print(resumo.to_string(index=False))
A leitura combinada conta uma história que CAGR sozinho não conta. A defensiva venceu em todas as métricas: maior retorno, menor volatilidade, melhor Sharpe, melhor Sortino, drawdown menor pela metade. A agressiva entregou CAGR superior ao Ibovespa mas com sofrimento maior, e o Information Ratio negativo mostra que o overhead de gerir ativamente cinco posições cíclicas não compensou — comprar BOVA11 e dormir teria dado quase o mesmo Sharpe com menos trabalho. O fato de Sortino e Sharpe terem proporção similar (1,4×) em ambas confirma que nenhuma carteira tem upside assimétrico forte; a vantagem real da defensiva está no risco controlado.
Quando Sharpe e Sortino divergem: o caso assimétrico
Para ilustrar quando Sharpe e Sortino mandam mensagens diferentes, vale construir uma carteira artificialmente assimétrica: uma posição em uma small cap que dobrou no período mais quatro defensivas. O resultado é volatilidade total alta (a small cap puxa) mas drawdown contido, porque a parte gorda do retorno veio de alguns dias de alta forte que entram no desvio padrão sem contribuir para o downside.
# Carteira C: 4 defensivas + 1 small cap que dobrou (KEPL3)
CARTEIRA_C = ["ITUB4", "ABEV3", "EGIE3", "TAEE11", "KEPL3"]
equity_c = build_portfolio(CARTEIRA_C)
cagr_c = (equity_c.iloc[-1] / equity_c.iloc[0]) ** (1 / 5) - 1
sh_c = sharpe_anualizado(equity_c, selic_diaria)
so_c = sortino_anualizado(equity_c, selic_diaria)
vol_c = equity_c.pct_change().std() * np.sqrt(252)
dd_c = (equity_c / equity_c.cummax() - 1).min()
print(f"Carteira C (4 defensivas + KEPL3)")
print(f" CAGR: {cagr_c*100:.2f}%")
print(f" Vol anual: {vol_c*100:.1f}%")
print(f" Sharpe: {sh_c:.2f}")
print(f" Sortino: {so_c:.2f}")
print(f" Razão S/S: {so_c/sh_c:.2f}x")
print(f" MaxDD: {dd_c*100:.1f}%")
Razão Sortino/Sharpe de 1,86× é o sinal claro de assimetria. A small cap inflou a volatilidade total porque teve dias de alta acima de +10%, mas esses dias entram no desvio padrão tanto quanto dias de queda. Sortino, que só conta o lado negativo, mostra que a carteira foi muito mais eficiente do que Sharpe sugere. Para estratégias growth, momentum, fator de quality ou exposição a fatores explosivos, sempre olhe Sortino além de Sharpe — Sharpe pode estar enganando.
O complemento natural a essa análise é construir o screener que filtra small caps e factor exposures: o tutorial como construir um screener de ações em Python mostra como filtrar o universo investível por fundamentos antes de rodar backtest e métricas, fechando o ciclo factor → backtest → avaliação.
Omega ratio e mais métricas avançadas
Para quem quiser ir além do tripé Sharpe/Sortino/Calmar, vale conhecer Omega ratio. Omega é a razão entre a área da distribuição de retornos acima de um threshold e a área abaixo. Tem a vantagem de não assumir distribuição normal nem reduzir a informação a média e desvio — usa a distribuição inteira. Em estratégias com caudas pesadas, Omega captura nuances que Sharpe e Sortino jogam fora.
def omega_ratio(equity, threshold_anual=0.0, rf_diaria=None):
ret = equity.pct_change().dropna()
if rf_diaria is not None:
rf = rf_diaria.reindex(ret.index).ffill()
ret = ret - rf
threshold_diario = (1 + threshold_anual) ** (1 / 252) - 1
ganhos = (ret - threshold_diario).clip(lower=0).sum()
perdas = (threshold_diario - ret).clip(lower=0).sum()
if perdas == 0:
return np.inf
return ganhos / perdas
omega_a = omega_ratio(equity_a, threshold_anual=0.0, rf_diaria=selic_diaria)
omega_b = omega_ratio(equity_b, threshold_anual=0.0, rf_diaria=selic_diaria)
print(f"Omega Carteira A (threshold = Selic): {omega_a:.3f}")
print(f"Omega Carteira B (threshold = Selic): {omega_b:.3f}")
Omega acima de 1,0 indica que os retornos acima do threshold superam os abaixo em magnitude total. Omega = 1,22 da carteira A significa: para cada R$ 1 de perda abaixo da Selic, a carteira gerou R$ 1,22 de ganho acima dela. Quanto mais alto, melhor. Omega é particularmente útil para comparar estratégias com distribuições diferentes — duas estratégias podem ter o mesmo Sharpe mas Omegas muito distintos se uma tiver caudas mais pesadas que a outra.
Armadilhas comuns ao reportar métricas
Janela curta inflando Sharpe
Sharpe calculado em janela de 1–2 anos é altamente instável. Uma única alta forte no início da janela pode inflar artificialmente a média sem mexer significativamente na volatilidade. A regra prática: Sharpe só é confiável com pelo menos 36 meses de retornos, idealmente 60+. Reportar Sharpe de 2,5 em backtest de 12 meses é convite para overfitting.
Taxa livre de risco fixa em ambiente de juros variável
Usar uma Selic constante (ex: 12% ao ano) para um período onde ela variou de 2% (pandemia) a 14% (aperto monetário) introduz erro sistemático. A Selic diária equivalente usada neste tutorial corrige isso ao deixar a taxa livre de risco se mover dia a dia. Para horizontes onde a Selic oscilou bastante (2020–2026, por exemplo), a diferença em Sharpe pode ser de 0,2 ponto inteiro.
Sobrevivência no universo do backtest
Calcular Sharpe sobre uma carteira de blue chips que sobreviveram até hoje ignora as empresas que faliram, foram delistadas ou sofreram OPA no caminho. O Sharpe verdadeiro de uma estratégia ativa rodada em 2018 com universo point-in-time seria menor que o calculado retroativamente com a composição atual. Survivorship bias infla todas as métricas de retorno e infla Sharpe ainda mais ao subestimar a cauda esquerda.
Custos de transação e impostos ignorados
Sharpe bruto é um número diferente de Sharpe líquido. Estratégias de alta rotação podem ter Sharpe bruto alto e Sharpe líquido próximo de zero depois de descontar emolumentos, slippage e IR de 15% sobre ganho de capital. Reporte sempre o Sharpe líquido para tomada de decisão real; o bruto serve apenas para diagnóstico inicial.
Cuidado com Sharpe acima de 2,0. Em mercados líquidos, Sharpe anualizado sustentado acima de 2,0 em janela longa é raríssimo (a renda fixa do governo americano teve Sharpe ~0,4 nos últimos 50 anos; o S&P 500 teve ~0,5). Sharpe muito alto em backtest geralmente indica look-ahead bias, survivorship bias, overfitting ou bug de unidade. Cheque a metodologia antes de comemorar.
Perguntas frequentes
O que é o índice de Sharpe e como interpretá-lo?
Sharpe é o retorno em excesso sobre a taxa livre de risco dividido pela volatilidade total da carteira. Mede quanto retorno acima do CDI/Selic você ganha por unidade de risco. Acima de 1 é bom, acima de 2 é excelente, abaixo de 0 indica que a carteira teve performance pior que a renda fixa de risco zero no período. A fórmula é Sharpe = (Rp − Rf) / σp, onde Rp é o retorno médio anualizado da carteira, Rf a taxa livre de risco anualizada e σp a volatilidade anualizada dos retornos.
Qual a diferença entre Sharpe e Sortino?
Sharpe pune toda volatilidade no denominador, incluindo dias de alta forte. Sortino só pune a volatilidade negativa, calculada apenas com retornos abaixo de um alvo (geralmente zero ou a taxa livre de risco). Carteiras com upside assimétrico, como estratégias long-only de small caps em ciclo de alta, costumam ter Sortino bem maior que Sharpe porque o desvio padrão captura a volatilidade positiva como se fosse risco, enquanto Sortino ignora ela. Sortino é mais coerente com a visão do investidor, que só se preocupa com perdas.
Por que multiplicar Sharpe por raiz quadrada de 252?
Volatilidade calculada a partir de retornos diários precisa ser anualizada para comparar com taxa livre de risco anual. O fator é raiz quadrada de 252 (número de pregões úteis em um ano) porque a variância dos retornos escala linearmente com o tempo sob hipótese de retornos independentes. O retorno médio diário também precisa ser anualizado, mas linearmente: multiplica por 252. Uma armadilha comum é misturar retornos diários no numerador e volatilidade anualizada no denominador, o que devolve um Sharpe absurdamente alto.
Como interpretar o drawdown máximo de uma carteira?
Drawdown máximo é a maior queda percentual do patrimônio entre um pico histórico e o vale subsequente. Mede a dor máxima que o investidor teria sentido se entrasse no pior momento possível. Drawdown de -40% significa que o patrimônio chegou a valer 40% menos que o pico anterior. Para recuperar, o investidor precisa de +66,7% de retorno (1 / 0,6 − 1). A duração do drawdown também importa: ficar 18 meses abaixo do pico exige outro tipo de paciência que ficar 3 meses.
Qual taxa livre de risco usar para Sharpe no Brasil?
Para Sharpe brasileiro, o padrão é usar CDI ou Selic acumulada no período como taxa livre de risco. CDI tende a ficar 0,1 ponto percentual abaixo da Selic; Selic é a referência teórica e tem cobertura histórica completa via API do BCB. Para Sharpe diário, divide a Selic anual por 252 para chegar ao excesso de retorno diário. Para Sharpe anualizado, basta usar a Selic média anualizada do período no numerador.
Leia também
- Backtest de carteira em Python com dados ajustados: como rodar a simulação de buy-and-hold e rebalanceamento mensal antes de calcular as métricas deste post
- BCB e séries macroeconômicas via API: Selic, CDI, IPCA, IGP-M e USD/BRL para usar como taxa livre de risco ou para ajustar retornos pela inflação
- Como construir um screener de ações em Python: filtrar o universo por fundamentos antes de montar a carteira que vai entrar no backtest
- Histórico de dividendos da bolsa brasileira: ajuste por proventos e cálculo de retorno total para entrar correto no Sharpe
- Dados históricos da B3 via API em Python: o endpoint
/stocks/{ticker}/historyem profundidade - API gratuita de ações brasileiras com 200 requisições por dia: limites do plano free e como aproveitar para rodar o tutorial completo
- ChatGPT na bolsa brasileira via GPT Actions: análise conversacional de Sharpe e drawdown com IA conectada à API
Próximos passos
Métricas de performance carteira separam estratégias que funcionam de estratégias que parecem funcionar. Sharpe, Sortino, Calmar e drawdown são o vocabulário mínimo para discutir uma carteira sem mentir sobre o risco. A bolsai fornece os dois insumos do cálculo: histórico ajustado da B3 desde 1986 via /stocks/{ticker}/history e Selic diária via /macro/selic como taxa livre de risco. O plano gratuito dá 200 requisições por dia, o suficiente para rodar todo o tutorial várias vezes. O plano Pro libera 10.000 requisições, ideal para varrer dezenas de carteiras candidatas e otimizar parâmetros.
Disclaimer: este conteúdo tem caráter educacional e não constitui recomendação de investimento. Métricas de performance medem comportamento histórico simulado a partir de premissas explícitas; resultados passados não garantem retorno futuro. Os tickers usados como exemplo foram escolhidos como amostra didática e não representam sugestão de compra ou venda. Análise de risco, adequação de perfil e consulta a profissional habilitado são de responsabilidade do leitor.