Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

IndexFixedRateBond computes Price and YTM as non-inflated #349

Open
ArnaudToma opened this issue Aug 27, 2024 · 5 comments · Fixed by #354
Open

IndexFixedRateBond computes Price and YTM as non-inflated #349

ArnaudToma opened this issue Aug 27, 2024 · 5 comments · Fixed by #354
Labels
bug Something isn't working securities

Comments

@ArnaudToma
Copy link

In the following example I've priced two bonds, a TIPS 07/28 and it's fake nominal equivalent.
The inflation fixings are hard coded for convenience.

treas_0728 = rl.FixedRateBond(
        effective=datetime(2018,7,31),
        termination=datetime(2028,7,15),
        spec="us_gb_tsy",
        fixed_rate=0.75,
        notional=-100e6,  
        curves=sofr,
        )

tii_0728 = rl.IndexFixedRateBond(
        effective=datetime(2018,7,31),
        termination=datetime(2028,7,15),
        spec="us_gb_tsy",
        fixed_rate=0.75,
        notional=-100e6,
        curves=sofr,
        index_lag=3,
        index_method="monthly",
        index_base=251.01658,
        index_fixings=[100,100,100,100,100,100,100,100,100,100,100,100,  #for passed cashflows
                       314.31,319.276,321.919,326.769,329.082,334.13,336.585,341.329,341.329] #for future cashflows
    )

treas_0728.npv(curves=sofr)  # > 90,403,537.579279
tii_0728.npv(curves=sofr)     # > 122,777,670.728857
treas_0728.ytm(price=100, settlement=datetime(2024, 8, 28), dirty=True) #0.7734324189287921
tii_0728.ytm(price=100, settlement=datetime(2024, 8, 28), dirty=True)      #0.7734324189287921

The future cashflows of the TIPS are well inflated and the NPV adjusts accordingly but the YTM should also be way higher if it was the case.
Probably the price() and ytm() function are non-inflated by default.

Also when trying to analize the inflation risk of the TIPS, i can't get the solver to work as it fails with the following.
I tried passing it the already solved sofr curve and its solver, with ZCIS rates computed from my estimated future fixings.

#helper to convert pd.timestamp to datetime
def _makedt(dt):
    return datetime.combine(dt, datetime.min.time())

# hard coded dataframe for reproductability
# the dataframe is used to provide cpi fixings and equivalent ZCIS ratefor the solver, computed as :
# rate = [ ( CPI_end / CPI_start ) ^(1/year_fraction) -1 ] *100 
# CPI_start is today's inflation fixing value 314.15790

inflation_zcis= {'CPI_fixing': {pd.Timestamp('2025-01-15 00:00:00'): 314.31,
  pd.Timestamp('2025-07-15 00:00:00'): 319.276,
  pd.Timestamp('2026-01-15 00:00:00'): 321.919,
  pd.Timestamp('2026-07-15 00:00:00'): 326.769,
  pd.Timestamp('2027-01-15 00:00:00'): 329.082,
  pd.Timestamp('2027-07-15 00:00:00'): 334.13,
  pd.Timestamp('2028-01-15 00:00:00'): 336.585,
  pd.Timestamp('2028-07-15 00:00:00'): 341.329},
 'Years': {pd.Timestamp('2025-01-15 00:00:00'): 0.3887748117727584,
  pd.Timestamp('2025-07-15 00:00:00'): 0.8843258042436687,
  pd.Timestamp('2026-01-15 00:00:00'): 1.3880903490759753,
  pd.Timestamp('2026-07-15 00:00:00'): 1.8836413415468858,
  pd.Timestamp('2027-01-15 00:00:00'): 2.3874058863791925,
  pd.Timestamp('2027-07-15 00:00:00'): 2.8829568788501025,
  pd.Timestamp('2028-01-15 00:00:00'): 3.3867214236824092,
  pd.Timestamp('2028-07-15 00:00:00'): 3.885010266940452},
 'Annualized_Rate': {pd.Timestamp('2025-01-15 00:00:00'): 0.12458001529613849,
  pd.Timestamp('2025-07-15 00:00:00'): 1.844203384706522,
  pd.Timestamp('2026-01-15 00:00:00'): 1.7736615976980064,
  pd.Timestamp('2026-07-15 00:00:00'): 2.1114334374509047,
  pd.Timestamp('2027-01-15 00:00:00'): 1.9630214396074486,
  pd.Timestamp('2027-07-15 00:00:00'): 2.1609054022704965,
  pd.Timestamp('2028-01-15 00:00:00'): 2.056908349482489,
  pd.Timestamp('2028-07-15 00:00:00'): 2.1581152924567304},
 'df': {pd.Timestamp('2025-01-15 00:00:00'): 0.9995160828481435,
  pd.Timestamp('2025-07-15 00:00:00'): 0.9839696688758315,
  pd.Timestamp('2026-01-15 00:00:00'): 0.975891140317906,
  pd.Timestamp('2026-07-15 00:00:00'): 0.9614066817843797,
  pd.Timestamp('2027-01-15 00:00:00'): 0.9546492971356683,
  pd.Timestamp('2027-07-15 00:00:00'): 0.9402265585251249,
  pd.Timestamp('2028-01-15 00:00:00'): 0.9333686884442266,
  pd.Timestamp('2028-07-15 00:00:00'): 0.9203961573730916}}

df = pd.DataFrame(inflation_zcis)


cpi_curve = rl.IndexCurve(
    id="cpi_us",
    index_base=100,
    index_lag=3,
    nodes={
        datetime.today(): 1.0,
        datetime(2025,1,15):1.0,
        datetime(2025,7,15):1.0,
        datetime(2026,1,15):1.0,
        datetime(2026,7,15):1.0,
        datetime(2027,1,15):1.0,
        datetime(2027,7,15):1.0,
        datetime(2028,1,15):1.0,
        datetime(2028,7,15):1.0
    },
)

#sofr curve is the same as the one used for the bonds pricing above and gives same pricing as bloomberg.
cpi_kws = dict(
    effective=datetime.today(),
    frequency="A",
    convention="1+",
    calendar="nyc",
    leg2_index_method="monthly",
    currency="usd",
    curves=["sofr", "sofr", "cpi_us", "sofr"]
)

solver = rl.Solver(
    pre_solvers=[solv_sofr],
    curves=[cpi_curve],
    instruments=[rl.ZCIS(termination=_makedt(_), **cpi_kws) for _ in df.index],
    s=[_ for _ in df["Annualized_Rate"]],
    instrument_labels=["ZCi"+str(_)[:7] for _ in df.index],
    id="cpi_rates",
)
# > FAILURE: `max_iter` breached after 100 iterations (levenberg_marquardt), `f_val`: nan, `time`: 0.2220s

Do you have guidelines on what can cause the solver to fail ?
Let me know if you need more information to reproduce this.

Thank for your time,

@attack68
Copy link
Owner

attack68 commented Aug 27, 2024

Two things I have observed occasionally with Solvers:

  1. Initially guessing DFs at 1.0 for every node (i.e rates at 0%) means the first iteration might overshoot and determine a negative discount factor. The fact your f_val is nan suggests it has tried to determine the logarithm of a negative value, fail and never recover. You can try guessing initial DFs on your CPI curve closer to target if you can estimate and see if it helps.

  2. Strange oscillations close to the solution. If the tolerances are too small (with too many parameters) sometimes the machine precision gets into a loop and wont exit, reporting a failure when the solution is practically found. The solution here is to increase the tolerance as keywords to the Solver (see the API docs)

For your point on pricing IndexFixedRateBonds I will take a look. I'd like to find a basis for comparison first, so will experiment a little with my domestic bond market: will revert when I know more.
image

image

@attack68
Copy link
Owner

OK, so coming back to the pricing issue. For the inflation linked bonds I have seen, pricing is done in real space and the indexed up prices are determined in post.

As an example, the specification for the EC0013374 Swedish inflation linked bond posted above is:

bond = IndexFixedRateBond(  # EC0013374
    effective=dt(1997, 12, 1),
    termination=dt(2028, 12, 1),
    index_method="daily",
    index_fixings=NoInput(0),
    index_lag=3,
    index_base=245.1,
    spec="se_gb",
    fixed_rate=3.5
)

If you price this with a yield of 1.787 you get a clean-unindexed price of

bond.price(ytm=1.787, settlement=dt(2024, 8, 28))
# 106.95823136

As the Bloomberg buy ticket shows the index value at settlement is 415.892 and the ratio is 1.696825785.
This gives an indexed clean price of 181.48948489 (106.95.. * 1.69..).

The analogue bond ytm and price functions are designed to work without Curves (this is by design).
Now, in order to derive either indexed-dirty or indexed-clean prices knowledge of either the index-ratio or the reference index value at settlement is required. It would require an enhancement in rateslib to accept this as an input to the ytm or price function to derive the result (but again I would not want to do this with Curves). Although with knowledge of that value one can easily calculate the indexed up values of the bond prices with multiplication.

The digital method: the rate function is already capable of calculating a metric in {"clean_price", "dirty_price", "ytm", "index_clean_price", "index_dirty_price"} because the data to do so is readily available from the discount curve and from the CPI curve.

Index products are not well documented with many examples so I appreciate the issue - the working case is useful.

@attack68
Copy link
Owner

attack68 commented Aug 27, 2024

Also when you construct the IndexCurve the parameter index_base is not "solved". You need to input the known index_base for the initial node date of the Curve. This is also poorly documented. Your comment says it should be 314.15790? This is probably the biggest cause of Curve solver error.

@ArnaudToma
Copy link
Author

Also when you construct the IndexCurve the parameter index_base is not "solved". You need to input the known index_base for the initial node date of the Curve. This is also poorly documented. Your comment says it should be 314.15790? This is probably the biggest cause of Curve solver error.

For this particular issue i re-ran it with today's value for index_base and i've tried to help the solver by putting DFs that would be CPI_today/CPI_future but it still yields the same failure.

I'll update once this is resolved.
Also I'll go forward with the digital method once i can get the solver to work for Inflation since it'll be need for that !

@attack68
Copy link
Owner

OK, I looked more closely.

The Solver returns nan becuase the index_base on the calibrating ZCIS does not forecast. It is in the past (1st of month) so returns zero. The issue is not iteration overshoot.

If you add index_base to kwargs then it works:

cpi_kws = dict(
    effective=datetime.today(),
    frequency="A",
    convention="1+",
    calendar="nyc",
    leg2_index_method="monthly",
    leg2_index_base=314.00     #  <---- ADD
    currency="usd",
    curves=["sofr", "sofr", "cpi_us", "sofr"]
)

The new version will now report this error:
Screenshot 2024-08-27 at 20 14 21

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working securities
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants