From adf7659e95cb56fd06885c4b680046ec3b3a7029 Mon Sep 17 00:00:00 2001 From: Scott Date: Mon, 27 Sep 2021 16:01:23 +1000 Subject: [PATCH] backtester: shared exchange level funding, decimal implementation (#783) * Better designed backtester funding concept * Fleshes out funding concepts further to allow two funding types * Adds types, finishes adding to portfolio and adds to exchange * Fixes a bug to reveal another * Fixes issues with purchasing * A partial conversion to using decimal.decimal for the backtester * Further decimal rollout. Can compile and output report * More cleanup * Fix rendering and initial funds issue. * Adds new concept for trading using the exchange level funding to see what happens * Fixes a bug in funding not being found * New strat config to test RSI and discover issues * Can run with pairs that contain 0 funding * Finally fixes the arrangement to share funds * Adds testing and funding transfer * end of day * More comments, more tests! * Improves item comparisons and completes testing * Initial attempt at new strategy which utilisies shared funding and transfers * end of day broken * Chronological output. Fixes output bug where multi currency. * End of day commit * Fixes bug where events were being overwritten in a simultaneous context * Begins transitioning from portfolio holdings to funding holdings. Am I doing the right thing * End of day run around * Likely fix for holding calculations * Improvement to template. Improvement to holdings * DARK MODE. Report upgrades. Even handling with funds. Fix output * Output funding to cmd * Add new trasnferred funds "side" * Fixing test run 1 * Test updates * Test updating * More test fixing * Fixes portfolio tests * More test fixes * Fixes remaining tests and lints * Fixes currencystatistics tests. Adds decimal math implementations * Fixes hilarious bug where there could only be on holding * Adds funding support for config. Minor fixes * Adds documentation * Finishes config builder support for funding * Logs inexact conversions, updates tests. adds config validation * The quest to understand a new funding bug begins. New strategy * Fixes bug where wrong funding was retrieved. Expands t2b2 strat * End of the day commit. Gotta revert the nulldecimal stuff * Fixes tests, adds extra funding transfer feature * Fixes initial total values, tries to add a grand total value * Rebase fixes, documentation updates, tests for strategy * Swaps the err statement for tests. Regenerates tests. Math warnings * Attempts to solve Live data problems. Fixes volume * Fixes live data missing * can trade at any interval. skip volume sizing. volume colours. * config regen. display fixes * test fixes, lint fixes * Anti-funky errors * docs * Rmbad * docs * docs update * Simplifies err handling. Updates readmes. Data type checks * docs. new field initial-base-funds. comment errs. config test coverage * minMaxing * testfix * Fixes fee calculation, re-bans minMax being equal * Crazy concepts to attempt to solve totals. Addresses nits * Adds in totals calculation for exchange level funding.Uses external API In future, this will be replaced by proper pricing supplied by the same exchange that is requested. This is an unknown price * rm dollar signs in cmd and report. rm bad error. fix chart decimal. padding * re-run docs post merge * Fixes oopsie for fee parsing Co-authored-by: Adrian Gallagher Co-authored-by: Adrian Gallagher --- CONTRIBUTORS | 7 +- README.md | 13 +- backtester/README.md | 10 +- backtester/backtest/backtest.go | 448 ++++---- backtester/backtest/backtest_test.go | 200 ++-- backtester/backtest/backtest_types.go | 23 +- backtester/common/common_test.go | 2 +- backtester/common/common_types.go | 12 +- backtester/config/README.md | 40 +- backtester/config/config.go | 215 +++- backtester/config/config_test.go | 994 +++++++++++------- backtester/config/config_types.go | 75 +- backtester/config/configbuilder/main.go | 175 ++- backtester/config/examples/README.md | 20 +- ...a-api-candles-exchange-level-funding.strat | 106 ++ .../dca-api-candles-multiple-currencies.strat | 86 +- ...-api-candles-simultaneous-processing.strat | 86 +- .../config/examples/dca-api-candles.strat | 55 +- .../config/examples/dca-api-trades.strat | 61 +- .../config/examples/dca-candles-live.strat | 56 +- .../config/examples/dca-csv-candles.strat | 53 +- .../config/examples/dca-csv-trades.strat | 53 +- .../examples/dca-database-candles.strat | 55 +- .../config/examples/rsi-api-candles.strat | 84 +- .../t2b2-api-candles-exchange-funding.strat | 230 ++++ backtester/data/data_test.go | 17 +- backtester/data/data_types.go | 11 +- backtester/data/kline/api/api_test.go | 2 +- backtester/data/kline/csv/csv_test.go | 2 +- .../data/kline/database/database_test.go | 2 +- backtester/data/kline/kline.go | 84 +- backtester/data/kline/kline_test.go | 69 +- backtester/data/kline/kline_types.go | 7 +- backtester/data/kline/live/live_test.go | 2 +- .../eventholder/eventholder_types.go | 2 +- backtester/eventhandlers/exchange/exchange.go | 149 +-- .../eventhandlers/exchange/exchange_test.go | 245 ++--- .../eventhandlers/exchange/exchange_types.go | 21 +- .../exchange/slippage/slippage.go | 30 +- .../exchange/slippage/slippage_test.go | 11 +- .../exchange/slippage/slippage_types.go | 8 +- .../portfolio/compliance/compliance_test.go | 16 +- .../portfolio/compliance/compliance_types.go | 9 +- .../portfolio/holdings/holdings.go | 127 ++- .../portfolio/holdings/holdings_test.go | 252 ++--- .../portfolio/holdings/holdings_types.go | 55 +- .../eventhandlers/portfolio/portfolio.go | 263 +++-- .../eventhandlers/portfolio/portfolio_test.go | 315 +++--- .../portfolio/portfolio_types.go | 25 +- .../eventhandlers/portfolio/risk/risk.go | 44 +- .../eventhandlers/portfolio/risk/risk_test.go | 57 +- .../portfolio/risk/risk_types.go | 9 +- .../portfolio/settings/settings.go | 11 +- .../portfolio/settings/settings_test.go | 16 +- .../portfolio/settings/settings_types.go | 4 +- .../eventhandlers/portfolio/size/size.go | 80 +- .../eventhandlers/portfolio/size/size_test.go | 167 +-- .../statistics/currencystatistics/README.md | 6 +- .../currencystatistics/currencystatistics.go | 278 +++-- .../currencystatistics_test.go | 206 ++-- .../currencystatistics_types.go | 70 +- .../eventhandlers/statistics/statistics.go | 233 ++-- .../statistics/statistics_test.go | 370 ++++--- .../statistics/statistics_types.go | 30 +- .../eventhandlers/strategies/base/base.go | 16 +- .../strategies/base/base_test.go | 25 +- .../strategies/base/base_types.go | 15 +- .../dollarcostaverage/dollarcostaverage.go | 6 +- .../dollarcostaverage_test.go | 47 +- .../eventhandlers/strategies/rsi/README.md | 2 +- .../eventhandlers/strategies/rsi/rsi.go | 67 +- .../eventhandlers/strategies/rsi/rsi_test.go | 73 +- .../eventhandlers/strategies/strategies.go | 9 +- .../strategies/strategies_test.go | 12 +- .../strategies/strategies_types.go | 8 +- .../strategies/top2bottom2/README.md | 55 + .../strategies/top2bottom2/top2bottom2.go | 254 +++++ .../top2bottom2/top2bottom2_test.go | 232 ++++ backtester/eventtypes/event/event_test.go | 7 + backtester/eventtypes/fill/fill.go | 19 +- backtester/eventtypes/fill/fill_test.go | 47 +- backtester/eventtypes/fill/fill_types.go | 37 +- backtester/eventtypes/kline/kline.go | 10 +- backtester/eventtypes/kline/kline_test.go | 30 +- backtester/eventtypes/kline/kline_types.go | 11 +- backtester/eventtypes/order/order.go | 21 +- backtester/eventtypes/order/order_test.go | 32 +- backtester/eventtypes/order/order_types.go | 31 +- backtester/eventtypes/signal/signal.go | 13 +- backtester/eventtypes/signal/signal_test.go | 26 +- backtester/eventtypes/signal/signal_types.go | 21 +- backtester/funding/README.md | 102 ++ backtester/funding/funding.go | 467 ++++++++ backtester/funding/funding_test.go | 821 +++++++++++++++ backtester/funding/funding_types.go | 102 ++ backtester/main.go | 18 +- backtester/report/report.go | 37 +- backtester/report/report_test.go | 216 ++-- backtester/report/report_types.go | 18 +- backtester/report/tpl.gohtml | 907 ++++++++++------ .../backtester_config_examples_readme.tmpl | 20 +- .../backtester_config_readme.tmpl | 40 +- ..._statistics_currencystatistics_readme.tmpl | 6 +- ...r_eventhandlers_strategies_rsi_readme.tmpl | 2 +- ...andlers_strategies_top2bottom2_readme.tmpl | 21 + .../backtester_funding_readme.tmpl | 68 ++ .../backtester_readme.tmpl | 10 +- cmd/documentation/documentation.go | 13 +- .../engine_templates/database_connection.tmpl | 2 +- .../engine_templates/datahistory_manager.tmpl | 10 +- common/math/math.go | 239 ++++- common/math/math_test.go | 419 +++++++- currency/code_test.go | 2 +- currency/manager.go | 18 +- engine/currency_state_manager.md | 74 +- engine/database_connection.md | 2 +- engine/datahistory_manager.md | 10 +- engine/engine_types.go | 2 +- exchanges/ftx/ftx_test.go | 2 +- exchanges/ftx/ftx_wrapper.go | 13 +- exchanges/order/limits.go | 20 + exchanges/order/limits_test.go | 47 + exchanges/stats/stats_test.go | 4 +- 123 files changed, 7965 insertions(+), 3357 deletions(-) create mode 100644 backtester/config/examples/dca-api-candles-exchange-level-funding.strat create mode 100644 backtester/config/examples/t2b2-api-candles-exchange-funding.strat create mode 100644 backtester/eventhandlers/strategies/top2bottom2/README.md create mode 100644 backtester/eventhandlers/strategies/top2bottom2/top2bottom2.go create mode 100644 backtester/eventhandlers/strategies/top2bottom2/top2bottom2_test.go create mode 100644 backtester/funding/README.md create mode 100644 backtester/funding/funding.go create mode 100644 backtester/funding/funding_test.go create mode 100644 backtester/funding/funding_types.go create mode 100644 cmd/documentation/backtester_templates/backtester_eventhandlers_strategies_top2bottom2_readme.tmpl create mode 100644 cmd/documentation/backtester_templates/backtester_funding_readme.tmpl diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 0d26e02c..f5774673 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -19,20 +19,21 @@ dackroyd | https://github.com/dackroyd cranktakular | https://github.com/cranktakular woshidama323 | https://github.com/woshidama323 yangrq1018 | https://github.com/yangrq1018 +TaltaM | https://github.com/TaltaM crackcomm | https://github.com/crackcomm azhang | https://github.com/azhang andreygrehov | https://github.com/andreygrehov bretep | https://github.com/bretep Christian-Achilli | https://github.com/Christian-Achilli MarkDzulko | https://github.com/MarkDzulko -TaltaM | https://github.com/TaltaM gam-phon | https://github.com/gam-phon cornelk | https://github.com/cornelk if1live | https://github.com/if1live lozdog245 | https://github.com/lozdog245 -tk42 | https://github.com/tk42 -mshogin | https://github.com/mshogin herenow | https://github.com/herenow +mshogin | https://github.com/mshogin +soxipy | https://github.com/soxipy +tk42 | https://github.com/tk42 daniel-cohen | https://github.com/daniel-cohen DirectX | https://github.com/DirectX frankzougc | https://github.com/frankzougc diff --git a/README.md b/README.md index 8af6c0b0..18bb74b6 100644 --- a/README.md +++ b/README.md @@ -143,11 +143,11 @@ Binaries will be published once the codebase reaches a stable condition. |User|Contribution Amount| |--|--| | [thrasher-](https://github.com/thrasher-) | 658 | -| [shazbert](https://github.com/shazbert) | 222 | +| [shazbert](https://github.com/shazbert) | 223 | | [gloriousCode](https://github.com/gloriousCode) | 190 | | [dependabot-preview[bot]](https://github.com/apps/dependabot-preview) | 88 | | [xtda](https://github.com/xtda) | 47 | -| [dependabot[bot]](https://github.com/apps/dependabot) | 22 | +| [dependabot[bot]](https://github.com/apps/dependabot) | 23 | | [Rots](https://github.com/Rots) | 15 | | [vazha](https://github.com/vazha) | 15 | | [ermalguni](https://github.com/ermalguni) | 14 | @@ -156,25 +156,26 @@ Binaries will be published once the codebase reaches a stable condition. | [vadimzhukck](https://github.com/vadimzhukck) | 10 | | [140am](https://github.com/140am) | 8 | | [marcofranssen](https://github.com/marcofranssen) | 8 | -| [lrascao](https://github.com/lrascao) | 6 | +| [lrascao](https://github.com/lrascao) | 7 | | [dackroyd](https://github.com/dackroyd) | 5 | | [cranktakular](https://github.com/cranktakular) | 5 | | [woshidama323](https://github.com/woshidama323) | 3 | | [yangrq1018](https://github.com/yangrq1018) | 3 | +| [TaltaM](https://github.com/TaltaM) | 3 | | [crackcomm](https://github.com/crackcomm) | 3 | | [azhang](https://github.com/azhang) | 2 | | [andreygrehov](https://github.com/andreygrehov) | 2 | | [bretep](https://github.com/bretep) | 2 | | [Christian-Achilli](https://github.com/Christian-Achilli) | 2 | | [MarkDzulko](https://github.com/MarkDzulko) | 2 | -| [TaltaM](https://github.com/TaltaM) | 2 | | [gam-phon](https://github.com/gam-phon) | 2 | | [cornelk](https://github.com/cornelk) | 2 | | [if1live](https://github.com/if1live) | 2 | | [lozdog245](https://github.com/lozdog245) | 2 | -| [tk42](https://github.com/tk42) | 2 | -| [mshogin](https://github.com/mshogin) | 2 | | [herenow](https://github.com/herenow) | 2 | +| [mshogin](https://github.com/mshogin) | 2 | +| [soxipy](https://github.com/soxipy) | 2 | +| [tk42](https://github.com/tk42) | 2 | | [daniel-cohen](https://github.com/daniel-cohen) | 1 | | [DirectX](https://github.com/DirectX) | 1 | | [frankzougc](https://github.com/frankzougc) | 1 | diff --git a/backtester/README.md b/backtester/README.md index 3297c069..a428001a 100644 --- a/backtester/README.md +++ b/backtester/README.md @@ -27,24 +27,28 @@ An event-driven backtesting tool to test and iterate trading strategies using hi - CSV data import - Database data import - Proof of concept live data running +- Shopspring decimal implementation to track stats more accurately - Can run strategies against multiple cryptocurrencies - Can run strategies that can assess multiple currencies simultaneously to make complex decisions -- Dollar cost strategy implementation -- RSI strategy implementation +- Dollar cost strategy example strategies +- RSI example strategy +- MFI example strategy - Rules customisation via config `.strat` files +- Strategy config builder application - Strategy customisation without requiring recompilation. For example, customising RSI high, low and length values via config `.strat` files. - Report generation - Portfolio manager to help size orders based on config rules, risk and candle volume - Order manager to place orders with customisable slippage estimator - Helpful statistics to help determine whether a strategy was effective - Compliance manager to keep snapshots of every transaction and their changes at every interval +- Exchange level funding allows funding to be shared across multiple currency pairs and to allow for complex strategy design +- Fund transfer. At a strategy level, transfer funds between exchanges to allow for complex strategy design ## Planned Features We welcome pull requests on any feature for the Backtester! We will be especially appreciative of any contribution towards the following planned features: | Feature | Description | |---------|-------------| -| Add quote-based portfolio funding feature | Funds are currently currency-pair based which is helpful for running the same strategy against many pairs simultaneously. This feature would allow for shared funding pool for an overarching strategy | | Add backtesting support for futures asset types | Spot trading is currently the only supported asset type. Futures trading greatly expands the Backtester's potential | | Example futures pairs trading strategy | Providing a basic example will allow for esteemed traders to build and customise their own | | Save Backtester results to database | This will allow for easier comparison of results over time | diff --git a/backtester/backtest/backtest.go b/backtester/backtest/backtest.go index 7532bbc8..9eaca69e 100644 --- a/backtester/backtest/backtest.go +++ b/backtester/backtest/backtest.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/common" "github.com/thrasher-corp/gocryptotrader/backtester/config" "github.com/thrasher-corp/gocryptotrader/backtester/data" @@ -33,6 +34,7 @@ import ( "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/funding" "github.com/thrasher-corp/gocryptotrader/backtester/report" gctcommon "github.com/thrasher-corp/gocryptotrader/common" "github.com/thrasher-corp/gocryptotrader/currency" @@ -59,6 +61,7 @@ func (bt *BackTest) Reset() { bt.Portfolio.Reset() bt.Statistic.Reset() bt.Exchange.Reset() + bt.Funding.Reset() bt.Bot = nil } @@ -71,7 +74,6 @@ func NewFromConfig(cfg *config.Config, templatePath, output string, bot *engine. if bot == nil { return nil, errNilBot } - bt := New() bt.Datas = &data.HandlerPerCurrency{} bt.EventQueue = &eventholder.Holder{} @@ -92,18 +94,42 @@ func NewFromConfig(cfg *config.Config, templatePath, output string, bot *engine. 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, } + useExchangeLevelFunding := cfg.StrategySettings.UseExchangeLevelFunding + funds := funding.SetupFundingManager(useExchangeLevelFunding) + if useExchangeLevelFunding { + for i := range cfg.StrategySettings.ExchangeLevelFunding { + var a asset.Item + a, err = asset.New(cfg.StrategySettings.ExchangeLevelFunding[i].Asset) + if err != nil { + return nil, err + } + cq := currency.NewCode(cfg.StrategySettings.ExchangeLevelFunding[i].Currency) + var item *funding.Item + item, err = funding.CreateItem(cfg.StrategySettings.ExchangeLevelFunding[i].ExchangeName, + a, + cq, + cfg.StrategySettings.ExchangeLevelFunding[i].InitialFunds, + cfg.StrategySettings.ExchangeLevelFunding[i].TransferFee) + if err != nil { + return nil, err + } + err = funds.AddItem(item) + if err != nil { + return nil, err + } + } + } + portfolioRisk := &risk.Risk{ CurrencySettings: make(map[string]map[asset.Item]map[currency.Pair]*risk.CurrencySettings), } @@ -126,40 +152,107 @@ func NewFromConfig(cfg *config.Config, templatePath, output string, bot *engine. 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) + var b, q currency.Code + b = currency.NewCode(cfg.CurrencySettings[i].Base) + q = currency.NewCode(cfg.CurrencySettings[i].Quote) + curr = currency.NewPair(b, q) + var exch gctexchange.IBotExchange + exch, err = bot.ExchangeManager.GetExchangeByName(cfg.CurrencySettings[i].ExchangeName) if err != nil { + return nil, err + } + exchBase := exch.GetBase() + var requestFormat currency.PairFormat + requestFormat, err = exchBase.GetPairFormat(a, true) + if err != nil { + return nil, fmt.Errorf("could not format currency %v, %w", curr, err) + } + curr = curr.Format(requestFormat.Delimiter, requestFormat.Uppercase) + err = exchBase.CurrencyPairs.EnablePair(a, curr) + if err != nil && !errors.Is(err, currency.ErrPairAlreadyEnabled) { return nil, fmt.Errorf( - "%w for %v %v %v. Err %v", - errInvalidConfigCurrency, + "could not enable currency %v %v %v. Err %w", cfg.CurrencySettings[i].ExchangeName, cfg.CurrencySettings[i].Asset, cfg.CurrencySettings[i].Base+cfg.CurrencySettings[i].Quote, err) } - var exch gctexchange.IBotExchange - exch, err = bot.ExchangeManager.GetExchangeByName(cfg.CurrencySettings[i].ExchangeName) - if err != nil { - return nil, fmt.Errorf("could not get exchange by name %w", err) - } - b := exch.GetBase() - var pFmt currency.PairFormat - pFmt, err = b.GetPairFormat(a, true) - if err != nil { - return nil, fmt.Errorf("could not format currency %v, %w", curr, err) - } - curr = curr.Format(pFmt.Delimiter, pFmt.Uppercase) - 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 { + if cfg.CurrencySettings[i].MakerFee.GreaterThan(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 baseItem, quoteItem *funding.Item + if useExchangeLevelFunding { + // add any remaining currency items that have no funding data in the strategy config + baseItem, err = funding.CreateItem(cfg.CurrencySettings[i].ExchangeName, + a, + b, + decimal.Zero, + decimal.Zero) + if err != nil { + return nil, err + } + quoteItem, err = funding.CreateItem(cfg.CurrencySettings[i].ExchangeName, + a, + q, + decimal.Zero, + decimal.Zero) + if err != nil { + return nil, err + } + err = funds.AddItem(baseItem) + if err != nil && !errors.Is(err, funding.ErrAlreadyExists) { + return nil, err + } + err = funds.AddItem(quoteItem) + if err != nil && !errors.Is(err, funding.ErrAlreadyExists) { + return nil, err + } + } else { + var bFunds, qFunds decimal.Decimal + if cfg.CurrencySettings[i].InitialBaseFunds != nil { + bFunds = *cfg.CurrencySettings[i].InitialBaseFunds + } + if cfg.CurrencySettings[i].InitialQuoteFunds != nil { + qFunds = *cfg.CurrencySettings[i].InitialQuoteFunds + } + baseItem, err = funding.CreateItem( + cfg.CurrencySettings[i].ExchangeName, + a, + curr.Base, + bFunds, + decimal.Zero) + if err != nil { + return nil, err + } + quoteItem, err = funding.CreateItem( + cfg.CurrencySettings[i].ExchangeName, + a, + curr.Quote, + qFunds, + decimal.Zero) + if err != nil { + return nil, err + } + var pair *funding.Pair + pair, err = funding.CreatePair(baseItem, quoteItem) + if err != nil { + return nil, err + } + err = funds.AddPair(pair) + if err != nil { + return nil, err + } + } } + bt.Funding = funds var p *portfolio.Portfolio p, err = portfolio.Setup(sizeManager, portfolioRisk, cfg.StatisticSettings.RiskFreeRate) if err != nil { @@ -204,7 +297,6 @@ func NewFromConfig(cfg *config.Config, templatePath, output string, bot *engine. 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{}, } @@ -237,43 +329,43 @@ func (bt *BackTest) setupExchangeSettings(cfg *config.Config) (exchange.Exchange return resp, err } bt.Datas.SetDataForCurrency(exchangeName, a, pair, klineData) - var makerFee, takerFee float64 - if cfg.CurrencySettings[i].MakerFee > 0 { + var makerFee, takerFee decimal.Decimal + if cfg.CurrencySettings[i].MakerFee.GreaterThan(decimal.Zero) { makerFee = cfg.CurrencySettings[i].MakerFee } - if cfg.CurrencySettings[i].TakerFee > 0 { + if cfg.CurrencySettings[i].TakerFee.GreaterThan(decimal.Zero) { takerFee = cfg.CurrencySettings[i].TakerFee } - if makerFee == 0 || takerFee == 0 { - var apiMakerFee, apiTakerFee float64 + if makerFee.IsZero() || takerFee.IsZero() { + var apiMakerFee, apiTakerFee decimal.Decimal apiMakerFee, apiTakerFee = getFees(context.TODO(), exch, pair) - if makerFee == 0 { + if makerFee.IsZero() { makerFee = apiMakerFee } - if takerFee == 0 { + if takerFee.IsZero() { takerFee = apiTakerFee } } - if cfg.CurrencySettings[i].MaximumSlippagePercent < 0 { - log.Warnf(log.BackTester, "invalid maximum slippage percent '%f'. Slippage percent is defined as a number, eg '100.00', defaulting to '%f'", + if cfg.CurrencySettings[i].MaximumSlippagePercent.LessThan(decimal.Zero) { + 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 { + if cfg.CurrencySettings[i].MaximumSlippagePercent.IsZero() { cfg.CurrencySettings[i].MaximumSlippagePercent = slippage.DefaultMaximumSlippagePercent } - if cfg.CurrencySettings[i].MinimumSlippagePercent < 0 { - log.Warnf(log.BackTester, "invalid minimum slippage percent '%f'. Slippage percent is defined as a number, eg '80.00', defaulting to '%f'", + if cfg.CurrencySettings[i].MinimumSlippagePercent.LessThan(decimal.Zero) { + 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 { + if cfg.CurrencySettings[i].MinimumSlippagePercent.IsZero() { cfg.CurrencySettings[i].MinimumSlippagePercent = slippage.DefaultMinimumSlippagePercent } - if cfg.CurrencySettings[i].MaximumSlippagePercent < cfg.CurrencySettings[i].MinimumSlippagePercent { + if cfg.CurrencySettings[i].MaximumSlippagePercent.LessThan(cfg.CurrencySettings[i].MinimumSlippagePercent) { cfg.CurrencySettings[i].MaximumSlippagePercent = slippage.DefaultMaximumSlippagePercent } @@ -287,13 +379,11 @@ func (bt *BackTest) setupExchangeSettings(cfg *config.Config) (exchange.Exchange 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) { @@ -302,17 +392,15 @@ func (bt *BackTest) setupExchangeSettings(cfg *config.Config) (exchange.Exchange 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", + log.Warnf(log.BackTester, "exchange %s order execution limits supported but disabled for %s %s, live results may differ", 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, @@ -328,8 +416,9 @@ func (bt *BackTest) setupExchangeSettings(cfg *config.Config) (exchange.Exchange MaximumLeverageRate: cfg.CurrencySettings[i].Leverage.MaximumLeverageRate, MaximumOrdersWithLeverageRatio: cfg.CurrencySettings[i].Leverage.MaximumOrdersWithLeverageRatio, }, - Limits: limits, - CanUseExchangeLimits: cfg.CurrencySettings[i].CanUseExchangeLimits, + Limits: limits, + SkipCandleVolumeFitting: cfg.CurrencySettings[i].SkipCandleVolumeFitting, + CanUseExchangeLimits: cfg.CurrencySettings[i].CanUseExchangeLimits, }) } @@ -371,10 +460,6 @@ func (bt *BackTest) loadExchangePairAssetBase(exch, base, quote, ass string) (gc 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, nil) @@ -401,11 +486,9 @@ func (bt *BackTest) setupBot(cfg *config.Config, bot *engine.Engine) error { } // getFees will return an exchange's fee rate from GCT's wrapper function -func getFees(ctx context.Context, exch gctexchange.IBotExchange, fPair currency.Pair) (makerFee, takerFee float64) { - var err error - takerFee, err = exch.GetFeeByType(ctx, - &gctexchange.FeeBuilder{ - FeeType: gctexchange.OfflineTradeFee, +func getFees(ctx context.Context, exch gctexchange.IBotExchange, fPair currency.Pair) (makerFee, takerFee decimal.Decimal) { + fTakerFee, err := exch.GetFeeByType(ctx, + &gctexchange.FeeBuilder{FeeType: gctexchange.OfflineTradeFee, Pair: fPair, IsMaker: false, PurchasePrice: 1, @@ -415,7 +498,7 @@ func getFees(ctx context.Context, exch gctexchange.IBotExchange, fPair currency. log.Errorf(log.BackTester, "Could not retrieve taker fee for %v. %v", exch.GetName(), err) } - makerFee, err = exch.GetFeeByType(ctx, + fMakerFee, err := exch.GetFeeByType(ctx, &gctexchange.FeeBuilder{ FeeType: gctexchange.OfflineTradeFee, Pair: fPair, @@ -427,7 +510,7 @@ func getFees(ctx context.Context, exch gctexchange.IBotExchange, fPair currency. log.Errorf(log.BackTester, "Could not retrieve maker fee for %v. %v", exch.GetName(), err) } - return makerFee, takerFee + return decimal.NewFromFloat(fMakerFee), decimal.NewFromFloat(fTakerFee) } // loadData will create kline data from the sources defined in start config files. It can exist from databases, csv or API endpoints @@ -476,7 +559,7 @@ func (bt *BackTest) loadData(cfg *config.Config, exch gctexchange.IBotExchange, } resp.Item.RemoveDuplicates() resp.Item.SortCandlesByTimestamp(false) - resp.Range, err = gctkline.CalculateCandleDateRanges( + resp.RangeHolder, err = 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), @@ -485,8 +568,8 @@ func (bt *BackTest) loadData(cfg *config.Config, exch gctexchange.IBotExchange, if err != nil { return nil, err } - resp.Range.SetHasDataFromCandles(resp.Item.Candles) - summary := resp.Range.DataSummary(false) + resp.RangeHolder.SetHasDataFromCandles(resp.Item.Candles) + summary := resp.RangeHolder.DataSummary(false) if len(summary) > 0 { log.Warnf(log.BackTester, "%v", summary) } @@ -524,7 +607,7 @@ func (bt *BackTest) loadData(cfg *config.Config, exch gctexchange.IBotExchange, resp.Item.RemoveDuplicates() resp.Item.SortCandlesByTimestamp(false) - resp.Range, err = gctkline.CalculateCandleDateRanges( + resp.RangeHolder, err = gctkline.CalculateCandleDateRanges( cfg.DataSettings.DatabaseData.StartDate, cfg.DataSettings.DatabaseData.EndDate, gctkline.Interval(cfg.DataSettings.Interval), @@ -533,8 +616,8 @@ func (bt *BackTest) loadData(cfg *config.Config, exch gctexchange.IBotExchange, if err != nil { return nil, err } - resp.Range.SetHasDataFromCandles(resp.Item.Candles) - summary := resp.Range.DataSummary(false) + resp.RangeHolder.SetHasDataFromCandles(resp.Item.Candles) + summary := resp.RangeHolder.DataSummary(false) if len(summary) > 0 { log.Warnf(log.BackTester, "%v", summary) } @@ -575,7 +658,9 @@ func (bt *BackTest) loadData(cfg *config.Config, exch gctexchange.IBotExchange, err = b.ValidateKline(fPair, a, resp.Item.Interval) if err != nil { - return nil, err + if dataType != common.DataTrade || !strings.EqualFold(err.Error(), "interval not supported") { + return nil, err + } } err = resp.Load() @@ -590,10 +675,6 @@ func loadDatabaseData(cfg *config.Config, name string, fPair currency.Pair, a as 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 } @@ -609,10 +690,6 @@ func loadDatabaseData(cfg *config.Config, name string, fPair currency.Pair, a as } 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 } @@ -643,8 +720,8 @@ func loadAPIData(cfg *config.Config, exch gctexchange.IBotExchange, fPair curren candles.FillMissingDataWithEmptyEntries(dates) candles.RemoveOutsideRange(cfg.DataSettings.APIData.StartDate, cfg.DataSettings.APIData.EndDate) return &kline.DataFromKline{ - Item: *candles, - Range: dates, + Item: *candles, + RangeHolder: dates, }, nil } @@ -668,8 +745,8 @@ func loadLiveData(cfg *config.Config, base *gctexchange.Base) error { 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 + if cfg.DataSettings.LiveData.APISubAccountOverride != "" { + base.API.Credentials.Subaccount = cfg.DataSettings.LiveData.APISubAccountOverride } validated := base.ValidateAPICredentials() base.API.AuthenticatedSupport = validated @@ -699,7 +776,7 @@ dataLoadingIssue: } break dataLoadingIssue } - if bt.Strategy.UseSimultaneousProcessing() && hasProcessedData { + if bt.Strategy.UsingSimultaneousProcessing() && hasProcessedData { continue } bt.EventQueue.AppendEvent(d) @@ -708,10 +785,11 @@ dataLoadingIssue: } } } - - err := bt.handleEvent(ev) - if err != nil { - return err + if ev != nil { + err := bt.handleEvent(ev) + if err != nil { + return err + } } if !bt.hasHandledEvent { bt.hasHandledEvent = true @@ -725,27 +803,57 @@ dataLoadingIssue: // 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) { +func (bt *BackTest) handleEvent(ev common.EventHandler) error { + funds, err := bt.Funding.GetFundingForEvent(ev) + if err != nil { + return err + } + switch eType := ev.(type) { case common.DataEventHandler: - return bt.processDataEvent(ev) + if bt.Strategy.UsingSimultaneousProcessing() { + return bt.processSimultaneousDataEvents() + } + return bt.processSingleDataEvent(eType, funds) case signal.Event: - bt.processSignalEvent(ev) + bt.processSignalEvent(eType, funds) case order.Event: - bt.processOrderEvent(ev) + bt.processOrderEvent(eType, funds) case fill.Event: - bt.processFillEvent(ev) - case nil: + bt.processFillEvent(eType, funds) default: return fmt.Errorf("%w %v received, could not process", errUnhandledDatatype, - e) + ev) } return nil } -// processDataEvent determines what signal events are generated and appended +func (bt *BackTest) processSingleDataEvent(ev common.DataEventHandler, funds funding.IPairReader) error { + err := bt.updateStatsForDataEvent(ev, funds) + if err != nil { + return err + } + d := bt.Datas.GetDataForCurrency(ev.GetExchange(), ev.GetAssetType(), ev.Pair()) + s, err := bt.Strategy.OnSignal(d, bt.Funding) + 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 +} + +// processSimultaneousDataEvents 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 @@ -753,80 +861,72 @@ func (bt *BackTest) handleEvent(e common.EventHandler) error { // // 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) +func (bt *BackTest) processSimultaneousDataEvents() error { + var dataEvents []data.Handler + dataHandlerMap := bt.Datas.GetAllData() + for _, exchangeMap := range dataHandlerMap { + for _, assetMap := range exchangeMap { + for _, dataHandler := range assetMap { + latestData := dataHandler.Latest() + funds, err := bt.Funding.GetFundingForEAP(latestData.GetExchange(), latestData.GetAssetType(), latestData.Pair()) + if err != nil { + return err } + err = bt.updateStatsForDataEvent(latestData, funds) + if err != nil && err == statistics.ErrAlreadyProcessed { + continue + } + 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 + } + signals, err := bt.Strategy.OnSimultaneousSignals(dataEvents, bt.Funding) + if err != nil { + if errors.Is(err, base.ErrTooMuchBadData) { + // too much bad data is a severe error and backtesting must cease + return err } - 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) + 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(s) + bt.EventQueue.AppendEvent(signals[i]) } 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) +func (bt *BackTest) updateStatsForDataEvent(ev common.DataEventHandler, funds funding.IPairReader) error { + // update statistics with the latest price + err := bt.Statistic.SetupEventForTime(ev) + if err != nil { + if err == statistics.ErrAlreadyProcessed { + return err + } + log.Error(log.BackTester, err) + } + // update portfolio manager with the latest price + err = bt.Portfolio.UpdateHoldings(ev, funds) if err != nil { log.Error(log.BackTester, err) } + return nil } -func (bt *BackTest) processSignalEvent(ev signal.Event) { +// processSignalEvent receives an event from the strategy for processing under the portfolio +func (bt *BackTest) processSignalEvent(ev signal.Event, funds funding.IPairReserver) { 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) + o, err = bt.Portfolio.OnSignal(ev, &cs, funds) if err != nil { log.Error(log.BackTester, err) return @@ -839,9 +939,9 @@ func (bt *BackTest) processSignalEvent(ev signal.Event) { bt.EventQueue.AppendEvent(o) } -func (bt *BackTest) processOrderEvent(ev order.Event) { +func (bt *BackTest) processOrderEvent(ev order.Event, funds funding.IPairReleaser) { d := bt.Datas.GetDataForCurrency(ev.GetExchange(), ev.GetAssetType(), ev.Pair()) - f, err := bt.Exchange.ExecuteOrder(ev, d, bt.Bot) + f, err := bt.Exchange.ExecuteOrder(ev, d, bt.Bot, funds) if err != nil { if f == nil { log.Errorf(log.BackTester, "fill event should always be returned, please fix, %v", err) @@ -856,8 +956,8 @@ func (bt *BackTest) processOrderEvent(ev order.Event) { bt.EventQueue.AppendEvent(f) } -func (bt *BackTest) processFillEvent(ev fill.Event) { - t, err := bt.Portfolio.OnFill(ev) +func (bt *BackTest) processFillEvent(ev fill.Event, funds funding.IPairReader) { + t, err := bt.Portfolio.OnFill(ev, funds) if err != nil { log.Error(log.BackTester, err) return @@ -868,13 +968,13 @@ func (bt *BackTest) processFillEvent(ev fill.Event) { log.Error(log.BackTester, err) } - var holding holdings.Holding - holding, err = bt.Portfolio.ViewHoldingAtTimePeriod(ev.GetExchange(), ev.GetAssetType(), ev.Pair(), ev.GetTime()) + var holding *holdings.Holding + holding, err = bt.Portfolio.ViewHoldingAtTimePeriod(ev) if err != nil { log.Error(log.BackTester, err) } - err = bt.Statistic.AddHoldingsForTime(&holding) + err = bt.Statistic.AddHoldingsForTime(holding) if err != nil { log.Error(log.BackTester, err) } @@ -925,6 +1025,7 @@ func (bt *BackTest) RunLive() error { if de == nil { break } + bt.EventQueue.AppendEvent(de) doneARun = true continue @@ -944,7 +1045,16 @@ func (bt *BackTest) RunLive() error { // 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() + startDate := time.Now().Add(-cfg.DataSettings.Interval * 2) + dates, err := gctkline.CalculateCandleDateRanges( + startDate, + startDate.AddDate(1, 0, 0), + gctkline.Interval(cfg.DataSettings.Interval), + 0) + if err != nil { + log.Errorf(log.BackTester, "%v. Please check your GoCryptoTrader configuration", err) + return + } candles, err := live.LoadData(context.TODO(), exch, dataType, @@ -955,6 +1065,8 @@ func (bt *BackTest) loadLiveDataLoop(resp *kline.DataFromKline, cfg *config.Conf log.Errorf(log.BackTester, "%v. Please check your GoCryptoTrader configuration", err) return } + dates.SetHasDataFromCandles(candles.Candles) + resp.RangeHolder = dates resp.Item = *candles loadNewDataTimer := time.NewTimer(time.Second * 5) @@ -964,8 +1076,8 @@ func (bt *BackTest) loadLiveDataLoop(resp *kline.DataFromKline, cfg *config.Conf return case <-loadNewDataTimer.C: log.Infof(log.BackTester, "fetching data for %v %v %v %v", exch.GetName(), a, fPair, cfg.DataSettings.Interval) - loadNewDataTimer.Reset(time.Second * 30) - err = bt.loadLiveData(resp, cfg, exch, fPair, a, startDate, dataType) + loadNewDataTimer.Reset(time.Second * 15) + err = bt.loadLiveData(resp, cfg, exch, fPair, a, dataType) if err != nil { log.Error(log.BackTester, err) return @@ -974,7 +1086,7 @@ func (bt *BackTest) loadLiveDataLoop(resp *kline.DataFromKline, cfg *config.Conf } } -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 { +func (bt *BackTest) loadLiveData(resp *kline.DataFromKline, cfg *config.Config, exch gctexchange.IBotExchange, fPair currency.Pair, a asset.Item, dataType int64) error { if resp == nil { return errNilData } @@ -993,49 +1105,11 @@ func (bt *BackTest) loadLiveData(resp *kline.DataFromKline, cfg *config.Config, if err != nil { return err } - - resp.Item.Candles = append(resp.Item.Candles, candles.Candles...) - _, err = exch.FetchOrderbook(context.TODO(), 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 == nil || resp.Range.Ranges == nil { - dataRange, err := gctkline.CalculateCandleDateRanges( - startDate, - endDate, - gctkline.Interval(cfg.DataSettings.Interval), - 0, - ) - if err != nil { - return err - } - 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) + resp.AppendResults(candles) + bt.Reports.UpdateItem(&resp.Item) log.Info(log.BackTester, "sleeping for 30 seconds before checking for new candle data") return nil } diff --git a/backtester/backtest/backtest_test.go b/backtester/backtest/backtest_test.go index 253600a9..15150933 100644 --- a/backtester/backtest/backtest_test.go +++ b/backtester/backtest/backtest_test.go @@ -3,11 +3,13 @@ package backtest import ( "errors" "log" + "os" "path/filepath" "strings" "testing" "time" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/common" "github.com/thrasher-corp/gocryptotrader/backtester/config" "github.com/thrasher-corp/gocryptotrader/backtester/data" @@ -20,8 +22,11 @@ import ( "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/eventhandlers/strategies/dollarcostaverage" + "github.com/thrasher-corp/gocryptotrader/backtester/funding" "github.com/thrasher-corp/gocryptotrader/backtester/report" + gctcommon "github.com/thrasher-corp/gocryptotrader/common" "github.com/thrasher-corp/gocryptotrader/common/convert" gctconfig "github.com/thrasher-corp/gocryptotrader/config" "github.com/thrasher-corp/gocryptotrader/currency" @@ -35,6 +40,14 @@ import ( const testExchange = "Bitstamp" +var leet *decimal.Decimal + +func TestMain(m *testing.M) { + oneThreeThreeSeven := decimal.NewFromInt(1337) + leet = &oneThreeThreeSeven + os.Exit(m.Run()) +} + func newBotWithExchange() *engine.Engine { bot := &engine.Engine{ Config: &gctconfig.Config{ @@ -79,13 +92,13 @@ func TestNewFromConfig(t *testing.T) { cfg := &config.Config{} _, err = NewFromConfig(cfg, "", "", nil) if !errors.Is(err, errNilBot) { - t.Errorf("expected: %v, received %v", errNilBot, err) + t.Errorf("received: %v, expected: %v", err, errNilBot) } bot := newBotWithExchange() _, err = NewFromConfig(cfg, "", "", bot) - if !errors.Is(err, config.ErrNoCurrencySettings) { - t.Errorf("expected: %v, received %v", config.ErrNoCurrencySettings, err) + if !errors.Is(err, base.ErrStrategyNotFound) { + t.Errorf("received: %v, expected: %v", err, base.ErrStrategyNotFound) } cfg.CurrencySettings = []config.CurrencySettings{ @@ -96,20 +109,25 @@ func TestNewFromConfig(t *testing.T) { }, } _, err = NewFromConfig(cfg, "", "", bot) - if !errors.Is(err, config.ErrBadInitialFunds) { - t.Errorf("expected: %v, received %v", config.ErrBadInitialFunds, err) + if !errors.Is(err, engine.ErrExchangeNotFound) { + t.Errorf("received: %v, expected: %v", err, engine.ErrExchangeNotFound) } - - cfg.CurrencySettings[0].InitialFunds = 1337 + cfg.CurrencySettings[0].ExchangeName = testExchange _, err = NewFromConfig(cfg, "", "", bot) - if !errors.Is(err, config.ErrUnsetAsset) { - t.Errorf("expected: %v, received %v", config.ErrUnsetAsset, err) + if !errors.Is(err, errInvalidConfigAsset) { + t.Errorf("received: %v, expected: %v", err, errInvalidConfigAsset) } - cfg.CurrencySettings[0].Asset = asset.Spot.String() _, err = NewFromConfig(cfg, "", "", bot) - if !errors.Is(err, engine.ErrExchangeNotFound) { - t.Errorf("expected: %v, received %v", engine.ErrExchangeNotFound, err) + if !errors.Is(err, currency.ErrPairNotFound) { + t.Errorf("received: %v, expected: %v", err, currency.ErrPairNotFound) + } + + cfg.CurrencySettings[0].Base = "btc" + cfg.CurrencySettings[0].Quote = "usd" + _, err = NewFromConfig(cfg, "", "", bot) + if !errors.Is(err, base.ErrStrategyNotFound) { + t.Errorf("received: %v, expected: %v", err, base.ErrStrategyNotFound) } cfg.StrategySettings = config.StrategySettings{ @@ -118,12 +136,6 @@ func TestNewFromConfig(t *testing.T) { "hello": "moto", }, } - cfg.CurrencySettings[0].ExchangeName = testExchange - _, err = NewFromConfig(cfg, "", "", bot) - if !errors.Is(err, errNoDataSource) { - t.Errorf("expected: %v, received %v", errNoDataSource, err) - } - cfg.CurrencySettings[0].Base = "BTC" cfg.CurrencySettings[0].Quote = "USD" cfg.DataSettings.APIData = &config.APIData{ @@ -137,24 +149,23 @@ func TestNewFromConfig(t *testing.T) { } cfg.DataSettings.DataType = common.CandleStr _, err = NewFromConfig(cfg, "", "", bot) - if !errors.Is(err, config.ErrStartEndUnset) { - t.Errorf("expected: %v, received %v", config.ErrStartEndUnset, err) + if !errors.Is(err, errIntervalUnset) { + t.Errorf("received: %v, expected: %v", err, errIntervalUnset) + } + cfg.DataSettings.Interval = gctkline.OneMin.Duration() + cfg.CurrencySettings[0].MakerFee = decimal.Zero + cfg.CurrencySettings[0].TakerFee = decimal.Zero + _, err = NewFromConfig(cfg, "", "", bot) + if !errors.Is(err, gctcommon.ErrDateUnset) { + t.Errorf("received: %v, expected: %v", err, gctcommon.ErrDateUnset) } cfg.DataSettings.APIData.StartDate = time.Now().Add(-time.Minute) cfg.DataSettings.APIData.EndDate = time.Now() cfg.DataSettings.APIData.InclusiveEndDate = true _, err = NewFromConfig(cfg, "", "", bot) - if !errors.Is(err, errIntervalUnset) { - t.Errorf("expected: %v, received %v", errIntervalUnset, err) - } - - cfg.DataSettings.Interval = gctkline.OneMin.Duration() - cfg.CurrencySettings[0].MakerFee = 1337 - cfg.CurrencySettings[0].TakerFee = 1337 - _, err = NewFromConfig(cfg, "", "", bot) - if err != nil { - t.Error(err) + if !errors.Is(err, nil) { + t.Errorf("received: %v, expected: %v", err, nil) } } @@ -168,16 +179,16 @@ func TestLoadDataAPI(t *testing.T) { cfg := &config.Config{ CurrencySettings: []config.CurrencySettings{ { - ExchangeName: "Binance", - Asset: asset.Spot.String(), - Base: cp.Base.String(), - Quote: cp.Quote.String(), - InitialFunds: 1337, - Leverage: config.Leverage{}, - BuySide: config.MinMax{}, - SellSide: config.MinMax{}, - MakerFee: 1337, - TakerFee: 1337, + ExchangeName: "Binance", + Asset: asset.Spot.String(), + Base: cp.Base.String(), + Quote: cp.Quote.String(), + InitialQuoteFunds: leet, + Leverage: config.Leverage{}, + BuySide: config.MinMax{}, + SellSide: config.MinMax{}, + MakerFee: decimal.Zero, + TakerFee: decimal.Zero, }, }, DataSettings: config.DataSettings{ @@ -227,16 +238,16 @@ func TestLoadDataDatabase(t *testing.T) { cfg := &config.Config{ CurrencySettings: []config.CurrencySettings{ { - ExchangeName: "Binance", - Asset: asset.Spot.String(), - Base: cp.Base.String(), - Quote: cp.Quote.String(), - InitialFunds: 1337, - Leverage: config.Leverage{}, - BuySide: config.MinMax{}, - SellSide: config.MinMax{}, - MakerFee: 1337, - TakerFee: 1337, + ExchangeName: "Binance", + Asset: asset.Spot.String(), + Base: cp.Base.String(), + Quote: cp.Quote.String(), + InitialQuoteFunds: leet, + Leverage: config.Leverage{}, + BuySide: config.MinMax{}, + SellSide: config.MinMax{}, + MakerFee: decimal.Zero, + TakerFee: decimal.Zero, }, }, DataSettings: config.DataSettings{ @@ -292,16 +303,16 @@ func TestLoadDataCSV(t *testing.T) { cfg := &config.Config{ CurrencySettings: []config.CurrencySettings{ { - ExchangeName: "Binance", - Asset: asset.Spot.String(), - Base: cp.Base.String(), - Quote: cp.Quote.String(), - InitialFunds: 1337, - Leverage: config.Leverage{}, - BuySide: config.MinMax{}, - SellSide: config.MinMax{}, - MakerFee: 1337, - TakerFee: 1337, + ExchangeName: "Binance", + Asset: asset.Spot.String(), + Base: cp.Base.String(), + Quote: cp.Quote.String(), + InitialQuoteFunds: leet, + Leverage: config.Leverage{}, + BuySide: config.MinMax{}, + SellSide: config.MinMax{}, + MakerFee: decimal.Zero, + TakerFee: decimal.Zero, }, }, DataSettings: config.DataSettings{ @@ -350,16 +361,16 @@ func TestLoadDataLive(t *testing.T) { cfg := &config.Config{ CurrencySettings: []config.CurrencySettings{ { - ExchangeName: "Binance", - Asset: asset.Spot.String(), - Base: cp.Base.String(), - Quote: cp.Quote.String(), - InitialFunds: 1337, - Leverage: config.Leverage{}, - BuySide: config.MinMax{}, - SellSide: config.MinMax{}, - MakerFee: 1337, - TakerFee: 1337, + ExchangeName: "Binance", + Asset: asset.Spot.String(), + Base: cp.Base.String(), + Quote: cp.Quote.String(), + InitialQuoteFunds: leet, + Leverage: config.Leverage{}, + BuySide: config.MinMax{}, + SellSide: config.MinMax{}, + MakerFee: decimal.Zero, + TakerFee: decimal.Zero, }, }, DataSettings: config.DataSettings{ @@ -460,7 +471,7 @@ func TestLoadLiveData(t *testing.T) { cfg.DataSettings.LiveData.APISecretOverride = "1234" cfg.DataSettings.LiveData.APIClientIDOverride = "1234" cfg.DataSettings.LiveData.API2FAOverride = "1234" - cfg.DataSettings.LiveData.APISubaccountOverride = "1234" + cfg.DataSettings.LiveData.APISubAccountOverride = "1234" err = loadLiveData(cfg, b) if err != nil { t.Error(err) @@ -479,6 +490,7 @@ func TestReset(t *testing.T) { Statistic: &statistics.Statistic{}, EventQueue: &eventholder.Holder{}, Reports: &report.Data{}, + Funding: &funding.FundManager{}, } bt.Reset() if bt.Bot != nil { @@ -501,7 +513,7 @@ func TestFullCycle(t *testing.T) { port, err := portfolio.Setup(&size.Size{ BuySide: config.MinMax{}, SellSide: config.MinMax{}, - }, &risk.Risk{}, 0) + }, &risk.Risk{}, decimal.Zero) if err != nil { t.Error(err) } @@ -509,12 +521,24 @@ func TestFullCycle(t *testing.T) { if err != nil { t.Error(err) } - err = port.SetInitialFunds(ex, a, cp, 1333337) + bot := newBotWithExchange() + f := &funding.FundManager{} + b, err := funding.CreateItem(ex, a, cp.Base, decimal.Zero, decimal.Zero) + if err != nil { + t.Error(err) + } + quote, err := funding.CreateItem(ex, a, cp.Quote, decimal.NewFromInt(1337), decimal.Zero) + if err != nil { + t.Error(err) + } + pair, err := funding.CreatePair(b, quote) + if err != nil { + t.Error(err) + } + err = f.AddPair(pair) if err != nil { t.Error(err) } - bot := newBotWithExchange() - bt := BackTest{ Bot: bot, shutdown: nil, @@ -525,6 +549,7 @@ func TestFullCycle(t *testing.T) { Statistic: stats, EventQueue: &eventholder.Holder{}, Reports: &report.Data{}, + Funding: f, } bt.Datas.Setup() @@ -544,7 +569,7 @@ func TestFullCycle(t *testing.T) { }}, }, Base: data.Base{}, - Range: &gctkline.IntervalRangeHolder{ + RangeHolder: &gctkline.IntervalRangeHolder{ Start: gctkline.CreateIntervalTime(tt), End: gctkline.CreateIntervalTime(tt.Add(gctkline.FifteenMin.Duration())), Ranges: []gctkline.IntervalRange{ @@ -595,7 +620,7 @@ func TestFullCycleMulti(t *testing.T) { port, err := portfolio.Setup(&size.Size{ BuySide: config.MinMax{}, SellSide: config.MinMax{}, - }, &risk.Risk{}, 0) + }, &risk.Risk{}, decimal.Zero) if err != nil { t.Error(err) } @@ -603,12 +628,24 @@ func TestFullCycleMulti(t *testing.T) { if err != nil { t.Error(err) } - err = port.SetInitialFunds(ex, a, cp, 1333337) + bot := newBotWithExchange() + f := &funding.FundManager{} + b, err := funding.CreateItem(ex, a, cp.Base, decimal.Zero, decimal.Zero) + if err != nil { + t.Error(err) + } + quote, err := funding.CreateItem(ex, a, cp.Quote, decimal.NewFromInt(1337), decimal.Zero) + if err != nil { + t.Error(err) + } + pair, err := funding.CreatePair(b, quote) + if err != nil { + t.Error(err) + } + err = f.AddPair(pair) if err != nil { t.Error(err) } - bot := newBotWithExchange() - bt := BackTest{ Bot: bot, shutdown: nil, @@ -618,6 +655,7 @@ func TestFullCycleMulti(t *testing.T) { Statistic: stats, EventQueue: &eventholder.Holder{}, Reports: &report.Data{}, + Funding: f, } bt.Strategy, err = strategies.LoadStrategyByName(dollarcostaverage.Name, true) @@ -642,7 +680,7 @@ func TestFullCycleMulti(t *testing.T) { }}, }, Base: data.Base{}, - Range: &gctkline.IntervalRangeHolder{ + RangeHolder: &gctkline.IntervalRangeHolder{ Start: gctkline.CreateIntervalTime(tt), End: gctkline.CreateIntervalTime(tt.Add(gctkline.FifteenMin.Duration())), Ranges: []gctkline.IntervalRange{ diff --git a/backtester/backtest/backtest_types.go b/backtester/backtest/backtest_types.go index a7803fcf..50e847ae 100644 --- a/backtester/backtest/backtest_types.go +++ b/backtester/backtest/backtest_types.go @@ -9,22 +9,22 @@ import ( "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/portfolio" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/statistics" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/strategies" + "github.com/thrasher-corp/gocryptotrader/backtester/funding" "github.com/thrasher-corp/gocryptotrader/backtester/report" "github.com/thrasher-corp/gocryptotrader/engine" ) var ( - errNilConfig = errors.New("unable to setup backtester with nil config") - errNilBot = errors.New("unable to setup backtester without a loaded GoCryptoTrader bot") - errInvalidConfigAsset = errors.New("invalid asset in config") - errInvalidConfigCurrency = errors.New("invalid currency in config") - errAmbiguousDataSource = errors.New("ambiguous settings received. Only one data type can be set") - errNoDataSource = errors.New("no data settings set in config") - errIntervalUnset = errors.New("candle interval unset") - errUnhandledDatatype = errors.New("unhandled datatype") - errLiveDataTimeout = errors.New("no data returned in 5 minutes, shutting down") - errNilData = errors.New("nil data received") - errNilExchange = errors.New("nil exchange received") + errNilConfig = errors.New("unable to setup backtester with nil config") + errNilBot = errors.New("unable to setup backtester without a loaded GoCryptoTrader bot") + errInvalidConfigAsset = errors.New("invalid asset in config") + errAmbiguousDataSource = errors.New("ambiguous settings received. Only one data type can be set") + errNoDataSource = errors.New("no data settings set in config") + errIntervalUnset = errors.New("candle interval unset") + errUnhandledDatatype = errors.New("unhandled datatype") + errLiveDataTimeout = errors.New("no data returned in 5 minutes, shutting down") + errNilData = errors.New("nil data received") + errNilExchange = errors.New("nil exchange received") ) // BackTest is the main holder of all backtesting functionality @@ -39,4 +39,5 @@ type BackTest struct { Statistic statistics.Handler EventQueue eventholder.EventHolder Reports report.Handler + Funding funding.IFundingManager } diff --git a/backtester/common/common_test.go b/backtester/common/common_test.go index 9b621297..25992ab8 100644 --- a/backtester/common/common_test.go +++ b/backtester/common/common_test.go @@ -33,7 +33,7 @@ func TestDataTypeConversion(t *testing.T) { got, err := DataTypeToInt(ti.dataType) if ti.expectErr { if err == nil { - t.Errorf("expected error") + t.Error("expected error") } } else { if err != nil || got != ti.want { diff --git a/backtester/common/common_types.go b/backtester/common/common_types.go index 2e497419..132d49b5 100644 --- a/backtester/common/common_types.go +++ b/backtester/common/common_types.go @@ -4,6 +4,7 @@ import ( "errors" "time" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/exchanges/asset" "github.com/thrasher-corp/gocryptotrader/exchanges/kline" @@ -14,6 +15,8 @@ const ( // DoNothing is an explicit signal for the backtester to not perform an action // based upon indicator results DoNothing order.Side = "DO NOTHING" + // TransferredFunds is a status signal to do nothing + TransferredFunds order.Side = "TRANSFERRED FUNDS" // CouldNotBuy is flagged when a BUY signal is raised in the strategy/signal phase, but the // portfolio manager or exchange cannot place an order CouldNotBuy order.Side = "COULD NOT BUY" @@ -55,7 +58,6 @@ type EventHandler interface { GetExchange() string GetInterval() kline.Interval GetAssetType() asset.Item - GetReason() string AppendReason(string) } @@ -63,10 +65,10 @@ type EventHandler interface { // DataEventHandler interface used for loading and interacting with Data type DataEventHandler interface { EventHandler - ClosePrice() float64 - HighPrice() float64 - LowPrice() float64 - OpenPrice() float64 + ClosePrice() decimal.Decimal + HighPrice() decimal.Decimal + LowPrice() decimal.Decimal + OpenPrice() decimal.Decimal } // Directioner dictates the side of an order diff --git a/backtester/config/README.md b/backtester/config/README.md index 5d80d84e..34b28003 100644 --- a/backtester/config/README.md +++ b/backtester/config/README.md @@ -42,12 +42,34 @@ See below for a set of tables and fields, expected values and what they can do | --- | ------| | Nickname | A nickname for the specific config. When running multiple variants of the same strategy, use the nickname to help differentiate between runs | | Goal | A description of what you would hope the outcome to be. When verifying output, you can review and confirm whether the strategy met that goal | -| CurrencySettings | Currency settings is an array of settings for each individual currency you wish to run the strategy against. | +| CurrencySettings | Currency settings is an array of settings for each individual currency you wish to run the strategy against | | StrategySettings | Select which strategy to run, what custom settings to load and whether the strategy can assess multiple currencies at once to make more in-depth decisions | | PortfolioSettings | Contains a list of global rules for the portfolio manager. CurrencySettings contain their own rules on things like how big a position is allowable, the portfolio manager rules are the same, but override any individual currency's settings | | StatisticSettings | Contains settings that impact statistics calculation. Such as the risk-free rate for the sharpe ratio | | GoCryptoTraderConfigPath | The filepath for the location of GoCryptoTrader's config path. The Backtester utilises settings from GoCryptoTrader. If unset, will utilise the default filepath via `config.DefaultFilePath`, implemented [here](/config/config.go#L1460) | + +#### Strategy Settings + +| Key | Description | Example | +| --- | ------- | --- | +| Name | The strategy to use | `rsi` | +| UsesSimultaneousProcessing | This denotes whether multiple currencies are processed simultaneously with the strategy function `OnSimultaneousSignals`. Eg If you have multiple CurrencySettings and only wish to purchase BTC-USDT when XRP-DOGE is 1337, this setting is useful as you can analyse both signal events to output a purchase call for BTC | `true` | +| CustomSettings | This is a map where you can enter custom settings for a strategy. The RSI strategy allows for customisation of the upper, lower and length variables to allow you to change them from 70, 30 and 14 respectively to 69, 36, 12 | `"custom-settings": { "rsi-high": 70, "rsi-low": 30, "rsi-period": 14 } ` | +| UseExchangeLevelFunding | Allows shared funding at an exchange asset level. You can set funding for `USDT` and all pairs that feature `USDT` will have access to those funds when making orders. See [this](/backtester/funding/README.md) for more information | `false` | +| ExchangeLevelFunding | An array of exchange level funding settings. See below, or [this](/backtester/funding/README.md) for more information | `[]` | + +##### Funding Config Settings + +| Key | Description | Example | +| --- | ------- | ----- | +| ExchangeName | The exchange to set funds. See [here](https://github.com/thrasher-corp/gocryptotrader/blob/master/README.md) for a list of supported exchanges | `Binance` | +| Asset | The asset type to set funds. Typically, this will be `spot`, however, see [this package](https://github.com/thrasher-corp/gocryptotrader/blob/master/exchanges/asset/asset.go) for the various asset types GoCryptoTrader supports| `spot` | +| Currency | The currency to set funds | `BTC` | +| InitialFunds | The initial funding for the currency | `1337` | +| TransferFee | If your strategy utilises transferring of funds via the Funding Manager, this is deducted upon doing so | `0.005` | + + #### Currency Settings | Key | Description | Example | @@ -56,7 +78,9 @@ See below for a set of tables and fields, expected values and what they can do | Asset | The asset type. Typically, this will be `spot`, however, see [this package](https://github.com/thrasher-corp/gocryptotrader/blob/master/exchanges/asset/asset.go) for the various asset types GoCryptoTrader supports| `spot` | | Base | The base of a currency | `BTC` | | Quote | The quote of a currency | `USDT` | -| InitialFunds | The funds that the GoCryptoTraderBacktester has for the specific currency | `10000` | +| InitialFunds | A legacy field, will be temporarily migrated to `InitialQuoteFunds` if present in your strat config | `` | +| InitialBaseFunds | The funds that the GoCryptoTraderBacktester has for the base currency. This is only required if the strategy setting `UseExchangeLevelFunding` is `false` | `2` | +| InitialQuoteFunds | The funds that the GoCryptoTraderBacktester has for the quote currency. This is only required if the strategy setting `UseExchangeLevelFunding` is `false` | `10000` | | Leverage | This struct defines the leverage rules that this specific currency setting must abide by | `1` | | BuySide | This struct defines the buying side rules this specific currency setting must abide by such as maximum purchase amount | - | | SellSide | This struct defines the selling side rules this specific currency setting must abide by such as maximum selling amount | - | @@ -65,14 +89,8 @@ See below for a set of tables and fields, expected values and what they can do | MakerFee | The fee to use when sizing and purchasing currency | `0.001` | | TakerFee | Unused fee for when an order is placed in the orderbook, rather than taken from the orderbook | `0.002` | | MaximumHoldingsRatio | When multiple currency settings are used, you may set a maximum holdings ratio to prevent having too large a stake in a single currency | `0.5` | - -#### Strategy Settings - -| Key | Description | Example | -| --- | ------- | --- | -| Name | The strategy to use. | `rsi` | -| UsesSimultaneousProcessing | This denotes whether multiple currencies are processed simultaneously with the strategy function `OnSimultaneousSignals`. Eg If you have multiple CurrencySettings and only wish to purchase BTC-USDT when XRP-DOGE is 1337, this setting is useful as you can analyse both signal events to output a purchase call for BTC. | `true` | -| CustomSettings | This is a map where you can enter custom settings for a strategy. The RSI strategy allows for customisation of the upper, lower and length variables to allow you to change them from 70, 30 and 14 respectively to 69, 36, 12 | `"custom-settings": { "rsi-high": 70, "rsi-low": 30, "rsi-period": 14 } ` | +| CanUseExchangeLimits | Will lookup exchange rules around purchase sizing eg minimum order increments of 0.0005. Note: Will retrieve up-to-date rules which may not have existed for the data you are using. Best to use this when considering to use this strategy live | `false` | +| SkipCandleVolumeFitting | When placing orders, by default the BackTester will shrink an order's size to fit the candle data's volume so as to not rewrite history. Set this to `true` to ignore this and to set order size at what the portfolio manager prescribes | `false` | #### PortfolioSettings @@ -128,7 +146,7 @@ See below for a set of tables and fields, expected values and what they can do | APIClientIDOverride | Will set the GoCryptoTrader exchange to use the following API Client ID | `9012` | | API2FAOverride | Will set the GoCryptoTrader exchange to use the following 2FA seed | `hello-moto` | | APISubaccountOverride | Will set the GoCryptoTrader exchange to use the following subaccount on supported exchanges | `subzero` | -| RealOrders | Whether to place real orders. You really should never consider using this. Ever ever. | `true` | +| RealOrders | Whether to place real orders. You really should never consider using this. Ever ever | `true` | ##### Leverage Settings diff --git a/backtester/config/config.go b/backtester/config/config.go index ec8d5be1..f81eb26f 100644 --- a/backtester/config/config.go +++ b/backtester/config/config.go @@ -7,6 +7,9 @@ import ( "io/ioutil" "strings" + "github.com/shopspring/decimal" + "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/strategies" + "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/strategies/base" gctcommon "github.com/thrasher-corp/gocryptotrader/common" "github.com/thrasher-corp/gocryptotrader/common/file" "github.com/thrasher-corp/gocryptotrader/log" @@ -49,25 +52,50 @@ func (c *Config) PrintSetting() { log.Info(log.BackTester, "Custom strategy variables: unset") } log.Infof(log.BackTester, "Simultaneous Signal Processing: %v", c.StrategySettings.SimultaneousSignalProcessing) + log.Infof(log.BackTester, "Use Exchange Level Funding: %v", c.StrategySettings.UseExchangeLevelFunding) + if c.StrategySettings.UseExchangeLevelFunding && c.StrategySettings.SimultaneousSignalProcessing { + log.Info(log.BackTester, "-------------------------------------------------------------") + log.Info(log.BackTester, "------------------Funding Settings---------------------------") + for i := range c.StrategySettings.ExchangeLevelFunding { + log.Infof(log.BackTester, "Initial funds for %v %v %v: %v", + c.StrategySettings.ExchangeLevelFunding[i].ExchangeName, + c.StrategySettings.ExchangeLevelFunding[i].Asset, + c.StrategySettings.ExchangeLevelFunding[i].Currency, + c.StrategySettings.ExchangeLevelFunding[i].InitialFunds.Round(8)) + } + } + for i := range c.CurrencySettings { log.Info(log.BackTester, "-------------------------------------------------------------") - currStr := fmt.Sprintf("------------------%v %v-%v Settings---------------------------------------------------------", + currStr := fmt.Sprintf("------------------%v %v-%v Currency Settings---------------------------------------------------------", c.CurrencySettings[i].Asset, c.CurrencySettings[i].Base, c.CurrencySettings[i].Quote) log.Infof(log.BackTester, currStr[:61]) log.Info(log.BackTester, "-------------------------------------------------------------") log.Infof(log.BackTester, "Exchange: %v", c.CurrencySettings[i].ExchangeName) - log.Infof(log.BackTester, "Initial funds: %.4f", c.CurrencySettings[i].InitialFunds) - log.Infof(log.BackTester, "Maker fee: %.2f", c.CurrencySettings[i].TakerFee) - log.Infof(log.BackTester, "Taker fee: %.2f", c.CurrencySettings[i].MakerFee) - log.Infof(log.BackTester, "Minimum slippage percent %.2f", c.CurrencySettings[i].MinimumSlippagePercent) - log.Infof(log.BackTester, "Maximum slippage percent: %.2f", c.CurrencySettings[i].MaximumSlippagePercent) + if !c.StrategySettings.UseExchangeLevelFunding { + if c.CurrencySettings[i].InitialBaseFunds != nil { + log.Infof(log.BackTester, "Initial base funds: %v %v", + c.CurrencySettings[i].InitialBaseFunds.Round(8), + c.CurrencySettings[i].Base) + } + if c.CurrencySettings[i].InitialQuoteFunds != nil { + log.Infof(log.BackTester, "Initial quote funds: %v %v", + c.CurrencySettings[i].InitialQuoteFunds.Round(8), + c.CurrencySettings[i].Quote) + } + } + log.Infof(log.BackTester, "Maker fee: %v", c.CurrencySettings[i].TakerFee.Round(8)) + log.Infof(log.BackTester, "Taker fee: %v", c.CurrencySettings[i].MakerFee.Round(8)) + log.Infof(log.BackTester, "Minimum slippage percent %v", c.CurrencySettings[i].MinimumSlippagePercent.Round(8)) + log.Infof(log.BackTester, "Maximum slippage percent: %v", c.CurrencySettings[i].MaximumSlippagePercent.Round(8)) log.Infof(log.BackTester, "Buy rules: %+v", c.CurrencySettings[i].BuySide) log.Infof(log.BackTester, "Sell rules: %+v", c.CurrencySettings[i].SellSide) log.Infof(log.BackTester, "Leverage rules: %+v", c.CurrencySettings[i].Leverage) log.Infof(log.BackTester, "Can use exchange defined order execution limits: %+v", c.CurrencySettings[i].CanUseExchangeLimits) } + log.Info(log.BackTester, "-------------------------------------------------------------") log.Info(log.BackTester, "------------------Portfolio Settings-------------------------") log.Info(log.BackTester, "-------------------------------------------------------------") @@ -112,73 +140,182 @@ func (c *Config) PrintSetting() { log.Info(log.BackTester, "-------------------------------------------------------------\n\n") } -// Validate ensures no one sets bad config values on purpose -func (m *MinMax) Validate() { - if m.MaximumSize < 0 { - m.MaximumSize *= -1 - log.Warnf(log.BackTester, "invalid maximum size set to %f", m.MaximumSize) +// Validate checks all config settings +func (c *Config) Validate() error { + err := c.validateDate() + if err != nil { + return err } - if m.MinimumSize < 0 { - m.MinimumSize *= -1 - log.Warnf(log.BackTester, "invalid minimum size set to %f", m.MinimumSize) + err = c.validateStrategySettings() + if err != nil { + return err } - if m.MaximumSize <= m.MinimumSize && m.MinimumSize != 0 && m.MaximumSize != 0 { - m.MaximumSize = m.MinimumSize + 1 - log.Warnf(log.BackTester, "invalid maximum size set to %f", m.MaximumSize) - } - if m.MaximumTotal < 0 { - m.MaximumTotal *= -1 - log.Warnf(log.BackTester, "invalid maximum total set to %f", m.MaximumTotal) + err = c.validateCurrencySettings() + if err != nil { + return err } + return c.validateMinMaxes() } -// ValidateDate checks whether someone has set a date poorly in their config -func (c *Config) ValidateDate() error { +// validate ensures no one sets bad config values on purpose +func (m *MinMax) validate() error { + if m.MaximumSize.IsNegative() { + return fmt.Errorf("invalid maximum size %w", errSizeLessThanZero) + } + if m.MinimumSize.IsNegative() { + return fmt.Errorf("invalid minimum size %w", errSizeLessThanZero) + } + if m.MaximumTotal.IsNegative() { + return fmt.Errorf("invalid maximum total set to %w", errSizeLessThanZero) + } + if m.MaximumSize.LessThan(m.MinimumSize) && !m.MinimumSize.IsZero() && !m.MaximumSize.IsZero() { + return fmt.Errorf("%w maximum size %v vs minimum size %v", + errMaxSizeMinSizeMismatch, + m.MaximumSize, + m.MinimumSize) + } + if m.MaximumSize.Equal(m.MinimumSize) && !m.MinimumSize.IsZero() && !m.MaximumSize.IsZero() { + return fmt.Errorf("%w %v", + errMinMaxEqual, + m.MinimumSize) + } + + return nil +} + +func (c *Config) validateMinMaxes() (err error) { + for i := range c.CurrencySettings { + err = c.CurrencySettings[i].BuySide.validate() + if err != nil { + return err + } + err = c.CurrencySettings[i].SellSide.validate() + if err != nil { + return err + } + } + err = c.PortfolioSettings.BuySide.validate() + if err != nil { + return err + } + err = c.PortfolioSettings.SellSide.validate() + if err != nil { + return err + } + return nil +} + +func (c *Config) validateStrategySettings() error { + if c.StrategySettings.UseExchangeLevelFunding && !c.StrategySettings.SimultaneousSignalProcessing { + return errSimultaneousProcessingRequired + } + if len(c.StrategySettings.ExchangeLevelFunding) > 0 && !c.StrategySettings.UseExchangeLevelFunding { + return errExchangeLevelFundingRequired + } + if c.StrategySettings.UseExchangeLevelFunding && len(c.StrategySettings.ExchangeLevelFunding) == 0 { + return errExchangeLevelFundingDataRequired + } + if c.StrategySettings.UseExchangeLevelFunding { + for i := range c.StrategySettings.ExchangeLevelFunding { + if c.StrategySettings.ExchangeLevelFunding[i].InitialFunds.IsNegative() { + return fmt.Errorf("%w for %v %v %v", + errBadInitialFunds, + c.StrategySettings.ExchangeLevelFunding[i].ExchangeName, + c.StrategySettings.ExchangeLevelFunding[i].Asset, + c.StrategySettings.ExchangeLevelFunding[i].Currency, + ) + } + } + } + strats := strategies.GetStrategies() + for i := range strats { + if strings.EqualFold(strats[i].Name(), c.StrategySettings.Name) { + return nil + } + } + + return fmt.Errorf("strategty %v %w", c.StrategySettings.Name, base.ErrStrategyNotFound) +} + +// validateDate checks whether someone has set a date poorly in their config +func (c *Config) validateDate() error { if c.DataSettings.DatabaseData != nil { if c.DataSettings.DatabaseData.StartDate.IsZero() || c.DataSettings.DatabaseData.EndDate.IsZero() { - return ErrStartEndUnset + return errStartEndUnset } if c.DataSettings.DatabaseData.StartDate.After(c.DataSettings.DatabaseData.EndDate) || c.DataSettings.DatabaseData.StartDate.Equal(c.DataSettings.DatabaseData.EndDate) { - return ErrBadDate + return errBadDate } } if c.DataSettings.APIData != nil { if c.DataSettings.APIData.StartDate.IsZero() || c.DataSettings.APIData.EndDate.IsZero() { - return ErrStartEndUnset + return errStartEndUnset } if c.DataSettings.APIData.StartDate.After(c.DataSettings.APIData.EndDate) || c.DataSettings.APIData.StartDate.Equal(c.DataSettings.APIData.EndDate) { - return ErrBadDate + return errBadDate } } return nil } -// ValidateCurrencySettings checks whether someone has set invalid currency setting data in their config -func (c *Config) ValidateCurrencySettings() error { +// validateCurrencySettings checks whether someone has set invalid currency setting data in their config +func (c *Config) validateCurrencySettings() error { if len(c.CurrencySettings) == 0 { - return ErrNoCurrencySettings + return errNoCurrencySettings } for i := range c.CurrencySettings { - if c.CurrencySettings[i].InitialFunds <= 0 { - return ErrBadInitialFunds + if c.CurrencySettings[i].InitialLegacyFunds > 0 { + // temporarily migrate legacy start config value + log.Warn(log.BackTester, "config field 'initial-funds' no longer supported, please use 'initial-quote-funds'") + log.Warnf(log.BackTester, "temporarily setting 'initial-quote-funds' to 'initial-funds' value of %v", c.CurrencySettings[i].InitialLegacyFunds) + iqf := decimal.NewFromFloat(c.CurrencySettings[i].InitialLegacyFunds) + c.CurrencySettings[i].InitialQuoteFunds = &iqf } + if c.StrategySettings.UseExchangeLevelFunding { + if c.CurrencySettings[i].InitialQuoteFunds != nil && + c.CurrencySettings[i].InitialQuoteFunds.GreaterThan(decimal.Zero) { + return fmt.Errorf("non-nil quote %w", errBadInitialFunds) + } + if c.CurrencySettings[i].InitialBaseFunds != nil && + c.CurrencySettings[i].InitialBaseFunds.GreaterThan(decimal.Zero) { + return fmt.Errorf("non-nil base %w", errBadInitialFunds) + } + } else { + if c.CurrencySettings[i].InitialQuoteFunds == nil && + c.CurrencySettings[i].InitialBaseFunds == nil { + return fmt.Errorf("nil base and quote %w", errBadInitialFunds) + } + if c.CurrencySettings[i].InitialQuoteFunds != nil && + c.CurrencySettings[i].InitialBaseFunds != nil && + c.CurrencySettings[i].InitialBaseFunds.IsZero() && + c.CurrencySettings[i].InitialQuoteFunds.IsZero() { + return fmt.Errorf("base or quote funds set to zero %w", errBadInitialFunds) + } + if c.CurrencySettings[i].InitialQuoteFunds == nil { + c.CurrencySettings[i].InitialQuoteFunds = &decimal.Zero + } + if c.CurrencySettings[i].InitialBaseFunds == nil { + c.CurrencySettings[i].InitialBaseFunds = &decimal.Zero + } + } + if c.CurrencySettings[i].Base == "" { - return ErrUnsetCurrency + return errUnsetCurrency } if c.CurrencySettings[i].Asset == "" { - return ErrUnsetAsset + return errUnsetAsset } if c.CurrencySettings[i].ExchangeName == "" { - return ErrUnsetExchange + return errUnsetExchange } - if c.CurrencySettings[i].MinimumSlippagePercent < 0 || - c.CurrencySettings[i].MaximumSlippagePercent < 0 || - c.CurrencySettings[i].MinimumSlippagePercent > c.CurrencySettings[i].MaximumSlippagePercent { - return ErrBadSlippageRates + if c.CurrencySettings[i].MinimumSlippagePercent.LessThan(decimal.Zero) || + c.CurrencySettings[i].MaximumSlippagePercent.LessThan(decimal.Zero) || + c.CurrencySettings[i].MinimumSlippagePercent.GreaterThan(c.CurrencySettings[i].MaximumSlippagePercent) { + return errBadSlippageRates } c.CurrencySettings[i].ExchangeName = strings.ToLower(c.CurrencySettings[i].ExchangeName) } diff --git a/backtester/config/config_test.go b/backtester/config/config_test.go index 885092fb..26bb8162 100644 --- a/backtester/config/config_test.go +++ b/backtester/config/config_test.go @@ -9,7 +9,10 @@ import ( "testing" "time" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/common" + "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/strategies/base" + "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/strategies/top2bottom2" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/database" "github.com/thrasher-corp/gocryptotrader/database/drivers" @@ -18,8 +21,6 @@ import ( ) const ( - makerFee = 0.001 - takerFee = 0.002 testExchange = "binance" dca = "dollarcostaverage" // change this if you modify a config and want it to save to the example folder @@ -27,13 +28,28 @@ const ( ) var ( - startDate time.Time - endDate time.Time + startDate = time.Date(time.Now().Year()-1, 8, 1, 0, 0, 0, 0, time.Local) + endDate = time.Date(time.Now().Year()-1, 12, 1, 0, 0, 0, 0, time.Local) + tradeEndDate = startDate.Add(time.Hour * 72) + makerFee = decimal.NewFromFloat(0.001) + takerFee = decimal.NewFromFloat(0.002) + minMax = MinMax{ + MinimumSize: decimal.NewFromFloat(0.005), + MaximumSize: decimal.NewFromInt(2), + MaximumTotal: decimal.NewFromInt(40000), + } + initialQuoteFunds1 *decimal.Decimal + initialQuoteFunds2 *decimal.Decimal + initialBaseFunds *decimal.Decimal ) func TestMain(m *testing.M) { - startDate = time.Date(time.Now().Year()-1, 11, 1, 0, 0, 0, 0, time.Local) - endDate = time.Date(time.Now().Year()-1, 12, 1, 0, 0, 0, 0, time.Local) + iF1 := decimal.NewFromInt(1000000) + iF2 := decimal.NewFromInt(100000) + iBF := decimal.NewFromInt(10) + initialQuoteFunds1 = &iF1 + initialQuoteFunds2 = &iF2 + initialBaseFunds = &iBF os.Exit(m.Run()) } @@ -88,21 +104,13 @@ func TestPrintSettings(t *testing.T) { }, CurrencySettings: []CurrencySettings{ { - ExchangeName: testExchange, - Asset: asset.Spot.String(), - Base: currency.BTC.String(), - Quote: currency.USDT.String(), - InitialFunds: 100000, - BuySide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, - SellSide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, + ExchangeName: testExchange, + Asset: asset.Spot.String(), + Base: currency.BTC.String(), + Quote: currency.USDT.String(), + InitialQuoteFunds: initialQuoteFunds1, + BuySide: minMax, + SellSide: minMax, Leverage: Leverage{ CanUseLeverage: false, }, @@ -126,7 +134,7 @@ func TestPrintSettings(t *testing.T) { APISecretOverride: "", APIClientIDOverride: "", API2FAOverride: "", - APISubaccountOverride: "", + APISubAccountOverride: "", RealOrders: false, }, DatabaseData: &DatabaseData{ @@ -137,22 +145,14 @@ func TestPrintSettings(t *testing.T) { }, }, PortfolioSettings: PortfolioSettings{ - BuySide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, - SellSide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, + BuySide: minMax, + SellSide: minMax, Leverage: Leverage{ CanUseLeverage: false, }, }, StatisticSettings: StatisticSettings{ - RiskFreeRate: 0.03, + RiskFreeRate: decimal.NewFromFloat(0.03), }, } cfg.PrintSetting() @@ -160,28 +160,20 @@ func TestPrintSettings(t *testing.T) { func TestGenerateConfigForDCAAPICandles(t *testing.T) { cfg := Config{ - Nickname: "TestGenerateConfigForDCAAPICandles", + Nickname: "ExampleStrategyDCAAPICandles", Goal: "To demonstrate DCA strategy using API candles", StrategySettings: StrategySettings{ Name: dca, }, CurrencySettings: []CurrencySettings{ { - ExchangeName: testExchange, - Asset: asset.Spot.String(), - Base: currency.BTC.String(), - Quote: currency.USDT.String(), - InitialFunds: 100000, - BuySide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, - SellSide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, + ExchangeName: testExchange, + Asset: asset.Spot.String(), + Base: currency.BTC.String(), + Quote: currency.USDT.String(), + InitialQuoteFunds: initialQuoteFunds2, + BuySide: minMax, + SellSide: minMax, Leverage: Leverage{ CanUseLeverage: false, }, @@ -199,32 +191,24 @@ func TestGenerateConfigForDCAAPICandles(t *testing.T) { }, }, PortfolioSettings: PortfolioSettings{ - BuySide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, - SellSide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, + BuySide: minMax, + SellSide: minMax, Leverage: Leverage{ CanUseLeverage: false, }, }, StatisticSettings: StatisticSettings{ - RiskFreeRate: 0.03, + RiskFreeRate: decimal.NewFromFloat(0.03), }, } if saveConfig { result, err := json.MarshalIndent(cfg, "", " ") if err != nil { - t.Error(err) + t.Fatal(err) } p, err := os.Getwd() if err != nil { - t.Error(err) + t.Fatal(err) } err = ioutil.WriteFile(filepath.Join(p, "examples", "dca-api-candles.strat"), result, 0770) if err != nil { @@ -233,12 +217,22 @@ func TestGenerateConfigForDCAAPICandles(t *testing.T) { } } -func TestGenerateConfigForDCAAPITrades(t *testing.T) { +func TestGenerateConfigForDCAAPICandlesExchangeLevelFunding(t *testing.T) { cfg := Config{ - Nickname: "TestGenerateConfigForDCAAPITrades", - Goal: "To demonstrate running the DCA strategy using API trade data", + Nickname: "ExampleStrategyDCAAPICandlesExchangeLevelFunding", + Goal: "To demonstrate DCA strategy using API candles using a shared pool of funds", StrategySettings: StrategySettings{ - Name: dca, + Name: dca, + SimultaneousSignalProcessing: true, + UseExchangeLevelFunding: true, + ExchangeLevelFunding: []ExchangeLevelFunding{ + { + ExchangeName: testExchange, + Asset: asset.Spot.String(), + Currency: currency.USDT.String(), + InitialFunds: decimal.NewFromInt(100000), + }, + }, }, CurrencySettings: []CurrencySettings{ { @@ -246,60 +240,120 @@ func TestGenerateConfigForDCAAPITrades(t *testing.T) { Asset: asset.Spot.String(), Base: currency.BTC.String(), Quote: currency.USDT.String(), - InitialFunds: 100000, - BuySide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, - SellSide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, - Leverage: Leverage{ - CanUseLeverage: false, - }, - MakerFee: makerFee, - TakerFee: takerFee, + BuySide: minMax, + SellSide: minMax, + Leverage: Leverage{}, + MakerFee: makerFee, + TakerFee: takerFee, + }, + { + ExchangeName: testExchange, + Asset: asset.Spot.String(), + Base: currency.ETH.String(), + Quote: currency.USDT.String(), + BuySide: minMax, + SellSide: minMax, + Leverage: Leverage{}, + MakerFee: makerFee, + TakerFee: takerFee, }, }, DataSettings: DataSettings{ Interval: kline.OneDay.Duration(), - DataType: common.TradeStr, + DataType: common.CandleStr, APIData: &APIData{ StartDate: startDate, EndDate: endDate, InclusiveEndDate: false, }, }, + PortfolioSettings: PortfolioSettings{ + BuySide: minMax, + SellSide: minMax, + Leverage: Leverage{ + CanUseLeverage: false, + }, + }, + StatisticSettings: StatisticSettings{ + RiskFreeRate: decimal.NewFromFloat(0.03), + }, + } + if saveConfig { + result, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + t.Fatal(err) + } + p, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + err = ioutil.WriteFile(filepath.Join(p, "examples", "dca-api-candles-exchange-level-funding.strat"), result, 0770) + if err != nil { + t.Error(err) + } + } +} + +func TestGenerateConfigForDCAAPITrades(t *testing.T) { + cfg := Config{ + Nickname: "ExampleStrategyDCAAPITrades", + Goal: "To demonstrate running the DCA strategy using API trade data", + StrategySettings: StrategySettings{ + Name: dca, + }, + CurrencySettings: []CurrencySettings{ + { + ExchangeName: "ftx", + Asset: asset.Spot.String(), + Base: currency.BTC.String(), + Quote: currency.USDT.String(), + InitialQuoteFunds: initialQuoteFunds2, + BuySide: minMax, + SellSide: minMax, + Leverage: Leverage{ + CanUseLeverage: false, + }, + MakerFee: makerFee, + TakerFee: takerFee, + SkipCandleVolumeFitting: true, + }, + }, + DataSettings: DataSettings{ + Interval: kline.OneHour.Duration(), + DataType: common.TradeStr, + APIData: &APIData{ + StartDate: startDate, + EndDate: tradeEndDate, + InclusiveEndDate: false, + }, + }, PortfolioSettings: PortfolioSettings{ BuySide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, + MinimumSize: decimal.NewFromFloat(0.1), + MaximumSize: decimal.NewFromInt(1), + MaximumTotal: decimal.NewFromInt(10000), }, SellSide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, + MinimumSize: decimal.NewFromFloat(0.1), + MaximumSize: decimal.NewFromInt(1), + MaximumTotal: decimal.NewFromInt(10000), }, Leverage: Leverage{ CanUseLeverage: false, }, }, StatisticSettings: StatisticSettings{ - RiskFreeRate: 0.03, + RiskFreeRate: decimal.NewFromFloat(0.03), }, } if saveConfig { result, err := json.MarshalIndent(cfg, "", " ") if err != nil { - t.Error(err) + t.Fatal(err) } p, err := os.Getwd() if err != nil { - t.Error(err) + t.Fatal(err) } err = ioutil.WriteFile(filepath.Join(p, "examples", "dca-api-trades.strat"), result, 0770) if err != nil { @@ -310,28 +364,20 @@ func TestGenerateConfigForDCAAPITrades(t *testing.T) { func TestGenerateConfigForDCAAPICandlesMultipleCurrencies(t *testing.T) { cfg := Config{ - Nickname: "TestGenerateConfigForDCAAPICandlesMultipleCurrencies", + Nickname: "ExampleStrategyDCAAPICandlesMultipleCurrencies", Goal: "To demonstrate running the DCA strategy using the API against multiple currencies candle data", StrategySettings: StrategySettings{ Name: dca, }, CurrencySettings: []CurrencySettings{ { - ExchangeName: testExchange, - Asset: asset.Spot.String(), - Base: currency.BTC.String(), - Quote: currency.USDT.String(), - InitialFunds: 100000, - BuySide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, - SellSide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, + ExchangeName: testExchange, + Asset: asset.Spot.String(), + Base: currency.BTC.String(), + Quote: currency.USDT.String(), + InitialQuoteFunds: initialQuoteFunds2, + BuySide: minMax, + SellSide: minMax, Leverage: Leverage{ CanUseLeverage: false, }, @@ -339,21 +385,13 @@ func TestGenerateConfigForDCAAPICandlesMultipleCurrencies(t *testing.T) { TakerFee: takerFee, }, { - ExchangeName: testExchange, - Asset: asset.Spot.String(), - Base: currency.ETH.String(), - Quote: currency.USDT.String(), - InitialFunds: 100000, - BuySide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, - SellSide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, + ExchangeName: testExchange, + Asset: asset.Spot.String(), + Base: currency.ETH.String(), + Quote: currency.USDT.String(), + InitialQuoteFunds: initialQuoteFunds2, + BuySide: minMax, + SellSide: minMax, Leverage: Leverage{ CanUseLeverage: false, }, @@ -371,32 +409,24 @@ func TestGenerateConfigForDCAAPICandlesMultipleCurrencies(t *testing.T) { }, }, PortfolioSettings: PortfolioSettings{ - BuySide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, - SellSide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, + BuySide: minMax, + SellSide: minMax, Leverage: Leverage{ CanUseLeverage: false, }, }, StatisticSettings: StatisticSettings{ - RiskFreeRate: 0.03, + RiskFreeRate: decimal.NewFromFloat(0.03), }, } if saveConfig { result, err := json.MarshalIndent(cfg, "", " ") if err != nil { - t.Error(err) + t.Fatal(err) } p, err := os.Getwd() if err != nil { - t.Error(err) + t.Fatal(err) } err = ioutil.WriteFile(filepath.Join(p, "examples", "dca-api-candles-multiple-currencies.strat"), result, 0770) if err != nil { @@ -407,7 +437,7 @@ func TestGenerateConfigForDCAAPICandlesMultipleCurrencies(t *testing.T) { func TestGenerateConfigForDCAAPICandlesSimultaneousProcessing(t *testing.T) { cfg := Config{ - Nickname: "TestGenerateConfigForDCAAPICandlesSimultaneousProcessing", + Nickname: "ExampleStrategyDCAAPICandlesSimultaneousProcessing", Goal: "To demonstrate how simultaneous processing can work", StrategySettings: StrategySettings{ Name: dca, @@ -415,21 +445,13 @@ func TestGenerateConfigForDCAAPICandlesSimultaneousProcessing(t *testing.T) { }, CurrencySettings: []CurrencySettings{ { - ExchangeName: testExchange, - Asset: asset.Spot.String(), - Base: currency.BTC.String(), - Quote: currency.USDT.String(), - InitialFunds: 1000000, - BuySide: MinMax{ - MinimumSize: 0, - MaximumSize: 0, - MaximumTotal: 1000, - }, - SellSide: MinMax{ - MinimumSize: 0, - MaximumSize: 0, - MaximumTotal: 1000, - }, + ExchangeName: testExchange, + Asset: asset.Spot.String(), + Base: currency.BTC.String(), + Quote: currency.USDT.String(), + InitialQuoteFunds: initialQuoteFunds1, + BuySide: minMax, + SellSide: minMax, Leverage: Leverage{ CanUseLeverage: false, }, @@ -437,21 +459,13 @@ func TestGenerateConfigForDCAAPICandlesSimultaneousProcessing(t *testing.T) { TakerFee: takerFee, }, { - ExchangeName: testExchange, - Asset: asset.Spot.String(), - Base: currency.ETH.String(), - Quote: currency.USDT.String(), - InitialFunds: 100000, - BuySide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, - SellSide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, + ExchangeName: testExchange, + Asset: asset.Spot.String(), + Base: currency.ETH.String(), + Quote: currency.USDT.String(), + InitialQuoteFunds: initialQuoteFunds2, + BuySide: minMax, + SellSide: minMax, Leverage: Leverage{ CanUseLeverage: false, }, @@ -469,32 +483,24 @@ func TestGenerateConfigForDCAAPICandlesSimultaneousProcessing(t *testing.T) { }, }, PortfolioSettings: PortfolioSettings{ - BuySide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, - SellSide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, + BuySide: minMax, + SellSide: minMax, Leverage: Leverage{ CanUseLeverage: false, }, }, StatisticSettings: StatisticSettings{ - RiskFreeRate: 0.03, + RiskFreeRate: decimal.NewFromFloat(0.03), }, } if saveConfig { result, err := json.MarshalIndent(cfg, "", " ") if err != nil { - t.Error(err) + t.Fatal(err) } p, err := os.Getwd() if err != nil { - t.Error(err) + t.Fatal(err) } err = ioutil.WriteFile(filepath.Join(p, "examples", "dca-api-candles-simultaneous-processing.strat"), result, 0770) if err != nil { @@ -505,28 +511,20 @@ func TestGenerateConfigForDCAAPICandlesSimultaneousProcessing(t *testing.T) { func TestGenerateConfigForDCALiveCandles(t *testing.T) { cfg := Config{ - Nickname: "TestGenerateConfigForDCALiveCandles", + Nickname: "ExampleStrategyDCALiveCandles", Goal: "To demonstrate live trading proof of concept against candle data", StrategySettings: StrategySettings{ Name: dca, }, CurrencySettings: []CurrencySettings{ { - ExchangeName: testExchange, - Asset: asset.Spot.String(), - Base: currency.BTC.String(), - Quote: currency.USDT.String(), - InitialFunds: 100000, - BuySide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, - SellSide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, + ExchangeName: testExchange, + Asset: asset.Spot.String(), + Base: currency.BTC.String(), + Quote: currency.USDT.String(), + InitialQuoteFunds: initialQuoteFunds2, + BuySide: minMax, + SellSide: minMax, Leverage: Leverage{ CanUseLeverage: false, }, @@ -535,44 +533,36 @@ func TestGenerateConfigForDCALiveCandles(t *testing.T) { }, }, DataSettings: DataSettings{ - Interval: kline.OneHour.Duration(), + Interval: kline.OneMin.Duration(), DataType: common.CandleStr, LiveData: &LiveData{ APIKeyOverride: "", APISecretOverride: "", APIClientIDOverride: "", API2FAOverride: "", - APISubaccountOverride: "", + APISubAccountOverride: "", RealOrders: false, }, }, PortfolioSettings: PortfolioSettings{ - BuySide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, - SellSide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, + BuySide: minMax, + SellSide: minMax, Leverage: Leverage{ CanUseLeverage: false, }, }, StatisticSettings: StatisticSettings{ - RiskFreeRate: 0.03, + RiskFreeRate: decimal.NewFromFloat(0.03), }, } if saveConfig { result, err := json.MarshalIndent(cfg, "", " ") if err != nil { - t.Error(err) + t.Fatal(err) } p, err := os.Getwd() if err != nil { - t.Error(err) + t.Fatal(err) } err = ioutil.WriteFile(filepath.Join(p, "examples", "dca-candles-live.strat"), result, 0770) if err != nil { @@ -595,21 +585,13 @@ func TestGenerateConfigForRSIAPICustomSettings(t *testing.T) { }, CurrencySettings: []CurrencySettings{ { - ExchangeName: testExchange, - Asset: asset.Spot.String(), - Base: currency.BTC.String(), - Quote: currency.USDT.String(), - InitialFunds: 1000000, - BuySide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, - SellSide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, + ExchangeName: testExchange, + Asset: asset.Spot.String(), + Base: currency.BTC.String(), + Quote: currency.USDT.String(), + InitialQuoteFunds: initialQuoteFunds2, + BuySide: minMax, + SellSide: minMax, Leverage: Leverage{ CanUseLeverage: false, }, @@ -617,21 +599,14 @@ func TestGenerateConfigForRSIAPICustomSettings(t *testing.T) { TakerFee: takerFee, }, { - ExchangeName: testExchange, - Asset: asset.Spot.String(), - Base: currency.ETH.String(), - Quote: currency.USDT.String(), - InitialFunds: 100000, - BuySide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, - SellSide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, + ExchangeName: testExchange, + Asset: asset.Spot.String(), + Base: currency.ETH.String(), + Quote: currency.USDT.String(), + InitialBaseFunds: initialBaseFunds, + InitialQuoteFunds: initialQuoteFunds1, + BuySide: minMax, + SellSide: minMax, Leverage: Leverage{ CanUseLeverage: false, }, @@ -649,32 +624,24 @@ func TestGenerateConfigForRSIAPICustomSettings(t *testing.T) { }, }, PortfolioSettings: PortfolioSettings{ - BuySide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, - SellSide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, + BuySide: minMax, + SellSide: minMax, Leverage: Leverage{ CanUseLeverage: false, }, }, StatisticSettings: StatisticSettings{ - RiskFreeRate: 0.03, + RiskFreeRate: decimal.NewFromFloat(0.03), }, } if saveConfig { result, err := json.MarshalIndent(cfg, "", " ") if err != nil { - t.Error(err) + t.Fatal(err) } p, err := os.Getwd() if err != nil { - t.Error(err) + t.Fatal(err) } err = ioutil.WriteFile(filepath.Join(p, "examples", "rsi-api-candles.strat"), result, 0770) if err != nil { @@ -686,28 +653,20 @@ func TestGenerateConfigForRSIAPICustomSettings(t *testing.T) { func TestGenerateConfigForDCACSVCandles(t *testing.T) { fp := filepath.Join("..", "testdata", "binance_BTCUSDT_24h_2019_01_01_2020_01_01.csv") cfg := Config{ - Nickname: "TestGenerateConfigForDCACSVCandles", + Nickname: "ExampleStrategyDCACSVCandles", Goal: "To demonstrate the DCA strategy using CSV candle data", StrategySettings: StrategySettings{ Name: dca, }, CurrencySettings: []CurrencySettings{ { - ExchangeName: testExchange, - Asset: asset.Spot.String(), - Base: currency.BTC.String(), - Quote: currency.USDT.String(), - InitialFunds: 100000, - BuySide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, - SellSide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, + ExchangeName: testExchange, + Asset: asset.Spot.String(), + Base: currency.BTC.String(), + Quote: currency.USDT.String(), + InitialQuoteFunds: initialQuoteFunds2, + BuySide: minMax, + SellSide: minMax, Leverage: Leverage{ CanUseLeverage: false, }, @@ -723,32 +682,24 @@ func TestGenerateConfigForDCACSVCandles(t *testing.T) { }, }, PortfolioSettings: PortfolioSettings{ - BuySide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, - SellSide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, + BuySide: minMax, + SellSide: minMax, Leverage: Leverage{ CanUseLeverage: false, }, }, StatisticSettings: StatisticSettings{ - RiskFreeRate: 0.03, + RiskFreeRate: decimal.NewFromFloat(0.03), }, } if saveConfig { result, err := json.MarshalIndent(cfg, "", " ") if err != nil { - t.Error(err) + t.Fatal(err) } p, err := os.Getwd() if err != nil { - t.Error(err) + t.Fatal(err) } err = ioutil.WriteFile(filepath.Join(p, "examples", "dca-csv-candles.strat"), result, 0770) if err != nil { @@ -760,28 +711,18 @@ func TestGenerateConfigForDCACSVCandles(t *testing.T) { func TestGenerateConfigForDCACSVTrades(t *testing.T) { fp := filepath.Join("..", "testdata", "binance_BTCUSDT_24h-trades_2020_11_16.csv") cfg := Config{ - Nickname: "TestGenerateConfigForDCACSVTrades", + Nickname: "ExampleStrategyDCACSVTrades", Goal: "To demonstrate the DCA strategy using CSV trade data", StrategySettings: StrategySettings{ Name: dca, }, CurrencySettings: []CurrencySettings{ { - ExchangeName: testExchange, - Asset: asset.Spot.String(), - Base: currency.BTC.String(), - Quote: currency.USDT.String(), - InitialFunds: 100000, - BuySide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, - SellSide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, + ExchangeName: testExchange, + Asset: asset.Spot.String(), + Base: currency.BTC.String(), + Quote: currency.USDT.String(), + InitialQuoteFunds: initialQuoteFunds2, Leverage: Leverage{ CanUseLeverage: false, }, @@ -797,32 +738,22 @@ func TestGenerateConfigForDCACSVTrades(t *testing.T) { }, }, PortfolioSettings: PortfolioSettings{ - BuySide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, - SellSide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, Leverage: Leverage{ CanUseLeverage: false, }, }, StatisticSettings: StatisticSettings{ - RiskFreeRate: 0.03, + RiskFreeRate: decimal.NewFromFloat(0.03), }, } if saveConfig { result, err := json.MarshalIndent(cfg, "", " ") if err != nil { - t.Error(err) + t.Fatal(err) } p, err := os.Getwd() if err != nil { - t.Error(err) + t.Fatal(err) } err = ioutil.WriteFile(filepath.Join(p, "examples", "dca-csv-trades.strat"), result, 0770) if err != nil { @@ -833,28 +764,20 @@ func TestGenerateConfigForDCACSVTrades(t *testing.T) { func TestGenerateConfigForDCADatabaseCandles(t *testing.T) { cfg := Config{ - Nickname: "TestGenerateConfigForDCADatabaseCandles", + Nickname: "ExampleStrategyDCADatabaseCandles", Goal: "To demonstrate the DCA strategy using database candle data", StrategySettings: StrategySettings{ Name: dca, }, CurrencySettings: []CurrencySettings{ { - ExchangeName: testExchange, - Asset: asset.Spot.String(), - Base: currency.BTC.String(), - Quote: currency.USDT.String(), - InitialFunds: 100000, - BuySide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, - SellSide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, + ExchangeName: testExchange, + Asset: asset.Spot.String(), + Base: currency.BTC.String(), + Quote: currency.USDT.String(), + InitialQuoteFunds: initialQuoteFunds2, + BuySide: minMax, + SellSide: minMax, Leverage: Leverage{ CanUseLeverage: false, }, @@ -881,32 +804,24 @@ func TestGenerateConfigForDCADatabaseCandles(t *testing.T) { }, }, PortfolioSettings: PortfolioSettings{ - BuySide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, - SellSide: MinMax{ - MinimumSize: 0.1, - MaximumSize: 1, - MaximumTotal: 10000, - }, + BuySide: minMax, + SellSide: minMax, Leverage: Leverage{ CanUseLeverage: false, }, }, StatisticSettings: StatisticSettings{ - RiskFreeRate: 0.03, + RiskFreeRate: decimal.NewFromFloat(0.03), }, } if saveConfig { result, err := json.MarshalIndent(cfg, "", " ") if err != nil { - t.Error(err) + t.Fatal(err) } p, err := os.Getwd() if err != nil { - t.Error(err) + t.Fatal(err) } err = ioutil.WriteFile(filepath.Join(p, "examples", "dca-database-candles.strat"), result, 0770) if err != nil { @@ -915,64 +830,172 @@ func TestGenerateConfigForDCADatabaseCandles(t *testing.T) { } } -func TestValidate(t *testing.T) { - m := MinMax{ - MinimumSize: -1, - MaximumSize: -1, - MaximumTotal: -1, +func TestGenerateConfigForTop2Bottom2(t *testing.T) { + cfg := Config{ + Nickname: "ExampleStrategyTop2Bottom2", + Goal: "To demonstrate a complex strategy using exchange level funding and simultaneous processing of data signals", + StrategySettings: StrategySettings{ + Name: top2bottom2.Name, + UseExchangeLevelFunding: true, + SimultaneousSignalProcessing: true, + ExchangeLevelFunding: []ExchangeLevelFunding{ + { + ExchangeName: testExchange, + Asset: asset.Spot.String(), + Currency: currency.BTC.String(), + InitialFunds: decimal.NewFromFloat(3), + }, + { + ExchangeName: testExchange, + Asset: asset.Spot.String(), + Currency: currency.USDT.String(), + InitialFunds: decimal.NewFromInt(10000), + }, + }, + CustomSettings: map[string]interface{}{ + "mfi-low": 32, + "mfi-high": 68, + "mfi-period": 14, + }, + }, + CurrencySettings: []CurrencySettings{ + { + ExchangeName: testExchange, + Asset: asset.Spot.String(), + Base: currency.BTC.String(), + Quote: currency.USDT.String(), + BuySide: minMax, + SellSide: minMax, + Leverage: Leverage{}, + MakerFee: makerFee, + TakerFee: takerFee, + }, + { + ExchangeName: testExchange, + Asset: asset.Spot.String(), + Base: currency.DOGE.String(), + Quote: currency.USDT.String(), + BuySide: minMax, + SellSide: minMax, + Leverage: Leverage{}, + MakerFee: makerFee, + TakerFee: takerFee, + }, + { + ExchangeName: testExchange, + Asset: asset.Spot.String(), + Base: currency.ETH.String(), + Quote: currency.BTC.String(), + BuySide: minMax, + SellSide: minMax, + Leverage: Leverage{}, + MakerFee: makerFee, + TakerFee: takerFee, + }, + { + ExchangeName: testExchange, + Asset: asset.Spot.String(), + Base: currency.LTC.String(), + Quote: currency.BTC.String(), + BuySide: minMax, + SellSide: minMax, + Leverage: Leverage{}, + MakerFee: makerFee, + TakerFee: takerFee, + }, + { + ExchangeName: testExchange, + Asset: asset.Spot.String(), + Base: currency.XRP.String(), + Quote: currency.USDT.String(), + BuySide: minMax, + SellSide: minMax, + Leverage: Leverage{}, + MakerFee: makerFee, + TakerFee: takerFee, + }, + { + ExchangeName: testExchange, + Asset: asset.Spot.String(), + Base: currency.BNB.String(), + Quote: currency.BTC.String(), + BuySide: minMax, + SellSide: minMax, + Leverage: Leverage{}, + MakerFee: makerFee, + TakerFee: takerFee, + }, + }, + DataSettings: DataSettings{ + Interval: kline.OneDay.Duration(), + DataType: common.CandleStr, + APIData: &APIData{ + StartDate: startDate, + EndDate: endDate, + }, + }, + PortfolioSettings: PortfolioSettings{ + BuySide: minMax, + SellSide: minMax, + Leverage: Leverage{}, + }, + StatisticSettings: StatisticSettings{ + RiskFreeRate: decimal.NewFromFloat(0.03), + }, } - m.Validate() - if m.MinimumSize > m.MaximumSize { - t.Errorf("expected %v > %v", m.MaximumSize, m.MinimumSize) - } - if m.MinimumSize < 0 { - t.Errorf("expected %v > %v", m.MinimumSize, 0) - } - if m.MaximumSize < 0 { - t.Errorf("expected %v > %v", m.MaximumSize, 0) - } - if m.MaximumTotal < 0 { - t.Errorf("expected %v > %v", m.MaximumTotal, 0) + if saveConfig { + result, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + t.Fatal(err) + } + p, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + err = ioutil.WriteFile(filepath.Join(p, "examples", "t2b2-api-candles-exchange-funding.strat"), result, 0770) + if err != nil { + t.Error(err) + } } } func TestValidateDate(t *testing.T) { c := Config{} - err := c.ValidateDate() + err := c.validateDate() if err != nil { t.Error(err) } c.DataSettings = DataSettings{ DatabaseData: &DatabaseData{}, } - err = c.ValidateDate() - if !errors.Is(ErrStartEndUnset, err) { - t.Errorf("expected %v, received %v", ErrStartEndUnset, err) + err = c.validateDate() + if !errors.Is(err, errStartEndUnset) { + t.Errorf("received: %v, expected: %v", err, errStartEndUnset) } c.DataSettings.DatabaseData.StartDate = time.Now() c.DataSettings.DatabaseData.EndDate = c.DataSettings.DatabaseData.StartDate - err = c.ValidateDate() - if !errors.Is(ErrBadDate, err) { - t.Errorf("expected %v, received %v", ErrBadDate, err) + err = c.validateDate() + if !errors.Is(err, errBadDate) { + t.Errorf("received: %v, expected: %v", err, errBadDate) } c.DataSettings.DatabaseData.EndDate = c.DataSettings.DatabaseData.StartDate.Add(time.Minute) - err = c.ValidateDate() + err = c.validateDate() if err != nil { t.Error(err) } c.DataSettings.APIData = &APIData{} - err = c.ValidateDate() - if !errors.Is(ErrStartEndUnset, err) { - t.Errorf("expected %v, received %v", ErrStartEndUnset, err) + err = c.validateDate() + if !errors.Is(err, errStartEndUnset) { + t.Errorf("received: %v, expected: %v", err, errStartEndUnset) } c.DataSettings.APIData.StartDate = time.Now() c.DataSettings.APIData.EndDate = c.DataSettings.APIData.StartDate - err = c.ValidateDate() - if !errors.Is(ErrBadDate, err) { - t.Errorf("expected %v, received %v", ErrBadDate, err) + err = c.validateDate() + if !errors.Is(err, errBadDate) { + t.Errorf("received: %v, expected: %v", err, errBadDate) } c.DataSettings.APIData.EndDate = c.DataSettings.APIData.StartDate.Add(time.Minute) - err = c.ValidateDate() + err = c.validateDate() if err != nil { t.Error(err) } @@ -980,50 +1003,213 @@ func TestValidateDate(t *testing.T) { func TestValidateCurrencySettings(t *testing.T) { c := Config{} - err := c.ValidateCurrencySettings() - if !errors.Is(ErrNoCurrencySettings, err) { - t.Errorf("expected %v, received %v", ErrNoCurrencySettings, err) + err := c.validateCurrencySettings() + if !errors.Is(err, errNoCurrencySettings) { + t.Errorf("received: %v, expected: %v", err, errNoCurrencySettings) } c.CurrencySettings = append(c.CurrencySettings, CurrencySettings{}) - err = c.ValidateCurrencySettings() - if !errors.Is(ErrBadInitialFunds, err) { - t.Errorf("expected %v, received %v", ErrBadInitialFunds, err) + err = c.validateCurrencySettings() + if !errors.Is(err, errBadInitialFunds) { + t.Errorf("received: %v, expected: %v", err, errBadInitialFunds) } - c.CurrencySettings[0].InitialFunds = 1337 - err = c.ValidateCurrencySettings() - if !errors.Is(ErrUnsetCurrency, err) { - t.Errorf("expected %v, received %v", ErrUnsetCurrency, err) + leet := decimal.NewFromInt(1337) + c.CurrencySettings[0].InitialQuoteFunds = &leet + err = c.validateCurrencySettings() + if !errors.Is(err, errUnsetCurrency) { + t.Errorf("received: %v, expected: %v", err, errUnsetCurrency) } c.CurrencySettings[0].Base = "lol" - err = c.ValidateCurrencySettings() - if !errors.Is(ErrUnsetAsset, err) { - t.Errorf("expected %v, received %v", ErrUnsetAsset, err) + err = c.validateCurrencySettings() + if !errors.Is(err, errUnsetAsset) { + t.Errorf("received: %v, expected: %v", err, errUnsetAsset) } c.CurrencySettings[0].Asset = "lol" - err = c.ValidateCurrencySettings() - if !errors.Is(ErrUnsetExchange, err) { - t.Errorf("expected %v, received %v", ErrUnsetExchange, err) + err = c.validateCurrencySettings() + if !errors.Is(err, errUnsetExchange) { + t.Errorf("received: %v, expected: %v", err, errUnsetExchange) } c.CurrencySettings[0].ExchangeName = "lol" - err = c.ValidateCurrencySettings() + err = c.validateCurrencySettings() if err != nil { t.Error(err) } - c.CurrencySettings[0].MinimumSlippagePercent = -1.0 - err = c.ValidateCurrencySettings() - if !errors.Is(ErrBadSlippageRates, err) { - t.Errorf("expected %v, received %v", ErrBadSlippageRates, err) + c.CurrencySettings[0].MinimumSlippagePercent = decimal.NewFromInt(-1) + err = c.validateCurrencySettings() + if !errors.Is(err, errBadSlippageRates) { + t.Errorf("received: %v, expected: %v", err, errBadSlippageRates) } - c.CurrencySettings[0].MinimumSlippagePercent = 2.0 - c.CurrencySettings[0].MaximumSlippagePercent = -1.0 - err = c.ValidateCurrencySettings() - if !errors.Is(ErrBadSlippageRates, err) { - t.Errorf("expected %v, received %v", ErrBadSlippageRates, err) + c.CurrencySettings[0].MinimumSlippagePercent = decimal.NewFromInt(2) + c.CurrencySettings[0].MaximumSlippagePercent = decimal.NewFromInt(-1) + err = c.validateCurrencySettings() + if !errors.Is(err, errBadSlippageRates) { + t.Errorf("received: %v, expected: %v", err, errBadSlippageRates) } - c.CurrencySettings[0].MinimumSlippagePercent = 2.0 - c.CurrencySettings[0].MaximumSlippagePercent = 1.0 - err = c.ValidateCurrencySettings() - if !errors.Is(ErrBadSlippageRates, err) { - t.Errorf("expected %v, received %v", ErrBadSlippageRates, err) + c.CurrencySettings[0].MinimumSlippagePercent = decimal.NewFromInt(2) + c.CurrencySettings[0].MaximumSlippagePercent = decimal.NewFromInt(1) + err = c.validateCurrencySettings() + if !errors.Is(err, errBadSlippageRates) { + t.Errorf("received: %v, expected: %v", err, errBadSlippageRates) + } +} + +func TestValidateMinMaxes(t *testing.T) { + t.Parallel() + c := &Config{} + err := c.validateMinMaxes() + if err != nil { + t.Error(err) + } + + c.CurrencySettings = []CurrencySettings{ + { + SellSide: MinMax{ + MinimumSize: decimal.NewFromInt(-1), + }, + }, + } + err = c.validateMinMaxes() + if !errors.Is(err, errSizeLessThanZero) { + t.Errorf("received %v expected %v", err, errSizeLessThanZero) + } + c.CurrencySettings = []CurrencySettings{ + { + SellSide: MinMax{ + MaximumTotal: decimal.NewFromInt(-1), + }, + }, + } + err = c.validateMinMaxes() + if !errors.Is(err, errSizeLessThanZero) { + t.Errorf("received %v expected %v", err, errSizeLessThanZero) + } + c.CurrencySettings = []CurrencySettings{ + { + SellSide: MinMax{ + MaximumSize: decimal.NewFromInt(-1), + }, + }, + } + err = c.validateMinMaxes() + if !errors.Is(err, errSizeLessThanZero) { + t.Errorf("received %v expected %v", err, errSizeLessThanZero) + } + + c.CurrencySettings = []CurrencySettings{ + { + BuySide: MinMax{ + MinimumSize: decimal.NewFromInt(2), + MaximumTotal: decimal.NewFromInt(10), + MaximumSize: decimal.NewFromInt(1), + }, + }, + } + err = c.validateMinMaxes() + if !errors.Is(err, errMaxSizeMinSizeMismatch) { + t.Errorf("received %v expected %v", err, errMaxSizeMinSizeMismatch) + } + + c.CurrencySettings = []CurrencySettings{ + { + BuySide: MinMax{ + MinimumSize: decimal.NewFromInt(2), + MaximumSize: decimal.NewFromInt(2), + }, + }, + } + err = c.validateMinMaxes() + if !errors.Is(err, errMinMaxEqual) { + t.Errorf("received %v expected %v", err, errMinMaxEqual) + } + + c.CurrencySettings = []CurrencySettings{ + { + BuySide: MinMax{ + MinimumSize: decimal.NewFromInt(1), + MaximumTotal: decimal.NewFromInt(10), + MaximumSize: decimal.NewFromInt(2), + }, + }, + } + c.PortfolioSettings = PortfolioSettings{ + BuySide: MinMax{ + MinimumSize: decimal.NewFromInt(-1), + }, + } + err = c.validateMinMaxes() + if !errors.Is(err, errSizeLessThanZero) { + t.Errorf("received %v expected %v", err, errSizeLessThanZero) + } + c.PortfolioSettings = PortfolioSettings{ + SellSide: MinMax{ + MinimumSize: decimal.NewFromInt(-1), + }, + } + err = c.validateMinMaxes() + if !errors.Is(err, errSizeLessThanZero) { + t.Errorf("received %v expected %v", err, errSizeLessThanZero) + } +} + +func TestValidateStrategySettings(t *testing.T) { + t.Parallel() + c := &Config{} + err := c.validateStrategySettings() + if !errors.Is(err, base.ErrStrategyNotFound) { + t.Errorf("received %v expected %v", err, base.ErrStrategyNotFound) + } + c.StrategySettings = StrategySettings{Name: dca} + err = c.validateStrategySettings() + if !errors.Is(err, nil) { + t.Errorf("received %v expected %v", err, nil) + } + c.StrategySettings.UseExchangeLevelFunding = true + err = c.validateStrategySettings() + if !errors.Is(err, errSimultaneousProcessingRequired) { + t.Errorf("received %v expected %v", err, errSimultaneousProcessingRequired) + } + c.StrategySettings.SimultaneousSignalProcessing = true + err = c.validateStrategySettings() + if !errors.Is(err, errExchangeLevelFundingDataRequired) { + t.Errorf("received %v expected %v", err, errExchangeLevelFundingDataRequired) + } + c.StrategySettings.ExchangeLevelFunding = []ExchangeLevelFunding{ + { + InitialFunds: decimal.NewFromInt(-1), + }, + } + err = c.validateStrategySettings() + if !errors.Is(err, errBadInitialFunds) { + t.Errorf("received %v expected %v", err, errBadInitialFunds) + } + c.StrategySettings.UseExchangeLevelFunding = false + err = c.validateStrategySettings() + if !errors.Is(err, errExchangeLevelFundingRequired) { + t.Errorf("received %v expected %v", err, errExchangeLevelFundingRequired) + } +} + +func TestValidate(t *testing.T) { + t.Parallel() + c := &Config{ + StrategySettings: StrategySettings{Name: dca}, + CurrencySettings: []CurrencySettings{ + { + ExchangeName: testExchange, + Asset: asset.Spot.String(), + Base: currency.BTC.String(), + Quote: currency.USDT.String(), + InitialBaseFunds: initialBaseFunds, + InitialQuoteFunds: initialQuoteFunds2, + BuySide: MinMax{ + MinimumSize: decimal.NewFromInt(1), + MaximumSize: decimal.NewFromInt(10), + MaximumTotal: decimal.NewFromInt(10), + }, + }, + }, + } + err := c.Validate() + if !errors.Is(err, nil) { + t.Errorf("received %v expected %v", err, nil) } } diff --git a/backtester/config/config_types.go b/backtester/config/config_types.go index 6bd2bee0..15166f20 100644 --- a/backtester/config/config_types.go +++ b/backtester/config/config_types.go @@ -4,19 +4,26 @@ import ( "errors" "time" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/database" ) // Errors for config validation var ( - ErrBadDate = errors.New("start date >= end date, please check your config") - ErrNoCurrencySettings = errors.New("no currency settings set in the config") - ErrBadInitialFunds = errors.New("initial funds set with invalid data, please check your config") - ErrUnsetExchange = errors.New("exchange name unset for currency settings, please check your config") - ErrUnsetAsset = errors.New("asset unset for currency settings, please check your config") - ErrUnsetCurrency = errors.New("currency unset for currency settings, please check your config") - ErrBadSlippageRates = errors.New("invalid slippage rates in currency settings, please check your config") - ErrStartEndUnset = errors.New("data start and end dates are invalid, please check your config") + errBadDate = errors.New("start date >= end date, please check your config") + errNoCurrencySettings = errors.New("no currency settings set in the config") + errBadInitialFunds = errors.New("initial funds set with invalid data, please check your config") + errUnsetExchange = errors.New("exchange name unset for currency settings, please check your config") + errUnsetAsset = errors.New("asset unset for currency settings, please check your config") + errUnsetCurrency = errors.New("currency unset for currency settings, please check your config") + errBadSlippageRates = errors.New("invalid slippage rates in currency settings, please check your config") + errStartEndUnset = errors.New("data start and end dates are invalid, please check your config") + errSimultaneousProcessingRequired = errors.New("exchange level funding requires simultaneous processing, please check your config and view funding readme for details") + errExchangeLevelFundingRequired = errors.New("invalid config, funding details set while exchange level funding is disabled") + errExchangeLevelFundingDataRequired = errors.New("invalid config, exchange level funding enabled with no funding data set") + errSizeLessThanZero = errors.New("size less than zero") + errMaxSizeMinSizeMismatch = errors.New("maximum size must be greater to minimum size") + errMinMaxEqual = errors.New("minimum and maximum limits cannot be equal") ) // Config defines what is in an individual strategy config @@ -48,13 +55,30 @@ type DataSettings struct { type StrategySettings struct { Name string `json:"name"` SimultaneousSignalProcessing bool `json:"use-simultaneous-signal-processing"` - CustomSettings map[string]interface{} `json:"custom-settings"` + UseExchangeLevelFunding bool `json:"use-exchange-level-funding"` + ExchangeLevelFunding []ExchangeLevelFunding `json:"exchange-level-funding,omitempty"` + CustomSettings map[string]interface{} `json:"custom-settings,omitempty"` } -// StatisticSettings holds configurable varialbes to adjust ratios where +// ExchangeLevelFunding allows the portfolio manager to access +// a shared pool. For example, The base currencies BTC and LTC can both +// access the same USDT funding to make purchasing decisions +// Similarly, when a BTC is sold, LTC can now utilise the increased funding +// Importantly, exchange level funding is all-inclusive, you cannot have it for only some uses +// It also is required to use SimultaneousSignalProcessing, otherwise the first currency processed +// will have dibs +type ExchangeLevelFunding struct { + ExchangeName string `json:"exchange-name"` + Asset string `json:"asset"` + Currency string `json:"currency"` + InitialFunds decimal.Decimal `json:"initial-funds"` + TransferFee decimal.Decimal `json:"transfer-fee"` +} + +// StatisticSettings adjusts ratios where // proper data is currently lacking type StatisticSettings struct { - RiskFreeRate float64 `json:"risk-free-rate"` + RiskFreeRate decimal.Decimal `json:"risk-free-rate"` } // PortfolioSettings act as a global protector for strategies @@ -69,16 +93,16 @@ type PortfolioSettings struct { // Leverage rules are used to allow or limit the use of leverage in orders // when supported type Leverage struct { - CanUseLeverage bool `json:"can-use-leverage"` - MaximumOrdersWithLeverageRatio float64 `json:"maximum-orders-with-leverage-ratio"` - MaximumLeverageRate float64 `json:"maximum-leverage-rate"` + CanUseLeverage bool `json:"can-use-leverage"` + MaximumOrdersWithLeverageRatio decimal.Decimal `json:"maximum-orders-with-leverage-ratio"` + MaximumLeverageRate decimal.Decimal `json:"maximum-leverage-rate"` } // MinMax are the rules which limit the placement of orders. type MinMax struct { - MinimumSize float64 `json:"minimum-size"` // will not place an order if under this amount - MaximumSize float64 `json:"maximum-size"` // can only place an order up to this amount - MaximumTotal float64 `json:"maximum-total"` + MinimumSize decimal.Decimal `json:"minimum-size"` // will not place an order if under this amount + MaximumSize decimal.Decimal `json:"maximum-size"` // can only place an order up to this amount + MaximumTotal decimal.Decimal `json:"maximum-total"` } // CurrencySettings stores pair based variables @@ -91,21 +115,24 @@ type CurrencySettings struct { Base string `json:"base"` Quote string `json:"quote"` - InitialFunds float64 `json:"initial-funds"` + InitialBaseFunds *decimal.Decimal `json:"initial-base-funds,omitempty"` + InitialQuoteFunds *decimal.Decimal `json:"initial-quote-funds,omitempty"` + InitialLegacyFunds float64 `json:"initial-funds,omitempty"` Leverage Leverage `json:"leverage"` BuySide MinMax `json:"buy-side"` SellSide MinMax `json:"sell-side"` - MinimumSlippagePercent float64 `json:"min-slippage-percent"` - MaximumSlippagePercent float64 `json:"max-slippage-percent"` + MinimumSlippagePercent decimal.Decimal `json:"min-slippage-percent"` + MaximumSlippagePercent decimal.Decimal `json:"max-slippage-percent"` - MakerFee float64 `json:"maker-fee-override"` - TakerFee float64 `json:"taker-fee-override"` + MakerFee decimal.Decimal `json:"maker-fee-override"` + TakerFee decimal.Decimal `json:"taker-fee-override"` - MaximumHoldingsRatio float64 `json:"maximum-holdings-ratio"` + MaximumHoldingsRatio decimal.Decimal `json:"maximum-holdings-ratio"` CanUseExchangeLimits bool `json:"use-exchange-order-limits"` + SkipCandleVolumeFitting bool `json:"skip-candle-volume-fitting"` ShowExchangeOrderLimitWarning bool `json:"-"` } @@ -135,6 +162,6 @@ type LiveData struct { APISecretOverride string `json:"api-secret-override"` APIClientIDOverride string `json:"api-client-id-override"` API2FAOverride string `json:"api-2fa-override"` - APISubaccountOverride string `json:"api-subaccount-override"` + APISubAccountOverride string `json:"api-sub-account-override"` RealOrders bool `json:"real-orders"` } diff --git a/backtester/config/configbuilder/main.go b/backtester/config/configbuilder/main.go index 3e647d97..6c65feac 100644 --- a/backtester/config/configbuilder/main.go +++ b/backtester/config/configbuilder/main.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/common" "github.com/thrasher-corp/gocryptotrader/backtester/config" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/strategies" @@ -45,6 +46,8 @@ func main() { StrategySettings: config.StrategySettings{ Name: "", SimultaneousSignalProcessing: false, + UseExchangeLevelFunding: false, + ExchangeLevelFunding: nil, CustomSettings: nil, }, CurrencySettings: []config.CurrencySettings{}, @@ -66,11 +69,10 @@ func main() { } fmt.Println("-----Strategy Settings-----") var err error - var strats []strategies.Handler firstRun := true for err != nil || firstRun { firstRun = false - strats, err = parseStrategySettings(&cfg, reader) + err = parseStrategySettings(&cfg, reader) if err != nil { log.Println(err) } @@ -80,7 +82,7 @@ func main() { firstRun = true for err != nil || firstRun { firstRun = false - err = parseExchangeSettings(reader, &cfg, strats) + err = parseExchangeSettings(reader, &cfg) if err != nil { log.Println(err) } @@ -170,9 +172,12 @@ func main() { func parseStatisticsSettings(cfg *config.Config, reader *bufio.Reader) error { fmt.Println("Enter the risk free rate. eg 0.03") - var err error - cfg.StatisticSettings.RiskFreeRate, err = strconv.ParseFloat(quickParse(reader), 64) - return err + rfr, err := strconv.ParseFloat(quickParse(reader), 64) + if err != nil { + return err + } + cfg.StatisticSettings.RiskFreeRate = decimal.NewFromFloat(rfr) + return nil } func parseDataSettings(cfg *config.Config, reader *bufio.Reader) error { @@ -228,12 +233,12 @@ func parsePortfolioSettings(reader *bufio.Reader, cfg *config.Config) error { return nil } -func parseExchangeSettings(reader *bufio.Reader, cfg *config.Config, strats []strategies.Handler) error { +func parseExchangeSettings(reader *bufio.Reader, cfg *config.Config) error { var err error addCurrency := y for strings.Contains(addCurrency, y) { var currencySetting *config.CurrencySettings - currencySetting, err = addCurrencySetting(reader) + currencySetting, err = addCurrencySetting(reader, cfg.StrategySettings.UseExchangeLevelFunding) if err != nil { return err } @@ -243,23 +248,10 @@ func parseExchangeSettings(reader *bufio.Reader, cfg *config.Config, strats []st addCurrency = quickParse(reader) } - if len(cfg.CurrencySettings) > 1 { - for i := range strats { - if strats[i].Name() == cfg.StrategySettings.Name && - strats[i].SupportsSimultaneousProcessing() { - fmt.Println("Will this strategy use simultaneous processing? y/n") - yn := quickParse(reader) - if yn == y || yn == yes { - cfg.StrategySettings.SimultaneousSignalProcessing = true - } - break - } - } - } return nil } -func parseStrategySettings(cfg *config.Config, reader *bufio.Reader) ([]strategies.Handler, error) { +func parseStrategySettings(cfg *config.Config, reader *bufio.Reader) error { fmt.Println("Firstly, please select which strategy you wish to use") strats := strategies.GetStrategies() var strategiesToUse []string @@ -270,7 +262,7 @@ func parseStrategySettings(cfg *config.Config, reader *bufio.Reader) ([]strategi var err error cfg.StrategySettings.Name, err = parseStratName(quickParse(reader), strategiesToUse) if err != nil { - return nil, err + return err } fmt.Println("What is the goal of your strategy?") @@ -282,7 +274,71 @@ func parseStrategySettings(cfg *config.Config, reader *bufio.Reader) ([]strategi if strings.Contains(customSettings, y) { cfg.StrategySettings.CustomSettings = customSettingsLoop(reader) } - return strats, nil + fmt.Println("Will this strategy use simultaneous processing? y/n") + yn := quickParse(reader) + cfg.StrategySettings.SimultaneousSignalProcessing = strings.Contains(yn, y) + if !cfg.StrategySettings.SimultaneousSignalProcessing { + return nil + } + fmt.Println("Will this strategy be able to share funds at an exchange level? y/n") + yn = quickParse(reader) + cfg.StrategySettings.UseExchangeLevelFunding = strings.Contains(yn, y) + if !cfg.StrategySettings.UseExchangeLevelFunding { + return nil + } + + addFunding := y + for strings.Contains(addFunding, y) { + fund := config.ExchangeLevelFunding{} + fmt.Println("What is the exchange name to add funding to?") + fund.ExchangeName = quickParse(reader) + fmt.Println("What is the asset to add funding to?") + supported := asset.Supported() + for i := range supported { + fmt.Printf("%v. %s\n", i+1, supported[i]) + } + response := quickParse(reader) + num, err := strconv.ParseFloat(response, 64) + if err == nil { + intNum := int(num) + if intNum > len(supported) || intNum <= 0 { + return errors.New("unknown option") + } + fund.Asset = supported[intNum-1].String() + } else { + for i := range supported { + if strings.EqualFold(response, supported[i].String()) { + fund.Asset = supported[i].String() + break + } + } + if fund.Asset == "" { + return errors.New("unrecognised data option") + } + } + + fmt.Println("What is the individual currency to add funding to? eg BTC") + fund.Currency = quickParse(reader) + fmt.Printf("How much funding for %v?\n", fund.Currency) + fund.InitialFunds, err = decimal.NewFromString(quickParse(reader)) + if err != nil { + return err + } + + fmt.Println("If your strategy utilises fund transfer, what is the transfer fee?") + fee := quickParse(reader) + if fee != "" { + fund.TransferFee, err = decimal.NewFromString(fee) + if err != nil { + return err + } + } + cfg.StrategySettings.ExchangeLevelFunding = append(cfg.StrategySettings.ExchangeLevelFunding, fund) + fmt.Println("Add another source of funds? y/n") + addFunding = quickParse(reader) + } + + return nil } func parseAPI(reader *bufio.Reader, cfg *config.Config) error { @@ -434,7 +490,7 @@ func parseLive(reader *bufio.Reader, cfg *config.Config) { fmt.Println("What is the 2FA seed?") cfg.DataSettings.LiveData.API2FAOverride = quickParse(reader) fmt.Println("What is the subaccount to use?") - cfg.DataSettings.LiveData.APISubaccountOverride = quickParse(reader) + cfg.DataSettings.LiveData.APISubAccountOverride = quickParse(reader) } } } @@ -517,7 +573,7 @@ func customSettingsLoop(reader *bufio.Reader) map[string]interface{} { return resp } -func addCurrencySetting(reader *bufio.Reader) (*config.CurrencySettings, error) { +func addCurrencySetting(reader *bufio.Reader, usingExchangeLevelFunding bool) (*config.CurrencySettings, error) { setting := config.CurrencySettings{ BuySide: config.MinMax{}, SellSide: config.MinMax{}, @@ -545,36 +601,52 @@ func addCurrencySetting(reader *bufio.Reader) (*config.CurrencySettings, error) } } + var f float64 fmt.Println("Enter the currency base. eg BTC") setting.Base = quickParse(reader) - - fmt.Println("Enter the currency quote. eg USDT") - setting.Quote = quickParse(reader) - - fmt.Println("Enter the initial funds. eg 10000") - parseNum := quickParse(reader) - if parseNum != "" { - setting.InitialFunds, err = strconv.ParseFloat(parseNum, 64) - if err != nil { - return nil, err + if !usingExchangeLevelFunding { + fmt.Println("Enter the initial base funds. eg 0") + parseNum := quickParse(reader) + if parseNum != "" { + f, err = strconv.ParseFloat(parseNum, 64) + if err != nil { + return nil, err + } + iqf := decimal.NewFromFloat(f) + setting.InitialBaseFunds = &iqf + } + } + fmt.Println("Enter the currency quote. eg USDT") + setting.Quote = quickParse(reader) + if !usingExchangeLevelFunding { + fmt.Println("Enter the initial quote funds. eg 10000") + parseNum := quickParse(reader) + if parseNum != "" { + f, err = strconv.ParseFloat(parseNum, 64) + if err != nil { + return nil, err + } + iqf := decimal.NewFromFloat(f) + setting.InitialQuoteFunds = &iqf } } - fmt.Println("Enter the maker-fee. eg 0.001") - parseNum = quickParse(reader) + parseNum := quickParse(reader) if parseNum != "" { - setting.MakerFee, err = strconv.ParseFloat(parseNum, 64) + f, err = strconv.ParseFloat(parseNum, 64) if err != nil { return nil, err } + setting.MakerFee = decimal.NewFromFloat(f) } fmt.Println("Enter the taker-fee. eg 0.01") parseNum = quickParse(reader) if parseNum != "" { - setting.TakerFee, err = strconv.ParseFloat(parseNum, 64) + f, err = strconv.ParseFloat(parseNum, 64) if err != nil { return nil, err } + setting.TakerFee = decimal.NewFromFloat(f) } fmt.Println("Will there be buy-side limits? y/n") @@ -598,6 +670,13 @@ func addCurrencySetting(reader *bufio.Reader) (*config.CurrencySettings, error) if yn == y || yn == yes { setting.CanUseExchangeLimits = true } + + fmt.Println("Should order size shrink to fit within candle volume? y/n") + yn = quickParse(reader) + if yn == y || yn == yes { + setting.SkipCandleVolumeFitting = true + } + fmt.Println("Do you wish to include slippage? y/n") yn = quickParse(reader) if yn == y || yn == yes { @@ -606,16 +685,18 @@ func addCurrencySetting(reader *bufio.Reader) (*config.CurrencySettings, error) fmt.Println("If the upper bound is 100, then the price can be unaffected. A minimum of 80 and a maximum of 100 means that the price will randomly be set between those bounds as a way of emulating slippage") fmt.Println("What is the lower bounds of slippage? eg 80") - setting.MinimumSlippagePercent, err = strconv.ParseFloat(quickParse(reader), 64) + f, err = strconv.ParseFloat(quickParse(reader), 64) if err != nil { return nil, err } + setting.MinimumSlippagePercent = decimal.NewFromFloat(f) fmt.Println("What is the upper bounds of slippage? eg 100") - setting.MaximumSlippagePercent, err = strconv.ParseFloat(quickParse(reader), 64) + f, err = strconv.ParseFloat(quickParse(reader), 64) if err != nil { return nil, err } + setting.MaximumSlippagePercent = decimal.NewFromFloat(f) } return &setting, nil @@ -623,30 +704,32 @@ func addCurrencySetting(reader *bufio.Reader) (*config.CurrencySettings, error) func minMaxParse(buySell string, reader *bufio.Reader) (config.MinMax, error) { resp := config.MinMax{} - var err error fmt.Printf("What is the maximum %s size? eg 1\n", buySell) parseNum := quickParse(reader) if parseNum != "" { - resp.MaximumSize, err = strconv.ParseFloat(parseNum, 64) + f, err := strconv.ParseFloat(parseNum, 64) if err != nil { return resp, err } + resp.MaximumSize = decimal.NewFromFloat(f) } fmt.Printf("What is the minimum %s size? eg 0.1\n", buySell) parseNum = quickParse(reader) if parseNum != "" { - resp.MinimumSize, err = strconv.ParseFloat(parseNum, 64) + f, err := strconv.ParseFloat(parseNum, 64) if err != nil { return resp, err } + resp.MinimumSize = decimal.NewFromFloat(f) } fmt.Printf("What is the maximum spend %s buy? eg 12000\n", buySell) parseNum = quickParse(reader) if parseNum != "" { - resp.MaximumTotal, err = strconv.ParseFloat(parseNum, 64) + f, err := strconv.ParseFloat(parseNum, 64) if err != nil { return resp, err } + resp.MaximumTotal = decimal.NewFromFloat(f) } return resp, nil diff --git a/backtester/config/examples/README.md b/backtester/config/examples/README.md index cdffcd04..0e973e47 100644 --- a/backtester/config/examples/README.md +++ b/backtester/config/examples/README.md @@ -20,15 +20,23 @@ Join our slack to discuss all things related to GoCryptoTrader! [GoCryptoTrader ## Examples package overview -Current Config Examples: +### Current Config Examples | Config | Description | | --- | ------ | -| dollar-cost-average.strat | A simple dollar cost average strategy which makes a purchase on every candle. | -| dollar-cost-average-live.strat | Using the same dollar cost average strategy, but runs the analysis against live candles | -| dollar-cost-average-multi-currency-assessment.strat | This strategy will assess multiple currencies in the one `OnSignals` function, however, it also just simply makes a purchase on every candle | -| dollar-cost-average-multiple-currencies.strat | This runs the same strategy against multiple currencies independently | -| rsi.strat | Runs a strategy using rsi figures to make buy or sell orders based on market figures | +| dca-api-candles.strat | A simple dollar cost average strategy which makes a purchase on every candle | +| dca-api-candles-multiple-currencies.strat| The same DCA strategy, but applied to multiple currencies | +| dca-api-candles-simultaneous-processing.strat | The same DCA strategy, but uses simultaneous signal processing | +| dca-api-candles-exchange-level-funding.strat| The same DCA strategy, but utilises simultaneous signal processing and a shared pool of funding against multiple currencies | +| dca-api-trades.strat| The same DCA strategy, but sources its candle data from trades | +| dca-candles-live.strat| The same DCA strategy, but utilises live data instead of old data | +| dca-csv-candles.strat | The same DCA strategy, but uses a CSV to source candle data | +| dca-database-candles.strat | The same DCA strategy, but uses a database to retrieve candle data | +| rsi-api-candles.strat | Runs a strategy using rsi figures to make buy or sell orders based on market figures | +| t2b2-api-candles-exchange-funding.strat | Runs a more complex strategy using simultaneous signal processing, exchange level funding and MFI values to make buy or sell signals based on the two strongest and weakest MFI values | + +### Want to make your own configs? +Use the provided config builder under `/backtester/config/configbuilder` or modify tests under `/backtester/config/config_test.go` to generates strategy files quickly ### Please click GoDocs chevron above to view current GoDoc information for this package diff --git a/backtester/config/examples/dca-api-candles-exchange-level-funding.strat b/backtester/config/examples/dca-api-candles-exchange-level-funding.strat new file mode 100644 index 00000000..e8c18558 --- /dev/null +++ b/backtester/config/examples/dca-api-candles-exchange-level-funding.strat @@ -0,0 +1,106 @@ +{ + "nickname": "ExampleStrategyDCAAPICandlesExchangeLevelFunding", + "goal": "To demonstrate DCA strategy using API candles using a shared pool of funds", + "strategy-settings": { + "name": "dollarcostaverage", + "use-simultaneous-signal-processing": true, + "use-exchange-level-funding": true, + "exchange-level-funding": [ + { + "exchange-name": "binance", + "asset": "spot", + "currency": "USDT", + "initial-funds": "100000", + "transfer-fee": "0" + } + ] + }, + "currency-settings": [ + { + "exchange-name": "binance", + "asset": "spot", + "base": "BTC", + "quote": "USDT", + "leverage": { + "can-use-leverage": false, + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" + }, + "buy-side": { + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" + }, + "sell-side": { + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" + }, + "min-slippage-percent": "0", + "max-slippage-percent": "0", + "maker-fee-override": "0.001", + "taker-fee-override": "0.002", + "maximum-holdings-ratio": "0", + "use-exchange-order-limits": false, + "skip-candle-volume-fitting": false + }, + { + "exchange-name": "binance", + "asset": "spot", + "base": "ETH", + "quote": "USDT", + "leverage": { + "can-use-leverage": false, + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" + }, + "buy-side": { + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" + }, + "sell-side": { + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" + }, + "min-slippage-percent": "0", + "max-slippage-percent": "0", + "maker-fee-override": "0.001", + "taker-fee-override": "0.002", + "maximum-holdings-ratio": "0", + "use-exchange-order-limits": false, + "skip-candle-volume-fitting": false + } + ], + "data-settings": { + "interval": 86400000000000, + "data-type": "candle", + "api-data": { + "start-date": "2020-08-01T00:00:00+10:00", + "end-date": "2020-12-01T00:00:00+11:00", + "inclusive-end-date": false + } + }, + "portfolio-settings": { + "leverage": { + "can-use-leverage": false, + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" + }, + "buy-side": { + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" + }, + "sell-side": { + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" + } + }, + "statistic-settings": { + "risk-free-rate": "0.03" + }, + "gocryptotrader-config-path": "" +} \ No newline at end of file diff --git a/backtester/config/examples/dca-api-candles-multiple-currencies.strat b/backtester/config/examples/dca-api-candles-multiple-currencies.strat index 1c70ccbc..af109a6c 100644 --- a/backtester/config/examples/dca-api-candles-multiple-currencies.strat +++ b/backtester/config/examples/dca-api-candles-multiple-currencies.strat @@ -1,10 +1,10 @@ { - "nickname": "TestGenerateConfigForDCAAPICandlesMultipleCurrencies", + "nickname": "ExampleStrategyDCAAPICandlesMultipleCurrencies", "goal": "To demonstrate running the DCA strategy using the API against multiple currencies candle data", "strategy-settings": { "name": "dollarcostaverage", "use-simultaneous-signal-processing": false, - "custom-settings": null + "use-exchange-level-funding": false }, "currency-settings": [ { @@ -12,63 +12,65 @@ "asset": "spot", "base": "BTC", "quote": "USDT", - "initial-funds": 100000, + "initial-quote-funds": "100000", "leverage": { "can-use-leverage": false, - "maximum-orders-with-leverage-ratio": 0, - "maximum-leverage-rate": 0 + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" }, "buy-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, "sell-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, - "min-slippage-percent": 0, - "max-slippage-percent": 0, - "maker-fee-override": 0.001, - "taker-fee-override": 0.002, - "maximum-holdings-ratio": 0, - "use-exchange-order-limits": false + "min-slippage-percent": "0", + "max-slippage-percent": "0", + "maker-fee-override": "0.001", + "taker-fee-override": "0.002", + "maximum-holdings-ratio": "0", + "use-exchange-order-limits": false, + "skip-candle-volume-fitting": false }, { "exchange-name": "binance", "asset": "spot", "base": "ETH", "quote": "USDT", - "initial-funds": 100000, + "initial-quote-funds": "100000", "leverage": { "can-use-leverage": false, - "maximum-orders-with-leverage-ratio": 0, - "maximum-leverage-rate": 0 + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" }, "buy-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, "sell-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, - "min-slippage-percent": 0, - "max-slippage-percent": 0, - "maker-fee-override": 0.001, - "taker-fee-override": 0.002, - "maximum-holdings-ratio": 0, - "use-exchange-order-limits": false + "min-slippage-percent": "0", + "max-slippage-percent": "0", + "maker-fee-override": "0.001", + "taker-fee-override": "0.002", + "maximum-holdings-ratio": "0", + "use-exchange-order-limits": false, + "skip-candle-volume-fitting": false } ], "data-settings": { "interval": 86400000000000, "data-type": "candle", "api-data": { - "start-date": "2020-11-01T00:00:00+11:00", + "start-date": "2020-08-01T00:00:00+10:00", "end-date": "2020-12-01T00:00:00+11:00", "inclusive-end-date": false } @@ -76,22 +78,22 @@ "portfolio-settings": { "leverage": { "can-use-leverage": false, - "maximum-orders-with-leverage-ratio": 0, - "maximum-leverage-rate": 0 + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" }, "buy-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, "sell-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" } }, "statistic-settings": { - "risk-free-rate": 0.03 + "risk-free-rate": "0.03" }, "gocryptotrader-config-path": "" } \ No newline at end of file diff --git a/backtester/config/examples/dca-api-candles-simultaneous-processing.strat b/backtester/config/examples/dca-api-candles-simultaneous-processing.strat index ee763d66..cd3d22b9 100644 --- a/backtester/config/examples/dca-api-candles-simultaneous-processing.strat +++ b/backtester/config/examples/dca-api-candles-simultaneous-processing.strat @@ -1,10 +1,10 @@ { - "nickname": "TestGenerateConfigForDCAAPICandlesSimultaneousProcessing", + "nickname": "ExampleStrategyDCAAPICandlesSimultaneousProcessing", "goal": "To demonstrate how simultaneous processing can work", "strategy-settings": { "name": "dollarcostaverage", "use-simultaneous-signal-processing": true, - "custom-settings": null + "use-exchange-level-funding": false }, "currency-settings": [ { @@ -12,63 +12,65 @@ "asset": "spot", "base": "BTC", "quote": "USDT", - "initial-funds": 1000000, + "initial-quote-funds": "1000000", "leverage": { "can-use-leverage": false, - "maximum-orders-with-leverage-ratio": 0, - "maximum-leverage-rate": 0 + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" }, "buy-side": { - "minimum-size": 0, - "maximum-size": 0, - "maximum-total": 1000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, "sell-side": { - "minimum-size": 0, - "maximum-size": 0, - "maximum-total": 1000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, - "min-slippage-percent": 0, - "max-slippage-percent": 0, - "maker-fee-override": 0.001, - "taker-fee-override": 0.002, - "maximum-holdings-ratio": 0, - "use-exchange-order-limits": false + "min-slippage-percent": "0", + "max-slippage-percent": "0", + "maker-fee-override": "0.001", + "taker-fee-override": "0.002", + "maximum-holdings-ratio": "0", + "use-exchange-order-limits": false, + "skip-candle-volume-fitting": false }, { "exchange-name": "binance", "asset": "spot", "base": "ETH", "quote": "USDT", - "initial-funds": 100000, + "initial-quote-funds": "100000", "leverage": { "can-use-leverage": false, - "maximum-orders-with-leverage-ratio": 0, - "maximum-leverage-rate": 0 + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" }, "buy-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, "sell-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, - "min-slippage-percent": 0, - "max-slippage-percent": 0, - "maker-fee-override": 0.001, - "taker-fee-override": 0.002, - "maximum-holdings-ratio": 0, - "use-exchange-order-limits": false + "min-slippage-percent": "0", + "max-slippage-percent": "0", + "maker-fee-override": "0.001", + "taker-fee-override": "0.002", + "maximum-holdings-ratio": "0", + "use-exchange-order-limits": false, + "skip-candle-volume-fitting": false } ], "data-settings": { "interval": 86400000000000, "data-type": "candle", "api-data": { - "start-date": "2020-11-01T00:00:00+11:00", + "start-date": "2020-08-01T00:00:00+10:00", "end-date": "2020-12-01T00:00:00+11:00", "inclusive-end-date": false } @@ -76,22 +78,22 @@ "portfolio-settings": { "leverage": { "can-use-leverage": false, - "maximum-orders-with-leverage-ratio": 0, - "maximum-leverage-rate": 0 + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" }, "buy-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, "sell-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" } }, "statistic-settings": { - "risk-free-rate": 0.03 + "risk-free-rate": "0.03" }, "gocryptotrader-config-path": "" } \ No newline at end of file diff --git a/backtester/config/examples/dca-api-candles.strat b/backtester/config/examples/dca-api-candles.strat index b5728d10..57abcfc8 100644 --- a/backtester/config/examples/dca-api-candles.strat +++ b/backtester/config/examples/dca-api-candles.strat @@ -1,10 +1,10 @@ { - "nickname": "TestGenerateConfigForDCAAPICandles", + "nickname": "ExampleStrategyDCAAPICandles", "goal": "To demonstrate DCA strategy using API candles", "strategy-settings": { "name": "dollarcostaverage", "use-simultaneous-signal-processing": false, - "custom-settings": null + "use-exchange-level-funding": false }, "currency-settings": [ { @@ -12,35 +12,36 @@ "asset": "spot", "base": "BTC", "quote": "USDT", - "initial-funds": 100000, + "initial-quote-funds": "100000", "leverage": { "can-use-leverage": false, - "maximum-orders-with-leverage-ratio": 0, - "maximum-leverage-rate": 0 + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" }, "buy-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, "sell-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, - "min-slippage-percent": 0, - "max-slippage-percent": 0, - "maker-fee-override": 0.001, - "taker-fee-override": 0.002, - "maximum-holdings-ratio": 0, - "use-exchange-order-limits": false + "min-slippage-percent": "0", + "max-slippage-percent": "0", + "maker-fee-override": "0.001", + "taker-fee-override": "0.002", + "maximum-holdings-ratio": "0", + "use-exchange-order-limits": false, + "skip-candle-volume-fitting": false } ], "data-settings": { "interval": 86400000000000, "data-type": "candle", "api-data": { - "start-date": "2020-11-01T00:00:00+11:00", + "start-date": "2020-08-01T00:00:00+10:00", "end-date": "2020-12-01T00:00:00+11:00", "inclusive-end-date": false } @@ -48,22 +49,22 @@ "portfolio-settings": { "leverage": { "can-use-leverage": false, - "maximum-orders-with-leverage-ratio": 0, - "maximum-leverage-rate": 0 + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" }, "buy-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, "sell-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" } }, "statistic-settings": { - "risk-free-rate": 0.03 + "risk-free-rate": "0.03" }, "gocryptotrader-config-path": "" } \ No newline at end of file diff --git a/backtester/config/examples/dca-api-trades.strat b/backtester/config/examples/dca-api-trades.strat index 83053cae..5c59d2b2 100644 --- a/backtester/config/examples/dca-api-trades.strat +++ b/backtester/config/examples/dca-api-trades.strat @@ -1,69 +1,70 @@ { - "nickname": "TestGenerateConfigForDCAAPITrades", + "nickname": "ExampleStrategyDCAAPITrades", "goal": "To demonstrate running the DCA strategy using API trade data", "strategy-settings": { "name": "dollarcostaverage", "use-simultaneous-signal-processing": false, - "custom-settings": null + "use-exchange-level-funding": false }, "currency-settings": [ { - "exchange-name": "binance", + "exchange-name": "ftx", "asset": "spot", "base": "BTC", "quote": "USDT", - "initial-funds": 100000, + "initial-quote-funds": "100000", "leverage": { "can-use-leverage": false, - "maximum-orders-with-leverage-ratio": 0, - "maximum-leverage-rate": 0 + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" }, "buy-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, "sell-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, - "min-slippage-percent": 0, - "max-slippage-percent": 0, - "maker-fee-override": 0.001, - "taker-fee-override": 0.002, - "maximum-holdings-ratio": 0, - "use-exchange-order-limits": false + "min-slippage-percent": "0", + "max-slippage-percent": "0", + "maker-fee-override": "0.001", + "taker-fee-override": "0.002", + "maximum-holdings-ratio": "0", + "use-exchange-order-limits": false, + "skip-candle-volume-fitting": true } ], "data-settings": { - "interval": 86400000000000, + "interval": 3600000000000, "data-type": "trade", "api-data": { - "start-date": "2020-11-01T00:00:00+11:00", - "end-date": "2020-12-01T00:00:00+11:00", + "start-date": "2020-08-01T00:00:00+10:00", + "end-date": "2020-08-04T00:00:00+10:00", "inclusive-end-date": false } }, "portfolio-settings": { "leverage": { "can-use-leverage": false, - "maximum-orders-with-leverage-ratio": 0, - "maximum-leverage-rate": 0 + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" }, "buy-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.1", + "maximum-size": "1", + "maximum-total": "10000" }, "sell-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.1", + "maximum-size": "1", + "maximum-total": "10000" } }, "statistic-settings": { - "risk-free-rate": 0.03 + "risk-free-rate": "0.03" }, "gocryptotrader-config-path": "" } \ No newline at end of file diff --git a/backtester/config/examples/dca-candles-live.strat b/backtester/config/examples/dca-candles-live.strat index c1d8537b..9e390cec 100644 --- a/backtester/config/examples/dca-candles-live.strat +++ b/backtester/config/examples/dca-candles-live.strat @@ -1,10 +1,10 @@ { - "nickname": "TestGenerateConfigForDCALiveCandles", + "nickname": "ExampleStrategyDCALiveCandles", "goal": "To demonstrate live trading proof of concept against candle data", "strategy-settings": { "name": "dollarcostaverage", "use-simultaneous-signal-processing": false, - "custom-settings": null + "use-exchange-level-funding": false }, "currency-settings": [ { @@ -12,60 +12,62 @@ "asset": "spot", "base": "BTC", "quote": "USDT", - "initial-funds": 100000, + "initial-quote-funds": "100000", "leverage": { "can-use-leverage": false, - "maximum-orders-with-leverage-ratio": 0, - "maximum-leverage-rate": 0 + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" }, "buy-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, "sell-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, - "min-slippage-percent": 0, - "max-slippage-percent": 0, - "maker-fee-override": 0.001, - "taker-fee-override": 0.002, - "maximum-holdings-ratio": 0, - "use-exchange-order-limits": true + "min-slippage-percent": "0", + "max-slippage-percent": "0", + "maker-fee-override": "0.001", + "taker-fee-override": "0.002", + "maximum-holdings-ratio": "0", + "use-exchange-order-limits": false, + "skip-candle-volume-fitting": false } ], "data-settings": { - "interval": 3600000000000, + "interval": 60000000000, "data-type": "candle", "live-data": { "api-key-override": "", "api-secret-override": "", "api-client-id-override": "", "api-2fa-override": "", + "api-sub-account-override": "", "real-orders": false } }, "portfolio-settings": { "leverage": { "can-use-leverage": false, - "maximum-orders-with-leverage-ratio": 0, - "maximum-leverage-rate": 0 + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" }, "buy-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, "sell-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" } }, "statistic-settings": { - "risk-free-rate": 0.03 + "risk-free-rate": "0.03" }, "gocryptotrader-config-path": "" } \ No newline at end of file diff --git a/backtester/config/examples/dca-csv-candles.strat b/backtester/config/examples/dca-csv-candles.strat index 8bff4f2d..c8a97cdb 100644 --- a/backtester/config/examples/dca-csv-candles.strat +++ b/backtester/config/examples/dca-csv-candles.strat @@ -1,10 +1,10 @@ { - "nickname": "TestGenerateConfigForDCACSVCandles", + "nickname": "ExampleStrategyDCACSVCandles", "goal": "To demonstrate the DCA strategy using CSV candle data", "strategy-settings": { "name": "dollarcostaverage", "use-simultaneous-signal-processing": false, - "custom-settings": null + "use-exchange-level-funding": false }, "currency-settings": [ { @@ -12,28 +12,29 @@ "asset": "spot", "base": "BTC", "quote": "USDT", - "initial-funds": 100000, + "initial-quote-funds": "100000", "leverage": { "can-use-leverage": false, - "maximum-orders-with-leverage-ratio": 0, - "maximum-leverage-rate": 0 + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" }, "buy-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, "sell-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, - "min-slippage-percent": 0, - "max-slippage-percent": 0, - "maker-fee-override": 0.001, - "taker-fee-override": 0.002, - "maximum-holdings-ratio": 0, - "use-exchange-order-limits": false + "min-slippage-percent": "0", + "max-slippage-percent": "0", + "maker-fee-override": "0.001", + "taker-fee-override": "0.002", + "maximum-holdings-ratio": "0", + "use-exchange-order-limits": false, + "skip-candle-volume-fitting": false } ], "data-settings": { @@ -46,22 +47,22 @@ "portfolio-settings": { "leverage": { "can-use-leverage": false, - "maximum-orders-with-leverage-ratio": 0, - "maximum-leverage-rate": 0 + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" }, "buy-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, "sell-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" } }, "statistic-settings": { - "risk-free-rate": 0.03 + "risk-free-rate": "0.03" }, "gocryptotrader-config-path": "" } \ No newline at end of file diff --git a/backtester/config/examples/dca-csv-trades.strat b/backtester/config/examples/dca-csv-trades.strat index 1f019916..db62de39 100644 --- a/backtester/config/examples/dca-csv-trades.strat +++ b/backtester/config/examples/dca-csv-trades.strat @@ -1,10 +1,10 @@ { - "nickname": "TestGenerateConfigForDCACSVTrades", + "nickname": "ExampleStrategyDCACSVTrades", "goal": "To demonstrate the DCA strategy using CSV trade data", "strategy-settings": { "name": "dollarcostaverage", "use-simultaneous-signal-processing": false, - "custom-settings": null + "use-exchange-level-funding": false }, "currency-settings": [ { @@ -12,28 +12,29 @@ "asset": "spot", "base": "BTC", "quote": "USDT", - "initial-funds": 100000, + "initial-quote-funds": "100000", "leverage": { "can-use-leverage": false, - "maximum-orders-with-leverage-ratio": 0, - "maximum-leverage-rate": 0 + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" }, "buy-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0", + "maximum-size": "0", + "maximum-total": "0" }, "sell-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0", + "maximum-size": "0", + "maximum-total": "0" }, - "min-slippage-percent": 0, - "max-slippage-percent": 0, - "maker-fee-override": 0.001, - "taker-fee-override": 0.002, - "maximum-holdings-ratio": 0, - "use-exchange-order-limits": false + "min-slippage-percent": "0", + "max-slippage-percent": "0", + "maker-fee-override": "0.001", + "taker-fee-override": "0.002", + "maximum-holdings-ratio": "0", + "use-exchange-order-limits": false, + "skip-candle-volume-fitting": false } ], "data-settings": { @@ -46,22 +47,22 @@ "portfolio-settings": { "leverage": { "can-use-leverage": false, - "maximum-orders-with-leverage-ratio": 0, - "maximum-leverage-rate": 0 + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" }, "buy-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0", + "maximum-size": "0", + "maximum-total": "0" }, "sell-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0", + "maximum-size": "0", + "maximum-total": "0" } }, "statistic-settings": { - "risk-free-rate": 0.03 + "risk-free-rate": "0.03" }, "gocryptotrader-config-path": "" } \ No newline at end of file diff --git a/backtester/config/examples/dca-database-candles.strat b/backtester/config/examples/dca-database-candles.strat index 05a29487..c18be601 100644 --- a/backtester/config/examples/dca-database-candles.strat +++ b/backtester/config/examples/dca-database-candles.strat @@ -1,10 +1,10 @@ { - "nickname": "TestGenerateConfigForDCADatabaseCandles", + "nickname": "ExampleStrategyDCADatabaseCandles", "goal": "To demonstrate the DCA strategy using database candle data", "strategy-settings": { "name": "dollarcostaverage", "use-simultaneous-signal-processing": false, - "custom-settings": null + "use-exchange-level-funding": false }, "currency-settings": [ { @@ -12,35 +12,36 @@ "asset": "spot", "base": "BTC", "quote": "USDT", - "initial-funds": 100000, + "initial-quote-funds": "100000", "leverage": { "can-use-leverage": false, - "maximum-orders-with-leverage-ratio": 0, - "maximum-leverage-rate": 0 + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" }, "buy-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, "sell-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, - "min-slippage-percent": 0, - "max-slippage-percent": 0, - "maker-fee-override": 0.001, - "taker-fee-override": 0.002, - "maximum-holdings-ratio": 0, - "use-exchange-order-limits": false + "min-slippage-percent": "0", + "max-slippage-percent": "0", + "maker-fee-override": "0.001", + "taker-fee-override": "0.002", + "maximum-holdings-ratio": "0", + "use-exchange-order-limits": false, + "skip-candle-volume-fitting": false } ], "data-settings": { "interval": 86400000000000, "data-type": "candle", "database-data": { - "start-date": "2020-11-01T00:00:00+11:00", + "start-date": "2020-08-01T00:00:00+10:00", "end-date": "2020-12-01T00:00:00+11:00", "config-override": { "enabled": true, @@ -61,22 +62,22 @@ "portfolio-settings": { "leverage": { "can-use-leverage": false, - "maximum-orders-with-leverage-ratio": 0, - "maximum-leverage-rate": 0 + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" }, "buy-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, "sell-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" } }, "statistic-settings": { - "risk-free-rate": 0.03 + "risk-free-rate": "0.03" }, "gocryptotrader-config-path": "" } \ No newline at end of file diff --git a/backtester/config/examples/rsi-api-candles.strat b/backtester/config/examples/rsi-api-candles.strat index 4ab686c4..39e45488 100644 --- a/backtester/config/examples/rsi-api-candles.strat +++ b/backtester/config/examples/rsi-api-candles.strat @@ -4,6 +4,7 @@ "strategy-settings": { "name": "rsi", "use-simultaneous-signal-processing": false, + "use-exchange-level-funding": false, "custom-settings": { "rsi-high": 70, "rsi-low": 30, @@ -16,63 +17,66 @@ "asset": "spot", "base": "BTC", "quote": "USDT", - "initial-funds": 1000000, + "initial-quote-funds": "100000", "leverage": { "can-use-leverage": false, - "maximum-orders-with-leverage-ratio": 0, - "maximum-leverage-rate": 0 + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" }, "buy-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, "sell-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, - "min-slippage-percent": 0, - "max-slippage-percent": 0, - "maker-fee-override": 0.001, - "taker-fee-override": 0.002, - "maximum-holdings-ratio": 0, - "use-exchange-order-limits": false + "min-slippage-percent": "0", + "max-slippage-percent": "0", + "maker-fee-override": "0.001", + "taker-fee-override": "0.002", + "maximum-holdings-ratio": "0", + "use-exchange-order-limits": false, + "skip-candle-volume-fitting": false }, { "exchange-name": "binance", "asset": "spot", "base": "ETH", "quote": "USDT", - "initial-funds": 100000, + "initial-base-funds": "10", + "initial-quote-funds": "1000000", "leverage": { "can-use-leverage": false, - "maximum-orders-with-leverage-ratio": 0, - "maximum-leverage-rate": 0 + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" }, "buy-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, "sell-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, - "min-slippage-percent": 0, - "max-slippage-percent": 0, - "maker-fee-override": 0.001, - "taker-fee-override": 0.002, - "maximum-holdings-ratio": 0, - "use-exchange-order-limits": false + "min-slippage-percent": "0", + "max-slippage-percent": "0", + "maker-fee-override": "0.001", + "taker-fee-override": "0.002", + "maximum-holdings-ratio": "0", + "use-exchange-order-limits": false, + "skip-candle-volume-fitting": false } ], "data-settings": { "interval": 86400000000000, "data-type": "candle", "api-data": { - "start-date": "2020-11-01T00:00:00+11:00", + "start-date": "2020-08-01T00:00:00+10:00", "end-date": "2020-12-01T00:00:00+11:00", "inclusive-end-date": false } @@ -80,22 +84,22 @@ "portfolio-settings": { "leverage": { "can-use-leverage": false, - "maximum-orders-with-leverage-ratio": 0, - "maximum-leverage-rate": 0 + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" }, "buy-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" }, "sell-side": { - "minimum-size": 0.1, - "maximum-size": 1, - "maximum-total": 10000 + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" } }, "statistic-settings": { - "risk-free-rate": 0.03 + "risk-free-rate": "0.03" }, "gocryptotrader-config-path": "" } \ No newline at end of file diff --git a/backtester/config/examples/t2b2-api-candles-exchange-funding.strat b/backtester/config/examples/t2b2-api-candles-exchange-funding.strat new file mode 100644 index 00000000..b943344f --- /dev/null +++ b/backtester/config/examples/t2b2-api-candles-exchange-funding.strat @@ -0,0 +1,230 @@ +{ + "nickname": "ExampleStrategyTop2Bottom2", + "goal": "To demonstrate a complex strategy using exchange level funding and simultaneous processing of data signals", + "strategy-settings": { + "name": "top2bottom2", + "use-simultaneous-signal-processing": true, + "use-exchange-level-funding": true, + "exchange-level-funding": [ + { + "exchange-name": "binance", + "asset": "spot", + "currency": "BTC", + "initial-funds": "3", + "transfer-fee": "0" + }, + { + "exchange-name": "binance", + "asset": "spot", + "currency": "USDT", + "initial-funds": "10000", + "transfer-fee": "0" + } + ], + "custom-settings": { + "mfi-high": 68, + "mfi-low": 32, + "mfi-period": 14 + } + }, + "currency-settings": [ + { + "exchange-name": "binance", + "asset": "spot", + "base": "BTC", + "quote": "USDT", + "leverage": { + "can-use-leverage": false, + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" + }, + "buy-side": { + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" + }, + "sell-side": { + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" + }, + "min-slippage-percent": "0", + "max-slippage-percent": "0", + "maker-fee-override": "0.001", + "taker-fee-override": "0.002", + "maximum-holdings-ratio": "0", + "use-exchange-order-limits": false, + "skip-candle-volume-fitting": false + }, + { + "exchange-name": "binance", + "asset": "spot", + "base": "DOGE", + "quote": "USDT", + "leverage": { + "can-use-leverage": false, + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" + }, + "buy-side": { + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" + }, + "sell-side": { + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" + }, + "min-slippage-percent": "0", + "max-slippage-percent": "0", + "maker-fee-override": "0.001", + "taker-fee-override": "0.002", + "maximum-holdings-ratio": "0", + "use-exchange-order-limits": false, + "skip-candle-volume-fitting": false + }, + { + "exchange-name": "binance", + "asset": "spot", + "base": "ETH", + "quote": "BTC", + "leverage": { + "can-use-leverage": false, + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" + }, + "buy-side": { + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" + }, + "sell-side": { + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" + }, + "min-slippage-percent": "0", + "max-slippage-percent": "0", + "maker-fee-override": "0.001", + "taker-fee-override": "0.002", + "maximum-holdings-ratio": "0", + "use-exchange-order-limits": false, + "skip-candle-volume-fitting": false + }, + { + "exchange-name": "binance", + "asset": "spot", + "base": "LTC", + "quote": "BTC", + "leverage": { + "can-use-leverage": false, + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" + }, + "buy-side": { + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" + }, + "sell-side": { + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" + }, + "min-slippage-percent": "0", + "max-slippage-percent": "0", + "maker-fee-override": "0.001", + "taker-fee-override": "0.002", + "maximum-holdings-ratio": "0", + "use-exchange-order-limits": false, + "skip-candle-volume-fitting": false + }, + { + "exchange-name": "binance", + "asset": "spot", + "base": "XRP", + "quote": "USDT", + "leverage": { + "can-use-leverage": false, + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" + }, + "buy-side": { + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" + }, + "sell-side": { + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" + }, + "min-slippage-percent": "0", + "max-slippage-percent": "0", + "maker-fee-override": "0.001", + "taker-fee-override": "0.002", + "maximum-holdings-ratio": "0", + "use-exchange-order-limits": false, + "skip-candle-volume-fitting": false + }, + { + "exchange-name": "binance", + "asset": "spot", + "base": "BNB", + "quote": "BTC", + "leverage": { + "can-use-leverage": false, + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" + }, + "buy-side": { + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" + }, + "sell-side": { + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" + }, + "min-slippage-percent": "0", + "max-slippage-percent": "0", + "maker-fee-override": "0.001", + "taker-fee-override": "0.002", + "maximum-holdings-ratio": "0", + "use-exchange-order-limits": false, + "skip-candle-volume-fitting": false + } + ], + "data-settings": { + "interval": 86400000000000, + "data-type": "candle", + "api-data": { + "start-date": "2020-08-01T00:00:00+10:00", + "end-date": "2020-12-01T00:00:00+11:00", + "inclusive-end-date": false + } + }, + "portfolio-settings": { + "leverage": { + "can-use-leverage": false, + "maximum-orders-with-leverage-ratio": "0", + "maximum-leverage-rate": "0" + }, + "buy-side": { + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" + }, + "sell-side": { + "minimum-size": "0.005", + "maximum-size": "2", + "maximum-total": "40000" + } + }, + "statistic-settings": { + "risk-free-rate": "0.03" + }, + "gocryptotrader-config-path": "" +} \ No newline at end of file diff --git a/backtester/data/data_test.go b/backtester/data/data_test.go index 176dd831..ab76211d 100644 --- a/backtester/data/data_test.go +++ b/backtester/data/data_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/exchanges/asset" "github.com/thrasher-corp/gocryptotrader/exchanges/kline" @@ -211,18 +212,18 @@ func (t fakeDataHandler) GetReason() string { func (t fakeDataHandler) AppendReason(string) { } -func (t fakeDataHandler) ClosePrice() float64 { - return 0 +func (t fakeDataHandler) ClosePrice() decimal.Decimal { + return decimal.Zero } -func (t fakeDataHandler) HighPrice() float64 { - return 0 +func (t fakeDataHandler) HighPrice() decimal.Decimal { + return decimal.Zero } -func (t fakeDataHandler) LowPrice() float64 { - return 0 +func (t fakeDataHandler) LowPrice() decimal.Decimal { + return decimal.Zero } -func (t fakeDataHandler) OpenPrice() float64 { - return 0 +func (t fakeDataHandler) OpenPrice() decimal.Decimal { + return decimal.Zero } diff --git a/backtester/data/data_types.go b/backtester/data/data_types.go index 459c3de2..9ff5a139 100644 --- a/backtester/data/data_types.go +++ b/backtester/data/data_types.go @@ -3,6 +3,7 @@ package data 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" @@ -51,11 +52,11 @@ type Streamer interface { List() []common.DataEventHandler Offset() int - StreamOpen() []float64 - StreamHigh() []float64 - StreamLow() []float64 - StreamClose() []float64 - StreamVol() []float64 + StreamOpen() []decimal.Decimal + StreamHigh() []decimal.Decimal + StreamLow() []decimal.Decimal + StreamClose() []decimal.Decimal + StreamVol() []decimal.Decimal HasDataAtTime(time.Time) bool } diff --git a/backtester/data/kline/api/api_test.go b/backtester/data/kline/api/api_test.go index 0576aeb8..b78f5fdc 100644 --- a/backtester/data/kline/api/api_test.go +++ b/backtester/data/kline/api/api_test.go @@ -50,7 +50,7 @@ func TestLoadCandles(t *testing.T) { _, err = LoadData(context.Background(), -1, tt1, tt2, interval.Duration(), exch, cp, a) if !errors.Is(err, common.ErrInvalidDataType) { - t.Errorf("expected '%v' received '%v'", err, common.ErrInvalidDataType) + t.Errorf("received: %v, expected: %v", err, common.ErrInvalidDataType) } } diff --git a/backtester/data/kline/csv/csv_test.go b/backtester/data/kline/csv/csv_test.go index 02b2728a..2a5607b6 100644 --- a/backtester/data/kline/csv/csv_test.go +++ b/backtester/data/kline/csv/csv_test.go @@ -57,6 +57,6 @@ func TestLoadDataInvalid(t *testing.T) { p, a) if !errors.Is(err, common.ErrInvalidDataType) { - t.Errorf("expected '%v' received '%v'", err, common.ErrInvalidDataType) + t.Errorf("received: %v, expected: %v", err, common.ErrInvalidDataType) } } diff --git a/backtester/data/kline/database/database_test.go b/backtester/data/kline/database/database_test.go index a7311ce3..1b54d931 100644 --- a/backtester/data/kline/database/database_test.go +++ b/backtester/data/kline/database/database_test.go @@ -212,6 +212,6 @@ func TestLoadDataInvalid(t *testing.T) { dEnd := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) _, err := LoadData(dStart, dEnd, gctkline.FifteenMin.Duration(), exch, -1, p, a) if !errors.Is(err, common.ErrInvalidDataType) { - t.Errorf("expected '%v' received '%v'", err, common.ErrInvalidDataType) + t.Errorf("received: %v, expected: %v", err, common.ErrInvalidDataType) } } diff --git a/backtester/data/kline/kline.go b/backtester/data/kline/kline.go index fc9b025e..a0908163 100644 --- a/backtester/data/kline/kline.go +++ b/backtester/data/kline/kline.go @@ -3,6 +3,7 @@ package kline import ( "time" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/common" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/event" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/kline" @@ -13,10 +14,10 @@ import ( // HasDataAtTime verifies checks the underlying range data // To determine whether there is any candle data present at the time provided func (d *DataFromKline) HasDataAtTime(t time.Time) bool { - if d.Range == nil { + if d.RangeHolder == nil { return false } - return d.Range.HasDataAtDate(t) + return d.RangeHolder.HasDataAtDate(t) } // Load sets the candle data to the stream for processing @@ -37,11 +38,11 @@ func (d *DataFromKline) Load() error { CurrencyPair: d.Item.Pair, AssetType: d.Item.Asset, }, - Open: d.Item.Candles[i].Open, - High: d.Item.Candles[i].High, - Low: d.Item.Candles[i].Low, - Close: d.Item.Candles[i].Close, - Volume: d.Item.Candles[i].Volume, + Open: decimal.NewFromFloat(d.Item.Candles[i].Open), + High: decimal.NewFromFloat(d.Item.Candles[i].High), + Low: decimal.NewFromFloat(d.Item.Candles[i].Low), + Close: decimal.NewFromFloat(d.Item.Candles[i].Close), + Volume: decimal.NewFromFloat(d.Item.Candles[i].Volume), ValidationIssues: d.Item.Candles[i].ValidationIssues, } d.addedTimes[d.Item.Candles[i].Time] = true @@ -51,8 +52,8 @@ func (d *DataFromKline) Load() error { return nil } -// Append adds a candle item to the data stream and sorts it to ensure it is all in order -func (d *DataFromKline) Append(ki *gctkline.Item) { +// AppendResults adds a candle item to the data stream and sorts it to ensure it is all in order +func (d *DataFromKline) AppendResults(ki *gctkline.Item) { if d.addedTimes == nil { d.addedTimes = make(map[time.Time]bool) } @@ -76,76 +77,101 @@ func (d *DataFromKline) Append(ki *gctkline.Item) { CurrencyPair: ki.Pair, AssetType: ki.Asset, }, - Open: gctCandles[i].Open, - High: gctCandles[i].High, - Low: gctCandles[i].Low, - Close: gctCandles[i].Close, - Volume: gctCandles[i].Volume, + Open: decimal.NewFromFloat(gctCandles[i].Open), + High: decimal.NewFromFloat(gctCandles[i].High), + Low: decimal.NewFromFloat(gctCandles[i].Low), + Close: decimal.NewFromFloat(gctCandles[i].Close), + Volume: decimal.NewFromFloat(gctCandles[i].Volume), ValidationIssues: gctCandles[i].ValidationIssues, }) candleTimes = append(candleTimes, gctCandles[i].Time) } + for i := range d.RangeHolder.Ranges { + for j := range d.RangeHolder.Ranges[i].Intervals { + d.RangeHolder.Ranges[i].Intervals[j].HasData = true + } + } log.Debugf(log.BackTester, "appending %v candle intervals: %v", len(gctCandles), candleTimes) d.AppendStream(klineData...) d.SortStream() } // StreamOpen returns all Open prices from the beginning until the current iteration -func (d *DataFromKline) StreamOpen() []float64 { +func (d *DataFromKline) StreamOpen() []decimal.Decimal { s := d.GetStream() o := d.Offset() - ret := make([]float64, o) + ret := make([]decimal.Decimal, o) for x := range s[:o] { - ret[x] = s[x].(*kline.Kline).Open + if val, ok := s[x].(*kline.Kline); ok { + ret[x] = val.Open + } else { + log.Errorf(log.BackTester, "incorrect data loaded into stream") + } } return ret } // StreamHigh returns all High prices from the beginning until the current iteration -func (d *DataFromKline) StreamHigh() []float64 { +func (d *DataFromKline) StreamHigh() []decimal.Decimal { s := d.GetStream() o := d.Offset() - ret := make([]float64, o) + ret := make([]decimal.Decimal, o) for x := range s[:o] { - ret[x] = s[x].(*kline.Kline).High + if val, ok := s[x].(*kline.Kline); ok { + ret[x] = val.High + } else { + log.Errorf(log.BackTester, "incorrect data loaded into stream") + } } return ret } // StreamLow returns all Low prices from the beginning until the current iteration -func (d *DataFromKline) StreamLow() []float64 { +func (d *DataFromKline) StreamLow() []decimal.Decimal { s := d.GetStream() o := d.Offset() - ret := make([]float64, o) + ret := make([]decimal.Decimal, o) for x := range s[:o] { - ret[x] = s[x].(*kline.Kline).Low + if val, ok := s[x].(*kline.Kline); ok { + ret[x] = val.Low + } else { + log.Errorf(log.BackTester, "incorrect data loaded into stream") + } } return ret } // StreamClose returns all Close prices from the beginning until the current iteration -func (d *DataFromKline) StreamClose() []float64 { +func (d *DataFromKline) StreamClose() []decimal.Decimal { s := d.GetStream() o := d.Offset() - ret := make([]float64, o) + ret := make([]decimal.Decimal, o) for x := range s[:o] { - ret[x] = s[x].(*kline.Kline).Close + if val, ok := s[x].(*kline.Kline); ok { + ret[x] = val.Close + } else { + log.Errorf(log.BackTester, "incorrect data loaded into stream") + } } return ret } // StreamVol returns all Volume prices from the beginning until the current iteration -func (d *DataFromKline) StreamVol() []float64 { +func (d *DataFromKline) StreamVol() []decimal.Decimal { s := d.GetStream() o := d.Offset() - ret := make([]float64, o) + ret := make([]decimal.Decimal, o) for x := range s[:o] { - ret[x] = s[x].(*kline.Kline).Volume + if val, ok := s[x].(*kline.Kline); ok { + ret[x] = val.Volume + } else { + log.Errorf(log.BackTester, "incorrect data loaded into stream") + } } return ret } diff --git a/backtester/data/kline/kline_test.go b/backtester/data/kline/kline_test.go index a6948d05..6489857a 100644 --- a/backtester/data/kline/kline_test.go +++ b/backtester/data/kline/kline_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/common" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/event" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/kline" @@ -15,6 +16,8 @@ import ( const testExchange = "binance" +var elite = decimal.NewFromInt(1337) + func TestLoad(t *testing.T) { t.Parallel() exch := testExchange @@ -24,7 +27,7 @@ func TestLoad(t *testing.T) { d := DataFromKline{} err := d.Load() if !errors.Is(err, errNoCandleData) { - t.Errorf("expected: %v, received %v", errNoCandleData, err) + t.Errorf("received: %v, expected: %v", err, errNoCandleData) } d.Item = gctkline.Item{ Exchange: exch, @@ -92,8 +95,8 @@ func TestHasDataAtTime(t *testing.T) { if err != nil { t.Error(err) } - d.Range = ranger - d.Range.SetHasDataFromCandles(d.Item.Candles) + d.RangeHolder = ranger + d.RangeHolder.SetHasDataFromCandles(d.Item.Candles) has = d.HasDataAtTime(dInsert) if !has { t.Error("expected true") @@ -105,7 +108,9 @@ func TestAppend(t *testing.T) { exch := testExchange a := asset.Spot p := currency.NewPair(currency.BTC, currency.USDT) - d := DataFromKline{} + d := DataFromKline{ + RangeHolder: &gctkline.IntervalRangeHolder{}, + } item := gctkline.Item{ Exchange: exch, Pair: p, @@ -122,7 +127,7 @@ func TestAppend(t *testing.T) { }, }, } - d.Append(&item) + d.AppendResults(&item) } func TestStreamOpen(t *testing.T) { @@ -144,11 +149,11 @@ func TestStreamOpen(t *testing.T) { CurrencyPair: p, AssetType: a, }, - Open: 1337, - High: 1337, - Low: 1337, - Close: 1337, - Volume: 1337, + Open: elite, + High: elite, + Low: elite, + Close: elite, + Volume: elite, }, }) d.Next() @@ -177,11 +182,11 @@ func TestStreamVolume(t *testing.T) { CurrencyPair: p, AssetType: a, }, - Open: 1337, - High: 1337, - Low: 1337, - Close: 1337, - Volume: 1337, + Open: elite, + High: elite, + Low: elite, + Close: elite, + Volume: elite, }, }) d.Next() @@ -210,11 +215,11 @@ func TestStreamClose(t *testing.T) { CurrencyPair: p, AssetType: a, }, - Open: 1337, - High: 1337, - Low: 1337, - Close: 1337, - Volume: 1337, + Open: elite, + High: elite, + Low: elite, + Close: elite, + Volume: elite, }, }) d.Next() @@ -243,11 +248,11 @@ func TestStreamHigh(t *testing.T) { CurrencyPair: p, AssetType: a, }, - Open: 1337, - High: 1337, - Low: 1337, - Close: 1337, - Volume: 1337, + Open: elite, + High: elite, + Low: elite, + Close: elite, + Volume: elite, }, }) d.Next() @@ -262,7 +267,9 @@ func TestStreamLow(t *testing.T) { exch := testExchange a := asset.Spot p := currency.NewPair(currency.BTC, currency.USDT) - d := DataFromKline{} + d := DataFromKline{ + RangeHolder: &gctkline.IntervalRangeHolder{}, + } bad := d.StreamLow() if len(bad) > 0 { t.Error("expected no stream") @@ -276,11 +283,11 @@ func TestStreamLow(t *testing.T) { CurrencyPair: p, AssetType: a, }, - Open: 1337, - High: 1337, - Low: 1337, - Close: 1337, - Volume: 1337, + Open: elite, + High: elite, + Low: elite, + Close: elite, + Volume: elite, }, }) d.Next() diff --git a/backtester/data/kline/kline_types.go b/backtester/data/kline/kline_types.go index 5a3c346e..d44d395f 100644 --- a/backtester/data/kline/kline_types.go +++ b/backtester/data/kline/kline_types.go @@ -13,9 +13,8 @@ var errNoCandleData = errors.New("no candle data provided") // DataFromKline is a struct which implements the data.Streamer interface // It holds candle data for a specified range with helper functions type DataFromKline struct { - Item gctkline.Item data.Base - Range *gctkline.IntervalRangeHolder - - addedTimes map[time.Time]bool + addedTimes map[time.Time]bool + Item gctkline.Item + RangeHolder *gctkline.IntervalRangeHolder } diff --git a/backtester/data/kline/live/live_test.go b/backtester/data/kline/live/live_test.go index a8434089..c3e7fdb3 100644 --- a/backtester/data/kline/live/live_test.go +++ b/backtester/data/kline/live/live_test.go @@ -47,7 +47,7 @@ func TestLoadCandles(t *testing.T) { } _, err = LoadData(context.Background(), exch, -1, interval.Duration(), cp1, a) if !errors.Is(err, common.ErrInvalidDataType) { - t.Errorf("expected '%v' received '%v'", err, common.ErrInvalidDataType) + t.Errorf("received: %v, expected: %v", err, common.ErrInvalidDataType) } } diff --git a/backtester/eventhandlers/eventholder/eventholder_types.go b/backtester/eventhandlers/eventholder/eventholder_types.go index b6572126..6599464f 100644 --- a/backtester/eventhandlers/eventholder/eventholder_types.go +++ b/backtester/eventhandlers/eventholder/eventholder_types.go @@ -13,5 +13,5 @@ type Holder struct { type EventHolder interface { Reset() AppendEvent(common.EventHandler) - NextEvent() (e common.EventHandler) + NextEvent() common.EventHandler } diff --git a/backtester/eventhandlers/exchange/exchange.go b/backtester/eventhandlers/exchange/exchange.go index 12a6320b..0377fe55 100644 --- a/backtester/eventhandlers/exchange/exchange.go +++ b/backtester/eventhandlers/exchange/exchange.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/gofrs/uuid" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/common" "github.com/thrasher-corp/gocryptotrader/backtester/config" "github.com/thrasher-corp/gocryptotrader/backtester/data" @@ -12,6 +13,7 @@ import ( "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/event" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/fill" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/order" + "github.com/thrasher-corp/gocryptotrader/backtester/funding" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/engine" "github.com/thrasher-corp/gocryptotrader/exchanges/asset" @@ -26,7 +28,7 @@ func (e *Exchange) Reset() { // ExecuteOrder assesses the portfolio manager's order event and if it passes validation // will send an order to the exchange/fake order manager to be stored and raise a fill event -func (e *Exchange) ExecuteOrder(o order.Event, data data.Handler, bot *engine.Engine) (*fill.Fill, error) { +func (e *Exchange) ExecuteOrder(o order.Event, data data.Handler, bot *engine.Engine, funds funding.IPairReleaser) (*fill.Fill, error) { f := &fill.Fill{ Base: event.Base{ Offset: o.GetOffset(), @@ -41,7 +43,7 @@ func (e *Exchange) ExecuteOrder(o order.Event, data data.Handler, bot *engine.En Amount: o.GetAmount(), ClosePrice: data.Latest().ClosePrice(), } - + eventFunds := o.GetAllocatedFunds() cs, err := e.GetCurrencySettings(o.GetExchange(), o.GetAssetType(), o.Pair()) if err != nil { return f, err @@ -59,7 +61,7 @@ func (e *Exchange) ExecuteOrder(o order.Event, data data.Handler, bot *engine.En volStr := data.StreamVol() volume := volStr[len(volStr)-1] - var adjustedPrice, amount float64 + var adjustedPrice, amount decimal.Decimal if cs.UseRealOrders { // get current orderbook @@ -69,8 +71,8 @@ func (e *Exchange) ExecuteOrder(o order.Event, data data.Handler, bot *engine.En return f, err } // calculate an estimated slippage rate - adjustedPrice, amount = slippage.CalculateSlippageByOrderbook(ob, o.GetDirection(), o.GetFunds(), f.ExchangeFee) - f.Slippage = ((adjustedPrice - f.ClosePrice) / f.ClosePrice) * 100 + adjustedPrice, amount = slippage.CalculateSlippageByOrderbook(ob, o.GetDirection(), eventFunds, f.ExchangeFee) + f.Slippage = adjustedPrice.Sub(f.ClosePrice).Div(f.ClosePrice).Mul(decimal.NewFromInt(100)) } else { adjustedPrice, amount, err = e.sizeOfflineOrder(high, low, volume, &cs, f) if err != nil { @@ -87,30 +89,34 @@ func (e *Exchange) ExecuteOrder(o order.Event, data data.Handler, bot *engine.En } } - portfolioLimitedAmount := reduceAmountToFitPortfolioLimit(adjustedPrice, amount, o.GetFunds(), f.GetDirection()) - if portfolioLimitedAmount != amount { - f.AppendReason(fmt.Sprintf("Order size shrunk from %f to %f to remain within portfolio limits", amount, portfolioLimitedAmount)) + portfolioLimitedAmount := reduceAmountToFitPortfolioLimit(adjustedPrice, amount, eventFunds, f.GetDirection()) + if !portfolioLimitedAmount.Equal(amount) { + f.AppendReason(fmt.Sprintf("Order size shrunk from %v to %v to remain within portfolio limits", amount, portfolioLimitedAmount)) } limitReducedAmount := portfolioLimitedAmount if cs.CanUseExchangeLimits { // Conforms the amount to the exchange order defined step amount // reducing it when needed - limitReducedAmount = cs.Limits.ConformToAmount(portfolioLimitedAmount) - if limitReducedAmount != portfolioLimitedAmount { - f.AppendReason(fmt.Sprintf("Order size shrunk from %f to %f to remain within exchange step amount limits", + limitReducedAmount = cs.Limits.ConformToDecimalAmount(portfolioLimitedAmount) + if !limitReducedAmount.Equal(portfolioLimitedAmount) { + f.AppendReason(fmt.Sprintf("Order size shrunk from %v to %v to remain within exchange step amount limits", portfolioLimitedAmount, limitReducedAmount)) } } - err = verifyOrderWithinLimits(f, limitReducedAmount, &cs) if err != nil { return f, err } + f.ExchangeFee = calculateExchangeFee(adjustedPrice, limitReducedAmount, cs.ExchangeFee) orderID, err := e.placeOrder(context.TODO(), adjustedPrice, limitReducedAmount, cs.UseRealOrders, cs.CanUseExchangeLimits, f, bot) if err != nil { + fundErr := funds.Release(eventFunds, eventFunds, f.GetDirection()) + if fundErr != nil { + f.AppendReason(fundErr.Error()) + } if f.GetDirection() == gctorder.Buy { f.SetDirection(common.CouldNotBuy) } else if f.GetDirection() == gctorder.Sell { @@ -118,6 +124,20 @@ func (e *Exchange) ExecuteOrder(o order.Event, data data.Handler, bot *engine.En } return f, err } + switch f.GetDirection() { + case gctorder.Buy: + err = funds.Release(eventFunds, eventFunds.Sub(limitReducedAmount.Mul(adjustedPrice)), f.GetDirection()) + if err != nil { + return f, err + } + funds.IncreaseAvailable(limitReducedAmount, f.GetDirection()) + case gctorder.Sell: + err = funds.Release(eventFunds, eventFunds.Sub(limitReducedAmount), f.GetDirection()) + if err != nil { + return f, err + } + funds.IncreaseAvailable(limitReducedAmount.Mul(adjustedPrice), f.GetDirection()) + } ords, _ := bot.OrderManager.GetOrdersSnapshot("") for i := range ords { @@ -128,8 +148,8 @@ func (e *Exchange) ExecuteOrder(o order.Event, data data.Handler, bot *engine.En ords[i].LastUpdated = o.GetTime() ords[i].CloseTime = o.GetTime() f.Order = &ords[i] - f.PurchasePrice = ords[i].Price - f.Total = (f.PurchasePrice * limitReducedAmount) + f.ExchangeFee + f.PurchasePrice = decimal.NewFromFloat(ords[i].Price) + f.Total = f.PurchasePrice.Mul(limitReducedAmount).Add(f.ExchangeFee) } if f.Order == nil { @@ -140,14 +160,14 @@ func (e *Exchange) ExecuteOrder(o order.Event, data data.Handler, bot *engine.En } // verifyOrderWithinLimits conforms the amount to fall into the minimum size and maximum size limit after reduced -func verifyOrderWithinLimits(f *fill.Fill, limitReducedAmount float64, cs *Settings) error { +func verifyOrderWithinLimits(f *fill.Fill, limitReducedAmount decimal.Decimal, cs *Settings) error { if f == nil { return common.ErrNilEvent } if cs == nil { return errNilCurrencySettings } - exceeded := false + isBeyondLimit := false var minMax config.MinMax var direction gctorder.Side switch f.GetDirection() { @@ -162,44 +182,46 @@ func verifyOrderWithinLimits(f *fill.Fill, limitReducedAmount float64, cs *Setti f.SetDirection(common.DoNothing) return fmt.Errorf("%w: %v", errInvalidDirection, direction) } - var exceededLimit string - var size float64 - if limitReducedAmount < minMax.MinimumSize && minMax.MinimumSize > 0 { - exceeded = true - exceededLimit = "minimum" + var minOrMax, belowExceed string + var size decimal.Decimal + if limitReducedAmount.LessThan(minMax.MinimumSize) && minMax.MinimumSize.GreaterThan(decimal.Zero) { + isBeyondLimit = true + belowExceed = "below" + minOrMax = "minimum" size = minMax.MinimumSize } - if limitReducedAmount > minMax.MaximumSize && minMax.MaximumSize > 0 { - exceeded = true - exceededLimit = "maximum" + if limitReducedAmount.GreaterThan(minMax.MaximumSize) && minMax.MaximumSize.GreaterThan(decimal.Zero) { + isBeyondLimit = true + belowExceed = "exceeded" + minOrMax = "maximum" size = minMax.MaximumSize } - if exceeded { + if isBeyondLimit { f.SetDirection(direction) - e := fmt.Sprintf("Order size %.8f exceeded %v size %.8f", limitReducedAmount, exceededLimit, size) + e := fmt.Sprintf("Order size %v %s %s size %v", limitReducedAmount, belowExceed, minOrMax, size) f.AppendReason(e) return fmt.Errorf("%w %v", errExceededPortfolioLimit, e) } return nil } -func reduceAmountToFitPortfolioLimit(adjustedPrice, amount, sizedPortfolioTotal float64, side gctorder.Side) float64 { +func reduceAmountToFitPortfolioLimit(adjustedPrice, amount, sizedPortfolioTotal decimal.Decimal, side gctorder.Side) decimal.Decimal { switch side { case gctorder.Buy: - if adjustedPrice*amount > sizedPortfolioTotal { + if adjustedPrice.Mul(amount).GreaterThan(sizedPortfolioTotal) { // adjusted amounts exceeds portfolio manager's allowed funds // the amount has to be reduced to equal the sizedPortfolioTotal - amount = sizedPortfolioTotal / adjustedPrice + amount = sizedPortfolioTotal.Div(adjustedPrice) } case gctorder.Sell: - if amount > sizedPortfolioTotal { + if amount.GreaterThan(sizedPortfolioTotal) { amount = sizedPortfolioTotal } } return amount } -func (e *Exchange) placeOrder(ctx context.Context, price, amount float64, useRealOrders, useExchangeLimits bool, f *fill.Fill, bot *engine.Engine) (string, error) { +func (e *Exchange) placeOrder(ctx context.Context, price, amount decimal.Decimal, useRealOrders, useExchangeLimits bool, f *fill.Fill, bot *engine.Engine) (string, error) { if f == nil { return "", common.ErrNilEvent } @@ -208,10 +230,13 @@ func (e *Exchange) placeOrder(ctx context.Context, price, amount float64, useRea return "", err } var orderID string + p, _ := price.Float64() + a, _ := amount.Float64() + fee, _ := f.ExchangeFee.Float64() o := &gctorder.Submit{ - Price: price, - Amount: amount, - Fee: f.ExchangeFee, + Price: p, + Amount: a, + Fee: fee, Exchange: f.Exchange, ID: u.String(), Side: f.Direction, @@ -231,12 +256,13 @@ func (e *Exchange) placeOrder(ctx context.Context, price, amount float64, useRea return orderID, err } } else { + rate, _ := f.Amount.Float64() submitResponse := gctorder.SubmitResponse{ IsOrderPlaced: true, OrderID: u.String(), - Rate: f.Amount, - Fee: f.ExchangeFee, - Cost: price, + Rate: rate, + Fee: fee, + Cost: p, FullyMatched: true, } resp, err := bot.OrderManager.SubmitFakeOrder(o, submitResponse, useExchangeLimits) @@ -250,33 +276,38 @@ func (e *Exchange) placeOrder(ctx context.Context, price, amount float64, useRea return orderID, nil } -func (e *Exchange) sizeOfflineOrder(high, low, volume float64, cs *Settings, f *fill.Fill) (adjustedPrice, adjustedAmount float64, err error) { +func (e *Exchange) sizeOfflineOrder(high, low, volume decimal.Decimal, cs *Settings, f *fill.Fill) (adjustedPrice, adjustedAmount decimal.Decimal, err error) { if cs == nil || f == nil { - return 0, 0, common.ErrNilArguments + return decimal.Zero, decimal.Zero, common.ErrNilArguments } // provide history and estimate volatility slippageRate := slippage.EstimateSlippagePercentage(cs.MinimumSlippageRate, cs.MaximumSlippageRate) - f.VolumeAdjustedPrice, adjustedAmount = ensureOrderFitsWithinHLV(f.ClosePrice, f.Amount, high, low, volume) - if adjustedAmount != f.Amount { - f.AppendReason(fmt.Sprintf("Order size shrunk from %f to %f to fit candle", f.Amount, adjustedAmount)) + if cs.SkipCandleVolumeFitting { + f.VolumeAdjustedPrice = f.ClosePrice + adjustedAmount = f.Amount + } else { + f.VolumeAdjustedPrice, adjustedAmount = ensureOrderFitsWithinHLV(f.ClosePrice, f.Amount, high, low, volume) + if !adjustedAmount.Equal(f.Amount) { + f.AppendReason(fmt.Sprintf("Order size shrunk from %v to %v to fit candle", f.Amount, adjustedAmount)) + } } - if adjustedAmount <= 0 && f.Amount > 0 { - return 0, 0, fmt.Errorf("amount set to 0, %w", errDataMayBeIncorrect) + if adjustedAmount.LessThanOrEqual(decimal.Zero) && f.Amount.GreaterThan(decimal.Zero) { + return decimal.Zero, decimal.Zero, fmt.Errorf("amount set to 0, %w", errDataMayBeIncorrect) } adjustedPrice = applySlippageToPrice(f.GetDirection(), f.GetVolumeAdjustedPrice(), slippageRate) - f.Slippage = (slippageRate * 100) - 100 - f.ExchangeFee = calculateExchangeFee(adjustedPrice, adjustedAmount, cs.ExchangeFee) + f.Slippage = slippageRate.Mul(decimal.NewFromInt(100)).Sub(decimal.NewFromInt(100)) + f.ExchangeFee = calculateExchangeFee(adjustedPrice, adjustedAmount, cs.TakerFee) return adjustedPrice, adjustedAmount, nil } -func applySlippageToPrice(direction gctorder.Side, price, slippageRate float64) float64 { +func applySlippageToPrice(direction gctorder.Side, price, slippageRate decimal.Decimal) decimal.Decimal { adjustedPrice := price if direction == gctorder.Buy { - adjustedPrice = price + (price * (1 - slippageRate)) + adjustedPrice = price.Add(price.Mul(decimal.NewFromInt(1).Sub(slippageRate))) } else if direction == gctorder.Sell { - adjustedPrice = price * slippageRate + adjustedPrice = price.Mul(slippageRate) } return adjustedPrice } @@ -314,31 +345,31 @@ func (e *Exchange) GetCurrencySettings(exch string, a asset.Item, cp currency.Pa return Settings{}, fmt.Errorf("no currency settings found for %v %v %v", exch, a, cp) } -func ensureOrderFitsWithinHLV(slippagePrice, amount, high, low, volume float64) (adjustedPrice, adjustedAmount float64) { +func ensureOrderFitsWithinHLV(slippagePrice, amount, high, low, volume decimal.Decimal) (adjustedPrice, adjustedAmount decimal.Decimal) { adjustedPrice = slippagePrice - if adjustedPrice < low { + if adjustedPrice.LessThan(low) { adjustedPrice = low } - if adjustedPrice > high { + if adjustedPrice.GreaterThan(high) { adjustedPrice = high } - if volume <= 0 { + if volume.LessThanOrEqual(decimal.Zero) { return adjustedPrice, adjustedAmount } - currentVolume := amount * adjustedPrice - if currentVolume > volume { + currentVolume := amount.Mul(adjustedPrice) + if currentVolume.GreaterThan(volume) { // reduce the volume to not exceed the total volume of the candle // it is slightly less than the total to still allow for the illusion // that open high low close values are valid with the remaining volume // this is very opinionated - currentVolume = volume * 0.99999999 + currentVolume = volume.Mul(decimal.NewFromFloat(0.99999999)) } // extract the amount from the adjusted volume - adjustedAmount = currentVolume / adjustedPrice + adjustedAmount = currentVolume.Div(adjustedPrice) return adjustedPrice, adjustedAmount } -func calculateExchangeFee(price, amount, fee float64) float64 { - return fee * price * amount +func calculateExchangeFee(price, amount, fee decimal.Decimal) decimal.Decimal { + return fee.Mul(price).Mul(amount) } diff --git a/backtester/eventhandlers/exchange/exchange_test.go b/backtester/eventhandlers/exchange/exchange_test.go index 27316809..ed93f2a7 100644 --- a/backtester/eventhandlers/exchange/exchange_test.go +++ b/backtester/eventhandlers/exchange/exchange_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/common" "github.com/thrasher-corp/gocryptotrader/backtester/config" "github.com/thrasher-corp/gocryptotrader/backtester/data/kline" @@ -23,6 +24,13 @@ import ( const testExchange = "binance" +type fakeFund struct{} + +func (f *fakeFund) IncreaseAvailable(decimal.Decimal, gctorder.Side) {} +func (f *fakeFund) Release(decimal.Decimal, decimal.Decimal, gctorder.Side) error { + return nil +} + func TestReset(t *testing.T) { t.Parallel() e := Exchange{ @@ -43,28 +51,26 @@ func TestSetCurrency(t *testing.T) { } cs := &Settings{ ExchangeName: testExchange, - UseRealOrders: false, - InitialFunds: 1337, + UseRealOrders: true, CurrencyPair: currency.NewPair(currency.BTC, currency.USDT), AssetType: asset.Spot, - ExchangeFee: 0, - MakerFee: 0, - TakerFee: 0, + ExchangeFee: decimal.Zero, + MakerFee: decimal.Zero, + TakerFee: decimal.Zero, BuySide: config.MinMax{}, SellSide: config.MinMax{}, Leverage: config.Leverage{}, - MinimumSlippageRate: 0, - MaximumSlippageRate: 0, + MinimumSlippageRate: decimal.Zero, + MaximumSlippageRate: decimal.Zero, } e.SetExchangeAssetCurrencySettings(testExchange, asset.Spot, currency.NewPair(currency.BTC, currency.USDT), cs) result, err := e.GetCurrencySettings(testExchange, asset.Spot, currency.NewPair(currency.BTC, currency.USDT)) if err != nil { t.Error(err) } - if result.InitialFunds != 1337 { - t.Errorf("expected 1337, received %v", result.InitialFunds) + if !result.UseRealOrders { + t.Error("expected true") } - e.SetExchangeAssetCurrencySettings(testExchange, asset.Spot, currency.NewPair(currency.BTC, currency.USDT), cs) if len(e.CurrencySettings) != 1 { t.Error("expected 1") @@ -73,31 +79,31 @@ func TestSetCurrency(t *testing.T) { func TestEnsureOrderFitsWithinHLV(t *testing.T) { t.Parallel() - adjustedPrice, adjustedAmount := ensureOrderFitsWithinHLV(123, 1, 100, 99, 100) - if adjustedAmount != 1 { + adjustedPrice, adjustedAmount := ensureOrderFitsWithinHLV(decimal.NewFromInt(123), decimal.NewFromInt(1), decimal.NewFromInt(100), decimal.NewFromInt(99), decimal.NewFromInt(100)) + if !adjustedAmount.Equal(decimal.NewFromInt(1)) { t.Error("expected 1") } - if adjustedPrice != 100 { + if !adjustedPrice.Equal(decimal.NewFromInt(100)) { t.Error("expected 100") } - adjustedPrice, adjustedAmount = ensureOrderFitsWithinHLV(123, 1, 100, 99, 80) - if adjustedAmount != 0.7999999919999999 { - t.Errorf("expected %v received %v", 0.7999999919999999, adjustedAmount) + adjustedPrice, adjustedAmount = ensureOrderFitsWithinHLV(decimal.NewFromInt(123), decimal.NewFromInt(1), decimal.NewFromInt(100), decimal.NewFromInt(99), decimal.NewFromInt(80)) + if !adjustedAmount.Equal(decimal.NewFromFloat(0.799999992)) { + t.Errorf("received: %v, expected: %v", adjustedAmount, decimal.NewFromFloat(0.799999992)) } - if adjustedPrice != 100 { + if !adjustedPrice.Equal(decimal.NewFromInt(100)) { t.Error("expected 100") } } func TestCalculateExchangeFee(t *testing.T) { t.Parallel() - fee := calculateExchangeFee(1, 1, 0.1) - if fee != 0.1 { + fee := calculateExchangeFee(decimal.NewFromInt(1), decimal.NewFromInt(1), decimal.NewFromFloat(0.1)) + if !fee.Equal(decimal.NewFromFloat(0.1)) { t.Error("expected 0.1") } - fee = calculateExchangeFee(2, 1, 0.005) - if fee != 0.01 { + fee = calculateExchangeFee(decimal.NewFromInt(2), decimal.NewFromFloat(1), decimal.NewFromFloat(0.005)) + if !fee.Equal(decimal.NewFromFloat(0.01)) { t.Error("expected 0.01") } } @@ -105,28 +111,28 @@ func TestCalculateExchangeFee(t *testing.T) { func TestSizeOrder(t *testing.T) { t.Parallel() e := Exchange{} - _, _, err := e.sizeOfflineOrder(0, 0, 0, nil, nil) + _, _, err := e.sizeOfflineOrder(decimal.Zero, decimal.Zero, decimal.Zero, nil, nil) if !errors.Is(err, common.ErrNilArguments) { t.Error(err) } cs := &Settings{} f := &fill.Fill{ - ClosePrice: 1337, - Amount: 1, + ClosePrice: decimal.NewFromInt(1337), + Amount: decimal.NewFromInt(1), } - _, _, err = e.sizeOfflineOrder(0, 0, 0, cs, f) + _, _, err = e.sizeOfflineOrder(decimal.Zero, decimal.Zero, decimal.Zero, cs, f) if !errors.Is(err, errDataMayBeIncorrect) { - t.Errorf("expected: %v, received %v", errDataMayBeIncorrect, err) + t.Errorf("received: %v, expected: %v", err, errDataMayBeIncorrect) } - var p, a float64 - p, a, err = e.sizeOfflineOrder(10, 2, 10, cs, f) + var p, a decimal.Decimal + p, a, err = e.sizeOfflineOrder(decimal.NewFromInt(10), decimal.NewFromInt(2), decimal.NewFromInt(10), cs, f) if err != nil { t.Error(err) } - if p != 10 { + if !p.Equal(decimal.NewFromInt(10)) { t.Error("expected 10") } - if a != 1 { + if !a.Equal(decimal.NewFromInt(1)) { t.Error("expected 1") } } @@ -160,30 +166,30 @@ func TestPlaceOrder(t *testing.T) { t.Error(err) } e := Exchange{} - _, err = e.placeOrder(context.Background(), 1, 1, false, true, nil, nil) + _, err = e.placeOrder(context.Background(), decimal.NewFromInt(1), decimal.NewFromInt(1), false, true, nil, nil) if !errors.Is(err, common.ErrNilEvent) { - t.Errorf("expected: %v, received %v", common.ErrNilEvent, err) + t.Errorf("received: %v, expected: %v", err, common.ErrNilEvent) } f := &fill.Fill{} - _, err = e.placeOrder(context.Background(), 1, 1, false, true, f, bot) + _, err = e.placeOrder(context.Background(), decimal.NewFromInt(1), decimal.NewFromInt(1), false, true, f, bot) if err != nil && err.Error() != "order exchange name must be specified" { t.Error(err) } f.Exchange = testExchange - _, err = e.placeOrder(context.Background(), 1, 1, false, true, f, bot) + _, err = e.placeOrder(context.Background(), decimal.NewFromInt(1), decimal.NewFromInt(1), false, true, f, bot) if !errors.Is(err, gctorder.ErrPairIsEmpty) { - t.Errorf("expected: %v, received %v", gctorder.ErrPairIsEmpty, err) + t.Errorf("received: %v, expected: %v", err, gctorder.ErrPairIsEmpty) } f.CurrencyPair = currency.NewPair(currency.BTC, currency.USDT) f.AssetType = asset.Spot f.Direction = gctorder.Buy - _, err = e.placeOrder(context.Background(), 1, 1, false, true, f, bot) + _, err = e.placeOrder(context.Background(), decimal.NewFromInt(1), decimal.NewFromInt(1), false, true, f, bot) if err != nil { t.Error(err) } - _, err = e.placeOrder(context.Background(), 1, 1, true, true, f, bot) + _, err = e.placeOrder(context.Background(), decimal.NewFromInt(1), decimal.NewFromInt(1), true, true, f, bot) if err != nil && !strings.Contains(err.Error(), "unset/default API keys") { t.Error(err) } @@ -228,17 +234,16 @@ func TestExecuteOrder(t *testing.T) { cs := Settings{ ExchangeName: testExchange, UseRealOrders: false, - InitialFunds: 1337, CurrencyPair: p, AssetType: a, - ExchangeFee: 0.01, - MakerFee: 0.01, - TakerFee: 0.01, + ExchangeFee: decimal.NewFromFloat(0.01), + MakerFee: decimal.NewFromFloat(0.01), + TakerFee: decimal.NewFromFloat(0.01), BuySide: config.MinMax{}, SellSide: config.MinMax{}, Leverage: config.Leverage{}, - MinimumSlippageRate: 0, - MaximumSlippageRate: 1, + MinimumSlippageRate: decimal.Zero, + MaximumSlippageRate: decimal.NewFromInt(1), } e := Exchange{ CurrencySettings: []Settings{cs}, @@ -251,10 +256,10 @@ func TestExecuteOrder(t *testing.T) { AssetType: a, } o := &order.Order{ - Base: ev, - Direction: gctorder.Buy, - Amount: 10, - Funds: 1337, + Base: ev, + Direction: gctorder.Buy, + Amount: decimal.NewFromInt(10), + AllocatedFunds: decimal.NewFromInt(1337), } d := &kline.DataFromKline{ @@ -278,7 +283,8 @@ func TestExecuteOrder(t *testing.T) { t.Error(err) } d.Next() - _, err = e.ExecuteOrder(o, d, bot) + + _, err = e.ExecuteOrder(o, d, bot, &fakeFund{}) if err != nil { t.Error(err) } @@ -287,7 +293,7 @@ func TestExecuteOrder(t *testing.T) { cs.CanUseExchangeLimits = true o.Direction = gctorder.Sell e.CurrencySettings = []Settings{cs} - _, err = e.ExecuteOrder(o, d, bot) + _, err = e.ExecuteOrder(o, d, bot, &fakeFund{}) if err != nil && !strings.Contains(err.Error(), "unset/default API keys") { t.Error(err) } @@ -342,23 +348,22 @@ func TestExecuteOrderBuySellSizeLimit(t *testing.T) { cs := Settings{ ExchangeName: testExchange, UseRealOrders: false, - InitialFunds: 1337, CurrencyPair: p, AssetType: a, - ExchangeFee: 0.01, - MakerFee: 0.01, - TakerFee: 0.01, + ExchangeFee: decimal.NewFromFloat(0.01), + MakerFee: decimal.NewFromFloat(0.01), + TakerFee: decimal.NewFromFloat(0.01), BuySide: config.MinMax{ - MaximumSize: 0.01, - MinimumSize: 0, + MaximumSize: decimal.NewFromFloat(0.01), + MinimumSize: decimal.Zero, }, SellSide: config.MinMax{ - MaximumSize: 0.1, - MinimumSize: 0, + MaximumSize: decimal.NewFromFloat(0.1), + MinimumSize: decimal.Zero, }, Leverage: config.Leverage{}, - MinimumSlippageRate: 0, - MaximumSlippageRate: 1, + MinimumSlippageRate: decimal.Zero, + MaximumSlippageRate: decimal.NewFromInt(1), Limits: limits, } e := Exchange{ @@ -372,10 +377,10 @@ func TestExecuteOrderBuySellSizeLimit(t *testing.T) { AssetType: a, } o := &order.Order{ - Base: ev, - Direction: gctorder.Buy, - Amount: 10, - Funds: 1337, + Base: ev, + Direction: gctorder.Buy, + Amount: decimal.NewFromInt(10), + AllocatedFunds: decimal.NewFromInt(1337), } d := &kline.DataFromKline{ @@ -399,20 +404,20 @@ func TestExecuteOrderBuySellSizeLimit(t *testing.T) { t.Error(err) } d.Next() - _, err = e.ExecuteOrder(o, d, bot) + _, err = e.ExecuteOrder(o, d, bot, &fakeFund{}) if !errors.Is(err, errExceededPortfolioLimit) { t.Errorf("received %v expected %v", err, errExceededPortfolioLimit) } o = &order.Order{ - Base: ev, - Direction: gctorder.Buy, - Amount: 10, - Funds: 1337, + Base: ev, + Direction: gctorder.Buy, + Amount: decimal.NewFromInt(10), + AllocatedFunds: decimal.NewFromInt(1337), } - cs.BuySide.MaximumSize = 0 - cs.BuySide.MinimumSize = 0.01 + cs.BuySide.MaximumSize = decimal.Zero + cs.BuySide.MinimumSize = decimal.NewFromFloat(0.01) e.CurrencySettings = []Settings{cs} - _, err = e.ExecuteOrder(o, d, bot) + _, err = e.ExecuteOrder(o, d, bot, &fakeFund{}) if err != nil && !strings.Contains(err.Error(), "exceed minimum size") { t.Error(err) } @@ -420,15 +425,15 @@ func TestExecuteOrderBuySellSizeLimit(t *testing.T) { t.Error("limitReducedAmount adjusted to 0.99999999, direction BUY, should fall in buyside {MinimumSize:0.01 MaximumSize:0 MaximumTotal:0}") } o = &order.Order{ - Base: ev, - Direction: gctorder.Sell, - Amount: 10, - Funds: 1337, + Base: ev, + Direction: gctorder.Sell, + Amount: decimal.NewFromInt(10), + AllocatedFunds: decimal.NewFromInt(1337), } - cs.SellSide.MaximumSize = 0 - cs.SellSide.MinimumSize = 0.01 + cs.SellSide.MaximumSize = decimal.Zero + cs.SellSide.MinimumSize = decimal.NewFromFloat(0.01) e.CurrencySettings = []Settings{cs} - _, err = e.ExecuteOrder(o, d, bot) + _, err = e.ExecuteOrder(o, d, bot, &fakeFund{}) if err != nil && !strings.Contains(err.Error(), "exceed minimum size") { t.Error(err) } @@ -437,33 +442,33 @@ func TestExecuteOrderBuySellSizeLimit(t *testing.T) { } o = &order.Order{ - Base: ev, - Direction: gctorder.Sell, - Amount: 0.5, - Funds: 1337, + Base: ev, + Direction: gctorder.Sell, + Amount: decimal.NewFromFloat(0.5), + AllocatedFunds: decimal.NewFromInt(1337), } - cs.SellSide.MaximumSize = 0 - cs.SellSide.MinimumSize = 1 + cs.SellSide.MaximumSize = decimal.Zero + cs.SellSide.MinimumSize = decimal.NewFromInt(1) e.CurrencySettings = []Settings{cs} - _, err = e.ExecuteOrder(o, d, bot) + _, err = e.ExecuteOrder(o, d, bot, &fakeFund{}) if !errors.Is(err, errExceededPortfolioLimit) { t.Errorf("received %v expected %v", err, errExceededPortfolioLimit) } o = &order.Order{ - Base: ev, - Direction: gctorder.Sell, - Amount: 0.02, - Funds: 0.01337, + Base: ev, + Direction: gctorder.Sell, + Amount: decimal.NewFromFloat(0.02), + AllocatedFunds: decimal.NewFromFloat(0.01337), } - cs.SellSide.MaximumSize = 0 - cs.SellSide.MinimumSize = 0.01 + cs.SellSide.MaximumSize = decimal.Zero + cs.SellSide.MinimumSize = decimal.NewFromFloat(0.01) cs.UseRealOrders = true cs.CanUseExchangeLimits = true o.Direction = gctorder.Sell e.CurrencySettings = []Settings{cs} - _, err = e.ExecuteOrder(o, d, bot) + _, err = e.ExecuteOrder(o, d, bot, &fakeFund{}) if !errors.Is(err, exchange.ErrAuthenticatedRequestWithoutCredentialsSet) { t.Errorf("received %v expected %v", err, exchange.ErrAuthenticatedRequestWithoutCredentialsSet) } @@ -471,87 +476,87 @@ func TestExecuteOrderBuySellSizeLimit(t *testing.T) { func TestApplySlippageToPrice(t *testing.T) { t.Parallel() - resp := applySlippageToPrice(gctorder.Buy, 1, 0.9) - if resp != 1.1 { - t.Errorf("expected 1.1, received %v", resp) + resp := applySlippageToPrice(gctorder.Buy, decimal.NewFromInt(1), decimal.NewFromFloat(0.9)) + if !resp.Equal(decimal.NewFromFloat(1.1)) { + t.Errorf("received: %v, expected: %v", resp, decimal.NewFromFloat(1.1)) } - resp = applySlippageToPrice(gctorder.Sell, 1, 0.9) - if resp != 0.9 { - t.Errorf("expected 0.9, received %v", resp) + resp = applySlippageToPrice(gctorder.Sell, decimal.NewFromInt(1), decimal.NewFromFloat(0.9)) + if !resp.Equal(decimal.NewFromFloat(0.9)) { + t.Errorf("received: %v, expected: %v", resp, decimal.NewFromFloat(0.9)) } } func TestReduceAmountToFitPortfolioLimit(t *testing.T) { t.Parallel() - initialPrice := 1003.37 - initialAmount := 1337 / initialPrice - portfolioAdjustedTotal := initialAmount * initialPrice - adjustedPrice := 1000.0 - amount := 2.0 + initialPrice := decimal.NewFromInt(100) + initialAmount := decimal.NewFromInt(10).Div(initialPrice) + portfolioAdjustedTotal := initialAmount.Mul(initialPrice) + adjustedPrice := decimal.NewFromInt(1000) + amount := decimal.NewFromInt(2) finalAmount := reduceAmountToFitPortfolioLimit(adjustedPrice, amount, portfolioAdjustedTotal, gctorder.Buy) - if finalAmount*adjustedPrice != portfolioAdjustedTotal { - t.Errorf("expected value %v to match portfolio total %v", finalAmount*adjustedPrice, portfolioAdjustedTotal) + if !finalAmount.Mul(adjustedPrice).Equal(portfolioAdjustedTotal) { + t.Errorf("expected value %v to match portfolio total %v", finalAmount.Mul(adjustedPrice), portfolioAdjustedTotal) } - finalAmount = reduceAmountToFitPortfolioLimit(adjustedPrice, 133333333337, portfolioAdjustedTotal, gctorder.Sell) + finalAmount = reduceAmountToFitPortfolioLimit(adjustedPrice, decimal.NewFromInt(133333333337), portfolioAdjustedTotal, gctorder.Sell) if finalAmount != portfolioAdjustedTotal { t.Errorf("expected value %v to match portfolio total %v", finalAmount, portfolioAdjustedTotal) } - finalAmount = reduceAmountToFitPortfolioLimit(adjustedPrice, 1, portfolioAdjustedTotal, gctorder.Sell) - if finalAmount != 1 { + finalAmount = reduceAmountToFitPortfolioLimit(adjustedPrice, decimal.NewFromInt(1), portfolioAdjustedTotal, gctorder.Sell) + if !finalAmount.Equal(decimal.NewFromInt(1)) { t.Errorf("expected value %v to match portfolio total %v", finalAmount, portfolioAdjustedTotal) } } func TestVerifyOrderWithinLimits(t *testing.T) { t.Parallel() - err := verifyOrderWithinLimits(nil, 0, nil) + err := verifyOrderWithinLimits(nil, decimal.Zero, nil) if !errors.Is(err, common.ErrNilEvent) { t.Errorf("received %v expected %v", err, common.ErrNilEvent) } - err = verifyOrderWithinLimits(&fill.Fill{}, 0, nil) + err = verifyOrderWithinLimits(&fill.Fill{}, decimal.Zero, nil) if !errors.Is(err, errNilCurrencySettings) { t.Errorf("received %v expected %v", err, errNilCurrencySettings) } - err = verifyOrderWithinLimits(&fill.Fill{}, 0, &Settings{}) + err = verifyOrderWithinLimits(&fill.Fill{}, decimal.Zero, &Settings{}) if !errors.Is(err, errInvalidDirection) { t.Errorf("received %v expected %v", err, errInvalidDirection) } f := &fill.Fill{ Direction: gctorder.Buy, } - err = verifyOrderWithinLimits(f, 0, &Settings{}) + err = verifyOrderWithinLimits(f, decimal.Zero, &Settings{}) if !errors.Is(err, nil) { t.Errorf("received %v expected %v", err, nil) } s := &Settings{ BuySide: config.MinMax{ - MinimumSize: 1, - MaximumSize: 1, + MinimumSize: decimal.NewFromInt(1), + MaximumSize: decimal.NewFromInt(1), }, } - err = verifyOrderWithinLimits(f, 0.5, s) + err = verifyOrderWithinLimits(f, decimal.NewFromFloat(0.5), s) if !errors.Is(err, errExceededPortfolioLimit) { t.Errorf("received %v expected %v", err, errExceededPortfolioLimit) } f.Direction = gctorder.Buy - err = verifyOrderWithinLimits(f, 2, s) + err = verifyOrderWithinLimits(f, decimal.NewFromInt(2), s) if !errors.Is(err, errExceededPortfolioLimit) { t.Errorf("received %v expected %v", err, errExceededPortfolioLimit) } f.Direction = gctorder.Sell s.SellSide = config.MinMax{ - MinimumSize: 1, - MaximumSize: 1, + MinimumSize: decimal.NewFromInt(1), + MaximumSize: decimal.NewFromInt(1), } - err = verifyOrderWithinLimits(f, 0.5, s) + err = verifyOrderWithinLimits(f, decimal.NewFromFloat(0.5), s) if !errors.Is(err, errExceededPortfolioLimit) { t.Errorf("received %v expected %v", err, errExceededPortfolioLimit) } f.Direction = gctorder.Sell - err = verifyOrderWithinLimits(f, 2, s) + err = verifyOrderWithinLimits(f, decimal.NewFromInt(2), s) if !errors.Is(err, errExceededPortfolioLimit) { t.Errorf("received %v expected %v", err, errExceededPortfolioLimit) } diff --git a/backtester/eventhandlers/exchange/exchange_types.go b/backtester/eventhandlers/exchange/exchange_types.go index cb96b1b3..bf6c0fd4 100644 --- a/backtester/eventhandlers/exchange/exchange_types.go +++ b/backtester/eventhandlers/exchange/exchange_types.go @@ -3,10 +3,12 @@ package exchange import ( "errors" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/config" "github.com/thrasher-corp/gocryptotrader/backtester/data" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/fill" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/order" + "github.com/thrasher-corp/gocryptotrader/backtester/funding" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/engine" "github.com/thrasher-corp/gocryptotrader/exchanges/asset" @@ -24,7 +26,7 @@ var ( type ExecutionHandler interface { SetExchangeAssetCurrencySettings(string, asset.Item, currency.Pair, *Settings) GetCurrencySettings(string, asset.Item, currency.Pair) (Settings, error) - ExecuteOrder(order.Event, data.Handler, *engine.Engine) (*fill.Fill, error) + ExecuteOrder(order.Event, data.Handler, *engine.Engine, funding.IPairReleaser) (*fill.Fill, error) Reset() } @@ -38,23 +40,22 @@ type Settings struct { ExchangeName string UseRealOrders bool - InitialFunds float64 - CurrencyPair currency.Pair AssetType asset.Item - ExchangeFee float64 - MakerFee float64 - TakerFee float64 + ExchangeFee decimal.Decimal + MakerFee decimal.Decimal + TakerFee decimal.Decimal BuySide config.MinMax SellSide config.MinMax Leverage config.Leverage - MinimumSlippageRate float64 - MaximumSlippageRate float64 + MinimumSlippageRate decimal.Decimal + MaximumSlippageRate decimal.Decimal - Limits *gctorder.Limits - CanUseExchangeLimits bool + Limits *gctorder.Limits + CanUseExchangeLimits bool + SkipCandleVolumeFitting bool } diff --git a/backtester/eventhandlers/exchange/slippage/slippage.go b/backtester/eventhandlers/exchange/slippage/slippage.go index 738c79bc..7e094dbd 100644 --- a/backtester/eventhandlers/exchange/slippage/slippage.go +++ b/backtester/eventhandlers/exchange/slippage/slippage.go @@ -3,36 +3,40 @@ package slippage import ( "math/rand" + "github.com/shopspring/decimal" gctorder "github.com/thrasher-corp/gocryptotrader/exchanges/order" "github.com/thrasher-corp/gocryptotrader/exchanges/orderbook" ) // EstimateSlippagePercentage takes in an int range of numbers // turns it into a percentage -func EstimateSlippagePercentage(maximumSlippageRate, minimumSlippageRate float64) float64 { - if minimumSlippageRate < 1 || minimumSlippageRate > 100 { - return 1 +func EstimateSlippagePercentage(maximumSlippageRate, minimumSlippageRate decimal.Decimal) decimal.Decimal { + if minimumSlippageRate.LessThan(decimal.NewFromInt(1)) || minimumSlippageRate.GreaterThan(decimal.NewFromInt(100)) { + return decimal.NewFromInt(1) } - if maximumSlippageRate < 1 || maximumSlippageRate > 100 { - return 1 + if maximumSlippageRate.LessThan(decimal.NewFromInt(1)) || maximumSlippageRate.GreaterThan(decimal.NewFromInt(100)) { + return decimal.NewFromInt(1) } // the language here is confusing. The maximum slippage rate is the lower bounds of the number, // eg 80 means for every dollar, keep 80% - randSeed := int(minimumSlippageRate) - int(maximumSlippageRate) + randSeed := int(minimumSlippageRate.IntPart()) - int(maximumSlippageRate.IntPart()) if randSeed > 0 { - result := float64(rand.Intn(randSeed)) // nolint:gosec // basic number generation required, no need for crypto/rand - return (result + maximumSlippageRate) / 100 + result := int64(rand.Intn(randSeed)) // nolint:gosec // basic number generation required, no need for crypto/rand + + return maximumSlippageRate.Add(decimal.NewFromInt(result)).Div(decimal.NewFromInt(100)) } - return 1 + return decimal.NewFromInt(1) } // CalculateSlippageByOrderbook will analyse a provided orderbook and return the result of attempting to // place the order on there -func CalculateSlippageByOrderbook(ob *orderbook.Base, side gctorder.Side, amountOfFunds, feeRate float64) (price, amount float64) { - result := ob.SimulateOrder(amountOfFunds, side == gctorder.Buy) +func CalculateSlippageByOrderbook(ob *orderbook.Base, side gctorder.Side, amountOfFunds, feeRate decimal.Decimal) (price, amount decimal.Decimal) { + funds, _ := amountOfFunds.Float64() + fee, _ := feeRate.Float64() + result := ob.SimulateOrder(funds, side == gctorder.Buy) rate := (result.MinimumPrice - result.MaximumPrice) / result.MaximumPrice - price = result.MinimumPrice * (rate + 1) - amount = result.Amount * (1 - feeRate) + price = decimal.NewFromFloat(result.MinimumPrice * (rate + 1)) + amount = decimal.NewFromFloat(result.Amount * (1 - fee)) return } diff --git a/backtester/eventhandlers/exchange/slippage/slippage_test.go b/backtester/eventhandlers/exchange/slippage/slippage_test.go index a3b395b9..fb7f7392 100644 --- a/backtester/eventhandlers/exchange/slippage/slippage_test.go +++ b/backtester/eventhandlers/exchange/slippage/slippage_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/exchanges/asset" "github.com/thrasher-corp/gocryptotrader/exchanges/bitstamp" @@ -12,8 +13,8 @@ import ( func TestRandomSlippage(t *testing.T) { t.Parallel() - resp := EstimateSlippagePercentage(80, 100) - if resp < 0.8 || resp > 1 { + resp := EstimateSlippagePercentage(decimal.NewFromInt(80), decimal.NewFromInt(100)) + if resp.LessThan(decimal.NewFromFloat(0.8)) || resp.GreaterThan(decimal.NewFromInt(1)) { t.Error("expected result > 0.8 and < 100") } } @@ -27,10 +28,10 @@ func TestCalculateSlippageByOrderbook(t *testing.T) { if err != nil { t.Fatal(err) } - amountOfFunds := 1000.0 - feeRate := 0.03 + amountOfFunds := decimal.NewFromInt(1000) + feeRate := decimal.NewFromFloat(0.03) price, amount := CalculateSlippageByOrderbook(ob, gctorder.Buy, amountOfFunds, feeRate) - if price*amount+(price*amount*feeRate) > amountOfFunds { + if price.Mul(amount).Add(price.Mul(amount).Mul(feeRate)).GreaterThan(amountOfFunds) { t.Error("order size must be less than funds") } } diff --git a/backtester/eventhandlers/exchange/slippage/slippage_types.go b/backtester/eventhandlers/exchange/slippage/slippage_types.go index afd9fc7f..950038c4 100644 --- a/backtester/eventhandlers/exchange/slippage/slippage_types.go +++ b/backtester/eventhandlers/exchange/slippage/slippage_types.go @@ -1,8 +1,10 @@ package slippage +import "github.com/shopspring/decimal" + // Default slippage rates. It works on a percentage basis // 100 means unaffected, 95 would mean 95% -const ( - DefaultMaximumSlippagePercent float64 = 100 - DefaultMinimumSlippagePercent float64 = 100 +var ( + DefaultMaximumSlippagePercent = decimal.NewFromInt(100) + DefaultMinimumSlippagePercent = decimal.NewFromInt(100) ) diff --git a/backtester/eventhandlers/portfolio/compliance/compliance_test.go b/backtester/eventhandlers/portfolio/compliance/compliance_test.go index 22e93b4b..6508d5b7 100644 --- a/backtester/eventhandlers/portfolio/compliance/compliance_test.go +++ b/backtester/eventhandlers/portfolio/compliance/compliance_test.go @@ -4,6 +4,8 @@ import ( "errors" "testing" "time" + + "github.com/shopspring/decimal" ) func TestAddSnapshot(t *testing.T) { @@ -12,7 +14,7 @@ func TestAddSnapshot(t *testing.T) { tt := time.Now() err := m.AddSnapshot([]SnapshotOrder{}, tt, 1, true) if !errors.Is(err, errSnapshotNotFound) { - t.Errorf("expected: %v, received %v", errSnapshotNotFound, err) + t.Errorf("received: %v, expected: %v", err, errSnapshotNotFound) } err = m.AddSnapshot([]SnapshotOrder{}, tt, 1, false) @@ -32,7 +34,7 @@ func TestGetSnapshotAtTime(t *testing.T) { tt := time.Now() err := m.AddSnapshot([]SnapshotOrder{ { - ClosePrice: 1337, + ClosePrice: decimal.NewFromInt(1337), }, }, tt, 1, false) if err != nil { @@ -46,8 +48,8 @@ func TestGetSnapshotAtTime(t *testing.T) { if len(snappySnap.Orders) == 0 { t.Fatal("expected an order") } - if snappySnap.Orders[0].ClosePrice != 1337 { - t.Error("expected 1337") + if !snappySnap.Orders[0].ClosePrice.Equal(decimal.NewFromInt(1337)) { + t.Error("expected decimal.NewFromInt(1337)") } if !snappySnap.Timestamp.Equal(tt) { t.Errorf("expected %v, received %v", tt, snappySnap.Timestamp) @@ -55,7 +57,7 @@ func TestGetSnapshotAtTime(t *testing.T) { _, err = m.GetSnapshotAtTime(time.Now().Add(time.Hour)) if !errors.Is(err, errSnapshotNotFound) { - t.Errorf("expected: %v, received %v", errSnapshotNotFound, err) + t.Errorf("received: %v, expected: %v", err, errSnapshotNotFound) } } @@ -69,7 +71,7 @@ func TestGetLatestSnapshot(t *testing.T) { tt := time.Now() err := m.AddSnapshot([]SnapshotOrder{ { - ClosePrice: 1337, + ClosePrice: decimal.NewFromInt(1337), }, }, tt, 1, false) if err != nil { @@ -77,7 +79,7 @@ func TestGetLatestSnapshot(t *testing.T) { } err = m.AddSnapshot([]SnapshotOrder{ { - ClosePrice: 1337, + ClosePrice: decimal.NewFromInt(1337), }, }, tt.Add(time.Hour), 1, false) if err != nil { diff --git a/backtester/eventhandlers/portfolio/compliance/compliance_types.go b/backtester/eventhandlers/portfolio/compliance/compliance_types.go index c6721bbc..51584ff7 100644 --- a/backtester/eventhandlers/portfolio/compliance/compliance_types.go +++ b/backtester/eventhandlers/portfolio/compliance/compliance_types.go @@ -4,6 +4,7 @@ import ( "errors" "time" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/exchanges/order" ) @@ -28,9 +29,9 @@ type Snapshot struct { // SnapshotOrder adds some additional data that's only relevant for backtesting // to the order.Detail without adding to order.Detail type SnapshotOrder struct { - ClosePrice float64 `json:"close-price"` - VolumeAdjustedPrice float64 `json:"volume-adjusted-price"` - SlippageRate float64 `json:"slippage-rate"` - CostBasis float64 `json:"cost-basis"` + ClosePrice decimal.Decimal `json:"close-price"` + VolumeAdjustedPrice decimal.Decimal `json:"volume-adjusted-price"` + SlippageRate decimal.Decimal `json:"slippage-rate"` + CostBasis decimal.Decimal `json:"cost-basis"` *order.Detail `json:"order-detail"` } diff --git a/backtester/eventhandlers/portfolio/holdings/holdings.go b/backtester/eventhandlers/portfolio/holdings/holdings.go index f50696b8..e5a23720 100644 --- a/backtester/eventhandlers/portfolio/holdings/holdings.go +++ b/backtester/eventhandlers/portfolio/holdings/holdings.go @@ -1,39 +1,41 @@ package holdings import ( + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/common" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/fill" + "github.com/thrasher-corp/gocryptotrader/backtester/funding" "github.com/thrasher-corp/gocryptotrader/exchanges/order" ) -// Create takes a fill event and creates a new holding for the exchange, asset, pair -func Create(f fill.Event, initialFunds, riskFreeRate float64) (Holding, error) { - if f == nil { +// 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, riskFreeRate decimal.Decimal) (Holding, error) { + if ev == nil { return Holding{}, common.ErrNilEvent } - if initialFunds <= 0 { + if funding.QuoteInitialFunds().LessThan(decimal.Zero) { return Holding{}, ErrInitialFundsZero } - h := Holding{ - Offset: f.GetOffset(), - Pair: f.Pair(), - Asset: f.GetAssetType(), - Exchange: f.GetExchange(), - Timestamp: f.GetTime(), - InitialFunds: initialFunds, - RemainingFunds: initialFunds, - RiskFreeRate: riskFreeRate, - } - h.update(f) - - return h, nil + return Holding{ + Offset: ev.GetOffset(), + Pair: ev.Pair(), + Asset: ev.GetAssetType(), + Exchange: ev.GetExchange(), + Timestamp: ev.GetTime(), + QuoteInitialFunds: funding.QuoteInitialFunds(), + QuoteSize: funding.QuoteInitialFunds(), + BaseInitialFunds: funding.BaseInitialFunds(), + BaseSize: funding.BaseInitialFunds(), + RiskFreeRate: riskFreeRate, + TotalInitialValue: funding.BaseInitialFunds().Mul(funding.QuoteInitialFunds()).Add(funding.QuoteInitialFunds()), + }, nil } // Update calculates holding statistics for the events time -func (h *Holding) Update(f fill.Event) { - h.Timestamp = f.GetTime() - h.Offset = f.GetOffset() - h.update(f) +func (h *Holding) Update(e fill.Event, f funding.IPairReader) { + h.Timestamp = e.GetTime() + h.Offset = e.GetOffset() + h.update(e, f) } // UpdateValue calculates the holding's value for a data event's time and price @@ -44,49 +46,58 @@ func (h *Holding) UpdateValue(d common.DataEventHandler) { h.updateValue(latest) } -func (h *Holding) update(f fill.Event) { - direction := f.GetDirection() - o := f.GetOrder() - switch direction { - case order.Buy: - h.CommittedFunds += (o.Amount * o.Price) + o.Fee - h.PositionsSize += o.Amount - h.PositionsValue += o.Amount * o.Price - h.RemainingFunds -= (o.Amount * o.Price) + o.Fee - h.TotalFees += o.Fee - h.BoughtAmount += o.Amount - h.BoughtValue += o.Amount * o.Price - case order.Sell: - h.CommittedFunds -= (o.Amount * o.Price) + o.Fee - h.PositionsSize -= o.Amount - h.PositionsValue -= o.Amount * o.Price - h.RemainingFunds += (o.Amount * o.Price) - o.Fee - h.TotalFees += o.Fee - h.SoldAmount += o.Amount - h.SoldValue += o.Amount * o.Price - case common.DoNothing, common.CouldNotSell, common.CouldNotBuy, common.MissingData, "": - } - h.TotalValueLostToVolumeSizing += (f.GetClosePrice() - f.GetVolumeAdjustedPrice()) * f.GetAmount() - h.TotalValueLostToSlippage += (f.GetVolumeAdjustedPrice() - f.GetPurchasePrice()) * f.GetAmount() - h.updateValue(f.GetClosePrice()) +// HasInvestments determines whether there are any holdings in the base funds +func (h *Holding) HasInvestments() bool { + return h.BaseSize.GreaterThan(decimal.Zero) } -func (h *Holding) updateValue(l float64) { - origPosValue := h.PositionsValue +// HasFunds determines whether there are any holdings in the quote funds +func (h *Holding) HasFunds() bool { + return h.QuoteSize.GreaterThan(decimal.Zero) +} + +func (h *Holding) update(e fill.Event, f funding.IPairReader) { + direction := e.GetDirection() + o := e.GetOrder() + if o != nil { + amount := decimal.NewFromFloat(o.Amount) + fee := decimal.NewFromFloat(o.Fee) + price := decimal.NewFromFloat(o.Price) + h.BaseSize = f.BaseAvailable() + h.QuoteSize = f.QuoteAvailable() + h.BaseValue = h.BaseSize.Mul(price) + h.TotalFees = h.TotalFees.Add(fee) + switch direction { + case order.Buy: + h.BoughtAmount = h.BoughtAmount.Add(amount) + h.BoughtValue = h.BoughtAmount.Mul(price) + case order.Sell: + h.SoldAmount = h.SoldAmount.Add(amount) + h.SoldValue = h.SoldAmount.Mul(price) + case common.DoNothing, common.CouldNotSell, common.CouldNotBuy, common.MissingData, common.TransferredFunds, "": + } + } + h.TotalValueLostToVolumeSizing = h.TotalValueLostToVolumeSizing.Add(e.GetClosePrice().Sub(e.GetVolumeAdjustedPrice()).Mul(e.GetAmount())) + h.TotalValueLostToSlippage = h.TotalValueLostToSlippage.Add(e.GetVolumeAdjustedPrice().Sub(e.GetPurchasePrice()).Mul(e.GetAmount())) + h.updateValue(e.GetClosePrice()) +} + +func (h *Holding) updateValue(latestPrice decimal.Decimal) { + origPosValue := h.BaseValue origBoughtValue := h.BoughtValue origSoldValue := h.SoldValue origTotalValue := h.TotalValue - h.PositionsValue = h.PositionsSize * l - h.BoughtValue = h.BoughtAmount * l - h.SoldValue = h.SoldAmount * l - h.TotalValue = h.PositionsValue + h.RemainingFunds + h.BaseValue = h.BaseSize.Mul(latestPrice) + h.BoughtValue = h.BoughtAmount.Mul(latestPrice) + h.SoldValue = h.SoldAmount.Mul(latestPrice) + h.TotalValue = h.BaseValue.Add(h.QuoteSize) - h.TotalValueDifference = h.TotalValue - origTotalValue - h.BoughtValueDifference = h.BoughtValue - origBoughtValue - h.PositionsValueDifference = h.PositionsValue - origPosValue - h.SoldValueDifference = h.SoldValue - origSoldValue + h.TotalValueDifference = h.TotalValue.Sub(origTotalValue) + h.BoughtValueDifference = h.BoughtValue.Sub(origBoughtValue) + h.PositionsValueDifference = h.BaseValue.Sub(origPosValue) + h.SoldValueDifference = h.SoldValue.Sub(origSoldValue) - if origTotalValue != 0 { - h.ChangeInTotalValuePercent = (h.TotalValue - origTotalValue) / origTotalValue + if !origTotalValue.IsZero() { + h.ChangeInTotalValuePercent = h.TotalValue.Sub(origTotalValue).Div(origTotalValue) } } diff --git a/backtester/eventhandlers/portfolio/holdings/holdings_test.go b/backtester/eventhandlers/portfolio/holdings/holdings_test.go index adb94928..efa96ca0 100644 --- a/backtester/eventhandlers/portfolio/holdings/holdings_test.go +++ b/backtester/eventhandlers/portfolio/holdings/holdings_test.go @@ -5,10 +5,12 @@ import ( "testing" "time" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/common" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/event" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/fill" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/kline" + "github.com/thrasher-corp/gocryptotrader/backtester/funding" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/exchanges/asset" gctkline "github.com/thrasher-corp/gocryptotrader/exchanges/kline" @@ -17,38 +19,44 @@ import ( const ( testExchange = "binance" - riskFreeRate = 0.03 ) +var ( + riskFreeRate = decimal.NewFromFloat(0.03) +) + +func pair(t *testing.T) *funding.Pair { + t.Helper() + b, err := funding.CreateItem(testExchange, asset.Spot, currency.BTC, decimal.Zero, decimal.Zero) + if err != nil { + t.Fatal(err) + } + q, err := funding.CreateItem(testExchange, asset.Spot, currency.USDT, decimal.NewFromInt(1337), decimal.Zero) + if err != nil { + t.Fatal(err) + } + p, err := funding.CreatePair(b, q) + if err != nil { + t.Fatal(err) + } + return p +} + func TestCreate(t *testing.T) { t.Parallel() - _, err := Create(nil, -1, riskFreeRate) + _, err := Create(nil, pair(t), riskFreeRate) if !errors.Is(err, common.ErrNilEvent) { - t.Errorf("expected: %v, received %v", ErrInitialFundsZero, err) + t.Errorf("received: %v, expected: %v", err, common.ErrNilEvent) } - - _, err = Create(&fill.Fill{}, -1, riskFreeRate) - if !errors.Is(err, ErrInitialFundsZero) { - t.Errorf("expected: %v, received %v", ErrInitialFundsZero, err) - } - - _, err = Create(nil, 1, riskFreeRate) - if !errors.Is(err, common.ErrNilEvent) { - t.Errorf("expected: %v, received %v", common.ErrNilEvent, err) - } - - h, err := Create(&fill.Fill{}, 1, riskFreeRate) + _, err = Create(&fill.Fill{}, pair(t), riskFreeRate) if err != nil { t.Error(err) } - if h.InitialFunds != 1 { - t.Errorf("expected 1, received '%v'", h.InitialFunds) - } } func TestUpdate(t *testing.T) { t.Parallel() - h, err := Create(&fill.Fill{}, 1, riskFreeRate) + h, err := Create(&fill.Fill{}, pair(t), riskFreeRate) if err != nil { t.Error(err) } @@ -57,7 +65,8 @@ func TestUpdate(t *testing.T) { Base: event.Base{ Time: time.Now(), }, - }) + }, pair(t)) + if t1.Equal(h.Timestamp) { t.Errorf("expected '%v' received '%v'", h.Timestamp, t1) } @@ -65,25 +74,38 @@ func TestUpdate(t *testing.T) { func TestUpdateValue(t *testing.T) { t.Parallel() - h, err := Create(&fill.Fill{}, 1, riskFreeRate) + h, err := Create(&fill.Fill{}, pair(t), riskFreeRate) if err != nil { t.Error(err) } - h.PositionsSize = 1 + h.BaseSize = decimal.NewFromInt(1) h.UpdateValue(&kline.Kline{ - Close: 1337, + Close: decimal.NewFromInt(1337), }) - if h.PositionsValue != 1337 { - t.Errorf("expected '%v' received '%v'", h.PositionsValue, 1337) + if !h.BaseValue.Equal(decimal.NewFromInt(1337)) { + t.Errorf("expected '%v' received '%v'", h.BaseSize, decimal.NewFromInt(1337)) } } func TestUpdateBuyStats(t *testing.T) { t.Parallel() - h, err := Create(&fill.Fill{}, 1000, riskFreeRate) + b, err := funding.CreateItem(testExchange, asset.Spot, currency.BTC, decimal.NewFromInt(1), decimal.Zero) + if err != nil { + t.Fatal(err) + } + q, err := funding.CreateItem(testExchange, asset.Spot, currency.USDT, decimal.NewFromInt(100), decimal.Zero) + if err != nil { + t.Fatal(err) + } + p, err := funding.CreatePair(b, q) + if err != nil { + t.Fatal(err) + } + h, err := Create(&fill.Fill{}, p, riskFreeRate) if err != nil { t.Error(err) } + h.update(&fill.Fill{ Base: event.Base{ Exchange: testExchange, @@ -93,17 +115,17 @@ func TestUpdateBuyStats(t *testing.T) { AssetType: asset.Spot, }, Direction: order.Buy, - Amount: 1, - ClosePrice: 500, - VolumeAdjustedPrice: 500, - PurchasePrice: 500, - ExchangeFee: 0, - Slippage: 0, + Amount: decimal.NewFromInt(1), + ClosePrice: decimal.NewFromInt(500), + VolumeAdjustedPrice: decimal.NewFromInt(500), + PurchasePrice: decimal.NewFromInt(500), + ExchangeFee: decimal.Zero, + Slippage: decimal.Zero, Order: &order.Detail{ Price: 500, Amount: 1, Exchange: testExchange, - ID: "1337", + ID: "decimal.NewFromInt(1337)", Type: order.Limit, Side: order.Buy, Status: order.New, @@ -115,35 +137,32 @@ func TestUpdateBuyStats(t *testing.T) { Trades: nil, Fee: 1, }, - }) + }, p) if err != nil { t.Error(err) } - if h.PositionsSize != 1 { - t.Errorf("expected '%v' received '%v'", 1, h.PositionsSize) + if !h.BaseSize.Equal(p.BaseAvailable()) { + t.Errorf("expected '%v' received '%v'", 1, h.BaseSize) } - if h.PositionsValue != 500 { - t.Errorf("expected '%v' received '%v'", 500, h.PositionsValue) + if !h.BaseValue.Equal(p.BaseAvailable().Mul(decimal.NewFromInt(500))) { + t.Errorf("expected '%v' received '%v'", 500, h.BaseValue) } - if h.InitialFunds != 1000 { - t.Errorf("expected '%v' received '%v'", 1000, h.InitialFunds) + if !h.QuoteSize.Equal(decimal.NewFromInt(100)) { + t.Errorf("expected '%v' received '%v'", 100, h.QuoteSize) } - if h.RemainingFunds != 499 { - t.Errorf("expected '%v' received '%v'", 499, h.RemainingFunds) - } - if h.TotalValue != 999 { + if !h.TotalValue.Equal(decimal.NewFromInt(600)) { t.Errorf("expected '%v' received '%v'", 999, h.TotalValue) } - if h.BoughtAmount != 1 { + if !h.BoughtAmount.Equal(decimal.NewFromInt(1)) { t.Errorf("expected '%v' received '%v'", 1, h.BoughtAmount) } - if h.BoughtValue != 500 { + if !h.BoughtValue.Equal(decimal.NewFromInt(500)) { t.Errorf("expected '%v' received '%v'", 500, h.BoughtValue) } - if h.SoldAmount != 0 { + if !h.SoldAmount.Equal(decimal.Zero) { t.Errorf("expected '%v' received '%v'", 0, h.SoldAmount) } - if h.TotalFees != 1 { + if !h.TotalFees.Equal(decimal.NewFromInt(1)) { t.Errorf("expected '%v' received '%v'", 1, h.TotalFees) } @@ -156,17 +175,17 @@ func TestUpdateBuyStats(t *testing.T) { AssetType: asset.Spot, }, Direction: order.Buy, - Amount: 0.5, - ClosePrice: 500, - VolumeAdjustedPrice: 500, - PurchasePrice: 500, - ExchangeFee: 0, - Slippage: 0, + Amount: decimal.NewFromFloat(0.5), + ClosePrice: decimal.NewFromInt(500), + VolumeAdjustedPrice: decimal.NewFromInt(500), + PurchasePrice: decimal.NewFromInt(500), + ExchangeFee: decimal.Zero, + Slippage: decimal.Zero, Order: &order.Detail{ Price: 500, Amount: 0.5, Exchange: testExchange, - ID: "1337", + ID: "decimal.NewFromInt(1337)", Type: order.Limit, Side: order.Buy, Status: order.New, @@ -178,42 +197,40 @@ func TestUpdateBuyStats(t *testing.T) { Trades: nil, Fee: 0.5, }, - }) + }, p) if err != nil { t.Error(err) } - if h.PositionsSize != 1.5 { - t.Errorf("expected '%v' received '%v'", 1, h.PositionsSize) - } - if h.PositionsValue != 750 { - t.Errorf("expected '%v' received '%v'", 750, h.PositionsValue) - } - if h.InitialFunds != 1000 { - t.Errorf("expected '%v' received '%v'", 1000, h.InitialFunds) - } - if h.RemainingFunds != 248.5 { - t.Errorf("expected '%v' received '%v'", 248.5, h.RemainingFunds) - } - if h.TotalValue != 998.5 { - t.Errorf("expected '%v' received '%v'", 998.5, h.TotalValue) - } - if h.BoughtAmount != 1.5 { + + if !h.BoughtAmount.Equal(decimal.NewFromFloat(1.5)) { t.Errorf("expected '%v' received '%v'", 1, h.BoughtAmount) } - if h.BoughtValue != 750 { + if !h.BoughtValue.Equal(decimal.NewFromInt(750)) { t.Errorf("expected '%v' received '%v'", 750, h.BoughtValue) } - if h.SoldAmount != 0 { + if !h.SoldAmount.Equal(decimal.Zero) { t.Errorf("expected '%v' received '%v'", 0, h.SoldAmount) } - if h.TotalFees != 1.5 { + if !h.TotalFees.Equal(decimal.NewFromFloat(1.5)) { t.Errorf("expected '%v' received '%v'", 1.5, h.TotalFees) } } func TestUpdateSellStats(t *testing.T) { t.Parallel() - h, err := Create(&fill.Fill{}, 1000, riskFreeRate) + b, err := funding.CreateItem(testExchange, asset.Spot, currency.BTC, decimal.NewFromInt(1), decimal.Zero) + if err != nil { + t.Fatal(err) + } + q, err := funding.CreateItem(testExchange, asset.Spot, currency.USDT, decimal.NewFromInt(100), decimal.Zero) + if err != nil { + t.Fatal(err) + } + p, err := funding.CreatePair(b, q) + if err != nil { + t.Fatal(err) + } + h, err := Create(&fill.Fill{}, p, riskFreeRate) if err != nil { t.Error(err) } @@ -226,17 +243,17 @@ func TestUpdateSellStats(t *testing.T) { AssetType: asset.Spot, }, Direction: order.Buy, - Amount: 1, - ClosePrice: 500, - VolumeAdjustedPrice: 500, - PurchasePrice: 500, - ExchangeFee: 0, - Slippage: 0, + Amount: decimal.NewFromInt(1), + ClosePrice: decimal.NewFromInt(500), + VolumeAdjustedPrice: decimal.NewFromInt(500), + PurchasePrice: decimal.NewFromInt(500), + ExchangeFee: decimal.Zero, + Slippage: decimal.Zero, Order: &order.Detail{ Price: 500, Amount: 1, Exchange: testExchange, - ID: "1337", + ID: "decimal.NewFromInt(1337)", Type: order.Limit, Side: order.Buy, Status: order.New, @@ -248,35 +265,35 @@ func TestUpdateSellStats(t *testing.T) { Trades: nil, Fee: 1, }, - }) + }, p) if err != nil { t.Error(err) } - if h.PositionsSize != 1 { - t.Errorf("expected '%v' received '%v'", 1, h.PositionsSize) + if !h.BaseSize.Equal(decimal.NewFromInt(1)) { + t.Errorf("expected '%v' received '%v'", 1, h.BaseSize) } - if h.PositionsValue != 500 { - t.Errorf("expected '%v' received '%v'", 500, h.PositionsValue) + if !h.BaseValue.Equal(decimal.NewFromInt(500)) { + t.Errorf("expected '%v' received '%v'", 500, h.BaseValue) } - if h.InitialFunds != 1000 { - t.Errorf("expected '%v' received '%v'", 1000, h.InitialFunds) + if !h.QuoteInitialFunds.Equal(decimal.NewFromInt(100)) { + t.Errorf("expected '%v' received '%v'", 100, h.QuoteInitialFunds) } - if h.RemainingFunds != 499 { - t.Errorf("expected '%v' received '%v'", 499, h.RemainingFunds) + if !h.QuoteSize.Equal(decimal.NewFromInt(100)) { + t.Errorf("expected '%v' received '%v'", 100, h.QuoteSize) } - if h.TotalValue != 999 { - t.Errorf("expected '%v' received '%v'", 999, h.TotalValue) + if !h.TotalValue.Equal(decimal.NewFromInt(600)) { + t.Errorf("expected '%v' received '%v'", 600, h.TotalValue) } - if h.BoughtAmount != 1 { + if !h.BoughtAmount.Equal(decimal.NewFromInt(1)) { t.Errorf("expected '%v' received '%v'", 1, h.BoughtAmount) } - if h.BoughtValue != 500 { + if !h.BoughtValue.Equal(decimal.NewFromInt(500)) { t.Errorf("expected '%v' received '%v'", 500, h.BoughtValue) } - if h.SoldAmount != 0 { + if !h.SoldAmount.Equal(decimal.Zero) { t.Errorf("expected '%v' received '%v'", 0, h.SoldAmount) } - if h.TotalFees != 1 { + if !h.TotalFees.Equal(decimal.NewFromInt(1)) { t.Errorf("expected '%v' received '%v'", 1, h.TotalFees) } @@ -289,17 +306,17 @@ func TestUpdateSellStats(t *testing.T) { AssetType: asset.Spot, }, Direction: order.Sell, - Amount: 1, - ClosePrice: 500, - VolumeAdjustedPrice: 500, - PurchasePrice: 500, - ExchangeFee: 0, - Slippage: 0, + Amount: decimal.NewFromInt(1), + ClosePrice: decimal.NewFromInt(500), + VolumeAdjustedPrice: decimal.NewFromInt(500), + PurchasePrice: decimal.NewFromInt(500), + ExchangeFee: decimal.Zero, + Slippage: decimal.Zero, Order: &order.Detail{ Price: 500, Amount: 1, Exchange: testExchange, - ID: "1337", + ID: "decimal.NewFromInt(1337)", Type: order.Limit, Side: order.Sell, Status: order.New, @@ -311,33 +328,18 @@ func TestUpdateSellStats(t *testing.T) { Trades: nil, Fee: 1, }, - }) + }, p) - if h.PositionsSize != 0 { - t.Errorf("expected '%v' received '%v'", 0, h.PositionsSize) - } - if h.PositionsValue != 0 { - t.Errorf("expected '%v' received '%v'", 0, h.PositionsValue) - } - if h.InitialFunds != 1000 { - t.Errorf("expected '%v' received '%v'", 1000, h.InitialFunds) - } - if h.RemainingFunds != 998 { - t.Errorf("expected '%v' received '%v'", 998, h.RemainingFunds) - } - if h.TotalValue != 998 { - t.Errorf("expected '%v' received '%v'", 998, h.TotalValue) - } - if h.BoughtAmount != 1 { + if !h.BoughtAmount.Equal(decimal.NewFromInt(1)) { t.Errorf("expected '%v' received '%v'", 1, h.BoughtAmount) } - if h.BoughtValue != 500 { + if !h.BoughtValue.Equal(decimal.NewFromInt(500)) { t.Errorf("expected '%v' received '%v'", 500, h.BoughtValue) } - if h.SoldAmount != 1 { + if !h.SoldAmount.Equal(decimal.NewFromInt(1)) { t.Errorf("expected '%v' received '%v'", 1, h.SoldAmount) } - if h.TotalFees != 2 { + if !h.TotalFees.Equal(decimal.NewFromInt(2)) { t.Errorf("expected '%v' received '%v'", 2, h.TotalFees) } } diff --git a/backtester/eventhandlers/portfolio/holdings/holdings_types.go b/backtester/eventhandlers/portfolio/holdings/holdings_types.go index 08a68a92..35cb9980 100644 --- a/backtester/eventhandlers/portfolio/holdings/holdings_types.go +++ b/backtester/eventhandlers/portfolio/holdings/holdings_types.go @@ -4,42 +4,45 @@ import ( "errors" "time" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/exchanges/asset" ) // ErrInitialFundsZero is an error when initial funds are zero or less -var ErrInitialFundsZero = errors.New("initial funds <= 0") +var ErrInitialFundsZero = errors.New("initial funds < 0") // Holding contains pricing statistics for a given time // for a given exchange asset pair type Holding struct { - Offset int64 - Pair currency.Pair `json:"pair"` - Asset asset.Item `json:"asset"` - Exchange string `json:"exchange"` - Timestamp time.Time `json:"timestamp"` - InitialFunds float64 `json:"initial-funds"` - PositionsSize float64 `json:"positions-size"` - PositionsValue float64 `json:"postions-value"` - SoldAmount float64 `json:"sold-amount"` - SoldValue float64 `json:"sold-value"` - BoughtAmount float64 `json:"bought-amount"` - BoughtValue float64 `json:"bought-value"` - RemainingFunds float64 `json:"remaining-funds"` - CommittedFunds float64 `json:"committed-funds"` + Offset int64 + Item currency.Code + Pair currency.Pair + Asset asset.Item `json:"asset"` + Exchange string `json:"exchange"` + Timestamp time.Time `json:"timestamp"` + BaseInitialFunds decimal.Decimal `json:"base-initial-funds"` + BaseSize decimal.Decimal `json:"base-size"` + BaseValue decimal.Decimal `json:"base-value"` + QuoteInitialFunds decimal.Decimal `json:"quote-initial-funds"` + TotalInitialValue decimal.Decimal `json:"total-initial-value"` + QuoteSize decimal.Decimal `json:"quote-size"` + SoldAmount decimal.Decimal `json:"sold-amount"` + SoldValue decimal.Decimal `json:"sold-value"` + BoughtAmount decimal.Decimal `json:"bought-amount"` + BoughtValue decimal.Decimal `json:"bought-value"` - TotalValueDifference float64 - ChangeInTotalValuePercent float64 - BoughtValueDifference float64 - SoldValueDifference float64 - PositionsValueDifference float64 + TotalValueDifference decimal.Decimal + ChangeInTotalValuePercent decimal.Decimal + BoughtValueDifference decimal.Decimal + SoldValueDifference decimal.Decimal + PositionsValueDifference decimal.Decimal - TotalValue float64 `json:"total-value"` - TotalFees float64 `json:"total-fees"` - TotalValueLostToVolumeSizing float64 `json:"total-value-lost-to-volume-sizing"` - TotalValueLostToSlippage float64 `json:"total-value-lost-to-slippage"` - TotalValueLost float64 `json:"total-value-lost"` + TotalValue decimal.Decimal `json:"total-value"` + TotalFees decimal.Decimal `json:"total-fees"` + TotalValueLostToVolumeSizing decimal.Decimal `json:"total-value-lost-to-volume-sizing"` + TotalValueLostToSlippage decimal.Decimal `json:"total-value-lost-to-slippage"` + TotalValueLost decimal.Decimal `json:"total-value-lost"` - RiskFreeRate float64 `json:"risk-free-rate"` + RiskFreeRate decimal.Decimal `json:"risk-free-rate"` } diff --git a/backtester/eventhandlers/portfolio/portfolio.go b/backtester/eventhandlers/portfolio/portfolio.go index 0a2cada0..27ee520a 100644 --- a/backtester/eventhandlers/portfolio/portfolio.go +++ b/backtester/eventhandlers/portfolio/portfolio.go @@ -3,9 +3,8 @@ package portfolio import ( "errors" "fmt" - "math" - "time" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/common" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/exchange" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/portfolio/compliance" @@ -16,6 +15,7 @@ import ( "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/funding" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/exchanges/asset" gctorder "github.com/thrasher-corp/gocryptotrader/exchanges/order" @@ -23,11 +23,11 @@ import ( ) // Setup creates a portfolio manager instance and sets private fields -func Setup(sh SizeHandler, r risk.Handler, riskFreeRate float64) (*Portfolio, error) { +func Setup(sh SizeHandler, r risk.Handler, riskFreeRate decimal.Decimal) (*Portfolio, error) { if sh == nil { return nil, errSizeManagerUnset } - if riskFreeRate < 0 { + if riskFreeRate.IsNegative() { return nil, errNegativeRiskFreeRate } if r == nil { @@ -50,8 +50,8 @@ func (p *Portfolio) Reset() { // on buy/sell, the portfolio manager will size the order and assess the risk of the order // if successful, it will pass on an order.Order to be used by the exchange event handler to place an order based on // the portfolio manager's recommendations -func (p *Portfolio) OnSignal(s signal.Event, cs *exchange.Settings) (*order.Order, error) { - if s == nil || cs == nil { +func (p *Portfolio) OnSignal(ev signal.Event, cs *exchange.Settings, funds funding.IPairReserver) (*order.Order, error) { + if ev == nil || cs == nil { return nil, common.ErrNilArguments } if p.sizeManager == nil { @@ -60,85 +60,67 @@ func (p *Portfolio) OnSignal(s signal.Event, cs *exchange.Settings) (*order.Orde if p.riskManager == nil { return nil, errRiskManagerUnset } + if funds == nil { + return nil, funding.ErrFundsNotFound + } o := &order.Order{ Base: event.Base{ - Offset: s.GetOffset(), - Exchange: s.GetExchange(), - Time: s.GetTime(), - CurrencyPair: s.Pair(), - AssetType: s.GetAssetType(), - Interval: s.GetInterval(), - Reason: s.GetReason(), + Offset: ev.GetOffset(), + Exchange: ev.GetExchange(), + Time: ev.GetTime(), + CurrencyPair: ev.Pair(), + AssetType: ev.GetAssetType(), + Interval: ev.GetInterval(), + Reason: ev.GetReason(), }, - Direction: s.GetDirection(), + Direction: ev.GetDirection(), } - if s.GetDirection() == "" { + if ev.GetDirection() == "" { return o, errInvalidDirection } - lookup := p.exchangeAssetPairSettings[s.GetExchange()][s.GetAssetType()][s.Pair()] + lookup := p.exchangeAssetPairSettings[ev.GetExchange()][ev.GetAssetType()][ev.Pair()] if lookup == nil { return nil, fmt.Errorf("%w for %v %v %v", errNoPortfolioSettings, - s.GetExchange(), - s.GetAssetType(), - s.Pair()) + ev.GetExchange(), + ev.GetAssetType(), + ev.Pair()) } - prevHolding := lookup.GetLatestHoldings() - if p.iteration == 0 { - prevHolding.InitialFunds = lookup.InitialFunds - prevHolding.RemainingFunds = lookup.InitialFunds - prevHolding.Exchange = s.GetExchange() - prevHolding.Pair = s.Pair() - prevHolding.Asset = s.GetAssetType() - prevHolding.Timestamp = s.GetTime() - } - p.iteration++ - if s.GetDirection() == common.DoNothing || s.GetDirection() == common.MissingData || s.GetDirection() == "" { + if ev.GetDirection() == common.DoNothing || + ev.GetDirection() == common.MissingData || + ev.GetDirection() == common.TransferredFunds || + ev.GetDirection() == "" { return o, nil } - if s.GetDirection() == gctorder.Sell && prevHolding.PositionsSize == 0 { - o.AppendReason("no holdings to sell") - o.SetDirection(common.CouldNotSell) - s.SetDirection(o.Direction) - return o, nil - } - - // for simplicity, the backtester will round to 8 decimal places - remainingFundsRounded := math.Floor(prevHolding.RemainingFunds*100000000) / 100000000 - if s.GetDirection() == gctorder.Buy && remainingFundsRounded <= 0 { - o.AppendReason("not enough funds to buy") - o.SetDirection(common.CouldNotBuy) - s.SetDirection(o.Direction) - return o, nil - } - - o.Price = s.GetPrice() - o.OrderType = gctorder.Market - o.BuyLimit = s.GetBuyLimit() - o.SellLimit = s.GetSellLimit() - sizingFunds := prevHolding.RemainingFunds - if s.GetDirection() == gctorder.Sell { - sizingFunds = prevHolding.PositionsSize - } - - sizedOrder := p.sizeOrder(s, cs, o, sizingFunds) - o.Funds = sizingFunds - sizedAmountRounded := math.Floor(sizedOrder.Amount*100000000) / 100000000 - if sizedAmountRounded <= 0 { - o.AppendReason("sized amount is zero") - if o.Direction == gctorder.Buy { - o.SetDirection(common.CouldNotBuy) - } else if o.Direction == gctorder.Sell { + if !funds.CanPlaceOrder(ev.GetDirection()) { + if ev.GetDirection() == gctorder.Sell { + o.AppendReason("no holdings to sell") o.SetDirection(common.CouldNotSell) + } else if ev.GetDirection() == gctorder.Buy { + o.AppendReason("not enough funds to buy") + o.SetDirection(common.CouldNotBuy) } + ev.SetDirection(o.Direction) return o, nil } - return p.evaluateOrder(s, o, sizedOrder) + o.Price = ev.GetPrice() + o.OrderType = gctorder.Market + o.BuyLimit = ev.GetBuyLimit() + o.SellLimit = ev.GetSellLimit() + var sizingFunds decimal.Decimal + if ev.GetDirection() == gctorder.Sell { + sizingFunds = funds.BaseAvailable() + } else { + sizingFunds = funds.QuoteAvailable() + } + sizedOrder := p.sizeOrder(ev, cs, o, sizingFunds, funds) + + return p.evaluateOrder(ev, o, sizedOrder) } func (p *Portfolio) evaluateOrder(d common.Directioner, originalOrderSignal, sizedOrder *order.Order) (*order.Order, error) { @@ -167,7 +149,7 @@ func (p *Portfolio) evaluateOrder(d common.Directioner, originalOrderSignal, siz return evaluatedOrder, nil } -func (p *Portfolio) sizeOrder(d common.Directioner, cs *exchange.Settings, originalOrderSignal *order.Order, sizingFunds float64) *order.Order { +func (p *Portfolio) sizeOrder(d common.Directioner, cs *exchange.Settings, originalOrderSignal *order.Order, sizingFunds decimal.Decimal, funds funding.IPairReserver) *order.Order { sizedOrder, err := p.sizeManager.SizeOrder(originalOrderSignal, sizingFunds, cs) if err != nil { originalOrderSignal.AppendReason(err.Error()) @@ -183,7 +165,7 @@ func (p *Portfolio) sizeOrder(d common.Directioner, cs *exchange.Settings, origi return originalOrderSignal } - if sizedOrder.Amount == 0 { + if sizedOrder.Amount.IsZero() { switch originalOrderSignal.Direction { case gctorder.Buy: originalOrderSignal.Direction = common.CouldNotBuy @@ -195,60 +177,78 @@ func (p *Portfolio) sizeOrder(d common.Directioner, cs *exchange.Settings, origi d.SetDirection(originalOrderSignal.Direction) originalOrderSignal.AppendReason("sized order to 0") } - + if d.GetDirection() == gctorder.Sell { + err = funds.Reserve(sizedOrder.Amount, gctorder.Sell) + sizedOrder.AllocatedFunds = sizedOrder.Amount + } else { + err = funds.Reserve(sizedOrder.Amount.Mul(sizedOrder.Price), gctorder.Buy) + sizedOrder.AllocatedFunds = sizedOrder.Amount.Mul(sizedOrder.Price) + } + if err != nil { + sizedOrder.Direction = common.DoNothing + sizedOrder.AppendReason(err.Error()) + } return sizedOrder } // OnFill processes the event after an order has been placed by the exchange. Its purpose is to track holdings for future portfolio decisions. -func (p *Portfolio) OnFill(fillEvent fill.Event) (*fill.Fill, error) { - if fillEvent == nil { +func (p *Portfolio) OnFill(ev fill.Event, funding funding.IPairReader) (*fill.Fill, error) { + if ev == nil { return nil, common.ErrNilEvent } - lookup := p.exchangeAssetPairSettings[fillEvent.GetExchange()][fillEvent.GetAssetType()][fillEvent.Pair()] + lookup := p.exchangeAssetPairSettings[ev.GetExchange()][ev.GetAssetType()][ev.Pair()] if lookup == nil { - return nil, fmt.Errorf("%w for %v %v %v", errNoPortfolioSettings, fillEvent.GetExchange(), fillEvent.GetAssetType(), fillEvent.Pair()) + return nil, fmt.Errorf("%w for %v %v %v", errNoPortfolioSettings, ev.GetExchange(), ev.GetAssetType(), ev.Pair()) } var err error + // Get the holding from the previous iteration, create it if it doesn't yet have a timestamp - h := lookup.GetHoldingsForTime(fillEvent.GetTime().Add(-fillEvent.GetInterval().Duration())) + h := lookup.GetHoldingsForTime(ev.GetTime().Add(-ev.GetInterval().Duration())) if !h.Timestamp.IsZero() { - h.Update(fillEvent) + h.Update(ev, funding) } else { h = lookup.GetLatestHoldings() - if !h.Timestamp.IsZero() { - h.Update(fillEvent) - } else { - h, err = holdings.Create(fillEvent, lookup.InitialFunds, p.riskFreeRate) + if h.Timestamp.IsZero() { + h, err = holdings.Create(ev, funding, p.riskFreeRate) if err != nil { return nil, err } + } else { + h.Update(ev, funding) } } - err = p.setHoldingsForOffset(fillEvent.GetExchange(), fillEvent.GetAssetType(), fillEvent.Pair(), &h, true) + err = p.setHoldingsForOffset(&h, true) if errors.Is(err, errNoHoldings) { - err = p.setHoldingsForOffset(fillEvent.GetExchange(), fillEvent.GetAssetType(), fillEvent.Pair(), &h, false) + err = p.setHoldingsForOffset(&h, false) } if err != nil { log.Error(log.BackTester, err) } - err = p.addComplianceSnapshot(fillEvent) + err = p.addComplianceSnapshot(ev) if err != nil { log.Error(log.BackTester, err) } - direction := fillEvent.GetDirection() + direction := ev.GetDirection() if direction == common.DoNothing || direction == common.CouldNotBuy || direction == common.CouldNotSell || direction == common.MissingData || direction == "" { - fe := fillEvent.(*fill.Fill) - fe.ExchangeFee = 0 + fe, ok := ev.(*fill.Fill) + if !ok { + return nil, fmt.Errorf("%w expected fill event", common.ErrInvalidDataType) + } + fe.ExchangeFee = decimal.Zero return fe, nil } - return fillEvent.(*fill.Fill), nil + fe, ok := ev.(*fill.Fill) + if !ok { + return nil, fmt.Errorf("%w expected fill event", common.ErrInvalidDataType) + } + return fe, nil } // addComplianceSnapshot gets the previous snapshot of compliance events, updates with the latest fillevent @@ -264,12 +264,15 @@ func (p *Portfolio) addComplianceSnapshot(fillEvent fill.Event) error { prevSnap := complianceManager.GetLatestSnapshot() fo := fillEvent.GetOrder() if fo != nil { + price := decimal.NewFromFloat(fo.Price) + amount := decimal.NewFromFloat(fo.Amount) + fee := decimal.NewFromFloat(fo.Fee) snapOrder := compliance.SnapshotOrder{ ClosePrice: fillEvent.GetClosePrice(), VolumeAdjustedPrice: fillEvent.GetVolumeAdjustedPrice(), SlippageRate: fillEvent.GetSlippageRate(), Detail: fo, - CostBasis: (fo.Price * fo.Amount) + fo.Fee, + CostBasis: price.Mul(amount).Add(fee), } prevSnap.Orders = append(prevSnap.Orders, snapOrder) } @@ -286,75 +289,53 @@ func (p *Portfolio) GetComplianceManager(exchangeName string, a asset.Item, cp c } // SetFee sets the fee rate -func (p *Portfolio) SetFee(exch string, a asset.Item, cp currency.Pair, fee float64) { +func (p *Portfolio) SetFee(exch string, a asset.Item, cp currency.Pair, fee decimal.Decimal) { lookup := p.exchangeAssetPairSettings[exch][a][cp] lookup.Fee = fee } // GetFee can panic for bad requests, but why are you getting things that don't exist? -func (p *Portfolio) GetFee(exchangeName string, a asset.Item, cp currency.Pair) float64 { +func (p *Portfolio) GetFee(exchangeName string, a asset.Item, cp currency.Pair) decimal.Decimal { if p.exchangeAssetPairSettings == nil { - return 0 + return decimal.Zero } lookup := p.exchangeAssetPairSettings[exchangeName][a][cp] if lookup == nil { - return 0 + return decimal.Zero } return lookup.Fee } -// IsInvested determines if there are any holdings for a given exchange, asset, pair -func (p *Portfolio) IsInvested(exchangeName string, a asset.Item, cp currency.Pair) (holdings.Holding, bool) { - s := p.exchangeAssetPairSettings[exchangeName][a][cp] - if s == nil { - return holdings.Holding{}, false - } - h := s.GetLatestHoldings() - if h.PositionsSize > 0 { - return h, true - } - return h, false -} - -// Update updates the portfolio holdings for the data event -func (p *Portfolio) Update(d common.DataEventHandler) error { - if d == nil { +// UpdateHoldings updates the portfolio holdings for the data event +func (p *Portfolio) UpdateHoldings(ev common.DataEventHandler, funds funding.IPairReader) error { + if ev == nil { return common.ErrNilEvent } - h, ok := p.IsInvested(d.GetExchange(), d.GetAssetType(), d.Pair()) - if !ok { - return nil + if funds == nil { + return funding.ErrFundsNotFound } - h.UpdateValue(d) - err := p.setHoldingsForOffset(d.GetExchange(), d.GetAssetType(), d.Pair(), &h, true) - if errors.Is(err, errNoHoldings) { - err = p.setHoldingsForOffset(d.GetExchange(), d.GetAssetType(), d.Pair(), &h, false) - } - return err -} - -// SetInitialFunds sets the initial funds -func (p *Portfolio) SetInitialFunds(exch string, a asset.Item, cp currency.Pair, funds float64) error { - lookup, ok := p.exchangeAssetPairSettings[exch][a][cp] + lookup, ok := p.exchangeAssetPairSettings[ev.GetExchange()][ev.GetAssetType()][ev.Pair()] if !ok { + return fmt.Errorf("%w for %v %v %v", + errNoPortfolioSettings, + ev.GetExchange(), + ev.GetAssetType(), + ev.Pair()) + } + h := lookup.GetLatestHoldings() + if h.Timestamp.IsZero() { var err error - lookup, err = p.SetupCurrencySettingsMap(exch, a, cp) + h, err = holdings.Create(ev, funds, p.riskFreeRate) if err != nil { return err } } - lookup.InitialFunds = funds - - return nil -} - -// GetInitialFunds returns the initial funds -func (p *Portfolio) GetInitialFunds(exch string, a asset.Item, cp currency.Pair) float64 { - lookup, ok := p.exchangeAssetPairSettings[exch][a][cp] - if !ok { - return 0 + h.UpdateValue(ev) + err := p.setHoldingsForOffset(&h, true) + if errors.Is(err, errNoHoldings) { + err = p.setHoldingsForOffset(&h, false) } - return lookup.InitialFunds + return err } // GetLatestHoldingsForAllCurrencies will return the current holdings for all loaded currencies @@ -365,7 +346,7 @@ func (p *Portfolio) GetLatestHoldingsForAllCurrencies() []holdings.Holding { for _, y := range x { for _, z := range y { holds := z.GetLatestHoldings() - if holds.Offset != 0 { + if !holds.Timestamp.IsZero() { resp = append(resp, holds) } } @@ -374,14 +355,14 @@ func (p *Portfolio) GetLatestHoldingsForAllCurrencies() []holdings.Holding { return resp } -func (p *Portfolio) setHoldingsForOffset(exch string, a asset.Item, cp currency.Pair, h *holdings.Holding, overwriteExisting bool) error { +func (p *Portfolio) setHoldingsForOffset(h *holdings.Holding, overwriteExisting bool) error { if h.Timestamp.IsZero() { return errHoldingsNoTimestamp } - lookup := p.exchangeAssetPairSettings[exch][a][cp] + lookup := p.exchangeAssetPairSettings[h.Exchange][h.Asset][h.Pair] if lookup == nil { var err error - lookup, err = p.SetupCurrencySettingsMap(exch, a, cp) + lookup, err = p.SetupCurrencySettingsMap(h.Exchange, h.Asset, h.Pair) if err != nil { return err } @@ -408,19 +389,19 @@ func (p *Portfolio) setHoldingsForOffset(exch string, a asset.Item, cp currency. // ViewHoldingAtTimePeriod retrieves a snapshot of holdings at a specific time period, // returning empty when not found -func (p *Portfolio) ViewHoldingAtTimePeriod(exch string, a asset.Item, cp currency.Pair, t time.Time) (holdings.Holding, error) { - exchangeAssetPairSettings := p.exchangeAssetPairSettings[exch][a][cp] +func (p *Portfolio) ViewHoldingAtTimePeriod(ev common.EventHandler) (*holdings.Holding, error) { + exchangeAssetPairSettings := p.exchangeAssetPairSettings[ev.GetExchange()][ev.GetAssetType()][ev.Pair()] if exchangeAssetPairSettings == nil { - return holdings.Holding{}, fmt.Errorf("%w for %v %v %v", errNoHoldings, exch, a, cp) + return nil, fmt.Errorf("%w for %v %v %v", errNoHoldings, ev.GetExchange(), ev.GetAssetType(), ev.Pair()) } for i := len(exchangeAssetPairSettings.HoldingsSnapshots) - 1; i >= 0; i-- { - if t.Equal(exchangeAssetPairSettings.HoldingsSnapshots[i].Timestamp) { - return exchangeAssetPairSettings.HoldingsSnapshots[i], nil + if ev.GetTime().Equal(exchangeAssetPairSettings.HoldingsSnapshots[i].Timestamp) { + return &exchangeAssetPairSettings.HoldingsSnapshots[i], nil } } - return holdings.Holding{}, fmt.Errorf("%w for %v %v %v at %v", errNoHoldings, exch, a, cp, t) + return nil, fmt.Errorf("%w for %v %v %v at %v", errNoHoldings, ev.GetExchange(), ev.GetAssetType(), ev.Pair(), ev.GetTime()) } // SetupCurrencySettingsMap ensures a map is created and no panics happen diff --git a/backtester/eventhandlers/portfolio/portfolio_test.go b/backtester/eventhandlers/portfolio/portfolio_test.go index 3f61f32e..13a0b05a 100644 --- a/backtester/eventhandlers/portfolio/portfolio_test.go +++ b/backtester/eventhandlers/portfolio/portfolio_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/common" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/exchange" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/portfolio/compliance" @@ -17,6 +18,7 @@ import ( "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/kline" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/order" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/signal" + "github.com/thrasher-corp/gocryptotrader/backtester/funding" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/exchanges/asset" gctorder "github.com/thrasher-corp/gocryptotrader/exchanges/order" @@ -37,26 +39,26 @@ func TestReset(t *testing.T) { func TestSetup(t *testing.T) { t.Parallel() - _, err := Setup(nil, nil, -1) + _, err := Setup(nil, nil, decimal.NewFromInt(-1)) if !errors.Is(err, errSizeManagerUnset) { - t.Errorf("expected: %v, received %v", errSizeManagerUnset, err) + t.Errorf("received: %v, expected: %v", err, errSizeManagerUnset) } - _, err = Setup(&size.Size{}, nil, -1) + _, err = Setup(&size.Size{}, nil, decimal.NewFromInt(-1)) if !errors.Is(err, errNegativeRiskFreeRate) { - t.Errorf("expected: %v, received %v", errNegativeRiskFreeRate, err) + t.Errorf("received: %v, expected: %v", err, errNegativeRiskFreeRate) } - _, err = Setup(&size.Size{}, nil, 1) + _, err = Setup(&size.Size{}, nil, decimal.NewFromInt(1)) if !errors.Is(err, errRiskManagerUnset) { - t.Errorf("expected: %v, received %v", errRiskManagerUnset, err) + t.Errorf("received: %v, expected: %v", err, errRiskManagerUnset) } var p *Portfolio - p, err = Setup(&size.Size{}, &risk.Risk{}, 1) + p, err = Setup(&size.Size{}, &risk.Risk{}, decimal.NewFromInt(1)) if err != nil { t.Error(err) } - if p.riskFreeRate != 1 { + if !p.riskFreeRate.Equal(decimal.NewFromInt(1)) { t.Error("expected 1") } } @@ -66,17 +68,17 @@ func TestSetupCurrencySettingsMap(t *testing.T) { p := &Portfolio{} _, err := p.SetupCurrencySettingsMap("", "", currency.Pair{}) if !errors.Is(err, errExchangeUnset) { - t.Errorf("expected: %v, received %v", errExchangeUnset, err) + t.Errorf("received: %v, expected: %v", err, errExchangeUnset) } _, err = p.SetupCurrencySettingsMap("hi", "", currency.Pair{}) if !errors.Is(err, errAssetUnset) { - t.Errorf("expected: %v, received %v", errAssetUnset, err) + t.Errorf("received: %v, expected: %v", err, errAssetUnset) } _, err = p.SetupCurrencySettingsMap("hi", asset.Spot, currency.Pair{}) if !errors.Is(err, errCurrencyPairUnset) { - t.Errorf("expected: %v, received %v", errCurrencyPairUnset, err) + t.Errorf("received: %v, expected: %v", err, errCurrencyPairUnset) } _, err = p.SetupCurrencySettingsMap("hi", asset.Spot, currency.NewPair(currency.BTC, currency.USD)) @@ -89,23 +91,31 @@ func TestSetHoldings(t *testing.T) { t.Parallel() p := &Portfolio{} - err := p.setHoldingsForOffset("", "", currency.Pair{}, &holdings.Holding{}, false) + err := p.setHoldingsForOffset(&holdings.Holding{}, false) if !errors.Is(err, errHoldingsNoTimestamp) { - t.Errorf("expected: %v, received %v", errHoldingsNoTimestamp, err) + t.Errorf("received: %v, expected: %v", err, errHoldingsNoTimestamp) } tt := time.Now() - err = p.setHoldingsForOffset("", "", currency.Pair{}, &holdings.Holding{Timestamp: tt}, false) + err = p.setHoldingsForOffset(&holdings.Holding{Timestamp: tt}, false) if !errors.Is(err, errExchangeUnset) { - t.Errorf("expected: %v, received %v", errExchangeUnset, err) + t.Errorf("received: %v, expected: %v", err, errExchangeUnset) } - err = p.setHoldingsForOffset(testExchange, asset.Spot, currency.NewPair(currency.BTC, currency.USD), &holdings.Holding{Timestamp: tt}, false) + err = p.setHoldingsForOffset(&holdings.Holding{ + Exchange: testExchange, + Asset: asset.Spot, + Pair: currency.NewPair(currency.BTC, currency.USD), + Timestamp: tt}, false) if err != nil { t.Error(err) } - err = p.setHoldingsForOffset(testExchange, asset.Spot, currency.NewPair(currency.BTC, currency.USD), &holdings.Holding{Timestamp: tt}, true) + err = p.setHoldingsForOffset(&holdings.Holding{ + Exchange: testExchange, + Asset: asset.Spot, + Pair: currency.NewPair(currency.BTC, currency.USD), + Timestamp: tt}, true) if err != nil { t.Error(err) } @@ -119,61 +129,52 @@ func TestGetLatestHoldingsForAllCurrencies(t *testing.T) { t.Error("expected 0") } tt := time.Now() - err := p.setHoldingsForOffset(testExchange, asset.Spot, currency.NewPair(currency.BTC, currency.USD), &holdings.Holding{Timestamp: tt}, true) + err := p.setHoldingsForOffset(&holdings.Holding{ + Exchange: testExchange, + Asset: asset.Spot, + Pair: currency.NewPair(currency.BTC, currency.USD), + Timestamp: tt}, true) if !errors.Is(err, errNoHoldings) { - t.Errorf("expected: %v, received %v", errNoHoldings, err) + t.Errorf("received: %v, expected: %v", err, errNoHoldings) + } + h = p.GetLatestHoldingsForAllCurrencies() + if len(h) != 0 { + t.Errorf("received %v, expected %v", len(h), 0) + } + err = p.setHoldingsForOffset(&holdings.Holding{ + Offset: 1, + Exchange: testExchange, + Asset: asset.Spot, + Pair: currency.NewPair(currency.BTC, currency.USD), + Timestamp: tt}, false) + if err != nil { + t.Error(err) } h = p.GetLatestHoldingsForAllCurrencies() if len(h) != 1 { - t.Error("expected 1") + t.Errorf("received %v, expected %v", len(h), 1) } - if !h[0].Timestamp.IsZero() { - t.Error("expected unset holding") - } - err = p.setHoldingsForOffset(testExchange, asset.Spot, currency.NewPair(currency.BTC, currency.DOGE), &holdings.Holding{Offset: 1, Timestamp: tt}, false) - if err != nil { - t.Error(err) - } - h = p.GetLatestHoldingsForAllCurrencies() - if len(h) != 2 { - t.Error("expected 2") - } - err = p.setHoldingsForOffset(testExchange, asset.Spot, currency.NewPair(currency.BTC, currency.DOGE), &holdings.Holding{Offset: 1, Timestamp: tt}, false) + err = p.setHoldingsForOffset(&holdings.Holding{ + Offset: 1, + Exchange: testExchange, + Asset: asset.Spot, + Pair: currency.NewPair(currency.BTC, currency.USD), + Timestamp: tt}, false) if !errors.Is(err, errHoldingsAlreadySet) { - t.Errorf("expected: %v, received %v", errHoldingsAlreadySet, err) + t.Errorf("received: %v, expected: %v", err, errHoldingsAlreadySet) } - - err = p.setHoldingsForOffset(testExchange, asset.Spot, currency.NewPair(currency.BTC, currency.DOGE), &holdings.Holding{Offset: 2, Timestamp: tt.Add(time.Minute)}, true) - if !errors.Is(err, errNoHoldings) { - t.Errorf("expected: %v, received %v", errNoHoldings, err) + err = p.setHoldingsForOffset(&holdings.Holding{ + Offset: 1, + Exchange: testExchange, + Asset: asset.Spot, + Pair: currency.NewPair(currency.BTC, currency.USD), + Timestamp: tt}, true) + if !errors.Is(err, nil) { + t.Errorf("received: %v, expected: %v", err, nil) } h = p.GetLatestHoldingsForAllCurrencies() - if len(h) != 2 { - t.Error("expected 2") - } -} - -func TestGetInitialFunds(t *testing.T) { - t.Parallel() - p := Portfolio{} - f := p.GetInitialFunds("", "", currency.Pair{}) - if f != 0 { - t.Error("expected zero") - } - - err := p.SetInitialFunds("", "", currency.Pair{}, 1) - if !errors.Is(err, errExchangeUnset) { - t.Errorf("expected: %v, received %v", errExchangeUnset, err) - } - - err = p.SetInitialFunds(testExchange, asset.Spot, currency.NewPair(currency.BTC, currency.DOGE), 1) - if err != nil { - t.Error(err) - } - - f = p.GetInitialFunds(testExchange, asset.Spot, currency.NewPair(currency.BTC, currency.DOGE)) - if f != 1 { - t.Error("expected 1") + if len(h) != 1 { + t.Errorf("received %v, expected %v", len(h), 1) } } @@ -181,28 +182,41 @@ func TestViewHoldingAtTimePeriod(t *testing.T) { t.Parallel() p := Portfolio{} tt := time.Now() - _, err := p.ViewHoldingAtTimePeriod("", "", currency.Pair{}, tt) + s := &signal.Signal{ + Base: event.Base{ + Time: tt, + Exchange: testExchange, + AssetType: asset.Spot, + CurrencyPair: currency.NewPair(currency.BTC, currency.USD), + }, + } + _, err := p.ViewHoldingAtTimePeriod(s) if !errors.Is(err, errNoHoldings) { - t.Errorf("expected: %v, received %v", errNoHoldings, err) + t.Errorf("received: %v, expected: %v", err, errNoHoldings) } - err = p.setHoldingsForOffset(testExchange, asset.Spot, currency.NewPair(currency.BTC, currency.USD), &holdings.Holding{Offset: 1, Timestamp: tt}, false) + err = p.setHoldingsForOffset(&holdings.Holding{ + Offset: 1, + Exchange: testExchange, + Asset: asset.Spot, + Pair: currency.NewPair(currency.BTC, currency.USD), + Timestamp: tt}, false) if err != nil { t.Error(err) } - err = p.setHoldingsForOffset(testExchange, asset.Spot, currency.NewPair(currency.BTC, currency.USD), &holdings.Holding{Offset: 2, Timestamp: tt.Add(time.Hour)}, false) + err = p.setHoldingsForOffset(&holdings.Holding{ + Offset: 2, + Exchange: testExchange, + Asset: asset.Spot, + Pair: currency.NewPair(currency.BTC, currency.USD), + Timestamp: tt.Add(time.Hour)}, false) if err != nil { t.Error(err) } - _, err = p.ViewHoldingAtTimePeriod(testExchange, asset.Spot, currency.NewPair(currency.BTC, currency.USD), tt) + var h *holdings.Holding + h, err = p.ViewHoldingAtTimePeriod(s) if err != nil { - t.Error(err) - } - - var h holdings.Holding - h, err = p.ViewHoldingAtTimePeriod(testExchange, asset.Spot, currency.NewPair(currency.BTC, currency.USD), tt) - if err != nil { - t.Error(err) + t.Fatal(err) } if !h.Timestamp.Equal(tt) { t.Errorf("expected %v received %v", tt, h.Timestamp) @@ -212,41 +226,51 @@ func TestViewHoldingAtTimePeriod(t *testing.T) { func TestUpdate(t *testing.T) { t.Parallel() p := Portfolio{} - err := p.Update(nil) + err := p.UpdateHoldings(nil, nil) if !errors.Is(err, common.ErrNilEvent) { - t.Errorf("expected: %v, received %v", common.ErrNilEvent, err) + t.Errorf("received: %v, expected: %v", err, common.ErrNilEvent) } - err = p.Update(&kline.Kline{}) - if err != nil { - t.Error(err) + err = p.UpdateHoldings(&kline.Kline{}, nil) + if !errors.Is(err, funding.ErrFundsNotFound) { + t.Errorf("received '%v' expected '%v'", err, funding.ErrFundsNotFound) } - - err = p.Update(&kline.Kline{ - Base: event.Base{ - Exchange: testExchange, - CurrencyPair: currency.NewPair(currency.BTC, currency.USD), - AssetType: asset.Spot, - }, - }) + b, err := funding.CreateItem(testExchange, asset.Spot, currency.BTC, decimal.NewFromInt(1), decimal.Zero) if err != nil { - t.Error(err) + t.Fatal(err) + } + q, err := funding.CreateItem(testExchange, asset.Spot, currency.USDT, decimal.NewFromInt(100), decimal.Zero) + if err != nil { + t.Fatal(err) + } + pair, err := funding.CreatePair(b, q) + if err != nil { + t.Fatal(err) + } + err = p.UpdateHoldings(&kline.Kline{}, pair) + if !errors.Is(err, errNoPortfolioSettings) { + t.Errorf("received '%v' expected '%v'", err, errNoPortfolioSettings) } tt := time.Now() - err = p.setHoldingsForOffset(testExchange, asset.Spot, currency.NewPair(currency.BTC, currency.USD), &holdings.Holding{Timestamp: tt, PositionsSize: 1337}, false) + err = p.setHoldingsForOffset(&holdings.Holding{ + Offset: 1, + Exchange: testExchange, + Asset: asset.Spot, + Pair: currency.NewPair(currency.BTC, currency.USD), + Timestamp: tt}, false) if err != nil { t.Error(err) } - err = p.Update(&kline.Kline{ + err = p.UpdateHoldings(&kline.Kline{ Base: event.Base{ + Time: tt, Exchange: testExchange, CurrencyPair: currency.NewPair(currency.BTC, currency.USD), AssetType: asset.Spot, - Time: tt, }, - }) + }, pair) if err != nil { t.Error(err) } @@ -256,7 +280,7 @@ func TestGetFee(t *testing.T) { t.Parallel() p := Portfolio{} f := p.GetFee("", "", currency.Pair{}) - if f != 0 { + if !f.IsZero() { t.Error("expected 0") } @@ -265,10 +289,10 @@ func TestGetFee(t *testing.T) { t.Error(err) } - p.SetFee("hi", asset.Spot, currency.NewPair(currency.BTC, currency.USD), 1337) + p.SetFee("hi", asset.Spot, currency.NewPair(currency.BTC, currency.USD), decimal.NewFromInt(1337)) f = p.GetFee("hi", asset.Spot, currency.NewPair(currency.BTC, currency.USD)) - if f != 1337 { - t.Error("expected 1337") + if !f.Equal(decimal.NewFromInt(1337)) { + t.Errorf("expected %v received %v", 1337, f) } } @@ -277,7 +301,7 @@ func TestGetComplianceManager(t *testing.T) { p := Portfolio{} _, err := p.GetComplianceManager("", "", currency.Pair{}) if !errors.Is(err, errNoPortfolioSettings) { - t.Errorf("expected: %v, received %v", errNoPortfolioSettings, err) + t.Errorf("received: %v, expected: %v", err, errNoPortfolioSettings) } _, err = p.SetupCurrencySettingsMap("hi", asset.Spot, currency.NewPair(currency.BTC, currency.USD)) @@ -299,12 +323,12 @@ func TestAddComplianceSnapshot(t *testing.T) { p := Portfolio{} err := p.addComplianceSnapshot(nil) if !errors.Is(err, common.ErrNilEvent) { - t.Errorf("expected: %v, received %v", common.ErrNilEvent, err) + t.Errorf("received: %v, expected: %v", err, common.ErrNilEvent) } err = p.addComplianceSnapshot(&fill.Fill{}) if !errors.Is(err, errNoPortfolioSettings) { - t.Errorf("expected: %v, received %v", errNoPortfolioSettings, err) + t.Errorf("received: %v, expected: %v", err, errNoPortfolioSettings) } _, err = p.SetupCurrencySettingsMap("hi", asset.Spot, currency.NewPair(currency.BTC, currency.USD)) @@ -332,9 +356,9 @@ func TestAddComplianceSnapshot(t *testing.T) { func TestOnFill(t *testing.T) { t.Parallel() p := Portfolio{} - _, err := p.OnFill(nil) + _, err := p.OnFill(nil, nil) if !errors.Is(err, common.ErrNilEvent) { - t.Errorf("expected: %v, received %v", common.ErrNilEvent, err) + t.Errorf("received: %v, expected: %v", err, common.ErrNilEvent) } f := &fill.Fill{ @@ -349,28 +373,34 @@ func TestOnFill(t *testing.T) { AssetType: asset.Spot, }, } - _, err = p.OnFill(f) + _, err = p.OnFill(f, nil) if !errors.Is(err, errNoPortfolioSettings) { - t.Errorf("expected: %v, received %v", errNoPortfolioSettings, err) + t.Errorf("received: %v, expected: %v", err, errNoPortfolioSettings) } - var s *settings.Settings - s, err = p.SetupCurrencySettingsMap("hi", asset.Spot, currency.NewPair(currency.BTC, currency.USD)) + _, err = p.SetupCurrencySettingsMap("hi", asset.Spot, currency.NewPair(currency.BTC, currency.USD)) if err != nil { t.Error(err) } - _, err = p.OnFill(f) - if !errors.Is(err, holdings.ErrInitialFundsZero) { - t.Errorf("expected: %v, received %v", holdings.ErrInitialFundsZero, err) - } - s.InitialFunds = 1337 - _, err = p.OnFill(f) + b, err := funding.CreateItem(testExchange, asset.Spot, currency.BTC, decimal.NewFromInt(1), decimal.Zero) if err != nil { - t.Error(err) + t.Fatal(err) + } + q, err := funding.CreateItem(testExchange, asset.Spot, currency.USDT, decimal.NewFromInt(100), decimal.Zero) + if err != nil { + t.Fatal(err) + } + pair, err := funding.CreatePair(b, q) + if err != nil { + t.Fatal(err) + } + _, err = p.OnFill(f, pair) + if !errors.Is(err, nil) { + t.Errorf("received: %v, expected: %v", err, nil) } f.Direction = gctorder.Buy - _, err = p.OnFill(f) + _, err = p.OnFill(f, pair) if err != nil { t.Error(err) } @@ -379,34 +409,50 @@ func TestOnFill(t *testing.T) { func TestOnSignal(t *testing.T) { t.Parallel() p := Portfolio{} - _, err := p.OnSignal(nil, nil) + _, err := p.OnSignal(nil, nil, nil) if !errors.Is(err, common.ErrNilArguments) { t.Error(err) } s := &signal.Signal{} - _, err = p.OnSignal(s, &exchange.Settings{}) + _, err = p.OnSignal(s, &exchange.Settings{}, nil) if !errors.Is(err, errSizeManagerUnset) { - t.Errorf("expected: %v, received %v", errSizeManagerUnset, err) + t.Errorf("received: %v, expected: %v", err, errSizeManagerUnset) } p.sizeManager = &size.Size{} - _, err = p.OnSignal(s, &exchange.Settings{}) + _, err = p.OnSignal(s, &exchange.Settings{}, nil) if !errors.Is(err, errRiskManagerUnset) { - t.Errorf("expected: %v, received %v", errRiskManagerUnset, err) + t.Errorf("received: %v, expected: %v", err, errRiskManagerUnset) } p.riskManager = &risk.Risk{} - _, err = p.OnSignal(s, &exchange.Settings{}) + _, err = p.OnSignal(s, &exchange.Settings{}, nil) + if !errors.Is(err, funding.ErrFundsNotFound) { + t.Errorf("received: %v, expected: %v", err, funding.ErrFundsNotFound) + } + b, err := funding.CreateItem(testExchange, asset.Spot, currency.BTC, decimal.NewFromInt(1337), decimal.Zero) + if err != nil { + t.Fatal(err) + } + q, err := funding.CreateItem(testExchange, asset.Spot, currency.USDT, decimal.NewFromInt(1337), decimal.Zero) + if err != nil { + t.Fatal(err) + } + pair, err := funding.CreatePair(b, q) + if err != nil { + t.Fatal(err) + } + _, err = p.OnSignal(s, &exchange.Settings{}, pair) if !errors.Is(err, errInvalidDirection) { - t.Errorf("expected: %v, received %v", errInvalidDirection, err) + t.Errorf("received: %v, expected: %v", err, errInvalidDirection) } s.Direction = gctorder.Buy - _, err = p.OnSignal(s, &exchange.Settings{}) + _, err = p.OnSignal(s, &exchange.Settings{}, pair) if !errors.Is(err, errNoPortfolioSettings) { - t.Errorf("expected: %v, received %v", errNoPortfolioSettings, err) + t.Errorf("received: %v, expected: %v", err, errNoPortfolioSettings) } _, err = p.SetupCurrencySettingsMap("hi", asset.Spot, currency.NewPair(currency.BTC, currency.USD)) if err != nil { @@ -421,16 +467,16 @@ func TestOnSignal(t *testing.T) { Direction: gctorder.Buy, } var resp *order.Order - resp, err = p.OnSignal(s, &exchange.Settings{}) + resp, err = p.OnSignal(s, &exchange.Settings{}, pair) if err != nil { - t.Error(err) + t.Fatal(err) } if resp.Reason == "" { t.Error("expected issue") } s.Direction = gctorder.Sell - _, err = p.OnSignal(s, &exchange.Settings{}) + _, err = p.OnSignal(s, &exchange.Settings{}, pair) if err != nil { t.Error(err) } @@ -439,17 +485,22 @@ func TestOnSignal(t *testing.T) { } s.Direction = common.MissingData - _, err = p.OnSignal(s, &exchange.Settings{}) + _, err = p.OnSignal(s, &exchange.Settings{}, pair) if err != nil { t.Error(err) } s.Direction = gctorder.Buy - err = p.setHoldingsForOffset("hi", asset.Spot, currency.NewPair(currency.BTC, currency.USD), &holdings.Holding{Timestamp: time.Now(), RemainingFunds: 1337}, false) + err = p.setHoldingsForOffset(&holdings.Holding{ + Exchange: testExchange, + Asset: asset.Spot, + Pair: currency.NewPair(currency.BTC, currency.USD), + Timestamp: time.Now(), + QuoteSize: decimal.NewFromInt(1337)}, false) if err != nil { t.Error(err) } - resp, err = p.OnSignal(s, &exchange.Settings{}) + resp, err = p.OnSignal(s, &exchange.Settings{}, pair) if err != nil { t.Error(err) } @@ -457,13 +508,13 @@ func TestOnSignal(t *testing.T) { t.Errorf("expected common.CouldNotBuy, received %v", resp.Direction) } - s.ClosePrice = 10 + s.ClosePrice = decimal.NewFromInt(10) s.Direction = gctorder.Buy - resp, err = p.OnSignal(s, &exchange.Settings{}) + resp, err = p.OnSignal(s, &exchange.Settings{}, pair) if err != nil { t.Error(err) } - if resp.Amount == 0 { + if resp.Amount.IsZero() { t.Error("expected an amount to be sized") } } diff --git a/backtester/eventhandlers/portfolio/portfolio_types.go b/backtester/eventhandlers/portfolio/portfolio_types.go index 69fd6c57..aeca0979 100644 --- a/backtester/eventhandlers/portfolio/portfolio_types.go +++ b/backtester/eventhandlers/portfolio/portfolio_types.go @@ -2,8 +2,8 @@ package portfolio import ( "errors" - "time" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/common" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/exchange" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/portfolio/compliance" @@ -13,6 +13,7 @@ import ( "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/funding" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/exchanges/asset" ) @@ -34,8 +35,7 @@ var ( // Portfolio stores all holdings and rules to assess orders, allowing the portfolio manager to // modify, accept or reject strategy signals type Portfolio struct { - iteration float64 - riskFreeRate float64 + riskFreeRate decimal.Decimal sizeManager SizeHandler riskManager risk.Handler exchangeAssetPairSettings map[string]map[asset.Item]map[currency.Pair]*settings.Settings @@ -43,23 +43,22 @@ type Portfolio struct { // Handler contains all functions expected to operate a portfolio manager type Handler interface { - OnSignal(signal.Event, *exchange.Settings) (*order.Order, error) - OnFill(fill.Event) (*fill.Fill, error) - Update(common.DataEventHandler) error + OnSignal(signal.Event, *exchange.Settings, funding.IPairReserver) (*order.Order, error) + OnFill(fill.Event, funding.IPairReader) (*fill.Fill, error) - SetInitialFunds(string, asset.Item, currency.Pair, float64) error - GetInitialFunds(string, asset.Item, currency.Pair) float64 + ViewHoldingAtTimePeriod(common.EventHandler) (*holdings.Holding, error) + setHoldingsForOffset(*holdings.Holding, bool) error + UpdateHoldings(common.DataEventHandler, funding.IPairReader) error GetComplianceManager(string, asset.Item, currency.Pair) (*compliance.Manager, error) - setHoldingsForOffset(string, asset.Item, currency.Pair, *holdings.Holding, bool) error - ViewHoldingAtTimePeriod(string, asset.Item, currency.Pair, time.Time) (holdings.Holding, error) - SetFee(string, asset.Item, currency.Pair, float64) - GetFee(string, asset.Item, currency.Pair) float64 + SetFee(string, asset.Item, currency.Pair, decimal.Decimal) + GetFee(string, asset.Item, currency.Pair) decimal.Decimal + Reset() } // SizeHandler is the interface to help size orders type SizeHandler interface { - SizeOrder(order.Event, float64, *exchange.Settings) (*order.Order, error) + SizeOrder(order.Event, decimal.Decimal, *exchange.Settings) (*order.Order, error) } diff --git a/backtester/eventhandlers/portfolio/risk/risk.go b/backtester/eventhandlers/portfolio/risk/risk.go index 07524947..d66e799c 100644 --- a/backtester/eventhandlers/portfolio/risk/risk.go +++ b/backtester/eventhandlers/portfolio/risk/risk.go @@ -3,6 +3,7 @@ package risk import ( "fmt" + "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" @@ -16,7 +17,10 @@ func (r *Risk) EvaluateOrder(o order.Event, latestHoldings []holdings.Holding, s if o == nil || latestHoldings == nil { return nil, common.ErrNilArguments } - retOrder := o.(*order.Order) + retOrder, ok := o.(*order.Order) + if !ok { + return nil, fmt.Errorf("%w expected order event", common.ErrInvalidDataType) + } ex := o.GetExchange() a := o.GetAssetType() p := o.Pair() @@ -30,17 +34,17 @@ func (r *Risk) EvaluateOrder(o order.Event, latestHoldings []holdings.Holding, s return nil, errLeverageNotAllowed } ratio := existingLeverageRatio(s) - if ratio > lookup.MaximumOrdersWithLeverageRatio && lookup.MaximumOrdersWithLeverageRatio > 0 { - return nil, fmt.Errorf("proceeding with the order would put maximum orders using leverage ratio beyond its limit of %f to %f and %w", lookup.MaximumOrdersWithLeverageRatio, ratio, errCannotPlaceLeverageOrder) + if ratio.GreaterThan(lookup.MaximumOrdersWithLeverageRatio) && lookup.MaximumOrdersWithLeverageRatio.GreaterThan(decimal.Zero) { + return nil, fmt.Errorf("proceeding with the order would put maximum orders using leverage ratio beyond its limit of %v to %v and %w", lookup.MaximumOrdersWithLeverageRatio, ratio, errCannotPlaceLeverageOrder) } - if retOrder.GetLeverage() > lookup.MaxLeverageRate && lookup.MaxLeverageRate > 0 { - return nil, fmt.Errorf("proceeding with the order would put leverage rate beyond its limit of %f to %f and %w", lookup.MaxLeverageRate, retOrder.GetLeverage(), errCannotPlaceLeverageOrder) + if retOrder.GetLeverage().GreaterThan(lookup.MaxLeverageRate) && lookup.MaxLeverageRate.GreaterThan(decimal.Zero) { + return nil, fmt.Errorf("proceeding with the order would put leverage rate beyond its limit of %v to %v and %w", lookup.MaxLeverageRate, retOrder.GetLeverage(), errCannotPlaceLeverageOrder) } } if len(latestHoldings) > 1 { ratio := assessHoldingsRatio(o.Pair(), latestHoldings) - if lookup.MaximumHoldingRatio > 0 && ratio != 1 && ratio > lookup.MaximumHoldingRatio { - return nil, fmt.Errorf("order would exceed maximum holding ratio of %f to %f for %v %v %v. %w", lookup.MaximumHoldingRatio, ratio, ex, a, p, errCannotPlaceLeverageOrder) + if lookup.MaximumHoldingRatio.GreaterThan(decimal.Zero) && !ratio.Equal(decimal.NewFromInt(1)) && ratio.GreaterThan(lookup.MaximumHoldingRatio) { + return nil, fmt.Errorf("order would exceed maximum holding ratio of %v to %v for %v %v %v. %w", lookup.MaximumHoldingRatio, ratio, ex, a, p, errCannotPlaceLeverageOrder) } } return retOrder, nil @@ -49,31 +53,31 @@ func (r *Risk) EvaluateOrder(o order.Event, latestHoldings []holdings.Holding, s // existingLeverageRatio compares orders with leverage to the total number of orders // a proof of concept to demonstrate risk manager's ability to prevent an order from being placed // when an order exceeds a config setting -func existingLeverageRatio(s compliance.Snapshot) float64 { +func existingLeverageRatio(s compliance.Snapshot) decimal.Decimal { if len(s.Orders) == 0 { - return 0 + return decimal.Zero } - var ordersWithLeverage float64 + var ordersWithLeverage decimal.Decimal for o := range s.Orders { if s.Orders[o].Leverage != 0 { - ordersWithLeverage++ + ordersWithLeverage = ordersWithLeverage.Add(decimal.NewFromInt(1)) } } - return ordersWithLeverage / float64(len(s.Orders)) + return ordersWithLeverage.Div(decimal.NewFromInt(int64(len(s.Orders)))) } -func assessHoldingsRatio(c currency.Pair, h []holdings.Holding) float64 { - resp := make(map[currency.Pair]float64) - totalPosition := 0.0 +func assessHoldingsRatio(c currency.Pair, h []holdings.Holding) decimal.Decimal { + resp := make(map[currency.Pair]decimal.Decimal) + totalPosition := decimal.Zero for i := range h { - resp[h[i].Pair] += h[i].PositionsValue - totalPosition += h[i].PositionsValue + resp[h[i].Pair] = resp[h[i].Pair].Add(h[i].BaseValue) + totalPosition = totalPosition.Add(h[i].BaseValue) } - if totalPosition == 0 { - return 0 + if totalPosition.IsZero() { + return decimal.Zero } - ratio := resp[c] / totalPosition + ratio := resp[c].Div(totalPosition) return ratio } diff --git a/backtester/eventhandlers/portfolio/risk/risk_test.go b/backtester/eventhandlers/portfolio/risk/risk_test.go index 8f9a35d9..92c585dc 100644 --- a/backtester/eventhandlers/portfolio/risk/risk_test.go +++ b/backtester/eventhandlers/portfolio/risk/risk_test.go @@ -4,6 +4,7 @@ import ( "errors" "testing" + "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" @@ -17,33 +18,33 @@ func TestAssessHoldingsRatio(t *testing.T) { t.Parallel() ratio := assessHoldingsRatio(currency.NewPair(currency.BTC, currency.USDT), []holdings.Holding{ { - Pair: currency.NewPair(currency.BTC, currency.USDT), - PositionsValue: 2, + Pair: currency.NewPair(currency.BTC, currency.USDT), + BaseValue: decimal.NewFromInt(2), }, { - Pair: currency.NewPair(currency.LTC, currency.USDT), - PositionsValue: 2, + Pair: currency.NewPair(currency.LTC, currency.USDT), + BaseValue: decimal.NewFromInt(2), }, }) - if ratio != 0.5 { + if !ratio.Equal(decimal.NewFromFloat(0.5)) { t.Errorf("expected %v received %v", 0.5, ratio) } ratio = assessHoldingsRatio(currency.NewPair(currency.BTC, currency.USDT), []holdings.Holding{ { - Pair: currency.NewPair(currency.BTC, currency.USDT), - PositionsValue: 1, + Pair: currency.NewPair(currency.BTC, currency.USDT), + BaseValue: decimal.NewFromInt(1), }, { - Pair: currency.NewPair(currency.LTC, currency.USDT), - PositionsValue: 2, + Pair: currency.NewPair(currency.LTC, currency.USDT), + BaseValue: decimal.NewFromInt(2), }, { - Pair: currency.NewPair(currency.DOGE, currency.USDT), - PositionsValue: 1, + Pair: currency.NewPair(currency.DOGE, currency.USDT), + BaseValue: decimal.NewFromInt(1), }, }) - if ratio != 0.25 { + if !ratio.Equal(decimal.NewFromFloat(0.25)) { t.Errorf("expected %v received %v", 0.25, ratio) } } @@ -73,14 +74,14 @@ func TestEvaluateOrder(t *testing.T) { } r.CurrencySettings[e][a][p] = &CurrencySettings{ - MaximumOrdersWithLeverageRatio: 0.3, - MaxLeverageRate: 0.3, - MaximumHoldingRatio: 0.3, + MaximumOrdersWithLeverageRatio: decimal.NewFromFloat(0.3), + MaxLeverageRate: decimal.NewFromFloat(0.3), + MaximumHoldingRatio: decimal.NewFromFloat(0.3), } h = append(h, holdings.Holding{ - Pair: p, - PositionsSize: 1, + Pair: p, + BaseSize: decimal.NewFromInt(1), }) _, err = r.EvaluateOrder(o, h, compliance.Snapshot{}) if err != nil { @@ -88,11 +89,11 @@ func TestEvaluateOrder(t *testing.T) { } h = append(h, holdings.Holding{ - Pair: currency.NewPair(currency.DOGE, currency.USDT), - PositionsSize: 0, + Pair: currency.NewPair(currency.DOGE, currency.USDT), + BaseSize: decimal.Zero, }) - o.Leverage = 1.1 - r.CurrencySettings[e][a][p].MaximumHoldingRatio = 0 + o.Leverage = decimal.NewFromFloat(1.1) + r.CurrencySettings[e][a][p].MaximumHoldingRatio = decimal.Zero _, err = r.EvaluateOrder(o, h, compliance.Snapshot{}) if !errors.Is(err, errLeverageNotAllowed) { t.Error(err) @@ -103,15 +104,15 @@ func TestEvaluateOrder(t *testing.T) { t.Error(err) } - r.MaximumLeverage = 33 - r.CurrencySettings[e][a][p].MaxLeverageRate = 33 + r.MaximumLeverage = decimal.NewFromInt(33) + r.CurrencySettings[e][a][p].MaxLeverageRate = decimal.NewFromInt(33) _, err = r.EvaluateOrder(o, h, compliance.Snapshot{}) if err != nil { t.Error(err) } - r.MaximumLeverage = 33 - r.CurrencySettings[e][a][p].MaxLeverageRate = 33 + r.MaximumLeverage = decimal.NewFromInt(33) + r.CurrencySettings[e][a][p].MaxLeverageRate = decimal.NewFromInt(33) _, err = r.EvaluateOrder(o, h, compliance.Snapshot{ Orders: []compliance.SnapshotOrder{ @@ -126,14 +127,14 @@ func TestEvaluateOrder(t *testing.T) { t.Error(err) } - h = append(h, holdings.Holding{Pair: p, PositionsValue: 1337}, holdings.Holding{Pair: p, PositionsValue: 1337.42}) - r.CurrencySettings[e][a][p].MaximumHoldingRatio = 0.1 + h = append(h, holdings.Holding{Pair: p, BaseValue: decimal.NewFromInt(1337)}, holdings.Holding{Pair: p, BaseValue: decimal.NewFromFloat(1337.42)}) + r.CurrencySettings[e][a][p].MaximumHoldingRatio = decimal.NewFromFloat(0.1) _, err = r.EvaluateOrder(o, h, compliance.Snapshot{}) if err != nil { t.Error(err) } - h = append(h, holdings.Holding{Pair: currency.NewPair(currency.DOGE, currency.LTC), PositionsValue: 1337}) + h = append(h, holdings.Holding{Pair: currency.NewPair(currency.DOGE, currency.LTC), BaseValue: decimal.NewFromInt(1337)}) _, err = r.EvaluateOrder(o, h, compliance.Snapshot{}) if !errors.Is(err, errCannotPlaceLeverageOrder) { t.Error(err) diff --git a/backtester/eventhandlers/portfolio/risk/risk_types.go b/backtester/eventhandlers/portfolio/risk/risk_types.go index b2755ea7..d3c1d5b9 100644 --- a/backtester/eventhandlers/portfolio/risk/risk_types.go +++ b/backtester/eventhandlers/portfolio/risk/risk_types.go @@ -3,6 +3,7 @@ package risk import ( "errors" + "github.com/shopspring/decimal" "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/order" @@ -25,12 +26,12 @@ type Handler interface { type Risk struct { CurrencySettings map[string]map[asset.Item]map[currency.Pair]*CurrencySettings CanUseLeverage bool - MaximumLeverage float64 + MaximumLeverage decimal.Decimal } // CurrencySettings contains relevant limits to assess risk type CurrencySettings struct { - MaximumOrdersWithLeverageRatio float64 - MaxLeverageRate float64 - MaximumHoldingRatio float64 + MaximumOrdersWithLeverageRatio decimal.Decimal + MaxLeverageRate decimal.Decimal + MaximumHoldingRatio decimal.Decimal } diff --git a/backtester/eventhandlers/portfolio/settings/settings.go b/backtester/eventhandlers/portfolio/settings/settings.go index 1668de89..28b713d2 100644 --- a/backtester/eventhandlers/portfolio/settings/settings.go +++ b/backtester/eventhandlers/portfolio/settings/settings.go @@ -3,14 +3,14 @@ package settings import ( "time" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/portfolio/holdings" ) // GetLatestHoldings returns the latest holdings after being sorted by time func (e *Settings) GetLatestHoldings() holdings.Holding { - if e.HoldingsSnapshots == nil { - // no holdings yet - return holdings.Holding{Offset: 1} + if len(e.HoldingsSnapshots) == 0 { + return holdings.Holding{} } return e.HoldingsSnapshots[len(e.HoldingsSnapshots)-1] @@ -31,7 +31,10 @@ func (e *Settings) GetHoldingsForTime(t time.Time) holdings.Holding { } // Value returns the total value of the latest holdings -func (e *Settings) Value() float64 { +func (e *Settings) Value() decimal.Decimal { latest := e.GetLatestHoldings() + if latest.Timestamp.IsZero() { + return decimal.Zero + } return latest.TotalValue } diff --git a/backtester/eventhandlers/portfolio/settings/settings_test.go b/backtester/eventhandlers/portfolio/settings/settings_test.go index cec71f31..a37ee8df 100644 --- a/backtester/eventhandlers/portfolio/settings/settings_test.go +++ b/backtester/eventhandlers/portfolio/settings/settings_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/portfolio/holdings" ) @@ -12,7 +13,7 @@ func TestGetLatestHoldings(t *testing.T) { cs := Settings{} h := cs.GetLatestHoldings() if !h.Timestamp.IsZero() { - t.Error("expected zero time") + t.Error("expected unset holdings") } tt := time.Now() cs.HoldingsSnapshots = append(cs.HoldingsSnapshots, holdings.Holding{Timestamp: tt}) @@ -27,13 +28,18 @@ func TestValue(t *testing.T) { t.Parallel() cs := Settings{} v := cs.Value() - if v != 0 { + if !v.IsZero() { t.Error("expected 0") } - cs.HoldingsSnapshots = append(cs.HoldingsSnapshots, holdings.Holding{TotalValue: 1337}) + cs.HoldingsSnapshots = append(cs.HoldingsSnapshots, + holdings.Holding{ + Timestamp: time.Now(), + TotalValue: decimal.NewFromInt(1337), + }, + ) v = cs.Value() - if v != 1337 { - t.Errorf("expected %v, received %v", 1337, v) + if !v.Equal(decimal.NewFromInt(1337)) { + t.Errorf("expected %v, received %v", decimal.NewFromInt(1337), v) } } diff --git a/backtester/eventhandlers/portfolio/settings/settings_types.go b/backtester/eventhandlers/portfolio/settings/settings_types.go index 3dd63efe..345b1299 100644 --- a/backtester/eventhandlers/portfolio/settings/settings_types.go +++ b/backtester/eventhandlers/portfolio/settings/settings_types.go @@ -1,6 +1,7 @@ package settings import ( + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/config" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/portfolio/compliance" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/portfolio/holdings" @@ -9,8 +10,7 @@ import ( // Settings holds all important information for the portfolio manager // to assess purchasing decisions type Settings struct { - InitialFunds float64 - Fee float64 + Fee decimal.Decimal BuySideSizing config.MinMax SellSideSizing config.MinMax Leverage config.Leverage diff --git a/backtester/eventhandlers/portfolio/size/size.go b/backtester/eventhandlers/portfolio/size/size.go index da5827a3..24552601 100644 --- a/backtester/eventhandlers/portfolio/size/size.go +++ b/backtester/eventhandlers/portfolio/size/size.go @@ -3,6 +3,7 @@ package size import ( "fmt" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/common" "github.com/thrasher-corp/gocryptotrader/backtester/config" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/exchange" @@ -11,15 +12,18 @@ import ( ) // SizeOrder is responsible for ensuring that the order size is within config limits -func (s *Size) SizeOrder(o order.Event, amountAvailable float64, cs *exchange.Settings) (*order.Order, error) { +func (s *Size) SizeOrder(o order.Event, amountAvailable decimal.Decimal, cs *exchange.Settings) (*order.Order, error) { if o == nil || cs == nil { return nil, common.ErrNilArguments } - if amountAvailable <= 0 { + if amountAvailable.LessThanOrEqual(decimal.Zero) { return nil, errNoFunds } - retOrder := o.(*order.Order) - var amount float64 + retOrder, ok := o.(*order.Order) + if !ok { + return nil, fmt.Errorf("%w expected order event", common.ErrInvalidDataType) + } + var amount decimal.Decimal var err error switch retOrder.GetDirection() { case gctorder.Buy: @@ -29,13 +33,13 @@ func (s *Size) SizeOrder(o order.Event, amountAvailable float64, cs *exchange.Se return nil, err } // check size against portfolio specific settings - var portfolioSize float64 + var portfolioSize decimal.Decimal portfolioSize, err = s.calculateBuySize(retOrder.Price, amountAvailable, cs.ExchangeFee, o.GetBuyLimit(), s.BuySide) if err != nil { return nil, err } // global settings overrule individual currency settings - if amount > portfolioSize { + if amount.GreaterThan(portfolioSize) { amount = portfolioSize } @@ -51,11 +55,12 @@ func (s *Size) SizeOrder(o order.Event, amountAvailable float64, cs *exchange.Se return nil, err } // global settings overrule individual currency settings - if amount > portfolioSize { + if amount.GreaterThan(portfolioSize) { amount = portfolioSize } } - if amount <= 0 { + amount = amount.Round(8) + if amount.LessThanOrEqual(decimal.Zero) { return retOrder, fmt.Errorf("%w at %v for %v %v %v", errCannotAllocate, o.GetTime(), o.GetExchange(), o.GetAssetType(), o.Pair()) } retOrder.SetAmount(amount) @@ -67,25 +72,28 @@ func (s *Size) SizeOrder(o order.Event, amountAvailable float64, cs *exchange.Se // that is allowed to be spent/sold for an event. // As fee calculation occurs during the actual ordering process // this can only attempt to factor the potential fee to remain under the max rules -func (s *Size) calculateBuySize(price, availableFunds, feeRate, buyLimit float64, minMaxSettings config.MinMax) (float64, error) { - if availableFunds <= 0 { - return 0, errNoFunds +func (s *Size) calculateBuySize(price, availableFunds, feeRate, buyLimit decimal.Decimal, minMaxSettings config.MinMax) (decimal.Decimal, error) { + if availableFunds.LessThanOrEqual(decimal.Zero) { + return decimal.Zero, errNoFunds } - if price == 0 { - return 0, nil + if price.IsZero() { + return decimal.Zero, nil } - amount := availableFunds * (1 - feeRate) / price - if buyLimit != 0 && buyLimit >= minMaxSettings.MinimumSize && (buyLimit <= minMaxSettings.MaximumSize || minMaxSettings.MaximumSize == 0) && buyLimit <= amount { + amount := availableFunds.Mul(decimal.NewFromInt(1).Sub(feeRate)).Div(price) + if !buyLimit.IsZero() && + buyLimit.GreaterThanOrEqual(minMaxSettings.MinimumSize) && + (buyLimit.LessThanOrEqual(minMaxSettings.MaximumSize) || minMaxSettings.MaximumSize.IsZero()) && + buyLimit.LessThanOrEqual(amount) { amount = buyLimit } - if minMaxSettings.MaximumSize > 0 && amount > minMaxSettings.MaximumSize { - amount = minMaxSettings.MaximumSize * (1 - feeRate) + if minMaxSettings.MaximumSize.GreaterThan(decimal.Zero) && amount.GreaterThan(minMaxSettings.MaximumSize) { + amount = minMaxSettings.MaximumSize.Mul(decimal.NewFromInt(1).Sub(feeRate)) } - if minMaxSettings.MaximumTotal > 0 && (amount+feeRate)*price > minMaxSettings.MaximumTotal { - amount = minMaxSettings.MaximumTotal * (1 - feeRate) / price + if minMaxSettings.MaximumTotal.GreaterThan(decimal.Zero) && amount.Add(feeRate).Mul(price).GreaterThan(minMaxSettings.MaximumTotal) { + amount = minMaxSettings.MaximumTotal.Mul(decimal.NewFromInt(1).Sub(feeRate)).Div(price) } - if amount < minMaxSettings.MinimumSize && minMaxSettings.MinimumSize > 0 { - return 0, fmt.Errorf("%w. Sized: '%.8f' Minimum: '%f'", errLessThanMinimum, amount, minMaxSettings.MinimumSize) + if amount.LessThan(minMaxSettings.MinimumSize) && minMaxSettings.MinimumSize.GreaterThan(decimal.Zero) { + return decimal.Zero, fmt.Errorf("%w. Sized: '%v' Minimum: '%v'", errLessThanMinimum, amount, minMaxSettings.MinimumSize) } return amount, nil } @@ -96,25 +104,29 @@ func (s *Size) calculateBuySize(price, availableFunds, feeRate, buyLimit float64 // eg BTC-USD baseAmount will be BTC to be sold // As fee calculation occurs during the actual ordering process // this can only attempt to factor the potential fee to remain under the max rules -func (s *Size) calculateSellSize(price, baseAmount, feeRate, sellLimit float64, minMaxSettings config.MinMax) (float64, error) { - if baseAmount <= 0 { - return 0, errNoFunds +func (s *Size) calculateSellSize(price, baseAmount, feeRate, sellLimit decimal.Decimal, minMaxSettings config.MinMax) (decimal.Decimal, error) { + if baseAmount.LessThanOrEqual(decimal.Zero) { + return decimal.Zero, errNoFunds } - if price == 0 { - return 0, nil + if price.IsZero() { + return decimal.Zero, nil } - amount := baseAmount * (1 - feeRate) - if sellLimit != 0 && sellLimit >= minMaxSettings.MinimumSize && (sellLimit <= minMaxSettings.MaximumSize || minMaxSettings.MaximumSize == 0) && sellLimit <= amount { + oneMFeeRate := decimal.NewFromInt(1).Sub(feeRate) + amount := baseAmount.Mul(oneMFeeRate) + if !sellLimit.IsZero() && + sellLimit.GreaterThanOrEqual(minMaxSettings.MinimumSize) && + (sellLimit.LessThanOrEqual(minMaxSettings.MaximumSize) || minMaxSettings.MaximumSize.IsZero()) && + sellLimit.LessThanOrEqual(amount) { amount = sellLimit } - if minMaxSettings.MaximumSize > 0 && amount > minMaxSettings.MaximumSize { - amount = minMaxSettings.MaximumSize * (1 - feeRate) + if minMaxSettings.MaximumSize.GreaterThan(decimal.Zero) && amount.GreaterThan(minMaxSettings.MaximumSize) { + amount = minMaxSettings.MaximumSize.Mul(oneMFeeRate) } - if minMaxSettings.MaximumTotal > 0 && amount*price > minMaxSettings.MaximumTotal { - amount = minMaxSettings.MaximumTotal * (1 - feeRate) / price + if minMaxSettings.MaximumTotal.GreaterThan(decimal.Zero) && amount.Mul(price).GreaterThan(minMaxSettings.MaximumTotal) { + amount = minMaxSettings.MaximumTotal.Mul(oneMFeeRate).Div(price) } - if amount < minMaxSettings.MinimumSize && minMaxSettings.MinimumSize > 0 { - return 0, fmt.Errorf("%w. Sized: '%.8f' Minimum: '%f'", errLessThanMinimum, amount, minMaxSettings.MinimumSize) + if amount.LessThan(minMaxSettings.MinimumSize) && minMaxSettings.MinimumSize.GreaterThan(decimal.Zero) { + return decimal.Zero, fmt.Errorf("%w. Sized: '%v' Minimum: '%v'", errLessThanMinimum, amount, minMaxSettings.MinimumSize) } return amount, nil diff --git a/backtester/eventhandlers/portfolio/size/size_test.go b/backtester/eventhandlers/portfolio/size/size_test.go index ac05f37b..1de22700 100644 --- a/backtester/eventhandlers/portfolio/size/size_test.go +++ b/backtester/eventhandlers/portfolio/size/size_test.go @@ -4,6 +4,7 @@ import ( "errors" "testing" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/common" "github.com/thrasher-corp/gocryptotrader/backtester/config" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/exchange" @@ -14,48 +15,48 @@ import ( func TestSizingAccuracy(t *testing.T) { t.Parallel() globalMinMax := config.MinMax{ - MinimumSize: 0, - MaximumSize: 1, - MaximumTotal: 1337, + MinimumSize: decimal.Zero, + MaximumSize: decimal.NewFromInt(1), + MaximumTotal: decimal.NewFromInt(10), } sizer := Size{ BuySide: globalMinMax, SellSide: globalMinMax, } - price := 1338.0 - availableFunds := 1338.0 - feeRate := 0.02 - var buylimit float64 = 1 - amountWithoutFee, err := sizer.calculateBuySize(price, availableFunds, feeRate, buylimit, globalMinMax) + price := decimal.NewFromInt(10) + availableFunds := decimal.NewFromInt(11) + feeRate := decimal.NewFromFloat(0.02) + buyLimit := decimal.NewFromInt(1) + amountWithoutFee, err := sizer.calculateBuySize(price, availableFunds, feeRate, buyLimit, globalMinMax) if err != nil { t.Error(err) } - totalWithFee := (price * amountWithoutFee) + (globalMinMax.MaximumTotal * feeRate) - if totalWithFee != globalMinMax.MaximumTotal { - t.Error("incorrect amount calculation") + totalWithFee := (price.Mul(amountWithoutFee)).Add(globalMinMax.MaximumTotal.Mul(feeRate)) + if !totalWithFee.Equal(globalMinMax.MaximumTotal) { + t.Errorf("expected %v received %v", globalMinMax.MaximumTotal, totalWithFee) } } func TestSizingOverMaxSize(t *testing.T) { t.Parallel() globalMinMax := config.MinMax{ - MinimumSize: 0, - MaximumSize: 0.5, - MaximumTotal: 1337, + MinimumSize: decimal.Zero, + MaximumSize: decimal.NewFromFloat(0.5), + MaximumTotal: decimal.NewFromInt(1337), } sizer := Size{ BuySide: globalMinMax, SellSide: globalMinMax, } - price := 1338.0 - availableFunds := 1338.0 - feeRate := 0.02 - var buylimit float64 = 1 - amount, err := sizer.calculateBuySize(price, availableFunds, feeRate, buylimit, globalMinMax) + price := decimal.NewFromInt(1338) + availableFunds := decimal.NewFromInt(1338) + feeRate := decimal.NewFromFloat(0.02) + buyLimit := decimal.NewFromInt(1) + amount, err := sizer.calculateBuySize(price, availableFunds, feeRate, buyLimit, globalMinMax) if err != nil { t.Error(err) } - if amount > globalMinMax.MaximumSize { + if amount.GreaterThan(globalMinMax.MaximumSize) { t.Error("greater than max") } } @@ -63,112 +64,112 @@ func TestSizingOverMaxSize(t *testing.T) { func TestSizingUnderMinSize(t *testing.T) { t.Parallel() globalMinMax := config.MinMax{ - MinimumSize: 1, - MaximumSize: 2, - MaximumTotal: 1337, + MinimumSize: decimal.NewFromInt(1), + MaximumSize: decimal.NewFromInt(2), + MaximumTotal: decimal.NewFromInt(1337), } sizer := Size{ BuySide: globalMinMax, SellSide: globalMinMax, } - price := 1338.0 - availableFunds := 1338.0 - feeRate := 0.02 - var buylimit float64 = 1 - _, err := sizer.calculateBuySize(price, availableFunds, feeRate, buylimit, globalMinMax) + price := decimal.NewFromInt(1338) + availableFunds := decimal.NewFromInt(1338) + feeRate := decimal.NewFromFloat(0.02) + buyLimit := decimal.NewFromInt(1) + _, err := sizer.calculateBuySize(price, availableFunds, feeRate, buyLimit, globalMinMax) if !errors.Is(err, errLessThanMinimum) { - t.Errorf("expected: %v, received %v", errLessThanMinimum, err) + t.Errorf("received: %v, expected: %v", err, errLessThanMinimum) } } func TestMaximumBuySizeEqualZero(t *testing.T) { t.Parallel() globalMinMax := config.MinMax{ - MinimumSize: 1, - MaximumSize: 0, - MaximumTotal: 1437, + MinimumSize: decimal.NewFromInt(1), + MaximumSize: decimal.Zero, + MaximumTotal: decimal.NewFromInt(1437), } sizer := Size{ BuySide: globalMinMax, SellSide: globalMinMax, } - price := 1338.0 - availableFunds := 13380.0 - feeRate := 0.02 - var buylimit float64 = 1 - amount, err := sizer.calculateBuySize(price, availableFunds, feeRate, buylimit, globalMinMax) - if amount != buylimit || err != nil { - t.Errorf("expected: %v, received %v, err: %+v", buylimit, amount, err) + price := decimal.NewFromInt(1338) + availableFunds := decimal.NewFromInt(13380) + feeRate := decimal.NewFromFloat(0.02) + buyLimit := decimal.NewFromInt(1) + amount, err := sizer.calculateBuySize(price, availableFunds, feeRate, buyLimit, globalMinMax) + if amount != buyLimit || err != nil { + t.Errorf("expected: %v, received %v, err: %+v", buyLimit, amount, err) } } func TestMaximumSellSizeEqualZero(t *testing.T) { t.Parallel() globalMinMax := config.MinMax{ - MinimumSize: 1, - MaximumSize: 0, - MaximumTotal: 1437, + MinimumSize: decimal.NewFromInt(1), + MaximumSize: decimal.Zero, + MaximumTotal: decimal.NewFromInt(1437), } sizer := Size{ BuySide: globalMinMax, SellSide: globalMinMax, } - price := 1338.0 - availableFunds := 13380.0 - feeRate := 0.02 - var selllimit float64 = 1 - amount, err := sizer.calculateSellSize(price, availableFunds, feeRate, selllimit, globalMinMax) - if amount != selllimit || err != nil { - t.Errorf("expected: %v, received %v, err: %+v", selllimit, amount, err) + price := decimal.NewFromInt(1338) + availableFunds := decimal.NewFromInt(13380) + feeRate := decimal.NewFromFloat(0.02) + sellLimit := decimal.NewFromInt(1) + amount, err := sizer.calculateSellSize(price, availableFunds, feeRate, sellLimit, globalMinMax) + if amount != sellLimit || err != nil { + t.Errorf("expected: %v, received %v, err: %+v", sellLimit, amount, err) } } func TestSizingErrors(t *testing.T) { t.Parallel() globalMinMax := config.MinMax{ - MinimumSize: 1, - MaximumSize: 2, - MaximumTotal: 1337, + MinimumSize: decimal.NewFromInt(1), + MaximumSize: decimal.NewFromInt(2), + MaximumTotal: decimal.NewFromInt(1337), } sizer := Size{ BuySide: globalMinMax, SellSide: globalMinMax, } - price := 1338.0 - availableFunds := 0.0 - feeRate := 0.02 - var buylimit float64 = 1 - _, err := sizer.calculateBuySize(price, availableFunds, feeRate, buylimit, globalMinMax) + price := decimal.NewFromInt(1338) + availableFunds := decimal.Zero + feeRate := decimal.NewFromFloat(0.02) + buyLimit := decimal.NewFromInt(1) + _, err := sizer.calculateBuySize(price, availableFunds, feeRate, buyLimit, globalMinMax) if !errors.Is(err, errNoFunds) { - t.Errorf("expected: %v, received %v", errNoFunds, err) + t.Errorf("received: %v, expected: %v", err, errNoFunds) } } func TestCalculateSellSize(t *testing.T) { t.Parallel() globalMinMax := config.MinMax{ - MinimumSize: 1, - MaximumSize: 2, - MaximumTotal: 1337, + MinimumSize: decimal.NewFromInt(1), + MaximumSize: decimal.NewFromInt(2), + MaximumTotal: decimal.NewFromInt(1337), } sizer := Size{ BuySide: globalMinMax, SellSide: globalMinMax, } - price := 1338.0 - availableFunds := 0.0 - feeRate := 0.02 - var sellLimit float64 = 1 + price := decimal.NewFromInt(1338) + availableFunds := decimal.Zero + feeRate := decimal.NewFromFloat(0.02) + sellLimit := decimal.NewFromInt(1) _, err := sizer.calculateSellSize(price, availableFunds, feeRate, sellLimit, globalMinMax) if !errors.Is(err, errNoFunds) { - t.Errorf("expected: %v, received %v", errNoFunds, err) + t.Errorf("received: %v, expected: %v", err, errNoFunds) } - availableFunds = 1337 + availableFunds = decimal.NewFromInt(1337) _, err = sizer.calculateSellSize(price, availableFunds, feeRate, sellLimit, globalMinMax) if !errors.Is(err, errLessThanMinimum) { - t.Errorf("expected: %v, received %v", errLessThanMinimum, err) + t.Errorf("received: %v, expected: %v", err, errLessThanMinimum) } - price = 12 - availableFunds = 1339 + price = decimal.NewFromInt(12) + availableFunds = decimal.NewFromInt(1339) _, err = sizer.calculateSellSize(price, availableFunds, feeRate, sellLimit, globalMinMax) if err != nil { t.Error(err) @@ -178,40 +179,40 @@ func TestCalculateSellSize(t *testing.T) { func TestSizeOrder(t *testing.T) { t.Parallel() s := Size{} - _, err := s.SizeOrder(nil, 0, nil) + _, err := s.SizeOrder(nil, decimal.Zero, nil) if !errors.Is(err, common.ErrNilArguments) { t.Error(err) } o := &order.Order{} cs := &exchange.Settings{} - _, err = s.SizeOrder(o, 0, cs) + _, err = s.SizeOrder(o, decimal.Zero, cs) if !errors.Is(err, errNoFunds) { - t.Errorf("expected: %v, received %v", errNoFunds, err) + t.Errorf("received: %v, expected: %v", err, errNoFunds) } - _, err = s.SizeOrder(o, 1337, cs) + _, err = s.SizeOrder(o, decimal.NewFromInt(1337), cs) if !errors.Is(err, errCannotAllocate) { - t.Errorf("expected: %v, received %v", errCannotAllocate, err) + t.Errorf("received: %v, expected: %v", err, errCannotAllocate) } o.Direction = gctorder.Buy - o.Price = 1 - s.BuySide.MaximumSize = 1 - s.BuySide.MinimumSize = 1 - _, err = s.SizeOrder(o, 1337, cs) + o.Price = decimal.NewFromInt(1) + s.BuySide.MaximumSize = decimal.NewFromInt(1) + s.BuySide.MinimumSize = decimal.NewFromInt(1) + _, err = s.SizeOrder(o, decimal.NewFromInt(1337), cs) if err != nil { t.Error(err) } o.Direction = gctorder.Sell - _, err = s.SizeOrder(o, 1337, cs) + _, err = s.SizeOrder(o, decimal.NewFromInt(1337), cs) if err != nil { t.Error(err) } - s.SellSide.MaximumSize = 1 - s.SellSide.MinimumSize = 1 - _, err = s.SizeOrder(o, 1337, cs) + s.SellSide.MaximumSize = decimal.NewFromInt(1) + s.SellSide.MinimumSize = decimal.NewFromInt(1) + _, err = s.SizeOrder(o, decimal.NewFromInt(1337), cs) if err != nil { t.Error(err) } diff --git a/backtester/eventhandlers/statistics/currencystatistics/README.md b/backtester/eventhandlers/statistics/currencystatistics/README.md index f7ba615c..77b44741 100644 --- a/backtester/eventhandlers/statistics/currencystatistics/README.md +++ b/backtester/eventhandlers/statistics/currencystatistics/README.md @@ -35,10 +35,10 @@ It can calculate the following: | Ratio | Description | A good range | | ----- | ----------- | ------------ | -| Calmar ratio | It is a function of the fund's average compounded annual rate of return versus its maximum drawdown. The higher the Calmar ratio, the better it performed on a risk-adjusted basis during the given time frame, which is mostly commonly set at 36 months. | 3.0 to 5.0 | +| Calmar ratio | It is a function of the fund's average compounded annual rate of return versus its maximum drawdown. The higher the Calmar ratio, the better it performed on a risk-adjusted basis during the given time frame, which is mostly commonly set at 36 months | 3.0 to 5.0 | | Information ratio| It is a measurement of portfolio returns beyond the returns of a benchmark, usually an index, compared to the volatility of those returns. The ratio is often used as a measure of a portfolio manager's level of skill and ability to generate excess returns relative to a benchmark | 0.40-0.60. Any positive number means that it has beaten the benchmark | | Sharpe ratio | The Sharpe Ratio is a financial metric often used by investors when assessing the performance of investment management products and professionals. It consists of taking the excess return of the portfolio, relative to the risk-free rate, and dividing it by the standard deviation of the portfolio's excess returns | Any Sharpe ratio greater than 1.0 is good. Higher than 2.0 is very good. 3.0 or higher is excellent. Under 1.0 is sub-optimal | -| Sortino ratio | The Sortino ratio measures the risk-adjusted return of an investment asset, portfolio, or strategy. It is a modification of the Sharpe ratio but penalizes only those returns falling below a user-specified target or required rate of return, while the Sharpe ratio penalizes both upside and downside volatility equally. | The higher the better, but > 2 is considered good. | +| Sortino ratio | The Sortino ratio measures the risk-adjusted return of an investment asset, portfolio, or strategy. It is a modification of the Sharpe ratio but penalizes only those returns falling below a user-specified target or required rate of return, while the Sharpe ratio penalizes both upside and downside volatility equally | The higher the better, but > 2 is considered good | | Compound annual growth rate | Compound annual growth rate is the rate of return that would be required for an investment to grow from its beginning balance to its ending balance, assuming the profits were reinvested at the end of each year of the investment’s lifespan | Any positive number | ## Arithmetic or versus geometric? @@ -47,7 +47,7 @@ Both! We calculate ratios where an average is required using both types. The rea | Average type | A reason to use it | | ------------ | ------------------ | | Arithmetic | The arithmetic mean is the average of a sum of numbers, which reflects the central tendency of the position of the numbers | -| Geometric | The geometric mean differs from the arithmetic average, or arithmetic mean, in how it is calculated because it takes into account the compounding that occurs from period to period. Because of this, investors usually consider the geometric mean a more accurate measure of returns than the arithmetic mean. | +| Geometric | The geometric mean differs from the arithmetic average, or arithmetic mean, in how it is calculated because it takes into account the compounding that occurs from period to period. Because of this, investors usually consider the geometric mean a more accurate measure of returns than the arithmetic mean | diff --git a/backtester/eventhandlers/statistics/currencystatistics/currencystatistics.go b/backtester/eventhandlers/statistics/currencystatistics/currencystatistics.go index 1ca49619..a2b2e1aa 100644 --- a/backtester/eventhandlers/statistics/currencystatistics/currencystatistics.go +++ b/backtester/eventhandlers/statistics/currencystatistics/currencystatistics.go @@ -1,13 +1,16 @@ package currencystatistics import ( + "errors" "fmt" "sort" "time" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/common" + "github.com/thrasher-corp/gocryptotrader/backtester/funding" gctcommon "github.com/thrasher-corp/gocryptotrader/common" - "github.com/thrasher-corp/gocryptotrader/common/math" + 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" @@ -16,9 +19,12 @@ import ( ) // CalculateResults calculates all statistics for the exchange, asset, currency pair -func (c *CurrencyStatistic) CalculateResults() error { +func (c *CurrencyStatistic) CalculateResults(f funding.IPairReader) 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() @@ -31,19 +37,23 @@ func (c *CurrencyStatistic) CalculateResults() error { } for i := range c.Events { price := c.Events[i].DataEvent.ClosePrice() - if c.LowestClosePrice == 0 || price < c.LowestClosePrice { + if c.LowestClosePrice.IsZero() || price.LessThan(c.LowestClosePrice) { c.LowestClosePrice = price } - if price > c.HighestClosePrice { + if price.GreaterThan(c.HighestClosePrice) { c.HighestClosePrice = price } } - c.MarketMovement = ((lastPrice - firstPrice) / firstPrice) * 100 - c.StrategyMovement = ((last.Holdings.TotalValue - last.Holdings.InitialFunds) / last.Holdings.InitialFunds) * 100 + + 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() - c.RiskFreeRate = last.Holdings.RiskFreeRate * 100 - returnPerCandle := make([]float64, len(c.Events)) - benchmarkRates := make([]float64, len(c.Events)) + c.RiskFreeRate = last.Holdings.RiskFreeRate.Mul(oneHundred) + returnPerCandle := make([]decimal.Decimal, len(c.Events)) + benchmarkRates := make([]decimal.Decimal, len(c.Events)) var allDataEvents []common.DataEventHandler for i := range c.Events { @@ -55,7 +65,9 @@ func (c *CurrencyStatistic) CalculateResults() error { if c.Events[i].SignalEvent != nil && c.Events[i].SignalEvent.GetDirection() == common.MissingData { c.ShowMissingDataWarning = true } - benchmarkRates[i] = (c.Events[i].DataEvent.ClosePrice() - c.Events[i-1].DataEvent.ClosePrice()) / c.Events[i-1].DataEvent.ClosePrice() + 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 @@ -63,13 +75,12 @@ func (c *CurrencyStatistic) CalculateResults() error { benchmarkRates = benchmarkRates[1:] returnPerCandle = returnPerCandle[1:] - var arithmeticBenchmarkAverage, geometricBenchmarkAverage float64 - var err error - arithmeticBenchmarkAverage, err = math.ArithmeticMean(benchmarkRates) + var arithmeticBenchmarkAverage, geometricBenchmarkAverage decimal.Decimal + arithmeticBenchmarkAverage, err = gctmath.DecimalArithmeticMean(benchmarkRates) if err != nil { errs = append(errs, err) } - geometricBenchmarkAverage, err = math.FinancialGeometricMean(benchmarkRates) + geometricBenchmarkAverage, err = gctmath.DecimalFinancialGeometricMean(benchmarkRates) if err != nil { errs = append(errs, err) } @@ -78,75 +89,108 @@ func (c *CurrencyStatistic) CalculateResults() error { interval := first.DataEvent.GetInterval() intervalsPerYear := interval.IntervalsPerYear() - riskFreeRatePerCandle := first.Holdings.RiskFreeRate / intervalsPerYear - riskFreeRateForPeriod := riskFreeRatePerCandle * float64(len(benchmarkRates)) + riskFreeRatePerCandle := first.Holdings.RiskFreeRate.Div(decimal.NewFromFloat(intervalsPerYear)) + riskFreeRateForPeriod := riskFreeRatePerCandle.Mul(decimal.NewFromInt(int64(len(benchmarkRates)))) var arithmeticReturnsPerCandle, geometricReturnsPerCandle, arithmeticSharpe, arithmeticSortino, - arithmeticInformation, arithmeticCalmar, geomSharpe, geomSortino, geomInformation, geomCalmar float64 + arithmeticInformation, arithmeticCalmar, geomSharpe, geomSortino, geomInformation, geomCalmar decimal.Decimal - arithmeticReturnsPerCandle, err = math.ArithmeticMean(returnPerCandle) + arithmeticReturnsPerCandle, err = gctmath.DecimalArithmeticMean(returnPerCandle) if err != nil { errs = append(errs, err) } - geometricReturnsPerCandle, err = math.FinancialGeometricMean(returnPerCandle) + geometricReturnsPerCandle, err = gctmath.DecimalFinancialGeometricMean(returnPerCandle) if err != nil { errs = append(errs, err) } - arithmeticSharpe, err = math.SharpeRatio(returnPerCandle, riskFreeRatePerCandle, arithmeticReturnsPerCandle) + arithmeticSharpe, err = gctmath.DecimalSharpeRatio(returnPerCandle, riskFreeRatePerCandle, arithmeticReturnsPerCandle) if err != nil { errs = append(errs, err) } - arithmeticSortino, err = math.SortinoRatio(returnPerCandle, riskFreeRatePerCandle, arithmeticReturnsPerCandle) + arithmeticSortino, err = gctmath.DecimalSortinoRatio(returnPerCandle, riskFreeRatePerCandle, arithmeticReturnsPerCandle) + if err != nil && !errors.Is(err, gctmath.ErrNoNegativeResults) { + if errors.Is(err, gctmath.ErrInexactConversion) { + log.Warnf(log.BackTester, "%v arithmetic sortino ratio %v", sep, err) + } else { + errs = append(errs, err) + } + } + arithmeticInformation, err = gctmath.DecimalInformationRatio(returnPerCandle, benchmarkRates, arithmeticReturnsPerCandle, arithmeticBenchmarkAverage) if err != nil { errs = append(errs, err) } - arithmeticInformation, err = math.InformationRatio(returnPerCandle, benchmarkRates, arithmeticReturnsPerCandle, arithmeticBenchmarkAverage) + mxhp := c.MaxDrawdown.Highest.Price + mdlp := c.MaxDrawdown.Lowest.Price + arithmeticCalmar, err = gctmath.DecimalCalmarRatio(mxhp, mdlp, arithmeticReturnsPerCandle, riskFreeRateForPeriod) if err != nil { errs = append(errs, err) } - arithmeticCalmar, err = math.CalmarRatio(c.MaxDrawdown.Highest.Price, c.MaxDrawdown.Lowest.Price, arithmeticReturnsPerCandle, riskFreeRateForPeriod) - if err != nil { - errs = append(errs, err) - } - c.ArithmeticRatios = Ratios{ - SharpeRatio: arithmeticSharpe, - SortinoRatio: arithmeticSortino, - InformationRatio: arithmeticInformation, - CalmarRatio: arithmeticCalmar, - } - geomSharpe, err = math.SharpeRatio(returnPerCandle, riskFreeRatePerCandle, geometricReturnsPerCandle) - if err != nil { - errs = append(errs, err) + c.ArithmeticRatios = Ratios{} + if !arithmeticSharpe.IsZero() { + c.ArithmeticRatios.SharpeRatio = arithmeticSharpe } - geomSortino, err = math.SortinoRatio(returnPerCandle, riskFreeRatePerCandle, geometricReturnsPerCandle) - if err != nil { - errs = append(errs, err) + if !arithmeticSortino.IsZero() { + c.ArithmeticRatios.SortinoRatio = arithmeticSortino } - geomInformation, err = math.InformationRatio(returnPerCandle, benchmarkRates, geometricReturnsPerCandle, geometricBenchmarkAverage) - if err != nil { - errs = append(errs, err) + if !arithmeticInformation.IsZero() { + c.ArithmeticRatios.InformationRatio = arithmeticInformation } - geomCalmar, err = math.CalmarRatio(c.MaxDrawdown.Highest.Price, c.MaxDrawdown.Lowest.Price, geometricReturnsPerCandle, riskFreeRateForPeriod) - if err != nil { - errs = append(errs, err) - } - c.GeometricRatios = Ratios{ - SharpeRatio: geomSharpe, - SortinoRatio: geomSortino, - InformationRatio: geomInformation, - CalmarRatio: geomCalmar, + if !arithmeticCalmar.IsZero() { + c.ArithmeticRatios.CalmarRatio = arithmeticCalmar } - c.CompoundAnnualGrowthRate, err = math.CompoundAnnualGrowthRate( - last.Holdings.InitialFunds, - last.Holdings.TotalValue, - intervalsPerYear, - float64(len(c.Events))) + geomSharpe, err = gctmath.DecimalSharpeRatio(returnPerCandle, riskFreeRatePerCandle, geometricReturnsPerCandle) if err != nil { errs = append(errs, err) } + geomSortino, err = gctmath.DecimalSortinoRatio(returnPerCandle, riskFreeRatePerCandle, geometricReturnsPerCandle) + if err != nil && !errors.Is(err, gctmath.ErrNoNegativeResults) { + if errors.Is(err, gctmath.ErrInexactConversion) { + log.Warnf(log.BackTester, "%v geometric sortino ratio %v", sep, err) + } else { + errs = append(errs, err) + } + } + geomInformation, err = gctmath.DecimalInformationRatio(returnPerCandle, benchmarkRates, geometricReturnsPerCandle, geometricBenchmarkAverage) + if err != nil { + errs = append(errs, err) + } + geomCalmar, err = gctmath.DecimalCalmarRatio(mxhp, mdlp, geometricReturnsPerCandle, riskFreeRateForPeriod) + if err != nil { + errs = append(errs, err) + } + c.GeometricRatios = Ratios{} + if !arithmeticSharpe.IsZero() { + c.GeometricRatios.SharpeRatio = geomSharpe + } + if !arithmeticSortino.IsZero() { + c.GeometricRatios.SortinoRatio = geomSortino + } + if !arithmeticInformation.IsZero() { + c.GeometricRatios.InformationRatio = geomInformation + } + if !arithmeticCalmar.IsZero() { + c.GeometricRatios.CalmarRatio = geomCalmar + } + + 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) if len(errs) > 0 { return errs } @@ -154,7 +198,7 @@ func (c *CurrencyStatistic) CalculateResults() error { } // PrintResults outputs all calculated statistics to the command line -func (c *CurrencyStatistic) PrintResults(e string, a asset.Item, p currency.Pair) { +func (c *CurrencyStatistic) PrintResults(e string, a asset.Item, p currency.Pair, f funding.IPairReader, 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()) @@ -164,74 +208,84 @@ func (c *CurrencyStatistic) PrintResults(e string, a asset.Item, p currency.Pair c.StartingClosePrice = first.DataEvent.ClosePrice() c.EndingClosePrice = last.DataEvent.ClosePrice() c.TotalOrders = c.BuyOrders + c.SellOrders - last.Holdings.TotalValueLost = last.Holdings.TotalValueLostToSlippage + last.Holdings.TotalValueLostToVolumeSizing + 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, "Initial funds: $%.2f", last.Holdings.InitialFunds) - log.Infof(log.BackTester, "Highest committed funds: $%.2f at %v\n\n", c.HighestCommittedFunds.Value, c.HighestCommittedFunds.Time) + log.Infof(log.BackTester, "%s Initial base funds: %v", sep, f.BaseInitialFunds()) + log.Infof(log.BackTester, "%s Initial base quote: %v", sep, f.QuoteInitialFunds()) + log.Infof(log.BackTester, "%s Highest committed funds: %v at %v\n\n", sep, c.HighestCommittedFunds.Value.Round(8), c.HighestCommittedFunds.Time) - log.Infof(log.BackTester, "Buy orders: %d", c.BuyOrders) - log.Infof(log.BackTester, "Buy value: $%.2f", last.Holdings.BoughtValue) - log.Infof(log.BackTester, "Buy amount: %.2f %v", last.Holdings.BoughtAmount, last.Holdings.Pair.Base) - log.Infof(log.BackTester, "Sell orders: %d", c.SellOrders) - log.Infof(log.BackTester, "Sell value: $%.2f", last.Holdings.SoldValue) - log.Infof(log.BackTester, "Sell amount: %.2f %v", last.Holdings.SoldAmount, last.Holdings.Pair.Base) - log.Infof(log.BackTester, "Total orders: %d\n\n", c.TotalOrders) + log.Infof(log.BackTester, "%s Buy orders: %d", sep, c.BuyOrders) + log.Infof(log.BackTester, "%s Buy value: %v", sep, last.Holdings.BoughtValue.Round(8)) + log.Infof(log.BackTester, "%s Buy amount: %v %v", sep, last.Holdings.BoughtAmount.Round(8), last.Holdings.Pair.Base) + log.Infof(log.BackTester, "%s Sell orders: %d", sep, c.SellOrders) + log.Infof(log.BackTester, "%s Sell value: %v", sep, last.Holdings.SoldValue.Round(8)) + log.Infof(log.BackTester, "%s Sell amount: %v %v", sep, last.Holdings.SoldAmount.Round(8), last.Holdings.Pair.Base) + log.Infof(log.BackTester, "%s Total orders: %d\n\n", sep, c.TotalOrders) log.Info(log.BackTester, "------------------Max Drawdown-------------------------------") - log.Infof(log.BackTester, "Highest Price of drawdown: $%.2f", c.MaxDrawdown.Highest.Price) - log.Infof(log.BackTester, "Time of highest price of drawdown: %v", c.MaxDrawdown.Highest.Time) - log.Infof(log.BackTester, "Lowest Price of drawdown: $%.2f", c.MaxDrawdown.Lowest.Price) - log.Infof(log.BackTester, "Time of lowest price of drawdown: %v", c.MaxDrawdown.Lowest.Time) - log.Infof(log.BackTester, "Calculated Drawdown: %.2f%%", c.MaxDrawdown.DrawdownPercent) - log.Infof(log.BackTester, "Difference: $%.2f", c.MaxDrawdown.Highest.Price-c.MaxDrawdown.Lowest.Price) - log.Infof(log.BackTester, "Drawdown length: %d\n\n", c.MaxDrawdown.IntervalDuration) + log.Infof(log.BackTester, "%s Highest Price of drawdown: %v", sep, c.MaxDrawdown.Highest.Price.Round(8)) + log.Infof(log.BackTester, "%s Time of highest price of drawdown: %v", sep, c.MaxDrawdown.Highest.Time) + log.Infof(log.BackTester, "%s Lowest Price of drawdown: %v", sep, c.MaxDrawdown.Lowest.Price.Round(8)) + log.Infof(log.BackTester, "%s Time of lowest price of drawdown: %v", sep, c.MaxDrawdown.Lowest.Time) + log.Infof(log.BackTester, "%s Calculated Drawdown: %v%%", sep, c.MaxDrawdown.DrawdownPercent.Round(2)) + log.Infof(log.BackTester, "%s Difference: %v", sep, c.MaxDrawdown.Highest.Price.Sub(c.MaxDrawdown.Lowest.Price).Round(2)) + log.Infof(log.BackTester, "%s Drawdown length: %d\n\n", sep, c.MaxDrawdown.IntervalDuration) log.Info(log.BackTester, "------------------Rates-------------------------------------------------") - log.Infof(log.BackTester, "Risk free rate: %.3f%%", c.RiskFreeRate) - log.Infof(log.BackTester, "Compound Annual Growth Rate: %.2f\n\n", c.CompoundAnnualGrowthRate) + log.Infof(log.BackTester, "%s Risk free rate: %v%%", sep, c.RiskFreeRate.Round(2)) + log.Infof(log.BackTester, "%s Compound Annual Growth Rate: %v\n\n", sep, c.CompoundAnnualGrowthRate.Round(2)) - log.Info(log.BackTester, "------------------Arithmetic Ratios-------------------------------------") + log.Info(log.BackTester, "------------------Ratios------------------------------------------------") + if usingExchangeLevelFunding { + log.Warnf(log.BackTester, "%s This strategy is using Exchange Level Funding. Calculation of ratios may be inaccurate\n", sep) + } + 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, "Sharpe ratio: %.2f", c.ArithmeticRatios.SharpeRatio) - log.Infof(log.BackTester, "Sortino ratio: %.2f", c.ArithmeticRatios.SortinoRatio) - log.Infof(log.BackTester, "Information ratio: %.2f", c.ArithmeticRatios.InformationRatio) - log.Infof(log.BackTester, "Calmar ratio: %.2f\n\n", c.ArithmeticRatios.CalmarRatio) + 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\n\n", sep, c.ArithmeticRatios.CalmarRatio.Round(4)) - log.Info(log.BackTester, "------------------Geometric Ratios-------------------------------------") + 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, "Sharpe ratio: %.2f", c.GeometricRatios.SharpeRatio) - log.Infof(log.BackTester, "Sortino ratio: %.2f", c.GeometricRatios.SortinoRatio) - log.Infof(log.BackTester, "Information ratio: %.2f", c.GeometricRatios.InformationRatio) - log.Infof(log.BackTester, "Calmar ratio: %.2f\n\n", c.GeometricRatios.CalmarRatio) + 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, "Starting Close Price: $%.2f", c.StartingClosePrice) - log.Infof(log.BackTester, "Finishing Close Price: $%.2f", c.EndingClosePrice) - log.Infof(log.BackTester, "Lowest Close Price: $%.2f", c.LowestClosePrice) - log.Infof(log.BackTester, "Highest Close Price: $%.2f", c.HighestClosePrice) + log.Infof(log.BackTester, "%s Starting Close Price: %v", sep, c.StartingClosePrice.Round(8)) + log.Infof(log.BackTester, "%s Finishing Close Price: %v", sep, c.EndingClosePrice.Round(8)) + log.Infof(log.BackTester, "%s Lowest Close Price: %v", sep, c.LowestClosePrice.Round(8)) + log.Infof(log.BackTester, "%s Highest Close Price: %v", sep, c.HighestClosePrice.Round(8)) - log.Infof(log.BackTester, "Market movement: %.4f%%", c.MarketMovement) - log.Infof(log.BackTester, "Strategy movement: %.4f%%", c.StrategyMovement) - log.Infof(log.BackTester, "Did it beat the market: %v", c.StrategyMovement > c.MarketMovement) + log.Infof(log.BackTester, "%s Market movement: %v%%", sep, c.MarketMovement.Round(2)) + if usingExchangeLevelFunding { + log.Warnf(log.BackTester, "%s This strategy is using Exchange Level Funding. Calculation of strategic performance may be inaccurate", sep) + } + log.Infof(log.BackTester, "%s Strategy movement: %v%%", sep, c.StrategyMovement.Round(2)) + log.Infof(log.BackTester, "%s Did it beat the market: %v", sep, c.StrategyMovement.GreaterThan(c.MarketMovement)) - log.Infof(log.BackTester, "Value lost to volume sizing: $%.2f", last.Holdings.TotalValueLostToVolumeSizing) - log.Infof(log.BackTester, "Value lost to slippage: $%.2f", last.Holdings.TotalValueLostToSlippage) - log.Infof(log.BackTester, "Total Value lost: $%.2f", last.Holdings.TotalValueLost) - log.Infof(log.BackTester, "Total Fees: $%.2f\n\n", last.Holdings.TotalFees) - - log.Infof(log.BackTester, "Final funds: $%.2f", last.Holdings.RemainingFunds) - log.Infof(log.BackTester, "Final holdings: %.2f", last.Holdings.PositionsSize) - log.Infof(log.BackTester, "Final holdings value: $%.2f", last.Holdings.PositionsValue) - log.Infof(log.BackTester, "Final total value: $%.2f\n\n", last.Holdings.TotalValue) + log.Infof(log.BackTester, "%s Value lost to volume sizing: %v", sep, last.Holdings.TotalValueLostToVolumeSizing.Round(2)) + log.Infof(log.BackTester, "%s Value lost to slippage: %v", sep, last.Holdings.TotalValueLostToSlippage.Round(2)) + log.Infof(log.BackTester, "%s Total Value lost: %v", sep, last.Holdings.TotalValueLost.Round(2)) + log.Infof(log.BackTester, "%s Total Fees: %v\n\n", sep, last.Holdings.TotalFees.Round(8)) + log.Infof(log.BackTester, "%s Final funds: %v", sep, last.Holdings.QuoteSize.Round(8)) + log.Infof(log.BackTester, "%s Final holdings: %v", sep, last.Holdings.BaseSize.Round(8)) + if usingExchangeLevelFunding { + log.Warnf(log.BackTester, "%s This strategy is using Exchange Level Funding. Calculation of holding values may be inaccurate", sep) + } + log.Infof(log.BackTester, "%s Final holdings value: %v", sep, last.Holdings.BaseValue.Round(8)) + log.Infof(log.BackTester, "%s Final total value: %v\n\n", sep, last.Holdings.TotalValue.Round(8)) if len(errs) > 0 { log.Info(log.BackTester, "------------------Errors-------------------------------------") for i := range errs { @@ -241,7 +295,7 @@ func (c *CurrencyStatistic) PrintResults(e string, a asset.Item, p currency.Pair } func calculateMaxDrawdown(closePrices []common.DataEventHandler) Swing { - var lowestPrice, highestPrice float64 + var lowestPrice, highestPrice decimal.Decimal var lowestTime, highestTime time.Time var swings []Swing if len(closePrices) > 0 { @@ -254,11 +308,11 @@ func calculateMaxDrawdown(closePrices []common.DataEventHandler) Swing { currHigh := closePrices[i].HighPrice() currLow := closePrices[i].LowPrice() currTime := closePrices[i].GetTime() - if lowestPrice > currLow && currLow != 0 { + if lowestPrice.GreaterThan(currLow) && !currLow.IsZero() { lowestPrice = currLow lowestTime = currTime } - if highestPrice < currHigh && highestPrice > 0 { + if highestPrice.LessThan(currHigh) && highestPrice.IsPositive() { if lowestTime.Equal(highestTime) { // create distinction if the greatest drawdown occurs within the same candle lowestTime = lowestTime.Add((time.Hour * 23) + (time.Minute * 59) + (time.Second * 59)) @@ -277,7 +331,7 @@ func calculateMaxDrawdown(closePrices []common.DataEventHandler) Swing { Time: lowestTime, Price: lowestPrice, }, - DrawdownPercent: ((lowestPrice - highestPrice) / highestPrice) * 100, + DrawdownPercent: lowestPrice.Sub(highestPrice).Div(highestPrice).Mul(decimal.NewFromInt(100)), IntervalDuration: int64(len(intervals.Ranges[0].Intervals)), }) // reset the drawdown @@ -297,9 +351,9 @@ func calculateMaxDrawdown(closePrices []common.DataEventHandler) Swing { if err != nil { log.Error(log.BackTester, err) } - drawdownPercent := 0.0 - if highestPrice > 0 { - drawdownPercent = ((lowestPrice - highestPrice) / highestPrice) * 100 + 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 @@ -324,7 +378,7 @@ func calculateMaxDrawdown(closePrices []common.DataEventHandler) Swing { maxDrawdown = swings[0] } for i := range swings { - if swings[i].DrawdownPercent < maxDrawdown.DrawdownPercent { + if swings[i].DrawdownPercent.LessThan(maxDrawdown.DrawdownPercent) { // drawdowns are negative maxDrawdown = swings[i] } @@ -335,8 +389,8 @@ func calculateMaxDrawdown(closePrices []common.DataEventHandler) Swing { func (c *CurrencyStatistic) calculateHighestCommittedFunds() { for i := range c.Events { - if c.Events[i].Holdings.CommittedFunds > c.HighestCommittedFunds.Value { - c.HighestCommittedFunds.Value = c.Events[i].Holdings.CommittedFunds + 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 } } diff --git a/backtester/eventhandlers/statistics/currencystatistics/currencystatistics_test.go b/backtester/eventhandlers/statistics/currencystatistics/currencystatistics_test.go index 113305e9..364d50c1 100644 --- a/backtester/eventhandlers/statistics/currencystatistics/currencystatistics_test.go +++ b/backtester/eventhandlers/statistics/currencystatistics/currencystatistics_test.go @@ -4,12 +4,14 @@ import ( "testing" "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" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/kline" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/signal" + "github.com/thrasher-corp/gocryptotrader/backtester/funding" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/exchanges/asset" gctkline "github.com/thrasher-corp/gocryptotrader/exchanges/kline" @@ -19,6 +21,7 @@ import ( const testExchange = "binance" func TestCalculateResults(t *testing.T) { + t.Parallel() cs := CurrencyStatistic{} tt1 := time.Now() tt2 := time.Now().Add(gctkline.OneDay.Duration()) @@ -34,87 +37,101 @@ func TestCalculateResults(t *testing.T) { } ev := EventStore{ Holdings: holdings.Holding{ - ChangeInTotalValuePercent: 0.1333, + ChangeInTotalValuePercent: decimal.NewFromFloat(0.1333), Timestamp: tt1, - InitialFunds: 1337, + QuoteInitialFunds: decimal.NewFromInt(1337), + RiskFreeRate: decimal.NewFromInt(1), }, Transactions: compliance.Snapshot{ Orders: []compliance.SnapshotOrder{ { - ClosePrice: 1338, - VolumeAdjustedPrice: 1338, - SlippageRate: 1338, - CostBasis: 1338, + ClosePrice: decimal.NewFromInt(1338), + VolumeAdjustedPrice: decimal.NewFromInt(1338), + SlippageRate: decimal.NewFromInt(1338), + CostBasis: decimal.NewFromInt(1338), Detail: &order.Detail{Side: order.Buy}, }, { - ClosePrice: 1337, - VolumeAdjustedPrice: 1337, - SlippageRate: 1337, - CostBasis: 1337, + ClosePrice: decimal.NewFromInt(1337), + VolumeAdjustedPrice: decimal.NewFromInt(1337), + SlippageRate: decimal.NewFromInt(1337), + CostBasis: decimal.NewFromInt(1337), Detail: &order.Detail{Side: order.Sell}, }, }, }, DataEvent: &kline.Kline{ Base: even, - Open: 2000, - Close: 2000, - Low: 2000, - High: 2000, - Volume: 2000, + Open: decimal.NewFromInt(2000), + Close: decimal.NewFromInt(2000), + Low: decimal.NewFromInt(2000), + High: decimal.NewFromInt(2000), + Volume: decimal.NewFromInt(2000), }, SignalEvent: &signal.Signal{ Base: even, - ClosePrice: 2000, + ClosePrice: decimal.NewFromInt(2000), }, } even2 := even even2.Time = tt2 ev2 := EventStore{ Holdings: holdings.Holding{ - ChangeInTotalValuePercent: 0.1337, + ChangeInTotalValuePercent: decimal.NewFromFloat(0.1337), Timestamp: tt2, - InitialFunds: 1337, + QuoteInitialFunds: decimal.NewFromInt(1337), + RiskFreeRate: decimal.NewFromInt(1), }, Transactions: compliance.Snapshot{ Orders: []compliance.SnapshotOrder{ { - ClosePrice: 1338, - VolumeAdjustedPrice: 1338, - SlippageRate: 1338, - CostBasis: 1338, + ClosePrice: decimal.NewFromInt(1338), + VolumeAdjustedPrice: decimal.NewFromInt(1338), + SlippageRate: decimal.NewFromInt(1338), + CostBasis: decimal.NewFromInt(1338), Detail: &order.Detail{Side: order.Buy}, }, { - ClosePrice: 1337, - VolumeAdjustedPrice: 1337, - SlippageRate: 1337, - CostBasis: 1337, + ClosePrice: decimal.NewFromInt(1337), + VolumeAdjustedPrice: decimal.NewFromInt(1337), + SlippageRate: decimal.NewFromInt(1337), + CostBasis: decimal.NewFromInt(1337), Detail: &order.Detail{Side: order.Sell}, }, }, }, DataEvent: &kline.Kline{ Base: even2, - Open: 1337, - Close: 1337, - Low: 1337, - High: 1337, - Volume: 1337, + Open: decimal.NewFromInt(1337), + Close: decimal.NewFromInt(1337), + Low: decimal.NewFromInt(1337), + High: decimal.NewFromInt(1337), + Volume: decimal.NewFromInt(1337), }, SignalEvent: &signal.Signal{ Base: even2, - ClosePrice: 1337, + ClosePrice: decimal.NewFromInt(1337), }, } cs.Events = append(cs.Events, ev, ev2) - err := cs.CalculateResults() + b, err := funding.CreateItem(testExchange, asset.Spot, currency.BTC, decimal.NewFromInt(13337), decimal.Zero) + if err != nil { + t.Fatal(err) + } + q, err := funding.CreateItem(testExchange, asset.Spot, currency.USDT, decimal.NewFromInt(13337), decimal.Zero) + if err != nil { + t.Fatal(err) + } + pair, err := funding.CreatePair(b, q) + if err != nil { + t.Fatal(err) + } + err = cs.CalculateResults(pair) if err != nil { t.Error(err) } - if cs.MarketMovement != -33.15 { + if !cs.MarketMovement.Equal(decimal.NewFromFloat(-33.15)) { t.Error("expected -33.15") } } @@ -135,87 +152,99 @@ func TestPrintResults(t *testing.T) { } ev := EventStore{ Holdings: holdings.Holding{ - ChangeInTotalValuePercent: 0.1333, + ChangeInTotalValuePercent: decimal.NewFromFloat(0.1333), Timestamp: tt1, - InitialFunds: 1337, + QuoteInitialFunds: decimal.NewFromInt(1337), }, Transactions: compliance.Snapshot{ Orders: []compliance.SnapshotOrder{ { - ClosePrice: 1338, - VolumeAdjustedPrice: 1338, - SlippageRate: 1338, - CostBasis: 1338, + ClosePrice: decimal.NewFromInt(1338), + VolumeAdjustedPrice: decimal.NewFromInt(1338), + SlippageRate: decimal.NewFromInt(1338), + CostBasis: decimal.NewFromInt(1338), Detail: &order.Detail{Side: order.Buy}, }, { - ClosePrice: 1337, - VolumeAdjustedPrice: 1337, - SlippageRate: 1337, - CostBasis: 1337, + ClosePrice: decimal.NewFromInt(1337), + VolumeAdjustedPrice: decimal.NewFromInt(1337), + SlippageRate: decimal.NewFromInt(1337), + CostBasis: decimal.NewFromInt(1337), Detail: &order.Detail{Side: order.Sell}, }, }, }, DataEvent: &kline.Kline{ Base: even, - Open: 2000, - Close: 2000, - Low: 2000, - High: 2000, - Volume: 2000, + Open: decimal.NewFromInt(2000), + Close: decimal.NewFromInt(2000), + Low: decimal.NewFromInt(2000), + High: decimal.NewFromInt(2000), + Volume: decimal.NewFromInt(2000), }, SignalEvent: &signal.Signal{ Base: even, - ClosePrice: 2000, + ClosePrice: decimal.NewFromInt(2000), }, } even2 := even even2.Time = tt2 ev2 := EventStore{ Holdings: holdings.Holding{ - ChangeInTotalValuePercent: 0.1337, + ChangeInTotalValuePercent: decimal.NewFromFloat(0.1337), Timestamp: tt2, - InitialFunds: 1337, + QuoteInitialFunds: decimal.NewFromInt(1337), }, Transactions: compliance.Snapshot{ Orders: []compliance.SnapshotOrder{ { - ClosePrice: 1338, - VolumeAdjustedPrice: 1338, - SlippageRate: 1338, - CostBasis: 1338, + ClosePrice: decimal.NewFromInt(1338), + VolumeAdjustedPrice: decimal.NewFromInt(1338), + SlippageRate: decimal.NewFromInt(1338), + CostBasis: decimal.NewFromInt(1338), Detail: &order.Detail{Side: order.Buy}, }, { - ClosePrice: 1337, - VolumeAdjustedPrice: 1337, - SlippageRate: 1337, - CostBasis: 1337, + ClosePrice: decimal.NewFromInt(1337), + VolumeAdjustedPrice: decimal.NewFromInt(1337), + SlippageRate: decimal.NewFromInt(1337), + CostBasis: decimal.NewFromInt(1337), Detail: &order.Detail{Side: order.Sell}, }, }, }, DataEvent: &kline.Kline{ Base: even2, - Open: 1337, - Close: 1337, - Low: 1337, - High: 1337, - Volume: 1337, + Open: decimal.NewFromInt(1337), + Close: decimal.NewFromInt(1337), + Low: decimal.NewFromInt(1337), + High: decimal.NewFromInt(1337), + Volume: decimal.NewFromInt(1337), }, SignalEvent: &signal.Signal{ Base: even2, - ClosePrice: 1337, + ClosePrice: decimal.NewFromInt(1337), }, } cs.Events = append(cs.Events, ev, ev2) - err := cs.CalculateResults() + b, err := funding.CreateItem(testExchange, asset.Spot, currency.BTC, decimal.NewFromInt(1), decimal.Zero) + if err != nil { + t.Fatal(err) + } + q, err := funding.CreateItem(testExchange, asset.Spot, currency.USDT, decimal.NewFromInt(100), decimal.Zero) + if err != nil { + t.Fatal(err) + } + pair, err := funding.CreatePair(b, q) + if err != nil { + t.Fatal(err) + } + err = cs.CalculateResults(pair) if err != nil { t.Error(err) } - cs.PrintResults(exch, a, p) + cs.PrintResults(exch, a, p, pair, true) } func TestCalculateMaxDrawdown(t *testing.T) { @@ -224,7 +253,7 @@ func TestCalculateMaxDrawdown(t *testing.T) { a := asset.Spot p := currency.NewPair(currency.BTC, currency.USDT) var events []common.DataEventHandler - for i := 0; i < 100; i++ { + for i := int64(0); i < 100; i++ { tt1 = tt1.Add(gctkline.OneDay.Duration()) even := event.Base{ Exchange: exch, @@ -237,16 +266,16 @@ func TestCalculateMaxDrawdown(t *testing.T) { // throw in a wrench, a spike in price events = append(events, &kline.Kline{ Base: even, - Close: 1336, - High: 1336, - Low: 1336, + Close: decimal.NewFromInt(1336), + High: decimal.NewFromInt(1336), + Low: decimal.NewFromInt(1336), }) } else { events = append(events, &kline.Kline{ Base: even, - Close: 1337 - float64(i), - High: 1337 - float64(i), - Low: 1337 - float64(i), + Close: decimal.NewFromInt(1337).Sub(decimal.NewFromInt(i)), + High: decimal.NewFromInt(1337).Sub(decimal.NewFromInt(i)), + Low: decimal.NewFromInt(1337).Sub(decimal.NewFromInt(i)), }) } } @@ -261,9 +290,9 @@ func TestCalculateMaxDrawdown(t *testing.T) { } events = append(events, &kline.Kline{ Base: even, - Close: 1338, - High: 1338, - Low: 1338, + Close: decimal.NewFromInt(1338), + High: decimal.NewFromInt(1338), + Low: decimal.NewFromInt(1338), }) tt1 = tt1.Add(gctkline.OneDay.Duration()) @@ -276,9 +305,9 @@ func TestCalculateMaxDrawdown(t *testing.T) { } events = append(events, &kline.Kline{ Base: even, - Close: 1337, - High: 1337, - Low: 1337, + Close: decimal.NewFromInt(1337), + High: decimal.NewFromInt(1337), + Low: decimal.NewFromInt(1337), }) tt1 = tt1.Add(gctkline.OneDay.Duration()) @@ -291,18 +320,19 @@ func TestCalculateMaxDrawdown(t *testing.T) { } events = append(events, &kline.Kline{ Base: even, - Close: 1339, - High: 1339, - Low: 1339, + Close: decimal.NewFromInt(1339), + High: decimal.NewFromInt(1339), + Low: decimal.NewFromInt(1339), }) resp := calculateMaxDrawdown(events) - if resp.Highest.Price != 1337 && resp.Lowest.Price != 1238 { + if resp.Highest.Price != decimal.NewFromInt(1337) && !resp.Lowest.Price.Equal(decimal.NewFromInt(1238)) { t.Error("unexpected max drawdown") } } func TestCalculateHighestCommittedFunds(t *testing.T) { + t.Parallel() c := CurrencyStatistic{} c.calculateHighestCommittedFunds() if !c.HighestCommittedFunds.Time.IsZero() { @@ -312,9 +342,9 @@ func TestCalculateHighestCommittedFunds(t *testing.T) { tt2 := time.Date(2021, 2, 1, 0, 0, 0, 0, time.UTC) tt3 := time.Date(2021, 3, 1, 0, 0, 0, 0, time.UTC) c.Events = append(c.Events, - EventStore{Holdings: holdings.Holding{Timestamp: tt1, CommittedFunds: 10}}, - EventStore{Holdings: holdings.Holding{Timestamp: tt2, CommittedFunds: 1337}}, - EventStore{Holdings: holdings.Holding{Timestamp: tt3, CommittedFunds: 11}}, + EventStore{DataEvent: &kline.Kline{Close: decimal.NewFromInt(1337)}, Holdings: holdings.Holding{Timestamp: tt1, BaseSize: decimal.NewFromInt(10)}}, + EventStore{DataEvent: &kline.Kline{Close: decimal.NewFromInt(1338)}, Holdings: holdings.Holding{Timestamp: tt2, BaseSize: decimal.NewFromInt(1337)}}, + EventStore{DataEvent: &kline.Kline{Close: decimal.NewFromInt(1339)}, Holdings: holdings.Holding{Timestamp: tt3, BaseSize: decimal.NewFromInt(11)}}, ) c.calculateHighestCommittedFunds() if c.HighestCommittedFunds.Time != tt2 { diff --git a/backtester/eventhandlers/statistics/currencystatistics/currencystatistics_types.go b/backtester/eventhandlers/statistics/currencystatistics/currencystatistics_types.go index 03beb4fd..7e12ea9c 100644 --- a/backtester/eventhandlers/statistics/currencystatistics/currencystatistics_types.go +++ b/backtester/eventhandlers/statistics/currencystatistics/currencystatistics_types.go @@ -3,6 +3,7 @@ package currencystatistics 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" @@ -14,11 +15,11 @@ import ( // CurrencyStats defines what is expected in order to // calculate statistics based on an exchange, asset type and currency pair type CurrencyStats interface { - TotalEquityReturn() (float64, error) + TotalEquityReturn() (decimal.Decimal, error) MaxDrawdown() Swing LongestDrawdown() Swing - SharpeRatio(float64) float64 - SortinoRatio(float64) float64 + SharpeRatio(decimal.Decimal) decimal.Decimal + SortinoRatio(decimal.Decimal) decimal.Decimal } // EventStore is used to hold all event information @@ -34,51 +35,54 @@ type EventStore struct { // CurrencyStatistic Holds all events and statistics relevant to an exchange, asset type and currency pair type CurrencyStatistic struct { - Events []EventStore `json:"-"` - MaxDrawdown Swing `json:"max-drawdown,omitempty"` - StartingClosePrice float64 `json:"starting-close-price"` - EndingClosePrice float64 `json:"ending-close-price"` - LowestClosePrice float64 `json:"lowest-close-price"` - HighestClosePrice float64 `json:"highest-close-price"` - MarketMovement float64 `json:"market-movement"` - StrategyMovement float64 `json:"strategy-movement"` - HighestCommittedFunds HighestCommittedFunds `json:"highest-committed-funds"` - RiskFreeRate float64 `json:"risk-free-rate"` - BuyOrders int64 `json:"buy-orders"` - GeometricRatios Ratios `json:"geometric-ratios"` - ArithmeticRatios Ratios `json:"arithmetic-ratios"` - CompoundAnnualGrowthRate float64 `json:"compound-annual-growth-rate"` - SellOrders int64 `json:"sell-orders"` - TotalOrders int64 `json:"total-orders"` - FinalHoldings holdings.Holding `json:"final-holdings"` - FinalOrders compliance.Snapshot `json:"final-orders"` - ShowMissingDataWarning bool `json:"-"` + Events []EventStore `json:"-"` + MaxDrawdown Swing `json:"max-drawdown,omitempty"` + StartingClosePrice decimal.Decimal `json:"starting-close-price"` + EndingClosePrice decimal.Decimal `json:"ending-close-price"` + LowestClosePrice decimal.Decimal `json:"lowest-close-price"` + HighestClosePrice decimal.Decimal `json:"highest-close-price"` + MarketMovement decimal.Decimal `json:"market-movement"` + StrategyMovement decimal.Decimal `json:"strategy-movement"` + HighestCommittedFunds HighestCommittedFunds `json:"highest-committed-funds"` + RiskFreeRate decimal.Decimal `json:"risk-free-rate"` + BuyOrders int64 `json:"buy-orders"` + GeometricRatios Ratios `json:"geometric-ratios"` + ArithmeticRatios Ratios `json:"arithmetic-ratios"` + CompoundAnnualGrowthRate decimal.Decimal `json:"compound-annual-growth-rate"` + SellOrders int64 `json:"sell-orders"` + TotalOrders int64 `json:"total-orders"` + InitialHoldings holdings.Holding `json:"initial-holdings-holdings"` + FinalHoldings holdings.Holding `json:"final-holdings"` + FinalOrders compliance.Snapshot `json:"final-orders"` + ShowMissingDataWarning bool `json:"-"` + IsStrategyProfitable bool `json:"is-strategy-profitable"` + DoesPerformanceBeatTheMarket bool `json:"does-performance-beat-the-market"` } // Ratios stores all the ratios used for statistics type Ratios struct { - SharpeRatio float64 `json:"sharpe-ratio"` - SortinoRatio float64 `json:"sortino-ratio"` - InformationRatio float64 `json:"information-ratio"` - CalmarRatio float64 `json:"calmar-ratio"` + SharpeRatio decimal.Decimal `json:"sharpe-ratio"` + SortinoRatio decimal.Decimal `json:"sortino-ratio"` + InformationRatio decimal.Decimal `json:"information-ratio"` + CalmarRatio decimal.Decimal `json:"calmar-ratio"` } // Swing holds a drawdown type Swing struct { - Highest Iteration `json:"highest"` - Lowest Iteration `json:"lowest"` - DrawdownPercent float64 `json:"drawdown"` + Highest Iteration `json:"highest"` + Lowest Iteration `json:"lowest"` + DrawdownPercent decimal.Decimal `json:"drawdown"` IntervalDuration int64 } // Iteration is an individual iteration of price at a time type Iteration struct { - Time time.Time `json:"time"` - Price float64 `json:"price"` + Time time.Time `json:"time"` + Price decimal.Decimal `json:"price"` } // HighestCommittedFunds is an individual iteration of price at a time type HighestCommittedFunds struct { - Time time.Time `json:"time"` - Value float64 `json:"value"` + Time time.Time `json:"time"` + Value decimal.Decimal `json:"value"` } diff --git a/backtester/eventhandlers/statistics/statistics.go b/backtester/eventhandlers/statistics/statistics.go index 885d39b4..25d37c69 100644 --- a/backtester/eventhandlers/statistics/statistics.go +++ b/backtester/eventhandlers/statistics/statistics.go @@ -3,7 +3,10 @@ package statistics import ( "encoding/json" "fmt" + "sort" + "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" @@ -11,6 +14,7 @@ import ( "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/funding" gctcommon "github.com/thrasher-corp/gocryptotrader/common" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/exchanges/asset" @@ -23,21 +27,30 @@ func (s *Statistic) Reset() { } // SetupEventForTime sets up the big map for to store important data at each time interval -func (s *Statistic) SetupEventForTime(e common.DataEventHandler) error { - if e == nil { +func (s *Statistic) SetupEventForTime(ev common.DataEventHandler) error { + if ev == nil { return common.ErrNilEvent } - ex := e.GetExchange() - a := e.GetAssetType() - p := e.Pair() + ex := ev.GetExchange() + a := ev.GetAssetType() + p := ev.Pair() s.setupMap(ex, a) lookup := s.ExchangeAssetPairStatistics[ex][a][p] if lookup == nil { lookup = ¤cystatistics.CurrencyStatistic{} } + for i := range lookup.Events { + if lookup.Events[i].DataEvent.GetTime().Equal(ev.GetTime()) && + lookup.Events[i].DataEvent.GetExchange() == ev.GetExchange() && + lookup.Events[i].DataEvent.GetAssetType() == ev.GetAssetType() && + lookup.Events[i].DataEvent.Pair().Equal(ev.Pair()) && + lookup.Events[i].DataEvent.GetOffset() == ev.GetOffset() { + return ErrAlreadyProcessed + } + } lookup.Events = append(lookup.Events, currencystatistics.EventStore{ - DataEvent: e, + DataEvent: ev, }, ) s.ExchangeAssetPairStatistics[ex][a][p] = lookup @@ -58,32 +71,32 @@ func (s *Statistic) setupMap(ex string, a asset.Item) { } // SetEventForOffset sets the event for the time period in the event -func (s *Statistic) SetEventForOffset(e common.EventHandler) error { - if e == nil { +func (s *Statistic) SetEventForOffset(ev common.EventHandler) error { + if ev == nil { return common.ErrNilEvent } if s.ExchangeAssetPairStatistics == nil { return errExchangeAssetPairStatsUnset } - exch := e.GetExchange() - a := e.GetAssetType() - p := e.Pair() - offset := e.GetOffset() + exch := ev.GetExchange() + a := ev.GetAssetType() + p := ev.Pair() + offset := ev.GetOffset() lookup := s.ExchangeAssetPairStatistics[exch][a][p] if lookup == nil { return fmt.Errorf("%w for %v %v %v to set signal event", errCurrencyStatisticsUnset, exch, a, p) } for i := len(lookup.Events) - 1; i >= 0; i-- { if lookup.Events[i].DataEvent.GetOffset() == offset { - return applyEventAtOffset(e, lookup, i) + return applyEventAtOffset(ev, lookup, i) } } return nil } -func applyEventAtOffset(e common.EventHandler, lookup *currencystatistics.CurrencyStatistic, i int) error { - switch t := e.(type) { +func applyEventAtOffset(ev common.EventHandler, lookup *currencystatistics.CurrencyStatistic, i int) error { + switch t := ev.(type) { case common.DataEventHandler: lookup.Events[i].DataEvent = t case signal.Event: @@ -93,7 +106,7 @@ func applyEventAtOffset(e common.EventHandler, lookup *currencystatistics.Curren case fill.Event: lookup.Events[i].FillEvent = t default: - return fmt.Errorf("unknown event type received: %v", e) + return fmt.Errorf("unknown event type received: %v", ev) } return nil } @@ -143,22 +156,41 @@ func (s *Statistic) AddComplianceSnapshotForTime(c compliance.Snapshot, e fill.E // CalculateAllResults calculates the statistics of all exchange asset pair holdings, // orders, ratios and drawdowns -func (s *Statistic) CalculateAllResults() error { +func (s *Statistic) CalculateAllResults(funds funding.IFundingManager) error { log.Info(log.BackTester, "calculating backtesting results") - s.PrintAllEvents() + s.PrintAllEventsChronologically() currCount := 0 var finalResults []FinalResultsHolder + var err error + var startDate, endDate time.Time for exchangeName, exchangeMap := range s.ExchangeAssetPairStatistics { for assetItem, assetMap := range exchangeMap { for pair, stats := range assetMap { currCount++ - err := stats.CalculateResults() + var f funding.IPairReader + last := stats.Events[len(stats.Events)-1] + startDate = stats.Events[0].DataEvent.GetTime() + endDate = last.DataEvent.GetTime() + var event common.EventHandler + switch { + case last.FillEvent != nil: + event = last.FillEvent + case last.SignalEvent != nil: + event = last.SignalEvent + default: + event = last.DataEvent + } + f, err = funds.GetFundingForEvent(event) if err != nil { return err } - stats.PrintResults(exchangeName, assetItem, pair) - last := stats.Events[len(stats.Events)-1] + err = stats.CalculateResults(f) + if err != nil { + log.Error(log.BackTester, err) + } + stats.PrintResults(exchangeName, assetItem, pair, f, funds.IsUsingExchangeLevelFunding()) stats.FinalHoldings = last.Holdings + stats.InitialHoldings = stats.Events[0].Holdings stats.FinalOrders = last.Transactions s.AllStats = append(s.AllStats, *stats) @@ -178,44 +210,71 @@ func (s *Statistic) CalculateAllResults() error { } } } + s.Funding = funds.GenerateReport(startDate, endDate) s.TotalOrders = s.TotalBuyOrders + s.TotalSellOrders if currCount > 1 { s.BiggestDrawdown = s.GetTheBiggestDrawdownAcrossCurrencies(finalResults) s.BestMarketMovement = s.GetBestMarketPerformer(finalResults) s.BestStrategyResults = s.GetBestStrategyPerformer(finalResults) - s.PrintTotalResults() + s.PrintTotalResults(funds.IsUsingExchangeLevelFunding()) } return nil } // PrintTotalResults outputs all results to the CMD -func (s *Statistic) PrintTotalResults() { +func (s *Statistic) PrintTotalResults(isUsingExchangeLevelFunding bool) { log.Info(log.BackTester, "------------------Strategy-----------------------------------") log.Infof(log.BackTester, "Strategy Name: %v", s.StrategyName) log.Infof(log.BackTester, "Strategy Nickname: %v", s.StrategyNickname) log.Infof(log.BackTester, "Strategy Goal: %v\n\n", s.StrategyGoal) + log.Info(log.BackTester, "------------------Funding------------------------------------") + for i := range s.Funding.Items { + log.Infof(log.BackTester, "Exchange: %v", s.Funding.Items[i].Exchange) + log.Infof(log.BackTester, "Asset: %v", s.Funding.Items[i].Asset) + log.Infof(log.BackTester, "Currency: %v", s.Funding.Items[i].Currency) + if !s.Funding.Items[i].PairedWith.IsEmpty() { + log.Infof(log.BackTester, "Paired with: %v", s.Funding.Items[i].PairedWith) + } + log.Infof(log.BackTester, "Initial funds: %v", s.Funding.Items[i].InitialFunds) + log.Infof(log.BackTester, "Initial funds in USD: $%v", s.Funding.Items[i].InitialFundsUSD) + log.Infof(log.BackTester, "Final funds: %v", s.Funding.Items[i].FinalFunds) + log.Infof(log.BackTester, "Final funds in USD: $%v", s.Funding.Items[i].FinalFundsUSD) + if s.Funding.Items[i].InitialFunds.IsZero() { + log.Info(log.BackTester, "Difference: ∞%") + } else { + log.Infof(log.BackTester, "Difference: %v%%", s.Funding.Items[i].Difference) + } + if s.Funding.Items[i].TransferFee.GreaterThan(decimal.Zero) { + log.Infof(log.BackTester, "Transfer fee: %v", s.Funding.Items[i].TransferFee) + } + log.Info(log.BackTester, "") + } + log.Infof(log.BackTester, "Initial total funds in USD: $%v", s.Funding.InitialTotalUSD) + log.Infof(log.BackTester, "Final total funds in USD: $%v", s.Funding.FinalTotalUSD) + log.Infof(log.BackTester, "Difference: %v%%\n", s.Funding.Difference) + log.Info(log.BackTester, "------------------Total Results------------------------------") - log.Info(log.BackTester, "------------------Orders----------------------------------") + log.Info(log.BackTester, "------------------Orders-------------------------------------") log.Infof(log.BackTester, "Total buy orders: %v", s.TotalBuyOrders) log.Infof(log.BackTester, "Total sell orders: %v", s.TotalSellOrders) log.Infof(log.BackTester, "Total orders: %v\n\n", s.TotalOrders) if s.BiggestDrawdown != nil { - log.Info(log.BackTester, "------------------Biggest Drawdown------------------------") + log.Info(log.BackTester, "------------------Biggest Drawdown-----------------------") log.Infof(log.BackTester, "Exchange: %v Asset: %v Currency: %v", s.BiggestDrawdown.Exchange, s.BiggestDrawdown.Asset, s.BiggestDrawdown.Pair) - log.Infof(log.BackTester, "Highest Price: $%.2f", s.BiggestDrawdown.MaxDrawdown.Highest.Price) + log.Infof(log.BackTester, "Highest Price: %v", s.BiggestDrawdown.MaxDrawdown.Highest.Price.Round(8)) log.Infof(log.BackTester, "Highest Price Time: %v", s.BiggestDrawdown.MaxDrawdown.Highest.Time) - log.Infof(log.BackTester, "Lowest Price: $%.2f", s.BiggestDrawdown.MaxDrawdown.Lowest.Price) + log.Infof(log.BackTester, "Lowest Price: %v", s.BiggestDrawdown.MaxDrawdown.Lowest.Price.Round(8)) log.Infof(log.BackTester, "Lowest Price Time: %v", s.BiggestDrawdown.MaxDrawdown.Lowest.Time) - log.Infof(log.BackTester, "Calculated Drawdown: %.2f%%", s.BiggestDrawdown.MaxDrawdown.DrawdownPercent) - log.Infof(log.BackTester, "Difference: $%.2f", s.BiggestDrawdown.MaxDrawdown.Highest.Price-s.BiggestDrawdown.MaxDrawdown.Lowest.Price) + log.Infof(log.BackTester, "Calculated Drawdown: %v%%", s.BiggestDrawdown.MaxDrawdown.DrawdownPercent.Round(2)) + log.Infof(log.BackTester, "Difference: %v", s.BiggestDrawdown.MaxDrawdown.Highest.Price.Sub(s.BiggestDrawdown.MaxDrawdown.Lowest.Price).Round(8)) log.Infof(log.BackTester, "Drawdown length: %v\n\n", s.BiggestDrawdown.MaxDrawdown.IntervalDuration) } if s.BestMarketMovement != nil && s.BestStrategyResults != nil { log.Info(log.BackTester, "------------------Orders----------------------------------") - log.Infof(log.BackTester, "Best performing market movement: %v %v %v %f%%", s.BestMarketMovement.Exchange, s.BestMarketMovement.Asset, s.BestMarketMovement.Pair, s.BestMarketMovement.MarketMovement) - log.Infof(log.BackTester, "Best performing strategy movement: %v %v %v %f%%\n\n", s.BestStrategyResults.Exchange, s.BestStrategyResults.Asset, s.BestStrategyResults.Pair, s.BestStrategyResults.StrategyMovement) + log.Infof(log.BackTester, "Best performing market movement: %v %v %v %v%%", s.BestMarketMovement.Exchange, s.BestMarketMovement.Asset, s.BestMarketMovement.Pair, s.BestMarketMovement.MarketMovement.Round(2)) + log.Infof(log.BackTester, "Best performing strategy movement: %v %v %v %v%%\n\n", s.BestStrategyResults.Exchange, s.BestStrategyResults.Asset, s.BestStrategyResults.Pair, s.BestStrategyResults.StrategyMovement.Round(2)) } } @@ -223,7 +282,7 @@ func (s *Statistic) PrintTotalResults() { func (s *Statistic) GetBestMarketPerformer(results []FinalResultsHolder) *FinalResultsHolder { result := &FinalResultsHolder{} for i := range results { - if results[i].MarketMovement > result.MarketMovement || result.MarketMovement == 0 { + if results[i].MarketMovement.GreaterThan(result.MarketMovement) || result.MarketMovement.IsZero() { result = &results[i] break } @@ -236,7 +295,7 @@ func (s *Statistic) GetBestMarketPerformer(results []FinalResultsHolder) *FinalR func (s *Statistic) GetBestStrategyPerformer(results []FinalResultsHolder) *FinalResultsHolder { result := &FinalResultsHolder{} for i := range results { - if results[i].StrategyMovement > result.StrategyMovement || result.StrategyMovement == 0 { + if results[i].StrategyMovement.GreaterThan(result.StrategyMovement) || result.StrategyMovement.IsZero() { result = &results[i] } } @@ -248,7 +307,7 @@ func (s *Statistic) GetBestStrategyPerformer(results []FinalResultsHolder) *Fina func (s *Statistic) GetTheBiggestDrawdownAcrossCurrencies(results []FinalResultsHolder) *FinalResultsHolder { result := &FinalResultsHolder{} for i := range results { - if results[i].MaxDrawdown.DrawdownPercent > result.MaxDrawdown.DrawdownPercent || result.MaxDrawdown.DrawdownPercent == 0 { + if results[i].MaxDrawdown.DrawdownPercent.GreaterThan(result.MaxDrawdown.DrawdownPercent) || result.MaxDrawdown.DrawdownPercent.IsZero() { result = &results[i] } } @@ -256,55 +315,97 @@ func (s *Statistic) GetTheBiggestDrawdownAcrossCurrencies(results []FinalResults return result } -// PrintAllEvents outputs all event details in the CMD -func (s *Statistic) PrintAllEvents() { +func addEventOutputToTime(events []eventOutputHolder, t time.Time, message string) []eventOutputHolder { + for i := range events { + if events[i].Time.Equal(t) { + events[i].Events = append(events[i].Events, message) + return events + } + } + events = append(events, eventOutputHolder{Time: t, Events: []string{message}}) + return events +} + +// PrintAllEventsChronologically outputs all event details in the CMD +// rather than separated by exchange, asset and currency pair, it's +// grouped by time to allow a clearer picture of events +func (s *Statistic) PrintAllEventsChronologically() { + var results []eventOutputHolder log.Info(log.BackTester, "------------------Events-------------------------------------") var errs gctcommon.Errors - for e, x := range s.ExchangeAssetPairStatistics { + for exch, x := range s.ExchangeAssetPairStatistics { for a, y := range x { - for p, c := range y { - for i := range c.Events { + for pair, currencyStatistic := range y { + for i := range currencyStatistic.Events { switch { - case c.Events[i].FillEvent != nil: - direction := c.Events[i].FillEvent.GetDirection() + case currencyStatistic.Events[i].FillEvent != nil: + direction := currencyStatistic.Events[i].FillEvent.GetDirection() if direction == common.CouldNotBuy || direction == common.CouldNotSell || direction == common.DoNothing || direction == common.MissingData || + direction == common.TransferredFunds || direction == "" { - log.Infof(log.BackTester, "%v | Price: $%f - Direction: %v - Reason: %s", - c.Events[i].FillEvent.GetTime().Format(gctcommon.SimpleTimeFormat), - c.Events[i].FillEvent.GetClosePrice(), - c.Events[i].FillEvent.GetDirection(), - c.Events[i].FillEvent.GetReason()) + results = addEventOutputToTime(results, currencyStatistic.Events[i].FillEvent.GetTime(), + fmt.Sprintf("%v %v %v %v | Price: $%v - Direction: %v - Reason: %s", + currencyStatistic.Events[i].FillEvent.GetTime().Format(gctcommon.SimpleTimeFormat), + currencyStatistic.Events[i].FillEvent.GetExchange(), + currencyStatistic.Events[i].FillEvent.GetAssetType(), + currencyStatistic.Events[i].FillEvent.Pair(), + currencyStatistic.Events[i].FillEvent.GetClosePrice().Round(8), + currencyStatistic.Events[i].FillEvent.GetDirection(), + currencyStatistic.Events[i].FillEvent.GetReason())) } else { - log.Infof(log.BackTester, "%v | Price: $%f - Amount: %f - Fee: $%f - Total: $%f - Direction %v - Reason: %s", - c.Events[i].FillEvent.GetTime().Format(gctcommon.SimpleTimeFormat), - c.Events[i].FillEvent.GetPurchasePrice(), - c.Events[i].FillEvent.GetAmount(), - c.Events[i].FillEvent.GetExchangeFee(), - c.Events[i].FillEvent.GetTotal(), - c.Events[i].FillEvent.GetDirection(), - c.Events[i].FillEvent.GetReason(), - ) + results = addEventOutputToTime(results, currencyStatistic.Events[i].FillEvent.GetTime(), + fmt.Sprintf("%v %v %v %v | Price: $%v - Amount: %v - Fee: $%v - Total: $%v - Direction %v - Reason: %s", + currencyStatistic.Events[i].FillEvent.GetTime().Format(gctcommon.SimpleTimeFormat), + currencyStatistic.Events[i].FillEvent.GetExchange(), + currencyStatistic.Events[i].FillEvent.GetAssetType(), + currencyStatistic.Events[i].FillEvent.Pair(), + currencyStatistic.Events[i].FillEvent.GetPurchasePrice().Round(8), + currencyStatistic.Events[i].FillEvent.GetAmount().Round(8), + currencyStatistic.Events[i].FillEvent.GetExchangeFee().Round(8), + currencyStatistic.Events[i].FillEvent.GetTotal().Round(8), + currencyStatistic.Events[i].FillEvent.GetDirection(), + currencyStatistic.Events[i].FillEvent.GetReason(), + )) } - case c.Events[i].SignalEvent != nil: - log.Infof(log.BackTester, "%v | Price: $%f - Reason: %v", - c.Events[i].SignalEvent.GetTime().Format(gctcommon.SimpleTimeFormat), - c.Events[i].SignalEvent.GetPrice(), - c.Events[i].SignalEvent.GetReason()) - case c.Events[i].DataEvent != nil: - log.Infof(log.BackTester, "%v | Price: $%f - Reason: %v", - c.Events[i].DataEvent.GetTime().Format(gctcommon.SimpleTimeFormat), - c.Events[i].DataEvent.ClosePrice(), - c.Events[i].DataEvent.GetReason()) + case currencyStatistic.Events[i].SignalEvent != nil: + results = addEventOutputToTime(results, currencyStatistic.Events[i].SignalEvent.GetTime(), + fmt.Sprintf("%v %v %v %v | Price: $%v - Reason: %v", + currencyStatistic.Events[i].SignalEvent.GetTime().Format(gctcommon.SimpleTimeFormat), + currencyStatistic.Events[i].SignalEvent.GetExchange(), + currencyStatistic.Events[i].SignalEvent.GetAssetType(), + currencyStatistic.Events[i].SignalEvent.Pair(), + currencyStatistic.Events[i].SignalEvent.GetPrice().Round(8), + currencyStatistic.Events[i].SignalEvent.GetReason())) + case currencyStatistic.Events[i].DataEvent != nil: + results = addEventOutputToTime(results, currencyStatistic.Events[i].DataEvent.GetTime(), + fmt.Sprintf("%v %v %v %v | Price: $%v - Reason: %v", + currencyStatistic.Events[i].DataEvent.GetTime().Format(gctcommon.SimpleTimeFormat), + 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.GetReason())) default: - errs = append(errs, fmt.Errorf("%v %v %v unexpected data received %+v", e, a, p, c.Events[i])) + errs = append(errs, fmt.Errorf("%v %v %v unexpected data received %+v", exch, a, pair, currencyStatistic.Events[i])) } } } } } + + sort.Slice(results, func(i, j int) bool { + b1 := results[i] + b2 := results[j] + return b1.Time.Before(b2.Time) + }) + for i := range results { + for j := range results[i].Events { + log.Info(log.BackTester, results[i].Events[j]) + } + } if len(errs) > 0 { log.Info(log.BackTester, "------------------Errors-------------------------------------") for i := range errs { diff --git a/backtester/eventhandlers/statistics/statistics_test.go b/backtester/eventhandlers/statistics/statistics_test.go index 500aec48..5cf59157 100644 --- a/backtester/eventhandlers/statistics/statistics_test.go +++ b/backtester/eventhandlers/statistics/statistics_test.go @@ -5,6 +5,7 @@ import ( "testing" "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" @@ -14,6 +15,7 @@ import ( "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/kline" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/order" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/signal" + "github.com/thrasher-corp/gocryptotrader/backtester/funding" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/exchanges/asset" gctkline "github.com/thrasher-corp/gocryptotrader/exchanges/kline" @@ -22,7 +24,15 @@ import ( const testExchange = "binance" +var ( + eleeg = decimal.NewFromInt(1336) + eleet = decimal.NewFromInt(1337) + eleeet = decimal.NewFromInt(13337) + eleeb = decimal.NewFromInt(1338) +) + func TestReset(t *testing.T) { + t.Parallel() s := Statistic{ TotalOrders: 1, } @@ -33,6 +43,7 @@ func TestReset(t *testing.T) { } func TestAddDataEventForTime(t *testing.T) { + t.Parallel() tt := time.Now() exch := testExchange a := asset.Spot @@ -40,7 +51,7 @@ func TestAddDataEventForTime(t *testing.T) { s := Statistic{} err := s.SetupEventForTime(nil) if !errors.Is(err, common.ErrNilEvent) { - t.Errorf("expected: %v, received %v", common.ErrNilEvent, err) + t.Errorf("received: %v, expected: %v", err, common.ErrNilEvent) } err = s.SetupEventForTime(&kline.Kline{ Base: event.Base{ @@ -50,11 +61,11 @@ func TestAddDataEventForTime(t *testing.T) { CurrencyPair: p, AssetType: a, }, - Open: 1337, - Close: 1337, - Low: 1337, - High: 1337, - Volume: 1337, + Open: eleet, + Close: eleet, + Low: eleet, + High: eleet, + Volume: eleet, }) if err != nil { t.Error(err) @@ -68,6 +79,7 @@ func TestAddDataEventForTime(t *testing.T) { } func TestAddSignalEventForTime(t *testing.T) { + t.Parallel() tt := time.Now() exch := testExchange a := asset.Spot @@ -75,17 +87,17 @@ func TestAddSignalEventForTime(t *testing.T) { s := Statistic{} err := s.SetEventForOffset(nil) if !errors.Is(err, common.ErrNilEvent) { - t.Errorf("expected: %v, received %v", common.ErrNilEvent, err) + t.Errorf("received: %v, expected: %v", err, common.ErrNilEvent) } err = s.SetEventForOffset(&signal.Signal{}) if !errors.Is(err, errExchangeAssetPairStatsUnset) { - t.Errorf("expected: %v, received %v", errExchangeAssetPairStatsUnset, err) + t.Errorf("received: %v, expected: %v", err, errExchangeAssetPairStatsUnset) } s.setupMap(exch, a) s.ExchangeAssetPairStatistics = make(map[string]map[asset.Item]map[currency.Pair]*currencystatistics.CurrencyStatistic) err = s.SetEventForOffset(&signal.Signal{}) if !errors.Is(err, errCurrencyStatisticsUnset) { - t.Errorf("expected: %v, received %v", errCurrencyStatisticsUnset, err) + t.Errorf("received: %v, expected: %v", err, errCurrencyStatisticsUnset) } err = s.SetupEventForTime(&kline.Kline{ @@ -96,11 +108,11 @@ func TestAddSignalEventForTime(t *testing.T) { CurrencyPair: p, AssetType: a, }, - Open: 1337, - Close: 1337, - Low: 1337, - High: 1337, - Volume: 1337, + Open: eleet, + Close: eleet, + Low: eleet, + High: eleet, + Volume: eleet, }) if err != nil { t.Error(err) @@ -113,7 +125,7 @@ func TestAddSignalEventForTime(t *testing.T) { CurrencyPair: p, AssetType: a, }, - ClosePrice: 1337, + ClosePrice: eleet, Direction: gctorder.Buy, }) if err != nil { @@ -122,6 +134,7 @@ func TestAddSignalEventForTime(t *testing.T) { } func TestAddExchangeEventForTime(t *testing.T) { + t.Parallel() tt := time.Now() exch := testExchange a := asset.Spot @@ -129,17 +142,17 @@ func TestAddExchangeEventForTime(t *testing.T) { s := Statistic{} err := s.SetEventForOffset(nil) if !errors.Is(err, common.ErrNilEvent) { - t.Errorf("expected: %v, received %v", common.ErrNilEvent, err) + t.Errorf("received: %v, expected: %v", err, common.ErrNilEvent) } err = s.SetEventForOffset(&order.Order{}) if !errors.Is(err, errExchangeAssetPairStatsUnset) { - t.Errorf("expected: %v, received %v", errExchangeAssetPairStatsUnset, err) + t.Errorf("received: %v, expected: %v", err, errExchangeAssetPairStatsUnset) } s.setupMap(exch, a) s.ExchangeAssetPairStatistics = make(map[string]map[asset.Item]map[currency.Pair]*currencystatistics.CurrencyStatistic) err = s.SetEventForOffset(&order.Order{}) if !errors.Is(err, errCurrencyStatisticsUnset) { - t.Errorf("expected: %v, received %v", errCurrencyStatisticsUnset, err) + t.Errorf("received: %v, expected: %v", err, errCurrencyStatisticsUnset) } err = s.SetupEventForTime(&kline.Kline{ @@ -150,11 +163,11 @@ func TestAddExchangeEventForTime(t *testing.T) { CurrencyPair: p, AssetType: a, }, - Open: 1337, - Close: 1337, - Low: 1337, - High: 1337, - Volume: 1337, + Open: eleet, + Close: eleet, + Low: eleet, + High: eleet, + Volume: eleet, }) if err != nil { t.Error(err) @@ -167,13 +180,13 @@ func TestAddExchangeEventForTime(t *testing.T) { CurrencyPair: p, AssetType: a, }, - ID: "1337", + ID: "elite", Direction: gctorder.Buy, Status: gctorder.New, - Price: 1337, - Amount: 1337, + Price: eleet, + Amount: eleet, OrderType: gctorder.Stop, - Leverage: 1337, + Leverage: eleet, }) if err != nil { t.Error(err) @@ -181,6 +194,7 @@ func TestAddExchangeEventForTime(t *testing.T) { } func TestAddFillEventForTime(t *testing.T) { + t.Parallel() tt := time.Now() exch := testExchange a := asset.Spot @@ -188,7 +202,7 @@ func TestAddFillEventForTime(t *testing.T) { s := Statistic{} err := s.SetEventForOffset(nil) if !errors.Is(err, common.ErrNilEvent) { - t.Errorf("expected: %v, received %v", common.ErrNilEvent, err) + t.Errorf("received: %v, expected: %v", err, common.ErrNilEvent) } err = s.SetEventForOffset(&fill.Fill{}) if err != nil && err.Error() != "exchangeAssetPairStatistics not setup" { @@ -198,7 +212,7 @@ func TestAddFillEventForTime(t *testing.T) { s.ExchangeAssetPairStatistics = make(map[string]map[asset.Item]map[currency.Pair]*currencystatistics.CurrencyStatistic) err = s.SetEventForOffset(&fill.Fill{}) if !errors.Is(err, errCurrencyStatisticsUnset) { - t.Errorf("expected: %v, received %v", errCurrencyStatisticsUnset, err) + t.Errorf("received: %v, expected: %v", err, errCurrencyStatisticsUnset) } err = s.SetupEventForTime(&kline.Kline{ @@ -209,11 +223,11 @@ func TestAddFillEventForTime(t *testing.T) { CurrencyPair: p, AssetType: a, }, - Open: 1337, - Close: 1337, - Low: 1337, - High: 1337, - Volume: 1337, + Open: eleet, + Close: eleet, + Low: eleet, + High: eleet, + Volume: eleet, }) if err != nil { t.Error(err) @@ -227,12 +241,12 @@ func TestAddFillEventForTime(t *testing.T) { AssetType: a, }, Direction: gctorder.Buy, - Amount: 1337, - ClosePrice: 1337, - VolumeAdjustedPrice: 1337, - PurchasePrice: 1337, - ExchangeFee: 1337, - Slippage: 1337, + Amount: eleet, + ClosePrice: eleet, + VolumeAdjustedPrice: eleet, + PurchasePrice: eleet, + ExchangeFee: eleet, + Slippage: eleet, }) if err != nil { t.Error(err) @@ -240,6 +254,7 @@ func TestAddFillEventForTime(t *testing.T) { } func TestAddHoldingsForTime(t *testing.T) { + t.Parallel() tt := time.Now() exch := testExchange a := asset.Spot @@ -247,12 +262,12 @@ func TestAddHoldingsForTime(t *testing.T) { s := Statistic{} err := s.AddHoldingsForTime(&holdings.Holding{}) if !errors.Is(err, errExchangeAssetPairStatsUnset) { - t.Errorf("expected: %v, received %v", errExchangeAssetPairStatsUnset, err) + t.Errorf("received: %v, expected: %v", err, errExchangeAssetPairStatsUnset) } s.ExchangeAssetPairStatistics = make(map[string]map[asset.Item]map[currency.Pair]*currencystatistics.CurrencyStatistic) err = s.AddHoldingsForTime(&holdings.Holding{}) if !errors.Is(err, errCurrencyStatisticsUnset) { - t.Errorf("expected: %v, received %v", errCurrencyStatisticsUnset, err) + t.Errorf("received: %v, expected: %v", err, errCurrencyStatisticsUnset) } err = s.SetupEventForTime(&kline.Kline{ @@ -263,11 +278,11 @@ func TestAddHoldingsForTime(t *testing.T) { CurrencyPair: p, AssetType: a, }, - Open: 1337, - Close: 1337, - Low: 1337, - High: 1337, - Volume: 1337, + Open: eleet, + Close: eleet, + Low: eleet, + High: eleet, + Volume: eleet, }) if err != nil { t.Error(err) @@ -277,25 +292,25 @@ func TestAddHoldingsForTime(t *testing.T) { Asset: a, Exchange: exch, Timestamp: tt, - InitialFunds: 1337, - PositionsSize: 1337, - PositionsValue: 1337, - SoldAmount: 1337, - SoldValue: 1337, - BoughtAmount: 1337, - BoughtValue: 1337, - RemainingFunds: 1337, - TotalValueDifference: 1337, - ChangeInTotalValuePercent: 1337, - BoughtValueDifference: 1337, - SoldValueDifference: 1337, - PositionsValueDifference: 1337, - TotalValue: 1337, - TotalFees: 1337, - TotalValueLostToVolumeSizing: 1337, - TotalValueLostToSlippage: 1337, - TotalValueLost: 1337, - RiskFreeRate: 1337, + QuoteInitialFunds: eleet, + BaseSize: eleet, + BaseValue: eleet, + SoldAmount: eleet, + SoldValue: eleet, + BoughtAmount: eleet, + BoughtValue: eleet, + QuoteSize: eleet, + TotalValueDifference: eleet, + ChangeInTotalValuePercent: eleet, + BoughtValueDifference: eleet, + SoldValueDifference: eleet, + PositionsValueDifference: eleet, + TotalValue: eleet, + TotalFees: eleet, + TotalValueLostToVolumeSizing: eleet, + TotalValueLostToSlippage: eleet, + TotalValueLost: eleet, + RiskFreeRate: eleet, }) if err != nil { t.Error(err) @@ -303,6 +318,7 @@ func TestAddHoldingsForTime(t *testing.T) { } func TestAddComplianceSnapshotForTime(t *testing.T) { + t.Parallel() tt := time.Now() exch := testExchange a := asset.Spot @@ -311,17 +327,17 @@ func TestAddComplianceSnapshotForTime(t *testing.T) { err := s.AddComplianceSnapshotForTime(compliance.Snapshot{}, nil) if !errors.Is(err, common.ErrNilEvent) { - t.Errorf("expected: %v, received %v", common.ErrNilEvent, err) + t.Errorf("received: %v, expected: %v", err, common.ErrNilEvent) } err = s.AddComplianceSnapshotForTime(compliance.Snapshot{}, &fill.Fill{}) if !errors.Is(err, errExchangeAssetPairStatsUnset) { - t.Errorf("expected: %v, received %v", errExchangeAssetPairStatsUnset, err) + t.Errorf("received: %v, expected: %v", err, errExchangeAssetPairStatsUnset) } s.setupMap(exch, a) s.ExchangeAssetPairStatistics = make(map[string]map[asset.Item]map[currency.Pair]*currencystatistics.CurrencyStatistic) err = s.AddComplianceSnapshotForTime(compliance.Snapshot{}, &fill.Fill{}) if !errors.Is(err, errCurrencyStatisticsUnset) { - t.Errorf("expected: %v, received %v", errCurrencyStatisticsUnset, err) + t.Errorf("received: %v, expected: %v", err, errCurrencyStatisticsUnset) } err = s.SetupEventForTime(&kline.Kline{ @@ -332,11 +348,11 @@ func TestAddComplianceSnapshotForTime(t *testing.T) { CurrencyPair: p, AssetType: a, }, - Open: 1337, - Close: 1337, - Low: 1337, - High: 1337, - Volume: 1337, + Open: eleet, + Close: eleet, + Low: eleet, + High: eleet, + Volume: eleet, }) if err != nil { t.Error(err) @@ -358,6 +374,7 @@ func TestAddComplianceSnapshotForTime(t *testing.T) { } func TestSerialise(t *testing.T) { + t.Parallel() s := Statistic{} _, err := s.Serialise() if err != nil { @@ -366,6 +383,7 @@ func TestSerialise(t *testing.T) { } func TestSetStrategyName(t *testing.T) { + t.Parallel() s := Statistic{} s.SetStrategyName("test") if s.StrategyName != "test" { @@ -374,12 +392,17 @@ func TestSetStrategyName(t *testing.T) { } func TestPrintTotalResults(t *testing.T) { - s := Statistic{} + t.Parallel() + s := Statistic{ + Funding: &funding.Report{ + Items: []funding.ReportItem{{}}, + }, + } s.BiggestDrawdown = s.GetTheBiggestDrawdownAcrossCurrencies([]FinalResultsHolder{ { Exchange: "test", MaxDrawdown: currencystatistics.Swing{ - DrawdownPercent: 1337, + DrawdownPercent: eleet, }, }, }) @@ -389,20 +412,21 @@ func TestPrintTotalResults(t *testing.T) { Asset: asset.Spot, Pair: currency.NewPair(currency.BTC, currency.DOGE), MaxDrawdown: currencystatistics.Swing{}, - MarketMovement: 1337, - StrategyMovement: 1337, + MarketMovement: eleet, + StrategyMovement: eleet, }, }) s.BestMarketMovement = s.GetBestMarketPerformer([]FinalResultsHolder{ { Exchange: "test", - MarketMovement: 1337, + MarketMovement: eleet, }, }) - s.PrintTotalResults() + s.PrintTotalResults(true) } func TestGetBestStrategyPerformer(t *testing.T) { + t.Parallel() s := Statistic{} resp := s.GetBestStrategyPerformer(nil) if resp.Exchange != "" { @@ -415,16 +439,16 @@ func TestGetBestStrategyPerformer(t *testing.T) { Asset: asset.Spot, Pair: currency.NewPair(currency.BTC, currency.DOGE), MaxDrawdown: currencystatistics.Swing{}, - MarketMovement: 1337, - StrategyMovement: 1337, + MarketMovement: eleet, + StrategyMovement: eleet, }, { Exchange: "test2", Asset: asset.Spot, Pair: currency.NewPair(currency.BTC, currency.DOGE), MaxDrawdown: currencystatistics.Swing{}, - MarketMovement: 1338, - StrategyMovement: 1338, + MarketMovement: eleeb, + StrategyMovement: eleeb, }, }) @@ -434,6 +458,7 @@ func TestGetBestStrategyPerformer(t *testing.T) { } func TestGetTheBiggestDrawdownAcrossCurrencies(t *testing.T) { + t.Parallel() s := Statistic{} result := s.GetTheBiggestDrawdownAcrossCurrencies(nil) if result.Exchange != "" { @@ -444,13 +469,13 @@ func TestGetTheBiggestDrawdownAcrossCurrencies(t *testing.T) { { Exchange: "test", MaxDrawdown: currencystatistics.Swing{ - DrawdownPercent: 1337, + DrawdownPercent: eleet, }, }, { Exchange: "test2", MaxDrawdown: currencystatistics.Swing{ - DrawdownPercent: 1338, + DrawdownPercent: eleeb, }, }, }) @@ -460,6 +485,7 @@ func TestGetTheBiggestDrawdownAcrossCurrencies(t *testing.T) { } func TestGetBestMarketPerformer(t *testing.T) { + t.Parallel() s := Statistic{} result := s.GetBestMarketPerformer(nil) if result.Exchange != "" { @@ -469,11 +495,11 @@ func TestGetBestMarketPerformer(t *testing.T) { result = s.GetBestMarketPerformer([]FinalResultsHolder{ { Exchange: "test", - MarketMovement: 1337, + MarketMovement: eleet, }, { Exchange: "test2", - MarketMovement: 1336, + MarketMovement: eleeg, }, }) if result.Exchange != "test" { @@ -481,16 +507,17 @@ func TestGetBestMarketPerformer(t *testing.T) { } } -func TestPrintAllEvents(t *testing.T) { +func TestPrintAllEventsChronologically(t *testing.T) { + t.Parallel() s := Statistic{} - s.PrintAllEvents() + s.PrintAllEventsChronologically() tt := time.Now() exch := testExchange a := asset.Spot p := currency.NewPair(currency.BTC, currency.USDT) err := s.SetupEventForTime(nil) if !errors.Is(err, common.ErrNilEvent) { - t.Errorf("expected: %v, received %v", common.ErrNilEvent, err) + t.Errorf("received: %v, expected: %v", err, common.ErrNilEvent) } err = s.SetupEventForTime(&kline.Kline{ Base: event.Base{ @@ -500,11 +527,11 @@ func TestPrintAllEvents(t *testing.T) { CurrencyPair: p, AssetType: a, }, - Open: 1337, - Close: 1337, - Low: 1337, - High: 1337, - Volume: 1337, + Open: eleet, + Close: eleet, + Low: eleet, + High: eleet, + Volume: eleet, }) if err != nil { t.Error(err) @@ -519,12 +546,12 @@ func TestPrintAllEvents(t *testing.T) { AssetType: a, }, Direction: gctorder.Buy, - Amount: 1337, - ClosePrice: 1337, - VolumeAdjustedPrice: 1337, - PurchasePrice: 1337, - ExchangeFee: 1337, - Slippage: 1337, + Amount: eleet, + ClosePrice: eleet, + VolumeAdjustedPrice: eleet, + PurchasePrice: eleet, + ExchangeFee: eleet, + Slippage: eleet, }) if err != nil { t.Error(err) @@ -538,19 +565,20 @@ func TestPrintAllEvents(t *testing.T) { CurrencyPair: p, AssetType: a, }, - ClosePrice: 1337, + ClosePrice: eleet, Direction: gctorder.Buy, }) if err != nil { t.Error(err) } - s.PrintAllEvents() + s.PrintAllEventsChronologically() } func TestCalculateTheResults(t *testing.T) { + t.Parallel() s := Statistic{} - err := s.CalculateAllResults() + err := s.CalculateAllResults(&funding.FundManager{}) if err != nil { t.Error(err) } @@ -560,10 +588,10 @@ func TestCalculateTheResults(t *testing.T) { exch := testExchange a := asset.Spot p := currency.NewPair(currency.BTC, currency.USDT) - p2 := currency.NewPair(currency.DOGE, currency.DOGE) + p2 := currency.NewPair(currency.XRP, currency.DOGE) err = s.SetupEventForTime(nil) if !errors.Is(err, common.ErrNilEvent) { - t.Errorf("expected: %v, received %v", common.ErrNilEvent, err) + t.Errorf("received: %v, expected: %v", err, common.ErrNilEvent) } err = s.SetupEventForTime(&kline.Kline{ Base: event.Base{ @@ -573,11 +601,11 @@ func TestCalculateTheResults(t *testing.T) { CurrencyPair: p, AssetType: a, }, - Open: 1337, - Close: 1337, - Low: 1337, - High: 1337, - Volume: 1337, + Open: eleet, + Close: eleet, + Low: eleet, + High: eleet, + Volume: eleet, }) if err != nil { t.Error(err) @@ -590,11 +618,11 @@ func TestCalculateTheResults(t *testing.T) { CurrencyPair: p, AssetType: a, }, - OpenPrice: 1337, - HighPrice: 1337, - LowPrice: 1337, - ClosePrice: 1337, - Volume: 1337, + OpenPrice: eleet, + HighPrice: eleet, + LowPrice: eleet, + ClosePrice: eleet, + Volume: eleet, Direction: gctorder.Buy, }) if err != nil { @@ -608,11 +636,11 @@ func TestCalculateTheResults(t *testing.T) { CurrencyPair: p2, AssetType: a, }, - Open: 1338, - Close: 1338, - Low: 1338, - High: 1338, - Volume: 1338, + Open: eleeb, + Close: eleeb, + Low: eleeb, + High: eleeb, + Volume: eleeb, }) if err != nil { t.Error(err) @@ -626,11 +654,11 @@ func TestCalculateTheResults(t *testing.T) { CurrencyPair: p2, AssetType: a, }, - OpenPrice: 1337, - HighPrice: 1337, - LowPrice: 1337, - ClosePrice: 1337, - Volume: 1337, + OpenPrice: eleet, + HighPrice: eleet, + LowPrice: eleet, + ClosePrice: eleet, + Volume: eleet, Direction: gctorder.Buy, }) if err != nil { @@ -645,11 +673,11 @@ func TestCalculateTheResults(t *testing.T) { CurrencyPair: p, AssetType: a, }, - Open: 1338, - Close: 1338, - Low: 1338, - High: 1338, - Volume: 1338, + Open: eleeb, + Close: eleeb, + Low: eleeb, + High: eleeb, + Volume: eleeb, }) if err != nil { t.Error(err) @@ -662,11 +690,11 @@ func TestCalculateTheResults(t *testing.T) { CurrencyPair: p, AssetType: a, }, - OpenPrice: 1338, - HighPrice: 1338, - LowPrice: 1338, - ClosePrice: 1338, - Volume: 1338, + OpenPrice: eleeb, + HighPrice: eleeb, + LowPrice: eleeb, + ClosePrice: eleeb, + Volume: eleeb, Direction: gctorder.Buy, }) if err != nil { @@ -681,11 +709,11 @@ func TestCalculateTheResults(t *testing.T) { CurrencyPair: p2, AssetType: a, }, - Open: 1338, - Close: 1338, - Low: 1338, - High: 1338, - Volume: 1338, + Open: eleeb, + Close: eleeb, + Low: eleeb, + High: eleeb, + Volume: eleeb, }) if err != nil { t.Error(err) @@ -698,24 +726,58 @@ func TestCalculateTheResults(t *testing.T) { CurrencyPair: p2, AssetType: a, }, - OpenPrice: 1338, - HighPrice: 1338, - LowPrice: 1338, - ClosePrice: 1338, - Volume: 1338, + OpenPrice: eleeb, + HighPrice: eleeb, + LowPrice: eleeb, + ClosePrice: eleeb, + Volume: eleeb, Direction: gctorder.Buy, }) if err != nil { t.Error(err) } - s.ExchangeAssetPairStatistics[exch][a][p].Events[1].Holdings.InitialFunds = 1337 - s.ExchangeAssetPairStatistics[exch][a][p].Events[1].Holdings.TotalValue = 13337 - s.ExchangeAssetPairStatistics[exch][a][p2].Events[1].Holdings.InitialFunds = 1337 - s.ExchangeAssetPairStatistics[exch][a][p2].Events[1].Holdings.TotalValue = 13337 + s.ExchangeAssetPairStatistics[exch][a][p].Events[1].Holdings.QuoteInitialFunds = eleet + s.ExchangeAssetPairStatistics[exch][a][p].Events[1].Holdings.TotalValue = eleeet + s.ExchangeAssetPairStatistics[exch][a][p2].Events[1].Holdings.QuoteInitialFunds = eleet + s.ExchangeAssetPairStatistics[exch][a][p2].Events[1].Holdings.TotalValue = eleeet - err = s.CalculateAllResults() - if err != nil { - t.Error(err) + funds := &funding.FundManager{} + pBase, err := funding.CreateItem(exch, a, p.Base, eleeet, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + pQuote, err := funding.CreateItem(exch, a, p.Quote, eleeet, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + + pair, err := funding.CreatePair(pBase, pQuote) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + err = funds.AddPair(pair) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + pBase2, err := funding.CreateItem(exch, a, p2.Base, eleeet, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + pQuote2, err := funding.CreateItem(exch, a, p2.Quote, eleeet, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + pair2, err := funding.CreatePair(pBase2, pQuote2) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + err = funds.AddPair(pair2) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + err = s.CalculateAllResults(funds) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) } } diff --git a/backtester/eventhandlers/statistics/statistics_types.go b/backtester/eventhandlers/statistics/statistics_types.go index 1b9ef016..40ddc04a 100644 --- a/backtester/eventhandlers/statistics/statistics_types.go +++ b/backtester/eventhandlers/statistics/statistics_types.go @@ -4,17 +4,21 @@ import ( "errors" "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/eventhandlers/statistics/currencystatistics" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/fill" + "github.com/thrasher-corp/gocryptotrader/backtester/funding" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/exchanges/asset" gctorder "github.com/thrasher-corp/gocryptotrader/exchanges/order" ) var ( + // ErrAlreadyProcessed occurs when an event has already been processed + ErrAlreadyProcessed = errors.New("this event has been processed already") errExchangeAssetPairStatsUnset = errors.New("exchangeAssetPairStatistics not setup") errCurrencyStatisticsUnset = errors.New("no data") ) @@ -27,7 +31,7 @@ type Statistic struct { StrategyNickname string `json:"strategy-nickname"` StrategyGoal string `json:"strategy-goal"` ExchangeAssetPairStatistics map[string]map[asset.Item]map[currency.Pair]*currencystatistics.CurrencyStatistic `json:"-"` - RiskFreeRate float64 `json:"risk-free-rate"` + RiskFreeRate decimal.Decimal `json:"risk-free-rate"` TotalBuyOrders int64 `json:"total-buy-orders"` TotalSellOrders int64 `json:"total-sell-orders"` TotalOrders int64 `json:"total-orders"` @@ -36,6 +40,7 @@ type Statistic struct { BestMarketMovement *FinalResultsHolder `json:"best-market-movement,omitempty"` AllStats []currencystatistics.CurrencyStatistic `json:"results"` // as ExchangeAssetPairStatistics cannot be rendered via json.Marshall, we append all result to this slice instead WasAnyDataMissing bool `json:"was-any-data-missing"` + Funding *funding.Report `json:"funding"` } // FinalResultsHolder holds important stats about a currency's performance @@ -44,18 +49,18 @@ type FinalResultsHolder struct { Asset asset.Item `json:"asset"` Pair currency.Pair `json:"currency"` MaxDrawdown currencystatistics.Swing `json:"max-drawdown"` - MarketMovement float64 `json:"market-movement"` - StrategyMovement float64 `json:"strategy-movement"` + MarketMovement decimal.Decimal `json:"market-movement"` + StrategyMovement decimal.Decimal `json:"strategy-movement"` } // Handler interface details what a statistic is expected to do type Handler interface { SetStrategyName(string) SetupEventForTime(common.DataEventHandler) error - SetEventForOffset(e common.EventHandler) error + SetEventForOffset(common.EventHandler) error AddHoldingsForTime(*holdings.Holding) error AddComplianceSnapshotForTime(compliance.Snapshot, fill.Event) error - CalculateAllResults() error + CalculateAllResults(funding.IFundingManager) error Reset() Serialise() (string, error) } @@ -72,14 +77,19 @@ type Results struct { // ResultTransactions stores details on a transaction type ResultTransactions struct { - Time time.Time `json:"time"` - Direction gctorder.Side `json:"direction"` - Price float64 `json:"price"` - Amount float64 `json:"amount"` - Reason string `json:"reason,omitempty"` + Time time.Time `json:"time"` + Direction gctorder.Side `json:"direction"` + Price decimal.Decimal `json:"price"` + Amount decimal.Decimal `json:"amount"` + Reason string `json:"reason,omitempty"` } // ResultEvent stores the time type ResultEvent struct { Time time.Time `json:"time"` } + +type eventOutputHolder struct { + Time time.Time + Events []string +} diff --git a/backtester/eventhandlers/strategies/base/base.go b/backtester/eventhandlers/strategies/base/base.go index 6ff53075..8f4e0541 100644 --- a/backtester/eventhandlers/strategies/base/base.go +++ b/backtester/eventhandlers/strategies/base/base.go @@ -10,6 +10,7 @@ import ( // Strategy is base implementation of the Handler interface type Strategy struct { useSimultaneousProcessing bool + usingExchangeLevelFunding bool } // GetBaseData returns the non-interface version of the Handler @@ -29,6 +30,7 @@ func (s *Strategy) GetBaseData(d data.Handler) (signal.Signal, error) { CurrencyPair: latest.Pair(), AssetType: latest.GetAssetType(), Interval: latest.GetInterval(), + Reason: latest.GetReason(), }, ClosePrice: latest.ClosePrice(), HighPrice: latest.HighPrice(), @@ -37,8 +39,8 @@ func (s *Strategy) GetBaseData(d data.Handler) (signal.Signal, error) { }, nil } -// UseSimultaneousProcessing returns whether multiple currencies can be assessed in one go -func (s *Strategy) UseSimultaneousProcessing() bool { +// UsingSimultaneousProcessing returns whether multiple currencies can be assessed in one go +func (s *Strategy) UsingSimultaneousProcessing() bool { return s.useSimultaneousProcessing } @@ -46,3 +48,13 @@ func (s *Strategy) UseSimultaneousProcessing() bool { func (s *Strategy) SetSimultaneousProcessing(b bool) { s.useSimultaneousProcessing = b } + +// UsingExchangeLevelFunding returns whether funding is based on currency pairs or individual currencies at the exchange level +func (s *Strategy) UsingExchangeLevelFunding() bool { + return s.usingExchangeLevelFunding +} + +// SetExchangeLevelFunding sets whether funding is based on currency pairs or individual currencies at the exchange level +func (s *Strategy) SetExchangeLevelFunding(b bool) { + s.usingExchangeLevelFunding = b +} diff --git a/backtester/eventhandlers/strategies/base/base_test.go b/backtester/eventhandlers/strategies/base/base_test.go index 0ecea32e..091fca63 100644 --- a/backtester/eventhandlers/strategies/base/base_test.go +++ b/backtester/eventhandlers/strategies/base/base_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/common" "github.com/thrasher-corp/gocryptotrader/backtester/data" datakline "github.com/thrasher-corp/gocryptotrader/backtester/data/kline" @@ -19,12 +20,12 @@ func TestGetBase(t *testing.T) { s := Strategy{} _, err := s.GetBaseData(nil) if !errors.Is(err, common.ErrNilArguments) { - t.Errorf("expected: %v, received %v", common.ErrNilArguments, err) + t.Errorf("received: %v, expected: %v", err, common.ErrNilArguments) } _, err = s.GetBaseData(&datakline.DataFromKline{}) if !errors.Is(err, common.ErrNilEvent) { - t.Errorf("expected: %v, received %v", common.ErrNilEvent, err) + t.Errorf("received: %v, expected: %v", err, common.ErrNilEvent) } tt := time.Now() exch := "binance" @@ -39,18 +40,18 @@ func TestGetBase(t *testing.T) { CurrencyPair: p, AssetType: a, }, - Open: 1337, - Close: 1337, - Low: 1337, - High: 1337, - Volume: 1337, + Open: decimal.NewFromInt(1337), + Close: decimal.NewFromInt(1337), + Low: decimal.NewFromInt(1337), + High: decimal.NewFromInt(1337), + Volume: decimal.NewFromInt(1337), }}) d.Next() _, err = s.GetBaseData(&datakline.DataFromKline{ - Item: gctkline.Item{}, - Base: d, - Range: &gctkline.IntervalRangeHolder{}, + Item: gctkline.Item{}, + Base: d, + RangeHolder: &gctkline.IntervalRangeHolder{}, }) if err != nil { t.Error(err) @@ -59,12 +60,12 @@ func TestGetBase(t *testing.T) { func TestSetSimultaneousProcessing(t *testing.T) { s := Strategy{} - is := s.UseSimultaneousProcessing() + is := s.UsingSimultaneousProcessing() if is { t.Error("expected false") } s.SetSimultaneousProcessing(true) - is = s.UseSimultaneousProcessing() + is = s.UsingSimultaneousProcessing() if !is { t.Error("expected true") } diff --git a/backtester/eventhandlers/strategies/base/base_types.go b/backtester/eventhandlers/strategies/base/base_types.go index 09eccc3b..21f854ce 100644 --- a/backtester/eventhandlers/strategies/base/base_types.go +++ b/backtester/eventhandlers/strategies/base/base_types.go @@ -2,11 +2,16 @@ package base import "errors" -// Error vars related to strategies and invalid config settings var ( - ErrCustomSettingsUnsupported = errors.New("custom settings not supported") + // ErrCustomSettingsUnsupported used when custom settings are found in the start config when they shouldn't be + ErrCustomSettingsUnsupported = errors.New("custom settings not supported") + // ErrSimultaneousProcessingNotSupported used when strategy does not support simultaneous processing + // but start config is set to use it ErrSimultaneousProcessingNotSupported = errors.New("does not support simultaneous processing and could not be loaded") - ErrStrategyNotFound = errors.New("not found. Please ensure the strategy-settings field 'name' is spelled properly in your .start config") - ErrInvalidCustomSettings = errors.New("invalid custom settings in config") - ErrTooMuchBadData = errors.New("backtesting cannot continue as there is too much invalid data. Please review your dataset") + // ErrStrategyNotFound used when strategy specified in start config does not exist + ErrStrategyNotFound = errors.New("not found. Please ensure the strategy-settings field 'name' is spelled properly in your .start config") + // ErrInvalidCustomSettings used when bad custom settings are found in the start config + ErrInvalidCustomSettings = errors.New("invalid custom settings in config") + // ErrTooMuchBadData used when there is too much missing data + ErrTooMuchBadData = errors.New("backtesting cannot continue as there is too much invalid data. Please review your dataset") ) diff --git a/backtester/eventhandlers/strategies/dollarcostaverage/dollarcostaverage.go b/backtester/eventhandlers/strategies/dollarcostaverage/dollarcostaverage.go index a6f1e531..0e313749 100644 --- a/backtester/eventhandlers/strategies/dollarcostaverage/dollarcostaverage.go +++ b/backtester/eventhandlers/strategies/dollarcostaverage/dollarcostaverage.go @@ -5,9 +5,9 @@ import ( "github.com/thrasher-corp/gocryptotrader/backtester/common" "github.com/thrasher-corp/gocryptotrader/backtester/data" - "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/portfolio" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/strategies/base" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/signal" + "github.com/thrasher-corp/gocryptotrader/backtester/funding" gctcommon "github.com/thrasher-corp/gocryptotrader/common" "github.com/thrasher-corp/gocryptotrader/exchanges/order" ) @@ -36,7 +36,7 @@ func (s *Strategy) Description() string { // OnSignal handles a data event and returns what action the strategy believes should occur // For dollarcostaverage, this means returning a buy signal on every event -func (s *Strategy) OnSignal(d data.Handler, _ portfolio.Handler) (signal.Event, error) { +func (s *Strategy) OnSignal(d data.Handler, _ funding.IFundTransferer) (signal.Event, error) { if d == nil { return nil, common.ErrNilEvent } @@ -65,7 +65,7 @@ func (s *Strategy) SupportsSimultaneousProcessing() bool { // OnSimultaneousSignals analyses multiple data points simultaneously, allowing flexibility // in allowing a strategy to only place an order for X currency if Y currency's price is Z // For dollarcostaverage, the strategy is always "buy", so it uses the OnSignal function -func (s *Strategy) OnSimultaneousSignals(d []data.Handler, p portfolio.Handler) ([]signal.Event, error) { +func (s *Strategy) OnSimultaneousSignals(d []data.Handler, _ funding.IFundTransferer) ([]signal.Event, error) { var resp []signal.Event var errs gctcommon.Errors for i := range d { diff --git a/backtester/eventhandlers/strategies/dollarcostaverage/dollarcostaverage_test.go b/backtester/eventhandlers/strategies/dollarcostaverage/dollarcostaverage_test.go index 944319a7..7771b94e 100644 --- a/backtester/eventhandlers/strategies/dollarcostaverage/dollarcostaverage_test.go +++ b/backtester/eventhandlers/strategies/dollarcostaverage/dollarcostaverage_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/common" "github.com/thrasher-corp/gocryptotrader/backtester/data" "github.com/thrasher-corp/gocryptotrader/backtester/data/kline" @@ -37,7 +38,7 @@ func TestSetCustomSettings(t *testing.T) { s := Strategy{} err := s.SetCustomSettings(nil) if !errors.Is(err, base.ErrCustomSettingsUnsupported) { - t.Errorf("expected: %v, received %v", base.ErrCustomSettingsUnsupported, err) + t.Errorf("received: %v, expected: %v", err, base.ErrCustomSettingsUnsupported) } } @@ -45,7 +46,7 @@ func TestOnSignal(t *testing.T) { s := Strategy{} _, err := s.OnSignal(nil, nil) if !errors.Is(err, common.ErrNilEvent) { - t.Errorf("expected: %v, received %v", common.ErrNilEvent, err) + t.Errorf("received: %v, expected: %v", err, common.ErrNilEvent) } dStart := time.Date(2020, 1, 0, 0, 0, 0, 0, time.UTC) @@ -63,17 +64,17 @@ func TestOnSignal(t *testing.T) { CurrencyPair: p, AssetType: a, }, - Open: 1337, - Close: 1337, - Low: 1337, - High: 1337, - Volume: 1337, + Open: decimal.NewFromInt(1337), + Close: decimal.NewFromInt(1337), + Low: decimal.NewFromInt(1337), + High: decimal.NewFromInt(1337), + Volume: decimal.NewFromInt(1337), }}) d.Next() da := &kline.DataFromKline{ - Item: gctkline.Item{}, - Base: d, - Range: &gctkline.IntervalRangeHolder{}, + Item: gctkline.Item{}, + Base: d, + RangeHolder: &gctkline.IntervalRangeHolder{}, } var resp signal.Event resp, err = s.OnSignal(da, nil) @@ -109,8 +110,8 @@ func TestOnSignal(t *testing.T) { if err != nil { t.Error(err) } - da.Range = ranger - da.Range.SetHasDataFromCandles(da.Item.Candles) + da.RangeHolder = ranger + da.RangeHolder.SetHasDataFromCandles(da.Item.Candles) resp, err = s.OnSignal(da, nil) if err != nil { t.Error(err) @@ -124,7 +125,7 @@ func TestOnSignals(t *testing.T) { s := Strategy{} _, err := s.OnSignal(nil, nil) if !errors.Is(err, common.ErrNilEvent) { - t.Errorf("expected: %v, received %v", common.ErrNilEvent, err) + t.Errorf("received: %v, expected: %v", err, common.ErrNilEvent) } dStart := time.Date(2020, 1, 0, 0, 0, 0, 0, time.UTC) dInsert := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) @@ -142,17 +143,17 @@ func TestOnSignals(t *testing.T) { CurrencyPair: p, AssetType: a, }, - Open: 1337, - Close: 1337, - Low: 1337, - High: 1337, - Volume: 1337, + Open: decimal.NewFromInt(1337), + Close: decimal.NewFromInt(1337), + Low: decimal.NewFromInt(1337), + High: decimal.NewFromInt(1337), + Volume: decimal.NewFromInt(1337), }}) d.Next() da := &kline.DataFromKline{ - Item: gctkline.Item{}, - Base: d, - Range: &gctkline.IntervalRangeHolder{}, + Item: gctkline.Item{}, + Base: d, + RangeHolder: &gctkline.IntervalRangeHolder{}, } var resp []signal.Event resp, err = s.OnSimultaneousSignals([]data.Handler{da}, nil) @@ -191,8 +192,8 @@ func TestOnSignals(t *testing.T) { if err != nil { t.Error(err) } - da.Range = ranger - da.Range.SetHasDataFromCandles(da.Item.Candles) + da.RangeHolder = ranger + da.RangeHolder.SetHasDataFromCandles(da.Item.Candles) resp, err = s.OnSimultaneousSignals([]data.Handler{da}, nil) if err != nil { t.Error(err) diff --git a/backtester/eventhandlers/strategies/rsi/README.md b/backtester/eventhandlers/strategies/rsi/README.md index c1542cf8..1ee1e435 100644 --- a/backtester/eventhandlers/strategies/rsi/README.md +++ b/backtester/eventhandlers/strategies/rsi/README.md @@ -21,7 +21,7 @@ Join our slack to discuss all things related to GoCryptoTrader! [GoCryptoTrader ## Rsi package overview The RSI strategy utilises [the gct-ta RSI package](https://github.com/thrasher-corp/gct-ta) to analyse market signals and output buy or sell signals based on the RSI output. -This strategy does not support `SimultaneousSignalProcessing` aka [use-simultaneous-signal-processing](/backtester/config/README.md). +This strategy does support `SimultaneousSignalProcessing` aka [use-simultaneous-signal-processing](/backtester/config/README.md). This strategy does support strategy customisation in the following ways: | Field | Description | Example | diff --git a/backtester/eventhandlers/strategies/rsi/rsi.go b/backtester/eventhandlers/strategies/rsi/rsi.go index 26227a83..2478a66b 100644 --- a/backtester/eventhandlers/strategies/rsi/rsi.go +++ b/backtester/eventhandlers/strategies/rsi/rsi.go @@ -4,12 +4,13 @@ import ( "fmt" "time" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gct-ta/indicators" "github.com/thrasher-corp/gocryptotrader/backtester/common" "github.com/thrasher-corp/gocryptotrader/backtester/data" - "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/portfolio" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/strategies/base" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/signal" + "github.com/thrasher-corp/gocryptotrader/backtester/funding" gctcommon "github.com/thrasher-corp/gocryptotrader/common" "github.com/thrasher-corp/gocryptotrader/exchanges/order" ) @@ -26,9 +27,9 @@ const ( // Strategy is an implementation of the Handler interface type Strategy struct { base.Strategy - rsiPeriod float64 - rsiLow float64 - rsiHigh float64 + rsiPeriod decimal.Decimal + rsiLow decimal.Decimal + rsiHigh decimal.Decimal } // Name returns the name of the strategy @@ -45,7 +46,7 @@ func (s *Strategy) Description() string { // OnSignal handles a data event and returns what action the strategy believes should occur // For rsi, this means returning a buy signal when rsi is at or below a certain level, and a // sell signal when it is at or above a certain level -func (s *Strategy) OnSignal(d data.Handler, _ portfolio.Handler) (signal.Event, error) { +func (s *Strategy) OnSignal(d data.Handler, _ funding.IFundTransferer) (signal.Event, error) { if d == nil { return nil, common.ErrNilEvent } @@ -56,7 +57,7 @@ func (s *Strategy) OnSignal(d data.Handler, _ portfolio.Handler) (signal.Event, es.SetPrice(d.Latest().ClosePrice()) offset := d.Offset() - if offset <= int(s.rsiPeriod) { + if offset <= int(s.rsiPeriod.IntPart()) { es.AppendReason("Not enough data for signal generation") es.SetDirection(common.DoNothing) return &es, nil @@ -68,8 +69,8 @@ func (s *Strategy) OnSignal(d data.Handler, _ portfolio.Handler) (signal.Event, if err != nil { return nil, err } - rsi := indicators.RSI(massagedData, int(s.rsiPeriod)) - latestRSIValue := rsi[len(rsi)-1] + rsi := indicators.RSI(massagedData, int(s.rsiPeriod.IntPart())) + latestRSIValue := decimal.NewFromFloat(rsi[len(rsi)-1]) if !d.HasDataAtTime(d.Latest().GetTime()) { es.SetDirection(common.MissingData) es.AppendReason(fmt.Sprintf("missing data at %v, cannot perform any actions. RSI %v", d.Latest().GetTime(), latestRSIValue)) @@ -77,14 +78,14 @@ func (s *Strategy) OnSignal(d data.Handler, _ portfolio.Handler) (signal.Event, } switch { - case latestRSIValue >= s.rsiHigh: + case latestRSIValue.GreaterThanOrEqual(s.rsiHigh): es.SetDirection(order.Sell) - case latestRSIValue <= s.rsiLow: + case latestRSIValue.LessThanOrEqual(s.rsiLow): es.SetDirection(order.Buy) default: es.SetDirection(common.DoNothing) } - es.AppendReason(fmt.Sprintf("RSI at %.2f", latestRSIValue)) + es.AppendReason(fmt.Sprintf("RSI at %v", latestRSIValue)) return &es, nil } @@ -93,14 +94,27 @@ func (s *Strategy) OnSignal(d data.Handler, _ portfolio.Handler) (signal.Event, // There is nothing actually stopping this strategy from considering multiple currencies at once // but for demonstration purposes, this strategy does not func (s *Strategy) SupportsSimultaneousProcessing() bool { - return false + return true } // OnSimultaneousSignals analyses multiple data points simultaneously, allowing flexibility // in allowing a strategy to only place an order for X currency if Y currency's price is Z -// For rsi, multi-currency signal processing is unsupported for demonstration purposes -func (s *Strategy) OnSimultaneousSignals(_ []data.Handler, _ portfolio.Handler) ([]signal.Event, error) { - return nil, base.ErrSimultaneousProcessingNotSupported +func (s *Strategy) OnSimultaneousSignals(d []data.Handler, _ funding.IFundTransferer) ([]signal.Event, error) { + var resp []signal.Event + var errs gctcommon.Errors + for i := range d { + sigEvent, err := s.OnSignal(d[i], nil) + if err != nil { + errs = append(errs, fmt.Errorf("%v %v %v %w", d[i].Latest().GetExchange(), d[i].Latest().GetAssetType(), d[i].Latest().Pair(), err)) + } else { + resp = append(resp, sigEvent) + } + } + + if len(errs) > 0 { + return nil, errs + } + return resp, nil } // SetCustomSettings allows a user to modify the RSI limits in their config @@ -112,19 +126,19 @@ func (s *Strategy) SetCustomSettings(customSettings map[string]interface{}) erro if !ok || rsiHigh <= 0 { return fmt.Errorf("%w provided rsi-high value could not be parsed: %v", base.ErrInvalidCustomSettings, v) } - s.rsiHigh = rsiHigh + s.rsiHigh = decimal.NewFromFloat(rsiHigh) case rsiLowKey: rsiLow, ok := v.(float64) if !ok || rsiLow <= 0 { return fmt.Errorf("%w provided rsi-low value could not be parsed: %v", base.ErrInvalidCustomSettings, v) } - s.rsiLow = rsiLow + s.rsiLow = decimal.NewFromFloat(rsiLow) case rsiPeriodKey: rsiPeriod, ok := v.(float64) if !ok || rsiPeriod <= 0 { return fmt.Errorf("%w provided rsi-period value could not be parsed: %v", base.ErrInvalidCustomSettings, v) } - s.rsiPeriod = rsiPeriod + s.rsiPeriod = decimal.NewFromFloat(rsiPeriod) default: return fmt.Errorf("%w unrecognised custom setting key %v with value %v. Cannot apply", base.ErrInvalidCustomSettings, k, v) } @@ -135,32 +149,33 @@ func (s *Strategy) SetCustomSettings(customSettings map[string]interface{}) erro // SetDefaults sets the custom settings to their default values func (s *Strategy) SetDefaults() { - s.rsiHigh = 70 - s.rsiLow = 30 - s.rsiPeriod = 14 + s.rsiHigh = decimal.NewFromInt(70) + s.rsiLow = decimal.NewFromInt(30) + s.rsiPeriod = decimal.NewFromInt(14) } // massageMissingData will replace missing data with the previous candle's data // this will ensure that RSI can be calculated correctly // the decision to handle missing data occurs at the strategy level, not all strategies // may wish to modify data -func (s *Strategy) massageMissingData(data []float64, t time.Time) ([]float64, error) { +func (s *Strategy) massageMissingData(data []decimal.Decimal, t time.Time) ([]float64, error) { var resp []float64 - var missingDataStreak float64 + var missingDataStreak int64 for i := range data { - if data[i] == 0 && i > int(s.rsiPeriod) { + if data[i].IsZero() && i > int(s.rsiPeriod.IntPart()) { data[i] = data[i-1] missingDataStreak++ } else { missingDataStreak = 0 } - if missingDataStreak >= s.rsiPeriod { + if missingDataStreak >= s.rsiPeriod.IntPart() { return nil, fmt.Errorf("missing data exceeds RSI period length of %v at %s and will distort results. %w", s.rsiPeriod, t.Format(gctcommon.SimpleTimeFormat), base.ErrTooMuchBadData) } - resp = append(resp, data[i]) + d, _ := data[i].Float64() + resp = append(resp, d) } return resp, nil } diff --git a/backtester/eventhandlers/strategies/rsi/rsi_test.go b/backtester/eventhandlers/strategies/rsi/rsi_test.go index 8ca6273b..dd5509e2 100644 --- a/backtester/eventhandlers/strategies/rsi/rsi_test.go +++ b/backtester/eventhandlers/strategies/rsi/rsi_test.go @@ -2,9 +2,11 @@ package rsi import ( "errors" + "strings" "testing" "time" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/common" "github.com/thrasher-corp/gocryptotrader/backtester/data" "github.com/thrasher-corp/gocryptotrader/backtester/data/kline" @@ -18,6 +20,7 @@ import ( ) func TestName(t *testing.T) { + t.Parallel() d := Strategy{} n := d.Name() if n != Name { @@ -26,13 +29,15 @@ func TestName(t *testing.T) { } func TestSupportsSimultaneousProcessing(t *testing.T) { + t.Parallel() s := Strategy{} - if s.SupportsSimultaneousProcessing() { - t.Error("expected false") + if !s.SupportsSimultaneousProcessing() { + t.Error("expected true") } } func TestSetCustomSettings(t *testing.T) { + t.Parallel() s := Strategy{} err := s.SetCustomSettings(nil) if err != nil { @@ -52,36 +57,37 @@ func TestSetCustomSettings(t *testing.T) { mappalopalous[rsiPeriodKey] = "14" err = s.SetCustomSettings(mappalopalous) if !errors.Is(err, base.ErrInvalidCustomSettings) { - t.Errorf("expected: %v, received %v", base.ErrInvalidCustomSettings, err) + t.Errorf("received: %v, expected: %v", err, base.ErrInvalidCustomSettings) } mappalopalous[rsiPeriodKey] = float14 mappalopalous[rsiLowKey] = "14" err = s.SetCustomSettings(mappalopalous) if !errors.Is(err, base.ErrInvalidCustomSettings) { - t.Errorf("expected: %v, received %v", base.ErrInvalidCustomSettings, err) + t.Errorf("received: %v, expected: %v", err, base.ErrInvalidCustomSettings) } mappalopalous[rsiLowKey] = float14 mappalopalous[rsiHighKey] = "14" err = s.SetCustomSettings(mappalopalous) if !errors.Is(err, base.ErrInvalidCustomSettings) { - t.Errorf("expected: %v, received %v", base.ErrInvalidCustomSettings, err) + t.Errorf("received: %v, expected: %v", err, base.ErrInvalidCustomSettings) } mappalopalous[rsiHighKey] = float14 mappalopalous["lol"] = float14 err = s.SetCustomSettings(mappalopalous) if !errors.Is(err, base.ErrInvalidCustomSettings) { - t.Errorf("expected: %v, received %v", base.ErrInvalidCustomSettings, err) + t.Errorf("received: %v, expected: %v", err, base.ErrInvalidCustomSettings) } } func TestOnSignal(t *testing.T) { + t.Parallel() s := Strategy{} _, err := s.OnSignal(nil, nil) if !errors.Is(err, common.ErrNilEvent) { - t.Errorf("expected: %v, received %v", common.ErrNilEvent, err) + t.Errorf("received: %v, expected: %v", err, common.ErrNilEvent) } dStart := time.Date(2020, 1, 0, 0, 0, 0, 0, time.UTC) dInsert := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) @@ -99,18 +105,18 @@ func TestOnSignal(t *testing.T) { CurrencyPair: p, AssetType: a, }, - Open: 1337, - Close: 1337, - Low: 1337, - High: 1337, - Volume: 1337, + Open: decimal.NewFromInt(1337), + Close: decimal.NewFromInt(1337), + Low: decimal.NewFromInt(1337), + High: decimal.NewFromInt(1337), + Volume: decimal.NewFromInt(1337), }}, ) d.Next() da := &kline.DataFromKline{ - Item: gctkline.Item{}, - Base: d, - Range: &gctkline.IntervalRangeHolder{}, + Item: gctkline.Item{}, + Base: d, + RangeHolder: &gctkline.IntervalRangeHolder{}, } var resp signal.Event _, err = s.OnSignal(da, nil) @@ -118,7 +124,7 @@ func TestOnSignal(t *testing.T) { t.Fatalf("expected: %v, received %v", base.ErrTooMuchBadData, err) } - s.rsiPeriod = 1 + s.rsiPeriod = decimal.NewFromInt(1) _, err = s.OnSignal(da, nil) if err != nil { t.Error(err) @@ -149,8 +155,8 @@ func TestOnSignal(t *testing.T) { if err != nil { t.Error(err) } - da.Range = ranger - da.Range.SetHasDataFromCandles(da.Item.Candles) + da.RangeHolder = ranger + da.RangeHolder.SetHasDataFromCandles(da.Item.Candles) resp, err = s.OnSignal(da, nil) if err != nil { t.Error(err) @@ -161,10 +167,11 @@ func TestOnSignal(t *testing.T) { } func TestOnSignals(t *testing.T) { + t.Parallel() s := Strategy{} _, err := s.OnSignal(nil, nil) if !errors.Is(err, common.ErrNilEvent) { - t.Errorf("expected: %v, received %v", common.ErrNilEvent, err) + t.Errorf("received: %v, expected: %v", err, common.ErrNilEvent) } dInsert := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) exch := "binance" @@ -179,34 +186,36 @@ func TestOnSignals(t *testing.T) { CurrencyPair: p, AssetType: a, }, - Open: 1337, - Close: 1337, - Low: 1337, - High: 1337, - Volume: 1337, + Open: decimal.NewFromInt(1337), + Close: decimal.NewFromInt(1337), + Low: decimal.NewFromInt(1337), + High: decimal.NewFromInt(1337), + Volume: decimal.NewFromInt(1337), }}) d.Next() da := &kline.DataFromKline{ - Item: gctkline.Item{}, - Base: d, - Range: &gctkline.IntervalRangeHolder{}, + Item: gctkline.Item{}, + Base: d, + RangeHolder: &gctkline.IntervalRangeHolder{}, } _, err = s.OnSimultaneousSignals([]data.Handler{da}, nil) - if !errors.Is(err, base.ErrSimultaneousProcessingNotSupported) { - t.Errorf("expected: %v, received %v", base.ErrSimultaneousProcessingNotSupported, err) + if !strings.Contains(err.Error(), base.ErrTooMuchBadData.Error()) { + // common.Errs type doesn't keep type + t.Errorf("received: %v, expected: %v", err, base.ErrTooMuchBadData) } } func TestSetDefaults(t *testing.T) { + t.Parallel() s := Strategy{} s.SetDefaults() - if s.rsiHigh != 70.0 { + if !s.rsiHigh.Equal(decimal.NewFromInt(70)) { t.Error("expected 70") } - if s.rsiLow != 30.0 { + if !s.rsiLow.Equal(decimal.NewFromInt(30)) { t.Error("expected 30") } - if s.rsiPeriod != 14.0 { + if !s.rsiPeriod.Equal(decimal.NewFromInt(14)) { t.Error("expected 14") } } diff --git a/backtester/eventhandlers/strategies/strategies.go b/backtester/eventhandlers/strategies/strategies.go index 5860791b..99982a73 100644 --- a/backtester/eventhandlers/strategies/strategies.go +++ b/backtester/eventhandlers/strategies/strategies.go @@ -7,6 +7,7 @@ import ( "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/strategies/base" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/strategies/dollarcostaverage" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/strategies/rsi" + "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/strategies/top2bottom2" ) // LoadStrategyByName returns the strategy by its name @@ -33,11 +34,9 @@ func LoadStrategyByName(name string, useSimultaneousProcessing bool) (Handler, e // GetStrategies returns a static list of set strategies // they must be set in here for the backtester to recognise them func GetStrategies() []Handler { - var strats []Handler - strats = append(strats, + return []Handler{ new(dollarcostaverage.Strategy), new(rsi.Strategy), - ) - - return strats + new(top2bottom2.Strategy), + } } diff --git a/backtester/eventhandlers/strategies/strategies_test.go b/backtester/eventhandlers/strategies/strategies_test.go index 0f493059..0259143c 100644 --- a/backtester/eventhandlers/strategies/strategies_test.go +++ b/backtester/eventhandlers/strategies/strategies_test.go @@ -10,6 +10,7 @@ import ( ) func TestGetStrategies(t *testing.T) { + t.Parallel() resp := GetStrategies() if len(resp) < 2 { t.Error("expected at least 2 strategies to be loaded") @@ -17,14 +18,15 @@ func TestGetStrategies(t *testing.T) { } func TestLoadStrategyByName(t *testing.T) { + t.Parallel() var resp Handler _, err := LoadStrategyByName("test", false) if !errors.Is(err, base.ErrStrategyNotFound) { - t.Errorf("expected: %v, received %v", base.ErrStrategyNotFound, err) + t.Errorf("received: %v, expected: %v", err, base.ErrStrategyNotFound) } _, err = LoadStrategyByName("test", true) if !errors.Is(err, base.ErrStrategyNotFound) { - t.Errorf("expected: %v, received %v", base.ErrStrategyNotFound, err) + t.Errorf("received: %v, expected: %v", err, base.ErrStrategyNotFound) } resp, err = LoadStrategyByName(dollarcostaverage.Name, false) @@ -38,7 +40,7 @@ func TestLoadStrategyByName(t *testing.T) { if err != nil { t.Error(err) } - if !resp.UseSimultaneousProcessing() { + if !resp.UsingSimultaneousProcessing() { t.Error("expected true") } @@ -50,7 +52,7 @@ func TestLoadStrategyByName(t *testing.T) { t.Error("expected rsi") } _, err = LoadStrategyByName(rsi.Name, true) - if !errors.Is(err, base.ErrSimultaneousProcessingNotSupported) { - t.Errorf("expected: %v, received %v", base.ErrSimultaneousProcessingNotSupported, err) + if !errors.Is(err, nil) { + t.Errorf("received: %v, expected: %v", err, nil) } } diff --git a/backtester/eventhandlers/strategies/strategies_types.go b/backtester/eventhandlers/strategies/strategies_types.go index 530f035b..e77e32c7 100644 --- a/backtester/eventhandlers/strategies/strategies_types.go +++ b/backtester/eventhandlers/strategies/strategies_types.go @@ -2,17 +2,17 @@ package strategies import ( "github.com/thrasher-corp/gocryptotrader/backtester/data" - "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/portfolio" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/signal" + "github.com/thrasher-corp/gocryptotrader/backtester/funding" ) // Handler defines all functions required to run strategies against data events type Handler interface { Name() string Description() string - OnSignal(data.Handler, portfolio.Handler) (signal.Event, error) - OnSimultaneousSignals([]data.Handler, portfolio.Handler) ([]signal.Event, error) - UseSimultaneousProcessing() bool + OnSignal(data.Handler, funding.IFundTransferer) (signal.Event, error) + OnSimultaneousSignals([]data.Handler, funding.IFundTransferer) ([]signal.Event, error) + UsingSimultaneousProcessing() bool SupportsSimultaneousProcessing() bool SetSimultaneousProcessing(bool) SetCustomSettings(map[string]interface{}) error diff --git a/backtester/eventhandlers/strategies/top2bottom2/README.md b/backtester/eventhandlers/strategies/top2bottom2/README.md new file mode 100644 index 00000000..1a139991 --- /dev/null +++ b/backtester/eventhandlers/strategies/top2bottom2/README.md @@ -0,0 +1,55 @@ +# GoCryptoTrader Backtester: Top2bottom2 package + + + + +[![Build Status](https://github.com/thrasher-corp/gocryptotrader/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/thrasher-corp/gocryptotrader/actions/workflows/tests.yml) +[![Software License](https://img.shields.io/badge/License-MIT-orange.svg?style=flat-square)](https://github.com/thrasher-corp/gocryptotrader/blob/master/LICENSE) +[![GoDoc](https://godoc.org/github.com/thrasher-corp/gocryptotrader?status.svg)](https://godoc.org/github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/strategies/top2bottom2) +[![Coverage Status](http://codecov.io/github/thrasher-corp/gocryptotrader/coverage.svg?branch=master)](http://codecov.io/github/thrasher-corp/gocryptotrader?branch=master) +[![Go Report Card](https://goreportcard.com/badge/github.com/thrasher-corp/gocryptotrader)](https://goreportcard.com/report/github.com/thrasher-corp/gocryptotrader) + + +This top2bottom2 package is part of the GoCryptoTrader codebase. + +## This is still in active development + +You can track ideas, planned features and what's in progress on this Trello board: [https://trello.com/b/ZAhMhpOy/gocryptotrader](https://trello.com/b/ZAhMhpOy/gocryptotrader). + +Join our slack to discuss all things related to GoCryptoTrader! [GoCryptoTrader Slack](https://join.slack.com/t/gocryptotrader/shared_invite/enQtNTQ5NDAxMjA2Mjc5LTc5ZDE1ZTNiOGM3ZGMyMmY1NTAxYWZhODE0MWM5N2JlZDk1NDU0YTViYzk4NTk3OTRiMDQzNGQ1YTc4YmRlMTk) + +## Top 2 Bottom 2 package overview + +The Top 2 Bottom 2 (T2B2) strategy utilises [the gct-ta MFI package](https://github.com/thrasher-corp/gct-ta) to analyse market signals and selects the top and bottom two currencies based on MFI value. +It is a basic example strategy to highlight how the backtester can perform more complex data event signal processing + +This strategy *requires* at least 4 exchange currency settings to determine the 4 signals to process +This strategy *requires* `SimultaneousSignalProcessing` aka [use-simultaneous-signal-processing](/backtester/config/README.md). +This strategy does support strategy customisation in the following ways: + +| Field | Description | Example | +| --- | ------- | --- | +|mfi-high| The upper bounds of MFI that when met, will trigger a Sell signal | 70 | +|mfi-low| The lower bounds of MFI that when met, will trigger a Buy signal | 30 | +|mfi-period| The consecutive candle periods used in order to generate a value. All values less than this number cannot output a buy or sell signal | 14 | + +### Please click GoDocs chevron above to view current GoDoc information for this package + +## Contribution + +Please feel free to submit any pull requests or suggest any desired features to be added. + +When submitting a PR, please abide by our coding guidelines: + ++ Code must adhere to the official Go [formatting](https://golang.org/doc/effective_go.html#formatting) guidelines (i.e. uses [gofmt](https://golang.org/cmd/gofmt/)). ++ Code must be documented adhering to the official Go [commentary](https://golang.org/doc/effective_go.html#commentary) guidelines. ++ Code must adhere to our [coding style](https://github.com/thrasher-corp/gocryptotrader/blob/master/doc/coding_style.md). ++ Pull requests need to be based on and opened against the `master` branch. + +## Donations + + + +If this framework helped you in any way, or you would like to support the developers working on it, please donate Bitcoin to: + +***bc1qk0jareu4jytc0cfrhr5wgshsq8282awpavfahc*** diff --git a/backtester/eventhandlers/strategies/top2bottom2/top2bottom2.go b/backtester/eventhandlers/strategies/top2bottom2/top2bottom2.go new file mode 100644 index 00000000..4bfd1061 --- /dev/null +++ b/backtester/eventhandlers/strategies/top2bottom2/top2bottom2.go @@ -0,0 +1,254 @@ +package top2bottom2 + +import ( + "errors" + "fmt" + "sort" + "time" + + "github.com/shopspring/decimal" + "github.com/thrasher-corp/gct-ta/indicators" + "github.com/thrasher-corp/gocryptotrader/backtester/common" + "github.com/thrasher-corp/gocryptotrader/backtester/data" + "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/strategies/base" + "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/signal" + "github.com/thrasher-corp/gocryptotrader/backtester/funding" + gctcommon "github.com/thrasher-corp/gocryptotrader/common" + "github.com/thrasher-corp/gocryptotrader/exchanges/order" +) + +const ( + // Name is the strategy name + Name = "top2bottom2" + mfiPeriodKey = "mfi-period" + mfiLowKey = "mfi-low" + mfiHighKey = "mfi-high" + description = `This is an example strategy to highlight more complex strategy design. All signals are processed and then ranked. Only the top 2 and bottom 2 proceed further` +) + +var ( + errStrategyOnlySupportsSimultaneousProcessing = errors.New("strategy only supports simultaneous processing") + errStrategyCurrencyRequirements = errors.New("top2bottom2 strategy requires at least 4 currencies") +) + +// Strategy is an implementation of the Handler interface +type Strategy struct { + base.Strategy + mfiPeriod decimal.Decimal + mfiLow decimal.Decimal + mfiHigh decimal.Decimal +} + +// Name returns the name of the strategy +func (s *Strategy) Name() string { + return Name +} + +// Description provides a nice overview of the strategy +// be it definition of terms or to highlight its purpose +func (s *Strategy) Description() string { + return description +} + +// OnSignal handles a data event and returns what action the strategy believes should occur +// however,this complex strategy cannot function on an individual basis +func (s *Strategy) OnSignal(_ data.Handler, _ funding.IFundTransferer) (signal.Event, error) { + return nil, errStrategyOnlySupportsSimultaneousProcessing +} + +// SupportsSimultaneousProcessing highlights whether the strategy can handle multiple currency calculation +// There is nothing actually stopping this strategy from considering multiple currencies at once +// but for demonstration purposes, this strategy does not +func (s *Strategy) SupportsSimultaneousProcessing() bool { + return true +} + +type mfiFundEvent struct { + event signal.Event + mfi decimal.Decimal + funds funding.IPairReader +} + +// ByPrice used for sorting orders by order date +type byMFI []mfiFundEvent + +func (b byMFI) Len() int { return len(b) } +func (b byMFI) Less(i, j int) bool { return b[i].mfi.LessThan(b[j].mfi) } +func (b byMFI) Swap(i, j int) { b[i], b[j] = b[j], b[i] } + +// sortOrdersByPrice the caller function to sort orders +func sortByMFI(o *[]mfiFundEvent, reverse bool) { + if reverse { + sort.Sort(sort.Reverse(byMFI(*o))) + } else { + sort.Sort(byMFI(*o)) + } +} + +// OnSimultaneousSignals analyses multiple data points simultaneously, allowing flexibility +// in allowing a strategy to only place an order for X currency if Y currency's price is Z +func (s *Strategy) OnSimultaneousSignals(d []data.Handler, f funding.IFundTransferer) ([]signal.Event, error) { + if len(d) < 4 { + return nil, errStrategyCurrencyRequirements + } + var mfiFundEvents []mfiFundEvent + var resp []signal.Event + for i := range d { + if d == nil { + return nil, common.ErrNilEvent + } + es, err := s.GetBaseData(d[i]) + if err != nil { + return nil, err + } + es.SetPrice(d[i].Latest().ClosePrice()) + offset := d[i].Offset() + + if offset <= int(s.mfiPeriod.IntPart()) { + es.AppendReason("Not enough data for signal generation") + es.SetDirection(common.DoNothing) + resp = append(resp, &es) + continue + } + + closeData := d[i].StreamClose() + volumeData := d[i].StreamVol() + highData := d[i].StreamHigh() + lowData := d[i].StreamLow() + var massagedCloseData, massagedVolumeData, massagedHighData, massagedLowData []float64 + massagedCloseData, err = s.massageMissingData(closeData, es.GetTime()) + if err != nil { + return nil, err + } + massagedVolumeData, err = s.massageMissingData(volumeData, es.GetTime()) + if err != nil { + return nil, err + } + massagedHighData, err = s.massageMissingData(highData, es.GetTime()) + if err != nil { + return nil, err + } + massagedLowData, err = s.massageMissingData(lowData, es.GetTime()) + if err != nil { + return nil, err + } + mfi := indicators.MFI(massagedHighData, massagedLowData, massagedCloseData, massagedVolumeData, int(s.mfiPeriod.IntPart())) + latestMFI := decimal.NewFromFloat(mfi[len(mfi)-1]) + if !d[i].HasDataAtTime(d[i].Latest().GetTime()) { + es.SetDirection(common.MissingData) + es.AppendReason(fmt.Sprintf("missing data at %v, cannot perform any actions. MFI %v", d[i].Latest().GetTime(), latestMFI)) + resp = append(resp, &es) + continue + } + + es.SetDirection(common.DoNothing) + es.AppendReason(fmt.Sprintf("MFI at %v", latestMFI)) + + funds, err := f.GetFundingForEvent(&es) + if err != nil { + return nil, err + } + mfiFundEvents = append(mfiFundEvents, mfiFundEvent{ + event: &es, + mfi: latestMFI, + funds: funds, + }) + } + + return s.selectTopAndBottomPerformers(mfiFundEvents, resp) +} + +func (s *Strategy) selectTopAndBottomPerformers(mfiFundEvents []mfiFundEvent, resp []signal.Event) ([]signal.Event, error) { + if len(mfiFundEvents) == 0 { + return resp, nil + } + sortByMFI(&mfiFundEvents, true) + buyingOrSelling := false + for i := range mfiFundEvents { + if i < 2 && mfiFundEvents[i].mfi.GreaterThanOrEqual(s.mfiHigh) { + mfiFundEvents[i].event.SetDirection(order.Sell) + buyingOrSelling = true + } else if i >= 2 { + break + } + } + sortByMFI(&mfiFundEvents, false) + for i := range mfiFundEvents { + if i < 2 && mfiFundEvents[i].mfi.LessThanOrEqual(s.mfiLow) { + mfiFundEvents[i].event.SetDirection(order.Buy) + buyingOrSelling = true + } else if i >= 2 { + break + } + } + for i := range mfiFundEvents { + if buyingOrSelling && mfiFundEvents[i].event.GetDirection() == common.DoNothing { + mfiFundEvents[i].event.AppendReason("MFI was not in the top or bottom two ranks") + } + resp = append(resp, mfiFundEvents[i].event) + } + return resp, nil +} + +// SetCustomSettings allows a user to modify the MFI limits in their config +func (s *Strategy) SetCustomSettings(customSettings map[string]interface{}) error { + for k, v := range customSettings { + switch k { + case mfiHighKey: + mfiHigh, ok := v.(float64) + if !ok || mfiHigh <= 0 { + return fmt.Errorf("%w provided mfi-high value could not be parsed: %v", base.ErrInvalidCustomSettings, v) + } + s.mfiHigh = decimal.NewFromFloat(mfiHigh) + case mfiLowKey: + mfiLow, ok := v.(float64) + if !ok || mfiLow <= 0 { + return fmt.Errorf("%w provided mfi-low value could not be parsed: %v", base.ErrInvalidCustomSettings, v) + } + s.mfiLow = decimal.NewFromFloat(mfiLow) + case mfiPeriodKey: + mfiPeriod, ok := v.(float64) + if !ok || mfiPeriod <= 0 { + return fmt.Errorf("%w provided mfi-period value could not be parsed: %v", base.ErrInvalidCustomSettings, v) + } + s.mfiPeriod = decimal.NewFromFloat(mfiPeriod) + default: + return fmt.Errorf("%w unrecognised custom setting key %v with value %v. Cannot apply", base.ErrInvalidCustomSettings, k, v) + } + } + + return nil +} + +// SetDefaults sets the custom settings to their default values +func (s *Strategy) SetDefaults() { + s.mfiHigh = decimal.NewFromInt(70) + s.mfiLow = decimal.NewFromInt(30) + s.mfiPeriod = decimal.NewFromInt(14) +} + +// massageMissingData will replace missing data with the previous candle's data +// this will ensure that mfi can be calculated correctly +// the decision to handle missing data occurs at the strategy level, not all strategies +// may wish to modify data +func (s *Strategy) massageMissingData(data []decimal.Decimal, t time.Time) ([]float64, error) { + var resp []float64 + var missingDataStreak int64 + for i := range data { + if data[i].IsZero() && i > int(s.mfiPeriod.IntPart()) { + data[i] = data[i-1] + missingDataStreak++ + } else { + missingDataStreak = 0 + } + if missingDataStreak >= s.mfiPeriod.IntPart() { + return nil, fmt.Errorf("missing data exceeds mfi period length of %v at %s and will distort results. %w", + s.mfiPeriod, + t.Format(gctcommon.SimpleTimeFormat), + base.ErrTooMuchBadData) + } + d, _ := data[i].Float64() + resp = append(resp, d) + } + return resp, nil +} diff --git a/backtester/eventhandlers/strategies/top2bottom2/top2bottom2_test.go b/backtester/eventhandlers/strategies/top2bottom2/top2bottom2_test.go new file mode 100644 index 00000000..99ee0234 --- /dev/null +++ b/backtester/eventhandlers/strategies/top2bottom2/top2bottom2_test.go @@ -0,0 +1,232 @@ +package top2bottom2 + +import ( + "errors" + "strings" + "testing" + "time" + + "github.com/shopspring/decimal" + "github.com/thrasher-corp/gocryptotrader/backtester/common" + "github.com/thrasher-corp/gocryptotrader/backtester/data" + "github.com/thrasher-corp/gocryptotrader/backtester/data/kline" + "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/strategies/base" + "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/event" + eventkline "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/kline" + "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/signal" + "github.com/thrasher-corp/gocryptotrader/currency" + "github.com/thrasher-corp/gocryptotrader/exchanges/asset" + gctkline "github.com/thrasher-corp/gocryptotrader/exchanges/kline" + "github.com/thrasher-corp/gocryptotrader/exchanges/order" +) + +func TestName(t *testing.T) { + t.Parallel() + d := Strategy{} + n := d.Name() + if n != Name { + t.Errorf("expected %v", Name) + } +} + +func TestSupportsSimultaneousProcessing(t *testing.T) { + t.Parallel() + s := Strategy{} + if !s.SupportsSimultaneousProcessing() { + t.Error("expected true") + } +} + +func TestDescription(t *testing.T) { + t.Parallel() + s := Strategy{} + if s.Description() != description { + t.Error("unexpected description") + } +} + +func TestSetCustomSettings(t *testing.T) { + t.Parallel() + s := Strategy{} + err := s.SetCustomSettings(nil) + if err != nil { + t.Error(err) + } + float14 := float64(14) + mappalopalous := make(map[string]interface{}) + mappalopalous[mfiPeriodKey] = float14 + mappalopalous[mfiLowKey] = float14 + mappalopalous[mfiHighKey] = float14 + + err = s.SetCustomSettings(mappalopalous) + if err != nil { + t.Error(err) + } + + mappalopalous[mfiPeriodKey] = "14" + err = s.SetCustomSettings(mappalopalous) + if !errors.Is(err, base.ErrInvalidCustomSettings) { + t.Errorf("received: %v, expected: %v", err, base.ErrInvalidCustomSettings) + } + + mappalopalous[mfiPeriodKey] = float14 + mappalopalous[mfiLowKey] = "14" + err = s.SetCustomSettings(mappalopalous) + if !errors.Is(err, base.ErrInvalidCustomSettings) { + t.Errorf("received: %v, expected: %v", err, base.ErrInvalidCustomSettings) + } + + mappalopalous[mfiLowKey] = float14 + mappalopalous[mfiHighKey] = "14" + err = s.SetCustomSettings(mappalopalous) + if !errors.Is(err, base.ErrInvalidCustomSettings) { + t.Errorf("received: %v, expected: %v", err, base.ErrInvalidCustomSettings) + } + + mappalopalous[mfiHighKey] = float14 + mappalopalous["lol"] = float14 + err = s.SetCustomSettings(mappalopalous) + if !errors.Is(err, base.ErrInvalidCustomSettings) { + t.Errorf("received: %v, expected: %v", err, base.ErrInvalidCustomSettings) + } +} + +func TestOnSignal(t *testing.T) { + t.Parallel() + s := Strategy{} + _, err := s.OnSignal(nil, nil) + if !errors.Is(err, errStrategyOnlySupportsSimultaneousProcessing) { + t.Errorf("received: %v, expected: %v", err, errStrategyOnlySupportsSimultaneousProcessing) + } +} + +func TestOnSignals(t *testing.T) { + t.Parallel() + s := Strategy{} + _, err := s.OnSignal(nil, nil) + if !errors.Is(err, errStrategyOnlySupportsSimultaneousProcessing) { + t.Errorf("received: %v, expected: %v", err, errStrategyOnlySupportsSimultaneousProcessing) + } + dInsert := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + exch := "binance" + a := asset.Spot + p := currency.NewPair(currency.BTC, currency.USDT) + d := data.Base{} + d.SetStream([]common.DataEventHandler{&eventkline.Kline{ + Base: event.Base{ + Exchange: exch, + Time: dInsert, + Interval: gctkline.OneDay, + CurrencyPair: p, + AssetType: a, + }, + Open: decimal.NewFromInt(1337), + Close: decimal.NewFromInt(1337), + Low: decimal.NewFromInt(1337), + High: decimal.NewFromInt(1337), + Volume: decimal.NewFromInt(1337), + }}) + d.Next() + da := &kline.DataFromKline{ + Item: gctkline.Item{}, + Base: d, + RangeHolder: &gctkline.IntervalRangeHolder{}, + } + _, err = s.OnSimultaneousSignals([]data.Handler{da}, nil) + if !strings.Contains(err.Error(), errStrategyCurrencyRequirements.Error()) { + // common.Errs type doesn't keep type + t.Errorf("received: %v, expected: %v", err, errStrategyCurrencyRequirements) + } + + _, err = s.OnSimultaneousSignals([]data.Handler{da, da, da, da}, nil) + if !strings.Contains(err.Error(), base.ErrTooMuchBadData.Error()) { + // common.Errs type doesn't keep type + t.Errorf("received: %v, expected: %v", err, base.ErrTooMuchBadData) + } +} + +func TestSetDefaults(t *testing.T) { + t.Parallel() + s := Strategy{} + s.SetDefaults() + if !s.mfiHigh.Equal(decimal.NewFromInt(70)) { + t.Error("expected 70") + } + if !s.mfiLow.Equal(decimal.NewFromInt(30)) { + t.Error("expected 30") + } + if !s.mfiPeriod.Equal(decimal.NewFromInt(14)) { + t.Error("expected 14") + } +} + +func TestSelectTopAndBottomPerformers(t *testing.T) { + t.Parallel() + s := Strategy{} + s.SetDefaults() + _, err := s.selectTopAndBottomPerformers(nil, nil) + if err != nil { + t.Error(err) + } + + fundEvents := []mfiFundEvent{ + { + event: &signal.Signal{ + ClosePrice: decimal.NewFromInt(99), + Direction: common.DoNothing, + }, + mfi: decimal.NewFromInt(99), + }, + { + event: &signal.Signal{ + ClosePrice: decimal.NewFromInt(98), + Direction: common.DoNothing, + }, + mfi: decimal.NewFromInt(98), + }, + { + event: &signal.Signal{ + ClosePrice: decimal.NewFromInt(1), + Direction: common.DoNothing, + }, + mfi: decimal.NewFromInt(1), + }, + { + event: &signal.Signal{ + ClosePrice: decimal.NewFromInt(2), + Direction: common.DoNothing, + }, + mfi: decimal.NewFromInt(2), + }, + { + event: &signal.Signal{ + ClosePrice: decimal.NewFromInt(50), + Direction: common.DoNothing, + }, + mfi: decimal.NewFromInt(50), + }, + } + resp, err := s.selectTopAndBottomPerformers(fundEvents, nil) + if err != nil { + t.Error(err) + } + if len(resp) != 5 { + t.Error("expected 5 events") + } + for i := range resp { + switch resp[i].GetDirection() { + case order.Buy: + if !resp[i].GetPrice().Equal(decimal.NewFromInt(1)) && !resp[i].GetPrice().Equal(decimal.NewFromInt(2)) { + t.Error("expected 1 or 2") + } + case order.Sell: + if !resp[i].GetPrice().Equal(decimal.NewFromInt(99)) && !resp[i].GetPrice().Equal(decimal.NewFromInt(98)) { + t.Error("expected 99 or 98") + } + case common.DoNothing: + if !resp[i].GetPrice().Equal(decimal.NewFromInt(50)) { + t.Error("expected 50") + } + } + } +} diff --git a/backtester/eventtypes/event/event_test.go b/backtester/eventtypes/event/event_test.go index d0d789ad..8f9b184e 100644 --- a/backtester/eventtypes/event/event_test.go +++ b/backtester/eventtypes/event/event_test.go @@ -11,6 +11,7 @@ import ( ) func TestEvent_AppendWhy(t *testing.T) { + t.Parallel() e := &Base{} e.AppendReason("test") y := e.GetReason() @@ -20,6 +21,7 @@ func TestEvent_AppendWhy(t *testing.T) { } func TestEvent_GetAssetType(t *testing.T) { + t.Parallel() e := &Base{ AssetType: asset.Spot, } @@ -30,6 +32,7 @@ func TestEvent_GetAssetType(t *testing.T) { } func TestEvent_GetExchange(t *testing.T) { + t.Parallel() e := &Base{ Exchange: "test", } @@ -40,6 +43,7 @@ func TestEvent_GetExchange(t *testing.T) { } func TestEvent_GetInterval(t *testing.T) { + t.Parallel() e := &Base{ Interval: gctkline.OneMin, } @@ -50,6 +54,7 @@ func TestEvent_GetInterval(t *testing.T) { } func TestEvent_GetTime(t *testing.T) { + t.Parallel() tt := time.Now() e := &Base{ Time: tt, @@ -61,6 +66,7 @@ func TestEvent_GetTime(t *testing.T) { } func TestEvent_IsEvent(t *testing.T) { + t.Parallel() e := &Base{} y := e.IsEvent() if !y { @@ -69,6 +75,7 @@ func TestEvent_IsEvent(t *testing.T) { } func TestEvent_Pair(t *testing.T) { + t.Parallel() e := &Base{ CurrencyPair: currency.NewPair(currency.BTC, currency.USDT), } diff --git a/backtester/eventtypes/fill/fill.go b/backtester/eventtypes/fill/fill.go index b6eb2d2f..609c6225 100644 --- a/backtester/eventtypes/fill/fill.go +++ b/backtester/eventtypes/fill/fill.go @@ -1,6 +1,7 @@ package fill import ( + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/exchanges/order" ) @@ -15,42 +16,42 @@ func (f *Fill) GetDirection() order.Side { } // SetAmount sets the amount -func (f *Fill) SetAmount(i float64) { +func (f *Fill) SetAmount(i decimal.Decimal) { f.Amount = i } // GetAmount returns the amount -func (f *Fill) GetAmount() float64 { +func (f *Fill) GetAmount() decimal.Decimal { return f.Amount } // GetClosePrice returns the closing price -func (f *Fill) GetClosePrice() float64 { +func (f *Fill) GetClosePrice() decimal.Decimal { return f.ClosePrice } // GetVolumeAdjustedPrice returns the volume adjusted price -func (f *Fill) GetVolumeAdjustedPrice() float64 { +func (f *Fill) GetVolumeAdjustedPrice() decimal.Decimal { return f.VolumeAdjustedPrice } // GetPurchasePrice returns the purchase price -func (f *Fill) GetPurchasePrice() float64 { +func (f *Fill) GetPurchasePrice() decimal.Decimal { return f.PurchasePrice } // GetTotal returns the total cost -func (f *Fill) GetTotal() float64 { +func (f *Fill) GetTotal() decimal.Decimal { return f.Total } // GetExchangeFee returns the exchange fee -func (f *Fill) GetExchangeFee() float64 { +func (f *Fill) GetExchangeFee() decimal.Decimal { return f.ExchangeFee } // SetExchangeFee sets the exchange fee -func (f *Fill) SetExchangeFee(fee float64) { +func (f *Fill) SetExchangeFee(fee decimal.Decimal) { f.ExchangeFee = fee } @@ -60,6 +61,6 @@ func (f *Fill) GetOrder() *order.Detail { } // GetSlippageRate returns the slippage rate -func (f *Fill) GetSlippageRate() float64 { +func (f *Fill) GetSlippageRate() decimal.Decimal { return f.Slippage } diff --git a/backtester/eventtypes/fill/fill_test.go b/backtester/eventtypes/fill/fill_test.go index 66e03989..1d343d9b 100644 --- a/backtester/eventtypes/fill/fill_test.go +++ b/backtester/eventtypes/fill/fill_test.go @@ -3,10 +3,12 @@ package fill import ( "testing" + "github.com/shopspring/decimal" gctorder "github.com/thrasher-corp/gocryptotrader/exchanges/order" ) func TestSetDirection(t *testing.T) { + t.Parallel() f := Fill{ Direction: gctorder.Sell, } @@ -17,53 +19,59 @@ func TestSetDirection(t *testing.T) { } func TestSetAmount(t *testing.T) { + t.Parallel() f := Fill{ - Amount: 1, + Amount: decimal.NewFromInt(1), } - f.SetAmount(1337) - if f.GetAmount() != 1337 { - t.Error("expected 1337") + f.SetAmount(decimal.NewFromInt(1337)) + if !f.GetAmount().Equal(decimal.NewFromInt(1337)) { + t.Error("expected decimal.NewFromInt(1337)") } } func TestGetClosePrice(t *testing.T) { + t.Parallel() f := Fill{ - ClosePrice: 1337, + ClosePrice: decimal.NewFromInt(1337), } - if f.GetClosePrice() != 1337 { - t.Error("expected 1337") + if !f.GetClosePrice().Equal(decimal.NewFromInt(1337)) { + t.Error("expected decimal.NewFromInt(1337)") } } func TestGetVolumeAdjustedPrice(t *testing.T) { + t.Parallel() f := Fill{ - VolumeAdjustedPrice: 1337, + VolumeAdjustedPrice: decimal.NewFromInt(1337), } - if f.GetVolumeAdjustedPrice() != 1337 { - t.Error("expected 1337") + if !f.GetVolumeAdjustedPrice().Equal(decimal.NewFromInt(1337)) { + t.Error("expected decimal.NewFromInt(1337)") } } func TestGetPurchasePrice(t *testing.T) { + t.Parallel() f := Fill{ - PurchasePrice: 1337, + PurchasePrice: decimal.NewFromInt(1337), } - if f.GetPurchasePrice() != 1337 { - t.Error("expected 1337") + if !f.GetPurchasePrice().Equal(decimal.NewFromInt(1337)) { + t.Error("expected decimal.NewFromInt(1337)") } } func TestSetExchangeFee(t *testing.T) { + t.Parallel() f := Fill{ - ExchangeFee: 1, + ExchangeFee: decimal.NewFromInt(1), } - f.SetExchangeFee(1337) - if f.GetExchangeFee() != 1337 { - t.Error("expected 1337") + f.SetExchangeFee(decimal.NewFromInt(1337)) + if !f.GetExchangeFee().Equal(decimal.NewFromInt(1337)) { + t.Error("expected decimal.NewFromInt(1337)") } } func TestGetOrder(t *testing.T) { + t.Parallel() f := Fill{ Order: &gctorder.Detail{}, } @@ -73,10 +81,11 @@ func TestGetOrder(t *testing.T) { } func TestGetSlippageRate(t *testing.T) { + t.Parallel() f := Fill{ - Slippage: 1, + Slippage: decimal.NewFromInt(1), } - if f.GetSlippageRate() != 1 { + if !f.GetSlippageRate().Equal(decimal.NewFromInt(1)) { t.Error("expected 1") } } diff --git a/backtester/eventtypes/fill/fill_types.go b/backtester/eventtypes/fill/fill_types.go index 5d098277..f172357a 100644 --- a/backtester/eventtypes/fill/fill_types.go +++ b/backtester/eventtypes/fill/fill_types.go @@ -1,6 +1,7 @@ package fill import ( + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/common" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/event" "github.com/thrasher-corp/gocryptotrader/exchanges/order" @@ -9,15 +10,15 @@ import ( // Fill is an event that details the events from placing an order type Fill struct { event.Base - Direction order.Side `json:"side"` - Amount float64 `json:"amount"` - ClosePrice float64 `json:"close-price"` - VolumeAdjustedPrice float64 `json:"volume-adjusted-price"` - PurchasePrice float64 `json:"purchase-price"` - Total float64 `json:"total"` - ExchangeFee float64 `json:"exchange-fee"` - Slippage float64 `json:"slippage"` - Order *order.Detail `json:"-"` + Direction order.Side `json:"side"` + Amount decimal.Decimal `json:"amount"` + ClosePrice decimal.Decimal `json:"close-price"` + VolumeAdjustedPrice decimal.Decimal `json:"volume-adjusted-price"` + PurchasePrice decimal.Decimal `json:"purchase-price"` + Total decimal.Decimal `json:"total"` + ExchangeFee decimal.Decimal `json:"exchange-fee"` + Slippage decimal.Decimal `json:"slippage"` + Order *order.Detail `json:"-"` } // Event holds all functions required to handle a fill event @@ -25,14 +26,14 @@ type Event interface { common.EventHandler common.Directioner - SetAmount(float64) - GetAmount() float64 - GetClosePrice() float64 - GetVolumeAdjustedPrice() float64 - GetSlippageRate() float64 - GetPurchasePrice() float64 - GetTotal() float64 - GetExchangeFee() float64 - SetExchangeFee(float64) + SetAmount(decimal.Decimal) + GetAmount() decimal.Decimal + GetClosePrice() decimal.Decimal + GetVolumeAdjustedPrice() decimal.Decimal + GetSlippageRate() decimal.Decimal + GetPurchasePrice() decimal.Decimal + GetTotal() decimal.Decimal + GetExchangeFee() decimal.Decimal + SetExchangeFee(decimal.Decimal) GetOrder() *order.Detail } diff --git a/backtester/eventtypes/kline/kline.go b/backtester/eventtypes/kline/kline.go index 019775e0..f61360e6 100644 --- a/backtester/eventtypes/kline/kline.go +++ b/backtester/eventtypes/kline/kline.go @@ -1,21 +1,23 @@ package kline +import "github.com/shopspring/decimal" + // ClosePrice returns the closing price of a kline -func (k *Kline) ClosePrice() float64 { +func (k *Kline) ClosePrice() decimal.Decimal { return k.Close } // HighPrice returns the high price of a kline -func (k *Kline) HighPrice() float64 { +func (k *Kline) HighPrice() decimal.Decimal { return k.High } // LowPrice returns the low price of a kline -func (k *Kline) LowPrice() float64 { +func (k *Kline) LowPrice() decimal.Decimal { return k.Low } // OpenPrice returns the open price of a kline -func (k *Kline) OpenPrice() float64 { +func (k *Kline) OpenPrice() decimal.Decimal { return k.Open } diff --git a/backtester/eventtypes/kline/kline_test.go b/backtester/eventtypes/kline/kline_test.go index 1788296f..7684c52f 100644 --- a/backtester/eventtypes/kline/kline_test.go +++ b/backtester/eventtypes/kline/kline_test.go @@ -2,40 +2,46 @@ package kline import ( "testing" + + "github.com/shopspring/decimal" ) func TestClose(t *testing.T) { + t.Parallel() k := Kline{ - Close: 1337, + Close: decimal.NewFromInt(1337), } - if k.ClosePrice() != 1337 { - t.Error("expected 1337") + if !k.ClosePrice().Equal(decimal.NewFromInt(1337)) { + t.Error("expected decimal.NewFromInt(1337)") } } func TestHigh(t *testing.T) { + t.Parallel() k := Kline{ - High: 1337, + High: decimal.NewFromInt(1337), } - if k.HighPrice() != 1337 { - t.Error("expected 1337") + if !k.HighPrice().Equal(decimal.NewFromInt(1337)) { + t.Error("expected decimal.NewFromInt(1337)") } } func TestLow(t *testing.T) { + t.Parallel() k := Kline{ - Low: 1337, + Low: decimal.NewFromInt(1337), } - if k.LowPrice() != 1337 { - t.Error("expected 1337") + if !k.LowPrice().Equal(decimal.NewFromInt(1337)) { + t.Error("expected decimal.NewFromInt(1337)") } } func TestOpen(t *testing.T) { + t.Parallel() k := Kline{ - Open: 1337, + Open: decimal.NewFromInt(1337), } - if k.OpenPrice() != 1337 { - t.Error("expected 1337") + if !k.OpenPrice().Equal(decimal.NewFromInt(1337)) { + t.Error("expected decimal.NewFromInt(1337)") } } diff --git a/backtester/eventtypes/kline/kline_types.go b/backtester/eventtypes/kline/kline_types.go index 97f9e7ce..71121e5b 100644 --- a/backtester/eventtypes/kline/kline_types.go +++ b/backtester/eventtypes/kline/kline_types.go @@ -1,6 +1,7 @@ package kline import ( + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/event" ) @@ -8,10 +9,10 @@ import ( // a common.DataEventHandler type type Kline struct { event.Base - Open float64 - Close float64 - Low float64 - High float64 - Volume float64 + Open decimal.Decimal + Close decimal.Decimal + Low decimal.Decimal + High decimal.Decimal + Volume decimal.Decimal ValidationIssues string } diff --git a/backtester/eventtypes/order/order.go b/backtester/eventtypes/order/order.go index db3649a8..102ba10a 100644 --- a/backtester/eventtypes/order/order.go +++ b/backtester/eventtypes/order/order.go @@ -1,6 +1,7 @@ package order import ( + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/exchanges/order" ) @@ -21,22 +22,22 @@ func (o *Order) GetDirection() order.Side { } // SetAmount sets the amount -func (o *Order) SetAmount(i float64) { +func (o *Order) SetAmount(i decimal.Decimal) { o.Amount = i } // GetAmount returns the amount -func (o *Order) GetAmount() float64 { +func (o *Order) GetAmount() decimal.Decimal { return o.Amount } // GetBuyLimit returns the buy limit -func (o *Order) GetBuyLimit() float64 { +func (o *Order) GetBuyLimit() decimal.Decimal { return o.BuyLimit } // GetSellLimit returns the sell limit -func (o *Order) GetSellLimit() float64 { +func (o *Order) GetSellLimit() decimal.Decimal { return o.SellLimit } @@ -62,21 +63,21 @@ func (o *Order) GetID() string { // IsLeveraged returns if it is leveraged func (o *Order) IsLeveraged() bool { - return o.Leverage > 1.0 + return o.Leverage.GreaterThan(decimal.NewFromFloat(1)) } // GetLeverage returns leverage rate -func (o *Order) GetLeverage() float64 { +func (o *Order) GetLeverage() decimal.Decimal { return o.Leverage } // SetLeverage sets leverage -func (o *Order) SetLeverage(l float64) { +func (o *Order) SetLeverage(l decimal.Decimal) { o.Leverage = l } -// GetFunds returns the amount of funds the portfolio manager +// GetAllocatedFunds returns the amount of funds the portfolio manager // has allocated to this potential position -func (o *Order) GetFunds() float64 { - return o.Funds +func (o *Order) GetAllocatedFunds() decimal.Decimal { + return o.AllocatedFunds } diff --git a/backtester/eventtypes/order/order_test.go b/backtester/eventtypes/order/order_test.go index b3294981..e41f2684 100644 --- a/backtester/eventtypes/order/order_test.go +++ b/backtester/eventtypes/order/order_test.go @@ -3,12 +3,14 @@ package order import ( "testing" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/event" "github.com/thrasher-corp/gocryptotrader/currency" gctorder "github.com/thrasher-corp/gocryptotrader/exchanges/order" ) func TestIsOrder(t *testing.T) { + t.Parallel() o := Order{} if !o.IsOrder() { t.Error("expected true") @@ -16,6 +18,7 @@ func TestIsOrder(t *testing.T) { } func TestSetDirection(t *testing.T) { + t.Parallel() o := Order{ Direction: gctorder.Sell, } @@ -26,16 +29,18 @@ func TestSetDirection(t *testing.T) { } func TestSetAmount(t *testing.T) { + t.Parallel() o := Order{ - Amount: 1, + Amount: decimal.NewFromInt(1), } - o.SetAmount(1337) - if o.GetAmount() != 1337 { - t.Error("expected 1337") + o.SetAmount(decimal.NewFromInt(1337)) + if !o.GetAmount().Equal(decimal.NewFromInt(1337)) { + t.Error("expected decimal.NewFromInt(1337)") } } func TestPair(t *testing.T) { + t.Parallel() o := Order{ Base: event.Base{ CurrencyPair: currency.NewPair(currency.BTC, currency.USDT), @@ -48,8 +53,9 @@ func TestPair(t *testing.T) { } func TestSetID(t *testing.T) { + t.Parallel() o := Order{ - ID: "1337", + ID: "decimal.NewFromInt(1337)", } o.SetID("1338") if o.GetID() != "1338" { @@ -58,21 +64,23 @@ func TestSetID(t *testing.T) { } func TestLeverage(t *testing.T) { + t.Parallel() o := Order{ - Leverage: 1, + Leverage: decimal.NewFromInt(1), } - o.SetLeverage(1337) - if o.GetLeverage() != 1337 || !o.IsLeveraged() { + o.SetLeverage(decimal.NewFromInt(1337)) + if !o.GetLeverage().Equal(decimal.NewFromInt(1337)) || !o.IsLeveraged() { t.Error("expected leverage") } } func TestGetFunds(t *testing.T) { + t.Parallel() o := Order{ - Funds: 1337, + AllocatedFunds: decimal.NewFromInt(1337), } - funds := o.GetFunds() - if funds != 1337 { - t.Error("expected 1337") + funds := o.GetAllocatedFunds() + if !funds.Equal(decimal.NewFromInt(1337)) { + t.Error("expected decimal.NewFromInt(1337)") } } diff --git a/backtester/eventtypes/order/order_types.go b/backtester/eventtypes/order/order_types.go index 2e5be9ef..4f7c6aff 100644 --- a/backtester/eventtypes/order/order_types.go +++ b/backtester/eventtypes/order/order_types.go @@ -1,6 +1,7 @@ package order import ( + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/common" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/event" "github.com/thrasher-corp/gocryptotrader/exchanges/order" @@ -9,30 +10,30 @@ import ( // Order contains all details for an order event type Order struct { event.Base - ID string - Direction order.Side - Status order.Status - Price float64 - Amount float64 - OrderType order.Type - Leverage float64 - Funds float64 - BuyLimit float64 - SellLimit float64 + ID string + Direction order.Side + Status order.Status + Price decimal.Decimal + Amount decimal.Decimal + OrderType order.Type + Leverage decimal.Decimal + AllocatedFunds decimal.Decimal + BuyLimit decimal.Decimal + SellLimit decimal.Decimal } // Event inherits common event interfaces along with extra functions related to handling orders type Event interface { common.EventHandler common.Directioner - GetBuyLimit() float64 - GetSellLimit() float64 - SetAmount(float64) - GetAmount() float64 + GetBuyLimit() decimal.Decimal + GetSellLimit() decimal.Decimal + SetAmount(decimal.Decimal) + GetAmount() decimal.Decimal IsOrder() bool GetStatus() order.Status SetID(id string) GetID() string IsLeveraged() bool - GetFunds() float64 + GetAllocatedFunds() decimal.Decimal } diff --git a/backtester/eventtypes/signal/signal.go b/backtester/eventtypes/signal/signal.go index b5d795d0..36703086 100644 --- a/backtester/eventtypes/signal/signal.go +++ b/backtester/eventtypes/signal/signal.go @@ -1,6 +1,7 @@ package signal import ( + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/exchanges/order" ) @@ -21,22 +22,22 @@ func (s *Signal) GetDirection() order.Side { } // SetBuyLimit sets the buy limit -func (s *Signal) SetBuyLimit(f float64) { +func (s *Signal) SetBuyLimit(f decimal.Decimal) { s.BuyLimit = f } // GetBuyLimit returns the buy limit -func (s *Signal) GetBuyLimit() float64 { +func (s *Signal) GetBuyLimit() decimal.Decimal { return s.BuyLimit } // SetSellLimit sets the sell limit -func (s *Signal) SetSellLimit(f float64) { +func (s *Signal) SetSellLimit(f decimal.Decimal) { s.SellLimit = f } // GetSellLimit returns the sell limit -func (s *Signal) GetSellLimit() float64 { +func (s *Signal) GetSellLimit() decimal.Decimal { return s.SellLimit } @@ -46,11 +47,11 @@ func (s *Signal) Pair() currency.Pair { } // GetPrice returns the price -func (s *Signal) GetPrice() float64 { +func (s *Signal) GetPrice() decimal.Decimal { return s.ClosePrice } // SetPrice sets the price -func (s *Signal) SetPrice(f float64) { +func (s *Signal) SetPrice(f decimal.Decimal) { s.ClosePrice = f } diff --git a/backtester/eventtypes/signal/signal_test.go b/backtester/eventtypes/signal/signal_test.go index 0dd3e91a..d6e223eb 100644 --- a/backtester/eventtypes/signal/signal_test.go +++ b/backtester/eventtypes/signal/signal_test.go @@ -3,10 +3,12 @@ package signal import ( "testing" + "github.com/shopspring/decimal" gctorder "github.com/thrasher-corp/gocryptotrader/exchanges/order" ) func TestIsSignal(t *testing.T) { + t.Parallel() s := Signal{} if !s.IsSignal() { t.Error("expected true") @@ -14,6 +16,7 @@ func TestIsSignal(t *testing.T) { } func TestSetDirection(t *testing.T) { + t.Parallel() s := Signal{Direction: gctorder.Sell} s.SetDirection(gctorder.Buy) if s.GetDirection() != gctorder.Buy { @@ -22,31 +25,34 @@ func TestSetDirection(t *testing.T) { } func TestSetPrice(t *testing.T) { + t.Parallel() s := Signal{ - ClosePrice: 1, + ClosePrice: decimal.NewFromInt(1), } - s.SetPrice(1337) - if s.GetPrice() != 1337 { - t.Error("expected 1337") + s.SetPrice(decimal.NewFromInt(1337)) + if !s.GetPrice().Equal(decimal.NewFromInt(1337)) { + t.Error("expected decimal.NewFromInt(1337)") } } func TestSetBuyLimit(t *testing.T) { + t.Parallel() s := Signal{ - BuyLimit: 10, + BuyLimit: decimal.NewFromInt(10), } - s.SetBuyLimit(20) - if s.GetBuyLimit() != 20 { + s.SetBuyLimit(decimal.NewFromInt(20)) + if !s.GetBuyLimit().Equal(decimal.NewFromInt(20)) { t.Errorf("expected 20, received %v", s.GetBuyLimit()) } } func TestSetSellLimit(t *testing.T) { + t.Parallel() s := Signal{ - SellLimit: 10, + SellLimit: decimal.NewFromInt(10), } - s.SetSellLimit(20) - if s.GetSellLimit() != 20 { + s.SetSellLimit(decimal.NewFromInt(20)) + if !s.GetSellLimit().Equal(decimal.NewFromInt(20)) { t.Errorf("expected 20, received %v", s.GetSellLimit()) } } diff --git a/backtester/eventtypes/signal/signal_types.go b/backtester/eventtypes/signal/signal_types.go index 48d6f0a6..a07cd676 100644 --- a/backtester/eventtypes/signal/signal_types.go +++ b/backtester/eventtypes/signal/signal_types.go @@ -1,6 +1,7 @@ package signal import ( + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/common" "github.com/thrasher-corp/gocryptotrader/backtester/eventtypes/event" "github.com/thrasher-corp/gocryptotrader/exchanges/order" @@ -12,21 +13,21 @@ type Event interface { common.EventHandler common.Directioner - GetPrice() float64 + GetPrice() decimal.Decimal IsSignal() bool - GetSellLimit() float64 - GetBuyLimit() float64 + GetSellLimit() decimal.Decimal + GetBuyLimit() decimal.Decimal } // Signal contains everything needed for a strategy to raise a signal event type Signal struct { event.Base - OpenPrice float64 - HighPrice float64 - LowPrice float64 - ClosePrice float64 - Volume float64 - BuyLimit float64 - SellLimit float64 + OpenPrice decimal.Decimal + HighPrice decimal.Decimal + LowPrice decimal.Decimal + ClosePrice decimal.Decimal + Volume decimal.Decimal + BuyLimit decimal.Decimal + SellLimit decimal.Decimal Direction order.Side } diff --git a/backtester/funding/README.md b/backtester/funding/README.md new file mode 100644 index 00000000..cc3eb3ae --- /dev/null +++ b/backtester/funding/README.md @@ -0,0 +1,102 @@ +# GoCryptoTrader Backtester: Funding package + + + + +[![Build Status](https://github.com/thrasher-corp/gocryptotrader/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/thrasher-corp/gocryptotrader/actions/workflows/tests.yml) +[![Software License](https://img.shields.io/badge/License-MIT-orange.svg?style=flat-square)](https://github.com/thrasher-corp/gocryptotrader/blob/master/LICENSE) +[![GoDoc](https://godoc.org/github.com/thrasher-corp/gocryptotrader?status.svg)](https://godoc.org/github.com/thrasher-corp/gocryptotrader/backtester/funding) +[![Coverage Status](http://codecov.io/github/thrasher-corp/gocryptotrader/coverage.svg?branch=master)](http://codecov.io/github/thrasher-corp/gocryptotrader?branch=master) +[![Go Report Card](https://goreportcard.com/badge/github.com/thrasher-corp/gocryptotrader)](https://goreportcard.com/report/github.com/thrasher-corp/gocryptotrader) + + +This funding package is part of the GoCryptoTrader codebase. + +## This is still in active development + +You can track ideas, planned features and what's in progress on this Trello board: [https://trello.com/b/ZAhMhpOy/gocryptotrader](https://trello.com/b/ZAhMhpOy/gocryptotrader). + +Join our slack to discuss all things related to GoCryptoTrader! [GoCryptoTrader Slack](https://join.slack.com/t/gocryptotrader/shared_invite/enQtNTQ5NDAxMjA2Mjc5LTc5ZDE1ZTNiOGM3ZGMyMmY1NTAxYWZhODE0MWM5N2JlZDk1NDU0YTViYzk4NTk3OTRiMDQzNGQ1YTc4YmRlMTk) + +## Funding package overview + +### What does the funding package do? +The funding package is responsible for keeping track of all funds across all events during a backtesting run. It is backwards compatible with all existing backtesting strategies + +### What is the funding manager? +The funding manager is responsible for holding all funding Items and Pairs over the course of a backtesting run. It prevents funds from being overwritten and maintains relationships of currency pairs. + +Consider the following example: Exchange Level Funding is disabled and Simultaneous Processing is disabled, so each currency in the `.strat` config will execute a strategy individually. If the pairs BTC-USDT, BNB-USDT and LTC-BTC are present, then the funding manager will ensure that none of the funds are shared between each of the currencies, even if they all share base or quote values. + +Conversely, Exchange level funding and Simultaneous Processing is enabled, so each currency in the `.strat` file will be processed in one step per time interval. The pairs BTC-USDT, BNB-USDT and BTC-LTC can all share the same base or quote level funds and can make complex decisions on how that funding is used, such as allowing BTC-LTC to make a purchase if an indicator is strongest for that pair. + +### What is a funding Item? +A funding item holds the initial funding, current funding, reserved funding and transfer fees associated with an exchange, asset and currency. If it is a Pair, then the Item will be linked to the paired Item. + +### What is a funding Pair? +A funding Pair consists of two funding Items, the Base and Quote. If Exchange Level Funding is disabled, the Base and Quote are linked to each other and the funds cannot be shared with other Pairs or Items. If Exchange Level Funding is enabled, the pair can access the same funds as every other currency that shares the exchange and asset type. + +### What does Exchange Level Funding mean? +Exchange level funding allows funds to be shared during a backtesting run. If the strategy contains the two pairs BTC-USDT and BNB-USDT and the strategy sells 3 BTC for $100,000 USDT, then BNB-USDT can use that $100,000 USDT to make a purchase of $20,000 BNB. +It is restricted to an exchange and asset type, so BTC used in spot, cannot be used in a futures contract (futures backtesting is not currently supported). However, the funding manager can transfer funds between exchange and asset types. + +Having funding at the exchange level also allows for a finer degree of control while also being more realistic for strategic execution. +A user can create a strategy with many pairs, such as BTC-USDT, LTC-BTC, DOGE-XRP and XRP-USDT, but only creating funding for USDT and still see the purchase of LTC or DOGE. +Another strategy could start with funding in the Base currency. So an RSI strategy for BTC-USDT that has 3 BTC as funding will then start by selling rather than buying. + +#### Why is Simultaneous Processing a prerequisite of Exchange Level Funding? +Simultaneous Processing allows a strategy to process multiple data signals for a single time period to be processed in one step. The reason Simultaneous Processing is required for Exchange Level Funding is that if it is disabled, all events are handled in a sequence. +If any funding was to be shared in such a scenario, the first currency to be processed will always get the choice share of funding. Simultaneous Processing ensures the decision to spend funds for BTC-USDT over BNB-USDT is a measured decision, and not done by the order of currencies in a strategy config. + +### Can I transfer funds from one place to another? +Yes! Though it does use some things to consider. +- It is handled at the strategy execution level, so when creating a strategy, you design the conditions in which funding may be transferred from one place to another. + - For example, if an indicator is very strong on one exchange, but not another, you may wish to transfer funds to the strongest exchange to act upon +- It comes with the assumption that a transfer is actually possible in the candle timeframe your strategy runs on. + - For example, a 1 minute candle strategy likely would not be able to process a transfer of funds and have another exchange use it in that timeframe. So any positive results from such a strategy may not be reflected in real-world scenarios +- You can only transfer to the same currency eg BTC from Binance to FTX, no conversions +- You set the transfer fee in your config + +### Do I need to add funding settings to my config if Exchange Level Funding is disabled? +No. The already existing `CurrencySettings` will populate the funding manager with initial funds if Exchange Level Funding is disabled. + +#### Strategy Settings + +| Key | Description | Example | +| --- | ------- | --- | +| Name | The strategy to use | `rsi` | +| UsesSimultaneousProcessing | This denotes whether multiple currencies are processed simultaneously with the strategy function `OnSimultaneousSignals`. Eg If you have multiple CurrencySettings and only wish to purchase BTC-USDT when XRP-DOGE is 1337, this setting is useful as you can analyse both signal events to output a purchase call for BTC | `true` | +| CustomSettings | This is a map where you can enter custom settings for a strategy. The RSI strategy allows for customisation of the upper, lower and length variables to allow you to change them from 70, 30 and 14 respectively to 69, 36, 12 | `"custom-settings": { "rsi-high": 70, "rsi-low": 30, "rsi-period": 14 } ` | +| UseExchangeLevelFunding | This allows shared exchange funds to be used in your strategy. Requires `UsesSimultaneousProcessing` to be set to `true` to use | `false` | +| ExchangeLevelFunding | This is a list of funding definitions if `UseExchangeLevelFunding` is set to true | See below table | + +#### Funding Config Settings + +| Key | Description | Example | +| --- | ------- | ----- | +| ExchangeName | The exchange to set funds. See [here](https://github.com/thrasher-corp/gocryptotrader/blob/master/README.md) for a list of supported exchanges | `Binance` | +| Asset | The asset type to set funds. Typically, this will be `spot`, however, see [this package](https://github.com/thrasher-corp/gocryptotrader/blob/master/exchanges/asset/asset.go) for the various asset types GoCryptoTrader supports| `spot` | +| Currency | The currency to set funds | `BTC` | +| InitialFunds | The initial funding for the currency | `1337` | +| TransferFee | If your strategy utilises transferring of funds via the Funding Manager, this is deducted upon doing so | `0.005` | + +### Please click GoDocs chevron above to view current GoDoc information for this package + +## Contribution + +Please feel free to submit any pull requests or suggest any desired features to be added. + +When submitting a PR, please abide by our coding guidelines: + ++ Code must adhere to the official Go [formatting](https://golang.org/doc/effective_go.html#formatting) guidelines (i.e. uses [gofmt](https://golang.org/cmd/gofmt/)). ++ Code must be documented adhering to the official Go [commentary](https://golang.org/doc/effective_go.html#commentary) guidelines. ++ Code must adhere to our [coding style](https://github.com/thrasher-corp/gocryptotrader/blob/master/doc/coding_style.md). ++ Pull requests need to be based on and opened against the `master` branch. + +## Donations + + + +If this framework helped you in any way, or you would like to support the developers working on it, please donate Bitcoin to: + +***bc1qk0jareu4jytc0cfrhr5wgshsq8282awpavfahc*** diff --git a/backtester/funding/funding.go b/backtester/funding/funding.go new file mode 100644 index 00000000..1b6c0c2e --- /dev/null +++ b/backtester/funding/funding.go @@ -0,0 +1,467 @@ +package funding + +import ( + "errors" + "fmt" + "strings" + "time" + + "github.com/shopspring/decimal" + "github.com/thrasher-corp/gocryptotrader/backtester/common" + "github.com/thrasher-corp/gocryptotrader/currency" + fbase "github.com/thrasher-corp/gocryptotrader/currency/forexprovider/base" + exchangeratehost "github.com/thrasher-corp/gocryptotrader/currency/forexprovider/exchangerate.host" + "github.com/thrasher-corp/gocryptotrader/exchanges/asset" + "github.com/thrasher-corp/gocryptotrader/exchanges/order" + "github.com/thrasher-corp/gocryptotrader/log" +) + +var ( + // ErrFundsNotFound used when funds are requested but the funding is not found in the manager + ErrFundsNotFound = errors.New("funding not found") + // ErrAlreadyExists used when a matching item or pair is already in the funding manager + ErrAlreadyExists = errors.New("funding already exists") + errCannotAllocate = errors.New("cannot allocate funds") + errZeroAmountReceived = errors.New("amount received less than or equal to zero") + errNegativeAmountReceived = errors.New("received negative decimal") + errNotEnoughFunds = errors.New("not enough funds") + errCannotTransferToSameFunds = errors.New("cannot send funds to self") + errTransferMustBeSameCurrency = errors.New("cannot transfer to different currency") +) + +// SetupFundingManager creates the funding holder. It carries knowledge about levels of funding +// across all execution handlers and enables fund transfers +func SetupFundingManager(usingExchangeLevelFunding bool) *FundManager { + return &FundManager{usingExchangeLevelFunding: usingExchangeLevelFunding} +} + +// CreateItem creates a new funding item +func CreateItem(exch string, a asset.Item, ci currency.Code, initialFunds, transferFee decimal.Decimal) (*Item, error) { + if initialFunds.IsNegative() { + return nil, fmt.Errorf("%v %v %v %w initial funds: %v", exch, a, ci, errNegativeAmountReceived, initialFunds) + } + if transferFee.IsNegative() { + return nil, fmt.Errorf("%v %v %v %w transfer fee: %v", exch, a, ci, errNegativeAmountReceived, transferFee) + } + + return &Item{ + exchange: exch, + asset: a, + currency: ci, + initialFunds: initialFunds, + available: initialFunds, + transferFee: transferFee, + }, nil +} + +// CreatePair adds two funding items and associates them with one another +// the association allows for the same currency to be used multiple times when +// usingExchangeLevelFunding is false. eg BTC-USDT and LTC-USDT do not share the same +// USDT level funding +func CreatePair(base, quote *Item) (*Pair, error) { + if base == nil { + return nil, fmt.Errorf("base %w", common.ErrNilArguments) + } + if quote == nil { + return nil, fmt.Errorf("quote %w", common.ErrNilArguments) + } + // copy to prevent the off chance of sending in the same base OR quote + // to create a new pair with a new base OR quote + bCopy := *base + qCopy := *quote + bCopy.pairedWith = &qCopy + qCopy.pairedWith = &bCopy + return &Pair{Base: &bCopy, Quote: &qCopy}, nil +} + +// Reset clears all settings +func (f *FundManager) Reset() { + *f = FundManager{} +} + +// GenerateReport builds report data for result HTML report +func (f *FundManager) GenerateReport(startDate, endDate time.Time) *Report { + report := &Report{} + var items []ReportItem + var erh exchangeratehost.ExchangeRateHost + var skipAPICheck bool + err := erh.Setup(fbase.Settings{Enabled: true}) + if err != nil { + log.Errorf(log.CommunicationMgr, "issue setting up exchangerate.host API %v", err) + skipAPICheck = true + } + for i := range f.items { + // exact conversion not required for initial version + fInitialFunds, _ := f.items[i].initialFunds.Float64() + fFinalFunds, _ := f.items[i].available.Float64() + var initialWorthDecimal, finalWorthDecimal decimal.Decimal + if !skipAPICheck { + // calculating totals for shared funding across multiple currency pairs is difficult + // converting totals using a free API is better suited as an initial concept + // TODO convert currencies without external dependency + if strings.Contains(f.items[i].currency.String(), "USD") { + // not worth converting + initialWorthDecimal = f.items[i].initialFunds + finalWorthDecimal = f.items[i].available + } else { + from := f.items[i].currency.String() + to := "USD" + if from == "BTC" { + // api has conversion difficulties for BTC to USD only + to = "BUSD" + } + if fInitialFunds > 0 { + initialWorth, err := erh.ConvertCurrency(from, to, "", "", "crypto", startDate, fInitialFunds, 0) + if err != nil { + log.Errorf(log.CommunicationMgr, "issue converting %v to %v at %v on exchangerate.host API %v", from, to, startDate, err) + } else { + initialWorthDecimal = decimal.NewFromFloat(initialWorth.Result) + } + } + if fFinalFunds > 0 { + finalWorth, err := erh.ConvertCurrency(from, to, "", "", "crypto", endDate, fFinalFunds, 0) + if err != nil { + log.Errorf(log.CommunicationMgr, "issue converting %v to %v at %v on exchangerate.host API %v", from, to, endDate, err) + } else { + finalWorthDecimal = decimal.NewFromFloat(finalWorth.Result) + } + } + } + } + item := ReportItem{ + Exchange: f.items[i].exchange, + Asset: f.items[i].asset, + Currency: f.items[i].currency, + InitialFunds: f.items[i].initialFunds, + InitialFundsUSD: initialWorthDecimal.Round(2), + TransferFee: f.items[i].transferFee, + FinalFunds: f.items[i].available, + FinalFundsUSD: finalWorthDecimal.Round(2), + } + + if f.items[i].initialFunds.IsZero() { + item.ShowInfinite = true + } else { + item.Difference = f.items[i].available.Sub(f.items[i].initialFunds).Div(f.items[i].initialFunds).Mul(decimal.NewFromInt(100)) + } + if f.items[i].pairedWith != nil { + item.PairedWith = f.items[i].pairedWith.currency + } + report.InitialTotalUSD = report.InitialTotalUSD.Add(initialWorthDecimal).Round(2) + report.FinalTotalUSD = report.FinalTotalUSD.Add(finalWorthDecimal).Round(2) + items = append(items, item) + } + if !report.InitialTotalUSD.IsZero() { + report.Difference = report.FinalTotalUSD.Sub(report.InitialTotalUSD).Div(report.InitialTotalUSD).Mul(decimal.NewFromInt(100)) + } + report.Items = items + return report +} + +// Transfer allows transferring funds from one pretend exchange to another +func (f *FundManager) Transfer(amount decimal.Decimal, sender, receiver *Item, inclusiveFee bool) error { + if sender == nil || receiver == nil { + return common.ErrNilArguments + } + if amount.LessThanOrEqual(decimal.Zero) { + return errZeroAmountReceived + } + if inclusiveFee { + if sender.available.LessThan(amount) { + return fmt.Errorf("%w for %v", errNotEnoughFunds, sender.currency) + } + } else { + if sender.available.LessThan(amount.Add(sender.transferFee)) { + return fmt.Errorf("%w for %v", errNotEnoughFunds, sender.currency) + } + } + + if sender.currency != receiver.currency { + return errTransferMustBeSameCurrency + } + if sender.currency == receiver.currency && + sender.exchange == receiver.exchange && + sender.asset == receiver.asset { + return fmt.Errorf("%v %v %v %w", sender.exchange, sender.asset, sender.currency, errCannotTransferToSameFunds) + } + + sendAmount := amount + receiveAmount := amount + if inclusiveFee { + receiveAmount = amount.Sub(sender.transferFee) + } else { + sendAmount = amount.Add(sender.transferFee) + } + err := sender.Reserve(sendAmount) + if err != nil { + return err + } + receiver.IncreaseAvailable(receiveAmount) + return sender.Release(sendAmount, decimal.Zero) +} + +// AddItem appends a new funding item. Will reject if exists by exchange asset currency +func (f *FundManager) AddItem(item *Item) error { + if f.Exists(item) { + return fmt.Errorf("cannot add item %v %v %v %w", item.exchange, item.asset, item.currency, ErrAlreadyExists) + } + f.items = append(f.items, item) + return nil +} + +// Exists verifies whether there is a funding item that exists +// with the same exchange, asset and currency +func (f *FundManager) Exists(item *Item) bool { + for i := range f.items { + if f.items[i].Equal(item) { + return true + } + } + return false +} + +// AddPair adds a pair to the fund manager if it does not exist +func (f *FundManager) AddPair(p *Pair) error { + if f.Exists(p.Base) { + return fmt.Errorf("%w %v", ErrAlreadyExists, p.Base) + } + if f.Exists(p.Quote) { + return fmt.Errorf("%w %v", ErrAlreadyExists, p.Quote) + } + f.items = append(f.items, p.Base, p.Quote) + return nil +} + +// IsUsingExchangeLevelFunding returns if using usingExchangeLevelFunding +func (f *FundManager) IsUsingExchangeLevelFunding() bool { + return f.usingExchangeLevelFunding +} + +// GetFundingForEvent This will construct a funding based on a backtesting event +func (f *FundManager) GetFundingForEvent(ev common.EventHandler) (*Pair, error) { + return f.GetFundingForEAP(ev.GetExchange(), ev.GetAssetType(), ev.Pair()) +} + +// GetFundingForEAC This will construct a funding based on the exchange, asset, currency code +func (f *FundManager) GetFundingForEAC(exch string, a asset.Item, c currency.Code) (*Item, error) { + for i := range f.items { + if f.items[i].BasicEqual(exch, a, c, currency.Code{}) { + return f.items[i], nil + } + } + return nil, ErrFundsNotFound +} + +// GetFundingForEAP This will construct a funding based on the exchange, asset, currency pair +func (f *FundManager) GetFundingForEAP(exch string, a asset.Item, p currency.Pair) (*Pair, error) { + var resp Pair + for i := range f.items { + if f.items[i].BasicEqual(exch, a, p.Base, p.Quote) { + resp.Base = f.items[i] + continue + } + if f.items[i].BasicEqual(exch, a, p.Quote, p.Base) { + resp.Quote = f.items[i] + } + } + if resp.Base == nil { + return nil, fmt.Errorf("base %w", ErrFundsNotFound) + } + if resp.Quote == nil { + return nil, fmt.Errorf("quote %w", ErrFundsNotFound) + } + return &resp, nil +} + +// BaseInitialFunds returns the initial funds +// from the base in a currency pair +func (p *Pair) BaseInitialFunds() decimal.Decimal { + return p.Base.initialFunds +} + +// QuoteInitialFunds returns the initial funds +// from the quote in a currency pair +func (p *Pair) QuoteInitialFunds() decimal.Decimal { + return p.Quote.initialFunds +} + +// BaseAvailable returns the available funds +// from the base in a currency pair +func (p *Pair) BaseAvailable() decimal.Decimal { + return p.Base.available +} + +// QuoteAvailable returns the available funds +// from the quote in a currency pair +func (p *Pair) QuoteAvailable() decimal.Decimal { + return p.Quote.available +} + +// Reserve allocates an amount of funds to be used at a later time +// it prevents multiple events from claiming the same resource +// changes which currency to affect based on the order side +func (p *Pair) Reserve(amount decimal.Decimal, side order.Side) error { + switch side { + case order.Buy: + return p.Quote.Reserve(amount) + case order.Sell: + return p.Base.Reserve(amount) + default: + return fmt.Errorf("%w for %v %v %v. Unknown side %v", + errCannotAllocate, + p.Base.exchange, + p.Base.asset, + p.Base.currency, + side) + } +} + +// Release reduces the amount of funding reserved and adds any difference +// back to the available amount +// changes which currency to affect based on the order side +func (p *Pair) Release(amount, diff decimal.Decimal, side order.Side) error { + switch side { + case order.Buy: + return p.Quote.Release(amount, diff) + case order.Sell: + return p.Base.Release(amount, diff) + default: + return fmt.Errorf("%w for %v %v %v. Unknown side %v", + errCannotAllocate, + p.Base.exchange, + p.Base.asset, + p.Base.currency, + side) + } +} + +// IncreaseAvailable adds funding to the available amount +// changes which currency to affect based on the order side +func (p *Pair) IncreaseAvailable(amount decimal.Decimal, side order.Side) { + switch side { + case order.Buy: + p.Base.IncreaseAvailable(amount) + case order.Sell: + p.Quote.IncreaseAvailable(amount) + } +} + +// CanPlaceOrder does a > 0 check to see if there are any funds +// to place an order with +// changes which currency to affect based on the order side +func (p *Pair) CanPlaceOrder(side order.Side) bool { + switch side { + case order.Buy: + return p.Quote.CanPlaceOrder() + case order.Sell: + return p.Base.CanPlaceOrder() + } + return false +} + +// Reserve allocates an amount of funds to be used at a later time +// it prevents multiple events from claiming the same resource +func (i *Item) Reserve(amount decimal.Decimal) error { + if amount.LessThanOrEqual(decimal.Zero) { + return errZeroAmountReceived + } + if amount.GreaterThan(i.available) { + return fmt.Errorf("%w for %v %v %v. Requested %v Available: %v", + errCannotAllocate, + i.exchange, + i.asset, + i.currency, + amount, + i.available) + } + i.available = i.available.Sub(amount) + i.reserved = i.reserved.Add(amount) + return nil +} + +// Release reduces the amount of funding reserved and adds any difference +// back to the available amount +func (i *Item) Release(amount, diff decimal.Decimal) error { + if amount.LessThanOrEqual(decimal.Zero) { + return errZeroAmountReceived + } + if diff.IsNegative() { + return fmt.Errorf("%w diff", errNegativeAmountReceived) + } + if amount.GreaterThan(i.reserved) { + return fmt.Errorf("%w for %v %v %v. Requested %v Reserved: %v", + errCannotAllocate, + i.exchange, + i.asset, + i.currency, + amount, + i.reserved) + } + i.reserved = i.reserved.Sub(amount) + i.available = i.available.Add(diff) + return nil +} + +// IncreaseAvailable adds funding to the available amount +func (i *Item) IncreaseAvailable(amount decimal.Decimal) { + if amount.IsNegative() || amount.IsZero() { + return + } + i.available = i.available.Add(amount) +} + +// CanPlaceOrder checks if the item has any funds available +func (i *Item) CanPlaceOrder() bool { + return i.available.GreaterThan(decimal.Zero) +} + +// Equal checks for equality via an Item to compare to +func (i *Item) Equal(item *Item) bool { + if i == nil && item == nil { + return true + } + if item == nil || i == nil { + return false + } + if i.currency == item.currency && + i.asset == item.asset && + i.exchange == item.exchange { + if i.pairedWith == nil && item.pairedWith == nil { + return true + } + if i.pairedWith == nil || item.pairedWith == nil { + return false + } + if i.pairedWith.currency == item.pairedWith.currency && + i.pairedWith.asset == item.pairedWith.asset && + i.pairedWith.exchange == item.pairedWith.exchange { + return true + } + } + return false +} + +// BasicEqual checks for equality via passed in values +func (i *Item) BasicEqual(exch string, a asset.Item, currency, pairedCurrency currency.Code) bool { + return i != nil && + i.exchange == exch && + i.asset == a && + i.currency == currency && + (i.pairedWith == nil || + (i.pairedWith != nil && i.pairedWith.currency == pairedCurrency)) +} + +// MatchesCurrency checks that an item's currency is equal +func (i *Item) MatchesCurrency(c currency.Code) bool { + return i != nil && i.currency == c +} + +// MatchesItemCurrency checks that an item's currency is equal +func (i *Item) MatchesItemCurrency(item *Item) bool { + return i != nil && item != nil && i.currency == item.currency +} + +// MatchesExchange checks that an item's exchange is equal +func (i *Item) MatchesExchange(item *Item) bool { + return i != nil && item != nil && i.exchange == item.exchange +} diff --git a/backtester/funding/funding_test.go b/backtester/funding/funding_test.go new file mode 100644 index 00000000..4ee6bb03 --- /dev/null +++ b/backtester/funding/funding_test.go @@ -0,0 +1,821 @@ +package funding + +import ( + "errors" + "testing" + "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" + gctkline "github.com/thrasher-corp/gocryptotrader/exchanges/kline" + gctorder "github.com/thrasher-corp/gocryptotrader/exchanges/order" +) + +var ( + elite = decimal.NewFromInt(1337) + neg = decimal.NewFromInt(-1) + one = decimal.NewFromInt(1) + exch = "exch" + a = asset.Spot + base = currency.DOGE + quote = currency.XRP + pair = currency.NewPair(base, quote) +) + +func TestSetupFundingManager(t *testing.T) { + t.Parallel() + f := SetupFundingManager(true) + if !f.usingExchangeLevelFunding { + t.Errorf("expected '%v received '%v'", true, false) + } + f = SetupFundingManager(false) + if f.usingExchangeLevelFunding { + t.Errorf("expected '%v received '%v'", false, true) + } +} + +func TestReset(t *testing.T) { + t.Parallel() + f := SetupFundingManager(true) + baseItem, err := CreateItem(exch, a, base, decimal.Zero, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + err = f.AddItem(baseItem) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + f.Reset() + if f.usingExchangeLevelFunding { + t.Errorf("expected '%v received '%v'", false, true) + } + if f.Exists(baseItem) { + t.Errorf("expected '%v received '%v'", false, true) + } +} + +func TestIsUsingExchangeLevelFunding(t *testing.T) { + t.Parallel() + f := SetupFundingManager(true) + if !f.IsUsingExchangeLevelFunding() { + t.Errorf("expected '%v received '%v'", true, false) + } +} + +func TestTransfer(t *testing.T) { + t.Parallel() + f := FundManager{ + usingExchangeLevelFunding: false, + items: nil, + } + err := f.Transfer(decimal.Zero, nil, nil, false) + if !errors.Is(err, common.ErrNilArguments) { + t.Errorf("received '%v' expected '%v'", err, common.ErrNilArguments) + } + err = f.Transfer(decimal.Zero, &Item{}, nil, false) + if !errors.Is(err, common.ErrNilArguments) { + t.Errorf("received '%v' expected '%v'", err, common.ErrNilArguments) + } + err = f.Transfer(decimal.Zero, &Item{}, &Item{}, false) + if !errors.Is(err, errZeroAmountReceived) { + t.Errorf("received '%v' expected '%v'", err, errZeroAmountReceived) + } + err = f.Transfer(elite, &Item{}, &Item{}, false) + if !errors.Is(err, errNotEnoughFunds) { + t.Errorf("received '%v' expected '%v'", err, errNotEnoughFunds) + } + item1 := &Item{exchange: "hello", asset: a, currency: base, available: elite} + err = f.Transfer(elite, item1, item1, false) + if !errors.Is(err, errCannotTransferToSameFunds) { + t.Errorf("received '%v' expected '%v'", err, errCannotTransferToSameFunds) + } + + item2 := &Item{exchange: "hello", asset: a, currency: quote} + err = f.Transfer(elite, item1, item2, false) + if !errors.Is(err, errTransferMustBeSameCurrency) { + t.Errorf("received '%v' expected '%v'", err, errTransferMustBeSameCurrency) + } + + item2.exchange = "moto" + item2.currency = base + err = f.Transfer(elite, item1, item2, false) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + if !item2.available.Equal(elite) { + t.Errorf("received '%v' expected '%v'", item2.available, elite) + } + if !item1.available.Equal(decimal.Zero) { + t.Errorf("received '%v' expected '%v'", item1.available, decimal.Zero) + } + + item2.transferFee = one + err = f.Transfer(elite, item2, item1, true) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + if !item1.available.Equal(elite.Sub(item2.transferFee)) { + t.Errorf("received '%v' expected '%v'", item2.available, elite.Sub(item2.transferFee)) + } +} + +func TestAddItem(t *testing.T) { + t.Parallel() + f := FundManager{} + err := f.AddItem(nil) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + + baseItem, err := CreateItem(exch, a, base, decimal.Zero, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + err = f.AddItem(baseItem) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + + err = f.AddItem(baseItem) + if !errors.Is(err, ErrAlreadyExists) { + t.Errorf("received '%v' expected '%v'", err, ErrAlreadyExists) + } +} + +func TestExists(t *testing.T) { + t.Parallel() + f := FundManager{} + exists := f.Exists(nil) + if exists { + t.Errorf("received '%v' expected '%v'", exists, false) + } + conflictingSingleItem, err := CreateItem(exch, a, base, decimal.Zero, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + err = f.AddItem(conflictingSingleItem) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + exists = f.Exists(conflictingSingleItem) + if !exists { + t.Errorf("received '%v' expected '%v'", exists, true) + } + baseItem, err := CreateItem(exch, a, base, decimal.Zero, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + quoteItem, err := CreateItem(exch, a, quote, elite, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + p, err := CreatePair(baseItem, quoteItem) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + err = f.AddPair(p) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + pairItems, err := f.GetFundingForEAP(exch, a, pair) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + exists = f.Exists(pairItems.Base) + if !exists { + t.Errorf("received '%v' expected '%v'", exists, true) + } + exists = f.Exists(pairItems.Quote) + if !exists { + t.Errorf("received '%v' expected '%v'", exists, true) + } + + funds, err := f.GetFundingForEAP(exch, a, pair) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + // demonstration that you don't need the original *Items + // to check for existence, just matching fields + baseCopy := *funds.Base + quoteCopy := *funds.Quote + quoteCopy.pairedWith = &baseCopy + exists = f.Exists(&baseCopy) + if !exists { + t.Errorf("received '%v' expected '%v'", exists, true) + } + + currFunds, err := f.GetFundingForEAC(exch, a, base) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + if currFunds.pairedWith != nil { + t.Errorf("received '%v' expected '%v'", nil, currFunds.pairedWith) + } +} + +func TestAddPair(t *testing.T) { + t.Parallel() + f := FundManager{} + baseItem, err := CreateItem(exch, a, pair.Base, decimal.Zero, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + quoteItem, err := CreateItem(exch, a, pair.Quote, elite, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + p, err := CreatePair(baseItem, quoteItem) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + err = f.AddPair(p) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + resp, err := f.GetFundingForEAP(exch, a, pair) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + if resp.Base.exchange != exch || + resp.Base.asset != a || + resp.Base.currency != pair.Base { + t.Error("woah nelly") + } + if resp.Quote.exchange != exch || + resp.Quote.asset != a || + resp.Quote.currency != pair.Quote { + t.Error("woah nelly") + } + if resp.Quote.pairedWith != resp.Base { + t.Errorf("received '%v' expected '%v'", resp.Base, resp.Quote.pairedWith) + } + if resp.Base.pairedWith != resp.Quote { + t.Errorf("received '%v' expected '%v'", resp.Quote, resp.Base.pairedWith) + } + if !resp.Base.initialFunds.Equal(decimal.Zero) { + t.Errorf("received '%v' expected '%v'", resp.Base.initialFunds, decimal.Zero) + } + if !resp.Quote.initialFunds.Equal(elite) { + t.Errorf("received '%v' expected '%v'", resp.Quote.initialFunds, elite) + } + + p, err = CreatePair(baseItem, quoteItem) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + err = f.AddPair(p) + if !errors.Is(err, ErrAlreadyExists) { + t.Errorf("received '%v' expected '%v'", err, ErrAlreadyExists) + } +} + +// fakeEvent implements common.EventHandler without +// caring about the response, or dealing with import cycles +type fakeEvent struct{} + +func (f *fakeEvent) GetOffset() int64 { return 0 } +func (f *fakeEvent) SetOffset(int64) {} +func (f *fakeEvent) IsEvent() bool { return true } +func (f *fakeEvent) GetTime() time.Time { return time.Now() } +func (f *fakeEvent) Pair() currency.Pair { return pair } +func (f *fakeEvent) GetExchange() string { return exch } +func (f *fakeEvent) GetInterval() gctkline.Interval { return gctkline.OneMin } +func (f *fakeEvent) GetAssetType() asset.Item { return asset.Spot } +func (f *fakeEvent) GetReason() string { return "" } +func (f *fakeEvent) AppendReason(string) {} + +func TestGetFundingForEvent(t *testing.T) { + t.Parallel() + e := &fakeEvent{} + f := FundManager{} + _, err := f.GetFundingForEvent(e) + if !errors.Is(err, ErrFundsNotFound) { + t.Errorf("received '%v' expected '%v'", err, ErrFundsNotFound) + } + baseItem, err := CreateItem(exch, a, pair.Base, decimal.Zero, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + quoteItem, err := CreateItem(exch, a, pair.Quote, elite, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + p, err := CreatePair(baseItem, quoteItem) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + err = f.AddPair(p) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + _, err = f.GetFundingForEvent(e) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } +} + +func TestGetFundingForEAC(t *testing.T) { + t.Parallel() + f := FundManager{} + _, err := f.GetFundingForEAC(exch, a, base) + if !errors.Is(err, ErrFundsNotFound) { + t.Errorf("received '%v' expected '%v'", err, ErrFundsNotFound) + } + baseItem, err := CreateItem(exch, a, pair.Base, decimal.Zero, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + err = f.AddItem(baseItem) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + + fundo, err := f.GetFundingForEAC(exch, a, base) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + + if !baseItem.Equal(fundo) { + t.Errorf("received '%v' expected '%v'", baseItem, fundo) + } +} + +func TestGetFundingForEAP(t *testing.T) { + t.Parallel() + f := FundManager{} + _, err := f.GetFundingForEAP(exch, a, pair) + if !errors.Is(err, ErrFundsNotFound) { + t.Errorf("received '%v' expected '%v'", err, ErrFundsNotFound) + } + baseItem, err := CreateItem(exch, a, pair.Base, decimal.Zero, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + quoteItem, err := CreateItem(exch, a, pair.Quote, elite, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + p, err := CreatePair(baseItem, quoteItem) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + err = f.AddPair(p) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + _, err = f.GetFundingForEAP(exch, a, pair) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + + _, err = CreatePair(baseItem, nil) + if !errors.Is(err, common.ErrNilArguments) { + t.Errorf("received '%v' expected '%v'", err, common.ErrNilArguments) + } + _, err = CreatePair(nil, quoteItem) + if !errors.Is(err, common.ErrNilArguments) { + t.Errorf("received '%v' expected '%v'", err, common.ErrNilArguments) + } + p, err = CreatePair(baseItem, quoteItem) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + err = f.AddPair(p) + if !errors.Is(err, ErrAlreadyExists) { + t.Errorf("received '%v' expected '%v'", err, ErrAlreadyExists) + } +} + +func TestBaseInitialFunds(t *testing.T) { + t.Parallel() + baseItem, err := CreateItem(exch, a, pair.Base, decimal.Zero, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + quoteItem, err := CreateItem(exch, a, pair.Quote, elite, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + baseItem.pairedWith = quoteItem + quoteItem.pairedWith = baseItem + pairItems := Pair{Base: baseItem, Quote: quoteItem} + funds := pairItems.BaseInitialFunds() + if !funds.IsZero() { + t.Errorf("received '%v' expected '%v'", funds, baseItem.available) + } +} + +func TestQuoteInitialFunds(t *testing.T) { + t.Parallel() + baseItem, err := CreateItem(exch, a, pair.Base, decimal.Zero, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + quoteItem, err := CreateItem(exch, a, pair.Quote, elite, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + baseItem.pairedWith = quoteItem + quoteItem.pairedWith = baseItem + pairItems := Pair{Base: baseItem, Quote: quoteItem} + funds := pairItems.QuoteInitialFunds() + if !funds.Equal(elite) { + t.Errorf("received '%v' expected '%v'", funds, elite) + } +} + +func TestBaseAvailable(t *testing.T) { + t.Parallel() + baseItem, err := CreateItem(exch, a, pair.Base, decimal.Zero, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + quoteItem, err := CreateItem(exch, a, pair.Quote, elite, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + baseItem.pairedWith = quoteItem + quoteItem.pairedWith = baseItem + pairItems := Pair{Base: baseItem, Quote: quoteItem} + funds := pairItems.BaseAvailable() + if !funds.IsZero() { + t.Errorf("received '%v' expected '%v'", funds, baseItem.available) + } +} + +func TestQuoteAvailable(t *testing.T) { + t.Parallel() + baseItem, err := CreateItem(exch, a, pair.Base, decimal.Zero, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + quoteItem, err := CreateItem(exch, a, pair.Quote, elite, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + baseItem.pairedWith = quoteItem + quoteItem.pairedWith = baseItem + pairItems := Pair{Base: baseItem, Quote: quoteItem} + funds := pairItems.QuoteAvailable() + if !funds.Equal(elite) { + t.Errorf("received '%v' expected '%v'", funds, elite) + } +} + +func TestReservePair(t *testing.T) { + t.Parallel() + baseItem, err := CreateItem(exch, a, pair.Base, decimal.Zero, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + quoteItem, err := CreateItem(exch, a, pair.Quote, elite, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + baseItem.pairedWith = quoteItem + quoteItem.pairedWith = baseItem + pairItems := Pair{Base: baseItem, Quote: quoteItem} + err = pairItems.Reserve(decimal.Zero, gctorder.Buy) + if !errors.Is(err, errZeroAmountReceived) { + t.Errorf("received '%v' expected '%v'", err, errZeroAmountReceived) + } + err = pairItems.Reserve(elite, gctorder.Buy) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + err = pairItems.Reserve(decimal.Zero, gctorder.Sell) + if !errors.Is(err, errZeroAmountReceived) { + t.Errorf("received '%v' expected '%v'", err, errZeroAmountReceived) + } + err = pairItems.Reserve(elite, gctorder.Sell) + if !errors.Is(err, errCannotAllocate) { + t.Errorf("received '%v' expected '%v'", err, errCannotAllocate) + } + err = pairItems.Reserve(elite, common.DoNothing) + if !errors.Is(err, errCannotAllocate) { + t.Errorf("received '%v' expected '%v'", err, errCannotAllocate) + } +} + +func TestReleasePair(t *testing.T) { + t.Parallel() + baseItem, err := CreateItem(exch, a, pair.Base, decimal.Zero, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + quoteItem, err := CreateItem(exch, a, pair.Quote, elite, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + baseItem.pairedWith = quoteItem + quoteItem.pairedWith = baseItem + pairItems := Pair{Base: baseItem, Quote: quoteItem} + err = pairItems.Reserve(decimal.Zero, gctorder.Buy) + if !errors.Is(err, errZeroAmountReceived) { + t.Errorf("received '%v' expected '%v'", err, errZeroAmountReceived) + } + err = pairItems.Reserve(elite, gctorder.Buy) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + err = pairItems.Reserve(decimal.Zero, gctorder.Sell) + if !errors.Is(err, errZeroAmountReceived) { + t.Errorf("received '%v' expected '%v'", err, errZeroAmountReceived) + } + err = pairItems.Reserve(elite, gctorder.Sell) + if !errors.Is(err, errCannotAllocate) { + t.Errorf("received '%v' expected '%v'", err, errCannotAllocate) + } + + err = pairItems.Release(decimal.Zero, decimal.Zero, gctorder.Buy) + if !errors.Is(err, errZeroAmountReceived) { + t.Errorf("received '%v' expected '%v'", err, errZeroAmountReceived) + } + err = pairItems.Release(elite, decimal.Zero, gctorder.Buy) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + err = pairItems.Release(elite, decimal.Zero, gctorder.Buy) + if !errors.Is(err, errCannotAllocate) { + t.Errorf("received '%v' expected '%v'", err, errCannotAllocate) + } + + err = pairItems.Release(elite, decimal.Zero, common.DoNothing) + if !errors.Is(err, errCannotAllocate) { + t.Errorf("received '%v' expected '%v'", err, errCannotAllocate) + } + + err = pairItems.Release(elite, decimal.Zero, gctorder.Sell) + if !errors.Is(err, errCannotAllocate) { + t.Errorf("received '%v' expected '%v'", err, errCannotAllocate) + } + err = pairItems.Release(decimal.Zero, decimal.Zero, gctorder.Sell) + if !errors.Is(err, errZeroAmountReceived) { + t.Errorf("received '%v' expected '%v'", err, errZeroAmountReceived) + } +} + +func TestIncreaseAvailablePair(t *testing.T) { + t.Parallel() + baseItem, err := CreateItem(exch, a, pair.Base, decimal.Zero, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + quoteItem, err := CreateItem(exch, a, pair.Quote, elite, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + baseItem.pairedWith = quoteItem + quoteItem.pairedWith = baseItem + pairItems := Pair{Base: baseItem, Quote: quoteItem} + pairItems.IncreaseAvailable(decimal.Zero, gctorder.Buy) + if !pairItems.Quote.available.Equal(elite) { + t.Errorf("received '%v' expected '%v'", elite, pairItems.Quote.available) + } + pairItems.IncreaseAvailable(decimal.Zero, gctorder.Sell) + if !pairItems.Base.available.Equal(decimal.Zero) { + t.Errorf("received '%v' expected '%v'", decimal.Zero, pairItems.Base.available) + } + + pairItems.IncreaseAvailable(elite.Neg(), gctorder.Sell) + if !pairItems.Quote.available.Equal(elite) { + t.Errorf("received '%v' expected '%v'", elite, pairItems.Quote.available) + } + pairItems.IncreaseAvailable(elite, gctorder.Buy) + if !pairItems.Base.available.Equal(elite) { + t.Errorf("received '%v' expected '%v'", elite, pairItems.Base.available) + } + + pairItems.IncreaseAvailable(elite, common.DoNothing) + if !pairItems.Base.available.Equal(elite) { + t.Errorf("received '%v' expected '%v'", elite, pairItems.Base.available) + } +} + +func TestCanPlaceOrderPair(t *testing.T) { + t.Parallel() + p := Pair{ + Base: &Item{}, + Quote: &Item{}, + } + if p.CanPlaceOrder(common.DoNothing) { + t.Error("expected false") + } + if p.CanPlaceOrder(gctorder.Buy) { + t.Error("expected false") + } + if p.CanPlaceOrder(gctorder.Sell) { + t.Error("expected false") + } + + p.Quote.available = decimal.NewFromInt(32) + if !p.CanPlaceOrder(gctorder.Buy) { + t.Error("expected true") + } + p.Base.available = decimal.NewFromInt(32) + if !p.CanPlaceOrder(gctorder.Sell) { + t.Error("expected true") + } +} + +func TestIncreaseAvailable(t *testing.T) { + t.Parallel() + i := Item{} + i.IncreaseAvailable(elite) + if !i.available.Equal(elite) { + t.Errorf("expected %v", elite) + } + i.IncreaseAvailable(decimal.Zero) + i.IncreaseAvailable(neg) + if !i.available.Equal(elite) { + t.Errorf("expected %v", elite) + } +} + +func TestRelease(t *testing.T) { + t.Parallel() + i := Item{} + err := i.Release(decimal.Zero, decimal.Zero) + if !errors.Is(err, errZeroAmountReceived) { + t.Errorf("received '%v' expected '%v'", err, errZeroAmountReceived) + } + err = i.Release(elite, decimal.Zero) + if !errors.Is(err, errCannotAllocate) { + t.Errorf("received '%v' expected '%v'", err, errCannotAllocate) + } + i.reserved = elite + err = i.Release(elite, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + + i.reserved = elite + err = i.Release(elite, one) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + + err = i.Release(neg, decimal.Zero) + if !errors.Is(err, errZeroAmountReceived) { + t.Errorf("received '%v' expected '%v'", err, errZeroAmountReceived) + } + err = i.Release(elite, neg) + if !errors.Is(err, errNegativeAmountReceived) { + t.Errorf("received '%v' expected '%v'", err, errNegativeAmountReceived) + } +} + +func TestReserve(t *testing.T) { + t.Parallel() + i := Item{} + err := i.Reserve(decimal.Zero) + if !errors.Is(err, errZeroAmountReceived) { + t.Errorf("received '%v' expected '%v'", err, errZeroAmountReceived) + } + err = i.Reserve(elite) + if !errors.Is(err, errCannotAllocate) { + t.Errorf("received '%v' expected '%v'", err, errCannotAllocate) + } + + i.reserved = elite + err = i.Reserve(elite) + if !errors.Is(err, errCannotAllocate) { + t.Errorf("received '%v' expected '%v'", err, errCannotAllocate) + } + + i.available = elite + err = i.Reserve(elite) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + + err = i.Reserve(elite) + if !errors.Is(err, errCannotAllocate) { + t.Errorf("received '%v' expected '%v'", err, errCannotAllocate) + } + + err = i.Reserve(neg) + if !errors.Is(err, errZeroAmountReceived) { + t.Errorf("received '%v' expected '%v'", err, errZeroAmountReceived) + } +} + +func TestMatchesItemCurrency(t *testing.T) { + t.Parallel() + i := Item{} + if i.MatchesItemCurrency(nil) { + t.Errorf("received '%v' expected '%v'", true, false) + } + baseItem, err := CreateItem(exch, a, pair.Base, decimal.Zero, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + quoteItem, err := CreateItem(exch, a, pair.Quote, elite, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + if baseItem.MatchesItemCurrency(quoteItem) { + t.Errorf("received '%v' expected '%v'", true, false) + } + if !baseItem.MatchesItemCurrency(baseItem) { + t.Errorf("received '%v' expected '%v'", false, true) + } +} + +func TestMatchesExchange(t *testing.T) { + t.Parallel() + i := Item{} + if i.MatchesExchange(nil) { + t.Errorf("received '%v' expected '%v'", true, false) + } + baseItem, err := CreateItem(exch, a, pair.Base, decimal.Zero, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + quoteItem, err := CreateItem(exch, a, pair.Quote, elite, decimal.Zero) + if !errors.Is(err, nil) { + t.Errorf("received '%v' expected '%v'", err, nil) + } + if !baseItem.MatchesExchange(quoteItem) { + t.Errorf("received '%v' expected '%v'", false, true) + } + if !baseItem.MatchesExchange(baseItem) { + t.Errorf("received '%v' expected '%v'", false, true) + } +} + +func TestGenerateReport(t *testing.T) { + t.Parallel() + f := FundManager{} + s := time.Now().Add(-time.Hour).Round(time.Hour) + e := time.Now() + report := f.GenerateReport(s, e) + if report == nil { + t.Fatal("shouldn't be nil") + } + if len(report.Items) > 0 { + t.Error("expected 0") + } + item := &Item{ + exchange: "hello :)", + initialFunds: decimal.NewFromInt(100), + available: decimal.NewFromInt(200), + currency: currency.BTC, + } + err := f.AddItem(item) + if err != nil { + t.Fatal(err) + } + report = f.GenerateReport(s, e) + if len(report.Items) != 1 { + t.Fatal("expected 1") + } + if report.Items[0].Exchange != item.exchange { + t.Error("expected matching name") + } + + f.usingExchangeLevelFunding = true + err = f.AddItem(&Item{ + exchange: "hello :)", + initialFunds: decimal.NewFromInt(100), + available: decimal.NewFromInt(200), + currency: currency.USD, + }) + if err != nil { + t.Fatal(err) + } + report = f.GenerateReport(s, e) + if len(report.Items) != 2 { + t.Fatal("expected 2") + } + if report.Items[0].Exchange != item.exchange { + t.Error("expected matching name") + } + if report.Items[0].FinalFundsUSD.Equal(decimal.NewFromInt(200)) { + t.Errorf("received %v expected converted values", decimal.NewFromInt(200)) + } + if !report.Items[1].FinalFundsUSD.Equal(decimal.NewFromInt(200)) { + t.Errorf("received %v expected %v", report.Items[1].FinalFunds, decimal.NewFromInt(200)) + } +} + +func TestMatchesCurrency(t *testing.T) { + t.Parallel() + i := Item{ + currency: currency.BTC, + } + if i.MatchesCurrency(currency.USDT) { + t.Error("expected false") + } + if !i.MatchesCurrency(currency.BTC) { + t.Error("expected true") + } + if i.MatchesCurrency(currency.Code{}) { + t.Error("expected false") + } + if i.MatchesCurrency(currency.NewCode("")) { + t.Error("expected false") + } +} diff --git a/backtester/funding/funding_types.go b/backtester/funding/funding_types.go new file mode 100644 index 00000000..e5c34441 --- /dev/null +++ b/backtester/funding/funding_types.go @@ -0,0 +1,102 @@ +package funding + +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" + "github.com/thrasher-corp/gocryptotrader/exchanges/order" +) + +// FundManager is the benevolent holder of all funding levels across all +// currencies used in the backtester +type FundManager struct { + usingExchangeLevelFunding bool + items []*Item +} + +// Report holds all funding data for result reporting +type Report struct { + InitialTotalUSD decimal.Decimal + FinalTotalUSD decimal.Decimal + Difference decimal.Decimal + Items []ReportItem +} + +// ReportItem holds reporting fields +type ReportItem struct { + Exchange string + Asset asset.Item + Currency currency.Code + InitialFunds decimal.Decimal + InitialFundsUSD decimal.Decimal + TransferFee decimal.Decimal + FinalFunds decimal.Decimal + FinalFundsUSD decimal.Decimal + Difference decimal.Decimal + ShowInfinite bool + PairedWith currency.Code +} + +// IFundingManager limits funding usage for portfolio event handling +type IFundingManager interface { + Reset() + IsUsingExchangeLevelFunding() bool + GetFundingForEAC(string, asset.Item, currency.Code) (*Item, error) + GetFundingForEvent(common.EventHandler) (*Pair, error) + GetFundingForEAP(string, asset.Item, currency.Pair) (*Pair, error) + Transfer(decimal.Decimal, *Item, *Item, bool) error + GenerateReport(startDate, endDate time.Time) *Report +} + +// IFundTransferer allows for funding amounts to be transferred +// implementation can be swapped for live transferring +type IFundTransferer interface { + IsUsingExchangeLevelFunding() bool + Transfer(decimal.Decimal, *Item, *Item, bool) error + GetFundingForEAC(string, asset.Item, currency.Code) (*Item, error) + GetFundingForEvent(common.EventHandler) (*Pair, error) + GetFundingForEAP(string, asset.Item, currency.Pair) (*Pair, error) +} + +// IPairReader is used to limit pair funding functions +// to readonly +type IPairReader interface { + BaseInitialFunds() decimal.Decimal + QuoteInitialFunds() decimal.Decimal + BaseAvailable() decimal.Decimal + QuoteAvailable() decimal.Decimal +} + +// IPairReserver limits funding usage for portfolio event handling +type IPairReserver interface { + IPairReader + CanPlaceOrder(order.Side) bool + Reserve(decimal.Decimal, order.Side) error +} + +// IPairReleaser limits funding usage for exchange event handling +type IPairReleaser interface { + IncreaseAvailable(decimal.Decimal, order.Side) + Release(decimal.Decimal, decimal.Decimal, order.Side) error +} + +// Item holds funding data per currency item +type Item struct { + exchange string + asset asset.Item + currency currency.Code + initialFunds decimal.Decimal + available decimal.Decimal + reserved decimal.Decimal + transferFee decimal.Decimal + pairedWith *Item +} + +// Pair holds two currencies that are associated with each other +type Pair struct { + Base *Item + Quote *Item +} diff --git a/backtester/main.go b/backtester/main.go index 7903d898..d1c8c00d 100644 --- a/backtester/main.go +++ b/backtester/main.go @@ -17,7 +17,7 @@ import ( func main() { var configPath, templatePath, reportOutput string - var printLogo, generateReport bool + var printLogo, generateReport, darkReport bool wd, err := os.Getwd() if err != nil { fmt.Printf("Could not get working directory. Error: %v.\n", err) @@ -57,7 +57,11 @@ func main() { "printlogo", true, "print out the logo to the command line, projected profits likely won't be affected if disabled") - + flag.BoolVar( + &darkReport, + "darkreport", + false, + "sets the initial rerport to use a dark theme") flag.Parse() var bt *backtest.BackTest @@ -76,6 +80,7 @@ func main() { if cfg.GoCryptoTraderConfigPath != "" { path = cfg.GoCryptoTraderConfigPath } + var bot *engine.Engine flags := map[string]bool{ "tickersync": false, @@ -94,6 +99,12 @@ func main() { fmt.Printf("Could not load backtester. Error: %v.\n", err) os.Exit(-1) } + + err = cfg.Validate() + if err != nil { + fmt.Printf("Could not read config. Error: %v.\n", err) + os.Exit(1) + } bt, err = backtest.NewFromConfig(cfg, templatePath, reportOutput, bot) if err != nil { fmt.Printf("Could not setup backtester from config. Error: %v.\n", err) @@ -118,13 +129,14 @@ func main() { } } - err = bt.Statistic.CalculateAllResults() + err = bt.Statistic.CalculateAllResults(bt.Funding) if err != nil { gctlog.Error(gctlog.BackTester, err) os.Exit(1) } if generateReport { + bt.Reports.UseDarkMode(darkReport) err = bt.Reports.GenerateReport() if err != nil { gctlog.Error(gctlog.BackTester, err) diff --git a/backtester/report/report.go b/backtester/report/report.go index 23f910d8..60d3abcd 100644 --- a/backtester/report/report.go +++ b/backtester/report/report.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/common" "github.com/thrasher-corp/gocryptotrader/exchanges/kline" "github.com/thrasher-corp/gocryptotrader/exchanges/order" @@ -87,6 +88,16 @@ func (d *Data) AddKlineItem(k *kline.Item) { d.OriginalCandles = append(d.OriginalCandles, k) } +// UpdateItem updates an existing kline item for LIVE data usage +func (d *Data) UpdateItem(k *kline.Item) { + if len(d.OriginalCandles) == 0 { + d.OriginalCandles = append(d.OriginalCandles, k) + } else { + d.OriginalCandles[0].Candles = append(d.OriginalCandles[0].Candles, k.Candles...) + d.OriginalCandles[0].RemoveDuplicates() + } +} + // enhanceCandles will enhance candle data with order information allowing // report charts to have annotations to highlight buy and sell events func (d *Data) enhanceCandles() error { @@ -96,7 +107,7 @@ func (d *Data) enhanceCandles() error { if d.Statistics == nil { return errStatisticsUnset } - d.Statistics.RiskFreeRate *= 100 + d.Statistics.RiskFreeRate = d.Statistics.RiskFreeRate.Mul(decimal.NewFromInt(100)) for intVal := range d.OriginalCandles { lookup := d.OriginalCandles[intVal] @@ -123,16 +134,16 @@ func (d *Data) enhanceCandles() error { tt := d.OriginalCandles[intVal].Candles[j].Time.Add(time.Duration(offset) * time.Second) enhancedCandle := DetailedCandle{ Time: tt.Unix(), - Open: d.OriginalCandles[intVal].Candles[j].Open, - High: d.OriginalCandles[intVal].Candles[j].High, - Low: d.OriginalCandles[intVal].Candles[j].Low, - Close: d.OriginalCandles[intVal].Candles[j].Close, - Volume: d.OriginalCandles[intVal].Candles[j].Volume, - VolumeColour: "rgba(47, 194, 27, 0.8)", + Open: decimal.NewFromFloat(d.OriginalCandles[intVal].Candles[j].Open), + High: decimal.NewFromFloat(d.OriginalCandles[intVal].Candles[j].High), + Low: decimal.NewFromFloat(d.OriginalCandles[intVal].Candles[j].Low), + Close: decimal.NewFromFloat(d.OriginalCandles[intVal].Candles[j].Close), + Volume: decimal.NewFromFloat(d.OriginalCandles[intVal].Candles[j].Volume), + VolumeColour: "rgba(50, 204, 30, 0.5)", } if j != 0 { if d.OriginalCandles[intVal].Candles[j].Close < d.OriginalCandles[intVal].Candles[j-1].Close { - enhancedCandle.VolumeColour = "rgba(252, 3, 3, 0.8)" + enhancedCandle.VolumeColour = "rgba(232, 3, 3, 0.5)" } } if !requiresIteration { @@ -157,8 +168,8 @@ func (d *Data) enhanceCandles() error { } // an order was placed here, can enhance chart! enhancedCandle.MadeOrder = true - enhancedCandle.OrderAmount = statsForCandles.FinalOrders.Orders[k].Amount - enhancedCandle.PurchasePrice = statsForCandles.FinalOrders.Orders[k].Price + enhancedCandle.OrderAmount = decimal.NewFromFloat(statsForCandles.FinalOrders.Orders[k].Amount) + enhancedCandle.PurchasePrice = decimal.NewFromFloat(statsForCandles.FinalOrders.Orders[k].Price) enhancedCandle.OrderDirection = statsForCandles.FinalOrders.Orders[k].Side if enhancedCandle.OrderDirection == order.Buy { enhancedCandle.Colour = "green" @@ -192,3 +203,9 @@ func (d *DetailedCandle) copyCloseFromPreviousEvent(enhancedKline *DetailedKline d.Shape = "arrowDown" d.Text = common.MissingData.String() } + +// UseDarkMode sets whether to use a dark theme by default +// for the html generated report +func (d *Data) UseDarkMode(use bool) { + d.UseDarkTheme = use +} diff --git a/backtester/report/report_test.go b/backtester/report/report_test.go index dae21fc5..91d6b927 100644 --- a/backtester/report/report_test.go +++ b/backtester/report/report_test.go @@ -8,11 +8,13 @@ import ( "testing" "time" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/config" "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/statistics" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/statistics/currencystatistics" + "github.com/thrasher-corp/gocryptotrader/backtester/funding" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/exchanges/asset" gctkline "github.com/thrasher-corp/gocryptotrader/exchanges/kline" @@ -22,6 +24,7 @@ import ( const testExchange = "binance" func TestGenerateReport(t *testing.T) { + t.Parallel() e := testExchange a := asset.Spot p := currency.NewPair(currency.BTC, currency.USDT) @@ -29,7 +32,12 @@ func TestGenerateReport(t *testing.T) { if err != nil { t.Fatalf("Problem creating temp dir at %s: %s\n", tempDir, err) } - defer os.RemoveAll(tempDir) + defer func(path string) { + err = os.RemoveAll(path) + if err != nil { + t.Error(err) + } + }(tempDir) d := Data{ Config: &config.Config{}, OutputPath: filepath.Join("..", "results"), @@ -59,79 +67,79 @@ func TestGenerateReport(t *testing.T) { Candles: []DetailedCandle{ { Time: time.Now().Add(-time.Hour * 5).Unix(), - Open: 1337, - High: 1339, - Low: 1336, - Close: 1338, - Volume: 3, + Open: decimal.NewFromInt(1337), + High: decimal.NewFromInt(1339), + Low: decimal.NewFromInt(1336), + Close: decimal.NewFromInt(1338), + Volume: decimal.NewFromInt(3), MadeOrder: true, OrderDirection: gctorder.Buy, - OrderAmount: 1337, + OrderAmount: decimal.NewFromInt(1337), Shape: "arrowUp", Text: "hi", Position: "aboveBar", Colour: "green", - PurchasePrice: 50, + PurchasePrice: decimal.NewFromInt(50), VolumeColour: "rgba(47, 194, 27, 0.8)", }, { Time: time.Now().Add(-time.Hour * 4).Unix(), - Open: 1332, - High: 1332, - Low: 1330, - Close: 1331, - Volume: 2, + Open: decimal.NewFromInt(1332), + High: decimal.NewFromInt(1332), + Low: decimal.NewFromInt(1330), + Close: decimal.NewFromInt(1331), + Volume: decimal.NewFromInt(2), MadeOrder: true, OrderDirection: gctorder.Buy, - OrderAmount: 1337, + OrderAmount: decimal.NewFromInt(1337), Shape: "arrowUp", Text: "hi", Position: "aboveBar", Colour: "green", - PurchasePrice: 50, + PurchasePrice: decimal.NewFromInt(50), VolumeColour: "rgba(252, 3, 3, 0.8)", }, { Time: time.Now().Add(-time.Hour * 3).Unix(), - Open: 1337, - High: 1339, - Low: 1336, - Close: 1338, - Volume: 3, + Open: decimal.NewFromInt(1337), + High: decimal.NewFromInt(1339), + Low: decimal.NewFromInt(1336), + Close: decimal.NewFromInt(1338), + Volume: decimal.NewFromInt(3), MadeOrder: true, OrderDirection: gctorder.Buy, - OrderAmount: 1337, + OrderAmount: decimal.NewFromInt(1337), Shape: "arrowUp", Text: "hi", Position: "aboveBar", Colour: "green", - PurchasePrice: 50, + PurchasePrice: decimal.NewFromInt(50), VolumeColour: "rgba(47, 194, 27, 0.8)", }, { Time: time.Now().Add(-time.Hour * 2).Unix(), - Open: 1337, - High: 1339, - Low: 1336, - Close: 1338, - Volume: 3, + Open: decimal.NewFromInt(1337), + High: decimal.NewFromInt(1339), + Low: decimal.NewFromInt(1336), + Close: decimal.NewFromInt(1338), + Volume: decimal.NewFromInt(3), MadeOrder: true, OrderDirection: gctorder.Buy, - OrderAmount: 1337, + OrderAmount: decimal.NewFromInt(1337), Shape: "arrowUp", Text: "hi", Position: "aboveBar", Colour: "green", - PurchasePrice: 50, + PurchasePrice: decimal.NewFromInt(50), VolumeColour: "rgba(252, 3, 3, 0.8)", }, { Time: time.Now().Unix(), - Open: 1337, - High: 1339, - Low: 1336, - Close: 1338, - Volume: 3, + Open: decimal.NewFromInt(1337), + High: decimal.NewFromInt(1339), + Low: decimal.NewFromInt(1336), + Close: decimal.NewFromInt(1338), + Volume: decimal.NewFromInt(3), VolumeColour: "rgba(47, 194, 27, 0.8)", }, }, @@ -145,97 +153,98 @@ func TestGenerateReport(t *testing.T) { Candles: []DetailedCandle{ { Time: time.Now().Add(-time.Hour * 5).Unix(), - Open: 1337, - High: 1339, - Low: 1336, - Close: 1338, - Volume: 3, + Open: decimal.NewFromInt(1337), + High: decimal.NewFromInt(1339), + Low: decimal.NewFromInt(1336), + Close: decimal.NewFromInt(1338), + Volume: decimal.NewFromInt(3), MadeOrder: true, OrderDirection: gctorder.Buy, - OrderAmount: 1337, + OrderAmount: decimal.NewFromInt(1337), Shape: "arrowUp", Text: "hi", Position: "aboveBar", Colour: "green", - PurchasePrice: 50, + PurchasePrice: decimal.NewFromInt(50), VolumeColour: "rgba(47, 194, 27, 0.8)", }, { Time: time.Now().Add(-time.Hour * 4).Unix(), - Open: 1332, - High: 1332, - Low: 1330, - Close: 1331, - Volume: 2, + Open: decimal.NewFromInt(1332), + High: decimal.NewFromInt(1332), + Low: decimal.NewFromInt(1330), + Close: decimal.NewFromInt(1331), + Volume: decimal.NewFromInt(2), MadeOrder: true, OrderDirection: gctorder.Buy, - OrderAmount: 1337, + OrderAmount: decimal.NewFromInt(1337), Shape: "arrowUp", Text: "hi", Position: "aboveBar", Colour: "green", - PurchasePrice: 50, + PurchasePrice: decimal.NewFromInt(50), VolumeColour: "rgba(252, 3, 3, 0.8)", }, { Time: time.Now().Add(-time.Hour * 3).Unix(), - Open: 1337, - High: 1339, - Low: 1336, - Close: 1338, - Volume: 3, + Open: decimal.NewFromInt(1337), + High: decimal.NewFromInt(1339), + Low: decimal.NewFromInt(1336), + Close: decimal.NewFromInt(1338), + Volume: decimal.NewFromInt(3), MadeOrder: true, OrderDirection: gctorder.Buy, - OrderAmount: 1337, + OrderAmount: decimal.NewFromInt(1337), Shape: "arrowUp", Text: "hi", Position: "aboveBar", Colour: "green", - PurchasePrice: 50, + PurchasePrice: decimal.NewFromInt(50), VolumeColour: "rgba(47, 194, 27, 0.8)", }, { Time: time.Now().Add(-time.Hour * 2).Unix(), - Open: 1337, - High: 1339, - Low: 1336, - Close: 1338, - Volume: 3, + Open: decimal.NewFromInt(1337), + High: decimal.NewFromInt(1339), + Low: decimal.NewFromInt(1336), + Close: decimal.NewFromInt(1338), + Volume: decimal.NewFromInt(3), MadeOrder: true, OrderDirection: gctorder.Buy, - OrderAmount: 1337, + OrderAmount: decimal.NewFromInt(1337), Shape: "arrowUp", Text: "hi", Position: "aboveBar", Colour: "green", - PurchasePrice: 50, + PurchasePrice: decimal.NewFromInt(50), VolumeColour: "rgba(252, 3, 3, 0.8)", }, { Time: time.Now().Unix(), - Open: 1337, - High: 1339, - Low: 1336, - Close: 1338, - Volume: 3, + Open: decimal.NewFromInt(1337), + High: decimal.NewFromInt(1339), + Low: decimal.NewFromInt(1336), + Close: decimal.NewFromInt(1338), + Volume: decimal.NewFromInt(3), VolumeColour: "rgba(47, 194, 27, 0.8)", }, }, }, }, Statistics: &statistics.Statistic{ + Funding: &funding.Report{}, StrategyName: "testStrat", ExchangeAssetPairStatistics: map[string]map[asset.Item]map[currency.Pair]*currencystatistics.CurrencyStatistic{ e: { a: { p: ¤cystatistics.CurrencyStatistic{ MaxDrawdown: currencystatistics.Swing{}, - LowestClosePrice: 100, - HighestClosePrice: 200, - MarketMovement: 100, - StrategyMovement: 100, - RiskFreeRate: 1, - CompoundAnnualGrowthRate: 1, + LowestClosePrice: decimal.NewFromInt(100), + HighestClosePrice: decimal.NewFromInt(200), + MarketMovement: decimal.NewFromInt(100), + StrategyMovement: decimal.NewFromInt(100), + RiskFreeRate: decimal.NewFromInt(1), + CompoundAnnualGrowthRate: decimal.NewFromInt(1), BuyOrders: 1, SellOrders: 1, FinalHoldings: holdings.Holding{}, @@ -244,7 +253,7 @@ func TestGenerateReport(t *testing.T) { }, }, }, - RiskFreeRate: 0.03, + RiskFreeRate: decimal.NewFromFloat(0.03), TotalBuyOrders: 1337, TotalSellOrders: 1330, TotalOrders: 200, @@ -255,16 +264,16 @@ func TestGenerateReport(t *testing.T) { MaxDrawdown: currencystatistics.Swing{ Highest: currencystatistics.Iteration{ Time: time.Now(), - Price: 1337, + Price: decimal.NewFromInt(1337), }, Lowest: currencystatistics.Iteration{ Time: time.Now(), - Price: 137, + Price: decimal.NewFromInt(137), }, - DrawdownPercent: 100, + DrawdownPercent: decimal.NewFromInt(100), }, - MarketMovement: 1377, - StrategyMovement: 1377, + MarketMovement: decimal.NewFromInt(1377), + StrategyMovement: decimal.NewFromInt(1377), }, BestStrategyResults: &statistics.FinalResultsHolder{ Exchange: e, @@ -273,16 +282,16 @@ func TestGenerateReport(t *testing.T) { MaxDrawdown: currencystatistics.Swing{ Highest: currencystatistics.Iteration{ Time: time.Now(), - Price: 1337, + Price: decimal.NewFromInt(1337), }, Lowest: currencystatistics.Iteration{ Time: time.Now(), - Price: 137, + Price: decimal.NewFromInt(137), }, - DrawdownPercent: 100, + DrawdownPercent: decimal.NewFromInt(100), }, - MarketMovement: 1337, - StrategyMovement: 1337, + MarketMovement: decimal.NewFromInt(1337), + StrategyMovement: decimal.NewFromInt(1337), }, BestMarketMovement: &statistics.FinalResultsHolder{ Exchange: e, @@ -291,16 +300,16 @@ func TestGenerateReport(t *testing.T) { MaxDrawdown: currencystatistics.Swing{ Highest: currencystatistics.Iteration{ Time: time.Now(), - Price: 1337, + Price: decimal.NewFromInt(1337), }, Lowest: currencystatistics.Iteration{ Time: time.Now(), - Price: 137, + Price: decimal.NewFromInt(137), }, - DrawdownPercent: 100, + DrawdownPercent: decimal.NewFromInt(100), }, - MarketMovement: 1337, - StrategyMovement: 1337, + MarketMovement: decimal.NewFromInt(1337), + StrategyMovement: decimal.NewFromInt(1337), }, }, } @@ -312,16 +321,17 @@ func TestGenerateReport(t *testing.T) { } func TestEnhanceCandles(t *testing.T) { + t.Parallel() tt := time.Now() var d Data err := d.enhanceCandles() if !errors.Is(err, errNoCandles) { - t.Errorf("expected: %v, received %v", errNoCandles, err) + t.Errorf("received: %v, expected: %v", err, errNoCandles) } d.AddKlineItem(&gctkline.Item{}) err = d.enhanceCandles() if !errors.Is(err, errStatisticsUnset) { - t.Errorf("expected: %v, received %v", errStatisticsUnset, err) + t.Errorf("received: %v, expected: %v", err, errStatisticsUnset) } d.Statistics = &statistics.Statistic{} err = d.enhanceCandles() @@ -388,10 +398,10 @@ func TestEnhanceCandles(t *testing.T) { d.Statistics.ExchangeAssetPairStatistics[testExchange][asset.Spot][currency.NewPair(currency.BTC, currency.USDT)].FinalOrders = compliance.Snapshot{ Orders: []compliance.SnapshotOrder{ { - ClosePrice: 1335, - VolumeAdjustedPrice: 1337, - SlippageRate: 1, - CostBasis: 1337, + ClosePrice: decimal.NewFromInt(1335), + VolumeAdjustedPrice: decimal.NewFromInt(1337), + SlippageRate: decimal.NewFromInt(1), + CostBasis: decimal.NewFromInt(1337), Detail: nil, }, }, @@ -405,10 +415,10 @@ func TestEnhanceCandles(t *testing.T) { d.Statistics.ExchangeAssetPairStatistics[testExchange][asset.Spot][currency.NewPair(currency.BTC, currency.USDT)].FinalOrders = compliance.Snapshot{ Orders: []compliance.SnapshotOrder{ { - ClosePrice: 1335, - VolumeAdjustedPrice: 1337, - SlippageRate: 1, - CostBasis: 1337, + ClosePrice: decimal.NewFromInt(1335), + VolumeAdjustedPrice: decimal.NewFromInt(1337), + SlippageRate: decimal.NewFromInt(1), + CostBasis: decimal.NewFromInt(1337), Detail: &gctorder.Detail{ Date: tt, Side: gctorder.Buy, @@ -425,10 +435,10 @@ func TestEnhanceCandles(t *testing.T) { d.Statistics.ExchangeAssetPairStatistics[testExchange][asset.Spot][currency.NewPair(currency.BTC, currency.USDT)].FinalOrders = compliance.Snapshot{ Orders: []compliance.SnapshotOrder{ { - ClosePrice: 1335, - VolumeAdjustedPrice: 1337, - SlippageRate: 1, - CostBasis: 1337, + ClosePrice: decimal.NewFromInt(1335), + VolumeAdjustedPrice: decimal.NewFromInt(1337), + SlippageRate: decimal.NewFromInt(1), + CostBasis: decimal.NewFromInt(1337), Detail: &gctorder.Detail{ Date: tt, Side: gctorder.Sell, diff --git a/backtester/report/report_types.go b/backtester/report/report_types.go index 07d58100..10f2547f 100644 --- a/backtester/report/report_types.go +++ b/backtester/report/report_types.go @@ -3,6 +3,7 @@ package report import ( "errors" + "github.com/shopspring/decimal" "github.com/thrasher-corp/gocryptotrader/backtester/config" "github.com/thrasher-corp/gocryptotrader/backtester/eventhandlers/statistics" "github.com/thrasher-corp/gocryptotrader/currency" @@ -23,6 +24,8 @@ var ( type Handler interface { GenerateReport() error AddKlineItem(*kline.Item) + UpdateItem(*kline.Item) + UseDarkMode(bool) } // Data holds all statistical information required to output detailed backtesting results @@ -34,6 +37,7 @@ type Data struct { TemplatePath string OutputPath string Warnings []Warning + UseDarkTheme bool } // Warning holds any candle warnings @@ -58,18 +62,18 @@ type DetailedKline struct { // DetailedCandle contains extra details to enable rich reporting results type DetailedCandle struct { Time int64 - Open float64 - High float64 - Low float64 - Close float64 - Volume float64 + Open decimal.Decimal + High decimal.Decimal + Low decimal.Decimal + Close decimal.Decimal + Volume decimal.Decimal VolumeColour string MadeOrder bool OrderDirection order.Side - OrderAmount float64 + OrderAmount decimal.Decimal Shape string Text string Position string Colour string - PurchasePrice float64 + PurchasePrice decimal.Decimal } diff --git a/backtester/report/tpl.gohtml b/backtester/report/tpl.gohtml index 36e8462a..7a1fa3ae 100644 --- a/backtester/report/tpl.gohtml +++ b/backtester/report/tpl.gohtml @@ -1,13 +1,17 @@ - + + {{.Config.Nickname}} Results - + + - - - - - + + + {{if .UseDarkTheme}} + + {{else}} + + {{end}} @@ -18,159 +22,329 @@ + + {{- /*gotype: github.com/thrasher-corp/gocryptotrader/backtester/report.Data*/ -}} -
-