Ctrl K

Large-Cap NASDAQ Indicator Strategy Comparison

This notebook combines four long-only technical indicator based strategy backtests on the same ten-stock large-cap NASDAQ universe:

  • ATR Exit Strategy
  • Bollinger Band Breakout Strategy
  • Simple Moving Average Crossover
  • Oversold Mean-Reversion Strategy

Each strategy is compared against two baselines:

  • equal-weight buy-and-hold across the same ten stocks
  • QQQ buy-and-hold as a passive NASDAQ proxy

All strategies use the same starting capital, the same Tiingo adjusted daily price source, and the same backtest window.

Data source and setup

This notebook uses Tiingo daily adjusted prices for all stocks and QQQ.

To run the notebook, create a project root level .env file with:

In [1]:
import os
import time
import requests
import numpy as np
import pandas as pd
import matplotlib
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import matplotlib.ticker as mticker

from pathlib import Path
from dotenv import load_dotenv
from requests.adapters import HTTPAdapter
from urllib3.util import Retry

FL_BLUE = '#2563eb'
FL_SLATE = '#64748b'
FL_AMBER = '#f59e0b'
FL_GREEN = '#16a34a'
FL_RED = '#ef4444'
FL_BG = '#ffffff'
FL_GRID = '#e2e8f0'
FL_TEXT = '#0f172a'
FL_TEXT2 = '#334155'
FL_BORDER = '#e2e8f0'

matplotlib.rcParams.update({
    'figure.facecolor': FL_BG,
    'axes.facecolor': FL_BG,
    'axes.edgecolor': FL_BORDER,
    'axes.labelcolor': FL_TEXT2,
    'axes.spines.top': False,
    'axes.spines.right': False,
    'axes.grid': True,
    'grid.color': FL_GRID,
    'grid.linewidth': 0.7,
    'xtick.color': FL_TEXT2,
    'ytick.color': FL_TEXT2,
    'xtick.labelsize': 10,
    'ytick.labelsize': 10,
    'axes.labelsize': 11,
    'axes.titlesize': 12,
    'axes.titlecolor': FL_TEXT,
    'axes.titlepad': 12,
    'legend.frameon': False,
    'legend.fontsize': 10,
    'figure.dpi': 300,
    'savefig.bbox': 'tight',
    'font.family': 'sans-serif',
    'font.sans-serif': ['Inter', 'Helvetica Neue', 'Arial', 'DejaVu Sans'],
})

PROJECT_ROOT = Path.cwd()
load_dotenv(PROJECT_ROOT / '.env')

TIINGO_API_KEY = os.getenv('TIINGO_API_KEY')
if not TIINGO_API_KEY:
    raise RuntimeError('TIINGO_API_KEY is missing from the project-level .env')

FIXED_TICKERS = ['AAPL', 'MSFT', 'NVDA', 'GOOGL', 'META', 'AMZN', 'AVGO', 'TSLA', 'NFLX', 'COST']
STARTING_CAPITAL = 100_000.0
START_DATE = '2018-01-02'
REQUEST_SLEEP = 0.15
INCLUDE_QQQ_BASELINE = True

QQQ_TICKER = 'QQQ'

STRATEGY_DISPLAY_NAMES = {
    'atr_exit': 'ATR Exit',
    'bb_breakout': 'Bollinger Band Breakout',
    'ma_crossover': 'MA Crossover',
    'oversold': 'Oversold Mean-Reversion',
    'equal_weight': 'Equal Weighted Buy and Hold',
    'qqq': 'QQQ Buy and Hold',
}

COLORS = {
    'ATR Exit': FL_BLUE,
    'Bollinger Band Breakout': FL_BLUE,
    'MA Crossover': FL_BLUE,
    'Oversold Mean-Reversion': FL_BLUE,
    'Equal Weighted Buy and Hold': FL_SLATE,
    'QQQ Buy and Hold': FL_AMBER,
}

LWIDTHS = {
    'ATR Exit': 2.0,
    'Bollinger Band Breakout': 2.0,
    'MA Crossover': 2.0,
    'Oversold Mean-Reversion': 2.0,
    'Equal Weighted Buy and Hold': 1.5,
    'QQQ Buy and Hold': 1.5,
}

Shared helper functions

These helpers are used by all four strategies for:

  • Tiingo downloads
  • ATR calculation
  • equity curve construction
  • performance summary formatting
  • common plotting
In [2]:
def make_session():
    session = requests.Session()
    session.mount('https://', HTTPAdapter(max_retries=Retry(
        total=5,
        backoff_factor=1,
        status_forcelist=[429, 500, 502, 503, 504],
        allowed_methods=['GET']
    )))
    return session

def fetch_tiingo_prices(session, ticker, start_date):
    resp = session.get(
        f'https://api.tiingo.com/tiingo/daily/{ticker}/prices',
        headers={'Authorization': f'Token {TIINGO_API_KEY}', 'Content-Type': 'application/json'},
        params={'startDate': start_date, 'resampleFreq': 'daily', 'format': 'json'},
        timeout=60
    )
    resp.raise_for_status()
    raw = pd.DataFrame(resp.json())
    if raw.empty:
        raise ValueError(f'No price data returned for {ticker}')

    df = pd.DataFrame({
        'date': pd.to_datetime(raw['date'], utc=True).dt.tz_localize(None),
        'open': pd.to_numeric(raw['adjOpen'], errors='coerce'),
        'high': pd.to_numeric(raw['adjHigh'], errors='coerce'),
        'low': pd.to_numeric(raw['adjLow'], errors='coerce'),
        'close': pd.to_numeric(raw['adjClose'], errors='coerce'),
        'volume': pd.to_numeric(raw['adjVolume'], errors='coerce'),
    })
    df = df.dropna(subset=['date', 'open', 'high', 'low', 'close', 'volume']).sort_values('date').reset_index(drop=True)
    if df.empty:
        raise ValueError(f'Processed dataframe is empty for {ticker}')
    return df

def calc_atr(df, period=14):
    hl = df['high'] - df['low']
    hpc = (df['high'] - df['close'].shift(1)).abs()
    lpc = (df['low'] - df['close'].shift(1)).abs()
    return pd.concat([hl, hpc, lpc], axis=1).max(axis=1).ewm(span=period, adjust=False).mean()

def build_daily_equity_curve_from_returns(daily_returns_df, starting_capital):
    if daily_returns_df.empty:
        return pd.DataFrame(columns=['date', 'daily_return', 'equity', 'cum_pnl', 'daily_pnl'])

    eq = daily_returns_df.copy().sort_values('date').reset_index(drop=True)
    eq['equity'] = starting_capital * (1 + eq['daily_return']).cumprod()
    eq['cum_pnl'] = eq['equity'] - starting_capital
    eq['daily_pnl'] = eq['equity'].diff().fillna(eq['equity'] - starting_capital)
    return eq

def build_daily_equity_curve_from_trade_book(trade_book, starting_capital):
    if trade_book.empty:
        return pd.DataFrame(columns=['date', 'daily_pnl', 'cum_pnl', 'equity', 'daily_return'])

    daily = (
        trade_book.groupby('exit_date', as_index=False)['pnl']
        .sum()
        .rename(columns={'exit_date': 'date', 'pnl': 'daily_pnl'})
        .sort_values('date')
        .reset_index(drop=True)
    )
    daily['cum_pnl'] = daily['daily_pnl'].cumsum()
    daily['equity'] = starting_capital + daily['cum_pnl']
    daily['daily_return'] = daily['daily_pnl'] / starting_capital
    return daily

def calc_cagr_and_sharpe(daily_equity_df, starting_capital):
    if daily_equity_df.empty:
        return 0.0, 0.0

    df = daily_equity_df.copy()
    df['date'] = pd.to_datetime(df['date'], errors='coerce')
    df = df.dropna(subset=['date', 'equity']).sort_values('date').reset_index(drop=True)

    if df.empty:
        return 0.0, 0.0

    span_days = (df['date'].iloc[-1] - df['date'].iloc[0]).days
    years = span_days / 365.25 if span_days > 0 else 0.0

    ending = df['equity'].iloc[-1]
    cagr = (ending / starting_capital) ** (1 / years) - 1 if years > 0 and ending > 0 else 0.0

    dr = pd.to_numeric(df['daily_return'], errors='coerce').dropna()
    sharpe = (dr.mean() / dr.std(ddof=1)) * (252 ** 0.5) if len(dr) > 1 and dr.std(ddof=1) > 0 else 0.0

    return round(cagr, 6), round(sharpe, 6)

def build_summary_row(strategy_name, equity_df, extra_cols=None):
    if not equity_df.empty:
        total_pnl = equity_df['cum_pnl'].iloc[-1]
        ending = equity_df['equity'].iloc[-1]
        max_dd = (equity_df['equity'] - equity_df['equity'].cummax()).min()
        cagr, sharpe = calc_cagr_and_sharpe(equity_df[['date', 'daily_return', 'equity']].copy(), STARTING_CAPITAL)
        start_dt = equity_df['date'].min()
        end_dt = equity_df['date'].max()
    else:
        total_pnl = ending = max_dd = cagr = sharpe = 0.0
        start_dt = end_dt = pd.NaT

    row = {
        'Strategy': strategy_name,
        'Start': start_dt.strftime('%Y-%m-%d') if pd.notna(start_dt) else '',
        'End': end_dt.strftime('%Y-%m-%d') if pd.notna(end_dt) else '',
        'Ending Equity': round(float(ending), 2),
        'Total PnL': round(float(total_pnl), 2),
        'Max Drawdown': round(float(max_dd), 2),
        'CAGR': cagr,
        'Sharpe': sharpe,
    }

    if extra_cols:
        row.update(extra_cols)

    return row

def fmt_dollar(v):
    return f'${v:,.0f}'

def fmt_pct(v):
    return f'{v:.2%}'

def fmt_f2(v):
    return f'{v:.2f}'

def fmt_int(v):
    return f'{int(v):,}'

def fmt_pct_or_na(v):
    return '-' if pd.isna(v) else f'{v:.2%}'

Universe and data coverage

The backtest uses a fixed list of ten large-cap NASDAQ stocks. Prices are fetched once and reused across all four strategy sections.

In [3]:
universe_df = pd.DataFrame({'Ticker': FIXED_TICKERS})
session = make_session()

selected_tickers = FIXED_TICKERS.copy()
all_tickers = selected_tickers + ([QQQ_TICKER] if INCLUDE_QQQ_BASELINE else [])

price_cache = {}
fetch_rows = []

for i, ticker in enumerate(all_tickers, start=1):
    try:
        df = fetch_tiingo_prices(session, ticker, START_DATE)
        price_cache[ticker] = df.copy()

        fetch_rows.append({
            'Ticker': ticker,
            'Status': 'ok',
            'First': df['date'].min().strftime('%Y-%m-%d'),
            'Last': df['date'].max().strftime('%Y-%m-%d'),
            'Rows': len(df),
        })
    except Exception as exc:
        fetch_rows.append({
            'Ticker': ticker,
            'Status': f'error: {exc}',
            'First': '',
            'Last': '',
            'Rows': 0,
        })

    time.sleep(REQUEST_SLEEP)

fetch_summary_df = pd.DataFrame(fetch_rows)
display(fetch_summary_df[['Ticker', 'Status', 'First', 'Last', 'Rows']])
Ticker Status First Last Rows
0 AAPL ok 2018-01-02 2026-05-22 2109
1 MSFT ok 2018-01-02 2026-05-22 2109
2 NVDA ok 2018-01-02 2026-05-22 2109
3 GOOGL ok 2018-01-02 2026-05-22 2109
4 META ok 2018-01-02 2026-05-22 2109
5 AMZN ok 2018-01-02 2026-05-22 2109
6 AVGO ok 2018-01-02 2026-05-22 2109
7 TSLA ok 2018-01-02 2026-05-22 2109
8 NFLX ok 2018-01-02 2026-05-22 2109
9 COST ok 2018-01-02 2026-05-22 2109
10 QQQ ok 2018-01-02 2026-05-22 2109

Strategy functions

Paste the four existing strategy function blocks here, unchanged from the source notebooks, plus the shared baseline helpers if you want them kept local to this section.

You only need one copy of:

  • run_equal_weight_buy_and_hold
  • run_qqq_buy_and_hold

Then include these four strategy functions:

  • run_atr_exit_strategy
  • run_breakout_strategy
  • run_ma_crossover_strategy
  • run_oversold_strategy
In [4]:
def build_daily_equity_curve(trade_book, starting_capital):
    if trade_book.empty:
        return pd.DataFrame(columns=['date', 'daily_return', 'equity', 'cum_pnl', 'daily_pnl'])

    realized = (
        trade_book.groupby('exit_date', as_index=False)['pnl']
        .sum()
        .rename(columns={'exit_date': 'date', 'pnl': 'daily_pnl'})
        .sort_values('date')
        .reset_index(drop=True)
    )

    realized['cum_pnl'] = realized['daily_pnl'].cumsum()
    realized['equity'] = starting_capital + realized['cum_pnl']
    realized['prev_equity'] = realized['equity'].shift(1).fillna(starting_capital)
    realized['daily_return'] = np.where(
        realized['prev_equity'] > 0,
        realized['daily_pnl'] / realized['prev_equity'],
        0.0
    )

    equity_df = realized[['date', 'daily_return', 'equity', 'cum_pnl', 'daily_pnl']].copy()
    return equity_df
In [5]:
SHORT_WINDOW = 20
LONG_WINDOW = 50
ATR_PERIOD = 14
SL_ATR_MULTIPLE = 1.0
TP_ATR_MULTIPLE = 1.5
MAX_HOLDING_DAYS = 30
TRANSACTION_COST_RT = 0.0002
def run_atr_exit_strategy(tickers, price_cache):
    all_trades = []
    position_map = {}

    for ticker in tickers:
        if ticker not in price_cache:
            continue

        df = price_cache[ticker].copy()
        if df.empty:
            continue

        if 'date' in df.columns:
            df = df.sort_values('date').reset_index(drop=True)
            df['trade_date'] = pd.to_datetime(df['date'])
            series_index = df['trade_date']
        else:
            df = df.sort_index().reset_index(drop=False)
            df['trade_date'] = pd.to_datetime(df['index'])
            series_index = df['trade_date']

        df['sma_short'] = df['close'].rolling(SHORT_WINDOW).mean()
        df['sma_long'] = df['close'].rolling(LONG_WINDOW).mean()
        df['atr'] = calc_atr(df, ATR_PERIOD)
        df['signal'] = np.where(df['sma_short'] > df['sma_long'], 1, 0)

        in_trade = False
        entry_date = None
        entry_price = np.nan
        sl = np.nan
        tp = np.nan
        trades = []
        pos_series = pd.Series(0.0, index=series_index)

        for _, row in df.iterrows():
            current_date = row['trade_date']

            if not in_trade:
                if row['signal'] == 1 and pd.notna(row['close']) and pd.notna(row['atr']):
                    in_trade = True
                    entry_date = current_date
                    entry_price = row['close']
                    sl = entry_price - row['atr'] * SL_ATR_MULTIPLE
                    tp = entry_price + row['atr'] * TP_ATR_MULTIPLE
            else:
                pos_series.loc[current_date] = 1.0
                holding = (current_date - entry_date).days

                sl_hit = pd.notna(row['low']) and row['low'] <= sl
                tp_hit = pd.notna(row['high']) and row['high'] >= tp
                time_hit = holding >= MAX_HOLDING_DAYS

                if sl_hit or tp_hit or time_hit:
                    if sl_hit:
                        exit_price = sl
                        exit_reason = 'stop_loss'
                    elif tp_hit:
                        exit_price = tp
                        exit_reason = 'take_profit'
                    else:
                        exit_price = row['close']
                        exit_reason = 'time_stop'

                    cost = (entry_price + exit_price) * TRANSACTION_COST_RT
                    trades.append({
                        'ticker': ticker,
                        'entry_date': entry_date,
                        'entry_price': entry_price,
                        'exit_date': current_date,
                        'exit_price': exit_price,
                        'exit_reason': exit_reason,
                        'holding_days': holding,
                        'pnl': round(exit_price - entry_price - cost, 4),
                    })

                    in_trade = False
                    entry_date = None
                    entry_price = np.nan
                    sl = np.nan
                    tp = np.nan

        all_trades.append(
            pd.DataFrame(trades) if trades else pd.DataFrame(
                columns=['ticker', 'entry_date', 'entry_price', 'exit_date', 'exit_price', 'exit_reason', 'holding_days', 'pnl']
            )
        )
        position_map[ticker] = pos_series

    trade_book = (
        pd.concat(all_trades, ignore_index=True).sort_values('exit_date').reset_index(drop=True)
        if all_trades else
        pd.DataFrame(columns=['ticker', 'entry_date', 'entry_price', 'exit_date', 'exit_price', 'exit_reason', 'holding_days', 'pnl'])
    )

    agg_position = (
        pd.DataFrame(position_map).ffill().mean(axis=1)
        if position_map else pd.Series(dtype=float)
    )

    equity_df = build_daily_equity_curve(trade_book, STARTING_CAPITAL)

    return {
        'trade_book': trade_book,
        'agg_position': agg_position,
        'equity_df': equity_df,
        'tickers_used': len(position_map),
    }

BB_WINDOW = 20
BB_STD = 2
BW_MA_WINDOW = 21
ATR_PERIOD = 14
SL_ATR_MULTIPLE = 2.0
TP_ATR_MULTIPLE = 3.0
TRANSACTION_COST_RT = 0.0002


def run_breakout_strategy(tickers, price_cache):
    all_trades = []
    position_map = {}

    for ticker in tickers:
        if ticker not in price_cache:
            continue

        df = price_cache[ticker].copy()
        if df.empty:
            continue

        if 'date' in df.columns:
            df = df.sort_values('date').reset_index(drop=True)
            df['trade_date'] = pd.to_datetime(df['date'])
            series_index = df['trade_date']
        else:
            df = df.sort_index().reset_index(drop=False)
            df['trade_date'] = pd.to_datetime(df['index'])
            series_index = df['trade_date']

        roll = df['close'].rolling(BB_WINDOW)
        df['bb_mid'] = roll.mean()
        df['bb_std'] = roll.std()
        df['bb_upper'] = df['bb_mid'] + BB_STD * df['bb_std']
        df['bandwidth'] = (df['bb_upper'] - (df['bb_mid'] - BB_STD * df['bb_std'])) / df['bb_mid']
        df['bw_ma'] = df['bandwidth'].rolling(BW_MA_WINDOW).mean()
        df['bw_expanding'] = df['bandwidth'] > df['bw_ma']
        df['atr'] = calc_atr(df, ATR_PERIOD)

        df['signal'] = np.where(
            (df['high'] > df['bb_upper']) & df['bw_expanding'],
            1,
            0
        )

        in_trade = False
        entry_date = None
        entry_price = np.nan
        sl = np.nan
        tp = np.nan
        trades = []
        pos_series = pd.Series(0.0, index=series_index)

        for _, row in df.iterrows():
            current_date = row['trade_date']

            if not in_trade:
                if row['signal'] == 1 and pd.notna(row['close']) and pd.notna(row['atr']):
                    in_trade = True
                    entry_date = current_date
                    entry_price = row['close']
                    sl = entry_price - row['atr'] * SL_ATR_MULTIPLE
                    tp = entry_price + row['atr'] * TP_ATR_MULTIPLE
            else:
                pos_series.loc[current_date] = 1.0
                close = row['close']
                sl_hit = pd.notna(close) and close < sl
                tp_hit = pd.notna(close) and close > tp

                if sl_hit or tp_hit:
                    exit_price = sl if sl_hit else tp
                    exit_reason = 'stop_loss' if sl_hit else 'take_profit'
                    cost = (entry_price + exit_price) * TRANSACTION_COST_RT

                    trades.append({
                        'ticker': ticker,
                        'entry_date': entry_date,
                        'entry_price': entry_price,
                        'exit_date': current_date,
                        'exit_price': exit_price,
                        'exit_reason': exit_reason,
                        'holding_days': (current_date - entry_date).days,
                        'pnl': round(exit_price - entry_price - cost, 4),
                    })

                    in_trade = False
                    entry_date = None
                    entry_price = np.nan
                    sl = np.nan
                    tp = np.nan

        all_trades.append(
            pd.DataFrame(trades) if trades else pd.DataFrame(
                columns=['ticker', 'entry_date', 'entry_price', 'exit_date', 'exit_price', 'exit_reason', 'holding_days', 'pnl']
            )
        )
        position_map[ticker] = pos_series

    trade_book = (
        pd.concat(all_trades, ignore_index=True).sort_values('exit_date').reset_index(drop=True)
        if all_trades else
        pd.DataFrame(columns=['ticker', 'entry_date', 'entry_price', 'exit_date', 'exit_price', 'exit_reason', 'holding_days', 'pnl'])
    )

    agg_position = (
        pd.DataFrame(position_map).ffill().mean(axis=1)
        if position_map else pd.Series(dtype=float)
    )

    equity_df = build_daily_equity_curve(trade_book, STARTING_CAPITAL)

    return {
        'trade_book': trade_book,
        'agg_position': agg_position,
        'equity_df': equity_df,
        'tickers_used': len(position_map),
    }

def run_ma_crossover_strategy(tickers, price_cache, short_window, long_window):
    strategy_return_frames = []
    position_frames = []

    for ticker in tickers:
        if ticker not in price_cache:
            continue

        df = price_cache[ticker].copy()
        if df.empty:
            continue

        if 'date' in df.columns:
            df = df.sort_values('date').reset_index(drop=True)
            date_index = pd.to_datetime(df['date'])
        else:
            df = df.sort_index().reset_index(drop=False)
            date_index = pd.to_datetime(df['index'])

        df['ma_short'] = df['close'].rolling(short_window).mean()
        df['ma_long'] = df['close'].rolling(long_window).mean()
        df['position'] = np.where(df['ma_short'] > df['ma_long'], 1.0, 0.0)
        df['asset_return'] = df['close'].pct_change()
        df['strategy_return'] = df['asset_return'] * df['position'].shift(1)

        strategy_return_frames.append(
            pd.DataFrame({
                ticker: df['strategy_return'].values
            }, index=date_index)
        )
        position_frames.append(
            pd.DataFrame({
                ticker: df['position'].values
            }, index=date_index)
        )

    if not strategy_return_frames:
        return {
            'daily_returns_df': pd.DataFrame(columns=['date', 'daily_return']),
            'signal_series': pd.Series(dtype=float),
            'equity_df': pd.DataFrame(columns=['date', 'daily_return', 'equity', 'cum_pnl', 'daily_pnl']),
            'tickers_used': 0,
        }

    ret_df = pd.concat(strategy_return_frames, axis=1).sort_index()
    pos_df = pd.concat(position_frames, axis=1).sort_index()

    daily_r = ret_df.mean(axis=1, skipna=True).rename('daily_return')
    signal = pos_df.mean(axis=1, skipna=True).rename('signal')

    dr_df = daily_r.dropna().reset_index().rename(columns={'index': 'date'})
    eq_df = build_daily_equity_curve_from_returns(dr_df, STARTING_CAPITAL)

    return {
        'daily_returns_df': dr_df,
        'signal_series': signal,
        'equity_df': eq_df,
        'tickers_used': len(ret_df.columns),
    }


BB_WINDOW = 20
BB_STD = 2
ATR_PERIOD = 14
SL_ATR_MULTIPLE = 4.0
TP_ATR_MULTIPLE = 8.0
TRANSACTION_COST_RT = 0.0002
def run_oversold_strategy(tickers, price_cache):
    all_trades = []
    position_map = {}

    for ticker in tickers:
        if ticker not in price_cache:
            continue

        df = price_cache[ticker].copy()
        if df.empty:
            continue

        if 'date' in df.columns:
            df = df.sort_values('date').reset_index(drop=True)
            df['trade_date'] = pd.to_datetime(df['date'])
            series_index = df['trade_date']
        else:
            df = df.sort_index().reset_index(drop=False)
            df['trade_date'] = pd.to_datetime(df['index'])
            series_index = df['trade_date']

        roll = df['close'].rolling(BB_WINDOW)
        df['bb_mid'] = roll.mean()
        df['bb_std'] = roll.std()
        df['bb_lower'] = df['bb_mid'] - BB_STD * df['bb_std']
        df['atr'] = calc_atr(df, ATR_PERIOD)

        df['signal'] = np.where(
            (df['close'] < df['bb_lower']) & (df['close'].shift(1) >= df['bb_lower'].shift(1)),
            1,
            0
        )

        in_trade = False
        entry_date = None
        entry_price = np.nan
        sl = np.nan
        tp = np.nan
        trades = []
        pos_series = pd.Series(0.0, index=series_index)

        for _, row in df.iterrows():
            current_date = row['trade_date']

            if not in_trade:
                if row['signal'] == 1 and pd.notna(row['close']) and pd.notna(row['atr']):
                    in_trade = True
                    entry_date = current_date
                    entry_price = row['close']
                    sl = entry_price - row['atr'] * SL_ATR_MULTIPLE
                    tp = entry_price + row['atr'] * TP_ATR_MULTIPLE
            else:
                pos_series.loc[current_date] = 1.0
                close = row['close']
                sl_hit = pd.notna(close) and close < sl
                tp_hit = pd.notna(close) and close > tp

                if sl_hit or tp_hit:
                    exit_price = sl if sl_hit else tp
                    exit_reason = 'stop_loss' if sl_hit else 'take_profit'
                    cost = (entry_price + exit_price) * TRANSACTION_COST_RT

                    trades.append({
                        'ticker': ticker,
                        'entry_date': entry_date,
                        'entry_price': entry_price,
                        'exit_date': current_date,
                        'exit_price': exit_price,
                        'exit_reason': exit_reason,
                        'holding_days': (current_date - entry_date).days,
                        'pnl': round(exit_price - entry_price - cost, 4),
                    })

                    in_trade = False
                    entry_date = None
                    entry_price = np.nan
                    sl = np.nan
                    tp = np.nan

        all_trades.append(
            pd.DataFrame(trades) if trades else pd.DataFrame(
                columns=['ticker', 'entry_date', 'entry_price', 'exit_date', 'exit_price', 'exit_reason', 'holding_days', 'pnl']
            )
        )
        position_map[ticker] = pos_series

    trade_book = (
        pd.concat(all_trades, ignore_index=True).sort_values('exit_date').reset_index(drop=True)
        if all_trades else
        pd.DataFrame(columns=['ticker', 'entry_date', 'entry_price', 'exit_date', 'exit_price', 'exit_reason', 'holding_days', 'pnl'])
    )

    agg_position = (
        pd.DataFrame(position_map).ffill().mean(axis=1)
        if position_map else pd.Series(dtype=float)
    )

    equity_df = build_daily_equity_curve(trade_book, STARTING_CAPITAL)

    return {
        'trade_book': trade_book,
        'agg_position': agg_position,
        'equity_df': equity_df,
        'tickers_used': len(position_map),
    }


def run_equal_weight_buy_and_hold(tickers, price_cache):
    frames = []

    for ticker in tickers:
        if ticker not in price_cache:
            continue

        df = price_cache[ticker].copy()
        if df.empty:
            continue

        if 'date' in df.columns:
            df = df.sort_values('date').reset_index(drop=True)
            date_index = pd.to_datetime(df['date'])
        else:
            df = df.sort_index().reset_index(drop=False)
            date_index = pd.to_datetime(df['index'])

        df['asset_return'] = df['close'].pct_change()

        frames.append(
            pd.DataFrame(
                {ticker: df['asset_return'].values},
                index=date_index
            )
        )

    if not frames:
        return {
            'daily_returns_df': pd.DataFrame(columns=['date', 'daily_return']),
            'equity_df': pd.DataFrame(columns=['date', 'daily_return', 'equity', 'cum_pnl', 'daily_pnl']),
            'tickers_used': 0,
        }

    ret_df = pd.concat(frames, axis=1).sort_index()
    daily_r = ret_df.mean(axis=1, skipna=True).rename('daily_return')
    dr_df = daily_r.dropna().reset_index().rename(columns={'index': 'date'})
    eq_df = build_daily_equity_curve_from_returns(dr_df, STARTING_CAPITAL)

    return {
        'daily_returns_df': dr_df,
        'equity_df': eq_df,
        'tickers_used': len(ret_df.columns),
    }


def run_qqq_buy_and_hold(qqq_price_df):
    if qqq_price_df.empty:
        return {
            'daily_returns_df': pd.DataFrame(columns=['date', 'daily_return']),
            'equity_df': pd.DataFrame(columns=['date', 'daily_return', 'equity', 'cum_pnl', 'daily_pnl']),
            'tickers_used': 0,
        }

    w = qqq_price_df[['date', 'close']].copy()
    w['daily_return'] = w['close'].pct_change()
    dr_df = w[['date', 'daily_return']].dropna().reset_index(drop=True)
    eq_df = build_daily_equity_curve_from_returns(dr_df, STARTING_CAPITAL)

    return {
        'daily_returns_df': dr_df,
        'equity_df': eq_df,
        'tickers_used': 1,
    }

All strategies summary

This section compares the four active strategies against the shared baselines at a high level before moving into the individual strategy sections.

In [6]:
ATR_NAME = 'ATR Exit'
BB_NAME = 'Bollinger Band Breakout'
MA_NAME = 'MA Crossover'
OS_NAME = 'Oversold Mean-Reversion'
BH_NAME = 'Equal Weighted Buy and Hold'
QQQ_NAME = 'QQQ Buy and Hold'

atr_result = run_atr_exit_strategy(selected_tickers, price_cache)
bb_result = run_breakout_strategy(selected_tickers, price_cache)
ma_result = run_ma_crossover_strategy(selected_tickers, price_cache, 40, 70)
os_result = run_oversold_strategy(selected_tickers, price_cache)
ew_bh_result = run_equal_weight_buy_and_hold(selected_tickers, price_cache)

qqq_price_df = price_cache.get(QQQ_TICKER, pd.DataFrame())
qqq_bh_result = run_qqq_buy_and_hold(qqq_price_df) if INCLUDE_QQQ_BASELINE else None

atr_equity_df = atr_result['equity_df']
bb_equity_df = bb_result['equity_df']
ma_equity_df = ma_result['equity_df']
os_equity_df = os_result['equity_df']
ew_bh_equity_df = ew_bh_result['equity_df']
qqq_bh_equity_df = qqq_bh_result['equity_df'] if qqq_bh_result is not None else pd.DataFrame()

strategy_results = {
    ATR_NAME: {
        'type': 'trade_based',
        'result': atr_result,
        'equity_df': atr_equity_df,
        'signal': atr_result.get('agg_position', pd.Series(dtype=float)),
        'trade_book': atr_result.get('trade_book', pd.DataFrame()),
    },
    BB_NAME: {
        'type': 'trade_based',
        'result': bb_result,
        'equity_df': bb_equity_df,
        'signal': bb_result.get('agg_position', pd.Series(dtype=float)),
        'trade_book': bb_result.get('trade_book', pd.DataFrame()),
    },
    MA_NAME: {
        'type': 'return_based',
        'result': ma_result,
        'equity_df': ma_equity_df,
        'signal': ma_result.get('signal_series', pd.Series(dtype=float)),
        'trade_book': pd.DataFrame(),
    },
    OS_NAME: {
        'type': 'trade_based',
        'result': os_result,
        'equity_df': os_equity_df,
        'signal': os_result.get('agg_position', pd.Series(dtype=float)),
        'trade_book': os_result.get('trade_book', pd.DataFrame()),
    },
}

summary_rows = []

for strategy_name, pack in strategy_results.items():
    extra = None
    tb = pack['trade_book']

    if not tb.empty:
        extra = {
            'Trades': len(tb),
            'Win Rate': (tb['pnl'] > 0).mean(),
            'Avg PnL/Trade': tb['pnl'].mean(),
            'Avg Hold Days': tb['holding_days'].mean(),
        }

    summary_rows.append(build_summary_row(strategy_name, pack['equity_df'], extra))

summary_rows.append(build_summary_row(BH_NAME, ew_bh_equity_df))

if INCLUDE_QQQ_BASELINE:
    summary_rows.append(build_summary_row(QQQ_NAME, qqq_bh_equity_df))

summary_df = pd.DataFrame(summary_rows)
display(summary_df[['Strategy','Start','End','Total PnL','Max Drawdown','Sharpe']])
Strategy Start End Total PnL Max Drawdown Sharpe
0 ATR Exit 2018-03-23 2026-05-22 2030.52 -762.09 2.129670
1 Bollinger Band Breakout 2018-07-03 2026-05-18 2273.44 -617.20 4.357395
2 MA Crossover 2018-01-03 2026-05-22 437562.17 -87658.62 1.171002
3 Oversold Mean-Reversion 2018-03-28 2026-05-13 2003.75 -599.62 4.600733
4 Equal Weighted Buy and Hold 2018-01-03 2026-05-22 1099878.17 -237926.34 1.193515
5 QQQ Buy and Hold 2018-01-03 2026-05-22 378323.89 -92010.77 0.905338
In [7]:
all_strategy_frames = []
all_drawdown_frames = []

all_specs = [
    (ATR_NAME, atr_equity_df),
    (BB_NAME, bb_equity_df),
    (MA_NAME, ma_equity_df),
    (OS_NAME, os_equity_df),
    (BH_NAME, ew_bh_equity_df),
]

if INCLUDE_QQQ_BASELINE:
    all_specs.append((QQQ_NAME, qqq_bh_equity_df))

for name, eq_df in all_specs:
    if eq_df.empty:
        continue

    frame = eq_df[['date', 'daily_return', 'daily_pnl', 'cum_pnl', 'equity']].copy()
    frame['Strategy'] = name
    all_strategy_frames.append(frame)

    dd = eq_df[['date', 'equity']].copy()
    dd['drawdown'] = dd['equity'] - dd['equity'].cummax()
    dd['Strategy'] = name
    all_drawdown_frames.append(dd)

all_equity_df = pd.concat(all_strategy_frames, ignore_index=True) if all_strategy_frames else pd.DataFrame()
all_drawdown_df = pd.concat(all_drawdown_frames, ignore_index=True) if all_drawdown_frames else pd.DataFrame()

if not all_equity_df.empty:
    plt.figure(figsize=(8, 4.5))
    for name, group in all_equity_df.groupby('Strategy'):
        plt.plot(
            group['date'],
            group['equity'],
            label=name,
            color=COLORS.get(name, '#333'),
            linewidth=LWIDTHS.get(name, 1.5),
            linestyle='-' if name in [ATR_NAME, BB_NAME, MA_NAME, OS_NAME] else '--',
        )

    plt.ylabel('Portfolio equity ($)')
    plt.title('All strategy equity curves')
    plt.gca().yaxis.set_major_formatter(mticker.FuncFormatter(lambda y, _: f'${y:,.0f}'))
    plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%Y'))
    plt.gca().xaxis.set_major_locator(mdates.YearLocator())
    plt.legend(loc='upper left')
    plt.tick_params(axis='both', which='both', length=0)
    plt.tight_layout()
    plt.show()

if not all_drawdown_df.empty:
    plt.figure(figsize=(8, 4.5))
    for name, group in all_drawdown_df.groupby('Strategy'):
        plt.plot(
            group['date'],
            group['drawdown'],
            label=name,
            color=COLORS.get(name, '#333'),
            linewidth=LWIDTHS.get(name, 1.5),
            linestyle='-' if name in [ATR_NAME, BB_NAME, MA_NAME, OS_NAME] else '--',
        )

    plt.axhline(0, color=FL_GRID, linewidth=0.8)
    plt.ylabel('Drawdown ($)')
    plt.title('All strategy drawdowns')
    plt.gca().yaxis.set_major_formatter(mticker.FuncFormatter(lambda y, _: f'${y:,.0f}'))
    plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%Y'))
    plt.gca().xaxis.set_major_locator(mdates.YearLocator())
    plt.legend(loc='lower left')
    plt.tick_params(axis='both', which='both', length=0)
    plt.tight_layout()
    plt.show()
No description has been provided for this image
No description has been provided for this image
In [8]:
def build_comparison_frames(primary_strategy_name, primary_equity_df):
    comparison_frames = []
    drawdown_frames = []

    strategy_specs = [
        (primary_strategy_name, primary_equity_df),
        (BH_NAME, ew_bh_equity_df),
    ]

    if INCLUDE_QQQ_BASELINE:
        strategy_specs.append((QQQ_NAME, qqq_bh_equity_df))

    for name, eq_df in strategy_specs:
        if eq_df.empty:
            continue

        row = eq_df[['date', 'daily_return', 'daily_pnl', 'cum_pnl', 'equity']].copy()
        row['Strategy'] = name
        comparison_frames.append(row)

        dd = eq_df[['date', 'equity']].copy()
        dd['drawdown'] = dd['equity'] - dd['equity'].cummax()
        dd['Strategy'] = name
        drawdown_frames.append(dd)

    comparison_equity_df = pd.concat(comparison_frames, ignore_index=True) if comparison_frames else pd.DataFrame()
    drawdown_comparison_df = pd.concat(drawdown_frames, ignore_index=True) if drawdown_frames else pd.DataFrame()

    return comparison_equity_df, drawdown_comparison_df


def plot_equity_curves(comparison_equity_df, strategy_name):
    if comparison_equity_df.empty:
        return

    plt.figure(figsize=(8, 4.5))
    for name, group in comparison_equity_df.groupby('Strategy'):
        plt.plot(
            group['date'],
            group['equity'],
            label=name,
            color=COLORS.get(name, '#333'),
            linewidth=LWIDTHS.get(name, 1.5),
            linestyle='-' if name == strategy_name else '--',
        )

    plt.ylabel('Portfolio equity ($)')
    plt.title('Portfolio equity from $100,000 starting capital')
    plt.gca().yaxis.set_major_formatter(mticker.FuncFormatter(lambda y, _: f'${y:,.0f}'))
    plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%Y'))
    plt.gca().xaxis.set_major_locator(mdates.YearLocator())
    plt.legend(loc='upper left')
    plt.tick_params(axis='both', which='both', length=0)
    plt.tight_layout()
    plt.show()


def plot_cum_pnl(comparison_equity_df, strategy_name):
    if comparison_equity_df.empty:
        return

    plt.figure(figsize=(8, 4.5))
    for name, group in comparison_equity_df.groupby('Strategy'):
        plt.plot(
            group['date'],
            group['cum_pnl'],
            label=name,
            color=COLORS.get(name, '#333'),
            linewidth=LWIDTHS.get(name, 1.5),
            linestyle='-' if name == strategy_name else '--',
        )

    plt.axhline(0, color=FL_GRID, linewidth=0.8)
    plt.ylabel('Cumulative PnL ($)')
    plt.title('Cumulative profit and loss')
    plt.gca().yaxis.set_major_formatter(mticker.FuncFormatter(lambda y, _: f'${y:,.0f}'))
    plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%Y'))
    plt.gca().xaxis.set_major_locator(mdates.YearLocator())
    plt.legend(loc='upper left')
    plt.tick_params(axis='both', which='both', length=0)
    plt.tight_layout()
    plt.show()


def plot_drawdown(drawdown_comparison_df, strategy_name):
    if drawdown_comparison_df.empty:
        return

    plt.figure(figsize=(8, 4.5))
    for name, group in drawdown_comparison_df.groupby('Strategy'):
        plt.plot(
            group['date'],
            group['drawdown'],
            label=name,
            color=COLORS.get(name, '#333'),
            linewidth=LWIDTHS.get(name, 1.5),
            linestyle='-' if name == strategy_name else '--',
        )

    plt.axhline(0, color=FL_GRID, linewidth=0.8)
    plt.ylabel('Drawdown ($)')
    plt.title('Drawdown from equity peak')
    plt.gca().yaxis.set_major_formatter(mticker.FuncFormatter(lambda y, _: f'${y:,.0f}'))
    plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%Y'))
    plt.gca().xaxis.set_major_locator(mdates.YearLocator())
    plt.legend(loc='lower left')
    plt.tick_params(axis='both', which='both', length=0)
    plt.tight_layout()
    plt.show()


def plot_signal_state(signal_series, title):
    if signal_series is None or len(signal_series) == 0:
        return

    s = signal_series.dropna()
    if s.empty:
        return

    plt.figure(figsize=(8, 4.5))
    plt.fill_between(s.index, s.values, alpha=0.35, color=FL_RED)
    plt.plot(s.index, s.values, color=FL_RED, linewidth=1.0)
    plt.ylabel('Signal')
    plt.yticks([0, 1], ['Off', 'On'])
    plt.ylim(-0.05, 1.1)
    plt.title(title)
    plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%Y'))
    plt.gca().xaxis.set_major_locator(mdates.YearLocator())
    plt.tick_params(axis='both', which='both', length=0)
    plt.tight_layout()
    plt.show()


def plot_exit_reasons(trade_book, title):
    if trade_book is None or trade_book.empty or 'exit_reason' not in trade_book.columns:
        return

    reason_counts = trade_book['exit_reason'].value_counts().reset_index()
    reason_counts.columns = ['Exit Reason', 'Count']
    reason_counts['Share'] = reason_counts['Count'] / reason_counts['Count'].sum()

    plt.figure(figsize=(8, 4.5))
    bars = plt.barh(
        reason_counts['Exit Reason'],
        reason_counts['Count'],
        color=[FL_RED if r == 'stop_loss' else FL_GREEN if r == 'take_profit' else FL_SLATE for r in reason_counts['Exit Reason']],
        height=0.45
    )

    for bar, (_, rrow) in zip(bars, reason_counts.iterrows()):
        plt.text(
            bar.get_width() + 2,
            bar.get_y() + bar.get_height() / 2,
            f"{rrow['Share']:.1%}",
            va='center',
            fontsize=10,
            color=FL_TEXT2
        )

    plt.xlabel('Number of trades')
    plt.title(title)
    plt.gca().invert_yaxis()
    plt.tick_params(axis='both', which='both', length=0)
    plt.tight_layout()
    plt.show()

    reason_display = reason_counts.copy()
    reason_display['Count'] = reason_display['Count'].map(fmt_int)
    reason_display['Share'] = reason_display['Share'].map(fmt_pct)
    display(reason_display)


def display_strategy_summary(strategy_name):
    row = summary_df[summary_df['Strategy'] == strategy_name].copy()
    if row.empty:
        return

    display_view = row.copy()

    for col in ['Ending Equity', 'Total PnL', 'Max Drawdown']:
        if col in display_view.columns:
            display_view[col] = display_view[col].map(fmt_dollar)

    if 'CAGR' in display_view.columns:
        display_view['CAGR'] = display_view['CAGR'].map(fmt_pct)

    if 'Sharpe' in display_view.columns:
        display_view['Sharpe'] = display_view['Sharpe'].map(fmt_f2)

    if 'Trades' in display_view.columns:
        display_view['Trades'] = display_view['Trades'].map(lambda v: '-' if pd.isna(v) else fmt_int(v))

    if 'Win Rate' in display_view.columns:
        display_view['Win Rate'] = display_view['Win Rate'].map(fmt_pct_or_na)

    if 'Avg PnL/Trade' in display_view.columns:
        display_view['Avg PnL/Trade'] = display_view['Avg PnL/Trade'].map(lambda v: '-' if pd.isna(v) else f'${v:,.2f}')

    if 'Avg Hold Days' in display_view.columns:
        display_view['Avg Hold Days'] = display_view['Avg Hold Days'].map(lambda v: '-' if pd.isna(v) else f'{v:.1f}')

    display(display_view)

Moving Average Crossover

In [9]:
display_strategy_summary(MA_NAME)

ma_comparison_equity_df, ma_drawdown_df = build_comparison_frames(MA_NAME, ma_equity_df)

plot_signal_state(strategy_results[MA_NAME]['signal'], 'MA Crossover signal state over time')
plot_equity_curves(ma_comparison_equity_df, MA_NAME)
plot_cum_pnl(ma_comparison_equity_df, MA_NAME)
plot_drawdown(ma_drawdown_df, MA_NAME)
Strategy Start End Ending Equity Total PnL Max Drawdown CAGR Sharpe Trades Win Rate Avg PnL/Trade Avg Hold Days
2 MA Crossover 2018-01-03 2026-05-22 $537,562 $437,562 $-87,659 22.22% 1.17 - - - -
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
In [10]:
# display_strategy_summary(ATR_NAME)

# atr_comparison_equity_df, atr_drawdown_df = build_comparison_frames(ATR_NAME, atr_equity_df)

# plot_signal_state(strategy_results[ATR_NAME]['signal'], 'ATR Exit signal state over time')
# plot_exit_reasons(strategy_results[ATR_NAME]['trade_book'], 'ATR Exit exit reason distribution')
# plot_equity_curves(atr_comparison_equity_df, ATR_NAME)
# plot_cum_pnl(atr_comparison_equity_df, ATR_NAME)
# plot_drawdown(atr_drawdown_df, ATR_NAME)
In [11]:
# display_strategy_summary(BB_NAME)

# bb_comparison_equity_df, bb_drawdown_df = build_comparison_frames(BB_NAME, bb_equity_df)

# plot_signal_state(strategy_results[BB_NAME]['signal'], 'Bollinger Band Breakout signal state over time')
# plot_exit_reasons(strategy_results[BB_NAME]['trade_book'], 'Bollinger Band Breakout exit reason distribution')
# plot_equity_curves(bb_comparison_equity_df, BB_NAME)
# plot_cum_pnl(bb_comparison_equity_df, BB_NAME)
# plot_drawdown(bb_drawdown_df, BB_NAME)
In [12]:
# display_strategy_summary(OS_NAME)

# os_comparison_equity_df, os_drawdown_df = build_comparison_frames(OS_NAME, os_equity_df)

# plot_signal_state(strategy_results[OS_NAME]['signal'], 'Oversold Mean-Reversion signal state over time')
# plot_exit_reasons(strategy_results[OS_NAME]['trade_book'], 'Oversold Mean-Reversion exit reason distribution')
# plot_equity_curves(os_comparison_equity_df, OS_NAME)
# plot_cum_pnl(os_comparison_equity_df, OS_NAME)
# plot_drawdown(os_drawdown_df, OS_NAME)