QuantLib 金融計算——案例之浮息債(掛鈎 LPR)的價格、久期和凸性
概述
作為利率風險系列的第三篇,本文將依據中債登公布的估值公式,介紹掛鈎 LPR 的浮息債的價格、久期和凸性的計算方法,並依托 QuantLib 和 Python 展示相關的編程案例。
中債登的估值公式
貸款市場報價利率(Loan Prime Rate,簡稱 LPR),是由具有代表性的報價行,根據本行對最優質客戶的貸款利率,以公開市場操作利率(主要指中期借貸便利利率)加點形成的方式報價,由中國人民銀行授權全國銀行間同業拆借中心計算並公布的基礎性的貸款參考利率,各金融機構應主要參考 LPR 進行貸款定價。
目前,LPR 包括 1 年期和 5 年期以上兩個品種。每月 20 日(遇節假日順延)上午 9 時 30 分由人民銀行授權全國銀行間同業拆借中心發布。
最近一年來央行一直在大力推行 LPR,不但推出了掛鈎 LPR 的浮息債,而且推出了 LPR 的利率互換和利率期權(未來將專門論述)。
對於掛鈎 LPR 的浮息債,中債登使用如下估值公式:
其中:
- \(PV\):債券全價
- \(r\):當期債券基礎利率
- \(R\):估值日基礎利率
- \(s\):債券招標利差
- \(f\):每年付息次數
- \(y\):點差利率
- \(R + y\):到期利率(YTM)
- \(n\):剩余完整付息周期個數
- \(t\):距離下一付息日的天數占當前付息周期長度的比例
這與國外教科書中的估值公式有很大不同,國外教科書中的公式通常要利用當前的期限結構推算遠期利率,進而得到預期的未來現金流(浮動票息),再對現金流貼現。中債登的公式可以看做是使用了“水平”的期限結構,如果浮息債掛鈎 Shibor3M 或 FR007,也許可以照搬教科書,因為這兩種利率有對應的 IRS 在交易,且流動性較好,理論上可以推算出 Shibor 和 FR 的期限結構(或遠期利率)。
浮息債的久期和凸性
依據中債登的估值公式,浮息債的價格受到兩個可變參數的影響,分別是 \(R\) 和 \(y\)。因此,浮息債分別就 \(R\) 和 \(y\) 衍生出兩套久期和凸性。
利差久期和利差凸性
浮息債價格關於點差利率(\(y\))的一階敏感性叫做“利差久期”,二階敏感性叫做“利差凸性”。由於 \(y\) 只出現在貼現因子中,浮息債的利差久期(凸性)和普通固息債的久期(凸性)別無二致。
- 利差久期:
- 利差凸性:
利率久期和利率凸性
浮息債價格關於估值日基礎利率(\(R\))的一階敏感性叫做“利率久期”,二階敏感性叫做“利率凸性”。由於 \(R\) 同時出現在貼現因子和現金流中,\(R\) 變化的影響會被抵消掉一部分。因此,浮息債的利率久期(凸性)較利差久期(凸性)通常要小很多。
對浮息債而言,利率的市場風險主要體現在點差利率 \(y\) 的變化上。(這一點和信用利差非常相似)
- 利率久期:
記
利率久期等於利差久期減去 \(\Sigma / PV\),可以推測利率久期要比利差久期小很多。
- 利率凸性
根據(3)可以知道
因此
進而
而對於 \(\Sigma\) 來說,
所以
最終
利率凸性等於利差凸性加 \(2\frac{1}{PV}\frac{\mathrm{d}\Sigma}{\mathrm{d}y}\),可以推測利率凸性要比利差凸性小很多。
計算案例
下面將以 200218 為例,計算 2020-09-15 這一天的價格、久期和凸性。
價格與現金流
首先從中國貨幣網查詢債券的基本信息,用以配置 FloatingRateBond
對象。
- 債券起息日:2020-06-09
- 到期兌付日:2025-06-09
- 債券期限:5 年
- 面值(元):100.00
- 計息基准:A/A
- 息票類型:附息式浮動利率
- 付息頻率:季
- 票面利率(%):3.1(當前水平)
- 基准利率(%):3.85(當前水平)
- 基准利差(%):-0.75
- 基准利率名:LPR1Y
- 利率杠桿:1
- 提前確定利率的天數:1(沒有查到該項目,不過此項不影響估值計算)
- 結算方式:T+0(與中債估值的規則保持一致)
import QuantLib as ql
import prettytable as pt
from datetime import date
today = ql.Date(15, ql.September, 2020)
ql.Settings.instance().evaluationDate = today
evalueDate = ql.Settings.instance().evaluationDate
settlementDays = 0
faceAmount = 100.0
effectiveDate = ql.Date(9, ql.June, 2020)
terminationDate = ql.Date(9, ql.June, 2025)
tenor = ql.Period(ql.Quarterly)
calendar = ql.China(ql.China.IB)
convention = ql.Unadjusted
terminationDateConvention = convention
rule = ql.DateGeneration.Backward
endOfMonth = False
schedule = ql.Schedule(
effectiveDate,
terminationDate,
tenor,
calendar,
convention,
terminationDateConvention,
rule,
endOfMonth)
nextLpr = 3.85 / 100.0
nextLprQuote = ql.SimpleQuote(nextLpr)
nextLprHandle = ql.QuoteHandle(nextLprQuote)
fixedLpr = 3.85 / 100.0
需要注意的是,日歷采用中國的銀行間市場,遇到假期不調整。
目前,QuantLib 中沒有掛鈎 LPR 的浮息債的直接實現,但是鑒於中債登估值的公式比較簡單,可以用 QuantLib 中的一些組件“模擬”出中債登的估值方法。
- 首先,要把 LPR1Y “想象”成一種類似 Shibor3M 的短期利率。此時的 \(r\) 就是最新的 LPR1Y 利率;
- 浮動票息由一個水平的期限結構推算出來,對應利率是 \(R\),也就是到期利率和點差利率的差(實際上就等於最新的 LPR1Y 利率);
- 貼現因子也由一個水平的期限結構推算出來,對應利率是 \(R+y\),也就是到期利率。
compounding = ql.Compounded
frequency = ql.Quarterly
accrualDayCounter = ql.ActualActual(ql.ActualActual.Bond, schedule)
cfDayCounter = ql.ActualActual(ql.ActualActual.Bond)
paymentConvention = ql.Unadjusted
fixingDays = 1
gearings = ql.DoubleVector(1, 1.0)
benchmarkSpread = ql.DoubleVector(1, -0.75 / 100.0)
cfLprTermStructure = ql.YieldTermStructureHandle(
ql.FlatForward(
settlementDays,
calendar,
nextLprHandle,
cfDayCounter,
compounding,
frequency))
lprTermStructure = ql.YieldTermStructureHandle(
ql.FlatForward(
settlementDays,
calendar,
nextLprHandle,
accrualDayCounter,
compounding,
frequency))
lpr3m = ql.IborIndex(
'LPR1Y',
ql.Period(3, ql.Months),
settlementDays,
ql.CNYCurrency(),
calendar,
convention,
endOfMonth,
cfDayCounter,
cfLprTermStructure)
lpr3m.addFixing(ql.Date(8, ql.June, 2020), fixedLpr)
lpr3m.addFixing(ql.Date(8, ql.September, 2020), fixedLpr)
bond = ql.FloatingRateBond(
settlementDays,
faceAmount,
schedule,
lpr3m,
accrualDayCounter,
convention,
fixingDays,
gearings,
benchmarkSpread)
bondYield = 3.7179 / 100.0
basisSpread = bondYield - nextLpr
basisSpreadQuote = ql.SimpleQuote(basisSpread)
basisSpreadHandle = ql.QuoteHandle(basisSpreadQuote)
zeroSpreadedTermStructure = ql.YieldTermStructureHandle(
ql.ZeroSpreadedTermStructure(
lprTermStructure,
basisSpreadHandle,
compounding,
frequency,
accrualDayCounter))
engine = ql.DiscountingBondEngine(zeroSpreadedTermStructure)
bond.setPricingEngine(engine)
有三點注意事項:
- 推算票息和貼現因子的期限結構使用了各自的 day counter,原因出在
IborIndex
上,它和前面的Schedule
在有關時間的計算上可能產生不一致(不算嚴重的 bug,算是個 flaw),具體的原因請閱讀以下兩個鏈接的內容(鏈接 1、鏈接 2) - 由於是對存續債券估值,需要為期限結構添加“歷史浮動利率”——歷史上 fixing date 上的 LPR1Y 數據。盡管只有最近一次 fixing 的 LPR1Y 利率會參與估值,但用戶還是要添加更早期 fixing date 的利率,否則會報錯,幸運的是更早期的歷史利率不參與估值,可以隨便用個數來填充。(《案例之普通利率互換分析(1)》也出現了這個情況,可以作為參考閱讀)
- 計算貼現因子用到了
ZeroSpreadedTermStructure
,這里的利差就是點差利率 \(y\)。
打印出債券的現金流。
cfTab = pt.PrettyTable(['Date', 'Amount'])
for c in bond.cashflows():
dt = date(c.date().year(), c.date().month(), c.date().dayOfMonth())
cfTab.add_row([dt, c.amount()])
cfTab.float_format = '.4'
print(cfTab)
'''
+------------+----------+
| Date | Amount |
+------------+----------+
| 2020-09-09 | 0.7750 |
| 2020-12-09 | 0.7750 |
| 2021-03-09 | 0.7750 |
| 2021-06-09 | 0.7750 |
| 2021-09-09 | 0.7750 |
| 2021-12-09 | 0.7750 |
| 2022-03-09 | 0.7750 |
| 2022-06-09 | 0.7750 |
| 2022-09-09 | 0.7750 |
| 2022-12-09 | 0.7750 |
| 2023-03-09 | 0.7750 |
| 2023-06-09 | 0.7750 |
| 2023-09-09 | 0.7750 |
| 2023-12-09 | 0.7750 |
| 2024-03-09 | 0.7750 |
| 2024-06-09 | 0.7750 |
| 2024-09-09 | 0.7750 |
| 2024-12-09 | 0.7750 |
| 2025-03-09 | 0.7750 |
| 2025-06-09 | 0.7750 |
| 2025-06-09 | 100.0000 |
+------------+----------+
'''
如果 day counter 使用不當,現金流可能與 0.7750 存在肉眼難以發覺的細微差距(但對估值依然可以產生可觀的影響),特別是 2023-09-09 這一天,示例詳見鏈接 1。
測試一下有關價格的計算。
cleanPrice = bond.cleanPrice()
dirtyPrice = bond.dirtyPrice()
accruedAmount = bond.accruedAmount()
tab = pt.PrettyTable(['item', 'value'])
tab.add_row(['clean price', cleanPrice])
tab.add_row(['dirty price', dirtyPrice])
tab.add_row(['accrued amount', accruedAmount])
tab.float_format = '.4'
print(tab)
'''
+----------------+---------+
| item | value |
+----------------+---------+
| clean price | 97.3292 |
| dirty price | 97.3803 |
| accrued amount | 0.0511 |
+----------------+---------+
'''
久期與凸性
之前的公式推導顯示,浮息債的利差久期(凸性)就是通常意義上的久期(凸性),而利率久期(凸性)則可以在利差久期(凸性)的基礎上添加一個附加項得到。因此,可以針對 LPR 浮息債創建一個 BondFunctions
的派生類,把利率久期(凸性)的計算放到派生類里面,同時還可以復用 BondFunctions
的函數。
class LprBondFunctions(ql.BondFunctions):
def __init__(self):
ql.BondFunctions.__init__(self)
@staticmethod
def yieldDuration(bond: ql.FloatingRateBond,
bondYield: float,
dayCounter: ql.DayCounter,
compounding,
frequency):
evalueDate = ql.Settings.instance().evaluationDate
notOccurred = [
cf for cf in bond.cashflows() if cf.date() > evalueDate]
dur = ql.BondFunctions.duration(
bond,
bondYield,
dayCounter,
compounding,
frequency,
ql.Duration.Modified)
p = bond.dirtyPrice()
y = ql.InterestRate(
bondYield,
dayCounter,
compounding,
frequency)
f = y.frequency()
sigma = 0.0
# 如果 len(notOccurred) <= 2,這意味着
# 當前處於最后一個付息周期
if len(notOccurred) > 2:
# 跳過第一個和最后一個日期,因為在最后一個日期,
# 本金與票息是兩個獨立的現金流
for i in range(1, len(notOccurred) - 1):
df = y.discountFactor(
evalueDate,
notOccurred[i].date())
sigma += df
dur -= sigma / p / f * 100.0
return dur
@staticmethod
def yieldConvexity(bond: ql.FloatingRateBond,
bondYield: float,
dayCounter: ql.DayCounter,
compounding,
frequency):
evalueDate = ql.Settings.instance().evaluationDate
notOccurred = [
cf for cf in bond.cashflows() if cf.date() > evalueDate]
conv = ql.BondFunctions.convexity(
bond,
bondYield,
dayCounter,
compounding,
frequency)
p = bond.dirtyPrice()
y = ql.InterestRate(
bondYield,
dayCounter,
compounding,
frequency)
f = y.frequency()
dSigma = 0.0
# 如果 len(notOccurred) <= 2,這意味着
# 當前處於最后一個付息周期
if len(notOccurred) > 2:
# 跳過第一個和最后一個日期,因為在最后一個日期,
# 本金與票息是兩個獨立的現金流
for i in range(1, len(notOccurred) - 1):
t = f * dayCounter.yearFraction(
evalueDate,
notOccurred[i].date())
df = y.discountFactor(
evalueDate,
notOccurred[i].date())
dSigma += t * df
dSigma /= 1 + bondYield / f
conv -= 2.0 * dSigma / p / f ** 2 * 100.0
return conv
用解析公式和數值法分別計算久期和凸性,相互驗證。這里依然用到了 Quote
類的奇妙特性。
compTab = pt.PrettyTable()
compTab.add_column(
'項目',
['利差久期', '利差凸性', '利率久期', '利率凸性'])
spreadDuration = ql.BondFunctions.duration(
bond,
bondYield,
accrualDayCounter,
compounding,
frequency,
ql.Duration.Modified)
spreadConvexity = ql.BondFunctions.convexity(
bond,
bondYield,
accrualDayCounter,
compounding,
frequency)
yieldDuration = LprBondFunctions.yieldDuration(
bond,
bondYield,
accrualDayCounter,
compounding,
frequency)
yieldConvexity = LprBondFunctions.yieldConvexity(
bond,
bondYield,
accrualDayCounter,
compounding,
frequency)
compTab.add_column(
'解析結果',
[spreadDuration, spreadConvexity, yieldDuration, yieldConvexity])
bp = 0.01 / 100.0
nextLprQuote.setValue(nextLpr + bp)
dp1 = bond.dirtyPrice()
nextLprQuote.setValue(nextLpr - bp)
dp2 = bond.dirtyPrice()
nextLprQuote.setValue(nextLpr)
yieldDuration = -(dp1 - dp2) / (2.0 * dirtyPrice * bp)
yieldConvexity = (dp1 + dp2 - 2.0 * dirtyPrice) / (dirtyPrice * bp ** 2)
basisSpreadQuote.setValue(basisSpread + bp)
dp1 = bond.dirtyPrice()
basisSpreadQuote.setValue(basisSpread - bp)
dp2 = bond.dirtyPrice()
basisSpreadQuote.setValue(basisSpread)
spreadDuration = -(dp1 - dp2) / (2.0 * dirtyPrice * bp)
spreadConvexity = (dp1 + dp2 - 2.0 * dirtyPrice) / (dirtyPrice * bp ** 2)
compTab.add_column(
'數值結果',
[spreadDuration, spreadConvexity, yieldDuration, yieldConvexity])
compTab.float_format = '.8'
print(compTab)
'''
+----------+-------------+-------------+
| 項目 | 解析結果 | 數值結果 |
+----------+-------------+-------------+
| 利差久期 | 4.37254790 | 4.37254808 |
| 利差凸性 | 21.08466334 | 21.08466386 |
| 利率久期 | 0.17188881 | 0.17188881 |
| 利率凸性 | -0.11051093 | -0.11051092 |
+----------+-------------+-------------+
'''
和中債登的結果比較一下。
項目 | 值 |
---|---|
估價收益率(%) | 3.7179 |
估價利差凸性 | 21.0847 |
估價全價 | 97.3803 |
點差收益率(%) | -0.1321 |
估價利差久期 | 4.3725 |
估價利率久期 | 0.1719 |
估價利差凸性 | 21.0847 |
估價利率凸性 | 空 |
下一步
上述實踐雖然成功,但實屬非常規的做法,正規的做法是在 C++ 源代碼層面上為 FloatingRateBond
、PricingEngine
和 BondFunctions
分別創建派生類用於掛鈎 LPR 浮息債的計算,下一步將嘗試這種做法。
參考文獻
- 《浮動利率債券收益率計算與風險分析》
- 《浮動利率債券久期和凸性的研究》
- 《浮動利率債券的基准利率選擇及定價》
- 《中債價格指標產品久期基本計算方法》
- 《浮動利率債券定價的理論與實踐》