From 773889b2f98ea9cb3bb1a373482c88d275213ca3 Mon Sep 17 00:00:00 2001 From: Debnil Sur Date: Mon, 19 Oct 2020 15:59:48 -0700 Subject: [PATCH 1/6] Initial commit --- examples/configs/trader/sample_buytwap.cfg | 120 +++++++++++++++++++++ examples/walkthroughs/trader/buy_twap.md | 63 +++++++++++ plugins/buyTwapStrategy.go | 77 +++++++++++++ plugins/factory.go | 26 +++++ 4 files changed, 286 insertions(+) create mode 100644 examples/configs/trader/sample_buytwap.cfg create mode 100644 examples/walkthroughs/trader/buy_twap.md create mode 100644 plugins/buyTwapStrategy.go diff --git a/examples/configs/trader/sample_buytwap.cfg b/examples/configs/trader/sample_buytwap.cfg new file mode 100644 index 000000000..40e055ed7 --- /dev/null +++ b/examples/configs/trader/sample_buytwap.cfg @@ -0,0 +1,120 @@ +# 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. +#DATA_FEED_A_URL="ccxt-kraken/XLM/USD/last" +#DATA_FEED_A_URL="ccxt-binance/XLM/USDT/ask" +#DATA_FEED_A_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). +#DATA_FEED_A_URL="ccxt-bittrex/XLM/BTC" +START_ASK_FEED_URL="kraken/XXLM/ZUSD/mid" + +# sample priceFeed with the "crypto" type +#DATA_TYPE_A="crypto" +# this is the URL to a coinmarketcap feed which the bot understands. +#DATA_FEED_A_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 +# 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:" + +# 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]) +#DATA_TYPE_A = "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 +#DATA_FEED_A_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 + +# 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/sell/base/10000.0/exact" +Tu = "volume/daily/sell/base/10000.0/exact" +We = "volume/daily/sell/base/10000.0/exact" +Th = "volume/daily/sell/base/10000.0/exact" +Fr = "volume/daily/sell/base/10000.0/exact" +Sa = "volume/daily/sell/base/10000.0/exact" +Su = "volume/daily/sell/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..66fd8e9a5 --- /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_. diff --git a/plugins/buyTwapStrategy.go b/plugins/buyTwapStrategy.go new file mode 100644 index 000000000..052b1811b --- /dev/null +++ b/plugins/buyTwapStrategy.go @@ -0,0 +1,77 @@ +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(), + ) + 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 0594f348a..384cf9479 100644 --- a/plugins/factory.go +++ b/plugins/factory.go @@ -167,6 +167,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 From f4c5927f6f136ced1760b06f6eb0ea23feae3a04 Mon Sep 17 00:00:00 2001 From: Nikhil Saraf <1028334+nikhilsaraf@users.noreply.github.com> Date: Tue, 20 Oct 2020 15:21:37 +0530 Subject: [PATCH 2/6] 1 - add hint message when db is missing --- queries/dailyVolumeByDate.go | 2 ++ queries/strategyMirrorTradeTriggerExists.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/queries/dailyVolumeByDate.go b/queries/dailyVolumeByDate.go index cf1516c1c..485983a92 100644 --- a/queries/dailyVolumeByDate.go +++ b/queries/dailyVolumeByDate.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/stellar/kelp/api" + "github.com/stellar/kelp/support/utils" ) // sqlQueryDailyValuesTemplateAllAccounts queries the trades table to get the values for a given day @@ -37,6 +38,7 @@ func MakeDailyVolumeByDateForMarketIdsAction( optionalAccountIDs []string, ) (*DailyVolumeByDate, error) { if db == nil { + utils.PrintErrorHintf("the provided POSTGRES_DB config in the trader.cfg file should be non-nil") return nil, fmt.Errorf("the provided db should be non-nil") } diff --git a/queries/strategyMirrorTradeTriggerExists.go b/queries/strategyMirrorTradeTriggerExists.go index 0afd5278c..a927eca1b 100644 --- a/queries/strategyMirrorTradeTriggerExists.go +++ b/queries/strategyMirrorTradeTriggerExists.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/stellar/kelp/api" + "github.com/stellar/kelp/support/utils" ) // sqlQueryStrategyMirrorTradeTriggerExists queries the strategy_mirror_trade_triggers table by market_id and txid (primary key) to see if the row exists @@ -23,6 +24,7 @@ var _ api.Query = &StrategyMirrorTradeTriggerExists{} // MakeStrategyMirrorTradeTriggerExists makes the StrategyMirrorTradeTriggerExists query func MakeStrategyMirrorTradeTriggerExists(db *sql.DB, marketID string) (*StrategyMirrorTradeTriggerExists, error) { if db == nil { + utils.PrintErrorHintf("the provided POSTGRES_DB config in the trader.cfg file should be non-nil") return nil, fmt.Errorf("the provided db should be non-nil") } From 059c840f9e979ec9b6179e2e1db0d5ccda55a7a4 Mon Sep 17 00:00:00 2001 From: Nikhil Saraf <1028334+nikhilsaraf@users.noreply.github.com> Date: Tue, 20 Oct 2020 16:59:58 +0530 Subject: [PATCH 3/6] 2 - sample buy_twap should use "buy" instead of "sell" in the config --- examples/configs/trader/sample_buytwap.cfg | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/configs/trader/sample_buytwap.cfg b/examples/configs/trader/sample_buytwap.cfg index 40e055ed7..f325b15a5 100644 --- a/examples/configs/trader/sample_buytwap.cfg +++ b/examples/configs/trader/sample_buytwap.cfg @@ -111,10 +111,10 @@ MIN_CHILD_ORDER_SIZE_PERCENT_OF_PARENT = 0.2 # 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/sell/base/10000.0/exact" -Tu = "volume/daily/sell/base/10000.0/exact" -We = "volume/daily/sell/base/10000.0/exact" -Th = "volume/daily/sell/base/10000.0/exact" -Fr = "volume/daily/sell/base/10000.0/exact" -Sa = "volume/daily/sell/base/10000.0/exact" -Su = "volume/daily/sell/base/10000.0/exact" \ No newline at end of file +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 From c98d75a7c5a0c815938687e0c112b0c7545d7a3a Mon Sep 17 00:00:00 2001 From: Nikhil Saraf <1028334+nikhilsaraf@users.noreply.github.com> Date: Tue, 20 Oct 2020 17:00:23 +0530 Subject: [PATCH 4/6] 3 - update db query struct to support "buy" type queries --- queries/dailyVolumeByDate.go | 30 +++++++++++++++++++++++++++--- queries/dailyVolumeByDate_test.go | 18 +++++++++++++++++- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/queries/dailyVolumeByDate.go b/queries/dailyVolumeByDate.go index 485983a92..07f08dcdc 100644 --- a/queries/dailyVolumeByDate.go +++ b/queries/dailyVolumeByDate.go @@ -15,11 +15,35 @@ const sqlQueryDailyValuesTemplateAllAccounts = "SELECT SUM(base_volume) as total // sqlQueryDailyValuesTemplateSpecificAccounts queries the trades table to get the values for a given day filtered by specific accounts const sqlQueryDailyValuesTemplateSpecificAccounts = "SELECT SUM(base_volume) as total_base_volume, SUM(counter_cost) as total_counter_volume FROM trades WHERE market_id IN (%s) AND account_id IN (%s) AND DATE(date_utc) = $1 and action = $2 group by DATE(date_utc)" +// DailyVolumeAction represents either a sell or a buy +type DailyVolumeAction string + +// type of DailyVolumeAction +const ( + DailyVolumeActionBuy DailyVolumeAction = "buy" + DailyVolumeActionSell DailyVolumeAction = "sell" +) + +// String is the Stringer method impl +func (a DailyVolumeAction) String() string { + return string(a) +} + +// ParseDailyVolumeAction converts a string to a DailyVolumeAction +func ParseDailyVolumeAction(action string) (DailyVolumeAction, error) { + if action == DailyVolumeActionBuy.String() { + return DailyVolumeActionBuy, nil + } else if action == DailyVolumeActionSell.String() { + return DailyVolumeActionSell, nil + } + return DailyVolumeActionSell, fmt.Errorf("invalid action value '%s'", action) +} + // DailyVolumeByDate is a query that fetches the daily volume of sales type DailyVolumeByDate struct { db *sql.DB sqlQuery string - action string + action DailyVolumeAction } var _ api.Query = &DailyVolumeByDate{} @@ -34,7 +58,7 @@ type DailyVolume struct { func MakeDailyVolumeByDateForMarketIdsAction( db *sql.DB, marketIDs []string, - action string, + action DailyVolumeAction, optionalAccountIDs []string, ) (*DailyVolumeByDate, error) { if db == nil { @@ -63,7 +87,7 @@ func (q *DailyVolumeByDate) QueryRow(args ...interface{}) (interface{}, error) { return nil, fmt.Errorf("input arg needs to be of type 'string', but was of type '%T'", args[0]) } - row := q.db.QueryRow(q.sqlQuery, args[0], q.action) + row := q.db.QueryRow(q.sqlQuery, args[0], q.action.String()) var baseVol sql.NullFloat64 var quoteVol sql.NullFloat64 diff --git a/queries/dailyVolumeByDate_test.go b/queries/dailyVolumeByDate_test.go index d9a5d75b2..dd09cd279 100644 --- a/queries/dailyVolumeByDate_test.go +++ b/queries/dailyVolumeByDate_test.go @@ -39,6 +39,7 @@ func connectTestDb() *sql.DB { func TestDailyVolumeByDate_QueryRow(t *testing.T) { testCases := []struct { + action DailyVolumeAction queryByOptionalAccountIDs []string wantYesterdayBase float64 wantYesterdayQuote float64 @@ -48,6 +49,17 @@ func TestDailyVolumeByDate_QueryRow(t *testing.T) { wantTomorrowQuote float64 }{ { + // TODO add case for buy base/quote and add trade data in test below accordingly + // action: DailyVolumeActionBuy, + // queryByOptionalAccountIDs: []string{}, // accountID1 and accountID2 are the only ones that exists + // wantYesterdayBase: 100.0, + // wantYesterdayQuote: 10.0, + // wantTodayBase: 207.0, + // wantTodayQuote: 21.83, + // wantTomorrowBase: 102.0, + // wantTomorrowQuote: 12.24, + // }, { + action: DailyVolumeActionSell, queryByOptionalAccountIDs: []string{}, // accountID1 and accountID2 are the only ones that exists wantYesterdayBase: 100.0, wantYesterdayQuote: 10.0, @@ -56,6 +68,7 @@ func TestDailyVolumeByDate_QueryRow(t *testing.T) { wantTomorrowBase: 102.0, wantTomorrowQuote: 12.24, }, { + action: DailyVolumeActionSell, queryByOptionalAccountIDs: []string{"accountID1", "accountID2"}, // accountID1 and accountID2 are the only ones that exists wantYesterdayBase: 100.0, wantYesterdayQuote: 10.0, @@ -64,6 +77,7 @@ func TestDailyVolumeByDate_QueryRow(t *testing.T) { wantTomorrowBase: 102.0, wantTomorrowQuote: 12.24, }, { + action: DailyVolumeActionSell, queryByOptionalAccountIDs: []string{"accountID1"}, // accountID1 has most of the entries wantYesterdayBase: 100.0, wantYesterdayQuote: 10.0, @@ -72,6 +86,7 @@ func TestDailyVolumeByDate_QueryRow(t *testing.T) { wantTomorrowBase: 102.0, wantTomorrowQuote: 12.24, }, { + action: DailyVolumeActionSell, queryByOptionalAccountIDs: []string{"accountID2"}, //accountID2 has only one entry, which is for today wantYesterdayBase: 0.0, wantYesterdayQuote: 0.0, @@ -80,6 +95,7 @@ func TestDailyVolumeByDate_QueryRow(t *testing.T) { wantTomorrowBase: 0.0, wantTomorrowQuote: 0.0, }, { + action: DailyVolumeActionSell, queryByOptionalAccountIDs: []string{"accountID3"}, //accountID3 does not exist wantYesterdayBase: 0.0, wantYesterdayQuote: 0.0, @@ -196,7 +212,7 @@ func TestDailyVolumeByDate_QueryRow(t *testing.T) { dailyVolumeByDateQuery, e := MakeDailyVolumeByDateForMarketIdsAction( db, []string{"market1"}, - "sell", + k.action, k.queryByOptionalAccountIDs, ) if !assert.NoError(t, e) { From e9870c7bdb09193f66de47b5cae6e88ccc33891e Mon Sep 17 00:00:00 2001 From: Nikhil Saraf <1028334+nikhilsaraf@users.noreply.github.com> Date: Tue, 20 Oct 2020 17:07:06 +0530 Subject: [PATCH 5/6] 4 - update structure of volumeFilter to make room for buy side --- plugins/volumeFilter.go | 74 ++++++++++++++++++----------------------- 1 file changed, 33 insertions(+), 41 deletions(-) diff --git a/plugins/volumeFilter.go b/plugins/volumeFilter.go index df90eee5f..6f32f5ae2 100644 --- a/plugins/volumeFilter.go +++ b/plugins/volumeFilter.go @@ -5,7 +5,6 @@ import ( "fmt" "log" "strconv" - "strings" "time" hProtocol "github.com/stellar/go/protocols/horizon" @@ -35,13 +34,12 @@ func parseVolumeFilterMode(mode string) (volumeFilterMode, error) { // VolumeFilterConfig ensures that any one constraint that is hit will result in deleting all offers and pausing until limits are no longer constrained type VolumeFilterConfig struct { - SellBaseAssetCapInBaseUnits *float64 - SellBaseAssetCapInQuoteUnits *float64 - mode volumeFilterMode - additionalMarketIDs []string - optionalAccountIDs []string - // buyBaseAssetCapInBaseUnits *float64 - // buyBaseAssetCapInQuoteUnits *float64 + BaseAssetCapInBaseUnits *float64 + BaseAssetCapInQuoteUnits *float64 + action queries.DailyVolumeAction + mode volumeFilterMode + additionalMarketIDs []string + optionalAccountIDs []string } type volumeFilter struct { @@ -75,7 +73,7 @@ func makeFilterVolume( } marketID := MakeMarketID(exchangeName, baseAssetString, quoteAssetString) marketIDs := utils.Dedupe(append([]string{marketID}, config.additionalMarketIDs...)) - dailyVolumeByDateQuery, e := queries.MakeDailyVolumeByDateForMarketIdsAction(db, marketIDs, "sell", config.optionalAccountIDs) + dailyVolumeByDateQuery, e := queries.MakeDailyVolumeByDateForMarketIdsAction(db, marketIDs, config.action, config.optionalAccountIDs) if e != nil { return nil, fmt.Errorf("could not make daily volume by date Query: %s", e) } @@ -102,8 +100,8 @@ func (c *VolumeFilterConfig) Validate() error { // String is the stringer method func (c *VolumeFilterConfig) String() string { - return fmt.Sprintf("VolumeFilterConfig[SellBaseAssetCapInBaseUnits=%s, SellBaseAssetCapInQuoteUnits=%s, mode=%s, additionalMarketIDs=%v, optionalAccountIDs=%v]", - utils.CheckedFloatPtr(c.SellBaseAssetCapInBaseUnits), utils.CheckedFloatPtr(c.SellBaseAssetCapInQuoteUnits), c.mode, c.additionalMarketIDs, c.optionalAccountIDs) + return fmt.Sprintf("VolumeFilterConfig[BaseAssetCapInBaseUnits=%s, BaseAssetCapInQuoteUnits=%s, mode=%s, action=%s, additionalMarketIDs=%v, optionalAccountIDs=%v]", + utils.CheckedFloatPtr(c.BaseAssetCapInBaseUnits), utils.CheckedFloatPtr(c.BaseAssetCapInQuoteUnits), c.mode, c.action, c.additionalMarketIDs, c.optionalAccountIDs) } func (f *volumeFilter) Apply(ops []txnbuild.Operation, sellingOffers []hProtocol.Offer, buyingOffers []hProtocol.Offer) ([]txnbuild.Operation, error) { @@ -123,15 +121,15 @@ func (f *volumeFilter) Apply(ops []txnbuild.Operation, sellingOffers []hProtocol // daily on-the-books dailyOTB := &VolumeFilterConfig{ - SellBaseAssetCapInBaseUnits: &dailyValuesBaseSold.BaseVol, - SellBaseAssetCapInQuoteUnits: &dailyValuesBaseSold.QuoteVol, + BaseAssetCapInBaseUnits: &dailyValuesBaseSold.BaseVol, + BaseAssetCapInQuoteUnits: &dailyValuesBaseSold.QuoteVol, } // daily to-be-booked starts out as empty and accumulates the values of the operations - dailyTbbSellBase := 0.0 - dailyTbbSellQuote := 0.0 + dailyTbbBase := 0.0 + dailyTbbQuote := 0.0 dailyTBB := &VolumeFilterConfig{ - SellBaseAssetCapInBaseUnits: &dailyTbbSellBase, - SellBaseAssetCapInQuoteUnits: &dailyTbbSellQuote, + BaseAssetCapInBaseUnits: &dailyTbbBase, + BaseAssetCapInQuoteUnits: &dailyTbbQuote, } innerFn := func(op *txnbuild.ManageSellOffer) (*txnbuild.ManageSellOffer, error) { @@ -165,12 +163,12 @@ func (f *volumeFilter) volumeFilterFn(dailyOTB *VolumeFilterConfig, dailyTBB *Vo newAmountBeingSold := amountValueUnitsBeingSold var keepSellingBase bool var keepSellingQuote bool - if f.config.SellBaseAssetCapInBaseUnits != nil { - projectedSoldInBaseUnits := *dailyOTB.SellBaseAssetCapInBaseUnits + *dailyTBB.SellBaseAssetCapInBaseUnits + amountValueUnitsBeingSold - keepSellingBase = projectedSoldInBaseUnits <= *f.config.SellBaseAssetCapInBaseUnits + if f.config.BaseAssetCapInBaseUnits != nil { + projectedSoldInBaseUnits := *dailyOTB.BaseAssetCapInBaseUnits + *dailyTBB.BaseAssetCapInBaseUnits + amountValueUnitsBeingSold + keepSellingBase = projectedSoldInBaseUnits <= *f.config.BaseAssetCapInBaseUnits newAmountString := "" if f.config.mode == volumeFilterModeExact && !keepSellingBase { - newAmount := *f.config.SellBaseAssetCapInBaseUnits - *dailyOTB.SellBaseAssetCapInBaseUnits - *dailyTBB.SellBaseAssetCapInBaseUnits + newAmount := *f.config.BaseAssetCapInBaseUnits - *dailyOTB.BaseAssetCapInBaseUnits - *dailyTBB.BaseAssetCapInBaseUnits if newAmount > 0 { newAmountBeingSold = newAmount opToReturn.Amount = fmt.Sprintf("%.7f", newAmountBeingSold) @@ -178,17 +176,17 @@ func (f *volumeFilter) volumeFilterFn(dailyOTB *VolumeFilterConfig, dailyTBB *Vo newAmountString = ", newAmountString = " + opToReturn.Amount } } - log.Printf("volumeFilter: selling (base units), price=%.8f amount=%.8f, keep = (projectedSoldInBaseUnits) %.7f <= %.7f (config.SellBaseAssetCapInBaseUnits): keepSellingBase = %v%s", sellPrice, amountValueUnitsBeingSold, projectedSoldInBaseUnits, *f.config.SellBaseAssetCapInBaseUnits, keepSellingBase, newAmountString) + log.Printf("volumeFilter: selling (base units), price=%.8f amount=%.8f, keep = (projectedSoldInBaseUnits) %.7f <= %.7f (config.BaseAssetCapInBaseUnits): keepSellingBase = %v%s", sellPrice, amountValueUnitsBeingSold, projectedSoldInBaseUnits, *f.config.BaseAssetCapInBaseUnits, keepSellingBase, newAmountString) } else { keepSellingBase = true } - if f.config.SellBaseAssetCapInQuoteUnits != nil { - projectedSoldInQuoteUnits := *dailyOTB.SellBaseAssetCapInQuoteUnits + *dailyTBB.SellBaseAssetCapInQuoteUnits + (newAmountBeingSold * sellPrice) - keepSellingQuote = projectedSoldInQuoteUnits <= *f.config.SellBaseAssetCapInQuoteUnits + if f.config.BaseAssetCapInQuoteUnits != nil { + projectedSoldInQuoteUnits := *dailyOTB.BaseAssetCapInQuoteUnits + *dailyTBB.BaseAssetCapInQuoteUnits + (newAmountBeingSold * sellPrice) + keepSellingQuote = projectedSoldInQuoteUnits <= *f.config.BaseAssetCapInQuoteUnits newAmountString := "" if f.config.mode == volumeFilterModeExact && !keepSellingQuote { - newAmount := (*f.config.SellBaseAssetCapInQuoteUnits - *dailyOTB.SellBaseAssetCapInQuoteUnits - *dailyTBB.SellBaseAssetCapInQuoteUnits) / sellPrice + newAmount := (*f.config.BaseAssetCapInQuoteUnits - *dailyOTB.BaseAssetCapInQuoteUnits - *dailyTBB.BaseAssetCapInQuoteUnits) / sellPrice if newAmount > 0 { newAmountBeingSold = newAmount opToReturn.Amount = fmt.Sprintf("%.7f", newAmountBeingSold) @@ -196,19 +194,19 @@ func (f *volumeFilter) volumeFilterFn(dailyOTB *VolumeFilterConfig, dailyTBB *Vo newAmountString = ", newAmountString = " + opToReturn.Amount } } - log.Printf("volumeFilter: selling (quote units), price=%.8f amount=%.8f, keep = (projectedSoldInQuoteUnits) %.7f <= %.7f (config.SellBaseAssetCapInQuoteUnits): keepSellingQuote = %v%s", sellPrice, amountValueUnitsBeingSold, projectedSoldInQuoteUnits, *f.config.SellBaseAssetCapInQuoteUnits, keepSellingQuote, newAmountString) + log.Printf("volumeFilter: selling (quote units), price=%.8f amount=%.8f, keep = (projectedSoldInQuoteUnits) %.7f <= %.7f (config.BaseAssetCapInQuoteUnits): keepSellingQuote = %v%s", sellPrice, amountValueUnitsBeingSold, projectedSoldInQuoteUnits, *f.config.BaseAssetCapInQuoteUnits, keepSellingQuote, newAmountString) } else { keepSellingQuote = true } if keepSellingBase && keepSellingQuote { // update the dailyTBB to include the additional amounts so they can be used in the calculation of the next operation - *dailyTBB.SellBaseAssetCapInBaseUnits += newAmountBeingSold - *dailyTBB.SellBaseAssetCapInQuoteUnits += (newAmountBeingSold * sellPrice) + *dailyTBB.BaseAssetCapInBaseUnits += newAmountBeingSold + *dailyTBB.BaseAssetCapInQuoteUnits += (newAmountBeingSold * sellPrice) return opToReturn, nil } } else { - // TODO buying side + // TODO buying side - we need to implement this to support buy side filters; extract common logic from the above sell side case } // we don't want to keep it so return the dropped command @@ -222,29 +220,23 @@ 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/") + return f.config.action == queries.DailyVolumeActionSell && f.config.BaseAssetCapInBaseUnits != nil } func (f *volumeFilter) mustGetBaseAssetCapInBaseUnits() (float64, error) { - value := f.config.SellBaseAssetCapInBaseUnits + value := f.config.BaseAssetCapInBaseUnits if value == nil { - return 0.0, fmt.Errorf("SellBaseAssetCapInBaseUnits is nil, config = %v", f.config) + return 0.0, fmt.Errorf("BaseAssetCapInBaseUnits is nil, config = %v", f.config) } return *value, nil } func (c *VolumeFilterConfig) isEmpty() bool { - if c.SellBaseAssetCapInBaseUnits != nil { + if c.BaseAssetCapInBaseUnits != nil { return false } - if c.SellBaseAssetCapInQuoteUnits != nil { + if c.BaseAssetCapInQuoteUnits != nil { return false } - // if buyBaseAssetCapInBaseUnits != nil { - // return false - // } - // if buyBaseAssetCapInQuoteUnits != nil { - // return false - // } return true } From 4f31e5f17de6d960ef7fe80a30ada855254da77d Mon Sep 17 00:00:00 2001 From: Nikhil Saraf <1028334+nikhilsaraf@users.noreply.github.com> Date: Tue, 20 Oct 2020 17:07:10 +0530 Subject: [PATCH 6/6] 5 - update filterFactory and test to construct the VolumeFilterConfig correctly --- plugins/filterFactory.go | 14 +++--- plugins/filterFactory_test.go | 82 +++++++++++++++++++++++------------ 2 files changed, 64 insertions(+), 32 deletions(-) diff --git a/plugins/filterFactory.go b/plugins/filterFactory.go index a419be207..544292251 100644 --- a/plugins/filterFactory.go +++ b/plugins/filterFactory.go @@ -9,6 +9,7 @@ import ( hProtocol "github.com/stellar/go/protocols/horizon" "github.com/stellar/kelp/model" + "github.com/stellar/kelp/queries" ) var filterIDRegex *regexp.Regexp @@ -88,6 +89,12 @@ func makeVolumeFilterConfig(configInput string) (*VolumeFilterConfig, error) { return nil, fmt.Errorf("invalid input (%s), the second part needs to equal or start with \"daily\"", configInput) } + action, e := queries.ParseDailyVolumeAction(parts[2]) + if e != nil { + return nil, fmt.Errorf("could not parse volume filter action from input (%s): %s", configInput, e) + } + config.action = action + errInvalid := fmt.Errorf("invalid input (%s), the modifier for \"daily\" can be either \"market_ids\" or \"account_ids\" like so 'daily:market_ids=[4c19915f47,db4531d586]' or 'daily:account_ids=[account1,account2]' or 'daily:market_ids=[4c19915f47,db4531d586]:account_ids=[account1,account2]'", configInput) if len(limitWindowParts) == 2 { e = addModifierToConfig(config, limitWindowParts[1]) @@ -108,17 +115,14 @@ func makeVolumeFilterConfig(configInput string) (*VolumeFilterConfig, error) { return nil, fmt.Errorf("invalid input (%s), the second part needs to be \"daily\" and can have only one modifier \"market_ids\" like so 'daily:market_ids=[4c19915f47,db4531d586]'", configInput) } - if parts[2] != "sell" { - return nil, fmt.Errorf("invalid input (%s), the third part needs to be \"sell\"", configInput) - } limit, e := strconv.ParseFloat(parts[4], 64) if e != nil { return nil, fmt.Errorf("could not parse the fourth part as a float value from config value (%s): %s", configInput, e) } if parts[3] == "base" { - config.SellBaseAssetCapInBaseUnits = &limit + config.BaseAssetCapInBaseUnits = &limit } else if parts[3] == "quote" { - config.SellBaseAssetCapInQuoteUnits = &limit + config.BaseAssetCapInQuoteUnits = &limit } else { return nil, fmt.Errorf("invalid input (%s), the third part needs to be \"base\" or \"quote\"", configInput) } diff --git a/plugins/filterFactory_test.go b/plugins/filterFactory_test.go index f905cfce6..1dbb7422b 100644 --- a/plugins/filterFactory_test.go +++ b/plugins/filterFactory_test.go @@ -6,6 +6,8 @@ import ( "github.com/openlyinc/pointy" "github.com/stretchr/testify/assert" + + "github.com/stellar/kelp/queries" ) func TestParseIdsArray(t *testing.T) { @@ -136,49 +138,74 @@ func TestMakeVolumeFilterConfig(t *testing.T) { wantConfig *VolumeFilterConfig }{ { + configInput: "volume/daily/buy/base/3500.0/exact", + wantConfig: &VolumeFilterConfig{ + BaseAssetCapInBaseUnits: pointy.Float64(3500.0), + BaseAssetCapInQuoteUnits: nil, + mode: volumeFilterModeExact, + action: queries.DailyVolumeActionBuy, + additionalMarketIDs: nil, + optionalAccountIDs: nil, + }, + }, { + configInput: "volume/daily/buy/quote/4000.0/exact", + wantConfig: &VolumeFilterConfig{ + BaseAssetCapInBaseUnits: nil, + BaseAssetCapInQuoteUnits: pointy.Float64(4000.0), + mode: volumeFilterModeExact, + action: queries.DailyVolumeActionBuy, + additionalMarketIDs: nil, + optionalAccountIDs: nil, + }, + }, { configInput: "volume/daily/sell/base/3500.0/exact", wantConfig: &VolumeFilterConfig{ - SellBaseAssetCapInBaseUnits: pointy.Float64(3500.0), - SellBaseAssetCapInQuoteUnits: nil, - mode: volumeFilterModeExact, - additionalMarketIDs: nil, - optionalAccountIDs: nil, + BaseAssetCapInBaseUnits: pointy.Float64(3500.0), + BaseAssetCapInQuoteUnits: nil, + mode: volumeFilterModeExact, + action: queries.DailyVolumeActionSell, + additionalMarketIDs: nil, + optionalAccountIDs: nil, }, }, { configInput: "volume/daily/sell/quote/1000.0/ignore", wantConfig: &VolumeFilterConfig{ - SellBaseAssetCapInBaseUnits: nil, - SellBaseAssetCapInQuoteUnits: pointy.Float64(1000.0), - mode: volumeFilterModeIgnore, - additionalMarketIDs: nil, - optionalAccountIDs: nil, + BaseAssetCapInBaseUnits: nil, + BaseAssetCapInQuoteUnits: pointy.Float64(1000.0), + mode: volumeFilterModeIgnore, + action: queries.DailyVolumeActionSell, + additionalMarketIDs: nil, + optionalAccountIDs: nil, }, }, { configInput: "volume/daily:market_ids=[4c19915f47,db4531d586]/sell/base/3500.0/exact", wantConfig: &VolumeFilterConfig{ - SellBaseAssetCapInBaseUnits: pointy.Float64(3500.0), - SellBaseAssetCapInQuoteUnits: nil, - mode: volumeFilterModeExact, - additionalMarketIDs: []string{"4c19915f47", "db4531d586"}, - optionalAccountIDs: nil, + BaseAssetCapInBaseUnits: pointy.Float64(3500.0), + BaseAssetCapInQuoteUnits: nil, + mode: volumeFilterModeExact, + action: queries.DailyVolumeActionSell, + additionalMarketIDs: []string{"4c19915f47", "db4531d586"}, + optionalAccountIDs: nil, }, }, { configInput: "volume/daily:account_ids=[account1,account2]/sell/base/3500.0/exact", wantConfig: &VolumeFilterConfig{ - SellBaseAssetCapInBaseUnits: pointy.Float64(3500.0), - SellBaseAssetCapInQuoteUnits: nil, - mode: volumeFilterModeExact, - additionalMarketIDs: nil, - optionalAccountIDs: []string{"account1", "account2"}, + BaseAssetCapInBaseUnits: pointy.Float64(3500.0), + BaseAssetCapInQuoteUnits: nil, + mode: volumeFilterModeExact, + action: queries.DailyVolumeActionSell, + additionalMarketIDs: nil, + optionalAccountIDs: []string{"account1", "account2"}, }, }, { configInput: "volume/daily:market_ids=[4c19915f47,db4531d586]:account_ids=[account1,account2]/sell/base/3500.0/exact", wantConfig: &VolumeFilterConfig{ - SellBaseAssetCapInBaseUnits: pointy.Float64(3500.0), - SellBaseAssetCapInQuoteUnits: nil, - mode: volumeFilterModeExact, - additionalMarketIDs: []string{"4c19915f47", "db4531d586"}, - optionalAccountIDs: []string{"account1", "account2"}, + BaseAssetCapInBaseUnits: pointy.Float64(3500.0), + BaseAssetCapInQuoteUnits: nil, + mode: volumeFilterModeExact, + action: queries.DailyVolumeActionSell, + additionalMarketIDs: []string{"4c19915f47", "db4531d586"}, + optionalAccountIDs: []string{"account1", "account2"}, }, }, } @@ -200,9 +227,10 @@ func assertVolumeFilterConfigEqual(t *testing.T, want *VolumeFilterConfig, actua } else if actual == nil { assert.Fail(t, fmt.Sprintf("actual was nil but expected %v", *want)) } else { - assert.Equal(t, want.SellBaseAssetCapInBaseUnits, actual.SellBaseAssetCapInBaseUnits) - assert.Equal(t, want.SellBaseAssetCapInQuoteUnits, actual.SellBaseAssetCapInQuoteUnits) + assert.Equal(t, want.BaseAssetCapInBaseUnits, actual.BaseAssetCapInBaseUnits) + assert.Equal(t, want.BaseAssetCapInQuoteUnits, actual.BaseAssetCapInQuoteUnits) assert.Equal(t, want.mode, actual.mode) + assert.Equal(t, want.action, actual.action) assert.Equal(t, want.additionalMarketIDs, actual.additionalMarketIDs) assert.Equal(t, want.optionalAccountIDs, actual.optionalAccountIDs) }