214 lines
5.2 KiB
Go
214 lines
5.2 KiB
Go
package backtest
|
|
|
|
import (
|
|
"math"
|
|
"sort"
|
|
|
|
"github.com/shopspring/decimal"
|
|
)
|
|
|
|
type Metrics struct {
|
|
TotalReturn float64 `json:"total_return"`
|
|
CAGR float64 `json:"cagr"`
|
|
AnnualizedVolatility float64 `json:"annualized_volatility"`
|
|
SharpeRatio float64 `json:"sharpe_ratio"`
|
|
SortinoRatio float64 `json:"sortino_ratio"`
|
|
MaxDrawdown float64 `json:"max_drawdown"`
|
|
CalmarRatio float64 `json:"calmar_ratio"`
|
|
WinRate float64 `json:"win_rate"`
|
|
AverageTradeReturn float64 `json:"average_trade_return"`
|
|
MedianTradeReturn float64 `json:"median_trade_return"`
|
|
ProfitFactor float64 `json:"profit_factor"`
|
|
AverageSpreadBps float64 `json:"average_spread_bps"`
|
|
AverageSlippageBps float64 `json:"average_slippage_bps"`
|
|
NumberOfTrades int `json:"number_of_trades"`
|
|
WorstOvernightGap float64 `json:"worst_overnight_gap"`
|
|
VaR95 float64 `json:"var_95"`
|
|
CVaR95 float64 `json:"cvar_95"`
|
|
CapacityEstimate float64 `json:"capacity_estimate"`
|
|
}
|
|
|
|
func ComputeMetrics(points []Point, trades []Trade) Metrics {
|
|
if len(points) == 0 {
|
|
return Metrics{}
|
|
}
|
|
start, _ := points[0].Equity.Float64()
|
|
end, _ := points[len(points)-1].Equity.Float64()
|
|
returns := make([]float64, 0, len(points)-1)
|
|
for _, point := range points[1:] {
|
|
r, _ := point.Return.Float64()
|
|
returns = append(returns, r)
|
|
}
|
|
tradeReturns := make([]float64, 0, len(trades))
|
|
spreads := make([]float64, 0, len(trades))
|
|
slippages := make([]float64, 0, len(trades))
|
|
profits := 0.0
|
|
losses := 0.0
|
|
wins := 0
|
|
worstGap := 0.0
|
|
capacity := 0.0
|
|
for _, trade := range trades {
|
|
r, _ := trade.Return.Float64()
|
|
tradeReturns = append(tradeReturns, r)
|
|
spread, _ := trade.SpreadBps.Float64()
|
|
spreads = append(spreads, spread)
|
|
slippage, _ := trade.SlippageBps.Float64()
|
|
slippages = append(slippages, slippage)
|
|
if r > 0 {
|
|
wins++
|
|
profits += r
|
|
} else {
|
|
losses += r
|
|
}
|
|
gap, _ := trade.OvernightGap.Float64()
|
|
if gap < worstGap {
|
|
worstGap = gap
|
|
}
|
|
tradeCapacity, _ := trade.CapacityRUB.Float64()
|
|
if tradeCapacity > 0 && (capacity == 0 || tradeCapacity < capacity) {
|
|
capacity = tradeCapacity
|
|
}
|
|
}
|
|
totalReturn := 0.0
|
|
if start > 0 {
|
|
totalReturn = end/start - 1
|
|
}
|
|
vol := stddev(returns) * math.Sqrt(252)
|
|
meanReturn := mean(returns)
|
|
sharpe := 0.0
|
|
if std := stddev(returns); std > 0 {
|
|
sharpe = meanReturn / std * math.Sqrt(252)
|
|
}
|
|
sortino := 0.0
|
|
if down := downsideStddev(returns); down > 0 {
|
|
sortino = meanReturn / down * math.Sqrt(252)
|
|
}
|
|
tradingDays := math.Max(float64(len(returns)), 1)
|
|
cagr := 0.0
|
|
if start > 0 && end > 0 {
|
|
cagr = math.Pow(end/start, 252/tradingDays) - 1
|
|
}
|
|
maxDD := maxDrawdown(points)
|
|
calmar := 0.0
|
|
if maxDD != 0 {
|
|
calmar = cagr / math.Abs(maxDD)
|
|
}
|
|
pf := 0.0
|
|
if losses != 0 {
|
|
pf = profits / math.Abs(losses)
|
|
}
|
|
var95 := percentile(returns, 0.05)
|
|
cvar95 := conditionalMean(returns, var95)
|
|
return Metrics{
|
|
TotalReturn: totalReturn,
|
|
CAGR: cagr,
|
|
AnnualizedVolatility: vol,
|
|
SharpeRatio: sharpe,
|
|
SortinoRatio: sortino,
|
|
MaxDrawdown: maxDD,
|
|
CalmarRatio: calmar,
|
|
WinRate: ratio(wins, len(tradeReturns)),
|
|
AverageTradeReturn: mean(tradeReturns),
|
|
MedianTradeReturn: percentile(tradeReturns, 0.50),
|
|
ProfitFactor: pf,
|
|
AverageSpreadBps: mean(spreads),
|
|
AverageSlippageBps: mean(slippages),
|
|
NumberOfTrades: len(trades),
|
|
WorstOvernightGap: worstGap,
|
|
VaR95: var95,
|
|
CVaR95: cvar95,
|
|
CapacityEstimate: capacity,
|
|
}
|
|
}
|
|
|
|
func maxDrawdown(points []Point) float64 {
|
|
peak := 0.0
|
|
maxDD := 0.0
|
|
for _, point := range points {
|
|
e, _ := point.Equity.Float64()
|
|
if e > peak {
|
|
peak = e
|
|
}
|
|
if peak > 0 {
|
|
dd := e/peak - 1
|
|
if dd < maxDD {
|
|
maxDD = dd
|
|
}
|
|
}
|
|
}
|
|
return maxDD
|
|
}
|
|
|
|
func mean(values []float64) float64 {
|
|
if len(values) == 0 {
|
|
return 0
|
|
}
|
|
sum := 0.0
|
|
for _, value := range values {
|
|
sum += value
|
|
}
|
|
return sum / float64(len(values))
|
|
}
|
|
|
|
func stddev(values []float64) float64 {
|
|
if len(values) < 2 {
|
|
return 0
|
|
}
|
|
m := mean(values)
|
|
sum := 0.0
|
|
for _, value := range values {
|
|
diff := value - m
|
|
sum += diff * diff
|
|
}
|
|
return math.Sqrt(sum / float64(len(values)-1))
|
|
}
|
|
|
|
func downsideStddev(values []float64) float64 {
|
|
var downs []float64
|
|
for _, value := range values {
|
|
if value < 0 {
|
|
downs = append(downs, value)
|
|
}
|
|
}
|
|
return stddev(downs)
|
|
}
|
|
|
|
func percentile(values []float64, q float64) float64 {
|
|
if len(values) == 0 {
|
|
return 0
|
|
}
|
|
cp := append([]float64(nil), values...)
|
|
sort.Float64s(cp)
|
|
pos := q * float64(len(cp)-1)
|
|
lower := int(math.Floor(pos))
|
|
upper := int(math.Ceil(pos))
|
|
if lower == upper {
|
|
return cp[lower]
|
|
}
|
|
weight := pos - float64(lower)
|
|
return cp[lower]*(1-weight) + cp[upper]*weight
|
|
}
|
|
|
|
func conditionalMean(values []float64, threshold float64) float64 {
|
|
var selected []float64
|
|
for _, value := range values {
|
|
if value <= threshold {
|
|
selected = append(selected, value)
|
|
}
|
|
}
|
|
return mean(selected)
|
|
}
|
|
|
|
func ratio(n, d int) float64 {
|
|
if d == 0 {
|
|
return 0
|
|
}
|
|
return float64(n) / float64(d)
|
|
}
|
|
|
|
func point(date string, equity, ret string) Point {
|
|
e, _ := decimal.NewFromString(equity)
|
|
r, _ := decimal.NewFromString(ret)
|
|
return Point{Date: date, Equity: e, Return: r}
|
|
}
|