lib: new more robust price lookup implementation, fixing #1402
This commit is contained in:
parent
13e3e7607e
commit
221a6d9001
@ -28,19 +28,18 @@ module Hledger.Data.Valuation (
|
||||
where
|
||||
|
||||
import Control.Applicative ((<|>))
|
||||
import Data.Decimal (roundTo)
|
||||
import Data.Foldable (asum)
|
||||
import Data.Function ((&), on)
|
||||
import Data.Graph.Inductive (Gr, Node, NodeMap, mkMapGraph, mkNode, lab, out, sp)
|
||||
import Data.List
|
||||
import Data.List ( (\\), sortBy )
|
||||
import Data.List.Extra (nubSortBy)
|
||||
import qualified Data.Map as M
|
||||
import qualified Data.Set as S
|
||||
import Data.Maybe
|
||||
import Data.Maybe ( fromMaybe )
|
||||
import qualified Data.Text as T
|
||||
import Data.Time.Calendar (Day, fromGregorian)
|
||||
import Data.MemoUgly (memo)
|
||||
import GHC.Generics (Generic)
|
||||
import Safe (headMay)
|
||||
import Safe (lastMay)
|
||||
|
||||
import Hledger.Utils
|
||||
import Hledger.Data.Types
|
||||
@ -62,31 +61,6 @@ data ValuationType =
|
||||
| AtDefault (Maybe CommoditySymbol) -- ^ works like AtNow in single period reports, like AtEnd in multiperiod reports
|
||||
deriving (Show,Eq)
|
||||
|
||||
-- | A snapshot of the known exchange rates between commodity pairs at a given date,
|
||||
-- as a graph allowing fast lookup and path finding, along with some helper data.
|
||||
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,
|
||||
-- which were either:
|
||||
-- declared by P directives,
|
||||
-- inferred from transaction prices,
|
||||
-- inferred by reversing a declared rate,
|
||||
-- or inferred by reversing a transaction-inferred 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.
|
||||
,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 inferred, but not reverse) each
|
||||
-- source commodity's latest market price (on the date of this
|
||||
-- graph).
|
||||
}
|
||||
deriving (Show,Generic)
|
||||
|
||||
-- | A price oracle is a magic memoising function that efficiently
|
||||
-- looks up market prices (exchange rates) from one commodity to
|
||||
-- another (or if unspecified, to a default valuation commodity) on a
|
||||
@ -229,11 +203,11 @@ priceLookup :: (Day -> PriceGraph) -> Day -> CommoditySymbol -> Maybe CommodityS
|
||||
priceLookup makepricegraph d from mto =
|
||||
-- trace ("priceLookup ("++show d++", "++show from++", "++show 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, prDefaultValuationCommodities=defaultdests} =
|
||||
PriceGraph{pgEdges=forwardprices
|
||||
,pgEdgesRev=allprices
|
||||
,pgDefaultValuationCommodities=defaultdests
|
||||
} =
|
||||
traceAt 1 ("valuation date: "++show d) $ makepricegraph d
|
||||
fromnode = node m from
|
||||
mto' = mto <|> mdefaultto
|
||||
where
|
||||
mdefaultto = dbg1 ("default valuation commodity for "++T.unpack from) $
|
||||
@ -243,25 +217,15 @@ priceLookup makepricegraph d from mto =
|
||||
Nothing -> Nothing
|
||||
Just to | to==from -> Nothing
|
||||
Just to ->
|
||||
-- We have a commodity to convert to. Find the most direct price available.
|
||||
case mindirectprice of
|
||||
-- We have a commodity to convert to. Find the most direct price available,
|
||||
-- according to the rules described in makePriceGraph.
|
||||
case
|
||||
pricesShortestPath forwardprices from to <|>
|
||||
pricesShortestPath allprices from to
|
||||
of
|
||||
Nothing -> Nothing
|
||||
Just q -> Just (to, q)
|
||||
where
|
||||
tonode = node m to
|
||||
mindirectprice :: Maybe Quantity =
|
||||
-- Find the shortest path, if any, between from and to.
|
||||
case dbg9 "shortest price path" $ sp fromnode tonode g :: Maybe [Node] of
|
||||
Nothing -> Nothing
|
||||
Just nodes ->
|
||||
dbg ("market price for "++intercalate " -> " (map T.unpack comms)) $
|
||||
-- TODO: it would be nice to include price date as part of the label
|
||||
-- in PriceGraph, so we could show the dates of market prices here
|
||||
Just $ product $ pathEdgeLabels g nodes -- convert to a single exchange rate
|
||||
where comms = catMaybes $ map (lab g) nodes
|
||||
|
||||
-- log a message and a Maybe Quantity, hiding Just/Nothing and limiting decimal places
|
||||
dbg msg = dbg1With (((msg++": ")++) . maybe "" (show . roundTo 8))
|
||||
Just [] -> Nothing
|
||||
Just ps -> Just (mpto $ last ps, product $ map mprate ps)
|
||||
|
||||
tests_priceLookup =
|
||||
let
|
||||
@ -280,13 +244,85 @@ tests_priceLookup =
|
||||
priceLookup makepricegraph (fromGregorian 2000 01 01) "B" (Just "A") @?= Just ("A",0.1)
|
||||
priceLookup makepricegraph (fromGregorian 2000 01 01) "A" (Just "E") @?= Just ("E",500)
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
-- Market price graph
|
||||
|
||||
type Edge = MarketPrice
|
||||
type Path = [Edge]
|
||||
|
||||
data PriceGraph = PriceGraph {
|
||||
pgDate :: Day
|
||||
-- ^ The date on which these prices are in effect.
|
||||
,pgEdges :: [Edge]
|
||||
-- ^ "Forward" exchange rates between commodity pairs, either
|
||||
-- declared by P directives or inferred from transaction prices,
|
||||
-- forming the edges of a directed graph.
|
||||
,pgEdgesRev :: [Edge]
|
||||
-- ^ The same edges, plus any additional edges that can be
|
||||
-- inferred by reversing them and inverting the rates.
|
||||
--
|
||||
-- In both of these there will be at most one edge between each
|
||||
-- directed pair of commodities, eg there can be one USD->EUR and one EUR->USD.
|
||||
,pgDefaultValuationCommodities :: 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 each source commodity's
|
||||
-- latest (declared or inferred, but not reverse) market price
|
||||
-- (on the date of this graph).
|
||||
}
|
||||
deriving (Show,Generic)
|
||||
|
||||
-- | Find the shortest path and corresponding conversion rate, if any,
|
||||
-- from one commodity to another using the provided market prices which
|
||||
-- form the edges of a directed graph. There should be at most one edge
|
||||
-- between each directed pair of commodities, eg there can be one
|
||||
-- USD->EUR price and one EUR->USD price.
|
||||
pricesShortestPath :: [Edge] -> CommoditySymbol -> CommoditySymbol -> Maybe Path
|
||||
pricesShortestPath edges start end =
|
||||
dbg1 ("shortest price path for "++T.unpack start++" -> "++T.unpack end) $
|
||||
asum $ map (findPath end edgesremaining) initialpaths
|
||||
where
|
||||
initialpaths = dbg9 "initial price paths" $ [[p] | p <- edges, mpfrom p == start]
|
||||
edgesremaining = dbg9 "initial edges remaining" $ edges \\ concat initialpaths
|
||||
|
||||
-- Helper: breadth-first search for a continuation of the given path
|
||||
-- using zero or more of the given edges, to the specified end commodity.
|
||||
-- Returns the first & shortest complete path found, or Nothing.
|
||||
findPath :: CommoditySymbol -> [Edge] -> Path -> Maybe Path
|
||||
findPath end _ path | mpathend == Just end = Just path -- path is complete
|
||||
where
|
||||
mpathend = mpto <$> lastMay path
|
||||
findPath _ [] _ = Nothing -- no more edges are available
|
||||
findPath end edgesremaining path = -- try continuing with all the remaining edges
|
||||
asum [
|
||||
findPath end edgesremaining' path'
|
||||
| e <- nextedges
|
||||
, let path' = path++[e]
|
||||
, let edgesremaining' = filter (/=e) edgesremaining
|
||||
]
|
||||
where
|
||||
nextedges = [ e | e <- edgesremaining, Just (mpfrom e) == mpathend ]
|
||||
where
|
||||
mpathend = mpto <$> lastMay path
|
||||
|
||||
-- | A snapshot of the known exchange rates between commodity pairs at a given date.
|
||||
-- This is a home-made version, more tailored to our needs.
|
||||
-- | Build the graph of commodity conversion prices for a given day.
|
||||
-- Converts a list of declared market prices in parse order, and a
|
||||
-- list of transaction-inferred market prices in parse order, to a
|
||||
-- graph of all known exchange rates between commodity pairs in effect
|
||||
-- on that day. Cf hledger.m4.md -> Valuation:
|
||||
-- list of transaction-inferred market prices in parse order, to:
|
||||
--
|
||||
-- hledger looks for a market price (exchange rate) from commodity A
|
||||
-- 1. a graph of all known exchange rates declared or inferred from
|
||||
-- one commodity to another in effect on that day
|
||||
--
|
||||
-- 2. a second graph which includes any additional exchange rates
|
||||
-- that can be inferred by reversing known rates
|
||||
--
|
||||
-- 3. a map of each commodity's default valuation commodity, if any.
|
||||
--
|
||||
-- These allow price lookup and valuation to be performed as
|
||||
-- described in hledger.m4.md -> Valuation:
|
||||
--
|
||||
-- "hledger looks for a market price (exchange rate) from commodity A
|
||||
-- to commodity B in one or more of these ways, in this order of
|
||||
-- preference:
|
||||
--
|
||||
@ -298,15 +334,15 @@ tests_priceLookup =
|
||||
-- 2. A *reverse market price*:
|
||||
-- the inverse of a declared or inferred market price from B to A.
|
||||
--
|
||||
-- 3. A *chained market price*:
|
||||
-- a synthetic price formed by combining the shortest chain of market
|
||||
-- prices (any of the above types) leading from A to B.
|
||||
-- 3. A *a forward chain of market prices*:
|
||||
-- a synthetic price formed by combining the shortest chain of
|
||||
-- "forward" (only 1 above) market prices, leading from A to B.
|
||||
--
|
||||
-- 1 and 2 form the edges of the price graph, and we can query it for
|
||||
-- 3 (which is the reason we use a graph).
|
||||
-- 4. A *any chain of market prices*:
|
||||
-- a chain of any market prices, including both forward and
|
||||
-- reverse prices (1 and 2 above), leading from A to B."
|
||||
--
|
||||
-- We also identify each commodity's default valuation commodity, if
|
||||
-- any. For each commodity A, hledger picks a default valuation
|
||||
-- and: "For each commodity A, hledger picks a default valuation
|
||||
-- commodity as follows, in this order of preference:
|
||||
--
|
||||
-- 1. The price commodity from the latest declared market price for A
|
||||
@ -318,35 +354,31 @@ tests_priceLookup =
|
||||
--
|
||||
-- 3. If there are no P directives at all (any commodity or date), and
|
||||
-- the `--infer-value` flag is used, then the price commodity from
|
||||
-- the latest transaction price for A on or before valuation date.
|
||||
-- the latest transaction price for A on or before valuation date."
|
||||
--
|
||||
makePriceGraph :: [MarketPrice] -> [MarketPrice] -> Day -> PriceGraph
|
||||
makePriceGraph alldeclaredprices allinferredprices d =
|
||||
dbg9 ("makePriceGraph "++show d) $
|
||||
PriceGraph{prGraph=g, prNodemap=m, prDefaultValuationCommodities=defaultdests}
|
||||
PriceGraph{
|
||||
pgDate = d
|
||||
,pgEdges=forwardprices
|
||||
,pgEdgesRev=allprices
|
||||
,pgDefaultValuationCommodities=defaultdests
|
||||
}
|
||||
where
|
||||
-- prices in effect on date d, either declared or inferred
|
||||
visibledeclaredprices = dbg2 "visibledeclaredprices" $ filter ((<=d).mpdate) alldeclaredprices
|
||||
visibleinferredprices = dbg2 "visibleinferredprices" $ filter ((<=d).mpdate) allinferredprices
|
||||
declaredandinferredprices = effectiveMarketPrices visibledeclaredprices visibleinferredprices
|
||||
forwardprices = effectiveMarketPrices visibledeclaredprices visibleinferredprices
|
||||
|
||||
-- infer any additional reverse prices not already declared or inferred
|
||||
reverseprices = dbg2 "additional reverse prices" $
|
||||
[p | p@MarketPrice{..} <- map marketPriceReverse declaredandinferredprices
|
||||
[p | p@MarketPrice{..} <- map marketPriceReverse forwardprices
|
||||
, not $ (mpfrom,mpto) `S.member` forwardpairs
|
||||
]
|
||||
where
|
||||
forwardpairs = S.fromList [(mpfrom,mpto) | MarketPrice{..} <- declaredandinferredprices]
|
||||
|
||||
-- build the graph and associated node map
|
||||
(g, m) =
|
||||
mkMapGraph
|
||||
(dbg9 "price graph labels" $ sort allcomms) -- this must include all nodes mentioned in edges
|
||||
(dbg9 "price graph edges" $ [(mpfrom, mpto, mprate) | MarketPrice{..} <- prices])
|
||||
:: (Gr CommoditySymbol Quantity, NodeMap CommoditySymbol)
|
||||
where
|
||||
prices = dbg2 "prices used as price graph edges" $ declaredandinferredprices ++ reverseprices
|
||||
allcomms = map mpfrom prices
|
||||
forwardpairs = S.fromList [(mpfrom,mpto) | MarketPrice{..} <- forwardprices]
|
||||
allprices = forwardprices ++ reverseprices
|
||||
|
||||
-- determine a default valuation commodity for each source commodity
|
||||
-- somewhat but not quite like effectiveMarketPrices
|
||||
@ -389,31 +421,6 @@ marketPriceReverse :: MarketPrice -> MarketPrice
|
||||
marketPriceReverse mp@MarketPrice{..} =
|
||||
mp{mpfrom=mpto, mpto=mpfrom, mprate=if mprate==0 then 0 else 1/mprate} -- PARTIAL: /
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
-- fgl helpers
|
||||
|
||||
-- | Look up an existing graph node by its label.
|
||||
-- (If the node does not exist, a new one will be generated, but not
|
||||
-- persisted in the nodemap.)
|
||||
node :: Ord a => NodeMap a -> a -> Node
|
||||
node m = fst . fst . mkNode m
|
||||
|
||||
-- | Convert a valid path within the given graph to the corresponding
|
||||
-- edge labels. When there are multiple edges between two nodes, the
|
||||
-- lowest-sorting label is used.
|
||||
pathEdgeLabels :: (Show b, Ord b) => Gr a b -> [Node] -> [b]
|
||||
pathEdgeLabels g = map frommaybe . map (nodesEdgeLabel g) . pathEdges
|
||||
where frommaybe = fromMaybe (error' "pathEdgeLabels: expected no Nothings here") -- PARTIAL:
|
||||
|
||||
-- | Convert a path to node pairs representing the path's edges.
|
||||
pathEdges :: [Node] -> [(Node,Node)]
|
||||
pathEdges p = [(f,t) | f:t:_ <- tails p]
|
||||
|
||||
-- | Get the label of a graph edge from one node to another.
|
||||
-- When there are multiple such edges, the lowest-sorting label is used.
|
||||
nodesEdgeLabel :: Ord b => Gr a b -> (Node, Node) -> Maybe b
|
||||
nodesEdgeLabel g (from,to) = headMay $ sort [l | (_,t,l) <- out g from, t==to]
|
||||
|
||||
nullmarketprice :: MarketPrice
|
||||
nullmarketprice = MarketPrice {
|
||||
mpdate=nulldate
|
||||
|
||||
@ -4,7 +4,7 @@ cabal-version: 1.12
|
||||
--
|
||||
-- see: https://github.com/sol/hpack
|
||||
--
|
||||
-- hash: 8be95614d73bb909eb29a27d8411f09623debad24f7fe84cf42ebc8b851a7ba8
|
||||
-- hash: db3635dfa2836ab24208180e8c88c2179321d6b634a359969844aa9974634577
|
||||
|
||||
name: hledger-lib
|
||||
version: 1.19.99
|
||||
@ -127,7 +127,6 @@ library
|
||||
, data-default >=0.5
|
||||
, directory
|
||||
, extra >=1.6.3
|
||||
, fgl >=5.5.4.0
|
||||
, file-embed >=0.0.10
|
||||
, filepath
|
||||
, hashtables >=1.2.3.1
|
||||
@ -180,7 +179,6 @@ test-suite doctest
|
||||
, directory
|
||||
, doctest >=0.16.3
|
||||
, extra >=1.6.3
|
||||
, fgl >=5.5.4.0
|
||||
, file-embed >=0.0.10
|
||||
, filepath
|
||||
, hashtables >=1.2.3.1
|
||||
@ -234,7 +232,6 @@ test-suite unittest
|
||||
, data-default >=0.5
|
||||
, directory
|
||||
, extra >=1.6.3
|
||||
, fgl >=5.5.4.0
|
||||
, file-embed >=0.0.10
|
||||
, filepath
|
||||
, hashtables >=1.2.3.1
|
||||
|
||||
@ -60,7 +60,6 @@ dependencies:
|
||||
- data-default >=0.5
|
||||
- Decimal >=0.5.1
|
||||
- directory
|
||||
- fgl >=5.5.4.0
|
||||
- file-embed >=0.0.10
|
||||
- filepath
|
||||
- hashtables >=1.2.3.1
|
||||
|
||||
@ -1196,23 +1196,27 @@ valued on the last day of the period, by default.
|
||||
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) -->
|
||||
<!-- (-X tries all of these; -V tries only 1) (really ?) -->
|
||||
:
|
||||
|
||||
1. A *declared market price* or *inferred market price*:
|
||||
A's latest market price in B on or before the valuation date
|
||||
as declared by a [P directive](journal.html#declaring-market-prices),
|
||||
or (if the `--infer-value` flag is used)
|
||||
as declared by a [P directive](journal.html#declaring-market-prices),
|
||||
or (with the `--infer-value` flag)
|
||||
inferred from [transaction prices](journal.html#transaction-prices).
|
||||
<!-- (Latest by date, then parse order.) -->
|
||||
<!-- (A declared price overrides an inferred price on the same date.) -->
|
||||
|
||||
|
||||
2. A *reverse market price*:
|
||||
the inverse of a declared or inferred market price from B to A.
|
||||
|
||||
3. A *chained market price*:
|
||||
a synthetic price formed by combining the shortest chain of market
|
||||
prices (any of the above types) leading from A to B.
|
||||
3. A *a forward chain of market prices*:
|
||||
a synthetic price formed by combining the shortest chain of
|
||||
"forward" (only 1 above) market prices, leading from A to B.
|
||||
|
||||
4. A *any chain of market prices*:
|
||||
a chain of any market prices, including both forward and
|
||||
reverse prices (1 and 2 above), leading from A to B.
|
||||
|
||||
Amounts for which no applicable market price can be found, are not converted.
|
||||
|
||||
|
||||
@ -291,13 +291,13 @@ $ hledger -f- bal -N -e 2020-11-20 -X A
|
||||
A 3.00 a
|
||||
|
||||
# 29. #1402 It should pick the direct B->A price here, not the indirect B->C->A price.
|
||||
# <
|
||||
# 2020-11-10
|
||||
# (a) B 1.00
|
||||
<
|
||||
2020-11-10
|
||||
(a) B 1.00
|
||||
|
||||
# P 2020-11-01 B C 2
|
||||
# P 2020-11-02 C A 3
|
||||
# P 2020-11-03 B A 5
|
||||
P 2020-11-01 B C 2
|
||||
P 2020-11-02 C A 3
|
||||
P 2020-11-03 B A 5
|
||||
|
||||
# $ hledger -f- bal -N -e 2020-11-20 -X A
|
||||
# A 5 a
|
||||
$ hledger -f- bal -N -e 2020-11-20 -X A
|
||||
A 5 a
|
||||
|
||||
Loading…
Reference in New Issue
Block a user