From 8afec86c831c45aef2e4cc8e0c85c1de6d192325 Mon Sep 17 00:00:00 2001 From: Reid McCamish <43561569+Reidmcc@users.noreply.github.com> Date: Wed, 30 Jan 2019 20:41:44 -0600 Subject: [PATCH] Add sdex price feed (#90), closes #97 --- cmd/trade.go | 9 +++ examples/configs/trader/sample_buysell.cfg | 10 ++- examples/configs/trader/sample_sell.cfg | 10 ++- plugins/priceFeed.go | 30 +++++++ plugins/sdex.go | 73 +++++++++++++++++ plugins/sdexFeed.go | 91 ++++++++++++++++++++++ support/utils/functions.go | 19 +++++ trader/config.go | 27 +------ 8 files changed, 244 insertions(+), 25 deletions(-) create mode 100644 plugins/sdexFeed.go diff --git a/cmd/trade.go b/cmd/trade.go index 67f668c37..48373bdb7 100644 --- a/cmd/trade.go +++ b/cmd/trade.go @@ -163,6 +163,15 @@ func init() { sdexAssetMap, ) + // setting the temp hack variables for the sdex price feeds + e = plugins.SetPrivateSdexHack(client, utils.ParseNetwork(botConfig.HorizonURL)) + if e != nil { + l.Info("") + l.Errorf("%s", e) + // we want to delete all the offers and exit here since there is something wrong with our setup + deleteAllOffersAndExit(l, botConfig, client, sdex) + } + dataKey := model.MakeSortedBotKey(assetBase, assetQuote) strat, e := plugins.MakeStrategy(sdex, tradingPair, &assetBase, &assetQuote, *strategy, *stratConfigPath, *simMode) if e != nil { diff --git a/examples/configs/trader/sample_buysell.cfg b/examples/configs/trader/sample_buysell.cfg index e84b814f1..139a154c6 100644 --- a/examples/configs/trader/sample_buysell.cfg +++ b/examples/configs/trader/sample_buysell.cfg @@ -6,6 +6,7 @@ # fiat # fixed # exchange +# sdex # # We take the values from both feeds and divide them to get the center price. @@ -25,7 +26,7 @@ DATA_FEED_A_URL="kraken/XXLM/ZUSD" # this is the URL to a coinmarketcap feed which the bot understands. #DATA_FEED_A_URL="https://api.coinmarketcap.com/v1/ticker/stellar/" -# this is a fixed value of 1 here because the exchange priceFeed provides a ratio of two assets. +# this is a fixed value of 1 here because the exchange and sdex priceFeeds provides a ratio of two assets. DATA_TYPE_B="fixed" DATA_FEED_B_URL="1.0" @@ -34,6 +35,13 @@ DATA_FEED_B_URL="1.0" # you can use a service like apilayer.net to get prices for fiat if you want real-time updates. You will need to fill in the access_key in this url #DATA_FEED_B_URL="http://apilayer.net/api/live?access_key=¤cies=NGN" +# 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" +# this is a string representing a SDEX pair; the format is CODE:ISSUER/CODE:ISSUER +# for XLM leave the issuer string blank +# DATA_FEED_A_URL="COUPON:GBMMZMK2DC4FFP4CAI6KCVNCQ7WLO5A7DQU7EC7WGHRDQBZB763X4OQI/XLM:" + # 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) PRICE_TOLERANCE=0.001 diff --git a/examples/configs/trader/sample_sell.cfg b/examples/configs/trader/sample_sell.cfg index dda89943b..6d8b7be0b 100644 --- a/examples/configs/trader/sample_sell.cfg +++ b/examples/configs/trader/sample_sell.cfg @@ -8,6 +8,7 @@ # fiat # fixed # exchange +# sdex # # We take the values from both feeds and divide them to get the center price. @@ -27,7 +28,7 @@ DATA_FEED_A_URL="kraken/XXLM/ZUSD" # this is the URL to a coinmarketcap feed which the bot understands. #DATA_FEED_A_URL="https://api.coinmarketcap.com/v1/ticker/stellar/" -# this is a fixed value of 1 here because the exchange priceFeed provides a ratio of two assets. +# this is a fixed value of 1 here because the exchange and sdex priceFeeds provides a ratio of two assets. DATA_TYPE_B="fixed" DATA_FEED_B_URL="1.0" @@ -36,6 +37,13 @@ DATA_FEED_B_URL="1.0" # you can use a service like apilayer.net to get prices for fiat if you want real-time updates. You will need to fill in the access_key in this url #DATA_FEED_B_URL="http://apilayer.net/api/live?access_key=¤cies=NGN" +# 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" +# this is a string representing a SDEX pair; the format is CODE:ISSUER/CODE:ISSUER +# for XLM leave the issuer string blank +# DATA_FEED_A_URL="COUPON:GBMMZMK2DC4FFP4CAI6KCVNCQ7WLO5A7DQU7EC7WGHRDQBZB763X4OQI/XLM:" + # 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) PRICE_TOLERANCE=0.001 diff --git a/plugins/priceFeed.go b/plugins/priceFeed.go index 64c5abfe9..b7868757f 100644 --- a/plugins/priceFeed.go +++ b/plugins/priceFeed.go @@ -6,8 +6,32 @@ import ( "github.com/interstellar/kelp/api" "github.com/interstellar/kelp/model" + "github.com/stellar/go/build" + "github.com/stellar/go/clients/horizon" ) +// privateSdexHack is a temporary hack struct for SDEX price feeds pending refactor +type privateSdexHack struct { + API *horizon.Client + Network build.Network +} + +// privateSdexHackVar is a temporary hack variable for SDEX price feeds pending refactor +var privateSdexHackVar *privateSdexHack + +// SetPrivateSdexHack sets the privateSdexHack variable which is temporary until the pending SDEX price feed refactor +func SetPrivateSdexHack(api *horizon.Client, network build.Network) error { + if privateSdexHackVar != nil { + return fmt.Errorf("privateSdexHack is already set: %+v", privateSdexHackVar) + } + + privateSdexHackVar = &privateSdexHack{ + API: api, + Network: network, + } + return nil +} + // MakePriceFeed makes a PriceFeed func MakePriceFeed(feedType string, url string) (api.PriceFeed, error) { switch feedType { @@ -38,6 +62,12 @@ func MakePriceFeed(feedType string, url string) (api.PriceFeed, error) { } tickerAPI := api.TickerAPI(exchange) return newExchangeFeed(url, &tickerAPI, &tradingPair), nil + case "sdex": + sdex, e := makeSDEXFeed(url) + if e != nil { + return nil, fmt.Errorf("error occured while making the SDEX price feed: %s", e) + } + return sdex, nil } return nil, nil } diff --git a/plugins/sdex.go b/plugins/sdex.go index b4cd7dee6..c409e3d46 100644 --- a/plugins/sdex.go +++ b/plugins/sdex.go @@ -7,6 +7,7 @@ import ( "reflect" "strconv" "strings" + "time" "github.com/interstellar/kelp/api" "github.com/interstellar/kelp/model" @@ -813,3 +814,75 @@ func (sdex *SDEX) GetLatestTradeCursor() (interface{}, error) { return records[0].PT, nil } + +// GetOrderBook gets the SDEX orderbook +func (sdex *SDEX) GetOrderBook(pair *model.TradingPair, maxCount int32) (*model.OrderBook, error) { + if pair != sdex.pair { + return nil, fmt.Errorf("unregistered trading pair (%s) cannot be converted to horizon.Assets, instance's pair: %s", pair.String(), sdex.pair.String()) + } + + baseAsset, quoteAsset, e := sdex.pair2Assets() + if e != nil { + return nil, fmt.Errorf("cannot get SDEX orderbook: %s", e) + } + + ob, e := sdex.API.LoadOrderBook(baseAsset, quoteAsset) + if e != nil { + return nil, fmt.Errorf("cannot get SDEX orderbook: %s", e) + } + + ts := model.MakeTimestamp(time.Now().UnixNano() / int64(time.Millisecond)) + transformedBids, e := sdex.transformHorizonOrders(pair, ob.Bids, model.OrderActionBuy, ts, maxCount) + if e != nil { + return nil, fmt.Errorf("could not transform bid side of SDEX orderbook: %s", e) + } + + transformedAsks, e := sdex.transformHorizonOrders(pair, ob.Asks, model.OrderActionSell, ts, maxCount) + if e != nil { + return nil, fmt.Errorf("could not transform ask side of SDEX orderbook: %s", e) + } + + return model.MakeOrderBook( + pair, + transformedAsks, + transformedBids, + ), nil +} + +func (sdex *SDEX) transformHorizonOrders( + pair *model.TradingPair, + side []horizon.PriceLevel, + orderAction model.OrderAction, + ts *model.Timestamp, + maxCount int32, +) ([]model.Order, error) { + transformed := []model.Order{} + for i, o := range side { + if i >= int(maxCount) { + break + } + + floatPrice := float64(o.PriceR.N) / float64(o.PriceR.D) + price := model.NumberFromFloat(floatPrice, sdexOrderConstraints.PricePrecision) + + volume, e := model.NumberFromString(o.Amount, sdexOrderConstraints.VolumePrecision) + if e != nil { + return nil, fmt.Errorf("could not parse amount for horizon order: %s", e) + } + // special handling of amount for bids + if orderAction.IsBuy() { + // use floatPrice here for more accuracy since floatPrice is what will be used in stellar-core + volume = volume.Scale(1.0 / floatPrice) + } + + transformed = append(transformed, model.Order{ + Pair: pair, + OrderAction: orderAction, + OrderType: model.OrderTypeLimit, + Price: price, + Volume: volume, + Timestamp: ts, + }) + } + return transformed, nil +} diff --git a/plugins/sdexFeed.go b/plugins/sdexFeed.go new file mode 100644 index 000000000..f98001fad --- /dev/null +++ b/plugins/sdexFeed.go @@ -0,0 +1,91 @@ +package plugins + +import ( + "fmt" + "strings" + + "github.com/interstellar/kelp/api" + "github.com/interstellar/kelp/model" + "github.com/interstellar/kelp/support/utils" + "github.com/stellar/go/clients/horizon" +) + +// sdexFeed represents a pricefeed from the SDEX +type sdexFeed struct { + sdex *SDEX + assetBase *horizon.Asset + assetQuote *horizon.Asset +} + +// ensure that it implements PriceFeed +var _ api.PriceFeed = &sdexFeed{} + +// makeSDEXFeed creates a price feed from buysell's url fields +func makeSDEXFeed(url string) (*sdexFeed, error) { + urlParts := strings.Split(url, "/") + + baseAsset, e := parseHorizonAsset(urlParts[0]) + if e != nil { + return nil, fmt.Errorf("unable to convert base asset url to sdex asset: %s", e) + } + quoteAsset, e := parseHorizonAsset(urlParts[1]) + if e != nil { + return nil, fmt.Errorf("unable to convert quote asset url to sdex asset: %s", e) + } + + tradingPair := &model.TradingPair{ + Base: model.Asset(utils.Asset2CodeString(*baseAsset)), + Quote: model.Asset(utils.Asset2CodeString(*quoteAsset)), + } + sdexAssetMap := map[model.Asset]horizon.Asset{ + tradingPair.Base: *baseAsset, + tradingPair.Quote: *quoteAsset, + } + sdex := MakeSDEX( + privateSdexHackVar.API, + "", + "", + "", + "", + privateSdexHackVar.Network, + nil, + 0, + 0, + true, + tradingPair, + sdexAssetMap, + ) + + return &sdexFeed{ + sdex: sdex, + assetBase: baseAsset, + assetQuote: quoteAsset, + }, nil +} + +func parseHorizonAsset(assetString string) (*horizon.Asset, error) { + parts := strings.Split(assetString, ":") + code := parts[0] + issuer := parts[1] + + asset, e := utils.ParseAsset(code, issuer) + if e != nil { + return nil, fmt.Errorf("could not read horizon asset from string (%s): %s", assetString, e) + } + + return asset, e +} + +// GetPrice returns the SDEX mid price for the trading pair +func (s *sdexFeed) GetPrice() (float64, error) { + orderBook, e := s.sdex.GetOrderBook(s.sdex.pair, 1) + if e != nil { + return 0, fmt.Errorf("unable to get sdex price: %s", e) + } + + topBidPrice := orderBook.Bids()[0].Price + topAskPrice := orderBook.Asks()[0].Price + + centerPrice := topBidPrice.Add(*topAskPrice).Scale(0.5).AsFloat() + return centerPrice, nil +} diff --git a/support/utils/functions.go b/support/utils/functions.go index 7d1b33e58..bb5d3d631 100644 --- a/support/utils/functions.go +++ b/support/utils/functions.go @@ -235,3 +235,22 @@ func CheckedString(v interface{}) string { } return fmt.Sprintf("%v", v) } + +// ParseAsset returns a horizon asset a string +func ParseAsset(code string, issuer string) (*horizon.Asset, error) { + if code != "XLM" && issuer == "" { + return nil, fmt.Errorf("error: issuer can only be empty if asset is XLM") + } + + if code == "XLM" && issuer != "" { + return nil, fmt.Errorf("error: issuer needs to be empty if asset is XLM") + } + + if code == "XLM" { + asset := Asset2Asset2(build.NativeAsset()) + return &asset, nil + } + + asset := Asset2Asset2(build.CreditAsset(code, issuer)) + return &asset, nil +} diff --git a/trader/config.go b/trader/config.go index a318cabda..c27703371 100644 --- a/trader/config.go +++ b/trader/config.go @@ -4,7 +4,6 @@ import ( "fmt" "github.com/interstellar/kelp/support/utils" - "github.com/stellar/go/build" "github.com/stellar/go/clients/horizon" ) @@ -76,15 +75,15 @@ func (b *BotConfig) Init() error { return fmt.Errorf("error: both assets cannot be the same '%s:%s'", b.AssetCodeA, b.IssuerA) } - asset, e := parseAsset(b.AssetCodeA, b.IssuerA, "A") + asset, e := utils.ParseAsset(b.AssetCodeA, b.IssuerA) if e != nil { - return e + return fmt.Errorf("Error while parsing Asset A: %s", e) } b.assetBase = *asset - asset, e = parseAsset(b.AssetCodeB, b.IssuerB, "B") + asset, e = utils.ParseAsset(b.AssetCodeB, b.IssuerB) if e != nil { - return e + return fmt.Errorf("Error while parsing Asset B: %s", e) } b.assetQuote = *asset @@ -99,21 +98,3 @@ func (b *BotConfig) Init() error { b.sourceAccount, e = utils.ParseSecret(b.SourceSecretSeed) return e } - -func parseAsset(code string, issuer string, letter string) (*horizon.Asset, error) { - if code != XLM && issuer == "" { - return nil, fmt.Errorf("error: ISSUER_%s can only be empty if ASSET_CODE_%s is '%s'", letter, letter, XLM) - } - - if code == XLM && issuer != "" { - return nil, fmt.Errorf("error: ISSUER_%s needs to be empty if ASSET_CODE_%s is '%s'", letter, letter, XLM) - } - - if code == XLM { - asset := utils.Asset2Asset2(build.NativeAsset()) - return &asset, nil - } - - asset := utils.Asset2Asset2(build.CreditAsset(code, issuer)) - return &asset, nil -}