MODULE 09 OF 10 · ~35 MIN · ADVANCED · LIGHT CODE

Building Cash Flow Forecasting Models

From the clean dataset to a 13-week forecast with Python and Prophet, backtested, with scenarios.

What you'll cover
  1. 01The forecasting pipeline and which model to use: ARIMA, Prophet, XGBoost or LLM-assisted
  2. 02Train/test split, backtesting, and the right accuracy metric for the right number
  3. 03Base, optimistic and pessimistic scenarios with breach detection
You walk away with
  • 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.

DATA
The clean dataset from Module 8
FEATURES
Time, lag and business flags
SPLIT
80% train, 20% test
TRAIN
Fit on historical data
VALIDATE
Measure error on unseen data
FORECAST
The next 13 weeks

Which model? A decision framework

ARIMAMedium

Short term (1–4 weeks). Stable patterns, no external factors. Needs stationary data and manual parameter tuning.

ProphetEasy

Medium term (4–13 weeks). Trend changes, seasonality, holidays, known events as regressors. Weekly or daily data.

XGBoost / Random ForestHard

Complex, non-linear patterns with many input variables. Needs strong feature engineering and 2+ years of data.

LLM-assistedEasy

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:

Python · the minimal model
m = Prophet()
m.fit(df)                       # df has two columns: ds (date) and y (value)
forecast = m.predict(future)
What it does well
  • 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.
What we tested
  • 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

TRAIN · weeks 1–42
TEST · 43–52

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

MAPE · Mean absolute percentage error

Average of |actual − forecast| / actual. Under 10% is good, under 5% excellent. Only for series that stay well away from zero.

MAE · Mean absolute error

"Off by EUR X on average." The easiest to explain.

RMSE · Root mean square error

Penalizes large misses. Use it alongside MAE.

Bias · Average of forecast − actual

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

Base

Recent trends continue. The model's forecast with its confidence interval. Your most likely outcome.

Optimistic

Receipts +10%, payment terms extended, a new contract starts.

Pessimistic

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.

LIVE BUILD

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

Prompt · Prophet forecast with backtest and scenarios
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
m9_forecast.py
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')
LIVE BUILD · RESULTS

Read the backtest before you trust the forecast

Model (weeks 43–52)MAEWAPEMAPE
Net flow, 3-line default modelEUR 162K82%118% ✗
Net flow + payroll and tax regressorsEUR 41K21%n/a
ReceiptsEUR 34K4%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.

LIVE BUILD · SCENARIOS

Scenario and breach calculator

The 13-week forecast from the script, live. Change the shocks and see when the balance breaks the minimum.

WeekBase netScenario netBalanceStatus

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

01

Testing on training data. Accuracy looks amazing on paper, then collapses in production. You deployed a lie.

02

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.

03

Ignoring the business calendar. Cash flows forecast on Christmas Day, payment runs on bank holidays. Add holidays and payroll/tax cycles explicitly.

04

One scenario only. The CFO asks "what if receipts drop 15%?" and you have no answer prepared.

05

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

01

The pipeline: DATA > FEATURES > SPLIT > TRAIN > VALIDATE > FORECAST.

02

4 model options. Prophet is the sweet spot, if you feed it your calendar.

03

Train/test split is non-negotiable. Hold out the most recent weeks.

04

MAPE for receipts and payments; MAE and WAPE for net flow. Watch the bias.

05

3 scenarios minimum, with the shock applied to receipts, not to the net.

06

A tested Prophet forecast, from clean data to breach flags.

07

One year of history cannot teach seasonality. Put the dip in a scenario.

✓ Module 9 complete

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