Files
gocryptotrader/backtester/eventhandlers/statistics/currencystatistics.go
Scott 6eaa2e4073 Backtester: USD tracking (#818)
* Initial concept for creating price tracking pairs

* Completes coverage, even with a slow test

* I dont know what point to hook this stuff up

* Bit of a broken way of handling tracking pairs

* Correctly calculates USD rates against all currencies

* Removes dependency on GCT config

* Failed currency statistics redesign

* initial Update chart to use highcharts

* Minor changes to stats

* Creats funding stats to handle the stat calculations. Needs more work

* tracks USD snapshots and BREAKS THINGS FURTHER

* Fixed!

* Adds ratio calculations and such, but its WRONG. do it at totals level dummy

* End of day basic lint

* Remaining lints

* USD totals statistics

* Minor panic fixes

* Printing of funding stats, but its bad

* Properly calculates overall benchmark, moves funding stat output

* Adds some template charge, removes duplicate fields

* New charts!

* Darkcharts. funding protection when disabled

* Now works with usd tracking/funding disabled!

* Attempting to only show working stats based on settings.

* Spruces up the goose/reporting

* Completes report HTML rendering

* lint and test fixes

* funding statistics testing

* slightly more test coverage

* Test coverage

* Initial documentation

* Fixes tests

* Database testing and rendering improvements and breakages

* report and cmd rendering, linting. fix comma output. rm gct cfg

* PR mode 🎉 Path field, config builder support,testing,linting,docs

* minor calculation improvement

* Secret lint that did not show up locally

* Disable USD tracking for example configs

* ShazNitNoScope

* Forgotten errors

* ""

* literally Logarithmically logically renders the date 👀

* Fixes typos, fixes parallel test, fixes chart gui and exporting
2021-11-08 12:10:15 +11:00

398 lines
17 KiB
Go

package statistics
import (
"fmt"
"sort"
"time"
"github.com/shopspring/decimal"
"github.com/thrasher-corp/gocryptotrader/backtester/common"
gctcommon "github.com/thrasher-corp/gocryptotrader/common"
"github.com/thrasher-corp/gocryptotrader/common/convert"
gctmath "github.com/thrasher-corp/gocryptotrader/common/math"
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
gctkline "github.com/thrasher-corp/gocryptotrader/exchanges/kline"
gctorder "github.com/thrasher-corp/gocryptotrader/exchanges/order"
"github.com/thrasher-corp/gocryptotrader/log"
)
// CalculateResults calculates all statistics for the exchange, asset, currency pair
func (c *CurrencyPairStatistic) CalculateResults(riskFreeRate decimal.Decimal) error {
var errs gctcommon.Errors
var err error
first := c.Events[0]
sep := fmt.Sprintf("%v %v %v |\t", first.DataEvent.GetExchange(), first.DataEvent.GetAssetType(), first.DataEvent.Pair())
firstPrice := first.DataEvent.ClosePrice()
last := c.Events[len(c.Events)-1]
lastPrice := last.DataEvent.ClosePrice()
for i := range last.Transactions.Orders {
if last.Transactions.Orders[i].Side == gctorder.Buy {
c.BuyOrders++
} else if last.Transactions.Orders[i].Side == gctorder.Sell {
c.SellOrders++
}
}
for i := range c.Events {
price := c.Events[i].DataEvent.ClosePrice()
if c.LowestClosePrice.IsZero() || price.LessThan(c.LowestClosePrice) {
c.LowestClosePrice = price
}
if price.GreaterThan(c.HighestClosePrice) {
c.HighestClosePrice = price
}
}
oneHundred := decimal.NewFromInt(100)
c.MarketMovement = lastPrice.Sub(firstPrice).Div(firstPrice).Mul(oneHundred)
if first.Holdings.TotalValue.GreaterThan(decimal.Zero) {
c.StrategyMovement = last.Holdings.TotalValue.Sub(first.Holdings.TotalValue).Div(first.Holdings.TotalValue).Mul(oneHundred)
}
c.calculateHighestCommittedFunds()
returnsPerCandle := make([]decimal.Decimal, len(c.Events))
benchmarkRates := make([]decimal.Decimal, len(c.Events))
var allDataEvents []common.DataEventHandler
for i := range c.Events {
returnsPerCandle[i] = c.Events[i].Holdings.ChangeInTotalValuePercent
allDataEvents = append(allDataEvents, c.Events[i].DataEvent)
if i == 0 {
continue
}
if c.Events[i].SignalEvent != nil && c.Events[i].SignalEvent.GetDirection() == common.MissingData {
c.ShowMissingDataWarning = true
}
benchmarkRates[i] = c.Events[i].DataEvent.ClosePrice().Sub(
c.Events[i-1].DataEvent.ClosePrice()).Div(
c.Events[i-1].DataEvent.ClosePrice())
}
// remove the first entry as its zero and impacts
// ratio calculations as no movement has been made
benchmarkRates = benchmarkRates[1:]
returnsPerCandle = returnsPerCandle[1:]
c.MaxDrawdown, err = CalculateBiggestEventDrawdown(allDataEvents)
if err != nil {
errs = append(errs, err)
}
interval := first.DataEvent.GetInterval()
intervalsPerYear := interval.IntervalsPerYear()
riskFreeRatePerCandle := riskFreeRate.Div(decimal.NewFromFloat(intervalsPerYear))
c.ArithmeticRatios, c.GeometricRatios, err = CalculateRatios(benchmarkRates, returnsPerCandle, riskFreeRatePerCandle, &c.MaxDrawdown, sep)
if err != nil {
return err
}
if last.Holdings.QuoteInitialFunds.GreaterThan(decimal.Zero) {
cagr, err := gctmath.DecimalCompoundAnnualGrowthRate(
last.Holdings.QuoteInitialFunds,
last.Holdings.TotalValue,
decimal.NewFromFloat(intervalsPerYear),
decimal.NewFromInt(int64(len(c.Events))),
)
if err != nil {
errs = append(errs, err)
}
if !cagr.IsZero() {
c.CompoundAnnualGrowthRate = cagr
}
}
c.IsStrategyProfitable = last.Holdings.TotalValue.GreaterThan(first.Holdings.TotalValue)
c.DoesPerformanceBeatTheMarket = c.StrategyMovement.GreaterThan(c.MarketMovement)
c.TotalFees = last.Holdings.TotalFees.Round(8)
c.TotalValueLostToVolumeSizing = last.Holdings.TotalValueLostToVolumeSizing.Round(2)
c.TotalValueLost = last.Holdings.TotalValueLost.Round(2)
c.TotalValueLostToSlippage = last.Holdings.TotalValueLostToSlippage.Round(2)
c.TotalAssetValue = last.Holdings.BaseValue.Round(8)
if len(errs) > 0 {
return errs
}
return nil
}
// PrintResults outputs all calculated statistics to the command line
func (c *CurrencyPairStatistic) PrintResults(e string, a asset.Item, p currency.Pair, usingExchangeLevelFunding bool) {
var errs gctcommon.Errors
sort.Slice(c.Events, func(i, j int) bool {
return c.Events[i].DataEvent.GetTime().Before(c.Events[j].DataEvent.GetTime())
})
last := c.Events[len(c.Events)-1]
first := c.Events[0]
c.StartingClosePrice = first.DataEvent.ClosePrice()
c.EndingClosePrice = last.DataEvent.ClosePrice()
c.TotalOrders = c.BuyOrders + c.SellOrders
last.Holdings.TotalValueLost = last.Holdings.TotalValueLostToSlippage.Add(last.Holdings.TotalValueLostToVolumeSizing)
sep := fmt.Sprintf("%v %v %v |\t", e, a, p)
currStr := fmt.Sprintf("------------------Stats for %v %v %v------------------------------------------", e, a, p)
log.Infof(log.BackTester, currStr[:61])
log.Infof(log.BackTester, "%s Highest committed funds: %s at %v", sep, convert.DecimalToHumanFriendlyString(c.HighestCommittedFunds.Value, 8, ".", ","), c.HighestCommittedFunds.Time)
log.Infof(log.BackTester, "%s Buy orders: %s", sep, convert.IntToHumanFriendlyString(c.BuyOrders, ","))
log.Infof(log.BackTester, "%s Buy value: %s", sep, convert.DecimalToHumanFriendlyString(last.Holdings.BoughtValue, 8, ".", ","))
log.Infof(log.BackTester, "%s Buy amount: %s %s", sep, convert.DecimalToHumanFriendlyString(last.Holdings.BoughtAmount, 8, ".", ","), last.Holdings.Pair.Base)
log.Infof(log.BackTester, "%s Sell orders: %s", sep, convert.IntToHumanFriendlyString(c.SellOrders, ","))
log.Infof(log.BackTester, "%s Sell value: %s", sep, convert.DecimalToHumanFriendlyString(last.Holdings.SoldValue, 8, ".", ","))
log.Infof(log.BackTester, "%s Sell amount: %s", sep, convert.DecimalToHumanFriendlyString(last.Holdings.SoldAmount, 8, ".", ","))
log.Infof(log.BackTester, "%s Total orders: %s\n\n", sep, convert.IntToHumanFriendlyString(c.TotalOrders, ","))
log.Info(log.BackTester, "------------------Max Drawdown-------------------------------")
log.Infof(log.BackTester, "%s Highest Price of drawdown: %s at %v", sep, convert.DecimalToHumanFriendlyString(c.MaxDrawdown.Highest.Value, 8, ".", ","), c.MaxDrawdown.Highest.Time)
log.Infof(log.BackTester, "%s Lowest Price of drawdown: %s at %v", sep, convert.DecimalToHumanFriendlyString(c.MaxDrawdown.Lowest.Value, 8, ".", ","), c.MaxDrawdown.Lowest.Time)
log.Infof(log.BackTester, "%s Calculated Drawdown: %s%%", sep, convert.DecimalToHumanFriendlyString(c.MaxDrawdown.DrawdownPercent, 8, ".", ","))
log.Infof(log.BackTester, "%s Difference: %s", sep, convert.DecimalToHumanFriendlyString(c.MaxDrawdown.Highest.Value.Sub(c.MaxDrawdown.Lowest.Value), 2, ".", ","))
log.Infof(log.BackTester, "%s Drawdown length: %s\n\n", sep, convert.IntToHumanFriendlyString(c.MaxDrawdown.IntervalDuration, ","))
if !usingExchangeLevelFunding {
log.Info(log.BackTester, "------------------Ratios------------------------------------------------")
log.Info(log.BackTester, "------------------Rates-------------------------------------------------")
log.Infof(log.BackTester, "%s Compound Annual Growth Rate: %s", sep, convert.DecimalToHumanFriendlyString(c.CompoundAnnualGrowthRate, 2, ".", ","))
log.Info(log.BackTester, "------------------Arithmetic--------------------------------------------")
if c.ShowMissingDataWarning {
log.Infoln(log.BackTester, "Missing data was detected during this backtesting run")
log.Infoln(log.BackTester, "Ratio calculations will be skewed")
}
log.Infof(log.BackTester, "%s Sharpe ratio: %v", sep, c.ArithmeticRatios.SharpeRatio.Round(4))
log.Infof(log.BackTester, "%s Sortino ratio: %v", sep, c.ArithmeticRatios.SortinoRatio.Round(4))
log.Infof(log.BackTester, "%s Information ratio: %v", sep, c.ArithmeticRatios.InformationRatio.Round(4))
log.Infof(log.BackTester, "%s Calmar ratio: %v", sep, c.ArithmeticRatios.CalmarRatio.Round(4))
log.Info(log.BackTester, "------------------Geometric--------------------------------------------")
if c.ShowMissingDataWarning {
log.Infoln(log.BackTester, "Missing data was detected during this backtesting run")
log.Infoln(log.BackTester, "Ratio calculations will be skewed")
}
log.Infof(log.BackTester, "%s Sharpe ratio: %v", sep, c.GeometricRatios.SharpeRatio.Round(4))
log.Infof(log.BackTester, "%s Sortino ratio: %v", sep, c.GeometricRatios.SortinoRatio.Round(4))
log.Infof(log.BackTester, "%s Information ratio: %v", sep, c.GeometricRatios.InformationRatio.Round(4))
log.Infof(log.BackTester, "%s Calmar ratio: %v\n\n", sep, c.GeometricRatios.CalmarRatio.Round(4))
}
log.Info(log.BackTester, "------------------Results------------------------------------")
log.Infof(log.BackTester, "%s Starting Close Price: %s", sep, convert.DecimalToHumanFriendlyString(c.StartingClosePrice, 8, ".", ","))
log.Infof(log.BackTester, "%s Finishing Close Price: %s", sep, convert.DecimalToHumanFriendlyString(c.EndingClosePrice, 8, ".", ","))
log.Infof(log.BackTester, "%s Lowest Close Price: %s", sep, convert.DecimalToHumanFriendlyString(c.LowestClosePrice, 8, ".", ","))
log.Infof(log.BackTester, "%s Highest Close Price: %s", sep, convert.DecimalToHumanFriendlyString(c.HighestClosePrice, 8, ".", ","))
log.Infof(log.BackTester, "%s Market movement: %s%%", sep, convert.DecimalToHumanFriendlyString(c.MarketMovement, 2, ".", ","))
if !usingExchangeLevelFunding {
log.Infof(log.BackTester, "%s Strategy movement: %s%%", sep, convert.DecimalToHumanFriendlyString(c.StrategyMovement, 2, ".", ","))
log.Infof(log.BackTester, "%s Did it beat the market: %v", sep, c.StrategyMovement.GreaterThan(c.MarketMovement))
}
log.Infof(log.BackTester, "%s Value lost to volume sizing: %s", sep, convert.DecimalToHumanFriendlyString(c.TotalValueLostToVolumeSizing, 2, ".", ","))
log.Infof(log.BackTester, "%s Value lost to slippage: %s", sep, convert.DecimalToHumanFriendlyString(c.TotalValueLostToSlippage, 2, ".", ","))
log.Infof(log.BackTester, "%s Total Value lost: %s", sep, convert.DecimalToHumanFriendlyString(c.TotalValueLost, 2, ".", ","))
log.Infof(log.BackTester, "%s Total Fees: %s\n\n", sep, convert.DecimalToHumanFriendlyString(c.TotalFees, 8, ".", ","))
log.Infof(log.BackTester, "%s Final holdings value: %s", sep, convert.DecimalToHumanFriendlyString(c.TotalAssetValue, 8, ".", ","))
if !usingExchangeLevelFunding {
// the following have no direct translation to individual exchange level funds as they
// combine base and quote values
log.Infof(log.BackTester, "%s Final funds: %s", sep, convert.DecimalToHumanFriendlyString(last.Holdings.QuoteSize, 8, ".", ","))
log.Infof(log.BackTester, "%s Final holdings: %s", sep, convert.DecimalToHumanFriendlyString(last.Holdings.BaseSize, 8, ".", ","))
log.Infof(log.BackTester, "%s Final total value: %s\n\n", sep, convert.DecimalToHumanFriendlyString(last.Holdings.TotalValue, 8, ".", ","))
}
if len(errs) > 0 {
log.Info(log.BackTester, "------------------Errors-------------------------------------")
for i := range errs {
log.Error(log.BackTester, errs[i].Error())
}
}
}
// CalculateBiggestEventDrawdown calculates the biggest drawdown using a slice of DataEvents
func CalculateBiggestEventDrawdown(closePrices []common.DataEventHandler) (Swing, error) {
if len(closePrices) == 0 {
return Swing{}, fmt.Errorf("%w to calculate drawdowns", errReceivedNoData)
}
var swings []Swing
lowestPrice := closePrices[0].LowPrice()
highestPrice := closePrices[0].HighPrice()
lowestTime := closePrices[0].GetTime()
highestTime := closePrices[0].GetTime()
interval := closePrices[0].GetInterval()
for i := range closePrices {
currHigh := closePrices[i].HighPrice()
currLow := closePrices[i].LowPrice()
currTime := closePrices[i].GetTime()
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, closePrices[i].GetInterval(), 0)
if err != nil {
log.Error(log.BackTester, err)
continue
}
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].LowPrice()) || 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{}, 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
}
func (c *CurrencyPairStatistic) calculateHighestCommittedFunds() {
for i := range c.Events {
if c.Events[i].Holdings.BaseSize.Mul(c.Events[i].DataEvent.ClosePrice()).GreaterThan(c.HighestCommittedFunds.Value) {
c.HighestCommittedFunds.Value = c.Events[i].Holdings.BaseSize.Mul(c.Events[i].DataEvent.ClosePrice())
c.HighestCommittedFunds.Time = c.Events[i].Holdings.Timestamp
}
}
}
// 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(log.BackTester, 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
}