Implementing Pro-Rata Allocation Algorithms
Pro-rata allocation is the arithmetic core of the Expense Allocation Logic & Rule Engines pipeline: once a recoverable expense pool has been assembled and every carve-out removed, this stage decides how many cents of that pool each tenant owes. The rule sounds trivial — divide each tenant’s rentable area by the building’s total and multiply by the pool — but a production allocation must survive partial-year occupancy, mid-term lease changes, vacancy gross-up, banker’s rounding, and a hard requirement that the allocated amounts sum back to the pool exactly. Get any of those wrong and the failure is quiet: a denominator that silently includes vacant space undercharges everyone, a naive rounding pass leaves a stray cent that fails year-end tie-out, and a floating-point share drifts a fraction of a percent that an auditor’s spreadsheet catches before you do. This page specifies the data contract the allocator depends on, the weighted pro-rata mathematics, a runnable typed implementation that keeps every amount in Decimal, the edge cases that break real portfolios, and how the output feeds the rest of reconciliation.
Prerequisites & Data Contracts
Pro-rata allocation computes nothing on its own — it is a pure function of two inputs, and it can only be as correct as the contracts feeding it. Before the allocator runs, three things must already be true.
A clean, recoverable expense pool. The allocator never sees a raw ledger. The pool it distributes is the recoverable set produced upstream: general-ledger lines that have passed GL code mapping for CAM expenses, cleared schema validation for parsed expense data, and had every tenant-specific carve-out stripped by exclusion mapping for tenant-specific CAM. By the time allocation begins, the pool is a single Decimal total with no capital, no landlord-reserved overhead, and no out-of-term charges left to filter.
An authoritative area register. Every share divides into a denominator built from rentable area, so those figures must be measured on one consistent basis. Production engines anchor their square footage to BOMA measurement standards rather than to whatever number a leasing broker typed into an abstract, and they read the reconciled figures from the lease abstraction database. Keeping that basis consistent across properties is the job of standardizing CAM taxonomies across portfolios; the allocator treats the register as authoritative and refuses to invent area it was not given.
Per-tenant occupancy facts. A tenant that occupied its suite for 140 of 365 days does not owe a full-year share. The allocator therefore requires, for each tenant, the rentable area, the days occupied in the reconciliation period, and the pool’s total recoverable amount. Occupancy windows come from the same lease commencement and expiration dates that drive exclusion mapping for tenant-specific CAM, so the two stages agree on who was in the building and when.
Algorithm & Rule Design
The allocation runs as a deterministic sequence: build a time-weighted factor for each tenant, normalize those factors so they sum to one, distribute the pool, then repair rounding so the cents tie out exactly. Each step is a rule, not a heuristic.
Time-weighted base factor. A tenant’s raw weight is its rentable area multiplied by the fraction of the period it occupied. For tenant with rentable area and days occupied out of days in the period, the normalized share is that weight over the sum of all tenants’ weights:
Normalizing against the sum of effective (occupied) area — rather than against gross building area — is what makes vacant space fall out of the denominator automatically. The nominal allocation for tenant from a recoverable pool is then
and by construction the exact real-valued shares sum to the pool, .
Gross-up for fixed costs. Normalizing to occupied area is correct for variable costs, but fixed operating costs — a security contract sized for a full building — should not be borne entirely by the few tenants present during a vacancy. Gross-up restates the variable portion of the pool as if the building were at a stabilized occupancy (commonly 95%):
where is the fixed portion and the variable portion of the recoverable pool. Grossing up before allocation keeps the two concerns separate: gross-up adjusts the pool, pro-rata distributes it.
Deterministic rounding. The nominal shares almost never land on whole cents. Rounding each independently leaves a residual — the rounded amounts miss the pool by a few cents — so the allocator uses a largest-remainder pass: quantize every share down to the cent, compute the leftover cents the pool still owes, and hand those one-cent adjustments to the tenants with the largest fractional remainders. This guarantees to the exact cent with a defensible, reproducible tie-break rather than dumping the residual on whichever tenant happens to sort last.
Python Implementation
The engine below is typed and deterministic, and every monetary value is a Decimal — binary float cannot represent cent amounts exactly, and drift summed across a large rent roll produces allocations that fail to tie out. Lease inputs are pydantic models so a malformed rent roll is rejected at the boundary rather than deep inside the arithmetic. The largest-remainder pass makes the rounded allocations sum to the pool exactly; see the official Python decimal documentation for the rounding modes used here.
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_EVEN
from typing import Iterable
from pydantic import BaseModel, Field
CENT = Decimal("0.01")
class TenantOccupancy(BaseModel):
"""One tenant's allocation inputs for a single reconciliation period."""
tenant_id: str
rentable_area: Decimal = Field(gt=0) # BOMA-measured RSF
days_occupied: int = Field(ge=0) # days in-term during the period
class Allocation(BaseModel):
"""The engine's verdict for one tenant, ready for the tenant statement."""
tenant_id: str
share_factor: Decimal # normalized pro-rata factor, sums to 1 across tenants
amount: Decimal # cent-exact recoverable charge
def allocate_pro_rata(
tenants: Iterable[TenantOccupancy],
recoverable_pool: Decimal,
period_days: int = 365,
) -> list[Allocation]:
"""Distribute a recoverable pool across tenants by time-weighted area.
Each tenant's weight is rentable_area * days_occupied, normalized so the
weights sum to one. Nominal shares are quantized to the cent, then a
largest-remainder pass distributes leftover cents so the allocations sum
to ``recoverable_pool`` exactly. Vacant area drops out of the denominator
because normalization is against occupied area, not gross building area.
"""
roster = list(tenants)
if not roster:
raise ValueError("Empty tenant roster: nothing to allocate.")
if recoverable_pool < 0:
raise ValueError("Recoverable pool must be non-negative.")
if period_days <= 0:
raise ValueError("Period length must be positive.")
weights = {
t.tenant_id: t.rentable_area * Decimal(t.days_occupied) / Decimal(period_days)
for t in roster
}
total_weight = sum(weights.values(), Decimal("0"))
if total_weight == 0:
raise ValueError("Zero effective occupancy: cannot build a denominator.")
# Nominal shares, floored to the cent; track the fractional remainder.
floored: dict[str, Decimal] = {}
remainders: dict[str, Decimal] = {}
for tid, w in weights.items():
exact = recoverable_pool * w / total_weight
down = exact.quantize(CENT, rounding=ROUND_HALF_EVEN)
if down > exact: # never round a share above its exact value
down -= CENT
floored[tid] = down
remainders[tid] = exact - down
# Distribute the residual cents to the largest remainders (deterministic tie-break).
allocated = sum(floored.values(), Decimal("0"))
residual_cents = int(((recoverable_pool - allocated) / CENT).to_integral_value())
ranked = sorted(remainders, key=lambda k: (remainders[k], k), reverse=True)
for tid in ranked[:max(residual_cents, 0)]:
floored[tid] += CENT
return [
Allocation(
tenant_id=t.tenant_id,
share_factor=(weights[t.tenant_id] / total_weight).quantize(Decimal("0.000001")),
amount=floored[t.tenant_id],
)
for t in roster
]
def gross_up_pool(
fixed: Decimal,
variable: Decimal,
actual_occupancy: Decimal,
target_occupancy: Decimal = Decimal("0.95"),
) -> Decimal:
"""Restate the variable portion of a pool to a stabilized-occupancy basis.
Fixed costs pass through unchanged; the variable portion is scaled by
target/actual occupancy so present tenants do not absorb the cost of
vacant space. Runs before allocation, on the pool, never on a share.
"""
if actual_occupancy <= 0:
raise ValueError("Actual occupancy must be positive to gross up.")
grossed = variable * (target_occupancy / actual_occupancy)
return (fixed + grossed).quantize(CENT, rounding=ROUND_HALF_EVEN)
The allocator returns one typed Allocation per tenant: the normalized share_factor that an auditor can multiply back out, and a cent-exact amount that is the value posted to the tenant statement. Because the residual pass runs against Decimal remainders, the returned amounts sum to the recoverable pool to the exact cent regardless of how the raw division fell.
Validation Rules & Edge Cases
The formula is short; the ways a real rent roll breaks it are not. Each of the following is a failure mode the engine handles explicitly rather than absorbing silently.
- Sum integrity is mandatory. After allocation,
sum(a.amount for a in result) == recoverable_poolmust hold withDecimalequality, not afloattolerance. The largest-remainder pass exists precisely to enforce this; if it ever fails, a tenant weight is malformed and the run must stop rather than issue a statement that does not tie out. - Missing square footage. A null rentable area cannot be silently treated as zero — that drops the tenant out of the denominator and overbills everyone else. Resolve it through a fallback chain before allocation: current lease abstract, then BOMA-certified survey, then the historical rent roll, and finally a flagged manual override with an audit note. The vertical and horizontal subdivision logic behind those fallbacks lives in calculating CAM pro-rata shares in mixed-use buildings.
- Overlapping occupancy windows. When one suite is re-let mid-period, a predecessor and successor tenant can both claim days in the same window and inflate the denominator. Time-weighting by actual in-term days per tenant prevents double-counting, but the occupancy dates must be reconciled against the lease abstract first — conflicting commencement and termination dates are a leading cause of denominator drift.
- Zero-day tenants. A tenant whose lease commences after the period ends has
days_occupied == 0and a zero weight; it correctly receives nothing. Guard against the pathological case where every tenant is zero-day, which the engine treats as an error rather than dividing by zero. - Rounding bias on high-volume portfolios. Standard half-up rounding accumulates a systematic upward bias across thousands of cycles. The engine uses banker’s rounding (
ROUND_HALF_EVEN) so repeated allocations do not drift the pool in one direction over a fiscal year. - Cap and exclusion interaction. Allocation assumes its pool is already clean. If a controllable-expense cap or a tenant carve-out has not been applied upstream, the pro-rata result is arithmetically correct but contractually wrong — which is why exclusion and capping must run before this stage, never after.
Integration Points
Pro-rata allocation sits in the middle of the reconciliation chain, and its output shapes everything downstream of it.
- Cap enforcement. The nominal shares this engine produces are the input to managing expense caps and controllable limits. A two-pass model computes the uncapped pro-rata share first, then applies each lease’s ceiling and redistributes or absorbs the excess. The split between capped and uncapped cost centers depends on handling controllable vs non-controllable CAM expenses.
- Threshold and tolerance calibration. The materiality bands that decide when a share variance routes to human review — typically ±$0.01 per tenant or ±0.05% of the pool, whichever is greater — are set alongside threshold tuning for allocation accuracy, so the review queue stays small without letting a material misallocation through.
- Portfolio-scale execution. At month-end and year-end the allocator fans out across every property. Because each run is a pure function of its inputs, the work parallelizes cleanly using the same async batch processing for high-volume invoices patterns used upstream, with each property allocation keyed on a content hash of its inputs so a retry reproduces byte-identical output.
- Statement generation and audit. Each
Allocationcarries both theshare_factorand the cent-exactamount, which is exactly what a tenant statement and its audit trail need. Preserving the resolved factors as an immutable snapshot lets a run be replayed under the access controls described in CAM reconciliation security and access controls, and satisfies the period-consistency requirement of FASB ASC 842, which treats each reconciliation period as a closed snapshot rather than something later runs may silently rewrite.
Testing & Verification
Allocation bugs are invisible until a tenant’s lease consultant re-derives the numbers, so the engine is tested against small fixtures where the correct answer is computed by hand. The properties that matter are conservation (nothing is created or destroyed), correct time-weighting, and vacancy falling out of the denominator.
from decimal import Decimal
def test_allocation_ties_out_and_weights_by_occupancy() -> None:
tenants = [
TenantOccupancy(tenant_id="A", rentable_area=Decimal("10000"), days_occupied=365),
TenantOccupancy(tenant_id="B", rentable_area=Decimal("10000"), days_occupied=182),
TenantOccupancy(tenant_id="C", rentable_area=Decimal("5000"), days_occupied=0),
]
pool = Decimal("100000.00")
result = allocate_pro_rata(tenants, pool, period_days=365)
by_id = {a.tenant_id: a for a in result}
# Conservation: the rounded amounts sum to the pool, to the exact cent.
assert sum(a.amount for a in result) == pool
# A occupied twice as many tenant-days as B, so it carries roughly twice the share.
assert by_id["A"].amount > by_id["B"].amount
ratio = by_id["A"].amount / by_id["B"].amount
assert Decimal("1.98") < ratio < Decimal("2.02")
# C was never in-term this period: zero weight, zero charge, out of the denominator.
assert by_id["C"].amount == Decimal("0.00")
# Share factors normalize to one across the roster.
assert sum(a.share_factor for a in result).quantize(Decimal("0.0001")) == Decimal("1.0000")
def test_residual_cents_are_distributed_not_dropped() -> None:
# A pool that does not divide evenly into three still ties out exactly.
tenants = [
TenantOccupancy(tenant_id="A", rentable_area=Decimal("1"), days_occupied=1),
TenantOccupancy(tenant_id="B", rentable_area=Decimal("1"), days_occupied=1),
TenantOccupancy(tenant_id="C", rentable_area=Decimal("1"), days_occupied=1),
]
result = allocate_pro_rata(tenants, Decimal("100.00"), period_days=1)
assert sum(a.amount for a in result) == Decimal("100.00")
# Two tenants get 33.33 and one gets 33.34 — the stray cent is placed, not lost.
assert sorted(a.amount for a in result) == [
Decimal("33.33"), Decimal("33.33"), Decimal("33.34"),
]
The first test locks in the three behaviors that define a correct allocator: it ties out to the cent, it weights by occupied tenant-days rather than raw area, and a zero-day tenant contributes nothing to the denominator. The second proves the residual pass never drops or invents a cent even when the pool refuses to divide evenly. Because every amount is Decimal, both conservation checks use exact equality rather than a floating-point tolerance that could hide a real one-cent leak. For debugging a mismatch in production, log each intermediate value — raw weight, normalized factor, floored amount, and remainder — per tenant so a delta against the prior-year baseline pinpoints which input drifted.
Where This Fits
Pro-rata allocation is the point where one clean recoverable pool becomes a defensible per-tenant charge. By weighting each share by occupied tenant-days, grossing up fixed costs to a stabilized basis, keeping every amount in Decimal, and repairing rounding with a deterministic largest-remainder pass, the engine turns a deceptively simple division into a result that ties out to the cent and reproduces on every run. It consumes the recoverable pool that exclusion mapping for tenant-specific CAM produces, hands its nominal shares to managing expense caps and controllable limits, and feeds cent-exact amounts into tenant statements — with the accuracy of every statement in the portfolio riding on the arithmetic this stage gets right.
Frequently Asked Questions
Why normalize against occupied area instead of gross building area? Normalizing each tenant’s weight against the sum of occupied (time-weighted) area makes vacant space fall out of the denominator automatically, so present tenants split the variable pool among themselves. Dividing by gross building area instead would leave the vacant fraction unallocated and quietly undercharge everyone. Fixed costs that should reflect a full building are handled separately by grossing up the pool before allocation, not by inflating the denominator.
How do the allocated amounts sum to the pool exactly when the division does not come out even? The engine floors every tenant’s share to the cent, then runs a largest-remainder pass: it counts the leftover cents the pool still owes and hands one cent each to the tenants with the largest fractional remainders. That guarantees the rounded amounts sum to the recoverable pool to the exact cent, with a reproducible tie-break, instead of dumping a stray cent on whichever tenant happens to sort last.
Why use Decimal and banker’s rounding instead of floats?
Binary floating point cannot represent most cent values exactly, so summing shares across a large rent roll drifts by fractions of a cent and produces statements that fail to tie out. Decimal keeps every amount penny-exact, and ROUND_HALF_EVEN (banker’s rounding) avoids the systematic upward bias that half-up rounding accumulates over thousands of allocation cycles in a fiscal year.
What should the allocator do when a tenant’s square footage is missing? Never treat a null area as zero — that removes the tenant from the denominator and overbills the rest of the roster. Resolve it before allocation through a fallback chain: current lease abstract, then a BOMA-certified survey, then the historical rent roll, then a flagged manual override with an audit note. The subdivision logic behind those fallbacks is covered in calculating CAM pro-rata shares in mixed-use buildings.
Does allocation apply expense caps and exclusions itself? No. Allocation assumes its pool is already clean — exclusion mapping and cap enforcement run upstream and downstream of it respectively. The engine distributes whatever recoverable pool it is handed; if a carve-out or a controllable-expense cap has not been applied, the pro-rata math is arithmetically correct but contractually wrong, which is why the pipeline orders those stages around allocation deliberately.
Related
- Expense Allocation Logic & Rule Engines — the parent pipeline this allocation stage belongs to, from recoverable pool to reconciled tenant statement.
- Exclusion mapping for tenant-specific CAM — the upstream stage that produces the clean recoverable pool this engine distributes.
- Managing expense caps and controllable limits — the downstream cap logic that consumes these nominal pro-rata shares.
- Threshold tuning for allocation accuracy — how the tolerance bands and review thresholds around allocation variances are calibrated.
- Calculating CAM pro-rata shares in mixed-use buildings — the hierarchical subdivision logic for retail, office, and residential components in one asset.