Automated Repricing Rule Engines
A repricing engine is the component that turns a competitive dataset into an actual price change, and it is the last place in the pipeline where a bug becomes real money. Unlike the upstream matching and normalization stages, its outputs mutate live storefront prices, trigger marketplace fee recalculations, and — if it oscillates — can start a price war with a competitor’s own bot. This guide details how to build a deterministic, ordered, fully auditable rule engine: how to represent rules as data, evaluate them in a defined order with short-circuiting, choose between cost-plus, competitive, and Buy-Box strategies, and guarantee idempotency and a dry-run path before anything reaches production. It sits under Competitive Price Intelligence & Repricing and consumes the competitive index produced by price positioning and competitive benchmarking; its floor logic is tightly coupled to MAP violation and price-war detection, which supplies the minimum-advertised-price constraints the engine must never cross.
Problem Framing & Prerequisites
The temptation with repricing is to write a tall stack of if/elif branches directly against a product row and ship it. That works for one storefront and one strategy, and it becomes unmaintainable the moment a second category needs different logic, or a stakeholder asks “why did SKU 44182 drop to $18.99 last Tuesday?” and the honest answer is “somewhere in three hundred lines of nested conditionals.” A rule engine solves the second problem by treating rules as data, not control flow: an ordered list of (condition, action) pairs the engine evaluates uniformly, so the same evaluation core drives every category, every decision is attributable to exactly one rule, and the whole configuration is diffable in version control.
The engine assumes three upstream contracts are already satisfied. First, every product carries a resolved identity and a comparable competitive picture. The competitor minimum, median, and the normalized price index (your price divided by the market reference, so 1.00 means parity) come from price positioning and competitive benchmarking; the engine consumes that index rather than re-deriving it. Second, the cost figure is a true landed cost — unit cost plus inbound freight, duties, and marketplace referral fees — not a purchase-order line, because the cost-plus floor is only as trustworthy as the number under it. Third, MAP constraints and any active price-war signals arrive as explicit fields, sourced from MAP violation and price-war detection, so the engine treats them as hard inputs rather than discovering them.
The single input record the engine evaluates is a frozen snapshot. Freezing it matters: the engine must be a pure function of its input so that the same state always yields the same decision, which is what makes dry-run runs meaningful and audits reproducible.
from dataclasses import dataclass, field
from typing import Optional
@dataclass(frozen=True)
class ProductState:
sku: str
current_price: float # live storefront price
landed_cost: float # unit cost + freight + duties + fees
competitor_min: float # lowest in-stock competitor offer
price_index: float # our price / market reference (1.0 = parity)
stock: int # our on-hand units
velocity_7d: int # units sold, trailing 7 days
map_floor: Optional[float] = None # minimum advertised price, if bound
owns_buybox: bool = False # do we currently hold the Buy-Box?
buybox_price: Optional[float] = None
Anything that cannot be assembled into a valid ProductState — a null cost, a nonsensical negative index — is diverted to a dead-letter queue and never reaches the evaluator, exactly as malformed records are quarantined before the matcher in fuzzy matching algorithms for SKU alignment.
Algorithm & Architecture Detail
A rule has two halves: a condition that inspects the ProductState and returns a boolean, and an action that, when the condition is true, returns a candidate price and a human-readable reason. Representing both as callables keeps the engine oblivious to what any particular rule actually does, so new strategies are added by appending to a list rather than editing the evaluator.
from dataclasses import dataclass
from typing import Callable, Optional
@dataclass(frozen=True)
class Decision:
sku: str
proposed_price: Optional[float] # None => hold / no change
rule_name: str
reason: str
@dataclass(frozen=True)
class Rule:
name: str
when: Callable[[ProductState], bool] # condition predicate
then: Callable[[ProductState], Decision] # action, builds a Decision
enabled: bool = True
The evaluator itself is deliberately tiny. It walks the ordered list, and the first rule whose condition is true wins — its action produces the decision and evaluation stops. This short-circuit is the core semantic: rule order encodes priority, so a MAP or cost-plus safety rule placed at the top can never be overridden by a more aggressive competitive rule below it.
def evaluate(state: ProductState, ruleset: list[Rule]) -> Decision:
"""Return the Decision from the first enabled rule that matches.
Ordered, short-circuiting: rule position encodes priority. A ruleset
should always end with a catch-all so evaluate() is total.
"""
for rule in ruleset:
if not rule.enabled:
continue
if rule.when(state):
return rule.then(state)
# Defensive fallback: a well-formed ruleset never reaches here.
return Decision(state.sku, None, "no_rule", "no rule matched; holding")
The three canonical strategies are just three rules with different conditions and actions:
- Cost-plus protects margin. Its condition asks whether following the market would push price below
landed_cost × (1 + min_margin); if so it fires and returns that floor. Placed first, it makes margin non-negotiable regardless of what competitors do. - Competitive chases position. Its condition inspects the
price_indexagainst a target band — say, “we want to sit at parity to 2% under the market reference” — and its action sets price tocompetitor_min - delta, undercutting by a configured amount only when doing so is warranted. - Buy-Box optimizes for the marketplace’s featured-offer slot. On platforms where winning the Buy-Box drives the majority of sales, the action shaves a single tick below
buybox_pricewhen the state shows we are eligible but not currently winning, and deliberately does not keep cutting once we already own it.
A concrete ruleset wiring these together, ordered by priority:
MIN_MARGIN = 0.15
UNDERCUT_DELTA = 0.01
def cost_plus_floor(min_margin=MIN_MARGIN):
floor = lambda s: round(s.landed_cost * (1 + min_margin), 2)
return Rule(
name="cost_plus_floor",
when=lambda s: s.competitor_min < floor(s), # market below our floor
then=lambda s: Decision(s.sku, floor(s), "cost_plus_floor",
f"held at margin floor {floor(s):.2f}"),
)
def competitive(target_hi=1.00, delta=UNDERCUT_DELTA):
return Rule(
name="competitive",
when=lambda s: s.price_index > target_hi, # we are priced above band
then=lambda s: Decision(s.sku, round(s.competitor_min - delta, 2),
"competitive", "undercut competitor_min"),
)
def buybox(tick=0.01):
return Rule(
name="buybox",
when=lambda s: not s.owns_buybox and s.buybox_price is not None,
then=lambda s: Decision(s.sku, round(s.buybox_price - tick, 2),
"buybox", "shave a tick below Buy-Box"),
)
hold = Rule(name="hold", when=lambda s: True,
then=lambda s: Decision(s.sku, None, "hold", "within band; no change"))
RULESET = [cost_plus_floor(), competitive(), buybox(), hold]
The hold rule at the tail is not optional cosmetics — it makes evaluate a total function, guaranteeing every product exits with an explicit decision rather than falling through to undefined behavior. The full worked implementation of this evaluator, with tests, lives in rule-based repricing logic in Python.
Evaluation Order, Idempotency & Dry-Run
Three properties separate a production engine from a prototype, and none of them are about the rules themselves.
Short-circuiting is a feature, not an optimization. Because evaluation stops at the first match, the ruleset is read top-to-bottom as a priority list. Safety rules — cost-plus floor, MAP compliance — live at the top; aggressive rules live below; the catch-all lives at the bottom. Reordering the list changes behavior in a way that is obvious on a diff, which is exactly the auditability property you want. Never write a rule whose condition silently overlaps a higher one and assume both run; only the first fires.
Idempotency prevents thrash. The engine must be a pure function of ProductState, and re-running it on an unchanged state must produce an unchanged decision. Concretely, a competitive rule that sets price to competitor_min - 0.01 will, on the next cycle, see its own new price as the market and want to cut again — a self-inflicted downward spiral. The fix is that the action targets an absolute value derived from external inputs (the competitor’s price), and the engine emits no change when the proposed price equals the current price within a cent:
def is_noop(state: ProductState, decision: Decision, eps: float = 0.005) -> bool:
"""Suppress churn: a proposal equal to the live price is not a reprice."""
if decision.proposed_price is None:
return True
return abs(decision.proposed_price - state.current_price) <= eps
Dry-run is mandatory before any write. Every engine run should support a mode that computes decisions and emits the full decision log without dispatching a single price change to the marketplace. This is how you validate a ruleset edit against last night’s real states and read the diff before it touches revenue. A dry-run that produces the same decision log as the subsequent live run — because the engine is a pure function — is the strongest safety property the system has.
The candidate price a rule produces is never trusted blindly. It flows through a separate clamp layer that enforces hard floors, ceilings, and a maximum step per cycle, which is significant enough to warrant its own treatment in price floors and guardrails in repricing. Keeping the guardrails outside the rules is a deliberate separation of concerns: rules express strategy, guardrails express inviolable limits, and no rule author can accidentally weaken a limit.
Configuration & Threshold Tuning
Every constant above — margins, undercut deltas, bands, ticks — belongs in versioned configuration keyed by category, not inline in the code. High-velocity commodities are repriced aggressively toward the market; slow-moving or exclusive items protect margin and barely move. The table is the contract between the pricing strategist and the engine.
| Parameter | Typical range | Applies to | Effect if too high | Effect if too low |
|---|---|---|---|---|
min_margin (cost-plus) | 0.08 – 0.30 | All categories | Uncompetitive; lose Buy-Box | Margin erosion; sell at a loss on fee spikes |
target_hi (index band) | 0.98 – 1.05 | Competitive rule | Rarely reprices; drifts above market | Constant churn; chases every fluctuation |
undercut_delta | 0.01 – 0.50 | Competitive rule | Leaves margin on the table | Triggers competitor bots; price war |
buybox_tick | 0.01 – 0.05 | Buy-Box marketplaces | Fails to win the box | Unnecessary give-back once winning |
noop_eps | 0.005 – 0.02 | All | Suppresses real moves | Emits micro-changes; log noise |
max_step_pct (guardrail) | 0.05 – 0.20 | All | Oscillation, visible price swings | Slow to reach target; stale pricing |
Tune against a replay harness: take a week of real ProductState snapshots, run the proposed ruleset in dry-run, and inspect the distribution of proposed changes before promoting the config. A parameter change that would have moved 40% of the catalog by more than 10% is almost always a mistake caught here rather than in production. Store the resolved config with a version tag and stamp that tag onto every decision, exactly as threshold versions are recorded in the matching stage.
Failure Modes & Mitigations
Repricing engines fail in recognizable ways, and each failure has a mechanical guard.
- Oscillation / price wars. Two bots undercutting each other by a cent per cycle race prices to the floor within hours. The primary defenses are a maximum step per cycle and a cool-down window that bars re-pricing the same SKU inside N minutes — both enforced in the guardrail layer, not the rules. A cost-plus floor at the top of the ruleset is the backstop that keeps the war above break-even.
- Stale or partial inputs. A
competitor_minfrom a scrape that silently failed can beNoneor wildly stale, and a competitive rule acting on it reprices to a phantom market. Gate every rule condition on input freshness: if the competitive picture is older than one cycle, the competitive and Buy-Box rules must not fire, and the engine falls through to hold. - Cost drift. Landed cost moves when freight or fee schedules change; a cost-plus floor computed from yesterday’s cost can sell below true break-even. Recompute landed cost inside the
ProductStateassembly, never cache it across a fee-schedule change, and alert when the floor crosses the current price. - Rule overlap and dead rules. As rulesets grow, a broad early condition can shadow a later rule so it never fires. Lint the ruleset offline by replaying labeled states and asserting each rule fires at least once over a representative sample; a rule with zero fires is either dead or misordered.
The same statistical hygiene used to reject fabricated discounts upstream — see statistical outlier detection for price data — protects the engine’s inputs: a competitor “price” that is a decimal-point error must be filtered before it becomes your new price.
Compliance & Auditability
Because every repricing decision can move a real price, every decision must be reconstructable months later. The engine writes one immutable decision record per evaluation, whether or not a change was dispatched, capturing the full input state, which rule fired, the proposed price, the guardrail adjustments, and the config version in force.
decision_log = {
"sku": "44182",
"evaluated_at": "2026-07-15T02:14:07Z",
"input_state": {"current_price": 21.99, "landed_cost": 14.20,
"competitor_min": 19.49, "price_index": 1.06, "stock": 40},
"rule_fired": "competitive",
"candidate_price": 19.48,
"guardrail": {"clamped": True, "reason": "max_step", "final": 20.79},
"proposed_price": 20.79,
"dispatched": False, # dry-run
"config_version": "electronics-repricer-v11",
}
Three obligations sit on this log. MAP compliance is contractual: a decision that would breach a supplier’s minimum advertised price must be blocked and logged as blocked, never dispatched — the detection of those breaches is covered in MAP violation and price-war detection. Reproducibility means the engine is version-controlled end to end, so replaying a stored input_state through the tagged config yields the identical decision. And retention means these records are kept for the full audit period your jurisdiction and supplier agreements require, because a decision log is what defends a pricing strategy in a channel dispute.
Deployment Checklist
A repricing rule engine earns its place by being boring: deterministic, ordered, and auditable, so that the interesting work of pricing strategy happens in reviewable configuration rather than in code no one dares to touch. Isolate the evaluator, keep the guardrails outside it, and log every decision, and the engine becomes a component you can reason about even when it is moving thousands of prices an hour.
Related
- Competitive Price Intelligence & Repricing — the parent guide that frames how a competitive dataset becomes an automated pricing action.
- Rule-Based Repricing Logic in Python — the end-to-end, tested implementation of the evaluator sketched here.
- Price Floors and Guardrails in Repricing — the clamp layer that enforces floors, ceilings, and max-step outside the rules.
- Price Positioning & Competitive Benchmarking — produces the price index and competitor reference the engine consumes.
- MAP Violation & Price-War Detection — supplies the MAP constraints and war signals the engine must respect.