Dynamic Pricing Rule Engines: Deterministic, Auditable Rate Decisions

A revenue team that cannot explain why a rate went out the door does not really control its pricing — it is at the mercy of whichever ad hoc script last touched the number. When yield logic lives as a pile of imperative if branches scattered across connectors, three things break at once: finance cannot reconstruct why a €212 rate fired on a Friday, two engineers implement the same occupancy lift two different ways, and a runaway rule quietly drops a suite under its floor for a week before anyone notices in a parity report. The people who absorb that are the revenue manager who gets asked to justify an inexplicable rate, the operations lead fielding a distressed-inventory complaint, and the on-call engineer bisecting commits to find which branch fired. A dynamic pricing rule engine fixes this by treating pricing logic as declarative data evaluated in a fixed, documented order, so every rate is a deterministic, replayable function of its inputs. This guide sits inside rate parity monitoring and revenue optimization, where the authoritative rate this engine emits is exactly the number the parity layer later defends against every channel.

The engine specified here has one job: take the demand signals and competitor benchmarks for a (property_id, room_type_code, rate_plan_code, date) cell and emit a single authoritative rate, together with a decision record explaining precisely how it got there. It is the local specialization of the centralized pricing engine that the broader architecture insists must be the sole producer of the rate per cell — one place, one number, one audit trail.

Architecture and prerequisites

The engine is a pure function wrapped in an ordered pipeline. Rules are rows in a versioned table, not code; the evaluator reads them, sorts them by an explicit priority, and applies each in turn against a mutable working rate that starts at the base and ends as the authoritative output. The ordering is not incidental — it is the contract. Every rule falls into one of four phases that always execute in the same sequence: convert the base into the working currency and tax basis, adjust it with demand and competitor rules, clamp it inside floor and ceiling guardrails, and round exactly once at the end. Reordering these phases changes the output, so the phase of a rule is as load-bearing as its formula.

Ordered four-phase evaluation of pricing rules into one authoritative rate and a decision record Inputs on the left — a base rate of 180, an occupancy of 0.82, a competitor average of 171, and a floor of 150 with a ceiling of 340 — feed a rule engine that evaluates in four ordered phases. Phase one, convert, resolves currency and tax basis. Phase two, adjust, applies demand and competitor rules by priority: a high-occupancy lift of plus twelve percent and a competitor undercut to match minus two. Phase three, clamp, holds the working rate inside the floor and ceiling. Phase four, round, snaps to the minor unit exactly once. The pipeline emits two outputs: one authoritative rate of 197, and a decision record listing every rule that fired with its before and after value, which flows to the parity layer and the audit trail. One authoritative rate per cell, produced in a fixed four-phase order convert → adjust → clamp → round once; every rule that fires is recorded for finance to trace. Inputs Ordered evaluation Outputs base 180.00 bar_flex · EUR occupancy 0.82 demand signal comp avg 171.00 rate-shop benchmark floor 150 · ceil 340 guardrails 1 · convert resolve currency and tax basis onto the working rate 180.00 2 · adjust occ_high +12% · comp match_minus_2 — applied by priority 201.60 3 · clamp hold inside [floor, ceiling] — fail closed on breach 201.60 4 · round once snap to minor unit, banker's rounding, exactly once 197.00 rate 197.00 authoritative decision record why each rule fired to parity layer + audit trail
Figure 1: a cell's inputs flow through four ordered phases — convert, adjust, clamp, round once — producing exactly one authoritative rate and a decision record that names every rule that fired with its before-and-after value.

Two rules that adjust the same working rate must have a total order, so every rule carries an integer priority; ties are broken by rule_id to keep evaluation deterministic across machines and Polars/Python versions. The base rate the engine converts is resolved from the immutable rate plan taxonomy, so a derivative plan’s authoritative rate always traces back to a single governed base rather than a number typed into a connector.

Inputs, outputs, and environment for the reference implementation:

The one non-negotiable prerequisite is that rules are data under version control, not code. If an engineer can hard-code a lift inside a connector, the engine loses its two defining properties — determinism and explainability — and you are back to bisecting commits to explain a rate.

Implementation

Step 1 — Model a rule as a typed row with an explicit phase and priority

Represent each rule as an immutable record naming its phase, its priority, the predicate that decides whether it applies to a cell, and the action that transforms the working rate. Keeping the predicate and action as small named operations (not arbitrary eval) is what lets you serialize, diff, and review a rule as data.

python
from dataclasses import dataclass
from decimal import Decimal
from enum import IntEnum
from typing import Callable
import structlog

log = structlog.get_logger()

class Phase(IntEnum):          # IntEnum so phases sort in evaluation order for free
    CONVERT = 1
    ADJUST = 2
    CLAMP = 3
    ROUND = 4

@dataclass(frozen=True)        # frozen: a rule is immutable once loaded from the store
class Rule:
    rule_id: str               # e.g. "occ_high_lift"
    phase: Phase
    priority: int              # lower runs first within a phase; ties break on rule_id
    predicate: Callable[[dict], bool]   # does this rule apply to the cell?
    action: Callable[[Decimal, dict], Decimal]  # working_rate, facts -> new working_rate

def evaluation_order(rules: list[Rule]) -> list[Rule]:
    # (phase, priority, rule_id) is a total order, so evaluation is deterministic
    return sorted(rules, key=lambda r: (r.phase, r.priority, r.rule_id))

Sorting on the (phase, priority, rule_id) triple rather than list position means the order is a property of the data, not of how the rows happened to be loaded — two workers that fetch the same rule set in different orders still evaluate identically.

Step 2 — Evaluate the ordered pipeline and record every decision

Fold the sorted rules over a working rate that starts at the base. Each time a rule’s predicate matches, apply its action and append a decision line capturing the before and after value. The record is the explanation finance replays later, so it is built in the same loop that computes the rate, never reconstructed afterward.

python
def price_cell(facts: dict, rules: list[Rule]) -> tuple[Decimal, list[dict]]:
    working = Decimal(str(facts["base_rate"]))
    trace: list[dict] = []
    for rule in evaluation_order(rules):
        if not rule.predicate(facts):
            continue
        before = working
        working = rule.action(working, facts)
        trace.append({
            "rule_id": rule.rule_id, "phase": rule.phase.name,
            "before": str(before), "after": str(working),
        })
        log.info("rule_fired", rule_id=rule.rule_id, phase=rule.phase.name,
                 property_id=facts["property_id"], before=str(before), after=str(working))
    return working, trace

Because the trace is appended inside the same fold that mutates working, a decision record can never disagree with the rate it explains — there is no second code path that could drift from the first.

Step 3 — Author the four phases as ordinary rules

Every behaviour — even clamping and rounding — is expressed as a rule, so the pipeline has no special cases hidden in the evaluator. The occupancy lift below reads a demand tier; the competitor rule undercuts a benchmark; the clamp holds the guardrails; the round fires exactly once at the end.

python
def occ_high_lift(rate: Decimal, f: dict) -> Decimal:
    return rate * Decimal("1.12")          # +12% when the date is filling

def comp_match_minus_2(rate: Decimal, f: dict) -> Decimal:
    target = Decimal(str(f["competitor_avg"])) * Decimal("0.98")
    return min(rate, target)               # never sit above 2% under the comp set

def clamp_guardrails(rate: Decimal, f: dict) -> Decimal:
    return max(Decimal(str(f["floor"])), min(rate, Decimal(str(f["ceiling"]))))

RULESET = [
    Rule("occ_high_lift", Phase.ADJUST, 10,
         predicate=lambda f: f["occupancy"] >= 0.80, action=occ_high_lift),
    Rule("comp_match_minus_2", Phase.ADJUST, 20,
         predicate=lambda f: f.get("competitor_avg") is not None, action=comp_match_minus_2),
    Rule("clamp_guardrails", Phase.CLAMP, 10,
         predicate=lambda f: True, action=clamp_guardrails),
]

Giving the occupancy lift priority 10 and the competitor rule priority 20 inside the same ADJUST phase encodes a real revenue policy — the demand lift is computed first, then the competitor rule is allowed to pull the lifted rate back down — so the interaction between the two is decided by data, not by whichever branch an engineer happened to write first.

Step 4 — Round exactly once, at the very end

Rounding is a phase, not something each rule does, because rounding mid-pipeline compounds error. The final rule snaps the working rate to the minor unit with a documented rounding mode, and nothing downstream is permitted to round again.

python
from decimal import ROUND_HALF_EVEN

def round_once(rate: Decimal, f: dict) -> Decimal:
    # Banker's rounding on the minor unit; applied once so error never compounds
    return rate.quantize(Decimal("1.00"), rounding=ROUND_HALF_EVEN)

RULESET.append(Rule("round_once", Phase.ROUND, 99,
                    predicate=lambda f: True, action=round_once))

Using ROUND_HALF_EVEN (banker’s rounding) rather than the default half-up matches the rounding mode the centralized pricing engine documents, so a rate produced here compares exactly against the parity comparator instead of tripping it on a sub-cent difference.

Schema and data contracts

The rule set and the decision record are both contracts, so both are Pydantic v2 models validated at their boundaries. The rule model rejects a malformed or out-of-range action at load time; the decision model is the shape finance and the parity monitoring and alerting layer consume, so it carries the full provenance of the authoritative rate.

python
from pydantic import BaseModel, Field, field_validator
from datetime import date

class RuleSpec(BaseModel):
    rule_id: str = Field(min_length=3, max_length=40, pattern=r"^[a-z0-9_]+$")
    phase: str = Field(pattern=r"^(convert|adjust|clamp|round)$")
    priority: int = Field(ge=0, le=999)
    version: int = Field(ge=1)          # bumped on every reviewed change to the rule
    param: float | None = Field(default=None)

    @field_validator("param")
    @classmethod
    def bounded_param(cls, v: float | None) -> float | None:
        if v is not None and not (-0.5 <= v <= 0.5):
            raise ValueError("adjust param must be a fraction within +/-50%")
        return v

class RuleDecision(BaseModel):
    property_id: str = Field(pattern=r"^[A-Z]{3}-[A-Z0-9]{3,}-\d{2}$")
    room_type_code: str = Field(pattern=r"^[A-Z0-9_]+$")
    rate_plan_code: str = Field(pattern=r"^[a-z0-9_]+$")
    stay_date: date
    base_rate: float = Field(gt=0)
    final_rate: float = Field(gt=0)
    fired_rules: list[str]              # rule_ids in the order they fired
    ruleset_version: int = Field(ge=1)  # exact rule-set snapshot that priced this cell

Stamping every RuleDecision with the ruleset_version that produced it is the non-obvious field that makes the engine auditable across time: a rate questioned three months later can be replayed against the exact rule snapshot that fired it, not against whatever the rules have since become. This contract specializes the property-wide data schema standardization rules for the pricing decision specifically.

Error handling and retry strategy

A pricing engine that is a pure function does not make network calls, so its failure modes are not HTTP faults — they are bad inputs and impossible outputs, and the discipline is to fail closed locally rather than distribute a rate the engine cannot defend. The classification below is the pricing-side complement to the retryable-versus-terminal split in error categorization and retry logic.

python
import hashlib

def decision_key(d: dict) -> str:
    raw = (f"{d['property_id']}|{d['room_type_code']}|{d['rate_plan_code']}|"
           f"{d['stay_date']}|{d['ruleset_version']}|{d['base_rate']}")
    return hashlib.sha256(raw.encode()).hexdigest()

def price_or_dlq(facts: dict, rules: list[Rule], dlq: list) -> dict | None:
    try:
        rate, trace = price_cell(facts, rules)
    except Exception as exc:                      # a predicate/action bug on this cell
        dlq.append({"cell": facts, "error": str(exc)})
        log.error("cell_priced_dlq", property_id=facts.get("property_id"), error=str(exc))
        return None
    return {"final_rate": str(rate), "fired_rules": [t["rule_id"] for t in trace]}

Deriving the key from ruleset_version and base_rate (not a UUID) means a genuinely changed rule set or base rate produces a new key and does propagate, while a mechanical replay of the same decision is deduplicated — the engine never double-applies a rate, and it never suppresses a real repricing.

Verification and testing

Prove three things before the engine prices a live channel: that evaluation order is stable, that guardrails actually bind, and that the decision record faithfully explains the rate. Assert on the authoritative number and on the trace, not on a distributed HTTP response.

python
import pytest
from decimal import Decimal

FACTS = {"property_id": "LON-STJ-01", "room_type_code": "DLX_KING",
         "rate_plan_code": "bar_flex", "base_rate": 180.0,
         "occupancy": 0.82, "competitor_avg": 171.0, "floor": 150.0, "ceiling": 340.0}

def test_order_is_deterministic():
    a = [r.rule_id for r in evaluation_order(RULESET)]
    b = [r.rule_id for r in evaluation_order(list(reversed(RULESET)))]
    assert a == b == ["occ_high_lift", "comp_match_minus_2", "clamp_guardrails", "round_once"]

def test_guardrail_clamps_below_floor():
    facts = {**FACTS, "competitor_avg": 40.0}    # comp rule would push far under floor
    rate, _ = price_cell(facts, RULESET)
    assert rate == Decimal("150.00")             # clamp holds the floor

def test_trace_explains_the_rate():
    rate, trace = price_cell(FACTS, RULESET)
    assert [t["rule_id"] for t in trace][-1] == "round_once"
    assert Decimal(trace[-1]["after"]) == rate   # record and rate cannot disagree

test_order_is_deterministic()
test_guardrail_clamps_below_floor()
test_trace_explains_the_rate()

The three assertions map to the three ways the engine silently breaks: an unstable order (the same inputs pricing two ways on two machines), a guardrail that does not bind (a rate published under floor), and a decision record that has drifted from the rate it claims to explain (an audit trail finance cannot trust). Each price_cell call emits rule_fired events carrying the cell key and before/after values, so the same reconciliation the parity monitoring layer runs can confirm what the engine intended against what actually distributed.

Troubleshooting

Symptom Root cause Fix
Same cell prices differently on two workers Rules evaluated in list order instead of the (phase, priority, rule_id) sort Always sort through evaluation_order; never iterate the raw list, and break priority ties on rule_id
Rate published a fraction of a cent off, trips parity Rounding applied mid-pipeline or re-rounded downstream Round exactly once in the ROUND phase with ROUND_HALF_EVEN; forbid any connector from re-rounding
A rate lands below the property floor Clamp ran before an adjust rule, or a floor exceeds its ceiling in the inputs Keep CLAMP after ADJUST; treat floor > ceiling as a terminal input error and quarantine the cell
Competitor feed outage yanks rates to zero Missing benchmark passed through as 0 instead of skipping the rule Predicate the competitor rule on benchmark freshness; degrade to demand-only pricing with a flag
Finance cannot explain a three-month-old rate Decision record lacked the rule-set version that fired it Stamp every RuleDecision with ruleset_version and replay against that exact snapshot

Frequently Asked Questions

Why evaluate rules in a fixed order instead of letting them all apply?

Because pricing rules are not commutative: applying a demand lift before a competitor undercut produces a different rate than the reverse. A fixed convert → adjust → clamp → round order, with an explicit priority per rule, makes the output a deterministic function of the inputs — which is exactly what lets finance replay any rate and lets two engineers reason about the same result instead of racing two implementations.

Should pricing rules live in code or in a data store?

In a versioned data store. Rules as data can be diffed, reviewed, and replayed against a specific version, and a new promotional lift becomes a reviewed configuration change rather than a deploy. Rules hard-coded inside connectors reintroduce the drift and the un-auditability the engine exists to eliminate — the moment a lift lives in a branch, you are back to bisecting commits to explain a rate.

Where should rounding happen in the pipeline?

Exactly once, at the very end, in a dedicated ROUND phase using a documented mode such as ROUND_HALF_EVEN. Rounding mid-pipeline compounds error across rules, and re-rounding in a downstream connector reintroduces the sub-cent differences that trip the parity comparator. The numeric value is frozen after this phase; the dialect layer may reshape and relabel it but never re-round it.

How do floor and ceiling guardrails fit the rule model?

As a CLAMP phase that always runs after ADJUST and before ROUND. The clamp holds the working rate inside the floor and ceiling. If it is still out of bounds afterward, the inputs themselves are contradictory — a floor above its ceiling — so the cell is quarantined and never distributed, rather than shipped at a defensible-looking but wrong number. A rate under floor leaks revenue; a rate over ceiling kills conversion.

What makes the engine explainable to finance?

The decision record. Every cell emits a structured trace naming each rule that fired, its before-and-after value, and the ruleset_version, built in the same loop that computes the rate so the record can never disagree with the number it explains. A rate questioned months later is answered by reading its trace against the exact rule snapshot that fired it — not by reverse-engineering whatever the source has since become.

← Back to Rate Parity Monitoring & Revenue Optimization