# Build a competitor price position report

> Compare your prices with the lowest recent price at each retailer: gap, rank and retailer count per product, limited to the competitors you choose.

*Source: https://shopsavvy.com/data/documentation/recipe-price-position* · Part of the [ShopSavvy Data API documentation](https://shopsavvy.com/data/documentation.md)

---

For each of your products, find the lowest price among other retailers and where your own price ranks. The input file has `identifier` and `my_price` columns. The report compares the lowest new-condition price per retailer, counts only prices seen in the last 7 days, and leaves out third-party marketplace sellers. Set `COMPETITORS` to the retailers you actually compete with, so small sellers that list the same product do not set your benchmark.

| Step | REST endpoint | MCP tool | Credits |
|------|---------------|----------|---------|
| Current offers for up to 100 products | `GET /v1/products/offers` | `product_offers` | 3 per product with offers |

**Cost for N products:** at most 3 × N credits per run. Run it daily for a month and that is 90 × N, so 300 products checked daily come to about 27,000 credits. Each offer's `timestamp` is when ShopSavvy last saw that price; the 7-day window keeps old prices out of the comparison. See [Retrieving Offers for Products at All Retailers](https://shopsavvy.com/data/documentation/offers-all-retailers).

## bash

```bash
#!/usr/bin/env bash
# Where does your price sit against every retailer? One row per product.
# Usage: SHOPSAVVY_API_KEY=... ./price-position.sh my-prices.csv position.csv
# my-prices.csv has "identifier" (barcode, ASIN, model number or URL) and "my_price" columns.
set -euo pipefail
. ./shopsavvy-call.sh

MAX_AGE_DAYS=7       # ignore prices not seen in the last week
COMPETITORS='null'   # or a JSON array of retailer names, e.g. '["Amazon","Best Buy","Walmart"]'

echo "identifier,title,my_price,lowest_price,lowest_retailer,gap_pct,rank,retailers" > "$2"
tail -n +2 "$1" | tr -d '\r' | grep -v '^$' | split -l 100 - "$SS_TMP/chunk_"
for chunk in "$SS_TMP"/chunk_*; do
  ids=$(cut -d, -f1 "$chunk" | paste -sd, - | jq -sRr 'rtrimstr("\n") | @uri')
  api GET "/products/offers?ids=$ids" > "$SS_TMP/offers.json"
  jq -r --rawfile chunk "$chunk" --argjson competitors "$COMPETITORS" --argjson maxAge "$((MAX_AGE_DAYS * 86400))" '
    ((.data // []) | map({key: .match.identifier, value: .}) | from_entries) as $products
    | $chunk | split("\n") | map(select(length > 0) | split(","))[]
    | .[0] as $id | (.[1] | tonumber) as $mine | $products[$id] as $p
    # Lowest recent new-condition price per retailer: the retailer'"'"'s own price, not a marketplace seller'"'"'s.
    | [($p.offers // [])[]
        | select(.condition == "new" and .price != null and (.is_marketplace | not)
                 and (now - (.timestamp | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601)) <= $maxAge
                 and ($competitors == null or (.retailer as $r | $competitors | index($r))))]
    | group_by(.retailer) | map({retailer: .[0].retailer, price: (map(.price) | min)}) as $lowest
    | if ($lowest | length) == 0 then [$id, ($p.title // ""), $mine, "", "", "", "", 0]
      else ($lowest | min_by(.price)) as $best
        | [$id, $p.title, $mine, $best.price, $best.retailer,
           ((($mine - $best.price) / $best.price * 1000 | round) / 10),
           (1 + ($lowest | map(select(.price < $mine)) | length)), ($lowest | length)]
      end
    | @csv' "$SS_TMP/offers.json" >> "$2"
done
echo "Wrote $2"
```

## Python

```python
"""Where does your price sit against every retailer? One row per product.

Usage: SHOPSAVVY_API_KEY=... python price_position.py my-prices.csv position.csv
my-prices.csv has "identifier" (barcode, ASIN, model number or URL) and "my_price" columns.
"""
import csv
import sys
from datetime import datetime, timedelta, timezone

from shopsavvy_call import call

MAX_AGE = timedelta(days=7)  # ignore prices not seen in the last week
COMPETITORS = None  # or a set of retailer names to compare against only those, e.g. {"Amazon", "Best Buy", "Walmart"}


def main(source, destination):
    with open(source, newline="") as f:
        mine = {row["identifier"].strip(): float(row["my_price"]) for row in csv.DictReader(f) if row["identifier"].strip()}
    identifiers = list(mine)
    products = {}
    for start in range(0, len(identifiers), 100):
        response = call("GET", "/products/offers", not_found_ok=True, params={"ids": ",".join(identifiers[start:start + 100])})
        if response.status_code == 404:
            continue  # none of these matched: nothing charged
        for product in response.json()["data"]:
            products[product["match"]["identifier"]] = product
    with open(destination, "w", newline="") as f:
        out = csv.writer(f)
        out.writerow(["identifier", "title", "my_price", "lowest_price", "lowest_retailer", "gap_pct", "rank", "retailers"])
        for identifier, my_price in mine.items():
            product = products.get(identifier)
            # Lowest recent new-condition price per retailer.
            cutoff = datetime.now(timezone.utc) - MAX_AGE
            lowest = {}
            for offer in (product or {}).get("offers", []):
                seen = datetime.fromisoformat(offer["timestamp"].replace("Z", "+00:00"))
                if (offer.get("condition") == "new" and offer.get("price") is not None and seen >= cutoff
                        and not offer.get("is_marketplace")  # the retailer's own price, not a third-party seller's
                        and (COMPETITORS is None or offer["retailer"] in COMPETITORS)):
                    lowest[offer["retailer"]] = min(offer["price"], lowest.get(offer["retailer"], offer["price"]))
            if not lowest:
                out.writerow([identifier, (product or {}).get("title", ""), my_price, "", "", "", "", 0])
                continue
            retailer, price = min(lowest.items(), key=lambda item: item[1])
            rank = 1 + sum(1 for p in lowest.values() if p < my_price)
            out.writerow([identifier, product["title"], my_price, price, retailer,
                          round((my_price - price) / price * 100, 1), rank, len(lowest)])
    print(f"Wrote {destination}")


if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2])
```

## TypeScript

```typescript
// Where does your price sit against every retailer? One row per product.
// Usage: SHOPSAVVY_API_KEY=... npx tsx price-position.ts my-prices.csv position.csv
// my-prices.csv has "identifier" (barcode, ASIN, model number or URL) and "my_price" columns.
import { readFileSync, writeFileSync } from 'node:fs'
import { call, csvLine } from './shopsavvy-call'

const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 // ignore prices not seen in the last week
const COMPETITORS: Set<string> | null = null // or e.g. new Set(['Amazon', 'Best Buy', 'Walmart']) to compare against those only

async function main(source: string, destination: string) {
  const mine = new Map<string, number>()
  for (const line of readFileSync(source, 'utf8').split(/\r?\n/).slice(1)) {
    const [identifier, myPrice] = line.split(',').map((field) => field.trim())
    if (identifier) mine.set(identifier, Number(myPrice))
  }
  const identifiers = [...mine.keys()]
  const products = new Map<string, any>()
  for (let start = 0; start < identifiers.length; start += 100) {
    const ids = encodeURIComponent(identifiers.slice(start, start + 100).join(','))
    const response = await call('GET', `/products/offers?ids=${ids}`, { notFoundOk: true })
    if (response.status === 404) continue // none of these matched: nothing charged
    for (const product of (await response.json()).data) products.set(product.match.identifier, product)
  }
  let csv = csvLine(['identifier', 'title', 'my_price', 'lowest_price', 'lowest_retailer', 'gap_pct', 'rank', 'retailers'])
  for (const [identifier, myPrice] of mine) {
    const product = products.get(identifier)
    // Lowest recent new-condition price per retailer.
    const lowest = new Map<string, number>()
    for (const offer of product?.offers ?? []) {
      const recent = Date.now() - Date.parse(offer.timestamp) <= MAX_AGE_MS
      // The retailer's own price (not a third-party marketplace seller), optionally only your competitors.
      const wanted = !offer.is_marketplace && (COMPETITORS === null || COMPETITORS.has(offer.retailer))
      if (offer.condition === 'new' && offer.price != null && recent && wanted) lowest.set(offer.retailer, Math.min(offer.price, lowest.get(offer.retailer) ?? offer.price))
    }
    if (lowest.size === 0) {
      csv += csvLine([identifier, product?.title ?? '', myPrice, '', '', '', '', 0])
      continue
    }
    const [retailer, price] = [...lowest].reduce((best, entry) => (entry[1] < best[1] ? entry : best))
    const rank = 1 + [...lowest.values()].filter((p) => p < myPrice).length
    csv += csvLine([identifier, product.title, myPrice, price, retailer, Math.round((myPrice - price) / price * 1000) / 10, rank, lowest.size])
  }
  writeFileSync(destination, csv)
  console.log(`Wrote ${destination}`)
}

main(process.argv[2], process.argv[3])
```
