Skip to Content

Balance Prediction Scenario Lab

Desktop-first what-if tooling on top of the existing 3-scenario balance prediction engine: saved scenarios, inline adjustments, baseline comparison, budget impact, and affordability checks.

Overview

The scenario lab extends BalancePredictionFilters / BalancePredictionResponse without replacing the core BASE / OPTIMISTIC / PESSIMISTIC model. Users can layer saved scenarios (persisted in PostgreSQL), inline adjustments (session-only), and legacy periodic spends into a single prediction run. When scenarios or inline adjustments are active, the engine can return a baseline curve (compareToBaseline) and per-budget impact rows for the point-in-time view.

Platform coverage (current):

CapabilityDesktop (composeApp)Android (finance_presentation)Web
Core prediction chartYesYesYes (BalancePredictionTab.tsx)
Saved scenarios CRUD + toggleYesNoNo
Scenario adjustment dialogYesNoNo
Affordability (“Can I afford…?”)YesNoNo
Budget impact panelYesNoNo
Baseline overlay on chartYesNoNo
Category dropdowns in dialogsYesPartial (volatility editor still text on Android)N/A

Wire dates use dd/MM/yyyy (calendar dates) and dd/MM/yyyy HH:mm for date-times — not ISO yyyy-MM-dd.

Architecture

Adjustment resolution order: enabled adjustments from selected scenarioIds + inlineAdjustments + periodicSpends (each periodic spend is converted to a NEW_TRANSACTION adjustment). Disabled adjustments are skipped.

API Endpoints

MethodPathDescription
POST/finance/transactions/balance-predictionRun prediction (BalancePredictionFilters)
POST/finance/transactions/balance-prediction/affordabilityOne-off purchase what-if (AffordabilityRequest)
GET/finance/balance-scenariosList saved scenarios for user
POST/finance/balance-scenariosCreate scenario
PUT/finance/balance-scenarios/{id}Update scenario
DELETE/finance/balance-scenarios/{id}Soft-delete scenario
GET/finance/balance-prediction/settingsLoad per-user engine defaults
PUT/finance/balance-prediction/settingsPersist per-user engine defaults

Prediction filters (scenario lab fields)

FieldTypePurpose
scenarioIdsList<String>UUIDs of saved scenarios to merge
inlineAdjustmentsList<ScenarioAdjustment>Session-only adjustments
compareToBaselineBooleanWhen true, response includes baselinePoints (run without scenario/inline layer)
periodicSpendsList<PeriodicSpend>Legacy recurring items; merged as NEW_TRANSACTION

Existing fields (fromDate, toDate, accountIds, includeScheduledTransactions, includePastMonthBehavior, noisePercentage, categoryVolatilityOverrides, amplitudeFactor, safetyFloor, goalId, pagination) behave as before. See Prediction engine and balance-prediction-algorithm-analysis.md.

Scenario adjustment types

Defined in shared/.../models/finance/ScenarioAdjustment.kt:

TypePurpose
NEW_TRANSACTIONAdd income/expense on a frequency (ONE_TIME, WEEKLY, …); optional category (enum name, e.g. FOOD)
MODIFY_CATEGORYScale or shift spending for a category between appliesFrom / appliesTo (multiplier and/or delta)
MODIFY_SCHEDULEDSKIP or OVERRIDE_AMOUNT on a scheduled transaction (scheduledTransactionId, optional occurrenceDate)

Each adjustment has enabled, name, and appliesToScenarios (BASE, OPTIMISTIC, PESSIMISTIC).

Response extras

FieldWhen present
budgetImpactPer active budget + unbudgeted bucket: baseline vs scenario spend, verdict (OVER, TIGHT, UNDER, …)
baselinePointscompareToBaseline == true
compareToBaselineEcho of request flag

Affordability

POST .../affordability builds a one-time NEW_TRANSACTION expense (inlineAdjustments), runs prediction with compareToBaseline, and returns AffordabilityResult:

VerdictMeaning (precedence order)
BREAKS_BUDGETAny budget impact verdict == OVER
GOES_NEGATIVEAny projected balance < 0
RISKYAny point below safetyFloor
TIGHTAny budget impact verdict == TIGHT (≥ 85% of cap)
OKOtherwise

Includes endingBalanceBaseline, endingBalanceWithPurchase, worstDipDate / worstDipBalance, budgetImpact, dangerEvents, explanation, and trimmed points / baselinePoints (~30) for the affordability mini-chart.

Desktop UI

Entry: Finance → Balance Prediction (BalancePredictionContent).

UI areaComponentBehavior
HeaderBalancePredictionHeaderDate range pill → DateRangePickerDialog; granularity chip; refresh; Settings cog → PredictionSettingsDialog; close
Action barBalancePredictionActionBarToggle Scenarios sidebar (badge = enabled count); + Adjustment; Can I afford…?; link to periodic spends count when non-zero
Scenarios sidebarScenariosPanelCards with color, description, pinned, adjustment count, kebab (edit/duplicate/delete); Session adjustments + save-as-scenario; empty state CTA
ChartChartContentArea / BalanceChartSummary stats + chart; dashed baseline when baselinePoints present
InsightsInsightsSectionCollapsible: safe-to-spend, danger, goal, budget impact (global), category forecasts
Point detailsPointDetailsPanelSlide-in on point select
SettingsPredictionSettingsDialogData sources, risk thresholds, uncertainty, starting balance override, category volatility, periodic spends (save-on-change, debounced PUT)
Scenario editorScenarioEditorDialogCreate/edit scenario metadata + adjustment list
Add adjustmentScenarioAdjustmentDialogNew / Modify category / Modify scheduled; target picker (session, scenario, new); date + frequency/action dropdowns
AffordabilityAffordabilityDialogDate picker; mini chart; verdict chip; save as scenario

BalancePredictionViewModel debounces prediction reloads (~300 ms) and persists engine settings (~500 ms debounce) on knob changes. Session-only state (date range, selected scenarios, inline adjustments, baseAmount) is not persisted.

Persisted user settings (BalancePredictionUserSettings)

Stored in usersettings.balance_prediction_settings (migration V26__balance_prediction_settings.sql) as JSON:

FieldNotes
granularity, pageSizeChart paging
includeScheduledTransactions, includePastMonthBehaviorData sources
noisePercentage, amplitudeFactorUncertainty
safetyFloor, overdraftThresholdRisk
categoryVolatilityOverridesPer-category multipliers
onboardingDismissedFirst-run tip

Not persisted: baseAmount, date range, scenarioIds, inlineAdjustments, periodicSpends (periodic spends remain per-session in filters until saved via settings dialog).

Default filters use compareToBaseline = true when scenarios or inline adjustments are active.

Category inputs

All category pickers on this screen use CategoryDropdown (shared/.../finance/ui/components/CategoryDropdown.kt) backed by the Category enum. Volatility overrides and affordability use the same control; category keys sent to the API are enum names (FOOD, DONATIONS, …).

Dialogs use PopupMaterialTheme so dropdown popups inherit dark/light theme on desktop (Compose Dialog windows do not inherit MaterialTheme by default).

Persistence

Scenarios

  • Migration: V25__balance_scenarios.sql
  • Entity: balance_scenariosadjustments stored as JSON text
  • Repository: BalanceScenarioRepository — CRUD + soft delete

Engine settings (per user)

  • Migration: V26__balance_prediction_settings.sql — column on usersettings
  • Repository: BalancePredictionSettingsRepository
  • Routes: GET / PUT /finance/balance-prediction/settings

Key files

LayerPath
Modelsshared/.../models/finance/BalancePrediction.kt, ScenarioAdjustment.kt
HTTP clientshared/.../services/finance/FinanceService.kt
Mergerserver/.../service/ScenarioAdjustmentMerger.kt
Category applyserver/.../service/CategoryAdjustmentApplier.kt
Scheduled applyserver/.../service/ScheduledAdjustmentApplier.kt
Budget impactserver/.../service/BudgetImpactService.kt
Affordabilityserver/.../service/BalancePredictionAffordabilityService.kt
Engineserver/.../service/BalancePredictionService.kt
Desktop UIcomposeApp/.../balanceprediction/*, BalancePredictionContent.kt
ViewModelcomposeApp/.../viewmodels/BalancePredictionViewModel.kt