imp: bal: more predictable sort order with multiple commodities (#1563, #1564)

This change provides more predictable and intuitive behaviour when 
using -S/--sort-amount with multiple commodities. 
It implements a custom Ord (and Eq) instance for MixedAmount 
which substitutes zero for any missing commodities.

As a consequence, all the ways of representing zero with a MixedAmount ([],
[A 0], [A 0, B 0, ...]) are now Eq-ual (==), whereas before they were
not. We have not been able to find anything broken by this change.

* imp: lib: Compare MixedAmounts by substituting zero for any missing commodities. (#1563)
* ;doc: Update docs for new multicommodity sort by amount rules.
This commit is contained in:
Stephen Morgan 2021-07-13 17:11:50 +10:00 committed by GitHub
parent 3380190d9a
commit cf25d7d56d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 106 additions and 24 deletions

View File

@ -93,6 +93,7 @@ module Hledger.Data.Amount (
-- * MixedAmount -- * MixedAmount
nullmixedamt, nullmixedamt,
missingmixedamt, missingmixedamt,
isMissingMixedAmount,
mixed, mixed,
mixedAmount, mixedAmount,
maAddAmount, maAddAmount,
@ -540,6 +541,10 @@ nullmixedamt = Mixed mempty
missingmixedamt :: MixedAmount missingmixedamt :: MixedAmount
missingmixedamt = mixedAmount missingamt missingmixedamt = mixedAmount missingamt
-- | Whether a MixedAmount has a missing amount
isMissingMixedAmount :: MixedAmount -> Bool
isMissingMixedAmount (Mixed ma) = amountKey missingamt `M.member` ma
-- | Convert amounts in various commodities into a mixed amount. -- | Convert amounts in various commodities into a mixed amount.
mixed :: Foldable t => t Amount -> MixedAmount mixed :: Foldable t => t Amount -> MixedAmount
mixed = maAddAmounts nullmixedamt mixed = maAddAmounts nullmixedamt
@ -637,13 +642,12 @@ maIsNonZero = not . mixedAmountIsZero
-- --
amounts :: MixedAmount -> [Amount] amounts :: MixedAmount -> [Amount]
amounts (Mixed ma) amounts (Mixed ma)
| missingkey `M.member` ma = [missingamt] -- missingamt should always be alone, but detect it even if not | isMissingMixedAmount (Mixed ma) = [missingamt] -- missingamt should always be alone, but detect it even if not
| M.null nonzeros = [newzero] | M.null nonzeros = [newzero]
| otherwise = toList nonzeros | otherwise = toList nonzeros
where where
newzero = fromMaybe nullamt $ find (not . T.null . acommodity) zeros newzero = fromMaybe nullamt $ find (not . T.null . acommodity) zeros
(zeros, nonzeros) = M.partition amountIsZero ma (zeros, nonzeros) = M.partition amountIsZero ma
missingkey = amountKey missingamt
-- | Get a mixed amount's component amounts without normalising zero and missing -- | 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 -- amounts. This is used for JSON serialisation, so the order is important. In
@ -966,7 +970,14 @@ tests_Amount = tests "Amount" [
,tests "MixedAmount" [ ,tests "MixedAmount" [
test "adding mixed amounts to zero, the commodity and amount style are preserved" $ test "comparing mixed amounts compares based on quantities" $ do
let usdpos = mixed [usd 1]
usdneg = mixed [usd (-1)]
eurneg = mixed [eur (-12)]
compare usdneg usdpos @?= LT
compare eurneg usdpos @?= LT
,test "adding mixed amounts to zero, the commodity and amount style are preserved" $
maSum (map mixedAmount maSum (map mixedAmount
[usd 1.25 [usd 1.25
,usd (-1) `withPrecision` Precision 3 ,usd (-1) `withPrecision` Precision 3

View File

@ -183,7 +183,7 @@ isBalancedVirtual :: Posting -> Bool
isBalancedVirtual p = ptype p == BalancedVirtualPosting isBalancedVirtual p = ptype p == BalancedVirtualPosting
hasAmount :: Posting -> Bool hasAmount :: Posting -> Bool
hasAmount = (/= missingmixedamt) . pamount hasAmount = not . isMissingMixedAmount . pamount
hasBalanceAssignment :: Posting -> Bool hasBalanceAssignment :: Posting -> Bool
hasBalanceAssignment p = not (hasAmount p) && isJust (pbalanceassertion p) hasBalanceAssignment p = not (hasAmount p) && isJust (pbalanceassertion p)

View File

@ -27,8 +27,8 @@ module Hledger.Data.Types
where where
import GHC.Generics (Generic) import GHC.Generics (Generic)
import Data.Decimal import Data.Decimal (Decimal)
import Data.Default import Data.Default (Default(..))
import Data.Functor (($>)) import Data.Functor (($>))
import Data.List (intercalate) import Data.List (intercalate)
import Text.Blaze (ToMarkup(..)) import Text.Blaze (ToMarkup(..))
@ -230,7 +230,27 @@ data Amount = Amount {
aprice :: !(Maybe AmountPrice) -- ^ the (fixed, transaction-specific) price for this amount, if any aprice :: !(Maybe AmountPrice) -- ^ the (fixed, transaction-specific) price for this amount, if any
} deriving (Eq,Ord,Generic,Show) } deriving (Eq,Ord,Generic,Show)
newtype MixedAmount = Mixed (M.Map MixedAmountKey Amount) deriving (Eq,Ord,Generic,Show) newtype MixedAmount = Mixed (M.Map MixedAmountKey Amount) deriving (Generic,Show)
instance Eq MixedAmount where a == b = maCompare a b == EQ
instance Ord MixedAmount where compare = maCompare
-- | Compare two MixedAmounts, substituting 0 for the quantity of any missing
-- commodities in either.
maCompare :: MixedAmount -> MixedAmount -> Ordering
maCompare (Mixed a) (Mixed b) = go (M.toList a) (M.toList b)
where
go xss@((kx,x):xs) yss@((ky,y):ys) = case compare kx ky of
EQ -> compareQuantities (Just x) (Just y) <> go xs ys
LT -> compareQuantities (Just x) Nothing <> go xs yss
GT -> compareQuantities Nothing (Just y) <> go xss ys
go ((_,x):xs) [] = compareQuantities (Just x) Nothing <> go xs []
go [] ((_,y):ys) = compareQuantities Nothing (Just y) <> go [] ys
go [] [] = EQ
compareQuantities = comparing (maybe 0 aquantity) <> comparing (maybe 0 totalprice)
totalprice x = case aprice x of
Just (TotalPrice p) -> aquantity p
_ -> 0
-- | Stores the CommoditySymbol of the Amount, along with the CommoditySymbol of -- | Stores the CommoditySymbol of the Amount, along with the CommoditySymbol of
-- the price, and its unit price if being used. -- the price, and its unit price if being used.

View File

@ -259,6 +259,9 @@ Here are some ways to handle that:
With `-S/--sort-amount`, accounts with the largest (most positive) balances are shown first. With `-S/--sort-amount`, accounts with the largest (most positive) balances are shown first.
Eg: `hledger bal expenses -MAS` shows your biggest averaged monthly expenses first. Eg: `hledger bal expenses -MAS` shows your biggest averaged monthly expenses first.
When more than one commodity is present, they will be sorted by the alphabetically earliest
commodity first, and then by subsequent commodities (if an amount is missing a commodity, it
is treated as 0).
Revenues and liability balances are typically negative, however, so `-S` shows these in reverse order. Revenues and liability balances are typically negative, however, so `-S` shows these in reverse order.
To work around this, you can add `--invert` to flip the signs. To work around this, you can add `--invert` to flip the signs.

View File

@ -15,7 +15,7 @@
2018/1/1 2018/1/1
(a:k) 1 (a:k) 1
# In tree mode, rows are sorted alphabetically by account name, at each tree level. # 1. In tree mode, rows are sorted alphabetically by account name, at each tree level.
# Missing parent accounts are added (b). # Missing parent accounts are added (b).
$ hledger -f- bal -N --tree $ hledger -f- bal -N --tree
1 a:k 1 a:k
@ -24,7 +24,7 @@ $ hledger -f- bal -N --tree
1 j 1 j
1 c 1 c
# In flat mode, unused parent accounts are not added (b). # 2. In flat mode, unused parent accounts are not added (b).
$ hledger -f- bal -N --flat $ hledger -f- bal -N --flat
1 a:k 1 a:k
1 b:i 1 b:i
@ -56,7 +56,7 @@ account d
2018/1/1 2018/1/1
(d) 1 (d) 1
# With account directives, in tree mode, at each tree level # 3. With account directives, in tree mode, at each tree level
# declared accounts are sorted first in declaration order, # declared accounts are sorted first in declaration order,
# followed by undeclared accounts sorted alphabetically. # followed by undeclared accounts sorted alphabetically.
# Missing parent accounts are added (b). # Missing parent accounts are added (b).
@ -70,7 +70,7 @@ $ hledger -f- bal -N --tree
1 i 1 i
1 c 1 c
# In flat mode, unused parent accounts are not added (b). # 4. In flat mode, unused parent accounts are not added (b).
$ hledger -f- bal -N --flat $ hledger -f- bal -N --flat
1 d 1 d
1 a:l 1 a:l
@ -81,7 +81,7 @@ $ hledger -f- bal -N --flat
# ** Sort by amount # ** Sort by amount
# In flat mode with -S, largest (most-positive) amounts are shown first: # 5. In flat mode with -S, largest (most-positive) amounts are shown first:
< <
2018/1/1 2018/1/1
(b:j) 2 (b:j) 2
@ -101,7 +101,7 @@ $ hledger -f- bal -N -S --flat
1 b:i 1 b:i
1 c 1 c
# In tree mode with -S, rows are sorted by largest amount, and then by account name, at each tree level. # 6. In tree mode with -S, rows are sorted by largest amount, and then by account name, at each tree level.
$ hledger -f- bal -N -S --tree $ hledger -f- bal -N -S --tree
3 b 3 b
2 j 2 j
@ -109,8 +109,8 @@ $ hledger -f- bal -N -S --tree
1 a:k 1 a:k
1 c 1 c
# In hledger 1.4-1.10, when the larger amount was composed of differently-priced amounts, # 7. In hledger 1.4-1.10, when the larger amount was composed of differently-priced amounts,
# it could get sorted as if smaller. Should work now: # it could get sorted as if smaller. Should work now. Test tree mode.
< <
2018/1/1 2018/1/1
(a) 2X @ 1Y (a) 2X @ 1Y
@ -119,11 +119,16 @@ $ hledger -f- bal -N -S --tree
2018/1/1 2018/1/1
(b) 3X (b) 3X
$ hledger -f- bal -N -S $ hledger -f- bal -N -S --tree
4X a 4X a
3X b 3X b
# #1279 in hledger 1.11-1.18, bal -S did not respect the hierarchy. Should work now: # 8. Same in flat mode.
$ hledger -f- bal -N -S --flat
4X a
3X b
# 9. #1279 in hledger 1.11-1.18, bal -S did not respect the hierarchy. Should work now:
< <
2020-01-01 2020-01-01
(a:aa) 1 (a:aa) 1
@ -136,19 +141,19 @@ $ hledger -f- bal -N -S --tree
1 aa 1 aa
2 b 2 b
# #1287 bal -S -H did not sort by amount, should work now: # 10. #1287 bal -S -H did not sort by amount, should work now:
$ hledger -f- bal -N -S -H --flat $ hledger -f- bal -N -S -H --flat
3 a:ab 3 a:ab
2 b 2 b
1 a:aa 1 a:aa
# #1287 and bal -S --cumulative: # 11. #1287 and bal -S --cumulative:
$ hledger -f- bal -N -S --cumulative --flat $ hledger -f- bal -N -S --cumulative --flat
3 a:ab 3 a:ab
2 b 2 b
1 a:aa 1 a:aa
# #1283 most-negative amounts are sorted last, so eg largest revenues/liabilities are last: # 12. #1283 most-negative amounts are sorted last, so eg largest revenues/liabilities are last:
< <
2020-01-01 2020-01-01
(revenues:a) -1 (revenues:a) -1
@ -160,13 +165,13 @@ $ hledger -f- bal -N -S
-2 revenues:c -2 revenues:c
-3 revenues:b -3 revenues:b
# This can be worked around by using --invert (sorting happens after sign-flipping): # 13. This can be worked around by using --invert (sorting happens after sign-flipping):
$ hledger -f- bal -N -S --invert $ hledger -f- bal -N -S --invert
3 revenues:b 3 revenues:b
2 revenues:c 2 revenues:c
1 revenues:a 1 revenues:a
# Or a sign-flipping command like incomestatement: # 14. Or a sign-flipping command like incomestatement:
$ hledger -f- is -N -S $ hledger -f- is -N -S
Income Statement 2020-01-01 Income Statement 2020-01-01
@ -180,3 +185,46 @@ Income Statement 2020-01-01
============++============ ============++============
Expenses || Expenses ||
------------++------------ ------------++------------
<
2021-01-01 Post in X
(f) 1 X
2021-01-01 Post in X
(b) -1 X
2021-01-01 Post in X
(e) 1 X
(e) -1 Y
2021-01-01 Post in Y
(d) -1 Y
2021-01-01 Post in X
(c) -1 X
2021-01-01 Post in X
(a) -1 X
# 15. When sorting by amount with different commodities, missing commodities are
# treated as 0, so negative amounts go before positive amounts. (#1563)
#
# Explanation: We treats these amounts as
# 1 X, 0 Y f
# 1 X, -1 Y e
# 0 X, -1 Y d
# -1 X, 0 Y a
# -1 X, 0 Y b
# -1 X, 0 Y c
# X is alphabetically before Y, so the X amount determines the order.
# If X amounts are equal, the Y amount will decide, and so on.
# If all commodity amounts are equal (a, b, and c above), their sort order is
# determined by their account name.
$ hledger -f- bal -N -S
1 X f
1 X
-1 Y e
-1 Y d
-1 X a
-1 X b
-1 X c