Files
nifodea/application/scenarios.py
T
oqyude 363d440d53 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.
2026-10-08 16:44:59 +03:00

92 lines
3.0 KiB
Python

from copy import deepcopy
from decimal import Decimal
from domain import FinancialModel, ForecastScenario
from application.forecast import ForecastService
class ScenarioError(Exception):
pass
DEFAULT_SCENARIOS = {
"baseline": ForecastScenario(
name="baseline",
income_multiplier=Decimal("1.0"),
expense_multiplier=Decimal("1.0"),
growth_multiplier=Decimal("1.0"),
description="Базовый сценарий без изменений",
),
"optimistic": ForecastScenario(
name="optimistic",
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=Decimal("0.85"),
expense_multiplier=Decimal("1.1"),
growth_multiplier=Decimal("0.8"),
description="Пессимистичный: доход -15%, расход +10%, рост активов -20%",
),
}
class ScenarioService:
def __init__(self, model: FinancialModel):
self.model = deepcopy(model)
def apply(self, scenario: ForecastScenario, months: int = 12) -> dict:
model = deepcopy(self.model)
for t in model.transactions:
if t.amount > 0:
t.amount = t.amount * scenario.income_multiplier
else:
t.amount = t.amount * scenario.expense_multiplier
for r in model.recurring:
if r.category == "income":
r.amount = r.amount * scenario.income_multiplier
else:
r.amount = r.amount * scenario.expense_multiplier
for a in model.assets:
a.growth_rate = a.growth_rate * scenario.growth_multiplier
service = ForecastService(model)
result = service.summary(months)
result["scenario"] = scenario.name
result["scenario_description"] = scenario.description
return result
def compare(self, months: int = 12) -> dict:
results = {}
for name, scenario in DEFAULT_SCENARIOS.items():
results[name] = self.apply(scenario, months)
return results
def what_if(
self,
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=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}"
),
)
return self.apply(scenario, months)