Skip to content

Percentages

The Percentage class represents a non-negative percentage applied flat over a value, with no temporal dimension and no compounding. It is the right type for fees, fines, MDR, IOF flat components, and any percentage where the operation is valor × taxa and ends there.

Why a separate type?

Rate and InterestRate model rates with a temporal dimension — they have a period and support conversions between periodicities (to_daily, to_monthly, to_annual). A flat percentage does not belong in those types:

Concept Has time dimension? Compounds? Example
Rate / InterestRate Yes Yes "5% a.a." (annual interest)
Percentage No No "5%" (MDR, multa, fee)

If you store a flat percentage as InterestRate("5% a.m.") (a common workaround), any later call to .to_annual() or .accrue() produces a silently wrong number — the type checker won't catch it. Percentage exists to remove that footgun: it physically does not expose those methods.

Construction is string-only

Percentage only accepts strings of the form "<number>%". Numeric inputs and bare strings without % are rejected on purpose:

from money_warp import Percentage

# OK
Percentage("5%")          # 5%
Percentage("5.5%")        # 5.5%
Percentage("0.5%")        # 0.5%
Percentage("0%")          # 0% (no fee)
Percentage("100%")        # 100%

# Rejected
Percentage(5)             # TypeError
Percentage(0.05)          # TypeError
Percentage("5")           # ValueError — missing '%'
Percentage("0.05")        # ValueError — missing '%'
Percentage("-5%")         # ValueError — negative not allowed
Percentage("5% a.a.")     # ValueError — temporal suffix → use Rate/InterestRate
Percentage("5% monthly")  # ValueError — same reason

The reason for rejecting numeric inputs is the classic ambiguity: when someone writes Percentage(5), you cannot tell if they mean 5% or 500%. The literal % in the string is the only unambiguous contract.

API: as_decimal and as_percentage

Percentage exposes only two accessors. There is no apply(money), no as_float, no to_*, no accrue — those would all imply semantics the type does not have.

from money_warp import Percentage
from decimal import Decimal

mdr = Percentage("5%")

mdr.as_decimal()     # Decimal('0.05')
mdr.as_percentage()  # Decimal('5')

# Optional precision argument
high_precision = Percentage("6.123%")
high_precision.as_decimal()    # Decimal('0.06123')
high_precision.as_decimal(2)   # Decimal('0.06')  (rounded)
high_precision.as_percentage(2)  # Decimal('6.12')

# Constructor-level precision applies as default
fixed = Percentage("6.123%", precision=2)
fixed.as_decimal()    # Decimal('0.06')

# String form (canonical: 2 decimals by default)
str(Percentage("5%"))                    # '5.00%'
str(Percentage("5%", str_decimals=4))    # '5.0000%'
str(Percentage("5%", str_decimals=0))    # '5%'

# Round-trip (stable within the configured str_decimals)
p = Percentage("5%")
Percentage(str(p)) == p                  # True

# High-precision values: increase str_decimals to keep the round-trip lossless
hp = Percentage("6.123%", str_decimals=4)
Percentage(str(hp), str_decimals=4) == hp  # True ('6.1230%')

# Default str_decimals=2 truncates beyond 2 places (rounded via ROUND_HALF_UP)
str(Percentage("6.129%"))                # '6.13%'

Applying a percentage to Money

Percentage does not include an apply(money) method. The consumer does the multiplication explicitly so the boundary between "I have a percentage" and "I'm computing a fee in money" stays visible:

from money_warp import Money, Percentage

mdr = Percentage("5%")
amount = Money("1000")

fee = Money(amount.raw_amount * mdr.as_decimal())
print(fee)  # 50.00

This pattern works for any value-based fee:

# Late-payment fine over an overdue installment
fine_rate = Percentage("2%")
overdue = Money("450")
fine = Money(overdue.raw_amount * fine_rate.as_decimal())  # 9.00

# IOF flat component
iof_flat_rate = Percentage("0.38%")
principal = Money("10000")
iof_flat = Money(principal.raw_amount * iof_flat_rate.as_decimal())  # 38.00

Comparisons

Percentage supports ==, <, <=, >, >= and is hashable. Equality has a small tolerance to make equal-by-value percentages compare equal regardless of construction precision:

from money_warp import Percentage

Percentage("5%") == Percentage("5.00%")         # True
Percentage("5%") < Percentage("6%")             # True
Percentage("5%") <= Percentage("5%")            # True

{Percentage("5%"), Percentage("5.00%")}         # set with 1 element

# Cross-type comparisons return NotImplemented (==) or fail (ordering),
# because Percentage and Rate live in different semantic dimensions.
from money_warp import Rate
Percentage("5%") == Rate("5% a.a.")             # False
# Percentage("5%") < Rate("5% a.a.")            # raises (cannot compare)

Decision table: which type?

Case Type Justification
MDR (partner rate) Percentage Applied once over the operation amount. No capitalization, no period.
Fine (fine_rate, multa) Percentage Applied once over the overdue amount. No capitalization.
IOF flat component Percentage Percentage applied once over the value.
Contractual interest rate InterestRate Has a temporal dimension, capitalizes, ≥ 0.
Late-payment interest rate InterestRate Capitalizes over days of delay.
IRR / MIRR (computed) Rate Computed metric, may be negative, has a temporal dimension.

Practical rule: if the operation you want to do with the "rate" is valor × taxa and ends there, it is Percentage. If it involves time (accrual, conversion between periodicities, capitalization), it is Rate / InterestRate.

Serialization

Percentage integrates with the same extensions as Rate and InterestRate:

# Marshmallow
from money_warp.ext.marshmallow import PercentageField
from marshmallow import Schema

class FeeSchema(Schema):
    mdr = PercentageField()

FeeSchema().dump({"mdr": Percentage("5%")})    # {"mdr": "5.00%"}
FeeSchema().load({"mdr": "5.00%"})             # {"mdr": Percentage('5.00%')}

# SQLAlchemy
from money_warp.ext.sa import PercentageType
from sqlalchemy import Column, Integer
from sqlalchemy.orm import DeclarativeBase

class Base(DeclarativeBase): ...

class PartnerFee(Base):
    __tablename__ = "partner_fees"
    id = Column(Integer, primary_key=True)
    mdr = Column(PercentageType())  # stored as String("5.00%")

Both extensions delegate validation to the Percentage constructor, so invalid inputs (numeric, missing %, temporal suffix, negative) raise on load and never poison the in-memory model.