Price is the effect. The order book is the cause.

That phrase hung on my monitor for three weeks when I started building my first quant strategy. I had spent eight years as a software engineer, writing clean APIs, shipping features on time, and treating bugs as personal failures. Then I tried to build a trading strategy and discovered that everything I knew about engineering — rigorous testing, reproducible builds, clean data pipelines — mattered more than I had anticipated. And everything I assumed I knew about markets was a liability.

If you are a programmer who has never touched quantitative finance, this article is for you. We will build a complete, runnable moving average crossover strategy on US equities from scratch. You will leave with a working backtesting framework, a real data connection via TickDB, and a mental model for how quantitative strategy development actually works.

No hand-waving. No vague "just use Python." Production-grade code throughout.


1. What Quantitative Trading Actually Is (And What It Is Not)

Before writing a single line of strategy code, it pays to be clear about what you are building.

Quantitative trading is the practice of making trading decisions based on systematic rules, expressed in code, operating on numerical market data. It is not algorithmic trading in the high-frequency-trading sense — most retail quant strategies operate on time frames from minutes to days, not microseconds. It is also not investment advice. A quant strategy is a set of rules that historically produced certain results under certain conditions.

The mental model that helped me most as a programmer: a quant strategy is a data pipeline that transforms price series into position signals, which trigger orders.

That pipeline has four stages:

  1. Ingestion: Acquire clean, timestamped market data (OHLCV candles, order book snapshots, trade ticks).
  2. Signal generation: Apply mathematical rules to the data to produce a scalar signal (e.g., "the 10-period MA just crossed above the 50-period MA").
  3. Position sizing: Decide how much capital to allocate when a signal fires, accounting for risk parameters.
  4. Execution: Send orders to a brokerage (we will not cover brokerage integration in this article, but the code structure makes it clear where it plugs in).

Each stage has distinct engineering concerns. Most beginners underestimate the data stage — not because the math is hard, but because bad data pipelines produce silent failures that corrupt backtests and destroy live performance. We will spend a disproportionate amount of time on the ingestion stage for exactly this reason.


2. The Data Problem: Why Your First Impulse Will Probably Fail

Here is the trap most programmers fall into: they Google "free stock API," find a convenient REST endpoint, write a loop to fetch daily closes, and start backtesting. Three weeks later they discover that their results are useless because:

  • Survivorship bias: Their historical dataset only includes companies that still exist today. Dead companies — the ones that went bankrupt or got acquired — are absent from the data. This systematically overestimates strategy performance by 20–40% in most equity strategies.
  • Split and dividend adjustments: Most raw price data does not account for stock splits and dividend adjustments. A stock that split 4-for-1 will show a $400 closing price in 2005 and a $100 closing price in 2025 — not because it lost 75% of its value, but because the data is unadjusted. Your strategy will see phantom drawdowns.
  • Timestamp alignment: Market data arrives from multiple exchanges with millisecond-level clock differences. If you mix venues without alignment, you will generate phantom arbitrage signals.
  • Look-ahead bias: Using future data in a backtest — even accidentally, via a poorly written rolling window — produces strategies that look extraordinary on paper and fail in live trading.

TickDB addresses the first three problems directly: its US equity OHLCV data is cleaned, split-adjusted, and aligned across venues. For this article, we will use TickDB's /v1/market/kline endpoint to fetch 10+ years of daily OHLCV data for backtesting. This gives us a dataset large enough to span at least one full market cycle.


3. Setting Up the Data Pipeline with TickDB

3.1 Prerequisites

You need:

  • Python 3.9+
  • A TickDB API key (sign up at tickdb.ai — the free tier is sufficient for the examples in this article)
  • requests library (pip install requests)

Set your API key as an environment variable. Never hardcode API keys in source files.

export TICKDB_API_KEY="your_api_key_here"

3.2 The Data Acquisition Module

Below is a production-grade data fetcher. It includes the four non-negotiable engineering patterns: environment-variable auth, timeout on every HTTP request, rate-limit handling, and exponential backoff with jitter on reconnect.

import os
import time
import requests
from datetime import datetime, timedelta

# ⚠️ Load API key from environment — never hardcode
TICKDB_API_KEY = os.environ.get("TICKDB_API_KEY")
if not TICKDB_API_KEY:
    raise ValueError("Set TICKDB_API_KEY environment variable before running.")

BASE_URL = "https://api.tickdb.ai/v1"

def handle_api_error(response: requests.Response, context: str = ""):
    """
    Standard TickDB error handler.
    Handles known error codes per TickDB Core Knowledge Base (v2.0).
    """
    try:
        body = response.json()
    except ValueError:
        raise RuntimeError(f"Non-JSON response from TickDB ({response.status_code}): {response.text}")

    code = body.get("code", 0)
    if code == 0:
        return body.get("data", [])

    error_messages = {
        1001: "Invalid API key — check your TICKDB_API_KEY env var",
        1002: "Missing API key — set the X-API-Key header",
        2002: f"Symbol not found ({context}) — verify via /v1/symbols/available",
        3001: "Rate limit exceeded",
    }

    if code == 3001:
        retry_after = int(response.headers.get("Retry-After", 5))
        print(f"[RateLimit] Waiting {retry_after}s before retrying ({context})")
        time.sleep(retry_after)
        return None

    msg = error_messages.get(code, f"Unknown error code {code}: {body.get('message')}")
    raise RuntimeError(msg)


def fetch_kline(symbol: str, interval: str = "1d", limit: int = 500,
                start_time: datetime = None, end_time: datetime = None):
    """
    Fetch OHLCV kline data from TickDB.

    Args:
        symbol: Exchange-qualified ticker (e.g., "AAPL.US")
        interval: Candle interval ("1m", "5m", "1h", "1d", "1w")
        limit: Maximum candles per request (max 2000 for daily)
        start_time: Inclusive start timestamp (UTC)
        end_time: Exclusive end timestamp (UTC)

    Returns:
        List of OHLCV candles, each as a dict with keys:
        open_time, open, high, low, close, volume
    """
    endpoint = f"{BASE_URL}/market/kline"
    params = {
        "symbol": symbol,
        "interval": interval,
        "limit": limit,
    }
    if start_time:
        params["start_time"] = int(start_time.timestamp() * 1000)
    if end_time:
        params["end_time"] = int(end_time.timestamp() * 1000)

    headers = {"X-API-Key": TICKDB_API_KEY}

    # ⚠️ Every HTTP request MUST have a timeout to prevent hanging in production
    response = requests.get(endpoint, headers=headers, params=params, timeout=(3.05, 10))
    if not response.ok:
        handle_api_error(response, context=symbol)

    data = handle_api_error(response, context=symbol)
    return data if data else []


def fetch_years(symbol: str, years: int = 5, interval: str = "1d"):
    """
    Convenience wrapper: fetch the last N years of daily kline data.
    Handles pagination automatically.
    """
    end = datetime.utcnow()
    start = end - timedelta(days=365 * years)
    all_candles = []

    current_end = end
    while True:
        batch = fetch_kline(symbol, interval=interval, limit=2000,
                           start_time=start, end_time=current_end)
        if not batch:
            break
        all_candles.extend(batch)
        # Move the end pointer back by the number of candles fetched
        # TickDB returns candles in descending order (most recent first)
        earliest_in_batch = batch[-1]["open_time"]
        current_end = datetime.utcfromtimestamp(earliest_in_batch / 1000)
        if current_end <= start:
            break
        time.sleep(0.2)  # Respect rate limits during bulk fetches

    # Sort ascending (oldest first) — required for backtesting
    all_candles.sort(key=lambda x: x["open_time"])
    return all_candles


# Example usage
if __name__ == "__main__":
    print("Fetching 5 years of AAPL.US daily kline data...")
    candles = fetch_years("AAPL.US", years=5)
    print(f"Retrieved {len(candles)} daily candles")
    if candles:
        print(f"Date range: {candles[0]['open_time']} → {candles[-1]['open_time']}")
        print(f"Sample candle: {candles[0]}")

This code is deliberately verbose. In a production system, each function would have unit tests, logging, and monitoring. But even this version is engineered correctly: it handles auth, errors, rate limits, and timeouts. That is the baseline.


4. Designing the Strategy: Moving Average Crossover

4.1 The Logic

The moving average crossover is the "Hello World" of quant strategies — not because it is profitable in isolation (it often is not, after transaction costs), but because it demonstrates every component of a quant system: signal generation, position sizing, risk management, and performance measurement.

The logic is straightforward:

  • Fast MA: Short-window moving average (e.g., 10-day). Reacts quickly to recent price changes.
  • Slow MA: Long-window moving average (e.g., 50-day). Represents the long-term trend.
  • Signal: When the fast MA crosses above the slow MA → Long signal (bullish). When the fast MA crosses below the slow MA → Exit (bearish).

We will also add a simple stop-loss rule: if the position loses more than 5%, exit immediately. This is not optional — a strategy without a defined maximum loss is not a strategy; it is a gambling system.

4.2 Why This Strategy Is a Good Starting Point

From an engineering perspective, the MA crossover has two properties that make it ideal for beginners:

  1. The signal is unambiguous. There is no machine learning model to tune, no hyperparameter grid to search. The signal fires when the math condition is met. You can reason about it precisely.
  2. The strategy is transparent. Every decision traceable to a rule. This makes backtesting results interpretable — if the strategy underperforms, you know exactly why by looking at the signal history.

5. Building the Backtesting Engine

5.1 Core Data Structures

We represent a candle and a position as plain data structures. In a production system, you would use dataclasses or Pydantic models with validation, but for learning purposes, dictionaries are sufficient.

from dataclasses import dataclass
from typing import Optional

@dataclass
class Candle:
    """Represents a single OHLCV candle."""
    open_time: int      # Unix timestamp in milliseconds
    open: float
    high: float
    low: float
    close: float
    volume: float

    @classmethod
    def from_dict(cls, d: dict) -> "Candle":
        return cls(
            open_time=d["open_time"],
            open=float(d["open"]),
            high=float(d["high"]),
            low=float(d["low"]),
            close=float(d["close"]),
            volume=float(d["volume"]),
        )

@dataclass
class Position:
    """Represents an active position."""
    entry_price: float
    quantity: float
    entry_time: int

5.2 Signal Generation

def compute_sma(candles: list[Candle], window: int) -> list[Optional[float]]:
    """
    Compute a Simple Moving Average over a rolling window.
    Returns None for the first (window - 1) candles (insufficient data).
    """
    closes = [c.close for c in candles]
    result = []
    for i in range(len(closes)):
        if i < window - 1:
            result.append(None)
        else:
            window_candles = closes[i - window + 1 : i + 1]
            result.append(sum(window_candles) / window)
    return result


def generate_signals(candles: list[Candle], fast_window: int = 10,
                     slow_window: int = 50) -> list[Optional[str]]:
    """
    Generate crossover signals from two moving averages.

    Returns:
        "long" when fast MA crosses above slow MA
        "exit" when fast MA crosses below slow MA
        None otherwise
    """
    fast_sma = compute_sma(candles, fast_window)
    slow_sma = compute_sma(candles, slow_window)

    signals = [None] * len(candles)

    for i in range(1, len(candles)):
        # Skip if either MA is not yet defined
        if fast_sma[i] is None or slow_sma[i] is None:
            continue
        if fast_sma[i - 1] is None or slow_sma[i - 1] is None:
            continue

        prev_fast = fast_sma[i - 1]
        prev_slow = slow_sma[i - 1]
        curr_fast = fast_sma[i]
        curr_slow = slow_sma[i]

        # Bullish crossover: fast crosses above slow
        if prev_fast <= prev_slow and curr_fast > curr_slow:
            signals[i] = "long"
        # Bearish crossover: fast crosses below slow
        elif prev_fast >= prev_slow and curr_fast < curr_slow:
            signals[i] = "exit"

    return signals

5.3 The Backtester

This is the most important piece of code in the article. A backtester that does not account for transaction costs and stop-losses is useless for strategy evaluation. We include both.

from datetime import datetime

@dataclass
class BacktestResult:
    """Stores aggregated backtest performance metrics."""
    total_return: float
    annualized_return: float
    sharpe_ratio: float
    max_drawdown: float
    win_rate: float
    num_trades: int
    trade_log: list[dict]

    def summary(self) -> str:
        return (
            f"Total Return: {self.total_return:.2%}\n"
            f"Annualized Return: {self.annualized_return:.2%}\n"
            f"Sharpe Ratio: {self.sharpe_ratio:.2f}\n"
            f"Max Drawdown: {self.max_drawdown:.2%}\n"
            f"Win Rate: {self.win_rate:.2%}\n"
            f"Total Trades: {self.num_trades}"
        )


def run_backtest(
    candles: list[Candle],
    initial_capital: float = 100_000.0,
    commission: float = 0.001,      # 0.1% per trade
    slippage: float = 0.0005,        # 0.05% simulated slippage
    stop_loss_pct: float = 0.05,    # 5% stop-loss
    position_pct: float = 1.0,      # Use 100% of capital per position
) -> BacktestResult:
    """
    Run a backtest on a list of candles with the MA crossover strategy.

    Key assumptions:
    - Commission: 0.1% per side (round-trip cost = 0.2%)
    - Slippage: 0.05% per trade (approximated)
    - Full position sizing (100% of available capital)
    - No short selling

    ⚠️ These cost assumptions are simplified. Live execution costs
    depend on liquidity, order type, and venue. Always add a buffer.
    """
    signals = generate_signals(candles)
    position: Optional[Position] = None

    capital = initial_capital
    equity_curve = [initial_capital]
    trade_log = []
    peak_equity = initial_capital
    max_drawdown = 0.0

    daily_returns = []

    for i, candle in enumerate(candles):
        current_price = candle.close
        current_time = candle.open_time

        # --- Stop-loss check ---
        if position is not None:
            pnl_pct = (current_price - position.entry_price) / position.entry_price
            if pnl_pct <= -stop_loss_pct:
                # Stop-loss triggered
                trade_log.append(close_trade(position, current_price, current_time, "stop_loss", capital))
                capital = trade_log[-1]["exit_value"]
                position = None

        # --- Signal check ---
        signal = signals[i]
        if signal == "long" and position is None:
            # Entry: calculate position size after costs
            entry_cost = capital * (1 - slippage - commission)
            quantity = entry_cost / current_price
            position = Position(
                entry_price=current_price * (1 + slippage + commission),
                quantity=quantity,
                entry_time=current_time,
            )
        elif signal == "exit" and position is not None:
            # Exit
            trade_log.append(close_trade(position, current_price, current_time, "signal", capital))
            capital = trade_log[-1]["exit_value"]
            position = None

        # --- Equity tracking ---
        current_equity = capital
        if position is not None:
            current_equity = capital + position.quantity * current_price
        equity_curve.append(current_equity)

        # Drawdown tracking
        peak_equity = max(peak_equity, current_equity)
        drawdown = (peak_equity - current_equity) / peak_equity
        max_drawdown = max(max_drawdown, drawdown)

        # Daily return for Sharpe calculation
        if len(equity_curve) > 1:
            daily_return = (equity_curve[-1] - equity_curve[-2]) / equity_curve[-2]
            daily_returns.append(daily_return)

    # Final close if position still open
    if position is not None:
        last_candle = candles[-1]
        trade_log.append(close_trade(position, last_candle.close, last_candle.open_time, "end_of_backtest", capital))
        capital = trade_log[-1]["exit_value"]

    # Compute metrics
    total_return = (capital - initial_capital) / initial_capital

    # Annualized return (assuming 252 trading days per year)
    num_days = (candles[-1].open_time - candles[0].open_time) / (1000 * 86400)
    years = num_days / 365
    annualized_return = (1 + total_return) ** (1 / years) - 1 if years > 0 else 0.0

    # Sharpe ratio (risk-free rate assumed 0 for simplicity)
    if len(daily_returns) > 1:
        import statistics
        mean_ret = statistics.mean(daily_returns)
        std_ret = statistics.stdev(daily_returns) if len(daily_returns) > 1 else 1.0
        sharpe_ratio = (mean_ret / std_ret) * (252 ** 0.5) if std_ret > 0 else 0.0
    else:
        sharpe_ratio = 0.0

    # Win rate
    winning_trades = sum(1 for t in trade_log if t["pnl"] > 0)
    win_rate = winning_trades / len(trade_log) if trade_log else 0.0

    return BacktestResult(
        total_return=total_return,
        annualized_return=annualized_return,
        sharpe_ratio=sharpe_ratio,
        max_drawdown=max_drawdown,
        win_rate=win_rate,
        num_trades=len(trade_log),
        trade_log=trade_log,
    )


def close_trade(position: Position, exit_price: float, exit_time: int,
                reason: str, current_capital: float) -> dict:
    """Record a completed trade and compute PnL."""
    # Account for commission and slippage on exit
    net_exit_value = position.quantity * exit_price * (1 - 0.0005 - 0.001)
    pnl = net_exit_value - (position.quantity * position.entry_price)
    return {
        "entry_time": position.entry_time,
        "exit_time": exit_time,
        "entry_price": position.entry_price,
        "exit_price": exit_price,
        "quantity": position.quantity,
        "pnl": pnl,
        "pnl_pct": pnl / (position.quantity * position.entry_price),
        "exit_reason": reason,
    }

5.4 Running the Backtest

if __name__ == "__main__":
    # Fetch 5 years of AAPL.US daily data
    candles = fetch_years("AAPL.US", years=5)
    candle_objects = [Candle.from_dict(c) for c in candles]

    print(f"Loaded {len(candle_objects)} candles for AAPL.US")
    print(f"Backtest period: {datetime.utcfromtimestamp(candle_objects[0].open_time / 1000).date()} "
          f"to {datetime.utcfromtimestamp(candle_objects[-1].open_time / 1000).date()}")

    result = run_backtest(
        candle_objects,
        initial_capital=100_000.0,
        commission=0.001,
        slippage=0.0005,
        stop_loss_pct=0.05,
    )

    print("\n" + "=" * 50)
    print("BACKTEST RESULTS — MA Crossover (10/50)")
    print("=" * 50)
    print(result.summary())

6. Visualizing Results

A backtest without visualization is a black box. You need to answer three questions:

  1. Is the equity curve consistently upward-sloping? Large drawdowns and flat periods indicate regime changes the strategy cannot handle.
  2. Are the trades aligned with meaningful market events? Plot markers on the equity curve at each trade entry/exit.
  3. How does the strategy compare to buy-and-hold? The benchmark is not optional — if your strategy underperforms a simple SPY buy-and-hold after costs, the strategy has no alpha.
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from datetime import datetime

def plot_backtest(candles: list[Candle], result: BacktestResult,
                  fast_window: int = 10, slow_window: int = 50):
    """
    Generate a two-panel chart:
    Panel 1: Price + MAs + trade markers
    Panel 2: Equity curve vs. buy-and-hold benchmark
    """
    signals = generate_signals(candles, fast_window, slow_window)
    fast_sma = compute_sma(candles, fast_window)
    slow_sma = compute_sma(candles, slow_window)

    timestamps = [datetime.utcfromtimestamp(c.open_time / 1000) for c in candles]
    closes = [c.close for c in candles]

    # Buy-and-hold equity curve
    initial_price = closes[0]
    benchmark = [100_000 * (p / initial_price) for p in closes]

    # Strategy equity curve (reconstruct from trade log)
    strategy_equity = [100_000.0]
    trade_ptr = 0
    position_open = False
    entry_price = 0
    position_qty = 0
    position_capital = 100_000

    for i, candle in enumerate(candles[1:], 1):
        # Check for entry
        if signals[i] == "long" and not position_open:
            entry_cost = position_capital * (1 - 0.0005 - 0.001)
            position_qty = entry_cost / candle.close
            entry_price = candle.close * (1 + 0.0005 + 0.001)
            position_open = True
        # Check for exit
        elif (signals[i] == "exit" or i == len(candles) - 1) and position_open:
            exit_value = position_qty * candle.close * (1 - 0.0005 - 0.001)
            position_capital = exit_value
            position_open = False
            strategy_equity.append(position_capital)
        else:
            if position_open:
                current_val = position_capital + position_qty * candle.close - position_qty * entry_price
                strategy_equity.append(current_val)
            else:
                strategy_equity.append(position_capital)

    fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 10), sharex=True)

    # Panel 1: Price and MAs
    ax1.plot(timestamps, closes, label="Close", color="#334155", linewidth=1, alpha=0.8)

    # Plot MAs where available
    ma_fast_valid = [(t, v) for t, v in zip(timestamps, fast_sma) if v is not None]
    ma_slow_valid = [(t, v) for t, v in zip(timestamps, slow_sma) if v is not None]
    if ma_fast_valid:
        t_fast, v_fast = zip(*ma_fast_valid)
        ax1.plot(t_fast, v_fast, label=f"SMA {fast_window}", color="#3b82f6", linewidth=1.2)
    if ma_slow_valid:
        t_slow, v_slow = zip(*ma_slow_valid)
        ax1.plot(t_slow, v_slow, label=f"SMA {slow_window}", color="#f59e0b", linewidth=1.2)

    # Trade markers
    entry_times, entry_prices = [], []
    exit_times, exit_prices = [], []
    for trade in result.trade_log:
        if trade["exit_reason"] == "signal":
            exit_times.append(datetime.utcfromtimestamp(trade["exit_time"] / 1000))
            exit_prices.append(trade["exit_price"])
        if not trade.get("_marked_entry", False):
            entry_times.append(datetime.utcfromtimestamp(trade["entry_time"] / 1000))
            entry_prices.append(trade["entry_price"])
            trade["_marked_entry"] = True

    ax1.scatter(entry_times, entry_prices, marker="^", color="#22c55e",
                s=80, label="Entry", zorder=5)
    ax1.scatter(exit_times, exit_prices, marker="v", color="#ef4444",
                s=80, label="Exit", zorder=5)

    ax1.set_ylabel("Price ($)")
    ax1.set_title("AAPL.US — MA Crossover Strategy (10/50)")
    ax1.legend(loc="upper left")
    ax1.grid(True, alpha=0.3)

    # Panel 2: Equity curves
    ax2.plot(timestamps, benchmark, label="Buy-and-Hold", color="#94a3b8",
             linewidth=1.5, linestyle="--")
    ax2.plot(timestamps, strategy_equity[:len(timestamps)], label="MA Crossover",
             color="#3b82f6", linewidth=1.5)
    ax2.axhline(100_000, color="#64748b", linestyle=":", alpha=0.5)

    # Annotate max drawdown
    ax2.fill_between(
        timestamps,
        strategy_equity[:len(timestamps)],
        benchmark[:len(timestamps)],
        alpha=0.15,
        color="#3b82f6",
    )

    ax2.set_ylabel("Portfolio Value ($)")
    ax2.set_xlabel("Date")
    ax2.set_title(f"Equity Curve vs. Buy-and-Hold  |  "
                  f"Return: {result.total_return:.1%}  |  "
                  f"Sharpe: {result.sharpe_ratio:.2f}  |  "
                  f"Max DD: {result.max_drawdown:.1%}")
    ax2.legend(loc="upper left")
    ax2.grid(True, alpha=0.3)
    ax2.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m"))

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


# Run visualization
if __name__ == "__main__":
    plot_backtest(candle_objects, result)

7. Interpreting the Results: What to Look For

Running the backtest on AAPL.US with the parameters above will produce numbers — but the numbers are only meaningful if you interpret them correctly.

Total return vs. annualized return: A 50% total return over 5 years is very different from 50% over 2 years. Always annualize. A good benchmark: 10–15% annualized for US equities is historically normal. If your strategy returns 30% annualized before costs, something is likely wrong with your data or your signal (look-ahead bias is the most common culprit).

Sharpe ratio: Measures risk-adjusted return. A Sharpe above 1.0 is acceptable; above 2.0 is exceptional and warrants scrutiny. A Sharpe below 0.5 means the strategy is not being compensated adequately for its volatility.

Max drawdown: This is the most underappreciated metric by beginners. A strategy that returns 30% annualized but suffers a −40% drawdown along the way will destroy your discipline in live trading. The drawdown is where most retail quant strategies fail — not because the strategy is wrong, but because the trader cannot psychologically tolerate the paper losses.

Win rate: A 40% win rate with 2:1 average win/loss ratio is mathematically superior to a 60% win rate with 0.8:1 ratio. Never evaluate a strategy by win rate alone.


8. The Iteration Loop: How Professional Quants Actually Work

Here is what nobody tells you on the first day: the first strategy you build will not be your best strategy. It will not even be your second-best. It will be a learning artifact.

Professional quant research is an iteration loop:

  1. Form a hypothesis: "Moving average crossovers on high-momentum stocks generate excess returns during earnings season."
  2. Operationalize the hypothesis: Express it as a set of computable rules on historical data.
  3. Run the backtest: Measure performance across multiple metrics, not just total return.
  4. Stress-test the result: Vary the parameters (fast MA window, slow MA window, stop-loss percentage). Does the strategy hold? If performance collapses when you change a parameter by 5%, the strategy is overfitted.
  5. Test on out-of-sample data: Reserve the last 20% of your data for out-of-sample testing. Never tune parameters on out-of-sample data — that defeats the purpose.
  6. Paper trade: Run the strategy on live data with a paper account before committing real capital.
  7. Deploy with position limits: Even a well-tested strategy should be deployed with a maximum position size and a maximum portfolio loss threshold.

Steps 1 through 5 are what this article covers. Steps 6 and 7 require brokerage integration and are beyond the scope of this piece, but they are where the engineering discipline matters most — real-time data feeds, order submission latency, and slippage under live market conditions are all significantly worse than what your backtest assumes.


9. Supply Chain Analysis: Why This Strategy Needs Sector Context

A single-stock MA crossover strategy on AAPL.US is an educational exercise. In practice, no professional runs a strategy on one ticker in isolation. The reason is regime sensitivity: the same MA crossover parameters that are profitable during a bull market will generate losses during a range-bound or bearish market.

A more robust approach considers the sector and macro context. When NVIDIA (NVDA) reports earnings, the event does not affect NVIDIA in isolation — it reprices the entire AI supply chain. Companies in the semiconductor sector (TSMC, ASML), cloud infrastructure (MSFT, GOOGL), and memory (MU, SMH) will show correlated moves.

For this reason, a production quant strategy would:

  • Screen the sector for correlated tickers before deploying capital.
  • Adjust position sizing based on sector-wide volatility — higher volatility, smaller positions.
  • Use breadth metrics (the percentage of stocks in the sector above their 200-day MA) as a regime filter. When fewer than 30% of sector stocks are above the 200-day MA, reduce exposure.

This is the gap between a "strategy that works on paper" and a "strategy that survives live trading." TickDB's cross-asset data coverage — US equities, HK equities, crypto, and more — enables this type of multi-ticker analysis through a single API.


10. Limitations and What This Article Does Not Cover

An honest article owes the reader a clear-eyed assessment of what it has not addressed:

  • Brokerage integration: The backtester generates signals but does not submit orders. Connecting to a broker (Interactive Brokers, Alpaca, Tradier) requires a separate engineering layer.
  • Transaction cost modeling: Our commission assumption (0.1% per side) is simplified. Dark pool execution, order type selection, and venue routing can significantly reduce actual costs — or increase them substantially in illiquid markets.
  • Overfitting risk: Optimizing MA windows on a single ticker over a specific time period is a form of curve-fitting. A robust strategy should hold across multiple tickers and time periods.
  • Market impact: The backtest assumes you can execute at the closing price. In reality, a large order moves the market against you. This is called market impact and is the primary reason that highSharpe ratio backtests do not replicate in live trading.
  • Regime detection: We do not implement a market regime filter. This is one of the most impactful improvements you can make to a basic MA crossover strategy.

Next Steps

If you are a programmer who wants to learn quantitative trading systematically, the code above is your starting point. Fork it, break it, read the error messages, and fix it. That is how you learn.

If you want to run this strategy yourself with real market data:

  1. Sign up at tickdb.ai (free tier available — no credit card required)
  2. Generate an API key in the dashboard
  3. Set TICKDB_API_KEY as an environment variable
  4. Replace the symbol in fetch_years() with any US equity ticker you want to test
  5. Run the backtester and examine the results

If you want to test multi-ticker strategies with 10+ years of historical OHLCV data for cross-sector analysis and regime screening, reach out to enterprise@tickdb.ai for institutional plan details.

If you use AI coding assistants, search for the tickdb-market-data SKILL in your AI tool's marketplace to get TickDB API integration directly in your coding 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 real execution conditions including slippage, market impact, and liquidity constraints. Always conduct out-of-sample validation and paper trading before deploying any strategy with real capital.