├── testdata
├── btc-1d-2021-05-13.csv
├── btc-1d.csv
├── btc-1d-header.csv
└── btc-1h-2021-05-13.csv
├── Makefile
├── codecov.yml
├── storage
├── sql_test.go
├── buntdb_test.go
├── storage.go
├── sql.go
├── buntdb.go
└── storage_test.go
├── exchange
├── pairs_test.go
├── pairs.go
├── binance_test.go
├── exchange.go
├── csvfeed_test.go
├── csvfeed.go
└── paperwallet_test.go
├── order
├── feed_test.go
├── feed.go
├── controller_test.go
└── controller.go
├── strategy
├── indicator.go
├── strategy.go
└── controller.go
├── model
├── order_test.go
├── series.go
├── priorityqueue_test.go
├── series_test.go
├── order.go
├── priorityqueue.go
├── model.go
└── model_test.go
├── tools
├── trailing.go
├── trailing_test.go
└── scheduler.go
├── plot
├── indicator
│ ├── obv.go
│ ├── cci.go
│ ├── ema.go
│ ├── rsi.go
│ ├── sma.go
│ ├── willr.go
│ ├── stoch.go
│ ├── bollingerbands.go
│ ├── macd.go
│ └── supertrend.go
├── assets
│ ├── chart.html
│ └── chart.js
├── chart_test.go
└── chart.go
├── LICENSE
├── examples
├── binance
│ └── main.go
├── strategies
│ ├── turtle.go
│ ├── emacross.go
│ ├── trailingstop.go
│ └── ocosell.go
├── paperwallet
│ └── main.go
└── backtesting
│ └── main.go
├── types.go
├── indicator
└── supertrend.go
├── service
└── service.go
├── notification
├── mail.go
└── telegram.go
├── cmd
└── ninjabot
│ └── ninjabot.go
├── go.mod
├── download
├── download_test.go
└── download.go
├── ninjabot_test.go
├── README.md
├── readme.md
└── ninjabot.go
/testdata/btc-1d-2021-05-13.csv:
--------------------------------------------------------------------------------
1 | 1620864000,49537.150000,49670.970000,46000.000000,51367.190000,147332.0,3570506
2 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | generate:
2 | go generate ./...
3 | lint:
4 | golangci-lint run
5 | test:
6 | go test -race -cover ./...
7 | release:
8 | goreleaser build --snapshot
9 |
--------------------------------------------------------------------------------
/codecov.yml:
--------------------------------------------------------------------------------
1 | coverage:
2 | status:
3 | project:
4 | default:
5 | informational: true
6 | patch:
7 | default:
8 | informational: true
9 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 | }
19 |
20 | for _, tc := range tt {
21 | t.Run(tc.Pair, func(t *testing.T) {
22 | asset, quote := SplitAssetQuote(tc.Pair)
23 | require.Equal(t, tc.Asset, asset)
24 | require.Equal(t, tc.Quote, quote)
25 | })
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/order/feed_test.go:
--------------------------------------------------------------------------------
1 | package order
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/rodrigo-brito/ninjabot/model"
7 | "github.com/stretchr/testify/require"
8 | )
9 |
10 | func TestFeed_NewOrderFeed(t *testing.T) {
11 | feed := NewOrderFeed()
12 | require.NotEmpty(t, feed)
13 | }
14 |
15 | func TestFeed_Subscribe(t *testing.T) {
16 | feed, pair := NewOrderFeed(), "blaus"
17 | called := make(chan bool, 1)
18 |
19 | feed.Subscribe(pair, func(order model.Order) {
20 | called <- true
21 | }, false)
22 |
23 | feed.Start()
24 | feed.Publish(model.Order{Pair: pair}, false)
25 | require.True(t, <-called)
26 | }
27 |
--------------------------------------------------------------------------------
/strategy/indicator.go:
--------------------------------------------------------------------------------
1 | package strategy
2 |
3 | import (
4 | "github.com/rodrigo-brito/ninjabot/model"
5 | "time"
6 | )
7 |
8 | type MetricStyle string
9 |
10 | const (
11 | StyleBar = "bar"
12 | StyleScatter = "scatter"
13 | StyleLine = "line"
14 | StyleHistogram = "histogram"
15 | StyleWaterfall = "waterfall"
16 | )
17 |
18 | type IndicatorMetric struct {
19 | Name string
20 | Color string
21 | Style MetricStyle // default: line
22 | Values model.Series
23 | }
24 |
25 | type ChartIndicator struct {
26 | Time []time.Time
27 | Metrics []IndicatorMetric
28 | Overlay bool
29 | GroupName string
30 | }
31 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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
21 | Time []time.Time
22 | }
23 |
24 | func (e obv) Name() string {
25 | return "OBV"
26 | }
27 |
28 | func (e obv) Overlay() bool {
29 | return false
30 | }
31 |
32 | func (e *obv) Load(df *model.Dataframe) {
33 | e.Values = talib.Obv(df.Close, df.Volume)
34 | e.Time = df.Time
35 | }
36 |
37 | func (e obv) Metrics() []plot.IndicatorMetric {
38 | return []plot.IndicatorMetric{
39 | {
40 | Color: e.Color,
41 | Style: "line",
42 | Values: e.Values,
43 | Time: e.Time,
44 | },
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/exchange/pairs.go:
--------------------------------------------------------------------------------
1 | package exchange
2 |
3 | import (
4 | "context"
5 | "sync"
6 |
7 | "github.com/adshao/go-binance/v2"
8 | log "github.com/sirupsen/logrus"
9 | )
10 |
11 | type AssetQuote struct {
12 | Quote string
13 | Asset string
14 | }
15 |
16 | var (
17 | once sync.Once
18 | pairAssetQuoteMap = make(map[string]AssetQuote)
19 | )
20 |
21 | func SplitAssetQuote(pair string) (asset string, quote string) {
22 | once.Do(func() {
23 | client := binance.NewClient("", "")
24 | info, err := client.NewExchangeInfoService().Do(context.Background())
25 | if err != nil {
26 | log.Fatalf("failed to get exchange info: %v", err)
27 | }
28 |
29 | for _, info := range info.Symbols {
30 | pairAssetQuoteMap[info.Symbol] = AssetQuote{
31 | Quote: info.QuoteAsset,
32 | Asset: info.BaseAsset,
33 | }
34 | }
35 | })
36 |
37 | data := pairAssetQuoteMap[pair]
38 | return data.Asset, data.Quote
39 | }
40 |
--------------------------------------------------------------------------------
/model/series.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "strconv"
5 | "strings"
6 | )
7 |
8 | type Series []float64
9 |
10 | func (s Series) Values() []float64 {
11 | return s
12 | }
13 |
14 | func (s Series) Last(position int) float64 {
15 | return s[len(s)-1-position]
16 | }
17 |
18 | func (s Series) LastValues(size int) []float64 {
19 | if l := len(s); l > size {
20 | return s[l-size:]
21 | }
22 | return s
23 | }
24 |
25 | func (s Series) Crossover(ref Series) bool {
26 | return s.Last(0) > ref.Last(0) && s.Last(1) <= ref.Last(1)
27 | }
28 |
29 | func (s Series) Crossunder(ref Series) bool {
30 | return s.Last(0) <= ref.Last(0) && s.Last(1) > ref.Last(1)
31 | }
32 |
33 | func (s Series) Cross(ref Series) bool {
34 | return s.Crossover(ref) || s.Crossunder(ref)
35 | }
36 |
37 | func NumDecPlaces(v float64) int64 {
38 | s := strconv.FormatFloat(v, 'f', -1, 64)
39 | i := strings.IndexByte(s, '.')
40 | if i > -1 {
41 | return int64(len(s) - i - 1)
42 | }
43 | return 0
44 | }
45 |
--------------------------------------------------------------------------------
/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
24 | Time []time.Time
25 | }
26 |
27 | func (c cci) Name() string {
28 | return fmt.Sprintf("CCI(%d)", c.Period)
29 | }
30 |
31 | func (c cci) Overlay() bool {
32 | return false
33 | }
34 |
35 | func (c *cci) Load(dataframe *model.Dataframe) {
36 | c.Values = talib.Cci(dataframe.High, dataframe.Low, dataframe.Close, c.Period)[c.Period:]
37 | c.Time = dataframe.Time[c.Period:]
38 | }
39 |
40 | func (c cci) Metrics() []plot.IndicatorMetric {
41 | return []plot.IndicatorMetric{
42 | {
43 | Color: c.Color,
44 | Style: "line",
45 | Values: c.Values,
46 | Time: c.Time,
47 | },
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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
24 | Time []time.Time
25 | }
26 |
27 | func (e ema) Name() string {
28 | return fmt.Sprintf("EMA(%d)", e.Period)
29 | }
30 |
31 | func (e ema) Overlay() bool {
32 | return true
33 | }
34 |
35 | func (e *ema) Load(dataframe *model.Dataframe) {
36 | if len(dataframe.Time) < e.Period {
37 | return
38 | }
39 |
40 | e.Values = talib.Ema(dataframe.Close, e.Period)[e.Period:]
41 | e.Time = dataframe.Time[e.Period:]
42 | }
43 |
44 | func (e ema) Metrics() []plot.IndicatorMetric {
45 | return []plot.IndicatorMetric{
46 | {
47 | Style: "line",
48 | Color: e.Color,
49 | Values: e.Values,
50 | Time: e.Time,
51 | },
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/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
24 | Time []time.Time
25 | }
26 |
27 | func (e rsi) Name() string {
28 | return fmt.Sprintf("RSI(%d)", e.Period)
29 | }
30 |
31 | func (e rsi) Overlay() bool {
32 | return false
33 | }
34 |
35 | func (e *rsi) Load(dataframe *model.Dataframe) {
36 | if len(dataframe.Time) < e.Period {
37 | return
38 | }
39 |
40 | e.Values = talib.Rsi(dataframe.Close, e.Period)[e.Period:]
41 | e.Time = dataframe.Time[e.Period:]
42 | }
43 |
44 | func (e rsi) Metrics() []plot.IndicatorMetric {
45 | return []plot.IndicatorMetric{
46 | {
47 | Color: e.Color,
48 | Style: "line",
49 | Values: e.Values,
50 | Time: e.Time,
51 | },
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/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
24 | Time []time.Time
25 | }
26 |
27 | func (s sma) Name() string {
28 | return fmt.Sprintf("SMA(%d)", s.Period)
29 | }
30 |
31 | func (s sma) Overlay() bool {
32 | return true
33 | }
34 |
35 | func (s *sma) Load(dataframe *model.Dataframe) {
36 | if len(dataframe.Time) < s.Period {
37 | return
38 | }
39 |
40 | s.Values = talib.Sma(dataframe.Close, s.Period)[s.Period:]
41 | s.Time = dataframe.Time[s.Period:]
42 | }
43 |
44 | func (s sma) Metrics() []plot.IndicatorMetric {
45 | return []plot.IndicatorMetric{
46 | {
47 | Style: "line",
48 | Color: s.Color,
49 | Values: s.Values,
50 | Time: s.Time,
51 | },
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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
24 | Time []time.Time
25 | }
26 |
27 | func (w willR) Name() string {
28 | return fmt.Sprintf("%%R(%d)", w.Period)
29 | }
30 |
31 | func (w willR) Overlay() bool {
32 | return false
33 | }
34 |
35 | func (w *willR) Load(dataframe *model.Dataframe) {
36 | if len(dataframe.Time) < w.Period {
37 | return
38 | }
39 |
40 | w.Values = talib.WillR(dataframe.High, dataframe.Low, dataframe.Close, w.Period)[w.Period:]
41 | w.Time = dataframe.Time[w.Period:]
42 | }
43 |
44 | func (w willR) Metrics() []plot.IndicatorMetric {
45 | return []plot.IndicatorMetric{
46 | {
47 | Style: "line",
48 | Color: w.Color,
49 | Values: w.Values,
50 | Time: w.Time,
51 | },
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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-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 |
--------------------------------------------------------------------------------
/exchange/binance_test.go:
--------------------------------------------------------------------------------
1 | package exchange
2 |
3 | import (
4 | "fmt"
5 | "github.com/rodrigo-brito/ninjabot/model"
6 | "github.com/stretchr/testify/require"
7 | "testing"
8 | )
9 |
10 | func TestFormatQuantity(t *testing.T) {
11 | binance := Binance{assetsInfo: map[string]model.AssetInfo{
12 | "BTCUSDT": {
13 | StepSize: 0.00001000,
14 | TickSize: 0.00001000,
15 | BaseAssetPrecision: 5,
16 | QuotePrecision: 5,
17 | },
18 | "BATUSDT": {
19 | StepSize: 0.01,
20 | TickSize: 0.01,
21 | BaseAssetPrecision: 2,
22 | QuotePrecision: 2,
23 | },
24 | }}
25 |
26 | tt := []struct {
27 | pair string
28 | quantity float64
29 | expected string
30 | }{
31 | {"BTCUSDT", 1.1, "1.1"},
32 | {"BTCUSDT", 11, "11"},
33 | {"BTCUSDT", 1.1111111111, "1.11111"},
34 | {"BTCUSDT", 1111111.1111111111, "1111111.11111"},
35 | {"BATUSDT", 111.111, "111.11"},
36 | {"BATUSDT", 9.99999, "9.99"},
37 | }
38 |
39 | for _, tc := range tt {
40 | t.Run(fmt.Sprintf("given %f %s", tc.quantity, tc.pair), func(t *testing.T) {
41 | require.Equal(t, tc.expected, binance.formatQuantity(tc.pair, tc.quantity))
42 | require.Equal(t, tc.expected, binance.formatPrice(tc.pair, tc.quantity))
43 | })
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/examples/binance/main.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 | func main() {
15 | var (
16 | ctx = context.Background()
17 | apiKey = os.Getenv("API_KEY")
18 | secretKey = os.Getenv("API_SECRET")
19 | telegramToken = os.Getenv("TELEGRAM_TOKEN")
20 | telegramUser, _ = strconv.Atoi(os.Getenv("TELEGRAM_USER"))
21 | )
22 |
23 | settings := ninjabot.Settings{
24 | Pairs: []string{
25 | "BTCUSDT",
26 | "ETHUSDT",
27 | },
28 | Telegram: ninjabot.TelegramSettings{
29 | Enabled: true,
30 | Token: telegramToken,
31 | Users: []int{telegramUser},
32 | },
33 | }
34 |
35 | // Initialize your exchange
36 | binance, err := exchange.NewBinance(ctx, exchange.WithBinanceCredentials(apiKey, secretKey))
37 | if err != nil {
38 | log.Fatalln(err)
39 | }
40 |
41 | // Initialize your strategy and bot
42 | strategy := new(strategies.CrossEMA)
43 | bot, err := ninjabot.NewBot(ctx, settings, binance, strategy)
44 | if err != nil {
45 | log.Fatalln(err)
46 | }
47 |
48 | err = bot.Run(ctx)
49 | if err != nil {
50 | log.Fatalln(err)
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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
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 |
--------------------------------------------------------------------------------
/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
30 | ValuesD model.Series
31 | Time []time.Time
32 | }
33 |
34 | func (e stoch) Name() string {
35 | return fmt.Sprintf("STOCH(%d, %d, %d)", e.FastK, e.SlowK, e.SlowD)
36 | }
37 |
38 | func (e stoch) Overlay() bool {
39 | return false
40 | }
41 |
42 | func (e *stoch) Load(dataframe *model.Dataframe) {
43 | e.ValuesK, e.ValuesD = talib.Stoch(
44 | dataframe.High, dataframe.Low, dataframe.Close, e.FastK, e.SlowK, talib.SMA, e.SlowD, talib.SMA,
45 | )
46 | e.Time = dataframe.Time
47 | }
48 |
49 | func (e stoch) Metrics() []plot.IndicatorMetric {
50 | return []plot.IndicatorMetric{
51 | {
52 | Color: e.ColorK,
53 | Name: "K",
54 | Style: "line",
55 | Values: e.ValuesK,
56 | Time: e.Time,
57 | },
58 | {
59 | Color: e.ColorD,
60 | Name: "D",
61 | Style: "line",
62 | Values: e.ValuesD,
63 | Time: e.Time,
64 | },
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/service/service.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "context"
5 | "time"
6 |
7 | "github.com/rodrigo-brito/ninjabot/model"
8 | )
9 |
10 | type Exchange interface {
11 | Broker
12 | Feeder
13 | }
14 |
15 | type Feeder interface {
16 | AssetsInfo(pair string) model.AssetInfo
17 | LastQuote(ctx context.Context, pair string) (float64, error)
18 | CandlesByPeriod(ctx context.Context, pair, period string, start, end time.Time) ([]model.Candle, error)
19 | CandlesByLimit(ctx context.Context, pair, period string, limit int) ([]model.Candle, error)
20 | CandlesSubscription(ctx context.Context, pair, timeframe string) (chan model.Candle, chan error)
21 | }
22 |
23 | type Broker interface {
24 | Account() (model.Account, error)
25 | Position(pair string) (asset, quote float64, err error)
26 | Order(pair string, id int64) (model.Order, error)
27 | CreateOrderOCO(side model.SideType, pair string, size, price, stop, stopLimit float64) ([]model.Order, error)
28 | CreateOrderLimit(side model.SideType, pair string, size float64, limit float64) (model.Order, error)
29 | CreateOrderMarket(side model.SideType, pair string, size float64) (model.Order, error)
30 | CreateOrderMarketQuote(side model.SideType, pair string, quote float64) (model.Order, error)
31 | CreateOrderStop(pair string, quantity float64, limit float64) (model.Order, error)
32 | Cancel(model.Order) error
33 | }
34 |
35 | type Notifier interface {
36 | Notify(string)
37 | OnOrder(order model.Order)
38 | OnError(err error)
39 | }
40 |
41 | type Telegram interface {
42 | Notifier
43 | Start()
44 | }
45 |
--------------------------------------------------------------------------------
/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, newOrder 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 |
--------------------------------------------------------------------------------
/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{1, 2, 3, 4, 5}).Values())
12 | }
13 |
14 | func TestSeries_Last(t *testing.T) {
15 | series := Series([]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{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{})
28 | require.Empty(t, series.LastValues(2))
29 | })
30 | }
31 |
32 | func TestSeries_Crossover(t *testing.T) {
33 | s1 := Series([]float64{4, 5})
34 | s2 := Series([]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{4, 5})
41 | s2 := Series([]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 |
--------------------------------------------------------------------------------
/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
28 | MiddleBand model.Series
29 | LowerBand model.Series
30 | Time []time.Time
31 | }
32 |
33 | func (bb bollingerBands) Name() string {
34 | return fmt.Sprintf("BB(%d, %.2f)", bb.Period, bb.StdDeviation)
35 | }
36 |
37 | func (bb bollingerBands) Overlay() bool {
38 | return true
39 | }
40 |
41 | func (bb *bollingerBands) Load(dataframe *model.Dataframe) {
42 | if len(dataframe.Time) < bb.Period {
43 | return
44 | }
45 |
46 | upper, mid, lower := talib.BBands(dataframe.Close, bb.Period, bb.StdDeviation, bb.StdDeviation, talib.EMA)
47 | bb.UpperBand, bb.MiddleBand, bb.LowerBand = upper[bb.Period:], mid[bb.Period:], lower[bb.Period:]
48 |
49 | bb.Time = dataframe.Time[bb.Period:]
50 | }
51 |
52 | func (bb bollingerBands) Metrics() []plot.IndicatorMetric {
53 | return []plot.IndicatorMetric{
54 | {
55 | Style: "line",
56 | Color: bb.UpDnBandColor,
57 | Values: bb.UpperBand,
58 | Time: bb.Time,
59 | },
60 | {
61 | Style: "line",
62 | Color: bb.MidBandColor,
63 | Values: bb.MiddleBand,
64 | Time: bb.Time,
65 | },
66 | {
67 | Style: "line",
68 | Color: bb.UpDnBandColor,
69 | Values: bb.LowerBand,
70 | Time: bb.Time,
71 | },
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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
32 | ValuesMACDSignal model.Series
33 | ValuesMACDHist model.Series
34 | Time []time.Time
35 | }
36 |
37 | func (e macd) Name() string {
38 | return fmt.Sprintf("MACD(%d, %d, %d)", e.Fast, e.Slow, e.Signal)
39 | }
40 |
41 | func (e macd) Overlay() bool {
42 | return false
43 | }
44 |
45 | func (e *macd) Load(df *model.Dataframe) {
46 | warmup := e.Slow + e.Signal
47 | e.ValuesMACD, e.ValuesMACDSignal, e.ValuesMACDHist = talib.Macd(df.Close, e.Fast, e.Slow, e.Signal)
48 | e.Time = df.Time[warmup:]
49 | e.ValuesMACD = e.ValuesMACD[warmup:]
50 | e.ValuesMACDSignal = e.ValuesMACDSignal[warmup:]
51 | e.ValuesMACDHist = e.ValuesMACDHist[warmup:]
52 | }
53 |
54 | func (e macd) Metrics() []plot.IndicatorMetric {
55 | return []plot.IndicatorMetric{
56 | {
57 | Color: e.ColorMACD,
58 | Name: "MACD",
59 | Style: "line",
60 | Values: e.ValuesMACD,
61 | Time: e.Time,
62 | },
63 | {
64 | Color: e.ColorMACDSignal,
65 | Name: "MACDSignal",
66 | Style: "line",
67 | Values: e.ValuesMACDSignal,
68 | Time: e.Time,
69 | },
70 | {
71 | Color: e.ColorMACDHist,
72 | Name: "MACDHist",
73 | Style: "bar",
74 | Values: e.ValuesMACDHist,
75 | Time: e.Time,
76 | },
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/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 |
9 | log "github.com/sirupsen/logrus"
10 | )
11 |
12 | // https://www.investopedia.com/articles/trading/08/turtle-trading.asp
13 | type Turtle struct{}
14 |
15 | func (e Turtle) Timeframe() string {
16 | return "4h"
17 | }
18 |
19 | func (e Turtle) WarmupPeriod() int {
20 | return 40
21 | }
22 |
23 | func (e Turtle) Indicators(df *ninjabot.Dataframe) []strategy.ChartIndicator {
24 | df.Metadata["turtleHighest"] = indicator.Max(df.Close, 40)
25 | df.Metadata["turtleLowest"] = indicator.Min(df.Close, 20)
26 |
27 | return nil
28 | }
29 |
30 | func (e *Turtle) OnCandle(df *ninjabot.Dataframe, broker service.Broker) {
31 | closePrice := df.Close.Last(0)
32 | highest := df.Metadata["turtleHighest"].Last(0)
33 | lowest := df.Metadata["turtleLowest"].Last(0)
34 |
35 | assetPosition, quotePosition, err := broker.Position(df.Pair)
36 | if err != nil {
37 | log.Error(err)
38 | return
39 | }
40 |
41 | // If position already open wait till it will be closed
42 | if assetPosition == 0 && closePrice >= highest {
43 | _, err := broker.CreateOrderMarketQuote(ninjabot.SideTypeBuy, df.Pair, quotePosition/2)
44 | if err != nil {
45 | log.WithFields(map[string]interface{}{
46 | "pair": df.Pair,
47 | "side": ninjabot.SideTypeBuy,
48 | "close": closePrice,
49 | "asset": assetPosition,
50 | "quote": quotePosition,
51 | }).Error(err)
52 | }
53 | return
54 | }
55 |
56 | if assetPosition > 0 && closePrice <= lowest {
57 | _, err := broker.CreateOrderMarket(ninjabot.SideTypeSell, df.Pair, assetPosition)
58 | if err != nil {
59 | log.WithFields(map[string]interface{}{
60 | "pair": df.Pair,
61 | "side": ninjabot.SideTypeSell,
62 | "close": closePrice,
63 | "asset": assetPosition,
64 | "quote": quotePosition,
65 | "size": assetPosition,
66 | }).Error(err)
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | Candle Candle `json:"-" gorm:"-"`
54 | }
55 |
56 | func (o Order) String() string {
57 | return fmt.Sprintf("[%s] %s %s | ID: %d, Type: %s, %f x $%f (~$%.f)",
58 | o.Status, o.Side, o.Pair, o.ID, o.Type, o.Quantity, o.Price, o.Quantity*o.Price)
59 | }
60 |
--------------------------------------------------------------------------------
/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 |
9 | log "github.com/sirupsen/logrus"
10 | )
11 |
12 | type CrossEMA struct{}
13 |
14 | func (e CrossEMA) Timeframe() string {
15 | return "4h"
16 | }
17 |
18 | func (e CrossEMA) WarmupPeriod() int {
19 | return 21
20 | }
21 |
22 | func (e CrossEMA) Indicators(df *ninjabot.Dataframe) []strategy.ChartIndicator {
23 | df.Metadata["ema8"] = indicator.EMA(df.Close, 8)
24 | df.Metadata["sma21"] = indicator.SMA(df.Close, 21)
25 |
26 | return []strategy.ChartIndicator{
27 | {
28 | Overlay: true,
29 | GroupName: "MA's",
30 | Time: df.Time,
31 | Metrics: []strategy.IndicatorMetric{
32 | {
33 | Values: df.Metadata["ema8"],
34 | Name: "EMA 8",
35 | Color: "red",
36 | Style: strategy.StyleLine,
37 | },
38 | {
39 | Values: df.Metadata["sma21"],
40 | Name: "SMA 21",
41 | Color: "blue",
42 | Style: strategy.StyleLine,
43 | },
44 | },
45 | },
46 | }
47 | }
48 |
49 | func (e *CrossEMA) OnCandle(df *ninjabot.Dataframe, broker service.Broker) {
50 | closePrice := df.Close.Last(0)
51 |
52 | assetPosition, quotePosition, err := broker.Position(df.Pair)
53 | if err != nil {
54 | log.Error(err)
55 | return
56 | }
57 |
58 | if quotePosition > 10 && df.Metadata["ema8"].Crossover(df.Metadata["sma21"]) {
59 | _, err := broker.CreateOrderMarketQuote(ninjabot.SideTypeBuy, df.Pair, quotePosition)
60 | if err != nil {
61 | log.WithFields(map[string]interface{}{
62 | "pair": df.Pair,
63 | "side": ninjabot.SideTypeBuy,
64 | "close": closePrice,
65 | "asset": assetPosition,
66 | "quote": quotePosition,
67 | }).Error(err)
68 | }
69 | return
70 | }
71 |
72 | if assetPosition > 0 &&
73 | df.Metadata["ema8"].Crossunder(df.Metadata["sma21"]) {
74 | _, err := broker.CreateOrderMarket(ninjabot.SideTypeSell, df.Pair, assetPosition)
75 | if err != nil {
76 | log.WithFields(map[string]interface{}{
77 | "pair": df.Pair,
78 | "side": ninjabot.SideTypeSell,
79 | "close": closePrice,
80 | "asset": assetPosition,
81 | "quote": quotePosition,
82 | "size": assetPosition,
83 | }).Error(err)
84 | }
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/storage/buntdb.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | import (
4 | "encoding/json"
5 | "log"
6 | "strconv"
7 | "sync/atomic"
8 |
9 | "github.com/rodrigo-brito/ninjabot/model"
10 | "github.com/tidwall/buntdb"
11 | )
12 |
13 | type Bunt struct {
14 | lastID int64
15 | db *buntdb.DB
16 | }
17 |
18 | func FromMemory() (Storage, error) {
19 | return newBunt(":memory:")
20 | }
21 |
22 | func FromFile(file string) (Storage, error) {
23 | return newBunt(file)
24 | }
25 |
26 | func newBunt(sourceFile string) (Storage, error) {
27 | db, err := buntdb.Open(sourceFile)
28 | if err != nil {
29 | return nil, err
30 | }
31 |
32 | err = db.CreateIndex("update_index", "*", buntdb.IndexJSON("updated_at"))
33 | if err != nil {
34 | return nil, err
35 | }
36 |
37 | return &Bunt{
38 | db: db,
39 | }, nil
40 | }
41 |
42 | func (b *Bunt) getID() int64 {
43 | return atomic.AddInt64(&b.lastID, 1)
44 | }
45 |
46 | func (b *Bunt) CreateOrder(order *model.Order) error {
47 | return b.db.Update(func(tx *buntdb.Tx) error {
48 | order.ID = b.getID()
49 | content, err := json.Marshal(order)
50 | if err != nil {
51 | return err
52 | }
53 |
54 | _, _, err = tx.Set(strconv.FormatInt(order.ID, 10), string(content), nil)
55 | return err
56 | })
57 | }
58 |
59 | func (b Bunt) UpdateOrder(order *model.Order) error {
60 | return b.db.Update(func(tx *buntdb.Tx) error {
61 | id := strconv.FormatInt(order.ID, 10)
62 |
63 | content, err := json.Marshal(order)
64 | if err != nil {
65 | return err
66 | }
67 |
68 | _, _, err = tx.Set(id, string(content), nil)
69 | return err
70 | })
71 | }
72 |
73 | func (b Bunt) Orders(filters ...OrderFilter) ([]*model.Order, error) {
74 | orders := make([]*model.Order, 0)
75 | err := b.db.View(func(tx *buntdb.Tx) error {
76 | err := tx.Ascend("update_index", func(key, value string) bool {
77 | var order model.Order
78 | err := json.Unmarshal([]byte(value), &order)
79 | if err != nil {
80 | log.Println(err)
81 | return true
82 | }
83 |
84 | for _, filter := range filters {
85 | if ok := filter(order); !ok {
86 | return true
87 | }
88 | }
89 |
90 | orders = append(orders, &order)
91 |
92 | return true
93 | })
94 | return err
95 | })
96 | if err != nil {
97 | return nil, err
98 | }
99 | return orders, nil
100 | }
101 |
--------------------------------------------------------------------------------
/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 |
11 | log "github.com/sirupsen/logrus"
12 | )
13 |
14 | type trailing struct {
15 | trailingStop map[string]*tools.TrailingStop
16 | scheduler map[string]*tools.Scheduler
17 | }
18 |
19 | func NewTrailing(pairs []string) strategy.HighFrequencyStrategy {
20 | strategy := &trailing{
21 | trailingStop: make(map[string]*tools.TrailingStop),
22 | scheduler: make(map[string]*tools.Scheduler),
23 | }
24 |
25 | for _, pair := range pairs {
26 | strategy.trailingStop[pair] = tools.NewTrailingStop()
27 | strategy.scheduler[pair] = tools.NewScheduler(pair)
28 | }
29 |
30 | return strategy
31 | }
32 |
33 | func (t trailing) Timeframe() string {
34 | return "4h"
35 | }
36 |
37 | func (t trailing) WarmupPeriod() int {
38 | return 21
39 | }
40 |
41 | func (t trailing) Indicators(df *model.Dataframe) []strategy.ChartIndicator {
42 | df.Metadata["ema_fast"] = indicator.EMA(df.Close, 8)
43 | df.Metadata["sma_slow"] = indicator.SMA(df.Close, 21)
44 |
45 | return nil
46 | }
47 |
48 | func (t trailing) OnCandle(df *model.Dataframe, broker service.Broker) {
49 | asset, quote, err := broker.Position(df.Pair)
50 | if err != nil {
51 | log.Error(err)
52 | return
53 | }
54 |
55 | if quote > 10.0 && // enough cash?
56 | asset*df.Close.Last(0) < 10 && // without position yet
57 | df.Metadata["ema_fast"].Crossover(df.Metadata["sma_slow"]) {
58 | _, err = broker.CreateOrderMarketQuote(ninjabot.SideTypeBuy, df.Pair, quote)
59 | if err != nil {
60 | log.Error(err)
61 | return
62 | }
63 |
64 | t.trailingStop[df.Pair].Start(df.Close.Last(0), df.Low.Last(0))
65 |
66 | return
67 | }
68 | }
69 |
70 | func (t trailing) OnPartialCandle(df *model.Dataframe, broker service.Broker) {
71 | if trailing := t.trailingStop[df.Pair]; trailing != nil && trailing.Update(df.Close.Last(0)) {
72 | asset, _, err := broker.Position(df.Pair)
73 | if err != nil {
74 | log.Error(err)
75 | return
76 | }
77 |
78 | if asset > 0 {
79 | _, err = broker.CreateOrderMarket(ninjabot.SideTypeSell, df.Pair, asset)
80 | if err != nil {
81 | log.Error(err)
82 | return
83 | }
84 | trailing.Stop()
85 | }
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/examples/paperwallet/main.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 |
16 | log "github.com/sirupsen/logrus"
17 | )
18 |
19 | func main() {
20 | var (
21 | ctx = context.Background()
22 | telegramToken = os.Getenv("TELEGRAM_TOKEN")
23 | telegramUser, _ = strconv.Atoi(os.Getenv("TELEGRAM_USER"))
24 | )
25 |
26 | settings := ninjabot.Settings{
27 | Pairs: []string{
28 | "BTCUSDT",
29 | "ETHUSDT",
30 | "BNBUSDT",
31 | "LTCUSDT",
32 | },
33 | Telegram: ninjabot.TelegramSettings{
34 | Enabled: true,
35 | Token: telegramToken,
36 | Users: []int{telegramUser},
37 | },
38 | }
39 |
40 | // Use binance for realtime data feed
41 | binance, err := exchange.NewBinance(ctx)
42 | if err != nil {
43 | log.Fatal(err)
44 | }
45 |
46 | // creating a storage to save trades
47 | storage, err := storage.FromMemory()
48 | if err != nil {
49 | log.Fatal(err)
50 | }
51 |
52 | // creating a paper wallet to simulate an exchange waller for fake operataions
53 | paperWallet := exchange.NewPaperWallet(
54 | ctx,
55 | "USDT",
56 | exchange.WithPaperFee(0.001, 0.001),
57 | exchange.WithPaperAsset("USDT", 10000),
58 | exchange.WithDataFeed(binance),
59 | )
60 |
61 | // initializing my strategy
62 | strategy := new(strategies.CrossEMA)
63 |
64 | chart, err := plot.NewChart(
65 | plot.WithCustomIndicators(
66 | indicator.EMA(8, "red"),
67 | indicator.SMA(21, "blue"),
68 | ),
69 | )
70 | if err != nil {
71 | log.Fatal(err)
72 | }
73 |
74 | // initializer ninjabot
75 | bot, err := ninjabot.NewBot(
76 | ctx,
77 | settings,
78 | paperWallet,
79 | strategy,
80 | ninjabot.WithStorage(storage),
81 | ninjabot.WithPaperWallet(paperWallet),
82 | ninjabot.WithCandleSubscription(chart),
83 | ninjabot.WithOrderSubscription(chart),
84 | )
85 | if err != nil {
86 | log.Fatalln(err)
87 | }
88 |
89 | go func() {
90 | err := chart.Start()
91 | if err != nil {
92 | log.Fatal(err)
93 | }
94 | }()
95 |
96 | err = bot.Run(ctx)
97 | if err != nil {
98 | log.Fatalln(err)
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/examples/strategies/ocosell.go:
--------------------------------------------------------------------------------
1 | package strategies
2 |
3 | import (
4 | "github.com/markcheno/go-talib"
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 |
10 | log "github.com/sirupsen/logrus"
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 |
--------------------------------------------------------------------------------
/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 |
10 | "github.com/urfave/cli/v2"
11 | )
12 |
13 | func main() {
14 | app := &cli.App{
15 | Name: "ninjabot",
16 | HelpName: "ninjabot",
17 | Usage: "Utilities for bot creation",
18 | Commands: []*cli.Command{
19 | {
20 | Name: "download",
21 | HelpName: "download",
22 | Usage: "Download historical data",
23 | Flags: []cli.Flag{
24 | &cli.StringFlag{
25 | Name: "pair",
26 | Aliases: []string{"p"},
27 | Usage: "eg. BTCUSDT",
28 | Required: true,
29 | },
30 | &cli.IntFlag{
31 | Name: "days",
32 | Aliases: []string{"d"},
33 | Usage: "eg. 100 (default 30 days)",
34 | Required: false,
35 | },
36 | &cli.TimestampFlag{
37 | Name: "start",
38 | Aliases: []string{"s"},
39 | Usage: "eg. 2021-12-01",
40 | Layout: "2006-01-02",
41 | Required: false,
42 | },
43 | &cli.TimestampFlag{
44 | Name: "end",
45 | Aliases: []string{"e"},
46 | Usage: "eg. 2020-12-31",
47 | Layout: "2006-01-02",
48 | Required: false,
49 | },
50 | &cli.StringFlag{
51 | Name: "timeframe",
52 | Aliases: []string{"t"},
53 | Usage: "eg. 1h",
54 | Required: true,
55 | },
56 | &cli.StringFlag{
57 | Name: "output",
58 | Aliases: []string{"o"},
59 | Usage: "eg. ./btc.csv",
60 | Required: true,
61 | },
62 | },
63 | Action: func(c *cli.Context) error {
64 | exc, err := exchange.NewBinance(c.Context)
65 | if err != nil {
66 | return err
67 | }
68 |
69 | var options []download.Option
70 | if days := c.Int("days"); days > 0 {
71 | options = append(options, download.WithDays(days))
72 | }
73 |
74 | start := c.Timestamp("start")
75 | end := c.Timestamp("end")
76 | if start != nil && end != nil && !start.IsZero() && !end.IsZero() {
77 | options = append(options, download.WithInterval(*start, *end))
78 | } else if start != nil || end != nil {
79 | log.Fatal("START and END must be informed together")
80 | }
81 |
82 | return download.NewDownloader(exc).Download(c.Context, c.String("pair"),
83 | c.String("timeframe"), c.String("output"), options...)
84 |
85 | },
86 | },
87 | },
88 | }
89 |
90 | err := app.Run(os.Args)
91 | if err != nil {
92 | log.Fatal(err)
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/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),
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 | s.strategy.Indicators(s.dataframe)
80 | if s.started {
81 | s.strategy.OnCandle(s.dataframe, s.broker)
82 | }
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/rodrigo-brito/ninjabot
2 |
3 | go 1.18
4 |
5 | require (
6 | github.com/StudioSol/set v0.0.0-20211001132805-52fe71d0afcf
7 | github.com/adshao/go-binance/v2 v2.3.10
8 | github.com/evanw/esbuild v0.15.12
9 | github.com/glebarez/sqlite v1.5.0
10 | github.com/jpillora/backoff v1.0.0
11 | github.com/markcheno/go-talib v0.0.0-20190307022042-cd53a9264d70
12 | github.com/olekukonko/tablewriter v0.0.5
13 | github.com/samber/lo v1.33.0
14 | github.com/schollz/progressbar/v3 v3.11.0
15 | github.com/sirupsen/logrus v1.9.0
16 | github.com/stretchr/testify v1.8.1
17 | github.com/tidwall/buntdb v1.2.10
18 | github.com/urfave/cli/v2 v2.23.0
19 | github.com/xhit/go-str2duration/v2 v2.0.0
20 | gopkg.in/tucnak/telebot.v2 v2.5.0
21 | gorm.io/gorm v1.24.2
22 | )
23 |
24 | require (
25 | github.com/bitly/go-simplejson v0.5.0 // indirect
26 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
27 | github.com/davecgh/go-spew v1.1.1 // indirect
28 | github.com/glebarez/go-sqlite v1.19.1 // indirect
29 | github.com/google/uuid v1.3.0 // indirect
30 | github.com/gorilla/websocket v1.5.0 // indirect
31 | github.com/jinzhu/inflection v1.0.0 // indirect
32 | github.com/jinzhu/now v1.1.5 // indirect
33 | github.com/json-iterator/go v1.1.12 // indirect
34 | github.com/kr/pretty v0.3.0 // indirect
35 | github.com/mattn/go-isatty v0.0.16 // indirect
36 | github.com/mattn/go-runewidth v0.0.13 // indirect
37 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
38 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
39 | github.com/modern-go/reflect2 v1.0.2 // indirect
40 | github.com/pkg/errors v0.9.1 // indirect
41 | github.com/pmezard/go-difflib v1.0.0 // indirect
42 | github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect
43 | github.com/rivo/uniseg v0.3.4 // indirect
44 | github.com/russross/blackfriday/v2 v2.1.0 // indirect
45 | github.com/tidwall/btree v1.4.2 // indirect
46 | github.com/tidwall/gjson v1.14.3 // indirect
47 | github.com/tidwall/grect v0.1.4 // indirect
48 | github.com/tidwall/match v1.1.1 // indirect
49 | github.com/tidwall/pretty v1.2.0 // indirect
50 | github.com/tidwall/rtred v0.1.2 // indirect
51 | github.com/tidwall/tinyqueue v0.1.1 // indirect
52 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
53 | golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect
54 | golang.org/x/sys v0.0.0-20220829200755-d48e67d00261 // indirect
55 | golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 // indirect
56 | gopkg.in/yaml.v3 v3.0.1 // indirect
57 | modernc.org/libc v1.19.0 // indirect
58 | modernc.org/mathutil v1.5.0 // indirect
59 | modernc.org/memory v1.4.0 // indirect
60 | modernc.org/sqlite v1.19.1 // indirect
61 | )
62 |
--------------------------------------------------------------------------------
/examples/backtesting/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 |
6 | log "github.com/sirupsen/logrus"
7 |
8 | "github.com/rodrigo-brito/ninjabot"
9 | "github.com/rodrigo-brito/ninjabot/examples/strategies"
10 | "github.com/rodrigo-brito/ninjabot/exchange"
11 | "github.com/rodrigo-brito/ninjabot/plot"
12 | "github.com/rodrigo-brito/ninjabot/plot/indicator"
13 | "github.com/rodrigo-brito/ninjabot/storage"
14 | )
15 |
16 | func main() {
17 | ctx := context.Background()
18 |
19 | // bot settings (eg: pairs, telegram, etc)
20 | settings := ninjabot.Settings{
21 | Pairs: []string{
22 | "BTCUSDT",
23 | "ETHUSDT",
24 | },
25 | }
26 |
27 | // initialize your strategy
28 | strategy := new(strategies.CrossEMA)
29 |
30 | // load historical data from CSV files
31 | csvFeed, err := exchange.NewCSVFeed(
32 | strategy.Timeframe(),
33 | exchange.PairFeed{
34 | Pair: "BTCUSDT",
35 | File: "testdata/btc-1h.csv",
36 | Timeframe: "1h",
37 | },
38 | exchange.PairFeed{
39 | Pair: "ETHUSDT",
40 | File: "testdata/eth-1h.csv",
41 | Timeframe: "1h",
42 | },
43 | )
44 | if err != nil {
45 | log.Fatal(err)
46 | }
47 |
48 | // initialize a database in memory
49 | storage, err := storage.FromMemory()
50 | if err != nil {
51 | log.Fatal(err)
52 | }
53 |
54 | // create a paper wallet for simulation, initializing with 10.000 USDT
55 | wallet := exchange.NewPaperWallet(
56 | ctx,
57 | "USDT",
58 | exchange.WithPaperAsset("USDT", 10000),
59 | exchange.WithDataFeed(csvFeed),
60 | )
61 |
62 | // create a chart with indicators from the strategy and a custom additional RSI indicator
63 | chart, err := plot.NewChart(
64 | plot.WithStrategyIndicators(strategy),
65 | plot.WithCustomIndicators(
66 | indicator.RSI(14, "purple"),
67 | ),
68 | plot.WithPaperWallet(wallet),
69 | )
70 | if err != nil {
71 | log.Fatal(err)
72 | }
73 |
74 | // initializer Ninjabot with the objects created before
75 | bot, err := ninjabot.NewBot(
76 | ctx,
77 | settings,
78 | wallet,
79 | strategy,
80 | ninjabot.WithBacktest(wallet), // Required for Backtest mode
81 | ninjabot.WithStorage(storage),
82 |
83 | // connect bot feed (candle and orders) to the chart
84 | ninjabot.WithCandleSubscription(chart),
85 | ninjabot.WithOrderSubscription(chart),
86 | ninjabot.WithLogLevel(log.WarnLevel),
87 | )
88 | if err != nil {
89 | log.Fatal(err)
90 | }
91 |
92 | // Initializer simulation
93 | err = bot.Run(ctx)
94 | if err != nil {
95 | log.Fatal(err)
96 | }
97 |
98 | // Print bot results
99 | bot.Summary()
100 |
101 | // Display candlesticks chart in local browser
102 | err = chart.Start()
103 | if err != nil {
104 | log.Fatal(err)
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/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.Broker
80 | service.Feeder
81 | }{
82 | Feeder: csvFeed,
83 | }
84 |
85 | downloader := Downloader{fakeExchange}
86 |
87 | t.Run("success", func(t *testing.T) {
88 | err = downloader.Download(ctx, "BTCUSDT", "1d", tmpFile.Name(), WithInterval(param.Start, param.End))
89 | require.NoError(t, err)
90 |
91 | csvFeed, err := exchange.NewCSVFeed(
92 | "1d",
93 | exchange.PairFeed{
94 | Pair: "BTCUSDT",
95 | File: "../testdata/btc-1d.csv",
96 | Timeframe: "1d",
97 | })
98 | require.NoError(t, err)
99 | require.Len(t, csvFeed.CandlePairTimeFrame["BTCUSDT--1d"], 14)
100 | })
101 | }
102 |
--------------------------------------------------------------------------------
/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
26 | BasicUpperBand model.Series
27 | FinalUpperBand model.Series
28 | BasicLowerBand model.Series
29 | FinalLowerBand model.Series
30 | SuperTrend model.Series
31 | Time []time.Time
32 | }
33 |
34 | func (s supertrend) Name() string {
35 | return fmt.Sprintf("SuperTrend(%d,%.1f)", s.Period, s.Factor)
36 | }
37 |
38 | func (s supertrend) Overlay() bool {
39 | return true
40 | }
41 |
42 | func (s *supertrend) Load(df *model.Dataframe) {
43 | if len(df.Time) < s.Period {
44 | return
45 | }
46 |
47 | atr := talib.Atr(df.High, df.Low, df.Close, s.Period)
48 | s.BasicUpperBand = make([]float64, len(atr))
49 | s.BasicLowerBand = make([]float64, len(atr))
50 | s.FinalUpperBand = make([]float64, len(atr))
51 | s.FinalLowerBand = make([]float64, len(atr))
52 | s.SuperTrend = make([]float64, len(atr))
53 |
54 | for i := 1; i < len(s.BasicLowerBand); i++ {
55 | s.BasicUpperBand[i] = (df.High[i]+df.Low[i])/2.0 + atr[i]*s.Factor
56 | s.BasicLowerBand[i] = (df.High[i]+df.Low[i])/2.0 - atr[i]*s.Factor
57 |
58 | if i == 0 {
59 | s.FinalUpperBand[i] = s.BasicUpperBand[i]
60 | } else if s.BasicUpperBand[i] < s.FinalUpperBand[i-1] ||
61 | df.Close[i-1] > s.FinalUpperBand[i-1] {
62 | s.FinalUpperBand[i] = s.BasicUpperBand[i]
63 | } else {
64 | s.FinalUpperBand[i] = s.FinalUpperBand[i-1]
65 | }
66 |
67 | if i == 0 || s.BasicLowerBand[i] > s.FinalLowerBand[i-1] ||
68 | df.Close[i-1] < s.FinalLowerBand[i-1] {
69 | s.FinalLowerBand[i] = s.BasicLowerBand[i]
70 | } else {
71 | s.FinalLowerBand[i] = s.FinalLowerBand[i-1]
72 | }
73 |
74 | if i == 0 || s.FinalUpperBand[i-1] == s.SuperTrend[i-1] {
75 | if df.Close[i] > s.FinalUpperBand[i] {
76 | s.SuperTrend[i] = s.FinalLowerBand[i]
77 | } else {
78 | s.SuperTrend[i] = s.FinalUpperBand[i]
79 | }
80 | } else {
81 | if df.Close[i] < s.FinalLowerBand[i] {
82 | s.SuperTrend[i] = s.FinalUpperBand[i]
83 | } else {
84 | s.SuperTrend[i] = s.FinalLowerBand[i]
85 | }
86 | }
87 | }
88 |
89 | s.Time = df.Time[s.Period:]
90 | s.SuperTrend = s.SuperTrend[s.Period:]
91 |
92 | }
93 |
94 | func (s supertrend) Metrics() []plot.IndicatorMetric {
95 | return []plot.IndicatorMetric{
96 | {
97 | Style: "scatter",
98 | Color: s.Color,
99 | Values: s.SuperTrend,
100 | Time: s.Time,
101 | },
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/ninjabot_test.go:
--------------------------------------------------------------------------------
1 | package ninjabot
2 |
3 | import (
4 | "context"
5 | "github.com/rodrigo-brito/ninjabot/strategy"
6 | "testing"
7 |
8 | "github.com/markcheno/go-talib"
9 | log "github.com/sirupsen/logrus"
10 | "github.com/stretchr/testify/require"
11 |
12 | "github.com/rodrigo-brito/ninjabot/exchange"
13 | "github.com/rodrigo-brito/ninjabot/service"
14 | "github.com/rodrigo-brito/ninjabot/storage"
15 | )
16 |
17 | type fakeStrategy struct{}
18 |
19 | func (e fakeStrategy) Timeframe() string {
20 | return "1d"
21 | }
22 |
23 | func (e fakeStrategy) WarmupPeriod() int {
24 | return 9
25 | }
26 |
27 | func (e fakeStrategy) Indicators(df *Dataframe) []strategy.ChartIndicator {
28 | df.Metadata["ema9"] = talib.Ema(df.Close, 9)
29 | return nil
30 | }
31 |
32 | func (e *fakeStrategy) OnCandle(df *Dataframe, broker service.Broker) {
33 | closePrice := df.Close.Last(0)
34 | assetPosition, quotePosition, err := broker.Position(df.Pair)
35 | if err != nil {
36 | log.Error(err)
37 | }
38 |
39 | if quotePosition > 0 && df.Close.Crossover(df.Metadata["ema9"]) {
40 | _, err := broker.CreateOrderMarket(SideTypeBuy, df.Pair, quotePosition/closePrice*0.5)
41 | if err != nil {
42 | log.Fatal(err)
43 | }
44 | }
45 |
46 | if assetPosition > 0 &&
47 | df.Close.Crossunder(df.Metadata["ema9"]) {
48 | _, err := broker.CreateOrderMarket(SideTypeSell, df.Pair, assetPosition)
49 | if err != nil {
50 | log.Fatal(err)
51 | }
52 | }
53 | }
54 |
55 | func TestMarketOrder(t *testing.T) {
56 | ctx := context.Background()
57 |
58 | storage, err := storage.FromMemory()
59 | require.NoError(t, err)
60 |
61 | strategy := new(fakeStrategy)
62 | csvFeed, err := exchange.NewCSVFeed(
63 | strategy.Timeframe(),
64 | exchange.PairFeed{
65 | Pair: "BTCUSDT",
66 | File: "testdata/btc-1h.csv",
67 | Timeframe: "1h",
68 | },
69 | exchange.PairFeed{
70 | Pair: "ETHUSDT",
71 | File: "testdata/eth-1h.csv",
72 | Timeframe: "1h",
73 | },
74 | )
75 | require.NoError(t, err)
76 |
77 | paperWallet := exchange.NewPaperWallet(
78 | ctx,
79 | "USDT",
80 | exchange.WithPaperAsset("USDT", 10000),
81 | exchange.WithDataFeed(csvFeed),
82 | )
83 |
84 | bot, err := NewBot(ctx, Settings{
85 | Pairs: []string{
86 | "BTCUSDT",
87 | "ETHUSDT",
88 | },
89 | },
90 | paperWallet,
91 | strategy,
92 | WithStorage(storage),
93 | WithBacktest(paperWallet),
94 | WithLogLevel(log.ErrorLevel),
95 | )
96 | require.NoError(t, err)
97 | require.NoError(t, bot.Run(ctx))
98 |
99 | assets, quote, err := bot.paperWallet.Position("BTCUSDT")
100 | require.NoError(t, err)
101 | require.Equal(t, assets, 0.0)
102 | require.InDelta(t, quote, 26694.6741, 0.001)
103 |
104 | results := bot.orderController.Results["BTCUSDT"]
105 | require.InDelta(t, 7424.3705, results.Profit(), 0.001)
106 | require.Len(t, results.Win, 6)
107 | require.Len(t, results.Lose, 11)
108 |
109 | results = bot.orderController.Results["ETHUSDT"]
110 | require.InDelta(t, 9270.3036, results.Profit(), 0.001)
111 | require.Len(t, results.Win, 9)
112 | require.Len(t, results.Lose, 8)
113 |
114 | bot.Summary()
115 | }
116 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | fast cryptocurrency trading bot framework implemented in Go. Ninjabot permits users to create and test custom strategies for spot markets.
2 | | DISCLAIMER |
3 | |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
4 | | 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 |
5 |
6 | ## Examples of Usage
7 |
8 | Check [examples](examples) directory:
9 |
10 | - Paper Wallet (Live Simulation)
11 | - Backtesting (Simulation with historical data)
12 | - Real Account
13 |
14 |
15 | **Example of usage**
16 | ```bash
17 | # Download candles of BTCUSDT to btc.csv file (Last 30 days, timeframe 1D)
18 | ninjabot download --pair BTCUSDT --timeframe 1d --days 30 --output ./btc.csv
19 | ```
20 |
21 | ### Backtesting Example
22 |
23 | - Backtesting a custom strategy from [examples](examples) directory:
24 | ```
25 | go run examples/backtesting/main.go
26 | ```
27 |
28 | Output:
29 |
30 | ```
31 | INFO[2022-10-16 16:34] [SETUP] Using paper wallet
32 | INFO[2022-10-16 16:34] [SETUP] Initial Portfolio = 10000.000000 USDT
33 | +---------+--------+-----+------+--------+--------+-----+----------+-----------+
34 | | PAIR | TRADES | WIN | LOSS | % WIN | PAYOFF | SQN | PROFIT | VOLUME |
35 | +---------+--------+-----+------+--------+--------+-----+----------+-----------+
36 | | BTCUSDT | 14 | 6 | 8 | 42.9 % | 5.929 | 1.5 | 13511.66 | 448030.04 |
37 | | ETHUSDT | 9 | 6 | 3 | 66.7 % | 3.407 | 1.3 | 21748.41 | 407769.64 |
38 | +---------+--------+-----+------+--------+--------+-----+----------+-----------+
39 | | TOTAL | 23 | 12 | 11 | 52.2 % | 4.942 | 1.4 | 35260.07 | 855799.68 |
40 | +---------+--------+-----+------+--------+--------+-----+----------+-----------+
41 |
42 | -- FINAL WALLET --
43 | 0.0000 BTC = 0.0000 USDT
44 | 0.0000 ETH = 0.0000 USDT
45 | 45260.0734 USDT
46 |
47 | ----- RETURNS -----
48 | START PORTFOLIO = 10000.00 USDT
49 | FINAL PORTFOLIO = 45260.07 USDT
50 | GROSS PROFIT = 35260.073380 USDT (352.60%)
51 | MARKET CHANGE (B&H) = 407.09%
52 |
53 | ------ RISK -------
54 | MAX DRAWDOWN = -11.76 %
55 |
56 | ------ VOLUME -----
57 | ETHUSDT = 407769.64 USDT
58 | BTCUSDT = 448030.04 USDT
59 | TOTAL = 855799.68 USDT
60 | COSTS (0.001*V) = 855.80 USDT (ESTIMATION)
61 | -------------------
62 | Chart available at http://localhost:8080
63 |
64 | ### Features:
65 |
66 | - [x] Live Trading
67 | - [x] Custom Strategy
68 | - [x] Order Limit, Market, Stop Limit, OCO
69 |
70 | - [x] Backtesting
71 | - [x] Paper Wallet (Live Trading with fake wallet)
72 | - [x] Load Feed from CSV
73 | - [x] Order Limit, Market, Stop Limit, OCO
74 |
75 | - [x] Bot Utilities
76 | - [x] CLI to download historical data
77 | - [x] Plot (Candles + Sell / Buy orders, Indicators)
78 | - [x] Telegram Controller (Status, Buy, Sell, and Notification)
79 | - [x] Heikin Ashi candle type support
80 | - [x] Trailing stop tool
81 | - [x] In app order scheduler
82 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | fast cryptocurrency trading bot framework implemented in Go. Ninjabot permits users to create and test custom strategies for spot markets.
4 | | DISCLAIMER |
5 | |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
6 | | 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 |
7 |
8 | ## Examples of Usage
9 |
10 | Check [examples](examples) directory:
11 |
12 | - Paper Wallet (Live Simulation)
13 | - Backtesting (Simulation with historical data)
14 | - Real Account
15 |
16 |
17 | **Example of usage**
18 | ```bash
19 | # Download candles of BTCUSDT to btc.csv file (Last 30 days, timeframe 1D)
20 | ninjabot download --pair BTCUSDT --timeframe 1d --days 30 --output ./btc.csv
21 | ```
22 |
23 | ### Backtesting Example
24 |
25 | - Backtesting a custom strategy from [examples](examples) directory:
26 | ```
27 | go run examples/backtesting/main.go
28 | ```
29 |
30 | Output:
31 |
32 | ```
33 | INFO[2022-10-16 16:34] [SETUP] Using paper wallet
34 | INFO[2022-10-16 16:34] [SETUP] Initial Portfolio = 10000.000000 USDT
35 | +---------+--------+-----+------+--------+--------+-----+----------+-----------+
36 | | PAIR | TRADES | WIN | LOSS | % WIN | PAYOFF | SQN | PROFIT | VOLUME |
37 | +---------+--------+-----+------+--------+--------+-----+----------+-----------+
38 | | BTCUSDT | 14 | 6 | 8 | 42.9 % | 5.929 | 1.5 | 13511.66 | 448030.04 |
39 | | ETHUSDT | 9 | 6 | 3 | 66.7 % | 3.407 | 1.3 | 21748.41 | 407769.64 |
40 | +---------+--------+-----+------+--------+--------+-----+----------+-----------+
41 | | TOTAL | 23 | 12 | 11 | 52.2 % | 4.942 | 1.4 | 35260.07 | 855799.68 |
42 | +---------+--------+-----+------+--------+--------+-----+----------+-----------+
43 |
44 | -- FINAL WALLET --
45 | 0.0000 BTC = 0.0000 USDT
46 | 0.0000 ETH = 0.0000 USDT
47 | 45260.0734 USDT
48 |
49 | ----- RETURNS -----
50 | START PORTFOLIO = 10000.00 USDT
51 | FINAL PORTFOLIO = 45260.07 USDT
52 | GROSS PROFIT = 35260.073380 USDT (352.60%)
53 | MARKET CHANGE (B&H) = 407.09%
54 |
55 | ------ RISK -------
56 | MAX DRAWDOWN = -11.76 %
57 |
58 | ------ VOLUME -----
59 | ETHUSDT = 407769.64 USDT
60 | BTCUSDT = 448030.04 USDT
61 | TOTAL = 855799.68 USDT
62 | COSTS (0.001*V) = 855.80 USDT (ESTIMATION)
63 | -------------------
64 | Chart available at http://localhost:8080
65 |
66 | ### Features:
67 |
68 | - [x] Live Trading
69 | - [x] Custom Strategy
70 | - [x] Order Limit, Market, Stop Limit, OCO
71 |
72 | - [x] Backtesting
73 | - [x] Paper Wallet (Live Trading with fake wallet)
74 | - [x] Load Feed from CSV
75 | - [x] Order Limit, Market, Stop Limit, OCO
76 |
77 | - [x] Bot Utilities
78 | - [x] CLI to download historical data
79 | - [x] Plot (Candles + Sell / Buy orders, Indicators)
80 | - [x] Telegram Controller (Status, Buy, Sell, and Notification)
81 | - [x] Heikin Ashi candle type support
82 | - [x] Trailing stop tool
83 | - [x] In app order scheduler
84 |
85 |
86 |
87 |
88 |
--------------------------------------------------------------------------------
/download/download.go:
--------------------------------------------------------------------------------
1 | package download
2 |
3 | import (
4 | "context"
5 | "encoding/csv"
6 | "os"
7 | "time"
8 |
9 | "github.com/rodrigo-brito/ninjabot/service"
10 |
11 | "github.com/schollz/progressbar/v3"
12 | log "github.com/sirupsen/logrus"
13 | "github.com/xhit/go-str2duration/v2"
14 | )
15 |
16 | const batchSize = 500
17 |
18 | type Downloader struct {
19 | exchange service.Exchange
20 | }
21 |
22 | func NewDownloader(exchange service.Exchange) 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 |
64 | now := time.Now()
65 | parameters := &Parameters{
66 | Start: now.AddDate(0, -1, 0),
67 | End: now,
68 | }
69 |
70 | for _, option := range options {
71 | option(parameters)
72 | }
73 |
74 | parameters.Start = time.Date(parameters.Start.Year(), parameters.Start.Month(), parameters.Start.Day(),
75 | 0, 0, 0, 0, time.UTC)
76 |
77 | if now.Sub(parameters.End) > 0 {
78 | parameters.End = time.Date(parameters.End.Year(), parameters.End.Month(), parameters.End.Day(),
79 | 0, 0, 0, 0, time.UTC)
80 | } else {
81 | parameters.End = now
82 | }
83 |
84 | candlesCount, interval, err := candlesCount(parameters.Start, parameters.End, timeframe)
85 | if err != nil {
86 | return err
87 | }
88 | candlesCount++
89 |
90 | log.Infof("Downloading %d candles of %s for %s", candlesCount, timeframe, pair)
91 | info := d.exchange.AssetsInfo(pair)
92 | writer := csv.NewWriter(recordFile)
93 |
94 | progressBar := progressbar.Default(int64(candlesCount))
95 | lostData := 0
96 | isLastLoop := false
97 |
98 | // write headers
99 | err = writer.Write([]string{
100 | "time", "open", "close", "low", "high", "volume",
101 | })
102 | if err != nil {
103 | return err
104 | }
105 |
106 | for begin := parameters.Start; begin.Before(parameters.End); begin = begin.Add(interval * batchSize) {
107 | end := begin.Add(interval * batchSize)
108 | if end.Before(parameters.End) {
109 | end = end.Add(-1 * time.Second)
110 | } else {
111 | end = parameters.End
112 | isLastLoop = true
113 | }
114 |
115 | candles, err := d.exchange.CandlesByPeriod(ctx, pair, timeframe, begin, end)
116 | if err != nil {
117 | return err
118 | }
119 |
120 | for _, candle := range candles {
121 | err := writer.Write(candle.ToSlice(info.QuotePrecision))
122 | if err != nil {
123 | return err
124 | }
125 | }
126 |
127 | countCandles := len(candles)
128 | if !isLastLoop {
129 | lostData += batchSize - countCandles
130 | }
131 |
132 | if err = progressBar.Add(countCandles); err != nil {
133 | log.Warningf("update progresbar fail: %s", err.Error())
134 | }
135 | }
136 |
137 | if err = progressBar.Close(); err != nil {
138 | log.Warningf("close progresbar fail: %s", err.Error())
139 | }
140 |
141 | if lostData > 0 {
142 | log.Warningf("%d missing candles", lostData)
143 | }
144 |
145 | writer.Flush()
146 | log.Info("Done!")
147 | return writer.Error()
148 | }
149 |
--------------------------------------------------------------------------------
/exchange/exchange.go:
--------------------------------------------------------------------------------
1 | package exchange
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "strings"
8 | "sync"
9 |
10 | "github.com/rodrigo-brito/ninjabot/model"
11 | "github.com/rodrigo-brito/ninjabot/service"
12 |
13 | "github.com/StudioSol/set"
14 | log "github.com/sirupsen/logrus"
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 |
--------------------------------------------------------------------------------
/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 | Tick string
23 | Free float64
24 | Lock float64
25 | }
26 |
27 | type AssetInfo struct {
28 | BaseAsset string
29 | QuoteAsset string
30 |
31 | MinPrice float64
32 | MaxPrice float64
33 | MinQuantity float64
34 | MaxQuantity float64
35 | StepSize float64
36 | TickSize float64
37 |
38 | QuotePrecision int
39 | BaseAssetPrecision int
40 | }
41 |
42 | type Dataframe struct {
43 | Pair string
44 |
45 | Close Series
46 | Open Series
47 | High Series
48 | Low Series
49 | Volume Series
50 |
51 | Time []time.Time
52 | LastUpdate time.Time
53 |
54 | // Custom user metadata
55 | Metadata map[string]Series
56 | }
57 |
58 | type Candle struct {
59 | Pair string
60 | Time time.Time
61 | UpdatedAt time.Time
62 | Open float64
63 | Close float64
64 | Low float64
65 | High float64
66 | Volume float64
67 | Complete bool
68 |
69 | // Aditional collums from CSV inputs
70 | Metadata map[string]float64
71 | }
72 |
73 | func (c Candle) Empty() bool {
74 | return c.Pair == "" && c.Close == 0 && c.Open == 0 && c.Volume == 0
75 | }
76 |
77 | type HeikinAshi struct {
78 | PreviousHACandle Candle
79 | }
80 |
81 | func NewHeikinAshi() *HeikinAshi {
82 | return &HeikinAshi{}
83 | }
84 |
85 | func (c Candle) ToSlice(precision int) []string {
86 | return []string{
87 | fmt.Sprintf("%d", c.Time.Unix()),
88 | strconv.FormatFloat(c.Open, 'f', precision, 64),
89 | strconv.FormatFloat(c.Close, 'f', precision, 64),
90 | strconv.FormatFloat(c.Low, 'f', precision, 64),
91 | strconv.FormatFloat(c.High, 'f', precision, 64),
92 | fmt.Sprintf("%.1f", c.Volume),
93 | }
94 | }
95 |
96 | func (c Candle) ToHeikinAshi(ha *HeikinAshi) Candle {
97 | haCandle := ha.CalculateHeikinAshi(c)
98 |
99 | return Candle{
100 | Pair: c.Pair,
101 | Open: haCandle.Open,
102 | High: haCandle.High,
103 | Low: haCandle.Low,
104 | Close: haCandle.Close,
105 | Volume: c.Volume,
106 | Complete: c.Complete,
107 | Time: c.Time,
108 | UpdatedAt: c.UpdatedAt,
109 | }
110 | }
111 |
112 | func (c Candle) Less(j Item) bool {
113 | diff := j.(Candle).Time.Sub(c.Time)
114 | if diff < 0 {
115 | return false
116 | }
117 | if diff > 0 {
118 | return true
119 | }
120 |
121 | diff = j.(Candle).UpdatedAt.Sub(c.UpdatedAt)
122 | if diff < 0 {
123 | return false
124 | }
125 | if diff > 0 {
126 | return true
127 | }
128 |
129 | return c.Pair < j.(Candle).Pair
130 | }
131 |
132 | type Account struct {
133 | Balances []Balance
134 | }
135 |
136 | func (a Account) Balance(assetTick, quoteTick string) (Balance, Balance) {
137 | var assetBalance, quoteBalance Balance
138 | var isSetAsset, isSetQuote bool
139 |
140 | for _, balance := range a.Balances {
141 | switch balance.Tick {
142 | case assetTick:
143 | assetBalance = balance
144 | isSetAsset = true
145 | case quoteTick:
146 | quoteBalance = balance
147 | isSetQuote = true
148 | }
149 |
150 | if isSetAsset && isSetQuote {
151 | break
152 | }
153 | }
154 |
155 | return assetBalance, quoteBalance
156 | }
157 |
158 | func (a Account) Equity() float64 {
159 | var total float64
160 |
161 | for _, balance := range a.Balances {
162 | total += balance.Free
163 | total += balance.Lock
164 | }
165 |
166 | return total
167 | }
168 |
169 | func (ha *HeikinAshi) CalculateHeikinAshi(c Candle) Candle {
170 | var hkCandle Candle
171 |
172 | openValue := ha.PreviousHACandle.Open
173 | closeValue := ha.PreviousHACandle.Close
174 |
175 | // First HA candle is calculated using current candle
176 | if ha.PreviousHACandle.Empty() {
177 | openValue = c.Open
178 | closeValue = c.Close
179 | }
180 |
181 | hkCandle.Open = (openValue + closeValue) / 2
182 | hkCandle.Close = (c.Open + c.High + c.Low + c.Close) / 4
183 | hkCandle.High = math.Max(c.High, math.Max(hkCandle.Open, hkCandle.Close))
184 | hkCandle.Low = math.Min(c.Low, math.Min(hkCandle.Open, hkCandle.Close))
185 | ha.PreviousHACandle = hkCandle
186 |
187 | return hkCandle
188 | }
189 |
--------------------------------------------------------------------------------
/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.1,
15 | Close: 10000.1,
16 | Low: 10000.1,
17 | High: 10000.1,
18 | Volume: 10000.1,
19 | Complete: true,
20 | }
21 | require.Equal(t, []string{"1609459200", "10000.1", "10000.1", "10000.1", "10000.1", "10000.1"}, candle.ToSlice(1))
22 | }
23 |
24 | func TestCandle_Less(t *testing.T) {
25 | now := time.Now()
26 |
27 | t.Run("equal time", func(t *testing.T) {
28 | candle := Candle{Time: now, UpdatedAt: now, Pair: "A"}
29 | item := Item(Candle{Time: now, UpdatedAt: now.Add(time.Minute), Pair: "B"})
30 | require.True(t, candle.Less(item))
31 | })
32 |
33 | t.Run("candle after item", func(t *testing.T) {
34 | candle := Candle{Time: now.Add(time.Minute), Pair: "A"}
35 | item := Item(Candle{Time: now, Pair: "B"})
36 | require.False(t, candle.Less(item))
37 | })
38 | }
39 |
40 | func TestAccount_Balance(t *testing.T) {
41 | account := Account{}
42 | account.Balances = []Balance{{Tick: "A", Free: 1.2, Lock: 1.0}, {Tick: "B", Free: 1.1, Lock: 1.3}}
43 | assetBalance, quoteBalance := account.Balance("A", "B")
44 | require.Equal(t, Balance{Tick: "A", Free: 1.2, Lock: 1.0}, assetBalance)
45 | require.Equal(t, Balance{Tick: "B", Free: 1.1, Lock: 1.3}, quoteBalance)
46 | }
47 |
48 | func TestHeikinAshi_CalculateHeikinAshi(t *testing.T) {
49 | ha := NewHeikinAshi()
50 |
51 | if (!HeikinAshi{}.PreviousHACandle.Empty()) {
52 | t.Errorf("PreviousCandle should be empty")
53 | }
54 |
55 | // BTC-USDT weekly candles from Binance from 2017-08-14 to 2017-10-30
56 | // First market candles were used to easily test accuracy against
57 | // TradingView without having to download all market data.
58 | candles := []Candle{
59 | {Open: 4261.48, Close: 4086.29, High: 4485.39, Low: 3850.00},
60 | {Open: 4069.13, Close: 4310.01, High: 4453.91, Low: 3400.00},
61 | {Open: 4310.01, Close: 4509.08, High: 4939.19, Low: 4124.54},
62 | {Open: 4505.00, Close: 4130.37, High: 4788.59, Low: 3603.00},
63 | {Open: 4153.62, Close: 3699.99, High: 4394.59, Low: 2817.00},
64 | {Open: 3690.00, Close: 3660.02, High: 4123.20, Low: 3505.55},
65 | {Open: 3660.02, Close: 4378.48, High: 4406.52, Low: 3653.69},
66 | {Open: 4400.00, Close: 4640.00, High: 4658.00, Low: 4110.00},
67 | {Open: 4640.00, Close: 5709.99, High: 5922.30, Low: 4550.00},
68 | {Open: 5710.00, Close: 5950.02, High: 6171.00, Low: 5037.95},
69 | {Open: 5975.00, Close: 6169.98, High: 6189.88, Low: 5286.98},
70 | {Open: 6133.01, Close: 7345.01, High: 7590.25, Low: 6030.00},
71 | }
72 |
73 | var results []Candle
74 |
75 | for _, candle := range candles {
76 | haCandle := ha.CalculateHeikinAshi(candle)
77 | results = append(results, haCandle)
78 | }
79 |
80 | // Expected values taken from TradingView.
81 | // Source: Binance BTC-USDT
82 | expected := []Candle{
83 | {Open: 4173.885, Close: 4170.79, High: 4485.39, Low: 3850},
84 | {Open: 4172.3375, Close: 4058.2625000000003, High: 4453.91, Low: 3400},
85 | {Open: 4115.3, Close: 4470.705, High: 4939.19, Low: 4115.30},
86 | {Open: 4293.0025000000005, Close: 4256.74, High: 4788.59, Low: 3603},
87 | {Open: 4274.87125, Close: 3766.2999999999997, High: 4394.59, Low: 2817},
88 | {Open: 4020.5856249999997, Close: 3744.6925, High: 4123.2, Low: 3505.55},
89 | {Open: 3882.6390625, Close: 4024.6775000000002, High: 4406.52, Low: 3653.69},
90 | {Open: 3953.65828125, Close: 4452, High: 4658, Low: 3953.65828125},
91 | {Open: 4202.829140625, Close: 5205.5725, High: 5922.3, Low: 4202.829140625},
92 | {Open: 4704.200820312501, Close: 5717.2425, High: 6171.00, Low: 4704.200820312501},
93 | {Open: 5210.72166015625, Close: 5905.46, High: 6189.88, Low: 5210.72166015625},
94 | {Open: 5558.090830078125, Close: 6774.567500000001, High: 7590.25, Low: 5558.090830078125},
95 | }
96 |
97 | if len(expected) != len(results) {
98 | t.Errorf("Expected %d HA candles. Got %d.", len(expected), len(results))
99 | }
100 |
101 | for index, expectedHaCandle := range expected {
102 | require.Equal(t, expectedHaCandle.Open, results[index].Open)
103 | require.Equal(t, expectedHaCandle.Close, results[index].Close)
104 | require.Equal(t, expectedHaCandle.High, results[index].High)
105 | require.Equal(t, expectedHaCandle.Low, results[index].Low)
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/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.ordersByPair[pair1] = set.NewLinkedHashSetINT64()
212 | c.ordersByPair[pair1].Add(order1.ID)
213 | c.orderByID[order1.ID] = order1
214 |
215 | c.ordersByPair[pair1].Add(order2.ID)
216 | c.orderByID[order2.ID] = order2
217 |
218 | c.ordersByPair[pair2] = set.NewLinkedHashSetINT64()
219 | c.ordersByPair[pair2].Add(order3.ID)
220 | c.orderByID[order3.ID] = order3
221 |
222 | expectPair1 := [][]string{
223 | {
224 | "FILLED", "SELL", "1", "MARKET", "4783.340000", "3059.370000",
225 | "14634006.90", "2021-09-26 20:00:00 +0000 UTC",
226 | },
227 | {
228 | "FILLED", "BUY", "2", "MARKET", "0.751520", "3607.420000",
229 | "2711.05", "2021-10-13 20:00:00 +0000 UTC",
230 | },
231 | }
232 |
233 | ordersPair1 := c.orderStringByPair(pair1)
234 | require.Equal(t, expectPair1, ordersPair1)
235 |
236 | expectPair2 := [][]string{
237 | {
238 | "FILLED", "BUY", "13", "MARKET", "12.083240", "470.000000",
239 | "5679.12", "2021-10-13 20:00:00 +0000 UTC",
240 | },
241 | }
242 | ordersPair2 := c.orderStringByPair(pair2)
243 | require.Equal(t, expectPair2, ordersPair2)
244 | }
245 |
--------------------------------------------------------------------------------
/order/controller_test.go:
--------------------------------------------------------------------------------
1 | package order
2 |
3 | import (
4 | "context"
5 | "testing"
6 | "time"
7 |
8 | "github.com/rodrigo-brito/ninjabot/exchange"
9 | "github.com/rodrigo-brito/ninjabot/model"
10 | "github.com/rodrigo-brito/ninjabot/storage"
11 | "github.com/stretchr/testify/assert"
12 | "github.com/stretchr/testify/require"
13 | )
14 |
15 | func TestController_calculateProfit(t *testing.T) {
16 | t.Run("market orders", func(t *testing.T) {
17 | storage, err := storage.FromMemory()
18 | require.NoError(t, err)
19 | ctx := context.Background()
20 | wallet := exchange.NewPaperWallet(ctx, "USDT", exchange.WithPaperAsset("USDT", 3000))
21 | controller := NewController(ctx, wallet, storage, NewOrderFeed())
22 |
23 | wallet.OnCandle(model.Candle{Pair: "BTCUSDT", Close: 1000})
24 | _, err = controller.CreateOrderMarket(model.SideTypeBuy, "BTCUSDT", 1)
25 | require.NoError(t, err)
26 |
27 | wallet.OnCandle(model.Candle{Pair: "BTCUSDT", Close: 2000})
28 | _, err = controller.CreateOrderMarket(model.SideTypeBuy, "BTCUSDT", 1)
29 | require.NoError(t, err)
30 |
31 | // close half position 1BTC with 100% of profit
32 | wallet.OnCandle(model.Candle{Pair: "BTCUSDT", Close: 3000})
33 | sellOrder, err := controller.CreateOrderMarket(model.SideTypeSell, "BTCUSDT", 1)
34 | require.NoError(t, err)
35 |
36 | value, profit, err := controller.calculateProfit(&sellOrder)
37 | require.NoError(t, err)
38 | assert.Equal(t, 1500.0, value)
39 | assert.Equal(t, 1.0, profit)
40 |
41 | // sell remaining BTC, 50% of loss
42 | wallet.OnCandle(model.Candle{Pair: "BTCUSDT", Close: 750})
43 | sellOrder, err = controller.CreateOrderMarket(model.SideTypeSell, "BTCUSDT", 1)
44 | require.NoError(t, err)
45 | value, profit, err = controller.calculateProfit(&sellOrder)
46 | require.NoError(t, err)
47 | assert.Equal(t, -750.0, value)
48 | assert.Equal(t, -0.5, profit)
49 | })
50 |
51 | t.Run("limit order", func(t *testing.T) {
52 | storage, err := storage.FromMemory()
53 | require.NoError(t, err)
54 | ctx := context.Background()
55 | wallet := exchange.NewPaperWallet(ctx, "USDT", exchange.WithPaperAsset("USDT", 3000))
56 | controller := NewController(ctx, wallet, storage, NewOrderFeed())
57 | wallet.OnCandle(model.Candle{Time: time.Now(), Pair: "BTCUSDT", High: 1500, Close: 1500})
58 |
59 | _, err = controller.CreateOrderLimit(model.SideTypeBuy, "BTCUSDT", 1, 1000)
60 | require.NoError(t, err)
61 |
62 | // should execute previous order
63 | wallet.OnCandle(model.Candle{Time: time.Now(), Pair: "BTCUSDT", High: 1000, Close: 1000})
64 |
65 | sellOrder, err := controller.CreateOrderLimit(model.SideTypeSell, "BTCUSDT", 1, 2000)
66 | require.NoError(t, err)
67 |
68 | // should execute previous order
69 | wallet.OnCandle(model.Candle{Time: time.Now(), Pair: "BTCUSDT", High: 2000, Close: 2000})
70 | controller.updateOrders()
71 |
72 | value, profit, err := controller.calculateProfit(&sellOrder)
73 | require.NoError(t, err)
74 | assert.Equal(t, 1000.0, value)
75 | assert.Equal(t, 1.0, profit)
76 | })
77 |
78 | t.Run("oco order limit maker", func(t *testing.T) {
79 | storage, err := storage.FromMemory()
80 | require.NoError(t, err)
81 | ctx := context.Background()
82 | wallet := exchange.NewPaperWallet(ctx, "USDT", exchange.WithPaperAsset("USDT", 3000))
83 | controller := NewController(ctx, wallet, storage, NewOrderFeed())
84 | wallet.OnCandle(model.Candle{Time: time.Now(), Pair: "BTCUSDT", High: 1500, Close: 1500})
85 |
86 | _, err = controller.CreateOrderLimit(model.SideTypeBuy, "BTCUSDT", 1, 1000)
87 | require.NoError(t, err)
88 |
89 | // should execute previous order
90 | wallet.OnCandle(model.Candle{Time: time.Now(), Pair: "BTCUSDT", High: 1000, Close: 1000})
91 |
92 | sellOrder, err := controller.CreateOrderOCO(model.SideTypeSell, "BTCUSDT", 1, 2000, 500, 500)
93 | require.NoError(t, err)
94 |
95 | // should execute previous order
96 | wallet.OnCandle(model.Candle{Time: time.Now(), Pair: "BTCUSDT", High: 2000, Close: 2000})
97 | controller.updateOrders()
98 |
99 | value, profit, err := controller.calculateProfit(&sellOrder[0])
100 | require.NoError(t, err)
101 | assert.Equal(t, 1000.0, value)
102 | assert.Equal(t, 1.0, profit)
103 | })
104 |
105 | t.Run("oco stop sell", func(t *testing.T) {
106 | storage, err := storage.FromMemory()
107 | require.NoError(t, err)
108 | ctx := context.Background()
109 | wallet := exchange.NewPaperWallet(ctx, "USDT", exchange.WithPaperAsset("USDT", 3000))
110 | controller := NewController(ctx, wallet, storage, NewOrderFeed())
111 | wallet.OnCandle(model.Candle{Time: time.Now(), Pair: "BTCUSDT", Close: 1500, Low: 1500})
112 |
113 | _, err = controller.CreateOrderLimit(model.SideTypeBuy, "BTCUSDT", 0.5, 1000)
114 | require.NoError(t, err)
115 |
116 | _, err = controller.CreateOrderLimit(model.SideTypeBuy, "BTCUSDT", 1.5, 1000)
117 | require.NoError(t, err)
118 |
119 | // should execute previous order
120 | wallet.OnCandle(model.Candle{Time: time.Now(), Pair: "BTCUSDT", Close: 1000, Low: 1000})
121 |
122 | _, err = controller.CreateOrderMarket(model.SideTypeBuy, "BTCUSDT", 1.0)
123 | require.NoError(t, err)
124 |
125 | sellOrder, err := controller.CreateOrderOCO(model.SideTypeSell, "BTCUSDT", 1, 2000, 500, 500)
126 | require.NoError(t, err)
127 |
128 | // should execute previous order
129 | wallet.OnCandle(model.Candle{Time: time.Now(), Pair: "BTCUSDT", Close: 400, Low: 400})
130 | controller.updateOrders()
131 |
132 | value, profit, err := controller.calculateProfit(&sellOrder[1])
133 | require.NoError(t, err)
134 | assert.Equal(t, -500.0, value)
135 | assert.Equal(t, -0.5, profit)
136 | })
137 |
138 | t.Run("no buy information", func(t *testing.T) {
139 | storage, err := storage.FromMemory()
140 | require.NoError(t, err)
141 | ctx := context.Background()
142 | wallet := exchange.NewPaperWallet(ctx, "USDT", exchange.WithPaperAsset("USDT", 0),
143 | exchange.WithPaperAsset("BTC", 2))
144 | controller := NewController(ctx, wallet, storage, NewOrderFeed())
145 | wallet.OnCandle(model.Candle{Time: time.Now(), Pair: "BTCUSDT", Close: 1500, Low: 1500})
146 |
147 | sellOrder, err := controller.CreateOrderMarket(model.SideTypeSell, "BTCUSDT", 1)
148 | require.NoError(t, err)
149 |
150 | value, profit, err := controller.calculateProfit(&sellOrder)
151 | require.NoError(t, err)
152 | assert.Equal(t, 0.0, value)
153 | assert.Equal(t, 0.0, profit)
154 | })
155 | }
156 |
157 | func TestController_PositionValue(t *testing.T) {
158 | storage, err := storage.FromMemory()
159 | require.NoError(t, err)
160 | ctx := context.Background()
161 | wallet := exchange.NewPaperWallet(ctx, "USDT", exchange.WithPaperAsset("USDT", 3000))
162 | controller := NewController(ctx, wallet, storage, NewOrderFeed())
163 |
164 | lastCandle := model.Candle{Time: time.Now(), Pair: "BTCUSDT", Close: 1500, Low: 1500}
165 |
166 | // update wallet and controller
167 | wallet.OnCandle(lastCandle)
168 | controller.OnCandle(lastCandle)
169 |
170 | _, err = controller.CreateOrderMarket(model.SideTypeBuy, "BTCUSDT", 1.0)
171 | require.NoError(t, err)
172 |
173 | value, err := controller.PositionValue("BTCUSDT")
174 | require.NoError(t, err)
175 | assert.Equal(t, 1500.0, value)
176 | }
177 |
178 | func TestController_Position(t *testing.T) {
179 | storage, err := storage.FromMemory()
180 | require.NoError(t, err)
181 | ctx := context.Background()
182 | wallet := exchange.NewPaperWallet(ctx, "USDT", exchange.WithPaperAsset("USDT", 3000))
183 | controller := NewController(ctx, wallet, storage, NewOrderFeed())
184 |
185 | lastCandle := model.Candle{Time: time.Now(), Pair: "BTCUSDT", Close: 1500, Low: 1500}
186 |
187 | // update wallet and controller
188 | wallet.OnCandle(lastCandle)
189 | controller.OnCandle(lastCandle)
190 |
191 | _, err = controller.CreateOrderMarket(model.SideTypeBuy, "BTCUSDT", 1.0)
192 | require.NoError(t, err)
193 |
194 | asset, quote, err := controller.Position("BTCUSDT")
195 | require.NoError(t, err)
196 | assert.Equal(t, 1.0, asset)
197 | assert.Equal(t, 1500.0, quote)
198 | }
199 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/ninjabot.go:
--------------------------------------------------------------------------------
1 | package ninjabot
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "strconv"
8 |
9 | "github.com/rodrigo-brito/ninjabot/exchange"
10 | "github.com/rodrigo-brito/ninjabot/model"
11 | "github.com/rodrigo-brito/ninjabot/notification"
12 | "github.com/rodrigo-brito/ninjabot/order"
13 | "github.com/rodrigo-brito/ninjabot/service"
14 | "github.com/rodrigo-brito/ninjabot/storage"
15 | "github.com/rodrigo-brito/ninjabot/strategy"
16 |
17 | "github.com/olekukonko/tablewriter"
18 | "github.com/schollz/progressbar/v3"
19 | log "github.com/sirupsen/logrus"
20 | )
21 |
22 | const defaultDatabase = "ninjabot.db"
23 |
24 | func init() {
25 | log.SetFormatter(&log.TextFormatter{
26 | FullTimestamp: true,
27 | TimestampFormat: "2006-01-02 15:04",
28 | })
29 | }
30 |
31 | type OrderSubscriber interface {
32 | OnOrder(model.Order)
33 | }
34 |
35 | type CandleSubscriber interface {
36 | OnCandle(model.Candle)
37 | }
38 |
39 | type NinjaBot struct {
40 | storage storage.Storage
41 | settings model.Settings
42 | exchange service.Exchange
43 | strategy strategy.Strategy
44 | notifier service.Notifier
45 | telegram service.Telegram
46 |
47 | orderController *order.Controller
48 | priorityQueueCandle *model.PriorityQueue
49 | strategiesControllers map[string]*strategy.Controller
50 | orderFeed *order.Feed
51 | dataFeed *exchange.DataFeedSubscription
52 | paperWallet *exchange.PaperWallet
53 |
54 | backtest bool
55 | }
56 |
57 | type Option func(*NinjaBot)
58 |
59 | func NewBot(ctx context.Context, settings model.Settings, exch service.Exchange, str strategy.Strategy,
60 | options ...Option) (*NinjaBot, error) {
61 |
62 | bot := &NinjaBot{
63 | settings: settings,
64 | exchange: exch,
65 | strategy: str,
66 | orderFeed: order.NewOrderFeed(),
67 | dataFeed: exchange.NewDataFeed(exch),
68 | strategiesControllers: make(map[string]*strategy.Controller),
69 | priorityQueueCandle: model.NewPriorityQueue(nil),
70 | }
71 |
72 | for _, pair := range settings.Pairs {
73 | asset, quote := exchange.SplitAssetQuote(pair)
74 | if asset == "" || quote == "" {
75 | return nil, fmt.Errorf("invalid pair: %s", pair)
76 | }
77 | }
78 |
79 | for _, option := range options {
80 | option(bot)
81 | }
82 |
83 | var err error
84 | if bot.storage == nil {
85 | bot.storage, err = storage.FromFile(defaultDatabase)
86 | if err != nil {
87 | return nil, err
88 | }
89 | }
90 |
91 | bot.orderController = order.NewController(ctx, exch, bot.storage, bot.orderFeed)
92 |
93 | if settings.Telegram.Enabled {
94 | bot.telegram, err = notification.NewTelegram(bot.orderController, settings)
95 | if err != nil {
96 | return nil, err
97 | }
98 | // register telegram as notifier
99 | WithNotifier(bot.telegram)(bot)
100 | }
101 |
102 | return bot, nil
103 | }
104 |
105 | // WithBacktest sets the bot to run in backtest mode, it is required for backtesting environments
106 | // Backtest mode optimize the input read for CSV and deal with race conditions
107 | func WithBacktest(wallet *exchange.PaperWallet) Option {
108 | return func(bot *NinjaBot) {
109 | bot.backtest = true
110 | opt := WithPaperWallet(wallet)
111 | opt(bot)
112 | }
113 | }
114 |
115 | // WithStorage sets the storage for the bot, by default it uses a local file called ninjabot.db
116 | func WithStorage(storage storage.Storage) Option {
117 | return func(bot *NinjaBot) {
118 | bot.storage = storage
119 | }
120 | }
121 |
122 | // WithLogLevel sets the log level. eg: log.DebugLevel, log.InfoLevel, log.WarnLevel, log.ErrorLevel, log.FatalLevel
123 | func WithLogLevel(level log.Level) Option {
124 | return func(bot *NinjaBot) {
125 | log.SetLevel(level)
126 | }
127 | }
128 |
129 | // WithNotifier registers a notifier to the bot, currently only email and telegram are supported
130 | func WithNotifier(notifier service.Notifier) Option {
131 | return func(bot *NinjaBot) {
132 | bot.notifier = notifier
133 | bot.orderController.SetNotifier(notifier)
134 | bot.SubscribeOrder(notifier)
135 | }
136 | }
137 |
138 | // WithCandleSubscription subscribes a given struct to the candle feed
139 | func WithCandleSubscription(subscriber CandleSubscriber) Option {
140 | return func(bot *NinjaBot) {
141 | bot.SubscribeCandle(subscriber)
142 | }
143 | }
144 |
145 | // WithPaperWallet sets the paper wallet for the bot (used for backtesting and live simulation)
146 | func WithPaperWallet(wallet *exchange.PaperWallet) Option {
147 | return func(bot *NinjaBot) {
148 | bot.paperWallet = wallet
149 | }
150 | }
151 |
152 | func (n *NinjaBot) SubscribeCandle(subscriptions ...CandleSubscriber) {
153 | for _, pair := range n.settings.Pairs {
154 | for _, subscription := range subscriptions {
155 | n.dataFeed.Subscribe(pair, n.strategy.Timeframe(), subscription.OnCandle, false)
156 | }
157 | }
158 | }
159 |
160 | func WithOrderSubscription(subscriber OrderSubscriber) Option {
161 | return func(bot *NinjaBot) {
162 | bot.SubscribeOrder(subscriber)
163 | }
164 | }
165 |
166 | func (n *NinjaBot) SubscribeOrder(subscriptions ...OrderSubscriber) {
167 | for _, pair := range n.settings.Pairs {
168 | for _, subscription := range subscriptions {
169 | n.orderFeed.Subscribe(pair, subscription.OnOrder, false)
170 | }
171 | }
172 | }
173 |
174 | func (n *NinjaBot) Controller() *order.Controller {
175 | return n.orderController
176 | }
177 |
178 | // Summary function displays all trades, accuracy and some bot metrics in stdout
179 | // To access the raw data, you may access `bot.Controller().Results`
180 | func (n *NinjaBot) Summary() {
181 | var (
182 | total float64
183 | wins int
184 | loses int
185 | volume float64
186 | sqn float64
187 | )
188 |
189 | buffer := bytes.NewBuffer(nil)
190 | table := tablewriter.NewWriter(buffer)
191 | table.SetHeader([]string{"Pair", "Trades", "Win", "Loss", "% Win", "Payoff", "SQN", "Profit", "Volume"})
192 | table.SetFooterAlignment(tablewriter.ALIGN_RIGHT)
193 | avgPayoff := 0.0
194 |
195 | for _, summary := range n.orderController.Results {
196 | avgPayoff += summary.Payoff() * float64(len(summary.Win)+len(summary.Lose))
197 | table.Append([]string{
198 | summary.Pair,
199 | strconv.Itoa(len(summary.Win) + len(summary.Lose)),
200 | strconv.Itoa(len(summary.Win)),
201 | strconv.Itoa(len(summary.Lose)),
202 | fmt.Sprintf("%.1f %%", float64(len(summary.Win))/float64(len(summary.Win)+len(summary.Lose))*100),
203 | fmt.Sprintf("%.3f", summary.Payoff()),
204 | fmt.Sprintf("%.1f", summary.SQN()),
205 | fmt.Sprintf("%.2f", summary.Profit()),
206 | fmt.Sprintf("%.2f", summary.Volume),
207 | })
208 | total += summary.Profit()
209 | sqn += summary.SQN()
210 | wins += len(summary.Win)
211 | loses += len(summary.Lose)
212 | volume += summary.Volume
213 | }
214 |
215 | table.SetFooter([]string{
216 | "TOTAL",
217 | strconv.Itoa(wins + loses),
218 | strconv.Itoa(wins),
219 | strconv.Itoa(loses),
220 | fmt.Sprintf("%.1f %%", float64(wins)/float64(wins+loses)*100),
221 | fmt.Sprintf("%.3f", avgPayoff/float64(wins+loses)),
222 | fmt.Sprintf("%.1f", sqn/float64(len(n.orderController.Results))),
223 | fmt.Sprintf("%.2f", total),
224 | fmt.Sprintf("%.2f", volume),
225 | })
226 | table.Render()
227 |
228 | fmt.Println(buffer.String())
229 | if n.paperWallet != nil {
230 | n.paperWallet.Summary()
231 | }
232 | }
233 |
234 | func (n *NinjaBot) onCandle(candle model.Candle) {
235 | n.priorityQueueCandle.Push(candle)
236 | }
237 |
238 | func (n *NinjaBot) processCandle(candle model.Candle) {
239 | if n.paperWallet != nil {
240 | n.paperWallet.OnCandle(candle)
241 | }
242 |
243 | n.strategiesControllers[candle.Pair].OnPartialCandle(candle)
244 | if candle.Complete {
245 | n.strategiesControllers[candle.Pair].OnCandle(candle)
246 | n.orderController.OnCandle(candle)
247 | }
248 | }
249 |
250 | // Process pending candles in buffer
251 | func (n *NinjaBot) processCandles() {
252 | for item := range n.priorityQueueCandle.PopLock() {
253 | n.processCandle(item.(model.Candle))
254 | }
255 | }
256 |
257 | // Start the backtest process and create a progress bar
258 | // backtestCandles will process candles from a prirority queue in chronological order
259 | func (n *NinjaBot) backtestCandles() {
260 | log.Info("[SETUP] Starting backtesting")
261 |
262 | progressBar := progressbar.Default(int64(n.priorityQueueCandle.Len()))
263 | for n.priorityQueueCandle.Len() > 0 {
264 | item := n.priorityQueueCandle.Pop()
265 |
266 | candle := item.(model.Candle)
267 | if n.paperWallet != nil {
268 | n.paperWallet.OnCandle(candle)
269 | }
270 |
271 | n.strategiesControllers[candle.Pair].OnPartialCandle(candle)
272 | if candle.Complete {
273 | n.strategiesControllers[candle.Pair].OnCandle(candle)
274 | }
275 |
276 | if err := progressBar.Add(1); err != nil {
277 | log.Warningf("update progresbar fail: %v", err)
278 | }
279 | }
280 | }
281 |
282 | // Before Ninjabot start, we need to load the necessary data to fill strategy indicators
283 | // Then, we need to get the time frame and warmup period to fetch the necessary candles
284 | func (n *NinjaBot) preload(ctx context.Context, pair string) error {
285 | if n.backtest {
286 | return nil
287 | }
288 |
289 | candles, err := n.exchange.CandlesByLimit(ctx, pair, n.strategy.Timeframe(), n.strategy.WarmupPeriod())
290 | if err != nil {
291 | return err
292 | }
293 |
294 | for _, candle := range candles {
295 | n.processCandle(candle)
296 | }
297 |
298 | n.dataFeed.Preload(pair, n.strategy.Timeframe(), candles)
299 |
300 | return nil
301 | }
302 |
303 | // Run will initialize the strategy controller, order controller, preload data and start the bot
304 | func (n *NinjaBot) Run(ctx context.Context) error {
305 | for _, pair := range n.settings.Pairs {
306 | // setup and subscribe strategy to data feed (candles)
307 | n.strategiesControllers[pair] = strategy.NewStrategyController(pair, n.strategy, n.orderController)
308 |
309 | // preload candles for warmup period
310 | err := n.preload(ctx, pair)
311 | if err != nil {
312 | return err
313 | }
314 |
315 | // link to ninja bot controller
316 | n.dataFeed.Subscribe(pair, n.strategy.Timeframe(), n.onCandle, false)
317 |
318 | // start strategy controller
319 | n.strategiesControllers[pair].Start()
320 | }
321 |
322 | // start order feed and controller
323 | n.orderFeed.Start()
324 | n.orderController.Start()
325 | defer n.orderController.Stop()
326 | if n.telegram != nil {
327 | n.telegram.Start()
328 | }
329 |
330 | // start data feed and receives new candles
331 | n.dataFeed.Start(n.backtest)
332 |
333 | // start processing new candles for production or backtesting environment
334 | if n.backtest {
335 | n.backtestCandles()
336 | } else {
337 | n.processCandles()
338 | }
339 |
340 | return nil
341 | }
342 |
--------------------------------------------------------------------------------
/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.go:
--------------------------------------------------------------------------------
1 | package order
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "math"
7 | "strconv"
8 | "strings"
9 | "sync"
10 | "time"
11 |
12 | "github.com/rodrigo-brito/ninjabot/exchange"
13 | "github.com/rodrigo-brito/ninjabot/model"
14 | "github.com/rodrigo-brito/ninjabot/service"
15 | "github.com/rodrigo-brito/ninjabot/storage"
16 |
17 | "github.com/olekukonko/tablewriter"
18 | log "github.com/sirupsen/logrus"
19 | )
20 |
21 | type summary struct {
22 | Pair string
23 | Win []float64
24 | Lose []float64
25 | Volume float64
26 | }
27 |
28 | func (s summary) Profit() float64 {
29 | profit := 0.0
30 | for _, value := range append(s.Win, s.Lose...) {
31 | profit += value
32 | }
33 | return profit
34 | }
35 |
36 | func (s summary) SQN() float64 {
37 | total := float64(len(s.Win) + len(s.Lose))
38 | avgProfit := s.Profit() / total
39 | stdDev := 0.0
40 | for _, profit := range append(s.Win, s.Lose...) {
41 | stdDev += math.Pow(profit-avgProfit, 2)
42 | }
43 | stdDev = math.Sqrt(stdDev / total)
44 | return math.Sqrt(total) * (s.Profit() / total) / stdDev
45 | }
46 |
47 | func (s summary) Payoff() float64 {
48 | avgWin := 0.0
49 | avgLose := 0.0
50 |
51 | for _, value := range s.Win {
52 | avgWin += value
53 | }
54 |
55 | for _, value := range s.Lose {
56 | avgLose += value
57 | }
58 |
59 | if len(s.Win) == 0 || len(s.Lose) == 0 || avgLose == 0 {
60 | return 0
61 | }
62 |
63 | return (avgWin / float64(len(s.Win))) / math.Abs(avgLose/float64(len(s.Lose)))
64 | }
65 |
66 | func (s summary) WinPercentage() float64 {
67 | if len(s.Win)+len(s.Lose) == 0 {
68 | return 0
69 | }
70 |
71 | return float64(len(s.Win)) / float64(len(s.Win)+len(s.Lose)) * 100
72 | }
73 |
74 | func (s summary) String() string {
75 | tableString := &strings.Builder{}
76 | table := tablewriter.NewWriter(tableString)
77 | _, quote := exchange.SplitAssetQuote(s.Pair)
78 | data := [][]string{
79 | {"Coin", s.Pair},
80 | {"Trades", strconv.Itoa(len(s.Lose) + len(s.Win))},
81 | {"Win", strconv.Itoa(len(s.Win))},
82 | {"Loss", strconv.Itoa(len(s.Lose))},
83 | {"% Win", fmt.Sprintf("%.1f", s.WinPercentage())},
84 | {"Payoff", fmt.Sprintf("%.1f", s.Payoff()*100)},
85 | {"Profit", fmt.Sprintf("%.4f %s", s.Profit(), quote)},
86 | {"Volume", fmt.Sprintf("%.4f %s", s.Volume, quote)},
87 | }
88 | table.AppendBulk(data)
89 | table.SetColumnAlignment([]int{tablewriter.ALIGN_LEFT, tablewriter.ALIGN_RIGHT})
90 | table.Render()
91 | return tableString.String()
92 | }
93 |
94 | type Status string
95 |
96 | const (
97 | StatusRunning Status = "running"
98 | StatusStopped Status = "stopped"
99 | StatusError Status = "error"
100 | )
101 |
102 | type Controller struct {
103 | mtx sync.Mutex
104 | ctx context.Context
105 | exchange service.Exchange
106 | storage storage.Storage
107 | orderFeed *Feed
108 | notifier service.Notifier
109 | Results map[string]*summary
110 | lastPrice map[string]float64
111 | tickerInterval time.Duration
112 | finish chan bool
113 | status Status
114 | }
115 |
116 | func NewController(ctx context.Context, exchange service.Exchange, storage storage.Storage,
117 | orderFeed *Feed) *Controller {
118 |
119 | return &Controller{
120 | ctx: ctx,
121 | storage: storage,
122 | exchange: exchange,
123 | orderFeed: orderFeed,
124 | lastPrice: make(map[string]float64),
125 | Results: make(map[string]*summary),
126 | tickerInterval: time.Second,
127 | finish: make(chan bool),
128 | }
129 | }
130 |
131 | func (c *Controller) SetNotifier(notifier service.Notifier) {
132 | c.notifier = notifier
133 | }
134 |
135 | func (c *Controller) OnCandle(candle model.Candle) {
136 | c.lastPrice[candle.Pair] = candle.Close
137 | }
138 |
139 | func (c *Controller) calculateProfit(o *model.Order) (value, percent float64, err error) {
140 | // get filled orders before the current order
141 | orders, err := c.storage.Orders(
142 | storage.WithUpdateAtBeforeOrEqual(o.UpdatedAt),
143 | storage.WithStatus(model.OrderStatusTypeFilled),
144 | storage.WithPair(o.Pair),
145 | )
146 | if err != nil {
147 | return 0, 0, err
148 | }
149 |
150 | quantity := 0.0
151 | avgPrice := 0.0
152 |
153 | for _, order := range orders {
154 | // skip current order
155 | if o.ID == order.ID {
156 | continue
157 | }
158 |
159 | // calculate avg price
160 | if order.Side == model.SideTypeBuy {
161 | price := order.Price
162 | if order.Type == model.OrderTypeStopLoss || order.Type == model.OrderTypeStopLossLimit {
163 | price = *order.Stop
164 | }
165 | avgPrice = (order.Quantity*price + avgPrice*quantity) / (order.Quantity + quantity)
166 | quantity += order.Quantity
167 | } else {
168 | quantity = math.Max(quantity-order.Quantity, 0)
169 | }
170 | }
171 |
172 | if quantity == 0 {
173 | return 0, 0, nil
174 | }
175 |
176 | cost := o.Quantity * avgPrice
177 | price := o.Price
178 | if o.Type == model.OrderTypeStopLoss || o.Type == model.OrderTypeStopLossLimit {
179 | price = *o.Stop
180 | }
181 | profitValue := o.Quantity*price - cost
182 | return profitValue, profitValue / cost, nil
183 | }
184 |
185 | func (c *Controller) notify(message string) {
186 | log.Info(message)
187 | if c.notifier != nil {
188 | c.notifier.Notify(message)
189 | }
190 | }
191 |
192 | func (c *Controller) notifyError(err error) {
193 | log.Error(err)
194 | if c.notifier != nil {
195 | c.notifier.OnError(err)
196 | }
197 | }
198 |
199 | func (c *Controller) processTrade(order *model.Order) {
200 | if order.Status != model.OrderStatusTypeFilled {
201 | return
202 | }
203 |
204 | // initializer results map if needed
205 | if _, ok := c.Results[order.Pair]; !ok {
206 | c.Results[order.Pair] = &summary{Pair: order.Pair}
207 | }
208 |
209 | // register order volume
210 | c.Results[order.Pair].Volume += order.Price * order.Quantity
211 |
212 | // calculate profit only to sell orders
213 | if order.Side != model.SideTypeSell {
214 | return
215 | }
216 |
217 | profitValue, profit, err := c.calculateProfit(order)
218 | if err != nil {
219 | c.notifyError(err)
220 | return
221 | }
222 |
223 | order.Profit = profit
224 | if profitValue >= 0 {
225 | c.Results[order.Pair].Win = append(c.Results[order.Pair].Win, profitValue)
226 | } else {
227 | c.Results[order.Pair].Lose = append(c.Results[order.Pair].Lose, profitValue)
228 | }
229 |
230 | _, quote := exchange.SplitAssetQuote(order.Pair)
231 | c.notify(fmt.Sprintf("[PROFIT] %f %s (%f %%)\n`%s`", profitValue, quote, profit*100, c.Results[order.Pair].String()))
232 | }
233 |
234 | func (c *Controller) updateOrders() {
235 | c.mtx.Lock()
236 | defer c.mtx.Unlock()
237 |
238 | // pending orders
239 | orders, err := c.storage.Orders(storage.WithStatusIn(
240 | model.OrderStatusTypeNew,
241 | model.OrderStatusTypePartiallyFilled,
242 | model.OrderStatusTypePendingCancel,
243 | ))
244 | if err != nil {
245 | c.notifyError(err)
246 | c.mtx.Unlock()
247 | return
248 | }
249 |
250 | // For each pending order, check for updates
251 | var updatedOrders []model.Order
252 | for _, order := range orders {
253 | excOrder, err := c.exchange.Order(order.Pair, order.ExchangeID)
254 | if err != nil {
255 | log.WithField("id", order.ExchangeID).Error("orderControler/get: ", err)
256 | continue
257 | }
258 |
259 | // no status change
260 | if excOrder.Status == order.Status {
261 | continue
262 | }
263 |
264 | excOrder.ID = order.ID
265 | err = c.storage.UpdateOrder(&excOrder)
266 | if err != nil {
267 | c.notifyError(err)
268 | continue
269 | }
270 |
271 | log.Infof("[ORDER %s] %s", excOrder.Status, excOrder)
272 | updatedOrders = append(updatedOrders, excOrder)
273 | }
274 |
275 | for _, processOrder := range updatedOrders {
276 | c.processTrade(&processOrder)
277 | c.orderFeed.Publish(processOrder, false)
278 | }
279 | }
280 |
281 | func (c *Controller) Status() Status {
282 | return c.status
283 | }
284 |
285 | func (c *Controller) Start() {
286 | if c.status != StatusRunning {
287 | c.status = StatusRunning
288 | go func() {
289 | ticker := time.NewTicker(c.tickerInterval)
290 | for {
291 | select {
292 | case <-ticker.C:
293 | c.updateOrders()
294 | case <-c.finish:
295 | ticker.Stop()
296 | return
297 | }
298 | }
299 | }()
300 | log.Info("Bot started.")
301 | }
302 | }
303 |
304 | func (c *Controller) Stop() {
305 | if c.status == StatusRunning {
306 | c.status = StatusStopped
307 | c.updateOrders()
308 | c.finish <- true
309 | log.Info("Bot stopped.")
310 | }
311 | }
312 |
313 | func (c *Controller) Account() (model.Account, error) {
314 | return c.exchange.Account()
315 | }
316 |
317 | func (c *Controller) Position(pair string) (asset, quote float64, err error) {
318 | return c.exchange.Position(pair)
319 | }
320 |
321 | func (c *Controller) LastQuote(pair string) (float64, error) {
322 | return c.exchange.LastQuote(c.ctx, pair)
323 | }
324 |
325 | func (c *Controller) PositionValue(pair string) (float64, error) {
326 | asset, _, err := c.exchange.Position(pair)
327 | if err != nil {
328 | return 0, err
329 | }
330 | return asset * c.lastPrice[pair], nil
331 | }
332 |
333 | func (c *Controller) Order(pair string, id int64) (model.Order, error) {
334 | return c.exchange.Order(pair, id)
335 | }
336 |
337 | func (c *Controller) CreateOrderOCO(side model.SideType, pair string, size, price, stop,
338 | stopLimit float64) ([]model.Order, error) {
339 | c.mtx.Lock()
340 | defer c.mtx.Unlock()
341 |
342 | log.Infof("[ORDER] Creating OCO order for %s", pair)
343 | orders, err := c.exchange.CreateOrderOCO(side, pair, size, price, stop, stopLimit)
344 | if err != nil {
345 | c.notifyError(err)
346 | return nil, err
347 | }
348 |
349 | for i := range orders {
350 | err := c.storage.CreateOrder(&orders[i])
351 | if err != nil {
352 | c.notifyError(err)
353 | return nil, err
354 | }
355 | go c.orderFeed.Publish(orders[i], true)
356 | }
357 |
358 | return orders, nil
359 | }
360 |
361 | func (c *Controller) CreateOrderLimit(side model.SideType, pair string, size, limit float64) (model.Order, error) {
362 | c.mtx.Lock()
363 | defer c.mtx.Unlock()
364 |
365 | log.Infof("[ORDER] Creating LIMIT %s order for %s", side, pair)
366 | order, err := c.exchange.CreateOrderLimit(side, pair, size, limit)
367 | if err != nil {
368 | c.notifyError(err)
369 | return model.Order{}, err
370 | }
371 |
372 | err = c.storage.CreateOrder(&order)
373 | if err != nil {
374 | c.notifyError(err)
375 | return model.Order{}, err
376 | }
377 | go c.orderFeed.Publish(order, true)
378 | log.Infof("[ORDER CREATED] %s", order)
379 | return order, nil
380 | }
381 |
382 | func (c *Controller) CreateOrderMarketQuote(side model.SideType, pair string, amount float64) (model.Order, error) {
383 | c.mtx.Lock()
384 | defer c.mtx.Unlock()
385 |
386 | log.Infof("[ORDER] Creating MARKET %s order for %s", side, pair)
387 | order, err := c.exchange.CreateOrderMarketQuote(side, pair, amount)
388 | if err != nil {
389 | c.notifyError(err)
390 | return model.Order{}, err
391 | }
392 |
393 | err = c.storage.CreateOrder(&order)
394 | if err != nil {
395 | c.notifyError(err)
396 | return model.Order{}, err
397 | }
398 |
399 | // calculate profit
400 | c.processTrade(&order)
401 | go c.orderFeed.Publish(order, true)
402 | log.Infof("[ORDER CREATED] %s", order)
403 | return order, err
404 | }
405 |
406 | func (c *Controller) CreateOrderMarket(side model.SideType, pair string, size float64) (model.Order, error) {
407 | c.mtx.Lock()
408 | defer c.mtx.Unlock()
409 |
410 | log.Infof("[ORDER] Creating MARKET %s order for %s", side, pair)
411 | order, err := c.exchange.CreateOrderMarket(side, pair, size)
412 | if err != nil {
413 | c.notifyError(err)
414 | return model.Order{}, err
415 | }
416 |
417 | err = c.storage.CreateOrder(&order)
418 | if err != nil {
419 | c.notifyError(err)
420 | return model.Order{}, err
421 | }
422 |
423 | // calculate profit
424 | c.processTrade(&order)
425 | go c.orderFeed.Publish(order, true)
426 | log.Infof("[ORDER CREATED] %s", order)
427 | return order, err
428 | }
429 |
430 | func (c *Controller) CreateOrderStop(pair string, size float64, limit float64) (model.Order, error) {
431 | c.mtx.Lock()
432 | defer c.mtx.Unlock()
433 |
434 | log.Infof("[ORDER] Creating STOP order for %s", pair)
435 | order, err := c.exchange.CreateOrderStop(pair, size, limit)
436 | if err != nil {
437 | c.notifyError(err)
438 | return model.Order{}, err
439 | }
440 |
441 | err = c.storage.CreateOrder(&order)
442 | if err != nil {
443 | c.notifyError(err)
444 | return model.Order{}, err
445 | }
446 | go c.orderFeed.Publish(order, true)
447 | log.Infof("[ORDER CREATED] %s", order)
448 | return order, nil
449 | }
450 |
451 | func (c *Controller) Cancel(order model.Order) error {
452 | c.mtx.Lock()
453 | defer c.mtx.Unlock()
454 |
455 | log.Infof("[ORDER] Cancelling order for %s", order.Pair)
456 | err := c.exchange.Cancel(order)
457 | if err != nil {
458 | return err
459 | }
460 |
461 | order.Status = model.OrderStatusTypePendingCancel
462 | err = c.storage.UpdateOrder(&order)
463 | if err != nil {
464 | c.notifyError(err)
465 | return err
466 | }
467 | log.Infof("[ORDER CANCELED] %s", order)
468 | return nil
469 | }
470 |
--------------------------------------------------------------------------------
/plot/chart.go:
--------------------------------------------------------------------------------
1 | package plot
2 |
3 | import (
4 | "bytes"
5 | "embed"
6 | "encoding/csv"
7 | "encoding/json"
8 | "fmt"
9 | "html/template"
10 | "net/http"
11 | "sort"
12 | "strings"
13 | "sync"
14 | "time"
15 |
16 | "github.com/rodrigo-brito/ninjabot/exchange"
17 | "github.com/rodrigo-brito/ninjabot/model"
18 | "github.com/rodrigo-brito/ninjabot/strategy"
19 |
20 | "github.com/StudioSol/set"
21 | "github.com/evanw/esbuild/pkg/api"
22 | log "github.com/sirupsen/logrus"
23 | )
24 |
25 | var (
26 | //go:embed assets
27 | staticFiles embed.FS
28 | )
29 |
30 | type Chart struct {
31 | sync.Mutex
32 | port int
33 | debug bool
34 | candles map[string][]Candle
35 | dataframe map[string]*model.Dataframe
36 | ordersByPair map[string]*set.LinkedHashSetINT64
37 | orderByID map[int64]model.Order
38 | indicators []Indicator
39 | paperWallet *exchange.PaperWallet
40 | scriptContent string
41 | indexHTML *template.Template
42 | strategy strategy.Strategy
43 | }
44 |
45 | type Candle struct {
46 | Time time.Time `json:"time"`
47 | Open float64 `json:"open"`
48 | Close float64 `json:"close"`
49 | High float64 `json:"high"`
50 | Low float64 `json:"low"`
51 | Volume float64 `json:"volume"`
52 | Orders []model.Order `json:"orders"`
53 | }
54 |
55 | type Shape struct {
56 | StartX time.Time `json:"x0"`
57 | EndX time.Time `json:"x1"`
58 | StartY float64 `json:"y0"`
59 | EndY float64 `json:"y1"`
60 | Color string `json:"color"`
61 | }
62 |
63 | type assetValue struct {
64 | Time time.Time `json:"time"`
65 | Value float64 `json:"value"`
66 | }
67 |
68 | type indicatorMetric struct {
69 | Name string `json:"name"`
70 | Time []time.Time `json:"time"`
71 | Values []float64 `json:"value"`
72 | Color string `json:"color"`
73 | Style string `json:"style"`
74 | }
75 |
76 | type plotIndicator struct {
77 | Name string `json:"name"`
78 | Overlay bool `json:"overlay"`
79 | Metrics []indicatorMetric `json:"metrics"`
80 | }
81 |
82 | type drawdown struct {
83 | Value string `json:"value"`
84 | Start time.Time `json:"start"`
85 | End time.Time `json:"end"`
86 | }
87 |
88 | type Indicator interface {
89 | Name() string
90 | Overlay() bool
91 | Metrics() []IndicatorMetric
92 | Load(dataframe *model.Dataframe)
93 | }
94 |
95 | type IndicatorMetric struct {
96 | Name string
97 | Color string
98 | Style string
99 | Values model.Series
100 | Time []time.Time
101 | }
102 |
103 | func (c *Chart) OnOrder(order model.Order) {
104 | c.Lock()
105 | defer c.Unlock()
106 |
107 | c.ordersByPair[order.Pair].Add(order.ID)
108 | c.orderByID[order.ID] = order
109 |
110 | }
111 |
112 | func (c *Chart) OnCandle(candle model.Candle) {
113 | c.Lock()
114 | defer c.Unlock()
115 |
116 | if candle.Complete && (len(c.candles[candle.Pair]) == 0 ||
117 | candle.Time.After(c.candles[candle.Pair][len(c.candles[candle.Pair])-1].Time)) {
118 |
119 | c.candles[candle.Pair] = append(c.candles[candle.Pair], Candle{
120 | Time: candle.Time,
121 | Open: candle.Open,
122 | Close: candle.Close,
123 | High: candle.High,
124 | Low: candle.Low,
125 | Volume: candle.Volume,
126 | Orders: make([]model.Order, 0),
127 | })
128 |
129 | if c.dataframe[candle.Pair] == nil {
130 | c.dataframe[candle.Pair] = &model.Dataframe{
131 | Pair: candle.Pair,
132 | Metadata: make(map[string]model.Series),
133 | }
134 | c.ordersByPair[candle.Pair] = set.NewLinkedHashSetINT64()
135 | }
136 |
137 | c.dataframe[candle.Pair].Close = append(c.dataframe[candle.Pair].Close, candle.Close)
138 | c.dataframe[candle.Pair].Open = append(c.dataframe[candle.Pair].Open, candle.Open)
139 | c.dataframe[candle.Pair].High = append(c.dataframe[candle.Pair].High, candle.High)
140 | c.dataframe[candle.Pair].Low = append(c.dataframe[candle.Pair].Low, candle.Low)
141 | c.dataframe[candle.Pair].Volume = append(c.dataframe[candle.Pair].Volume, candle.Volume)
142 | c.dataframe[candle.Pair].Time = append(c.dataframe[candle.Pair].Time, candle.Time)
143 | c.dataframe[candle.Pair].LastUpdate = candle.Time
144 | for k, v := range candle.Metadata {
145 | c.dataframe[candle.Pair].Metadata[k] = append(c.dataframe[candle.Pair].Metadata[k], v)
146 | }
147 | }
148 | }
149 |
150 | func (c *Chart) equityValuesByPair(pair string) (asset []assetValue, quote []assetValue) {
151 | assetValues := make([]assetValue, 0)
152 | equityValues := make([]assetValue, 0)
153 |
154 | if c.paperWallet != nil {
155 | asset, _ := exchange.SplitAssetQuote(pair)
156 | for _, value := range c.paperWallet.AssetValues(asset) {
157 | assetValues = append(assetValues, assetValue{
158 | Time: value.Time,
159 | Value: value.Value,
160 | })
161 | }
162 |
163 | for _, value := range c.paperWallet.EquityValues() {
164 | equityValues = append(equityValues, assetValue{
165 | Time: value.Time,
166 | Value: value.Value,
167 | })
168 | }
169 | }
170 |
171 | return assetValues, equityValues
172 | }
173 |
174 | func (c *Chart) indicatorsByPair(pair string) []plotIndicator {
175 | indicators := make([]plotIndicator, 0)
176 | for _, i := range c.indicators {
177 | i.Load(c.dataframe[pair])
178 | indicator := plotIndicator{
179 | Name: i.Name(),
180 | Overlay: i.Overlay(),
181 | Metrics: make([]indicatorMetric, 0),
182 | }
183 |
184 | for _, metric := range i.Metrics() {
185 | indicator.Metrics = append(indicator.Metrics, indicatorMetric{
186 | Name: metric.Name,
187 | Values: metric.Values,
188 | Time: metric.Time,
189 | Color: metric.Color,
190 | Style: metric.Style,
191 | })
192 | }
193 |
194 | indicators = append(indicators, indicator)
195 | }
196 |
197 | if c.strategy != nil {
198 | warmup := c.strategy.WarmupPeriod()
199 | strategyIndicators := c.strategy.Indicators(c.dataframe[pair])
200 | for _, i := range strategyIndicators {
201 | indicator := plotIndicator{
202 | Name: i.GroupName,
203 | Overlay: i.Overlay,
204 | Metrics: make([]indicatorMetric, 0),
205 | }
206 |
207 | for _, metric := range i.Metrics {
208 | if len(metric.Values) < warmup {
209 | continue
210 | }
211 |
212 | indicator.Metrics = append(indicator.Metrics, indicatorMetric{
213 | Time: i.Time[warmup:],
214 | Values: metric.Values[warmup:],
215 | Name: metric.Name,
216 | Color: metric.Color,
217 | Style: string(metric.Style),
218 | })
219 | }
220 | indicators = append(indicators, indicator)
221 | }
222 | }
223 |
224 | return indicators
225 | }
226 |
227 | func (c *Chart) candlesByPair(pair string) []Candle {
228 | candles := make([]Candle, len(c.candles[pair]))
229 | for i := range c.candles[pair] {
230 | candles[i] = c.candles[pair][i]
231 | for id := range c.ordersByPair[pair].Iter() {
232 | order := c.orderByID[id]
233 |
234 | if i < len(c.candles[pair])-1 &&
235 | (order.UpdatedAt.After(c.candles[pair][i].Time) &&
236 | order.UpdatedAt.Before(c.candles[pair][i+1].Time)) ||
237 | order.UpdatedAt.Equal(c.candles[pair][i].Time) {
238 | candles[i].Orders = append(candles[i].Orders, order)
239 | }
240 | }
241 | }
242 |
243 | return candles
244 | }
245 |
246 | func (c *Chart) shapesByPair(pair string) []Shape {
247 | shapes := make([]Shape, 0)
248 | for id := range c.ordersByPair[pair].Iter() {
249 | order := c.orderByID[id]
250 |
251 | if order.Type != model.OrderTypeStopLoss &&
252 | order.Type != model.OrderTypeLimitMaker {
253 | continue
254 | }
255 |
256 | shape := Shape{
257 | StartX: order.CreatedAt,
258 | EndX: order.UpdatedAt,
259 | StartY: order.RefPrice,
260 | EndY: order.Price,
261 | Color: "rgba(0, 255, 0, 0.3)",
262 | }
263 |
264 | if order.Type == model.OrderTypeStopLoss {
265 | shape.Color = "rgba(255, 0, 0, 0.3)"
266 | }
267 |
268 | shapes = append(shapes, shape)
269 | }
270 |
271 | return shapes
272 | }
273 |
274 | func (c *Chart) orderStringByPair(pair string) [][]string {
275 | orders := make([][]string, 0)
276 | for id := range c.ordersByPair[pair].Iter() {
277 | o := c.orderByID[id]
278 | orderString := fmt.Sprintf("%s,%s,%d,%s,%f,%f,%.2f,%s",
279 | o.Status, o.Side, o.ID, o.Type, o.Quantity, o.Price, o.Quantity*o.Price, o.CreatedAt)
280 | order := strings.Split(orderString, ",")
281 | orders = append(orders, order)
282 | }
283 | return orders
284 | }
285 |
286 | func (c *Chart) handleIndex(w http.ResponseWriter, r *http.Request) {
287 | var pairs = make([]string, 0, len(c.candles))
288 | for pair := range c.candles {
289 | pairs = append(pairs, pair)
290 | }
291 |
292 | sort.Strings(pairs)
293 | pair := r.URL.Query().Get("pair")
294 | if pair == "" && len(pairs) > 0 {
295 | http.Redirect(w, r, fmt.Sprintf("/?pair=%s", pairs[0]), http.StatusFound)
296 | return
297 | }
298 |
299 | w.Header().Add("Content-Type", "text/html")
300 | err := c.indexHTML.Execute(w, map[string]interface{}{
301 | "pair": pair,
302 | "pairs": pairs,
303 | })
304 | if err != nil {
305 | log.Error(err)
306 | }
307 | }
308 |
309 | func (c *Chart) handleData(w http.ResponseWriter, r *http.Request) {
310 | pair := r.URL.Query().Get("pair")
311 | if pair == "" {
312 | w.WriteHeader(http.StatusNotFound)
313 | return
314 | }
315 |
316 | w.Header().Set("Content-type", "text/json")
317 |
318 | var maxDrawdown *drawdown
319 | if c.paperWallet != nil {
320 | value, start, end := c.paperWallet.MaxDrawdown()
321 | maxDrawdown = &drawdown{
322 | Start: start,
323 | End: end,
324 | Value: fmt.Sprintf("%.1f", value*100),
325 | }
326 | }
327 |
328 | asset, quote := exchange.SplitAssetQuote(pair)
329 | assetValues, equityValues := c.equityValuesByPair(pair)
330 | err := json.NewEncoder(w).Encode(map[string]interface{}{
331 | "candles": c.candlesByPair(pair),
332 | "indicators": c.indicatorsByPair(pair),
333 | "shapes": c.shapesByPair(pair),
334 | "asset_values": assetValues,
335 | "equity_values": equityValues,
336 | "quote": quote,
337 | "asset": asset,
338 | "max_drawdown": maxDrawdown,
339 | })
340 | if err != nil {
341 | log.Error(err)
342 | }
343 | }
344 |
345 | func (c *Chart) handleTradingHistoryData(w http.ResponseWriter, r *http.Request) {
346 | pair := r.URL.Query().Get("pair")
347 | if pair == "" {
348 | w.WriteHeader(http.StatusNotFound)
349 | return
350 | }
351 |
352 | w.Header().Set("Content-type", "text/csv")
353 | w.Header().Set("Content-Disposition", "attachment;filename=history_"+pair+".csv")
354 | w.Header().Set("Transfer-Encoding", "chunked")
355 |
356 | orders := c.orderStringByPair(pair)
357 |
358 | buffer := bytes.NewBuffer(nil)
359 | csvWriter := csv.NewWriter(buffer)
360 | err := csvWriter.Write([]string{"status", "side", "id", "type", "quantity", "price", "total", "created_at"})
361 | if err != nil {
362 | log.Errorf("failed writing header file: %s", err.Error())
363 | w.WriteHeader(http.StatusBadRequest)
364 | return
365 | }
366 |
367 | err = csvWriter.WriteAll(orders)
368 | if err != nil {
369 | log.Errorf("failed writing data: %s", err.Error())
370 | w.WriteHeader(http.StatusBadRequest)
371 | return
372 | }
373 | csvWriter.Flush()
374 |
375 | w.WriteHeader(http.StatusOK)
376 | _, err = w.Write(buffer.Bytes())
377 | if err != nil {
378 | log.Errorf("failed writing response: %s", err.Error())
379 | w.WriteHeader(http.StatusBadRequest)
380 | return
381 | }
382 | }
383 |
384 | func (c *Chart) Start() error {
385 | http.Handle(
386 | "/assets/",
387 | http.FileServer(http.FS(staticFiles)),
388 | )
389 |
390 | http.HandleFunc("/assets/chart.js", func(w http.ResponseWriter, req *http.Request) {
391 | w.Header().Set("Content-type", "application/javascript")
392 | fmt.Fprint(w, c.scriptContent)
393 | })
394 |
395 | http.HandleFunc("/history", c.handleTradingHistoryData)
396 | http.HandleFunc("/data", c.handleData)
397 | http.HandleFunc("/", c.handleIndex)
398 |
399 | fmt.Printf("Chart available at http://localhost:%d\n", c.port)
400 | return http.ListenAndServe(fmt.Sprintf(":%d", c.port), nil)
401 | }
402 |
403 | type Option func(*Chart)
404 |
405 | func WithPort(port int) Option {
406 | return func(chart *Chart) {
407 | chart.port = port
408 | }
409 | }
410 |
411 | func WithStrategyIndicators(strategy strategy.Strategy) Option {
412 | return func(chart *Chart) {
413 | chart.strategy = strategy
414 | }
415 | }
416 |
417 | func WithPaperWallet(paperWallet *exchange.PaperWallet) Option {
418 | return func(chart *Chart) {
419 | chart.paperWallet = paperWallet
420 | }
421 | }
422 |
423 | // WithDebug starts chart without compress
424 | func WithDebug() Option {
425 | return func(chart *Chart) {
426 | chart.debug = true
427 | }
428 | }
429 |
430 | func WithCustomIndicators(indicators ...Indicator) Option {
431 | return func(chart *Chart) {
432 | chart.indicators = indicators
433 | }
434 | }
435 |
436 | func NewChart(options ...Option) (*Chart, error) {
437 | chart := &Chart{
438 | port: 8080,
439 | candles: make(map[string][]Candle),
440 | dataframe: make(map[string]*model.Dataframe),
441 | ordersByPair: make(map[string]*set.LinkedHashSetINT64),
442 | orderByID: make(map[int64]model.Order),
443 | }
444 |
445 | for _, option := range options {
446 | option(chart)
447 | }
448 |
449 | chartJS, err := staticFiles.ReadFile("assets/chart.js")
450 | if err != nil {
451 | return nil, err
452 | }
453 |
454 | chart.indexHTML, err = template.ParseFS(staticFiles, "assets/chart.html")
455 | if err != nil {
456 | return nil, err
457 | }
458 |
459 | transpileChartJS := api.Transform(string(chartJS), api.TransformOptions{
460 | Loader: api.LoaderJS,
461 | Target: api.ES2015,
462 | MinifySyntax: !chart.debug,
463 | MinifyIdentifiers: !chart.debug,
464 | MinifyWhitespace: !chart.debug,
465 | })
466 |
467 | if len(transpileChartJS.Errors) > 0 {
468 | return nil, fmt.Errorf("chart script faild with: %v", transpileChartJS.Errors)
469 | }
470 |
471 | chart.scriptContent = string(transpileChartJS.Code)
472 |
473 | return chart, nil
474 | }
475 |
--------------------------------------------------------------------------------
/exchange/paperwallet_test.go:
--------------------------------------------------------------------------------
1 | package exchange
2 |
3 | import (
4 | "context"
5 | "testing"
6 | "time"
7 |
8 | "github.com/rodrigo-brito/ninjabot/model"
9 | "github.com/stretchr/testify/assert"
10 | "github.com/stretchr/testify/require"
11 | )
12 |
13 | func TestPaperWallet_OrderLimit(t *testing.T) {
14 | t.Run("normal order", func(t *testing.T) {
15 | wallet := NewPaperWallet(context.Background(), "USDT", WithPaperAsset("USDT", 100))
16 | order, err := wallet.CreateOrderLimit(model.SideTypeBuy, "BTCUSDT", 1, 100)
17 | require.NoError(t, err)
18 |
19 | // create order and lock values
20 | require.Len(t, wallet.orders, 1)
21 | require.Equal(t, 1.0, order.Quantity)
22 | require.Equal(t, 100.0, order.Price)
23 | require.Equal(t, 0.0, wallet.assets["USDT"].Free)
24 | require.Equal(t, 100.0, wallet.assets["USDT"].Lock)
25 |
26 | // a new candle should execute order and unlock values
27 | wallet.OnCandle(model.Candle{Pair: "BTCUSDT", Close: 100})
28 | require.Equal(t, model.OrderStatusTypeFilled, wallet.orders[0].Status)
29 | require.Equal(t, 0.0, wallet.assets["USDT"].Free)
30 | require.Equal(t, 0.0, wallet.assets["USDT"].Lock)
31 | require.Equal(t, 1.0, wallet.assets["BTC"].Free)
32 | require.Equal(t, 0.0, wallet.assets["BTC"].Lock)
33 | require.Equal(t, 100.0, wallet.avgPrice["BTCUSDT"])
34 |
35 | // try to buy again without funds
36 | order, err = wallet.CreateOrderLimit(model.SideTypeBuy, "BTCUSDT", 1, 100)
37 | require.Empty(t, order)
38 | require.Equal(t, &OrderError{
39 | Err: ErrInsufficientFunds,
40 | Pair: "USDT",
41 | Quantity: 100}, err)
42 |
43 | // try to sell and profit 100 USDT
44 | order, err = wallet.CreateOrderLimit(model.SideTypeSell, "BTCUSDT", 1, 200)
45 | require.NoError(t, err)
46 | require.Len(t, wallet.orders, 2)
47 | require.Equal(t, 1.0, order.Quantity)
48 | require.Equal(t, 200.0, order.Price)
49 | require.Equal(t, 0.0, wallet.assets["USDT"].Free)
50 | require.Equal(t, 0.0, wallet.assets["USDT"].Lock)
51 | require.Equal(t, 0.0, wallet.assets["BTC"].Free)
52 | require.Equal(t, 1.0, wallet.assets["BTC"].Lock)
53 |
54 | // a new candle should execute order and unlock values
55 | wallet.OnCandle(model.Candle{Pair: "BTCUSDT", Close: 200, High: 200})
56 | require.Equal(t, model.OrderStatusTypeFilled, wallet.orders[1].Status)
57 | require.Equal(t, 200.0, wallet.assets["USDT"].Free)
58 | require.Equal(t, 0.0, wallet.assets["USDT"].Lock)
59 | require.Equal(t, 0.0, wallet.assets["BTC"].Free)
60 | require.Equal(t, 0.0, wallet.assets["BTC"].Lock)
61 | })
62 |
63 | t.Run("multiple pending orders", func(t *testing.T) {
64 | wallet := NewPaperWallet(context.Background(), "USDT", WithPaperAsset("USDT", 100))
65 | wallet.lastCandle["BTCUSDT"] = model.Candle{Close: 10}
66 |
67 | order, err := wallet.CreateOrderLimit(model.SideTypeBuy, "BTCUSDT", 1, 10)
68 | require.NoError(t, err)
69 | require.NotEmpty(t, order)
70 |
71 | require.Equal(t, 90.0, wallet.assets["USDT"].Free)
72 | require.Equal(t, 10.0, wallet.assets["USDT"].Lock)
73 |
74 | order, err = wallet.CreateOrderLimit(model.SideTypeBuy, "BTCUSDT", 1, 20)
75 | require.NoError(t, err)
76 | require.NotEmpty(t, order)
77 |
78 | require.Equal(t, 70.0, wallet.assets["USDT"].Free)
79 | require.Equal(t, 30.0, wallet.assets["USDT"].Lock)
80 |
81 | order, err = wallet.CreateOrderLimit(model.SideTypeBuy, "BTCUSDT", 1, 50)
82 | require.NoError(t, err)
83 | require.NotEmpty(t, order)
84 |
85 | require.Equal(t, 20.0, wallet.assets["USDT"].Free)
86 | require.Equal(t, 80.0, wallet.assets["USDT"].Lock)
87 |
88 | // should execute two orders and keep one pending
89 | wallet.OnCandle(model.Candle{Pair: "BTCUSDT", Close: 15})
90 | require.Equal(t, 20.0, wallet.assets["USDT"].Free)
91 | require.Equal(t, 10.0, wallet.assets["USDT"].Lock)
92 | require.Equal(t, 0.0, wallet.assets["BTC"].Lock)
93 | require.Equal(t, 2.0, wallet.assets["BTC"].Free)
94 | require.Equal(t, 35.0, wallet.avgPrice["BTCUSDT"])
95 | require.Equal(t, model.OrderStatusTypeNew, wallet.orders[0].Status)
96 | require.Equal(t, model.OrderStatusTypeFilled, wallet.orders[1].Status)
97 | require.Equal(t, model.OrderStatusTypeFilled, wallet.orders[2].Status)
98 |
99 | // sell all bitcoin position
100 | order, err = wallet.CreateOrderLimit(model.SideTypeSell, "BTCUSDT", 2, 40)
101 | require.NoError(t, err)
102 | require.NotEmpty(t, order)
103 |
104 | require.Equal(t, 20.0, wallet.assets["USDT"].Free)
105 | require.Equal(t, 10.0, wallet.assets["USDT"].Lock)
106 | require.Equal(t, 0.0, wallet.assets["BTC"].Free)
107 | require.Equal(t, 2.0, wallet.assets["BTC"].Lock)
108 |
109 | wallet.OnCandle(model.Candle{Pair: "BTCUSDT", Close: 50, High: 50})
110 | require.Equal(t, 0.0, wallet.assets["BTC"].Free)
111 | require.Equal(t, 0.0, wallet.assets["BTC"].Lock)
112 | require.Equal(t, 100.0, wallet.assets["USDT"].Free)
113 | require.Equal(t, 10.0, wallet.assets["USDT"].Lock)
114 |
115 | // execute old buy position
116 | wallet.OnCandle(model.Candle{Pair: "BTCUSDT", Close: 9, High: 9})
117 | require.Equal(t, 1.0, wallet.assets["BTC"].Free)
118 | require.Equal(t, 0.0, wallet.assets["BTC"].Lock)
119 | require.Equal(t, 100.0, wallet.assets["USDT"].Free)
120 | require.Equal(t, 0.0, wallet.assets["USDT"].Lock)
121 | require.Equal(t, 10.0, wallet.avgPrice["BTCUSDT"])
122 | })
123 | }
124 |
125 | func TestPaperWallet_OrderMarket(t *testing.T) {
126 | wallet := NewPaperWallet(context.Background(), "USDT", WithPaperAsset("USDT", 100))
127 | wallet.OnCandle(model.Candle{Pair: "BTCUSDT", Close: 50})
128 | order, err := wallet.CreateOrderMarket(model.SideTypeBuy, "BTCUSDT", 1)
129 | require.NoError(t, err)
130 |
131 | // create buy order
132 | require.Len(t, wallet.orders, 1)
133 | assert.Equal(t, model.OrderStatusTypeFilled, order.Status)
134 | assert.Equal(t, 1.0, order.Quantity)
135 | assert.Equal(t, 50.0, order.Price)
136 | assert.Equal(t, 50.0, wallet.assets["USDT"].Free)
137 | assert.Equal(t, 0.0, wallet.assets["USDT"].Lock)
138 | assert.Equal(t, 1.0, wallet.assets["BTC"].Free)
139 | assert.Equal(t, 0.0, wallet.assets["BTC"].Lock)
140 | assert.Equal(t, 50.0, wallet.avgPrice["BTCUSDT"])
141 |
142 | // insufficient funds
143 | order, err = wallet.CreateOrderMarket(model.SideTypeBuy, "BTCUSDT", 100)
144 | require.Equal(t, &OrderError{
145 | Err: ErrInsufficientFunds,
146 | Pair: "BTCUSDT",
147 | Quantity: 100}, err)
148 | require.Empty(t, order)
149 |
150 | // sell
151 | wallet.OnCandle(model.Candle{Pair: "BTCUSDT", Close: 100})
152 | order, err = wallet.CreateOrderMarket(model.SideTypeSell, "BTCUSDT", 1)
153 | require.NoError(t, err)
154 | assert.Equal(t, 1.0, order.Quantity)
155 | assert.Equal(t, 100.0, order.Price)
156 | assert.Equal(t, 150.0, wallet.assets["USDT"].Free)
157 | assert.Equal(t, 0.0, wallet.assets["USDT"].Lock)
158 | assert.Equal(t, 0.0, wallet.assets["BTC"].Free)
159 | assert.Equal(t, 0.0, wallet.assets["BTC"].Lock)
160 | assert.Equal(t, 50.0, wallet.avgPrice["BTCUSDT"])
161 | }
162 |
163 | func TestPaperWallet_OrderOCO(t *testing.T) {
164 | wallet := NewPaperWallet(context.Background(), "USDT", WithPaperAsset("USDT", 50))
165 | wallet.OnCandle(model.Candle{Pair: "BTCUSDT", Close: 50})
166 | _, err := wallet.CreateOrderMarket(model.SideTypeBuy, "BTCUSDT", 1)
167 | require.NoError(t, err)
168 |
169 | orders, err := wallet.CreateOrderOCO(model.SideTypeSell, "BTCUSDT", 1, 100, 40, 39)
170 | require.NoError(t, err)
171 |
172 | // create buy order
173 | require.Len(t, wallet.orders, 3)
174 | assert.Equal(t, model.OrderStatusTypeNew, orders[0].Status)
175 | assert.Equal(t, model.OrderStatusTypeNew, orders[1].Status)
176 | assert.Equal(t, 1.0, orders[0].Quantity)
177 | assert.Equal(t, 1.0, orders[1].Quantity)
178 |
179 | assert.Equal(t, 0.0, wallet.assets["USDT"].Free)
180 | assert.Equal(t, 0.0, wallet.assets["USDT"].Lock)
181 | assert.Equal(t, 0.0, wallet.assets["BTC"].Free)
182 | assert.Equal(t, 1.0, wallet.assets["BTC"].Lock)
183 |
184 | // insufficient funds
185 | orders, err = wallet.CreateOrderOCO(model.SideTypeSell, "BTCUSDT", 1, 100, 40, 39)
186 | require.Equal(t, &OrderError{
187 | Err: ErrInsufficientFunds,
188 | Pair: "BTC",
189 | Quantity: 1}, err)
190 | require.Nil(t, orders)
191 |
192 | // execute stop and cancel target
193 | wallet.OnCandle(model.Candle{Pair: "BTCUSDT", Close: 30})
194 | assert.Equal(t, 40.0, wallet.assets["USDT"].Free)
195 | assert.Equal(t, 0.0, wallet.assets["USDT"].Lock)
196 | assert.Equal(t, 0.0, wallet.assets["BTC"].Free)
197 | assert.Equal(t, 0.0, wallet.assets["BTC"].Lock)
198 | assert.Equal(t, wallet.orders[1].Status, model.OrderStatusTypeCanceled)
199 | assert.Equal(t, wallet.orders[2].Status, model.OrderStatusTypeFilled)
200 | }
201 |
202 | func TestPaperWallet_Order(t *testing.T) {
203 | wallet := NewPaperWallet(context.Background(), "USDT", WithPaperAsset("USDT", 100))
204 | expectOrder, err := wallet.CreateOrderMarket(model.SideTypeBuy, "BTCUSDT", 1)
205 | require.NoError(t, err)
206 | require.Equal(t, int64(1), expectOrder.ExchangeID)
207 |
208 | order, err := wallet.Order("BTCUSDT", expectOrder.ExchangeID)
209 | require.NoError(t, err)
210 | require.Equal(t, expectOrder, order)
211 | }
212 |
213 | func TestPaperWallet_MaxDrawndown(t *testing.T) {
214 | tt := []struct {
215 | name string
216 | values []AssetValue
217 | result float64
218 | start time.Time
219 | end time.Time
220 | }{
221 | {
222 | name: "down only",
223 | values: []AssetValue{
224 | {Time: time.Date(2019, time.January, 1, 0, 0, 0, 0, time.UTC), Value: 10},
225 | {Time: time.Date(2019, time.January, 2, 0, 0, 0, 0, time.UTC), Value: 5},
226 | },
227 | result: -0.5,
228 | start: time.Date(2019, time.January, 1, 0, 0, 0, 0, time.UTC),
229 | end: time.Date(2019, time.January, 2, 0, 0, 0, 0, time.UTC),
230 | },
231 | {
232 | name: "up and down",
233 | values: []AssetValue{
234 | {Time: time.Date(2019, time.January, 1, 0, 0, 0, 0, time.UTC), Value: 1},
235 | {Time: time.Date(2019, time.January, 2, 0, 0, 0, 0, time.UTC), Value: 10},
236 | {Time: time.Date(2019, time.January, 3, 0, 0, 0, 0, time.UTC), Value: 5},
237 | },
238 | result: -0.5,
239 | start: time.Date(2019, time.January, 2, 0, 0, 0, 0, time.UTC),
240 | end: time.Date(2019, time.January, 3, 0, 0, 0, 0, time.UTC),
241 | },
242 | {
243 | name: "down and up",
244 | values: []AssetValue{
245 | {Time: time.Date(2019, time.January, 1, 0, 0, 0, 0, time.UTC), Value: 4},
246 | {Time: time.Date(2019, time.January, 2, 0, 0, 0, 0, time.UTC), Value: 5},
247 | {Time: time.Date(2019, time.January, 3, 0, 0, 0, 0, time.UTC), Value: 4},
248 | {Time: time.Date(2019, time.January, 4, 0, 0, 0, 0, time.UTC), Value: 3},
249 | {Time: time.Date(2019, time.January, 5, 0, 0, 0, 0, time.UTC), Value: 4},
250 | {Time: time.Date(2019, time.January, 6, 0, 0, 0, 0, time.UTC), Value: 5},
251 | {Time: time.Date(2019, time.January, 7, 0, 0, 0, 0, time.UTC), Value: 6},
252 | {Time: time.Date(2019, time.January, 8, 0, 0, 0, 0, time.UTC), Value: 7},
253 | {Time: time.Date(2019, time.January, 9, 0, 0, 0, 0, time.UTC), Value: 6},
254 | },
255 | result: -0.4,
256 | start: time.Date(2019, time.January, 2, 0, 0, 0, 0, time.UTC),
257 | end: time.Date(2019, time.January, 4, 0, 0, 0, 0, time.UTC),
258 | },
259 | {
260 | name: "two drawn downs",
261 | values: []AssetValue{
262 | {Time: time.Date(2019, time.January, 1, 0, 0, 0, 0, time.UTC), Value: 1},
263 | {Time: time.Date(2019, time.January, 2, 0, 0, 0, 0, time.UTC), Value: 5},
264 | {Time: time.Date(2019, time.January, 3, 0, 0, 0, 0, time.UTC), Value: 4},
265 | {Time: time.Date(2019, time.January, 4, 0, 0, 0, 0, time.UTC), Value: 7},
266 | {Time: time.Date(2019, time.January, 5, 0, 0, 0, 0, time.UTC), Value: 8},
267 | {Time: time.Date(2019, time.January, 6, 0, 0, 0, 0, time.UTC), Value: 4},
268 | {Time: time.Date(2019, time.January, 7, 0, 0, 0, 0, time.UTC), Value: 5},
269 | {Time: time.Date(2019, time.January, 8, 0, 0, 0, 0, time.UTC), Value: 2},
270 | {Time: time.Date(2019, time.January, 9, 0, 0, 0, 0, time.UTC), Value: 3},
271 | },
272 | result: -0.75,
273 | start: time.Date(2019, time.January, 5, 0, 0, 0, 0, time.UTC),
274 | end: time.Date(2019, time.January, 8, 0, 0, 0, 0, time.UTC),
275 | },
276 | }
277 |
278 | for _, tc := range tt {
279 | t.Run(tc.name, func(t *testing.T) {
280 | wallet := PaperWallet{
281 | equityValues: tc.values,
282 | }
283 |
284 | max, start, end := wallet.MaxDrawdown()
285 | assert.Equal(t, tc.result, max)
286 | assert.Equal(t, tc.start, start)
287 | assert.Equal(t, tc.end, end)
288 | })
289 | }
290 | }
291 |
292 | func TestPaperWallet_AssetsInfo(t *testing.T) {
293 | wallet := PaperWallet{}
294 | info := wallet.AssetsInfo("BTCUSDT")
295 | require.Equal(t, info.QuotePrecision, 8)
296 | require.Equal(t, info.BaseAsset, "BTC")
297 | require.Equal(t, info.QuoteAsset, "USDT")
298 | }
299 |
300 | func TestPaperWallet_CreateOrderStop(t *testing.T) {
301 | t.Run("success", func(t *testing.T) {
302 | wallet := NewPaperWallet(context.Background(), "USDT", WithPaperAsset("USDT", 100))
303 | wallet.OnCandle(model.Candle{Pair: "BTCUSDT", Close: 100})
304 | _, err := wallet.CreateOrderMarket(model.SideTypeBuy, "BTCUSDT", 1)
305 | require.NoError(t, err)
306 |
307 | order, err := wallet.CreateOrderStop("BTCUSDT", 1, 50)
308 | require.NoError(t, err)
309 |
310 | // create order and lock values
311 | require.Len(t, wallet.orders, 2)
312 | require.Equal(t, 1.0, order.Quantity)
313 | require.Equal(t, 50.0, order.Price)
314 | require.Equal(t, 50.0, *order.Stop)
315 | require.Equal(t, 0.0, wallet.assets["BTC"].Free)
316 | require.Equal(t, 1.0, wallet.assets["BTC"].Lock)
317 |
318 | // a new candle should execute order and unlock values
319 | wallet.OnCandle(model.Candle{Pair: "BTCUSDT", Close: 40})
320 | require.Equal(t, model.OrderStatusTypeFilled, wallet.orders[1].Status)
321 | require.Equal(t, 50.0, wallet.assets["USDT"].Free)
322 | require.Equal(t, 0.0, wallet.assets["USDT"].Lock)
323 | require.Equal(t, 0.0, wallet.assets["BTC"].Free)
324 | require.Equal(t, 0.0, wallet.assets["BTC"].Lock)
325 | require.Equal(t, 100.0, wallet.avgPrice["BTCUSDT"])
326 | })
327 | }
328 |
--------------------------------------------------------------------------------