Price Hierarchy & Rule-Based Fallback Routing
Raw competitor price ingestion is only half the battle. The deterministic transformation of scraped, noisy, or incomplete price signals into actionable, auditable pricing decisions requires a rigorously isolated evaluation layer that decides which price wins when several candidate values exist for the same product. This guide details a production-grade price hierarchy and fallback router that sits under Core Architecture & Catalog Matching Fundamentals, consumes resolved identities from Fuzzy Matching Algorithms for SKU Alignment, and reads from the canonical entity model defined in Building a Unified Product Catalog Schema. It targets Python developers building ingestion pipelines, e-commerce analysts configuring pricing logic, and infrastructure engineers hardening live systems.
Problem Framing & Prerequisites
Without a dedicated routing layer, pricing logic leaks into scraping runners and catalog workers, and the system loses its single source of truth for “what price applies right now.” The symptoms are predictable: a flash-sale anomaly from one retailer overwrites a negotiated B2B rate, a stale cache silently undercuts a MAP floor, and nobody can explain after the fact why a SKU repriced. The router exists to make that decision once, deterministically, with a recorded reason.
The component must operate as a stateless, horizontally scalable worker pool, communicating exclusively via a message broker (Kafka, RabbitMQ, or AWS SQS) with strict schema contracts. Three upstream stages must run before it. First, ingestion delivers immutable, source-tagged payloads — covered in Scraping & Data Ingestion Workflows — so every routed price traces back to a URL and scrape timestamp. Second, normalization must have reconciled currency and unit basis: the Data Normalization & Promo Parsing Pipelines stage, including currency conversion and exchange-rate sync and tax and shipping-cost normalization, guarantees the router compares like-for-like figures rather than mixing a tax-inclusive EUR price with a bare USD one. Third, identity resolution must have attached a confidence score, because a low-confidence match should never be allowed to drive a live price.
Ingress payloads are validated against a formal contract before entering the evaluation queue. Pydantic models (or the equivalent JSON Schema definition) enforce type safety and predictable routing; anything that fails validation goes to a dead-letter queue rather than the evaluator.
from pydantic import BaseModel, Field
from typing import Optional
class PriceCandidate(BaseModel):
canonical_sku: str # resolved internal identifier
gtin: Optional[str] = None
mpn: Optional[str] = None
competitor_id: str # source of this observation
region_code: str # ISO-3166, drives regional overrides
channel: str # "dtc", "marketplace", "wholesale"
observed_price: float # already normalized to base currency
currency: str # base currency after conversion
availability_flag: bool
shipping_cost: float = 0.0
scrape_timestamp: str # ISO-8601, for staleness + audit
match_confidence: float = Field(ge=0.0, le=1.0) # from SKU alignment
By enforcing strict boundaries, scraper instability, anti-bot throttling, or DOM parsing failures never cascade into live pricing decisions. Downstream consumers — dashboards, automated repricers, alerting systems — receive only normalized outputs carrying explicit routing metadata (applied_rule_id, fallback_tier, data_freshness_score, evaluation_trace_id). This isolation enables independent deployment cycles, targeted load testing, and precise observability without disturbing the broader pipeline.
Algorithm or Architecture Detail
Price hierarchy evaluation is fundamentally a precedence-resolution problem. Unlike the probabilistic scoring in catalog matching, fallback routing must be fully deterministic, auditable, and reversible: the same inputs must always produce the same applied price and the same recorded reason. Model the rule set as an ordered chain of predicates that short-circuits at the first satisfied condition, rather than a tangle of nested conditionals scattered across services.
The canonical precedence stack for enterprise retail typically reads top-down:
- Contractual / B2B overrides — volume discounts, negotiated rate cards, and partner-specific agreements that legally supersede any observed market price. Region- and customer-tier variations of these overrides are handled in the child guide on setting up price override rules for regional variants.
- Regional regulatory or promotional overrides — tax jurisdictions, VAT thresholds, geo-fenced promotions, and localized compliance mandates keyed on
region_code. - Channel-specific pricing — marketplace fee absorption, DTC margin targets, and wholesale distributor floors keyed on
channel. - MSRP / MAP compliance floors — automated guardrails that prevent brand devaluation and retailer-agreement violations.
- Last-known-valid (LKV) cached baseline — a time-bound historical price used when live signals degrade.
- Competitor-derived proxy pricing — algorithmic estimation when direct identity alignment fails.
Each tier is a small, pure function returning either a decision or None (meaning “I do not apply, continue”). The engine walks them in order and stops at the first hit:
from dataclasses import dataclass
from typing import Callable, Optional
@dataclass(frozen=True)
class Decision:
price: float
rule_id: str
tier: str
reason: str
# Each rule: (candidate, context) -> Decision | None
Rule = Callable[[PriceCandidate, "RoutingContext"], Optional[Decision]]
def route(candidate: PriceCandidate, ctx: "RoutingContext",
chain: list[Rule]) -> Decision:
for rule in chain: # ordered, highest precedence first
decision = rule(candidate, ctx)
if decision is not None: # short-circuit on first match
return decision
# The chain MUST be total: a terminal proxy rule always returns a Decision.
raise RoutingError(f"no rule matched {candidate.canonical_sku}")
The confidence score acts as a gate before the competitive tiers ever run. A match below the per-category cutoff bypasses direct competitor pricing entirely and routes to LKV or proxy, so a shaky alignment can never misprice live inventory:
def competitor_price_rule(c: PriceCandidate, ctx) -> Optional[Decision]:
if c.match_confidence < ctx.min_confidence: # gate on alignment quality
return None # defer to LKV / proxy tiers
if not c.availability_flag:
return None # out-of-stock is not a price
return Decision(c.observed_price, "competitor.direct",
tier="competitor", reason="DIRECT_MATCH")
The data-structure choice matters. Representing the chain as an explicit ordered list — rather than nested if/elif — keeps precedence reviewable in code review, makes every tier independently unit-testable, and lets the chain be assembled from versioned configuration at deploy time. Short-circuit evaluation keeps the common case (a confident direct match) at O(1) in tiers traversed, while degraded SKUs pay only for the tiers they actually fall through.
Candidate Generation & Compute Optimization
A naive router re-evaluates every rule predicate for every payload, re-parses threshold configuration on each message, and hits the database for floors and caches inline. At tens of thousands of SKUs per refresh cycle that collapses the p95 budget. The optimization is to precompute and index everything that does not change per-message, so the hot path touches only in-memory structures.
Compile the rule chain once at worker startup and key the catalog-specific parameters (MAP floor, contractual rate, channel target) by canonical_sku in a RoutingContext loaded into memory or a co-located Redis cluster:
@dataclass
class RoutingContext:
min_confidence: float
map_floors: dict[str, float] # sku -> MAP floor
contracts: dict[tuple[str, str], float] # (sku, partner) -> agreed price
lkv_ttl_seconds: int
decay_half_life_seconds: int
def build_context(config, snapshot) -> RoutingContext:
"""Materialize all per-SKU parameters ONCE per deployment / refresh."""
return RoutingContext(
min_confidence=config["min_confidence"],
map_floors=snapshot.map_floors(), # bulk-loaded, not per-message
contracts=snapshot.contracts(),
lkv_ttl_seconds=config["lkv_ttl"],
decay_half_life_seconds=config["decay_half_life"],
)
Three patterns keep routing decisions under a 50 ms p95:
- Precompiled predicates. Parse the declarative rule definition (JSON/YAML) into closures at startup, never per message. The hot path calls functions, not an interpreter.
- Indexed lookups, not scans. Floors, contracts, and category mappings are dictionary lookups keyed on the canonical identifier and
region_code; nothing iterates the catalog at evaluation time. - Cached LKV with a staleness decay function. The fallback price is not a flat cached value — it is exponentially discounted toward a conservative baseline as it ages, so a long scraper outage gradually loses influence instead of pinning a stale figure.
import math, time
def lkv_rule(c: PriceCandidate, ctx: RoutingContext) -> Optional[Decision]:
cached = ctx_cache_get(c.canonical_sku, c.region_code) # O(1) KV read
if cached is None:
return None
age = time.time() - cached.observed_at
if age > ctx.lkv_ttl_seconds:
return None # too stale, fall through
# Exponential decay weights newer observations far above older ones.
weight = math.exp(-math.log(2) * age / ctx.decay_half_life_seconds)
freshness = round(weight, 4)
return Decision(cached.price, "lkv.cached", tier="last_known_valid",
reason="STALE_DATA", ) # freshness recorded in audit
Batch scoring should run as a broker consumer scaled against queue depth — the same async patterns documented in Async Data Pipelines with Python & Scrapy. When direct signals are missing entirely, the terminal proxy tier can lean on an official API fallback source before resorting to a purely algorithmic estimate.
Configuration & Threshold Tuning
Fallback routing is a strategic trade-off, not a single safety net, and the knobs are category-specific. High-velocity electronics tolerate almost no stale-price exposure and demand tight TTLs; commoditized consumables can ride a cached baseline far longer without competitive harm. Store every value below as versioned configuration — never as inline constants — so a later investigation can reconstruct exactly which settings produced a given decision.
| Category | Min match confidence | LKV TTL | Decay half-life | MAP enforcement | Proxy markup band | Notes |
|---|---|---|---|---|---|---|
| Consumer electronics | 0.92 | 6 h | 2 h | Hard block | ±3% | Flash-sale anomalies must not overwrite contract prices |
| Apparel & footwear | 0.85 | 24 h | 8 h | Soft warn | ±8% | Seasonal markdowns widen the acceptable band |
| Grocery & consumables | 0.88 | 12 h | 4 h | Soft warn | ±5% | Requires upstream unit-price normalization |
| Home & furniture | 0.86 | 48 h | 18 h | Hard block | ±6% | Low refresh cadence justifies long TTL |
| Media & books | 0.95 | 24 h | 12 h | Hard block | ±2% | Prefer exact identifier; proxy is rare |
The trade-offs each row encodes:
- Freshness vs. stability. Aggressive fallback to LKV prevents volatility during scraper outages but risks competitive lag. The decay half-life is the dial: a short half-life discounts stale prices quickly toward a conservative baseline.
- Compliance vs. competitiveness. Hard MAP floors protect brand equity but can cost conversions when competitors undercut. Use soft warnings to inform analysts while enforcing hard blocks only for automated repricers, so a human can still override at the margin.
- Compute vs. latency. Deep cross-platform resolution improves accuracy but adds evaluation time; precompute category mappings (see Cross-Platform Category Taxonomy Mapping) and cache resolution graphs to keep the hot path fast.
Calibrate min_confidence against a labeled ground-truth sample per vertical rather than guessing a round number, and re-check it whenever a vendor changes its title format or a source’s reliability shifts. Predictive models can anticipate price gaps before they manifest, but they must augment the deterministic chain, never replace it — in regulated or high-stakes pricing, the rule-based path remains the production baseline of record.
Failure Modes & Mitigations
Routing fails in characteristic, repeatable ways. Each has a concrete guard that belongs in code, not in a runbook footnote.
- Flash-sale anomalies overwriting contracts. A competitor’s two-hour doorbuster arrives as a legitimate
observed_priceand, without precedence, can undercut a negotiated B2B rate. Mitigation: contractual and MAP tiers sit above the competitor tier in the chain, so a short-circuit there means the anomaly is never even considered. Pair this with statistical outlier detection upstream to flag the anomaly before it reaches routing. - Currency or tax drift. A price that skipped normalization compares a tax-inclusive figure against a bare one and trips a false MAP violation. Mitigation: reject any candidate whose
currencyis not the configured base, and treat unnormalized payloads as schema failures. - Stale-cache lock-in. A prolonged outage leaves LKV pinning an obsolete price indefinitely. Mitigation: the TTL hard-stops the cached tier and the decay function bleeds its weight; once past TTL the chain falls through to the proxy estimate rather than serving a frozen value.
- Non-total chains. A SKU matches no tier and the router raises mid-batch. Mitigation: the terminal proxy rule must always return a
Decision; assert chain totality in a startup self-test against a synthetic catalog.
def map_floor_rule(c: PriceCandidate, ctx: RoutingContext) -> Optional[Decision]:
floor = ctx.map_floors.get(c.canonical_sku)
if floor is None:
return None
if c.currency != ctx.base_currency:
raise RoutingError("unnormalized currency reached router") # fail loud
if c.observed_price < floor:
# Enforce the floor rather than publishing a violating price.
return Decision(floor, "compliance.map_floor", tier="map",
reason="COMPLIANCE_VIOLATION")
return None
When a candidate lands below the confidence gate, the router does not guess — it defers to LKV or proxy and records the deferral, exactly as the SKU-alignment stage routes review-band scores here for deterministic tie-breaking.
Compliance & Auditability
Every pricing decision must be reconstructable. In regulated markets or multi-channel retail, unexplainable fluctuations trigger compliance reviews, partner disputes, and revenue leakage. The router emits a structured, deterministic record for every evaluated payload — this is what defends a pricing strategy during an audit or an SLA dispute.
Emit distributed traces via the OpenTelemetry standard and persist an append-only audit record capturing:
rule_chain_evaluated— the ordered list of tiers checked before the hitfallback_trigger_reason— an explicit enum (STALE_DATA,COMPLIANCE_VIOLATION,LOW_MATCH_CONFIDENCE,REGIONAL_OVERRIDE,DIRECT_MATCH)audit_hash— a cryptographic signature over the input payload, applied rule, and timestampmargin_impact_delta— the projected P&L shift relative to the previously valid price
audit_record = {
"canonical_sku": candidate.canonical_sku,
"region_code": candidate.region_code,
"applied_rule_id": decision.rule_id,
"fallback_tier": decision.tier,
"fallback_trigger_reason": decision.reason,
"rule_chain_evaluated": ["contract", "regional", "channel", "map"],
"data_freshness_score": 1.0,
"chain_version": "electronics-v7",
"scrape_timestamp": candidate.scrape_timestamp,
"evaluation_trace_id": trace_id,
}
Store traces in an append-only log (CloudWatch, Datadog, or a dedicated PostgreSQL table with row-level security) and retain them for the full audit window your jurisdiction requires. Logs redact or hash any PII, and the precedence chain itself is version-controlled so a given decision is reproducible across pricing-model iterations — a change to a threshold or tier order must bump chain_version and propagate into the record. For analysts, expose a queryable interface that correlates applied_rule_id with conversion-rate shifts, enabling rapid hypothesis testing without engineering intervention. Scraping compliance — robots.txt, rate limits, data-use terms — is owned upstream; the router inherits the obligation to preserve that lineage end-to-end.
Deployment Checklist
A production-grade price hierarchy and fallback router turns chaotic competitor intelligence into a controlled, auditable pricing engine. By decoupling ingestion from evaluation, enforcing schema contracts, ordering deterministic precedence chains, and hardening fallback state with TTLs and decay, retail teams scale price monitoring without sacrificing compliance or margin integrity — and the routing layer remains the foundational control plane that keeps every published price deterministic, reversible, and explainable.
Related
- Core Architecture & Catalog Matching Fundamentals — the parent guide that frames how matched records become a comparable, routable price feed.
- Setting Up Price Override Rules for Regional Variants — the step-by-step build for the regional and customer-tier override tiers at the top of the chain.
- Fuzzy Matching Algorithms for SKU Alignment — produces the confidence score that gates the competitor pricing tiers.
- Building a Unified Product Catalog Schema — the canonical entity model the router reads floors and contracts against.
- Statistical Outlier Detection for Price Data — flags flash-sale anomalies before they reach the precedence chain.