diff --git a/pkg/cmd/builtin.go b/pkg/cmd/builtin.go index ced181fd2..abaa25a60 100644 --- a/pkg/cmd/builtin.go +++ b/pkg/cmd/builtin.go @@ -16,6 +16,7 @@ import ( _ "github.com/c9s/bbgo/pkg/strategy/swing" _ "github.com/c9s/bbgo/pkg/strategy/techsignal" _ "github.com/c9s/bbgo/pkg/strategy/xbalance" + _ "github.com/c9s/bbgo/pkg/strategy/xnav" _ "github.com/c9s/bbgo/pkg/strategy/xmaker" _ "github.com/c9s/bbgo/pkg/strategy/xpuremaker" ) diff --git a/pkg/strategy/xnav/csv.go b/pkg/strategy/xnav/csv.go new file mode 100644 index 000000000..df42dff3b --- /dev/null +++ b/pkg/strategy/xnav/csv.go @@ -0,0 +1,2 @@ +package xnav + diff --git a/pkg/strategy/xnav/strategy.go b/pkg/strategy/xnav/strategy.go new file mode 100644 index 000000000..625ae9607 --- /dev/null +++ b/pkg/strategy/xnav/strategy.go @@ -0,0 +1,191 @@ +package xnav + +import ( + "context" + "github.com/c9s/bbgo/pkg/fixedpoint" + "sync" + "time" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/service" + "github.com/c9s/bbgo/pkg/types" + "github.com/c9s/bbgo/pkg/util" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "github.com/slack-go/slack" +) + +const ID = "xnav" + +const stateKey = "state-v1" + +func init() { + bbgo.RegisterStrategy(ID, &Strategy{}) +} + +type State struct { + Since int64 `json:"since"` +} + +func (s *State) IsOver24Hours() bool { + return util.Over24Hours(time.Unix(s.Since, 0)) +} + +func (s *State) PlainText() string { + return util.Render(`{{ .Asset }} transfer stats: +daily number of transfers: {{ .DailyNumberOfTransfers }} +daily amount of transfers {{ .DailyAmountOfTransfers.Float64 }}`, s) +} + +func (s *State) SlackAttachment() slack.Attachment { + return slack.Attachment{ + // Pretext: "", + // Text: text, + Fields: []slack.AttachmentField{}, + Footer: util.Render("Since {{ . }}", time.Unix(s.Since, 0).Format(time.RFC822)), + } +} + +func (s *State) Reset() { + var beginningOfTheDay = util.BeginningOfTheDay(time.Now().Local()) + *s = State{ + Since: beginningOfTheDay.Unix(), + } +} + +type Strategy struct { + Notifiability *bbgo.Notifiability + *bbgo.Graceful + *bbgo.Persistence + + Interval types.Duration `json:"interval"` + ReportOnStart bool `json:"reportOnStart"` + IgnoreDusts bool `json:"ignoreDusts"` + state *State +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) CrossSubscribe(sessions map[string]*bbgo.ExchangeSession) {} + +func (s *Strategy) recordNetAssetValue(ctx context.Context, sessions map[string]*bbgo.ExchangeSession) { + totalAssets := types.AssetMap{} + totalBalances := types.BalanceMap{} + lastPrices := map[string]float64{} + for _, session := range sessions { + balances := session.Account.Balances() + if err := session.UpdatePrices(ctx); err != nil { + log.WithError(err).Error("price update failed") + return + } + + for _, b := range balances { + if tb, ok := totalBalances[b.Currency]; ok { + tb.Available += b.Available + tb.Locked += b.Locked + totalBalances[b.Currency] = tb + } else { + totalBalances[b.Currency] = b + } + } + + prices := session.LastPrices() + for m, p := range prices { + lastPrices[m] = p + } + } + + assets := totalBalances.Assets(lastPrices) + for currency, asset := range assets { + if s.IgnoreDusts && asset.InUSD < fixedpoint.NewFromFloat(10.0) { + continue + } + + totalAssets[currency] = asset + } + + s.Notifiability.Notify(totalAssets) + + if s.state != nil { + if s.state.IsOver24Hours() { + s.state.Reset() + } + + s.SaveState() + } +} + +func (s *Strategy) SaveState() { + if err := s.Persistence.Save(s.state, ID, stateKey); err != nil { + log.WithError(err).Errorf("%s can not save state: %+v", ID, s.state) + } else { + log.Infof("%s state is saved: %+v", ID, s.state) + // s.Notifiability.Notify("%s %s state is saved", ID, s.Asset, s.state) + } +} + +func (s *Strategy) newDefaultState() *State { + return &State{} +} + +func (s *Strategy) LoadState() error { + var state State + if err := s.Persistence.Load(&state, ID, stateKey); err != nil { + if err != service.ErrPersistenceNotExists { + return err + } + + s.state = s.newDefaultState() + s.state.Reset() + } else { + // we loaded it successfully + s.state = &state + + // update Asset name for legacy caches + // s.state.Asset = s.Asset + + log.Infof("%s state is restored: %+v", ID, s.state) + s.Notifiability.Notify("%s state is restored", ID, s.state) + } + + return nil +} + +func (s *Strategy) CrossRun(ctx context.Context, _ bbgo.OrderExecutionRouter, sessions map[string]*bbgo.ExchangeSession) error { + if s.Interval == 0 { + return errors.New("interval can not be zero") + } + + if err := s.LoadState(); err != nil { + return err + } + + s.Graceful.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + s.SaveState() + }) + + if s.ReportOnStart { + s.recordNetAssetValue(ctx, sessions) + } + + go func() { + ticker := time.NewTicker(util.MillisecondsJitter(s.Interval.Duration(), 1000)) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + + case <-ticker.C: + s.recordNetAssetValue(ctx, sessions) + } + } + }() + + return nil +} diff --git a/pkg/types/account.go b/pkg/types/account.go index d1bf90c76..19f813042 100644 --- a/pkg/types/account.go +++ b/pkg/types/account.go @@ -2,8 +2,12 @@ package types import ( "fmt" + "github.com/slack-go/slack" + "math" + "sort" "strings" "sync" + "time" "github.com/sirupsen/logrus" "github.com/spf13/viper" @@ -40,10 +44,70 @@ type Asset struct { Total fixedpoint.Value `json:"total"` InUSD fixedpoint.Value `json:"inUSD"` InBTC fixedpoint.Value `json:"inBTC"` + Time time.Time `json:"time"` } type AssetMap map[string]Asset +func (m AssetMap) PlainText() (o string) { + for _, a := range m { + o += fmt.Sprintf("%s: %f (≈ %s) (≈ %s)", + a.Currency, + a.Total.Float64(), + USD.FormatMoneyFloat64(a.InUSD.Float64()), + BTC.FormatMoneyFloat64(a.InBTC.Float64()), + ) + "\n" + } + + return o +} + +func (m AssetMap) Slice() (assets []Asset) { + for _, a := range m { + assets = append(assets, a) + } + return assets +} + +func (m AssetMap) SlackAttachment() slack.Attachment { + var fields []slack.AttachmentField + var totalBTC, totalUSD fixedpoint.Value + + var assets = m.Slice() + + // sort assets + sort.Slice(assets, func(i, j int) bool { + return assets[i].InUSD > assets[j].InUSD + }) + + for _, a := range assets { + totalUSD += a.InUSD + totalBTC += a.InBTC + } + + for _, a := range assets { + fields = append(fields, slack.AttachmentField{ + Title: a.Currency, + Value: fmt.Sprintf("%f (≈ %s) (≈ %s) (%.2f%%)", + a.Total.Float64(), + USD.FormatMoneyFloat64(a.InUSD.Float64()), + BTC.FormatMoneyFloat64(a.InBTC.Float64()), + math.Round(a.InUSD.Div(totalUSD).Float64() * 100.0), + ), + Short: false, + }) + } + + + return slack.Attachment{ + Title: fmt.Sprintf("Net Asset Value %s (≈ %s)", + USD.FormatMoneyFloat64(totalUSD.Float64()), + BTC.FormatMoneyFloat64(totalBTC.Float64()), + ), + Fields: fields, + } +} + type BalanceMap map[string]Balance func (m BalanceMap) String() string { @@ -66,6 +130,7 @@ func (m BalanceMap) Copy() (d BalanceMap) { func (m BalanceMap) Assets(prices map[string]float64) AssetMap { assets := make(AssetMap) + now := time.Now() for currency, b := range m { if b.Locked == 0 && b.Available == 0 { continue @@ -74,6 +139,7 @@ func (m BalanceMap) Assets(prices map[string]float64) AssetMap { asset := Asset{ Currency: currency, Total: b.Available + b.Locked, + Time: now, } btcusdt, hasBtcPrice := prices["BTCUSDT"] diff --git a/pkg/types/currencies.go b/pkg/types/currencies.go index ba8c65926..0fa97c7b3 100644 --- a/pkg/types/currencies.go +++ b/pkg/types/currencies.go @@ -3,7 +3,7 @@ package types import "github.com/leekchan/accounting" var USD = accounting.Accounting{Symbol: "$ ", Precision: 2} -var BTC = accounting.Accounting{Symbol: "BTC ", Precision: 2} +var BTC = accounting.Accounting{Symbol: "BTC ", Precision: 8} var BNB = accounting.Accounting{Symbol: "BNB ", Precision: 4} var FiatCurrencies = []string{"USDC", "USDT", "USD", "TWD", "EUR", "GBP", "BUSD"} diff --git a/pkg/util/time.go b/pkg/util/time.go index 5671a602d..4bc49c07e 100644 --- a/pkg/util/time.go +++ b/pkg/util/time.go @@ -15,3 +15,6 @@ func BeginningOfTheDay(t time.Time) time.Time { return time.Date(year, month, day, 0, 0, 0, 0, t.Location()) } +func Over24Hours(since time.Time) bool { + return time.Since(since) >= 24 * time.Hour +}