Module 10 · Financial Time-Series Foundations Module demo

Daily Payment Volume Forecaster

A calendar, not a stopwatch.

5:55 clipUses lessons 91–100Watch on YouTube

Transcript

40 sentences · select one to jump there

Code lab

Run it yourself

The demo source in one language. Edit it, run TypeScript and Python right here, and compare with the expected output.

demo-payment-volume-forecaster.ts
Start from GitHub
/**
 * Fintech Math Bootcamp · Module 10 demo · Daily Payment Volume Forecaster
 * A payments team forecasts tomorrow's processed volume (in millions of dollars) so treasury can
 * pre-fund settlement accounts. Twelve weeks of business days, one bank holiday, a weekly rhythm,
 * steady growth, and a large new merchant that goes live in week nine.
 * Lessons 091–100: calendars, levels and changes, lags and leads, rolling and expanding windows,
 * resampling, trend and seasonality, autocorrelation, differencing, smoothing and naive baselines,
 * and look-ahead leakage in train/test splits.
 * The series is synthetic: a formula plus seeded noise, identical on every run.
 */

export type Day = {date: string; dayNumber: number; weekday: number; week: number; volume: number};

export function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
const normal = (rand: () => number) => Math.sqrt(-2 * Math.log(1 - rand())) * Math.cos(2 * Math.PI * rand());
const mean = (a: number[]) => a.reduce((s, v) => s + v, 0) / a.length;
const round1 = (v: number) => Math.round(v * 10) / 10;

// 091 · a business-day calendar: elapsed days between observations are not constant
const START = Date.UTC(2026, 0, 5); // a Monday; fixed, so the calendar never depends on the clock
export const HOLIDAY = 42; // calendar day 42 = Monday of week 7, a synthetic bank holiday
const iso = (dayNumber: number) => new Date(START + dayNumber * 86400000).toISOString().slice(0, 10);
export function businessDays(weeks: number): number[] {
  const out: number[] = [];
  for (let d = 0; d < weeks * 7; d++) if (d % 7 < 5 && d !== HOLIDAY) out.push(d);
  return out;
}
export const gaps = (days: number[]) => days.slice(1).map((d, i) => d - days[i]);

// the synthetic generator: level + growth + weekday effect + new merchant from week 9 + noise (the holiday is simply closed)
export const WEEKDAY_EFFECT = [4, -1, -3, -2, 2]; // Mon..Fri, in $M, sums to zero
export const STEP = 8; // $M a day added by the new merchant from the first Monday of week 9 (calendar day 56)
export function generate(seed: number, weeks = 12): Day[] {
  const rand = mulberry32(seed);
  const base = (d: number) => 40 + 0.12 * d + WEEKDAY_EFFECT[d % 7] + (d >= 56 ? STEP : 0);
  return businessDays(weeks).map(d => ({date: iso(d), dayNumber: d, weekday: d % 7, week: Math.floor(d / 7) + 1, volume: round1(base(d) + 1.2 * normal(rand))}));
}

// 092 · levels, differences and simple returns
export const differences = (x: number[]) => x.slice(1).map((v, i) => v - x[i]);
export const simpleReturns = (x: number[]) => x.slice(1).map((v, i) => v / x[i] - 1);

// 093 · lag by calendar ("same weekday last week"), not by row count; a lead is a future label, never a feature
export function lagByCalendar(days: Day[], i: number, calendarDays: number): number | null {
  const target = days[i].dayNumber - calendarDays;
  const hit = days.find(d => d.dayNumber === target);
  return hit ? hit.volume : null;
}
export const lagByRows = (days: Day[], i: number, rows: number) => (i - rows >= 0 ? days[i - rows] : null);

// 094 · rolling (last w observations) and expanding (everything so far) means
export const rollingMean = (x: number[], w: number) => x.map((_, i) => (i + 1 < w ? null : mean(x.slice(i - w + 1, i + 1))));
export const expandingMean = (x: number[]) => x.map((_, i) => mean(x.slice(0, i + 1)));

// 095 · resample daily → weekly with left-closed, right-open buckets [Monday, next Monday)
export function weeklyTotals(days: Day[]): number[] {
  const sums = new Map<number, number>();
  for (const d of days) {const bucket = Math.floor(d.dayNumber / 7); sums.set(bucket, (sums.get(bucket) ?? 0) + d.volume);}
  return [...sums.values()];
}
// the bug to avoid: closed on both ends, so each Monday lands in two weeks
export function weeklyTotalsInclusive(days: Day[]): number[] {
  const weeks = Math.max(...days.map(d => d.week));
  return Array.from({length: weeks}, (_, k) => days.filter(d => d.dayNumber >= 7 * k && d.dayNumber <= 7 * (k + 1)).reduce((s, d) => s + d.volume, 0));
}

// small least-squares solver (normal equations with Gaussian elimination) for trend + weekday models
export function leastSquares(rows: number[][], y: number[]): number[] {
  const p = rows[0].length, A = Array.from({length: p}, () => new Array<number>(p + 1).fill(0));
  rows.forEach((r, k) => {for (let i = 0; i < p; i++) {for (let j = 0; j < p; j++) A[i][j] += r[i] * r[j]; A[i][p] += r[i] * y[k];}});
  for (let c = 0; c < p; c++) {
    let piv = c; for (let r = c + 1; r < p; r++) if (Math.abs(A[r][c]) > Math.abs(A[piv][c])) piv = r;
    [A[c], A[piv]] = [A[piv], A[c]];
    for (let r = 0; r < p; r++) if (r !== c) {const f = A[r][c] / A[c][c]; for (let j = c; j <= p; j++) A[r][j] -= f * A[c][j];}
  }
  return A.map((row, i) => row[p] / A[i][i]);
}
// 096 · trend + weekly seasonality: volume ≈ a + b·day + weekday effect (Monday is the reference)
export const features = (d: Day) => [1, d.dayNumber, d.weekday === 1 ? 1 : 0, d.weekday === 2 ? 1 : 0, d.weekday === 3 ? 1 : 0, d.weekday === 4 ? 1 : 0];
export const fitTrendSeason = (train: Day[]) => leastSquares(train.map(features), train.map(d => d.volume));
export const predictTrendSeason = (coef: number[], d: Day) => features(d).reduce((s, v, i) => s + v * coef[i], 0);

// 097 · autocorrelation at lag k with the 1/n convention
export function autocorrelation(x: number[], k: number): number {
  const m = mean(x), g = (lag: number) => x.slice(lag).reduce((s, v, j) => s + (v - m) * (x[j] - m), 0) / x.length;
  return g(k) / g(0);
}

// 099 · simple exponential smoothing and the seasonal naive forecast
export function ses(x: number[], alpha: number): number[] {
  let level = x[0]; const out = [level];
  for (const v of x.slice(1)) {level = alpha * v + (1 - alpha) * level; out.push(level);}
  return out;
}
export function seasonalNaive(days: Day[], i: number): number | null {
  for (let back = 7; back <= 21; back += 7) {const v = lagByCalendar(days, i, back); if (v !== null) return v;}
  return null;
}
export const mae = (actual: number[], forecast: number[]) => mean(actual.map((v, i) => Math.abs(v - forecast[i])));

// 100 · a row is usable for training only if it is dated on or before the cutoff
export const eligible = (d: Day, cutoffDay: number) => d.dayNumber <= cutoffDay;

export function runDemo() {
  const days = generate(1016);
  const vol = days.map(d => d.volume);

  // calendar
  const g = gaps(days.map(d => d.dayNumber));
  const gapCounts = {one: g.filter(v => v === 1).length, three: g.filter(v => v === 3).length, four: g.filter(v => v === 4).length};

  // levels, changes, returns for one sample week (week 3) plus the Monday before it
  const w3 = days.filter(d => d.week === 3), prevFri = days[days.indexOf(w3[0]) - 1];
  const sampleLevels = [prevFri.volume, ...w3.map(d => d.volume)];
  const sampleDates = [prevFri.date, ...w3.map(d => d.date)];

  // lags: row shift vs calendar lookup
  const mismatches = days.map((d, i) => ({i, row: lagByRows(days, i, 5)})).filter(({i, row}) => row !== null && row.weekday !== days[i].weekday).length;
  const afterHoliday = days.findIndex(d => d.dayNumber === HOLIDAY + 7);
  const lagExample = {date: days[afterHoliday].date, rowShiftDate: lagByRows(days, afterHoliday, 5)!.date, calendarLag: lagByCalendar(days, afterHoliday, 7), fallback: seasonalNaive(days, afterHoliday)};

  // windows
  const rolling = rollingMean(vol, 10), expanding = expandingMean(vol);

  // resampling
  const weekly = weeklyTotals(days), weeklyBad = weeklyTotalsInclusive(days);
  const dailyTotal = vol.reduce((s, v) => s + v, 0);

  // train / test split: weeks 1–8 train, weeks 9–12 test (cutoff = last calendar day of week 8)
  const cutoff = 55, train = days.filter(d => eligible(d, cutoff)), test = days.filter(d => !eligible(d, cutoff));
  const coef = fitTrendSeason(train);
  const detrended = train.map(d => d.volume - (coef[0] + coef[1] * d.dayNumber));
  const acf = Array.from({length: 10}, (_, k) => autocorrelation(detrended, k + 1));
  const d1 = differences(train.map(d => d.volume));
  const weeklyDiff = train.map((d, i) => {const p = lagByCalendar(train, i, 7); return p === null ? null : d.volume - p;}).filter((v): v is number => v !== null);
  const range = (a: number[]) => Math.max(...a) - Math.min(...a);

  // honest, time-aware backtest: each test week the model is refit on every day before that Monday
  const actualTest = test.map(d => d.volume);
  const testIdx = test.map(d => days.indexOf(d));
  const naiveTest = testIdx.map(i => seasonalNaive(days, i)!);
  const smartTest = test.map(d => predictTrendSeason(fitTrendSeason(days.filter(x => x.dayNumber < 7 * (d.week - 1))), d));
  const sesLevels = ses(vol, 0.3);
  const sesTest = testIdx.map(i => sesLevels[i - 1]);
  const honest = {smart: mae(actualTest, smartTest), naive: mae(actualTest, naiveTest), ses: mae(actualTest, sesTest)};
  const byWeek = [9, 10, 11, 12].map(w => {const k = test.map((d, j) => (d.week === w ? j : -1)).filter(j => j >= 0), pick = (a: number[]) => k.map(j => a[j]);
    return {week: w, smart: mae(pick(actualTest), pick(smartTest)), naive: mae(pick(actualTest), pick(naiveTest))};});

  // the leaky backtest: the same model fitted once on all twelve weeks, then scored on weeks it has already seen
  const leakCoef = fitTrendSeason(days);
  const leakyTest = test.map(d => predictTrendSeason(leakCoef, d));
  const leaky = {smart: mae(actualTest, leakyTest), naive: honest.naive, futureRowsInTraining: days.filter(d => !eligible(d, cutoff)).length};

  return {
    days, businessDays: days.length, calendarDays: 12 * 7, holiday: iso(HOLIDAY), gapCounts,
    changes: {dates: sampleDates, levels: sampleLevels, differences: differences(sampleLevels), returns: simpleReturns(sampleLevels)},
    lags: {rowShiftMismatches: mismatches, example: lagExample},
    windows: {size: 10, rolling, expanding, lastRolling: rolling.at(-1)!, lastExpanding: expanding.at(-1)!, lastWeekAverage: mean(vol.slice(-5))},
    resample: {weekly, weeklyInclusive: weeklyBad, dailyTotal, weeklySum: weekly.reduce((s, v) => s + v, 0), inclusiveSum: weeklyBad.reduce((s, v) => s + v, 0)},
    decomposition: {coef, trendPerDay: coef[1], weekdayEffects: [0, coef[2], coef[3], coef[4], coef[5]], acf,
      levelRange: range(train.map(d => d.volume)), diffRange: range(d1), weeklyDiffRange: range(weeklyDiff)},
    forecast: {cutoff: iso(cutoff), trainDays: train.length, testDays: test.length, actualTest, naiveTest, smartTest, leakyTest, sesTest, honest, leaky, byWeek, alpha: 0.3,
      stepWeek: 9, stepSize: STEP},
  };
}

export const checkedResult = {"days":[{"date":"2026-01-05","dayNumber":0,"weekday":0,"week":1,"volume":47.5},{"date":"2026-01-06","dayNumber":1,"weekday":1,"week":1,"volume":39},{"date":"2026-01-07","dayNumber":2,"weekday":2,"week":1,"volume":38.2},{"date":"2026-01-08","dayNumber":3,"weekday":3,"week":1,"volume":40},{"date":"2026-01-09","dayNumber":4,"weekday":4,"week":1,"volume":43.7},{"date":"2026-01-12","dayNumber":7,"weekday":0,"week":2,"volume":44.8},{"date":"2026-01-13","dayNumber":8,"weekday":1,"week":2,"volume":40.4},{"date":"2026-01-14","dayNumber":9,"weekday":2,"week":2,"volume":37.6},{"date":"2026-01-15","dayNumber":10,"weekday":3,"week":2,"volume":40.1},{"date":"2026-01-16","dayNumber":11,"weekday":4,"week":2,"volume":41.8},{"date":"2026-01-19","dayNumber":14,"weekday":0,"week":3,"volume":46.1},{"date":"2026-01-20","dayNumber":15,"weekday":1,"week":3,"volume":39.8},{"date":"2026-01-21","dayNumber":16,"weekday":2,"week":3,"volume":38.8},{"date":"2026-01-22","dayNumber":17,"weekday":3,"week":3,"volume":40.4},{"date":"2026-01-23","dayNumber":18,"weekday":4,"week":3,"volume":44.8},{"date":"2026-01-26","dayNumber":21,"weekday":0,"week":4,"volume":47.5},{"date":"2026-01-27","dayNumber":22,"weekday":1,"week":4,"volume":42.4},{"date":"2026-01-28","dayNumber":23,"weekday":2,"week":4,"volume":38.9},{"date":"2026-01-29","dayNumber":24,"weekday":3,"week":4,"volume":41.1},{"date":"2026-01-30","dayNumber":25,"weekday":4,"week":4,"volume":44.5},{"date":"2026-02-02","dayNumber":28,"weekday":0,"week":5,"volume":49},{"date":"2026-02-03","dayNumber":29,"weekday":1,"week":5,"volume":41.4},{"date":"2026-02-04","dayNumber":30,"weekday":2,"week":5,"volume":40.6},{"date":"2026-02-05","dayNumber":31,"weekday":3,"week":5,"volume":40.6},{"date":"2026-02-06","dayNumber":32,"weekday":4,"week":5,"volume":44.2},{"date":"2026-02-09","dayNumber":35,"weekday":0,"week":6,"volume":48.3},{"date":"2026-02-10","dayNumber":36,"weekday":1,"week":6,"volume":41.5},{"date":"2026-02-11","dayNumber":37,"weekday":2,"week":6,"volume":42.6},{"date":"2026-02-12","dayNumber":38,"weekday":3,"week":6,"volume":43.2},{"date":"2026-02-13","dayNumber":39,"weekday":4,"week":6,"volume":46.6},{"date":"2026-02-17","dayNumber":43,"weekday":1,"week":7,"volume":42.8},{"date":"2026-02-18","dayNumber":44,"weekday":2,"week":7,"volume":40.8},{"date":"2026-02-19","dayNumber":45,"weekday":3,"week":7,"volume":39.8},{"date":"2026-02-20","dayNumber":46,"weekday":4,"week":7,"volume":46.3},{"date":"2026-02-23","dayNumber":49,"weekday":0,"week":8,"volume":50.5},{"date":"2026-02-24","dayNumber":50,"weekday":1,"week":8,"volume":47.8},{"date":"2026-02-25","dayNumber":51,"weekday":2,"week":8,"volume":44.3},{"date":"2026-02-26","dayNumber":52,"weekday":3,"week":8,"volume":43.8},{"date":"2026-02-27","dayNumber":53,"weekday":4,"week":8,"volume":47.1},{"date":"2026-03-02","dayNumber":56,"weekday":0,"week":9,"volume":58.3},{"date":"2026-03-03","dayNumber":57,"weekday":1,"week":9,"volume":52.9},{"date":"2026-03-04","dayNumber":58,"weekday":2,"week":9,"volume":53},{"date":"2026-03-05","dayNumber":59,"weekday":3,"week":9,"volume":54.1},{"date":"2026-03-06","dayNumber":60,"weekday":4,"week":9,"volume":58},{"date":"2026-03-09","dayNumber":63,"weekday":0,"week":10,"volume":61.2},{"date":"2026-03-10","dayNumber":64,"weekday":1,"week":10,"volume":57.1},{"date":"2026-03-11","dayNumber":65,"weekday":2,"week":10,"volume":52.8},{"date":"2026-03-12","dayNumber":66,"weekday":3,"week":10,"volume":53.3},{"date":"2026-03-13","dayNumber":67,"weekday":4,"week":10,"volume":57.2},{"date":"2026-03-16","dayNumber":70,"weekday":0,"week":11,"volume":61.1},{"date":"2026-03-17","dayNumber":71,"weekday":1,"week":11,"volume":55.8},{"date":"2026-03-18","dayNumber":72,"weekday":2,"week":11,"volume":54.3},{"date":"2026-03-19","dayNumber":73,"weekday":3,"week":11,"volume":55.2},{"date":"2026-03-20","dayNumber":74,"weekday":4,"week":11,"volume":58.6},{"date":"2026-03-23","dayNumber":77,"weekday":0,"week":12,"volume":60.3},{"date":"2026-03-24","dayNumber":78,"weekday":1,"week":12,"volume":56.6},{"date":"2026-03-25","dayNumber":79,"weekday":2,"week":12,"volume":55.9},{"date":"2026-03-26","dayNumber":80,"weekday":3,"week":12,"volume":57.2},{"date":"2026-03-27","dayNumber":81,"weekday":4,"week":12,"volume":60.5}],"businessDays":59,"calendarDays":84,"holiday":"2026-02-16","gapCounts":{"one":47,"three":10,"four":1},"changes":{"dates":["2026-01-16","2026-01-19","2026-01-20","2026-01-21","2026-01-22","2026-01-23"],"levels":[41.8,46.1,39.8,38.8,40.4,44.8],"differences":[4.300000000000004,-6.300000000000004,-1,1.6000000000000014,4.399999999999999],"returns":[0.10287081339712922,-0.13665943600867692,-0.025125628140703515,0.04123711340206193,0.10891089108910879]},"lags":{"rowShiftMismatches":5,"example":{"date":"2026-02-23","rowShiftDate":"2026-02-13","calendarLag":null,"fallback":48.3}},"windows":{"size":10,"rolling":[null,null,null,null,null,null,null,null,null,41.31,41.17,41.25000000000001,41.31,41.35,41.459999999999994,41.73,41.92999999999999,42.059999999999995,42.16,42.42999999999999,42.720000000000006,42.88,43.06,43.08,43.019999999999996,43.10000000000001,43.010000000000005,43.38,43.59,43.800000000000004,43.18000000000001,43.120000000000005,43.040000000000006,43.61000000000001,44.24,44.190000000000005,44.470000000000006,44.59000000000001,44.980000000000004,46.150000000000006,47.160000000000004,48.38,49.81,50.98,52.05,52.98,53.83,54.78000000000001,55.79,56.07000000000001,56.36,56.489999999999995,56.60000000000001,56.660000000000004,56.57000000000001,56.52,56.83,57.22000000000001,57.55],"expanding":[47.5,43.25,41.56666666666667,41.175,41.67999999999999,42.199999999999996,41.942857142857136,41.4,41.25555555555556,41.31,41.74545454545455,41.583333333333336,41.369230769230775,41.300000000000004,41.53333333333333,41.90625,41.93529411764706,41.766666666666666,41.73157894736842,41.87,42.20952380952381,42.17272727272727,42.10434782608696,42.041666666666664,42.128,42.36538461538461,42.333333333333336,42.34285714285714,42.37241379310345,42.51333333333333,42.522580645161284,42.46874999999999,42.387878787878776,42.50294117647058,42.73142857142856,42.87222222222221,42.9108108108108,42.93421052631578,43.04102564102563,43.422499999999985,43.653658536585354,43.876190476190466,44.11395348837208,44.42954545454544,44.80222222222221,45.06956521739129,45.23404255319148,45.40208333333333,45.64285714285713,45.95199999999999,46.14509803921568,46.301923076923075,46.46981132075471,46.694444444444436,46.94181818181818,47.11428571428571,47.268421052631574,47.43965517241379,47.66101694915253],"lastRolling":57.55,"lastExpanding":47.66101694915253,"lastWeekAverage":58.1},"resample":{"weekly":[208.39999999999998,204.7,209.89999999999998,214.4,215.8,222.20000000000002,169.7,233.49999999999997,276.29999999999995,281.6,285,290.5],"weeklyInclusive":[253.2,250.79999999999998,257.4,263.4,264.1,222.20000000000002,220.2,291.79999999999995,337.49999999999994,342.70000000000005,345.3,290.5],"dailyTotal":2811.9999999999995,"weeklySum":2812,"inclusiveSum":3339.1000000000004},"decomposition":{"coef":[45.522887323943536,0.09766096579477022,-6.125741951710172,-7.885902917504943,-7.083563883299716,-3.4312248490944772],"trendPerDay":0.09766096579477022,"weekdayEffects":[0,-6.125741951710172,-7.885902917504943,-7.083563883299716,-3.4312248490944772],"acf":[0.1716749955205985,-0.5182192595416294,-0.5586199047312769,0.1900769282243814,0.6328453910845095,0.07376047747002419,-0.47854327318282086,-0.3662245789103331,0.24095660431096347,0.5245305150258739],"levelRange":12.899999999999999,"diffRange":15,"weeklyDiffRange":8.400000000000006},"forecast":{"cutoff":"2026-03-01","trainDays":39,"testDays":20,"actualTest":[58.3,52.9,53,54.1,58,61.2,57.1,52.8,53.3,57.2,61.1,55.8,54.3,55.2,58.6,60.3,56.6,55.9,57.2,60.5],"naiveTest":[50.5,47.8,44.3,43.8,47.1,58.3,52.9,53,54.1,58,61.2,57.1,52.8,53.3,57.2,61.1,55.8,54.3,55.2,58.6],"smartTest":[50.991901408450666,44.963820422535264,43.30132042253526,44.20132042253526,47.951320422535275,55.81167512690358,49.59842075578122,48.13175408911455,49.053976311336775,52.82064297800345,59.34873983739834,53.23867886178866,51.48867886178866,52.36867886178865,56.14867886178866,61.80222262707229,55.738549826926636,54.01127709965392,54.893095281472085,58.63854982692664],"leakyTest":[56.6704146527601,50.813204867294175,49.17153820062751,50.08820486729418,53.79653820062751,58.412363266344435,52.55515348087851,50.91348681421184,51.830153480878515,55.53848681421185,60.15431187992878,54.29710209446284,52.655435427796185,53.572102094462856,57.28043542779619,61.89626049351311,56.039050708047185,54.397384041380526,55.3140507080472,59.022384041380526],"sesTest":[45.66887119087318,49.45820983361122,50.49074688352785,51.243522818469486,52.10046597292863,53.87032618105004,56.06922832673502,56.37845982871451,55.30492188010015,54.7034453160701,55.452411721249064,57.14668820487434,56.742681743412035,56.00987722038842,55.76691405427189,56.61683983799032,57.72178788659322,57.385251520615256,56.93967606443067,57.017773245101466],"honest":{"smart":4.595057147390437,"naive":3.2500000000000013,"ses":3.3445618419194503},"leaky":{"smart":2.10872297125411,"naive":3.2500000000000013,"futureRowsInTraining":20},"byWeek":[{"week":9,"smart":8.978063380281654,"naive":8.56},{"week":10,"smart":5.236706147772085,"naive":1.7800000000000025},{"week":11,"smart":2.481308943089407,"naive":1.240000000000002},{"week":12,"smart":1.684150118418603,"naive":1.4200000000000017}],"alpha":0.3,"stepWeek":9,"stepSize":8}};

// Run this file directly: npx tsx lessons/10-financial-time-series-foundations/demo-payment-volume-forecaster.ts
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/").split("/").pop()!)) {
  console.log(JSON.stringify(runDemo(), null, 2));
}

Your output

Press Run to execute the code in your browser.

Expected output

{
  "days": [
    {
      "date": "2026-01-05",
      "dayNumber": 0,
      "weekday": 0,
      "week": 1,
      "volume": 47.5
    },
    {
      "date": "2026-01-06",
      "dayNumber": 1,
      "weekday": 1,
      "week": 1,
      "volume": 39
    },
    {
      "date": "2026-01-07",
      "dayNumber": 2,
      "weekday": 2,
      "week": 1,
      "volume": 38.2
    },
    {
      "date": "2026-01-08",
      "dayNumber": 3,
      "weekday": 3,
      "week": 1,
      "volume": 40
    },
    {
      "date": "2026-01-09",
      "dayNumber": 4,
      "weekday": 4,
      "week": 1,
      "volume": 43.7
    },
    {
      "date": "2026-01-12",
      "dayNumber": 7,
      "weekday": 0,
      "week": 2,
      "volume": 44.8
    },
    {
      "date": "2026-01-13",
      "dayNumber": 8,
      "weekday": 1,
      "week": 2,
      "volume": 40.4
    },
    {
      "date": "2026-01-14",
      "dayNumber": 9,
      "weekday": 2,
      "week": 2,
      "volume": 37.6
    },
    {
      "date": "2026-01-15",
      "dayNumber": 10,
      "weekday": 3,
      "week": 2,
      "volume": 40.1
    },
    {
      "date": "2026-01-16",
      "dayNumber": 11,
      "weekday": 4,
      "week": 2,
      "volume": 41.8
    },
    {
      "date": "2026-01-19",
      "dayNumber": 14,
      "weekday": 0,
      "week": 3,
      "volume": 46.1
    },
    {
      "date": "2026-01-20",
      "dayNumber": 15,
      "weekday": 1,
      "week": 3,
      "volume": 39.8
    },
    {
      "date": "2026-01-21",
      "dayNumber": 16,
      "weekday": 2,
      "week": 3,
      "volume": 38.8
    },
    {
      "date": "2026-01-22",
      "dayNumber": 17,
      "weekday": 3,
      "week": 3,
      "volume": 40.4
    },
    {
      "date": "2026-01-23",
      "dayNumber": 18,
      "weekday": 4,
      "week": 3,
      "volume": 44.8
    },
    {
      "date": "2026-01-26",
      "dayNumber": 21,
      "weekday": 0,
      "week": 4,
      "volume": 47.5
    },
    {
      "date": "2026-01-27",
      "dayNumber": 22,
      "weekday": 1,
      "week": 4,
      "volume": 42.4
    },
    {
      "date": "2026-01-28",
      "dayNumber": 23,
      "weekday": 2,
      "week": 4,
      "volume": 38.9
    },
    {
      "date": "2026-01-29",
      "dayNumber": 24,
      "weekday": 3,
      "week": 4,
      "volume": 41.1
    },
    {
      "date": "2026-01-30",
      "dayNumber": 25,
      "weekday": 4,
      "week": 4,
      "volume": 44.5
    },
    {
      "date": "2026-02-02",
      "dayNumber": 28,
      "weekday": 0,
      "week": 5,
      "volume": 49
    },
    {
      "date": "2026-02-03",
      "dayNumber": 29,
      "weekday": 1,
      "week": 5,
      "volume": 41.4
    },
    {
      "date": "2026-02-04",
      "dayNumber": 30,
      "weekday": 2,
      "week": 5,
      "volume": 40.6
    },
    {
      "date": "2026-02-05",
      "dayNumber": 31,
      "weekday": 3,
      "week": 5,
      "volume": 40.6
    },
    {
      "date": "2026-02-06",
      "dayNumber": 32,
      "weekday": 4,
      "week": 5,
      "volume": 44.2
    },
    {
      "date": "2026-02-09",
      "dayNumber": 35,
      "weekday": 0,
      "week": 6,
      "volume": 48.3
    },
    {
      "date": "2026-02-10",
      "dayNumber": 36,
      "weekday": 1,
      "week": 6,
      "volume": 41.5
    },
    {
      "date": "2026-02-11",
      "dayNumber": 37,
      "weekday": 2,
      "week": 6,
      "volume": 42.6
    },
    {
      "date": "2026-02-12",
      "dayNumber": 38,
      "weekday": 3,
      "week": 6,
      "volume": 43.2
    },
    {
      "date": "2026-02-13",
      "dayNumber": 39,
      "weekday": 4,
      "week": 6,
      "volume": 46.6
    },
    {
      "date": "2026-02-17",
      "dayNumber": 43,
      "weekday": 1,
      "week": 7,
      "volume": 42.8
    },
    {
      "date": "2026-02-18",
      "dayNumber": 44,
      "weekday": 2,
      "week": 7,
      "volume": 40.8
    },
    {
      "date": "2026-02-19",
      "dayNumber": 45,
      "weekday": 3,
      "week": 7,
      "volume": 39.8
    },
    {
      "date": "2026-02-20",
      "dayNumber": 46,
      "weekday": 4,
      "week": 7,
      "volume": 46.3
    },
    {
      "date": "2026-02-23",
      "dayNumber": 49,
      "weekday": 0,
      "week": 8,
      "volume": 50.5
    },
    {
      "date": "2026-02-24",
      "dayNumber": 50,
      "weekday": 1,
      "week": 8,
      "volume": 47.8
    },
    {
      "date": "2026-02-25",
      "dayNumber": 51,
      "weekday": 2,
      "week": 8,
      "volume": 44.3
    },
    {
      "date": "2026-02-26",
      "dayNumber": 52,
      "weekday": 3,
      "week": 8,
      "volume": 43.8
    },
    {
      "date": "2026-02-27",
      "dayNumber": 53,
      "weekday": 4,
      "week": 8,
      "volume": 47.1
    },
    {
      "date": "2026-03-02",
      "dayNumber": 56,
      "weekday": 0,
      "week": 9,
      "volume": 58.3
    },
    {
      "date": "2026-03-03",
      "dayNumber": 57,
      "weekday": 1,
      "week": 9,
      "volume": 52.9
    },
    {
      "date": "2026-03-04",
      "dayNumber": 58,
      "weekday": 2,
      "week": 9,
      "volume": 53
    },
    {
      "date": "2026-03-05",
      "dayNumber": 59,
      "weekday": 3,
      "week": 9,
      "volume": 54.1
    },
    {
      "date": "2026-03-06",
      "dayNumber": 60,
      "weekday": 4,
      "week": 9,
      "volume": 58
    },
    {
      "date": "2026-03-09",
      "dayNumber": 63,
      "weekday": 0,
      "week": 10,
      "volume": 61.2
    },
    {
      "date": "2026-03-10",
      "dayNumber": 64,
      "weekday": 1,
      "week": 10,
      "volume": 57.1
    },
    {
      "date": "2026-03-11",
      "dayNumber": 65,
      "weekday": 2,
      "week": 10,
      "volume": 52.8
    },
    {
      "date": "2026-03-12",
      "dayNumber": 66,
      "weekday": 3,
      "week": 10,
      "volume": 53.3
    },
    {
      "date": "2026-03-13",
      "dayNumber": 67,
      "weekday": 4,
      "week": 10,
      "volume": 57.2
    },
    {
      "date": "2026-03-16",
      "dayNumber": 70,
      "weekday": 0,
      "week": 11,
      "volume": 61.1
    },
    {
      "date": "2026-03-17",
      "dayNumber": 71,
      "weekday": 1,
      "week": 11,
      "volume": 55.8
    },
    {
      "date": "2026-03-18",
      "dayNumber": 72,
      "weekday": 2,
      "week": 11,
      "volume": 54.3
    },
    {
      "date": "2026-03-19",
      "dayNumber": 73,
      "weekday": 3,
      "week": 11,
      "volume": 55.2
    },
    {
      "date": "2026-03-20",
      "dayNumber": 74,
      "weekday": 4,
      "week": 11,
      "volume": 58.6
    },
    {
      "date": "2026-03-23",
      "dayNumber": 77,
      "weekday": 0,
      "week": 12,
      "volume": 60.3
    },
    {
      "date": "2026-03-24",
      "dayNumber": 78,
      "weekday": 1,
      "week": 12,
      "volume": 56.6
    },
    {
      "date": "2026-03-25",
      "dayNumber": 79,
      "weekday": 2,
      "week": 12,
      "volume": 55.9
    },
    {
      "date": "2026-03-26",
      "dayNumber": 80,
      "weekday": 3,
      "week": 12,
      "volume": 57.2
    },
    {
      "date": "2026-03-27",
      "dayNumber": 81,
      "weekday": 4,
      "week": 12,
      "volume": 60.5
    }
  ],
  "businessDays": 59,
  "calendarDays": 84,
  "holiday": "2026-02-16",
  "gapCounts": {
    "one": 47,
    "three": 10,
    "four": 1
  },
  "changes": {
    "dates": [
      "2026-01-16",
      "2026-01-19",
      "2026-01-20",
      "2026-01-21",
      "2026-01-22",
      "2026-01-23"
    ],
    "levels": [
      41.8,
      46.1,
      39.8,
      38.8,
      40.4,
      44.8
    ],
    "differences": [
      4.300000000000004,
      -6.300000000000004,
      -1,
      1.6000000000000014,
      4.399999999999999
    ],
    "returns": [
      0.10287081339712922,
      -0.13665943600867692,
      -0.025125628140703515,
      0.04123711340206193,
      0.10891089108910879
    ]
  },
  "lags": {
    "rowShiftMismatches": 5,
    "example": {
      "date": "2026-02-23",
      "rowShiftDate": "2026-02-13",
      "calendarLag": null,
      "fallback": 48.3
    }
  },
  "windows": {
    "size": 10,
    "rolling": [
      null,
      null,
      null,
      null,
      null,
      null,
      null,
      null,
      null,
      41.31,
      41.17,
      41.25000000000001,
      41.31,
      41.35,
      41.459999999999994,
      41.73,
      41.92999999999999,
      42.059999999999995,
      42.16,
      42.42999999999999,
      42.720000000000006,
      42.88,
      43.06,
      43.08,
      43.019999999999996,
      43.10000000000001,
      43.010000000000005,
      43.38,
      43.59,
      43.800000000000004,
      43.18000000000001,
      43.120000000000005,
      43.040000000000006,
      43.61000000000001,
      44.24,
      44.190000000000005,
      44.470000000000006,
      44.59000000000001,
      44.980000000000004,
      46.150000000000006,
      47.160000000000004,
      48.38,
      49.81,
      50.98,
      52.05,
      52.98,
      53.83,
      54.78000000000001,
      55.79,
      56.07000000000001,
      56.36,
      56.489999999999995,
      56.60000000000001,
      56.660000000000004,
      56.57000000000001,
      56.52,
      56.83,
      57.22000000000001,
      57.55
    ],
    "expanding": [
      47.5,
      43.25,
      41.56666666666667,
      41.175,
      41.67999999999999,
      42.199999999999996,
      41.942857142857136,
      41.4,
      41.25555555555556,
      41.31,
      41.74545454545455,
      41.583333333333336,
      41.369230769230775,
      41.300000000000004,
      41.53333333333333,
      41.90625,
      41.93529411764706,
      41.766666666666666,
      41.73157894736842,
      41.87,
      42.20952380952381,
      42.17272727272727,
      42.10434782608696,
      42.041666666666664,
      42.128,
      42.36538461538461,
      42.333333333333336,
      42.34285714285714,
      42.37241379310345,
      42.51333333333333,
      42.522580645161284,
      42.46874999999999,
      42.387878787878776,
      42.50294117647058,
      42.73142857142856,
      42.87222222222221,
      42.9108108108108,
      42.93421052631578,
      43.04102564102563,
      43.422499999999985,
      43.653658536585354,
      43.876190476190466,
      44.11395348837208,
      44.42954545454544,
      44.80222222222221,
      45.06956521739129,
      45.23404255319148,
      45.40208333333333,
      45.64285714285713,
      45.95199999999999,
      46.14509803921568,
      46.301923076923075,
      46.46981132075471,
      46.694444444444436,
      46.94181818181818,
      47.11428571428571,
      47.268421052631574,
      47.43965517241379,
      47.66101694915253
    ],
    "lastRolling": 57.55,
    "lastExpanding": 47.66101694915253,
    "lastWeekAverage": 58.1
  },
  "resample": {
    "weekly": [
      208.39999999999998,
      204.7,
      209.89999999999998,
      214.4,
      215.8,
      222.20000000000002,
      169.7,
      233.49999999999997,
      276.29999999999995,
      281.6,
      285,
      290.5
    ],
    "weeklyInclusive": [
      253.2,
      250.79999999999998,
      257.4,
      263.4,
      264.1,
      222.20000000000002,
      220.2,
      291.79999999999995,
      337.49999999999994,
      342.70000000000005,
      345.3,
      290.5
    ],
    "dailyTotal": 2811.9999999999995,
    "weeklySum": 2812,
    "inclusiveSum": 3339.1000000000004
  },
  "decomposition": {
    "coef": [
      45.522887323943536,
      0.09766096579477022,
      -6.125741951710172,
      -7.885902917504943,
      -7.083563883299716,
      -3.4312248490944772
    ],
    "trendPerDay": 0.09766096579477022,
    "weekdayEffects": [
      0,
      -6.125741951710172,
      -7.885902917504943,
      -7.083563883299716,
      -3.4312248490944772
    ],
    "acf": [
      0.1716749955205985,
      -0.5182192595416294,
      -0.5586199047312769,
      0.1900769282243814,
      0.6328453910845095,
      0.07376047747002419,
      -0.47854327318282086,
      -0.3662245789103331,
      0.24095660431096347,
      0.5245305150258739
    ],
    "levelRange": 12.899999999999999,
    "diffRange": 15,
    "weeklyDiffRange": 8.400000000000006
  },
  "forecast": {
    "cutoff": "2026-03-01",
    "trainDays": 39,
    "testDays": 20,
    "actualTest": [
      58.3,
      52.9,
      53,
      54.1,
      58,
      61.2,
      57.1,
      52.8,
      53.3,
      57.2,
      61.1,
      55.8,
      54.3,
      55.2,
      58.6,
      60.3,
      56.6,
      55.9,
      57.2,
      60.5
    ],
    "naiveTest": [
      50.5,
      47.8,
      44.3,
      43.8,
      47.1,
      58.3,
      52.9,
      53,
      54.1,
      58,
      61.2,
      57.1,
      52.8,
      53.3,
      57.2,
      61.1,
      55.8,
      54.3,
      55.2,
      58.6
    ],
    "smartTest": [
      50.991901408450666,
      44.963820422535264,
      43.30132042253526,
      44.20132042253526,
      47.951320422535275,
      55.81167512690358,
      49.59842075578122,
      48.13175408911455,
      49.053976311336775,
      52.82064297800345,
      59.34873983739834,
      53.23867886178866,
      51.48867886178866,
      52.36867886178865,
      56.14867886178866,
      61.80222262707229,
      55.738549826926636,
      54.01127709965392,
      54.893095281472085,
      58.63854982692664
    ],
    "leakyTest": [
      56.6704146527601,
      50.813204867294175,
      49.17153820062751,
      50.08820486729418,
      53.79653820062751,
      58.412363266344435,
      52.55515348087851,
      50.91348681421184,
      51.830153480878515,
      55.53848681421185,
      60.15431187992878,
      54.29710209446284,
      52.655435427796185,
      53.572102094462856,
      57.28043542779619,
      61.89626049351311,
      56.039050708047185,
      54.397384041380526,
      55.3140507080472,
      59.022384041380526
    ],
    "sesTest": [
      45.66887119087318,
      49.45820983361122,
      50.49074688352785,
      51.243522818469486,
      52.10046597292863,
      53.87032618105004,
      56.06922832673502,
      56.37845982871451,
      55.30492188010015,
      54.7034453160701,
      55.452411721249064,
      57.14668820487434,
      56.742681743412035,
      56.00987722038842,
      55.76691405427189,
      56.61683983799032,
      57.72178788659322,
      57.385251520615256,
      56.93967606443067,
      57.017773245101466
    ],
    "honest": {
      "smart": 4.595057147390437,
      "naive": 3.2500000000000013,
      "ses": 3.3445618419194503
    },
    "leaky": {
      "smart": 2.10872297125411,
      "naive": 3.2500000000000013,
      "futureRowsInTraining": 20
    },
    "byWeek": [
      {
        "week": 9,
        "smart": 8.978063380281654,
        "naive": 8.56
      },
      {
        "week": 10,
        "smart": 5.236706147772085,
        "naive": 1.7800000000000025
      },
      {
        "week": 11,
        "smart": 2.481308943089407,
        "naive": 1.240000000000002
      },
      {
        "week": 12,
        "smart": 1.684150118418603,
        "naive": 1.4200000000000017
      }
    ],
    "alpha": 0.3,
    "stepWeek": 9,
    "stepSize": 8
  }
}

Prefer your own machine? Every file is in the course repository · open it in Codespaces.

What the demo does

Twelve synthetic weeks of daily payment volume, with a bank holiday and a new merchant going live in week nine. A trend-plus-weekday model looks brilliant until look-ahead leakage is removed, then loses to "same as last week". Every module 10 lesson becomes one forecaster feature.