86 lines
2.7 KiB
Python
86 lines
2.7 KiB
Python
|
|
"""Performance analysis and reporting utilities."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from typing import List
|
||
|
|
|
||
|
|
import numpy as np
|
||
|
|
import pandas as pd
|
||
|
|
|
||
|
|
from backtest.backtester import BacktestResult, BacktestTrade
|
||
|
|
|
||
|
|
|
||
|
|
def compute_sharpe_ratio(
|
||
|
|
equity_curve: List[float], risk_free_rate: float = 0.0, periods: int = 252
|
||
|
|
) -> float:
|
||
|
|
"""Compute annualised Sharpe ratio from an equity curve."""
|
||
|
|
if len(equity_curve) < 2:
|
||
|
|
return 0.0
|
||
|
|
returns = pd.Series(equity_curve).pct_change().dropna()
|
||
|
|
excess = returns - risk_free_rate / periods
|
||
|
|
if excess.std() == 0:
|
||
|
|
return 0.0
|
||
|
|
return float((excess.mean() / excess.std()) * np.sqrt(periods))
|
||
|
|
|
||
|
|
|
||
|
|
def compute_sortino_ratio(
|
||
|
|
equity_curve: List[float], risk_free_rate: float = 0.0, periods: int = 252
|
||
|
|
) -> float:
|
||
|
|
"""Compute annualised Sortino ratio (downside deviation only)."""
|
||
|
|
if len(equity_curve) < 2:
|
||
|
|
return 0.0
|
||
|
|
returns = pd.Series(equity_curve).pct_change().dropna()
|
||
|
|
excess = returns - risk_free_rate / periods
|
||
|
|
downside = excess[excess < 0]
|
||
|
|
if len(downside) == 0 or downside.std() == 0:
|
||
|
|
return float("inf") if excess.mean() > 0 else 0.0
|
||
|
|
return float((excess.mean() / downside.std()) * np.sqrt(periods))
|
||
|
|
|
||
|
|
|
||
|
|
def compute_max_consecutive_losses(trades: List[BacktestTrade]) -> int:
|
||
|
|
"""Return the longest streak of consecutive losing trades."""
|
||
|
|
max_streak = 0
|
||
|
|
current = 0
|
||
|
|
for t in trades:
|
||
|
|
if t.pnl <= 0:
|
||
|
|
current += 1
|
||
|
|
max_streak = max(max_streak, current)
|
||
|
|
else:
|
||
|
|
current = 0
|
||
|
|
return max_streak
|
||
|
|
|
||
|
|
|
||
|
|
def compute_calmar_ratio(
|
||
|
|
total_return: float, max_drawdown: float, years: float = 1.0
|
||
|
|
) -> float:
|
||
|
|
"""Calmar ratio = annualised return / max drawdown."""
|
||
|
|
if max_drawdown == 0:
|
||
|
|
return float("inf") if total_return > 0 else 0.0
|
||
|
|
annual_return = total_return / years
|
||
|
|
return annual_return / max_drawdown
|
||
|
|
|
||
|
|
|
||
|
|
def trade_analysis(trades: List[BacktestTrade]) -> dict:
|
||
|
|
"""Detailed trade-level statistics."""
|
||
|
|
if not trades:
|
||
|
|
return {}
|
||
|
|
|
||
|
|
pnls = [t.pnl for t in trades]
|
||
|
|
wins = [p for p in pnls if p > 0]
|
||
|
|
losses = [p for p in pnls if p <= 0]
|
||
|
|
durations = [t.exit_index - t.entry_index for t in trades]
|
||
|
|
|
||
|
|
return {
|
||
|
|
"total_trades": len(trades),
|
||
|
|
"avg_pnl": np.mean(pnls),
|
||
|
|
"median_pnl": np.median(pnls),
|
||
|
|
"std_pnl": np.std(pnls),
|
||
|
|
"best_trade": max(pnls),
|
||
|
|
"worst_trade": min(pnls),
|
||
|
|
"avg_win": np.mean(wins) if wins else 0,
|
||
|
|
"avg_loss": np.mean(losses) if losses else 0,
|
||
|
|
"max_consecutive_losses": compute_max_consecutive_losses(trades),
|
||
|
|
"avg_duration_candles": np.mean(durations),
|
||
|
|
"total_fees": sum(t.fee for t in trades),
|
||
|
|
}
|