packages feed

hasquant-0.7.0.0: app/SofrXva/Main.hs

-- |Wiring for the SOFR-OIS exposure-profile pipeline: loads the vendor-format CSV
-- dumps (via 'SofrXva.Data'), prices the swap on every (scenario, timestep) pair (via
-- 'SofrXva.Pricing'), renders an NPV profile plot (via 'SofrXva.Plot'), and compares
-- against the reference NPV file, printing a summary and writing @_NPVDiff.csv@.
--
-- The curve\/quote\/index name fields each loader filters rows by (see 'SofrXva.Data')
-- are a source-specific naming convention, not something this program can guess --
-- @--curve-name@\/@--quote-name@\/@--quote-name-full@ override the generic defaults
-- (which match the synthetic fixtures under @app\/SofrXva\/example-data@) with whatever
-- convention a real data source actually uses, e.g. @--quote-name-full MY-SOURCE.USD.SOFR.1D@.
module Main (main) where

import Control.Monad (forM_, unless)
import Data.List (sortOn)
import Data.Maybe (fromMaybe)
import qualified Data.Map.Strict as Map
import System.Environment (getArgs)
import System.FilePath ((</>))
import Text.Printf (printf)

import SofrXva.Data (loadIndexHist, loadNpvComparison, loadSofrCurve, loadSofrQuote)
import SofrXva.Plot (plotNpvProfile)
import SofrXva.Pricing (SofrProfile(..), buildSofrProfile)
import SofrXva.Xva (XvaResult(..), computeXva)

-- |Which files to load. A real data source's on-disk file names are as much a
-- source-specific convention as the row-level name fields below, so these generic
-- basenames (which match the synthetic fixtures under @app\/SofrXva\/example-data@)
-- are only defaults -- @--curve-file@\/@--quote-file@\/@--history-file@\/@--npv-file@
-- override them for any other source. @--quote-file@ may be repeated (a source may
-- split its quote dump across several files); repeating it replaces the default
-- single-file list rather than appending to it. @--plot-file@ likewise overrides where
-- the NPV profile plot (see 'SofrXva.Plot') is written -- its @.dat@\/@.gp@ gnuplot
-- inputs land alongside it, under the same path with a different extension.
data FilePaths = FilePaths
  { fpCurve :: FilePath
  , fpQuotes :: [FilePath]
  , fpHistory :: FilePath
  , fpNpv :: FilePath
  , fpPlot :: FilePath
  }

defaultPaths :: FilePath -> FilePaths
defaultPaths dataDir = FilePaths
  { fpCurve = dataDir </> "curve.csv"
  , fpQuotes = [dataDir </> "quotes.csv"]
  , fpHistory = dataDir </> "history.csv"
  , fpNpv = dataDir </> "npv.csv"
  , fpPlot = dataDir </> "_NPVProfile.png"
  }

-- |The row-level name fields 'SofrXva.Data''s loaders filter by. A real data source
-- will use its own convention here; these generic defaults only match the synthetic
-- example fixtures, and are overridden via CLI flags for any other source.
data Names = Names
  { nCurve :: String
  , nQuote :: String
  , nQuoteFull :: String
  , nCptyCurve :: String
  , nOwnCurve :: String
  }

defaultNames :: Names
defaultNames = Names
  { nCurve = "USD.SOFR.1D"
  , nQuote = "USD.SOFR.1D"
  , nQuoteFull = "INDEX.USD.SOFR.1D"
  , nCptyCurve = "CPTY"
  , nOwnCurve = "OWN"
  }

data Args = Args
  { aDataDir :: FilePath
  , aExample :: Bool
  , aCurveFile :: Maybe FilePath
  , aQuoteFiles :: Maybe [FilePath]
  , aHistoryFile :: Maybe FilePath
  , aNpvFile :: Maybe FilePath
  , aPlotFile :: Maybe FilePath
  , aCurveName :: Maybe String
  , aQuoteName :: Maybe String
  , aQuoteNameFull :: Maybe String
  , aCptyCurveName :: Maybe String
  , aOwnCurveName :: Maybe String
  , aCptyRecovery :: Double
  , aOwnRecovery :: Double
  , aPercentile :: Double
  }

defaultArgs :: Args
defaultArgs = Args
  { aDataDir = "py"
  , aExample = False
  , aCurveFile = Nothing
  , aQuoteFiles = Nothing
  , aHistoryFile = Nothing
  , aNpvFile = Nothing
  , aPlotFile = Nothing
  , aCurveName = Nothing
  , aQuoteName = Nothing
  , aQuoteNameFull = Nothing
  , aCptyCurveName = Nothing
  , aOwnCurveName = Nothing
  , aCptyRecovery = 0.4
  , aOwnRecovery = 0.4
  , aPercentile = 0.95
  }

usage :: String
usage = "usage: sofr-xva [--data-dir DIR | --example] "
  ++ "[--curve-file FILE] [--quote-file FILE]... [--history-file FILE] [--npv-file FILE] "
  ++ "[--plot-file FILE] "
  ++ "[--curve-name NAME] [--quote-name NAME] [--quote-name-full NAME] "
  ++ "[--cpty-curve-name NAME] [--cpty-recovery R] [--own-curve-name NAME] [--own-recovery R] "
  ++ "[--percentile P]"

parseArgs :: [String] -> Args
parseArgs = go defaultArgs
  where
    go acc [] = acc
    go acc ("--data-dir" : d : rest) = go acc{aDataDir = d} rest
    go acc ("--example" : rest) = go acc{aExample = True} rest
    go acc ("--curve-file" : f : rest) = go acc{aCurveFile = Just f} rest
    go acc ("--quote-file" : f : rest) =
      go acc{aQuoteFiles = Just (concat (aQuoteFiles acc) ++ [f])} rest
    go acc ("--history-file" : f : rest) = go acc{aHistoryFile = Just f} rest
    go acc ("--npv-file" : f : rest) = go acc{aNpvFile = Just f} rest
    go acc ("--cpty-curve-name" : n : rest) = go acc{aCptyCurveName = Just n} rest
    go acc ("--own-curve-name" : n : rest) = go acc{aOwnCurveName = Just n} rest
    go acc ("--cpty-recovery" : r : rest) = go acc{aCptyRecovery = read r} rest
    go acc ("--own-recovery" : r : rest) = go acc{aOwnRecovery = read r} rest
    go acc ("--percentile" : p : rest) = go acc{aPercentile = read p} rest
    go acc ("--plot-file" : f : rest) = go acc{aPlotFile = Just f} rest
    go acc ("--curve-name" : n : rest) = go acc{aCurveName = Just n} rest
    go acc ("--quote-name" : n : rest) = go acc{aQuoteName = Just n} rest
    go acc ("--quote-name-full" : n : rest) = go acc{aQuoteNameFull = Just n} rest
    go _ _ = error usage

main :: IO ()
main = do
  args <- parseArgs <$> getArgs
  let dataDir = if aExample args then "app/SofrXva/example-data" else aDataDir args
      base = defaultPaths dataDir
      paths = base
        { fpCurve = fromMaybe (fpCurve base) (aCurveFile args)
        , fpQuotes = fromMaybe (fpQuotes base) (aQuoteFiles args)
        , fpHistory = fromMaybe (fpHistory base) (aHistoryFile args)
        , fpNpv = fromMaybe (fpNpv base) (aNpvFile args)
        , fpPlot = fromMaybe (fpPlot base) (aPlotFile args)
        }
      names = defaultNames
        { nCurve = fromMaybe (nCurve defaultNames) (aCurveName args)
        , nQuote = fromMaybe (nQuote defaultNames) (aQuoteName args)
        , nQuoteFull = fromMaybe (nQuoteFull defaultNames) (aQuoteNameFull args)
        , nCptyCurve = fromMaybe (nCptyCurve defaultNames) (aCptyCurveName args)
        , nOwnCurve = fromMaybe (nOwnCurve defaultNames) (aOwnCurveName args)
        }

  curveMap <- loadSofrCurve (fpCurve paths) (nCurve names)
  quoteMap <- loadSofrQuote (fpQuotes paths) (nQuote names)
  histMap <- loadIndexHist (fpHistory paths) (nQuoteFull names)
  refNpvMap <- loadNpvComparison (fpNpv paths)
  cptyCurveMap <- loadSofrCurve (fpCurve paths) (nCptyCurve names)
  ownCurveMap <- loadSofrCurve (fpCurve paths) (nOwnCurve names)

  profile <- buildSofrProfile curveMap histMap quoteMap
  let npvMap = spNpvs profile

  xva <- computeXva npvMap curveMap (spT0DiscountCurve profile)
    (snd (cptyCurveMap Map.! (0, 0))) (snd (ownCurveMap Map.! (0, 0)))
    (aCptyRecovery args) (aOwnRecovery args) (aPercentile args)
  printf "sofr-xva: CVA = %.6f, DVA = %.6f (percentile=%.2f)\n"
    (xvaCva xva) (xvaDva xva) (aPercentile args)
  writeFile (dataDir </> "_PFE.csv") $ unlines $
    "TS,Date,PFE" : [ printf "%d,%s,%.10f" ts (show d) v
                    | (ts, (d, v)) <- zip [(0 :: Int) ..] (xvaPfe xva) ]

  plotNpvProfile (fpPlot paths) npvMap (map snd (xvaPfe xva))

  -- refNpvMap is keyed (TS,Scen) (loadNpvComparison's file layout), npvMap (Scen,TS)
  -- (buildSofrProfile's natural iteration order) -- transpose here rather than in
  -- either loader/pricer, since each key order is the natural one for its own file.
  let diffs = [ ((scen, ts), abs ((ref - v) / ref))
              | ((scen, ts), v) <- Map.toList npvMap
              , Just ref <- [Map.lookup (ts, scen) refNpvMap]
              ]

  if null diffs
    then putStrLn "sofr-xva: no (scen,ts) pairs overlapped the reference NPV file"
    else do
      -- a handful of reference cells are 0 (trade fully rolled off in that scenario),
      -- which blows the relative diff up to Infinity/NaN -- exclude them from the
      -- summary stats here, but still write every cell to the CSV.
      let rels = filter (not . isNaN) [d | (_, d) <- diffs, isFinite d]
          isFinite d = not (isNaN d) && not (isInfinite d)
          worst = take 10 (sortOn (negate . snd) [dd | dd@(_, d) <- diffs, isFinite d])
      printf "sofr-xva: %d (scen,ts) pairs priced, %d compared against reference\n"
        (Map.size npvMap) (length diffs)
      unless (null rels) $ do
        printf "  max relative diff  = %.6f\n" (maximum rels)
        printf "  mean relative diff = %.6f\n" (sum rels / fromIntegral (length rels))
      putStrLn "  worst 10 (excluding non-finite):"
      forM_ worst $ \((scen, ts), d) ->
        printf "    scen=%d ts=%d relDiff=%.6f\n" scen ts d

  writeFile (dataDir </> "_NPVDiff.csv") $ unlines $
    "TS,Scen,RelDiff" : [ printf "%d,%d,%.10f" ts scen d | ((scen, ts), d) <- sortOn fst diffs ]