Module 10 · Financial Time-Series Foundations Module demo
Daily Payment Volume Forecaster
A calendar, not a stopwatch.
Transcript
40 sentences · select one to jump thereCode lab
Run it yourself
The demo source in one language. Edit it, run TypeScript and Python right here, and compare with the expected output.
/**
* 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.
Lessons it combines
- Time Order, Frequency, Regularity, and Financial Calendars
- Levels, Changes, Differences, and Returns
- Lags, Leads, and Temporal Dependence
- Rolling and Expanding Windows
- Resampling, Aggregation, and Time Alignment
- Trend, Seasonality, Cycles, and Remainder
- Autocovariance and Autocorrelation
- Stationarity and Differencing Intuition
- Smoothing, Baselines, and Naive Forecasts
- Look-Ahead Leakage and Time-Aware Data Splits