天天看點

QuantLib 金融計算——案例之 KRD、Fisher-Weil 久期及久期的解釋能力

目錄

  • QuantLib 金融計算——案例之 KRD、Fisher-Weil 久期及久期的解釋能力
    • 概述
    • Fisher-Weil 久期的基本概念
    • 計算案例
      • 久期解釋能力的實證
    • 擴充閱讀

作為利率風險系列的第四篇,本文将以《Interest Rate Risk Modeling》為藍本,介紹 Fisher-Weil 久期,并探讨它與 KRD 的關聯,最後用線性回歸模型簡單研究一下久期的解釋能力。

QuantLib 金融計算——案例之 KRD、Fisher-Weil 久期及久期的解釋能力

有關 KRD 的進階内容請見《《Interest Rate Risk Modeling》閱讀筆記——第九章》。

上一篇《案例之固息債的價格、久期、凸性和 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')
           
QuantLib 金融計算——案例之 KRD、Fisher-Weil 久期及久期的解釋能力

債券最大一筆現金流的期限落在 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.
'''
           
QuantLib 金融計算——案例之 KRD、Fisher-Weil 久期及久期的解釋能力

散點圖非常完美。

最終的回歸系數确實和 Fisher-Weil 久期大體相等,但 \(R^2\) 約等于 50%,也就是說目前的“水準因子”(或者說久期)僅能解釋一半的回報率變化。不可解釋的本分可能來自于

  • 模型價格與市場價格之間的差異(市場有效性不足)
  • 水準因子的二階敏感性,以及
  • 其他曲線形态因子的一(二)階敏感性。

其他曲線形态因子的一階敏感性則是下一篇的主題——主成分久期(PCD)。

《QuantLib 金融計算》系列合集

★ 持續學習 ★ 堅持創作 ★

繼續閱讀