Tutorial 11 min read

How to Backtest a Trading Strategy with Historical API Data

Learn how to backtest your trading strategy using historical OHLC and indicator data from the TickAtlas API. Includes a complete Python backtesting framework.

9Sections
5Code samples
11Min read
TutorialCategory

Why Backtesting Matters

A strategy that sounds good on paper can lose money in practice. Backtesting runs your strategy against historical data to measure how it would have performed. It is the difference between guessing and knowing.

The TickAtlas API provides historical OHLC data and pre-calculated indicators, making backtesting straightforward — no need to compute indicators yourself.

Step 1: Fetch Historical Data

Pull OHLC candles and indicator values for your target symbol and timeframe. The API supports up to 1000 bars per request.

Python Step 1: Fetch Historical Data
import requests
import pandas as pd

API_KEY = "your_api_key_here"
BASE_URL = "https://tickatlas.com/v1"
HEADERS = {"X-API-Key": API_KEY}

def fetch_historical_data(symbol: str, timeframe: str,
                          bars: int = 500) -> pd.DataFrame:
    """Fetch historical OHLC data from the API."""
    resp = requests.get(f"{BASE_URL}/ohlc", params={
        "symbol": symbol,
        "timeframe": timeframe,
        "limit": bars
    }, headers=HEADERS)
    resp.raise_for_status()
    data = resp.json()["data"]["candles"]
    df = pd.DataFrame(data)
    df["time"] = df["time"].map(pd.Timestamp)
    df.index = df["time"]
    return df

# Fetch 500 H1 candles for EURUSD
ohlc = fetch_historical_data("EURUSD", "H1", 500)
print(f"Fetched {len(ohlc)} candles from {ohlc.index[0]} to {ohlc.index[-1]}")

API Response Format

JSON API Response Format
// GET /v1/ohlc?symbol=EURUSD&timeframe=H1&limit=3
{
  "success": true,
  "data": {
    "symbol": "EURUSD",
    "timeframe": "H1",
    "candles": [
      {
        "time": "2026-03-28T10:00:00Z",
        "open": 1.0845,
        "high": 1.0862,
        "low": 1.0838,
        "close": 1.0855,
        "volume": 12450
      },
      {
        "time": "2026-03-28T11:00:00Z",
        "open": 1.0855,
        "high": 1.0871,
        "low": 1.0849,
        "close": 1.0867,
        "volume": 9820
      }
    ]
  }
}

Step 2: Build the Backtester

The backtester iterates through each candle, evaluates your strategy, and tracks hypothetical trades with proper accounting for entry, exit, stop loss, and take profit.

Python Step 2: Build the Backtester
class Backtester:
    def __init__(self, initial_balance: float = 10000,
                 risk_per_trade: float = 1.0):
        self.balance = initial_balance
        self.initial_balance = initial_balance
        self.risk_pct = risk_per_trade
        self.trades = []
        self.open_trade = None

    def run(self, df: pd.DataFrame, strategy) -> dict:
        """Run backtest over historical data."""
        for i in range(1, len(df)):
            candle = df.iloc[i]
            prev_candle = df.iloc[i - 1]

            # Check if open trade hits SL or TP
            if self.open_trade:
                self._check_exit(candle)

            # Generate signal if no open trade
            if not self.open_trade:
                signal = strategy.evaluate(candle, prev_candle)
                if signal in ("BUY", "SELL"):
                    self._open_trade(signal, candle, strategy)

        # Close any remaining open trade at last price
        if self.open_trade:
            self._close_trade(df.iloc[-1]["close"], "END_OF_DATA")

        return self._calculate_stats()

    def _open_trade(self, direction, candle, strategy):
        entry = candle["close"]
        atr = candle.get("atr", 0.001)
        sl = entry - (2 * atr) if direction == "BUY" else entry + (2 * atr)
        tp = entry + (3 * atr) if direction == "BUY" else entry - (3 * atr)

        self.open_trade = {
            "direction": direction,
            "entry": entry,
            "sl": sl,
            "tp": tp,
            "time": candle.name
        }

    def _check_exit(self, candle):
        trade = self.open_trade
        if trade["direction"] == "BUY":
            if candle["low"] <= trade["sl"]:
                self._close_trade(trade["sl"], "STOP_LOSS")
            elif candle["high"] >= trade["tp"]:
                self._close_trade(trade["tp"], "TAKE_PROFIT")
        else:
            if candle["high"] >= trade["sl"]:
                self._close_trade(trade["sl"], "STOP_LOSS")
            elif candle["low"] <= trade["tp"]:
                self._close_trade(trade["tp"], "TAKE_PROFIT")

    def _close_trade(self, exit_price, reason):
        trade = self.open_trade
        if trade["direction"] == "BUY":
            pnl = exit_price - trade["entry"]
        else:
            pnl = trade["entry"] - exit_price

        risk_amount = self.balance * (self.risk_pct / 100)
        dollar_pnl = (pnl / abs(trade["entry"] - trade["sl"])) * risk_amount
        self.balance += dollar_pnl

        self.trades.append({
            "direction": trade["direction"],
            "entry": trade["entry"],
            "exit": exit_price,
            "pnl": dollar_pnl,
            "reason": reason
        })
        self.open_trade = None

    def _calculate_stats(self) -> dict:
        if not self.trades:
            return {"total_trades": 0}

        wins = [t for t in self.trades if t["pnl"] > 0]
        losses = [t for t in self.trades if t["pnl"] <= 0]

        return {
            "total_trades": len(self.trades),
            "wins": len(wins),
            "losses": len(losses),
            "win_rate": len(wins) / len(self.trades) * 100,
            "total_pnl": sum(t["pnl"] for t in self.trades),
            "final_balance": self.balance,
            "return_pct": (self.balance - self.initial_balance)
                          / self.initial_balance * 100,
            "max_drawdown": self._max_drawdown()
        }

    def _max_drawdown(self) -> float:
        peak = self.initial_balance
        max_dd = 0
        balance = self.initial_balance
        for trade in self.trades:
            balance += trade["pnl"]
            peak = max(peak, balance)
            dd = (peak - balance) / peak * 100
            max_dd = max(max_dd, dd)
        return round(max_dd, 2)

Step 3: Define a Strategy

Python Step 3: Define a Strategy
class RSIMACDStrategy:
    """Buy when RSI exits oversold and MACD histogram turns positive."""
    def evaluate(self, candle, prev_candle) -> str:
        rsi = candle.get("rsi", 50)
        macd_hist = candle.get("macd_histogram", 0)
        prev_macd_hist = prev_candle.get("macd_histogram", 0)

        # Buy: RSI recovering from oversold + MACD crossing zero
        if rsi < 40 and rsi > 30 and macd_hist > 0 and prev_macd_hist <= 0:
            return "BUY"

        # Sell: RSI falling from overbought + MACD crossing zero
        if rsi > 60 and rsi < 70 and macd_hist < 0 and prev_macd_hist >= 0:
            return "SELL"

        return "HOLD"

Step 4: Run and Analyze

Python Step 4: Run and Analyze
# Run the backtest
bt = Backtester(initial_balance=10000, risk_per_trade=1.0)
stats = bt.run(ohlc, RSIMACDStrategy())

print(f"Total trades:   {stats['total_trades']}")
print(f"Win rate:       {stats['win_rate']:.1f}%")
print(f"Total P&L:      \${stats['total_pnl']:.2f}")
print(f"Final balance:  \${stats['final_balance']:.2f}")
print(f"Return:         {stats['return_pct']:.1f}%")
print(f"Max drawdown:   {stats['max_drawdown']:.1f}%")

Key Metrics to Watch

Win Rate

Percentage of trades that are profitable. Above 50% is acceptable if your reward-to-risk ratio is good.

Max Drawdown

The worst peak-to-trough decline. Keep this under 20%. Higher drawdowns are psychologically devastating in live trading.

Profit Factor

Total gross profit divided by total gross loss. Above 1.5 is good. Below 1.0 means the strategy loses money.

Sharpe Ratio

Risk-adjusted return. Above 1.0 is acceptable, above 2.0 is excellent. Below 0.5 means you are not being compensated for the risk.

Common Backtesting Mistakes

Look-Ahead Bias

Using future data in your strategy logic. Always use only the current and past candles when evaluating signals.

Ignoring Spread and Slippage

Add realistic spread costs. Use the /v1/spread endpoint for real spread data on each pair.

Overfitting

If your strategy only works on one specific date range, it is curve-fitted. Test on multiple time periods and instruments.

Survivorship Bias

Only testing on pairs that exist today. Some instruments get delisted. This is less of an issue with major forex pairs.

Next Steps

Run this against live data.

Every account starts pay-as-you-go with $2.50 of credit and no card. Paste the key into the samples above and the requests work unchanged.

Prefer the API to the article

Build it instead of reading about it.

Create a key, run the first request, then extend one layer at a time. Every account starts pay-as-you-go with $2.50 of credit — no card required.