Calibrating demand-elasticity multipliers from booking history

An elasticity multiplier that is guessed rather than measured is just a fixed markup wearing a lab coat. To price demand honestly, you have to estimate how your bookings actually respond to price from your own history. This guide fits a constant-elasticity demand model from a booking ledger and turns it into the multipliers the pricing engine consumes. It implements the calibration side of Threshold Tuning for Price Elasticity in the Occupancy Forecasting & Demand Analytics pillar, and its output feeds Yield Optimization Algorithms.

Prerequisites

  • Python 3.11+
  • Standard library math, statistics, logging, dataclasses — no heavy ML stack required
  • A booking ledger with, per stay date and room type: the rate charged and rooms sold
  • Enough price variation in history to identify a slope (a single price teaches nothing)
  • The elasticity consumer contract in the yield-optimization cluster

Step 1 — Understand the model

Constant-elasticity demand is linear once you take logarithms. Demand QQ responds to price PP as

Q=APεlnQ=lnA+εlnPQ = A \cdot P^{\varepsilon} \quad\Longleftrightarrow\quad \ln Q = \ln A + \varepsilon \ln P

where ε\varepsilon is the price elasticity of demand (negative — higher price, less demand). Fitting a straight line to (lnP,lnQ)(\ln P, \ln Q) recovers ε\varepsilon as the slope. The multiplier the engine wants is the ratio of demand at a candidate price to demand at the reference price, which under this model is (Pnew/Pref)ε(P_{\text{new}}/P_{\text{ref}})^{\varepsilon}.

Step 2 — Fit elasticity with ordinary least squares

No external library is needed for a single-variable fit. Compute the slope of lnQ\ln Q on lnP\ln P directly from the observations, guarding against the degenerate case of no price variation.

python
from __future__ import annotations

import logging
import math
from dataclasses import dataclass

logger = logging.getLogger("forecast.elasticity")


@dataclass(frozen=True)
class Observation:
    price_cents: int
    rooms_sold: int


def fit_elasticity(obs: list[Observation]) -> float:
    points = [(math.log(o.price_cents), math.log(o.rooms_sold))
              for o in obs if o.price_cents > 0 and o.rooms_sold > 0]
    if len(points) < 2:
        raise ValueError("need at least two positive observations")

    n = len(points)
    mean_x = sum(x for x, _ in points) / n
    mean_y = sum(y for _, y in points) / n
    cov = sum((x - mean_x) * (y - mean_y) for x, y in points)
    var = sum((x - mean_x) ** 2 for x, _ in points)
    if var == 0:
        raise ValueError("no price variation; elasticity unidentifiable")

    elasticity = cov / var
    logger.info("fitted elasticity=%.3f from %d observations", elasticity, n)
    return elasticity

Step 3 — Convert elasticity into a price multiplier

The engine applies a demand multiplier, not a raw elasticity. Translate a candidate price relative to the reference into the expected demand ratio, and clamp the elasticity to a sane band so a noisy fit cannot produce an absurd multiplier.

python
def demand_multiplier(elasticity: float, price_cents: int,
                      ref_price_cents: int) -> float:
    if not -5.0 <= elasticity < 0.0:
        # clamp implausible fits; log so the calibration is reviewed
        clamped = min(max(elasticity, -5.0), -0.05)
        logger.warning("clamping elasticity %.3f -> %.3f", elasticity, clamped)
        elasticity = clamped
    ratio = price_cents / ref_price_cents
    return ratio ** elasticity

Clamping to a plausible band is the guardrail that keeps a thin or noisy history from handing the optimizer an elasticity of, say, +2 — which would tell it that raising prices increases demand and send rates to the ceiling.

Verification and testing

Generate observations from a known elasticity and confirm the fit recovers it, then check the multiplier moves in the right direction.

python
import math


def test_recovers_known_elasticity() -> None:
    true_eps, a = -1.3, 12.0
    obs = [Observation(price_cents=p,
                       rooms_sold=max(1, round(math.exp(a + true_eps * math.log(p)))))
           for p in (10000, 12000, 15000, 18000, 22000)]
    eps = fit_elasticity(obs)
    assert abs(eps - true_eps) < 0.1

    # a price above reference should reduce expected demand (multiplier < 1)
    assert demand_multiplier(eps, price_cents=20000, ref_price_cents=15000) < 1.0

Common pitfalls and edge cases

  • No price variation. If history sold at one price, elasticity is unidentifiable; deliberately vary price to learn it.
  • Confounded demand. A holiday that raised both price and demand biases the slope toward zero; segment by season and exclude event dates flagged in Event-Driven Demand Adjustments.
  • Log of zero. Zero rooms sold or a zero price breaks the log; filter non-positive observations.
  • Overfitting thin segments. A room type with five bookings yields a noisy slope; pool related segments or widen the window.
  • Stale calibration. Elasticity drifts with market conditions; timestamp each fit and refresh on a cadence.