packages feed

hasquant-0.7.0.0: app/SofrXva/Data.hs

-- |Ad-hoc loaders for the fixed vendor-specific CSV layouts consumed by the sofr-xva
-- pipeline (curve dumps, quote dumps, historical fixings, and the reference NPV file).
-- Each loader filters rows by name/type in a single pass over its file(s) --
-- there is no general dataframe machinery here, deliberately: this is interim, targeted
-- only at these exact layouts, and expected to be superseded by proper dataframe support
-- later.
module SofrXva.Data
  ( loadSofrCurve
  , loadSofrQuote
  , loadIndexHist
  , loadNpvComparison
  ) where

import Data.Time.Calendar (Day)
import Data.List.NonEmpty (NonEmpty(..))
import qualified Data.Map.Strict as Map

import SofrXva.Csv (parseDMY, splitComma)

-- |Curve dump rows look like:
-- @Scen,\<n\>,TS,\<n\>,\<valDate\>,CurveDate|CurvePoint,\<type\>,\<id\>,\<name\>,\<data...\>@
-- A @CurveDate@ row (pillar dates) is immediately followed by a @CurvePoint@ row (matching
-- discount factors) for the same (scenario, timestep). Returns, per (scenario, timestep),
-- the valuation date and its (pillar date, discount factor) pairs -- with a synthetic
-- @(valDate, 1.0)@ point prepended, since the source discount factors are relative to
-- the valuation date rather than including it.
loadSofrCurve :: FilePath -> String -> IO (Map.Map (Int, Int) (Day, NonEmpty (Day, Double)))
loadSofrCurve path curveName = do
  rows <- curveRows curveName <$> readFile path
  pure (Map.fromList (pairCurveRows rows))

data CurveRow = CurveRow
  { crScen :: !Int
  , crTS :: !Int
  , crValDate :: !Day
  , crCol :: !String -- ^"CurveDate" or "CurvePoint"
  , crData :: ![String]
  }

curveRows :: String -> String -> [CurveRow]
curveRows curveName content =
  [ CurveRow scen ts valDate col rest
  | l <- lines content
  , let fs = splitComma l
  , length fs > 9
  , fs !! 8 == curveName
  , let scen = read (fs !! 1)
  , let ts = read (fs !! 3)
  , let valDate = parseDMY (fs !! 4)
  , let col = fs !! 5
  , let rest = drop 9 fs
  ]

pairCurveRows :: [CurveRow] -> [((Int, Int), (Day, NonEmpty (Day, Double)))]
pairCurveRows rows =
  [ ((crScen dr, crTS dr), (crValDate dr, (crValDate dr, 1.0) :| zip (map parseDMY (crData dr)) (map read (crData pr))))
  | (dr, pr) <- zip rows (drop 1 rows)
  , crCol dr == "CurveDate", crCol pr == "CurvePoint"
  , crScen dr == crScen pr, crTS dr == crTS pr
  ]

-- |Quote dump rows look like:
-- @Scen,\<n\>,TS,\<n\>,\<tsDate\>,Quote,\<name\>,\<date\>,\<type\>,\<value\>@
-- Returns the quote value keyed by (scenario, timestep, quote date), merged across all
-- the given files (the real data is split across several @*_1\/_2\/_3.csv@ files).
loadSofrQuote :: [FilePath] -> String -> IO (Map.Map (Int, Int, Day) Double)
loadSofrQuote paths quoteName = do
  contents <- mapM readFile paths
  pure (Map.fromList (concatMap (quoteRows quoteName) contents))

quoteRows :: String -> String -> [((Int, Int, Day), Double)]
quoteRows quoteName content =
  [ ((scen, ts, date), value)
  | l <- lines content
  , let fs = splitComma l
  , length fs > 9
  , fs !! 6 == quoteName
  , let scen = read (fs !! 1)
  , let ts = read (fs !! 3)
  , let date = parseDMY (fs !! 7)
  , let value = read (fs !! 9)
  ]

-- |Historical fixing scale: a "Yield" quote type is stored as a percentage and must be
-- divided by 100; a "Price" quote is already a plain number.
quoteScale :: String -> Double
quoteScale "Yield" = 100
quoteScale "Price" = 1
quoteScale t = error ("SofrXva.Data.quoteScale: unrecognised quote type " ++ show t)

-- |Historical fixing file: a real header row, then
-- @Date,Quote Name,Quote Type,Bid,Ask,Open,Close,High,Low,Last,...@. Returns the (scaled)
-- @Close@ value keyed by date, for rows whose @Quote Name@ matches.
loadIndexHist :: FilePath -> String -> IO (Map.Map Day Double)
loadIndexHist path histName = do
  ls <- lines <$> readFile path
  pure $ Map.fromList
    [ (date, value)
    | l <- drop 1 ls -- skip header
    , dateField : nameField : typeField : _bid : _ask : _open : closeField : _ <- [splitComma l]
    , nameField == histName
    , let date = parseDMY dateField
    , let value = read closeField / quoteScale typeField
    ]

-- |Reference NPV file: @NettingKey: ...@, then a header row
-- @TimeStep_down | Scen_across, \<scen0\>,\<scen1\>,...@, then rows
-- @\"\<ts\>: \<date\>\",v0,v1,...@ (one value per header scenario, in order). Rows past
-- a trade's maturity may carry fewer values than there are scenarios; those trailing
-- scenarios are simply absent from the result for that timestep.
loadNpvComparison :: FilePath -> IO (Map.Map (Int, Int) Double)
loadNpvComparison path = do
  ls <- lines <$> readFile path
  case ls of
    (_nettingKey : headerLine : dataLines) ->
      let scenIds = map read (drop 1 (splitComma headerLine)) :: [Int]
      in pure $ Map.fromList
           [ ((ts, scen), v)
           | l <- dataLines
           , not (null l)
           , tsField : vals <- [splitComma l]
           , let ts = read (takeWhile (/= ':') tsField)
           , (scen, v) <- zip scenIds (map read vals)
           ]
    _ -> error ("SofrXva.Data.loadNpvComparison: malformed file " ++ path)