Cashflow forecasting sounds like a solved machine learning problem until you try to build it for a retail banking population. Then it becomes a question of: what does "accurate" mean when spending behavior is partly deterministic (recurring bills) and partly random (discretionary), when the same dollar amount can represent three completely different things depending on the merchant, and when the user's life situation can change the pattern entirely from one month to the next?
We built the forecasting component of Openaggr's cashflow API over about six months, trying several model approaches before landing on the architecture we have now. This post is an honest account of those architectural decisions, including the ones we reversed.
The Core Problem: Two Different Forecasting Problems in One
The first thing we got wrong was treating cashflow forecasting as a single problem. It is actually two problems with very different characteristics that need to be modeled separately and combined at the output layer.
Problem 1: Committed cashflow forecasting. Rent, utilities, subscriptions, insurance, loan payments. These are recurring transactions detected by our recurring detection pipeline. For each recurring item, we have a pattern type, an expected date window, and either a fixed amount or an amount range. The "forecast" for committed cashflow is essentially: project the recurring items with their expected next-occurrence dates into a 30-day window and sum them. This is not a statistical model. It is a deterministic schedule derived from detected patterns.
Problem 2: Variable cashflow forecasting. Dining, groceries, entertainment, shopping, health spending. These are the discretionary and irregular categories where past behavior is the best predictor of future behavior, but the signal is noisy. A user who spent $280 on dining last month might spend $150 or $450 next month depending on circumstances the data cannot observe.
Our first architecture mistake was building a single time-series model (we tried ARIMA, then a simple LSTM) over total debit outflow. The model saw the aggregate line, not the composition. It predicted total spending reasonably well for typical months but failed badly on months where recurring item timing shifted (say, a quarterly insurance bill landing in that window), or where a user's income changed (a freelancer with a lean month reduced discretionary spending dramatically). The model had no way to separate the deterministic component from the variable component, so it tried to learn both from the same signal and failed at both.
The Separation Architecture
The current architecture processes the two problems separately and combines them:
Enriched transaction history
|
+--- Recurring detection layer
| |
| Committed schedule
| (deterministic, pattern-based)
|
+--- Variable spending history
|
Per-category rolling average
(statistical, category-level)
|
Seasonal adjustment
|
Variable forecast
|
|-- Combine: Committed + Variable = 30-day forecast
|
+--- Confidence interval construction
The committed component is straightforward: take all recurring items with pattern types 1-9 from the recurring detection output, project their next-occurrence dates into the forecast window, use the expected amount (fixed) or expected amount mean (variable-amount items), and sum by day and by category. The output is a deterministic schedule with no uncertainty from the forecasting model itself, though the recurring detection itself has a confidence score that propagates.
The variable component is where the modeling work happens.
Variable Spending: Why We Chose Rolling Averages Over ML Models
We built and evaluated three approaches for the variable spending forecast before landing on category-level rolling averages:
Approach 1: Per-user ARIMA models on total variable spending. Performance was acceptable at population level but unreliable for individual users, especially those with short history (under 6 months) or irregular income. ARIMA needs more data than most users have at the point when the forecast feature becomes useful. Training time and serving complexity were higher than we wanted for what was essentially an auxiliary feature.
Approach 2: Gradient boosted trees with user-level features. We extracted features: trailing 3-month average by category, day-of-week patterns, income level proxies, account age. The model was more accurate than ARIMA at 12 weeks of history. But it required a full per-user feature vector, which meant a serving infrastructure dependency we were not ready to scale. Explainability was also poor: when the forecast was wrong, we could not trace back why.
Approach 3: Per-category trailing averages with seasonal adjustment. For each category, compute a trailing 90-day average of non-recurring spending, normalized to a 30-day window. Apply a seasonal index based on the calendar month (January dining spending is typically lower than December, for example). Cap at the 90th percentile of recent monthly totals to reduce the impact of outlier months.
Approach 3 lost to approaches 1 and 2 on pure prediction accuracy over a held-out evaluation set. But it had three properties that made it the right choice for a user-facing forecast feature: it requires only 60 days of history to produce a reasonable estimate (low minimum data requirement), it is fully explainable (the forecast comes from "your average spending in this category over the past 90 days"), and it degrades gracefully when input data is sparse.
We are not saying rolling averages are the best forecasting model. We are saying that for a feature in a consumer finance app, interpretability and graceful degradation matter more than raw prediction accuracy. A forecast that is 15% off but explainable ("this is based on your typical monthly dining spending") is better than a forecast that is 8% off but opaque. Users who understand where the number comes from trust it more and engage with it more.
Confidence Intervals and Their Display
Every cashflow forecast from the Openaggr API comes with a confidence interval. The interval construction combines two uncertainty sources:
For committed items: uncertainty from recurring detection confidence. A recurring item with detection_confidence: 0.88 contributes less certainty to the committed schedule than one with detection_confidence: 0.97. We propagate detection confidence into the forecast range as a small amount contribution uncertainty.
For variable items: uncertainty from historical spending variance. Categories with low variance (groceries for most users) get tight confidence intervals. Categories with high variance (entertainment, travel for users who occasionally take trips) get wide intervals.
The combined output looks like this in the API response:
{
"forecast": {
"period_start": "2026-01-17",
"period_end": "2026-02-16",
"total_committed": {
"amount": 2180.00,
"items_count": 8
},
"total_variable": {
"amount_estimate": 640.00,
"confidence_interval": {
"low": 480.00,
"high": 820.00,
"confidence_level": 0.80
}
},
"total_forecast": {
"low": 2660.00,
"high": 3000.00,
"point_estimate": 2820.00
},
"by_category": [
{
"category": "housing.rent",
"forecast_type": "committed",
"amount": 1450.00,
"expected_date": "2026-02-01",
"recurrence_confidence": 0.97
},
{
"category": "food_and_drink.restaurants",
"forecast_type": "variable",
"amount_estimate": 180.00,
"confidence_interval": { "low": 120.00, "high": 260.00 }
}
]
}
}
The Minimum History Cliff
Forecasting requires history. Below certain thresholds, the forecast degrades to the point where it should not be presented as a forecast at all. Our minimum history requirements:
- Below 30 days of enriched history: return
forecast_available: false. No forecast. - 30-60 days: return committed-only forecast (scheduled recurring items only). No variable component. Label clearly as "committed spending only."
- 60-90 days: return full forecast with wide confidence intervals. Variable component based on limited trailing average.
- 90+ days: full forecast with standard confidence intervals. Seasonal adjustment becomes available at 13 months of history.
We made the decision to return forecast_available: false for users under 30 days rather than returning a guess. A new user who sees a wildly wrong forecast in their first month develops a negative prior about the feature that is hard to recover from. Better to show nothing and explain why ("Connect more accounts or wait for history to build") than to show a number that has no grounding.
What We Did Not Build
We considered and decided against several features that appear in more sophisticated forecasting systems:
Income forecasting: predicting when the next paycheck will arrive and how large it will be. We detect income-side recurring events and include them in the committed schedule, but we do not generate point estimates for variable income sources (freelance, gig work, irregular employment). Income is the harder side of the cashflow equation and wrong income forecasts create more user anxiety than wrong spending forecasts.
Event-driven adjustments: modifying the forecast based on upcoming events detected in the user's data (a car registration that appears every April, a holiday travel spike in December). We do capture seasonal patterns at the category level with the seasonal index, but we do not do calendar-driven event injection. This is on the roadmap.
The architecture described here is what we have as of this writing. We will revisit the variable component modeling as our dataset grows. A larger labeled dataset with more user-months of history opens up approaches that were not viable when we were calibrating on early data. The rolling average will likely give way to something more sophisticated for the variable component, but the separation architecture, committed versus variable, will stay. That separation is the right abstraction for the problem, regardless of what sits inside each branch.