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.
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:
- Inputs: a versioned rule set keyed by
rule_id(each withphase,priority, a predicate, and an action), plus per-cell facts — base rate, occupancy tier, competitor benchmark, floor, and ceiling. - Outputs: one authoritative rate per cell and a structured
RateDecisionlisting every rule that fired, its input value, and its output value. - Runtime: Python 3.11+,
pydantic2.6+ for the rule and decision contracts,polars1.x for the vectorized batch pass,structlog24.x for key=value decision telemetry, and the standard-librarydecimalmodule for the final rounding step. - Assumptions: the base rate is authoritative and already floor-validated by the taxonomy; competitor benchmarks arrive pre-normalized to the property’s own currency and tax basis; and every rule action is a pure function of the working rate and the cell facts, with no I/O.
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.
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.
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.
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.
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.
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.
- Floor or ceiling breach after clamp (terminal, fail closed): if the working rate is still outside the guardrails after the clamp phase — which can only happen if a floor exceeds a ceiling in the input — the cell is quarantined, not published. Emit a
rate_guardrail_breachevent with the cell key and both bounds; a rate below floor is a revenue leak and a rate above ceiling is a conversion killer, so neither is ever distributed. - Stale or missing competitor benchmark (degrade, do not fail): if the competitor rate shopping feed is missing or older than its freshness budget, the competitor rule’s predicate returns
Falseand the cell is priced on demand rules alone, with acompetitor_benchmark_skippedflag on the decision — a benched competitor rule must never yank a live rate toward a zero or an off-market figure. - Predicate raises (terminal for the cell): a rule whose predicate throws on malformed facts (a
Noneoccupancy, a non-numeric benchmark) is a data bug, not a transient fault. Catch it per-cell, route that one cell to a dead-letter sink with the offending facts, and let the rest of the batch price — one poisoned cell must not abort a portfolio run. - Idempotency: pricing is deterministic, so the natural idempotency key is
sha256(property_id | room_type_code | rate_plan_code | stay_date | ruleset_version | base_rate). Re-running the engine on unchanged inputs produces the same key and the same rate, so a replay after a downstream failure is a no-op the parity layer can safely deduplicate.
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.
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.
Related
- Building a yield rule pipeline with Polars — applying this ordered rule model vectorized across a whole portfolio dataframe.
- Backtesting pricing rules against historical demand — replaying a candidate rule set over historical bookings to estimate its RevPAR and ADR impact before shipping.
- Competitor rate shopping ingestion — the normalized benchmark feed the adjust phase consumes.
- Parity monitoring and alerting — where the authoritative rate this engine emits is defended against every channel.
- Rate plan taxonomy design — the immutable base rate the convert phase resolves from.