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 copy import deepcopy
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
|
||||||
from domain import Account, FinancialModel
|
from domain import Account, FinancialModel
|
||||||
|
|
||||||
@@ -7,6 +8,12 @@ class ForecastError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_ZERO = Decimal("0")
|
||||||
|
_ONE = Decimal("1")
|
||||||
|
_TWELVE = Decimal("12")
|
||||||
|
_HUNDRED = Decimal("100")
|
||||||
|
|
||||||
|
|
||||||
class ForecastService:
|
class ForecastService:
|
||||||
def __init__(self, model: FinancialModel):
|
def __init__(self, model: FinancialModel):
|
||||||
self.model = deepcopy(model)
|
self.model = deepcopy(model)
|
||||||
@@ -30,19 +37,19 @@ class ForecastService:
|
|||||||
# Asset growth — once per month, distributed across accounts proportionally
|
# Asset growth — once per month, distributed across accounts proportionally
|
||||||
for m in range(months):
|
for m in range(months):
|
||||||
total_growth = sum(
|
total_growth = sum(
|
||||||
a.value * a.growth_rate / 100 / 12
|
a.value * a.growth_rate / _HUNDRED / _TWELVE
|
||||||
for a in self.model.assets
|
for a in self.model.assets
|
||||||
)
|
)
|
||||||
month_rows = [r for r in results if r["month"] == m + 1]
|
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:
|
for r in month_rows:
|
||||||
share = r["balance"] / total_bal
|
share = r["balance"] / total_bal
|
||||||
r["income"] = round(r["income"] + total_growth * share, 2)
|
r["income"] = _q(r["income"] + total_growth * share)
|
||||||
r["balance"] = round(r["balance"] + total_growth * share, 2)
|
r["balance"] = _q(r["balance"] + total_growth * share)
|
||||||
|
|
||||||
# Compound asset values for next month
|
# Compound asset values for next month
|
||||||
for a in self.model.assets:
|
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
|
return results
|
||||||
|
|
||||||
@@ -50,8 +57,8 @@ class ForecastService:
|
|||||||
balance = account.balance
|
balance = account.balance
|
||||||
monthly = []
|
monthly = []
|
||||||
for m in range(months):
|
for m in range(months):
|
||||||
income = 0.0
|
income = _ZERO
|
||||||
expenses = 0.0
|
expenses = _ZERO
|
||||||
|
|
||||||
for t in self.model.transactions:
|
for t in self.model.transactions:
|
||||||
if t.account == str(account.id):
|
if t.account == str(account.id):
|
||||||
@@ -68,23 +75,23 @@ class ForecastService:
|
|||||||
|
|
||||||
expenses += self._liability_cost(account)
|
expenses += self._liability_cost(account)
|
||||||
|
|
||||||
balance += income - expenses
|
balance = balance + income - expenses
|
||||||
|
|
||||||
monthly.append({
|
monthly.append({
|
||||||
"balance": round(balance, 2),
|
"balance": _q(balance),
|
||||||
"income": round(income, 2),
|
"income": _q(income),
|
||||||
"expenses": round(expenses, 2),
|
"expenses": _q(expenses),
|
||||||
})
|
})
|
||||||
return monthly
|
return monthly
|
||||||
|
|
||||||
def _liability_cost(self, account: Account) -> float:
|
def _liability_cost(self, account: Account) -> Decimal:
|
||||||
total = 0.0
|
total = _ZERO
|
||||||
for liability in self.model.liabilities:
|
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
|
total += interest_cost
|
||||||
liability.balance -= liability.payment - interest_cost
|
liability.balance = liability.balance - (liability.payment - interest_cost)
|
||||||
if liability.balance < 0:
|
if liability.balance < 0:
|
||||||
liability.balance = 0
|
liability.balance = _ZERO
|
||||||
return total
|
return total
|
||||||
|
|
||||||
def summary(self, months: int = 12) -> dict:
|
def summary(self, months: int = 12) -> dict:
|
||||||
@@ -97,7 +104,12 @@ class ForecastService:
|
|||||||
all_expenses = sum(r["expenses"] for r in results)
|
all_expenses = sum(r["expenses"] for r in results)
|
||||||
return {
|
return {
|
||||||
"total_balance": final["balance"],
|
"total_balance": final["balance"],
|
||||||
"total_income": round(all_income, 2),
|
"total_income": _q(all_income),
|
||||||
"total_expenses": round(all_expenses, 2),
|
"total_expenses": _q(all_expenses),
|
||||||
"months": months,
|
"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 copy import deepcopy
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from domain import FinancialModel, ForecastScenario
|
from domain import FinancialModel, ForecastScenario
|
||||||
from application.forecast import ForecastService
|
from application.forecast import ForecastService
|
||||||
@@ -11,23 +12,23 @@ class ScenarioError(Exception):
|
|||||||
DEFAULT_SCENARIOS = {
|
DEFAULT_SCENARIOS = {
|
||||||
"baseline": ForecastScenario(
|
"baseline": ForecastScenario(
|
||||||
name="baseline",
|
name="baseline",
|
||||||
income_multiplier=1.0,
|
income_multiplier=Decimal("1.0"),
|
||||||
expense_multiplier=1.0,
|
expense_multiplier=Decimal("1.0"),
|
||||||
growth_multiplier=1.0,
|
growth_multiplier=Decimal("1.0"),
|
||||||
description="Базовый сценарий без изменений",
|
description="Базовый сценарий без изменений",
|
||||||
),
|
),
|
||||||
"optimistic": ForecastScenario(
|
"optimistic": ForecastScenario(
|
||||||
name="optimistic",
|
name="optimistic",
|
||||||
income_multiplier=1.15,
|
income_multiplier=Decimal("1.15"),
|
||||||
expense_multiplier=0.95,
|
expense_multiplier=Decimal("0.95"),
|
||||||
growth_multiplier=1.2,
|
growth_multiplier=Decimal("1.2"),
|
||||||
description="Оптимистичный: доход +15%, расход -5%, рост активов +20%",
|
description="Оптимистичный: доход +15%, расход -5%, рост активов +20%",
|
||||||
),
|
),
|
||||||
"pessimistic": ForecastScenario(
|
"pessimistic": ForecastScenario(
|
||||||
name="pessimistic",
|
name="pessimistic",
|
||||||
income_multiplier=0.85,
|
income_multiplier=Decimal("0.85"),
|
||||||
expense_multiplier=1.1,
|
expense_multiplier=Decimal("1.1"),
|
||||||
growth_multiplier=0.8,
|
growth_multiplier=Decimal("0.8"),
|
||||||
description="Пессимистичный: доход -15%, расход +10%, рост активов -20%",
|
description="Пессимистичный: доход -15%, расход +10%, рост активов -20%",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -42,18 +43,18 @@ class ScenarioService:
|
|||||||
|
|
||||||
for t in model.transactions:
|
for t in model.transactions:
|
||||||
if t.amount > 0:
|
if t.amount > 0:
|
||||||
t.amount *= scenario.income_multiplier
|
t.amount = t.amount * scenario.income_multiplier
|
||||||
else:
|
else:
|
||||||
t.amount *= scenario.expense_multiplier
|
t.amount = t.amount * scenario.expense_multiplier
|
||||||
|
|
||||||
for r in model.recurring:
|
for r in model.recurring:
|
||||||
if r.category == "income":
|
if r.category == "income":
|
||||||
r.amount *= scenario.income_multiplier
|
r.amount = r.amount * scenario.income_multiplier
|
||||||
else:
|
else:
|
||||||
r.amount *= scenario.expense_multiplier
|
r.amount = r.amount * scenario.expense_multiplier
|
||||||
|
|
||||||
for a in model.assets:
|
for a in model.assets:
|
||||||
a.growth_rate *= scenario.growth_multiplier
|
a.growth_rate = a.growth_rate * scenario.growth_multiplier
|
||||||
|
|
||||||
service = ForecastService(model)
|
service = ForecastService(model)
|
||||||
result = service.summary(months)
|
result = service.summary(months)
|
||||||
@@ -69,16 +70,19 @@ class ScenarioService:
|
|||||||
|
|
||||||
def what_if(
|
def what_if(
|
||||||
self,
|
self,
|
||||||
income_mult: float = 1.0,
|
income_mult: Decimal | float = Decimal("1.0"),
|
||||||
expense_mult: float = 1.0,
|
expense_mult: Decimal | float = Decimal("1.0"),
|
||||||
growth_mult: float = 1.0,
|
growth_mult: Decimal | float = Decimal("1.0"),
|
||||||
months: int = 12,
|
months: int = 12,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
def to_d(v: Decimal | float) -> Decimal:
|
||||||
|
return v if isinstance(v, Decimal) else Decimal(str(v))
|
||||||
|
|
||||||
scenario = ForecastScenario(
|
scenario = ForecastScenario(
|
||||||
name="what-if",
|
name="what-if",
|
||||||
income_multiplier=income_mult,
|
income_multiplier=to_d(income_mult),
|
||||||
expense_multiplier=expense_mult,
|
expense_multiplier=to_d(expense_mult),
|
||||||
growth_multiplier=growth_mult,
|
growth_multiplier=to_d(growth_mult),
|
||||||
description=(
|
description=(
|
||||||
f"What-if: income x{income_mult}, "
|
f"What-if: income x{income_mult}, "
|
||||||
f"expense x{expense_mult}, growth x{growth_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 uuid import UUID, uuid4
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
@@ -7,11 +8,11 @@ class Account(BaseModel):
|
|||||||
id: UUID = Field(default_factory=uuid4)
|
id: UUID = Field(default_factory=uuid4)
|
||||||
name: str = ""
|
name: str = ""
|
||||||
currency: str = "USD"
|
currency: str = "USD"
|
||||||
balance: float = 0.0
|
balance: Decimal = Field(default=Decimal("0"))
|
||||||
|
|
||||||
@field_validator("balance")
|
@field_validator("balance")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _balance_non_negative(cls, v: float) -> float:
|
def _balance_non_negative(cls, v: Decimal) -> Decimal:
|
||||||
if v < 0:
|
if v < 0:
|
||||||
raise ValueError("balance must be non-negative")
|
raise ValueError("balance must be non-negative")
|
||||||
return v
|
return v
|
||||||
|
|||||||
+3
-2
@@ -1,3 +1,4 @@
|
|||||||
|
from decimal import Decimal
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -6,5 +7,5 @@ from pydantic import BaseModel, Field
|
|||||||
class Asset(BaseModel):
|
class Asset(BaseModel):
|
||||||
id: UUID = Field(default_factory=uuid4)
|
id: UUID = Field(default_factory=uuid4)
|
||||||
name: str = ""
|
name: str = ""
|
||||||
value: float = 0.0
|
value: Decimal = Field(default=Decimal("0"))
|
||||||
growth_rate: float = 0.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
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
CURRENCY_SYMBOLS = {
|
CURRENCY_SYMBOLS = {
|
||||||
@@ -15,18 +17,18 @@ CURRENCY_SYMBOLS = {
|
|||||||
class ExchangeRate(BaseModel):
|
class ExchangeRate(BaseModel):
|
||||||
from_currency: str = "USD"
|
from_currency: str = "USD"
|
||||||
to_currency: str = "RUB"
|
to_currency: str = "RUB"
|
||||||
rate: float = 80.0
|
rate: Decimal = Field(default=Decimal("80"))
|
||||||
|
|
||||||
@field_validator("rate")
|
@field_validator("rate")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _rate_positive(cls, v: float) -> float:
|
def _rate_positive(cls, v: Decimal) -> Decimal:
|
||||||
if v <= 0:
|
if v <= 0:
|
||||||
raise ValueError("rate must be positive")
|
raise ValueError("rate must be positive")
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_RATES: list[ExchangeRate] = [
|
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:
|
class CurrencyConverter:
|
||||||
def __init__(self, rates: list[ExchangeRate] | None = None):
|
def __init__(self, rates: list[ExchangeRate] | None = None):
|
||||||
self._rates: dict[tuple[str, str], float] = {}
|
self._rates: dict[tuple[str, str], Decimal] = {}
|
||||||
if rates:
|
if rates:
|
||||||
for r in rates:
|
for r in rates:
|
||||||
self.set_rate(r.from_currency, r.to_currency, r.rate)
|
self.set_rate(r.from_currency, r.to_currency, r.rate)
|
||||||
|
|
||||||
def set_rate(self, from_currency: str, to_currency: str, rate: float) -> None:
|
def set_rate(
|
||||||
if rate <= 0:
|
self,
|
||||||
raise CurrencyError(f"Rate must be positive: {rate}")
|
from_currency: str,
|
||||||
self._rates[(from_currency, to_currency)] = rate
|
to_currency: str,
|
||||||
inverse = 1.0 / rate
|
rate: Decimal | float | int | str,
|
||||||
self._rates[(to_currency, from_currency)] = inverse
|
) -> 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:
|
if from_currency == to_currency:
|
||||||
return 1.0
|
return Decimal("1")
|
||||||
try:
|
try:
|
||||||
return self._rates[(from_currency, to_currency)]
|
return self._rates[(from_currency, to_currency)]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
raise CurrencyError(f"No exchange rate: {from_currency} → {to_currency}")
|
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:
|
if from_currency == to_currency:
|
||||||
return amount
|
return a
|
||||||
rate = self.get_rate(from_currency, to_currency)
|
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:
|
def get_symbol(self, currency: str) -> str:
|
||||||
return CURRENCY_SYMBOLS.get(currency, currency)
|
return CURRENCY_SYMBOLS.get(currency, currency)
|
||||||
|
|||||||
+5
-4
@@ -1,3 +1,4 @@
|
|||||||
|
from decimal import Decimal
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
@@ -6,13 +7,13 @@ from pydantic import BaseModel, Field, field_validator
|
|||||||
class Liability(BaseModel):
|
class Liability(BaseModel):
|
||||||
id: UUID = Field(default_factory=uuid4)
|
id: UUID = Field(default_factory=uuid4)
|
||||||
name: str = ""
|
name: str = ""
|
||||||
balance: float = 0.0
|
balance: Decimal = Field(default=Decimal("0"))
|
||||||
interest: float = 0.0
|
interest: Decimal = Field(default=Decimal("0"))
|
||||||
payment: float = 0.0
|
payment: Decimal = Field(default=Decimal("0"))
|
||||||
|
|
||||||
@field_validator("interest", "payment")
|
@field_validator("interest", "payment")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _non_negative(cls, v: float) -> float:
|
def _non_negative(cls, v: Decimal) -> Decimal:
|
||||||
if v < 0:
|
if v < 0:
|
||||||
raise ValueError("must be non-negative")
|
raise ValueError("must be non-negative")
|
||||||
return v
|
return v
|
||||||
|
|||||||
+3
-2
@@ -1,3 +1,4 @@
|
|||||||
|
from decimal import Decimal
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
@@ -8,7 +9,7 @@ class RecurringCashflow(BaseModel):
|
|||||||
start_date: str = ""
|
start_date: str = ""
|
||||||
end_date: str = ""
|
end_date: str = ""
|
||||||
frequency: str = "monthly"
|
frequency: str = "monthly"
|
||||||
amount: float = 0.0
|
amount: Decimal = Field(default=Decimal("0"))
|
||||||
category: str = ""
|
category: str = ""
|
||||||
|
|
||||||
@field_validator("frequency")
|
@field_validator("frequency")
|
||||||
@@ -20,7 +21,7 @@ class RecurringCashflow(BaseModel):
|
|||||||
|
|
||||||
@field_validator("amount")
|
@field_validator("amount")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _amount_nonzero(cls, v: float) -> float:
|
def _amount_nonzero(cls, v: Decimal) -> Decimal:
|
||||||
if v == 0:
|
if v == 0:
|
||||||
raise ValueError("amount must be non-zero")
|
raise ValueError("amount must be non-zero")
|
||||||
return v
|
return v
|
||||||
|
|||||||
+5
-4
@@ -1,3 +1,4 @@
|
|||||||
|
from decimal import Decimal
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
@@ -6,14 +7,14 @@ from pydantic import BaseModel, Field, field_validator
|
|||||||
class ForecastScenario(BaseModel):
|
class ForecastScenario(BaseModel):
|
||||||
id: UUID = Field(default_factory=uuid4)
|
id: UUID = Field(default_factory=uuid4)
|
||||||
name: str = "baseline"
|
name: str = "baseline"
|
||||||
income_multiplier: float = 1.0
|
income_multiplier: Decimal = Field(default=Decimal("1"))
|
||||||
expense_multiplier: float = 1.0
|
expense_multiplier: Decimal = Field(default=Decimal("1"))
|
||||||
growth_multiplier: float = 1.0
|
growth_multiplier: Decimal = Field(default=Decimal("1"))
|
||||||
description: str = ""
|
description: str = ""
|
||||||
|
|
||||||
@field_validator("income_multiplier", "expense_multiplier", "growth_multiplier")
|
@field_validator("income_multiplier", "expense_multiplier", "growth_multiplier")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _non_negative(cls, v: float) -> float:
|
def _non_negative(cls, v: Decimal) -> Decimal:
|
||||||
if v < 0:
|
if v < 0:
|
||||||
raise ValueError("multiplier must be non-negative")
|
raise ValueError("multiplier must be non-negative")
|
||||||
return v
|
return v
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from decimal import Decimal
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
@@ -8,12 +9,12 @@ class Transaction(BaseModel):
|
|||||||
date: str = ""
|
date: str = ""
|
||||||
account: str = ""
|
account: str = ""
|
||||||
category: str = ""
|
category: str = ""
|
||||||
amount: float = 0.0
|
amount: Decimal = Field(default=Decimal("0"))
|
||||||
description: str = ""
|
description: str = ""
|
||||||
|
|
||||||
@field_validator("amount")
|
@field_validator("amount")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _amount_nonzero(cls, v: float) -> float:
|
def _amount_nonzero(cls, v: Decimal) -> Decimal:
|
||||||
if v == 0:
|
if v == 0:
|
||||||
raise ValueError("amount must be non-zero")
|
raise ValueError("amount must be non-zero")
|
||||||
return v
|
return v
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
from infrastructure.ai import prompts
|
from infrastructure.ai import prompts
|
||||||
from domain import CurrencyConverter, FinancialModel
|
from domain import CurrencyConverter, FinancialModel
|
||||||
@@ -9,6 +10,15 @@ class AssistantError(Exception):
|
|||||||
pass
|
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:
|
class AssistantService:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -26,8 +36,8 @@ class AssistantService:
|
|||||||
summary = forecast_service.summary(months)
|
summary = forecast_service.summary(months)
|
||||||
|
|
||||||
prompt = prompts.format_context(
|
prompt = prompts.format_context(
|
||||||
model_json=json.dumps(self.model.to_dict(), 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),
|
forecast_json=json.dumps(forecast_result, indent=2, ensure_ascii=False, cls=_DecimalEncoder),
|
||||||
months=months,
|
months=months,
|
||||||
base_currency=self.model.base_currency,
|
base_currency=self.model.base_currency,
|
||||||
display_currency=self.display_currency,
|
display_currency=self.display_currency,
|
||||||
@@ -45,8 +55,8 @@ class AssistantService:
|
|||||||
forecast_result = forecast_service.forecast_cashflow(months)
|
forecast_result = forecast_service.forecast_cashflow(months)
|
||||||
|
|
||||||
prompt = prompts.ADVICE_PROMPT.format(
|
prompt = prompts.ADVICE_PROMPT.format(
|
||||||
model_json=json.dumps(self.model.to_dict(), 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),
|
forecast_json=json.dumps(forecast_result, indent=2, ensure_ascii=False, cls=_DecimalEncoder),
|
||||||
question=question,
|
question=question,
|
||||||
base_currency=self.model.base_currency,
|
base_currency=self.model.base_currency,
|
||||||
display_currency=self.display_currency,
|
display_currency=self.display_currency,
|
||||||
|
|||||||
Reference in New Issue
Block a user