exchangerates-0.0.0.0: src/ExchangeRates/Cache.hs
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
-- | Caches for the raw API
module ExchangeRates.Cache
( ExchangeRatesCache(..)
, insertRates
, ExchangeRatesCacheResult(..)
, lookupRates
, emptyExchangeRatesCache
, RateCache(..)
, emptyRateCache
, insertRatesInCache
, lookupRatesInCache
, smartInsertInCache
, smartLookupRateInCache
-- Defaults
, defaultBaseCurrency
, allSymbolsExcept
-- Helpers
, convertToBaseWithRate
, rawInsertInCache
, rawLookupInCache
) where
import Control.Monad
import Data.Aeson
import qualified Data.List.NonEmpty as NE
import qualified Data.Map as M
import Data.Map (Map)
import Data.Maybe
import qualified Data.Set as S
import Data.Set (Set)
import Data.Time
import Data.Validity
import GHC.Generics (Generic)
import ExchangeRates.Types
-- | A complete cache for the raw API.
--
-- This includes a cache for the rates we get, as well as a cache for the
-- rates we do not get.
data ExchangeRatesCache = ExchangeRatesCache
{ fCacheRates :: RateCache
, fCacheDaysWithoutRates :: Set Day
} deriving (Show, Eq, Generic)
instance Validity ExchangeRatesCache
instance FromJSON ExchangeRatesCache where
parseJSON =
withObject "ExchangeRatesCache" $ \o ->
ExchangeRatesCache <$> o .: "rates" <*> o .: "days-without-rates"
instance ToJSON ExchangeRatesCache where
toJSON ExchangeRatesCache {..} =
object
[ "rates" .= fCacheRates
, "days-without-rates" .= fCacheDaysWithoutRates
]
-- | Insert a given raw response in a 'ExchangeRatesCache'
insertRates ::
Day -- ^ The current date
-> Day -- ^ The requested date
-> Rates
-> ExchangeRatesCache
-> ExchangeRatesCache
insertRates n d r fc
| ratesDate r == d =
let rc' = insertRatesInCache r $ fCacheRates fc
in fc {fCacheRates = rc'}
| d >= n = fc
| otherwise =
let dwr' = S.insert d $ fCacheDaysWithoutRates fc
in fc {fCacheDaysWithoutRates = dwr'}
-- | The result of looking up rates in a 'ExchangeRatesCache'
data ExchangeRatesCacheResult
= NotInCache
| CacheDateNotInPast -- ^ Because we requested a date in the future
| WillNeverExist -- ^ Because it was on a weekend or holiday
| InCache Rates
deriving (Show, Eq, Generic)
instance Validity ExchangeRatesCacheResult
-- | Look up rates in cache
lookupRates ::
Day -- ^ The current date
-> Day -- ^ The requested date
-> Currency
-> Symbols
-> ExchangeRatesCache
-> ExchangeRatesCacheResult
lookupRates n d c s ExchangeRatesCache {..}
| d >= n = CacheDateNotInPast
| S.member d fCacheDaysWithoutRates = WillNeverExist
| otherwise =
case lookupRatesInCache d c s fCacheRates of
Nothing -> NotInCache
Just r -> InCache r
-- | The empty 'ExchangeRatesCache'
emptyExchangeRatesCache :: ExchangeRatesCache
emptyExchangeRatesCache =
ExchangeRatesCache
{fCacheRates = emptyRateCache, fCacheDaysWithoutRates = S.empty}
-- | A cache for currency rates
--
-- This cache uses 'EUR' as the base currency, but will still cache
-- rates appropriately if rates with a different base currency are cached.
newtype RateCache = RateCache
{ unRateCache :: Map Day (Map Currency (Map Currency Rate))
} deriving (Show, Eq, Generic, FromJSON, ToJSON)
instance Validity RateCache where
validate RateCache {..} =
mconcat
[ unRateCache <?!> "unRateCache"
, let go :: Map Currency (Map Currency Rate) -> Bool
go m =
not . or $
M.mapWithKey (\c m_ -> isJust (M.lookup c m_)) m
in all go unRateCache <?@>
"Does not contain conversions to from a currency to itself"
]
isValid = isValidByValidating
-- | The empty Cache
emptyRateCache :: RateCache
emptyRateCache = RateCache M.empty
-- | Insert a rate into the cache as-is.
--
-- You probably want to be using 'insertRatesInCache' or 'smartInsertInCache' instead.
rawInsertInCache ::
Day -> Currency -> Currency -> Rate -> RateCache -> RateCache
rawInsertInCache d from to rate (RateCache fc) = RateCache $ M.alter go1 d fc
where
go1 :: Maybe (Map Currency (Map Currency Rate))
-> Maybe (Map Currency (Map Currency Rate))
go1 Nothing = Just $ M.singleton from $ M.singleton to rate
go1 (Just c1) = Just $ M.alter go2 from c1
go2 :: Maybe (Map Currency Rate) -> Maybe (Map Currency Rate)
go2 Nothing = Just $ M.singleton to rate
go2 (Just c2) = Just $ M.insert to rate c2
-- | Lookup a rate in the cache as-is.
--
-- You probably want to be using 'smartLookupRateInCache' instead.
rawLookupInCache :: Day -> Currency -> Currency -> RateCache -> Maybe Rate
rawLookupInCache d from to (RateCache fc) =
M.lookup d fc >>= M.lookup from >>= M.lookup to
-- | The default base currency. Currently this is 'EUR'
defaultBaseCurrency :: Currency
defaultBaseCurrency = EUR
-- | The symbols to get by default, given a base currency.
allSymbolsExcept :: Currency -> Symbols
allSymbolsExcept base =
Symbols $ NE.fromList $ filter (/= base) [minBound .. maxBound]
-- | Insert a result into the cache.
--
-- This is probably the function you want to use, it does all the smartness.
insertRatesInCache :: Rates -> RateCache -> RateCache
insertRatesInCache rs fc =
if ratesBase rs == defaultBaseCurrency
then insertRatesAsIs rs
-- If we're not already using the base, then we need to see if we can figure out how many
-- of this base we can get for the default base
-- We can figure this out in two ways:
-- 1 if the default base is in the rates
else case M.lookup defaultBaseCurrency $ ratesRates rs of
Just r -> insertRatesAtOtherBase r rs
Nothing
-- or
-- 2 if the default base is in the cache
->
case rawLookupInCache
(ratesDate rs)
(ratesBase rs)
defaultBaseCurrency
fc of
Just r -> insertRatesAtOtherBase r rs
Nothing
-- If we find neither, then we just save in the cache as-is
-> insertRatesAsIs rs
where
insertRatesAsIs :: Rates -> RateCache
insertRatesAsIs rates =
M.foldlWithKey (go (ratesBase rates)) fc $ ratesRates rates
insertRatesAtOtherBase :: Rate -> Rates -> RateCache
insertRatesAtOtherBase r =
insertRatesAsIs . convertToBaseWithRate defaultBaseCurrency r
go :: Currency -> RateCache -> Currency -> Rate -> RateCache
go base fc_ c r = smartInsertInCache (ratesDate rs) base c r fc_
-- | Insert a rate in a cache, but don't insert it if the from and to currencies are the same.
smartInsertInCache ::
Day -> Currency -> Currency -> Rate -> RateCache -> RateCache
smartInsertInCache date from to rate fc =
if from == to
then fc
else rawInsertInCache date from to rate fc
-- | Look up multiple rates in a cache.
--
-- This function uses 'smartLookupRateInCache' for each requested symbol.
lookupRatesInCache :: Day -> Currency -> Symbols -> RateCache -> Maybe Rates
lookupRatesInCache date base (Symbols nec) fc =
Rates base date <$>
(M.fromList <$>
mapM
(\to -> (,) to <$> smartLookupRateInCache date base to fc)
(NE.filter (/= base) nec))
-- | Look up a rate in a cache.
--
-- This function will try to be smart about what it can find, but will
-- give up after one redirection.
smartLookupRateInCache :: Day -> Currency -> Currency -> RateCache -> Maybe Rate
smartLookupRateInCache date from to fc@(RateCache m) =
if from == to
then Just oneRate
else case rawLookupInCache date from to fc of
Just r -> pure r
-- First try to look up at the correct base currency
-- If that works, return it.
-- Otherwise, try all the other bases at that day, and convert if necessary.
Nothing -> do
dm <- M.lookup date m
msum $
M.elems $
flip M.mapWithKey dm $ \newFrom nfm ->
lookupVia newFrom from to nfm
lookupVia :: Currency -> Currency -> Currency -> Map Currency Rate -> Maybe Rate
lookupVia newFrom from to nfm = do
nfr <-
if newFrom == from
then Just oneRate
else M.lookup from nfm
-- This is the rate at which we can convert from newFrom to from
-- for each 'from', you get '1/nfr' newFroms
tr <-
if newFrom == to
then Just oneRate
else M.lookup to nfm
-- This is the rate at which we can convert from newFrom to to
pure $ divRate tr nfr
-- | Convert a set of rates to another base currency with the given rate of the new base currency
-- with respect to the old base currency.
-- In the map, we have the info that
-- for 1 base currency, you get s of the currency in the key.
--
-- If we now say that for 1 of the old base currency, you can get
-- r of the new base currency
--
-- This rate means for one of the new base currency, you can get s / r of
-- the currency in the key.
convertToBaseWithRate :: Currency -> Rate -> Rates -> Rates
convertToBaseWithRate new rate rs =
if ratesBase rs == new
then rs
else rs {ratesBase = new, ratesRates = newRates}
where
newRates =
M.map (`divRate` rate) . withOldBase . withoutNewBase $ ratesRates rs
withOldBase = M.insert (ratesBase rs) oneRate
withoutNewBase = M.delete new