Skip to content
This repository has been archived by the owner on Feb 1, 2024. It is now read-only.

feat(424): generic price feed #735

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions examples/configs/trader/sample_buysell.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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=<YOUR_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=<URL>;<RESPONSE_PATH>

# 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"
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
59 changes: 59 additions & 0 deletions plugins/genericPriceFeed.go
Original file line number Diff line number Diff line change
@@ -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
}
186 changes: 186 additions & 0 deletions plugins/genericPriceFeed_test.go
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 4 additions & 0 deletions plugins/priceFeed.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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":
Expand Down
52 changes: 52 additions & 0 deletions plugins/priceFeed_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
27 changes: 27 additions & 0 deletions support/json/gjsonwrapper.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading