How to Scrape Etsy Shop Analytics with Python (2026)
How to Scrape Etsy Shop Analytics with Python (2026)
Etsy doesn't give sellers — let alone researchers — a clean analytics export. Shop owners see their own dashboard, buyers see nothing, and third-party tools charge $30+/month for data you can collect yourself in an afternoon. The underlying numbers are there: review counts, favoriting activity, listing age, and shop-level transaction totals. With Python, httpx, and a few scraping fallbacks, you can build a picture of any shop's performance without paying for a SaaS tool.
This post covers extracting analytics-grade data from Etsy shops: listing performance metrics, review patterns, sales volume estimation, and storage. We'll use the official Etsy API v3 where it's useful and fall back to direct scraping where the API doesn't expose what we need.
What You Can Extract
Etsy's public-facing shop and listing pages expose more than most people realize:
- Shop-level stats — total transaction count, star rating, number of admirers (followers), shop age, location, and active listing count
- Listing performance proxies — favorite count per listing, number of reviews, listing age (how long it's been live), and views estimated from engagement ratios
- Review patterns — rating distribution over time, review velocity (reviews per month), response rate, and buyer sentiment
- Sales volume estimation — derived from review counts using Etsy's well-documented review-to-purchase multiplier
- Favoriting behavior — which listings accumulate favorites fastest, indicating high-conversion products even without purchase data
The Etsy API v3 gives you some of this cleanly. For the rest — specifically review velocity, favorite counts per listing, and listing-level engagement trends — you need to scrape.
Etsy API v3 (Free Tier)
Etsy's v3 API is publicly available and useful for shop-level data. You need an API key (free, apply at etsy.com/developers) but no OAuth for read-only public data.
import httpx
import time
ETSY_API_KEY = "your_api_key_here"
BASE = "https://openapi.etsy.com/v3/application"
def get_shop(shop_name: str) -> dict:
"""Fetch shop overview from the official Etsy API v3."""
url = f"{BASE}/shops/{shop_name}"
headers = {"x-api-key": ETSY_API_KEY}
with httpx.Client(timeout=15) as client:
resp = client.get(url, headers=headers)
if resp.status_code != 200:
return {}
data = resp.json()
return {
"shop_id": data.get("shop_id"),
"shop_name": data.get("shop_name"),
"transaction_sold_count": data.get("transaction_sold_count"),
"num_favorers": data.get("num_favorers"),
"review_average": data.get("review_average"),
"review_count": data.get("review_count"),
"listing_active_count": data.get("listing_active_count"),
"create_date": data.get("create_date"),
"location": data.get("location"),
"is_vacation": data.get("is_vacation"),
}
def get_shop_listings(shop_id: int, limit: int = 100) -> list:
"""Fetch active listings for a shop with engagement metrics."""
url = f"{BASE}/shops/{shop_id}/listings/active"
headers = {"x-api-key": ETSY_API_KEY}
params = {
"limit": min(limit, 100),
"includes": ["Images", "Shop"],
"sort_on": "created",
"sort_order": "desc",
}
with httpx.Client(timeout=15) as client:
resp = client.get(url, headers=headers, params=params)
if resp.status_code != 200:
return []
results = resp.json().get("results", [])
listings = []
for item in results:
listings.append({
"listing_id": item.get("listing_id"),
"title": item.get("title"),
"price": item.get("price", {}).get("amount", 0) / 100,
"currency": item.get("price", {}).get("currency_code"),
"views": item.get("views"),
"num_favorers": item.get("num_favorers"),
"quantity": item.get("quantity"),
"created_timestamp": item.get("created_timestamp"),
"tags": item.get("tags", []),
"is_bestseller": item.get("is_bestseller", False),
})
return listings
The API returns views and num_favorers per listing — two of the more useful engagement signals. The transaction_sold_count at the shop level is your anchor for sales estimation.
Web Scraping Fallback
The API doesn't expose review timestamps, individual review ratings per listing, or the review velocity over time that's essential for trend analysis. For that, scrape the listing review endpoint directly.
import re
import json
from selectolax.parser import HTMLParser
def scrape_listing_reviews(listing_id: int, proxy: str = None) -> list:
"""
Scrape reviews for a listing using Etsy's bespoke AJAX endpoint.
Returns list of reviews with rating, text, and date.
"""
url = f"https://www.etsy.com/api/v3/ajax/bespoke/public/neu/specs/reviews/{listing_id}"
params = {"page": 1, "sort_by": "recent", "limit": 25}
headers = {
"Accept": "application/json",
"X-Requested-With": "XMLHttpRequest",
"Referer": f"https://www.etsy.com/listing/{listing_id}",
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
}
client_kwargs = {"headers": headers, "follow_redirects": True, "timeout": 15}
if proxy:
client_kwargs["proxies"] = {"all://": proxy}
with httpx.Client(**client_kwargs) as client:
resp = client.get(url, params=params)
if resp.status_code != 200:
return []
reviews = []
for review in resp.json().get("reviews", []):
reviews.append({
"rating": review.get("rating"),
"text": review.get("review", ""),
"date": review.get("created_at"),
"buyer": review.get("reviewer", {}).get("name"),
"transaction_title": review.get("transaction_title"),
})
return reviews
def scrape_shop_page(shop_name: str, proxy: str = None) -> dict:
"""
Scrape a shop's main page for data not available in the API:
announcement text, recent activity, and policy signals.
"""
url = f"https://www.etsy.com/shop/{shop_name}"
headers = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml",
"Accept-Language": "en-US,en;q=0.9",
"Referer": "https://www.google.com/",
}
client_kwargs = {"headers": headers, "follow_redirects": True, "timeout": 20}
if proxy:
client_kwargs["proxies"] = {"all://": proxy}
with httpx.Client(**client_kwargs) as client:
resp = client.get(url)
if resp.status_code != 200:
return {}
tree = HTMLParser(resp.text)
result = {}
# Announcement text
announcement = tree.css_first("[data-region='announcement'] p")
if announcement:
result["announcement"] = announcement.text(strip=True)
# Sale banner — signals active promotions
sale_node = tree.css_first("[data-component='sale-banner']")
result["has_active_sale"] = sale_node is not None
# Extract embedded page state for additional metrics
state_match = re.search(r'"num_transactions"\s*:\s*(\d+)', resp.text)
if state_match:
result["num_transactions"] = int(state_match.group(1))
return result
Anti-Bot Measures
Etsy runs several layers of protection and they've gotten more aggressive since 2024:
- Cloudflare — All Etsy domains sit behind Cloudflare. JS challenges fire on suspicious traffic patterns, particularly requests with mismatched or missing browser headers.
- TLS fingerprinting — Etsy's infrastructure checks the TLS client hello against known browser signatures. Python's default SSL stack presents a recognizable fingerprint. Using httpx with an
http2=Trueclient helps, and tools likecurl_cffican impersonate Chrome's TLS handshake directly if you need it. - Rate limiting — More than 15-20 requests per minute from one IP triggers soft throttling. You'll still get 200 responses but results come back empty or truncated. Always add randomized delays.
- Session tracking — Etsy correlates browsing patterns across requests. Hitting paginated listings in sequential order at regular intervals is a clear bot signal. Randomize your request order and spacing.
- IP reputation scoring — Datacenter IPs (AWS, GCP, DigitalOcean ranges) are immediately challenged. Only residential IPs pass reliably.
Proxy Setup
Residential proxies are non-negotiable for Etsy at any meaningful volume. Datacenter IPs fail on the first request to most Etsy endpoints. You need IPs from real ISPs that appear to Cloudflare like normal browser traffic.
ThorData's residential proxy network works reliably for Etsy — the IP pool covers major US cities, which is where Etsy's buyer base concentrates, and the ASN ranges pass Cloudflare's checks without triggering JS challenges. Geo-targeting to US residential IPs specifically gives you the best hit rate.
import random
import time
PROXY = "http://USER:[email protected]:9000"
def scrape_shop_analytics(shop_name: str) -> dict:
"""Full shop analytics scrape combining API and web scraping."""
# API call — no proxy needed for low-volume API requests
shop = get_shop(shop_name)
if not shop:
return {}
shop_id = shop["shop_id"]
# Listings from API
listings = get_shop_listings(shop_id)
# Supplement with scraped data where API falls short
shop_page_data = scrape_shop_page(shop_name, proxy=PROXY)
shop.update(shop_page_data)
# Scrape reviews for top listings by favorite count
top_listings = sorted(listings, key=lambda x: x.get("num_favorers", 0), reverse=True)[:5]
review_data = []
for listing in top_listings:
time.sleep(random.uniform(5, 12))
reviews = scrape_listing_reviews(listing["listing_id"], proxy=PROXY)
review_data.append({
"listing_id": listing["listing_id"],
"title": listing["title"],
"reviews": reviews,
})
return {
"shop": shop,
"listings": listings,
"listing_reviews": review_data,
}
Sales Estimation Logic
Etsy doesn't expose per-listing sales counts through any public endpoint. The accepted estimation method uses review counts as a proxy, applying a multiplier based on Etsy's platform-wide review behavior.
Research across Etsy seller communities consistently puts the review rate at roughly 10-20% of buyers leaving a review. That means for every review you see, there are roughly 5-10 actual purchases. The multiplier varies by category — digital downloads skew lower (buyers often skip reviewing), while personalized physical items skew higher.
def estimate_sales(shop: dict, listings: list) -> dict:
"""
Estimate sales volume using review counts and favorite ratios.
Multiplier range: 5x (conservative) to 10x (optimistic).
"""
total_reviews = shop.get("review_count", 0)
total_transactions = shop.get("transaction_sold_count", 0)
# Shop-level estimate from review count
conservative_sales = total_reviews * 5
optimistic_sales = total_reviews * 10
# If transaction count is exposed (sometimes embedded in page state),
# use it directly as ground truth
if total_transactions:
sales_estimate = total_transactions
review_rate = round(total_reviews / total_transactions * 100, 1) if total_transactions else None
else:
sales_estimate = round((conservative_sales + optimistic_sales) / 2)
review_rate = None
# Per-listing estimates using favorite count as a secondary signal
listing_estimates = []
for listing in listings:
faves = listing.get("num_favorers", 0)
listing_reviews = 0 # would be populated from review scrape
# Favorites-to-purchase conversion is roughly 3-8% on Etsy
fave_based_estimate = round(faves * 0.05)
listing_estimates.append({
"listing_id": listing["listing_id"],
"title": listing["title"][:60],
"favorites": faves,
"estimated_sales_from_faves": fave_based_estimate,
"views": listing.get("views", 0),
"fave_to_view_ratio": round(faves / listing["views"], 3) if listing.get("views") else None,
})
return {
"shop_sales_estimate": sales_estimate,
"review_count": total_reviews,
"review_rate_pct": review_rate,
"conservative_estimate": conservative_sales,
"optimistic_estimate": optimistic_sales,
"per_listing": listing_estimates,
}
The fave_to_view_ratio is worth tracking over time. Listings where that ratio climbs — more people favoriting relative to views — often indicate conversion rate improvements or growing demand. It's a leading indicator before review counts catch up.
Storing Data
import sqlite3
def init_db(db_path: str = "etsy_analytics.db") -> sqlite3.Connection:
conn = sqlite3.connect(db_path)
conn.executescript("""
CREATE TABLE IF NOT EXISTS shops (
shop_name TEXT PRIMARY KEY,
shop_id INTEGER,
transaction_sold_count INTEGER,
review_count INTEGER,
review_average REAL,
num_favorers INTEGER,
listing_active_count INTEGER,
create_date INTEGER,
location TEXT,
scraped_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS listings (
listing_id INTEGER PRIMARY KEY,
shop_name TEXT,
title TEXT,
price REAL,
currency TEXT,
views INTEGER,
num_favorers INTEGER,
quantity INTEGER,
is_bestseller INTEGER,
created_timestamp INTEGER,
scraped_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (shop_name) REFERENCES shops(shop_name)
);
CREATE TABLE IF NOT EXISTS reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
listing_id INTEGER,
rating INTEGER,
text TEXT,
date TEXT,
buyer TEXT,
transaction_title TEXT,
FOREIGN KEY (listing_id) REFERENCES listings(listing_id)
);
CREATE TABLE IF NOT EXISTS sales_estimates (
shop_name TEXT,
estimated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
sales_estimate INTEGER,
review_count INTEGER,
conservative_estimate INTEGER,
optimistic_estimate INTEGER
);
""")
conn.commit()
return conn
def save_shop_data(conn: sqlite3.Connection, shop: dict, estimates: dict):
conn.execute(
"INSERT OR REPLACE INTO shops VALUES (?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)",
(shop.get("shop_name"), shop.get("shop_id"), shop.get("transaction_sold_count"),
shop.get("review_count"), shop.get("review_average"), shop.get("num_favorers"),
shop.get("listing_active_count"), shop.get("create_date"), shop.get("location"))
)
conn.execute(
"INSERT INTO sales_estimates (shop_name, sales_estimate, review_count, conservative_estimate, optimistic_estimate) VALUES (?,?,?,?,?)",
(shop.get("shop_name"), estimates.get("shop_sales_estimate"),
estimates.get("review_count"), estimates.get("conservative_estimate"),
estimates.get("optimistic_estimate"))
)
conn.commit()
Running this weekly for a set of competitor shops gives you a trend line. Review count delta over two weeks divided by 14 gives you a daily review velocity — multiply by your multiplier to get estimated daily sales pace.
Legal Note
Etsy's Terms of Service prohibit automated scraping. Their robots.txt disallows most crawling paths. Treat this as a research and competitive intelligence tool for personal or internal use — don't republish Etsy seller data, don't scrape personal information, and don't use the data to undercut specific sellers. The API is the right path if you're building anything commercial; apply for official API access and respect rate limits.
Key Takeaways
- The Etsy API v3 exposes shop transaction counts, review averages, and per-listing view and favorite counts on the free tier — start there before scraping anything.
- Review count times a 5-10x multiplier is the standard Etsy sales estimation method; use the shop-level transaction count when it's embedded in the page state to validate your multiplier.
- Favorite-to-view ratio per listing is a leading performance indicator — it surfaces high-converting products before review counts accumulate.
- Etsy's bot detection combines Cloudflare, TLS fingerprinting, and session pattern analysis; datacenter IPs fail reliably and residential proxies are required for any volume above a few requests.
- ThorData's residential proxy network with US geo-targeting is the reliable path for Etsy — set your proxy as
PROXY = "http://USER:[email protected]:9000"and randomize delays between 5-12 seconds per request. - Store snapshots weekly rather than scraping continuously; the trend data — review velocity, favorite growth rate, listing count changes — is more actionable than any single point-in-time read.
Competitor Gap Analysis
Identify opportunities by comparing your target niche against what competitors offer:
import sqlite3
import json
import statistics
from collections import Counter
def run_competitor_gap_analysis(
shop_names: list,
db_path: str = "etsy_analytics.db",
) -> dict:
"""
Compare multiple competitor shops to identify gaps in their offering.
Returns pricing, category, and review pattern analysis.
"""
conn = sqlite3.connect(db_path)
shop_data = {}
for shop_name in shop_names:
shop_row = conn.execute(
"SELECT * FROM shops WHERE shop_name = ?", (shop_name,)
).fetchone()
listings = conn.execute(
"""SELECT price, views, num_favorers, is_bestseller, created_timestamp
FROM listings WHERE shop_name = ?""",
(shop_name,)
).fetchall()
if not listings:
continue
prices = [row[0] for row in listings if row[0]]
faves = [row[2] for row in listings if row[2]]
bestsellers = sum(1 for row in listings if row[3])
shop_data[shop_name] = {
"listing_count": len(listings),
"price_range": [min(prices), max(prices)] if prices else None,
"median_price": round(statistics.median(prices), 2) if prices else None,
"avg_favorites": round(statistics.mean(faves), 1) if faves else 0,
"bestseller_rate": round(bestsellers / len(listings) * 100, 1) if listings else 0,
"total_sales": shop_row[2] if shop_row else None,
}
conn.close()
# Find gaps
all_prices = [v["median_price"] for v in shop_data.values() if v.get("median_price")]
gaps = {
"price_gap_low": min(all_prices) * 0.8 if all_prices else None, # Undercut low end
"price_gap_high": max(all_prices) * 1.5 if all_prices else None, # Premium opportunity
"shops_analyzed": len(shop_data),
"competitor_profiles": shop_data,
}
return gaps
def find_uncontested_keywords(
listings: list,
min_favorites: int = 100,
) -> list:
"""
Find tags with high engagement but low listing count -- underserved keywords.
"""
tag_stats = {}
for listing in listings:
tags = listing.get("tags", [])
if isinstance(tags, str):
try:
tags = json.loads(tags)
except json.JSONDecodeError:
tags = []
faves = listing.get("num_favorers", 0) or 0
reviews = listing.get("review_count", 0) or 0
engagement = faves + reviews * 5
for tag in tags:
if not tag or len(tag) < 3:
continue
if tag not in tag_stats:
tag_stats[tag] = {"count": 0, "total_engagement": 0, "listings": []}
tag_stats[tag]["count"] += 1
tag_stats[tag]["total_engagement"] += engagement
tag_stats[tag]["listings"].append(listing.get("price", 0))
# Score by engagement/listing ratio (high engagement, low competition)
scored = []
for tag, stats in tag_stats.items():
if stats["count"] < 2:
continue
avg_engagement = stats["total_engagement"] / stats["count"]
if avg_engagement < min_favorites:
continue
competition_score = stats["count"]
opportunity_score = avg_engagement / competition_score
prices = [p for p in stats["listings"] if p]
scored.append({
"tag": tag,
"listing_count": stats["count"],
"avg_engagement": round(avg_engagement, 1),
"opportunity_score": round(opportunity_score, 2),
"avg_price": round(statistics.mean(prices), 2) if prices else None,
})
return sorted(scored, key=lambda x: x["opportunity_score"], reverse=True)
Revenue Estimation from Analytics Data
Build revenue estimates that combine multiple data signals:
from datetime import datetime, timedelta
def estimate_monthly_revenue(
shop_name: str,
db_path: str = "etsy_analytics.db",
) -> dict:
"""
Estimate a shop's monthly revenue using available signals.
Combines review velocity, transaction count, and pricing data.
"""
conn = sqlite3.connect(db_path)
# Get shop basics
shop = conn.execute(
"SELECT transaction_sold_count, review_count, review_average FROM shops WHERE shop_name = ?",
(shop_name,)
).fetchone()
if not shop:
conn.close()
return {"error": "shop_not_found"}
# Get review timestamps to calculate velocity
recent_reviews = conn.execute(
"""SELECT date FROM reviews
WHERE listing_id IN (SELECT listing_id FROM listings WHERE shop_name = ?)
ORDER BY date DESC LIMIT 200""",
(shop_name,)
).fetchall()
# Get pricing data
listings = conn.execute(
"SELECT price, is_bestseller FROM listings WHERE shop_name = ?",
(shop_name,)
).fetchall()
conn.close()
prices = [row[0] for row in listings if row[0] and row[0] > 0]
avg_price = statistics.mean(prices) if prices else 0
# Review velocity
daily_reviews = 0
if recent_reviews and len(recent_reviews) >= 5:
dates = []
for row in recent_reviews[:50]:
try:
d = datetime.fromisoformat(row[0].replace("Z", "+00:00"))
dates.append(d)
except (ValueError, TypeError):
pass
if len(dates) >= 2:
date_range = (max(dates) - min(dates)).days
if date_range > 0:
daily_reviews = len(dates) / date_range
# Estimate daily sales from review velocity (5-10x multiplier)
daily_sales_low = daily_reviews * 5
daily_sales_high = daily_reviews * 10
# Monthly revenue estimate
monthly_revenue_low = daily_sales_low * 30 * avg_price
monthly_revenue_high = daily_sales_high * 30 * avg_price
# Cross-check with total transaction count if available
if shop[0]: # transaction_sold_count
# Estimate shop age from listing creation dates (rough)
implied_daily_sales = shop[0] / 365 # Assume 1 year old minimum
alt_monthly_revenue = implied_daily_sales * 30 * avg_price
else:
alt_monthly_revenue = None
return {
"shop_name": shop_name,
"avg_price": round(avg_price, 2),
"daily_review_velocity": round(daily_reviews, 3),
"estimated_monthly_sales_low": round(daily_sales_low * 30),
"estimated_monthly_sales_high": round(daily_sales_high * 30),
"estimated_monthly_revenue_low": round(monthly_revenue_low),
"estimated_monthly_revenue_high": round(monthly_revenue_high),
"transaction_based_revenue": round(alt_monthly_revenue) if alt_monthly_revenue else None,
"review_count": shop[1],
"review_average": shop[2],
"confidence": "low" if daily_reviews < 0.1 else "medium" if daily_reviews < 1 else "high",
}
def analyze_shop_portfolio(
shop_names: list,
db_path: str = "etsy_analytics.db",
) -> list:
"""Run revenue estimates across multiple shops for competitive comparison."""
results = []
for shop in shop_names:
est = estimate_monthly_revenue(shop, db_path)
if "error" not in est:
results.append(est)
return sorted(results, key=lambda x: x["estimated_monthly_revenue_high"], reverse=True)
Automated Monitoring Pipeline
Set up automated weekly monitoring of your tracked shops:
import schedule
import time
def weekly_analytics_run(
shop_names: list,
proxy: str = None,
db_path: str = "etsy_analytics.db",
):
"""Weekly analytics collection run."""
print(f"Weekly Etsy analytics run: {datetime.now().isoformat()}")
conn = init_db(db_path)
for shop_name in shop_names:
print(f"\nProcessing: {shop_name}")
data = scrape_shop_analytics(shop_name)
if not data:
continue
shop = data["shop"]
listings = data["listings"]
# Save shop data
save_shop_data(conn, shop, estimate_sales(shop, listings))
# Save listings
for listing in listings:
conn.execute(
"""INSERT OR REPLACE INTO listings
(listing_id, shop_name, title, price, currency, views,
num_favorers, quantity, is_bestseller, created_timestamp)
VALUES (?,?,?,?,?,?,?,?,?,?)""",
(listing.get("listing_id"), shop_name, listing.get("title"),
listing.get("price"), listing.get("currency"),
listing.get("views"), listing.get("num_favorers"),
listing.get("quantity"), listing.get("is_bestseller"),
listing.get("created_timestamp")),
)
conn.commit()
# Revenue estimate
revenue = estimate_monthly_revenue(shop_name, db_path)
low = revenue.get("estimated_monthly_revenue_low", 0)
high = revenue.get("estimated_monthly_revenue_high", 0)
print(f" Revenue estimate: ${low:,} - ${high:,}/mo")
time.sleep(random.uniform(20, 40))
conn.close()
print("\nWeekly run complete")
# Schedule for weekly execution
SHOPS_TO_MONITOR = ["EtsyShopName1", "EtsyShopName2", "EtsyShopName3"]
PROXY = "http://USER:[email protected]:9000"
schedule.every().monday.at("08:00").do(
weekly_analytics_run,
shop_names=SHOPS_TO_MONITOR,
proxy=PROXY,
)
if __name__ == "__main__":
weekly_analytics_run(SHOPS_TO_MONITOR, proxy=PROXY) # Run immediately
while True:
schedule.run_pending()
time.sleep(3600)
Key Takeaways
- Etsy API v3 exposes shop transaction counts, review averages, and per-listing view and favorite counts on the free tier -- start there before scraping anything
- Review count times 5-10x is the standard Etsy sales estimation method; use the shop-level transaction count when embedded in the page state to validate your multiplier
- Favorite-to-view ratio per listing is a leading performance indicator -- it surfaces high-converting products before review counts accumulate
- Etsy's bot detection combines Cloudflare, TLS fingerprinting, and session pattern analysis; datacenter IPs fail reliably and residential proxies are required
- ThorData's residential proxy network with US geo-targeting is reliable for Etsy -- randomize delays between 5-12 seconds per request
- Store snapshots weekly and track trends: review velocity, favorite growth rate, listing count changes over time are more actionable than any single data point
- The niche opportunity scoring (opportunity_count - saturation_count) gives a quick filter for evaluating whether a market is worth entering