imp: prices: clarify, fixes, improve semantics (precisiongeddon)

- The prices comand now more accurately lists the prices that hledger
  uses when calculating value reports (similar to what you'd see with
  eg `hledger bal -V --debug=2`).

- The prices command's --infer-reverse-prices flag was confusing since
  we always infer and use reverse prices; it has been renamed to --show-reverse.

- --infer-market-prices and --show-reverse combine properly.

- --show-reverse now ignores all zero prices rather than giving an error.

- Reverse prices (which can be infinite decimals) are now displayed
  with at most 8 decimal digits (rather than the internal precision of
  255 digits).

- Filtering prices by cur: or amt: now works properly.

- Price amounts are styled, but all decimal digits are shown.
This commit is contained in:
Simon Michael 2023-11-01 06:50:48 +00:00
parent 50dc7bebb1
commit c51d883162
6 changed files with 243 additions and 149 deletions

View File

@ -67,6 +67,7 @@ module Hledger.Data.Amount (
amountLooksZero,
divideAmount,
multiplyAmount,
invertAmount,
-- ** styles
amountstyle,
canonicaliseAmount,
@ -89,6 +90,7 @@ module Hledger.Data.Amount (
showAmountDebug,
showAmountWithoutPrice,
amountSetPrecision,
amountSetPrecisionMin,
withPrecision,
amountSetFullPrecision,
amountSetFullPrecisionUpTo,
@ -335,6 +337,10 @@ divideAmount n = transformAmount (/n)
multiplyAmount :: Quantity -> Amount -> Amount
multiplyAmount n = transformAmount (*n)
-- | Invert an amount (replace its quantity q with 1/q).
invertAmount :: Amount -> Amount
invertAmount a@Amount{aquantity=q} = a{aquantity=1/q}
-- | Is this amount negative ? The price is ignored.
isNegativeAmount :: Amount -> Bool
isNegativeAmount Amount{aquantity=q} = q < 0
@ -375,6 +381,12 @@ withPrecision = flip amountSetPrecision
amountSetPrecision :: AmountPrecision -> Amount -> Amount
amountSetPrecision p a@Amount{astyle=s} = a{astyle=s{asprecision=p}}
-- | Ensure an amount's display precision is at least the given minimum precision.
-- Always sets an explicit Precision.
amountSetPrecisionMin :: Word8 -> Amount -> Amount
amountSetPrecisionMin minp a = amountSetPrecision p a
where p = Precision $ max minp (amountDisplayPrecision a)
-- | Increase an amount's display precision, if needed, to enough decimal places
-- to show it exactly (showing all significant decimal digits, without trailing zeros).
-- If the amount's display precision is unset, it will be treated as precision 0.
@ -784,14 +796,12 @@ isNegativeMixedAmount m =
_ -> Nothing -- multiple amounts with different signs
-- | Does this mixed amount appear to be zero when rendered with its display precision?
-- i.e. does it have zero quantity with no price, zero quantity with a total price (which is also zero),
-- and zero quantity for each unit price?
-- See amountLooksZero.
mixedAmountLooksZero :: MixedAmount -> Bool
mixedAmountLooksZero (Mixed ma) = all amountLooksZero ma
-- | Is this mixed amount exactly zero, ignoring its display precision?
-- i.e. does it have zero quantity with no price, zero quantity with a total price (which is also zero),
-- and zero quantity for each unit price?
-- See amountIsZero.
mixedAmountIsZero :: MixedAmount -> Bool
mixedAmountIsZero (Mixed ma) = all amountIsZero ma

View File

@ -203,8 +203,12 @@ amountValueAtDate priceoracle styles mto d a =
-- The display precision will be that of nullamt (0).
-- Now apply the standard display style for comm
& styleAmounts styles
-- and set the display precision to rate's (internal) precision
-- and set the display precision to rate's internal precision
-- XXX (unnormalised - don't strip trailing zeros) ?
-- XXX valuation.test:8 why is it showing precision 1 ?
& amountSetPrecision (Precision $ decimalPlaces $ normalizeDecimal rate)
-- or at least 1, ensuring we show at least one decimal place.
-- & amountSetPrecision (Precision $ max 1 (decimalPlaces $ normalizeDecimal rate))
-- see also print-styles.test, valuation2.test
-- | Calculate the gain of each component amount, that is the difference
@ -447,6 +451,8 @@ makePriceGraph alldeclaredprices allinferredprices d =
,pgDefaultValuationCommodities=defaultdests
}
where
-- XXX logic duplicated in Hledger.Cli.Commands.Prices.prices, keep synced
-- prices in effect on date d, either declared or inferred
visibledeclaredprices = dbg9 "visibledeclaredprices" $ filter ((<=d).mpdate) alldeclaredprices
visibleinferredprices = dbg9 "visibleinferredprices" $ filter ((<=d).mpdate) allinferredprices

View File

@ -1,5 +1,6 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE NamedFieldPuns #-}
module Hledger.Cli.Commands.Prices (
pricesmode
@ -7,78 +8,97 @@ module Hledger.Cli.Commands.Prices (
)
where
import qualified Data.Map as M
import Data.List
import qualified Data.Text as T
import qualified Data.Text.IO as T
import Hledger
import Hledger.Cli.CliOptions
import System.Console.CmdArgs.Explicit
import Data.Maybe (mapMaybe)
pricesmode = hledgerCommandMode
$(embedFileRelative "Hledger/Cli/Commands/Prices.txt")
[flagNone ["infer-reverse-prices"] (setboolopt "infer-reverse-prices") "also show prices obtained by inverting transaction prices"
[flagNone ["show-reverse"] (setboolopt "show-reverse")
"also show the prices inferred by reversing known prices"
]
[generalflagsgroup1]
(hiddenflags ++
[flagNone ["costs"] (setboolopt "infer-market-prices") "deprecated, use --infer-market-prices instead"
,flagNone ["inverted-costs"] (setboolopt "infer-reverse-prices") "deprecated, use --infer-reverse-prices instead"
[flagNone ["costs"] (setboolopt "infer-market-prices") "deprecated, use --infer-market-prices instead"
,flagNone ["inverted-costs"] (setboolopt "show-reverse") "deprecated, use --show-reverse instead"
,flagNone ["infer-reverse-prices"] (setboolopt "show-reverse") "deprecated, use --show-reverse instead"
])
([], Just $ argsFlag "[QUERY]")
-- XXX the original hledger-prices script always ignored assertions
instance HasAmounts PriceDirective where
styleAmounts styles pd = pd{pdamount=styleAmounts styles $ pdamount pd}
-- List market prices.
prices opts j = do
let
styles = journalCommodityStyles j
q = _rsQuery $ reportspec_ opts
ps = filter (matchesPosting q) $ allPostings j
mprices = jpricedirectives j
cprices =
map (stylePriceDirectiveExceptPrecision styles) $
concatMap postingPriceDirectivesFromCost ps
rcprices =
map (stylePriceDirectiveExceptPrecision styles) $
concatMap (postingPriceDirectivesFromCost . postingTransformAmount (mapMixedAmount invertPrice))
ps
allprices =
mprices
++ ifBoolOpt "infer-market-prices" cprices
++ ifBoolOpt "infer-reverse-prices" rcprices -- TODO: shouldn't this show reversed P prices also ? valuation will use them
styles = journalCommodityStyles j
q = _rsQuery $ reportspec_ opts
mapM_ (T.putStrLn . showPriceDirective) $
sortOn pddate $
filter (matchesPriceDirective q) $
allprices
-- XXX duplicates logic in Hledger.Data.Valuation.makePriceGraph, keep synced
declaredprices =
-- dbg0 "declaredprices" $
jpricedirectives j
pricesfromcosts =
-- dbg0 "pricesfromcosts" $
concatMap postingPriceDirectivesFromCost $
journalPostings j
forwardprices =
-- dbg0 "forwardprices" $
if boolopt "infer-market-prices" (rawopts_ opts)
then declaredprices `mergePriceDirectives` pricesfromcosts
else declaredprices
reverseprices =
-- dbg0 "reverseprices" $
mapMaybe reversePriceDirective forwardprices
allprices =
-- dbg0 "allprices" $
if boolopt "show-reverse" (rawopts_ opts)
then forwardprices `mergePriceDirectives` reverseprices
else forwardprices
filteredprices =
-- dbg0 "filtered unsorted" $
filter (matchesPriceDirective q) allprices
mapM_ (T.putStrLn . showPriceDirective . styleAmounts styles) $
sortOn pddate filteredprices
-- XXX performance
-- | Append any new price directives (with different from commodity,
-- to commodity, or date) from the second list to the first.
-- (Does not remove redundant prices from the first; just avoids adding more.)
mergePriceDirectives :: [PriceDirective] -> [PriceDirective] -> [PriceDirective]
mergePriceDirectives pds1 pds2 =
pds1 ++ [ pd | pd <- pds2 , pdid pd `notElem` pds1ids ]
where
ifBoolOpt opt | boolopt opt $ rawopts_ opts = id
| otherwise = const []
pds1ids = map pdid pds1
pdid PriceDirective{pddate,pdcommodity,pdamount} = (pddate, pdcommodity, acommodity pdamount)
showPriceDirective :: PriceDirective -> T.Text
showPriceDirective mp = T.unwords ["P", T.pack . show $ pddate mp, quoteCommoditySymbolIfNeeded $ pdcommodity mp, wbToText . showAmountB noColour{displayZeroCommodity=True} $ pdamount mp]
showPriceDirective mp = T.unwords [
"P",
T.pack . show $ pddate mp,
quoteCommoditySymbolIfNeeded $ pdcommodity mp,
wbToText . showAmountB noColour{displayZeroCommodity=True} $ pdamount mp
]
-- XXX
-- | Invert an amount's price for --invert-cost, somehow ? Unclear.
invertPrice :: Amount -> Amount
invertPrice a =
case aprice a of
Nothing -> a
Just (UnitPrice pa) -> invertPrice
-- normalize to TotalPrice
a { aprice = Just $ TotalPrice pa' } where
pa' = ((1 / aquantity a) `divideAmount` pa) { aprice = Nothing }
Just (TotalPrice pa) ->
a { aquantity = aquantity pa * nonZeroSignum (aquantity a), acommodity = acommodity pa, aprice = Just $ TotalPrice pa' } where
pa' = pa { aquantity = abs $ aquantity a, acommodity = acommodity a, aprice = Nothing, astyle = astyle a }
where
nonZeroSignum x = if x < 0 then -1 else 1
-- | Given a map of standard amount display styles, apply the
-- appropriate one, if any, to this price directive's amount.
-- But keep the number of decimal places unchanged.
stylePriceDirectiveExceptPrecision :: M.Map CommoditySymbol AmountStyle -> PriceDirective -> PriceDirective
stylePriceDirectiveExceptPrecision styles pd@PriceDirective{pdamount=a} =
pd{pdamount = styleAmounts styles a}
allPostings :: Journal -> [Posting]
allPostings = concatMap tpostings . jtxns
-- | Convert a market price directive to a corresponding one in the
-- opposite direction, if possible. (A price directive specifying zero
-- as the price can't be reversed.)
-- The display precision is set to show all significant decimal digits,
-- up to a maximum of 8 (this is visible eg in the prices command's output).
reversePriceDirective :: PriceDirective -> Maybe PriceDirective
reversePriceDirective pd@PriceDirective{pdcommodity=c, pdamount=a}
| amountIsZero a = Nothing
| otherwise =
Just pd{pdcommodity=acommodity a, pdamount=setprec $ invertAmount a{acommodity=c}}
where setprec = amountSetFullPrecisionUpTo 8

View File

@ -1,9 +1,17 @@
## prices
Print [market price directives](https://hledger.org/hledger.html#market-prices) from the journal.
With --infer-market-prices, generate additional market prices from [costs](https://hledger.org/hledger.html#costs).
With --infer-reverse-prices, also generate market prices by inverting known prices.
Prices can be filtered by a query.
Price amounts are displayed with their full precision.
Print the [market prices](hledger.md#p-directive) declared with P directives.
With --infer-market-prices, also show any additional prices inferred from [costs](hledger.md#costs).
With --show-reverse, also show additional prices inferred by reversing known prices.
Price amounts are always displayed with their full precision,
except for reverse prices which are limited to 8 decimal digits.
Prices can be filtered by a date:, cur: or amt: query.
Generally if you run this command with --infer-market-prices --show-reverse,
it will show the same prices used internally to calculate value reports.
But if in doubt, you can inspect those directly by running the value report
with --debug=2.
_FLAGS

View File

@ -5231,9 +5231,7 @@ the `V` key always resets it to "end".)
To convert a commodity A to its market value in another commodity B,
hledger looks for a suitable market price (exchange rate) as follows,
in this order of preference
<!-- (-X tries all of these; -V tries only 1) (really ?) -->
:
in this order of preference:
1. A *declared market price* or *inferred market price*:
A's latest market price in B on or before the valuation date

View File

@ -1,92 +1,119 @@
# * prices command
# ** 1. by default only market prices are reported
# ** 1. By default it lists market prices declared with P directives.
# It shows them with their original precisions, ignoring commodity display styles.
# Redundant P directives on the same date are shown (though only the last is used in value reports).
<
P 2016/1/1 EUR $1.06
P 2016/2/1 EUR $1.05
P 2023-01-01 B A10
P 2023-01-01 B A100
P 2023-01-01 A B100
2016/1/1 paycheck
income:remuneration $-100
income:donations $-15
assets:bank
2023-01-02
(a) A2 @ B1
2016/1/2 spend
expenses 20 EUR @ $1.07
assets:bank
$ hledger prices -f-
P 2016-01-01 EUR $1.06
P 2016-02-01 EUR $1.05
2023-01-02
(a) B1 @ A200
# ** 2. costs from postings can be included also
<
P 2016/1/1 EUR $1.06
P 2016/2/1 EUR $1.05
2023-01-03
a B1
b A-3
2016/1/1 paycheck
income:remuneration $-100
income:donations $-15
assets:bank
2023-01-04
(a) B1 @@ A4
2016/1/2 spend
expenses 20 EUR @ $1.07
assets:bank
2023-01-05
(a) B-1 @@ A5
2016/1/3 spend
expenses 20 EUR @@ $21.45
assets:bank
2023-01-06
a B1
b
2023-01-07
(a) B1 @ A7
2016/1/4 spend
expenses -20 EUR @@ $21.45
assets:bank
P 2023-01-07 B A70
$ hledger prices -f- -c A1.00
P 2023-01-01 B A10
P 2023-01-01 B A100
P 2023-01-01 A B100
P 2023-01-07 B A70
# ** 2. With --show-reverse it also lists any new prices inferred by inverting known prices.
# Reverse prices are shown with all of their decimal digits up to a maximum of 8.
# Redundant reverse prices are discarded.
$ hledger prices -f- --show-reverse
P 2023-01-01 B A10
P 2023-01-01 B A100
P 2023-01-01 A B100
P 2023-01-07 B A70
P 2023-01-07 A B0.01428571
# ** 3. With --infer-market-prices it also lists prices inferred from costs
# (explicit or inferred, unit or total, positive or negative amounts).
# Redundant prices inferred from costs are discarded.
# ** XXX why precision 1 for 3/4/5 ?
$ hledger prices -f- --infer-market-prices
P 2016-01-01 EUR $1.06
P 2016-01-02 EUR $1.07
P 2016-01-03 EUR $1.0725
P 2016-01-04 EUR $1.0725
P 2016-02-01 EUR $1.05
P 2023-01-01 B A10
P 2023-01-01 B A100
P 2023-01-01 A B100
P 2023-01-02 A B1
P 2023-01-02 B A200
P 2023-01-03 B A3.0
P 2023-01-04 B A4.0
P 2023-01-05 B A5.0
P 2023-01-07 B A70
# ** 3. inverted prices can be calculated
# ** 4. --infer-market-prices and --show-reverse combine.
$ hledger prices -f- --infer-market-prices --show-reverse
P 2023-01-01 B A10
P 2023-01-01 B A100
P 2023-01-01 A B100
P 2023-01-02 A B1
P 2023-01-02 B A200
P 2023-01-03 B A3.0
P 2023-01-03 A B0.33333333
P 2023-01-04 B A4.0
P 2023-01-04 A B0.25
P 2023-01-05 B A5.0
P 2023-01-05 A B0.2
P 2023-01-07 B A70
P 2023-01-07 A B0.01428571
# ** 5. Prices can be filtered by a date: query.
$ hledger prices -f- --infer-market-prices --show-reverse date:2023-01-01
P 2023-01-01 B A10
P 2023-01-01 B A100
P 2023-01-01 A B100
# ** 6. Prices can be filtered by a cur: query.
$ hledger prices -f- --infer-market-prices --show-reverse cur:A
P 2023-01-01 A B100
P 2023-01-02 A B1
P 2023-01-03 A B0.33333333
P 2023-01-04 A B0.25
P 2023-01-05 A B0.2
P 2023-01-07 A B0.01428571
# ** 7. Prices can be filtered by a amt: query.
$ hledger prices -f- --infer-market-prices --show-reverse amt:100
P 2023-01-01 B A100
P 2023-01-01 A B100
# ** 8. Zero prices, which can't be reversed, are not reversed.
<
P 2016/1/1 EUR $1.06
P 2016/2/1 EUR $1.05
P 2021-10-16 B 0.0 A
$ hledger -f- prices --show-reverse
P 2021-10-16 B 0.0 A
2016/1/1 paycheck
income:remuneration $-100
income:donations $-15
assets:bank
2016/1/3 spend
expenses $21.45 @@ 20.00 EUR
assets:bank
$ hledger prices -f- --infer-reverse-prices
P 2016-01-01 EUR $1.06
P 2016-01-03 EUR $1.0725
P 2016-02-01 EUR $1.05
#
# ** 9. Zero postings, which can't determine a price, are ignored.
<
commodity 1.000,00 A
2021-10-16
(b) 0 A @@ 1 B
P 2019-01-01 X A1000,123
P 2019-01-02 X A1000,1
2019-02-01
(a) X1 @ A1000,2345
2019-02-02
(a) X1 @ A1000,2
# ** 4. Commodity styles are applied, but precision is left unchanged.
$ hledger -f- prices
P 2019-01-01 X 1.000,123 A
P 2019-01-02 X 1.000,1 A
# ** 5. Commodity styles aren't yet applied to prices inferred from transaction prices.
$ hledger -f- prices --infer-market-prices
P 2019-01-01 X 1.000,123 A
P 2019-01-02 X 1.000,1 A
P 2019-02-01 X 1.000,2345 A
P 2019-02-02 X 1.000,2 A
# ** 10. Inferring prices should play well with balance assertions mixing prices and no prices. (#1736)
<
;; Total asset value should be 400 USD + 1000 USD = 1400 USD
2021-10-15 Broker initial balance (equity ABC)
@ -97,22 +124,47 @@ P 2019-02-02 X 1.000,2 A
Assets:Broker = 1000 USD
Equity:Opening Balances
# ** 6. Inferring prices should play well with balance assertions involving mixing
# of prices and no prices. (#1736)
$ hledger -f- prices --infer-market-prices
P 2021-10-15 ABC 100.0 USD
# ** 11. Commodity styles are applied to all price amounts, but their precision is left unchanged.
<
2021-10-15
(a) 1 A @@ 0 B
commodity 1.000,00 A
2021-10-16
(b) 0 A @@ 1 B
P 2019-01-01 X A1000,123
P 2019-01-02 X A1000,1
# ** 7. Gracefully ignore any postings which would result in an infinite price.
$ hledger -f- prices --infer-market-prices
P 2021-10-15 A 0.0 B
2019-02-01
(a) X1 @ A1000,2345
2019-02-02
(a) X1 @ A1000,2
2019-03-01
(a) 20 EUR @ $1.07
$ hledger -f- prices --infer-market-prices --show-reverse
P 2019-01-01 X 1.000,123 A
P 2019-01-01 A X0.00099988
P 2019-01-02 X 1.000,1 A
P 2019-01-02 A X0.00099990
P 2019-02-01 X 1.000,2345 A
P 2019-02-01 A X0.00099977
P 2019-02-02 X 1.000,2 A
P 2019-02-02 A X0.00099980
P 2019-03-01 EUR $1.07
P 2019-03-01 $ 0.93457944 EUR
# ** 12. Reverse market prices are shown with all decimal digits, up to a maximum of 8.
# ** XXX does this prevent displaying more precise prices ?
<
P 2023-01-01 B 3A
2023-01-01
a 1A
b
$ hledger -f - prices --show-reverse
P 2023-01-01 B 3A
P 2023-01-01 A 0.33333333B
# ** 8. Same for reverse prices
$ hledger -f- prices --infer-reverse-prices
P 2021-10-16 B 0.0 A