From 28732f0bf26731a0e74dc77924cb6ca24e970066 Mon Sep 17 00:00:00 2001 From: Nikhil Saraf <1028334+nikhilsaraf@users.noreply.github.com> Date: Mon, 1 Jun 2020 14:28:00 +0530 Subject: [PATCH] New Trading Strategy: Pendulum Strategy (closes #427) (#428) * 1 - swing strategy * 2 - add comments to sample_swing explaining config fields * 3 - use correct cursor in sdex.go#GetTradeHistory * 4 - swingLevelProvider should return cursor from result so it skips any noop trade items noop trade items can be those that are irrelevant to the current bot instance * 5 - convert IsCcxtTradeHistoryHack into incrementTimestampCursor on swingLevelProvider * 6 - adjust sample spread amounts in sample_swing.cfg * 7 - add BULL and ETHBULL assets * 8 - better logging for price2LastPrice map and price fetched from map * 9 - use orderConstraints in swingLevelProvider * 10 - use orderConstraints from exchangeShim instead of sdex * 11 - fixed getLastPriceFromMap and added TestGetLastPriceFromMap * 12 - improve getLastPriceFromMap to distinguish between buy/sell offers buy offers should have an offer price < lp whereas sell offer should have an offer price > lp * 13 - fix ccxt_test.go and add poloniex as a tested exchange * 14 - update README and config file for additional documentation and tips * 15 - update swing strategy to use hProcool.Asset * 16 - simplify config file by removing OFFSET_SPREAD configuration and adding more comments on spread intuition * 17 - rename "swing" strategy to "pendulum" strategy, in all files and code - avoid confusion with "swing" trading, while retaining the concept of a swinging pendulum * 18 - add walkthough guide for pendulum strategy * 19 - fix SortOrder of the strategy * 20 - add tests for strategies and exchanges commands * 21 - inject sentinel values for build variables in circleci config --- .circleci/config.yml | 2 +- README.md | 16 +- cmd/exchanges.go | 4 +- cmd/exchanges_test.go | 9 + cmd/root.go | 2 +- cmd/strategies_test.go | 9 + cmd/trade.go | 14 +- examples/configs/trader/sample_pendulum.cfg | 53 ++++ examples/walkthroughs/trader/pendulum.md | 103 ++++++++ model/assets.go | 80 +++--- plugins/factory.go | 39 ++- plugins/pendulumLevelProvider.go | 268 ++++++++++++++++++++ plugins/pendulumLevelProvider_test.go | 74 ++++++ plugins/pendulumStrategy.go | 126 +++++++++ plugins/sdex.go | 4 +- support/sdk/ccxt_test.go | 5 + 16 files changed, 762 insertions(+), 46 deletions(-) create mode 100644 cmd/exchanges_test.go create mode 100644 cmd/strategies_test.go create mode 100644 examples/configs/trader/sample_pendulum.cfg create mode 100644 examples/walkthroughs/trader/pendulum.md create mode 100644 plugins/pendulumLevelProvider.go create mode 100644 plugins/pendulumLevelProvider_test.go create mode 100644 plugins/pendulumStrategy.go diff --git a/.circleci/config.yml b/.circleci/config.yml index d58fb85b5..f465b983f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -40,7 +40,7 @@ commands: steps: - run: name: Run Kelp tests - command: go test --short ./... + command: go test --short -ldflags="-X github.com/stellar/kelp/cmd.version=test_compile -X github.com/stellar/kelp/cmd.guiVersion=test_compile -X github.com/stellar/kelp/cmd.gitBranch=test_compile -X github.com/stellar/kelp/cmd.gitHash=test_compile -X github.com/stellar/kelp/cmd.buildDate=test_compile -X github.com/stellar/kelp/cmd.env=dev" ./... build_kelp: steps: diff --git a/README.md b/README.md index 489693d72..76bf74e3e 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ These are the following commands available from the `kelp` binary: The `trade` command has three required parameters which are: - **botConf**: full path to the _.cfg_ file with the account details, [sample file here](examples/configs/trader/sample_trader.cfg). -- **strategy**: the strategy you want to run (_sell_, _buysell_, _balanced_, _mirror_, _delete_). +- **strategy**: the strategy you want to run (_sell_, _buysell_, _balanced_, _pendulum_, _mirror_, _delete_). - **stratConf**: full path to the _.cfg_ file specific to your chosen strategy, [sample files here](examples/configs/trader/). Kelp sets the `X-App-Name` and `X-App-Version` headers on requests made to Horizon. These headers help us track overall Kelp usage, so that we can learn about general usage patterns and adapt Kelp to be more useful in the future. These can be turned off using the `--no-headers` flag. See `kelp trade --help` for more information. @@ -218,11 +218,19 @@ The following strategies are available **out of the box** with Kelp: - **Complexity:** Beginner - balanced ([source](plugins/balancedStrategy.go)): - - **What:** dynamically prices two tokens based on their relative demand. For example, if more traders buy token A _from_ the bot (the traders are therefore selling token B), the bot will automatically raise the price for token A and drop the price for token B. + + - **What:** dynamically prices two tokens based on their relative demand. For example, if more traders buy token A _from_ the bot (the traders are therefore selling token B), the bot will automatically raise the price for token A and drop the price for token B. This strategy does not allow you to configure the order size but can run out of assets. This is a mean-reversion strategy. - **Why:** To let the market surface the _true price_ for one token in terms of another. - - **Who:** Market makers and traders for tokens that trade only on Stellar + - **Who:** Market makers and traders for tokens that have a neutral view on the market - **Complexity:** Intermediate +- pendulum ([source](plugins/pendulumStrategy.go)): + + - **What:** dynamically prices two tokens based on their relative demand. For example, if more traders buy token A _from_ the bot (the traders are therefore selling token B), the bot will automatically raise the price for token A and drop the price for token B. This strategy allows you to configure the order size but runs the risk of running out of one of the two assets. This is a mean-reversion strategy. + - **Why:** To let the market surface the _true price_ for one token in terms of another. + - **Who:** Market makers and traders for tokens that have a neutral view on the market + - **Complexity:** Beginner + - mirror ([source](plugins/mirrorStrategy.go)): - **What:** mirrors an orderbook from another exchange by placing the same orders on Stellar after including a [spread][spread]. @@ -327,6 +335,7 @@ It's easier to learn with examples! Take a look at the walkthrough guides and sa - [Market making for a stablecoin](examples/walkthroughs/trader/buysell.md): This guide uses the _buysell_ strategy to provide liquidity for a stablecoin. - [ICO sale](examples/walkthroughs/trader/sell.md): This guide uses the `sell` strategy to make a market using sell offers for native tokens in a hypothetical ICO. - [Create liquidity for a Stellar-based token](examples/walkthroughs/trader/balanced.md): This guide uses the `balanced` strategy to create liquidty for a token which only trades on the Stellar network. +- [Create wide liquidity within a bounded price range](examples/walkthroughs/trader/pendulum.md): This guide uses the `pendulum` strategy to create liquidty for a token. ## Configuration Files @@ -335,6 +344,7 @@ Reference config files are in the [examples folder](examples/configs/trader). Sp - [Sample Sell strategy config file](examples/configs/trader/sample_sell.cfg) - [Sample BuySell strategy config file](examples/configs/trader/sample_buysell.cfg) - [Sample Balanced strategy config file](examples/configs/trader/sample_balanced.cfg) +- [Sample Pendulum strategy config file](examples/configs/trader/sample_pendulum.cfg) - [Sample Mirror strategy config file](examples/configs/trader/sample_mirror.cfg) # Changelog diff --git a/cmd/exchanges.go b/cmd/exchanges.go index 3dfde3687..ebacdaad2 100644 --- a/cmd/exchanges.go +++ b/cmd/exchanges.go @@ -9,13 +9,13 @@ import ( "github.com/spf13/cobra" ) -var exchanagesCmd = &cobra.Command{ +var exchangesCmd = &cobra.Command{ Use: "exchanges", Short: "Lists the available exchange integrations", } func init() { - exchanagesCmd.Run = func(ccmd *cobra.Command, args []string) { + exchangesCmd.Run = func(ccmd *cobra.Command, args []string) { checkInitRootFlags() // call sdk.GetExchangeList() here so we pre-load exchanges before displaying the table sdk.GetExchangeList() diff --git a/cmd/exchanges_test.go b/cmd/exchanges_test.go new file mode 100644 index 000000000..7919f7c19 --- /dev/null +++ b/cmd/exchanges_test.go @@ -0,0 +1,9 @@ +package cmd + +import ( + "testing" +) + +func TestExchanges(t *testing.T) { + exchangesCmd.Run(nil, nil) +} diff --git a/cmd/root.go b/cmd/root.go index c27370046..77d1fec12 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -67,7 +67,7 @@ func init() { RootCmd.AddCommand(tradeCmd) RootCmd.AddCommand(serverCmd) RootCmd.AddCommand(strategiesCmd) - RootCmd.AddCommand(exchanagesCmd) + RootCmd.AddCommand(exchangesCmd) RootCmd.AddCommand(terminateCmd) RootCmd.AddCommand(versionCmd) } diff --git a/cmd/strategies_test.go b/cmd/strategies_test.go new file mode 100644 index 000000000..a1e66cb3e --- /dev/null +++ b/cmd/strategies_test.go @@ -0,0 +1,9 @@ +package cmd + +import ( + "testing" +) + +func TestStrategies(t *testing.T) { + strategiesCmd.Run(nil, nil) +} diff --git a/cmd/trade.go b/cmd/trade.go index 569298f94..aa36eef70 100644 --- a/cmd/trade.go +++ b/cmd/trade.go @@ -324,7 +324,19 @@ func makeStrategy( deleteAllOffersAndExit(l, botConfig, client, sdex, exchangeShim, threadTracker) } - strategy, e := plugins.MakeStrategy(sdex, ieif, tradingPair, &assetBase, &assetQuote, *options.strategy, *options.stratConfigPath, *options.simMode) + strategy, e := plugins.MakeStrategy( + sdex, + exchangeShim, + exchangeShim, + ieif, + tradingPair, + &assetBase, + &assetQuote, + *options.strategy, + *options.stratConfigPath, + *options.simMode, + botConfig.IsTradingSdex(), + ) if e != nil { l.Info("") l.Errorf("%s", e) diff --git a/examples/configs/trader/sample_pendulum.cfg b/examples/configs/trader/sample_pendulum.cfg new file mode 100644 index 000000000..58048a16c --- /dev/null +++ b/examples/configs/trader/sample_pendulum.cfg @@ -0,0 +1,53 @@ +# Sample config file for the "pendulum" strategy + +# what % deviation from the ideal price is allowed before we reset the price, specified as a decimal (0 < PRICE_TOLERANCE < 1.00) +PRICE_TOLERANCE=0.001 + +# what % deviation from the ideal amount is allowed before we reset the price, specified as a decimal (0 < AMOUNT_TOLERANCE < 1.00) +AMOUNT_TOLERANCE=1.0 + +# Amounts +# Note: advanced users could adjust these numbers to effectively control how much of any result from each rountrip trade (buy followed +# by sell, or sell followed by buy) ends up in the base asset vs. the quote asset. In order to control this you need to account for +# the fee paid to the exchange and your spread setting (below). +# When the buy and sell amounts are configured to the same value then all of any result from each roundtrip trade will be in the quote +# asset. +# AMOUNT_BASE_BUY is the amount to place denominated in the base asset for the buy side +AMOUNT_BASE_BUY=100.0 +# AMOUNT_BASE_SELL is the amount to place denominated in the base asset for the sell side +AMOUNT_BASE_SELL=100.0 + +# define the bid/ask spread that you are willing to provide. +# spread is a percentage specified as a decimal number (0 < spread < 1.00) - here it is 0.1% +# +# Note 1: the resting bid and ask orders will have a larger spread than what is specified here. The reason is that the bids and +# asks adjust automatically by moving up/down when orders are taken. If an ask is taken then all bid and ask orders move up. +# If a bid is taken then all bid and ask orders move down. +# This SPREAD is the effective spread percent you will receive. +# +# Note 2: this spread value should be greater than or equal to (2 x fee) on the exchange you are trading on. +# The intuition behind this is that in order to complete a roundtrip (buy followed by sell, or sell followed by buy), you will +# make two trades which will cost you (2 x fee) as a percentage of your order size. +# By setting a spread value greater than or equal to (2 x fee) you are accounting for the fees as a cost of your trading activities. +SPREAD=0.001 + +# max number of levels to have on either side. Defines how deep of an orderbook you want to make. +MAX_LEVELS=2 + +# Price Limits to control for Market Conditions changing +# It is required to set the seed price otherwise the algorithm will not work. It is recommended to set the min/max price so if market +# conditions change and there is an extreme spike in the value of one asset relative to the other then the bot will pause trading. +# (recommended) maximum price to offer, without this setting you could end up at a price where your algorithm is no longer effective +MAX_PRICE=0.070 +# (required) price with which to start off as the last trade price (i.e. initial center price) +SEED_LAST_TRADE_PRICE=0.066 +# (recommended) minimum price to offer, without this setting you could end up at a price where your algorithm is no longer effective +MIN_PRICE=0.062 + +# minimum amount of base asset balance to maintain after which the strategy won't place any more orders +MIN_BASE=0.0 +# minimum amount of quote asset balance to maintain after which the strategy won't place any more orders +MIN_QUOTE=0.0 + +# cursor from where to start fetching fills. If left blank then it will fetch from the first trade +#LAST_TRADE_CURSOR="TX_ID" diff --git a/examples/walkthroughs/trader/pendulum.md b/examples/walkthroughs/trader/pendulum.md new file mode 100644 index 000000000..e84e4ff72 --- /dev/null +++ b/examples/walkthroughs/trader/pendulum.md @@ -0,0 +1,103 @@ +# Create Wide Liquidity Within A Bounded Price Range + +This guide shows you how to setup the **kelp** bot using the [pendulum](../../../plugins/pendulumStrategy.go) strategy. We'll configure it to create liquidity for a `COUPON` token. `COUPON` can be any token you want and is only used as a sample token for the purpose of this walkthrough guide. + +The bot dynamically prices two tokens based on their relative demand. When someone _buys_ the [base asset](https://en.wikipedia.org/wiki/Currency_pair#Base_currency) from the bot (i.e. the bot's ask order is executed resulting in the bot selling the base asset), the bot will _increase_ the price of the base asset relative to the counter asset. Conversely, when someone _sells_ the [base asset](https://en.wikipedia.org/wiki/Currency_pair#Base_currency) to the bot (i.e. the bot's bid order is executed resulting in the bot buying the base asset), the bot will _decrease_ the price of the base asset relative to the counter asset. + +This strategy operates like a pendulum in that if a trader hits the ask then the bot moves the price to the right side (higher), if a trader hits the bid then the bot moves the price to the left side (lower). + +## Account Setup + +First, go through the [Account Setup guide](account_setup.md) to set up your Stellar accounts and the necessary configuration file, `trader.cfg`. + +## Install Bots + +Download the pre-compiled binaries for **kelp** for your platform from the [Github Releases Page](https://github.com/stellar/kelp/releases). If you have downloaded the correct version for your platform you can run it directly. + +## Pendulum Strategy Configuration + +Use the [sample configuration file for the pendulum strategy](../../configs/trader/sample_pendulum.cfg) as a template. We will walk through the configuration parameters below. + +### Tolerances + +For the purposes of this walkthrough, we set the `PRICE_TOLERANCE` value to `0.001` which means that a _0.1% change in price will trigger the bot to refresh its orders_. Similarly, we set the `AMOUNT_TOLERANCE` value to `1.0` which means that we need _at least a 100% change in amount will trigger the bot to refresh its orders_, i.e. an order needs to be fully consumed to trigger a replacement of that order. If either one of these conditions is met then the bot will refresh its orders. + +In practice, you should set any value you're comfortable with. + +### Amounts + +- **`AMOUNT_BASE_BUY`**: refers to the order size denominated in units of the base asset to be placed for the bids, represented as a decimal number. +- **`AMOUNT_BASE_SELL`**: refers to the order size denominated in units of the base asset to be placed for the asks, represented as a decimal number. + +### Spread + +- **`SPREAD`**: refers to the [bid/ask spread](https://en.wikipedia.org/wiki/Bid%E2%80%93ask_spread) as a percentage represented as a decimal number (`0.0` < spread < `1.00`). + +**Note 1**: the resting bid and ask orders will have a larger spread than what is specified in the config. +The reason is that the bids and asks adjust automatically by moving up/down when orders are taken as described above. +If an ask is taken then all bid and ask orders move up. If a bid is taken then all bid and ask orders move down. +This `SPREAD` config value is the effective spread percent you will receive after adjusting for the automatic movement of orders, assuming that the bot has time to move the orders before the next trade happens. If the bot does not have time to move the orders then the bot will receive a larger spread than what is specified in the config. + +**Note 2**: this spread value percent should be greater than or equal to **2 x fee** on the exchange you are trading on. +The intuition behind this is that in order to complete a roundtrip (buy followed by sell, or sell followed by buy), you will make two trades which will cost you **2 x fee** as a percentage of your order size. +By setting a spread value greater than or equal to **2 x fee** you are accounting for the fees as a cost of your trading activities. + +### Levels + +A level defines a [layer](https://en.wikipedia.org/wiki/Layering_(finance)) that sits in the orderbook. The bot will create mirrored orders on both the buy side and the sell side for each level configured. + +- **`MAX_LEVELS`**: refers to the number of levels that you want on either side of the mid-price. + +![level screenshot](https://i.imgur.com/QVjZXGA.png "Levels Screenshot") + +### Price Limits + +It is important to set price limits to control for changing market conditions. **It is highly recommended to set all three of these values. It is extremely dangerous to not set them.** + +- **`SEED_LAST_TRADE_PRICE`**: (required) price with which to start off as the last trade price (i.e. initial center price). A good value for this is the current mid-price of the market you are trading on, but it is not always the best choice. +- **`MAX_PRICE`**: maximum price to offer, without this setting you could end up at a price where your algorithm is no longer effective +- **`MIN_PRICE`**: minimum price to offer, without this setting you could end up at a price where your algorithm is no longer effective + +**Note 1**: You are required to set the `SEED_LAST_TRADE_PRICE` otherwise the algorithm will not work. **it is highly recommend to always update the value of `SEED_LAST_TRADE_PRICE` in the configuration before starting a new run of the bot so you can ensure it is line with the current market price.** + +**Note 2**: It is recommended to set the `MAX_PRICE` and the `MIN_PRICE` to define the bounds in which your algorithm will work as expected. If market conditions change to the point where the price of the market goes outside this range then your configuration is no longer valid and it is better for your bot to pause trading. +This can be caused by a spike in the relative value of one asset compared to the other, which is not conducive to this trading strategy and you should re-evaluate and update your configuration in this situation, or consider stopping your trading activities on this market altogether. + +### Minimum Amount Limits + +You may want to ensure that your account has a minimum balance of a given asset so you do not risk running out of any one asset. These settings help you configure that. + +- **`MIN_BASE`**: decimal value representing the minimum amount of base asset balance to maintain after which the strategy won't place any more orders +- **`MIN_QUOTE`**: decimal value representing the minimum amount of quote asset balance to maintain after which the strategy won't place any more orders + +### Historical Trades + +This trading strategy adjusts the offered price based on the last price it received for a trade. In order to do this it needs to fetch trades from the exchange. In order to do this the bot will need to know from which point to start fetching trades (_cursor_). + +If this value is left empty then it will fetch all the trades for your account for the given market which may be excessive and can result in your bot hitting or exceeding the rate limit. This configuration helps you to set the starting point from where to fetch trades so that you do not fetch more trades than you need to. + +- **`LAST_TRADE_CURSOR`**: cursor from where to start fetching trades. If left blank then it will fetch from the first trade made on the specified market. + +**Note 1**: The `LAST_TRADE_CURSOR` should be specified in the same format as defined by your exchange. On SDEX this can be the paging token, on Kraken this can be your transaction ID, on binance this may be your timestamp etc. You will need to figure out the value to be used. The log files for this trading strategy displays the trades as they happen which includes the trade cursor for each trade entry and can be used to fill in the `LAST_TRADE_CURSOR` value. At each update interval of the bot it logs the currently held value for `LAST_TRADE_CURSOR`, which can also be used to update this configuration value when resuming the bot after it has been paused. + +**Note 2**: The first time that the bot fetches trades from the cursor specified in the `LAST_TRADE_CURSOR` at startup, it will update the value held in memory for `LAST_TRADE_CURSOR` but will not use the price from these values retrieved to update the bot's orders because it will use the price set in the `SEED_LAST_TRADE_PRICE` (configured above) for the initial run of the bot. This allows you to set a new price for the bot via the `SEED_LAST_TRADE_PRICE` configuration parameter if you are resuming the bot under new market conditions compared to the last run of your bot. For every subsequent trade it will update the vale of `SEED_LAST_TRADE_PRICE` along with `LAST_TRADE_CURSOR` held in memory. This behavior allows you to leave the `LAST_TRADE_CURSOR` setting as-is if your bot has not seen many trades (i.e. for short runs of the bot). Although, it is highly recommend to always update the value of `LAST_TRADE_CURSOR` in the configuration before starting your bot. + +## Comparison to Balanced Strategy + +This strategy functions similarly to the [balanced strategy](balanced.md) but gives you the ability to control the order size (amount). + +Another benefit of this strategy over the balanced strategy is that you do not need a fixed ratio of balances of your assets to begin trading. The amount and initial price is set in the configuration file directly instead of being computed from the balances of the assets in the account, which makes this strategy more flexible than the balanced strategy. + +However, one of the tradeoffs of this additional flexibility is that this strategy can run out of one of the assets. To safeguard from this, you can set up _Price Limits_ and _Minimum Amount Limits_ as described in the configuration sections above. + +## Run Kelp + +Assuming your botConfig is called `trader.cfg` and your strategy config is called `pendulum.cfg`, you can run `kelp` with the following command: + +``` +kelp trade --botConf ./path/trader.cfg --strategy pendulum --stratConf ./path/pendulum.cfg +``` + +# Above and Beyond + +You can also play around with the configuration parameters of the [sample configuration file for the pendulum strategy](../../configs/trader/sample_pendulum.cfg), look at some of the other strategies that are available out-of-the-box or dig into the code and _create your own strategy_. diff --git a/model/assets.go b/model/assets.go index b94b1dbd2..9a46e9c3d 100644 --- a/model/assets.go +++ b/model/assets.go @@ -15,37 +15,46 @@ type Asset string // this is the list of assets understood by the bot. // This string can be converted by the specific exchange adapter as is needed by the exchange's API const ( - XLM Asset = "XLM" - BTC Asset = "BTC" - USD Asset = "USD" - ETH Asset = "ETH" - LTC Asset = "LTC" - REP Asset = "REP" - ADA Asset = "ADA" - BCH Asset = "BCH" - DASH Asset = "DASH" - EOS Asset = "EOS" - GNO Asset = "GNO" - FEE Asset = "FEE" - QTUM Asset = "QTUM" - USDT Asset = "USDT" - USDC Asset = "USDC" - DAO Asset = "DAO" - ETC Asset = "ETC" - ICN Asset = "ICN" - MLN Asset = "MLN" - NMC Asset = "NMC" - XDG Asset = "XDG" - XMR Asset = "XMR" - XRP Asset = "XRP" - XVN Asset = "XVN" - ZEC Asset = "ZEC" - CAD Asset = "CAD" - EUR Asset = "EUR" - GBP Asset = "GBP" - JPY Asset = "JPY" - KRW Asset = "KRW" - OMG Asset = "OMG" + XLM Asset = "XLM" + BTC Asset = "BTC" + USD Asset = "USD" + ETH Asset = "ETH" + LTC Asset = "LTC" + REP Asset = "REP" + ADA Asset = "ADA" + BCH Asset = "BCH" + DASH Asset = "DASH" + EOS Asset = "EOS" + GNO Asset = "GNO" + GRIN Asset = "GRIN" + FEE Asset = "FEE" + QTUM Asset = "QTUM" + USDT Asset = "USDT" + TUSD Asset = "TUSD" + USDC Asset = "USDC" + USDS Asset = "USDS" + PAX Asset = "PAX" + BUSD Asset = "BUSD" + DAI Asset = "DAI" + DAO Asset = "DAO" + ETC Asset = "ETC" + ICN Asset = "ICN" + MLN Asset = "MLN" + NMC Asset = "NMC" + XDG Asset = "XDG" + XMR Asset = "XMR" + XRP Asset = "XRP" + XVN Asset = "XVN" + ZEC Asset = "ZEC" + CAD Asset = "CAD" + EUR Asset = "EUR" + GBP Asset = "GBP" + JPY Asset = "JPY" + KRW Asset = "KRW" + OMG Asset = "OMG" + MANA Asset = "MANA" + BULL Asset = "BULL" + ETHBULL Asset = "ETHBULL" ) // AssetConverterInterface is the interface which allows the creation of asset converters with logic instead of static bindings @@ -169,9 +178,12 @@ var KrakenAssetConverter = makeAssetConverter(map[Asset]string{ // KrakenAssetConverterOpenOrders is the asset converter for the Kraken exchange's GetOpenOrders API var KrakenAssetConverterOpenOrders = makeAssetConverter(map[Asset]string{ - XLM: "XLM", - BTC: "XBT", - USD: "USD", + XLM: "XLM", + BTC: "XBT", + USD: "USD", + USDT: "USDT", + REP: "REP", + ETH: "ETH", }) // FromHorizonAsset is a factory method diff --git a/plugins/factory.go b/plugins/factory.go index 63199e753..812d93539 100644 --- a/plugins/factory.go +++ b/plugins/factory.go @@ -15,12 +15,15 @@ import ( // strategyFactoryData is a data container that has all the information needed to make a strategy type strategyFactoryData struct { sdex *SDEX + exchangeShim api.ExchangeShim + tradeFetcher api.TradeFetcher ieif *IEIF tradingPair *model.TradingPair assetBase *hProtocol.Asset assetQuote *hProtocol.Asset stratConfigPath string simMode bool + isTradingSdex bool } // StrategyContainer contains the strategy factory method along with some metadata @@ -52,7 +55,7 @@ var strategies = map[string]StrategyContainer{ }, }, "mirror": { - SortOrder: 4, + SortOrder: 5, Description: "Mirrors an orderbook from another exchange by placing the same orders on Stellar", NeedsConfig: true, Complexity: "Advanced", @@ -86,7 +89,7 @@ var strategies = map[string]StrategyContainer{ }, }, "balanced": { - SortOrder: 3, + SortOrder: 4, Description: "Dynamically prices two tokens based on their relative demand", NeedsConfig: true, Complexity: "Intermediate", @@ -99,7 +102,7 @@ var strategies = map[string]StrategyContainer{ }, }, "delete": { - SortOrder: 2, + SortOrder: 3, Description: "Deletes all orders for the configured orderbook", NeedsConfig: false, Complexity: "Beginner", @@ -107,11 +110,36 @@ var strategies = map[string]StrategyContainer{ return makeDeleteStrategy(strategyFactoryData.sdex, strategyFactoryData.assetBase, strategyFactoryData.assetQuote), nil }, }, + "pendulum": { + SortOrder: 2, + Description: "Oscillating bids and asks like a pendulum based on last trade price as the equilibrium poistion", + NeedsConfig: true, + Complexity: "Beginner", + makeFn: func(strategyFactoryData strategyFactoryData) (api.Strategy, error) { + var cfg pendulumConfig + err := config.Read(strategyFactoryData.stratConfigPath, &cfg) + utils.CheckConfigError(cfg, err, strategyFactoryData.stratConfigPath) + utils.LogConfig(cfg) + return makePendulumStrategy( + strategyFactoryData.sdex, + strategyFactoryData.exchangeShim, + strategyFactoryData.ieif, + strategyFactoryData.assetBase, + strategyFactoryData.assetQuote, + &cfg, + strategyFactoryData.tradeFetcher, + strategyFactoryData.tradingPair, + !strategyFactoryData.isTradingSdex, + ), nil + }, + }, } // MakeStrategy makes a strategy func MakeStrategy( sdex *SDEX, + exchangeShim api.ExchangeShim, + tradeFetcher api.TradeFetcher, ieif *IEIF, tradingPair *model.TradingPair, assetBase *hProtocol.Asset, @@ -119,6 +147,7 @@ func MakeStrategy( strategy string, stratConfigPath string, simMode bool, + isTradingSdex bool, ) (api.Strategy, error) { log.Printf("Making strategy: %s\n", strategy) if s, ok := strategies[strategy]; ok { @@ -128,12 +157,15 @@ func MakeStrategy( s, e := s.makeFn(strategyFactoryData{ sdex: sdex, + exchangeShim: exchangeShim, + tradeFetcher: tradeFetcher, ieif: ieif, tradingPair: tradingPair, assetBase: assetBase, assetQuote: assetQuote, stratConfigPath: stratConfigPath, simMode: simMode, + isTradingSdex: isTradingSdex, }) if e != nil { return nil, fmt.Errorf("cannot make '%s' strategy: %s", strategy, e) @@ -182,6 +214,7 @@ func loadExchanges() { testedCcxtExchanges := map[string]bool{ "kraken": true, "binance": true, + "poloniex": true, "coinbasepro": true, } diff --git a/plugins/pendulumLevelProvider.go b/plugins/pendulumLevelProvider.go new file mode 100644 index 000000000..370c999e6 --- /dev/null +++ b/plugins/pendulumLevelProvider.go @@ -0,0 +1,268 @@ +package plugins + +import ( + "fmt" + "log" + "math" + "sort" + "strconv" + + "github.com/stellar/kelp/api" + "github.com/stellar/kelp/model" +) + +// use a global variable for now so it is common across both instances (buy and sell side) +var price2LastPrice map[float64]float64 = map[float64]float64{} + +// the keys in price2LastPrice should have a larger precision than the exchange's market supports because we use the same map for +// storing prices of both buy and sell orders which could hold prices at the same level and we want the map to allow both (instead +// of rounding to the same offerPrice key) +const offerPriceLargePrecision int8 = 15 + +// pendulumLevelProvider provides levels based on the concept of a pendulum that swings from one side to another +type pendulumLevelProvider struct { + spread float64 + offsetSpread float64 + amountBase float64 + useMaxQuoteInTargetAmountCalc bool // else use maxBase + maxLevels int16 + lastTradePrice float64 + priceLimit float64 // last price for which to place order + minBase float64 + tradeFetcher api.TradeFetcher + tradingPair *model.TradingPair + lastTradeCursor interface{} + isFirstTradeHistoryRun bool + incrementTimestampCursor bool + orderConstraints *model.OrderConstraints +} + +// ensure it implements LevelProvider +var _ api.LevelProvider = &pendulumLevelProvider{} + +// makePendulumLevelProvider is the factory method +func makePendulumLevelProvider( + spread float64, + offsetSpread float64, + useMaxQuoteInTargetAmountCalc bool, + amountBase float64, + maxLevels int16, + lastTradePrice float64, + priceLimit float64, + minBase float64, + tradeFetcher api.TradeFetcher, + tradingPair *model.TradingPair, + lastTradeCursor interface{}, + incrementTimestampCursor bool, + orderConstraints *model.OrderConstraints, +) *pendulumLevelProvider { + return &pendulumLevelProvider{ + spread: spread, + offsetSpread: offsetSpread, + useMaxQuoteInTargetAmountCalc: useMaxQuoteInTargetAmountCalc, + amountBase: amountBase, + maxLevels: maxLevels, + lastTradePrice: lastTradePrice, + priceLimit: priceLimit, + minBase: minBase, + tradeFetcher: tradeFetcher, + tradingPair: tradingPair, + lastTradeCursor: lastTradeCursor, + isFirstTradeHistoryRun: true, + incrementTimestampCursor: incrementTimestampCursor, + orderConstraints: orderConstraints, + } +} + +func printPrice2LastPriceMap() { + keys := []float64{} + for k, _ := range price2LastPrice { + keys = append(keys, k) + } + sort.Float64s(keys) + + log.Printf("price2LastPrice map (%d elements):\n", len(price2LastPrice)) + for _, k := range keys { + log.Printf(" %.8f -> %.8f\n", k, price2LastPrice[k]) + } +} + +func getLastPriceFromMap(price2LastPriceMap map[float64]float64, tradePrice float64, lastTradeIsBuy bool) (lastTradePrice float64, lastPrice float64) { + if lp, ok := price2LastPriceMap[tradePrice]; ok { + if lastTradeIsBuy { + if tradePrice < lp { + log.Printf("getLastPriceFromMap, found in map for tradePrice = %.8f (lastTradeIsBuy = true): last price (%.8f)\n", tradePrice, lp) + return tradePrice, lp + } + + log.Printf("getLastPriceFromMap, found in map for tradePrice = %.8f with unexpected last price for the lastTradeIsBuy = true: last price (%.8f); was expecting lastPrice to be greater than trade price\n", tradePrice, lp) + // don't return + } else if !lastTradeIsBuy { + if tradePrice > lp { + log.Printf("getLastPriceFromMap, found in map for tradePrice = %.8f (lastTradeIsBuy = false): last price (%.8f)\n", tradePrice, lp) + return tradePrice, lp + } + + log.Printf("getLastPriceFromMap, found in map for tradePrice = %.8f with unexpected last price for the lastTradeIsBuy = false: last price (%.8f); was expecting lastPrice to be less than trade price\n", tradePrice, lp) + // don't return + } + } + + closestOfferPrice := -1.0 + diff := -1.0 + for offerPrice, lp := range price2LastPriceMap { + if lastTradeIsBuy && !(offerPrice < lp) { + // skip sell prices when we are in buy mode + continue + } + if !lastTradeIsBuy && !(offerPrice > lp) { + // skip buy prices when we are in sell mode + continue + } + + d := math.Abs(tradePrice - offerPrice) + + firstIter := closestOfferPrice == -1 + if firstIter { + closestOfferPrice = offerPrice + diff = d + continue + } + + if d < diff { + closestOfferPrice = offerPrice + diff = d + } + } + lp := price2LastPriceMap[closestOfferPrice] + + log.Printf("getLastPriceFromMap, calculated for tradePrice = %.8f (lastTradeIsBuy = %v): closest offerPrice (%.8f) and last price (%.8f) when it was not in map\n", tradePrice, lastTradeIsBuy, closestOfferPrice, lp) + return closestOfferPrice, lp +} + +// GetFillHandlers impl +func (p *pendulumLevelProvider) GetFillHandlers() ([]api.FillHandler, error) { + return nil, nil +} + +// GetLevels impl. +func (p *pendulumLevelProvider) GetLevels(maxAssetBase float64, maxAssetQuote float64) ([]api.Level, error) { + if maxAssetBase <= p.minBase { + return []api.Level{}, nil + } + + lastPrice, lastCursor, lastIsBuy, e := p.fetchLatestTradePrice() + if e != nil { + return nil, fmt.Errorf("error in fetchLatestTradePrice: %s", e) + } + + // update it only if there's no error + if p.isFirstTradeHistoryRun { + p.isFirstTradeHistoryRun = false + p.lastTradeCursor = lastCursor + log.Printf("isFirstTradeHistoryRun so updated lastTradeCursor=%v, leaving unchanged lastTradePrice=%.10f", p.lastTradeCursor, p.lastTradePrice) + } else if lastCursor == p.lastTradeCursor { + log.Printf("lastCursor == p.lastTradeCursor leaving lastTradeCursor=%v and lastTradePrice=%.10f", p.lastTradeCursor, p.lastTradePrice) + } else { + p.lastTradeCursor = lastCursor + mapKey := model.NumberFromFloat(lastPrice, p.orderConstraints.PricePrecision) + printPrice2LastPriceMap() + _, p.lastTradePrice = getLastPriceFromMap(price2LastPrice, mapKey.AsFloat(), lastIsBuy) + log.Printf("updated lastTradeCursor=%v and lastTradePrice=%.10f (converted=%.10f)", p.lastTradeCursor, lastPrice, p.lastTradePrice) + } + + levels := []api.Level{} + newPrice := p.lastTradePrice + if p.useMaxQuoteInTargetAmountCalc { + // invert lastTradePrice here -- it's always kept in the actual quote price at all other times + newPrice = 1 / newPrice + } + baseExposed := 0.0 + for i := 0; i < int(p.maxLevels); i++ { + newPrice = newPrice * (1 + p.spread/2) + priceToUse := newPrice * (1 + p.offsetSpread/2) + + // check what the balance would be if we were to place this level, ensuring it will still be within the limits + expectedBaseUsage := p.amountBase + if p.useMaxQuoteInTargetAmountCalc { + expectedBaseUsage = expectedBaseUsage / priceToUse + } + expectedEndingBase := maxAssetBase - baseExposed - expectedBaseUsage + if expectedEndingBase <= p.minBase { + log.Printf("early exiting level creation loop (sideIsBuy=%v), expectedEndingBase=%.10f, minBase=%.10f\n", p.useMaxQuoteInTargetAmountCalc, expectedEndingBase, p.minBase) + break + } + + if p.useMaxQuoteInTargetAmountCalc && 1/priceToUse < p.priceLimit { + log.Printf("early exiting level creation loop (buy side) because we crossed minPrice, priceLimit=%.10f, current price=%.10f\n", p.priceLimit, 1/priceToUse) + break + } + + if !p.useMaxQuoteInTargetAmountCalc && priceToUse > p.priceLimit { + log.Printf("early exiting level creation loop (sell side) because we crossed maxPrice, priceLimit=%.10f, current price=%.10f\n", p.priceLimit, priceToUse) + break + } + + levels = append(levels, api.Level{ + Price: *model.NumberFromFloat(priceToUse, p.orderConstraints.PricePrecision), + Amount: *model.NumberFromFloat(p.amountBase, p.orderConstraints.VolumePrecision), + }) + + // update last price map here + // the keys in price2LastPrice should have a larger precision than the exchange's market supports because we use the same map for + // storing prices of both buy and sell orders which could hold prices at the same level and we want the map to allow both (instead + // of rounding to the same offerPrice key) + mapKey := model.NumberFromFloat(priceToUse, offerPriceLargePrecision) + mapValue := newPrice + if p.useMaxQuoteInTargetAmountCalc { + mapKey = model.NumberFromFloat(1/priceToUse, offerPriceLargePrecision) + mapValue = 1 / newPrice + } + price2LastPrice[mapKey.AsFloat()] = mapValue + + baseExposed += expectedBaseUsage + } + + log.Printf("levels created (sideIsBuy=%v): %v\n", p.useMaxQuoteInTargetAmountCalc, levels) + printPrice2LastPriceMap() + + return levels, nil +} + +func (p *pendulumLevelProvider) fetchLatestTradePrice() (float64, interface{}, bool, error) { + lastPrice := p.lastTradePrice + lastCursor := p.lastTradeCursor + lastIsBuy := false + for { + tradeHistoryResult, e := p.tradeFetcher.GetTradeHistory(*p.tradingPair, lastCursor, nil) + if e != nil { + return 0, "", false, fmt.Errorf("error in tradeFetcher.GetTradeHistory: %s", e) + } + + // TODO need to check for volume here too at some point (if full lot is not taken then we don't want to update last price) + + if len(tradeHistoryResult.Trades) == 0 { + return lastPrice, tradeHistoryResult.Cursor, lastIsBuy, nil + } + + log.Printf("listing %d trades since last cycle", len(tradeHistoryResult.Trades)) + for _, t := range tradeHistoryResult.Trades { + log.Printf(" Trade: %v\n", t) + } + + lastTrade := tradeHistoryResult.Trades[len(tradeHistoryResult.Trades)-1] + if p.incrementTimestampCursor { + i64Cursor, e := strconv.Atoi(lastTrade.Order.Timestamp.String()) + if e != nil { + return 0, "", false, fmt.Errorf("unable to convert order timestamp to integer for binance cursor: %s", e) + } + // increment last timestamp cursor for binance because it's inclusive + lastCursor = strconv.FormatInt(int64(i64Cursor)+1, 10) + } else { + lastCursor = lastTrade.TransactionID.String() + } + lastIsBuy = lastTrade.Order.OrderAction == model.OrderActionBuy + price := lastTrade.Order.Price.AsFloat() + lastPrice = price + } +} diff --git a/plugins/pendulumLevelProvider_test.go b/plugins/pendulumLevelProvider_test.go new file mode 100644 index 000000000..6ac74b385 --- /dev/null +++ b/plugins/pendulumLevelProvider_test.go @@ -0,0 +1,74 @@ +package plugins + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetLastPriceFromMap(t *testing.T) { + price2LastPriceMap := map[float64]float64{ + 0.075: 0.070, // sell side because offer price (key) is greater than last price (value) + 0.074: 0.080, // buy side because offer price (key) is less than last price (value) + } + + testCases := []struct { + price2LastPrice map[float64]float64 + tradePrice float64 + isBuy bool + wantTradePrice float64 + wantLastPrice float64 + }{ + { + price2LastPrice: price2LastPriceMap, + tradePrice: 0.075, + isBuy: false, + wantTradePrice: 0.075, + wantLastPrice: 0.070, + }, { + price2LastPrice: price2LastPriceMap, + tradePrice: 0.074, + isBuy: false, + wantTradePrice: 0.075, + wantLastPrice: 0.070, + }, { + price2LastPrice: price2LastPriceMap, + tradePrice: 0.0745, + isBuy: false, + wantTradePrice: 0.075, + wantLastPrice: 0.070, + }, { + price2LastPrice: price2LastPriceMap, + tradePrice: 0.074, + isBuy: true, + wantTradePrice: 0.074, + wantLastPrice: 0.080, + }, { + price2LastPrice: price2LastPriceMap, + tradePrice: 0.075, + isBuy: true, + wantTradePrice: 0.074, + wantLastPrice: 0.080, + }, { + price2LastPrice: price2LastPriceMap, + tradePrice: 0.0745, + isBuy: true, + wantTradePrice: 0.074, + wantLastPrice: 0.080, + }, + } + + for _, kase := range testCases { + t.Run(fmt.Sprintf("%.4f/%v", kase.tradePrice, kase.isBuy), func(t *testing.T) { + lastTradePrice, lastPrice := getLastPriceFromMap(kase.price2LastPrice, kase.tradePrice, kase.isBuy) + if !assert.Equal(t, kase.wantTradePrice, lastTradePrice) { + return + } + + if !assert.Equal(t, kase.wantLastPrice, lastPrice) { + return + } + }) + } +} diff --git a/plugins/pendulumStrategy.go b/plugins/pendulumStrategy.go new file mode 100644 index 000000000..8fb898c73 --- /dev/null +++ b/plugins/pendulumStrategy.go @@ -0,0 +1,126 @@ +package plugins + +import ( + hProtocol "github.com/stellar/go/protocols/horizon" + "github.com/stellar/kelp/api" + "github.com/stellar/kelp/model" + "github.com/stellar/kelp/support/utils" +) + +// pendulumConfig contains the configuration params for this Strategy +type pendulumConfig struct { + PriceTolerance float64 `valid:"-" toml:"PRICE_TOLERANCE"` + AmountTolerance float64 `valid:"-" toml:"AMOUNT_TOLERANCE"` + AmountBaseBuy float64 `valid:"-" toml:"AMOUNT_BASE_BUY"` + AmountBaseSell float64 `valid:"-" toml:"AMOUNT_BASE_SELL"` + Spread float64 `valid:"-" toml:"SPREAD"` // this is the bid-ask spread (i.e. it is not the spread from the center price) + MaxLevels int16 `valid:"-" toml:"MAX_LEVELS"` // max number of levels to have on either side + SeedLastTradePrice float64 `valid:"-" toml:"SEED_LAST_TRADE_PRICE"` // price with which to start off as the last trade price (i.e. initial center price) + MaxPrice float64 `valid:"-" toml:"MAX_PRICE"` // max price for which to place an order + MinPrice float64 `valid:"-" toml:"MIN_PRICE"` // min price for which to place an order + MinBase float64 `valid:"-" toml:"MIN_BASE"` + MinQuote float64 `valid:"-" toml:"MIN_QUOTE"` + LastTradeCursor string `valid:"-" toml:"LAST_TRADE_CURSOR"` +} + +/* +Note on Spread vs. OffsetSpread (hardcoded for now) +# define the bid/ask spread that you are willing to provide. spread is a percentage specified as a decimal number (0 < spread < 1.00) - here it is 0.1% +# How to set these spread levels: +# - The spread between an oscillating buy and sell is spread + offset_spread - spread/2 = offset_spread + spread/2 +# (spread/2 subtracted because price shifts by spread/2 when trade made!) +# - You need to account for 2x fee because a buy and a sell will take the fee on both buy and sell +# - You need to set both values so that you are not buying and selling at the same price levels +# As an example: +# if the fees on the exchange is 0.10% and you want to break even on every trade then set spread to 0.0020 and offset_spread to 0.0010 +# this will ensure that the oscillating spread os 0.20%, so there is no net loss for every trade +# (0.20% + 0.10% - 0.20%/2 = 0.20% + 0.10% - 0.10% = 0.20%) +# SPREAD - this is the difference between each level on the same side, a smaller value here means subsequent levels will be closer together +# OFFSET_SPREAD - this is the difference between the buy and sell at the same logical price level when they do overlap + +For now we hardcode offsetSpread to be 0.5 * spread to keep it less confusing for users +*/ + +// String impl. +func (c pendulumConfig) String() string { + return utils.StructString(c, 0, nil) +} + +// makePendulumStrategy is a factory method for pendulumStrategy +func makePendulumStrategy( + sdex *SDEX, + exchangeShim api.ExchangeShim, + ieif *IEIF, + assetBase *hProtocol.Asset, + assetQuote *hProtocol.Asset, + config *pendulumConfig, + tradeFetcher api.TradeFetcher, + tradingPair *model.TradingPair, + incrementTimestampCursor bool, // only do this if we are on ccxt +) api.Strategy { + if config.AmountTolerance != 1.0 { + panic("pendulum strategy needs to be configured with AMOUNT_TOLERANCE = 1.0") + } + + orderConstraints := exchangeShim.GetOrderConstraints(tradingPair) + sellLevelProvider := makePendulumLevelProvider( + config.Spread, + config.Spread/2, + false, + config.AmountBaseSell, + config.MaxLevels, + config.SeedLastTradePrice, + config.MaxPrice, + config.MinBase, + tradeFetcher, + tradingPair, + config.LastTradeCursor, + incrementTimestampCursor, + orderConstraints, + ) + sellSideStrategy := makeSellSideStrategy( + sdex, + orderConstraints, + ieif, + assetBase, + assetQuote, + sellLevelProvider, + config.PriceTolerance, + config.AmountTolerance, + false, + ) + buyLevelProvider := makePendulumLevelProvider( + config.Spread, + config.Spread/2, + true, // real base is passed in as quote so pass in true + config.AmountBaseBuy, + config.MaxLevels, + config.SeedLastTradePrice, // we don't invert seed last trade price for the buy side because it's handeld in the pendulumLevelProvider + config.MinPrice, // use minPrice for buy side + config.MinQuote, // use minQuote for buying side + tradeFetcher, + tradingPair, + config.LastTradeCursor, + incrementTimestampCursor, + orderConstraints, + ) + // switch sides of base/quote here for buy side + buySideStrategy := makeSellSideStrategy( + sdex, + orderConstraints, + ieif, + assetQuote, + assetBase, + buyLevelProvider, + config.PriceTolerance, + config.AmountTolerance, + true, + ) + + return makeComposeStrategy( + assetBase, + assetQuote, + buySideStrategy, + sellSideStrategy, + ) +} diff --git a/plugins/sdex.go b/plugins/sdex.go index 797b35006..82aec61d3 100644 --- a/plugins/sdex.go +++ b/plugins/sdex.go @@ -752,6 +752,9 @@ func (sdex *SDEX) tradesPage2TradeHistoryResult(baseAsset hProtocol.Asset, quote trades := []model.Trade{} for _, t := range tradesPage.Embedded.Records { + // update cursor first so we keep it moving + cursor = t.PT + orderAction, e := sdex.getOrderAction(baseAsset, quoteAsset, t) if e != nil { return nil, false, fmt.Errorf("could not load orderAction: %s", e) @@ -782,7 +785,6 @@ func (sdex *SDEX) tradesPage2TradeHistoryResult(baseAsset hProtocol.Asset, quote Fee: model.NumberFromFloat(baseFee, sdexOrderConstraints.PricePrecision), }) - cursor = t.PT if cursor == cursorEnd { return &api.TradeHistoryResult{ Cursor: cursor, diff --git a/support/sdk/ccxt_test.go b/support/sdk/ccxt_test.go index a29fd62d6..d8d00e69e 100644 --- a/support/sdk/ccxt_test.go +++ b/support/sdk/ccxt_test.go @@ -227,6 +227,7 @@ func TestFetchTrades(t *testing.T) { // "id" is not always part of a trade result on Kraken for the public trades API krakenFields := []string{"amount", "cost", "datetime", "price", "side", "symbol", "timestamp"} + poloniexFields := []string{"amount", "cost", "datetime", "id", "price", "side", "symbol", "timestamp", "type"} binanceFields := []string{"amount", "cost", "datetime", "id", "price", "side", "symbol", "timestamp"} bittrexFields := []string{"amount", "datetime", "id", "price", "side", "symbol", "timestamp", "type"} @@ -255,6 +256,10 @@ func TestFetchTrades(t *testing.T) { exchangeName: "bittrex", tradingPair: "XLM/BTC", expectedFields: bittrexFields, + }, { + exchangeName: "poloniex", + tradingPair: "XLM/BTC", + expectedFields: poloniexFields, }, } { tradingPairString := strings.Replace(k.tradingPair, "/", "_", -1)