DEV Community

Cover image for 🧿 How I Made Look-Ahead Bias Architecturally Impossible in Trading Backtests
Petr Tripolsky
Petr Tripolsky

Posted on

🧿 How I Made Look-Ahead Bias Architecturally Impossible in Trading Backtests

The Problem That Breaks 90% of Trading Bots

You write a trading strategy. Backtest shows amazing returns. You deploy it live. It loses money.

Why? Look-ahead bias. Your backtest accidentally used future data.

I built backtest-kit to make this architecturally impossible using Node.js AsyncLocalStorage.


What is Look-Ahead Bias?

Look-ahead bias happens when your backtest uses information that wouldn't have been available at the time of the trade.

Classic example:

// ❌ WRONG: This peeks into the future!
function shouldBuy(candles, currentIndex) {
  const currentPrice = candles[currentIndex].close;
  const nextPrice = candles[currentIndex + 1].close; // Future data!

  return nextPrice > currentPrice; // "Buy if price will go up"
}
Enter fullscreen mode Exit fullscreen mode

Obvious, right? But most look-ahead bias is subtle:

  • Loading indicators without filtering by timestamp
  • Accidentally including one extra candle in calculations
  • Using data that "leaked" from the future

The Traditional Approach (That Doesn't Work)

Most frameworks tell you: "Just be careful!"

// Traditional: You must remember to filter
getSignal: async (candles, currentTime) => {
  const validCandles = candles.filter(c => c.timestamp <= currentTime);
  const rsi = calculateRSI(validCandles);
  // What if you forget to filter? Backtest won't catch it.
}
Enter fullscreen mode Exit fullscreen mode

The problem: This relies on discipline. One mistake ruins everything.


The Solution: AsyncLocalStorage as Temporal Context

I used Node.js AsyncLocalStorage to create an immutable temporal context that flows through all async operations.

How it works:

import { AsyncLocalStorage } from 'async_hooks';

// Framework creates a temporal context storage
const backtestContext = new AsyncLocalStorage();

// For each backtest tick, we establish the current time
async function processTick(timestamp, symbol) {
  const context = {
    currentTime: timestamp,  // This is "now" for this tick
  };

  // Everything inside run() operates in this temporal context
  await backtestContext.run(context, async () => {
    const signal = await strategy.getSignal(symbol);
    await processSignal(signal);
  });
}
Enter fullscreen mode Exit fullscreen mode

When you request data, it automatically respects the temporal context:

async function getCandles(symbol, interval, limit) {
  const context = backtestContext.getStore();

  // ALWAYS fetches data UP TO context.currentTime
  // Future data is architecturally impossible to access
  return await exchange.getCandles(
    symbol,
    interval,
    context.currentTime,  // From backtest context
    limit
  );
}
Enter fullscreen mode Exit fullscreen mode

Result: Your strategy can't accidentally use future data. The architecture prevents it.


Multi-Timeframe Analysis: Automatic Synchronization

Traditional frameworks make multi-timeframe analysis error-prone:

// ❌ Traditional: Easy to mess up
getSignal: async (currentTime) => {
  const candles1h = await getCandles('1h', currentTime, 100);
  const candles15m = await getCandles('15m', currentTime, 100);
  // Are these synchronized? Did I pass currentTime everywhere?
}
Enter fullscreen mode Exit fullscreen mode

With AsyncLocalStorage, all timeframes are automatically synchronized:

// ✅ backtest-kit: Impossible to mess up
getSignal: async (symbol) => {
  // All these use the SAME timestamp from context
  const candles1h = await getCandles(symbol, '1h', 100);
  const candles15m = await getCandles(symbol, '15m', 100);
  const candles5m = await getCandles(symbol, '5m', 100);

  // Data is perfectly synchronized to current backtest tick
  // No way to accidentally include future data
}
Enter fullscreen mode Exit fullscreen mode

No timestamp parameters. No manual filtering. Just works.


Same Code for Backtest AND Live Trading

Here's the magic: identical strategy code works for both modes.

Backtest Mode (from demo/backtest/src/index.mjs)

import { Backtest, listenSignalBacktest, listenBacktestProgress } from "backtest-kit";

// Run backtest on historical data
Backtest.background("BTCUSDT", {
    strategyName: "test_strategy",
    exchangeName: "test_exchange",
    frameName: "test_frame",
});

// Monitor progress
listenBacktestProgress((event) => {
    console.log(`Progress: ${(event.progress * 100).toFixed(2)}%`);
    console.log(`Processed: ${event.processedFrames} / ${event.totalFrames}`);
});

// Handle signals
listenSignalBacktest((event) => {
    console.log(event);
});
Enter fullscreen mode Exit fullscreen mode

Live Mode (from demo/live/src/index.mjs)

import { Live, listenSignalLive } from "backtest-kit";

// Same strategy, now running live!
Live.background("BTCUSDT", {
    strategyName: "test_strategy",
    exchangeName: "test_exchange",
    frameName: "test_frame",
});

// Handle live signals
listenSignalLive(async (event) => {
    if (event.action === "opened") {
        console.log("Open position");
    }
    if (event.action === "closed") {
        console.log("Close position");
        await Live.dump(event.symbol, event.strategyName);
    }
});
Enter fullscreen mode Exit fullscreen mode

The only difference?

  • Backtest: context.currentTime comes from historical data
  • Live: context.currentTime = Date.now()

Your strategy doesn't change at all.


Real Production Setup (Actual Demo Code)

Here's the complete setup from my demo project:

import ccxt from "ccxt";
import { addExchange, addStrategy, addFrame, addRisk } from "backtest-kit";
import { v4 as uuid } from "uuid";
import { json } from "./utils/json.mjs";
import { getMessages } from "./utils/messages.mjs";

// 1. Configure exchange (CCXT integration)
addExchange({
    exchangeName: "test_exchange",
    getCandles: async (symbol, interval, since, limit) => {
        const exchange = new ccxt.binance();
        const ohlcv = await exchange.fetchOHLCV(
            symbol, 
            interval, 
            since.getTime(), 
            limit
        );
        return ohlcv.map(([timestamp, open, high, low, close, volume]) => ({
            timestamp, open, high, low, close, volume
        }));
    },
    formatPrice: async (symbol, price) => price.toFixed(2),
    formatQuantity: async (symbol, quantity) => quantity.toFixed(8),
});

// 2. Risk management rules
addRisk({
    riskName: "demo_risk",
    validations: [
        {
            validate: ({ pendingSignal, currentPrice }) => {
                const { priceOpen = currentPrice, priceTakeProfit, position } = pendingSignal;
                if (!priceOpen) return;

                // Calculate TP distance percentage
                const tpDistance = position === "long"
                    ? ((priceTakeProfit - priceOpen) / priceOpen) * 100
                    : ((priceOpen - priceTakeProfit) / priceOpen) * 100;
                if (tpDistance < 1) {
                    throw new Error(`TP distance ${tpDistance.toFixed(2)}% < 1%`);
                }
            },
            note: "TP distance must be at least 1%",
        },
        {
            validate: ({ pendingSignal, currentPrice }) => {
                const { priceOpen = currentPrice, priceTakeProfit, priceStopLoss, position } = pendingSignal;
                if (!priceOpen) return;

                // Calculate reward (TP distance)
                const reward = position === "long"
                    ? priceTakeProfit - priceOpen
                    : priceOpen - priceTakeProfit;

                // Calculate risk (SL distance)
                const risk = position === "long"
                    ? priceOpen - priceStopLoss
                    : priceStopLoss - priceOpen;

                if (risk <= 0) {
                    throw new Error("Invalid SL: risk must be positive");
                }

                const rrRatio = reward / risk;
                if (rrRatio < 2) {
                    throw new Error(`RR ratio ${rrRatio.toFixed(2)} < 2:1`);
                }
            },
            note: "Risk-Reward ratio must be at least 1:2",
        },
    ],
});

// 3. Define timeframe
addFrame({
    frameName: "test_frame",
    interval: "1m",
    startDate: new Date("2025-12-01T00:00:00.000Z"),
    endDate: new Date("2025-12-01T23:59:59.000Z"),
});

// 4. Strategy logic
addStrategy({
    strategyName: "test_strategy",
    interval: "5m",
    riskName: "demo_risk",
    getSignal: async (symbol) => {
        // getMessages internally calls getCandles
        // which automatically respects temporal context
        const messages = await getMessages(symbol);

        const resultId = uuid();
        // Creates a trading signal using Ollama
        const result = await json(messages);
        await dumpSignal(resultId, messages, result);

        result.id = resultId;
        return result;
    },
});
Enter fullscreen mode Exit fullscreen mode

Notice how getSignal has no timestamp parameters. The temporal context flows automatically through getMessagesgetCandles.


Production Features from Demo

1. Progress Monitoring

import { listenBacktestProgress, listenDoneBacktest } from "backtest-kit";

listenBacktestProgress((event) => {
    console.log(`Progress: ${(event.progress * 100).toFixed(2)}%`);
    console.log(`Processed: ${event.processedFrames} / ${event.totalFrames}`);
});

listenDoneBacktest(async (event) => {
    console.log("Backtest completed:", event.symbol);
    await Backtest.dump(event.symbol, event.strategyName);
});
Enter fullscreen mode Exit fullscreen mode

2. Partial Profit/Loss Management

import { listenPartialProfit, listenPartialLoss, Constant } from "backtest-kit";

listenPartialProfit(({ symbol, price, level }) => {
  console.log(`${symbol} reached ${level}% profit at ${price}`);

  if (level === Constant.TP_LEVEL3) {
    console.log("Close 33% at 90% profit");
  }
  if (level === Constant.TP_LEVEL2) {
    console.log("Close 33% at 60% profit");
  }
  if (level === Constant.TP_LEVEL1) {
    console.log("Close 34% at 30% profit");
  }
});

listenPartialLoss(({ symbol, price, level }) => {
  console.log(`${symbol} reached -${level}% loss at ${price}`);

  if (level === Constant.SL_LEVEL2) {
    console.log("Close 50% at -80% loss");
  }
  if (level === Constant.SL_LEVEL1) {
    console.log("Close 50% at -40% loss");
  }
});
Enter fullscreen mode Exit fullscreen mode

3. Risk Validation Events

import { listenRisk, listenError } from "backtest-kit";

listenRisk(async (event) => {
    // Risk validation failed
    await Risk.dump(event.symbol, event.strategyName);
});

listenError((error) => {
    console.error("Error occurred:", error);
});
Enter fullscreen mode Exit fullscreen mode

4. Live Trading State Persistence

import { listenSignalLive } from "backtest-kit";

listenSignalLive(async (event) => {
    if (event.action === "opened") {
        console.log("Open position");
    }

    if (event.action === "closed") {
        console.log("Close position");
        // Atomic dump to disk for crash recovery
        await Live.dump(event.symbol, event.strategyName);
        await Partial.dump(event.symbol, event.strategyName);
    }

    if (event.action === "scheduled") {
        // Limit order scheduled
        await Schedule.dump(event.symbol, event.strategyName);
    }

    if (event.action === "cancelled") {
        // Scheduled order cancelled
        await Schedule.dump(event.symbol, event.strategyName);
    }
});
Enter fullscreen mode Exit fullscreen mode

Why This Matters

Look-ahead bias is silent. Your backtest won't warn you. It just shows inflated returns.

Then you deploy live and lose money.

Traditional solutions rely on discipline. One mistake, one forgotten filter, and your months of work are worthless.

backtest-kit makes it architectural. You literally cannot access future data, even if you try.


Why AsyncLocalStorage is Perfect for This

Traditional approach: Pass context explicitly

// Verbose and error-prone
async function getSignal(symbol, context) {
  const candles = await getCandles(symbol, context.currentTime);
  const rsi = await calculateRSI(candles, context.currentTime);
  // What if you forget to pass context somewhere?
}
Enter fullscreen mode Exit fullscreen mode

AsyncLocalStorage approach: Context flows automatically

// Clean and foolproof
async function getSignal(symbol) {
  const candles = await getCandles(symbol);
  const rsi = await calculateRSI(candles);
  // Context is always available, always correct
}
Enter fullscreen mode Exit fullscreen mode

Key benefits:

  1. Zero boilerplate — No context parameters to pass around
  2. Type-safe — TypeScript knows what's available
  3. Impossible to forget — Context is always there
  4. Works across async boundaries — Even through Promise.all()

How AsyncLocalStorage Handles Edge Cases

Works Through Promise.all

getSignal: async (symbol) => {
  // Context preserved across concurrent operations
  const [c1h, c15m, c5m] = await Promise.all([
    getCandles(symbol, '1h', 100),
    getCandles(symbol, '15m', 100),
    getCandles(symbol, '5m', 100)
  ]);

  // All synchronized to the same timestamp
}
Enter fullscreen mode Exit fullscreen mode

Error Handling

listenError((error) => {
    console.error("Error occurred:", error);
});

listenRisk(async (event) => {
    // Validation failed - log for analysis
    await Risk.dump(event.symbol, event.strategyName);
});
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Architecture > Discipline — Don't rely on being careful. Make mistakes impossible.
  2. Same Code, Same Results — If backtest and live use different code paths, you're doing it wrong.
  3. AsyncLocalStorage is Underrated — Perfect for this pattern, criminally underused.
  4. Temporal Context Flows — No timestamp parameters needed. Context propagates automatically.
  5. Production-Ready Matters — Crash recovery, atomic persistence, and validation aren't optional.

Conclusion

Look-ahead bias breaks most trading bots. Traditional solutions rely on developers being careful.

backtest-kit makes carelessness impossible by using AsyncLocalStorage to create an immutable temporal context.

The result? Strategies that work the same in backtest and live trading.


Try it yourself:

npm install backtest-kit ccxt
Enter fullscreen mode Exit fullscreen mode

Resources:


Top comments (0)