├── .github ├── FUNDING.yml ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── ci.yaml │ └── release.yaml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── LICENSE ├── Makefile ├── cmd └── ninjabot │ └── ninjabot.go ├── codecov.yml ├── download ├── download.go └── download_test.go ├── examples ├── backtesting │ └── backtesting.go ├── futuremarket │ └── futures.go ├── paperwallet │ └── paperwallet.go ├── spotmarket │ └── spot.go └── strategies │ ├── emacross.go │ ├── ocosell.go │ ├── trailingstop.go │ └── turtle.go ├── exchange ├── binance.go ├── binance_future.go ├── binance_test.go ├── csvfeed.go ├── csvfeed_test.go ├── exchange.go ├── pairs.go ├── pairs.json ├── pairs_test.go ├── paperwallet.go └── paperwallet_test.go ├── go.mod ├── go.sum ├── indicator ├── supertrend.go └── talib.go ├── model ├── model.go ├── model_test.go ├── order.go ├── order_test.go ├── priorityqueue.go ├── priorityqueue_test.go ├── series.go └── series_test.go ├── ninjabot.go ├── ninjabot_test.go ├── notification ├── mail.go └── telegram.go ├── order ├── controller.go ├── controller_test.go ├── feed.go └── feed_test.go ├── plot ├── assets │ ├── chart.html │ └── chart.js ├── chart.go ├── chart_test.go └── indicator │ ├── bollingerbands.go │ ├── cci.go │ ├── ema.go │ ├── macd.go │ ├── obv.go │ ├── rsi.go │ ├── sma.go │ ├── stoch.go │ ├── supertrend.go │ └── willr.go ├── readme.md ├── service └── service.go ├── storage ├── buntdb.go ├── buntdb_test.go ├── sql.go ├── sql_test.go ├── storage.go └── storage_test.go ├── strategy ├── controller.go ├── indicator.go └── strategy.go ├── testdata ├── btc-1d-2021-05-13.csv ├── btc-1d-header.csv ├── btc-1d.csv ├── btc-1h-2021-05-13.csv ├── btc-1h.csv ├── eth-1h.csv └── mocks │ ├── Broker.go │ ├── Exchange.go │ ├── Feeder.go │ ├── Notifier.go │ └── Telegram.go ├── tools ├── log │ └── logger.go ├── metrics │ ├── bootstrap.go │ ├── bootstrap_test.go │ └── metrics.go ├── scheduler.go ├── tools.go ├── trailing.go └── trailing_test.go └── types.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: rodrigo-brito 4 | patreon: ninjabot_github 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ### What have you changed and why? 5 | Describe your changes here. 6 | 7 | ##### Related Issues 8 | Link related issues here. 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | name: Tests and Lint 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Setup go 16 | uses: actions/setup-go@v3 17 | with: 18 | go-version: 1.18 19 | - run: go test -race -coverprofile="coverage.txt" -covermode=atomic ./... 20 | 21 | - name: lint 22 | if: github.event_name == 'pull_request' 23 | uses: golangci/golangci-lint-action@v3 24 | with: 25 | version: latest 26 | skip-build-cache: true 27 | skip-pkg-cache: true 28 | 29 | - name: coverage 30 | uses: codecov/codecov-action@v2 31 | with: 32 | token: ${{ secrets.CODECOV_TOKEN }} 33 | files: coverage.txt 34 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v3 22 | with: 23 | go-version: 1.18 24 | 25 | - name: Run GoReleaser 26 | uses: goreleaser/goreleaser-action@v3 27 | with: 28 | version: latest 29 | args: release --rm-dist 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | /ninjabot 15 | /ninjabot.db 16 | /backtest.db 17 | /dist/ 18 | /vendor 19 | 20 | # IDE 21 | .idea 22 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 1m 3 | skip-dirs: 4 | - example 5 | - pkg/ent 6 | 7 | linters: 8 | enable: 9 | - lll 10 | - gofmt 11 | - revive 12 | - exportloopref 13 | - unparam 14 | 15 | linters-settings: 16 | lll: 17 | line-length: 120 18 | 19 | issues: 20 | exclude-rules: 21 | # Exclude lll issues for long lines with go:generate 22 | - linters: 23 | - lll 24 | source: "^//go:generate " 25 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | 5 | builds: 6 | - main: ./cmd/ninjabot 7 | id: "ninjabot" 8 | binary: ninjabot 9 | env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - linux 13 | - darwin 14 | - windows 15 | 16 | archives: 17 | - replacements: 18 | darwin: Darwin 19 | linux: Linux 20 | windows: Windows 21 | 386: i386 22 | amd64: x86_64 23 | format: zip 24 | 25 | checksum: 26 | name_template: 'checksums.txt' 27 | 28 | snapshot: 29 | name_template: "{{ .Tag }}" 30 | 31 | changelog: 32 | filters: 33 | exclude: 34 | - '^docs\(' 35 | - '^test\(' 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Rodrigo Brito 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | generate: 2 | go generate ./... 3 | lint: 4 | golangci-lint run --fix 5 | test: 6 | go test -race -cover ./... 7 | release: 8 | goreleaser build --snapshot 9 | -------------------------------------------------------------------------------- /cmd/ninjabot/ninjabot.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/rodrigo-brito/ninjabot/download" 8 | "github.com/rodrigo-brito/ninjabot/exchange" 9 | "github.com/rodrigo-brito/ninjabot/service" 10 | 11 | "github.com/urfave/cli/v2" 12 | ) 13 | 14 | func main() { 15 | app := &cli.App{ 16 | Name: "ninjabot", 17 | HelpName: "ninjabot", 18 | Usage: "Utilities for bot creation", 19 | Commands: []*cli.Command{ 20 | { 21 | Name: "download", 22 | HelpName: "download", 23 | Usage: "Download historical data", 24 | Flags: []cli.Flag{ 25 | &cli.StringFlag{ 26 | Name: "pair", 27 | Aliases: []string{"p"}, 28 | Usage: "eg. BTCUSDT", 29 | Required: true, 30 | }, 31 | &cli.IntFlag{ 32 | Name: "days", 33 | Aliases: []string{"d"}, 34 | Usage: "eg. 100 (default 30 days)", 35 | Required: false, 36 | }, 37 | &cli.TimestampFlag{ 38 | Name: "start", 39 | Aliases: []string{"s"}, 40 | Usage: "eg. 2021-12-01", 41 | Layout: "2006-01-02", 42 | Required: false, 43 | }, 44 | &cli.TimestampFlag{ 45 | Name: "end", 46 | Aliases: []string{"e"}, 47 | Usage: "eg. 2020-12-31", 48 | Layout: "2006-01-02", 49 | Required: false, 50 | }, 51 | &cli.StringFlag{ 52 | Name: "timeframe", 53 | Aliases: []string{"t"}, 54 | Usage: "eg. 1h", 55 | Required: true, 56 | }, 57 | &cli.StringFlag{ 58 | Name: "output", 59 | Aliases: []string{"o"}, 60 | Usage: "eg. ./btc.csv", 61 | Required: true, 62 | }, 63 | &cli.BoolFlag{ 64 | Name: "futures", 65 | Aliases: []string{"f"}, 66 | Usage: "true or false", 67 | Value: false, 68 | Required: false, 69 | }, 70 | }, 71 | Action: func(c *cli.Context) error { 72 | var ( 73 | exc service.Feeder 74 | err error 75 | ) 76 | 77 | if c.Bool("futures") { 78 | // fetch data from binance futures 79 | exc, err = exchange.NewBinanceFuture(c.Context) 80 | if err != nil { 81 | return err 82 | } 83 | } else { 84 | // fetch data from binance spot 85 | exc, err = exchange.NewBinance(c.Context) 86 | if err != nil { 87 | return err 88 | } 89 | } 90 | 91 | var options []download.Option 92 | if days := c.Int("days"); days > 0 { 93 | options = append(options, download.WithDays(days)) 94 | } 95 | 96 | start := c.Timestamp("start") 97 | end := c.Timestamp("end") 98 | if start != nil && end != nil && !start.IsZero() && !end.IsZero() { 99 | options = append(options, download.WithInterval(*start, *end)) 100 | } else if start != nil || end != nil { 101 | log.Fatal("START and END must be informed together") 102 | } 103 | 104 | return download.NewDownloader(exc).Download(c.Context, c.String("pair"), 105 | c.String("timeframe"), c.String("output"), options...) 106 | 107 | }, 108 | }, 109 | }, 110 | } 111 | 112 | err := app.Run(os.Args) 113 | if err != nil { 114 | log.Fatal(err) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | informational: true 6 | patch: 7 | default: 8 | informational: true 9 | 10 | ignore: 11 | - "examples" 12 | - "cmd" 13 | - "testdata" -------------------------------------------------------------------------------- /download/download.go: -------------------------------------------------------------------------------- 1 | package download 2 | 3 | import ( 4 | "context" 5 | "encoding/csv" 6 | "os" 7 | "time" 8 | 9 | "github.com/schollz/progressbar/v3" 10 | "github.com/xhit/go-str2duration/v2" 11 | 12 | "github.com/rodrigo-brito/ninjabot/service" 13 | "github.com/rodrigo-brito/ninjabot/tools/log" 14 | ) 15 | 16 | const batchSize = 500 17 | 18 | type Downloader struct { 19 | exchange service.Feeder 20 | } 21 | 22 | func NewDownloader(exchange service.Feeder) Downloader { 23 | return Downloader{ 24 | exchange: exchange, 25 | } 26 | } 27 | 28 | type Parameters struct { 29 | Start time.Time 30 | End time.Time 31 | } 32 | 33 | type Option func(*Parameters) 34 | 35 | func WithInterval(start, end time.Time) Option { 36 | return func(parameters *Parameters) { 37 | parameters.Start = start 38 | parameters.End = end 39 | } 40 | } 41 | 42 | func WithDays(days int) Option { 43 | return func(parameters *Parameters) { 44 | parameters.Start = time.Now().AddDate(0, 0, -days) 45 | parameters.End = time.Now() 46 | } 47 | } 48 | 49 | func candlesCount(start, end time.Time, timeframe string) (int, time.Duration, error) { 50 | totalDuration := end.Sub(start) 51 | interval, err := str2duration.ParseDuration(timeframe) 52 | if err != nil { 53 | return 0, 0, err 54 | } 55 | return int(totalDuration / interval), interval, nil 56 | } 57 | 58 | func (d Downloader) Download(ctx context.Context, pair, timeframe string, output string, options ...Option) error { 59 | recordFile, err := os.Create(output) 60 | if err != nil { 61 | return err 62 | } 63 | defer recordFile.Close() 64 | 65 | now := time.Now() 66 | parameters := &Parameters{ 67 | Start: now.AddDate(0, -1, 0), 68 | End: now, 69 | } 70 | 71 | for _, option := range options { 72 | option(parameters) 73 | } 74 | 75 | parameters.Start = time.Date(parameters.Start.Year(), parameters.Start.Month(), parameters.Start.Day(), 76 | 0, 0, 0, 0, time.UTC) 77 | 78 | if now.Sub(parameters.End) > 0 { 79 | parameters.End = time.Date(parameters.End.Year(), parameters.End.Month(), parameters.End.Day(), 80 | 0, 0, 0, 0, time.UTC) 81 | } else { 82 | parameters.End = now 83 | } 84 | 85 | candlesCount, interval, err := candlesCount(parameters.Start, parameters.End, timeframe) 86 | if err != nil { 87 | return err 88 | } 89 | candlesCount++ 90 | 91 | log.Infof("Downloading %d candles of %s for %s", candlesCount, timeframe, pair) 92 | info := d.exchange.AssetsInfo(pair) 93 | writer := csv.NewWriter(recordFile) 94 | 95 | progressBar := progressbar.Default(int64(candlesCount)) 96 | lostData := 0 97 | isLastLoop := false 98 | 99 | // write headers 100 | err = writer.Write([]string{ 101 | "time", "open", "close", "low", "high", "volume", 102 | }) 103 | if err != nil { 104 | return err 105 | } 106 | 107 | for begin := parameters.Start; begin.Before(parameters.End); begin = begin.Add(interval * batchSize) { 108 | end := begin.Add(interval * batchSize) 109 | if end.Before(parameters.End) { 110 | end = end.Add(-1 * time.Second) 111 | } else { 112 | end = parameters.End 113 | isLastLoop = true 114 | } 115 | 116 | candles, err := d.exchange.CandlesByPeriod(ctx, pair, timeframe, begin, end) 117 | if err != nil { 118 | return err 119 | } 120 | 121 | for _, candle := range candles { 122 | err := writer.Write(candle.ToSlice(info.QuotePrecision)) 123 | if err != nil { 124 | return err 125 | } 126 | } 127 | 128 | countCandles := len(candles) 129 | if !isLastLoop { 130 | lostData += batchSize - countCandles 131 | } 132 | 133 | if err = progressBar.Add(countCandles); err != nil { 134 | log.Warnf("update progresbar fail: %s", err.Error()) 135 | } 136 | } 137 | 138 | if err = progressBar.Close(); err != nil { 139 | log.Warnf("close progresbar fail: %s", err.Error()) 140 | } 141 | 142 | if lostData > 0 { 143 | log.Warnf("%d missing candles", lostData) 144 | } 145 | 146 | writer.Flush() 147 | log.Info("Done!") 148 | return writer.Error() 149 | } 150 | -------------------------------------------------------------------------------- /download/download_test.go: -------------------------------------------------------------------------------- 1 | package download 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/rodrigo-brito/ninjabot/exchange" 10 | "github.com/rodrigo-brito/ninjabot/service" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func TestDownloader_candlesCount(t *testing.T) { 17 | tt := []struct { 18 | start time.Time 19 | end time.Time 20 | timeframe string 21 | interval time.Duration 22 | total int 23 | }{ 24 | {time.Now(), time.Now().AddDate(0, 0, 10), "1d", time.Hour * 24, 10}, 25 | {time.Now(), time.Now().Add(60 * time.Minute), "1m", time.Minute, 60}, 26 | {time.Now(), time.Now().Add(60 * time.Minute), "15m", 15 * time.Minute, 4}, 27 | } 28 | 29 | t.Run("failed attempt", func(t *testing.T) { 30 | _, _, err := candlesCount(tt[0].start, tt[0].end, "batata") 31 | require.Error(t, err) 32 | }) 33 | 34 | t.Run("Success candlesCount", func(t *testing.T) { 35 | for _, tc := range tt { 36 | total, interval, err := candlesCount(tc.start, tc.end, tc.timeframe) 37 | require.NoError(t, err) 38 | assert.Equal(t, tc.total, total) 39 | assert.Equal(t, tc.interval, interval) 40 | } 41 | }) 42 | 43 | } 44 | 45 | func TestDownloader_withInterval(t *testing.T) { 46 | startingParams := []Parameters{ 47 | {Start: time.Now(), End: time.Now().AddDate(0, 0, 10)}, 48 | {Start: time.Now().AddDate(0, 0, 15), End: time.Now().AddDate(0, 0, 25)}, 49 | } 50 | 51 | WithInterval(startingParams[0].Start, startingParams[0].End)(&startingParams[1]) 52 | 53 | assert.Equal(t, startingParams[0], startingParams[1]) 54 | } 55 | 56 | func TestDownloader_download(t *testing.T) { 57 | ctx := context.Background() 58 | tmpFile, err := os.CreateTemp(os.TempDir(), "*.csv") 59 | require.NoError(t, err) 60 | 61 | time, err := time.Parse("2006-01-02", "2021-04-26") 62 | require.NoError(t, err) 63 | 64 | param := Parameters{ 65 | Start: time, 66 | End: time.AddDate(0, 0, 20), 67 | } 68 | 69 | csvFeed, err := exchange.NewCSVFeed( 70 | "1d", 71 | exchange.PairFeed{ 72 | Pair: "BTCUSDT", 73 | File: "../testdata/btc-1d.csv", 74 | Timeframe: "1d", 75 | }) 76 | require.NoError(t, err) 77 | 78 | fakeExchange := struct { 79 | service.Feeder 80 | }{ 81 | Feeder: csvFeed, 82 | } 83 | 84 | downloader := Downloader{fakeExchange} 85 | 86 | t.Run("success", func(t *testing.T) { 87 | err = downloader.Download(ctx, "BTCUSDT", "1d", tmpFile.Name(), WithInterval(param.Start, param.End)) 88 | require.NoError(t, err) 89 | 90 | csvFeed, err := exchange.NewCSVFeed( 91 | "1d", 92 | exchange.PairFeed{ 93 | Pair: "BTCUSDT", 94 | File: "../testdata/btc-1d.csv", 95 | Timeframe: "1d", 96 | }) 97 | require.NoError(t, err) 98 | require.Len(t, csvFeed.CandlePairTimeFrame["BTCUSDT--1d"], 14) 99 | }) 100 | } 101 | -------------------------------------------------------------------------------- /examples/backtesting/backtesting.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/rodrigo-brito/ninjabot" 7 | "github.com/rodrigo-brito/ninjabot/examples/strategies" 8 | "github.com/rodrigo-brito/ninjabot/exchange" 9 | "github.com/rodrigo-brito/ninjabot/plot" 10 | "github.com/rodrigo-brito/ninjabot/plot/indicator" 11 | "github.com/rodrigo-brito/ninjabot/storage" 12 | "github.com/rodrigo-brito/ninjabot/tools/log" 13 | ) 14 | 15 | // This example shows how to use backtesting with NinjaBot 16 | // Backtesting is a simulation of the strategy in historical data (from CSV) 17 | func main() { 18 | ctx := context.Background() 19 | 20 | // bot settings (eg: pairs, telegram, etc) 21 | settings := ninjabot.Settings{ 22 | Pairs: []string{ 23 | "BTCUSDT", 24 | "ETHUSDT", 25 | }, 26 | } 27 | 28 | // initialize your strategy 29 | strategy := new(strategies.CrossEMA) 30 | 31 | // load historical data from CSV files 32 | csvFeed, err := exchange.NewCSVFeed( 33 | strategy.Timeframe(), 34 | exchange.PairFeed{ 35 | Pair: "BTCUSDT", 36 | File: "testdata/btc-1h.csv", 37 | Timeframe: "1h", 38 | }, 39 | exchange.PairFeed{ 40 | Pair: "ETHUSDT", 41 | File: "testdata/eth-1h.csv", 42 | Timeframe: "1h", 43 | }, 44 | ) 45 | if err != nil { 46 | log.Fatal(err) 47 | } 48 | 49 | // initialize a database in memory 50 | storage, err := storage.FromMemory() 51 | if err != nil { 52 | log.Fatal(err) 53 | } 54 | 55 | // create a paper wallet for simulation, initializing with 10.000 USDT 56 | wallet := exchange.NewPaperWallet( 57 | ctx, 58 | "USDT", 59 | exchange.WithPaperAsset("USDT", 10000), 60 | exchange.WithDataFeed(csvFeed), 61 | ) 62 | 63 | // create a chart with indicators from the strategy and a custom additional RSI indicator 64 | chart, err := plot.NewChart( 65 | plot.WithStrategyIndicators(strategy), 66 | plot.WithCustomIndicators( 67 | indicator.RSI(14, "purple"), 68 | ), 69 | plot.WithPaperWallet(wallet), 70 | ) 71 | if err != nil { 72 | log.Fatal(err) 73 | } 74 | 75 | // initializer Ninjabot with the objects created before 76 | bot, err := ninjabot.NewBot( 77 | ctx, 78 | settings, 79 | wallet, 80 | strategy, 81 | ninjabot.WithBacktest(wallet), // Required for Backtest mode 82 | ninjabot.WithStorage(storage), 83 | 84 | // connect bot feed (candle and orders) to the chart 85 | ninjabot.WithCandleSubscription(chart), 86 | ninjabot.WithOrderSubscription(chart), 87 | ninjabot.WithLogLevel(log.WarnLevel), 88 | ) 89 | if err != nil { 90 | log.Fatal(err) 91 | } 92 | 93 | // Initializer simulation 94 | err = bot.Run(ctx) 95 | if err != nil { 96 | log.Fatal(err) 97 | } 98 | 99 | // Print bot results 100 | bot.Summary() 101 | 102 | // Display candlesticks chart in local browser 103 | err = chart.Start() 104 | if err != nil { 105 | log.Fatal(err) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /examples/futuremarket/futures.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "strconv" 8 | 9 | "github.com/rodrigo-brito/ninjabot" 10 | "github.com/rodrigo-brito/ninjabot/examples/strategies" 11 | "github.com/rodrigo-brito/ninjabot/exchange" 12 | ) 13 | 14 | // This example shows how to use futures market with NinjaBot. 15 | func main() { 16 | var ( 17 | ctx = context.Background() 18 | apiKey = os.Getenv("API_KEY") 19 | secretKey = os.Getenv("API_SECRET") 20 | telegramToken = os.Getenv("TELEGRAM_TOKEN") 21 | telegramUser, _ = strconv.Atoi(os.Getenv("TELEGRAM_USER")) 22 | ) 23 | 24 | settings := ninjabot.Settings{ 25 | Pairs: []string{ 26 | "BTCUSDT", 27 | "ETHUSDT", 28 | }, 29 | Telegram: ninjabot.TelegramSettings{ 30 | Enabled: true, 31 | Token: telegramToken, 32 | Users: []int{telegramUser}, 33 | }, 34 | } 35 | 36 | // Initialize your exchange with futures 37 | binance, err := exchange.NewBinanceFuture(ctx, 38 | exchange.WithBinanceFutureCredentials(apiKey, secretKey), 39 | exchange.WithBinanceFutureLeverage("BTCUSDT", 1, exchange.MarginTypeIsolated), 40 | exchange.WithBinanceFutureLeverage("ETHUSDT", 1, exchange.MarginTypeIsolated), 41 | ) 42 | if err != nil { 43 | log.Fatal(err) 44 | } 45 | 46 | // Initialize your strategy and bot 47 | strategy := new(strategies.CrossEMA) 48 | bot, err := ninjabot.NewBot(ctx, settings, binance, strategy) 49 | if err != nil { 50 | log.Fatalln(err) 51 | } 52 | 53 | err = bot.Run(ctx) 54 | if err != nil { 55 | log.Fatalln(err) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /examples/paperwallet/paperwallet.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "strconv" 7 | 8 | "github.com/rodrigo-brito/ninjabot/plot" 9 | "github.com/rodrigo-brito/ninjabot/plot/indicator" 10 | 11 | "github.com/rodrigo-brito/ninjabot" 12 | "github.com/rodrigo-brito/ninjabot/examples/strategies" 13 | "github.com/rodrigo-brito/ninjabot/exchange" 14 | "github.com/rodrigo-brito/ninjabot/storage" 15 | "github.com/rodrigo-brito/ninjabot/tools/log" 16 | ) 17 | 18 | // This example shows how to use NinjaBot with a simulation with a fake exchange 19 | // A peperwallet is a wallet that is not connected to any exchange, it is a simulation with live data (realtime) 20 | func main() { 21 | var ( 22 | ctx = context.Background() 23 | telegramToken = os.Getenv("TELEGRAM_TOKEN") 24 | telegramUser, _ = strconv.Atoi(os.Getenv("TELEGRAM_USER")) 25 | ) 26 | 27 | settings := ninjabot.Settings{ 28 | Pairs: []string{ 29 | "BTCUSDT", 30 | "ETHUSDT", 31 | "BNBUSDT", 32 | "LTCUSDT", 33 | }, 34 | Telegram: ninjabot.TelegramSettings{ 35 | Enabled: telegramToken != "" && telegramUser != 0, 36 | Token: telegramToken, 37 | Users: []int{telegramUser}, 38 | }, 39 | } 40 | 41 | // Use binance for realtime data feed 42 | binance, err := exchange.NewBinance(ctx) 43 | if err != nil { 44 | log.Fatal(err) 45 | } 46 | 47 | // creating a storage to save trades 48 | storage, err := storage.FromMemory() 49 | if err != nil { 50 | log.Fatal(err) 51 | } 52 | 53 | // creating a paper wallet to simulate an exchange waller for fake operataions 54 | // paper wallet is simulation of a real exchange wallet 55 | paperWallet := exchange.NewPaperWallet( 56 | ctx, 57 | "USDT", 58 | exchange.WithPaperFee(0.001, 0.001), 59 | exchange.WithPaperAsset("USDT", 10000), 60 | exchange.WithDataFeed(binance), 61 | ) 62 | 63 | // initializing my strategy 64 | strategy := new(strategies.CrossEMA) 65 | 66 | chart, err := plot.NewChart( 67 | plot.WithCustomIndicators( 68 | indicator.EMA(8, "red"), 69 | indicator.SMA(21, "blue"), 70 | ), 71 | ) 72 | if err != nil { 73 | log.Fatal(err) 74 | } 75 | 76 | // initializer ninjabot 77 | bot, err := ninjabot.NewBot( 78 | ctx, 79 | settings, 80 | paperWallet, 81 | strategy, 82 | ninjabot.WithStorage(storage), 83 | ninjabot.WithPaperWallet(paperWallet), 84 | ninjabot.WithCandleSubscription(chart), 85 | ninjabot.WithOrderSubscription(chart), 86 | ) 87 | if err != nil { 88 | log.Fatal(err) 89 | } 90 | 91 | go func() { 92 | err := chart.Start() 93 | if err != nil { 94 | log.Fatal(err) 95 | } 96 | }() 97 | 98 | err = bot.Run(ctx) 99 | if err != nil { 100 | log.Fatal(err) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /examples/spotmarket/spot.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "strconv" 8 | 9 | "github.com/rodrigo-brito/ninjabot" 10 | "github.com/rodrigo-brito/ninjabot/examples/strategies" 11 | "github.com/rodrigo-brito/ninjabot/exchange" 12 | ) 13 | 14 | // This example shows how to use spot market with NinjaBot in Binance 15 | func main() { 16 | var ( 17 | ctx = context.Background() 18 | apiKey = os.Getenv("API_KEY") 19 | secretKey = os.Getenv("API_SECRET") 20 | telegramToken = os.Getenv("TELEGRAM_TOKEN") 21 | telegramUser, _ = strconv.Atoi(os.Getenv("TELEGRAM_USER")) 22 | ) 23 | 24 | settings := ninjabot.Settings{ 25 | Pairs: []string{ 26 | "BTCUSDT", 27 | "ETHUSDT", 28 | }, 29 | Telegram: ninjabot.TelegramSettings{ 30 | Enabled: true, 31 | Token: telegramToken, 32 | Users: []int{telegramUser}, 33 | }, 34 | } 35 | 36 | // Initialize your exchange 37 | binance, err := exchange.NewBinance(ctx, exchange.WithBinanceCredentials(apiKey, secretKey)) 38 | if err != nil { 39 | log.Fatalln(err) 40 | } 41 | 42 | // Initialize your strategy and bot 43 | strategy := new(strategies.CrossEMA) 44 | bot, err := ninjabot.NewBot(ctx, settings, binance, strategy) 45 | if err != nil { 46 | log.Fatalln(err) 47 | } 48 | 49 | err = bot.Run(ctx) 50 | if err != nil { 51 | log.Fatalln(err) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /examples/strategies/emacross.go: -------------------------------------------------------------------------------- 1 | package strategies 2 | 3 | import ( 4 | "github.com/rodrigo-brito/ninjabot" 5 | "github.com/rodrigo-brito/ninjabot/indicator" 6 | "github.com/rodrigo-brito/ninjabot/service" 7 | "github.com/rodrigo-brito/ninjabot/strategy" 8 | "github.com/rodrigo-brito/ninjabot/tools/log" 9 | ) 10 | 11 | type CrossEMA struct{} 12 | 13 | func (e CrossEMA) Timeframe() string { 14 | return "4h" 15 | } 16 | 17 | func (e CrossEMA) WarmupPeriod() int { 18 | return 22 19 | } 20 | 21 | func (e CrossEMA) Indicators(df *ninjabot.Dataframe) []strategy.ChartIndicator { 22 | df.Metadata["ema8"] = indicator.EMA(df.Close, 8) 23 | df.Metadata["sma21"] = indicator.SMA(df.Close, 21) 24 | 25 | return []strategy.ChartIndicator{ 26 | { 27 | Overlay: true, 28 | GroupName: "MA's", 29 | Time: df.Time, 30 | Metrics: []strategy.IndicatorMetric{ 31 | { 32 | Values: df.Metadata["ema8"], 33 | Name: "EMA 8", 34 | Color: "red", 35 | Style: strategy.StyleLine, 36 | }, 37 | { 38 | Values: df.Metadata["sma21"], 39 | Name: "SMA 21", 40 | Color: "blue", 41 | Style: strategy.StyleLine, 42 | }, 43 | }, 44 | }, 45 | } 46 | } 47 | 48 | func (e *CrossEMA) OnCandle(df *ninjabot.Dataframe, broker service.Broker) { 49 | closePrice := df.Close.Last(0) 50 | 51 | assetPosition, quotePosition, err := broker.Position(df.Pair) 52 | if err != nil { 53 | log.Error(err) 54 | return 55 | } 56 | 57 | if quotePosition >= 10 && // minimum quote position to trade 58 | df.Metadata["ema8"].Crossover(df.Metadata["sma21"]) { // trade signal (EMA8 > SMA21) 59 | 60 | amount := quotePosition / closePrice // calculate amount of asset to buy 61 | _, err := broker.CreateOrderMarket(ninjabot.SideTypeBuy, df.Pair, amount) 62 | if err != nil { 63 | log.Error(err) 64 | } 65 | 66 | return 67 | } 68 | 69 | if assetPosition > 0 && 70 | df.Metadata["ema8"].Crossunder(df.Metadata["sma21"]) { // trade signal (EMA8 < SMA21) 71 | 72 | _, err = broker.CreateOrderMarket(ninjabot.SideTypeSell, df.Pair, assetPosition) 73 | if err != nil { 74 | log.Error(err) 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /examples/strategies/ocosell.go: -------------------------------------------------------------------------------- 1 | package strategies 2 | 3 | import ( 4 | "github.com/markcheno/go-talib" 5 | 6 | "github.com/rodrigo-brito/ninjabot/indicator" 7 | "github.com/rodrigo-brito/ninjabot/model" 8 | "github.com/rodrigo-brito/ninjabot/service" 9 | "github.com/rodrigo-brito/ninjabot/strategy" 10 | "github.com/rodrigo-brito/ninjabot/tools/log" 11 | ) 12 | 13 | type OCOSell struct{} 14 | 15 | func (e OCOSell) Timeframe() string { 16 | return "1d" 17 | } 18 | 19 | func (e OCOSell) WarmupPeriod() int { 20 | return 9 21 | } 22 | 23 | func (e OCOSell) Indicators(df *model.Dataframe) []strategy.ChartIndicator { 24 | df.Metadata["stoch"], df.Metadata["stoch_signal"] = indicator.Stoch( 25 | df.High, 26 | df.Low, 27 | df.Close, 28 | 8, 29 | 3, 30 | talib.SMA, 31 | 3, 32 | talib.SMA, 33 | ) 34 | 35 | return []strategy.ChartIndicator{ 36 | { 37 | Overlay: false, 38 | GroupName: "Stochastic", 39 | Time: df.Time, 40 | Metrics: []strategy.IndicatorMetric{ 41 | { 42 | Values: df.Metadata["stoch"], 43 | Name: "K", 44 | Color: "red", 45 | Style: strategy.StyleLine, 46 | }, 47 | { 48 | Values: df.Metadata["stoch_signal"], 49 | Name: "D", 50 | Color: "blue", 51 | Style: strategy.StyleLine, 52 | }, 53 | }, 54 | }, 55 | } 56 | } 57 | 58 | func (e *OCOSell) OnCandle(df *model.Dataframe, broker service.Broker) { 59 | closePrice := df.Close.Last(0) 60 | log.Info("New Candle = ", df.Pair, df.LastUpdate, closePrice) 61 | 62 | assetPosition, quotePosition, err := broker.Position(df.Pair) 63 | if err != nil { 64 | log.Error(err) 65 | return 66 | } 67 | 68 | buyAmount := 4000.0 69 | if quotePosition > buyAmount && df.Metadata["stoch"].Crossover(df.Metadata["stoch_signal"]) { 70 | size := buyAmount / closePrice 71 | _, err := broker.CreateOrderMarket(model.SideTypeBuy, df.Pair, size) 72 | if err != nil { 73 | log.WithFields(map[string]interface{}{ 74 | "pair": df.Pair, 75 | "side": model.SideTypeBuy, 76 | "close": closePrice, 77 | "asset": assetPosition, 78 | "quote": quotePosition, 79 | "size": size, 80 | }).Error(err) 81 | } 82 | 83 | _, err = broker.CreateOrderOCO(model.SideTypeSell, df.Pair, size, closePrice*1.1, closePrice*0.95, closePrice*0.95) 84 | if err != nil { 85 | log.WithFields(map[string]interface{}{ 86 | "pair": df.Pair, 87 | "side": model.SideTypeBuy, 88 | "close": closePrice, 89 | "asset": assetPosition, 90 | "quote": quotePosition, 91 | "size": size, 92 | }).Error(err) 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /examples/strategies/trailingstop.go: -------------------------------------------------------------------------------- 1 | package strategies 2 | 3 | import ( 4 | "github.com/rodrigo-brito/ninjabot" 5 | "github.com/rodrigo-brito/ninjabot/indicator" 6 | "github.com/rodrigo-brito/ninjabot/model" 7 | "github.com/rodrigo-brito/ninjabot/service" 8 | "github.com/rodrigo-brito/ninjabot/strategy" 9 | "github.com/rodrigo-brito/ninjabot/tools" 10 | "github.com/rodrigo-brito/ninjabot/tools/log" 11 | ) 12 | 13 | type trailing struct { 14 | trailingStop map[string]*tools.TrailingStop 15 | scheduler map[string]*tools.Scheduler 16 | } 17 | 18 | func NewTrailing(pairs []string) strategy.HighFrequencyStrategy { 19 | strategy := &trailing{ 20 | trailingStop: make(map[string]*tools.TrailingStop), 21 | scheduler: make(map[string]*tools.Scheduler), 22 | } 23 | 24 | for _, pair := range pairs { 25 | strategy.trailingStop[pair] = tools.NewTrailingStop() 26 | strategy.scheduler[pair] = tools.NewScheduler(pair) 27 | } 28 | 29 | return strategy 30 | } 31 | 32 | func (t trailing) Timeframe() string { 33 | return "4h" 34 | } 35 | 36 | func (t trailing) WarmupPeriod() int { 37 | return 21 38 | } 39 | 40 | func (t trailing) Indicators(df *model.Dataframe) []strategy.ChartIndicator { 41 | df.Metadata["ema_fast"] = indicator.EMA(df.Close, 8) 42 | df.Metadata["sma_slow"] = indicator.SMA(df.Close, 21) 43 | 44 | return nil 45 | } 46 | 47 | func (t trailing) OnCandle(df *model.Dataframe, broker service.Broker) { 48 | asset, quote, err := broker.Position(df.Pair) 49 | if err != nil { 50 | log.Error(err) 51 | return 52 | } 53 | 54 | if quote > 10.0 && // enough cash? 55 | asset*df.Close.Last(0) < 10 && // without position yet 56 | df.Metadata["ema_fast"].Crossover(df.Metadata["sma_slow"]) { 57 | _, err = broker.CreateOrderMarketQuote(ninjabot.SideTypeBuy, df.Pair, quote) 58 | if err != nil { 59 | log.Error(err) 60 | return 61 | } 62 | 63 | t.trailingStop[df.Pair].Start(df.Close.Last(0), df.Low.Last(0)) 64 | 65 | return 66 | } 67 | } 68 | 69 | func (t trailing) OnPartialCandle(df *model.Dataframe, broker service.Broker) { 70 | if trailing := t.trailingStop[df.Pair]; trailing != nil && trailing.Update(df.Close.Last(0)) { 71 | asset, _, err := broker.Position(df.Pair) 72 | if err != nil { 73 | log.Error(err) 74 | return 75 | } 76 | 77 | if asset > 0 { 78 | _, err = broker.CreateOrderMarket(ninjabot.SideTypeSell, df.Pair, asset) 79 | if err != nil { 80 | log.Error(err) 81 | return 82 | } 83 | trailing.Stop() 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /examples/strategies/turtle.go: -------------------------------------------------------------------------------- 1 | package strategies 2 | 3 | import ( 4 | "github.com/rodrigo-brito/ninjabot" 5 | "github.com/rodrigo-brito/ninjabot/indicator" 6 | "github.com/rodrigo-brito/ninjabot/service" 7 | "github.com/rodrigo-brito/ninjabot/strategy" 8 | "github.com/rodrigo-brito/ninjabot/tools/log" 9 | ) 10 | 11 | // https://www.investopedia.com/articles/trading/08/turtle-trading.asp 12 | type Turtle struct{} 13 | 14 | func (e Turtle) Timeframe() string { 15 | return "4h" 16 | } 17 | 18 | func (e Turtle) WarmupPeriod() int { 19 | return 40 20 | } 21 | 22 | func (e Turtle) Indicators(df *ninjabot.Dataframe) []strategy.ChartIndicator { 23 | df.Metadata["max40"] = indicator.Max(df.Close, 40) 24 | df.Metadata["low20"] = indicator.Min(df.Close, 20) 25 | 26 | return nil 27 | } 28 | 29 | func (e *Turtle) OnCandle(df *ninjabot.Dataframe, broker service.Broker) { 30 | closePrice := df.Close.Last(0) 31 | highest := df.Metadata["max40"].Last(0) 32 | lowest := df.Metadata["low20"].Last(0) 33 | 34 | assetPosition, quotePosition, err := broker.Position(df.Pair) 35 | if err != nil { 36 | log.Error(err) 37 | return 38 | } 39 | 40 | // If position already open wait till it will be closed 41 | if assetPosition == 0 && closePrice >= highest { 42 | _, err := broker.CreateOrderMarketQuote(ninjabot.SideTypeBuy, df.Pair, quotePosition/2) 43 | if err != nil { 44 | log.Error(err) 45 | } 46 | return 47 | } 48 | 49 | if assetPosition > 0 && closePrice <= lowest { 50 | _, err := broker.CreateOrderMarket(ninjabot.SideTypeSell, df.Pair, assetPosition) 51 | if err != nil { 52 | log.Error(err) 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /exchange/binance_test.go: -------------------------------------------------------------------------------- 1 | package exchange 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | 9 | "github.com/rodrigo-brito/ninjabot/model" 10 | ) 11 | 12 | func TestFormatQuantity(t *testing.T) { 13 | binance := Binance{assetsInfo: map[string]model.AssetInfo{ 14 | "BTCUSDT": { 15 | StepSize: 0.00001000, 16 | TickSize: 0.00001000, 17 | BaseAssetPrecision: 5, 18 | QuotePrecision: 5, 19 | }, 20 | "BATUSDT": { 21 | StepSize: 0.01, 22 | TickSize: 0.01, 23 | BaseAssetPrecision: 2, 24 | QuotePrecision: 2, 25 | }, 26 | }} 27 | 28 | tt := []struct { 29 | pair string 30 | quantity float64 31 | expected string 32 | }{ 33 | {"BTCUSDT", 1.1, "1.1"}, 34 | {"BTCUSDT", 11, "11"}, 35 | {"BTCUSDT", 11, "11"}, 36 | {"BTCUSDT", 1.1111111111, "1.11111"}, 37 | {"BTCUSDT", 1.9999999999999, "1.99999"}, 38 | {"BTCUSDT", 1111111.1111111111, "1111111.11111"}, 39 | {"BATUSDT", 111.111, "111.11"}, 40 | {"BATUSDT", 9.9999999999, "9.99"}, 41 | {"BATUSDT", 9.9999999999, "9.99"}, 42 | {"BATUSDT", 10, "10"}, 43 | {"BATUSDT", 10.11111, "10.11"}, 44 | {"BATUSDT", 0.01, "0.01"}, 45 | } 46 | 47 | for _, tc := range tt { 48 | t.Run(fmt.Sprintf("given %f %s", tc.quantity, tc.pair), func(t *testing.T) { 49 | require.Equal(t, tc.expected, binance.formatQuantity(tc.pair, tc.quantity)) 50 | require.Equal(t, tc.expected, binance.formatPrice(tc.pair, tc.quantity)) 51 | }) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /exchange/csvfeed.go: -------------------------------------------------------------------------------- 1 | package exchange 2 | 3 | import ( 4 | "context" 5 | "encoding/csv" 6 | "errors" 7 | "fmt" 8 | "math" 9 | "os" 10 | "strconv" 11 | "time" 12 | 13 | "github.com/samber/lo" 14 | "github.com/xhit/go-str2duration/v2" 15 | 16 | "github.com/rodrigo-brito/ninjabot/model" 17 | ) 18 | 19 | var ErrInsufficientData = errors.New("insufficient data") 20 | 21 | type PairFeed struct { 22 | Pair string 23 | File string 24 | Timeframe string 25 | HeikinAshi bool 26 | } 27 | 28 | type CSVFeed struct { 29 | Feeds map[string]PairFeed 30 | CandlePairTimeFrame map[string][]model.Candle 31 | } 32 | 33 | func (c CSVFeed) AssetsInfo(pair string) model.AssetInfo { 34 | asset, quote := SplitAssetQuote(pair) 35 | return model.AssetInfo{ 36 | BaseAsset: asset, 37 | QuoteAsset: quote, 38 | MaxPrice: math.MaxFloat64, 39 | MaxQuantity: math.MaxFloat64, 40 | StepSize: 0.00000001, 41 | TickSize: 0.00000001, 42 | QuotePrecision: 8, 43 | BaseAssetPrecision: 8, 44 | } 45 | } 46 | 47 | func parseHeaders(headers []string) (index map[string]int, additional []string, ok bool) { 48 | headerMap := map[string]int{ 49 | "time": 0, "open": 1, "close": 2, "low": 3, "high": 4, "volume": 5, 50 | } 51 | 52 | _, err := strconv.Atoi(headers[0]) 53 | if err == nil { 54 | return headerMap, additional, false 55 | } 56 | 57 | for index, h := range headers { 58 | if _, ok := headerMap[h]; !ok { 59 | additional = append(additional, h) 60 | } 61 | headerMap[h] = index 62 | } 63 | 64 | return headerMap, additional, true 65 | } 66 | 67 | // NewCSVFeed creates a new data feed from CSV files and resample 68 | func NewCSVFeed(targetTimeframe string, feeds ...PairFeed) (*CSVFeed, error) { 69 | csvFeed := &CSVFeed{ 70 | Feeds: make(map[string]PairFeed), 71 | CandlePairTimeFrame: make(map[string][]model.Candle), 72 | } 73 | 74 | for _, feed := range feeds { 75 | csvFeed.Feeds[feed.Pair] = feed 76 | 77 | csvFile, err := os.Open(feed.File) 78 | if err != nil { 79 | return nil, err 80 | } 81 | 82 | csvLines, err := csv.NewReader(csvFile).ReadAll() 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | var candles []model.Candle 88 | ha := model.NewHeikinAshi() 89 | 90 | // map each header label with its index 91 | headerMap, additionalHeaders, hasCustomHeaders := parseHeaders(csvLines[0]) 92 | if hasCustomHeaders { 93 | csvLines = csvLines[1:] 94 | } 95 | 96 | for _, line := range csvLines { 97 | timestamp, err := strconv.Atoi(line[headerMap["time"]]) 98 | if err != nil { 99 | return nil, err 100 | } 101 | 102 | candle := model.Candle{ 103 | Time: time.Unix(int64(timestamp), 0).UTC(), 104 | UpdatedAt: time.Unix(int64(timestamp), 0).UTC(), 105 | Pair: feed.Pair, 106 | Complete: true, 107 | } 108 | 109 | candle.Open, err = strconv.ParseFloat(line[headerMap["open"]], 64) 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | candle.Close, err = strconv.ParseFloat(line[headerMap["close"]], 64) 115 | if err != nil { 116 | return nil, err 117 | } 118 | 119 | candle.Low, err = strconv.ParseFloat(line[headerMap["low"]], 64) 120 | if err != nil { 121 | return nil, err 122 | } 123 | 124 | candle.High, err = strconv.ParseFloat(line[headerMap["high"]], 64) 125 | if err != nil { 126 | return nil, err 127 | } 128 | 129 | candle.Volume, err = strconv.ParseFloat(line[headerMap["volume"]], 64) 130 | if err != nil { 131 | return nil, err 132 | } 133 | 134 | if hasCustomHeaders { 135 | candle.Metadata = make(map[string]float64) 136 | for _, header := range additionalHeaders { 137 | candle.Metadata[header], err = strconv.ParseFloat(line[headerMap[header]], 64) 138 | if err != nil { 139 | return nil, err 140 | } 141 | } 142 | } 143 | 144 | if feed.HeikinAshi { 145 | candle = candle.ToHeikinAshi(ha) 146 | } 147 | 148 | candles = append(candles, candle) 149 | } 150 | 151 | csvFeed.CandlePairTimeFrame[csvFeed.feedTimeframeKey(feed.Pair, feed.Timeframe)] = candles 152 | 153 | err = csvFeed.resample(feed.Pair, feed.Timeframe, targetTimeframe) 154 | if err != nil { 155 | return nil, err 156 | } 157 | } 158 | 159 | return csvFeed, nil 160 | } 161 | 162 | func (c CSVFeed) feedTimeframeKey(pair, timeframe string) string { 163 | return fmt.Sprintf("%s--%s", pair, timeframe) 164 | } 165 | 166 | func (c CSVFeed) LastQuote(_ context.Context, _ string) (float64, error) { 167 | return 0, errors.New("invalid operation") 168 | } 169 | 170 | func (c *CSVFeed) Limit(duration time.Duration) *CSVFeed { 171 | for pair, candles := range c.CandlePairTimeFrame { 172 | start := candles[len(candles)-1].Time.Add(-duration) 173 | c.CandlePairTimeFrame[pair] = lo.Filter(candles, func(candle model.Candle, _ int) bool { 174 | return candle.Time.After(start) 175 | }) 176 | } 177 | return c 178 | } 179 | 180 | func isFistCandlePeriod(t time.Time, fromTimeframe, targetTimeframe string) (bool, error) { 181 | fromDuration, err := str2duration.ParseDuration(fromTimeframe) 182 | if err != nil { 183 | return false, err 184 | } 185 | 186 | prev := t.Add(-fromDuration).UTC() 187 | 188 | return isLastCandlePeriod(prev, fromTimeframe, targetTimeframe) 189 | } 190 | 191 | func isLastCandlePeriod(t time.Time, fromTimeframe, targetTimeframe string) (bool, error) { 192 | if fromTimeframe == targetTimeframe { 193 | return true, nil 194 | } 195 | 196 | fromDuration, err := str2duration.ParseDuration(fromTimeframe) 197 | if err != nil { 198 | return false, err 199 | } 200 | 201 | next := t.Add(fromDuration).UTC() 202 | 203 | switch targetTimeframe { 204 | case "1m": 205 | return next.Second()%60 == 0, nil 206 | case "5m": 207 | return next.Minute()%5 == 0, nil 208 | case "10m": 209 | return next.Minute()%10 == 0, nil 210 | case "15m": 211 | return next.Minute()%15 == 0, nil 212 | case "30m": 213 | return next.Minute()%30 == 0, nil 214 | case "1h": 215 | return next.Minute()%60 == 0, nil 216 | case "2h": 217 | return next.Minute() == 0 && next.Hour()%2 == 0, nil 218 | case "4h": 219 | return next.Minute() == 0 && next.Hour()%4 == 0, nil 220 | case "12h": 221 | return next.Minute() == 0 && next.Hour()%12 == 0, nil 222 | case "1d": 223 | return next.Minute() == 0 && next.Hour()%24 == 0, nil 224 | case "1w": 225 | return next.Minute() == 0 && next.Hour()%24 == 0 && next.Weekday() == time.Sunday, nil 226 | } 227 | 228 | return false, fmt.Errorf("invalid timeframe: %s", targetTimeframe) 229 | } 230 | 231 | func (c *CSVFeed) resample(pair, sourceTimeframe, targetTimeframe string) error { 232 | sourceKey := c.feedTimeframeKey(pair, sourceTimeframe) 233 | targetKey := c.feedTimeframeKey(pair, targetTimeframe) 234 | 235 | var i int 236 | for ; i < len(c.CandlePairTimeFrame[sourceKey]); i++ { 237 | if ok, err := isFistCandlePeriod(c.CandlePairTimeFrame[sourceKey][i].Time, sourceTimeframe, 238 | targetTimeframe); err != nil { 239 | return err 240 | } else if ok { 241 | break 242 | } 243 | } 244 | 245 | candles := make([]model.Candle, 0) 246 | for ; i < len(c.CandlePairTimeFrame[sourceKey]); i++ { 247 | candle := c.CandlePairTimeFrame[sourceKey][i] 248 | if last, err := isLastCandlePeriod(candle.Time, sourceTimeframe, targetTimeframe); err != nil { 249 | return err 250 | } else if last { 251 | candle.Complete = true 252 | } else { 253 | candle.Complete = false 254 | } 255 | 256 | lastIndex := len(candles) - 1 257 | if lastIndex >= 0 && !candles[lastIndex].Complete { 258 | candle.Time = candles[lastIndex].Time 259 | candle.Open = candles[lastIndex].Open 260 | candle.High = math.Max(candles[lastIndex].High, candle.High) 261 | candle.Low = math.Min(candles[lastIndex].Low, candle.Low) 262 | candle.Volume += candles[lastIndex].Volume 263 | } 264 | candles = append(candles, candle) 265 | } 266 | 267 | // remove last candle if not complete 268 | if !candles[len(candles)-1].Complete { 269 | candles = candles[:len(candles)-1] 270 | } 271 | 272 | c.CandlePairTimeFrame[targetKey] = candles 273 | 274 | return nil 275 | } 276 | 277 | func (c CSVFeed) CandlesByPeriod(_ context.Context, pair, timeframe string, 278 | start, end time.Time) ([]model.Candle, error) { 279 | 280 | key := c.feedTimeframeKey(pair, timeframe) 281 | candles := make([]model.Candle, 0) 282 | for _, candle := range c.CandlePairTimeFrame[key] { 283 | if candle.Time.Before(start) || candle.Time.After(end) { 284 | continue 285 | } 286 | candles = append(candles, candle) 287 | } 288 | return candles, nil 289 | } 290 | 291 | func (c *CSVFeed) CandlesByLimit(_ context.Context, pair, timeframe string, limit int) ([]model.Candle, error) { 292 | var result []model.Candle 293 | key := c.feedTimeframeKey(pair, timeframe) 294 | if len(c.CandlePairTimeFrame[key]) < limit { 295 | return nil, fmt.Errorf("%w: %s", ErrInsufficientData, pair) 296 | } 297 | result, c.CandlePairTimeFrame[key] = c.CandlePairTimeFrame[key][:limit], c.CandlePairTimeFrame[key][limit:] 298 | return result, nil 299 | } 300 | 301 | func (c CSVFeed) CandlesSubscription(_ context.Context, pair, timeframe string) (chan model.Candle, chan error) { 302 | ccandle := make(chan model.Candle) 303 | cerr := make(chan error) 304 | key := c.feedTimeframeKey(pair, timeframe) 305 | go func() { 306 | for _, candle := range c.CandlePairTimeFrame[key] { 307 | ccandle <- candle 308 | } 309 | close(ccandle) 310 | close(cerr) 311 | }() 312 | return ccandle, cerr 313 | } 314 | -------------------------------------------------------------------------------- /exchange/csvfeed_test.go: -------------------------------------------------------------------------------- 1 | package exchange 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestNewCSVFeed(t *testing.T) { 14 | t.Run("no header", func(t *testing.T) { 15 | feed, err := NewCSVFeed("1d", PairFeed{ 16 | Timeframe: "1d", 17 | Pair: "BTCUSDT", 18 | File: "../testdata/btc-1d.csv", 19 | }) 20 | 21 | candle := feed.CandlePairTimeFrame["BTCUSDT--1d"][0] 22 | require.NoError(t, err) 23 | require.Len(t, feed.CandlePairTimeFrame["BTCUSDT--1d"], 14) 24 | require.Equal(t, "2021-04-26 00:00:00", candle.Time.UTC().Format("2006-01-02 15:04:05")) 25 | require.Equal(t, 49066.76, candle.Open) 26 | require.Equal(t, 54001.39, candle.Close) 27 | require.Equal(t, 48753.44, candle.Low) 28 | require.Equal(t, 54356.62, candle.High) 29 | require.Equal(t, 86310.8, candle.Volume) 30 | }) 31 | 32 | t.Run("with header and custom data", func(t *testing.T) { 33 | feed, err := NewCSVFeed("1d", PairFeed{ 34 | Timeframe: "1d", 35 | Pair: "BTCUSDT", 36 | File: "../testdata/btc-1d-header.csv", 37 | }) 38 | require.NoError(t, err) 39 | 40 | candle := feed.CandlePairTimeFrame["BTCUSDT--1d"][0] 41 | require.Len(t, feed.CandlePairTimeFrame["BTCUSDT--1d"], 14) 42 | require.Equal(t, "2021-04-26 00:00:00", candle.Time.UTC().Format("2006-01-02 15:04:05")) 43 | require.Equal(t, 49066.76, candle.Open) 44 | require.Equal(t, 54001.39, candle.Close) 45 | require.Equal(t, 48753.44, candle.Low) 46 | require.Equal(t, 54356.62, candle.High) 47 | require.Equal(t, 86310.8, candle.Volume) 48 | require.Equal(t, 1.1, candle.Metadata["lsr"]) 49 | }) 50 | } 51 | 52 | func TestCSVFeed_CandlesByLimit(t *testing.T) { 53 | feed, err := NewCSVFeed("1d", PairFeed{ 54 | Timeframe: "1d", 55 | Pair: "BTCUSDT", 56 | File: "../testdata/btc-1d.csv", 57 | }) 58 | require.NoError(t, err) 59 | candles, err := feed.CandlesByLimit(context.Background(), "BTCUSDT", "1d", 1) 60 | require.Nil(t, err) 61 | require.Len(t, candles, 1) 62 | require.Equal(t, "2021-04-26 00:00:00", candles[0].Time.UTC().Format("2006-01-02 15:04:05")) 63 | 64 | // should remove the candle from array 65 | require.Len(t, feed.CandlePairTimeFrame["BTCUSDT--1d"], 13) 66 | candle := feed.CandlePairTimeFrame["BTCUSDT--1d"][0] 67 | require.Equal(t, "2021-04-27 00:00:00", candle.Time.UTC().Format("2006-01-02 15:04:05")) 68 | } 69 | 70 | func TestCSVFeed_resample(t *testing.T) { 71 | t.Run("1h to 1d", func(t *testing.T) { 72 | feed, err := NewCSVFeed( 73 | "1d", 74 | PairFeed{ 75 | Timeframe: "1h", 76 | Pair: "BTCUSDT", 77 | File: "../testdata/btc-1h-2021-05-13.csv", 78 | }) 79 | require.NoError(t, err) 80 | require.Len(t, feed.CandlePairTimeFrame["BTCUSDT--1d"], 24) 81 | require.Len(t, feed.CandlePairTimeFrame["BTCUSDT--1h"], 24) 82 | 83 | for _, candle := range feed.CandlePairTimeFrame["BTCUSDT--1d"][:23] { 84 | require.False(t, candle.Complete) 85 | } 86 | 87 | last := feed.CandlePairTimeFrame["BTCUSDT--1d"][23] 88 | require.Equal(t, int64(1620864000), last.Time.UTC().Unix()) // 13 May 2021 00:00:00 89 | 90 | assert.Equal(t, 49537.15, last.Open) 91 | assert.Equal(t, 49670.97, last.Close) 92 | assert.Equal(t, 46000.00, last.Low) 93 | assert.Equal(t, 51367.19, last.High) 94 | assert.Equal(t, 147332.0, last.Volume) 95 | assert.True(t, last.Complete) 96 | 97 | // load feed with 180 days witch candles of 1h 98 | feed, err = NewCSVFeed( 99 | "1d", 100 | PairFeed{ 101 | Timeframe: "1h", 102 | Pair: "BTCUSDT", 103 | File: "../testdata/btc-1h.csv", 104 | }) 105 | require.NoError(t, err) 106 | 107 | totalComplete := 0 108 | for _, candle := range feed.CandlePairTimeFrame["BTCUSDT--1d"] { 109 | if candle.Time.Hour() == 23 { 110 | require.True(t, true) 111 | } 112 | if candle.Complete { 113 | totalComplete++ 114 | } 115 | } 116 | require.Equal(t, 180, totalComplete) 117 | }) 118 | 119 | t.Run("invalid timeframe", func(t *testing.T) { 120 | feed, err := NewCSVFeed( 121 | "1d", 122 | PairFeed{ 123 | Timeframe: "invalid", 124 | Pair: "BTCUSDT", 125 | File: "../testdata/btc-1h-2021-05-13.csv", 126 | }) 127 | require.Error(t, err) 128 | require.Nil(t, feed) 129 | }) 130 | } 131 | 132 | func TestIsLastCandlePeriod(t *testing.T) { 133 | t.Run("valid", func(t *testing.T) { 134 | tt := []struct { 135 | sourceTimeFrame string 136 | targetTimeFrame string 137 | time time.Time 138 | last bool 139 | }{ 140 | {"1s", "1m", time.Date(2021, 1, 1, 23, 59, 59, 0, time.UTC), true}, 141 | {"1h", "1h", time.Date(2021, 1, 1, 23, 59, 0, 0, time.UTC), true}, 142 | {"1m", "1d", time.Date(2021, 1, 1, 23, 59, 0, 0, time.UTC), true}, 143 | {"1m", "1d", time.Date(2021, 1, 1, 23, 58, 0, 0, time.UTC), false}, 144 | {"1h", "1d", time.Date(2021, 1, 1, 23, 00, 0, 0, time.UTC), true}, 145 | {"1h", "1d", time.Date(2021, 1, 1, 22, 00, 0, 0, time.UTC), false}, 146 | {"1m", "5m", time.Date(2021, 1, 1, 0, 4, 0, 0, time.UTC), true}, 147 | {"1m", "5m", time.Date(2021, 1, 1, 0, 1, 0, 0, time.UTC), false}, 148 | {"1m", "10m", time.Date(2021, 1, 1, 0, 9, 0, 0, time.UTC), true}, 149 | {"1m", "15m", time.Date(2021, 1, 1, 0, 14, 0, 0, time.UTC), true}, 150 | {"1m", "15m", time.Date(2021, 1, 1, 0, 13, 0, 0, time.UTC), false}, 151 | {"1h", "1w", time.Date(2021, 1, 2, 23, 00, 0, 0, time.UTC), true}, 152 | {"1m", "30m", time.Date(2021, 1, 2, 0, 29, 0, 0, time.UTC), true}, 153 | {"1m", "1h", time.Date(2021, 1, 2, 0, 59, 0, 0, time.UTC), true}, 154 | {"1m", "2h", time.Date(2021, 1, 2, 1, 59, 0, 0, time.UTC), true}, 155 | {"1m", "4h", time.Date(2021, 1, 2, 3, 59, 0, 0, time.UTC), true}, 156 | {"1m", "12h", time.Date(2021, 1, 2, 23, 59, 0, 0, time.UTC), true}, 157 | {"1d", "1w", time.Date(2021, 1, 2, 0, 0, 0, 0, time.UTC), true}, 158 | } 159 | 160 | for _, tc := range tt { 161 | t.Run(fmt.Sprintf("%s to %s", tc.sourceTimeFrame, tc.targetTimeFrame), func(t *testing.T) { 162 | last, err := isLastCandlePeriod(tc.time, tc.sourceTimeFrame, tc.targetTimeFrame) 163 | require.NoError(t, err) 164 | require.Equal(t, tc.last, last) 165 | }) 166 | } 167 | }) 168 | 169 | t.Run("invalid source", func(t *testing.T) { 170 | last, err := isLastCandlePeriod(time.Now(), "invalid", "1h") 171 | require.Error(t, err) 172 | require.False(t, last) 173 | }) 174 | 175 | t.Run("not supported interval", func(t *testing.T) { 176 | last, err := isLastCandlePeriod(time.Now(), "1d", "1y") 177 | require.EqualError(t, err, "invalid timeframe: 1y") 178 | require.False(t, last) 179 | }) 180 | } 181 | 182 | func TestIsFistCandlePeriod(t *testing.T) { 183 | t.Run("valid", func(t *testing.T) { 184 | tt := []struct { 185 | sourceTimeFrame string 186 | targetTimeFrame string 187 | time time.Time 188 | last bool 189 | }{ 190 | {"1d", "1w", time.Date(2021, 11, 6, 0, 0, 0, 0, time.UTC), false}, // sunday 191 | {"1d", "1w", time.Date(2021, 11, 7, 0, 0, 0, 0, time.UTC), true}, // monday 192 | {"1d", "1w", time.Date(2021, 11, 8, 0, 0, 0, 0, time.UTC), false}, // monday 193 | {"1h", "1d", time.Date(2021, 11, 8, 0, 0, 0, 0, time.UTC), true}, // monday 194 | {"1h", "1d", time.Date(2021, 11, 8, 1, 0, 0, 0, time.UTC), false}, // monday 195 | } 196 | 197 | for _, tc := range tt { 198 | t.Run(fmt.Sprintf("%s to %s", tc.sourceTimeFrame, tc.targetTimeFrame), func(t *testing.T) { 199 | first, err := isFistCandlePeriod(tc.time, tc.sourceTimeFrame, tc.targetTimeFrame) 200 | require.NoError(t, err) 201 | require.Equal(t, tc.last, first) 202 | }) 203 | } 204 | }) 205 | 206 | t.Run("invalid source", func(t *testing.T) { 207 | last, err := isFistCandlePeriod(time.Now(), "invalid", "1h") 208 | require.Error(t, err) 209 | require.False(t, last) 210 | }) 211 | 212 | t.Run("not supported interval", func(t *testing.T) { 213 | last, err := isFistCandlePeriod(time.Now(), "1d", "1y") 214 | require.EqualError(t, err, "invalid timeframe: 1y") 215 | require.False(t, last) 216 | }) 217 | } 218 | -------------------------------------------------------------------------------- /exchange/exchange.go: -------------------------------------------------------------------------------- 1 | package exchange 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | "sync" 9 | 10 | "github.com/StudioSol/set" 11 | 12 | "github.com/rodrigo-brito/ninjabot/model" 13 | "github.com/rodrigo-brito/ninjabot/service" 14 | "github.com/rodrigo-brito/ninjabot/tools/log" 15 | ) 16 | 17 | var ( 18 | ErrInvalidQuantity = errors.New("invalid quantity") 19 | ErrInsufficientFunds = errors.New("insufficient funds or locked") 20 | ErrInvalidAsset = errors.New("invalid asset") 21 | ) 22 | 23 | type DataFeed struct { 24 | Data chan model.Candle 25 | Err chan error 26 | } 27 | 28 | type DataFeedSubscription struct { 29 | exchange service.Exchange 30 | Feeds *set.LinkedHashSetString 31 | DataFeeds map[string]*DataFeed 32 | SubscriptionsByDataFeed map[string][]Subscription 33 | } 34 | 35 | type Subscription struct { 36 | onCandleClose bool 37 | consumer DataFeedConsumer 38 | } 39 | 40 | type OrderError struct { 41 | Err error 42 | Pair string 43 | Quantity float64 44 | } 45 | 46 | func (o *OrderError) Error() string { 47 | return fmt.Sprintf("order error: %v", o.Err) 48 | } 49 | 50 | type DataFeedConsumer func(model.Candle) 51 | 52 | func NewDataFeed(exchange service.Exchange) *DataFeedSubscription { 53 | return &DataFeedSubscription{ 54 | exchange: exchange, 55 | Feeds: set.NewLinkedHashSetString(), 56 | DataFeeds: make(map[string]*DataFeed), 57 | SubscriptionsByDataFeed: make(map[string][]Subscription), 58 | } 59 | } 60 | 61 | func (d *DataFeedSubscription) feedKey(pair, timeframe string) string { 62 | return fmt.Sprintf("%s--%s", pair, timeframe) 63 | } 64 | 65 | func (d *DataFeedSubscription) pairTimeframeFromKey(key string) (pair, timeframe string) { 66 | parts := strings.Split(key, "--") 67 | return parts[0], parts[1] 68 | } 69 | 70 | func (d *DataFeedSubscription) Subscribe(pair, timeframe string, consumer DataFeedConsumer, onCandleClose bool) { 71 | key := d.feedKey(pair, timeframe) 72 | d.Feeds.Add(key) 73 | d.SubscriptionsByDataFeed[key] = append(d.SubscriptionsByDataFeed[key], Subscription{ 74 | onCandleClose: onCandleClose, 75 | consumer: consumer, 76 | }) 77 | } 78 | 79 | func (d *DataFeedSubscription) Preload(pair, timeframe string, candles []model.Candle) { 80 | log.Infof("[SETUP] preloading %d candles for %s-%s", len(candles), pair, timeframe) 81 | key := d.feedKey(pair, timeframe) 82 | for _, candle := range candles { 83 | if !candle.Complete { 84 | continue 85 | } 86 | 87 | for _, subscription := range d.SubscriptionsByDataFeed[key] { 88 | subscription.consumer(candle) 89 | } 90 | } 91 | } 92 | 93 | func (d *DataFeedSubscription) Connect() { 94 | log.Infof("Connecting to the exchange.") 95 | for feed := range d.Feeds.Iter() { 96 | pair, timeframe := d.pairTimeframeFromKey(feed) 97 | ccandle, cerr := d.exchange.CandlesSubscription(context.Background(), pair, timeframe) 98 | d.DataFeeds[feed] = &DataFeed{ 99 | Data: ccandle, 100 | Err: cerr, 101 | } 102 | } 103 | } 104 | 105 | func (d *DataFeedSubscription) Start(loadSync bool) { 106 | d.Connect() 107 | wg := new(sync.WaitGroup) 108 | for key, feed := range d.DataFeeds { 109 | wg.Add(1) 110 | go func(key string, feed *DataFeed) { 111 | for { 112 | select { 113 | case candle, ok := <-feed.Data: 114 | if !ok { 115 | wg.Done() 116 | return 117 | } 118 | for _, subscription := range d.SubscriptionsByDataFeed[key] { 119 | if subscription.onCandleClose && !candle.Complete { 120 | continue 121 | } 122 | subscription.consumer(candle) 123 | } 124 | case err := <-feed.Err: 125 | if err != nil { 126 | log.Error("dataFeedSubscription/start: ", err) 127 | } 128 | } 129 | } 130 | }(key, feed) 131 | } 132 | 133 | log.Infof("Data feed connected.") 134 | if loadSync { 135 | wg.Wait() 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /exchange/pairs.go: -------------------------------------------------------------------------------- 1 | package exchange 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | "encoding/json" 7 | "fmt" 8 | "os" 9 | 10 | "github.com/adshao/go-binance/v2" 11 | "github.com/adshao/go-binance/v2/futures" 12 | ) 13 | 14 | type AssetQuote struct { 15 | Quote string 16 | Asset string 17 | } 18 | 19 | var ( 20 | //go:embed pairs.json 21 | pairs []byte 22 | pairAssetQuoteMap = make(map[string]AssetQuote) 23 | ) 24 | 25 | func init() { 26 | err := json.Unmarshal(pairs, &pairAssetQuoteMap) 27 | if err != nil { 28 | panic(err) 29 | } 30 | } 31 | 32 | func SplitAssetQuote(pair string) (asset string, quote string) { 33 | data := pairAssetQuoteMap[pair] 34 | return data.Asset, data.Quote 35 | } 36 | 37 | func updatePairsFile() error { 38 | client := binance.NewClient("", "") 39 | sportInfo, err := client.NewExchangeInfoService().Do(context.Background()) 40 | if err != nil { 41 | return fmt.Errorf("failed to get exchange info: %v", err) 42 | } 43 | 44 | futureClient := futures.NewClient("", "") 45 | futureInfo, err := futureClient.NewExchangeInfoService().Do(context.Background()) 46 | if err != nil { 47 | return fmt.Errorf("failed to get exchange info: %v", err) 48 | } 49 | 50 | for _, info := range sportInfo.Symbols { 51 | pairAssetQuoteMap[info.Symbol] = AssetQuote{ 52 | Quote: info.QuoteAsset, 53 | Asset: info.BaseAsset, 54 | } 55 | } 56 | 57 | for _, info := range futureInfo.Symbols { 58 | pairAssetQuoteMap[info.Symbol] = AssetQuote{ 59 | Quote: info.QuoteAsset, 60 | Asset: info.BaseAsset, 61 | } 62 | } 63 | 64 | fmt.Printf("Total pairs: %d\n", len(pairAssetQuoteMap)) 65 | 66 | content, err := json.Marshal(pairAssetQuoteMap) 67 | if err != nil { 68 | return fmt.Errorf("failed to marshal pairs: %v", err) 69 | } 70 | 71 | err = os.WriteFile("pairs.json", content, 0644) 72 | if err != nil { 73 | return fmt.Errorf("failed to write to file: %v", err) 74 | } 75 | 76 | return nil 77 | } 78 | -------------------------------------------------------------------------------- /exchange/pairs_test.go: -------------------------------------------------------------------------------- 1 | package exchange 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestSplitAssetQuote(t *testing.T) { 10 | tt := []struct { 11 | Pair string 12 | Asset string 13 | Quote string 14 | }{ 15 | {"BTCUSDT", "BTC", "USDT"}, 16 | {"ETHBTC", "ETH", "BTC"}, 17 | {"BTCBUSD", "BTC", "BUSD"}, 18 | {"1000SHIBBUSD", "1000SHIB", "BUSD"}, 19 | } 20 | 21 | for _, tc := range tt { 22 | t.Run(tc.Pair, func(t *testing.T) { 23 | asset, quote := SplitAssetQuote(tc.Pair) 24 | require.Equal(t, tc.Asset, asset) 25 | require.Equal(t, tc.Quote, quote) 26 | }) 27 | } 28 | } 29 | 30 | func TestUpdatePairFile(t *testing.T) { 31 | t.Skip() // it is not a test, just utility function to update pairs list 32 | err := updatePairsFile() 33 | require.NoError(t, err) 34 | } 35 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/rodrigo-brito/ninjabot 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/StudioSol/set v1.0.0 7 | github.com/adshao/go-binance/v2 v2.6.1 8 | github.com/aybabtme/uniplot v0.0.0-20151203143629-039c559e5e7e 9 | github.com/evanw/esbuild v0.24.0 10 | github.com/glebarez/sqlite v1.11.0 11 | github.com/jpillora/backoff v1.0.0 12 | github.com/markcheno/go-talib v0.0.0-20190307022042-cd53a9264d70 13 | github.com/olekukonko/tablewriter v0.0.5 14 | github.com/samber/lo v1.47.0 15 | github.com/schollz/progressbar/v3 v3.16.1 16 | github.com/sirupsen/logrus v1.9.3 17 | github.com/stretchr/testify v1.9.0 18 | github.com/tidwall/buntdb v1.3.2 19 | github.com/urfave/cli/v2 v2.27.5 20 | github.com/vektra/mockery/v2 v2.38.0 21 | github.com/xhit/go-str2duration/v2 v2.1.0 22 | golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa 23 | gonum.org/v1/gonum v0.15.0 24 | gopkg.in/tucnak/telebot.v2 v2.5.0 25 | gorm.io/gorm v1.25.12 26 | ) 27 | 28 | require ( 29 | github.com/bitly/go-simplejson v0.5.0 // indirect 30 | github.com/chigopher/pathlib v0.15.0 // indirect 31 | github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect 32 | github.com/davecgh/go-spew v1.1.1 // indirect 33 | github.com/dustin/go-humanize v1.0.1 // indirect 34 | github.com/fsnotify/fsnotify v1.6.0 // indirect 35 | github.com/glebarez/go-sqlite v1.21.2 // indirect 36 | github.com/google/uuid v1.3.0 // indirect 37 | github.com/gorilla/websocket v1.5.0 // indirect 38 | github.com/hashicorp/hcl v1.0.0 // indirect 39 | github.com/huandu/xstrings v1.4.0 // indirect 40 | github.com/iancoleman/strcase v0.2.0 // indirect 41 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 42 | github.com/jinzhu/copier v0.3.5 // indirect 43 | github.com/jinzhu/inflection v1.0.0 // indirect 44 | github.com/jinzhu/now v1.1.5 // indirect 45 | github.com/json-iterator/go v1.1.12 // indirect 46 | github.com/magiconair/properties v1.8.7 // indirect 47 | github.com/mattn/go-colorable v0.1.13 // indirect 48 | github.com/mattn/go-isatty v0.0.20 // indirect 49 | github.com/mattn/go-runewidth v0.0.16 // indirect 50 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect 51 | github.com/mitchellh/go-homedir v1.1.0 // indirect 52 | github.com/mitchellh/mapstructure v1.5.0 // indirect 53 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 54 | github.com/modern-go/reflect2 v1.0.2 // indirect 55 | github.com/pelletier/go-toml/v2 v2.0.6 // indirect 56 | github.com/pkg/errors v0.9.1 // indirect 57 | github.com/pmezard/go-difflib v1.0.0 // indirect 58 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 59 | github.com/rivo/uniseg v0.4.7 // indirect 60 | github.com/rs/zerolog v1.29.0 // indirect 61 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 62 | github.com/spf13/afero v1.9.3 // indirect 63 | github.com/spf13/cast v1.5.0 // indirect 64 | github.com/spf13/cobra v1.6.1 // indirect 65 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 66 | github.com/spf13/pflag v1.0.5 // indirect 67 | github.com/spf13/viper v1.15.0 // indirect 68 | github.com/subosito/gotenv v1.4.2 // indirect 69 | github.com/tidwall/btree v1.4.2 // indirect 70 | github.com/tidwall/gjson v1.14.3 // indirect 71 | github.com/tidwall/grect v0.1.4 // indirect 72 | github.com/tidwall/match v1.1.1 // indirect 73 | github.com/tidwall/pretty v1.2.0 // indirect 74 | github.com/tidwall/rtred v0.1.2 // indirect 75 | github.com/tidwall/tinyqueue v0.1.1 // indirect 76 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 77 | golang.org/x/mod v0.17.0 // indirect 78 | golang.org/x/sync v0.7.0 // indirect 79 | golang.org/x/sys v0.25.0 // indirect 80 | golang.org/x/term v0.24.0 // indirect 81 | golang.org/x/text v0.16.0 // indirect 82 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect 83 | gopkg.in/ini.v1 v1.67.0 // indirect 84 | gopkg.in/yaml.v2 v2.4.0 // indirect 85 | gopkg.in/yaml.v3 v3.0.1 // indirect 86 | modernc.org/libc v1.22.5 // indirect 87 | modernc.org/mathutil v1.5.0 // indirect 88 | modernc.org/memory v1.5.0 // indirect 89 | modernc.org/sqlite v1.23.1 // indirect 90 | ) 91 | -------------------------------------------------------------------------------- /indicator/supertrend.go: -------------------------------------------------------------------------------- 1 | package indicator 2 | 3 | import "github.com/markcheno/go-talib" 4 | 5 | func SuperTrend(high, low, close []float64, atrPeriod int, factor float64) []float64 { 6 | atr := talib.Atr(high, low, close, atrPeriod) 7 | basicUpperBand := make([]float64, len(atr)) 8 | basicLowerBand := make([]float64, len(atr)) 9 | finalUpperBand := make([]float64, len(atr)) 10 | finalLowerBand := make([]float64, len(atr)) 11 | superTrend := make([]float64, len(atr)) 12 | 13 | for i := 1; i < len(basicLowerBand); i++ { 14 | basicUpperBand[i] = (high[i]+low[i])/2.0 + atr[i]*factor 15 | basicLowerBand[i] = (high[i]+low[i])/2.0 - atr[i]*factor 16 | 17 | if basicUpperBand[i] < finalUpperBand[i-1] || 18 | close[i-1] > finalUpperBand[i-1] { 19 | finalUpperBand[i] = basicUpperBand[i] 20 | } else { 21 | finalUpperBand[i] = finalUpperBand[i-1] 22 | } 23 | 24 | if basicLowerBand[i] > finalLowerBand[i-1] || 25 | close[i-1] < finalLowerBand[i-1] { 26 | finalLowerBand[i] = basicLowerBand[i] 27 | } else { 28 | finalLowerBand[i] = finalLowerBand[i-1] 29 | } 30 | 31 | if finalUpperBand[i-1] == superTrend[i-1] { 32 | if close[i] > finalUpperBand[i] { 33 | superTrend[i] = finalLowerBand[i] 34 | } else { 35 | superTrend[i] = finalUpperBand[i] 36 | } 37 | } else { 38 | if close[i] < finalLowerBand[i] { 39 | superTrend[i] = finalUpperBand[i] 40 | } else { 41 | superTrend[i] = finalLowerBand[i] 42 | } 43 | } 44 | } 45 | 46 | return superTrend 47 | } 48 | -------------------------------------------------------------------------------- /model/model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "strconv" 7 | "time" 8 | ) 9 | 10 | type TelegramSettings struct { 11 | Enabled bool 12 | Token string 13 | Users []int 14 | } 15 | 16 | type Settings struct { 17 | Pairs []string 18 | Telegram TelegramSettings 19 | } 20 | 21 | type Balance struct { 22 | Asset string 23 | Free float64 24 | Lock float64 25 | Leverage float64 26 | } 27 | 28 | type AssetInfo struct { 29 | BaseAsset string 30 | QuoteAsset string 31 | 32 | MinPrice float64 33 | MaxPrice float64 34 | MinQuantity float64 35 | MaxQuantity float64 36 | StepSize float64 37 | TickSize float64 38 | 39 | QuotePrecision int 40 | BaseAssetPrecision int 41 | } 42 | 43 | type Dataframe struct { 44 | Pair string 45 | 46 | Close Series[float64] 47 | Open Series[float64] 48 | High Series[float64] 49 | Low Series[float64] 50 | Volume Series[float64] 51 | 52 | Time []time.Time 53 | LastUpdate time.Time 54 | 55 | // Custom user metadata 56 | Metadata map[string]Series[float64] 57 | } 58 | 59 | func (df Dataframe) Sample(positions int) Dataframe { 60 | size := len(df.Time) 61 | start := size - positions 62 | if start <= 0 { 63 | return df 64 | } 65 | 66 | sample := Dataframe{ 67 | Pair: df.Pair, 68 | Close: df.Close.LastValues(positions), 69 | Open: df.Open.LastValues(positions), 70 | High: df.High.LastValues(positions), 71 | Low: df.Low.LastValues(positions), 72 | Volume: df.Volume.LastValues(positions), 73 | Time: df.Time[start:], 74 | LastUpdate: df.LastUpdate, 75 | Metadata: make(map[string]Series[float64]), 76 | } 77 | 78 | for key := range df.Metadata { 79 | sample.Metadata[key] = df.Metadata[key].LastValues(positions) 80 | } 81 | 82 | return sample 83 | } 84 | 85 | type Candle struct { 86 | Pair string 87 | Time time.Time 88 | UpdatedAt time.Time 89 | Open float64 90 | Close float64 91 | Low float64 92 | High float64 93 | Volume float64 94 | Complete bool 95 | 96 | // Aditional collums from CSV inputs 97 | Metadata map[string]float64 98 | } 99 | 100 | func (c Candle) Empty() bool { 101 | return c.Pair == "" && c.Close == 0 && c.Open == 0 && c.Volume == 0 102 | } 103 | 104 | type HeikinAshi struct { 105 | PreviousHACandle Candle 106 | } 107 | 108 | func NewHeikinAshi() *HeikinAshi { 109 | return &HeikinAshi{} 110 | } 111 | 112 | func (c Candle) ToSlice(precision int) []string { 113 | return []string{ 114 | fmt.Sprintf("%d", c.Time.Unix()), 115 | strconv.FormatFloat(c.Open, 'f', precision, 64), 116 | strconv.FormatFloat(c.Close, 'f', precision, 64), 117 | strconv.FormatFloat(c.Low, 'f', precision, 64), 118 | strconv.FormatFloat(c.High, 'f', precision, 64), 119 | strconv.FormatFloat(c.Volume, 'f', precision, 64), 120 | } 121 | } 122 | 123 | func (c Candle) ToHeikinAshi(ha *HeikinAshi) Candle { 124 | haCandle := ha.CalculateHeikinAshi(c) 125 | 126 | return Candle{ 127 | Pair: c.Pair, 128 | Open: haCandle.Open, 129 | High: haCandle.High, 130 | Low: haCandle.Low, 131 | Close: haCandle.Close, 132 | Volume: c.Volume, 133 | Complete: c.Complete, 134 | Time: c.Time, 135 | UpdatedAt: c.UpdatedAt, 136 | } 137 | } 138 | 139 | func (c Candle) Less(j Item) bool { 140 | diff := j.(Candle).Time.Sub(c.Time) 141 | if diff < 0 { 142 | return false 143 | } 144 | if diff > 0 { 145 | return true 146 | } 147 | 148 | diff = j.(Candle).UpdatedAt.Sub(c.UpdatedAt) 149 | if diff < 0 { 150 | return false 151 | } 152 | if diff > 0 { 153 | return true 154 | } 155 | 156 | return c.Pair < j.(Candle).Pair 157 | } 158 | 159 | type Account struct { 160 | Balances []Balance 161 | } 162 | 163 | func (a Account) Balance(assetTick, quoteTick string) (Balance, Balance) { 164 | var assetBalance, quoteBalance Balance 165 | var isSetAsset, isSetQuote bool 166 | 167 | for _, balance := range a.Balances { 168 | switch balance.Asset { 169 | case assetTick: 170 | assetBalance = balance 171 | isSetAsset = true 172 | case quoteTick: 173 | quoteBalance = balance 174 | isSetQuote = true 175 | } 176 | 177 | if isSetAsset && isSetQuote { 178 | break 179 | } 180 | } 181 | 182 | return assetBalance, quoteBalance 183 | } 184 | 185 | func (a Account) Equity() float64 { 186 | var total float64 187 | 188 | for _, balance := range a.Balances { 189 | total += balance.Free 190 | total += balance.Lock 191 | } 192 | 193 | return total 194 | } 195 | 196 | func (ha *HeikinAshi) CalculateHeikinAshi(c Candle) Candle { 197 | var hkCandle Candle 198 | 199 | openValue := ha.PreviousHACandle.Open 200 | closeValue := ha.PreviousHACandle.Close 201 | 202 | // First HA candle is calculated using current candle 203 | if ha.PreviousHACandle.Empty() { 204 | openValue = c.Open 205 | closeValue = c.Close 206 | } 207 | 208 | hkCandle.Open = (openValue + closeValue) / 2 209 | hkCandle.Close = (c.Open + c.High + c.Low + c.Close) / 4 210 | hkCandle.High = math.Max(c.High, math.Max(hkCandle.Open, hkCandle.Close)) 211 | hkCandle.Low = math.Min(c.Low, math.Min(hkCandle.Open, hkCandle.Close)) 212 | ha.PreviousHACandle = hkCandle 213 | 214 | return hkCandle 215 | } 216 | -------------------------------------------------------------------------------- /model/model_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestCandle_ToSlice(t *testing.T) { 11 | candle := Candle{ 12 | Pair: "BTCUSDT", 13 | Time: time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), 14 | Open: 10000.11, 15 | Close: 10000.12, 16 | Low: 10000.13, 17 | High: 10000.14, 18 | Volume: 10000.15, 19 | Complete: true, 20 | } 21 | 22 | expectedOutput := []string{"1609459200", "10000.11", "10000.12", "10000.13", "10000.14", "10000.15"} 23 | require.Equal(t, expectedOutput, candle.ToSlice(2)) 24 | } 25 | 26 | func TestCandle_Less(t *testing.T) { 27 | now := time.Now() 28 | 29 | t.Run("equal time", func(t *testing.T) { 30 | candle := Candle{Time: now, UpdatedAt: now, Pair: "A"} 31 | item := Item(Candle{Time: now, UpdatedAt: now.Add(time.Minute), Pair: "B"}) 32 | require.True(t, candle.Less(item)) 33 | }) 34 | 35 | t.Run("candle after item", func(t *testing.T) { 36 | candle := Candle{Time: now.Add(time.Minute), Pair: "A"} 37 | item := Item(Candle{Time: now, Pair: "B"}) 38 | require.False(t, candle.Less(item)) 39 | }) 40 | } 41 | 42 | func TestAccount_Balance(t *testing.T) { 43 | account := Account{} 44 | account.Balances = []Balance{{Asset: "A", Free: 1.2, Lock: 1.0}, {Asset: "B", Free: 1.1, Lock: 1.3}} 45 | assetBalance, quoteBalance := account.Balance("A", "B") 46 | require.Equal(t, Balance{Asset: "A", Free: 1.2, Lock: 1.0}, assetBalance) 47 | require.Equal(t, Balance{Asset: "B", Free: 1.1, Lock: 1.3}, quoteBalance) 48 | } 49 | 50 | func TestHeikinAshi_CalculateHeikinAshi(t *testing.T) { 51 | ha := NewHeikinAshi() 52 | 53 | if (!HeikinAshi{}.PreviousHACandle.Empty()) { 54 | t.Errorf("PreviousCandle should be empty") 55 | } 56 | 57 | // BTC-USDT weekly candles from Binance from 2017-08-14 to 2017-10-30 58 | // First market candles were used to easily test accuracy against 59 | // TradingView without having to download all market data. 60 | candles := []Candle{ 61 | {Open: 4261.48, Close: 4086.29, High: 4485.39, Low: 3850.00}, 62 | {Open: 4069.13, Close: 4310.01, High: 4453.91, Low: 3400.00}, 63 | {Open: 4310.01, Close: 4509.08, High: 4939.19, Low: 4124.54}, 64 | {Open: 4505.00, Close: 4130.37, High: 4788.59, Low: 3603.00}, 65 | {Open: 4153.62, Close: 3699.99, High: 4394.59, Low: 2817.00}, 66 | {Open: 3690.00, Close: 3660.02, High: 4123.20, Low: 3505.55}, 67 | {Open: 3660.02, Close: 4378.48, High: 4406.52, Low: 3653.69}, 68 | {Open: 4400.00, Close: 4640.00, High: 4658.00, Low: 4110.00}, 69 | {Open: 4640.00, Close: 5709.99, High: 5922.30, Low: 4550.00}, 70 | {Open: 5710.00, Close: 5950.02, High: 6171.00, Low: 5037.95}, 71 | {Open: 5975.00, Close: 6169.98, High: 6189.88, Low: 5286.98}, 72 | {Open: 6133.01, Close: 7345.01, High: 7590.25, Low: 6030.00}, 73 | } 74 | 75 | var results []Candle 76 | 77 | for _, candle := range candles { 78 | haCandle := ha.CalculateHeikinAshi(candle) 79 | results = append(results, haCandle) 80 | } 81 | 82 | // Expected values taken from TradingView. 83 | // Source: Binance BTC-USDT 84 | expected := []Candle{ 85 | {Open: 4173.885, Close: 4170.79, High: 4485.39, Low: 3850}, 86 | {Open: 4172.3375, Close: 4058.2625000000003, High: 4453.91, Low: 3400}, 87 | {Open: 4115.3, Close: 4470.705, High: 4939.19, Low: 4115.30}, 88 | {Open: 4293.0025000000005, Close: 4256.74, High: 4788.59, Low: 3603}, 89 | {Open: 4274.87125, Close: 3766.2999999999997, High: 4394.59, Low: 2817}, 90 | {Open: 4020.5856249999997, Close: 3744.6925, High: 4123.2, Low: 3505.55}, 91 | {Open: 3882.6390625, Close: 4024.6775000000002, High: 4406.52, Low: 3653.69}, 92 | {Open: 3953.65828125, Close: 4452, High: 4658, Low: 3953.65828125}, 93 | {Open: 4202.829140625, Close: 5205.5725, High: 5922.3, Low: 4202.829140625}, 94 | {Open: 4704.200820312501, Close: 5717.2425, High: 6171.00, Low: 4704.200820312501}, 95 | {Open: 5210.72166015625, Close: 5905.46, High: 6189.88, Low: 5210.72166015625}, 96 | {Open: 5558.090830078125, Close: 6774.567500000001, High: 7590.25, Low: 5558.090830078125}, 97 | } 98 | 99 | if len(expected) != len(results) { 100 | t.Errorf("Expected %d HA candles. Got %d.", len(expected), len(results)) 101 | } 102 | 103 | for index, expectedHaCandle := range expected { 104 | require.Equal(t, expectedHaCandle.Open, results[index].Open) 105 | require.Equal(t, expectedHaCandle.Close, results[index].Close) 106 | require.Equal(t, expectedHaCandle.High, results[index].High) 107 | require.Equal(t, expectedHaCandle.Low, results[index].Low) 108 | } 109 | } 110 | 111 | func TestDataframe_Sample(t *testing.T) { 112 | df := Dataframe{ 113 | Pair: "BTCUSDT", 114 | Close: []float64{1, 2, 3, 4, 5, 6, 7, 8, 9}, 115 | Open: []float64{1, 2, 3, 4, 5, 6, 7, 8, 9}, 116 | High: []float64{1, 2, 3, 4, 5, 6, 7, 8, 9}, 117 | Low: []float64{1, 2, 3, 4, 5, 6, 7, 8, 9}, 118 | Volume: []float64{1, 2, 3, 4, 5, 6, 7, 8, 9}, 119 | Time: []time.Time{time.Now(), time.Now(), time.Now(), time.Now(), time.Now(), time.Now(), time.Now(), 120 | time.Now(), time.Now()}, 121 | LastUpdate: time.Now(), 122 | Metadata: map[string]Series[float64]{ 123 | "test": []float64{1, 2, 3, 4, 5, 6, 7, 8, 9}, 124 | }, 125 | } 126 | 127 | sample := df.Sample(5) 128 | require.Equal(t, "BTCUSDT", sample.Pair) 129 | require.Len(t, sample.Time, 5) 130 | require.Equal(t, df.LastUpdate, sample.LastUpdate) 131 | require.Equal(t, Series[float64]([]float64{5, 6, 7, 8, 9}), sample.Close) 132 | require.Equal(t, Series[float64]([]float64{5, 6, 7, 8, 9}), sample.Open) 133 | require.Equal(t, Series[float64]([]float64{5, 6, 7, 8, 9}), sample.High) 134 | require.Equal(t, Series[float64]([]float64{5, 6, 7, 8, 9}), sample.Low) 135 | require.Equal(t, Series[float64]([]float64{5, 6, 7, 8, 9}), sample.Volume) 136 | require.Equal(t, Series[float64]([]float64{5, 6, 7, 8, 9}), sample.Metadata["test"]) 137 | 138 | // mutate the sample must not mutate the original dataframe 139 | sample.Metadata["test"] = []float64{10, 11, 12, 13, 14} 140 | require.Equal(t, df.Metadata["test"], Series[float64]([]float64{1, 2, 3, 4, 5, 6, 7, 8, 9})) 141 | } 142 | -------------------------------------------------------------------------------- /model/order.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | type SideType string 9 | type OrderType string 10 | type OrderStatusType string 11 | 12 | var ( 13 | SideTypeBuy SideType = "BUY" 14 | SideTypeSell SideType = "SELL" 15 | 16 | OrderTypeLimit OrderType = "LIMIT" 17 | OrderTypeMarket OrderType = "MARKET" 18 | OrderTypeLimitMaker OrderType = "LIMIT_MAKER" 19 | OrderTypeStopLoss OrderType = "STOP_LOSS" 20 | OrderTypeStopLossLimit OrderType = "STOP_LOSS_LIMIT" 21 | OrderTypeTakeProfit OrderType = "TAKE_PROFIT" 22 | OrderTypeTakeProfitLimit OrderType = "TAKE_PROFIT_LIMIT" 23 | 24 | OrderStatusTypeNew OrderStatusType = "NEW" 25 | OrderStatusTypePartiallyFilled OrderStatusType = "PARTIALLY_FILLED" 26 | OrderStatusTypeFilled OrderStatusType = "FILLED" 27 | OrderStatusTypeCanceled OrderStatusType = "CANCELED" 28 | OrderStatusTypePendingCancel OrderStatusType = "PENDING_CANCEL" 29 | OrderStatusTypeRejected OrderStatusType = "REJECTED" 30 | OrderStatusTypeExpired OrderStatusType = "EXPIRED" 31 | ) 32 | 33 | type Order struct { 34 | ID int64 `db:"id" json:"id" gorm:"primaryKey,autoIncrement"` 35 | ExchangeID int64 `db:"exchange_id" json:"exchange_id"` 36 | Pair string `db:"pair" json:"pair"` 37 | Side SideType `db:"side" json:"side"` 38 | Type OrderType `db:"type" json:"type"` 39 | Status OrderStatusType `db:"status" json:"status"` 40 | Price float64 `db:"price" json:"price"` 41 | Quantity float64 `db:"quantity" json:"quantity"` 42 | 43 | CreatedAt time.Time `db:"created_at" json:"created_at"` 44 | UpdatedAt time.Time `db:"updated_at" json:"updated_at"` 45 | 46 | // OCO Orders only 47 | Stop *float64 `db:"stop" json:"stop"` 48 | GroupID *int64 `db:"group_id" json:"group_id"` 49 | 50 | // Internal use (Plot) 51 | RefPrice float64 `json:"ref_price" gorm:"-"` 52 | Profit float64 `json:"profit" gorm:"-"` 53 | ProfitValue float64 `json:"profit_value" gorm:"-"` 54 | Candle Candle `json:"-" gorm:"-"` 55 | } 56 | 57 | func (o Order) String() string { 58 | return fmt.Sprintf("[%s] %s %s | ID: %d, Type: %s, %f x $%f (~$%.f)", 59 | o.Status, o.Side, o.Pair, o.ID, o.Type, o.Quantity, o.Price, o.Quantity*o.Price) 60 | } 61 | -------------------------------------------------------------------------------- /model/order_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestOrder_String(t *testing.T) { 11 | order := Order{ 12 | ID: 1, 13 | ExchangeID: 2, 14 | Pair: "BNBUSDT", 15 | Side: SideTypeSell, 16 | Type: OrderTypeLimit, 17 | Status: OrderStatusTypeFilled, 18 | Price: 10, 19 | Quantity: 1, 20 | CreatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), 21 | UpdatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), 22 | } 23 | require.Equal(t, "[FILLED] SELL BNBUSDT | ID: 1, Type: LIMIT, 1.000000 x $10.000000 (~$10)", order.String()) 24 | } 25 | -------------------------------------------------------------------------------- /model/priorityqueue.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "sync" 4 | 5 | type PriorityQueue struct { 6 | sync.Mutex 7 | length int 8 | data []Item 9 | notifyCallbacks []func(Item) 10 | } 11 | 12 | type Item interface { 13 | Less(Item) bool 14 | } 15 | 16 | func NewPriorityQueue(data []Item) *PriorityQueue { 17 | q := &PriorityQueue{} 18 | q.data = data 19 | q.length = len(data) 20 | if q.length > 0 { 21 | i := q.length >> 1 22 | for ; i >= 0; i-- { 23 | q.down(i) 24 | } 25 | } 26 | return q 27 | } 28 | 29 | func (q *PriorityQueue) Push(item Item) { 30 | q.Lock() 31 | defer q.Unlock() 32 | 33 | q.data = append(q.data, item) 34 | q.length++ 35 | q.up(q.length - 1) 36 | 37 | for _, notify := range q.notifyCallbacks { 38 | go notify(item) 39 | } 40 | } 41 | 42 | func (q *PriorityQueue) PopLock() <-chan Item { 43 | ch := make(chan Item) 44 | q.notifyCallbacks = append(q.notifyCallbacks, func(_ Item) { 45 | ch <- q.Pop() 46 | }) 47 | return ch 48 | } 49 | 50 | func (q *PriorityQueue) Pop() Item { 51 | q.Lock() 52 | defer q.Unlock() 53 | 54 | if q.length == 0 { 55 | return nil 56 | } 57 | top := q.data[0] 58 | q.length-- 59 | if q.length > 0 { 60 | q.data[0] = q.data[q.length] 61 | q.down(0) 62 | } 63 | q.data = q.data[:len(q.data)-1] 64 | return top 65 | } 66 | 67 | func (q *PriorityQueue) Peek() Item { 68 | q.Lock() 69 | defer q.Unlock() 70 | 71 | if q.length == 0 { 72 | return nil 73 | } 74 | return q.data[0] 75 | } 76 | 77 | func (q *PriorityQueue) Len() int { 78 | q.Lock() 79 | defer q.Unlock() 80 | 81 | return q.length 82 | } 83 | func (q *PriorityQueue) down(pos int) { 84 | data := q.data 85 | halfLength := q.length >> 1 86 | item := data[pos] 87 | for pos < halfLength { 88 | left := (pos << 1) + 1 89 | right := left + 1 90 | best := data[left] 91 | if right < q.length && data[right].Less(best) { 92 | left = right 93 | best = data[right] 94 | } 95 | if !best.Less(item) { 96 | break 97 | } 98 | data[pos] = best 99 | pos = left 100 | } 101 | data[pos] = item 102 | } 103 | 104 | func (q *PriorityQueue) up(pos int) { 105 | data := q.data 106 | item := data[pos] 107 | for pos > 0 { 108 | parent := (pos - 1) >> 1 109 | current := data[parent] 110 | if !item.Less(current) { 111 | break 112 | } 113 | data[pos] = current 114 | pos = parent 115 | } 116 | data[pos] = item 117 | } 118 | -------------------------------------------------------------------------------- /model/priorityqueue_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestPriorityQueue(t *testing.T) { 11 | now := time.Now() 12 | pq := NewPriorityQueue(nil) 13 | require.Nil(t, pq.Pop()) 14 | 15 | pq.Push(Candle{Time: now, Close: 2}) 16 | pq.Push(Candle{Time: now.Add(time.Minute), Close: 6, Pair: "D"}) 17 | pq.Push(Candle{Time: now.Add(time.Minute), Close: 5, Pair: "C"}) 18 | pq.Push(Candle{Time: now.Add(time.Minute), Close: 4, Pair: "B"}) 19 | pq.Push(Candle{Time: now.Add(time.Minute), Close: 3, Pair: "A"}) 20 | pq.Push(Candle{Time: now.Add(-time.Minute), Close: 1}) 21 | 22 | require.Equal(t, 1.0, pq.Pop().(Candle).Close) 23 | require.Equal(t, 2.0, pq.Pop().(Candle).Close) 24 | require.Equal(t, 3.0, pq.Pop().(Candle).Close) 25 | require.Equal(t, 4.0, pq.Pop().(Candle).Close) 26 | require.Equal(t, 5.0, pq.Pop().(Candle).Close) 27 | require.Equal(t, 6.0, pq.Pop().(Candle).Close) 28 | } 29 | 30 | func TestPriorityQueue_Peek(t *testing.T) { 31 | pq := &PriorityQueue{} 32 | require.Nil(t, pq.Peek()) 33 | 34 | pq = NewPriorityQueue([]Item{Candle{Pair: "A"}}) 35 | require.Equal(t, "A", pq.Peek().(Candle).Pair) 36 | } 37 | 38 | func TestPriorityQueue_Len(t *testing.T) { 39 | pq := &PriorityQueue{} 40 | require.Zero(t, pq.Len()) 41 | 42 | pq = NewPriorityQueue([]Item{Candle{Pair: "A"}}) 43 | require.Equal(t, 1, pq.Len()) 44 | } 45 | -------------------------------------------------------------------------------- /model/series.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | 7 | "golang.org/x/exp/constraints" 8 | ) 9 | 10 | // Series is a time series of values 11 | type Series[T constraints.Ordered] []T 12 | 13 | // Values returns the values of the series 14 | func (s Series[T]) Values() []T { 15 | return s 16 | } 17 | 18 | // Length returns the number of values in the series 19 | func (s Series[T]) Length() int { 20 | return len(s) 21 | } 22 | 23 | // Last returns the last value of the series given a past index position 24 | func (s Series[T]) Last(position int) T { 25 | return s[len(s)-1-position] 26 | } 27 | 28 | // LastValues returns the last values of the series given a size 29 | func (s Series[T]) LastValues(size int) []T { 30 | if l := len(s); l > size { 31 | return s[l-size:] 32 | } 33 | return s 34 | } 35 | 36 | // Crossover returns true if the last value of the series is greater than the last value of the reference series 37 | func (s Series[T]) Crossover(ref Series[T]) bool { 38 | return s.Last(0) > ref.Last(0) && s.Last(1) <= ref.Last(1) 39 | } 40 | 41 | // Crossunder returns true if the last value of the series is less than the last value of the reference series 42 | func (s Series[T]) Crossunder(ref Series[T]) bool { 43 | return s.Last(0) <= ref.Last(0) && s.Last(1) > ref.Last(1) 44 | } 45 | 46 | // Cross returns true if the last value of the series is greater than the last value of the 47 | // reference series or less than the last value of the reference series 48 | func (s Series[T]) Cross(ref Series[T]) bool { 49 | return s.Crossover(ref) || s.Crossunder(ref) 50 | } 51 | 52 | // NumDecPlaces returns the number of decimal places of a float64 53 | func NumDecPlaces(v float64) int64 { 54 | s := strconv.FormatFloat(v, 'f', -1, 64) 55 | i := strings.IndexByte(s, '.') 56 | if i > -1 { 57 | return int64(len(s) - i - 1) 58 | } 59 | return 0 60 | } 61 | -------------------------------------------------------------------------------- /model/series_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestSeries_Values(t *testing.T) { 11 | require.Equal(t, []float64{1, 2, 3, 4, 5}, Series[float64]([]float64{1, 2, 3, 4, 5}).Values()) 12 | } 13 | 14 | func TestSeries_Last(t *testing.T) { 15 | series := Series[float64]([]float64{1, 2, 3, 4, 5}) 16 | require.Equal(t, 5.0, series.Last(0)) 17 | require.Equal(t, 3.0, series.Last(2)) 18 | } 19 | 20 | func TestSeries_LastValues(t *testing.T) { 21 | t.Run("with value", func(t *testing.T) { 22 | series := Series[float64]([]float64{1, 2, 3, 4, 5}) 23 | require.Equal(t, []float64{4, 5}, series.LastValues(2)) 24 | }) 25 | 26 | t.Run("empty", func(t *testing.T) { 27 | series := Series[float64]([]float64{}) 28 | require.Empty(t, series.LastValues(2)) 29 | }) 30 | } 31 | 32 | func TestSeries_Crossover(t *testing.T) { 33 | s1 := Series[float64]([]float64{4, 5}) 34 | s2 := Series[float64]([]float64{5, 4}) 35 | require.True(t, s1.Crossover(s2)) 36 | require.False(t, s2.Crossover(s1)) 37 | } 38 | 39 | func TestSeries_Crossunder(t *testing.T) { 40 | s1 := Series[float64]([]float64{4, 5}) 41 | s2 := Series[float64]([]float64{5, 4}) 42 | require.False(t, s1.Crossunder(s2)) 43 | require.True(t, s2.Crossunder(s1)) 44 | } 45 | 46 | func TestNumDecPlaces(t *testing.T) { 47 | tt := []struct { 48 | Value float64 49 | Expect int64 50 | }{ 51 | {0.1, 1}, 52 | {0.10001, 5}, 53 | {1000, 0}, 54 | {-1000, 0}, 55 | {-1.1, 1}, 56 | } 57 | 58 | for _, tc := range tt { 59 | t.Run(fmt.Sprintf("given %f", tc.Value), func(t *testing.T) { 60 | require.Equal(t, tc.Expect, NumDecPlaces(tc.Value)) 61 | }) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /ninjabot.go: -------------------------------------------------------------------------------- 1 | package ninjabot 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "os" 8 | "strconv" 9 | 10 | "github.com/aybabtme/uniplot/histogram" 11 | 12 | "github.com/rodrigo-brito/ninjabot/exchange" 13 | "github.com/rodrigo-brito/ninjabot/model" 14 | "github.com/rodrigo-brito/ninjabot/notification" 15 | "github.com/rodrigo-brito/ninjabot/order" 16 | "github.com/rodrigo-brito/ninjabot/service" 17 | "github.com/rodrigo-brito/ninjabot/storage" 18 | "github.com/rodrigo-brito/ninjabot/strategy" 19 | "github.com/rodrigo-brito/ninjabot/tools/log" 20 | "github.com/rodrigo-brito/ninjabot/tools/metrics" 21 | 22 | "github.com/olekukonko/tablewriter" 23 | "github.com/schollz/progressbar/v3" 24 | ) 25 | 26 | const defaultDatabase = "ninjabot.db" 27 | 28 | func init() { 29 | log.SetFormatter(&log.TextFormatter{ 30 | FullTimestamp: true, 31 | TimestampFormat: "2006-01-02 15:04", 32 | }) 33 | } 34 | 35 | type OrderSubscriber interface { 36 | OnOrder(model.Order) 37 | } 38 | 39 | type CandleSubscriber interface { 40 | OnCandle(model.Candle) 41 | } 42 | 43 | type NinjaBot struct { 44 | storage storage.Storage 45 | settings model.Settings 46 | exchange service.Exchange 47 | strategy strategy.Strategy 48 | notifier service.Notifier 49 | telegram service.Telegram 50 | 51 | orderController *order.Controller 52 | priorityQueueCandle *model.PriorityQueue 53 | strategiesControllers map[string]*strategy.Controller 54 | orderFeed *order.Feed 55 | dataFeed *exchange.DataFeedSubscription 56 | paperWallet *exchange.PaperWallet 57 | 58 | backtest bool 59 | } 60 | 61 | type Option func(*NinjaBot) 62 | 63 | func NewBot(ctx context.Context, settings model.Settings, exch service.Exchange, str strategy.Strategy, 64 | options ...Option) (*NinjaBot, error) { 65 | 66 | bot := &NinjaBot{ 67 | settings: settings, 68 | exchange: exch, 69 | strategy: str, 70 | orderFeed: order.NewOrderFeed(), 71 | dataFeed: exchange.NewDataFeed(exch), 72 | strategiesControllers: make(map[string]*strategy.Controller), 73 | priorityQueueCandle: model.NewPriorityQueue(nil), 74 | } 75 | 76 | for _, pair := range settings.Pairs { 77 | asset, quote := exchange.SplitAssetQuote(pair) 78 | if asset == "" || quote == "" { 79 | return nil, fmt.Errorf("invalid pair: %s", pair) 80 | } 81 | } 82 | 83 | for _, option := range options { 84 | option(bot) 85 | } 86 | 87 | var err error 88 | if bot.storage == nil { 89 | bot.storage, err = storage.FromFile(defaultDatabase) 90 | if err != nil { 91 | return nil, err 92 | } 93 | } 94 | 95 | bot.orderController = order.NewController(ctx, exch, bot.storage, bot.orderFeed) 96 | 97 | if settings.Telegram.Enabled { 98 | bot.telegram, err = notification.NewTelegram(bot.orderController, settings) 99 | if err != nil { 100 | return nil, err 101 | } 102 | // register telegram as notifier 103 | WithNotifier(bot.telegram)(bot) 104 | } 105 | 106 | return bot, nil 107 | } 108 | 109 | // WithBacktest sets the bot to run in backtest mode, it is required for backtesting environments 110 | // Backtest mode optimize the input read for CSV and deal with race conditions 111 | func WithBacktest(wallet *exchange.PaperWallet) Option { 112 | return func(bot *NinjaBot) { 113 | bot.backtest = true 114 | opt := WithPaperWallet(wallet) 115 | opt(bot) 116 | } 117 | } 118 | 119 | // WithStorage sets the storage for the bot, by default it uses a local file called ninjabot.db 120 | func WithStorage(storage storage.Storage) Option { 121 | return func(bot *NinjaBot) { 122 | bot.storage = storage 123 | } 124 | } 125 | 126 | // WithLogLevel sets the log level. eg: log.DebugLevel, log.InfoLevel, log.WarnLevel, log.ErrorLevel, log.FatalLevel 127 | func WithLogLevel(level log.Level) Option { 128 | return func(_ *NinjaBot) { 129 | log.SetLevel(level) 130 | } 131 | } 132 | 133 | // WithNotifier registers a notifier to the bot, currently only email and telegram are supported 134 | func WithNotifier(notifier service.Notifier) Option { 135 | return func(bot *NinjaBot) { 136 | bot.notifier = notifier 137 | bot.orderController.SetNotifier(notifier) 138 | bot.SubscribeOrder(notifier) 139 | } 140 | } 141 | 142 | // WithCandleSubscription subscribes a given struct to the candle feed 143 | func WithCandleSubscription(subscriber CandleSubscriber) Option { 144 | return func(bot *NinjaBot) { 145 | bot.SubscribeCandle(subscriber) 146 | } 147 | } 148 | 149 | // WithPaperWallet sets the paper wallet for the bot (used for backtesting and live simulation) 150 | func WithPaperWallet(wallet *exchange.PaperWallet) Option { 151 | return func(bot *NinjaBot) { 152 | bot.paperWallet = wallet 153 | } 154 | } 155 | 156 | func (n *NinjaBot) SubscribeCandle(subscriptions ...CandleSubscriber) { 157 | for _, pair := range n.settings.Pairs { 158 | for _, subscription := range subscriptions { 159 | n.dataFeed.Subscribe(pair, n.strategy.Timeframe(), subscription.OnCandle, false) 160 | } 161 | } 162 | } 163 | 164 | func WithOrderSubscription(subscriber OrderSubscriber) Option { 165 | return func(bot *NinjaBot) { 166 | bot.SubscribeOrder(subscriber) 167 | } 168 | } 169 | 170 | func (n *NinjaBot) SubscribeOrder(subscriptions ...OrderSubscriber) { 171 | for _, pair := range n.settings.Pairs { 172 | for _, subscription := range subscriptions { 173 | n.orderFeed.Subscribe(pair, subscription.OnOrder, false) 174 | } 175 | } 176 | } 177 | 178 | func (n *NinjaBot) Controller() *order.Controller { 179 | return n.orderController 180 | } 181 | 182 | // Summary function displays all trades, accuracy and some bot metrics in stdout 183 | // To access the raw data, you may access `bot.Controller().Results` 184 | func (n *NinjaBot) Summary() { 185 | var ( 186 | total float64 187 | wins int 188 | loses int 189 | volume float64 190 | sqn float64 191 | ) 192 | 193 | buffer := bytes.NewBuffer(nil) 194 | table := tablewriter.NewWriter(buffer) 195 | table.SetHeader([]string{"Pair", "Trades", "Win", "Loss", "% Win", "Payoff", "Pr Fact.", "SQN", "Profit", "Volume"}) 196 | table.SetFooterAlignment(tablewriter.ALIGN_RIGHT) 197 | avgPayoff := 0.0 198 | avgProfitFactor := 0.0 199 | 200 | returns := make([]float64, 0) 201 | for _, summary := range n.orderController.Results { 202 | avgPayoff += summary.Payoff() * float64(len(summary.Win())+len(summary.Lose())) 203 | avgProfitFactor += summary.ProfitFactor() * float64(len(summary.Win())+len(summary.Lose())) 204 | table.Append([]string{ 205 | summary.Pair, 206 | strconv.Itoa(len(summary.Win()) + len(summary.Lose())), 207 | strconv.Itoa(len(summary.Win())), 208 | strconv.Itoa(len(summary.Lose())), 209 | fmt.Sprintf("%.1f %%", float64(len(summary.Win()))/float64(len(summary.Win())+len(summary.Lose()))*100), 210 | fmt.Sprintf("%.3f", summary.Payoff()), 211 | fmt.Sprintf("%.3f", summary.ProfitFactor()), 212 | fmt.Sprintf("%.1f", summary.SQN()), 213 | fmt.Sprintf("%.2f", summary.Profit()), 214 | fmt.Sprintf("%.2f", summary.Volume), 215 | }) 216 | total += summary.Profit() 217 | sqn += summary.SQN() 218 | wins += len(summary.Win()) 219 | loses += len(summary.Lose()) 220 | volume += summary.Volume 221 | 222 | returns = append(returns, summary.WinPercent()...) 223 | returns = append(returns, summary.LosePercent()...) 224 | } 225 | 226 | table.SetFooter([]string{ 227 | "TOTAL", 228 | strconv.Itoa(wins + loses), 229 | strconv.Itoa(wins), 230 | strconv.Itoa(loses), 231 | fmt.Sprintf("%.1f %%", float64(wins)/float64(wins+loses)*100), 232 | fmt.Sprintf("%.3f", avgPayoff/float64(wins+loses)), 233 | fmt.Sprintf("%.3f", avgProfitFactor/float64(wins+loses)), 234 | fmt.Sprintf("%.1f", sqn/float64(len(n.orderController.Results))), 235 | fmt.Sprintf("%.2f", total), 236 | fmt.Sprintf("%.2f", volume), 237 | }) 238 | table.Render() 239 | 240 | fmt.Println(buffer.String()) 241 | fmt.Println("------ RETURN -------") 242 | totalReturn := 0.0 243 | returnsPercent := make([]float64, len(returns)) 244 | for i, p := range returns { 245 | returnsPercent[i] = p * 100 246 | totalReturn += p 247 | } 248 | hist := histogram.Hist(15, returnsPercent) 249 | histogram.Fprint(os.Stdout, hist, histogram.Linear(10)) 250 | fmt.Println() 251 | 252 | fmt.Println("------ CONFIDENCE INTERVAL (95%) -------") 253 | for pair, summary := range n.orderController.Results { 254 | fmt.Printf("| %s |\n", pair) 255 | returns := append(summary.WinPercent(), summary.LosePercent()...) 256 | returnsInterval := metrics.Bootstrap(returns, metrics.Mean, 10000, 0.95) 257 | payoffInterval := metrics.Bootstrap(returns, metrics.Payoff, 10000, 0.95) 258 | profitFactorInterval := metrics.Bootstrap(returns, metrics.ProfitFactor, 10000, 0.95) 259 | 260 | fmt.Printf("RETURN: %.2f%% (%.2f%% ~ %.2f%%)\n", 261 | returnsInterval.Mean*100, returnsInterval.Lower*100, returnsInterval.Upper*100) 262 | fmt.Printf("PAYOFF: %.2f (%.2f ~ %.2f)\n", 263 | payoffInterval.Mean, payoffInterval.Lower, payoffInterval.Upper) 264 | fmt.Printf("PROF.FACTOR: %.2f (%.2f ~ %.2f)\n", 265 | profitFactorInterval.Mean, profitFactorInterval.Lower, profitFactorInterval.Upper) 266 | } 267 | 268 | fmt.Println() 269 | 270 | if n.paperWallet != nil { 271 | n.paperWallet.Summary() 272 | } 273 | 274 | } 275 | 276 | func (n NinjaBot) SaveReturns(outputDir string) error { 277 | for _, summary := range n.orderController.Results { 278 | outputFile := fmt.Sprintf("%s/%s.csv", outputDir, summary.Pair) 279 | if err := summary.SaveReturns(outputFile); err != nil { 280 | return err 281 | } 282 | } 283 | return nil 284 | } 285 | 286 | func (n *NinjaBot) onCandle(candle model.Candle) { 287 | n.priorityQueueCandle.Push(candle) 288 | } 289 | 290 | func (n *NinjaBot) processCandle(candle model.Candle) { 291 | if n.paperWallet != nil { 292 | n.paperWallet.OnCandle(candle) 293 | } 294 | 295 | n.strategiesControllers[candle.Pair].OnPartialCandle(candle) 296 | if candle.Complete { 297 | n.strategiesControllers[candle.Pair].OnCandle(candle) 298 | n.orderController.OnCandle(candle) 299 | } 300 | } 301 | 302 | // Process pending candles in buffer 303 | func (n *NinjaBot) processCandles() { 304 | for item := range n.priorityQueueCandle.PopLock() { 305 | n.processCandle(item.(model.Candle)) 306 | } 307 | } 308 | 309 | // Start the backtest process and create a progress bar 310 | // backtestCandles will process candles from a prirority queue in chronological order 311 | func (n *NinjaBot) backtestCandles() { 312 | log.Info("[SETUP] Starting backtesting") 313 | 314 | progressBar := progressbar.Default(int64(n.priorityQueueCandle.Len())) 315 | for n.priorityQueueCandle.Len() > 0 { 316 | item := n.priorityQueueCandle.Pop() 317 | 318 | candle := item.(model.Candle) 319 | if n.paperWallet != nil { 320 | n.paperWallet.OnCandle(candle) 321 | } 322 | 323 | n.strategiesControllers[candle.Pair].OnPartialCandle(candle) 324 | if candle.Complete { 325 | n.strategiesControllers[candle.Pair].OnCandle(candle) 326 | } 327 | 328 | if err := progressBar.Add(1); err != nil { 329 | log.Warnf("update progressbar fail: %v", err) 330 | } 331 | } 332 | } 333 | 334 | // Before Ninjabot start, we need to load the necessary data to fill strategy indicators 335 | // Then, we need to get the time frame and warmup period to fetch the necessary candles 336 | func (n *NinjaBot) preload(ctx context.Context, pair string) error { 337 | if n.backtest { 338 | return nil 339 | } 340 | 341 | candles, err := n.exchange.CandlesByLimit(ctx, pair, n.strategy.Timeframe(), n.strategy.WarmupPeriod()) 342 | if err != nil { 343 | return err 344 | } 345 | 346 | for _, candle := range candles { 347 | n.processCandle(candle) 348 | } 349 | 350 | n.dataFeed.Preload(pair, n.strategy.Timeframe(), candles) 351 | 352 | return nil 353 | } 354 | 355 | // Run will initialize the strategy controller, order controller, preload data and start the bot 356 | func (n *NinjaBot) Run(ctx context.Context) error { 357 | for _, pair := range n.settings.Pairs { 358 | // setup and subscribe strategy to data feed (candles) 359 | n.strategiesControllers[pair] = strategy.NewStrategyController(pair, n.strategy, n.orderController) 360 | 361 | // preload candles for warmup period 362 | err := n.preload(ctx, pair) 363 | if err != nil { 364 | return err 365 | } 366 | 367 | // link to ninja bot controller 368 | n.dataFeed.Subscribe(pair, n.strategy.Timeframe(), n.onCandle, false) 369 | 370 | // start strategy controller 371 | n.strategiesControllers[pair].Start() 372 | } 373 | 374 | // start order feed and controller 375 | n.orderFeed.Start() 376 | n.orderController.Start() 377 | defer n.orderController.Stop() 378 | if n.telegram != nil { 379 | n.telegram.Start() 380 | } 381 | 382 | // start data feed and receives new candles 383 | n.dataFeed.Start(n.backtest) 384 | 385 | // start processing new candles for production or backtesting environment 386 | if n.backtest { 387 | n.backtestCandles() 388 | } else { 389 | n.processCandles() 390 | } 391 | 392 | return nil 393 | } 394 | -------------------------------------------------------------------------------- /ninjabot_test.go: -------------------------------------------------------------------------------- 1 | package ninjabot 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/rodrigo-brito/ninjabot/strategy" 8 | 9 | "github.com/markcheno/go-talib" 10 | log "github.com/sirupsen/logrus" 11 | "github.com/stretchr/testify/require" 12 | 13 | "github.com/rodrigo-brito/ninjabot/exchange" 14 | "github.com/rodrigo-brito/ninjabot/service" 15 | "github.com/rodrigo-brito/ninjabot/storage" 16 | ) 17 | 18 | type fakeStrategy struct{} 19 | 20 | func (e fakeStrategy) Timeframe() string { 21 | return "1d" 22 | } 23 | 24 | func (e fakeStrategy) WarmupPeriod() int { 25 | return 10 26 | } 27 | 28 | func (e fakeStrategy) Indicators(df *Dataframe) []strategy.ChartIndicator { 29 | df.Metadata["ema9"] = talib.Ema(df.Close, 9) 30 | return nil 31 | } 32 | 33 | func (e *fakeStrategy) OnCandle(df *Dataframe, broker service.Broker) { 34 | closePrice := df.Close.Last(0) 35 | assetPosition, quotePosition, err := broker.Position(df.Pair) 36 | if err != nil { 37 | log.Error(err) 38 | } 39 | 40 | if quotePosition > 0 && df.Close.Crossover(df.Metadata["ema9"]) { 41 | _, err := broker.CreateOrderMarket(SideTypeBuy, df.Pair, quotePosition/closePrice*0.5) 42 | if err != nil { 43 | log.Fatal(err) 44 | } 45 | } 46 | 47 | if assetPosition > 0 && 48 | df.Close.Crossunder(df.Metadata["ema9"]) { 49 | _, err := broker.CreateOrderMarket(SideTypeSell, df.Pair, assetPosition) 50 | if err != nil { 51 | log.Fatal(err) 52 | } 53 | } 54 | } 55 | 56 | func TestMarketOrder(t *testing.T) { 57 | ctx := context.Background() 58 | 59 | storage, err := storage.FromMemory() 60 | require.NoError(t, err) 61 | 62 | strategy := new(fakeStrategy) 63 | csvFeed, err := exchange.NewCSVFeed( 64 | strategy.Timeframe(), 65 | exchange.PairFeed{ 66 | Pair: "BTCUSDT", 67 | File: "testdata/btc-1h.csv", 68 | Timeframe: "1h", 69 | }, 70 | exchange.PairFeed{ 71 | Pair: "ETHUSDT", 72 | File: "testdata/eth-1h.csv", 73 | Timeframe: "1h", 74 | }, 75 | ) 76 | require.NoError(t, err) 77 | 78 | paperWallet := exchange.NewPaperWallet( 79 | ctx, 80 | "USDT", 81 | exchange.WithPaperAsset("USDT", 10000), 82 | exchange.WithDataFeed(csvFeed), 83 | ) 84 | 85 | bot, err := NewBot(ctx, Settings{ 86 | Pairs: []string{ 87 | "BTCUSDT", 88 | "ETHUSDT", 89 | }, 90 | }, 91 | paperWallet, 92 | strategy, 93 | WithStorage(storage), 94 | WithBacktest(paperWallet), 95 | WithLogLevel(log.ErrorLevel), 96 | ) 97 | require.NoError(t, err) 98 | require.NoError(t, bot.Run(ctx)) 99 | 100 | assets, quote, err := bot.paperWallet.Position("BTCUSDT") 101 | require.NoError(t, err) 102 | require.Equal(t, assets, 0.0) 103 | require.InDelta(t, quote, 22930.9622, 0.001) 104 | 105 | results := bot.orderController.Results["BTCUSDT"] 106 | require.InDelta(t, 5340.224, results.Profit(), 0.001) 107 | require.Len(t, results.Win(), 5) 108 | require.Len(t, results.Lose(), 3) 109 | 110 | results = bot.orderController.Results["ETHUSDT"] 111 | require.InDelta(t, 7590.7381, results.Profit(), 0.001) 112 | require.Len(t, results.Win(), 7) 113 | require.Len(t, results.Lose(), 9) 114 | 115 | bot.Summary() 116 | } 117 | -------------------------------------------------------------------------------- /notification/mail.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "fmt" 5 | "net/smtp" 6 | 7 | log "github.com/sirupsen/logrus" 8 | 9 | "github.com/rodrigo-brito/ninjabot/model" 10 | ) 11 | 12 | type Mail struct { 13 | auth smtp.Auth 14 | 15 | smtpServerPort int 16 | smtpServerAddress string 17 | 18 | to string 19 | from string 20 | } 21 | 22 | func (t Mail) Notify(text string) { 23 | serverAddress := fmt.Sprintf( 24 | "%s:%d", 25 | t.smtpServerAddress, 26 | t.smtpServerPort) 27 | 28 | message := fmt.Sprintf( 29 | `To: "User" <%s>\nFrom: "NinjaBot" <%s>\n%s`, 30 | t.to, 31 | t.from, 32 | text, 33 | ) 34 | 35 | err := smtp.SendMail( 36 | serverAddress, 37 | t.auth, 38 | t.from, 39 | []string{t.to}, 40 | []byte(message)) 41 | if err != nil { 42 | log. 43 | WithError(err). 44 | Errorf("notification/mail: couldnt send mail") 45 | } 46 | } 47 | 48 | func (t Mail) OnOrder(order model.Order) { 49 | title := "" 50 | switch order.Status { 51 | case model.OrderStatusTypeFilled: 52 | title = fmt.Sprintf("✅ ORDER FILLED - %s", order.Pair) 53 | case model.OrderStatusTypeNew: 54 | title = fmt.Sprintf("🆕 NEW ORDER - %s", order.Pair) 55 | case model.OrderStatusTypeCanceled, model.OrderStatusTypeRejected: 56 | title = fmt.Sprintf("❌ ORDER CANCELED / REJECTED - %s", order.Pair) 57 | } 58 | 59 | message := fmt.Sprintf("Subject: %s\nOrder %s", title, order) 60 | 61 | t.Notify(message) 62 | } 63 | 64 | func (t Mail) OnError(err error) { 65 | message := fmt.Sprintf("Subject: 🛑 ERROR\nError %s", err) 66 | t.Notify(message) 67 | } 68 | 69 | type MailParams struct { 70 | SMTPServerPort int 71 | SMTPServerAddress string 72 | 73 | To string 74 | From string 75 | Password string 76 | } 77 | 78 | func NewMail(params MailParams) Mail { 79 | return Mail{ 80 | from: params.From, 81 | to: params.To, 82 | smtpServerPort: params.SMTPServerPort, 83 | smtpServerAddress: params.SMTPServerAddress, 84 | auth: smtp.PlainAuth( 85 | "", 86 | params.From, 87 | params.Password, 88 | params.SMTPServerAddress, 89 | ), 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /notification/telegram.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | log "github.com/sirupsen/logrus" 12 | tb "gopkg.in/tucnak/telebot.v2" 13 | 14 | "github.com/rodrigo-brito/ninjabot/exchange" 15 | "github.com/rodrigo-brito/ninjabot/model" 16 | "github.com/rodrigo-brito/ninjabot/order" 17 | "github.com/rodrigo-brito/ninjabot/service" 18 | ) 19 | 20 | var ( 21 | buyRegexp = regexp.MustCompile(`/buy\s+(?P\w+)\s+(?P\d+(?:\.\d+)?)(?P%)?`) 22 | sellRegexp = regexp.MustCompile(`/sell\s+(?P\w+)\s+(?P\d+(?:\.\d+)?)(?P%)?`) 23 | ) 24 | 25 | type telegram struct { 26 | settings model.Settings 27 | orderController *order.Controller 28 | defaultMenu *tb.ReplyMarkup 29 | client *tb.Bot 30 | } 31 | 32 | type Option func(telegram *telegram) 33 | 34 | func NewTelegram(controller *order.Controller, settings model.Settings, options ...Option) (service.Telegram, error) { 35 | menu := &tb.ReplyMarkup{ResizeReplyKeyboard: true} 36 | poller := &tb.LongPoller{Timeout: 10 * time.Second} 37 | 38 | userMiddleware := tb.NewMiddlewarePoller(poller, func(u *tb.Update) bool { 39 | if u.Message == nil || u.Message.Sender == nil { 40 | log.Error("no message, ", u) 41 | return false 42 | } 43 | 44 | for _, user := range settings.Telegram.Users { 45 | if int(u.Message.Sender.ID) == user { 46 | return true 47 | } 48 | } 49 | 50 | log.Error("invalid user, ", u.Message) 51 | return false 52 | }) 53 | 54 | client, err := tb.NewBot(tb.Settings{ 55 | ParseMode: tb.ModeMarkdown, 56 | Token: settings.Telegram.Token, 57 | Poller: userMiddleware, 58 | }) 59 | if err != nil { 60 | return nil, err 61 | } 62 | 63 | var ( 64 | statusBtn = menu.Text("/status") 65 | profitBtn = menu.Text("/profit") 66 | balanceBtn = menu.Text("/balance") 67 | startBtn = menu.Text("/start") 68 | stopBtn = menu.Text("/stop") 69 | buyBtn = menu.Text("/buy") 70 | sellBtn = menu.Text("/sell") 71 | ) 72 | 73 | err = client.SetCommands([]tb.Command{ 74 | {Text: "/help", Description: "Display help instructions"}, 75 | {Text: "/stop", Description: "Stop buy and sell coins"}, 76 | {Text: "/start", Description: "Start buy and sell coins"}, 77 | {Text: "/status", Description: "Check bot status"}, 78 | {Text: "/balance", Description: "Wallet balance"}, 79 | {Text: "/profit", Description: "Summary of last trade results"}, 80 | {Text: "/buy", Description: "open a buy order"}, 81 | {Text: "/sell", Description: "open a sell order"}, 82 | }) 83 | if err != nil { 84 | return nil, err 85 | } 86 | 87 | menu.Reply( 88 | menu.Row(statusBtn, balanceBtn, profitBtn), 89 | menu.Row(startBtn, stopBtn, buyBtn, sellBtn), 90 | ) 91 | 92 | bot := &telegram{ 93 | orderController: controller, 94 | client: client, 95 | settings: settings, 96 | defaultMenu: menu, 97 | } 98 | 99 | for _, option := range options { 100 | option(bot) 101 | } 102 | 103 | client.Handle("/help", bot.HelpHandle) 104 | client.Handle("/start", bot.StartHandle) 105 | client.Handle("/stop", bot.StopHandle) 106 | client.Handle("/status", bot.StatusHandle) 107 | client.Handle("/balance", bot.BalanceHandle) 108 | client.Handle("/profit", bot.ProfitHandle) 109 | client.Handle("/buy", bot.BuyHandle) 110 | client.Handle("/sell", bot.SellHandle) 111 | 112 | return bot, nil 113 | } 114 | 115 | func (t telegram) Start() { 116 | go t.client.Start() 117 | for _, id := range t.settings.Telegram.Users { 118 | _, err := t.client.Send(&tb.User{ID: int64(id)}, "Bot initialized.", t.defaultMenu) 119 | if err != nil { 120 | log.Error(err) 121 | } 122 | } 123 | } 124 | 125 | func (t telegram) Notify(text string) { 126 | for _, user := range t.settings.Telegram.Users { 127 | _, err := t.client.Send(&tb.User{ID: int64(user)}, text) 128 | if err != nil { 129 | log.Error(err) 130 | } 131 | } 132 | } 133 | 134 | func (t telegram) BalanceHandle(m *tb.Message) { 135 | message := "*BALANCE*\n" 136 | quotesValue := make(map[string]float64) 137 | total := 0.0 138 | 139 | account, err := t.orderController.Account() 140 | if err != nil { 141 | log.Error(err) 142 | t.OnError(err) 143 | return 144 | } 145 | 146 | for _, pair := range t.settings.Pairs { 147 | assetPair, quotePair := exchange.SplitAssetQuote(pair) 148 | assetBalance, quoteBalance := account.Balance(assetPair, quotePair) 149 | 150 | assetSize := assetBalance.Free + assetBalance.Lock 151 | quoteSize := quoteBalance.Free + quoteBalance.Lock 152 | 153 | quote, err := t.orderController.LastQuote(pair) 154 | if err != nil { 155 | log.Error(err) 156 | t.OnError(err) 157 | return 158 | } 159 | 160 | assetValue := assetSize * quote 161 | quotesValue[quotePair] = quoteSize 162 | total += assetValue 163 | message += fmt.Sprintf("%s: `%.4f` ≅ `%.2f` %s \n", assetPair, assetSize, assetValue, quotePair) 164 | } 165 | 166 | for quote, value := range quotesValue { 167 | total += value 168 | message += fmt.Sprintf("%s: `%.4f`\n", quote, value) 169 | } 170 | 171 | message += fmt.Sprintf("-----\nTotal: `%.4f`\n", total) 172 | 173 | _, err = t.client.Send(m.Sender, message) 174 | if err != nil { 175 | log.Error(err) 176 | } 177 | } 178 | 179 | func (t telegram) HelpHandle(m *tb.Message) { 180 | commands, err := t.client.GetCommands() 181 | if err != nil { 182 | log.Error(err) 183 | t.OnError(err) 184 | return 185 | } 186 | 187 | lines := make([]string, 0, len(commands)) 188 | for _, command := range commands { 189 | lines = append(lines, fmt.Sprintf("/%s - %s", command.Text, command.Description)) 190 | } 191 | 192 | _, err = t.client.Send(m.Sender, strings.Join(lines, "\n")) 193 | if err != nil { 194 | log.Error(err) 195 | } 196 | } 197 | 198 | func (t telegram) ProfitHandle(m *tb.Message) { 199 | if len(t.orderController.Results) == 0 { 200 | _, err := t.client.Send(m.Sender, "No trades registered.") 201 | if err != nil { 202 | log.Error(err) 203 | } 204 | return 205 | } 206 | 207 | for pair, summary := range t.orderController.Results { 208 | _, err := t.client.Send(m.Sender, fmt.Sprintf("*PAIR*: `%s`\n`%s`", pair, summary.String())) 209 | if err != nil { 210 | log.Error(err) 211 | } 212 | } 213 | } 214 | 215 | func (t telegram) BuyHandle(m *tb.Message) { 216 | match := buyRegexp.FindStringSubmatch(m.Text) 217 | if len(match) == 0 { 218 | _, err := t.client.Send(m.Sender, "Invalid command.\nExamples of usage:\n`/buy BTCUSDT 100`\n\n`/buy BTCUSDT 50%`") 219 | if err != nil { 220 | log.Error(err) 221 | } 222 | return 223 | } 224 | 225 | command := make(map[string]string) 226 | for i, name := range buyRegexp.SubexpNames() { 227 | if i != 0 && name != "" { 228 | command[name] = match[i] 229 | } 230 | } 231 | 232 | pair := strings.ToUpper(command["pair"]) 233 | amount, err := strconv.ParseFloat(command["amount"], 64) 234 | if err != nil { 235 | log.Error(err) 236 | t.OnError(err) 237 | return 238 | } else if amount <= 0 { 239 | _, err := t.client.Send(m.Sender, "Invalid amount") 240 | if err != nil { 241 | log.Error(err) 242 | } 243 | return 244 | } 245 | 246 | if command["percent"] != "" { 247 | _, quote, err := t.orderController.Position(pair) 248 | if err != nil { 249 | log.Error(err) 250 | t.OnError(err) 251 | return 252 | } 253 | 254 | amount = amount * quote / 100.0 255 | } 256 | 257 | order, err := t.orderController.CreateOrderMarketQuote(model.SideTypeBuy, pair, amount) 258 | if err != nil { 259 | return 260 | } 261 | log.Info("[TELEGRAM]: BUY ORDER CREATED: ", order) 262 | } 263 | 264 | func (t telegram) SellHandle(m *tb.Message) { 265 | match := sellRegexp.FindStringSubmatch(m.Text) 266 | if len(match) == 0 { 267 | _, err := t.client.Send(m.Sender, "Invalid command.\nExample of usage:\n`/sell BTCUSDT 100`\n\n`/sell BTCUSDT 50%") 268 | if err != nil { 269 | log.Error(err) 270 | } 271 | return 272 | } 273 | 274 | command := make(map[string]string) 275 | for i, name := range sellRegexp.SubexpNames() { 276 | if i != 0 && name != "" { 277 | command[name] = match[i] 278 | } 279 | } 280 | 281 | pair := strings.ToUpper(command["pair"]) 282 | amount, err := strconv.ParseFloat(command["amount"], 64) 283 | if err != nil { 284 | log.Error(err) 285 | t.OnError(err) 286 | return 287 | } else if amount <= 0 { 288 | _, err := t.client.Send(m.Sender, "Invalid amount") 289 | if err != nil { 290 | log.Error(err) 291 | } 292 | return 293 | } 294 | 295 | if command["percent"] != "" { 296 | asset, _, err := t.orderController.Position(pair) 297 | if err != nil { 298 | return 299 | } 300 | 301 | amount = amount * asset / 100.0 302 | order, err := t.orderController.CreateOrderMarket(model.SideTypeSell, pair, amount) 303 | if err != nil { 304 | return 305 | } 306 | log.Info("[TELEGRAM]: SELL ORDER CREATED: ", order) 307 | return 308 | } 309 | 310 | order, err := t.orderController.CreateOrderMarketQuote(model.SideTypeSell, pair, amount) 311 | if err != nil { 312 | return 313 | } 314 | log.Info("[TELEGRAM]: SELL ORDER CREATED: ", order) 315 | } 316 | 317 | func (t telegram) StatusHandle(m *tb.Message) { 318 | status := t.orderController.Status() 319 | _, err := t.client.Send(m.Sender, fmt.Sprintf("Status: `%s`", status)) 320 | if err != nil { 321 | log.Error(err) 322 | } 323 | } 324 | 325 | func (t telegram) StartHandle(m *tb.Message) { 326 | if t.orderController.Status() == order.StatusRunning { 327 | _, err := t.client.Send(m.Sender, "Bot is already running.", t.defaultMenu) 328 | if err != nil { 329 | log.Error(err) 330 | } 331 | return 332 | } 333 | 334 | t.orderController.Start() 335 | _, err := t.client.Send(m.Sender, "Bot started.", t.defaultMenu) 336 | if err != nil { 337 | log.Error(err) 338 | } 339 | } 340 | 341 | func (t telegram) StopHandle(m *tb.Message) { 342 | if t.orderController.Status() == order.StatusStopped { 343 | _, err := t.client.Send(m.Sender, "Bot is already stopped.", t.defaultMenu) 344 | if err != nil { 345 | log.Error(err) 346 | } 347 | return 348 | } 349 | 350 | t.orderController.Stop() 351 | _, err := t.client.Send(m.Sender, "Bot stopped.", t.defaultMenu) 352 | if err != nil { 353 | log.Error(err) 354 | } 355 | } 356 | 357 | func (t telegram) OnOrder(order model.Order) { 358 | title := "" 359 | switch order.Status { 360 | case model.OrderStatusTypeFilled: 361 | title = fmt.Sprintf("✅ ORDER FILLED - %s", order.Pair) 362 | case model.OrderStatusTypeNew: 363 | title = fmt.Sprintf("🆕 NEW ORDER - %s", order.Pair) 364 | case model.OrderStatusTypeCanceled, model.OrderStatusTypeRejected: 365 | title = fmt.Sprintf("❌ ORDER CANCELED / REJECTED - %s", order.Pair) 366 | } 367 | message := fmt.Sprintf("%s\n-----\n%s", title, order) 368 | t.Notify(message) 369 | } 370 | 371 | func (t telegram) OnError(err error) { 372 | title := "🛑 ERROR" 373 | 374 | var orderError *exchange.OrderError 375 | if errors.As(err, &orderError) { 376 | message := fmt.Sprintf(`%s 377 | ----- 378 | Pair: %s 379 | Quantity: %.4f 380 | ----- 381 | %s`, title, orderError.Pair, orderError.Quantity, orderError.Err) 382 | t.Notify(message) 383 | return 384 | } 385 | 386 | t.Notify(fmt.Sprintf("%s\n-----\n%s", title, err)) 387 | } 388 | -------------------------------------------------------------------------------- /order/controller_test.go: -------------------------------------------------------------------------------- 1 | package order 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/rodrigo-brito/ninjabot/exchange" 12 | "github.com/rodrigo-brito/ninjabot/model" 13 | "github.com/rodrigo-brito/ninjabot/storage" 14 | ) 15 | 16 | func TestController_updatePosition(t *testing.T) { 17 | t.Run("market orders", func(t *testing.T) { 18 | storage, err := storage.FromMemory() 19 | require.NoError(t, err) 20 | ctx := context.Background() 21 | wallet := exchange.NewPaperWallet(ctx, "USDT", exchange.WithPaperAsset("USDT", 3000)) 22 | controller := NewController(ctx, wallet, storage, NewOrderFeed()) 23 | 24 | wallet.OnCandle(model.Candle{Pair: "BTCUSDT", Close: 1000}) 25 | _, err = controller.CreateOrderMarket(model.SideTypeBuy, "BTCUSDT", 1) 26 | require.NoError(t, err) 27 | 28 | require.Equal(t, 1000.0, controller.position["BTCUSDT"].AvgPrice) 29 | require.Equal(t, 1.0, controller.position["BTCUSDT"].Quantity) 30 | assert.Equal(t, model.SideTypeBuy, controller.position["BTCUSDT"].Side) 31 | 32 | wallet.OnCandle(model.Candle{Pair: "BTCUSDT", Close: 2000}) 33 | _, err = controller.CreateOrderMarket(model.SideTypeBuy, "BTCUSDT", 1) 34 | require.NoError(t, err) 35 | 36 | require.Equal(t, 1500.0, controller.position["BTCUSDT"].AvgPrice) 37 | require.Equal(t, 2.0, controller.position["BTCUSDT"].Quantity) 38 | 39 | // close half position 1BTC with 100% of profit 40 | wallet.OnCandle(model.Candle{Pair: "BTCUSDT", Close: 3000}) 41 | order, err := controller.CreateOrderMarket(model.SideTypeSell, "BTCUSDT", 1) 42 | require.NoError(t, err) 43 | 44 | assert.Equal(t, 1500.0, controller.position["BTCUSDT"].AvgPrice) 45 | assert.Equal(t, 1.0, controller.position["BTCUSDT"].Quantity) 46 | 47 | assert.Equal(t, 1500.0, order.ProfitValue) 48 | assert.Equal(t, 1.0, order.Profit) 49 | 50 | // sell remaining BTC, 50% of loss 51 | wallet.OnCandle(model.Candle{Pair: "BTCUSDT", Close: 750}) 52 | order, err = controller.CreateOrderMarket(model.SideTypeSell, "BTCUSDT", 1) 53 | require.NoError(t, err) 54 | 55 | assert.Nil(t, controller.position["BTCUSDT"]) // close position 56 | assert.Equal(t, -750.0, order.ProfitValue) 57 | assert.Equal(t, -0.5, order.Profit) 58 | }) 59 | 60 | t.Run("limit order", func(t *testing.T) { 61 | storage, err := storage.FromMemory() 62 | require.NoError(t, err) 63 | ctx := context.Background() 64 | wallet := exchange.NewPaperWallet(ctx, "USDT", exchange.WithPaperAsset("USDT", 3000)) 65 | controller := NewController(ctx, wallet, storage, NewOrderFeed()) 66 | wallet.OnCandle(model.Candle{Time: time.Now(), Pair: "BTCUSDT", High: 1500, Close: 1500}) 67 | 68 | _, err = controller.CreateOrderLimit(model.SideTypeBuy, "BTCUSDT", 1, 1000) 69 | require.NoError(t, err) 70 | 71 | // should execute previous order 72 | wallet.OnCandle(model.Candle{Time: time.Now(), Pair: "BTCUSDT", High: 1000, Close: 1000}) 73 | controller.updateOrders() 74 | 75 | require.Equal(t, 1000.0, controller.position["BTCUSDT"].AvgPrice) 76 | require.Equal(t, 1.0, controller.position["BTCUSDT"].Quantity) 77 | 78 | _, err = controller.CreateOrderLimit(model.SideTypeSell, "BTCUSDT", 1, 2000) 79 | require.NoError(t, err) 80 | 81 | // should execute previous order 82 | wallet.OnCandle(model.Candle{Time: time.Now(), Pair: "BTCUSDT", High: 2000, Close: 2000}) 83 | controller.updateOrders() 84 | 85 | require.Nil(t, controller.position["BTCUSDT"]) 86 | require.Len(t, controller.Results["BTCUSDT"].WinLong, 1) 87 | require.Equal(t, 1000.0, controller.Results["BTCUSDT"].WinLong[0]) 88 | require.Len(t, controller.Results["BTCUSDT"].WinLongPercent, 1) 89 | require.Equal(t, 1.0, controller.Results["BTCUSDT"].WinLongPercent[0]) 90 | }) 91 | 92 | t.Run("oco order limit maker", func(t *testing.T) { 93 | storage, err := storage.FromMemory() 94 | require.NoError(t, err) 95 | ctx := context.Background() 96 | wallet := exchange.NewPaperWallet(ctx, "USDT", exchange.WithPaperAsset("USDT", 3000)) 97 | controller := NewController(ctx, wallet, storage, NewOrderFeed()) 98 | wallet.OnCandle(model.Candle{Time: time.Now(), Pair: "BTCUSDT", High: 1500, Close: 1500}) 99 | 100 | _, err = controller.CreateOrderLimit(model.SideTypeBuy, "BTCUSDT", 1, 1000) 101 | require.NoError(t, err) 102 | 103 | // should execute previous order 104 | wallet.OnCandle(model.Candle{Time: time.Now(), Pair: "BTCUSDT", High: 1000, Close: 1000}) 105 | controller.updateOrders() 106 | 107 | _, err = controller.CreateOrderOCO(model.SideTypeSell, "BTCUSDT", 1, 2000, 500, 500) 108 | require.NoError(t, err) 109 | 110 | // should execute previous order 111 | wallet.OnCandle(model.Candle{Time: time.Now(), Pair: "BTCUSDT", High: 2000, Close: 2000}) 112 | controller.updateOrders() 113 | 114 | require.Nil(t, controller.position["BTCUSDT"]) 115 | require.Len(t, controller.Results["BTCUSDT"].WinLong, 1) 116 | require.Equal(t, 1000.0, controller.Results["BTCUSDT"].WinLong[0]) 117 | require.Len(t, controller.Results["BTCUSDT"].WinLongPercent, 1) 118 | require.Equal(t, 1.0, controller.Results["BTCUSDT"].WinLongPercent[0]) 119 | }) 120 | 121 | t.Run("oco stop sell", func(t *testing.T) { 122 | storage, err := storage.FromMemory() 123 | require.NoError(t, err) 124 | ctx := context.Background() 125 | wallet := exchange.NewPaperWallet(ctx, "USDT", exchange.WithPaperAsset("USDT", 3000)) 126 | controller := NewController(ctx, wallet, storage, NewOrderFeed()) 127 | wallet.OnCandle(model.Candle{Time: time.Now(), Pair: "BTCUSDT", Close: 1500, Low: 1500}) 128 | 129 | _, err = controller.CreateOrderLimit(model.SideTypeBuy, "BTCUSDT", 0.5, 1000) 130 | require.NoError(t, err) 131 | 132 | _, err = controller.CreateOrderLimit(model.SideTypeBuy, "BTCUSDT", 1.5, 1000) 133 | require.NoError(t, err) 134 | 135 | // should execute previous order 136 | wallet.OnCandle(model.Candle{Time: time.Now(), Pair: "BTCUSDT", Close: 1000, Low: 1000}) 137 | controller.updateOrders() 138 | 139 | assert.Equal(t, 1000.0, controller.position["BTCUSDT"].AvgPrice) 140 | assert.Equal(t, 2.0, controller.position["BTCUSDT"].Quantity) 141 | 142 | _, err = controller.CreateOrderMarket(model.SideTypeBuy, "BTCUSDT", 1.0) 143 | require.NoError(t, err) 144 | 145 | assert.Equal(t, 1000.0, controller.position["BTCUSDT"].AvgPrice) 146 | assert.Equal(t, 3.0, controller.position["BTCUSDT"].Quantity) 147 | 148 | _, err = controller.CreateOrderOCO(model.SideTypeSell, "BTCUSDT", 1, 2000, 500, 500) 149 | require.NoError(t, err) 150 | 151 | // should execute previous order 152 | wallet.OnCandle(model.Candle{Time: time.Now(), Pair: "BTCUSDT", Close: 400, Low: 400}) 153 | controller.updateOrders() 154 | 155 | assert.Equal(t, 1000.0, controller.position["BTCUSDT"].AvgPrice) 156 | assert.Equal(t, 2.0, controller.position["BTCUSDT"].Quantity) 157 | 158 | require.Len(t, controller.Results["BTCUSDT"].LoseLong, 1) 159 | require.Equal(t, -500.0, controller.Results["BTCUSDT"].LoseLong[0]) 160 | require.Len(t, controller.Results["BTCUSDT"].LoseLongPercent, 1) 161 | require.Equal(t, -0.5, controller.Results["BTCUSDT"].LoseLongPercent[0]) 162 | }) 163 | 164 | t.Run("short market", func(t *testing.T) { 165 | storage, err := storage.FromMemory() 166 | require.NoError(t, err) 167 | ctx := context.Background() 168 | 169 | wallet := exchange.NewPaperWallet(ctx, "USDT", exchange.WithPaperAsset("USDT", 0), 170 | exchange.WithPaperAsset("BTC", 2)) 171 | controller := NewController(ctx, wallet, storage, NewOrderFeed()) 172 | wallet.OnCandle(model.Candle{Time: time.Now(), Pair: "BTCUSDT", Close: 1500, Low: 1500}) 173 | 174 | _, err = controller.CreateOrderMarket(model.SideTypeSell, "BTCUSDT", 1) 175 | require.NoError(t, err) 176 | 177 | assert.Equal(t, model.SideTypeSell, controller.position["BTCUSDT"].Side) 178 | assert.Equal(t, 1500.0, controller.position["BTCUSDT"].AvgPrice) 179 | assert.Equal(t, 1.0, controller.position["BTCUSDT"].Quantity) 180 | }) 181 | } 182 | 183 | func TestController_PositionValue(t *testing.T) { 184 | storage, err := storage.FromMemory() 185 | require.NoError(t, err) 186 | ctx := context.Background() 187 | wallet := exchange.NewPaperWallet(ctx, "USDT", exchange.WithPaperAsset("USDT", 3000)) 188 | controller := NewController(ctx, wallet, storage, NewOrderFeed()) 189 | 190 | lastCandle := model.Candle{Time: time.Now(), Pair: "BTCUSDT", Close: 1500, Low: 1500} 191 | 192 | // update wallet and controller 193 | wallet.OnCandle(lastCandle) 194 | controller.OnCandle(lastCandle) 195 | 196 | _, err = controller.CreateOrderMarket(model.SideTypeBuy, "BTCUSDT", 1.0) 197 | require.NoError(t, err) 198 | 199 | value, err := controller.PositionValue("BTCUSDT") 200 | require.NoError(t, err) 201 | assert.Equal(t, 1500.0, value) 202 | } 203 | 204 | func TestController_Position(t *testing.T) { 205 | storage, err := storage.FromMemory() 206 | require.NoError(t, err) 207 | ctx := context.Background() 208 | wallet := exchange.NewPaperWallet(ctx, "USDT", exchange.WithPaperAsset("USDT", 3000)) 209 | controller := NewController(ctx, wallet, storage, NewOrderFeed()) 210 | 211 | lastCandle := model.Candle{Time: time.Now(), Pair: "BTCUSDT", Close: 1500, Low: 1500} 212 | 213 | // update wallet and controller 214 | wallet.OnCandle(lastCandle) 215 | controller.OnCandle(lastCandle) 216 | 217 | _, err = controller.CreateOrderMarket(model.SideTypeBuy, "BTCUSDT", 1.0) 218 | require.NoError(t, err) 219 | 220 | asset, quote, err := controller.Position("BTCUSDT") 221 | require.NoError(t, err) 222 | assert.Equal(t, 1.0, asset) 223 | assert.Equal(t, 1500.0, quote) 224 | } 225 | -------------------------------------------------------------------------------- /order/feed.go: -------------------------------------------------------------------------------- 1 | package order 2 | 3 | import ( 4 | "github.com/rodrigo-brito/ninjabot/model" 5 | ) 6 | 7 | type DataFeed struct { 8 | Data chan model.Order 9 | Err chan error 10 | } 11 | 12 | type FeedConsumer func(order model.Order) 13 | 14 | type Feed struct { 15 | OrderFeeds map[string]*DataFeed 16 | SubscriptionsBySymbol map[string][]Subscription 17 | } 18 | 19 | type Subscription struct { 20 | onlyNewOrder bool 21 | consumer FeedConsumer 22 | } 23 | 24 | func NewOrderFeed() *Feed { 25 | return &Feed{ 26 | OrderFeeds: make(map[string]*DataFeed), 27 | SubscriptionsBySymbol: make(map[string][]Subscription), 28 | } 29 | } 30 | 31 | func (d *Feed) Subscribe(pair string, consumer FeedConsumer, onlyNewOrder bool) { 32 | if _, ok := d.OrderFeeds[pair]; !ok { 33 | d.OrderFeeds[pair] = &DataFeed{ 34 | Data: make(chan model.Order), 35 | Err: make(chan error), 36 | } 37 | } 38 | 39 | d.SubscriptionsBySymbol[pair] = append(d.SubscriptionsBySymbol[pair], Subscription{ 40 | onlyNewOrder: onlyNewOrder, 41 | consumer: consumer, 42 | }) 43 | } 44 | 45 | func (d *Feed) Publish(order model.Order, _ bool) { 46 | if _, ok := d.OrderFeeds[order.Pair]; ok { 47 | d.OrderFeeds[order.Pair].Data <- order 48 | } 49 | } 50 | 51 | func (d *Feed) Start() { 52 | for pair := range d.OrderFeeds { 53 | go func(pair string, feed *DataFeed) { 54 | for order := range feed.Data { 55 | for _, subscription := range d.SubscriptionsBySymbol[pair] { 56 | subscription.consumer(order) 57 | } 58 | } 59 | }(pair, d.OrderFeeds[pair]) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /order/feed_test.go: -------------------------------------------------------------------------------- 1 | package order 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/rodrigo-brito/ninjabot/model" 9 | ) 10 | 11 | func TestFeed_NewOrderFeed(t *testing.T) { 12 | feed := NewOrderFeed() 13 | require.NotEmpty(t, feed) 14 | } 15 | 16 | func TestFeed_Subscribe(t *testing.T) { 17 | feed, pair := NewOrderFeed(), "blaus" 18 | called := make(chan bool, 1) 19 | 20 | feed.Subscribe(pair, func(_ model.Order) { 21 | called <- true 22 | }, false) 23 | 24 | feed.Start() 25 | feed.Publish(model.Order{Pair: pair}, false) 26 | require.True(t, <-called) 27 | } 28 | -------------------------------------------------------------------------------- /plot/assets/chart.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | Ninja Bot - Trade Results 11 | 12 | 13 | 14 | 107 | 108 | 127 |
128 | 129 | 130 | -------------------------------------------------------------------------------- /plot/assets/chart.js: -------------------------------------------------------------------------------- 1 | const LIMIT_TYPE = "LIMIT"; 2 | const MARKET_TYPE = "MARKET"; 3 | const STOP_LOSS_TYPE = "STOP_LOSS"; 4 | const LIMIT_MAKER_TYPE = "LIMIT_MAKER"; 5 | 6 | const SELL_SIDE = "SELL"; 7 | const BUY_SIDE = "BUY"; 8 | 9 | const STATUS_FILLED = "FILLED"; 10 | 11 | function unpack(rows, key) { 12 | return rows.map(function (row) { 13 | return row[key]; 14 | }); 15 | } 16 | 17 | document.addEventListener("DOMContentLoaded", function () { 18 | const params = new URLSearchParams(window.location.search); 19 | const pair = params.get("pair") || ""; 20 | fetch("/data?pair=" + pair) 21 | .then((data) => data.json()) 22 | .then((data) => { 23 | const candleStickData = { 24 | name: "Candles", 25 | x: unpack(data.candles, "time"), 26 | close: unpack(data.candles, "close"), 27 | open: unpack(data.candles, "open"), 28 | low: unpack(data.candles, "low"), 29 | high: unpack(data.candles, "high"), 30 | type: "candlestick", 31 | xaxis: "x1", 32 | yaxis: "y2", 33 | }; 34 | 35 | const equityData = { 36 | name: `Equity (${data.quote})`, 37 | x: unpack(data.equity_values, "time"), 38 | y: unpack(data.equity_values, "value"), 39 | mode: "lines", 40 | fill: "tozeroy", 41 | xaxis: "x1", 42 | yaxis: "y1", 43 | }; 44 | 45 | const assetData = { 46 | name: `Position (${data.asset}/${data.quote})`, 47 | x: unpack(data.asset_values, "time"), 48 | y: unpack(data.asset_values, "value"), 49 | mode: "lines", 50 | fill: "tozeroy", 51 | xaxis: "x1", 52 | yaxis: "y1", 53 | }; 54 | 55 | const points = []; 56 | const annotations = []; 57 | data.candles.forEach((candle) => { 58 | candle.orders 59 | .filter((o) => o.status === STATUS_FILLED) 60 | .forEach((order) => { 61 | const point = { 62 | time: candle.time, 63 | position: order.price, 64 | side: order.side, 65 | color: "green", 66 | }; 67 | if (order.side === SELL_SIDE) { 68 | point.color = "red"; 69 | } 70 | points.push(point); 71 | 72 | const annotation = { 73 | x: candle.time, 74 | y: candle.low, 75 | xref: "x1", 76 | yref: "y2", 77 | text: "B", 78 | hovertext: `${order.updated_at} 79 |
ID: ${order.id} 80 |
Price: ${order.price.toLocaleString()} 81 |
Size: ${order.quantity 82 | .toPrecision(4) 83 | .toLocaleString()}
Type: ${order.type}
${ 84 | (order.profit && 85 | "Profit: " + 86 | +(order.profit * 100).toPrecision(2).toLocaleString() + 87 | "%") || 88 | "" 89 | }`, 90 | showarrow: true, 91 | arrowcolor: "green", 92 | valign: "bottom", 93 | borderpad: 4, 94 | arrowhead: 2, 95 | ax: 0, 96 | ay: 20, 97 | font: { 98 | size: 12, 99 | color: "green", 100 | }, 101 | }; 102 | 103 | if (order.side === SELL_SIDE) { 104 | annotation.font.color = "red"; 105 | annotation.arrowcolor = "red"; 106 | annotation.text = "S"; 107 | annotation.y = candle.high; 108 | annotation.ay = -20; 109 | annotation.valign = "top"; 110 | } 111 | 112 | annotations.push(annotation); 113 | }); 114 | }); 115 | 116 | const shapes = data.shapes.map((s) => { 117 | return { 118 | type: "rect", 119 | xref: "x1", 120 | yref: "y2", 121 | yaxis: "y2", 122 | xaxis: "x1", 123 | x0: s.x0, 124 | y0: s.y0, 125 | x1: s.x1, 126 | y1: s.y1, 127 | line: { 128 | width: 0, 129 | }, 130 | fillcolor: s.color, 131 | }; 132 | }); 133 | 134 | // max draw down 135 | if (data.max_drawdown) { 136 | const topPosition = data.equity_values.reduce((p, v) => { 137 | return p > v.value ? p : v.value; 138 | }); 139 | shapes.push({ 140 | type: "rect", 141 | xref: "x1", 142 | yref: "y1", 143 | yaxis: "y1", 144 | xaxis: "x1", 145 | x0: data.max_drawdown.start, 146 | y0: 0, 147 | x1: data.max_drawdown.end, 148 | y1: topPosition, 149 | line: { 150 | width: 0, 151 | }, 152 | fillcolor: "rgba(255,0,0,0.2)", 153 | layer: "below", 154 | }); 155 | 156 | const annotationPosition = new Date( 157 | (new Date(data.max_drawdown.start).getTime() + 158 | new Date(data.max_drawdown.end).getTime()) / 159 | 2 160 | ); 161 | 162 | annotations.push({ 163 | x: annotationPosition, 164 | y: topPosition / 2.0, 165 | xref: "x1", 166 | yref: "y1", 167 | text: `Drawdown
${data.max_drawdown.value}%`, 168 | showarrow: false, 169 | font: { 170 | size: 12, 171 | color: "red", 172 | }, 173 | }); 174 | } 175 | 176 | const sellPoints = points.filter((p) => p.side === SELL_SIDE); 177 | const buyPoints = points.filter((p) => p.side === BUY_SIDE); 178 | const buyData = { 179 | name: "Buy Points", 180 | x: unpack(buyPoints, "time"), 181 | y: unpack(buyPoints, "position"), 182 | xaxis: "x1", 183 | yaxis: "y2", 184 | mode: "markers", 185 | type: "scatter", 186 | marker: { 187 | color: "green", 188 | }, 189 | }; 190 | const sellData = { 191 | name: "Sell Points", 192 | x: unpack(sellPoints, "time"), 193 | y: unpack(sellPoints, "position"), 194 | xaxis: "x1", 195 | yaxis: "y2", 196 | mode: "markers", 197 | type: "scatter", 198 | marker: { 199 | color: "red", 200 | }, 201 | }; 202 | 203 | const standaloneIndicators = data.indicators.reduce( 204 | (total, indicator) => { 205 | if (!indicator.overlay) { 206 | return total + 1; 207 | } 208 | return total; 209 | }, 210 | 0 211 | ); 212 | 213 | let layout = { 214 | template: "ggplot2", 215 | dragmode: "zoom", 216 | margin: { 217 | t: 25, 218 | }, 219 | showlegend: true, 220 | xaxis: { 221 | autorange: true, 222 | rangeslider: { visible: false }, 223 | showline: true, 224 | anchor: standaloneIndicators > 0 ? "y3" : "y2", 225 | }, 226 | yaxis2: { 227 | domain: standaloneIndicators > 0 ? [0.4, 0.9] : [0, 0.9], 228 | autorange: true, 229 | mirror: true, 230 | showline: true, 231 | gridcolor: "#ddd", 232 | }, 233 | yaxis1: { 234 | domain: [0.9, 1], 235 | autorange: true, 236 | mirror: true, 237 | showline: true, 238 | gridcolor: "#ddd", 239 | }, 240 | hovermode: "x unified", 241 | annotations: annotations, 242 | shapes: shapes, 243 | }; 244 | 245 | let plotData = [ 246 | candleStickData, 247 | equityData, 248 | assetData, 249 | buyData, 250 | sellData, 251 | ]; 252 | 253 | const indicatorsHeight = 0.39 / standaloneIndicators; 254 | let standaloneIndicatorIndex = 0; 255 | data.indicators.forEach((indicator) => { 256 | const axisNumber = standaloneIndicatorIndex + 3; 257 | if (!indicator.overlay) { 258 | const heightStart = standaloneIndicatorIndex * indicatorsHeight; 259 | layout["yaxis" + axisNumber] = { 260 | title: indicator.name, 261 | domain: [heightStart, heightStart + indicatorsHeight], 262 | autorange: true, 263 | mirror: true, 264 | showline: true, 265 | linecolor: "black", 266 | gridcolor: "#ddd", 267 | }; 268 | standaloneIndicatorIndex++; 269 | } 270 | 271 | indicator.metrics.forEach((metric) => { 272 | const data = { 273 | title: indicator.name, 274 | name: indicator.name + (metric.name && " - " + metric.name), 275 | x: metric.time, 276 | y: metric.value, 277 | type: metric.style, 278 | line: { 279 | color: metric.color, 280 | }, 281 | xaxis: "x1", 282 | yaxis: "y2", 283 | }; 284 | if (!indicator.overlay) { 285 | data.yaxis = "y" + axisNumber; 286 | } 287 | plotData.push(data); 288 | }); 289 | }); 290 | Plotly.newPlot("graph", plotData, layout); 291 | }); 292 | }); 293 | -------------------------------------------------------------------------------- /plot/chart_test.go: -------------------------------------------------------------------------------- 1 | package plot 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/rodrigo-brito/ninjabot/exchange" 8 | "github.com/rodrigo-brito/ninjabot/model" 9 | 10 | "github.com/StudioSol/set" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestChart_CandleAndOrder(t *testing.T) { 15 | pair := "ETHUSDT" 16 | c, err := NewChart() 17 | require.NoErrorf(t, err, "error when initial chart") 18 | 19 | candle := model.Candle{ 20 | Pair: "ETHUSDT", 21 | Time: time.Date(2021, 9, 26, 20, 0, 0, 0, time.UTC), 22 | Open: 3057.67, 23 | Close: 3059.37, 24 | Low: 3011.00, 25 | High: 3115.51, 26 | Volume: 87666.8, 27 | Complete: true, 28 | } 29 | c.OnCandle(candle) 30 | 31 | order := model.Order{ 32 | ID: 1, 33 | ExchangeID: 1, 34 | Pair: "ETHUSDT", 35 | Side: "BUY", 36 | Type: "MARKET", 37 | Status: "FILLED", 38 | Price: 3059.37, 39 | Quantity: 1.634323, 40 | CreatedAt: time.Date(2021, 9, 26, 20, 0, 0, 0, time.UTC), 41 | UpdatedAt: time.Date(2021, 9, 26, 20, 0, 0, 0, time.UTC), 42 | Stop: nil, 43 | GroupID: nil, 44 | RefPrice: 10, 45 | Profit: 10, 46 | } 47 | c.OnOrder(order) 48 | require.Equal(t, order, c.orderByID[order.ID]) 49 | 50 | //feed candle and oco order 51 | candle2 := model.Candle{ 52 | Pair: "ETHUSDT", 53 | Time: time.Date(2021, 9, 28, 8, 0, 0, 0, time.UTC), 54 | Open: 2894.18, 55 | Close: 2926.80, 56 | Low: 2876.12, 57 | High: 2940.74, 58 | Volume: 88470.1, 59 | Complete: true, 60 | } 61 | c.OnCandle(candle2) 62 | 63 | groupID := int64(3) 64 | limitMakerOrder := model.Order{ 65 | ID: 3, 66 | ExchangeID: 3, 67 | CreatedAt: time.Date(2021, 9, 28, 8, 0, 0, 0, time.UTC), 68 | UpdatedAt: time.Date(2021, 9, 28, 8, 0, 0, 0, time.UTC), 69 | Pair: pair, 70 | Side: "SELL", 71 | Type: model.OrderTypeLimitMaker, 72 | Status: model.OrderStatusTypeNew, 73 | Price: 2926.00, 74 | Quantity: 1.634323, 75 | GroupID: &groupID, 76 | RefPrice: 3059.37, 77 | } 78 | c.OnOrder(limitMakerOrder) 79 | require.Equal(t, limitMakerOrder, c.orderByID[limitMakerOrder.ID]) 80 | 81 | stop := 2900.00 82 | stopOrder := model.Order{ 83 | ID: 4, 84 | ExchangeID: 3, 85 | CreatedAt: time.Date(2021, 9, 28, 8, 0, 0, 0, time.UTC), 86 | UpdatedAt: time.Date(2021, 9, 28, 8, 0, 0, 0, time.UTC), 87 | Pair: pair, 88 | Side: "SELL", 89 | Type: model.OrderTypeStopLoss, 90 | Status: model.OrderStatusTypeNew, 91 | Price: 3000.00, 92 | Stop: &stop, 93 | Quantity: 1.634323, 94 | GroupID: &groupID, 95 | RefPrice: 3059.37, 96 | } 97 | c.OnOrder(stopOrder) 98 | require.Equal(t, stopOrder, c.orderByID[stopOrder.ID]) 99 | 100 | //test candles by pair 101 | expectCandleByPair := []Candle{ 102 | { 103 | Time: time.Date(2021, 9, 26, 20, 0, 0, 0, time.UTC), 104 | Open: 3057.67, 105 | Close: 3059.37, 106 | High: 3115.51, 107 | Low: 3011.00, 108 | Volume: 87666.8, 109 | Orders: []model.Order{ 110 | order, 111 | }, 112 | }, 113 | { 114 | Time: time.Date(2021, 9, 28, 8, 0, 0, 0, time.UTC), 115 | Open: 2894.18, 116 | Close: 2926.80, 117 | High: 2940.74, 118 | Low: 2876.12, 119 | Volume: 88470.1, 120 | Orders: []model.Order{ 121 | limitMakerOrder, 122 | stopOrder, 123 | }, 124 | }, 125 | } 126 | candles := c.candlesByPair(pair) 127 | require.Equal(t, expectCandleByPair, candles) 128 | 129 | //test shapes by pare 130 | expectShapesByPair := []Shape{ 131 | { 132 | StartX: time.Date(2021, 9, 28, 8, 0, 0, 0, time.UTC), 133 | EndX: time.Date(2021, 9, 28, 8, 0, 0, 0, time.UTC), 134 | StartY: 3059.37, 135 | EndY: 2926, 136 | Color: "rgba(0, 255, 0, 0.3)", 137 | }, 138 | { 139 | StartX: time.Date(2021, 9, 28, 8, 0, 0, 0, time.UTC), 140 | EndX: time.Date(2021, 9, 28, 8, 0, 0, 0, time.UTC), 141 | StartY: 3059.37, 142 | EndY: 3000, 143 | Color: "rgba(255, 0, 0, 0.3)", 144 | }, 145 | } 146 | shaped := c.shapesByPair(pair) 147 | require.Equal(t, expectShapesByPair, shaped) 148 | } 149 | 150 | func TestChart_WithPort(t *testing.T) { 151 | port := 8081 152 | c, err := NewChart(WithPort(port)) 153 | require.NoErrorf(t, err, "error when initial chart") 154 | require.Equal(t, port, c.port) 155 | } 156 | 157 | func TestChart_WithPaperWallet(t *testing.T) { 158 | wallet := &exchange.PaperWallet{} 159 | c, err := NewChart(WithPaperWallet(wallet)) 160 | require.NoErrorf(t, err, "error when initial chart") 161 | require.Equal(t, wallet, c.paperWallet) 162 | } 163 | 164 | func TestChart_WithDebug(t *testing.T) { 165 | c, err := NewChart(WithDebug()) 166 | require.NoErrorf(t, err, "error when initial chart") 167 | require.Equal(t, true, c.debug) 168 | } 169 | 170 | func TestChart_WithIndicator(t *testing.T) { 171 | var indicator []Indicator 172 | c, err := NewChart(WithCustomIndicators(indicator...)) 173 | require.NoErrorf(t, err, "error when initial chart") 174 | require.Equal(t, indicator, c.indicators) 175 | } 176 | 177 | func TestChart_OrderStringByPair(t *testing.T) { 178 | c, err := NewChart() 179 | require.NoErrorf(t, err, "error when initial chart") 180 | 181 | pair1 := "ETHUSDT" 182 | pair2 := "BNBUSDT" 183 | order1 := model.Order{ 184 | ID: 1, 185 | Side: "SELL", 186 | Type: "MARKET", 187 | Status: "FILLED", 188 | Price: 3059.37, 189 | Quantity: 4783.34, 190 | CreatedAt: time.Date(2021, 9, 26, 20, 0, 0, 0, time.UTC), 191 | } 192 | order2 := model.Order{ 193 | ID: 2, 194 | Side: "BUY", 195 | Type: "MARKET", 196 | Status: "FILLED", 197 | Price: 3607.42, 198 | Quantity: 0.75152, 199 | CreatedAt: time.Date(2021, 10, 13, 20, 0, 0, 0, time.UTC), 200 | } 201 | 202 | order3 := model.Order{ 203 | ID: 13, 204 | Side: "BUY", 205 | Type: "MARKET", 206 | Status: "FILLED", 207 | Price: 470, 208 | Quantity: 12.08324, 209 | CreatedAt: time.Date(2021, 10, 13, 20, 0, 0, 0, time.UTC), 210 | } 211 | c.ordersIDsByPair[pair1] = set.NewLinkedHashSetINT64() 212 | c.ordersIDsByPair[pair1].Add(order1.ID) 213 | c.orderByID[order1.ID] = order1 214 | 215 | c.ordersIDsByPair[pair1].Add(order2.ID) 216 | c.orderByID[order2.ID] = order2 217 | 218 | c.ordersIDsByPair[pair2] = set.NewLinkedHashSetINT64() 219 | c.ordersIDsByPair[pair2].Add(order3.ID) 220 | c.orderByID[order3.ID] = order3 221 | 222 | expectPair1 := [][]string{ 223 | {"2021-09-26 20:00:00 +0000 UTC", "FILLED", "SELL", "1", "MARKET", "4783.340000", "3059.370000", "14634006.90", ""}, 224 | {"2021-10-13 20:00:00 +0000 UTC", "FILLED", "BUY", "2", "MARKET", "0.751520", "3607.420000", "2711.05", ""}, 225 | } 226 | 227 | ordersPair1 := c.orderStringByPair(pair1) 228 | require.Equal(t, expectPair1, ordersPair1) 229 | 230 | expectPair2 := [][]string{ 231 | {"2021-10-13 20:00:00 +0000 UTC", "FILLED", "BUY", "13", "MARKET", "12.083240", "470.000000", "5679.12", ""}, 232 | } 233 | ordersPair2 := c.orderStringByPair(pair2) 234 | require.Equal(t, expectPair2, ordersPair2) 235 | } 236 | -------------------------------------------------------------------------------- /plot/indicator/bollingerbands.go: -------------------------------------------------------------------------------- 1 | package indicator 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/rodrigo-brito/ninjabot/model" 8 | "github.com/rodrigo-brito/ninjabot/plot" 9 | 10 | "github.com/markcheno/go-talib" 11 | ) 12 | 13 | func BollingerBands(period int, stdDeviation float64, upDnBandColor, midBandColor string) plot.Indicator { 14 | return &bollingerBands{ 15 | Period: period, 16 | StdDeviation: stdDeviation, 17 | UpDnBandColor: upDnBandColor, 18 | MidBandColor: midBandColor, 19 | } 20 | } 21 | 22 | type bollingerBands struct { 23 | Period int 24 | StdDeviation float64 25 | UpDnBandColor string 26 | MidBandColor string 27 | UpperBand model.Series[float64] 28 | MiddleBand model.Series[float64] 29 | LowerBand model.Series[float64] 30 | Time []time.Time 31 | } 32 | 33 | func (bb bollingerBands) Warmup() int { 34 | return bb.Period 35 | } 36 | 37 | func (bb bollingerBands) Name() string { 38 | return fmt.Sprintf("BB(%d, %.2f)", bb.Period, bb.StdDeviation) 39 | } 40 | 41 | func (bb bollingerBands) Overlay() bool { 42 | return true 43 | } 44 | 45 | func (bb *bollingerBands) Load(dataframe *model.Dataframe) { 46 | if len(dataframe.Time) < bb.Period { 47 | return 48 | } 49 | 50 | upper, mid, lower := talib.BBands(dataframe.Close, bb.Period, bb.StdDeviation, bb.StdDeviation, talib.EMA) 51 | bb.UpperBand, bb.MiddleBand, bb.LowerBand = upper[bb.Period:], mid[bb.Period:], lower[bb.Period:] 52 | 53 | bb.Time = dataframe.Time[bb.Period:] 54 | } 55 | 56 | func (bb bollingerBands) Metrics() []plot.IndicatorMetric { 57 | return []plot.IndicatorMetric{ 58 | { 59 | Style: "line", 60 | Color: bb.UpDnBandColor, 61 | Values: bb.UpperBand, 62 | Time: bb.Time, 63 | }, 64 | { 65 | Style: "line", 66 | Color: bb.MidBandColor, 67 | Values: bb.MiddleBand, 68 | Time: bb.Time, 69 | }, 70 | { 71 | Style: "line", 72 | Color: bb.UpDnBandColor, 73 | Values: bb.LowerBand, 74 | Time: bb.Time, 75 | }, 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /plot/indicator/cci.go: -------------------------------------------------------------------------------- 1 | package indicator 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/rodrigo-brito/ninjabot/model" 8 | "github.com/rodrigo-brito/ninjabot/plot" 9 | 10 | "github.com/markcheno/go-talib" 11 | ) 12 | 13 | func CCI(period int, color string) plot.Indicator { 14 | return &cci{ 15 | Period: period, 16 | Color: color, 17 | } 18 | } 19 | 20 | type cci struct { 21 | Period int 22 | Color string 23 | Values model.Series[float64] 24 | Time []time.Time 25 | } 26 | 27 | func (c cci) Warmup() int { 28 | return c.Period 29 | } 30 | 31 | func (c cci) Name() string { 32 | return fmt.Sprintf("CCI(%d)", c.Period) 33 | } 34 | 35 | func (c cci) Overlay() bool { 36 | return false 37 | } 38 | 39 | func (c *cci) Load(dataframe *model.Dataframe) { 40 | c.Values = talib.Cci(dataframe.High, dataframe.Low, dataframe.Close, c.Period)[c.Period:] 41 | c.Time = dataframe.Time[c.Period:] 42 | } 43 | 44 | func (c cci) Metrics() []plot.IndicatorMetric { 45 | return []plot.IndicatorMetric{ 46 | { 47 | Color: c.Color, 48 | Style: "line", 49 | Values: c.Values, 50 | Time: c.Time, 51 | }, 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /plot/indicator/ema.go: -------------------------------------------------------------------------------- 1 | package indicator 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/rodrigo-brito/ninjabot/model" 8 | "github.com/rodrigo-brito/ninjabot/plot" 9 | 10 | "github.com/markcheno/go-talib" 11 | ) 12 | 13 | func EMA(period int, color string) plot.Indicator { 14 | return &ema{ 15 | Period: period, 16 | Color: color, 17 | } 18 | } 19 | 20 | type ema struct { 21 | Period int 22 | Color string 23 | Values model.Series[float64] 24 | Time []time.Time 25 | } 26 | 27 | func (e ema) Warmup() int { 28 | return e.Period 29 | } 30 | 31 | func (e ema) Name() string { 32 | return fmt.Sprintf("EMA(%d)", e.Period) 33 | } 34 | 35 | func (e ema) Overlay() bool { 36 | return true 37 | } 38 | 39 | func (e *ema) Load(dataframe *model.Dataframe) { 40 | if len(dataframe.Time) < e.Period { 41 | return 42 | } 43 | 44 | e.Values = talib.Ema(dataframe.Close, e.Period)[e.Period:] 45 | e.Time = dataframe.Time[e.Period:] 46 | } 47 | 48 | func (e ema) Metrics() []plot.IndicatorMetric { 49 | return []plot.IndicatorMetric{ 50 | { 51 | Style: "line", 52 | Color: e.Color, 53 | Values: e.Values, 54 | Time: e.Time, 55 | }, 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /plot/indicator/macd.go: -------------------------------------------------------------------------------- 1 | package indicator 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/rodrigo-brito/ninjabot/model" 8 | "github.com/rodrigo-brito/ninjabot/plot" 9 | 10 | "github.com/markcheno/go-talib" 11 | ) 12 | 13 | func MACD(fast, slow, signal int, colorMACD, colorMACDSignal, colorMACDHist string) plot.Indicator { 14 | return &macd{ 15 | Fast: fast, 16 | Slow: slow, 17 | Signal: signal, 18 | ColorMACD: colorMACD, 19 | ColorMACDSignal: colorMACDSignal, 20 | ColorMACDHist: colorMACDHist, 21 | } 22 | } 23 | 24 | type macd struct { 25 | Fast int 26 | Slow int 27 | Signal int 28 | ColorMACD string 29 | ColorMACDSignal string 30 | ColorMACDHist string 31 | ValuesMACD model.Series[float64] 32 | ValuesMACDSignal model.Series[float64] 33 | ValuesMACDHist model.Series[float64] 34 | Time []time.Time 35 | } 36 | 37 | func (e macd) Warmup() int { 38 | return e.Slow + e.Signal 39 | } 40 | 41 | func (e macd) Name() string { 42 | return fmt.Sprintf("MACD(%d, %d, %d)", e.Fast, e.Slow, e.Signal) 43 | } 44 | 45 | func (e macd) Overlay() bool { 46 | return false 47 | } 48 | 49 | func (e *macd) Load(df *model.Dataframe) { 50 | warmup := e.Slow + e.Signal 51 | e.ValuesMACD, e.ValuesMACDSignal, e.ValuesMACDHist = talib.Macd(df.Close, e.Fast, e.Slow, e.Signal) 52 | e.Time = df.Time[warmup:] 53 | e.ValuesMACD = e.ValuesMACD[warmup:] 54 | e.ValuesMACDSignal = e.ValuesMACDSignal[warmup:] 55 | e.ValuesMACDHist = e.ValuesMACDHist[warmup:] 56 | } 57 | 58 | func (e macd) Metrics() []plot.IndicatorMetric { 59 | return []plot.IndicatorMetric{ 60 | { 61 | Color: e.ColorMACD, 62 | Name: "MACD", 63 | Style: "line", 64 | Values: e.ValuesMACD, 65 | Time: e.Time, 66 | }, 67 | { 68 | Color: e.ColorMACDSignal, 69 | Name: "MACDSignal", 70 | Style: "line", 71 | Values: e.ValuesMACDSignal, 72 | Time: e.Time, 73 | }, 74 | { 75 | Color: e.ColorMACDHist, 76 | Name: "MACDHist", 77 | Style: "bar", 78 | Values: e.ValuesMACDHist, 79 | Time: e.Time, 80 | }, 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /plot/indicator/obv.go: -------------------------------------------------------------------------------- 1 | package indicator 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/rodrigo-brito/ninjabot/model" 7 | "github.com/rodrigo-brito/ninjabot/plot" 8 | 9 | "github.com/markcheno/go-talib" 10 | ) 11 | 12 | func OBV(color string) plot.Indicator { 13 | return &obv{ 14 | Color: color, 15 | } 16 | } 17 | 18 | type obv struct { 19 | Color string 20 | Values model.Series[float64] 21 | Time []time.Time 22 | } 23 | 24 | func (e obv) Warmup() int { 25 | return 0 26 | } 27 | 28 | func (e obv) Name() string { 29 | return "OBV" 30 | } 31 | 32 | func (e obv) Overlay() bool { 33 | return false 34 | } 35 | 36 | func (e *obv) Load(df *model.Dataframe) { 37 | e.Values = talib.Obv(df.Close, df.Volume) 38 | e.Time = df.Time 39 | } 40 | 41 | func (e obv) Metrics() []plot.IndicatorMetric { 42 | return []plot.IndicatorMetric{ 43 | { 44 | Color: e.Color, 45 | Style: "line", 46 | Values: e.Values, 47 | Time: e.Time, 48 | }, 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /plot/indicator/rsi.go: -------------------------------------------------------------------------------- 1 | package indicator 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/rodrigo-brito/ninjabot/model" 8 | "github.com/rodrigo-brito/ninjabot/plot" 9 | 10 | "github.com/markcheno/go-talib" 11 | ) 12 | 13 | func RSI(period int, color string) plot.Indicator { 14 | return &rsi{ 15 | Period: period, 16 | Color: color, 17 | } 18 | } 19 | 20 | type rsi struct { 21 | Period int 22 | Color string 23 | Values model.Series[float64] 24 | Time []time.Time 25 | } 26 | 27 | func (e rsi) Warmup() int { 28 | return e.Period 29 | } 30 | 31 | func (e rsi) Name() string { 32 | return fmt.Sprintf("RSI(%d)", e.Period) 33 | } 34 | 35 | func (e rsi) Overlay() bool { 36 | return false 37 | } 38 | 39 | func (e *rsi) Load(dataframe *model.Dataframe) { 40 | if len(dataframe.Time) < e.Period { 41 | return 42 | } 43 | 44 | e.Values = talib.Rsi(dataframe.Close, e.Period)[e.Period:] 45 | e.Time = dataframe.Time[e.Period:] 46 | } 47 | 48 | func (e rsi) Metrics() []plot.IndicatorMetric { 49 | return []plot.IndicatorMetric{ 50 | { 51 | Color: e.Color, 52 | Style: "line", 53 | Values: e.Values, 54 | Time: e.Time, 55 | }, 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /plot/indicator/sma.go: -------------------------------------------------------------------------------- 1 | package indicator 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/rodrigo-brito/ninjabot/model" 8 | "github.com/rodrigo-brito/ninjabot/plot" 9 | 10 | "github.com/markcheno/go-talib" 11 | ) 12 | 13 | func SMA(period int, color string) plot.Indicator { 14 | return &sma{ 15 | Period: period, 16 | Color: color, 17 | } 18 | } 19 | 20 | type sma struct { 21 | Period int 22 | Color string 23 | Values model.Series[float64] 24 | Time []time.Time 25 | } 26 | 27 | func (s sma) Warmup() int { 28 | return s.Period 29 | } 30 | 31 | func (s sma) Name() string { 32 | return fmt.Sprintf("SMA(%d)", s.Period) 33 | } 34 | 35 | func (s sma) Overlay() bool { 36 | return true 37 | } 38 | 39 | func (s *sma) Load(dataframe *model.Dataframe) { 40 | if len(dataframe.Time) < s.Period { 41 | return 42 | } 43 | 44 | s.Values = talib.Sma(dataframe.Close, s.Period)[s.Period:] 45 | s.Time = dataframe.Time[s.Period:] 46 | } 47 | 48 | func (s sma) Metrics() []plot.IndicatorMetric { 49 | return []plot.IndicatorMetric{ 50 | { 51 | Style: "line", 52 | Color: s.Color, 53 | Values: s.Values, 54 | Time: s.Time, 55 | }, 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /plot/indicator/stoch.go: -------------------------------------------------------------------------------- 1 | package indicator 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/rodrigo-brito/ninjabot/model" 8 | "github.com/rodrigo-brito/ninjabot/plot" 9 | 10 | "github.com/markcheno/go-talib" 11 | ) 12 | 13 | func Stoch(fastK, slowK, slowD int, colorK, colorD string) plot.Indicator { 14 | return &stoch{ 15 | FastK: fastK, 16 | SlowK: slowK, 17 | SlowD: slowD, 18 | ColorK: colorK, 19 | ColorD: colorD, 20 | } 21 | } 22 | 23 | type stoch struct { 24 | FastK int 25 | SlowK int 26 | SlowD int 27 | ColorK string 28 | ColorD string 29 | ValuesK model.Series[float64] 30 | ValuesD model.Series[float64] 31 | Time []time.Time 32 | } 33 | 34 | func (e stoch) Warmup() int { 35 | return e.SlowD + e.SlowK 36 | } 37 | 38 | func (e stoch) Name() string { 39 | return fmt.Sprintf("STOCH(%d, %d, %d)", e.FastK, e.SlowK, e.SlowD) 40 | } 41 | 42 | func (e stoch) Overlay() bool { 43 | return false 44 | } 45 | 46 | func (e *stoch) Load(dataframe *model.Dataframe) { 47 | e.ValuesK, e.ValuesD = talib.Stoch( 48 | dataframe.High, dataframe.Low, dataframe.Close, e.FastK, e.SlowK, talib.SMA, e.SlowD, talib.SMA, 49 | ) 50 | e.Time = dataframe.Time 51 | } 52 | 53 | func (e stoch) Metrics() []plot.IndicatorMetric { 54 | return []plot.IndicatorMetric{ 55 | { 56 | Color: e.ColorK, 57 | Name: "K", 58 | Style: "line", 59 | Values: e.ValuesK, 60 | Time: e.Time, 61 | }, 62 | { 63 | Color: e.ColorD, 64 | Name: "D", 65 | Style: "line", 66 | Values: e.ValuesD, 67 | Time: e.Time, 68 | }, 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /plot/indicator/supertrend.go: -------------------------------------------------------------------------------- 1 | package indicator 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/rodrigo-brito/ninjabot/model" 8 | "github.com/rodrigo-brito/ninjabot/plot" 9 | 10 | "github.com/markcheno/go-talib" 11 | ) 12 | 13 | func Spertrend(period int, factor float64, color string) plot.Indicator { 14 | return &supertrend{ 15 | Period: period, 16 | Factor: factor, 17 | Color: color, 18 | } 19 | } 20 | 21 | type supertrend struct { 22 | Period int 23 | Factor float64 24 | Color string 25 | Close model.Series[float64] 26 | BasicUpperBand model.Series[float64] 27 | FinalUpperBand model.Series[float64] 28 | BasicLowerBand model.Series[float64] 29 | FinalLowerBand model.Series[float64] 30 | SuperTrend model.Series[float64] 31 | Time []time.Time 32 | } 33 | 34 | func (s supertrend) Warmup() int { 35 | return s.Period 36 | } 37 | 38 | func (s supertrend) Name() string { 39 | return fmt.Sprintf("SuperTrend(%d,%.1f)", s.Period, s.Factor) 40 | } 41 | 42 | func (s supertrend) Overlay() bool { 43 | return true 44 | } 45 | 46 | func (s *supertrend) Load(df *model.Dataframe) { 47 | if len(df.Time) < s.Period { 48 | return 49 | } 50 | 51 | atr := talib.Atr(df.High, df.Low, df.Close, s.Period) 52 | s.BasicUpperBand = make([]float64, len(atr)) 53 | s.BasicLowerBand = make([]float64, len(atr)) 54 | s.FinalUpperBand = make([]float64, len(atr)) 55 | s.FinalLowerBand = make([]float64, len(atr)) 56 | s.SuperTrend = make([]float64, len(atr)) 57 | 58 | for i := 1; i < len(s.BasicLowerBand); i++ { 59 | s.BasicUpperBand[i] = (df.High[i]+df.Low[i])/2.0 + atr[i]*s.Factor 60 | s.BasicLowerBand[i] = (df.High[i]+df.Low[i])/2.0 - atr[i]*s.Factor 61 | 62 | if i == 0 { 63 | s.FinalUpperBand[i] = s.BasicUpperBand[i] 64 | } else if s.BasicUpperBand[i] < s.FinalUpperBand[i-1] || 65 | df.Close[i-1] > s.FinalUpperBand[i-1] { 66 | s.FinalUpperBand[i] = s.BasicUpperBand[i] 67 | } else { 68 | s.FinalUpperBand[i] = s.FinalUpperBand[i-1] 69 | } 70 | 71 | if i == 0 || s.BasicLowerBand[i] > s.FinalLowerBand[i-1] || 72 | df.Close[i-1] < s.FinalLowerBand[i-1] { 73 | s.FinalLowerBand[i] = s.BasicLowerBand[i] 74 | } else { 75 | s.FinalLowerBand[i] = s.FinalLowerBand[i-1] 76 | } 77 | 78 | if i == 0 || s.FinalUpperBand[i-1] == s.SuperTrend[i-1] { 79 | if df.Close[i] > s.FinalUpperBand[i] { 80 | s.SuperTrend[i] = s.FinalLowerBand[i] 81 | } else { 82 | s.SuperTrend[i] = s.FinalUpperBand[i] 83 | } 84 | } else { 85 | if df.Close[i] < s.FinalLowerBand[i] { 86 | s.SuperTrend[i] = s.FinalUpperBand[i] 87 | } else { 88 | s.SuperTrend[i] = s.FinalLowerBand[i] 89 | } 90 | } 91 | } 92 | 93 | s.Time = df.Time[s.Period:] 94 | s.SuperTrend = s.SuperTrend[s.Period:] 95 | 96 | } 97 | 98 | func (s supertrend) Metrics() []plot.IndicatorMetric { 99 | return []plot.IndicatorMetric{ 100 | { 101 | Style: "scatter", 102 | Color: s.Color, 103 | Values: s.SuperTrend, 104 | Time: s.Time, 105 | }, 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /plot/indicator/willr.go: -------------------------------------------------------------------------------- 1 | package indicator 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/rodrigo-brito/ninjabot/model" 8 | "github.com/rodrigo-brito/ninjabot/plot" 9 | 10 | "github.com/markcheno/go-talib" 11 | ) 12 | 13 | func WillR(period int, color string) plot.Indicator { 14 | return &willR{ 15 | Period: period, 16 | Color: color, 17 | } 18 | } 19 | 20 | type willR struct { 21 | Period int 22 | Color string 23 | Values model.Series[float64] 24 | Time []time.Time 25 | } 26 | 27 | func (w willR) Warmup() int { 28 | return w.Period 29 | } 30 | 31 | func (w willR) Name() string { 32 | return fmt.Sprintf("%%R(%d)", w.Period) 33 | } 34 | 35 | func (w willR) Overlay() bool { 36 | return false 37 | } 38 | 39 | func (w *willR) Load(dataframe *model.Dataframe) { 40 | if len(dataframe.Time) < w.Period { 41 | return 42 | } 43 | 44 | w.Values = talib.WillR(dataframe.High, dataframe.Low, dataframe.Close, w.Period)[w.Period:] 45 | w.Time = dataframe.Time[w.Period:] 46 | } 47 | 48 | func (w willR) Metrics() []plot.IndicatorMetric { 49 | return []plot.IndicatorMetric{ 50 | { 51 | Style: "line", 52 | Color: w.Color, 53 | Values: w.Values, 54 | Time: w.Time, 55 | }, 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ![Ninjabot](https://user-images.githubusercontent.com/7620947/161434011-adc89d1a-dccb-45a7-8a07-2bb55e62d2d9.png) 2 | 3 | [![tests](https://github.com/rodrigo-brito/ninjabot/actions/workflows/ci.yaml/badge.svg)](https://github.com/rodrigo-brito/ninjabot/actions/workflows/ci.yaml) 4 | [![codecov](https://codecov.io/gh/rodrigo-brito/ninjabot/branch/main/graph/badge.svg)](https://codecov.io/gh/rodrigo-brito/ninjabot) 5 | [![Go Reference](https://pkg.go.dev/badge/github.com/rodrigo-brito/ninjabot.svg)](https://pkg.go.dev/github.com/rodrigo-brito/ninjabot) 6 | [![Discord](https://img.shields.io/discord/960156400376483840?color=5865F2&label=discord)](https://discord.gg/TGCrUH972E) 7 | [![Discord](https://img.shields.io/badge/donate-patreon-red)](https://www.patreon.com/ninjabot_github) 8 | 9 | A fast cryptocurrency trading bot framework implemented in Go. Ninjabot permits users to create and test custom strategies for spot and futures market. 10 | 11 | Docs: https://rodrigo-brito.github.io/ninjabot/ 12 | 13 | | DISCLAIMER | 14 | |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 15 | | This software is for educational purposes only. Do not risk money which you are afraid to lose. USE THE SOFTWARE AT YOUR OWN RISK. THE AUTHORS AND ALL AFFILIATES ASSUME NO RESPONSIBILITY FOR YOUR TRADING RESULTS | 16 | 17 | ## Installation 18 | 19 | `go get -u github.com/rodrigo-brito/ninjabot/...` 20 | 21 | ## Examples of Usage 22 | 23 | Check [examples](examples) directory: 24 | 25 | - Paper Wallet (Live Simulation) 26 | - Backtesting (Simulation with historical data) 27 | - Real Account (Binance) 28 | 29 | ### CLI 30 | 31 | To download historical data you can download ninjabot CLI from: 32 | 33 | - Pre-build binaries in [release page](https://github.com/rodrigo-brito/ninjabot/releases) 34 | - Or with `go install github.com/rodrigo-brito/ninjabot/cmd/ninjabot@latest` 35 | 36 | **Example of usage** 37 | ```bash 38 | # Download candles of BTCUSDT to btc.csv file (Last 30 days, timeframe 1D) 39 | ninjabot download --pair BTCUSDT --timeframe 1d --days 30 --output ./btc.csv 40 | ``` 41 | 42 | ### Backtesting Example 43 | 44 | - Backtesting a custom strategy from [examples](examples) directory: 45 | ``` 46 | go run examples/backtesting/main.go 47 | ``` 48 | 49 | Output: 50 | 51 | ``` 52 | INFO[2023-03-25 13:54] [SETUP] Using paper wallet 53 | INFO[2023-03-25 13:54] [SETUP] Initial Portfolio = 10000.000000 USDT 54 | ---------+--------+-----+------+--------+--------+-----+----------+-----------+ 55 | | PAIR | TRADES | WIN | LOSS | % WIN | PAYOFF | SQN | PROFIT | VOLUME | 56 | +---------+--------+-----+------+--------+--------+-----+----------+-----------+ 57 | | ETHUSDT | 9 | 6 | 3 | 66.7 % | 3.407 | 1.3 | 21748.41 | 407769.64 | 58 | | BTCUSDT | 14 | 6 | 8 | 42.9 % | 5.929 | 1.5 | 13511.66 | 448030.05 | 59 | +---------+--------+-----+------+--------+--------+-----+----------+-----------+ 60 | | TOTAL | 23 | 12 | 11 | 52.2 % | 4.942 | 1.4 | 35260.07 | 855799.68 | 61 | +---------+--------+-----+------+--------+--------+-----+----------+-----------+ 62 | 63 | -- FINAL WALLET -- 64 | 0.0000 BTC = 0.0000 USDT 65 | 0.0000 ETH = 0.0000 USDT 66 | 45260.0735 USDT 67 | 68 | ----- RETURNS ----- 69 | START PORTFOLIO = 10000.00 USDT 70 | FINAL PORTFOLIO = 45260.07 USDT 71 | GROSS PROFIT = 35260.073493 USDT (352.60%) 72 | MARKET CHANGE (B&H) = 407.09% 73 | 74 | ------ RISK ------- 75 | MAX DRAWDOWN = -11.76 % 76 | 77 | ------ VOLUME ----- 78 | BTCUSDT = 448030.05 USDT 79 | ETHUSDT = 407769.64 USDT 80 | TOTAL = 855799.68 USDT 81 | ------------------- 82 | Chart available at http://localhost:8080 83 | 84 | ``` 85 | 86 | ### Plot result 87 | 88 | 89 | 90 | ### Features 91 | 92 | | | Binance Spot | Binance Futures | 93 | |-------------------- |-------------- |-------------------| 94 | | Order Market | :ok: | :ok: | 95 | | Order Market Quote | :ok: | | 96 | | Order Limit | :ok: | :ok: | 97 | | Order Stop | :ok: | :ok: | 98 | | Order OCO | :ok: | | 99 | | Backtesting | :ok: | :ok: | 100 | 101 | - [x] Backtesting 102 | - [x] Paper Wallet (Live Trading with fake wallet) 103 | - [x] Load Feed from CSV 104 | - [x] Order Limit, Market, Stop Limit, OCO 105 | 106 | - [x] Bot Utilities 107 | - [x] CLI to download historical data 108 | - [x] Plot (Candles + Sell / Buy orders, Indicators) 109 | - [x] Telegram Controller (Status, Buy, Sell, and Notification) 110 | - [x] Heikin Ashi candle type support 111 | - [x] Trailing stop tool 112 | - [x] In app order scheduler 113 | 114 | # Roadmap 115 | - [ ] Include Web UI Controller 116 | - [ ] Include more chart indicators - [Details](https://github.com/rodrigo-brito/ninjabot/issues/110) 117 | 118 | ### Exchanges 119 | 120 | Currently, we only support [Binance](https://www.binance.com/en?ref=35723227) exchange. If you want to include support for other exchanges, you need to implement a new `struct` that implements the interface `Exchange`. You can check some examples in [exchange](exchange) directory. 121 | 122 | ### Support the project 123 | 124 | | | Address | 125 | | --- | --- | 126 | |**BTC** | `bc1qpk6yqju6rkz33ntzj8kuepmynmztzydmec2zm4`| 127 | |**ETH** | `0x2226FFe4aBD2Afa84bf7222C2b17BBC65F64555A` | 128 | |**LTC** | `ltc1qj2n9r4yfsm5dnsmmtzhgj8qcj8fjpcvgkd9v3j` | 129 | 130 | **Patreon**: https://www.patreon.com/ninjabot_github 131 | -------------------------------------------------------------------------------- /service/service.go: -------------------------------------------------------------------------------- 1 | //go:generate go run github.com/vektra/mockery/v2 --all --with-expecter --output=../testdata/mocks 2 | 3 | package service 4 | 5 | import ( 6 | "context" 7 | "time" 8 | 9 | "github.com/rodrigo-brito/ninjabot/model" 10 | ) 11 | 12 | type Exchange interface { 13 | Broker 14 | Feeder 15 | } 16 | 17 | type Feeder interface { 18 | AssetsInfo(pair string) model.AssetInfo 19 | LastQuote(ctx context.Context, pair string) (float64, error) 20 | CandlesByPeriod(ctx context.Context, pair, period string, start, end time.Time) ([]model.Candle, error) 21 | CandlesByLimit(ctx context.Context, pair, period string, limit int) ([]model.Candle, error) 22 | CandlesSubscription(ctx context.Context, pair, timeframe string) (chan model.Candle, chan error) 23 | } 24 | 25 | type Broker interface { 26 | Account() (model.Account, error) 27 | Position(pair string) (asset, quote float64, err error) 28 | Order(pair string, id int64) (model.Order, error) 29 | CreateOrderOCO(side model.SideType, pair string, size, price, stop, stopLimit float64) ([]model.Order, error) 30 | CreateOrderLimit(side model.SideType, pair string, size float64, limit float64) (model.Order, error) 31 | CreateOrderMarket(side model.SideType, pair string, size float64) (model.Order, error) 32 | CreateOrderMarketQuote(side model.SideType, pair string, quote float64) (model.Order, error) 33 | CreateOrderStop(pair string, quantity float64, limit float64) (model.Order, error) 34 | Cancel(model.Order) error 35 | } 36 | 37 | type Notifier interface { 38 | Notify(string) 39 | OnOrder(order model.Order) 40 | OnError(err error) 41 | } 42 | 43 | type Telegram interface { 44 | Notifier 45 | Start() 46 | } 47 | -------------------------------------------------------------------------------- /storage/buntdb.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "strconv" 7 | "sync/atomic" 8 | 9 | "github.com/tidwall/buntdb" 10 | 11 | "github.com/rodrigo-brito/ninjabot/model" 12 | ) 13 | 14 | type Bunt struct { 15 | lastID int64 16 | db *buntdb.DB 17 | } 18 | 19 | func FromMemory() (Storage, error) { 20 | return newBunt(":memory:") 21 | } 22 | 23 | func FromFile(file string) (Storage, error) { 24 | return newBunt(file) 25 | } 26 | 27 | func newBunt(sourceFile string) (Storage, error) { 28 | db, err := buntdb.Open(sourceFile) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | err = db.CreateIndex("update_index", "*", buntdb.IndexJSON("updated_at")) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | return &Bunt{ 39 | db: db, 40 | }, nil 41 | } 42 | 43 | func (b *Bunt) getID() int64 { 44 | return atomic.AddInt64(&b.lastID, 1) 45 | } 46 | 47 | func (b *Bunt) CreateOrder(order *model.Order) error { 48 | return b.db.Update(func(tx *buntdb.Tx) error { 49 | order.ID = b.getID() 50 | content, err := json.Marshal(order) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | _, _, err = tx.Set(strconv.FormatInt(order.ID, 10), string(content), nil) 56 | return err 57 | }) 58 | } 59 | 60 | func (b Bunt) UpdateOrder(order *model.Order) error { 61 | return b.db.Update(func(tx *buntdb.Tx) error { 62 | id := strconv.FormatInt(order.ID, 10) 63 | 64 | content, err := json.Marshal(order) 65 | if err != nil { 66 | return err 67 | } 68 | 69 | _, _, err = tx.Set(id, string(content), nil) 70 | return err 71 | }) 72 | } 73 | 74 | func (b Bunt) Orders(filters ...OrderFilter) ([]*model.Order, error) { 75 | orders := make([]*model.Order, 0) 76 | err := b.db.View(func(tx *buntdb.Tx) error { 77 | err := tx.Ascend("update_index", func(_, value string) bool { 78 | var order model.Order 79 | err := json.Unmarshal([]byte(value), &order) 80 | if err != nil { 81 | log.Println(err) 82 | return true 83 | } 84 | 85 | for _, filter := range filters { 86 | if ok := filter(order); !ok { 87 | return true 88 | } 89 | } 90 | 91 | orders = append(orders, &order) 92 | 93 | return true 94 | }) 95 | return err 96 | }) 97 | if err != nil { 98 | return nil, err 99 | } 100 | return orders, nil 101 | } 102 | -------------------------------------------------------------------------------- /storage/buntdb_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestFromFile(t *testing.T) { 11 | file, err := os.CreateTemp(os.TempDir(), "*.db") 12 | require.NoError(t, err) 13 | defer func() { 14 | os.RemoveAll(file.Name()) 15 | }() 16 | db, err := FromFile(file.Name()) 17 | require.NoError(t, err) 18 | require.NotNil(t, db) 19 | } 20 | 21 | func TestNewBunt(t *testing.T) { 22 | repo, err := FromMemory() 23 | require.NoError(t, err) 24 | 25 | storageUseCase(repo, t) 26 | } 27 | -------------------------------------------------------------------------------- /storage/sql.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/samber/lo" 7 | "gorm.io/gorm" 8 | 9 | "github.com/rodrigo-brito/ninjabot/model" 10 | ) 11 | 12 | type SQL struct { 13 | db *gorm.DB 14 | } 15 | 16 | // FromSQL creates a new SQL connections for orders storage. Example of usage: 17 | // 18 | // import "github.com/glebarez/sqlite" 19 | // storage, err := storage.FromSQL(sqlite.Open("sqlite.db"), &gorm.Config{}) 20 | // if err != nil { 21 | // log.Fatal(err) 22 | // } 23 | func FromSQL(dialect gorm.Dialector, opts ...gorm.Option) (Storage, error) { 24 | db, err := gorm.Open(dialect, opts...) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | sqlDB, err := db.DB() 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | sqlDB.SetMaxIdleConns(10) 35 | sqlDB.SetMaxOpenConns(100) 36 | sqlDB.SetConnMaxLifetime(time.Hour) 37 | 38 | err = db.AutoMigrate(&model.Order{}) 39 | if err != nil { 40 | return nil, err 41 | } 42 | 43 | return &SQL{ 44 | db: db, 45 | }, nil 46 | } 47 | 48 | // CreateOrder creates a new order in a SQL database 49 | func (s *SQL) CreateOrder(order *model.Order) error { 50 | result := s.db.Create(order) // pass pointer of data to Create 51 | return result.Error 52 | } 53 | 54 | // UpdateOrder updates a given order 55 | func (s *SQL) UpdateOrder(order *model.Order) error { 56 | o := model.Order{ID: order.ID} 57 | s.db.First(&o) 58 | o = *order 59 | result := s.db.Save(&o) 60 | return result.Error 61 | } 62 | 63 | // Orders filter a list of orders given a filter 64 | func (s *SQL) Orders(filters ...OrderFilter) ([]*model.Order, error) { 65 | orders := make([]*model.Order, 0) 66 | 67 | result := s.db.Find(&orders) 68 | if result.Error != nil && result.Error != gorm.ErrRecordNotFound { 69 | return orders, nil 70 | } 71 | 72 | return lo.Filter(orders, func(order *model.Order, _ int) bool { 73 | for _, filter := range filters { 74 | if !filter(*order) { 75 | return false 76 | } 77 | } 78 | return true 79 | }), nil 80 | } 81 | -------------------------------------------------------------------------------- /storage/sql_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "gorm.io/gorm" 8 | 9 | "github.com/glebarez/sqlite" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestFromSQL(t *testing.T) { 14 | file, err := os.CreateTemp(os.TempDir(), "*.db") 15 | require.NoError(t, err) 16 | defer func() { 17 | os.RemoveAll(file.Name()) 18 | }() 19 | 20 | repo, err := FromSQL(sqlite.Open(file.Name()), &gorm.Config{}) 21 | require.NoError(t, err) 22 | 23 | storageUseCase(repo, t) 24 | } 25 | -------------------------------------------------------------------------------- /storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/rodrigo-brito/ninjabot/model" 7 | ) 8 | 9 | type OrderFilter func(model.Order) bool 10 | 11 | type Storage interface { 12 | CreateOrder(order *model.Order) error 13 | UpdateOrder(order *model.Order) error 14 | Orders(filters ...OrderFilter) ([]*model.Order, error) 15 | } 16 | 17 | func WithStatusIn(status ...model.OrderStatusType) OrderFilter { 18 | return func(order model.Order) bool { 19 | for _, s := range status { 20 | if s == order.Status { 21 | return true 22 | } 23 | } 24 | return false 25 | } 26 | } 27 | 28 | func WithStatus(status model.OrderStatusType) OrderFilter { 29 | return func(order model.Order) bool { 30 | return order.Status == status 31 | } 32 | } 33 | 34 | func WithPair(pair string) OrderFilter { 35 | return func(order model.Order) bool { 36 | return order.Pair == pair 37 | } 38 | } 39 | 40 | func WithUpdateAtBeforeOrEqual(time time.Time) OrderFilter { 41 | return func(order model.Order) bool { 42 | return !order.UpdatedAt.After(time) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /storage/storage_test.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/rodrigo-brito/ninjabot/model" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func storageUseCase(repo Storage, t *testing.T) { 12 | t.Helper() 13 | now := time.Now() 14 | 15 | firstOrder := &model.Order{ 16 | ExchangeID: 1, 17 | Pair: "BTCUSDT", 18 | Side: model.SideTypeBuy, 19 | Type: model.OrderTypeLimit, 20 | Status: model.OrderStatusTypeNew, 21 | Price: 10, 22 | Quantity: 1, 23 | CreatedAt: now.Add(-time.Minute), 24 | UpdatedAt: now.Add(-time.Minute), 25 | } 26 | err := repo.CreateOrder(firstOrder) 27 | require.NoError(t, err) 28 | 29 | secondOrder := &model.Order{ 30 | ExchangeID: 2, 31 | Pair: "ETHUSDT", 32 | Side: model.SideTypeBuy, 33 | Type: model.OrderTypeLimit, 34 | Status: model.OrderStatusTypeFilled, 35 | Price: 10, 36 | Quantity: 1, 37 | CreatedAt: now.Add(time.Minute), 38 | UpdatedAt: now.Add(time.Minute), 39 | } 40 | err = repo.CreateOrder(secondOrder) 41 | require.NoError(t, err) 42 | 43 | t.Run("filter with date restriction", func(t *testing.T) { 44 | orders, err := repo.Orders(WithUpdateAtBeforeOrEqual(now)) 45 | require.NoError(t, err) 46 | require.Len(t, orders, 1) 47 | require.Equal(t, orders[0].ExchangeID, int64(1)) 48 | }) 49 | 50 | t.Run("get all", func(t *testing.T) { 51 | orders, err := repo.Orders() 52 | require.NoError(t, err) 53 | require.Len(t, orders, 2) 54 | require.Equal(t, orders[0].ExchangeID, int64(1)) 55 | require.Equal(t, orders[1].ExchangeID, int64(2)) 56 | }) 57 | 58 | t.Run("pair filter", func(t *testing.T) { 59 | orders, err := repo.Orders(WithPair("ETHUSDT")) 60 | require.NoError(t, err) 61 | require.Len(t, orders, 1) 62 | require.Equal(t, orders[0].Pair, "ETHUSDT") 63 | }) 64 | 65 | t.Run("status filter", func(t *testing.T) { 66 | orders, err := repo.Orders(WithStatusIn(model.OrderStatusTypeFilled)) 67 | require.NoError(t, err) 68 | require.Len(t, orders, 1) 69 | require.Equal(t, orders[0].ID, secondOrder.ID) 70 | }) 71 | 72 | t.Run("update", func(t *testing.T) { 73 | firstOrder.Status = model.OrderStatusTypeCanceled 74 | err := repo.UpdateOrder(firstOrder) 75 | require.NoError(t, err) 76 | 77 | orders, err := repo.Orders(WithStatus(model.OrderStatusTypeCanceled)) 78 | require.NoError(t, err) 79 | require.Len(t, orders, 1) 80 | require.Equal(t, firstOrder.ID, orders[0].ID) 81 | require.Equal(t, firstOrder.Price, orders[0].Price) 82 | require.Equal(t, firstOrder.Quantity, orders[0].Quantity) 83 | }) 84 | } 85 | -------------------------------------------------------------------------------- /strategy/controller.go: -------------------------------------------------------------------------------- 1 | package strategy 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | 6 | "github.com/rodrigo-brito/ninjabot/model" 7 | "github.com/rodrigo-brito/ninjabot/service" 8 | ) 9 | 10 | type Controller struct { 11 | strategy Strategy 12 | dataframe *model.Dataframe 13 | broker service.Broker 14 | started bool 15 | } 16 | 17 | func NewStrategyController(pair string, strategy Strategy, broker service.Broker) *Controller { 18 | dataframe := &model.Dataframe{ 19 | Pair: pair, 20 | Metadata: make(map[string]model.Series[float64]), 21 | } 22 | 23 | return &Controller{ 24 | dataframe: dataframe, 25 | strategy: strategy, 26 | broker: broker, 27 | } 28 | } 29 | 30 | func (s *Controller) Start() { 31 | s.started = true 32 | } 33 | 34 | func (s *Controller) OnPartialCandle(candle model.Candle) { 35 | if !candle.Complete && len(s.dataframe.Close) >= s.strategy.WarmupPeriod() { 36 | if str, ok := s.strategy.(HighFrequencyStrategy); ok { 37 | s.updateDataFrame(candle) 38 | str.Indicators(s.dataframe) 39 | str.OnPartialCandle(s.dataframe, s.broker) 40 | } 41 | } 42 | } 43 | 44 | func (s *Controller) updateDataFrame(candle model.Candle) { 45 | if len(s.dataframe.Time) > 0 && candle.Time.Equal(s.dataframe.Time[len(s.dataframe.Time)-1]) { 46 | last := len(s.dataframe.Time) - 1 47 | s.dataframe.Close[last] = candle.Close 48 | s.dataframe.Open[last] = candle.Open 49 | s.dataframe.High[last] = candle.High 50 | s.dataframe.Low[last] = candle.Low 51 | s.dataframe.Volume[last] = candle.Volume 52 | s.dataframe.Time[last] = candle.Time 53 | for k, v := range candle.Metadata { 54 | s.dataframe.Metadata[k][last] = v 55 | } 56 | } else { 57 | s.dataframe.Close = append(s.dataframe.Close, candle.Close) 58 | s.dataframe.Open = append(s.dataframe.Open, candle.Open) 59 | s.dataframe.High = append(s.dataframe.High, candle.High) 60 | s.dataframe.Low = append(s.dataframe.Low, candle.Low) 61 | s.dataframe.Volume = append(s.dataframe.Volume, candle.Volume) 62 | s.dataframe.Time = append(s.dataframe.Time, candle.Time) 63 | s.dataframe.LastUpdate = candle.Time 64 | for k, v := range candle.Metadata { 65 | s.dataframe.Metadata[k] = append(s.dataframe.Metadata[k], v) 66 | } 67 | } 68 | } 69 | 70 | func (s *Controller) OnCandle(candle model.Candle) { 71 | if len(s.dataframe.Time) > 0 && candle.Time.Before(s.dataframe.Time[len(s.dataframe.Time)-1]) { 72 | log.Errorf("late candle received: %#v", candle) 73 | return 74 | } 75 | 76 | s.updateDataFrame(candle) 77 | 78 | if len(s.dataframe.Close) >= s.strategy.WarmupPeriod() { 79 | sample := s.dataframe.Sample(s.strategy.WarmupPeriod()) 80 | s.strategy.Indicators(&sample) 81 | if s.started { 82 | s.strategy.OnCandle(&sample, s.broker) 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /strategy/indicator.go: -------------------------------------------------------------------------------- 1 | package strategy 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/rodrigo-brito/ninjabot/model" 7 | ) 8 | 9 | type MetricStyle string 10 | 11 | const ( 12 | StyleBar = "bar" 13 | StyleScatter = "scatter" 14 | StyleLine = "line" 15 | StyleHistogram = "histogram" 16 | StyleWaterfall = "waterfall" 17 | ) 18 | 19 | type IndicatorMetric struct { 20 | Name string 21 | Color string 22 | Style MetricStyle // default: line 23 | Values model.Series[float64] 24 | } 25 | 26 | type ChartIndicator struct { 27 | Time []time.Time 28 | Metrics []IndicatorMetric 29 | Overlay bool 30 | GroupName string 31 | Warmup int 32 | } 33 | -------------------------------------------------------------------------------- /strategy/strategy.go: -------------------------------------------------------------------------------- 1 | package strategy 2 | 3 | import ( 4 | "github.com/rodrigo-brito/ninjabot/model" 5 | "github.com/rodrigo-brito/ninjabot/service" 6 | ) 7 | 8 | type Strategy interface { 9 | // Timeframe is the time interval in which the strategy will be executed. eg: 1h, 1d, 1w 10 | Timeframe() string 11 | // WarmupPeriod is the necessary time to wait before executing the strategy, to load data for indicators. 12 | // This time is measured in the period specified in the `Timeframe` function. 13 | WarmupPeriod() int 14 | // Indicators will be executed for each new candle, in order to fill indicators before `OnCandle` function is called. 15 | Indicators(df *model.Dataframe) []ChartIndicator 16 | // OnCandle will be executed for each new candle, after indicators are filled, here you can do your trading logic. 17 | // OnCandle is executed after the candle close. 18 | OnCandle(df *model.Dataframe, broker service.Broker) 19 | } 20 | 21 | type HighFrequencyStrategy interface { 22 | Strategy 23 | 24 | // OnPartialCandle will be executed for each new partial candle, after indicators are filled. 25 | OnPartialCandle(df *model.Dataframe, broker service.Broker) 26 | } 27 | -------------------------------------------------------------------------------- /testdata/btc-1d-2021-05-13.csv: -------------------------------------------------------------------------------- 1 | 1620864000,49537.150000,49670.970000,46000.000000,51367.190000,147332.0,3570506 2 | -------------------------------------------------------------------------------- /testdata/btc-1d-header.csv: -------------------------------------------------------------------------------- 1 | time,open,close,low,high,volume,trades,lsr 2 | 1619395200,49066.760000,54001.390000,48753.440000,54356.620000,86310.8,2174544,1.1 3 | 1619481600,54001.380000,55011.970000,53222.000000,55460.000000,54064.0,1568666,2.2 4 | 1619568000,55011.970000,54846.220000,53813.160000,56428.000000,55130.5,1830042,3.3 5 | 1619654400,54846.230000,53555.000000,52330.940000,55195.840000,52486.0,1763676,1.1 6 | 1619740800,53555.000000,57694.270000,53013.010000,57963.000000,68578.9,2267648,2.2 7 | 1619827200,57697.250000,57800.370000,56956.140000,58458.070000,42600.4,1743013,3.3 8 | 1619913600,57797.350000,56578.210000,56035.250000,57911.020000,36812.9,1371638,1.1 9 | 1620000000,56578.210000,57169.390000,56435.000000,58981.440000,57649.9,2102128,2.2 10 | 1620086400,57169.390000,53200.010000,53046.690000,57200.000000,85324.6,2492629,3.3 11 | 1620172800,53205.050000,57436.110000,52900.000000,58069.820000,77263.9,2378119,1.1 12 | 1620259200,57436.110000,56393.680000,55200.000000,58360.000000,70181.7,2459881,2.2 13 | 1620345600,56393.680000,57314.750000,55241.630000,58650.000000,74542.7,2361692,3.3 14 | 1620432000,57315.490000,58862.050000,56900.000000,59500.000000,69709.9,2311378,1.1 15 | 1620518400,58866.530000,57479.240000,56235.660000,59300.000000,57209.7,1770266,2.2 16 | -------------------------------------------------------------------------------- /testdata/btc-1d.csv: -------------------------------------------------------------------------------- 1 | 1619395200,49066.760000,54001.390000,48753.440000,54356.620000,86310.8,2174544 2 | 1619481600,54001.380000,55011.970000,53222.000000,55460.000000,54064.0,1568666 3 | 1619568000,55011.970000,54846.220000,53813.160000,56428.000000,55130.5,1830042 4 | 1619654400,54846.230000,53555.000000,52330.940000,55195.840000,52486.0,1763676 5 | 1619740800,53555.000000,57694.270000,53013.010000,57963.000000,68578.9,2267648 6 | 1619827200,57697.250000,57800.370000,56956.140000,58458.070000,42600.4,1743013 7 | 1619913600,57797.350000,56578.210000,56035.250000,57911.020000,36812.9,1371638 8 | 1620000000,56578.210000,57169.390000,56435.000000,58981.440000,57649.9,2102128 9 | 1620086400,57169.390000,53200.010000,53046.690000,57200.000000,85324.6,2492629 10 | 1620172800,53205.050000,57436.110000,52900.000000,58069.820000,77263.9,2378119 11 | 1620259200,57436.110000,56393.680000,55200.000000,58360.000000,70181.7,2459881 12 | 1620345600,56393.680000,57314.750000,55241.630000,58650.000000,74542.7,2361692 13 | 1620432000,57315.490000,58862.050000,56900.000000,59500.000000,69709.9,2311378 14 | 1620518400,58866.530000,57479.240000,56235.660000,59300.000000,57209.7,1770266 15 | -------------------------------------------------------------------------------- /testdata/btc-1h-2021-05-13.csv: -------------------------------------------------------------------------------- 1 | 1620864000,49537.150000,49666.990000,46000.000000,50630.000000,19866.0,448521 2 | 1620867600,49661.120000,50522.520000,49305.430000,50600.000000,10089.0,210671 3 | 1620871200,50522.520000,50255.840000,50000.000000,50829.980000,6577.5,140305 4 | 1620874800,50257.780000,50461.990000,49544.000000,50490.090000,6253.5,140253 5 | 1620878400,50461.990000,50445.870000,50282.000000,50986.220000,4747.3,115140 6 | 1620882000,50445.870000,50884.090000,50444.000000,51076.410000,3757.0,103398 7 | 1620885600,50884.090000,51332.740000,50760.420000,51335.000000,3988.4,103187 8 | 1620889200,51332.740000,50879.720000,50605.380000,51367.190000,3632.1,101776 9 | 1620892800,50879.730000,50015.820000,49938.440000,50950.000000,4616.6,127263 10 | 1620896400,50015.830000,49212.910000,48747.890000,50354.280000,8623.5,205034 11 | 1620900000,49212.920000,49171.760000,48901.000000,50243.610000,8782.3,182114 12 | 1620903600,49176.560000,49777.010000,48457.120000,49909.800000,6132.2,152730 13 | 1620907200,49777.010000,50158.050000,49528.460000,50400.000000,4589.2,122933 14 | 1620910800,50159.290000,50500.000000,49594.000000,50585.520000,3970.5,108246 15 | 1620914400,50500.010000,50288.690000,50207.560000,50884.340000,3752.9,104145 16 | 1620918000,50288.680000,49627.640000,49458.680000,50394.200000,4978.8,115590 17 | 1620921600,49627.630000,48635.400000,48434.200000,50429.990000,6007.2,149029 18 | 1620925200,48635.650000,48465.110000,47000.000000,48888.000000,11630.7,244584 19 | 1620928800,48465.110000,47844.040000,47672.590000,48964.410000,6375.3,149295 20 | 1620932400,47844.040000,48568.850000,47832.380000,49384.200000,4923.9,119719 21 | 1620936000,48569.970000,49304.150000,48367.560000,49450.000000,4073.9,100429 22 | 1620939600,49304.110000,49564.940000,49000.000000,49866.030000,2593.2,93588 23 | 1620943200,49564.930000,48857.720000,48838.380000,49879.770000,3571.6,110578 24 | 1620946800,48857.760000,49670.970000,48350.000000,49759.100000,3799.4,121978 25 | -------------------------------------------------------------------------------- /testdata/mocks/Feeder.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.15.0. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import ( 6 | context "context" 7 | 8 | model "github.com/rodrigo-brito/ninjabot/model" 9 | mock "github.com/stretchr/testify/mock" 10 | 11 | time "time" 12 | ) 13 | 14 | // Feeder is an autogenerated mock type for the Feeder type 15 | type Feeder struct { 16 | mock.Mock 17 | } 18 | 19 | type Feeder_Expecter struct { 20 | mock *mock.Mock 21 | } 22 | 23 | func (_m *Feeder) EXPECT() *Feeder_Expecter { 24 | return &Feeder_Expecter{mock: &_m.Mock} 25 | } 26 | 27 | // AssetsInfo provides a mock function with given fields: pair 28 | func (_m *Feeder) AssetsInfo(pair string) model.AssetInfo { 29 | ret := _m.Called(pair) 30 | 31 | var r0 model.AssetInfo 32 | if rf, ok := ret.Get(0).(func(string) model.AssetInfo); ok { 33 | r0 = rf(pair) 34 | } else { 35 | r0 = ret.Get(0).(model.AssetInfo) 36 | } 37 | 38 | return r0 39 | } 40 | 41 | // Feeder_AssetsInfo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AssetsInfo' 42 | type Feeder_AssetsInfo_Call struct { 43 | *mock.Call 44 | } 45 | 46 | // AssetsInfo is a helper method to define mock.On call 47 | // - pair string 48 | func (_e *Feeder_Expecter) AssetsInfo(pair interface{}) *Feeder_AssetsInfo_Call { 49 | return &Feeder_AssetsInfo_Call{Call: _e.mock.On("AssetsInfo", pair)} 50 | } 51 | 52 | func (_c *Feeder_AssetsInfo_Call) Run(run func(pair string)) *Feeder_AssetsInfo_Call { 53 | _c.Call.Run(func(args mock.Arguments) { 54 | run(args[0].(string)) 55 | }) 56 | return _c 57 | } 58 | 59 | func (_c *Feeder_AssetsInfo_Call) Return(_a0 model.AssetInfo) *Feeder_AssetsInfo_Call { 60 | _c.Call.Return(_a0) 61 | return _c 62 | } 63 | 64 | // CandlesByLimit provides a mock function with given fields: ctx, pair, period, limit 65 | func (_m *Feeder) CandlesByLimit(ctx context.Context, pair string, period string, limit int) ([]model.Candle, error) { 66 | ret := _m.Called(ctx, pair, period, limit) 67 | 68 | var r0 []model.Candle 69 | if rf, ok := ret.Get(0).(func(context.Context, string, string, int) []model.Candle); ok { 70 | r0 = rf(ctx, pair, period, limit) 71 | } else { 72 | if ret.Get(0) != nil { 73 | r0 = ret.Get(0).([]model.Candle) 74 | } 75 | } 76 | 77 | var r1 error 78 | if rf, ok := ret.Get(1).(func(context.Context, string, string, int) error); ok { 79 | r1 = rf(ctx, pair, period, limit) 80 | } else { 81 | r1 = ret.Error(1) 82 | } 83 | 84 | return r0, r1 85 | } 86 | 87 | // Feeder_CandlesByLimit_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CandlesByLimit' 88 | type Feeder_CandlesByLimit_Call struct { 89 | *mock.Call 90 | } 91 | 92 | // CandlesByLimit is a helper method to define mock.On call 93 | // - ctx context.Context 94 | // - pair string 95 | // - period string 96 | // - limit int 97 | func (_e *Feeder_Expecter) CandlesByLimit(ctx interface{}, pair interface{}, period interface{}, limit interface{}) *Feeder_CandlesByLimit_Call { 98 | return &Feeder_CandlesByLimit_Call{Call: _e.mock.On("CandlesByLimit", ctx, pair, period, limit)} 99 | } 100 | 101 | func (_c *Feeder_CandlesByLimit_Call) Run(run func(ctx context.Context, pair string, period string, limit int)) *Feeder_CandlesByLimit_Call { 102 | _c.Call.Run(func(args mock.Arguments) { 103 | run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(int)) 104 | }) 105 | return _c 106 | } 107 | 108 | func (_c *Feeder_CandlesByLimit_Call) Return(_a0 []model.Candle, _a1 error) *Feeder_CandlesByLimit_Call { 109 | _c.Call.Return(_a0, _a1) 110 | return _c 111 | } 112 | 113 | // CandlesByPeriod provides a mock function with given fields: ctx, pair, period, start, end 114 | func (_m *Feeder) CandlesByPeriod(ctx context.Context, pair string, period string, start time.Time, end time.Time) ([]model.Candle, error) { 115 | ret := _m.Called(ctx, pair, period, start, end) 116 | 117 | var r0 []model.Candle 118 | if rf, ok := ret.Get(0).(func(context.Context, string, string, time.Time, time.Time) []model.Candle); ok { 119 | r0 = rf(ctx, pair, period, start, end) 120 | } else { 121 | if ret.Get(0) != nil { 122 | r0 = ret.Get(0).([]model.Candle) 123 | } 124 | } 125 | 126 | var r1 error 127 | if rf, ok := ret.Get(1).(func(context.Context, string, string, time.Time, time.Time) error); ok { 128 | r1 = rf(ctx, pair, period, start, end) 129 | } else { 130 | r1 = ret.Error(1) 131 | } 132 | 133 | return r0, r1 134 | } 135 | 136 | // Feeder_CandlesByPeriod_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CandlesByPeriod' 137 | type Feeder_CandlesByPeriod_Call struct { 138 | *mock.Call 139 | } 140 | 141 | // CandlesByPeriod is a helper method to define mock.On call 142 | // - ctx context.Context 143 | // - pair string 144 | // - period string 145 | // - start time.Time 146 | // - end time.Time 147 | func (_e *Feeder_Expecter) CandlesByPeriod(ctx interface{}, pair interface{}, period interface{}, start interface{}, end interface{}) *Feeder_CandlesByPeriod_Call { 148 | return &Feeder_CandlesByPeriod_Call{Call: _e.mock.On("CandlesByPeriod", ctx, pair, period, start, end)} 149 | } 150 | 151 | func (_c *Feeder_CandlesByPeriod_Call) Run(run func(ctx context.Context, pair string, period string, start time.Time, end time.Time)) *Feeder_CandlesByPeriod_Call { 152 | _c.Call.Run(func(args mock.Arguments) { 153 | run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(time.Time), args[4].(time.Time)) 154 | }) 155 | return _c 156 | } 157 | 158 | func (_c *Feeder_CandlesByPeriod_Call) Return(_a0 []model.Candle, _a1 error) *Feeder_CandlesByPeriod_Call { 159 | _c.Call.Return(_a0, _a1) 160 | return _c 161 | } 162 | 163 | // CandlesSubscription provides a mock function with given fields: ctx, pair, timeframe 164 | func (_m *Feeder) CandlesSubscription(ctx context.Context, pair string, timeframe string) (chan model.Candle, chan error) { 165 | ret := _m.Called(ctx, pair, timeframe) 166 | 167 | var r0 chan model.Candle 168 | if rf, ok := ret.Get(0).(func(context.Context, string, string) chan model.Candle); ok { 169 | r0 = rf(ctx, pair, timeframe) 170 | } else { 171 | if ret.Get(0) != nil { 172 | r0 = ret.Get(0).(chan model.Candle) 173 | } 174 | } 175 | 176 | var r1 chan error 177 | if rf, ok := ret.Get(1).(func(context.Context, string, string) chan error); ok { 178 | r1 = rf(ctx, pair, timeframe) 179 | } else { 180 | if ret.Get(1) != nil { 181 | r1 = ret.Get(1).(chan error) 182 | } 183 | } 184 | 185 | return r0, r1 186 | } 187 | 188 | // Feeder_CandlesSubscription_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CandlesSubscription' 189 | type Feeder_CandlesSubscription_Call struct { 190 | *mock.Call 191 | } 192 | 193 | // CandlesSubscription is a helper method to define mock.On call 194 | // - ctx context.Context 195 | // - pair string 196 | // - timeframe string 197 | func (_e *Feeder_Expecter) CandlesSubscription(ctx interface{}, pair interface{}, timeframe interface{}) *Feeder_CandlesSubscription_Call { 198 | return &Feeder_CandlesSubscription_Call{Call: _e.mock.On("CandlesSubscription", ctx, pair, timeframe)} 199 | } 200 | 201 | func (_c *Feeder_CandlesSubscription_Call) Run(run func(ctx context.Context, pair string, timeframe string)) *Feeder_CandlesSubscription_Call { 202 | _c.Call.Run(func(args mock.Arguments) { 203 | run(args[0].(context.Context), args[1].(string), args[2].(string)) 204 | }) 205 | return _c 206 | } 207 | 208 | func (_c *Feeder_CandlesSubscription_Call) Return(_a0 chan model.Candle, _a1 chan error) *Feeder_CandlesSubscription_Call { 209 | _c.Call.Return(_a0, _a1) 210 | return _c 211 | } 212 | 213 | // LastQuote provides a mock function with given fields: ctx, pair 214 | func (_m *Feeder) LastQuote(ctx context.Context, pair string) (float64, error) { 215 | ret := _m.Called(ctx, pair) 216 | 217 | var r0 float64 218 | if rf, ok := ret.Get(0).(func(context.Context, string) float64); ok { 219 | r0 = rf(ctx, pair) 220 | } else { 221 | r0 = ret.Get(0).(float64) 222 | } 223 | 224 | var r1 error 225 | if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { 226 | r1 = rf(ctx, pair) 227 | } else { 228 | r1 = ret.Error(1) 229 | } 230 | 231 | return r0, r1 232 | } 233 | 234 | // Feeder_LastQuote_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LastQuote' 235 | type Feeder_LastQuote_Call struct { 236 | *mock.Call 237 | } 238 | 239 | // LastQuote is a helper method to define mock.On call 240 | // - ctx context.Context 241 | // - pair string 242 | func (_e *Feeder_Expecter) LastQuote(ctx interface{}, pair interface{}) *Feeder_LastQuote_Call { 243 | return &Feeder_LastQuote_Call{Call: _e.mock.On("LastQuote", ctx, pair)} 244 | } 245 | 246 | func (_c *Feeder_LastQuote_Call) Run(run func(ctx context.Context, pair string)) *Feeder_LastQuote_Call { 247 | _c.Call.Run(func(args mock.Arguments) { 248 | run(args[0].(context.Context), args[1].(string)) 249 | }) 250 | return _c 251 | } 252 | 253 | func (_c *Feeder_LastQuote_Call) Return(_a0 float64, _a1 error) *Feeder_LastQuote_Call { 254 | _c.Call.Return(_a0, _a1) 255 | return _c 256 | } 257 | 258 | type mockConstructorTestingTNewFeeder interface { 259 | mock.TestingT 260 | Cleanup(func()) 261 | } 262 | 263 | // NewFeeder creates a new instance of Feeder. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 264 | func NewFeeder(t mockConstructorTestingTNewFeeder) *Feeder { 265 | mock := &Feeder{} 266 | mock.Mock.Test(t) 267 | 268 | t.Cleanup(func() { mock.AssertExpectations(t) }) 269 | 270 | return mock 271 | } 272 | -------------------------------------------------------------------------------- /testdata/mocks/Notifier.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.15.0. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import ( 6 | model "github.com/rodrigo-brito/ninjabot/model" 7 | mock "github.com/stretchr/testify/mock" 8 | ) 9 | 10 | // Notifier is an autogenerated mock type for the Notifier type 11 | type Notifier struct { 12 | mock.Mock 13 | } 14 | 15 | type Notifier_Expecter struct { 16 | mock *mock.Mock 17 | } 18 | 19 | func (_m *Notifier) EXPECT() *Notifier_Expecter { 20 | return &Notifier_Expecter{mock: &_m.Mock} 21 | } 22 | 23 | // Notify provides a mock function with given fields: _a0 24 | func (_m *Notifier) Notify(_a0 string) { 25 | _m.Called(_a0) 26 | } 27 | 28 | // Notifier_Notify_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Notify' 29 | type Notifier_Notify_Call struct { 30 | *mock.Call 31 | } 32 | 33 | // Notify is a helper method to define mock.On call 34 | // - _a0 string 35 | func (_e *Notifier_Expecter) Notify(_a0 interface{}) *Notifier_Notify_Call { 36 | return &Notifier_Notify_Call{Call: _e.mock.On("Notify", _a0)} 37 | } 38 | 39 | func (_c *Notifier_Notify_Call) Run(run func(_a0 string)) *Notifier_Notify_Call { 40 | _c.Call.Run(func(args mock.Arguments) { 41 | run(args[0].(string)) 42 | }) 43 | return _c 44 | } 45 | 46 | func (_c *Notifier_Notify_Call) Return() *Notifier_Notify_Call { 47 | _c.Call.Return() 48 | return _c 49 | } 50 | 51 | // OnError provides a mock function with given fields: err 52 | func (_m *Notifier) OnError(err error) { 53 | _m.Called(err) 54 | } 55 | 56 | // Notifier_OnError_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnError' 57 | type Notifier_OnError_Call struct { 58 | *mock.Call 59 | } 60 | 61 | // OnError is a helper method to define mock.On call 62 | // - err error 63 | func (_e *Notifier_Expecter) OnError(err interface{}) *Notifier_OnError_Call { 64 | return &Notifier_OnError_Call{Call: _e.mock.On("OnError", err)} 65 | } 66 | 67 | func (_c *Notifier_OnError_Call) Run(run func(err error)) *Notifier_OnError_Call { 68 | _c.Call.Run(func(args mock.Arguments) { 69 | run(args[0].(error)) 70 | }) 71 | return _c 72 | } 73 | 74 | func (_c *Notifier_OnError_Call) Return() *Notifier_OnError_Call { 75 | _c.Call.Return() 76 | return _c 77 | } 78 | 79 | // OnOrder provides a mock function with given fields: order 80 | func (_m *Notifier) OnOrder(order model.Order) { 81 | _m.Called(order) 82 | } 83 | 84 | // Notifier_OnOrder_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnOrder' 85 | type Notifier_OnOrder_Call struct { 86 | *mock.Call 87 | } 88 | 89 | // OnOrder is a helper method to define mock.On call 90 | // - order model.Order 91 | func (_e *Notifier_Expecter) OnOrder(order interface{}) *Notifier_OnOrder_Call { 92 | return &Notifier_OnOrder_Call{Call: _e.mock.On("OnOrder", order)} 93 | } 94 | 95 | func (_c *Notifier_OnOrder_Call) Run(run func(order model.Order)) *Notifier_OnOrder_Call { 96 | _c.Call.Run(func(args mock.Arguments) { 97 | run(args[0].(model.Order)) 98 | }) 99 | return _c 100 | } 101 | 102 | func (_c *Notifier_OnOrder_Call) Return() *Notifier_OnOrder_Call { 103 | _c.Call.Return() 104 | return _c 105 | } 106 | 107 | type mockConstructorTestingTNewNotifier interface { 108 | mock.TestingT 109 | Cleanup(func()) 110 | } 111 | 112 | // NewNotifier creates a new instance of Notifier. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 113 | func NewNotifier(t mockConstructorTestingTNewNotifier) *Notifier { 114 | mock := &Notifier{} 115 | mock.Mock.Test(t) 116 | 117 | t.Cleanup(func() { mock.AssertExpectations(t) }) 118 | 119 | return mock 120 | } 121 | -------------------------------------------------------------------------------- /testdata/mocks/Telegram.go: -------------------------------------------------------------------------------- 1 | // Code generated by mockery v2.15.0. DO NOT EDIT. 2 | 3 | package mocks 4 | 5 | import ( 6 | model "github.com/rodrigo-brito/ninjabot/model" 7 | mock "github.com/stretchr/testify/mock" 8 | ) 9 | 10 | // Telegram is an autogenerated mock type for the Telegram type 11 | type Telegram struct { 12 | mock.Mock 13 | } 14 | 15 | type Telegram_Expecter struct { 16 | mock *mock.Mock 17 | } 18 | 19 | func (_m *Telegram) EXPECT() *Telegram_Expecter { 20 | return &Telegram_Expecter{mock: &_m.Mock} 21 | } 22 | 23 | // Notify provides a mock function with given fields: _a0 24 | func (_m *Telegram) Notify(_a0 string) { 25 | _m.Called(_a0) 26 | } 27 | 28 | // Telegram_Notify_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Notify' 29 | type Telegram_Notify_Call struct { 30 | *mock.Call 31 | } 32 | 33 | // Notify is a helper method to define mock.On call 34 | // - _a0 string 35 | func (_e *Telegram_Expecter) Notify(_a0 interface{}) *Telegram_Notify_Call { 36 | return &Telegram_Notify_Call{Call: _e.mock.On("Notify", _a0)} 37 | } 38 | 39 | func (_c *Telegram_Notify_Call) Run(run func(_a0 string)) *Telegram_Notify_Call { 40 | _c.Call.Run(func(args mock.Arguments) { 41 | run(args[0].(string)) 42 | }) 43 | return _c 44 | } 45 | 46 | func (_c *Telegram_Notify_Call) Return() *Telegram_Notify_Call { 47 | _c.Call.Return() 48 | return _c 49 | } 50 | 51 | // OnError provides a mock function with given fields: err 52 | func (_m *Telegram) OnError(err error) { 53 | _m.Called(err) 54 | } 55 | 56 | // Telegram_OnError_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnError' 57 | type Telegram_OnError_Call struct { 58 | *mock.Call 59 | } 60 | 61 | // OnError is a helper method to define mock.On call 62 | // - err error 63 | func (_e *Telegram_Expecter) OnError(err interface{}) *Telegram_OnError_Call { 64 | return &Telegram_OnError_Call{Call: _e.mock.On("OnError", err)} 65 | } 66 | 67 | func (_c *Telegram_OnError_Call) Run(run func(err error)) *Telegram_OnError_Call { 68 | _c.Call.Run(func(args mock.Arguments) { 69 | run(args[0].(error)) 70 | }) 71 | return _c 72 | } 73 | 74 | func (_c *Telegram_OnError_Call) Return() *Telegram_OnError_Call { 75 | _c.Call.Return() 76 | return _c 77 | } 78 | 79 | // OnOrder provides a mock function with given fields: order 80 | func (_m *Telegram) OnOrder(order model.Order) { 81 | _m.Called(order) 82 | } 83 | 84 | // Telegram_OnOrder_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'OnOrder' 85 | type Telegram_OnOrder_Call struct { 86 | *mock.Call 87 | } 88 | 89 | // OnOrder is a helper method to define mock.On call 90 | // - order model.Order 91 | func (_e *Telegram_Expecter) OnOrder(order interface{}) *Telegram_OnOrder_Call { 92 | return &Telegram_OnOrder_Call{Call: _e.mock.On("OnOrder", order)} 93 | } 94 | 95 | func (_c *Telegram_OnOrder_Call) Run(run func(order model.Order)) *Telegram_OnOrder_Call { 96 | _c.Call.Run(func(args mock.Arguments) { 97 | run(args[0].(model.Order)) 98 | }) 99 | return _c 100 | } 101 | 102 | func (_c *Telegram_OnOrder_Call) Return() *Telegram_OnOrder_Call { 103 | _c.Call.Return() 104 | return _c 105 | } 106 | 107 | // Start provides a mock function with given fields: 108 | func (_m *Telegram) Start() { 109 | _m.Called() 110 | } 111 | 112 | // Telegram_Start_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Start' 113 | type Telegram_Start_Call struct { 114 | *mock.Call 115 | } 116 | 117 | // Start is a helper method to define mock.On call 118 | func (_e *Telegram_Expecter) Start() *Telegram_Start_Call { 119 | return &Telegram_Start_Call{Call: _e.mock.On("Start")} 120 | } 121 | 122 | func (_c *Telegram_Start_Call) Run(run func()) *Telegram_Start_Call { 123 | _c.Call.Run(func(args mock.Arguments) { 124 | run() 125 | }) 126 | return _c 127 | } 128 | 129 | func (_c *Telegram_Start_Call) Return() *Telegram_Start_Call { 130 | _c.Call.Return() 131 | return _c 132 | } 133 | 134 | type mockConstructorTestingTNewTelegram interface { 135 | mock.TestingT 136 | Cleanup(func()) 137 | } 138 | 139 | // NewTelegram creates a new instance of Telegram. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. 140 | func NewTelegram(t mockConstructorTestingTNewTelegram) *Telegram { 141 | mock := &Telegram{} 142 | mock.Mock.Test(t) 143 | 144 | t.Cleanup(func() { mock.AssertExpectations(t) }) 145 | 146 | return mock 147 | } 148 | -------------------------------------------------------------------------------- /tools/log/logger.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import "github.com/sirupsen/logrus" 4 | 5 | var ( 6 | WarnLevel = logrus.WarnLevel 7 | InfoLevel = logrus.InfoLevel 8 | DebugLevel = logrus.DebugLevel 9 | ErrorLevel = logrus.ErrorLevel 10 | FatalLevel = logrus.FatalLevel 11 | PanicLevel = logrus.PanicLevel 12 | ) 13 | 14 | type ( 15 | TextFormatter = logrus.TextFormatter 16 | Level = logrus.Level 17 | ) 18 | 19 | func CheckErr(level logrus.Level, err error) { 20 | if err != nil { 21 | Log(level, err) 22 | } 23 | } 24 | 25 | func Log(level logrus.Level, messages ...interface{}) { 26 | switch level { 27 | case logrus.InfoLevel: 28 | logrus.Info(messages...) 29 | case logrus.WarnLevel: 30 | logrus.Warn(messages...) 31 | case logrus.ErrorLevel: 32 | logrus.Error(messages...) 33 | case logrus.FatalLevel: 34 | logrus.Fatal(messages...) 35 | case logrus.PanicLevel: 36 | logrus.Panic(messages...) 37 | case logrus.DebugLevel: 38 | fallthrough 39 | default: 40 | logrus.Debug(messages...) 41 | } 42 | } 43 | 44 | func SetFormatter(formatter logrus.Formatter) { 45 | logrus.SetFormatter(formatter) 46 | } 47 | 48 | func SetLevel(level logrus.Level) { 49 | logrus.SetLevel(level) 50 | } 51 | 52 | func WithField(key string, value interface{}) *logrus.Entry { 53 | return logrus.WithField(key, value) 54 | } 55 | 56 | func WithFields(fields logrus.Fields) *logrus.Entry { 57 | return logrus.WithFields(fields) 58 | } 59 | 60 | func Info(messages ...interface{}) { 61 | logrus.Info(messages...) 62 | } 63 | 64 | func Infof(format string, messages ...interface{}) { 65 | logrus.Infof(format, messages...) 66 | } 67 | 68 | func Warn(messages ...interface{}) { 69 | logrus.Warn(messages...) 70 | } 71 | 72 | func Warnf(format string, messages ...interface{}) { 73 | logrus.Warnf(format, messages...) 74 | } 75 | 76 | func Error(messages ...interface{}) { 77 | logrus.Error(messages...) 78 | } 79 | 80 | func Errorf(format string, messages ...interface{}) { 81 | logrus.Errorf(format, messages...) 82 | } 83 | 84 | func Fatal(messages ...interface{}) { 85 | logrus.Fatal(messages...) 86 | } 87 | 88 | func Debug(messages ...interface{}) { 89 | logrus.Debug(messages...) 90 | } 91 | 92 | func Debugf(format string, messages ...interface{}) { 93 | logrus.Debugf(format, messages...) 94 | } 95 | -------------------------------------------------------------------------------- /tools/metrics/bootstrap.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/samber/lo" 7 | "gonum.org/v1/gonum/stat" 8 | ) 9 | 10 | type BootstrapInterval struct { 11 | Lower float64 12 | Upper float64 13 | StdDev float64 14 | Mean float64 15 | } 16 | 17 | // Bootstrap calculates the confidence interval of a sample using the bootstrap method. 18 | func Bootstrap(values []float64, measure func([]float64) float64, sampleSize int, 19 | confidence float64) BootstrapInterval { 20 | 21 | var data []float64 22 | for i := 0; i < sampleSize; i++ { 23 | samples := make([]float64, len(values)) 24 | for j := 0; j < len(values); j++ { 25 | samples[j] = lo.Sample(values) 26 | } 27 | data = append(data, measure(samples)) 28 | } 29 | 30 | tail := 1 - confidence 31 | 32 | sort.Float64s(data) 33 | mean, stdDev := stat.MeanStdDev(data, nil) 34 | upper := stat.Quantile(1-tail/2, stat.LinInterp, data, nil) 35 | lower := stat.Quantile(tail/2, stat.LinInterp, data, nil) 36 | 37 | return BootstrapInterval{ 38 | Lower: lower, 39 | Upper: upper, 40 | StdDev: stdDev, 41 | Mean: mean, 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tools/metrics/bootstrap_test.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | "gonum.org/v1/gonum/stat" 8 | ) 9 | 10 | func TestBootstrap(t *testing.T) { 11 | values := []float64{7, 9, 10, 10, 12, 14, 15, 16, 16, 17, 19, 20, 21, 21, 23} 12 | result := Bootstrap(values, func(samples []float64) float64 { 13 | return stat.Mean(samples, nil) 14 | }, 10000, 0.95) 15 | 16 | require.InDelta(t, 15.34, result.Mean, 0.1) 17 | require.InDelta(t, 1.24, result.StdDev, 0.1) 18 | require.InDelta(t, 12.9, result.Lower, 0.1) 19 | require.InDelta(t, 17.7, result.Upper, 0.1) 20 | } 21 | -------------------------------------------------------------------------------- /tools/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "math" 5 | 6 | "gonum.org/v1/gonum/stat" 7 | ) 8 | 9 | func Mean(values []float64) float64 { 10 | return stat.Mean(values, nil) 11 | } 12 | 13 | func Payoff(values []float64) float64 { 14 | wins := []float64{} 15 | loses := []float64{} 16 | for _, value := range values { 17 | if value >= 0 { 18 | wins = append(wins, value) 19 | } else { 20 | loses = append(loses, value) 21 | } 22 | } 23 | 24 | return math.Abs(stat.Mean(wins, nil) / stat.Mean(loses, nil)) 25 | } 26 | 27 | func ProfitFactor(values []float64) float64 { 28 | var ( 29 | wins float64 30 | loses float64 31 | ) 32 | 33 | for _, value := range values { 34 | if value >= 0 { 35 | wins += value 36 | } else { 37 | loses += value 38 | } 39 | } 40 | 41 | if loses == 0 { 42 | return 10 43 | } 44 | 45 | return math.Abs(wins / loses) 46 | } 47 | -------------------------------------------------------------------------------- /tools/scheduler.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | import ( 4 | "github.com/rodrigo-brito/ninjabot" 5 | "github.com/rodrigo-brito/ninjabot/service" 6 | "github.com/samber/lo" 7 | log "github.com/sirupsen/logrus" 8 | ) 9 | 10 | type OrderCondition struct { 11 | Condition func(df *ninjabot.Dataframe) bool 12 | Size float64 13 | Side ninjabot.SideType 14 | } 15 | 16 | type Scheduler struct { 17 | pair string 18 | orderConditions []OrderCondition 19 | } 20 | 21 | func NewScheduler(pair string) *Scheduler { 22 | return &Scheduler{pair: pair} 23 | } 24 | 25 | func (s *Scheduler) SellWhen(size float64, condition func(df *ninjabot.Dataframe) bool) { 26 | s.orderConditions = append( 27 | s.orderConditions, 28 | OrderCondition{Condition: condition, Size: size, Side: ninjabot.SideTypeSell}, 29 | ) 30 | } 31 | 32 | func (s *Scheduler) BuyWhen(size float64, condition func(df *ninjabot.Dataframe) bool) { 33 | s.orderConditions = append( 34 | s.orderConditions, 35 | OrderCondition{Condition: condition, Size: size, Side: ninjabot.SideTypeBuy}, 36 | ) 37 | } 38 | 39 | func (s *Scheduler) Update(df *ninjabot.Dataframe, broker service.Broker) { 40 | s.orderConditions = lo.Filter[OrderCondition](s.orderConditions, func(oc OrderCondition, _ int) bool { 41 | if oc.Condition(df) { 42 | _, err := broker.CreateOrderMarket(oc.Side, s.pair, oc.Size) 43 | if err != nil { 44 | log.Error(err) 45 | return true 46 | } 47 | return false 48 | } 49 | return true 50 | }) 51 | } 52 | -------------------------------------------------------------------------------- /tools/tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | // +build tools 3 | 4 | package tools 5 | 6 | import ( 7 | _ "github.com/vektra/mockery/v2" 8 | ) 9 | -------------------------------------------------------------------------------- /tools/trailing.go: -------------------------------------------------------------------------------- 1 | package tools 2 | 3 | type TrailingStop struct { 4 | current float64 5 | stop float64 6 | active bool 7 | } 8 | 9 | func NewTrailingStop() *TrailingStop { 10 | return &TrailingStop{} 11 | } 12 | 13 | func (t *TrailingStop) Start(current, stop float64) { 14 | t.stop = stop 15 | t.current = current 16 | t.active = true 17 | } 18 | 19 | func (t *TrailingStop) Stop() { 20 | t.active = false 21 | } 22 | 23 | func (t TrailingStop) Active() bool { 24 | return t.active 25 | } 26 | 27 | func (t *TrailingStop) Update(current float64) bool { 28 | if !t.active { 29 | return false 30 | } 31 | 32 | if current > t.current { 33 | t.stop = t.stop + (current - t.current) 34 | t.current = current 35 | return false 36 | } 37 | 38 | t.current = current 39 | return current <= t.stop 40 | } 41 | -------------------------------------------------------------------------------- /tools/trailing_test.go: -------------------------------------------------------------------------------- 1 | package tools_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/rodrigo-brito/ninjabot/tools" 9 | ) 10 | 11 | func TestNewTrailingStop(t *testing.T) { 12 | ts := tools.NewTrailingStop() 13 | 14 | require.NotNil(t, ts) 15 | } 16 | 17 | func TestTrailingStop_Start(t *testing.T) { 18 | ts := tools.NewTrailingStop() 19 | ts.Start(21.5, 13.0) 20 | 21 | require.True(t, ts.Active()) 22 | } 23 | 24 | func TestTrailingStop_Stop(t *testing.T) { 25 | ts := tools.NewTrailingStop() 26 | ts.Start(21.5, 13.0) 27 | ts.Stop() 28 | 29 | require.False(t, ts.Active()) 30 | } 31 | 32 | func TestTrailingStop_Update(t *testing.T) { 33 | ts := tools.NewTrailingStop() 34 | 35 | // not started 36 | require.False(t, ts.Update(12.0)) 37 | 38 | current := 21.5 39 | stop := 13.0 40 | 41 | ts.Start(current, stop) 42 | 43 | // When the new value is higher than the current value, the TrailingStop is 44 | // not triggered and the stop value e summed up with the difference of the 45 | // two values. 46 | difference := 5.0 47 | require.False(t, ts.Update(current+difference)) 48 | 49 | // So when called with the new stop value or a lower one, the TrailingStop 50 | // should be triggered. 51 | require.True(t, ts.Update(stop+difference)) 52 | require.True(t, ts.Update(stop-difference)) 53 | } 54 | -------------------------------------------------------------------------------- /types.go: -------------------------------------------------------------------------------- 1 | package ninjabot 2 | 3 | import ( 4 | "github.com/rodrigo-brito/ninjabot/model" 5 | ) 6 | 7 | type ( 8 | Settings = model.Settings 9 | TelegramSettings = model.TelegramSettings 10 | Dataframe = model.Dataframe 11 | Series = model.Series[float64] 12 | SideType = model.SideType 13 | OrderType = model.OrderType 14 | OrderStatusType = model.OrderStatusType 15 | ) 16 | 17 | var ( 18 | SideTypeBuy = model.SideTypeBuy 19 | SideTypeSell = model.SideTypeSell 20 | OrderTypeLimit = model.OrderTypeLimit 21 | OrderTypeMarket = model.OrderTypeMarket 22 | OrderTypeLimitMaker = model.OrderTypeLimitMaker 23 | OrderTypeStopLoss = model.OrderTypeStopLoss 24 | OrderTypeStopLossLimit = model.OrderTypeStopLossLimit 25 | OrderTypeTakeProfit = model.OrderTypeTakeProfit 26 | OrderTypeTakeProfitLimit = model.OrderTypeTakeProfitLimit 27 | OrderStatusTypeNew = model.OrderStatusTypeNew 28 | OrderStatusTypePartiallyFilled = model.OrderStatusTypePartiallyFilled 29 | OrderStatusTypeFilled = model.OrderStatusTypeFilled 30 | OrderStatusTypeCanceled = model.OrderStatusTypeCanceled 31 | OrderStatusTypePendingCancel = model.OrderStatusTypePendingCancel 32 | OrderStatusTypeRejected = model.OrderStatusTypeRejected 33 | OrderStatusTypeExpired = model.OrderStatusTypeExpired 34 | ) 35 | --------------------------------------------------------------------------------