hasquant-0.7.0.0: app/SofrXva/Pricing.hs
-- |Prices a single fixed 7Y SOFR OIS (effective 1 Dec 2021, fixed rate 2.78%) on every
-- (scenario, timestep) pair covered by the given curve data, seeding daily interpolated
-- fixings between consecutive timesteps. One 'RelinkableYieldTermStructure' drives both
-- the 'Sofr' index's forecasting and the engine's discounting, so a single handle
-- relink is enough to move the whole pricing setup to the next timestep's curve.
module SofrXva.Pricing
( buildSofrProfile
, SofrProfile(..)
) where
import Control.Monad (filterM, forM, unless)
import Data.List (nub, sort)
import Data.List.NonEmpty (NonEmpty(..))
import qualified Data.List.NonEmpty as NE
import Data.Time.Calendar (Day, addDays, addGregorianYearsClip, diffDays, fromGregorian)
import qualified Data.Map.Strict as Map
import QuantLib.CashFlow (RateAveragingType(..))
import QuantLib.Index (fixingCalendar, isValidFixingDate, addFixings)
import qualified QuantLib.Index.InterestRate as IR
import QuantLib.Instrument (npv, setPricingEngine)
import QuantLib.Instrument.Swap (SwapType(..), defaultOvernightObservation, overnightIndexedSwap)
import QuantLib.Math (Interpolation(..))
import QuantLib.PricingEngine (discountingSwapEngine)
import QuantLib.Context (setEvaluationDate)
import qualified QuantLib.TermStructure.Yield as TS
import QuantLib.Time.Calendar (CalendarConstructor(..), calendar, BusinessDayConvention(..))
import QuantLib.Time.Schedule (DayCounterConstructor(..), DateGenerationRule(..), TimeUnit(..), dayCounter, schedule)
-- |'buildSofrProfile''s result: the per-(scenario, timestep) NPVs, plus the t0
-- ((scen, ts) = (0, 0)) discount curve, reusable by an XVA step that needs to
-- discount future timestep dates back to the valuation date without rebuilding it.
data SofrProfile = SofrProfile
{ spNpvs :: Map.Map (Int, Int) Double
, spT0DiscountCurve :: TS.YieldTermStructure
}
-- |Prices the swap on every (scenario, timestep) pair present in 'curves'. 'curves'
-- gives, per (scen, ts), the valuation date and its (pillar date, discount factor)
-- points (see 'SofrXva.Data.loadSofrCurve'); 'hist' the historical SOFR fixings;
-- 'quotes' the per-(scen, ts, date) SOFR quotes.
buildSofrProfile
:: Map.Map (Int, Int) (Day, NonEmpty (Day, Double))
-> Map.Map Day Double
-> Map.Map (Int, Int, Day) Double
-> IO SofrProfile
buildSofrProfile curves hist quotes = do
tsh <- TS.relinkableYieldTermStructure (Nothing :: Maybe TS.YieldTermStructure)
index <- IR.overnightIborIndex IR.Sofr (Just tsh)
histBDs <- filterM (isValidFixingDate index) (Map.keys hist)
unless (null histBDs) $
addFixings index (zip histBDs (map (hist Map.!) histBDs)) True
cal <- fixingCalendar index
dc <- dayCounter (Actual360 False)
paymentCal <- calendar UnitedStatesNYSE
let effectiveDate = fromGregorian 2021 12 1
terminationDate = addGregorianYearsClip 7 effectiveDate
fixedSched <- schedule (Just effectiveDate) terminationDate (6, Months) cal
ModifiedFollowing ModifiedFollowing Backward False Nothing Nothing
swap <- overnightIndexedSwap Payer 1000000 fixedSched 0.0278 dc index 0 2
ModifiedFollowing paymentCal False AveragingCompound defaultOvernightObservation
engine <- discountingSwapEngine tsh (Just False) Nothing Nothing
setPricingEngine swap engine
let d0 = fst (curves Map.! (0, 0))
scens = sort (nub (map fst (Map.keys curves)))
lookupQuote scen ts date = case Map.lookup (scen, ts, date) quotes of
Just v -> v
-- mirrors getQuote's synthetic (scen, ts, d0) row, which falls back to the
-- historical fixing whenever no real quote is recorded for the valuation date
Nothing | date == d0 -> hist Map.! d0
Nothing -> error ("SofrXva.Pricing.buildSofrProfile: no quote for "
++ show (scen, ts, date))
results <- forM scens $ \scen -> do
let tss = sort [ts | (s, ts) <- Map.keys curves, s == scen]
(_, npvs) <- foldDates d0 tss $ \d1 ts -> do
let (d2, points) = curves Map.! (scen, ts)
setEvaluationDate (Just d2)
addInterpolatedFixings index d1 d2
(lookupQuote scen (max 0 (ts - 1)) d1) (lookupQuote scen ts d2)
curve <- TS.interpolatedDiscountCurve points dc cal [] LogLinear True
TS.linkTo tsh curve
v <- npv swap
pure (d2, ((scen, ts), v))
pure npvs
t0DiscountCurve <- TS.interpolatedDiscountCurve (snd (curves Map.! (0, 0)))
dc cal [] LogLinear True
pure SofrProfile
{ spNpvs = Map.fromList (concat results)
, spT0DiscountCurve = t0DiscountCurve
}
where
foldDates :: Monad m => Day -> [Int] -> (Day -> Int -> m (Day, a)) -> m (Day, [a])
foldDates d0 xs f = go d0 xs []
where
go d [] acc = pure (d, reverse acc)
go d (x:rest) acc = do
(d', a) <- f d x
go d' rest (a : acc)
-- |Adds daily interpolated fixings for every business day between 'd1' and 'd2'
-- (inclusive), linearly interpolating between the two quote values known at those
-- dates.
addInterpolatedFixings :: IR.OvernightIborIndex -> Day -> Day -> Double -> Double -> IO ()
addInterpolatedFixings index d1 d2 v1 v2 = do
let n = fromIntegral (diffDays d2 d1) :: Int
allDates = [addDays i d1 | i <- [0 .. fromIntegral n]]
values = linspace v1 v2 (n + 1)
pairs <- filterM (\(d, _) -> isValidFixingDate index d) (zip allDates (NE.toList values))
unless (null pairs) $
addFixings index pairs True
-- |@numpy.linspace@'s behaviour for the cases this module needs: 'num' evenly spaced
-- points from 'a' to 'b' inclusive, with 'num' == 1 returning just 'a'.
linspace :: Double -> Double -> Int -> NonEmpty Double
linspace a _ 1 = a :| []
linspace _ _ num | num <= 0 = error "SofrXva.Pricing.linspace: point count must be positive"
linspace a b num = a :| [a + (b - a) * fromIntegral i / fromIntegral (num - 1) | i <- [1 .. num - 1]]