Guia de Retornos de Portfólio e Duration¶
Calcule retornos, YTM e sensibilidade de taxa de juros para portfólios de renda fixa.
Conceitos Principais¶
Yield to Maturity (YTM)¶
Retorno anual se o título for mantido até o vencimento. Contabiliza:
- Preço de mercado atual
- Pagamentos de cupons
- Reembolso de principal no vencimento
- Reinvestimento no mesmo yield
Para títulos brasileiros, YTM já é fornecido por tesouro-direto-fetcher.
Duration Modificada¶
Medida de sensibilidade de preço do título a mudanças em taxas de juros:
Regra de ouro: Aumento de 1% no yield → queda de \(D_{\text{modificada}}\)% no preço
Exemplo: Título de 5 anos com duration de 5 anos:
- Yield aumenta 1% → Preço cai ~5%
- Yield cai 1% → Preço sobe ~5%
Juros Acumulados¶
Juros ganhos desde a última data de cupom mas ainda não pagos:
Preço clean: Preço do título cotado (ex-acumulado)
Preço dirty: Preço clean + juros acumulados = o que você realmente paga
Retorno Real (Indexado ao IPCA)¶
Para títulos NTN-B, separe o yield nominal em componentes:
Exemplo:
- Yield NTN-B: 5.0%
- IPCA esperado: 3.5%
- Yield real implícito: 1.5%
Cálculos Rápidos¶
Holding de Título Único¶
import polars as pl
from datetime import date
from pathlib import Path
from tesouro_direto_fetcher import reader
from tesouro_direto_fetcher.constants import Column as C, BondType
# Ler CSV de preços baixado com `tesouro-direto-fetcher download --dataset prices`
prices = reader.read_prices(next(Path("./data").glob("taxas-dos-titulos*.csv")))
# Escolher um título: NTN-B (Tesouro IPCA+ com Juros Semestrais) vencendo 2027-05-15
bond = prices.filter(
(pl.col(C.BOND_TYPE.value) == BondType.NTNB.value)
& (pl.col(C.MATURITY_DATE.value) == date(2027, 5, 15))
).sort(C.REFERENCE_DATE.value).tail(1).row(0, named=True)
face_value = 1000.0
purchase_price = bond[C.BUY_PRICE.value] # ask atual, em BRL
ytm_pct = bond[C.BUY_YIELD.value] # já em percentual no CSV
print(f"Preço de compra: R${purchase_price:,.2f}")
print(f"YTM anual: {ytm_pct:.2f}%")
Yield Médio Ponderado de Portfólio¶
import polars as pl
# Portfólio
portfolio = pl.DataFrame({
"bond_name": ["LTN_2025", "NTN-B_2027", "LFT_2026"],
"quantity": [100, 50, 75], # Unidades detidas
"current_price": [97.50, 105.25, 100.10], # Preço clean
"ytm": [9.5, 5.8, 10.6] # YTM anual %
})
# Calcular valor do portfólio e yield ponderado
portfolio = portfolio.with_columns([
(pl.col("quantity") * pl.col("current_price")).alias("market_value")
])
total_value = portfolio["market_value"].sum()
portfolio_ytm = (
(portfolio["market_value"] * portfolio["ytm"]).sum() / total_value
)
print(f"Valor total do portfólio: R${total_value:,.2f}")
print(f"YTM do portfólio: {portfolio_ytm:.2f}%")
# Mostrar alocação
portfolio = portfolio.with_columns([
(pl.col("market_value") / total_value * 100).alias("allocation_%")
])
print(portfolio.select(["bond_name", "allocation_%"]))
Duration de Portfólio (Risco de Taxa de Juros)¶
import polars as pl
# Portfólio com dados de duration
portfolio = pl.DataFrame({
"bond_name": ["LTN_2025", "NTN-B_2027", "LFT_2026"],
"market_value": [97500, 52625, 75075], # Valor total em Reais
"duration": [0.9, 4.5, 1.2] # Duration modificada em anos
})
# Calcular duration do portfólio
total_value = portfolio["market_value"].sum()
portfolio_duration = (
(portfolio["market_value"] * portfolio["duration"]).sum() / total_value
)
print(f"Duration do portfólio: {portfolio_duration:.2f} anos")
print(f"Se yields subirem 1%, portfólio perde ~{portfolio_duration:.2f}%")
print(f"Se yields caírem 1%, portfólio ganha ~{portfolio_duration:.2f}%")
# Mostrar duration por posição
portfolio = portfolio.with_columns([
(pl.col("market_value") / total_value * 100).alias("weight_%"),
(pl.col("duration") * pl.col("market_value") / total_value).alias("duration_contribution")
])
print(portfolio.select(["bond_name", "weight_%", "duration", "duration_contribution"]))
Análise de Choque de Taxa¶
Como o portfólio se sai se yields mudam?
import polars as pl
bonds = pl.DataFrame({
"name": ["LTN_2025", "NTN-B_2027"],
"clean_price": [97.50, 105.25],
"quantity": [100, 50],
"duration": [0.9, 4.5],
"ytm": [9.5, 5.8]
})
# Valor de mercado atual
bonds = bonds.with_columns([
(pl.col("clean_price") * pl.col("quantity")).alias("market_value")
])
current_value = bonds["market_value"].sum()
# Calcular impacto de preço das mudanças de yield usando duration
# Preço novo ≈ Preço antigo - Duration × Preço × ΔYield
yield_changes = [-1.0, -0.5, 0.0, 0.5, 1.0] # Pontos percentuais
print(f"Valor atual do portfólio: R${current_value:,.2f}\n")
print("Mudança de yield | Novo valor | P&L")
print("-" * 40)
for dy in yield_changes:
new_price = bonds.with_columns([
(pl.col("clean_price") * (1 - pl.col("duration") * dy / 100)).alias("new_price")
])
new_value = (new_price["new_price"] * new_price["quantity"]).sum()
pnl = new_value - current_value
print(f"{dy:+.1f}% | R${new_value:,.0f} | R${pnl:+,.0f}")
Precificação de Títulos¶
Preço Clean vs. Preço Dirty¶
import polars as pl
bond = pl.DataFrame({
"bond": ["NTN-B_2027"],
"clean_price": [105.25],
"accrued_interest": [1.45], # Do último cupom
"quantity": [50]
})
# Calcular custo total
bond = bond.with_columns([
pl.col("clean_price") + pl.col("accrued_interest"),
(
(pl.col("clean_price") + pl.col("accrued_interest")) *
pl.col("quantity") / 100
).alias("total_cost")
])
print(f"Preço clean: R${bond['clean_price'][0]}")
print(f"Juros acumulados: R${bond['accrued_interest'][0]}")
print(f"Preço dirty: R${bond['clean_price'][0] + bond['accrued_interest'][0]}")
print(f"Custo total para {bond['quantity'][0]} unidades: R${bond['total_cost'][0]:,.2f}")
Cálculo de Retorno Real (Títulos Indexados ao IPCA)¶
Método 1: Usando YTM Fornecido¶
import polars as pl
# NTN-B com YTM e inflação realizada
bonds = pl.DataFrame({
"date": ["2023-01-01", "2024-01-01"],
"yield": [5.8, 6.2], # Yield nominal %
"ipca_realized": [10.1, 4.5] # IPCA realizado %
})
# Retorno real = (1 + Nominal) / (1 + Inflação) - 1
bonds = bonds.with_columns([
(
((1 + pl.col("yield") / 100) / (1 + pl.col("ipca_realized") / 100)) - 1
).alias("real_return")
])
print(bonds.select(["date", "yield", "ipca_realized", "real_return"]))
Método 2: Inflação de Equilíbrio¶
Qual taxa de inflação deixa indiferente entre prefixado e indexado ao IPCA?
# LTN (prefixado) yield
ltn_yield = 0.095 # 9.5%
# NTN-B (indexado ao IPCA) yield
ntb_yield = 0.058 # 5.8%
# Inflação de equilíbrio = (1 + yield LTN) / (1 + yield NTN-B) - 1
breakeven = ((1 + ltn_yield) / (1 + ntb_yield)) - 1
print(f"Yield LTN: {ltn_yield*100:.1f}%")
print(f"Yield NTN-B: {ntb_yield*100:.1f}%")
print(f"Mercado espera inflação: {breakeven*100:.1f}%")
Pipeline de Produção¶
Script de Atualização Diária¶
import asyncio
import polars as pl
from pathlib import Path
from tesouro_direto_fetcher import downloader, reader
from tesouro_direto_fetcher.constants import Column as C
data_dir = Path("data/treasury")
data_dir.mkdir(parents=True, exist_ok=True)
# 1. Buscar CSV de preços mais recente (idempotente em Last-Modified)
asyncio.run(
downloader.download(
data_dir,
dataset_id="taxas-dos-titulos-ofertados-pelo-tesouro-direto",
max_concurrency=3,
)
)
# 2. Ler CSV mais novo
latest_csv = max(data_dir.glob("taxas-dos-titulos*.csv"), key=lambda p: p.stat().st_mtime)
prices = reader.read_prices(latest_csv)
# 3. Anexar ao histórico em execução (dedup por reference_date + bond_type + maturity)
history_file = data_dir / "prices_history.parquet"
if history_file.exists():
existing = pl.read_parquet(history_file)
prices = pl.concat([existing, prices]).unique(
subset=[C.REFERENCE_DATE.value, C.BOND_TYPE.value, C.MATURITY_DATE.value]
)
prices.write_parquet(history_file)
# 4. Snapshot da data de referência mais recente
latest_date = prices[C.REFERENCE_DATE.value].max()
latest = prices.filter(pl.col(C.REFERENCE_DATE.value) == latest_date)
latest.write_parquet(data_dir / "latest_snapshot.parquet")
print(f"Atualizado {prices.height:,} linhas; data de referência mais recente {latest_date}")
# 5. Agregados por tipo de título
metrics = (
latest.group_by(C.BOND_TYPE.value)
.agg(
avg_buy_yield=pl.col(C.BUY_YIELD.value).mean(),
avg_buy_price=pl.col(C.BUY_PRICE.value).mean(),
n_maturities=pl.col(C.MATURITY_DATE.value).n_unique(),
)
)
metrics.write_parquet(data_dir / "portfolio_metrics.parquet")
Métricas de Dashboard de Monitoramento¶
import polars as pl
from datetime import datetime
# Carregar dados mais recentes
bonds = pl.read_parquet("data/treasury/latest_snapshot.parquet")
# Métricas-chave por tipo de título
metrics = bonds.group_by("bond_type").agg([
pl.col("yield").mean().alias("avg_ytm"),
pl.col("duration").mean().alias("avg_duration"),
pl.col("volume").sum().alias("total_volume"),
pl.col("bond_id").count().alias("num_bonds")
]).sort("bond_type")
print(f"Dashboard Tesouro - {datetime.now().strftime('%Y-%m-%d')}\n")
print(metrics)
# Curva de yield (exemplo NTN-B)
ntb = bonds.filter(pl.col("bond_type") == "NTN-B").sort("maturity_date")
print("\nCurva de Yield NTN-B:")
print(ntb.select(["maturity_date", "yield", "duration"]))
Armadilhas Comuns¶
Erro 1: Confundir Preço Clean com Preço Dirty¶
Sempre lembre: você paga preço dirty mas cotações de títulos são preço clean.
# Errado: Comprando 50 títulos cotados em 105.25
cost = 50 * 105.25 * 10 # Errado: não inclui juros acumulados
# Certo: Adicionar juros acumulados
cost = 50 * (105.25 + 1.45) * 10 # Correto
Erro 2: Ignorar Datas de Cupom¶
Cupons são pagos em datas específicas. Se compra entre datas de cupom, vendedor leva seus juros acumulados:
# Se compra título entre datas de cupom
# 1. Você paga juros acumulados ao vendedor
# 2. Na próxima data de cupom, recebe cupom cheio
# 3. Efeito líquido: você ganha juros acumulados até data de cupom
Erro 3: Usar YTM para Retornos de Curto Prazo¶
YTM assume manutenção até vencimento. Para trades de curto prazo, use:
Retorno de preço = (Preço novo - Preço de compra) / Preço de compra
Retorno total = Retorno de preço + Cupom recebido / Preço de compra
Erro 4: Esquecer Risco de Reinvestimento¶
YTM assume que cupons são reinvestidos no mesmo yield. Na realidade:
# Yields menores ao reinvestir cupons
current_yield = 5.8 # %
coupon_yield = 4.5 # % para reinvestimento
# Retorno real < YTM
Saiba Mais¶
- Visão Geral do Tesouro
- Ferramenta tesouro-direto-fetcher
- Dados IBGE — Para dados de inflação