mirror of
https://github.com/simonmichael/hledger.git
synced 2024-11-08 15:14:49 +03:00
lib: also infer market prices from transactions, like Ledger (#1239) (WIP)
This commit is contained in:
parent
cbacef21eb
commit
e1ddfc3a1b
@ -21,6 +21,7 @@ module Hledger.Data.Journal (
|
||||
addPeriodicTransaction,
|
||||
addTransaction,
|
||||
journalBalanceTransactions,
|
||||
journalInferMarketPricesFromTransactions,
|
||||
journalApplyCommodityStyles,
|
||||
commodityStylesFromAmounts,
|
||||
journalCommodityStyles,
|
||||
@ -185,6 +186,7 @@ instance Semigroup Journal where
|
||||
,jcommodities = jcommodities j1 <> jcommodities j2
|
||||
,jinferredcommodities = jinferredcommodities j1 <> jinferredcommodities j2
|
||||
,jpricedirectives = jpricedirectives j1 <> jpricedirectives j2
|
||||
,jtransactionimpliedmarketprices = jtransactionimpliedmarketprices j1 <> jtransactionimpliedmarketprices j2
|
||||
,jtxnmodifiers = jtxnmodifiers j1 <> jtxnmodifiers j2
|
||||
,jperiodictxns = jperiodictxns j1 <> jperiodictxns j2
|
||||
,jtxns = jtxns j1 <> jtxns j2
|
||||
@ -210,6 +212,7 @@ nulljournal = Journal {
|
||||
,jcommodities = M.empty
|
||||
,jinferredcommodities = M.empty
|
||||
,jpricedirectives = []
|
||||
,jtransactionimpliedmarketprices = []
|
||||
,jtxnmodifiers = []
|
||||
,jperiodictxns = []
|
||||
,jtxns = []
|
||||
@ -1036,6 +1039,32 @@ canonicalStyleFrom ss@(s:_) =
|
||||
-- case ps of (PriceDirective{pdamount=a}:_) -> Just a
|
||||
-- _ -> Nothing
|
||||
|
||||
-- | Infer transaction-implied market prices from commodity-exchanging
|
||||
-- transactions, if any. It's best to call this after transactions have
|
||||
-- been balanced and posting amounts have appropriate prices attached.
|
||||
journalInferMarketPricesFromTransactions :: Journal -> Journal
|
||||
journalInferMarketPricesFromTransactions j =
|
||||
j{jtransactionimpliedmarketprices =
|
||||
dbg4 "jtransactionimpliedmarketprices" $
|
||||
mapMaybe postingImpliedMarketPrice $ journalPostings j
|
||||
}
|
||||
|
||||
-- | Make a market price equivalent to this posting's amount's unit
|
||||
-- price, if any. If the posting amount is multicommodity, only the
|
||||
-- first commodity amount is considered.
|
||||
postingImpliedMarketPrice :: Posting -> Maybe MarketPrice
|
||||
postingImpliedMarketPrice p@Posting{pamount} =
|
||||
-- convert any total prices to unit prices
|
||||
case mixedAmountTotalPriceToUnitPrice pamount of
|
||||
Mixed ( 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.
|
||||
journalToCost :: Journal -> Journal
|
||||
|
@ -471,8 +471,8 @@ data Journal = Journal {
|
||||
,jdeclaredaccounttypes :: M.Map AccountType [AccountName] -- ^ Accounts whose type has been declared in account directives (usually 5 top-level accounts)
|
||||
,jcommodities :: M.Map CommoditySymbol Commodity -- ^ commodities and formats declared by commodity directives
|
||||
,jinferredcommodities :: M.Map CommoditySymbol AmountStyle -- ^ commodities and formats inferred from journal amounts TODO misnamed - jusedstyles
|
||||
,jpricedirectives :: [PriceDirective] -- ^ All market price declarations (P directives), in parse order (after journal finalisation).
|
||||
-- These will be converted to a Prices db for looking up prices by date.
|
||||
,jpricedirectives :: [PriceDirective] -- ^ Declarations of market prices by P directives, in parse order (after journal finalisation)
|
||||
,jtransactionimpliedmarketprices :: [MarketPrice] -- ^ Market prices implied by transactions, in parse order (after journal finalisation)
|
||||
,jtxnmodifiers :: [TransactionModifier]
|
||||
,jperiodictxns :: [PeriodicTransaction]
|
||||
,jtxns :: [Transaction]
|
||||
|
@ -31,7 +31,7 @@ import Control.Applicative ((<|>))
|
||||
import Control.DeepSeq (NFData)
|
||||
import Data.Data
|
||||
import Data.Decimal (roundTo)
|
||||
import Data.Function (on)
|
||||
import Data.Function ((&), on)
|
||||
import Data.Graph.Inductive (Gr, Node, NodeMap, mkMapGraph, mkNode, lab, out, sp)
|
||||
import Data.List
|
||||
import Data.List.Extra (nubSortBy)
|
||||
@ -58,17 +58,22 @@ data PriceGraph = PriceGraph {
|
||||
prGraph :: Gr CommoditySymbol Quantity
|
||||
-- ^ A directed graph of exchange rates between commodity pairs.
|
||||
-- Node labels are commodities and edge labels are exchange rates,
|
||||
-- either explicitly declared (preferred) or inferred by reversing a declared rate.
|
||||
-- which were either:
|
||||
-- declared by P directives,
|
||||
-- implied by transaction prices,
|
||||
-- inferred by reversing a declared rate,
|
||||
-- or inferred by reversing a transaction-implied rate.
|
||||
-- There will be at most one edge between each directed pair of commodities,
|
||||
-- eg there can be one USD->EUR and one EUR->USD.
|
||||
,prNodemap :: NodeMap CommoditySymbol
|
||||
-- ^ Mapping of graph node ids to commodity symbols.
|
||||
,prDeclaredPairs :: [(Node,Node)]
|
||||
-- ^ Which of the edges in this graph are declared rates,
|
||||
-- rather than inferred reverse rates.
|
||||
-- A bit ugly. We could encode this in the edges,
|
||||
-- but those have to be Real for shortest path finding,
|
||||
-- so we'd have to transform them all first.
|
||||
,prDefaultValuationCommodities :: M.Map CommoditySymbol CommoditySymbol
|
||||
-- ^ The default valuation commodity for each source commodity.
|
||||
-- These are used when a valuation commodity is not specified
|
||||
-- (-V). They are the destination commodity of the latest
|
||||
-- (declared or transaction-implied, but not reverse) each
|
||||
-- source commodity's latest market price (on the date of this
|
||||
-- graph).
|
||||
}
|
||||
deriving (Show,Generic)
|
||||
|
||||
@ -184,17 +189,18 @@ amountValueAtDate priceoracle styles mto d a =
|
||||
------------------------------------------------------------------------------
|
||||
-- Market price lookup
|
||||
|
||||
-- From a journal's market price directives, generate a memoising function
|
||||
-- that efficiently looks up exchange rates between commodities on any date.
|
||||
-- For best results, you should generate this only once per journal, reusing it
|
||||
-- across reports if there are more than one (as in compoundBalanceCommand).
|
||||
-- From a journal's directive-declared and transaction-implied market
|
||||
-- prices, generate a memoising function that efficiently looks up
|
||||
-- exchange rates between commodities on any date. For best performance,
|
||||
-- you should generate this only once per journal, reusing it across
|
||||
-- reports if there are more than one (as in compoundBalanceCommand).
|
||||
journalPriceOracle :: Journal -> PriceOracle
|
||||
journalPriceOracle Journal{jpricedirectives} =
|
||||
journalPriceOracle Journal{jpricedirectives, jtransactionimpliedmarketprices} =
|
||||
-- traceStack "journalPriceOracle" $
|
||||
let
|
||||
pricesatdate =
|
||||
memo $
|
||||
pricesAtDate jpricedirectives
|
||||
pricesAtDate jpricedirectives jtransactionimpliedmarketprices
|
||||
in
|
||||
memo $
|
||||
uncurry3 $
|
||||
@ -205,21 +211,32 @@ journalPriceOracle Journal{jpricedirectives} =
|
||||
-- a different specified valuation commodity, or a default valuation
|
||||
-- commodity.
|
||||
--
|
||||
-- When the valuation commodity is specified, this looks for, in order:
|
||||
-- When the valuation commodity is specified, this looks for an
|
||||
-- exchange rate (market price) calculated in any of the following
|
||||
-- ways, in order of preference:
|
||||
--
|
||||
-- - a price declaration giving the exchange rate from source
|
||||
-- commodity to valuation commodity ("declared price").
|
||||
-- 1. a declared market price (DMP) - a P directive giving the
|
||||
-- exchange rate from source commodity to valuation commodity
|
||||
--
|
||||
-- - a price declaration from valuation to source commodity, which
|
||||
-- gets inverted ("reverse price").
|
||||
-- 2. a transaction-implied market price (TMP) - a market price
|
||||
-- equivalent to the transaction price used in the latest
|
||||
-- transaction from source commodity to valuation commodity
|
||||
-- (on or before the valuation date)
|
||||
--
|
||||
-- - the shortest chain of prices (declared or reverse) leading from
|
||||
-- source commodity to valuation commodity, which gets collapsed
|
||||
-- into a single synthetic exchange rate ("indirect price").
|
||||
-- 3. a reverse declared market price (RDMP) - calculated by inverting
|
||||
-- a DMP
|
||||
--
|
||||
-- 4. a reverse transaction-implied market price (RTMP) - calculated
|
||||
-- by inverting a TMP
|
||||
--
|
||||
-- 5. an indirect market price (IMP) - calculated by combining the
|
||||
-- shortest chain of market prices (any of the above types) leading
|
||||
-- from source commodity to valuation commodity.
|
||||
--
|
||||
-- When the valuation commodity is not specified, this looks for the
|
||||
-- latest applicable declared price, and converts to the commodity
|
||||
-- mentioned in that price (the default valuation commodity).
|
||||
-- latest applicable declared or transaction-implied price, and
|
||||
-- converts to the commodity mentioned in that price (the default
|
||||
-- valuation commodity).
|
||||
--
|
||||
-- Note this default valuation commodity can vary across successive
|
||||
-- calls for different dates, since it depends on the price
|
||||
@ -237,17 +254,12 @@ priceLookup pricesatdate d from mto =
|
||||
let
|
||||
-- build a graph of the commodity exchange rates in effect on this day
|
||||
-- XXX should hide these fgl details better
|
||||
PriceGraph{prGraph=g, prNodemap=m, prDeclaredPairs=dps} = pricesatdate d
|
||||
PriceGraph{prGraph=g, prNodemap=m, prDefaultValuationCommodities=defaultdests} = pricesatdate d
|
||||
fromnode = node m from
|
||||
mto' = mto <|> mdefaultto
|
||||
where
|
||||
-- If to is unspecified, try to pick a default valuation commodity from declared prices (only).
|
||||
-- XXX how to choose ? Take lowest sorted ?
|
||||
-- Take first, hoping current order is useful ? <-
|
||||
-- Keep parse order in label and take latest parsed ?
|
||||
mdefaultto =
|
||||
dbg4 ("default valuation commodity for "++T.unpack from) $
|
||||
headMay [t | (f,t,_) <- out g fromnode, (f,t) `elem` dps] >>= lab g
|
||||
mdefaultto = dbg4 ("default valuation commodity for "++T.unpack from) $
|
||||
M.lookup from defaultdests
|
||||
in
|
||||
case mto' of
|
||||
Nothing -> Nothing
|
||||
@ -283,7 +295,7 @@ tests_priceLookup =
|
||||
,p "2000/01/01" "E" 2 "D"
|
||||
,p "2001/01/01" "A" 11 "B"
|
||||
]
|
||||
pricesatdate = pricesAtDate ps1
|
||||
pricesatdate = pricesAtDate ps1 []
|
||||
in test "priceLookup" $ do
|
||||
priceLookup pricesatdate (d "1999/01/01") "A" Nothing @?= Nothing
|
||||
priceLookup pricesatdate (d "2000/01/01") "A" Nothing @?= Just ("B",10)
|
||||
@ -293,20 +305,21 @@ tests_priceLookup =
|
||||
------------------------------------------------------------------------------
|
||||
-- Building the price graph (network of commodity conversions) on a given day.
|
||||
|
||||
-- | Convert a list of market price directives in parse order to a
|
||||
-- graph of all prices in effect on a given day, allowing efficient
|
||||
-- lookup of exchange rates between commodity pairs.
|
||||
pricesAtDate :: [PriceDirective] -> Day -> PriceGraph
|
||||
pricesAtDate pricedirectives d =
|
||||
-- | Convert a list of market price directives in parse order, and a
|
||||
-- list of transaction-implied market prices in parse order, to a
|
||||
-- graph of the effective exchange rates between commodity pairs on
|
||||
-- the given day.
|
||||
pricesAtDate :: [PriceDirective] -> [MarketPrice] -> Day -> PriceGraph
|
||||
pricesAtDate pricedirectives transactionimpliedmarketprices d =
|
||||
-- trace ("pricesAtDate ("++show d++")") $
|
||||
PriceGraph{prGraph=g, prNodemap=m, prDeclaredPairs=dps}
|
||||
PriceGraph{prGraph=g, prNodemap=m, prDefaultValuationCommodities=defaultdests}
|
||||
where
|
||||
declaredprices = latestPriceForEachPairOn pricedirectives d
|
||||
declaredandimpliedprices = latestPriceForEachPairOn pricedirectives transactionimpliedmarketprices d
|
||||
|
||||
-- infer additional reverse prices where not already declared
|
||||
-- infer any additional reverse prices not already declared or implied
|
||||
reverseprices =
|
||||
dbg5 "reverseprices" $
|
||||
map marketPriceReverse declaredprices \\ declaredprices
|
||||
map marketPriceReverse declaredandimpliedprices \\ declaredandimpliedprices
|
||||
|
||||
-- build the graph and associated node map
|
||||
(g, m) =
|
||||
@ -315,23 +328,37 @@ pricesAtDate pricedirectives d =
|
||||
(dbg5 "g edges" $ [(mpfrom, mpto, mprate) | MarketPrice{..} <- prices])
|
||||
:: (Gr CommoditySymbol Quantity, NodeMap CommoditySymbol)
|
||||
where
|
||||
prices = declaredprices ++ reverseprices
|
||||
prices = declaredandimpliedprices ++ reverseprices
|
||||
allcomms = map mpfrom prices
|
||||
|
||||
-- remember which edges correspond to declared prices
|
||||
dps = [(node m mpfrom, node m mpto) | MarketPrice{..} <- declaredprices ]
|
||||
-- save the forward prices' destinations as the default valuation
|
||||
-- commodity for those source commodities
|
||||
defaultdests = M.fromList [(mpfrom,mpto) | MarketPrice{..} <- declaredandimpliedprices]
|
||||
|
||||
-- From a list of price directives in parse order, get the latest
|
||||
-- price declared on or before date d for each commodity pair.
|
||||
latestPriceForEachPairOn :: [PriceDirective] -> Day -> [MarketPrice]
|
||||
latestPriceForEachPairOn pricedirectives d =
|
||||
-- From a list of price directives in parse order, and a list of
|
||||
-- transaction-implied market prices in parse order, get the effective
|
||||
-- price on the given date for each commodity pair. That is, the
|
||||
-- latest declared or transaction-implied price dated on or before
|
||||
-- that day, with declared prices taking precedence.
|
||||
latestPriceForEachPairOn :: [PriceDirective] -> [MarketPrice] -> Day -> [MarketPrice]
|
||||
latestPriceForEachPairOn pricedirectives transactionimpliedmarketprices d =
|
||||
dbg5 "latestPriceForEachPairOn" $
|
||||
nubSortBy (compare `on` (\(MarketPrice{..})->(mpfrom,mpto))) $ -- keep only the first (ie newest and latest parsed) price for each pair
|
||||
map snd $ -- discard the parse order label
|
||||
sortBy (flip compare `on` (\(parseorder,mp)->(mpdate mp,parseorder))) $ -- sort with newest dates and latest parse order first
|
||||
zip [1..] $ -- label with parse order
|
||||
map priceDirectiveToMarketPrice $
|
||||
filter ((<=d).pddate) pricedirectives -- consider only price declarations up to the valuation date
|
||||
let
|
||||
-- consider only declarations/transactions before the valuation date
|
||||
declaredprices = map priceDirectiveToMarketPrice $ filter ((<=d).pddate) pricedirectives
|
||||
transactionimpliedmarketprices' = filter ((<=d).mpdate) transactionimpliedmarketprices
|
||||
-- label the items with their precedence and then their parse order
|
||||
declaredprices' = [(1, i, p) | (i,p) <- zip [1..] declaredprices]
|
||||
transactionimpliedmarketprices'' = [(0, i, p) | (i,p) <- zip [1..] transactionimpliedmarketprices']
|
||||
in
|
||||
-- combine
|
||||
declaredprices' ++ transactionimpliedmarketprices''
|
||||
-- sort by newest date then highest precedence then latest parse order
|
||||
& sortBy (flip compare `on` (\(precedence,parseorder,mp)->(mpdate mp,precedence,parseorder)))
|
||||
-- discard the sorting labels
|
||||
& map third3
|
||||
-- keep only the first (ie the newest, highest precedence and latest parsed) price for each pair
|
||||
& nubSortBy (compare `on` (\(MarketPrice{..})->(mpfrom,mpto)))
|
||||
|
||||
priceDirectiveToMarketPrice :: PriceDirective -> MarketPrice
|
||||
priceDirectiveToMarketPrice PriceDirective{..} =
|
||||
|
@ -290,6 +290,8 @@ parseAndFinaliseJournal' parser iopts f txt = do
|
||||
--
|
||||
-- - check balance assertions if enabled.
|
||||
--
|
||||
-- - infer transaction-implied market prices from transaction prices
|
||||
--
|
||||
journalFinalise :: InputOpts -> FilePath -> Text -> Journal -> ExceptT String IO Journal
|
||||
journalFinalise iopts f txt pj = do
|
||||
t <- liftIO getClockTime
|
||||
@ -305,23 +307,25 @@ journalFinalise iopts f txt pj = do
|
||||
& journalAddFile (f, txt) -- save the file path and content
|
||||
& journalSetLastReadTime t -- save the last read time
|
||||
& journalReverse -- convert all lists to parse order
|
||||
& if not (auto_ iopts) || null (jtxnmodifiers pj)
|
||||
then
|
||||
-- Auto postings are not active.
|
||||
-- Balance all transactions and maybe check balance assertions.
|
||||
journalBalanceTransactions (not $ ignore_assertions_ iopts)
|
||||
else \j -> do -- Either monad
|
||||
-- Auto postings are active.
|
||||
-- Balance all transactions without checking balance assertions,
|
||||
j' <- journalBalanceTransactions False j
|
||||
-- then add the auto postings
|
||||
-- (Note adding auto postings after balancing means #893b fails;
|
||||
-- adding them before balancing probably means #893a, #928, #938 fail.)
|
||||
let j'' = journalModifyTransactions j'
|
||||
-- then apply commodity styles once more, to style the auto posting amounts. (XXX inefficient ?)
|
||||
j''' <- journalApplyCommodityStyles j''
|
||||
-- then check balance assertions.
|
||||
journalBalanceTransactions (not $ ignore_assertions_ iopts) j'''
|
||||
& (if not (auto_ iopts) || null (jtxnmodifiers pj)
|
||||
then
|
||||
-- Auto postings are not active.
|
||||
-- Balance all transactions and maybe check balance assertions.
|
||||
journalBalanceTransactions (not $ ignore_assertions_ iopts)
|
||||
else \j -> do -- Either monad
|
||||
-- Auto postings are active.
|
||||
-- Balance all transactions without checking balance assertions,
|
||||
j' <- journalBalanceTransactions False j
|
||||
-- then add the auto postings
|
||||
-- (Note adding auto postings after balancing means #893b fails;
|
||||
-- adding them before balancing probably means #893a, #928, #938 fail.)
|
||||
let j'' = journalModifyTransactions j'
|
||||
-- then apply commodity styles once more, to style the auto posting amounts. (XXX inefficient ?)
|
||||
j''' <- journalApplyCommodityStyles j''
|
||||
-- then check balance assertions.
|
||||
journalBalanceTransactions (not $ ignore_assertions_ iopts) j'''
|
||||
)
|
||||
& fmap journalInferMarketPricesFromTransactions -- infer market prices from commodity-exchanging transactions
|
||||
|
||||
setYear :: Year -> JournalParser m ()
|
||||
setYear y = modify' (\j -> j{jparsedefaultyear=Just y})
|
||||
|
@ -76,9 +76,9 @@ $ hledger -f- bal -N -V -e 3000/2
|
||||
|
||||
D 1000.00 H ; declare a default commodity named H
|
||||
|
||||
P 2015/08/14 EEEE 41.66 ; default commodity H is used for these market prices
|
||||
P 2015/08/14 FFFF 74.62
|
||||
P 2015/08/14 GGGG 32.39
|
||||
P 2015/08/15 EEEE 41.66 ; default commodity H is used for these market prices
|
||||
P 2015/08/15 FFFF 74.62
|
||||
P 2015/08/15 GGGG 32.39
|
||||
|
||||
2015/08/15
|
||||
a 2.4120 EEEE @@ 100 ; default commodity H is used for these transaction prices
|
||||
|
@ -221,3 +221,32 @@ P 2002/01/01 A 2 B
|
||||
# was inclusive.
|
||||
$ hledger -f- bal -N -V -e 2002-01-01
|
||||
1 B a
|
||||
|
||||
# Test market prices inferred from transactions, as in Ledger.
|
||||
|
||||
<
|
||||
2020-01-01
|
||||
(assets:stock) 1 TSLA @ $500
|
||||
|
||||
2020-03-01
|
||||
(assets:stock) 1 TSLA @ $500
|
||||
|
||||
P 2020-03-01 TSLA $600
|
||||
|
||||
2020-05-01
|
||||
(assets:stock) 1 TSLA @ $800
|
||||
|
||||
# 22. Market price is inferred from a transaction price,
|
||||
# -V works without a P directive.
|
||||
$ hledger -f- bal -N -V -e 2020-01-02
|
||||
$500 assets:stock
|
||||
|
||||
# 23. A P-declared market price has precedence over a transaction price
|
||||
# on the same date.
|
||||
$ hledger -f- bal -N -V -e 2020-03-02
|
||||
$1200 assets:stock
|
||||
|
||||
# 24. A transaction-implied market price has precedence
|
||||
# over an older P-declared market price.
|
||||
$ hledger -f- bal -N -V -e 2020-05-02
|
||||
$2400 assets:stock
|
||||
|
Loading…
Reference in New Issue
Block a user