Building an Estimate-at-Completion from Committed and Forecast Costs
The estimate-at-completion for a single cost code looks like a one-line sum, and that appearance is exactly what makes it dangerous: EAC = actuals + open_commitments + forecast_to_complete is trivial to write and easy to get subtly, expensively wrong. The failure that surfaces in a bond review is almost never a mistyped formula — it is a purchase order counted twice because it had already been actualized, a foreign figure summed before it was normalized, or a forecast quietly folded into committed cost so nobody can see how soft the number really is. This walkthrough builds the per-cost-code estimate-at-completion as a pure, Decimal-exact function whose output a completion guarantor’s analyst can reproduce line by line months later, and it isolates the three components so the ratio of hard money to soft money is always legible. It is a focused companion to the parent Cost-to-Complete Projections engine, drilling into the arithmetic that engine aggregates.
Prerequisites and Context
This page assumes cost lines have already been parsed, cost-coded, and currency-normalized upstream, and focuses entirely on the estimate-at-completion arithmetic for one code and the audit record around it. It targets Python 3.11+ for the standard-library zoneinfo module, and uses the same deliberate stack as the rest of Completion Bond Reporting & Guarantor Analytics: decimal for every monetary value, hashlib for the payload fingerprint, zoneinfo for the timezone-aware report timestamp, and Pydantic v2 (model_validate, field_validator) for the boundary schema. Records reach this function already cleaned by the Cost Ingestion & Data Parsing Workflows subsystem, with every foreign amount converted to the reporting currency by Currency & FX Normalization, and each line keyed to exactly one budget line through Cost Code Standardization. The one rule that governs everything below: the three components are distinct kinds of money and must stay distinct all the way to the ledger, because a completion guarantor reads their proportions, not just their sum.
Step-by-Step Implementation
The arithmetic proceeds in four disciplined steps. First, validate the components at the boundary and reject any float. Second, net each open commitment against the portion of it that has already been actualized, so a paid-down purchase order is not counted twice. Third, sum the three de-duplicated components into the estimate-at-completion in Decimal, quantized to cents. Fourth, derive the variance against the approved budget and emit a hashed audit record. The critical middle step is the one naive implementations skip: an open commitment is only open to the extent it has not yet been invoiced, and the actualized portion already lives in the actuals column.
from __future__ import annotations
import hashlib
import json
from datetime import datetime
from decimal import Decimal, ROUND_HALF_UP
from zoneinfo import ZoneInfo
from pydantic import BaseModel, ConfigDict, Field, field_validator
CENTS = Decimal("0.01")
class CostCodeInputs(BaseModel):
"""The three components of one cost code's estimate-at-completion."""
model_config = ConfigDict(frozen=True)
cost_code: str
approved_budget: Decimal = Field(ge=Decimal("0"))
actual: Decimal = Field(ge=Decimal("0"))
# Gross commitment on outstanding POs/contracts for this code.
committed_gross: Decimal = Field(ge=Decimal("0"))
# Portion of committed_gross already invoiced and posted into `actual`.
committed_invoiced: Decimal = Field(ge=Decimal("0"))
forecast_to_complete: Decimal = Field(ge=Decimal("0"))
@field_validator(
"approved_budget", "actual", "committed_gross",
"committed_invoiced", "forecast_to_complete",
mode="before",
)
@classmethod
def reject_float(cls, v) -> Decimal:
if isinstance(v, float):
raise ValueError("monetary fields must be str/Decimal, never float")
return Decimal(str(v))
@field_validator("committed_invoiced")
@classmethod
def invoiced_within_gross(cls, v: Decimal, info) -> Decimal:
gross = info.data.get("committed_gross")
if gross is not None and v > gross:
raise ValueError("invoiced portion cannot exceed gross commitment")
return v
def _q(amount: Decimal) -> Decimal:
return amount.quantize(CENTS, rounding=ROUND_HALF_UP)
def estimate_at_completion(inp: CostCodeInputs) -> dict:
# Open commitment = gross obligation minus the part already actualized.
open_commitment = _q(inp.committed_gross - inp.committed_invoiced)
eac = _q(inp.actual + open_commitment + inp.forecast_to_complete)
variance = _q(inp.approved_budget - eac) # negative == over budget
payload = {
"cost_code": inp.cost_code,
"approved_budget": str(_q(inp.approved_budget)),
"actual": str(_q(inp.actual)),
"open_commitment": str(open_commitment),
"forecast_to_complete": str(_q(inp.forecast_to_complete)),
"estimate_at_completion": str(eac),
"variance_at_completion": str(variance),
"over_budget": variance < 0,
# Legibility for the guarantor: how much of the EAC is hard money?
"committed_and_actual": str(_q(inp.actual + open_commitment)),
"computed_at": datetime.now(tz=ZoneInfo("UTC")).isoformat(),
}
canonical = json.dumps(
{k: v for k, v in payload.items() if k != "computed_at"},
sort_keys=True, separators=(",", ":"),
).encode()
payload["payload_sha256"] = hashlib.sha256(canonical).hexdigest()
return payload
Because estimate_at_completion reads only its validated inputs, touches no network, and quantizes at every monetary step, identical inputs always produce an identical payload and an identical hash — excluding the wall-clock computed_at, which is deliberately kept out of the canonical material so a re-run at a different instant still fingerprints the same. That determinism is what lets a guarantor’s auditor recompute a disputed estimate-at-completion from the archived record alone.
The netting step deserves the emphasis it gets. committed_gross is the face value of every outstanding purchase order and signed contract for the code; committed_invoiced is the slice of that obligation whose invoices have already landed and posted into actual. The open commitment — the money still owed but not yet paid — is the difference. Summing actual + committed_gross instead of actual + open_commitment double-counts every invoice that has been paid against a still-listed purchase order, and on a picture with hundreds of open orders that double-count can inflate the estimate-at-completion by a full category before anyone notices.
Audit Trail Requirements
Every evaluation — including the within-budget branch — must be serialized to a write-once store so a guarantor’s analyst sees an unbroken record of how the estimate-at-completion was built, not only the codes that ran over. At minimum the persisted payload must carry: the cost_code; the approved_budget; the actual, the netted open_commitment, and the forecast_to_complete as separate string fields; the derived estimate_at_completion and variance_at_completion; the over_budget flag; the committed_and_actual hard-money subtotal that lets a reader gauge how much of the estimate is soft forecast; and the payload_sha256 fingerprint of the canonical payload. Persist the record to append-only, write-once storage — object storage with an immutability lock, or an event-sourced table with no in-place update — before any downstream snapshot commits, so a crash mid-run leaves a replayable record of intent rather than a silent gap. A later correction is a new compensating record stamped with a fresh report date, never an edit of the posted one, so the figure a reserve decision was built on stays recoverable. This is the same deterministic audit discipline the parent Cost-to-Complete Projections engine applies at the snapshot level, which is why the hashing and normalization belong in one shared module rather than duplicated per report.
Gotchas and Production Edge Cases
Double-counting an actualized commitment. This is the defining error of estimate-at-completion arithmetic. A purchase order stays on the open-commitment register until it is closed, but its invoices post to actuals as they arrive, so for a window the same money lives in both columns. Summing gross commitment and actual together counts it twice and inflates the estimate. Always net the invoiced portion out of the commitment first, as the open = gross − invoiced step does, and reconcile the register against paid invoices every reporting cycle so a fully-paid order does not linger as a phantom open commitment.
Float contamination. Never build a Decimal from a float literal — Decimal(0.1) imports the binary rounding error, and a single fractional cent compounded across hundreds of cost lines becomes the variance a guarantor asks you to explain. Read every amount through its string form, as Decimal(str(...)) above, and reject float at the boundary so a contaminated figure never reaches the sum. The same discipline governs the multi-currency conversions handled in Normalizing Multi-Currency Cost Reports with Decimal, and a figure that arrives already normalized must not be re-multiplied here.
Forecast masquerading as commitment. A forecast-to-complete blended into committed cost hides exactly the information a guarantor wants — the proportion of the estimate-at-completion that is a firm obligation versus a human guess. Keep the two fields distinct through the whole pipeline. A category whose estimate is 80 percent forecast is a different risk from one that is 80 percent committed, even at an identical total, and only separated fields expose that difference. When the forecast itself needs a bounded upper estimate rather than a single point, hand it to the simulation in Monte Carlo Cost-to-Complete Simulation in Python.
Idempotency on replay. Because the payload hash is deterministic and the store is append-only, re-running a reporting batch after a partial failure is safe only if the consumer deduplicates on (cost_code, report_date, payload_sha256); without that guard a retried chunk writes a second estimate for the same code and the same week, and a naive rollup sums both. The report date is part of the identity precisely so a genuine week-over-week reforecast is a new record while an accidental replay is a duplicate.
Negative and zero-budget codes. A cost code opened mid-shoot may carry a zero approved budget, which makes any estimate-at-completion an infinite percentage overage if a report divides by the budget. Compute variance as an absolute Decimal difference, never a ratio, and let the reporting layer decide how to present a code the bond never budgeted for — usually as an explicit unbudgeted line the guarantor must acknowledge rather than a silently swallowed one.
Related
- Cost-to-Complete Projections — the parent engine that aggregates this per-code arithmetic into a report-dated, hashed projection snapshot.
- Monte Carlo Cost-to-Complete Simulation in Python — a sibling walkthrough that bounds the soft forecast component this function keeps isolated.
- Contingency Drawdown Tracking — the reserve ledger an over-budget cost code draws against once its estimate-at-completion crosses the approved line.
- Normalizing Multi-Currency Cost Reports with Decimal — the upstream normalization that guarantees the amounts summed here are already in the reporting currency.
- Completion Bond Reporting & Guarantor Analytics — the parent architecture that turns these projections into guarantor-facing reporting.
Up one level: Cost-to-Complete Projections, part of Completion Bond Reporting & Guarantor Analytics.