diff --git a/doc/development/migration.md b/doc/development/migration.md index 77cf0c11a..50070dc51 100644 --- a/doc/development/migration.md +++ b/doc/development/migration.md @@ -8,19 +8,22 @@ 2. Create migration files -```sh -rockhopper --config rockhopper_sqlite.yaml create --type sql add_pnl_column -rockhopper --config rockhopper_mysql.yaml create --type sql add_pnl_column -``` -or you can use the util script: +You can use the util script to generate the migration files: ``` bash utils/generate-new-migration.sh add_pnl_column ``` -Be sure to edit both sqlite3 and mysql migration files. ( [Sample](migrations/mysql/20210531234123_add_kline_taker_buy_columns.sql) ) +Or, you can generate the migration files separately: +```sh +rockhopper --config rockhopper_sqlite.yaml create --type sql add_pnl_column +rockhopper --config rockhopper_mysql.yaml create --type sql add_pnl_column +``` + + +Be sure to edit both sqlite3 and mysql migration files. ( [Sample](migrations/mysql/20210531234123_add_kline_taker_buy_columns.sql) ) To test the drivers, you have to update the rockhopper_mysql.yaml file to connect your database, then do: @@ -39,9 +42,9 @@ make migrations or ```shell - rockhopper compile --config rockhopper_mysql.yaml --output pkg/migrations/mysql - rockhopper compile --config rockhopper_sqlite.yaml --output pkg/migrations/sqlite3 - git add -v pkg/migrations && git commit -m "compile and update migration package" pkg/migrations || true +rockhopper compile --config rockhopper_mysql.yaml --output pkg/migrations/mysql +rockhopper compile --config rockhopper_sqlite.yaml --output pkg/migrations/sqlite3 +git add -v pkg/migrations && git commit -m "compile and update migration package" pkg/migrations || true ``` diff --git a/migrations/mysql/20220304153317_add_profit_table.sql b/migrations/mysql/20220304153317_add_profit_table.sql new file mode 100644 index 000000000..f74a21304 --- /dev/null +++ b/migrations/mysql/20220304153317_add_profit_table.sql @@ -0,0 +1,71 @@ +-- +up +CREATE TABLE `profits` +( + `gid` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + + `strategy` VARCHAR(32) NOT NULL, + `strategy_instance_id` VARCHAR(64) NOT NULL, + + `symbol` VARCHAR(8) NOT NULL, + + -- average_cost is the position average cost + `average_cost` DECIMAL(16, 8) UNSIGNED NOT NULL, + + -- profit is the pnl (profit and loss) + `profit` DECIMAL(16, 8) NOT NULL, + + -- net_profit is the pnl (profit and loss) + `net_profit` DECIMAL(16, 8) NOT NULL, + + -- profit_margin is the pnl (profit and loss) + `profit_margin` DECIMAL(16, 8) NOT NULL, + + -- net_profit_margin is the pnl (profit and loss) + `net_profit_margin` DECIMAL(16, 8) NOT NULL, + + `quote_currency` VARCHAR(10) NOT NULL, + + `base_currency` VARCHAR(10) NOT NULL, + + -- ------------------------------------------------------- + -- embedded trade data -- + -- ------------------------------------------------------- + `exchange` VARCHAR(24) NOT NULL DEFAULT '', + + `is_futures` BOOLEAN NOT NULL DEFAULT FALSE, + + `is_margin` BOOLEAN NOT NULL DEFAULT FALSE, + + `is_isolated` BOOLEAN NOT NULL DEFAULT FALSE, + + `trade_id` BIGINT UNSIGNED NOT NULL, + + -- side is the side of the trade that makes profit + `side` VARCHAR(4) NOT NULL DEFAULT '', + + `is_buyer` BOOLEAN NOT NULL DEFAULT FALSE, + + `is_maker` BOOLEAN NOT NULL DEFAULT FALSE, + + -- price is the price of the trade that makes profit + `price` DECIMAL(16, 8) UNSIGNED NOT NULL, + + -- quantity is the quantity of the trade that makes profit + `quantity` DECIMAL(16, 8) UNSIGNED NOT NULL, + + -- quote_quantity is the quote quantity of the trade that makes profit + `quote_quantity` DECIMAL(16, 8) UNSIGNED NOT NULL, + + `traded_at` DATETIME(3) NOT NULL, + + -- fee + `fee_in_usd` DECIMAL(16, 8) UNSIGNED, + `fee` DECIMAL(16, 8) UNSIGNED NOT NULL, + `fee_currency` VARCHAR(10) NOT NULL, + + PRIMARY KEY (`gid`), + UNIQUE KEY `trade_id` (`trade_id`) +); + +-- +down +DROP TABLE IF EXISTS `profits`; diff --git a/migrations/sqlite3/20220304153309_add_profit_table.sql b/migrations/sqlite3/20220304153309_add_profit_table.sql new file mode 100644 index 000000000..290d32539 --- /dev/null +++ b/migrations/sqlite3/20220304153309_add_profit_table.sql @@ -0,0 +1,68 @@ +-- +up +CREATE TABLE `profits` +( + `gid` INTEGER PRIMARY KEY AUTOINCREMENT, + + `strategy` VARCHAR(32) NOT NULL, + `strategy_instance_id` VARCHAR(64) NOT NULL, + + `symbol` VARCHAR(8) NOT NULL, + + -- average_cost is the position average cost + `average_cost` DECIMAL(16, 8) NOT NULL, + + -- profit is the pnl (profit and loss) + `profit` DECIMAL(16, 8) NOT NULL, + + -- net_profit is the pnl (profit and loss) + `net_profit` DECIMAL(16, 8) NOT NULL, + + -- profit_margin is the pnl (profit and loss) + `profit_margin` DECIMAL(16, 8) NOT NULL, + + -- net_profit_margin is the pnl (profit and loss) + `net_profit_margin` DECIMAL(16, 8) NOT NULL, + + `quote_currency` VARCHAR(10) NOT NULL, + + `base_currency` VARCHAR(10) NOT NULL, + + -- ------------------------------------------------------- + -- embedded trade data -- + -- ------------------------------------------------------- + `exchange` VARCHAR(24) NOT NULL DEFAULT '', + + `is_futures` BOOLEAN NOT NULL DEFAULT FALSE, + + `is_margin` BOOLEAN NOT NULL DEFAULT FALSE, + + `is_isolated` BOOLEAN NOT NULL DEFAULT FALSE, + + `trade_id` BIGINT NOT NULL, + + -- side is the side of the trade that makes profit + `side` VARCHAR(4) NOT NULL DEFAULT '', + + `is_buyer` BOOLEAN NOT NULL DEFAULT FALSE, + + `is_maker` BOOLEAN NOT NULL DEFAULT FALSE, + + -- price is the price of the trade that makes profit + `price` DECIMAL(16, 8) NOT NULL, + + -- quantity is the quantity of the trade that makes profit + `quantity` DECIMAL(16, 8) NOT NULL, + + -- trade_amount is the quote quantity of the trade that makes profit + `quote_quantity` DECIMAL(16, 8) NOT NULL, + + `traded_at` DATETIME(3) NOT NULL, + + -- fee + `fee_in_usd` DECIMAL(16, 8), + `fee` DECIMAL(16, 8) NOT NULL, + `fee_currency` VARCHAR(10) NOT NULL +); + +-- +down +DROP TABLE IF EXISTS `profits`; diff --git a/pkg/bbgo/environment.go b/pkg/bbgo/environment.go index 658d848d1..14c385b40 100644 --- a/pkg/bbgo/environment.go +++ b/pkg/bbgo/environment.go @@ -76,6 +76,7 @@ type Environment struct { DatabaseService *service.DatabaseService OrderService *service.OrderService TradeService *service.TradeService + ProfitService *service.ProfitService BacktestService *service.BacktestService RewardService *service.RewardService SyncService *service.SyncService @@ -170,6 +171,7 @@ func (environ *Environment) ConfigureDatabaseDriver(ctx context.Context, driver environ.TradeService = &service.TradeService{DB: db} environ.RewardService = &service.RewardService{DB: db} environ.AccountService = &service.AccountService{DB: db} + environ.ProfitService = &service.ProfitService{DB: db} environ.SyncService = &service.SyncService{ TradeService: environ.TradeService, @@ -569,6 +571,19 @@ func (environ *Environment) Sync(ctx context.Context, userConfig ...*Config) err return nil } +func (environ *Environment) RecordProfit(profit types.Profit) { + if environ.DatabaseService == nil { + return + } + if environ.ProfitService == nil { + return + } + + if err := environ.ProfitService.Insert(profit) ; err != nil { + log.WithError(err).Errorf("can not insert profit record: %+v", profit) + } +} + func (environ *Environment) SyncSession(ctx context.Context, session *ExchangeSession, defaultSymbols ...string) error { if environ.SyncService == nil { return nil @@ -584,7 +599,7 @@ func (environ *Environment) SyncSession(ctx context.Context, session *ExchangeSe } func (environ *Environment) syncSession(ctx context.Context, session *ExchangeSession, defaultSymbols ...string) error { - symbols, err := getSessionSymbols(session, defaultSymbols...) + symbols, err := session.getSessionSymbols(defaultSymbols...) if err != nil { return err } @@ -594,17 +609,6 @@ func (environ *Environment) syncSession(ctx context.Context, session *ExchangeSe return environ.SyncService.SyncSessionSymbols(ctx, session.Exchange, environ.syncStartTime, symbols...) } -func getSessionSymbols(session *ExchangeSession, defaultSymbols ...string) ([]string, error) { - if session.IsolatedMargin { - return []string{session.IsolatedMarginSymbol}, nil - } - - if len(defaultSymbols) > 0 { - return defaultSymbols, nil - } - - return session.FindPossibleSymbols() -} func (environ *Environment) ConfigureNotificationSystem(userConfig *Config) error { environ.Notifiability = Notifiability{ @@ -654,6 +658,10 @@ func (environ *Environment) ConfigureNotificationSystem(userConfig *Config) erro return nil } +// getAuthStoreID returns the authentication store id +// if telegram bot token is defined, the bot id will be used. +// if not, env var $USER will be used. +// if both are not defined, a default "default" will be used. func getAuthStoreID() string { telegramBotToken := viper.GetString("telegram-bot-token") if len(telegramBotToken) > 0 { @@ -924,3 +932,15 @@ And then enter your token `, token) } + +func (session *ExchangeSession) getSessionSymbols(defaultSymbols ...string) ([]string, error) { + if session.IsolatedMargin { + return []string{session.IsolatedMarginSymbol}, nil + } + + if len(defaultSymbols) > 0 { + return defaultSymbols, nil + } + + return session.FindPossibleSymbols() +} diff --git a/pkg/bbgo/injection.go b/pkg/bbgo/injection.go index 5d2338412..03ff9fd7f 100644 --- a/pkg/bbgo/injection.go +++ b/pkg/bbgo/injection.go @@ -3,6 +3,8 @@ package bbgo import ( "fmt" "reflect" + + "github.com/sirupsen/logrus" ) func isSymbolBasedStrategy(rs reflect.Value) (string, bool) { @@ -53,3 +55,79 @@ func injectField(rs reflect.Value, fieldName string, obj interface{}, pointerOnl return nil } + +// parseStructAndInject parses the struct fields and injects the objects into the corresponding fields by its type. +// if the given object is a reference of an object, the type of the target field MUST BE a pointer field. +// if the given object is a struct value, the type of the target field CAN BE a pointer field or a struct value field. +func parseStructAndInject(f interface{}, objects ...interface{}) error { + sv := reflect.ValueOf(f) + st := reflect.TypeOf(f) + + if st.Kind() != reflect.Ptr { + return fmt.Errorf("f needs to be a pointer of a struct, %s given", st) + } + + // solve the reference + st = st.Elem() + sv = sv.Elem() + + if st.Kind() != reflect.Struct { + return fmt.Errorf("f needs to be a struct, %s given", st) + } + + for i := 0; i < sv.NumField(); i++ { + fv := sv.Field(i) + ft := fv.Type() + + // skip unexported fields + if !st.Field(i).IsExported() { + continue + } + + switch k := fv.Kind(); k { + + case reflect.Ptr, reflect.Struct: + for oi := 0; oi < len(objects); oi++ { + obj := objects[oi] + if obj == nil { + continue + } + + ot := reflect.TypeOf(obj) + if ft.AssignableTo(ot) { + if !fv.CanSet() { + return fmt.Errorf("field %v of %s can not be set to %s, make sure it is an exported field", fv, sv.Type(), ot) + } + + if k == reflect.Ptr && ot.Kind() == reflect.Struct { + fv.Set(reflect.ValueOf(obj).Addr()) + } else { + fv.Set(reflect.ValueOf(obj)) + } + + } + } + + case reflect.Interface: + for oi := 0; oi < len(objects); oi++ { + obj := objects[oi] + if obj == nil { + continue + } + + objT := reflect.TypeOf(obj) + logrus.Debugln( + ft.PkgPath(), + ft.Name(), + objT, "implements", ft, "=", objT.Implements(ft), + ) + + if objT.Implements(ft) { + fv.Set(reflect.ValueOf(obj)) + } + } + } + } + + return nil +} diff --git a/pkg/bbgo/injection_test.go b/pkg/bbgo/injection_test.go index 1bb2fe6f8..dd6370320 100644 --- a/pkg/bbgo/injection_test.go +++ b/pkg/bbgo/injection_test.go @@ -3,10 +3,12 @@ package bbgo import ( "reflect" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/c9s/bbgo/pkg/service" + "github.com/c9s/bbgo/pkg/types" ) func Test_injectField(t *testing.T) { @@ -28,3 +30,76 @@ func Test_injectField(t *testing.T) { err := injectField(rv, "TradeService", ts, true) assert.NoError(t, err) } + +func Test_parseStructAndInject(t *testing.T) { + t.Run("skip nil", func(t *testing.T) { + ss := struct { + a int + Env *Environment + }{ + a: 1, + Env: nil, + } + err := parseStructAndInject(&ss, nil) + assert.NoError(t, err) + assert.Nil(t, ss.Env) + }) + t.Run("pointer", func(t *testing.T) { + ss := struct { + a int + Env *Environment + }{ + a: 1, + Env: nil, + } + err := parseStructAndInject(&ss, &Environment{}) + assert.NoError(t, err) + assert.NotNil(t, ss.Env) + }) + + t.Run("composition", func(t *testing.T) { + type TT struct { + *service.TradeService + } + ss := TT{} + err := parseStructAndInject(&ss, &service.TradeService{}) + assert.NoError(t, err) + assert.NotNil(t, ss.TradeService) + }) + + t.Run("struct", func(t *testing.T) { + ss := struct { + a int + Env Environment + }{ + a: 1, + } + err := parseStructAndInject(&ss, Environment{ + startTime: time.Now(), + }) + assert.NoError(t, err) + assert.NotEqual(t, time.Time{}, ss.Env.startTime) + }) + t.Run("interface/any", func(t *testing.T) { + ss := struct { + Any interface{} // anything + }{ + Any: nil, + } + err := parseStructAndInject(&ss, &Environment{ + startTime: time.Now(), + }) + assert.NoError(t, err) + assert.NotNil(t, ss.Any) + }) + t.Run("interface/stringer", func(t *testing.T) { + ss := struct { + Stringer types.Stringer // stringer interface + }{ + Stringer: nil, + } + err := parseStructAndInject(&ss, &types.Trade{}) + assert.NoError(t, err) + assert.NotNil(t, ss.Stringer) + }) +} diff --git a/pkg/bbgo/profitstats.go b/pkg/bbgo/profitstats.go index 0b46307a1..920078f66 100644 --- a/pkg/bbgo/profitstats.go +++ b/pkg/bbgo/profitstats.go @@ -1,343 +1,2 @@ package bbgo -import ( - "fmt" - "github.com/slack-go/slack" - "time" - - "github.com/c9s/bbgo/pkg/fixedpoint" - "github.com/c9s/bbgo/pkg/types" - "github.com/c9s/bbgo/pkg/util" -) - -// Profit struct stores the PnL information -type Profit struct { - Symbol string `json:"symbol"` - - // Profit is the profit of this trade made. negative profit means loss. - Profit fixedpoint.Value `json:"profit" db:"profit"` - - // NetProfit is (profit - trading fee) - NetProfit fixedpoint.Value `json:"netProfit" db:"net_profit"` - AverageCost fixedpoint.Value `json:"averageCost" db:"average_ost"` - - TradeAmount fixedpoint.Value `json:"tradeAmount" db:"trade_amount"` - - // ProfitMargin is a percentage of the profit and the capital amount - ProfitMargin fixedpoint.Value `json:"profitMargin" db:"profit_margin"` - - // NetProfitMargin is a percentage of the net profit and the capital amount - NetProfitMargin fixedpoint.Value `json:"netProfitMargin" db:"net_profit_margin"` - - QuoteCurrency string `json:"quoteCurrency" db:"quote_currency"` - BaseCurrency string `json:"baseCurrency" db:"base_currency"` - - // FeeInUSD is the summed fee of this profit, - // you will need to convert the trade fee into USD since the fee currencies can be different. - FeeInUSD fixedpoint.Value `json:"feeInUSD" db:"fee_in_usd"` - Time time.Time `json:"time" db:"time"` - Strategy string `json:"strategy" db:"strategy"` - StrategyInstanceID string `json:"strategyInstanceID" db:"strategy_instance_id"` -} - -func (p *Profit) SlackAttachment() slack.Attachment { - var color = pnlColor(p.Profit) - var title = fmt.Sprintf("%s PnL ", p.Symbol) - title += pnlEmojiMargin(p.Profit, p.ProfitMargin, defaultPnlLevelResolution) + " " - title += pnlSignString(p.Profit) + " " + p.QuoteCurrency - - var fields []slack.AttachmentField - - if !p.NetProfit.IsZero() { - fields = append(fields, slack.AttachmentField{ - Title: "Net Profit", - Value: pnlSignString(p.NetProfit) + " " + p.QuoteCurrency, - Short: true, - }) - } - - if !p.ProfitMargin.IsZero() { - fields = append(fields, slack.AttachmentField{ - Title: "Profit Margin", - Value: p.ProfitMargin.Percentage(), - Short: true, - }) - } - - if !p.NetProfitMargin.IsZero() { - fields = append(fields, slack.AttachmentField{ - Title: "Net Profit Margin", - Value: p.NetProfitMargin.Percentage(), - Short: true, - }) - } - - if !p.TradeAmount.IsZero() { - fields = append(fields, slack.AttachmentField{ - Title: "Trade Amount", - Value: p.TradeAmount.String() + " " + p.QuoteCurrency, - Short: true, - }) - } - - if !p.FeeInUSD.IsZero() { - fields = append(fields, slack.AttachmentField{ - Title: "Fee In USD", - Value: p.FeeInUSD.String() + " USD", - Short: true, - }) - } - - if len(p.Strategy) != 0 { - fields = append(fields, slack.AttachmentField{ - Title: "Strategy", - Value: p.Strategy, - Short: true, - }) - } - - return slack.Attachment{ - Color: color, - Title: title, - Fields: fields, - // Footer: "", - } -} - -func (p *Profit) PlainText() string { - var emoji string - if !p.ProfitMargin.IsZero() { - emoji = pnlEmojiMargin(p.Profit, p.ProfitMargin, defaultPnlLevelResolution) - } else { - emoji = pnlEmojiSimple(p.Profit) - } - - return fmt.Sprintf("%s trade profit %s %s %s (%s), net profit =~ %s %s (%s)", - p.Symbol, - emoji, - p.Profit.String(), p.QuoteCurrency, - p.ProfitMargin.Percentage(), - p.NetProfit.String(), p.QuoteCurrency, - p.NetProfitMargin.Percentage(), - ) -} - -var lossEmoji = "🔥" -var profitEmoji = "💰" -var defaultPnlLevelResolution = fixedpoint.NewFromFloat(0.001) - -func pnlColor(pnl fixedpoint.Value) string { - if pnl.Sign() > 0 { - return types.GreenColor - } - return types.RedColor -} - -func pnlSignString(pnl fixedpoint.Value) string { - if pnl.Sign() > 0 { - return "+" + pnl.String() - } - return pnl.String() -} - -func pnlEmojiSimple(pnl fixedpoint.Value) string { - if pnl.Sign() < 0 { - return lossEmoji - } - - if pnl.IsZero() { - return "" - } - - return profitEmoji -} - -func pnlEmojiMargin(pnl, margin, resolution fixedpoint.Value) (out string) { - if margin.IsZero() { - return pnlEmojiSimple(pnl) - } - - if pnl.Sign() < 0 { - out = lossEmoji - level := (margin.Neg()).Div(resolution).Int() - for i := 1; i < level; i++ { - out += lossEmoji - } - return out - } - - if pnl.IsZero() { - return out - } - - out = profitEmoji - level := margin.Div(resolution).Int() - for i := 1; i < level; i++ { - out += profitEmoji - } - return out -} - -type ProfitStats struct { - Symbol string `json:"symbol"` - QuoteCurrency string `json:"quoteCurrency"` - BaseCurrency string `json:"baseCurrency"` - - AccumulatedPnL fixedpoint.Value `json:"accumulatedPnL,omitempty"` - AccumulatedNetProfit fixedpoint.Value `json:"accumulatedNetProfit,omitempty"` - AccumulatedProfit fixedpoint.Value `json:"accumulatedProfit,omitempty"` - AccumulatedLoss fixedpoint.Value `json:"accumulatedLoss,omitempty"` - AccumulatedVolume fixedpoint.Value `json:"accumulatedVolume,omitempty"` - AccumulatedSince int64 `json:"accumulatedSince,omitempty"` - - TodayPnL fixedpoint.Value `json:"todayPnL,omitempty"` - TodayNetProfit fixedpoint.Value `json:"todayNetProfit,omitempty"` - TodayProfit fixedpoint.Value `json:"todayProfit,omitempty"` - TodayLoss fixedpoint.Value `json:"todayLoss,omitempty"` - TodaySince int64 `json:"todaySince,omitempty"` -} - -func (s *ProfitStats) Init(market types.Market) { - s.Symbol = market.Symbol - s.BaseCurrency = market.BaseCurrency - s.QuoteCurrency = market.QuoteCurrency - if s.AccumulatedSince == 0 { - s.AccumulatedSince = time.Now().Unix() - } -} - -func (s *ProfitStats) AddProfit(profit Profit) { - s.AccumulatedPnL = s.AccumulatedPnL.Add(profit.Profit) - s.AccumulatedNetProfit = s.AccumulatedNetProfit.Add(profit.NetProfit) - s.TodayPnL = s.TodayPnL.Add(profit.Profit) - s.TodayNetProfit = s.TodayNetProfit.Add(profit.NetProfit) - - if profit.Profit.Sign() < 0 { - s.AccumulatedLoss = s.AccumulatedLoss.Add(profit.Profit) - s.TodayLoss = s.TodayLoss.Add(profit.Profit) - } else if profit.Profit.Sign() > 0 { - s.AccumulatedProfit = s.AccumulatedLoss.Add(profit.Profit) - s.TodayProfit = s.TodayProfit.Add(profit.Profit) - } -} - -func (s *ProfitStats) AddTrade(trade types.Trade) { - if s.IsOver24Hours() { - s.ResetToday() - } - - s.AccumulatedVolume = s.AccumulatedVolume.Add(trade.Quantity) -} - -func (s *ProfitStats) IsOver24Hours() bool { - return time.Since(time.Unix(s.TodaySince, 0)) > 24*time.Hour -} - -func (s *ProfitStats) ResetToday() { - s.TodayPnL = fixedpoint.Zero - s.TodayNetProfit = fixedpoint.Zero - s.TodayProfit = fixedpoint.Zero - s.TodayLoss = fixedpoint.Zero - - var beginningOfTheDay = util.BeginningOfTheDay(time.Now().Local()) - s.TodaySince = beginningOfTheDay.Unix() -} - -func (s *ProfitStats) PlainText() string { - since := time.Unix(s.AccumulatedSince, 0).Local() - return fmt.Sprintf("%s Profit Today\n"+ - "Profit %s %s\n"+ - "Net profit %s %s\n"+ - "Trade Loss %s %s\n"+ - "Summary:\n"+ - "Accumulated Profit %s %s\n"+ - "Accumulated Net Profit %s %s\n"+ - "Accumulated Trade Loss %s %s\n"+ - "Since %s", - s.Symbol, - s.TodayPnL.String(), s.QuoteCurrency, - s.TodayNetProfit.String(), s.QuoteCurrency, - s.TodayLoss.String(), s.QuoteCurrency, - s.AccumulatedPnL.String(), s.QuoteCurrency, - s.AccumulatedNetProfit.String(), s.QuoteCurrency, - s.AccumulatedLoss.String(), s.QuoteCurrency, - since.Format(time.RFC822), - ) -} - -func (s *ProfitStats) SlackAttachment() slack.Attachment { - var color = pnlColor(s.AccumulatedPnL) - var title = fmt.Sprintf("%s Accumulated PnL %s %s", s.Symbol, pnlSignString(s.AccumulatedPnL), s.QuoteCurrency) - - since := time.Unix(s.AccumulatedSince, 0).Local() - title += " Since " + since.Format(time.RFC822) - - var fields []slack.AttachmentField - - if !s.TodayPnL.IsZero() { - fields = append(fields, slack.AttachmentField{ - Title: "P&L Today", - Value: pnlSignString(s.TodayPnL) + " " + s.QuoteCurrency, - Short: true, - }) - } - - if !s.TodayProfit.IsZero() { - fields = append(fields, slack.AttachmentField{ - Title: "Profit Today", - Value: pnlSignString(s.TodayProfit) + " " + s.QuoteCurrency, - Short: true, - }) - } - - if !s.TodayNetProfit.IsZero() { - fields = append(fields, slack.AttachmentField{ - Title: "Net Profit Today", - Value: pnlSignString(s.TodayNetProfit) + " " + s.QuoteCurrency, - Short: true, - }) - } - - if !s.TodayLoss.IsZero() { - fields = append(fields, slack.AttachmentField{ - Title: "Loss Today", - Value: pnlSignString(s.TodayLoss) + " " + s.QuoteCurrency, - Short: true, - }) - } - - if !s.AccumulatedPnL.IsZero() { - fields = append(fields, slack.AttachmentField{ - Title: "Accumulated P&L", - Value: pnlSignString(s.AccumulatedPnL) + " " + s.QuoteCurrency, - }) - } - - if !s.AccumulatedProfit.IsZero() { - fields = append(fields, slack.AttachmentField{ - Title: "Accumulated Profit", - Value: pnlSignString(s.AccumulatedProfit) + " " + s.QuoteCurrency, - }) - } - - if !s.AccumulatedNetProfit.IsZero() { - fields = append(fields, slack.AttachmentField{ - Title: "Accumulated Net Profit", - Value: pnlSignString(s.AccumulatedNetProfit) + " " + s.QuoteCurrency, - }) - } - - if !s.AccumulatedLoss.IsZero() { - fields = append(fields, slack.AttachmentField{ - Title: "Accumulated Loss", - Value: pnlSignString(s.AccumulatedLoss) + " " + s.QuoteCurrency, - }) - } - - return slack.Attachment{ - Color: color, - Title: title, - Fields: fields, - // Footer: "", - } -} diff --git a/pkg/bbgo/trader.go b/pkg/bbgo/trader.go index 8f8f6d60a..f5d1736de 100644 --- a/pkg/bbgo/trader.go +++ b/pkg/bbgo/trader.go @@ -219,7 +219,7 @@ func (trader *Trader) RunSingleExchangeStrategy(ctx context.Context, strategy Si return errors.New("strategy object is not a struct") } - if err := trader.injectCommonServices(rs); err != nil { + if err := trader.injectCommonServices(strategy) ; err != nil { return err } @@ -229,36 +229,30 @@ func (trader *Trader) RunSingleExchangeStrategy(ctx context.Context, strategy Si if symbol, ok := isSymbolBasedStrategy(rs); ok { log.Infof("found symbol based strategy from %s", rs.Type()) - if _, ok := hasField(rs, "Market"); ok { - if market, ok := session.Market(symbol); ok { - // let's make the market object passed by pointer - if err := injectField(rs, "Market", &market, false); err != nil { - return errors.Wrapf(err, "failed to inject Market on %T", strategy) - } - } + + market, ok := session.Market(symbol) + if !ok { + return fmt.Errorf("market of symbol %s not found", symbol) } - // StandardIndicatorSet - if _, ok := hasField(rs, "StandardIndicatorSet"); ok { - indicatorSet, ok := session.StandardIndicatorSet(symbol) - if !ok { - return fmt.Errorf("standardIndicatorSet of symbol %s not found", symbol) - } - - if err := injectField(rs, "StandardIndicatorSet", indicatorSet, true); err != nil { - return errors.Wrapf(err, "failed to inject StandardIndicatorSet on %T", strategy) - } + indicatorSet, ok := session.StandardIndicatorSet(symbol) + if !ok { + return fmt.Errorf("standardIndicatorSet of symbol %s not found", symbol) } - if _, ok := hasField(rs, "MarketDataStore"); ok { - store, ok := session.MarketDataStore(symbol) - if !ok { - return fmt.Errorf("marketDataStore of symbol %s not found", symbol) - } + store, ok := session.MarketDataStore(symbol) + if !ok { + return fmt.Errorf("marketDataStore of symbol %s not found", symbol) + } - if err := injectField(rs, "MarketDataStore", store, true); err != nil { - return errors.Wrapf(err, "failed to inject MarketDataStore on %T", strategy) - } + if err := parseStructAndInject(strategy, + market, + indicatorSet, + store, + session, + session.OrderExecutor, + ); err != nil { + return errors.Wrapf(err, "failed to inject object into %T", strategy) } } @@ -339,12 +333,11 @@ func (trader *Trader) Run(ctx context.Context) error { // get the struct element from the struct pointer rs = rs.Elem() - if rs.Kind() != reflect.Struct { continue } - if err := trader.injectCommonServices(rs); err != nil { + if err := trader.injectCommonServices(strategy) ; err != nil { return err } @@ -356,59 +349,33 @@ func (trader *Trader) Run(ctx context.Context) error { return trader.environment.Connect(ctx) } -func (trader *Trader) injectCommonServices(rs reflect.Value) error { - if err := injectField(rs, "Graceful", &trader.Graceful, true); err != nil { - return errors.Wrap(err, "failed to inject Graceful") - } - - if err := injectField(rs, "Logger", &trader.logger, false); err != nil { - return errors.Wrap(err, "failed to inject Logger") - } - - if err := injectField(rs, "Notifiability", &trader.environment.Notifiability, false); err != nil { - return errors.Wrap(err, "failed to inject Notifiability") - } - - if trader.environment.TradeService != nil { - if err := injectField(rs, "TradeService", trader.environment.TradeService, true); err != nil { - return errors.Wrap(err, "failed to inject TradeService") - } - } - - if trader.environment.AccountService != nil { - if err := injectField(rs, "AccountService", trader.environment.AccountService, true); err != nil { - return errors.Wrap(err, "failed to inject AccountService") - } - } - - if field, ok := hasField(rs, "Persistence"); ok { - if trader.environment.PersistenceServiceFacade == nil { - log.Warnf("strategy has Persistence field but persistence service is not defined") - } else { - if field.IsNil() { - field.Set(reflect.ValueOf(&Persistence{ - PersistenceSelector: &PersistenceSelector{ - StoreID: "default", - Type: "memory", - }, - Facade: trader.environment.PersistenceServiceFacade, - })) - } else { - elem := field.Elem() - if elem.Kind() != reflect.Struct { - return fmt.Errorf("field Persistence is not a struct element") - } - - if err := injectField(elem, "Facade", trader.environment.PersistenceServiceFacade, true); err != nil { - return errors.Wrap(err, "failed to inject Persistence") - } - } - } - } - - return nil +var defaultPersistenceSelector = &PersistenceSelector{ + StoreID: "default", + Type: "memory", } +func (trader *Trader) injectCommonServices(s interface{}) error { + persistenceFacade := trader.environment.PersistenceServiceFacade + persistence := &Persistence{ + PersistenceSelector: defaultPersistenceSelector, + Facade: persistenceFacade, + } + + return parseStructAndInject(s, + &trader.Graceful, + &trader.logger, + &trader.environment.Notifiability, + trader.environment.TradeService, + trader.environment.OrderService, + trader.environment.DatabaseService, + trader.environment.AccountService, + trader.environment, + persistence, + persistenceFacade, + ) +} + + // ReportPnL configure and set the PnLReporter with the given notifier func (trader *Trader) ReportPnL() *PnLReporterManager { return NewPnLReporter(&trader.environment.Notifiability) diff --git a/pkg/bbgo/trader_test.go b/pkg/bbgo/trader_test.go new file mode 100644 index 000000000..920078f66 --- /dev/null +++ b/pkg/bbgo/trader_test.go @@ -0,0 +1,2 @@ +package bbgo + diff --git a/pkg/exchange/binance/parse.go b/pkg/exchange/binance/parse.go index b2d922848..2f88a1db2 100644 --- a/pkg/exchange/binance/parse.go +++ b/pkg/exchange/binance/parse.go @@ -102,7 +102,6 @@ type ExecutionReportEvent struct { } func (e *ExecutionReportEvent) Order() (*types.Order, error) { - switch e.CurrentExecutionType { case "NEW", "CANCELED", "REJECTED", "EXPIRED": case "REPLACED": @@ -113,7 +112,6 @@ func (e *ExecutionReportEvent) Order() (*types.Order, error) { orderCreationTime := time.Unix(0, e.OrderCreationTime*int64(time.Millisecond)) return &types.Order{ - Exchange: types.ExchangeBinance, SubmitOrder: types.SubmitOrder{ Symbol: e.Symbol, ClientOrderID: e.ClientOrderID, @@ -123,10 +121,13 @@ func (e *ExecutionReportEvent) Order() (*types.Order, error) { Price: e.OrderPrice, TimeInForce: types.TimeInForce(e.TimeInForce), }, + Exchange: types.ExchangeBinance, + IsWorking: e.IsOnBook, OrderID: uint64(e.OrderID), Status: toGlobalOrderStatus(binance.OrderStatusType(e.CurrentOrderStatus)), ExecutedQuantity: e.CumulativeFilledQuantity, CreationTime: types.Time(orderCreationTime), + UpdateTime: types.Time(orderCreationTime), }, nil } diff --git a/pkg/exchange/max/convert.go b/pkg/exchange/max/convert.go index a973c49e6..a43a95723 100644 --- a/pkg/exchange/max/convert.go +++ b/pkg/exchange/max/convert.go @@ -316,6 +316,11 @@ func convertWebSocketOrderUpdate(u max.OrderUpdate) (*types.Order, error) { return nil, err } + timeInForce := types.TimeInForceGTC + if u.OrderType == max.OrderTypeIOCLimit { + timeInForce = types.TimeInForceIOC + } + return &types.Order{ SubmitOrder: types.SubmitOrder{ ClientOrderID: u.ClientOID, @@ -325,7 +330,7 @@ func convertWebSocketOrderUpdate(u max.OrderUpdate) (*types.Order, error) { Quantity: fixedpoint.MustNewFromString(u.Volume), Price: fixedpoint.MustNewFromString(u.Price), StopPrice: fixedpoint.MustNewFromString(u.StopPrice), - TimeInForce: "GTC", // MAX only supports GTC + TimeInForce: timeInForce, // MAX only supports GTC GroupID: u.GroupID, }, Exchange: types.ExchangeMax, @@ -333,5 +338,6 @@ func convertWebSocketOrderUpdate(u max.OrderUpdate) (*types.Order, error) { Status: toGlobalOrderStatus(u.State, executedVolume, remainingVolume), ExecutedQuantity: executedVolume, CreationTime: types.Time(time.Unix(0, u.CreatedAtMs*int64(time.Millisecond))), + UpdateTime: types.Time(time.Unix(0, u.CreatedAtMs*int64(time.Millisecond))), }, nil } diff --git a/pkg/migrations/mysql/20220304153317_add_profit_table.go b/pkg/migrations/mysql/20220304153317_add_profit_table.go new file mode 100644 index 000000000..4cced13c0 --- /dev/null +++ b/pkg/migrations/mysql/20220304153317_add_profit_table.go @@ -0,0 +1,34 @@ +package mysql + +import ( + "context" + + "github.com/c9s/rockhopper" +) + +func init() { + AddMigration(upAddProfitTable, downAddProfitTable) + +} + +func upAddProfitTable(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + + _, err = tx.ExecContext(ctx, "CREATE TABLE `profits`\n(\n `gid` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,\n `strategy` VARCHAR(32) NOT NULL,\n `strategy_instance_id` VARCHAR(64) NOT NULL,\n `symbol` VARCHAR(8) NOT NULL,\n -- average_cost is the position average cost\n `average_cost` DECIMAL(16, 8) UNSIGNED NOT NULL,\n -- profit is the pnl (profit and loss)\n `profit` DECIMAL(16, 8) NOT NULL,\n -- net_profit is the pnl (profit and loss)\n `net_profit` DECIMAL(16, 8) NOT NULL,\n -- profit_margin is the pnl (profit and loss)\n `profit_margin` DECIMAL(16, 8) NOT NULL,\n -- net_profit_margin is the pnl (profit and loss)\n `net_profit_margin` DECIMAL(16, 8) NOT NULL,\n `quote_currency` VARCHAR(10) NOT NULL,\n `base_currency` VARCHAR(10) NOT NULL,\n -- -------------------------------------------------------\n -- embedded trade data --\n -- -------------------------------------------------------\n `exchange` VARCHAR(24) NOT NULL DEFAULT '',\n `is_futures` BOOLEAN NOT NULL DEFAULT FALSE,\n `is_margin` BOOLEAN NOT NULL DEFAULT FALSE,\n `is_isolated` BOOLEAN NOT NULL DEFAULT FALSE,\n `trade_id` BIGINT UNSIGNED NOT NULL,\n -- side is the side of the trade that makes profit\n `side` VARCHAR(4) NOT NULL DEFAULT '',\n `is_buyer` BOOLEAN NOT NULL DEFAULT FALSE,\n `is_maker` BOOLEAN NOT NULL DEFAULT FALSE,\n -- price is the price of the trade that makes profit\n `price` DECIMAL(16, 8) UNSIGNED NOT NULL,\n -- quantity is the quantity of the trade that makes profit\n `quantity` DECIMAL(16, 8) UNSIGNED NOT NULL,\n -- quote_quantity is the quote quantity of the trade that makes profit\n `quote_quantity` DECIMAL(16, 8) UNSIGNED NOT NULL,\n `traded_at` DATETIME(3) NOT NULL,\n -- fee\n `fee_in_usd` DECIMAL(16, 8) UNSIGNED,\n `fee` DECIMAL(16, 8) UNSIGNED NOT NULL,\n `fee_currency` VARCHAR(10) NOT NULL,\n PRIMARY KEY (`gid`),\n UNIQUE KEY `trade_id` (`trade_id`)\n);") + if err != nil { + return err + } + + return err +} + +func downAddProfitTable(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `profits`;") + if err != nil { + return err + } + + return err +} diff --git a/pkg/migrations/sqlite3/20220304153309_add_profit_table.go b/pkg/migrations/sqlite3/20220304153309_add_profit_table.go new file mode 100644 index 000000000..b95968be2 --- /dev/null +++ b/pkg/migrations/sqlite3/20220304153309_add_profit_table.go @@ -0,0 +1,34 @@ +package sqlite3 + +import ( + "context" + + "github.com/c9s/rockhopper" +) + +func init() { + AddMigration(upAddProfitTable, downAddProfitTable) + +} + +func upAddProfitTable(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is applied. + + _, err = tx.ExecContext(ctx, "CREATE TABLE `profits`\n(\n `gid` INTEGER PRIMARY KEY AUTOINCREMENT,\n `strategy` VARCHAR(32) NOT NULL,\n `strategy_instance_id` VARCHAR(64) NOT NULL,\n `symbol` VARCHAR(8) NOT NULL,\n -- average_cost is the position average cost\n `average_cost` DECIMAL(16, 8) NOT NULL,\n -- profit is the pnl (profit and loss)\n `profit` DECIMAL(16, 8) NOT NULL,\n -- net_profit is the pnl (profit and loss)\n `net_profit` DECIMAL(16, 8) NOT NULL,\n -- profit_margin is the pnl (profit and loss)\n `profit_margin` DECIMAL(16, 8) NOT NULL,\n -- net_profit_margin is the pnl (profit and loss)\n `net_profit_margin` DECIMAL(16, 8) NOT NULL,\n `quote_currency` VARCHAR(10) NOT NULL,\n `base_currency` VARCHAR(10) NOT NULL,\n -- -------------------------------------------------------\n -- embedded trade data --\n -- -------------------------------------------------------\n `exchange` VARCHAR(24) NOT NULL DEFAULT '',\n `is_futures` BOOLEAN NOT NULL DEFAULT FALSE,\n `is_margin` BOOLEAN NOT NULL DEFAULT FALSE,\n `is_isolated` BOOLEAN NOT NULL DEFAULT FALSE,\n `trade_id` BIGINT NOT NULL,\n -- side is the side of the trade that makes profit\n `side` VARCHAR(4) NOT NULL DEFAULT '',\n `is_buyer` BOOLEAN NOT NULL DEFAULT FALSE,\n `is_maker` BOOLEAN NOT NULL DEFAULT FALSE,\n -- price is the price of the trade that makes profit\n `price` DECIMAL(16, 8) NOT NULL,\n -- quantity is the quantity of the trade that makes profit\n `quantity` DECIMAL(16, 8) NOT NULL,\n -- trade_amount is the quote quantity of the trade that makes profit\n `quote_quantity` DECIMAL(16, 8) NOT NULL,\n `traded_at` DATETIME(3) NOT NULL,\n -- fee\n `fee_in_usd` DECIMAL(16, 8),\n `fee` DECIMAL(16, 8) NOT NULL,\n `fee_currency` VARCHAR(10) NOT NULL\n);") + if err != nil { + return err + } + + return err +} + +func downAddProfitTable(ctx context.Context, tx rockhopper.SQLExecutor) (err error) { + // This code is executed when the migration is rolled back. + + _, err = tx.ExecContext(ctx, "DROP TABLE IF EXISTS `profits`;") + if err != nil { + return err + } + + return err +} diff --git a/pkg/service/profit.go b/pkg/service/profit.go new file mode 100644 index 000000000..15af10261 --- /dev/null +++ b/pkg/service/profit.go @@ -0,0 +1,110 @@ +package service + +import ( + "context" + + "github.com/jmoiron/sqlx" + "github.com/pkg/errors" + + "github.com/c9s/bbgo/pkg/types" +) + +type ProfitService struct { + DB *sqlx.DB +} + +func NewProfitService(db *sqlx.DB) *ProfitService { + return &ProfitService{db} +} + +func (s *ProfitService) Load(ctx context.Context, id int64) (*types.Trade, error) { + var trade types.Trade + + rows, err := s.DB.NamedQuery("SELECT * FROM trades WHERE id = :id", map[string]interface{}{ + "id": id, + }) + if err != nil { + return nil, err + } + + defer rows.Close() + + if rows.Next() { + err = rows.StructScan(&trade) + return &trade, err + } + + return nil, errors.Wrapf(ErrTradeNotFound, "trade id:%d not found", id) +} + +func (s *ProfitService) scanRows(rows *sqlx.Rows) (profits []types.Profit, err error) { + for rows.Next() { + var profit types.Profit + if err := rows.StructScan(&profit); err != nil { + return profits, err + } + + profits = append(profits, profit) + } + + return profits, rows.Err() +} + +func (s *ProfitService) Insert(profit types.Profit) error { + _, err := s.DB.NamedExec(` + INSERT INTO profits ( + strategy, + strategy_instance_id, + symbol, + quote_currency, + base_currency, + average_cost, + profit, + net_profit, + profit_margin, + net_profit_margin, + trade_id, + price, + quantity, + quote_quantity, + side, + is_buyer, + is_maker, + fee, + fee_currency, + fee_in_usd, + traded_at, + exchange, + is_margin, + is_futures, + is_isolated + ) VALUES ( + :strategy, + :strategy_instance_id, + :symbol, + :quote_currency, + :base_currency, + :average_cost, + :profit, + :net_profit, + :profit_margin, + :net_profit_margin, + :trade_id, + :price, + :quantity, + :quote_quantity, + :side, + :is_buyer, + :is_maker, + :fee, + :fee_currency, + :fee_in_usd, + :traded_at, + :exchange, + :is_margin, + :is_futures, + :is_isolated + )`, + profit) + return err +} diff --git a/pkg/service/profit_test.go b/pkg/service/profit_test.go new file mode 100644 index 000000000..e4616a80e --- /dev/null +++ b/pkg/service/profit_test.go @@ -0,0 +1,41 @@ +package service + +import ( + "testing" + "time" + + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/assert" + + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" +) + +func TestProfitService(t *testing.T) { + db, err := prepareDB(t) + if err != nil { + t.Fatal(err) + } + + defer db.Close() + + xdb := sqlx.NewDb(db.DB, "sqlite3") + service := &ProfitService{DB: xdb} + + err = service.Insert(types.Profit{ + Symbol: "BTCUSDT", + BaseCurrency: "BTC", + QuoteCurrency: "USDT", + AverageCost: fixedpoint.NewFromFloat(44000), + Profit: fixedpoint.NewFromFloat(1.01), + NetProfit: fixedpoint.NewFromFloat(0.98), + TradeID: 99, + Side: types.SideTypeSell, + Price: fixedpoint.NewFromFloat(44300), + Quantity: fixedpoint.NewFromFloat(0.001), + QuoteQuantity: fixedpoint.NewFromFloat(44.0), + Exchange: types.ExchangeMax, + TradedAt: time.Now(), + }) + assert.NoError(t, err) +} diff --git a/pkg/service/trade_test.go b/pkg/service/trade_test.go index 805bf5fd5..15ebe6938 100644 --- a/pkg/service/trade_test.go +++ b/pkg/service/trade_test.go @@ -3,12 +3,13 @@ package service import ( "context" "testing" + "time" "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" - "github.com/c9s/bbgo/pkg/types" "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" ) func Test_tradeService(t *testing.T) { @@ -34,6 +35,7 @@ func Test_tradeService(t *testing.T) { Symbol: "BTCUSDT", Side: "BUY", IsBuyer: true, + Time: types.Time(time.Now()), }) assert.NoError(t, err) diff --git a/pkg/strategy/bollmaker/strategy.go b/pkg/strategy/bollmaker/strategy.go index cbf313d94..22d0ba941 100644 --- a/pkg/strategy/bollmaker/strategy.go +++ b/pkg/strategy/bollmaker/strategy.go @@ -38,8 +38,8 @@ func init() { } type State struct { - Position *types.Position `json:"position,omitempty"` - ProfitStats bbgo.ProfitStats `json:"profitStats,omitempty"` + Position *types.Position `json:"position,omitempty"` + ProfitStats types.ProfitStats `json:"profitStats,omitempty"` } type BollingerSetting struct { @@ -52,7 +52,9 @@ type Strategy struct { *bbgo.Notifiability *bbgo.Persistence + Environment *bbgo.Environment StandardIndicatorSet *bbgo.StandardIndicatorSet + Market types.Market // Symbol is the market symbol you want to trade Symbol string `json:"symbol"` @@ -141,8 +143,7 @@ type Strategy struct { bbgo.SmartStops session *bbgo.ExchangeSession - book *types.StreamOrderBook - market types.Market + book *types.StreamOrderBook state *State @@ -214,8 +215,8 @@ func (s *Strategy) ClosePosition(ctx context.Context, percentage fixedpoint.Valu side = types.SideTypeSell } - if quantity.Compare(s.market.MinQuantity) < 0 { - return fmt.Errorf("order quantity %v is too small, less than %v", quantity, s.market.MinQuantity) + if quantity.Compare(s.Market.MinQuantity) < 0 { + return fmt.Errorf("order quantity %v is too small, less than %v", quantity, s.Market.MinQuantity) } submitOrder := types.SubmitOrder{ @@ -223,7 +224,7 @@ func (s *Strategy) ClosePosition(ctx context.Context, percentage fixedpoint.Valu Side: side, Type: types.OrderTypeMarket, Quantity: quantity, - Market: s.market, + Market: s.Market, } s.Notify("Submitting %s %s order to close position by %v", s.Symbol, side.String(), percentage, submitOrder) @@ -264,13 +265,13 @@ func (s *Strategy) LoadState() error { // if position is nil, we need to allocate a new position for calculation if s.state.Position == nil { - s.state.Position = types.NewPositionFromMarket(s.market) + s.state.Position = types.NewPositionFromMarket(s.Market) } // init profit states - s.state.ProfitStats.Symbol = s.market.Symbol - s.state.ProfitStats.BaseCurrency = s.market.BaseCurrency - s.state.ProfitStats.QuoteCurrency = s.market.QuoteCurrency + s.state.ProfitStats.Symbol = s.Market.Symbol + s.state.ProfitStats.BaseCurrency = s.Market.BaseCurrency + s.state.ProfitStats.QuoteCurrency = s.Market.QuoteCurrency if s.state.ProfitStats.AccumulatedSince == 0 { s.state.ProfitStats.AccumulatedSince = time.Now().Unix() } @@ -323,7 +324,7 @@ func (s *Strategy) placeOrders(ctx context.Context, orderExecutor bbgo.OrderExec Type: types.OrderTypeLimitMaker, Quantity: sellQuantity, Price: askPrice, - Market: s.market, + Market: s.Market, GroupID: s.groupID, } buyOrder := types.SubmitOrder{ @@ -332,14 +333,14 @@ func (s *Strategy) placeOrders(ctx context.Context, orderExecutor bbgo.OrderExec Type: types.OrderTypeLimitMaker, Quantity: buyQuantity, Price: bidPrice, - Market: s.market, + Market: s.Market, GroupID: s.groupID, } var submitOrders []types.SubmitOrder - baseBalance, hasBaseBalance := balances[s.market.BaseCurrency] - quoteBalance, hasQuoteBalance := balances[s.market.QuoteCurrency] + baseBalance, hasBaseBalance := balances[s.Market.BaseCurrency] + quoteBalance, hasQuoteBalance := balances[s.Market.QuoteCurrency] downBand := s.defaultBoll.LastDownBand() upBand := s.defaultBoll.LastUpBand() @@ -421,12 +422,12 @@ func (s *Strategy) placeOrders(ctx context.Context, orderExecutor bbgo.OrderExec case UpTrend: skew := s.UptrendSkew - buyOrder.Quantity = fixedpoint.Max(s.market.MinQuantity, sellOrder.Quantity.Mul(skew)) + buyOrder.Quantity = fixedpoint.Max(s.Market.MinQuantity, sellOrder.Quantity.Mul(skew)) case DownTrend: skew := s.DowntrendSkew ratio := fixedpoint.One.Div(skew) - sellOrder.Quantity = fixedpoint.Max(s.market.MinQuantity, buyOrder.Quantity.Mul(ratio)) + sellOrder.Quantity = fixedpoint.Max(s.Market.MinQuantity, buyOrder.Quantity.Mul(ratio)) } @@ -506,12 +507,12 @@ func (s *Strategy) detectPriceTrend(inc *indicator.BOLL, price float64) PriceTre } func (s *Strategy) adjustOrderQuantity(submitOrder types.SubmitOrder) types.SubmitOrder { - if submitOrder.Quantity.Mul(submitOrder.Price).Compare(s.market.MinNotional) < 0 { - submitOrder.Quantity = bbgo.AdjustFloatQuantityByMinAmount(submitOrder.Quantity, submitOrder.Price, s.market.MinNotional.Mul(notionModifier)) + if submitOrder.Quantity.Mul(submitOrder.Price).Compare(s.Market.MinNotional) < 0 { + submitOrder.Quantity = bbgo.AdjustFloatQuantityByMinAmount(submitOrder.Quantity, submitOrder.Price, s.Market.MinNotional.Mul(notionModifier)) } - if submitOrder.Quantity.Compare(s.market.MinQuantity) < 0 { - submitOrder.Quantity = fixedpoint.Max(submitOrder.Quantity, s.market.MinQuantity) + if submitOrder.Quantity.Compare(s.Market.MinQuantity) < 0 { + submitOrder.Quantity = fixedpoint.Max(submitOrder.Quantity, s.Market.MinQuantity) } return submitOrder @@ -540,13 +541,6 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se // initial required information s.session = session - - market, ok := session.Market(s.Symbol) - if !ok { - return fmt.Errorf("market %s not found", s.Symbol) - } - s.market = market - s.neutralBoll = s.StandardIndicatorSet.BOLL(s.NeutralBollinger.IntervalWindow, s.NeutralBollinger.BandWidth) s.defaultBoll = s.StandardIndicatorSet.BOLL(s.DefaultBollinger.IntervalWindow, s.DefaultBollinger.BandWidth) @@ -571,17 +565,11 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se s.tradeCollector = bbgo.NewTradeCollector(s.Symbol, s.state.Position, s.orderStore) s.tradeCollector.OnProfit(func(trade types.Trade, profit fixedpoint.Value, netProfit fixedpoint.Value) { log.Infof("generated profit: %v", profit) - p := bbgo.Profit{ - Symbol: s.Symbol, - Profit: profit, - NetProfit: netProfit, - TradeAmount: trade.QuoteQuantity, - ProfitMargin: profit.Div(trade.QuoteQuantity), - NetProfitMargin: netProfit.Div(trade.QuoteQuantity), - QuoteCurrency: s.state.Position.QuoteCurrency, - BaseCurrency: s.state.Position.BaseCurrency, - Time: trade.Time.Time(), - } + p := s.state.Position.NewProfit(trade, profit, netProfit) + p.Strategy = ID + p.StrategyInstanceID = instanceID + s.Environment.RecordProfit(p) + s.state.ProfitStats.AddProfit(p) s.Notify(&p) s.Notify(&s.state.ProfitStats) diff --git a/pkg/strategy/grid/strategy.go b/pkg/strategy/grid/strategy.go index 572e0833a..f03d2df7b 100644 --- a/pkg/strategy/grid/strategy.go +++ b/pkg/strategy/grid/strategy.go @@ -41,7 +41,7 @@ type State struct { // [source Order ID] -> arbitrage order ArbitrageOrders map[uint64]types.Order `json:"arbitrageOrders"` - ProfitStats bbgo.ProfitStats `json:"profitStats,omitempty"` + ProfitStats types.ProfitStats `json:"profitStats,omitempty"` } type Strategy struct { diff --git a/pkg/strategy/pricealert/strategy.go b/pkg/strategy/pricealert/strategy.go index 345ac4d54..0e484e52b 100644 --- a/pkg/strategy/pricealert/strategy.go +++ b/pkg/strategy/pricealert/strategy.go @@ -16,7 +16,7 @@ func init() { type Strategy struct { // The notification system will be injected into the strategy automatically. - bbgo.Notifiability + *bbgo.Notifiability // These fields will be filled from the config file (it translates YAML to JSON) Symbol string `json:"symbol"` diff --git a/pkg/strategy/xmaker/state.go b/pkg/strategy/xmaker/state.go index f6a57c285..ea82bc96f 100644 --- a/pkg/strategy/xmaker/state.go +++ b/pkg/strategy/xmaker/state.go @@ -3,7 +3,6 @@ package xmaker import ( "sync" - "github.com/c9s/bbgo/pkg/bbgo" "github.com/c9s/bbgo/pkg/fixedpoint" "github.com/c9s/bbgo/pkg/types" ) @@ -15,7 +14,7 @@ type State struct { } type ProfitStats struct { - bbgo.ProfitStats + types.ProfitStats lock sync.Mutex MakerExchange types.ExchangeName `json:"makerExchange"` diff --git a/pkg/strategy/xmaker/strategy.go b/pkg/strategy/xmaker/strategy.go index bbfc162b8..6a02a9be5 100644 --- a/pkg/strategy/xmaker/strategy.go +++ b/pkg/strategy/xmaker/strategy.go @@ -763,16 +763,16 @@ func (s *Strategy) CrossRun(ctx context.Context, orderExecutionRouter bbgo.Order } }) s.tradeCollector.OnProfit(func(trade types.Trade, profit fixedpoint.Value, netProfit fixedpoint.Value) { - p := bbgo.Profit{ + p := types.Profit{ Symbol: s.Symbol, Profit: profit, NetProfit: netProfit, - TradeAmount: trade.QuoteQuantity, + QuoteQuantity: trade.QuoteQuantity, ProfitMargin: profit.Div(trade.QuoteQuantity), NetProfitMargin: netProfit.Div(trade.QuoteQuantity), QuoteCurrency: s.state.Position.QuoteCurrency, BaseCurrency: s.state.Position.BaseCurrency, - Time: trade.Time.Time(), + TradedAt: trade.Time.Time(), } s.state.ProfitStats.AddProfit(p) s.Notify(&p) diff --git a/pkg/types/position.go b/pkg/types/position.go index e922ed98a..68c8429ee 100644 --- a/pkg/types/position.go +++ b/pkg/types/position.go @@ -53,6 +53,38 @@ type Position struct { sync.Mutex } +// NewProfit generates the profit object from the current position +func (p *Position) NewProfit(trade Trade, profit, netProfit fixedpoint.Value) Profit { + return Profit{ + Symbol: p.Symbol, + QuoteCurrency: p.QuoteCurrency, + BaseCurrency: p.BaseCurrency, + AverageCost: p.AverageCost, + // profit related fields + Profit: profit, + NetProfit: netProfit, + ProfitMargin: profit.Div(trade.QuoteQuantity), + NetProfitMargin: netProfit.Div(trade.QuoteQuantity), + // trade related fields + TradeID: trade.ID, + Side: trade.Side, + IsBuyer: trade.IsBuyer, + IsMaker: trade.IsMaker, + Price: trade.Price, + Quantity: trade.Quantity, + QuoteQuantity: trade.QuoteQuantity, + // FeeInUSD: 0, + Fee: trade.Fee, + FeeCurrency: trade.FeeCurrency, + + Exchange: trade.Exchange, + IsMargin: trade.IsMargin, + IsFutures: trade.IsFutures, + IsIsolated: trade.IsIsolated, + TradedAt: trade.Time.Time(), + } +} + func (p *Position) NewClosePositionOrder(percentage fixedpoint.Value) *SubmitOrder { base := p.GetBase() quantity := base.Mul(percentage) diff --git a/pkg/types/profit.go b/pkg/types/profit.go new file mode 100644 index 000000000..8b8ef783b --- /dev/null +++ b/pkg/types/profit.go @@ -0,0 +1,364 @@ +package types + +import ( + "fmt" + "time" + + "github.com/slack-go/slack" + + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/util" +) + +// Profit struct stores the PnL information +type Profit struct { + // --- position related fields + // ------------------------------------------- + // Symbol is the symbol of the position + Symbol string `json:"symbol"` + QuoteCurrency string `json:"quoteCurrency" db:"quote_currency"` + BaseCurrency string `json:"baseCurrency" db:"base_currency"` + AverageCost fixedpoint.Value `json:"averageCost" db:"average_cost"` + + // profit related fields + // ------------------------------------------- + // Profit is the profit of this trade made. negative profit means loss. + Profit fixedpoint.Value `json:"profit" db:"profit"` + + // NetProfit is (profit - trading fee) + NetProfit fixedpoint.Value `json:"netProfit" db:"net_profit"` + + // ProfitMargin is a percentage of the profit and the capital amount + ProfitMargin fixedpoint.Value `json:"profitMargin" db:"profit_margin"` + + // NetProfitMargin is a percentage of the net profit and the capital amount + NetProfitMargin fixedpoint.Value `json:"netProfitMargin" db:"net_profit_margin"` + + // trade related fields + // -------------------------------------------- + // TradeID is the exchange trade id of that trade + TradeID uint64 `json:"tradeID" db:"trade_id"` + Side SideType `json:"side" db:"side"` + IsBuyer bool `json:"isBuyer" db:"is_buyer"` + IsMaker bool `json:"isMaker" db:"is_maker"` + Price fixedpoint.Value `json:"price" db:"price"` + Quantity fixedpoint.Value `json:"quantity" db:"quantity"` + QuoteQuantity fixedpoint.Value `json:"quoteQuantity" db:"quote_quantity"` + + // FeeInUSD is the summed fee of this profit, + // you will need to convert the trade fee into USD since the fee currencies can be different. + FeeInUSD fixedpoint.Value `json:"feeInUSD" db:"fee_in_usd"` + Fee fixedpoint.Value `json:"fee" db:"fee"` + FeeCurrency string `json:"feeCurrency" db:"fee_currency"` + Exchange ExchangeName `json:"exchange" db:"exchange"` + IsMargin bool `json:"isMargin" db:"is_margin"` + IsFutures bool `json:"isFutures" db:"is_futures"` + IsIsolated bool `json:"isIsolated" db:"is_isolated"` + TradedAt time.Time `json:"tradedAt" db:"traded_at"` + + // strategy related fields + Strategy string `json:"strategy" db:"strategy"` + StrategyInstanceID string `json:"strategyInstanceID" db:"strategy_instance_id"` +} + +func (p *Profit) SlackAttachment() slack.Attachment { + var color = pnlColor(p.Profit) + var title = fmt.Sprintf("%s PnL ", p.Symbol) + title += pnlEmojiMargin(p.Profit, p.ProfitMargin, defaultPnlLevelResolution) + " " + title += pnlSignString(p.Profit) + " " + p.QuoteCurrency + + var fields []slack.AttachmentField + + if !p.NetProfit.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Net Profit", + Value: pnlSignString(p.NetProfit) + " " + p.QuoteCurrency, + Short: true, + }) + } + + if !p.ProfitMargin.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Profit Margin", + Value: p.ProfitMargin.Percentage(), + Short: true, + }) + } + + if !p.NetProfitMargin.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Net Profit Margin", + Value: p.NetProfitMargin.Percentage(), + Short: true, + }) + } + + if !p.QuoteQuantity.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Trade Amount", + Value: p.QuoteQuantity.String() + " " + p.QuoteCurrency, + Short: true, + }) + } + + if !p.FeeInUSD.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Fee In USD", + Value: p.FeeInUSD.String() + " USD", + Short: true, + }) + } + + if len(p.Strategy) != 0 { + fields = append(fields, slack.AttachmentField{ + Title: "Strategy", + Value: p.Strategy, + Short: true, + }) + } + + return slack.Attachment{ + Color: color, + Title: title, + Fields: fields, + // Footer: "", + } +} + +func (p *Profit) PlainText() string { + var emoji string + if !p.ProfitMargin.IsZero() { + emoji = pnlEmojiMargin(p.Profit, p.ProfitMargin, defaultPnlLevelResolution) + } else { + emoji = pnlEmojiSimple(p.Profit) + } + + return fmt.Sprintf("%s trade profit %s %s %s (%s), net profit =~ %s %s (%s)", + p.Symbol, + emoji, + p.Profit.String(), p.QuoteCurrency, + p.ProfitMargin.Percentage(), + p.NetProfit.String(), p.QuoteCurrency, + p.NetProfitMargin.Percentage(), + ) +} + +var lossEmoji = "🔥" +var profitEmoji = "💰" +var defaultPnlLevelResolution = fixedpoint.NewFromFloat(0.001) + +func pnlColor(pnl fixedpoint.Value) string { + if pnl.Sign() > 0 { + return GreenColor + } + return RedColor +} + +func pnlSignString(pnl fixedpoint.Value) string { + if pnl.Sign() > 0 { + return "+" + pnl.String() + } + return pnl.String() +} + +func pnlEmojiSimple(pnl fixedpoint.Value) string { + if pnl.Sign() < 0 { + return lossEmoji + } + + if pnl.IsZero() { + return "" + } + + return profitEmoji +} + +func pnlEmojiMargin(pnl, margin, resolution fixedpoint.Value) (out string) { + if margin.IsZero() { + return pnlEmojiSimple(pnl) + } + + if pnl.Sign() < 0 { + out = lossEmoji + level := (margin.Neg()).Div(resolution).Int() + for i := 1; i < level; i++ { + out += lossEmoji + } + return out + } + + if pnl.IsZero() { + return out + } + + out = profitEmoji + level := margin.Div(resolution).Int() + for i := 1; i < level; i++ { + out += profitEmoji + } + return out +} + +type ProfitStats struct { + Symbol string `json:"symbol"` + QuoteCurrency string `json:"quoteCurrency"` + BaseCurrency string `json:"baseCurrency"` + + AccumulatedPnL fixedpoint.Value `json:"accumulatedPnL,omitempty"` + AccumulatedNetProfit fixedpoint.Value `json:"accumulatedNetProfit,omitempty"` + AccumulatedProfit fixedpoint.Value `json:"accumulatedProfit,omitempty"` + AccumulatedLoss fixedpoint.Value `json:"accumulatedLoss,omitempty"` + AccumulatedVolume fixedpoint.Value `json:"accumulatedVolume,omitempty"` + AccumulatedSince int64 `json:"accumulatedSince,omitempty"` + + TodayPnL fixedpoint.Value `json:"todayPnL,omitempty"` + TodayNetProfit fixedpoint.Value `json:"todayNetProfit,omitempty"` + TodayProfit fixedpoint.Value `json:"todayProfit,omitempty"` + TodayLoss fixedpoint.Value `json:"todayLoss,omitempty"` + TodaySince int64 `json:"todaySince,omitempty"` +} + +func (s *ProfitStats) Init(market Market) { + s.Symbol = market.Symbol + s.BaseCurrency = market.BaseCurrency + s.QuoteCurrency = market.QuoteCurrency + if s.AccumulatedSince == 0 { + s.AccumulatedSince = time.Now().Unix() + } +} + +func (s *ProfitStats) AddProfit(profit Profit) { + s.AccumulatedPnL = s.AccumulatedPnL.Add(profit.Profit) + s.AccumulatedNetProfit = s.AccumulatedNetProfit.Add(profit.NetProfit) + s.TodayPnL = s.TodayPnL.Add(profit.Profit) + s.TodayNetProfit = s.TodayNetProfit.Add(profit.NetProfit) + + if profit.Profit.Sign() < 0 { + s.AccumulatedLoss = s.AccumulatedLoss.Add(profit.Profit) + s.TodayLoss = s.TodayLoss.Add(profit.Profit) + } else if profit.Profit.Sign() > 0 { + s.AccumulatedProfit = s.AccumulatedLoss.Add(profit.Profit) + s.TodayProfit = s.TodayProfit.Add(profit.Profit) + } +} + +func (s *ProfitStats) AddTrade(trade Trade) { + if s.IsOver24Hours() { + s.ResetToday() + } + + s.AccumulatedVolume = s.AccumulatedVolume.Add(trade.Quantity) +} + +func (s *ProfitStats) IsOver24Hours() bool { + return time.Since(time.Unix(s.TodaySince, 0)) > 24*time.Hour +} + +func (s *ProfitStats) ResetToday() { + s.TodayPnL = fixedpoint.Zero + s.TodayNetProfit = fixedpoint.Zero + s.TodayProfit = fixedpoint.Zero + s.TodayLoss = fixedpoint.Zero + + var beginningOfTheDay = util.BeginningOfTheDay(time.Now().Local()) + s.TodaySince = beginningOfTheDay.Unix() +} + +func (s *ProfitStats) PlainText() string { + since := time.Unix(s.AccumulatedSince, 0).Local() + return fmt.Sprintf("%s Profit Today\n"+ + "Profit %s %s\n"+ + "Net profit %s %s\n"+ + "Trade Loss %s %s\n"+ + "Summary:\n"+ + "Accumulated Profit %s %s\n"+ + "Accumulated Net Profit %s %s\n"+ + "Accumulated Trade Loss %s %s\n"+ + "Since %s", + s.Symbol, + s.TodayPnL.String(), s.QuoteCurrency, + s.TodayNetProfit.String(), s.QuoteCurrency, + s.TodayLoss.String(), s.QuoteCurrency, + s.AccumulatedPnL.String(), s.QuoteCurrency, + s.AccumulatedNetProfit.String(), s.QuoteCurrency, + s.AccumulatedLoss.String(), s.QuoteCurrency, + since.Format(time.RFC822), + ) +} + +func (s *ProfitStats) SlackAttachment() slack.Attachment { + var color = pnlColor(s.AccumulatedPnL) + var title = fmt.Sprintf("%s Accumulated PnL %s %s", s.Symbol, pnlSignString(s.AccumulatedPnL), s.QuoteCurrency) + + since := time.Unix(s.AccumulatedSince, 0).Local() + title += " Since " + since.Format(time.RFC822) + + var fields []slack.AttachmentField + + if !s.TodayPnL.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "P&L Today", + Value: pnlSignString(s.TodayPnL) + " " + s.QuoteCurrency, + Short: true, + }) + } + + if !s.TodayProfit.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Profit Today", + Value: pnlSignString(s.TodayProfit) + " " + s.QuoteCurrency, + Short: true, + }) + } + + if !s.TodayNetProfit.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Net Profit Today", + Value: pnlSignString(s.TodayNetProfit) + " " + s.QuoteCurrency, + Short: true, + }) + } + + if !s.TodayLoss.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Loss Today", + Value: pnlSignString(s.TodayLoss) + " " + s.QuoteCurrency, + Short: true, + }) + } + + if !s.AccumulatedPnL.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Accumulated P&L", + Value: pnlSignString(s.AccumulatedPnL) + " " + s.QuoteCurrency, + }) + } + + if !s.AccumulatedProfit.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Accumulated Profit", + Value: pnlSignString(s.AccumulatedProfit) + " " + s.QuoteCurrency, + }) + } + + if !s.AccumulatedNetProfit.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Accumulated Net Profit", + Value: pnlSignString(s.AccumulatedNetProfit) + " " + s.QuoteCurrency, + }) + } + + if !s.AccumulatedLoss.IsZero() { + fields = append(fields, slack.AttachmentField{ + Title: "Accumulated Loss", + Value: pnlSignString(s.AccumulatedLoss) + " " + s.QuoteCurrency, + }) + } + + return slack.Attachment{ + Color: color, + Title: title, + Fields: fields, + // Footer: "", + } +} diff --git a/pkg/types/time.go b/pkg/types/time.go index fb4951334..47ef9ff72 100644 --- a/pkg/types/time.go +++ b/pkg/types/time.go @@ -176,6 +176,9 @@ func NewTimeFromUnix(sec int64, nsec int64) Time { // Value implements the driver.Valuer interface // see http://jmoiron.net/blog/built-in-interfaces/ func (t Time) Value() (driver.Value, error) { + if time.Time(t) == (time.Time{}) { + return nil, nil + } return time.Time(t), nil } diff --git a/rockhopper_mysql.yaml b/rockhopper_mysql.yaml index a0b6e7864..d8b459dbb 100644 --- a/rockhopper_mysql.yaml +++ b/rockhopper_mysql.yaml @@ -1,6 +1,15 @@ +# vim:filetype=yaml: +# you can copy this file to rockhopper_mysql_local.yaml to have your modification --- driver: mysql dialect: mysql -dsn: "root@tcp(localhost:3306)/bbgo_dev?parseTime=true" -# dsn: "root@tcp(localhost:3306)/bbgo_backtest?parseTime=true" + +# unix socket connection to mysql with password +# dsn: "root:123123@unix(/opt/local/var/run/mysql57/mysqld.sock)/bbgo_dev?parseTime=true" + +# tcp connection to mysql with password +# dsn: "root:123123@tcp(localhost:3306)/bbgo_dev?parseTime=true" + +# tcp connection to mysql without password +# dsn: "root@tcp(localhost:3306)/bbgo_dev?parseTime=true" migrationsDir: migrations/mysql