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 responds to price as
where is the price elasticity of demand (negative — higher price, less demand). Fitting a straight line to recovers 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 .
Step 2 — Fit elasticity with ordinary least squares
No external library is needed for a single-variable fit. Compute the slope of on directly from the observations, guarding against the degenerate case of no price variation.
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.
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.
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.
Related
- Threshold Tuning for Price Elasticity — the parent cluster on turning elasticity into pricing thresholds.
- Yield Optimization Algorithms — the consumer that prices against the demand curves these multipliers shape.
- Calculating weighted moving averages for hotel occupancy — a companion technique for preparing the demand history this fit consumes.