Аналитика текущего backend¶
Эта страница описывает не исходный Numbers-файл, а расчётную модель, которая сейчас реально работает в Spring Boot backend.
Главные точки входа:
- код расчёта:
PlanReadService; - HTTP boundary:
PlanReadController; - JSON mapping:
PlanApiMapper; - сценарии:
ScenarioService; - пенсионный required capital:
PensionRequiredCapitalCalculator.
Numbers-модель остаётся только историческим источником формул. Runtime source of truth — persisted PlanState из backend.
Карта аналитических endpoints¶
Базовый URL production:
| Endpoint | Что возвращает | Основной расчёт |
|---|---|---|
GET /plans/{planId}/dashboard |
KPI главного экрана, краткий прогноз, цели, pension estimate | dashboard() поверх годового cashflow |
GET /plans/{planId}/analytics/cashflow?years=N |
годовые строки доходов, расходов, целей, net savings и капитала | cashflow(planId, years) |
GET /plans/{planId}/analytics/cashflow/monthly |
12 помесячных строк для tracker/chart | monthlyCashflow() |
GET /plans/{planId}/analytics/projection?years=N |
упрощённый yearly projection | wrapper над cashflow() |
GET /plans/{planId}/analytics/balance/current |
снимок текущего года | текущие доходы/расходы/цели |
GET /plans/{planId}/analytics/health |
health score и причины | dashboard() + количество income sources |
GET /plans/{planId}/pension/projection |
preserve-capital и spend-down pension model | pensionProjection() |
POST /scenarios/compare |
base/optimistic/pessimistic/user scenario projections | ScenarioService.compare() |
/analytics/cashflow принимает years, но backend clamp'ит значение в диапазон 1..80. /analytics/projection принимает years в диапазоне 1..60.
Runtime inputs¶
Расчёт собирается из persisted state:
ModelAssumptions:startYear,horizonYears,birthYear,monthsPerYear,currency,initialCapital,investmentReturnPct,inflationSchedule;PensionSettings: возраст, пенсионный возраст, желаемые расходы, пенсионная доходность, пенсионная инфляция, стратегия, государственная пенсия;IncomeSource: сумма, частота, рост, даты активности,continueAfterRetirement;ExpenseItem: сумма, частота, рост, budget class, даты активности;Goal: стоимость, накопленный фактsavedAmount, месяц/год цели, рост, priority;MonthlyTrackerEntry: фактическая сумма накопления по месяцу.
Legacy contributions читаются для совместимости, но write-path отключён. operation_journal_entries существует для tracker page, но goal-операции сейчас отклоняются backend'ом. В текущем расчётном ядре фактический прогресс целей задаётся через Goal.savedAmount, а месячный план-факт накоплений — через monthly tracker.
Время и горизонт¶
Backend не начинает прогноз в прошлом:
Горизонт по умолчанию:
if modelAssumptions.horizonYears exists:
horizon = max(1, horizonYears)
else:
horizon = max(1, latestGoalYear - startYear + 1)
Пенсионный год:
yearsToRetirement = max(0, pension.retirementAge - pension.currentAge)
retirementYear = startYear + yearsToRetirement
retired = year >= retirementYear
Рост сумм¶
Доходы и расходы используют один паттерн growth:
Для строк с growthType=inflation backend берёт:
item.growthSchedule, если он задан;- иначе
modelAssumptions.inflationSchedule; - fallback —
pension.inflationPct.
Для строк без inflation-growth используется item.growthPct, но годовой growthSchedule всё равно может переопределить ставку конкретного года.
Активность строки считается по пересечению startDate/endDate с годом или месяцем прогноза.
Годовой cashflow¶
GET /analytics/cashflow возвращает строки CashFlowProjectionPoint.
Ключевые поля:
year
age
periodNo
monthlyIncome
yearlyIncome
totalIncome
monthlyExpenses
yearlyExpenses
totalExpenses
monthlyGoalExpenses
yearlyGoalExpenses
totalGoalExpenses
netSavings
investmentReturnPct
capitalStartOfYear
capitalEndOfYear
Для каждого года backend считает:
monthlyIncomeTotal = sum(active monthly incomes for each month)
yearlyIncome = sum(active yearly/one-time incomes)
totalIncome = monthlyIncomeTotal + yearlyIncome
monthlyExpensesTotal = sum(active monthly expenses for each month)
yearlyExpenses = sum(active yearly/one-time expenses)
totalExpenses = monthlyExpensesTotal + yearlyExpenses
Затем считает месячные накопления:
plannedMonthlySavings = monthlyIncomeForMonth - monthlyExpensesForMonth
monthlySavings = trackerAmountForMonth if exists else plannedMonthlySavings
Важно: MonthlyTrackerEntry.status сам по себе не участвует в формуле. На расчёт влияет сохранённый amount; для missed frontend обычно передаёт 0.
Цели в годовом cashflow сейчас попадают как плановый outflow в месяц/год цели:
targetCost = currentCost grown until targetYear
remaining = max(targetCost - savedAmount, 0)
plannedGoalExpenses[targetYear-targetMonth] += remaining
Годовая формула:
yearlyGoalExpenses = sum(planned goal outflows in this year)
netSavings = yearlyIncome - yearlyExpenses + sum(monthlySavings) - yearlyGoalExpenses
capitalEndOfYear =
capitalStartOfYear
+ netSavings
+ max(capitalStartOfYear, 0) * investmentReturnPctForYear / 100
До пенсионного года investmentReturnPctForYear = modelAssumptions.investmentReturnPct. Начиная с пенсионного года — pension.expectedReturnPct.
Помесячный cashflow¶
GET /analytics/cashflow/monthly возвращает 12 месяцев от startYear.
Отличия от годового cashflow:
incomeиexpensesсчитаются по месяцу;- yearly/one-time income и expenses добавляются в декабре;
- investment return добавляется в декабре и считается от
capitalStartOfYear; - monthly tracker заменяет только месячную часть savings;
- goal outflow применяется в конкретном target month.
Строка содержит:
month
year
monthNumber
age
income
expenses
goalExpenses
netSavings
investmentReturnPct
capitalStartOfMonth
capitalEndOfMonth
Retirement mode внутри cashflow¶
Начиная с retirementYear меняется источник monthly income/expenses.
Доходы после пенсии:
income = active incomes with continueAfterRetirement=true
if statePensionEnabled:
income += statePensionMonthly grown by pension.inflationPct
Расходы после пенсии:
expenses = active regular expenses
expenses += desiredMonthlyExpensesCurrentPrices grown by pension.inflationPct
Если desiredMonthlyExpensesCurrentPrices отсутствует, используется pension.monthlyExpenses.
Поэтому на графике после пенсионного года может оставаться доход: это либо доходная строка с continueAfterRetirement=true, либо государственная пенсия, проиндексированная инфляцией.
Dashboard¶
GET /plans/{planId}/dashboard — это сводка поверх текущего состояния и cashflow.
Основные вычисления:
currentYearGoalExpenses = first cashflow row totalGoalExpenses
netMonthlyBalance =
monthlyIncome - monthlyExpenses - currentYearGoalExpenses / 12
netYearlyBalance =
yearlyIncome - yearlyExpenses - currentYearGoalExpenses
savingsRatePct =
yearlyIncome == 0 ? 0 : netYearlyBalance / yearlyIncome * 100
monthlyGoalContribution =
max(yearlyIncome - yearlyExpenses, 0) / 12
availableForPension =
max(netMonthlyBalance, 0)
projectedPensionCapital =
initialCapital + availableForPension * 12 * yearsToRetirement
dashboard.projectedPensionCapital — грубая dashboard-оценка накопления, не required capital. Для вопроса «сколько капитала нужно на пенсии» используется /pension/projection.
dashboard.yearlyProjection — первые 4 строки cashflow с полями year, income, expenses, goalsCost, netSavings.
Health score¶
GET /analytics/health считается из dashboard:
Компоненты:
savings_rate: good от20%, warning от10%;emergency_fund: good от100%, warning от50%;diversification: good при 3+ источниках дохода.
Emergency fund ищется как первая цель, где имя содержит подушка. Target — 6 месяцев текущих monthly expenses.
Goal projections¶
GET /plans/current возвращает цели с дополнительными projected-полями:
projectedTargetCost
projectedSavedAmount
projectedProgressPct
projectedReachable
projectedCompletionYear
Расчёт идёт отдельно от cashflow через goalAllocationPlan().
Алгоритм:
pool = max(initialCapital, 0)
goals = sort by priority, targetYear, targetMonth, id
for each projected month:
monthlyFreeCashflow =
monthlyIncomeForMonth
- monthlyExpensesForMonth
+ yearlyFreeCashflowShare
monthlyFreeCashflow = trackerAmount if tracker exists else monthlyFreeCashflow
pool += monthlyFreeCashflow
for goal in goals:
allocate min(pool, remainingTargetCost)
Для reachability важен target month: сумма, накопленная после дедлайна цели, не помогает выполнить цель вовремя. projectedProgressPct считается как (savedAmount + projected allocation by deadline) / projectedTargetCost.
Pension projection¶
GET /pension/projection возвращает две модели: preserveCapital и spendDown.
Общие поля:
currentAge
retirementAge
retirementYear
capitalAtRetirement
nominalReturnPct
averageInflationPct
realReturnPct
capitalAtRetirement берётся из cashflow на год выхода на пенсию. Если пользователь уже на пенсии, используется initialCapital.
averageInflationPct — средняя ставка из modelAssumptions.inflationSchedule; если schedule пустой, берётся pension.inflationPct.
Preserve capital¶
Смысл: жить на доходность капитала, не тратя principal.
annualSpendableAtRetirement =
capitalAtRetirement * pension.expectedReturnPct / 100
+ statePensionMonthly * 12 if enabled
annualSpendableCurrentPrices =
discount(annualSpendableAtRetirement, averageInflationPct, yearsToRetirement)
monthlySpendableCurrentPrices =
annualSpendableCurrentPrices / 12
Отдельно считается required capital:
desiredAnnualAtRetirement =
desiredMonthlyExpensesCurrentPrices * 12
* (1 + pension.inflationPct / 100) ^ yearsToRetirement
statePensionAnnualAtRetirement =
statePensionEnabled
? statePensionMonthly * 12 * inflationFactor
: 0
annualNeed =
max(desiredAnnualAtRetirement - statePensionAnnualAtRetirement, 0)
realReturnPct =
pension.expectedReturnPct - pension.inflationPct
requiredCapitalAtRetirement =
annualNeed / (realReturnPct / 100)
Если annualNeed > 0, а realReturnPct <= 0, backend возвращает requiredCapitalAtRetirement = null и requiredCapitalStatus = non_positive_real_return.
Spend down¶
Смысл: постепенно расходовать капитал.
Displayed series:
desiredAnnualExpensesAtRetirement =
desiredMonthlyExpensesCurrentPrices * 12
grown by averageInflationPct until retirement
for each retirement year in projection horizon:
investmentReturn = max(beginningCapital, 0) * expectedReturnPct / 100
plannedExpense = retirement expenses + active one-time/yearly expenses - retirement income
endingCapital = beginningCapital + investmentReturn - plannedExpense
retirementYears для series берётся из общего горизонта модели:
spendDown.requiredCapitalAtRetirement считается отдельно на фиксированные 30 лет обратным проходом:
required = 0
for year from 29 down to 0:
withdrawal = annualNeed * (1 + pension.inflationPct / 100) ^ year
required = (required + withdrawal) / (1 + pension.expectedReturnPct / 100)
Scenarios¶
POST /scenarios/compare не мутирует план. Он строит adjusted copy:
- income amounts умножаются на
incomeAdjPct; - expense amounts и пенсионные расходы умножаются на
expenseAdjPct; - goal currentCost умножается на
goalsCostAdjPct; - investment return и pension expected return сдвигаются на
returnAdjPct; - inflation schedule и pension inflation сдвигаются на
inflationAdjPct; - retirement age сдвигается на
retirementAgeShift.
Затем ScenarioService вызывает:
Важное ограничение текущей реализации: scenario compare использует static cashflow без monthly tracker facts. Поэтому tracker влияет на base /analytics/cashflow, но не влияет на optimistic/pessimistic/user scenario lines.
Что сейчас точно не делает backend¶
- Не считает analytics от Apple Numbers-файла в runtime.
- Не пишет и не учитывает legacy contribution ledger как текущий write-path: create/update disabled.
- Не принимает
type=goalв operation journal: backend возвращает ошибку и просит использовать savings tracker иGoal.savedAmount. - Не учитывает monthly tracker в
/scenarios/compare. - Не считает
dashboard.projectedPensionCapitalкак required pension capital.
Историческая Numbers-модель¶
Apple Numbers-файл Модель_P_3---3c875af3-ffe2-4e95-96e6-0b82f82a7a40.numbers был исходным reference для первых формул доходов, расходов, целей, сбережений и пенсии.
Текущий backend сохранил несколько идей из файла:
- доходы/расходы имеют период активности и growth;
- cashflow разделяет income, expenses, goal outflow, net savings и capital;
- pension projection имеет preserve-capital и spend-down варианты.
Но фактическая документация для продукта должна смотреть на текущие endpoints и PlanReadService, потому что runtime-модель уже шире и местами отличается от Numbers.