From c4fc1cea064a479da0a655762b8a8958b75f5e68 Mon Sep 17 00:00:00 2001 From: martonp Date: Sat, 19 Oct 2024 13:39:03 +0200 Subject: [PATCH] Fee reserve top ups --- client/mm/config.go | 5 + client/mm/exchange_adaptor.go | 279 ++++++++++++++++++---- client/mm/exchange_adaptor_test.go | 324 ++++++++++++++++++++++++-- client/mm/mm.go | 5 +- client/mm/mm_arb_market_maker.go | 4 +- client/mm/mm_arb_market_maker_test.go | 4 +- client/mm/mm_basic.go | 23 +- client/mm/mm_basic_test.go | 1 + client/mm/mm_simple_arb.go | 4 +- 9 files changed, 569 insertions(+), 80 deletions(-) diff --git a/client/mm/config.go b/client/mm/config.go index 6d12ba7727..b16fc5ee2b 100644 --- a/client/mm/config.go +++ b/client/mm/config.go @@ -50,6 +50,11 @@ type CEXConfig struct { type AutoRebalanceConfig struct { MinBaseTransfer uint64 `json:"minBaseTransfer"` MinQuoteTransfer uint64 `json:"minQuoteTransfer"` + // InternalTransfers indicates whether the bot should attempt internal + // transfers or not. If MinBaseTransfer or MinQuoteTransfer > 0, + // internal transfers will be attempted regardless of this setting. + InternalTransfers bool `json:"internalTransfers"` + TopUpFeeReserves bool `json:"topUpFeeReserves"` } // BotBalanceAllocation is the initial allocation of funds for a bot. diff --git a/client/mm/exchange_adaptor.go b/client/mm/exchange_adaptor.go index af448341a6..c1c8bd20fa 100644 --- a/client/mm/exchange_adaptor.go +++ b/client/mm/exchange_adaptor.go @@ -404,7 +404,7 @@ func (m *market) msgRate(convRate float64) uint64 { return calc.MessageRate(convRate, m.bui, m.qui) } -type doTransferFunc func(dexAvailable, cexAvailable map[uint32]uint64) error +type doInternalTransferFunc func(dexAvailable, cexAvailable map[uint32]uint64) error // unifiedExchangeAdaptor implements both botCoreAdaptor and botCexAdaptor. type unifiedExchangeAdaptor struct { @@ -426,7 +426,7 @@ type unifiedExchangeAdaptor struct { quoteTraits asset.WalletTrait // ** IMPORTANT ** No mutexes should be locked when calling this // function. - internalTransfer func(*MarketWithHost, doTransferFunc) error + internalTransfer func(*MarketWithHost, doInternalTransferFunc) error botLooper dex.Connector botLoop *dex.ConnectionMaster @@ -2680,7 +2680,9 @@ func (u *unifiedExchangeAdaptor) handleDEXNotification(n core.Notification) { type lotCosts struct { dexBase, dexQuote, cexBase, cexQuote uint64 + baseSwap, quoteSwap uint64 baseRedeem, quoteRedeem uint64 + baseRefund, quoteRefund uint64 baseFunding, quoteFunding uint64 // per multi-order } @@ -2695,7 +2697,9 @@ func (u *unifiedExchangeAdaptor) lotCosts(sellVWAP, buyVWAP uint64) (*lotCosts, perLot.dexBase += sellFees.bookingFeesPerLot } perLot.cexBase = u.lotSize + perLot.baseSwap = sellFees.Max.Swap perLot.baseRedeem = buyFees.Max.Redeem + perLot.baseRefund = sellFees.Max.Refund perLot.baseFunding = sellFees.funding dexQuoteLot := calc.BaseToQuote(sellVWAP, u.lotSize) @@ -2705,42 +2709,156 @@ func (u *unifiedExchangeAdaptor) lotCosts(sellVWAP, buyVWAP uint64) (*lotCosts, perLot.dexQuote += buyFees.bookingFeesPerLot } perLot.cexQuote = cexQuoteLot + perLot.quoteSwap = buyFees.Max.Swap perLot.quoteRedeem = sellFees.Max.Redeem + perLot.quoteRefund = buyFees.Max.Refund perLot.quoteFunding = buyFees.funding return perLot, nil } // distribution is a collection of asset distributions and per-lot estimates. type distribution struct { - baseInv *assetInventory - quoteInv *assetInventory - perLot *lotCosts + baseInv *assetInventory + quoteInv *assetInventory + perLot *lotCosts + feeReserveTopUps map[uint32]uint64 } -func (u *unifiedExchangeAdaptor) newDistribution(perLot *lotCosts, additionalDEX, additionalCEX map[uint32]uint64) *distribution { - dist := &distribution{ - baseInv: u.inventory(u.baseID, perLot.dexBase, perLot.cexBase), - quoteInv: u.inventory(u.quoteID, perLot.dexQuote, perLot.cexQuote), - perLot: perLot, +func (u *unifiedExchangeAdaptor) newDistribution(perLot *lotCosts) *distribution { + return &distribution{ + baseInv: u.inventory(u.baseID, u.baseFeeID, perLot.dexBase, perLot.cexBase), + quoteInv: u.inventory(u.quoteID, u.quoteFeeID, perLot.dexQuote, perLot.cexQuote), + perLot: perLot, + feeReserveTopUps: make(map[uint32]uint64), } - dist.baseInv.dexAdditionalAvailable = additionalDEX[u.baseID] - dist.baseInv.cexAdditionalAvailable = additionalCEX[u.baseID] - dist.quoteInv.dexAdditionalAvailable = additionalDEX[u.quoteID] - dist.quoteInv.cexAdditionalAvailable = additionalCEX[u.quoteID] - return dist } -// optimizeTransfers populates the toDeposit and toWithdraw fields of the base -// and quote assetDistribution. To find the best asset distribution, a series -// of possible target configurations are tested and the distribution that -// results in the highest matchability is chosen. -func (u *unifiedExchangeAdaptor) optimizeTransfers(dist *distribution, dexSellLots, dexBuyLots, maxSellLots, maxBuyLots uint64) { - baseInv, quoteInv := dist.baseInv, dist.quoteInv - perLot := dist.perLot +type feeReserveInfo struct { + reserves uint64 + swapRefundFee uint64 + redeemFee uint64 +} + +const feeReserveBuffer uint64 = 2 // 2% buffer + +// requiredTopUps calculates the required top-ups of fee reserves for assets +// that have a fee asset which is neither the base nor quote asset in order +// to be able to place all the required orders. +func (u *unifiedExchangeAdaptor) requiredTopUps(baseInfo, quoteInfo *feeReserveInfo, maxBuyLots, maxSellLots uint64) map[uint32]uint64 { + feeReserveTopUps := map[uint32]uint64{} + + applyFeeReserveBuffer := func(amt uint64) uint64 { + return amt * (100 + feeReserveBuffer) / 100 + } + + sameFeeAssets := u.baseFeeID == u.quoteFeeID + baseMayRequireTopUp := u.baseFeeID != u.baseID && u.baseFeeID != u.quoteID + quoteMayRequireTopUp := u.quoteFeeID != u.baseID && u.quoteFeeID != u.quoteID + + if sameFeeAssets && baseMayRequireTopUp { + feeRequired := maxSellLots * (baseInfo.swapRefundFee + quoteInfo.redeemFee) + feeRequired += maxBuyLots * (baseInfo.redeemFee + quoteInfo.swapRefundFee) + feeRequired = applyFeeReserveBuffer(feeRequired) + if feeRequired > baseInfo.reserves { + feeReserveTopUps[u.baseFeeID] = feeRequired - baseInfo.reserves + } + } + if !sameFeeAssets && baseMayRequireTopUp { + baseFeeRequired := baseInfo.swapRefundFee*maxSellLots + baseInfo.redeemFee*maxBuyLots + baseFeeRequired = applyFeeReserveBuffer(baseFeeRequired) + if baseFeeRequired > baseInfo.reserves { + feeReserveTopUps[u.baseFeeID] = baseFeeRequired - baseInfo.reserves + } + } + if !sameFeeAssets && quoteMayRequireTopUp { + quoteFeeRequired := quoteInfo.swapRefundFee*maxBuyLots + quoteInfo.redeemFee*maxSellLots + quoteFeeRequired = applyFeeReserveBuffer(quoteFeeRequired) + if quoteFeeRequired > quoteInfo.reserves { + feeReserveTopUps[u.quoteFeeID] = quoteFeeRequired - quoteInfo.reserves + } + } + + return feeReserveTopUps +} + +func (u *unifiedExchangeAdaptor) feeReserveInfo() (base, quote *feeReserveInfo, err error) { + buyFees, sellFees, err := u.orderFees() + if err != nil { + return nil, nil, fmt.Errorf("error getting order fees: %w", err) + } + + u.balancesMtx.RLock() + defer u.balancesMtx.RUnlock() + + baseFeeBalance := u.dexBalance(u.baseFeeID) + quoteFeeBalance := u.dexBalance(u.quoteFeeID) + + base = &feeReserveInfo{ + reserves: baseFeeBalance.Available + baseFeeBalance.Locked, + swapRefundFee: sellFees.Max.Swap + sellFees.Max.Refund, + redeemFee: buyFees.Max.Redeem, + } + + quote = &feeReserveInfo{ + reserves: quoteFeeBalance.Available + quoteFeeBalance.Locked, + swapRefundFee: buyFees.Max.Swap + buyFees.Max.Refund, + redeemFee: sellFees.Max.Redeem, + } + + return +} + +func (u *unifiedExchangeAdaptor) topUpFeeReserves(maxSellLots, maxBuyLots uint64) { + if u.autoRebalanceCfg == nil || !u.autoRebalanceCfg.TopUpFeeReserves { + return + } + + baseInfo, quoteInfo, err := u.feeReserveInfo() + if err != nil { + u.log.Errorf("Error getting fee reserve info: %v", err) + return + } + + requiredTopUps := u.requiredTopUps(baseInfo, quoteInfo, maxBuyLots, maxSellLots) + + if len(requiredTopUps) == 0 { + return + } + + err = u.internalTransfer(u.mwh, func(dexAvail, _ map[uint32]uint64) error { + feeReserveTopUps := make(map[uint32]uint64) + for assetID, topUp := range requiredTopUps { + toTopUp := utils.Min(topUp, dexAvail[assetID]) + if toTopUp > 0 { + feeReserveTopUps[assetID] = toTopUp + } + } + + if len(feeReserveTopUps) == 0 { + return nil + } + + u.doInternalTransfers(0, 0, 0, 0, feeReserveTopUps) + return nil + }) + if err != nil { + u.log.Errorf("internal transfer error: %v", err) + } +} + +// optimizeTransfers populates the toDeposit and toWithdraw fields of the base +// and quote assetDistributions, and the feeReserveTopUps map. To find the best +// asset distribution, a series of possible target configurations are tested and +// the distribution that results in the highest matchability score is chosen. +func (u *unifiedExchangeAdaptor) optimizeTransfers(dist *distribution, dexSellLots, dexBuyLots, maxSellLots, maxBuyLots uint64, + dexAdditionalAvailable, cexAdditionalAvailable map[uint32]uint64) { if u.autoRebalanceCfg == nil { return } + + baseInv, quoteInv := dist.baseInv, dist.quoteInv + perLot := dist.perLot minBaseTransfer, minQuoteTransfer := u.autoRebalanceCfg.MinBaseTransfer, u.autoRebalanceCfg.MinQuoteTransfer additionalBaseFees, additionalQuoteFees := perLot.baseFunding, perLot.quoteFunding @@ -2758,6 +2876,40 @@ func (u *unifiedExchangeAdaptor) optimizeTransfers(dist *distribution, dexSellLo quoteAvail = quoteInv.total - additionalQuoteFees } + if u.autoRebalanceCfg.TopUpFeeReserves { + baseFeeReserveInfo := &feeReserveInfo{ + reserves: baseInv.feeReserves, + swapRefundFee: perLot.baseSwap + perLot.baseRefund, + redeemFee: perLot.baseRedeem, + } + quoteFeeReserveInfo := &feeReserveInfo{ + reserves: quoteInv.feeReserves, + swapRefundFee: perLot.quoteSwap + perLot.quoteRefund, + redeemFee: perLot.quoteRedeem, + } + requiredTopUps := u.requiredTopUps(baseFeeReserveInfo, quoteFeeReserveInfo, maxBuyLots, maxSellLots) + for assetID, topUp := range requiredTopUps { + var toTopUp uint64 + if assetID == u.baseFeeID { + toTopUp = utils.Min(dexAdditionalAvailable[u.baseFeeID], topUp) + } else { + toTopUp = utils.Min(dexAdditionalAvailable[u.quoteFeeID], topUp) + } + if toTopUp > 0 { + dist.feeReserveTopUps[assetID] = toTopUp + } + } + } + + if !u.autoRebalanceCfg.InternalTransfers && minBaseTransfer == 0 && minQuoteTransfer == 0 { + return + } + + baseDEXAdditionalAvailable := dexAdditionalAvailable[u.baseID] + quoteDEXAdditionalAvailable := dexAdditionalAvailable[u.quoteID] + baseCEXAdditionalAvailable := cexAdditionalAvailable[u.baseID] + quoteCEXAdditionalAvailable := cexAdditionalAvailable[u.quoteID] + // matchability is the number of lots that can be matched with a specified // asset distribution. matchability := func(dexBaseLots, dexQuoteLots, cexBaseLots, cexQuoteLots uint64) uint64 { @@ -2870,8 +3022,8 @@ func (u *unifiedExchangeAdaptor) optimizeTransfers(dist *distribution, dexSellLo if dexTarget > baseInv.dex { toWithdraw := dexTarget - baseInv.dex if baseInternal { - baseWithdraw = utils.Min(dist.baseInv.dexAdditionalAvailable, toWithdraw, dist.baseInv.cexAvail) - if toWithdraw > dist.baseInv.dexAdditionalAvailable { + baseWithdraw = utils.Min(baseDEXAdditionalAvailable, toWithdraw, dist.baseInv.cexAvail) + if toWithdraw > baseDEXAdditionalAvailable { dexTotal := utils.SafeSub(dist.baseInv.dex+baseWithdraw, additionalBaseFees) actualDexBaseLots = dexTotal / perLot.dexBase actualCexBaseLots = utils.SafeSub(dist.baseInv.cex, baseWithdraw) / perLot.cexBase @@ -2885,8 +3037,8 @@ func (u *unifiedExchangeAdaptor) optimizeTransfers(dist *distribution, dexSellLo } else if cexTarget > baseInv.cex { toDeposit := cexTarget - baseInv.cex if baseInternal { - baseDeposit = utils.Min(dist.baseInv.cexAdditionalAvailable, toDeposit, dist.baseInv.dexAvail) - if toDeposit > dist.baseInv.cexAdditionalAvailable { + baseDeposit = utils.Min(baseCEXAdditionalAvailable, toDeposit, dist.baseInv.dexAvail) + if toDeposit > baseCEXAdditionalAvailable { dexTotal := utils.SafeSub(dist.baseInv.dex, baseDeposit) dexTotal = utils.SafeSub(dexTotal, additionalBaseFees) actualDexBaseLots = dexTotal / perLot.dexBase @@ -2908,8 +3060,8 @@ func (u *unifiedExchangeAdaptor) optimizeTransfers(dist *distribution, dexSellLo if dexTarget > quoteInv.dex { toWithdraw := dexTarget - quoteInv.dex if quoteInternal { - quoteWithdraw = utils.Min(dist.quoteInv.dexAdditionalAvailable, toWithdraw, dist.quoteInv.cexAvail) - if toWithdraw > dist.quoteInv.dexAdditionalAvailable { + quoteWithdraw = utils.Min(quoteDEXAdditionalAvailable, toWithdraw, dist.quoteInv.cexAvail) + if toWithdraw > quoteDEXAdditionalAvailable { dexTotal := utils.SafeSub(dist.quoteInv.dex+quoteWithdraw, additionalQuoteFees) actualDexQuoteLots = dexTotal / perLot.dexQuote actualCexQuoteLots = (dist.quoteInv.cex - quoteWithdraw) / perLot.cexQuote @@ -2923,8 +3075,8 @@ func (u *unifiedExchangeAdaptor) optimizeTransfers(dist *distribution, dexSellLo } else if cexTarget > quoteInv.cex { toDeposit := cexTarget - quoteInv.cex if quoteInternal { - quoteDeposit = utils.Min(dist.quoteInv.cexAdditionalAvailable, toDeposit, dist.quoteInv.dexAvail) - if toDeposit > dist.quoteInv.cexAdditionalAvailable { + quoteDeposit = utils.Min(quoteCEXAdditionalAvailable, toDeposit, dist.quoteInv.dexAvail) + if toDeposit > quoteCEXAdditionalAvailable { dexTotal := utils.SafeSub(dist.quoteInv.dex, quoteDeposit) dexTotal = utils.SafeSub(dexTotal, additionalQuoteFees) actualDexQuoteLots = dexTotal / perLot.dexQuote @@ -3016,24 +3168,41 @@ func (u *unifiedExchangeAdaptor) optimizeTransfers(dist *distribution, dexSellLo } } -type distributionFunc func(dexAvail, cexAvail map[uint32]uint64) (*distribution, error) - -func (u *unifiedExchangeAdaptor) doInternalTransfers(dist *distribution) { +func (u *unifiedExchangeAdaptor) doInternalTransfers(baseDeposit, baseWithdraw, quoteDeposit, quoteWithdraw uint64, feeReserveTopUps map[uint32]uint64) { u.balancesMtx.Lock() + dexDiffs, cexDiffs := make(map[uint32]int64), make(map[uint32]int64) + dexDiffs[u.baseID] = int64(baseWithdraw - baseDeposit) + cexDiffs[u.baseID] = -int64(baseWithdraw - baseDeposit) + dexDiffs[u.quoteID] = int64(quoteWithdraw - quoteDeposit) + cexDiffs[u.quoteID] = -int64(quoteWithdraw - quoteDeposit) - dexDiffs[u.baseID] = int64(dist.baseInv.toInternalWithdraw - dist.baseInv.toInternalDeposit) - cexDiffs[u.baseID] = -int64(dist.baseInv.toInternalWithdraw - dist.baseInv.toInternalDeposit) - dexDiffs[u.quoteID] = int64(dist.quoteInv.toInternalWithdraw - dist.quoteInv.toInternalDeposit) - cexDiffs[u.quoteID] = -int64(dist.quoteInv.toInternalWithdraw - dist.quoteInv.toInternalDeposit) + toppedUpFeeReserves := false + for feeAsset, topUp := range feeReserveTopUps { + toppedUpFeeReserves = toppedUpFeeReserves || topUp != 0 + dexDiffs[feeAsset] += int64(topUp) + u.inventoryMods[feeAsset] += int64(topUp) + } + + for assetID, diff := range dexDiffs { + u.baseDexBalances[assetID] += diff + } + + for assetID, diff := range cexDiffs { + u.baseCexBalances[assetID] += diff + } - u.baseDexBalances[u.baseID] += dexDiffs[u.baseID] - u.baseCexBalances[u.baseID] += cexDiffs[u.baseID] - u.baseDexBalances[u.quoteID] += dexDiffs[u.quoteID] - u.baseCexBalances[u.quoteID] += cexDiffs[u.quoteID] u.balancesMtx.Unlock() - if dexDiffs[u.baseID] != 0 || dexDiffs[u.quoteID] != 0 { + if toppedUpFeeReserves { + inventoryUpdates := make(map[uint32]int64) + for assetID, topUp := range feeReserveTopUps { + inventoryUpdates[assetID] = int64(topUp) + } + u.updateInventoryEvent(inventoryUpdates) + } + + if toppedUpFeeReserves || baseDeposit > 0 || baseWithdraw > 0 || quoteDeposit > 0 || quoteWithdraw > 0 { u.logBalanceAdjustments(dexDiffs, cexDiffs, "internal transfers") } } @@ -3129,6 +3298,8 @@ func (u *unifiedExchangeAdaptor) doExternalTransfers(dist *distribution, currEpo return true, nil } +type distributionFunc func(dexAvail, cexAvail map[uint32]uint64) (*distribution, error) + func (u *unifiedExchangeAdaptor) tryTransfers(currEpoch uint64, df distributionFunc) (actionTaken bool, err error) { if u.autoRebalanceCfg == nil { return false, nil @@ -3141,7 +3312,11 @@ func (u *unifiedExchangeAdaptor) tryTransfers(currEpoch uint64, df distributionF return fmt.Errorf("distribution calculation error: %w", err) } - u.doInternalTransfers(dist) + baseDeposit := dist.baseInv.toInternalDeposit + baseWithdraw := dist.baseInv.toInternalWithdraw + quoteDeposit := dist.quoteInv.toInternalDeposit + quoteWithdraw := dist.quoteInv.toInternalWithdraw + u.doInternalTransfers(baseDeposit, baseWithdraw, quoteDeposit, quoteWithdraw, dist.feeReserveTopUps) return nil }) if err != nil { @@ -3159,15 +3334,17 @@ type assetInventory struct { dexPending uint64 dexLots uint64 + // feeReserves is the amount of the fee asset that is available on the dex. + // This is only populated if the fee asset is neither the base nor quote + // asset. + feeReserves uint64 + cex uint64 cexAvail uint64 cexLots uint64 total uint64 - dexAdditionalAvailable uint64 - cexAdditionalAvailable uint64 - toDeposit uint64 toWithdraw uint64 toInternalDeposit uint64 @@ -3176,7 +3353,7 @@ type assetInventory struct { // inventory generates a current view of the the bot's asset distribution. // Use optimizeTransfers to set toDeposit and toWithdraw. -func (u *unifiedExchangeAdaptor) inventory(assetID uint32, dexLot, cexLot uint64) (b *assetInventory) { +func (u *unifiedExchangeAdaptor) inventory(assetID, feeAssetID uint32, dexLot, cexLot uint64) (b *assetInventory) { b = new(assetInventory) u.balancesMtx.RLock() defer u.balancesMtx.RUnlock() @@ -3191,6 +3368,12 @@ func (u *unifiedExchangeAdaptor) inventory(assetID uint32, dexLot, cexLot uint64 b.cex = cexBalance.Available + cexBalance.Reserved + cexBalance.Pending b.cexLots = b.cex / cexLot b.total = b.dex + b.cex + + if feeAssetID != u.baseID && feeAssetID != u.quoteID { + feeBalance := u.dexBalance(feeAssetID) + b.feeReserves = feeBalance.Available + feeBalance.Locked + } + return } @@ -3625,7 +3808,7 @@ type exchangeAdaptorCfg struct { log dex.Logger eventLogDB eventLogDB botCfg *BotConfig - internalTransfer func(*MarketWithHost, doTransferFunc) error + internalTransfer func(*MarketWithHost, doInternalTransferFunc) error } // newUnifiedExchangeAdaptor is the constructor for a unifiedExchangeAdaptor. diff --git a/client/mm/exchange_adaptor_test.go b/client/mm/exchange_adaptor_test.go index d71c80646f..5c25bfbe3c 100644 --- a/client/mm/exchange_adaptor_test.go +++ b/client/mm/exchange_adaptor_test.go @@ -469,23 +469,31 @@ func TestFreeUpFunds(t *testing.T) { } func TestDistribution(t *testing.T) { - // utxo/utxo - testDistribution(t, 42, 0) - // utxo/account-locker - testDistribution(t, 42, 60) - testDistribution(t, 60, 42) - // token/parent - testDistribution(t, 60001, 60) - testDistribution(t, 60, 60001) - // token/token - same chain - testDistribution(t, 966002, 966001) - testDistribution(t, 966001, 966002) - // token/token - different chains - testDistribution(t, 60001, 966003) - testDistribution(t, 966003, 60001) - // utxo/token - testDistribution(t, 42, 966003) - testDistribution(t, 966003, 42) + tests := [][2]uint32{ + // utxo/utxo + {42, 0}, + // utxo/account-locker + {42, 60}, + {60, 42}, + // token/parent + {60001, 60}, + {60, 60001}, + // token/token - same chain + {966002, 966001}, + {966001, 966002}, + // token/token - different chains + {60001, 966003}, + {966003, 60001}, + // utxo/token + {42, 966003}, + {966003, 42}, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("%d/%d", test[0], test[1]), func(t *testing.T) { + testDistribution(t, test[0], test[1]) + }) + } } func testDistribution(t *testing.T, baseID, quoteID uint32) { @@ -507,7 +515,7 @@ func testDistribution(t *testing.T, baseID, quoteID uint32) { tCore := newTCore() u.CEX = cex u.clientCore = tCore - u.autoRebalanceCfg = &AutoRebalanceConfig{} + u.autoRebalanceCfg = &AutoRebalanceConfig{TopUpFeeReserves: true} a := &arbMarketMaker{unifiedExchangeAdaptor: u} a.cfgV.Store(&ArbMarketMakerConfig{Profit: profit}) fiatRates := map[uint32]float64{baseID: 1, quoteID: 1} @@ -579,6 +587,18 @@ func testDistribution(t *testing.T, baseID, quoteID uint32) { a.baseCexBalances[quoteID] = int64(cexQuote) } + // setFeeBal should only be used when the fee asset is not the + // other asset on the market. setLots will set the fee balances + // in order not to require top ups. This should be called again + // to test fee top ups. + setFeeBal := func(base bool, amt int64) { + if base { + a.baseDexBalances[u.baseFeeID] = amt + } else { + a.baseDexBalances[u.quoteFeeID] = amt + } + } + setLots := func(b, s uint64) { buyLots, sellLots = b, s a.placementLotsV.Store(&placementLots{ @@ -628,7 +648,18 @@ func testDistribution(t *testing.T, baseID, quoteID uint32) { updateInternalTransferBalances(u, dexAvailableBalances, cexAvailableBalances) } - checkDistribution := func(baseDeposit, baseWithdraw, quoteDeposit, quoteWithdraw uint64, baseInternal, quoteInternal bool) { + // setAvailableFeeBalances should only be used when the fee asset is not the + // other asset on the market. + setAvailableFeeBalance := func(base bool, amt uint64) { + if base { + dexAvailableBalances[u.baseFeeID] = amt + } else { + dexAvailableBalances[u.quoteFeeID] = amt + } + updateInternalTransferBalances(u, dexAvailableBalances, cexAvailableBalances) + } + + checkDistribution := func(baseDeposit, baseWithdraw, quoteDeposit, quoteWithdraw uint64, baseInternal, quoteInternal bool, feeReserveTopUps ...map[uint32]uint64) { t.Helper() dist, err := a.distribution(dexAvailableBalances, cexAvailableBalances) if err != nil { @@ -679,6 +710,27 @@ func testDistribution(t *testing.T, baseID, quoteID uint32) { if dist.quoteInv.toInternalWithdraw != expQuoteInternalWithdraw { t.Fatalf("wrong quote internal withrawal size. wanted %d, got %d", expQuoteInternalWithdraw, dist.quoteInv.toInternalWithdraw) } + + if len(feeReserveTopUps) > 0 != (len(dist.feeReserveTopUps) > 0) { + t.Fatalf("expected fee top ups %v, but got %v", len(feeReserveTopUps) > 0, len(dist.feeReserveTopUps) > 0) + } + if len(feeReserveTopUps) > 0 { + feeReserveTopUps := feeReserveTopUps[0] + for assetID, topUp := range feeReserveTopUps { + if topUp == 0 { + delete(feeReserveTopUps, assetID) + } + } + + if len(feeReserveTopUps) != len(dist.feeReserveTopUps) { + t.Fatalf("wrong number of fee top ups. wanted %d, got %d", len(feeReserveTopUps), len(dist.feeReserveTopUps)) + } + for assetID, exp := range feeReserveTopUps { + if dist.feeReserveTopUps[assetID] != exp { + t.Fatalf("wrong fee top up for asset %d. wanted %d, got %d", assetID, exp, dist.feeReserveTopUps[assetID]) + } + } + } } setLots(1, 1) @@ -686,6 +738,64 @@ func testDistribution(t *testing.T, baseID, quoteID uint32) { setBals(minDexBase, minCexBase, minDexQuote, minCexQuote) checkDistribution(0, 0, 0, 0, false, false) + // Test fee top ups. + baseCouldNeedTopUp := baseID != u.baseFeeID && quoteID != u.baseFeeID + quoteCouldNeedTopUp := baseID != u.quoteFeeID && quoteID != u.quoteFeeID + if baseCouldNeedTopUp || quoteCouldNeedTopUp { + applyTopUpBuffer := func(topUp uint64) uint64 { + return topUp * (100 + feeReserveBuffer) / 100 + } + + var minBaseFeeAsset, minQuoteFeeAsset uint64 + if baseCouldNeedTopUp { + minBaseFeeAsset = applyTopUpBuffer(sellSwapFees + buyRedeemFees + sellRefundFees) + setAvailableFeeBalance(true, minBaseFeeAsset) + } + if quoteCouldNeedTopUp { + minQuoteFeeAsset = applyTopUpBuffer(buySwapFees + sellRedeemFees + buyRefundFees) + setAvailableFeeBalance(false, minQuoteFeeAsset) + } + + if u.baseFeeID == u.quoteFeeID { + setAvailableFeeBalance(true, minBaseFeeAsset+minQuoteFeeAsset) + checkDistribution(0, 0, 0, 0, false, false, map[uint32]uint64{u.baseFeeID: minBaseFeeAsset + minQuoteFeeAsset}) + } else { + checkDistribution(0, 0, 0, 0, false, false, map[uint32]uint64{u.baseFeeID: minBaseFeeAsset, u.quoteFeeID: minQuoteFeeAsset}) + } + + if baseCouldNeedTopUp { + setFeeBal(true, 100) + } + if quoteCouldNeedTopUp { + setFeeBal(false, 100) + } + if u.baseFeeID == u.quoteFeeID { + checkDistribution(0, 0, 0, 0, false, false, map[uint32]uint64{u.baseFeeID: minBaseFeeAsset + minQuoteFeeAsset - 100}) + } else { + checkDistribution(0, 0, 0, 0, false, false, map[uint32]uint64{u.baseFeeID: utils.SafeSub(minBaseFeeAsset, 100), u.quoteFeeID: utils.SafeSub(minQuoteFeeAsset, 100)}) + } + + if baseCouldNeedTopUp { + setFeeBal(true, 0) + setAvailableFeeBalance(true, minBaseFeeAsset-50) + } + if quoteCouldNeedTopUp { + setFeeBal(false, 0) + setAvailableFeeBalance(false, minQuoteFeeAsset-50) + } + + if u.baseFeeID == u.quoteFeeID { + setAvailableFeeBalance(true, minBaseFeeAsset+minQuoteFeeAsset-50) + checkDistribution(0, 0, 0, 0, false, false, map[uint32]uint64{u.baseFeeID: minBaseFeeAsset + minQuoteFeeAsset - 50}) + } else { + checkDistribution(0, 0, 0, 0, false, false, map[uint32]uint64{u.baseFeeID: utils.SafeSub(minBaseFeeAsset, 50), u.quoteFeeID: utils.SafeSub(minQuoteFeeAsset, 50)}) + } + + setAvailableFeeBalance(true, 0) + setAvailableFeeBalance(false, 0) + setFeeBal(true, 0) + } + // Move all of the base balance to cex and max sure we get a withdraw. setBals(0, totalBase, minDexQuote, minCexQuote) checkDistribution(0, minDexBase, 0, 0, false, false) @@ -700,9 +810,9 @@ func testDistribution(t *testing.T, baseID, quoteID uint32) { // Raise the transfer theshold by one atom and it should zero the withdraw. a.autoRebalanceCfg.MinBaseTransfer = minDexBase + 1 checkDistribution(0, 0, 0, 0, false, false) - a.autoRebalanceCfg.MinBaseTransfer = 0 // Same for quote + setLots(1, 1) setBals(minDexBase, minCexBase, 0, totalQuote) checkDistribution(0, 0, 0, minDexQuote, false, false) setAvailableBalances(0, 0, minDexQuote, 0) @@ -712,9 +822,9 @@ func testDistribution(t *testing.T, baseID, quoteID uint32) { setAvailableBalances(0, 0, 0, 0) a.autoRebalanceCfg.MinQuoteTransfer = minDexQuote + 1 checkDistribution(0, 0, 0, 0, false, false) - a.autoRebalanceCfg.MinQuoteTransfer = 0 // Base deposit + setLots(1, 1) setBals(totalBase, 0, minDexQuote, minCexQuote) checkDistribution(minCexBase, 0, 0, 0, false, false) setAvailableBalances(0, minCexBase, 0, 0) @@ -1034,6 +1144,178 @@ func testDistribution(t *testing.T, baseID, quoteID uint32) { u.market = mustParseMarket(&core.Market{}) } +func TestFeeAssetTopUp(t *testing.T) { + tests := [][2]uint32{ + // utxo/utxo + {42, 0}, + // utxo/account-locker + {42, 60}, + {60, 42}, + // token/parent + {60001, 60}, + {60, 60001}, + // token/token - same chain + {966002, 966001}, + {966001, 966002}, + // token/token - different chains + {60001, 966003}, + {966003, 60001}, + // utxo/token + {42, 966003}, + {966003, 42}, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("%d/%d", test[0], test[1]), func(t *testing.T) { + testFeeAssetTopUp(t, test[0], test[1]) + }) + } +} + +func testFeeAssetTopUp(t *testing.T, baseID, quoteID uint32) { + const lotSize = 5e7 + const sellSwapFees, sellRedeemFees = 3e5, 1e5 + const buySwapFees, buyRedeemFees = 2e4, 1e4 + const sellRefundFees, buyRefundFees = 8e3, 9e4 + u := mustParseAdaptorFromMarket(&core.Market{ + LotSize: lotSize, + BaseID: baseID, + QuoteID: quoteID, + RateStep: 1e2, + }) + cex := newTCEX() + tCore := newTCore() + u.CEX = cex + u.clientCore = tCore + u.autoRebalanceCfg = &AutoRebalanceConfig{TopUpFeeReserves: true} + + maxBuyFees := &LotFees{ + Swap: buySwapFees, + Redeem: buyRedeemFees, + Refund: buyRefundFees, + } + maxSellFees := &LotFees{ + Swap: sellSwapFees, + Redeem: sellRedeemFees, + Refund: sellRefundFees, + } + + buyBookingFees, sellBookingFees := u.bookingFees(maxBuyFees, maxSellFees) + u.inventoryMods = make(map[uint32]int64) + fiatRates := map[uint32]float64{baseID: 1, quoteID: 1} + u.fiatRates.Store(fiatRates) + + u.buyFees = &orderFees{ + LotFeeRange: &LotFeeRange{ + Max: maxBuyFees, + Estimated: &LotFees{ + Swap: buySwapFees, + Redeem: buyRedeemFees, + Refund: buyRefundFees, + }, + }, + bookingFeesPerLot: buyBookingFees, + } + u.sellFees = &orderFees{ + LotFeeRange: &LotFeeRange{ + Max: maxSellFees, + Estimated: &LotFees{ + Swap: sellSwapFees, + Redeem: sellRedeemFees, + }, + }, + bookingFeesPerLot: sellBookingFees, + } + + setFeeBal := func(base bool, amt int64) { + if base { + u.baseDexBalances[u.baseFeeID] = amt + } else { + u.baseDexBalances[u.quoteFeeID] = amt + } + } + + dexAvailableBalances := map[uint32]uint64{} + cexAvailableBalances := map[uint32]uint64{} + setAvailableFeeBalance := func(base bool, amt uint64) { + if base { + dexAvailableBalances[u.baseFeeID] = amt + } else { + dexAvailableBalances[u.quoteFeeID] = amt + } + updateInternalTransferBalances(u, dexAvailableBalances, cexAvailableBalances) + } + + checkTopUps := func(buyLots, sellLots uint64, expBaseTopUp, expQuoteTopUp uint64) { + u.balancesMtx.RLock() + initialBaseFeeAsset, initialQuoteFeeAsset := u.baseDexBalances[u.baseFeeID], u.baseDexBalances[u.quoteFeeID] + u.balancesMtx.RUnlock() + + u.topUpFeeReserves(buyLots, sellLots) + + u.balancesMtx.RLock() + baseFeeAssetDiff := u.baseDexBalances[u.baseFeeID] - initialBaseFeeAsset + quoteFeeAssetDiff := u.baseDexBalances[u.quoteFeeID] - initialQuoteFeeAsset + u.balancesMtx.RUnlock() + + if baseFeeAssetDiff != int64(expBaseTopUp) { + t.Fatalf("wrong base fee asset top up. wanted %d, got %d", expBaseTopUp, baseFeeAssetDiff) + } + + if quoteFeeAssetDiff != int64(expQuoteTopUp) { + t.Fatalf("wrong quote fee asset top up. wanted %d, got %d", expQuoteTopUp, quoteFeeAssetDiff) + } + } + + sameFeeAsset := u.baseFeeID == u.quoteFeeID + baseFeeAssetMayNeedTopUp := baseID != u.baseFeeID && quoteID != u.baseFeeID + quoteFeeAssetMayNeedTopUp := baseID != u.quoteFeeID && quoteID != u.quoteFeeID + + minFeeAssets := func(buyLots, sellLots uint64) (uint64, uint64) { + applyTopUpBuffer := func(topUp uint64) uint64 { + return topUp * (100 + feeReserveBuffer) / 100 + } + + var minBaseFeeAsset, minQuoteFeeAsset uint64 + if baseFeeAssetMayNeedTopUp { + minBaseFeeAsset = sellLots * (sellSwapFees + sellRefundFees) + minBaseFeeAsset += buyLots * buyRedeemFees + } + if quoteFeeAssetMayNeedTopUp { + minQuoteFeeAsset = buyLots * (buySwapFees + buyRefundFees) + minQuoteFeeAsset += sellLots * sellRedeemFees + } + + if sameFeeAsset { + minBaseFeeAsset += minQuoteFeeAsset + minQuoteFeeAsset = minBaseFeeAsset + } + + return applyTopUpBuffer(minBaseFeeAsset), applyTopUpBuffer(minQuoteFeeAsset) + } + + minBaseFeeAsset, minQuoteFeeAsset := minFeeAssets(1, 1) + setFeeBal(true, 0) + setFeeBal(false, 0) + setAvailableFeeBalance(true, 0) + setAvailableFeeBalance(false, 0) + checkTopUps(1, 1, 0, 0) + + setAvailableFeeBalance(true, minBaseFeeAsset) + setAvailableFeeBalance(false, minQuoteFeeAsset) + checkTopUps(1, 1, minBaseFeeAsset, minQuoteFeeAsset) + + setFeeBal(true, 100) + setFeeBal(false, 100) + checkTopUps(1, 1, utils.SafeSub(minBaseFeeAsset, 100), utils.SafeSub(minQuoteFeeAsset, 100)) + + setFeeBal(true, 0) + setFeeBal(false, 0) + setAvailableFeeBalance(true, minBaseFeeAsset-200) + setAvailableFeeBalance(false, minQuoteFeeAsset-200) + checkTopUps(1, 1, utils.SafeSub(minBaseFeeAsset, 200), utils.SafeSub(minQuoteFeeAsset, 200)) +} + func TestMultiTrade(t *testing.T) { const lotSize uint64 = 50e8 const rateStep uint64 = 1e3 diff --git a/client/mm/mm.go b/client/mm/mm.go index 40c33f502f..de71b13fd6 100644 --- a/client/mm/mm.go +++ b/client/mm/mm.go @@ -931,7 +931,7 @@ func validRunningBotCfgUpdate(oldCfg, newCfg *BotConfig) error { // // ** IMPORTANT ** No mutexes in exchangeAdaptor should be locked when calling this // function. -func (m *MarketMaker) internalTransfer(mkt *MarketWithHost, doTransfer doTransferFunc) error { +func (m *MarketMaker) internalTransfer(mkt *MarketWithHost, doTransfer doInternalTransferFunc) error { m.startUpdateMtx.Lock() defer m.startUpdateMtx.Unlock() @@ -940,9 +940,6 @@ func (m *MarketMaker) internalTransfer(mkt *MarketWithHost, doTransfer doTransfe if !found { return fmt.Errorf("internalTransfer called for non-running bot %s", mkt) } - if rb.cexCfg == nil { - return fmt.Errorf("internalTransfer called for bot without CEX config %s", mkt) - } dex, cex, err := m.availableBalances(mkt, rb.cexCfg) if err != nil { diff --git a/client/mm/mm_arb_market_maker.go b/client/mm/mm_arb_market_maker.go index 2691e98f81..6b3a8a2262 100644 --- a/client/mm/mm_arb_market_maker.go +++ b/client/mm/mm_arb_market_maker.go @@ -320,8 +320,8 @@ func (a *arbMarketMaker) distribution(additionalDEX, additionalCEX map[uint32]ui if perLot == nil { return nil, fmt.Errorf("error getting lot costs: %w", err) } - dist = a.newDistribution(perLot, additionalDEX, additionalCEX) - a.optimizeTransfers(dist, dexSellLots, dexBuyLots, dexSellLots, dexBuyLots) + dist = a.newDistribution(perLot) + a.optimizeTransfers(dist, dexSellLots, dexBuyLots, dexSellLots, dexBuyLots, additionalDEX, additionalCEX) return dist, nil } diff --git a/client/mm/mm_arb_market_maker_test.go b/client/mm/mm_arb_market_maker_test.go index 5ebd4763dd..4bd2ba4610 100644 --- a/client/mm/mm_arb_market_maker_test.go +++ b/client/mm/mm_arb_market_maker_test.go @@ -443,14 +443,14 @@ func mustParseAdaptorFromMarket(m *core.Market) *unifiedExchangeAdaptor { eventLogDB: newTEventLogDB(), pendingDeposits: make(map[string]*pendingDeposit), pendingWithdrawals: make(map[string]*pendingWithdrawal), - internalTransfer: func(mwh *MarketWithHost, fn doTransferFunc) error { + internalTransfer: func(mwh *MarketWithHost, fn doInternalTransferFunc) error { return fn(map[uint32]uint64{}, map[uint32]uint64{}) }, } } func updateInternalTransferBalances(u *unifiedExchangeAdaptor, baseBal, quoteBal map[uint32]uint64) { - u.internalTransfer = func(mwh *MarketWithHost, fn doTransferFunc) error { + u.internalTransfer = func(mwh *MarketWithHost, fn doInternalTransferFunc) error { return fn(baseBal, quoteBal) } } diff --git a/client/mm/mm_basic.go b/client/mm/mm_basic.go index ae20c815da..df7d4f1822 100644 --- a/client/mm/mm_basic.go +++ b/client/mm/mm_basic.go @@ -274,6 +274,7 @@ func (b *basicMMCalculatorImpl) feeGapStats(basisPrice uint64) (*FeeGapStats, er type basicMarketMaker struct { *unifiedExchangeAdaptor cfgV atomic.Value // *BasicMarketMakingConfig + placementLotsV atomic.Value // *placementLots core botCoreAdaptor oracle oracle rebalanceRunning atomic.Bool @@ -373,7 +374,11 @@ func (m *basicMarketMaker) rebalance(newEpoch uint64) { } defer m.rebalanceRunning.Store(false) - m.log.Tracef("rebalance: epoch %d", newEpoch) + m.log.Infof("rebalance: epoch %d", newEpoch) + + placements := m.placementLotsV.Load().(*placementLots) + dexSellLots, dexBuyLots := placements.baseLots, placements.quoteLots + m.topUpFeeReserves(dexSellLots, dexBuyLots) buyOrders, sellOrders, err := m.ordersToPlace() if err != nil { @@ -422,6 +427,20 @@ func (m *basicMarketMaker) botLoop(ctx context.Context) (*sync.WaitGroup, error) return &wg, nil } +func (m *basicMarketMaker) setTransferConfig(cfg *BasicMarketMakingConfig) { + var baseLots, quoteLots uint64 + for _, p := range cfg.BuyPlacements { + quoteLots += p.Lots + } + for _, p := range cfg.SellPlacements { + baseLots += p.Lots + } + m.placementLotsV.Store(&placementLots{ + baseLots: baseLots, + quoteLots: quoteLots, + }) +} + func (m *basicMarketMaker) updateConfig(cfg *BotConfig) error { if cfg.BasicMMConfig == nil { // implies bug in caller @@ -434,6 +453,7 @@ func (m *basicMarketMaker) updateConfig(cfg *BotConfig) error { } m.cfgV.Store(cfg.BasicMMConfig) + m.setTransferConfig(cfg.BasicMMConfig) return nil } @@ -460,6 +480,7 @@ func newBasicMarketMaker(cfg *BotConfig, adaptorCfg *exchangeAdaptorCfg, oracle oracle: oracle, } basicMM.cfgV.Store(cfg.BasicMMConfig) + basicMM.setTransferConfig(cfg.BasicMMConfig) adaptor.setBotLoop(basicMM.botLoop) return basicMM, nil } diff --git a/client/mm/mm_basic_test.go b/client/mm/mm_basic_test.go index 624df55aaa..74e30bda56 100644 --- a/client/mm/mm_basic_test.go +++ b/client/mm/mm_basic_test.go @@ -356,6 +356,7 @@ func TestBasicMMRebalance(t *testing.T) { }, bookingFeesPerLot: sellSwapFees, } + mm.placementLotsV.Store(&placementLots{}) mm.baseDexBalances[baseID] = lotSize * 50 mm.baseCexBalances[baseID] = lotSize * 50 mm.baseDexBalances[quoteID] = int64(calc.BaseToQuote(basisPrice, lotSize*50)) diff --git a/client/mm/mm_simple_arb.go b/client/mm/mm_simple_arb.go index 01d2fd1366..576bebef0f 100644 --- a/client/mm/mm_simple_arb.go +++ b/client/mm/mm_simple_arb.go @@ -422,11 +422,11 @@ func (a *simpleArbMarketMaker) distribution(additionalDEX, additionalCEX map[uin if perLot == nil { return nil, fmt.Errorf("error getting lot costs: %w", err) } - dist = a.newDistribution(perLot, additionalDEX, additionalCEX) + dist = a.newDistribution(perLot) avgBaseLot, avgQuoteLot := float64(perLot.dexBase+perLot.cexBase)/2, float64(perLot.dexQuote+perLot.cexQuote)/2 baseLots := uint64(math.Round(float64(dist.baseInv.total) / avgBaseLot / 2)) quoteLots := uint64(math.Round(float64(dist.quoteInv.total) / avgQuoteLot / 2)) - a.optimizeTransfers(dist, baseLots, quoteLots, baseLots*2, quoteLots*2) + a.optimizeTransfers(dist, baseLots, quoteLots, baseLots*2, quoteLots*2, additionalDEX, additionalCEX) return dist, nil }