Skip to content

Commit

Permalink
Add sentinel strategy
Browse files Browse the repository at this point in the history
  • Loading branch information
narumiruna committed Dec 13, 2024
1 parent 45a4804 commit ed0be44
Show file tree
Hide file tree
Showing 6 changed files with 211 additions and 0 deletions.
19 changes: 19 additions & 0 deletions config/sentinel.yaml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
1 change: 1 addition & 0 deletions pkg/cmd/strategy/builtin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
30 changes: 30 additions & 0 deletions pkg/strategy/sentinel/math.go
Original file line number Diff line number Diff line change
@@ -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)
}
158 changes: 158 additions & 0 deletions pkg/strategy/sentinel/strategy.go
Original file line number Diff line number Diff line change
@@ -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)
}
}

0 comments on commit ed0be44

Please sign in to comment.