How to categorize earnings call transcripts with an LLM
Export a reproducible StockQL corpus, label demand, pricing power, margins, and capital spending with quoted evidence, then query the result in PostgreSQL.
An earnings transcript is already text, which makes “send it to a model and save the sentiment” tempting. The result is usually an unreviewable number. A useful label needs a narrow question, a constrained vocabulary, an exact quote, and enough provenance to reproduce it.
StockQL supplies the calls and their market identifiers. The classifier in this guide stays separate and works with any endpoint that implements the OpenAI-compatible chat-completions wire format, including a local model. Its output is derived research data, not a replacement for the source transcript.
1. Define a taxonomy a reviewer can disagree with
“Positive” compresses too much. A company can report strong demand, weaker pricing, narrowing margins, and higher capital spending in the same call. This guide keeps those dimensions separate:
| Theme | Chunk labels | Question answered |
|---|---|---|
| Demand | accelerating, stable, softening, not_discussed | How does management describe orders, units, customers, or end-market demand? |
| Pricing power | improving, stable, weakening, not_discussed | Can the company raise or hold price without losing the business? |
| Margin outlook | expanding, stable, compressing, not_discussed | What direction does management give for future operating economics? |
| Capital spending | increasing, stable, decreasing, not_discussed | Is planned investment rising, holding, or falling? |
The classifier labels chunks, not a preselected “important” excerpt. When different chunks support different directions for one theme, deterministic aggregation returns mixed. It does not majority-vote a contradiction away. not_discussed is also distinct from stable: silence is not guidance.
Each nonempty label must carry an exact quote of at most 240 characters from the same chunk. A summary is easier to read but harder to audit; the downloadable script rejects evidence that is not a literal substring of the source.
2. Export a bounded, reproducible corpus
Start with a small current-research queue rather than the entire archive. In psql, the query below selects the latest call for each non-delisted US common share inside a fixed date window, then emits the JSON Lines contract expected by the classifier:
\pset tuples_only on
\pset format unaligned
\o transcripts.jsonl
WITH latest_calls AS (
SELECT DISTINCT ON (et.ticker)
et.id,
et.ticker,
et.call_date,
et.transcript
FROM earnings_transcripts et
JOIN stocks s
ON s.symbol = et.ticker
AND s.exchange_code = 'US'
WHERE s.type = 'Common Stock'
AND s.security_class = 'common'
AND s.is_delisted IS NOT TRUE
AND et.call_date >= DATE '2026-06-15'
AND et.call_date <= DATE '2026-09-12'
ORDER BY et.ticker, et.call_date DESC, et.id DESC
)
SELECT jsonb_build_object(
'transcript_id', id::text,
'symbol', ticker,
'exchange_code', 'US',
'call_date', call_date,
'content', transcript
)::text
FROM latest_calls
ORDER BY call_date DESC, ticker
LIMIT 25;
\o
The fixed boundaries and deterministic ordering make the sample reproducible. On the verification snapshot, the bounded filter matched 2,817 calls from 2,807 tickers, spanning June 15 through September 1, 2026, and 18.94 million words. The 25-row limit is therefore a cost boundary, not a coverage claim. The call date and stable row id identify the source used for a label; fiscal year and quarter should not replace them as identity.
3. Run an evidence-grounded classifier
The complete example uses only Python’s standard library. Download it, inspect it, and point it at an OpenAI-compatible endpoint:
curl -fSLO https://stockql.ai/classify-earnings-transcripts.py
LLM_MODEL='your-model' \
LLM_URL='http://127.0.0.1:11434/v1/chat/completions' \
python3 classify-earnings-transcripts.py \
transcripts.jsonl transcript-labels.csv
For a hosted endpoint, set LLM_URL, LLM_MODEL, and LLM_API_KEY through your normal secret-management path. Do not paste an API key into the script or the output file.
The script makes five choices that keep the result inspectable:
- Bounded chunks
- Paragraphs are packed into chunks of at most 24,000 characters. That is a character limit, not a tokenizer claim; lower it if the selected model has a smaller context window.
- Untrusted input
- The system instruction treats transcript text as quoted data, never instructions, and tells the model not to turn an analyst’s question into management guidance.
- Closed labels
- Unexpected keys, unknown labels, non-JSON output, oversized evidence, and invented quotes stop the run instead of being coerced into a plausible row.
- Conservative aggregation
- One direction survives unchanged; conflicting directions become
mixed; no discussion remainsnot_discussed. - Content-addressed cache
- Successful chunk results are cached by endpoint, model, prompt version, and source text. A rerun reuses exactly matching work while a changed transcript or rubric receives a new key.
The CSV is replaced only after every requested transcript passes validation. If a call fails, correct the endpoint or prompt behavior and rerun; already validated chunks remain in the local cache.
The cache and CSV contain transcript-derived quotes. Protect and retain them under the same data policy as the JSONL corpus.
4. Load the labels and build a research queue
Load the CSV into a temporary table first. The checks below make the allowed call-level vocabulary explicit, including the mixed value introduced by aggregation:
CREATE TEMP TABLE transcript_labels (
transcript_id text PRIMARY KEY,
symbol text NOT NULL,
exchange_code text NOT NULL,
call_date date NOT NULL,
taxonomy_version text NOT NULL,
prompt_sha256 text NOT NULL CHECK (length(prompt_sha256) = 64),
model text NOT NULL,
transcript_sha256 text NOT NULL CHECK (length(transcript_sha256) = 64),
demand text NOT NULL CHECK (
demand IN ('accelerating', 'stable', 'softening', 'mixed', 'not_discussed')
),
pricing_power text NOT NULL CHECK (
pricing_power IN ('improving', 'stable', 'weakening', 'mixed', 'not_discussed')
),
margin_outlook text NOT NULL CHECK (
margin_outlook IN ('expanding', 'stable', 'compressing', 'mixed', 'not_discussed')
),
capital_spending text NOT NULL CHECK (
capital_spending IN ('increasing', 'stable', 'decreasing', 'mixed', 'not_discussed')
),
prompt_tokens bigint NOT NULL CHECK (prompt_tokens >= 0),
completion_tokens bigint NOT NULL CHECK (completion_tokens >= 0),
evidence_json jsonb NOT NULL CHECK (jsonb_typeof(evidence_json) = 'array')
);
\copy transcript_labels FROM 'transcript-labels.csv' CSV HEADER
Now the qualitative claims behave like any other explicit screen. This example asks for accelerating demand, pricing that is not weakening, and margins that are not compressing, then attaches the current StockQL company name and market capitalization:
SELECT
l.symbol,
s.name,
l.call_date,
l.demand,
l.pricing_power,
l.margin_outlook,
l.capital_spending,
ROUND((m.market_cap / 1e9)::numeric, 2) AS market_cap_usdbn,
l.evidence_json
FROM transcript_labels l
JOIN stocks s
ON s.exchange_code = l.exchange_code
AND s.symbol = l.symbol
LEFT JOIN stock_fundamental_metrics m
ON m.exchange_code = l.exchange_code
AND m.symbol = l.symbol
AND m.market_cap > 0
WHERE l.demand = 'accelerating'
AND l.pricing_power IN ('improving', 'stable')
AND l.margin_outlook IN ('expanding', 'stable')
ORDER BY m.market_cap DESC NULLS LAST, l.symbol;
The evidence stays in the result because the queue is for review, not automatic conviction. Open the source call before treating a label as part of an investment thesis.
5. Measure cost and quality before scaling
Row count is a poor estimate of LLM spend because transcript lengths vary widely. The classifier records usage returned by the endpoint. Sum it before changing the sample size:
SELECT
COUNT(*) AS calls,
SUM(prompt_tokens) AS input_tokens,
SUM(completion_tokens) AS output_tokens
FROM transcript_labels;
Apply the provider’s current rates directly: input tokens divided by one million times the input rate, plus output tokens divided by one million times the output rate. An endpoint that does not report usage leaves zeroes; use its server logs instead. A local model removes the metered token bill, not the compute time or review requirement.
Before labeling the archive, evaluate the workflow on a stratified sample:
- Hand-label calls of different lengths, industries, and prepared-remarks/Q&A mixes before looking at model output.
- Score each theme separately. Also score whether every quote supports its label; substring validation proves provenance, not interpretation.
- Inspect
mixedandnot_discussedrows. Those are where chunk boundaries and vague management language are most visible. - Compare models and prompt revisions on the same transcript digests. Never mix versions into one apparent historical series.
- Freeze the rubric before testing returns. Rewriting labels after seeing future performance turns taxonomy design into overfitting.
Temperature zero narrows sampling but does not make every model runtime deterministic. The model name, taxonomy version, prompt hash, content hash, evidence, and cache make any difference observable.
What StockQL contributes
The LLM is replaceable. The durable layer is the corpus beneath it: long-form transcripts, call dates, fiscal metadata, market identifiers, current company facts, and direct SQL access to the rows used by a screen.
The verification snapshot held 173,423 transcripts across 4,235 tickers, with call dates from October 2005 through September 2026. The database keeps that long-form corpus beside the structured facts needed to turn a label into a review queue.
That separation keeps model choice in your hands. StockQL maintains the research database; a local model, hosted model, notebook, or agent can add derived labels without turning them into hidden source truth.