diff --git a/config/sentinel.yaml b/config/sentinel.yaml new file mode 100644 index 0000000000..5e1aa66b36 --- /dev/null +++ b/config/sentinel.yaml @@ -0,0 +1,19 @@ +sessions: + max: + exchange: &exchange max + envVarPrefix: max + +persistence: + json: + directory: var/data + redis: + host: 127.0.0.1 + port: 6379 + db: 0 + +exchangeStrategies: +- on: *exchange + sentinel: + symbol: BTCUSDT + interval: 1m + scoreThreshold: 0.6 diff --git a/go.mod b/go.mod index 05c860737a..c286fb0791 100644 --- a/go.mod +++ b/go.mod @@ -125,6 +125,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/narumiruna/go-iforest v0.2.2 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.1 // indirect diff --git a/go.sum b/go.sum index c7356ef8b6..51afd5fffe 100644 --- a/go.sum +++ b/go.sum @@ -446,6 +446,8 @@ github.com/muesli/kmeans v0.3.0/go.mod h1:eNyybq0tX9/iBEP6EMU4Y7dpmGK0uEhODdZpnG github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/narumiruna/go-iforest v0.2.2 h1:48GGRVLSlgtV3vGr+eedXODn5RT3WvYroqpMNEoQvkk= +github.com/narumiruna/go-iforest v0.2.2/go.mod h1:2pumoiqKf0Lr+KvLECMC8uNrbRkxtSvUwMJC/6AW7DM= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= diff --git a/pkg/cmd/strategy/builtin.go b/pkg/cmd/strategy/builtin.go index 8c061ecc31..4fbdfa6379 100644 --- a/pkg/cmd/strategy/builtin.go +++ b/pkg/cmd/strategy/builtin.go @@ -40,6 +40,7 @@ import ( _ "github.com/c9s/bbgo/pkg/strategy/rsmaker" _ "github.com/c9s/bbgo/pkg/strategy/schedule" _ "github.com/c9s/bbgo/pkg/strategy/scmaker" + _ "github.com/c9s/bbgo/pkg/strategy/sentinel" _ "github.com/c9s/bbgo/pkg/strategy/supertrend" _ "github.com/c9s/bbgo/pkg/strategy/support" _ "github.com/c9s/bbgo/pkg/strategy/swing" diff --git a/pkg/strategy/sentinel/math.go b/pkg/strategy/sentinel/math.go new file mode 100644 index 0000000000..6b920ec049 --- /dev/null +++ b/pkg/strategy/sentinel/math.go @@ -0,0 +1,30 @@ +package sentinel + +import "math" + +func mean(floats []float64) float64 { + var sum float64 + for _, f := range floats { + sum += f + } + return sum / float64(len(floats)) +} + +func calculateMovingMeanAndStdDev(samples []float64, length int) (float64, float64) { + if len(samples) < length { + return 0, 0 + } + + var sum, mean, variance float64 + for _, sample := range samples[len(samples)-length:] { + sum += sample + } + mean = sum / float64(length) + + for _, sample := range samples[len(samples)-length:] { + variance += (sample - mean) * (sample - mean) + } + variance /= float64(length) + + return mean, math.Sqrt(variance) +} diff --git a/pkg/strategy/sentinel/strategy.go b/pkg/strategy/sentinel/strategy.go new file mode 100644 index 0000000000..16380441bb --- /dev/null +++ b/pkg/strategy/sentinel/strategy.go @@ -0,0 +1,158 @@ +package sentinel + +import ( + "context" + "time" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/types" + "github.com/narumiruna/go-iforest/pkg/iforest" + log "github.com/sirupsen/logrus" +) + +const ID = "sentinel" + +func init() { + bbgo.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + Symbol string `json:"symbol"` + Interval types.Interval `json:"interval"` + ScoreThreshold float64 `json:"scoreThreshold"` + KLineLimit int `json:"klineLimit"` + Window int `json:"window"` + + IsolationForest *iforest.IsolationForest `json:"isolationForest"` + NotificationInterval time.Duration `json:"notificationInterval"` + + lastNotifyTime time.Time +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) Defaults() error { + if s.ScoreThreshold == 0 { + s.ScoreThreshold = 0.6 + } + + if s.KLineLimit == 0 { + s.KLineLimit = 1440 + } + + if s.Window == 0 { + s.Window = 60 + } + + if s.NotificationInterval == 0 { + s.NotificationInterval = 10 * time.Minute + } + + return nil +} + +func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) +} + +func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, session *bbgo.ExchangeSession) error { + session.MarketDataStream.OnKLine(types.KLineWith(s.Symbol, s.Interval, func(kline types.KLine) { + if !s.isMarketAvailable(session, s.Symbol) { + return + } + + klines, err := s.queryKLines(ctx, session) + if err != nil { + log.Errorf("Unable to query klines: %v", err) + return + } + + volumes := s.extractVolumes(klines) + samples := s.generateSamples(volumes) + + if s.shouldSkipIsolationForest(volumes, samples) { + s.logSkipIsolationForest(samples, volumes, kline) + return + } + + s.fitIsolationForest(samples) + scores := s.IsolationForest.Score(samples) + s.handleIsolationForestScore(scores, kline) + })) + return nil +} + +func (s *Strategy) isMarketAvailable(session *bbgo.ExchangeSession, symbol string) bool { + _, ok := session.Market(symbol) + return ok +} + +func (s *Strategy) queryKLines(ctx context.Context, session *bbgo.ExchangeSession) ([]types.KLine, error) { + endTime := time.Now() + options := types.KLineQueryOptions{ + Limit: s.KLineLimit, + EndTime: &endTime, + } + return session.Exchange.QueryKLines(ctx, s.Symbol, s.Interval, options) +} + +func (s *Strategy) extractVolumes(klines []types.KLine) []float64 { + volumes := make([]float64, 0, len(klines)) + for _, kline := range klines { + volumes = append(volumes, kline.Volume.Float64()) + } + return volumes +} + +func (s *Strategy) generateSamples(volumes []float64) [][]float64 { + samples := make([][]float64, 0, len(volumes)) + for i := range volumes { + if i < s.Window { + continue + } + mean, stddev := calculateMovingMeanAndStdDev(volumes[:i], s.Window) + samples = append(samples, []float64{mean, stddev}) + } + return samples +} + +func (s *Strategy) shouldSkipIsolationForest(volumes []float64, samples [][]float64) bool { + volumeMean := mean(volumes) + lastMovingMean := samples[len(samples)-1][0] + return lastMovingMean < volumeMean +} + +func (s *Strategy) logSkipIsolationForest(samples [][]float64, volumes []float64, kline types.KLine) { + log.Infof("Skipping isolation forest calculation for symbol: %s, last moving mean: %f, average volume: %f, kline: %s", s.Symbol, samples[len(samples)-1][0], mean(volumes), kline.String()) +} + +func (s *Strategy) fitIsolationForest(samples [][]float64) { + s.IsolationForest = iforest.New() + s.IsolationForest.Fit(samples) + log.Infof("Isolation forest fitted with %d samples and %d/%d trees", len(samples), len(s.IsolationForest.Trees), s.IsolationForest.NumTrees) +} + +func (s *Strategy) handleIsolationForestScore(scores []float64, kline types.KLine) { + lastScore := scores[len(scores)-1] + log.Warnf("Symbol: %s, isolation forest score: %f, threshold: %f, kline: %s", s.Symbol, lastScore, s.ScoreThreshold, kline.String()) + if lastScore > s.ScoreThreshold { + s.notifyIsolationForestScore(lastScore) + } +} + +func (s *Strategy) notifyIsolationForestScore(score float64) { + now := time.Now() + if now.Sub(s.lastNotifyTime) < s.NotificationInterval { + log.Infof("Skipping notification for symbol: %s, score: %f, due to short interval", s.Symbol, score) + return + } + s.lastNotifyTime = now + + if channel, ok := bbgo.Notification.RouteSymbol(s.Symbol); ok { + bbgo.NotifyTo(channel, "symbol: %s, isolation forest score: %f", s.Symbol, score) + } else { + bbgo.Notify("symbol: %s isolation forest score: %f", s.Symbol, score) + } +}