"Covered call rende mais que buy-and-hold!" — você ouviu isso de alguém. Será que rende mesmo? Neste tutorial você vai construir, em Python, um backtest reproduzível de duas estratégias clássicas de opções (covered call e cash-secured put) usando o histórico real da B3 via API brapi. Em vez de "achismos", dados.
Por Que Backtestar?
Toda estratégia de opções tem dois jeitos de ser estudada:
- Mecânica: "como funciona a operação"
- Resultado: "como ela se comportou no passado"
A maioria dos materiais para. Mas a parte interessante começa onde param. Saber que covered call gera prêmio é trivial. Saber que ela rendeu 17% a.a. com 8% de drawdown em PETR4 entre 2018-2024 enquanto buy-and-hold rendeu 22% a.a. com 35% de drawdown — isso muda decisões.
Backtest é o instrumento que te tira do e se para o foi.
Backtest mostra o passado. Mercado muda. Custos de corretagem, spread bid-ask, slippage e regimes de volatilidade impactam o resultado real. Use o backtest como ferramenta de eliminação de estratégias ruins, não como promessa de retorno futuro.
O Que Vamos Backtestar
Estratégia 1: Covered Call (Lançamento Coberto)
Mecânica: você tem 100 ações de PETR4. Todo mês, vende 1 contrato de call OTM (ex.: 5% acima do spot) com vencimento no próximo terceiro friday. Recebe o prêmio.
Cenários no vencimento:
- Spot ≤ Strike: opção vira pó, você fica com as ações + prêmio. Repete no mês seguinte.
- Spot > Strike: você é exercido — vende as ações pelo strike, fica com (strike − preço de entrada) + prêmio + dividendos do período.
Tese: em mercados laterais ou de leve alta, prêmios mensais somam mais que o ganho perdido por exercício.
Estratégia 2: Cash-Secured Put
Mecânica: você tem dinheiro em caixa equivalente a 100×spot. Todo mês, vende 1 contrato de put OTM (ex.: 5% abaixo do spot). Recebe o prêmio.
Cenários no vencimento:
- Spot ≥ Strike: opção vira pó, você fica só com o prêmio (e os juros do caixa).
- Spot < Strike: você é exercido — compra as ações pelo strike (mais caro que o spot, mas com desconto via prêmio).
Tese: entrada com desconto + prêmio mensal enquanto espera.
O Que Vamos Construir
options-backtest/
├── data.py # Carrega dados de underlying e opções
├── strategies.py # Implementa as estratégias
├── metrics.py # Sharpe, drawdown, returns
├── plot.py # Visualizações
└── run.py # Pipeline principalPeríodo sugerido: 2 anos, mensal, com PETR4 como underlying (sandbox grátis na brapi).
Pré-requisitos
mkdir options-backtest && cd options-backtest
python -m venv .venv && source .venv/bin/activate
pip install requests pandas numpy matplotlib python-dateutil
export BRAPI_TOKEN="seu_token_pro" # opcional para PETR4Passo 1: Carregando Dados Históricos
Precisamos de duas coisas:
- OHLCV diário do PETR4 durante o período (
/api/quote/PETR4?range=2y) - OHLCV das séries de opção que vamos negociar (
/api/v2/options/historical)
import os
import requests
from datetime import date
import pandas as pd
BRAPI_QUOTE = "https://brapi.dev/api/quote"
BRAPI_OPTIONS = "https://brapi.dev/api/v2/options"
TOKEN = os.getenv("BRAPI_TOKEN", "")
HEADERS = {"Authorization": f"Bearer {TOKEN}"} if TOKEN else {}
def load_underlying_history(ticker: str, range_: str = "2y") -> pd.DataFrame:
"""Carrega OHLCV diário do underlying."""
resp = requests.get(
f"{BRAPI_QUOTE}/{ticker}",
params={"range": range_, "interval": "1d"},
headers=HEADERS,
timeout=60,
)
resp.raise_for_status()
data = resp.json()["results"][0].get("historicalDataPrice", [])
df = pd.DataFrame(data)
df["date"] = pd.to_datetime(df["date"], unit="s")
return df.set_index("date").sort_index()
def list_expirations(underlying: str, include_expired: bool = True) -> list[str]:
"""Vencimentos disponíveis (incluindo passados, para backtest)."""
resp = requests.get(
f"{BRAPI_OPTIONS}/expirations",
params={"underlying": underlying, "includeExpired": str(include_expired).lower()},
headers=HEADERS,
timeout=30,
)
resp.raise_for_status()
return resp.json()["expirations"]
def get_chain_at_date(
underlying: str,
expiration_date: str,
date_: str,
side: str | None = None,
) -> list[dict]:
"""Cadeia de opções em uma data específica do passado."""
params = {
"underlying": underlying,
"expirationDate": expiration_date,
"date": date_,
}
if side:
params["side"] = side
resp = requests.get(f"{BRAPI_OPTIONS}/chain", params=params, headers=HEADERS, timeout=30)
resp.raise_for_status()
return resp.json().get("series", [])
def get_option_history(symbol: str, expiration_date: str) -> pd.DataFrame:
"""OHLCV diário de uma série de opção."""
resp = requests.get(
f"{BRAPI_OPTIONS}/historical",
params={"symbol": symbol, "expirationDate": expiration_date},
headers=HEADERS,
timeout=60,
)
resp.raise_for_status()
payload = resp.json().get("option", {})
history = payload.get("history", [])
df = pd.DataFrame(history)
if df.empty:
return df
df["date"] = pd.to_datetime(df["date"], unit="s")
return df.set_index("date").sort_index()A API permite consulta histórica via date=YYYY-MM-DD em /chain, o que é exatamente o que um backtest precisa: "quais séries existiam e a que preço fechavam naquela data?"
Passo 2: Identificando o Vencimento Mensal
Precisamos rodar a estratégia mês a mês. Para isso, identificamos a primeira data de cada mês útil e o vencimento de opções mais próximo (geralmente terceira sexta-feira do mês):
from dateutil.relativedelta import relativedelta
def monthly_open_dates(start: date, end: date) -> list[date]:
"""Lista o primeiro dia útil de cada mês no intervalo."""
out: list[date] = []
cursor = date(start.year, start.month, 1)
while cursor <= end:
# Avança até o primeiro dia útil do mês
d = cursor
while d.weekday() >= 5: # 5=sat, 6=sun
d += relativedelta(days=+1)
out.append(d)
cursor = (cursor + relativedelta(months=+1)).replace(day=1)
return out
def pick_expiration_for_month(
expirations: list[str],
open_date: date,
) -> str | None:
"""Pega o próximo vencimento após a open_date."""
for exp_str in sorted(expirations):
exp = date.fromisoformat(exp_str)
if exp > open_date:
return exp_str
return NonePasso 3: Estratégia Covered Call
from dataclasses import dataclass, field
import pandas as pd
from data import (
load_underlying_history,
list_expirations,
get_chain_at_date,
monthly_open_dates,
pick_expiration_for_month,
)
from datetime import date
@dataclass
class Trade:
open_date: date
close_date: date
underlying_open_price: float
strike: float
premium_received: float
underlying_close_price: float
exercised: bool
pnl: float # PnL da posição em opção (prêmio − recompra ou exercício)
underlying_pnl: float # Variação do underlying no período
total_pnl: float # Soma
@dataclass
class CoveredCallConfig:
otm_pct: float = 0.05 # 5% acima do spot
contract_size: int = 100 # 1 contrato = 100 ações
min_trades_filter: int = 50 # Filtro mínimo de liquidez
def run_covered_call(
underlying: str,
start: date,
end: date,
config: CoveredCallConfig,
) -> list[Trade]:
underlying_df = load_underlying_history(underlying, range_="5y")
expirations = list_expirations(underlying)
trades: list[Trade] = []
for open_date in monthly_open_dates(start, end):
# Spot na abertura
try:
spot = float(underlying_df.loc[pd.Timestamp(open_date)]["close"])
except KeyError:
# Pega o pregão mais próximo
spot = float(underlying_df.loc[underlying_df.index >= pd.Timestamp(open_date)].iloc[0]["close"])
# Vencimento alvo
exp = pick_expiration_for_month(expirations, open_date)
if exp is None:
continue
# Cadeia naquela data
chain = get_chain_at_date(
underlying=underlying,
expiration_date=exp,
date_=open_date.isoformat(),
side="call",
)
# Filtra strikes ≥ spot×(1+otm_pct), pega o menor (mais próximo do dinheiro mas ainda OTM)
target_strike = spot * (1 + config.otm_pct)
candidates = [
s for s in chain
if s["strike"] >= target_strike
and s["trades"] >= config.min_trades_filter
and s.get("close") is not None
]
if not candidates:
continue
chosen = min(candidates, key=lambda s: s["strike"])
# Preço de venda do prêmio: usa close do open_date (proxy aceitável de execução EOD)
premium = float(chosen["close"])
# Spot no vencimento
exp_date = date.fromisoformat(exp)
try:
close_spot = float(underlying_df.loc[underlying_df.index >= pd.Timestamp(exp_date)].iloc[0]["close"])
except IndexError:
continue
strike = float(chosen["strike"])
exercised = close_spot > strike
# PnL da call vendida (você lucra se ela vira pó; perde se a paga acima do prêmio recebido no exercício)
# Simplificação: se exercido, paga max(close_spot − strike, 0) a quem comprou
if exercised:
option_pnl = (premium - (close_spot - strike)) * config.contract_size
# Underlying pnl: vc vendeu no strike (não no close_spot), então capture só até strike
underlying_pnl = (strike - spot) * config.contract_size
else:
option_pnl = premium * config.contract_size
underlying_pnl = (close_spot - spot) * config.contract_size
trades.append(Trade(
open_date=open_date,
close_date=exp_date,
underlying_open_price=spot,
strike=strike,
premium_received=premium,
underlying_close_price=close_spot,
exercised=exercised,
pnl=option_pnl,
underlying_pnl=underlying_pnl,
total_pnl=option_pnl + underlying_pnl,
))
return tradesPasso 4: Estratégia Cash-Secured Put
@dataclass
class CashSecuredPutConfig:
otm_pct: float = 0.05
contract_size: int = 100
min_trades_filter: int = 50
# Yield do caixa colocado em CDB / SELIC equivalente
cash_yield_annual: float = 0.10 # 10% a.a.
def run_cash_secured_put(
underlying: str,
start: date,
end: date,
config: CashSecuredPutConfig,
) -> list[Trade]:
underlying_df = load_underlying_history(underlying, range_="5y")
expirations = list_expirations(underlying)
trades: list[Trade] = []
for open_date in monthly_open_dates(start, end):
try:
spot = float(underlying_df.loc[pd.Timestamp(open_date)]["close"])
except KeyError:
spot = float(underlying_df.loc[underlying_df.index >= pd.Timestamp(open_date)].iloc[0]["close"])
exp = pick_expiration_for_month(expirations, open_date)
if exp is None:
continue
chain = get_chain_at_date(
underlying=underlying,
expiration_date=exp,
date_=open_date.isoformat(),
side="put",
)
target_strike = spot * (1 - config.otm_pct)
# Pega a put OTM mais próxima do spot (strike ≤ target_strike, maior strike primeiro)
candidates = [
s for s in chain
if s["strike"] <= target_strike
and s["trades"] >= config.min_trades_filter
and s.get("close") is not None
]
if not candidates:
continue
chosen = max(candidates, key=lambda s: s["strike"])
premium = float(chosen["close"])
strike = float(chosen["strike"])
exp_date = date.fromisoformat(exp)
try:
close_spot = float(underlying_df.loc[underlying_df.index >= pd.Timestamp(exp_date)].iloc[0]["close"])
except IndexError:
continue
exercised = close_spot < strike
# Caixa rende no período
days = (exp_date - open_date).days
cash_pnl = strike * config.contract_size * config.cash_yield_annual * days / 365
if exercised:
# Comprou no strike, mark-to-market no close_spot
option_pnl = (premium - (strike - close_spot)) * config.contract_size
else:
option_pnl = premium * config.contract_size
trades.append(Trade(
open_date=open_date,
close_date=exp_date,
underlying_open_price=spot,
strike=strike,
premium_received=premium,
underlying_close_price=close_spot,
exercised=exercised,
pnl=option_pnl,
underlying_pnl=cash_pnl, # neste caso "underlying" é o caixa rendendo
total_pnl=option_pnl + cash_pnl,
))
return tradesPasso 5: Métricas de Performance
import numpy as np
import pandas as pd
from strategies import Trade
def trades_to_returns(trades: list[Trade], capital_per_trade: float) -> pd.Series:
"""Converte trades em série temporal de retornos mensais (% sobre capital alocado)."""
df = pd.DataFrame([
{"date": t.close_date, "pnl": t.total_pnl}
for t in trades
]).set_index("date").sort_index()
df["return"] = df["pnl"] / capital_per_trade
return df["return"]
def sharpe_ratio(returns: pd.Series, risk_free_monthly: float = 0.008) -> float:
"""Sharpe anualizado (assumindo retornos mensais)."""
excess = returns - risk_free_monthly
if excess.std() == 0:
return 0.0
return float(excess.mean() / excess.std() * np.sqrt(12))
def max_drawdown(returns: pd.Series) -> float:
"""Maximum drawdown da curva de capital."""
equity = (1 + returns).cumprod()
peak = equity.cummax()
drawdown = (equity - peak) / peak
return float(drawdown.min())
def cagr(returns: pd.Series) -> float:
"""CAGR a partir da série de retornos mensais."""
if returns.empty:
return 0.0
total_return = (1 + returns).prod()
n_years = len(returns) / 12
return float(total_return ** (1 / n_years) - 1) if n_years > 0 else 0.0
def summary(trades: list[Trade], capital_per_trade: float, label: str) -> dict:
returns = trades_to_returns(trades, capital_per_trade)
n_assigned = sum(1 for t in trades if t.exercised)
return {
"label": label,
"trades": len(trades),
"assignment_rate": n_assigned / len(trades) if trades else 0,
"cagr": cagr(returns),
"sharpe": sharpe_ratio(returns),
"max_dd": max_drawdown(returns),
"total_pnl": sum(t.total_pnl for t in trades),
}Passo 6: Pipeline Final
from datetime import date
import pandas as pd
from strategies import (
CoveredCallConfig,
CashSecuredPutConfig,
run_covered_call,
run_cash_secured_put,
)
from metrics import summary, trades_to_returns
from data import load_underlying_history
def buy_and_hold_returns(ticker: str, start: date, end: date) -> pd.Series:
"""Retornos mensais de buy-and-hold para comparação."""
df = load_underlying_history(ticker, range_="5y")
monthly = df["close"].resample("MS").first()
monthly = monthly[(monthly.index.date >= start) & (monthly.index.date <= end)]
return monthly.pct_change().dropna()
def main() -> None:
underlying = "PETR4"
start = date(2023, 5, 1)
end = date(2025, 4, 30)
print(f"Backtestando {underlying} de {start} até {end}\n")
# 1) Covered Call
cc_config = CoveredCallConfig(otm_pct=0.05)
cc_trades = run_covered_call(underlying, start, end, cc_config)
cc_summary = summary(cc_trades, capital_per_trade=100 * cc_trades[0].underlying_open_price, label="Covered Call OTM 5%")
# 2) Cash-Secured Put
csp_config = CashSecuredPutConfig(otm_pct=0.05)
csp_trades = run_cash_secured_put(underlying, start, end, csp_config)
csp_summary = summary(csp_trades, capital_per_trade=100 * csp_trades[0].underlying_open_price, label="Cash-Secured Put OTM 5%")
# 3) Buy and Hold
bh_returns = buy_and_hold_returns(underlying, start, end)
bh_summary = {
"label": "Buy and Hold",
"trades": len(bh_returns),
"assignment_rate": None,
"cagr": (1 + bh_returns).prod() ** (12 / len(bh_returns)) - 1,
"sharpe": (bh_returns.mean() / bh_returns.std()) * (12 ** 0.5) if bh_returns.std() > 0 else 0,
"max_dd": float((((1 + bh_returns).cumprod() / (1 + bh_returns).cumprod().cummax()) - 1).min()),
"total_pnl": None,
}
# 4) Resumo comparativo
print(pd.DataFrame([cc_summary, csp_summary, bh_summary]).to_string(index=False))
if __name__ == "__main__":
main()Resultados Hipotéticos (Exemplo Ilustrativo)
Rodando sobre PETR4 entre 2023-2025, você esperaria algo como:
label trades assignment_rate cagr sharpe max_dd
Covered Call 5% 24 0.42 0.184 1.21 -0.08
Cash-Secured Put 5% 24 0.30 0.146 1.05 -0.06
Buy and Hold 24 NaN 0.221 0.78 -0.31Leitura típica desses números:
- Buy-and-hold tem o maior CAGR mas o maior drawdown.
- Covered Call entrega CAGR menor mas com drawdown 4× menor — Sharpe maior.
- Cash-Secured Put fica entre os dois, com a vantagem de ter sempre caixa rendendo Selic.
A pergunta correta não é "qual rendeu mais?" e sim "dado meu apetite a risco, qual rendeu melhor por unidade de risco?" — e é aí que Sharpe e drawdown brilham.
Os números acima são ilustrativos. Rode o código com seu período e underlying. PETR4 num ciclo de alta de commodities terá comportamento bem diferente de IBOV num ciclo lateral. Generalizar uma estratégia a partir de um único backtest é o erro mais comum de quem está começando.
Visualização
Comparando curvas de capital:
import matplotlib.pyplot as plt
from metrics import trades_to_returns
def plot_equity_curves(strategies: dict, output_path: str) -> None:
"""strategies: dict de label -> pd.Series de retornos mensais"""
fig, ax = plt.subplots(figsize=(10, 6))
for label, returns in strategies.items():
equity = (1 + returns).cumprod()
ax.plot(equity.index, equity.values, label=label, linewidth=2)
ax.set_xlabel("Data")
ax.set_ylabel("Capital normalizado (1.0 = início)")
ax.set_title("Curvas de Capital — Estratégias com Opções vs Buy-and-Hold")
ax.legend()
ax.grid(True, alpha=0.3)
fig.tight_layout()
fig.savefig(output_path, dpi=120)
print(f"Gráfico salvo em {output_path}")Variações da Estratégia
A) Strikes Mais Distantes (10% OTM)
Reduz frequência de exercício mas o prêmio é menor. Ideal para mercados com tendência de alta forte.
cc_config = CoveredCallConfig(otm_pct=0.10)B) Filtro por Volatilidade Implícita
Você só vende a call se a IV estiver acima da mediana histórica:
def should_sell(implied_vol: float, threshold: float) -> bool:
return implied_vol >= thresholdA API atual da brapi não retorna IV diretamente — você calcula via Black-Scholes invertido sobre o close da série. Veja a próxima evolução do tutorial em Cadeia de Opções com Python.
C) Rolagem em vez de Exercício
Em vez de deixar a call ser exercida, recompre a call quando spot cruza o strike e venda outra mais para fora:
def maybe_roll(trade: Trade, current_spot: float, current_premium: float) -> Trade | None:
"""Recompra a call atual e abre uma nova com strike mais alto."""
# Implementação fica como exercício
...D) Position Sizing por Volatilidade
Ajuste o número de contratos com base na volatilidade do underlying — em momentos turbulentos, opera menos.
Cuidados e Limitações
Slippage e custos não estão modelados
Este backtest assume execução no close do dia. No mundo real, você paga
spread bid-ask, corretagem e impostos. Estimativa conservadora: subtraia
0.5% a 1% do retorno bruto para chegar no líquido aproximado. Para PETR4
isso é tolerável; para opções menos líquidas, pode ser maior.
Sobrevivência e seleção
PETR4 sobreviveu, é líquida e tem opções. Cuidado em generalizar para ações menores. Em um portfólio diversificado, faça o mesmo backtest em vários underlyings e olhe a média.
Vencimento mensal vs semanal
Atualmente a B3 tem opções com vencimento mensal e semanal. Este backtest
usa o vencimento mais próximo, mas você pode adaptar pick_expiration_for_month
para sempre selecionar o terceiro friday do mês (vencimento mensal padrão).
Imposto de renda não modelado
Operações com opções têm tratamento tributário específico (15% para day-trade, 15% para operações comuns acima do limite de isenção, com recolhimento mensal via DARF). Consulte um contador para o cenário real.
Indo Além
A partir desse esqueleto, você pode evoluir para:
- Iron Condor / Spreads — combinar 4 opções
- Volatility Smile — backtestar entrada apenas quando IV ATM > histórica
- Opções sobre IBOV — com
underlying=IBOV(plano Pro) - Cobertura dinâmica (delta hedging) — recompra parcial quando delta excede limite
Conclusão
O backtest é o filtro mais barato que existe para separar boas estratégias de hipóteses caras. Em ~300 linhas de Python e o histórico aberto da brapi, você passa de "será que covered call funciona?" para uma resposta quantitativa com seus parâmetros, no seu underlying, no seu período.
Comece com o código deste post, rode em PETR4 (sandbox grátis), depois evolua para outros tickers do plano Pro. Em poucas semanas você terá uma biblioteca pessoal de estratégias medidas — não chutadas.
Próximos passos: se você ainda não tem familiaridade com a cadeia de opções, leia primeiro o tutorial de Cadeia de Opções B3 com Python. Para entender a mecânica das estratégias, veja o guia de Covered Call e o guia de Opções para Iniciantes.
Disclaimer: backtest não é promessa de retorno futuro. Opções têm risco de perda total ou superior ao capital. Estude e simule antes de operar com dinheiro real.
