-- | A database of price information. A PricePoint has a DateTime, a -- From commodity, a To commodity, and a QtyPerUnit. The PriceDb holds -- this information for several prices. You can query the database by -- supplying a from commodity, a to commodity, and a DateTime, and the -- database will give you the QtyPerUnit, if there is one. module Penny.Lincoln.PriceDb ( PriceDb, emptyDb, addPrice, getPrice, PriceDbError(FromNotFound, ToNotFound, CpuNotFound), convert ) where import qualified Control.Monad.Exception.Synchronous as Ex import qualified Data.Foldable as Fdbl import qualified Data.Map as M import qualified Data.Time as T import qualified Penny.Lincoln.NestedMap as NM import qualified Penny.Lincoln.Bits as B type CpuMap = M.Map T.UTCTime B.CountPerUnit type ToMap = M.Map B.To CpuMap -- | The PriceDb holds information about prices. Create an empty one -- using 'emptyDb' then fill it with values using foldl or similar. newtype PriceDb = PriceDb (NM.NestedMap B.SubCommodity ToMap) -- | An empty PriceDb emptyDb :: PriceDb emptyDb = PriceDb NM.empty -- | Add a single price to the PriceDb. addPrice :: PriceDb -> B.PricePoint -> PriceDb addPrice (PriceDb db) pp@(B.PricePoint _ pr _) = PriceDb $ NM.relabel db ls where ls = firsts ++ [lst] cmdtyList = Fdbl.toList . B.unCommodity . B.unFrom . B.from $ pr firsts = map toFst (init cmdtyList) where toFst cty = (cty, f) f maybeL = case maybeL of Nothing -> M.empty Just m -> m lst = (last cmdtyList, insertIntoToMap pp) -- | Returns a function to use when inserting a new value into the -- ToMap label of a PriceDb. insertIntoToMap :: B.PricePoint -> Maybe ToMap -> ToMap insertIntoToMap (B.PricePoint dt pr _) = let toCmdty = B.to pr newToMap oldMap = M.alter alterTo toCmdty oldMap alterTo mayCpuMap = let newKey = dateTimeToUTC dt newVal = B.countPerUnit pr in Just $ case mayCpuMap of Nothing -> M.singleton newKey newVal Just m -> M.insert newKey newVal m relabeler maybeToMap = newToMap (maybe M.empty id maybeToMap) in relabeler dateTimeToUTC :: B.DateTime -> T.UTCTime dateTimeToUTC dt = T.localTimeToUTC tz lt where tz = T.minutesToTimeZone . B.offsetToMins $ tzo lt = B.localTime dt tzo = B.timeZone dt -- | Getting prices can fail; if it fails, an Error is returned. data PriceDbError = FromNotFound | ToNotFound | CpuNotFound -- | Looks up values from the PriceDb. Throws "Error" if something -- fails. -- -- First, tries to find the best possible From match. For example, if -- From is LUV:2001, first tries to see if there is a From match for -- LUV:2001. If there is not an exact match for LUV:2001 but there -- is a match for LUV, then LUV is used. If there is not a match -- for either LUV:2001 or for LUV, then FromNotFound is thrown. -- -- The To commodity must match exactly. So, if the TO commodity is -- LUV:2001, only LUV:2001 will do. If the To commodity is not -- found, ToNotFound is thrown. -- -- The DateTime is the time at which to find a price. If a price -- exists for that exact DateTime, that price is returned. If no price -- exists for that exact DateTime, but there is a price for an earlier -- DateTime, the latest possible price is returned. If there are no -- earlier prices, CpuNotFound is thrown. -- -- There is no backtracking on earlier decisions. For example, if From -- is LUV:2001, and there is indeed at least one From price in the -- PriceDb and CpuNotFound occurs, getPrice does not check to see if -- the computation would have succeeded had it used LUV rather than -- LUV:2001. getPrice :: PriceDb -> B.From -> B.To -> B.DateTime -> Ex.Exceptional PriceDbError B.CountPerUnit getPrice (PriceDb db) fr to dt = do let utc = dateTimeToUTC dt subs = Fdbl.toList . B.unCommodity . B.unFrom $ fr toMap <- case NM.descend subs db of [] -> Ex.throw FromNotFound xs -> return . snd . last $ xs cpuMap <- case M.lookup to toMap of Nothing -> Ex.throw ToNotFound Just m -> return m let (lower, exact, _) = M.splitLookup utc cpuMap cpu <- case exact of Just c -> return c Nothing -> if M.null lower then Ex.throw CpuNotFound else return . snd . M.findMax $ lower return cpu -- | Given an Amount and a Commodity to convert the amount to, -- converts the Amount to the given commodity. If the Amount given is -- already in the To commodity, simply returns what was passed in. Can -- fail and throw PriceDbError. Internally uses 'getPrice', so read its -- documentation for details on how price lookup works. convert :: PriceDb -> B.DateTime -> B.To -> B.Amount -> Ex.Exceptional PriceDbError B.Amount convert db dt to (B.Amount qt fr) = do cpu <- fmap B.unCountPerUnit (getPrice db (B.From fr) to dt) let qt' = B.mult cpu qt return $ B.Amount qt' (B.unTo to)