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:
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
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,
....: )
....:
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.