Backtester: Fix dividing by zero when missing data (#841)

* Fixes zero data issues on backtester & fixes drawdown calcs for zeros

* Fixes valuation of holdings + when simultaneous processing disabled

* Fixes an issue for when there is a lot of missing data
This commit is contained in:
Scott
2021-11-19 12:59:34 +11:00
committed by GitHub
parent da3402476e
commit 3cde5ad7dd
16 changed files with 134 additions and 71 deletions

View File

@@ -65,10 +65,10 @@ type EventHandler interface {
// DataEventHandler interface used for loading and interacting with Data
type DataEventHandler interface {
EventHandler
ClosePrice() decimal.Decimal
HighPrice() decimal.Decimal
LowPrice() decimal.Decimal
OpenPrice() decimal.Decimal
GetClosePrice() decimal.Decimal
GetHighPrice() decimal.Decimal
GetLowPrice() decimal.Decimal
GetOpenPrice() decimal.Decimal
}
// Directioner dictates the side of an order

View File

@@ -74,10 +74,10 @@ func TestStream(t *testing.T) {
f.GetAssetType()
f.GetReason()
f.AppendReason("fake")
f.ClosePrice()
f.HighPrice()
f.LowPrice()
f.OpenPrice()
f.GetClosePrice()
f.GetHighPrice()
f.GetLowPrice()
f.GetOpenPrice()
d.AppendStream(fakeDataHandler{time: 1})
d.AppendStream(fakeDataHandler{time: 4})
@@ -208,18 +208,18 @@ func (t fakeDataHandler) GetReason() string {
func (t fakeDataHandler) AppendReason(string) {
}
func (t fakeDataHandler) ClosePrice() decimal.Decimal {
func (t fakeDataHandler) GetClosePrice() decimal.Decimal {
return decimal.Zero
}
func (t fakeDataHandler) HighPrice() decimal.Decimal {
func (t fakeDataHandler) GetHighPrice() decimal.Decimal {
return decimal.Zero
}
func (t fakeDataHandler) LowPrice() decimal.Decimal {
func (t fakeDataHandler) GetLowPrice() decimal.Decimal {
return decimal.Zero
}
func (t fakeDataHandler) OpenPrice() decimal.Decimal {
func (t fakeDataHandler) GetOpenPrice() decimal.Decimal {
return decimal.Zero
}

View File

@@ -40,7 +40,7 @@ func (e *Exchange) ExecuteOrder(o order.Event, data data.Handler, orderManager *
},
Direction: o.GetDirection(),
Amount: o.GetAmount(),
ClosePrice: data.Latest().ClosePrice(),
ClosePrice: data.Latest().GetClosePrice(),
}
eventFunds := o.GetAllocatedFunds()
cs, err := e.GetCurrencySettings(o.GetExchange(), o.GetAssetType(), o.Pair())

View File

@@ -9,13 +9,14 @@ import (
)
// Create makes a Holding struct to track total values of strategy holdings over the course of a backtesting run
func Create(ev common.EventHandler, funding funding.IPairReader) (Holding, error) {
func Create(ev ClosePriceReader, funding funding.IPairReader) (Holding, error) {
if ev == nil {
return Holding{}, common.ErrNilEvent
}
if funding.QuoteInitialFunds().LessThan(decimal.Zero) {
return Holding{}, ErrInitialFundsZero
}
return Holding{
Offset: ev.GetOffset(),
Pair: ev.Pair(),
@@ -26,7 +27,7 @@ func Create(ev common.EventHandler, funding funding.IPairReader) (Holding, error
QuoteSize: funding.QuoteInitialFunds(),
BaseInitialFunds: funding.BaseInitialFunds(),
BaseSize: funding.BaseInitialFunds(),
TotalInitialValue: funding.BaseInitialFunds().Mul(funding.QuoteInitialFunds()).Add(funding.QuoteInitialFunds()),
TotalInitialValue: funding.QuoteInitialFunds().Add(funding.BaseInitialFunds().Mul(ev.GetClosePrice())),
}, nil
}
@@ -40,7 +41,7 @@ func (h *Holding) Update(e fill.Event, f funding.IPairReader) {
// UpdateValue calculates the holding's value for a data event's time and price
func (h *Holding) UpdateValue(d common.DataEventHandler) {
h.Timestamp = d.GetTime()
latest := d.ClosePrice()
latest := d.GetClosePrice()
h.Offset = d.GetOffset()
h.updateValue(latest)
}

View File

@@ -5,6 +5,7 @@ import (
"time"
"github.com/shopspring/decimal"
"github.com/thrasher-corp/gocryptotrader/backtester/common"
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
)
@@ -44,3 +45,10 @@ type Holding struct {
TotalValueLostToSlippage decimal.Decimal `json:"total-value-lost-to-slippage"`
TotalValueLost decimal.Decimal `json:"total-value-lost"`
}
// ClosePriceReader is used for holdings calculations
// without needing to consider event types
type ClosePriceReader interface {
common.EventHandler
GetClosePrice() decimal.Decimal
}

View File

@@ -24,9 +24,9 @@ func (c *CurrencyPairStatistic) CalculateResults(riskFreeRate decimal.Decimal) e
first := c.Events[0]
sep := fmt.Sprintf("%v %v %v |\t", first.DataEvent.GetExchange(), first.DataEvent.GetAssetType(), first.DataEvent.Pair())
firstPrice := first.DataEvent.ClosePrice()
firstPrice := first.DataEvent.GetClosePrice()
last := c.Events[len(c.Events)-1]
lastPrice := last.DataEvent.ClosePrice()
lastPrice := last.DataEvent.GetClosePrice()
for i := range last.Transactions.Orders {
if last.Transactions.Orders[i].Side == gctorder.Buy {
c.BuyOrders++
@@ -35,7 +35,7 @@ func (c *CurrencyPairStatistic) CalculateResults(riskFreeRate decimal.Decimal) e
}
}
for i := range c.Events {
price := c.Events[i].DataEvent.ClosePrice()
price := c.Events[i].DataEvent.GetClosePrice()
if c.LowestClosePrice.IsZero() || price.LessThan(c.LowestClosePrice) {
c.LowestClosePrice = price
}
@@ -45,7 +45,9 @@ func (c *CurrencyPairStatistic) CalculateResults(riskFreeRate decimal.Decimal) e
}
oneHundred := decimal.NewFromInt(100)
c.MarketMovement = lastPrice.Sub(firstPrice).Div(firstPrice).Mul(oneHundred)
if !firstPrice.IsZero() {
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)
}
@@ -63,9 +65,16 @@ func (c *CurrencyPairStatistic) CalculateResults(riskFreeRate decimal.Decimal) e
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())
if c.Events[i].DataEvent.GetClosePrice().IsZero() || c.Events[i-1].DataEvent.GetClosePrice().IsZero() {
// closing price for the current candle or previous candle is zero, use the previous
// benchmark rate to allow some consistency
c.ShowMissingDataWarning = true
benchmarkRates[i] = benchmarkRates[i-1]
continue
}
benchmarkRates[i] = c.Events[i].DataEvent.GetClosePrice().Sub(
c.Events[i-1].DataEvent.GetClosePrice()).Div(
c.Events[i-1].DataEvent.GetClosePrice())
}
// remove the first entry as its zero and impacts
@@ -121,8 +130,8 @@ func (c *CurrencyPairStatistic) PrintResults(e string, a asset.Item, p currency.
})
last := c.Events[len(c.Events)-1]
first := c.Events[0]
c.StartingClosePrice = first.DataEvent.ClosePrice()
c.EndingClosePrice = last.DataEvent.ClosePrice()
c.StartingClosePrice = first.DataEvent.GetClosePrice()
c.EndingClosePrice = last.DataEvent.GetClosePrice()
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)
@@ -207,21 +216,21 @@ func CalculateBiggestEventDrawdown(closePrices []common.DataEventHandler) (Swing
return Swing{}, fmt.Errorf("%w to calculate drawdowns", errReceivedNoData)
}
var swings []Swing
lowestPrice := closePrices[0].LowPrice()
highestPrice := closePrices[0].HighPrice()
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].HighPrice()
currLow := closePrices[i].LowPrice()
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) && highestPrice.IsPositive() {
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)
@@ -231,18 +240,20 @@ func CalculateBiggestEventDrawdown(closePrices []common.DataEventHandler) (Swing
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)),
})
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
@@ -250,7 +261,7 @@ func CalculateBiggestEventDrawdown(closePrices []common.DataEventHandler) (Swing
lowestTime = currTime
}
}
if (len(swings) > 0 && swings[len(swings)-1].Lowest.Value != closePrices[len(closePrices)-1].LowPrice()) || swings == nil {
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
@@ -297,8 +308,8 @@ func CalculateBiggestEventDrawdown(closePrices []common.DataEventHandler) (Swing
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())
if c.Events[i].Holdings.BaseSize.Mul(c.Events[i].DataEvent.GetClosePrice()).GreaterThan(c.HighestCommittedFunds.Value) {
c.HighestCommittedFunds.Value = c.Events[i].Holdings.BaseSize.Mul(c.Events[i].DataEvent.GetClosePrice())
c.HighestCommittedFunds.Time = c.Events[i].Holdings.Timestamp
}
}

View File

@@ -5,6 +5,7 @@ import (
"time"
"github.com/shopspring/decimal"
"github.com/thrasher-corp/gocryptotrader/backtester/common"
"github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/portfolio/compliance"
"github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/portfolio/holdings"
"github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/event"
@@ -105,6 +106,7 @@ func TestCalculateResults(t *testing.T) {
SignalEvent: &signal.Signal{
Base: even2,
ClosePrice: decimal.NewFromInt(1337),
Direction: common.MissingData,
},
}
@@ -116,6 +118,41 @@ func TestCalculateResults(t *testing.T) {
if !cs.MarketMovement.Equal(decimal.NewFromFloat(-33.15)) {
t.Error("expected -33.15")
}
ev3 := ev2
ev3.DataEvent = &kline.Kline{
Base: even2,
Open: decimal.NewFromInt(1339),
Close: decimal.NewFromInt(1339),
Low: decimal.NewFromInt(1339),
High: decimal.NewFromInt(1339),
Volume: decimal.NewFromInt(1339),
}
cs.Events = append(cs.Events, ev, ev3)
cs.Events[0].DataEvent = &kline.Kline{
Base: even2,
Open: decimal.Zero,
Close: decimal.Zero,
Low: decimal.Zero,
High: decimal.Zero,
Volume: decimal.Zero,
}
err = cs.CalculateResults(decimal.NewFromFloat(0.03))
if err != nil {
t.Error(err)
}
cs.Events[1].DataEvent = &kline.Kline{
Base: even2,
Open: decimal.Zero,
Close: decimal.Zero,
Low: decimal.Zero,
High: decimal.Zero,
Volume: decimal.Zero,
}
err = cs.CalculateResults(decimal.NewFromFloat(0.03))
if err != nil {
t.Error(err)
}
}
func TestPrintResults(t *testing.T) {

View File

@@ -176,7 +176,6 @@ func CalculateIndividualFundingStatistics(disableUSDTracking bool, reportItem *f
item.HighestClosePrice.Time = closePrices[i].Time
}
}
for i := range relatedStats {
if relatedStats[i].stat == nil {
return nil, fmt.Errorf("%w related stats", common.ErrNilArguments)

View File

@@ -354,7 +354,7 @@ func (s *Statistic) PrintAllEventsChronologically() {
currencyStatistic.Events[i].DataEvent.GetExchange(),
currencyStatistic.Events[i].DataEvent.GetAssetType(),
currencyStatistic.Events[i].DataEvent.Pair(),
currencyStatistic.Events[i].DataEvent.ClosePrice().Round(8),
currencyStatistic.Events[i].DataEvent.GetClosePrice().Round(8),
currencyStatistic.Events[i].DataEvent.GetReason()))
default:
errs = append(errs, fmt.Errorf("%v %v %v unexpected data received %+v", exch, a, pair, currencyStatistic.Events[i]))

View File

@@ -32,10 +32,10 @@ func (s *Strategy) GetBaseData(d data.Handler) (signal.Signal, error) {
Interval: latest.GetInterval(),
Reason: latest.GetReason(),
},
ClosePrice: latest.ClosePrice(),
HighPrice: latest.HighPrice(),
OpenPrice: latest.OpenPrice(),
LowPrice: latest.LowPrice(),
ClosePrice: latest.GetClosePrice(),
HighPrice: latest.GetHighPrice(),
OpenPrice: latest.GetOpenPrice(),
LowPrice: latest.GetLowPrice(),
}, nil
}

View File

@@ -52,7 +52,7 @@ func (s *Strategy) OnSignal(d data.Handler, _ funding.IFundTransferer, _ portfol
return &es, nil
}
es.SetPrice(d.Latest().ClosePrice())
es.SetPrice(d.Latest().GetClosePrice())
es.SetDirection(order.Buy)
es.AppendReason("DCA purchases on every iteration")
return &es, nil

View File

@@ -55,7 +55,7 @@ func (s *Strategy) OnSignal(d data.Handler, _ funding.IFundTransferer, _ portfol
if err != nil {
return nil, err
}
es.SetPrice(d.Latest().ClosePrice())
es.SetPrice(d.Latest().GetClosePrice())
if offset := d.Offset(); offset <= int(s.rsiPeriod.IntPart()) {
es.AppendReason("Not enough data for signal generation")

View File

@@ -102,7 +102,7 @@ func (s *Strategy) OnSimultaneousSignals(d []data.Handler, f funding.IFundTransf
if err != nil {
return nil, err
}
es.SetPrice(d[i].Latest().ClosePrice())
es.SetPrice(d[i].Latest().GetClosePrice())
offset := d[i].Offset()
if offset <= int(s.mfiPeriod.IntPart()) {

View File

@@ -2,22 +2,22 @@ package kline
import "github.com/shopspring/decimal"
// ClosePrice returns the closing price of a kline
func (k *Kline) ClosePrice() decimal.Decimal {
// GetClosePrice returns the closing price of a kline
func (k *Kline) GetClosePrice() decimal.Decimal {
return k.Close
}
// HighPrice returns the high price of a kline
func (k *Kline) HighPrice() decimal.Decimal {
// GetHighPrice returns the high price of a kline
func (k *Kline) GetHighPrice() decimal.Decimal {
return k.High
}
// LowPrice returns the low price of a kline
func (k *Kline) LowPrice() decimal.Decimal {
// GetLowPrice returns the low price of a kline
func (k *Kline) GetLowPrice() decimal.Decimal {
return k.Low
}
// OpenPrice returns the open price of a kline
func (k *Kline) OpenPrice() decimal.Decimal {
// GetOpenPrice returns the open price of a kline
func (k *Kline) GetOpenPrice() decimal.Decimal {
return k.Open
}

View File

@@ -11,7 +11,7 @@ func TestClose(t *testing.T) {
k := Kline{
Close: decimal.NewFromInt(1337),
}
if !k.ClosePrice().Equal(decimal.NewFromInt(1337)) {
if !k.GetClosePrice().Equal(decimal.NewFromInt(1337)) {
t.Error("expected decimal.NewFromInt(1337)")
}
}
@@ -21,7 +21,7 @@ func TestHigh(t *testing.T) {
k := Kline{
High: decimal.NewFromInt(1337),
}
if !k.HighPrice().Equal(decimal.NewFromInt(1337)) {
if !k.GetHighPrice().Equal(decimal.NewFromInt(1337)) {
t.Error("expected decimal.NewFromInt(1337)")
}
}
@@ -31,7 +31,7 @@ func TestLow(t *testing.T) {
k := Kline{
Low: decimal.NewFromInt(1337),
}
if !k.LowPrice().Equal(decimal.NewFromInt(1337)) {
if !k.GetLowPrice().Equal(decimal.NewFromInt(1337)) {
t.Error("expected decimal.NewFromInt(1337)")
}
}
@@ -41,7 +41,7 @@ func TestOpen(t *testing.T) {
k := Kline{
Open: decimal.NewFromInt(1337),
}
if !k.OpenPrice().Equal(decimal.NewFromInt(1337)) {
if !k.GetOpenPrice().Equal(decimal.NewFromInt(1337)) {
t.Error("expected decimal.NewFromInt(1337)")
}
}

View File

@@ -82,7 +82,7 @@ func (f *FundManager) CreateSnapshot(t time.Time) {
usdCandles := f.items[i].usdTrackingCandles.GetStream()
for j := range usdCandles {
if usdCandles[j].GetTime().Equal(t) {
usdClosePrice = usdCandles[j].ClosePrice()
usdClosePrice = usdCandles[j].GetClosePrice()
break
}
}
@@ -105,6 +105,7 @@ func (f *FundManager) AddUSDTrackingData(k *kline.DataFromKline) error {
}
baseSet := false
quoteSet := false
var basePairedWith currency.Code
for i := range f.items {
if baseSet && quoteSet {
return nil
@@ -115,10 +116,16 @@ func (f *FundManager) AddUSDTrackingData(k *kline.DataFromKline) error {
if f.items[i].usdTrackingCandles == nil &&
trackingcurrencies.CurrencyIsUSDTracked(k.Item.Pair.Quote) {
f.items[i].usdTrackingCandles = k
if f.items[i].pairedWith != nil {
basePairedWith = f.items[i].pairedWith.currency
}
}
baseSet = true
}
if trackingcurrencies.CurrencyIsUSDTracked(f.items[i].currency) {
if f.items[i].pairedWith != nil && f.items[i].currency != basePairedWith {
continue
}
if f.items[i].usdTrackingCandles == nil {
usdCandles := gctkline.Item{
Exchange: k.Item.Exchange,
@@ -205,10 +212,10 @@ func (f *FundManager) GenerateReport() *Report {
if !f.disableUSDTracking &&
f.items[i].usdTrackingCandles != nil {
usdStream := f.items[i].usdTrackingCandles.GetStream()
item.USDInitialFunds = f.items[i].initialFunds.Mul(usdStream[0].ClosePrice())
item.USDFinalFunds = f.items[i].available.Mul(usdStream[len(usdStream)-1].ClosePrice())
item.USDInitialCostForOne = usdStream[0].ClosePrice()
item.USDFinalCostForOne = usdStream[len(usdStream)-1].ClosePrice()
item.USDInitialFunds = f.items[i].initialFunds.Mul(usdStream[0].GetClosePrice())
item.USDFinalFunds = f.items[i].available.Mul(usdStream[len(usdStream)-1].GetClosePrice())
item.USDInitialCostForOne = usdStream[0].GetClosePrice()
item.USDFinalCostForOne = usdStream[len(usdStream)-1].GetClosePrice()
item.USDPairCandle = f.items[i].usdTrackingCandles
}