package statistics import ( "errors" "fmt" "time" "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/common" "github.com/thrasher-corp/gocryptotrader/backtester/data" gctmath "github.com/thrasher-corp/gocryptotrader/common/math" gctkline "github.com/thrasher-corp/gocryptotrader/exchanges/kline" "github.com/thrasher-corp/gocryptotrader/log" ) // fSIL shorthand wrapper for FitStringToLimit func fSIL(str string, limit int) string { spacer := " " return common.FitStringToLimit(str, spacer, limit, true) } // CalculateBiggestEventDrawdown calculates the biggest drawdown using a slice of DataEvents func CalculateBiggestEventDrawdown(closePrices []data.Event) (Swing, error) { if len(closePrices) == 0 { return Swing{}, fmt.Errorf("%w to calculate drawdowns", errReceivedNoData) } var swings []Swing lowestPrice := closePrices[0].GetLowPrice() highestPrice := closePrices[0].GetHighPrice() lowestTime := closePrices[0].GetTime() highestTime := closePrices[0].GetTime() interval := closePrices[0].GetInterval() for i := range closePrices { currHigh := closePrices[i].GetHighPrice() currLow := closePrices[i].GetLowPrice() currTime := closePrices[i].GetTime() if lowestPrice.GreaterThan(currLow) && !currLow.IsZero() { lowestPrice = currLow lowestTime = currTime } if highestPrice.LessThan(currHigh) { if lowestTime.Equal(highestTime) { // create distinction if the greatest drawdown occurs within the same candle lowestTime = lowestTime.Add(interval.Duration() - time.Nanosecond) } intervals, err := gctkline.CalculateCandleDateRanges(highestTime, lowestTime, closePrices[i].GetInterval(), 0) if err != nil { return Swing{}, fmt.Errorf("cannot calculate max drawdown, date range error: %w", err) } if highestPrice.IsPositive() && lowestPrice.IsPositive() { swings = append(swings, Swing{ Highest: ValueAtTime{ Time: highestTime, Value: highestPrice, }, Lowest: ValueAtTime{ Time: lowestTime, Value: lowestPrice, }, DrawdownPercent: lowestPrice.Sub(highestPrice).Div(highestPrice).Mul(decimal.NewFromInt(100)), IntervalDuration: int64(len(intervals.Ranges[0].Intervals)), }) } // reset the drawdown highestPrice = currHigh highestTime = currTime lowestPrice = currLow lowestTime = currTime } } if (len(swings) > 0 && swings[len(swings)-1].Lowest.Value != closePrices[len(closePrices)-1].GetLowPrice()) || swings == nil { // need to close out the final drawdown if lowestTime.Equal(highestTime) { // create distinction if the greatest drawdown occurs within the same candle lowestTime = lowestTime.Add(interval.Duration() - time.Nanosecond) } intervals, err := gctkline.CalculateCandleDateRanges(highestTime, lowestTime, closePrices[0].GetInterval(), 0) if err != nil { return Swing{}, fmt.Errorf("cannot close out max drawdown calculation: %w", err) } drawdownPercent := decimal.Zero if highestPrice.GreaterThan(decimal.Zero) { drawdownPercent = lowestPrice.Sub(highestPrice).Div(highestPrice).Mul(decimal.NewFromInt(100)) } if lowestTime.Equal(highestTime) { // create distinction if the greatest drawdown occurs within the same candle lowestTime = lowestTime.Add(interval.Duration() - time.Nanosecond) } swings = append(swings, Swing{ Highest: ValueAtTime{ Time: highestTime, Value: highestPrice, }, Lowest: ValueAtTime{ Time: lowestTime, Value: lowestPrice, }, DrawdownPercent: drawdownPercent, IntervalDuration: int64(len(intervals.Ranges[0].Intervals)), }) } var maxDrawdown Swing if len(swings) > 0 { maxDrawdown = swings[0] } for i := range swings { if swings[i].DrawdownPercent.LessThan(maxDrawdown.DrawdownPercent) { maxDrawdown = swings[i] } } return maxDrawdown, nil } // CalculateBiggestValueAtTimeDrawdown calculates the biggest drawdown using a slice of ValueAtTimes func CalculateBiggestValueAtTimeDrawdown(closePrices []ValueAtTime, interval gctkline.Interval) (Swing, error) { if len(closePrices) == 0 { return Swing{}, fmt.Errorf("%w to calculate drawdowns", errReceivedNoData) } var swings []Swing lowestPrice := closePrices[0].Value highestPrice := closePrices[0].Value lowestTime := closePrices[0].Time highestTime := closePrices[0].Time for i := range closePrices { currHigh := closePrices[i].Value currLow := closePrices[i].Value currTime := closePrices[i].Time if lowestPrice.GreaterThan(currLow) && !currLow.IsZero() { lowestPrice = currLow lowestTime = currTime } if highestPrice.LessThan(currHigh) && highestPrice.IsPositive() { if lowestTime.Equal(highestTime) { // create distinction if the greatest drawdown occurs within the same candle lowestTime = lowestTime.Add(interval.Duration() - time.Nanosecond) } intervals, err := gctkline.CalculateCandleDateRanges(highestTime, lowestTime, interval, 0) if err != nil { return Swing{}, err } swings = append(swings, Swing{ Highest: ValueAtTime{ Time: highestTime, Value: highestPrice, }, Lowest: ValueAtTime{ Time: lowestTime, Value: lowestPrice, }, DrawdownPercent: lowestPrice.Sub(highestPrice).Div(highestPrice).Mul(decimal.NewFromInt(100)), IntervalDuration: int64(len(intervals.Ranges[0].Intervals)), }) // reset the drawdown highestPrice = currHigh highestTime = currTime lowestPrice = currLow lowestTime = currTime } } if (len(swings) > 0 && !swings[len(swings)-1].Lowest.Value.Equal(closePrices[len(closePrices)-1].Value)) || swings == nil { // need to close out the final drawdown if lowestTime.Equal(highestTime) { // create distinction if the greatest drawdown occurs within the same candle lowestTime = lowestTime.Add(interval.Duration() - time.Nanosecond) } intervals, err := gctkline.CalculateCandleDateRanges(highestTime, lowestTime, interval, 0) if err != nil { log.Error(common.CurrencyStatistics, err) } drawdownPercent := decimal.Zero if highestPrice.GreaterThan(decimal.Zero) { drawdownPercent = lowestPrice.Sub(highestPrice).Div(highestPrice).Mul(decimal.NewFromInt(100)) } if lowestTime.Equal(highestTime) { // create distinction if the greatest drawdown occurs within the same candle lowestTime = lowestTime.Add(interval.Duration() - time.Nanosecond) } swings = append(swings, Swing{ Highest: ValueAtTime{ Time: highestTime, Value: highestPrice, }, Lowest: ValueAtTime{ Time: lowestTime, Value: lowestPrice, }, DrawdownPercent: drawdownPercent, IntervalDuration: int64(len(intervals.Ranges[0].Intervals)), }) } var maxDrawdown Swing if len(swings) > 0 { maxDrawdown = swings[0] } for i := range swings { if swings[i].DrawdownPercent.LessThan(maxDrawdown.DrawdownPercent) { maxDrawdown = swings[i] } } return maxDrawdown, nil } // CalculateRatios creates arithmetic and geometric ratios from funding or currency pair data func CalculateRatios(benchmarkRates, returnsPerCandle []decimal.Decimal, riskFreeRatePerCandle decimal.Decimal, maxDrawdown *Swing, logMessage string) (arithmeticStats, geometricStats *Ratios, err error) { var arithmeticBenchmarkAverage, geometricBenchmarkAverage decimal.Decimal arithmeticBenchmarkAverage, err = gctmath.DecimalArithmeticMean(benchmarkRates) if err != nil { return nil, nil, err } geometricBenchmarkAverage, err = gctmath.DecimalFinancialGeometricMean(benchmarkRates) if err != nil { return nil, nil, err } riskFreeRateForPeriod := riskFreeRatePerCandle.Mul(decimal.NewFromInt(int64(len(benchmarkRates)))) var arithmeticReturnsPerCandle, geometricReturnsPerCandle, arithmeticSharpe, arithmeticSortino, arithmeticInformation, arithmeticCalmar, geomSharpe, geomSortino, geomInformation, geomCalmar decimal.Decimal arithmeticReturnsPerCandle, err = gctmath.DecimalArithmeticMean(returnsPerCandle) if err != nil { return nil, nil, err } geometricReturnsPerCandle, err = gctmath.DecimalFinancialGeometricMean(returnsPerCandle) if err != nil { return nil, nil, err } arithmeticSharpe, err = gctmath.DecimalSharpeRatio(returnsPerCandle, riskFreeRatePerCandle, arithmeticReturnsPerCandle) if err != nil { return nil, nil, err } arithmeticSortino, err = gctmath.DecimalSortinoRatio(returnsPerCandle, riskFreeRatePerCandle, arithmeticReturnsPerCandle) if err != nil && !errors.Is(err, gctmath.ErrNoNegativeResults) { if errors.Is(err, gctmath.ErrInexactConversion) { log.Warnf(common.Statistics, "%s funding arithmetic sortino ratio %v", logMessage, err) } else { return nil, nil, err } } arithmeticInformation, err = gctmath.DecimalInformationRatio(returnsPerCandle, benchmarkRates, arithmeticReturnsPerCandle, arithmeticBenchmarkAverage) if err != nil { return nil, nil, err } arithmeticCalmar, err = gctmath.DecimalCalmarRatio(maxDrawdown.Highest.Value, maxDrawdown.Lowest.Value, arithmeticReturnsPerCandle, riskFreeRateForPeriod) if err != nil { log.Warnf(common.Statistics, "%s funding arithmetic calmar ratio %v", logMessage, err) } arithmeticStats = &Ratios{} if !arithmeticSharpe.IsZero() { arithmeticStats.SharpeRatio = arithmeticSharpe } if !arithmeticSortino.IsZero() { arithmeticStats.SortinoRatio = arithmeticSortino } if !arithmeticInformation.IsZero() { arithmeticStats.InformationRatio = arithmeticInformation } if !arithmeticCalmar.IsZero() { arithmeticStats.CalmarRatio = arithmeticCalmar } geomSharpe, err = gctmath.DecimalSharpeRatio(returnsPerCandle, riskFreeRatePerCandle, geometricReturnsPerCandle) if err != nil { return nil, nil, err } geomSortino, err = gctmath.DecimalSortinoRatio(returnsPerCandle, riskFreeRatePerCandle, geometricReturnsPerCandle) if err != nil && !errors.Is(err, gctmath.ErrNoNegativeResults) { if errors.Is(err, gctmath.ErrInexactConversion) { log.Warnf(common.Statistics, "%s geometric sortino ratio %v", logMessage, err) } else { return nil, nil, err } } geomInformation, err = gctmath.DecimalInformationRatio(returnsPerCandle, benchmarkRates, geometricReturnsPerCandle, geometricBenchmarkAverage) if err != nil { return nil, nil, err } geomCalmar, err = gctmath.DecimalCalmarRatio(maxDrawdown.Highest.Value, maxDrawdown.Lowest.Value, geometricReturnsPerCandle, riskFreeRateForPeriod) if err != nil { log.Warnf(common.Statistics, "%s funding geometric calmar ratio %v", logMessage, err) } geometricStats = &Ratios{} if !arithmeticSharpe.IsZero() { geometricStats.SharpeRatio = geomSharpe } if !arithmeticSortino.IsZero() { geometricStats.SortinoRatio = geomSortino } if !arithmeticInformation.IsZero() { geometricStats.InformationRatio = geomInformation } if !arithmeticCalmar.IsZero() { geometricStats.CalmarRatio = geomCalmar } return arithmeticStats, geometricStats, nil }