What are Exogenous Variables and Exogenous Sensitivities?#

Endogenous variables#

Being a fixed income library, there are some variables that are endogenous to rateslib - meaning they are created internally and used throughout its internal calculations. These are often easy to spot. For example when creating an FXRates object you will notice the user input for FX rate information is just expressed with regular floats, but rateslib internally creates dual number exposure to these variables.

In [1]: fxr = FXRates({"eurusd": 1.10, "gbpusd": 1.25}, settlement=dt(2000, 1, 1))

In [2]: fxr.rate(pair="eurgbp")
Out[2]: <Dual: 0.880000, (fx_gbpusd, fx_eurusd), [-0.7, 0.8]>

Similarly, when building Curves and calibrating them with a Solver, rateslib structures all its parameters internally, so that it can calculate delta() and gamma() later without any further user input.

In [3]: curve = Curve({dt(2000, 1, 1): 1.0, dt(2001, 1, 1): 1.0}, id="curve")

In [4]: solver = Solver(
   ...:     curves=[curve],
   ...:     instruments=[IRS(dt(2000, 1, 1), "6m", "S", curves=curve)],
   ...:     s=[2.50],
   ...:     id="solver",
   ...: )
   ...: 
SUCCESS: `func_tol` reached after 3 iterations (levenberg_marquardt), `f_val`: 1.6940356306532633e-14, `time`: 0.0014s

In [5]: irs = IRS(
   ...:     effective=dt(2000, 1, 1),
   ...:     termination="6m",
   ...:     frequency="S",
   ...:     leg2_frequency="M",
   ...:     fixed_rate=3.0,
   ...:     notional=5e6
   ...: )
   ...: 

In [6]: irs.npv(curves=curve)
Out[6]: <Dual: -12310.598822, (curve0, curve1), [2479878.7, -2555058.4]>

Exogneous variables#

Exogenous variables are those created dynamically by a user. The only reason one would typically do this is to create a baseline for measuring some financial sensitivity.

Start with an innocuous example. Suppose we wanted to capture the sensitivity of the IRS above to its notional. The notional is just a linear scaling factor for an IRS (and many other instruments too) so the financial exposure for 1 unit of notional is just its npv divided by its 5 million notional

In [7]: irs.npv(curves=curve) / 5e6
Out[7]: <Dual: -0.002462, (curve0, curve1), [0.5, -0.5]>

But this can also be captured using exo_delta().

In [8]: irs = IRS(
   ...:     effective=dt(2000, 1, 1),
   ...:     termination="6m",
   ...:     frequency="S",
   ...:     leg2_frequency="M",
   ...:     fixed_rate=3.0,
   ...:     notional=Variable(5e6, ["N"]),  # <-- `notional` is assigned as a Variable: 'N'
   ...:     curves="curve",
   ...: )
   ...: 

In [9]: irs.exo_delta(solver=solver, vars=["N"])
Out[9]: 
local_ccy                gbp
display_ccy              gbp
type      solver label      
exogenous solver N     -0.00

What about capturing the exposure to the fixed_rate? This is already provided by the analytical function analytic_delta() but it can be shown. Here, we scale the result from percentage points to basis points.

In [10]: irs.analytic_delta(curve)
Out[10]: <Dual: 246.211912, (N, curve0, curve1), [0.0, 122.4, 126.9]>
In [11]: irs = IRS(
   ....:     effective=dt(2000, 1, 1),
   ....:     termination="6m",
   ....:     frequency="S",
   ....:     leg2_frequency="M",
   ....:     fixed_rate=Variable(3.0, ["R"]),  # <-- `fixed_rate` also assigned as: 'R'
   ....:     notional=Variable(5e6, ["N"]),
   ....:     curves="curve",
   ....: )
   ....: 

In [12]: irs.exo_delta(solver=solver, vars=["N", "R"], vars_scalar=[1.0, 1/100])
Out[12]: 
local_ccy                  gbp
display_ccy                gbp
type      solver label        
exogenous solver N       -0.00
                 R     -246.21

Exposure to the float_spread? This is also covered by analytic_delta(), but anyway..

In [13]: irs.analytic_delta(curve, leg=2)
Out[13]: <Dual: -247.487518, (N, curve0, curve1), [-0.0, -174.5, -74.9]>
In [14]: irs = IRS(
   ....:     effective=dt(2000, 1, 1),
   ....:     termination="6m",
   ....:     frequency="S",
   ....:     leg2_frequency="M",
   ....:     fixed_rate=Variable(3.0, ["R"]),
   ....:     notional=Variable(5e6, ["N"]),
   ....:     leg2_float_spread=Variable(0.0, ["z"]),   # <-- `float_spread` also assigned as: 'z'
   ....:     curves="curve",
   ....: )
   ....: 

In [15]: irs.exo_delta(solver=solver, vars=["N", "R", "z"], vars_scalar=[1.0, 1/100, 1.0])
Out[15]: 
local_ccy                  gbp
display_ccy                gbp
type      solver label        
exogenous solver N       -0.00
                 R     -246.21
                 z      247.49

These calculations are completely independent of each other. The analytic varieties are just that, hand coded functions from manually derived equations. The exo_delta function organises and structures the AD variables dynamically into the Solver and uses the chain rule for differentiation.

Difference between Variable, and Dual and Dual2#

Dual and Dual2 do not permit binary operations between themselves because it is inconsistent and impossible to correctly define second order derivatives with such operations. For safety, TypeErrors are raised when this is encountered. Internally, for specific calculations dual numbers are converted to specific types first before performing calculations in rateslib.

But if a user wants to inject dual sensitivity at an arbitrary point in the code it may not be possible for rateslib to know what to convert and this may break downstream calculations.

In [16]: irs = IRS(
   ....:     effective=dt(2000, 1, 1),
   ....:     termination="6m",
   ....:     frequency="S",
   ....:     leg2_frequency="M",
   ....:     fixed_rate=Dual2(3.0, ["R"], [], []),  # <-- `fixed_rate` added as a Dual2
   ....:     curves="curve",
   ....: )
   ....: 

In [17]: try:
   ....:     irs.delta(solver=solver)
   ....: except TypeError as e:
   ....:     print(e)
   ....: 
Dual2 operation with incompatible type (Dual).

Using a Variable, instead, is designed to cover these user cases.

The Real Use Case#

The use case that triggered the development of exogenous variables came with credit default swaps (CDS). If you go through the Replicating a Pfizer Default Curve and CDS cookbook page, right at the very bottom is a considered value:

Rec Risk (1%): 78.75

This is the financial exposure of the constructed CDS if the recovery rate of Pfizer CDSs increase by 1%. But, the nuanced aspect of this value is that it is not what happens if the recovery rate of the specifically constructed CDS changes in recovery rate (that is very easy to measure), but rather what happens if Pfizer’s overall recovery rate changes for all its CDSs. This impacts all of the calibrating instruments used in the construction of the hazard Curve, and by implication all of the gradients attached to the Solver.

We will replicate all of the code from that page, some of the variables are directly shown:

In [18]: disc_curve  # the US SOFR discount curve created
Out[18]: <rl.Curve:sofr at 0x11f09b110>

In [19]: us_rates_sv  # the Solver calibrating the SOFR curve
Out[19]: <rl.Solver:us_rates at 0x11ddf3750>

In [20]: hazard_curve  # the Pfizer hazard curve
Out[20]: <rl.Curve:pfizer at 0x11e161130>

Now, this time our calibrating Instruments will include sensitivity to the recovery_rate which will be labelled as “RR”. This is an exogenous variable that we are directly injecting.

In [21]: pfizer_sv = Solver(
   ....:     curves=[hazard_curve],
   ....:     pre_solvers=[us_rates_sv],
   ....:     instruments=[
   ....:         CDS(
   ....:             effective=cds_eff,
   ....:             termination=_,
   ....:             spec="us_ig_cds",
   ....:             recovery_rate=Variable(0.4, ["RR"]),  # <-- add exogenous variable exposure
   ....:             curves=["pfizer", "sofr"]
   ....:         ) for _ in cds_mats
   ....:     ],
   ....:     s=cds_rates,
   ....:     instrument_labels=cds_tenor,
   ....:     id="pfizer_cds"
   ....: )
   ....: 
SUCCESS: `func_tol` reached after 6 iterations (levenberg_marquardt), `f_val`: 6.270401100053586e-12, `time`: 0.1175s

If we next create the same CDS to explore as the previous cookbook page and use exo_delta() we expect something close to 78.75.

In [22]: cds = CDS(
   ....:     effective=dt(2024, 9, 20),
   ....:     termination=dt(2029, 12, 20),
   ....:     spec="us_ig_cds",
   ....:     curves=["pfizer", "sofr"],
   ....:     recovery_rate=Variable(0.4, ["RR"]),  # <-- note the same "RR" variable
   ....:     notional=10e6,
   ....: )
   ....: 

In [23]: cds.rate(solver=pfizer_sv)
Out[23]: <Dual: 0.378610, (RR, sofr0, sofr1, ...), [-0.6, 0.0, 0.0, ...]>

In [24]: base_npv = cds.npv(solver=pfizer_sv)

In [25]: base_npv
Out[25]: <Dual: -298463.114836, (RR, sofr0, sofr1, ...), [-303087.1, 576.7, 892.1, ...]>

In [26]: cds.exo_delta(vars=["RR"], vars_scalar=[0.01], solver=pfizer_sv)
Out[26]: 
local_ccy                    usd
display_ccy                  usd
type      solver     label      
exogenous pfizer_cds RR    60.11

We can of course resort (just this once!) to numerical differentiation and see what happens there:

  1. Rebuild the solver with forward difference:

In [27]: pfizer_sv = Solver(
   ....:     curves=[hazard_curve],
   ....:     pre_solvers=[us_rates_sv],
   ....:     instruments=[
   ....:         CDS(
   ....:             effective=cds_eff,
   ....:             termination=_,
   ....:             spec="us_ig_cds",
   ....:             recovery_rate=0.41,  # <-- increase RR by 0.01
   ....:             curves=["pfizer", "sofr"]
   ....:         ) for _ in cds_mats
   ....:     ],
   ....:     s=cds_rates,
   ....:     instrument_labels=cds_tenor,
   ....:     id="pfizer_cds"
   ....: )
   ....: 
SUCCESS: `func_tol` reached after 6 iterations (levenberg_marquardt), `f_val`: 2.0643236636666406e-15, `time`: 0.1085s
  1. Recreate the CDS with forward difference:

In [28]: cds = CDS(
   ....:     effective=dt(2024, 9, 20),
   ....:     termination=dt(2029, 12, 20),
   ....:     spec="us_ig_cds",
   ....:     curves=["pfizer", "sofr"],
   ....:     recovery_rate=0.41,  # <-- increase the RR by 0.01
   ....:     notional=10e6,
   ....: )
   ....: 
  1. Revalue the NPV and compare it with the previous base value, scaling for 1% RR.

In [29]: float((cds.npv(solver=pfizer_sv) - base_npv) * 1.0)
Out[29]: 61.064857339602895

Personally, I am inclined to trust rateslib’s own figures here since these are calculated using AD and analytical maths and supported by a comparison to a forward difference method.