tradefloorStart free

Guides

Testing an LLM trading agent without look-ahead bias

Ask a language model to trade March 2020 and you may be testing its memory. It has read about March 2020. Many times, in many languages, including the bits where everyone said they saw it coming.

This is look-ahead bias with extra steps. A backtest on recorded prices hands the model a period it may already know the ending of. Hiding the dates and tickers helps less than you'd hope, because a crash has a shape, and models are good at shapes. Glasserman and Lin measured this kind of look-ahead bias in GPT's sentiment scores for financial news.

The fix is to test on markets that never existed. tradefloor generates each market from a seed: companies, prices, an order book and an economy that never happened anywhere. There is nothing in any training set to remember.

Three habits for a fair test

Generated markets remove the memory problem. Two more habits make the result something other people can trust.

  • Seal the test. Publish a commitment to the seeds before the run, and the seeds after it. Anyone can check that you didn't pick the markets where your agent happened to shine. It's the trading version of handing a prediction to a notary in a sealed envelope. This notary is SHA-256 and doesn't bill by the hour.
  • Record every answer. A live model answers differently each time and charges for each call. tradefloor records every answer the model gives beside what it was shown. Later, the run replays without the model, the framework or an API key.
  • Compare against doing nothing. Buy-and-hold on the same market is the bar. An agent that loses to it has learned to pay the spread.

The whole test in one script

A plain Python function plays the model here, and the script runs offline. Swap ask_model for your model call and nothing else changes.

article-llm-agent.py

"""Test an LLM trading agent on markets no model has seen, and seal the test.

The markets are generated from seeds, so they are in no model's training
data. The seeds are committed to before the run, so the agent's author can
show the test was not tuned to them. Every model answer is recorded, so
anyone can replay the run without the model or an API key.

A plain function stands in for the model here. Swap ask_model for your
model call and nothing else changes.
"""
import json

import tradefloor as tf
from tradefloor.integrations.callable import callable_agent
from tradefloor.integrations.common import AdapterInfo, Transcript, digest

PROMPT = "You manage a portfolio. Answer with one JSON decision."
DAYS = 10
market = tf.Universe.random(20, seed=4242)

# 1. Commit to the test markets before the run. In practice, draw the seeds
#    with secrets.randbits(64) and keep them and the salt private until the
#    commitment is published.
SEEDS = [9101, 9102, 9103, 9104, 9105, 9106, 9107, 9108]
SALT = b"published after the run"
commitment = tf.commit(SEEDS, SALT)


def ask_model(payload):
    # Your model call goes here. This stand-in buys the cheapest name it is
    # shown, the kind of rule a model might come up with.
    cheapest = min(payload["assets"], key=lambda a: a["best_ask"])
    order = {"symbol": cheapest["symbol"], "side": "BUY", "quantity": 300}
    return json.dumps({"actions": [order], "rationale": "buy the cheapest name"})


def to_decision(raw, payload):
    decision = json.loads(raw)
    for action in decision["actions"]:
        action["quantity"] = min(action["quantity"], 300)   # your risk cap
    return decision


info = AdapterInfo(framework="callable", instructions_digest=digest(PROMPT))

# 2. Run the agent live on every committed seed, beside buy-and-hold on the
#    same market, and keep each seed's recording.
recordings, live_pnl, ahead = {}, {}, 0
per_market = {"llm": [], "buy_and_hold": []}           # P&L in $ thousands
for seed in SEEDS:
    recordings[seed] = Transcript()
    agent = callable_agent(ask_model, postprocess=to_decision, info=info,
                           mode="live", recorder=recordings[seed])
    hold = tf.reference_agents()["buy_and_hold"]
    cards = tf.evaluate({"llm": agent, "buy_and_hold": hold},
                        seed=seed, universe=market, days=DAYS)
    live_pnl[seed] = cards["llm"].pnl
    ahead += cards["llm"].pnl > cards["buy_and_hold"].pnl
    per_market["llm"].append(round(cards["llm"].pnl / 1000, 1))
    per_market["buy_and_hold"].append(round(cards["buy_and_hold"].pnl / 1000, 1))

# 3. Replay every seed from its recording. No model is called.
replays_match = all(
    tf.evaluate({"llm": callable_agent(postprocess=to_decision, info=info,
                                       mode="replay", transcript=recordings[seed])},
                seed=seed, universe=market, days=DAYS)["llm"].pnl == live_pnl[seed]
    for seed in SEEDS)

# 4. Publish the seeds and the salt. Anyone can check them against the
#    commitment made before the run.
revealed = tf.reveal(commitment, SEEDS, SALT)

seeds = len(SEEDS)
replay_ok, reveal_ok = int(replays_match), int(revealed)
answers = sum(len(r) for r in recordings.values())
print(f"commitment published before the run: {commitment[:16]}...")
print(f"ahead of buy-and-hold on {ahead} of {seeds} sealed markets")
print(f"{answers} model answers recorded; replay without the model matches: {replays_match}")
print(f"seeds revealed after the run match the commitment: {revealed}")

Output on tradefloor 0.10.2

commitment published before the run: 000dacf998e31c00...
ahead of buy-and-hold on 2 of 8 sealed markets
80 model answers recorded; replay without the model matches: True
seeds revealed after the run match the commitment: True

Reading the result

The stand-in buys 300 shares of the cheapest stock each day. On a million-dollar account that is roughly the most cautious trader who ever lived, and the chart shows it. Its profit barely leaves the axis while buy-and-hold swings from a loss of a few thousand to a gain of 62.4 thousand on the same markets. It finished ahead on 2 of 8 markets.

The other three lines are the point of the exercise. The commitment was fixed before the run. All 80 model answers were recorded and the replay reproduced every result without calling the model. And the revealed seeds match the commitment. Nobody had a chance to swap in friendlier markets afterwards.

Your model in place of the stand-in

Replace the body of ask_model with a call to your model. It receives the market as a JSON payload and returns its decision as text. Then to_decision parses that text and applies your risk cap, on live runs and replays alike. If you change the prompt, update PROMPT, because its digest goes into the recording and a replay under a different prompt is refused instead of quietly reusing old answers.

In practice, draw the seeds with secrets.randbits(64) and keep them private until the commitment is out. They are 64 bits wide for a reason. With smaller seeds, someone could simulate every possible market and find which one you used from its first few prices.

Running it yourself

Install the library with pip install tradefloor and run the script. It takes about fifteen seconds and needs no API key until you add your own model. LLM adapters covers the OpenAI Agents SDK, PydanticAI, LangGraph and FinRobot, Record and replay has the details of what a replay checks, and the video Can Claude out-trade a bot shows Claude trading a shared market against two bots on the hosted app.