Cash-Flow Forecasting for Bond Covenants: Weekly Draw Projections in Python

A completion guarantor does not underwrite a picture on the promise that it will finish; it underwrites the specific weeks in which cash leaves the production account and the specific covenants that cap how much of it may. When payroll runs, vendor settlements, and contingency draws are projected as a rough monthly total, the production discovers a liquidity floor breach or an exhausted draw facility the week it happens — far too late to renegotiate a tranche or defer a payment. The remedy is to model the picture’s forward cash position as a deterministic, week-by-week schedule and to test every projected week against the thresholds the bond agreement actually sets: a cash-on-hand minimum, a cumulative draw cap, and the weekly burn limits a guarantor writes into its side letter. This page specifies that forecasting subsystem — its inputs, its timezone-aware period boundaries, its Decimal-exact draw math, and the hashed snapshots that let a guarantor recompute any week months later — as one component of the Completion Bond Reporting & Guarantor Analytics reference architecture.

Prerequisites and Expected Inputs

The implementation targets Python 3.11+, both for the standard-library zoneinfo module and for modern union-type syntax. The dependency set is deliberately narrow: Pydantic v2 validates every payload at the boundary through model_validate and field_validator, per the Pydantic v2 reference; the standard-library decimal, hashlib, and zoneinfo modules supply currency-safe arithmetic, deterministic snapshot hashing, and timezone-aware period math; and at production scale, polars streams committed-cost extracts and SQLAlchemy persists the append-only snapshot ledger. Money is never a float. As the Python decimal module documentation makes explicit, only fixed-point arithmetic under an explicit rounding context reproduces the figures a guarantor’s own model computes; a fractional-cent drift, rolled forward across twenty projected weeks, becomes exactly the variance an examiner asks a production accountant to reconcile.

This engine consumes obligations that are already clean. The raw feeds — accounts-payable aging, approved purchase orders, the payroll register from the paymaster, and the contingency drawdown schedule — are normalized upstream by the Cost Ingestion & Data Parsing Workflows subsystem before they reach any forecast. Each obligation keys to exactly one budget line through Cost Code Standardization, so a projected outflow rolls up into the category a guarantor reviews rather than an untraceable lump. The single input this subsystem accepts is a validated CashFlowLine — a scheduled outflow with a category, a Decimal amount, and a timezone-aware due instant — plus a CovenantSet describing the thresholds in force and an opening cash position. Its output is an ordered list of immutable WeeklyProjection records and a SHA-256 fingerprint over the whole run, so the forecast a producer presented on Monday is byte-for-byte the forecast a guarantor recomputes on demand.

Architecture: A Forecast Is a Bucketing Pipeline, Not a Spreadsheet Formula

The common design error is to sum obligations by calendar month and eyeball the result against a covenant. That approach hides the two things a guarantor cares about most: when within a period cash actually leaves, and whether the running position ever dips below the floor between reporting dates. A production-grade forecast instead assigns every obligation to a precise week bucket using timezone-aware boundaries, rolls the cash position forward through those buckets one week at a time, derives the draw each week requires to stay above the liquidity floor, and tests the resulting position against every covenant before emitting an immutable, hashed snapshot.

The pipeline below shows a validated cash-flow line entering the weekly bucketer, feeding the forecast engine that computes each week’s required draw and closing position, and reaching a covenant gate that routes a compliant week to a hashed snapshot and a breaching week to a guarantor alert — never guessing which side a marginal week lands on.

Weekly cash-flow forecasting pipeline for bond covenants Validated cost inputs — payroll runs, vendor payments, and contingency draws — enter a weekly bucketer that assigns each obligation to a timezone-aware week using zoneinfo. The forecast engine rolls the cash position forward week by week, computing each week's required draw in Decimal. A covenant-test decision then routes the week: a compliant week is written to a hashed, append-only snapshot, while a covenant breach is routed to a guarantor alert. The marginal week is never guessed; it is resolved by the explicit covenant test. Cost inputs payroll · vendor · draws Weekly bucketer zoneinfo week starts Forecast engine Decimal draw math Covenant tests? Compliant hashed snapshot Covenant breach alert guarantor pass breach

Two properties make this design defensible rather than merely convenient. First, bucketing is total: every obligation lands in exactly one week, so a payroll run that settles at 11pm on a Friday in the shoot zone can never drift into the wrong reporting week and quietly relieve a covenant test. Second, the projection is a pure function of its inputs — the same lines, covenants, and opening position always produce the same schedule and the same hash — which is what lets a production accountant replay a disputed forecast deterministically instead of re-deriving it by hand. When a financing tranche is delayed and the exact draw availability for a future week is uncertain, the forecast does not invent a number; it books the conservative, over-drawing assumption and flags the week for reconciliation once the tranche confirms. The Cost-to-Complete Projections subsystem supplies the estimate-at-completion that anchors the far end of the horizon, and the Contingency Drawdown Tracking subsystem owns the drawdown schedule this forecast consumes as one of its outflow streams.

Core Implementation

The reference engine models each obligation as a frozen Pydantic v2 object validated at the boundary, assigns it to a week bucket through a timezone-aware anchor, rolls the cash position forward, and emits a hashed provenance record for the whole run. Monetary fields are Decimal; period boundaries are computed on aware instants normalized to the covenant zone; a line whose amount is a float or whose zone is unresolvable is rejected before it can enter the schedule.

import hashlib
import json
import logging
from collections import defaultdict
from datetime import date, datetime, timedelta
from decimal import Decimal, ROUND_HALF_UP
from enum import Enum
from typing import Any
from zoneinfo import ZoneInfo

from pydantic import BaseModel, ConfigDict, Field, field_validator

logger = logging.getLogger("bond_cash_flow_forecast")

CENTS = Decimal("0.01")


class OutflowCategory(str, Enum):
    PAYROLL = "payroll"
    VENDOR = "vendor"
    CONTINGENCY = "contingency"


class CashFlowLine(BaseModel):
    """One scheduled cash outflow entering the forecast."""
    model_config = ConfigDict(frozen=True)

    line_id: str
    category: OutflowCategory
    amount: Decimal = Field(gt=Decimal("0"))   # positive outflow
    due_at: datetime                            # timezone-aware payment instant

    @field_validator("amount", mode="before")
    @classmethod
    def reject_float(cls, v: Any) -> Decimal:
        if isinstance(v, float):
            raise ValueError("amount must be str/Decimal, never float")
        return Decimal(str(v))

    @field_validator("due_at")
    @classmethod
    def require_aware(cls, v: datetime) -> datetime:
        if v.tzinfo is None or v.utcoffset() is None:
            raise ValueError("due_at must be timezone-aware")
        return v


class CovenantSet(BaseModel):
    """Thresholds a completion guarantor sets for the reporting horizon."""
    model_config = ConfigDict(frozen=True)

    covenant_zone: str = "America/Los_Angeles"   # IANA zone for week anchoring
    opening_cash: Decimal
    min_cash_on_hand: Decimal                    # liquidity floor per week
    cumulative_draw_cap: Decimal                 # facility ceiling over horizon
    weekly_draw_limit: Decimal | None = None     # optional single-week burn cap

    @field_validator(
        "opening_cash", "min_cash_on_hand", "cumulative_draw_cap",
        "weekly_draw_limit", mode="before",
    )
    @classmethod
    def coerce_money(cls, v: Any) -> Any:
        if v is None:
            return v
        if isinstance(v, float):
            raise ValueError("covenant amounts must be str/Decimal, never float")
        return Decimal(str(v))

    @field_validator("covenant_zone")
    @classmethod
    def zone_resolves(cls, v: str) -> str:
        ZoneInfo(v)   # raises on an unknown IANA id
        return v


class WeeklyProjection(BaseModel):
    model_config = ConfigDict(frozen=True)

    week_start: date
    payroll_out: Decimal
    vendor_out: Decimal
    contingency_out: Decimal
    total_outflow: Decimal
    opening_position: Decimal
    required_draw: Decimal
    closing_position: Decimal
    cumulative_draw: Decimal
    floor_ok: bool
    draw_cap_ok: bool
    weekly_limit_ok: bool

    @property
    def covenant_ok(self) -> bool:
        return self.floor_ok and self.draw_cap_ok and self.weekly_limit_ok


def _q(amount: Decimal) -> Decimal:
    return amount.quantize(CENTS, rounding=ROUND_HALF_UP)


def week_start(instant: datetime, zone: ZoneInfo) -> date:
    """The Monday of the ISO week the instant falls in, in the covenant zone."""
    local = instant.astimezone(zone)
    return (local - timedelta(days=local.weekday())).date()


class ForecastEngine:
    def __init__(self, covenants: CovenantSet) -> None:
        self.cov = covenants
        self.zone = ZoneInfo(covenants.covenant_zone)

    def _bucket(self, lines: list[CashFlowLine]) -> dict[date, dict[OutflowCategory, Decimal]]:
        buckets: dict[date, dict[OutflowCategory, Decimal]] = defaultdict(
            lambda: defaultdict(lambda: Decimal("0"))
        )
        for line in lines:
            wk = week_start(line.due_at, self.zone)
            buckets[wk][line.category] += line.amount
        return buckets

    def project(self, lines: list[CashFlowLine]) -> list[WeeklyProjection]:
        buckets = self._bucket(lines)
        position = self.cov.opening_cash
        cumulative = Decimal("0")
        projections: list[WeeklyProjection] = []

        for wk in sorted(buckets):
            cats = buckets[wk]
            payroll = _q(cats[OutflowCategory.PAYROLL])
            vendor = _q(cats[OutflowCategory.VENDOR])
            contingency = _q(cats[OutflowCategory.CONTINGENCY])
            total_out = _q(payroll + vendor + contingency)

            # Draw only what is needed to hold the floor after paying the week.
            shortfall = self.cov.min_cash_on_hand - (position - total_out)
            required_draw = _q(max(Decimal("0"), shortfall))
            closing = _q(position - total_out + required_draw)
            cumulative = _q(cumulative + required_draw)

            floor_ok = closing >= self.cov.min_cash_on_hand
            draw_cap_ok = cumulative <= self.cov.cumulative_draw_cap
            weekly_limit_ok = (
                self.cov.weekly_draw_limit is None
                or required_draw <= self.cov.weekly_draw_limit
            )

            proj = WeeklyProjection(
                week_start=wk,
                payroll_out=payroll,
                vendor_out=vendor,
                contingency_out=contingency,
                total_outflow=total_out,
                opening_position=position,
                required_draw=required_draw,
                closing_position=closing,
                cumulative_draw=cumulative,
                floor_ok=floor_ok,
                draw_cap_ok=draw_cap_ok,
                weekly_limit_ok=weekly_limit_ok,
            )
            projections.append(proj)
            if not proj.covenant_ok:
                logger.warning(
                    "covenant breach week=%s closing=%s cumulative_draw=%s",
                    wk.isoformat(), closing, cumulative,
                )
            position = closing
        return projections

    def snapshot_hash(self, projections: list[WeeklyProjection]) -> str:
        material = json.dumps(
            {
                "covenants": self.cov.model_dump(mode="json"),
                "weeks": [p.model_dump(mode="json") for p in projections],
            },
            sort_keys=True, separators=(",", ":"), default=str,
        ).encode("utf-8")
        return hashlib.sha256(material).hexdigest()

The frozen=True config makes both the incoming line and every emitted projection immutable once validated, so no downstream report can mutate a draw figure after it has been hashed. Because the snapshot serialization is canonical — sorted keys, no incidental whitespace, Decimal rendered through its string form — the SHA-256 fingerprint is stable across runs, and any tampering with a projected week changes it. The draw logic is deliberately conservative: required_draw is the smallest amount that holds the liquidity floor after the week’s obligations clear, so the forecast never overstates availability, and the cumulative_draw accumulator is what surfaces the week a picture would exhaust its facility. Payroll runs — whether the paymaster is settling a Screen Actors Guild – American Federation of Television and Radio Artists (SAG-AFTRA) performer register or an International Alliance of Theatrical Stage Employees (IATSE) crew timecard batch — enter the forecast as ordinary PAYROLL lines already cost-coded upstream, so the engine treats guild remittances and vendor invoices with identical arithmetic discipline.

Bond Covenant Specifics

A completion guarantor’s covenants are not generic financial ratios; they are the levers the bond company uses to keep a picture inside the envelope it insured. The engine models the three that appear most often in a bond agreement’s side letter, and it tests each independently so a report names the covenant that actually broke rather than a vague “out of compliance”:

  • Cash-on-hand minimum (liquidity floor). The production account must never close a week below a stated balance, so the guarantor is protected against a mid-shoot cash-out that would strand crew and vendors. The engine draws exactly enough to hold this floor and flags floor_ok=False only when a draw large enough to hold it is itself blocked by another covenant — which is the real failure state, not the floor alone.
  • Cumulative draw cap (facility ceiling). The bonded financing facility has a hard ceiling over the horizon. The cumulative_draw accumulator tests each week against that cap, so the forecast surfaces the exact week the picture would run out of committed money — the single most important number a producer can hand a guarantor before it becomes a funding emergency.
  • Weekly draw limit (burn cap). Some agreements cap the draw in any single week to smooth the guarantor’s own cash management. Modeled as an optional per-week test, it catches a spike — a large vendor settlement colliding with a payroll run in one week — that the cumulative cap alone would not.

Two realities shape all three. First, covenants are effective-dated: a bond amendment can lift a facility ceiling or tighten a floor mid-shoot, so the CovenantSet in force must be resolved by the reporting period, never assumed constant across the picture. Second, the forecast is only as trustworthy as its far horizon, which is why the estimate-at-completion feeding the last projected weeks is reconciled against the Guarantor Variance Reporting subsystem — a week that passes every covenant on an understated cost-to-complete is a false pass, and the variance report is what catches it before the guarantor does. Where a shortened turnaround or a forced call inflates a payroll run beyond the schedule, the exposure originates in the Guild Compliance & Rule Validation Automation engines and arrives here as a larger, already-validated PAYROLL line.

The distinction between a genuine breach and a marginal one is exactly what a guarantor pays for in this report. A week whose closing position lands one dollar above the floor is not the same risk as a week two hundred thousand dollars clear of it, and a forecast that only emits a boolean throws that gradient away. Because every WeeklyProjection carries the full closing_position and cumulative_draw alongside the pass-or-fail flags, a reader can rank the horizon by headroom and see the covenants tightening weeks before any of them actually break — the early-warning signal that lets a producer defer a discretionary vendor payment or accelerate an equity draw while both are still options rather than emergencies. A forecast that reports only the first breach has already waited too long.

Error Handling and Quarantine

Not every line belongs in a forecast, and a line that cannot be trusted must never silently relieve or trip a covenant. The discipline mirrors the boundary contract of the ingestion layer: bucketing handles placement; quarantine handles validity. A line whose amount is a float, whose due_at is naive, whose category is unmapped, or whose due instant falls outside the declared forecast horizon is not a candidate for any week bucket — it is quarantined before the projection runs.

Every quarantine event serializes the original payload verbatim, attaches the SHA-256 hash of that payload, records a machine-readable reason_code, and pushes the line to a reconciliation queue for human triage. The failing line never enters the schedule as a projected outflow; it enters a separate exception store, so the forecast stays clean while the discrepancy is preserved for review. This is the same quarantine contract enforced across the platform in Schema Validation & Error Handling: a rejected record carries a reason code, its original bytes, and its hash, so a production accountant can triage the one bad line without re-running the whole forecast.

Projection versus quarantine for an incoming cash-flow line An incoming cash-flow line hits a validity gate. On the projected side, a valid line is bucketed and projected into its week, and the resulting figure is written to an append-only covenant snapshot stamped with a SHA-256 hash. On the quarantine side, a line-validity failure — a float or naive amount, an unresolved IANA zone, or a due date outside the forecast horizon — is diverted to a separate quarantine store that keeps the payload verbatim with its SHA-256 hash and a reason_code. A dashed, barred link shows the quarantined line never enters the covenant snapshot. PROJECTED QUARANTINE Incoming cash-flow line Line valid? aware · Decimal valid invalid Bucket & project week Covenant snapshot append-only · SHA-256 Line-validity failure • float or naive amount • unresolved IANA zone • due date outside horizon Quarantine store payload verbatim · SHA-256 · reason_code never enters snapshot

When an input is delayed rather than wrong — a financing tranche whose exact availability has not confirmed, or a vendor invoice pending approval — the engine does not quarantine and does not guess. It books the conservative, over-drawing assumption for that week and flags the projection for reconciliation once the real figure lands, the same disciplined provisional pattern the Contingency Drawdown Tracking subsystem applies to a drawdown schedule. A forecast heavy with provisional weeks is itself a signal a producer should act on before the covenant that hides under it actually breaks.

Verification

A covenant forecast is only trustworthy if a guarantor can prove, after the fact, which inputs and which thresholds produced every projected week. Verification checks three artifacts, not one.

First, the projection set. Re-running project over the same lines and the same CovenantSet must yield an identical ordered list and an identical snapshot_hash — the idempotency check that lets a producer replay a disputed forecast deterministically. Every monetary field must be Decimal, and closing_position for each week must equal opening_position − total_outflow + required_draw to the cent, so the arithmetic ties out independently of the engine that produced it.

Second, the covenant results. Each WeeklyProjection carries an explicit floor_ok, draw_cap_ok, and weekly_limit_ok, so a report names the exact covenant a week breached and the exact week it first breached. The cumulative_draw must be monotonic and non-decreasing across the horizon; a decrease signals a bucketing error where a week was projected out of order.

Third, the snapshot provenance. The run’s SHA-256 hash, the resolved covenant_zone, and the covenant thresholds in force pin the whole forecast to a reproducible input state. A completion-bond examiner reads this as the provenance of the forward position, so none of it is optional. A minimal harness confirms the invariants:

def verify(engine: ForecastEngine, lines: list[CashFlowLine]) -> None:
    a = engine.project(lines)
    b = engine.project(lines)
    assert engine.snapshot_hash(a) == engine.snapshot_hash(b), "non-deterministic run"
    running = Decimal("0")
    for wk in a:
        assert isinstance(wk.closing_position, Decimal), "money must be Decimal"
        expected = wk.opening_position - wk.total_outflow + wk.required_draw
        assert wk.closing_position == expected, "closing position must tie out"
        assert wk.cumulative_draw >= running, "cumulative draw must be monotonic"
        running = wk.cumulative_draw

The focused single-function walkthrough in Forecasting Weekly Cash Flow for Covenant Tests builds the same weekly schedule and per-week covenant evaluation for a single facility, with a deeper treatment of the Daylight Saving Time boundary that decides which week a Friday-night payroll run lands in. Engineered with timezone-aware bucketing, Decimal-exact draw math, effective-dated covenants, and conservative provisional defaults, a cash-flow forecast turns the most anxious question on a production — when do we run out of money? — into a reproducible, auditable schedule a completion guarantor can recompute to the cent.

Up: Completion Bond Reporting & Guarantor Analytics