attack68 / rateslib

A fixed income library for pricing bonds and bond futures, and derivatives such as IRS, cross-currency and FX swaps. Contains tools for full Curveset construction with market standard optimisers and automatic differention (AD) and risk sensitivity calculations including delta and cross-gamma.
https://rateslib.readthedocs.io/en/stable/
Other
121 stars 22 forks source link

DOC: ZAR OIS Curve + Forecasting Curve Build + Depo Instruments #256

Closed KyleFe closed 1 month ago

KyleFe commented 2 months ago

Hi there,

I'm looking for some guidance on adding a depo as an instrument in the short-end of the curve, e.g. 1M, 2M, 3M. I can't seem to find any reference to depos being supported as instruments. I may have missed this somewhere though. Is there any reference material around this or any guidance on how this can best be achieved through a working example.

Appreciate the help and feedback.

Thanks!

attack68 commented 2 months ago

A depo is just a deposit rate. Use a single period IRS or a FRA to represent it with the necessary conventions. In the IBOR style it would be represented as a FRA (although it doesn't really matter) and in an RFR world, e.g. for a SOFR curve you would typically use an IRS. The working examples (e.g here ) just require you to change the start and tenors, to match the deposit rates you are tryinh to model (and of course set the nodes on a Curve to align).

KyleFe commented 2 months ago

Thanks @attack68, makes sense. On a 3M depo, you could model it as a FRA0x3.

I'm trying to compare the Ratelib build on two curves vs Quantlib vs our interal multicurve framework at the bank. For ZAR, we estimate/ proxy the ZAR OIS curve off an observed relationship between SAFEX and 3M Jibar. Out of interest, refer to this paper - https://www.sciencedirect.com/science/article/pii/S2212567115006644?via%3Dihub.

For the ZAR OIS curve, we essentially create synthetic tenor basis swaps between SAFEX and 3M Jibar at a fixed spread. The comparison between Rateslib, Quantlib and internal is close. Here is the Rateslib set up:

zar_ois_data = pd.DataFrame(
    {
        "Term": ["3M", "6M", "9M", "12M", "15M", "18M", "21M", "2Y", "3Y", "4Y", "5Y", "6Y", "7Y", "8Y", "9Y", "10Y", "12Y", "15Y", "20Y", "25Y", "30Y"],
        "Rate": [6.608, 6.540, 6.430, 6.410, 6.430, 6.470, 6.560, 6.630, 6.650, 6.790, 6.950, 7.090, 7.230, 7.345, 7.455, 7.560, 7.720, 7.860, 7.880, 7.860, 7.740]
    }
)

zar_ois_data["Maturity"] = [rl.add_tenor(datetime(2017,10,11),_,"F","bus") for _ in zar_ois_data["Term"]]

zar_ois_curve = rl.Curve(
    id="zar_ois_csa",
    convention="Act365F",
    calendar="bus",
    interpolation="log_linear",
    modifier="MF",
    nodes={
        **{datetime(2017,10,1): 1.0},  # <- this is today's DF, assumes 2 day settlement to start of first instrument
        **{_: 1.0 for _ in zar_ois_data["Maturity"]}
    }
)

zar_ois_args = dict(effective = datetime(2017,10,11), curves = "zar_ois_csa", frequency="q", calendar="bus")        #Check conventions for frequency

solver_zar_ois = rl.Solver(
    curves = [zar_ois_curve],
    instruments = [rl.IRS(termination=_, **zar_ois_args) for _ in zar_ois_data["Maturity"]],
    s = zar_ois_data["Rate"],
    instrument_labels= zar_ois_data["Maturity"],
    id="zar_ois_csa"
)

zar_ois_data["DF"] = [float(zar_ois_curve[_]) for _ in zar_ois_data["Maturity"]]

#Compute the zero rate using the calibrated DF's
zar_ois_data["Zero Rate"] = zar_ois_data.apply(
    lambda x : -100 * math.log(x["DF"]) * 365 / (x["Maturity"] - datetime(2017,10,11)).days, axis = 1
    )

with pd.option_context("display.float_format", lambda x: "%.6f" % x):
    print(zar_ois_data[["Term", "Maturity","Rate", "Zero Rate", "DF"]])

Inherently, the ZAR OIS curve is self-discounting.

I don't know if I'm setting up the 3M JIBAR curve correctly as a forecasting curve which is discounted with ZAR OIS.

Here's the code:

zar_jibar3m_data = pd.DataFrame(
    {
        "Term": ["3M", "6M", "9M", "12M", "15M", "18M", "21M", "2Y", "3Y", "4Y", "5Y", "6Y", "7Y", "8Y", "9Y", "10Y", "12Y", "15Y", "20Y", "25Y", "30Y"],
        "Rate": [7.008, 6.940, 6.830, 6.810, 6.830, 6.870, 6.960, 7.030, 7.050, 7.190, 7.350, 7.490, 7.630, 7.745, 7.855, 7.960, 8.120, 8.260, 8.280, 8.260, 8.140]
    }
)

zar_jibar3m_data["Maturity"] = [rl.add_tenor(datetime(2017,10,11),_,"F","bus") for _ in zar_jibar3m_data["Term"]]

zar_jibar3m_curve = rl.Curve(
    id="jibar3m",
    convention="Act365F",
    calendar="bus",
    interpolation="log_linear",
    modifier="MF",
    nodes={
        **{datetime(2017,10,9): 1.0},  # <- this is today's DF, assumes 2 day settlement to start of first instrument
        **{_: 1.0 for _ in zar_jibar3m_data["Maturity"]}
    }
)

zar_jibar3m_args1 = dict(curves="jibar3m", frequency="q", calendar="bus", termination="3m", convention="Act365F")
zar_jibar3m_args2 = dict(curves=["jibar3m"], frequency="q", calendar="bus", modifier="mf", convention="Act365F")

instruments = [
    rl.FRA(datetime(2017,10,11), **zar_jibar3m_args1),          #3M depo modelled as FRA 0x3, i.e FRA/Depo starts today
    rl.FRA(datetime(2018,1,11), **zar_jibar3m_args1),           #6M - FRA 3x6
    rl.FRA(datetime(2018,4,11), **zar_jibar3m_args1),           #9M - FRA 6x9
    rl.FRA(datetime(2018,7,11), **zar_jibar3m_args1),           #12M - FRA 9x12
    rl.FRA(datetime(2018,10,11), **zar_jibar3m_args1),          #15M - FRA 12x15
    rl.FRA(datetime(2019,1,11), **zar_jibar3m_args1),           #18M - FRA 15x18
    rl.FRA(datetime(2019,4,11), **zar_jibar3m_args1),           #21M - FRA 18x21
    rl.IRS(datetime(2017,10,11),"2y", **zar_jibar3m_args2),     #2Y - IRS 2Y
    rl.IRS(datetime(2017,10,11),"3y", **zar_jibar3m_args2),
    rl.IRS(datetime(2017,10,11),"4y", **zar_jibar3m_args2),
    rl.IRS(datetime(2017,10,11),"5y", **zar_jibar3m_args2),
    rl.IRS(datetime(2017,10,11),"6y", **zar_jibar3m_args2),
    rl.IRS(datetime(2017,10,11),"7y", **zar_jibar3m_args2),
    rl.IRS(datetime(2017,10,11),"8y", **zar_jibar3m_args2),
    rl.IRS(datetime(2017,10,11),"9y", **zar_jibar3m_args2),
    rl.IRS(datetime(2017,10,11),"10y", **zar_jibar3m_args2),
    rl.IRS(datetime(2017,10,11),"12y", **zar_jibar3m_args2),
    rl.IRS(datetime(2017,10,11),"15y", **zar_jibar3m_args2),
    rl.IRS(datetime(2017,10,11),"20y", **zar_jibar3m_args2),
    rl.IRS(datetime(2017,10,11),"25y", **zar_jibar3m_args2),
    rl.IRS(datetime(2017,10,11),"30y", **zar_jibar3m_args2),
]

solver_jibar3m = rl.Solver(
    pre_solvers=[solver_zar_ois],                  #Specify already determined curves/ solver. Used to specify the discounting curve for JIBAR3M.
    curves=[zar_jibar3m_curve],
    instruments=instruments,
    s=zar_jibar3m_data["Rate"],                     #Optimisation will calibrate the par rates provided to the Solver.
    instrument_labels=zar_jibar3m_data["Term"],
    #id="zar_ois_csa"
)

zar_jibar3m_data["DF"] = [float(zar_jibar3m_curve[_]) for _ in zar_jibar3m_data["Maturity"]]
with pd.option_context("display.float_format", lambda x: "%.6f" % x):
    zar_jibar3m_data

zar_jibar3m_data

My question is - have I set this 3M JIBAR curve up correctly? If I remove the pre_solvers, the returned curve is exactly the same as with it in, which I would expect to be different, i.e. multicurve set up vs self-discounting.

Appreciate the guidance.

Thanks

attack68 commented 2 months ago

A few comments:

1) Set Curves relative to today and not spot. Provided your instruments are set up with the right effective and termination dates the Solver will handle the correct calibration. The discount factor for today should always be 1.0, not spot. In your code the OIS and 3M curve seem to start on different dates, OIS: 1st Oct 2017 and 3M: 9th Oct 2017. I assume this is a typo and both are intended to be 9th.

2) This is irrelevant for your curve construction here becuase the rates dont have fixings and the IBOR rates are derived from the curve discount factors but I assume the correct definition of the IBOR swaps is:

zar_jibar3m_args2 = dict(
    curves=["jibar3m", "zar_ois_csa"], 
    frequency="q", 
    calendar="bus", 
    modifier="mf", 
    convention="Act365F"
    leg2_fixing_method="ibor",
    leg2_method_param=2,  # fixing publication lags the value dates by 2 business days
)

3) Your IBOR instruments have no knowledge of what discount curve you want them to use. When you supply the curves as "jibar3m" you are indirectly instructing: "use the "jibar3m" for forecasting cashflows and since there is no other discount curve specified use that to discount as well.". When you provide curvesas ["jibar3m", "zar_ois_csa"] you are directly specifying the discount curve.

Note that if you make these changes as suggested you will find with your data that the Solver fails to solve. This is becuase on the fourth iteration the gradient based optimiser derives one of the longer end discount factors to be a negative value. The initial guess is too far from the solution and thus it overshoots and cant recover from the logarithm of negative discount factors.

To solve this you can use the already solved nodes from the 3M curve that did solve correctly (under self discounting) as the initial guess:

zar_jibar3m_curve_TWO = rl.Curve(
    id="jibar3m_2",
    convention="Act365F",
    calendar="bus",
    interpolation="log_linear",
    modifier="MF",
    nodes=zar_jibar3m_curve.nodes.copy()  # <- initial curve guess
)

Then there will be no overshoot and it will solve after 5 iterations.

The difference between the self discouted 3m curve and the OIS discounted one solves to this:

zar_jibar3m_curve.plot("3m", comparators=[zar_jibar3m_curve_TWO], difference=True)

image

KyleFe commented 2 months ago

Thanks for the advice @attack68 and thanks for the quick responses, much appreciated. Useful way to work around failed calibration.

After specifying the new curve with a new initial guess set up as follows:

zar_jibar3m_curve_TWO = rl.Curve(
    id="jibar3m_2",
    convention="Act365F",
    calendar="bus",
    interpolation="log_linear",
    modifier="MF",
    nodes=zar_jibar3m_curve.nodes.copy()  # <- initial curve guess
)

I seem to be doing something silly with the new Solver:

solver_jibar3m_2 = rl.Solver(
    pre_solvers=[solver_zar_ois],                  # Pre-solver with discounting curve. Used to specify the discounting curve for JIBAR3M.
    curves=[zar_jibar3m_curve_TWO],
    instruments=instruments,
    s=zar_jibar3m_data["Rate"],                    # Calibrate the provided rates.
    instrument_labels=zar_jibar3m_data["Term"],
)

KeyError Traceback (most recent call last) c:\Users\KyleFerreira\anaconda3\envs\quantprojects\Lib\site-packages\rateslib\instruments.py in ?(curvesattr, solver, curves) 206 curves = tuple(_get_curve_from_solver(curve, solver) for curve in curves) 207 except KeyError: --> 208 raise ValueError( 209 "curves must contain str curve id s existing in solver "

c:\Users\KyleFerreira\anaconda3\envs\quantprojects\Lib\site-packages\rateslib\instruments.py in ?(.0) --> 206 def check_curve(curve): 207 if isinstance(curve, str):

c:\Users\KyleFerreira\anaconda3\envs\quantprojects\Lib\site-packages\rateslib\instruments.py in ?(curve, solver) 89 return curve 90 elif isinstance(curve, str): ---> 91 return solver.pre_curves[curve] 92 elif curve is NoInput.blank or curve is None:

KeyError: 'jibar3m'

During handling of the above exception, another exception occurred:

ValueError Traceback (most recent call last) ~\AppData\Local\Temp\ipykernel_29544\2129145223.py in ?() ----> 1 solver_jibar3m_2 = rl.Solver( ... 209 "curves must contain str curve id s existing in solver " 210 "(or its associated pre_solvers)" 211 )

ValueError: curves must contain str curve id s existing in solver (or its associated pre_solvers)

What am I doing wrong here?

attack68 commented 2 months ago

the Solver you are solving for (solver_jibar3m_2) has, internally, created a map of two curves:

{
    "jibar3m_2": zar_jibar3m_curve_TWO,  # <- this is the curve you are directly solving here
    "zar_ois_csa": zar_ois_curve,        # <- this is available from the given pre_solver
}

The error you are receiving is suggesting that an instrument rate call is trying to find a curve that doesn't exist in that map. I bet you didn't update the zar_jibar3m_args1 and zar_jibar3m_args2

attack68 commented 1 month ago

I believe this has been solved. Closing