hasquant-0.7.0.0: test/example/QuantLib/Example/HaskellLSM.hs
-- |Benchmarks 'QuantLib.Method.lsmRegress' (the coarsened primitive CLAUDE.md's callback-shape
-- rule prescribes -- one batched regression call per exercise date, using QuantLib's own
-- Eigen-backed least-squares solve) against the naive alternative: the identical backward
-- induction, but with the per-date regression re-implemented from scratch in plain Haskell
-- (normal equations solved by hand-rolled Gauss-Jordan elimination over small lists, no
-- optimized linear algebra). QuantLib is used only for path generation -- both loops walk the
-- exact same calibration\/pricing path sets from "QuantLib.Example.AmericanLSM"'s fixture, so
-- any timing difference is the regression implementation, not the paths.
--
-- This is deliberately an apples-to-oranges comparison, not a controlled microbenchmark: the
-- Haskell side uses contiguous vectors for the large path arrays and a textbook
-- (unoptimized, non-pivoted-for-speed) solve over the small regression system, while
-- 'lsmRegress' calls into QuantLib's C++ least-squares machinery. That gap is the point -- it
-- illustrates why CLAUDE.md's "coarsen the language-boundary crossing" pattern reuses QuantLib's
-- own regression primitive instead of shipping the state across the FFI boundary once per path
-- and reimplementing the fit on the Haskell side.
--
-- Every list traversal below is total: no 'head'\/'tail'\/'last'\/'init'\/@(!!)@\/'maximum', and
-- no partial conversion of a plain list into a 'NonEmpty'. Genuinely non-empty-by-construction
-- values (a monomial basis of a given order always has at least one term, the constant) are
-- built directly as a 'NonEmpty' via @(':|')@; everywhere else, list recursion pattern-matches
-- both @[]@ and @(x:xs)@ explicitly (with a defined, if practically unreachable, result for the
-- empty case) instead of calling a function that would fail on it.
module QuantLib.Example.HaskellLSM
(
Result(..)
, run
) where
import Control.Monad(when)
import Data.List(transpose)
import qualified Data.Vector.Storable as V
import qualified Data.Vector as BV
import qualified Data.Vector.Unboxed as U
import Data.List.NonEmpty(NonEmpty(..), toList)
import System.CPUTime(getCPUTime)
import QuantLib.InterestRate
import QuantLib.Math
import QuantLib.Method
import QuantLib.Process
import QuantLib.Quote
import QuantLib.Context
import QuantLib.Time.Calendar
import QuantLib.Time.Date
import QuantLib.Time.Schedule
import QuantLib.TermStructure.Yield
import QuantLib.TermStructure.Volatility
data Result = Result
{ lsmPrice :: !Double -- ^lsmRegress-driven backward induction (as QuantLib.Example.AmericanLSM)
, haskellPrice :: !Double -- ^identical backward induction, regression reimplemented in plain Haskell
, lsmSeconds :: !Double -- ^CPU time of the lsmRegress backward induction only (path generation excluded)
, haskellSeconds :: !Double -- ^CPU time of the Haskell-regression backward induction only
}
payoff :: Double -> Double -> Double
payoff strike s = max (strike - s) 0
mean :: RealVector -> Double
mean xs = V.sum xs / fromIntegral (V.length xs)
cpuSeconds :: IO a -> IO (a, Double)
cpuSeconds act = do
t0 <- getCPUTime
r <- act
t1 <- getCPUTime
return (r, fromIntegral (t1 - t0) / 1.0e12)
-- |the monomial basis @[1, x, x^2, .., x^order]@ -- always has at least one term (the constant),
-- so it is built directly as a 'NonEmpty' rather than a plain list.
monomialBasis :: Word -> Double -> NonEmpty Double
monomialBasis order x = 1 :| take (fromIntegral order) (drop 1 (iterate (* x) 1))
-- |dot product of two equal-length lists (the shorter one wins if they differ, same as 'zipWith').
dot :: [Double] -> [Double] -> Double
dot xs ys = sum (zipWith (*) xs ys)
-- |naive ordinary-least-squares fit of 'monomialBasis', solved via the normal equations
-- (@(A^T A) c = A^T y@). The @A^T A@\/@A^T y@ entries are read off column-by-column via
-- 'transpose' (total, unlike indexing each row with @(!!)@) -- no optimized linear algebra,
-- unlike 'lsmRegress''s underlying C++ solve.
haskellRegress :: Word -> RealVector -> RealVector -> RealVector -> RealVector
haskellRegress order fitStates fitTargets evalStates =
let basisRows = map (toList . monomialBasis order) (V.toList fitStates)
cols = transpose basisRows -- one column per basis term, [] if fitStates is []
ata = [ [ dot c1 c2 | c2 <- cols ] | c1 <- cols ]
aty = [ dot c (V.toList fitTargets) | c <- cols ]
coeffs = gaussSolve ata aty
in V.fromList (map (dot coeffs . toList . monomialBasis order) (V.toList evalStates))
-- |value at column @k@ (0-based) of a row, via 'drop' rather than @(!!)@ -- total regardless of
-- @k@\/row length, though every call site here only ever asks for a column the row actually has.
columnAt :: Int -> [Double] -> Double
columnAt k row = case drop k row of
(v : _) -> v
[] -> 0 -- unreachable: k is always < length row at every call site below
-- |the last element of a row, via 'reverse' rather than 'last'.
lastColumn :: [Double] -> Double
lastColumn row = case reverse row of
(v : _) -> v
[] -> 0 -- unreachable: every row here is an augmented coefficient-plus-rhs row, never empty
-- |pick the element maximizing @key@ out of a list, returning it paired with the rest (order of
-- the rest preserved) -- a total, pattern-matching-only stand-in for 'Data.Foldable.maximumBy'
-- plus manual removal, since @maximumBy@ alone doesn't give the "rest" needed to move the pivot
-- to the front. 'Nothing' only for an empty input, which never happens at either call site (both
-- iterate the current elimination step's own nonempty remaining-rows suffix).
extractMaxBy :: (a -> Double) -> [a] -> Maybe (a, [a])
extractMaxBy key = go
where
go [] = Nothing
go (x : xs) = case go xs of
Nothing -> Just (x, [])
Just (best, rest)
| key x >= key best -> Just (x, best : rest)
| otherwise -> Just (best, x : rest)
-- |solve a small dense linear system @A c = y@ (rows of @A@, then @y@) by Gauss-Jordan
-- elimination with partial pivoting -- deliberately the naive textbook approach 'lsmRegress''s
-- C++ solve is not. Every row access goes through 'columnAt'\/'extractMaxBy'\/'splitAt', never
-- @(!!)@\/'head'\/'maximum'; @[]@ input (never seen in practice: 'haskellRegress' only calls this
-- with as many rows/columns as 'monomialBasis' terms, always >= 1) yields @[]@.
gaussSolve :: [[Double]] -> [Double] -> [Double]
gaussSolve rows y = case zipWith (\r yi -> r ++ [yi]) rows y of
[] -> []
augmented -> map lastColumn (foldl reduceColumn augmented [0 .. length augmented - 1])
where
reduceColumn current k =
let (before, atOrAfterK) = splitAt k current
in case extractMaxBy (abs . columnAt k) atOrAfterK of
Nothing -> before ++ atOrAfterK -- unreachable: k < length current always
Just (pivot, restAtK) ->
let leadingCoeff = columnAt k pivot
normPivot = map (/ leadingCoeff) pivot
eliminate row = zipWith (\v p -> v - columnAt k row * p) row normPivot
in map eliminate before ++ [normPivot] ++ map eliminate restAtK
-- |one backward-induction step, structurally identical to 'QuantLib.Example.AmericanLSM.step'
-- except the continuation-value estimate comes from 'haskellRegress' instead of 'lsmRegress'.
haskellStep :: Word -> Double -> Double -> RealVector -> RealVector -> RealVector -> RealVector -> U.Vector Bool
-> (RealVector, RealVector, U.Vector Bool)
haskellStep order strike df calibS priceS calibCF0 priceCF0 exFlags0 =
let calibCF = V.map (* df) calibCF0
priceCF = V.map (* df) priceCF0
calibEx = V.map (payoff strike) calibS
priceEx = V.map (payoff strike) priceS
fitStates = V.ifilter (\i _ -> calibEx V.! i > 0) calibS
fitTargets = V.ifilter (\i _ -> calibEx V.! i > 0) calibCF
in if V.length fitStates <= fromIntegral order
then (calibCF, priceCF, exFlags0)
else
let contCalib = haskellRegress order fitStates fitTargets calibS
contPrice = haskellRegress order fitStates fitTargets priceS
exercise ex cont = ex > 0 && ex > cont
calibCF' = V.zipWith3 (\cf ex cont -> if exercise ex cont then ex else cf) calibCF calibEx contCalib
priceCF' = V.zipWith3 (\cf ex cont -> if exercise ex cont then ex else cf) priceCF priceEx contPrice
exercisedNow = U.generate (V.length priceCF) $ \i -> exercise (priceEx V.! i) (contPrice V.! i)
exFlags' = U.zipWith (||) exFlags0 exercisedNow
in (calibCF', priceCF', exFlags')
-- |walk the exercise dates strictly backward, given as a list of @(discount factor, calibration
-- states, pricing states)@ triples already in backward (latest-exercise-date-first) order --
-- see 'exerciseSteps' for how that list is built without 'last'\/'init'.
haskellGoBack :: Word -> Double -> [(Double, RealVector, RealVector)] -> RealVector -> RealVector -> U.Vector Bool
-> (RealVector, RealVector, U.Vector Bool)
haskellGoBack order strike steps calibCF priceCF exFlags = case steps of
[] -> (calibCF, priceCF, exFlags)
((df, calibS, priceS) : rest) ->
let (calibCF', priceCF', exFlags') = haskellStep order strike df calibS priceS calibCF priceCF exFlags
in haskellGoBack order strike rest calibCF' priceCF' exFlags'
-- |lsmRegress-driven step, copied from "QuantLib.Example.AmericanLSM" so this module can time it
-- head-to-head against 'haskellStep' over the exact same paths.
lsmStep :: PolynomialType -> Word -> Double -> Double -> RealVector -> RealVector -> RealVector -> RealVector -> U.Vector Bool
-> IO (RealVector, RealVector, U.Vector Bool)
lsmStep polyT order strike df calibS priceS calibCF0 priceCF0 exFlags0 = do
let calibCF = V.map (* df) calibCF0
priceCF = V.map (* df) priceCF0
calibEx = V.map (payoff strike) calibS
priceEx = V.map (payoff strike) priceS
fitStates = V.ifilter (\i _ -> calibEx V.! i > 0) calibS
fitTargets = V.ifilter (\i _ -> calibEx V.! i > 0) calibCF
if V.length fitStates <= fromIntegral order
then return (calibCF, priceCF, exFlags0)
else do
contCalib <- lsmRegress polyT order fitStates fitTargets calibS
contPrice <- lsmRegress polyT order fitStates fitTargets priceS
let exercise ex cont = ex > 0 && ex > cont
calibCF' = V.zipWith3 (\cf ex cont -> if exercise ex cont then ex else cf) calibCF calibEx contCalib
priceCF' = V.zipWith3 (\cf ex cont -> if exercise ex cont then ex else cf) priceCF priceEx contPrice
exercisedNow = U.generate (V.length priceCF) $ \i -> exercise (priceEx V.! i) (contPrice V.! i)
exFlags' = U.zipWith (||) exFlags0 exercisedNow
return (calibCF', priceCF', exFlags')
-- |'lsmStep' counterpart of 'haskellGoBack', walking the same backward-ordered triples.
lsmGoBack :: PolynomialType -> Word -> Double -> [(Double, RealVector, RealVector)] -> RealVector -> RealVector -> U.Vector Bool
-> IO (RealVector, RealVector, U.Vector Bool)
lsmGoBack polyT order strike steps calibCF priceCF exFlags = case steps of
[] -> return (calibCF, priceCF, exFlags)
((df, calibS, priceS) : rest) -> do
(calibCF', priceCF', exFlags') <- lsmStep polyT order strike df calibS priceS calibCF priceCF exFlags
lsmGoBack polyT order strike rest calibCF' priceCF' exFlags'
-- |split a per-date state matrix (ascending, one row per date including both endpoints) into its
-- last row (maturity) and every earlier row. The earlier rows remain ascending here; the caller
-- reverses them once when constructing the backward exercise-step list. @[]@ only if the process
-- is sampled at zero dates, which never happens ('timeSteps' is fixed and positive below).
splitMaturity :: BV.Vector RealVector -> (RealVector, BV.Vector RealVector)
splitMaturity states
| BV.null states = (V.empty, BV.empty)
| otherwise = (BV.last states, BV.init states)
run :: Bool -> IO Result
run gc = do
setEvaluationDate $ Just evalDate
dc <- dayCounter Actual365FixedStandard
underQ <- simpleQuote under
riskFreeQ <- simpleQuote riskFreeRate
ts <- flatForward (ReferenceDate settl) riskFreeQ dc Continuous Annual
divQ <- simpleQuote dividend
divTS <- flatForward (ReferenceDate settl) divQ dc Continuous Annual
volQ <- simpleQuote vol
volTS <- calendar TARGET >>= \cal -> blackConstantVol (CalendarReferenceDate settl) cal volQ dc
bsmProc <- blackScholesMertonProcess underQ divTS ts volTS EulerDiscretization False
t <- yearFraction dc settl maturity Nothing Nothing
grid <- timeGrid t timeSteps
times <- points grid
discFactors <- mapM (\x -> discount ts (TimePoint x) False) (V.toList times)
let dfs = zipWith (flip (/)) discFactors (drop 1 discFactors)
dfsRev = reverse (drop 1 dfs) -- one-step discount factors for exercise dates, latest first
df0 = case discFactors of
(d0 : d1 : _) -> d1 / d0
_ -> 1 -- unreachable: timeSteps >= 1 below gives at least two entries
genCalib <- pathGenerator PseudoRandom bsmProc grid seedCalib (size grid - 1) False
calibPaths <- BV.replicateM nCalib (next genCalib >>= \s -> asset s 0)
genPrice <- pathGenerator PseudoRandom bsmProc grid seedPrice (size grid - 1) False
pricePaths <- BV.replicateM nPrice (next genPrice >>= \s -> asset s 0)
let timeMajor nTimes paths = BV.generate nTimes $ \timeIndex ->
V.generate (BV.length paths) $ \pathIndex -> (paths BV.! pathIndex) V.! timeIndex
calibStates = timeMajor (fromIntegral timeSteps + 1) calibPaths
priceStates = timeMajor (fromIntegral timeSteps + 1) pricePaths
(calibMaturity, calibRestRev) = splitMaturity calibStates
(priceMaturity, priceRestRev) = splitMaturity priceStates
calibCF0 = V.map (payoff strike) calibMaturity
priceCF0 = V.map (payoff strike) priceMaturity
-- backward-ordered (discount factor, calibration state, pricing state) triples
-- 'haskellGoBack'\/'lsmGoBack' walk. dfsRev already excludes the valuation-date step and is
-- one shorter than calibRestRev\/priceRestRev (which still include the valuation date as
-- their final entry); zip3 truncates to the shortest list, dropping that trailing entry for
-- free -- the total-function replacement for the 'init' this would otherwise need.
steps = zip3 dfsRev (BV.toList (BV.reverse calibRestRev)) (BV.toList (BV.reverse priceRestRev))
-- 'mean' (via 'sum') fully forces each backward-induction result inside the timed block --
-- without it, the lazily-built cashflow lists would only be forced later, when 'Result' is
-- printed, and the CPU time captured here would be meaningless.
when gc collectGarbage
(lsmP, lsmT) <- cpuSeconds $ do
(_, priceFinal, _) <- lsmGoBack polyT order strike steps calibCF0 priceCF0 (U.replicate nPrice False)
let p = mean (V.map (* df0) priceFinal)
p `seq` return p
when gc collectGarbage
(haskellP, haskellT) <- cpuSeconds $ do
let (_, priceFinal, _) = haskellGoBack order strike steps calibCF0 priceCF0 (U.replicate nPrice False)
p = mean (V.map (* df0) priceFinal)
p `seq` return p
return $ Result lsmP haskellP lsmT haskellT
where
evalDate = 15 `may` 1998
settl = 17 `may` 1998
under = 36
strike = 40
dividend = 0.0
riskFreeRate = 0.06
vol = 0.20
maturity = 17 `may` 1999
timeSteps = 100 :: Word
order = 2 :: Word
polyT = Monomial
nCalib = 4096 :: Int
nPrice = 8192 :: Int
seedCalib = 42 :: Word
seedPrice = 43 :: Word
-- vim: set ft=haskell ff=unix ts=8 sts=2 sw=2 et: