Building a Clearinghouse Netting Batch in Python

This page builds the batch job that takes a matrix of bilateral gross obligations between transit operators and reduces it to the smallest set of net settlement transfers that leaves every operator whole. A network of a dozen operators can accumulate a hundred directed claims a day; paid gross, that is a hundred bank transfers and a hundred reconciliations. Multilateral netting collapses them to at most one payment per operator, and the batch here does it in Decimal with a conservation proof that total money paid equals total money received. It is a deep-dive within Inter-Agency Settlement Clearing, inside Revenue Reconciliation & Settlement.

The input is the obligation matrix the parent topic assembles; the individual claims inside it originate upstream — corridor shares from Prorating Shared-Corridor Revenue Across Operators and cross-operator transfer payouts, among others. This batch does not care where a claim came from; it cares only that the matrix nets to zero and that the transfers it emits reproduce every operator’s net position exactly.

Netting Flow: Gross Graph to Net Graph

A gross obligation matrix is a directed graph where every edge is a claim. Multilateral netting first collapses each operator’s inbound and outbound edges into one signed net position, then rebuilds the minimal graph that realizes those positions — debtors pay creditors directly, and any circular debt simply cancels. A cycle of mutual obligations that would have moved cash three times settles with a single transfer, or none.

Multilateral netting reduces a gross obligation cycle to one transfer On the left, a gross graph of three operators A, B and C carries three directed claims: A owes B 100, B owes C 100, and C owes A 60. Multilateral netting collapses these into net positions, where B nets to zero, A is a net debtor of 40, and C is a net creditor of 40. On the right, the net graph realizes those positions with a single transfer of 40 from A to C, replacing three gross claims with one settlement payment. Gross obligation graph Net settlement graph 100 100 60 A B C multilateral netting 3 gross claims → 1 transfer 40 A -40 B 0 C +40

The reduction is bounded: netting NN operators always settles in at most N1N - 1 transfers, because each transfer zeroes out at least one operator’s position. The batch does not search for a provably global minimum — that is NP-hard once you weight transfers by cost — but the greedy debtor-to-creditor match reaches the N1N - 1 bound in practice, and for a clearinghouse the operative win is going from a dense claim matrix to a sparse payment list, not shaving a last transfer.

Step 1 — Collapse the Gross Matrix to Net Positions

The batch takes the bilateral gross matrix as a mapping from (debtor, creditor) to a Decimal cent amount. Every operator’s net position is what it is owed minus what it owes; creditors come out positive, debtors negative. Before doing anything with the positions, assert they sum to zero — a matrix that does not conserve money cannot be settled, and catching it here is far cheaper than discovering it after transfers are cut.

from __future__ import annotations

import logging
from collections import defaultdict
from dataclasses import dataclass
from decimal import Decimal
from typing import Mapping

logger = logging.getLogger("transit.settlement.netting")

ZERO = Decimal("0")


class NettingError(Exception):
    """Base exception for the netting batch."""


class ConservationError(NettingError):
    """Gross matrix or emitted transfers failed to conserve money."""


@dataclass(frozen=True)
class Transfer:
    payer: str
    payee: str
    amount_cents: Decimal


def net_positions(matrix: Mapping[tuple[str, str], Decimal]) -> dict[str, Decimal]:
    """Collapse a directed gross obligation matrix into one signed net
    position per operator (creditor positive, debtor negative)."""
    positions: dict[str, Decimal] = defaultdict(lambda: ZERO)
    for (debtor, creditor), amount in matrix.items():
        if amount < ZERO:
            raise NettingError(f"Gross amount {amount} for {debtor}->{creditor} must be non-negative.")
        if debtor == creditor:
            continue  # a self-claim never moves money
        positions[debtor] -= amount   # owing reduces position
        positions[creditor] += amount  # being owed raises it

    total = sum(positions.values(), ZERO)
    if total != ZERO:
        raise ConservationError(f"Gross matrix nets to {total} cents, not zero.")
    return dict(positions)

Folding the matrix in a single pass is what makes the conservation check trustworthy: every cent is added to exactly one creditor and subtracted from exactly one debtor, so a non-zero sum can only mean a malformed input. Positions that come out at exactly zero — operator B in the diagram — are settled operators that neither pay nor receive.

Step 2 — Greedy Debtor-to-Creditor Settlement

With signed positions in hand, the minimal transfer set is produced by repeatedly matching the operator that owes the most against the operator that is owed the most, moving the smaller of the two amounts, and retiring whichever side reaches zero. Two heaps keep the largest debtor and largest creditor at the front. Each iteration zeroes at least one operator, so the loop runs at most N1N - 1 times.

import heapq
from typing import List


def compute_net_transfers(matrix: Mapping[tuple[str, str], Decimal]) -> List[Transfer]:
    """Reduce a bilateral gross obligation matrix to a minimal set of net
    settlement transfers, preserving total paid == total received."""
    positions = net_positions(matrix)

    # Min-heaps holding negative magnitudes, so the front is the largest debt / credit.
    debtors: list[tuple[Decimal, str]] = []    # (position, op) with position < 0
    creditors: list[tuple[Decimal, str]] = []  # (-position, op) with position > 0
    for op, amt in positions.items():
        if amt < ZERO:
            heapq.heappush(debtors, (amt, op))     # amt is negative; pops the largest debt first
        elif amt > ZERO:
            heapq.heappush(creditors, (-amt, op))  # negate so the largest credit pops first

    transfers: List[Transfer] = []
    while debtors and creditors:
        neg_debt, debtor = heapq.heappop(debtors)      # neg_debt <= 0
        neg_cred, creditor = heapq.heappop(creditors)  # neg_cred <= 0
        debt = -neg_debt   # positive magnitude the debtor still owes
        cred = -neg_cred   # positive magnitude the creditor is still owed

        pay = min(debt, cred)
        transfers.append(Transfer(payer=debtor, payee=creditor, amount_cents=pay))

        debt_left = debt - pay
        cred_left = cred - pay
        if debt_left > ZERO:
            heapq.heappush(debtors, (-debt_left, debtor))
        if cred_left > ZERO:
            heapq.heappush(creditors, (-cred_left, creditor))

    _assert_conservation(positions, transfers)
    logger.info("Netted %d operators into %d transfers.", len(positions), len(transfers))
    return transfers


def _assert_conservation(positions: Mapping[str, Decimal], transfers: List[Transfer]) -> None:
    """Prove the transfers reproduce every net position and balance overall."""
    realized: dict[str, Decimal] = defaultdict(lambda: ZERO)
    paid = ZERO
    for tr in transfers:
        if tr.amount_cents <= ZERO:
            raise ConservationError(f"Non-positive transfer {tr.payer}->{tr.payee}.")
        realized[tr.payer] -= tr.amount_cents
        realized[tr.payee] += tr.amount_cents
        paid += tr.amount_cents

    for op, want in positions.items():
        if realized.get(op, ZERO) != want:
            raise ConservationError(
                f"Operator {op} net {realized.get(op, ZERO)} != required {want}."
            )
    received = sum(tr.amount_cents for tr in transfers)
    if paid != received:
        raise ConservationError(f"Total paid {paid} != total received {received}.")

Because every amount stays a Decimal and min never fabricates a fraction, the transfers reproduce the net positions to the cent. The _assert_conservation pass is the batch’s proof of correctness: it re-derives each operator’s realized position from the emitted transfers and refuses the batch unless every one matches its required net and the grand totals balance. A settlement that cannot prove total-in equals total-out never leaves the job.

Validation & Test Cases

Two cases matter most: a circular debt that nets away almost entirely, and a case where conservation would break if the arithmetic drifted. The cycle proves the reduction; the assertions prove nothing was invented.

from decimal import Decimal

# Cycle: A owes B 100, B owes C 100, C owes A 60 -> nets to A pays C 40.
cycle = {
    ("A", "B"): Decimal("100"),
    ("B", "C"): Decimal("100"),
    ("C", "A"): Decimal("60"),
}
transfers = compute_net_transfers(cycle)
assert transfers == [Transfer(payer="A", payee="C", amount_cents=Decimal("40"))]
# Three gross claims collapsed to a single transfer.
assert len(transfers) == 1

# Perfect cycle: equal mutual debts settle with zero cash movement.
perfect = {("A", "B"): Decimal("50"), ("B", "C"): Decimal("50"), ("C", "A"): Decimal("50")}
assert compute_net_transfers(perfect) == []

# Conservation across a wider matrix: total paid equals total received.
wide = {
    ("A", "B"): Decimal("300"),
    ("A", "C"): Decimal("120"),
    ("B", "C"): Decimal("80"),
    ("C", "A"): Decimal("50"),
}
result = compute_net_transfers(wide)
# A is the only net debtor: owes 300 + 120 = 420, owed 50 -> net -370.
# B nets +220, C nets +150; A's payments fan out to both creditors.
total_out = sum(t.amount_cents for t in result)
assert total_out == Decimal("370")
assert len(result) <= 2  # at most N-1 transfers for 3 active operators

# Broken matrix: a negative gross amount is rejected outright.
try:
    compute_net_transfers({("A", "B"): Decimal("-10")})
    raise AssertionError("expected NettingError")
except NettingError:
    pass

The cycle case is the one that sells multilateral netting: three separate bank transfers become one, and the perfect cycle needs no cash at all. The wide case checks the invariant that actually protects a clearinghouse — the sum of every payment out equals the sum of every payment in, 320 cents either way — so no run can quietly move a net cent into or out of the system.

Edge Cases & Gotchas

  • Already-balanced operators. An operator whose credits exactly cancel its debits nets to zero and must appear in neither heap; pushing a zero position would emit a spurious zero-amount transfer.
  • Single dominant debtor. When one operator owes everyone, the greedy match fans its position out across creditors in descending order — still bounded by N1N - 1 transfers, one per creditor it must satisfy.
  • Rounding is upstream. This batch assumes whole-cent Decimal inputs. If the matrix carries fractional cents from a bad proration, the netting arithmetic stays exact but the conservation of a broken input is meaningless — reject fractional cents at the matrix boundary, not here.
  • Determinism under ties. Two debtors owing the identical amount are ordered by the heap’s tuple comparison, which falls through to the operator id. Keep that stable so a re-run produces the same payment list and an auditor sees identical transfers.

Integration Note

This batch is the settlement-producing stage of Inter-Agency Settlement Clearing: the parent engine assembles and conservation-checks the gross obligation matrix, and this job turns that matrix into the payment list the clearinghouse actually executes. Its closest sibling is Prorating Shared-Corridor Revenue Across Operators, which produces many of the claims inside the matrix; because that proration guarantees each corridor’s shares sum exactly to the collected fare, the matrix arrives here already conserving money, and this batch’s zero-sum assertion is the second, independent check that a proration or transfer bug never reaches a bank instruction.

FAQ

Does the greedy match always find the fewest possible transfers?
It reliably reaches the practical bound of N - 1 transfers for N active operators, because every iteration zeroes at least one operator's position. Finding a provably minimum-count settlement in every pathological case is NP-hard once you also weight transfers by fee or currency, so production clearinghouses use exactly this kind of greedy debtor-to-creditor match. For a transit network the win is collapsing a dense claim matrix into a sparse payment list, which the greedy pass achieves.
Why assert conservation twice — on the matrix and on the transfers?
Because they catch different failures. The matrix check catches a malformed input where the gross claims themselves do not net to zero, usually a proration or reversal bug upstream. The transfer check catches a defect in the netting itself — a transfer that does not reproduce an operator's net position, or totals that do not balance. Only when both hold is the payment list safe to hand to a bank, and each check re-derives its figures independently so a shared bug cannot pass both.
Can a net transfer ever move a fractional cent?
No, provided the input matrix carries whole-cent Decimal amounts. Every transfer amount is a min of two existing positions, and subtracting Decimal cents never introduces a fraction, so the outputs are whole cents whenever the inputs are. If a fractional cent appears in a transfer it means a fractional cent entered the matrix upstream — reject it at the proration boundary, where the largest-remainder method is supposed to have already resolved it.
How should the batch handle an operator that owes and is owed nothing?
It falls out naturally: an operator with a zero net position is excluded from both the debtor and creditor heaps, so it neither pays nor receives and never appears in the transfer list. This is correct — a fully offsetting operator, like the middle node in a debt cycle, is already settled. Emitting a zero-amount transfer for it would only add noise to the clearinghouse's payment file.

Part of Inter-Agency Settlement Clearing, within Revenue Reconciliation & Settlement.