Skip to content
All library documents

Modeling Brazilian DI1 Futures Rates and Curve Interpolation

Article Quant Q&A · Author: nba66

Summary

The document explores representing Brazilian DI1 interest rate futures with rateslib for curve calibration and sensitivity analysis. It proposes a zero-coupon swap representation and distinguishes the futures’ annualized compounded internal rate of return from simple period rates returned by curve-rate calculations. The response relates contract value to daily compounded overnight rates using the Brazil business-day/252 convention and illustrates how the quoted rate maps to a discount factor and a simple period rate.

It also explains odd rate behavior in the example: a reported discrepancy was attributed to a rateslib version bug in passing the calendar, while weekend jumps arise when calendar-day log-linear discount-factor interpolation is paired with a business-day/252 convention. A custom interpolation based on that day-count fraction is suggested. The answer’s wrapper is explicitly rough, and its implementation and bug notes are version-specific; the example is not a complete contract specification or independently validated production model.

Key ideas

  • A DI1 future’s quoted rate is treated as an annualized internal rate of return on compounded overnight accruals.
  • A zero-coupon swap can serve as an approximate representation for calibrating a curve to a DI1 quote.
  • Simple period rates and compounded internal rates are different measures, so their reported values need not match.
  • Calendar-day interpolation can create rate jumps when accrual follows Brazil’s business-day/252 convention.
  • A custom interpolation using business-day fractions can address the illustrated weekend behavior, but the example is explicitly approximate.

Tags

Full text
# How can I represent a Brazilian DI1 future in rateslib (python)?


# How can I represent a Brazilian DI1 future in rateslib (python)?












I am currently looking to use rateslib to explore Brazil's rates market, of which the DI1 futures are a big component. I have limited experience with both these futures and with rateslib, so pardon if I am overlooking something.

My end goal is to represent DI1 futures in rateslib, and use them to calibrate curves, calculate sensitivities, etc.

My research so far, and the helpful comments below have led me to believe that the `ZCS` object might do the trick, although I have come across some issues.

```
from rateslib import Cal, ZCS, Curve, Solver
from datetime import datetime as dt

# Holidays on which no overnight DI rate is published
reserve_holidays = [
    "2025-01-01",
    "2025-03-03",
    "2025-03-04",
    "2025-04-18",
    "2025-04-21",
    "2025-05-01",
    "2025-06-19",
    "2025-09-07",
    "2025-10-12",
    "2025-11-02",
    "2025-11-15",
    "2025-11-20",
    "2025-12-25",
    "2026-01-01",
    "2026-02-16",
    "2026-02-17",
    "2026-04-03",
    "2026-04-21",
    "2026-05-01",
    "2026-06-04",
    "2026-09-07",
    "2026-10-12",
    "2026-11-02",
    "2026-11-15",
    "2026-11-20",
    "2026-12-25",
]

# Build the rateslib calendar
reserve_calendar = Cal(holidays=[dt.strptime(h, "%Y-%m-%d") for h in reserve_holidays], week_mask=[5, 6])

# Relevant dates
today = dt(2025, 5, 15)
maturity = dt(2026, 1, 2) # Maturity of DI1F26

# Initialize rateslib curve
di_curve = Curve(
    nodes={today: 1, maturity: 1},
    calendar=reserve_calendar,
    convention="bus252",
    id="di",
)

# Initialize rateslib ZCS, used to represent the DI1 future
di1_example = ZCS(
    effective=today,
    termination=maturity,
    frequency="A",
    calendar=reserve_calendar,
    curves="di",
    currency="brl",
    fixed_rate=14, # Example of current quoted rate
    convention="bus252",
    notional=100e6, 
)

# Day-count fraction (of a year) (busdays / 252) make sense
assert 162 / 252 == di1_example.leg2.periods[0].dcf # True
assert 162 / 252 == di1_example.leg1.periods[0].dcf # True

# Fit the curve
solver = Solver(
    curves=[di_curve],
    instruments=[di1_example],
    s=[14],
    instrument_labels=["DI1F26"],
    id="di_solver",
)

# Under the curve, we got the correct fixed rate of 14%
assert abs(di1_example.rate(di_curve) - 14) < 1e-5 # True

# The DI1 NPV is roughly 0 (.005592), which would make sense
assert abs(di1_example.npv(di_curve)) < 1e-2 # True

### ISSUES ###

# Why is this so far off from 14%?
di_curve.rate(today, maturity)
<Dual: 13.670494, (di0, di1), [122.3, -137.7]>

# These rates are ~12.88% (very different from the 13.67% above), also off from 14%. Also, why do these 1d rates oscillate?
di_curve.plot("1d")
```

Thanks to everyone's help, I have been able to fit the curve, and successfully reprice the DI1, however I am have notable trouble in interpreting the curve. Perhaps my understanding of the business/252 day-count convention is wrong, or perhaps it is my understanding of rateslib.

Specifically:

- If the `Curve` and the `ZCS` have the same conventions, why are we getting such wildly different rates (14% vs. 13.67%)?

- For something like a SOFR curve, I might understand to see our values oscillate as the rate over a weekend would appear to be slightly elevated under the ACT360 convention despite the daily compounding factor being the same. I don't see why this would cause the oscillations we see in the DI curve I've created.

- Looking at plots of various tenors, I find weird rate jump behavior in general, even apart from the oscillations. For example `di_curve.plot("3m")` shows the rate decreasing towards the end.

Again, I appreciate any help that could be provided!

## Answer by Attack68 (score 3)

https://quant.stackexchange.com/a/82503

I had a second look at this. And the question DI futures contract value on bloomberg is quite a useful reference.

The relevant rate for this future seems to be compounded daily overnight rates between today and the expiry. This formula, $$ PO = \frac{100,000}{\left ( 1 + \frac{i}{100} \right )^{\frac{n}{252}}}$$ has the rate $i$ as an internal rate of return with an annualised frequency. This is similar to rateslib's documentation for the cashflow of a `ZeroFixedLeg` if `f` is set to annual=1:

$$ C = -N \left ( (1+\frac{R^{irr}}{f}) ^{df} - 1 \right ) $$

Your instinct to use a `ZCS` was correct. The `rate` method for a `ZCS` will return the internal rate o return which is the `i` value you need.

You can patch a D1B future in a very rough way with the following code. I haven't gone over any of the details so this is 15 minutes work and take it with a pinch of salt:

```
class D1B:
    _rate_scalar = 1.0
    def __init__(self, *args, **kwargs):
        self.zcs = ZCS(*args, **kwargs)

    def rate(self, *args, **kwargs):
        return self.zcs.rate(*args, **kwargs)

    def npv(self, *args, **kwargs):
        return self.zcs.npv(*args, **kwargs)

    def contract_value(self, *args, **kwargs):
        rate = self.zcs.rate(*args, **kwargs)
        return 100000 / (1 + rate / 100)**self.zcs.leg1.periods[0].dcf
```

With this you can calibrate a `Curve`.

```
curve = Curve(
    nodes={dt(2025, 5, 16): 1.0, dt(2028, 5, 16): 0.70}, 
    calendar="bus", 
    convention="bus252",
    id="my_curve",
)

d1b = D1B(  # ODF27 Comdty
    effective=dt(2025, 5, 16), termination=dt(2027, 1, 4), frequency="A",
    convention="bus252", calendar="bus", curves="my_curve", 
    notional=100000, fixed_rate=0.0
)

solver = Solver(curve=[curve], instruments=[d1b], s=[14.188])
SUCCESS: `func_tol` reached after 3 iterations (levenberg_marquardt), `f_val`: 7.448905818361101e-14, `time`: 0.0060s
```

```
d1b.contract_value(curve)
# <Dual: 79908.683423, (eb7580, eb7581), [-32445.3, 57045.0]>
```

This is an answer to your comprehensively edited question.

The differences between a simple period rate:

$$(1 + d_i r_i) = \frac{v_{i}}{v_{i+1}}$$

where $d$, $r$ are DCF and simple rate in a period and $v$ are the start and end DFs, and a compounded rate or internal rate of return:

$$ (1 + r^{irr} ) ^{d} = \frac{v_i}{v_{i+1}} $$

are visible in the formulae.

`rateslib` plots simple rates and the `Curve.rate` returns simple rates. (see the docs).

What DF should you expect?

$$ v_0 = 1.0 \\ v_1 = 1.0 / (1 + 0.14)^{162/252} = 0.9192175550891948 $$

What period rate should you expect?

$$ r_i = (1.0 / 0.919.. - 1 ) * 25200 / 162 = 13.670% $$

Is this what rateslib gives?

Not in v1.8.0 becuase of a bug I found while looking at this. The `calendar` is not passed in a Curve rate calculation (which is irrelevant for all other day count conventions, but essential here). The fix for this is a one liner see here

In v 2.0 this gives the right results...

But the plot looks like this..

This is because the interpolation style of "log-linear" is calendar day based. Over a weekend, traditional interest rates accrue 3 days, but Brazil only accrues 1. However under the interpolation style the discount factors still change for 3 days worth of interest so the simple period rate jumps up under the day count calculation. It would be better to use a version of "log-linear" interpolation that suited the brazilian day count fraction. You can define your own interpolation as a callable function with the arguments `(date: datetime, curve_nodes: dict[datetime, float])`

Something like this does the trick:

```
from rateslib.dual import dual_exp, dual_log
from rateslib.curves import index_left

def log_lin_bus252(date, nodes):
    node_list = list(nodes.keys())
    i = index_left(node_list, len(node_list), date)
    x_1, x_2 = node_list[i], node_list[i+1]
    y_1, y_2 = nodes[x_1], nodes[x_2]
    ly_1, ly_2 = dual_log(y_1), dual_log(y_2)
    frac = dcf(x_1, date, convention="bus252", calendar=reserve_calendar)
    frac /= dcf(x_1, x_2, convention="bus252", calendar=reserve_calendar)
    return dual_exp( ly_1 + (ly_2 - ly_1) * frac )

# Initialize rateslib curve
di_curve = Curve(
    nodes={today: 1, maturity: 1},
    calendar=reserve_calendar,
    convention="bus252",
    id="di",
    interpolation=log_lin_bus252
)
```

The plot of the "1b" rates curve will then be completely flat at 13.1062%

Shown in full with attribution under the source's licence. Licence: CC BY-SA 4.0 (Stack Exchange)

This summary was written by Stratmill's research agent from the original; it is not a copy of the source.