support exchange session test from the setup wizard

This commit is contained in:
c9s 2021-02-02 11:44:07 +08:00
parent ee3c76d3fb
commit 73762d9888
10 changed files with 354 additions and 121 deletions

View File

@ -2,6 +2,16 @@ import axios from "axios";
const baseURL = process.env.NODE_ENV === "development" ? "http://localhost:8080" : "" 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) { export function querySessions(cb) {
axios.get(baseURL + '/api/sessions', {}) axios.get(baseURL + '/api/sessions', {})
.then(response => { .then(response => {

View File

@ -1,8 +1,11 @@
import React from 'react'; import React from 'react';
import Grid from '@material-ui/core/Grid'; 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 Typography from '@material-ui/core/Typography';
import TextField from '@material-ui/core/TextField'; import TextField from '@material-ui/core/TextField';
import FormControlLabel from '@material-ui/core/FormControlLabel'; import FormControlLabel from '@material-ui/core/FormControlLabel';
import FormHelperText from '@material-ui/core/FormHelperText';
import InputLabel from '@material-ui/core/InputLabel'; import InputLabel from '@material-ui/core/InputLabel';
import FormControl from '@material-ui/core/FormControl'; 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 Select from '@material-ui/core/Select';
import MenuItem from '@material-ui/core/MenuItem'; 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'; import {makeStyles} from '@material-ui/core/styles';
const useStyles = makeStyles((theme) => ({ const useStyles = makeStyles((theme) => ({
@ -18,28 +25,69 @@ const useStyles = makeStyles((theme) => ({
marginBottom: theme.spacing(1), marginBottom: theme.spacing(1),
minWidth: 120, 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() { export default function ExchangeSessionForm() {
const classes = useStyles(); const classes = useStyles();
const [exchangeType, setExchangeType] = React.useState('max'); 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 [isMargin, setIsMargin] = React.useState(false);
const [isIsolatedMargin, setIsIsolatedMargin] = React.useState(false); const [isIsolatedMargin, setIsIsolatedMargin] = React.useState(false);
const [isolatedMarginSymbol, setIsolatedMarginSymbol] = React.useState(""); const [isolatedMarginSymbol, setIsolatedMarginSymbol] = React.useState("");
const resetTestResponse = () => {
setTestResponse(null)
}
const handleExchangeTypeChange = (event) => { const handleExchangeTypeChange = (event) => {
setExchangeType(event.target.value); setExchangeType(event.target.value);
setSessionName(event.target.value);
resetTestResponse()
}; };
const handleIsMarginChange = (event) => { const createSessionConfig = () => {
setIsMargin(event.target.checked); return {
}; name: sessionName,
exchange: exchangeType,
key: apiKey,
secret: apiSecret,
margin: isMargin,
envVarPrefix: exchangeType.toUpperCase() + "_",
isolatedMargin: isIsolatedMargin,
isolatedMarginSymbol: isolatedMarginSymbol,
}
}
const handleIsIsolatedMarginChange = (event) => { const handleTestConnection = (event) => {
setIsIsolatedMargin(event.target.checked); 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 ( return (
@ -64,71 +112,125 @@ export default function ExchangeSessionForm() {
</FormControl> </FormControl>
</Grid> </Grid>
<Grid item xs={12} sm={12}> <Grid item xs={12} sm={6}>
<TextField <TextField
required
id="name" id="name"
name="name" name="name"
label="Session Name" label="Session Name"
fullWidth fullWidth
autoComplete="given-name" required
disabled={!customSessionName}
onChange={(event) => {
setSessionName(event.target.value)
}}
value={sessionName}
/> />
</Grid> </Grid>
<Grid item xs={12} sm={6}> <Grid item xs={12} sm={6}>
<TextField id="state" name="state" label="State/Province/Region" fullWidth/> <FormControlLabel
</Grid> control={<Checkbox color="secondary" name="custom_session_name"
<Grid item xs={12} sm={6}> onChange={(event) => {
<TextField setCustomSessionName(event.target.checked);
required }} value="1"/>}
id="zip" label="Custom exchange session name"
name="zip"
label="Zip / Postal code"
fullWidth
autoComplete="shipping postal-code"
/> />
<FormHelperText id="session-name-helper-text">
By default, the session name will be the exchange type name,
e.g. <code>binance</code> or <code>max</code>.<br/>
If you're using multiple exchange sessions, you might need to custom the session name. <br/>
This is for advanced users.
</FormHelperText>
</Grid> </Grid>
<Grid item xs={12} sm={6}>
<TextField <Grid item xs={12}>
required <TextField id="key" name="api_key" label="API Key"
id="country"
name="country"
label="Country"
fullWidth fullWidth
autoComplete="shipping country" required
onChange={(event) => {
setApiKey(event.target.value)
resetTestResponse()
}}
/> />
</Grid> </Grid>
<Grid item xs={12}>
<TextField id="secret" name="api_secret" label="API Secret"
fullWidth
required
onChange={(event) => {
setApiSecret(event.target.value)
resetTestResponse()
}}
/>
</Grid>
{exchangeType === "binance" ? (
<Grid item xs={12}> <Grid item xs={12}>
<FormControlLabel <FormControlLabel
control={<Checkbox color="secondary" name="isMargin" onChange={handleIsMarginChange} control={<Checkbox color="secondary" name="isMargin" onChange={(event) => {
value="1"/>} setIsMargin(event.target.checked);
label="Use margin trading. This is only available for Binance" resetTestResponse();
}} value="1"/>}
label="Use margin trading."
/> />
</Grid> <FormHelperText id="isMargin-helper-text">This is only available for Binance. Please use the leverage at your own risk.</FormHelperText>
<Grid item xs={12}>
<FormControlLabel <FormControlLabel
control={<Checkbox color="secondary" name="isIsolatedMargin" control={<Checkbox color="secondary" name="isIsolatedMargin"
onChange={handleIsIsolatedMarginChange} value="1"/>} onChange={(event) => {
label="Use isolated margin trading, if this is set, you can only trade one symbol with one session. This is only available for Binance" setIsIsolatedMargin(event.target.checked);
resetTestResponse()
}} value="1"/>}
label="Use isolated margin trading."
/> />
</Grid> <FormHelperText id="isIsolatedMargin-helper-text">This is only available for Binance. If this is set, you can only trade one symbol with one session.</FormHelperText>
{isIsolatedMargin ? {isIsolatedMargin ?
<Grid item xs={12}>
<TextField <TextField
required
id="isolatedMarginSymbol" id="isolatedMarginSymbol"
name="isolatedMarginSymbol" name="isolatedMarginSymbol"
label="Isolated Margin Symbol" label="Isolated Margin Symbol"
onChange={(event) => {
setIsolatedMarginSymbol(event.target.value);
resetTestResponse()
}}
fullWidth fullWidth
required
/> />
</Grid>
: null} : null}
</Grid> </Grid>
) : null}
</Grid>
<div className={classes.buttons}>
<Button
color="primary"
onClick={handleTestConnection}
disabled={testing}>
{ testing ? "Testing" : "Test Connection"}
</Button>
<Button
variant="contained"
color="primary">
Create
</Button>
</div>
{
testResponse ? testResponse.error ? (
<Box m={2}>
<Alert severity="error">{testResponse.error}</Alert>
</Box>
) : testResponse.success ? (
<Box m={2}>
<Alert severity="success">Connection Test Succeeded</Alert>
</Box>
) : null : null
}
</React.Fragment> </React.Fragment>
); );
} }

View File

@ -12,6 +12,7 @@
"@material-ui/core": "^4.11.2", "@material-ui/core": "^4.11.2",
"@material-ui/data-grid": "^4.0.0-alpha.18", "@material-ui/data-grid": "^4.0.0-alpha.18",
"@material-ui/icons": "^4.11.2", "@material-ui/icons": "^4.11.2",
"@material-ui/lab": "^4.0.0-alpha.57",
"@nivo/bar": "^0.67.0", "@nivo/bar": "^0.67.0",
"@nivo/core": "^0.67.0", "@nivo/core": "^0.67.0",
"@nivo/pie": "^0.67.0", "@nivo/pie": "^0.67.0",

View File

@ -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) { function getStepContent(step) {
switch (step) { switch (step) {
@ -35,7 +35,7 @@ function getStepContent(step) {
} }
} }
export default function SetupSession() { export default function Setup() {
const classes = useStyles(); const classes = useStyles();
const [activeStep, setActiveStep] = React.useState(0); const [activeStep, setActiveStep] = React.useState(0);

View File

@ -159,6 +159,17 @@
dependencies: dependencies:
"@babel/runtime" "^7.4.4" "@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": "@material-ui/styles@^4.11.2":
version "4.11.2" version "4.11.2"
resolved "https://registry.yarnpkg.com/@material-ui/styles/-/styles-4.11.2.tgz#e70558be3f41719e8c0d63c7a3c9ae163fdc84cb" resolved "https://registry.yarnpkg.com/@material-ui/styles/-/styles-4.11.2.tgz#e70558be3f41719e8c0d63c7a3c9ae163fdc84cb"

View File

@ -53,10 +53,15 @@ type NotificationConfig struct {
} }
type Session struct { type Session struct {
Name string `json:"name,omitempty" yaml:"name,omitempty"`
ExchangeName string `json:"exchange" yaml:"exchange"` ExchangeName string `json:"exchange" yaml:"exchange"`
EnvVarPrefix string `json:"envVarPrefix" yaml:"envVarPrefix"` 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"` 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"` IsolatedMargin bool `json:"isolatedMargin,omitempty" yaml:"isolatedMargin,omitempty"`
IsolatedMarginSymbol string `json:"isolatedMarginSymbol,omitempty" yaml:"isolatedMarginSymbol,omitempty"` IsolatedMarginSymbol string `json:"isolatedMarginSymbol,omitempty" yaml:"isolatedMarginSymbol,omitempty"`
} }

View File

@ -142,23 +142,29 @@ func (environ *Environment) AddExchangesByViperKeys() error {
return nil return nil
} }
func (environ *Environment) AddExchangesFromSessionConfig(sessions map[string]Session) error { func NewExchangeSessionFromConfig(name string, sessionConfig Session) (*ExchangeSession, error) {
for sessionName, sessionConfig := range sessions {
exchangeName, err := types.ValidExchangeName(sessionConfig.ExchangeName) exchangeName, err := types.ValidExchangeName(sessionConfig.ExchangeName)
if err != nil { if err != nil {
return err 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)
} }
exchange, err := cmdutil.NewExchangeWithEnvVarPrefix(exchangeName, sessionConfig.EnvVarPrefix)
if err != nil { if err != nil {
return err return nil, err
} }
// configure exchange // configure exchange
if sessionConfig.Margin { if sessionConfig.Margin {
marginExchange, ok := exchange.(types.MarginExchange) marginExchange, ok := exchange.(types.MarginExchange)
if !ok { if !ok {
return fmt.Errorf("exchange %s does not support margin", exchangeName) return nil, fmt.Errorf("exchange %s does not support margin", exchangeName)
} }
if sessionConfig.IsolatedMargin { if sessionConfig.IsolatedMargin {
@ -168,10 +174,20 @@ func (environ *Environment) AddExchangesFromSessionConfig(sessions map[string]Se
} }
} }
session := NewExchangeSession(sessionName, exchange) session := NewExchangeSession(name, exchange)
session.IsMargin = sessionConfig.Margin session.IsMargin = sessionConfig.Margin
session.IsIsolatedMargin = sessionConfig.IsolatedMargin session.IsIsolatedMargin = sessionConfig.IsolatedMargin
session.IsolatedMarginSymbol = sessionConfig.IsolatedMarginSymbol session.IsolatedMarginSymbol = sessionConfig.IsolatedMarginSymbol
return session, nil
}
func (environ *Environment) AddExchangesFromSessionConfig(sessions map[string]Session) error {
for sessionName, sessionConfig := range sessions {
session, err := NewExchangeSessionFromConfig(sessionName, sessionConfig)
if err != nil {
return err
}
environ.AddExchangeSession(sessionName, session) environ.AddExchangeSession(sessionName, session)
} }
@ -188,7 +204,6 @@ func (environ *Environment) Init(ctx context.Context) (err error) {
for n := range environ.sessions { for n := range environ.sessions {
var session = environ.sessions[n] var session = environ.sessions[n]
if err := session.Init(ctx, environ); err != nil { if err := session.Init(ctx, environ); err != nil {
return err return err
} }

View File

@ -16,12 +16,14 @@ import (
"github.com/c9s/bbgo/pkg/types" "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 := gin.Default()
r.Use(cors.New(cors.Config{ r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"}, AllowOrigins: []string{"*"},
AllowHeaders: []string{"Origin"}, AllowHeaders: []string{"Origin", "Content-Type"},
ExposeHeaders: []string{"Content-Length"}, ExposeHeaders: []string{"Content-Length"},
AllowMethods: []string{"GET", "POST"},
AllowWebSockets: true,
AllowCredentials: true, AllowCredentials: true,
MaxAge: 12 * time.Hour, MaxAge: 12 * time.Hour,
})) }))
@ -134,6 +136,42 @@ func RunServer(ctx context.Context, userConfig *Config, environ *Environment) er
return 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) { r.GET("/api/sessions", func(c *gin.Context) {
var sessions []*ExchangeSession var sessions []*ExchangeSession
for _, session := range environ.Sessions() { for _, session := range environ.Sessions() {

View File

@ -11,29 +11,17 @@ import (
"github.com/c9s/bbgo/pkg/types" "github.com/c9s/bbgo/pkg/types"
) )
func NewExchangeWithEnvVarPrefix(n types.ExchangeName, varPrefix string) (types.Exchange, error) { func NewExchangeStandard(n types.ExchangeName, key, secret string) (types.Exchange, error) {
if len(varPrefix) == 0 { if len(key) == 0 || len(secret) == 0 {
varPrefix = n.String() return nil, errors.New("binance: empty key or secret")
} }
switch n { switch n {
case types.ExchangeBinance: 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 return binance.New(key, secret), nil
case types.ExchangeMax: 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 return max.New(key, secret), nil
default: 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. // NewExchange constructor exchange object from viper config.
func NewExchange(n types.ExchangeName) (types.Exchange, error) { func NewExchange(n types.ExchangeName) (types.Exchange, error) {
return NewExchangeWithEnvVarPrefix(n, "") return NewExchangeWithEnvVarPrefix(n, "")

View File

@ -55,6 +55,33 @@ var RunCmd = &cobra.Command{
RunE: run, 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 { func runConfig(basectx context.Context, userConfig *bbgo.Config, enableApiServer bool) error {
ctx, cancelTrading := context.WithCancel(basectx) ctx, cancelTrading := context.WithCancel(basectx)
defer cancelTrading() defer cancelTrading()
@ -227,7 +254,7 @@ func runConfig(basectx context.Context, userConfig *bbgo.Config, enableApiServer
if enableApiServer { if enableApiServer {
go func() { 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") log.WithError(err).Errorf("server error")
} }
}() }()
@ -262,24 +289,7 @@ func run(cmd *cobra.Command, args []string) error {
} }
} }
configFile, err := cmd.Flags().GetString("config") setup, err := cmd.Flags().GetBool("setup")
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)
if err != nil { if err != nil {
return err return err
} }
@ -289,17 +299,56 @@ func run(cmd *cobra.Command, args []string) error {
return err return err
} }
// for wrapper binary, we can just run the strategies noCompile, err := cmd.Flags().GetBool("no-compile")
if bbgo.IsWrapperBinary || (userConfig.Build != nil && len(userConfig.Build.Imports) == 0) || noCompile {
userConfig, err = bbgo.Load(configFile, true)
if err != nil { if err != nil {
return err 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 { if bbgo.IsWrapperBinary {
log.Infof("running wrapper binary...") 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) return runConfig(ctx, userConfig, enableApiServer)
} }