MAP Violation & Price-War Detection
A brand that publishes a Minimum Advertised Price (MAP) policy needs to know, within hours, which sellers are advertising below it and whether a burst of rapid, reciprocal undercuts has tipped into a price war that will erode the margin of every compliant seller in the listing. This guide builds the detection layer that turns a stream of observed competitor offers into two distinct signals: discrete MAP violations tied to a specific seller and marketplace, and the emergent, multi-party pattern of a price war. It sits under Competitive Price Intelligence & Repricing, consumes the same matched, normalized offers that feed automated repricing rule engines, and shares its output channels with price anomaly alerting & monitoring. One critical framing before any code: MAP is a unilateral policy a brand sets and enforces on its own; detection tooling must surface facts (who advertised what, where, when) and never coordinate prices between competing sellers, which crosses into antitrust territory.
Problem Framing & Prerequisites
MAP enforcement and price-war detection share one input — a matched, normalized stream of competitor offers — but answer different questions. MAP enforcement is a point-in-time compliance test: for a single observed offer, is the advertised price below the floor the brand set for that product? Price-war detection is a temporal, multi-party pattern test: across a window of observations, are two or more sellers repeatedly undercutting each other on the same listing? Building both on one pipeline keeps the evidence consistent, but conflating them produces noise — a single below-floor offer is not a war, and a war can consist entirely of compliant prices if there is no MAP at all.
Three upstream contracts must hold. First, every offer must already be resolved to a canonical product_id — MAP tables are keyed on your product identity, not on a competitor’s scraped SKU, so the fuzzy matching stage must have run and attached a confidence. A low-confidence match should never trigger an enforcement action; you cannot accuse a seller of violating MAP on a product you are not certain they are selling. Second, prices must be normalized to a single currency and tax basis; a “violation” that is really a VAT-inclusive versus VAT-exclusive artifact is an embarrassing false positive, so lean on currency conversion and tax and shipping normalization first. Third, the offer must carry both the advertised price and, where obtainable, the cart price, because the most common MAP-evasion tactic is a compliant advertised price with the real discount applied only in the cart.
The input contract for a single observed offer entering the detector:
from pydantic import BaseModel, Field
from typing import Optional
from datetime import datetime
class ObservedOffer(BaseModel):
product_id: str # canonical id (post-match), NOT competitor SKU
match_confidence: float # from the SKU alignment stage, [0,1]
seller_id: str # normalized seller/merchant identity
marketplace: str # "amazon", "walmart", "ebay", brand DTC, ...
advertised_price: float # price shown on the listing/search tile
cart_price: Optional[float] = None # price after "add to cart", if captured
currency: str = "USD" # already converted to base upstream
observed_at: datetime # scrape timestamp, for war windows & audit
listing_url: str # evidence anchor
attributes: dict = Field(default_factory=dict) # condition, bundle flags
The MAP reference side is a separate table the brand owns. It is deliberately not the MSRP: MSRP is a suggested selling price, MAP is the floor on advertised price, and the actual transacted price can legally sit below MAP as long as it was not advertised there. Keeping the three separate in the schema prevents the classic error of enforcing against MSRP.
class MapEntry(BaseModel):
product_id: str
map_price: float # the advertised-price floor
msrp: Optional[float] = None # suggested retail; reference only, never enforced
currency: str = "USD"
effective_from: datetime
effective_to: Optional[datetime] = None # MAP schedules change; keep history
tolerance_pct: float = 0.0 # grace band, e.g. 0.02 = 2%
policy_version: str # which published policy this row reflects
Algorithm or Architecture Detail
The core violation test is a temporal join followed by a classification. For each offer, select the MAP entry whose effective window contains observed_at, compare the advertised price (and separately the cart price) against the floor after applying tolerance, and assign a severity tier. Cart-price evasion is caught by testing the cart price against the same floor even when the advertised price passes.
from dataclasses import dataclass
from enum import Enum
class Severity(str, Enum):
COMPLIANT = "compliant"
SOFT = "soft" # within the grace/tolerance band
HARD = "hard" # clearly below floor
CART_EVASION = "cart_evasion" # advertised OK, cart below floor
@dataclass
class ViolationVerdict:
product_id: str
seller_id: str
marketplace: str
severity: Severity
map_price: float
observed_price: float # the price that triggered the verdict
shortfall_pct: float # how far below floor, 0 if compliant
policy_version: str
def classify_offer(offer, map_entry) -> ViolationVerdict:
floor = map_entry.map_price
grace = floor * (1.0 - map_entry.tolerance_pct) # soft band lower edge
adv = offer.advertised_price
# 1. Advertised price is the primary enforcement surface.
if adv < grace:
sev = Severity.HARD
price = adv
elif adv < floor:
sev = Severity.SOFT # inside tolerance grace band
price = adv
else:
sev = Severity.COMPLIANT
price = adv
# 2. Cart-price evasion: advertised passes, but cart is below floor.
if sev is Severity.COMPLIANT and offer.cart_price is not None:
if offer.cart_price < grace:
sev, price = Severity.CART_EVASION, offer.cart_price
shortfall = max(0.0, (floor - price) / floor)
return ViolationVerdict(
product_id=offer.product_id, seller_id=offer.seller_id,
marketplace=offer.marketplace, severity=sev, map_price=floor,
observed_price=round(price, 2), shortfall_pct=round(shortfall, 4),
policy_version=map_entry.policy_version,
)
Price-war detection is a different shape of computation. A war is not a property of one offer; it is a property of a listing’s price history. The signature that distinguishes a war from ordinary competitive pricing is reciprocity within a short window: seller A cuts, seller B matches-or-undercuts within hours, A cuts again. A simple, defensible detector counts distinct sellers making downward moves inside a sliding window and measures the cumulative depth of the descent.
from collections import defaultdict
def detect_price_war(history, window_hours=48, min_sellers=2,
min_moves=4, min_depth_pct=0.08):
"""history: list[ObservedOffer] for ONE product_id, time-ascending.
A war fires when, inside a rolling window, at least `min_moves`
downward price moves come from at least `min_sellers` distinct
sellers and the listing's best price falls by >= min_depth_pct.
"""
events, best_by_seller = [], {}
for off in history:
prev = best_by_seller.get(off.seller_id)
if prev is not None and off.advertised_price < prev:
events.append((off.observed_at, off.seller_id,
off.advertised_price, prev))
best_by_seller[off.seller_id] = min(
off.advertised_price, prev if prev is not None else off.advertised_price)
from datetime import timedelta
for i, (t0, _, _, _) in enumerate(events):
window = [e for e in events if t0 <= e[0] <= t0 + timedelta(hours=window_hours)]
sellers = {e[1] for e in window}
if len(window) >= min_moves and len(sellers) >= min_sellers:
start_best = max(e[3] for e in window) # highest "from" price
end_best = min(e[2] for e in window) # lowest "to" price
depth = (start_best - end_best) / start_best
if depth >= min_depth_pct:
return {"war": True, "window_start": t0,
"sellers": sorted(sellers), "moves": len(window),
"depth_pct": round(depth, 4)}
return {"war": False}
The two detectors compose: a war of below-MAP prices is the highest-priority event, because it means enforcement has broken down and margin is bleeding across the whole listing. Feeding the war verdict into automated repricing rule engines is where guardrails matter — the correct response to a war is often not to chase the bottom, and the price floors and guardrails logic is what stops your own repricer from becoming another combatant.
Candidate Generation & Compute Optimization
Running the war detector over every product’s full history on every scrape is wasteful. Most listings are quiescent; the interesting ones have recent downward movement. The optimization is to gate the expensive temporal scan behind a cheap trigger: only products whose latest offer produced a HARD/CART_EVASION verdict, or whose best price moved down since the last cycle, are enqueued for war analysis. This mirrors the candidate-generation discipline used across the platform — do the cheap filter first, spend compute only on survivors.
def war_candidates(verdicts, last_best_price):
"""Return product_ids worth running the war detector on this cycle."""
hot = set()
for v in verdicts:
if v.severity in (Severity.HARD, Severity.CART_EVASION):
hot.add(v.product_id)
prev = last_best_price.get(v.product_id)
if prev is not None and v.observed_price < prev:
hot.add(v.product_id) # any downward move keeps it warm
return hot
Maintain per-product price history in a time-series store (Redis sorted sets keyed on product_id, scored by timestamp, or a columnar table partitioned by day) and retain only the trailing window needed for the longest war rule — typically a week. Batch the classification with vectorized column operations in polars when processing a full feed: the MAP join is an as-of merge on effective_from/effective_to, and the severity assignment is a set of boolean masks, both of which stay near-linear. Reserve the Python-object path shown above for the per-listing war scan on the hot set only.
Configuration & Threshold Tuning
Every knob here is policy- and category-sensitive, and every one must be versioned so an enforcement notice can cite the exact configuration that produced it. Tolerance exists because scraped prices carry rounding and rendering noise; it is not a licensed discount. Keep it small.
| Parameter | Typical value | Meaning | Tuning guidance |
|---|---|---|---|
tolerance_pct | 0.01–0.03 | Grace band below MAP treated as soft, not hard | Set to your price-capture noise floor, not a negotiated allowance |
soft_grace_hours | 6–24 | How long a soft violation may persist before escalating | Shorter for high-velocity marketplaces |
| Hard-violation threshold | < floor × (1 − tolerance) | Below this is a hard violation | Do not widen to reduce alert volume; fix matching instead |
war_window_hours | 24–72 | Sliding window for reciprocity | Match to competitor repricing cadence |
war_min_sellers | 2–3 | Distinct sellers cutting within window | 2 is the minimum meaningful reciprocity |
war_min_moves | 3–5 | Downward moves inside window | Raise to suppress single-seller staircase drops |
war_min_depth_pct | 0.05–0.10 | Cumulative best-price descent | Below 5% is usually normal competition, not a war |
min_match_confidence | 0.90 | Floor to allow enforcement action | Never enforce on low-confidence matches |
The min_match_confidence gate is the most important row in the table and the most often forgotten. A violation record built on a 0.72-confidence match is not evidence; it is a liability. Route those to review exactly as the SKU alignment guide routes review-band matches, and only promote to an enforceable verdict once identity is certain.
Failure Modes & Mitigations
- Cart-price evasion. The advertised price sits exactly at MAP while the real price is revealed only after adding to cart or entering a coupon. The
CART_EVASIONbranch above catches it if the crawler captured the cart price; where it cannot, flag listings whose advertised price sits suspiciously pinned to MAP across many sellers for targeted cart-level re-crawl. This overlaps with parsing hidden discounts — see handling BOGO and bundle pricing in scraped data. - Bundle and multipack confusion. A “3-pack” advertised at a per-unit price below MAP is often not a violation, because MAP governs the single advertised unit. Strip and normalize quantity before comparison, and route bundles to a per-unit basis or exclude them from enforcement entirely rather than firing false violations.
- Currency and tax artifacts. A EUR listing compared against a USD floor, or a tax-inclusive price against a tax-exclusive MAP, manufactures violations. Enforce only after normalization; store the currency and tax basis on both the offer and the MAP entry so the audit record proves the comparison was like-for-like.
- Stale MAP schedules. MAP floors change for promotions and end-of-life; comparing today’s offer against last quarter’s floor produces both false positives and missed violations. The effective-window join above is the guard, but it only works if the MAP table’s
effective_tois maintained — an unbounded old row silently enforces a dead policy. - War false positives from clearance. A single seller liquidating stock produces a staircase of downward moves that is not reciprocal. The
war_min_sellers >= 2requirement is what separates a one-seller clearance from a genuine multi-party war; do not lower it to catch more events.
Compliance & Auditability
MAP is a policy a brand imposes unilaterally on resellers; detection and enforcement are lawful, but the coordination of prices among competing sellers is not. The tooling here must therefore be scrupulously one-directional: it observes and reports what sellers advertised, and it never feeds one seller’s price to another as a target. Keep the detector’s output as evidence, not instruction. Every verdict must be reconstructable, so each enforceable violation writes an immutable evidence record capturing the offer, the floor it breached, the exact configuration version, and the crawl provenance.
evidence_record = {
"product_id": "p-44182",
"seller_id": "mkt:acme-electronics",
"marketplace": "amazon",
"severity": "hard",
"advertised_price": 179.00,
"cart_price": None,
"map_price": 199.99,
"shortfall_pct": 0.1050,
"currency": "USD",
"match_confidence": 0.97,
"policy_version": "map-2026Q3-v2",
"config_version": "detector-v4",
"listing_url": "https://…", # the evidence anchor
"observed_at": "2026-07-15T09:14:00Z",
"screenshot_ref": "s3://evidence/…", # optional captured proof
}
Retain evidence for the enforcement window your legal team specifies, redact any incidental PII from captured pages, and version both the MAP policy and the detector configuration so a challenged notice can be defended with the precise inputs that produced it. Because enforcement actions can affect a seller’s business, treat the min_match_confidence gate and the effective-window join as compliance controls, not performance tunables — loosening either to raise volume manufactures false accusations.
Deployment Checklist
MAP violation and price-war detection is where a price-intelligence pipeline stops describing the market and starts protecting margin. By separating the point-in-time compliance test from the temporal war signature, gating both on match confidence and normalization, and capturing airtight evidence, brand and retail teams enforce policy defensibly without ever straying into coordination.
Related
- Competitive Price Intelligence & Repricing — the parent guide that frames how detected violations and wars feed pricing decisions.
- Automated Repricing Rule Engines — where war and violation verdicts become guarded repricing actions rather than a race to the bottom.
- Price Floors and Guardrails in Repricing — the guardrail logic that stops your own repricer from joining a price war.
- Price Anomaly Alerting & Monitoring — the routing and dedup layer that turns violation and war verdicts into paged alerts.
- Detecting MAP Violations Across Marketplaces — the runnable recipe that joins offers to a MAP table and emits evidence records.