.. _cook-rfr-standard-doc: .. ipython:: python :suppress: from rateslib import Curve, Solver, IRS, add_tenor, get_calendar, dt, get_imm import matplotlib.pyplot as plt from pandas import DataFrame 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 -------------------------------------- .. tabs:: .. tab:: Data Consumer / Risk Manager 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. .. ipython:: python 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], }) today = dt(2023, 9, 27) spot = get_calendar("nyc").lag_bus_days(today, 2, False) 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*. .. ipython:: python 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*. .. ipython:: python 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", ) Then plotting either the O/N rates or the zero rates: .. code-block:: python sofr.plot("1b") sofr.plot("Z") .. plot:: from rateslib import Curve, Solver, IRS, add_tenor, get_calendar, dt import matplotlib.pyplot as plt from pandas import DataFrame 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], }) today = dt(2023, 9, 27) spot = get_calendar("nyc").lag_bus_days(today, 2, False) data["Termination"] = [add_tenor(spot, _, "MF", "nyc") for _ in data["Term"]] 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 } ) 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", ) fig1, ax1, lines = sofr.plot("z") del fig1, ax1 plt.close() fig, ax, _ = sofr.plot("1b") ax.plot(lines[0]._x, lines[0]._y) plt.show() plt.close() 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 :ref:`'Pricing and Trading Interest Rate Derivatives: Single Currency Curve Example' ` .. code-block:: python 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 } ) .. plot:: from rateslib import Curve, Solver, IRS, add_tenor, get_calendar, dt, get_imm import matplotlib.pyplot as plt from pandas import DataFrame 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], }) today = dt(2023, 9, 27) spot = get_calendar("nyc").lag_bus_days(today, 2, False) data["Termination"] = [add_tenor(spot, _, "MF", "nyc") for _ in data["Term"]] sofr = Curve( id="sofr", convention="Act360", calendar="nyc", modifier="MF", interpolation="spline", nodes={ today: 1.0, # <- this is today's DF, **{_: 1.0 for _ in data["Termination"][:-1]}, dt(2063, 10, 7): 1.0, # <- avoid end spline warning } ) 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", ) fig1, ax1, lines = sofr.plot("z") del fig1, ax1 plt.close() fig, ax, _ = sofr.plot("1b") ax.plot(lines[0]._x, lines[0]._y) plt.show() plt.close() .. tab:: Market-maker The difference between a data-consumer and market-maker approach is that a market-maker usually creates or provides their own data to construct their *Curve*. This allows much more flexibility, and subjectivity, in construction choices, and also more complexity. Essentially it means that the market-maker chooses their *Instruments* and goes about creating the data to suit that approach. There is not one standard approach but there are some general principles that are often employed: - *Curves* are expected to be flat and jump on central bank effective dates at the ultra short end. Here we use *'log_linear'* ``interpolation`` to control this. - STIR Futures markets provide data at the short end of the *Curve*, instruments are chosen that reflect their prices. We assume that **convexity adjustments** are pre-processed and so the *rates* provided to the *Instruments* are swap-rate equivalent. For a framework that explicitly discusses this see :ref:`Building with STIR Convexity Adjustments `. - Medium term and longer term rates are derived from *par* tenor swap *Instruments* and use a log-cubie spline type interpolation to reflect sparser data. Here we configure mixed interpolation by choosing our ``t`` knot sequence. **Data** This data is similar to the previous but amended to account for specific STIR futures periods with swap-equivalent rates. .. ipython:: python data = DataFrame({ "Term": [ "ON", "1V23", "1X23", "1Z23", "1F24", "1G24", "1H24", "1J24", "1K24", "1M24", "Z23", "H24", "M24", "U24", "Z24", "H25", "M25", "U25", "Z25", "H26", "M26", "U26", "4Y", "5Y", "6Y", "7Y", "8Y", "9Y", "10Y", "12Y", "15Y", "20Y", "25Y", "30Y", "40Y", ], "Rate": [ 5.31, 5.307, 5.353, 5.387, 5.409, 5.421, 5.408, 5.387, 5.307, 5.251, 5.447, 5.372, 5.180, 4.913, 4.588, 4.293, 4.084, 3.969, 3.894, 3.838, 3.801, 3.782, 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 ], }) **Dates** As of our *Curve* build date (27th Sep 2023) we need to know the FED effective dates and some relevant IMM dates. Beyond these we will use par tenor swap dates as the **node dates**. .. ipython:: python today = dt(2023, 9, 27) spot = get_calendar("nyc").lag_bus_days(today, 2, False) IMM1 = ["V23", "X23", "Z23", "F24", "G24", "H24", "J24", "K24", "M24"] IMM3 = ["Z23", "H24", "M24", "U24", "Z24", "H25", "M25", "U25", "Z25", "H26", "M26", "U26", "Z26"] PAR = ["4Y", "5Y", "6Y", "7Y", "8Y", "9Y", "10Y", "12Y", "15Y", "20Y", "25Y", "30Y", "40Y"] fed = [dt(2023, 11, 2), dt(2023, 12, 14), dt(2024, 2, 1), dt(2024, 3, 21), dt(2024, 5, 2)] imm = [get_imm(code=_) for _ in IMM3[2:]] par = [add_tenor(spot, _, "MF", "nyc") for _ in PAR] **Curve Configuration** We use the above dates and subjectively opt that the spline interpolation , controlled by the knots ``t`` begins at the end of the 10th quarterly IMM contract contract. .. ipython:: python 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 fed}, # <- FED effective dates, **{_: 1.0 for _ in imm}, # <- 3M IMM end dates, **{_: 1.0 for _ in par}, # <- Par swap maturities }, t = [ get_imm(code="M26"), get_imm(code="M26"), get_imm(code="M26"), get_imm(code="M26"), get_imm(code="U26"), get_imm(code="Z26"), *par[:-1], dt(2063, 10, 7), dt(2063, 10, 7), dt(2063, 10, 7), dt(2063, 10, 7) ] ) **Calibration with Solver** We now calibrate, targeting the 3M-IMM futures rates and the par swap rates. The 1M-IMM futures and the O/N rate serve as regularizers to constrain the *Curve* in case the number of **nodes** (i.e. from the FED effective dates) are large. This is controlled by the ``weights`` parameter significantly reducing the importance of these *Instruments*. .. ipython:: python :okwarning: solver = Solver( curves=[sofr], instruments=[ IRS(today, "1b", spec="usd_irs", curves="sofr"), # <- O/N rate *[ # <- 1M-IMM rates x 9 STIRFuture(get_imm(code=_, definition="som"), "1m", spec="usd_stir1", curves="sofr", metric="rate") for _ in IMM1 ], *[ # <- 3M-IMM rates x 12 STIRFuture(get_imm(code=IMM3[i]), get_imm(code=IMM3[i+1]), spec="usd_stir", curves="sofr", metric="rate") for i in range(12) ], *[ # <- Par swap rates x 13 IRS(spot, _, spec="usd_irs", curves="sofr") for _ in PAR ] ], s=data["Rate"], weights=[1e-5]*10 + [1.0]*25, func_tol=1e-7, conv_tol=1e-8, ) The graph looks broadly similar after the first five years. .. code-block:: python sofr.plot("1b", right=dt(2029, 1, 1)) sofr.plot("Z", right=dt(2029, 1, 1)) .. plot:: from rateslib import * from pandas import DataFrame import matplotlib.pyplot as plt data = DataFrame({ "Term": [ "ON", "1V23", "1X23", "1Z23", "1F24", "1G24", "1H24", "1J24", "1K24", "1M24", "Z23", "H24", "M24", "U24", "Z24", "H25", "M25", "U25", "Z25", "H26", "M26", "U26", "4Y", "5Y", "6Y", "7Y", "8Y", "9Y", "10Y", "12Y", "15Y", "20Y", "25Y", "30Y", "40Y", ], "Rate": [ 5.31, 5.307, 5.353, 5.387, 5.409, 5.421, 5.408, 5.387, 5.307, 5.251, 5.447, 5.372, 5.180, 4.913, 4.588, 4.293, 4.084, 3.969, 3.894, 3.838, 3.801, 3.782, 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 ], }) today = dt(2023, 9, 27) spot = get_calendar("nyc").lag_bus_days(today, 2, False) IMM1 = ["V23", "X23", "Z23", "F24", "G24", "H24", "J24", "K24", "M24"] IMM3 = ["Z23", "H24", "M24", "U24", "Z24", "H25", "M25", "U25", "Z25", "H26", "M26", "U26", "Z26"] PAR = ["4Y", "5Y", "6Y", "7Y", "8Y", "9Y", "10Y", "12Y", "15Y", "20Y", "25Y", "30Y", "40Y"] fed = [dt(2023, 11, 2), dt(2023, 12, 14), dt(2024, 2, 1), dt(2024, 3, 21), dt(2024, 5, 2)] imm = [get_imm(code=_) for _ in IMM3[2:]] par = [add_tenor(spot, _, "MF", "nyc") for _ in PAR] 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 fed}, # <- FED effective dates, **{_: 1.0 for _ in imm}, # <- 3M IMM end dates, **{_: 1.0 for _ in par}, # <- Par swap maturities }, t = [ get_imm(code="M26"), get_imm(code="M26"), get_imm(code="M26"), get_imm(code="M26"), get_imm(code="U26"), get_imm(code="Z26"), *par[:-1], dt(2063, 10, 7), dt(2063, 10, 7), dt(2063, 10, 7), dt(2063, 10, 7) ] ) instruments=[ IRS(today, "1b", spec="usd_irs", curves="sofr"), # <- O/N rate *[ # <- 1M-IMM rates x 9 STIRFuture(get_imm(code=_, definition="som"), "1m", spec="usd_stir1", curves="sofr", metric="rate") for _ in IMM1 ], *[ # <- 3M-IMM rates x 12 STIRFuture(get_imm(code=IMM3[i]), get_imm(code=IMM3[i+1]), spec="usd_stir", curves="sofr", metric="rate") for i in range(12) ], *[ # <- Par swap rates x 13 IRS(spot, _, spec="usd_irs", curves="sofr") for _ in PAR ] ], print(instruments) solver = Solver( curves=[sofr], instruments=[ IRS(today, "1b", spec="usd_irs", curves="sofr"), # <- O/N rate *[ # <- 1M-IMM rates x 9 STIRFuture(get_imm(code=_, definition="som"), "1m", spec="usd_stir1", curves="sofr", metric="rate") for _ in IMM1 ], *[ # <- 3M-IMM rates x 12 STIRFuture(get_imm(code=IMM3[i]), get_imm(code=IMM3[i+1]), spec="usd_stir", metric="rate", curves="sofr") for i in range(12) ], *[ # <- Par swap rates x 13 IRS(spot, _, spec="usd_irs", curves="sofr") for _ in PAR ] ], s=data["Rate"], weights=[1e-5]*10 + [1.0]*25, func_tol=1e-7, conv_tol=1e-8, ) fig1, ax1, lines = sofr.plot("z", right=dt(2029, 1, 1)) del fig1, ax1 plt.close() fig, ax, _ = sofr.plot("1b", right=dt(2029, 1, 1)) ax.plot(lines[0]._x, lines[0]._y) plt.show() plt.close() **Alternatives** Employing different *Instruments*, using pseudo-*Instruments* as regularizers, changing the node dates, or the knot dates for the spline, or designing custom-*Instruments* to act as penalty function regularizers are all possible designs that market-makers might typically employ. For some further analysis, anecdotes of real trading and examples of these sorts of considerations see `Pricing and Trading Interest Rate Derivatives: A Practical Guide to Swaps `_. .. tab:: SWPM Comparison Bloomberg's SWPM *Curve* is (under default settings) no more than a log-linearly interpolated *Curve* using par tenors as the node-dates. I.e. it matches the *data consumer* type *Curve* already demonstrated in these tabs. However, for legacy reasons the page giving a specific example of this is still available. .. toctree:: :titlesonly: z_swpm.rst