Standard Liquid RFR Curves (USD, GBP, CAD, CHF, JPY)#

This example will discuss curve building standards for liquid RFR markets like USD, GBP, CAD, CHF, JPY. Less liquid markets like INR domestic onshore, MXN and COP for example follow the same principles but more nunaced choices of instruments may be required to suit available data.

Key Points

  • Standard configuration for Curve setup.

  • Differences between data consumers and market makers.

  • Advanced tinkering of convergence and Curve interpolation.

Alternative Construction Frameworks#

The advantage of these markets is that there is sufficiently granular data to construct a Curve with day-to-day consistent inputs. In this framework we choose standard tenors only to construct the Curve, and consume published data for those rates.

This is an advantage in terms of minimising on going maintenance and simplifying the setup. The following curve is constructed in six lines of code, and is replicable without fail on any day of the year.

Data

First, load (from source) the market data for each chosen tenor of the Curve and establish the relevant dates of construction. The below code also determines the appropriate maturity of each tenor under the market calendar.

In [1]: data = DataFrame({
   ...:     "Term": ["1W", "2W", "3W", "1M", "2M", "3M", "4M", "5M", "6M", "7M", "8M", "9M", "10M", "11M", "12M", "18M", "2Y", "3Y", "4Y", "5Y", "6Y", "7Y", "8Y", "9Y", "10Y", "12Y", "15Y", "20Y", "25Y", "30Y", "40Y"],
   ...:     "Rate": [5.309,5.312,5.314,5.318,5.351,5.382,5.410,5.435,5.452,5.467,5.471,5.470,5.467,5.457,5.445,5.208,4.990,4.650,4.458,4.352,4.291,4.250,4.224,4.210,4.201,4.198,4.199,4.153,4.047,3.941,3.719],
   ...: })
   ...: 

In [2]: today = dt(2023, 9, 27)

In [3]: spot = get_calendar("nyc").lag_bus_days(today, 2, False)

In [4]: data["Termination"] = [add_tenor(spot, _, "MF", "nyc") for _ in data["Term"]]

Curve Design

In order to create a perfectly solvable curve, the chosen nodes will be assigned as the maturity of each Instrument.

In [5]: sofr = Curve(
   ...:     id="sofr",
   ...:     convention="Act360",                          # <- important to match SOFR convention
   ...:     calendar="nyc",                               # <- important to match SOFR convention
   ...:     interpolation="log_linear",
   ...:     nodes={
   ...:         today: 1.0,                               # <- this is today's DF,
   ...:         **{_: 1.0 for _ in data["Termination"]},  # <- every instrument node
   ...:     }
   ...: )
   ...: 

Calibration with Solver

The final step puts the Curve and the Instruments together, mutating the Curve to match the rates: s.

In [6]: solver = Solver(
   ...:     curves=[sofr],
   ...:     instruments=[IRS(spot, _, spec="usd_irs", curves="sofr") for _ in data["Termination"]],
   ...:     s=data["Rate"],
   ...:     instrument_labels=data["Term"],
   ...:     id="us_rates",
   ...: )
   ...: 
SUCCESS: `func_tol` reached after 6 iterations (levenberg_marquardt), `f_val`: 9.815194353211927e-12, `time`: 0.1502s

Then plotting either the O/N rates or the zero rates:

sofr.plot("1b")
sofr.plot("Z")

(Source code, png, hires.png, pdf)

_images/z_rfr_standard-1_00_00.png

The most common tweak that one might make to this Curve is to change its interpolation style. A very simple approach is to use “spline” which applies a log-cubic spline over discount factors across the whole curve.

The t argument can also be used which specifies using a spline only for a section of the Curve resulting in mixed interpolation. All else remains the same.

Explicit examples of this are demonstrated in ‘Pricing and Trading Interest Rate Derivatives: Single Currency Curve Example’

sofr = Curve(
    id="sofr",
    convention="Act360",
    calendar="nyc",
    interpolation="spline",
    nodes={
        today: 1.0,
        **{_: 1.0 for _ in data["Termination"][:-1]},
        dt(2063, 10, 7): 1.0,  # <- avoid end spline warning
    }
)

(Source code, png, hires.png, pdf)

_images/z_rfr_standard-2_00_00.png