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