Разработка отчётов по бэктестам (P&L, Sharpe, drawdown, win rate)
Бэктест без качественной отчётности — набор цифр без смысла. Хороший отчёт позволяет быстро понять сильные и слабые стороны стратегии, сравнивать несколько стратегий между собой и принимать обоснованные решения о дальнейшей разработке.
Структура отчёта
from dataclasses import dataclass
from typing import Optional
import pandas as pd
import numpy as np
@dataclass
class BacktestReport:
# Сводные метрики
initial_capital: float
final_capital: float
total_return_pct: float
annual_return_pct: float
# Risk-adjusted
sharpe_ratio: float
sortino_ratio: float
calmar_ratio: float
# Drawdown
max_drawdown_pct: float
avg_drawdown_pct: float
max_drawdown_duration_days: int
# Торговля
total_trades: int
win_rate: float
profit_factor: float
avg_win_pct: float
avg_loss_pct: float
best_trade_pct: float
worst_trade_pct: float
avg_trade_duration_hours: float
# Комиссии
total_commission: float
commission_as_pct_of_pnl: float
# Временные серии
equity_curve: pd.Series
monthly_returns: pd.DataFrame
trade_list: pd.DataFrame
Расчёт метрик
def compute_all_metrics(equity_curve: pd.Series, trades: list[dict]) -> BacktestReport:
returns = equity_curve.pct_change().dropna()
annual_factor = 252
# Базовые
total_return = (equity_curve.iloc[-1] / equity_curve.iloc[0]) - 1
days = (equity_curve.index[-1] - equity_curve.index[0]).days
annual_return = (1 + total_return) ** (365 / max(days, 1)) - 1
# Sharpe (risk-free rate = 0 для крипто)
sharpe = (returns.mean() * annual_factor) / (returns.std() * np.sqrt(annual_factor)) if returns.std() > 0 else 0
# Sortino (только downside volatility)
downside = returns[returns < 0].std()
sortino = (returns.mean() * annual_factor) / (downside * np.sqrt(annual_factor)) if downside > 0 else 0
# Drawdown
rolling_max = equity_curve.cummax()
drawdown_series = (equity_curve - rolling_max) / rolling_max
max_dd = drawdown_series.min()
# Max drawdown duration
in_drawdown = drawdown_series < 0
dd_start = None
max_duration = 0
for date, is_dd in in_drawdown.items():
if is_dd and dd_start is None:
dd_start = date
elif not is_dd and dd_start is not None:
duration = (date - dd_start).days
max_duration = max(max_duration, duration)
dd_start = None
# Calmar
calmar = annual_return / abs(max_dd) if max_dd != 0 else 0
# Trade-level
trades_df = pd.DataFrame(trades)
if not trades_df.empty:
winning = trades_df[trades_df['pnl'] > 0]
losing = trades_df[trades_df['pnl'] < 0]
win_rate = len(winning) / len(trades_df)
gross_profit = winning['pnl'].sum()
gross_loss = abs(losing['pnl'].sum())
profit_factor = gross_profit / gross_loss if gross_loss > 0 else float('inf')
avg_win_pct = (winning['pnl'] / winning['entry_value'] * 100).mean() if not winning.empty else 0
avg_loss_pct = (losing['pnl'] / losing['entry_value'] * 100).mean() if not losing.empty else 0
else:
win_rate = profit_factor = avg_win_pct = avg_loss_pct = 0
return BacktestReport(
initial_capital=equity_curve.iloc[0],
final_capital=equity_curve.iloc[-1],
total_return_pct=total_return * 100,
annual_return_pct=annual_return * 100,
sharpe_ratio=round(sharpe, 3),
sortino_ratio=round(sortino, 3),
calmar_ratio=round(calmar, 3),
max_drawdown_pct=max_dd * 100,
avg_drawdown_pct=drawdown_series[drawdown_series < 0].mean() * 100,
max_drawdown_duration_days=max_duration,
total_trades=len(trades),
win_rate=win_rate,
profit_factor=profit_factor,
avg_win_pct=avg_win_pct,
avg_loss_pct=avg_loss_pct,
equity_curve=equity_curve,
trade_list=trades_df,
)
Monthly Returns Heatmap
def compute_monthly_returns(equity_curve: pd.Series) -> pd.DataFrame:
"""Создаём матрицу месячных доходностей для heatmap"""
monthly = equity_curve.resample('ME').last()
monthly_returns = monthly.pct_change().dropna()
# Создаём матрицу год × месяц
matrix = monthly_returns.groupby([
monthly_returns.index.year,
monthly_returns.index.month
]).first().unstack()
matrix.columns = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
return matrix * 100 # в процентах
HTML отчёт
def generate_html_report(report: BacktestReport, strategy_name: str) -> str:
import plotly.graph_objects as go
from plotly.subplots import make_subplots
fig = make_subplots(
rows=3, cols=2,
subplot_titles=['Equity Curve', 'Drawdown', 'Monthly Returns', 'Trade P&L Distribution', 'Win/Loss', 'Rolling Sharpe'],
)
# Equity curve
fig.add_trace(go.Scatter(x=report.equity_curve.index, y=report.equity_curve.values,
name='Portfolio', line=dict(color='#00C853')), row=1, col=1)
# Drawdown
rolling_max = report.equity_curve.cummax()
drawdown = (report.equity_curve - rolling_max) / rolling_max * 100
fig.add_trace(go.Scatter(x=drawdown.index, y=drawdown.values,
fill='tozeroy', name='Drawdown', line=dict(color='#FF5252')), row=1, col=2)
# P&L distribution
if not report.trade_list.empty:
pnl_pct = report.trade_list['pnl'] / report.trade_list['entry_value'] * 100
fig.add_trace(go.Histogram(x=pnl_pct, name='Trade P&L %', nbinsx=30), row=2, col=1)
fig.update_layout(
title=f'Backtest Report: {strategy_name}',
height=1000,
showlegend=False,
template='plotly_dark',
)
return fig.to_html(include_plotlyjs='cdn')
Интерпретация ключевых метрик
| Метрика | Хорошо | Приемлемо | Плохо |
|---|---|---|---|
| Sharpe Ratio | > 2.0 | 1.0–2.0 | < 1.0 |
| Sortino Ratio | > 2.5 | 1.5–2.5 | < 1.5 |
| Max Drawdown | < 15% | 15–30% | > 30% |
| Profit Factor | > 2.0 | 1.5–2.0 | < 1.5 |
| Win Rate | > 55% (для trend) | 45–55% | < 45% |
Важно: высокий win rate без хорошего risk/reward — бесполезен. Стратегия с 80% win rate и средним loss = 5× average win убыточна. Profit Factor и expectancy важнее голого win rate.







