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.0020s

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]: data = irs.exo_delta(solver=solver, vars=["N"])

In [10]: with option_context("display.float_format", lambda x: '%.6f' % x):
   ....:     print(data)
   ....: 
local_ccy                    gbp
display_ccy                  gbp
type      solver label          
exogenous solver N     -0.002462

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 [11]: irs.analytic_delta(curve)
Out[11]: <Dual: 246.211912, (N, curve0, curve1), [0.0, 122.4, 126.9]>
In [12]: 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 [13]: irs.exo_delta(solver=solver, vars=["N", "R"], vars_scalar=[1.0, 1/100])
Out[13]: 
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 [14]: irs.analytic_delta(curve, leg=2)
Out[14]: <Dual: -247.487518, (N, curve0, curve1), [-0.0, -174.5, -74.9]>
In [15]: 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 [16]: irs.exo_delta(solver=solver, vars=["N", "R", "z"], vars_scalar=[1.0, 1/100, 1.0])
Out[16]: 
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.

The below example shows a user injecting a Dual2 sensitivity directly and the calculations breaking becuase other variable are only in Dual mode.

In [17]: 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 [18]: 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. A Variable will convert to the necessary type as and when the calculation requires.

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 [19]: disc_curve  # the US SOFR discount curve created
Out[19]: <rl.Curve:sofr at 0x123704200>

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

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

In [22]: pfizer_sv  # the Solver calibrating the hazard curve
Out[22]: <rl.Solver:pfizer_cds at 0x123527ed0>

First we can demonstrate what happens when we inject sensitivity directly to a single Instrument calculation. The analytic_rec_risk() is an analytic calculation that determines the change in value for a 1% change in recovery rate just for a single CDS Instrument

In [23]: cds = CDS(
   ....:     effective=dt(2024, 9, 20),
   ....:     termination=dt(2029, 12, 20),
   ....:     spec="us_ig_cds",
   ....:     curves=["pfizer", "sofr"],
   ....:     notional=10e6,
   ....: )
   ....: 

In [24]: cds.analytic_rec_risk(hazard_curve, disc_curve)
Out[24]: -3031.0076128941723

We can also obtain this value by copying a curve, injecting sensitivity, as an exogenous variable into it and evaluating with exo_delta(). This copied curve is independent from, and not mapped by, the Solver so none of the Solver’s parameters are made sensitive to the change in recovery_rate here.

In [25]: hazard_curve_copy = hazard_curve.copy()

# Set a new id to avoid Solver curve mapping errors
In [26]: hazard_curve_copy._id = "something_else"

# Inject sensitivity to the recovery rate
In [27]: hazard_curve_copy.update_meta("credit_recovery_rate", Variable(0.4, ["RR"]))

In [28]: cds.exo_delta(curves=[hazard_curve_copy, disc_curve], solver=pfizer_sv, vars=["RR"], vars_scalar=[1/100.0])
Out[28]: 
local_ccy                       usd
display_ccy                     usd
type      solver     label         
exogenous pfizer_cds RR    -3031.01

But this isn’t really the value we want to capture. In fact we want to capture the change in NPV when the recovery rate of all Pfizer CDSs (including those that are calibrating the curves) have a different recovery rate, i.e. when the recovery_rate on the original hazard_curve is updated. This is the same process, except this time we inject the sensitivity to the Solver’s mapped curve directly and re-iterate.

# Update the Pfizer hazard curve to have exogenous exposure to "RR" variable
In [29]: hazard_curve.update_meta("credit_recovery_rate", Variable(0.40, ["RR"]))

In [30]: pfizer_sv.iterate()
SUCCESS: `func_tol` reached after 0 iterations (levenberg_marquardt), `f_val`: 6.274847254555824e-12, `time`: 0.0196s

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

This value is close to another system’s estimate of 78.75. But let’s validate it by resorting (just this once!) to numerical differentiation and see what happens there:

  1. Record the initial values:

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

In [33]: base_npv
Out[33]: <Dual: -298476.578988, (RR, sofr0, sofr1, ...), [-303100.8, 576.6, 892.1, ...]>
  1. Update the recovery_rate parameter and re-iterate the solver:

In [34]: hazard_curve.update_meta("credit_recovery_rate", 0.41)

In [35]: pfizer_sv.iterate()
SUCCESS: `func_tol` reached after 6 iterations (levenberg_marquardt), `f_val`: 2.0657830334975064e-15, `time`: 0.1342s
  1. Revalue the NPV and compare it with the previous base value, scaling for 1% RR.

In [36]: fwd_diff = cds.npv(solver=pfizer_sv)

In [37]: float(fwd_diff - base_npv)
Out[37]: 61.068403578712605

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.