|
|
"""Performance metrics and reporting for the intraday backtester.
|
|
|
|
|
|
All metric functions are pure (no side effects).
|
|
|
Uses rich for terminal output formatting.
|
|
|
"""
|
|
|
from __future__ import annotations
|
|
|
|
|
|
import hashlib
|
|
|
import json
|
|
|
import math
|
|
|
import statistics
|
|
|
import uuid
|
|
|
from datetime import datetime
|
|
|
from pathlib import Path
|
|
|
from typing import Any
|
|
|
|
|
|
from libs.intraday.domain import (
|
|
|
DayResult,
|
|
|
IntradayConfig,
|
|
|
IntradayMetrics,
|
|
|
IntradayTrade,
|
|
|
SweepResult,
|
|
|
)
|
|
|
|
|
|
|
|
|
# ── Metric Computation ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
def _get_initial_capital(config: IntradayConfig) -> float:
|
|
|
"""Get initial capital from the active strategy (ORB or momentum)."""
|
|
|
if getattr(config, "strategy_mode", "momentum") == "orb" and config.orb_strategy:
|
|
|
return config.orb_strategy.initial_capital
|
|
|
return config.strategy.initial_capital
|
|
|
|
|
|
|
|
|
def compute_metrics(
|
|
|
day_results: list[DayResult],
|
|
|
config: IntradayConfig,
|
|
|
run_id: str = "",
|
|
|
) -> IntradayMetrics:
|
|
|
"""Compute all performance metrics from simulation results."""
|
|
|
all_trades: list[IntradayTrade] = [t for r in day_results for t in r.trades]
|
|
|
# Include ALL days (0% for no-trade days) — idle capital dilutes Sharpe correctly
|
|
|
daily_returns = [r.daily_return_pct for r in day_results]
|
|
|
initial_capital = _get_initial_capital(config)
|
|
|
|
|
|
if not all_trades:
|
|
|
return IntradayMetrics(
|
|
|
run_id=run_id or str(uuid.uuid4())[:8],
|
|
|
initial_capital=initial_capital,
|
|
|
final_equity=initial_capital,
|
|
|
)
|
|
|
|
|
|
# Build equity curve
|
|
|
equity = initial_capital
|
|
|
equity_curve: list[float] = [equity]
|
|
|
for r in day_results:
|
|
|
equity += r.daily_pnl
|
|
|
equity_curve.append(equity)
|
|
|
|
|
|
# Win/loss
|
|
|
wins = [t for t in all_trades if t.pnl > 0]
|
|
|
losses = [t for t in all_trades if t.pnl <= 0]
|
|
|
win_rate = len(wins) / len(all_trades) if all_trades else None
|
|
|
avg_win_pct = statistics.mean(t.pnl_pct for t in wins) if wins else None
|
|
|
avg_loss_pct = statistics.mean(t.pnl_pct for t in losses) if losses else None
|
|
|
|
|
|
gross_profit = sum(t.pnl for t in wins)
|
|
|
gross_loss = abs(sum(t.pnl for t in losses))
|
|
|
profit_factor = gross_profit / gross_loss if gross_loss > 0 else None
|
|
|
expectancy_pct = (
|
|
|
(win_rate * avg_win_pct + (1 - win_rate) * avg_loss_pct)
|
|
|
if win_rate is not None and avg_win_pct is not None and avg_loss_pct is not None
|
|
|
else None
|
|
|
)
|
|
|
|
|
|
# Returns
|
|
|
total_return_pct = (equity_curve[-1] - equity_curve[0]) / equity_curve[0]
|
|
|
n_days = len(day_results)
|
|
|
annualized = total_return_pct * (252 / n_days) if n_days > 0 else None
|
|
|
avg_daily = statistics.mean(daily_returns) if daily_returns else None
|
|
|
|
|
|
# Max drawdown
|
|
|
max_eq = equity_curve[0]
|
|
|
max_dd = 0.0
|
|
|
for eq in equity_curve:
|
|
|
max_eq = max(max_eq, eq)
|
|
|
dd = (eq - max_eq) / max_eq
|
|
|
max_dd = min(max_dd, dd)
|
|
|
|
|
|
# Sharpe / Sortino (annualized, assuming 252 trading days)
|
|
|
sharpe = sortino = calmar = None
|
|
|
if len(daily_returns) >= 5:
|
|
|
try:
|
|
|
mean_r = statistics.mean(daily_returns)
|
|
|
std_r = statistics.stdev(daily_returns)
|
|
|
if std_r > 0:
|
|
|
sharpe = (mean_r / std_r) * math.sqrt(252)
|
|
|
down_devs = [r for r in daily_returns if r < 0]
|
|
|
if down_devs:
|
|
|
downside_std = math.sqrt(
|
|
|
sum(r ** 2 for r in down_devs) / len(daily_returns)
|
|
|
)
|
|
|
if downside_std > 0:
|
|
|
sortino = (mean_r / downside_std) * math.sqrt(252)
|
|
|
except Exception:
|
|
|
pass
|
|
|
|
|
|
if annualized is not None and max_dd < 0:
|
|
|
calmar = annualized / abs(max_dd)
|
|
|
|
|
|
# Intraday-specific
|
|
|
stop_exits = [t for t in all_trades if t.exit_reason == "stop_loss"]
|
|
|
stop_pct = len(stop_exits) / len(all_trades) if all_trades else None
|
|
|
|
|
|
# Average hold time (in minutes)
|
|
|
hold_minutes: list[float] = []
|
|
|
for t in all_trades:
|
|
|
try:
|
|
|
from zoneinfo import ZoneInfo
|
|
|
_ET = ZoneInfo("America/New_York")
|
|
|
entry = datetime.fromisoformat(t.entry_time.replace("Z", "+00:00"))
|
|
|
exit_ = datetime.fromisoformat(t.exit_time.replace("Z", "+00:00"))
|
|
|
hold_minutes.append((exit_ - entry).total_seconds() / 60)
|
|
|
except Exception:
|
|
|
pass
|
|
|
|
|
|
days_with_trades = sum(1 for r in day_results if r.trades)
|
|
|
|
|
|
# Date range from day_results
|
|
|
dates = sorted(r.date for r in day_results)
|
|
|
start_date = dates[0] if dates else ""
|
|
|
end_date = dates[-1] if dates else ""
|
|
|
|
|
|
is_orb = getattr(config, "strategy_mode", "momentum") == "orb"
|
|
|
active_strategy = config.orb_strategy if (is_orb and config.orb_strategy) else config.strategy
|
|
|
|
|
|
return IntradayMetrics(
|
|
|
run_id=run_id or str(uuid.uuid4())[:8],
|
|
|
params_hash=_hash_strategy(active_strategy),
|
|
|
start_date=start_date,
|
|
|
end_date=end_date,
|
|
|
trading_days=n_days,
|
|
|
days_with_trades=days_with_trades,
|
|
|
total_trades=len(all_trades),
|
|
|
stop_loss_exits=len(stop_exits),
|
|
|
win_rate=round(win_rate, 4) if win_rate is not None else None,
|
|
|
avg_win_pct=round(avg_win_pct, 4) if avg_win_pct is not None else None,
|
|
|
avg_loss_pct=round(avg_loss_pct, 4) if avg_loss_pct is not None else None,
|
|
|
profit_factor=round(profit_factor, 4) if profit_factor is not None else None,
|
|
|
expectancy_pct=round(expectancy_pct, 4) if expectancy_pct is not None else None,
|
|
|
total_return_pct=round(total_return_pct, 4),
|
|
|
annualized_return_pct=round(annualized, 4) if annualized is not None else None,
|
|
|
avg_daily_return_pct=round(avg_daily, 6) if avg_daily is not None else None,
|
|
|
max_drawdown_pct=round(max_dd, 4),
|
|
|
sharpe_ratio=round(sharpe, 4) if sharpe is not None else None,
|
|
|
sortino_ratio=round(sortino, 4) if sortino is not None else None,
|
|
|
calmar_ratio=round(calmar, 4) if calmar is not None else None,
|
|
|
avg_hold_minutes=round(statistics.mean(hold_minutes), 1) if hold_minutes else None,
|
|
|
stop_loss_exit_pct=round(stop_pct, 4) if stop_pct is not None else None,
|
|
|
initial_capital=initial_capital,
|
|
|
final_equity=round(equity_curve[-1], 2),
|
|
|
)
|
|
|
|
|
|
|
|
|
def _hash_strategy(strategy: Any) -> str:
|
|
|
s = json.dumps(strategy.model_dump(), sort_keys=True, default=str)
|
|
|
return hashlib.md5(s.encode()).hexdigest()[:8]
|
|
|
|
|
|
|
|
|
# ── Reporting ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
def format_summary(metrics: IntradayMetrics, config: IntradayConfig) -> str:
|
|
|
"""Format summary table for terminal output using rich."""
|
|
|
from rich.console import Console
|
|
|
from rich.table import Table
|
|
|
from io import StringIO
|
|
|
|
|
|
buf = StringIO()
|
|
|
console = Console(file=buf, width=80)
|
|
|
|
|
|
# Header
|
|
|
console.print()
|
|
|
is_orb = getattr(config, "strategy_mode", "momentum") == "orb"
|
|
|
title = "Opening Range Breakout (ORB) Results" if is_orb else "Morning Momentum Backtest Results"
|
|
|
console.print(
|
|
|
f"[bold cyan]{title}[/bold cyan] "
|
|
|
f"[dim]{metrics.start_date} → {metrics.end_date}[/dim]"
|
|
|
)
|
|
|
if is_orb and config.orb_strategy:
|
|
|
p = config.orb_strategy
|
|
|
console.print(
|
|
|
f"[dim]Universe: {config.universe.source} | "
|
|
|
f"ORB: {p.orb_minutes}min | "
|
|
|
f"Stop: {p.atr_stop_multiplier*100:.0f}%×ATR | "
|
|
|
f"Risk: {p.risk_per_trade_pct*100:.2f}%/trade | "
|
|
|
f"MinRVOL: {p.min_rvol:.1f}x | "
|
|
|
f"Exit: -{p.exit_minutes_before_close}min[/dim]"
|
|
|
)
|
|
|
else:
|
|
|
console.print(
|
|
|
f"[dim]Universe: {config.universe.source} | "
|
|
|
f"Entry: +{config.strategy.entry_minutes_after_open}min | "
|
|
|
f"Exit: -{config.strategy.exit_minutes_before_close}min | "
|
|
|
f"Stop: {config.strategy.stop_loss_pct or 'none'} | "
|
|
|
f"Top N: {config.strategy.top_n}[/dim]"
|
|
|
)
|
|
|
console.print()
|
|
|
|
|
|
t = Table(show_header=True, header_style="bold")
|
|
|
t.add_column("Metric", style="cyan")
|
|
|
t.add_column("Value", justify="right")
|
|
|
|
|
|
def _pct(v: float | None, decimals: int = 2) -> str:
|
|
|
if v is None:
|
|
|
return "—"
|
|
|
return f"{v*100:+.{decimals}f}%"
|
|
|
|
|
|
def _f(v: float | None, decimals: int = 2) -> str:
|
|
|
if v is None:
|
|
|
return "—"
|
|
|
return f"{v:.{decimals}f}"
|
|
|
|
|
|
t.add_row("Period", f"{metrics.trading_days} days ({metrics.days_with_trades} with trades)")
|
|
|
t.add_row("Total trades", str(metrics.total_trades))
|
|
|
t.add_row("Stop-loss exits", str(metrics.stop_loss_exits))
|
|
|
t.add_section()
|
|
|
t.add_row("Total return", _pct(metrics.total_return_pct))
|
|
|
t.add_row("Annualized return", _pct(metrics.annualized_return_pct))
|
|
|
t.add_row("Final equity", f"${metrics.final_equity:,.2f}")
|
|
|
t.add_section()
|
|
|
t.add_row("Win rate", _pct(metrics.win_rate, 1))
|
|
|
t.add_row("Avg winner", _pct(metrics.avg_win_pct))
|
|
|
t.add_row("Avg loser", _pct(metrics.avg_loss_pct))
|
|
|
t.add_row("Profit factor", _f(metrics.profit_factor))
|
|
|
t.add_row("Expectancy", _pct(metrics.expectancy_pct))
|
|
|
t.add_section()
|
|
|
t.add_row("Max drawdown", _pct(metrics.max_drawdown_pct))
|
|
|
t.add_row("Sharpe ratio", _f(metrics.sharpe_ratio))
|
|
|
t.add_row("Sortino ratio", _f(metrics.sortino_ratio))
|
|
|
t.add_row("Calmar ratio", _f(metrics.calmar_ratio))
|
|
|
t.add_section()
|
|
|
t.add_row("Avg hold (min)", _f(metrics.avg_hold_minutes, 0))
|
|
|
t.add_row("Stop-loss rate", _pct(metrics.stop_loss_exit_pct, 1))
|
|
|
|
|
|
console.print(t)
|
|
|
return buf.getvalue()
|
|
|
|
|
|
|
|
|
def format_daily_breakdown(day_results: list[DayResult]) -> str:
|
|
|
"""Format per-day P&L breakdown table."""
|
|
|
from rich.console import Console
|
|
|
from rich.table import Table
|
|
|
from io import StringIO
|
|
|
|
|
|
trading_days = [r for r in day_results if r.trades]
|
|
|
if not trading_days:
|
|
|
return "No trades.\n"
|
|
|
|
|
|
buf = StringIO()
|
|
|
console = Console(file=buf, width=120)
|
|
|
|
|
|
t = Table(show_header=True, header_style="bold", title="Daily Breakdown")
|
|
|
t.add_column("Date", style="cyan")
|
|
|
t.add_column("#", justify="right")
|
|
|
t.add_column("P&L", justify="right")
|
|
|
t.add_column("Return", justify="right")
|
|
|
t.add_column("Tickers")
|
|
|
|
|
|
for r in trading_days:
|
|
|
tickers_str = " ".join(
|
|
|
f"{tr.ticker}([green]+{tr.pnl_pct*100:.1f}%[/green])" if tr.pnl > 0
|
|
|
else f"{tr.ticker}([red]{tr.pnl_pct*100:.1f}%[/red])"
|
|
|
for tr in r.trades
|
|
|
)
|
|
|
pnl_str = f"[green]${r.daily_pnl:+,.2f}[/green]" if r.daily_pnl >= 0 else f"[red]${r.daily_pnl:+,.2f}[/red]"
|
|
|
ret_str = f"[green]{r.daily_return_pct*100:+.2f}%[/green]" if r.daily_return_pct >= 0 else f"[red]{r.daily_return_pct*100:+.2f}%[/red]"
|
|
|
t.add_row(r.date, str(len(r.trades)), pnl_str, ret_str, tickers_str)
|
|
|
|
|
|
console.print(t)
|
|
|
return buf.getvalue()
|
|
|
|
|
|
|
|
|
def format_top_trades(day_results: list[DayResult], n: int = 5) -> str:
|
|
|
"""Format best and worst N trades."""
|
|
|
from rich.console import Console
|
|
|
from rich.table import Table
|
|
|
from io import StringIO
|
|
|
|
|
|
all_trades = [t for r in day_results for t in r.trades]
|
|
|
if not all_trades:
|
|
|
return ""
|
|
|
|
|
|
buf = StringIO()
|
|
|
console = Console(file=buf, width=100)
|
|
|
|
|
|
sorted_trades = sorted(all_trades, key=lambda t: t.pnl_pct, reverse=True)
|
|
|
|
|
|
for label, trades in [("Top Winners", sorted_trades[:n]), ("Top Losers", sorted_trades[-n:])]:
|
|
|
t = Table(show_header=True, header_style="bold", title=label)
|
|
|
t.add_column("Date")
|
|
|
t.add_column("Ticker")
|
|
|
t.add_column("Entry", justify="right")
|
|
|
t.add_column("Exit", justify="right")
|
|
|
t.add_column("Return", justify="right")
|
|
|
t.add_column("P&L", justify="right")
|
|
|
t.add_column("Morn Gain", justify="right")
|
|
|
t.add_column("Exit Reason")
|
|
|
for tr in trades:
|
|
|
color = "green" if tr.pnl >= 0 else "red"
|
|
|
t.add_row(
|
|
|
tr.date, tr.ticker,
|
|
|
f"${tr.entry_price:.2f}", f"${tr.exit_price:.2f}",
|
|
|
f"[{color}]{tr.pnl_pct*100:+.2f}%[/{color}]",
|
|
|
f"[{color}]${tr.pnl:+.2f}[/{color}]",
|
|
|
f"{tr.morning_gain_pct*100:+.2f}%",
|
|
|
tr.exit_reason,
|
|
|
)
|
|
|
console.print(t)
|
|
|
|
|
|
return buf.getvalue()
|
|
|
|
|
|
|
|
|
def format_sweep_comparison(sweep_results: list[SweepResult], top_n: int = 20) -> str:
|
|
|
"""Format sweep results as a ranked comparison table (sorted by Sharpe).
|
|
|
|
|
|
Automatically detects ORB vs momentum by inspecting params keys.
|
|
|
"""
|
|
|
from rich.console import Console
|
|
|
from rich.table import Table
|
|
|
from io import StringIO
|
|
|
|
|
|
buf = StringIO()
|
|
|
console = Console(file=buf, width=160)
|
|
|
|
|
|
# Sort by Sharpe descending, then total_return
|
|
|
ranked = sorted(
|
|
|
sweep_results,
|
|
|
key=lambda r: (r.metrics.sharpe_ratio or -999, r.metrics.total_return_pct or -999),
|
|
|
reverse=True,
|
|
|
)[:top_n]
|
|
|
|
|
|
# Detect strategy from first result's params
|
|
|
is_orb = bool(ranked) and "atr_stop_multiplier" in ranked[0].params
|
|
|
|
|
|
t = Table(show_header=True, header_style="bold", title=f"Sweep Results — Top {top_n} by Sharpe")
|
|
|
t.add_column("#", justify="right")
|
|
|
|
|
|
if is_orb:
|
|
|
t.add_column("ATR\nMult", justify="right")
|
|
|
t.add_column("Min\nRVOL", justify="right")
|
|
|
t.add_column("MaxCand", justify="right")
|
|
|
t.add_column("BE\n@R", justify="right")
|
|
|
t.add_column("Trail\n@R", justify="right")
|
|
|
t.add_column("Timeout\n(min)", justify="right")
|
|
|
t.add_column("Risk\n%", justify="right")
|
|
|
else:
|
|
|
t.add_column("Entry\n(min)", justify="right")
|
|
|
t.add_column("Exit\n(min)", justify="right")
|
|
|
t.add_column("Stop\n(%)", justify="right")
|
|
|
t.add_column("Max\nGain%", justify="right")
|
|
|
t.add_column("Min\nGain%", justify="right")
|
|
|
t.add_column("Cooldown\n(days)", justify="right")
|
|
|
t.add_column("MinVol\n(K)", justify="right")
|
|
|
t.add_column("Top\nN", justify="right")
|
|
|
|
|
|
t.add_column("Return", justify="right")
|
|
|
t.add_column("Ann\nReturn", justify="right")
|
|
|
t.add_column("Sharpe", justify="right")
|
|
|
t.add_column("Max\nDD%", justify="right")
|
|
|
t.add_column("Win\n%", justify="right")
|
|
|
t.add_column("PF", justify="right")
|
|
|
t.add_column("Trades", justify="right")
|
|
|
|
|
|
for i, sr in enumerate(ranked, 1):
|
|
|
m = sr.metrics
|
|
|
p = sr.params
|
|
|
sharpe_str = f"{m.sharpe_ratio:.2f}" if m.sharpe_ratio is not None else "—"
|
|
|
color = "green" if (m.total_return_pct or 0) >= 0 else "red"
|
|
|
|
|
|
if is_orb:
|
|
|
param_cells = [
|
|
|
f"{p.get('atr_stop_multiplier', 0)*100:.0f}%",
|
|
|
f"{p.get('min_rvol', 0):.1f}x",
|
|
|
str(p.get("max_candidates", "")),
|
|
|
f"{p.get('breakeven_at_r', '')}R",
|
|
|
f"{p.get('trailing_at_r', '')}R",
|
|
|
str(p.get("order_timeout_minutes", "")),
|
|
|
f"{p.get('risk_per_trade_pct', 0)*100:.2f}%",
|
|
|
]
|
|
|
else:
|
|
|
stop = f"{p.get('stop_loss_pct', '')*100:.0f}" if p.get("stop_loss_pct") else "none"
|
|
|
param_cells = [
|
|
|
str(p.get("entry_minutes_after_open", "")),
|
|
|
str(p.get("exit_minutes_before_close", "")),
|
|
|
stop,
|
|
|
f"{p.get('max_morning_gain_pct', 0)*100:.0f}" if p.get("max_morning_gain_pct") is not None else "none",
|
|
|
f"{p.get('min_morning_gain_pct', 0)*100:.1f}",
|
|
|
str(p.get("ticker_cooldown_days", 0)),
|
|
|
f"{p.get('min_entry_volume', 0)//1000:.0f}" if p.get("min_entry_volume") else "—",
|
|
|
str(p.get("top_n", "")),
|
|
|
]
|
|
|
|
|
|
t.add_row(
|
|
|
str(i),
|
|
|
*param_cells,
|
|
|
f"[{color}]{(m.total_return_pct or 0)*100:+.1f}%[/{color}]",
|
|
|
f"[{color}]{(m.annualized_return_pct or 0)*100:+.1f}%[/{color}]",
|
|
|
sharpe_str,
|
|
|
f"{(m.max_drawdown_pct or 0)*100:.1f}%",
|
|
|
f"{(m.win_rate or 0)*100:.1f}%",
|
|
|
f"{m.profit_factor:.2f}" if m.profit_factor else "—",
|
|
|
str(m.total_trades),
|
|
|
)
|
|
|
|
|
|
console.print(t)
|
|
|
return buf.getvalue()
|
|
|
|
|
|
|
|
|
def write_results(
|
|
|
metrics: IntradayMetrics,
|
|
|
day_results: list[DayResult],
|
|
|
config: IntradayConfig,
|
|
|
output_dir: str,
|
|
|
) -> Path:
|
|
|
"""Write full results to a JSON file in the output directory."""
|
|
|
out = Path(output_dir)
|
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
|
filename = out / f"intraday_{ts}_{metrics.run_id}.json"
|
|
|
|
|
|
all_trades = [t.model_dump() for r in day_results for t in r.trades]
|
|
|
|
|
|
payload = {
|
|
|
"run_id": metrics.run_id,
|
|
|
"generated_at": datetime.now().isoformat(),
|
|
|
"config": config.model_dump(),
|
|
|
"metrics": metrics.model_dump(),
|
|
|
"trades": all_trades,
|
|
|
"daily_summary": [
|
|
|
{
|
|
|
"date": r.date,
|
|
|
"daily_pnl": r.daily_pnl,
|
|
|
"daily_return_pct": r.daily_return_pct,
|
|
|
"candidates_found": r.candidates_found,
|
|
|
"trades": len(r.trades),
|
|
|
}
|
|
|
for r in day_results
|
|
|
],
|
|
|
}
|
|
|
|
|
|
filename.write_text(json.dumps(payload, indent=2, default=str))
|
|
|
return filename
|