packages feed

hasquant-0.7.0.0: app/SofrXva/Plot.hs

-- |Renders NPV-by-timestep lines and an optional PFE profile with @gnuplot@. A plotting failure warns but does not fail the program; the summary and CSV remain available.
module SofrXva.Plot
  ( plotNpvProfile
  ) where

import Control.Exception (IOException, try)
import Data.List (groupBy, sortOn)
import Data.Function (on)
import qualified Data.Map.Strict as Map
import System.FilePath (replaceExtension)
import System.Process (readProcessWithExitCode)
import System.Exit (ExitCode(..))
import Text.Printf (printf)

-- |Writes the @.dat@\/@.gp@ gnuplot inputs next to 'pngPath' (same path, @.dat@\/@.gp@
-- extensions) and invokes @gnuplot@ on the script to render 'pngPath' itself. 'pfe',
-- if non-empty, is the percentile PFE profile (see 'SofrXva.Xva.XvaResult'), one
-- value per canonical timestep in timestep order -- it is overlaid as an extra
-- dashed line on top of the per-scenario NPV lines.
plotNpvProfile :: FilePath -> Map.Map (Int, Int) Double -> [Double] -> IO ()
plotNpvProfile pngPath npvMap pfe = do
  let byScen = groupBy ((==) `on` fst . fst) (sortOn fst (Map.toList npvMap))
      datPath = replaceExtension pngPath "dat"
      pfeDatPath = replaceExtension pngPath "pfe.dat"
      gpPath = replaceExtension pngPath "gp"
      -- gnuplot separates "index"-addressable data blocks with a *pair* of blank
      -- lines, not one -- a single blank line only breaks line continuity within a
      -- block, so each block ends with an extra blank line of its own on top of the
      -- one 'unlines' inserts between list elements.
      block scen = unlines [printf "%d %.10f" ts v | ((_, ts), v) <- scen] ++ "\n"
      datContent = unlines (map block byScen)
      pfeDatContent = unlines [printf "%d %.10f" ts v | (ts, v) <- zip [(0 :: Int) ..] pfe]
      -- A wide-below legend beats the default right-hand-side one once there are more
      -- than a handful of scenarios -- one column per scenario runs off the canvas, so
      -- wrap into a grid instead, sized to fit under the plot rather than beside it.
      -- The extra canvas height keeps the plot area itself from being squeezed as the
      -- legend grid grows with the scenario count.
      nScen = length byScen
      keyCols = max 1 (min 10 nScen)
      keyRows = (nScen + keyCols - 1) `div` keyCols
      height = 600 + 16 * keyRows
      npvPlot = printf
        "for [i=0:%d] '%s' index i using 1:2 with lines title sprintf('scen %%d', i)"
        (nScen - 1) datPath
      pfePlot = printf ", '%s' using 1:2 with lines dashtype 2 lw 2 lc rgb 'red' title 'PFE'"
        pfeDatPath
      plotLine = "plot " ++ npvPlot ++ (if null pfe then "" else pfePlot)
      script = unlines
        [ printf "set terminal png size 800,%d" (height :: Int)
        , printf "set output '%s'" pngPath
        , "set title 'SOFR-OIS NPV profile'"
        , "set xlabel 'Timestep'"
        , "set ylabel 'NPV'"
        , printf "set key below maxcols %d font ',8'" keyCols
        , plotLine
        ]

  writeFile datPath datContent
  writeFile gpPath script
  if null pfe then pure () else writeFile pfeDatPath pfeDatContent

  result <- try (readProcessWithExitCode "gnuplot" [gpPath] "") :: IO (Either IOException (ExitCode, String, String))
  case result of
    Left e -> putStrLn ("sofr-xva: gnuplot plot skipped: " ++ show e)
    Right (ExitSuccess, _, _) -> putStrLn ("sofr-xva: wrote NPV profile plot to " ++ pngPath)
    Right (ExitFailure _, _, stderrOut) ->
      putStrLn ("sofr-xva: gnuplot plot skipped: " ++ stderrOut)