{
 "title": "How to categorize earnings call transcripts with an LLM \u2014 StockQL",
 "description": "Export earnings calls from StockQL, classify demand, pricing, margins, and capital spending with quoted evidence, then screen the labels in PostgreSQL.",
 "url": "https://stockql.ai/blog/how-to-categorize-earnings-transcripts-with-an-llm.html",
 "slug": "blog/how-to-categorize-earnings-transcripts-with-an-llm",
 "published": "2026-09-12",
 "site": "StockQL",
 "contact": "hello@stockql.ai",
 "body_text": "Earnings transcript research\n\nHow to categorize earnings call transcripts with an LLM\n\nExport a reproducible StockQL corpus, label demand, pricing power, margins, and capital spending with quoted evidence, then query the result in PostgreSQL.\n\nSeptember 12, 2026 \u00b7 14 minute read\n\nIn this guide\n\nDefine the taxonomy\n\nExport a bounded corpus\n\nRun the classifier\n\nLoad and screen the labels\n\nMeasure cost and quality\n\nAn earnings transcript is already text, which makes \u201csend it to a model and save the sentiment\u201d 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.\n\nStockQL 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.\n\nLabels are claims, not facts. Keep the transcript id, call date, content digest, taxonomy version, exact prompt digest, model name, and quoted evidence beside every result. If any of those changes, produce a new label rather than silently overwriting the old one.\n\n1. Define a taxonomy a reviewer can disagree with\n\n\u201cPositive\u201d 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:\n\nTheme Chunk labels Question answered\n\nDemand accelerating , stable , softening , not_discussed How does management describe orders, units, customers, or end-market demand?\n\nPricing power improving , stable , weakening , not_discussed Can the company raise or hold price without losing the business?\n\nMargin outlook expanding , stable , compressing , not_discussed What direction does management give for future operating economics?\n\nCapital spending increasing , stable , decreasing , not_discussed Is planned investment rising, holding, or falling?\n\nThe classifier labels chunks, not a preselected \u201cimportant\u201d 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.\n\nEach 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.\n\n2. Export a bounded, reproducible corpus\n\nStart 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:\n\n\\pset tuples_only on\n\\pset format unaligned\n\\o transcripts.jsonl\nWITH latest_calls AS (\nSELECT DISTINCT ON (et.ticker)\net.id,\net.ticker,\net.call_date,\net.transcript\nFROM earnings_transcripts et\nJOIN stocks s\nON s.symbol = et.ticker\nAND s.exchange_code = 'US'\nWHERE s.type = 'Common Stock'\nAND s.security_class = 'common'\nAND s.is_delisted IS NOT TRUE\nAND et.call_date >= DATE '2026-06-15'\nAND et.call_date <= DATE '2026-09-12'\nORDER BY et.ticker, et.call_date DESC, et.id DESC\n)\nSELECT jsonb_build_object(\n'transcript_id', id::text,\n'symbol', ticker,\n'exchange_code', 'US',\n'call_date', call_date,\n'content', transcript\n)::text\nFROM latest_calls\nORDER BY call_date DESC, ticker\nLIMIT 25;\n\\o\n\nThe 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.\n\nKeep the corpus local unless your data terms permit otherwise. The JSONL file contains full transcript text. A local endpoint keeps it on your machine; a hosted endpoint receives every chunk sent to it. StockQL does not choose that boundary for you.\n\n3. Run an evidence-grounded classifier\n\nThe complete example uses only Python\u2019s standard library. Download it, inspect it, and point it at an OpenAI-compatible endpoint:\n\ncurl -fSLO https://stockql.ai/classify-earnings-transcripts.py\n\nLLM_MODEL='your-model' \\\nLLM_URL='http://127.0.0.1:11434/v1/chat/completions' \\\npython3 classify-earnings-transcripts.py \\\ntranscripts.jsonl transcript-labels.csv\n\nDownload the classifier \u2192\n\nFor 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.\n\nThe script makes five choices that keep the result inspectable:\n\nBounded 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.\nUntrusted input The system instruction treats transcript text as quoted data, never instructions, and tells the model not to turn an analyst\u2019s question into management guidance.\nClosed labels Unexpected keys, unknown labels, non-JSON output, oversized evidence, and invented quotes stop the run instead of being coerced into a plausible row.\nConservative aggregation One direction survives unchanged; conflicting directions become mixed ; no discussion remains not_discussed .\nContent-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.\n\nThe 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.\n\nThe cache and CSV contain transcript-derived quotes. Protect and retain them under the same data policy as the JSONL corpus.\n\n4. Load the labels and build a research queue\n\nLoad the CSV into a temporary table first. The checks below make the allowed call-level vocabulary explicit, including the mixed value introduced by aggregation:\n\nCREATE TEMP TABLE transcript_labels (\ntranscript_id text PRIMARY KEY,\nsymbol text NOT NULL,\nexchange_code text NOT NULL,\ncall_date date NOT NULL,\ntaxonomy_version text NOT NULL,\nprompt_sha256 text NOT NULL CHECK (length(prompt_sha256) = 64),\nmodel text NOT NULL,\ntranscript_sha256 text NOT NULL CHECK (length(transcript_sha256) = 64),\ndemand text NOT NULL CHECK (\ndemand IN ('accelerating', 'stable', 'softening', 'mixed', 'not_discussed')\n),\npricing_power text NOT NULL CHECK (\npricing_power IN ('improving', 'stable', 'weakening', 'mixed', 'not_discussed')\n),\nmargin_outlook text NOT NULL CHECK (\nmargin_outlook IN ('expanding', 'stable', 'compressing', 'mixed', 'not_discussed')\n),\ncapital_spending text NOT NULL CHECK (\ncapital_spending IN ('increasing', 'stable', 'decreasing', 'mixed', 'not_discussed')\n),\nprompt_tokens bigint NOT NULL CHECK (prompt_tokens >= 0),\ncompletion_tokens bigint NOT NULL CHECK (completion_tokens >= 0),\nevidence_json jsonb NOT NULL CHECK (jsonb_typeof(evidence_json) = 'array')\n);\n\n\\copy transcript_labels FROM 'transcript-labels.csv' CSV HEADER\n\nNow 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:\n\nSELECT\nl.symbol,\ns.name,\nl.call_date,\nl.demand,\nl.pricing_power,\nl.margin_outlook,\nl.capital_spending,\nROUND((m.market_cap / 1e9)::numeric, 2) AS market_cap_usdbn,\nl.evidence_json\nFROM transcript_labels l\nJOIN stocks s\nON s.exchange_code = l.exchange_code\nAND s.symbol = l.symbol\nLEFT JOIN stock_fundamental_metrics m\nON m.exchange_code = l.exchange_code\nAND m.symbol = l.symbol\nAND m.market_cap > 0\nWHERE l.demand = 'accelerating'\nAND l.pricing_power IN ('improving', 'stable')\nAND l.margin_outlook IN ('expanding', 'stable')\nORDER BY m.market_cap DESC NULLS LAST, l.symbol;\n\nThe 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.\n\nThis is a current screen, not a historical test. Current company status and market capitalization are appropriate for today\u2019s review queue. A backtest must form its universe and valuation inputs from facts available at the time, then delay any transcript-derived signal until the call was actually available. The StockQL backtesting guide covers the point-in-time discipline.\n\n5. Measure cost and quality before scaling\n\nRow 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:\n\nSELECT\nCOUNT(*) AS calls,\nSUM(prompt_tokens) AS input_tokens,\nSUM(completion_tokens) AS output_tokens\nFROM transcript_labels;\n\nApply the provider\u2019s 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.\n\nBefore labeling the archive, evaluate the workflow on a stratified sample:\n\nHand-label calls of different lengths, industries, and prepared-remarks/Q&A mixes before looking at model output.\n\nScore each theme separately. Also score whether every quote supports its label; substring validation proves provenance, not interpretation.\n\nInspect mixed and not_discussed rows. Those are where chunk boundaries and vague management language are most visible.\n\nCompare models and prompt revisions on the same transcript digests. Never mix versions into one apparent historical series.\n\nFreeze the rubric before testing returns. Rewriting labels after seeing future performance turns taxonomy design into overfitting.\n\nTemperature 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.\n\nWhat StockQL contributes\n\nThe 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.\n\nThe 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.\n\nThat 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.\n\nSee how StockQL handles the data \u2192"
}
