lib: Change internal representation of MixedAmount to use a strict Map

instead of a list of Amounts. No longer export Mixed constructor, to
keep API clean (if you really need it, you can import it directly from
Hledger.Data.Types). We also ensure the JSON representation of
MixedAmount doesn't change: it is stored as a normalised list of
Amounts.

This commit improves performance. Here are some indicative results.

hledger reg -f examples/10000x1000x10.journal
- Maximum residency decreases from 65MB to 60MB (8% decrease)
- Total memory in use decreases from 178MiB to 157MiB (12% decrease)

hledger reg -f examples/10000x10000x10.journal
- Maximum residency decreases from 69MB to 60MB (13% decrease)
- Total memory in use decreases from 198MiB to 153MiB (23% decrease)

hledger bal -f examples/10000x1000x10.journal
- Total heap usage decreases from 6.4GB to 6.0GB (6% decrease)
- Total memory in use decreases from 178MiB to 153MiB (14% decrease)

hledger bal -f examples/10000x10000x10.journal
- Total heap usage decreases from 7.3GB to 6.9GB (5% decrease)
- Total memory in use decreases from 196MiB to 185MiB (5% decrease)

hledger bal -M -f examples/10000x1000x10.journal
- Total heap usage decreases from 16.8GB to 10.6GB (47% decrease)
- Total time decreases from 14.3s to 12.0s (16% decrease)

hledger bal -M -f examples/10000x10000x10.journal
- Total heap usage decreases from 108GB to 48GB (56% decrease)
- Total time decreases from 62s to 41s (33% decrease)

If you never directly use the constructor Mixed or pattern match against
it then you don't need to make any changes. If you do, then do the
following:

- If you really care about the individual Amounts and never normalise
  your MixedAmount (for example, just storing `Mixed amts` and then
  extracting `amts` as a pattern match, then use should switch to using
  [Amount]. This should just involve removing the `Mixed` constructor.
- If you ever call `mixed`, `normaliseMixedAmount`, or do any sort of
  amount arithmetic (+), (-), then you should replace the constructor
  `Mixed` with the function `mixed`. To extract the list of Amounts, use
  the function `amounts`.
- If you ever call `normaliseMixedAmountSquashPricesForDisplay`, you can
  replace that with `mixedAmountStripPrices`. (N.B. this does something
  slightly different from `normaliseMixedAmountSquashPricesForDisplay`,
  but I don't think there's any use case for squashing prices and then
  keeping the first of the squashed prices around. If you disagree let
  me know.)
- Any remaining calls to `normaliseMixedAmount` can be removed, as that
  is now the identity function.
This commit is contained in:
Stephen Morgan 2021-01-29 16:07:11 +11:00 committed by Simon Michael
parent 4013a81af8
commit 5e7b69356f
24 changed files with 295 additions and 235 deletions

View File

@ -110,11 +110,11 @@ splitPosting acct dates p@Posting{paccount,pamount}
[d] -> (d, [])
[] -> error' "splitPosting ran out of dates, should not happen (maybe sort your transactions by date)"
days = initSafe [start..end]
amt = (genericLength days) `divideMixedAmount` pamount
amt = (fromIntegral $ length days) `divideMixedAmount` pamount
-- give one of the postings an exact balancing amount to ensure the transaction is balanced
-- lastamt = pamount - ptrace (amt `multiplyMixedAmount` (fromIntegral $ length days))
lastamt = missingmixedamt
daysamts = zip days (take (length days - 1) (repeat amt) ++ [lastamt])
daysamts = zip days (replicate (length days - 1) amt ++ [lastamt])
ps' = [postingSetDate (Just d) p{pamount=a} | (d,a) <- daysamts ]
-- | Set a posting's (primary) date, as if it had been parsed from the journal entry:

View File

@ -47,7 +47,7 @@ import Hledger.Data.StringFormat
import Hledger.Data.Timeclock
import Hledger.Data.Transaction
import Hledger.Data.TransactionModifier
import Hledger.Data.Types
import Hledger.Data.Types hiding (MixedAmountKey, Mixed)
import Hledger.Data.Valuation
import Hledger.Utils.Test

View File

@ -101,6 +101,7 @@ module Hledger.Data.Amount (
maAddAmount,
maAddAmounts,
amounts,
amountsRaw,
filterMixedAmount,
filterMixedAmountByCommodity,
mapMixedAmount,
@ -152,10 +153,8 @@ import Data.Foldable (toList)
import Data.List (find, foldl', intercalate, intersperse, mapAccumL, partition)
import Data.List.NonEmpty (NonEmpty(..), nonEmpty)
import qualified Data.Map.Strict as M
import Data.Maybe (fromMaybe)
#if !(MIN_VERSION_base(4,11,0))
import Data.Maybe (fromMaybe, isNothing)
import Data.Semigroup (Semigroup(..))
#endif
import qualified Data.Text as T
import qualified Data.Text.Lazy.Builder as TB
import Data.Word (Word8)
@ -589,48 +588,54 @@ canonicaliseAmount styles a@Amount{acommodity=c, astyle=s} = a{astyle=s'}
instance Semigroup MixedAmount where
(<>) = maPlus
sconcat = maSum
stimes n = multiplyMixedAmount (fromIntegral n)
instance Monoid MixedAmount where
mempty = nullmixedamt
mconcat = maSum
#if !(MIN_VERSION_base(4,11,0))
mappend = (<>)
#endif
instance Num MixedAmount where
fromInteger i = Mixed [fromInteger i]
negate = maNegate
(+) = maPlus
(*) = error' "error, mixed amounts do not support multiplication" -- PARTIAL:
abs = error' "error, mixed amounts do not support abs"
signum = error' "error, mixed amounts do not support signum"
fromInteger = mixedAmount . fromInteger
negate = maNegate
(+) = maPlus
(*) = error' "error, mixed amounts do not support multiplication" -- PARTIAL:
abs = error' "error, mixed amounts do not support abs"
signum = error' "error, mixed amounts do not support signum"
-- | Get a mixed amount's component amounts.
amounts :: MixedAmount -> [Amount]
amounts (Mixed as) = as
-- | Calculate the key used to store an Amount within a MixedAmount.
amountKey :: Amount -> MixedAmountKey
amountKey amt@Amount{acommodity=c} = case aprice amt of
Nothing -> MixedAmountKeyNoPrice c
Just (TotalPrice p) -> MixedAmountKeyTotalPrice c (acommodity p)
Just (UnitPrice p) -> MixedAmountKeyUnitPrice c (acommodity p) (aquantity p)
-- | The empty mixed amount.
nullmixedamt :: MixedAmount
nullmixedamt = Mixed []
nullmixedamt = Mixed mempty
-- | A temporary value for parsed transactions which had no amount specified.
missingmixedamt :: MixedAmount
missingmixedamt = mixedAmount missingamt
-- | Convert amounts in various commodities into a normalised MixedAmount.
mixed :: [Amount] -> MixedAmount
mixed = normaliseMixedAmount . Mixed
-- | Convert amounts in various commodities into a mixed amount.
mixed :: Foldable t => t Amount -> MixedAmount
mixed = maAddAmounts nullmixedamt
-- | Create a MixedAmount from a single Amount.
mixedAmount :: Amount -> MixedAmount
mixedAmount = Mixed . pure
mixedAmount a = Mixed $ M.singleton (amountKey a) a
-- | Add an Amount to a MixedAmount, normalising the result.
maAddAmount :: MixedAmount -> Amount -> MixedAmount
maAddAmount (Mixed as) a = normaliseMixedAmount . Mixed $ a : as
maAddAmount (Mixed ma) a = Mixed $ M.insertWith sumSimilarAmountsUsingFirstPrice (amountKey a) a ma
-- | Add a collection of Amounts to a MixedAmount, normalising the result.
maAddAmounts :: MixedAmount -> [Amount] -> MixedAmount
maAddAmounts (Mixed as) bs = bs `seq` normaliseMixedAmount . Mixed $ bs ++ as
maAddAmounts :: Foldable t => MixedAmount -> t Amount -> MixedAmount
maAddAmounts = foldl' maAddAmount
-- | Negate mixed amount's quantities (and total prices, if any).
maNegate :: MixedAmount -> MixedAmount
@ -638,7 +643,7 @@ maNegate = transformMixedAmount negate
-- | Sum two MixedAmount.
maPlus :: MixedAmount -> MixedAmount -> MixedAmount
maPlus (Mixed as) (Mixed bs) = normaliseMixedAmount . Mixed $ as ++ bs
maPlus (Mixed as) (Mixed bs) = Mixed $ M.unionWith sumSimilarAmountsUsingFirstPrice as bs
-- | Subtract a MixedAmount from another.
maMinus :: MixedAmount -> MixedAmount -> MixedAmount
@ -658,7 +663,7 @@ multiplyMixedAmount n = transformMixedAmount (*n)
-- | Apply a function to a mixed amount's quantities (and its total prices, if it has any).
transformMixedAmount :: (Quantity -> Quantity) -> MixedAmount -> MixedAmount
transformMixedAmount f = mapMixedAmount (transformAmount f)
transformMixedAmount f = mapMixedAmountUnsafe (transformAmount f)
-- | Calculate the average of some mixed amounts.
averageMixedAmounts :: [MixedAmount] -> MixedAmount
@ -699,7 +704,7 @@ maIsZero = mixedAmountIsZero
maIsNonZero :: MixedAmount -> Bool
maIsNonZero = not . mixedAmountIsZero
-- | Simplify a mixed amount's component amounts:
-- | Get a mixed amount's component amounts.
--
-- * amounts in the same commodity are combined unless they have different prices or total prices
--
@ -711,34 +716,35 @@ maIsNonZero = not . mixedAmountIsZero
--
-- * the special "missing" mixed amount remains unchanged
--
normaliseMixedAmount :: MixedAmount -> MixedAmount
normaliseMixedAmount = normaliseHelper False
normaliseHelper :: Bool -> MixedAmount -> MixedAmount
normaliseHelper squashprices (Mixed as)
| missingkey `M.member` amtMap = missingmixedamt -- missingamt should always be alone, but detect it even if not
| M.null nonzeros = Mixed [newzero]
| otherwise = Mixed $ toList nonzeros
amounts :: MixedAmount -> [Amount]
amounts (Mixed ma)
| missingkey `M.member` ma = [missingamt] -- missingamt should always be alone, but detect it even if not
| M.null nonzeros = [newzero]
| otherwise = toList nonzeros
where
newzero = fromMaybe nullamt $ find (not . T.null . acommodity) zeros
(zeros, nonzeros) = M.partition amountIsZero amtMap
amtMap = foldr (\a -> M.insertWith sumSimilarAmountsUsingFirstPrice (key a) a) mempty as
key Amount{acommodity=c,aprice=p} = (c, if squashprices then Nothing else priceKey <$> p)
where
priceKey (UnitPrice x) = (acommodity x, Just $ aquantity x)
priceKey (TotalPrice x) = (acommodity x, Nothing)
missingkey = key missingamt
(zeros, nonzeros) = M.partition amountIsZero ma
missingkey = amountKey missingamt
-- | Like normaliseMixedAmount, but combine each commodity's amounts
-- into just one by throwing away all prices except the first. This is
-- only used as a rendering helper, and could show a misleading price.
-- | Get a mixed amount's component amounts without normalising zero and missing
-- amounts. This is used for JSON serialisation, so the order is important. In
-- particular, we want the Amounts given in the order of the MixedAmountKeys,
-- i.e. lexicographically first by commodity, then by price commodity, then by
-- unit price from most negative to most positive.
amountsRaw :: MixedAmount -> [Amount]
amountsRaw (Mixed ma) = toList ma
normaliseMixedAmount :: MixedAmount -> MixedAmount
normaliseMixedAmount = id -- XXX Remove
-- | Strip prices from a MixedAmount.
normaliseMixedAmountSquashPricesForDisplay :: MixedAmount -> MixedAmount
normaliseMixedAmountSquashPricesForDisplay = normaliseHelper True
normaliseMixedAmountSquashPricesForDisplay = mixedAmountStripPrices -- XXX Remove
-- | Unify a MixedAmount to a single commodity value if possible.
-- Like normaliseMixedAmount, this consolidates amounts of the same commodity
-- and discards zero amounts; but this one insists on simplifying to
-- a single commodity, and will return Nothing if this is not possible.
-- This consolidates amounts of the same commodity and discards zero
-- amounts; but this one insists on simplifying to a single commodity,
-- and will return Nothing if this is not possible.
unifyMixedAmount :: MixedAmount -> Maybe Amount
unifyMixedAmount = foldM combine 0 . amounts
where
@ -768,22 +774,27 @@ sumSimilarAmountsUsingFirstPrice a b = (a + b){aprice=p}
-- | Filter a mixed amount's component amounts by a predicate.
filterMixedAmount :: (Amount -> Bool) -> MixedAmount -> MixedAmount
filterMixedAmount p (Mixed as) = Mixed $ filter p as
filterMixedAmount p (Mixed ma) = Mixed $ M.filter p ma
-- | Return an unnormalised MixedAmount containing exactly one Amount
-- with the specified commodity and the quantity of that commodity
-- found in the original. NB if Amount's quantity is zero it will be
-- discarded next time the MixedAmount gets normalised.
filterMixedAmountByCommodity :: CommoditySymbol -> MixedAmount -> MixedAmount
filterMixedAmountByCommodity c (Mixed as) = Mixed as'
where
as' = case filter ((==c) . acommodity) as of
[] -> [nullamt{acommodity=c}]
as'' -> [sum as'']
filterMixedAmountByCommodity c (Mixed ma)
| M.null ma' = mixedAmount nullamt{acommodity=c}
| otherwise = Mixed ma'
where ma' = M.filter ((c==) . acommodity) ma
-- | Apply a transform to a mixed amount's component 'Amount's.
mapMixedAmount :: (Amount -> Amount) -> MixedAmount -> MixedAmount
mapMixedAmount f (Mixed as) = Mixed $ map f as
mapMixedAmount f (Mixed ma) = mixed . map f $ toList ma
-- | Apply a transform to a mixed amount's component 'Amount's, which does not
-- affect the key of the amount (i.e. doesn't change the commodity, price
-- commodity, or unit price amount). This condition is not checked.
mapMixedAmountUnsafe :: (Amount -> Amount) -> MixedAmount -> MixedAmount
mapMixedAmountUnsafe f (Mixed ma) = Mixed $ M.map f ma -- Use M.map instead of fmap to maintain strictness
-- | Convert all component amounts to cost/selling price where
-- possible (see amountCost).
@ -795,17 +806,17 @@ mixedAmountCost = mapMixedAmount amountCost
-- -- For now, use this when cross-commodity zero equality is important.
-- mixedAmountEquals :: MixedAmount -> MixedAmount -> Bool
-- mixedAmountEquals a b = amounts a' == amounts b' || (mixedAmountLooksZero a' && mixedAmountLooksZero b')
-- where a' = normaliseMixedAmountSquashPricesForDisplay a
-- b' = normaliseMixedAmountSquashPricesForDisplay b
-- where a' = mixedAmountStripPrices a
-- b' = mixedAmountStripPrices b
-- | Given a map of standard commodity display styles, apply the
-- appropriate one to each individual amount.
styleMixedAmount :: M.Map CommoditySymbol AmountStyle -> MixedAmount -> MixedAmount
styleMixedAmount styles = mapMixedAmount (styleAmount styles)
styleMixedAmount styles = mapMixedAmountUnsafe (styleAmount styles)
-- | Reset each individual amount's display style to the default.
mixedAmountUnstyled :: MixedAmount -> MixedAmount
mixedAmountUnstyled = mapMixedAmount amountUnstyled
mixedAmountUnstyled = mapMixedAmountUnsafe amountUnstyled
-- | Get the string representation of a mixed amount, after
-- normalising it to one amount per commodity. Assumes amounts have
@ -871,8 +882,8 @@ showMixedAmountDebug m | m == missingmixedamt = "(missing)"
-- - If displayed on multiple lines, any Amounts longer than the
-- maximum width will be elided.
showMixedAmountB :: AmountDisplayOpts -> MixedAmount -> WideBuilder
showMixedAmountB opts = showAmountsB opts . amounts
. (if displayPrice opts then id else mixedAmountStripPrices) . normaliseMixedAmountSquashPricesForDisplay
showMixedAmountB opts =
showAmountsB opts . amounts . if displayPrice opts then id else mixedAmountStripPrices
data AmountDisplay = AmountDisplay
{ adBuilder :: !WideBuilder -- ^ String representation of the Amount
@ -916,20 +927,22 @@ ltraceamount s = traceWith (((s ++ ": ") ++).showMixedAmount)
-- | Set the display precision in the amount's commodities.
mixedAmountSetPrecision :: AmountPrecision -> MixedAmount -> MixedAmount
mixedAmountSetPrecision p = mapMixedAmount (amountSetPrecision p)
mixedAmountSetPrecision p = mapMixedAmountUnsafe (amountSetPrecision p)
-- | In each component amount, increase the display precision sufficiently
-- to render it exactly (showing all significant decimal digits).
mixedAmountSetFullPrecision :: MixedAmount -> MixedAmount
mixedAmountSetFullPrecision = mapMixedAmount amountSetFullPrecision
mixedAmountSetFullPrecision = mapMixedAmountUnsafe amountSetFullPrecision
-- | Strip all prices from a MixedAmount.
-- | Remove all prices from a MixedAmount.
mixedAmountStripPrices :: MixedAmount -> MixedAmount
mixedAmountStripPrices = mapMixedAmount amountStripPrices
mixedAmountStripPrices (Mixed ma) =
foldl' (\m a -> maAddAmount m a{aprice=Nothing}) (Mixed noPrices) withPrices
where (noPrices, withPrices) = M.partition (isNothing . aprice) ma
-- | Canonicalise a mixed amount's display styles using the provided commodity style map.
canonicaliseMixedAmount :: M.Map CommoditySymbol AmountStyle -> MixedAmount -> MixedAmount
canonicaliseMixedAmount styles = mapMixedAmount (canonicaliseAmount styles)
canonicaliseMixedAmount styles = mapMixedAmountUnsafe (canonicaliseAmount styles)
-- | Replace each component amount's TotalPrice, if it has one, with an equivalent UnitPrice.
-- Has no effect on amounts without one.
@ -982,14 +995,14 @@ tests_Amount = tests "Amount" [
,usd (-1) `withPrecision` Precision 3
,usd (-0.25)
])
@?= Mixed [usd 0 `withPrecision` Precision 3]
@?= mixedAmount (usd 0 `withPrecision` Precision 3)
,test "adding mixed amounts with total prices" $ do
maSum (map mixedAmount
[usd 1 @@ eur 1
,usd (-2) @@ eur 1
])
@?= Mixed [usd (-1) @@ eur 2 ]
@?= mixedAmount (usd (-1) @@ eur 2)
,test "showMixedAmount" $ do
showMixedAmount (mixedAmount (usd 1)) @?= "$1.00"
@ -1003,22 +1016,22 @@ tests_Amount = tests "Amount" [
showMixedAmountWithoutPrice False (mixedAmount (a)) @?= "$1.00"
showMixedAmountWithoutPrice False (mixed [a, -a]) @?= "0"
,tests "normaliseMixedAmount" [
,tests "amounts" [
test "a missing amount overrides any other amounts" $
amounts (normaliseMixedAmount $ mixed [usd 1, missingamt]) @?= [missingamt]
amounts (mixed [usd 1, missingamt]) @?= [missingamt]
,test "unpriced same-commodity amounts are combined" $
amounts (normaliseMixedAmount $ mixed [usd 0, usd 2]) @?= [usd 2]
amounts (mixed [usd 0, usd 2]) @?= [usd 2]
,test "amounts with same unit price are combined" $
amounts (normaliseMixedAmount $ mixed [usd 1 `at` eur 1, usd 1 `at` eur 1]) @?= [usd 2 `at` eur 1]
amounts (mixed [usd 1 `at` eur 1, usd 1 `at` eur 1]) @?= [usd 2 `at` eur 1]
,test "amounts with different unit prices are not combined" $
amounts (normaliseMixedAmount $ mixed [usd 1 `at` eur 1, usd 1 `at` eur 2]) @?= [usd 1 `at` eur 1, usd 1 `at` eur 2]
amounts (mixed [usd 1 `at` eur 1, usd 1 `at` eur 2]) @?= [usd 1 `at` eur 1, usd 1 `at` eur 2]
,test "amounts with total prices are combined" $
amounts (normaliseMixedAmount $ mixed [usd 1 @@ eur 1, usd 1 @@ eur 1]) @?= [usd 2 @@ eur 2]
amounts (mixed [usd 1 @@ eur 1, usd 1 @@ eur 1]) @?= [usd 2 @@ eur 2]
]
,test "normaliseMixedAmountSquashPricesForDisplay" $ do
amounts (normaliseMixedAmountSquashPricesForDisplay nullmixedamt) @?= [nullamt]
assertBool "" $ mixedAmountLooksZero $ normaliseMixedAmountSquashPricesForDisplay
,test "mixedAmountStripPrices" $ do
amounts (mixedAmountStripPrices nullmixedamt) @?= [nullamt]
assertBool "" $ mixedAmountLooksZero $ mixedAmountStripPrices
(mixed [usd 10
,usd 10 @@ eur 7
,usd (-10)

View File

@ -547,8 +547,8 @@ journalMapPostings :: (Posting -> Posting) -> Journal -> Journal
journalMapPostings f j@Journal{jtxns=ts} = j{jtxns=map (transactionMapPostings f) ts}
-- | Apply a transformation to a journal's posting amounts.
journalMapPostingAmounts :: (Amount -> Amount) -> Journal -> Journal
journalMapPostingAmounts f = journalMapPostings (postingTransformAmount (mapMixedAmount f))
journalMapPostingAmounts :: (MixedAmount -> MixedAmount) -> Journal -> Journal
journalMapPostingAmounts f = journalMapPostings (postingTransformAmount f)
{-
-------------------------------------------------------------------------------
@ -929,7 +929,7 @@ addOrAssignAmountAndCheckAssertionB p@Posting{paccount=acc, pamount=amt, pbalanc
-- need to see the balance as it stands after each individual posting.
addAmountAndCheckAssertionB :: Posting -> Balancing s Posting
addAmountAndCheckAssertionB p | hasAmount p = do
newbal <- addToRunningBalanceB (paccount p) (pamount p)
newbal <- addToRunningBalanceB (paccount p) $ pamount p
whenM (R.reader bsAssrt) $ checkBalanceAssertionB p newbal
return p
addAmountAndCheckAssertionB p = return p
@ -940,13 +940,12 @@ addAmountAndCheckAssertionB p = return p
-- are ignored; if it is total, they will cause the assertion to fail.
checkBalanceAssertionB :: Posting -> MixedAmount -> Balancing s ()
checkBalanceAssertionB p@Posting{pbalanceassertion=Just (BalanceAssertion{baamount,batotal})} actualbal =
forM_ assertedamts $ \amt -> checkBalanceAssertionOneCommodityB p amt actualbal
forM_ (baamount : otheramts) $ \amt -> checkBalanceAssertionOneCommodityB p amt actualbal
where
assertedamts = baamount : otheramts
where
assertedcomm = acommodity baamount
otheramts | batotal = map (\a -> a{aquantity=0}) $ amounts $ filterMixedAmount ((/=assertedcomm).acommodity) actualbal
| otherwise = []
assertedcomm = acommodity baamount
otheramts | batotal = map (\a -> a{aquantity=0}) . amountsRaw
$ filterMixedAmount ((/=assertedcomm).acommodity) actualbal
| otherwise = []
checkBalanceAssertionB _ _ = return ()
-- | Does this (single commodity) expected balance match the amount of that
@ -971,7 +970,7 @@ checkBalanceAssertionOneCommodityB p@Posting{paccount=assertedacct} assertedamt
else return actualbal
let
assertedcomm = acommodity assertedamt
actualbalincomm = headDef 0 $ amounts $ filterMixedAmountByCommodity assertedcomm $ actualbal'
actualbalincomm = headDef nullamt . amountsRaw . filterMixedAmountByCommodity assertedcomm $ actualbal'
pass =
aquantity
-- traceWith (("asserted:"++).showAmountDebug)
@ -1181,16 +1180,16 @@ journalInferMarketPricesFromTransactions j =
-- first commodity amount is considered.
postingInferredmarketPrice :: Posting -> Maybe MarketPrice
postingInferredmarketPrice p@Posting{pamount} =
-- convert any total prices to unit prices
case amounts $ mixedAmountTotalPriceToUnitPrice pamount of
Amount{acommodity=fromcomm, aprice = Just (UnitPrice Amount{acommodity=tocomm, aquantity=rate})}:_ ->
Just MarketPrice {
mpdate = postingDate p
,mpfrom = fromcomm
,mpto = tocomm
,mprate = rate
}
_ -> Nothing
-- convert any total prices to unit prices
case amountsRaw $ mixedAmountTotalPriceToUnitPrice pamount of
Amount{acommodity=fromcomm, aprice = Just (UnitPrice Amount{acommodity=tocomm, aquantity=rate})}:_ ->
Just MarketPrice {
mpdate = postingDate p
,mpfrom = fromcomm
,mpto = tocomm
,mprate = rate
}
_ -> Nothing
-- | Convert all this journal's amounts to cost using the transaction prices, if any.
-- The journal's commodity styles are applied to the resulting amounts.
@ -1229,12 +1228,12 @@ journalToCost j@Journal{jtxns=ts} = j{jtxns=map (transactionToCost styles) ts}
-- Transaction price amounts (posting amounts' aprice field) are not included.
--
journalStyleInfluencingAmounts :: Journal -> [Amount]
journalStyleInfluencingAmounts j =
journalStyleInfluencingAmounts j =
dbg7 "journalStyleInfluencingAmounts" $
catMaybes $ concat [
[mdefaultcommodityamt]
,map (Just . pdamount) $ jpricedirectives j
,map Just $ concatMap amounts $ map pamount $ journalPostings j
,map Just . concatMap (amountsRaw . pamount) $ journalPostings j
]
where
-- D's amount style isn't actually stored as an amount, make it into one
@ -1561,7 +1560,7 @@ tests_Journal = tests "Journal" [
]}
assertRight ej
let Right j = ej
(jtxns j & head & tpostings & head & pamount) @?= mixedAmount (num 1)
(jtxns j & head & tpostings & head & pamount & amountsRaw) @?= [num 1]
,test "same-day-1" $ do
assertRight $ journalBalanceTransactions True $

View File

@ -50,6 +50,7 @@ import GHC.Generics (Generic)
import System.Time (ClockTime)
import Hledger.Data.Types
import Hledger.Data.Amount (amountsRaw, mixed)
-- To JSON
@ -105,7 +106,11 @@ instance ToJSON AmountPrecision where
instance ToJSON Side
instance ToJSON DigitGroupStyle
instance ToJSON MixedAmount
instance ToJSON MixedAmount where
toJSON = toJSON . amountsRaw
toEncoding = toEncoding . amountsRaw
instance ToJSON BalanceAssertion
instance ToJSON AmountPrice
instance ToJSON MarketPrice
@ -188,7 +193,10 @@ instance FromJSON AmountPrecision where
instance FromJSON Side
instance FromJSON DigitGroupStyle
instance FromJSON MixedAmount
instance FromJSON MixedAmount where
parseJSON = fmap (mixed :: [Amount] -> MixedAmount) . parseJSON
instance FromJSON BalanceAssertion
instance FromJSON AmountPrice
instance FromJSON MarketPrice

View File

@ -115,7 +115,7 @@ posting = nullposting
-- | Make a posting to an account.
post :: AccountName -> Amount -> Posting
post acc amt = posting {paccount=acc, pamount=Mixed [amt]}
post acc amt = posting {paccount=acc, pamount=mixedAmount amt}
-- | Make a virtual (unbalanced) posting to an account.
vpost :: AccountName -> Amount -> Posting
@ -123,7 +123,7 @@ vpost acc amt = (post acc amt){ptype=VirtualPosting}
-- | Make a posting to an account, maybe with a balance assertion.
post' :: AccountName -> Amount -> Maybe BalanceAssertion -> Posting
post' acc amt ass = posting {paccount=acc, pamount=Mixed [amt], pbalanceassertion=ass}
post' acc amt ass = posting {paccount=acc, pamount=mixedAmount amt, pbalanceassertion=ass}
-- | Make a virtual (unbalanced) posting to an account, maybe with a balance assertion.
vpost' :: AccountName -> Amount -> Maybe BalanceAssertion -> Posting
@ -197,10 +197,11 @@ hasBalanceAssignment p = not (hasAmount p) && isJust (pbalanceassertion p)
accountNamesFromPostings :: [Posting] -> [AccountName]
accountNamesFromPostings = nubSort . map paccount
-- | Sum all amounts from a list of postings.
sumPostings :: [Posting] -> MixedAmount
sumPostings = foldl' (\amt p -> maPlus amt $ pamount p) nullmixedamt
-- | Remove all prices of a posting
-- | Strip all prices from a Posting.
postingStripPrices :: Posting -> Posting
postingStripPrices = postingTransformAmount mixedAmountStripPrices

View File

@ -65,7 +65,7 @@ where
import Data.Default (def)
import Data.List (intercalate, partition)
import Data.List.Extra (nubSort)
import Data.Maybe (fromMaybe, mapMaybe)
import Data.Maybe (fromMaybe, isJust, mapMaybe)
#if !(MIN_VERSION_base(4,11,0))
import Data.Semigroup ((<>))
#endif
@ -283,9 +283,8 @@ postingAsLines elideamount onelineamounts acctwidth amtwidth p =
-- amtwidth and thisamtwidth, make sure thisamtwidth does not depend on
-- amtwidth at all.
shownAmounts
| elideamount || null (amounts $ pamount p) = [mempty]
| otherwise = showAmountsLinesB displayopts . amounts $ pamount p
where displayopts = noColour{displayOneLine=onelineamounts}
| elideamount = [mempty]
| otherwise = showAmountsLinesB noColour{displayOneLine=onelineamounts} . amounts $ pamount p
thisamtwidth = maximumDef 0 $ map wbWidth shownAmounts
(samelinecomment, newlinecomments) =
@ -554,35 +553,35 @@ priceInferrerFor :: Transaction -> PostingType -> (Posting -> Posting)
priceInferrerFor t pt = inferprice
where
postings = filter ((==pt).ptype) $ tpostings t
pamounts = concatMap (amounts . pamount) postings
pcommodities = map acommodity pamounts
sumamounts = amounts $ sumPostings postings -- sum normalises to one amount per commodity & price
pmixedamounts = map pamount postings
pcommodities = map acommodity $ concatMap amountsRaw pmixedamounts
sumamounts = amounts $ maSum pmixedamounts -- sum normalises to one amount per commodity & price
sumcommodities = map acommodity sumamounts
sumprices = filter (/=Nothing) $ map aprice sumamounts
sumprices = filter isJust $ map aprice sumamounts
caninferprices = length sumcommodities == 2 && null sumprices
inferprice p@Posting{pamount=Mixed [a]}
| caninferprices && ptype p == pt && acommodity a == fromcommodity
= p{pamount=mixedAmount $ a{aprice=Just conversionprice}, poriginal=Just $ originalPosting p}
where
fromcommodity = head $ filter (`elem` sumcommodities) pcommodities -- these heads are ugly but should be safe
totalpricesign = if aquantity a < 0 then negate else id
conversionprice
| fromcount==1 = TotalPrice $ totalpricesign (abs toamount) `withPrecision` NaturalPrecision
| otherwise = UnitPrice $ abs unitprice `withPrecision` unitprecision
where
fromcount = length $ filter ((==fromcommodity).acommodity) pamounts
fromamount = head $ filter ((==fromcommodity).acommodity) sumamounts
fromprecision = asprecision $ astyle fromamount
tocommodity = head $ filter (/=fromcommodity) sumcommodities
toamount = head $ filter ((==tocommodity).acommodity) sumamounts
toprecision = asprecision $ astyle toamount
unitprice = (aquantity fromamount) `divideAmount` toamount
-- Sum two display precisions, capping the result at the maximum bound
unitprecision = case (fromprecision, toprecision) of
(Precision a, Precision b) -> Precision $ if maxBound - a < b then maxBound else max 2 (a + b)
_ -> NaturalPrecision
inferprice p = p
inferprice p@Posting{pamount=amt} = case amountsRaw amt of
[a] | caninferprices && ptype p == pt && acommodity a == fromcommodity
-> p{ pamount=mixedAmount a{aprice=Just conversionprice}
, poriginal=Just $ originalPosting p}
where
fromcommodity = head $ filter (`elem` sumcommodities) pcommodities -- these heads are ugly but should be safe
totalpricesign = if aquantity a < 0 then negate else id
conversionprice = case filter (==fromcommodity) pcommodities of
[_] -> TotalPrice $ totalpricesign (abs toamount) `withPrecision` NaturalPrecision
_ -> UnitPrice $ abs unitprice `withPrecision` unitprecision
where
fromamount = head $ filter ((==fromcommodity).acommodity) sumamounts
fromprecision = asprecision $ astyle fromamount
tocommodity = head $ filter (/=fromcommodity) sumcommodities
toamount = head $ filter ((==tocommodity).acommodity) sumamounts
toprecision = asprecision $ astyle toamount
unitprice = aquantity fromamount `divideAmount` toamount
-- Sum two display precisions, capping the result at the maximum bound
unitprecision = case (fromprecision, toprecision) of
(Precision a, Precision b) -> Precision $ if maxBound - a < b then maxBound else max 2 (a + b)
_ -> NaturalPrecision
_ -> p
-- Get a transaction's secondary date, defaulting to the primary date.
transactionDate2 :: Transaction -> Day
@ -638,8 +637,8 @@ transactionMapPostings :: (Posting -> Posting) -> Transaction -> Transaction
transactionMapPostings f t@Transaction{tpostings=ps} = t{tpostings=map f ps}
-- | Apply a transformation to a transaction's posting amounts.
transactionMapPostingAmounts :: (Amount -> Amount) -> Transaction -> Transaction
transactionMapPostingAmounts f = transactionMapPostings (postingTransformAmount (mapMixedAmount f))
transactionMapPostingAmounts :: (MixedAmount -> MixedAmount) -> Transaction -> Transaction
transactionMapPostingAmounts f = transactionMapPostings (postingTransformAmount f)
-- | The file path from which this transaction was parsed.
transactionFile :: Transaction -> FilePath
@ -655,13 +654,13 @@ tests_Transaction =
tests "Transaction" [
tests "showPostingLines" [
test "null posting" $ showPostingLines posting @?= [""]
test "null posting" $ showPostingLines nullposting @?= [" 0"]
, test "non-null posting" $
let p =
posting
{ pstatus = Cleared
, paccount = "a"
, pamount = Mixed [usd 1, hrs 2]
, pamount = mixed [usd 1, hrs 2]
, pcomment = "pcomment1\npcomment2\n tag3: val3 \n"
, ptype = RegularPosting
, ptags = [("ptag1", "val1"), ("ptag2", "val2")]
@ -742,7 +741,7 @@ tests_Transaction =
[ nullposting
{ pstatus = Cleared
, paccount = "a"
, pamount = Mixed [usd 1, hrs 2]
, pamount = mixed [usd 1, hrs 2]
, pcomment = "\npcomment2\n"
, ptype = RegularPosting
, ptags = [("ptag1", "val1"), ("ptag2", "val2")]
@ -771,8 +770,8 @@ tests_Transaction =
"coopportunity"
""
[]
[ posting {paccount = "expenses:food:groceries", pamount = Mixed [usd 47.18], ptransaction = Just t}
, posting {paccount = "assets:checking", pamount = Mixed [usd (-47.18)], ptransaction = Just t}
[ posting {paccount = "expenses:food:groceries", pamount = mixedAmount (usd 47.18), ptransaction = Just t}
, posting {paccount = "assets:checking", pamount = mixedAmount (usd (-47.18)), ptransaction = Just t}
]
in showTransaction t) @?=
(T.unlines
@ -795,8 +794,8 @@ tests_Transaction =
"coopportunity"
""
[]
[ posting {paccount = "expenses:food:groceries", pamount = Mixed [usd 47.18]}
, posting {paccount = "assets:checking", pamount = Mixed [usd (-47.19)]}
[ posting {paccount = "expenses:food:groceries", pamount = mixedAmount (usd 47.18)}
, posting {paccount = "assets:checking", pamount = mixedAmount (usd (-47.19))}
])) @?=
(T.unlines
[ "2007-01-28 coopportunity"
@ -834,7 +833,7 @@ tests_Transaction =
"x"
""
[]
[ posting {paccount = "a", pamount = Mixed [num 1 `at` (usd 2 `withPrecision` Precision 0)]}
[ posting {paccount = "a", pamount = mixedAmount $ num 1 `at` (usd 2 `withPrecision` Precision 0)}
, posting {paccount = "b", pamount = missingmixedamt}
])) @?=
(T.unlines ["2010-01-01 x", " a 1 @ $2", " b", ""])
@ -855,7 +854,7 @@ tests_Transaction =
"test"
""
[]
[posting {paccount = "a", pamount = Mixed [usd 1]}, posting {paccount = "b", pamount = Mixed [usd 1]}]))
[posting {paccount = "a", pamount = mixedAmount (usd 1)}, posting {paccount = "b", pamount = mixedAmount (usd 1)}]))
,test "detect unbalanced entry, multiple missing amounts" $
assertLeft $
balanceTransaction
@ -889,8 +888,8 @@ tests_Transaction =
""
""
[]
[posting {paccount = "a", pamount = Mixed [usd 1]}, posting {paccount = "b", pamount = missingmixedamt}])) @?=
Right (Mixed [usd (-1)])
[posting {paccount = "a", pamount = mixedAmount (usd 1)}, posting {paccount = "b", pamount = missingmixedamt}])) @?=
Right (mixedAmount $ usd (-1))
,test "conversion price is inferred" $
(pamount . head . tpostings <$>
balanceTransaction
@ -906,10 +905,10 @@ tests_Transaction =
""
""
[]
[ posting {paccount = "a", pamount = Mixed [usd 1.35]}
, posting {paccount = "b", pamount = Mixed [eur (-1)]}
[ posting {paccount = "a", pamount = mixedAmount (usd 1.35)}
, posting {paccount = "b", pamount = mixedAmount (eur (-1))}
])) @?=
Right (Mixed [usd 1.35 @@ (eur 1 `withPrecision` NaturalPrecision)])
Right (mixedAmount $ usd 1.35 @@ (eur 1 `withPrecision` NaturalPrecision))
,test "balanceTransaction balances based on cost if there are unit prices" $
assertRight $
balanceTransaction
@ -925,8 +924,8 @@ tests_Transaction =
""
""
[]
[ posting {paccount = "a", pamount = Mixed [usd 1 `at` eur 2]}
, posting {paccount = "a", pamount = Mixed [usd (-2) `at` eur 1]}
[ posting {paccount = "a", pamount = mixedAmount $ usd 1 `at` eur 2}
, posting {paccount = "a", pamount = mixedAmount $ usd (-2) `at` eur 1}
])
,test "balanceTransaction balances based on cost if there are total prices" $
assertRight $
@ -943,8 +942,8 @@ tests_Transaction =
""
""
[]
[ posting {paccount = "a", pamount = Mixed [usd 1 @@ eur 1]}
, posting {paccount = "a", pamount = Mixed [usd (-2) @@ eur (-1)]}
[ posting {paccount = "a", pamount = mixedAmount $ usd 1 @@ eur 1}
, posting {paccount = "a", pamount = mixedAmount $ usd (-2) @@ eur (-1)}
])
]
, tests "isTransactionBalanced" [
@ -962,8 +961,8 @@ tests_Transaction =
"a"
""
[]
[ posting {paccount = "b", pamount = Mixed [usd 1.00]}
, posting {paccount = "c", pamount = Mixed [usd (-1.00)]}
[ posting {paccount = "b", pamount = mixedAmount (usd 1.00)}
, posting {paccount = "c", pamount = mixedAmount (usd (-1.00))}
]
,test "detect unbalanced" $
assertBool "" $
@ -980,8 +979,8 @@ tests_Transaction =
"a"
""
[]
[ posting {paccount = "b", pamount = Mixed [usd 1.00]}
, posting {paccount = "c", pamount = Mixed [usd (-1.01)]}
[ posting {paccount = "b", pamount = mixedAmount (usd 1.00)}
, posting {paccount = "c", pamount = mixedAmount (usd (-1.01))}
]
,test "detect unbalanced, one posting" $
assertBool "" $
@ -998,7 +997,7 @@ tests_Transaction =
"a"
""
[]
[posting {paccount = "b", pamount = Mixed [usd 1.00]}]
[posting {paccount = "b", pamount = mixedAmount (usd 1.00)}]
,test "one zero posting is considered balanced for now" $
assertBool "" $
isTransactionBalanced Nothing $
@ -1013,7 +1012,7 @@ tests_Transaction =
"a"
""
[]
[posting {paccount = "b", pamount = Mixed [usd 0]}]
[posting {paccount = "b", pamount = mixedAmount (usd 0)}]
,test "virtual postings don't need to balance" $
assertBool "" $
isTransactionBalanced Nothing $
@ -1028,9 +1027,9 @@ tests_Transaction =
"a"
""
[]
[ posting {paccount = "b", pamount = Mixed [usd 1.00]}
, posting {paccount = "c", pamount = Mixed [usd (-1.00)]}
, posting {paccount = "d", pamount = Mixed [usd 100], ptype = VirtualPosting}
[ posting {paccount = "b", pamount = mixedAmount (usd 1.00)}
, posting {paccount = "c", pamount = mixedAmount (usd (-1.00))}
, posting {paccount = "d", pamount = mixedAmount (usd 100), ptype = VirtualPosting}
]
,test "balanced virtual postings need to balance among themselves" $
assertBool "" $
@ -1047,9 +1046,9 @@ tests_Transaction =
"a"
""
[]
[ posting {paccount = "b", pamount = Mixed [usd 1.00]}
, posting {paccount = "c", pamount = Mixed [usd (-1.00)]}
, posting {paccount = "d", pamount = Mixed [usd 100], ptype = BalancedVirtualPosting}
[ posting {paccount = "b", pamount = mixedAmount (usd 1.00)}
, posting {paccount = "c", pamount = mixedAmount (usd (-1.00))}
, posting {paccount = "d", pamount = mixedAmount (usd 100), ptype = BalancedVirtualPosting}
]
,test "balanced virtual postings need to balance among themselves (2)" $
assertBool "" $
@ -1065,10 +1064,10 @@ tests_Transaction =
"a"
""
[]
[ posting {paccount = "b", pamount = Mixed [usd 1.00]}
, posting {paccount = "c", pamount = Mixed [usd (-1.00)]}
, posting {paccount = "d", pamount = Mixed [usd 100], ptype = BalancedVirtualPosting}
, posting {paccount = "3", pamount = Mixed [usd (-100)], ptype = BalancedVirtualPosting}
[ posting {paccount = "b", pamount = mixedAmount (usd 1.00)}
, posting {paccount = "c", pamount = mixedAmount (usd (-1.00))}
, posting {paccount = "d", pamount = mixedAmount (usd 100), ptype = BalancedVirtualPosting}
, posting {paccount = "3", pamount = mixedAmount (usd (-100)), ptype = BalancedVirtualPosting}
]
]
]

View File

@ -114,13 +114,13 @@ tmPostingRuleToFunction querytxt pr =
Just n -> \p ->
-- Multiply the old posting's amount by the posting rule's multiplier.
let
pramount = dbg6 "pramount" $ head $ amounts $ pamount pr
pramount = dbg6 "pramount" . head . amountsRaw $ pamount pr
matchedamount = dbg6 "matchedamount" $ pamount p
-- Handle a matched amount with a total price carefully so as to keep the transaction balanced (#928).
-- Approach 1: convert to a unit price and increase the display precision slightly
-- Mixed as = dbg6 "multipliedamount" $ n `multiplyMixedAmount` mixedAmountTotalPriceToUnitPrice matchedamount
-- Approach 2: multiply the total price (keeping it positive) as well as the quantity
as = dbg6 "multipliedamount" $ n `multiplyMixedAmount` matchedamount
as = dbg6 "multipliedamount" $ multiplyMixedAmount n matchedamount
in
case acommodity pramount of
"" -> as
@ -130,10 +130,9 @@ tmPostingRuleToFunction querytxt pr =
c -> mapMixedAmount (\a -> a{acommodity = c, astyle = astyle pramount, aprice = aprice pramount}) as
postingRuleMultiplier :: TMPostingRule -> Maybe Quantity
postingRuleMultiplier p =
case amounts $ pamount p of
[a] | aismultiplier a -> Just $ aquantity a
_ -> Nothing
postingRuleMultiplier p = case amountsRaw $ pamount p of
[a] | aismultiplier a -> Just $ aquantity a
_ -> Nothing
renderPostingCommentDates :: Posting -> Posting
renderPostingCommentDates p = p { pcomment = comment' }

View File

@ -16,12 +16,13 @@ For more detailed documentation on each type, see the corresponding modules.
-}
{-# LANGUAGE CPP #-}
-- {-# LANGUAGE DeriveAnyClass #-} -- https://hackage.haskell.org/package/deepseq-1.4.4.0/docs/Control-DeepSeq.html#v:rnf
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeSynonymInstances #-}
module Hledger.Data.Types
@ -38,6 +39,10 @@ import Text.Blaze (ToMarkup(..))
--You will eventually need all the values stored.
--The stored values don't represent large virtual data structures to be lazily computed.
import qualified Data.Map as M
import Data.Ord (comparing)
#if !(MIN_VERSION_base(4,11,0))
import Data.Semigroup ((<>))
#endif
import Data.Text (Text)
-- import qualified Data.Text as T
import Data.Time.Calendar
@ -230,7 +235,38 @@ data Amount = Amount {
aprice :: !(Maybe AmountPrice) -- ^ the (fixed, transaction-specific) price for this amount, if any
} deriving (Eq,Ord,Generic,Show)
newtype MixedAmount = Mixed [Amount] deriving (Eq,Ord,Generic,Show)
newtype MixedAmount = Mixed (M.Map MixedAmountKey Amount) deriving (Eq,Ord,Generic,Show)
-- | Stores the CommoditySymbol of the Amount, along with the CommoditySymbol of
-- the price, and its unit price if being used.
data MixedAmountKey
= MixedAmountKeyNoPrice !CommoditySymbol
| MixedAmountKeyTotalPrice !CommoditySymbol !CommoditySymbol
| MixedAmountKeyUnitPrice !CommoditySymbol !CommoditySymbol !Quantity
deriving (Eq,Generic,Show)
-- | We don't auto-derive the Ord instance because it would give an undesired ordering.
-- We want the keys to be sorted lexicographically:
-- (1) By the primary commodity of the amount.
-- (2) By the commodity of the price, with no price being first.
-- (3) By the unit price, from most negative to most positive, with total prices
-- before unit prices.
-- For example, we would like the ordering to give
-- MixedAmountKeyNoPrice "X" < MixedAmountKeyTotalPrice "X" "Z" < MixedAmountKeyNoPrice "Y"
instance Ord MixedAmountKey where
compare = comparing commodity <> comparing pCommodity <> comparing pPrice
where
commodity (MixedAmountKeyNoPrice c) = c
commodity (MixedAmountKeyTotalPrice c _) = c
commodity (MixedAmountKeyUnitPrice c _ _) = c
pCommodity (MixedAmountKeyNoPrice _) = Nothing
pCommodity (MixedAmountKeyTotalPrice _ pc) = Just pc
pCommodity (MixedAmountKeyUnitPrice _ pc _) = Just pc
pPrice (MixedAmountKeyNoPrice _) = Nothing
pPrice (MixedAmountKeyTotalPrice _ _) = Nothing
pPrice (MixedAmountKeyUnitPrice _ _ q) = Just q
data PostingType = RegularPosting | VirtualPosting | BalancedVirtualPosting
deriving (Eq,Show,Generic)

View File

@ -17,8 +17,9 @@ module Hledger.Data.Valuation (
,ValuationType(..)
,PriceOracle
,journalPriceOracle
-- ,amountApplyValuation
-- ,amountValueAtDate
,amountApplyCostValuation
,amountApplyValuation
,amountValueAtDate
,mixedAmountApplyCostValuation
,mixedAmountApplyValuation
,mixedAmountValueAtDate
@ -105,12 +106,7 @@ priceDirectiveToMarketPrice PriceDirective{..} =
-- See amountApplyValuation and amountCost.
mixedAmountApplyCostValuation :: PriceOracle -> M.Map CommoditySymbol AmountStyle -> Day -> Day -> Day -> Costing -> Maybe ValuationType -> MixedAmount -> MixedAmount
mixedAmountApplyCostValuation priceoracle styles periodlast today postingdate cost v =
valuation . costing
where
valuation = maybe id (mixedAmountApplyValuation priceoracle styles periodlast today postingdate) v
costing = case cost of
Cost -> styleMixedAmount styles . mixedAmountCost
NoCost -> id
mapMixedAmount (amountApplyCostValuation priceoracle styles periodlast today postingdate cost v)
-- | Apply a specified valuation to this mixed amount, using the
-- provided price oracle, commodity styles, and reference dates.
@ -119,6 +115,19 @@ mixedAmountApplyValuation :: PriceOracle -> M.Map CommoditySymbol AmountStyle ->
mixedAmountApplyValuation priceoracle styles periodlast today postingdate v =
mapMixedAmount (amountApplyValuation priceoracle styles periodlast today postingdate v)
-- | Apply a specified costing and valuation to this Amount,
-- using the provided price oracle, commodity styles, and reference dates.
-- Costing is done first if requested, and after that any valuation.
-- See amountApplyValuation and amountCost.
amountApplyCostValuation :: PriceOracle -> M.Map CommoditySymbol AmountStyle -> Day -> Day -> Day -> Costing -> Maybe ValuationType -> Amount -> Amount
amountApplyCostValuation priceoracle styles periodlast today postingdate cost v =
valuation . costing
where
valuation = maybe id (amountApplyValuation priceoracle styles periodlast today postingdate) v
costing = case cost of
Cost -> styleAmount styles . amountCost
NoCost -> id
-- | Apply a specified valuation to this amount, using the provided
-- price oracle, reference dates, and whether this is for a
-- multiperiod report or not. Also fix up its display style using the

View File

@ -78,7 +78,7 @@ import Text.Megaparsec.Char (char, string)
import Hledger.Utils hiding (words')
import Hledger.Data.Types
import Hledger.Data.AccountName
import Hledger.Data.Amount (nullamt, usd)
import Hledger.Data.Amount (amountsRaw, mixedAmount, nullamt, usd)
import Hledger.Data.Dates
import Hledger.Data.Posting
import Hledger.Data.Transaction
@ -562,8 +562,9 @@ matchesAccount (Tag _ _) _ = False
matchesAccount _ _ = True
matchesMixedAmount :: Query -> MixedAmount -> Bool
matchesMixedAmount q (Mixed []) = q `matchesAmount` nullamt
matchesMixedAmount q (Mixed as) = any (q `matchesAmount`) as
matchesMixedAmount q ma = case amountsRaw ma of
[] -> q `matchesAmount` nullamt
as -> any (q `matchesAmount`) as
matchesCommodity :: Query -> CommoditySymbol -> Bool
matchesCommodity (Sym r) = regexMatchText r
@ -614,8 +615,8 @@ matchesPosting (Date2 span) p = span `spanContainsDate` postingDate2 p
matchesPosting (StatusQ s) p = postingStatus p == s
matchesPosting (Real v) p = v == isReal p
matchesPosting q@(Depth _) Posting{paccount=a} = q `matchesAccount` a
matchesPosting q@(Amt _ _) Posting{pamount=amt} = q `matchesMixedAmount` amt
matchesPosting (Sym r) Posting{pamount=Mixed as} = any (matchesCommodity (Sym r)) $ map acommodity as
matchesPosting q@(Amt _ _) Posting{pamount=as} = q `matchesMixedAmount` as
matchesPosting (Sym r) Posting{pamount=as} = any (matchesCommodity (Sym r)) . map acommodity $ amountsRaw as
matchesPosting (Tag n v) p = case (reString n, v) of
("payee", Just v) -> maybe False (regexMatchText v . transactionPayee) $ ptransaction p
("note", Just v) -> maybe False (regexMatchText v . transactionNote) $ ptransaction p
@ -811,10 +812,10 @@ tests_Query = tests "Query" [
,test "a tag match on a posting also sees inherited tags" $ assertBool "" $ (Tag (toRegex' "txntag") Nothing) `matchesPosting` nullposting{ptransaction=Just nulltransaction{ttags=[("txntag","")]}}
,test "cur:" $ do
let toSym = either id (const $ error' "No query opts") . either error' id . parseQueryTerm (fromGregorian 2000 01 01) . ("cur:"<>)
assertBool "" $ not $ toSym "$" `matchesPosting` nullposting{pamount=Mixed [usd 1]} -- becomes "^$$", ie testing for null symbol
assertBool "" $ (toSym "\\$") `matchesPosting` nullposting{pamount=Mixed [usd 1]} -- have to quote $ for regexpr
assertBool "" $ (toSym "shekels") `matchesPosting` nullposting{pamount=Mixed [nullamt{acommodity="shekels"}]}
assertBool "" $ not $ (toSym "shek") `matchesPosting` nullposting{pamount=Mixed [nullamt{acommodity="shekels"}]}
assertBool "" $ not $ toSym "$" `matchesPosting` nullposting{pamount=mixedAmount $ usd 1} -- becomes "^$$", ie testing for null symbol
assertBool "" $ (toSym "\\$") `matchesPosting` nullposting{pamount=mixedAmount $ usd 1} -- have to quote $ for regexpr
assertBool "" $ (toSym "shekels") `matchesPosting` nullposting{pamount=mixedAmount nullamt{acommodity="shekels"}}
assertBool "" $ not $ (toSym "shek") `matchesPosting` nullposting{pamount=mixedAmount nullamt{acommodity="shekels"}}
]
,test "matchesTransaction" $ do

View File

@ -407,7 +407,7 @@ journalCheckAccountsDeclared j = sequence_ $ map checkacct $ journalPostings j
-- | Check that all the commodities used in this journal's postings have been declared
-- by commodity directives, returning an error message otherwise.
journalCheckCommoditiesDeclared :: Journal -> Either String ()
journalCheckCommoditiesDeclared j =
journalCheckCommoditiesDeclared j =
sequence_ $ map checkcommodities $ journalPostings j
where
checkcommodities Posting{..} =
@ -423,7 +423,7 @@ journalCheckCommoditiesDeclared j =
where
mfirstundeclaredcomm =
find (`M.notMember` jcommodities j) . map acommodity $
(maybe id ((:) . baamount) pbalanceassertion) (filter (/= missingamt) $ amounts pamount)
(maybe id ((:) . baamount) pbalanceassertion) . filter (/= missingamt) $ amountsRaw pamount
setYear :: Year -> JournalParser m ()

View File

@ -711,7 +711,7 @@ postingp mTransactionYear = do
return (status, account)
let (ptype, account') = (accountNamePostingType account, textUnbracket account)
lift skipNonNewlineSpaces
amount <- option missingmixedamt $ mixedAmount <$> amountp
amount <- optional amountp
lift skipNonNewlineSpaces
massertion <- optional balanceassertionp
lift skipNonNewlineSpaces
@ -721,7 +721,7 @@ postingp mTransactionYear = do
, pdate2=mdate2
, pstatus=status
, paccount=account'
, pamount=amount
, pamount=maybe missingmixedamt mixedAmount amount
, pcomment=comment
, ptype=ptype
, ptags=tags
@ -823,7 +823,7 @@ tests_JournalReader = tests "JournalReader" [
" expenses:food:dining $10.00 ; a: a a \n ; b: b b \n"
posting{
paccount="expenses:food:dining",
pamount=Mixed [usd 10],
pamount=mixedAmount (usd 10),
pcomment="a: a a\nb: b b\n",
ptags=[("a","a a"), ("b","b b")]
}
@ -832,7 +832,7 @@ tests_JournalReader = tests "JournalReader" [
" a 1. ; date:2012/11/28, date2=2012/11/29,b:b\n"
nullposting{
paccount="a"
,pamount=Mixed [num 1]
,pamount=mixedAmount (num 1)
,pcomment="date:2012/11/28, date2=2012/11/29,b:b\n"
,ptags=[("date", "2012/11/28"), ("date2=2012/11/29,b", "b")] -- TODO tag name parsed too greedily
,pdate=Just $ fromGregorian 2012 11 28
@ -843,7 +843,7 @@ tests_JournalReader = tests "JournalReader" [
" a 1. ; [2012/11/28=2012/11/29]\n"
nullposting{
paccount="a"
,pamount=Mixed [num 1]
,pamount=mixedAmount (num 1)
,pcomment="[2012/11/28=2012/11/29]\n"
,ptags=[]
,pdate= Just $ fromGregorian 2012 11 28
@ -872,7 +872,7 @@ tests_JournalReader = tests "JournalReader" [
"= (some value expr)\n some:postings 1.\n"
nulltransactionmodifier {
tmquerytxt = "(some value expr)"
,tmpostingrules = [nullposting{paccount="some:postings", pamount=Mixed[num 1]}]
,tmpostingrules = [nullposting{paccount="some:postings", pamount=mixedAmount (num 1)}]
}
]
@ -905,7 +905,7 @@ tests_JournalReader = tests "JournalReader" [
pdate=Nothing,
pstatus=Cleared,
paccount="a",
pamount=Mixed [usd 1],
pamount=mixedAmount (usd 1),
pcomment="pcomment1\npcomment2\nptag1: val1\nptag2: val2\n",
ptype=RegularPosting,
ptags=[("ptag1","val1"),("ptag2","val2")],

View File

@ -182,7 +182,7 @@ entryp = do
tstatus = Cleared,
tpostings = [
nullposting{paccount=a
,pamount=Mixed [amountSetPrecision (Precision 2) $ num hours] -- don't assume hours; do set precision to 2
,pamount=mixedAmount . amountSetPrecision (Precision 2) $ num hours -- don't assume hours; do set precision to 2
,ptype=VirtualPosting
,ptransaction=Just t
}

View File

@ -90,7 +90,7 @@ Right samplejournal2 =
tcomment="",
ttags=[],
tpostings=
[posting {paccount="assets:bank:checking", pamount=Mixed [usd 1]}
[posting {paccount="assets:bank:checking", pamount=mixedAmount (usd 1)}
,posting {paccount="income:salary", pamount=missingmixedamt}
],
tprecedingcomment=""

View File

@ -170,7 +170,7 @@ postingsReportItems ((p,menddate):ps) (pprev,menddateprev) wd d b runningcalcfn
isdifferentdate = case wd of PrimaryDate -> postingDate p /= postingDate pprev
SecondaryDate -> postingDate2 p /= postingDate2 pprev
p' = p{paccount= clipOrEllipsifyAccountName d $ paccount p}
b' = runningcalcfn itemnum b (pamount p)
b' = runningcalcfn itemnum b $ pamount p
-- | Generate one postings report line item, containing the posting,
-- the current running balance, and optionally the posting date and/or
@ -231,7 +231,7 @@ summarisePostingsInDateSpan (DateSpan b e) wd mdepth showempty ps
isclipped a = maybe True (accountNameLevel a >=) mdepth
negatePostingAmount :: Posting -> Posting
negatePostingAmount p = p { pamount = maNegate $ pamount p }
negatePostingAmount = postingTransformAmount negate
-- tests

View File

@ -72,7 +72,7 @@ tsDraw UIState{aopts=UIOpts{cliopts_=copts@CliOpts{reportspec_=rspec@ReportSpec{
_ -> [maincontent]
where
-- as with print, show amounts with all of their decimal places
t = transactionMapPostingAmounts amountSetFullPrecision t'
t = transactionMapPostingAmounts mixedAmountSetFullPrecision t'
maincontent = Widget Greedy Greedy $ do
let
prices = journalPriceOracle (infer_value_ ropts) j

View File

@ -158,7 +158,7 @@ validatePostings acctRes amtRes = let
zipRow (Left e) (Left e') = Left (Just e, Just e')
zipRow (Left e) (Right _) = Left (Just e, Nothing)
zipRow (Right _) (Left e) = Left (Nothing, Just e)
zipRow (Right acct) (Right amt) = Right (nullposting {paccount = acct, pamount = Mixed [amt]})
zipRow (Right acct) (Right amt) = Right (nullposting {paccount = acct, pamount = mixedAmount amt})
errorToFormMsg = first (("Invalid value: " <>) . T.pack .
foldl (\s a -> s <> parseErrorTextPretty a) "" .

View File

@ -233,7 +233,7 @@ confirmedTransactionWizard prevInput es@EntryState{..} stack@(currentStage : _)
EnterAmountAndComment txnParams account -> amountAndCommentWizard prevInput es >>= \case
Just (amount, comment) -> do
let posting = nullposting{paccount=T.pack $ stripbrackets account
,pamount=Mixed [amount]
,pamount=mixedAmount amount
,pcomment=comment
,ptype=accountNamePostingType $ T.pack account
}

View File

@ -98,7 +98,7 @@ close CliOpts{rawopts_=rawopts, reportspec_=rspec} j = do
closingps =
concat [
[posting{paccount = a
,pamount = mixed [precise $ negate b]
,pamount = mixedAmount . precise $ negate b
-- after each commodity's last posting, assert 0 balance (#1035)
-- balance assertion amounts are unpriced (#824)
,pbalanceassertion =
@ -108,11 +108,11 @@ close CliOpts{rawopts_=rawopts, reportspec_=rspec} j = do
}
]
-- maybe an interleaved posting transferring this balance to equity
++ [posting{paccount=closingacct, pamount=Mixed [precise b]} | interleaved]
++ [posting{paccount=closingacct, pamount=mixedAmount $ precise b} | interleaved]
| -- get the balances for each commodity and transaction price
(a,_,_,mb) <- acctbals
, let bs = amounts $ normaliseMixedAmount mb
, let bs = amounts mb
-- mark the last balance in each commodity with True
, let bs' = concat [reverse $ zip (reverse bs) (True : repeat False)
| bs <- groupBy ((==) `on` acommodity) bs]
@ -121,21 +121,21 @@ close CliOpts{rawopts_=rawopts, reportspec_=rspec} j = do
-- or a final multicommodity posting transferring all balances to equity
-- (print will show this as multiple single-commodity postings)
++ [posting{paccount=closingacct, pamount=if explicit then mapMixedAmount precise totalamt else missingmixedamt} | not interleaved]
++ [posting{paccount=closingacct, pamount=if explicit then mixedAmountSetFullPrecision totalamt else missingmixedamt} | not interleaved]
-- the opening transaction
openingtxn = nulltransaction{tdate=openingdate, tdescription=openingdesc, tpostings=openingps}
openingps =
concat [
[posting{paccount = a
,pamount = mixed [precise b]
,pamount = mixedAmount $ precise b
,pbalanceassertion =
case mcommoditysum of
Just s -> Just nullassertion{baamount=precise s{aprice=Nothing}}
Nothing -> Nothing
}
]
++ [posting{paccount=openingacct, pamount=Mixed [precise $ negate b]} | interleaved]
++ [posting{paccount=openingacct, pamount=mixedAmount . precise $ negate b} | interleaved]
| (a,_,_,mb) <- acctbals
, let bs = amounts $ normaliseMixedAmount mb
@ -145,7 +145,7 @@ close CliOpts{rawopts_=rawopts, reportspec_=rspec} j = do
, let commoditysum = (sum bs)]
, (b, mcommoditysum) <- bs'
]
++ [posting{paccount=openingacct, pamount=if explicit then mapMixedAmount precise (maNegate totalamt) else missingmixedamt} | not interleaved]
++ [posting{paccount=openingacct, pamount=if explicit then mixedAmountSetFullPrecision (maNegate totalamt) else missingmixedamt} | not interleaved]
-- print them
when closing . T.putStr $ showTransaction closingtxn

View File

@ -33,7 +33,7 @@ prices opts j = do
ps = filter (matchesPosting q) $ allPostings j
mprices = jpricedirectives j
cprices = map (stylePriceDirectiveExceptPrecision styles) $ concatMap postingsPriceDirectivesFromCosts ps
icprices = map (stylePriceDirectiveExceptPrecision styles) $ concatMap postingsPriceDirectivesFromCosts $ mapAmount invertPrice ps
icprices = map (stylePriceDirectiveExceptPrecision styles) $ concatMap postingsPriceDirectivesFromCosts $ map (postingTransformAmount $ mapMixedAmount invertPrice) ps
allprices = mprices ++ ifBoolOpt "costs" cprices ++ ifBoolOpt "inverted-costs" icprices
mapM_ (T.putStrLn . showPriceDirective) $
sortOn pddate $
@ -71,8 +71,8 @@ invertPrice a =
pa' = pa { aquantity = abs $ aquantity a, acommodity = acommodity a, aprice = Nothing, astyle = astyle a }
postingsPriceDirectivesFromCosts :: Posting -> [PriceDirective]
postingsPriceDirectivesFromCosts p = mapMaybe (amountPriceDirectiveFromCost date) . amounts $ pamount p where
date = fromMaybe (tdate . fromJust $ ptransaction p) $ pdate p
postingsPriceDirectivesFromCosts p = mapMaybe (amountPriceDirectiveFromCost date) . amountsRaw $ pamount p
where date = fromMaybe (tdate . fromJust $ ptransaction p) $ pdate p
amountPriceDirectiveFromCost :: Day -> Amount -> Maybe PriceDirective
amountPriceDirectiveFromCost d a =
@ -92,8 +92,3 @@ stylePriceDirectiveExceptPrecision styles pd@PriceDirective{pdamount=a} =
allPostings :: Journal -> [Posting]
allPostings = concatMap tpostings . jtxns
mapAmount :: (Amount -> Amount) -> [Posting] -> [Posting]
mapAmount f = map pf where
pf p = p { pamount = mf (pamount p) }
mf = mixed . map f . amounts

View File

@ -59,7 +59,7 @@ print' opts j = do
-- that. For now we try to reverse it by increasing all amounts' decimal places
-- sufficiently to show the amount exactly. The displayed amounts may have minor
-- differences from the originals, such as trailing zeroes added.
let j' = journalMapPostingAmounts amountSetFullPrecision j
let j' = journalMapPostingAmounts mixedAmountSetFullPrecision j
case maybestringopt "match" $ rawopts_ opts of
Nothing -> printEntries opts j'
Just desc -> printMatch opts j' $ T.pack $ dbg1 "finding best match for description" desc
@ -181,7 +181,7 @@ postingToCSV p =
let credit = if q < 0 then showamt $ negate a_ else "" in
let debit = if q >= 0 then showamt a_ else "" in
[account, amount, c, credit, debit, status, comment])
. amounts $ pamount p
. amounts $ pamount p
where
status = T.pack . show $ pstatus p
account = showAccountName Nothing (ptype p) (paccount p)

View File

@ -190,7 +190,7 @@ postingsReportItemAsText opts preferredamtwidth preferredbalwidth (mdate, mendda
BalancedVirtualPosting -> (wrap "[" "]", acctwidth-2)
VirtualPosting -> (wrap "(" ")", acctwidth-2)
_ -> (id,acctwidth)
amt = showAmountsLinesB dopts . (\x -> if null x then [nullamt] else x) . amounts $ pamount p
amt = showAmountsLinesB dopts . (\x -> if null x then [nullamt] else x) . amountsRaw $ pamount p
bal = showAmountsLinesB dopts $ amounts b
-- Since postingsReport strips prices from all Amounts when not used, we can display prices.
dopts = oneLine{displayColour=color_, displayPrice=True}

View File

@ -82,7 +82,7 @@ showLedgerStats l today span =
path = journalFilePath j
ts = sortOn tdate $ filter (spanContainsDate span . tdate) $ jtxns j
as = nub $ map paccount $ concatMap tpostings ts
cs = either error' Map.keys $ commodityStylesFromAmounts $ concatMap (amounts . pamount) $ concatMap tpostings ts -- PARTIAL:
cs = either error' Map.keys . commodityStylesFromAmounts . concatMap (amountsRaw . pamount) $ concatMap tpostings ts -- PARTIAL:
lastdate | null ts = Nothing
| otherwise = Just $ tdate $ last ts
lastelapsed = fmap (diffDays today) lastdate