lballabio / QuantLib

The QuantLib C++ library
http://quantlib.org
Other
5.38k stars 1.79k forks source link

Unexpected Behavior in Zero Curve Interpolation #1340

Closed QuantInTheMaking closed 2 years ago

QuantInTheMaking commented 2 years ago

Hello all,

I followed Goutham Balaraman_ Luigi Ballabio - QuantLib Python Cookbook (2017) textbook to construct deposits + bonds yield curve with actual data. The code is working fine, but I am getting an unexpected output presented in the sudden drop in the zero curve at around year 2 (see the image below). Please ignore the drop at the end it is due to extrapolation.

zeroCurve

Although my input yields do not have such a drop as shown below image

I have seen a similar issue being reported here: https://github.com/lballabio/QuantLib/issues/930

Your efforts in helping me resolve this issue would be highly appreciated

boring-cyborg[bot] commented 2 years ago

Thanks for posting! It might take a while before we look at your issue, so don't worry if there seems to be no feedback. We'll get to it.

ralfkonrad commented 2 years ago

Hi @QuantInTheMaking,

I don't think it is similar to #930. There was a very specific bug in the TreeCallableFixedRateBondEngine implementation which I guess is not relevant here (see fix #1142).

Can you perhaps share your python code so we can reproduce and understand what causes the jump.

Regards Ralf

QuantInTheMaking commented 2 years ago

Thank you for your response @ralfkonrad

Here is my code:

import QuantLib as ql
import pandas as pd
import matplotlib.pyplot as plt
# Reading term structure data
yield_curve_input = pd.read_excel("Yield Curve Input.xlsx", sheet_name="Yield Curve - Input")
yield_curve_input["Maturity Date"] = pd.to_datetime(yield_curve_input["Maturity Date"])

calculation_date_parameter = pd.to_datetime("28/2/2022", format="%d/%m/%Y")
# Extracting deposits data
deposit_maturities = list(yield_curve_input.loc[yield_curve_input["Type"] == "Deposit"]["Maturity Date"])
deposit_maturities.insert(0, calculation_date_parameter)
deposit_rates = list(yield_curve_input.loc[yield_curve_input["Type"] == "Deposit"]["Adjusted YTM"])
deposit_rates.insert(0, deposit_rates[0])

# Extracting bonds data
bond_maturities = list(yield_curve_input.loc[yield_curve_input["Type"] == "Bond"]["Maturity Date"])
bond_rates = list(yield_curve_input.loc[yield_curve_input["Type"] == "Bond"]["Adjusted YTM"])
# Parameters
calc_date = ql.Date(calculation_date_parameter.day, calculation_date_parameter.month, calculation_date_parameter.year)
ql.Settings.instance().evaluationDate = calc_date
calendar = ql.SaudiArabia()
business_convention = ql.Following
day_count = ql.Actual360()
end_of_month = False
settlement_days = 0
face_amount = 100
coupon_frequency = ql.Period(ql.Semiannual)
# Constructing helpers
depo_helpers = []
for r, m in zip(deposit_rates, deposit_maturities): 
    maturity = ql.Period(ql.Actual360().dayCount(calc_date, ql.Date(m.day, m.month, m.year)), ql.Days)
    depo_helpers.append(ql.DepositRateHelper(ql.QuoteHandle(ql.SimpleQuote(r)),
                                            maturity,
                                            settlement_days,
                                            calendar,
                                            business_convention,
                                            end_of_month,
                                            day_count))

bond_helpers = []
for r, m in zip(bond_rates, bond_maturities):
    maturity = calc_date + ql.Period(ql.Actual360().dayCount(calc_date, ql.Date(m.day, m.month, m.year)), ql.Days)
    schedule = ql.Schedule(calc_date,
                            maturity,
                            coupon_frequency,
                            calendar,
                            business_convention,
                            business_convention,
                            ql.DateGeneration.Backward,
                            end_of_month)

    bond_helper = ql.FixedRateBondHelper(ql.QuoteHandle(ql.SimpleQuote(face_amount)),
                                            settlement_days,
                                            face_amount,
                                            schedule,
                                            [r],
                                            day_count,
                                            business_convention)
    bond_helpers.append(bond_helper)

# Combining helpers
rate_helpers = depo_helpers + bond_helpers
def get_spot_rates(yieldcurve, day_count, calendar=ql.SaudiArabia(), months=361):

    spots = []
    tenors = []
    ref_date = yieldcurve.referenceDate()
    calc_date = ref_date
    for month in range(0, months):
        yrs = month/12.0
        d = calendar.advance(ref_date, ql.Period(month, ql.Months))
        compounding = ql.Compounded
        freq = ql.Annual
        extrapolate = True
        zero_rate = yieldcurve.zeroRate(yrs, compounding, freq, extrapolate)
        tenors.append(yrs)
        eq_rate = zero_rate.equivalentRate(day_count,compounding,freq,calc_date,d).rate()
        spots.append(eq_rate)

    return pd.DataFrame(zip(tenors, spots), columns=["Maturities","Curve"], index=['']*len(tenors))
# Interpolation
yc_linearzero = ql.PiecewiseLinearZero(calc_date, rate_helpers, day_count)
splz = get_spot_rates(yc_linearzero, day_count)

# Plotting
plt.plot(splz["Maturities"],splz["Curve"],'--', label="LinearZero")
plt.xlabel("Years", size=12)
plt.ylabel("Zero Rate", size=12)
plt.legend(loc=0)

And here is the data I am reading from the Excel file:

<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40">

Type | Tenor | Start Date | Maturity Date | Adjusted YTM -- | -- | -- | -- | -- Deposit | 0.25 | 02/28/2022 | 05/30/2022 | 0.80% Deposit | 0.50 | 02/28/2022 | 08/29/2022 | 0.84% Deposit | 0.92 | 02/28/2022 | 02/27/2023 | 0.87% Bond | 1.08 | 02/28/2022 | 4/25/2023 | 1.76% Bond | 1.33 | 02/28/2022 | 7/25/2023 | 2.35% Bond | 1.58 | 02/28/2022 | 10/24/2023 | 2.49% Bond | 1.83 | 02/28/2022 | 1/23/2024 | 2.78% Bond | 2.33 | 02/28/2022 | 7/26/2024 | 2.53% Bond | 2.42 | 02/28/2022 | 8/23/2024 | 2.58% Bond | 2.50 | 02/28/2022 | 9/20/2024 | 2.91% Bond | 2.58 | 02/28/2022 | 10/25/2024 | 2.63% Bond | 2.83 | 02/28/2022 | 1/24/2025 | 2.66% Bond | 3.00 | 02/28/2022 | 3/23/2025 | 3.00% Bond | 3.08 | 02/28/2022 | 4/25/2025 | 2.70% Bond | 3.33 | 02/28/2022 | 7/25/2025 | 3.03% Bond | 3.58 | 02/28/2022 | 10/24/2025 | 3.04% Bond | 4.83 | 02/28/2022 | 1/27/2027 | 3.12% Bond | 5.33 | 02/28/2022 | 7/27/2027 | 3.18% Bond | 5.42 | 02/28/2022 | 8/23/2027 | 3.02% Bond | 5.50 | 02/28/2022 | 9/20/2027 | 3.18% Bond | 5.58 | 02/28/2022 | 10/25/2027 | 3.04% Bond | 5.83 | 02/28/2022 | 1/21/2028 | 3.23% Bond | 6.08 | 02/28/2022 | 4/25/2028 | 3.24% Bond | 6.33 | 02/28/2022 | 7/26/2028 | 3.27% Bond | 6.58 | 02/28/2022 | 10/24/2028 | 3.29% Bond | 6.83 | 02/28/2022 | 1/4/2029 | 3.20% Bond | 7.42 | 02/28/2022 | 8/19/2029 | 3.36% Bond | 7.83 | 02/28/2022 | 1/20/2030 | 3.38% Bond | 8.00 | 02/28/2022 | 3/23/2030 | 3.41% Bond | 8.92 | 02/28/2022 | 2/20/2031 | 3.49% Bond | 9.00 | 02/28/2022 | 3/18/2031 | 3.49% Bond | 9.25 | 02/28/2022 | 6/17/2031 | 3.47% Bond | 9.67 | 02/28/2022 | 11/4/2031 | 3.49% Bond | 10.33 | 02/28/2022 | 7/26/2032 | 3.48% Bond | 10.83 | 02/28/2022 | 1/21/2033 | 3.64% Bond | 11.67 | 02/28/2022 | 11/4/2033 | 3.60% Bond | 11.83 | 02/28/2022 | 1/20/2034 | 3.69% Bond | 12.00 | 02/28/2022 | 3/27/2034 | 3.79% Bond | 12.92 | 02/28/2022 | 2/24/2035 | 3.86% Bond | 13.33 | 02/28/2022 | 7/26/2035 | 3.83% Bond | 14.42 | 02/28/2022 | 8/19/2036 | 3.87% Bond | 14.67 | 02/28/2022 | 11/4/2036 | 3.91% Bond | 27.08 | 02/28/2022 | 4/24/2049 | 4.64% Bond | 28.08 | 02/28/2022 | 3/30/2050 | 4.04%

QuantInTheMaking commented 2 years ago

@ralfkonrad

I am getting the same drop using LogCubicDiscount as well, but with an additional hike near the end of the curve (see the graph)

zeroCurve

Change made to the last piece of the code:

# Interpolation
yc_logcubicdiscount = ql.PiecewiseLogCubicDiscount(calc_date, rate_helpers, day_count)
splcd = get_spot_rates(yc_logcubicdiscount, day_count)

yc_linearzero = ql.PiecewiseLinearZero(calc_date, rate_helpers, day_count)
splz = get_spot_rates(yc_linearzero, day_count)

# Plotting
plt.plot(splcd["Maturities"],splcd["Curve"], label="LogCubicDiscount")
plt.plot(splz["Maturities"],splz["Curve"],'--', label="LinearZero")

plt.xlabel("Years", size=12)
plt.ylabel("Zero Rate", size=12)
plt.legend(loc=0)
ralfkonrad commented 2 years ago

But looking at your YTM input data you already have a non-monotonic curve. I guess what you see is an increasing effect of non-monotonic behaviour when you interpolate the YTM curve.

Especially the big hike for the PiecewiseLogCubicDiscount at the end might be explained by it: Somehow you have to fit the jump from ~14Y up at ~27Y and down again at ~28Y.

Type | Tenor | Start Date | Maturity Date | Adjusted YTM -- | -- | -- | -- | -- ... Bond | 14.42 | 02/28/2022 | 8/19/2036 | 3.87% Bond | 14.67 | 02/28/2022 | 11/4/2036 | 3.91% Bond | 27.08 | 02/28/2022 | 4/24/2049 | 4.64% Bond | 28.08 | 02/28/2022 | 3/30/2050 | 4.04%
QuantInTheMaking commented 2 years ago

@ralfkonrad fair observation regarding the PiecewiseLogCubicDiscount. Thanks for the clarification.

What is your opinion regarding the drop at the beginning of the curve?

ralfkonrad commented 2 years ago

Probably the same explanation: As long as the input curve has jumps the interpolation might be tricky and might have unexpected jumps.

Is this real-world data? Keep in mind that the bond YTM is influenced by liquidity, seniority etc. So even bonds for the same company might not be easily comparable.

QuantInTheMaking commented 2 years ago

Yes. This is real word data. Specifically, Saudi Government Sukuks.

We have an in-house model that avoids falling into these fluctuations (see the graph below). Unfortunately, I can't share the code, but I can tell you that it uses Scipy library for interpolation and root-finding in the bootstrapping process.

image

ralfkonrad commented 2 years ago

Okay, I see why you are concerned about the beginning of the curve.

Of course it can still be an error in the QuantLib bootstrapping process but personally I think it is the input data of the bond here. E.g. it is hard to bootstrap when the YTM jumps by 0.59% within 3M. I would expect quiet some jump in the zero curve.

Type | Tenor | Start Date | Maturity Date | Adjusted YTM -- | -- | -- | -- | -- ... Bond | 1.08 | 02/28/2022 | 4/25/2023 | 1.76% Bond | 1.33 | 02/28/2022 | 7/25/2023 | 2.35% ...

You have a comparable situation at the end of your curve. The bond YTM drops by 0.6% from 27Y to 28Y and your and the QL zero curve needs to drop by ~3.5% to reflect the drop.

Type | Tenor | Start Date | Maturity Date | Adjusted YTM -- | -- | -- | -- | -- ... |   |   |   |   Bond | 27.08 | 02/28/2022 | 4/24/2049 | 4.64% Bond | 28.08 | 02/28/2022 | 3/30/2050 | 4.04%
QuantInTheMaking commented 2 years ago

@ralfkonrad I see. Thank you so much for your cooperation and insights. It is highly appreciated.