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:
- Idea generation — You observe a market pattern (e.g., "stock prices tend to rise after a golden cross")
- Data acquisition — You collect historical price data to test the idea
- Backtesting — You simulate the strategy on historical data and measure performance
- 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
3001by reading theRetry-Afterheader. - 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:
- Calculate a short-term moving average (e.g., 20-day SMA) and a long-term moving average (e.g., 50-day SMA).
- Buy signal: Short MA crosses above long MA (golden cross).
- 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.