diff --git a/.gitignore b/.gitignore index a832a8104..b7a6a3b6c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ vendor/ coverage.txt build/ bin/ +logs/ .kelp/ .idea gui/filesystem_vfsdata.go diff --git a/cmd/trade.go b/cmd/trade.go index d5de3a1bf..1e59652c8 100644 --- a/cmd/trade.go +++ b/cmd/trade.go @@ -483,9 +483,9 @@ func makeBot( plugins.MakeFilterMakerMode(exchangeShim, sdex, tradingPair), ) } - if len(botConfig.Filters) > 0 && *options.strategy != "sell" && *options.strategy != "sell_twap" && *options.strategy != "delete" { + if len(botConfig.Filters) > 0 && *options.strategy != "sell" && *options.strategy != "sell_twap" && *options.strategy != "buy_twap" && *options.strategy != "delete" { log.Println() - utils.PrintErrorHintf("FILTERS currently only supported on 'sell' and 'delete' strategies, remove FILTERS from the trader config file") + utils.PrintErrorHintf("FILTERS currently only supported on 'sell', 'sell_twap', 'buy_twap', 'delete' strategies, remove FILTERS from the trader config file") // we want to delete all the offers and exit here since there is something wrong with our setup deleteAllOffersAndExit(l, botConfig, client, sdex, exchangeShim, threadTracker, metricsTracker) } diff --git a/examples/configs/trader/sample_buytwap.cfg b/examples/configs/trader/sample_buytwap.cfg new file mode 100644 index 000000000..111e3bec5 --- /dev/null +++ b/examples/configs/trader/sample_buytwap.cfg @@ -0,0 +1,124 @@ +# Sample config file for the "buy twap" strategy + +# This strategy requires the database and the fill handler to be enabled in the trader.cfg file + +# We are buying the base asset here, i.e. ASSET_CODE_A as defined in the trader config + +# Price Feeds +# Note: we take the value from the A feed and divide it by the value retrieved from the B feed below. +# the type of feeds can be one of crypto, fiat, fixed, exchange, sdex, function. + +# specification of feed type "exchange" +START_ASK_FEED_TYPE="exchange" +# the specification of the A feed +# the format is /// +# exchange name: +# use "kraken" or any of the ccxt-exchanges (run `kelp exchanges` for full list) +# examples: "kraken", "ccxt-kraken", "ccxt-binance", "ccxt-poloniex", "ccxt-bittrex" +# base asset code defined by exchange: +# this is the asset code defined by the exchange for the asset whose price you want to fetch (base asset). +# this code can be retrieved from the exchange's website or from the ccxt manual for ccxt-based exchanges. +# quote asset code defined by exchange: +# this is the asset code defined by the exchange for asset in which you want to quote the price (quote asset). +# this code can be retrieved from the exchange's website or from the ccxt manual for ccxt-based exchanges. +# modifier: +# this is a modifier that can be included only for feed type "exchange". +# a modifier allows you to fetch the "mid" price, "ask" price, "bid" price, or "last" price for now. +# if left unspecified then this is defaulted to "mid" for backwards compatibility (until v2.0 is released) (LOH-2) +# uncomment below to use binance, poloniex, or bittrex as your price feed. You will need to set up CCXT to use this, see the "Using CCXT" section in the README for details. +# be careful about using USD vs. USDT since some exchanges support only one, or both, or in some cases neither. +#START_ASK_FEED_URL="ccxt-kraken/XLM/USD/last" +#START_ASK_FEED_URL="ccxt-binance/XLM/USDT/ask" +#START_ASK_FEED_URL="ccxt-poloniex/XLM/USDT/bid" +# bittrex does not have an XLM/USD market so this config lists XLM/BTC instead; you should NOT use this when trying to price an asset based on the XLM/USD price (unless you know what you are doing). +#START_ASK_FEED_URL="ccxt-bittrex/XLM/BTC" +START_ASK_FEED_URL="kraken/XXLM/ZUSD/mid" + +# sample priceFeed with the "crypto" type +#START_ASK_FEED_TYPE="crypto" +# this is the URL to a coinmarketcap feed which the bot understands. +#START_ASK_FEED_URL="https://api.coinmarketcap.com/v1/ticker/stellar/" + +# 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 +# START_ASK_FEED_TYPE = "sdex" +# this is a string representing a SDEX pair; the format is CODE:ISSUER/CODE:ISSUER +# for XLM leave the issuer string blank +# START_ASK_FEED_URL="COUPON:GBMMZMK2DC4FFP4CAI6KCVNCQ7WLO5A7DQU7EC7WGHRDQBZB763X4OQI/XLM:" + +# sample priceFeed of type "function" +# this feed type uses one of the pre-defined functions to recursively operate on other price feeds +# all URLs for this type of feed are formatted like so: function_name(feed_type/feed_url[,feed_type/feed_url]) +#START_ASK_FEED_TYPE = "function" +# the supported functions for now are only the "max" and "invert" functions, example usage: +# "max": max(exchange/ccxt-kraken/XLM/USD/mid,exchange/ccxt-binance/XLM/USDT/mid) -- will give you the larger price +# between kraken's mid price and binance's mid price +# "invert": invert(exchange/ccxt-kraken/XLM/USD/mid) -- will give you the effective USD/XLM price +#START_ASK_FEED_URL = "max(exchange/ccxt-kraken/XLM/USD/mid,exchange/ccxt-binance/XLM/USDT/mid)" + +# 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 + +# what value of an amount change triggers re-creating an offer. Amount change refers to the existing amount of the offer vs. what amount we want to set. value is a percentage specified as a decimal number (0 < value < 1.00) +AMOUNT_TOLERANCE=0.001 + +# how much percent to offset your rates by, specified as a decimal (ex: 0.05 = 5%). Can be used in conjunction with RATE_OFFSET below. +# A positive value indicates that your base asset (ASSET_A) has a higher rate than the rate received from your price feed +# A negative value indicates that your base asset (ASSET_A) has a lower rate than the rate received from your price feed +RATE_OFFSET_PERCENT=0.0 +# how much to offset your rates by, specified in number of units of the quote asset (ASSET_B) as a decimal. +# Can be used in conjunction with RATE_OFFSET_PERCENT above. +# A positive value indicates that your base asset (ASSET_A) has a higher rate than the rate received from your price feed +# A negative value indicates that your base asset (ASSET_A) has a lower rate than the rate received from your price feed +RATE_OFFSET=0.0 +# specifies the order in which to offset the rates. If true then we apply the RATE_OFFSET_PERCENT first otherwise we apply the RATE_OFFSET first +# example rate calculation when set to true: ((rate_from_price_feed_a/rate_from_price_feed_b) * (1 + rate_offset_percent)) + rate_offset +# example rate calculation when set to false: ((rate_from_price_feed_a/rate_from_price_feed_b) + rate_offset) * (1 + rate_offset_percent) +RATE_OFFSET_PERCENT_FIRST=true + +# NUM_HOURS_TO_SELL is an integer that defines the number of hours in which to complete the sales +# It is actually the number of hours to buy, but is called "SELL" for compatibility with pre-existing data structures +NUM_HOURS_TO_SELL = 23 + +# PARENT_BUCKET_SIZE_SECONDS is an integer value which represents the number of seconds to count as a single parent bucket. +# this should perfectly divide the number of seconds in a day (24 * 60 * 60) +# TODO how does this handle leap days? +PARENT_BUCKET_SIZE_SECONDS = 600 + +# DISTRIBUTE_SURPLUS_OVER_REMAINING_INTERVALS_PERCENT_CEILING is specified as a decimal value from 0.0-1.0 inclusive. +# we take the percent of remaining bucket intervals (ceiling of that number) to arrive at the number of intervals over which to distribute the surplus. +# ceiling(4.5) = 5 +# ceiling(5.1) = 6 +# example: a value of 0.05 here will distribute the surplus over 1/20th of the remaining bucket intervals +# if there are 345 bucket intervals remaining and this value is set to 0.05, the bot will use this calculation: +# ceiling(0.05 * 345) +# = ceiling(17.25) +# = 17 buckets over which to distribute the excess surplus +# therefore, the bot will distribute the surplus over the remaining 17 bucket intervals (of total 345 bucket intervals) +# Note that setting this to 0.0 will discard any surplus and setting it to 1.0 will distribute over all remaining bucket intervals +DISTRIBUTE_SURPLUS_OVER_REMAINING_INTERVALS_PERCENT_CEILING = 0.05 + +# EXPONENTIAL_SMOOTHING_FACTOR is a decimal (0 <= x <= 1) +# a larger number results in a smoother distribution across the remaining intervals +# set this to 1.0 for a linear distribution over the chosen bucket intervals and 0.0 to buy the entire surplus in the next bucket interval +EXPONENTIAL_SMOOTHING_FACTOR = 0.50 + +# MIN_CHILD_ORDER_SIZE_PERCENT_OF_PARENT is a decimal value (0 <= x <= 1) which defines the lower bound of the randomization function to be +# used when picking the size of the order to be placed in a bot update cycle. The randomized number is then multiplied by the total capacity +# for the current bucket interval. If the available capacity for the interval is less than this amount then we will use the available capacity. +MIN_CHILD_ORDER_SIZE_PERCENT_OF_PARENT = 0.2 + +#################################################################################################### +############################## ALL LISTS AND OBJECTS BELOW THIS LINE ############################### +#################################################################################################### + +# DAY_OF_WEEK_DAILY_CAP is a volume filter specified individually for every day of the week +# make sure any filters in your trader.cfg file is compliant with this configuration +[DAY_OF_WEEK_DAILY_CAP] +Mo = "volume/daily/buy/base/10000.0/exact" +Tu = "volume/daily/buy/base/10000.0/exact" +We = "volume/daily/buy/base/10000.0/exact" +Th = "volume/daily/buy/base/10000.0/exact" +Fr = "volume/daily/buy/base/10000.0/exact" +Sa = "volume/daily/buy/base/10000.0/exact" +Su = "volume/daily/buy/base/10000.0/exact" \ No newline at end of file diff --git a/examples/walkthroughs/trader/buy_twap.md b/examples/walkthroughs/trader/buy_twap.md new file mode 100644 index 000000000..c75c59efa --- /dev/null +++ b/examples/walkthroughs/trader/buy_twap.md @@ -0,0 +1,63 @@ +# TWAP Buy + +This guide shows you how to setup the **kelp** bot using the [buy_twap](../../../plugins/buyTwapStrategy.go) strategy. This strategy will buy tokens using the well-known [TWAP metric](https://en.wikipedia.org/wiki/Time-weighted_average_price). We'll configure it to buy 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 places a single buy order which is refreshed and randomized at every bot update as configured in `trader.cfg`. There is a fixed buy capacity for each `bucket` based on the daily buy limit configuration. Any value that is unbought in a given bucket (surplus) is distributed over the remaining buckets. The config values control the bucket size along with other factors to determine the distribution of the surplus. + +## 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`. In the `buy_twap` strategy the bot is programmed to buy `ASSET_CODE_A` from the `trader.cfg` file. + +## 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. + +## BuyTwap Strategy Configuration + +Use the [sample configuration file for the buy_twap strategy](../../configs/trader/sample_buytwap.cfg) as a template. We will walkthrough the configuration parameters below. + +### Price Feeds + +BuyTwap requires one price feed, `START_ASK_FEED`. This computes the price used when placing the single buy order. + +To give `COUPON` a stable price against USD, we're going to set `START_ASK_FEED_TYPE` to `"exchange"` and `START_ASK_FEED_URL` to `"kraken/XXLM/ZUSD"`. This points our bot to Kraken's price for 1 XLM, quoted in USD. + +### 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 `0.001` which means that we need _at least a 0.1% change in amount for the bot to refresh its orders_. 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. + +Note that this strategy randomizes the order size at every update, so that should be taken into consideration when setting these tolerance values. + +### Buckets + +The `PARENT_BUCKET_SIZE_SECONDS` configuration defines the size in seconds of each bucket. There are 86,400 seconds in each day. The number of buckets is determined by dividing 86,400 by `PARENT_BUCKET_SIZE_SECONDS`, so `PARENT_BUCKET_SIZE_SECONDS` needs to perfectly divide 86,400. + +In our sample configuration we have set this to 600 seconds, which is equal to 10 minutes. This will give us 144 buckets every day. + +### Distributing the Surplus + +There are two config params that control how the surplus is distributed, `DISTRIBUTE_SURPLUS_OVER_REMAINING_INTERVALS_PERCENT_CEILING` and `EXPONENTIAL_SMOOTHING_FACTOR`. + +`DISTRIBUTE_SURPLUS_OVER_REMAINING_INTERVALS_PERCENT_CEILING` is a decimal value between 0.0 and 1.0, both inclusive. Setting this to 0.0 will discard any surplus and setting it to 1.0 will distribute the surplus over all the remaining buckets. Setting it to 0.50 will distribute it over 50% of the remaining buckets. + +`EXPONENTIAL_SMOOTHING_FACTOR` determines how _smoothly_ we should distribute any surplus. This is a decimal value between 0.0 and 1.0, both inclusive. Setting this to 0.0 will buy the entire surplus in the next bucket interval (least smooth). Setting this to 1.0 will distribute the surplus evenly (linearly) over the chosen bucket intervals (most smooth). + +### Amounts + +The `DAY_OF_WEEK_DAILY_CAP` determines the daily limit of how many tokens of the base asset to buy. This has to be of the form `"volume/daily/buy/base/X/exact"` where `X` is the daily limit in base units to be bought. This configuration is a map of volume filters for each day of the week, which gives you flexibility to have a per-day configuration. A filter for each day must be specified. + +The capacity of each bucket is the daily buy amount divided by the number of buckets + the surplus allocated to that bucket. + +As mentioned above, the order is randomized and refreshed at each bot update at the interval described in the `trader.cfg` file. The minimum order size for this randomization is controlled by `MIN_CHILD_ORDER_SIZE_PERCENT_OF_PARENT` which is a decimal from 0.0 to 1.0, both inclusive. A value of 0.0 means that there is no minimum order size, whereas a value of 1.0 indicates that the order size should be the capacity of the bucket (i.e. no ramdomization). If the capacity for the bucket is less than the computed minimum amount then the remaining capacity is used as the order size. + +## Run Kelp + +Assuming your botConfig is called `trader.cfg` and your strategy config is called `buytwap.cfg`, you can run `kelp` with the following command: + +``` +kelp trade --botConf ./path/trader.cfg --strategy buy_twap --stratConf ./path/buytwap.cfg +``` + +# Above and Beyond + +You can also play around with the configuration parameters of the [sample configuration file for the buy_twap strategy](../../configs/trader/sample_buytwap.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_. \ No newline at end of file diff --git a/plugins/buyTwapStrategy.go b/plugins/buyTwapStrategy.go new file mode 100644 index 000000000..06c6d9f2c --- /dev/null +++ b/plugins/buyTwapStrategy.go @@ -0,0 +1,78 @@ +package plugins + +import ( + "fmt" + "time" + + hProtocol "github.com/stellar/go/protocols/horizon" + "github.com/stellar/kelp/api" + "github.com/stellar/kelp/model" +) + +// makeBuyTwapStrategy is a factory method for BuyTwapStrategy +func makeBuyTwapStrategy( + sdex *SDEX, + pair *model.TradingPair, + ieif *IEIF, + assetBase *hProtocol.Asset, + assetQuote *hProtocol.Asset, + filterFactory *FilterFactory, + config *sellTwapConfig, +) (api.Strategy, error) { + startPf, e := MakePriceFeed(config.StartAskFeedType, config.StartAskFeedURL) + if e != nil { + return nil, fmt.Errorf("error when making the start priceFeed: %s", e) + } + + orderConstraints := sdex.GetOrderConstraints(pair) + offset := rateOffset{ + percent: config.RateOffsetPercent, + absolute: config.RateOffset, + percentFirst: config.RateOffsetPercentFirst, + } + dowFilter, e := makeDowFilter(filterFactory, config.DayOfWeekDailyCap) + if e != nil { + return nil, fmt.Errorf("error when making dowFilter: %s", e) + } + levelProvider, e := makeSellTwapLevelProvider( + startPf, + offset, + orderConstraints, + dowFilter, + config.NumHoursToSell, + config.ParentBucketSizeSeconds, + config.DistributeSurplusOverRemainingIntervalsPercentCeiling, + config.ExponentialSmoothingFactor, + config.MinChildOrderSizePercentOfParent, + time.Now().UnixNano(), + true, + ) + if e != nil { + return nil, fmt.Errorf("error when making a sellTwapLevelProvider: %s", e) + } + + // switch sides of base/quote here for buy side + buySideStrategy := makeSellSideStrategy( + sdex, + orderConstraints, + ieif, + assetQuote, + assetBase, + levelProvider, + config.PriceTolerance, + config.AmountTolerance, + true, + ) + + // use assetBase as param to assetBase argument, since the delete strategy is + // on the sell side. so the params should line up correctly with the arguments + deleteSideStrategy := makeDeleteSideStrategy(sdex, assetBase, assetQuote) + + return makeComposeStrategy( + assetBase, + assetQuote, + buySideStrategy, + deleteSideStrategy, + ), nil + +} diff --git a/plugins/factory.go b/plugins/factory.go index b99eb9078..12a999b0a 100644 --- a/plugins/factory.go +++ b/plugins/factory.go @@ -168,6 +168,32 @@ var strategies = map[string]StrategyContainer{ return s, nil }, }, + "buy_twap": { + SortOrder: 7, + Description: "Creates buy offers by distributing orders over time for a given day using a twap metric", + NeedsConfig: true, + Complexity: "Intermediate", + makeFn: func(strategyFactoryData strategyFactoryData) (api.Strategy, error) { + // reuse the sellTwapConfig struct since we need the same info for buyTwap + var cfg sellTwapConfig + err := config.Read(strategyFactoryData.stratConfigPath, &cfg) + utils.CheckConfigError(cfg, err, strategyFactoryData.stratConfigPath) + utils.LogConfig(cfg) + s, e := makeBuyTwapStrategy( + strategyFactoryData.sdex, + strategyFactoryData.tradingPair, + strategyFactoryData.ieif, + strategyFactoryData.assetBase, + strategyFactoryData.assetQuote, + strategyFactoryData.filterFactory, + &cfg, + ) + if e != nil { + return nil, fmt.Errorf("make Fn failed: %s", e) + } + return s, nil + }, + }, } // MakeStrategy makes a strategy diff --git a/plugins/sellTwapLevelProvider.go b/plugins/sellTwapLevelProvider.go index 86d98f803..e0f550832 100644 --- a/plugins/sellTwapLevelProvider.go +++ b/plugins/sellTwapLevelProvider.go @@ -30,6 +30,7 @@ type sellTwapLevelProvider struct { exponentialSmoothingFactor float64 minChildOrderSizePercentOfParent float64 random *rand.Rand + isBuySide bool // uninitialized activeBucket *bucketInfo @@ -51,6 +52,7 @@ func makeSellTwapLevelProvider( exponentialSmoothingFactor float64, minChildOrderSizePercentOfParent float64, randSeed int64, + isBuySide bool, ) (api.LevelProvider, error) { if numHoursToSell <= 0 || numHoursToSell > 24 { return nil, fmt.Errorf("invalid number of hours to sell, expected 0 < numHoursToSell <= 24; was %d", numHoursToSell) @@ -77,8 +79,8 @@ func makeSellTwapLevelProvider( } for i, f := range dowFilter { - if !f.isSellingBase() { - return nil, fmt.Errorf("volume filter at index %d was not selling the base asset as expected: %s", i, f.configValue) + if !f.isBase() { + return nil, fmt.Errorf("volume filter at index %d was not constrained on the base asset as expected: %s (we currently only allow buy and sell constraints in base units)", i, f.configValue) } } @@ -94,6 +96,7 @@ func makeSellTwapLevelProvider( exponentialSmoothingFactor: exponentialSmoothingFactor, minChildOrderSizePercentOfParent: minChildOrderSizePercentOfParent, random: random, + isBuySide: isBuySide, }, nil } @@ -116,7 +119,9 @@ type bucketInfo struct { totalBuckets int64 totalBucketsToSell int64 dayBaseSoldStart float64 - dayBaseCapacity float64 + // currently we only allow dayBaseCapacity and not dayQuoteCapacity (or dayCapacity as a common field) + // TODO NS allow quote capacity to work for twap and adjust log lines accordingly + dayBaseCapacity float64 // surplus can be negative because offers are outstanding and can be consumed while we run these level calculations. i.e. it can never be atomic. // the probability of this happening is small and as the execution speed of the update loop improves (with better code) the probability will go down. // It can be made logically atomic (with more guarantees than the fix in #456) by deleting outstanding offers in the pre-update data synchronization. @@ -273,8 +278,15 @@ func (p *sellTwapLevelProvider) GetLevels(maxAssetBase float64, maxAssetQuote fl if round.sizeBaseCapped < p.orderConstraints.MinBaseVolume.AsFloat() { return []api.Level{}, nil } + + // we invert the price for buy side + price := round.price + if p.isBuySide { + price = 1 / price + } + return []api.Level{{ - Price: *model.NumberFromFloat(round.price, p.orderConstraints.PricePrecision), + Price: *model.NumberFromFloat(price, p.orderConstraints.PricePrecision), Amount: *model.NumberFromFloat(round.sizeBaseCapped, p.orderConstraints.VolumePrecision), }}, nil } diff --git a/plugins/sellTwapLevelProvider_test.go b/plugins/sellTwapLevelProvider_test.go index fc3e03d53..919cc6eb9 100644 --- a/plugins/sellTwapLevelProvider_test.go +++ b/plugins/sellTwapLevelProvider_test.go @@ -92,6 +92,7 @@ func makeTestSellTwapLevelProvider2( 0.5, minChildOrderSizePercentOfParent, seed, + false, ) if e != nil { panic(e) diff --git a/plugins/sellTwapStrategy.go b/plugins/sellTwapStrategy.go index 02c60e4ee..bac3a7c84 100644 --- a/plugins/sellTwapStrategy.go +++ b/plugins/sellTwapStrategy.go @@ -80,6 +80,7 @@ func makeSellTwapStrategy( config.ExponentialSmoothingFactor, config.MinChildOrderSizePercentOfParent, time.Now().UnixNano(), + false, ) if e != nil { return nil, fmt.Errorf("error when making a sellTwapLevelProvider: %s", e) diff --git a/plugins/volumeFilter.go b/plugins/volumeFilter.go index 10b32b8f7..201291e18 100644 --- a/plugins/volumeFilter.go +++ b/plugins/volumeFilter.go @@ -286,8 +286,8 @@ func (f *volumeFilter) String() string { } // isBase returns true if the filter is on the amount of the base asset sold, false otherwise -func (f *volumeFilter) isSellingBase() bool { - return strings.Contains(f.configValue, "/sell/base/") +func (f *volumeFilter) isBase() bool { + return strings.Contains(f.configValue, "/base/") } func (f *volumeFilter) mustGetBaseAssetCapInBaseUnits() (float64, error) {