feat: add MetricsCalculator service
- Total return, CAGR, MDD, Sharpe ratio, volatility - Benchmark comparison metrics Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d9d9c8d772
commit
a78c00ecbb
174
backend/app/services/backtest/metrics.py
Normal file
174
backend/app/services/backtest/metrics.py
Normal file
@ -0,0 +1,174 @@
|
||||
"""
|
||||
Backtest metrics calculation service.
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from typing import List
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
|
||||
|
||||
@dataclass
|
||||
class BacktestMetrics:
|
||||
"""Calculated backtest metrics."""
|
||||
total_return: Decimal
|
||||
cagr: Decimal
|
||||
mdd: Decimal
|
||||
sharpe_ratio: Decimal
|
||||
volatility: Decimal
|
||||
benchmark_return: Decimal
|
||||
excess_return: Decimal
|
||||
|
||||
|
||||
class MetricsCalculator:
|
||||
"""Calculates performance metrics from equity curve."""
|
||||
|
||||
TRADING_DAYS_PER_YEAR = 252
|
||||
RISK_FREE_RATE = Decimal("0.03") # 3% annual risk-free rate
|
||||
|
||||
@classmethod
|
||||
def calculate_all(
|
||||
cls,
|
||||
portfolio_values: List[Decimal],
|
||||
benchmark_values: List[Decimal],
|
||||
) -> BacktestMetrics:
|
||||
"""Calculate all metrics from equity curves."""
|
||||
if len(portfolio_values) < 2:
|
||||
return BacktestMetrics(
|
||||
total_return=Decimal("0"),
|
||||
cagr=Decimal("0"),
|
||||
mdd=Decimal("0"),
|
||||
sharpe_ratio=Decimal("0"),
|
||||
volatility=Decimal("0"),
|
||||
benchmark_return=Decimal("0"),
|
||||
excess_return=Decimal("0"),
|
||||
)
|
||||
|
||||
years = Decimal(str(len(portfolio_values))) / Decimal(str(cls.TRADING_DAYS_PER_YEAR))
|
||||
|
||||
total_return = cls.calculate_total_return(portfolio_values)
|
||||
benchmark_return = cls.calculate_total_return(benchmark_values)
|
||||
|
||||
returns = cls._calculate_daily_returns(portfolio_values)
|
||||
|
||||
return BacktestMetrics(
|
||||
total_return=total_return,
|
||||
cagr=cls.calculate_cagr(portfolio_values, years),
|
||||
mdd=cls.calculate_mdd(portfolio_values),
|
||||
sharpe_ratio=cls.calculate_sharpe_ratio(returns),
|
||||
volatility=cls.calculate_volatility(returns),
|
||||
benchmark_return=benchmark_return,
|
||||
excess_return=total_return - benchmark_return,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def calculate_total_return(cls, values: List[Decimal]) -> Decimal:
|
||||
"""Calculate total return percentage."""
|
||||
if not values or values[0] == 0:
|
||||
return Decimal("0")
|
||||
return (values[-1] - values[0]) / values[0] * 100
|
||||
|
||||
@classmethod
|
||||
def calculate_cagr(cls, values: List[Decimal], years: Decimal) -> Decimal:
|
||||
"""Calculate Compound Annual Growth Rate."""
|
||||
if not values or values[0] == 0 or years == 0:
|
||||
return Decimal("0")
|
||||
|
||||
total_return = float(values[-1]) / float(values[0])
|
||||
if total_return <= 0:
|
||||
return Decimal("0")
|
||||
|
||||
cagr = (total_return ** (1 / float(years)) - 1) * 100
|
||||
return Decimal(str(round(cagr, 4)))
|
||||
|
||||
@classmethod
|
||||
def calculate_mdd(cls, values: List[Decimal]) -> Decimal:
|
||||
"""Calculate Maximum Drawdown (negative value)."""
|
||||
if not values:
|
||||
return Decimal("0")
|
||||
|
||||
peak = values[0]
|
||||
max_dd = Decimal("0")
|
||||
|
||||
for value in values:
|
||||
if value > peak:
|
||||
peak = value
|
||||
if peak > 0:
|
||||
dd = (peak - value) / peak * 100
|
||||
if dd > max_dd:
|
||||
max_dd = dd
|
||||
|
||||
return -max_dd
|
||||
|
||||
@classmethod
|
||||
def calculate_sharpe_ratio(cls, daily_returns: List[Decimal]) -> Decimal:
|
||||
"""Calculate Sharpe Ratio."""
|
||||
if len(daily_returns) < 2:
|
||||
return Decimal("0")
|
||||
|
||||
returns_float = [float(r) for r in daily_returns]
|
||||
|
||||
avg_return = sum(returns_float) / len(returns_float)
|
||||
variance = sum((r - avg_return) ** 2 for r in returns_float) / len(returns_float)
|
||||
std_return = math.sqrt(variance) if variance > 0 else 0
|
||||
|
||||
if std_return == 0:
|
||||
return Decimal("0")
|
||||
|
||||
# Annualize
|
||||
annual_return = avg_return * cls.TRADING_DAYS_PER_YEAR
|
||||
annual_std = std_return * math.sqrt(cls.TRADING_DAYS_PER_YEAR)
|
||||
|
||||
sharpe = (annual_return - float(cls.RISK_FREE_RATE)) / annual_std
|
||||
return Decimal(str(round(sharpe, 4)))
|
||||
|
||||
@classmethod
|
||||
def calculate_volatility(cls, daily_returns: List[Decimal]) -> Decimal:
|
||||
"""Calculate annualized volatility."""
|
||||
if len(daily_returns) < 2:
|
||||
return Decimal("0")
|
||||
|
||||
returns_float = [float(r) for r in daily_returns]
|
||||
|
||||
avg_return = sum(returns_float) / len(returns_float)
|
||||
variance = sum((r - avg_return) ** 2 for r in returns_float) / len(returns_float)
|
||||
std_return = math.sqrt(variance) if variance > 0 else 0
|
||||
|
||||
# Annualize
|
||||
annual_volatility = std_return * math.sqrt(cls.TRADING_DAYS_PER_YEAR) * 100
|
||||
return Decimal(str(round(annual_volatility, 4)))
|
||||
|
||||
@classmethod
|
||||
def calculate_drawdown_series(cls, values: List[Decimal]) -> List[Decimal]:
|
||||
"""Calculate drawdown for each point."""
|
||||
if not values:
|
||||
return []
|
||||
|
||||
drawdowns = []
|
||||
peak = values[0]
|
||||
|
||||
for value in values:
|
||||
if value > peak:
|
||||
peak = value
|
||||
if peak > 0:
|
||||
dd = (peak - value) / peak * 100
|
||||
drawdowns.append(-dd)
|
||||
else:
|
||||
drawdowns.append(Decimal("0"))
|
||||
|
||||
return drawdowns
|
||||
|
||||
@classmethod
|
||||
def _calculate_daily_returns(cls, values: List[Decimal]) -> List[Decimal]:
|
||||
"""Calculate daily returns from value series."""
|
||||
if len(values) < 2:
|
||||
return []
|
||||
|
||||
returns = []
|
||||
for i in range(1, len(values)):
|
||||
if values[i - 1] != 0:
|
||||
ret = (values[i] - values[i - 1]) / values[i - 1]
|
||||
returns.append(ret)
|
||||
else:
|
||||
returns.append(Decimal("0"))
|
||||
|
||||
return returns
|
||||
Loading…
x
Reference in New Issue
Block a user