fix drawdown

This commit is contained in:
Sven Woldt 2023-11-02 06:59:40 +01:00
parent 0c2f4aef9d
commit 539e35d290
No known key found for this signature in database
GPG Key ID: D08249FCD4B52446
6 changed files with 124 additions and 46 deletions

View File

@ -87,6 +87,8 @@ type SessionSymbolReport struct {
GrossLoss fixedpoint.Value `json:"grossLoss,omitempty"` GrossLoss fixedpoint.Value `json:"grossLoss,omitempty"`
PRR fixedpoint.Value `json:"prr,omitempty"` PRR fixedpoint.Value `json:"prr,omitempty"`
PercentProfitable fixedpoint.Value `json:"percentProfitable,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"` MaxProfit fixedpoint.Value `json:"maxProfit,omitempty"`
MaxLoss fixedpoint.Value `json:"maxLoss,omitempty"` MaxLoss fixedpoint.Value `json:"maxLoss,omitempty"`
AvgProfit fixedpoint.Value `json:"avgProfit,omitempty"` AvgProfit fixedpoint.Value `json:"avgProfit,omitempty"`
@ -100,10 +102,12 @@ type SessionSymbolReport struct {
AnnualHistoricVolatility fixedpoint.Value `json:"annualHistoricVolatility,omitempty"` AnnualHistoricVolatility fixedpoint.Value `json:"annualHistoricVolatility,omitempty"`
CAGR fixedpoint.Value `json:"cagr,omitempty"` CAGR fixedpoint.Value `json:"cagr,omitempty"`
Calmar fixedpoint.Value `json:"calmar,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"` Kelly fixedpoint.Value `json:"kelly,omitempty"`
OptimalF fixedpoint.Value `json:"optimalF,omitempty"` OptimalF fixedpoint.Value `json:"optimalF,omitempty"`
StatN fixedpoint.Value `json:"statN,omitempty"` StatN fixedpoint.Value `json:"statN,omitempty"`
StatNStdErr fixedpoint.Value `json:"statNStdErr,omitempty"` StdErr fixedpoint.Value `json:"statNStdErr,omitempty"`
Sortino fixedpoint.Value `json:"sortinoRatio"` Sortino fixedpoint.Value `json:"sortinoRatio"`
ProfitFactor fixedpoint.Value `json:"profitFactor"` ProfitFactor fixedpoint.Value `json:"profitFactor"`
WinningRatio fixedpoint.Value `json:"winningRatio"` WinningRatio fixedpoint.Value `json:"winningRatio"`

View File

@ -4,7 +4,6 @@ import (
"bufio" "bufio"
"context" "context"
"fmt" "fmt"
"math"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@ -635,7 +634,10 @@ func createSymbolReport(userConfig *bbgo.Config, session *bbgo.ExchangeSession,
initBalances := accountConfig.Balances.BalanceMap() initBalances := accountConfig.Balances.BalanceMap()
finalBalances := session.GetAccount().Balances() finalBalances := session.GetAccount().Balances()
maxProfit := n(intervalProfit.Profits.Max()) 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)) roundTurnCount := n(float64(tradeStats.NumOfProfitTrade + tradeStats.NumOfLossTrade))
roundTurnLength := n(float64(intervalProfit.Profits.Length())) roundTurnLength := n(float64(intervalProfit.Profits.Length()))
winningCount := n(float64(tradeStats.NumOfProfitTrade)) winningCount := n(float64(tradeStats.NumOfProfitTrade))
@ -650,7 +652,7 @@ func createSymbolReport(userConfig *bbgo.Config, session *bbgo.ExchangeSession,
sortinoRatio := n(intervalProfit.GetSortino()) sortinoRatio := n(intervalProfit.GetSortino())
annVolHis := n(types.AnnualHistoricVolatility(intervalProfit.Profits)) annVolHis := n(types.AnnualHistoricVolatility(intervalProfit.Profits))
totalTimeInMarketSec, avgHoldSec := intervalProfit.GetTimeInMarket() totalTimeInMarketSec, avgHoldSec := intervalProfit.GetTimeInMarket()
statn, stdErr := types.StatN(*intervalProfit.Profits) statn, stdErr := types.StatN(intervalProfit.Profits)
symbolReport := backtest.SessionSymbolReport{ symbolReport := backtest.SessionSymbolReport{
Exchange: session.Exchange.Name(), Exchange: session.Exchange.Name(),
Symbol: symbol, Symbol: symbol,
@ -668,6 +670,8 @@ func createSymbolReport(userConfig *bbgo.Config, session *bbgo.ExchangeSession,
WinningRatio: tradeStats.WinningRatio, WinningRatio: tradeStats.WinningRatio,
PercentProfitable: winningPct, PercentProfitable: winningPct,
ProfitFactor: tradeStats.ProfitFactor, ProfitFactor: tradeStats.ProfitFactor,
MaxDrawdown: n(maxDrawdown),
AverageDrawdown: n(avgDrawdown),
MaxProfit: maxProfit, MaxProfit: maxProfit,
MaxLoss: maxLoss, MaxLoss: maxLoss,
MaxLossStreak: tradeStats.MaximumConsecutiveLosses, MaxLossStreak: tradeStats.MaximumConsecutiveLosses,
@ -681,9 +685,9 @@ func createSymbolReport(userConfig *bbgo.Config, session *bbgo.ExchangeSession,
PnL: report, PnL: report,
PRR: types.PRR(tradeStats.GrossProfit, tradeStats.GrossLoss, winningCount, loosingCount), PRR: types.PRR(tradeStats.GrossProfit, tradeStats.GrossLoss, winningCount, loosingCount),
Kelly: types.KellyCriterion(tradeStats.ProfitFactor, winningPct), Kelly: types.KellyCriterion(tradeStats.ProfitFactor, winningPct),
OptimalF: types.OptimalF(*intervalProfit.Profits), OptimalF: types.OptimalF(intervalProfit.Profits),
StatN: statn, StatN: statn,
StatNStdErr: stdErr, StdErr: stdErr,
Sharpe: sharpeRatio, Sharpe: sharpeRatio,
Sortino: sortinoRatio, Sortino: sortinoRatio,
} }
@ -696,7 +700,9 @@ func createSymbolReport(userConfig *bbgo.Config, session *bbgo.ExchangeSession,
), 0) ), 0)
symbolReport.CAGR = n(cagr) 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 { for _, s := range session.Subscriptions {
symbolReport.Subscriptions = append(symbolReport.Subscriptions, s) symbolReport.Subscriptions = append(symbolReport.Subscriptions, s)
@ -719,6 +725,7 @@ func createSymbolReport(userConfig *bbgo.Config, session *bbgo.ExchangeSession,
func n(v float64) fixedpoint.Value { func n(v float64) fixedpoint.Value {
return fixedpoint.NewFromFloat(v) return fixedpoint.NewFromFloat(v)
} }
func verify(userConfig *bbgo.Config, backtestService *service.BacktestService, sourceExchanges map[types.ExchangeName]types.Exchange, startTime, endTime time.Time) error { func verify(userConfig *bbgo.Config, backtestService *service.BacktestService, sourceExchanges map[types.ExchangeName]types.Exchange, startTime, endTime time.Time) error {
for _, sourceExchange := range sourceExchanges { for _, sourceExchange := range sourceExchanges {
err := backtestService.Verify(sourceExchange, userConfig.Backtest.Symbols, startTime, endTime) err := backtestService.Verify(sourceExchange, userConfig.Backtest.Symbols, startTime, endTime)

View File

@ -112,6 +112,18 @@ func (s Slice) Average() float64 {
return total / float64(len(s)) 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) { func (s Slice) Diff() (values Slice) {
for i, v := range s { for i, v := range s {
if i == 0 { if i == 0 {

View File

@ -31,11 +31,32 @@ func CAGR(initial, final float64, days int) float64 {
return math.Pow(x, y) - 1 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 { func CalmarRatio(cagr, maxDrawdown float64) float64 {
return cagr / maxDrawdown 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. // KellyCriterion the famous method for trade sizing.
func KellyCriterion(profitFactor, winP fixedpoint.Value) fixedpoint.Value { func KellyCriterion(profitFactor, winP fixedpoint.Value) fixedpoint.Value {
return profitFactor.Mul(winP).Sub(fixedpoint.One.Sub(winP)).Div(profitFactor) return profitFactor.Mul(winP).Sub(fixedpoint.One.Sub(winP)).Div(profitFactor)
@ -105,3 +126,26 @@ func NNZ(x, y float64) float64 {
} }
return x 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
}

View File

@ -42,3 +42,15 @@ func TestOptimalF(t *testing.T) {
f := OptimalF(roundturns) f := OptimalF(roundturns)
assert.EqualValues(t, 0.45, f) 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])
}
}

View File

@ -19,42 +19,6 @@ const (
ErrProfitArrEmpty = "profits array empty. Did you create IntervalProfitCollector instance using NewIntervalProfitCollector?" 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 { type ProfitReport struct {
StartTime time.Time `json:"startTime"` StartTime time.Time `json:"startTime"`
Profit float64 `json:"profit"` Profit float64 `json:"profit"`
@ -69,6 +33,41 @@ func (s ProfitReport) String() string {
return string(b) 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 // Determine average and total time spend in market
func (s *IntervalProfitCollector) GetTimeInMarket() (avgHoldSec, totalTimeInMarketSec int64) { func (s *IntervalProfitCollector) GetTimeInMarket() (avgHoldSec, totalTimeInMarketSec int64) {
if s.Profits == nil { if s.Profits == nil {
@ -116,7 +115,7 @@ func (s *IntervalProfitCollector) GetNumOfProfitableIntervals() (profit int) {
if s.Profits == nil { if s.Profits == nil {
panic(ErrProfitArrEmpty) panic(ErrProfitArrEmpty)
} }
for _, v := range *s.Profits { for _, v := range s.Profits {
if v > 1. { if v > 1. {
profit += 1 profit += 1
} }
@ -130,7 +129,7 @@ func (s *IntervalProfitCollector) GetNumOfNonProfitableIntervals() (nonprofit in
if s.Profits == nil { if s.Profits == nil {
panic(ErrProfitArrEmpty) panic(ErrProfitArrEmpty)
} }
for _, v := range *s.Profits { for _, v := range s.Profits {
if v <= 1. { if v <= 1. {
nonprofit += 1 nonprofit += 1
} }