Forecasting Weekly Cash Flow for Covenant Tests
The narrow problem this page solves is deciding, for one financing facility, which future week a production first breaches a bond covenant — and proving that answer to the cent months later. Given a list of scheduled outflows, each with a timezone-aware due instant, and a covenant set that names a cash-on-hand floor and a cumulative draw cap, the task is to assign every outflow to the correct week, roll the cash position forward through those weeks, and record a pass-or-fail verdict per week that a completion guarantor can reproduce from the archived record alone. The edge that makes it unforgiving is the week boundary: a payroll run that settles late on a Friday in the shoot zone belongs to a different reporting week than a naive timestamp would place it in, and getting that assignment wrong moves an entire week’s obligations across a covenant test. This walkthrough builds the check as a pure function over timezone-aware instants with Decimal money throughout, extending the engine documented in Cash-Flow Forecasting for Bond Covenants.
Prerequisites and Context
This page assumes the outflows have already been cost-coded and typed upstream and focuses entirely on the weekly bucketing, the forward roll, and the audit trail around a single facility’s forecast. It targets Python 3.11+ for the standard-library zoneinfo module, and uses the same deliberate dependency set as the rest of the Completion Bond Reporting & Guarantor Analytics reference architecture: decimal for every monetary value, zoneinfo for IANA timezone resolution (never a bare UTC offset), hashlib for the payload fingerprint, and Pydantic v2 (model_validate, field_validator) for the boundary schema. Outflows reach this function already normalized by the upstream Cost Ingestion & Data Parsing Workflows subsystem, and each keys to exactly one budget line through Cost Code Standardization, so a projected draw rolls up into the category a guarantor reviews. The covenant thresholds that matter are the ones the bond agreement sets: because a floor or a cap can change by amendment mid-shoot, treat both the liquidity floor and the draw cap as configurable rule parameters resolved by reporting period, never hardcoded constants.
Step-by-Step Implementation
The forecast begins at the boundary. A Pydantic v2 model rejects any due instant that is not timezone-aware, because a naive datetime is the single largest source of a silently misplaced week. Each outflow is then assigned to the Monday of its ISO week in the covenant zone, the weeks are rolled forward in order, and each week’s required draw and closing position are derived in Decimal before the covenant verdict is stamped.
from __future__ import annotations
import hashlib
import json
from collections import defaultdict
from datetime import date, datetime, timedelta
from decimal import Decimal, ROUND_HALF_UP
from zoneinfo import ZoneInfo
from pydantic import BaseModel, ConfigDict, Field, field_validator
CENTS = Decimal("0.01")
class Outflow(BaseModel):
"""One scheduled cash outflow for a single facility's forecast."""
model_config = ConfigDict(frozen=True)
ref: str
amount: Decimal = Field(gt=Decimal("0"))
due_at: datetime # timezone-aware payment instant
@field_validator("amount", mode="before")
@classmethod
def _no_float(cls, v: object) -> Decimal:
if isinstance(v, float):
raise ValueError("amount must be str/Decimal, never float")
return Decimal(str(v))
@field_validator("due_at")
@classmethod
def _must_be_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
def _q(amount: Decimal) -> Decimal:
return amount.quantize(CENTS, rounding=ROUND_HALF_UP)
def week_of(instant: datetime, zone: ZoneInfo) -> date:
"""Monday of the instant's ISO week, resolved in the covenant zone."""
local = instant.astimezone(zone)
return (local - timedelta(days=local.weekday())).date()
def forecast_weeks(
outflows: list[Outflow],
*,
covenant_zone: str,
opening_cash: Decimal,
min_cash_on_hand: Decimal,
cumulative_draw_cap: Decimal,
) -> list[dict]:
zone = ZoneInfo(covenant_zone)
buckets: dict[date, Decimal] = defaultdict(lambda: Decimal("0"))
for o in outflows:
buckets[week_of(o.due_at, zone)] += o.amount
position = opening_cash
cumulative = Decimal("0")
schedule: list[dict] = []
for wk in sorted(buckets):
total_out = _q(buckets[wk])
# Draw only enough to hold the floor after the week's obligations clear.
shortfall = 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 >= min_cash_on_hand
draw_cap_ok = cumulative <= cumulative_draw_cap
row = {
"week_start": wk.isoformat(),
"total_outflow": str(total_out),
"opening_position": str(position),
"required_draw": str(required_draw),
"closing_position": str(closing),
"cumulative_draw": str(cumulative),
"floor_ok": floor_ok,
"draw_cap_ok": draw_cap_ok,
"covenant_ok": floor_ok and draw_cap_ok,
}
canonical = json.dumps(row, sort_keys=True, separators=(",", ":")).encode()
row["week_sha256"] = hashlib.sha256(canonical).hexdigest()
schedule.append(row)
position = closing
return schedule
Because forecast_weeks reads its floor and cap from parameters and never touches the network, identical outflows always produce an identical schedule and identical per-week hashes. That determinism is what lets a guarantor’s examiner recompute a disputed week from the archived inputs alone, and it is why the required draw is defined as the smallest amount that holds the floor — the forecast never overstates how much committed money remains.
Reading the output tells the story a producer needs. Suppose the facility opens at 400,000, the floor is 250,000, and the cap is 1,000,000. A week carrying 600,000 of payroll and vendor obligations leaves a pre-draw position of −200,000, so required_draw is 450,000 — enough to clear the week and land exactly on the floor — and cumulative_draw steps to 450,000, comfortably under the cap. If the next week carries another 700,000 of obligations, its required draw pushes cumulative_draw past the 1,000,000 cap, draw_cap_ok flips to False, and the schedule names that specific week as the point the picture exhausts its committed money. That is the number a producer takes to the guarantor while there is still time to size a new tranche — not the surprise a monthly total would have hidden until the account ran dry.
The flow below assigns each outflow to its week, rolls the position forward, and resolves the covenant verdict before stamping the week.
Audit Trail Requirements
Every week in the schedule — including the weeks that pass — must be serialized to a write-once store so an examiner sees an unbroken forecast rather than only the breaching weeks. At minimum each persisted row must carry: the week_start in the covenant zone; the total_outflow, opening_position, required_draw, closing_position, and cumulative_draw as strings; the floor_ok, draw_cap_ok, and covenant_ok verdicts; and the week_sha256 fingerprint of the canonical row. Persist the schedule to append-only, write-once storage — object storage with an immutability lock, or an event-sourced table with no in-place update — before any covenant report is issued, so a re-forecast after new information leaves a replayable trail rather than overwriting the figure a guarantor was shown. A later revision is a new snapshot stamped with its own hash and inputs, never an edit of the posted one, so the forecast a bond release was granted against stays recoverable. This is the same deterministic audit discipline required across the sibling Guarantor Variance Reporting and Contingency Drawdown Tracking subsystems, which is why the bucketing and hashing belong in one shared module rather than duplicated per report.
Gotchas and Production Edge Cases
Daylight Saving Time week boundaries. The week a Friday-night payroll run lands in is decided by wall-clock arithmetic that DST quietly corrupts. A run scheduled for 23:30 on the Friday a region springs forward, computed with a naive datetime, can round into the following Monday and shift an entire payroll into the next reporting week — relieving that week’s covenant test and overstating the next one. Anchoring the due instant to an IANA zone through zoneinfo and taking the Monday of its local week keeps the assignment correct while the audit record still shows the true local time. Never derive the week from a bare UTC offset; the offset is exactly what changes at the transition.
Multi-location shoots. A picture that pays a New York second unit and a Los Angeles main unit in the same cycle has two shoot zones, but the covenant is written against one designated reporting zone. Bucket every outflow into that single covenant_zone — the one the bond agreement names — rather than each line’s local zone, or the same physical week splits across two reporting weeks and no covenant total ties out. Store the covenant zone on the forecast, as the function above does, and never infer it from the server clock. The same per-facility zone discipline governs the drawdown feed in Contingency Drawdown Tracking.
Idempotency on re-forecast. Because each week’s hash is deterministic and the store is append-only, re-running the forecast after a schedule change is safe only if the consumer deduplicates on (facility_id, week_start, week_sha256); without that guard a re-forecast double-writes weeks that did not change and an examiner cannot tell a genuine revision from a duplicate. Treat an unchanged hash as a no-op and only a changed hash as a new posted revision.
Float contamination. Never build a Decimal from a float literal — Decimal(0.1) imports the binary rounding error, and a fractional cent compounded across a rolled-forward cumulative_draw becomes the variance a guarantor asks you to explain. Read every amount, floor, and cap through its string form, as Decimal(str(...)) above, and quantize to cents at the point a figure becomes a reported draw. When a financing tranche’s exact availability is delayed rather than known, book the conservative over-drawing assumption for that week and reconcile it once the tranche confirms, rather than guessing an availability inline — the same provisional discipline the parent Cash-Flow Forecasting for Bond Covenants engine applies.
Related Guides
- Cash-Flow Forecasting for Bond Covenants — the parent engine that owns the covenant model, the weekly bucketer, and the snapshot ledger this walkthrough consumes.
- Completion Bond Reporting & Guarantor Analytics — the reference architecture that routes committed-cost data through every guarantor-facing reporting engine.
- Contingency Drawdown Tracking — the drawdown schedule that supplies the contingency outflow stream feeding this forecast.
- Cost-to-Complete Projections — the estimate-at-completion that anchors the far end of the forecast horizon.
- Cost Ingestion & Data Parsing Workflows — the upstream pipelines that deliver the clean, cost-coded outflows this function assumes.
Up one level: Cash-Flow Forecasting for Bond Covenants, part of Completion Bond Reporting & Guarantor Analytics.