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.

Portfolio price index plotted against a market-median reference bandA scatter chart. The vertical axis is the competitive price index, your price divided by the market median, running from 0.80 at the bottom to 1.20 at the top with the value 1.00 marked as the market median. A shaded horizontal band from 0.97 to 1.03 marks the at-market zone around the median. Each dot is one SKU in the portfolio. Dots above the band are priced above market, dots inside the band are at market, and dots below the band are priced below market. A legend at the top maps the three colours to above, at, and below market.Above marketAt marketBelow market1.201.101.000.900.80market reference band (median ±3%)SKUs across the portfolioprice index (your price ÷ market median)priced above marketpriced below market

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.

ParameterTypical valueApplies toNotes
Reference statisticmedianDefault positioningmin for price-match SKUs, trimmed_mean when ≥5 competitors
At-market band (half-width)±3%Electronics, brandedWiden to ±6–8% for private label / commodities
Staleness cutoff24 hFast-moving marketplaces72 h for slow B2B or MAP-protected categories
Min competitors for reference3Trimmed mean pathBelow this, fall back to median and flag low coverage
Trim fraction0.10 each sideTrimmed mean0.20 for noisy sources with frequent scrape errors
Outlier clip (MAD multiplier)3.5Reference inputsDrop observations beyond 3.5·MAD from the median
Tier-1 competitor weight1.0Direct rivalsFull influence on the reference
Tier-2 competitor weight0.5Adjacent / marketplaceHalved influence
Tier-3 competitor weight0.2Grey-market / resellersPresent 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.01 makes you look expensive. The in_stock filter in build_reference is 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.99 into $12,999 or 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 NaN index must propagate as no_reference, never collapse to 1.00. A single fillna(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.