DEV Community

Anakin
Anakin

Posted on

Price matching is a bad default: model the pricing decision instead

A common retail pricing bug is not a syntax error. It is a pricing service that does exactly what you told it to do: scrape a competitor price, set your price to match it, and slowly erase your margin because the competitor price was out of stock, coupon-gated, bundled, or part of a weekend promo.

Price matching is a control loop

If you build pricing software, it helps to think of price matching as a control loop:

  1. Observe competitor prices
  2. Normalize those observations
  3. Decide whether they matter
  4. Apply a price change
  5. Measure what happened

Most broken implementations spend all their effort on step 1 and almost none on steps 2 through 5.

A naive implementation looks like this:

def match_lowest_price(our_sku, competitor_offers):
    lowest = min(offer["price"] for offer in competitor_offers)
    return lowest
Enter fullscreen mode Exit fullscreen mode

That function is easy to explain and dangerous to run. It treats every competitor offer as equivalent. It ignores shipping, stock, coupons, minimum advertised price rules, product condition, seller reputation, and whether you can make money at the returned price.

The failure mode is predictable. One competitor drops a SKU from $299 to $249 for a clearance sale. Your system matches it. Another competitor matches you. Your system sees the market at $249 and keeps the new price. Nobody encoded the fact that the original signal was temporary.

Store observations, not just prices

The first fix is to stop storing a single price field as if it represents the market. A competitor offer is a bundle of facts.

A useful schema starts closer to this:

CREATE TABLE competitor_offer_observations (
    id BIGSERIAL PRIMARY KEY,
    observed_at TIMESTAMPTZ NOT NULL,
    competitor TEXT NOT NULL,
    competitor_sku TEXT NOT NULL,
    matched_internal_sku TEXT NOT NULL,
    shelf_price NUMERIC(10, 2),
    checkout_price NUMERIC(10, 2),
    shipping_price NUMERIC(10, 2),
    coupon_value NUMERIC(10, 2),
    currency CHAR(3) NOT NULL,
    in_stock BOOLEAN,
    delivery_days INT,
    seller_name TEXT,
    product_condition TEXT,
    promo_label TEXT,
    scrape_status TEXT NOT NULL,
    raw_payload JSONB NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

This gives your pricing code enough context to reject bad comparisons. A price from an out-of-stock item should usually not trigger a reaction. A marketplace seller with a two-week delivery estimate may not matter if your value proposition is next-day shipping. A coupon visible only after login may matter a lot, but you need to model it explicitly.

This is also where scraping reliability becomes part of pricing correctness, not just data plumbing. If your extractor misses coupons or silently fails on checkout pages, your pricing engine receives false market data. Wire fits this part of the workflow when you need extraction jobs that return price, availability, promotion details, and failure states instead of only a shelf-price field.

Add guardrails before automation

Before you reach for machine learning, write down the rules a human pricing manager would enforce. The rules do not need to be perfect. They need to prevent obvious damage.

Here is a simple Python example:

from dataclasses import dataclass
from decimal import Decimal
from typing import Optional

@dataclass
class Offer:
    competitor: str
    checkout_price: Decimal
    shipping_price: Decimal
    in_stock: bool
    delivery_days: int
    product_condition: str
    promo_label: Optional[str]

@dataclass
class ProductPolicy:
    current_price: Decimal
    unit_cost: Decimal
    min_margin_pct: Decimal
    max_single_change_pct: Decimal
    ignore_delivery_over_days: int


def landed_price(offer: Offer) -> Decimal:
    return offer.checkout_price + offer.shipping_price


def is_comparable(offer: Offer, policy: ProductPolicy) -> bool:
    if not offer.in_stock:
        return False
    if offer.product_condition.lower() != "new":
        return False
    if offer.delivery_days > policy.ignore_delivery_over_days:
        return False
    return True


def floor_price(policy: ProductPolicy) -> Decimal:
    return policy.unit_cost / (Decimal("1.0") - policy.min_margin_pct)


def recommended_price(policy: ProductPolicy, offers: list[Offer]) -> Decimal:
    comparable = [o for o in offers if is_comparable(o, policy)]

    if not comparable:
        return policy.current_price

    market_price = min(landed_price(o) for o in comparable)
    margin_floor = floor_price(policy)

    target = max(market_price, margin_floor)

    max_drop = policy.current_price * policy.max_single_change_pct
    lowest_allowed_today = policy.current_price - max_drop

    return max(target, lowest_allowed_today)
Enter fullscreen mode Exit fullscreen mode

This still matches prices, but it no longer does so blindly. It refuses to compare against irrelevant offers. It protects margin. It limits daily price movement so one bad scrape cannot cut a product by 40%.

The tradeoff is that rules create false negatives. You will sometimes ignore a competitor move that a human would care about. That is better than automatically following every noisy signal. You can tune rules after you inspect misses.

Separate signal from noise

A useful pricing system should ask questions like these before changing a price:

  • Did the competitor price persist across multiple observations?
  • Is the product actually in stock?
  • Is the lower price caused by a coupon, bundle, loyalty discount, or financing offer?
  • Does the competitor regularly discount this category?
  • Will matching violate margin, MAP, or brand constraints?
  • Are we seeing the same movement across several competitors?

The persistence check alone catches many bad updates. For example:

SELECT matched_internal_sku,
       competitor,
       MIN(checkout_price + shipping_price) AS min_landed_price,
       COUNT(*) AS observations
FROM competitor_offer_observations
WHERE observed_at > now() - interval '6 hours'
  AND scrape_status = 'ok'
  AND in_stock = true
GROUP BY matched_internal_sku, competitor
HAVING COUNT(*) >= 3;
Enter fullscreen mode Exit fullscreen mode

That query does not prove a price is meaningful, but it filters out one-off observations. You can then combine it with category-specific rules. Electronics may need faster reaction times than furniture. Private-label products may not need direct matching at all.

Checkout price is another edge many teams underestimate. The shelf price may be $299, but the customer sees $279 after an auto-applied coupon and $14.99 shipping at checkout. If your data model cannot represent those fields separately, your decision code has to guess. Wire is relevant here because checkout-level extraction and explicit job errors make it easier to tell the difference between “no discount exists” and “the scraper never reached the discount step.”

Treat automation as execution, not strategy

Once you have clean observations and explicit guardrails, automation becomes much safer. A model can estimate elasticity, predict competitor behavior, or choose among pricing strategies, but it should not be the first layer of defense.

In practice, I would log every recommendation before applying it:

{
  "sku": "TV-55-OLED-123",
  "current_price": "899.00",
  "recommended_price": "879.00",
  "reason_codes": [
    "competitor_landed_price_lower",
    "price_seen_3_times",
    "above_margin_floor",
    "within_daily_change_limit"
  ],
  "ignored_offers": [
    {
      "competitor": "example-shop",
      "reason": "out_of_stock"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Those reason codes matter when someone asks why revenue dropped in a category, or why you did not match a visible competitor price. Without them, pricing automation becomes hard to debug.

A good next step is to take one high-volume SKU category and build the observation table, comparability rules, margin floor, and recommendation log for that category only. Run it in shadow mode for two weeks, compare its recommendations with human decisions, then decide which rules deserve automation.

Top comments (0)