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)
|
||||
|
||||
Reference in New Issue
Block a user