The Brazilian sovereign fixed income market, primarily accessed through the Tesouro Direto platform, presents unique structural characteristics that require tailored quantitative approaches. Unlike more liquid and standardized sovereign bond markets, this market offers a diverse set of securities — including fixed-rate bonds (Tesouro Prefixado), floating-rate bonds (Tesouro Selic), and inflation-linked bonds (Tesouro IPCA+) — each with distinct cash flow profiles, coupon structures, and indexation mechanisms.
A particular feature of this market is its daily compounding convention based on 252 business days, combined with semi-annual or annual coupon payments, depending on the bond type. Additionally, the rotation of offered securities and the reliance on updated Nominal Par Value (VNA) for IPCA-linked instruments add further complexity to return calculation.
Given this landscape, the objective of this study is to apply Harry Markowitz’s Modern Portfolio Theory (MPT) to construct a minimum variance portfolio of Brazilian sovereign bonds. By leveraging quantitative optimization techniques, the analysis aims to provide a robust, replicable, and operationally relevant framework for balancing risk and return in this specific market environment.
This study focuses on three representative security types:
• Tesouro Selic (LFT) — floating-rate bonds indexed to the Selic overnight rate, with daily compounding.
• Tesouro IPCA+ com Cupom (NTN-B) — inflation-linked bonds with semi-annual coupon payments indexed to the VNA.
• Tesouro Prefixado com Cupom (NTN-F) — fixed-rate bonds with semi-annual coupon payments.
Data is collected from official and high-quality sources to ensure accuracy and consistency:
• Tesouro Direto API via
GetTDData: Provides daily historical prices
and yields for all publicly traded government bonds.
• Secretaria do Tesouro Nacional (STN) via
readx: Supplies the official VNA series,
essential for the correct computation of coupon payments on NTN-B
instruments.
Since Tesouro Direto bonds distribute coupons periodically, using raw price returns alone would underestimate total performance. To address this, the return series is adjusted to include coupon reinvestment:
• Coupon Calculation — Custom R functions determine the coupon amount for each bond on its exact payment dates, incorporating the updated VNA (in the case of NTN-B) or nominal value (NTN-F).
• Adjusted Total Return Series — Coupon values are added back to price series, producing a total return index for each instrument. This ensures an accurate representation of the asset’s effective yield over time.
We use a minimal and powerful set of R packages for data acquisition and transformation:
library(GetTDData) # Official Tesouro Direto data
library(readxl) # VNA series (Excel files)
library(tidyverse) # Data wrangling and transformation
library(tidyr) # Data reshaping
library(xts) # Time series manipulation
The GetTDData package allows direct and structured
access to Tesouro Direto’s historical database. Below, we download the
full series for the three main bond categories used in this study:
NTNB <- GetTDData::td_get('NTN-B') %>% # Inflation-linked bonds (both with coupon and zero-coupon)
dplyr::select(ref_date, asset_code, price_bid) %>%
tidyr::pivot_wider(names_from = asset_code, values_from = price_bid) %>%
dplyr::arrange(ref_date) %>%
as.xts()
NTNF <- GetTDData::td_get('NTN-F') %>% # Tesouro Prefixado (Fixed-rate)
dplyr::select(ref_date, asset_code, price_bid) %>%
tidyr::pivot_wider(names_from = asset_code, values_from = price_bid) %>%
dplyr::arrange(ref_date) %>%
as.xts()
LFT <- GetTDData::td_get('LFT') %>% # Tesouro Selic (Floating-rate)
dplyr::select(ref_date, asset_code, price_bid) %>%
tidyr::pivot_wider(names_from = asset_code, values_from = price_bid) %>%
dplyr::arrange(ref_date) %>%
as.xts()
It reshapes the original datasets into a wide format, where each bond series becomes a column and each observation date becomes a row. This transformation simplifies subsequent time series manipulation, analysis, and portfolio construction, as all bond prices are now aligned by date and easily accessible for computations such as return calculation, risk assessment, and optimization.
tail(NTNB, 10)
## NTN-B 150806 NTN-B 150507 NTN-B 150808 NTN-B 150509 NTN-B 150515 NTN-B 150824 NTN-B 150545 NTN-B 150810 NTN-B 150511
## 2025-10-06 NA NA NA NA NA NA 4077.64 NA NA
## 2025-10-07 NA NA NA NA NA NA 4067.10 NA NA
## 2025-10-08 NA NA NA NA NA NA 4060.73 NA NA
## 2025-10-09 NA NA NA NA NA NA 4077.09 NA NA
## 2025-10-10 NA NA NA NA NA NA 4067.79 NA NA
## 2025-10-13 NA NA NA NA NA NA 4057.24 NA NA
## 2025-10-14 NA NA NA NA NA NA 4022.35 NA NA
## 2025-10-15 NA NA NA NA NA NA 4027.82 NA NA
## 2025-10-16 NA NA NA NA NA NA 4053.68 NA NA
## 2025-10-17 NA NA NA NA NA NA 4019.08 NA NA
## NTN-B 150535 NTN-B 150812 NTN-B 150517 NTN-B 150513 NTN-B 150820 NTN-B 150850 NTN-B 150826 NTN-B 150830 NTN-B 150840
## 2025-10-06 4192.69 NA NA NA NA 3980.35 4453.83 4255.73 4061.22
## 2025-10-07 4180.89 NA NA NA NA 3959.49 4455.30 4247.90 4055.85
## 2025-10-08 4182.87 NA NA NA NA 3961.31 4456.10 4249.97 4050.49
## 2025-10-09 4196.53 NA NA NA NA 3983.83 4457.71 4266.41 4064.83
## 2025-10-10 4194.25 NA NA NA NA 3973.21 4460.86 4269.74 4057.06
## 2025-10-13 4185.15 NA NA NA NA 3965.89 4461.94 4268.42 4040.84
## 2025-10-14 4162.44 NA NA NA NA 3931.75 4461.05 4255.59 4006.93
## 2025-10-15 4169.41 NA NA NA NA 3937.58 4461.42 4262.12 4011.91
## 2025-10-16 4195.56 NA NA NA NA 3961.37 4466.06 4271.93 4038.32
## 2025-10-17 4178.46 NA NA NA NA 3927.52 4469.98 4267.53 4011.76
## NTN-B 150555 NTN-B 150832 NTN-B 150860
## 2025-10-06 4020.87 4173.64 3913.74
## 2025-10-07 3998.87 4162.71 3891.03
## 2025-10-08 4005.45 4164.72 3897.70
## 2025-10-09 4024.37 4184.05 3917.15
## 2025-10-10 4017.85 4182.98 3910.29
## 2025-10-13 4014.84 4182.75 3907.10
## 2025-10-14 3983.47 4161.04 3874.79
## 2025-10-15 3984.86 4171.16 3876.14
## 2025-10-16 4014.61 4187.75 3906.62
## 2025-10-17 3974.12 4176.96 3864.95
To calculate the coupons for NTN-B bonds, we require the historical
series of the Nominal Par Value (VNA). This data can be obtained
directly from the Secretaria do Tesouro Nacional
(STN) website as an Excel file, which can be read using
the read_excel function from the readxl
package. The following function automates this process:
download_vna_tibble <- function(url, sheet = 1) {
tmp <- tempfile(fileext = ".xlsx")
download.file(url, destfile = tmp, mode = "wb", quiet = TRUE)
raw_data <- read_excel(
tmp,
sheet = sheet,
skip = 8,
col_names = TRUE
)
data <- raw_data[-1, ] %>%
select(1:2) %>%
rename(
Date = 1,
VNA = 2
) %>%
mutate(
Date = as.Date(Date),
VNA = as.numeric(VNA)
)
return(as_tibble(data))
}
url_vna <- "https://thot-arquivos.tesouro.gov.br/publicacao/28715"
Coupons <- download_vna_tibble(url_vna) %>%
mutate(Coupom_NTNB = VNA * (1.06 ^ 0.5 - 1)) %>% # NTN-F Coupon Semiannual
mutate(Coupom_NTNF = 1000 * (1.1 ^ 0.5 - 1)) # NTN-B Coupon Semiannual
This process generates a tibble with four columns: the first column contains the Date, the second column holds the VNA (the updated Nominal Par Value for Tesouro IPCA+ bonds), the third column computes the semiannual coupon for NTN-B bonds, and the fourth column shows the semiannual coupon for NTN-F bonds, based on a fixed 10% annual rate (equivalent to 4.881% per semester on a par value of 1000).
tail(Coupons, 10)
## # A tibble: 10 × 4
## Date VNA Coupom_NTNB Coupom_NTNF
## <date> <dbl> <dbl> <dbl>
## 1 2025-01-15 4398. 130. 48.8
## 2 2025-02-15 4405. 130. 48.8
## 3 2025-03-15 4462. 132. 48.8
## 4 2025-04-15 4487. 133. 48.8
## 5 2025-05-15 4507. 133. 48.8
## 6 2025-06-15 4518. 134. 48.8
## 7 2025-07-15 4529. 134. 48.8
## 8 2025-08-15 4541. 134. 48.8
## 9 2025-09-15 4536. 134. 48.8
## 10 2025-10-15 4558. 135. 48.8
In order to adjust the copun to the prices, and to avoid inconsistencis, we are selecting witch bonds are gonna be part of the portfolio.
selected_prices <- c("NTN-B 150830", "NTN-B 150840", "NTN-B 150555", "NTN-F 010127", "LFT 010327")
Portfolio_Bonds <- {
xts_list <- lapply(list(NTNB, NTNF, LFT), function(x) x[, colnames(x) %in% selected_prices, drop = FALSE])
na.omit(Reduce(function(x, y) merge(x, y, join = "inner"), xts_list))
}
tail(Portfolio_Bonds, 10)
## NTN.B.150830 NTN.B.150840 NTN.B.150555 NTN.F.010127 LFT.010327
## 2025-10-06 4255.73 4061.22 4020.87 984.55 17500.57
## 2025-10-07 4247.90 4055.85 3998.87 984.86 17510.30
## 2025-10-08 4249.97 4050.49 4005.45 985.28 17519.97
## 2025-10-09 4266.41 4064.83 4024.37 986.88 17529.65
## 2025-10-10 4269.74 4057.06 4017.85 987.49 17539.66
## 2025-10-13 4268.42 4040.84 4014.84 988.20 17549.48
## 2025-10-14 4255.59 4006.93 3983.47 988.32 17559.19
## 2025-10-15 4262.12 4011.91 3984.86 988.93 17569.27
## 2025-10-16 4271.93 4038.32 4014.61 989.54 17579.24
## 2025-10-17 4267.53 4011.76 3974.12 989.95 17588.77
To accurately reflect the total returns of the bonds, we need to adjust the price series to account for coupon payments. The following function calculates the adjusted return series by adding the coupon payments back into the price series on their respective payment dates:
Coupon_Calendar <- tibble( # Define coupon payment schedule for each bond type
Bond_Type = c("NTNB_Par", "NTNB_Impar", "NTNF"),
Coupon1 = c("02-15", "05-15", "01-01"),
Coupon2 = c("08-15", "11-15", "07-01")
)
next_business_day <- function(date, reference_dates) { # Find the next business day after a given date
future_dates <- reference_dates[reference_dates >= date]
if (length(future_dates) == 0) return(NA)
future_dates[1]
}
generate_coupon_dates <- function(years, month_day) { # Generate coupon payment dates for given years and month-day combinations
md <- strsplit(month_day, "-")[[1]]
as.Date(paste0(years, "-", md[1], "-", md[2]))
}
years <- unique(year(index(Portfolio_Bonds))) # Extract unique years from the portfolio date index
Portfolio_Bonds_Adjusted <- Portfolio_Bonds
for (col in colnames(Portfolio_Bonds_Adjusted)) { # Iterate through each bond in the portfolio
price_vector <- as.numeric(Portfolio_Bonds_Adjusted[, col])
accumulated_coupon_adjustment <- rep(0, length(price_vector))
coupon_col_name <- ""
if (grepl("NTN.B", col)) {
bond_number <- as.numeric(substr(col, nchar(col)-5, nchar(col)))
bond_type <- ifelse(bond_number %% 2 == 0, "NTNB_Par", "NTNB_Impar")
coupon1 <- Coupon_Calendar$Coupon1[Coupon_Calendar$Bond_Type == bond_type]
coupon2 <- Coupon_Calendar$Coupon2[Coupon_Calendar$Bond_Type == bond_type]
coupon_col_name <- "Coupom_NTNB"
} else if (grepl("NTN.F", col)) {
coupon1 <- Coupon_Calendar$Coupon1[Coupon_Calendar$Bond_Type == "NTNF"]
coupon2 <- Coupon_Calendar$Coupon2[Coupon_Calendar$Bond_Type == "NTNF"]
coupon_col_name <- "Coupom_NTNF"
} else {
next
}
coupon_dates <- sort(unique(c(
generate_coupon_dates(years, coupon1),
generate_coupon_dates(years, coupon2)
)))
for (d in coupon_dates) {
d_ex_coupon <- next_business_day(d, index(Portfolio_Bonds_Adjusted))
if (!is.na(d_ex_coupon)) {
idx_coup <- which.min(abs(as.numeric(Coupons$Date - d)))
if (length(idx_coup) == 1) {
coupon_value <- Coupons[[coupon_col_name]][idx_coup]
idx_subsequent <- which(index(Portfolio_Bonds_Adjusted) >= d_ex_coupon)
accumulated_coupon_adjustment[idx_subsequent] <-
accumulated_coupon_adjustment[idx_subsequent] + coupon_value
}
}
}
Portfolio_Bonds_Adjusted[, col] <- xts(price_vector + accumulated_coupon_adjustment,
order.by = index(Portfolio_Bonds_Adjusted))
}
tail(Portfolio_Bonds_Adjusted, 10)
## NTN.B.150830 NTN.B.150840 NTN.B.150555 NTN.F.010127 LFT.010327
## 2025-10-06 5452.347 5257.837 5102.535 1472.638 17500.57
## 2025-10-07 5444.517 5252.467 5080.535 1472.948 17510.30
## 2025-10-08 5446.587 5247.107 5087.115 1473.368 17519.97
## 2025-10-09 5463.027 5261.447 5106.035 1474.968 17529.65
## 2025-10-10 5466.357 5253.677 5099.515 1475.578 17539.66
## 2025-10-13 5465.037 5237.457 5096.505 1476.288 17549.48
## 2025-10-14 5452.207 5203.547 5065.135 1476.408 17559.19
## 2025-10-15 5458.737 5208.527 5066.525 1477.018 17569.27
## 2025-10-16 5468.547 5234.937 5096.275 1477.628 17579.24
## 2025-10-17 5464.147 5208.377 5055.785 1478.038 17588.77
The code above iterates through each bond in the portfolio, identifies its type (NTN-B or NTN-F), and determines the coupon payment dates based on the bond’s characteristics. It then adds the coupon payments to the price series on the corresponding next business day after each coupon date, resulting in an adjusted price series that reflects total returns, including both price appreciation and coupon income.
With the adjusted price series that includes coupon payments, we can now calculate the daily log returns for each bond in the portfolio. Log returns are preferred in financial analysis due to their time-additive properties and better statistical characteristics. The following code computes the daily log returns:
Portfolio_Returns <- diff(log(Portfolio_Bonds_Adjusted))[-1, ] # Calculate daily log returns
tail(Portfolio_Returns, 10)
## NTN.B.150830 NTN.B.150840 NTN.B.150555 NTN.F.010127 LFT.010327
## 2025-10-06 0.0003778903 0.0017360597 0.0031720787 4.143083e-04 0.0005624255
## 2025-10-07 -0.0014371110 -0.0010218545 -0.0043209038 2.104844e-04 0.0005558274
## 2025-10-08 0.0003801268 -0.0010209939 0.0012943011 2.851017e-04 0.0005520940
## 2025-10-09 0.0030138578 0.0027292069 0.0037123010 1.085358e-03 0.0005523598
## 2025-10-10 0.0006093665 -0.0014778716 -0.0012777362 4.134827e-04 0.0005708695
## 2025-10-13 -0.0002415063 -0.0030921373 -0.0005904264 4.810515e-04 0.0005597174
## 2025-10-14 -0.0023504112 -0.0064955668 -0.0061742194 8.128162e-05 0.0005531397
## 2025-10-15 0.0011969635 0.0009565818 0.0002743874 4.130795e-04 0.0005738937
## 2025-10-16 0.0017955061 0.0050577197 0.0058547020 4.129089e-04 0.0005673071
## 2025-10-17 -0.0008049252 -0.0050865190 -0.0079767479 2.774332e-04 0.0005419698
This section calculates the daily log returns of the adjusted bond
prices, which now include coupon payments. Log returns are used because
they are additive over time and exhibit better statistical properties.
The diff(log(...)) function computes the difference of the
natural logarithm of the adjusted prices, producing the daily log
returns. The [-1, ] removes the first row, which is NA due
to the differencing. The resulting log_returns object
contains the daily log returns for each bond in the portfolio, ready for
subsequent analysis and portfolio optimization.
After the daily log returns, we can now proceed to optimize the portfolio using Modern Portfolio Theory (MPT). The goal is to construct a minimum variance portfolio that balances risk and return effectively. The following code performs the optimization:
library(PortfolioAnalytics) # Portfolio optimization
library(fPortfolio) # Financial portfolio functions
Portfolio.Specs <- PortfolioAnalytics::portfolio.spec(assets = colnames(Portfolio_Returns)) %>% # Define portfolio specifications
add.constraint(type = "full_investment") %>% # Full investment constraint
add.constraint(type = "box", min = 0.05, max = 0.55) %>% # Box constraints on weights
add.objective(type = "risk", name = "var") %>% # Minimize variance
add.objective(type = "return", name = "mean") ## Maximize mean return
Portfolio_Returns.TS <- fBasics::as.timeSeries(Portfolio_Returns) # Convert to timeSeries object for optimization
Portfolio_Optimize <- Portfolio_Returns.TS %>% # Optimize the portfolio using quadratic programming
PortfolioAnalytics::optimize.portfolio(portfolio = Portfolio.Specs, # Optimization method
optimize_method = "quadprog", trace = TRUE) # Trace for detailed output
Portfolio_Optimized_weights <- extractWeights(Portfolio_Optimize) # Extract optimized weights
Portfolio_Optimized.MV <- Return.portfolio(R = Portfolio_Returns, # Calculate portfolio returns using optimized weights
weights = Portfolio_Optimized_weights) # Portfolio returns
Now, there is a minimum variance portfolio constructed using the specified constraints and objectives. The optimized weights for each bond in the portfolio are extracted and defined, and the portfolio returns are calculated based on these weights.
print(Portfolio_Optimized_weights) # Display optimized weights
## NTN.B.150830 NTN.B.150840 NTN.B.150555 NTN.F.010127 LFT.010327
## 0.30 0.05 0.05 0.05 0.55
Finally, we analyze the performance of the optimized portfolio and compare it with an ETF benchmark. The following code provides key performance metrics and visualizations:
library(PerformanceAnalytics) # Performance analysis
library(quantmod) # Financial data retrieval
FIXA11 <- quantmod::getSymbols.yahoo("FIXA11.SA", from = "2015-12-30", # ETF benchmark for Brazilian fixed income
periodicity = "daily",
auto.assign = FALSE) %>%
Ad(.) %>% # Adjusted Close prices
Return.calculate(method = "log") %>% # Calculate log returns
setNames("Returns") %>% # Rename column
na.omit() # Remove NA values
Comparison <- merge(Portfolio_Optimized.MV, FIXA11, join = "inner") # Merge optimized portfolio returns with ETF returns
colnames(Comparison) <- c("Portfolio", "FIXA11") # Rename columns
PerformanceAnalytics::charts.PerformanceSummary(Comparison, # Performance summary chart
main = "Performance Comparison: Optimized Portfolio vs FIXA11 ETF",
colorset = c("blue", "red"))
table.AnnualizedReturns(Comparison)
## Portfolio FIXA11
## Annualized Return 0.0827 0.0659
## Annualized Std Dev 0.0355 0.0714
## Annualized Sharpe (Rf=0%) 2.3269 0.9231
Comparing the optimized bond portfolio against the FIXA11 ETF highlights substantial differences in performance, risk, and risk-adjusted returns. Based on the annualized metrics:
• Annualized Return: The optimized portfolio achieved 8.27%, compared with 6.59% for FIXA11.
• Annualized Volatility: The portfolio exhibits much lower volatility (3.55%) than the ETF (7.14%), indicating more stable performance.
Annualized Sharpe Ratio (Rf = 0%): The portfolio reaches 2.33, significantly higher than FIXA11’s 0.92, reflecting superior risk-adjusted returns.
The performance summary chart visually reinforces these findings, showing that the optimized portfolio not only delivers higher returns but also does so with less drawdown and smoother equity curves. These results underscore the effectiveness of the quantitative optimization approach: active management of Brazilian sovereign bonds can outperform a passive ETF in both absolute and risk-adjusted terms, demonstrating the benefits of a disciplined, MPT-based allocation in this market.
This study successfully applied Modern Portfolio Theory (MPT) to construct a minimum variance portfolio of Brazilian sovereign bonds, specifically adapted to the structural characteristics of the Tesouro Direto market. By adjusting the return series to include coupon payments, the analysis not only captured total returns accurately but also substantially reduced portfolio volatility, reflecting the stabilizing effect of predictable cash flows from coupons.
The quantitative optimization demonstrates that the managed bond portfolio outperforms a passive ETF benchmark (FIXA11) across multiple dimensions:
• Higher annualized return: 8.27% vs. 6.59% for the ETF.
• Lower annualized volatility: 3.55% vs. 7.14%.
• Superior risk-adjusted performance (Sharpe Ratio): 2.33 vs. 0.92.
These results illustrate that active, quantitatively driven allocation can deliver higher returns with significantly lower risk, offering a more stable investment profile compared to passive benchmarks.
Overall, the findings underscore the critical value of tailored quantitative approaches in the Brazilian fixed income market, providing investors with a clear, replicable framework for constructing efficient, low-risk portfolios that maximize total returns while controlling for volatility.