Files
momentum-system/comprehensive_backtest.py
T
momentum devandClaude Opus 4.8 007cd95c79 Baseline before Postgres migration (WS0)
Snapshot before the DuckDB->PostgreSQL migration. data/, archive/, the 232MB
rebuild zip, the Windows .venv, and .env are excluded via .gitignore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:39:29 -04:00

327 lines
13 KiB
Python

#!/usr/bin/env python3
"""Multi-week comprehensive backtest with detailed statistical analysis."""
from datetime import datetime, timedelta, date
import statistics
import pandas as pd
from rich.console import Console
from rich.table import Table
from config import Config
from research.connection import ResearchConnection
from research.query import query_minute_bars
from strategy.backtest.engine import BacktestEngine
console = Console()
def get_trading_days(start: date, end: date) -> list[date]:
"""Get all weekdays between start and end (trading days only)."""
days = []
current = start
while current <= end:
# Only include weekdays (0=Monday, 6=Sunday)
if current.weekday() < 5:
days.append(current)
current += timedelta(days=1)
return days
def run_backtest_multi_day(start_date: date, end_date: date, symbols: list[str] | None = None):
"""Run backtest across multiple days and aggregate results."""
config = Config()
con = ResearchConnection()
if symbols is None:
symbols = ["SNDL", "MARA", "RIOT", "BBAI", "SOUN", "PLUG"]
console.print(f"\n[bold blue]Comprehensive Backtest: {start_date} to {end_date}[/bold blue]")
console.print(f"[dim]Testing {len(symbols)} symbols across trading days[/dim]\n")
trading_days = get_trading_days(start_date, end_date)
console.print(f"[cyan]{len(trading_days)} trading days in range[/cyan]\n")
# Aggregate results
all_trades: list[dict] = []
daily_results: dict[date, dict] = {}
symbol_stats: dict[str, dict] = {}
engine = BacktestEngine(config)
# Progress bar
processed = 0
for trading_day in trading_days:
# Query minute bars for all symbols that day
day_symbols_with_data = []
day_bars = {}
for symbol in symbols:
try:
bars = query_minute_bars(con, symbol, trading_day)
if len(bars) > 0:
day_symbols_with_data.append(symbol)
day_bars[symbol] = bars
except Exception as e:
pass # No data for this day
if not day_symbols_with_data:
processed += 1
continue
# Initialize daily results if needed
if trading_day not in daily_results:
daily_results[trading_day] = {"trades": [], "pnl": 0.0}
# Run backtest for each symbol with data
for symbol in day_symbols_with_data:
bars = day_bars[symbol]
try:
result = engine.run(bars, symbol)
# Aggregate
daily_results[trading_day]["pnl"] += result.total_pnl
# Initialize symbol stats if needed
if symbol not in symbol_stats:
symbol_stats[symbol] = {"trades": [], "wins": 0, "losses": 0, "pnl": 0.0}
for trade in result.trades:
trade_data = {
"date": trading_day.isoformat(),
"symbol": symbol,
"entry_time": trade.entry_time,
"entry_price": trade.entry_price,
"stop_price": trade.stop_price,
"target_price": trade.target_price,
"quantity": trade.quantity,
"exit_price": trade.exit_price,
"exit_reason": trade.exit_reason,
"pnl": trade.realized_pnl or 0.0,
"r_multiple": trade.r_multiple or 0.0,
}
all_trades.append(trade_data)
daily_results[trading_day]["trades"].append(trade_data)
# Symbol stats
symbol_stats[symbol]["trades"].append(trade_data)
symbol_stats[symbol]["pnl"] += trade.realized_pnl or 0.0
if (trade.realized_pnl or 0) > 0:
symbol_stats[symbol]["wins"] += 1
else:
symbol_stats[symbol]["losses"] += 1
except Exception as e:
console.print(f"[red]Error backtesting {symbol} on {trading_day}: {e}[/red]")
processed += 1
console.print(f"[dim]Processed {processed}/{len(trading_days)} days[/dim]", end="\r")
console.print()
# === SUMMARY STATISTICS ===
if not all_trades:
console.print("[yellow]No trades found in backtest range[/yellow]")
return
# Daily summary
console.print("\n[bold cyan]=== DAILY SUMMARY ===[/bold cyan]")
daily_table = Table(title="P&L by Day")
daily_table.add_column("Date", style="cyan")
daily_table.add_column("Trades", justify="right")
daily_table.add_column("P&L", justify="right")
daily_pnls = []
for day in sorted(daily_results.keys()):
daily_pnl = daily_results[day]["pnl"]
daily_pnls.append(daily_pnl)
color = "green" if daily_pnl > 0 else "red"
daily_table.add_row(
str(day),
str(len(daily_results[day]["trades"])),
f"[{color}]${daily_pnl:,.2f}[/{color}]"
)
console.print(daily_table)
# Symbol summary
console.print("\n[bold cyan]=== SYMBOL SUMMARY ===[/bold cyan]")
symbol_table = Table(title="Results by Symbol")
symbol_table.add_column("Symbol", style="cyan")
symbol_table.add_column("Trades", justify="right")
symbol_table.add_column("Wins", justify="right")
symbol_table.add_column("Win %", justify="right")
symbol_table.add_column("P&L", justify="right")
symbol_table.add_column("Avg R", justify="right")
for symbol in sorted(symbol_stats.keys()):
stats = symbol_stats[symbol]
trades = stats["trades"]
if not trades:
continue
win_pct = (stats["wins"] / len(trades)) * 100 if trades else 0
avg_r = statistics.mean([t["r_multiple"] for t in trades]) if trades else 0
color = "green" if stats["pnl"] > 0 else "red"
symbol_table.add_row(
symbol,
str(len(trades)),
str(stats["wins"]),
f"{win_pct:.1f}%",
f"[{color}]${stats['pnl']:,.2f}[/{color}]",
f"{avg_r:.2f}R"
)
console.print(symbol_table)
# Overall stats
total_trades = len(all_trades)
total_pnl = sum(t["pnl"] for t in all_trades)
wins = len([t for t in all_trades if t["pnl"] > 0])
losses = len([t for t in all_trades if t["pnl"] < 0])
win_rate = (wins / total_trades) * 100 if total_trades else 0
r_multiples = [t["r_multiple"] for t in all_trades]
avg_r = statistics.mean(r_multiples) if r_multiples else 0
# Separate wins and losses for analysis
win_trades = [t for t in all_trades if t["pnl"] > 0]
loss_trades = [t for t in all_trades if t["pnl"] < 0]
avg_win = statistics.mean([t["pnl"] for t in win_trades]) if win_trades else 0
avg_loss = statistics.mean([t["pnl"] for t in loss_trades]) if loss_trades else 0
max_loss = min([t["pnl"] for t in loss_trades]) if loss_trades else 0
profit_factor = (sum([t["pnl"] for t in win_trades]) / abs(sum([t["pnl"] for t in loss_trades]))) if loss_trades and sum([t["pnl"] for t in loss_trades]) < 0 else 0
console.print("\n[bold cyan]=== OVERALL STATISTICS ===[/bold cyan]")
console.print(f"Total Trades: {total_trades}")
console.print(f"Wins: {wins} | Losses: {losses}")
console.print(f"Win Rate: {win_rate:.1f}%")
console.print(f"Total P&L: [{'green' if total_pnl > 0 else 'red'}]${total_pnl:,.2f}[/{'green' if total_pnl > 0 else 'red'}]")
console.print(f"Avg Win: ${avg_win:,.2f} | Avg Loss: ${avg_loss:,.2f}")
console.print(f"Max Loss: ${max_loss:,.2f}")
if profit_factor > 0:
console.print(f"Profit Factor: {profit_factor:.2f}x")
console.print(f"Avg R-Multiple: {avg_r:.2f}R")
# === LOSS ANALYSIS ===
if loss_trades:
console.print("\n[bold yellow]=== LOSS ANALYSIS ===[/bold yellow]")
console.print(f"Total Losing Trades: {len(loss_trades)}")
loss_table = Table(title="Top 10 Losses (for pattern analysis)")
loss_table.add_column("Date", style="cyan")
loss_table.add_column("Symbol", style="cyan")
loss_table.add_column("Entry", justify="right")
loss_table.add_column("Stop", justify="right")
loss_table.add_column("Exit", justify="right")
loss_table.add_column("P&L", justify="right")
loss_table.add_column("Reason")
sorted_losses = sorted(loss_trades, key=lambda t: t["pnl"])
for trade in sorted_losses[:10]:
loss_table.add_row(
trade["date"],
trade["symbol"],
f"${trade['entry_price']:.2f}",
f"${trade['stop_price']:.2f}",
f"${trade['exit_price']:.2f}" if trade["exit_price"] else "-",
f"[red]${trade['pnl']:,.2f}[/red]",
trade["exit_reason"] or "?"
)
console.print(loss_table)
# Loss metrics
stop_hits = len([t for t in loss_trades if "stop" in (t["exit_reason"] or "").lower()])
console.print(f"\n[dim]Losses by reason:[/dim]")
console.print(f" Hit stop: {stop_hits}/{len(loss_trades)}")
# Gap-through analysis
for trade in sorted_losses[:5]:
entry = trade["entry_price"]
stop = trade["stop_price"]
exit_price = trade["exit_price"]
if exit_price and exit_price < stop:
gap_pct = ((stop - exit_price) / stop) * 100
console.print(f" [yellow]{trade['symbol']} {trade['date']}: gap-through {gap_pct:.2f}% ({stop:.2f} → {exit_price:.2f})[/yellow]")
# === WIN ANALYSIS ===
if win_trades:
console.print("\n[bold green]=== WIN ANALYSIS ===[/bold green]")
console.print(f"Total Winning Trades: {len(win_trades)}")
win_table = Table(title="Recent Wins")
win_table.add_column("Date", style="cyan")
win_table.add_column("Symbol", style="cyan")
win_table.add_column("Entry", justify="right")
win_table.add_column("Target", justify="right")
win_table.add_column("Exit", justify="right")
win_table.add_column("P&L", justify="right")
win_table.add_column("R")
sorted_wins = sorted(win_trades, key=lambda t: t["pnl"], reverse=True)
for trade in sorted_wins[:5]:
win_table.add_row(
trade["date"],
trade["symbol"],
f"${trade['entry_price']:.2f}",
f"${trade['target_price']:.2f}",
f"${trade['exit_price']:.2f}" if trade["exit_price"] else "-",
f"[green]${trade['pnl']:,.2f}[/green]",
f"{trade['r_multiple']:.2f}R"
)
console.print(win_table)
# === RISK ASSESSMENT ===
console.print("\n[bold yellow]=== RISK ASSESSMENT ===[/bold yellow]")
if avg_loss != 0:
expectancy = (win_rate / 100 * avg_win) + ((1 - win_rate / 100) * avg_loss)
console.print(f"Expected value per trade: ${expectancy:,.2f}")
if profit_factor > 0:
if profit_factor < 1.5:
console.print("[yellow]⚠️ Profit factor < 1.5 indicates marginal edge or slippage issues[/yellow]")
else:
console.print("[green]✓ Profit factor suggests adequate win/loss ratio[/green]")
# Check for consistent losses
max_win = max([t["pnl"] for t in win_trades]) if win_trades else 0
if max_loss < 0 and abs(max_loss) > max_win * 1.5:
console.print(f"[red]⚠️ Max loss (${max_loss:,.2f}) >> max win (${max_win:,.2f}) — stop is too wide or slippage is severe[/red]")
# Sharpe-ish ratio (simplified)
if len(daily_pnls) > 1:
daily_std = statistics.stdev(daily_pnls) if len(daily_pnls) > 1 else 0
if daily_std > 0:
daily_mean = statistics.mean(daily_pnls)
sharpe_approx = daily_mean / daily_std if daily_std > 0 else 0
console.print(f"Daily Sharpe (approx): {sharpe_approx:.2f}")
console.print("\n[dim]Recommendations:[/dim]")
if win_rate < 40:
console.print("[red]• Win rate < 40% — strategy may lack edge even with profitable trades[/red]")
if profit_factor < 1.3 and profit_factor > 0:
console.print("[yellow]• Profit factor < 1.3 — consider tightening stops or improving entry[/yellow]")
if len(loss_trades) > 0 and max_loss < avg_loss / 2:
console.print("[yellow]• Losses are inconsistent — gap-throughs may be a factor[/yellow]")
console.print()
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Multi-week comprehensive backtest")
parser.add_argument("--start", type=str, default="2026-06-05", help="Start date (YYYY-MM-DD)")
parser.add_argument("--end", type=str, default="2026-06-12", help="End date (YYYY-MM-DD)")
parser.add_argument("--symbols", type=str, default="SNDL,MARA,RIOT,BBAI,SOUN,PLUG", help="Comma-separated symbols")
args = parser.parse_args()
start = datetime.strptime(args.start, "%Y-%m-%d").date()
end = datetime.strptime(args.end, "%Y-%m-%d").date()
symbols = args.symbols.split(",")
run_backtest_multi_day(start, end, symbols)