Skip to content

Commit 412b81c

Browse files
authoredJan 17, 2020
new feed type "function" with "max" and "invert" functions operating recursively on price feeds (closes stellar-deprecated#338) (stellar-deprecated#339)
* 1 - initial implementation, function feed and max function * 2 - tests for extractFunctionParts with a fix for extractFunctionParts * 3 - priceFeed_test * 4 - updated sample config files * 5 - invert function type added
1 parent 9062d7d commit 412b81c

7 files changed

+306
-2
lines changed
 

‎examples/configs/trader/sample_buysell.cfg

+11-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
# Price Feeds
44
# Note: we take the value from the A feed and divide it by the value retrieved from the B feed below.
5-
# the type of feeds can be one of crypto, fiat, fixed, exchange, sdex.
5+
# the type of feeds can be one of crypto, fiat, fixed, exchange, sdex, function.
66

77
# specification of feed type "exchange"
88
DATA_TYPE_A="exchange"
@@ -51,6 +51,16 @@ DATA_FEED_B_URL="1.0"
5151
# for XLM leave the issuer string blank
5252
# DATA_FEED_A_URL="COUPON:GBMMZMK2DC4FFP4CAI6KCVNCQ7WLO5A7DQU7EC7WGHRDQBZB763X4OQI/XLM:"
5353

54+
# sample priceFeed of type "function"
55+
# this feed type uses one of the pre-defined functions to recursively operate on other price feeds
56+
# all URLs for this type of feed are formatted like so: function_name(feed_type/feed_url[,feed_type/feed_url])
57+
#DATA_TYPE_A = "function"
58+
# the supported functions for now are only the "max" and "invert" functions, example usage:
59+
# "max": max(exchange/ccxt-kraken/XLM/USD/mid,exchange/ccxt-binance/XLM/USDT/mid) -- will give you the larger price
60+
# between kraken's mid price and binance's mid price
61+
# "invert": invert(exchange/ccxt-kraken/XLM/USD/mid) -- will give you the effective USD/XLM price
62+
#DATA_FEED_A_URL = "max(exchange/ccxt-kraken/XLM/USD/mid,exchange/ccxt-binance/XLM/USDT/mid)"
63+
5464
# what value of a price change triggers re-creating an offer. Price change refers to the existing price of the offer vs. what price we want to set. value is a percentage specified as a decimal number (0 < value < 1.00)
5565
PRICE_TOLERANCE=0.001
5666

‎examples/configs/trader/sample_sell.cfg

+11-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
# Price Feeds
66
# Note: we take the value from the A feed and divide it by the value retrieved from the B feed below.
7-
# the type of feeds can be one of crypto, fiat, fixed, exchange, sdex.
7+
# the type of feeds can be one of crypto, fiat, fixed, exchange, sdex, function.
88

99
# specification of feed type "exchange"
1010
DATA_TYPE_A="exchange"
@@ -53,6 +53,16 @@ DATA_FEED_B_URL="1.0"
5353
# for XLM leave the issuer string blank
5454
# DATA_FEED_A_URL="COUPON:GBMMZMK2DC4FFP4CAI6KCVNCQ7WLO5A7DQU7EC7WGHRDQBZB763X4OQI/XLM:"
5555

56+
# sample priceFeed of type "function"
57+
# this feed type uses one of the pre-defined functions to recursively operate on other price feeds
58+
# all URLs for this type of feed are formatted like so: function_name(feed_type/feed_url[,feed_type/feed_url])
59+
#DATA_TYPE_A = "function"
60+
# the supported functions for now are only the "max" and "invert" functions, example usage:
61+
# "max": max(exchange/ccxt-kraken/XLM/USD/mid,exchange/ccxt-binance/XLM/USDT/mid) -- will give you the larger price
62+
# between kraken's mid price and binance's mid price
63+
# "invert": invert(exchange/ccxt-kraken/XLM/USD/mid) -- will give you the effective USD/XLM price
64+
#DATA_FEED_A_URL = "max(exchange/ccxt-kraken/XLM/USD/mid,exchange/ccxt-binance/XLM/USDT/mid)"
65+
5666
# what value of a price change triggers re-creating an offer. Price change refers to the existing price of the offer vs. what price we want to set. value is a percentage specified as a decimal number (0 < value < 1.00)
5767
PRICE_TOLERANCE=0.001
5868

‎plugins/functionFeed.go

+87
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package plugins
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
"strings"
7+
8+
"github.com/stellar/kelp/api"
9+
)
10+
11+
type functionFeed struct {
12+
getPriceFn func() (float64, error)
13+
}
14+
15+
var _ api.PriceFeed = &functionFeed{}
16+
17+
// makeFeedFromFn is a convenience factory method that converts a GetPrice function in a PriceFeed struct
18+
func makeFunctionFeed(fn func() (float64, error)) api.PriceFeed {
19+
return &functionFeed{
20+
getPriceFn: fn,
21+
}
22+
}
23+
24+
// GetPrice delegator function
25+
func (f *functionFeed) GetPrice() (float64, error) {
26+
return f.getPriceFn()
27+
}
28+
29+
func makeFunctionPriceFeed(url string) (api.PriceFeed, error) {
30+
name, argsString, e := extractFunctionParts(url)
31+
if e != nil {
32+
return nil, fmt.Errorf("unable to extract function name from URL: %s", e)
33+
}
34+
35+
f, ok := fnFactoryMap[name]
36+
if !ok {
37+
return nil, fmt.Errorf("the passed in URL does not have the registered function '%s'", name)
38+
}
39+
40+
feeds, e := makeFeedsArray(argsString)
41+
if e != nil {
42+
return nil, fmt.Errorf("error when makings feeds array: %s", e)
43+
}
44+
45+
pf, e := f(feeds)
46+
if e != nil {
47+
return nil, fmt.Errorf("error when invoking price feed function '%s': %s", name, e)
48+
}
49+
50+
return pf, nil
51+
}
52+
53+
func extractFunctionParts(url string) (name string, args string, e error) {
54+
fnNameRegex, e := regexp.Compile("^([a-zA-Z]+)\\((.*)\\)$")
55+
if e != nil {
56+
return "", "", fmt.Errorf("unable to make regexp (programmer error)")
57+
}
58+
59+
submatches := fnNameRegex.FindStringSubmatch(url)
60+
if len(submatches) != 3 {
61+
return "", "", fmt.Errorf("incorrect number of matches, expected 3 entries in the returned array (matchedString, subgroup1, subgroup2), but found %v", submatches)
62+
}
63+
64+
return submatches[1], submatches[2], nil
65+
}
66+
67+
func makeFeedsArray(feedsStringCSV string) ([]api.PriceFeed, error) {
68+
parts := strings.Split(feedsStringCSV, ",")
69+
arr := []api.PriceFeed{}
70+
71+
for _, argPart := range parts {
72+
feedSpecParts := strings.SplitN(argPart, "/", 2)
73+
if len(feedSpecParts) != 2 {
74+
return nil, fmt.Errorf("unable to correctly split arg into a price feed spec: %s", argPart)
75+
}
76+
priceFeedType := feedSpecParts[0]
77+
priceFeedURL := feedSpecParts[1]
78+
79+
feed, e := MakePriceFeed(priceFeedType, priceFeedURL)
80+
if e != nil {
81+
return nil, fmt.Errorf("error creating a price feed (typ='%s', url='%s'): %s", priceFeedType, priceFeedURL, e)
82+
}
83+
arr = append(arr, feed)
84+
}
85+
86+
return arr, nil
87+
}

‎plugins/functionFeed_test.go

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package plugins
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestExtractFunctionParts(t *testing.T) {
10+
testCases := []struct {
11+
inputURL string
12+
wantName string
13+
wantArgs string
14+
}{
15+
{
16+
inputURL: "max(test)",
17+
wantName: "max",
18+
wantArgs: "test",
19+
}, {
20+
inputURL: "invert(max(test))",
21+
wantName: "invert",
22+
wantArgs: "max(test)",
23+
}, {
24+
inputURL: "max(fixed/0.02,crypto/https://api.coinmarketcap.com/v1/ticker/stellar/)",
25+
wantName: "max",
26+
wantArgs: "fixed/0.02,crypto/https://api.coinmarketcap.com/v1/ticker/stellar/",
27+
},
28+
}
29+
30+
for _, k := range testCases {
31+
t.Run(k.inputURL, func(t *testing.T) {
32+
fnName, fnArgs, e := extractFunctionParts(k.inputURL)
33+
if !assert.NoError(t, e) {
34+
return
35+
}
36+
37+
assert.Equal(t, k.wantName, fnName)
38+
assert.Equal(t, k.wantArgs, fnArgs)
39+
})
40+
}
41+
}

‎plugins/priceFeed.go

+6
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ func MakePriceFeed(feedType string, url string) (api.PriceFeed, error) {
7979
return nil, fmt.Errorf("error occurred while making the SDEX price feed: %s", e)
8080
}
8181
return sdex, nil
82+
case "function":
83+
fnFeed, e := makeFunctionPriceFeed(url)
84+
if e != nil {
85+
return nil, fmt.Errorf("error while making function feed for URL '%s': %s", url, e)
86+
}
87+
return fnFeed, nil
8288
}
8389
return nil, fmt.Errorf("unable to make price feed for feedType=%s and url=%s", feedType, url)
8490
}

‎plugins/priceFeedFunctions.go

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package plugins
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/stellar/kelp/api"
7+
)
8+
9+
type fnFactory func(feeds []api.PriceFeed) (api.PriceFeed, error)
10+
11+
var fnFactoryMap = map[string]fnFactory{
12+
"max": max,
13+
"invert": invert,
14+
}
15+
16+
func max(feeds []api.PriceFeed) (api.PriceFeed, error) {
17+
if len(feeds) < 2 {
18+
return nil, fmt.Errorf("need to provide at least 2 price feeds to the 'max' price feed function but found only %d price feeds", len(feeds))
19+
}
20+
21+
return makeFunctionFeed(func() (float64, error) {
22+
max := -1.0
23+
for i, f := range feeds {
24+
innerPrice, e := f.GetPrice()
25+
if e != nil {
26+
return 0.0, fmt.Errorf("error fetching price from feed (index=%d) in 'max' function feed: %s", i, e)
27+
}
28+
29+
if innerPrice <= 0.0 {
30+
return 0.0, fmt.Errorf("inner price of feed at index %d was <= 0.0 (%.10f)", i, innerPrice)
31+
}
32+
33+
if innerPrice > max {
34+
max = innerPrice
35+
}
36+
}
37+
return max, nil
38+
}), nil
39+
}
40+
41+
func invert(feeds []api.PriceFeed) (api.PriceFeed, error) {
42+
if len(feeds) != 1 {
43+
return nil, fmt.Errorf("need to provide exactly 1 price feed to the 'invert' function but found %d price feeds", len(feeds))
44+
}
45+
46+
return makeFunctionFeed(func() (float64, error) {
47+
innerPrice, e := feeds[0].GetPrice()
48+
if e != nil {
49+
return 0.0, fmt.Errorf("error fetching price from feed in 'invert' function feed: %s", e)
50+
}
51+
52+
if innerPrice <= 0.0 {
53+
return 0.0, fmt.Errorf("inner price of feed was <= 0.0 (%.10f)", innerPrice)
54+
}
55+
56+
return 1 / innerPrice, nil
57+
}), nil
58+
}

‎plugins/priceFeed_test.go

+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package plugins
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
const wantLowerBoundXLM = 0.04
11+
const wantUpperBoundXLM = 0.08
12+
13+
func TestMakePriceFeed(t *testing.T) {
14+
testCases := []struct {
15+
typ string
16+
url string
17+
wantLowerOrEqualBound float64
18+
wantHigherOrEqualBound float64
19+
}{
20+
{
21+
typ: "exchange",
22+
url: "kraken/XXLM/ZUSD/mid",
23+
wantLowerOrEqualBound: wantLowerBoundXLM,
24+
wantHigherOrEqualBound: wantUpperBoundXLM,
25+
}, {
26+
typ: "exchange",
27+
url: "ccxt-kraken/XLM/USD/last",
28+
wantLowerOrEqualBound: wantLowerBoundXLM,
29+
wantHigherOrEqualBound: wantUpperBoundXLM,
30+
}, {
31+
typ: "exchange",
32+
url: "ccxt-binance/XLM/USDT/bid",
33+
wantLowerOrEqualBound: wantLowerBoundXLM,
34+
wantHigherOrEqualBound: wantUpperBoundXLM,
35+
}, {
36+
typ: "exchange",
37+
url: "ccxt-coinbasepro/XLM/USD/ask",
38+
wantLowerOrEqualBound: wantLowerBoundXLM,
39+
wantHigherOrEqualBound: wantUpperBoundXLM,
40+
}, {
41+
typ: "fixed",
42+
url: "1.23456",
43+
wantLowerOrEqualBound: 1.23456,
44+
wantHigherOrEqualBound: 1.23456,
45+
}, {
46+
typ: "sdex",
47+
url: "XLM:/USD:GDUKMGUGDZQK6YHYA5Z6AY2G4XDSZPSZ3SW5UN3ARVMO6QSRDWP5YLEX",
48+
wantLowerOrEqualBound: wantLowerBoundXLM,
49+
wantHigherOrEqualBound: wantUpperBoundXLM,
50+
}, {
51+
typ: "sdex",
52+
url: "USD:GDUKMGUGDZQK6YHYA5Z6AY2G4XDSZPSZ3SW5UN3ARVMO6QSRDWP5YLEX/XLM:",
53+
wantLowerOrEqualBound: 1 / wantUpperBoundXLM,
54+
wantHigherOrEqualBound: 1 / wantLowerBoundXLM,
55+
}, {
56+
typ: "function",
57+
url: "max(fixed/1.0,fixed/1.4)",
58+
wantLowerOrEqualBound: 1.4,
59+
wantHigherOrEqualBound: 1.4,
60+
}, {
61+
typ: "function",
62+
url: "max(fixed/0.02,exchange/ccxt-kraken/XLM/USD/last)",
63+
wantLowerOrEqualBound: wantLowerBoundXLM,
64+
wantHigherOrEqualBound: wantUpperBoundXLM,
65+
}, {
66+
typ: "function",
67+
url: "invert(fixed/0.02)",
68+
wantLowerOrEqualBound: 50.0,
69+
wantHigherOrEqualBound: 50.0,
70+
},
71+
// not testing fiat here because it requires an access key
72+
// not testing crypto here because it's returning an error when passed an actual URL but works in practice
73+
}
74+
75+
// cannot run this in parallel because ccxt fails (by not recognizing exchanges) when hit with too many requests at once
76+
for _, k := range testCases {
77+
t.Run(k.typ+"/"+k.url, func(t *testing.T) {
78+
pf, e := MakePriceFeed(k.typ, k.url)
79+
if !assert.NoError(t, e) {
80+
return
81+
}
82+
83+
price, e := pf.GetPrice()
84+
if !assert.NoError(t, e) {
85+
return
86+
}
87+
88+
assert.True(t, price >= k.wantLowerOrEqualBound, fmt.Sprintf("price was %.10f, should have been >= %.10f", price, k.wantLowerOrEqualBound))
89+
assert.True(t, price <= k.wantHigherOrEqualBound, fmt.Sprintf("price was %.10f, should have been <= %.10f", price, k.wantHigherOrEqualBound))
90+
})
91+
}
92+
}

0 commit comments

Comments
 (0)
Please sign in to comment.