Earnings transcript research

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:

ThemeChunk labelsQuestion answered
Demandaccelerating, stable, softening, not_discussedHow does management describe orders, units, customers, or end-market demand?
Pricing powerimproving, stable, weakening, not_discussedCan the company raise or hold price without losing the business?
Margin outlookexpanding, stable, compressing, not_discussedWhat direction does management give for future operating economics?
Capital spendingincreasing, stable, decreasing, not_discussedIs 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

Download the classifier →

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 remains not_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 mixed and not_discussed rows. 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.

See how StockQL handles the data →