From 3e8478c502b95cd4f10f0444ef9792263d5f7f4c Mon Sep 17 00:00:00 2001 From: martonp Date: Fri, 21 Jun 2024 11:36:13 +0800 Subject: [PATCH 1/2] mm: Handle market params update This diff updates the market maker to listen for updates to the server config. If the lot size of a market that a bot is currently trading on is updated, the number of placements that the bot makes is updated so that the bot places as many lots as possible without exceeding the amount that was placed before the lot size update. --- client/core/core.go | 1 + client/core/notification.go | 15 ++++ client/mm/config.go | 105 +++++++++++++++++++++++++- client/mm/exchange_adaptor.go | 91 ++++++++++++++++------ client/mm/exchange_adaptor_test.go | 17 ++++- client/mm/mm.go | 33 +++++++- client/mm/mm_arb_market_maker.go | 100 ++++++++++++++---------- client/mm/mm_arb_market_maker_test.go | 33 ++++---- client/mm/mm_basic.go | 69 +++++++++++------ client/mm/mm_basic_test.go | 12 +-- client/mm/mm_simple_arb.go | 42 +++++------ client/mm/mm_simple_arb_test.go | 31 +++++--- client/rpcserver/handlers.go | 2 +- client/webserver/api.go | 2 +- client/webserver/live_test.go | 2 +- client/webserver/webserver.go | 2 +- 16 files changed, 402 insertions(+), 155 deletions(-) diff --git a/client/core/core.go b/client/core/core.go index b3884390ea..823291850d 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -8315,6 +8315,7 @@ func (c *Core) handleReconnect(host string) { c.log.Errorf("handleReconnect: Unable to apply new configuration for DEX at %s: %v", host, err) return } + c.notify(newServerConfigUpdateNote(host)) type market struct { // for book re-subscribe name string diff --git a/client/core/notification.go b/client/core/notification.go index f4d0f844fe..33f671c53a 100644 --- a/client/core/notification.go +++ b/client/core/notification.go @@ -686,6 +686,21 @@ func newUpgradeNote(topic Topic, subject, details string, severity db.Severity) } } +// ServerConfigUpdateNote is sent when a server's configuration is updated. +type ServerConfigUpdateNote struct { + db.Notification + Host string `json:"host"` +} + +const TopicServerConfigUpdate Topic = "ServerConfigUpdate" + +func newServerConfigUpdateNote(host string) *ServerConfigUpdateNote { + return &ServerConfigUpdateNote{ + Notification: db.NewNotification(NoteTypeServerNotify, TopicServerConfigUpdate, "", "", db.Data), + Host: host, + } +} + // WalletCreationNote is a notification regarding asynchronous wallet creation. type WalletCreationNote struct { db.Notification diff --git a/client/mm/config.go b/client/mm/config.go index ea11a2c2dd..6e5c5c72db 100644 --- a/client/mm/config.go +++ b/client/mm/config.go @@ -3,6 +3,8 @@ package mm import ( "encoding/json" "fmt" + + "decred.org/dcrdex/dex/utils" ) // MarketMakingConfig is the overall configuration of the market maker. @@ -49,12 +51,26 @@ type AutoRebalanceConfig struct { MinQuoteTransfer uint64 `json:"minQuoteTransfer"` } +func (a *AutoRebalanceConfig) copy() *AutoRebalanceConfig { + return &AutoRebalanceConfig{ + MinBaseTransfer: a.MinBaseTransfer, + MinQuoteTransfer: a.MinQuoteTransfer, + } +} + // BotBalanceAllocation is the initial allocation of funds for a bot. type BotBalanceAllocation struct { DEX map[uint32]uint64 `json:"dex"` CEX map[uint32]uint64 `json:"cex"` } +func (b *BotBalanceAllocation) copy() *BotBalanceAllocation { + return &BotBalanceAllocation{ + DEX: utils.CopyMap(b.DEX), + CEX: utils.CopyMap(b.CEX), + } +} + // BotInventoryDiffs is the amount of funds to add or remove from a bot's // allocation. type BotInventoryDiffs struct { @@ -62,6 +78,13 @@ type BotInventoryDiffs struct { CEX map[uint32]int64 `json:"cex"` } +func (d *BotInventoryDiffs) copy() *BotInventoryDiffs { + return &BotInventoryDiffs{ + DEX: utils.CopyMap(d.DEX), + CEX: utils.CopyMap(d.CEX), + } +} + // balanceDiffsToAllocations converts a BotInventoryDiffs to a // BotBalanceAllocation by removing all negative diffs. func balanceDiffsToAllocation(diffs *BotInventoryDiffs) *BotBalanceAllocation { @@ -89,6 +112,18 @@ func balanceDiffsToAllocation(diffs *BotInventoryDiffs) *BotBalanceAllocation { // should be created and the event log db should be updated to support both // versions. +type rpcConfig struct { + Alloc *BotBalanceAllocation `json:"alloc"` + AutoRebalance *AutoRebalanceConfig `json:"autoRebalance"` +} + +func (r *rpcConfig) copy() *rpcConfig { + return &rpcConfig{ + Alloc: r.Alloc.copy(), + AutoRebalance: r.AutoRebalance.copy(), + } +} + // BotConfig is the configuration for a market making bot. // The balance fields are the initial amounts that will be reserved to use for // this bot. As the bot trades, the amounts reserved for it will be updated. @@ -108,10 +143,12 @@ type BotConfig struct { // RPCConfig can be used for file-based initial allocations and // auto-rebalance settings. - RPCConfig *struct { - Alloc *BotBalanceAllocation `json:"alloc"` - AutoRebalance *AutoRebalanceConfig `json:"autoRebalance"` - } `json:"rpcConfig"` + RPCConfig *rpcConfig `json:"rpcConfig"` + + // LotSize is the lot size of the market at the time this configuration + // was created. It is used to notify the user if the lot size changes + // when they are starting the bot. + LotSize uint64 `json:"lotSize"` // Only one of the following configs should be set BasicMMConfig *BasicMarketMakingConfig `json:"basicMarketMakingConfig,omitempty"` @@ -119,6 +156,66 @@ type BotConfig struct { ArbMarketMakerConfig *ArbMarketMakerConfig `json:"arbMarketMakingConfig,omitempty"` } +func (c *BotConfig) copy() *BotConfig { + b := *c + + b.BaseWalletOptions = utils.CopyMap(c.BaseWalletOptions) + b.QuoteWalletOptions = utils.CopyMap(c.QuoteWalletOptions) + + if c.UIConfig != nil { + b.UIConfig = make(json.RawMessage, len(c.UIConfig)) + copy(b.UIConfig, c.UIConfig) + } + if c.RPCConfig != nil { + b.RPCConfig = c.RPCConfig.copy() + } + if c.BasicMMConfig != nil { + b.BasicMMConfig = c.BasicMMConfig.copy() + } + if c.SimpleArbConfig != nil { + b.SimpleArbConfig = c.SimpleArbConfig.copy() + } + if c.ArbMarketMakerConfig != nil { + b.ArbMarketMakerConfig = c.ArbMarketMakerConfig.copy() + } + + return &b +} + +// updateLotSize modifies the bot's configuration based on an update to the +// market's lot size. +func (c *BotConfig) updateLotSize(oldLotSize, newLotSize uint64) { + if c.BasicMMConfig != nil { + c.BasicMMConfig.updateLotSize(oldLotSize, newLotSize) + } else if c.ArbMarketMakerConfig != nil { + c.ArbMarketMakerConfig.updateLotSize(oldLotSize, newLotSize) + } +} + +func (c *BotConfig) validate() error { + if c.BasicMMConfig != nil { + return c.BasicMMConfig.Validate() + } else if c.SimpleArbConfig != nil { + return c.SimpleArbConfig.Validate() + } else if c.ArbMarketMakerConfig != nil { + // TODO: + // c.ArbMarketMakerConfig.Validate() + return nil + } + + return fmt.Errorf("no bot config set") +} + +func validateConfigUpdate(old, new *BotConfig) error { + if (old.BasicMMConfig == nil) != (new.BasicMMConfig == nil) || + (old.SimpleArbConfig == nil) != (new.SimpleArbConfig == nil) || + (old.ArbMarketMakerConfig == nil) != (new.ArbMarketMakerConfig == nil) { + return fmt.Errorf("cannot change bot type") + } + + return new.validate() +} + func (c *BotConfig) requiresPriceOracle() bool { return c.BasicMMConfig != nil } diff --git a/client/mm/exchange_adaptor.go b/client/mm/exchange_adaptor.go index a41d4a05eb..de4b731c94 100644 --- a/client/mm/exchange_adaptor.go +++ b/client/mm/exchange_adaptor.go @@ -302,8 +302,8 @@ type pendingCEXOrder struct { type market struct { host string name string - rateStep uint64 - lotSize uint64 + rateStep atomic.Uint64 + lotSize atomic.Uint64 baseID uint32 baseTicker string bui dex.UnitInfo @@ -343,11 +343,10 @@ func parseMarket(host string, mkt *core.Market) (*market, error) { return nil, err } } - return &market{ + + m := &market{ host: host, name: mkt.Name, - rateStep: mkt.RateStep, - lotSize: mkt.LotSize, baseID: mkt.BaseID, baseTicker: bui.Conventional.Unit, bui: bui, @@ -358,7 +357,10 @@ func parseMarket(host string, mkt *core.Market) (*market, error) { qui: qui, quoteFeeID: quoteFeeID, quoteFeeUI: quoteFeeUI, - }, nil + } + m.lotSize.Store(mkt.LotSize) + m.rateStep.Store(mkt.RateStep) + return m, nil } func (m *market) fmtRate(msgRate uint64) string { @@ -407,6 +409,7 @@ type unifiedExchangeAdaptor struct { libxc.CEX ctx context.Context + kill context.CancelFunc wg sync.WaitGroup botID string log dex.Logger @@ -510,12 +513,14 @@ func (u *unifiedExchangeAdaptor) withPause(f func() error) error { defer u.paused.Store(false) u.botLoop.Disconnect() + if err := f(); err != nil { return err } if u.ctx.Err() != nil { // Make sure we weren't shut down during pause. return u.ctx.Err() } + return u.botLoop.ConnectOnce(u.ctx) } @@ -646,7 +651,7 @@ func (u *unifiedExchangeAdaptor) SufficientBalanceForDEXTrade(rate, qty uint64, } balances[fromAsset] -= fromQty - numLots := qty / u.lotSize + numLots := qty / u.lotSize.Load() if balances[fromFeeAsset] < numLots*fees.Swap { return false, nil } @@ -1152,6 +1157,7 @@ func (u *unifiedExchangeAdaptor) multiTrade( if sell { or.Fees = sellFees } + lotSize := u.lotSize.Load() fromID, fromFeeID, toID, toFeeID := orderAssets(u.baseID, u.quoteID, sell) fees, fundingFees := or.Fees.Max, or.Fees.Funding @@ -1200,7 +1206,7 @@ func (u *unifiedExchangeAdaptor) multiTrade( } mustCancel := !withinTolerance(order.Rate, placements[o.placementIndex].Rate, driftTolerance) - or.Placements[o.placementIndex].StandingLots += (order.Qty - order.Filled) / u.lotSize + or.Placements[o.placementIndex].StandingLots += (order.Qty - order.Filled) / lotSize if or.Placements[o.placementIndex].StandingLots > or.Placements[o.placementIndex].Lots { mustCancel = true } @@ -1219,7 +1225,7 @@ func (u *unifiedExchangeAdaptor) multiTrade( rateCausesSelfMatch := u.rateCausesSelfMatchFunc(sell) fundingReq := func(rate, lots, counterTradeRate uint64) (dexReq map[uint32]uint64, cexReq uint64) { - qty := u.lotSize * lots + qty := lotSize * lots if !sell { qty = calc.BaseToQuote(rate, qty) } @@ -1234,9 +1240,9 @@ func (u *unifiedExchangeAdaptor) multiTrade( } if accountForCEXBal { if sell { - cexReq = calc.BaseToQuote(counterTradeRate, u.lotSize*lots) + cexReq = calc.BaseToQuote(counterTradeRate, lotSize*lots) } else { - cexReq = u.lotSize * lots + cexReq = lotSize * lots } } return @@ -1306,7 +1312,7 @@ func (u *unifiedExchangeAdaptor) multiTrade( placementIndex: uint64(i), counterTradeRate: placement.CounterTradeRate, placement: &core.QtyRate{ - Qty: lotsToPlace * u.lotSize, + Qty: lotsToPlace * lotSize, Rate: placement.Rate, }, }) @@ -2744,6 +2750,38 @@ func (u *unifiedExchangeAdaptor) handleDEXOrderUpdate(o *core.Order) { u.updateDEXOrderEvent(pendingOrder, complete) } +func (u *unifiedExchangeAdaptor) handleServerConfigUpdate() { + coreMkt, err := u.clientCore.ExchangeMarket(u.host, u.baseID, u.quoteID) + if err != nil { + u.log.Errorf("Stopping bot due to error getting market params: %v", err) + u.kill() + return + } + + if coreMkt.LotSize == u.lotSize.Load() && coreMkt.RateStep == u.rateStep.Load() { + return + } + + err = u.withPause(func() error { + if coreMkt.LotSize != u.lotSize.Load() { + cfg := u.botCfg() + copy := cfg.copy() + copy.updateLotSize(u.lotSize.Load(), coreMkt.LotSize) + err := u.updateConfig(copy) + if err != nil { + return err + } + u.lotSize.Store(coreMkt.LotSize) + } + u.rateStep.Store(coreMkt.RateStep) + return nil + }) + if err != nil { + u.log.Errorf("Error updating config due to server config update. stopping bot: %v", err) + u.kill() + } +} + func (u *unifiedExchangeAdaptor) handleDEXNotification(n core.Notification) { switch note := n.(type) { case *core.OrderNote: @@ -2771,6 +2809,11 @@ func (u *unifiedExchangeAdaptor) handleDEXNotification(n core.Notification) { } case *core.FiatRatesNote: u.fiatRates.Store(note.FiatRates) + case *core.ServerConfigUpdateNote: + if note.Host != u.host { + return + } + u.handleServerConfigUpdate() } } @@ -2791,16 +2834,17 @@ func (u *unifiedExchangeAdaptor) lotCosts(sellVWAP, buyVWAP uint64) (*lotCosts, if err != nil { return nil, fmt.Errorf("error getting order fees: %w", err) } - perLot.dexBase = u.lotSize + lotSize := u.lotSize.Load() + perLot.dexBase = lotSize if u.baseID == u.baseFeeID { perLot.dexBase += sellFees.BookingFeesPerLot } - perLot.cexBase = u.lotSize + perLot.cexBase = lotSize perLot.baseRedeem = buyFees.Max.Redeem perLot.baseFunding = sellFees.Funding - dexQuoteLot := calc.BaseToQuote(sellVWAP, u.lotSize) - cexQuoteLot := calc.BaseToQuote(buyVWAP, u.lotSize) + dexQuoteLot := calc.BaseToQuote(sellVWAP, lotSize) + cexQuoteLot := calc.BaseToQuote(buyVWAP, lotSize) perLot.dexQuote = dexQuoteLot if u.quoteID == u.quoteFeeID { perLot.dexQuote += buyFees.BookingFeesPerLot @@ -3165,6 +3209,7 @@ func (u *unifiedExchangeAdaptor) inventory(assetID uint32, dexLot, cexLot uint64 // specified number of lots. If the book is too empty for the specified number // of lots, a 1-lot estimate will be attempted too. func (u *unifiedExchangeAdaptor) cexCounterRates(cexBuyLots, cexSellLots uint64) (dexBuyRate, dexSellRate uint64, err error) { + lotSize := u.lotSize.Load() tryLots := func(b, s uint64) (uint64, uint64, bool, error) { if b == 0 { b = 1 @@ -3172,14 +3217,14 @@ func (u *unifiedExchangeAdaptor) cexCounterRates(cexBuyLots, cexSellLots uint64) if s == 0 { s = 1 } - buyRate, _, filled, err := u.CEX.VWAP(u.baseID, u.quoteID, true, u.lotSize*s) + buyRate, _, filled, err := u.CEX.VWAP(u.baseID, u.quoteID, true, lotSize*s) if err != nil { return 0, 0, false, fmt.Errorf("error calculating dex buy price for quote conversion: %w", err) } if !filled { return 0, 0, false, nil } - sellRate, _, filled, err := u.CEX.VWAP(u.baseID, u.quoteID, false, u.lotSize*b) + sellRate, _, filled, err := u.CEX.VWAP(u.baseID, u.quoteID, false, lotSize*b) if err != nil { return 0, 0, false, fmt.Errorf("error calculating dex sell price for quote conversion: %w", err) } @@ -3309,7 +3354,7 @@ func (u *unifiedExchangeAdaptor) updateFeeRates() (buyFees, sellFees *OrderFees, } func (u *unifiedExchangeAdaptor) Connect(ctx context.Context) (*sync.WaitGroup, error) { - u.ctx = ctx + u.ctx, u.kill = context.WithCancel(ctx) fiatRates := u.clientCore.FiatConversionRates() u.fiatRates.Store(fiatRates) @@ -3449,7 +3494,6 @@ func newProfitLoss( mods map[uint32]int64, fiatRates map[uint32]float64, ) *ProfitLoss { - pl := &ProfitLoss{ Initial: make(map[uint32]*Amount, len(initialBalances)), Mods: make(map[uint32]*Amount, len(mods)), @@ -3583,9 +3627,14 @@ func (u *unifiedExchangeAdaptor) applyInventoryDiffs(balanceDiffs *BotInventoryD return mods } -func (u *unifiedExchangeAdaptor) updateConfig(cfg *BotConfig) { +func (u *unifiedExchangeAdaptor) updateConfig(cfg *BotConfig) error { + if err := validateConfigUpdate(u.botCfg(), cfg); err != nil { + return err + } + u.botCfgV.Store(cfg) u.updateConfigEvent(cfg) + return nil } func (u *unifiedExchangeAdaptor) updateInventory(balanceDiffs *BotInventoryDiffs) { diff --git a/client/mm/exchange_adaptor_test.go b/client/mm/exchange_adaptor_test.go index c568c4541c..5ca43040a7 100644 --- a/client/mm/exchange_adaptor_test.go +++ b/client/mm/exchange_adaptor_test.go @@ -507,7 +507,9 @@ func testDistribution(t *testing.T, baseID, quoteID uint32) { u.clientCore = tCore u.autoRebalanceCfg = &AutoRebalanceConfig{} a := &arbMarketMaker{unifiedExchangeAdaptor: u} - a.cfgV.Store(&ArbMarketMakerConfig{Profit: profit}) + u.botCfgV.Store(&BotConfig{ + ArbMarketMakerConfig: &ArbMarketMakerConfig{Profit: profit}, + }) fiatRates := map[uint32]float64{baseID: 1, quoteID: 1} u.fiatRates.Store(fiatRates) @@ -579,9 +581,16 @@ func testDistribution(t *testing.T, baseID, quoteID uint32) { setLots := func(b, s uint64) { buyLots, sellLots = b, s - a.placementLotsV.Store(&placementLots{ - baseLots: sellLots, - quoteLots: buyLots, + u.botCfgV.Store(&BotConfig{ + ArbMarketMakerConfig: &ArbMarketMakerConfig{ + Profit: profit, + BuyPlacements: []*ArbMarketMakingPlacement{ + {Lots: buyLots, Multiplier: 1}, + }, + SellPlacements: []*ArbMarketMakingPlacement{ + {Lots: sellLots, Multiplier: 1}, + }, + }, }) addBaseFees, addQuoteFees = sellFundingFees, buyFundingFees cex.asksVWAP[lotSize*buyLots] = vwapResult{avg: buyVWAP} diff --git a/client/mm/mm.go b/client/mm/mm.go index f06ba59653..4dc85ad952 100644 --- a/client/mm/mm.go +++ b/client/mm/mm.go @@ -790,7 +790,7 @@ type StartConfig struct { } // StartBot starts a market making bot. -func (m *MarketMaker) StartBot(startCfg *StartConfig, alternateConfigPath *string, appPW []byte) (err error) { +func (m *MarketMaker) StartBot(startCfg *StartConfig, alternateConfigPath *string, appPW []byte, overrideLotSizeChange bool) (err error) { mkt := startCfg.MarketWithHost m.startUpdateMtx.Lock() @@ -827,6 +827,31 @@ func (m *MarketMaker) StartBot(startCfg *StartConfig, alternateConfigPath *strin startCfg.AutoRebalance = botCfg.RPCConfig.AutoRebalance } + // Lot size may be zero if started from RPC. If the lot size in the config + // is set, then we check if the lot size has changed since the configuration + // was saved. If so, and overrideLotSizeChange is false, we return an error. + // If overrideLotSizeChange is true, we update the lot size in the config. + if botCfg.LotSize > 0 { + mktInfo, err := m.core.ExchangeMarket(mkt.Host, mkt.BaseID, mkt.QuoteID) + if err != nil { + return fmt.Errorf("error getting market info for %s: %w", mkt, err) + } + + if botCfg.LotSize != mktInfo.LotSize { + if overrideLotSizeChange { + botCfg.LotSize = mktInfo.LotSize + m.updateDefaultBotConfig(botCfg) + } else { + return fmt.Errorf("lot size changed since configuration") + } + } + + if !overrideLotSizeChange && botCfg.LotSize != mktInfo.LotSize { + return fmt.Errorf("lot size for %s has changed: %d -> %d", mkt, botCfg.LotSize, mktInfo.LotSize) + } + botCfg.LotSize = mktInfo.LotSize + } + return m.startBot(startCfg, botCfg, cexCfg, appPW) } @@ -990,6 +1015,12 @@ func (m *MarketMaker) UpdateBotConfig(updatedCfg *BotConfig) error { return fmt.Errorf("call UpdateRunningBotCfg to update the config of a running bot") } + mkt, err := m.core.ExchangeMarket(updatedCfg.Host, updatedCfg.BaseID, updatedCfg.QuoteID) + if err != nil { + return fmt.Errorf("error getting market: %w", err) + } + updatedCfg.LotSize = mkt.LotSize + m.updateDefaultBotConfig(updatedCfg) return nil } diff --git a/client/mm/mm_arb_market_maker.go b/client/mm/mm_arb_market_maker.go index 174ad291f7..5488d82c26 100644 --- a/client/mm/mm_arb_market_maker.go +++ b/client/mm/mm_arb_market_maker.go @@ -82,6 +82,57 @@ type ArbMarketMakerConfig struct { NumEpochsLeaveOpen uint64 `json:"orderPersistence"` } +func (a *ArbMarketMakerConfig) copy() *ArbMarketMakerConfig { + c := *a + + c.BuyPlacements = make([]*ArbMarketMakingPlacement, 0, len(a.BuyPlacements)) + for _, p := range a.BuyPlacements { + c.BuyPlacements = append(c.BuyPlacements, &ArbMarketMakingPlacement{ + Lots: p.Lots, + Multiplier: p.Multiplier, + }) + } + + c.SellPlacements = make([]*ArbMarketMakingPlacement, 0, len(a.SellPlacements)) + for _, p := range a.SellPlacements { + c.SellPlacements = append(c.SellPlacements, &ArbMarketMakingPlacement{ + Lots: p.Lots, + Multiplier: p.Multiplier, + }) + } + + return &c +} + +// updateLotSize modifies the number of lots in each placement in the event +// of a lot size change. It will place as many lots as possible without +// exceeding the total quantity placed using the original lot size. +// +// This function is NOT thread safe. +func (c *ArbMarketMakerConfig) updateLotSize(originalLotSize, newLotSize uint64) { + for _, p := range c.SellPlacements { + p.Lots = (p.Lots * originalLotSize) / newLotSize + } + + for _, p := range c.BuyPlacements { + p.Lots = (p.Lots * originalLotSize) / newLotSize + } +} + +func (a *ArbMarketMakerConfig) placementLots() *placementLots { + var baseLots, quoteLots uint64 + for _, p := range a.BuyPlacements { + quoteLots += p.Lots + } + for _, p := range a.SellPlacements { + baseLots += p.Lots + } + return &placementLots{ + baseLots: baseLots, + quoteLots: quoteLots, + } +} + type placementLots struct { baseLots uint64 quoteLots uint64 @@ -91,8 +142,6 @@ type arbMarketMaker struct { *unifiedExchangeAdaptor cex botCexAdaptor core botCoreAdaptor - cfgV atomic.Value // *ArbMarketMakerConfig - placementLotsV atomic.Value // *placementLots book dexOrderBook rebalanceRunning atomic.Bool currEpoch atomic.Uint64 @@ -108,7 +157,7 @@ type arbMarketMaker struct { var _ bot = (*arbMarketMaker)(nil) func (a *arbMarketMaker) cfg() *ArbMarketMakerConfig { - return a.cfgV.Load().(*ArbMarketMakerConfig) + return a.botCfg().ArbMarketMakerConfig } func (a *arbMarketMaker) handleCEXTradeUpdate(update *libxc.Trade) { @@ -201,7 +250,8 @@ func dexPlacementRate(cexRate uint64, sell bool, profitRate float64, mkt *market unadjustedRate = uint64(math.Round(float64(cexRate) / (1 + profitRate))) } - rateAdj := rateAdjustment(feesInQuoteUnits, mkt.lotSize) + lotSize, rateStep := mkt.lotSize.Load(), mkt.rateStep.Load() + rateAdj := rateAdjustment(feesInQuoteUnits, lotSize) if log.Level() <= dex.LevelTrace { log.Tracef("%s %s placement rate: cexRate = %s, profitRate = %.3f, unadjustedRate = %s, rateAdj = %s, fees = %s", @@ -210,14 +260,14 @@ func dexPlacementRate(cexRate uint64, sell bool, profitRate float64, mkt *market } if sell { - return steppedRate(unadjustedRate+rateAdj, mkt.rateStep), nil + return steppedRate(unadjustedRate+rateAdj, rateStep), nil } if rateAdj > unadjustedRate { return 0, fmt.Errorf("rate adjustment required for fees %d > rate %d", rateAdj, unadjustedRate) } - return steppedRate(unadjustedRate-rateAdj, mkt.rateStep), nil + return steppedRate(unadjustedRate-rateAdj, rateStep), nil } func rateAdjustment(feesInQuoteUnits, lotSize uint64) uint64 { @@ -246,7 +296,7 @@ func (a *arbMarketMaker) ordersToPlace() (buys, sells []*TradePlacement, err err newPlacements := make([]*TradePlacement, 0, len(cfgPlacements)) var cumulativeCEXDepth uint64 for i, cfgPlacement := range cfgPlacements { - cumulativeCEXDepth += uint64(float64(cfgPlacement.Lots*a.lotSize) * cfgPlacement.Multiplier) + cumulativeCEXDepth += uint64(float64(cfgPlacement.Lots*a.lotSize.Load()) * cfgPlacement.Multiplier) _, extrema, filled, err := a.CEX.VWAP(a.baseID, a.quoteID, sellOnDEX, cumulativeCEXDepth) if err != nil { return nil, fmt.Errorf("error getting CEX VWAP: %w", err) @@ -291,11 +341,7 @@ func (a *arbMarketMaker) ordersToPlace() (buys, sells []*TradePlacement, err err // distribution parses the current inventory distribution and checks if better // distributions are possible via deposit or withdrawal. func (a *arbMarketMaker) distribution() (dist *distribution, err error) { - cfgI := a.placementLotsV.Load() - if cfgI == nil { - return nil, errors.New("no placements?") - } - placements := cfgI.(*placementLots) + placements := a.cfg().placementLots() if placements.baseLots == 0 && placements.quoteLots == 0 { return nil, errors.New("zero placement lots?") } @@ -429,7 +475,7 @@ func feeGap(core botCoreAdaptor, cex libxc.CEX, baseID, quoteID uint32, lotSize } func (a *arbMarketMaker) registerFeeGap() { - feeGap, err := feeGap(a.core, a.CEX, a.baseID, a.quoteID, a.lotSize) + feeGap, err := feeGap(a.core, a.CEX, a.baseID, a.quoteID, a.lotSize.Load()) if err != nil { a.log.Warnf("error getting fee-gap stats: %v", err) return @@ -508,31 +554,6 @@ func (a *arbMarketMaker) botLoop(ctx context.Context) (*sync.WaitGroup, error) { return &wg, nil } -func (a *arbMarketMaker) setTransferConfig(cfg *ArbMarketMakerConfig) { - var baseLots, quoteLots uint64 - for _, p := range cfg.BuyPlacements { - quoteLots += p.Lots - } - for _, p := range cfg.SellPlacements { - baseLots += p.Lots - } - a.placementLotsV.Store(&placementLots{ - baseLots: baseLots, - quoteLots: quoteLots, - }) -} - -func (a *arbMarketMaker) updateConfig(cfg *BotConfig) error { - if cfg.ArbMarketMakerConfig == nil { - return errors.New("no arb market maker config provided") - } - - a.cfgV.Store(cfg.ArbMarketMakerConfig) - a.setTransferConfig(cfg.ArbMarketMakerConfig) - a.unifiedExchangeAdaptor.updateConfig(cfg) - return nil -} - func newArbMarketMaker(cfg *BotConfig, adaptorCfg *exchangeAdaptorCfg, log dex.Logger) (*arbMarketMaker, error) { if cfg.ArbMarketMakerConfig == nil { // implies bug in caller @@ -554,8 +575,5 @@ func newArbMarketMaker(cfg *BotConfig, adaptorCfg *exchangeAdaptorCfg, log dex.L } adaptor.setBotLoop(arbMM.botLoop) - - arbMM.cfgV.Store(cfg.ArbMarketMakerConfig) - arbMM.setTransferConfig(cfg.ArbMarketMakerConfig) return arbMM, nil } diff --git a/client/mm/mm_arb_market_maker_test.go b/client/mm/mm_arb_market_maker_test.go index dbf232739c..177a72bd56 100644 --- a/client/mm/mm_arb_market_maker_test.go +++ b/client/mm/mm_arb_market_maker_test.go @@ -75,22 +75,14 @@ func TestArbMMRebalance(t *testing.T) { var buyLots, sellLots, minDexBase, minCexBase /* totalBase, */, minDexQuote, minCexQuote /*, totalQuote */ uint64 setLots := func(buy, sell uint64) { buyLots, sellLots = buy, sell - a.placementLotsV.Store(&placementLots{ - baseLots: sellLots, - quoteLots: buyLots, - }) - a.cfgV.Store(&ArbMarketMakerConfig{ - Profit: 0, - BuyPlacements: []*ArbMarketMakingPlacement{ - { - Lots: buyLots, - Multiplier: 1, + u.botCfgV.Store(&BotConfig{ + ArbMarketMakerConfig: &ArbMarketMakerConfig{ + Profit: 0, + BuyPlacements: []*ArbMarketMakingPlacement{ + {Lots: buyLots, Multiplier: 1}, }, - }, - SellPlacements: []*ArbMarketMakingPlacement{ - { - Lots: sellLots, - Multiplier: 1, + SellPlacements: []*ArbMarketMakingPlacement{ + {Lots: sellLots, Multiplier: 1}, }, }, }) @@ -315,9 +307,12 @@ func TestArbMarketMakerDEXUpdates(t *testing.T) { arbMM.CEX = newTCEX() arbMM.ctx = ctx arbMM.setBotLoop(arbMM.botLoop) - arbMM.cfgV.Store(&ArbMarketMakerConfig{ - Profit: profit, + arbMM.unifiedExchangeAdaptor.botCfgV.Store(&BotConfig{ + ArbMarketMakerConfig: &ArbMarketMakerConfig{ + Profit: profit, + }, }) + arbMM.currEpoch.Store(123) err := arbMM.runBotLoop(ctx) if err != nil { @@ -410,7 +405,7 @@ func TestDEXPlacementRate(t *testing.T) { } expectedProfitableSellRate := uint64(float64(tt.counterTradeRate) * (1 + tt.profit)) - additional := calc.BaseToQuote(sellRate, tt.mkt.lotSize) - calc.BaseToQuote(expectedProfitableSellRate, tt.mkt.lotSize) + additional := calc.BaseToQuote(sellRate, tt.mkt.lotSize.Load()) - calc.BaseToQuote(expectedProfitableSellRate, tt.mkt.lotSize.Load()) if additional > tt.fees*101/100 || additional < tt.fees*99/100 { t.Fatalf("%s: expected additional %d but got %d", tt.name, tt.fees, additional) } @@ -420,7 +415,7 @@ func TestDEXPlacementRate(t *testing.T) { t.Fatalf("%s: unexpected error: %v", tt.name, err) } expectedProfitableBuyRate := uint64(float64(tt.counterTradeRate) / (1 + tt.profit)) - savings := calc.BaseToQuote(expectedProfitableBuyRate, tt.mkt.lotSize) - calc.BaseToQuote(buyRate, tt.mkt.lotSize) + savings := calc.BaseToQuote(expectedProfitableBuyRate, tt.mkt.lotSize.Load()) - calc.BaseToQuote(buyRate, tt.mkt.lotSize.Load()) if savings > tt.fees*101/100 || savings < tt.fees*99/100 { t.Fatalf("%s: expected savings %d but got %d", tt.name, tt.fees, savings) } diff --git a/client/mm/mm_basic.go b/client/mm/mm_basic.go index e65fb0da11..dc7b337559 100644 --- a/client/mm/mm_basic.go +++ b/client/mm/mm_basic.go @@ -138,6 +138,45 @@ func (c *BasicMarketMakingConfig) Validate() error { return nil } +func (c *BasicMarketMakingConfig) copy() *BasicMarketMakingConfig { + cfg := *c + + sellPlacements := make([]*OrderPlacement, 0, len(c.SellPlacements)) + for _, p := range c.SellPlacements { + sellPlacements = append(sellPlacements, &OrderPlacement{ + Lots: p.Lots, + GapFactor: p.GapFactor, + }) + } + cfg.SellPlacements = sellPlacements + + buyPlacements := make([]*OrderPlacement, 0, len(c.BuyPlacements)) + for _, p := range c.BuyPlacements { + buyPlacements = append(buyPlacements, &OrderPlacement{ + Lots: p.Lots, + GapFactor: p.GapFactor, + }) + } + cfg.BuyPlacements = buyPlacements + + return &cfg +} + +// updateLotSize modifies the number of lots in each placement in the event +// of a lot size change. It will place as many lots as possible without +// exceeding the total quantity placed using the original lot size. +// +// This function is NOT thread safe. +func (c *BasicMarketMakingConfig) updateLotSize(originalLotSize, newLotSize uint64) { + for _, p := range c.SellPlacements { + p.Lots = (p.Lots * originalLotSize) / newLotSize + } + + for _, p := range c.BuyPlacements { + p.Lots = (p.Lots * originalLotSize) / newLotSize + } +} + type basicMMCalculator interface { basisPrice() (bp uint64, err error) halfSpread(uint64) (uint64, error) @@ -170,6 +209,7 @@ func (b *basicMMCalculatorImpl) basisPrice() (uint64, error) { b.log.Tracef("oracle rate = %s", b.fmtRate(oracleRate)) rateFromFiat := b.core.ExchangeRateFromFiatSources() + rateStep := b.rateStep.Load() if rateFromFiat == 0 { b.log.Meter("basisPrice_nofiat_"+b.market.name, time.Hour).Warn( "No fiat-based rate estimate(s) available for sanity check for %s", b.market.name, @@ -177,13 +217,13 @@ func (b *basicMMCalculatorImpl) basisPrice() (uint64, error) { if oracleRate == 0 { // steppedRate(0, x) => x, so we have to handle this. return 0, errNoBasisPrice } - return steppedRate(oracleRate, b.rateStep), nil + return steppedRate(oracleRate, rateStep), nil } if oracleRate == 0 { b.log.Meter("basisPrice_nooracle_"+b.market.name, time.Hour).Infof( "No oracle rate available. Using fiat-derived basis rate = %s for %s", b.fmtRate(rateFromFiat), b.market.name, ) - return steppedRate(rateFromFiat, b.rateStep), nil + return steppedRate(rateFromFiat, rateStep), nil } mismatch := math.Abs((float64(oracleRate) - float64(rateFromFiat)) / float64(oracleRate)) const maxOracleFiatMismatch = 0.05 @@ -195,7 +235,7 @@ func (b *basicMMCalculatorImpl) basisPrice() (uint64, error) { return 0, errOracleFiatMismatch } - return steppedRate(oracleRate, b.rateStep), nil + return steppedRate(oracleRate, rateStep), nil } // halfSpread calculates the distance from the mid-gap where if you sell a lot @@ -254,7 +294,7 @@ func (b *basicMMCalculatorImpl) feeGapStats(basisPrice uint64) (*FeeGapStats, er */ f := sellFeesInBaseUnits + buyFeesInBaseUnits - l := b.lotSize + l := b.lotSize.Load() r := float64(basisPrice) / calc.RateEncodingFactor g := float64(f) * r / float64(f+2*l) @@ -276,7 +316,6 @@ func (b *basicMMCalculatorImpl) feeGapStats(basisPrice uint64) (*FeeGapStats, er type basicMarketMaker struct { *unifiedExchangeAdaptor - cfgV atomic.Value // *BasicMarketMakingConfig core botCoreAdaptor oracle oracle rebalanceRunning atomic.Bool @@ -286,7 +325,7 @@ type basicMarketMaker struct { var _ bot = (*basicMarketMaker)(nil) func (m *basicMarketMaker) cfg() *BasicMarketMakingConfig { - return m.cfgV.Load().(*BasicMarketMakingConfig) + return m.botCfg().BasicMMConfig } func (m *basicMarketMaker) orderPrice(basisPrice, feeAdj uint64, sell bool, gapFactor float64) uint64 { @@ -308,7 +347,7 @@ func (m *basicMarketMaker) orderPrice(basisPrice, feeAdj uint64, sell bool, gapF adj += feeAdj } - adj = steppedRate(adj, m.rateStep) + adj = steppedRate(adj, m.rateStep.Load()) if sell { return basisPrice + adj @@ -437,21 +476,6 @@ func (m *basicMarketMaker) botLoop(ctx context.Context) (*sync.WaitGroup, error) return &wg, nil } -func (m *basicMarketMaker) updateConfig(cfg *BotConfig) error { - if cfg.BasicMMConfig == nil { - // implies bug in caller - return errors.New("no market making config provided") - } - - err := cfg.BasicMMConfig.Validate() - if err != nil { - return fmt.Errorf("invalid market making config: %v", err) - } - - m.cfgV.Store(cfg.BasicMMConfig) - return nil -} - // RunBasicMarketMaker starts a basic market maker bot. func newBasicMarketMaker(cfg *BotConfig, adaptorCfg *exchangeAdaptorCfg, oracle oracle, log dex.Logger) (*basicMarketMaker, error) { if cfg.BasicMMConfig == nil { @@ -474,7 +498,6 @@ func newBasicMarketMaker(cfg *BotConfig, adaptorCfg *exchangeAdaptorCfg, oracle core: adaptor, oracle: oracle, } - basicMM.cfgV.Store(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 2ebf31f4e7..7f49a345d5 100644 --- a/client/mm/mm_basic_test.go +++ b/client/mm/mm_basic_test.go @@ -365,11 +365,13 @@ func TestBasicMMRebalance(t *testing.T) { mm.baseCexBalances[baseID] = lotSize * 50 mm.baseDexBalances[quoteID] = int64(calc.BaseToQuote(basisPrice, lotSize*50)) mm.baseCexBalances[quoteID] = int64(calc.BaseToQuote(basisPrice, lotSize*50)) - mm.cfgV.Store(&BasicMarketMakingConfig{ - GapStrategy: tt.strategy, - BuyPlacements: tt.cfgBuyPlacements, - SellPlacements: tt.cfgSellPlacements, - }) + mm.unifiedExchangeAdaptor.botCfgV.Store(&BotConfig{ + BasicMMConfig: &BasicMarketMakingConfig{ + GapStrategy: tt.strategy, + BuyPlacements: tt.cfgBuyPlacements, + SellPlacements: tt.cfgSellPlacements, + }}) + mm.rebalance(100) if len(tcore.multiTradesPlaced) != 2 { diff --git a/client/mm/mm_simple_arb.go b/client/mm/mm_simple_arb.go index b32ce3282f..e2b256a0a9 100644 --- a/client/mm/mm_simple_arb.go +++ b/client/mm/mm_simple_arb.go @@ -35,6 +35,14 @@ type SimpleArbConfig struct { NumEpochsLeaveOpen uint32 `json:"numEpochsLeaveOpen"` } +func (c *SimpleArbConfig) copy() *SimpleArbConfig { + return &SimpleArbConfig{ + ProfitTrigger: c.ProfitTrigger, + MaxActiveArbs: c.MaxActiveArbs, + NumEpochsLeaveOpen: c.NumEpochsLeaveOpen, + } +} + func (c *SimpleArbConfig) Validate() error { if c.ProfitTrigger <= 0 || c.ProfitTrigger > 1 { return fmt.Errorf("profit trigger must be 0 < t <= 1, but got %v", c.ProfitTrigger) @@ -67,7 +75,6 @@ type simpleArbMarketMaker struct { *unifiedExchangeAdaptor cex botCexAdaptor core botCoreAdaptor - cfgV atomic.Value // *SimpleArbConfig book dexOrderBook rebalanceRunning atomic.Bool @@ -78,7 +85,7 @@ type simpleArbMarketMaker struct { var _ bot = (*simpleArbMarketMaker)(nil) func (a *simpleArbMarketMaker) cfg() *SimpleArbConfig { - return a.cfgV.Load().(*SimpleArbConfig) + return a.botCfg().SimpleArbConfig } // arbExists checks if an arbitrage opportunity exists. @@ -101,11 +108,11 @@ func (a *simpleArbMarketMaker) arbExists() (exists, sellOnDex bool, lotsToArb, d // arbExistsOnSide checks if an arbitrage opportunity exists either when // buying or selling on the dex. func (a *simpleArbMarketMaker) arbExistsOnSide(sellOnDEX bool) (exists bool, lotsToArb, dexRate, cexRate uint64, err error) { - lotSize := a.lotSize + lotSize := a.lotSize.Load() var prevProfit uint64 for numLots := uint64(1); ; numLots++ { - dexAvg, dexExtrema, dexFilled, err := a.book.VWAP(numLots, a.lotSize, !sellOnDEX) + dexAvg, dexExtrema, dexFilled, err := a.book.VWAP(numLots, lotSize, !sellOnDEX) if err != nil { return false, 0, 0, 0, fmt.Errorf("error calculating dex VWAP: %w", err) } @@ -207,6 +214,8 @@ func (a *simpleArbMarketMaker) executeArb(sellOnDex bool, lotsToArb, dexRate, ce } // also check self-match on CEX? + lotSize := a.lotSize.Load() + // Hold the lock for this entire process because updates to the cex trade // may come even before the Trade function has returned, and in order to // be able to process them, the new arbSequence struct must already be in @@ -215,13 +224,13 @@ func (a *simpleArbMarketMaker) executeArb(sellOnDex bool, lotsToArb, dexRate, ce defer a.activeArbsMtx.Unlock() // Place cex order first. If placing dex order fails then can freely cancel cex order. - cexTrade, err := a.cex.CEXTrade(a.ctx, a.baseID, a.quoteID, !sellOnDex, cexRate, lotsToArb*a.lotSize) + cexTrade, err := a.cex.CEXTrade(a.ctx, a.baseID, a.quoteID, !sellOnDex, cexRate, lotsToArb*lotSize) if err != nil { a.log.Errorf("error placing cex order: %v", err) return } - dexOrder, err := a.core.DEXTrade(dexRate, lotsToArb*a.lotSize, sellOnDex) + dexOrder, err := a.core.DEXTrade(dexRate, lotsToArb*lotSize, sellOnDex) if err != nil { if err != nil { a.log.Errorf("error placing dex order: %v", err) @@ -426,14 +435,15 @@ func (a *simpleArbMarketMaker) distribution() (dist *distribution, err error) { if err != nil { return nil, fmt.Errorf("error getting converted fees: %w", err) } - adj := float64(sellFeesInBase)/float64(a.lotSize) + a.cfg().ProfitTrigger - sellRate := steppedRate(uint64(math.Round(float64(sellVWAP)*(1+adj))), a.rateStep) + lotSize, rateStep := a.lotSize.Load(), a.rateStep.Load() + adj := float64(sellFeesInBase)/float64(lotSize) + a.cfg().ProfitTrigger + sellRate := steppedRate(uint64(math.Round(float64(sellVWAP)*(1+adj))), rateStep) buyFeesInBase, err := a.OrderFeesInUnits(false, true, buyVWAP) if err != nil { return nil, fmt.Errorf("error getting converted fees: %w", err) } - adj = float64(buyFeesInBase)/float64(a.lotSize) + a.cfg().ProfitTrigger - buyRate := steppedRate(uint64(math.Round(float64(buyVWAP)/(1+adj))), a.rateStep) + adj = float64(buyFeesInBase)/float64(lotSize) + a.cfg().ProfitTrigger + buyRate := steppedRate(uint64(math.Round(float64(buyVWAP)/(1+adj))), rateStep) perLot, err := a.lotCosts(sellRate, buyRate) if perLot == nil { return nil, fmt.Errorf("error getting lot costs: %w", err) @@ -521,7 +531,7 @@ func (a *simpleArbMarketMaker) botLoop(ctx context.Context) (*sync.WaitGroup, er } func (a *simpleArbMarketMaker) registerFeeGap() { - feeGap, err := feeGap(a.core, a.CEX, a.baseID, a.quoteID, a.lotSize) + feeGap, err := feeGap(a.core, a.CEX, a.baseID, a.quoteID, a.lotSize.Load()) if err != nil { a.log.Warnf("error getting fee-gap stats: %v", err) return @@ -529,15 +539,6 @@ func (a *simpleArbMarketMaker) registerFeeGap() { a.unifiedExchangeAdaptor.registerFeeGap(feeGap) } -func (a *simpleArbMarketMaker) updateConfig(cfg *BotConfig) error { - if cfg.SimpleArbConfig == nil { - // implies bug in caller - return fmt.Errorf("no arb config provided") - } - a.cfgV.Store(cfg.SimpleArbConfig) - return nil -} - func newSimpleArbMarketMaker(cfg *BotConfig, adaptorCfg *exchangeAdaptorCfg, log dex.Logger) (*simpleArbMarketMaker, error) { if cfg.SimpleArbConfig == nil { // implies bug in caller @@ -555,7 +556,6 @@ func newSimpleArbMarketMaker(cfg *BotConfig, adaptorCfg *exchangeAdaptorCfg, log core: adaptor, activeArbs: make([]*arbSequence, 0), } - simpleArb.cfgV.Store(cfg.SimpleArbConfig) adaptor.setBotLoop(simpleArb.botLoop) return simpleArb, nil } diff --git a/client/mm/mm_simple_arb_test.go b/client/mm/mm_simple_arb_test.go index 43ca7410cd..1b30bead5c 100644 --- a/client/mm/mm_simple_arb_test.go +++ b/client/mm/mm_simple_arb_test.go @@ -544,10 +544,12 @@ func TestArbRebalance(t *testing.T) { BookingFeesPerLot: sellSwapFees, } // arbEngine.setBotLoop(arbEngine.botLoop) - a.cfgV.Store(&SimpleArbConfig{ - ProfitTrigger: profitTrigger, - MaxActiveArbs: maxActiveArbs, - NumEpochsLeaveOpen: numEpochsLeaveOpen, + a.unifiedExchangeAdaptor.botCfgV.Store(&BotConfig{ + SimpleArbConfig: &SimpleArbConfig{ + ProfitTrigger: profitTrigger, + MaxActiveArbs: maxActiveArbs, + NumEpochsLeaveOpen: numEpochsLeaveOpen, + }, }) a.book = orderBook a.rebalance(currEpoch) @@ -698,11 +700,14 @@ func TestArbDexTradeUpdates(t *testing.T) { arbEngine.CEX = newTCEX() arbEngine.ctx = ctx arbEngine.setBotLoop(arbEngine.botLoop) - arbEngine.cfgV.Store(&SimpleArbConfig{ - ProfitTrigger: 0.01, - MaxActiveArbs: 5, - NumEpochsLeaveOpen: 10, + arbEngine.unifiedExchangeAdaptor.botCfgV.Store(&BotConfig{ + SimpleArbConfig: &SimpleArbConfig{ + ProfitTrigger: 0.01, + MaxActiveArbs: 5, + NumEpochsLeaveOpen: 10, + }, }) + err := arbEngine.runBotLoop(ctx) if err != nil { t.Fatalf("%s: Connect error: %v", test.name, err) @@ -820,10 +825,12 @@ func TestCexTradeUpdates(t *testing.T) { arbEngine.ctx = ctx arbEngine.CEX = newTCEX() arbEngine.setBotLoop(arbEngine.botLoop) - arbEngine.cfgV.Store(&SimpleArbConfig{ - ProfitTrigger: 0.01, - MaxActiveArbs: 5, - NumEpochsLeaveOpen: 10, + arbEngine.unifiedExchangeAdaptor.botCfgV.Store(&BotConfig{ + SimpleArbConfig: &SimpleArbConfig{ + ProfitTrigger: 0.01, + MaxActiveArbs: 5, + NumEpochsLeaveOpen: 10, + }, }) err := arbEngine.runBotLoop(ctx) diff --git a/client/rpcserver/handlers.go b/client/rpcserver/handlers.go index 8417584557..7d60d53ff8 100644 --- a/client/rpcserver/handlers.go +++ b/client/rpcserver/handlers.go @@ -884,7 +884,7 @@ func handleStartBot(s *RPCServer, params *RawParams) *msgjson.ResponsePayload { return usage(startBotRoute, err) } - err = s.mm.StartBot(&mm.StartConfig{MarketWithHost: *form.mkt}, &form.cfgFilePath, form.appPass) + err = s.mm.StartBot(&mm.StartConfig{MarketWithHost: *form.mkt}, &form.cfgFilePath, form.appPass, true) if err != nil { resErr := msgjson.NewError(msgjson.RPCStartMarketMakingError, "unable to start market making: %v", err) return createResponse(startBotRoute, nil, resErr) diff --git a/client/webserver/api.go b/client/webserver/api.go index 23bb0067fe..b4a489bfda 100644 --- a/client/webserver/api.go +++ b/client/webserver/api.go @@ -1908,7 +1908,7 @@ func (s *WebServer) apiStartMarketMakingBot(w http.ResponseWriter, r *http.Reque s.writeAPIError(w, errors.New("config missing")) return } - if err = s.mm.StartBot(form.Config, nil, appPW); err != nil { + if err = s.mm.StartBot(form.Config, nil, appPW, true); err != nil { s.writeAPIError(w, fmt.Errorf("error starting market making: %v", err)) return } diff --git a/client/webserver/live_test.go b/client/webserver/live_test.go index cae2faf0c1..e0302fd911 100644 --- a/client/webserver/live_test.go +++ b/client/webserver/live_test.go @@ -2145,7 +2145,7 @@ func (m *TMarketMaker) MarketReport(host string, baseID, quoteID uint32) (*mm.Ma }, nil } -func (m *TMarketMaker) StartBot(startCfg *mm.StartConfig, alternateConfigPath *string, appPW []byte) (err error) { +func (m *TMarketMaker) StartBot(startCfg *mm.StartConfig, alternateConfigPath *string, appPW []byte, overrideLotSizeUpdate bool) (err error) { m.runningBotsMtx.Lock() defer m.runningBotsMtx.Unlock() diff --git a/client/webserver/webserver.go b/client/webserver/webserver.go index 1ea09fa6fe..91900fc034 100644 --- a/client/webserver/webserver.go +++ b/client/webserver/webserver.go @@ -175,7 +175,7 @@ type clientCore interface { type MMCore interface { MarketReport(host string, base, quote uint32) (*mm.MarketReport, error) - StartBot(mkt *mm.StartConfig, alternateConfigPath *string, pw []byte) (err error) + StartBot(mkt *mm.StartConfig, alternateConfigPath *string, pw []byte, overrideLotSizeChange bool) (err error) StopBot(mkt *mm.MarketWithHost) error UpdateCEXConfig(updatedCfg *mm.CEXConfig) error CEXBalance(cexName string, assetID uint32) (*libxc.ExchangeBalance, error) From b6ae4f44ab3ae3a6b3fdc188baa36751c349a91c Mon Sep 17 00:00:00 2001 From: martonp Date: Mon, 18 Nov 2024 15:25:43 +0100 Subject: [PATCH 2/2] Review updates --- client/mm/mm_arb_market_maker.go | 21 +++++++--- client/mm/mm_basic.go | 34 ++++++++++++---- client/mm/mm_basic_test.go | 66 ++++++++++++++++++++++++++++++++ dex/utils/generics.go | 8 ++++ 4 files changed, 117 insertions(+), 12 deletions(-) diff --git a/client/mm/mm_arb_market_maker.go b/client/mm/mm_arb_market_maker.go index 5488d82c26..61af0b57e3 100644 --- a/client/mm/mm_arb_market_maker.go +++ b/client/mm/mm_arb_market_maker.go @@ -17,6 +17,7 @@ import ( "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/calc" "decred.org/dcrdex/dex/order" + "decred.org/dcrdex/dex/utils" ) // ArbMarketMakingPlacement is the configuration for an order placement @@ -110,13 +111,23 @@ func (a *ArbMarketMakerConfig) copy() *ArbMarketMakerConfig { // // This function is NOT thread safe. func (c *ArbMarketMakerConfig) updateLotSize(originalLotSize, newLotSize uint64) { - for _, p := range c.SellPlacements { - p.Lots = (p.Lots * originalLotSize) / newLotSize + b2a := func(p *OrderPlacement) *ArbMarketMakingPlacement { + return &ArbMarketMakingPlacement{ + Lots: p.Lots, + Multiplier: p.GapFactor, + } } - - for _, p := range c.BuyPlacements { - p.Lots = (p.Lots * originalLotSize) / newLotSize + a2b := func(p *ArbMarketMakingPlacement) *OrderPlacement { + return &OrderPlacement{ + Lots: p.Lots, + GapFactor: p.Multiplier, + } + } + update := func(placements []*ArbMarketMakingPlacement) []*ArbMarketMakingPlacement { + return utils.Map(updateLotSize(utils.Map(placements, a2b), originalLotSize, newLotSize), b2a) } + c.SellPlacements = update(c.SellPlacements) + c.BuyPlacements = update(c.BuyPlacements) } func (a *ArbMarketMakerConfig) placementLots() *placementLots { diff --git a/client/mm/mm_basic.go b/client/mm/mm_basic.go index dc7b337559..fae28697e4 100644 --- a/client/mm/mm_basic.go +++ b/client/mm/mm_basic.go @@ -15,6 +15,7 @@ import ( "decred.org/dcrdex/client/core" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/calc" + "decred.org/dcrdex/dex/utils" ) // GapStrategy is a specifier for an algorithm to choose the maker bot's target @@ -162,19 +163,38 @@ func (c *BasicMarketMakingConfig) copy() *BasicMarketMakingConfig { return &cfg } +func updateLotSize(placements []*OrderPlacement, originalLotSize, newLotSize uint64) (updatedPlacements []*OrderPlacement) { + var qtyCounter uint64 + for _, p := range placements { + qtyCounter += p.Lots * originalLotSize + } + newPlacements := make([]*OrderPlacement, 0, len(placements)) + for _, p := range placements { + lots := uint64(math.Round((float64(p.Lots) * float64(originalLotSize)) / float64(newLotSize))) + lots = utils.Max(lots, 1) + maxLots := qtyCounter / newLotSize + lots = utils.Min(lots, maxLots) + if lots == 0 { + continue + } + qtyCounter -= lots * newLotSize + newPlacements = append(newPlacements, &OrderPlacement{ + Lots: lots, + GapFactor: p.GapFactor, + }) + } + + return newPlacements +} + // updateLotSize modifies the number of lots in each placement in the event // of a lot size change. It will place as many lots as possible without // exceeding the total quantity placed using the original lot size. // // This function is NOT thread safe. func (c *BasicMarketMakingConfig) updateLotSize(originalLotSize, newLotSize uint64) { - for _, p := range c.SellPlacements { - p.Lots = (p.Lots * originalLotSize) / newLotSize - } - - for _, p := range c.BuyPlacements { - p.Lots = (p.Lots * originalLotSize) / newLotSize - } + c.SellPlacements = updateLotSize(c.SellPlacements, originalLotSize, newLotSize) + c.BuyPlacements = updateLotSize(c.BuyPlacements, originalLotSize, newLotSize) } type basicMMCalculator interface { diff --git a/client/mm/mm_basic_test.go b/client/mm/mm_basic_test.go index 7f49a345d5..2698cbc19a 100644 --- a/client/mm/mm_basic_test.go +++ b/client/mm/mm_basic_test.go @@ -176,6 +176,72 @@ func TestBreakEvenHalfSpread(t *testing.T) { } } +func TestUpdateLotSize(t *testing.T) { + tests := []struct { + name string + placements []*OrderPlacement + originalSize uint64 + newSize uint64 + wantPlacements []*OrderPlacement + }{ + { + name: "simple halving", + placements: []*OrderPlacement{ + {Lots: 2, GapFactor: 1.0}, + {Lots: 4, GapFactor: 2.0}, + }, + originalSize: 100, + newSize: 200, + wantPlacements: []*OrderPlacement{ + {Lots: 1, GapFactor: 1.0}, + {Lots: 2, GapFactor: 2.0}, + }, + }, + { + name: "rounding up", + placements: []*OrderPlacement{ + {Lots: 3, GapFactor: 1.0}, + {Lots: 1, GapFactor: 1.0}, + }, + originalSize: 100, + newSize: 160, + wantPlacements: []*OrderPlacement{ + {Lots: 2, GapFactor: 1.0}, + }, + }, + { + name: "minimum 1 lot", + placements: []*OrderPlacement{ + {Lots: 1, GapFactor: 1.0}, + {Lots: 1, GapFactor: 1.0}, + {Lots: 1, GapFactor: 1.0}, + }, + originalSize: 100, + newSize: 250, + wantPlacements: []*OrderPlacement{ + {Lots: 1, GapFactor: 1.0}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := updateLotSize(tt.placements, tt.originalSize, tt.newSize) + if len(got) != len(tt.wantPlacements) { + t.Fatalf("got %d placements, want %d", len(got), len(tt.wantPlacements)) + } + for i := range got { + if got[i].Lots != tt.wantPlacements[i].Lots { + t.Errorf("placement %d: got %d lots, want %d", i, got[i].Lots, tt.wantPlacements[i].Lots) + } + if got[i].GapFactor != tt.wantPlacements[i].GapFactor { + t.Errorf("placement %d: got %f gap factor, want %f", i, got[i].GapFactor, tt.wantPlacements[i].GapFactor) + } + } + }) + } +} + func TestBasicMMRebalance(t *testing.T) { const basisPrice uint64 = 5e6 const halfSpread uint64 = 2e5 diff --git a/dex/utils/generics.go b/dex/utils/generics.go index d5c56ae4da..1ead257ada 100644 --- a/dex/utils/generics.go +++ b/dex/utils/generics.go @@ -35,6 +35,14 @@ func MapKeys[K comparable, V any](m map[K]V) []K { return ks } +func Map[F any, T any](s []F, f func(F) T) []T { + r := make([]T, len(s)) + for i, v := range s { + r[i] = f(v) + } + return r +} + func SafeSub[I constraints.Unsigned](a I, b I) I { if a < b { return 0