hasquant-0.6.0.0: test/hspec/QuantLib/Spec/Instrument/InflationCapFloor.hs
module QuantLib.Spec.Instrument.InflationCapFloor (spec) where
import Control.Monad(forM_, forM)
import Data.Time.Calendar(toGregorian, fromGregorian)
import Test.Hspec
import qualified QuantLib.Settings as Settings
import QuantLib.CashFlow(Leg, yoyInflationLeg, blackYoYInflationCouponPricer, setYoYInflationCouponPricer)
import qualified QuantLib.CashFlow as CF
import QuantLib.Currency(currency, Ccy(GBP))
import QuantLib.Instrument.Option(OptionType(..))
import QuantLib.TermStructure.Inflation
import QuantLib.Index(addFixing)
import QuantLib.Index.Inflation
import qualified QuantLib.InterestRate as IR
import QuantLib.InterestRate(VolatilityType(..))
import QuantLib.Instrument(npv, setPricingEngine)
import QuantLib.Instrument.InflationCapFloor
import QuantLib.Math(Interpolation(..), Interpolation2D(..), Matrix(..))
import QuantLib.PricingEngine(PricingEngine, yoyInflationBlackCapFloorEngine, interpolatingCPICapFloorEngine)
import QuantLib.Quote(simpleQuote)
import QuantLib.TermStructure.InflationVolatility
import QuantLib.TermStructure.Yield(flatForward, PillarChoice(..))
import QuantLib.Time.Calendar
import QuantLib.Time.Date
import QuantLib.Time.Schedule
-- |A custom-named YoY index (rather than a shared named singleton like 'YYUKRPI') so this
-- module's made-up fixings and curve link can't collide with another test's fixings on the
-- same globally shared index-name fixing store -- QuantLib's fixing store is keyed by index
-- name, and hspec runs specs in random order, so any shared name is a latent cross-test
-- conflict.
customYoYIndex :: Maybe YoYInflationTermStructure -> IO YoYInflationIndex
customYoYIndex mts = do
gbp <- currency GBP
r <- region' "InflationCapFloor Test" "ICFT"
yoyInflationIndex' "ICFT YoY" r False Monthly (1, Months) gbp mts
-- |Bootstraps a tiny YoY curve and returns an index linked to it. A YoY index's
-- 'needsForecast' (ql/indexes/inflationindex.cpp) is date-driven, not data-driven: any fixing
-- date beyond @evaluationDate - availabilityLag@ always forecasts through the index's own
-- linked 'YoYInflationTermStructure', regardless of whether a fixing has been manually added
-- for it -- so a future-dated cap/floor leg needs a real linked curve, not just extra
-- 'addFixing' calls (contrast 'QuantLib.Spec.Examples' CPI fixtures, which stay within the
-- historical window on purpose). Mirrors upstream's own CommonVars fixture
-- (test-suite/inflationcapfloor.cpp), minus the relinkable-handle indirection hasquant doesn't
-- expose for inflation curves: build the swap helpers off an unlinked index, bootstrap, then
-- construct a second, curve-linked index sharing the same family name (fixing history is keyed
-- by name, not object identity, so it's shared automatically).
linkedYoYIndex :: Day -> IO YoYInflationIndex
linkedYoYIndex tod = do
cal <- calendar Null
dc <- dayCounter Actual365FixedStandard
yii0 <- customYoYIndex Nothing
-- Fixings span [today-8y, today+1y] (not a hardcoded absolute range) so this test keeps
-- working as the real wall-clock 'today' advances across future runs -- an absolute
-- 2018-01..2026-01 window (this test's original form) silently falls out of range once
-- 'today' itself passes 2026, since 'baseDate' below is derived from 'today'.
fixingDates <- mapM (\n -> advance cal tod (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 tod nominalQ dc IR.Continuous Annual
maturity1 <- advance cal tod (2, Years) Unadjusted False
maturity2 <- advance cal tod (5, Years) Unadjusted False
q1 <- simpleQuote 0.03
q2 <- simpleQuote 0.03
h1 <- yearOnYearInflationSwapHelper q1 (3, Months) maturity1 cal Unadjusted dc yii0 CPIFlat nominalCurve LastRelevantDate Nothing
h2 <- yearOnYearInflationSwapHelper q2 (3, Months) maturity2 cal Unadjusted dc yii0 CPIFlat nominalCurve LastRelevantDate Nothing
baseDate <- advance cal tod (-2, Months) Unadjusted False
yoyCurve <- piecewiseYoYInflationCurve tod baseDate 0.03 Monthly dc [h1, h2] Linear
customYoYIndex (Just yoyCurve)
-- |A short YoY-inflation leg (3 annual coupons) on the given (curve-linked) index -- mirrors
-- the fixture in upstream's inflationcapfloor.cpp's CommonVars, trimmed to what a fast
-- structural-consistency check needs.
setupLeg :: YoYInflationIndex -> Day -> IO Leg
setupLeg yii tod = do
cal <- calendar Null
dc <- dayCounter Actual365FixedStandard
endDate <- advance cal tod (3, Years) Unadjusted False
sch <- schedule (Just tod) endDate (1, Years) cal Unadjusted Unadjusted Forward False Nothing Nothing
yoyInflationLeg sch cal yii (3, Months) CPIFlat [1000000] dc Unadjusted [0] [1.0] [0.0] [] []
-- |Builds the Black engine (constant vol) all cap\/floor\/collar instruments in this module
-- share -- mirrors 'QuantLib.Spec.TermStructure`'s "Black cap/floor engine" setup, YoY-inflation
-- flavoured.
setupEngine :: YoYInflationIndex -> Day -> IO PricingEngine
setupEngine yii tod = do
cal <- calendar Null
dc <- dayCounter Actual365FixedStandard
nominalQ <- simpleQuote 0.02
nominalCurve <- flatForward tod nominalQ dc IR.Continuous Annual
volQ <- simpleQuote 0.02
vol <- constantYoYOptionletVolatility volQ 0 cal Unadjusted dc (3, Months) Annual False (-1.0) 100.0 ShiftedLognormal 0.0
yoyInflationBlackCapFloorEngine yii vol nominalCurve
-- |A custom-named zero index, linked to a tiny bootstrapped 'ZeroInflationTermStructure' --
-- same fixing-store-isolation reasoning as 'customYoYIndex'. A link is required even though the
-- price surface only ever queries its own price grid: 'InterpolatedCPICapFloorTermPriceSurface's
-- @performCalculations@ computes each grid column's ATM level via the index's own
-- 'QuantLib.TermStructure.Inflation.ZeroInflationTermStructure' (put\/call parity, see this
-- type's own C++ haddock), which throws "ZITS missing from index" if unset -- mirrors upstream's
-- own CommonVars building 'PiecewiseZeroInflationCurve' before the price surface.
customZeroIndex :: Day -> IO ZeroInflationIndex
customZeroIndex tod = do
gbp <- currency GBP
r <- region' "InflationCapFloor CPI Test" "ICFCT"
cal <- calendar Null
dc <- dayCounter Actual365FixedStandard
zii0 <- zeroInflationIndex' "ICFCT Zero" r False Monthly (1, Months) gbp Nothing
-- Same today-relative fixing window as 'linkedYoYIndex', for the same reason.
fixingDates <- mapM (\n -> advance cal tod (n, Months) Unadjusted False) [-96 .. 12 :: Int]
forM_ (zip [1 :: Double ..] fixingDates) $ \(i, d) -> addFixing zii0 d (100.0 + i * 0.1) False
maturity1 <- advance cal tod (2, Years) Unadjusted False
-- 10y, not 5y: this curve must reach past the price surface's own widest grid maturity (7y,
-- see the CPI cap/floor test below) since 'InterpolatedCPICapFloorTermPriceSurface's
-- performCalculations computes each grid column's ATM level off this index's linked curve.
maturity2 <- advance cal tod (10, Years) Unadjusted False
q1 <- simpleQuote 0.03
q2 <- simpleQuote 0.03
h1 <- zeroCouponInflationSwapHelper q1 (2, Months) maturity1 cal Unadjusted dc zii0 CPIFlat LastRelevantDate Nothing
h2 <- zeroCouponInflationSwapHelper q2 (2, Months) maturity2 cal Unadjusted dc zii0 CPIFlat LastRelevantDate Nothing
baseDate <- advance cal tod (-2, Months) Unadjusted False
zeroCurve <- piecewiseZeroInflationCurve tod baseDate Monthly dc [h1, h2] Linear
zeroInflationIndex' "ICFCT Zero" r False Monthly (1, Months) gbp (Just zeroCurve)
spec :: Spec
spec = do
describe "YoY inflation cap/floor" $ do
it "cap - floor = collar, and the sum of optionlets equals the parent NPV" $ Settings.keepingSettings' $ do
-- Anchored to the real wall-clock date (like 'QuantLib.Spec.Examples`'s SimpleChooserOption),
-- not a hardcoded past date: every maturity derived below is then always in the future, so a
-- later test in the suite changing the global evaluation date can never see this test's
-- still-alive curve/swap objects as stale. (An earlier version of this test hardcoded
-- "2 january 2024" and relied on 'performGC' alone to finalize those objects before the next
-- test moved the clock forward -- but 'performGC' only schedules finalizers, it doesn't run
-- them synchronously, so that was a race rather than a fix.)
todayD <- today
Settings.setEvaluationDate (Just todayD)
yii <- linkedYoYIndex todayD
leg <- setupLeg yii todayD
engine <- setupEngine yii todayD
let capRate = 0.035
floorRate = 0.025
capInst <- yoyInflationCap leg [capRate]
floorInst <- yoyInflationFloor leg [floorRate]
collarInst <- yoyInflationCollar leg [capRate] [floorRate]
mapM_ (`setPricingEngine` engine) [capInst, floorInst, collarInst]
capNPV <- npv capInst
floorNPV <- npv floorInst
collarNPV <- npv collarInst
abs ((capNPV - floorNPV) - collarNPV) `shouldSatisfy` (< 1e-6)
caplets <- forM [0 .. 2 :: Word] $ \n -> do
o <- yoyInflationCapFloorOptionlet capInst n
setPricingEngine o engine
npv o
abs (capNPV - sum caplets) `shouldSatisfy` (< 1e-6)
it "a capped yoyInflationLeg's NPV decomposes as uncapped leg NPV minus the equivalent cap's NPV" $ Settings.keepingSettings' $ do
-- Confirmed by reading inflationcoupon.cpp: InflationCoupon::rate() unconditionally requires
-- a pricer (QL_REQUIRE(pricer_, "pricer not set")), capped or not -- yoyInflationLeg's own
-- operator Leg() (yoyinflationcoupon.cpp) auto-attaches a default (non-vol) pricer only when
-- caps and floors are BOTH empty; a non-empty cap here means 'setYoYInflationCouponPricer'
-- must be called explicitly, and this test's NPV assertion below only succeeds if it actually
-- ran (leaving it out reproduces "pricer not set", not a silently-wrong number) -- so this
-- doubles as the setter's own regression check.
todayD <- today
Settings.setEvaluationDate (Just todayD)
yii <- linkedYoYIndex todayD
cal <- calendar Null
dc <- dayCounter Actual365FixedStandard
endDate <- advance cal todayD (3, Years) Unadjusted False
sch <- schedule (Just todayD) endDate (1, Years) cal Unadjusted Unadjusted Forward False Nothing Nothing
let capRate = 0.035
cappedLeg <- yoyInflationLeg sch cal yii (3, Months) CPIFlat [1000000] dc Unadjusted [0] [1.0] [0.0] [capRate] []
uncappedLeg <- yoyInflationLeg sch cal yii (3, Months) CPIFlat [1000000] dc Unadjusted [0] [1.0] [0.0] [] []
nominalQ <- simpleQuote 0.02
nominalCurve <- flatForward todayD nominalQ dc IR.Continuous Annual
volQ <- simpleQuote 0.02
vol <- constantYoYOptionletVolatility volQ 0 cal Unadjusted dc (3, Months) Annual False (-1.0) 100.0 ShiftedLognormal 0.0
pricer <- blackYoYInflationCouponPricer vol nominalCurve
setYoYInflationCouponPricer cappedLeg pricer
cappedNPV <- CF.npv cappedLeg nominalCurve True Nothing Nothing
uncappedNPV <- CF.npv uncappedLeg nominalCurve True Nothing Nothing
capEngine <- yoyInflationBlackCapFloorEngine yii vol nominalCurve
capInst <- yoyInflationCap uncappedLeg [capRate]
setPricingEngine capInst capEngine
capNPV <- npv capInst
abs ((uncappedNPV - capNPV) - cappedNPV) `shouldSatisfy` (< 1e-6)
describe "CPI cap/floor" $ do
-- CPI cap/floor has no vol-driven engine in QL 1.43 -- InterpolatingCPICapFloorEngine prices
-- purely by interpolating a market price surface (see cbits/qlInflationVol.cpp's
-- qlInterpolatingCPICapFloorEngine and CPICapFloorTermPriceSurface's own haddock). At an
-- exact grid node the interpolation is an identity, so this reproduces the input price
-- exactly rather than just approximately -- mirrors upstream's own
-- test-suite/inflationcpicapfloor.cpp::cpicapfloorpricer, whose fixture is built the same
-- way: pick an evaluation date on the 1st of a month (so "the start of the inflation period
-- containing the maturity", what CPI::Flat actually samples, coincides exactly with the
-- maturity itself) and a maturity/strike that exactly match one price-surface grid node.
-- baseCPI is a required constructor argument but never touched by this engine's calculate()
-- (confirmed by reading cpicapfloorengines.cpp) so an arbitrary placeholder is fine.
it "reproduces the exact grid price at a matching strike/maturity node" $ Settings.keepingSettings' $ do
-- First-of-month, derived from the real wall-clock date rather than hardcoded, so
-- CPI::Flat's period-start sampling (see the comment above) lands exactly on the raw date
-- without pinning the test to a date that will eventually become stale/past.
(y, m, _) <- toGregorian <$> today
let today' = fromGregorian y m 1
Settings.setEvaluationDate (Just today')
cal <- calendar Null
dc <- dayCounter Actual365FixedStandard
nominalQ <- simpleQuote 0.02
nominalCurve <- flatForward today' nominalQ dc IR.Continuous Annual
zii <- customZeroIndex today'
maturity5Y <- advance cal today' (5, Years) Unadjusted False
let obsLag = (2, Months)
cStrike = 0.04
fStrike = 0.01
cPriceGrid = 0.01279
fPriceGrid = 0.006666
-- A non-square (2 strikes x 3 maturities) slice of upstream's own cached cap/floor price
-- grid (test-suite/inflationcpicapfloor.cpp's cPrice/fPrice tables, strikes 0.03/0.04 and
-- 0.01/-0.01, maturities 3Y/5Y/7Y), row-major by strike then maturity -- deliberately
-- non-square so a Matrix construction that silently transposed strikes/maturities (rather
-- than throwing a dimension mismatch) would produce a detectably wrong value here; a square
-- grid can't distinguish the two orientations. The assertions below check the (strike
-- 0.04, maturity 5Y) and (strike 0.01, maturity 5Y) cells -- both off the [0][0] corner --
-- rather than only the corner a transposed 2x2 grid would still get right by accident.
surface <- cpiCapFloorTermPriceSurface 1.0 cStrike obsLag cal Unadjusted dc zii CPIFlat nominalCurve
[0.03, cStrike] [-0.01, fStrike] [(3, Years), (5, Years), (7, Years)]
(Matrix 2 3 [0.02276, 0.034532, 0.047795, 0.010027, cPriceGrid, 0.017019])
(Matrix 2 3 [0.001562, 0.002145, 0.002445, 0.005361, fPriceGrid, 0.007704])
Bilinear
engine <- interpolatingCPICapFloorEngine surface
capInst <- cpiCapFloor Call 1.0 today' 100.0 maturity5Y cal Unadjusted cal Unadjusted cStrike zii obsLag CPIFlat
setPricingEngine capInst engine
capNPV <- npv capInst
abs (capNPV - cPriceGrid) `shouldSatisfy` (< 1e-9)
floorInst <- cpiCapFloor Put 1.0 today' 100.0 maturity5Y cal Unadjusted cal Unadjusted fStrike zii obsLag CPIFlat
setPricingEngine floorInst engine
floorNPV <- npv floorInst
abs (floorNPV - fPriceGrid) `shouldSatisfy` (< 1e-9)
-- No upstream fixture covers a non-Bilinear Interpolation2D, so this is a
-- construction/sanity check only, same reasoning as the yoyCapFloorTermPriceSurface
-- spot-check in QuantLib.Spec.TermStructure.InflationVolatility.
it "cpiCapFloorTermPriceSurface: Bicubic builds and reproduces the same grid price" $ Settings.keepingSettings' $ do
(y, m, _) <- toGregorian <$> today
let today' = fromGregorian y m 1
Settings.setEvaluationDate (Just today')
cal <- calendar Null
dc <- dayCounter Actual365FixedStandard
nominalQ <- simpleQuote 0.02
nominalCurve <- flatForward today' nominalQ dc IR.Continuous Annual
zii <- customZeroIndex today'
maturity5Y <- advance cal today' (5, Years) Unadjusted False
let obsLag = (2, Months)
cStrike = 0.04
cPriceGrid = 0.01279
surface <- cpiCapFloorTermPriceSurface 1.0 cStrike obsLag cal Unadjusted dc zii CPIFlat nominalCurve
[0.03, cStrike] [-0.01, 0.01] [(3, Years), (5, Years), (7, Years)]
(Matrix 2 3 [0.02276, 0.034532, 0.047795, 0.010027, cPriceGrid, 0.017019])
(Matrix 2 3 [0.001562, 0.002145, 0.002445, 0.005361, 0.006666, 0.007704])
Bicubic
engine <- interpolatingCPICapFloorEngine surface
capInst <- cpiCapFloor Call 1.0 today' 100.0 maturity5Y cal Unadjusted cal Unadjusted cStrike zii obsLag CPIFlat
setPricingEngine capInst engine
capNPV <- npv capInst
abs (capNPV - cPriceGrid) `shouldSatisfy` (< 1e-9)