diff --git a/examples/configs/trader/sample_trackSDEX.cfg b/examples/configs/trader/sample_trackSDEX.cfg new file mode 100644 index 000000000..1760e77a2 --- /dev/null +++ b/examples/configs/trader/sample_trackSDEX.cfg @@ -0,0 +1,25 @@ +# Sample config file for the "trackSDEX" strategy + +# what % deviation from the ideal price is allowed before we reset the price, specified as a decimal (0 < PRICE_TOLERANCE < 1.00) +PRICE_TOLERANCE=0.01 + +# what % deviation from the ideal amount is allowed before we reset the price, specified as a decimal (0 < AMOUNT_TOLERANCE < 1.00) +AMOUNT_TOLERANCE=0.01 + +# define the bid/ask spread that you are willing to provide. spread is a percentage specified as a decimal number (0 < spread < 1.00) - here it is 0.1% +SPREAD=0.002 + +# What base percent of your (one sided) balance to use on each level specified as a decimal number +BASE_PERCENT_PER_LEVEL = 0.01 + +# max number of levels to have on either side. Defines how deep of an orderbook you want to make. +# MAX_LEVELS * BASE_PERCENT_PER_LEVEL must be < 1.00, or you'll use more than your balance. +# You also need to account for your XLM reserve if trading vs XLM: +# Needed XLM reserve = MAX_LEVELS, so subtract that from your balance when setting these parameters. +# i.e. if 45 was 10% of your XLM balance, then MAX_LEVELS * BASE_PERCENT_PER_LEVEL must be < 0.9 +MAX_LEVELS = 45 + +# the minimum portion of the total effective base value that either of the assets may represent before the bot stops placing orders on that side. +# this prevents your account from going to 0 for one asset and no longer being able to market make. +# the strategy also dynamically modifies the level amounts based on which asset you have more of, so this stop shouldn't trigger often +MAINTAIN_BALANCE_PERCENT = 0.25 diff --git a/plugins/factory.go b/plugins/factory.go index cda86f832..6a00f3d2e 100644 --- a/plugins/factory.go +++ b/plugins/factory.go @@ -94,6 +94,22 @@ var strategies = map[string]StrategyContainer{ return makeDeleteStrategy(sdex, assetBase, assetQuote), nil }, }, + "trackSDEX": StrategyContainer{ + SortOrder: 5, + Description: "Places buy and sell orders based on the SDEX orderbook price of an asset", + NeedsConfig: true, + Complexity: "Intermediate", + makeFn: func(sdex *SDEX, assetBase *horizon.Asset, assetQuote *horizon.Asset, stratConfigPath string) (api.Strategy, error) { + var cfg trackSDEXConfig + err := config.Read(stratConfigPath, &cfg) + utils.CheckConfigError(cfg, err, stratConfigPath) + s, e := makeTrackSDEXStrategy(sdex, assetBase, assetQuote, &cfg) + if e != nil { + return nil, fmt.Errorf("makeFn failed: %s", e) + } + return s, nil + }, + }, } // MakeStrategy makes a strategy diff --git a/plugins/sdexFeed.go b/plugins/sdexFeed.go new file mode 100644 index 000000000..e3651d98b --- /dev/null +++ b/plugins/sdexFeed.go @@ -0,0 +1,19 @@ +package plugins + +import ( + "github.com/lightyeario/kelp/support/utils" + "github.com/stellar/go/clients/horizon" +) + +func GetSDEXPrice(sdex *SDEX, assetBase *horizon.Asset, assetQuote *horizon.Asset) (float64, error) { + orderBook, e := utils.GetOrderBook(sdex.API, assetBase, assetQuote) + if e != nil { + return 0, e + } + bids := orderBook.Bids + topBidPrice := utils.PriceAsFloat(bids[0].Price) + asks := orderBook.Asks + lowAskPrice := utils.PriceAsFloat(asks[0].Price) + centerPrice := (topBidPrice + lowAskPrice) / 2 + return centerPrice, nil +} diff --git a/plugins/trackSDEXLevelProvider.go b/plugins/trackSDEXLevelProvider.go new file mode 100644 index 000000000..59811f9ac --- /dev/null +++ b/plugins/trackSDEXLevelProvider.go @@ -0,0 +1,116 @@ +package plugins + +import ( + "fmt" + "log" + + "github.com/lightyeario/kelp/api" + "github.com/lightyeario/kelp/model" + "github.com/lightyeario/kelp/support/utils" +) + +type SDEXLevelProvider struct { + spread float64 + basePercentPerLevel float64 + maxLevels int16 + maintainBalancePercent float64 + sdexMidPrice float64 + isBuy bool +} + +func makeSDEXLevelProvider( + spread float64, + basePercentPerLevel float64, + maxLevels int16, + maintainBalancePercent float64, + sdexMidPrice float64, + isBuy bool, +) api.LevelProvider { + validateTotalAmount(maxLevels, basePercentPerLevel) + return &SDEXLevelProvider{ + spread: spread, + basePercentPerLevel: basePercentPerLevel, + maxLevels: maxLevels, + maintainBalancePercent: maintainBalancePercent, + sdexMidPrice: sdexMidPrice, + isBuy: isBuy, + } +} + +func validateTotalAmount(maxLevels int16, basePercentPerLevel float64) { + l := float64(maxLevels) + totalAmount := l * basePercentPerLevel + if totalAmount > 1 { + log.Fatalf("Number of levels * percent per level must be < 1.0\n") + } + +} + +func (p *SDEXLevelProvider) GetLevels(maxAssetBase float64, maxAssetQuote float64) ([]api.Level, error) { + if maxAssetBase <= 0.0 { + return nil, fmt.Errorf("Had none of the base asset, unable to generate levels") + } + + levels := []api.Level{} + totalAssetValue := maxAssetBase + (maxAssetQuote / p.sdexMidPrice) + balanceRatio := maxAssetBase / totalAssetValue + ratioGap := balanceRatio - 0.5 + + log.Printf("balanceRatio = %v", balanceRatio) + + allowedSpend := maxAssetBase - totalAssetValue*p.maintainBalancePercent + if allowedSpend < 0.0 { + allowedSpend = 0.0 + } + backupBalance := maxAssetBase - allowedSpend + + spent := 0.0 + levelCounter := 1.0 + overSpent := false + for i := int16(0); i < p.maxLevels; i++ { + if spent >= maxAssetBase { + return nil, fmt.Errorf("Level provider spent more than asset balance") + } + if spent >= allowedSpend { + overSpent = true + } + + level, amountSpent, e := p.getLevel(maxAssetBase, levelCounter, ratioGap, overSpent, backupBalance) + if e != nil { + return nil, e + } + + spent += amountSpent + if overSpent { + backupBalance -= amountSpent + } + if spent < maxAssetBase { + levels = append(levels, level) + } + levelCounter += 1.0 + } + return levels, nil +} + +func (p *SDEXLevelProvider) getLevel(maxAssetBase float64, levelCounter float64, ratioGap float64, overSpent bool, backupBalance float64) (api.Level, float64, error) { + + targetPrice := p.sdexMidPrice * (1.0 + p.spread*levelCounter) + targetAmount := maxAssetBase * p.basePercentPerLevel * (1.0 + ratioGap*2) + if p.isBuy { + targetAmount = maxAssetBase * p.basePercentPerLevel * (1.0 + ratioGap*2) * targetPrice + } + + if overSpent { + targetAmount = backupBalance * 0.001 + } + + if overSpent && p.isBuy { + targetAmount = backupBalance * 0.001 * targetPrice + } + + level := api.Level{ + Price: *model.NumberFromFloat(targetPrice, utils.SdexPrecision), + Amount: *model.NumberFromFloat(targetAmount, utils.SdexPrecision), + } + return level, targetAmount, nil +} diff --git a/plugins/trackSDEXStrategy.go b/plugins/trackSDEXStrategy.go new file mode 100644 index 000000000..83ba61979 --- /dev/null +++ b/plugins/trackSDEXStrategy.go @@ -0,0 +1,83 @@ +package plugins + +import ( + "fmt" + + "github.com/lightyeario/kelp/api" + "github.com/lightyeario/kelp/support/utils" + "github.com/stellar/go/clients/horizon" +) + +// trackSDEXConfig contains the configuration params for this strategy +type trackSDEXConfig struct { + Spread float64 `valid:"-" toml:"SPREAD"` + PriceTolerance float64 `valid:"-" toml:"PRICE_TOLERANCE"` + AmountTolerance float64 `valid:"-" toml:"AMOUNT_TOLERANCE"` + BasePercentPerLevel float64 `valid:"-" toml:"BASE_PERCENT_PER_LEVEL"` + MaxLevels int16 `valid:"-" toml:"MAX_LEVELS"` + MaintainBalancePercent float64 `valid:"-" toml:"MAINTAIN_BALANCE_PERCENT"` +} + +// String impl. +func (c trackSDEXConfig) String() string { + return utils.StructString(c, nil) +} + +// makeTrackSDEXStrategy is a factory method +func makeTrackSDEXStrategy( + sdex *SDEX, + assetBase *horizon.Asset, + assetQuote *horizon.Asset, + config *trackSDEXConfig, +) (api.Strategy, error) { + sdexMidPrice, e := GetSDEXPrice( + sdex, + assetBase, + assetQuote, + ) + if e != nil { + return nil, fmt.Errorf("failed to get SDEX orderbook price", e) + } + sdexInversePrice := 1.0 / sdexMidPrice + + sellSideStrategy := makeSellSideStrategy( + sdex, + assetBase, + assetQuote, + makeSDEXLevelProvider( + config.Spread, + config.BasePercentPerLevel, + config.MaxLevels, + config.MaintainBalancePercent, + sdexMidPrice, + false, + ), + config.PriceTolerance, + config.AmountTolerance, + false, + ) + // switch sides of base/quote here for buy side + buySideStrategy := makeSellSideStrategy( + sdex, + assetQuote, + assetBase, + makeSDEXLevelProvider( + config.Spread, + config.BasePercentPerLevel, + config.MaxLevels, + config.MaintainBalancePercent, + sdexInversePrice, + true, + ), + config.PriceTolerance, + config.AmountTolerance, + true, + ) + + return makeComposeStrategy( + assetBase, + assetQuote, + buySideStrategy, + sellSideStrategy, + ), nil +} diff --git a/support/utils/functions.go b/support/utils/functions.go index 62a358549..1d7df66cd 100644 --- a/support/utils/functions.go +++ b/support/utils/functions.go @@ -144,6 +144,16 @@ func LoadAllOffers(account string, api *horizon.Client) (offersRet []horizon.Off return } +// GetOrderBook gets the orderbook for the A/B pair +func GetOrderBook(api *horizon.Client, assetBase *horizon.Asset, assetQuote *horizon.Asset) (orderBook horizon.OrderBookSummary, e error) { + b, e := api.LoadOrderBook(*assetBase, *assetQuote) + if e != nil { + log.Printf("Can't get SDEX orderbook: %s\n", e) + return + } + return b, e +} + // FilterOffers filters out the offers into selling and buying, where sellOffers sells the sellAsset and buyOffers buys the sellAsset func FilterOffers(offers []horizon.Offer, sellAsset horizon.Asset, buyAsset horizon.Asset) (sellOffers []horizon.Offer, buyOffers []horizon.Offer) { for _, offer := range offers {