From 363d440d53cc8e94831cbd298f0a2da38893f817 Mon Sep 17 00:00:00 2001 From: oqyude Date: Thu, 8 Oct 2026 16:44:59 +0300 Subject: [PATCH] =?UTF-8?q?T3:=20Decimal=20for=20money=20=E2=80=94=20all?= =?UTF-8?q?=20monetary=20fields=20migrated=20from=20float?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- application/forecast.py | 48 +++++++++++++++++++++------------- application/scenarios.py | 44 +++++++++++++++++-------------- domain/account.py | 5 ++-- domain/asset.py | 5 ++-- domain/currency.py | 43 +++++++++++++++++++----------- domain/liability.py | 9 ++++--- domain/recurring.py | 5 ++-- domain/scenario.py | 9 ++++--- domain/transaction.py | 5 ++-- infrastructure/ai/assistant.py | 18 ++++++++++--- 10 files changed, 118 insertions(+), 73 deletions(-) diff --git a/application/forecast.py b/application/forecast.py index 213f469..4081440 100644 --- a/application/forecast.py +++ b/application/forecast.py @@ -1,4 +1,5 @@ from copy import deepcopy +from decimal import Decimal, ROUND_HALF_UP from domain import Account, FinancialModel @@ -7,6 +8,12 @@ class ForecastError(Exception): pass +_ZERO = Decimal("0") +_ONE = Decimal("1") +_TWELVE = Decimal("12") +_HUNDRED = Decimal("100") + + class ForecastService: def __init__(self, model: FinancialModel): self.model = deepcopy(model) @@ -30,19 +37,19 @@ class ForecastService: # Asset growth — once per month, distributed across accounts proportionally for m in range(months): total_growth = sum( - a.value * a.growth_rate / 100 / 12 + a.value * a.growth_rate / _HUNDRED / _TWELVE for a in self.model.assets ) month_rows = [r for r in results if r["month"] == m + 1] - total_bal = sum(r["balance"] for r in month_rows) or 1 + total_bal = sum(r["balance"] for r in month_rows) or _ONE for r in month_rows: share = r["balance"] / total_bal - r["income"] = round(r["income"] + total_growth * share, 2) - r["balance"] = round(r["balance"] + total_growth * share, 2) + r["income"] = _q(r["income"] + total_growth * share) + r["balance"] = _q(r["balance"] + total_growth * share) # Compound asset values for next month for a in self.model.assets: - a.value += a.value * a.growth_rate / 100 / 12 + a.value = a.value + a.value * a.growth_rate / _HUNDRED / _TWELVE return results @@ -50,8 +57,8 @@ class ForecastService: balance = account.balance monthly = [] for m in range(months): - income = 0.0 - expenses = 0.0 + income = _ZERO + expenses = _ZERO for t in self.model.transactions: if t.account == str(account.id): @@ -68,23 +75,23 @@ class ForecastService: expenses += self._liability_cost(account) - balance += income - expenses + balance = balance + income - expenses monthly.append({ - "balance": round(balance, 2), - "income": round(income, 2), - "expenses": round(expenses, 2), + "balance": _q(balance), + "income": _q(income), + "expenses": _q(expenses), }) return monthly - def _liability_cost(self, account: Account) -> float: - total = 0.0 + def _liability_cost(self, account: Account) -> Decimal: + total = _ZERO for liability in self.model.liabilities: - interest_cost = liability.balance * liability.interest / 100 / 12 + interest_cost = liability.balance * liability.interest / _HUNDRED / _TWELVE total += interest_cost - liability.balance -= liability.payment - interest_cost + liability.balance = liability.balance - (liability.payment - interest_cost) if liability.balance < 0: - liability.balance = 0 + liability.balance = _ZERO return total def summary(self, months: int = 12) -> dict: @@ -97,7 +104,12 @@ class ForecastService: all_expenses = sum(r["expenses"] for r in results) return { "total_balance": final["balance"], - "total_income": round(all_income, 2), - "total_expenses": round(all_expenses, 2), + "total_income": _q(all_income), + "total_expenses": _q(all_expenses), "months": months, } + + +def _q(v: Decimal) -> Decimal: + """Quantize to 2 decimal places, ROUND_HALF_UP.""" + return v.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) diff --git a/application/scenarios.py b/application/scenarios.py index 8b0f2a0..2efbaf7 100644 --- a/application/scenarios.py +++ b/application/scenarios.py @@ -1,4 +1,5 @@ from copy import deepcopy +from decimal import Decimal from domain import FinancialModel, ForecastScenario from application.forecast import ForecastService @@ -11,23 +12,23 @@ class ScenarioError(Exception): DEFAULT_SCENARIOS = { "baseline": ForecastScenario( name="baseline", - income_multiplier=1.0, - expense_multiplier=1.0, - growth_multiplier=1.0, + income_multiplier=Decimal("1.0"), + expense_multiplier=Decimal("1.0"), + growth_multiplier=Decimal("1.0"), description="Базовый сценарий без изменений", ), "optimistic": ForecastScenario( name="optimistic", - income_multiplier=1.15, - expense_multiplier=0.95, - growth_multiplier=1.2, + income_multiplier=Decimal("1.15"), + expense_multiplier=Decimal("0.95"), + growth_multiplier=Decimal("1.2"), description="Оптимистичный: доход +15%, расход -5%, рост активов +20%", ), "pessimistic": ForecastScenario( name="pessimistic", - income_multiplier=0.85, - expense_multiplier=1.1, - growth_multiplier=0.8, + income_multiplier=Decimal("0.85"), + expense_multiplier=Decimal("1.1"), + growth_multiplier=Decimal("0.8"), description="Пессимистичный: доход -15%, расход +10%, рост активов -20%", ), } @@ -42,18 +43,18 @@ class ScenarioService: for t in model.transactions: if t.amount > 0: - t.amount *= scenario.income_multiplier + t.amount = t.amount * scenario.income_multiplier else: - t.amount *= scenario.expense_multiplier + t.amount = t.amount * scenario.expense_multiplier for r in model.recurring: if r.category == "income": - r.amount *= scenario.income_multiplier + r.amount = r.amount * scenario.income_multiplier else: - r.amount *= scenario.expense_multiplier + r.amount = r.amount * scenario.expense_multiplier for a in model.assets: - a.growth_rate *= scenario.growth_multiplier + a.growth_rate = a.growth_rate * scenario.growth_multiplier service = ForecastService(model) result = service.summary(months) @@ -69,16 +70,19 @@ class ScenarioService: def what_if( self, - income_mult: float = 1.0, - expense_mult: float = 1.0, - growth_mult: float = 1.0, + income_mult: Decimal | float = Decimal("1.0"), + expense_mult: Decimal | float = Decimal("1.0"), + growth_mult: Decimal | float = Decimal("1.0"), months: int = 12, ) -> dict: + def to_d(v: Decimal | float) -> Decimal: + return v if isinstance(v, Decimal) else Decimal(str(v)) + scenario = ForecastScenario( name="what-if", - income_multiplier=income_mult, - expense_multiplier=expense_mult, - growth_multiplier=growth_mult, + income_multiplier=to_d(income_mult), + expense_multiplier=to_d(expense_mult), + growth_multiplier=to_d(growth_mult), description=( f"What-if: income x{income_mult}, " f"expense x{expense_mult}, growth x{growth_mult}" diff --git a/domain/account.py b/domain/account.py index 3949112..38ac221 100644 --- a/domain/account.py +++ b/domain/account.py @@ -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 diff --git a/domain/asset.py b/domain/asset.py index c2bad2a..15a3621 100644 --- a/domain/asset.py +++ b/domain/asset.py @@ -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")) diff --git a/domain/currency.py b/domain/currency.py index e7baf0a..1393302 100644 --- a/domain/currency.py +++ b/domain/currency.py @@ -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) diff --git a/domain/liability.py b/domain/liability.py index 8a2e74c..c747202 100644 --- a/domain/liability.py +++ b/domain/liability.py @@ -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 diff --git a/domain/recurring.py b/domain/recurring.py index 4daec87..c17d40a 100644 --- a/domain/recurring.py +++ b/domain/recurring.py @@ -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 diff --git a/domain/scenario.py b/domain/scenario.py index 0dd33b6..02bdcf8 100644 --- a/domain/scenario.py +++ b/domain/scenario.py @@ -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 diff --git a/domain/transaction.py b/domain/transaction.py index 3a4e4ae..ccab260 100644 --- a/domain/transaction.py +++ b/domain/transaction.py @@ -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 diff --git a/infrastructure/ai/assistant.py b/infrastructure/ai/assistant.py index 3515cda..81a993d 100644 --- a/infrastructure/ai/assistant.py +++ b/infrastructure/ai/assistant.py @@ -1,4 +1,5 @@ import json +from decimal import Decimal from infrastructure.ai import prompts from domain import CurrencyConverter, FinancialModel @@ -9,6 +10,15 @@ class AssistantError(Exception): pass +class _DecimalEncoder(json.JSONEncoder): + """JSON-сериализатор: Decimal → str (для AI-промптов).""" + + def default(self, o): + if isinstance(o, Decimal): + return str(o) + return super().default(o) + + class AssistantService: def __init__( self, @@ -26,8 +36,8 @@ class AssistantService: summary = forecast_service.summary(months) prompt = prompts.format_context( - model_json=json.dumps(self.model.to_dict(), indent=2, ensure_ascii=False), - forecast_json=json.dumps(forecast_result, indent=2, ensure_ascii=False), + model_json=json.dumps(self.model.to_dict(), indent=2, ensure_ascii=False, cls=_DecimalEncoder), + forecast_json=json.dumps(forecast_result, indent=2, ensure_ascii=False, cls=_DecimalEncoder), months=months, base_currency=self.model.base_currency, display_currency=self.display_currency, @@ -45,8 +55,8 @@ class AssistantService: forecast_result = forecast_service.forecast_cashflow(months) prompt = prompts.ADVICE_PROMPT.format( - model_json=json.dumps(self.model.to_dict(), indent=2, ensure_ascii=False), - forecast_json=json.dumps(forecast_result, indent=2, ensure_ascii=False), + model_json=json.dumps(self.model.to_dict(), indent=2, ensure_ascii=False, cls=_DecimalEncoder), + forecast_json=json.dumps(forecast_result, indent=2, ensure_ascii=False, cls=_DecimalEncoder), question=question, base_currency=self.model.base_currency, display_currency=self.display_currency,