diff --git a/frontend/api/bbgo.js b/frontend/api/bbgo.js index 486c0fde6..8ab737ecf 100644 --- a/frontend/api/bbgo.js +++ b/frontend/api/bbgo.js @@ -2,6 +2,16 @@ import axios from "axios"; const baseURL = process.env.NODE_ENV === "development" ? "http://localhost:8080" : "" +export function testSessionConnection(data, cb) { + return axios.post(baseURL + '/api/sessions/test-connection', data, { + headers: { + 'Content-Type': 'application/json', + } + }).then(response => { + cb(response.data) + }); +} + export function querySessions(cb) { axios.get(baseURL + '/api/sessions', {}) .then(response => { @@ -10,14 +20,14 @@ export function querySessions(cb) { } export function queryTrades(params, cb) { - axios.get(baseURL + '/api/trades', { params: params }) + axios.get(baseURL + '/api/trades', {params: params}) .then(response => { cb(response.data.trades) }); } export function queryClosedOrders(params, cb) { - axios.get(baseURL + '/api/orders/closed', { params: params }) + axios.get(baseURL + '/api/orders/closed', {params: params}) .then(response => { cb(response.data.orders) }); @@ -31,7 +41,7 @@ export function queryAssets(cb) { } export function queryTradingVolume(params, cb) { - axios.get(baseURL + '/api/trading-volume', { params: params }) + axios.get(baseURL + '/api/trading-volume', {params: params}) .then(response => { cb(response.data.tradingVolumes) }); diff --git a/frontend/components/ExchangeSessionForm.js b/frontend/components/ExchangeSessionForm.js index fca9d2743..a3ec09606 100644 --- a/frontend/components/ExchangeSessionForm.js +++ b/frontend/components/ExchangeSessionForm.js @@ -1,8 +1,11 @@ import React from 'react'; import Grid from '@material-ui/core/Grid'; +import Box from '@material-ui/core/Box'; +import Button from '@material-ui/core/Button'; import Typography from '@material-ui/core/Typography'; import TextField from '@material-ui/core/TextField'; import FormControlLabel from '@material-ui/core/FormControlLabel'; +import FormHelperText from '@material-ui/core/FormHelperText'; import InputLabel from '@material-ui/core/InputLabel'; import FormControl from '@material-ui/core/FormControl'; @@ -10,6 +13,10 @@ import Checkbox from '@material-ui/core/Checkbox'; import Select from '@material-ui/core/Select'; import MenuItem from '@material-ui/core/MenuItem'; +import Alert from '@material-ui/lab/Alert'; + +import {testSessionConnection} from '../api/bbgo'; + import {makeStyles} from '@material-ui/core/styles'; const useStyles = makeStyles((theme) => ({ @@ -18,28 +25,69 @@ const useStyles = makeStyles((theme) => ({ marginBottom: theme.spacing(1), minWidth: 120, }, + buttons: { + display: 'flex', + justifyContent: 'flex-end', + marginTop: theme.spacing(2), + paddingTop: theme.spacing(2), + paddingBottom: theme.spacing(2), + '& > *': { + marginLeft: theme.spacing(1), + } + }, })); export default function ExchangeSessionForm() { - const classes = useStyles(); const [exchangeType, setExchangeType] = React.useState('max'); + const [customSessionName, setCustomSessionName] = React.useState(false); + const [sessionName, setSessionName] = React.useState(exchangeType); + const [testing, setTesting] = React.useState(false); + const [testResponse, setTestResponse] = React.useState(null); + + const [apiKey, setApiKey] = React.useState(''); + const [apiSecret, setApiSecret] = React.useState(''); const [isMargin, setIsMargin] = React.useState(false); const [isIsolatedMargin, setIsIsolatedMargin] = React.useState(false); const [isolatedMarginSymbol, setIsolatedMarginSymbol] = React.useState(""); + const resetTestResponse = () => { + setTestResponse(null) + } + const handleExchangeTypeChange = (event) => { setExchangeType(event.target.value); + setSessionName(event.target.value); + resetTestResponse() }; - const handleIsMarginChange = (event) => { - setIsMargin(event.target.checked); - }; + const createSessionConfig = () => { + return { + name: sessionName, + exchange: exchangeType, + key: apiKey, + secret: apiSecret, + margin: isMargin, + envVarPrefix: exchangeType.toUpperCase() + "_", + isolatedMargin: isIsolatedMargin, + isolatedMarginSymbol: isolatedMarginSymbol, + } + } - const handleIsIsolatedMarginChange = (event) => { - setIsIsolatedMargin(event.target.checked); + const handleTestConnection = (event) => { + const payload = createSessionConfig() + setTesting(true) + testSessionConnection(payload, (response) => { + console.log(response) + setTesting(false) + setTestResponse(response) + }).catch((reason) => { + console.error(reason) + setTesting(false) + setTestResponse(reason) + }) }; return ( @@ -64,71 +112,125 @@ export default function ExchangeSessionForm() { - + { + setSessionName(event.target.value) + }} + value={sessionName} /> - - - - { + setCustomSessionName(event.target.checked); + }} value="1"/>} + label="Custom exchange session name" /> + + By default, the session name will be the exchange type name, + e.g. binance or max.
+ If you're using multiple exchange sessions, you might need to custom the session name.
+ This is for advanced users. +
- - + { + setApiKey(event.target.value) + resetTestResponse() + }} /> - } - label="Use margin trading. This is only available for Binance" + { + setApiSecret(event.target.value) + resetTestResponse() + }} /> - - } - label="Use isolated margin trading, if this is set, you can only trade one symbol with one session. This is only available for Binance" - /> - - - {isIsolatedMargin ? + {exchangeType === "binance" ? ( - { + setIsMargin(event.target.checked); + resetTestResponse(); + }} value="1"/>} + label="Use margin trading." /> + This is only available for Binance. Please use the leverage at your own risk. + + { + setIsIsolatedMargin(event.target.checked); + resetTestResponse() + }} value="1"/>} + label="Use isolated margin trading." + /> + This is only available for Binance. If this is set, you can only trade one symbol with one session. + + {isIsolatedMargin ? + { + setIsolatedMarginSymbol(event.target.value); + resetTestResponse() + }} + fullWidth + required + /> + : null} - : null} - - + ) : null}
+ +
+ + + +
+ + { + testResponse ? testResponse.error ? ( + + {testResponse.error} + + ) : testResponse.success ? ( + + Connection Test Succeeded + + ) : null : null + } + + ); } diff --git a/frontend/package.json b/frontend/package.json index 9acbf71d1..896384d81 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "@material-ui/core": "^4.11.2", "@material-ui/data-grid": "^4.0.0-alpha.18", "@material-ui/icons": "^4.11.2", + "@material-ui/lab": "^4.0.0-alpha.57", "@nivo/bar": "^0.67.0", "@nivo/core": "^0.67.0", "@nivo/pie": "^0.67.0", diff --git a/frontend/pages/setup/session.js b/frontend/pages/setup/index.js similarity index 94% rename from frontend/pages/setup/session.js rename to frontend/pages/setup/index.js index 6d1b9c62d..6015b30c9 100644 --- a/frontend/pages/setup/session.js +++ b/frontend/pages/setup/index.js @@ -20,7 +20,7 @@ const useStyles = makeStyles((theme) => ({ }, })); -const steps = ['Add Exchange Session', 'Review Settings', 'Test Connection']; +const steps = ['Add Exchange Session', 'Configure Strategy', 'Restart']; function getStepContent(step) { switch (step) { @@ -35,7 +35,7 @@ function getStepContent(step) { } } -export default function SetupSession() { +export default function Setup() { const classes = useStyles(); const [activeStep, setActiveStep] = React.useState(0); diff --git a/frontend/yarn.lock b/frontend/yarn.lock index fb8257897..451828704 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -159,6 +159,17 @@ dependencies: "@babel/runtime" "^7.4.4" +"@material-ui/lab@^4.0.0-alpha.57": + version "4.0.0-alpha.57" + resolved "https://registry.yarnpkg.com/@material-ui/lab/-/lab-4.0.0-alpha.57.tgz#e8961bcf6449e8a8dabe84f2700daacfcafbf83a" + integrity sha512-qo/IuIQOmEKtzmRD2E4Aa6DB4A87kmY6h0uYhjUmrrgmEAgbbw9etXpWPVXuRK6AGIQCjFzV6WO2i21m1R4FCw== + dependencies: + "@babel/runtime" "^7.4.4" + "@material-ui/utils" "^4.11.2" + clsx "^1.0.4" + prop-types "^15.7.2" + react-is "^16.8.0 || ^17.0.0" + "@material-ui/styles@^4.11.2": version "4.11.2" resolved "https://registry.yarnpkg.com/@material-ui/styles/-/styles-4.11.2.tgz#e70558be3f41719e8c0d63c7a3c9ae163fdc84cb" diff --git a/pkg/bbgo/config.go b/pkg/bbgo/config.go index cb6f703fb..57cac009f 100644 --- a/pkg/bbgo/config.go +++ b/pkg/bbgo/config.go @@ -53,10 +53,15 @@ type NotificationConfig struct { } type Session struct { - ExchangeName string `json:"exchange" yaml:"exchange"` - EnvVarPrefix string `json:"envVarPrefix" yaml:"envVarPrefix"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + ExchangeName string `json:"exchange" yaml:"exchange"` + EnvVarPrefix string `json:"envVarPrefix" yaml:"envVarPrefix"` + + Key string `json:"key,omitempty" yaml:"key,omitempty"` + Secret string `json:"secret,omitempty" yaml:"secret,omitempty"` + PublicOnly bool `json:"publicOnly,omitempty" yaml:"publicOnly"` - Margin bool `json:"margin,omitempty" yaml:"margin"` + Margin bool `json:"margin,omitempty" yaml:"margin,omitempty"` IsolatedMargin bool `json:"isolatedMargin,omitempty" yaml:"isolatedMargin,omitempty"` IsolatedMarginSymbol string `json:"isolatedMarginSymbol,omitempty" yaml:"isolatedMarginSymbol,omitempty"` } diff --git a/pkg/bbgo/environment.go b/pkg/bbgo/environment.go index 02ce5a4c0..c871e9574 100644 --- a/pkg/bbgo/environment.go +++ b/pkg/bbgo/environment.go @@ -142,36 +142,52 @@ func (environ *Environment) AddExchangesByViperKeys() error { return nil } +func NewExchangeSessionFromConfig(name string, sessionConfig Session) (*ExchangeSession, error) { + exchangeName, err := types.ValidExchangeName(sessionConfig.ExchangeName) + if err != nil { + return nil, err + } + + var exchange types.Exchange + + if sessionConfig.Key != "" && sessionConfig.Secret != "" { + exchange, err = cmdutil.NewExchangeStandard(exchangeName, sessionConfig.Key, sessionConfig.Secret) + } else { + exchange, err = cmdutil.NewExchangeWithEnvVarPrefix(exchangeName, sessionConfig.EnvVarPrefix) + } + + if err != nil { + return nil, err + } + + // configure exchange + if sessionConfig.Margin { + marginExchange, ok := exchange.(types.MarginExchange) + if !ok { + return nil, fmt.Errorf("exchange %s does not support margin", exchangeName) + } + + if sessionConfig.IsolatedMargin { + marginExchange.UseIsolatedMargin(sessionConfig.IsolatedMarginSymbol) + } else { + marginExchange.UseMargin() + } + } + + session := NewExchangeSession(name, exchange) + session.IsMargin = sessionConfig.Margin + session.IsIsolatedMargin = sessionConfig.IsolatedMargin + session.IsolatedMarginSymbol = sessionConfig.IsolatedMarginSymbol + return session, nil +} + func (environ *Environment) AddExchangesFromSessionConfig(sessions map[string]Session) error { for sessionName, sessionConfig := range sessions { - exchangeName, err := types.ValidExchangeName(sessionConfig.ExchangeName) + session, err := NewExchangeSessionFromConfig(sessionName, sessionConfig) if err != nil { return err } - exchange, err := cmdutil.NewExchangeWithEnvVarPrefix(exchangeName, sessionConfig.EnvVarPrefix) - if err != nil { - return err - } - - // configure exchange - if sessionConfig.Margin { - marginExchange, ok := exchange.(types.MarginExchange) - if !ok { - return fmt.Errorf("exchange %s does not support margin", exchangeName) - } - - if sessionConfig.IsolatedMargin { - marginExchange.UseIsolatedMargin(sessionConfig.IsolatedMarginSymbol) - } else { - marginExchange.UseMargin() - } - } - - session := NewExchangeSession(sessionName, exchange) - session.IsMargin = sessionConfig.Margin - session.IsIsolatedMargin = sessionConfig.IsolatedMargin - session.IsolatedMarginSymbol = sessionConfig.IsolatedMarginSymbol environ.AddExchangeSession(sessionName, session) } @@ -188,7 +204,6 @@ func (environ *Environment) Init(ctx context.Context) (err error) { for n := range environ.sessions { var session = environ.sessions[n] - if err := session.Init(ctx, environ); err != nil { return err } diff --git a/pkg/bbgo/server.go b/pkg/bbgo/server.go index 77efecc59..34430933c 100644 --- a/pkg/bbgo/server.go +++ b/pkg/bbgo/server.go @@ -16,12 +16,14 @@ import ( "github.com/c9s/bbgo/pkg/types" ) -func RunServer(ctx context.Context, userConfig *Config, environ *Environment) error { +func RunServer(ctx context.Context, userConfig *Config, environ *Environment, trader *Trader) error { r := gin.Default() r.Use(cors.New(cors.Config{ AllowOrigins: []string{"*"}, - AllowHeaders: []string{"Origin"}, + AllowHeaders: []string{"Origin", "Content-Type"}, ExposeHeaders: []string{"Content-Length"}, + AllowMethods: []string{"GET", "POST"}, + AllowWebSockets: true, AllowCredentials: true, MaxAge: 12 * time.Hour, })) @@ -134,6 +136,42 @@ func RunServer(ctx context.Context, userConfig *Config, environ *Environment) er return }) + r.POST("/api/sessions/test-connection", func(c *gin.Context) { + var sessionConfig Session + if err := c.BindJSON(&sessionConfig); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + session, err := NewExchangeSessionFromConfig(sessionConfig.ExchangeName, sessionConfig) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": err.Error(), + }) + return + } + + var anyErr error + _, openOrdersErr := session.Exchange.QueryOpenOrders(ctx, "BTCUSDT") + if openOrdersErr != nil { + anyErr = openOrdersErr + } + + _, balanceErr := session.Exchange.QueryAccountBalances(ctx) + 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 []*ExchangeSession for _, session := range environ.Sessions() { diff --git a/pkg/cmd/cmdutil/exchange.go b/pkg/cmd/cmdutil/exchange.go index cadb68ec3..21b23f4ae 100644 --- a/pkg/cmd/cmdutil/exchange.go +++ b/pkg/cmd/cmdutil/exchange.go @@ -11,29 +11,17 @@ import ( "github.com/c9s/bbgo/pkg/types" ) -func NewExchangeWithEnvVarPrefix(n types.ExchangeName, varPrefix string) (types.Exchange, error) { - if len(varPrefix) == 0 { - varPrefix = n.String() +func NewExchangeStandard(n types.ExchangeName, key, secret string) (types.Exchange, error) { + if len(key) == 0 || len(secret) == 0 { + return nil, errors.New("binance: empty key or secret") } switch n { case types.ExchangeBinance: - key := viper.GetString(varPrefix + "-api-key") - secret := viper.GetString(varPrefix + "-api-secret") - if len(key) == 0 || len(secret) == 0 { - return nil, errors.New("binance: empty key or secret") - } - return binance.New(key, secret), nil case types.ExchangeMax: - key := viper.GetString(varPrefix + "-api-key") - secret := viper.GetString(varPrefix + "-api-secret") - if len(key) == 0 || len(secret) == 0 { - return nil, errors.New("max: empty key or secret") - } - return max.New(key, secret), nil default: @@ -42,6 +30,20 @@ func NewExchangeWithEnvVarPrefix(n types.ExchangeName, varPrefix string) (types. } } +func NewExchangeWithEnvVarPrefix(n types.ExchangeName, varPrefix string) (types.Exchange, error) { + if len(varPrefix) == 0 { + varPrefix = n.String() + } + + key := viper.GetString(varPrefix + "-api-key") + secret := viper.GetString(varPrefix + "-api-secret") + if len(key) == 0 || len(secret) == 0 { + return nil, errors.New("max: empty key or secret") + } + + return NewExchangeStandard(n, key, secret) +} + // NewExchange constructor exchange object from viper config. func NewExchange(n types.ExchangeName) (types.Exchange, error) { return NewExchangeWithEnvVarPrefix(n, "") diff --git a/pkg/cmd/run.go b/pkg/cmd/run.go index 0a4f30150..021d0dcf3 100644 --- a/pkg/cmd/run.go +++ b/pkg/cmd/run.go @@ -55,6 +55,33 @@ var RunCmd = &cobra.Command{ RunE: run, } +func runSetup(basectx context.Context, userConfig *bbgo.Config, enableApiServer bool) error { + ctx, cancelTrading := context.WithCancel(basectx) + defer cancelTrading() + + environ := bbgo.NewEnvironment() + + trader := bbgo.NewTrader(environ) + + if enableApiServer { + go func() { + if err := bbgo.RunServer(ctx, userConfig, environ, trader); err != nil { + log.WithError(err).Errorf("server error") + } + }() + } + + cmdutil.WaitForSignal(ctx, syscall.SIGINT, syscall.SIGTERM) + cancelTrading() + + shutdownCtx, cancelShutdown := context.WithDeadline(ctx, time.Now().Add(30*time.Second)) + + log.Infof("shutting down...") + trader.Graceful.Shutdown(shutdownCtx) + cancelShutdown() + return nil +} + func runConfig(basectx context.Context, userConfig *bbgo.Config, enableApiServer bool) error { ctx, cancelTrading := context.WithCancel(basectx) defer cancelTrading() @@ -227,7 +254,7 @@ func runConfig(basectx context.Context, userConfig *bbgo.Config, enableApiServer if enableApiServer { go func() { - if err := bbgo.RunServer(ctx, userConfig, environ); err != nil { + if err := bbgo.RunServer(ctx, userConfig, environ, trader); err != nil { log.WithError(err).Errorf("server error") } }() @@ -262,24 +289,7 @@ func run(cmd *cobra.Command, args []string) error { } } - configFile, err := cmd.Flags().GetString("config") - if err != nil { - return err - } - - if len(configFile) == 0 { - return errors.New("--config option is required") - } - - noCompile, err := cmd.Flags().GetBool("no-compile") - if err != nil { - return err - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - userConfig, err := bbgo.Load(configFile, false) + setup, err := cmd.Flags().GetBool("setup") if err != nil { return err } @@ -289,17 +299,56 @@ func run(cmd *cobra.Command, args []string) error { return err } - // for wrapper binary, we can just run the strategies - if bbgo.IsWrapperBinary || (userConfig.Build != nil && len(userConfig.Build.Imports) == 0) || noCompile { - userConfig, err = bbgo.Load(configFile, true) + noCompile, err := cmd.Flags().GetBool("no-compile") + if err != nil { + return err + } + + configFile, err := cmd.Flags().GetString("config") + if err != nil { + return err + } + + var userConfig *bbgo.Config + + if setup { + log.Infof("running in setup mode, skip reading config file") + enableApiServer = true + userConfig = &bbgo.Config{ + Notifications: nil, + Persistence: nil, + Sessions: nil, + ExchangeStrategies: nil, + } + } else { + if len(configFile) == 0 { + return errors.New("--config option is required") + } + + userConfig, err = bbgo.Load(configFile, false) if err != nil { return err } + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // for wrapper binary, we can just run the strategies + if bbgo.IsWrapperBinary || (userConfig.Build != nil && len(userConfig.Build.Imports) == 0) || noCompile { if bbgo.IsWrapperBinary { log.Infof("running wrapper binary...") } + if setup { + return runSetup(ctx, userConfig, enableApiServer) + } + + userConfig, err = bbgo.Load(configFile, true) + if err != nil { + return err + } + return runConfig(ctx, userConfig, enableApiServer) }