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:
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
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.
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_holdrun_qqq_buy_and_hold
Then include these four strategy functions:
run_atr_exit_strategyrun_breakout_strategyrun_ma_crossover_strategyrun_oversold_strategy
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
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.
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 |
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()
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
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 | - | - | - | - |
# 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)
# 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)
# 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)