Merge pull request #466 from c9s/feature/strategy-profit

feature: add strategy profit records
This commit is contained in:
Yo-An Lin 2022-03-07 12:20:47 +08:00 committed by GitHub
commit 35ef21ab1c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1059 additions and 493 deletions

View File

@ -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
```

View File

@ -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`;

View File

@ -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`;

View File

@ -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()
}

View File

@ -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
}

View File

@ -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)
})
}

View File

@ -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: "",
}
}

View File

@ -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)

2
pkg/bbgo/trader_test.go Normal file
View File

@ -0,0 +1,2 @@
package bbgo

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

110
pkg/service/profit.go Normal file
View File

@ -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
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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)

View File

@ -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 {

View File

@ -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"`

View File

@ -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"`

View File

@ -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)

View File

@ -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)

364
pkg/types/profit.go Normal file
View File

@ -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: "",
}
}

View File

@ -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
}

View File

@ -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