diff --git a/app/Features/Positions/PositionsTable.hs b/app/Features/Positions/PositionsTable.hs new file mode 100644 index 0000000..6509e79 --- /dev/null +++ b/app/Features/Positions/PositionsTable.hs @@ -0,0 +1,104 @@ +module Features.Positions.PositionsTable + ( createPositionsTable, + ) +where + +import Calc + ( calcPositionCostBasis, + calcPositionReturnPct, + calcPositionUnrealizedProfit, + ) +import Data.Text (Text) +import qualified Data.Text as T +import PrettyPrint + ( createPrettyTable, + doubleToText, + moneyToText, + ) +import Text.Printf (printf) +import Types.Money (Money (..)) +import Types.Position (Position (..)) +import Types.PrintableCell + ( FixedWidth (FixedWidth, l, s), + PrintableCell + ( textAlign, + width + ), + TextAlign + ( LeftAlign, + RightAlign + ), + createCellFromText, + createFilledCell, + createFixedCell, + ) +import Types.UiSize (UiSize) + +createPositionsTable :: UiSize -> Int -> [Position] -> Text +createPositionsTable uiSize termWidth positions = + let header = positionsHeader + contentRows = map (positionRowToCells header) positions + content = contentRows ++ [totalsRow header positions] + spacing = map width header + in createPrettyTable uiSize termWidth header content spacing + +-- | Konto | Namn | Antal | GAV (kr) | Värde (kr) | Ansk. (kr) | Vinst (kr) | Avkastn. % | +positionsHeader :: [PrintableCell] +positionsHeader = + [ createFixedCell "Konto" FixedWidth {s = 12, l = Just 14} LeftAlign, + createFilledCell "Namn" LeftAlign, + createFixedCell "Antal" FixedWidth {s = 8, l = Just 10} RightAlign, + createFixedCell "GAV (kr)" FixedWidth {s = 10, l = Just 12} RightAlign, + createFixedCell "Värde (kr)" FixedWidth {s = 10, l = Just 13} RightAlign, + createFixedCell "Ansk. (kr)" FixedWidth {s = 10, l = Just 13} RightAlign, + createFixedCell "Vinst (kr)" FixedWidth {s = 10, l = Just 13} RightAlign, + createFixedCell "Avkastn. %" FixedWidth {s = 9, l = Just 11} RightAlign + ] + +positionRowToCells :: [PrintableCell] -> Position -> [PrintableCell] +positionRowToCells header p = + rowToCells + header + [ account p, + name p, + doubleToText (volume p), + moneyToText (gavSek p), + moneyToText (marketValue p), + moneyToText (calcPositionCostBasis p), + moneyToText (calcPositionUnrealizedProfit p), + percentToText (calcPositionReturnPct p) + ] + +-- A summary line totalling market value, cost basis and unrealized profit across +-- every holding, plus the overall return. +totalsRow :: [PrintableCell] -> [Position] -> [PrintableCell] +totalsRow header positions = + let totalValue = sum (map marketValue positions) + totalCost = sum (map calcPositionCostBasis positions) + totalProfit = totalValue - totalCost + totalPct = + if unMoney totalCost == 0 + then Nothing + else Just (unMoney totalProfit / unMoney totalCost * 100) + in rowToCells + header + [ T.pack "Totalt", + T.empty, + T.empty, + T.empty, + moneyToText totalValue, + moneyToText totalCost, + moneyToText totalProfit, + percentToText totalPct + ] + +rowToCells :: [PrintableCell] -> [Text] -> [PrintableCell] +rowToCells header texts = + zipWith + (\txt h -> createCellFromText txt (width h) (textAlign h)) + texts + header + +percentToText :: Maybe Double -> Text +percentToText Nothing = T.pack "-" +percentToText (Just p) = T.pack (printf "%.2f%%" p) diff --git a/app/Main.hs b/app/Main.hs index 9f46c3c..36de272 100644 --- a/app/Main.hs +++ b/app/Main.hs @@ -40,10 +40,12 @@ import Features.GroupByCompany.GroupByCompanyArgParse (sortableColumnToStr) import qualified Features.GroupByCompany.GroupByCompanyArgParse as GbcArgParse import Features.GroupByCompany.GroupByCompanyTable (createGroupByCompanyTable) import qualified Features.GroupByCompany.GroupByCompanyTypes as GBC -import Parse (parseCsvData) +import Features.Positions.PositionsTable (createPositionsTable) +import Parse (parseCsvData, parsePositionCsvData) import qualified PrettyPrint as PP import System.Environment (getArgs) import Types.CLITypes (CommandLineOption (..), FlagDoc (..)) +import Types.Position (Position) import Types.Transaction.GenericTransaction ( Transaction, ) @@ -87,7 +89,13 @@ mainCommands = flagDescriptionRows = ["Limit number of rows (must be > 1)"] } ] - printGroupByComany + printGroupByComany, + CommandLineOption + "positions" + "pos" + "Print current holdings with unrealized profit/loss" + [] + printPositions ] where createGbcSortOpts xs = List.intercalate ", " (map sortableColumnToStr xs) @@ -139,8 +147,26 @@ printGroupByComany cliFlags filePath = do T.putStr printContent +printPositions :: [String] -> FilePath -> IO () +printPositions cliFlags filePath = do + positions <- readPositions filePath + termWidth <- getAdjustedTerminalWidth + let uiSize = calcUiSize termWidth + let printContent = + createPositionsTable + uiSize + (fromMaybe 0 termWidth) + positions + + T.putStr printContent + readCsv :: FilePath -> IO [Transaction] readCsv filePath = do csvData <- BSL.readFile filePath let rows = parseCsvData csvData return rows + +readPositions :: FilePath -> IO [Position] +readPositions filePath = do + csvData <- BSL.readFile filePath + return (parsePositionCsvData csvData) diff --git a/hava.cabal b/hava.cabal index 129cb4a..096008c 100644 --- a/hava.cabal +++ b/hava.cabal @@ -28,6 +28,7 @@ library Features.GroupByCompany.GroupByCompanyArgParse Features.GroupByCompany.GroupByCompanyTable Features.GroupByCompany.GroupByCompanyTypes + Features.Positions.PositionsTable Main Calc CLIHelp @@ -35,6 +36,7 @@ library PrettyPrint Types.CLITypes Types.Money + Types.Position Types.PrintableCell Types.Table.SortOption Types.Transaction.GenericTransaction @@ -77,6 +79,7 @@ executable hava-exe Features.GroupByCompany.GroupByCompanyArgParse Features.GroupByCompany.GroupByCompanyTable Features.GroupByCompany.GroupByCompanyTypes + Features.Positions.PositionsTable Paths_hava hs-source-dirs: app diff --git a/src/Calc.hs b/src/Calc.hs index b99458f..5f4849b 100644 --- a/src/Calc.hs +++ b/src/Calc.hs @@ -9,6 +9,9 @@ module Calc calcRemainingAmount, calcSellProfit, calcTotalDividendProfit, + calcPositionCostBasis, + calcPositionUnrealizedProfit, + calcPositionReturnPct, ) where @@ -31,6 +34,7 @@ import Debug.Trace traceShowId, ) import Types.Money (Money (..)) +import Types.Position (Position (..)) import Types.Transaction.GenericTransaction ( Transaction, getAction, @@ -188,3 +192,23 @@ calcRemainingAmount = sum . map getQuantity' calcTotalDividendProfit :: [TransactionDividend] -> Money calcTotalDividendProfit = sum . map getAmount + +-- Position (current-holding) calculations. +-- +-- Cost basis uses the SEK average cost ("GAV (SEK)") so it shares a currency +-- with "Marknadsvärde" (also SEK); the plain "GAV" is in the instrument's own +-- currency for foreign holdings and would mismatch. +calcPositionCostBasis :: Position -> Money +calcPositionCostBasis p = Money (volume p * unMoney (gavSek p)) + +calcPositionUnrealizedProfit :: Position -> Money +calcPositionUnrealizedProfit p = marketValue p - calcPositionCostBasis p + +-- 'Nothing' when the cost basis is zero (free shares, renamed company, …) so the +-- caller can render it as "-" rather than dividing by zero. +calcPositionReturnPct :: Position -> Maybe Double +calcPositionReturnPct p = + let cost = unMoney (calcPositionCostBasis p) + in if cost == 0 + then Nothing + else Just (unMoney (calcPositionUnrealizedProfit p) / cost * 100) diff --git a/src/Parse.hs b/src/Parse.hs index c0b16e1..c1b372e 100644 --- a/src/Parse.hs +++ b/src/Parse.hs @@ -4,6 +4,7 @@ module Parse ( parseCsvData, + parsePositionCsvData, ) where @@ -32,13 +33,14 @@ import Data.Word (Word8) import System.Exit (die) -- Types import Types.Money (Money (..)) +import Types.Position (Position, expectedPositionHeaders) import Types.Transaction.GenericTransaction ( GenericTransaction (..), Transaction (..), ) -expectedHeaders :: [BSU.ByteString] -expectedHeaders = +expectedTransactionHeaders :: [BSU.ByteString] +expectedTransactionHeaders = map BSU.fromString [ "Datum", @@ -71,11 +73,21 @@ instance FromNamedRecord Transaction where -- Note: "resultat" avilable but not parsed at this time parseCsvData :: BSL.ByteString -> [Transaction] -parseCsvData csvData = +parseCsvData = parseCsvWith expectedTransactionHeaders + +parsePositionCsvData :: BSL.ByteString -> [Position] +parsePositionCsvData = parseCsvWith expectedPositionHeaders + +-- | Decode a ';'-delimited Avanza CSV export into a list of records of any +-- format with a 'FromNamedRecord' instance. Strips a leading UTF-8 BOM and +-- validates the header against the format's expected columns before decoding. +parseCsvWith :: + (FromNamedRecord a) => [BSU.ByteString] -> BSL.ByteString -> [a] +parseCsvWith expectedHeaders csvData = let withoutBom = dropBOM csvData header = readHeader withoutBom decOptions = defaultDecodeOptions {decDelimiter = fromIntegral (ord ';')} - in case checkHeadersStrict header of + in case checkHeadersStrict expectedHeaders header of Just msg -> error msg Nothing -> case decodeByNameWith decOptions withoutBom of Left err -> error err @@ -91,13 +103,13 @@ readHeader bs = let (hdr, _) = BSL.break (== nl) bs in BS.split semi (BSL.toStrict hdr) -checkHeadersStrict :: [BS.ByteString] -> Maybe String -checkHeadersStrict found = - if all (`elem` found) expectedHeaders +checkHeadersStrict :: [BS.ByteString] -> [BS.ByteString] -> Maybe String +checkHeadersStrict expected found = + if all (`elem` found) expected then Nothing else - let missing = filter (`notElem` found) expectedHeaders - extra = filter (`notElem` expectedHeaders) found + let missing = filter (`notElem` found) expected + extra = filter (`notElem` expected) found pretty = L.intercalate ", " s = map BSU.toString in Just $ @@ -105,7 +117,7 @@ checkHeadersStrict found = [ "Unexpected CSV header.", "Missing: " <> pretty (s missing), "Extra: " <> pretty (s extra), - "Expected headers:\n" <> unlines (map (" " <>) (s expectedHeaders)), + "Expected headers:\n" <> unlines (map (" " <>) (s expected)), "Found headers:\n" <> unlines (map (" " <>) (s found)) ] diff --git a/src/PrettyPrint.hs b/src/PrettyPrint.hs index 7ddef0a..c3d6a13 100644 --- a/src/PrettyPrint.hs +++ b/src/PrettyPrint.hs @@ -6,6 +6,7 @@ module PrettyPrint createPrettyTable, intToText, moneyToText, + doubleToText, ) where @@ -117,6 +118,16 @@ intToText = T.pack . show moneyToText :: Money -> Text moneyToText = T.pack . printf "%.2f" . unMoney +-- Render a Double for display: whole numbers print without decimals (e.g. a +-- stock volume of 10 -> "10"), fractional values keep up to 4 decimals (e.g. a +-- fund volume -> "12.3456"). +doubleToText :: Double -> Text +doubleToText d + | d == fromIntegral r = T.pack (show r) + | otherwise = T.pack (printf "%.4f" d) + where + r = round d :: Integer + -- General utility functions createTableRow :: UiSize -> [Int] -> [PrintableCell] -> Text diff --git a/src/Types/Position.hs b/src/Types/Position.hs new file mode 100644 index 0000000..50c7154 --- /dev/null +++ b/src/Types/Position.hs @@ -0,0 +1,88 @@ +{-# LANGUAGE FlexibleInstances #-} + +-- | A single row of Avanza's per-account holdings ("positioner") export — i.e. +-- one current position held in one account. The same instrument can therefore +-- appear in several rows (once per account it's held in), distinguished by +-- 'account' (Kontonummer). +module Types.Position + ( Position (..), + expectedPositionHeaders, + ) +where + +import qualified Data.ByteString.UTF8 as BSU +import Data.Csv + ( FromNamedRecord (..), + (.:), + ) +import Data.Text (Text) +import Types.Money (Money) +import Types.UtilTypes + ( parseSvDouble, + parseSvMoney, + ) + +data Position = Position + { account :: !Text, -- Kontonummer + name :: !Text, -- Namn + shortName :: !Text, -- Kortnamn + volume :: !Double, -- Volym (fractional for funds) + marketValue :: !Money, -- Marknadsvärde + gavSek :: !Money, -- GAV (SEK) + gav :: !Money, -- GAV + currency :: !Text, -- Valuta + country :: !Text, -- Land + isin :: !Text, -- ISIN + market :: !Text, -- Marknad + instrumentType :: !Text -- Typ (e.g. "STOCK", "FUND") + } + deriving (Show, Eq) + +expectedPositionHeaders :: [BSU.ByteString] +expectedPositionHeaders = + map + BSU.fromString + [ "Kontonummer", + "Namn", + "Kortnamn", + "Volym", + "Marknadsvärde", + "GAV (SEK)", + "GAV", + "Valuta", + "Land", + "ISIN", + "Marknad", + "Typ" + ] + +instance FromNamedRecord Position where + parseNamedRecord r = do + account <- r .: "Kontonummer" + name <- r .: "Namn" + shortName <- r .: "Kortnamn" + volumeRaw <- r .: "Volym" + -- "Marknadsvärde" contains a non-ASCII character, so the column name must be + -- built as UTF-8 bytes rather than via OverloadedStrings (which would pack + -- it as Latin-1 and fail to match the header). + marketValueRaw <- r .: BSU.fromString "Marknadsvärde" + gavSekRaw <- r .: "GAV (SEK)" + gavRaw <- r .: "GAV" + currency <- r .: "Valuta" + country <- r .: "Land" + isin <- r .: "ISIN" + market <- r .: "Marknad" + instrumentType <- r .: "Typ" + + volume <- parseField "Volym" parseSvDouble volumeRaw + marketValue <- parseField "Marknadsvärde" parseSvMoney marketValueRaw + gavSek <- parseField "GAV (SEK)" parseSvMoney gavSekRaw + gav <- parseField "GAV" parseSvMoney gavRaw + + return Position {..} + where + parseField column parse raw = + maybe + (fail $ "Failed to parse \"" <> column <> "\" from value: " <> show raw) + return + (parse raw) diff --git a/src/Types/UtilTypes.hs b/src/Types/UtilTypes.hs index ec0d37e..c424551 100644 --- a/src/Types/UtilTypes.hs +++ b/src/Types/UtilTypes.hs @@ -9,6 +9,9 @@ module Types.UtilTypes parseDouble, parseMoney, parseMoneyDefault0, + normalizeSvNumber, + parseSvDouble, + parseSvMoney, SortedByDateList (..), ) where @@ -41,6 +44,22 @@ parseMoneyDefault0 t = case parseMoney t of Just m -> m Nothing -> Money 0.0 +-- Avanza's "consolidated holdings" export formats numbers in the Swedish locale: +-- a comma as the decimal separator and (for large values) spaces as thousands +-- separators, e.g. "1 234 567,89". This normalizes such a value into something +-- 'parseDouble' understands by dropping the (possibly non-breaking) spaces and +-- swapping the decimal comma for a dot. +normalizeSvNumber :: Text -> Text +normalizeSvNumber = + T.replace (T.pack ",") (T.pack ".") + . T.filter (\c -> c /= ' ' && c /= '\160') + +parseSvDouble :: Text -> Maybe Double +parseSvDouble = parseDouble . normalizeSvNumber + +parseSvMoney :: Text -> Maybe Money +parseSvMoney t = Money <$> parseSvDouble t + newtype SortedByDateList a = SortedByDateList {getSortedByDateList :: [a]} deriving (Show, Eq, Ord, Functor, Foldable) diff --git a/test/CalcSpec.hs b/test/CalcSpec.hs index 6edc9d9..3552b5b 100644 --- a/test/CalcSpec.hs +++ b/test/CalcSpec.hs @@ -11,7 +11,8 @@ import Test.Hspec it, shouldBe, ) -import Types.Money (Money) +import Types.Money (Money (..)) +import Types.Position (Position (..)) import Types.Transaction.GenericTransaction ( GenericTransaction (..), ) @@ -44,6 +45,7 @@ spec = do describe "Calculate total sell and dividend profit for company" testCalcTotalProfitForCompany + describe "Calculate position profit" testCalcPositionProfit testCalcSellProfit :: Spec testCalcSellProfit = do @@ -167,3 +169,37 @@ createSplitRowWithPlaceHolders qty = } where placeHolder = T.pack "placeHolder" + +testCalcPositionProfit :: Spec +testCalcPositionProfit = do + it "computes cost basis as volume * GAV (SEK)" $ do + C.calcPositionCostBasis (positionWith 10 (Money 100) (Money 1200)) + `shouldBe` Money 1000 + it "computes unrealized profit as market value - cost basis" $ do + C.calcPositionUnrealizedProfit (positionWith 10 (Money 100) (Money 1200)) + `shouldBe` Money 200 + it "computes return as profit / cost basis in percent" $ do + C.calcPositionReturnPct (positionWith 10 (Money 100) (Money 1200)) + `shouldBe` Just 20.0 + it "returns Nothing for a zero cost basis (avoids divide by zero)" $ do + C.calcPositionReturnPct (positionWith 0 (Money 0) (Money 50)) + `shouldBe` Nothing + +positionWith :: Double -> Money -> Money -> Position +positionWith vol gavSekVal marketVal = + Position + { account = placeHolder, + name = placeHolder, + shortName = placeHolder, + volume = vol, + marketValue = marketVal, + gavSek = gavSekVal, + gav = gavSekVal, + currency = placeHolder, + country = placeHolder, + isin = placeHolder, + market = placeHolder, + instrumentType = placeHolder + } + where + placeHolder = T.pack "placeHolder" diff --git a/test/ParseSpec.hs b/test/ParseSpec.hs index 86d87ff..d2b1629 100644 --- a/test/ParseSpec.hs +++ b/test/ParseSpec.hs @@ -11,6 +11,8 @@ import qualified Data.Text as T import qualified Parse as P import Test.Hspec import TestUtils (shouldThrowErrorContaining) +import Types.Money (Money (..)) +import Types.Position (Position (..)) import Types.Transaction.GenericTransaction (GenericTransaction (..)) import Types.Transaction.TransactionBuySell (transformToBuySell) @@ -18,6 +20,7 @@ spec :: Spec spec = do describe "test buy transaction" testParseBuy describe "test basic parsing" testBasicParsing + describe "test position parsing" testParsePosition testBasicParsing :: Spec testBasicParsing = do @@ -86,6 +89,46 @@ testParseBuy = do let parsedBuySellTransaction = transformToBuySell $ head parsedGenericTransaction parsedBuySellTransaction `shouldSatisfy` isJust +testParsePosition :: Spec +testParsePosition = do + it "parses a per-account holdings row with Swedish decimal commas" $ do + let csv = + stringsToCsvByteString + [ positionHeader, + "0000-0000000;Acme Corp;ACME;10;1234,50;100,00;100,00;SEK;SE;SE0000000001;XSTO;STOCK" + ] + let expected = + [ Position + { account = T.pack "0000-0000000", + name = T.pack "Acme Corp", + shortName = T.pack "ACME", + volume = 10, + marketValue = Money 1234.50, + gavSek = Money 100.00, + gav = Money 100.00, + currency = T.pack "SEK", + country = T.pack "SE", + isin = T.pack "SE0000000001", + market = T.pack "XSTO", + instrumentType = T.pack "STOCK" + } + ] + P.parsePositionCsvData csv `shouldBe` expected + + it "fails with a friendly message if a required header is missing" $ do + let badHeader = + "Kontonummer-with-extra-chars;Namn;Kortnamn;Volym;Marknadsvärde;GAV (SEK);GAV;Valuta;Land;ISIN;Marknad;Typ" + let csv = stringsToCsvByteString [badHeader] + + evaluate (P.parsePositionCsvData csv) + `shouldThrowErrorContaining` "Unexpected CSV header." + + evaluate (P.parsePositionCsvData csv) + `shouldThrowErrorContaining` "Missing: Kontonummer" + +positionHeader :: String +positionHeader = "Kontonummer;Namn;Kortnamn;Volym;Marknadsvärde;GAV (SEK);GAV;Valuta;Land;ISIN;Marknad;Typ" + csvHeader :: String csvHeader = "Datum;Konto;Typ av transaktion;Värdepapper/beskrivning;Antal;Kurs;Belopp;Courtage;Valuta;ISIN" diff --git a/test/TablesSpec.hs b/test/TablesSpec.hs index 97ddc11..68fd119 100644 --- a/test/TablesSpec.hs +++ b/test/TablesSpec.hs @@ -4,8 +4,10 @@ module TablesSpec where import Data.Text (Text) +import qualified Data.Text as T import Features.BuySell.BuySellTable (createBuySellTable) import Features.GroupByCompany.GroupByCompanyTable (createGroupByCompanyTable) +import Features.Positions.PositionsTable (createPositionsTable) import Test.Hspec ( Spec, describe, @@ -13,12 +15,15 @@ import Test.Hspec shouldReturn, shouldSatisfy, ) +import Types.Money (Money (..)) +import Types.Position (Position (..)) import Types.UiSize (UiSize (..)) spec :: Spec spec = do describe "Buy/sell table" testBuySellTable describe "Group-by-company table" testGroupByCompanyTable + describe "Positions table" testPositionsTable testBuySellTable :: Spec testBuySellTable = do @@ -40,5 +45,30 @@ testGroupByCompanyTable = do table `shouldSatisfy` isText +testPositionsTable :: Spec +testPositionsTable = do + it "should produce text" $ do + let uiSize = Small + let termWidth = 100 + let rows = + [ Position + { account = T.pack "0000-0000000", + name = T.pack "Acme Corp", + shortName = T.pack "ACME", + volume = 10, + marketValue = Money 1200, + gavSek = Money 100, + gav = Money 100, + currency = T.pack "SEK", + country = T.pack "SE", + isin = T.pack "SE0000000001", + market = T.pack "XSTO", + instrumentType = T.pack "STOCK" + } + ] + let table = createPositionsTable uiSize termWidth rows + + table `shouldSatisfy` isText + isText :: Text -> Bool isText _ = True