Building Cash Flow Forecasting Models
From the clean dataset to a 13-week forecast with Python and Prophet, backtested, with scenarios.
- 01The forecasting pipeline and which model to use: ARIMA, Prophet, XGBoost or LLM-assisted
- 02Train/test split, backtesting, and the right accuracy metric for the right number
- 03Base, optimistic and pessimistic scenarios with breach detection
- A tested 13-week forecast script
- A scenario and breach calculator
- The 5 forecasting mistakes to avoid
The forecasting pipeline
The same six steps every time you build a model. Learn it once.
Which model? A decision framework
Short term (1–4 weeks). Stable patterns, no external factors. Needs stationary data and manual parameter tuning.
Medium term (4–13 weeks). Trend changes, seasonality, holidays, known events as regressors. Weekly or daily data.
Complex, non-linear patterns with many input variables. Needs strong feature engineering and 2+ years of data.
Describes patterns, drafts scenarios, explains a forecast in plain language. Human validation is mandatory.
How much history? One year is enough for a trend and for known events. To learn yearly patterns (a Q4 spike, a January dip), a model needs to see them at least twice, so 2+ years. Prophet only switches on yearly seasonality by itself when you give it two years of data.
Why we use Prophet, and what it will not do for you
Open-source, built at Meta for business time series. Three lines to run:
m = Prophet() m.fit(df) # df has two columns: ds (date) and y (value) forecast = m.predict(future)
- Tolerates missing weeks: leave y empty and it fits around the gap.
- Detects trend changepoints (a new contract, a lost customer).
- Takes known events as regressors: payroll weeks, tax weeks.
- Interpretable: trend + seasonality + holidays + regressors. You can explain it to your CFO.
- On our 52 weekly rows, the 3-line default model switched off all seasonality. It fitted a trend line and nothing else.
- It knows no holidays unless you add them:
m.add_country_holidays(country_name='NL'). - Business calendars, payroll and tax cycles come from you, as regressors. That is why Module 8 built those features.
Train/test split: never test on data the model has seen
Backtesting: train on weeks 1–42, predict weeks 43–52, compare with what actually happened. Only then forecast forward.
Testing on training data is cheating. The accuracy looks amazing on paper and collapses in production. Always hold out the most recent weeks, never a random sample: in real life, you forecast the future from the past.
How to measure forecast accuracy
Average of |actual − forecast| / actual. Under 10% is good, under 5% excellent. Only for series that stay well away from zero.
"Off by EUR X on average." The easiest to explain.
Penalizes large misses. Use it alongside MAE.
Positive = over-forecasting. Should be close to zero. A consistent bias is a systematic error.
The MAPE trap, tested. Weekly net flow swings between +EUR 300K and −EUR 550K. Divide by an actual near zero and MAPE explodes. The same default model scored a MAPE of 118% on net flow and 4% on receipts.
Use MAPE on receipts and payments. For net flow, use MAE and WAPE (total absolute error ÷ total absolute actuals).
Scenarios: base, optimistic, pessimistic
Recent trends continue. The model's forecast with its confidence interval. Your most likely outcome.
Receipts +10%, payment terms extended, a new contract starts.
Receipts −15%, suppliers paid earlier, the largest customer delays by 30 days. The stress test.
Shock the receipts, not the net flow. A common shortcut is "pessimistic = net forecast × 0.80". On a week with a net outflow of −300K, that gives −240K: the pessimistic scenario comes out better than the base.
Apply the shock where it happens: pessimistic = base − 15% × forecast receipts. Timing shocks (suppliers paid earlier, a customer paying later) move cash between weeks, and can pull payments from outside the 13 weeks into the window.
If the base forecast says you are fine but the pessimistic one breaches in week 8, you have 8 weeks to arrange funding. That is the whole point.
Build the 13-week forecast
Start from the clean dataset of Module 8 (download it below if you skipped it). Route A: upload it with this prompt. Route B: run the script in Jupyter.
↓ treasury_cash_flows_clean.csv
I have uploaded treasury_cash_flows_clean.csv: 52 weeks of weekly cash flows (Mondays, EUR), cleaned in Module 8. Build a 13-week cash flow forecast with Prophet in Python. If Prophet is not available in your environment, tell me instead of switching to another method. 1. Prepare: rename date to ds and the target column to y. Set y to empty (NaN) for rows where non_recurring is True. 2. Model: two Prophet models with weekly, daily and yearly seasonality turned off (we only have one year of weekly data), changepoint_prior_scale=0.05. Model A: net_flow, with is_payroll_week and is_tax_week as regressors. Model B: receipts, no regressors. 3. Backtest: train on the first 42 weeks and predict the last 10 using the real test dates. Do not use make_future_dataframe with freq='W': it generates Sundays and our dates are Mondays. 4. Report MAE, WAPE, bias and RMSE for both models. Report MAPE for receipts only: net flow crosses zero, so MAPE is meaningless there. 5. Refit both models on all 52 weeks and forecast the next 13 Mondays. Payroll continues every 4 weeks after the last payroll week. Tax falls in the week that contains a quarter end. 6. Scenarios: base = net forecast; optimistic = base + 10% of forecast receipts; pessimistic = base - 15% of forecast receipts. Do not multiply the net flow itself. 7. Opening balance EUR 2,450,000, minimum EUR 2,000,000. Show the cumulative balance per scenario, a status per week (OK or BREACH), and save forecast_13_weeks.csv.
Python script · tested on Prophet 1.1.7 + pandas 2.3 and Prophet 1.4 + pandas 3.0
import pandas as pd
import numpy as np
from prophet import Prophet
df = pd.read_csv('treasury_cash_flows_clean.csv', parse_dates=['date'])
REGS = ['is_payroll_week', 'is_tax_week'] # known calendar events from Module 8
df[REGS] = df[REGS].astype(int)
def fit(data, target, regs):
d = data[['date', target] + regs].rename(columns={'date': 'ds', target: 'y'})
d.loc[data['non_recurring'], 'y'] = np.nan # never train on one-offs
m = Prophet(weekly_seasonality=False, yearly_seasonality=False,
daily_seasonality=False, changepoint_prior_scale=0.05)
for r in regs:
m.add_regressor(r)
return m.fit(d)
# 1. Backtest: train on weeks 1-42, test on weeks 43-52
train, test = df[:42], df[42:]
for target, regs in [('net_flow', REGS), ('receipts', [])]:
m = fit(train, target, regs)
pred = m.predict(test[['date'] + regs].rename(columns={'date': 'ds'}))['yhat'].values
actual = test[target].values
err = pred - actual
line = (f"{target}: MAE EUR {np.abs(err).mean():,.0f} | "
f"WAPE {np.abs(err).sum() / np.abs(actual).sum() * 100:.1f}% | "
f"Bias EUR {err.mean():,.0f} | RMSE EUR {np.sqrt((err ** 2).mean()):,.0f}")
if (actual > 0).all(): # MAPE only when actuals never cross zero
line += f" | MAPE {np.mean(np.abs(err) / actual) * 100:.1f}%"
print(line)
# 2. Next 13 weeks, on the same weekday as the data
future = pd.DataFrame({'ds': df['date'].max() + pd.to_timedelta(np.arange(1, 14) * 7, unit='D')})
last_payroll = df.loc[df['is_payroll_week'] == 1, 'date'].max()
future['is_payroll_week'] = (((future['ds'] - last_payroll).dt.days // 7) % 4 == 0).astype(int)
month_end = (future['ds'] + pd.Timedelta(days=6)).dt.month != future['ds'].dt.month
future['is_tax_week'] = (month_end & future['ds'].dt.month.isin([3, 6, 9, 12])).astype(int)
net = fit(df, 'net_flow', REGS).predict(future)
rec = fit(df, 'receipts', []).predict(future)
# 3. Scenarios: shock the receipts, not the net flow
out = future[['ds']].copy()
out['base'] = net['yhat'].values
out['lower'] = net['yhat_lower'].values
out['upper'] = net['yhat_upper'].values
out['receipts_fc'] = rec['yhat'].values
out['optimistic'] = out['base'] + 0.10 * out['receipts_fc']
out['pessimistic'] = out['base'] - 0.15 * out['receipts_fc']
OPENING, MINIMUM = 2_450_000, 2_000_000
for s in ['base', 'optimistic', 'pessimistic']:
out[f'{s}_balance'] = OPENING + out[s].cumsum()
out[f'{s}_status'] = np.where(out[f'{s}_balance'] < MINIMUM, 'BREACH', 'OK')
out = out.set_index('ds').round(0)
print(out[['base', 'pessimistic', 'pessimistic_balance', 'pessimistic_status']])
out.to_csv('forecast_13_weeks.csv')
Read the backtest before you trust the forecast
| Model (weeks 43–52) | MAE | WAPE | MAPE |
|---|---|---|---|
| Net flow, 3-line default model | EUR 162K | 82% | 118% ✗ |
| Net flow + payroll and tax regressors | EUR 41K | 21% | n/a |
| Receipts | EUR 34K | 4% | 3.9% |
- Features pay. Telling the model when payroll and tax happen cut the average miss by three quarters.
- Receipts are production-grade at a 3.9% MAPE. Net flow is judged on MAE: off by about EUR 41K a week.
- Bias is about −EUR 27K a week: the model under-forecasts the Q4 test weeks. Worth a note in the forecast pack.
A good backtest is not a guarantee. The model forecasts January 2026 receipts at about EUR 850K a week. Last January they were EUR 607K. With one year of data it has seen one January, and it cannot tell a seasonal dip from noise. Put the dip into a scenario yourself: the calculator on the next screen does exactly that.
Scenario and breach calculator
The 13-week forecast from the script, live. Change the shocks and see when the balance breaks the minimum.
| Week | Base net | Scenario net | Balance | Status |
|---|
Try this: keep the 15% shock and set the extra dip to 13%, so January lands about 28% below the forecast, like last year. The buffer above the minimum shrinks to under EUR 100K. Then try a 30% shock.
5 forecasting mistakes treasury teams make
Testing on training data. Accuracy looks amazing on paper, then collapses in production. You deployed a lie.
Not removing one-time items. The model learns the EUR 5M acquisition as normal and expects it again. Flag it, set y to empty for that week.
Ignoring the business calendar. Cash flows forecast on Christmas Day, payment runs on bank holidays. Add holidays and payroll/tax cycles explicitly.
One scenario only. The CFO asks "what if receipts drop 15%?" and you have no answer prepared.
No retraining schedule. A model trained two years ago is still running. The business has changed; the forecast has not. Module 10.
What you take away
The pipeline: DATA > FEATURES > SPLIT > TRAIN > VALIDATE > FORECAST.
4 model options. Prophet is the sweet spot, if you feed it your calendar.
Train/test split is non-negotiable. Hold out the most recent weeks.
MAPE for receipts and payments; MAE and WAPE for net flow. Watch the bias.
3 scenarios minimum, with the shock applied to receipts, not to the net.
A tested Prophet forecast, from clean data to breach flags.
One year of history cannot teach seasonality. Put the dip in a scenario.
A backtested forecast with three scenarios.
You know how accurate it is, where it is biased, and which week to worry about.
Next: Module 10, AI Operations & Monitoring. Keep this model accurate, tested and auditable once it is in production.
Built by a treasurer, for treasurers. · treasuryease.com