Closed KyleFe closed 1 month 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).
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
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 curves
as ["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)
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?
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
I believe this has been solved. Closing
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!