QuantLib 金融計算——案例之 KRD、Fisher-Weil 久期及久期的解釋能力
概述
作為利率風險系列的第四篇,本文將以《Interest Rate Risk Modeling》為藍本,介紹 Fisher-Weil 久期,並探討它與 KRD 的關聯,最后用線性回歸模型簡單研究一下久期的解釋能力。
有關 KRD 的高級內容請見《《Interest Rate Risk Modeling》閱讀筆記——第九章》。
Fisher-Weil 久期的基本概念
上一篇《案例之固息債的價格、久期、凸性和 BPS》中出現的久期和凸性均是基於到期利率(YTM)的風險度量指標,也是最常見的一類債券參數。與此對應,存在着另外一套基於即期利率的風險度量體系,即 Fisher-Weil 久期和凸性。
和麥考利久期的概念一致,Fisher-Weil 久期也是各個現金流的期限關於貼現后現金流的加權平均。不同的是,Fisher-Weil 久期在計算貼現因子時用的是即期利率,而麥考利久期用的是到期利率。此外,Fisher-Weil 久期通常使用連續復利(在連續復利的情況下麥考利久期和修正久期相等),而大多數利率模型也是使用的連續復利。
計算案例
為兼顧歷史數據長度以及流動性,下面以 190210 為例,計算 2020-11-10 這一天的 Fisher-Weil 久期和 KRD,隨后會用最近一年的利率和價格數據做久期的實證分析,數據均來自上清所。
首先從中國貨幣網查詢債券的基本信息,用以配置 FixedRateBond
對象。
- 債券起息日:2019-05-21
- 到期兌付日:2029-05-21
- 債券期限:10 年
- 面值(元):100.00
- 計息基准:A/A
- 息票類型:附息式固定利率
- 付息頻率:年
- 票面利率(%):3.65
- 結算方式:T+1
計算 KRD 的過程基本上照搬上一篇文章的代碼,個別細節略有不同。
import QuantLib as ql
import prettytable as pt
import seaborn as sns
import numpy as np
import pandas as pd
import statsmodels.api as sm
today = ql.Date(10, ql.November, 2020)
ql.Settings.instance().evaluationDate = today
effectiveDate = ql.Date(21, ql.May, 2019)
terminationDate = ql.Date(21, ql.May, 2029)
tenor = ql.Period(1, ql.Years)
calendar = ql.China(ql.China.IB)
convention = ql.Unadjusted
terminationDateConvention = convention
rule = ql.DateGeneration.Backward
endOfMonth = False
settlementDays = 1
faceAmount = 100.0
schedule = ql.Schedule(
effectiveDate,
terminationDate,
tenor,
calendar,
convention,
terminationDateConvention,
rule,
endOfMonth)
scheduleEx = ql.Schedule(
effectiveDate,
ql.Date(21, ql.May, 2041),
tenor,
calendar,
convention,
terminationDateConvention,
rule,
endOfMonth)
coupons = ql.DoubleVector(1)
coupons[0] = 3.65 / 100.0
accrualDayCounter = ql.ActualActual(
ql.ActualActual.Bond, scheduleEx)
paymentConvention = ql.Unadjusted
bond = ql.FixedRateBond(
settlementDays,
faceAmount,
schedule,
coupons,
accrualDayCounter,
paymentConvention)
spotRates = np.array(
[1.0,
1.71078231001656, 2.56940621917972, 2.83503053129122, 3.08812284213447,
3.27817582743435, 3.36929632559628, 3.38215484755107, 3.48805389778613,
3.54897231967215, 3.70182895980812, 3.70828526340311, 3.65884055155817,
3.96859895108321, 4.00107855792347]) / 100.0
tenors = ql.DateVector()
tenors.append(today)
tenors.append(today + ql.Period(1, ql.Days))
tenors.append(today + ql.Period(6, ql.Months))
tenors.append(today + ql.Period(1, ql.Years))
tenors.append(today + ql.Period(2, ql.Years))
tenors.append(today + ql.Period(3, ql.Years))
tenors.append(today + ql.Period(4, ql.Years))
tenors.append(today + ql.Period(5, ql.Years))
tenors.append(today + ql.Period(6, ql.Years))
tenors.append(today + ql.Period(7, ql.Years))
tenors.append(today + ql.Period(8, ql.Years))
tenors.append(today + ql.Period(9, ql.Years))
tenors.append(today + ql.Period(10, ql.Years))
tenors.append(today + ql.Period(15, ql.Years))
tenors.append(today + ql.Period(20, ql.Years))
compounding = ql.Continuous
frequency = ql.Annual
spotCurve = ql.YieldTermStructureHandle(
ql.LogLinearZeroCurve(
tenors,
spotRates,
accrualDayCounter,
calendar,
ql.LogLinear(),
compounding))
關於
scheduleEx
,請看這里。
spotRates
里面是 2020-11-10 這天的即期利率(根據上清所的數據轉換成連續復利),用於構造即期期限結構。spotRates
中的第一個元素通常用於為插值計算提供邊界點,表示今天的利率值。這里采用 LogLinear
插值,所以可以用 1.0
,若用 Linear
插值,也可以用 0.0
。tenors
中的第一個元素通常用於為期限結構提供基准日期,一般來說就是估值日期當天。由於后面提供了隔夜利率,spotRates[0]
這個數其實不參與計算,但必須有,以便和 tenors
對齊。
下面是計算 KRD 的過程。(有點兒繁瑣,感興趣的讀者可以嘗試改寫成一個簡單的 for
循環)
initValue = 0.0
rate1d = ql.SimpleQuote(initValue)
rate6m = ql.SimpleQuote(initValue)
rate1y = ql.SimpleQuote(initValue)
rate2y = ql.SimpleQuote(initValue)
rate3y = ql.SimpleQuote(initValue)
rate4y = ql.SimpleQuote(initValue)
rate5y = ql.SimpleQuote(initValue)
rate6y = ql.SimpleQuote(initValue)
rate7y = ql.SimpleQuote(initValue)
rate8y = ql.SimpleQuote(initValue)
rate9y = ql.SimpleQuote(initValue)
rate10y = ql.SimpleQuote(initValue)
rate15y = ql.SimpleQuote(initValue)
rate20y = ql.SimpleQuote(initValue)
rate1dHandle = ql.QuoteHandle(rate1d)
rate6mHandle = ql.QuoteHandle(rate6m)
rate1yHandle = ql.QuoteHandle(rate1y)
rate2yHandle = ql.QuoteHandle(rate2y)
rate3yHandle = ql.QuoteHandle(rate3y)
rate4yHandle = ql.QuoteHandle(rate4y)
rate5yHandle = ql.QuoteHandle(rate5y)
rate6yHandle = ql.QuoteHandle(rate6y)
rate7yHandle = ql.QuoteHandle(rate7y)
rate8yHandle = ql.QuoteHandle(rate8y)
rate9yHandle = ql.QuoteHandle(rate9y)
rate10yHandle = ql.QuoteHandle(rate10y)
rate15yHandle = ql.QuoteHandle(rate15y)
rate20yHandle = ql.QuoteHandle(rate20y)
spreads = ql.QuoteHandleVector()
spreads.append(rate1dHandle)
spreads.append(rate6mHandle)
spreads.append(rate1yHandle)
spreads.append(rate2yHandle)
spreads.append(rate3yHandle)
spreads.append(rate4yHandle)
spreads.append(rate5yHandle)
spreads.append(rate6yHandle)
spreads.append(rate7yHandle)
spreads.append(rate8yHandle)
spreads.append(rate9yHandle)
spreads.append(rate10yHandle)
spreads.append(rate15yHandle)
spreads.append(rate20yHandle)
termStructure = ql.YieldTermStructureHandle(
ql.SpreadedLinearZeroInterpolatedTermStructure(
spotCurve,
spreads,
tenors[1:],
compounding,
frequency,
accrualDayCounter))
engine = ql.DiscountingBondEngine(termStructure)
bond.setPricingEngine(engine)
dirtyPrice = bond.dirtyPrice()
tab = pt.PrettyTable(['item', 'value'])
# calculate KRDs
bp = 0.01 / 100.0
krdSum = 0.0
krds = []
times = []
# 1d KRD
rate1d.setValue(bp)
dirtyPrice1 = bond.dirtyPrice()
rate1d.setValue(-bp)
dirtyPrice2 = bond.dirtyPrice()
rate1d.setValue(initValue)
krd1d = -(dirtyPrice1 - dirtyPrice2) / (2.0 * bp * dirtyPrice)
krdSum += krd1d
krds.append(krd1d)
times.append(0.00274) # 1.0 / 365
tab.add_row(['krd1d', krd1d])
# 6m KRD
rate6m.setValue(bp)
dirtyPrice1 = bond.dirtyPrice()
rate6m.setValue(-bp)
dirtyPrice2 = bond.dirtyPrice()
rate6m.setValue(initValue)
krd6m = -(dirtyPrice1 - dirtyPrice2) / (2.0 * bp * dirtyPrice)
krdSum += krd6m
krds.append(krd6m)
times.append(0.5)
tab.add_row(['krd6m', krd6m])
# 1y KRD
rate1y.setValue(bp)
dirtyPrice1 = bond.dirtyPrice()
rate1y.setValue(-bp)
dirtyPrice2 = bond.dirtyPrice()
rate1y.setValue(initValue)
krd1y = -(dirtyPrice1 - dirtyPrice2) / (2.0 * bp * dirtyPrice)
krdSum += krd1y
krds.append(krd1y)
times.append(1.0)
tab.add_row(['krd1y', krd1y])
# 2y KRD
rate2y.setValue(bp)
dirtyPrice1 = bond.dirtyPrice()
rate2y.setValue(-bp)
dirtyPrice2 = bond.dirtyPrice()
rate2y.setValue(initValue)
krd2y = -(dirtyPrice1 - dirtyPrice2) / (2.0 * bp * dirtyPrice)
krdSum += krd2y
krds.append(krd2y)
times.append(2.0)
tab.add_row(['krd2y', krd2y])
# 3y KRD
rate3y.setValue(bp)
dirtyPrice1 = bond.dirtyPrice()
rate3y.setValue(-bp)
dirtyPrice2 = bond.dirtyPrice()
rate3y.setValue(initValue)
krd3y = -(dirtyPrice1 - dirtyPrice2) / (2.0 * bp * dirtyPrice)
krdSum += krd3y
krds.append(krd3y)
times.append(3.0)
tab.add_row(['krd3y', krd3y])
# 4y KRD
rate4y.setValue(bp)
dirtyPrice1 = bond.dirtyPrice()
rate4y.setValue(-bp)
dirtyPrice2 = bond.dirtyPrice()
rate4y.setValue(initValue)
krd4y = -(dirtyPrice1 - dirtyPrice2) / (2.0 * bp * dirtyPrice)
krdSum += krd4y
krds.append(krd4y)
times.append(4.0)
tab.add_row(['krd4y', krd4y])
# 5y KRD
rate5y.setValue(bp)
dirtyPrice1 = bond.dirtyPrice()
rate5y.setValue(-bp)
dirtyPrice2 = bond.dirtyPrice()
rate5y.setValue(initValue)
krd5y = -(dirtyPrice1 - dirtyPrice2) / (2.0 * bp * dirtyPrice)
krdSum += krd5y
krds.append(krd5y)
times.append(5.0)
tab.add_row(['krd5y', krd5y])
# 6y KRD
rate6y.setValue(bp)
dirtyPrice1 = bond.dirtyPrice()
rate6y.setValue(-bp)
dirtyPrice2 = bond.dirtyPrice()
rate6y.setValue(initValue)
krd6y = -(dirtyPrice1 - dirtyPrice2) / (2.0 * bp * dirtyPrice)
krdSum += krd6y
krds.append(krd6y)
times.append(6.0)
tab.add_row(['krd6y', krd6y])
# 7y KRD
rate7y.setValue(bp)
dirtyPrice1 = bond.dirtyPrice()
rate7y.setValue(-bp)
dirtyPrice2 = bond.dirtyPrice()
rate7y.setValue(initValue)
krd7y = -(dirtyPrice1 - dirtyPrice2) / (2.0 * bp * dirtyPrice)
krdSum += krd7y
krds.append(krd7y)
times.append(7.0)
tab.add_row(['krd7y', krd7y])
# 8y KRD
rate8y.setValue(bp)
dirtyPrice1 = bond.dirtyPrice()
rate8y.setValue(-bp)
dirtyPrice2 = bond.dirtyPrice()
rate8y.setValue(initValue)
krd8y = -(dirtyPrice1 - dirtyPrice2) / (2.0 * bp * dirtyPrice)
krdSum += krd8y
krds.append(krd8y)
times.append(8.0)
tab.add_row(['krd8y', krd8y])
# 9y KRD
rate9y.setValue(bp)
dirtyPrice1 = bond.dirtyPrice()
rate9y.setValue(-bp)
dirtyPrice2 = bond.dirtyPrice()
rate9y.setValue(initValue)
krd9y = -(dirtyPrice1 - dirtyPrice2) / (2.0 * bp * dirtyPrice)
krdSum += krd9y
krds.append(krd9y)
times.append(9.0)
tab.add_row(['krd9y', krd9y])
# 10y KRD
rate10y.setValue(bp)
dirtyPrice1 = bond.dirtyPrice()
rate10y.setValue(-bp)
dirtyPrice2 = bond.dirtyPrice()
rate10y.setValue(initValue)
krd10y = -(dirtyPrice1 - dirtyPrice2) / (2.0 * bp * dirtyPrice)
krdSum += krd10y
krds.append(krd10y)
times.append(10.0)
tab.add_row(['krd10y', krd10y])
# 15y KRD
rate15y.setValue(bp)
dirtyPrice1 = bond.dirtyPrice()
rate15y.setValue(-bp)
dirtyPrice2 = bond.dirtyPrice()
rate15y.setValue(initValue)
krd15y = -(dirtyPrice1 - dirtyPrice2) / (2.0 * bp * dirtyPrice)
krdSum += krd15y
krds.append(krd15y)
times.append(15.0)
tab.add_row(['krd15y', krd15y])
# 20y KRD
rate20y.setValue(bp)
dirtyPrice1 = bond.dirtyPrice()
rate20y.setValue(-bp)
dirtyPrice2 = bond.dirtyPrice()
rate20y.setValue(initValue)
krd20y = -(dirtyPrice1 - dirtyPrice2) / (2.0 * bp * dirtyPrice)
krdSum += krd20y
krds.append(krd20y)
times.append(20.0)
tab.add_row(['krd20y', krd20y])
tab.add_row(['krdSum', krdSum])
def FisherWeilDuration(bond: ql.FixedRateBond,
term_structure: ql.YieldTermStructureHandle,
settlement: ql.Date = ql.Date()):
if settlement == ql.Date():
settlement = bond.settlementDate()
fwd = 0.0
p = bond.dirtyPrice()
dc = bond.dayCounter()
for cf in bond.cashflows():
if cf.date() > settlement:
df = term_structure.discount(cf.date())
t = dc.yearFraction(settlement, cf.date())
fwd += t * df * cf.amount()
return fwd / p
fwd = FisherWeilDuration(bond, spotCurve)
tab.add_row(['fwd', fwd])
tab.add_row(['dirty', dirtyPrice])
tab.add_row(['mkt', 101.0751])
tab.float_format = '.8'
print(tab)
'''
+--------+--------------+
| item | value |
+--------+--------------+
| krd1d | -0.00273973 |
| krd6m | 0.01761345 |
| krd1y | 0.02607589 |
| krd2y | 0.06751827 |
| krd3y | 0.09790298 |
| krd4y | 0.12608806 |
| krd5y | 0.15196510 |
| krd6y | 0.17540198 |
| krd7y | 0.19649419 |
| krd8y | 3.12947679 |
| krd9y | 3.35232738 |
| krd10y | -0.00000000 |
| krd15y | -0.00000000 |
| krd20y | -0.00000000 |
| krdSum | 7.33812436 |
| fwd | 7.33778022 |
| dirty | 101.11162007 |
| mkt | 101.07510000 |
+--------+--------------+
'''
sns.lineplot(
x=times, y=krds, marker='o')
債券最大一筆現金流的期限落在 8~9 年之間,所 8 和 9 年期利率的敏感性最大,10 年以上的利率則完全沒有影響。隔夜利率有着極其微弱的負久期比較讓人意外。
FisherWeilDuration
函數用來計算 Fisher-Weil 久期,其邏輯完全參照 BondFunctions::duration
方法。Fisher-Weil 久期隱含地假設了曲線水平移動,因此 KRD 的和應該和 Fisher-Weil 久期極為接近,計算結果也證實了這一點。
可以看到,根據期限結構計算出的“理論價格”和實際的市場價格有一定的出入,即期期限結構通常由交易數據擬合得到,誤差在所難免。
久期解釋能力的實證
之前說到了 Fisher-Weil 久期隱含地假設了曲線水平移動,下面簡單研究一下曲線水平移動能在多大程度上解釋債券價格的變化。
選取最近一年的即期利率(轉換成連續復利),關鍵期限分別是隔夜、半年、1 至 10 年、15 年和 20 年,計算每天的利率變化,並把關鍵期限利率變化的平均值視作曲線的水平移動量。再根據全價計算出債券每天的回報率,兩者建立線性回歸模型,預計得到的回歸系數應該和 Fisher-Weil 久期大體相等。
# Empirical Test
ratesChg = pd.read_csv(
'rates_chg.csv', parse_dates=True, index_col='date')
returns = pd.read_csv(
'returns.csv', parse_dates=True, index_col='date')
levelChgs = pd.DataFrame(
ratesChg.values.mean(1), index=ratesChg.index, columns=['levelChgs'])
ols = sm.OLS(endog=returns, exog=sm.add_constant(levelChgs))
olsEst = ols.fit()
print(olsEst.summary())
sns.regplot(
x=levelChgs, y=returns)
'''
OLS Regression Results
==============================================================================
Dep. Variable: return R-squared: 0.532
Model: OLS Adj. R-squared: 0.530
Method: Least Squares F-statistic: 279.9
Date: Sat, 14 Nov 2020 Prob (F-statistic): 1.79e-42
Time: 23:32:01 Log-Likelihood: 83.522
No. Observations: 248 AIC: -163.0
Df Residuals: 246 BIC: -156.0
Df Model: 1
Covariance Type: nonrobust
==============================================================================
coef std err t P>|t| [0.025 0.975]
------------------------------------------------------------------------------
const 0.0121 0.011 1.096 0.274 -0.010 0.034
levelChgs -7.3574 0.440 -16.731 0.000 -8.224 -6.491
==============================================================================
Omnibus: 28.630 Durbin-Watson: 2.172
Prob(Omnibus): 0.000 Jarque-Bera (JB): 155.775
Skew: -0.048 Prob(JB): 1.49e-34
Kurtosis: 6.881 Cond. No. 39.9
==============================================================================
Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.
'''
散點圖非常完美。
最終的回歸系數確實和 Fisher-Weil 久期大體相等,但 \(R^2\) 約等於 50%,也就是說當前的“水平因子”(或者說久期)僅能解釋一半的回報率變化。不可解釋的本分可能來自於
- 模型價格與市場價格之間的差異(市場有效性不足)
- 水平因子的二階敏感性,以及
- 其他曲線形態因子的一(二)階敏感性。
其他曲線形態因子的一階敏感性則是下一篇的主題——主成分久期(PCD)。