packages feed

exchangealgebra-0.5.0.0: src/ExchangeAlgebra/Value.hs

{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE DerivingStrategies         #-}

{- |
    Module     : ExchangeAlgebra.Value
    Copyright  : (c) Kaya Akagi. 2018-2026
    Maintainer : yakagika@icloud.com

    Released under the OWL license

    Exact, non-negative decimal value type (t'MoneyDecimal') for use as the @v@
    parameter of @Alg v b@ / @Journal n v b@.

    == Why this exists (DESIGN, 2026-06-06)

    The accounting value type is selectable:

      * @Double@      — fast IEEE-754; the default. Addition is /non-associative/,
        so the order in which same-base postings are summed (which depends on how a
        value was /constructed/) can change @norm@ / @bar@ results. In the
        agent-based simulations this manifested as a ~3% swing in a stock value when
        the construction order changed (see plans/in-progress/LAZY_EVAL_AUDIT.md and
        FP_SUMMATION_SURVEY.md). This is acceptable for relative-price ABM work where
        speed matters, but it is not deterministic/auditable.

      * @MoneyDecimal@   — exact base-10 fixed-point (a non-negative 'Data.Decimal').
        Addition is /exact and associative/, so results are independent of
        construction order: the fromList fold direction, parallel merges, etc. no
        longer change the answer. This is the right choice for audited ledgers and
        for making the construction-order optimizations (fromList O(N)) safe.

    @Integer@ (minimal-currency-unit) is intentionally NOT offered: ABM simulations
    use relative prices with base unit 1 and sub-unit fractional prices, which an
    integer cannot represent.

    == Ergonomics

    Numeric literals work without wrapping, because 'Num'/'Fractional' are derived:

    > type Ledger = Journal Term MoneyDecimal (HatBase AccountTitles)
    > entry = 10.5 :@ Hat:<Cash .+ 2 :@ Not:<Sales   -- 10.5 and 2 are MoneyDecimal literals

    == Rounding

    The core algebra only adds/subtracts, which is exact for t'MoneyDecimal' and needs no
    rounding. Rounding is only needed by /multiplication and division/ (tax ratios,
    proration, scalar product) at the point a monetary amount is /finalised/. Use
    'bankersRound': it rounds half-to-even (the unbiased financial default; also GHC's
    'Prelude.round' and IEEE-754's default mode). A ceiling variant ('ceilingRound') is
    provided for the previous @rounding = ceiling@ behaviour and for jurisdictions whose
    rules differ. There is no single correct rule (e.g. Japanese consumption tax rounding
    varies by company), so the rounding function is explicit and swappable.
-}
module ExchangeAlgebra.Value
    ( MoneyDecimal(..)
    , toDecimal
    , bankersRound
    , ceilingRound
    , MoneyDouble(..)
    , toDouble
    ) where

import           ExchangeAlgebra.Algebra (HatVal (..), Nearly (..))
import           Data.Decimal            (Decimal, DecimalRaw (Decimal), roundTo')
import           Data.Word               (Word8)
import           Data.Hashable           (Hashable (..))
import           Control.DeepSeq         (NFData (..))
import qualified Data.Binary             as Binary

-- | A non-negative exact decimal value (wraps 'Data.Decimal.Decimal').
--
-- Non-negativity is a /soft/ invariant, the same as for the @Double@ instance:
-- it is not enforced by the constructor (intermediate subtraction inside @bar@/@(.-)@
-- can produce negatives), but 'isErrorValue' reports @x < 0@ so the @(.@)@ smart
-- constructor rejects negative postings.
newtype MoneyDecimal = MoneyDecimal Decimal
  -- Num/Fractional are derived so numeric literals (@10.5@, @0.08@) work directly,
  -- with no @MoneyDecimal@ wrapper at use sites. Show/Eq/Ord delegate to t'Decimal'.
  -- 'Real' (and thus 'toRational') is derived so values can be converted to/from
  -- @Double@ via 'realToFrac' at the simulation boundary: ABM parameters, input
  -- coefficients and random draws stay 'Double', and are converted to t'MoneyDecimal'
  -- only where they enter a ledger; final stock/profit amounts convert back for
  -- reporting. The ledger arithmetic in between is exact.
  deriving newtype (Eq, Ord, Show, Num, Fractional, Real)

-- | Project out the underlying t'Decimal'.
toDecimal :: MoneyDecimal -> Decimal
toDecimal (MoneyDecimal d) = d

-- 'Nearly': for an exact type there is no rounding noise to tolerate, so the
-- tolerance argument is ignored and equality is exact. (Contrast the @Double@
-- instance, which uses a scale-aware tolerance.)
instance Nearly MoneyDecimal where
    {-# INLINE isNearly #-}
    isNearly x y _ = x == y

instance HatVal MoneyDecimal where
    {-# INLINE zeroValue #-}
    zeroValue = MoneyDecimal 0
    -- Exact decimals have no NaN/Infinity; the only "error value" is a negative
    -- amount, which violates the non-negativity invariant of the algebra.
    {-# INLINE isErrorValue #-}
    isErrorValue (MoneyDecimal x) = x < 0
    -- Render exactly (e.g. "0.3", "12.34"); unlike the Double instance there is no
    -- fixed-2-decimal formatting, because the decimal value is already exact.
    {-# INLINE showValue #-}
    showValue (MoneyDecimal x) = show x

-- 'Binary'/'Hashable' are defined here (not orphan) because 'Data.Decimal' ships
-- neither, and 'Alg'/t'Journal' serialisation and the binary spill path require
-- @Binary v@. Both go through the (places, mantissa) structure of t'Decimal'.
instance Binary.Binary MoneyDecimal where
    {-# INLINE put #-}
    put (MoneyDecimal (Decimal places mantissa)) = do
        Binary.put (places :: Word8)
        Binary.put (mantissa :: Integer)
    {-# INLINE get #-}
    get = do
        places   <- Binary.get :: Binary.Get Word8
        mantissa <- Binary.get :: Binary.Get Integer
        pure (MoneyDecimal (Decimal places mantissa))

instance Hashable MoneyDecimal where
    {-# INLINE hashWithSalt #-}
    hashWithSalt s (MoneyDecimal (Decimal places mantissa)) =
        s `hashWithSalt` places `hashWithSalt` mantissa

instance NFData MoneyDecimal where
    {-# INLINE rnf #-}
    rnf (MoneyDecimal (Decimal places mantissa)) = rnf places `seq` rnf mantissa

------------------------------------------------------------------
-- * MoneyDouble — fast IEEE-754 value type
------------------------------------------------------------------

-- | A fast IEEE-754 money value (wraps 'Prelude.Double').
--
-- This is the @newtype@ counterpart of the bare-@Double@ instance: a dedicated,
-- domain-specific money type so a ledger value cannot be silently confused with
-- an ABM coefficient, a random draw, or any other raw 'Double'. Every instance
-- it needs is owned here (via @deriving newtype@), so — exactly like
-- t'MoneyDecimal' — there are no orphan instances. Use t'MoneyDouble' for the same
-- speed as bare 'Double' while keeping the value type distinct in signatures.
--
-- Trade-off vs t'MoneyDecimal': addition is /non-associative/ (FP), so @norm@ \/
-- @bar@ can differ in the last ULP depending on construction order. It is fast
-- and runs everywhere bare 'Double' does (subtraction is signed, so the
-- intermediate negatives that arise inside @bar@\/@(.-)@ are fine — unlike
-- @Number.NonNegative.Double@, whose @(-)@ /errors/ on a negative result).
--
-- Non-negativity is the same /soft/ invariant as for t'MoneyDecimal' and bare
-- 'Double': not enforced by the constructor, but 'isErrorValue' reports
-- @isNaN x || isInfinite x || x < 0@, so the @(.\@)@ smart constructor and
-- @(.*)@ reject negative\/non-finite values.
newtype MoneyDouble = MoneyDouble Double
  -- All instances are coerced from the existing bare-'Double' instances
  -- ('Nearly'/'HatVal' live in "ExchangeAlgebra.Algebra"; 'Binary'/'Hashable'/
  -- 'NFData' come from the binary/hashable/deepseq packages), so t'MoneyDouble'
  -- is a zero-cost wrapper with identical numeric behaviour and 2-decimal
  -- 'showValue' formatting.
  deriving newtype ( Eq, Ord, Show, Num, Fractional, Real, RealFrac
                   , Nearly, HatVal, Hashable, NFData, Binary.Binary )

-- | Project out the underlying 'Prelude.Double'.
toDouble :: MoneyDouble -> Double
toDouble (MoneyDouble d) = d

-- | Round a value to @n@ decimal places using /banker's rounding/
-- (round-half-to-even): the unbiased financial default. Ties go to the nearest
-- even digit (@2.5 -> 2@, @3.5 -> 4@, @0.125 -> 0.12@), so repeated rounding over
-- many transactions does not drift the total upward the way half-up does. This is
-- 'Prelude.round' applied per 'Data.Decimal.roundTo''.
--
-- Apply at the point a monetary amount is finalised after multiplication/division
-- (tax, proration, scalar product). The core algebra (add/subtract) is exact and
-- needs no rounding.
bankersRound :: Word8 -> MoneyDecimal -> MoneyDecimal
bankersRound places (MoneyDecimal d) = MoneyDecimal (roundTo' round places d)

-- | Round a value to @n@ decimal places by rounding /up/ (ceiling). This preserves
-- the previous library default (@rounding = ceiling@) and suits jurisdictions whose
-- rules round up. Prefer 'bankersRound' unless a ceiling rule is specifically required.
ceilingRound :: Word8 -> MoneyDecimal -> MoneyDecimal
ceilingRound places (MoneyDecimal d) = MoneyDecimal (roundTo' ceiling places d)