Skip to content
AI Trading

Using LLMs to generate trading signals: what actually works

llm trading signals — a computer screen with a bunch of code on it

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.

from google import genai

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 (baseline features): 0.031
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.

Join the conversation

Your email address will not be published. Required fields are marked *