From 539e35d290f7fa072728bc97d2a7885e64be3991 Mon Sep 17 00:00:00 2001 From: Sven Woldt Date: Thu, 2 Nov 2023 06:59:40 +0100 Subject: [PATCH] fix drawdown --- pkg/backtest/report.go | 6 ++- pkg/cmd/backtest.go | 19 ++++++--- pkg/datatype/floats/slice.go | 12 ++++++ pkg/types/trade_stat.go | 46 +++++++++++++++++++++- pkg/types/trade_stat_test.go | 12 ++++++ pkg/types/trade_stats.go | 75 ++++++++++++++++++------------------ 6 files changed, 124 insertions(+), 46 deletions(-) diff --git a/pkg/backtest/report.go b/pkg/backtest/report.go index ceea79455..3772e2786 100644 --- a/pkg/backtest/report.go +++ b/pkg/backtest/report.go @@ -87,6 +87,8 @@ type SessionSymbolReport struct { GrossLoss fixedpoint.Value `json:"grossLoss,omitempty"` PRR fixedpoint.Value `json:"prr,omitempty"` PercentProfitable fixedpoint.Value `json:"percentProfitable,omitempty"` + MaxDrawdown fixedpoint.Value `json:"maxDrawdown,omitempty"` + AverageDrawdown fixedpoint.Value `json:"avgDrawdown,omitempty"` MaxProfit fixedpoint.Value `json:"maxProfit,omitempty"` MaxLoss fixedpoint.Value `json:"maxLoss,omitempty"` AvgProfit fixedpoint.Value `json:"avgProfit,omitempty"` @@ -100,10 +102,12 @@ type SessionSymbolReport struct { AnnualHistoricVolatility fixedpoint.Value `json:"annualHistoricVolatility,omitempty"` CAGR fixedpoint.Value `json:"cagr,omitempty"` Calmar fixedpoint.Value `json:"calmar,omitempty"` + Sterling fixedpoint.Value `json:"sterling,omitempty"` + Burke fixedpoint.Value `json:"burke,omitempty"` Kelly fixedpoint.Value `json:"kelly,omitempty"` OptimalF fixedpoint.Value `json:"optimalF,omitempty"` StatN fixedpoint.Value `json:"statN,omitempty"` - StatNStdErr fixedpoint.Value `json:"statNStdErr,omitempty"` + StdErr fixedpoint.Value `json:"statNStdErr,omitempty"` Sortino fixedpoint.Value `json:"sortinoRatio"` ProfitFactor fixedpoint.Value `json:"profitFactor"` WinningRatio fixedpoint.Value `json:"winningRatio"` diff --git a/pkg/cmd/backtest.go b/pkg/cmd/backtest.go index f02b1aae0..7c7fe9e33 100644 --- a/pkg/cmd/backtest.go +++ b/pkg/cmd/backtest.go @@ -4,7 +4,6 @@ import ( "bufio" "context" "fmt" - "math" "os" "path/filepath" "strings" @@ -635,7 +634,10 @@ func createSymbolReport(userConfig *bbgo.Config, session *bbgo.ExchangeSession, initBalances := accountConfig.Balances.BalanceMap() finalBalances := session.GetAccount().Balances() maxProfit := n(intervalProfit.Profits.Max()) - maxLoss := n(math.Abs(intervalProfit.Profits.Min())) + maxLoss := n(intervalProfit.Profits.Min()) + drawdown := types.Drawdown(intervalProfit.Profits) + maxDrawdown := drawdown.Max() + avgDrawdown := drawdown.Average() roundTurnCount := n(float64(tradeStats.NumOfProfitTrade + tradeStats.NumOfLossTrade)) roundTurnLength := n(float64(intervalProfit.Profits.Length())) winningCount := n(float64(tradeStats.NumOfProfitTrade)) @@ -650,7 +652,7 @@ func createSymbolReport(userConfig *bbgo.Config, session *bbgo.ExchangeSession, sortinoRatio := n(intervalProfit.GetSortino()) annVolHis := n(types.AnnualHistoricVolatility(intervalProfit.Profits)) totalTimeInMarketSec, avgHoldSec := intervalProfit.GetTimeInMarket() - statn, stdErr := types.StatN(*intervalProfit.Profits) + statn, stdErr := types.StatN(intervalProfit.Profits) symbolReport := backtest.SessionSymbolReport{ Exchange: session.Exchange.Name(), Symbol: symbol, @@ -668,6 +670,8 @@ func createSymbolReport(userConfig *bbgo.Config, session *bbgo.ExchangeSession, WinningRatio: tradeStats.WinningRatio, PercentProfitable: winningPct, ProfitFactor: tradeStats.ProfitFactor, + MaxDrawdown: n(maxDrawdown), + AverageDrawdown: n(avgDrawdown), MaxProfit: maxProfit, MaxLoss: maxLoss, MaxLossStreak: tradeStats.MaximumConsecutiveLosses, @@ -681,9 +685,9 @@ func createSymbolReport(userConfig *bbgo.Config, session *bbgo.ExchangeSession, PnL: report, PRR: types.PRR(tradeStats.GrossProfit, tradeStats.GrossLoss, winningCount, loosingCount), Kelly: types.KellyCriterion(tradeStats.ProfitFactor, winningPct), - OptimalF: types.OptimalF(*intervalProfit.Profits), + OptimalF: types.OptimalF(intervalProfit.Profits), StatN: statn, - StatNStdErr: stdErr, + StdErr: stdErr, Sharpe: sharpeRatio, Sortino: sortinoRatio, } @@ -696,7 +700,9 @@ func createSymbolReport(userConfig *bbgo.Config, session *bbgo.ExchangeSession, ), 0) symbolReport.CAGR = n(cagr) - symbolReport.Calmar = n(types.CalmarRatio(cagr, tradeStats.MaxDrawdown)) + symbolReport.Calmar = n(types.CalmarRatio(cagr, maxDrawdown)) + symbolReport.Sterling = n(types.SterlingRatio(cagr, avgDrawdown)) + symbolReport.Burke = n(types.BurkeRatio(cagr, drawdown.AverageSquared())) for _, s := range session.Subscriptions { symbolReport.Subscriptions = append(symbolReport.Subscriptions, s) @@ -719,6 +725,7 @@ func createSymbolReport(userConfig *bbgo.Config, session *bbgo.ExchangeSession, func n(v float64) fixedpoint.Value { return fixedpoint.NewFromFloat(v) } + func verify(userConfig *bbgo.Config, backtestService *service.BacktestService, sourceExchanges map[types.ExchangeName]types.Exchange, startTime, endTime time.Time) error { for _, sourceExchange := range sourceExchanges { err := backtestService.Verify(sourceExchange, userConfig.Backtest.Symbols, startTime, endTime) diff --git a/pkg/datatype/floats/slice.go b/pkg/datatype/floats/slice.go index 1d610a4f5..dd7b14aa1 100644 --- a/pkg/datatype/floats/slice.go +++ b/pkg/datatype/floats/slice.go @@ -112,6 +112,18 @@ func (s Slice) Average() float64 { return total / float64(len(s)) } +func (s Slice) AverageSquared() float64 { + if len(s) == 0 { + return 0.0 + } + + total := 0.0 + for _, value := range s { + total += math.Pow(value, 2) + } + return total / float64(len(s)) +} + func (s Slice) Diff() (values Slice) { for i, v := range s { if i == 0 { diff --git a/pkg/types/trade_stat.go b/pkg/types/trade_stat.go index cf1c68cfa..d89c59484 100644 --- a/pkg/types/trade_stat.go +++ b/pkg/types/trade_stat.go @@ -31,11 +31,32 @@ func CAGR(initial, final float64, days int) float64 { return math.Pow(x, y) - 1 } -// CalmarRatio relates the capaital growth rate to the maximum drawdown. +// measures of risk-adjusted return based on drawdown risk + +// calmar ratio - discounts expected excess return of a portfolio by the +// worst expected maximum draw down for that portfolio +// CR = E(re)/MD1 = (E(r) - rf) / MD1 func CalmarRatio(cagr, maxDrawdown float64) float64 { return cagr / maxDrawdown } +// Sterling ratio +// discounts the expected excess return of a portfolio by the average of the N worst +// expected maximum drawdowns for that portfolio +// CR = E(re) / (1/N)(sum MDi) +func SterlingRatio(cagr, avgDrawdown float64) float64 { + return cagr / avgDrawdown +} + +// Burke Ratio +// similar to sterling, but less sensitive to outliers +// discounts the expected excess return of a portfolio by the square root of the average +// of the N worst expected maximum drawdowns for that portfolio +// BR = E(re) / ((1/N)(sum MD^2))^0.5 ---> smoothing, can take roots, logs etc +func BurkeRatio(cagr, avgDrawdownSquared float64) float64 { + return cagr / math.Sqrt(avgDrawdownSquared) +} + // KellyCriterion the famous method for trade sizing. func KellyCriterion(profitFactor, winP fixedpoint.Value) fixedpoint.Value { return profitFactor.Mul(winP).Sub(fixedpoint.One.Sub(winP)).Div(profitFactor) @@ -105,3 +126,26 @@ func NNZ(x, y float64) float64 { } return x } + +// Compute the drawdown function associated to a portfolio equity curve, +// also called the portfolio underwater equity curve. +// Portfolio Optimization with Drawdown Constraints, Chekhlov et al., 2000 +// http://papers.ssrn.com/sol3/papers.cfm?abstract_id=223323 +func Drawdown(equityCurve floats.Slice) floats.Slice { + // Initialize highWaterMark + highWaterMark := math.Inf(-1) + + // Create ddVector with the same length as equityCurve + ddVector := make([]float64, len(equityCurve)) + + // Loop over all the values to compute the drawdown vector + for i := 0; i < len(equityCurve); i++ { + if equityCurve[i] > highWaterMark { + highWaterMark = equityCurve[i] + } + + ddVector[i] = (highWaterMark - equityCurve[i]) / highWaterMark + } + + return ddVector +} diff --git a/pkg/types/trade_stat_test.go b/pkg/types/trade_stat_test.go index 2b8312b11..4236d0b25 100644 --- a/pkg/types/trade_stat_test.go +++ b/pkg/types/trade_stat_test.go @@ -42,3 +42,15 @@ func TestOptimalF(t *testing.T) { f := OptimalF(roundturns) assert.EqualValues(t, 0.45, f) } + +func TestDrawdown(t *testing.T) { + roundturns := floats.Slice{100, 50, 100} + expected := []float64{.0, .5, .0} + drawdown := Drawdown(roundturns) + assert.EqualValues(t, 0.5, drawdown.Max()) + assert.EqualValues(t, 0.16666666666666666, drawdown.Average()) + assert.EqualValues(t, 0.08333333333333333, drawdown.AverageSquared()) + for i, v := range expected { + assert.EqualValues(t, v, drawdown[i]) + } +} diff --git a/pkg/types/trade_stats.go b/pkg/types/trade_stats.go index 821bb2569..0cc3e0f48 100644 --- a/pkg/types/trade_stats.go +++ b/pkg/types/trade_stats.go @@ -19,42 +19,6 @@ const ( ErrProfitArrEmpty = "profits array empty. Did you create IntervalProfitCollector instance using NewIntervalProfitCollector?" ) -type IntervalProfitCollector struct { - Interval Interval `json:"interval"` - Profits *floats.Slice `json:"profits"` - TimeInMarket []time.Duration `json:"timeInMarket"` - Timestamp *floats.Slice `json:"timestamp"` - tmpTime time.Time `json:"tmpTime"` -} - -func NewIntervalProfitCollector(i Interval, startTime time.Time) *IntervalProfitCollector { - return &IntervalProfitCollector{Interval: i, tmpTime: startTime, Profits: &floats.Slice{1.}, Timestamp: &floats.Slice{float64(startTime.Unix())}} -} - -// Update the collector by every traded profit -func (s *IntervalProfitCollector) Update(profit *Profit) { - s.TimeInMarket = append(s.TimeInMarket, profit.TradedAt.Sub(profit.PositionOpenedAt)) - - if s.tmpTime.IsZero() { - panic(ErrStartTimeNotValid) - } else { - duration := s.Interval.Duration() - if profit.TradedAt.Before(s.tmpTime.Add(duration)) { - (*s.Profits)[len(*s.Profits)-1] *= 1. + profit.NetProfitMargin.Float64() - } else { - for { - s.Profits.Update(1.) - s.tmpTime = s.tmpTime.Add(duration) - s.Timestamp.Update(float64(s.tmpTime.Unix())) - if profit.TradedAt.Before(s.tmpTime.Add(duration)) { - (*s.Profits)[len(*s.Profits)-1] *= 1. + profit.NetProfitMargin.Float64() - break - } - } - } - } -} - type ProfitReport struct { StartTime time.Time `json:"startTime"` Profit float64 `json:"profit"` @@ -69,6 +33,41 @@ func (s ProfitReport) String() string { return string(b) } +type IntervalProfitCollector struct { + Interval Interval `json:"interval"` + Profits floats.Slice `json:"profits"` + TimeInMarket []time.Duration `json:"timeInMarket"` + Timestamp floats.Slice `json:"timestamp"` + tmpTime time.Time `json:"tmpTime"` +} + +func NewIntervalProfitCollector(i Interval, startTime time.Time) *IntervalProfitCollector { + return &IntervalProfitCollector{Interval: i, tmpTime: startTime, Profits: floats.Slice{1.}, Timestamp: floats.Slice{float64(startTime.Unix())}} +} + +// Update the collector by every traded profit +func (s *IntervalProfitCollector) Update(profit *Profit) { + if s.tmpTime.IsZero() { + panic(ErrStartTimeNotValid) + } else { + s.TimeInMarket = append(s.TimeInMarket, profit.TradedAt.Sub(profit.PositionOpenedAt)) + duration := s.Interval.Duration() + if profit.TradedAt.Before(s.tmpTime.Add(duration)) { + (s.Profits)[len(s.Profits)-1] *= 1. + profit.NetProfitMargin.Float64() + } else { + for { + s.Profits.Update(1.) + s.tmpTime = s.tmpTime.Add(duration) + s.Timestamp.Update(float64(s.tmpTime.Unix())) + if profit.TradedAt.Before(s.tmpTime.Add(duration)) { + (s.Profits)[len(s.Profits)-1] *= 1. + profit.NetProfitMargin.Float64() + break + } + } + } + } +} + // Determine average and total time spend in market func (s *IntervalProfitCollector) GetTimeInMarket() (avgHoldSec, totalTimeInMarketSec int64) { if s.Profits == nil { @@ -116,7 +115,7 @@ func (s *IntervalProfitCollector) GetNumOfProfitableIntervals() (profit int) { if s.Profits == nil { panic(ErrProfitArrEmpty) } - for _, v := range *s.Profits { + for _, v := range s.Profits { if v > 1. { profit += 1 } @@ -130,7 +129,7 @@ func (s *IntervalProfitCollector) GetNumOfNonProfitableIntervals() (nonprofit in if s.Profits == nil { panic(ErrProfitArrEmpty) } - for _, v := range *s.Profits { + for _, v := range s.Profits { if v <= 1. { nonprofit += 1 }