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>
327 lines
13 KiB
Python
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)
|