implement position closer interaction

This commit is contained in:
c9s 2022-01-15 02:52:46 +08:00
parent 77c2a6e10b
commit 93722e6db3
12 changed files with 337 additions and 33 deletions

1
go.mod
View File

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

2
go.sum
View File

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

View File

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

172
pkg/bbgo/interact.go Normal file
View File

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

28
pkg/bbgo/interact_test.go Normal file
View File

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

View File

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

View File

@ -13,7 +13,7 @@ func SetMessenger(messenger Messenger) {
}
func AddCustomInteraction(custom CustomInteraction) {
custom.Commands(defaultInteraction)
defaultInteraction.AddCustomInteraction(custom)
}
func Start(ctx context.Context) error {

View File

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

View File

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

View File

@ -1,6 +1,7 @@
package interact
type Reply interface {
Send(message string)
Message(message string)
AddButton(text string)
RemoveKeyboard()

View File

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

View File

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