I am having problems reconciling the yield to maturity calculation for a stream of cashflows.
In the code, I take vectors of dates and amounts to create a BondLeg, to which I assign a dirty_value.
I use ql.Cashflows.yieldRate to calculate an Annually Compounding and Continuous Compounding Yield to Maturity.
I generate the yearFraction for the dates using the same daycount basis used to calculate the yields.
I then use a PYOMO Non Linear Optimization Solver (ipopt) to solve for the yield to maturities using the yearfractions, cashflow amounts and dirty_value.
The PYOMO annually compounding yield is within 1bp of the quantlib yield. However, the quantlib continuously compounded yield is 13bp higher than the PYOMO yield.
Code from my Jupyter Notebook is below:
Imports
import numpy as np
import QuantLib as ql
import pyomo.environ as pyo
from pyomo.opt import SolverFactory
ql.Settings.instance().evaluationDate = ql.Date(30,6,2023)
Cashflows and Dirty Value
dates = [ql.Date(14,11,2023),
ql.Date(14,11,2024),
ql.Date(14,11,2025),
ql.Date(16,11,2026),
ql.Date(15,11,2027),
ql.Date(14,11,2028),
ql.Date(14,11,2029),
ql.Date(14,11,2030),
ql.Date(14,11,2031),
ql.Date(15,11,2032)]
amounts = [84070,
84070,
84070,
84537.05555555558,
65131.50532953604,
65131.50532953604,
65312.930135468014,
65312.930135468014,
65312.930135468014,
1065494.3549413998]
dirty_val = 1050876.388888889
Quantlib Code
BondLeg = ql.Leg([ql.SimpleCashFlow(amt, dt) for dt,amt in zip(dates,amounts)])
ql_ra = ql.CashFlows.yieldRate(BondLeg, dirty_val, ql.Actual365Fixed(), ql.Compounded, ql.Annual, True)
ql_rc = ql.CashFlows.yieldRate(BondLeg, dirty_val, ql.Actual365Fixed(), ql.Compounded, ql.Continuous, True)
Use Quantlib to generate YearFractions
year_fracs = [ql.Actual365Fixed().yearFraction(ql.Date(30,6,2023),dt) for dt in dates]
periods = np.array([frac for frac in year_fracs])
p_int = np.int_(periods)
p_frac = periods - p_int
amount_vec = np.array(amounts)
Calculate using PYOMO Solver
Annually Compounding Yield
model_ra = pyo.ConcreteModel()
# Decision Variables
model_ra.ra = pyo.Var(domain = pyo.NonNegativeReals, initialize=0.05)
ra = model_ra.ra
# Objective Function
def ra_objective_rule(model_ra):
return (amount_vec.dot(1/(((1+ra)**p_int)* (1+(ra*p_frac)))) - dirty_val)**2
model_ra.objf = pyo.Objective(rule=ra_objective_rule, sense=pyo.minimize)
# Constraints
Solver = SolverFactory('ipopt')
results_ra = Solver.solve(model_ra)
print(results_ra)
print(f'Objective Function = {model_ra.objf()}')
print(f'ra = {ra()}')
Continuously Compounding Yield
model_rc = pyo.ConcreteModel()
# Decision Variables
model_rc.rc = pyo.Var(domain = pyo.NonNegativeReals, initialize=0.05)
rc = model_rc.rc
# Objective Function
def rc_objective_rule(model_rc):
return (amount_vec.dot(np.array([pyo.exp(-p*rc) for p in periods])) - dirty_val)**2
model_rc.objf = pyo.Objective(rule=rc_objective_rule, sense=pyo.minimize)
# Constraints
Solver = SolverFactory('ipopt')
results_rc = Solver.solve(model_rc)
print(results_rc)
print(f'Objective Function = {model_rc.objf()}')
print(f'rc = {rc()}')
Comparison of Results
print(f'QuantLib - Annually Compounded Rate : {ql_ra:.4%}')
print(f'QuantLib - Continuously Compounded Rate : {ql_rc:.4%}')
print(f'PYOMO - Annually Compounded Rate : {ra.value:.4%}')
print(f'PYOMO - Continuously Compounded Rate : {rc.value:.4%}')
QuantLib - Annually Compounded Rate : 7.3623% QuantLib - Continuously Compounded Rate : 7.2315%
PYOMO - Annually Compounded Rate : 7.3526% PYOMO - Continuously Compounded Rate : 7.1039%
[Copying the answer from the QuantLib mailing list in case someone lands here from searching]
The call in the code above,
should instead be:
that is,
ql.Continuousis not a frequency to associate toql.Compoundedbut a compounding method in its own right. The second call gives the result you expect.Unfortunately, SWIG exports C++ enums as numeric constants with no particular type, so Python can't warn of the mismatch. The first call was interpreting the numeric value of
ql.Continuous, 2, as a semiannual compounding frequency.