Skip to content

Commit c54fb4d

Browse files
Xitian9simonmichael
authored andcommitted
imp: cost: Allow matching equity conversion equity postings to
transaction prices. When given --infer-costs, hledger will now separately infer transaction prices for different prices. Given a pair of adjacent conversion postings, hledger will check if there is a single posting with a transaction price which matches both the amounts. If so, it associates those conversion postings to that priced post. If it can't find any transaction price postings which match, it will find the first non-transaction price post which matches one of the two amounts, and will add a transaction price to that, and associate them.
1 parent 07d1b01 commit c54fb4d

File tree

9 files changed

+562
-287
lines changed

9 files changed

+562
-287
lines changed

hledger-lib/Hledger/Data/Balancing.hs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,16 +97,18 @@ transactionCheckBalanced BalancingOpts{commodity_styles_} t = errs
9797

9898
-- check for mixed signs, detecting nonzeros at display precision
9999
canonicalise = maybe id canonicaliseMixedAmount commodity_styles_
100+
postingBalancingAmount p
101+
| "_price-matched" `elem` map fst (ptags p) = mixedAmountStripPrices $ pamount p
102+
| otherwise = mixedAmountCost $ pamount p
100103
signsOk ps =
101-
case filter (not.mixedAmountLooksZero) $ map (canonicalise.mixedAmountCost.pamount) ps of
104+
case filter (not.mixedAmountLooksZero) $ map (canonicalise.postingBalancingAmount) ps of
102105
nonzeros | length nonzeros >= 2
103106
-> length (nubSort $ mapMaybe isNegativeMixedAmount nonzeros) > 1
104107
_ -> True
105108
(rsignsok, bvsignsok) = (signsOk rps, signsOk bvps)
106109

107110
-- check for zero sum, at display precision
108-
(rsum, bvsum) = (sumPostings rps, sumPostings bvps)
109-
(rsumcost, bvsumcost) = (mixedAmountCost rsum, mixedAmountCost bvsum)
111+
(rsumcost, bvsumcost) = (foldMap postingBalancingAmount rps, foldMap postingBalancingAmount bvps)
110112
(rsumdisplay, bvsumdisplay) = (canonicalise rsumcost, canonicalise bvsumcost)
111113
(rsumok, bvsumok) = (mixedAmountLooksZero rsumdisplay, mixedAmountLooksZero bvsumdisplay)
112114

hledger-lib/Hledger/Data/Journal.hs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -951,8 +951,10 @@ journalAddInferredEquityPostings j = journalMapTransactions (transactionAddInfer
951951
equityAcct = journalConversionAccount j
952952

953953
-- | Add inferred transaction prices from equity postings.
954-
journalAddPricesFromEquity :: Journal -> Journal
955-
journalAddPricesFromEquity j = journalMapTransactions (transactionAddPricesFromEquity $ jaccounttypes j) j
954+
journalAddPricesFromEquity :: Journal -> Either String Journal
955+
journalAddPricesFromEquity j = do
956+
ts <- mapM (transactionAddPricesFromEquity $ jaccounttypes j) $ jtxns j
957+
return j{jtxns=ts}
956958

957959
-- -- | Get this journal's unique, display-preference-canonicalised commodities, by symbol.
958960
-- journalCanonicalCommodities :: Journal -> M.Map String CommoditySymbol

hledger-lib/Hledger/Data/Posting.hs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -423,22 +423,23 @@ postingApplyValuation priceoracle styles periodlast today v p =
423423
postingToCost :: M.Map CommoditySymbol AmountStyle -> ConversionOp -> Posting -> Maybe Posting
424424
postingToCost _ NoConversionOp p = Just p
425425
postingToCost styles ToCost p
426-
| ("_matched-conversion-posting","") `elem` ptags p = Nothing
427-
| otherwise = Just $ postingTransformAmount (styleMixedAmount styles . mixedAmountCost) p
426+
-- If this is a conversion posting with a matched transaction price posting, ignore it
427+
| "_conversion-matched" `elem` map fst (ptags p) && noCost = Nothing
428+
| otherwise = Just $ postingTransformAmount (styleMixedAmount styles . mixedAmountCost) p
429+
where
430+
noCost = null . filter (isJust . aprice) . amountsRaw $ pamount p
428431

429432
-- | Generate inferred equity postings from a 'Posting' using transaction prices.
430433
-- Make sure not to generate equity postings when there are already matched
431434
-- conversion postings.
432435
postingAddInferredEquityPostings :: Text -> Posting -> [Posting]
433436
postingAddInferredEquityPostings equityAcct p
434-
| ("_matched-transaction-price","") `elem` ptags p = [p]
437+
| "_price-matched" `elem` map fst (ptags p) = [p]
435438
| otherwise = taggedPosting : concatMap conversionPostings priceAmounts
436439
where
437440
taggedPosting
438441
| null priceAmounts = p
439-
| otherwise = p{ pcomment = pcomment p `commentAddTag` priceTag
440-
, ptags = priceTag : ptags p
441-
}
442+
| otherwise = p{ ptags = ("_price-matched","") : ptags p }
442443
conversionPostings amt = case aprice amt of
443444
Nothing -> []
444445
Just _ -> [ cp{ paccount = accountPrefix <> amtCommodity
@@ -453,15 +454,14 @@ postingAddInferredEquityPostings equityAcct p
453454
amtCommodity = commodity amt
454455
costCommodity = commodity cost
455456
cp = p{ pcomment = pcomment p `commentAddTag` ("generated-posting","")
456-
, ptags = [("generated-posting", ""), ("_generated-posting", "")]
457+
, ptags = [("_conversion-matched", ""), ("generated-posting", ""), ("_generated-posting", "")]
457458
, pbalanceassertion = Nothing
458459
, poriginal = Nothing
459460
}
460461
accountPrefix = mconcat [ equityAcct, ":", T.intercalate "-" $ sort [amtCommodity, costCommodity], ":"]
461462
-- Take the commodity of an amount and collapse consecutive spaces to a single space
462463
commodity = T.unwords . filter (not . T.null) . T.words . acommodity
463464

464-
priceTag = ("cost", T.strip . wbToText $ foldMap showAmountPrice priceAmounts)
465465
priceAmounts = filter (isJust . aprice) . amountsRaw $ pamount p
466466

467467
-- | Make a market price equivalent to this posting's amount's unit

hledger-lib/Hledger/Data/Transaction.hs

Lines changed: 127 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ tags.
77
88
-}
99

10+
{-# LANGUAGE MultiWayIf #-}
1011
{-# LANGUAGE NamedFieldPuns #-}
1112
{-# LANGUAGE OverloadedStrings #-}
13+
{-# LANGUAGE TupleSections #-}
1214

1315
module Hledger.Data.Transaction
1416
( -- * Transaction
@@ -44,18 +46,23 @@ module Hledger.Data.Transaction
4446
, showTransactionOneLineAmounts
4547
, showTransactionLineFirstPart
4648
, transactionFile
49+
-- * transaction errors
50+
, annotateErrorWithTransaction
4751
-- * tests
4852
, tests_Transaction
4953
) where
5054

51-
import Data.Bifunctor (second)
52-
import Data.Maybe (fromMaybe, mapMaybe)
55+
import Control.Monad.Trans.State (StateT(..), evalStateT)
56+
import Data.Bifunctor (first)
57+
import Data.Foldable (foldrM)
58+
import Data.Maybe (fromMaybe, isJust, mapMaybe)
59+
import Data.Semigroup (Endo(..))
5360
import Data.Text (Text)
61+
import qualified Data.Map as M
5462
import qualified Data.Text as T
5563
import qualified Data.Text.Lazy as TL
5664
import qualified Data.Text.Lazy.Builder as TB
5765
import Data.Time.Calendar (Day, fromGregorian)
58-
import qualified Data.Map as M
5966

6067
import Hledger.Utils
6168
import Hledger.Data.Types
@@ -219,34 +226,120 @@ transactionAddInferredEquityPostings :: AccountName -> Transaction -> Transactio
219226
transactionAddInferredEquityPostings equityAcct t =
220227
t{tpostings=concatMap (postingAddInferredEquityPostings equityAcct) $ tpostings t}
221228

222-
-- | Add inferred transaction prices from equity postings. The transaction
223-
-- price will be added to the first posting whose amount is the negation of one
224-
-- of the (exactly) two conversion postings, if it exists.
225-
transactionAddPricesFromEquity :: M.Map AccountName AccountType -> Transaction -> Transaction
226-
transactionAddPricesFromEquity acctTypes t
227-
| [(n1, cp1), (n2, cp2)] <- conversionps -- Exactly two conversion postings with indices
228-
, Just ca1 <- maybePostingAmount cp1, Just ca2 <- maybePostingAmount cp2 -- Each conversion posting has exactly one amount
229-
, (np,pricep):_ <- mapMaybe (maybeAddPrice ca1 ca2) npostings -- Get the first posting which matches one of the conversion postings
230-
, let subPosting (n, p) = if n == np then pricep else if n == n1 then cp1 else if n == n2 then cp2 else p
231-
= t{tpostings = map subPosting npostings}
232-
| otherwise = t
229+
-- | Add inferred transaction prices from equity postings. For every adjacent
230+
-- pair of conversion postings, it will first search the postings with
231+
-- transaction prices to see if any match. If so, it will tag it as matched.
232+
-- If no postings with transaction prices match, it will then search the
233+
-- postings without transaction prices, and will match the first such posting
234+
-- which matches one of the conversion amounts. If it finds a match, it will
235+
-- add a transaction price and then tag it.
236+
type IdxPosting = (Int, Posting)
237+
transactionAddPricesFromEquity :: M.Map AccountName AccountType -> Transaction -> Either String Transaction
238+
transactionAddPricesFromEquity acctTypes t = first (annotateErrorWithTransaction t . T.unpack) $ do
239+
(conversionPairs, stateps) <- partitionPs npostings
240+
f <- transformIndexedPostingsF addPricesToPostings conversionPairs stateps
241+
return t{tpostings = map (snd . f) npostings}
233242
where
234-
maybeAddPrice a1 a2 (n,p)
235-
| Just a <- mpamt, amountMatches (-a1) a = Just (n, markPosting p{pamount = mixedAmount a{aprice = Just $ TotalPrice a2}})
236-
| Just a <- mpamt, amountMatches (-a2) a = Just (n, markPosting p{pamount = mixedAmount a{aprice = Just $ TotalPrice a1}})
237-
| otherwise = Nothing
238-
where
239-
mpamt = maybePostingAmount p
240-
241-
conversionps = map (second (`postingAddTags` [("_matched-conversion-posting","")]))
242-
$ filter (\(_,p) -> M.lookup (paccount p) acctTypes == Just Conversion) npostings
243-
markPosting = (`postingAddTags` [("_matched-transaction-price","")])
243+
-- Include indices for postings
244244
npostings = zip [0..] $ tpostings t
245+
transformIndexedPostingsF f = evalStateT . fmap (appEndo . foldMap Endo) . traverse f
246+
247+
-- Sort postings into pairs of conversion postings, transaction price postings, and other postings
248+
partitionPs = fmap fst . foldrM select (([], ([], [])), Nothing)
249+
select np@(_, p) ((cs, others@(ps, os)), Nothing)
250+
| isConversion p = Right ((cs, others), Just np)
251+
| hasPrice p = Right ((cs, (np:ps, os)), Nothing)
252+
| otherwise = Right ((cs, (ps, np:os)), Nothing)
253+
select np@(_, p) ((cs, others), Just last)
254+
| isConversion p = Right (((last, np):cs, others), Nothing)
255+
| otherwise = Left "Conversion postings must occur in adjacent pairs"
256+
257+
-- Given a pair of indexed conversion postings, and a state consisting of lists of
258+
-- priced and unpriced non-conversion postings, create a function which adds transaction
259+
-- prices to the posting which matches the conversion postings if necessary, and tags
260+
-- the conversion and matched postings. Then update the state by removing the matched
261+
-- postings. If there are no matching postings or too much ambiguity, return an error
262+
-- string annotated with the conversion postings.
263+
addPricesToPostings :: (IdxPosting, IdxPosting)
264+
-> StateT ([IdxPosting], [IdxPosting]) (Either Text) (IdxPosting -> IdxPosting)
265+
addPricesToPostings ((n1, cp1), (n2, cp2)) = StateT $ \(priceps, otherps) -> do
266+
-- Get the two conversion posting amounts, if possible
267+
ca1 <- postingAmountNoPrice cp1
268+
ca2 <- postingAmountNoPrice cp2
269+
let -- The function to add transaction prices and tag postings in the indexed list of postings
270+
transformPostingF np pricep = \(n, p) ->
271+
(n, if | n == np -> pricep `postingAddTags` [("_price-matched","")]
272+
| n == n1 || n == n2 -> p `postingAddTags` [("_conversion-matched","")]
273+
| otherwise -> p)
274+
-- All priced postings which match the conversion posting pair
275+
matchingPricePs = mapMaybe (mapM $ pricedPostingIfMatchesBothAmounts ca1 ca2) priceps
276+
-- All other postings which match at least one of the conversion posting pair
277+
matchingOtherPs = mapMaybe (mapM $ addPriceIfMatchesOneAmount ca1 ca2) otherps
278+
279+
-- Annotate any errors with the conversion posting pair
280+
first (annotateWithPostings [cp1, cp2]) $
281+
if -- If a single transaction price posting matches the conversion postings,
282+
-- delete it from the list of priced postings in the state, delete the
283+
-- first matching unpriced posting from the list of non-priced postings
284+
-- in the state, and return the transformation function with the new state.
285+
| [(np, (pricep, _))] <- matchingPricePs
286+
, Just newpriceps <- deleteIdx np priceps
287+
-> Right (transformPostingF np pricep, (newpriceps, otherps))
288+
-- If no transaction price postings match the conversion postings, but some
289+
-- of the unpriced postings match, check that the first such posting has a
290+
-- different amount from all the others, and if so add a transaction price to
291+
-- it, then delete it from the list of non-priced postings in the state, and
292+
-- return the transformation function with the new state.
293+
| [] <- matchingPricePs
294+
, (np, (pricep, amt)):nps <- matchingOtherPs
295+
, not $ any (amountMatches amt . snd . snd) nps
296+
, Just newotherps <- deleteIdx np otherps
297+
-> Right (transformPostingF np pricep, (priceps, newotherps))
298+
-- Otherwise it's too ambiguous to make a guess, so return an error.
299+
| otherwise -> Left "There is not a unique posting which matches the conversion posting pair:"
300+
301+
-- If a posting with transaction price matches both the conversion amounts, return it along
302+
-- with the matching amount which must be present in another non-conversion posting.
303+
pricedPostingIfMatchesBothAmounts :: Amount -> Amount -> Posting -> Maybe (Posting, Amount)
304+
pricedPostingIfMatchesBothAmounts a1 a2 p = do
305+
a@Amount{aprice=Just _} <- postingSingleAmount p
306+
if | amountMatches (-a1) a && amountMatches a2 (amountCost a) -> Just (p, -a2)
307+
| amountMatches (-a2) a && amountMatches a1 (amountCost a) -> Just (p, -a1)
308+
| otherwise -> Nothing
309+
310+
-- Add a transaction price to a posting if it matches (negative) one of the
311+
-- supplied conversion amounts, adding the other amount as the price
312+
addPriceIfMatchesOneAmount :: Amount -> Amount -> Posting -> Maybe (Posting, Amount)
313+
addPriceIfMatchesOneAmount a1 a2 p = do
314+
a <- postingSingleAmount p
315+
let newp price = p{pamount = mixedAmount a{aprice = Just $ TotalPrice price}}
316+
if | amountMatches (-a1) a -> Just (newp a2, a2)
317+
| amountMatches (-a2) a -> Just (newp a1, a1)
318+
| otherwise -> Nothing
319+
320+
hasPrice p = isJust $ aprice =<< postingSingleAmount p
321+
postingAmountNoPrice p = case postingSingleAmount p of
322+
Just a@Amount{aprice=Nothing} -> Right a
323+
_ -> Left $ annotateWithPostings [p] "The posting must only have a single amount with no transaction price"
324+
postingSingleAmount p = case amountsRaw (pamount p) of
325+
[a] -> Just a
326+
_ -> Nothing
245327

246-
maybePostingAmount p = case amountsRaw $ pamount p of
247-
[a@Amount{aprice=Nothing}] -> Just a
248-
_ -> Nothing
249328
amountMatches a b = acommodity a == acommodity b && aquantity a == aquantity b
329+
isConversion p = M.lookup (paccount p) acctTypes == Just Conversion
330+
331+
-- Delete a posting from the indexed list of postings based on either its
332+
-- index or its posting amount.
333+
-- Note: traversing the whole list to delete a single match is generally not efficient,
334+
-- but given that a transaction probably doesn't have more than four postings, it should
335+
-- still be more efficient than using a Map or another data structure. Even monster
336+
-- transactions with up to 10 postings, which are generally not a good
337+
-- idea, are still too small for there to be an advantage.
338+
deleteIdx n = deleteUniqueMatch ((n==) . fst)
339+
deleteUniqueMatch p (x:xs) | p x = if any p xs then Nothing else Just xs
340+
| otherwise = (x:) <$> deleteUniqueMatch p xs
341+
deleteUniqueMatch _ [] = Nothing
342+
annotateWithPostings xs str = T.unlines $ str : postingsAsLines False xs
250343

251344
-- | Apply some account aliases to all posting account names in the transaction, as described by accountNameApplyAliases.
252345
-- This can fail due to a bad replacement pattern in a regular expression alias.
@@ -268,6 +361,13 @@ transactionMapPostingAmounts f = transactionMapPostings (postingTransformAmount
268361
transactionFile :: Transaction -> FilePath
269362
transactionFile Transaction{tsourcepos} = sourceName $ fst tsourcepos
270363

364+
-- Add transaction information to an error message.
365+
annotateErrorWithTransaction :: Transaction -> String -> String
366+
annotateErrorWithTransaction t s =
367+
unlines [ sourcePosPairPretty $ tsourcepos t, s
368+
, T.unpack . T.stripEnd $ showTransaction t
369+
]
370+
271371
-- tests
272372

273373
tests_Transaction :: TestTree

hledger-lib/Hledger/Read/Common.hs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -320,9 +320,9 @@ journalFinalise iopts@InputOpts{..} f txt pj = do
320320
>>= (if auto_ && not (null $ jtxnmodifiers pj)
321321
then journalAddAutoPostings _ioDay balancingopts_ -- Add auto postings if enabled, and account tags if needed
322322
else pure)
323-
>>= journalBalanceTransactions balancingopts_ -- Balance all transactions and maybe check balance assertions.
324-
<$> (if infer_costs_ then journalAddPricesFromEquity else id) -- Add inferred transaction prices from equity postings, if present
325-
<&> (if infer_equity_ then journalAddInferredEquityPostings else id) -- Add inferred equity postings, after balancing transactions and generating auto postings
323+
>>= (if infer_costs_ then journalAddPricesFromEquity else pure) -- Add inferred transaction prices from equity postings, if present
324+
>>= journalBalanceTransactions balancingopts_ -- Balance all transactions and maybe check balance assertions.
325+
<&> (if infer_equity_ then journalAddInferredEquityPostings else id) -- Add inferred equity postings, after balancing and generating auto postings
326326
<&> journalInferMarketPricesFromTransactions -- infer market prices from commodity-exchanging transactions
327327
when strict_ $ do
328328
journalCheckAccounts j -- If in strict mode, check all postings are to declared accounts

hledger/Hledger/Cli/Commands/Close.hs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import Data.Maybe (fromMaybe)
1414
import qualified Data.Text as T
1515
import qualified Data.Text.IO as T
1616
import Data.Time.Calendar (addDays)
17+
import Lens.Micro ((^.))
1718
import System.Console.CmdArgs.Explicit as C
1819

1920
import Hledger
@@ -48,7 +49,7 @@ closemode = hledgerCommandMode
4849

4950
-- debugger, beware: close is incredibly devious. simple rules combine to make a horrid maze.
5051
-- tests are in hledger/test/close.test.
51-
close CliOpts{rawopts_=rawopts, reportspec_=rspec'} j = do
52+
close copts@CliOpts{rawopts_=rawopts, reportspec_=rspec'} j = do
5253
let
5354
-- show opening entry, closing entry, or (default) both ?
5455
(opening, closing) =
@@ -101,7 +102,7 @@ close CliOpts{rawopts_=rawopts, reportspec_=rspec'} j = do
101102
openingdate = addDays 1 closingdate
102103

103104
-- should we show the amount(s) on the equity posting(s) ?
104-
explicit = boolopt "explicit" rawopts
105+
explicit = boolopt "explicit" rawopts || copts ^. infer_costs
105106

106107
-- the balances to close
107108
(acctbals',_) = balanceReport rspec j

hledger/Hledger/Cli/Commands/Print.hs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,23 +75,20 @@ printEntries opts@CliOpts{reportspec_=rspec} j =
7575

7676
entriesReportAsText :: CliOpts -> EntriesReport -> TL.Text
7777
entriesReportAsText opts =
78-
TB.toLazyText . foldMap (TB.fromText . showTransaction . maybeStripPrices . whichtxn)
78+
TB.toLazyText . foldMap (TB.fromText . showTransaction . whichtxn)
7979
where
8080
whichtxn
8181
-- With -x, use the fully-inferred txn with all amounts & txn prices explicit.
8282
| boolopt "explicit" (rawopts_ opts) = id
83+
-- With --show-costs, make txn prices explicit.
84+
| opts ^. infer_costs = id
8385
-- Or also, if any of -B/-V/-X/--value are active.
8486
-- Because of #551, and because of print -V valuing only one
8587
-- posting when there's an implicit txn price.
8688
-- So -B/-V/-X/--value implies -x. Is this ok ?
8789
| has (value . _Just) opts = id
8890
-- By default, use the original as-written-in-the-journal txn.
8991
| otherwise = originalTransaction
90-
maybeStripPrices
91-
-- Strip prices when inferring equity, unless the show_costs_ is set
92-
| opts ^. infer_equity && not (opts ^. show_costs) =
93-
transactionTransformPostings postingStripPrices
94-
| otherwise = id
9592

9693
-- Replace this transaction's postings with the original postings if any, but keep the
9794
-- current possibly rewritten account names, and the inferred values of any auto postings

0 commit comments

Comments
 (0)