refine setup steps

This commit is contained in:
c9s 2021-02-02 17:26:35 +08:00
parent b33f08e9a0
commit 17d5e301dc
13 changed files with 461 additions and 59 deletions

View File

@ -2,12 +2,26 @@ 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) { export function testDatabaseConnection(dsn, cb) {
return axios.post(baseURL + '/api/sessions/test-connection', data, { return axios.post(baseURL + '/api/setup/test-db', {dsn: dsn}).then(response => {
headers: { cb(response.data)
'Content-Type': 'application/json', });
} }
}).then(response => {
export function configureDatabase(dsn, cb) {
return axios.post(baseURL + '/api/setup/configure-db', {dsn: dsn}).then(response => {
cb(response.data)
});
}
export function addSession(session, cb) {
return axios.post(baseURL + '/api/sessions', session).then(response => {
cb(response.data)
});
}
export function testSessionConnection(session, cb) {
return axios.post(baseURL + '/api/sessions/test', session).then(response => {
cb(response.data) cb(response.data)
}); });
} }

View File

@ -15,7 +15,7 @@ import MenuItem from '@material-ui/core/MenuItem';
import Alert from '@material-ui/lab/Alert'; import Alert from '@material-ui/lab/Alert';
import {testSessionConnection} from '../api/bbgo'; import {addSession, testSessionConnection} from '../api/bbgo';
import {makeStyles} from '@material-ui/core/styles'; import {makeStyles} from '@material-ui/core/styles';
@ -37,7 +37,7 @@ const useStyles = makeStyles((theme) => ({
}, },
})); }));
export default function ExchangeSessionForm() { export default function AddExchangeSessionForm({onBack, onAdded}) {
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 [customSessionName, setCustomSessionName] = React.useState(false);
@ -45,6 +45,7 @@ export default function ExchangeSessionForm() {
const [testing, setTesting] = React.useState(false); const [testing, setTesting] = React.useState(false);
const [testResponse, setTestResponse] = React.useState(null); const [testResponse, setTestResponse] = React.useState(null);
const [response, setResponse] = React.useState(null);
const [apiKey, setApiKey] = React.useState(''); const [apiKey, setApiKey] = React.useState('');
const [apiSecret, setApiSecret] = React.useState(''); const [apiSecret, setApiSecret] = React.useState('');
@ -76,6 +77,19 @@ export default function ExchangeSessionForm() {
} }
} }
const handleAdd = (event) => {
const payload = createSessionConfig()
addSession(payload, (response) => {
setResponse(response)
if (onAdded) {
setTimeout(onAdded, 3000)
}
}).catch((error) => {
console.error(error)
setResponse(error.response)
})
};
const handleTestConnection = (event) => { const handleTestConnection = (event) => {
const payload = createSessionConfig() const payload = createSessionConfig()
setTesting(true) setTesting(true)
@ -83,10 +97,10 @@ export default function ExchangeSessionForm() {
console.log(response) console.log(response)
setTesting(false) setTesting(false)
setTestResponse(response) setTestResponse(response)
}).catch((reason) => { }).catch((error) => {
console.error(reason) console.error(error)
setTesting(false) setTesting(false)
setTestResponse(reason) setTestResponse(error.response)
}) })
}; };
@ -174,7 +188,8 @@ export default function ExchangeSessionForm() {
}} value="1"/>} }} value="1"/>}
label="Use margin trading." label="Use margin trading."
/> />
<FormHelperText id="isMargin-helper-text">This is only available for Binance. Please use the leverage at your own risk.</FormHelperText> <FormHelperText id="isMargin-helper-text">This is only available for Binance. Please use the
leverage at your own risk.</FormHelperText>
<FormControlLabel <FormControlLabel
control={<Checkbox color="secondary" name="isIsolatedMargin" control={<Checkbox color="secondary" name="isIsolatedMargin"
@ -184,7 +199,8 @@ export default function ExchangeSessionForm() {
}} value="1"/>} }} value="1"/>}
label="Use isolated margin trading." label="Use isolated margin trading."
/> />
<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> <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 ?
<TextField <TextField
@ -204,17 +220,28 @@ export default function ExchangeSessionForm() {
</Grid> </Grid>
<div className={classes.buttons}> <div className={classes.buttons}>
<Button
onClick={() => {
if (onBack) {
onBack();
}
}}>
Back
</Button>
<Button <Button
color="primary" color="primary"
onClick={handleTestConnection} onClick={handleTestConnection}
disabled={testing}> disabled={testing}>
{ testing ? "Testing" : "Test Connection"} {testing ? "Testing" : "Test Connection"}
</Button> </Button>
<Button <Button
variant="contained" variant="contained"
color="primary"> color="primary"
Create onClick={handleAdd}
>
Add
</Button> </Button>
</div> </div>
@ -230,6 +257,18 @@ export default function ExchangeSessionForm() {
) : null : null ) : null : null
} }
{
response ? response.error ? (
<Box m={2}>
<Alert severity="error">{response.error}</Alert>
</Box>
) : response.success ? (
<Box m={2}>
<Alert severity="success">Exchange Session Added</Alert>
</Box>
) : null : null
}
</React.Fragment> </React.Fragment>
); );

View File

@ -0,0 +1,154 @@
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 FormHelperText from '@material-ui/core/FormHelperText';
import Alert from '@material-ui/lab/Alert';
import {testDatabaseConnection, configureDatabase} from '../api/bbgo';
import {makeStyles} from '@material-ui/core/styles';
const useStyles = makeStyles((theme) => ({
formControl: {
marginTop: theme.spacing(1),
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 ConfigureDatabaseForm({ onConfigured }) {
const classes = useStyles();
const [mysqlURL, setMysqlURL] = React.useState("")
const [testing, setTesting] = React.useState(false);
const [testResponse, setTestResponse] = React.useState(null);
const [configured, setConfigured] = React.useState(false);
const resetTestResponse = () => {
setTestResponse(null)
}
const handleConfigureDatabase = (event) => {
configureDatabase(mysqlURL, (response) => {
console.log(response);
setTesting(false);
setTestResponse(response);
if (onConfigured) {
setConfigured(true);
setTimeout(onConfigured, 3000);
}
}).catch((reason) => {
console.error(reason);
setTesting(false);
setTestResponse(reason);
})
}
const handleTestConnection = (event) => {
setTesting(true);
testDatabaseConnection(mysqlURL, (response) => {
console.log(response)
setTesting(false)
setTestResponse(response)
}).catch((reason) => {
console.error(reason)
setTesting(false)
setTestResponse(reason)
})
};
return (
<React.Fragment>
<Typography variant="h6" gutterBottom>
Configure Database
</Typography>
<Typography variant="body1" gutterBottom>
If you have database installed on your machine, you can enter the DSN string in the following field.
Please note this is optional, you CAN SKIP this step.
</Typography>
<Grid container spacing={3}>
<Grid item xs={12} sm={6}>
<TextField id="mysql_url" name="mysql_url" label="MySQL Data Source Name"
fullWidth
required
onChange={(event) => {
setMysqlURL(event.target.value)
resetTestResponse()
}}
/>
<FormHelperText id="session-name-helper-text">
If you have database installed on your machine, you can enter the DSN string like the
following
format:
<br/>
<code>
root:password@tcp(127.0.0.1:3306)/bbgo
</code>
<br/>
Be sure to create your database before using it. You need to execute the following statement
to
create a database:
<br/>
<code>
CREATE DATABASE bbgo CHARSET utf8;
</code>
</FormHelperText>
</Grid>
</Grid>
<div className={classes.buttons}>
<Button
color="primary"
onClick={handleTestConnection}
disabled={testing || configured}
>
{testing ? "Testing" : "Test Connection"}
</Button>
<Button
variant="contained"
color="primary"
disabled={testing || configured}
onClick={handleConfigureDatabase}
>
Configure
</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>
);
}

View File

@ -0,0 +1,89 @@
import React from 'react';
import Grid from '@material-ui/core/Grid';
import Button from '@material-ui/core/Button';
import Typography from '@material-ui/core/Typography';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemText from '@material-ui/core/ListItemText';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import PowerIcon from '@material-ui/icons/Power';
import {makeStyles} from '@material-ui/core/styles';
import {querySessions} from "../api/bbgo";
import {Power} from "@material-ui/icons";
const useStyles = makeStyles((theme) => ({
formControl: {
marginTop: theme.spacing(1),
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 ReviewSessions({onBack, onNext}) {
const classes = useStyles();
const [sessions, setSessions] = React.useState([]);
React.useEffect(() => {
querySessions((sessions) => {
setSessions(sessions)
});
}, [])
const items = sessions.map((session, i) => {
console.log(session)
return (
<ListItem key={session.name}>
<ListItemIcon>
<PowerIcon/>
</ListItemIcon>
<ListItemText primary={session.name} secondary={session.exchange}/>
</ListItem>
);
})
return (
<React.Fragment>
<Typography variant="h6" gutterBottom>
Review Sessions
</Typography>
<List component="nav">
{items}
</List>
<div className={classes.buttons}>
<Button
onClick={() => {
if (onBack) {
onBack();
}
}}>
Back
</Button>
<Button
variant="contained"
color="primary"
onClick={() => {
if (onNext) {
onNext();
}
}}>
Next
</Button>
</div>
</React.Fragment>
);
}

View File

@ -8,7 +8,9 @@ import Stepper from '@material-ui/core/Stepper';
import Step from '@material-ui/core/Step'; import Step from '@material-ui/core/Step';
import StepLabel from '@material-ui/core/StepLabel'; import StepLabel from '@material-ui/core/StepLabel';
import ExchangeSessionForm from "../../components/ExchangeSessionForm"; import ConfigureDatabaseForm from "../../components/ConfigureDatabaseForm";
import AddExchangeSessionForm from "../../components/AddExchangeSessionForm";
import ReviewSessions from "../../components/ReviewSessions";
import PlainLayout from '../../layouts/PlainLayout'; import PlainLayout from '../../layouts/PlainLayout';
@ -20,14 +22,24 @@ const useStyles = makeStyles((theme) => ({
const steps = ['Configure Database', 'Add Exchange Session', 'Configure Strategy', 'Restart BBGO']; const steps = ['Configure Database', 'Add Exchange Session', 'Configure Strategy', 'Restart BBGO'];
function getStepContent(step) { function getStepContent(step, setActiveStep) {
switch (step) { switch (step) {
case 0: case 0:
return; return <ConfigureDatabaseForm onConfigured={() => {
setActiveStep(1)
}}/>;
case 1: case 1:
return <ExchangeSessionForm/>; return <AddExchangeSessionForm onAdded={() => {
setActiveStep(2)
}} onBack={() => {
setActiveStep(0)
}}/>;
case 2: case 2:
return; return <ReviewSessions onBack={() => {
setActiveStep(1)
}} onNext={() => {
setActiveStep(3)
}}/>
case 3: case 3:
return; return;
default: default:
@ -56,7 +68,7 @@ export default function Setup() {
</Stepper> </Stepper>
<React.Fragment> <React.Fragment>
{getStepContent(activeStep)} {getStepContent(activeStep, setActiveStep)}
</React.Fragment> </React.Fragment>
</Paper> </Paper>
</Box> </Box>

View File

@ -164,7 +164,7 @@ type Config struct {
Persistence *PersistenceConfig `json:"persistence,omitempty" yaml:"persistence,omitempty"` Persistence *PersistenceConfig `json:"persistence,omitempty" yaml:"persistence,omitempty"`
Sessions map[string]Session `json:"sessions,omitempty" yaml:"sessions,omitempty"` Sessions map[string]*ExchangeSession `json:"sessions,omitempty" yaml:"sessions,omitempty"`
RiskControls *RiskControls `json:"riskControls,omitempty" yaml:"riskControls,omitempty"` RiskControls *RiskControls `json:"riskControls,omitempty" yaml:"riskControls,omitempty"`

View File

@ -75,21 +75,17 @@ func (environ *Environment) Sessions() map[string]*ExchangeSession {
return environ.sessions return environ.sessions
} }
func (environ *Environment) ConfigureDatabase(ctx context.Context) error { func (environ *Environment) ConfigureDatabase(ctx context.Context, dsn string) error {
if viper.IsSet("mysql-url") { db, err := ConnectMySQL(dsn)
dsn := viper.GetString("mysql-url") if err != nil {
db, err := ConnectMySQL(dsn) return err
if err != nil {
return err
}
if err := upgradeDB(ctx, "mysql", db.DB); err != nil {
return err
}
environ.SetDB(db)
} }
if err := upgradeDB(ctx, "mysql", db.DB); err != nil {
return err
}
environ.SetDB(db)
return nil return nil
} }
@ -142,7 +138,7 @@ func (environ *Environment) AddExchangesByViperKeys() error {
return nil return nil
} }
func NewExchangeSessionFromConfig(name string, sessionConfig Session) (*ExchangeSession, error) { func NewExchangeSessionFromConfig(name string, sessionConfig *ExchangeSession) (*ExchangeSession, error) {
exchangeName, err := types.ValidExchangeName(sessionConfig.ExchangeName) exchangeName, err := types.ValidExchangeName(sessionConfig.ExchangeName)
if err != nil { if err != nil {
return nil, err return nil, err
@ -175,13 +171,13 @@ func NewExchangeSessionFromConfig(name string, sessionConfig Session) (*Exchange
} }
session := NewExchangeSession(name, exchange) session := NewExchangeSession(name, exchange)
session.IsMargin = sessionConfig.Margin session.Margin = sessionConfig.Margin
session.IsIsolatedMargin = sessionConfig.IsolatedMargin session.IsolatedMargin = sessionConfig.IsolatedMargin
session.IsolatedMarginSymbol = sessionConfig.IsolatedMarginSymbol session.IsolatedMarginSymbol = sessionConfig.IsolatedMarginSymbol
return session, nil return session, nil
} }
func (environ *Environment) AddExchangesFromSessionConfig(sessions map[string]Session) error { func (environ *Environment) AddExchangesFromSessionConfig(sessions map[string]*ExchangeSession) error {
for sessionName, sessionConfig := range sessions { for sessionName, sessionConfig := range sessions {
session, err := NewExchangeSessionFromConfig(sessionName, sessionConfig) session, err := NewExchangeSessionFromConfig(sessionName, sessionConfig)
if err != nil { if err != nil {

View File

@ -136,8 +136,59 @@ func RunServer(ctx context.Context, userConfig *Config, environ *Environment, tr
return return
}) })
r.POST("/api/sessions/test-connection", func(c *gin.Context) { r.POST("/api/setup/test-db", func(c *gin.Context) {
var sessionConfig Session payload := struct {
DSN string `json:"dsn"`
}{}
if err := c.BindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing arguments"})
return
}
dsn := payload.DSN
if len(dsn) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing dsn argument"})
return
}
db, err := ConnectMySQL(dsn)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
_ = db.Close()
c.JSON(http.StatusOK, gin.H{"success": true})
})
r.POST("/api/setup/configure-db", func(c *gin.Context) {
payload := struct {
DSN string `json:"dsn"`
}{}
if err := c.BindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing arguments"})
return
}
dsn := payload.DSN
if len(dsn) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing dsn argument"})
return
}
if err := environ.ConfigureDatabase(ctx, dsn); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
})
r.POST("/api/sessions", func(c *gin.Context) {
var sessionConfig ExchangeSession
if err := c.BindJSON(&sessionConfig); err != nil { if err := c.BindJSON(&sessionConfig); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(), "error": err.Error(),
@ -145,7 +196,31 @@ func RunServer(ctx context.Context, userConfig *Config, environ *Environment, tr
return return
} }
session, err := NewExchangeSessionFromConfig(sessionConfig.ExchangeName, sessionConfig) session, err := NewExchangeSessionFromConfig(sessionConfig.ExchangeName, &sessionConfig)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
environ.AddExchangeSession(sessionConfig.Name, session)
c.JSON(http.StatusOK, gin.H{
"success": true,
})
})
r.POST("/api/sessions/test", func(c *gin.Context) {
var sessionConfig ExchangeSession
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 { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(), "error": err.Error(),
@ -173,7 +248,7 @@ func RunServer(ctx context.Context, userConfig *Config, environ *Environment, tr
}) })
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() {
sessions = append(sessions, session) sessions = append(sessions, session)
} }

View File

@ -100,18 +100,29 @@ type ExchangeSession struct {
// we make it as a value field so that we can configure it separately // we make it as a value field so that we can configure it separately
Notifiability `json:"-"` Notifiability `json:"-"`
// ---------------------------
// Session config fields
// ---------------------------
// Exchange Session name // Exchange Session name
Name string `json:"name"` 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"`
IsolatedMargin bool `json:"isolatedMargin,omitempty" yaml:"isolatedMargin,omitempty"`
IsolatedMarginSymbol string `json:"isolatedMarginSymbol,omitempty" yaml:"isolatedMarginSymbol,omitempty"`
// ---------------------------
// Runtime fields
// ---------------------------
// The exchange account states // The exchange account states
Account *types.Account `json:"account"` Account *types.Account `json:"account"`
IsMargin bool `json:"isMargin"`
IsIsolatedMargin bool `json:"isIsolatedMargin,omitempty"`
IsolatedMarginSymbol string `json:"isolatedMarginSymbol,omitempty"`
IsInitialized bool `json:"isInitialized"` IsInitialized bool `json:"isInitialized"`
// Stream is the connection stream of the exchange // Stream is the connection stream of the exchange

View File

@ -116,8 +116,11 @@ var BacktestCmd = &cobra.Command{
} }
environ := bbgo.NewEnvironment() environ := bbgo.NewEnvironment()
if err := environ.ConfigureDatabase(ctx); err != nil { if viper.IsSet("mysql-url") {
return err dsn := viper.GetString("mysql-url")
if err := environ.ConfigureDatabase(ctx, dsn); err != nil {
return err
}
} }
backtestService := &service.BacktestService{DB: db} backtestService := &service.BacktestService{DB: db}

View File

@ -88,8 +88,11 @@ func runConfig(basectx context.Context, userConfig *bbgo.Config, enableApiServer
environ := bbgo.NewEnvironment() environ := bbgo.NewEnvironment()
if err := environ.ConfigureDatabase(ctx); err != nil { if viper.IsSet("mysql-url") {
return err dsn := viper.GetString("mysql-url")
if err := environ.ConfigureDatabase(ctx, dsn); err != nil {
return err
}
} }
if err := environ.AddExchangesFromConfig(userConfig); err != nil { if err := environ.AddExchangesFromConfig(userConfig); err != nil {

View File

@ -9,6 +9,7 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/c9s/bbgo/pkg/bbgo" "github.com/c9s/bbgo/pkg/bbgo"
) )
@ -51,8 +52,12 @@ var SyncCmd = &cobra.Command{
} }
environ := bbgo.NewEnvironment() environ := bbgo.NewEnvironment()
if err := environ.ConfigureDatabase(ctx); err != nil {
return err if viper.IsSet("mysql-url") {
dsn := viper.GetString("mysql-url")
if err := environ.ConfigureDatabase(ctx, dsn); err != nil {
return err
}
} }
if err := environ.AddExchangesFromConfig(userConfig); err != nil { if err := environ.AddExchangesFromConfig(userConfig); err != nil {
@ -108,7 +113,7 @@ var SyncCmd = &cobra.Command{
func syncSession(ctx context.Context, environ *bbgo.Environment, session *bbgo.ExchangeSession, symbol string, startTime time.Time) error { func syncSession(ctx context.Context, environ *bbgo.Environment, session *bbgo.ExchangeSession, symbol string, startTime time.Time) error {
log.Infof("starting syncing exchange session %s", session.Name) log.Infof("starting syncing exchange session %s", session.Name)
if session.IsIsolatedMargin { if session.IsolatedMargin {
log.Infof("session is configured as isolated margin session, using isolated margin symbol %s instead of %s", session.IsolatedMarginSymbol, symbol) log.Infof("session is configured as isolated margin session, using isolated margin symbol %s instead of %s", session.IsolatedMarginSymbol, symbol)
symbol = session.IsolatedMarginSymbol symbol = session.IsolatedMarginSymbol
} }

View File

@ -78,8 +78,9 @@ case "$command" in
if [[ -n $currency ]] ; then if [[ -n $currency ]] ; then
rewards_params[currency]=$currency rewards_params[currency]=$currency
fi fi
# rewards rewards_params | jq -r '.[] | "\(.type)\t\((.amount | tonumber) * 1000 | floor / 1000)\t\(.currency) \(.state) \(.created_at | strflocaltime("%Y-%m-%dT%H:%M:%S %Z"))"' # rewards rewards_params | jq -r '.[] | "\(.type)\t\((.amount | tonumber) * 1000 | floor / 1000)\t\(.currency) \(.state) \(.created_at | strflocaltime("%Y-%m-%dT%H:%M:%S %Z"))"'
rewards rewards_params | jq -r '.[] | [ .type, ((.amount | tonumber) * 10000 | floor / 10000), .currency, .state, (.created_at | strflocaltime("%Y-%m-%dT%H:%M:%S %Z")) ] | @tsv' \ rewards rewards_params | jq -r '.[] | [ .uuid, .type, ((.amount | tonumber) * 10000 | floor / 10000), .currency, .state, (.created_at | strflocaltime("%Y-%m-%dT%H:%M:%S %Z")), .note ] | @tsv' \
| column -ts $'\t' | column -ts $'\t'
;; ;;