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

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.

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

python
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

python
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 remaining and, in the full model, decrement it across accepted stays.
  • Log of a non-positive price. A zero or negative floor makes math.log throw; 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 step for high-value nights.