package backtest import ( "errors" "fmt" "path/filepath" "runtime" "strings" "time" "github.com/thrasher-corp/gocryptotrader/backtester/common" "github.com/thrasher-corp/gocryptotrader/backtester/config" "github.com/thrasher-corp/gocryptotrader/backtester/data" "github.com/thrasher-corp/gocryptotrader/backtester/data/kline" "github.com/thrasher-corp/gocryptotrader/backtester/data/kline/api" "github.com/thrasher-corp/gocryptotrader/backtester/data/kline/csv" "github.com/thrasher-corp/gocryptotrader/backtester/data/kline/database" "github.com/thrasher-corp/gocryptotrader/backtester/data/kline/live" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/eventholder" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/exchange" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/exchange/slippage" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/portfolio" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/portfolio/compliance" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/portfolio/holdings" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/portfolio/risk" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/portfolio/settings" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/portfolio/size" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/statistics" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/statistics/currencystatistics" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/strategies" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/strategies/base" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/fill" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/order" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/signal" "github.com/thrasher-corp/gocryptotrader/backtester/report" gctcommon "github.com/thrasher-corp/gocryptotrader/common" "github.com/thrasher-corp/gocryptotrader/currency" gctdatabase "github.com/thrasher-corp/gocryptotrader/database" "github.com/thrasher-corp/gocryptotrader/engine" gctexchange "github.com/thrasher-corp/gocryptotrader/exchanges" "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" ) // New returns a new BackTest instance func New() *BackTest { return &BackTest{ shutdown: make(chan struct{}), } } // Reset BackTest values to default func (bt *BackTest) Reset() { bt.EventQueue.Reset() bt.Datas.Reset() bt.Portfolio.Reset() bt.Statistic.Reset() bt.Exchange.Reset() bt.Bot = nil } // NewFromConfig takes a strategy config and configures a backtester variable to run func NewFromConfig(cfg *config.Config, templatePath, output string, bot *engine.Engine) (*BackTest, error) { log.Infoln(log.BackTester, "loading config...") if cfg == nil { return nil, errNilConfig } if bot == nil { return nil, errNilBot } bt := New() bt.Datas = &data.HandlerPerCurrency{} bt.EventQueue = &eventholder.Holder{} reports := &report.Data{ Config: cfg, TemplatePath: templatePath, OutputPath: output, } bt.Reports = reports err := bt.setupBot(cfg, bot) if err != nil { return nil, err } e, err := bt.setupExchangeSettings(cfg) if err != nil { return nil, err } bt.Exchange = &e buyRule := config.MinMax{ MinimumSize: cfg.PortfolioSettings.BuySide.MinimumSize, MaximumSize: cfg.PortfolioSettings.BuySide.MaximumSize, MaximumTotal: cfg.PortfolioSettings.BuySide.MaximumTotal, } buyRule.Validate() sellRule := config.MinMax{ MinimumSize: cfg.PortfolioSettings.SellSide.MinimumSize, MaximumSize: cfg.PortfolioSettings.SellSide.MaximumSize, MaximumTotal: cfg.PortfolioSettings.SellSide.MaximumTotal, } sellRule.Validate() sizeManager := &size.Size{ BuySide: buyRule, SellSide: sellRule, } portfolioRisk := &risk.Risk{ CurrencySettings: make(map[string]map[asset.Item]map[currency.Pair]*risk.CurrencySettings), } for i := range cfg.CurrencySettings { if portfolioRisk.CurrencySettings[cfg.CurrencySettings[i].ExchangeName] == nil { portfolioRisk.CurrencySettings[cfg.CurrencySettings[i].ExchangeName] = make(map[asset.Item]map[currency.Pair]*risk.CurrencySettings) } var a asset.Item a, err = asset.New(cfg.CurrencySettings[i].Asset) if err != nil { return nil, fmt.Errorf( "%w for %v %v %v. Err %v", errInvalidConfigAsset, cfg.CurrencySettings[i].ExchangeName, cfg.CurrencySettings[i].Asset, cfg.CurrencySettings[i].Base+cfg.CurrencySettings[i].Quote, err) } if portfolioRisk.CurrencySettings[cfg.CurrencySettings[i].ExchangeName][a] == nil { portfolioRisk.CurrencySettings[cfg.CurrencySettings[i].ExchangeName][a] = make(map[currency.Pair]*risk.CurrencySettings) } var curr currency.Pair curr, err = currency.NewPairFromString(cfg.CurrencySettings[i].Base + cfg.CurrencySettings[i].Quote) if err != nil { return nil, fmt.Errorf( "%w for %v %v %v. Err %v", errInvalidConfigCurrency, cfg.CurrencySettings[i].ExchangeName, cfg.CurrencySettings[i].Asset, cfg.CurrencySettings[i].Base+cfg.CurrencySettings[i].Quote, err) } portfolioRisk.CurrencySettings[cfg.CurrencySettings[i].ExchangeName][a][curr] = &risk.CurrencySettings{ MaximumOrdersWithLeverageRatio: cfg.CurrencySettings[i].Leverage.MaximumOrdersWithLeverageRatio, MaxLeverageRate: cfg.CurrencySettings[i].Leverage.MaximumLeverageRate, MaximumHoldingRatio: cfg.CurrencySettings[i].MaximumHoldingsRatio, } if cfg.CurrencySettings[i].MakerFee > cfg.CurrencySettings[i].TakerFee { log.Warnf(log.BackTester, "maker fee '%v' should not exceed taker fee '%v'. Please review config", cfg.CurrencySettings[i].MakerFee, cfg.CurrencySettings[i].TakerFee) } } var p *portfolio.Portfolio p, err = portfolio.Setup(sizeManager, portfolioRisk, cfg.StatisticSettings.RiskFreeRate) if err != nil { return nil, err } for i := range e.CurrencySettings { var lookup *settings.Settings lookup, err = p.SetupCurrencySettingsMap(e.CurrencySettings[i].ExchangeName, e.CurrencySettings[i].AssetType, e.CurrencySettings[i].CurrencyPair) if err != nil { return nil, err } lookup.Fee = e.CurrencySettings[i].TakerFee lookup.Leverage = e.CurrencySettings[i].Leverage lookup.BuySideSizing = e.CurrencySettings[i].BuySide lookup.SellSideSizing = e.CurrencySettings[i].SellSide lookup.InitialFunds = e.CurrencySettings[i].InitialFunds lookup.ComplianceManager = compliance.Manager{ Snapshots: []compliance.Snapshot{}, } } bt.Portfolio = p bt.Strategy, err = strategies.LoadStrategyByName(cfg.StrategySettings.Name, cfg.StrategySettings.SimultaneousSignalProcessing) if err != nil { return nil, err } bt.Strategy.SetDefaults() if cfg.StrategySettings.CustomSettings != nil { err = bt.Strategy.SetCustomSettings(cfg.StrategySettings.CustomSettings) if err != nil && !errors.Is(err, base.ErrCustomSettingsUnsupported) { return nil, err } } stats := &statistics.Statistic{ StrategyName: bt.Strategy.Name(), StrategyNickname: cfg.Nickname, StrategyDescription: bt.Strategy.Description(), StrategyGoal: cfg.Goal, ExchangeAssetPairStatistics: make(map[string]map[asset.Item]map[currency.Pair]*currencystatistics.CurrencyStatistic), RiskFreeRate: cfg.StatisticSettings.RiskFreeRate, } bt.Statistic = stats reports.Statistics = stats cfg.PrintSetting() return bt, nil } func (bt *BackTest) setupExchangeSettings(cfg *config.Config) (exchange.Exchange, error) { log.Infoln(log.BackTester, "setting exchange settings...") resp := exchange.Exchange{} for i := range cfg.CurrencySettings { exch, pair, a, err := bt.loadExchangePairAssetBase( cfg.CurrencySettings[i].ExchangeName, cfg.CurrencySettings[i].Base, cfg.CurrencySettings[i].Quote, cfg.CurrencySettings[i].Asset) if err != nil { return resp, err } exchangeName := strings.ToLower(exch.GetName()) bt.Datas.Setup() klineData, err := bt.loadData(cfg, exch, pair, a) if err != nil { return resp, err } bt.Datas.SetDataForCurrency(exchangeName, a, pair, klineData) var makerFee, takerFee float64 if cfg.CurrencySettings[i].MakerFee > 0 { makerFee = cfg.CurrencySettings[i].MakerFee } if cfg.CurrencySettings[i].TakerFee > 0 { takerFee = cfg.CurrencySettings[i].TakerFee } if makerFee == 0 || takerFee == 0 { var apiMakerFee, apiTakerFee float64 apiMakerFee, apiTakerFee = getFees(exch, pair) if makerFee == 0 { makerFee = apiMakerFee } if takerFee == 0 { takerFee = apiTakerFee } } if cfg.CurrencySettings[i].MaximumSlippagePercent < 0 { log.Warnf(log.BackTester, "invalid maximum slippage percent '%v'. Slippage percent is defined as a number, eg '100.00', defaulting to '%v'", cfg.CurrencySettings[i].MaximumSlippagePercent, slippage.DefaultMaximumSlippagePercent) cfg.CurrencySettings[i].MaximumSlippagePercent = slippage.DefaultMaximumSlippagePercent } if cfg.CurrencySettings[i].MaximumSlippagePercent == 0 { cfg.CurrencySettings[i].MaximumSlippagePercent = slippage.DefaultMaximumSlippagePercent } if cfg.CurrencySettings[i].MinimumSlippagePercent < 0 { log.Warnf(log.BackTester, "invalid minimum slippage percent '%v'. Slippage percent is defined as a number, eg '80.00', defaulting to '%v'", cfg.CurrencySettings[i].MinimumSlippagePercent, slippage.DefaultMinimumSlippagePercent) cfg.CurrencySettings[i].MinimumSlippagePercent = slippage.DefaultMinimumSlippagePercent } if cfg.CurrencySettings[i].MinimumSlippagePercent == 0 { cfg.CurrencySettings[i].MinimumSlippagePercent = slippage.DefaultMinimumSlippagePercent } if cfg.CurrencySettings[i].MaximumSlippagePercent < cfg.CurrencySettings[i].MinimumSlippagePercent { cfg.CurrencySettings[i].MaximumSlippagePercent = slippage.DefaultMaximumSlippagePercent } realOrders := false if cfg.DataSettings.LiveData != nil { realOrders = cfg.DataSettings.LiveData.RealOrders } buyRule := config.MinMax{ MinimumSize: cfg.CurrencySettings[i].BuySide.MinimumSize, MaximumSize: cfg.CurrencySettings[i].BuySide.MaximumSize, MaximumTotal: cfg.CurrencySettings[i].BuySide.MaximumTotal, } buyRule.Validate() sellRule := config.MinMax{ MinimumSize: cfg.CurrencySettings[i].SellSide.MinimumSize, MaximumSize: cfg.CurrencySettings[i].SellSide.MaximumSize, MaximumTotal: cfg.CurrencySettings[i].SellSide.MaximumTotal, } sellRule.Validate() limits, err := exch.GetOrderExecutionLimits(a, pair) if err != nil && !errors.Is(err, gctorder.ErrExchangeLimitNotLoaded) { return resp, err } if limits != nil { if !cfg.CurrencySettings[i].CanUseExchangeLimits { log.Warnf(log.BackTester, "exchange %s order execution limits supported but disabled for %s %s, results may not work when in production", cfg.CurrencySettings[i].ExchangeName, pair, a) cfg.CurrencySettings[i].ShowExchangeOrderLimitWarning = true } } resp.CurrencySettings = append(resp.CurrencySettings, exchange.Settings{ ExchangeName: cfg.CurrencySettings[i].ExchangeName, InitialFunds: cfg.CurrencySettings[i].InitialFunds, MinimumSlippageRate: cfg.CurrencySettings[i].MinimumSlippagePercent, MaximumSlippageRate: cfg.CurrencySettings[i].MaximumSlippagePercent, CurrencyPair: pair, AssetType: a, ExchangeFee: takerFee, MakerFee: takerFee, TakerFee: makerFee, UseRealOrders: realOrders, BuySide: buyRule, SellSide: sellRule, Leverage: config.Leverage{ CanUseLeverage: cfg.CurrencySettings[i].Leverage.CanUseLeverage, MaximumLeverageRate: cfg.CurrencySettings[i].Leverage.MaximumLeverageRate, MaximumOrdersWithLeverageRatio: cfg.CurrencySettings[i].Leverage.MaximumOrdersWithLeverageRatio, }, Limits: limits, CanUseExchangeLimits: cfg.CurrencySettings[i].CanUseExchangeLimits, }) } return resp, nil } func (bt *BackTest) loadExchangePairAssetBase(exch, base, quote, ass string) (gctexchange.IBotExchange, currency.Pair, asset.Item, error) { var err error e := bt.Bot.GetExchangeByName(exch) if e == nil { return nil, currency.Pair{}, "", engine.ErrExchangeNotFound } var cp, fPair currency.Pair cp, err = currency.NewPairFromStrings(base, quote) if err != nil { return nil, currency.Pair{}, "", err } var a asset.Item a, err = asset.New(ass) if err != nil { return nil, currency.Pair{}, "", err } exchangeBase := e.GetBase() if !exchangeBase.ValidateAPICredentials() { log.Warnf(log.BackTester, "no credentials set for %v, this is theoretical only", exchangeBase.Name) } fPair, err = exchangeBase.FormatExchangeCurrency(cp, a) if err != nil { return nil, currency.Pair{}, "", err } return e, fPair, a, nil } // setupBot sets up a basic bot to retrieve exchange data // as well as process orders func (bt *BackTest) setupBot(cfg *config.Config, bot *engine.Engine) error { var err error bt.Bot = bot err = cfg.ValidateCurrencySettings() if err != nil { return err } bt.Bot.ExchangeManager = engine.SetupExchangeManager() for i := range cfg.CurrencySettings { err = bt.Bot.LoadExchange(cfg.CurrencySettings[i].ExchangeName, false, nil) if err != nil && !errors.Is(err, engine.ErrExchangeAlreadyLoaded) { return err } } if !bt.Bot.OrderManager.IsRunning() { bt.Bot.OrderManager, err = engine.SetupOrderManager( bt.Bot.ExchangeManager, bt.Bot.CommunicationsManager, &bt.Bot.ServicesWG, bot.Settings.Verbose) if err != nil { return err } err = bt.Bot.OrderManager.Start() if err != nil { return err } } return nil } // getFees will return an exchange's fee rate from GCT's wrapper function func getFees(exch gctexchange.IBotExchange, fPair currency.Pair) (makerFee, takerFee float64) { var err error takerFee, err = exch.GetFeeByType(&gctexchange.FeeBuilder{ FeeType: gctexchange.OfflineTradeFee, Pair: fPair, IsMaker: false, PurchasePrice: 1, Amount: 1, }) if err != nil { log.Errorf(log.BackTester, "Could not retrieve taker fee for %v. %v", exch.GetName(), err) } makerFee, err = exch.GetFeeByType(&gctexchange.FeeBuilder{ FeeType: gctexchange.OfflineTradeFee, Pair: fPair, IsMaker: true, PurchasePrice: 1, Amount: 1, }) if err != nil { log.Errorf(log.BackTester, "Could not retrieve maker fee for %v. %v", exch.GetName(), err) } return makerFee, takerFee } // loadData will create kline data from the sources defined in start config files. It can exist from databases, csv or API endpoints // it can also be generated from trade data which will be converted into kline data func (bt *BackTest) loadData(cfg *config.Config, exch gctexchange.IBotExchange, fPair currency.Pair, a asset.Item) (*kline.DataFromKline, error) { log.Infof(log.BackTester, "loading data for %v %v %v...\n", exch.GetName(), a, fPair) if exch == nil { return nil, engine.ErrExchangeNotFound } b := exch.GetBase() if cfg.DataSettings.DatabaseData == nil && cfg.DataSettings.LiveData == nil && cfg.DataSettings.APIData == nil && cfg.DataSettings.CSVData == nil { return nil, errNoDataSource } resp := &kline.DataFromKline{} if (cfg.DataSettings.APIData != nil && cfg.DataSettings.DatabaseData != nil) || (cfg.DataSettings.APIData != nil && cfg.DataSettings.LiveData != nil) || (cfg.DataSettings.APIData != nil && cfg.DataSettings.CSVData != nil) || (cfg.DataSettings.DatabaseData != nil && cfg.DataSettings.LiveData != nil) || (cfg.DataSettings.CSVData != nil && cfg.DataSettings.LiveData != nil) || (cfg.DataSettings.CSVData != nil && cfg.DataSettings.DatabaseData != nil) { return nil, errAmbiguousDataSource } dataType, err := common.DataTypeToInt(cfg.DataSettings.DataType) if err != nil { return nil, err } switch { case cfg.DataSettings.CSVData != nil: if cfg.DataSettings.Interval <= 0 { return nil, errIntervalUnset } resp, err = csv.LoadData( dataType, cfg.DataSettings.CSVData.FullPath, strings.ToLower(exch.GetName()), cfg.DataSettings.Interval, fPair, a) if err != nil { return nil, fmt.Errorf("%v. Please check your GoCryptoTrader configuration", err) } resp.Item.RemoveDuplicates() resp.Item.SortCandlesByTimestamp(false) resp.Range = gctkline.CalculateCandleDateRanges( resp.Item.Candles[0].Time, resp.Item.Candles[len(resp.Item.Candles)-1].Time.Add(cfg.DataSettings.Interval), gctkline.Interval(cfg.DataSettings.Interval), 0, ) err = resp.Range.VerifyResultsHaveData(resp.Item.Candles) if err != nil { if strings.Contains(err.Error(), "missing candles data between") { log.Warn(log.BackTester, err.Error()) } else { return nil, err } } case cfg.DataSettings.DatabaseData != nil: if cfg.DataSettings.DatabaseData.InclusiveEndDate { cfg.DataSettings.DatabaseData.EndDate = cfg.DataSettings.DatabaseData.EndDate.Add(cfg.DataSettings.Interval) } if cfg.DataSettings.DatabaseData.ConfigOverride != nil { bt.Bot.Config.Database = *cfg.DataSettings.DatabaseData.ConfigOverride gctdatabase.DB.DataPath = filepath.Join(gctcommon.GetDefaultDataDir(runtime.GOOS), "database") err = gctdatabase.DB.SetConfig(cfg.DataSettings.DatabaseData.ConfigOverride) if err != nil { return nil, err } } bt.Bot.DatabaseManager, err = engine.SetupDatabaseConnectionManager(gctdatabase.DB.GetConfig()) if err != nil { return nil, err } err = bt.Bot.DatabaseManager.Start(&bt.Bot.ServicesWG) if err != nil { return nil, err } defer func() { stopErr := bt.Bot.DatabaseManager.Stop() if stopErr != nil { log.Error(log.BackTester, stopErr) } }() resp, err = loadDatabaseData(cfg, exch.GetName(), fPair, a, dataType) if err != nil { return nil, fmt.Errorf("unable to retrieve data from GoCryptoTrader database. Error: %v. Please ensure the database is setup correctly and has data before use", err) } resp.Item.RemoveDuplicates() resp.Item.SortCandlesByTimestamp(false) resp.Range = gctkline.CalculateCandleDateRanges( cfg.DataSettings.DatabaseData.StartDate, cfg.DataSettings.DatabaseData.EndDate, gctkline.Interval(cfg.DataSettings.Interval), 0, ) err = resp.Range.VerifyResultsHaveData(resp.Item.Candles) if err != nil { if strings.Contains(err.Error(), "missing candles data between") { log.Warn(log.BackTester, err.Error()) } else { return nil, err } } case cfg.DataSettings.APIData != nil: if cfg.DataSettings.APIData.InclusiveEndDate { cfg.DataSettings.APIData.EndDate = cfg.DataSettings.APIData.EndDate.Add(cfg.DataSettings.Interval) } resp, err = loadAPIData( cfg, exch, fPair, a, b.Features.Enabled.Kline.ResultLimit, dataType) if err != nil { return resp, err } case cfg.DataSettings.LiveData != nil: if len(cfg.CurrencySettings) > 1 { return nil, errors.New("live data simulation only supports one currency") } err = loadLiveData(cfg, b) if err != nil { return nil, err } go bt.loadLiveDataLoop( resp, cfg, exch, fPair, a, dataType) return resp, nil } if resp == nil { return nil, fmt.Errorf("processing error, response returned nil") } err = b.ValidateKline(fPair, a, resp.Item.Interval) if err != nil { return nil, err } err = resp.Load() if err != nil { return nil, err } bt.Reports.AddKlineItem(&resp.Item) return resp, nil } func loadDatabaseData(cfg *config.Config, name string, fPair currency.Pair, a asset.Item, dataType int64) (*kline.DataFromKline, error) { if cfg == nil || cfg.DataSettings.DatabaseData == nil { return nil, errors.New("nil config data received") } err := cfg.ValidateDate() if err != nil { return nil, err } if cfg.DataSettings.Interval <= 0 { return nil, errIntervalUnset } return database.LoadData( cfg.DataSettings.DatabaseData.StartDate, cfg.DataSettings.DatabaseData.EndDate, cfg.DataSettings.Interval, strings.ToLower(name), dataType, fPair, a) } func loadAPIData(cfg *config.Config, exch gctexchange.IBotExchange, fPair currency.Pair, a asset.Item, resultLimit uint32, dataType int64) (*kline.DataFromKline, error) { err := cfg.ValidateDate() if err != nil { return nil, err } if cfg.DataSettings.Interval <= 0 { return nil, errIntervalUnset } dates := gctkline.CalculateCandleDateRanges( cfg.DataSettings.APIData.StartDate, cfg.DataSettings.APIData.EndDate, gctkline.Interval(cfg.DataSettings.Interval), resultLimit) candles, err := api.LoadData( dataType, cfg.DataSettings.APIData.StartDate, cfg.DataSettings.APIData.EndDate, cfg.DataSettings.Interval, exch, fPair, a) if err != nil { return nil, fmt.Errorf("%v. Please check your GoCryptoTrader configuration", err) } err = dates.VerifyResultsHaveData(candles.Candles) if err != nil && errors.Is(err, gctkline.ErrMissingCandleData) { log.Warn(log.BackTester, err.Error()) } else if err != nil { return nil, err } candles.FillMissingDataWithEmptyEntries(&dates) candles.RemoveOutsideRange(cfg.DataSettings.APIData.StartDate, cfg.DataSettings.APIData.EndDate) return &kline.DataFromKline{ Item: *candles, Range: dates, }, nil } func loadLiveData(cfg *config.Config, base *gctexchange.Base) error { if cfg == nil || base == nil || cfg.DataSettings.LiveData == nil { return common.ErrNilArguments } if cfg.DataSettings.Interval <= 0 { return errIntervalUnset } if cfg.DataSettings.LiveData.APIKeyOverride != "" { base.API.Credentials.Key = cfg.DataSettings.LiveData.APIKeyOverride } if cfg.DataSettings.LiveData.APISecretOverride != "" { base.API.Credentials.Secret = cfg.DataSettings.LiveData.APISecretOverride } if cfg.DataSettings.LiveData.APIClientIDOverride != "" { base.API.Credentials.ClientID = cfg.DataSettings.LiveData.APIClientIDOverride } if cfg.DataSettings.LiveData.API2FAOverride != "" { base.API.Credentials.PEMKey = cfg.DataSettings.LiveData.API2FAOverride } if cfg.DataSettings.LiveData.APISubaccountOverride != "" { base.API.Credentials.Subaccount = cfg.DataSettings.LiveData.APISubaccountOverride } validated := base.ValidateAPICredentials() base.API.AuthenticatedSupport = validated if !validated && cfg.DataSettings.LiveData.RealOrders { log.Warn(log.BackTester, "invalid API credentials set, real orders set to false") cfg.DataSettings.LiveData.RealOrders = false } return nil } // Run will iterate over loaded data events // save them and then handle the event based on its type func (bt *BackTest) Run() error { log.Info(log.BackTester, "running backtester against pre-defined data") dataLoadingIssue: for ev := bt.EventQueue.NextEvent(); ; ev = bt.EventQueue.NextEvent() { if ev == nil { dataHandlerMap := bt.Datas.GetAllData() for exchangeName, exchangeMap := range dataHandlerMap { for assetItem, assetMap := range exchangeMap { var hasProcessedData bool for currencyPair, dataHandler := range assetMap { d := dataHandler.Next() if d == nil { if !bt.hasHandledEvent { log.Errorf(log.BackTester, "Unable to perform `Next` for %v %v %v", exchangeName, assetItem, currencyPair) } break dataLoadingIssue } if bt.Strategy.UseSimultaneousProcessing() && hasProcessedData { continue } bt.EventQueue.AppendEvent(d) hasProcessedData = true } } } } err := bt.handleEvent(ev) if err != nil { return err } if !bt.hasHandledEvent { bt.hasHandledEvent = true } } return nil } // handleEvent is the main processor of data for the backtester // after data has been loaded and Run has appended a data event to the queue, // handle event will process events and add further events to the queue if they // are required func (bt *BackTest) handleEvent(e common.EventHandler) error { switch ev := e.(type) { case common.DataEventHandler: return bt.processDataEvent(ev) case signal.Event: bt.processSignalEvent(ev) case order.Event: bt.processOrderEvent(ev) case fill.Event: bt.processFillEvent(ev) case nil: default: return fmt.Errorf("%w %v received, could not process", errUnhandledDatatype, e) } return nil } // processDataEvent determines what signal events are generated and appended // to the event queue based on whether it is running a multi-currency consideration strategy order not // // for multi-currency-consideration it will pass all currency datas to the strategy for it to determine what // currencies to act upon // // for non-multi-currency-consideration strategies, it will simply process every currency individually // against the strategy and generate signals func (bt *BackTest) processDataEvent(e common.DataEventHandler) error { if bt.Strategy.UseSimultaneousProcessing() { var dataEvents []data.Handler dataHandlerMap := bt.Datas.GetAllData() for _, exchangeMap := range dataHandlerMap { for _, assetMap := range exchangeMap { for _, dataHandler := range assetMap { latestData := dataHandler.Latest() bt.updateStatsForDataEvent(latestData) dataEvents = append(dataEvents, dataHandler) } } } signals, err := bt.Strategy.OnSimultaneousSignals(dataEvents, bt.Portfolio) if err != nil { if errors.Is(err, base.ErrTooMuchBadData) { // too much bad data is a severe error and backtesting must cease return err } log.Error(log.BackTester, err) return nil } for i := range signals { err = bt.Statistic.SetEventForOffset(signals[i]) if err != nil { log.Error(log.BackTester, err) } bt.EventQueue.AppendEvent(signals[i]) } } else { bt.updateStatsForDataEvent(e) d := bt.Datas.GetDataForCurrency(e.GetExchange(), e.GetAssetType(), e.Pair()) s, err := bt.Strategy.OnSignal(d, bt.Portfolio) if err != nil { if errors.Is(err, base.ErrTooMuchBadData) { // too much bad data is a severe error and backtesting must cease return err } log.Error(log.BackTester, err) return nil } err = bt.Statistic.SetEventForOffset(s) if err != nil { log.Error(log.BackTester, err) } bt.EventQueue.AppendEvent(s) } return nil } // updateStatsForDataEvent makes various systems aware of price movements from // data events func (bt *BackTest) updateStatsForDataEvent(e common.DataEventHandler) { // update portfoliomanager with latest price err := bt.Portfolio.Update(e) if err != nil { log.Error(log.BackTester, err) } // update statistics with latest price err = bt.Statistic.SetupEventForTime(e) if err != nil { log.Error(log.BackTester, err) } } func (bt *BackTest) processSignalEvent(ev signal.Event) { cs, err := bt.Exchange.GetCurrencySettings(ev.GetExchange(), ev.GetAssetType(), ev.Pair()) if err != nil { log.Error(log.BackTester, err) return } var o *order.Order o, err = bt.Portfolio.OnSignal(ev, &cs) if err != nil { log.Error(log.BackTester, err) return } err = bt.Statistic.SetEventForOffset(o) if err != nil { log.Error(log.BackTester, err) } bt.EventQueue.AppendEvent(o) } func (bt *BackTest) processOrderEvent(ev order.Event) { d := bt.Datas.GetDataForCurrency(ev.GetExchange(), ev.GetAssetType(), ev.Pair()) f, err := bt.Exchange.ExecuteOrder(ev, d, bt.Bot) if err != nil { if f == nil { log.Errorf(log.BackTester, "fill event should always be returned, please fix, %v", err) return } log.Errorf(log.BackTester, "%v %v %v %v", f.GetExchange(), f.GetAssetType(), f.Pair(), err) } err = bt.Statistic.SetEventForOffset(f) if err != nil { log.Error(log.BackTester, err) } bt.EventQueue.AppendEvent(f) } func (bt *BackTest) processFillEvent(ev fill.Event) { t, err := bt.Portfolio.OnFill(ev) if err != nil { log.Error(log.BackTester, err) return } err = bt.Statistic.SetEventForOffset(t) if err != nil { log.Error(log.BackTester, err) } var holding holdings.Holding holding, err = bt.Portfolio.ViewHoldingAtTimePeriod(ev.GetExchange(), ev.GetAssetType(), ev.Pair(), ev.GetTime()) if err != nil { log.Error(log.BackTester, err) } err = bt.Statistic.AddHoldingsForTime(&holding) if err != nil { log.Error(log.BackTester, err) } var cp *compliance.Manager cp, err = bt.Portfolio.GetComplianceManager(ev.GetExchange(), ev.GetAssetType(), ev.Pair()) if err != nil { log.Error(log.BackTester, err) } snap := cp.GetLatestSnapshot() err = bt.Statistic.AddComplianceSnapshotForTime(snap, ev) if err != nil { log.Error(log.BackTester, err) } } // RunLive is a proof of concept function that does not yet support multi currency usage // It runs by constantly checking for new live datas and running through the list of events // once new data is processed. It will run until application close event has been received func (bt *BackTest) RunLive() error { log.Info(log.BackTester, "running backtester against live data") timeoutTimer := time.NewTimer(time.Minute * 5) // a frequent timer so that when a new candle is released by an exchange // that it can be processed quickly processEventTicker := time.NewTicker(time.Second) doneARun := false for { select { case <-bt.shutdown: return nil case <-timeoutTimer.C: return errLiveDataTimeout case <-processEventTicker.C: for e := bt.EventQueue.NextEvent(); ; e = bt.EventQueue.NextEvent() { if e == nil { // as live only supports singular currency, just get the proper reference manually var d data.Handler dd := bt.Datas.GetAllData() for k1, v1 := range dd { for k2, v2 := range v1 { for k3 := range v2 { d = dd[k1][k2][k3] } } } de := d.Next() if de == nil { break } bt.EventQueue.AppendEvent(de) doneARun = true continue } err := bt.handleEvent(e) if err != nil { return err } } if doneARun { timeoutTimer = time.NewTimer(time.Minute * 5) } } } } // loadLiveDataLoop is an incomplete function to continuously retrieve exchange data on a loop // from live. Its purpose is to be able to perform strategy analysis against current data func (bt *BackTest) loadLiveDataLoop(resp *kline.DataFromKline, cfg *config.Config, exch gctexchange.IBotExchange, fPair currency.Pair, a asset.Item, dataType int64) { startDate := time.Now() candles, err := live.LoadData( exch, dataType, cfg.DataSettings.Interval, fPair, a) if err != nil { log.Errorf(log.BackTester, "%v. Please check your GoCryptoTrader configuration", err) return } resp.Item = *candles err = bt.loadLiveData(resp, cfg, exch, fPair, a, startDate, dataType) if err != nil { log.Error(log.BackTester, err) return } loadNewDataTicker := time.NewTicker(time.Second * 30) for { select { case <-bt.shutdown: return case <-loadNewDataTicker.C: err = bt.loadLiveData(resp, cfg, exch, fPair, a, startDate, dataType) if err != nil { log.Error(log.BackTester, err) return } } } } func (bt *BackTest) loadLiveData(resp *kline.DataFromKline, cfg *config.Config, exch gctexchange.IBotExchange, fPair currency.Pair, a asset.Item, startDate time.Time, dataType int64) error { candles, err := live.LoadData( exch, dataType, cfg.DataSettings.Interval, fPair, a) if err != nil { return err } resp.Item.Candles = append(resp.Item.Candles, candles.Candles...) _, err = exch.FetchOrderbook(fPair, a) if err != nil { return err } resp.Item.RemoveDuplicates() resp.Item.SortCandlesByTimestamp(false) if len(candles.Candles) == 0 { return nil } endDate := candles.Candles[len(candles.Candles)-1].Time.Add(cfg.DataSettings.Interval) if resp.Range.Ranges == nil { dataRange := gctkline.CalculateCandleDateRanges( startDate, endDate, gctkline.Interval(cfg.DataSettings.Interval), 0, ) resp.Range = gctkline.IntervalRangeHolder{ Start: gctkline.CreateIntervalTime(startDate), End: gctkline.CreateIntervalTime(endDate), Ranges: dataRange.Ranges, } } var intervalData []gctkline.IntervalData for i := range candles.Candles { intervalData = append(intervalData, gctkline.IntervalData{ Start: gctkline.CreateIntervalTime(candles.Candles[i].Time), End: gctkline.CreateIntervalTime(candles.Candles[i].Time.Add(cfg.DataSettings.Interval)), HasData: true, }) } resp.Range.Ranges[0].Intervals = intervalData if len(intervalData) > 0 { resp.Range.Ranges[0].End = intervalData[len(intervalData)-1].End } resp.Append(candles) bt.Reports.AddKlineItem(&resp.Item) log.Info(log.BackTester, "sleeping for 30 seconds before checking for new candle data") return nil } // Stop shuts down the live data loop func (bt *BackTest) Stop() { close(bt.shutdown) }