diff --git a/go.mod b/go.mod index f34524adf..7eace2ffb 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/lestrrat-go/strftime v1.0.0 // indirect github.com/magiconair/properties v1.8.4 // indirect github.com/mattn/go-colorable v0.1.7 // indirect + github.com/mattn/go-shellwords v1.0.12 // indirect github.com/mattn/go-sqlite3 v1.14.10 // indirect github.com/mitchellh/mapstructure v1.4.1 // indirect github.com/pelletier/go-toml v1.8.1 // indirect diff --git a/go.sum b/go.sum index 38710c263..a3fa25afb 100644 --- a/go.sum +++ b/go.sum @@ -313,6 +313,8 @@ github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNx github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= +github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.9 h1:10HX2Td0ocZpYEjhilsuo6WWtUqttj2Kb0KtD86/KYA= github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= diff --git a/pkg/bbgo/environment.go b/pkg/bbgo/environment.go index 087762c6b..02e255d30 100644 --- a/pkg/bbgo/environment.go +++ b/pkg/bbgo/environment.go @@ -716,6 +716,10 @@ func (environ *Environment) setupTelegram(userConfig *Config, telegramBotToken s // you must restore the session after the notifier updates messenger.RestoreSession(session) + + // right now it's only for telegram, should we share the session (?) + interact.Default().SetOriginState(interact.StateAuthenticated) + interact.Default().SetState(interact.StateAuthenticated) } messenger.OnAuthorized(func(a *interact.TelegramAuthorizer) { diff --git a/pkg/bbgo/interact.go b/pkg/bbgo/interact.go new file mode 100644 index 000000000..7e08278d9 --- /dev/null +++ b/pkg/bbgo/interact.go @@ -0,0 +1,172 @@ +package bbgo + +import ( + "context" + "fmt" + "path" + "reflect" + "strconv" + "strings" + + "github.com/c9s/bbgo/pkg/interact" + "github.com/c9s/bbgo/pkg/types" +) + +type PositionCloser interface { + ClosePosition(ctx context.Context, percentage float64) error +} + +type PositionReader interface { + CurrentPosition() *types.Position +} + +type closePositionContext struct { + signature string + closer PositionCloser + percentage float64 +} + +type CoreInteraction struct { + environment *Environment + trader *Trader + + exchangeStrategies map[string]SingleExchangeStrategy + closePositionContext closePositionContext +} + +func NewCoreInteraction(environment *Environment, trader *Trader) *CoreInteraction { + return &CoreInteraction{ + environment: environment, + trader: trader, + exchangeStrategies: make(map[string]SingleExchangeStrategy), + } +} + +func (it *CoreInteraction) Commands(i *interact.Interact) { + i.PrivateCommand("/closeposition", "close the position of a strategy", func(reply interact.Reply) error { + // it.trader.exchangeStrategies + // send symbol options + found := false + for signature, strategy := range it.exchangeStrategies { + if _, ok := strategy.(PositionCloser); ok { + reply.AddButton(signature) + found = true + } + } + + if found { + reply.Message("Please choose your position from the current running strategies") + } else { + reply.Message("No any strategy supports PositionCloser") + } + return nil + }).Next(func(signature string, reply interact.Reply) error { + strategy, ok := it.exchangeStrategies[signature] + if !ok { + reply.Message("Strategy not found") + return fmt.Errorf("strategy %s not found", signature) + } + + closer, implemented := strategy.(PositionCloser) + if !implemented { + reply.Message(fmt.Sprintf("Strategy %s does not support position close", signature)) + return fmt.Errorf("strategy %s does not implement PositionCloser interface", signature) + } + + it.closePositionContext.closer = closer + it.closePositionContext.signature = signature + + if reader, implemented := strategy.(PositionReader); implemented { + position := reader.CurrentPosition() + if position != nil { + reply.Send("Your current position:") + reply.Send(position.String()) + + if position.Base == 0 { + reply.Message("No opened position") + reply.RemoveKeyboard() + return fmt.Errorf("no opened position") + } + } + } + + reply.Message("Choose or enter the percentage to close") + for _, symbol := range []string{"5%", "25%", "50%", "80%", "100%"} { + reply.AddButton(symbol) + } + + return nil + }).Next(func(percentageStr string, reply interact.Reply) error { + percentage, err := parseFloatPercent(percentageStr, 64) + if err != nil { + reply.Message(fmt.Sprintf("%q is not a valid percentage string", percentageStr)) + return err + } + + reply.RemoveKeyboard() + + err = it.closePositionContext.closer.ClosePosition(context.Background(), percentage) + if err != nil { + reply.Message(fmt.Sprintf("Failed to close the position, %s", err.Error())) + return err + } + + return nil + }) +} + +func (it *CoreInteraction) Initialize() error { + // re-map exchange strategies into the signature-object map + for sessionID, strategies := range it.trader.exchangeStrategies { + for _, strategy := range strategies { + signature, err := getStrategySignature(strategy) + if err != nil { + return err + } + + key := sessionID + "." + signature + it.exchangeStrategies[key] = strategy + } + } + return nil +} + +func getStrategySignature(strategy SingleExchangeStrategy) (string, error) { + rv := reflect.ValueOf(strategy).Elem() + if rv.Kind() != reflect.Struct { + return "", fmt.Errorf("strategy %T instance is not a struct", strategy) + } + + var signature = path.Base(rv.Type().PkgPath()) + + var id = strategy.ID() + + if !strings.EqualFold(id, signature) { + signature += "." + strings.ToLower(id) + } + + for i := 0; i < rv.NumField(); i++ { + field := rv.Field(i) + if field.Kind() == reflect.String { + str := field.String() + if len(str) > 0 { + signature += "." + field.String() + } + } + } + + return signature, nil +} + +func parseFloatPercent(s string, bitSize int) (f float64, err error) { + i := strings.Index(s, "%") + if i < 0 { + return strconv.ParseFloat(s, bitSize) + } + + f, err = strconv.ParseFloat(s[:i], bitSize) + if err != nil { + return 0, err + } + return f / 100.0, nil +} diff --git a/pkg/bbgo/interact_test.go b/pkg/bbgo/interact_test.go new file mode 100644 index 000000000..2f03b7ae8 --- /dev/null +++ b/pkg/bbgo/interact_test.go @@ -0,0 +1,28 @@ +package bbgo + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +type myStrategy struct { + Symbol string `json:"symbol"` +} + +func (m myStrategy) ID() string { + return "mystrategy" +} + +func (m *myStrategy) Run(ctx context.Context, orderExecutor OrderExecutor, session *ExchangeSession) error { + return nil +} + +func Test_getStrategySignature(t *testing.T) { + signature, err := getStrategySignature(&myStrategy{ + Symbol: "BTCUSDT", + }) + assert.NoError(t, err) + assert.Equal(t, "bbgo.mystrategy.BTCUSDT", signature) +} diff --git a/pkg/bbgo/trader.go b/pkg/bbgo/trader.go index bb01e148b..e5923e4d6 100644 --- a/pkg/bbgo/trader.go +++ b/pkg/bbgo/trader.go @@ -10,6 +10,8 @@ import ( log "github.com/sirupsen/logrus" _ "github.com/go-sql-driver/mysql" + + "github.com/c9s/bbgo/pkg/interact" ) // SingleExchangeStrategy represents the single Exchange strategy @@ -291,6 +293,11 @@ func (trader *Trader) RunAllSingleExchangeStrategy(ctx context.Context) error { } func (trader *Trader) Run(ctx context.Context) error { + // before we start the interaction, + // register the core interaction, because we can only get the strategies in this scope + // trader.environment.Connect will call interact.Start + interact.AddCustomInteraction(NewCoreInteraction(trader.environment, trader)) + trader.Subscribe() if err := trader.environment.Start(ctx); err != nil { diff --git a/pkg/interact/default.go b/pkg/interact/default.go index 09ef93b49..26e08757c 100644 --- a/pkg/interact/default.go +++ b/pkg/interact/default.go @@ -13,7 +13,7 @@ func SetMessenger(messenger Messenger) { } func AddCustomInteraction(custom CustomInteraction) { - custom.Commands(defaultInteraction) + defaultInteraction.AddCustomInteraction(custom) } func Start(ctx context.Context) error { diff --git a/pkg/interact/interact.go b/pkg/interact/interact.go index 62062c617..5af8508f7 100644 --- a/pkg/interact/interact.go +++ b/pkg/interact/interact.go @@ -12,6 +12,10 @@ type CustomInteraction interface { Commands(interact *Interact) } +type Initializer interface { + Initialize() error +} + type Messenger interface { TextMessageResponder CommandResponder @@ -33,17 +37,20 @@ type Interact struct { originState, currentState State + customInteractions []CustomInteraction + messenger Messenger } func New() *Interact { return &Interact{ - startTime: time.Now(), - commands: make(map[string]*Command), - originState: StatePublic, - currentState: StatePublic, - states: make(map[State]State), - statesFunc: make(map[State]interface{}), + startTime: time.Now(), + commands: make(map[string]*Command), + privateCommands: make(map[string]*Command), + originState: StatePublic, + currentState: StatePublic, + states: make(map[State]State), + statesFunc: make(map[State]interface{}), } } @@ -53,10 +60,11 @@ func (it *Interact) SetOriginState(s State) { func (it *Interact) AddCustomInteraction(custom CustomInteraction) { custom.Commands(it) + it.customInteractions = append(it.customInteractions, custom) } -func (it *Interact) PrivateCommand(command string, f interface{}) *Command { - cmd := NewCommand(command, "", f) +func (it *Interact) PrivateCommand(command, desc string, f interface{}) *Command { + cmd := NewCommand(command, desc, f) it.privateCommands[command] = cmd return cmd } @@ -84,7 +92,7 @@ func (it *Interact) getNextState(currentState State) (nextState State, final boo return it.originState, final } -func (it *Interact) setState(s State) { +func (it *Interact) SetState(s State) { log.Infof("[interact] transiting state from %s -> %s", it.currentState, s) it.currentState = s } @@ -111,11 +119,11 @@ func (it *Interact) handleResponse(text string, ctxObjects ...interface{}) error nextState, end := it.getNextState(it.currentState) if end { - it.setState(it.originState) + it.SetState(it.originState) return nil } - it.setState(nextState) + it.SetState(nextState) return nil } @@ -146,7 +154,7 @@ func (it *Interact) runCommand(command string, args []string, ctxObjects ...inte return err } - it.setState(cmd.initState) + it.SetState(cmd.initState) if _, err := parseFuncArgsAndCall(cmd.F, args, ctxObjects...); err != nil { return err } @@ -154,11 +162,11 @@ func (it *Interact) runCommand(command string, args []string, ctxObjects ...inte // if we can successfully execute the command, then we can go to the next state. nextState, end := it.getNextState(it.currentState) if end { - it.setState(it.originState) + it.SetState(it.originState) return nil } - it.setState(nextState) + it.SetState(nextState) return nil } @@ -186,7 +194,19 @@ func (it *Interact) init() error { return err } - for n, cmd := range it.commands { + if err := it.registerCommands(it.commands); err != nil { + return err + } + + if err := it.registerCommands(it.privateCommands); err != nil { + return err + } + + return nil +} + +func (it *Interact) registerCommands(commands map[string]*Command) error { + for n, cmd := range commands { for s1, s2 := range cmd.states { if _, exist := it.states[s1]; exist { return fmt.Errorf("state %s already exists", s1) @@ -209,7 +229,6 @@ func (it *Interact) init() error { return it.runCommand(commandName, args, append(ctxObjects, reply)...) }) } - return nil } @@ -218,6 +237,16 @@ func (it *Interact) Start(ctx context.Context) error { return err } + for _, custom := range it.customInteractions { + log.Infof("checking %T custom interaction...", custom) + if initializer, ok := custom.(Initializer); ok { + log.Infof("initializing %T custom interaction...", custom) + if err := initializer.Initialize(); err != nil { + return err + } + } + } + // TODO: use go routine and context it.messenger.Start(ctx) return nil diff --git a/pkg/interact/parse.go b/pkg/interact/parse.go index 54e2fbf7f..8d60a99d2 100644 --- a/pkg/interact/parse.go +++ b/pkg/interact/parse.go @@ -6,6 +6,8 @@ import ( "strconv" "strings" "text/scanner" + + "github.com/mattn/go-shellwords" ) func parseFuncArgsAndCall(f interface{}, args []string, objects ...interface{}) (State, error) { @@ -112,6 +114,13 @@ func parseFuncArgsAndCall(f interface{}, args []string, objects ...interface{}) } func parseCommand(src string) (args []string) { + var err error + args, err = shellwords.Parse(src) + if err == nil { + return args + } + + // fallback to go text/scanner var s scanner.Scanner s.Init(strings.NewReader(src)) s.Filename = "command" @@ -125,4 +134,3 @@ func parseCommand(src string) (args []string) { return args } - diff --git a/pkg/interact/reply.go b/pkg/interact/reply.go index 779e093a6..e7fe2b2a9 100644 --- a/pkg/interact/reply.go +++ b/pkg/interact/reply.go @@ -1,6 +1,7 @@ package interact type Reply interface { + Send(message string) Message(message string) AddButton(text string) RemoveKeyboard() diff --git a/pkg/interact/telegram.go b/pkg/interact/telegram.go index c5a0e1b10..42eaffb89 100644 --- a/pkg/interact/telegram.go +++ b/pkg/interact/telegram.go @@ -11,13 +11,19 @@ import ( ) type TelegramReply struct { - bot *telebot.Bot + bot *telebot.Bot + chat *telebot.Chat + message string menu *telebot.ReplyMarkup buttons [][]telebot.Btn set bool } +func (r *TelegramReply) Send(message string) { + checkSendErr(r.bot.Send(r.chat, message)) +} + func (r *TelegramReply) Message(message string) { r.message = message r.set = true @@ -115,7 +121,7 @@ func (tm *Telegram) Start(context.Context) { } authorizer := tm.newAuthorizer(m) - reply := tm.newReply() + reply := tm.newReply(m) if tm.textMessageResponder != nil { if err := tm.textMessageResponder(m.Text, reply, authorizer); err != nil { log.WithError(err).Errorf("[telegram] response handling error") @@ -148,30 +154,35 @@ func (tm *Telegram) Start(context.Context) { go tm.Bot.Start() } +func checkSendErr(m *telebot.Message, err error) { + if err != nil { + log.WithError(err).Errorf("[telegram] message send error") + } +} + func (tm *Telegram) AddCommand(cmd *Command, responder Responder) { tm.commands = append(tm.commands, cmd) tm.Bot.Handle(cmd.Name, func(m *telebot.Message) { authorizer := tm.newAuthorizer(m) - reply := tm.newReply() + reply := tm.newReply(m) if err := responder(m.Payload, reply, authorizer); err != nil { log.WithError(err).Errorf("[telegram] responder error") - tm.Bot.Send(m.Sender, fmt.Sprintf("error: %v", err)) + checkSendErr(tm.Bot.Send(m.Sender, fmt.Sprintf("error: %v", err))) return } // build up the response objects if reply.set { reply.build() - if _, err := tm.Bot.Send(m.Sender, reply.message, reply.menu); err != nil { - log.WithError(err).Errorf("[telegram] message send error") - } + checkSendErr(tm.Bot.Send(m.Sender, reply.message, reply.menu)) } }) } -func (tm *Telegram) newReply() *TelegramReply { +func (tm *Telegram) newReply(m *telebot.Message) *TelegramReply { return &TelegramReply{ bot: tm.Bot, + chat: m.Chat, menu: &telebot.ReplyMarkup{ResizeReplyKeyboard: true}, } } diff --git a/pkg/strategy/bollmaker/strategy.go b/pkg/strategy/bollmaker/strategy.go index 9707d3696..19c3a3970 100644 --- a/pkg/strategy/bollmaker/strategy.go +++ b/pkg/strategy/bollmaker/strategy.go @@ -51,19 +51,19 @@ type Strategy struct { StandardIndicatorSet *bbgo.StandardIndicatorSet // Symbol is the market symbol you want to trade - Symbol string `json:"symbol"` + Symbol string `json:"symbol"` // Interval is how long do you want to update your order price and quantity - Interval types.Interval `json:"interval"` + Interval types.Interval `json:"interval"` // Quantity is the base order quantity for your buy/sell order. - Quantity fixedpoint.Value `json:"quantity"` + Quantity fixedpoint.Value `json:"quantity"` // Spread is the price spread from the middle price. // For ask orders, the ask price is ((bestAsk + bestBid) / 2 * (1.0 + spread)) // For bid orders, the bid price is ((bestAsk + bestBid) / 2 * (1.0 - spread)) // Spread can be set by percentage or floating number. e.g., 0.1% or 0.001 - Spread fixedpoint.Value `json:"spread"` + Spread fixedpoint.Value `json:"spread"` // MinProfitSpread is the minimal order price spread from the current average cost. // For long position, you will only place sell order above the price (= average cost * (1 + minProfitSpread)) @@ -73,7 +73,7 @@ type Strategy struct { // UseTickerPrice use the ticker api to get the mid price instead of the closed kline price. // The back-test engine is kline-based, so the ticker price api is not supported. // Turn this on if you want to do real trading. - UseTickerPrice bool `json:"useTickerPrice"` + UseTickerPrice bool `json:"useTickerPrice"` // MaxExposurePosition is the maximum position you can hold // +10 means you can hold 10 ETH long position by maximum @@ -100,7 +100,7 @@ type Strategy struct { // when the bollinger band detect a strong uptrend, what's the order quantity skew we want to use. // greater than 1.0 means when placing buy order, place sell order with less quantity // less than 1.0 means when placing sell order, place buy order with less quantity - StrongUptrendSkew fixedpoint.Value `json:"strongUptrendSkew"` + StrongUptrendSkew fixedpoint.Value `json:"strongUptrendSkew"` // DowntrendSkew is the order quantity skew for normal downtrend band. // The price is still in the default bollinger band. @@ -112,7 +112,7 @@ type Strategy struct { // The price is still in the default bollinger band. // greater than 1.0 means when placing buy order, place sell order with less quantity // less than 1.0 means when placing sell order, place buy order with less quantity - UptrendSkew fixedpoint.Value `json:"uptrendSkew"` + UptrendSkew fixedpoint.Value `json:"uptrendSkew"` session *bbgo.ExchangeSession book *types.StreamOrderBook @@ -166,6 +166,47 @@ func (s *Strategy) Validate() error { return nil } +func (s *Strategy) CurrentPosition() *types.Position { + return s.state.Position +} + +func (s *Strategy) ClosePosition(ctx context.Context, percentage float64) error { + base := s.state.Position.GetBase() + if base == 0 { + return fmt.Errorf("no opened %s position", s.state.Position.Symbol) + } + + // make it negative + quantity := base.MulFloat64(percentage).Abs() + side := types.SideTypeBuy + if base > 0 { + side = types.SideTypeSell + } + + if quantity.Float64() < s.market.MinQuantity { + return fmt.Errorf("order quantity %f is too small, less than %f", quantity.Float64(), s.market.MinQuantity) + } + + submitOrder := types.SubmitOrder{ + Symbol: s.Symbol, + Side: side, + Type: types.OrderTypeMarket, + Quantity: quantity.Float64(), + Market: s.market, + } + + s.Notify("Submitting %s %s order to close position by %f", s.Symbol, side.String(), percentage, submitOrder) + + createdOrders, err := s.session.Exchange.SubmitOrders(ctx, submitOrder) + if err != nil { + log.WithError(err).Errorf("can not place position close order") + } + + s.orderStore.Add(createdOrders...) + s.activeMakerOrders.Add(createdOrders...) + return err +} + func (s *Strategy) SaveState() error { if err := s.Persistence.Save(s.state, ID, s.Symbol, stateKey); err != nil { return err