35 min

Project: the rails simulator

One synthetic payment set, three settlement engines, two measurements - liquidity used and delay accepted - plus a gridlock you cause and then cure.

Where you are. Module 1 ended with miniledger v1, importable and proven over a scripted day; this module has spent fifteen lessons wiring rails onto it. You drew the map and split clearing from settlement, then built the three engines: RTGS settling one payment at a time in reserves, with its intraday liquidity bill, its liquidity-delay frontier and its gridlock cure; the deferred-net batch with its cutoffs, returns and reversals; the instant rail with its prefunded always-on finality and its scoreboard. Cards, wallets and overlays turned out to be riders on those same engines, and ISO 20022 gave every rail one message language. What you have never done is run the engines against each other. This project does exactly that: one seeded day, three rails, one table.

One Monday, three machines

The country you have been building has grown to six banks: Alder, Birch and Cedar from module 1, joined by Damson, Elm and Fir. Over one Monday, midnight to midnight, 300 payments will ask to move between them - some in the small hours, most across the working day, a stubborn few after everything with a closing time has closed. The amounts are stylised, round tens with nothing larger than 490, so every diff reads at sight. Nothing about the day is exotic, and that is deliberate: the question is not whether the payments settle. Every rail this module built can settle all of them. The question is what each rail charges for the privilege - in cash pushed through reserves, in minutes waited, in evenings stranded - and the only honest way to ask it is to run the same Monday three times, once per engine, and put the answers in one table.

The idea in one paragraph

A module that built three settlement engines owes you the comparison, and a comparison is only honest on one input. So the project fixes the day - a seeded fixture of 300 payments among six banks, gross value 73,550 stylised units - and drives it through all three engines: RTGS settling each payment in full the moment it arrives inside opening hours, the deferred-net batch holding everything for two cutoffs and settling only differences, the instant rail settling every arrival on the spot with the night included. Four measurements per rail: cash moved through reserves, mean delay to finality, payments settled by the day’s end, and whether one minute to midnight still works. The rails disagree on all four - that disagreement is the design space this whole module has been mapping - and they must agree on exactly one thing: every customer’s closing balance. That agreement is the milestone’s real assertion, and the last line your script prints.

The brief

Open code/project_rails.py. The fixture is make_fixture(): 300 payments, each an arrival minute, an amount and a payer-payee pair of banks, drawn from a seeded generator and sorted by arrival. Run it a thousand times and the same Monday comes out, which is what makes the comparison repeatable. build_world() endows each bank’s one customer with 100,000 - deliberately generous, so no engine queues for want of reserves today. The table below measures what the rails are, not how they suffocate; the suffocation gets its own section afterwards.

The engines are the module’s three, compact, and the harness names them in one list:

ENGINES = [
    ("RTGS", run_rtgs, rtgs_settle_time),
    ("deferred net", run_dns, dns_settle_time),
    ("instant", run_instant, instant_settle_time),
]

Each engine settles the day on its own fresh World, through the same submit/settle API you have called since module 1. The paired settle-time function is the rail’s clock: it maps an arrival minute to the minute finality lands. RTGS is open 08:00 to 17:00 - minutes 480 to 1020 - so in-hours arrivals settle on the spot, early risers wait for opening, and anything after the close waits for tomorrow’s. The deferred-net rail settles only at its two cutoffs, 12:00 and 17:00; miss the last one and the payment rides tomorrow’s first cycle. The instant rail’s clock is the identity function: finality the minute the payment arrives. And the batch engine moves cash differently, not just later: each cycle posts every customer leg in full but moves reserves only by each bank’s net position, with assert_world() run at the end of every cycle - lesson 7’s machinery, now one function among three.

Four disagreements, one agreement

Complete run_comparison and the day prints as a table. These are the seeded fixture’s numbers, deterministic on every machine:

railcash movedmean delaysettled by cutoffworks at midnight
RTGS73,550268.4 min224/300no
deferred net8,110471.8 min224/300no
instant73,5500.0 min300/300yes

n = 300 trials · one seeded Monday, six banks, three engines

Read it column by column. Cash moved: the two gross rails tie at 73,550, the fixture’s full gross value, because gross means every payment moves its whole amount through reserves. The batch rail moved 8,110 - roughly a ninth. Module 1 called netting compaction; this column is compaction with a number on it, and it is the whole reason everyday payments are cheap. Mean delay: zero for instant, by construction. RTGS posts 268.4 minutes despite settling in-hours arrivals instantly - hold that thought for one paragraph. The batch rail’s 471.8 is the price of quantising the day into two cutoffs: a nine-o’clock payment waits three hours for the noon cycle even when the rail is idle. Settled by cutoff counts finality landing before the day ends. Works at midnight probes each clock with a payment at minute 1439 - one minute to midnight - and asks whether finality arrives within five minutes.

Those stranded evenings also explain RTGS’s mean delay. An in-hours payment settles in zero minutes, and only 112 of the 300 arrive in hours. Another 112 arrive before opening and queue for 08:00, and the 76 evening arrivals wait overnight - a 23:00 arrival sits nine hours for tomorrow’s opening. Average all three groups and the “immediate” rail posts 268.4 minutes. The rail is immediate; its day is not.

image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ 2000 3000 4000 5000 6000 7000 8000 9000 central bank money moved (the module fixture) 1 0 0 1 0 1 1 0 2 mean delay, minutes (log) RTGS DNS batch instant
three rails on the liquidity-delay plane

The figure places the three rails on the plane lesson 5 built, liquidity used against delay accepted. The two gross rails sit at the liquidity-hungry end, split by their hours: RTGS pays for immediacy inside a business day, the instant rail pays more - liquidity parked around the clock, waiting for payments that may not come - to delete the concept of a closing time. The batch rail sits alone in the opposite corner, cheapest on cash and longest on delay. No rail sits in the empty corner, cheap and immediate at once, because lesson 5 showed why nothing can: liquidity and delay trade off along a frontier, and each rail is one chosen point on it.

The day at thin reserves

The comparison above endowed the banks generously on purpose. The milestone’s other act cuts the endowment and watches RTGS change character. At thin reserves an RTGS payment can arrive faster than the reserves to settle it, so a queue forms - lesson 5’s machinery - and the day develops a shape:

  1. first half of the day: the queue builds
  2. the full day: the evening inflows drain it
The RTGS queue over one day at thin reserves: morning backlog, evening drain

The morning is a backlog: outgoing payments wait on incoming reserves that have not arrived yet. The evening is the relief: inflows land, the queue drains, the day still closes clean. Cut reserves further and the drain never comes - every bank waiting to be paid before it can pay, the cycle lesson 6 named gridlock - and first-in-first-out cannot break it, because the payment at the head of every queue is exactly the one its bank cannot afford. What breaks it is lesson 6’s multilateral offsetting pass: find the cycle, settle it simultaneously, and value that no bank could move alone settles for almost no liquidity at all. You caused and cured that deadlock in lesson 6, value settled before and after on the screen; the milestone claims that act as its second half, and the filmstrip is the record of the thin-but-still-solvent day one notch above it.

The milestone, restated

The module README’s spec, clause by clause. Assemble the rails simulator on miniledger v1: one deterministic synthetic payment set - several hundred payments among six banks over a simulated day; here, 300 among six. Run it through the three engines built in this module: immediate-settle RTGS, deferred multilateral-net batch with two cutoffs, prefunded instant. For each rail, measure peak intraday liquidity used and the payment-delay distribution - lesson 5’s two measurements, the axes of the plane above - and plot all three rails as points on that frontier. Then cut opening reserves until the RTGS queue gridlocks, show the queue cannot drain under first-in-first-out, and clear it with lesson 6’s multilateral offsetting pass, reporting value settled before and after. Done when all three runs finish on a laptop CPU in under a minute with every double-entry invariant holding - which they do here by construction, because every engine posts through the miniledger, every posting re-asserts its sheet, and the netting cycles re-run assert_world() besides.

Check yourself

1. Instant and RTGS tie on cash moved at 73,550, yet lesson 9 called the instant rail liquidity-hungry. What cost does the cash-moved column not see?

The stock, as opposed to the flow. Cash moved counts what crossed reserve lines during the day; prefunding is money parked before the day begins and held around the clock whether payments come or not. RTGS needs a payment’s full value at its in-hours settlement moment; the instant rail needs liquidity positioned at 03:00 on a Sunday too. That is why the plane’s axis is liquidity used rather than cash moved - on cash moved alone, the two gross rails look identical, and their liquidity stories are not.

2. RTGS settles an in-hours payment the moment it arrives, yet its mean delay is 268.4 minutes. Where does the delay live, and what machine do you get if you delete it?

It lives entirely in the closed hours. Only the in-hours arrivals - 112 of 300 on this fixture - settle in zero minutes; the 112 before opening queue for 08:00, and the 76 after the close wait overnight, several hundred minutes each. Averaged together, that is the 268.4. Delete the closing time - run the same gross, immediate engine around the clock - and the delay collapses to zero. That machine is the instant rail, and question 1 is its price tag.

3. RTGS and deferred net both settle exactly 224 of 300 by the day’s end. Why is the tie guaranteed rather than lucky?

Both rails close at the same minute. Any payment arriving by 17:00 reaches finality inside the day on either rail - immediately on RTGS, by the next cutoff on the batch rail - and any payment after 17:00 strands overnight on both. So the two counts are the same count: arrivals by minute 1020, of which this fixture has 224. The column measures the closing time, and the two rails share one; only deleting the close, as the instant rail does, changes the number.

4. Why are the three closing-balance snapshots guaranteed to agree, rather than happening to on this fixture?

Because a closing balance is the opening balance plus the signed sum of that customer’s payments, and the fixture fixes that sum before any engine runs. Every engine settles every payment in full eventually - tomorrow’s opening and tomorrow’s cycle included - and netting cancels only reserve movements, never customer legs: the cycle posts every customer entry whole. Ordering, batching and netting change the path money takes between the same endpoints; double entry conserves the endpoints. A disagreeing snapshot therefore means a rail broke a leg, and the invariants would already have raised.

Do this

Thirty-five minutes, from module-02-domestic-rails. Open code/project_rails.py: the fixture, the three engines and their clocks are written; run_comparison is yours. Work the TODO(you) in order: run each engine in ENGINES on the fixture; compute the mean delay; count how many finality times land before the day ends; probe the clock with a payment at minute 1439 and answer yes if finality arrives within five minutes; and snapshot customer_balances(world) - the truth the rails must share. Return the rows and the three snapshots.

python3 code/project_rails.py

As shipped, the starter prints the fixture line and stops at NotImplementedError. Done right, it prints the table from this lesson, then five assertions read the module back to you - RTGS moves gross value, netting moves less, instant is gross with zero delay, only always-on strands nothing, and the snapshots agree - and the run ends with the line

three rails, one ledger, one truth: the balances agree

The whole thing finishes in well under a second; the milestone’s minute is a generous ceiling. Then take the docstring’s invitation: if your own engine wrappers from lessons 3, 7 and 9 are importable, swap them in for the compact ones here. The measurements must not change - which makes the swap a differential test of your code against the module’s. The completed version is solutions/project_rails.py; compare when you are green.

What you can now do. You can drive one day through three rails and read the result both ways: four columns of disagreement - cash moved, delay accepted, evenings stranded, midnight answered - and one row of forced agreement, the balances. The disagreement is the design space; it is why lesson 1’s map had several rails on it and why a country routes the trillion-unit obligation and the coffee onto different machinery. The agreement is the floor under all of it: three machines, one ledger, one truth. Module 2’s milestone is met - the simulator, the measurements, the frontier, the gridlock caused and cured - all on miniledger v1, which you extended and never edited. You leave with the miniledger as a library, the rails simulator as its proof, and finality, liquidity cost and gridlock understood domestically. Module 3 sends a payment where none of this reaches: across a border, where the two banks share no central bank and no ledger, the message and the money part company, and the exposure sitting quietly inside the netted batch - cleared, not yet settled - stops being a footnote and becomes the story.

What you can now do

You can drive one day through three rails and prove they agree on the balances while disagreeing on everything else.