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.

python
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.

python
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.

python
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

python
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.