diff --git a/cmd/trade.go b/cmd/trade.go index 170d19359..6faa038a8 100644 --- a/cmd/trade.go +++ b/cmd/trade.go @@ -1,6 +1,7 @@ package cmd import ( + "database/sql" "fmt" "io" "log" @@ -329,7 +330,9 @@ func makeBot( exchangeShim api.ExchangeShim, ieif *plugins.IEIF, tradingPair *model.TradingPair, + db *sql.DB, strategy api.Strategy, + assetDisplayFn model.AssetDisplayFn, threadTracker *multithreading.ThreadTracker, options inputs, ) *trader.Trader { @@ -344,30 +347,67 @@ func makeBot( // 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) } - dataKey := model.MakeSortedBotKey(botConfig.AssetBase(), botConfig.AssetQuote()) + + assetBase := botConfig.AssetBase() + assetQuote := botConfig.AssetQuote() + dataKey := model.MakeSortedBotKey(assetBase, assetQuote) alert, e := monitoring.MakeAlert(botConfig.AlertType, botConfig.AlertAPIKey) if e != nil { l.Infof("Unable to set up monitoring for alert type '%s' with the given API key\n", botConfig.AlertType) } - bot := trader.MakeBot( + + // start make filters + submitFilters := []plugins.SubmitFilter{ + plugins.MakeFilterOrderConstraints(exchangeShim.GetOrderConstraints(tradingPair), assetBase, assetQuote), + } + if submitMode == api.SubmitModeMakerOnly { + submitFilters = append(submitFilters, + plugins.MakeFilterMakerMode(exchangeShim, sdex, tradingPair), + ) + } + if len(botConfig.Filters) > 0 && *options.strategy != "sell" && *options.strategy != "delete" { + log.Println() + utils.PrintErrorHintf("FILTERS currently only supported on 'sell' and '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) + } + filterFactory := plugins.FilterFactory{ + ExchangeName: botConfig.TradingExchangeName(), + TradingPair: tradingPair, + AssetDisplayFn: assetDisplayFn, + BaseAsset: assetBase, + QuoteAsset: assetQuote, + DB: db, + } + for _, filterString := range botConfig.Filters { + filter, e := filterFactory.MakeFilter(filterString) + if e != nil { + log.Println() + log.Println(e) + // 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) + } + submitFilters = append(submitFilters, filter) + } + // end make filters + + return trader.MakeTrader( client, ieif, - botConfig.AssetBase(), - botConfig.AssetQuote(), - tradingPair, + assetBase, + assetQuote, botConfig.TradingAccount(), sdex, exchangeShim, strategy, timeController, botConfig.DeleteCyclesThreshold, - submitMode, + submitFilters, threadTracker, options.fixedIterations, dataKey, alert, ) - return bot } func convertDeprecatedBotConfigValues(l logger.Logger, botConfig trader.BotConfig) trader.BotConfig { @@ -434,6 +474,20 @@ func runTradeCmd(options inputs) { tradingPair.Base: botConfig.AssetBase(), tradingPair.Quote: botConfig.AssetQuote(), } + assetDisplayFn := model.MakePassthroughAssetDisplayFn() + if botConfig.IsTradingSdex() { + assetDisplayFn = model.MakeSdexMappedAssetDisplayFn(sdexAssetMap) + } + + var db *sql.DB + if botConfig.PostgresDbConfig != nil { + var e error + db, e = database.ConnectInitializedDatabase(botConfig.PostgresDbConfig) + if e != nil { + logger.Fatal(l, fmt.Errorf("problem encountered while initializing the db: %s", e)) + } + log.Printf("made db instance with config: %s\n", botConfig.PostgresDbConfig.MakeConnectString()) + } exchangeShim, sdex := makeExchangeShimSdex( l, botConfig, @@ -467,7 +521,9 @@ func runTradeCmd(options inputs) { exchangeShim, ieif, tradingPair, + db, strategy, + assetDisplayFn, threadTracker, options, ) @@ -496,7 +552,8 @@ func runTradeCmd(options inputs) { sdex, exchangeShim, tradingPair, - sdexAssetMap, + assetDisplayFn, + db, threadTracker, ) startQueryServer( @@ -565,7 +622,8 @@ func startFillTracking( sdex *plugins.SDEX, exchangeShim api.ExchangeShim, tradingPair *model.TradingPair, - sdexAssetMap map[model.Asset]hProtocol.Asset, + assetDisplayFn model.AssetDisplayFn, + db *sql.DB, threadTracker *multithreading.ThreadTracker, ) { strategyFillHandlers, e := strategy.GetFillHandlers() @@ -580,19 +638,7 @@ func startFillTracking( fillTracker := plugins.MakeFillTracker(tradingPair, threadTracker, exchangeShim, botConfig.FillTrackerSleepMillis, botConfig.FillTrackerDeleteCyclesThreshold) fillLogger := plugins.MakeFillLogger() fillTracker.RegisterHandler(fillLogger) - if botConfig.PostgresDbConfig != nil { - db, e := database.ConnectInitializedDatabase(botConfig.PostgresDbConfig) - if e != nil { - l.Info("") - l.Errorf("problem encountered while initializing the db: %s", e) - deleteAllOffersAndExit(l, botConfig, client, sdex, exchangeShim, threadTracker) - } - log.Printf("made db instance with config: %s\n", botConfig.PostgresDbConfig.MakeConnectString()) - - assetDisplayFn := model.MakePassthroughAssetDisplayFn() - if botConfig.IsTradingSdex() { - assetDisplayFn = model.MakeSdexMappedAssetDisplayFn(sdexAssetMap) - } + if db != nil { fillDBWriter := plugins.MakeFillDBWriter(db, assetDisplayFn, botConfig.TradingExchangeName()) fillTracker.RegisterHandler(fillDBWriter) } diff --git a/database/schema.go b/database/schema.go index 0ec1d6375..26507b8e1 100644 --- a/database/schema.go +++ b/database/schema.go @@ -17,6 +17,8 @@ const sqlTradesTableCreate = "CREATE TABLE IF NOT EXISTS trades (market_id TEXT indexes */ const sqlTradesIndexCreate = "CREATE INDEX IF NOT EXISTS date ON trades (market_id, date_utc)" +const sqlTradesIndexDrop = "DROP INDEX IF EXISTS date" +const sqlTradesIndexCreate2 = "CREATE INDEX IF NOT EXISTS trades_mdd ON trades (market_id, DATE(date_utc), date_utc)" /* insert statements @@ -39,6 +41,9 @@ const SqlQueryMarketsById = "SELECT market_id, exchange_name, base, quote FROM m // sqlQueryDbVersion queries the db_version table const sqlQueryDbVersion = "SELECT version FROM db_version ORDER BY version desc LIMIT 1" +// SqlQueryDailyValues queries the trades table to get the values for a given day +const SqlQueryDailyValues = "SELECT SUM(base_volume) as total_base_volume, SUM(counter_cost) as total_counter_volume FROM trades WHERE market_id = $1 AND DATE(date_utc) = $2 and action = $3 group by DATE(date_utc)" + /* query helper functions */ diff --git a/database/upgrade.go b/database/upgrade.go index 489d4863c..9cb5c6eb5 100644 --- a/database/upgrade.go +++ b/database/upgrade.go @@ -18,6 +18,10 @@ var upgradeScripts = []*UpgradeScript{ sqlTradesTableCreate, sqlTradesIndexCreate, ), + makeUpgradeScript(3, + sqlTradesIndexDrop, + sqlTradesIndexCreate2, + ), } // UpgradeScript encapsulates a script to be run to upgrade the database from one version to the next @@ -104,7 +108,7 @@ func runUpgradeScripts(db *sql.DB, scripts []*UpgradeScript) error { // add entry to db_version table sqlInsertDbVersion := fmt.Sprintf(sqlDbVersionTableInsertTemplate, script.version, - startTime.Format(postgresdb.DateFormatString), + startTime.Format(postgresdb.TimestampFormatString), len(script.commands), elapsedMillis, ) diff --git a/examples/configs/trader/sample_trader.cfg b/examples/configs/trader/sample_trader.cfg index 946183e33..fdcccf311 100644 --- a/examples/configs/trader/sample_trader.cfg +++ b/examples/configs/trader/sample_trader.cfg @@ -51,15 +51,6 @@ HORIZON_URL="https://horizon-testnet.stellar.org" # the URL to use for your CCXT-rest instance. Defaults to http://localhost:3000 if unset #CCXT_REST_URL="http://localhost:3000" -# specify parameters for how we compute the operation fee from the /fee_stats endpoint -[FEE] -# trigger when "ledger_capacity_usage" in /fee_stats is >= this value -CAPACITY_TRIGGER=0.8 -# percentile computation to use from /fee_stats (10, 20, ..., 90, 95, 99) -PERCENTILE=90 -# max fee in stroops per operation to use -MAX_OP_FEE_STROOPS=5000 - # uncomment below to add support for monitoring. # type of alerting system to use, currently only "PagerDuty" is supported. #ALERT_TYPE="PagerDuty" @@ -99,6 +90,33 @@ MAX_OP_FEE_STROOPS=5000 # (optional) minimum volume of quote units needed to place an order on the non-sdex (centralized) exchange #CENTRALIZED_MIN_QUOTE_VOLUME_OVERRIDE=10.0 +# uncomment to include these filters in order (these filters only work with sell strategy for now) +# these are the only four filters available for now via this new filtration method and any new filters added will include a +# corresponding sample entry with an explanation. +# the best way to use these filters is to uncomment the one you want to use and update the price (last param) accordingly. +#FILTERS = [ +# # limit the amount of the base asset that is sold every day, denominated in units of the base asset (needs POSTGRES_DB) +# "volume/sell/base/3500.0", +# +# # limit the amount of the base asset that is sold every day, denominated in units of the quote asset (needs POSTGRES_DB) +# "volume/sell/quote/1000.0", +# +# # limit offers based on a minimim price requirement +# "price/min/0.04", +# +# # limit offers based on a maximum price requirement +# "price/max/1.00", +#] + +# specify parameters for how we compute the operation fee from the /fee_stats endpoint +[FEE] +# trigger when "ledger_capacity_usage" in /fee_stats is >= this value +CAPACITY_TRIGGER=0.8 +# percentile computation to use from /fee_stats (10, 20, ..., 90, 95, 99) +PERCENTILE=90 +# max fee in stroops per operation to use +MAX_OP_FEE_STROOPS=5000 + # uncomment if you want to track fills in a postgres db #[POSTGRES_DB] #HOST="localhost" diff --git a/plugins/balancedStrategy.go b/plugins/balancedStrategy.go index e312fbb2f..f2b42e3f5 100644 --- a/plugins/balancedStrategy.go +++ b/plugins/balancedStrategy.go @@ -26,7 +26,7 @@ type balancedConfig struct { // String impl. func (c balancedConfig) String() string { - return utils.StructString(c, nil) + return utils.StructString(c, 0, nil) } // makeBalancedStrategy is a factory method for balancedStrategy diff --git a/plugins/buysellStrategy.go b/plugins/buysellStrategy.go index bd7769c82..8e7068836 100644 --- a/plugins/buysellStrategy.go +++ b/plugins/buysellStrategy.go @@ -55,7 +55,7 @@ func MakeBuysellConfig( // String impl. func (c BuySellConfig) String() string { - return utils.StructString(c, nil) + return utils.StructString(c, 0, nil) } // makeBuySellStrategy is a factory method diff --git a/plugins/fillDBWriter.go b/plugins/fillDBWriter.go index 73c64d6ae..1e27e2170 100644 --- a/plugins/fillDBWriter.go +++ b/plugins/fillDBWriter.go @@ -46,14 +46,17 @@ type FillDBWriter struct { market *tradingMarket } -// makeTradingMarket makes a market along with the ID field -func makeTradingMarket(exchangeName string, baseAsset string, quoteAsset string) *tradingMarket { +func makeMarketID(exchangeName string, baseAsset string, quoteAsset string) string { idString := fmt.Sprintf("%s_%s_%s", exchangeName, baseAsset, quoteAsset) h := sha256.New() h.Write([]byte(idString)) sha256Hash := fmt.Sprintf("%x", h.Sum(nil)) - sha256HashPrefix := sha256Hash[0:marketIdHashLength] + return sha256Hash[0:marketIdHashLength] +} +// makeTradingMarket makes a market along with the ID field +func makeTradingMarket(exchangeName string, baseAsset string, quoteAsset string) *tradingMarket { + sha256HashPrefix := makeMarketID(exchangeName, baseAsset, quoteAsset) return &tradingMarket{ ID: sha256HashPrefix, ExchangeName: exchangeName, @@ -156,7 +159,7 @@ func (f *FillDBWriter) HandleFill(trade model.Trade) error { txid := utils.CheckedString(trade.TransactionID) timeSeconds := trade.Timestamp.AsInt64() / 1000 date := time.Unix(timeSeconds, 0).UTC() - dateString := date.Format(postgresdb.DateFormatString) + dateString := date.Format(postgresdb.TimestampFormatString) market, e := f.fetchOrRegisterMarket(trade) if e != nil { diff --git a/plugins/filterFactory.go b/plugins/filterFactory.go new file mode 100644 index 000000000..71c44d144 --- /dev/null +++ b/plugins/filterFactory.go @@ -0,0 +1,98 @@ +package plugins + +import ( + "database/sql" + "fmt" + "strconv" + "strings" + + hProtocol "github.com/stellar/go/protocols/horizon" + "github.com/stellar/kelp/model" +) + +var filterMap = map[string]func(f *FilterFactory, configInput string) (SubmitFilter, error){ + "volume": filterVolume, + "price": filterPrice, +} + +// FilterFactory is a struct that handles creating all the filters +type FilterFactory struct { + ExchangeName string + TradingPair *model.TradingPair + AssetDisplayFn model.AssetDisplayFn + BaseAsset hProtocol.Asset + QuoteAsset hProtocol.Asset + DB *sql.DB +} + +// MakeFilter is the function that makes the required filters +func (f *FilterFactory) MakeFilter(configInput string) (SubmitFilter, error) { + parts := strings.Split(configInput, "/") + if len(parts) <= 0 { + return nil, fmt.Errorf("invalid input (%s), needs at least 1 delimiter (/)", configInput) + } + + filterName := parts[0] + factoryMethod, ok := filterMap[filterName] + if !ok { + return nil, fmt.Errorf("could not find filter of type '%s'", filterName) + } + + return factoryMethod(f, configInput) +} + +func filterVolume(f *FilterFactory, configInput string) (SubmitFilter, error) { + parts := strings.Split(configInput, "/") + if len(parts) != 4 { + return nil, fmt.Errorf("invalid input (%s), needs 4 parts separated by the delimiter (/)", configInput) + } + + config := &VolumeFilterConfig{} + if parts[1] != "sell" { + return nil, fmt.Errorf("invalid input (%s), the second part needs to be \"sell\"", configInput) + } + limit, e := strconv.ParseFloat(parts[3], 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[2] == "base" { + config.SellBaseAssetCapInBaseUnits = &limit + } else if parts[2] == "quote" { + config.SellBaseAssetCapInQuoteUnits = &limit + } else { + return nil, fmt.Errorf("invalid input (%s), the third part needs to be \"base\" or \"quote\"", configInput) + } + if e := config.Validate(); e != nil { + return nil, fmt.Errorf("invalid input (%s), did not pass validation: %s", configInput, e) + } + + return makeFilterVolume( + f.ExchangeName, + f.TradingPair, + f.AssetDisplayFn, + f.BaseAsset, + f.QuoteAsset, + f.DB, + config, + ) +} + +func filterPrice(f *FilterFactory, configInput string) (SubmitFilter, error) { + parts := strings.Split(configInput, "/") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid input (%s), needs 3 parts separated by the delimiter (/)", configInput) + } + + limit, e := strconv.ParseFloat(parts[2], 64) + if e != nil { + return nil, fmt.Errorf("could not parse the third part as a float value from config value (%s): %s", configInput, e) + } + if parts[1] == "min" { + config := MinPriceFilterConfig{MinPrice: &limit} + return MakeFilterMinPrice(f.BaseAsset, f.QuoteAsset, &config) + } else if parts[1] == "max" { + config := MaxPriceFilterConfig{MaxPrice: &limit} + return MakeFilterMaxPrice(f.BaseAsset, f.QuoteAsset, &config) + } + return nil, fmt.Errorf("invalid price filter type in second argument (%s)", configInput) +} diff --git a/plugins/makerModeFilter.go b/plugins/makerModeFilter.go index 188f8728e..51a28d287 100644 --- a/plugins/makerModeFilter.go +++ b/plugins/makerModeFilter.go @@ -13,21 +13,20 @@ import ( ) type makerModeFilter struct { + name string tradingPair *model.TradingPair exchangeShim api.ExchangeShim sdex *SDEX } // MakeFilterMakerMode makes a submit filter based on the passed in submitMode -func MakeFilterMakerMode(submitMode api.SubmitMode, exchangeShim api.ExchangeShim, sdex *SDEX, tradingPair *model.TradingPair) SubmitFilter { - if submitMode == api.SubmitModeMakerOnly { - return &makerModeFilter{ - tradingPair: tradingPair, - exchangeShim: exchangeShim, - sdex: sdex, - } +func MakeFilterMakerMode(exchangeShim api.ExchangeShim, sdex *SDEX, tradingPair *model.TradingPair) SubmitFilter { + return &makerModeFilter{ + name: "makeModeFilter", + tradingPair: tradingPair, + exchangeShim: exchangeShim, + sdex: sdex, } - return nil } var _ SubmitFilter = &makerModeFilter{} @@ -38,7 +37,24 @@ func (f *makerModeFilter) Apply(ops []txnbuild.Operation, sellingOffers []hProto return nil, fmt.Errorf("could not fetch orderbook: %s", e) } - ops, e = f.filterOps(ops, ob, sellingOffers, buyingOffers) + baseAsset, quoteAsset, e := f.sdex.Assets() + if e != nil { + return nil, fmt.Errorf("could not get assets: %s", e) + } + + innerFn := func(op *txnbuild.ManageSellOffer) (*txnbuild.ManageSellOffer, bool, error) { + topBidPrice, e := f.topOrderPriceExcludingTrader(ob.Bids(), buyingOffers, false) + if e != nil { + return nil, false, fmt.Errorf("could not get topOrderPriceExcludingTrader for bids: %s", e) + } + topAskPrice, e := f.topOrderPriceExcludingTrader(ob.Asks(), sellingOffers, true) + if e != nil { + return nil, false, fmt.Errorf("could not get topOrderPriceExcludingTrader for asks: %s", e) + } + + return f.transformOfferMakerMode(baseAsset, quoteAsset, topBidPrice, topAskPrice, op) + } + ops, e = filterOps(f.name, baseAsset, quoteAsset, sellingOffers, buyingOffers, ops, innerFn) if e != nil { return nil, fmt.Errorf("could not apply filter: %s", e) } @@ -109,65 +125,6 @@ func (f *makerModeFilter) topOrderPriceExcludingTrader(obSide []model.Order, tra return nil, nil } -func (f *makerModeFilter) filterOps( - ops []txnbuild.Operation, - ob *model.OrderBook, - sellingOffers []hProtocol.Offer, - buyingOffers []hProtocol.Offer, -) ([]txnbuild.Operation, error) { - baseAsset, quoteAsset, e := f.sdex.Assets() - if e != nil { - return nil, fmt.Errorf("could not get assets: %s", e) - } - - topBidPrice, e := f.topOrderPriceExcludingTrader(ob.Bids(), buyingOffers, false) - if e != nil { - return nil, fmt.Errorf("could not get topOrderPriceExcludingTrader for bids: %s", e) - } - topAskPrice, e := f.topOrderPriceExcludingTrader(ob.Asks(), sellingOffers, true) - if e != nil { - return nil, fmt.Errorf("could not get topOrderPriceExcludingTrader for asks: %s", e) - } - - numKeep := 0 - numDropped := 0 - numTransformed := 0 - filteredOps := []txnbuild.Operation{} - for _, op := range ops { - var newOp txnbuild.Operation - var keep bool - switch o := op.(type) { - case *txnbuild.ManageSellOffer: - newOp, keep, e = f.transformOfferMakerMode(baseAsset, quoteAsset, topBidPrice, topAskPrice, o) - if e != nil { - return nil, fmt.Errorf("could not transform offer (pointer case): %s", e) - } - default: - newOp = o - keep = true - } - - isNewOpNil := newOp == nil || fmt.Sprintf("%v", newOp) == "" - if keep { - if isNewOpNil { - return nil, fmt.Errorf("we want to keep op but newOp was nil (programmer error?)") - } - filteredOps = append(filteredOps, newOp) - numKeep++ - } else { - if !isNewOpNil { - // newOp can be a transformed op to change the op to an effectively "dropped" state - filteredOps = append(filteredOps, newOp) - numTransformed++ - } else { - numDropped++ - } - } - } - log.Printf("makerModeFilter: dropped %d, transformed %d, kept %d ops from original %d ops, len(filteredOps) = %d\n", numDropped, numTransformed, numKeep, len(ops), len(filteredOps)) - return filteredOps, nil -} - func (f *makerModeFilter) transformOfferMakerMode( baseAsset hProtocol.Asset, quoteAsset hProtocol.Asset, diff --git a/plugins/maxPriceFilter.go b/plugins/maxPriceFilter.go new file mode 100644 index 000000000..7907d87b6 --- /dev/null +++ b/plugins/maxPriceFilter.go @@ -0,0 +1,77 @@ +package plugins + +import ( + "fmt" + "strconv" + + hProtocol "github.com/stellar/go/protocols/horizon" + "github.com/stellar/go/txnbuild" + "github.com/stellar/kelp/support/utils" +) + +// MaxPriceFilterConfig ensures that any one constraint that is hit will result in deleting all offers and pausing until limits are no longer constrained +type MaxPriceFilterConfig struct { + MaxPrice *float64 +} + +type maxPriceFilter struct { + name string + config *MaxPriceFilterConfig + baseAsset hProtocol.Asset + quoteAsset hProtocol.Asset +} + +// MakeFilterMaxPrice makes a submit filter that limits orders placed based on the price +func MakeFilterMaxPrice(baseAsset hProtocol.Asset, quoteAsset hProtocol.Asset, config *MaxPriceFilterConfig) (SubmitFilter, error) { + return &maxPriceFilter{ + name: "maxPriceFilter", + config: config, + baseAsset: baseAsset, + quoteAsset: quoteAsset, + }, nil +} + +var _ SubmitFilter = &maxPriceFilter{} + +// Validate ensures validity +func (c *MaxPriceFilterConfig) Validate() error { + if c.MaxPrice == nil { + return fmt.Errorf("needs a maxPrice config value") + } + return nil +} + +// String is the stringer method +func (c *MaxPriceFilterConfig) String() string { + return fmt.Sprintf("MaxPriceFilterConfig[MaxPrice=%s]", utils.CheckedFloatPtr(c.MaxPrice)) +} + +func (f *maxPriceFilter) Apply(ops []txnbuild.Operation, sellingOffers []hProtocol.Offer, buyingOffers []hProtocol.Offer) ([]txnbuild.Operation, error) { + ops, e := filterOps(f.name, f.baseAsset, f.quoteAsset, sellingOffers, buyingOffers, ops, f.maxPriceFilterFn) + if e != nil { + return nil, fmt.Errorf("could not apply filter: %s", e) + } + return ops, nil +} + +func (f *maxPriceFilter) maxPriceFilterFn(op *txnbuild.ManageSellOffer) (*txnbuild.ManageSellOffer, bool, error) { + isSell, e := utils.IsSelling(f.baseAsset, f.quoteAsset, op.Selling, op.Buying) + if e != nil { + return nil, false, fmt.Errorf("error when running the isSelling check: %s", e) + } + + sellPrice, e := strconv.ParseFloat(op.Price, 64) + if e != nil { + return nil, false, fmt.Errorf("could not convert price (%s) to float: %s", op.Price, e) + } + + if isSell { + if sellPrice > *f.config.MaxPrice { + return nil, false, nil + } + return op, true, nil + } + + // TODO for buy side + return op, true, nil +} diff --git a/plugins/minPriceFilter.go b/plugins/minPriceFilter.go new file mode 100644 index 000000000..049b1890e --- /dev/null +++ b/plugins/minPriceFilter.go @@ -0,0 +1,77 @@ +package plugins + +import ( + "fmt" + "strconv" + + hProtocol "github.com/stellar/go/protocols/horizon" + "github.com/stellar/go/txnbuild" + "github.com/stellar/kelp/support/utils" +) + +// MinPriceFilterConfig ensures that any one constraint that is hit will result in deleting all offers and pausing until limits are no longer constrained +type MinPriceFilterConfig struct { + MinPrice *float64 +} + +type minPriceFilter struct { + name string + config *MinPriceFilterConfig + baseAsset hProtocol.Asset + quoteAsset hProtocol.Asset +} + +// MakeFilterMinPrice makes a submit filter that limits orders placed based on the price +func MakeFilterMinPrice(baseAsset hProtocol.Asset, quoteAsset hProtocol.Asset, config *MinPriceFilterConfig) (SubmitFilter, error) { + return &minPriceFilter{ + name: "minPriceFilter", + config: config, + baseAsset: baseAsset, + quoteAsset: quoteAsset, + }, nil +} + +var _ SubmitFilter = &minPriceFilter{} + +// Validate ensures validity +func (c *MinPriceFilterConfig) Validate() error { + if c.MinPrice == nil { + return fmt.Errorf("needs a minPrice config value") + } + return nil +} + +// String is the stringer method +func (c *MinPriceFilterConfig) String() string { + return fmt.Sprintf("MinPriceFilterConfig[MinPrice=%s]", utils.CheckedFloatPtr(c.MinPrice)) +} + +func (f *minPriceFilter) Apply(ops []txnbuild.Operation, sellingOffers []hProtocol.Offer, buyingOffers []hProtocol.Offer) ([]txnbuild.Operation, error) { + ops, e := filterOps(f.name, f.baseAsset, f.quoteAsset, sellingOffers, buyingOffers, ops, f.minPriceFilterFn) + if e != nil { + return nil, fmt.Errorf("could not apply filter: %s", e) + } + return ops, nil +} + +func (f *minPriceFilter) minPriceFilterFn(op *txnbuild.ManageSellOffer) (*txnbuild.ManageSellOffer, bool, error) { + isSell, e := utils.IsSelling(f.baseAsset, f.quoteAsset, op.Selling, op.Buying) + if e != nil { + return nil, false, fmt.Errorf("error when running the isSelling check: %s", e) + } + + sellPrice, e := strconv.ParseFloat(op.Price, 64) + if e != nil { + return nil, false, fmt.Errorf("could not convert price (%s) to float: %s", op.Price, e) + } + + if isSell { + if sellPrice < *f.config.MinPrice { + return nil, false, nil + } + return op, true, nil + } + + // TODO for buy side + return op, true, nil +} diff --git a/plugins/mirrorStrategy.go b/plugins/mirrorStrategy.go index cd05af6c4..4928c1064 100644 --- a/plugins/mirrorStrategy.go +++ b/plugins/mirrorStrategy.go @@ -36,15 +36,10 @@ type mirrorConfig struct { // String impl. func (c mirrorConfig) String() string { - return utils.StructString(c, map[string]func(interface{}) interface{}{ - "EXCHANGE_API_KEYS": utils.Hide, - "EXCHANGE_PARAMS": utils.Hide, - "EXCHANGE_HEADERS": utils.Hide, - "PRICE_PRECISION_OVERRIDE": utils.UnwrapInt8Pointer, - "VOLUME_PRECISION_OVERRIDE": utils.UnwrapInt8Pointer, - "MIN_BASE_VOLUME": utils.UnwrapFloat64Pointer, - "MIN_BASE_VOLUME_OVERRIDE": utils.UnwrapFloat64Pointer, - "MIN_QUOTE_VOLUME_OVERRIDE": utils.UnwrapFloat64Pointer, + return utils.StructString(c, 0, map[string]func(interface{}) interface{}{ + "EXCHANGE_API_KEYS": utils.Hide, + "EXCHANGE_PARAMS": utils.Hide, + "EXCHANGE_HEADERS": utils.Hide, }) } diff --git a/plugins/sellStrategy.go b/plugins/sellStrategy.go index 240c231fa..2a3ac851c 100644 --- a/plugins/sellStrategy.go +++ b/plugins/sellStrategy.go @@ -27,7 +27,7 @@ type sellConfig struct { // String impl. func (c sellConfig) String() string { - return utils.StructString(c, nil) + return utils.StructString(c, 0, nil) } // makeSellStrategy is a factory method for SellStrategy diff --git a/plugins/submitFilter.go b/plugins/submitFilter.go index e47269c4d..516c9f14d 100644 --- a/plugins/submitFilter.go +++ b/plugins/submitFilter.go @@ -1,8 +1,13 @@ package plugins import ( + "fmt" + "log" + "strconv" + hProtocol "github.com/stellar/go/protocols/horizon" "github.com/stellar/go/txnbuild" + "github.com/stellar/kelp/support/utils" ) // SubmitFilter allows you to filter out operations before submitting to the network @@ -13,3 +18,217 @@ type SubmitFilter interface { buyingOffers []hProtocol.Offer, // quoted base/quote ) ([]txnbuild.Operation, error) } + +type filterFn func(op *txnbuild.ManageSellOffer) (*txnbuild.ManageSellOffer, bool, error) + +type filterCounter struct { + idx int + kept uint8 + dropped uint8 + transformed uint8 +} + +// build a list of the existing offers that have a corresponding operation so we ignore these offers and only consider the operation version +func ignoreOfferIDs(ops []txnbuild.Operation) map[int64]bool { + ignoreOfferIDs := map[int64]bool{} + for _, op := range ops { + switch o := op.(type) { + case *txnbuild.ManageSellOffer: + ignoreOfferIDs[o.OfferID] = true + default: + continue + } + } + return ignoreOfferIDs +} + +// TODO - simplify filterOps by separating out logic to convert into a single list of operations from transforming the operations +/* +What filterOps() does and why: + +Solving the "existing offers problem": +Problem: We need to run the existing offers against the filter as well since they may no longer be compliant. +Solution: Do a merge of two "sorted" lists (operations list, offers list) to create a new list of operations. + When sorted by price, this will ensure that we delete any spurious existing offers to meet the filter's + needs. This also serves the purpose of "interleaving" the operations related to the offers and ops. + +Solving the "ordering problem": +Problem: The incoming operations list combines both buy and sell operations. We want to run it though the filter + without modifying the order of the buy or sell segments, or modify operations within the segments since that + ordering is dictated by the strategy logic. +Solution: Since both these segments of buy/sell offers are contiguous, i.e. buy offers are all together and sell + offers are all together, we can identify the "cutover point" in each list of operations and offers, and then + advance the iteration index to the next segment for both segments in both lists by converting the remaining + offers and operations to delete operations. This will not affect the order of operations, but any new delete + operations created should be placed at the beginning of the respective buy and sell segments as is a requirement + on sdex (see sellSideStrategy.go for details on why we need to start off with the delete operations). + +Possible Question: Why do we not reuse the same logic that is in sellSideStrategy.go to "delete remaining offers"? +Answer: The logic that could possibly be reused is minimal -- it's just a for loop. The logic that converts offers + to the associated delete operation is reused, which is the main crux of the "business logic" that we want to + avoid rewriting. The logic in sellSideStrategy.go also only works on offers, here we work on offers and ops. + +Solving the "increase price problem": +Problem: If we increase the price off a sell offer (or decrease price of a buy offer) then we will see the offer + with an incorrect price before we see the update to the offer. This will result in an incorrect calculation, + since we will later on see the updated offer and make adjustments, which would result in runtime complexity + worse than O(N). +Solution: We first "dedupe" the offers and operations, by removing any offers that have a corresponding operation + update based on offerID. This has an additional overhead on runtime complexity of O(N). +*/ +func filterOps( + filterName string, + baseAsset hProtocol.Asset, + quoteAsset hProtocol.Asset, + sellingOffers []hProtocol.Offer, + buyingOffers []hProtocol.Offer, + ops []txnbuild.Operation, + fn filterFn, +) ([]txnbuild.Operation, error) { + ignoreOfferIds := ignoreOfferIDs(ops) + + opCounter := filterCounter{} + buyCounter := filterCounter{} + sellCounter := filterCounter{} + ignoredSellOffers, ignoredBuyOffers := 0, 0 + filteredOps := []txnbuild.Operation{} + for opCounter.idx < len(ops) { + op := ops[opCounter.idx] + var offerList []hProtocol.Offer + var offerCounter *filterCounter + var originalOffer *txnbuild.ManageSellOffer + var newOp txnbuild.Operation + var keep bool + switch o := op.(type) { + case *txnbuild.ManageSellOffer: + isSellOp, e := utils.IsSelling(baseAsset, quoteAsset, o.Selling, o.Buying) + if e != nil { + return nil, fmt.Errorf("could not check whether the ManageSellOffer was selling or buying: %s", e) + } + if isSellOp { + offerList = sellingOffers + offerCounter = &sellCounter + } else { + offerList = buyingOffers + offerCounter = &buyCounter + } + + opPrice, e := strconv.ParseFloat(o.Price, 64) + if e != nil { + return nil, fmt.Errorf("could not parse price as float64: %s", e) + } + + var opToTransform *txnbuild.ManageSellOffer + if offerCounter.idx >= len(offerList) { + opToTransform = o + opCounter.idx++ + } else { + existingOffer := offerList[offerCounter.idx] + if _, ignoreOffer := ignoreOfferIds[existingOffer.ID]; ignoreOffer { + // we want to only compare against valid offers so go to the next offer in the list + offerCounter.idx++ + if isSellOp { + ignoredSellOffers++ + } else { + ignoredBuyOffers++ + } + continue + } + + offerPrice := float64(existingOffer.PriceR.N) / float64(existingOffer.PriceR.D) + // use the existing offer if the price is the same so we don't recreate an offer unnecessarily + if opPrice < offerPrice { + opToTransform = o + opCounter.idx++ + } else { + opToTransform = convertOffer2MSO(existingOffer) + offerCounter.idx++ + originalOffer = convertOffer2MSO(existingOffer) + } + } + + // delete operations should never be dropped + if opToTransform.Amount == "0" { + newOp, keep = opToTransform, true + } else { + newOp, keep, e = fn(opToTransform) + if e != nil { + return nil, fmt.Errorf("could not transform offer (pointer case): %s", e) + } + } + default: + newOp = o + keep = true + } + + isNewOpNil := newOp == nil || fmt.Sprintf("%v", newOp) == "" + if keep { + if isNewOpNil { + return nil, fmt.Errorf("we want to keep op but newOp was nil (programmer error?)") + } + + newOpMSO := newOp.(*txnbuild.ManageSellOffer) + if originalOffer != nil && originalOffer.Price == newOpMSO.Price && originalOffer.Amount == newOpMSO.Amount { + // do not append to filteredOps because this is an existing offer that we want to keep as-is + offerCounter.kept++ + } else if originalOffer != nil { + // we were dealing with an existing offer that was changed + filteredOps = append(filteredOps, newOp) + offerCounter.transformed++ + } else { + // we were dealing with an operation + filteredOps = append(filteredOps, newOp) + opCounter.kept++ + } + } else { + if !isNewOpNil { + // newOp can be a transformed op to change the op to an effectively "dropped" state + // prepend this so we always have delete commands at the beginning of the operation list + filteredOps = append([]txnbuild.Operation{newOp}, filteredOps...) + if originalOffer != nil { + // we are dealing with an existing offer that needs dropping + offerCounter.dropped++ + } else { + // we are dealing with an operation that had updated an offer which now needs dropping + opCounter.transformed++ + } + } else { + // newOp will never be nil for an original offer since it has an offerID + opCounter.dropped++ + } + } + } + + // convert all remaining buy and sell offers to delete offers + for sellCounter.idx < len(sellingOffers) { + dropOp := convertOffer2MSO(sellingOffers[sellCounter.idx]) + dropOp.Amount = "0" + filteredOps = append([]txnbuild.Operation{dropOp}, filteredOps...) + sellCounter.dropped++ + sellCounter.idx++ + } + for buyCounter.idx < len(buyingOffers) { + dropOp := convertOffer2MSO(buyingOffers[buyCounter.idx]) + dropOp.Amount = "0" + filteredOps = append([]txnbuild.Operation{dropOp}, filteredOps...) + buyCounter.dropped++ + buyCounter.idx++ + } + + log.Printf("filter \"%s\" result A: dropped %d, transformed %d, kept %d ops from the %d ops passed in\n", filterName, opCounter.dropped, opCounter.transformed, opCounter.kept, len(ops)) + log.Printf("filter \"%s\" result B: dropped %d, transformed %d, kept %d, ignored %d sell offers from original %d sell offers\n", filterName, sellCounter.dropped, sellCounter.transformed, sellCounter.kept, ignoredSellOffers, len(sellingOffers)) + log.Printf("filter \"%s\" result C: dropped %d, transformed %d, kept %d, ignored %d buy offers from original %d buy offers\n", filterName, buyCounter.dropped, buyCounter.transformed, buyCounter.kept, ignoredBuyOffers, len(buyingOffers)) + log.Printf("filter \"%s\" result D: len(filteredOps) = %d\n", filterName, len(filteredOps)) + return filteredOps, nil +} + +func convertOffer2MSO(offer hProtocol.Offer) *txnbuild.ManageSellOffer { + return &txnbuild.ManageSellOffer{ + Selling: utils.Asset2Asset(offer.Selling), + Buying: utils.Asset2Asset(offer.Buying), + Amount: offer.Amount, + Price: offer.Price, + OfferID: offer.ID, + SourceAccount: &txnbuild.SimpleAccount{AccountID: offer.Seller}, + } +} diff --git a/plugins/volumeFilter.go b/plugins/volumeFilter.go new file mode 100644 index 000000000..d93c1e608 --- /dev/null +++ b/plugins/volumeFilter.go @@ -0,0 +1,233 @@ +package plugins + +import ( + "database/sql" + "fmt" + "log" + "strconv" + "strings" + "time" + + hProtocol "github.com/stellar/go/protocols/horizon" + "github.com/stellar/go/txnbuild" + "github.com/stellar/kelp/database" + "github.com/stellar/kelp/model" + "github.com/stellar/kelp/support/postgresdb" + "github.com/stellar/kelp/support/utils" +) + +// 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 + // buyBaseAssetCapInBaseUnits *float64 + // buyBaseAssetCapInQuoteUnits *float64 +} + +type volumeFilter struct { + name string + baseAsset hProtocol.Asset + quoteAsset hProtocol.Asset + marketID string + config *VolumeFilterConfig + db *sql.DB +} + +// makeFilterVolume makes a submit filter that limits orders placed based on the daily volume traded +func makeFilterVolume( + exchangeName string, + tradingPair *model.TradingPair, + assetDisplayFn model.AssetDisplayFn, + baseAsset hProtocol.Asset, + quoteAsset hProtocol.Asset, + db *sql.DB, + config *VolumeFilterConfig, +) (SubmitFilter, error) { + if db == nil { + return nil, fmt.Errorf("the provided db should be non-nil") + } + + // use assetDisplayFn to make baseAssetString and quoteAssetString because it is issuer independent for non-sdex exchanges keeping a consistent marketID + baseAssetString, e := assetDisplayFn(tradingPair.Base) + if e != nil { + return nil, fmt.Errorf("could not convert base asset (%s) from trading pair via the passed in assetDisplayFn: %s", string(tradingPair.Base), e) + } + quoteAssetString, e := assetDisplayFn(tradingPair.Quote) + if e != nil { + return nil, fmt.Errorf("could not convert quote asset (%s) from trading pair via the passed in assetDisplayFn: %s", string(tradingPair.Quote), e) + } + marketID := makeMarketID(exchangeName, baseAssetString, quoteAssetString) + + return &volumeFilter{ + name: "volumeFilter", + baseAsset: baseAsset, + quoteAsset: quoteAsset, + marketID: marketID, + config: config, + db: db, + }, nil +} + +var _ SubmitFilter = &volumeFilter{} + +// Validate ensures validity +func (c *VolumeFilterConfig) Validate() error { + if c.isEmpty() { + return fmt.Errorf("the volumeFilterConfig was empty\n") + } + return nil +} + +// String is the stringer method +func (c *VolumeFilterConfig) String() string { + return fmt.Sprintf("VolumeFilterConfig[SellBaseAssetCapInBaseUnits=%s, SellBaseAssetCapInQuoteUnits=%s]", + utils.CheckedFloatPtr(c.SellBaseAssetCapInBaseUnits), utils.CheckedFloatPtr(c.SellBaseAssetCapInQuoteUnits)) +} + +func (f *volumeFilter) Apply(ops []txnbuild.Operation, sellingOffers []hProtocol.Offer, buyingOffers []hProtocol.Offer) ([]txnbuild.Operation, error) { + dateString := time.Now().UTC().Format(postgresdb.DateFormatString) + // TODO do for buying base and also for a flipped marketID + dailyValuesBaseSold, e := f.dailyValuesByDate(f.marketID, dateString, "sell") + if e != nil { + return nil, fmt.Errorf("could not load dailyValuesByDate for today (%s): %s", dateString, e) + } + + log.Printf("dailyValuesByDate for today (%s): baseSoldUnits = %.8f %s, quoteCostUnits = %.8f %s (%s)\n", + dateString, dailyValuesBaseSold.baseVol, utils.Asset2String(f.baseAsset), dailyValuesBaseSold.quoteVol, utils.Asset2String(f.quoteAsset), f.config) + + // daily on-the-books + dailyOTB := &VolumeFilterConfig{ + SellBaseAssetCapInBaseUnits: &dailyValuesBaseSold.baseVol, + SellBaseAssetCapInQuoteUnits: &dailyValuesBaseSold.quoteVol, + } + // daily to-be-booked starts out as empty and accumulates the values of the operations + dailyTbbSellBase := 0.0 + dailyTbbSellQuote := 0.0 + dailyTBB := &VolumeFilterConfig{ + SellBaseAssetCapInBaseUnits: &dailyTbbSellBase, + SellBaseAssetCapInQuoteUnits: &dailyTbbSellQuote, + } + + innerFn := func(op *txnbuild.ManageSellOffer) (*txnbuild.ManageSellOffer, bool, error) { + return f.volumeFilterFn(dailyOTB, dailyTBB, op) + } + ops, e = filterOps(f.name, f.baseAsset, f.quoteAsset, sellingOffers, buyingOffers, ops, innerFn) + if e != nil { + return nil, fmt.Errorf("could not apply filter: %s", e) + } + return ops, nil +} + +func (f *volumeFilter) volumeFilterFn(dailyOTB *VolumeFilterConfig, dailyTBB *VolumeFilterConfig, op *txnbuild.ManageSellOffer) (*txnbuild.ManageSellOffer, bool, error) { + isSell, e := utils.IsSelling(f.baseAsset, f.quoteAsset, op.Selling, op.Buying) + if e != nil { + return nil, false, fmt.Errorf("error when running the isSelling check: %s", e) + } + + sellPrice, e := strconv.ParseFloat(op.Price, 64) + if e != nil { + return nil, false, fmt.Errorf("could not convert price (%s) to float: %s", op.Price, e) + } + + amountValueUnitsBeingSold, e := strconv.ParseFloat(op.Amount, 64) + if e != nil { + return nil, false, fmt.Errorf("could not convert amount (%s) to float: %s", op.Amount, e) + } + amountValueUnitsBeingBought := amountValueUnitsBeingSold * sellPrice + + var keep bool + if isSell { + var keepSellingBase bool + var keepSellingQuote bool + if f.config.SellBaseAssetCapInBaseUnits != nil { + projectedSoldInBaseUnits := *dailyOTB.SellBaseAssetCapInBaseUnits + *dailyTBB.SellBaseAssetCapInBaseUnits + amountValueUnitsBeingSold + keepSellingBase = projectedSoldInBaseUnits <= *f.config.SellBaseAssetCapInBaseUnits + log.Printf("volumeFilter: selling (base units), price=%.8f amount=%.8f, keep = (projectedSoldInBaseUnits) %.7f <= %.7f (config.SellBaseAssetCapInBaseUnits): keepSellingBase = %v", sellPrice, amountValueUnitsBeingSold, projectedSoldInBaseUnits, *f.config.SellBaseAssetCapInBaseUnits, keepSellingBase) + } else { + keepSellingBase = true + } + + if f.config.SellBaseAssetCapInQuoteUnits != nil { + projectedSoldInQuoteUnits := *dailyOTB.SellBaseAssetCapInQuoteUnits + *dailyTBB.SellBaseAssetCapInQuoteUnits + amountValueUnitsBeingBought + keepSellingQuote = projectedSoldInQuoteUnits <= *f.config.SellBaseAssetCapInQuoteUnits + log.Printf("volumeFilter: selling (quote units), price=%.8f amount=%.8f, keep = (projectedSoldInQuoteUnits) %.7f <= %.7f (config.SellBaseAssetCapInQuoteUnits): keepSellingQuote = %v", sellPrice, amountValueUnitsBeingSold, projectedSoldInQuoteUnits, *f.config.SellBaseAssetCapInQuoteUnits, keepSellingQuote) + } else { + keepSellingQuote = true + } + + keep = keepSellingBase && keepSellingQuote + } else { + // TODO buying side + } + + if keep { + // update the dailyTBB to include the additional amounts so they can be used in the calculation of the next operation + *dailyTBB.SellBaseAssetCapInBaseUnits += amountValueUnitsBeingSold + *dailyTBB.SellBaseAssetCapInQuoteUnits += amountValueUnitsBeingBought + return op, true, nil + } + + // TODO - reduce amount in offer so we can just meet the capacity limit, instead of dropping + // convert the offer to a dropped state + if op.OfferID == 0 { + // new offers can be dropped + return nil, false, nil + } else if op.Amount != "0" { + // modify offers should be converted to delete offers + opCopy := *op + opCopy.Amount = "0" + return &opCopy, false, nil + } + return nil, keep, fmt.Errorf("unable to transform manageOffer operation: offerID=%d, amount=%s, price=%.7f", op.OfferID, op.Amount, sellPrice) +} + +func (c *VolumeFilterConfig) isEmpty() bool { + if c.SellBaseAssetCapInBaseUnits != nil { + return false + } + if c.SellBaseAssetCapInQuoteUnits != nil { + return false + } + // if buyBaseAssetCapInBaseUnits != nil { + // return false + // } + // if buyBaseAssetCapInQuoteUnits != nil { + // return false + // } + return true +} + +// dailyValues represents any volume value which can be either bought or sold depending on the query +type dailyValues struct { + baseVol float64 + quoteVol float64 +} + +func (f *volumeFilter) dailyValuesByDate(marketID string, dateUTC string, action string) (*dailyValues, error) { + row := f.db.QueryRow(database.SqlQueryDailyValues, marketID, dateUTC, action) + + var baseVol sql.NullFloat64 + var quoteVol sql.NullFloat64 + e := row.Scan(&baseVol, "eVol) + if e != nil { + if strings.Contains(e.Error(), "no rows in result set") { + return &dailyValues{ + baseVol: 0, + quoteVol: 0, + }, nil + } + return nil, fmt.Errorf("could not read data from SqlQueryDailyValues query: %s", e) + } + + if !baseVol.Valid { + return nil, fmt.Errorf("baseVol was invalid") + } + if !quoteVol.Valid { + return nil, fmt.Errorf("quoteVol was invalid") + } + + return &dailyValues{ + baseVol: baseVol.Float64, + quoteVol: quoteVol.Float64, + }, nil +} diff --git a/support/postgresdb/functions.go b/support/postgresdb/functions.go index 4fce2e5ea..47ba57ec9 100644 --- a/support/postgresdb/functions.go +++ b/support/postgresdb/functions.go @@ -6,8 +6,11 @@ import ( "strings" ) -// DateFormatString is the format to be used when inserting dates in the database -const DateFormatString = "2006/01/02 15:04:05 MST" +// TimestampFormatString is the format to be used when inserting timestamps in the database +const TimestampFormatString = "2006/01/02 15:04:05 MST" + +// DateFormatString is the format to be used when converting a timestamp to a date +const DateFormatString = "2006/01/02" // CreateDatabaseIfNotExists returns whether the db was created and an error if creation failed func CreateDatabaseIfNotExists(postgresDbConfig *Config) (bool, error) { diff --git a/support/utils/configs.go b/support/utils/configs.go index 667ca305a..5ec084b54 100644 --- a/support/utils/configs.go +++ b/support/utils/configs.go @@ -25,8 +25,9 @@ func LogConfig(cfg fmt.Stringer) { } } -// StructString is a helper method that -func StructString(s interface{}, transforms map[string]func(interface{}) interface{}) string { +// StructString is a helper method that serizlies configs; the transform keys are always flattened, +// i.e specify the key meant to be on an inner object at a top level key on the transform map +func StructString(s interface{}, indentLevel uint8, transforms map[string]func(interface{}) interface{}) string { var buf bytes.Buffer numFields := reflect.TypeOf(s).NumField() for i := 0; i < numFields; i++ { @@ -46,13 +47,32 @@ func StructString(s interface{}, transforms map[string]func(interface{}) interfa if reflect.ValueOf(s).Field(i).CanInterface() { if !isDeprecated || !reflect.ValueOf(s).Field(i).IsNil() { - value := reflect.ValueOf(s).Field(i).Interface() - transformedValue := transformFn(value) deprecatedWarning := "" if isDeprecated { deprecatedWarning = " (deprecated)" } - buf.WriteString(fmt.Sprintf("%s: %+v%s\n", fieldDisplayName, transformedValue, deprecatedWarning)) + + currentField := reflect.ValueOf(s).Field(i) + value := currentField.Interface() + kind := currentField.Kind() + if kind == reflect.Ptr { + derefField := reflect.Indirect(currentField) + if !currentField.IsZero() { + value = derefField.Interface() + kind = derefField.Kind() + } + } + + for indentIdx := 0; indentIdx < int(indentLevel); indentIdx++ { + buf.WriteString(" ") + } + if kind == reflect.Struct { + subString := StructString(value, indentLevel+1, transforms) + buf.WriteString(fmt.Sprintf("%s: %s\n%s", fieldDisplayName, deprecatedWarning, subString)) + } else { + transformedValue := transformFn(value) + buf.WriteString(fmt.Sprintf("%s: %+v%s\n", fieldDisplayName, transformedValue, deprecatedWarning)) + } } } } @@ -86,21 +106,3 @@ func passthrough(i interface{}) interface{} { func Hide(i interface{}) interface{} { return "" } - -// UnwrapFloat64Pointer unwraps a float64 pointer -func UnwrapFloat64Pointer(i interface{}) interface{} { - p := i.(*float64) - if p == nil { - return "" - } - return *p -} - -// UnwrapInt8Pointer unwraps a int8 pointer -func UnwrapInt8Pointer(i interface{}) interface{} { - p := i.(*int8) - if p == nil { - return "" - } - return *p -} diff --git a/support/utils/functions.go b/support/utils/functions.go index 541f60ed1..84d7e9557 100644 --- a/support/utils/functions.go +++ b/support/utils/functions.go @@ -251,6 +251,14 @@ func CheckedString(v interface{}) string { return fmt.Sprintf("%v", v) } +// CheckedFloatPtr returns "" if the object is nil, otherwise calls the String() function on the object +func CheckedFloatPtr(v *float64) string { + if v == nil { + return "" + } + return fmt.Sprintf("%.10f", *v) +} + // ParseAsset returns a horizon asset a string func ParseAsset(code string, issuer string) (*hProtocol.Asset, error) { if code != "XLM" && issuer == "" { @@ -381,3 +389,16 @@ func PrintErrorHintf(message string, args ...interface{}) { log.Printf("*************************************** /HINT ****************************************\n") log.Printf("\n") } + +// ParseMaybeFloat parses an optional string value as a float pointer +func ParseMaybeFloat(valueString string) (*float64, error) { + if valueString == "" { + return nil, nil + } + + valueFloat, e := strconv.ParseFloat(valueString, 64) + if e != nil { + return nil, fmt.Errorf("unable to parse value '%s' as float: %s", valueString, e) + } + return &valueFloat, nil +} diff --git a/terminator/config.go b/terminator/config.go index 054e6b403..f43c5852d 100644 --- a/terminator/config.go +++ b/terminator/config.go @@ -20,7 +20,7 @@ type Config struct { // String impl. func (c Config) String() string { - return utils.StructString(c, map[string]func(interface{}) interface{}{ + return utils.StructString(c, 0, map[string]func(interface{}) interface{}{ "SOURCE_SECRET_SEED": utils.SecretKey2PublicKey, "TRADING_SECRET_SEED": utils.SecretKey2PublicKey, }) diff --git a/trader/config.go b/trader/config.go index dfeb432be..505f6f453 100644 --- a/trader/config.go +++ b/trader/config.go @@ -42,7 +42,8 @@ type BotConfig struct { MinCentralizedBaseVolumeDeprecated *float64 `valid:"-" toml:"MIN_CENTRALIZED_BASE_VOLUME" deprecated:"true" json:"min_centralized_base_volume"` CentralizedMinBaseVolumeOverride *float64 `valid:"-" toml:"CENTRALIZED_MIN_BASE_VOLUME_OVERRIDE" json:"centralized_min_base_volume_override"` CentralizedMinQuoteVolumeOverride *float64 `valid:"-" toml:"CENTRALIZED_MIN_QUOTE_VOLUME_OVERRIDE" json:"centralized_min_quote_volume_override"` - PostgresDbConfig *postgresdb.Config `valid:"-" toml:"POSTGRES_DB"` + PostgresDbConfig *postgresdb.Config `valid:"-" toml:"POSTGRES_DB" json:"postgres_db"` + Filters []string `valid:"-" toml:"FILTERS" json:"filters"` AlertType string `valid:"-" toml:"ALERT_TYPE" json:"alert_type"` AlertAPIKey string `valid:"-" toml:"ALERT_API_KEY" json:"alert_api_key"` MonitoringPort uint16 `valid:"-" toml:"MONITORING_PORT" json:"monitoring_port"` @@ -111,21 +112,16 @@ func MakeBotConfig( // String impl. func (b BotConfig) String() string { - return utils.StructString(b, map[string]func(interface{}) interface{}{ - "EXCHANGE_API_KEYS": utils.Hide, - "EXCHANGE_PARAMS": utils.Hide, - "EXCHANGE_HEADERS": utils.Hide, - "SOURCE_SECRET_SEED": utils.SecretKey2PublicKey, - "TRADING_SECRET_SEED": utils.SecretKey2PublicKey, - "ALERT_API_KEY": utils.Hide, - "GOOGLE_CLIENT_ID": utils.Hide, - "GOOGLE_CLIENT_SECRET": utils.Hide, - "ACCEPTABLE_GOOGLE_EMAILS": utils.Hide, - "CENTRALIZED_PRICE_PRECISION_OVERRIDE": utils.UnwrapInt8Pointer, - "CENTRALIZED_VOLUME_PRECISION_OVERRIDE": utils.UnwrapInt8Pointer, - "MIN_CENTRALIZED_BASE_VOLUME": utils.UnwrapFloat64Pointer, - "CENTRALIZED_MIN_BASE_VOLUME_OVERRIDE": utils.UnwrapFloat64Pointer, - "CENTRALIZED_MIN_QUOTE_VOLUME_OVERRIDE": utils.UnwrapFloat64Pointer, + return utils.StructString(b, 0, map[string]func(interface{}) interface{}{ + "EXCHANGE_API_KEYS": utils.Hide, + "EXCHANGE_PARAMS": utils.Hide, + "EXCHANGE_HEADERS": utils.Hide, + "SOURCE_SECRET_SEED": utils.SecretKey2PublicKey, + "TRADING_SECRET_SEED": utils.SecretKey2PublicKey, + "ALERT_API_KEY": utils.Hide, + "GOOGLE_CLIENT_ID": utils.Hide, + "GOOGLE_CLIENT_SECRET": utils.Hide, + "ACCEPTABLE_GOOGLE_EMAILS": utils.Hide, }) } diff --git a/trader/trader.go b/trader/trader.go index b177c1048..1459f13d3 100644 --- a/trader/trader.go +++ b/trader/trader.go @@ -50,33 +50,24 @@ type Trader struct { sellingAOffers []hProtocol.Offer // quoted B/A } -// MakeBot is the factory method for the Trader struct -func MakeBot( +// MakeTrader is the factory method for the Trader struct +func MakeTrader( api *horizonclient.Client, ieif *plugins.IEIF, assetBase hProtocol.Asset, assetQuote hProtocol.Asset, - tradingPair *model.TradingPair, tradingAccount string, sdex *plugins.SDEX, exchangeShim api.ExchangeShim, strategy api.Strategy, timeController api.TimeController, deleteCyclesThreshold int64, - submitMode api.SubmitMode, + submitFilters []plugins.SubmitFilter, threadTracker *multithreading.ThreadTracker, fixedIterations *uint64, dataKey *model.BotKey, alert api.Alert, ) *Trader { - submitFilters := []plugins.SubmitFilter{ - plugins.MakeFilterOrderConstraints(exchangeShim.GetOrderConstraints(tradingPair), assetBase, assetQuote), - } - sdexSubmitFilter := plugins.MakeFilterMakerMode(submitMode, exchangeShim, sdex, tradingPair) - if sdexSubmitFilter != nil { - submitFilters = append(submitFilters, sdexSubmitFilter) - } - return &Trader{ api: api, ieif: ieif,