Building a booking pace report in Python
A pace report answers one question a dashboard of final occupancy never can: are we ahead or behind for a date that hasn’t happened yet? This guide builds one from a raw booking ledger, producing per-stay-date pace and pickup against a historical baseline. It implements the reporting side of Pace & Pickup Analysis in the Occupancy Forecasting & Demand Analytics pillar.
Prerequisites
- Python 3.11+
- Standard library
datetime,collections,dataclasses,logging - A booking ledger with, per booking: stay date, booking date, rooms, and a cancellation flag
- A baseline of comparable historical cumulative curves
- Room-type identifiers consistent with Rate Plan Structuring & Mapping
Step 1 — Reduce the ledger to a cumulative pickup curve
Pace is measured in days-before-arrival, so convert each booking’s booking date into a lead-time offset and accumulate rooms by that offset. The result is a cumulative curve: how many rooms were on the books at each point before arrival.
from __future__ import annotations
import logging
from collections import defaultdict
from datetime import date
logger = logging.getLogger("forecast.pace_report")
def cumulative_curve(bookings: list[dict], stay_date: date) -> dict[int, int]:
"""Map days-before-arrival -> cumulative rooms sold for one stay date."""
per_dba: dict[int, int] = defaultdict(int)
for b in bookings:
if b["stay_date"] != stay_date or b["cancelled"]:
continue
dba = (stay_date - b["booking_date"]).days
if dba < 0:
continue # booked after arrival; ignore
per_dba[dba] += b["rooms"]
# accumulate from the earliest lead time down to arrival
curve: dict[int, int] = {}
running = 0
for dba in sorted(per_dba, reverse=True):
running += per_dba[dba]
curve[dba] = running
return curve
Step 2 — Compare against the baseline at the current reading
With the current curve and a baseline curve, pace is their difference at today’s days-before-arrival. Interpolate the baseline if it lacks the exact offset, so the comparison is always defined.
def baseline_at(baseline: dict[int, int], dba: int) -> int:
if dba in baseline:
return baseline[dba]
# nearest available offset at or before this lead time
candidates = [d for d in baseline if d >= dba]
key = min(candidates) if candidates else max(baseline, default=dba)
return baseline.get(key, 0)
def pace_at(current: dict[int, int], baseline: dict[int, int], dba: int) -> int:
return current.get(dba, 0) - baseline_at(baseline, dba)
Step 3 — Assemble the report across stay dates
Loop the stay dates in the reporting window and emit a row per date: current on-the-books, baseline, and pace. Sorting by pace surfaces the dates that most need a pricing response.
from dataclasses import dataclass
@dataclass(frozen=True)
class PaceRow:
stay_date: date
on_the_books: int
baseline: int
pace: int
def build_report(ledger: list[dict], baselines: dict[date, dict[int, int]],
as_of: date) -> list[PaceRow]:
rows: list[PaceRow] = []
for stay_date, baseline in baselines.items():
dba = (stay_date - as_of).days
if dba < 0:
continue
curve = cumulative_curve(ledger, stay_date)
row = PaceRow(stay_date, curve.get(dba, 0), baseline_at(baseline, dba),
pace_at(curve, baseline, dba))
rows.append(row)
rows.sort(key=lambda r: r.pace) # most-behind first
logger.info("pace report: %d stay dates as of %s", len(rows), as_of)
return rows
Sorting most-behind-first is a small decision with operational weight: the report leads with the dates bleeding demand, where a rate cut or a promotion can still change the outcome, rather than burying them under the dates that are already fine.
Verification and testing
from datetime import date
def test_positive_pace_when_ahead_of_baseline() -> None:
stay = date(2026, 9, 5)
ledger = [
{"stay_date": stay, "booking_date": date(2026, 8, 6), "rooms": 3, "cancelled": False},
{"stay_date": stay, "booking_date": date(2026, 8, 26), "rooms": 2, "cancelled": False},
]
baselines = {stay: {30: 2, 10: 3}} # baseline had 3 rooms at 10 dba
rows = build_report(ledger, baselines, as_of=date(2026, 8, 26)) # dba = 10
assert rows[0].on_the_books == 5 # 3 + 2 cumulative
assert rows[0].pace == 2 # 5 current - 3 baseline
Common pitfalls and edge cases
- Counting cancellations. Including cancelled bookings inflates on-the-books; filter them or apply decrements.
- Post-arrival bookings. Negative days-before-arrival should be dropped, not folded into the last bucket.
- Baseline offset gaps. A baseline missing today’s exact lead time needs interpolation, or pace is undefined.
- Mixing room types. Aggregating across room types hides a segment selling out; report per room type.
- Timezone slips. Computing lead time across timezones can be off by a day; normalize both dates to the property calendar.
Related
- Pace & Pickup Analysis — the parent cluster defining pace, pickup, and baselines.
- Calculating weighted moving averages for hotel occupancy — a companion technique for building the baseline this report compares against.
- Yield Optimization Algorithms — where a most-behind pace row becomes a concrete rate action.