Using LLMs to generate trading signals: what actually works
The first thing I tried with LLMs and trading was the obvious, wrong thing: I asked the model to predict price direction. “Given this news, will the stock go up or down?” It was confidently right about as often as a coin, and it cost me real money to learn that.
What actually works is narrower and far more useful: use the LLM as a qualitative-to-structured extractor, and let a boring quant model do the predicting.
The problem I hit
LLMs are not price oracles. They have no edge on “what happens next,” and asking them to guess invites three failure modes at once: hallucinated confidence, non-determinism, and latency you can’t afford in a live loop.
But the thing they’re genuinely good at — reading messy unstructured text and turning it into clean fields — is exactly the step that used to require an analyst.
My approach: LLM extracts features, model makes the call
I stopped asking “up or down” and started asking “extract these specific fields as JSON.” The LLM never sees a price and never predicts a return. It turns a news item into structured features; a downstream model I can backtest decides what to do with them.
client = genai.Client()
SCHEMA = {
"type": "object",
"properties": {
"event_type": {"type": "string",
"enum": ["guidance_cut", "guidance_raise", "mna", "litigation",
"product", "macro", "none"]},
"sentiment": {"type": "number"}, # -1.0 .. 1.0
"surprise": {"type": "number"}, # 0.0 .. 1.0, vs. what was expected
"horizon_days": {"type": "integer"},
},
"required": ["event_type", "sentiment", "surprise", "horizon_days"],
}
def extract_signal(headline: str, body: str) -> dict:
resp = client.models.generate_content(
model="gemini-3.5-flash",
contents=f"Headline: {headline}\n\n{body}",
config={
"system_instruction": "You extract structured trading-relevant features. "
"You never predict prices. Output only the schema.",
"response_mime_type": "application/json",
"response_schema": SCHEMA,
},
)
import json
return json.loads(resp.text)
The features become columns. A gradient-boosted model (the part I can actually validate out-of-sample) maps them to a position.
flowchart LR
news["News + filings"] --> llm["LLM extractor"]
llm --> feat["Structured features"]
feat --> model["GBM signal model"]
model --> risk["Risk + sizing"]
risk --> order["Order"]
What made it work
- Structured output, always. Free-text responses are unparseable in a pipeline. Schema-constrained JSON turned a flaky idea into a deterministic feature step.
- The LLM is upstream of the model, never the decision. Every prediction is made by something I can backtest. The LLM only changes the inputs.
- Cache aggressively. The same wire story hits multiple tickers; I key the extraction on a content hash so I pay for each article once. That cut my token spend by more than half.
Results
On a held-out period, adding the LLM-extracted surprise and event_type features improved the model’s information coefficient from 0.03 to 0.05. Modest — but it’s signal that simply wasn’t available as a clean feature before.
IC (+ event_type, surprise): 0.052
lift: +0.021
The honest caveat: most of the lift came from surprise and event_type, not raw sentiment. Sentiment alone was nearly useless — it’s already priced in by the time the headline is public.
Lessons
- Don’t ask an LLM to predict returns. Ask it to structure information and let a validatable model predict.
- The win is in features you couldn’t cheaply compute before (event type, surprise vs. expectation), not in sentiment everyone already has.
- Treat the extractor like any other data vendor: version the prompt, cache the outputs, and monitor for drift when the model updates.