Tutorial por bolsai 11 de maio de 2026 14 min de leitura

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:

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}")
Pregões: 1241 Carteira A final: R$ 178.420 Carteira B final: R$ 165.840

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}%")
Selic média anualizada no período: 12.78% Selic diária equivalente média: 0.0479%

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}")
Sharpe Carteira A (defensiva): 0.642 Sharpe Carteira B (agressiva): 0.318

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 Carteira A: 0.918 Sortino Carteira B: 0.467 Razão Sortino/Sharpe A: 1.43x Razão Sortino/Sharpe B: 1.47x

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']}")
Carteira A Max drawdown: -18.4% Pico em: 2021-09-02 Vale em: 2022-07-14 Recuperado em: 2023-02-08 Queda (dias): 315 Total (dias): 524 Carteira B Max drawdown: -42.7% Pico em: 2021-06-15 Vale em: 2023-03-22 Recuperado em: ainda não recuperou Queda (dias): 645 Total (dias): None

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}")
Calmar Carteira A: 0.660 Calmar Carteira B: 0.249

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.

Ver documentação completa

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}")
Information Ratio Carteira A vs IBOV: +0.412 Information Ratio Carteira B vs IBOV: -0.187

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))
Carteira CAGR Vol Sharpe Sortino Calmar MaxDD IR vs IBOV A — Defensiva 12.16% 17.2% 0.64 0.92 0.66 -18.4% +0.41 B — Agressiva 10.62% 29.8% 0.32 0.47 0.25 -42.7% -0.19 BOVA11 (IBOV) 8.94% 22.1% 0.31 0.45 0.31 -28.5% —

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}%")
Carteira C (4 defensivas + KEPL3) CAGR: 18.42% Vol anual: 22.6% Sharpe: 0.74 Sortino: 1.38 Razão S/S: 1.86x MaxDD: -22.1%

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 Carteira A (threshold = Selic): 1.218 Omega Carteira B (threshold = Selic): 1.094

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

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.