Solver#

The rateslib.solver module includes a Solver class which iteratively solves for the parameters of Curve objects, to fit the given market data of calibrating Instruments.

This module relies on the utility module dual for gradient based optimization.

rateslib.solver.Solver([curves, ...])

A numerical solver to determine node values on multiple curves simultaneously.

Parameters#

The Solver solves the following least squares objective function:

\[\min_\mathbf{v} f(\mathbf{v}, \mathbf{S}) = (\mathbf{r(v)-S})\mathbf{W}(\mathbf{r(v)-S})^\mathbf{T}\]

where \(\mathbf{S}\) are the known calibrating instrument rates, \(\mathbf{r}\) are the determined instrument rates based on the solved parameters, \(\mathbf{v}\), and \(\mathbf{W}\) is a diagonal matrix of weights.

Each curve type has the following parameters:

Parameters and hyper parameters of Curves and Solver interaction.#

Parameter

Type

Summary

Affected by Solver

interpolation

Hyper parameter

Equation or mechanism to determine intermediate values not defined explicitly by nodes.

No

nodes keys

Hyper parameters

Fixed points which implicitly impact the interpolated values across the curve.

No

t

Hyper parameters

Framework for defining the (log) cubic spline structure which implicitly impacts the interpolated values across the curve.

No

endpoints

Hyper parameters

Method used to control spline curves on the left and right boundaries.

No

nodes values

Parameters

The explicit values associated with node dates.

Yes.
For Curve all parameters except the initial node value of 1.0 is varied.
For LineCurve all parameters including the initial node value is varied.

Calibrating Curves#

Thus, in order to calibrate or solve curves the hyper parameters must already be defined, so that nodes, interpolation, t and endpoints must all be configured. These will not be changed by the Solver. The nodes values (the parameters) should be initialised with sensible values from which the optimizer will start. However, it is usually quite robust and should be able to solve from a variety of initialised node values.

We define a simple Curve using default hyper parameters and only a few nodes.

In [1]: ll_curve = Curve(
   ...:     nodes={
   ...:         dt(2022,1,1): 1.0,
   ...:         dt(2023,1,1): 0.99,
   ...:         dt(2024,1,1): 0.979,
   ...:         dt(2025,1,3): 0.967
   ...:     },
   ...:     id="curve",
   ...: )
   ...: 

Next, we must define the instruments which will instruct the solution.

In [2]: instruments = [
   ...:     IRS(dt(2022, 1, 1), "1Y", "A", curves="curve"),
   ...:     IRS(dt(2022, 1, 1), "2Y", "A", curves="curve"),
   ...:     IRS(dt(2022, 1, 1), "3Y", "A", curves="curve"),
   ...: ]
   ...: 

There are a number of different mechanisms for the way in which this can be done, but the example here reflects best practice as demonstrated in pricing mechanisms.

Once a suitable, and valid, set of instruments has been configured we can supply it, and the curves, to the solver. We must also supply some target rates, s, and the optimizer will update the curves.

In [3]: solver = Solver(
   ...:     curves = [ll_curve],
   ...:     instruments = instruments,
   ...:     s = [1.0, 1.6, 2.0],
   ...: )
   ...: 
SUCCESS: `func_tol` reached after 4 iterations (levenberg_marquardt), `f_val`: 9.549288083712273e-14, `time`: 0.0057s

In [4]: ll_curve.plot("1D")
Out[4]: 
(<Figure size 640x480 with 1 Axes>,
 <Axes: >,
 [<matplotlib.lines.Line2D at 0x7f0a9fadff90>])

(Source code, png, hires.png, pdf)

_images/c_solver-1.png

The values of the solver.s can be updated and the curves can be redetermined

In [5]: print(instruments[1].rate(ll_curve).real)
1.5999999997080336

In [6]: solver.s[1] = 1.5

In [7]: solver.iterate()
SUCCESS: `func_tol` reached after 3 iterations (levenberg_marquardt), `f_val`: 1.6299982557749375e-12, `time`: 0.0097s

In [8]: print(instruments[1].rate(ll_curve).real)
1.500001236890998

Changing the hyper parameters of a curve does not require any fundamental change to the input arguments to the Solver. Here a mixed interpolation scheme is used and the Curve calibrated.

In [9]: mixed_curve = Curve(
   ...:     nodes={
   ...:         dt(2022,1,1): 1.0,
   ...:         dt(2023,1,1): 0.99,
   ...:         dt(2024,1,1): 0.965,
   ...:         dt(2025,1,3): 0.93,
   ...:     },
   ...:     interpolation="log_linear",
   ...:     t = [dt(2023,1,1), dt(2023,1,1), dt(2023,1,1), dt(2023,1,1), dt(2024,1,1), dt(2025,1,3), dt(2025,1,3), dt(2025,1,3), dt(2025,1,3)],
   ...:     id="curve",
   ...: )
   ...: 

In [10]: solver = Solver(
   ....:     curves = [mixed_curve],
   ....:     instruments = instruments,
   ....:     s = [1.0, 1.5, 2.0],
   ....: )
   ....: 
SUCCESS: `func_tol` reached after 4 iterations (levenberg_marquardt), `f_val`: 2.0488787156020367e-14, `time`: 0.0058s

In [11]: ll_curve.plot("1D", comparators=[mixed_curve], labels=["log-linear", "mixed"])
Out[11]: 
(<Figure size 640x480 with 1 Axes>,
 <Axes: >,
 [<matplotlib.lines.Line2D at 0x7f0a9fab4890>,
  <matplotlib.lines.Line2D at 0x7f0a9f8ed290>])

(Source code, png, hires.png, pdf)

_images/c_solver-2.png

Algorithms#

In the defaults settings of rateslib, Solver uses a “levenberg_marquardt” algorithm.

There is an option to use a “gauss_newton” algorithm which is faster if the initial guess is reasonable. This should be used where possible, but this is a more unstable algorithm so is not set as the default.

For other debugging procedures the “gradient_descent” method is available although this is not recommended due to computational inefficiency.

Details on these algorithms are provided in the rateslib supplementary materials.

Weights#

The argument weights allows certain instrument rates to be targeted with greater priority than others. In the above examples this was of no relevance since in all previous cases the minimum solution of zero was fully attainable.

The following pathological example, where the same instruments are provided multiple times with different rates, shows the effect.

In [12]: instruments = [
   ....:     IRS(dt(2022, 1, 1), "1Y", "A", curves="curve"),
   ....:     IRS(dt(2022, 1, 1), "2Y", "A", curves="curve"),
   ....:     IRS(dt(2022, 1, 1), "3Y", "A", curves="curve"),
   ....:     IRS(dt(2022, 1, 1), "1Y", "A", curves="curve"),
   ....:     IRS(dt(2022, 1, 1), "2Y", "A", curves="curve"),
   ....:     IRS(dt(2022, 1, 1), "3Y", "A", curves="curve"),
   ....: ]
   ....: 

In [13]: solver = Solver(
   ....:     curves = [mixed_curve],
   ....:     instruments = instruments,
   ....:     s = [1.0, 1.1, 1.2, 5.0, 5.1, 5.2],
   ....:     weights = [1, 1, 1, 1e-4, 1e-4, 1e-4],
   ....: )
   ....: 
SUCCESS: `conv_tol` reached after 7 iterations (levenberg_marquardt), `f_val`: 0.0047995200479952005, `time`: 0.0165s

In [14]: for instrument in instruments:
   ....:     print(float(instrument.rate(solver=solver)))
   ....: 
1.0003999600039903
1.1003999600039887
1.200399960003984
1.0003999600039903
1.1003999600039887
1.200399960003984

In [15]: solver = Solver(
   ....:     curves = [mixed_curve],
   ....:     instruments = instruments,
   ....:     s = [1.0, 1.1, 1.2, 5.0, 5.1, 5.2],
   ....:     weights = [1e-4, 1e-4, 1e-4, 1, 1, 1],
   ....: )
   ....: 
FAILURE: `max_iter` breached after 100 iterations (levenberg_marquardt), `f_val`: 0.0047995200479952005, `time`: 0.2082s

In [16]: for instrument in instruments:
   ....:     print(float(instrument.rate(solver=solver)))
   ....: 
4.999600039996006
5.099600039996002
5.199600039996004
4.999600039996006
5.099600039996002
5.199600039996004

Dependency Chains#

In real fixed income trading environments every curve should be synchronous and dependencies should use the same construction method in one division as in another. The pre_solvers argument allows a chain of Solver s. Here a SOFR curve is constructed via a solver and is then added to another solver which solves an ESTR curve. There is no technical dependence here of one on the other so these solvers could be arranged in either order.

In [17]: sofr_curve = Curve(
   ....:     nodes={
   ....:         dt(2022, 1, 1): 1.0,
   ....:         dt(2023, 1, 1): 1.0,
   ....:         dt(2024, 1, 1): 1.0,
   ....:         dt(2025, 1, 1): 1.0,
   ....:     },
   ....:     id="sofr",
   ....: )
   ....: 

In [18]: sofr_instruments = [
   ....:     IRS(dt(2022, 1, 1), "1Y", "A", currency="usd", curves="sofr"),
   ....:     IRS(dt(2022, 1, 1), "2Y", "A", currency="usd", curves="sofr"),
   ....:     IRS(dt(2022, 1, 1), "3Y", "A", currency="usd", curves="sofr"),
   ....: ]
   ....: 

In [19]: sofr_solver = Solver(
   ....:     curves = [sofr_curve],
   ....:     instruments = sofr_instruments,
   ....:     s = [2.5, 3.0, 3.5],
   ....: )
   ....: 
SUCCESS: `func_tol` reached after 4 iterations (levenberg_marquardt), `f_val`: 4.659124754473029e-13, `time`: 0.0056s

In [20]: estr_curve = Curve(
   ....:     nodes={
   ....:         dt(2022, 1, 1): 1.0,
   ....:         dt(2023, 1, 1): 1.0,
   ....:         dt(2024, 1, 1): 1.0,
   ....:         dt(2025, 1, 1): 1.0,
   ....:     },
   ....:     id="estr",
   ....: )
   ....: 

In [21]: estr_instruments = [
   ....:     IRS(dt(2022, 1, 1), "1Y", "A", currency="eur", curves="estr"),
   ....:     IRS(dt(2022, 1, 1), "2Y", "A", currency="eur", curves="estr"),
   ....:     IRS(dt(2022, 1, 1), "3Y", "A", currency="eur", curves="estr"),
   ....: ]
   ....: 

In [22]: estr_solver = Solver(
   ....:     curves = [estr_curve],
   ....:     instruments = estr_instruments,
   ....:     s = [1.25, 1.5, 1.75],
   ....:     pre_solvers=[sofr_solver]
   ....: )
   ....: 
SUCCESS: `func_tol` reached after 4 iterations (levenberg_marquardt), `f_val`: 3.1986237566494413e-13, `time`: 0.0055s

It is possible to create only a single solver using the two curves and six instruments above. However, in practice it is less efficient to solve independent solvers within the same framework. And practically, this is not usually how trading teams are configured, all as one big group. Normally siloed teams are responsible for their own subsections, be it one currency or another, or different product types.

Multi-Currency Instruments#

Multi-currency derivatives rely on FXForwards. In this example we establish a new cash-collateral discount curve and use XCS within a Solver.

In [23]: eurusd = Curve(
   ....:     nodes={
   ....:         dt(2022, 1, 1): 1.0,
   ....:         dt(2023, 1, 1): 1.0,
   ....:         dt(2024, 1, 1): 1.0,
   ....:         dt(2025, 1, 1): 1.0,
   ....:     },
   ....:     id="eurusd",
   ....: )
   ....: 

In [24]: fxr = FXRates({"eurusd": 1.10}, settlement=dt(2022, 1, 3))

In [25]: fxf = FXForwards(
   ....:     fx_rates=fxr,
   ....:     fx_curves={
   ....:         "eureur": estr_curve,
   ....:         "eurusd": eurusd,
   ....:         "usdusd": sofr_curve,
   ....:     }
   ....: )
   ....: 

In [26]: kwargs={
   ....:     "currency": "eur",
   ....:     "leg2_currency": "usd",
   ....:     "curves": ["estr", "eurusd", "sofr", "sofr"],
   ....: }
   ....: 

In [27]: xcs_instruments = [
   ....:     XCS(dt(2022, 1, 1), "1Y", "A", **kwargs),
   ....:     XCS(dt(2022, 1, 1), "2Y", "A", **kwargs),
   ....:     XCS(dt(2022, 1, 1), "3Y", "A", **kwargs),
   ....: ]
   ....: 

In [28]: xcs_solver = Solver(
   ....:     curves = [eurusd],
   ....:     instruments = xcs_instruments,
   ....:     s = [-10, -15, -20],
   ....:     fx=fxf,
   ....:     pre_solvers=[estr_solver],
   ....: )
   ....: 
SUCCESS: `func_tol` reached after 3 iterations (levenberg_marquardt), `f_val`: 3.0042493839090535e-17, `time`: 0.0327s

In [29]: estr_curve.plot("1d", comparators=[eurusd], labels=["Eur:eur", "Eur:usd"])
Out[29]: 
(<Figure size 640x480 with 1 Axes>,
 <Axes: >,
 [<matplotlib.lines.Line2D at 0x7f0a9f7dbc50>,
  <matplotlib.lines.Line2D at 0x7f0a9f774450>])

(Source code, png, hires.png, pdf)

_images/c_solver-3_00_00.png

Calibration Instrument Error#

Depending upon the hyper parameters, parameters and calibrating instrument choices, the optimized solution may well lead to curves that do not completely reprice the calibrating instruments. Sometimes this is representative of errors in the construction process, and at other times this is completely desirable.

When the Solver is initialised and iterates it will print an output to console indicating a success or failure and the value of the objective function. If this value is very small, that already indicates that there is no error in any instruments. However for cases where the curve is over-specified, error is to be expected.

In [30]: solver_with_error = Solver(
   ....:     curves=[
   ....:         Curve(
   ....:             nodes={dt(2022, 1, 1): 1.0, dt(2022, 7, 1): 1.0, dt(2023, 1, 1): 1.0},
   ....:             id="curve1"
   ....:         )
   ....:     ],
   ....:     instruments=[
   ....:         IRS(dt(2022, 1, 1), "1M", "A", curves="curve1"),
   ....:         IRS(dt(2022, 1, 1), "2M", "A", curves="curve1"),
   ....:         IRS(dt(2022, 1, 1), "3M", "A", curves="curve1"),
   ....:         IRS(dt(2022, 1, 1), "4M", "A", curves="curve1"),
   ....:         IRS(dt(2022, 1, 1), "8M", "A", curves="curve1"),
   ....:         IRS(dt(2022, 1, 1), "12M", "A", curves="curve1"),
   ....:     ],
   ....:     s=[2.0, 2.2, 2.3, 2.4, 2.45, 2.55],
   ....:     instrument_labels=["1m", "2m", "3m", "4m", "8m", "12m"],
   ....: )
   ....: 
SUCCESS: `conv_tol` reached after 4 iterations (levenberg_marquardt), `f_val`: 0.08763280492897038, `time`: 0.0062s

In [31]: solver_with_error.error
Out[31]: 
e77b7_  1m     22.80
        2m      2.99
        3m     -6.79
        4m    -16.59
        8m     -4.59
        12m     2.30
dtype: float64

Composite, Proxy and Multi-CSA Curves#

CompositeCurve, ProxyCurve and MultiCsaCurve do not have their own parameters. These rely on the parameters from other fundamental curves. It is possible to create a Solver defined with Instruments that reference these complex curves as pricing curves with the Solver updating the underlying parameters of the fundamental curves.

This does not require much additional configuration, it simply requires ensuring all necessary curves are documented.

Below we will calculate a EUR IRS defined by a CompositeCurve and a Curve, a USD IRS defined just by a Curve, and then create an FXForwards defined with USD collateral, but calibrate a solver by XCS instruments priced with EUR collateral.

In [32]: eureur = Curve({dt(2022, 1, 1): 1.0, dt(2023, 1, 1): 1.0}, id="eureur")

In [33]: eurspd = Curve({dt(2022, 1, 1): 1.0, dt(2023, 1, 1): 0.999}, id="eurspd")

In [34]: eur3m = CompositeCurve([eureur, eurspd], id="eur3m")

In [35]: usdusd = Curve({dt(2022, 1, 1): 1.0, dt(2023, 1, 1): 1.0}, id="usdusd")

In [36]: eurusd = Curve({dt(2022, 1, 1): 1.0, dt(2023, 1, 1): 1.0}, id="eurusd")

In [37]: fxr = FXRates({"eurusd": 1.1}, settlement=dt(2022, 1, 3))

In [38]: fxf = FXForwards(
   ....:     fx_rates=fxr,
   ....:     fx_curves={
   ....:         "eureur": eureur,
   ....:         "usdusd": usdusd,
   ....:         "eurusd": eurusd,
   ....:     }
   ....: )
   ....: 

In [39]: usdeur = fxf.curve("usd", "eur", id="usdeur")

In [40]: instruments = [
   ....:     IRS(dt(2022, 1, 1), "1Y", "A", currency="eur", curves=["eur3m", "eureur"]),
   ....:     IRS(dt(2022, 1, 1), "1Y", "A", currency="usd", curves="usdusd"),
   ....:     XCS(dt(2022, 1, 1), "1Y", "A", currency="eur", leg2_currency="usd", curves=["eureur", "eureur", "usdusd", "usdeur"]),
   ....: ]
   ....: 

In [41]: solver = Solver(curves=[eureur, eur3m, usdusd, eurusd, usdeur], instruments=instruments, s=[2.0, 2.7, -15], fx=fxf)
SUCCESS: `func_tol` reached after 3 iterations (levenberg_marquardt), `f_val`: 3.73941410218606e-12, `time`: 0.0124s

We can plot all five curves defined above by the 3 fundamental curves, ‘eureur’, ‘usdusd’, ‘eurusd’.

In [42]: eureur.plot("1d", comparators=[eur3m, eurusd], labels=["eureur", "eur3m", "eurusd"])
Out[42]: 
(<Figure size 640x480 with 1 Axes>,
 <Axes: >,
 [<matplotlib.lines.Line2D at 0x7f0a9f593c50>,
  <matplotlib.lines.Line2D at 0x7f0a9f3e3050>,
  <matplotlib.lines.Line2D at 0x7f0a9f3e3950>])

In [43]: usdusd.plot("1d", comparators=[usdeur], labels=["usdusd", "usdeur"])
Out[43]: 
(<Figure size 640x480 with 1 Axes>,
 <Axes: >,
 [<matplotlib.lines.Line2D at 0x7f0a9f98a9d0>,
  <matplotlib.lines.Line2D at 0x7f0a9f443c90>])

(Source code, png, hires.png, pdf)

_images/c_solver-4_00_00.png

(Source code, png, hires.png, pdf)

_images/c_solver-5_00_00.png