Length-of-stay optimization with dynamic programming
Pricing a single night is a one-dimensional search. Pricing a multi-night stay is not, because the nights compete for the same finite inventory and a low-value long stay can crowd out several high-value short ones. This guide solves the length-of-stay allocation problem with dynamic programming, extending the single-night optimizer from Yield Optimization Algorithms in the Dynamic Pricing Rule Engines & Optimization pillar. The output is a set of per-night prices and a minimum-length-of-stay decision that maximizes expected revenue across the horizon.
Prerequisites
- Python 3.11+
- Standard library
functools,logging,dataclasses— no third-party solver required - Per-night demand curves from the elasticity calibration in Threshold Tuning for Price Elasticity
- Remaining inventory per night, reconciled with Channel Manager Integration Patterns
- Rates in integer minor units
Step 1 — Define per-night value under a candidate price
The building block is the expected revenue a single night yields at a given price, capped by remaining inventory. This mirrors the single-night model but is expressed as a pure function so it can be memoized across the horizon.
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from functools import lru_cache
logger = logging.getLogger("pricing.los")
@dataclass(frozen=True)
class Night:
stay_date: str
intercept: float
elasticity: float # negative
remaining: int
floor_cents: int
ceiling_cents: int
def night_revenue(night: Night, price_cents: int) -> float:
demand = math.exp(night.intercept + night.elasticity * math.log(price_cents))
demand = min(demand, float(night.remaining))
return price_cents * demand
Step 2 — Optimize each night’s price independently, then couple by LOS
First find each night’s revenue-maximizing price within its band; then decide the minimum length of stay by comparing the cumulative value of admitting stays of each length. The DP runs over stay-length as the state, accumulating the best per-night prices.
def best_night_price(night: Night, step: int = 100) -> tuple[int, float]:
best_price, best_rev = night.floor_cents, -1.0
for price in range(night.floor_cents, night.ceiling_cents + 1, step):
rev = night_revenue(night, price)
if rev > best_rev:
best_price, best_rev = price, rev
return best_price, best_rev
def optimize_stay(nights: list[Night]) -> dict:
"""Return per-night prices and the revenue-maximizing minimum LOS."""
if not nights:
raise ValueError("empty stay horizon")
priced = [best_night_price(n) for n in nights]
# prefix revenue: value of accepting a stay of length k starting at night 0
prefix: list[float] = []
running = 0.0
for _, rev in priced:
running += rev
prefix.append(running)
best_los = max(range(1, len(nights) + 1), key=lambda k: prefix[k - 1] / k)
result = {
"prices_cents": [p for p, _ in priced],
"min_los": best_los,
"expected_revenue": prefix[best_los - 1],
}
logger.info("LOS optimization: min_los=%d expected_revenue=%.0f",
best_los, result["expected_revenue"])
return result
The prefix[k-1] / k objective picks the length of stay with the highest per-night yield, which is what protects a scarce peak night from being consumed by a long, low-value stay. In a fuller model the DP state also carries remaining inventory and iterates arrivals; the structure — optimize locally, couple through a stay-length state — is the same.
Verification and testing
def test_min_los_prefers_high_yield_short_stay() -> None:
nights = [
Night("2026-12-31", intercept=9.0, elasticity=-0.7,
remaining=5, floor_cents=20000, ceiling_cents=60000), # NYE, scarce
Night("2027-01-01", intercept=6.5, elasticity=-0.9,
remaining=40, floor_cents=8000, ceiling_cents=20000), # soft
]
result = optimize_stay(nights)
assert result["min_los"] == 1 # do not force the soft night onto NYE demand
assert len(result["prices_cents"]) == 2
The assertion confirms the optimizer does not impose a two-night minimum that would dilute the high-yield New Year’s Eve night with soft January-first demand.
Common pitfalls and edge cases
- Ignoring inventory coupling. Optimizing nights in isolation over-books the scarce night; cap demand by
remainingand, in the full model, decrement it across accepted stays. - Log of a non-positive price. A zero or negative floor makes
math.logthrow; assert floors are strictly positive. - Uniform LOS across seasons. A single minimum length of stay for the whole horizon destroys value; solve per arrival window.
- Overfit per-night curves. Jagged adjacent-night prices signal overfitting; smooth curves across neighboring dates before optimizing.
- Step too coarse. A large grid step can miss the optimum near a steep part of the curve; tighten
stepfor high-value nights.
Related
- Yield Optimization Algorithms — the parent cluster and the single-night optimizer this extends.
- Overbooking limit optimization in Python — the inventory-risk sibling to length-of-stay control.
- Modeling cancellation curves for dynamic pricing — the cancellation signal that feeds realistic remaining-inventory estimates.