From 505162a86777f99fba26bc953b3125aba90e2f7e Mon Sep 17 00:00:00 2001 From: Nikhil Saraf <1028334+nikhilsaraf@users.noreply.github.com> Date: Tue, 12 Mar 2019 12:16:30 -0700 Subject: [PATCH] Enable centralized trading2 (#118), closes #102 * 1 - GetAccountBalances API and impls. * 2 - new interface types in exchange, submittable exchang and Balance * 3 - typo in ccxtExchange.go * 4 - merge in changes first attempt * 5 - remove redundant check of fixedIterations * 6 - add check for valid submitMode for non-sdex exchange * 7 - use balance and offers hack methods in trader/trader.go * 8 - incorporate changes into trade.go and trader.go * 9 - explicilty disable fill tracking for non-sdex exchanges * 10 - cursor support to ccxt.go#FetchMyTrades * 11 - log file name, don't log native by default * 12 - errorSuffix in batchedExchange log line on submit result * 13 !! - fix krakenExchange tradeHistory cursor logic * 14 - sdex tradingOnSdex * 15 - more %.7f -> %.8f changes * 16 - wrap error when cannot load open orders for kraken * 17 - API fixes for MakeIEIF * 18 - correct name of /health endpoint * 19 - use 7 for SdexPrecision * 20 - remove redundant log lines in factory.go * 21 - add check for ccxt trading needing exactly 1 API key * 22 - update sample_trader.cfg * 23 - rename SubmittableExchange to ExchangeShim * 24 - make FEE config field optional when not trading on SDEX * 25 - use exchange precision when converting from manageOffer2Order * 26 - use large precision for intermediate conversion in manageOffer2Order; log orderConstraints * 27 - include minQuote volume in cost, displayed when logging orderConstraints --- api/exchange.go | 18 +- cmd/terminate.go | 2 + cmd/trade.go | 108 +++-- examples/configs/trader/sample_trader.cfg | 13 + model/assets.go | 11 + model/orderbook.go | 31 +- plugins/balancedStrategy.go | 3 + plugins/batchedExchange.go | 494 ++++++++++++++++++++++ plugins/buysellStrategy.go | 3 + plugins/ccxtExchange.go | 26 +- plugins/ccxtExchange_test.go | 2 +- plugins/factory.go | 12 +- plugins/ieif.go | 304 +++++++++++++ plugins/krakenExchange.go | 40 +- plugins/krakenExchange_test.go | 2 +- plugins/mirrorStrategy.go | 18 +- plugins/priceFeed.go | 4 +- plugins/sdex.go | 384 ++++------------- plugins/sdexFeed.go | 2 + plugins/sellSideStrategy.go | 32 +- plugins/sellStrategy.go | 2 + support/sdk/ccxt.go | 22 +- support/sdk/ccxt_test.go | 38 +- support/utils/functions.go | 10 + trader/config.go | 15 +- trader/trader.go | 78 ++-- 26 files changed, 1237 insertions(+), 437 deletions(-) create mode 100644 plugins/batchedExchange.go create mode 100644 plugins/ieif.go diff --git a/api/exchange.go b/api/exchange.go index 784c86489..8cb53d157 100644 --- a/api/exchange.go +++ b/api/exchange.go @@ -3,6 +3,8 @@ package api import ( "fmt" + "github.com/stellar/go/build" + "github.com/stellar/go/clients/horizon" "github.com/stellar/kelp/model" ) @@ -14,7 +16,7 @@ type ExchangeAPIKey struct { // Account allows you to access key account functions type Account interface { - GetAccountBalances(assetList []model.Asset) (map[model.Asset]model.Number, error) + GetAccountBalances(assetList []interface{}) (map[interface{}]model.Number, error) } // Ticker encapsulates all the data for a given Trading Pair @@ -191,3 +193,17 @@ type Exchange interface { DepositAPI WithdrawAPI } + +// Balance repesents various aspects of an asset's balance +type Balance struct { + Balance float64 + Trust float64 + Reserve float64 +} + +// ExchangeShim is the interface we use as a generic API for all crypto exchanges +type ExchangeShim interface { + SubmitOps(ops []build.TransactionMutator, asyncCallback func(hash string, e error)) error + GetBalanceHack(asset horizon.Asset) (*Balance, error) + LoadOffersHack() ([]horizon.Offer, error) +} diff --git a/cmd/terminate.go b/cmd/terminate.go index 7b1af0c3d..a775ee9c4 100644 --- a/cmd/terminate.go +++ b/cmd/terminate.go @@ -43,6 +43,8 @@ func init() { } sdex := plugins.MakeSDEX( client, + plugins.MakeIEIF(true), // used true for now since it's only ever been tested on SDEX and uses SDEX's data for now + nil, configFile.SourceSecretSeed, configFile.TradingSecretSeed, *configFile.SourceAccount, diff --git a/cmd/trade.go b/cmd/trade.go index ff2021e7b..e7041b563 100644 --- a/cmd/trade.go +++ b/cmd/trade.go @@ -89,9 +89,9 @@ func init() { } } - validateBotConfig := func(l logger.Logger, botConfig trader.BotConfig) { - if botConfig.Fee == nil { - logger.Fatal(l, fmt.Errorf("The `FEE` object needs to exist in the trader config file")) + validateBotConfig := func(l logger.Logger, botConfig trader.BotConfig, isTradingSdex bool) { + if isTradingSdex && botConfig.Fee == nil { + logger.Fatal(l, fmt.Errorf("The `FEE` object needs to exist in the trader config file when trading on SDEX")) } } @@ -105,9 +105,14 @@ func init() { logger.Fatal(l, e) } + isTradingSdex := botConfig.TradingExchange == "" || botConfig.TradingExchange == "sdex" + if *logPrefix != "" { t := time.Now().Format("20060102T150405MST") fileName := fmt.Sprintf("%s_%s_%s_%s_%s_%s.log", *logPrefix, botConfig.AssetCodeA, botConfig.IssuerA, botConfig.AssetCodeB, botConfig.IssuerB, t) + if !isTradingSdex { + fileName = fmt.Sprintf("%s_%s_%s_%s.log", *logPrefix, botConfig.AssetCodeA, botConfig.AssetCodeB, t) + } e = setLogFile(fileName) if e != nil { logger.Fatal(l, e) @@ -130,7 +135,7 @@ func init() { // only log botConfig file here so it can be included in the log file utils.LogConfig(botConfig) - validateBotConfig(l, botConfig) + validateBotConfig(l, botConfig, isTradingSdex) l.Infof("Trading %s:%s for %s:%s\n", botConfig.AssetCodeA, botConfig.IssuerA, botConfig.AssetCodeB, botConfig.IssuerB) client := &horizon.Client{ @@ -144,6 +149,7 @@ func init() { } // --- start initialization of objects ---- threadTracker := multithreading.MakeThreadTracker() + ieif := plugins.MakeIEIF(isTradingSdex) assetBase := botConfig.AssetBase() assetQuote := botConfig.AssetQuote() @@ -151,22 +157,49 @@ func init() { Base: model.Asset(utils.Asset2CodeString(assetBase)), Quote: model.Asset(utils.Asset2CodeString(assetQuote)), } - sdexAssetMap := map[model.Asset]horizon.Asset{ tradingPair.Base: assetBase, tradingPair.Quote: assetQuote, } - feeFn, e := plugins.SdexFeeFnFromStats( - botConfig.HorizonURL, - botConfig.Fee.CapacityTrigger, - botConfig.Fee.Percentile, - botConfig.Fee.MaxOpFeeStroops, - ) - if e != nil { - logger.Fatal(l, fmt.Errorf("could not set up feeFn correctly: %s", e)) + var feeFn plugins.OpFeeStroops + if isTradingSdex { + feeFn, e = plugins.SdexFeeFnFromStats( + botConfig.HorizonURL, + botConfig.Fee.CapacityTrigger, + botConfig.Fee.Percentile, + botConfig.Fee.MaxOpFeeStroops, + ) + if e != nil { + logger.Fatal(l, fmt.Errorf("could not set up feeFn correctly: %s", e)) + } + } else { + feeFn = plugins.SdexFixedFeeFn(0) + } + + var exchangeShim api.ExchangeShim + if !isTradingSdex { + exchangeAPIKeys := []api.ExchangeAPIKey{} + for _, apiKey := range botConfig.ExchangeAPIKeys { + exchangeAPIKeys = append(exchangeAPIKeys, api.ExchangeAPIKey{ + Key: apiKey.Key, + Secret: apiKey.Secret, + }) + } + + var exchangeAPI api.Exchange + exchangeAPI, e = plugins.MakeTradingExchange(botConfig.TradingExchange, exchangeAPIKeys, *simMode) + if e != nil { + logger.Fatal(l, fmt.Errorf("unable to make trading exchange: %s", e)) + return + } + + exchangeShim = plugins.MakeBatchedExchange(exchangeAPI, *simMode, botConfig.AssetBase(), botConfig.AssetQuote(), botConfig.TradingAccount()) } + sdex := plugins.MakeSDEX( client, + ieif, + exchangeShim, botConfig.SourceSecretSeed, botConfig.TradingSecretSeed, botConfig.SourceAccount(), @@ -180,23 +213,26 @@ func init() { sdexAssetMap, feeFn, ) + if isTradingSdex { + exchangeShim = sdex + } // setting the temp hack variables for the sdex price feeds - e = plugins.SetPrivateSdexHack(client, utils.ParseNetwork(botConfig.HorizonURL)) + e = plugins.SetPrivateSdexHack(client, plugins.MakeIEIF(true), utils.ParseNetwork(botConfig.HorizonURL)) if e != nil { l.Info("") l.Errorf("%s", e) // we want to delete all the offers and exit here since there is something wrong with our setup - deleteAllOffersAndExit(l, botConfig, client, sdex) + deleteAllOffersAndExit(l, botConfig, client, sdex, exchangeShim) } dataKey := model.MakeSortedBotKey(assetBase, assetQuote) - strat, e := plugins.MakeStrategy(sdex, tradingPair, &assetBase, &assetQuote, *strategy, *stratConfigPath, *simMode) + strat, e := plugins.MakeStrategy(sdex, ieif, tradingPair, &assetBase, &assetQuote, *strategy, *stratConfigPath, *simMode) if e != nil { l.Info("") l.Errorf("%s", e) // we want to delete all the offers and exit here since there is something wrong with our setup - deleteAllOffersAndExit(l, botConfig, client, sdex) + deleteAllOffersAndExit(l, botConfig, client, sdex, exchangeShim) } submitMode, e := api.ParseSubmitMode(botConfig.SubmitMode) @@ -204,7 +240,19 @@ func init() { 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) + deleteAllOffersAndExit(l, botConfig, client, sdex, exchangeShim) + } + if !isTradingSdex && submitMode != api.SubmitModeBoth { + log.Println() + log.Println("cannot run on a non-SDEX exchange with SUBMIT_MODE set to something other than \"both\"") + // we want to delete all the offers and exit here since there is something wrong with our setup + deleteAllOffersAndExit(l, botConfig, client, sdex, exchangeShim) + } + if !isTradingSdex && botConfig.FillTrackerSleepMillis != 0 { + log.Println() + log.Println("cannot run on a non-SDEX exchange with FILL_TRACKER_SLEEP_MILLIS set to something other than 0") + // we want to delete all the offers and exit here since there is something wrong with our setup + deleteAllOffersAndExit(l, botConfig, client, sdex, exchangeShim) } timeController := plugins.MakeIntervalTimeController( time.Duration(botConfig.TickIntervalSeconds)*time.Second, @@ -212,11 +260,13 @@ func init() { ) bot := trader.MakeBot( client, + ieif, botConfig.AssetBase(), botConfig.AssetQuote(), tradingPair, botConfig.TradingAccount(), sdex, + exchangeShim, strat, timeController, botConfig.DeleteCyclesThreshold, @@ -228,9 +278,13 @@ func init() { ) // --- end initialization of objects --- - l.Info("validating trustlines...") - validateTrustlines(l, client, &botConfig) - l.Info("trustlines valid") + if isTradingSdex { + log.Printf("validating trustlines...\n") + validateTrustlines(l, client, &botConfig) + l.Info("trustlines valid") + } else { + l.Info("no need to validate trustlines because we're not using SDEX as the trading exchange") + } // --- start initialization of services --- if botConfig.MonitoringPort != 0 { @@ -243,7 +297,7 @@ func init() { // we want to delete all the offers and exit here because we don't want the bot to run if monitoring isn't working // if monitoring is desired but not working properly, we want the bot to be shut down and guarantee that there // aren't outstanding offers. - deleteAllOffersAndExit(l, botConfig, client, sdex) + deleteAllOffersAndExit(l, botConfig, client, sdex, exchangeShim) } }() } @@ -253,7 +307,7 @@ func init() { l.Info("") l.Info("problem encountered while instantiating the fill tracker:") l.Errorf("%s", e) - deleteAllOffersAndExit(l, botConfig, client, sdex) + deleteAllOffersAndExit(l, botConfig, client, sdex, exchangeShim) } if botConfig.FillTrackerSleepMillis != 0 { fillTracker := plugins.MakeFillTracker(tradingPair, threadTracker, sdex, botConfig.FillTrackerSleepMillis) @@ -273,14 +327,14 @@ func init() { l.Info("problem encountered while running the fill tracker:") l.Errorf("%s", e) // we want to delete all the offers and exit here because we don't want the bot to run if fill tracking isn't working - deleteAllOffersAndExit(l, botConfig, client, sdex) + deleteAllOffersAndExit(l, botConfig, client, sdex, exchangeShim) } }() } else if strategyFillHandlers != nil && len(strategyFillHandlers) > 0 { l.Info("") l.Error("error: strategy has FillHandlers but fill tracking was disabled (set FILL_TRACKER_SLEEP_MILLIS to a non-zero value)") // we want to delete all the offers and exit here because we don't want the bot to run if fill tracking isn't working - deleteAllOffersAndExit(l, botConfig, client, sdex) + deleteAllOffersAndExit(l, botConfig, client, sdex, exchangeShim) } // --- end initialization of services --- @@ -353,7 +407,7 @@ func validateTrustlines(l logger.Logger, client *horizon.Client, botConfig *trad } } -func deleteAllOffersAndExit(l logger.Logger, botConfig trader.BotConfig, client *horizon.Client, sdex *plugins.SDEX) { +func deleteAllOffersAndExit(l logger.Logger, botConfig trader.BotConfig, client *horizon.Client, sdex *plugins.SDEX, exchangeShim api.ExchangeShim) { l.Info("") l.Info("deleting all offers and then exiting...") @@ -369,7 +423,7 @@ func deleteAllOffersAndExit(l logger.Logger, botConfig trader.BotConfig, client l.Infof("created %d operations to delete offers\n", len(dOps)) if len(dOps) > 0 { - e := sdex.SubmitOps(dOps, func(hash string, e error) { + e := exchangeShim.SubmitOps(dOps, func(hash string, e error) { if e != nil { logger.Fatal(l, e) return diff --git a/examples/configs/trader/sample_trader.cfg b/examples/configs/trader/sample_trader.cfg index bc96ab010..2ecdd0955 100644 --- a/examples/configs/trader/sample_trader.cfg +++ b/examples/configs/trader/sample_trader.cfg @@ -21,6 +21,7 @@ TICK_INTERVAL_SECONDS=300 MAX_TICK_DELAY_MILLIS=0 # the mode to use when submitting - maker_only, both (default) +# when trading on a non-SDEX exchange the only supported mode is "both" SUBMIT_MODE="both" # how many continuous errors in each update cycle can the bot accept before it will delete all offers to protect its exposure. @@ -33,6 +34,7 @@ SUBMIT_MODE="both" # example: use 2 if you want to tolerate 2 continuous update cycle with errors, i.e. three continuous update cycles with errors will delete all offers. DELETE_CYCLES_THRESHOLD=0 # how many milliseconds to sleep before checking for fills again, a value of 0 disables fill tracking +# fill tracking is not supported when trading on a non-SDEX exchange (i.e. set it to 0) FILL_TRACKER_SLEEP_MILLIS=0 # the url for your horizon instance. If this url contains the string "test" then the bot assumes it is using the test network. HORIZON_URL="https://horizon-testnet.stellar.org" @@ -73,3 +75,14 @@ MAX_OP_FEE_STROOPS=5000 # a comma-separated list of emails (Google accounts) that are allowed access to monitoring endpoints that require # Google authentication. #ACCEPTABLE_GOOGLE_EMAILS="" + +# uncomment lines below to use kraken. Can use "sdex" or leave out to trade on the Stellar Decentralized Exchange. +# can alternatively use any of the ccxt-exchanges marked as "Supports Trading" (run `kelp exchanges` for full list) +#TRADING_EXCHANGE="kraken" +# you can use multiple API keys to overcome rate limit concerns for kraken +#[[EXCHANGE_API_KEYS]] +#KEY="" +#SECRET="" +#[[EXCHANGE_API_KEYS]] +#KEY="" +#SECRET="" diff --git a/model/assets.go b/model/assets.go index b2a373d43..89383d6ad 100644 --- a/model/assets.go +++ b/model/assets.go @@ -4,6 +4,9 @@ import ( "errors" "fmt" "log" + + "github.com/stellar/go/clients/horizon" + "github.com/stellar/kelp/support/utils" ) // Asset is typed and enlists the allowed assets that are understood by the bot @@ -168,3 +171,11 @@ var KrakenAssetConverterOpenOrders = makeAssetConverter(map[Asset]string{ BTC: "XBT", USD: "USD", }) + +// FromHorizonAsset is a factory method +func FromHorizonAsset(hAsset horizon.Asset) Asset { + if hAsset.Type == utils.Native { + return XLM + } + return Asset(hAsset.Code) +} diff --git a/model/orderbook.go b/model/orderbook.go index bb22d3501..fac3fef9f 100644 --- a/model/orderbook.go +++ b/model/orderbook.go @@ -15,6 +15,10 @@ const ( OrderActionSell OrderAction = true ) +// minQuoteVolumePrecision allows for having precise enough minQuoteVolume values +const minQuoteVolumePrecision = 10 +const nilString = "" + // IsBuy returns true for buy actions func (a OrderAction) IsBuy() bool { return a == OrderActionBuy @@ -104,7 +108,7 @@ type Order struct { // String is the stringer function func (o Order) String() string { - tsString := "" + tsString := nilString if o.Timestamp != nil { tsString = fmt.Sprintf("%d", o.Timestamp.AsInt64()) } @@ -191,7 +195,7 @@ type OpenOrder struct { // String is the stringer function func (o OpenOrder) String() string { - expireTimeString := "" + expireTimeString := nilString if o.ExpireTime != nil { expireTimeString = fmt.Sprintf("%d", o.ExpireTime.AsInt64()) } @@ -253,6 +257,7 @@ type OrderConstraints struct { PricePrecision int8 VolumePrecision int8 MinBaseVolume Number + MinQuoteVolume *Number } // MakeOrderConstraints is a factory method for OrderConstraints @@ -261,5 +266,27 @@ func MakeOrderConstraints(pricePrecision int8, volumePrecision int8, minBaseVolu PricePrecision: pricePrecision, VolumePrecision: volumePrecision, MinBaseVolume: *NumberFromFloat(minBaseVolume, volumePrecision), + MinQuoteVolume: nil, } } + +// MakeOrderConstraintsWithCost is a factory method for OrderConstraints +func MakeOrderConstraintsWithCost(pricePrecision int8, volumePrecision int8, minBaseVolume float64, minQuoteVolume float64) *OrderConstraints { + return &OrderConstraints{ + PricePrecision: pricePrecision, + VolumePrecision: volumePrecision, + MinBaseVolume: *NumberFromFloat(minBaseVolume, volumePrecision), + MinQuoteVolume: NumberFromFloat(minQuoteVolume, minQuoteVolumePrecision), + } +} + +// OrderConstraints describes constraints when placing orders on an excahnge +func (o *OrderConstraints) String() string { + minQuoteVolumeStr := nilString + if o.MinQuoteVolume != nil { + minQuoteVolumeStr = o.MinQuoteVolume.AsString() + } + + return fmt.Sprintf("OrderConstraints[PricePrecision: %d, VolumePrecision: %d, MinBaseVolume: %s, MinQuoteVolume: %s]", + o.PricePrecision, o.VolumePrecision, o.MinBaseVolume.AsString(), minQuoteVolumeStr) +} diff --git a/plugins/balancedStrategy.go b/plugins/balancedStrategy.go index f56f905fc..9725327a3 100644 --- a/plugins/balancedStrategy.go +++ b/plugins/balancedStrategy.go @@ -33,6 +33,7 @@ func (c balancedConfig) String() string { func makeBalancedStrategy( sdex *SDEX, pair *model.TradingPair, + ieif *IEIF, assetBase *horizon.Asset, assetQuote *horizon.Asset, config *balancedConfig, @@ -41,6 +42,7 @@ func makeBalancedStrategy( sellSideStrategy := makeSellSideStrategy( sdex, orderConstraints, + ieif, assetBase, assetQuote, makeBalancedLevelProvider( @@ -65,6 +67,7 @@ func makeBalancedStrategy( buySideStrategy := makeSellSideStrategy( sdex, orderConstraints, + ieif, assetQuote, assetBase, makeBalancedLevelProvider( diff --git a/plugins/batchedExchange.go b/plugins/batchedExchange.go new file mode 100644 index 000000000..a77855b34 --- /dev/null +++ b/plugins/batchedExchange.go @@ -0,0 +1,494 @@ +package plugins + +import ( + "fmt" + "log" + "math" + "reflect" + "time" + + "math/rand" + + "github.com/stellar/go/build" + "github.com/stellar/go/clients/horizon" + "github.com/stellar/go/xdr" + "github.com/stellar/kelp/api" + "github.com/stellar/kelp/model" + "github.com/stellar/kelp/support/utils" +) + +// largePrecision is a large precision value for in-memory calculations +const largePrecision = 10 + +// BatchedExchange accumulates instructions that can be read out and processed in a batch-style later +type BatchedExchange struct { + commands []Command + inner api.Exchange + simMode bool + baseAsset horizon.Asset + quoteAsset horizon.Asset + tradingAccount string + orderID2OfferID map[string]int64 + offerID2OrderID map[int64]string +} + +var _ api.ExchangeShim = BatchedExchange{} + +// MakeBatchedExchange factory +func MakeBatchedExchange( + inner api.Exchange, + simMode bool, + baseAsset horizon.Asset, + quoteAsset horizon.Asset, + tradingAccount string, +) *BatchedExchange { + return &BatchedExchange{ + commands: []Command{}, + inner: inner, + simMode: simMode, + baseAsset: baseAsset, + quoteAsset: quoteAsset, + tradingAccount: tradingAccount, + orderID2OfferID: map[string]int64{}, + offerID2OrderID: map[int64]string{}, + } +} + +// Operation represents a type of operation +type Operation int8 + +// type of Operations +const ( + OpAdd Operation = iota + OpCancel +) + +// Command struct allows us to follow the Command pattern +type Command struct { + op Operation + add *model.Order + cancel *model.OpenOrder +} + +// GetOp returns the Operation +func (c *Command) GetOp() Operation { + return c.op +} + +// GetAdd returns the add op +func (c *Command) GetAdd() (*model.Order, error) { + if c.add == nil { + return nil, fmt.Errorf("add op does not exist") + } + return c.add, nil +} + +// GetCancel returns the cancel op +func (c *Command) GetCancel() (*model.OpenOrder, error) { + if c.cancel == nil { + return nil, fmt.Errorf("cancel op does not exist") + } + return c.cancel, nil +} + +// MakeCommandAdd impl +func MakeCommandAdd(order *model.Order) Command { + return Command{ + op: OpAdd, + add: order, + } +} + +// MakeCommandCancel impl +func MakeCommandCancel(openOrder *model.OpenOrder) Command { + return Command{ + op: OpCancel, + cancel: openOrder, + } +} + +// GetBalanceHack impl +func (b BatchedExchange) GetBalanceHack(asset horizon.Asset) (*api.Balance, error) { + modelAsset := model.FromHorizonAsset(asset) + balances, e := b.GetAccountBalances([]interface{}{modelAsset}) + if e != nil { + return nil, fmt.Errorf("error fetching balances in GetBalanceHack: %s", e) + } + + if v, ok := balances[modelAsset]; ok { + return &api.Balance{ + Balance: v.AsFloat(), + Trust: math.MaxFloat64, + Reserve: 0.0, + }, nil + } + return nil, fmt.Errorf("asset was missing in GetBalanceHack result: %s", utils.Asset2String(asset)) +} + +// LoadOffersHack impl +func (b BatchedExchange) LoadOffersHack() ([]horizon.Offer, error) { + pair := &model.TradingPair{ + Base: model.FromHorizonAsset(b.baseAsset), + Quote: model.FromHorizonAsset(b.quoteAsset), + } + openOrders, e := b.GetOpenOrders([]*model.TradingPair{pair}) + if e != nil { + return nil, fmt.Errorf("error fetching open orders in LoadOffersHack: %s", e) + } + + offers := []horizon.Offer{} + for i, v := range openOrders { + var offers1 []horizon.Offer + offers1, e = b.OpenOrders2Offers(v, b.baseAsset, b.quoteAsset, b.tradingAccount) + if e != nil { + return nil, fmt.Errorf("error converting open orders to offers in iteration %v in LoadOffersHack: %s", i, e) + } + offers = append(offers, offers1...) + } + return offers, nil +} + +// SubmitOps performs any finalization or submission step needed by the exchange +func (b BatchedExchange) SubmitOps(ops []build.TransactionMutator, asyncCallback func(hash string, e error)) error { + var e error + b.commands, e = b.Ops2Commands(ops, b.baseAsset, b.quoteAsset) + if e != nil { + if asyncCallback != nil { + go asyncCallback("", e) + } + return fmt.Errorf("could not convert ops2commands: %s | allOps = %v", e, ops) + } + + if b.simMode { + log.Printf("running in simulation mode so not submitting to the inner exchange\n") + if asyncCallback != nil { + go asyncCallback("", nil) + } + return nil + } + + pair := &model.TradingPair{ + Base: model.FromHorizonAsset(b.baseAsset), + Quote: model.FromHorizonAsset(b.quoteAsset), + } + log.Printf("order constraints for trading pair %s: %s", pair, b.inner.GetOrderConstraints(pair)) + + results := []submitResult{} + numProcessed := 0 + for _, c := range b.commands { + r := c.exec(b.inner) + if r == nil { + // remove all processed commands + // b.commands = b.commands[numProcessed:] + b.logResults(results) + e := fmt.Errorf("unrecognized operation '%v', stopped submitting", c.op) + if asyncCallback != nil { + go asyncCallback("", e) + } + return e + } + results = append(results, *r) + numProcessed++ + } + + // remove all processed commands + // b.commands = b.commands[numProcessed:] + + b.logResults(results) + if asyncCallback != nil { + go asyncCallback("", nil) + } + return nil +} + +func (b BatchedExchange) logResults(results []submitResult) { + log.Printf("Results from submitting:\n") + for _, r := range results { + opString := "add" + var v interface{} + v = r.add + if r.op == OpCancel { + opString = "cancel" + v = r.cancel + } + + errorSuffix := "" + if r.e != nil { + errorSuffix = fmt.Sprintf(", error=%s", r.e) + } + log.Printf(" submitResult[op=%s, value=%v%s]\n", opString, v, errorSuffix) + } +} + +func (c Command) exec(x api.Exchange) *submitResult { + switch c.op { + case OpAdd: + v, e := x.AddOrder(c.add) + return &submitResult{ + op: c.op, + e: e, + add: v, + } + case OpCancel: + v, e := x.CancelOrder(model.MakeTransactionID(c.cancel.ID), *c.cancel.Pair) + return &submitResult{ + op: c.op, + e: e, + cancel: &v, + } + default: + return nil + } +} + +// GetAccountBalances impl. +func (b BatchedExchange) GetAccountBalances(assetList []interface{}) (map[interface{}]model.Number, error) { + return b.inner.GetAccountBalances(assetList) +} + +// GetOpenOrders impl. +func (b BatchedExchange) GetOpenOrders(pairs []*model.TradingPair) (map[model.TradingPair][]model.OpenOrder, error) { + return b.inner.GetOpenOrders(pairs) +} + +type submitResult struct { + op Operation + e error + add *model.TransactionID + cancel *model.CancelOrderResult +} + +func (b BatchedExchange) genUniqueID() int64 { + var ID int64 + for { + ID = rand.Int63() + log.Printf("generated unique ID = %d\n", ID) + // should have generated a unique value + if _, ok := b.offerID2OrderID[ID]; !ok { + break + } + log.Printf("generated ID (%d) was not unique! retrying...\n", ID) + } + return ID +} + +// OpenOrders2Offers converts... +func (b BatchedExchange) OpenOrders2Offers(orders []model.OpenOrder, baseAsset horizon.Asset, quoteAsset horizon.Asset, tradingAccount string) ([]horizon.Offer, error) { + offers := []horizon.Offer{} + for _, order := range orders { + sellingAsset := baseAsset + buyingAsset := quoteAsset + amount := order.Volume.AsString() + price, e := convert2Price(order.Price) + if e != nil { + return nil, fmt.Errorf("unable to convert order price to a ratio: %s", e) + } + priceString := order.Price.AsString() + if order.OrderAction == model.OrderActionBuy { + sellingAsset = quoteAsset + buyingAsset = baseAsset + // TODO need to test price and volume conversions correctly + amount = fmt.Sprintf("%.8f", order.Volume.AsFloat()*order.Price.AsFloat()) + invertedPrice := model.InvertNumber(order.Price) + // invert price ratio here instead of using convert2Price again since it has an overflow for XLM/BTC + price = horizon.Price{ + N: price.D, + D: price.N, + } + priceString = invertedPrice.AsString() + } + + // generate an offerID for the non-numerical orderID (hoops we have to jump through because of the hacked approach to using centralized exchanges) + var ID int64 + if v, ok := b.orderID2OfferID[order.ID]; ok { + ID = v + } else { + ID = b.genUniqueID() + b.orderID2OfferID[order.ID] = ID + b.offerID2OrderID[ID] = order.ID + } + + var lmt time.Time + if order.Timestamp != nil { + lmt = time.Unix(int64(*order.Timestamp)/1000, 0) + } + offers = append(offers, horizon.Offer{ + ID: ID, + Seller: tradingAccount, + Selling: sellingAsset, + Buying: buyingAsset, + Amount: amount, + PriceR: price, + Price: priceString, + LastModifiedLedger: 0, // TODO fix? + LastModifiedTime: lmt, + }) + } + return offers, nil +} + +func convert2Price(number *model.Number) (horizon.Price, error) { + n, d, e := number.AsRatio() + if e != nil { + return horizon.Price{}, fmt.Errorf("unable to convert2Price: %s", e) + } + return horizon.Price{ + N: n, + D: d, + }, nil +} + +func assetsEqual(hAsset horizon.Asset, xAsset xdr.Asset) (bool, error) { + if xAsset.Type == xdr.AssetTypeAssetTypeNative { + return hAsset.Type == utils.Native, nil + } else if hAsset.Type == utils.Native { + return false, nil + } + + var xAssetType, xAssetCode, xAssetIssuer string + e := xAsset.Extract(&xAssetType, &xAssetCode, &xAssetIssuer) + if e != nil { + return false, e + } + return xAssetCode == hAsset.Code, nil +} + +// manageOffer2Order converts a manage offer operation to a model.Order +func manageOffer2Order(mob *build.ManageOfferBuilder, baseAsset horizon.Asset, quoteAsset horizon.Asset, orderConstraints *model.OrderConstraints) (*model.Order, error) { + orderAction := model.OrderActionSell + price := model.NumberFromFloat(float64(mob.MO.Price.N)/float64(mob.MO.Price.D), largePrecision) + volume := model.NumberFromFloat(float64(mob.MO.Amount)/math.Pow(10, 7), largePrecision) + isBuy, e := assetsEqual(quoteAsset, mob.MO.Selling) + if e != nil { + return nil, fmt.Errorf("could not compare assets, error: %s", e) + } + if isBuy { + orderAction = model.OrderActionBuy + // TODO need to test price and volume conversions correctly + // volume calculation needs to happen first since it uses the non-inverted price when multiplying + volume = model.NumberFromFloat(volume.AsFloat()*price.AsFloat(), orderConstraints.VolumePrecision) + price = model.InvertNumber(price) + } + volume = model.NumberByCappingPrecision(volume, orderConstraints.VolumePrecision) + price = model.NumberByCappingPrecision(price, orderConstraints.PricePrecision) + + return &model.Order{ + Pair: &model.TradingPair{ + Base: model.FromHorizonAsset(baseAsset), + Quote: model.FromHorizonAsset(quoteAsset), + }, + OrderAction: orderAction, + OrderType: model.OrderTypeLimit, + Price: price, + Volume: volume, + Timestamp: model.MakeTimestamp(time.Now().UnixNano() / int64(time.Millisecond)), + }, nil +} + +func order2OpenOrder(order *model.Order, txID *model.TransactionID) *model.OpenOrder { + return &model.OpenOrder{ + Order: *order, + ID: txID.String(), + // we don't know these values so use nil + StartTime: nil, + ExpireTime: nil, + VolumeExecuted: nil, + } +} + +// Ops2Commands converts... +func (b BatchedExchange) Ops2Commands(ops []build.TransactionMutator, baseAsset horizon.Asset, quoteAsset horizon.Asset) ([]Command, error) { + pair := &model.TradingPair{ + Base: model.FromHorizonAsset(baseAsset), + Quote: model.FromHorizonAsset(quoteAsset), + } + return Ops2CommandsHack(ops, baseAsset, quoteAsset, b.offerID2OrderID, b.inner.GetOrderConstraints(pair)) +} + +// Ops2CommandsHack converts... +func Ops2CommandsHack( + ops []build.TransactionMutator, + baseAsset horizon.Asset, + quoteAsset horizon.Asset, + offerID2OrderID map[int64]string, // if map is nil then we ignore ID errors + orderConstraints *model.OrderConstraints, +) ([]Command, error) { + commands := []Command{} + for _, op := range ops { + switch manageOffer := op.(type) { + case *build.ManageOfferBuilder: + c, e := op2CommandsHack(manageOffer, baseAsset, quoteAsset, offerID2OrderID, orderConstraints) + if e != nil { + return nil, fmt.Errorf("unable to convert *build.ManageOfferBuilder to a Command: %s", e) + } + commands = append(commands, c...) + case build.ManageOfferBuilder: + c, e := op2CommandsHack(&manageOffer, baseAsset, quoteAsset, offerID2OrderID, orderConstraints) + if e != nil { + return nil, fmt.Errorf("unable to convert build.ManageOfferBuilder to a Command: %s", e) + } + commands = append(commands, c...) + default: + return nil, fmt.Errorf("unable to recognize transaction mutator op (%s): %v", reflect.TypeOf(op), manageOffer) + } + } + return commands, nil +} + +// op2CommandsHack converts one op to possibly many Commands +func op2CommandsHack( + manageOffer *build.ManageOfferBuilder, + baseAsset horizon.Asset, + quoteAsset horizon.Asset, + offerID2OrderID map[int64]string, // if map is nil then we ignore ID errors + orderConstraints *model.OrderConstraints, +) ([]Command, error) { + commands := []Command{} + order, e := manageOffer2Order(manageOffer, baseAsset, quoteAsset, orderConstraints) + if e != nil { + return nil, fmt.Errorf("error converting from manageOffer op to Order: %s", e) + } + + if manageOffer.MO.Amount == 0 { + // cancel + // fetch real orderID here (hoops we have to jump through because of the hacked approach to using centralized exchanges) + var orderID string + if offerID2OrderID != nil { + ID := int64(manageOffer.MO.OfferId) + var ok bool + orderID, ok = offerID2OrderID[ID] + if !ok { + return nil, fmt.Errorf("there was an order that we have never seen before and did not have in the offerID2OrderID map, offerID (int): %d", ID) + } + } else { + orderID = "" + } + txID := model.MakeTransactionID(orderID) + openOrder := order2OpenOrder(order, txID) + commands = append(commands, MakeCommandCancel(openOrder)) + } else if manageOffer.MO.OfferId != 0 { + // modify is cancel followed by create + // -- cancel + // fetch real orderID here (hoops we have to jump through because of the hacked approach to using centralized exchanges) + var orderID string + if offerID2OrderID != nil { + ID := int64(manageOffer.MO.OfferId) + var ok bool + orderID, ok = offerID2OrderID[ID] + if !ok { + return nil, fmt.Errorf("there was an order that we have never seen before and did not have in the offerID2OrderID map, offerID (int): %d", ID) + } + } else { + orderID = "" + } + txID := model.MakeTransactionID(orderID) + openOrder := order2OpenOrder(order, txID) + commands = append(commands, MakeCommandCancel(openOrder)) + // -- create + commands = append(commands, MakeCommandAdd(order)) + } else { + // create + commands = append(commands, MakeCommandAdd(order)) + } + return commands, nil +} diff --git a/plugins/buysellStrategy.go b/plugins/buysellStrategy.go index ab860662b..88e86b1ce 100644 --- a/plugins/buysellStrategy.go +++ b/plugins/buysellStrategy.go @@ -33,6 +33,7 @@ func (c buySellConfig) String() string { func makeBuySellStrategy( sdex *SDEX, pair *model.TradingPair, + ieif *IEIF, assetBase *horizon.Asset, assetQuote *horizon.Asset, config *buySellConfig, @@ -55,6 +56,7 @@ func makeBuySellStrategy( sellSideStrategy := makeSellSideStrategy( sdex, orderConstraints, + ieif, assetBase, assetQuote, makeStaticSpreadLevelProvider( @@ -88,6 +90,7 @@ func makeBuySellStrategy( buySideStrategy := makeSellSideStrategy( sdex, orderConstraints, + ieif, assetQuote, assetBase, makeStaticSpreadLevelProvider( diff --git a/plugins/ccxtExchange.go b/plugins/ccxtExchange.go index 3f1e5e27a..ca7161266 100644 --- a/plugins/ccxtExchange.go +++ b/plugins/ccxtExchange.go @@ -36,6 +36,10 @@ func makeCcxtExchange( return nil, fmt.Errorf("need at least 1 ExchangeAPIKey, even if it is an empty key") } + if len(apiKeys) != 1 { + return nil, fmt.Errorf("need exactly 1 ExchangeAPIKey") + } + c, e := sdk.MakeInitializedCcxtExchange(ccxtBaseURL, exchangeName, apiKeys[0]) if e != nil { return nil, fmt.Errorf("error making a ccxt exchange: %s", e) @@ -108,7 +112,7 @@ func (c ccxtExchange) GetOrderConstraints(pair *model.TradingPair) *model.OrderC if ccxtMarket == nil { panic(fmt.Errorf("CCXT does not have precision and limit data for the passed in market: %s", pairString)) } - oc := *model.MakeOrderConstraints(ccxtMarket.Precision.Price, ccxtMarket.Precision.Amount, ccxtMarket.Limits.Amount.Min) + oc := *model.MakeOrderConstraintsWithCost(ccxtMarket.Precision.Price, ccxtMarket.Precision.Amount, ccxtMarket.Limits.Amount.Min, ccxtMarket.Limits.Cost.Min) // cache it before returning c.orderConstraints[*pair] = oc @@ -117,14 +121,21 @@ func (c ccxtExchange) GetOrderConstraints(pair *model.TradingPair) *model.OrderC } // GetAccountBalances impl -func (c ccxtExchange) GetAccountBalances(assetList []model.Asset) (map[model.Asset]model.Number, error) { +func (c ccxtExchange) GetAccountBalances(assetList []interface{}) (map[interface{}]model.Number, error) { balanceResponse, e := c.api.FetchBalance() if e != nil { return nil, e } - m := map[model.Asset]model.Number{} - for _, asset := range assetList { + m := map[interface{}]model.Number{} + for _, elem := range assetList { + var asset model.Asset + if v, ok := elem.(model.Asset); ok { + asset = v + } else { + return nil, fmt.Errorf("invalid type of asset passed in, only model.Asset accepted") + } + ccxtAssetString, e := c.GetAssetConverter().ToString(asset) if e != nil { return nil, e @@ -189,8 +200,9 @@ func (c ccxtExchange) GetTradeHistory(pair model.TradingPair, maybeCursorStart i return nil, fmt.Errorf("error converting pair to string: %s", e) } - // TODO use cursor when fetching trade history - tradesRaw, e := c.api.FetchMyTrades(pairString) + // TODO fix limit logic to check result so we get full history instead of just 50 trades + const limit = 50 + tradesRaw, e := c.api.FetchMyTrades(pairString, limit, maybeCursorStart) if e != nil { return nil, fmt.Errorf("error while fetching trade history for trading pair '%s': %s", pairString, e) } @@ -302,7 +314,7 @@ func (c ccxtExchange) GetOpenOrders(pairs []*model.TradingPair) (map[model.Tradi for asset, ccxtOrderList := range openOrdersMap { pair, ok := string2Pair[asset] if !ok { - return nil, fmt.Errorf("traing symbol %s returned from FetchOpenOrders was not in the original list of trading pairs: %v", asset, pairStrings) + return nil, fmt.Errorf("symbol %s returned from FetchOpenOrders was not in the original list of trading pairs: %v", asset, pairStrings) } openOrderList := []model.OpenOrder{} diff --git a/plugins/ccxtExchange_test.go b/plugins/ccxtExchange_test.go index 857f7277b..171167ac0 100644 --- a/plugins/ccxtExchange_test.go +++ b/plugins/ccxtExchange_test.go @@ -176,7 +176,7 @@ func TestGetAccountBalances_Ccxt(t *testing.T) { return } - balances, e := testCcxtExchange.GetAccountBalances([]model.Asset{ + balances, e := testCcxtExchange.GetAccountBalances([]interface{}{ model.XLM, model.BTC, model.USD, diff --git a/plugins/factory.go b/plugins/factory.go index 8a4abac10..9372ce8a8 100644 --- a/plugins/factory.go +++ b/plugins/factory.go @@ -14,6 +14,7 @@ import ( // strategyFactoryData is a data container that has all the information needed to make a strategy type strategyFactoryData struct { sdex *SDEX + ieif *IEIF tradingPair *model.TradingPair assetBase *horizon.Asset assetQuote *horizon.Asset @@ -42,7 +43,7 @@ var strategies = map[string]StrategyContainer{ err := config.Read(strategyFactoryData.stratConfigPath, &cfg) utils.CheckConfigError(cfg, err, strategyFactoryData.stratConfigPath) utils.LogConfig(cfg) - s, e := makeBuySellStrategy(strategyFactoryData.sdex, strategyFactoryData.tradingPair, strategyFactoryData.assetBase, strategyFactoryData.assetQuote, &cfg) + s, e := makeBuySellStrategy(strategyFactoryData.sdex, strategyFactoryData.tradingPair, strategyFactoryData.ieif, strategyFactoryData.assetBase, strategyFactoryData.assetQuote, &cfg) if e != nil { return nil, fmt.Errorf("makeFn failed: %s", e) } @@ -59,7 +60,7 @@ var strategies = map[string]StrategyContainer{ err := config.Read(strategyFactoryData.stratConfigPath, &cfg) utils.CheckConfigError(cfg, err, strategyFactoryData.stratConfigPath) utils.LogConfig(cfg) - s, e := makeMirrorStrategy(strategyFactoryData.sdex, strategyFactoryData.tradingPair, strategyFactoryData.assetBase, strategyFactoryData.assetQuote, &cfg, strategyFactoryData.simMode) + s, e := makeMirrorStrategy(strategyFactoryData.sdex, strategyFactoryData.ieif, strategyFactoryData.tradingPair, strategyFactoryData.assetBase, strategyFactoryData.assetQuote, &cfg, strategyFactoryData.simMode) if e != nil { return nil, fmt.Errorf("makeFn failed: %s", e) } @@ -76,7 +77,7 @@ var strategies = map[string]StrategyContainer{ err := config.Read(strategyFactoryData.stratConfigPath, &cfg) utils.CheckConfigError(cfg, err, strategyFactoryData.stratConfigPath) utils.LogConfig(cfg) - s, e := makeSellStrategy(strategyFactoryData.sdex, strategyFactoryData.tradingPair, strategyFactoryData.assetBase, strategyFactoryData.assetQuote, &cfg) + s, e := makeSellStrategy(strategyFactoryData.sdex, strategyFactoryData.tradingPair, strategyFactoryData.ieif, strategyFactoryData.assetBase, strategyFactoryData.assetQuote, &cfg) if e != nil { return nil, fmt.Errorf("makeFn failed: %s", e) } @@ -93,7 +94,7 @@ var strategies = map[string]StrategyContainer{ err := config.Read(strategyFactoryData.stratConfigPath, &cfg) utils.CheckConfigError(cfg, err, strategyFactoryData.stratConfigPath) utils.LogConfig(cfg) - return makeBalancedStrategy(strategyFactoryData.sdex, strategyFactoryData.tradingPair, strategyFactoryData.assetBase, strategyFactoryData.assetQuote, &cfg), nil + return makeBalancedStrategy(strategyFactoryData.sdex, strategyFactoryData.tradingPair, strategyFactoryData.ieif, strategyFactoryData.assetBase, strategyFactoryData.assetQuote, &cfg), nil }, }, "delete": StrategyContainer{ @@ -110,6 +111,7 @@ var strategies = map[string]StrategyContainer{ // MakeStrategy makes a strategy func MakeStrategy( sdex *SDEX, + ieif *IEIF, tradingPair *model.TradingPair, assetBase *horizon.Asset, assetQuote *horizon.Asset, @@ -122,8 +124,10 @@ func MakeStrategy( if strat.NeedsConfig && stratConfigPath == "" { return nil, fmt.Errorf("the '%s' strategy needs a config file", strategy) } + s, e := strat.makeFn(strategyFactoryData{ sdex: sdex, + ieif: ieif, tradingPair: tradingPair, assetBase: assetBase, assetQuote: assetQuote, diff --git a/plugins/ieif.go b/plugins/ieif.go new file mode 100644 index 000000000..34f566c08 --- /dev/null +++ b/plugins/ieif.go @@ -0,0 +1,304 @@ +package plugins + +import ( + "fmt" + "log" + + "github.com/stellar/go/clients/horizon" + "github.com/stellar/kelp/api" + "github.com/stellar/kelp/support/utils" +) + +// Liabilities represents the "committed" units of an asset on both the buy and sell sides +type Liabilities struct { + Buying float64 // affects how much more can be bought + Selling float64 // affects how much more can be sold +} + +// IEIF is the module that allows us to ensure that orders are always "Immediately Executable In Full" +type IEIF struct { + // explicitly calculate liabilities here for now, we can switch over to using the values from Horizon once the protocol change has taken effect + cachedLiabilities map[horizon.Asset]Liabilities + + // TODO 2 streamline requests instead of caching + // cache balances to avoid redundant requests + cachedBalances map[horizon.Asset]api.Balance + + isTradingSdex bool + + // TODO this is a hack because the logic to fetch balances is in the exchange, maybe take in an api.Account interface + // TODO this is a hack because the logic to fetch offers is in the exchange, maybe take in api.GetOpenOrders() as an interface + // TODO 1 this should not be horizon specific + exchangeShim api.ExchangeShim +} + +// SetExchangeShim is a hack, TODO remove this hack +func (ieif *IEIF) SetExchangeShim(exchangeShim api.ExchangeShim) { + ieif.exchangeShim = exchangeShim +} + +// MakeIEIF factory method +func MakeIEIF(isTradingSdex bool) *IEIF { + return &IEIF{ + cachedLiabilities: map[horizon.Asset]Liabilities{}, + cachedBalances: map[horizon.Asset]api.Balance{}, + isTradingSdex: isTradingSdex, + } +} + +// AddLiabilities updates the cached liabilities, units are in their respective assets +func (ieif *IEIF) AddLiabilities(selling horizon.Asset, buying horizon.Asset, incrementalSell float64, incrementalBuy float64, incrementalNativeAmountRaw float64) { + ieif.cachedLiabilities[selling] = Liabilities{ + Selling: ieif.cachedLiabilities[selling].Selling + incrementalSell, + Buying: ieif.cachedLiabilities[selling].Buying, + } + ieif.cachedLiabilities[buying] = Liabilities{ + Selling: ieif.cachedLiabilities[buying].Selling, + Buying: ieif.cachedLiabilities[buying].Buying + incrementalBuy, + } + ieif.cachedLiabilities[utils.NativeAsset] = Liabilities{ + Selling: ieif.cachedLiabilities[utils.NativeAsset].Selling + incrementalNativeAmountRaw, + Buying: ieif.cachedLiabilities[utils.NativeAsset].Buying, + } +} + +// RecomputeAndLogCachedLiabilities clears the cached liabilities and recomputes from the network before logging +func (ieif *IEIF) RecomputeAndLogCachedLiabilities(assetBase horizon.Asset, assetQuote horizon.Asset) { + ieif.cachedLiabilities = map[horizon.Asset]Liabilities{} + // reset cached balances too so we fetch fresh balances + ieif.ResetCachedBalances() + ieif.LogAllLiabilities(assetBase, assetQuote) +} + +// ResetCachedLiabilities resets the cache to include only the two assets passed in +func (ieif *IEIF) ResetCachedLiabilities(assetBase horizon.Asset, assetQuote horizon.Asset) error { + // re-compute the liabilities + ieif.cachedLiabilities = map[horizon.Asset]Liabilities{} + baseLiabilities, basePairLiabilities, e := ieif.pairLiabilities(assetBase, assetQuote) + if e != nil { + return e + } + quoteLiabilities, quotePairLiabilities, e := ieif.pairLiabilities(assetQuote, assetBase) + if e != nil { + return e + } + + // delete liability amounts related to all offers (filter on only those offers involving **both** assets in case the account is used by multiple bots) + ieif.cachedLiabilities[assetBase] = Liabilities{ + Buying: baseLiabilities.Buying - basePairLiabilities.Buying, + Selling: baseLiabilities.Selling - basePairLiabilities.Selling, + } + ieif.cachedLiabilities[assetQuote] = Liabilities{ + Buying: quoteLiabilities.Buying - quotePairLiabilities.Buying, + Selling: quoteLiabilities.Selling - quotePairLiabilities.Selling, + } + return nil +} + +// willOversellNative returns willOversellNative, error +func (ieif *IEIF) willOversellNative(incrementalNativeAmount float64) (bool, error) { + nativeBalance, e := ieif.assetBalance(utils.NativeAsset) + if e != nil { + return false, e + } + // TODO don't break out into vars + nativeBal, _, minAccountBal := nativeBalance.Balance, nativeBalance.Trust, nativeBalance.Reserve + nativeLiabilities, e := ieif.assetLiabilities(utils.NativeAsset) + if e != nil { + return false, e + } + + willOversellNative := incrementalNativeAmount > (nativeBal - minAccountBal - nativeLiabilities.Selling) + if willOversellNative { + log.Printf("we will oversell the native asset after considering fee and min reserves, incrementalNativeAmount = %.8f, nativeBal = %.8f, minAccountBal = %.8f, nativeLiabilities.Selling = %.8f\n", + incrementalNativeAmount, nativeBal, minAccountBal, nativeLiabilities.Selling) + } + return willOversellNative, nil +} + +// willOversell returns willOversell, error +func (ieif *IEIF) willOversell(asset horizon.Asset, amountSelling float64) (bool, error) { + balance, e := ieif.assetBalance(asset) + if e != nil { + return false, e + } + // TODO don't break out into vars + bal, _, minAccountBal := balance.Balance, balance.Trust, balance.Reserve + liabilities, e := ieif.assetLiabilities(asset) + if e != nil { + return false, e + } + + willOversell := amountSelling > (bal - minAccountBal - liabilities.Selling) + if willOversell { + log.Printf("we will oversell the asset '%s', amountSelling = %.8f, bal = %.8f, minAccountBal = %.8f, liabilities.Selling = %.8f\n", + utils.Asset2String(asset), amountSelling, bal, minAccountBal, liabilities.Selling) + } + return willOversell, nil +} + +// willOverbuy returns willOverbuy, error +func (ieif *IEIF) willOverbuy(asset horizon.Asset, amountBuying float64) (bool, error) { + if asset.Type == utils.Native { + // you can never overbuy the native asset + return false, nil + } + + balance, e := ieif.assetBalance(asset) + if e != nil { + return false, e + } + liabilities, e := ieif.assetLiabilities(asset) + if e != nil { + return false, e + } + + willOverbuy := amountBuying > (balance.Trust - liabilities.Buying) + return willOverbuy, nil +} + +// LogAllLiabilities logs the liabilities for the two assets along with the native asset +func (ieif *IEIF) LogAllLiabilities(assetBase horizon.Asset, assetQuote horizon.Asset) { + ieif.logLiabilities(assetBase, "base ") + ieif.logLiabilities(assetQuote, "quote ") + + if ieif.isTradingSdex && assetBase != utils.NativeAsset && assetQuote != utils.NativeAsset { + ieif.logLiabilities(utils.NativeAsset, "native") + } +} + +func (ieif *IEIF) logLiabilities(asset horizon.Asset, assetStr string) { + l, e := ieif.assetLiabilities(asset) + if e != nil { + log.Printf("could not fetch liability for asset '%s', error = %s\n", assetStr, e) + return + } + + balance, e := ieif.assetBalance(asset) + if e != nil { + log.Printf("cannot fetch balance for asset '%s', error = %s\n", assetStr, e) + return + } + // TODO don't break out into vars + bal, trust, minAccountBal := balance.Balance, balance.Trust, balance.Reserve + + trustString := "math.MaxFloat64" + if trust != maxLumenTrust { + trustString = fmt.Sprintf("%.8f", trust) + } + log.Printf("asset=%s, balance=%.8f, trust=%s, minAccountBal=%.8f, buyingLiabilities=%.8f, sellingLiabilities=%.8f\n", + assetStr, bal, trustString, minAccountBal, l.Buying, l.Selling) +} + +// AvailableCapacity returns the buying and selling amounts available for a given asset +func (ieif *IEIF) AvailableCapacity(asset horizon.Asset, incrementalNativeAmountRaw float64) (*Liabilities, error) { + l, e := ieif.assetLiabilities(asset) + if e != nil { + return nil, e + } + + balance, e := ieif.assetBalance(asset) + if e != nil { + return nil, e + } + // TODO don't break out into vars + bal, trust, minAccountBal := balance.Balance, balance.Trust, balance.Reserve + + // factor in cost of increase in minReserve and fee when calculating selling capacity of native asset + incrementalSellingLiability := 0.0 + if asset == utils.NativeAsset { + incrementalSellingLiability = incrementalNativeAmountRaw + } + + return &Liabilities{ + Buying: trust - l.Buying, + Selling: bal - minAccountBal - l.Selling - incrementalSellingLiability, + }, nil +} + +// assetLiabilities returns the liabilities for the asset +func (ieif *IEIF) assetLiabilities(asset horizon.Asset) (*Liabilities, error) { + if v, ok := ieif.cachedLiabilities[asset]; ok { + return &v, nil + } + + assetLiabilities, _, e := ieif._liabilities(asset, asset) // pass in the same asset, we ignore the returned object anyway + return assetLiabilities, e +} + +// pairLiabilities returns the liabilities for the asset along with the pairLiabilities +func (ieif *IEIF) pairLiabilities(asset horizon.Asset, otherAsset horizon.Asset) (*Liabilities, *Liabilities, error) { + assetLiabilities, pairLiabilities, e := ieif._liabilities(asset, otherAsset) + return assetLiabilities, pairLiabilities, e +} + +// liabilities returns the asset liabilities and pairLiabilities (non-nil only if the other asset is specified) +func (ieif *IEIF) _liabilities(asset horizon.Asset, otherAsset horizon.Asset) (*Liabilities, *Liabilities, error) { + // uses all offers for this trading account to accommodate sharing by other bots + offers, err := ieif.exchangeShim.LoadOffersHack() + if err != nil { + assetString := utils.Asset2String(asset) + log.Printf("error: cannot load offers to compute liabilities for asset (%s): %s\n", assetString, err) + return nil, nil, err + } + + // liabilities for the asset + liabilities := Liabilities{} + // liabilities for the asset w.r.t. the trading pair + pairLiabilities := Liabilities{} + for _, offer := range offers { + if offer.Selling == asset { + offerAmt, err := utils.ParseOfferAmount(offer.Amount) + if err != nil { + return nil, nil, err + } + liabilities.Selling += offerAmt + + if offer.Buying == otherAsset { + pairLiabilities.Selling += offerAmt + } + } else if offer.Buying == asset { + offerAmt, err := utils.ParseOfferAmount(offer.Amount) + if err != nil { + return nil, nil, err + } + offerPrice, err := utils.ParseOfferAmount(offer.Price) + if err != nil { + return nil, nil, err + } + buyingAmount := offerAmt * offerPrice + liabilities.Buying += buyingAmount + + if offer.Selling == otherAsset { + pairLiabilities.Buying += buyingAmount + } + } + } + + ieif.cachedLiabilities[asset] = liabilities + return &liabilities, &pairLiabilities, nil +} + +// ResetCachedBalances resets the cached balances map +func (ieif *IEIF) ResetCachedBalances() { + ieif.cachedBalances = map[horizon.Asset]api.Balance{} +} + +// GetAssetBalance is the exported version of assetBalance +func (ieif *IEIF) GetAssetBalance(asset horizon.Asset) (*api.Balance, error) { + return ieif.assetBalance(asset) +} + +// assetBalance is a memoized version of submitX. +func (ieif *IEIF) assetBalance(asset horizon.Asset) (*api.Balance, error) { + if v, ok := ieif.cachedBalances[asset]; ok { + return &v, nil + } + + balance, e := ieif.exchangeShim.GetBalanceHack(asset) + if e == nil { + ieif.cachedBalances[asset] = *balance + } + + return balance, e +} diff --git a/plugins/krakenExchange.go b/plugins/krakenExchange.go index 76017fbea..b95d54db4 100644 --- a/plugins/krakenExchange.go +++ b/plugins/krakenExchange.go @@ -6,7 +6,6 @@ import ( "log" "math" "reflect" - "strconv" "strings" "github.com/Beldur/kraken-go-api-client" @@ -103,6 +102,8 @@ func (k *krakenExchange) AddOrder(order *model.Order) (*model.TransactionID, err args := map[string]string{ "price": order.Price.AsString(), } + log.Printf("kraken is submitting order: pair=%s, orderAction=%s, orderType=%s, volume=%s, price=%s\n", + pairStr, order.OrderAction.String(), order.OrderType.String(), order.Volume.AsString(), order.Price.AsString()) resp, e := k.nextAPI().AddOrder( pairStr, order.OrderAction.String(), @@ -131,6 +132,7 @@ func (k *krakenExchange) CancelOrder(txID *model.TransactionID, pair model.Tradi if k.isSimulated { return model.CancelResultCancelSuccessful, nil } + log.Printf("kraken is canceling order: ID=%s, tradingPair=%s\n", txID.String(), pair.String()) // we don't actually use the pair for kraken resp, e := k.nextAPI().CancelOrder(txID.String()) @@ -155,21 +157,28 @@ func (k *krakenExchange) CancelOrder(txID *model.TransactionID, pair model.Tradi } // GetAccountBalances impl. -func (k *krakenExchange) GetAccountBalances(assetList []model.Asset) (map[model.Asset]model.Number, error) { +func (k *krakenExchange) GetAccountBalances(assetList []interface{}) (map[interface{}]model.Number, error) { balanceResponse, e := k.nextAPI().Balance() if e != nil { return nil, e } - m := map[model.Asset]model.Number{} - for _, a := range assetList { - krakenAssetString, e := k.assetConverter.ToString(a) + m := map[interface{}]model.Number{} + for _, elem := range assetList { + var asset model.Asset + if v, ok := elem.(model.Asset); ok { + asset = v + } else { + return nil, fmt.Errorf("invalid type of asset passed in, only model.Asset accepted") + } + + krakenAssetString, e := k.assetConverter.ToString(asset) if e != nil { // discard partially built map for now return nil, e } bal := getFieldValue(*balanceResponse, krakenAssetString) - m[a] = *model.NumberFromFloat(bal, precisionBalances) + m[asset] = *model.NumberFromFloat(bal, precisionBalances) } return m, nil } @@ -199,7 +208,7 @@ func (k *krakenExchange) GetAssetConverter() *model.AssetConverter { func (k *krakenExchange) GetOpenOrders(pairs []*model.TradingPair) (map[model.TradingPair][]model.OpenOrder, error) { openOrdersResponse, e := k.nextAPI().OpenOrders(map[string]string{}) if e != nil { - return nil, e + return nil, fmt.Errorf("cannot load open orders for Kraken: %s", e) } // convert to a map so we can easily search for the existence of a trading pair @@ -319,28 +328,28 @@ func values(m map[model.TradingPair]string) []string { // GetTradeHistory impl. func (k *krakenExchange) GetTradeHistory(pair model.TradingPair, maybeCursorStart interface{}, maybeCursorEnd interface{}) (*api.TradeHistoryResult, error) { - var mcs *int64 + var mcs *string if maybeCursorStart != nil { - i := maybeCursorStart.(int64) + i := maybeCursorStart.(string) mcs = &i } - var mce *int64 + var mce *string if maybeCursorEnd != nil { - i := maybeCursorEnd.(int64) + i := maybeCursorEnd.(string) mce = &i } return k.getTradeHistory(pair, mcs, mce) } -func (k *krakenExchange) getTradeHistory(tradingPair model.TradingPair, maybeCursorStart *int64, maybeCursorEnd *int64) (*api.TradeHistoryResult, error) { +func (k *krakenExchange) getTradeHistory(tradingPair model.TradingPair, maybeCursorStart *string, maybeCursorEnd *string) (*api.TradeHistoryResult, error) { input := map[string]string{} if maybeCursorStart != nil { - input["start"] = strconv.FormatInt(*maybeCursorStart, 10) + input["start"] = *maybeCursorStart } if maybeCursorEnd != nil { - input["end"] = strconv.FormatInt(*maybeCursorEnd, 10) + input["end"] = *maybeCursorEnd } resp, e := k.nextAPI().Query("TradesHistory", input) @@ -351,9 +360,8 @@ func (k *krakenExchange) getTradeHistory(tradingPair model.TradingPair, maybeCur krakenTrades := krakenResp["trades"].(map[string]interface{}) res := api.TradeHistoryResult{Trades: []model.Trade{}} - for _, v := range krakenTrades { + for _txid, v := range krakenTrades { m := v.(map[string]interface{}) - _txid := m["ordertxid"].(string) _time := m["time"].(float64) ts := model.MakeTimestamp(int64(_time)) _type := m["type"].(string) diff --git a/plugins/krakenExchange_test.go b/plugins/krakenExchange_test.go index fdadb79e2..6d01d279a 100644 --- a/plugins/krakenExchange_test.go +++ b/plugins/krakenExchange_test.go @@ -48,7 +48,7 @@ func TestGetAccountBalances(t *testing.T) { return } - assetList := []model.Asset{ + assetList := []interface{}{ model.USD, model.XLM, model.BTC, diff --git a/plugins/mirrorStrategy.go b/plugins/mirrorStrategy.go index d210fde36..c8d49cecd 100644 --- a/plugins/mirrorStrategy.go +++ b/plugins/mirrorStrategy.go @@ -65,6 +65,7 @@ func makeAssetSurplus() *assetSurplus { // mirrorStrategy is a strategy to mirror the orderbook of a given exchange type mirrorStrategy struct { sdex *SDEX + ieif *IEIF baseAsset *horizon.Asset quoteAsset *horizon.Asset primaryConstraints *model.OrderConstraints @@ -90,7 +91,7 @@ var _ api.Strategy = &mirrorStrategy{} var _ api.FillHandler = &mirrorStrategy{} // makeMirrorStrategy is a factory method -func makeMirrorStrategy(sdex *SDEX, pair *model.TradingPair, baseAsset *horizon.Asset, quoteAsset *horizon.Asset, config *mirrorConfig, simMode bool) (api.Strategy, error) { +func makeMirrorStrategy(sdex *SDEX, ieif *IEIF, pair *model.TradingPair, baseAsset *horizon.Asset, quoteAsset *horizon.Asset, config *mirrorConfig, simMode bool) (api.Strategy, error) { var exchange api.Exchange var e error if config.OffsetTrades { @@ -116,6 +117,7 @@ func makeMirrorStrategy(sdex *SDEX, pair *model.TradingPair, baseAsset *horizon. backingConstraints := exchange.GetOrderConstraints(backingPair) return &mirrorStrategy{ sdex: sdex, + ieif: ieif, baseAsset: baseAsset, quoteAsset: quoteAsset, primaryConstraints: primaryConstraints, @@ -145,7 +147,7 @@ func (s *mirrorStrategy) PreUpdate(maxAssetA float64, maxAssetB float64, trustA } func (s *mirrorStrategy) recordBalances() error { - balanceMap, e := s.exchange.GetAccountBalances([]model.Asset{s.backingPair.Base, s.backingPair.Quote}) + balanceMap, e := s.exchange.GetAccountBalances([]interface{}{s.backingPair.Base, s.backingPair.Quote}) if e != nil { return fmt.Errorf("unable to fetch balances for assets: %s", e) } @@ -289,9 +291,9 @@ func (s *mirrorStrategy) updateLevels( ops = append(ops, *mo) // update the cached liabilities if we create a valid operation to create an offer if hackPriceInvertForBuyOrderChangeCheck { - s.sdex.AddLiabilities(*s.quoteAsset, *s.baseAsset, vol.Multiply(*price).AsFloat(), vol.AsFloat(), incrementalNativeAmountRaw) + s.ieif.AddLiabilities(*s.quoteAsset, *s.baseAsset, vol.Multiply(*price).AsFloat(), vol.AsFloat(), incrementalNativeAmountRaw) } else { - s.sdex.AddLiabilities(*s.baseAsset, *s.quoteAsset, vol.AsFloat(), vol.Multiply(*price).AsFloat(), incrementalNativeAmountRaw) + s.ieif.AddLiabilities(*s.baseAsset, *s.quoteAsset, vol.AsFloat(), vol.Multiply(*price).AsFloat(), incrementalNativeAmountRaw) } } } @@ -349,9 +351,9 @@ func (s *mirrorStrategy) doModifyOffer( if sameOrderParams { // update the cached liabilities if we keep the existing offer if hackPriceInvertForBuyOrderChangeCheck { - s.sdex.AddLiabilities(oldOffer.Selling, oldOffer.Buying, oldVol.Multiply(*oldPrice).AsFloat(), oldVol.AsFloat(), incrementalNativeAmountRaw) + s.ieif.AddLiabilities(oldOffer.Selling, oldOffer.Buying, oldVol.Multiply(*oldPrice).AsFloat(), oldVol.AsFloat(), incrementalNativeAmountRaw) } else { - s.sdex.AddLiabilities(oldOffer.Selling, oldOffer.Buying, oldVol.AsFloat(), oldVol.Multiply(*oldPrice).AsFloat(), incrementalNativeAmountRaw) + s.ieif.AddLiabilities(oldOffer.Selling, oldOffer.Buying, oldVol.AsFloat(), oldVol.Multiply(*oldPrice).AsFloat(), incrementalNativeAmountRaw) } return nil, nil, nil } @@ -377,9 +379,9 @@ func (s *mirrorStrategy) doModifyOffer( if mo != nil { // update the cached liabilities if we create a valid operation to modify the offer if hackPriceInvertForBuyOrderChangeCheck { - s.sdex.AddLiabilities(oldOffer.Selling, oldOffer.Buying, offerAmount.Multiply(*offerPrice).AsFloat(), offerAmount.AsFloat(), incrementalNativeAmountRaw) + s.ieif.AddLiabilities(oldOffer.Selling, oldOffer.Buying, offerAmount.Multiply(*offerPrice).AsFloat(), offerAmount.AsFloat(), incrementalNativeAmountRaw) } else { - s.sdex.AddLiabilities(oldOffer.Selling, oldOffer.Buying, offerAmount.AsFloat(), offerAmount.Multiply(*offerPrice).AsFloat(), incrementalNativeAmountRaw) + s.ieif.AddLiabilities(oldOffer.Selling, oldOffer.Buying, offerAmount.AsFloat(), offerAmount.Multiply(*offerPrice).AsFloat(), incrementalNativeAmountRaw) } return *mo, nil, nil } diff --git a/plugins/priceFeed.go b/plugins/priceFeed.go index f01ce27b9..2619aaf1c 100644 --- a/plugins/priceFeed.go +++ b/plugins/priceFeed.go @@ -13,6 +13,7 @@ import ( // privateSdexHack is a temporary hack struct for SDEX price feeds pending refactor type privateSdexHack struct { API *horizon.Client + Ieif *IEIF Network build.Network } @@ -20,13 +21,14 @@ type privateSdexHack struct { var privateSdexHackVar *privateSdexHack // SetPrivateSdexHack sets the privateSdexHack variable which is temporary until the pending SDEX price feed refactor -func SetPrivateSdexHack(api *horizon.Client, network build.Network) error { +func SetPrivateSdexHack(api *horizon.Client, ieif *IEIF, network build.Network) error { if privateSdexHackVar != nil { return fmt.Errorf("privateSdexHack is already set: %+v", privateSdexHackVar) } privateSdexHackVar = &privateSdexHack{ API: api, + Ieif: ieif, Network: network, } return nil diff --git a/plugins/sdex.go b/plugins/sdex.go index a708402e4..de5dbde1b 100644 --- a/plugins/sdex.go +++ b/plugins/sdex.go @@ -43,26 +43,19 @@ type SDEX struct { pair *model.TradingPair assetMap map[model.Asset]horizon.Asset // this is needed until we fully address putting SDEX behind the Exchange interface opFeeStroopsFn OpFeeStroops + tradingOnSdex bool // uninitialized seqNum uint64 reloadSeqNum bool - // explicitly calculate liabilities here for now, we can switch over to using the values from Horizon once the protocol change has taken effect - cachedLiabilities map[horizon.Asset]Liabilities - - // TODO 2 streamline requests instead of caching - // cache balances to avoid redundant requests - cachedBalances map[horizon.Asset]Balance + ieif *IEIF } // enforce SDEX implements api.Constrainable var _ api.Constrainable = &SDEX{} -// Liabilities represents the "committed" units of an asset on both the buy and sell sides -type Liabilities struct { - Buying float64 // affects how much more can be bought - Selling float64 // affects how much more can be sold -} +// enforce SDEX implements api.ExchangeShim +var _ api.ExchangeShim = &SDEX{} // Balance repesents an asset's balance response from the assetBalance method below type Balance struct { @@ -74,6 +67,8 @@ type Balance struct { // MakeSDEX is a factory method for SDEX func MakeSDEX( api *horizon.Client, + ieif *IEIF, + exchangeShim api.ExchangeShim, sourceSeed string, tradingSeed string, sourceAccount string, @@ -89,6 +84,7 @@ func MakeSDEX( ) *SDEX { sdex := &SDEX{ API: api, + ieif: ieif, SourceSeed: sourceSeed, TradingSeed: tradingSeed, SourceAccount: sourceAccount, @@ -101,8 +97,15 @@ func MakeSDEX( pair: pair, assetMap: assetMap, opFeeStroopsFn: opFeeStroopsFn, + tradingOnSdex: exchangeShim == nil, } + if exchangeShim == nil { + exchangeShim = sdex + } + // TODO 2 remove this hack, we need to find a way of having ieif get a handle to compute balances or always compute and pass balances in? + ieif.SetExchangeShim(exchangeShim) + log.Printf("Using network passphrase: %s\n", sdex.Network.Passphrase) if sdex.SourceAccount == "" { @@ -115,6 +118,37 @@ func MakeSDEX( return sdex } +// IEIF exoses the ieif var +func (sdex *SDEX) IEIF() *IEIF { + return sdex.ieif +} + +// GetAccountBalances impl +func (sdex *SDEX) GetAccountBalances(assetList []interface{}) (map[interface{}]model.Number, error) { + m := map[interface{}]model.Number{} + for _, elem := range assetList { + var a horizon.Asset + if v, ok := elem.(horizon.Asset); ok { + a = v + } else { + return nil, fmt.Errorf("invalid type of asset passed in, only horizon.Asset accepted") + } + + balance, e := sdex.ieif.assetBalance(a) + if e != nil { + return nil, fmt.Errorf("could not fetch asset balance: %s", e) + } + + m[elem] = *model.NumberFromFloat(balance.Balance, utils.SdexPrecision) + } + return m, nil +} + +// GetAssetConverter impl +func (sdex *SDEX) GetAssetConverter() *model.AssetConverter { + return model.Display +} + func (sdex *SDEX) incrementSeqNum() { if sdex.reloadSeqNum { log.Println("reloading sequence number") @@ -173,68 +207,59 @@ func (sdex *SDEX) CreateSellOffer(base horizon.Asset, counter horizon.Asset, pri return sdex.createModifySellOffer(nil, base, counter, price, amount, incrementalNativeAmountRaw) } -// ParseOfferAmount is a convenience method to parse an offer amount -func (sdex *SDEX) ParseOfferAmount(amt string) (float64, error) { - offerAmt, err := strconv.ParseFloat(amt, 64) - if err != nil { - log.Printf("error parsing offer amount: %s\n", err) - return -1, err - } - return offerAmt, nil -} - func (sdex *SDEX) minReserve(subentries int32) float64 { return float64(2+subentries) * baseReserve } -// ResetCachedBalances resets the cached balances map -func (sdex *SDEX) ResetCachedBalances() { - sdex.cachedBalances = map[horizon.Asset]Balance{} -} - -// assetBalance is a memoized version of _assetBalance -func (sdex *SDEX) assetBalance(asset horizon.Asset) (float64, float64, float64, error) { - if v, ok := sdex.cachedBalances[asset]; ok { - return v.Balance, v.Trust, v.Reserve, nil - } - - b, t, r, e := sdex._assetBalance(asset) - if e == nil { - sdex.cachedBalances[asset] = Balance{ - Balance: b, - Trust: t, - Reserve: r, - } - } - - return b, t, r, e -} - -// assetBalance returns asset balance, asset trust limit, reserve balance, error -func (sdex *SDEX) _assetBalance(asset horizon.Asset) (float64, float64, float64, error) { +// assetBalance returns asset balance, asset trust limit, reserve balance (zero for non-XLM), error +func (sdex *SDEX) _assetBalance(asset horizon.Asset) (*api.Balance, error) { account, err := sdex.API.LoadAccount(sdex.TradingAccount) if err != nil { - return -1, -1, -1, fmt.Errorf("error: unable to load account to fetch balance: %s", err) + return nil, fmt.Errorf("error: unable to load account to fetch balance: %s", err) } for _, balance := range account.Balances { if utils.AssetsEqual(balance.Asset, asset) { b, e := strconv.ParseFloat(balance.Balance, 64) if e != nil { - return -1, -1, -1, fmt.Errorf("error: cannot parse balance: %s", e) + return nil, fmt.Errorf("error: cannot parse balance: %s", e) } if balance.Asset.Type == utils.Native { - return b, maxLumenTrust, sdex.minReserve(account.SubentryCount) + sdex.operationalBuffer, e + return &api.Balance{ + Balance: b, + Trust: maxLumenTrust, + Reserve: sdex.minReserve(account.SubentryCount) + sdex.operationalBuffer, + }, nil } t, e := strconv.ParseFloat(balance.Limit, 64) if e != nil { - return -1, -1, -1, fmt.Errorf("error: cannot parse trust limit: %s", e) + return nil, fmt.Errorf("error: cannot parse trust limit: %s", e) } - return b, t, b * sdex.operationalBufferNonNativePct, nil + + return &api.Balance{ + Balance: b, + Trust: t, + Reserve: b * sdex.operationalBufferNonNativePct, + }, nil } } - return -1, -1, -1, errors.New("could not find a balance for the asset passed in") + return nil, errors.New("could not find a balance for the asset passed in") +} + +// GetBalanceHack impl +func (sdex *SDEX) GetBalanceHack(asset horizon.Asset) (*api.Balance, error) { + b, e := sdex._assetBalance(asset) + return b, e +} + +// LoadOffersHack impl +func (sdex *SDEX) LoadOffersHack() ([]horizon.Offer, error) { + return sdex._loadOffers() +} + +func (sdex *SDEX) _loadOffers() ([]horizon.Offer, error) { + return utils.LoadAllOffers(sdex.TradingAccount, sdex.API) } // ComputeIncrementalNativeAmountRaw returns the native amount that will be added to liabilities because of fee and min-reserve additions @@ -254,15 +279,15 @@ func (sdex *SDEX) ComputeIncrementalNativeAmountRaw(isNewOffer bool) float64 { // createModifySellOffer is the main method that handles the logic of creating or modifying an offer, note that all offers are treated as sell offers in Stellar func (sdex *SDEX) createModifySellOffer(offer *horizon.Offer, selling horizon.Asset, buying horizon.Asset, price float64, amount float64, incrementalNativeAmountRaw float64) (*build.ManageOfferBuilder, error) { if price <= 0 { - return nil, fmt.Errorf("error: cannot create or modify offer, invalid price: %.7f", price) + return nil, fmt.Errorf("error: cannot create or modify offer, invalid price: %.8f", price) } if amount <= 0 { - return nil, fmt.Errorf("error: cannot create or modify offer, invalid amount: %.7f", amount) + return nil, fmt.Errorf("error: cannot create or modify offer, invalid amount: %.8f", amount) } // check liability limits on the asset being sold incrementalSell := amount - willOversell, e := sdex.willOversell(selling, amount) + willOversell, e := sdex.ieif.willOversell(selling, amount) if e != nil { return nil, e } @@ -272,7 +297,7 @@ func (sdex *SDEX) createModifySellOffer(offer *horizon.Offer, selling horizon.As // check trust limits on asset being bought incrementalBuy := price * amount - willOverbuy, e := sdex.willOverbuy(buying, incrementalBuy) + willOverbuy, e := sdex.ieif.willOverbuy(buying, incrementalBuy) if e != nil { return nil, e } @@ -281,16 +306,18 @@ func (sdex *SDEX) createModifySellOffer(offer *horizon.Offer, selling horizon.As } // explicitly check that we will not oversell XLM because of fee and min reserves - incrementalNativeAmountTotal := incrementalNativeAmountRaw - if selling.Type == utils.Native { - incrementalNativeAmountTotal += incrementalSell - } - willOversellNative, e := sdex.willOversellNative(incrementalNativeAmountTotal) - if e != nil { - return nil, e - } - if willOversellNative { - return nil, nil + if sdex.tradingOnSdex { + incrementalNativeAmountTotal := incrementalNativeAmountRaw + if selling.Type == utils.Native { + incrementalNativeAmountTotal += incrementalSell + } + willOversellNative, e := sdex.ieif.willOversellNative(incrementalNativeAmountTotal) + if e != nil { + return nil, e + } + if willOversellNative { + return nil, nil + } } stringPrice := strconv.FormatFloat(price, 'f', int(sdexOrderConstraints.PricePrecision), 64) @@ -314,80 +341,6 @@ func (sdex *SDEX) createModifySellOffer(offer *horizon.Offer, selling horizon.As return &result, nil } -// AddLiabilities updates the cached liabilities, units are in their respective assets -func (sdex *SDEX) AddLiabilities(selling horizon.Asset, buying horizon.Asset, incrementalSell float64, incrementalBuy float64, incrementalNativeAmountRaw float64) { - sdex.cachedLiabilities[selling] = Liabilities{ - Selling: sdex.cachedLiabilities[selling].Selling + incrementalSell, - Buying: sdex.cachedLiabilities[selling].Buying, - } - sdex.cachedLiabilities[buying] = Liabilities{ - Selling: sdex.cachedLiabilities[buying].Selling, - Buying: sdex.cachedLiabilities[buying].Buying + incrementalBuy, - } - sdex.cachedLiabilities[utils.NativeAsset] = Liabilities{ - Selling: sdex.cachedLiabilities[utils.NativeAsset].Selling + incrementalNativeAmountRaw, - Buying: sdex.cachedLiabilities[utils.NativeAsset].Buying, - } -} - -// willOversellNative returns willOversellNative, error -func (sdex *SDEX) willOversellNative(incrementalNativeAmount float64) (bool, error) { - nativeBal, _, minAccountBal, e := sdex.assetBalance(utils.NativeAsset) - if e != nil { - return false, e - } - nativeLiabilities, e := sdex.assetLiabilities(utils.NativeAsset) - if e != nil { - return false, e - } - - willOversellNative := incrementalNativeAmount > (nativeBal - minAccountBal - nativeLiabilities.Selling) - if willOversellNative { - log.Printf("we will oversell the native asset after considering fee and min reserves, incrementalNativeAmount = %.7f, nativeBal = %.7f, minAccountBal = %.7f, nativeLiabilities.Selling = %.7f\n", - incrementalNativeAmount, nativeBal, minAccountBal, nativeLiabilities.Selling) - } - return willOversellNative, nil -} - -// willOversell returns willOversell, error -func (sdex *SDEX) willOversell(asset horizon.Asset, amountSelling float64) (bool, error) { - bal, _, minAccountBal, e := sdex.assetBalance(asset) - if e != nil { - return false, e - } - liabilities, e := sdex.assetLiabilities(asset) - if e != nil { - return false, e - } - - willOversell := amountSelling > (bal - minAccountBal - liabilities.Selling) - if willOversell { - log.Printf("we will oversell the asset '%s', amountSelling = %.7f, bal = %.7f, minAccountBal = %.7f, liabilities.Selling = %.7f\n", - utils.Asset2String(asset), amountSelling, bal, minAccountBal, liabilities.Selling) - } - return willOversell, nil -} - -// willOverbuy returns willOverbuy, error -func (sdex *SDEX) willOverbuy(asset horizon.Asset, amountBuying float64) (bool, error) { - if asset.Type == utils.Native { - // you can never overbuy the native asset - return false, nil - } - - _, trust, _, e := sdex.assetBalance(asset) - if e != nil { - return false, e - } - liabilities, e := sdex.assetLiabilities(asset) - if e != nil { - return false, e - } - - willOverbuy := amountBuying > (trust - liabilities.Buying) - return willOverbuy, nil -} - // SubmitOps submits the passed in operations to the network asynchronously in a single transaction func (sdex *SDEX) SubmitOps(ops []build.TransactionMutator, asyncCallback func(hash string, e error)) error { sdex.incrementSeqNum() @@ -488,157 +441,6 @@ func (sdex *SDEX) invokeAsyncCallback(asyncCallback func(hash string, e error), }, nil) } -func (sdex *SDEX) logLiabilities(asset horizon.Asset, assetStr string) { - l, e := sdex.assetLiabilities(asset) - if e != nil { - log.Printf("could not fetch liability for asset '%s', error = %s\n", assetStr, e) - return - } - - bal, trust, minAccountBal, e := sdex.assetBalance(asset) - if e != nil { - log.Printf("cannot fetch balance for asset '%s', error = %s\n", assetStr, e) - return - } - - trustString := "math.MaxFloat64" - if trust != maxLumenTrust { - trustString = fmt.Sprintf("%.7f", trust) - } - log.Printf("asset=%s, balance=%.7f, trust=%s, minAccountBal=%.7f, buyingLiabilities=%.7f, sellingLiabilities=%.7f\n", - assetStr, bal, trustString, minAccountBal, l.Buying, l.Selling) -} - -// LogAllLiabilities logs the liabilities for the two assets along with the native asset -func (sdex *SDEX) LogAllLiabilities(assetBase horizon.Asset, assetQuote horizon.Asset) { - sdex.logLiabilities(assetBase, "base ") - sdex.logLiabilities(assetQuote, "quote ") - - if assetBase != utils.NativeAsset && assetQuote != utils.NativeAsset { - sdex.logLiabilities(utils.NativeAsset, "native") - } -} - -// RecomputeAndLogCachedLiabilities clears the cached liabilities and recomputes from the network before logging -func (sdex *SDEX) RecomputeAndLogCachedLiabilities(assetBase horizon.Asset, assetQuote horizon.Asset) { - sdex.cachedLiabilities = map[horizon.Asset]Liabilities{} - // reset cached balances too so we fetch fresh balances - sdex.ResetCachedBalances() - sdex.LogAllLiabilities(assetBase, assetQuote) -} - -// ResetCachedLiabilities resets the cache to include only the two assets passed in -func (sdex *SDEX) ResetCachedLiabilities(assetBase horizon.Asset, assetQuote horizon.Asset) error { - // re-compute the liabilities - sdex.cachedLiabilities = map[horizon.Asset]Liabilities{} - baseLiabilities, basePairLiabilities, e := sdex.pairLiabilities(assetBase, assetQuote) - if e != nil { - return e - } - quoteLiabilities, quotePairLiabilities, e := sdex.pairLiabilities(assetQuote, assetBase) - if e != nil { - return e - } - - // delete liability amounts related to all offers (filter on only those offers involving **both** assets in case the account is used by multiple bots) - sdex.cachedLiabilities[assetBase] = Liabilities{ - Buying: baseLiabilities.Buying - basePairLiabilities.Buying, - Selling: baseLiabilities.Selling - basePairLiabilities.Selling, - } - sdex.cachedLiabilities[assetQuote] = Liabilities{ - Buying: quoteLiabilities.Buying - quotePairLiabilities.Buying, - Selling: quoteLiabilities.Selling - quotePairLiabilities.Selling, - } - return nil -} - -// AvailableCapacity returns the buying and selling amounts available for a given asset -func (sdex *SDEX) AvailableCapacity(asset horizon.Asset, incrementalNativeAmountRaw float64) (*Liabilities, error) { - l, e := sdex.assetLiabilities(asset) - if e != nil { - return nil, e - } - - bal, trust, minAccountBal, e := sdex.assetBalance(asset) - if e != nil { - return nil, e - } - - // factor in cost of increase in minReserve and fee when calculating selling capacity of native asset - incrementalSellingLiability := 0.0 - if asset == utils.NativeAsset { - incrementalSellingLiability = incrementalNativeAmountRaw - } - - return &Liabilities{ - Buying: trust - l.Buying, - Selling: bal - minAccountBal - l.Selling - incrementalSellingLiability, - }, nil -} - -// assetLiabilities returns the liabilities for the asset -func (sdex *SDEX) assetLiabilities(asset horizon.Asset) (*Liabilities, error) { - if v, ok := sdex.cachedLiabilities[asset]; ok { - return &v, nil - } - - assetLiabilities, _, e := sdex._liabilities(asset, asset) // pass in the same asset, we ignore the returned object anyway - return assetLiabilities, e -} - -// pairLiabilities returns the liabilities for the asset along with the pairLiabilities -func (sdex *SDEX) pairLiabilities(asset horizon.Asset, otherAsset horizon.Asset) (*Liabilities, *Liabilities, error) { - assetLiabilities, pairLiabilities, e := sdex._liabilities(asset, otherAsset) - return assetLiabilities, pairLiabilities, e -} - -// liabilities returns the asset liabilities and pairLiabilities (non-nil only if the other asset is specified) -func (sdex *SDEX) _liabilities(asset horizon.Asset, otherAsset horizon.Asset) (*Liabilities, *Liabilities, error) { - // uses all offers for this trading account to accommodate sharing by other bots - offers, err := utils.LoadAllOffers(sdex.TradingAccount, sdex.API) - if err != nil { - assetString := utils.Asset2String(asset) - log.Printf("error: cannot load offers to compute liabilities for asset (%s): %s\n", assetString, err) - return nil, nil, err - } - - // liabilities for the asset - liabilities := Liabilities{} - // liabilities for the asset w.r.t. the trading pair - pairLiabilities := Liabilities{} - for _, offer := range offers { - if offer.Selling == asset { - offerAmt, err := sdex.ParseOfferAmount(offer.Amount) - if err != nil { - return nil, nil, err - } - liabilities.Selling += offerAmt - - if offer.Buying == otherAsset { - pairLiabilities.Selling += offerAmt - } - } else if offer.Buying == asset { - offerAmt, err := sdex.ParseOfferAmount(offer.Amount) - if err != nil { - return nil, nil, err - } - offerPrice, err := sdex.ParseOfferAmount(offer.Price) - if err != nil { - return nil, nil, err - } - buyingAmount := offerAmt * offerPrice - liabilities.Buying += buyingAmount - - if offer.Selling == otherAsset { - pairLiabilities.Buying += buyingAmount - } - } - } - - sdex.cachedLiabilities[asset] = liabilities - return &liabilities, &pairLiabilities, nil -} - // Assets returns the base and quote asset used by sdex func (sdex *SDEX) Assets() (baseAsset horizon.Asset, quoteAsset horizon.Asset, e error) { var ok bool diff --git a/plugins/sdexFeed.go b/plugins/sdexFeed.go index 9dec294d2..23a812a95 100644 --- a/plugins/sdexFeed.go +++ b/plugins/sdexFeed.go @@ -43,6 +43,8 @@ func makeSDEXFeed(url string) (*sdexFeed, error) { } sdex := MakeSDEX( privateSdexHackVar.API, + privateSdexHackVar.Ieif, + nil, "", "", "", diff --git a/plugins/sellSideStrategy.go b/plugins/sellSideStrategy.go index c98bb80f4..fc413d6ff 100644 --- a/plugins/sellSideStrategy.go +++ b/plugins/sellSideStrategy.go @@ -18,6 +18,7 @@ const actionBuy = "buy " type sellSideStrategy struct { sdex *SDEX orderConstraints *model.OrderConstraints + ieif *IEIF assetBase *horizon.Asset assetQuote *horizon.Asset levelsProvider api.LevelProvider @@ -39,6 +40,7 @@ var _ api.SideStrategy = &sellSideStrategy{} func makeSellSideStrategy( sdex *SDEX, orderConstraints *model.OrderConstraints, + ieif *IEIF, assetBase *horizon.Asset, assetQuote *horizon.Asset, levelsProvider api.LevelProvider, @@ -53,6 +55,7 @@ func makeSellSideStrategy( return &sellSideStrategy{ sdex: sdex, orderConstraints: orderConstraints, + ieif: ieif, assetBase: assetBase, assetQuote: assetQuote, levelsProvider: levelsProvider, @@ -86,7 +89,7 @@ func (s *sellSideStrategy) PruneExistingOffers(offers []horizon.Offer) ([]build. curPrice = 1 / curPrice } // base and quote here refers to the bot's base and quote, not the base and quote of the sellSideStrategy - log.Printf("offer | %s | level=%d | curPriceQuote=%.7f | curAmtBase=%.7f | pruning=%v\n", s.action, i+1, curPrice, curAmount, isPruning) + log.Printf("offer | %s | level=%d | curPriceQuote=%.8f | curAmtBase=%.8f | pruning=%v\n", s.action, i+1, curPrice, curAmount, isPruning) } return pruneOps, updatedOffers } @@ -217,6 +220,7 @@ func (s *sellSideStrategy) createPrecedingOffers( for i := 0; i < len(precedingLevels); i++ { if hitCapacityLimit { // we consider the ith level consumed because we don't want to create an offer for it anyway since we hit the capacity limit + log.Printf("hitCapacityLimit in preceding level loop, returning numLevelsConsumed=%d\n", i+1) return (i + 1), true, ops, newTopOffer, nil } @@ -326,22 +330,22 @@ func (s *sellSideStrategy) PostUpdate() error { // computeRemainderAmount returns sellingAmount, buyingAmount, error func (s *sellSideStrategy) computeRemainderAmount(incrementalSellAmount float64, incrementalBuyAmount float64, price float64, incrementalNativeAmountRaw float64) (float64, float64, error) { - availableSellingCapacity, e := s.sdex.AvailableCapacity(*s.assetBase, incrementalNativeAmountRaw) + availableSellingCapacity, e := s.ieif.AvailableCapacity(*s.assetBase, incrementalNativeAmountRaw) if e != nil { return 0, 0, e } - availableBuyingCapacity, e := s.sdex.AvailableCapacity(*s.assetQuote, incrementalNativeAmountRaw) + availableBuyingCapacity, e := s.ieif.AvailableCapacity(*s.assetQuote, incrementalNativeAmountRaw) if e != nil { return 0, 0, e } if availableSellingCapacity.Selling >= incrementalSellAmount && availableBuyingCapacity.Buying >= incrementalBuyAmount { - return 0, 0, fmt.Errorf("error: (programmer?) unable to create offer but available capacities were more than the attempted offer amounts, sellingCapacity=%.7f, incrementalSellAmount=%.7f, buyingCapacity=%.7f, incrementalBuyAmount=%.7f", + return 0, 0, fmt.Errorf("error: (programmer?) unable to create offer but available capacities were more than the attempted offer amounts, sellingCapacity=%.8f, incrementalSellAmount=%.8f, buyingCapacity=%.8f, incrementalBuyAmount=%.8f", availableSellingCapacity.Selling, incrementalSellAmount, availableBuyingCapacity.Buying, incrementalBuyAmount) } if availableSellingCapacity.Selling <= 0 || availableBuyingCapacity.Buying <= 0 { - log.Printf("computed remainder amount, no capacity available: availableSellingCapacity=%.7f, availableBuyingCapacity=%.7f\n", availableSellingCapacity.Selling, availableBuyingCapacity.Buying) + log.Printf("computed remainder amount, no capacity available: availableSellingCapacity=%.8f, availableBuyingCapacity=%.8f\n", availableSellingCapacity.Selling, availableBuyingCapacity.Buying) return 0, 0, nil } @@ -349,15 +353,15 @@ func (s *sellSideStrategy) computeRemainderAmount(incrementalSellAmount float64, if availableSellingCapacity.Selling*price < availableBuyingCapacity.Buying { sellingAmount := availableSellingCapacity.Selling buyingAmount := availableSellingCapacity.Selling * price - log.Printf("computed remainder amount, constrained by selling capacity, returning sellingAmount=%.7f, buyingAmount=%.7f\n", sellingAmount, buyingAmount) + log.Printf("computed remainder amount, constrained by selling capacity, returning sellingAmount=%.8f, buyingAmount=%.8f\n", sellingAmount, buyingAmount) return sellingAmount, buyingAmount, nil } else if availableBuyingCapacity.Buying/price < availableBuyingCapacity.Selling { sellingAmount := availableBuyingCapacity.Buying / price buyingAmount := availableBuyingCapacity.Buying - log.Printf("computed remainder amount, constrained by buying capacity, returning sellingAmount=%.7f, buyingAmount=%.7f\n", sellingAmount, buyingAmount) + log.Printf("computed remainder amount, constrained by buying capacity, returning sellingAmount=%.8f, buyingAmount=%.8f\n", sellingAmount, buyingAmount) return sellingAmount, buyingAmount, nil } - return 0, 0, fmt.Errorf("error: (programmer?) unable to constrain by either buying capacity or selling capacity, sellingCapacity=%.7f, buyingCapacity=%.7f, price=%.7f", + return 0, 0, fmt.Errorf("error: (programmer?) unable to constrain by either buying capacity or selling capacity, sellingCapacity=%.8f, buyingCapacity=%.8f, price=%.8f", availableSellingCapacity.Selling, availableBuyingCapacity.Buying, price) } @@ -378,7 +382,7 @@ func (s *sellSideStrategy) createSellLevel(index int, targetPrice model.Number, priceLogged = 1 / price amountLogged = amount * price } - log.Printf("%s | create | level=%d | priceQuote=%.7f | amtBase=%.7f\n", s.action, index+1, priceLogged, amountLogged) + log.Printf("%s | create | level=%d | priceQuote=%.8f | amtBase=%.8f\n", s.action, index+1, priceLogged, amountLogged) return s.sdex.CreateSellOffer(*s.assetBase, *s.assetQuote, price, amount, incrementalNativeAmountRaw) }, *s.assetBase, @@ -404,7 +408,7 @@ func (s *sellSideStrategy) modifySellLevel(offers []horizon.Offer, index int, ta incrementalNativeAmountRaw := s.sdex.ComputeIncrementalNativeAmountRaw(false) if !priceTrigger && !amountTrigger { // always add back the current offer in the cached liabilities when we don't modify it - s.sdex.AddLiabilities(offers[index].Selling, offers[index].Buying, curAmount, curAmount*curPrice, incrementalNativeAmountRaw) + s.ieif.AddLiabilities(offers[index].Selling, offers[index].Buying, curAmount, curAmount*curPrice, incrementalNativeAmountRaw) offerPrice := model.NumberFromFloat(curPrice, s.orderConstraints.PricePrecision) return offerPrice, false, nil, nil } @@ -435,7 +439,7 @@ func (s *sellSideStrategy) modifySellLevel(offers []horizon.Offer, index int, ta lowestPriceLogged = 1 / highestPrice highestPriceLogged = 1 / lowestPrice } - log.Printf("%s | modify | level=%d | targetPriceQuote=%.7f | targetAmtBase=%.7f | curPriceQuote=%.7f | lowPriceQuote=%.7f | highPriceQuote=%.7f | curAmtBase=%.7f | minAmtBase=%.7f | maxAmtBase=%.7f\n", + log.Printf("%s | modify | level=%d | targetPriceQuote=%.8f | targetAmtBase=%.8f | curPriceQuote=%.8f | lowPriceQuote=%.8f | highPriceQuote=%.8f | curAmtBase=%.8f | minAmtBase=%.8f | maxAmtBase=%.8f\n", s.action, index+1, priceLogged, amountLogged, curPriceLogged, lowestPriceLogged, highestPriceLogged, curAmountLogged, minAmountLogged, maxAmountLogged) return s.sdex.ModifySellOffer(offers[index], price, amount, incrementalNativeAmountRaw) }, @@ -463,7 +467,7 @@ func (s *sellSideStrategy) placeOrderWithRetry( // op is nil only when we hit capacity limits if op != nil { // update the cached liabilities if we create a valid operation to create an offer - s.sdex.AddLiabilities(assetBase, assetQuote, incrementalSellAmount, incrementalBuyAmount, incrementalNativeAmountRaw) + s.ieif.AddLiabilities(assetBase, assetQuote, incrementalSellAmount, incrementalBuyAmount, incrementalNativeAmountRaw) return false, op, nil } @@ -483,10 +487,10 @@ func (s *sellSideStrategy) placeOrderWithRetry( if op != nil { // update the cached liabilities if we create a valid operation to create an offer - s.sdex.AddLiabilities(assetBase, assetQuote, newSellingAmount, newBuyingAmount, incrementalNativeAmountRaw) + s.ieif.AddLiabilities(assetBase, assetQuote, newSellingAmount, newBuyingAmount, incrementalNativeAmountRaw) return true, op, nil } - return true, nil, fmt.Errorf("error: (programmer?) unable to place offer with the new (reduced) selling and buying amounts, oldSellingAmount=%.7f, newSellingAmount=%.7f, oldBuyingAmount=%.7f, newBuyingAmount=%.7f", + return true, nil, fmt.Errorf("error: (programmer?) unable to place offer with the new (reduced) selling and buying amounts, oldSellingAmount=%.8f, newSellingAmount=%.8f, oldBuyingAmount=%.8f, newBuyingAmount=%.8f", incrementalSellAmount, newSellingAmount, incrementalBuyAmount, newBuyingAmount) } diff --git a/plugins/sellStrategy.go b/plugins/sellStrategy.go index d1aec1ab3..e815aa0f4 100644 --- a/plugins/sellStrategy.go +++ b/plugins/sellStrategy.go @@ -34,6 +34,7 @@ func (c sellConfig) String() string { func makeSellStrategy( sdex *SDEX, pair *model.TradingPair, + ieif *IEIF, assetBase *horizon.Asset, assetQuote *horizon.Asset, config *sellConfig, @@ -57,6 +58,7 @@ func makeSellStrategy( sellSideStrategy := makeSellSideStrategy( sdex, orderConstraints, + ieif, assetBase, assetQuote, makeStaticSpreadLevelProvider(config.Levels, config.AmountOfABase, offset, pf, orderConstraints), diff --git a/support/sdk/ccxt.go b/support/sdk/ccxt.go index 70169e101..e48000ba1 100644 --- a/support/sdk/ccxt.go +++ b/support/sdk/ccxt.go @@ -7,6 +7,7 @@ import ( "log" "net/http" "reflect" + "strconv" "strings" "github.com/mitchellh/mapstructure" @@ -36,6 +37,9 @@ type CcxtMarket struct { Price struct { Min float64 `json:"min"` } `json:"price"` + Cost struct { + Min float64 `json:"min"` + } `json:"cost"` } `json:"limits"` Precision struct { Amount int8 `json:"amount"` @@ -338,17 +342,27 @@ func (c *Ccxt) FetchTrades(tradingPair string) ([]CcxtTrade, error) { return output, nil } -func (c *Ccxt) FetchMyTrades(tradingPair string) ([]CcxtTrade, error) { +func (c *Ccxt) FetchMyTrades(tradingPair string, limit int, maybeCursorStart interface{}) ([]CcxtTrade, error) { e := c.symbolExists(tradingPair) if e != nil { return nil, fmt.Errorf("symbol does not exist: %s", e) } // marshal input data - data, e := json.Marshal(&[]string{tradingPair}) - if e != nil { - return nil, fmt.Errorf("error marshaling input (tradingPair=%s) as an array for exchange '%s': %s", tradingPair, c.exchangeName, e) + var data []byte + if maybeCursorStart == nil { + data, e = json.Marshal(&[]string{tradingPair, strconv.Itoa(limit)}) + if e != nil { + return nil, fmt.Errorf("error marshaling input (tradingPair=%s) as an array for exchange '%s': %s", tradingPair, c.exchangeName, e) + } + } else { + cursorString := fmt.Sprintf("%v", maybeCursorStart) + data, e = json.Marshal(&[]string{tradingPair, cursorString, strconv.Itoa(limit)}) + if e != nil { + return nil, fmt.Errorf("error marshaling input (tradingPair=%s, maybeCursorStart=%v) as an array for exchange '%s': %s", tradingPair, maybeCursorStart, c.exchangeName, e) + } } + // fetch trades for symbol url := c.ccxtBaseURL + pathExchanges + "/" + c.exchangeName + "/" + c.instanceName + "/fetchMyTrades" // decode generic data (see "https://blog.golang.org/json-and-go#TOC_4.") diff --git a/support/sdk/ccxt_test.go b/support/sdk/ccxt_test.go index 5f0527d20..2909fe91a 100644 --- a/support/sdk/ccxt_test.go +++ b/support/sdk/ccxt_test.go @@ -283,26 +283,30 @@ func TestFetchMyTrades(t *testing.T) { bittrexFields := []string{"amount", "datetime", "id", "price", "side", "symbol", "timestamp", "type"} for _, k := range []struct { - exchangeName string - tradingPair string - expectedFields []string - apiKey api.ExchangeAPIKey + exchangeName string + tradingPair string + maybeCursorStart interface{} + expectedFields []string + apiKey api.ExchangeAPIKey }{ { - exchangeName: "kraken", - tradingPair: "BTC/USD", - expectedFields: krakenFields, - apiKey: api.ExchangeAPIKey{}, + exchangeName: "kraken", + tradingPair: "BTC/USD", + maybeCursorStart: nil, + expectedFields: krakenFields, + apiKey: api.ExchangeAPIKey{}, }, { - exchangeName: "binance", - tradingPair: "XLM/USDT", - expectedFields: binanceFields, - apiKey: api.ExchangeAPIKey{}, + exchangeName: "binance", + tradingPair: "XLM/USDT", + maybeCursorStart: nil, + expectedFields: binanceFields, + apiKey: api.ExchangeAPIKey{}, }, { - exchangeName: "bittrex", - tradingPair: "XLM/BTC", - expectedFields: bittrexFields, - apiKey: api.ExchangeAPIKey{}, + exchangeName: "bittrex", + tradingPair: "XLM/BTC", + maybeCursorStart: nil, + expectedFields: bittrexFields, + apiKey: api.ExchangeAPIKey{}, }, } { tradingPairString := strings.Replace(k.tradingPair, "/", "_", -1) @@ -313,7 +317,7 @@ func TestFetchMyTrades(t *testing.T) { return } - trades, e := c.FetchMyTrades(k.tradingPair) + trades, e := c.FetchMyTrades(k.tradingPair, 50, k.maybeCursorStart) if e != nil { assert.Fail(t, fmt.Sprintf("error when fetching my trades: %s", e)) return diff --git a/support/utils/functions.go b/support/utils/functions.go index c66cf86d2..5ad089ee1 100644 --- a/support/utils/functions.go +++ b/support/utils/functions.go @@ -59,6 +59,16 @@ func AmountStringAsFloat(amount string) float64 { return p } +// ParseOfferAmount is a convenience method to parse an offer amount +func ParseOfferAmount(amt string) (float64, error) { + offerAmt, e := strconv.ParseFloat(amt, 64) + if e != nil { + log.Printf("error parsing offer amount: %s\n", e) + return -1, e + } + return offerAmt, nil +} + // GetPrice gets the price from an offer func GetPrice(offer horizon.Offer) float64 { if int64(offer.PriceR.D) == 0 { diff --git a/trader/config.go b/trader/config.go index 013d18435..16996d6ca 100644 --- a/trader/config.go +++ b/trader/config.go @@ -40,7 +40,13 @@ type BotConfig struct { GoogleClientID string `valid:"-" toml:"GOOGLE_CLIENT_ID"` GoogleClientSecret string `valid:"-" toml:"GOOGLE_CLIENT_SECRET"` AcceptableEmails string `valid:"-" toml:"ACCEPTABLE_GOOGLE_EMAILS"` + TradingExchange string `valid:"-" toml:"TRADING_EXCHANGE"` + ExchangeAPIKeys []struct { + Key string `valid:"-" toml:"KEY"` + Secret string `valid:"-" toml:"SECRET"` + } `valid:"-" toml:"EXCHANGE_API_KEYS"` + // initialized later tradingAccount *string sourceAccount *string // can be nil assetBase horizon.Asset @@ -50,8 +56,13 @@ type BotConfig struct { // String impl. func (b BotConfig) String() string { return utils.StructString(b, map[string]func(interface{}) interface{}{ - "SOURCE_SECRET_SEED": utils.SecretKey2PublicKey, - "TRADING_SECRET_SEED": utils.SecretKey2PublicKey, + "EXCHANGE_API_KEYS": 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 9b27097bb..b1f719184 100644 --- a/trader/trader.go +++ b/trader/trader.go @@ -21,10 +21,12 @@ const maxLumenTrust float64 = math.MaxFloat64 // Trader represents a market making bot, which is composed of various parts include the strategy and various APIs. type Trader struct { api *horizon.Client + ieif *plugins.IEIF assetBase horizon.Asset assetQuote horizon.Asset tradingAccount string sdex *plugins.SDEX + exchangeShim api.ExchangeShim strat api.Strategy // the instance of this bot is bound to this strategy timeController api.TimeController deleteCyclesThreshold int64 @@ -49,11 +51,13 @@ type Trader struct { // MakeBot is the factory method for the Trader struct func MakeBot( api *horizon.Client, + ieif *plugins.IEIF, assetBase horizon.Asset, assetQuote horizon.Asset, tradingPair *model.TradingPair, tradingAccount string, sdex *plugins.SDEX, + exchangeShim api.ExchangeShim, strat api.Strategy, timeController api.TimeController, deleteCyclesThreshold int64, @@ -71,10 +75,12 @@ func MakeBot( return &Trader{ api: api, + ieif: ieif, assetBase: assetBase, assetQuote: assetQuote, tradingAccount: tradingAccount, sdex: sdex, + exchangeShim: exchangeShim, strat: strat, timeController: timeController, deleteCyclesThreshold: deleteCyclesThreshold, @@ -141,7 +147,7 @@ func (t *Trader) deleteAllOffers() { log.Printf("created %d operations to delete offers\n", len(dOps)) if len(dOps) > 0 { - e := t.sdex.SubmitOps(dOps, nil) + e := t.exchangeShim.SubmitOps(dOps, nil) if e != nil { log.Println(e) return @@ -157,11 +163,11 @@ func (t *Trader) update() { // TODO 2 streamline the request data instead of caching // reset cache of balances for this update cycle to reduce redundant requests to calculate asset balances - t.sdex.ResetCachedBalances() + t.sdex.IEIF().ResetCachedBalances() // reset and recompute cached liabilities for this update cycle - e = t.sdex.ResetCachedLiabilities(t.assetBase, t.assetQuote) + e = t.sdex.IEIF().ResetCachedLiabilities(t.assetBase, t.assetQuote) log.Printf("liabilities after resetting\n") - t.sdex.LogAllLiabilities(t.assetBase, t.assetQuote) + t.sdex.IEIF().LogAllLiabilities(t.assetBase, t.assetQuote) if e != nil { log.Println(e) t.deleteAllOffers() @@ -181,7 +187,7 @@ func (t *Trader) update() { pruneOps, t.buyingAOffers, t.sellingAOffers = t.strat.PruneExistingOffers(t.buyingAOffers, t.sellingAOffers) log.Printf("created %d operations to prune excess offers\n", len(pruneOps)) if len(pruneOps) > 0 { - e = t.sdex.SubmitOps(pruneOps, nil) + e = t.exchangeShim.SubmitOps(pruneOps, nil) if e != nil { log.Println(e) t.deleteAllOffers() @@ -191,11 +197,11 @@ func (t *Trader) update() { // TODO 2 streamline the request data instead of caching // reset cache of balances for this update cycle to reduce redundant requests to calculate asset balances - t.sdex.ResetCachedBalances() + t.sdex.IEIF().ResetCachedBalances() // reset and recompute cached liabilities for this update cycle - e = t.sdex.ResetCachedLiabilities(t.assetBase, t.assetQuote) + e = t.sdex.IEIF().ResetCachedLiabilities(t.assetBase, t.assetQuote) log.Printf("liabilities after resetting\n") - t.sdex.LogAllLiabilities(t.assetBase, t.assetQuote) + t.sdex.IEIF().LogAllLiabilities(t.assetBase, t.assetQuote) if e != nil { log.Println(e) t.deleteAllOffers() @@ -204,11 +210,11 @@ func (t *Trader) update() { ops, e := t.strat.UpdateWithOps(t.buyingAOffers, t.sellingAOffers) log.Printf("liabilities at the end of a call to UpdateWithOps\n") - t.sdex.LogAllLiabilities(t.assetBase, t.assetQuote) + t.sdex.IEIF().LogAllLiabilities(t.assetBase, t.assetQuote) if e != nil { log.Println(e) log.Printf("liabilities (force recomputed) after encountering an error after a call to UpdateWithOps\n") - t.sdex.RecomputeAndLogCachedLiabilities(t.assetBase, t.assetQuote) + t.sdex.IEIF().RecomputeAndLogCachedLiabilities(t.assetBase, t.assetQuote) t.deleteAllOffers() return } @@ -224,7 +230,7 @@ func (t *Trader) update() { log.Printf("created %d operations to update existing offers\n", len(ops)) if len(ops) > 0 { - e = t.sdex.SubmitOps(ops, nil) + e = t.exchangeShim.SubmitOps(ops, nil) if e != nil { log.Println(e) t.deleteAllOffers() @@ -245,47 +251,37 @@ func (t *Trader) update() { func (t *Trader) load() { // load the maximum amounts we can offer for each asset - account, e := t.api.LoadAccount(t.tradingAccount) + baseBalance, e := t.exchangeShim.GetBalanceHack(t.assetBase) + if e != nil { + log.Println(e) + return + } + quoteBalance, e := t.exchangeShim.GetBalanceHack(t.assetQuote) if e != nil { log.Println(e) return } - var maxA float64 - var maxB float64 - var trustA float64 - var trustB float64 - var trustAString string - var trustBString string - for _, balance := range account.Balances { - trust := maxLumenTrust - trustString := "math.MaxFloat64" - if balance.Asset.Type != utils.Native { - trust = utils.AmountStringAsFloat(balance.Limit) - trustString = fmt.Sprintf("%.7f", trust) - } + t.maxAssetA = baseBalance.Balance + t.maxAssetB = quoteBalance.Balance + t.trustAssetA = baseBalance.Trust + t.trustAssetB = quoteBalance.Trust - if utils.AssetsEqual(balance.Asset, t.assetBase) { - maxA = utils.AmountStringAsFloat(balance.Balance) - trustA = trust - trustAString = trustString - } else if utils.AssetsEqual(balance.Asset, t.assetQuote) { - maxB = utils.AmountStringAsFloat(balance.Balance) - trustB = trust - trustBString = trustString - } + trustAString := "math.MaxFloat64" + if t.assetBase.Type != utils.Native { + trustAString = fmt.Sprintf("%.8f", t.trustAssetA) + } + trustBString := "math.MaxFloat64" + if t.assetQuote.Type != utils.Native { + trustBString = fmt.Sprintf("%.8f", t.trustAssetB) } - t.maxAssetA = maxA - t.maxAssetB = maxB - t.trustAssetA = trustA - t.trustAssetB = trustB - log.Printf(" (base) assetA=%s, maxA=%.7f, trustA=%s\n", utils.Asset2String(t.assetBase), maxA, trustAString) - log.Printf("(quote) assetB=%s, maxB=%.7f, trustB=%s\n", utils.Asset2String(t.assetQuote), maxB, trustBString) + log.Printf(" (base) assetA=%s, maxA=%.8f, trustA=%s\n", utils.Asset2String(t.assetBase), t.maxAssetA, trustAString) + log.Printf("(quote) assetB=%s, maxB=%.8f, trustB=%s\n", utils.Asset2String(t.assetQuote), t.maxAssetB, trustBString) } func (t *Trader) loadExistingOffers() { - offers, e := utils.LoadAllOffers(t.tradingAccount, t.api) + offers, e := t.exchangeShim.LoadOffersHack() if e != nil { log.Println(e) return