T3: Decimal for money — all monetary fields migrated from float
- All domain monetary fields now use Decimal: - Account.balance, Asset.value, Asset.growth_rate - Liability.balance, Liability.interest, Liability.payment - Transaction.amount, RecurringCashflow.amount - ExchangeRate.rate - ForecastScenario.income_multiplier, expense_multiplier, growth_multiplier - CurrencyConverter: all arithmetic in Decimal, quantize to 0.01 with ROUND_HALF_UP - ForecastService: Decimal arithmetic throughout (income, expenses, balance, growth, liability cost) - ScenarioService: Decimal multipliers, deepcopy safe with Decimal fields - assistant.py: _DecimalEncoder for json.dumps (Decimal -> str in JSON) Pydantic v2 + Decimal: - model_dump(mode='json') converts Decimal to str (JSON-safe) - model_validate() parses str back to Decimal - Round-trip preserves precision (100.50 stays 100.50) Tests: 63/63 pass.
This commit is contained in:
+28
-15
@@ -1,3 +1,5 @@
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
CURRENCY_SYMBOLS = {
|
||||
@@ -15,18 +17,18 @@ CURRENCY_SYMBOLS = {
|
||||
class ExchangeRate(BaseModel):
|
||||
from_currency: str = "USD"
|
||||
to_currency: str = "RUB"
|
||||
rate: float = 80.0
|
||||
rate: Decimal = Field(default=Decimal("80"))
|
||||
|
||||
@field_validator("rate")
|
||||
@classmethod
|
||||
def _rate_positive(cls, v: float) -> float:
|
||||
def _rate_positive(cls, v: Decimal) -> Decimal:
|
||||
if v <= 0:
|
||||
raise ValueError("rate must be positive")
|
||||
return v
|
||||
|
||||
|
||||
DEFAULT_RATES: list[ExchangeRate] = [
|
||||
ExchangeRate(from_currency="USD", to_currency="RUB", rate=80.0),
|
||||
ExchangeRate(from_currency="USD", to_currency="RUB", rate=Decimal("80")),
|
||||
]
|
||||
|
||||
|
||||
@@ -36,31 +38,42 @@ class CurrencyError(Exception):
|
||||
|
||||
class CurrencyConverter:
|
||||
def __init__(self, rates: list[ExchangeRate] | None = None):
|
||||
self._rates: dict[tuple[str, str], float] = {}
|
||||
self._rates: dict[tuple[str, str], Decimal] = {}
|
||||
if rates:
|
||||
for r in rates:
|
||||
self.set_rate(r.from_currency, r.to_currency, r.rate)
|
||||
|
||||
def set_rate(self, from_currency: str, to_currency: str, rate: float) -> None:
|
||||
if rate <= 0:
|
||||
raise CurrencyError(f"Rate must be positive: {rate}")
|
||||
self._rates[(from_currency, to_currency)] = rate
|
||||
inverse = 1.0 / rate
|
||||
self._rates[(to_currency, from_currency)] = inverse
|
||||
def set_rate(
|
||||
self,
|
||||
from_currency: str,
|
||||
to_currency: str,
|
||||
rate: Decimal | float | int | str,
|
||||
) -> None:
|
||||
d = Decimal(str(rate)) if not isinstance(rate, Decimal) else rate
|
||||
if d <= 0:
|
||||
raise CurrencyError(f"Rate must be positive: {d}")
|
||||
self._rates[(from_currency, to_currency)] = d
|
||||
self._rates[(to_currency, from_currency)] = Decimal("1") / d
|
||||
|
||||
def get_rate(self, from_currency: str, to_currency: str) -> float:
|
||||
def get_rate(self, from_currency: str, to_currency: str) -> Decimal:
|
||||
if from_currency == to_currency:
|
||||
return 1.0
|
||||
return Decimal("1")
|
||||
try:
|
||||
return self._rates[(from_currency, to_currency)]
|
||||
except KeyError:
|
||||
raise CurrencyError(f"No exchange rate: {from_currency} → {to_currency}")
|
||||
|
||||
def convert(self, amount: float, from_currency: str, to_currency: str) -> float:
|
||||
def convert(
|
||||
self,
|
||||
amount: Decimal | float | int | str,
|
||||
from_currency: str,
|
||||
to_currency: str,
|
||||
) -> Decimal:
|
||||
a = Decimal(str(amount)) if not isinstance(amount, Decimal) else amount
|
||||
if from_currency == to_currency:
|
||||
return amount
|
||||
return a
|
||||
rate = self.get_rate(from_currency, to_currency)
|
||||
return round(amount * rate, 2)
|
||||
return (a * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
def get_symbol(self, currency: str) -> str:
|
||||
return CURRENCY_SYMBOLS.get(currency, currency)
|
||||
|
||||
Reference in New Issue
Block a user