hasquant-0.8.0.0: test/hspec/QuantLib/Spec/Model/RoughHeston.hs
{-# LANGUAGE TupleSections #-}
module QuantLib.Spec.Model.RoughHeston (spec) where
import Control.Monad(forM, forM_)
import Data.Complex(Complex((:+)), magnitude)
import Data.List.NonEmpty(NonEmpty(..))
import Data.Time.Calendar(Day, fromGregorian, addDays)
import Test.Hspec
import qualified QuantLib.Context as Context
import QuantLib.Time.Calendar
import QuantLib.Time.Schedule
import QuantLib.InterestRate(Compounding(..))
import QuantLib.Quote
import QuantLib.TermStructure(Reference(..), TermPoint(..), timeFromReference)
import QuantLib.TermStructure.Yield(flatForward, discount)
import QuantLib.Process(hestonProcess, HestonProcessDiscretization(..))
import qualified QuantLib.Model as Model
import QuantLib.Model(RoughHestonModel, roughHestonModel)
import QuantLib.Instrument(npv, setPricingEngine)
import QuantLib.Instrument.Option
import QuantLib.PricingEngine
import QuantLib.Math(OptimizationMethod(..), EndCriteria(..))
import QuantLib.Spec.Helpers(quantLibAtMost143, unsupportedQuantLib144)
fixtureDate :: Day
fixtureDate = fromGregorian 2026 7 2
fixture :: Double -> IO (RoughHestonModel, SimpleQuote)
fixture hurst = do
Context.setEvaluationDate (Just fixtureDate)
dc <- dayCounter Actual365FixedStandard
r <- simpleQuote 0.03 >>= \q -> flatForward (ReferenceDate fixtureDate) q dc Continuous Annual
q <- simpleQuote 0 >>= \v -> flatForward (ReferenceDate fixtureDate) v dc Continuous Annual
spot <- simpleQuote 100
process <- hestonProcess r (Just q) spot 0.04 0.3 0.04 0.4 (-0.7) QuadraticExponentialMartingale
model <- roughHestonModel process hurst
pure (model, spot)
near :: Double -> Double -> Double -> Expectation
near tolerance expected actual = abs (actual - expected) `shouldSatisfy` (< tolerance)
spec :: Spec
spec = describe "Rough Heston 1.44" $
if quantLibAtMost143 then
it "reports the required version before constructing the model" $ Context.keepingSettingsGc $
fixture 0.1 `shouldThrow` unsupportedQuantLib144 "roughHestonModel"
else do
it "matches upstream independent reference prices" $ Context.keepingSettingsGc $ do
(model, _) <- fixture 0.1
engine <- analyticRoughHestonEngine model 128 512 AdamsPredictorCorrector
forM_ [(0.25, 75, 25.762400), (0.25, 100, 3.793313), (1, 100, 8.336634), (2, 130, 1.285820)] $ \(t,k,expected) ->
roughHestonPriceVanillaPayoff engine (PlainVanillaPayoff Call k) (TimePoint t) >>= near 5e-4 expected
it "prices through the engine family and preserves date/time semantics" $ Context.keepingSettingsGc $ do
(model, _) <- fixture 0.1
engine <- analyticRoughHestonEngine model 128 256 AdamsPredictorCorrector
let maturity = addDays 365 fixtureDate
payoff = PlainVanillaPayoff Call 100
option <- vanillaOption (PlainVanilla payoff) (European (EuropeanExercise maturity))
setPricingEngine option engine
expected <- npv option
roughHestonPriceVanillaPayoff engine payoff (DatePoint maturity) >>= near 1e-8 expected
roughHestonPriceVanillaPayoff engine payoff (TimePoint 1) >>= near 1e-8 expected
process <- Model.roughHestonProcess model
-- The process's risk-free curve uses the same day counter as direct date pricing.
Model.roughHestonHurst model `shouldReturn` 0.1
Model.params model >>= \ps -> length ps `shouldBe` 6
engineAsBase <- asPricingEngine engine
setPricingEngine option engineAsBase
npv option >>= near 1e-8 expected
hm <- Model.hestonModel process
analyticHestonEngine hm (IntegrationOrder 128) >>= setPricingEngine option
hestonPrice <- npv option
(classical, _) <- fixture 0.5
classicalEngine <- analyticRoughHestonEngine classical 128 512 AdamsPredictorCorrector
roughHestonPriceVanillaPayoff classicalEngine payoff (DatePoint maturity) >>= near 5e-4 hestonPrice
it "supports all approximations, complex analytics and observer updates" $ Context.keepingSettingsGc $ do
(model, spot) <- fixture 0.1
engines <- mapM (analyticRoughHestonEngine model 128 512) [AdamsPredictorCorrector, Pade, Lifted 40]
forM_ engines $ \engine -> do
roughHestonCharacteristicFunction engine (0 :+ 0) 1 >>= \z -> magnitude (z - 1) `shouldSatisfy` (< 1e-10)
phi <- roughHestonCharacteristicFunction engine (1.2 :+ (-0.5)) 1
logPhi <- roughHestonLogCharacteristicFunction engine (1.2 :+ (-0.5)) 1
magnitude (phi - exp logPhi) `shouldSatisfy` (< 1e-10)
roughHestonRiccatiSolution engine (0 :+ 0) 1 >>= \z -> magnitude z `shouldSatisfy` (< 1e-10)
p <- roughHestonPriceVanillaPayoff engine (PlainVanillaPayoff Call 100) (TimePoint 1)
near 0.4 8.336634 p
roughHestonNumberOfEvaluations engine >>= (`shouldSatisfy` (> 0))
case engines of
engine:_ -> do
priceBefore <- roughHestonPriceVanillaPayoff engine (PlainVanillaPayoff Call 100) (TimePoint 1)
_ <- setValue spot 110
priceAfter <- roughHestonPriceVanillaPayoff engine (PlainVanillaPayoff Call 100) (TimePoint 1)
priceAfter `shouldSatisfy` (> priceBefore)
Model.setParams model [0.05, 0.4, 0.3, -0.6, 0.03, 0.2]
Model.roughHestonTheta model `shouldReturn` 0.05
Model.roughHestonHurst model `shouldReturn` 0.2
changed <- roughHestonPriceVanillaPayoff engine (PlainVanillaPayoff Call 100) (TimePoint 1)
changed `shouldSatisfy` (\p -> abs (p - priceAfter) > 1e-4)
[] -> expectationFailure "missing approximation engines"
it "honours Fourier integration controls and rejects invalid configurations" $ Context.keepingSettingsGc $ do
(model, _) <- fixture 0.1
engine <- analyticRoughHestonEngineWithIntegration model (FourierGaussLobatto 1e-8 1e-8 10000 False) 256 1e-25 (-0.5) AdamsPredictorCorrector
roughHestonPriceVanillaPayoff engine (PlainVanillaPayoff Call 100) (TimePoint 1) >>= near 5e-4 8.336634
analyticRoughHestonEngine model 128 0 AdamsPredictorCorrector `shouldThrow` anyException
analyticRoughHestonEngine model 128 256 (Lifted 0) `shouldThrow` anyException
analyticRoughHestonEngineWithIntegration model (FourierGaussLaguerre 128) 256 1e-25 0 Pade `shouldThrow` anyException
it "calibrates Hurst and regenerates model state" $ Context.keepingSettingsGc $ do
(model, _) <- fixture 0.12
dc <- dayCounter Actual365FixedStandard
r <- simpleQuote 0.03 >>= \q -> flatForward (ReferenceDate fixtureDate) q dc Continuous Annual
q <- simpleQuote 0 >>= \v -> flatForward (ReferenceDate fixtureDate) v dc Continuous Annual
spot <- simpleQuote 100
cal <- calendar Null
truth <- analyticRoughHestonEngine model 64 256 Pade
helpers <- forM [3, 6, 12] $ \months -> do
maturity <- advance cal fixtureDate (months, Months) Unadjusted False
t <- timeFromReference r maturity
df <- discount r (DatePoint maturity) False
let fwd = 100 / df
price <- roughHestonPriceVanillaPayoff truth (PlainVanillaPayoff Call 100) (DatePoint maturity)
stddev <- blackImpliedStdDev Call 100 fwd price df 0 0.2 1e-10 100
vol <- simpleQuote (stddev / sqrt t)
Model.hestonModelHelper (fromIntegral months, Months) cal spot 100 vol r q Model.ImpliedVolError
Model.setParams model [0.04, 0.3, 0.4, -0.7, 0.04, 0.2]
engine <- analyticRoughHestonEngine model 64 256 Pade
forM_ helpers $ \helper -> Model.setPricingEngine helper engine
case helpers of
h:hs -> Model.calibrate model ((h,1) :| map (,1) hs)
(LevenbergMarquardt 1e-8 1e-8 1e-8 False) (EndCriteria 100 20 1e-8 1e-8 1e-8) Nothing [True,True,True,True,True,False]
[] -> expectationFailure "missing calibration helpers"
Model.roughHestonHurst model >>= near 1e-4 0.12