diff --git a/examples/configs/trader/sample_buysell.cfg b/examples/configs/trader/sample_buysell.cfg index 0afb9a906..c607ee672 100644 --- a/examples/configs/trader/sample_buysell.cfg +++ b/examples/configs/trader/sample_buysell.cfg @@ -51,6 +51,22 @@ DATA_FEED_B_URL="1.0" # DATA_TYPE_B="fiat-oxr" # DATA_FEED_B_URL=https://openexchangerates.org/api/latest.json?app_id=&base=USD&symbols=NGN&prettyprint=true&show_alternative=true +# This is an example for generic price feed +# To use this you must supply the url of the feed and the path to the price in the expected response separated by a ; +# For example, given a feed url http://www.feed.com and expected response of +# { +# base: USD, +# rates: { +# AED: 3.672538, +# AFN: 66.809999, +# } +# } +# then the url would be http://www.feed.com;rates.AFN to retrieve the price for AFN +# Full examples of retrieving values from JSON payload can be found here https://github.com/tidwall/gjson/blob/master/SYNTAX.md +# +#DATA_TYPE_B="generic-price-feed" +#DATA_FEED_B_URL=; + # sample priceFeed with the "sdex" type # this feed pulls from the SDEX, you can use the asset you're trading or something else, like the same coin from another issuer # DATA_TYPE_A = "sdex" diff --git a/go.mod b/go.mod index 3e0d4e427..a41acc0ed 100644 --- a/go.mod +++ b/go.mod @@ -59,6 +59,8 @@ require ( github.com/stretchr/objx v0.3.0 // indirect github.com/stretchr/testify v1.6.1 github.com/subosito/gotenv v1.2.1-0.20190917103637-de67a6614a4d // indirect + github.com/tidwall/gjson v1.8.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 // indirect golang.org/x/net v0.0.0-20210614182718-04defd469f4e // indirect golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914 // indirect diff --git a/go.sum b/go.sum index bb68f22fd..8c6c6cf35 100644 --- a/go.sum +++ b/go.sum @@ -483,6 +483,13 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.2.1-0.20190917103637-de67a6614a4d h1:YN4gX82mT31qsizy2jRheOCrGLCs15VF9SV5XPuBvkQ= github.com/subosito/gotenv v1.2.1-0.20190917103637-de67a6614a4d/go.mod h1:GVSeM7r0P1RI1gOKYyN9IuNkhMmQwKGsjVf3ulDrdzo= +github.com/tidwall/gjson v1.8.1 h1:8j5EE9Hrh3l9Od1OIEDAb7IpezNA20UdRngNAj5N0WU= +github.com/tidwall/gjson v1.8.1/go.mod h1:5/xDoumyyDNerp2U36lyolv46b3uF/9Bu6OfyQ9GImk= +github.com/tidwall/match v1.0.3 h1:FQUVvBImDutD8wJLN6c5eMzWtjgONK9MwIBCOrUJKeE= +github.com/tidwall/match v1.0.3/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.1.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tyler-smith/go-bip39 v0.0.0-20180618194314-52158e4697b8/go.mod h1:sJ5fKU0s6JVwZjjcUEX2zFOnvq0ASQ2K9Zr6cf67kNs= github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc= diff --git a/plugins/genericPriceFeed.go b/plugins/genericPriceFeed.go new file mode 100644 index 000000000..85356d8e8 --- /dev/null +++ b/plugins/genericPriceFeed.go @@ -0,0 +1,59 @@ +package plugins + +import ( + "fmt" + "log" + "strconv" + "strings" +) + +type HttpClient interface { + Get(url string) ([]byte, error) +} + +type JsonParser interface { + GetRawJsonValue(json []byte, path string) (string, error) +} + +type GenericPriceFeed struct { + url string + jsonPath string + httpClient HttpClient + jsonParser JsonParser +} + +func newGenericPriceFeed(url string, httpClient HttpClient, jsonParser JsonParser) (*GenericPriceFeed, error) { + parts := strings.Split(url, ";") + if len(parts) != 2 { + return nil, fmt.Errorf("make price feed: generic price feed invalid url %s", url) + } + return &GenericPriceFeed{ + url: parts[0], + jsonPath: parts[1], + httpClient: httpClient, + jsonParser: jsonParser, + }, nil +} + +func (gpf GenericPriceFeed) GetPrice() (float64, error) { + res, err := gpf.httpClient.Get(gpf.url) + if err != nil { + return 0, fmt.Errorf("generic price feed error: %w", err) + } + + rawValue, err := gpf.jsonParser.GetRawJsonValue(res, gpf.jsonPath) + if err != nil { + return 0, fmt.Errorf("generic price feed error: %w", err) + } + + rawPrice := strings.Trim(rawValue, "\" ") + + price, err := strconv.ParseFloat(rawPrice, 64) + if err != nil { + return 0, fmt.Errorf("generic price feed error: %w", err) + } + + log.Println(fmt.Sprintf("price retrieved for generic %f", price)) + + return price, nil +} diff --git a/plugins/genericPriceFeed_test.go b/plugins/genericPriceFeed_test.go new file mode 100644 index 000000000..5020a4c37 --- /dev/null +++ b/plugins/genericPriceFeed_test.go @@ -0,0 +1,186 @@ +package plugins + +import ( + "fmt" + "testing" + + "github.com/stellar/kelp/tests" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetPrice_NewGenericPriceFeed(t *testing.T) { + url := fmt.Sprintf("%s;%s", tests.RandomString(), tests.RandomString()) + genericPriceFeed, err := newGenericPriceFeed(url, mockHttpClient{}, mockJsonParser{}) + assert.NoError(t, err) + assert.NotNil(t, genericPriceFeed) +} + +func TestGetPrice_NewGenericPriceFeed_InvalidURL(t *testing.T) { + url := tests.RandomString() + + httpClient := mockHttpClient{ + bytes: []byte{}, + err: fmt.Errorf(tests.RandomString()), + } + + genericPriceFeed, err := newGenericPriceFeed(url, httpClient, mockJsonParser{}) + + expected := fmt.Sprintf("make price feed: generic price feed invalid url %s", url) + + assert.Nil(t, genericPriceFeed) + assert.EqualError(t, err, expected) +} + +func TestGetPrice_HttpClient_Error(t *testing.T) { + url := fmt.Sprintf("%s;%s", tests.RandomString(), tests.RandomString()) + + httpClient := mockHttpClient{ + bytes: []byte{}, + err: fmt.Errorf(tests.RandomString()), + } + + genericPriceFeed, err := newGenericPriceFeed(url, httpClient, mockJsonParser{}) + require.NoError(t, err) + + price, err := genericPriceFeed.GetPrice() + + expected := fmt.Sprintf("generic price feed error: %s", httpClient.err.Error()) + + assert.EqualError(t, err, expected) + assert.Equal(t, float64(0), price) +} + +func TestGetPrice_JsonParser_Error(t *testing.T) { + url := fmt.Sprintf("%s;%s", tests.RandomString(), tests.RandomString()) + + httpClient := mockHttpClient{ + bytes: []byte{}, + err: nil, + } + + jsonParser := mockJsonParser{ + rawValue: "", + err: fmt.Errorf(tests.RandomString()), + } + + genericPriceFeed, err := newGenericPriceFeed(url, httpClient, jsonParser) + require.NoError(t, err) + + price, err := genericPriceFeed.GetPrice() + + expected := fmt.Sprintf("generic price feed error: %s", jsonParser.err.Error()) + + assert.EqualError(t, err, expected) + assert.Equal(t, float64(0), price) +} + +func TestGetPrice_ParseFloat_Error(t *testing.T) { + url := fmt.Sprintf("%s;%s", tests.RandomString(), tests.RandomString()) + + httpClient := mockHttpClient{ + bytes: []byte{}, + err: nil, + } + + jsonParser := mockJsonParser{ + rawValue: tests.RandomString(), + err: nil, + } + + genericPriceFeed, err := newGenericPriceFeed(url, httpClient, jsonParser) + require.NoError(t, err) + + price, err := genericPriceFeed.GetPrice() + + assert.Error(t, err) + assert.Contains(t, err.Error(), jsonParser.rawValue) + assert.Equal(t, float64(0), price) +} + +func TestGetPrice_Float(t *testing.T) { + url := fmt.Sprintf("%s;%s", tests.RandomString(), tests.RandomString()) + + httpClient := mockHttpClient{ + bytes: []byte{}, + err: nil, + } + + expected := tests.RandomFloat64() + jsonParser := mockJsonParser{ + rawValue: fmt.Sprintf("%f", expected), + err: nil, + } + + genericPriceFeed, err := newGenericPriceFeed(url, httpClient, jsonParser) + require.NoError(t, err) + + price, err := genericPriceFeed.GetPrice() + + assert.Equal(t, expected, price) + assert.NoError(t, err) +} + +func TestGetPrice_Trim_DoubleQuotes(t *testing.T) { + url := fmt.Sprintf("%s;%s", tests.RandomString(), tests.RandomString()) + + httpClient := mockHttpClient{ + bytes: []byte{}, + err: nil, + } + + expected := tests.RandomFloat64() + jsonParser := mockJsonParser{ + rawValue: fmt.Sprintf("\"%f\"", expected), + err: nil, + } + + genericPriceFeed, err := newGenericPriceFeed(url, httpClient, jsonParser) + require.NoError(t, err) + + price, err := genericPriceFeed.GetPrice() + + assert.Equal(t, expected, price) + assert.NoError(t, err) +} + +func TestGetPrice_Trim_WhiteSpace(t *testing.T) { + url := fmt.Sprintf("%s;%s", tests.RandomString(), tests.RandomString()) + + httpClient := mockHttpClient{ + bytes: []byte{}, + err: nil, + } + + expected := tests.RandomFloat64() + jsonParser := mockJsonParser{ + rawValue: fmt.Sprintf(" %f ", expected), + err: nil, + } + + genericPriceFeed, err := newGenericPriceFeed(url, httpClient, jsonParser) + require.NoError(t, err) + + price, err := genericPriceFeed.GetPrice() + + assert.Equal(t, expected, price) + assert.NoError(t, err) +} + +type mockHttpClient struct { + bytes []byte + err error +} + +func (m mockHttpClient) Get(url string) ([]byte, error) { + return m.bytes, m.err +} + +type mockJsonParser struct { + rawValue string + err error +} + +func (m mockJsonParser) GetRawJsonValue(json []byte, path string) (string, error) { + return m.rawValue, m.err +} diff --git a/plugins/priceFeed.go b/plugins/priceFeed.go index 80307c0a9..732edebbc 100644 --- a/plugins/priceFeed.go +++ b/plugins/priceFeed.go @@ -2,6 +2,8 @@ package plugins import ( "fmt" + . "github.com/stellar/kelp/support/json" + . "github.com/stellar/kelp/support/networking" "strings" "github.com/stellar/go/clients/horizonclient" @@ -42,6 +44,8 @@ func MakePriceFeed(feedType string, url string) (api.PriceFeed, error) { return newFiatFeed(url), nil case "fiat-oxr": return newFiatFeedOxr(url), nil + case "generic-price-feed": + return newGenericPriceFeed(url, NewHttpClient(), NewJsonParserWrapper()) case "fixed": return newFixedFeed(url) case "exchange": diff --git a/plugins/priceFeed_test.go b/plugins/priceFeed_test.go index ba8e54c70..537b8dc8c 100644 --- a/plugins/priceFeed_test.go +++ b/plugins/priceFeed_test.go @@ -164,3 +164,55 @@ func TestMakePriceFeed_CryptoFeed_Success(t *testing.T) { assert.Equal(t, expected, price) assert.NoError(t, err) } + +func TestMakePriceFeed_GenericFeed_Error(t *testing.T) { + url := "http://no-delimiter.com" + priceFeed, err := MakePriceFeed("generic-price-feed", url) + assert.Nil(t, priceFeed) + assert.EqualError(t, err, fmt.Sprintf("make price feed: generic price feed invalid url %s", url)) +} + +// uses mock call +func TestMakePriceFeed_GenericFeed_Success(t *testing.T) { + expected := tests.RandomInt() + + number := make(map[string]int) + + num := tests.RandomString() + + number[num] = expected + number[tests.RandomString()] = tests.RandomInt() + number[tests.RandomString()] = tests.RandomInt() + number[tests.RandomString()] = tests.RandomInt() + + response := TestJsonParserWrapperResponse{ + Data: TestJsonParserWrapperRates{ + Number: number, + }, + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + err := json.NewEncoder(w).Encode(response) + require.NoError(t, err) + })) + defer ts.Close() + + url := fmt.Sprintf("%s;%s", ts.URL, fmt.Sprintf("data.number.%s", num)) + + priceFeed, err := MakePriceFeed("generic-price-feed", url) + assert.NoError(t, err) + + price, err := priceFeed.GetPrice() + assert.Equal(t, float64(expected), price) + assert.NoError(t, err) +} + +type TestJsonParserWrapperResponse struct { + Data TestJsonParserWrapperRates `json:"data"` +} + +type TestJsonParserWrapperRates struct { + Number map[string]int `json:"number"` +} diff --git a/support/json/gjsonwrapper.go b/support/json/gjsonwrapper.go new file mode 100644 index 000000000..67d049815 --- /dev/null +++ b/support/json/gjsonwrapper.go @@ -0,0 +1,27 @@ +package json + +import ( + "fmt" + "github.com/tidwall/gjson" +) + +type GJsonParserWrapper struct{} + +func NewJsonParserWrapper() *GJsonParserWrapper { + return &GJsonParserWrapper{} +} + +func (j GJsonParserWrapper) GetRawJsonValue(json []byte, path string) (string, error) { + value := gjson.GetBytes(json, path) + + if value.Raw == "" { + return "", fmt.Errorf("json parser wrapper error: could not find json for path %s in %s", path, json) + } + + return value.Raw, nil +} + +func (j GJsonParserWrapper) GetNum(json []byte, path string) (float64, error) { + value := gjson.GetBytes(json, path) + return value.Num, nil +} diff --git a/support/json/gjsonwrapper_test.go b/support/json/gjsonwrapper_test.go new file mode 100644 index 000000000..33c0eaef5 --- /dev/null +++ b/support/json/gjsonwrapper_test.go @@ -0,0 +1,126 @@ +package json + +import ( + "encoding/json" + "fmt" + "github.com/stellar/kelp/tests" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestGJsonWrapper_GetRawJsonValue_Error(t *testing.T) { + response := TestJsonParserWrapperResponse{ + Data: TestJsonParserWrapperRates{ + Raw: nil, + }, + } + + json, _ := json.Marshal(response) + path := "data.raw.non_existent_field" + + jsonParserWrapper := NewJsonParserWrapper() + + rawValue, err := jsonParserWrapper.GetRawJsonValue(json, path) + + assert.EqualError(t, err, fmt.Sprintf("json parser wrapper error: could not find json for path %s in %s", path, json)) + assert.Equal(t, "", rawValue) +} + +func TestGJsonWrapper_GetRawJsonValue_RawValue(t *testing.T) { + target := tests.RandomString() + + raw := make(map[string]string) + + r := tests.RandomString() + + raw[r] = target + raw[tests.RandomString()] = tests.RandomString() + raw[tests.RandomString()] = tests.RandomString() + raw[tests.RandomString()] = tests.RandomString() + + response := TestJsonParserWrapperResponse{ + Data: TestJsonParserWrapperRates{ + Raw: raw, + }, + } + + json, _ := json.Marshal(response) + path := fmt.Sprintf("data.raw.%s", r) + + jsonParserWrapper := NewJsonParserWrapper() + + actual, err := jsonParserWrapper.GetRawJsonValue(json, path) + + expected := fmt.Sprintf("\"%s\"", target) + + assert.Equal(t, expected, actual) + assert.Nil(t, err) +} + +func TestGJsonWrapper_GetNum_Input_Float64(t *testing.T) { + expected := tests.RandomFloat64() + + float := make(map[string]float64) + + f := tests.RandomString() + + float[f] = expected + float[tests.RandomString()] = tests.RandomFloat64() + float[tests.RandomString()] = tests.RandomFloat64() + float[tests.RandomString()] = tests.RandomFloat64() + + response := TestJsonParserWrapperResponse{ + Data: TestJsonParserWrapperRates{ + Float: float, + }, + } + + json, _ := json.Marshal(response) + path := fmt.Sprintf("data.float.%s", f) + + jsonParserWrapper := NewJsonParserWrapper() + + actual, err := jsonParserWrapper.GetNum(json, path) + + assert.Equal(t, expected, actual) + assert.Nil(t, err) +} + +func TestGJsonWrapper_GetNum_Input_Int(t *testing.T) { + expected := tests.RandomInt() + + number := make(map[string]int) + + num := tests.RandomString() + + number[num] = expected + number[tests.RandomString()] = tests.RandomInt() + number[tests.RandomString()] = tests.RandomInt() + number[tests.RandomString()] = tests.RandomInt() + + response := TestJsonParserWrapperResponse{ + Data: TestJsonParserWrapperRates{ + Number: number, + }, + } + + json, _ := json.Marshal(response) + path := fmt.Sprintf("data.number.%s", num) + + jsonParserWrapper := NewJsonParserWrapper() + + actual, err := jsonParserWrapper.GetNum(json, path) + + assert.Equal(t, float64(expected), actual) + assert.Nil(t, err) +} + +type TestJsonParserWrapperResponse struct { + Data TestJsonParserWrapperRates `json:"data"` +} + +type TestJsonParserWrapperRates struct { + Raw map[string]string `json:"raw"` + Float map[string]float64 `json:"float"` + Number map[string]int `json:"number"` +} diff --git a/support/networking/client.go b/support/networking/client.go new file mode 100644 index 000000000..f1e429b87 --- /dev/null +++ b/support/networking/client.go @@ -0,0 +1,35 @@ +package networking + +import ( + "fmt" + "io/ioutil" + "net/http" + "time" +) + +type httpClient struct { + client *http.Client +} + +func NewHttpClient() *httpClient { + return &httpClient{ + client: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +func (hc httpClient) Get(url string) ([]byte, error) { + res, err := hc.client.Get(url) + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("http client error: status code %d %w", res.StatusCode, err) + } + defer res.Body.Close() + + bytes, err := ioutil.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("http client error: could not read body %w", err) + } + + return bytes, nil +} diff --git a/support/networking/client_test.go b/support/networking/client_test.go new file mode 100644 index 000000000..3a90075c8 --- /dev/null +++ b/support/networking/client_test.go @@ -0,0 +1,55 @@ +package networking + +import ( + "fmt" + "github.com/stellar/kelp/tests" + "github.com/stretchr/testify/assert" + "net/http" + "net/http/httptest" + "testing" +) + +func TestClientGet_Error(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer ts.Close() + + expected := fmt.Sprintf("http client error: status code %d", http.StatusInternalServerError) + + c := NewHttpClient() + res, err := c.Get(ts.URL) + + assert.Nil(t, res) + assert.Contains(t, err.Error(), expected) +} + +func TestClientGet_BodyError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", "1") + })) + defer ts.Close() + + expected := fmt.Sprint("http client error: could not read body unexpected EOF") + + c := NewHttpClient() + res, err := c.Get(ts.URL) + + assert.Nil(t, res) + assert.Contains(t, err.Error(), expected) +} + +func TestClientGet_Ok(t *testing.T) { + response := tests.RandomString() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(response)) + })) + defer ts.Close() + + c := NewHttpClient() + res, err := c.Get(ts.URL) + + assert.Nil(t, err) + assert.Equal(t, response, string(res)) +} diff --git a/tests/testsupport.go b/tests/testsupport.go index bd734535f..2b2bf13d2 100644 --- a/tests/testsupport.go +++ b/tests/testsupport.go @@ -22,6 +22,10 @@ func RandomInt() int { return RandomIntWithMax(10) } +func RandomFloat64() float64 { + return float64(RandomIntWithMax(10) / 11) +} + func RandomIntWithMax(max int) int { return rand.Intn(max) //nolint: gosec }