You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

456 lines
17 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""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