Module 4 · Location, Ranking, and Exploratory Summaries Module demo

Settlement Delay Dashboard

Where the payouts wait.

5:39 clipUses lessons 31–40Watch on YouTube

Transcript

37 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-settlement-delay-dashboard.ts
Start from GitHub
/**
 * Fintech Math Bootcamp · Module 04 demo · Settlement Delay Dashboard
 * An operations view of how long merchant payouts took to settle last week: twenty payouts across six
 * merchants, one of them held for 220 hours. Every summary on the dashboard is one module lesson, and
 * the dashboard shows why the mean is not what a typical customer experiences.
 * Lessons 031–040. Synthetic example; not financial advice.
 */

export type Payout = {id: string; merchant: string; hours: number};
export type Merchant = {merchant: string; attempts: number; approved: number};

const DELAYS: Record<string, number[]> = {
  A: [12, 14, 18, 20],
  B: [16, 18, 22],
  C: [20, 24, 24, 26],
  D: [18, 22, 24],
  E: [26, 30, 36],
  F: [28, 30, 220],
};
export const payouts: Payout[] = Object.entries(DELAYS).flatMap(([m, hs]) => hs.map((hours, i) => ({id: `${m}${i + 1}`, merchant: m, hours})));

export const merchants: Merchant[] = [
  {merchant: "A", attempts: 500, approved: 480},
  {merchant: "B", attempts: 250, approved: 230},
  {merchant: "C", attempts: 400, approved: 360},
  {merchant: "D", attempts: 300, approved: 282},
  {merchant: "E", attempts: 200, approved: 170},
  {merchant: "F", attempts: 50, approved: 30},
];

const sorted = (x: number[]) => [...x].sort((a, b) => a - b);
const r = (x: number, d = 4) => Math.round(x * 10 ** d) / 10 ** d;

// 031 · count, sum, minimum, maximum and range of a finite, nonempty sample
export function basics(x: number[]) {
  if (x.length === 0 || !x.every(Number.isFinite)) throw new Error("Finite, nonempty sample required");
  const min = Math.min(...x), max = Math.max(...x);
  return {count: x.length, sum: x.reduce((s, v) => s + v, 0), min, max, range: max - min};
}

// 032 · arithmetic mean: the balance point
export const mean = (x: number[]) => x.reduce((s, v) => s + v, 0) / x.length;

// 033 · weighted mean: Σ w·x / Σ w
export function weightedMean(values: number[], weights: number[]): number {
  if (values.length !== weights.length) throw new Error("Lengths differ");
  return values.reduce((s, v, i) => s + v * weights[i], 0) / weights.reduce((s, w) => s + w, 0);
}

// 034 · median and all tied modes
export function median(x: number[]): number {
  const s = sorted(x), mid = Math.floor(s.length / 2);
  return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
}
export function modes(x: number[]) {
  const counts = new Map<number, number>();
  for (const v of x) counts.set(v, (counts.get(v) ?? 0) + 1);
  const top = Math.max(...counts.values());
  return {count: top, values: [...counts].filter(([, c]) => c === top).map(([v]) => v).sort((a, b) => a - b)};
}

// 035 · trimmed and winsorized means with a declared tail share
export function trimWinsor(x: number[], share: number) {
  const s = sorted(x), k = Math.floor(s.length * share);
  const lo = s[k], hi = s[s.length - 1 - k];
  return {k, trimmedMean: mean(s.slice(k, s.length - k)), winsorizedMean: mean(s.map(v => Math.max(lo, Math.min(hi, v)))), lo, hi};
}

// 036 · type-7 quantile (linear interpolation) and nearest-rank, for comparison
export function quantile7(x: number[], p: number) {
  const s = sorted(x), h = (s.length - 1) * p, i = Math.floor(h);
  return {h, lower: s[i], upper: s[Math.min(i + 1, s.length - 1)], value: s[i] + (h - i) * (s[Math.min(i + 1, s.length - 1)] - s[i])};
}
export function nearestRank(x: number[], p: number) {
  const s = sorted(x), rank = Math.ceil(p * s.length);
  return {rank, value: s[rank - 1]};
}

// 037 · average ranks for ties and percentile rank 100(r − 1)/(n − 1)
export function averageRanks(x: number[]) {
  const ranks = x.map(v => x.filter(y => y < v).length + (x.filter(y => y === v).length + 1) / 2);
  return {ranks, percentileRanks: ranks.map(rk => (100 * (rk - 1)) / (x.length - 1))};
}

// 038 · frequency table over half-open bands [lo, hi)
export function frequency(x: number[], edges: number[]) {
  return edges.slice(0, -1).map((lo, i) => {
    const count = x.filter(v => v >= lo && v < edges[i + 1]).length;
    return {lo, hi: edges[i + 1], count, share: count / x.length};
  });
}

// 039 · histogram counts with half-open bins, and the ECDF F(t) = #(x ≤ t) / n
export const histogram = (x: number[], width: number, top: number) =>
  Array.from({length: Math.ceil(top / width)}, (_, i) => x.filter(v => v >= i * width && v < (i + 1) * width).length);
export const ecdf = (x: number[], t: number) => x.filter(v => v <= t).length / x.length;

// 040 · five-number summary, Tukey fences, observed whiskers and outliers
export function boxPlot(x: number[]) {
  const s = sorted(x), q1 = quantile7(s, 0.25).value, med = quantile7(s, 0.5).value, q3 = quantile7(s, 0.75).value;
  const iqr = q3 - q1, lo = q1 - 1.5 * iqr, hi = q3 + 1.5 * iqr, inside = s.filter(v => v >= lo && v <= hi);
  return {five: [s[0], q1, med, q3, s[s.length - 1]], iqr, fences: [lo, hi], whiskers: [Math.min(...inside), Math.max(...inside)], outliers: s.filter(v => v < lo || v > hi)};
}

export function runDemo() {
  const hours = payouts.map(p => p.hours);
  const b = basics(hours);
  const worst = payouts.reduce((a, p) => (p.hours > a.hours ? p : a));
  const withoutWorst = hours.filter(h => h !== worst.hours);

  const tw = trimWinsor(hours, 0.1);
  const p95 = quantile7(hours, 0.95), p95nr = nearestRank(hours, 0.95), slaHours = 40;

  const merchantMedians = merchants.map(m => median(payouts.filter(p => p.merchant === m.merchant).map(p => p.hours)));
  const rk = averageRanks(merchantMedians);

  const rates = merchants.map(m => m.approved / m.attempts);
  const box = boxPlot(hours);

  return {
    payouts, sortedHours: sorted(hours), merchants: merchants.map(m => m.merchant),
    basics: b, worst,
    location: {
      mean: mean(hours), median: median(hours), modes: modes(hours),
      meanWithoutWorst: r(mean(withoutWorst)), medianWithoutWorst: median(withoutWorst),
      meanShift: r(mean(hours) - mean(withoutWorst)),
      trimShare: 0.1, trimK: tw.k, trimmedMean: tw.trimmedMean, winsorizedMean: tw.winsorizedMean, winsorLo: tw.lo, winsorHi: tw.hi,
    },
    approval: {
      attempts: merchants.map(m => m.attempts), approved: merchants.map(m => m.approved), rates,
      unweighted: r(mean(rates)), weighted: r(weightedMean(rates, merchants.map(m => m.attempts))),
      totalAttempts: merchants.reduce((s, m) => s + m.attempts, 0), totalApproved: merchants.reduce((s, m) => s + m.approved, 0),
    },
    percentiles: {
      p50: quantile7(hours, 0.5).value, p90: r(quantile7(hours, 0.9).value), p95: r(p95.value), p95h: r(p95.h), p95lower: p95.lower, p95upper: p95.upper,
      p95nearestRank: p95nr.value, p95rank: p95nr.rank, slaHours,
      slaType7: p95.value <= slaHours ? "met" : "missed", slaNearestRank: p95nr.value <= slaHours ? "met" : "missed",
    },
    ranks: {merchantMedians, ranks: rk.ranks, percentileRanks: rk.percentileRanks},
    frequency: frequency(hours, [0, 24, 48, Infinity]).map(f => ({...f, hi: Number.isFinite(f.hi) ? f.hi : null})),
    histogram: {width12: histogram(hours, 12, 228), width48: histogram(hours, 48, 240)},
    ecdf: {at24: ecdf(hours, 24), at36: ecdf(hours, 36), at48: ecdf(hours, 48)},
    box,
  };
}

export const checkedResult = {"payouts":[{"id":"A1","merchant":"A","hours":12},{"id":"A2","merchant":"A","hours":14},{"id":"A3","merchant":"A","hours":18},{"id":"A4","merchant":"A","hours":20},{"id":"B1","merchant":"B","hours":16},{"id":"B2","merchant":"B","hours":18},{"id":"B3","merchant":"B","hours":22},{"id":"C1","merchant":"C","hours":20},{"id":"C2","merchant":"C","hours":24},{"id":"C3","merchant":"C","hours":24},{"id":"C4","merchant":"C","hours":26},{"id":"D1","merchant":"D","hours":18},{"id":"D2","merchant":"D","hours":22},{"id":"D3","merchant":"D","hours":24},{"id":"E1","merchant":"E","hours":26},{"id":"E2","merchant":"E","hours":30},{"id":"E3","merchant":"E","hours":36},{"id":"F1","merchant":"F","hours":28},{"id":"F2","merchant":"F","hours":30},{"id":"F3","merchant":"F","hours":220}],"sortedHours":[12,14,16,18,18,18,20,20,22,22,24,24,24,26,26,28,30,30,36,220],"merchants":["A","B","C","D","E","F"],"basics":{"count":20,"sum":648,"min":12,"max":220,"range":208},"worst":{"id":"F3","merchant":"F","hours":220},"location":{"mean":32.4,"median":23,"modes":{"count":3,"values":[18,24]},"meanWithoutWorst":22.5263,"medianWithoutWorst":22,"meanShift":9.8737,"trimShare":0.1,"trimK":2,"trimmedMean":22.875,"winsorizedMean":22.9,"winsorLo":16,"winsorHi":30},"approval":{"attempts":[500,250,400,300,200,50],"approved":[480,230,360,282,170,30],"rates":[0.96,0.92,0.9,0.94,0.85,0.6],"unweighted":0.8617,"weighted":0.9129,"totalAttempts":1700,"totalApproved":1552},"percentiles":{"p50":23,"p90":30.6,"p95":45.2,"p95h":18.05,"p95lower":36,"p95upper":220,"p95nearestRank":36,"p95rank":19,"slaHours":40,"slaType7":"missed","slaNearestRank":"met"},"ranks":{"merchantMedians":[16,18,24,22,30,30],"ranks":[1,2,4,3,5.5,5.5],"percentileRanks":[0,20,60,40,90,90]},"frequency":[{"lo":0,"hi":24,"count":10,"share":0.5},{"lo":24,"hi":48,"count":9,"share":0.45},{"lo":48,"hi":null,"count":1,"share":0.05}],"histogram":{"width12":[0,10,8,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],"width48":[19,0,0,0,1]},"ecdf":{"at24":0.65,"at36":0.95,"at48":0.95},"box":{"five":[12,18,23,26.5,220],"iqr":8.5,"fences":[5.25,39.25],"whiskers":[12,36],"outliers":[220]}};

// Run this file directly: npx tsx lessons/04-location-ranking-and-exploratory-summaries/demo-settlement-delay-dashboard.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

{
  "payouts": [
    {
      "id": "A1",
      "merchant": "A",
      "hours": 12
    },
    {
      "id": "A2",
      "merchant": "A",
      "hours": 14
    },
    {
      "id": "A3",
      "merchant": "A",
      "hours": 18
    },
    {
      "id": "A4",
      "merchant": "A",
      "hours": 20
    },
    {
      "id": "B1",
      "merchant": "B",
      "hours": 16
    },
    {
      "id": "B2",
      "merchant": "B",
      "hours": 18
    },
    {
      "id": "B3",
      "merchant": "B",
      "hours": 22
    },
    {
      "id": "C1",
      "merchant": "C",
      "hours": 20
    },
    {
      "id": "C2",
      "merchant": "C",
      "hours": 24
    },
    {
      "id": "C3",
      "merchant": "C",
      "hours": 24
    },
    {
      "id": "C4",
      "merchant": "C",
      "hours": 26
    },
    {
      "id": "D1",
      "merchant": "D",
      "hours": 18
    },
    {
      "id": "D2",
      "merchant": "D",
      "hours": 22
    },
    {
      "id": "D3",
      "merchant": "D",
      "hours": 24
    },
    {
      "id": "E1",
      "merchant": "E",
      "hours": 26
    },
    {
      "id": "E2",
      "merchant": "E",
      "hours": 30
    },
    {
      "id": "E3",
      "merchant": "E",
      "hours": 36
    },
    {
      "id": "F1",
      "merchant": "F",
      "hours": 28
    },
    {
      "id": "F2",
      "merchant": "F",
      "hours": 30
    },
    {
      "id": "F3",
      "merchant": "F",
      "hours": 220
    }
  ],
  "sortedHours": [
    12,
    14,
    16,
    18,
    18,
    18,
    20,
    20,
    22,
    22,
    24,
    24,
    24,
    26,
    26,
    28,
    30,
    30,
    36,
    220
  ],
  "merchants": [
    "A",
    "B",
    "C",
    "D",
    "E",
    "F"
  ],
  "basics": {
    "count": 20,
    "sum": 648,
    "min": 12,
    "max": 220,
    "range": 208
  },
  "worst": {
    "id": "F3",
    "merchant": "F",
    "hours": 220
  },
  "location": {
    "mean": 32.4,
    "median": 23,
    "modes": {
      "count": 3,
      "values": [
        18,
        24
      ]
    },
    "meanWithoutWorst": 22.5263,
    "medianWithoutWorst": 22,
    "meanShift": 9.8737,
    "trimShare": 0.1,
    "trimK": 2,
    "trimmedMean": 22.875,
    "winsorizedMean": 22.9,
    "winsorLo": 16,
    "winsorHi": 30
  },
  "approval": {
    "attempts": [
      500,
      250,
      400,
      300,
      200,
      50
    ],
    "approved": [
      480,
      230,
      360,
      282,
      170,
      30
    ],
    "rates": [
      0.96,
      0.92,
      0.9,
      0.94,
      0.85,
      0.6
    ],
    "unweighted": 0.8617,
    "weighted": 0.9129,
    "totalAttempts": 1700,
    "totalApproved": 1552
  },
  "percentiles": {
    "p50": 23,
    "p90": 30.6,
    "p95": 45.2,
    "p95h": 18.05,
    "p95lower": 36,
    "p95upper": 220,
    "p95nearestRank": 36,
    "p95rank": 19,
    "slaHours": 40,
    "slaType7": "missed",
    "slaNearestRank": "met"
  },
  "ranks": {
    "merchantMedians": [
      16,
      18,
      24,
      22,
      30,
      30
    ],
    "ranks": [
      1,
      2,
      4,
      3,
      5.5,
      5.5
    ],
    "percentileRanks": [
      0,
      20,
      60,
      40,
      90,
      90
    ]
  },
  "frequency": [
    {
      "lo": 0,
      "hi": 24,
      "count": 10,
      "share": 0.5
    },
    {
      "lo": 24,
      "hi": 48,
      "count": 9,
      "share": 0.45
    },
    {
      "lo": 48,
      "hi": null,
      "count": 1,
      "share": 0.05
    }
  ],
  "histogram": {
    "width12": [
      0,
      10,
      8,
      1,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      0,
      1
    ],
    "width48": [
      19,
      0,
      0,
      0,
      1
    ]
  },
  "ecdf": {
    "at24": 0.65,
    "at36": 0.95,
    "at48": 0.95
  },
  "box": {
    "five": [
      12,
      18,
      23,
      26.5,
      220
    ],
    "iqr": 8.5,
    "fences": [
      5.25,
      39.25
    ],
    "whiskers": [
      12,
      36
    ],
    "outliers": [
      220
    ]
  }
}

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

What the demo does

An operations dashboard of payout settlement delays: twenty synthetic payouts across six merchants, one held for 220 hours. Every module 04 lesson becomes one summary on the dashboard, and one extreme delay shows why the mean is not what a typical customer experiences.