From 2a2b5c753ecc14c81f6d76b81e93e707f1c3360e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tabacsk=C3=B3?= Date: Fri, 29 May 2026 23:04:19 +0200 Subject: [PATCH 1/3] feat: parse Avanza consolidated-holdings (positions) export --- hava.cabal | 1 + src/Parse.hs | 32 +++++++++++----- src/Types/Position.hs | 86 ++++++++++++++++++++++++++++++++++++++++++ src/Types/UtilTypes.hs | 19 ++++++++++ test/ParseSpec.hs | 42 +++++++++++++++++++++ 5 files changed, 170 insertions(+), 10 deletions(-) create mode 100644 src/Types/Position.hs diff --git a/hava.cabal b/hava.cabal index 129cb4a..8620f05 100644 --- a/hava.cabal +++ b/hava.cabal @@ -35,6 +35,7 @@ library PrettyPrint Types.CLITypes Types.Money + Types.Position Types.PrintableCell Types.Table.SortOption Types.Transaction.GenericTransaction 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/Types/Position.hs b/src/Types/Position.hs new file mode 100644 index 0000000..9c4d19c --- /dev/null +++ b/src/Types/Position.hs @@ -0,0 +1,86 @@ +{-# LANGUAGE FlexibleInstances #-} + +-- | A single row of Avanza's "Mitt sammanställda innehav" (consolidated +-- holdings) export — i.e. one current position in the portfolio. This is a +-- different shape from the transaction history (see +-- "Types.Transaction.GenericTransaction"); the two share only the ISIN, which +-- is the natural key for joining them later on. +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 + { 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 + [ "Namn", + "Kortnamn", + "Volym", + "Marknadsvärde", + "GAV (SEK)", + "GAV", + "Valuta", + "Land", + "ISIN", + "Marknad", + "Typ" + ] + +instance FromNamedRecord Position where + parseNamedRecord r = do + 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/ParseSpec.hs b/test/ParseSpec.hs index 86d87ff..c1f0878 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,45 @@ testParseBuy = do let parsedBuySellTransaction = transformToBuySell $ head parsedGenericTransaction parsedBuySellTransaction `shouldSatisfy` isJust +testParsePosition :: Spec +testParsePosition = do + it "parses a consolidated-holdings row with Swedish decimal commas" $ do + let csv = + stringsToCsvByteString + [ positionHeader, + "Acme Corp;ACME;10;1234,50;100,00;100,00;SEK;SE;SE0000000001;XSTO;STOCK" + ] + let expected = + [ Position + { 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 = + "Namn-with-extra-chars;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: Namn" + +positionHeader :: String +positionHeader = "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" From 0e24c5d2e22a8431b183cd37b5f1446ffbf701b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tabacsk=C3=B3?= Date: Fri, 29 May 2026 23:38:31 +0200 Subject: [PATCH 2/3] feat: add positions command showing unrealized profit/loss --- app/Features/Positions/PositionsTable.hs | 99 ++++++++++++++++++++++++ app/Main.hs | 30 ++++++- hava.cabal | 2 + src/Calc.hs | 24 ++++++ src/PrettyPrint.hs | 11 +++ test/CalcSpec.hs | 37 ++++++++- test/TablesSpec.hs | 29 +++++++ 7 files changed, 229 insertions(+), 3 deletions(-) create mode 100644 app/Features/Positions/PositionsTable.hs diff --git a/app/Features/Positions/PositionsTable.hs b/app/Features/Positions/PositionsTable.hs new file mode 100644 index 0000000..e6ba6a3 --- /dev/null +++ b/app/Features/Positions/PositionsTable.hs @@ -0,0 +1,99 @@ +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 + +-- | Namn | Antal | GAV (kr) | Värde (kr) | Ansk. (kr) | Vinst (kr) | Avkastn. % | +positionsHeader :: [PrintableCell] +positionsHeader = + [ 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 $ + [ 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, + 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 8620f05..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 @@ -78,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/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/test/CalcSpec.hs b/test/CalcSpec.hs index 6edc9d9..9f1c052 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,36 @@ 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 + { 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/TablesSpec.hs b/test/TablesSpec.hs index 97ddc11..c94b895 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,29 @@ testGroupByCompanyTable = do table `shouldSatisfy` isText +testPositionsTable :: Spec +testPositionsTable = do + it "should produce text" $ do + let uiSize = Small + let termWidth = 100 + let rows = + [ Position + { 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 From 4c92065e016a1ae90ca82e065b22138fba5c5091 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Tabacsk=C3=B3?= Date: Sun, 14 Jun 2026 21:11:03 +0200 Subject: [PATCH 3/3] feat: parse per-account positions export with account column Target Avanza's per-account holdings export (Kontonummer present) instead of the consolidated one: add the account field to Position, require it in the header, and surface it as a leading "Konto" column in the positions table. Format A (no Kontonummer) is now rejected with a clear message. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Features/Positions/PositionsTable.hs | 15 ++++++++++----- src/Types/Position.hs | 16 +++++++++------- test/CalcSpec.hs | 3 ++- test/ParseSpec.hs | 13 +++++++------ test/TablesSpec.hs | 3 ++- 5 files changed, 30 insertions(+), 20 deletions(-) diff --git a/app/Features/Positions/PositionsTable.hs b/app/Features/Positions/PositionsTable.hs index e6ba6a3..6509e79 100644 --- a/app/Features/Positions/PositionsTable.hs +++ b/app/Features/Positions/PositionsTable.hs @@ -42,10 +42,11 @@ createPositionsTable uiSize termWidth positions = spacing = map width header in createPrettyTable uiSize termWidth header content spacing --- | Namn | Antal | GAV (kr) | Värde (kr) | Ansk. (kr) | Vinst (kr) | Avkastn. % | +-- | Konto | Namn | Antal | GAV (kr) | Värde (kr) | Ansk. (kr) | Vinst (kr) | Avkastn. % | positionsHeader :: [PrintableCell] positionsHeader = - [ createFilledCell "Namn" LeftAlign, + [ 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, @@ -56,8 +57,10 @@ positionsHeader = positionRowToCells :: [PrintableCell] -> Position -> [PrintableCell] positionRowToCells header p = - rowToCells header $ - [ name p, + rowToCells + header + [ account p, + name p, doubleToText (volume p), moneyToText (gavSek p), moneyToText (marketValue p), @@ -77,8 +80,10 @@ totalsRow header positions = if unMoney totalCost == 0 then Nothing else Just (unMoney totalProfit / unMoney totalCost * 100) - in rowToCells header $ + in rowToCells + header [ T.pack "Totalt", + T.empty, T.empty, T.empty, moneyToText totalValue, diff --git a/src/Types/Position.hs b/src/Types/Position.hs index 9c4d19c..50c7154 100644 --- a/src/Types/Position.hs +++ b/src/Types/Position.hs @@ -1,10 +1,9 @@ {-# LANGUAGE FlexibleInstances #-} --- | A single row of Avanza's "Mitt sammanställda innehav" (consolidated --- holdings) export — i.e. one current position in the portfolio. This is a --- different shape from the transaction history (see --- "Types.Transaction.GenericTransaction"); the two share only the ISIN, which --- is the natural key for joining them later on. +-- | 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, @@ -24,7 +23,8 @@ import Types.UtilTypes ) data Position = Position - { name :: !Text, -- Namn + { account :: !Text, -- Kontonummer + name :: !Text, -- Namn shortName :: !Text, -- Kortnamn volume :: !Double, -- Volym (fractional for funds) marketValue :: !Money, -- Marknadsvärde @@ -42,7 +42,8 @@ expectedPositionHeaders :: [BSU.ByteString] expectedPositionHeaders = map BSU.fromString - [ "Namn", + [ "Kontonummer", + "Namn", "Kortnamn", "Volym", "Marknadsvärde", @@ -57,6 +58,7 @@ expectedPositionHeaders = instance FromNamedRecord Position where parseNamedRecord r = do + account <- r .: "Kontonummer" name <- r .: "Namn" shortName <- r .: "Kortnamn" volumeRaw <- r .: "Volym" diff --git a/test/CalcSpec.hs b/test/CalcSpec.hs index 9f1c052..3552b5b 100644 --- a/test/CalcSpec.hs +++ b/test/CalcSpec.hs @@ -188,7 +188,8 @@ testCalcPositionProfit = do positionWith :: Double -> Money -> Money -> Position positionWith vol gavSekVal marketVal = Position - { name = placeHolder, + { account = placeHolder, + name = placeHolder, shortName = placeHolder, volume = vol, marketValue = marketVal, diff --git a/test/ParseSpec.hs b/test/ParseSpec.hs index c1f0878..d2b1629 100644 --- a/test/ParseSpec.hs +++ b/test/ParseSpec.hs @@ -91,15 +91,16 @@ testParseBuy = do testParsePosition :: Spec testParsePosition = do - it "parses a consolidated-holdings row with Swedish decimal commas" $ do + it "parses a per-account holdings row with Swedish decimal commas" $ do let csv = stringsToCsvByteString [ positionHeader, - "Acme Corp;ACME;10;1234,50;100,00;100,00;SEK;SE;SE0000000001;XSTO;STOCK" + "0000-0000000;Acme Corp;ACME;10;1234,50;100,00;100,00;SEK;SE;SE0000000001;XSTO;STOCK" ] let expected = [ Position - { name = T.pack "Acme Corp", + { account = T.pack "0000-0000000", + name = T.pack "Acme Corp", shortName = T.pack "ACME", volume = 10, marketValue = Money 1234.50, @@ -116,17 +117,17 @@ testParsePosition = do it "fails with a friendly message if a required header is missing" $ do let badHeader = - "Namn-with-extra-chars;Kortnamn;Volym;Marknadsvärde;GAV (SEK);GAV;Valuta;Land;ISIN;Marknad;Typ" + "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: Namn" + `shouldThrowErrorContaining` "Missing: Kontonummer" positionHeader :: String -positionHeader = "Namn;Kortnamn;Volym;Marknadsvärde;GAV (SEK);GAV;Valuta;Land;ISIN;Marknad;Typ" +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 c94b895..68fd119 100644 --- a/test/TablesSpec.hs +++ b/test/TablesSpec.hs @@ -52,7 +52,8 @@ testPositionsTable = do let termWidth = 100 let rows = [ Position - { name = T.pack "Acme Corp", + { account = T.pack "0000-0000000", + name = T.pack "Acme Corp", shortName = T.pack "ACME", volume = 10, marketValue = Money 1200,