From d1fb1a2c8c9620dadff4109bbd4451a9381e1af3 Mon Sep 17 00:00:00 2001 From: Reid McCamish <43561569+Reidmcc@users.noreply.github.com> Date: Tue, 2 Oct 2018 12:33:12 -0500 Subject: [PATCH] Catch up to root master (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * cap3: 1 - implement liabilities for XLM * cap3: 2 - integrate liabilities into willOversell and willOverbuy methods * cap3: 3 - remove fractionalReserveMultiplier cli arg * cap3: 4 - offers in the same tx will contribute to liabilities, incorporate into cachedLiabilities * cap3: 5 - handle case of setting incrementalBuy for native asset * cap3: 6 - refactored willOversell and willOverbuy to extract common offer logic * cap3: 7 - added support for checking XLM fee and min reserves * cap3: 8 - update ordering of operations in strategies (sellSideStrategy, mirrorStrategy) when not all offers can be placed — always place inside orders first * cap3: 9 - native fee inclusion checks source/trader account usage * cap3: 10 - delete offer if nothing was created and we were planning to modify an existing offer * cap3: 11 - prepend deleteOps so we "free" up our liabilities capacity to place the new/modified offers * cap3: 12 - better error propagation from sdex.createModifySellOffer * cap3: 13 - extracted liability updates after placing/modifying offers to callers in sellSideStrategy and mirrorStrategy * cap3: 14 - prepend deleteOps for mirror strategy too * cap3: 15 - log liabilities in trader.go after resetting and after updating ops * cap3: 16 - add liabilities in mirror strat * cap3: 17 - added support for partial offers in sellSideStrategy, refactored updateSellLevel * cap3: 18 - updated comment when resetting cached liabilities * cap3: 19 - add current offer amounts to liabilities when not modifying an offer * cap3: 20 - add a caching layer for asset balances to reduce requests * cap3: 21 - cleaner logging of asset balance and trust amounts * cap3: 22 - let new capacity constraint system handle max limits for sellSideStrategy * cap3: 23 - reset caches before pruning and updating operations * cap3: 24 - update CHANGELOG * new release: v1.0.0-rc2 * expand assets allowed to use with the Kraken exchange, fixes #13 * new release: v1.0.0-rc3 * remove default rate offset percent from sample strategy config files * Print strat (#15) * Update trade.go * Update trade.go * Update trade.go * enable Travis CI, closes #17 (#18) * enable Travis CI * go_import_path * travis: ./bin/kelp version * doc clarify full path needed for configs (#16) --- .travis.yml | 17 + CHANGELOG.md | 25 +- README.md | 21 +- cmd/terminate.go | 1 - cmd/trade.go | 3 - examples/configs/trader/sample_buysell.cfg | 2 +- examples/configs/trader/sample_sell.cfg | 2 +- model/assets.go | 105 ++++- model/number.go | 8 + plugins/mirrorStrategy.go | 115 ++++-- plugins/sdex.go | 421 ++++++++++++++++----- plugins/sellSideStrategy.go | 204 ++++++++-- support/utils/functions.go | 12 + trader/trader.go | 66 +++- 14 files changed, 790 insertions(+), 212 deletions(-) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..21fef2837 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,17 @@ +language: go + +go_import_path: github.com/lightyeario/kelp + +go: +- "1.10.x" +- "1.11" + +before_install: +- curl https://glide.sh/get | sh + +install: +- glide install + +script: +- ./scripts/build.sh +- ./bin/kelp version diff --git a/CHANGELOG.md b/CHANGELOG.md index 6860368b0..5876a5559 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,23 +4,38 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). + ## [Unreleased] ### Added -- This CHANGELOG file ### Changed -- Updated dependency `github.com/stellar/go` to latest version `5bbd27814a3ffca9aeffcbd75a09a6164959776a`, run `glide up` to update this dependency ### Deprecated ### Removed ### Fixed -- If `SOURCE_SECRET_SEED` is missing or empty then the bot will not crash now. ### Security +## [v1.0.0-rc3] - 2018-09-29 + +### Added +- support for all currencies available on Kraken + +## [v1.0.0-rc2] - 2018-09-28 + +### Added +- This CHANGELOG file + +### Changed +- Updated dependency `github.com/stellar/go` to latest version `5bbd27814a3ffca9aeffcbd75a09a6164959776a`, run `glide up` to update this dependency + +### Fixed +- If `SOURCE_SECRET_SEED` is missing or empty then the bot will not crash now. +- support for [CAP-0003](https://github.com/stellar/stellar-protocol/blob/master/core/cap-0003.md) introduced in stellar-core protocol v10 ([issue #2](https://github.com/lightyeario/kelp/issues/2)) + ## v1.0.0-rc1 - 2018-08-13 @@ -31,4 +46,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Configuration file based approach to setting up a bot - Documentation on existing capabilities -[Unreleased]: https://github.com/lightyeario/kelp/compare/v1.0.0-rc1...HEAD +[Unreleased]: https://github.com/lightyeario/kelp/compare/v1.0.0-rc3...HEAD +[v1.0.0-rc3]: https://github.com/lightyeario/kelp/compare/v1.0.0-rc2...v1.0.0-rc3 +[v1.0.0-rc2]: https://github.com/lightyeario/kelp/compare/v1.0.0-rc1...v1.0.0-rc2 diff --git a/README.md b/README.md index 2efd111c6..b9497fcce 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![Github All Releases](https://img.shields.io/github/downloads/lightyeario/kelp/total.svg?style=for-the-badge)][github-releases] [![license](https://img.shields.io/badge/License-Apache%202.0-blue.svg?style=for-the-badge&longCache=true)][license-apache] +[![Build Status](https://travis-ci.com/lightyeario/kelp.svg?branch=master)](https://travis-ci.com/lightyeario/kelp) [![GitHub issues](https://img.shields.io/github/issues/lightyeario/kelp.svg?style=flat-square&longCache=true)][github-issues] [![GitHub closed issues](https://img.shields.io/github/issues-closed/lightyeario/kelp.svg?style=flat-square&longCache=true)][github-issues-closed] [![GitHub pull requests](https://img.shields.io/github/issues-pr/lightyeario/kelp.svg?style=flat-square&longCache=true)][github-pulls] @@ -54,24 +55,24 @@ There is **one** binary associated with this project: `kelp`. Once the binary is You can find the pre-compiled binary for your platform from the [Github Releases Page][github-releases]. -Here is a list of binaries for the most recent release **v1.0.0-rc1**: +Here is a list of binaries for the most recent release **v1.0.0-rc3**: | Platform | Architecture | Binary File Name | | -------------- | ------------ | ---------------- | -| MacOS (Darwin) | 64-bit | [kelp-v1.0.0-rc1-darwin-amd64.tar](https://github.com/lightyeario/kelp/releases/download/v1.0.0-rc1/kelp-v1.0.0-rc1-darwin-amd64.tar) | -| Windows | 64-bit | [kelp-v1.0.0-rc1-windows-amd64.tar](https://github.com/lightyeario/kelp/releases/download/v1.0.0-rc1/kelp-v1.0.0-rc1-windows-amd64.tar) | -| Linux | 64-bit | [kelp-v1.0.0-rc1-linux-amd64.tar](https://github.com/lightyeario/kelp/releases/download/v1.0.0-rc1/kelp-v1.0.0-rc1-linux-amd64.tar) | -| Linux | 64-bit arm | [kelp-v1.0.0-rc1-linux-arm64.tar](https://github.com/lightyeario/kelp/releases/download/v1.0.0-rc1/kelp-v1.0.0-rc1-linux-arm64.tar) | -| Linux | 32-bit arm5 | [kelp-v1.0.0-rc1-linux-arm5.tar](https://github.com/lightyeario/kelp/releases/download/v1.0.0-rc1/kelp-v1.0.0-rc1-linux-arm5.tar) | -| Linux | 32-bit arm6 | [kelp-v1.0.0-rc1-linux-arm6.tar](https://github.com/lightyeario/kelp/releases/download/v1.0.0-rc1/kelp-v1.0.0-rc1-linux-arm6.tar) | -| Linux | 32-bit arm7 | [kelp-v1.0.0-rc1-linux-arm7.tar](https://github.com/lightyeario/kelp/releases/download/v1.0.0-rc1/kelp-v1.0.0-rc1-linux-arm7.tar) | +| MacOS (Darwin) | 64-bit | [kelp-v1.0.0-rc3-darwin-amd64.tar](https://github.com/lightyeario/kelp/releases/download/v1.0.0-rc3/kelp-v1.0.0-rc3-darwin-amd64.tar) | +| Windows | 64-bit | [kelp-v1.0.0-rc3-windows-amd64.tar](https://github.com/lightyeario/kelp/releases/download/v1.0.0-rc3/kelp-v1.0.0-rc3-windows-amd64.tar) | +| Linux | 64-bit | [kelp-v1.0.0-rc3-linux-amd64.tar](https://github.com/lightyeario/kelp/releases/download/v1.0.0-rc3/kelp-v1.0.0-rc3-linux-amd64.tar) | +| Linux | 64-bit arm | [kelp-v1.0.0-rc3-linux-arm64.tar](https://github.com/lightyeario/kelp/releases/download/v1.0.0-rc3/kelp-v1.0.0-rc3-linux-arm64.tar) | +| Linux | 32-bit arm5 | [kelp-v1.0.0-rc3-linux-arm5.tar](https://github.com/lightyeario/kelp/releases/download/v1.0.0-rc3/kelp-v1.0.0-rc3-linux-arm5.tar) | +| Linux | 32-bit arm6 | [kelp-v1.0.0-rc3-linux-arm6.tar](https://github.com/lightyeario/kelp/releases/download/v1.0.0-rc3/kelp-v1.0.0-rc3-linux-arm6.tar) | +| Linux | 32-bit arm7 | [kelp-v1.0.0-rc3-linux-arm7.tar](https://github.com/lightyeario/kelp/releases/download/v1.0.0-rc3/kelp-v1.0.0-rc3-linux-arm7.tar) | -After you _untar_ the downloaded file, change to the generated directory (`kelp-v1.0.0-rc1`) and invoke the `kelp` binary. +After you _untar_ the downloaded file, change to the generated directory (`kelp-v1.0.0-rc3`) and invoke the `kelp` binary. Here's an example to get you started (replace `filename` with the name of the file that you download): tar xvf filename - cd kelp-v1.0.0-rc1 + cd kelp-v1.0.0-rc3 ./kelp To run the bot in simulation mode, try this command: diff --git a/cmd/terminate.go b/cmd/terminate.go index a055b94b2..4ea9dc60b 100644 --- a/cmd/terminate.go +++ b/cmd/terminate.go @@ -46,7 +46,6 @@ func init() { *configFile.TradingAccount, utils.ParseNetwork(configFile.HORIZON_URL), -1, // not needed here - -1, // not needed here false, ) terminator := terminator.MakeTerminator(client, sdex, *configFile.TradingAccount, configFile.TICK_INTERVAL_SECONDS, configFile.ALLOW_INACTIVE_MINUTES) diff --git a/cmd/trade.go b/cmd/trade.go index 40cb3c424..34d42fd0d 100644 --- a/cmd/trade.go +++ b/cmd/trade.go @@ -43,13 +43,11 @@ func init() { strategy := tradeCmd.Flags().StringP("strategy", "s", "", "(required) type of strategy to run") stratConfigPath := tradeCmd.Flags().StringP("stratConf", "f", "", "strategy config file path") // long-only flags - fractionalReserveMultiplier := tradeCmd.Flags().Int8("fractionalReserveMultiplier", 1, "fractional multiplier for XLM reserves") operationalBuffer := tradeCmd.Flags().Float64("operationalBuffer", 20, "buffer of native XLM to maintain beyond minimum account balance requirement") simMode := tradeCmd.Flags().Bool("sim", false, "simulate the bot's actions without placing any trades") requiredFlag("botConf") requiredFlag("strategy") - hiddenFlag("fractionalReserveMultiplier") hiddenFlag("operationalBuffer") tradeCmd.Flags().SortFlags = false @@ -87,7 +85,6 @@ func init() { botConfig.SourceAccount(), botConfig.TradingAccount(), utils.ParseNetwork(botConfig.HORIZON_URL), - *fractionalReserveMultiplier, *operationalBuffer, *simMode, ) diff --git a/examples/configs/trader/sample_buysell.cfg b/examples/configs/trader/sample_buysell.cfg index aa3338725..79b23b94b 100644 --- a/examples/configs/trader/sample_buysell.cfg +++ b/examples/configs/trader/sample_buysell.cfg @@ -37,7 +37,7 @@ AMOUNT_TOLERANCE=0.001 # how much percent to offset your rates by, specified as a decimal (ex: 0.05 = 5%). Can be used in conjunction with RATE_OFFSET below. # A positive value indicates that your base asset (ASSET_A) has a higher rate than the rate received from your price feed # A negative value indicates that your base asset (ASSET_A) has a lower rate than the rate received from your price feed -RATE_OFFSET_PERCENT=0.05 +RATE_OFFSET_PERCENT=0.0 # how much to offset your rates by, specified in number of units of the quote asset (ASSET_B) as a decimal. # Can be used in conjunction with RATE_OFFSET_PERCENT above. # A positive value indicates that your base asset (ASSET_A) has a higher rate than the rate received from your price feed diff --git a/examples/configs/trader/sample_sell.cfg b/examples/configs/trader/sample_sell.cfg index d8f00d434..3f74d49e5 100644 --- a/examples/configs/trader/sample_sell.cfg +++ b/examples/configs/trader/sample_sell.cfg @@ -39,7 +39,7 @@ AMOUNT_TOLERANCE=0.001 # how much percent to offset your rates by, specified as a decimal (ex: 0.05 = 5%). Can be used in conjunction with RATE_OFFSET below. # A positive value indicates that your base asset (ASSET_A) has a higher rate than the rate received from your price feed # A negative value indicates that your base asset (ASSET_A) has a lower rate than the rate received from your price feed -RATE_OFFSET_PERCENT=0.05 +RATE_OFFSET_PERCENT=0.0 # how much to offset your rates by, specified in number of units of the quote asset (ASSET_B) as a decimal. # Can be used in conjunction with RATE_OFFSET_PERCENT above. # A positive value indicates that your base asset (ASSET_A) has a higher rate than the rate received from your price feed diff --git a/model/assets.go b/model/assets.go index e60815ec1..9050fe9d3 100644 --- a/model/assets.go +++ b/model/assets.go @@ -11,12 +11,35 @@ type Asset string // this is the list of assets understood by the bot. // This string can be converted by the specific exchange adapter as is needed by the exchange's API const ( - XLM Asset = "XLM" - BTC Asset = "BTC" - USD Asset = "USD" - ETH Asset = "ETH" - LTC Asset = "LTC" - REP Asset = "REP" + XLM Asset = "XLM" + BTC Asset = "BTC" + USD Asset = "USD" + ETH Asset = "ETH" + LTC Asset = "LTC" + REP Asset = "REP" + ADA Asset = "ADA" + BCH Asset = "BCH" + DASH Asset = "DASH" + EOS Asset = "EOS" + GNO Asset = "GNO" + FEE Asset = "FEE" + QTUM Asset = "QTUM" + USDT Asset = "USDT" + DAO Asset = "DAO" + ETC Asset = "ETC" + ICN Asset = "ICN" + MLN Asset = "MLN" + NMC Asset = "NMC" + XDG Asset = "XDG" + XMR Asset = "XMR" + XRP Asset = "XRP" + XVN Asset = "XVN" + ZEC Asset = "ZEC" + CAD Asset = "CAD" + EUR Asset = "EUR" + GBP Asset = "GBP" + JPY Asset = "JPY" + KRW Asset = "KRW" ) // AssetConverter converts to and from the asset type, it is specific to an exchange @@ -67,20 +90,66 @@ func (c AssetConverter) MustFromString(s string) Asset { // Display is a basic converter for display purposes var Display = makeAssetConverter(map[Asset]string{ - XLM: string(XLM), - BTC: string(BTC), - USD: string(USD), - ETH: string(ETH), - LTC: string(LTC), - REP: string(REP), + XLM: string(XLM), + BTC: string(BTC), + USD: string(USD), + ETH: string(ETH), + LTC: string(LTC), + REP: string(REP), + ADA: string(ADA), + BCH: string(BCH), + DASH: string(DASH), + EOS: string(EOS), + GNO: string(GNO), + FEE: string(FEE), + QTUM: string(QTUM), + USDT: string(USDT), + DAO: string(DAO), + ETC: string(ETC), + ICN: string(ICN), + MLN: string(MLN), + NMC: string(NMC), + XDG: string(XDG), + XMR: string(XMR), + XRP: string(XRP), + XVN: string(XVN), + ZEC: string(ZEC), + CAD: string(CAD), + EUR: string(EUR), + GBP: string(GBP), + JPY: string(JPY), + KRW: string(KRW), }) // KrakenAssetConverter is the asset converter for the Kraken exchange var KrakenAssetConverter = makeAssetConverter(map[Asset]string{ - XLM: "XXLM", - BTC: "XXBT", - USD: "ZUSD", - ETH: "XETH", - LTC: "XLTC", - REP: "XREP", + XLM: "XXLM", + BTC: "XXBT", + USD: "ZUSD", + ETH: "XETH", + LTC: "XLTC", + REP: "XREP", + ADA: "ADA", + BCH: "BCH", + DASH: "DASH", + EOS: "EOS", + GNO: "GNO", + FEE: "KFEE", + QTUM: "QTUM", + USDT: "USDT", + DAO: "XDAO", + ETC: "XETC", + ICN: "XICN", + MLN: "XMLN", + NMC: "XNMC", + XDG: "XXDG", + XMR: "XXMR", + XRP: "XXRP", + XVN: "XXVN", + ZEC: "XZEC", + CAD: "ZCAD", + EUR: "ZEUR", + GBP: "ZGBP", + JPY: "ZJPY", + KRW: "ZKRW", }) diff --git a/model/number.go b/model/number.go index 6c1871887..bd8728932 100644 --- a/model/number.go +++ b/model/number.go @@ -66,6 +66,14 @@ func InvertNumber(n *Number) *Number { return NumberFromFloat(1/n.AsFloat(), n.Precision()) } +// NumberByCappingPrecision returns a number with a precision that is at max the passed in precision +func NumberByCappingPrecision(n *Number, precision int8) *Number { + if n.Precision() > precision { + return NumberFromFloat(n.AsFloat(), precision) + } + return n +} + func round(num float64) int { return int(num + math.Copysign(0.5, num)) } diff --git a/plugins/mirrorStrategy.go b/plugins/mirrorStrategy.go index d9f489886..bc21e10d6 100644 --- a/plugins/mirrorStrategy.go +++ b/plugins/mirrorStrategy.go @@ -75,7 +75,7 @@ func (s mirrorStrategy) UpdateWithOps( return nil, e } - buyOps := s.updateLevels( + buyOps, e := s.updateLevels( buyingAOffers, ob.Bids(), s.sdex.ModifyBuyOffer, @@ -83,8 +83,11 @@ func (s mirrorStrategy) UpdateWithOps( (1 - s.config.PER_LEVEL_SPREAD), true, ) + if e != nil { + return nil, e + } log.Printf("num. buyOps in this update: %d\n", len(buyOps)) - sellOps := s.updateLevels( + sellOps, e := s.updateLevels( sellingAOffers, ob.Asks(), s.sdex.ModifySellOffer, @@ -92,6 +95,9 @@ func (s mirrorStrategy) UpdateWithOps( (1 + s.config.PER_LEVEL_SPREAD), false, ) + if e != nil { + return nil, e + } log.Printf("num. sellOps in this update: %d\n", len(sellOps)) ops := []build.TransactionMutator{} @@ -109,54 +115,85 @@ func (s mirrorStrategy) UpdateWithOps( func (s *mirrorStrategy) updateLevels( oldOffers []horizon.Offer, newOrders []model.Order, - modifyOffer func(offer horizon.Offer, price float64, amount float64) *build.ManageOfferBuilder, - createOffer func(baseAsset horizon.Asset, quoteAsset horizon.Asset, price float64, amount float64) *build.ManageOfferBuilder, + modifyOffer func(offer horizon.Offer, price float64, amount float64, incrementalNativeAmountRaw float64) (*build.ManageOfferBuilder, error), + createOffer func(baseAsset horizon.Asset, quoteAsset horizon.Asset, price float64, amount float64, incrementalNativeAmountRaw float64) (*build.ManageOfferBuilder, error), priceMultiplier float64, hackPriceInvertForBuyOrderChangeCheck bool, // needed because createBuy and modBuy inverts price so we need this for price comparison in doModifyOffer -) []build.TransactionMutator { +) ([]build.TransactionMutator, error) { ops := []build.TransactionMutator{} + deleteOps := []build.TransactionMutator{} if len(newOrders) >= len(oldOffers) { - offset := len(newOrders) - len(oldOffers) - for i := len(newOrders) - 1; (i - offset) >= 0; i-- { - ops = doModifyOffer(oldOffers[i-offset], newOrders[i], priceMultiplier, s.config.VOLUME_DIVIDE_BY, modifyOffer, ops, hackPriceInvertForBuyOrderChangeCheck) + for i := 0; i < len(oldOffers); i++ { + modifyOp, deleteOp, e := s.doModifyOffer(oldOffers[i], newOrders[i], priceMultiplier, s.config.VOLUME_DIVIDE_BY, modifyOffer, hackPriceInvertForBuyOrderChangeCheck) + if e != nil { + return nil, e + } + if modifyOp != nil { + ops = append(ops, modifyOp) + } + if deleteOp != nil { + deleteOps = append(deleteOps, deleteOp) + } } // create offers for remaining new bids - for i := offset - 1; i >= 0; i-- { + for i := len(oldOffers); i < len(newOrders); i++ { price := model.NumberFromFloat(newOrders[i].Price.AsFloat()*priceMultiplier, utils.SdexPrecision).AsFloat() vol := model.NumberFromFloat(newOrders[i].Volume.AsFloat()/s.config.VOLUME_DIVIDE_BY, utils.SdexPrecision).AsFloat() - mo := createOffer(*s.baseAsset, *s.quoteAsset, price, vol) + incrementalNativeAmountRaw := s.sdex.ComputeIncrementalNativeAmountRaw(true) + mo, e := createOffer(*s.baseAsset, *s.quoteAsset, price, vol, incrementalNativeAmountRaw) + if e != nil { + return nil, e + } if mo != nil { 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*price, vol, incrementalNativeAmountRaw) + } else { + s.sdex.AddLiabilities(*s.baseAsset, *s.quoteAsset, vol, vol*price, incrementalNativeAmountRaw) + } } } } else { - offset := len(oldOffers) - len(newOrders) - for i := len(oldOffers) - 1; (i - offset) >= 0; i-- { - ops = doModifyOffer(oldOffers[i], newOrders[i-offset], priceMultiplier, s.config.VOLUME_DIVIDE_BY, modifyOffer, ops, hackPriceInvertForBuyOrderChangeCheck) + for i := 0; i < len(newOrders); i++ { + modifyOp, deleteOp, e := s.doModifyOffer(oldOffers[i], newOrders[i], priceMultiplier, s.config.VOLUME_DIVIDE_BY, modifyOffer, hackPriceInvertForBuyOrderChangeCheck) + if e != nil { + return nil, e + } + if modifyOp != nil { + ops = append(ops, modifyOp) + } + if deleteOp != nil { + deleteOps = append(deleteOps, deleteOp) + } } // delete remaining prior offers - for i := offset - 1; i >= 0; i-- { - op := s.sdex.DeleteOffer(oldOffers[i]) - ops = append(ops, op) + for i := len(newOrders); i < len(oldOffers); i++ { + deleteOp := s.sdex.DeleteOffer(oldOffers[i]) + deleteOps = append(deleteOps, deleteOp) } } - return ops + + // prepend deleteOps because we want to delete offers first so we "free" up our liabilities capacity to place the new/modified offers + allOps := append(deleteOps, ops...) + log.Printf("prepended %d deleteOps\n", len(deleteOps)) + + return allOps, nil } -func doModifyOffer( +// doModifyOffer returns a new modifyOp, deleteOp, error +func (s *mirrorStrategy) doModifyOffer( oldOffer horizon.Offer, newOrder model.Order, priceMultiplier float64, volumeDivideBy float64, - modifyOffer func(offer horizon.Offer, price float64, amount float64) *build.ManageOfferBuilder, - ops []build.TransactionMutator, + modifyOffer func(offer horizon.Offer, price float64, amount float64, incrementalNativeAmountRaw float64) (*build.ManageOfferBuilder, error), hackPriceInvertForBuyOrderChangeCheck bool, // needed because createBuy and modBuy inverts price so we need this for price comparison in doModifyOffer -) []build.TransactionMutator { +) (build.TransactionMutator, build.TransactionMutator, error) { price := newOrder.Price.AsFloat() * priceMultiplier vol := newOrder.Volume.AsFloat() / volumeDivideBy - oldPrice := model.MustNumberFromString(oldOffer.Price, 6) oldVol := model.MustNumberFromString(oldOffer.Amount, 6) if hackPriceInvertForBuyOrderChangeCheck { @@ -167,20 +204,42 @@ func doModifyOffer( newPrice := model.NumberFromFloat(price, 6) newVol := model.NumberFromFloat(vol, 6) epsilon := 0.0001 + incrementalNativeAmountRaw := s.sdex.ComputeIncrementalNativeAmountRaw(false) sameOrderParams := utils.FloatEquals(oldPrice.AsFloat(), newPrice.AsFloat(), epsilon) && utils.FloatEquals(oldVol.AsFloat(), newVol.AsFloat(), epsilon) if sameOrderParams { - return ops + // update the cached liabilities if we keep the existing offer + if hackPriceInvertForBuyOrderChangeCheck { + s.sdex.AddLiabilities(oldOffer.Selling, oldOffer.Buying, oldVol.AsFloat()*oldPrice.AsFloat(), oldVol.AsFloat(), incrementalNativeAmountRaw) + } else { + s.sdex.AddLiabilities(oldOffer.Selling, oldOffer.Buying, oldVol.AsFloat(), oldVol.AsFloat()*oldPrice.AsFloat(), incrementalNativeAmountRaw) + } + return nil, nil, nil } - mo := modifyOffer( + offerPrice := model.NumberFromFloat(price, utils.SdexPrecision).AsFloat() + offerAmount := model.NumberFromFloat(vol, utils.SdexPrecision).AsFloat() + mo, e := modifyOffer( oldOffer, - model.NumberFromFloat(price, utils.SdexPrecision).AsFloat(), - model.NumberFromFloat(vol, utils.SdexPrecision).AsFloat(), + offerPrice, + offerAmount, + incrementalNativeAmountRaw, ) + if e != nil { + return nil, nil, e + } if mo != nil { - ops = append(ops, *mo) + // update the cached liabilities if we create a valid operation to modify the offer + if hackPriceInvertForBuyOrderChangeCheck { + s.sdex.AddLiabilities(oldOffer.Selling, oldOffer.Buying, offerAmount*offerPrice, offerAmount, incrementalNativeAmountRaw) + } else { + s.sdex.AddLiabilities(oldOffer.Selling, oldOffer.Buying, offerAmount, offerAmount*offerPrice, incrementalNativeAmountRaw) + } + return *mo, nil, nil } - return ops + + // since mo is nil we want to delete this offer + deleteOp := s.sdex.DeleteOffer(oldOffer) + return nil, deleteOp, nil } // PostUpdate changes the strategy's state after the update has taken place diff --git a/plugins/sdex.go b/plugins/sdex.go index b17001284..5ec84412d 100644 --- a/plugins/sdex.go +++ b/plugins/sdex.go @@ -1,7 +1,9 @@ package plugins import ( + "fmt" "log" + "math" "strconv" "github.com/lightyeario/kelp/support/utils" @@ -11,23 +13,42 @@ import ( ) const baseReserve = 0.5 +const baseFee = 0.0000100 +const maxLumenTrust = math.MaxFloat64 // SDEX helps with building and submitting transactions to the Stellar network type SDEX struct { - API *horizon.Client - SourceAccount string - TradingAccount string - SourceSeed string - TradingSeed string - Network build.Network - FractionalReserveMultiplier int8 - operationalBuffer float64 - simMode bool + API *horizon.Client + SourceAccount string + TradingAccount string + SourceSeed string + TradingSeed string + Network build.Network + operationalBuffer float64 + simMode bool // uninitialized - seqNum uint64 - reloadSeqNum bool - cachedXlmExposure *float64 + 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 +} + +// 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 +} + +// Balance repesents an asset's balance response from the assetBalance method below +type Balance struct { + Balance float64 + Trust float64 + Reserve float64 } // MakeSDEX is a factory method for SDEX @@ -38,20 +59,18 @@ func MakeSDEX( sourceAccount string, tradingAccount string, network build.Network, - fractionalReserveMultiplier int8, operationalBuffer float64, simMode bool, ) *SDEX { sdex := &SDEX{ - API: api, - SourceSeed: sourceSeed, - TradingSeed: tradingSeed, - SourceAccount: sourceAccount, - TradingAccount: tradingAccount, - Network: network, - FractionalReserveMultiplier: fractionalReserveMultiplier, - operationalBuffer: operationalBuffer, - simMode: simMode, + API: api, + SourceSeed: sourceSeed, + TradingSeed: tradingSeed, + SourceAccount: sourceAccount, + TradingAccount: tradingAccount, + Network: network, + operationalBuffer: operationalBuffer, + simMode: simMode, } log.Printf("Using network passphrase: %s\n", sdex.Network.Passphrase) @@ -105,22 +124,18 @@ func (sdex *SDEX) DeleteOffer(offer horizon.Offer) build.ManageOfferBuilder { } // ModifyBuyOffer modifies a buy offer -func (sdex *SDEX) ModifyBuyOffer(offer horizon.Offer, price float64, amount float64) *build.ManageOfferBuilder { - return sdex.ModifySellOffer(offer, 1/price, amount*price) +func (sdex *SDEX) ModifyBuyOffer(offer horizon.Offer, price float64, amount float64, incrementalNativeAmountRaw float64) (*build.ManageOfferBuilder, error) { + return sdex.ModifySellOffer(offer, 1/price, amount*price, incrementalNativeAmountRaw) } // ModifySellOffer modifies a sell offer -func (sdex *SDEX) ModifySellOffer(offer horizon.Offer, price float64, amount float64) *build.ManageOfferBuilder { - return sdex.createModifySellOffer(&offer, offer.Selling, offer.Buying, price, amount) +func (sdex *SDEX) ModifySellOffer(offer horizon.Offer, price float64, amount float64, incrementalNativeAmountRaw float64) (*build.ManageOfferBuilder, error) { + return sdex.createModifySellOffer(&offer, offer.Selling, offer.Buying, price, amount, incrementalNativeAmountRaw) } // CreateSellOffer creates a sell offer -func (sdex *SDEX) CreateSellOffer(base horizon.Asset, counter horizon.Asset, price float64, amount float64) *build.ManageOfferBuilder { - if amount > 0 { - return sdex.createModifySellOffer(nil, base, counter, price, amount) - } - log.Println("error: cannot place sell order, zero amount") - return nil +func (sdex *SDEX) CreateSellOffer(base horizon.Asset, counter horizon.Asset, price float64, amount float64, incrementalNativeAmountRaw float64) (*build.ManageOfferBuilder, error) { + return sdex.createModifySellOffer(nil, base, counter, price, amount, incrementalNativeAmountRaw) } // ParseOfferAmount is a convenience method to parse an offer amount @@ -137,63 +152,110 @@ func (sdex *SDEX) minReserve(subentries int32) float64 { return float64(2+subentries) * baseReserve } -func (sdex *SDEX) lumenBalance() (float64, float64, error) { +// 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 (zero for non-XLM), error +func (sdex *SDEX) _assetBalance(asset horizon.Asset) (float64, float64, float64, error) { account, err := sdex.API.LoadAccount(sdex.TradingAccount) if err != nil { - log.Printf("error loading account to fetch lumen balance: %s\n", err) - return -1, -1, nil + return -1, -1, -1, fmt.Errorf("error: unable to load account to fetch balance: %s", err) } for _, balance := range account.Balances { - if balance.Asset.Type == utils.Native { + if utils.AssetsEqual(balance.Asset, asset) { b, e := strconv.ParseFloat(balance.Balance, 64) if e != nil { - log.Printf("error parsing native balance: %s\n", e) + return -1, -1, -1, fmt.Errorf("error: cannot parse balance: %s", e) } - return b, sdex.minReserve(account.SubentryCount), e + if balance.Asset.Type == utils.Native { + return b, maxLumenTrust, sdex.minReserve(account.SubentryCount) + sdex.operationalBuffer, e + } + + t, e := strconv.ParseFloat(balance.Limit, 64) + if e != nil { + return -1, -1, -1, fmt.Errorf("error: cannot parse trust limit: %s", e) + } + return b, t, 0, nil } } - return -1, -1, errors.New("could not find a native lumen balance") + return -1, -1, -1, errors.New("could not find a balance for the asset passed in") +} + +// ComputeIncrementalNativeAmountRaw returns the native amount that will be added to liabilities because of fee and min-reserve additions +func (sdex *SDEX) ComputeIncrementalNativeAmountRaw(isNewOffer bool) float64 { + incrementalNativeAmountRaw := 0.0 + if sdex.TradingAccount == sdex.SourceAccount { + // at the minimum it will cost us a unit of base fee for this operation + incrementalNativeAmountRaw += baseFee + } + if isNewOffer { + // new offers will increase the min reserve + incrementalNativeAmountRaw += baseReserve + } + return incrementalNativeAmountRaw } // 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) *build.ManageOfferBuilder { - if selling.Type == utils.Native { - var incrementalXlmAmount float64 - if offer != nil { - offerAmt, err := sdex.ParseOfferAmount(offer.Amount) - if err != nil { - log.Println(err) - return nil - } - // modifying an offer will not increase the min reserve but will affect the xlm exposure - incrementalXlmAmount = amount - offerAmt - } else { - // creating a new offer will incrase the min reserve on the account so add baseReserve - incrementalXlmAmount = amount + baseReserve - } +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) + } + if amount <= 0 { + return nil, fmt.Errorf("error: cannot create or modify offer, invalid amount: %.7f", amount) + } - // check if incrementalXlmAmount is within budget - bal, minAccountBal, err := sdex.lumenBalance() - if err != nil { - log.Println(err) - return nil - } + // check liability limits on the asset being sold + incrementalSell := amount + willOversell, e := sdex.willOversell(selling, amount) + if e != nil { + return nil, e + } + if willOversell { + return nil, nil + } - xlmExposure, err := sdex.xlmExposure() - if err != nil { - log.Println(err) - return nil - } + // check trust limits on asset being bought + incrementalBuy := price * amount + willOverbuy, e := sdex.willOverbuy(buying, incrementalBuy) + if e != nil { + return nil, e + } + if willOverbuy { + return nil, nil + } - additionalExposure := incrementalXlmAmount >= 0 - possibleTerminalExposure := ((xlmExposure + incrementalXlmAmount) / float64(sdex.FractionalReserveMultiplier)) > (bal - minAccountBal - sdex.operationalBuffer) - if additionalExposure && possibleTerminalExposure { - log.Println("not placing offer because we run the risk of running out of lumens | xlmExposure:", xlmExposure, - "| incrementalXlmAmount:", incrementalXlmAmount, "| bal:", bal, "| minAccountBal:", minAccountBal, - "| operationalBuffer:", sdex.operationalBuffer, "| fractionalReserveMultiplier:", sdex.FractionalReserveMultiplier) - return nil - } + // 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 } stringPrice := strconv.FormatFloat(price, 'f', int(utils.SdexPrecision), 64) @@ -214,7 +276,81 @@ func (sdex *SDEX) createModifySellOffer(offer *horizon.Offer, selling horizon.As mutators = append(mutators, build.SourceAccount{AddressOrSeed: sdex.TradingAccount}) } result := build.ManageOffer(false, mutators...) - return &result + 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 @@ -250,8 +386,8 @@ func (sdex *SDEX) SubmitOps(ops []build.TransactionMutator) error { } // CreateBuyOffer creates a buy offer -func (sdex *SDEX) CreateBuyOffer(base horizon.Asset, counter horizon.Asset, price float64, amount float64) *build.ManageOfferBuilder { - return sdex.CreateSellOffer(counter, base, 1/price, amount*price) +func (sdex *SDEX) CreateBuyOffer(base horizon.Asset, counter horizon.Asset, price float64, amount float64, incrementalNativeAmountRaw float64) (*build.ManageOfferBuilder, error) { + return sdex.CreateSellOffer(counter, base, 1/price, amount*price, incrementalNativeAmountRaw) } func (sdex *SDEX) sign(tx *build.TransactionBuilder) (string, error) { @@ -294,35 +430,138 @@ func (sdex *SDEX) submit(txeB64 string) { log.Printf("(async) tx confirmation hash: %s\n", resp.Hash) } -// ResetCachedXlmExposure resets the cache -func (sdex *SDEX) ResetCachedXlmExposure() { - sdex.cachedXlmExposure = nil +func (sdex *SDEX) logLiabilities(asset horizon.Asset) { + l, e := sdex.assetLiabilities(asset) + log.Printf("asset = %s, buyingLiabilities=%.7f, sellingLiabilities=%.7f\n", asset, l.Buying, l.Selling) + if e != nil { + log.Printf("could not fetch liability for asset '%s', error = %s\n", asset, e) + } +} + +// 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) + sdex.logLiabilities(assetQuote) + + if assetBase != utils.NativeAsset && assetQuote != utils.NativeAsset { + sdex.logLiabilities(utils.NativeAsset) + } +} + +// 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{} + 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 } -func (sdex *SDEX) xlmExposure() (float64, error) { - if sdex.cachedXlmExposure != nil { - return *sdex.cachedXlmExposure, 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 { - log.Printf("error computing XLM exposure: %s\n", err) - return -1, err + assetString := utils.Asset2String(asset) + log.Printf("error: cannot load offers to compute liabilities for asset (%s): %s\n", assetString, err) + return nil, nil, err } - var sum float64 + // liabilities for the asset + liabilities := Liabilities{} + // liabilities for the asset w.r.t. the trading pair + pairLiabilities := Liabilities{} for _, offer := range offers { - // only need to compute sum of selling because that's the max XLM we can give up if all our offers are taken - if offer.Selling.Type == utils.Native { + if offer.Selling == asset { offerAmt, err := sdex.ParseOfferAmount(offer.Amount) if err != nil { - return -1, err + 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 } - sum += offerAmt } } - sdex.cachedXlmExposure = &sum - return sum, nil + sdex.cachedLiabilities[asset] = liabilities + return &liabilities, &pairLiabilities, nil } diff --git a/plugins/sellSideStrategy.go b/plugins/sellSideStrategy.go index 7e6822366..fd4c7521c 100644 --- a/plugins/sellSideStrategy.go +++ b/plugins/sellSideStrategy.go @@ -1,8 +1,8 @@ package plugins import ( + "fmt" "log" - "math" "github.com/lightyeario/kelp/api" "github.com/lightyeario/kelp/model" @@ -90,23 +90,55 @@ func (s *sellSideStrategy) PreUpdate(maxAssetBase float64, maxAssetQuote float64 // UpdateWithOps impl func (s *sellSideStrategy) UpdateWithOps(offers []horizon.Offer) (ops []build.TransactionMutator, newTopOffer *model.Number, e error) { + deleteOps := []build.TransactionMutator{} newTopOffer = nil - for i := len(s.currentLevels) - 1; i >= 0; i-- { - op := s.updateSellLevel(offers, i) - if op != nil { - offer, e := model.NumberFromString(op.MO.Price.String(), 7) - if e != nil { - return nil, nil, e + hitCapacityLimit := false + for i := 0; i < len(s.currentLevels); i++ { + isModify := i < len(offers) + // we only want to delete offers after we hit the capacity limit which is why we perform this check in the beginning + if hitCapacityLimit { + if isModify { + delOp := s.sdex.DeleteOffer(offers[i]) + log.Printf("deleting offer because we previously hit the capacity limit, offerId=%d\n", offers[i].ID) + deleteOps = append(deleteOps, delOp) + continue + } else { + // we can break because we would never see a modify operation happen after a non-modify operation + break } + } - // newTopOffer is minOffer because this is a sell strategy, and the lowest price is the best (top) price on the orderbook - if newTopOffer == nil || offer.AsFloat() < newTopOffer.AsFloat() { - newTopOffer = offer - } + // hitCapacityLimit can be updated below + targetPrice := s.currentLevels[i].Price + targetAmount := s.currentLevels[i].Amount + if s.divideAmountByPrice { + targetAmount = *model.NumberFromFloat(targetAmount.AsFloat()/targetPrice.AsFloat(), targetAmount.Precision()) + } + var offerPrice *model.Number + var op *build.ManageOfferBuilder + var e error + if isModify { + offerPrice, hitCapacityLimit, op, e = s.modifySellLevel(offers, i, targetPrice, targetAmount) + } else { + offerPrice, hitCapacityLimit, op, e = s.createSellLevel(targetPrice, targetAmount) + } + if e != nil { + return nil, nil, e + } + if op != nil { ops = append(ops, op) } + + // update top offer, newTopOffer is minOffer because this is a sell strategy, and the lowest price is the best (top) price on the orderbook + if newTopOffer == nil || offerPrice.AsFloat() < newTopOffer.AsFloat() { + newTopOffer = offerPrice + } } + + // prepend deleteOps because we want to delete offers first so we "free" up our liabilities capacity to place the new/modified offers + ops = append(deleteOps, ops...) + return ops, newTopOffer, nil } @@ -115,27 +147,65 @@ func (s *sellSideStrategy) PostUpdate() error { return nil } -// Selling Base -func (s *sellSideStrategy) updateSellLevel(offers []horizon.Offer, index int) *build.ManageOfferBuilder { - targetPrice := s.currentLevels[index].Price - targetAmount := s.currentLevels[index].Amount - if s.divideAmountByPrice { - targetAmount = *model.NumberFromFloat(targetAmount.AsFloat()/targetPrice.AsFloat(), targetAmount.Precision()) +// 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) + if e != nil { + return 0, 0, e + } + availableBuyingCapacity, e := s.sdex.AvailableCapacity(*s.assetQuote, incrementalNativeAmountRaw) + if e != nil { + return 0, 0, e } - targetAmount = *model.NumberFromFloat(math.Min(targetAmount.AsFloat(), s.maxAssetBase), targetAmount.Precision()) - if len(offers) <= index { - if targetPrice.Precision() > utils.SdexPrecision { - targetPrice = *model.NumberFromFloat(targetPrice.AsFloat(), utils.SdexPrecision) - } - if targetAmount.Precision() > utils.SdexPrecision { - targetAmount = *model.NumberFromFloat(targetAmount.AsFloat(), utils.SdexPrecision) - } - // no existing offer at this index - log.Printf("sell,create,p=%.7f,a=%.7f\n", targetPrice.AsFloat(), targetAmount.AsFloat()) - return s.sdex.CreateSellOffer(*s.assetBase, *s.assetQuote, targetPrice.AsFloat(), targetAmount.AsFloat()) + 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", + 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) + return 0, 0, nil + } + + // return the smaller amount between the buying and selling capacities that will max out either one + 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) + 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) + 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", + availableSellingCapacity.Selling, availableBuyingCapacity.Buying, price) +} + +// createSellLevel returns offerPrice, hitCapacityLimit, op, error. +func (s *sellSideStrategy) createSellLevel(targetPrice model.Number, targetAmount model.Number) (*model.Number, bool, *build.ManageOfferBuilder, error) { + incrementalNativeAmountRaw := s.sdex.ComputeIncrementalNativeAmountRaw(true) + targetPrice = *model.NumberByCappingPrecision(&targetPrice, utils.SdexPrecision) + targetAmount = *model.NumberByCappingPrecision(&targetAmount, utils.SdexPrecision) + + hitCapacityLimit, op, e := s.placeOrderWithRetry( + targetPrice.AsFloat(), + targetAmount.AsFloat(), + incrementalNativeAmountRaw, + func(price float64, amount float64, incrementalNativeAmountRaw float64) (*build.ManageOfferBuilder, error) { + log.Printf("sell,create,p=%.7f,a=%.7f\n", price, amount) + return s.sdex.CreateSellOffer(*s.assetBase, *s.assetQuote, price, amount, incrementalNativeAmountRaw) + }, + *s.assetBase, + *s.assetQuote, + ) + return &targetPrice, hitCapacityLimit, op, e +} + +// modifySellLevel returns offerPrice, hitCapacityLimit, op, error. +func (s *sellSideStrategy) modifySellLevel(offers []horizon.Offer, index int, targetPrice model.Number, targetAmount model.Number) (*model.Number, bool, *build.ManageOfferBuilder, error) { highestPrice := targetPrice.AsFloat() + targetPrice.AsFloat()*s.priceTolerance lowestPrice := targetPrice.AsFloat() - targetPrice.AsFloat()*s.priceTolerance minAmount := targetAmount.AsFloat() - targetAmount.AsFloat()*s.amountTolerance @@ -148,16 +218,72 @@ func (s *sellSideStrategy) updateSellLevel(offers []horizon.Offer, index int) *b // existing offer not within tolerances priceTrigger := (curPrice > highestPrice) || (curPrice < lowestPrice) amountTrigger := (curAmount < minAmount) || (curAmount > maxAmount) - if priceTrigger || amountTrigger { - if targetPrice.Precision() > utils.SdexPrecision { - targetPrice = *model.NumberFromFloat(targetPrice.AsFloat(), utils.SdexPrecision) - } - if targetAmount.Precision() > utils.SdexPrecision { - targetAmount = *model.NumberFromFloat(targetAmount.AsFloat(), utils.SdexPrecision) - } - log.Printf("sell,modify,tp=%.7f,ta=%.7f,curPrice=%.7f,highPrice=%.7f,lowPrice=%.7f,curAmt=%.7f,minAmt=%.7f,maxAmt=%.7f\n", - targetPrice.AsFloat(), targetAmount.AsFloat(), curPrice, highestPrice, lowestPrice, curAmount, minAmount, maxAmount) - return s.sdex.ModifySellOffer(offers[index], targetPrice.AsFloat(), targetAmount.AsFloat()) + 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) + offerPrice := model.NumberFromFloat(curPrice, utils.SdexPrecision) + return offerPrice, false, nil, nil } - return nil + + targetPrice = *model.NumberByCappingPrecision(&targetPrice, utils.SdexPrecision) + targetAmount = *model.NumberByCappingPrecision(&targetAmount, utils.SdexPrecision) + hitCapacityLimit, op, e := s.placeOrderWithRetry( + targetPrice.AsFloat(), + targetAmount.AsFloat(), + incrementalNativeAmountRaw, + func(price float64, amount float64, incrementalNativeAmountRaw float64) (*build.ManageOfferBuilder, error) { + log.Printf("sell,modify,tp=%.7f,ta=%.7f,curPrice=%.7f,highPrice=%.7f,lowPrice=%.7f,curAmt=%.7f,minAmt=%.7f,maxAmt=%.7f\n", + price, amount, curPrice, highestPrice, lowestPrice, curAmount, minAmount, maxAmount) + return s.sdex.ModifySellOffer(offers[index], price, amount, incrementalNativeAmountRaw) + }, + offers[index].Selling, + offers[index].Buying, + ) + return &targetPrice, hitCapacityLimit, op, e +} + +// placeOrderWithRetry returns hitCapacityLimit, op, error +func (s *sellSideStrategy) placeOrderWithRetry( + targetPrice float64, + targetAmount float64, + incrementalNativeAmountRaw float64, + placeOffer func(price float64, amount float64, incrementalNativeAmountRaw float64) (*build.ManageOfferBuilder, error), + assetBase horizon.Asset, + assetQuote horizon.Asset, +) (bool, *build.ManageOfferBuilder, error) { + op, e := placeOffer(targetPrice, targetAmount, incrementalNativeAmountRaw) + if e != nil { + return false, nil, e + } + incrementalSellAmount := targetAmount + incrementalBuyAmount := targetAmount * targetPrice + // 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) + return false, op, nil + } + + // place an order for the remainder between our intended amount and our remaining capacity + newSellingAmount, newBuyingAmount, e := s.computeRemainderAmount(incrementalSellAmount, incrementalBuyAmount, targetPrice, incrementalNativeAmountRaw) + if e != nil { + return true, nil, e + } + if newSellingAmount == 0 || newBuyingAmount == 0 { + return true, nil, nil + } + + op, e = placeOffer(targetPrice, newSellingAmount, incrementalNativeAmountRaw) + if e != nil { + return true, nil, e + } + + 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) + 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", + incrementalSellAmount, newSellingAmount, incrementalBuyAmount, newBuyingAmount) } diff --git a/support/utils/functions.go b/support/utils/functions.go index 1c6babcad..e7d2b559c 100644 --- a/support/utils/functions.go +++ b/support/utils/functions.go @@ -2,6 +2,7 @@ package utils import ( "encoding/json" + "fmt" "log" "math" "math/big" @@ -20,6 +21,9 @@ import ( // Native is the string representing the type for the native lumen asset const Native = "native" +// NativeAsset represents the native asset +var NativeAsset = horizon.Asset{Type: Native} + // SdexPrecision defines the number of decimals used in SDEX const SdexPrecision int8 = 7 @@ -104,6 +108,14 @@ func Asset2Asset2(Asset build.Asset) horizon.Asset { return a } +// Asset2String converts a horizon.Asset to a string representation, using "native" for the native XLM +func Asset2String(asset horizon.Asset) string { + if asset.Type == Native { + return Native + } + return fmt.Sprintf("%s:%s", asset.Code, asset.Issuer) +} + // String2Asset converts a code:issuer to a horizon.Asset func String2Asset(code string, issuer string) horizon.Asset { if code == "XLM" { diff --git a/trader/trader.go b/trader/trader.go index 8506c4f5d..e1ab1036d 100644 --- a/trader/trader.go +++ b/trader/trader.go @@ -1,7 +1,9 @@ package trader import ( + "fmt" "log" + "math" "sort" "time" @@ -13,7 +15,7 @@ import ( "github.com/stellar/go/clients/horizon" ) -const maxLumenTrust float64 = 100000000000 +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 { @@ -70,6 +72,7 @@ func (t *Trader) Start() { // deletes all offers for the bot (not all offers on the account) func (t *Trader) deleteAllOffers() { + log.Printf("deleting all offers\n") dOps := []build.TransactionMutator{} dOps = append(dOps, t.sdex.DeleteAllOffers(t.sellingAOffers)...) @@ -93,6 +96,19 @@ func (t *Trader) update() { t.load() t.loadExistingOffers() + // 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() + // reset and recompute cached liabilities for this update cycle + e = t.sdex.ResetCachedLiabilities(t.assetBase, t.assetQuote) + log.Printf("liabilities after resetting\n") + t.sdex.LogAllLiabilities(t.assetBase, t.assetQuote) + if e != nil { + log.Println(e) + t.deleteAllOffers() + return + } + // strategy has a chance to set any state it needs e = t.strat.PreUpdate(t.maxAssetA, t.maxAssetB, t.trustAssetA, t.trustAssetB) if e != nil { @@ -114,12 +130,26 @@ func (t *Trader) update() { } } - // reset cached xlm exposure here so we only compute it once per update - // TODO 2 - calculate this here and pass it in - t.sdex.ResetCachedXlmExposure() + // 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() + // reset and recompute cached liabilities for this update cycle + e = t.sdex.ResetCachedLiabilities(t.assetBase, t.assetQuote) + log.Printf("liabilities after resetting\n") + t.sdex.LogAllLiabilities(t.assetBase, t.assetQuote) + if e != nil { + log.Println(e) + t.deleteAllOffers() + return + } + 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) 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.deleteAllOffers() return } @@ -154,29 +184,33 @@ func (t *Trader) load() { 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) + } + if utils.AssetsEqual(balance.Asset, t.assetBase) { maxA = utils.AmountStringAsFloat(balance.Balance) - if balance.Asset.Type == utils.Native { - trustA = maxLumenTrust - } else { - trustA = utils.AmountStringAsFloat(balance.Limit) - } - log.Printf("maxA=%.7f,trustA=%.7f\n", maxA, trustA) + trustA = trust + trustAString = trustString } else if utils.AssetsEqual(balance.Asset, t.assetQuote) { maxB = utils.AmountStringAsFloat(balance.Balance) - if balance.Asset.Type == utils.Native { - trustB = maxLumenTrust - } else { - trustB = utils.AmountStringAsFloat(balance.Limit) - } - log.Printf("maxB=%.7f,trustB=%.7f\n", maxB, trustB) + trustB = trust + trustBString = trustString } } 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) } func (t *Trader) loadExistingOffers() {