packages feed

hasquant-0.8.0.0: test/hspec/QuantLib/Spec/TermStructure/Inflation.hs

-- Interpolated zero inflation curves and construction-time price seasonality. Expected
-- seasonal rates follow MultiplicativePriceSeasonality::seasonalityCorrection on a flat curve.
module QuantLib.Spec.TermStructure.Inflation (spec) where

import Control.Monad(forM_)
import Data.List.NonEmpty(fromList)
import Data.Time.Calendar(Day, fromGregorian, addGregorianMonthsClip, diffDays)
import Test.Hspec

import qualified QuantLib.Context as Context
import QuantLib.Currency(currency, Ccy(GBP))
import QuantLib.Index(addFixing)
import QuantLib.Index.Inflation(customRegion, customZeroInflationIndex, customYoyInflationIndex)
import qualified QuantLib.InterestRate as IR
import QuantLib.Math(Interpolation(..), Approximation(..))
import QuantLib.Quote(simpleQuote)
import QuantLib.TermStructure.Inflation
import QuantLib.TermStructure.Yield(PillarChoice(..), Reference(..), flatForward)
import QuantLib.Time.Calendar
import QuantLib.Time.Schedule

import QuantLib.Spec.Helpers(closePrec)

baseDate :: Day
baseDate = fromGregorian 2020 1 1

monthly :: Integer -> Day
monthly k = addGregorianMonthsClip k baseDate

-- A zero inflation curve bootstrapped from 2Y and 10Y zero-coupon swaps on a custom monthly
-- index with a linear fixing history, the swaps' dates given by 'period' from the evaluation date
-- and each maturity. Returns the curve's zero rate a year out.
zeroCouponCurveRate :: Day -> (Day -> Day -> InflationSwapPeriod) -> CPIInterpolationType -> IO Double
zeroCouponCurveRate evalDate period interp = do
  gbp <- currency GBP
  r <- customRegion "InflationSwapHelper Test" "ISHT"
  cal <- calendar Null
  dc <- dayCounter Actual365FixedStandard
  zii0 <- customZeroInflationIndex "ISHT Zero" r False Monthly (1, Months) gbp Nothing
  fixingDates <- mapM (\n -> advance cal evalDate (n, Months) Unadjusted False) [-96 .. 12 :: Int]
  forM_ (zip [1 :: Double ..] fixingDates) $ \(i, d) -> addFixing zii0 d (100.0 + i * 0.1) False
  helpers <- mapM (\(n, rate) -> do
      m <- advance cal evalDate (n, Years) Unadjusted False
      q <- simpleQuote rate
      zeroCouponInflationSwapHelper q (2, Months) (period evalDate m) cal Unadjusted dc zii0 interp LastRelevantDate Nothing)
    [(2, 0.03), (10, 0.025) :: (Int, Double)]
  baseDate' <- advance cal evalDate (-2, Months) Unadjusted False
  curve <- piecewiseZeroInflationCurve evalDate baseDate' Monthly dc (fromList helpers) Nothing Linear
  zeroRate curve (addGregorianMonthsClip 12 evalDate) False

-- The same for a year-on-year curve from 2Y and 5Y swaps discounted on a flat nominal curve,
-- returning its year-on-year rate a year out.
yearOnYearCurveRate :: Day -> (Day -> Day -> InflationSwapPeriod) -> IO Double
yearOnYearCurveRate evalDate period = do
  gbp <- currency GBP
  r <- customRegion "InflationSwapHelper Test" "ISHT"
  cal <- calendar Null
  dc <- dayCounter Actual365FixedStandard
  yii0 <- customYoyInflationIndex "ISHT YoY" r False Monthly (1, Months) gbp Nothing
  fixingDates <- mapM (\n -> advance cal evalDate (n, Months) Unadjusted False) [-96 .. 12 :: Int]
  forM_ (zip [1 :: Double ..] fixingDates) $ \(i, d) -> addFixing yii0 d (0.03 + i * 0.0001) False
  nominalQ <- simpleQuote 0.02
  nominalCurve <- flatForward (ReferenceDate evalDate) nominalQ dc IR.Continuous Annual
  helpers <- mapM (\(n, rate) -> do
      m <- advance cal evalDate (n, Years) Unadjusted False
      q <- simpleQuote rate
      yearOnYearInflationSwapHelper q (3, Months) (period evalDate m) cal Unadjusted dc yii0 CPIFlat nominalCurve LastRelevantDate Nothing)
    [(2, 0.03), (5, 0.028) :: (Int, Double)]
  baseDate' <- advance cal evalDate (-2, Months) Unadjusted False
  curve <- piecewiseYoyInflationCurve evalDate baseDate' 0.03 Monthly dc (fromList helpers) Nothing Linear
  yoyRate curve (addGregorianMonthsClip 12 evalDate) False

spec :: Spec
spec = do
 describe "zero-coupon inflation swap helpers" $ do
  -- Mid-month, so a linearly interpolated CPI differs from the month's flat fixing.
  let evalDate = fromGregorian 2024 1 15

  it "InflationSwapBetweenDates from the evaluation date matches InflationSwapToMaturity" $ Context.keepingSettingsGc $ do
    Context.setEvaluationDate (Just evalDate)
    -- Flat observation: the dated form takes its pillar's interpolation weight from the start date
    -- rather than the maturity, which only matters when the observation is interpolated.
    toMaturity <- zeroCouponCurveRate evalDate (const InflationSwapToMaturity) CPIFlat
    betweenDates <- zeroCouponCurveRate evalDate InflationSwapBetweenDates CPIFlat
    betweenDates `shouldSatisfy` closePrec toMaturity 1.0e-12

  it "a year-on-year InflationSwapBetweenDates from the evaluation date matches InflationSwapToMaturity" $ Context.keepingSettingsGc $ do
    Context.setEvaluationDate (Just evalDate)
    toMaturity <- yearOnYearCurveRate evalDate (const InflationSwapToMaturity)
    betweenDates <- yearOnYearCurveRate evalDate InflationSwapBetweenDates
    betweenDates `shouldSatisfy` closePrec toMaturity 1.0e-12

  it "a zero-coupon helper reads its swap rate on a curve it did not bootstrap" $ Context.keepingSettingsGc $ do
    Context.setEvaluationDate (Just evalDate)
    gbp <- currency GBP
    r <- customRegion "InflationSwapHelper Test" "ISHT"
    cal <- calendar Null
    dc <- dayCounter Actual365FixedStandard
    zii0 <- customZeroInflationIndex "ISHT Zero" r False Monthly (1, Months) gbp Nothing
    fixingDates <- mapM (\n -> advance cal evalDate (n, Months) Unadjusted False) [-96 .. 12 :: Int]
    forM_ (zip [1 :: Double ..] fixingDates) $ \(i, d) -> addFixing zii0 d (100.0 + i * 0.1) False
    let quotes = [(2, 0.03), (10, 0.025)] :: [(Int, Double)]
        helperAt rate n = do
          m <- advance cal evalDate (n, Years) Unadjusted False
          q <- simpleQuote rate
          zeroCouponInflationSwapHelper q (2, Months) (InflationSwapToMaturity m) cal Unadjusted dc zii0 CPIFlat LastRelevantDate Nothing
    helpers <- mapM (\(n, rate) -> helperAt rate n) quotes
    baseDate' <- advance cal evalDate (-2, Months) Unadjusted False
    curve <- piecewiseZeroInflationCurve evalDate baseDate' Monthly dc (fromList helpers) Nothing Linear
    forM_ quotes $ \(n, rate) -> do
      fresh <- helperAt 0 n
      zeroCouponInflationSwapImpliedQuoteOn fresh curve >>= (`shouldSatisfy` closePrec rate 1.0e-8)

  it "a year-on-year helper reads its swap rate on a curve it did not bootstrap" $ Context.keepingSettingsGc $ do
    Context.setEvaluationDate (Just evalDate)
    gbp <- currency GBP
    r <- customRegion "InflationSwapHelper Test" "ISHT"
    cal <- calendar Null
    dc <- dayCounter Actual365FixedStandard
    yii0 <- customYoyInflationIndex "ISHT YoY" r False Monthly (1, Months) gbp Nothing
    fixingDates <- mapM (\n -> advance cal evalDate (n, Months) Unadjusted False) [-96 .. 12 :: Int]
    forM_ (zip [1 :: Double ..] fixingDates) $ \(i, d) -> addFixing yii0 d (0.03 + i * 0.0001) False
    nominalQ <- simpleQuote 0.02
    nominalCurve <- flatForward (ReferenceDate evalDate) nominalQ dc IR.Continuous Annual
    let quotes = [(2, 0.03), (5, 0.028)] :: [(Int, Double)]
        helperAt rate n = do
          m <- advance cal evalDate (n, Years) Unadjusted False
          q <- simpleQuote rate
          yearOnYearInflationSwapHelper q (3, Months) (InflationSwapToMaturity m) cal Unadjusted dc yii0 CPIFlat nominalCurve LastRelevantDate Nothing
    helpers <- mapM (\(n, rate) -> helperAt rate n) quotes
    baseDate' <- advance cal evalDate (-2, Months) Unadjusted False
    curve <- piecewiseYoyInflationCurve evalDate baseDate' 0.03 Monthly dc (fromList helpers) Nothing Linear
    forM_ quotes $ \(n, rate) -> do
      fresh <- helperAt 0 n
      yearOnYearInflationSwapImpliedQuoteOn fresh curve >>= (`shouldSatisfy` closePrec rate 1.0e-8)

  -- The shim once mapped the interpolation with `== 0 ? Flat : Linear`, but CPIFlat is 1, so every
  -- helper interpolated linearly and these two curves were the same.
  it "honours flat against linear observation interpolation" $ Context.keepingSettingsGc $ do
    Context.setEvaluationDate (Just evalDate)
    flat <- zeroCouponCurveRate evalDate (const InflationSwapToMaturity) CPIFlat
    linear <- zeroCouponCurveRate evalDate (const InflationSwapToMaturity) CPILinear
    abs (flat - linear) `shouldSatisfy` (> 1.0e-6)

 describe "zero inflation term structures" $ do
  it "interpolatedZeroInflationCurve reproduces its node rates" $ Context.keepingSettingsGc $ do
    Context.setEvaluationDate (Just baseDate)
    dc <- dayCounter Actual365FixedStandard
    let nodes = [(baseDate, 0.010), (monthly 12, 0.015), (monthly 36, 0.020), (monthly 120, 0.025)]
    forM_ [Linear, Cubic Kruger] $ \i -> do
      curve <- interpolatedZeroInflationCurve baseDate (fromList nodes) Monthly dc Nothing i
      forM_ (drop 1 nodes) $ \(d, r) -> zeroRate curve d False >>= (`shouldSatisfy` closePrec r 1.0e-10)

  it "applies multiplicative price seasonality normalized to the curve's base date" $ Context.keepingSettingsGc $ do
    Context.setEvaluationDate (Just baseDate)
    dc <- dayCounter Actual365FixedStandard
    let flatRate = 0.02
        nodes = [(baseDate, flatRate), (monthly 120, flatRate)]
        factors = [1.0, 1.01, 1.02, 1.015, 1.005, 0.995, 0.99, 0.985, 0.99, 1.0, 1.005, 1.01] :: [Double]
        curveWith s = interpolatedZeroInflationCurve baseDate (fromList nodes) Monthly dc s Linear
    plain <- curveWith Nothing
    unit <- curveWith (Just (MultiplicativePriceSeasonality baseDate Monthly (replicate 12 1.0)))
    seasonal <- curveWith (Just (MultiplicativePriceSeasonality baseDate Monthly factors))
    forM_ [1 .. 11 :: Int] $ \k -> do
      let d = monthly (fromIntegral k)
          t = fromIntegral (diffDays d baseDate) / 365
          -- factors !! 0 is 1, so the base-date normalization is the factor itself
          expected = (1 + flatRate) * (factors !! k) ** (1 / t) - 1
      r <- zeroRate plain d False
      r `shouldSatisfy` closePrec flatRate 1.0e-12
      zeroRate unit d False >>= (`shouldSatisfy` closePrec r 1.0e-12)
      zeroRate seasonal d False >>= (`shouldSatisfy` closePrec expected 1.0e-10)

    kerkhof <- curveWith (Just (KerkhofSeasonality baseDate factors))
    rk <- zeroRate kerkhof (monthly 5) False
    abs (rk - flatRate) `shouldSatisfy` (> 1.0e-6)

  it "rejects multi-year seasonality inconsistent at whole years from the base date" $ Context.keepingSettingsGc $ do
    Context.setEvaluationDate (Just baseDate)
    dc <- dayCounter Actual365FixedStandard
    let nodes = [(baseDate, 0.02), (monthly 120, 0.02)]
        factors = replicate 12 1.0 ++ replicate 12 1.1
    interpolatedZeroInflationCurve baseDate (fromList nodes) Monthly dc
      (Just (MultiplicativePriceSeasonality baseDate Monthly factors)) Linear `shouldThrow` anyException