Introduction

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.

1. Data Collection

1.1 Data Sources

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.

1.2 Return Adjustment and Coupon Processing

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.

1.3 R Packages

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

1.4 Downloading the Data

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

2. Get the Portfolio Prices

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

3. Adjusting the Return Series for Coupons

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.

4. Calculating Daily Log Returns

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.

5. 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

6. Performance Analysis

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.

Conclusion

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.