diff --git a/README.md b/README.md index a7028195e..6d1b6e252 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,11 @@ BINANCE_API_SECRET= MAX_API_KEY= MAX_API_SECRET= +FTX_API_KEY= +FTX_API_SECRET= +# specify it if credentials are for subaccount +FTX_SUBACCOUNT_NAME= + MYSQL_URL=root@tcp(127.0.0.1:3306)/bbgo?parseTime=true ``` diff --git a/pkg/exchange/ftx/balance.go b/pkg/exchange/ftx/balance.go new file mode 100644 index 000000000..c8e07d737 --- /dev/null +++ b/pkg/exchange/ftx/balance.go @@ -0,0 +1,29 @@ +package ftx + +import ( + "context" + "encoding/json" + "fmt" +) + +type balanceRequest struct { + *restRequest +} + +func (r *balanceRequest) Balances(ctx context.Context) (balances, error) { + resp, err := r. + Method("GET"). + ReferenceURL("api/wallet/balances"). + DoAuthenticatedRequest(ctx) + + if err != nil { + return balances{}, err + } + + var b balances + if err := json.Unmarshal(resp.Body, &b); err != nil { + return balances{}, fmt.Errorf("failed to unmarshal balance response body to json: %w", err) + } + + return b, nil +} diff --git a/pkg/exchange/ftx/convert.go b/pkg/exchange/ftx/convert.go new file mode 100644 index 000000000..59cafba13 --- /dev/null +++ b/pkg/exchange/ftx/convert.go @@ -0,0 +1,7 @@ +package ftx + +import "strings" + +func toGlobalCurrency(original string) string { + return strings.ToUpper(strings.TrimSpace(original)) +} diff --git a/pkg/exchange/ftx/exchange.go b/pkg/exchange/ftx/exchange.go new file mode 100644 index 000000000..b16fda8c7 --- /dev/null +++ b/pkg/exchange/ftx/exchange.go @@ -0,0 +1,107 @@ +package ftx + +import ( + "context" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" +) + +const ( + restEndpoint = "https://ftx.com" + defaultHTTPTimeout = 15 * time.Second +) + +type Exchange struct { + rest *restRequest +} + +func NewExchange(key, secret string, subAccount string) *Exchange { + u, err := url.Parse(restEndpoint) + if err != nil { + panic(err) + } + rest := newRestRequest(&http.Client{Timeout: defaultHTTPTimeout}, u).Auth(key, secret) + if subAccount != "" { + rest.SubAccount(subAccount) + } + return &Exchange{ + rest: rest, + } +} + +func (e *Exchange) Name() types.ExchangeName { + return types.ExchangeFTX +} + +func (e *Exchange) PlatformFeeCurrency() string { + panic("implement me") +} + +func (e *Exchange) NewStream() types.Stream { + panic("implement me") +} + +func (e *Exchange) QueryMarkets(ctx context.Context) (types.MarketMap, error) { + panic("implement me") +} + +func (e *Exchange) QueryAccount(ctx context.Context) (*types.Account, error) { + panic("implement me") +} + +func (e *Exchange) QueryAccountBalances(ctx context.Context) (types.BalanceMap, error) { + resp, err := e.rest.Balances(ctx) + if err != nil { + return nil, err + } + if !resp.Success { + return nil, fmt.Errorf("ftx returns querying balances failure") + } + var balances = make(types.BalanceMap) + for _, r := range resp.Result { + balances[toGlobalCurrency(r.Coin)] = types.Balance{ + Currency: toGlobalCurrency(r.Coin), + Available: fixedpoint.NewFromFloat(r.Free), + Locked: fixedpoint.NewFromFloat(r.Total).Sub(fixedpoint.NewFromFloat(r.Free)), + } + } + + return balances, nil +} + +func (e *Exchange) QueryKLines(ctx context.Context, symbol string, interval types.Interval, options types.KLineQueryOptions) ([]types.KLine, error) { + panic("implement me") +} + +func (e *Exchange) QueryTrades(ctx context.Context, symbol string, options *types.TradeQueryOptions) ([]types.Trade, error) { + panic("implement me") +} + +func (e *Exchange) QueryDepositHistory(ctx context.Context, asset string, since, until time.Time) (allDeposits []types.Deposit, err error) { + panic("implement me") +} + +func (e *Exchange) QueryWithdrawHistory(ctx context.Context, asset string, since, until time.Time) (allWithdraws []types.Withdraw, err error) { + panic("implement me") +} + +func (e *Exchange) SubmitOrders(ctx context.Context, orders ...types.SubmitOrder) (createdOrders types.OrderSlice, err error) { + panic("implement me") +} + +func (e *Exchange) QueryOpenOrders(ctx context.Context, symbol string) (orders []types.Order, err error) { + panic("implement me") +} + +func (e *Exchange) QueryClosedOrders(ctx context.Context, symbol string, since, until time.Time, lastOrderID uint64) (orders []types.Order, err error) { + panic("implement me") +} + +func (e *Exchange) CancelOrders(ctx context.Context, orders ...types.Order) error { + panic("implement me") +} diff --git a/pkg/exchange/ftx/exchange_test.go b/pkg/exchange/ftx/exchange_test.go new file mode 100644 index 000000000..af7c4ce8b --- /dev/null +++ b/pkg/exchange/ftx/exchange_test.go @@ -0,0 +1,61 @@ +package ftx + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/c9s/bbgo/pkg/fixedpoint" +) + +func TestExchange_QueryAccountBalances(t *testing.T) { + successResp := ` +{ + "result": [ + { + "availableWithoutBorrow": 19.47458865, + "coin": "USD", + "free": 19.48085209, + "spotBorrow": 0.0, + "total": 1094.66405065, + "usdValue": 1094.664050651561 + } + ], + "success": true +} +` + failureResp := `{"result":[],"success":false}` + i := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if i == 0 { + fmt.Fprintln(w, successResp) + i++ + return + } + fmt.Fprintln(w, failureResp) + })) + defer ts.Close() + + ex := NewExchange("", "", "") + serverURL, err := url.Parse(ts.URL) + assert.NoError(t, err) + ex.rest = newRestRequest(&http.Client{Timeout: defaultHTTPTimeout}, serverURL) + resp, err := ex.QueryAccountBalances(context.Background()) + assert.NoError(t, err) + + assert.Len(t, resp, 1) + b, ok := resp["USD"] + assert.True(t, ok) + expectedAvailable := fixedpoint.Must(fixedpoint.NewFromString("19.48085209")) + assert.Equal(t, expectedAvailable, b.Available) + assert.Equal(t, fixedpoint.Must(fixedpoint.NewFromString("1094.66405065")).Sub(expectedAvailable), b.Locked) + + resp, err = ex.QueryAccountBalances(context.Background()) + assert.EqualError(t, err, "ftx returns querying balances failure") + assert.Nil(t, resp) +} diff --git a/pkg/exchange/ftx/responses.go b/pkg/exchange/ftx/responses.go new file mode 100644 index 000000000..bf8c68ba4 --- /dev/null +++ b/pkg/exchange/ftx/responses.go @@ -0,0 +1,11 @@ +package ftx + +type balances struct { + Success bool `json:"Success"` + + Result []struct { + Coin string `json:"coin"` + Free float64 `json:"free"` + Total float64 `json:"total"` + } `json:"result"` +} diff --git a/pkg/exchange/ftx/rest.go b/pkg/exchange/ftx/rest.go new file mode 100644 index 000000000..7ce4ff537 --- /dev/null +++ b/pkg/exchange/ftx/rest.go @@ -0,0 +1,194 @@ +package ftx + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/pkg/errors" + + "github.com/c9s/bbgo/pkg/util" +) + +type restRequest struct { + *balanceRequest + + key, secret string + // Optional sub-account name + sub string + + c *http.Client + baseURL *url.URL + refURL string + // http method, e.g., GET or POST + m string + + // payload + p map[string]interface{} +} + +func newRestRequest(c *http.Client, baseURL *url.URL) *restRequest { + r := &restRequest{ + c: c, + baseURL: baseURL, + } + + r.balanceRequest = &balanceRequest{restRequest: r} + return r +} + +func (r *restRequest) Auth(key, secret string) *restRequest { + r.key = key + r.secret = secret + return r +} + +func (r *restRequest) SubAccount(subAccount string) *restRequest { + r.sub = subAccount + return r +} + +func (r *restRequest) Method(method string) *restRequest { + r.m = method + return r +} + +func (r *restRequest) ReferenceURL(refURL string) *restRequest { + r.refURL = refURL + return r +} + +func (r *restRequest) buildURL() (*url.URL, error) { + refURL, err := url.Parse(r.refURL) + if err != nil { + return nil, err + } + return r.baseURL.ResolveReference(refURL), nil +} + +func (r *restRequest) Payloads(payloads map[string]interface{}) *restRequest { + for k, v := range payloads { + r.p[k] = v + } + return r +} + +func (r *restRequest) DoAuthenticatedRequest(ctx context.Context) (*util.Response, error) { + req, err := r.newAuthenticatedRequest(ctx) + if err != nil { + return nil, err + } + + return r.sendRequest(req) +} + +func (r *restRequest) newAuthenticatedRequest(ctx context.Context) (*http.Request, error) { + u, err := r.buildURL() + if err != nil { + return nil, err + } + + var jsonPayload []byte + if len(r.p) > 0 { + var err2 error + jsonPayload, err2 = json.Marshal(r.p) + if err2 != nil { + return nil, fmt.Errorf("can't marshal payload map to json: %w", err2) + } + } + + ts := strconv.FormatInt(timestamp(), 10) + p := fmt.Sprintf("%s%s%s%s", ts, r.m, u.Path, jsonPayload) + signature := sign(r.secret, p) + + req, err := http.NewRequestWithContext(ctx, r.m, u.String(), bytes.NewBuffer(jsonPayload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("FTX-KEY", r.key) + req.Header.Set("FTX-SIGN", signature) + req.Header.Set("FTX-TS", ts) + if r.sub != "" { + req.Header.Set("FTX-SUBACCOUNT", r.sub) + } + + return req, nil +} + +func sign(secret, body string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(body)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func timestamp() int64 { + return time.Now().UnixNano() / int64(time.Millisecond) +} + +func (r *restRequest) sendRequest(req *http.Request) (*util.Response, error) { + resp, err := r.c.Do(req) + if err != nil { + return nil, err + } + + // newResponse reads the response body and return a new Response object + response, err := util.NewResponse(resp) + if err != nil { + return response, err + } + + // Check error, if there is an error, return the ErrorResponse struct type + if response.IsError() { + errorResponse, err := toErrorResponse(response) + if err != nil { + return response, err + } + return response, errorResponse + } + + return response, nil +} + +type ErrorResponse struct { + *util.Response + + IsSuccess bool `json:"Success"` + ErrorString string `json:"error,omitempty"` +} + +func (r *ErrorResponse) Error() string { + return fmt.Sprintf("%s %s %d, Success: %t, err: %s", + r.Response.Request.Method, + r.Response.Request.URL.String(), + r.Response.StatusCode, + r.IsSuccess, + r.ErrorString, + ) +} + +func toErrorResponse(response *util.Response) (*ErrorResponse, error) { + errorResponse := &ErrorResponse{Response: response} + + if response.IsJSON() { + var err = response.DecodeJSON(errorResponse) + if err != nil { + return nil, errors.Wrapf(err, "failed to decode json for response: %d %s", response.StatusCode, string(response.Body)) + } + + if errorResponse.IsSuccess { + return nil, fmt.Errorf("response.Success should be false") + } + return errorResponse, nil + } + + return errorResponse, fmt.Errorf("unexpected response content type %s", response.Header.Get("content-type")) +} diff --git a/pkg/exchange/ftx/rest_test.go b/pkg/exchange/ftx/rest_test.go new file mode 100644 index 000000000..ca1adaf3a --- /dev/null +++ b/pkg/exchange/ftx/rest_test.go @@ -0,0 +1,34 @@ +package ftx + +import ( + "bytes" + "io/ioutil" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/c9s/bbgo/pkg/util" +) + +func Test_toErrorResponse(t *testing.T) { + r, err := util.NewResponse(&http.Response{ + Header: http.Header{}, + StatusCode: 200, + Body: ioutil.NopCloser(bytes.NewReader([]byte(`{"Success": true}`))), + }) + assert.NoError(t, err) + + _, err = toErrorResponse(r) + assert.EqualError(t, err, "unexpected response content type ") + r.Header.Set("content-type", "text/json") + + _, err = toErrorResponse(r) + assert.EqualError(t, err, "response.Success should be false") + + r.Body = []byte(`{"error":"Not logged in","Success":false}`) + errResp, err := toErrorResponse(r) + assert.NoError(t, err) + assert.False(t, errResp.IsSuccess) + assert.Equal(t, "Not logged in", errResp.ErrorString) +} diff --git a/pkg/exchange/max/maxapi/restapi.go b/pkg/exchange/max/maxapi/restapi.go index 1a0e9c3d4..000ba91bb 100644 --- a/pkg/exchange/max/maxapi/restapi.go +++ b/pkg/exchange/max/maxapi/restapi.go @@ -274,7 +274,7 @@ func (c *RestClient) sendRequest(req *http.Request) (*util.Response, error) { } // Check error, if there is an error, return the ErrorResponse struct type - if isError(response) { + if response.IsError() { errorResponse, err := toErrorResponse(response) if err != nil { return response, err @@ -359,12 +359,6 @@ func (r *ErrorResponse) Error() string { ) } -// isError check the response status code so see if a response is an error. -func isError(response *util.Response) bool { - var c = response.StatusCode - return c < 200 || c > 299 -} - // toErrorResponse tries to convert/parse the server response to the standard Error interface object func toErrorResponse(response *util.Response) (errorResponse *ErrorResponse, err error) { errorResponse = &ErrorResponse{Response: response} diff --git a/pkg/types/exchange.go b/pkg/types/exchange.go index 236f80bac..d692f6002 100644 --- a/pkg/types/exchange.go +++ b/pkg/types/exchange.go @@ -19,6 +19,7 @@ func (n ExchangeName) String() string { const ( ExchangeMax = ExchangeName("max") ExchangeBinance = ExchangeName("binance") + ExchangeFTX = ExchangeName("ftx") ) func ValidExchangeName(a string) (ExchangeName, error) { diff --git a/pkg/util/http_response.go b/pkg/util/http_response.go index 05698ec3c..76b683716 100644 --- a/pkg/util/http_response.go +++ b/pkg/util/http_response.go @@ -36,3 +36,23 @@ func (r *Response) String() string { func (r *Response) DecodeJSON(o interface{}) error { return json.Unmarshal(r.Body, o) } + +func (r *Response) IsError() bool { + return r.StatusCode >= 400 +} + +func (r *Response) IsJSON() bool { + switch r.Header.Get("content-type") { + case "text/json", "application/json", "application/json; charset=utf-8": + return true + } + return false +} + +func (r *Response) IsHTML() bool { + switch r.Header.Get("content-type") { + case "text/html": + return true + } + return false +} diff --git a/pkg/util/http_response_test.go b/pkg/util/http_response_test.go index 13fbe7897..af864f825 100644 --- a/pkg/util/http_response_test.go +++ b/pkg/util/http_response_test.go @@ -26,3 +26,49 @@ func TestResponse_DecodeJSON(t *testing.T) { assert.NoError(t, resp.DecodeJSON(&result)) assert.Equal(t, "Test Name", result.Name) } + +func TestResponse_IsError(t *testing.T) { + resp := &Response{Response: &http.Response{}} + cases := map[int]bool{ + 100: false, + 200: false, + 300: false, + 400: true, + 500: true, + } + + for code, isErr := range cases { + resp.StatusCode = code + assert.Equal(t, isErr, resp.IsError()) + } +} + +func TestResponse_IsJSON(t *testing.T) { + cases := map[string]bool{ + "text/json": true, + "application/json": true, + "application/json; charset=utf-8": true, + "text/html": false, + } + for k, v := range cases { + resp := &Response{Response: &http.Response{}} + resp.Header = http.Header{} + resp.Header.Set("content-type", k) + assert.Equal(t, v, resp.IsJSON()) + } +} + +func TestResponse_IsHTML(t *testing.T) { + cases := map[string]bool{ + "text/json": false, + "application/json": false, + "application/json; charset=utf-8": false, + "text/html": true, + } + for k, v := range cases { + resp := &Response{Response: &http.Response{}} + resp.Header = http.Header{} + resp.Header.Set("content-type", k) + assert.Equal(t, v, resp.IsHTML()) + } +}