From a05733bb8153b81d32790fde20a009499ffd391f Mon Sep 17 00:00:00 2001 From: narumi Date: Sat, 7 Dec 2024 03:26:35 +0800 Subject: [PATCH] Add sentinel strategy --- config/sentinel.yaml | 19 ++++ pkg/cmd/strategy/builtin.go | 1 + pkg/strategy/sentinel/math.go | 30 ++++++ pkg/strategy/sentinel/strategy.go | 158 ++++++++++++++++++++++++++++++ 4 files changed, 208 insertions(+) create mode 100644 config/sentinel.yaml create mode 100644 pkg/strategy/sentinel/math.go create mode 100644 pkg/strategy/sentinel/strategy.go 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/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) + } +}