Price Positioning & Competitive Benchmarking
Once competitor prices have been scraped, matched to your catalog, and normalized to a common currency and unit, the next question is analytical rather than mechanical: where does each of your SKUs actually sit against the market, and by how much? Price positioning turns a wall of competitor observations into a small set of decision-ready numbers — a per-SKU price index, a signed price gap, and a positioning band that says below, at, or above market. This guide sits under Competitive Price Intelligence & Repricing and is the measurement layer the rest of that guide depends on: the index computed here is the input to automated repricing rule engines, and its interpretation is sharpened by price elasticity and demand modeling, which tells you whether closing a gap is worth the margin. Everything here assumes the input prices arrive already matched and normalized — the matching contract comes from Core Architecture & Catalog Matching Fundamentals and the price cleaning from Data Normalization & Promo Parsing Pipelines. Benchmarking against dirty inputs produces confident, wrong answers.
Problem Framing & Prerequisites
The naïve version of benchmarking — “are we cheaper than Amazon?” — collapses the moment you have more than one competitor or more than one SKU. A single competitor is a bad reference because it may itself be mispriced, out of stock, or running a clearance. A raw min-price comparison chases the most aggressive discounter in the set and reprices you into a loss on every SKU where one seller is dumping inventory. What you actually want is a stable, defensible reference per SKU and a normalized distance from it that is comparable across a $12 accessory and a $1,900 appliance. That normalized distance is the price index.
The price index for a SKU is your price divided by a market reference price:
$$\text{index}i = \frac{p_i^{\text{own}}}{r_i}, \qquad r_i = \operatorname{ref}\big({p : c \in C_i}\big)$$
where $C_i$ is the set of competitors carrying SKU $i$ and $\operatorname{ref}$ is a reference statistic — minimum, median, or trimmed mean — over their prices. An index of 1.00 means you are exactly at the reference; 1.08 means you are 8% above it; 0.94 means 6% below. Because it is a ratio, it is unit- and currency-free once the inputs are normalized, so it aggregates cleanly across a whole portfolio. The signed price gap is the same information in currency terms, $p_i^{\text{own}} - r_i$, which is what a merchandiser reads when deciding whether a 4% gap on a low-ticket item is even worth acting on.
Three input contracts must hold before any of this is trustworthy. First, every competitor price must already be attached to the correct canonical product — a mismatched SKU silently poisons the reference, so the alignment described in fuzzy matching algorithms for SKU alignment is a hard prerequisite. Second, prices must be normalized to one currency and one unit basis, including tax and shipping treatment, per currency conversion and exchange-rate sync and tax and shipping cost normalization; comparing a tax-inclusive EU price to a tax-exclusive US price manufactures a phantom gap. Third, each observation must carry a freshness timestamp and a stock flag, because a stale or out-of-stock price is not a market signal and must be excluded from the reference, not averaged into it.
The observation record the benchmarker consumes:
from pydantic import BaseModel
from datetime import datetime
class PriceObservation(BaseModel):
product_id: str # canonical id after matching
competitor_id: str # normalized competitor key
price: float # base currency, tax/shipping-normalized
currency: str # should already be your base, e.g. "USD"
in_stock: bool # False observations never enter the reference
observed_at: datetime # for staleness cutoff
Algorithm & Architecture Detail
The reference statistic is the most consequential design choice, and each option encodes a different assumption about the competitive set. The minimum answers “what is the cheapest anyone charges” — useful for a price-match guarantee, dangerous as a repricing target because it is maximally sensitive to a single outlier or a fat-finger scrape. The median answers “what does the typical seller charge” and is robust to a couple of extreme values by construction, which makes it the default reference for positioning. The trimmed mean — discard the top and bottom decile, average the rest — sits between the two: more stable than a raw mean, more informative than a median when the middle of the distribution has real spread. For a competitor set of two or three sellers the median and trimmed mean converge, so the choice only matters once you have five or more observations per SKU.
The core computation is a group-by over PriceObservation rows, filtered to fresh, in-stock competitor prices, reducing each SKU’s competitor set to one reference number and then dividing your own price into it.
import pandas as pd
import numpy as np
from datetime import datetime, timedelta, timezone
def trimmed_mean(s: pd.Series, trim: float = 0.10) -> float:
"""Mean after dropping the lowest and highest `trim` fraction each side."""
if len(s) < 5: # too few points to trim meaningfully
return float(s.median()) # fall back to the robust default
lo, hi = s.quantile(trim), s.quantile(1 - trim)
kept = s[(s >= lo) & (s <= hi)]
return float(kept.mean()) if len(kept) else float(s.median())
def build_reference(obs: pd.DataFrame, staleness_hours: int = 24) -> pd.DataFrame:
"""Reduce competitor observations to one reference row per product_id."""
cutoff = datetime.now(timezone.utc) - timedelta(hours=staleness_hours)
fresh = obs[(obs["in_stock"]) & (obs["observed_at"] >= cutoff)].copy()
ref = fresh.groupby("product_id")["price"].agg(
ref_min="min",
ref_median="median",
ref_trimmed=lambda s: trimmed_mean(s),
n_competitors="count",
).reset_index()
return ref
Joining your own catalogue prices onto this reference table produces the index and gap in two vectorized operations. Keeping the reference statistic a named column rather than a hard-coded choice lets a category use ref_median while a price-match SKU uses ref_min, without a second code path:
def compute_positioning(own: pd.DataFrame, ref: pd.DataFrame,
reference_col: str = "ref_median") -> pd.DataFrame:
df = own.merge(ref, on="product_id", how="left")
df["reference"] = df[reference_col]
df["price_index"] = df["own_price"] / df["reference"]
df["price_gap"] = df["own_price"] - df["reference"] # signed, currency
df["gap_pct"] = df["price_index"] - 1.0 # signed, unit-free
return df
A SKU with no fresh in-stock competitor gets a null reference from the left join, and that is the correct outcome — its price_index is NaN, which must be treated as “no signal” downstream rather than silently coerced to 1.0. Conflating “we are at market” with “we have no idea where the market is” is the most damaging benchmarking bug in production, because it lets a repricing engine act on absent data. The positioning band is then a simple bucketing of the index against configured cutoffs, and it is the field merchandisers and rule engines actually consume.
def assign_band(idx: float, low: float = 0.97, high: float = 1.03) -> str:
if pd.isna(idx):
return "no_reference" # never treat missing as at-market
if idx < low:
return "below_market"
if idx > high:
return "above_market"
return "at_market"
Candidate Generation & Compute Optimization
Benchmarking is embarrassingly parallel across products, so the scaling story is about doing the group-by cheaply and doing it incrementally rather than about clever candidate pruning. At a few hundred thousand SKUs with a handful of competitors each, the entire positioning table recomputes in a couple of seconds with a single groupby in pandas or polars; the cost only becomes visible when you recompute the full portfolio on every scrape event instead of on a schedule.
Two optimizations matter in practice. The first is pre-aggregating the reference and caching it: the reference statistics change only when a competitor observation lands, so materialize the ref table to a key-value store keyed by product_id and update just the rows whose competitor set changed on this scrape. Your own price changing does not require rebuilding the reference at all — only re-dividing. The second is keeping the freshness filter and the group-by in one pass; splitting them into a Python-level loop over products is the usual reason a benchmarking job that should take seconds takes minutes. The narrow, runnable recipe for the index computation itself, including the incremental portfolio roll-up, is building a competitive price index; the complementary problem of deciding when a competitor’s number has actually moved — so you know which references to recompute — is covered in detecting competitor price changes.
For the portfolio view, a single scalar index summarizes where the whole catalogue sits, but an unweighted mean of per-SKU indices is misleading: it lets a thousand $5 accessories drown out the twenty products that carry the business. Weight the portfolio index by each SKU’s revenue contribution so the number tracks where your money actually competes:
$$\text{portfolio index} = \frac{\sum_i w_i \cdot \text{index}_i}{\sum_i w_i}, \qquad w_i = \text{revenue}_i$$
def portfolio_index(df: pd.DataFrame, weight_col: str = "trailing_revenue") -> float:
valid = df.dropna(subset=["price_index"]) # exclude no-reference SKUs
w = valid[weight_col]
return float(np.average(valid["price_index"], weights=w)) if w.sum() else float("nan")
Configuration & Threshold Tuning
Every number that decides an outcome — which reference statistic, how wide the at-market band, how stale is too stale, how much each competitor counts — belongs in versioned configuration, not inline. The band width in particular is a business decision, not a statistical one: a 3% half-band suits high-consideration electronics where shoppers comparison-check to the dollar, while a private-label grocery line can run a 6–8% band because nobody cross-shops a can of beans that precisely. Competitor weights encode trust and relevance: a direct national rival should pull the reference harder than a grey-market marketplace seller whose price is often a scrape artifact.
| Parameter | Typical value | Applies to | Notes |
|---|---|---|---|
| Reference statistic | median | Default positioning | min for price-match SKUs, trimmed_mean when ≥5 competitors |
| At-market band (half-width) | ±3% | Electronics, branded | Widen to ±6–8% for private label / commodities |
| Staleness cutoff | 24 h | Fast-moving marketplaces | 72 h for slow B2B or MAP-protected categories |
| Min competitors for reference | 3 | Trimmed mean path | Below this, fall back to median and flag low coverage |
| Trim fraction | 0.10 each side | Trimmed mean | 0.20 for noisy sources with frequent scrape errors |
| Outlier clip (MAD multiplier) | 3.5 | Reference inputs | Drop observations beyond 3.5·MAD from the median |
| Tier-1 competitor weight | 1.0 | Direct rivals | Full influence on the reference |
| Tier-2 competitor weight | 0.5 | Adjacent / marketplace | Halved influence |
| Tier-3 competitor weight | 0.2 | Grey-market / resellers | Present but minimal pull |
Weighting the reference itself — rather than only the portfolio roll-up — means the reference statistic must respect competitor weights. A weighted median or a weight-repeated trimmed mean does this; the simplest robust implementation expands each observation by an integer proxy of its weight before taking the median, which keeps the code a one-liner while still letting a tier-1 competitor count for five times a tier-3 one. Store the full weight map and band configuration under a version tag and stamp that tag onto every positioning row, exactly as thresholds are versioned in the price hierarchy and rule-based fallback routing stage, so a later audit can reconstruct which configuration produced a given band.
Failure Modes & Mitigations
Benchmarking fails quietly. The output is always a plausible-looking number, so the failure modes are about wrong numbers that look right, and each has a concrete guard.
- Stock-outs pulling the reference. An out-of-stock listing often keeps its last price displayed, or shows a placeholder like
$9,999. Averaged in, it drags the reference up and makes you look cheap; taken as the min, a phantom$0.01makes you look expensive. Thein_stockfilter inbuild_referenceis the primary guard — an out-of-stock observation is not a price the market is actually offering and must never enter the statistic. - Scrape-error outliers. A parser regression can turn
$129.99into$12,999or drop a decimal. A raw mean is defenseless; even the median can be dragged if half the set is corrupt. Clip inputs to the reference at a median-absolute-deviation threshold before aggregating, and tie the same detection into statistical outlier detection for price data so a corrupt source is quarantined upstream rather than repeatedly poisoning the reference. - Thin competitor coverage. A SKU with one fresh competitor has a “reference” that is really just that one seller’s price, index variance is enormous, and a single move flips your band. Enforce a minimum-competitor floor and mark thin-coverage SKUs as low-confidence so downstream repricing holds rather than chases.
- Apples-to-oranges normalization drift. If one source starts reporting tax-inclusive prices after a site change, its observations shift systematically and the reference tilts. Monitor per-competitor index distributions over time; a sudden, source-specific shift is a normalization regression, not a market move, and should page the same way a matcher DOM-mutation alert does.
- Missing reference treated as at-market. Covered above but worth repeating as a failure mode: a
NaNindex must propagate asno_reference, never collapse to1.00. A singlefillna(1.0)upstream of a repricing engine will confidently hold prices on products you cannot actually see.
Compliance & Auditability
A price index is a decision input, and in regulated or contractual contexts — minimum advertised price agreements, supplier disputes, competition-authority scrutiny — you must be able to reconstruct exactly why a SKU was placed in a given band on a given day. That means persisting, per positioning row, the reference statistic used, the set of competitor observations (with their source URLs and scrape timestamps) that fed it, the configuration version, and the resulting index, gap, and band. The audit record is deliberately verbose because its job is to answer “which competitor prices, gathered when, produced this decision” months later.
positioning_audit = {
"product_id": "p-44182",
"own_price": 189.00,
"reference": 199.00,
"reference_stat": "ref_median",
"price_index": 0.9497,
"band": "below_market",
"contributing_observations": [
{"competitor_id": "acme", "price": 199.00, "observed_at": "2026-07-15T08:02Z"},
{"competitor_id": "globex", "price": 205.00, "observed_at": "2026-07-15T07:41Z"},
],
"config_version": "positioning-v4",
}
The lawful basis for gathering the underlying observations is owned upstream in the ingestion stage; the benchmarking layer inherits the obligation to preserve that lineage and never to strip the source URL or timestamp from an observation as it collapses into a reference. Positioning outputs that will drive automated price changes must also record whether each contributing competitor is a party to a pricing agreement, so that a MAP-relevant band can be routed differently — a link made explicit in MAP violation and price-war detection.
Deployment Checklist
Price positioning is where scraped data becomes a strategy input, and its whole value rests on the reference being defensible. A robust reference statistic, a hard freshness-and-stock filter, category-tuned bands, and honest handling of missing coverage turn a noisy stream of competitor observations into an index that a repricing engine — or a human merchandiser — can trust to move real prices.
Related
- Competitive Price Intelligence & Repricing — the parent guide that frames how positioning feeds repricing, elasticity, and alerting.
- Building a Competitive Price Index — the focused, runnable recipe for the per-SKU and portfolio index computed here.
- Detecting Competitor Price Changes — decides which references to recompute when a competitor’s number actually moves.
- Automated Repricing Rule Engines — the consumer that turns a positioning band into a price change.
- Price Elasticity & Demand Modeling — tells you whether closing a measured price gap is worth the margin.
- Statistical Outlier Detection for Price Data — the upstream guard that keeps scrape errors out of the reference statistic.