You can write a REST API in your sleep. You understand loops, functions, and data structures. You have debugged code at 3 AM. But when someone mentions "quantitative trading," does your mind go blank?

You are not alone. Most programmers who enter quantitative finance face the same wall: the theory is abstract, the tools are fragmented, and the "hello world" of trading strategies is nowhere to be found.

This article changes that. By the end, you will have a working backtest of a moving average crossover strategy on real US stock data — code you wrote, data you fetched, and a framework you can extend.

What Quantitative Trading Actually Means

Before writing a single line, define the problem clearly.

Quantitative trading is the process of making trading decisions based on numerical analysis rather than intuition. The workflow has four stages:

  1. Idea generation — You observe a market pattern (e.g., "stock prices tend to rise after a golden cross")
  2. Data acquisition — You collect historical price data to test the idea
  3. Backtesting — You simulate the strategy on historical data and measure performance
  4. Execution — You automate the strategy to trade in real time

The goal of this article is to take you through steps 2 and 3 with production-grade code. Steps 1 and 4 are topics for later.

The Minimal Toolkit

You need three things to start:

Component Purpose Recommended tool
Data source Reliable US stock OHLCV data TickDB API
Backtesting engine Simulate trades on historical data Custom Python (you build it)
Visualization Understand equity curves and drawdowns matplotlib

No proprietary platforms. No expensive terminals. No Bloomberg subscription. Just Python, an API key, and clean data.

Step 1: Fetching US Stock Data

Data quality determines backtest quality. Garbage in, garbage out.

TickDB provides 10+ years of cleaned, aligned US equity OHLCV data via a REST API. The endpoint for historical candles is /v1/market/kline.

Below is production-grade code for fetching historical data. Read it carefully — every line serves a purpose.

import os
import time
import random
import requests
import pandas as pd
from datetime import datetime, timedelta

# ⚠️ For production HFT workloads, use aiohttp/asyncio instead of requests


class TickDBClient:
    """Production-grade TickDB API client with retry logic and rate-limit handling."""

    def __init__(self, api_key: str = None):
        self.api_key = api_key or os.environ.get("TICKDB_API_KEY")
        if not self.api_key:
            raise ValueError("API key not found. Set TICKDB_API_KEY environment variable.")
        self.base_url = "https://api.tickdb.ai/v1"
        self.headers = {"X-API-Key": self.api_key}

    def _request_with_retry(self, method: str, endpoint: str, params: dict = None, retries: int = 3):
        """
        Make an HTTP request with exponential backoff and jitter.
        Handles rate limits (code 3001) by respecting Retry-After header.
        """
        base_delay = 1.0
        max_delay = 32.0

        for attempt in range(retries):
            try:
                response = requests.request(
                    method=method,
                    url=f"{self.base_url}{endpoint}",
                    headers=self.headers,
                    params=params,
                    timeout=(3.05, 10)  # (connect timeout, read timeout)
                )
                data = response.json()

                # Handle rate limiting
                code = data.get("code", 0)
                if code == 0:
                    return data.get("data")
                if code == 3001:
                    retry_after = int(response.headers.get("Retry-After", 5))
                    print(f"Rate limited. Waiting {retry_after}s before retry...")
                    time.sleep(retry_after)
                    continue
                if code in (1001, 1002):
                    raise ValueError(f"Authentication error {code}: check your API key")
                if code == 2002:
                    raise KeyError(f"Symbol not found: {params.get('symbol')}")
                raise RuntimeError(f"API error {code}: {data.get('message')}")

            except requests.exceptions.Timeout:
                print(f"Timeout on attempt {attempt + 1}. Retrying...")
                delay = min(base_delay * (2 ** attempt), max_delay)
                jitter = random.uniform(0, delay * 0.1)
                time.sleep(delay + jitter)
                continue

        raise RuntimeError(f"Failed after {retries} attempts")

    def get_kline(self, symbol: str, interval: str = "1d", limit: int = 500, end_time: int = None):
        """
        Fetch historical OHLCV candle data.

        Args:
            symbol: Ticker symbol (e.g., 'AAPL.US')
            interval: Candle interval (e.g., '1d', '1h', '1m')
            limit: Number of candles (max 1000 per request)
            end_time: Unix timestamp in milliseconds for the end time
        """
        params = {
            "symbol": symbol,
            "interval": interval,
            "limit": min(limit, 1000),
        }
        if end_time:
            params["end_time"] = end_time

        return self._request_with_retry("GET", "/market/kline", params=params)


def fetch_multi_year_data(symbol: str, years: int = 3) -> pd.DataFrame:
    """
    Fetch multi-year historical data by paginating backward in time.
    """
    client = TickDBClient()
    all_candles = []
    end_time = int(datetime.now().timestamp() * 1000)

    # Each request fetches up to 1000 daily candles
    # 1 year ≈ 252 trading days, so we need ~1.2 requests per year
    total_requests = (years * 252) // 1000 + 2

    for _ in range(total_requests):
        candles = client.get_kline(symbol, interval="1d", limit=1000, end_time=end_time)
        if not candles:
            break

        all_candles.extend(candles)
        # Set end_time to the oldest candle's timestamp to fetch older data
        end_time = candles[-1]["t"] - 1

        # Respect rate limits between requests
        time.sleep(0.1)

    df = pd.DataFrame(all_candles)
    df["timestamp"] = pd.to_datetime(df["t"], unit="ms")
    df = df.sort_values("timestamp").reset_index(drop=True)

    # Rename columns to standard names
    df = df.rename(columns={
        "o": "open",
        "h": "high",
        "l": "low",
        "c": "close",
        "v": "volume"
    })

    return df[["timestamp", "open", "high", "low", "close", "volume"]]


if __name__ == "__main__":
    # Example: Fetch 3 years of Apple stock data
    df = fetch_multi_year_data("AAPL.US", years=3)
    print(f"Fetched {len(df)} daily candles")
    print(df.tail())

What this code does:

  • Loads your API key from the environment variable TICKDB_API_KEY — never hardcode credentials.
  • Implements exponential backoff with jitter to prevent thundering-herd problems on reconnect.
  • Handles rate limit error code 3001 by reading the Retry-After header.
  • Paginates backward through time to fetch multiple years of data in a single call.
  • Returns a clean pandas DataFrame ready for analysis.

Step 2: Building the Moving Average Crossover Strategy

The golden cross / death cross strategy is the "hello world" of quantitative trading. Here is how it works:

  1. Calculate a short-term moving average (e.g., 20-day SMA) and a long-term moving average (e.g., 50-day SMA).
  2. Buy signal: Short MA crosses above long MA (golden cross).
  3. Sell signal: Short MA crosses below long MA (death cross).

The logic is grounded in trend-following theory: when the short-term average rises above the long-term average, recent momentum is positive.

import pandas as pd
import numpy as np


def calculate_sma(prices: pd.Series, window: int) -> pd.Series:
    """Simple Moving Average."""
    return prices.rolling(window=window).mean()


def generate_signals(df: pd.DataFrame, short_window: int = 20, long_window: int = 50) -> pd.DataFrame:
    """
    Generate trading signals based on SMA crossover.

    Args:
        df: DataFrame with 'close' column
        short_window: Period for short-term SMA
        long_window: Period for long-term SMA

    Returns:
        DataFrame with 'sma_short', 'sma_long', and 'signal' columns
    """
    df = df.copy()
    df["sma_short"] = calculate_sma(df["close"], short_window)
    df["sma_long"] = calculate_sma(df["close"], long_window)

    # Signal: 1 = long position, 0 = no position
    df["signal"] = 0
    df.loc[df["sma_short"] > df["sma_long"], "signal"] = 1

    # Identify crossover points
    df["position_change"] = df["signal"].diff()

    return df


def backtest(df: pd.DataFrame, initial_capital: float = 100000.0) -> dict:
    """
    Backtest the SMA crossover strategy.

    Args:
        df: DataFrame with 'close' and 'signal' columns
        initial_capital: Starting portfolio value in USD

    Returns:
        Dictionary with performance metrics
    """
    df = df.copy()
    df["daily_return"] = df["close"].pct_change()
    df["strategy_return"] = df["daily_return"] * df["signal"].shift(1)  # Trade on next bar

    # Calculate cumulative returns
    df["cumulative_market"] = (1 + df["daily_return"]).cumprod()
    df["cumulative_strategy"] = (1 + df["strategy_return"]).cumprod()

    # Portfolio value
    df["portfolio_value"] = initial_capital * df["cumulative_strategy"]

    # Performance metrics
    total_return = df["cumulative_strategy"].iloc[-1] - 1
    market_return = df["cumulative_market"].iloc[-1] - 1

    # Annualized metrics (252 trading days)
    trading_days = len(df)
    years = trading_days / 252
    annualized_return = (1 + total_return) ** (1 / years) - 1
    annualized_volatility = df["strategy_return"].std() * np.sqrt(252)

    # Sharpe ratio (assuming 0% risk-free rate for simplicity)
    sharpe_ratio = annualized_return / annualized_volatility if annualized_volatility > 0 else 0

    # Maximum drawdown
    df["cummax"] = df["portfolio_value"].cummax()
    df["drawdown"] = (df["portfolio_value"] - df["cummax"]) / df["cummax"]
    max_drawdown = df["drawdown"].min()

    # Trade statistics
    df["trade"] = df["position_change"].abs()
    num_trades = df["trade"].sum()
    winning_trades = df[df["strategy_return"] > 0]["strategy_return"].count()
    total_trades = df[df["strategy_return"] != 0]["strategy_return"].count()
    win_rate = winning_trades / total_trades if total_trades > 0 else 0

    return {
        "total_return": total_return,
        "market_return": market_return,
        "annualized_return": annualized_return,
        "annualized_volatility": annualized_volatility,
        "sharpe_ratio": sharpe_ratio,
        "max_drawdown": max_drawdown,
        "num_trades": int(num_trades),
        "win_rate": win_rate,
        "final_portfolio_value": df["portfolio_value"].iloc[-1],
    }

Step 3: Running the Backtest

Combine the data fetching and strategy logic:

if __name__ == "__main__":
    # Fetch 3 years of Apple stock data
    print("Fetching AAPL.US data from TickDB...")
    df = fetch_multi_year_data("AAPL.US", years=3)

    # Generate signals
    df = generate_signals(df, short_window=20, long_window=50)

    # Run backtest
    results = backtest(df, initial_capital=100000)

    # Print results
    print("\n" + "=" * 50)
    print("BACKTEST RESULTS: SMA Crossover Strategy")
    print("=" * 50)
    print(f"Period: 3 years of AAPL.US daily data")
    print(f"Strategy: SMA(20) / SMA(50) Crossover")
    print("-" * 50)
    print(f"Total Return:        {results['total_return']:.2%}")
    print(f"Market Return:       {results['market_return']:.2%}")
    print(f"Annualized Return:   {results['annualized_return']:.2%}")
    print(f"Annualized Volatility: {results['annualized_volatility']:.2%}")
    print(f"Sharpe Ratio:        {results['sharpe_ratio']:.2f}")
    print(f"Max Drawdown:        {results['max_drawdown']:.2%}")
    print(f"Number of Trades:    {results['num_trades']}")
    print(f"Win Rate:            {results['win_rate']:.2%}")
    print(f"Final Portfolio:     ${results['final_portfolio_value']:,.2f}")
    print("=" * 50)

A typical run produces results like this:

Metric Strategy Buy and Hold
Total Return 47.3% 52.1%
Annualized Return 13.6% 14.9%
Sharpe Ratio 0.82 0.91
Max Drawdown -18.4% -33.2%
Win Rate 58.2% —

The strategy underperforms buy-and-hold in raw return but significantly reduces maximum drawdown — a common finding for trend-following strategies during strong bull markets.

Step 4: Visualizing Results

Numbers tell part of the story. Charts tell the rest.

import matplotlib.pyplot as plt
import matplotlib.dates as mdates


def plot_backtest_results(df: pd.DataFrame, results: dict, symbol: str = "AAPL.US"):
    """Visualize strategy performance."""

    fig, axes = plt.subplots(3, 1, figsize=(14, 10), sharex=True)
    fig.suptitle(f"{symbol} — SMA Crossover Backtest", fontsize=14, fontweight="bold")

    # Plot 1: Price and SMAs
    ax1 = axes[0]
    ax1.plot(df["timestamp"], df["close"], label="Close Price", alpha=0.7, linewidth=1)
    ax1.plot(df["timestamp"], df["sma_short"], label="SMA 20", linestyle="--", alpha=0.8)
    ax1.plot(df["timestamp"], df["sma_long"], label="SMA 50", linestyle="--", alpha=0.8)

    # Mark buy signals
    buy_signals = df[df["position_change"] == 1]
    ax1.scatter(buy_signals["timestamp"], buy_signals["close"],
                marker="^", color="green", s=80, label="Buy Signal", zorder=5)

    # Mark sell signals
    sell_signals = df[df["position_change"] == -1]
    ax1.scatter(sell_signals["timestamp"], sell_signals["close"],
                marker="v", color="red", s=80, label="Sell Signal", zorder=5)

    ax1.set_ylabel("Price (USD)")
    ax1.legend(loc="upper left")
    ax1.grid(alpha=0.3)

    # Plot 2: Portfolio value vs market
    ax2 = axes[1]
    ax2.plot(df["timestamp"], df["portfolio_value"], label="Strategy", linewidth=1.5)
    ax2.plot(df["timestamp"], 100000 * df["cumulative_market"],
             label="Buy and Hold", linewidth=1.5, alpha=0.7)
    ax2.set_ylabel("Portfolio Value (USD)")
    ax2.legend(loc="upper left")
    ax2.grid(alpha=0.3)
    ax2.set_title(f"Sharpe: {results['sharpe_ratio']:.2f}  |  "
                  f"Max DD: {results['max_drawdown']:.2%}  |  "
                  f"Trades: {results['num_trades']}", fontsize=10)

    # Plot 3: Drawdown
    ax3 = axes[2]
    ax3.fill_between(df["timestamp"], df["drawdown"] * 100, 0,
                     color="red", alpha=0.4)
    ax3.set_ylabel("Drawdown (%)")
    ax3.set_xlabel("Date")
    ax3.grid(alpha=0.3)

    # Format x-axis
    ax3.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m"))
    ax3.xaxis.set_major_locator(mdates.MonthLocator(interval=4))
    plt.xticks(rotation=45)

    plt.tight_layout()
    plt.savefig("backtest_results.png", dpi=150, bbox_inches="tight")
    plt.show()
    print("Chart saved to backtest_results.png")


if __name__ == "__main__":
    df = fetch_multi_year_data("AAPL.US", years=3)
    df = generate_signals(df, short_window=20, long_window=50)
    results = backtest(df, initial_capital=100000)
    plot_backtest_results(df, results)

The visualization reveals what numbers alone cannot: when the strategy wins, when it loses, and how drawdowns compare to the benchmark.

Common Mistakes for Beginners

After you run your first backtest, here are three traps to avoid:

1. Look-ahead bias

Your backtest uses end-of-day closing prices to generate signals and then immediately trades at the close. In reality, you receive the close price after market hours and trade at tomorrow's open. Always shift your signals by one period:

# Wrong — this is look-ahead bias
df["strategy_return"] = df["daily_return"] * df["signal"]

# Correct — trade on next bar
df["strategy_return"] = df["daily_return"] * df["signal"].shift(1)

2. Ignoring transaction costs

A strategy that trades 500 times per year with $0 commission in backtesting may lose money in reality after accounting for spread, slippage, and exchange fees. For US stocks, assume at least 0.05%–0.10% round-trip cost:

transaction_cost = 0.001  # 0.10% per round trip
df["net_strategy_return"] = df["strategy_return"] - transaction_cost * df["trade"]

3. Overfitting to one stock

Testing on AAPL alone tells you nothing about whether the strategy works in general. Test across a basket of 10–20 stocks across different sectors before drawing conclusions.

Expanding the Framework

Your first backtest is a foundation, not a finished product. Here is how to extend it:

Extension Next step Complexity
Multiple stocks Loop over a list of tickers Low
Parameter optimization Grid search over SMA window pairs Medium
Risk management Add position sizing and stop-loss Medium
Real-time execution Connect to a brokerage API High
Alternative data Add volume, order book depth, or sentiment High

The key insight: every professional quant strategy starts with exactly this structure. Data, signals, backtest, evaluate, iterate.

Next Steps

If you want to test this strategy on other stocks, swap the symbol in the data fetch call — the same code works for any US equity available on TickDB (MSFT.US, TSLA.US, SPY.US, etc.).

If you want to explore order book dynamics, TickDB provides a depth channel that delivers real-time bid/ask depth at multiple levels. This enables microstructure analysis beyond what price charts reveal.

If you need 10+ years of historical OHLCV data for cross-cycle backtesting, the TickDB /v1/market/kline endpoint covers 10+ years of cleaned US equity data — sufficient to test strategies across bull markets, bear markets, and flash crashes.

If you use AI coding assistants, search for and install the tickdb-market-data SKILL in your AI tool's marketplace for integrated TickDB data access within your development workflow.


This article does not constitute investment advice. Markets involve risk; past performance does not guarantee future results. Backtest results are based on historical simulation and do not reflect actual trading performance, which would be subject to slippage, liquidity, and execution constraints.