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:
2026-10-08 16:44:59 +03:00
parent c765f3672a
commit 363d440d53
10 changed files with 118 additions and 73 deletions
+3 -2
View File
@@ -1,3 +1,4 @@
from decimal import Decimal
from uuid import UUID, uuid4
from pydantic import BaseModel, Field, field_validator
@@ -7,11 +8,11 @@ class Account(BaseModel):
id: UUID = Field(default_factory=uuid4)
name: str = ""
currency: str = "USD"
balance: float = 0.0
balance: Decimal = Field(default=Decimal("0"))
@field_validator("balance")
@classmethod
def _balance_non_negative(cls, v: float) -> float:
def _balance_non_negative(cls, v: Decimal) -> Decimal:
if v < 0:
raise ValueError("balance must be non-negative")
return v
+3 -2
View File
@@ -1,3 +1,4 @@
from decimal import Decimal
from uuid import UUID, uuid4
from pydantic import BaseModel, Field
@@ -6,5 +7,5 @@ from pydantic import BaseModel, Field
class Asset(BaseModel):
id: UUID = Field(default_factory=uuid4)
name: str = ""
value: float = 0.0
growth_rate: float = 0.0
value: Decimal = Field(default=Decimal("0"))
growth_rate: Decimal = Field(default=Decimal("0"))
+28 -15
View File
@@ -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)
+5 -4
View File
@@ -1,3 +1,4 @@
from decimal import Decimal
from uuid import UUID, uuid4
from pydantic import BaseModel, Field, field_validator
@@ -6,13 +7,13 @@ from pydantic import BaseModel, Field, field_validator
class Liability(BaseModel):
id: UUID = Field(default_factory=uuid4)
name: str = ""
balance: float = 0.0
interest: float = 0.0
payment: float = 0.0
balance: Decimal = Field(default=Decimal("0"))
interest: Decimal = Field(default=Decimal("0"))
payment: Decimal = Field(default=Decimal("0"))
@field_validator("interest", "payment")
@classmethod
def _non_negative(cls, v: float) -> float:
def _non_negative(cls, v: Decimal) -> Decimal:
if v < 0:
raise ValueError("must be non-negative")
return v
+3 -2
View File
@@ -1,3 +1,4 @@
from decimal import Decimal
from uuid import UUID, uuid4
from pydantic import BaseModel, Field, field_validator
@@ -8,7 +9,7 @@ class RecurringCashflow(BaseModel):
start_date: str = ""
end_date: str = ""
frequency: str = "monthly"
amount: float = 0.0
amount: Decimal = Field(default=Decimal("0"))
category: str = ""
@field_validator("frequency")
@@ -20,7 +21,7 @@ class RecurringCashflow(BaseModel):
@field_validator("amount")
@classmethod
def _amount_nonzero(cls, v: float) -> float:
def _amount_nonzero(cls, v: Decimal) -> Decimal:
if v == 0:
raise ValueError("amount must be non-zero")
return v
+5 -4
View File
@@ -1,3 +1,4 @@
from decimal import Decimal
from uuid import UUID, uuid4
from pydantic import BaseModel, Field, field_validator
@@ -6,14 +7,14 @@ from pydantic import BaseModel, Field, field_validator
class ForecastScenario(BaseModel):
id: UUID = Field(default_factory=uuid4)
name: str = "baseline"
income_multiplier: float = 1.0
expense_multiplier: float = 1.0
growth_multiplier: float = 1.0
income_multiplier: Decimal = Field(default=Decimal("1"))
expense_multiplier: Decimal = Field(default=Decimal("1"))
growth_multiplier: Decimal = Field(default=Decimal("1"))
description: str = ""
@field_validator("income_multiplier", "expense_multiplier", "growth_multiplier")
@classmethod
def _non_negative(cls, v: float) -> float:
def _non_negative(cls, v: Decimal) -> Decimal:
if v < 0:
raise ValueError("multiplier must be non-negative")
return v
+3 -2
View File
@@ -1,3 +1,4 @@
from decimal import Decimal
from uuid import UUID, uuid4
from pydantic import BaseModel, Field, field_validator
@@ -8,12 +9,12 @@ class Transaction(BaseModel):
date: str = ""
account: str = ""
category: str = ""
amount: float = 0.0
amount: Decimal = Field(default=Decimal("0"))
description: str = ""
@field_validator("amount")
@classmethod
def _amount_nonzero(cls, v: float) -> float:
def _amount_nonzero(cls, v: Decimal) -> Decimal:
if v == 0:
raise ValueError("amount must be non-zero")
return v