Every quarter, roughly 10,000 companies hold an earnings call. Somewhere in those transcripts, the next market narrative is already being rehearsed — quarters before it becomes a headline. Management teams add a phrase to prepared remarks when they believe it will move the stock, and they quietly stop when they believe it won't. That means mention counts flatten before headlines do, which makes a theme tracker the cheapest early-warning system in equity research.
This post builds one in Python. Under 60 lines, standard library only, one REST endpoint. At the end you'll have a CSV with one row per theme per quarter — ready for a spreadsheet or matplotlib — and two real result sets to prove the thing works.
The plan
The whole tracker is five ideas:
- Define themes as plain strings:
["tariff refund", "sovereign AI", "agentic commerce"]. - A
quarters()helper that yields(date_from, date_to)pairs, from 2024-01-01 through 2026-06-30 — the last complete quarter as I write this. - For each theme and quarter, page through the search endpoint and collect unique tickers.
- Write a CSV:
theme, quarter, companies, calls. - A watch mode: re-run after each earnings week via cron, diff against the stored CSV, and print any theme that just made a new high.
No NLP, no embeddings, no sentiment model. Counting. It's worth being honest about that up front, because counting done consistently across a quarter-million transcripts beats clever analysis done on twenty hand-picked ones.
One endpoint does all the work
Everything runs through a single GET request:
GET https://earningscalls.dev/api/v1/search
The parameters that matter here:
q— the query. Supports"exact phrase"quoting,word AND word,word OR word, and-wordnegation.type=transcripts— search full call transcripts (other values:speakers,companies,all).date_from/date_to—YYYY-MM-DD, which is how we slice by quarter.pageandlimit— pagination,limitmaxes out at 100.- Optional filters we'll use later:
ticker,sector,industry, andspeaker_typewhen searching speaker segments.
Auth is an X-API-Key header. Full-text search requires a Pro or Enterprise key — plans are on the API page, and the full parameter reference lives in the docs.
One methodology caveat, and I'll only say it once: keyword matching gives you an upper bound on theme adoption. And a multi-word query without quotes matches any call containing all the words anywhere in the transcript — "tariff" in the CFO's remarks plus "refund" in an unrelated analyst question would count. Quote your phrases. "tariff refund" means the phrase; tariff refund means both words, somewhere.
The code
Here's the entire tracker — 58 lines including blanks:
"""Theme tracker: companies mentioning a phrase on earnings calls, per quarter."""
import csv, hashlib, json, os, time, urllib.parse, urllib.request
API = "https://earningscalls.dev/api/v1/search"
KEY = os.environ["EARNINGSCALLS_API_KEY"]
THEMES = ['"tariff refund"', '"sovereign AI"', '"agentic commerce"']
CACHE = ".theme_cache"
def quarters(start_year=2024, end="2026-06-30"):
y = start_year
while True:
for q, (a, b) in enumerate([("01-01", "03-31"), ("04-01", "06-30"),
("07-01", "09-30"), ("10-01", "12-31")], 1):
d_from, d_to = f"{y}-{a}", f"{y}-{b}"
if d_from > end:
return
yield f"{y} Q{q}", d_from, d_to
y += 1
def get(params):
url = API + "?" + urllib.parse.urlencode(params)
path = os.path.join(CACHE, hashlib.sha1(url.encode()).hexdigest() + ".json")
if os.path.exists(path):
with open(path) as f:
return json.load(f)
req = urllib.request.Request(url, headers={"X-API-Key": KEY})
with urllib.request.urlopen(req) as r:
data = json.loads(r.read())
os.makedirs(CACHE, exist_ok=True)
with open(path, "w") as f:
json.dump(data, f)
time.sleep(1) # only real requests sleep; cache hits are free
return data
def count(theme, d_from, d_to):
tickers, calls, page = set(), 0, 1
while True:
data = get({"q": theme, "type": "transcripts", "limit": 100,
"page": page, "date_from": d_from, "date_to": d_to})
rows = data.get("results") or data.get("data") or []
for row in rows:
calls += 1
t = row.get("ticker") or (row.get("company") or {}).get("ticker")
if t:
tickers.add(t)
if len(rows) < 100:
return len(tickers), calls
page += 1
if __name__ == "__main__":
with open("themes.csv", "w", newline="") as f:
out = csv.writer(f)
out.writerow(["theme", "quarter", "companies", "calls"])
for theme in THEMES:
for label, d_from, d_to in quarters():
companies, calls = count(theme, d_from, d_to)
out.writerow([theme.strip('"'), label, companies, calls])
print(f"{theme:>20} {label}: {companies} companies / {calls} calls")
A few decisions worth explaining.
Pagination is the termination condition. The results come back as JSON; I page with limit=100 and stop as soon as a page comes back with fewer than 100 rows. That's the whole loop — no total-count field to trust, no off-by-one on the last page.
The ticker extraction is deliberately paranoid. row.get("ticker") or (row.get("company") or {}).get("ticker") looks ugly, but I don't want a tracker that runs weekly for a year to crash because a response gained or moved a field. Every access is a .get() with a fallback; a row I can't extract a ticker from still counts as a call, it just doesn't add a company. Defensive beats elegant in anything you put on a cron.
The cache makes re-runs free. Each request URL is hashed to a filename; if the file exists, no HTTP request happens and no sleep happens. Historical quarters never change, so after the first run, only the current quarter costs anything. This matters more than it looks: it means you can add a fourth theme tomorrow and only pay for the new queries, and it means the watch mode below can re-run the whole script guilt-free.
Politeness costs almost nothing. With limit=100, even the busiest theme-quarter in my results below is a handful of pages. Three themes across ten quarters is well under a hundred requests on the first run — a couple of minutes with the one-second sleep — and near-zero on every run after.
What came back
I ran exactly this script. Here are the first two themes — unique companies whose calls matched the quoted phrase, per quarter:
| Quarter | "tariff refund" | "sovereign AI" |
|---|---|---|
| 2025 Q1 | 87 | 93 |
| 2025 Q2 | 122 | 97 |
| 2025 Q3 | 117 | 149 |
| 2025 Q4 | 104 | 177 |
| 2026 Q1 | 206 | 244 |
| 2026 Q2 | 381 | 252 |
Two completely different shapes, and that's the point of running both.
"Tariff refund" is an event spike. Flat around 100 companies for most of 2025, then it triples in half a year. The Q2 2026 jump followed the Supreme Court ruling that IEEPA didn't authorize the tariffs — which turned tariff costs into refunds, and turned a cost-management talking point into a receivable that analysts started asking about line by line. I went deep on that quarter in the tariff refund quarter; the tracker is how you'd have seen the pressure building in Q1, when the count doubled before the ruling landed.
"Sovereign AI" is a steady diffusion. No single event, just 93 to 252 companies over six quarters, compounding every quarter as the theme moved from vendor pitch to budget line. That's the trajectory I traced in sovereign AI: from buzzword to buyer — and notice the Q2 2026 row: 244 to 252 is the first quarter of near-zero growth. One flat quarter isn't a trend. But if the narrative you're positioned around stops spreading, this table is where you find out, and it costs one cron job to keep watching.
That's the pattern worth internalizing: an event-driven spike and a slow diffusion look identical in headlines — both peak when coverage peaks. In mention counts they look nothing alike, and the flattening shows up here first. The chart you make from the CSV — quarters on the x-axis, companies on the y, one line per theme — is the whole product. No annotations needed.
The watch mode
The tracker earns its keep on re-runs. The idea: after each earnings week, re-run the script (the cache means only current-quarter queries hit the API), then diff against what you had and flag any theme that made a new high.
"""theme_watch.py -- flag themes at a new high after re-running the tracker."""
import csv, subprocess
from collections import defaultdict
prev = defaultdict(int)
for r in csv.DictReader(open("themes.csv")):
prev[r["theme"]] = max(prev[r["theme"]], int(r["companies"]))
subprocess.run(["python3", "theme_tracker.py"], check=True)
for r in csv.DictReader(open("themes.csv")):
if int(r["companies"]) > prev[r["theme"]]:
print(f"NEW HIGH: {r['theme']} — {r['companies']} companies in {r['quarter']}")
Put it on cron for Saturday mornings during earnings season — 0 7 * * 6 — and pipe the output to email or Slack. Diffing against the stored CSV instead of alerting on absolute levels keeps it quiet: you only hear about acceleration, never about a theme idling at its plateau. Most weeks it prints nothing. The weeks it prints something are the weeks worth reading transcripts.
Where to take it next
Three extensions, in the order I'd build them:
Scope by sector. Add sector= to the query params and run the same counts per sector. Themes diffuse in a predictable direction — tech first, then whoever tech sells to, then everyone else — and the sector split tells you who picks a theme up next, which is more actionable than the aggregate count.
Watch the sell side. Switch to type=speakers with speaker_type=analyst and count how often analysts raise the theme in Q&A, versus executives volunteering it in prepared remarks. Analysts asking before executives volunteering is the tell: it means the buy side is already pricing the question and management hasn't caught up to it. Executives volunteering before anyone asks is usually just marketing.
Then, and only then, get smarter than counting. This tracker deliberately stops at phrase frequency, because frequency done consistently is more robust than sentiment done inconsistently. When you're ready for hedging language, guidance specificity, and the other things quant teams actually extract from this corpus, start with five text signals quants pull from earnings calls — every one of them builds on the paging loop you already have.
And if you'd rather ask questions in plain English than write the loop at all, the same search runs through the MCP server from Claude or any MCP client. But for a tracker that runs unattended every Saturday for the next two years, 58 lines of standard-library Python is the right tool.
The corpus behind these numbers — 250,000+ earnings call transcripts across 12,000+ companies, 2020 to today — is one API key away at earningscalls.dev.