feature: add multiple exchange support in backtest

fix: change doc, since --exchange removed from backtest

fix: test for config changes
This commit is contained in:
zenix 2022-03-01 22:48:48 +09:00
parent d01fcb304d
commit 25b5eddc03
20 changed files with 237 additions and 185 deletions

View File

@ -52,11 +52,12 @@ backtest:
symbols:
- BTCUSDT
account:
makerCommission: 15
takerCommission: 15
balances:
BTC: 0.0
USDT: 10000.0
max:
makerCommission: 15
takerCommission: 15
balances:
BTC: 0.0
USDT: 10000.0
exchangeStrategies:
- on: max

View File

@ -5,8 +5,13 @@ persistence:
port: 6379
db: 0
sessions:
binance:
exchange: binance
envVarPrefix: BINANCE
# example command:
# godotenv -f .env.local -- go run ./cmd/bbgo backtest --exchange max --sync-from 2020-11-01 --config config/grid.yaml --base-asset-baseline
# godotenv -f .env.local -- go run ./cmd/bbgo backtest --sync-from 2020-11-01 --config config/grid.yaml --base-asset-baseline
backtest:
# for testing max draw down (MDD) at 03-12
# see here for more details
@ -16,9 +21,10 @@ backtest:
symbols:
- ETHUSDT
account:
balances:
ETH: 1.0
USDT: 20_000.0
binance:
balances:
ETH: 1.0
USDT: 20_000.0
exchangeStrategies:

View File

@ -30,12 +30,13 @@ backtest:
symbols:
- USDTTWD
account:
makerCommission: 15
takerCommission: 15
balances:
BTC: 0.0
USDT: 10_000.0
TWD: 100_000.0
max:
makerCommission: 15
takerCommission: 15
balances:
BTC: 0.0
USDT: 10_000.0
TWD: 100_000.0
exchangeStrategies:
- on: max

View File

@ -4,9 +4,9 @@ sessions:
exchange: binance
envVarPrefix: binance
max:
exchange: max
envVarPrefix: max
#max:
# exchange: max
# envVarPrefix: max
riskControls:
# This is the session-based risk controller, which let you configure different risk controller by session.
@ -26,23 +26,24 @@ riskControls:
maxOrderAmount: 1000.0
# example command:
# godotenv -f .env.local -- go run ./cmd/bbgo backtest --exchange max --sync-from 2020-11-01 --config config/grid.yaml --base-asset-baseline
# godotenv -f .env.local -- go run ./cmd/bbgo backtest --sync-from 2020-11-01 --config config/grid.yaml --base-asset-baseline
backtest:
# for testing max draw down (MDD) at 03-12
# see here for more details
# https://www.investopedia.com/terms/m/maximum-drawdown-mdd.asp
startTime: "2021-01-10"
endTime: "2021-01-21"
startTime: "2022-01-10"
endTime: "2022-01-11"
symbols:
- BTCUSDT
- BTCUSDT
account:
balances:
BTC: 0.0
USDT: 10000.0
binance:
balances:
BTC: 0.0
USDT: 10000.0
exchangeStrategies:
- on: max
- on: binance
grid:
symbol: BTCUSDT
quantity: 0.001

View File

@ -40,18 +40,19 @@ backtest:
# for testing max draw down (MDD) at 03-12
# see here for more details
# https://www.investopedia.com/terms/m/maximum-drawdown-mdd.asp
startTime: "2020-01-01"
endTime: "2020-01-15"
startTime: "2022-01-01"
endTime: "2022-01-15"
symbols:
- BTCUSDT
account:
makerCommission: 15
takerCommission: 15
buyerCommission: 0
sellerCommission: 0
balances:
BTC: 0.1
USDT: 10000.0
binance:
makerCommission: 15
takerCommission: 15
buyerCommission: 0
sellerCommission: 0
balances:
BTC: 0.1
USDT: 10000.0
exchangeStrategies:
- on: binance

View File

@ -1,14 +1,21 @@
---
# time godotenv -f .env.local -- go run ./cmd/bbgo backtest --exchange binance --base-asset-baseline --config config/schedule-ethusdt.yaml -v
sessions:
binance:
exchange: binance
envVarPrefix: binance
# time godotenv -f .env.local -- go run ./cmd/bbgo backtest --base-asset-baseline --config config/schedule-ethusdt.yaml -v
backtest:
startTime: "2021-08-01"
endTime: "2021-08-07"
symbols:
- ETHUSDT
account:
balances:
ETH: 1.0
USDT: 20_000.0
binance:
balances:
ETH: 1.0
USDT: 20_000.0
riskControls:
# This is the session-based risk controller, which let you configure different risk controller by session.

View File

@ -16,6 +16,7 @@ backtest:
symbols:
- BNBBUSD
account:
balances:
BNB: 0
BUSD: 10000
binance:
balances:
BNB: 0
BUSD: 10000

View File

@ -44,14 +44,14 @@ backtest:
startTime: "2020-09-04"
endTime: "2020-09-14"
symbols:
- BTCUSDT
- ETHUSDT
- LINKUSDT
account:
makerCommission: 15
takerCommission: 15
balances:
BTC: 0.0
USDT: 10000.0
binance:
makerCommission: 15
takerCommission: 15
balances:
LINK: 0.0
USDT: 10000.0
exchangeStrategies:
@ -59,14 +59,14 @@ exchangeStrategies:
support:
symbol: LINKUSDT
interval: 1m
minVolume: 1_000
minVolume: 2_000
marginOrderSideEffect: borrow
scaleQuantity:
byVolume:
exp:
domain: [ 1_000, 200_000 ]
range: [ 0.5, 1.0 ]
range: [ 3.0, 5.0 ]
maxBaseAssetBalance: 1000.0
minQuoteAssetBalance: 2000.0

View File

@ -44,9 +44,10 @@ backtest:
- BTCUSDT
- ETHUSDT
account:
balances:
BTC: 0.0
USDT: 10000.0
binance:
balances:
BTC: 0.0
USDT: 10000.0
exchangeStrategies:

View File

@ -47,7 +47,7 @@ bbgo [flags]
* [bbgo orderupdate](bbgo_orderupdate.md) - Listen to order update events
* [bbgo pnl](bbgo_pnl.md) - pnl calculator
* [bbgo run](bbgo_run.md) - run strategies from config file
* [bbgo submit-order](bbgo_submit-order.md) - place limit order to the exchange
* [bbgo submit-order](bbgo_submit-order.md) - place order to the exchange
* [bbgo sync](bbgo_sync.md) - sync trades and orders history
* [bbgo trades](bbgo_trades.md) - Query trading history
* [bbgo tradeupdate](bbgo_tradeupdate.md) - Listen to trade update events

View File

@ -3,7 +3,7 @@
show user account details (ex: balance)
```
bbgo account [--session=[exchange_name]] [flags]
bbgo account [--session SESSION] [flags]
```
### Options

View File

@ -10,7 +10,6 @@ bbgo backtest [flags]
```
--base-asset-baseline use base asset performance as the competitive baseline performance
--exchange string target exchange
--force force execution without confirm
-h, --help help for backtest
--output string the report output directory

View File

@ -3,7 +3,7 @@
Show user account balances
```
bbgo balances --session SESSION [flags]
bbgo balances [--session SESSION] [flags]
```
### Options

View File

@ -1,6 +1,6 @@
## bbgo submit-order
place limit order to the exchange
place order to the exchange
```
bbgo submit-order --session SESSION --symbol SYMBOL --side SIDE --quantity QUANTITY [--price PRICE] [flags]

View File

@ -20,9 +20,10 @@ backtest:
account:
# the initial account balance you want to start with
balances:
BTC: 0.0
USDT: 10000.0
binance: # exchange name
balances:
BTC: 0.0
USDT: 10000.0
```
Note on date formats, the following date formats are supported:
@ -33,7 +34,7 @@ Note on date formats, the following date formats are supported:
And then, you can sync remote exchange k-lines (candle bars) data for back-testing:
```sh
bbgo backtest --exchange binance -v --sync --sync-only --sync-from 2020-11-01 --config config/grid.yaml
bbgo backtest -v --sync --sync-only --sync-from 2020-11-01 --config config/grid.yaml
```
Note that, you should sync from an earlier date before your startTime because some indicator like EMA needs more data to calculate the current EMA value.
@ -48,13 +49,13 @@ Here we sync one month before `2021-01-10`.
Run back-test:
```sh
bbgo backtest --exchange binance --base-asset-baseline --config config/grid.yaml
bbgo backtest --base-asset-baseline --config config/grid.yaml
```
If you're developing a strategy, you might want to start with a command like this:
```shell
godotenv -f .env.local -- go run ./cmd/bbgo backtest --exchange binance --config config/grid.yaml --base-asset-baseline
godotenv -f .env.local -- go run ./cmd/bbgo backtest --config config/grid.yaml --base-asset-baseline
```
## See Also

View File

@ -83,13 +83,15 @@ func NewExchange(sourceName types.ExchangeName, sourceExchange types.Exchange, s
endTime = time.Now()
}
configAccount := config.Account[sourceName.String()]
account := &types.Account{
MakerFeeRate: config.Account.MakerFeeRate,
TakerFeeRate: config.Account.TakerFeeRate,
MakerFeeRate: configAccount.MakerFeeRate,
TakerFeeRate: configAccount.TakerFeeRate,
AccountType: types.AccountTypeSpot,
}
balances := config.Account.Balances.BalanceMap()
balances := configAccount.Balances.BalanceMap()
account.UpdateBalances(balances)
e := &Exchange{
@ -288,7 +290,7 @@ func (e *Exchange) matchingBook(symbol string) (*SimplePriceMatching, bool) {
return m, ok
}
func (e *Exchange) FeedMarketData() error {
func (e *Exchange) InitMarketData() {
e.userDataStream.OnTradeUpdate(func(trade types.Trade) {
e.addTrade(trade)
})
@ -301,7 +303,9 @@ func (e *Exchange) FeedMarketData() error {
}
e.matchingBooksMutex.Unlock()
marketDataStream := e.marketDataStream
}
func (e *Exchange) GetMarketData() (chan types.KLine, error) {
log.Infof("collecting backtest configurations...")
loadedSymbols := map[string]struct{}{}
@ -310,8 +314,7 @@ func (e *Exchange) FeedMarketData() error {
types.Interval1m: {},
types.Interval1d: {},
}
for _, sub := range marketDataStream.Subscriptions {
for _, sub := range e.marketDataStream.Subscriptions {
loadedSymbols[sub.Symbol] = struct{}{}
switch sub.Channel {
@ -319,7 +322,7 @@ func (e *Exchange) FeedMarketData() error {
loadedIntervals[types.Interval(sub.Options.Interval)] = struct{}{}
default:
return fmt.Errorf("stream channel %s is not supported in backtest", sub.Channel)
return nil, fmt.Errorf("stream channel %s is not supported in backtest", sub.Channel)
}
}
@ -336,35 +339,33 @@ func (e *Exchange) FeedMarketData() error {
log.Infof("using symbols: %v and intervals: %v for back-testing", symbols, intervals)
log.Infof("querying klines from database...")
klineC, errC := e.srv.QueryKLinesCh(e.startTime, e.endTime, e, symbols, intervals)
numKlines := 0
for k := range klineC {
if k.Interval == types.Interval1m {
matching, ok := e.matchingBook(k.Symbol)
if !ok {
log.Errorf("matching book of %s is not initialized", k.Symbol)
continue
}
go func() {
if err := <-errC; err != nil {
log.WithError(err).Error("backtest data feed error")
}
}()
return klineC, nil
}
// here we generate trades and order updates
matching.processKLine(k)
numKlines++
func (e *Exchange) ConsumeKLine(k types.KLine) {
if k.Interval == types.Interval1m {
matching, ok := e.matchingBook(k.Symbol)
if !ok {
log.Errorf("matching book of %s is not initialized", k.Symbol)
return
}
marketDataStream.EmitKLineClosed(k)
// here we generate trades and order updates
matching.processKLine(k)
}
if err := <-errC; err != nil {
log.WithError(err).Error("backtest data feed error")
}
e.marketDataStream.EmitKLineClosed(k)
}
if numKlines == 0 {
log.Error("kline data is empty, make sure you have sync the exchange market data")
}
if err := marketDataStream.Close(); err != nil {
func (e *Exchange) CloseMarketData() error {
if err := e.marketDataStream.Close(); err != nil {
log.WithError(err).Error("stream close error")
return err
}
return nil
}

View File

@ -98,10 +98,10 @@ type Backtest struct {
EndTime *types.LooseFormatTime `json:"endTime,omitempty" yaml:"endTime,omitempty"`
// RecordTrades is an option, if set to true, back-testing should record the trades into database
RecordTrades bool `json:"recordTrades,omitempty" yaml:"recordTrades,omitempty"`
Account BacktestAccount `json:"account" yaml:"account"`
Symbols []string `json:"symbols" yaml:"symbols"`
Session string `json:"session" yaml:"session"`
RecordTrades bool `json:"recordTrades,omitempty" yaml:"recordTrades,omitempty"`
Account map[string]BacktestAccount `json:"account" yaml:"account"`
Symbols []string `json:"symbols" yaml:"symbols"`
Sessions []string `json:"sessions" yaml:"sessions"`
}
type BacktestAccount struct {

View File

@ -188,8 +188,8 @@ func TestLoadConfig(t *testing.T) {
assert.Len(t, config.ExchangeStrategies, 1)
assert.NotNil(t, config.Backtest)
assert.NotNil(t, config.Backtest.Account)
assert.NotNil(t, config.Backtest.Account.Balances)
assert.Len(t, config.Backtest.Account.Balances, 2)
assert.NotNil(t, config.Backtest.Account["binance"].Balances)
assert.Len(t, config.Backtest.Account["binance"].Balances, 2)
},
},
}

View File

@ -14,13 +14,14 @@ backtest:
# https://www.investopedia.com/terms/m/maximum-drawdown-mdd.asp
startTime: "2020-01-01"
account:
makerCommission: 15
takerCommission: 15
buyerCommission: 0
sellerCommission: 0
balances:
BTC: 1.0
USDT: 5000.0
binance:
makerCommission: 15
takerCommission: 15
buyerCommission: 0
sellerCommission: 0
balances:
BTC: 1.0
USDT: 5000.0
exchangeStrategies:

View File

@ -36,7 +36,6 @@ type BackTestReport struct {
}
func init() {
BacktestCmd.Flags().String("exchange", "", "target exchange")
BacktestCmd.Flags().Bool("sync", false, "sync backtest data")
BacktestCmd.Flags().Bool("sync-only", false, "sync backtest data only, do not run backtest")
BacktestCmd.Flags().String("sync-from", "", "sync backtest data from the given time, which will override the time range in the backtest config")
@ -54,6 +53,9 @@ var BacktestCmd = &cobra.Command{
Use: "backtest",
Short: "backtest your strategies",
SilenceUsage: true,
PreRunE: cobraInitRequired([]string{
"config",
}),
RunE: func(cmd *cobra.Command, args []string) error {
verboseCnt, err := cmd.Flags().GetCount("verbose")
if err != nil {
@ -110,11 +112,6 @@ var BacktestCmd = &cobra.Command{
return err
}
exchangeNameStr, err := cmd.Flags().GetString("exchange")
if err != nil {
return err
}
userConfig, err := bbgo.Load(configFile, true)
if err != nil {
return err
@ -129,45 +126,44 @@ var BacktestCmd = &cobra.Command{
log.SetLevel(log.ErrorLevel)
}
// if it's declared in the cmd , use the cmd one first
if exchangeNameStr == "" {
exchangeNameStr = userConfig.Backtest.Session
if userConfig.Backtest == nil {
return errors.New("backtest config is not defined")
}
var sourceExchange types.Exchange
var exchangeName types.ExchangeName
acceptAllSessions := false
var whitelistedSessions map[string]struct{}
if len(userConfig.Backtest.Sessions) == 0 {
acceptAllSessions = true
} else {
for _, name := range userConfig.Backtest.Sessions {
_, err := types.ValidExchangeName(name)
if err != nil {
return err
}
whitelistedSessions[name] = struct{}{}
}
}
sourceExchanges := make(map[string]types.Exchange)
for key, session := range userConfig.Sessions {
if exchangeNameStr == key {
ok := acceptAllSessions
if !ok {
_, ok = whitelistedSessions[key]
}
if ok {
publicExchange, err := cmdutil.NewExchangePublic(session.ExchangeName)
if err != nil {
return err
}
sourceExchange = publicExchange
exchangeName = session.ExchangeName
}
}
if sourceExchange == nil {
exchangeName, err = types.ValidExchangeName(exchangeNameStr)
if err != nil {
return err
}
sourceExchange, err = cmdutil.NewExchangePublic(exchangeName)
if err != nil {
return err
sourceExchanges[key] = publicExchange
}
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if userConfig.Backtest == nil {
return errors.New("backtest config is not defined")
}
var now = time.Now()
var startTime, endTime time.Time
@ -183,10 +179,6 @@ var BacktestCmd = &cobra.Command{
}
_ = endTime
if len(userConfig.CrossExchangeStrategies) > 0 {
log.Warnf("backtest does not support CrossExchangeStrategy, strategies won't be added.")
}
log.Infof("starting backtest with startTime %s", startTime.Format(time.ANSIC))
environ := bbgo.NewEnvironment()
@ -223,43 +215,47 @@ var BacktestCmd = &cobra.Command{
log.Info("starting synchronization...")
for _, symbol := range userConfig.Backtest.Symbols {
exCustom, ok := sourceExchange.(types.CustomIntervalProvider)
for _, sourceExchange := range sourceExchanges {
exCustom, ok := sourceExchange.(types.CustomIntervalProvider)
var supportIntervals map[types.Interval]int
if ok {
supportIntervals = exCustom.SupportedInterval()
} else {
supportIntervals = types.SupportedIntervals
}
for interval := range supportIntervals {
// if err := s.SyncKLineByInterval(ctx, exchange, symbol, interval, startTime, endTime); err != nil {
// return err
// }
firstKLine, err := backtestService.QueryFirstKLine(sourceExchange.Name(), symbol, interval)
if err != nil {
return errors.Wrapf(err, "failed to query backtest kline")
var supportIntervals map[types.Interval]int
if ok {
supportIntervals = exCustom.SupportedInterval()
} else {
supportIntervals = types.SupportedIntervals
}
// if we don't have klines before the start time endpoint, the back-test will fail.
// because the last price will be missing.
if firstKLine != nil {
if err := backtestService.SyncExist(ctx, sourceExchange, symbol, syncFromTime, time.Now(), interval); err != nil {
return err
for interval := range supportIntervals {
// if err := s.SyncKLineByInterval(ctx, exchange, symbol, interval, startTime, endTime); err != nil {
// return err
// }
firstKLine, err := backtestService.QueryFirstKLine(sourceExchange.Name(), symbol, interval)
if err != nil {
return errors.Wrapf(err, "failed to query backtest kline")
}
} else {
if err := backtestService.Sync(ctx, sourceExchange, symbol, syncFromTime, time.Now(), interval); err != nil {
return err
// if we don't have klines before the start time endpoint, the back-test will fail.
// because the last price will be missing.
if firstKLine != nil {
if err := backtestService.SyncExist(ctx, sourceExchange, symbol, syncFromTime, time.Now(), interval); err != nil {
return err
}
} else {
if err := backtestService.Sync(ctx, sourceExchange, symbol, syncFromTime, time.Now(), interval); err != nil {
return err
}
}
}
}
}
log.Info("synchronization done")
if shouldVerify {
err2, done := backtestService.Verify(userConfig.Backtest.Symbols, startTime, time.Now(), sourceExchange, verboseCnt)
if done {
return err2
for _, sourceExchange := range sourceExchanges {
if shouldVerify {
err2, done := backtestService.Verify(userConfig.Backtest.Symbols, startTime, time.Now(), sourceExchange, verboseCnt)
if done {
return err2
}
}
}
@ -283,19 +279,17 @@ var BacktestCmd = &cobra.Command{
}
}
if userConfig.Backtest == nil {
return errors.New("backtest config can not be nil")
}
backtestExchange, err := backtest.NewExchange(exchangeName, sourceExchange, backtestService, userConfig.Backtest)
if err != nil {
return errors.Wrap(err, "failed to create backtest exchange")
}
environ.SetStartTime(startTime)
// exchangeNameStr is the session name.
environ.AddExchange(exchangeNameStr, backtestExchange)
for name, sourceExchange := range sourceExchanges {
backtestExchange, err := backtest.NewExchange(sourceExchange.Name(), sourceExchange, backtestService, userConfig.Backtest)
if err != nil {
return errors.Wrap(err, "failed to create backtest exchange")
}
environ.AddExchange(name, backtestExchange)
}
if err := environ.Init(ctx); err != nil {
return err
@ -314,7 +308,42 @@ var BacktestCmd = &cobra.Command{
return err
}
backtestExchange.FeedMarketData()
type KChanEx struct {
KChan chan types.KLine
Exchange *backtest.Exchange
}
for _, session := range environ.Sessions() {
backtestExchange := session.Exchange.(*backtest.Exchange)
backtestExchange.InitMarketData()
}
var klineChans []KChanEx
for _, session := range environ.Sessions() {
exchange := session.Exchange.(*backtest.Exchange)
c, err := exchange.GetMarketData()
if err != nil {
return err
}
klineChans = append(klineChans, KChanEx{KChan: c, Exchange: exchange})
}
for {
count := len(klineChans)
for _, kchanex := range klineChans {
kLine, more := <-kchanex.KChan
if more {
kchanex.Exchange.ConsumeKLine(kLine)
} else {
if err := kchanex.Exchange.CloseMarketData(); err != nil {
return err
}
count--
}
}
if count == 0 {
break
}
}
log.Infof("shutting down trader...")
shutdownCtx, cancel := context.WithDeadline(ctx, time.Now().Add(10*time.Second))
@ -324,10 +353,12 @@ var BacktestCmd = &cobra.Command{
// put the logger back to print the pnl
log.SetLevel(log.InfoLevel)
for _, session := range environ.Sessions() {
backtestExchange := session.Exchange.(*backtest.Exchange)
exchangeName := session.Exchange.Name().String()
for symbol, trades := range session.Trades {
market, ok := session.Market(symbol)
if !ok {
return fmt.Errorf("market not found: %s", symbol)
return fmt.Errorf("market not found: %s, %s", symbol, exchangeName)
}
calculator := &pnl.AverageCostCalculator{
@ -337,21 +368,21 @@ var BacktestCmd = &cobra.Command{
startPrice, ok := session.StartPrice(symbol)
if !ok {
return fmt.Errorf("start price not found: %s", symbol)
return fmt.Errorf("start price not found: %s, %s", symbol, exchangeName)
}
lastPrice, ok := session.LastPrice(symbol)
if !ok {
return fmt.Errorf("last price not found: %s", symbol)
return fmt.Errorf("last price not found: %s, %s", symbol, exchangeName)
}
log.Infof("%s PROFIT AND LOSS REPORT", symbol)
log.Infof("===============================================")
color.Green("%s %s PROFIT AND LOSS REPORT", strings.ToUpper(exchangeName), symbol)
color.Green("===============================================")
report := calculator.Calculate(symbol, trades.Trades, lastPrice)
report.Print()
initBalances := userConfig.Backtest.Account.Balances.BalanceMap()
initBalances := userConfig.Backtest.Account[exchangeName].Balances.BalanceMap()
finalBalances := session.Account.Balances()
log.Infof("INITIAL BALANCES:")