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:
+30
-18
@@ -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)
|
||||
|
||||
+24
-20
@@ -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}"
|
||||
|
||||
+3
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user