diff --git a/config/balancecheck.yaml b/config/balancecheck.yaml new file mode 100644 index 000000000..ad52271d4 --- /dev/null +++ b/config/balancecheck.yaml @@ -0,0 +1,9 @@ +--- +exchangeStrategies: + - on: max + balancecheck: + exceptedBalances: + BTC: 1.0 + USDT: 1000 + balanceCheckTorlerance: 20% + cronExpression: "@every 1s" diff --git a/pkg/cmd/strategy/builtin.go b/pkg/cmd/strategy/builtin.go index d868e926a..043f53aef 100644 --- a/pkg/cmd/strategy/builtin.go +++ b/pkg/cmd/strategy/builtin.go @@ -5,6 +5,7 @@ import ( _ "github.com/c9s/bbgo/pkg/strategy/atrpin" _ "github.com/c9s/bbgo/pkg/strategy/audacitymaker" _ "github.com/c9s/bbgo/pkg/strategy/autoborrow" + _ "github.com/c9s/bbgo/pkg/strategy/balancecheck" _ "github.com/c9s/bbgo/pkg/strategy/bollgrid" _ "github.com/c9s/bbgo/pkg/strategy/bollmaker" _ "github.com/c9s/bbgo/pkg/strategy/convert" diff --git a/pkg/risk/riskcontrol/balance_check.go b/pkg/risk/riskcontrol/balance_check.go new file mode 100644 index 000000000..554b85f4c --- /dev/null +++ b/pkg/risk/riskcontrol/balance_check.go @@ -0,0 +1,49 @@ +package riskcontrol + +import ( + "fmt" + + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" + log "github.com/sirupsen/logrus" +) + +type BalanceCheck struct { + ExpectedBalances map[string]fixedpoint.Value `json:"exceptedBalances"` + BalanceCheckTorlerance fixedpoint.Value `json:"balanceCheckTorlerance"` +} + +func (c *BalanceCheck) Validate() error { + if len(c.ExpectedBalances) == 0 { + return fmt.Errorf("expectedBalances is empty") + } + + if c.BalanceCheckTorlerance.IsZero() { + return fmt.Errorf("balanceCheckTorlerance is zero") + } + + for _, v := range c.ExpectedBalances { + if v.IsZero() { + return fmt.Errorf("expected balance is zero") + } + } + return nil +} + +func (c *BalanceCheck) Check(balances types.BalanceMap) error { + for currency, exceptedBalance := range c.ExpectedBalances { + b, ok := balances[currency] + if !ok { + return fmt.Errorf("balance of %s not found", currency) + } + + // | (actual - expected) / expected | <= torlerance + balanceErr := exceptedBalance.Sub(b.Available).Div(exceptedBalance).Abs() + log.Infof("[BalanceCheck] %s, expected: %s, actual: %s, error: %.2f%%", currency, exceptedBalance, b.Available, balanceErr.Float64()*100) + + if balanceErr.Compare(c.BalanceCheckTorlerance) >= 0 { + return fmt.Errorf("balance of %s is not matched, expected: %s, actual: %s, error: %.2f%%", currency, exceptedBalance, b.Available, balanceErr.Float64()*100) + } + } + return nil +} diff --git a/pkg/risk/riskcontrol/balance_check_test.go b/pkg/risk/riskcontrol/balance_check_test.go new file mode 100644 index 000000000..1215d4773 --- /dev/null +++ b/pkg/risk/riskcontrol/balance_check_test.go @@ -0,0 +1,55 @@ +package riskcontrol + +import ( + "testing" + + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" + "github.com/stretchr/testify/assert" +) + +func Test_BalanceCheck(t *testing.T) { + cases := []struct { + Currency string + ExpectedBalance fixedpoint.Value + BalanceCheckTorlerance fixedpoint.Value + ActualBalance fixedpoint.Value + wantErr bool + }{ + { + Currency: "USDT", + ExpectedBalance: fixedpoint.NewFromFloat(1_000), + BalanceCheckTorlerance: fixedpoint.NewFromFloat(0.05), + ActualBalance: fixedpoint.NewFromFloat(1_049), + wantErr: false, + }, + { + Currency: "USDT", + ExpectedBalance: fixedpoint.NewFromFloat(1_000), + BalanceCheckTorlerance: fixedpoint.NewFromFloat(0.05), + ActualBalance: fixedpoint.NewFromFloat(1_050), + wantErr: true, + }, + } + + for _, tc := range cases { + expectedBalances := map[string]fixedpoint.Value{ + tc.Currency: tc.ExpectedBalance, + } + bc := &BalanceCheck{ + ExpectedBalances: expectedBalances, + BalanceCheckTorlerance: tc.BalanceCheckTorlerance, + } + balances := types.BalanceMap{ + tc.Currency: types.Balance{ + Currency: tc.Currency, + Available: tc.ActualBalance, + }, + } + if tc.wantErr { + assert.Error(t, bc.Check(balances)) + } else { + assert.NoError(t, bc.Check(balances)) + } + } +} diff --git a/pkg/strategy/balancecheck/strategy.go b/pkg/strategy/balancecheck/strategy.go new file mode 100644 index 000000000..b7e5f8eef --- /dev/null +++ b/pkg/strategy/balancecheck/strategy.go @@ -0,0 +1,69 @@ +package balancecheck + +import ( + "context" + + "github.com/robfig/cron/v3" + "github.com/sirupsen/logrus" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/risk/riskcontrol" +) + +const ID = "balancecheck" + +var log = logrus.WithField("strategy", ID) + +func init() { + bbgo.RegisterStrategy(ID, &Strategy{}) +} + +type Strategy struct { + Environment *bbgo.Environment + + *riskcontrol.BalanceCheck + CronExpression string `json:"cronExpression"` + + cron *cron.Cron +} + +func (s *Strategy) Defaults() error { + return nil +} + +func (s *Strategy) Initialize() error { + s.BalanceCheck = &riskcontrol.BalanceCheck{ + ExpectedBalances: s.ExpectedBalances, + BalanceCheckTorlerance: s.BalanceCheckTorlerance, + } + return nil +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return ID +} + +func (s *Strategy) Validate() error { + if err := s.BalanceCheck.Validate(); err != nil { + return err + } + return nil +} + +func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) {} + +func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo.ExchangeSession) error { + s.cron = cron.New() + s.cron.AddFunc(s.CronExpression, func() { + balances := session.GetAccount().Balances() + if err := s.Check(balances); err != nil { + log.WithError(err).Error("balance check failed") + } + }) + s.cron.Start() + return nil +}