Skip to content

How to Avoid Backtesting Bias When Using LLMs for Trading Decisions

I ran a backtest on my LLM-powered trading strategy. The results were incredible: 25% returns over six months with a Sharpe ratio of 1.8. I was ready to deploy real capital.

Then I checked the model’s knowledge cutoff date.

My backtest period: January 2023 - June 2023 Model knowledge cutoff: October 2023

The model had already “seen” every single day of my backtest during training. My amazing returns weren’t prediction—they were memorization.

The Problem: Your LLM Already Knows the Future

When you backtest an LLM-based trading strategy, you might not be testing genuine prediction. You’re often testing recall.

Large language models are trained on vast corpora that include:

  • Historical news articles with market outcomes
  • Earnings reports and their subsequent price impacts
  • Market analysis and commentary from successful periods
  • Stock price discussions on forums and social media
  • Financial news headlines with their market reactions

This is data leakage at the model level, and it’s nearly impossible to detect without knowing the exact training data composition.

A comment from a Reddit discussion I found captures this perfectly:

“Keep in mind that the models were probably trained on data from that period. With full knowledge of what happened, that’s a terrible return.”

The numbers look great, but they’re meaningless.

Why Traditional Backtesting Assumptions Fail

Traditional backtesting relies on three core assumptions:

  1. The strategy has no future information
  2. Performance on past data predicts future performance
  3. Overfitting can be detected through cross-validation

LLMs break all three:

AssumptionWhy LLMs Break It
No future informationTraining data may contain all historical market outcomes
Past predicts futurePast “performance” reflects memorization, not prediction
Cross-validation worksThe model has seen all time periods during training

One commenter asked the critical question:

“The LLMs were trained on data for the backtest period or this was handled via some cutoff knowledge date?”

If you can’t answer this definitively, your backtest is contaminated.

Strategy 1: Enforce Knowledge Cutoffs

The first line of defense is knowing and enforcing your model’s knowledge cutoff date.

model_knowledge_cutoff.py
from datetime import datetime, timedelta
from dataclasses import dataclass
@dataclass
class LLMModel:
name: str
knowledge_cutoff: datetime
def get_safe_backtest_start(self, buffer_days: int = 30) -> datetime:
"""Returns the safe date to start backtesting."""
return self.knowledge_cutoff + timedelta(days=buffer_days)
def is_date_safe(self, test_date: datetime) -> bool:
"""Check if a date is safe for backtesting."""
return test_date > self.knowledge_cutoff
# Define your models with documented cutoffs
MODELS = {
"gpt-4o": LLMModel("gpt-4o", datetime(2023, 10, 1)),
"claude-3.5-sonnet": LLMModel("claude-3.5-sonnet", datetime(2024, 4, 1)),
"gpt-4-turbo": LLMModel("gpt-4-turbo", datetime(2023, 12, 1)),
}
def validate_backtest_period(model_name: str, start_date: datetime, end_date: datetime):
"""Validate that backtest period is safe for the given model."""
if model_name not in MODELS:
raise ValueError(f"Unknown model: {model_name}")
model = MODELS[model_name]
safe_start = model.get_safe_backtest_start()
if start_date < safe_start:
raise ValueError(
f"BACKTESTING BIAS DETECTED!\n"
f"Model {model_name} has knowledge cutoff: {model.knowledge_cutoff.date()}\n"
f"Your backtest starts: {start_date.date()}\n"
f"Safe backtest start: {safe_start.date()}\n"
f"The model may have seen this data during training!"
)
print(f"Backtest period validated for {model_name}")
print(f"Testing period: {start_date.date()} to {end_date.date()}")
return True
# Example usage
try:
validate_backtest_period(
model_name="gpt-4o",
start_date=datetime(2024, 1, 1), # After October 2023 cutoff
end_date=datetime(2024, 6, 1)
)
except ValueError as e:
print(e)

I added a 30-day buffer after the cutoff because model providers sometimes update their cutoffs without announcement.

Strategy 2: Classify and Separate Contaminated Data

If you have historical data spanning both contaminated and clean periods, you need to split them explicitly.

contamination_aware_backtest.py
import pandas as pd
from datetime import datetime
from typing import Optional
class ContaminationAwareBacktest:
"""
A backtest framework that accounts for LLM training data contamination.
"""
def __init__(self, model_knowledge_cutoff: datetime):
self.knowledge_cutoff = model_knowledge_cutoff
self.contaminated_periods = []
self.clean_periods = []
def classify_period(self, start: datetime, end: datetime) -> str:
"""Classify a time period as contaminated or clean."""
if end <= self.knowledge_cutoff:
return "contaminated"
elif start > self.knowledge_cutoff:
return "clean"
else:
return "partially_contaminated"
def split_backtest_data(self, data: pd.DataFrame, date_column: str = "date"):
"""
Split historical data into contaminated and clean periods.
Returns two DataFrames:
- contaminated: Data the LLM likely saw during training
- clean: Data after knowledge cutoff (safe for testing)
"""
data[date_column] = pd.to_datetime(data[date_column])
contaminated = data[data[date_column] <= self.knowledge_cutoff]
clean = data[data[date_column] > self.knowledge_cutoff]
print(f"Contaminated period: {len(contaminated)} samples")
print(f"Clean period: {len(clean)} samples")
print(f"Clean period range: {clean[date_column].min()} to {clean[date_column].max()}")
return contaminated, clean
def evaluate_with_warning(self, results: dict, test_period: str) -> dict:
"""Evaluate results with appropriate warnings about contamination."""
classification = self.classify_period(
results["start_date"],
results["end_date"]
)
if classification == "contaminated":
results["warning"] = "CRITICAL: Results may be due to memorization, not prediction"
results["reliable"] = False
elif classification == "partially_contaminated":
results["warning"] = "CAUTION: Some test data may be contaminated"
results["reliable"] = "partial"
else:
results["warning"] = None
results["reliable"] = True
return results
# Example usage
backtest = ContaminationAwareBacktest(
model_knowledge_cutoff=datetime(2023, 10, 1)
)
# Evaluate a strategy result
result = backtest.evaluate_with_warning(
results={
"start_date": datetime(2023, 6, 1),
"end_date": datetime(2023, 12, 1),
"returns": 0.25,
"sharpe": 1.8
},
test_period="Q2-Q4 2023"
)
print(f"Returns: {result['returns']:.2%}")
print(f"Reliable: {result['reliable']}")
if result['warning']:
print(f"WARNING: {result['warning']}")

This forces you to acknowledge contamination explicitly rather than ignoring it.

Strategy 3: Forward-Testing for Genuine Validation

The only truly reliable validation is real-time testing. I built a tracker to log recommendations and measure actual outcomes.

forward_testing_tracker.py
from datetime import datetime
from typing import List, Dict
import json
class ForwardTestingTracker:
"""
Track LLM recommendations in real-time for genuine out-of-sample validation.
"""
def __init__(self, model_name: str, knowledge_cutoff: datetime):
self.model_name = model_name
self.knowledge_cutoff = knowledge_cutoff
self.recommendations: List[Dict] = []
def log_recommendation(
self,
ticker: str,
action: str, # "buy", "sell", "hold"
reasoning: str,
confidence: float,
current_price: float
):
"""Log a new recommendation for tracking."""
now = datetime.now()
if now <= self.knowledge_cutoff:
raise ValueError("Cannot make recommendations before knowledge cutoff!")
rec = {
"timestamp": now.isoformat(),
"ticker": ticker,
"action": action,
"reasoning": reasoning,
"confidence": confidence,
"current_price": current_price,
"status": "pending",
"outcome_price": None,
"outcome_date": None
}
self.recommendations.append(rec)
print(f"Logged {action} recommendation for {ticker} at ${current_price:.2f}")
return len(self.recommendations) - 1 # Return index
def update_outcome(self, rec_index: int, final_price: float):
"""Update the outcome of a past recommendation."""
rec = self.recommendations[rec_index]
rec["outcome_price"] = final_price
rec["outcome_date"] = datetime.now().isoformat()
rec["status"] = "completed"
if rec["action"] == "buy":
rec["return"] = (final_price - rec["current_price"]) / rec["current_price"]
elif rec["action"] == "sell":
rec["return"] = (rec["current_price"] - final_price) / rec["current_price"]
else:
rec["return"] = 0
return rec
def get_performance_summary(self) -> dict:
"""Calculate actual performance of all completed recommendations."""
completed = [r for r in self.recommendations if r["status"] == "completed"]
if not completed:
return {"message": "No completed recommendations yet"}
returns = [r["return"] for r in completed if r["return"] is not None]
return {
"total_recommendations": len(self.recommendations),
"completed": len(completed),
"pending": len(self.recommendations) - len(completed),
"avg_return": sum(returns) / len(returns) if returns else 0,
"win_rate": sum(1 for r in returns if r > 0) / len(returns) if returns else 0,
"best_return": max(returns) if returns else 0,
"worst_return": min(returns) if returns else 0,
}
def export_for_analysis(self, filepath: str):
"""Export recommendations for external analysis."""
with open(filepath, 'w') as f:
json.dump({
"model": self.model_name,
"knowledge_cutoff": self.knowledge_cutoff.isoformat(),
"recommendations": self.recommendations
}, f, indent=2)
print(f"Exported {len(self.recommendations)} recommendations to {filepath}")
# Example usage
tracker = ForwardTestingTracker(
model_name="gpt-4o",
knowledge_cutoff=datetime(2023, 10, 1)
)
# Log a recommendation
idx = tracker.log_recommendation(
ticker="AAPL",
action="buy",
reasoning="Strong earnings, expanding margins",
confidence=0.75,
current_price=175.50
)
# Later, update with outcome (example)
# tracker.update_outcome(idx, final_price=182.30)

This is slower—real-time validation takes months—but it’s the only method that produces trustworthy results.

A Better Use Case: LLM as Portfolio Advisor

One insight from the Reddit discussion changed my approach entirely:

“this makes more sense than trying to pick winners from the whole market where the model is biased by its training set making back testing problematic”

And the recommended approach:

“Use it as a trusted advisor to make recommendations on my existing portfolio / watch lists”

Instead of asking an LLM to pick stocks from the entire market—where training bias is most problematic—I now use it to analyze my existing watchlist. The model provides reasoning and risk assessment for positions I’m already tracking, rather than attempting market-wide discovery.

This limits the contamination problem because the model isn’t “discovering” stocks it memorized—it’s analyzing specific tickers I provide.

Key Takeaways

After adjusting my approach, here’s what I’ve learned:

  1. Document knowledge cutoffs: Create a table of your models and their cutoffs. Never test before them.

  2. Accept shorter test periods: A 6-month clean backtest is worth infinitely more than a 5-year contaminated one.

  3. Forward-test for real validation: Paper trade with live data. Build a track record in real-time.

  4. Use LLMs as advisors, not oracles: Apply them to analyze your watchlist rather than picking stocks from the entire market.

  5. Disclose cutoffs when sharing results: Always mention the knowledge cutoff when presenting LLM trading results.

The honest truth is that LLM trading strategies cannot be reliably backtested using historical data alone. Forward-testing and out-of-sample validation are not optional extras—they’re essential for any credible claim of trading performance.

My original 25% return backtest? Worthless. My forward-testing tracker after three months? Modest returns, but they’re real.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments