├── 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 | --------------------------------------------------------------------------------