package server import ( "context" "fmt" "math/rand" "net" "net/http" "os" "strconv" "time" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" "github.com/joho/godotenv" "github.com/sirupsen/logrus" "github.com/c9s/bbgo/pkg/bbgo" "github.com/c9s/bbgo/pkg/fixedpoint" "github.com/c9s/bbgo/pkg/service" "github.com/c9s/bbgo/pkg/types" ) const DefaultBindAddress = "localhost:8080" type Setup struct { // Context is the trader context Context context.Context // Cancel is the trader context cancel function you want to cancel Cancel context.CancelFunc // Token is used for setup api authentication Token string BeforeRestart func() } type Server struct { Config *bbgo.Config Environ *bbgo.Environment Trader *bbgo.Trader Setup *Setup OpenInBrowser bool srv *http.Server } func (s *Server) newEngine(ctx context.Context) *gin.Engine { r := gin.Default() r.Use(cors.New(cors.Config{ AllowOrigins: []string{"*"}, AllowHeaders: []string{"Origin", "Content-Type"}, ExposeHeaders: []string{"Content-Length"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE"}, AllowWebSockets: true, AllowCredentials: true, MaxAge: 12 * time.Hour, })) r.GET("/api/ping", s.ping) if s.Setup != nil { r.POST("/api/setup/test-db", s.setupTestDB) r.POST("/api/setup/configure-db", s.setupConfigureDB) r.POST("/api/setup/strategy/single/:id/session/:session", s.setupAddStrategy) r.POST("/api/setup/save", s.setupSaveConfig) r.POST("/api/setup/restart", s.setupRestart) } r.GET("/api/environment/syncing", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "syncing": s.Environ.IsSyncing(), }) }) r.POST("/api/environment/sync", func(c *gin.Context) { if s.Environ.IsSyncing() != bbgo.Syncing { go func() { // We use the root context here because the syncing operation is a background goroutine. // It should not be terminated if the request is disconnected. if err := s.Environ.Sync(ctx); err != nil { logrus.WithError(err).Error("sync error") } }() } c.JSON(http.StatusOK, gin.H{ "success": true, }) }) r.GET("/api/outbound-ip", func(c *gin.Context) { outboundIP, err := GetOutboundIP() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": err.Error(), }) } c.JSON(http.StatusOK, gin.H{ "outboundIP": outboundIP.String(), }) }) r.GET("/api/trades", func(c *gin.Context) { if s.Environ.TradeService == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database is not configured"}) return } exchange := c.Query("exchange") symbol := c.Query("symbol") gidStr := c.DefaultQuery("gid", "0") lastGID, err := strconv.ParseInt(gidStr, 10, 64) if err != nil { logrus.WithError(err).Error("last gid parse error") c.Status(http.StatusBadRequest) return } trades, err := s.Environ.TradeService.Query(service.QueryTradesOptions{ Exchange: types.ExchangeName(exchange), Symbol: symbol, LastGID: lastGID, Ordering: "DESC", }) if err != nil { c.Status(http.StatusBadRequest) logrus.WithError(err).Error("order query error") return } c.JSON(http.StatusOK, gin.H{ "trades": trades, }) }) r.GET("/api/orders/closed", s.listClosedOrders) r.GET("/api/trading-volume", s.tradingVolume) r.POST("/api/sessions/test", func(c *gin.Context) { var session bbgo.ExchangeSession if err := c.BindJSON(&session); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": err.Error(), }) return } err := session.InitExchange(session.ExchangeName.String(), nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": err.Error(), }) return } var anyErr error _, openOrdersErr := session.Exchange.QueryOpenOrders(c, "BTCUSDT") if openOrdersErr != nil { anyErr = openOrdersErr } _, balanceErr := session.Exchange.QueryAccountBalances(c) if balanceErr != nil { anyErr = balanceErr } c.JSON(http.StatusOK, gin.H{ "success": anyErr == nil, "error": anyErr, "balance": balanceErr == nil, "openOrders": openOrdersErr == nil, }) }) r.GET("/api/sessions", func(c *gin.Context) { var sessions []*bbgo.ExchangeSession for _, session := range s.Environ.Sessions() { sessions = append(sessions, session) } if len(sessions) == 0 { c.JSON(http.StatusOK, gin.H{"sessions": []int{}}) } c.JSON(http.StatusOK, gin.H{"sessions": sessions}) }) r.POST("/api/sessions", func(c *gin.Context) { var session bbgo.ExchangeSession if err := c.BindJSON(&session); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": err.Error(), }) return } if err := session.InitExchange(session.ExchangeName.String(), nil); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": err.Error(), }) return } if s.Config.Sessions == nil { s.Config.Sessions = make(map[string]*bbgo.ExchangeSession) } s.Config.Sessions[session.Name] = &session s.Environ.AddExchangeSession(session.Name, &session) if err := session.Init(c, s.Environ); err != nil { c.JSON(http.StatusInternalServerError, gin.H{ "error": err.Error(), }) return } c.JSON(http.StatusOK, gin.H{"success": true}) }) r.GET("/api/assets", s.listAssets) r.GET("/api/sessions/:session", s.listSessions) r.GET("/api/sessions/:session/trades", s.listSessionTrades) r.GET("/api/sessions/:session/open-orders", s.listSessionOpenOrders) r.GET("/api/sessions/:session/account", s.getSessionAccount) r.GET("/api/sessions/:session/account/balances", s.getSessionAccountBalance) r.GET("/api/sessions/:session/symbols", s.listSessionSymbols) r.GET("/api/sessions/:session/pnl", func(c *gin.Context) { c.JSON(200, gin.H{"message": "pong"}) }) r.GET("/api/sessions/:session/market/:symbol/open-orders", func(c *gin.Context) { c.JSON(200, gin.H{"message": "pong"}) }) r.GET("/api/sessions/:session/market/:symbol/trades", func(c *gin.Context) { c.JSON(200, gin.H{"message": "pong"}) }) r.GET("/api/sessions/:session/market/:symbol/pnl", func(c *gin.Context) { c.JSON(200, gin.H{"message": "pong"}) }) r.GET("/api/strategies/single", s.listStrategies) r.NoRoute(s.assetsHandler) return r } func (s *Server) RunWithListener(ctx context.Context, l net.Listener) error { r := s.newEngine(ctx) bind := l.Addr().String() if s.OpenInBrowser { openBrowser(ctx, bind) } s.srv = newServer(r, bind) return serve(s.srv, l) } func (s *Server) Run(ctx context.Context, bindArgs ...string) error { r := s.newEngine(ctx) bind := resolveBind(bindArgs) if s.OpenInBrowser { openBrowser(ctx, bind) } s.srv = newServer(r, bind) return listenAndServe(s.srv) } func (s *Server) ping(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "pong"}) } func (s *Server) listClosedOrders(c *gin.Context) { if s.Environ.OrderService == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database is not configured"}) return } exchange := c.Query("exchange") symbol := c.Query("symbol") gidStr := c.DefaultQuery("gid", "0") lastGID, err := strconv.ParseInt(gidStr, 10, 64) if err != nil { logrus.WithError(err).Error("last gid parse error") c.Status(http.StatusBadRequest) return } orders, err := s.Environ.OrderService.Query(service.QueryOrdersOptions{ Exchange: types.ExchangeName(exchange), Symbol: symbol, LastGID: lastGID, Ordering: "DESC", }) if err != nil { c.Status(http.StatusBadRequest) logrus.WithError(err).Error("order query error") return } c.JSON(http.StatusOK, gin.H{ "orders": orders, }) } func (s *Server) listStrategies(c *gin.Context) { var stashes []map[string]interface{} for _, mount := range s.Config.ExchangeStrategies { stash, err := mount.Map() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } stash["strategy"] = mount.Strategy.ID() stashes = append(stashes, stash) } if len(stashes) == 0 { c.JSON(http.StatusOK, gin.H{"strategies": []int{}}) } c.JSON(http.StatusOK, gin.H{"strategies": stashes}) } func (s *Server) listSessions(c *gin.Context) { sessionName := c.Param("session") session, ok := s.Environ.Session(sessionName) if !ok { c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("session %s not found", sessionName)}) return } c.JSON(http.StatusOK, gin.H{"session": session}) } func (s *Server) listSessionSymbols(c *gin.Context) { sessionName := c.Param("session") session, ok := s.Environ.Session(sessionName) if !ok { c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("session %s not found", sessionName)}) return } var symbols []string for symbol := range session.Markets() { symbols = append(symbols, symbol) } c.JSON(http.StatusOK, gin.H{"symbols": symbols}) } func (s *Server) listSessionTrades(c *gin.Context) { sessionName := c.Param("session") session, ok := s.Environ.Session(sessionName) if !ok { c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("session %s not found", sessionName)}) return } c.JSON(http.StatusOK, gin.H{"trades": session.Trades}) } func (s *Server) getSessionAccount(c *gin.Context) { sessionName := c.Param("session") session, ok := s.Environ.Session(sessionName) if !ok { c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("session %s not found", sessionName)}) return } c.JSON(http.StatusOK, gin.H{"account": session.GetAccount()}) } func (s *Server) getSessionAccountBalance(c *gin.Context) { sessionName := c.Param("session") session, ok := s.Environ.Session(sessionName) if !ok { c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("session %s not found", sessionName)}) return } if session.Account == nil { c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("the account of session %s is nil", sessionName)}) return } c.JSON(http.StatusOK, gin.H{"balances": session.GetAccount().Balances()}) } func (s *Server) listSessionOpenOrders(c *gin.Context) { // FIXME c.JSON(http.StatusOK, gin.H{"orders": nil}) } func genFakeAssets() types.AssetMap { totalAssets := types.AssetMap{} balances := types.BalanceMap{ "BTC": types.Balance{Currency: "BTC", Available: fixedpoint.NewFromFloat(10.0 * rand.Float64())}, "BCH": types.Balance{Currency: "BCH", Available: fixedpoint.NewFromFloat(0.01 * rand.Float64())}, "LTC": types.Balance{Currency: "LTC", Available: fixedpoint.NewFromFloat(200.0 * rand.Float64())}, "ETH": types.Balance{Currency: "ETH", Available: fixedpoint.NewFromFloat(50.0 * rand.Float64())}, "SAND": types.Balance{Currency: "SAND", Available: fixedpoint.NewFromFloat(11500.0 * rand.Float64())}, "BNB": types.Balance{Currency: "BNB", Available: fixedpoint.NewFromFloat(1000.0 * rand.Float64())}, "GRT": types.Balance{Currency: "GRT", Available: fixedpoint.NewFromFloat(1000.0 * rand.Float64())}, "MAX": types.Balance{Currency: "MAX", Available: fixedpoint.NewFromFloat(200000.0 * rand.Float64())}, "COMP": types.Balance{Currency: "COMP", Available: fixedpoint.NewFromFloat(100.0 * rand.Float64())}, } assets := balances.Assets(map[string]fixedpoint.Value{ "BTCUSDT": fixedpoint.NewFromFloat(38000.0), "BCHUSDT": fixedpoint.NewFromFloat(478.0), "LTCUSDT": fixedpoint.NewFromFloat(150.0), "COMPUSDT": fixedpoint.NewFromFloat(450.0), "ETHUSDT": fixedpoint.NewFromFloat(1700.0), "BNBUSDT": fixedpoint.NewFromFloat(70.0), "GRTUSDT": fixedpoint.NewFromFloat(0.89), "DOTUSDT": fixedpoint.NewFromFloat(20.0), "SANDUSDT": fixedpoint.NewFromFloat(0.13), "MAXUSDT": fixedpoint.NewFromFloat(0.122), }, time.Now()) for currency, asset := range assets { totalAssets[currency] = asset } return totalAssets } func (s *Server) listAssets(c *gin.Context) { if ok, err := strconv.ParseBool(os.Getenv("USE_FAKE_ASSETS")); err == nil && ok { c.JSON(http.StatusOK, gin.H{"assets": genFakeAssets()}) return } totalAssets := types.AssetMap{} for _, session := range s.Environ.Sessions() { balances := session.GetAccount().Balances() if err := session.UpdatePrices(c, balances.Currencies(), "USDT"); err != nil { logrus.WithError(err).Error("price update failed") c.Status(http.StatusInternalServerError) return } assets := balances.Assets(session.LastPrices(), time.Now()) for currency, asset := range assets { totalAssets[currency] = asset } } c.JSON(http.StatusOK, gin.H{"assets": totalAssets}) } func (s *Server) setupSaveConfig(c *gin.Context) { if len(s.Config.Sessions) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "session is not configured"}) return } envVars, err := collectSessionEnvVars(s.Config.Sessions) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if s.Environ.DatabaseService != nil { envVars["DB_DRIVER"] = s.Environ.DatabaseService.Driver envVars["DB_DSN"] = s.Environ.DatabaseService.DSN } dotenvFile := ".env.local" if err := moveFileToBackup(dotenvFile); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if err := godotenv.Write(envVars, dotenvFile); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } out, err := s.Config.YAML() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } fmt.Println("config file") fmt.Println("=================================================") fmt.Println(string(out)) fmt.Println("=================================================") filename := "bbgo.yaml" if err := moveFileToBackup(filename); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if err := os.WriteFile(filename, out, 0666); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"success": true}) } // var pageRoutePattern = regexp.MustCompile("/[a-z]+$") func moveFileToBackup(filename string) error { stat, err := os.Stat(filename) if err == nil && stat != nil { err := os.Rename(filename, filename+"."+time.Now().Format("20060102_150405_07_00")) if err != nil { return err } } return nil } func (s *Server) tradingVolume(c *gin.Context) { if s.Environ.TradeService == nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "database is not configured"}) return } period := c.DefaultQuery("period", "day") segment := c.DefaultQuery("segment", "exchange") startTimeStr := c.Query("start-time") var startTime time.Time if startTimeStr != "" { v, err := time.Parse(time.RFC3339, startTimeStr) if err != nil { c.Status(http.StatusBadRequest) logrus.WithError(err).Error("start-time format incorrect") return } startTime = v } else { switch period { case "day": startTime = time.Now().AddDate(0, 0, -30) case "month": startTime = time.Now().AddDate(0, -6, 0) case "year": startTime = time.Now().AddDate(-2, 0, 0) default: startTime = time.Now().AddDate(0, 0, -7) } } rows, err := s.Environ.TradeService.QueryTradingVolume(startTime, service.TradingVolumeQueryOptions{ SegmentBy: segment, GroupByPeriod: period, }) if err != nil { logrus.WithError(err).Error("trading volume query error") c.Status(http.StatusInternalServerError) return } c.JSON(http.StatusOK, gin.H{"tradingVolumes": rows}) } func newServer(r http.Handler, bind string) *http.Server { return &http.Server{ Addr: bind, Handler: r, } } func serve(srv *http.Server, l net.Listener) (err error) { defer func() { if err != nil && err != http.ErrServerClosed { logrus.WithError(err).Error("unexpected http server error") } }() err = srv.Serve(l) if err != http.ErrServerClosed { return err } return nil } func listenAndServe(srv *http.Server) error { var err error defer func() { if err != nil && err != http.ErrServerClosed { logrus.WithError(err).Error("unexpected http server error") } }() err = srv.ListenAndServe() if err != http.ErrServerClosed { return err } return nil } func GetOutboundIP() (net.IP, error) { conn, err := net.Dial("udp", "8.8.8.8:80") if err != nil { return nil, err } defer conn.Close() localAddr := conn.LocalAddr().(*net.UDPAddr) return localAddr.IP, nil }