1017 lines
34 KiB
Python
1017 lines
34 KiB
Python
"""Results Analyzer for backtest trade analysis.
|
|
|
|
Issue #43: [BT-42] Results analyzer - metrics, trade analysis
|
|
|
|
This module provides detailed analysis of backtest results:
|
|
- Trade-by-trade analysis
|
|
- Performance metrics calculation
|
|
- Risk metrics computation
|
|
- Monthly/yearly performance breakdowns
|
|
- Trade pattern analysis
|
|
- Benchmark comparison
|
|
|
|
Classes:
|
|
TimeFrame: Analysis time frame enum
|
|
TradeAnalysis: Individual trade analysis
|
|
PerformanceBreakdown: Performance by period
|
|
RiskMetrics: Risk-related metrics
|
|
BenchmarkComparison: Comparison to benchmark
|
|
AnalysisResult: Complete analysis result
|
|
ResultsAnalyzer: Main analyzer class
|
|
|
|
Example:
|
|
>>> from tradingagents.backtest import BacktestResult
|
|
>>> from tradingagents.backtest.results_analyzer import ResultsAnalyzer
|
|
>>>
|
|
>>> analyzer = ResultsAnalyzer()
|
|
>>> analysis = analyzer.analyze(backtest_result)
|
|
>>> print(f"Sharpe: {analysis.risk_metrics.sharpe_ratio}")
|
|
"""
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timedelta
|
|
from decimal import Decimal
|
|
from enum import Enum
|
|
from typing import Any, Optional
|
|
import logging
|
|
import math
|
|
|
|
from .backtest_engine import (
|
|
BacktestResult,
|
|
BacktestTrade,
|
|
BacktestSnapshot,
|
|
OrderSide,
|
|
ZERO,
|
|
ONE,
|
|
HUNDRED,
|
|
)
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ============================================================================
|
|
# Enums
|
|
# ============================================================================
|
|
|
|
class TimeFrame(Enum):
|
|
"""Analysis time frame."""
|
|
DAILY = "daily"
|
|
WEEKLY = "weekly"
|
|
MONTHLY = "monthly"
|
|
QUARTERLY = "quarterly"
|
|
YEARLY = "yearly"
|
|
|
|
|
|
class TradeDirection(Enum):
|
|
"""Trade direction for analysis."""
|
|
LONG = "long"
|
|
SHORT = "short"
|
|
BOTH = "both"
|
|
|
|
|
|
# ============================================================================
|
|
# Data Classes
|
|
# ============================================================================
|
|
|
|
@dataclass
|
|
class TradeAnalysis:
|
|
"""Analysis of an individual trade.
|
|
|
|
Attributes:
|
|
trade: Original trade record
|
|
holding_period_days: Days position was held
|
|
return_pct: Return percentage
|
|
mae: Maximum Adverse Excursion
|
|
mfe: Maximum Favorable Excursion
|
|
efficiency: MFE capture efficiency
|
|
r_multiple: R-multiple (if stop loss defined)
|
|
edge_ratio: MFE/MAE ratio
|
|
"""
|
|
trade: BacktestTrade
|
|
holding_period_days: Decimal = ZERO
|
|
return_pct: Decimal = ZERO
|
|
mae: Decimal = ZERO # Max Adverse Excursion
|
|
mfe: Decimal = ZERO # Max Favorable Excursion
|
|
efficiency: Decimal = ZERO # Profit/MFE
|
|
r_multiple: Decimal = ZERO
|
|
edge_ratio: Decimal = ZERO # MFE/MAE
|
|
|
|
|
|
@dataclass
|
|
class TradePattern:
|
|
"""Trade pattern statistics.
|
|
|
|
Attributes:
|
|
pattern_name: Pattern identifier
|
|
occurrences: Number of times pattern occurred
|
|
win_rate: Win rate for this pattern
|
|
avg_return: Average return
|
|
total_pnl: Total P&L from pattern
|
|
"""
|
|
pattern_name: str
|
|
occurrences: int = 0
|
|
win_rate: Decimal = ZERO
|
|
avg_return: Decimal = ZERO
|
|
total_pnl: Decimal = ZERO
|
|
|
|
|
|
@dataclass
|
|
class PerformanceBreakdown:
|
|
"""Performance breakdown by period.
|
|
|
|
Attributes:
|
|
period: Period identifier (e.g., "2023-01", "2023-Q1")
|
|
start_date: Period start date
|
|
end_date: Period end date
|
|
return_pct: Return for period
|
|
trades: Number of trades
|
|
winning_trades: Number of winners
|
|
pnl: Total P&L
|
|
max_drawdown: Max drawdown in period
|
|
"""
|
|
period: str
|
|
start_date: datetime
|
|
end_date: datetime
|
|
return_pct: Decimal = ZERO
|
|
trades: int = 0
|
|
winning_trades: int = 0
|
|
pnl: Decimal = ZERO
|
|
max_drawdown: Decimal = ZERO
|
|
|
|
|
|
@dataclass
|
|
class RiskMetrics:
|
|
"""Risk-related metrics.
|
|
|
|
Attributes:
|
|
sharpe_ratio: Sharpe ratio
|
|
sortino_ratio: Sortino ratio
|
|
calmar_ratio: Calmar ratio (return / max drawdown)
|
|
omega_ratio: Omega ratio
|
|
tail_ratio: Tail ratio (95th / 5th percentile)
|
|
var_95: Value at Risk (95%)
|
|
cvar_95: Conditional VaR (95%)
|
|
max_drawdown: Maximum drawdown percentage
|
|
max_drawdown_duration: Duration of max drawdown in days
|
|
recovery_factor: Total return / max drawdown
|
|
ulcer_index: Ulcer index (pain index)
|
|
pain_ratio: Pain ratio
|
|
gain_to_pain_ratio: Gain to pain ratio
|
|
"""
|
|
sharpe_ratio: Decimal = ZERO
|
|
sortino_ratio: Decimal = ZERO
|
|
calmar_ratio: Decimal = ZERO
|
|
omega_ratio: Decimal = ZERO
|
|
tail_ratio: Decimal = ZERO
|
|
var_95: Decimal = ZERO
|
|
cvar_95: Decimal = ZERO
|
|
max_drawdown: Decimal = ZERO
|
|
max_drawdown_duration: int = 0
|
|
recovery_factor: Decimal = ZERO
|
|
ulcer_index: Decimal = ZERO
|
|
pain_ratio: Decimal = ZERO
|
|
gain_to_pain_ratio: Decimal = ZERO
|
|
|
|
|
|
@dataclass
|
|
class TradeStatistics:
|
|
"""Comprehensive trade statistics.
|
|
|
|
Attributes:
|
|
total_trades: Total number of trades
|
|
winning_trades: Number of winners
|
|
losing_trades: Number of losers
|
|
break_even_trades: Trades with zero P&L
|
|
win_rate: Win rate percentage
|
|
loss_rate: Loss rate percentage
|
|
avg_win: Average winning trade
|
|
avg_loss: Average losing trade
|
|
max_win: Largest win
|
|
max_loss: Largest loss
|
|
avg_trade: Average trade
|
|
median_trade: Median trade P&L
|
|
profit_factor: Gross profit / gross loss
|
|
expectancy: Expected value per trade
|
|
payoff_ratio: Average win / average loss
|
|
avg_holding_period: Average days held
|
|
max_consecutive_wins: Max winning streak
|
|
max_consecutive_losses: Max losing streak
|
|
long_trades: Number of long trades
|
|
short_trades: Number of short trades
|
|
long_win_rate: Win rate for long trades
|
|
short_win_rate: Win rate for short trades
|
|
"""
|
|
total_trades: int = 0
|
|
winning_trades: int = 0
|
|
losing_trades: int = 0
|
|
break_even_trades: int = 0
|
|
win_rate: Decimal = ZERO
|
|
loss_rate: Decimal = ZERO
|
|
avg_win: Decimal = ZERO
|
|
avg_loss: Decimal = ZERO
|
|
max_win: Decimal = ZERO
|
|
max_loss: Decimal = ZERO
|
|
avg_trade: Decimal = ZERO
|
|
median_trade: Decimal = ZERO
|
|
profit_factor: Decimal = ZERO
|
|
expectancy: Decimal = ZERO
|
|
payoff_ratio: Decimal = ZERO
|
|
avg_holding_period: Decimal = ZERO
|
|
max_consecutive_wins: int = 0
|
|
max_consecutive_losses: int = 0
|
|
long_trades: int = 0
|
|
short_trades: int = 0
|
|
long_win_rate: Decimal = ZERO
|
|
short_win_rate: Decimal = ZERO
|
|
|
|
|
|
@dataclass
|
|
class BenchmarkComparison:
|
|
"""Comparison to benchmark.
|
|
|
|
Attributes:
|
|
benchmark_symbol: Benchmark symbol
|
|
benchmark_return: Benchmark total return
|
|
strategy_return: Strategy total return
|
|
excess_return: Strategy - benchmark return
|
|
alpha: Alpha (risk-adjusted excess return)
|
|
beta: Beta (market sensitivity)
|
|
correlation: Correlation with benchmark
|
|
tracking_error: Standard deviation of excess returns
|
|
information_ratio: Excess return / tracking error
|
|
up_capture: Upside capture ratio
|
|
down_capture: Downside capture ratio
|
|
capture_ratio: Up/down capture ratio
|
|
"""
|
|
benchmark_symbol: str = ""
|
|
benchmark_return: Decimal = ZERO
|
|
strategy_return: Decimal = ZERO
|
|
excess_return: Decimal = ZERO
|
|
alpha: Decimal = ZERO
|
|
beta: Decimal = ZERO
|
|
correlation: Decimal = ZERO
|
|
tracking_error: Decimal = ZERO
|
|
information_ratio: Decimal = ZERO
|
|
up_capture: Decimal = ZERO
|
|
down_capture: Decimal = ZERO
|
|
capture_ratio: Decimal = ZERO
|
|
|
|
|
|
@dataclass
|
|
class DrawdownAnalysis:
|
|
"""Drawdown analysis.
|
|
|
|
Attributes:
|
|
current_drawdown: Current drawdown percentage
|
|
max_drawdown: Maximum drawdown percentage
|
|
max_drawdown_start: When max drawdown started
|
|
max_drawdown_end: When max drawdown ended
|
|
max_drawdown_duration: Duration in days
|
|
recovery_time: Time to recover from max drawdown
|
|
avg_drawdown: Average drawdown
|
|
drawdown_count: Number of drawdown periods
|
|
underwater_periods: List of (start, end, depth) tuples
|
|
"""
|
|
current_drawdown: Decimal = ZERO
|
|
max_drawdown: Decimal = ZERO
|
|
max_drawdown_start: Optional[datetime] = None
|
|
max_drawdown_end: Optional[datetime] = None
|
|
max_drawdown_duration: int = 0
|
|
recovery_time: int = 0
|
|
avg_drawdown: Decimal = ZERO
|
|
drawdown_count: int = 0
|
|
underwater_periods: list[tuple[datetime, datetime, Decimal]] = field(default_factory=list)
|
|
|
|
|
|
@dataclass
|
|
class AnalysisResult:
|
|
"""Complete analysis result.
|
|
|
|
Attributes:
|
|
backtest_result: Original backtest result
|
|
trade_statistics: Trade statistics
|
|
risk_metrics: Risk metrics
|
|
drawdown_analysis: Drawdown analysis
|
|
monthly_performance: Monthly breakdown
|
|
yearly_performance: Yearly breakdown
|
|
benchmark_comparison: Benchmark comparison
|
|
trade_analyses: Individual trade analyses
|
|
trade_patterns: Identified trade patterns
|
|
best_trades: Top N best trades
|
|
worst_trades: Top N worst trades
|
|
errors: Any analysis errors
|
|
"""
|
|
backtest_result: BacktestResult
|
|
trade_statistics: TradeStatistics = field(default_factory=TradeStatistics)
|
|
risk_metrics: RiskMetrics = field(default_factory=RiskMetrics)
|
|
drawdown_analysis: DrawdownAnalysis = field(default_factory=DrawdownAnalysis)
|
|
monthly_performance: list[PerformanceBreakdown] = field(default_factory=list)
|
|
yearly_performance: list[PerformanceBreakdown] = field(default_factory=list)
|
|
benchmark_comparison: Optional[BenchmarkComparison] = None
|
|
trade_analyses: list[TradeAnalysis] = field(default_factory=list)
|
|
trade_patterns: list[TradePattern] = field(default_factory=list)
|
|
best_trades: list[BacktestTrade] = field(default_factory=list)
|
|
worst_trades: list[BacktestTrade] = field(default_factory=list)
|
|
errors: list[str] = field(default_factory=list)
|
|
|
|
|
|
# ============================================================================
|
|
# Results Analyzer
|
|
# ============================================================================
|
|
|
|
class ResultsAnalyzer:
|
|
"""Analyzer for backtest results.
|
|
|
|
Attributes:
|
|
risk_free_rate: Annual risk-free rate for calculations
|
|
top_n_trades: Number of best/worst trades to track
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
risk_free_rate: Decimal = Decimal("0.05"),
|
|
top_n_trades: int = 10,
|
|
):
|
|
"""Initialize analyzer.
|
|
|
|
Args:
|
|
risk_free_rate: Annual risk-free rate
|
|
top_n_trades: Number of top trades to track
|
|
"""
|
|
self.risk_free_rate = risk_free_rate
|
|
self.top_n_trades = top_n_trades
|
|
|
|
def analyze(
|
|
self,
|
|
result: BacktestResult,
|
|
benchmark_returns: Optional[list[Decimal]] = None,
|
|
) -> AnalysisResult:
|
|
"""Perform complete analysis of backtest result.
|
|
|
|
Args:
|
|
result: Backtest result to analyze
|
|
benchmark_returns: Optional benchmark returns for comparison
|
|
|
|
Returns:
|
|
AnalysisResult with all metrics
|
|
"""
|
|
errors = []
|
|
|
|
# Calculate trade statistics
|
|
trade_stats = self._calculate_trade_statistics(result)
|
|
|
|
# Calculate risk metrics
|
|
risk_metrics = self._calculate_risk_metrics(result)
|
|
|
|
# Analyze drawdowns
|
|
drawdown_analysis = self._analyze_drawdowns(result)
|
|
|
|
# Calculate monthly performance
|
|
monthly = self._calculate_periodic_performance(result, TimeFrame.MONTHLY)
|
|
|
|
# Calculate yearly performance
|
|
yearly = self._calculate_periodic_performance(result, TimeFrame.YEARLY)
|
|
|
|
# Benchmark comparison
|
|
benchmark = None
|
|
if benchmark_returns:
|
|
try:
|
|
benchmark = self._calculate_benchmark_comparison(
|
|
result, benchmark_returns
|
|
)
|
|
except Exception as e:
|
|
errors.append(f"Benchmark comparison failed: {e}")
|
|
|
|
# Analyze individual trades
|
|
trade_analyses = self._analyze_trades(result)
|
|
|
|
# Identify patterns
|
|
patterns = self._identify_patterns(result)
|
|
|
|
# Get best and worst trades
|
|
best_trades, worst_trades = self._get_extreme_trades(result)
|
|
|
|
return AnalysisResult(
|
|
backtest_result=result,
|
|
trade_statistics=trade_stats,
|
|
risk_metrics=risk_metrics,
|
|
drawdown_analysis=drawdown_analysis,
|
|
monthly_performance=monthly,
|
|
yearly_performance=yearly,
|
|
benchmark_comparison=benchmark,
|
|
trade_analyses=trade_analyses,
|
|
trade_patterns=patterns,
|
|
best_trades=best_trades,
|
|
worst_trades=worst_trades,
|
|
errors=result.errors + errors,
|
|
)
|
|
|
|
def _calculate_trade_statistics(self, result: BacktestResult) -> TradeStatistics:
|
|
"""Calculate comprehensive trade statistics.
|
|
|
|
Args:
|
|
result: Backtest result
|
|
|
|
Returns:
|
|
TradeStatistics
|
|
"""
|
|
trades = result.trades
|
|
if not trades:
|
|
return TradeStatistics()
|
|
|
|
# Basic counts
|
|
total = len(trades)
|
|
winners = [t for t in trades if t.pnl > ZERO]
|
|
losers = [t for t in trades if t.pnl < ZERO]
|
|
break_even = [t for t in trades if t.pnl == ZERO]
|
|
|
|
winning_count = len(winners)
|
|
losing_count = len(losers)
|
|
break_even_count = len(break_even)
|
|
|
|
# Win/loss rates
|
|
win_rate = Decimal(str(winning_count)) / Decimal(str(total)) * HUNDRED if total > 0 else ZERO
|
|
loss_rate = Decimal(str(losing_count)) / Decimal(str(total)) * HUNDRED if total > 0 else ZERO
|
|
|
|
# Averages
|
|
win_pnls = [t.pnl for t in winners]
|
|
loss_pnls = [t.pnl for t in losers]
|
|
all_pnls = [t.pnl for t in trades]
|
|
|
|
avg_win = sum(win_pnls) / len(win_pnls) if win_pnls else ZERO
|
|
avg_loss = sum(loss_pnls) / len(loss_pnls) if loss_pnls else ZERO
|
|
avg_trade = sum(all_pnls) / len(all_pnls) if all_pnls else ZERO
|
|
|
|
# Max win/loss
|
|
max_win = max(win_pnls) if win_pnls else ZERO
|
|
max_loss = min(loss_pnls) if loss_pnls else ZERO
|
|
|
|
# Median
|
|
sorted_pnls = sorted(all_pnls)
|
|
median_idx = len(sorted_pnls) // 2
|
|
if len(sorted_pnls) % 2 == 0:
|
|
median_trade = (sorted_pnls[median_idx - 1] + sorted_pnls[median_idx]) / 2 if sorted_pnls else ZERO
|
|
else:
|
|
median_trade = sorted_pnls[median_idx] if sorted_pnls else ZERO
|
|
|
|
# Profit factor
|
|
gross_profit = sum(win_pnls)
|
|
gross_loss = abs(sum(loss_pnls))
|
|
profit_factor = gross_profit / gross_loss if gross_loss > ZERO else ZERO
|
|
|
|
# Expectancy
|
|
expectancy = avg_trade
|
|
|
|
# Payoff ratio
|
|
payoff_ratio = abs(avg_win / avg_loss) if avg_loss != ZERO else ZERO
|
|
|
|
# Consecutive wins/losses
|
|
max_consec_wins, max_consec_losses = self._calculate_streaks(trades)
|
|
|
|
# Long/short breakdown
|
|
long_trades = [t for t in trades if t.side == OrderSide.BUY]
|
|
short_trades = [t for t in trades if t.side == OrderSide.SELL]
|
|
long_winners = [t for t in long_trades if t.pnl > ZERO]
|
|
short_winners = [t for t in short_trades if t.pnl > ZERO]
|
|
|
|
long_win_rate = Decimal(str(len(long_winners))) / Decimal(str(len(long_trades))) * HUNDRED if long_trades else ZERO
|
|
short_win_rate = Decimal(str(len(short_winners))) / Decimal(str(len(short_trades))) * HUNDRED if short_trades else ZERO
|
|
|
|
return TradeStatistics(
|
|
total_trades=total,
|
|
winning_trades=winning_count,
|
|
losing_trades=losing_count,
|
|
break_even_trades=break_even_count,
|
|
win_rate=win_rate,
|
|
loss_rate=loss_rate,
|
|
avg_win=avg_win,
|
|
avg_loss=avg_loss,
|
|
max_win=max_win,
|
|
max_loss=max_loss,
|
|
avg_trade=avg_trade,
|
|
median_trade=median_trade,
|
|
profit_factor=profit_factor,
|
|
expectancy=expectancy,
|
|
payoff_ratio=payoff_ratio,
|
|
max_consecutive_wins=max_consec_wins,
|
|
max_consecutive_losses=max_consec_losses,
|
|
long_trades=len(long_trades),
|
|
short_trades=len(short_trades),
|
|
long_win_rate=long_win_rate,
|
|
short_win_rate=short_win_rate,
|
|
)
|
|
|
|
def _calculate_streaks(self, trades: list[BacktestTrade]) -> tuple[int, int]:
|
|
"""Calculate maximum consecutive wins and losses.
|
|
|
|
Args:
|
|
trades: List of trades
|
|
|
|
Returns:
|
|
Tuple of (max_wins, max_losses)
|
|
"""
|
|
max_wins = 0
|
|
max_losses = 0
|
|
current_wins = 0
|
|
current_losses = 0
|
|
|
|
for trade in trades:
|
|
if trade.pnl > ZERO:
|
|
current_wins += 1
|
|
current_losses = 0
|
|
max_wins = max(max_wins, current_wins)
|
|
elif trade.pnl < ZERO:
|
|
current_losses += 1
|
|
current_wins = 0
|
|
max_losses = max(max_losses, current_losses)
|
|
else:
|
|
# Break even doesn't break streak
|
|
pass
|
|
|
|
return max_wins, max_losses
|
|
|
|
def _calculate_risk_metrics(self, result: BacktestResult) -> RiskMetrics:
|
|
"""Calculate risk-related metrics.
|
|
|
|
Args:
|
|
result: Backtest result
|
|
|
|
Returns:
|
|
RiskMetrics
|
|
"""
|
|
returns = result.daily_returns
|
|
if not returns:
|
|
return RiskMetrics(max_drawdown=result.max_drawdown)
|
|
|
|
# Convert to float for calculations
|
|
returns_float = [float(r) for r in returns]
|
|
|
|
# Basic statistics
|
|
n = len(returns_float)
|
|
avg_return = sum(returns_float) / n
|
|
variance = sum((r - avg_return) ** 2 for r in returns_float) / n
|
|
std_dev = variance ** 0.5 if variance > 0 else 0.001
|
|
|
|
# Daily risk-free rate
|
|
daily_rf = float(self.risk_free_rate) / 252
|
|
|
|
# Sharpe ratio (annualized)
|
|
if std_dev > 0:
|
|
sharpe = (avg_return - daily_rf) / std_dev * (252 ** 0.5)
|
|
else:
|
|
sharpe = 0
|
|
|
|
# Sortino ratio (downside deviation)
|
|
negative_returns = [r for r in returns_float if r < 0]
|
|
if negative_returns:
|
|
downside_variance = sum(r ** 2 for r in negative_returns) / len(negative_returns)
|
|
downside_dev = downside_variance ** 0.5
|
|
sortino = (avg_return - daily_rf) / downside_dev * (252 ** 0.5) if downside_dev > 0 else 0
|
|
else:
|
|
sortino = 0
|
|
|
|
# Calmar ratio
|
|
max_dd = float(result.max_drawdown)
|
|
annual_return = float(result.annualized_return)
|
|
calmar = annual_return / max_dd if max_dd > 0 else 0
|
|
|
|
# VaR and CVaR (95%)
|
|
sorted_returns = sorted(returns_float)
|
|
var_idx = int(n * 0.05)
|
|
var_95 = abs(sorted_returns[var_idx]) if var_idx < n else 0
|
|
cvar_95 = abs(sum(sorted_returns[:var_idx + 1]) / (var_idx + 1)) if var_idx > 0 else var_95
|
|
|
|
# Tail ratio
|
|
upper_idx = int(n * 0.95)
|
|
if var_idx < n and upper_idx < n and sorted_returns[var_idx] != 0:
|
|
tail_ratio = abs(sorted_returns[upper_idx] / sorted_returns[var_idx])
|
|
else:
|
|
tail_ratio = 0
|
|
|
|
# Omega ratio (threshold = 0)
|
|
gains = sum(r for r in returns_float if r > 0)
|
|
losses = abs(sum(r for r in returns_float if r < 0))
|
|
omega = gains / losses if losses > 0 else 0
|
|
|
|
# Recovery factor
|
|
total_return = float(result.total_return)
|
|
recovery = total_return / max_dd if max_dd > 0 else 0
|
|
|
|
# Ulcer index (pain index)
|
|
drawdowns = [float(s.drawdown) for s in result.snapshots if s.drawdown > ZERO]
|
|
if drawdowns:
|
|
ulcer_squared = sum(d ** 2 for d in drawdowns) / len(drawdowns)
|
|
ulcer = ulcer_squared ** 0.5
|
|
else:
|
|
ulcer = 0
|
|
|
|
# Pain ratio
|
|
pain = sum(drawdowns) / len(drawdowns) if drawdowns else 0
|
|
pain_ratio = (annual_return - float(self.risk_free_rate) * 100) / pain if pain > 0 else 0
|
|
|
|
# Gain to pain ratio
|
|
total_pnl = sum(t.pnl for t in result.trades)
|
|
total_abs_pnl = sum(abs(t.pnl) for t in result.trades if t.pnl < ZERO)
|
|
gain_to_pain = float(total_pnl) / float(total_abs_pnl) if total_abs_pnl > ZERO else 0
|
|
|
|
# Max drawdown duration
|
|
dd_duration = self._calculate_drawdown_duration(result.snapshots)
|
|
|
|
return RiskMetrics(
|
|
sharpe_ratio=Decimal(str(round(sharpe, 4))),
|
|
sortino_ratio=Decimal(str(round(sortino, 4))),
|
|
calmar_ratio=Decimal(str(round(calmar, 4))),
|
|
omega_ratio=Decimal(str(round(omega, 4))),
|
|
tail_ratio=Decimal(str(round(tail_ratio, 4))),
|
|
var_95=Decimal(str(round(var_95 * 100, 4))), # As percentage
|
|
cvar_95=Decimal(str(round(cvar_95 * 100, 4))),
|
|
max_drawdown=result.max_drawdown,
|
|
max_drawdown_duration=dd_duration,
|
|
recovery_factor=Decimal(str(round(recovery, 4))),
|
|
ulcer_index=Decimal(str(round(ulcer, 4))),
|
|
pain_ratio=Decimal(str(round(pain_ratio, 4))),
|
|
gain_to_pain_ratio=Decimal(str(round(gain_to_pain, 4))),
|
|
)
|
|
|
|
def _calculate_drawdown_duration(self, snapshots: list[BacktestSnapshot]) -> int:
|
|
"""Calculate maximum drawdown duration.
|
|
|
|
Args:
|
|
snapshots: Portfolio snapshots
|
|
|
|
Returns:
|
|
Duration in days
|
|
"""
|
|
if not snapshots:
|
|
return 0
|
|
|
|
max_duration = 0
|
|
current_duration = 0
|
|
in_drawdown = False
|
|
|
|
for snapshot in snapshots:
|
|
if snapshot.drawdown > ZERO:
|
|
if not in_drawdown:
|
|
in_drawdown = True
|
|
current_duration = 1
|
|
else:
|
|
current_duration += 1
|
|
max_duration = max(max_duration, current_duration)
|
|
else:
|
|
in_drawdown = False
|
|
current_duration = 0
|
|
|
|
return max_duration
|
|
|
|
def _analyze_drawdowns(self, result: BacktestResult) -> DrawdownAnalysis:
|
|
"""Analyze drawdown periods.
|
|
|
|
Args:
|
|
result: Backtest result
|
|
|
|
Returns:
|
|
DrawdownAnalysis
|
|
"""
|
|
snapshots = result.snapshots
|
|
if not snapshots:
|
|
return DrawdownAnalysis()
|
|
|
|
# Current drawdown
|
|
current_dd = snapshots[-1].drawdown if snapshots else ZERO
|
|
|
|
# Max drawdown tracking
|
|
max_dd = ZERO
|
|
max_dd_start: Optional[datetime] = None
|
|
max_dd_end: Optional[datetime] = None
|
|
max_dd_duration = 0
|
|
|
|
# Track underwater periods
|
|
underwater_periods = []
|
|
in_drawdown = False
|
|
dd_start: Optional[datetime] = None
|
|
current_dd_depth = ZERO
|
|
current_duration = 0
|
|
|
|
for snapshot in snapshots:
|
|
if snapshot.drawdown > ZERO:
|
|
if not in_drawdown:
|
|
in_drawdown = True
|
|
dd_start = snapshot.timestamp
|
|
current_dd_depth = snapshot.drawdown
|
|
current_duration = 1
|
|
else:
|
|
current_duration += 1
|
|
current_dd_depth = max(current_dd_depth, snapshot.drawdown)
|
|
|
|
# Track max drawdown
|
|
if snapshot.drawdown > max_dd:
|
|
max_dd = snapshot.drawdown
|
|
max_dd_start = dd_start
|
|
max_dd_duration = current_duration
|
|
else:
|
|
if in_drawdown and dd_start:
|
|
underwater_periods.append((dd_start, snapshot.timestamp, current_dd_depth))
|
|
max_dd_end = snapshot.timestamp
|
|
in_drawdown = False
|
|
dd_start = None
|
|
current_dd_depth = ZERO
|
|
current_duration = 0
|
|
|
|
# If still in drawdown at end
|
|
if in_drawdown and dd_start:
|
|
underwater_periods.append((dd_start, snapshots[-1].timestamp, current_dd_depth))
|
|
|
|
# Average drawdown
|
|
all_dds = [s.drawdown for s in snapshots if s.drawdown > ZERO]
|
|
avg_dd = sum(all_dds) / len(all_dds) if all_dds else ZERO
|
|
|
|
return DrawdownAnalysis(
|
|
current_drawdown=current_dd,
|
|
max_drawdown=max_dd,
|
|
max_drawdown_start=max_dd_start,
|
|
max_drawdown_end=max_dd_end,
|
|
max_drawdown_duration=max_dd_duration,
|
|
avg_drawdown=avg_dd,
|
|
drawdown_count=len(underwater_periods),
|
|
underwater_periods=underwater_periods,
|
|
)
|
|
|
|
def _calculate_periodic_performance(
|
|
self,
|
|
result: BacktestResult,
|
|
timeframe: TimeFrame,
|
|
) -> list[PerformanceBreakdown]:
|
|
"""Calculate performance breakdown by period.
|
|
|
|
Args:
|
|
result: Backtest result
|
|
timeframe: Time frame for breakdown
|
|
|
|
Returns:
|
|
List of PerformanceBreakdown
|
|
"""
|
|
snapshots = result.snapshots
|
|
trades = result.trades
|
|
if not snapshots:
|
|
return []
|
|
|
|
# Group snapshots by period
|
|
periods: dict[str, list[BacktestSnapshot]] = {}
|
|
|
|
for snapshot in snapshots:
|
|
if timeframe == TimeFrame.MONTHLY:
|
|
period_key = snapshot.timestamp.strftime("%Y-%m")
|
|
elif timeframe == TimeFrame.YEARLY:
|
|
period_key = snapshot.timestamp.strftime("%Y")
|
|
elif timeframe == TimeFrame.QUARTERLY:
|
|
quarter = (snapshot.timestamp.month - 1) // 3 + 1
|
|
period_key = f"{snapshot.timestamp.year}-Q{quarter}"
|
|
elif timeframe == TimeFrame.WEEKLY:
|
|
period_key = snapshot.timestamp.strftime("%Y-W%W")
|
|
else:
|
|
period_key = snapshot.timestamp.strftime("%Y-%m-%d")
|
|
|
|
if period_key not in periods:
|
|
periods[period_key] = []
|
|
periods[period_key].append(snapshot)
|
|
|
|
# Calculate metrics for each period
|
|
breakdowns = []
|
|
for period_key in sorted(periods.keys()):
|
|
period_snapshots = periods[period_key]
|
|
if len(period_snapshots) < 2:
|
|
continue
|
|
|
|
start_value = period_snapshots[0].total_value
|
|
end_value = period_snapshots[-1].total_value
|
|
return_pct = (end_value - start_value) / start_value * HUNDRED if start_value > ZERO else ZERO
|
|
|
|
# Count trades in period
|
|
period_start = period_snapshots[0].timestamp
|
|
period_end = period_snapshots[-1].timestamp
|
|
period_trades = [t for t in trades if period_start <= t.timestamp <= period_end]
|
|
period_winners = [t for t in period_trades if t.pnl > ZERO]
|
|
period_pnl = sum(t.pnl for t in period_trades)
|
|
|
|
# Max drawdown in period
|
|
period_dd = max(s.drawdown for s in period_snapshots)
|
|
|
|
breakdowns.append(PerformanceBreakdown(
|
|
period=period_key,
|
|
start_date=period_start,
|
|
end_date=period_end,
|
|
return_pct=return_pct,
|
|
trades=len(period_trades),
|
|
winning_trades=len(period_winners),
|
|
pnl=period_pnl,
|
|
max_drawdown=period_dd,
|
|
))
|
|
|
|
return breakdowns
|
|
|
|
def _calculate_benchmark_comparison(
|
|
self,
|
|
result: BacktestResult,
|
|
benchmark_returns: list[Decimal],
|
|
) -> BenchmarkComparison:
|
|
"""Calculate benchmark comparison metrics.
|
|
|
|
Args:
|
|
result: Backtest result
|
|
benchmark_returns: Benchmark daily returns
|
|
|
|
Returns:
|
|
BenchmarkComparison
|
|
"""
|
|
strategy_returns = result.daily_returns
|
|
if not strategy_returns or not benchmark_returns:
|
|
return BenchmarkComparison()
|
|
|
|
# Align lengths
|
|
min_len = min(len(strategy_returns), len(benchmark_returns))
|
|
strat = [float(r) for r in strategy_returns[:min_len]]
|
|
bench = [float(r) for r in benchmark_returns[:min_len]]
|
|
|
|
n = len(strat)
|
|
if n < 2:
|
|
return BenchmarkComparison()
|
|
|
|
# Returns
|
|
strat_total = float(result.total_return)
|
|
bench_total = (1 + sum(bench)) - 1
|
|
excess = strat_total - bench_total * 100
|
|
|
|
# Correlation and beta
|
|
strat_mean = sum(strat) / n
|
|
bench_mean = sum(bench) / n
|
|
|
|
covariance = sum((s - strat_mean) * (b - bench_mean) for s, b in zip(strat, bench)) / n
|
|
bench_variance = sum((b - bench_mean) ** 2 for b in bench) / n
|
|
strat_variance = sum((s - strat_mean) ** 2 for s in strat) / n
|
|
|
|
beta = covariance / bench_variance if bench_variance > 0 else 0
|
|
correlation = covariance / ((strat_variance ** 0.5) * (bench_variance ** 0.5)) if strat_variance > 0 and bench_variance > 0 else 0
|
|
|
|
# Alpha (annualized)
|
|
daily_rf = float(self.risk_free_rate) / 252
|
|
alpha = (strat_mean - daily_rf - beta * (bench_mean - daily_rf)) * 252
|
|
|
|
# Tracking error
|
|
excess_returns = [s - b for s, b in zip(strat, bench)]
|
|
excess_mean = sum(excess_returns) / n
|
|
tracking_variance = sum((e - excess_mean) ** 2 for e in excess_returns) / n
|
|
tracking_error = (tracking_variance ** 0.5) * (252 ** 0.5)
|
|
|
|
# Information ratio
|
|
info_ratio = excess / (tracking_error * 100) if tracking_error > 0 else 0
|
|
|
|
# Capture ratios
|
|
up_strat = [s for s, b in zip(strat, bench) if b > 0]
|
|
up_bench = [b for b in bench if b > 0]
|
|
down_strat = [s for s, b in zip(strat, bench) if b < 0]
|
|
down_bench = [b for b in bench if b < 0]
|
|
|
|
up_capture = (sum(up_strat) / sum(up_bench) * 100) if up_bench and sum(up_bench) != 0 else 0
|
|
down_capture = (sum(down_strat) / sum(down_bench) * 100) if down_bench and sum(down_bench) != 0 else 0
|
|
capture_ratio = up_capture / down_capture if down_capture != 0 else 0
|
|
|
|
return BenchmarkComparison(
|
|
benchmark_symbol=result.config.benchmark_symbol,
|
|
benchmark_return=Decimal(str(round(bench_total * 100, 4))),
|
|
strategy_return=result.total_return,
|
|
excess_return=Decimal(str(round(excess, 4))),
|
|
alpha=Decimal(str(round(alpha * 100, 4))),
|
|
beta=Decimal(str(round(beta, 4))),
|
|
correlation=Decimal(str(round(correlation, 4))),
|
|
tracking_error=Decimal(str(round(tracking_error * 100, 4))),
|
|
information_ratio=Decimal(str(round(info_ratio, 4))),
|
|
up_capture=Decimal(str(round(up_capture, 4))),
|
|
down_capture=Decimal(str(round(down_capture, 4))),
|
|
capture_ratio=Decimal(str(round(capture_ratio, 4))),
|
|
)
|
|
|
|
def _analyze_trades(self, result: BacktestResult) -> list[TradeAnalysis]:
|
|
"""Analyze individual trades.
|
|
|
|
Args:
|
|
result: Backtest result
|
|
|
|
Returns:
|
|
List of TradeAnalysis
|
|
"""
|
|
analyses = []
|
|
|
|
for trade in result.trades:
|
|
# Calculate return percentage
|
|
trade_cost = trade.base_price * trade.quantity
|
|
return_pct = trade.pnl / trade_cost * HUNDRED if trade_cost > ZERO else ZERO
|
|
|
|
# Efficiency (how much of the move was captured)
|
|
# Would need intraday data for proper MAE/MFE calculation
|
|
# Using simplified version
|
|
efficiency = Decimal("1") if trade.pnl > ZERO else ZERO
|
|
|
|
analyses.append(TradeAnalysis(
|
|
trade=trade,
|
|
return_pct=return_pct,
|
|
efficiency=efficiency,
|
|
))
|
|
|
|
return analyses
|
|
|
|
def _identify_patterns(self, result: BacktestResult) -> list[TradePattern]:
|
|
"""Identify trade patterns.
|
|
|
|
Args:
|
|
result: Backtest result
|
|
|
|
Returns:
|
|
List of TradePattern
|
|
"""
|
|
patterns = []
|
|
|
|
# Day of week pattern
|
|
day_stats: dict[int, list[BacktestTrade]] = {i: [] for i in range(7)}
|
|
for trade in result.trades:
|
|
day_stats[trade.timestamp.weekday()].append(trade)
|
|
|
|
day_names = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
|
for day, trades in day_stats.items():
|
|
if trades:
|
|
winners = sum(1 for t in trades if t.pnl > ZERO)
|
|
win_rate = Decimal(str(winners)) / Decimal(str(len(trades))) * HUNDRED
|
|
avg_return = sum(t.pnl for t in trades) / len(trades)
|
|
patterns.append(TradePattern(
|
|
pattern_name=f"Day:{day_names[day]}",
|
|
occurrences=len(trades),
|
|
win_rate=win_rate,
|
|
avg_return=avg_return,
|
|
total_pnl=sum(t.pnl for t in trades),
|
|
))
|
|
|
|
# Hour of day pattern (if trades have time component)
|
|
hour_stats: dict[int, list[BacktestTrade]] = {}
|
|
for trade in result.trades:
|
|
hour = trade.timestamp.hour
|
|
if hour not in hour_stats:
|
|
hour_stats[hour] = []
|
|
hour_stats[hour].append(trade)
|
|
|
|
for hour, trades in sorted(hour_stats.items()):
|
|
if len(trades) >= 3: # Minimum sample size
|
|
winners = sum(1 for t in trades if t.pnl > ZERO)
|
|
win_rate = Decimal(str(winners)) / Decimal(str(len(trades))) * HUNDRED
|
|
avg_return = sum(t.pnl for t in trades) / len(trades)
|
|
patterns.append(TradePattern(
|
|
pattern_name=f"Hour:{hour:02d}:00",
|
|
occurrences=len(trades),
|
|
win_rate=win_rate,
|
|
avg_return=avg_return,
|
|
total_pnl=sum(t.pnl for t in trades),
|
|
))
|
|
|
|
return patterns
|
|
|
|
def _get_extreme_trades(
|
|
self,
|
|
result: BacktestResult,
|
|
) -> tuple[list[BacktestTrade], list[BacktestTrade]]:
|
|
"""Get best and worst trades.
|
|
|
|
Args:
|
|
result: Backtest result
|
|
|
|
Returns:
|
|
Tuple of (best_trades, worst_trades)
|
|
"""
|
|
sorted_trades = sorted(result.trades, key=lambda t: t.pnl, reverse=True)
|
|
|
|
best = sorted_trades[: self.top_n_trades]
|
|
worst = sorted_trades[-self.top_n_trades:] if len(sorted_trades) >= self.top_n_trades else sorted_trades
|
|
|
|
return best, worst
|
|
|
|
|
|
# ============================================================================
|
|
# Factory Functions
|
|
# ============================================================================
|
|
|
|
def create_results_analyzer(
|
|
risk_free_rate: Decimal = Decimal("0.05"),
|
|
top_n_trades: int = 10,
|
|
) -> ResultsAnalyzer:
|
|
"""Create a configured results analyzer.
|
|
|
|
Args:
|
|
risk_free_rate: Annual risk-free rate
|
|
top_n_trades: Number of extreme trades to track
|
|
|
|
Returns:
|
|
Configured ResultsAnalyzer
|
|
"""
|
|
return ResultsAnalyzer(
|
|
risk_free_rate=risk_free_rate,
|
|
top_n_trades=top_n_trades,
|
|
)
|