diff --git a/client/cmd/mmbot/main.go b/client/cmd/mmbot/main.go index fea258e015..2ad5fe3244 100644 --- a/client/cmd/mmbot/main.go +++ b/client/cmd/mmbot/main.go @@ -99,7 +99,7 @@ func mainErr() error { } if manualRate > 0 { - pgm.EmptyMarketRate = manualRate + pgm.GapEngineCfg.EmptyMarketRate = manualRate } net := dex.Mainnet diff --git a/client/core/bot.go b/client/core/bot.go index b168605c99..c79b2b17e0 100644 --- a/client/core/bot.go +++ b/client/core/bot.go @@ -34,8 +34,7 @@ const ( oraclePriceExpiration = time.Minute * 10 oracleRecheckInterval = time.Minute * 3 - ErrNoMarkets = dex.ErrorKind("no markets") - defaultOracleWeighting = 0.2 + ErrNoMarkets = dex.ErrorKind("no markets") // Our mid-gap rate derived from the local DEX order book is converted to an // effective mid-gap that can only vary by up to 3% from the oracle rate. @@ -46,26 +45,32 @@ const ( maxOracleMismatch = 0.03 ) -// GapStrategy is a specifier for an algorithm to choose the maker bot's target -// spread. -type GapStrategy string - -const ( - // GapStrategyMultiplier calculates the spread by multiplying the - // break-even gap by the specified multiplier, 1 <= r <= 100. - GapStrategyMultiplier GapStrategy = "multiplier" - // GapStrategyAbsolute sets the spread to the rate difference. - GapStrategyAbsolute GapStrategy = "absolute" - // GapStrategyAbsolutePlus sets the spread to the rate difference plus the - // break-even gap. - GapStrategyAbsolutePlus GapStrategy = "absolute-plus" - // GapStrategyPercent sets the spread as a ratio of the mid-gap rate. - // 0 <= r <= 0.1 - GapStrategyPercent GapStrategy = "percent" - // GapStrategyPercentPlus sets the spread as a ratio of the mid-gap rate - // plus the break-even gap. - GapStrategyPercentPlus GapStrategy = "percent-plus" -) +// newEpochEngineNote is used to notify a botEngine that a new epoch has begun. +type newEpochEngineNote uint64 + +// orderUpdateEngineNote is used to notify a botEngine that the status of an +// order has changed. +type orderUpdateEngineNote *Order + +// botEngine defines the functions required to implement an engine for a market +// maker bot. A botEngine holds the logic of when to place and cancel orders. +// Currently new orders/cancels are placed when a newEpochEngineNote is passed +// to Notify, but in the future this will be updated to happen due to more +// granular triggers. +type botEngine interface { + // run starts a botEngine. + run(context.Context) (*sync.WaitGroup, error) + // stop stops a botEngine. + stop() + // notify notifies the engine about DEX events. + notify(interface{}) + // update updates the configuration of a bot engine. + update([]byte) error + // initialLotsRequired returns the amount of lots of a combination of the + // base and quote assets that are required to be in the user's wallets before + // starting the bot. + initialLotsRequired() uint64 +} // MakerProgram is the program for a makerBot. type MakerProgram struct { @@ -73,42 +78,12 @@ type MakerProgram struct { BaseID uint32 `json:"baseID"` QuoteID uint32 `json:"quoteID"` - // Lots is the number of lots to allocate to each side of the market. This - // is an ideal allotment, but at any given time, a side could have up to - // 2 * Lots on order. - Lots uint64 `json:"lots"` - - // GapStrategy selects an algorithm for calculating the target spread. - GapStrategy GapStrategy `json:"gapStrategy"` - - // GapFactor controls the gap width in a way determined by the GapStrategy. - GapFactor float64 `json:"gapFactor"` - - // DriftTolerance is how far away from an ideal price an order can drift - // before it will replaced (units: ratio of price). Default: 0.1%. - // 0 <= x <= 0.01. - DriftTolerance float64 `json:"driftTolerance"` - - // OracleWeighting affects how the target price is derived based on external - // market data. OracleWeighting, r, determines the target price with the - // formula: - // target_price = dex_mid_gap_price * (1 - r) + oracle_price * r - // OracleWeighting is limited to 0 <= x <= 1.0. - // Fetching of price data is disabled if OracleWeighting = 0. - OracleWeighting *float64 `json:"oracleWeighting"` - - // OracleBias applies a bias in the positive (higher price) or negative - // (lower price) direction. -0.05 <= x <= 0.05. - OracleBias float64 `json:"oracleBias"` - - // EmptyMarketRate can be set if there is no market data available, and is - // ignored if there is market data available. - EmptyMarketRate float64 `json:"manualRate"` + // only one of these will be non-nil + GapEngineCfg *GapEngineCfg `json:"gapEngineCfg"` + ArbEngineCfg *ArbEngineCfg `json:"arbEngineCfg"` } -// validateProgram checks the sensibility of a *MakerProgram's values and sets -// some defaults. -func validateProgram(pgm *MakerProgram) error { +func (pgm *MakerProgram) validate() error { if pgm.Host == "" { return errors.New("no host specified") } @@ -118,51 +93,27 @@ func validateProgram(pgm *MakerProgram) error { if dex.BipIDSymbol(pgm.QuoteID) == "" { return fmt.Errorf("quote asset %d unknown", pgm.QuoteID) } - if pgm.Lots == 0 { - return errors.New("cannot run with lots = 0") - } - if pgm.OracleBias < -0.05 || pgm.OracleBias > 0.05 { - return fmt.Errorf("bias %f out of bounds", pgm.OracleBias) - } - if pgm.OracleWeighting != nil { - w := *pgm.OracleWeighting - if w < 0 || w > 1 { - return fmt.Errorf("oracle weighting %f out of bounds", w) - } - } - if pgm.DriftTolerance == 0 { - pgm.DriftTolerance = 0.001 + if pgm.GapEngineCfg == nil && pgm.ArbEngineCfg == nil { + return fmt.Errorf("at least one engine cfg must be populated") } - if pgm.DriftTolerance < 0 || pgm.DriftTolerance > 0.01 { - return fmt.Errorf("drift tolerance %f out of bounds", pgm.DriftTolerance) + if pgm.GapEngineCfg != nil && pgm.ArbEngineCfg != nil { + return fmt.Errorf("only one engine cfg can be populated") } - var limits [2]float64 - switch pgm.GapStrategy { - case GapStrategyMultiplier: - limits = [2]float64{1, 100} - case GapStrategyPercent, GapStrategyPercentPlus: - limits = [2]float64{0, 0.1} - case GapStrategyAbsolute, GapStrategyAbsolutePlus: - limits = [2]float64{0, math.MaxFloat64} // validate at < spot price at creation time - default: - return fmt.Errorf("unknown gap strategy %q", pgm.GapStrategy) + if pgm.GapEngineCfg != nil { + if err := pgm.GapEngineCfg.validate(); err != nil { + return fmt.Errorf("error validating gap engine cfg: %w", err) + } } - if pgm.GapFactor < limits[0] || pgm.GapFactor > limits[1] { - return fmt.Errorf("%s gap factor %f is out of bounds %+v", pgm.GapStrategy, pgm.GapFactor, limits) + if pgm.ArbEngineCfg != nil { + if err := pgm.ArbEngineCfg.validate(); err != nil { + return fmt.Errorf("error validating arb engine cfg: %w", err) + } } - return nil -} -// oracleWeighting returns the specified OracleWeighting, or the default if -// not set. -func (pgm *MakerProgram) oracleWeighting() float64 { - if pgm.OracleWeighting == nil { - return defaultOracleWeighting - } - return *pgm.OracleWeighting + return nil } // makerAsset combines a *dex.Asset with a WalletState. @@ -170,25 +121,10 @@ type makerAsset struct { *SupportedAsset // walletV atomic.Value // *WalletState balanceV atomic.Value // *WalletBalance - } // makerBot is a *Core extension that enables operation of a market-maker bot. -// Given an order for L lots, every epoch the makerBot will... -// 1. Calculate a "basis price", which is based on DEX market data, -// optionally mixed (OracleWeight) with external market data. -// 2. Calculate a "break-even spread". This is the spread at which tx fee -// losses exactly match profits. -// 3. The break-even spread serves as a hard minimum, and is used to determine -// the target spread based on the specified gap strategy, giving the target -// buy and sell prices. -// 4. Scan existing orders to determine if their prices are still valid, -// within DriftTolerance of the buy or sell price. If not, schedule them -// for cancellation. -// 5. Calculate how many lots are needed to be ordered in order to meet the -// 2 x L commitment. If low balance restricts the maintenance of L lots on -// one side, allow the difference in lots to be added to the opposite side. -// 6. Place orders, cancels first, then buys and sells. +// The strategy the makerBot follows depends on the engine. type makerBot struct { *Core pgmID uint64 @@ -198,13 +134,13 @@ type makerBot struct { market *Market log dex.Logger book *orderbook.OrderBook + engine botEngine running uint32 // atomic wg sync.WaitGroup + ctx context.Context die context.CancelFunc - rebalanceRunning uint32 // atomic - programV atomic.Value // *MakerProgram ordMtx sync.RWMutex @@ -213,10 +149,58 @@ type makerBot struct { oracleRunning uint32 // atomic } +var _ gapEngineInputs = (*makerBot)(nil) +var _ arbEngineInputs = (*makerBot)(nil) + +// checkInitialFunding ensures that the wallets have enough funds to start the bot. +// +// The makerBot's engine must be set before calling this function. +func (m *makerBot) checkInitialFunding(ctx context.Context) error { + // Get a rate now, because max buy won't have an oracle fallback. + var rate uint64 + if m.market.SpotPrice != nil { + rate = m.market.SpotPrice.Rate + } + if rate == 0 { + price, err := m.syncOraclePrice(ctx) + if err != nil { + return fmt.Errorf("failed to establish a starting price: %v", err) + } + rate = m.market.ConventionalRateToMsg(price) + } + + pgm := m.program() + + var maxBuyLots, maxSellLots uint64 + maxBuy, err := m.maxBuy(rate) + if err == nil { + maxBuyLots = maxBuy.Swap.Lots + } else { + m.log.Errorf("Bot MaxBuy error: %v", err) + } + maxSell, err := m.maxSell() + if err == nil { + maxSellLots = maxSell.Swap.Lots + } else { + m.log.Errorf("Bot MaxSell error: %v", err) + } + + lotsRequired := m.engine.initialLotsRequired() + if maxBuyLots+maxSellLots < lotsRequired { + return fmt.Errorf("cannot create bot. %d lots total balance required to start, "+ + "and only %d %s lots and %d %s lots = %d total lots are available", lotsRequired, + maxBuyLots, unbip(pgm.QuoteID), maxSellLots, unbip(pgm.BaseID), maxSellLots+maxBuyLots) + } + + return nil +} + func createMakerBot(ctx context.Context, c *Core, pgm *MakerProgram) (*makerBot, error) { - if err := validateProgram(pgm); err != nil { + err := pgm.validate() + if err != nil { return nil, err } + dc, err := c.connectedDEX(pgm.Host) if err != nil { return nil, err @@ -267,37 +251,32 @@ func createMakerBot(ctx context.Context, c *Core, pgm *MakerProgram) (*makerBot, } m.programV.Store(pgm) - // Get a rate now, because max buy won't have an oracle fallback. - var rate uint64 - if mkt.SpotPrice != nil { - rate = mkt.SpotPrice.Rate - } - if rate == 0 { - price, err := m.syncOraclePrice(ctx) + if pgm.GapEngineCfg != nil { + engine, err := newGapEngine(m, pgm.GapEngineCfg, m.log.SubLogger("GAP-ENGINE")) if err != nil { - return nil, fmt.Errorf("failed to establish a starting price: %v", err) + return nil, fmt.Errorf("failed to create gap engine: %w", err) } - rate = mkt.ConventionalRateToMsg(price) - } - - var maxBuyLots, maxSellLots uint64 - maxBuy, err := c.MaxBuy(pgm.Host, pgm.BaseID, pgm.QuoteID, rate) - if err == nil { - maxBuyLots = maxBuy.Swap.Lots - } else { - m.log.Errorf("Bot MaxBuy error: %v", err) - } - maxSell, err := c.MaxSell(pgm.Host, pgm.BaseID, pgm.QuoteID) - if err == nil { - maxSellLots = maxSell.Swap.Lots + m.engine = engine + } else if pgm.ArbEngineCfg != nil { + c.cexesMtx.RLock() + cex, found := c.cexes[pgm.ArbEngineCfg.CEXName] + c.cexesMtx.RUnlock() + if !found { + return nil, fmt.Errorf("could not find cex called %s", pgm.ArbEngineCfg.CEXName) + } + engine, err := newArbEngine(pgm.ArbEngineCfg, m, m.log.SubLogger("ARB-ENGINE"), m.net, pgm.BaseID, pgm.QuoteID, cex.cex) + if err != nil { + return nil, fmt.Errorf("failed to create arb engine: %w", err) + } + m.engine = engine } else { - m.log.Errorf("Bot MaxSell error: %v", err) + // This is also checked in pgm.validate() + return nil, fmt.Errorf("program does not contain an engine cfg") } - if maxBuyLots+maxSellLots < pgm.Lots*2 { - return nil, fmt.Errorf("cannot create bot with %d lots. 2 x %d = %d lots total balance required to start, "+ - "and only %d %s lots and %d %s lots = %d total lots are available", pgm.Lots, pgm.Lots, pgm.Lots*2, - maxBuyLots, unbip(pgm.QuoteID), maxSellLots, unbip(pgm.BaseID), maxSellLots+maxBuyLots) + err = m.checkInitialFunding(ctx) + if err != nil { + return nil, err } // We don't use the dynamic data from *Market, just the configuration. @@ -324,13 +303,17 @@ func newMakerBot(ctx context.Context, c *Core, pgm *MakerProgram) (*makerBot, er return m, nil } -// recreateMakerBot creates a new makerBot and assigns the specified program ID. -func recreateMakerBot(ctx context.Context, c *Core, pgmID uint64, pgm *MakerProgram) (*makerBot, error) { - m, err := createMakerBot(ctx, c, pgm) +// recreateMakerBot recreates a maker bot using the object saved in the db. +func recreateMakerBot(ctx context.Context, c *Core, id uint64, botPgm *db.BotProgram) (*makerBot, error) { + var makerPgm *MakerProgram + if err := json.Unmarshal(botPgm.Program, &makerPgm); err != nil { + return nil, fmt.Errorf("Error decoding maker program: %v", err) + } + m, err := createMakerBot(ctx, c, makerPgm) if err != nil { return nil, err } - m.pgmID = pgmID + m.pgmID = id return m, nil } @@ -357,6 +340,7 @@ func (m *makerBot) liveOrderIDs() []order.OrderID { // the database. func (m *makerBot) retire() { m.stop() + if err := m.db.RetireBotProgram(m.pgmID); err != nil { m.log.Errorf("error retiring bot program") return @@ -377,6 +361,12 @@ func (m *makerBot) stop() { } } + m.ordMtx.Lock() + m.ords = make(map[order.OrderID]*Order) + m.ordMtx.Unlock() + + m.engine.stop() + // TODO: Should really wait until the cancels match. If we're cancelling // orders placed in the same epoch, they might miss and need to be replaced. } @@ -392,6 +382,7 @@ func (m *makerBot) run(ctx context.Context) { }() ctx, m.die = context.WithCancel(ctx) + m.ctx = ctx defer m.die() pgm := m.program() @@ -403,8 +394,19 @@ func (m *makerBot) run(ctx context.Context) { } m.book = book - if pgm.OracleWeighting != nil { - m.startOracleSync(ctx) + if pgm.GapEngineCfg != nil && pgm.GapEngineCfg.OracleWeighting > 0 { + m.startOracleSync(m.ctx) + } + + if pgm.ArbEngineCfg != nil { + cexReport, initialConnection, err := m.Core.connectCEX(pgm.ArbEngineCfg.CEXName) + if err != nil { + m.log.Errorf("failed to connect to %v: %v", pgm.ArbEngineCfg.CEXName, err) + return + } + if initialConnection { + m.Core.notify(newCEXNote(cexReport)) + } } m.wg.Add(1) @@ -424,8 +426,19 @@ func (m *makerBot) run(ctx context.Context) { } }() - cid, notes := m.notificationFeed() + m.wg.Add(1) + go func() { + defer m.wg.Done() + engineWg, err := m.engine.run(ctx) + if err != nil { + m.log.Errorf("Run bot engine error: %v", err) + m.die() + return + } + engineWg.Wait() + }() + cid, notes := m.notificationFeed() m.wg.Add(1) go func() { defer m.wg.Done() @@ -433,7 +446,7 @@ func (m *makerBot) run(ctx context.Context) { for { select { case n := <-notes: - m.handleNote(ctx, n) + go m.handleNote(ctx, n) case <-ctx.Done(): return } @@ -445,6 +458,21 @@ func (m *makerBot) run(ctx context.Context) { m.wg.Wait() } +// handleNote handles the makerBot's Core notifications. +func (m *makerBot) handleNote(ctx context.Context, note Notification) { + switch n := note.(type) { + case *OrderNote: + ord := n.Order + if ord == nil { + return + } + m.processTrade(ord) + m.engine.notify(orderUpdateEngineNote(ord)) + case *EpochNotification: + m.engine.notify(newEpochEngineNote(n.Epoch)) + } +} + // botOrders is a list of orders created and monitored by the makerBot. func (m *makerBot) botOrders() []*BotOrder { m.ordMtx.RLock() @@ -493,23 +521,69 @@ func (m *makerBot) saveProgram() (uint64, error) { return m.db.SaveBotProgram(dbRecord) } -// updateProgram updates the current bot program. The applied changes will -// be in effect for the next rebalance. +// marshalEngineCfg marshals the engine's configuration. +func (m *makerBot) marshalEngineCfg() ([]byte, error) { + pgm := m.program() + + if pgm.GapEngineCfg != nil { + cfgB, err := json.Marshal(pgm.GapEngineCfg) + if err != nil { + return nil, err + } + return cfgB, nil + } + + if pgm.ArbEngineCfg != nil { + cfgB, err := json.Marshal(pgm.ArbEngineCfg) + if err != nil { + return nil, err + } + return cfgB, nil + } + + return nil, fmt.Errorf("program does not contain an engine cfg") +} + +// updateProgram updates the current bot program. func (m *makerBot) updateProgram(pgm *MakerProgram) error { - dbRecord, err := makerProgramDBRecord(pgm) + err := pgm.validate() if err != nil { return err } - if err := m.db.UpdateBotProgram(m.pgmID, dbRecord); err != nil { + currentPgm := m.program() + if pgm.Host != currentPgm.Host || + pgm.BaseID != currentPgm.BaseID || + pgm.QuoteID != currentPgm.QuoteID || + (pgm.GapEngineCfg == nil) != (currentPgm.GapEngineCfg == nil) || + (pgm.ArbEngineCfg == nil) != (currentPgm.ArbEngineCfg == nil) { + return errors.New("only engine config can be updated") + } + + dbRecord, err := makerProgramDBRecord(pgm) + if err != nil { return err } - if pgm.oracleWeighting() > 0 { - m.startOracleSync(m.ctx) // no-op if sync is already running + cfgB, err := m.marshalEngineCfg() + if err != nil { + return fmt.Errorf("failed to marshal engine cfg: %w", err) } + err = m.engine.update(cfgB) + if err != nil { + return fmt.Errorf("failed to update engine: %w", err) + } m.programV.Store(pgm) + + if pgm.GapEngineCfg != nil && pgm.GapEngineCfg.OracleWeighting > 0 && atomic.LoadUint32(&m.running) == 1 { + m.startOracleSync(m.ctx) + } + + if err := m.db.UpdateBotProgram(m.pgmID, dbRecord); err != nil { + m.log.Errorf("faied to store updated bot program in DB: %v", err) + } + m.notify(newBotNote(TopicBotUpdated, "", "", db.Data, m.report())) return nil } @@ -586,17 +660,17 @@ func (m *makerBot) syncOracle(ctx context.Context) { } // basisPrice is the basis price for the makerBot's market. -func (m *makerBot) basisPrice() uint64 { +func (m *makerBot) basisPrice(oracleBias, oracleWeighting, emptyMarketRate float64) uint64 { pgm := m.program() midGap, err := m.book.MidGap() if err != nil && !errors.Is(err, orderbook.ErrEmptyOrderbook) { m.log.Errorf("error calculating mid-gap: %w", err) return 0 } - if p := basisPrice(pgm.Host, m.market, pgm.OracleBias, pgm.oracleWeighting(), midGap, m, m.log); p > 0 { + if p := basisPrice(pgm.Host, m.market, oracleBias, oracleWeighting, midGap, m, m.log); p > 0 { return p } - return m.market.ConventionalRateToMsg(pgm.EmptyMarketRate) + return m.market.ConventionalRateToMsg(emptyMarketRate) } type basisPricer interface { @@ -805,20 +879,6 @@ func oracleMarketReport(ctx context.Context, b, q *SupportedAsset, log dex.Logge return } -// handleNote handles the makerBot's Core notifications. -func (m *makerBot) handleNote(ctx context.Context, note Notification) { - switch n := note.(type) { - case *OrderNote: - ord := n.Order - if ord == nil { - return - } - m.processTrade(ord) - case *EpochNotification: - go m.rebalance(ctx, n.Epoch) - } -} - // processTrade processes an order update. func (m *makerBot) processTrade(o *Order) { if len(o.ID) == 0 { @@ -861,261 +921,8 @@ func (m *makerBot) processTrade(o *Order) { } } -// rebalance rebalances the makerBot's orders. -// 1. Generate a basis price, p, adjusted for oracle weighting and bias. -// 2. Apply the gap strategy to get a target spread, s. -// 3. Check existing orders, if out of bounds -// [p +/- (s/2) - drift_tolerance, p +/- (s/2) + drift_tolerance], -// cancel the order -// 4. Compare remaining order counts to configured, lots, and place new -// orders. -func (m *makerBot) rebalance(ctx context.Context, newEpoch uint64) { - if !atomic.CompareAndSwapUint32(&m.rebalanceRunning, 0, 1) { - return - } - defer atomic.StoreUint32(&m.rebalanceRunning, 0) - - newBuyLots, newSellLots, buyPrice, sellPrice := rebalance(ctx, m, m.market, m.program(), m.log, newEpoch) - - // Place buy orders. - if newBuyLots > 0 { - ord := m.placeOrder(uint64(newBuyLots), buyPrice, false) - if ord != nil { - var oid order.OrderID - copy(oid[:], ord.ID) - m.ordMtx.Lock() - m.ords[oid] = ord - m.ordMtx.Unlock() - } - } - - // Place sell orders. - if newSellLots > 0 { - ord := m.placeOrder(uint64(newSellLots), sellPrice, true) - if ord != nil { - var oid order.OrderID - copy(oid[:], ord.ID) - m.ordMtx.Lock() - m.ords[oid] = ord - m.ordMtx.Unlock() - } - } -} - -// rebalancer is a stub to enabling testing of the rebalance calculations. -type rebalancer interface { - basisPrice() uint64 - halfSpread(basisPrice uint64) (uint64, error) - sortedOrders() (buys, sells []*sortedOrder) - cancelOrder(oid order.OrderID) error - MaxBuy(host string, base, quote uint32, rate uint64) (*MaxOrderEstimate, error) - MaxSell(host string, base, quote uint32) (*MaxOrderEstimate, error) -} - -func rebalance(ctx context.Context, m rebalancer, mkt *Market, pgm *MakerProgram, log dex.Logger, newEpoch uint64) (newBuyLots, newSellLots int, buyPrice, sellPrice uint64) { - basisPrice := m.basisPrice() - if basisPrice == 0 { - log.Errorf("No basis price available and no empty-market rate set") - return - } - - log.Tracef("rebalance: basis price = %d", basisPrice) - - // Three of the strategies will use a break-even half-gap. - var breakEven uint64 - switch pgm.GapStrategy { - case GapStrategyAbsolutePlus, GapStrategyPercentPlus, GapStrategyMultiplier: - var err error - breakEven, err = m.halfSpread(basisPrice) - if err != nil { - log.Errorf("Could not calculate break-even spread: %v", err) - return - } - } - - // Apply the base strategy. - var halfSpread uint64 - switch pgm.GapStrategy { - case GapStrategyMultiplier: - halfSpread = uint64(math.Round(float64(breakEven) * pgm.GapFactor)) - case GapStrategyPercent, GapStrategyPercentPlus: - halfSpread = uint64(math.Round(pgm.GapFactor * float64(basisPrice))) - case GapStrategyAbsolute, GapStrategyAbsolutePlus: - halfSpread = mkt.ConventionalRateToMsg(pgm.GapFactor) - } - - // Add the break-even to the "-plus" strategies. - switch pgm.GapStrategy { - case GapStrategyAbsolutePlus, GapStrategyPercentPlus: - halfSpread += breakEven - } - - log.Tracef("rebalance: strategized half-spread = %d, strategy = %s", halfSpread, pgm.GapStrategy) - - halfSpread = steppedRate(halfSpread, mkt.RateStep) - - log.Tracef("rebalance: step-resolved half-spread = %d", halfSpread) - - buyPrice = basisPrice - halfSpread - sellPrice = basisPrice + halfSpread - - log.Tracef("rebalance: buy price = %d, sell price = %d", buyPrice, sellPrice) - - buys, sells := m.sortedOrders() - - // Figure out the best existing sell and buy of existing monitored orders. - // These values are used to cancel order placement if there is a chance - // of self-matching, especially against a scheduled cancel order. - highestBuy, lowestSell := buyPrice, sellPrice - if len(sells) > 0 { - ord := sells[0] - if ord.rate < lowestSell { - lowestSell = ord.rate - } - } - if len(buys) > 0 { - ord := buys[0] - if ord.rate > highestBuy { - highestBuy = ord.rate - } - } - - // Check if order-placement might self-match. - var cantBuy, cantSell bool - if buyPrice >= lowestSell { - log.Tracef("rebalance: can't buy because delayed cancel sell order interferes. booked rate = %d, buy price = %d", - lowestSell, buyPrice) - cantBuy = true - } - if sellPrice <= highestBuy { - log.Tracef("rebalance: can't sell because delayed cancel sell order interferes. booked rate = %d, sell price = %d", - highestBuy, sellPrice) - cantSell = true - } - - var canceledBuyLots, canceledSellLots uint64 // for stats reporting - cancels := make([]*sortedOrder, 0) - addCancel := func(ord *sortedOrder) { - if newEpoch-ord.Epoch < 2 { - log.Debugf("rebalance: skipping cancel not past free cancel threshold") - } - - if ord.Sell { - canceledSellLots += ord.lots - } else { - canceledBuyLots += ord.lots - } - if ord.Status <= order.OrderStatusBooked { - cancels = append(cancels, ord) - } - } - - processSide := func(ords []*sortedOrder, price uint64, sell bool) (keptLots int) { - tol := uint64(math.Round(float64(price) * pgm.DriftTolerance)) - low, high := price-tol, price+tol - - // Limit large drift tolerances to their respective sides, i.e. mid-gap - // is a hard cutoff. - if !sell && high > basisPrice { - high = basisPrice - 1 - } - if sell && low < basisPrice { - low = basisPrice + 1 - } - - for _, ord := range ords { - log.Tracef("rebalance: processSide: sell = %t, order rate = %d, low = %d, high = %d", - sell, ord.rate, low, high) - if ord.rate < low || ord.rate > high { - if newEpoch < ord.Epoch+2 { // https://github.com/decred/dcrdex/pull/1682 - log.Tracef("rebalance: postponing cancellation for order < 2 epochs old") - keptLots += int(ord.lots) - } else { - log.Tracef("rebalance: cancelling out-of-bounds order (%d lots remaining). rate %d is not in range %d < r < %d", - ord.lots, ord.rate, low, high) - addCancel(ord) - } - } else { - keptLots += int(ord.lots) - } - } - return - } - - newBuyLots, newSellLots = int(pgm.Lots), int(pgm.Lots) - keptBuys := processSide(buys, buyPrice, false) - keptSells := processSide(sells, sellPrice, true) - newBuyLots -= keptBuys - newSellLots -= keptSells - - // Cancel out of bounds or over-stacked orders. - if len(cancels) > 0 { - // Only cancel orders that are > 1 epoch old. - log.Tracef("rebalance: cancelling %d orders", len(cancels)) - for _, cancel := range cancels { - if err := m.cancelOrder(cancel.id); err != nil { - log.Errorf("error cancelling order: %v", err) - return - } - } - } - - if cantBuy { - newBuyLots = 0 - } - if cantSell { - newSellLots = 0 - } - - log.Tracef("rebalance: %d buy lots and %d sell lots scheduled after existing valid %d buy and %d sell lots accounted", - newBuyLots, newSellLots, keptBuys, keptSells) - - // Resolve requested lots against the current balance. If we come up short, - // we may be able to place extra orders on the other side to satisfy our - // lot commitment and shift our balance back. - var maxBuyLots int - if newBuyLots > 0 { - // TODO: MaxBuy and MaxSell shouldn't error for insufficient funds, but - // they do. Maybe consider a constant error asset.InsufficientBalance. - maxOrder, err := m.MaxBuy(pgm.Host, pgm.BaseID, pgm.QuoteID, buyPrice) - if err != nil { - log.Tracef("MaxBuy error: %v", err) - } else { - maxBuyLots = int(maxOrder.Swap.Lots) - } - if maxBuyLots < newBuyLots { - // We don't have the balance. Add our shortcoming to the other side. - shortLots := newBuyLots - maxBuyLots - newSellLots += shortLots - newBuyLots = maxBuyLots - log.Tracef("rebalance: reduced buy lots to %d because of low balance", newBuyLots) - } - } - - if newSellLots > 0 { - var maxLots int - maxOrder, err := m.MaxSell(pgm.Host, pgm.BaseID, pgm.QuoteID) - if err != nil { - log.Tracef("MaxSell error: %v", err) - } else { - maxLots = int(maxOrder.Swap.Lots) - } - if maxLots < newSellLots { - shortLots := newSellLots - maxLots - newBuyLots += shortLots - if newBuyLots > maxBuyLots { - log.Tracef("rebalance: increased buy lot order to %d lots because sell balance is low", newBuyLots) - newBuyLots = maxBuyLots - } - newSellLots = maxLots - log.Tracef("rebalance: reduced sell lots to %d because of low balance", newSellLots) - } - } - return -} - // placeOrder places a single order on the market. -func (m *makerBot) placeOrder(lots, rate uint64, sell bool) *Order { +func (m *makerBot) placeOrder(lots, rate uint64, sell bool) (order.OrderID, error) { pgm := m.program() ord, err := m.Trade(nil, &TradeForm{ Host: pgm.Host, @@ -1128,51 +935,93 @@ func (m *makerBot) placeOrder(lots, rate uint64, sell bool) *Order { Program: m.pgmID, }) if err != nil { - m.log.Errorf("Error placing rebalancing order: %v", err) - return nil + return order.OrderID{}, fmt.Errorf("Error placing order: %v", err) } - return ord + + var oid order.OrderID + copy(oid[:], ord.ID) + m.ordMtx.Lock() + m.ords[oid] = ord + m.ordMtx.Unlock() + return oid, nil } -// sortedOrder is a subset of an *Order used internally for sorting. -type sortedOrder struct { - *Order - id order.OrderID - rate uint64 - lots uint64 +func (m *makerBot) lotSize() uint64 { + return m.market.LotSize } -// sortedOrders returns lists of buy and sell orders, with buys sorted -// high to low by rate, and sells low to high. -func (m *makerBot) sortedOrders() (buys, sells []*sortedOrder) { - makeSortedOrder := func(o *Order) *sortedOrder { - var oid order.OrderID - copy(oid[:], o.ID) - return &sortedOrder{ - Order: o, - id: oid, - rate: o.Rate, - lots: (o.Qty - o.Filled) / m.market.LotSize, +func (m *makerBot) vwap(numLots uint64, sell bool) (avgRate uint64, extrema uint64, filled bool, err error) { + orders, _, err := m.book.BestNOrders(int(numLots), sell) + if err != nil { + return 0, 0, false, err + } + + remainingLots := numLots + var weightedSum uint64 + for _, order := range orders { + extrema = order.Rate + lotsInOrder := order.Quantity / m.market.LotSize + if lotsInOrder >= remainingLots { + weightedSum += remainingLots * extrema + filled = true + break } + remainingLots -= lotsInOrder + weightedSum += lotsInOrder * extrema } - buys, sells = make([]*sortedOrder, 0), make([]*sortedOrder, 0) + if !filled { + return 0, 0, false, nil + } + + return weightedSum / numLots, extrema, true, nil +} + +// conventionalRateToMsg converts a conventional rate to a message-rate. +func (m *makerBot) conventionalRateToMsg(r float64) uint64 { + return m.market.ConventionalRateToMsg(r) +} + +// rateStep returns the increments in which a price on the market this bot +// is trading on can be specified. +func (m *makerBot) rateStep() uint64 { + return m.market.RateStep +} + +// sortedOrders returns lists of buy and sell orders, with buys sorted +// high to low by rate, and sells low to high. +func (m *makerBot) sortedOrders() (buys, sells []*Order) { + buys, sells = make([]*Order, 0), make([]*Order, 0) m.ordMtx.RLock() for _, ord := range m.ords { if ord.Sell { - sells = append(sells, makeSortedOrder(ord)) + sells = append(sells, ord) } else { - buys = append(buys, makeSortedOrder(ord)) + buys = append(buys, ord) } } m.ordMtx.RUnlock() - sort.Slice(buys, func(i, j int) bool { return buys[i].rate > buys[j].rate }) - sort.Slice(sells, func(i, j int) bool { return sells[i].rate < sells[j].rate }) + sort.Slice(buys, func(i, j int) bool { return buys[i].Rate > buys[j].Rate }) + sort.Slice(sells, func(i, j int) bool { return sells[i].Rate < sells[j].Rate }) return buys, sells } +// maxBuy finds the maximum priced buy order the user can place on the +// market traded on by this bot. +func (m *makerBot) maxBuy(rate uint64) (*MaxOrderEstimate, error) { + pgm := m.program() + return m.Core.MaxBuy(pgm.Host, pgm.BaseID, pgm.QuoteID, rate) +} + +// maxSell finds the maximum priced sell order the user can place on the +// market traded on by this bot. +func (m *makerBot) maxSell() (*MaxOrderEstimate, error) { + pgm := m.program() + return m.Core.MaxSell(pgm.Host, pgm.BaseID, pgm.QuoteID) +} + // feeEstimates calculates the swap and redeem fees on an order. If the wallet's // PreSwap/PreRedeem method cannot provide a value (because no balance, likely), // and the Wallet implements BotWallet, then the estimate from @@ -1281,6 +1130,8 @@ func (c *Core) feeEstimates(form *TradeForm) (swapFees, redeemFees uint64, err e return } +// halfSpread calculates the half-gap at which a buy->sell or sell->buy +// sequence breaks even in terms of profit and fee losses. func (m *makerBot) halfSpread(basisPrice uint64) (uint64, error) { pgm := m.program() return breakEvenHalfSpread(pgm.Host, m.market, basisPrice, m.Core, m.log) diff --git a/client/core/bot_test.go b/client/core/bot_test.go index 5d50448606..3ea8638c1f 100644 --- a/client/core/bot_test.go +++ b/client/core/bot_test.go @@ -3,276 +3,12 @@ package core import ( - "context" "errors" - "math" "testing" - "decred.org/dcrdex/client/asset" "decred.org/dcrdex/dex" - "decred.org/dcrdex/dex/calc" - "decred.org/dcrdex/dex/order" ) -type tRebalancer struct { - basis uint64 - breakEven uint64 - breakEvenErr error - sortedBuys []*sortedOrder - sortedSells []*sortedOrder - cancels int - maxBuy *MaxOrderEstimate - maxBuyErr error - maxSell *MaxOrderEstimate - maxSellErr error -} - -var _ rebalancer = (*tRebalancer)(nil) - -func (r *tRebalancer) basisPrice() uint64 { - return r.basis -} - -func (r *tRebalancer) halfSpread(basisPrice uint64) (uint64, error) { - return r.breakEven, r.breakEvenErr -} - -func (r *tRebalancer) sortedOrders() (buys, sells []*sortedOrder) { - return r.sortedBuys, r.sortedSells -} - -func (r *tRebalancer) cancelOrder(oid order.OrderID) error { - r.cancels++ - return nil -} - -func (r *tRebalancer) MaxBuy(host string, base, quote uint32, rate uint64) (*MaxOrderEstimate, error) { - return r.maxBuy, r.maxBuyErr -} - -func (r *tRebalancer) MaxSell(host string, base, quote uint32) (*MaxOrderEstimate, error) { - return r.maxSell, r.maxSellErr -} - -func tMaxOrderEstimate(lots uint64, swapFees, redeemFees uint64) *MaxOrderEstimate { - return &MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - RealisticWorstCase: swapFees, - Lots: lots, - }, - Redeem: &asset.RedeemEstimate{ - RealisticWorstCase: redeemFees, - }, - } -} - -func TestRebalance(t *testing.T) { - const rateStep uint64 = 7e14 - const midGap uint64 = 1234 * rateStep - const lotSize uint64 = 50e8 - const breakEven uint64 = 8 * rateStep - const newEpoch = 123_456_789 - const spreadMultiplier = 3 - const driftTolerance = 0.001 - - mkt := &Market{ - RateStep: rateStep, - AtomToConv: 1, - } - - inverseLot := calc.BaseToQuote(midGap, lotSize) - - maxBuy := func(lots uint64) *MaxOrderEstimate { - return tMaxOrderEstimate(lots, inverseLot/100, lotSize/200) - } - maxSell := func(lots uint64) *MaxOrderEstimate { - return tMaxOrderEstimate(lots, lotSize/100, inverseLot/200) - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - log := dex.StdOutLogger("T", dex.LevelTrace) - - log.Info("TestRebalance: rateStep =", rateStep) - log.Info("TestRebalance: midGap =", midGap) - log.Info("TestRebalance: lotSize =", lotSize) - log.Info("TestRebalance: breakEven =", breakEven) - - type test struct { - name string - rebalancer *tRebalancer - program *MakerProgram - epoch uint64 - expBuyLots int - expSellLots int - expCancels int - } - - newBalancer := func(maxBuyLots, maxSellLots uint64, buyErr, sellErr error, existingBuys, existingSells []*sortedOrder) *tRebalancer { - return &tRebalancer{ - basis: midGap, - breakEven: breakEven, - maxBuy: maxBuy(maxBuyLots), - maxBuyErr: buyErr, - maxSell: maxSell(maxSellLots), - maxSellErr: sellErr, - sortedBuys: existingBuys, - sortedSells: existingSells, - } - } - newProgram := func(lots uint64) *MakerProgram { - return &MakerProgram{ - Lots: lots, - DriftTolerance: driftTolerance, - GapStrategy: GapStrategyMultiplier, - GapFactor: spreadMultiplier, - } - } - - newSortedOrder := func(lots, rate uint64, sell bool, freeCancel bool) *sortedOrder { - var epoch uint64 = newEpoch - if freeCancel { - epoch = newEpoch - 2 - } - return &sortedOrder{ - Order: &Order{ - Epoch: epoch, - Sell: sell, - Status: order.OrderStatusBooked, - }, - rate: rate, - lots: lots, - } - } - - buyPrice := midGap - (breakEven * spreadMultiplier) - sellPrice := midGap + (breakEven * spreadMultiplier) - - sellTolerance := uint64(math.Round(float64(sellPrice) * driftTolerance)) - - tests := []*test{ - { - name: "1 lot per side", - rebalancer: newBalancer(1, 1, nil, nil, nil, nil), - program: newProgram(1), - expBuyLots: 1, - expSellLots: 1, - }, - { - name: "1 sell, buy already exists", - rebalancer: newBalancer(1, 1, nil, nil, []*sortedOrder{newSortedOrder(1, buyPrice, false, true)}, nil), - program: newProgram(1), - expBuyLots: 0, - expSellLots: 1, - }, - { - name: "1 buy, sell already exists", - rebalancer: newBalancer(1, 1, nil, nil, nil, []*sortedOrder{newSortedOrder(1, sellPrice, true, true)}), - program: newProgram(1), - expBuyLots: 1, - expSellLots: 0, - }, - { - name: "1 buy, sell already exists, just within tolerance", - rebalancer: newBalancer(1, 1, nil, nil, nil, []*sortedOrder{newSortedOrder(1, sellPrice+sellTolerance, true, true)}), - program: newProgram(1), - expBuyLots: 1, - expSellLots: 0, - }, - { - name: "1 lot each, sell just out of tolerance, but doesn't interfere", - rebalancer: newBalancer(1, 1, nil, nil, nil, []*sortedOrder{newSortedOrder(1, sellPrice+sellTolerance+1, true, true)}), - program: newProgram(1), - expBuyLots: 1, - expSellLots: 1, - expCancels: 1, - }, - { - name: "no buy, because an existing order (cancellation) interferes", - rebalancer: newBalancer(1, 1, nil, nil, nil, []*sortedOrder{newSortedOrder(1, buyPrice, true, true)}), - program: newProgram(1), - expBuyLots: 0, // cuz interference - expSellLots: 1, - expCancels: 1, - }, - { - name: "no sell, because an existing order (cancellation) interferes", - rebalancer: newBalancer(1, 1, nil, nil, []*sortedOrder{newSortedOrder(1, sellPrice, true, true)}, nil), - program: newProgram(1), - expBuyLots: 1, - expSellLots: 0, - expCancels: 1, - }, - { - name: "1 lot each, existing order barely escapes interference", - rebalancer: newBalancer(1, 1, nil, nil, nil, []*sortedOrder{newSortedOrder(1, buyPrice+1, true, true)}), - program: newProgram(1), - expBuyLots: 1, - expSellLots: 1, - expCancels: 1, - }, - { - name: "1 sell and 3 buy lots on an unbalanced 2 lot program", - rebalancer: newBalancer(10, 1, nil, nil, nil, nil), - program: newProgram(2), - expBuyLots: 3, - expSellLots: 1, - }, - { - name: "1 sell and 3 buy lots on an unbalanced 3 lot program with balance limitations", - rebalancer: newBalancer(3, 1, nil, nil, nil, nil), - program: newProgram(3), - expBuyLots: 3, - expSellLots: 1, - }, - { - name: "2 sell and 1 buy lots on an unbalanced 2 lot program with balance limitations", - rebalancer: newBalancer(1, 2, nil, nil, nil, nil), - program: newProgram(2), - expBuyLots: 1, - expSellLots: 2, - }, - { - name: "4 buy lots on an unbalanced 2 lot program with balance but MaxSell error", - rebalancer: newBalancer(10, 1, nil, errors.New("test error"), nil, nil), - program: newProgram(2), - expBuyLots: 4, - expSellLots: 0, - }, - { - name: "1 lot sell on an unbalanced 2 lot program with limited balance but MaxBuy error", - rebalancer: newBalancer(10, 1, errors.New("test error"), nil, nil, nil), - program: newProgram(2), - expBuyLots: 0, - expSellLots: 1, - }, - } - - for _, tt := range tests { - epoch := tt.epoch - if epoch == 0 { - epoch = newEpoch - } - newBuyLots, newSellLots, buyRate, sellRate := rebalance(ctx, tt.rebalancer, mkt, tt.program, log, epoch) - if newBuyLots != tt.expBuyLots { - t.Fatalf("%s: buy lots mismatch. expected %d, got %d", tt.name, tt.expBuyLots, newBuyLots) - } - if newSellLots != tt.expSellLots { - t.Fatalf("%s: sell lots mismatch. expected %d, got %d", tt.name, tt.expSellLots, newSellLots) - } - if sellPrice != sellRate { - t.Fatalf("%s: sell price mismatch. expected %d, got %d", tt.name, sellPrice, sellRate) - } - if buyPrice != buyRate { - t.Fatalf("%s: buy price mismatch. expected %d, got %d", tt.name, buyPrice, buyRate) - } - if tt.rebalancer.cancels != tt.expCancels { - t.Fatalf("%s: cancel count mismatch. expected %d, got %d", tt.name, tt.expCancels, tt.rebalancer.cancels) - } - } -} - type tBasisPricer struct { price *stampedPrice conversions map[uint32]float64 diff --git a/client/core/botengine_arb.go b/client/core/botengine_arb.go new file mode 100644 index 0000000000..311147ba79 --- /dev/null +++ b/client/core/botengine_arb.go @@ -0,0 +1,527 @@ +package core + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "sync/atomic" + + "decred.org/dcrdex/client/core/libxc" + "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/calc" + "decred.org/dcrdex/dex/order" +) + +// ArbEngineCfg is the configuration for an ArbEngine. +type ArbEngineCfg struct { + // CEXName is the name of the cex that the bot will arbitrage. + CEXName string `json:"cexName"` + // ProfitTrigger is the minimum profit before a cross-exchange trade + // sequence is initiated. Range: 0 < ProfitTrigger << 1. For example, if + // the ProfitTrigger is 0.01 and a trade sequence would produce a 1% profit + // or better, a trade sequence will be initiated. + ProfitTrigger float64 `json:"profitTrigger"` + // MaxActiveArbs sets a limit on the number of active arbitrage sequences + // that can be open simultaneously. + MaxActiveArbs uint32 `json:"maxActiveArbs"` + // NumEpochsLeaveOpen is the number of epochs an arbitrage sequence will + // stay open if one or both of the orders were not filled. + NumEpochsLeaveOpen uint32 `json:"numEpochsLeaveOpen"` +} + +func (cfg *ArbEngineCfg) validate() error { + if cfg.ProfitTrigger <= 0 || cfg.ProfitTrigger > 1 { + return fmt.Errorf("profit trigger must be 0 < t <= 1, but got %v", cfg.ProfitTrigger) + } + + if cfg.MaxActiveArbs == 0 { + return fmt.Errorf("must allow at least 1 active arb") + } + + if cfg.NumEpochsLeaveOpen < 2 { + return fmt.Errorf("arbs must be left open for at least 2 epochs") + } + + return nil +} + +// arbSequence represents an attempted arbitrage sequence. +type arbSequence struct { + dexOrderID order.OrderID + cexOrderID string + dexRate uint64 + cexRate uint64 + cexOrderFilled bool + dexOrderFilled bool + sellOnDEX bool + startEpoch uint64 +} + +// arbEngineInputs are the input functions required an ArbEngine to function. +type arbEngineInputs interface { + lotSize() uint64 + maxBuy(rate uint64) (*MaxOrderEstimate, error) + maxSell() (*MaxOrderEstimate, error) + vwap(numLots uint64, sell bool) (avgRate uint64, extrema uint64, filled bool, err error) + sortedOrders() (buys, sells []*Order) + placeOrder(lots, rate uint64, sell bool) (order.OrderID, error) + cancelOrder(oid order.OrderID) error +} + +type arbEngine struct { + ctx context.Context + cex libxc.CEX + cexTradeUpdatesID int + inputs arbEngineInputs + cfgV atomic.Value + log dex.Logger + + base uint32 + quote uint32 + + activeArbsMtx sync.RWMutex + activeArbs []*arbSequence + + rebalanceRunning uint32 //atomic +} + +func newArbEngine(cfg *ArbEngineCfg, inputs arbEngineInputs, log dex.Logger, net dex.Network, base, quote uint32, cex libxc.CEX) (botEngine, error) { + err := cfg.validate() + if err != nil { + return nil, err + } + + a := &arbEngine{ + inputs: inputs, + log: log, + activeArbs: make([]*arbSequence, 0, 8), + base: base, + quote: quote, + cex: cex, + } + a.cfgV.Store(cfg) + return (botEngine)(a), nil +} + +func (a *arbEngine) cfg() *ArbEngineCfg { + return a.cfgV.Load().(*ArbEngineCfg) +} + +// run starts the arb engine. +func (a *arbEngine) run(ctx context.Context) (*sync.WaitGroup, error) { + a.ctx = ctx + cfg := a.cfg() + + markets, err := a.cex.Markets() + if err != nil { + return nil, err + } + var foundMarket bool + for _, market := range markets { + if market.Base == a.base && market.Quote == a.quote { + foundMarket = true + } + } + if !foundMarket { + return nil, fmt.Errorf("%s does not have a %s-%s market", cfg.CEXName, dex.BipIDSymbol(a.base), dex.BipIDSymbol(a.quote)) + } + + err = a.cex.SubscribeMarket(dex.BipIDSymbol(a.base), dex.BipIDSymbol(a.quote)) + if err != nil { + return nil, fmt.Errorf("failed to subscribe to %s %s-%s market: %v", + a.cfg().CEXName, dex.BipIDSymbol(a.base), dex.BipIDSymbol(a.quote), err) + } + + tradeUpdates, tradeUpdatesID := a.cex.SubscribeTradeUpdates() + a.cexTradeUpdatesID = tradeUpdatesID + + wg := &sync.WaitGroup{} + + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case update := <-tradeUpdates: + a.log.Tracef("received CEX trade update: %+v", update) + a.handleCEXTradeUpdate(update) + case <-ctx.Done(): + return + } + } + }() + + return wg, nil +} + +// stop stops the arb engine. All orders on the CEX are cancelled. The makerBot +// cancels the orders on the DEX. +func (a *arbEngine) stop() { + a.activeArbsMtx.RLock() + defer a.activeArbsMtx.RUnlock() + + for _, arb := range a.activeArbs { + if !arb.cexOrderFilled { + err := a.cex.CancelTrade(dex.BipIDSymbol(a.base), dex.BipIDSymbol(a.quote), arb.cexOrderID) + if err != nil { + a.log.Errorf("error cancelling CEX order id %s: %v", arb.cexOrderID, err) + } + } + } + a.activeArbs = make([]*arbSequence, 0) + + err := a.cex.UnsubscribeMarket(dex.BipIDSymbol(a.base), dex.BipIDSymbol(a.quote)) + if err != nil { + a.log.Errorf("failed to unsubscribe market: %v", err) + } +} + +// update updates the configuration of the bot engine. +func (a *arbEngine) update(cfgB []byte) error { + cfg := new(ArbEngineCfg) + + err := json.Unmarshal(cfgB, &cfg) + if err != nil { + return fmt.Errorf("failed to unmarshal gap engine config: %w", err) + } + + err = cfg.validate() + if err != nil { + return fmt.Errorf("failed to validate gap engine config: %w", err) + } + + oldCfg := a.cfg() + if oldCfg.CEXName != cfg.CEXName { + return errors.New("the CEX cannot be updated") + } + + a.cfgV.Store(cfg) + + return nil +} + +// notify notifies the engine about DEX events. +func (a *arbEngine) notify(note interface{}) { + switch n := note.(type) { + case newEpochEngineNote: + a.rebalance(uint64(n)) + case orderUpdateEngineNote: + a.handleDEXOrderUpdate(n) + } +} + +// initialLotsRequired returns the amount of lots of a combination of the +// base and quote assets that are required to be in the user's wallets before +// starting the bot. +func (a *arbEngine) initialLotsRequired() uint64 { + // At least have one lot of something on the DEX... + return 1 +} + +// rebalance checks if there is an arbitrage opportunity between the dex and cex, +// and if so, executes trades to capitalize on it. +func (a *arbEngine) rebalance(newEpoch uint64) { + if !atomic.CompareAndSwapUint32(&a.rebalanceRunning, 0, 1) { + return + } + defer atomic.StoreUint32(&a.rebalanceRunning, 0) + + a.log.Tracef("arbEngine rebalance epoch: %v", newEpoch) + + cfg := a.cfg() + + exists, sellOnDex, lotsToArb, dexRate, cexRate := a.arbExists() + if exists { + // Execution will not happen if will cause a self-match. Orders that + // can cause a self-match will be cancelled below when cancelling + // arbs in the opposite direction. + a.executeArb(sellOnDex, lotsToArb, dexRate, cexRate, newEpoch, cfg) + } + + a.activeArbsMtx.Lock() + defer a.activeArbsMtx.Unlock() + + remainingArbs := make([]*arbSequence, 0, len(a.activeArbs)) + for _, arb := range a.activeArbs { + expired := newEpoch-arb.startEpoch > uint64(cfg.NumEpochsLeaveOpen) + oppositeDirectionArbFound := exists && sellOnDex != arb.sellOnDEX + + if expired || oppositeDirectionArbFound { + a.cancelArbSequence(arb) + } else { + remainingArbs = append(remainingArbs, arb) + } + } + + a.activeArbs = remainingArbs +} + +// arbExists checks if an arbitrage opportunity exists. +func (a *arbEngine) arbExists() (exists, sellOnDex bool, lotsToArb, dexRate, cexRate uint64) { + cfg := a.cfg() + + cexBaseBalance, err := a.cex.Balance(dex.BipIDSymbol(a.base)) + if err != nil { + a.log.Errorf("failed to get cex balance for %v", dex.BipIDSymbol(a.base)) + return false, false, 0, 0, 0 + } + + cexQuoteBalance, err := a.cex.Balance(dex.BipIDSymbol(a.quote)) + if err != nil { + a.log.Errorf("failed to get cex balance for %v", dex.BipIDSymbol(a.quote)) + return false, false, 0, 0, 0 + } + + sellOnDex = false + exists, lotsToArb, dexRate, cexRate = a.arbExistsOnSide(sellOnDex, cexBaseBalance.Available, cexQuoteBalance.Available, cfg) + if exists { + return + } + + sellOnDex = true + exists, lotsToArb, dexRate, cexRate = a.arbExistsOnSide(sellOnDex, cexBaseBalance.Available, cexQuoteBalance.Available, cfg) + return +} + +// arbExistsOnSide checks if an arbitrage opportunity exists either when +// buying or selling on the dex. +func (a *arbEngine) arbExistsOnSide(sellOnDEX bool, cexBaseBalance uint64, cexQuoteBalance uint64, cfg *ArbEngineCfg) (exists bool, lotsToArb, dexRate, cexRate uint64) { + noArb := func() (bool, uint64, uint64, uint64) { + return false, 0, 0, 0 + } + + lotSize := a.inputs.lotSize() + + a.log.Tracef("arbExistsOnSide - sellOnDex: %v", sellOnDEX) + + // maxLots is the max amount of lots of the base asset that can be traded + // on the exchange where the base asset is being sold. + var maxLots uint64 + if sellOnDEX { + maxOrder, err := a.inputs.maxSell() + if err != nil { + a.log.Errorf("maxSell error: %v", err) + return noArb() + } + maxLots = maxOrder.Swap.Lots + } else { + maxLots = cexBaseBalance / lotSize + } + if maxLots == 0 { + a.log.Infof("not enough balance to arb 1 lot") + return noArb() + } + + a.log.Tracef("arbExistsOnSide - num lots of base available to sell: %v", maxLots) + + for numLots := uint64(1); numLots <= maxLots; numLots++ { + a.log.Tracef("checking arb for numLots=%v", numLots) + + dexAvg, dexExtrema, dexFilled, err := a.inputs.vwap(numLots, !sellOnDEX) + if err != nil { + a.log.Errorf("error calculating dex VWAP: %v", err) + return noArb() + } + a.log.Tracef("DEX VWAP - avg: %v, extrema: %v, filled: %v", dexAvg, dexExtrema, dexFilled) + if !dexFilled { + break + } + // If buying on dex, check that we have enough to buy at this rate. + if !sellOnDEX { + maxBuy, err := a.inputs.maxBuy(dexExtrema) + if err != nil { + a.log.Errorf("maxBuy error: %v") + return noArb() + } + if maxBuy.Swap.Lots < numLots { + a.log.Infof("maxBuy.Swap.Lots %v < numLots %v", maxBuy.Swap.Lots, numLots) + break + } + } + + cexAvg, cexExtrema, cexFilled, err := a.cex.VWAP(dex.BipIDSymbol(a.base), dex.BipIDSymbol(a.quote), sellOnDEX, numLots*lotSize) + if err != nil { + a.log.Errorf("error calculating cex VWAP: %v", err) + return + } + a.log.Tracef("CEX VWAP - avg: %v, extrema: %v, filled: %v", cexAvg, cexExtrema, cexFilled) + if !cexFilled { + return + } + // If buying on cex, make sure we have enough to buy at this rate + amountNeeded := calc.BaseToQuote(cexExtrema, numLots*lotSize) + if sellOnDEX && (amountNeeded > cexQuoteBalance) { + break + } + + var priceRatio float64 + if sellOnDEX { + priceRatio = float64(dexAvg) / float64(cexAvg) + } else { + priceRatio = float64(cexAvg) / float64(dexAvg) + } + + a.log.Tracef("priceRatio - %v", priceRatio) + + // Is there an opportunity? + if priceRatio > (1 + cfg.ProfitTrigger) { + lotsToArb = numLots + dexRate = dexExtrema + cexRate = cexExtrema + } else { + break + } + } + + if lotsToArb > 0 { + return true, lotsToArb, dexRate, cexRate + } + + return noArb() +} + +// executeArb will execute an arbitrage sequence by placing orders on the dex +// and cex. An entry will be added to the a.activeArbs slice if both orders +// are successfully placed. +func (a *arbEngine) executeArb(sellOnDex bool, lotsToArb, dexRate, cexRate, epoch uint64, cfg *ArbEngineCfg) { + a.activeArbsMtx.RLock() + numArbs := len(a.activeArbs) + a.activeArbsMtx.RUnlock() + if numArbs >= int(cfg.MaxActiveArbs) { + a.log.Infof("cannot execute arb because already at max arbs") + return + } + + if a.selfMatch(sellOnDex, dexRate) { + a.log.Infof("cannot execute arb opportunity due to self-match") + return + } + // also check self-match on CEX? + + // Hold the lock for this entire process because updates to the cex trade + // may come even before the Trade function is complete, and in order to + // be able to process them, the new arbSequence struct must already be in + // the activeArbs slice. + a.activeArbsMtx.Lock() + defer a.activeArbsMtx.Unlock() + + // Place cex order first. If placing dex order fails then can freely cancel cex order. + cexTradeID := a.cex.GenerateTradeID() + err := a.cex.Trade(dex.BipIDSymbol(a.base), dex.BipIDSymbol(a.quote), !sellOnDex, cexRate, lotsToArb*a.inputs.lotSize(), a.cexTradeUpdatesID, cexTradeID) + if err != nil { + a.log.Errorf("error placing cex order: %v", err) + return + } + + dexOrderID, err := a.inputs.placeOrder(lotsToArb, dexRate, sellOnDex) + if err != nil { + a.log.Errorf("error placing dex order: %v", err) + + err := a.cex.CancelTrade(dex.BipIDSymbol(a.base), dex.BipIDSymbol(a.quote), cexTradeID) + if err != nil { + a.log.Errorf("error canceling cex order: %v", err) + } + return + } + + a.activeArbs = append(a.activeArbs, &arbSequence{ + dexOrderID: dexOrderID, + dexRate: dexRate, + cexOrderID: cexTradeID, + cexRate: cexRate, + sellOnDEX: sellOnDex, + startEpoch: epoch, + }) +} + +// selfMatch checks if a order will match with any other orders +// already placed on the dex. +func (a *arbEngine) selfMatch(sell bool, rate uint64) bool { + buys, sells := a.inputs.sortedOrders() + + if sell && len(buys) > 0 && buys[0].Rate >= rate { + return true + } + + if !sell && len(sells) > 0 && sells[0].Rate <= rate { + return true + } + + return false +} + +// cancelArbSequence will cancel both the dex and cex orders in an arb sequence +// if they have not yet been filled. +func (a *arbEngine) cancelArbSequence(arb *arbSequence) { + if !arb.cexOrderFilled { + err := a.cex.CancelTrade(dex.BipIDSymbol(a.base), dex.BipIDSymbol(a.quote), arb.cexOrderID) + if err != nil { + a.log.Errorf("failed to cancel cex trade ID %s: %v", arb.cexOrderID, err) + } + } + + if !arb.dexOrderFilled { + err := a.inputs.cancelOrder(arb.dexOrderID) + if err != nil { + a.log.Errorf("failed to cancel dex order ID %s: %v", arb.dexOrderID, err) + } + } + + // keep retrying if failed to cancel? +} + +// handleCEXTradeUpdate is called when the CEX sends a notification that the +// status of a trade has changed. +func (a *arbEngine) handleCEXTradeUpdate(update *libxc.TradeUpdate) { + if !update.Complete { + return + } + + a.activeArbsMtx.Lock() + defer a.activeArbsMtx.Unlock() + + for i, arb := range a.activeArbs { + if arb.cexOrderID == update.TradeID { + arb.cexOrderFilled = true + if arb.dexOrderFilled { + a.removeActiveArb(i) + } + return + } + } +} + +// handleDEXOrderUpdate is called when the DEX sends a notification that the +// status of an order has changed. +func (a *arbEngine) handleDEXOrderUpdate(o *Order) { + if o.Status <= order.OrderStatusBooked { + return + } + + var oid order.OrderID + copy(oid[:], o.ID) + + a.activeArbsMtx.Lock() + defer a.activeArbsMtx.Unlock() + + for i, arb := range a.activeArbs { + if arb.dexOrderID == oid { + arb.dexOrderFilled = true + if arb.cexOrderFilled { + a.removeActiveArb(i) + } + return + } + } +} + +// removeActiveArb removes the active arb at index i. +// +// activeArbsMtx MUST be held when calling this function. +func (a *arbEngine) removeActiveArb(i int) { + a.log.Tracef("removing active arb - %+v", a.activeArbs[i]) + a.activeArbs[i] = a.activeArbs[len(a.activeArbs)-1] + a.activeArbs = a.activeArbs[:len(a.activeArbs)-1] +} diff --git a/client/core/botengine_arb_test.go b/client/core/botengine_arb_test.go new file mode 100644 index 0000000000..9ee0c6d232 --- /dev/null +++ b/client/core/botengine_arb_test.go @@ -0,0 +1,990 @@ +package core + +import ( + "bytes" + "context" + "errors" + "fmt" + "sort" + "strings" + "sync" + "testing" + + "decred.org/dcrdex/client/asset" + "decred.org/dcrdex/client/core/libxc" + "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/calc" + "decred.org/dcrdex/dex/encode" + "decred.org/dcrdex/dex/order" +) + +var log = dex.StdOutLogger("T", dex.LevelTrace) + +type vwapResult struct { + avg uint64 + extrema uint64 +} + +type dexOrder struct { + lots, rate uint64 + sell bool +} + +type tArbEngineInputs struct { + bidsVWAP map[uint64]vwapResult + asksVWAP map[uint64]vwapResult + lotSizeV uint64 + maxSellV *MaxOrderEstimate + maxBuyV *MaxOrderEstimate + buys []*Order + sells []*Order + cancelledOrders []order.OrderID + placeOrderID order.OrderID + lastOrderPlaced *dexOrder + placeOrderErr error + maxSellErr error + maxBuyErr error + vwapErr error +} + +func newTArbEngineInputs() *tArbEngineInputs { + return &tArbEngineInputs{ + bidsVWAP: make(map[uint64]vwapResult), + asksVWAP: make(map[uint64]vwapResult), + cancelledOrders: make([]order.OrderID, 0, 4), + buys: make([]*Order, 0, 4), + sells: make([]*Order, 0, 4), + } +} + +func (i *tArbEngineInputs) vwap(numLots uint64, sell bool) (avgRate uint64, extrema uint64, filled bool, err error) { + if i.vwapErr != nil { + return 0, 0, false, i.vwapErr + } + + if sell { + res, found := i.asksVWAP[numLots] + if !found { + return 0, 0, false, nil + } + return res.avg, res.extrema, true, nil + } + + res, found := i.bidsVWAP[numLots] + if !found { + return 0, 0, false, nil + } + return res.avg, res.extrema, true, nil +} + +func (i *tArbEngineInputs) lotSize() uint64 { + return i.lotSizeV +} +func (i *tArbEngineInputs) maxBuy(rate uint64) (*MaxOrderEstimate, error) { + return i.maxBuyV, i.maxBuyErr +} +func (i *tArbEngineInputs) maxSell() (*MaxOrderEstimate, error) { + return i.maxSellV, i.maxSellErr +} +func (i *tArbEngineInputs) sortedOrders() (buys, sells []*Order) { + return i.buys, i.sells +} +func (i *tArbEngineInputs) placeOrder(lots, rate uint64, sell bool) (order.OrderID, error) { + if i.placeOrderErr != nil { + return order.OrderID{}, i.placeOrderErr + } + i.lastOrderPlaced = &dexOrder{lots, rate, sell} + return i.placeOrderID, nil +} +func (i *tArbEngineInputs) cancelOrder(oid order.OrderID) error { + i.cancelledOrders = append(i.cancelledOrders, oid) + return nil +} + +var _ arbEngineInputs = (*tArbEngineInputs)(nil) + +type cexOrder struct { + baseSymbol, quoteSymbol string + qty, rate uint64 + sell bool +} +type tCEX struct { + bidsVWAP map[uint64]vwapResult + asksVWAP map[uint64]vwapResult + vwapErr error + balances map[string]*libxc.ExchangeBalance + balanceErr error + + tradeID string + tradeErr error + lastTrade *cexOrder + + cancelledTrades []string + cancelTradeErr error + + tradeUpdates <-chan *libxc.TradeUpdate + tradeUpdatesID int +} + +func newTCEX() *tCEX { + return &tCEX{ + bidsVWAP: make(map[uint64]vwapResult), + asksVWAP: make(map[uint64]vwapResult), + balances: make(map[string]*libxc.ExchangeBalance), + cancelledTrades: make([]string, 0), + } +} + +func (c *tCEX) Connect(ctx context.Context) (*sync.WaitGroup, error) { + return nil, nil +} +func (c *tCEX) Balances() (map[uint32]*libxc.ExchangeBalance, error) { + return nil, nil +} +func (c *tCEX) Markets() ([]*libxc.Market, error) { + return nil, nil +} +func (c *tCEX) Balance(symbol string) (*libxc.ExchangeBalance, error) { + return c.balances[symbol], c.balanceErr +} +func (c *tCEX) GenerateTradeID() string { + return c.tradeID +} +func (c *tCEX) Trade(baseSymbol, quoteSymbol string, sell bool, rate, qty uint64, updaterID int, orderID string) error { + if c.tradeErr != nil { + return c.tradeErr + } + c.lastTrade = &cexOrder{baseSymbol, quoteSymbol, qty, rate, sell} + return nil +} +func (c *tCEX) CancelTrade(baseSymbol, quoteSymbol, tradeID string) error { + if c.cancelTradeErr != nil { + return c.cancelTradeErr + } + c.cancelledTrades = append(c.cancelledTrades, tradeID) + return nil +} +func (c *tCEX) SubscribeMarket(baseSymbol, quoteSymbol string) error { + return nil +} +func (c *tCEX) UnsubscribeMarket(baseSymbol, quoteSymbol string) error { + return nil +} +func (c *tCEX) VWAP(baseSymbol, quoteSymbol string, sell bool, qty uint64) (vwap, extrema uint64, filled bool, err error) { + if c.vwapErr != nil { + return 0, 0, false, c.vwapErr + } + + if sell { + res, found := c.asksVWAP[qty] + if !found { + return 0, 0, false, nil + } + return res.avg, res.extrema, true, nil + } + + res, found := c.bidsVWAP[qty] + if !found { + return 0, 0, false, nil + } + return res.avg, res.extrema, true, nil +} +func (c *tCEX) SubscribeTradeUpdates() (<-chan *libxc.TradeUpdate, int) { + return c.tradeUpdates, c.tradeUpdatesID +} +func (c *tCEX) SubscribeCEXUpdates() <-chan interface{} { + return nil +} + +var _ libxc.CEX = (*tCEX)(nil) + +func TestArbRebalance(t *testing.T) { + lotSize := uint64(40 * 1e8) + + orderIDs := make([]order.OrderID, 5) + for i := 0; i < 5; i++ { + copy(orderIDs[i][:], encode.RandomBytes(32)) + } + + cexTradeIDs := make([]string, 0, 5) + for i := 0; i < 5; i++ { + cexTradeIDs = append(cexTradeIDs, fmt.Sprintf("%x", encode.RandomBytes(32))) + } + + var currEpoch uint64 = 100 + var numEpochsLeaveOpen uint32 = 10 + + type testBooks struct { + dexBidsAvg []uint64 + dexBidsExtrema []uint64 + + dexAsksAvg []uint64 + dexAsksExtrema []uint64 + + cexBidsAvg []uint64 + cexBidsExtrema []uint64 + + cexAsksAvg []uint64 + cexAsksExtrema []uint64 + } + + noArbBooks := &testBooks{ + dexBidsAvg: []uint64{1.8e6, 1.7e6}, + dexBidsExtrema: []uint64{1.7e6, 1.6e6}, + + dexAsksAvg: []uint64{2e6, 2.5e6}, + dexAsksExtrema: []uint64{2e6, 3e6}, + + cexBidsAvg: []uint64{1.9e6, 1.8e6}, + cexBidsExtrema: []uint64{1.85e6, 1.75e6}, + + cexAsksAvg: []uint64{2.1e6, 2.2e6}, + cexAsksExtrema: []uint64{2.2e6, 2.3e6}, + } + + arbBuyOnDEXBooks := &testBooks{ + dexBidsAvg: []uint64{1.8e6, 1.7e6}, + dexBidsExtrema: []uint64{1.7e6, 1.6e6}, + + dexAsksAvg: []uint64{2e6, 2.5e6}, + dexAsksExtrema: []uint64{2e6, 3e6}, + + cexBidsAvg: []uint64{2.3e6, 2.1e6}, + cexBidsExtrema: []uint64{2.2e6, 1.9e6}, + + cexAsksAvg: []uint64{2.4e6, 2.6e6}, + cexAsksExtrema: []uint64{2.5e6, 2.7e6}, + } + + arbSellOnDEXBooks := &testBooks{ + cexBidsAvg: []uint64{1.8e6, 1.7e6}, + cexBidsExtrema: []uint64{1.7e6, 1.6e6}, + + cexAsksAvg: []uint64{2e6, 2.5e6}, + cexAsksExtrema: []uint64{2e6, 3e6}, + + dexBidsAvg: []uint64{2.3e6, 2.1e6}, + dexBidsExtrema: []uint64{2.2e6, 1.9e6}, + + dexAsksAvg: []uint64{2.4e6, 2.6e6}, + dexAsksExtrema: []uint64{2.5e6, 2.7e6}, + } + + arb2LotsBuyOnDEXBooks := &testBooks{ + dexBidsAvg: []uint64{1.8e6, 1.7e6}, + dexBidsExtrema: []uint64{1.7e6, 1.6e6}, + + dexAsksAvg: []uint64{2e6, 2e6, 2.5e6}, + dexAsksExtrema: []uint64{2e6, 2e6, 3e6}, + + cexBidsAvg: []uint64{2.3e6, 2.2e6, 2.1e6}, + cexBidsExtrema: []uint64{2.2e6, 2.2e6, 1.9e6}, + + cexAsksAvg: []uint64{2.4e6, 2.6e6}, + cexAsksExtrema: []uint64{2.5e6, 2.7e6}, + } + + arb2LotsSellOnDEXBooks := &testBooks{ + cexBidsAvg: []uint64{1.8e6, 1.7e6}, + cexBidsExtrema: []uint64{1.7e6, 1.6e6}, + + cexAsksAvg: []uint64{2e6, 2e6, 2.5e6}, + cexAsksExtrema: []uint64{2e6, 2e6, 3e6}, + + dexBidsAvg: []uint64{2.3e6, 2.2e6, 2.1e6}, + dexBidsExtrema: []uint64{2.2e6, 2.2e6, 1.9e6}, + + dexAsksAvg: []uint64{2.4e6, 2.6e6}, + dexAsksExtrema: []uint64{2.5e6, 2.7e6}, + } + + type test struct { + name string + books *testBooks + dexMaxSell *MaxOrderEstimate + dexMaxBuy *MaxOrderEstimate + cexBalances map[string]*libxc.ExchangeBalance + existingBuys []*Order + existingSells []*Order + activeArbs []*arbSequence + cexTradeErr error + dexPlaceOrderErr error + cexTradeID string + dexMaxSellErr error + dexMaxBuyErr error + dexVWAPErr error + cexVWAPErr error + + expectedDexOrder *dexOrder + expectedCexOrder *cexOrder + expectedCEXCancels []string + expectedDEXCancels []order.OrderID + } + + tests := []test{ + { + name: "no arb", + books: noArbBooks, + dexMaxSell: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexMaxBuy: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + cexBalances: map[string]*libxc.ExchangeBalance{ + "btc": {Available: 1e19}, + "dcr": {Available: 1e19}, + }, + }, + { + name: "1 lot, buy on dex, sell on cex", + books: arbBuyOnDEXBooks, + dexMaxSell: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexMaxBuy: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + cexBalances: map[string]*libxc.ExchangeBalance{ + "btc": {Available: 1e19}, + "dcr": {Available: 1e19}, + }, + expectedDexOrder: &dexOrder{ + lots: 1, + rate: 2e6, + sell: false, + }, + expectedCexOrder: &cexOrder{ + baseSymbol: "dcr", + quoteSymbol: "btc", + qty: lotSize, + rate: 2.2e6, + sell: true, + }, + }, + { + name: "1 lot, buy on dex, sell on cex, but cex base balance not enough", + books: arbBuyOnDEXBooks, + dexMaxSell: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexMaxBuy: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + cexBalances: map[string]*libxc.ExchangeBalance{ + "btc": {Available: 1e19}, + "dcr": {Available: lotSize / 2}, + }, + }, + { + name: "2 lot, buy on dex, sell on cex, but dex quote balance only enough for 1", + books: arb2LotsBuyOnDEXBooks, + dexMaxSell: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexMaxBuy: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 1, + }, + }, + + cexBalances: map[string]*libxc.ExchangeBalance{ + "btc": {Available: 1e19}, + "dcr": {Available: 1e19}, + }, + + expectedDexOrder: &dexOrder{ + lots: 1, + rate: 2e6, + sell: false, + }, + expectedCexOrder: &cexOrder{ + baseSymbol: "dcr", + quoteSymbol: "btc", + qty: lotSize, + rate: 2.2e6, + sell: true, + }, + }, + + { + name: "1 lot, sell on dex, buy on cex", + books: arbSellOnDEXBooks, + dexMaxSell: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexMaxBuy: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + cexBalances: map[string]*libxc.ExchangeBalance{ + "btc": {Available: 1e19}, + "dcr": {Available: 1e19}, + }, + expectedDexOrder: &dexOrder{ + lots: 1, + rate: 2.2e6, + sell: true, + }, + expectedCexOrder: &cexOrder{ + baseSymbol: "dcr", + quoteSymbol: "btc", + qty: lotSize, + rate: 2e6, + sell: false, + }, + }, + { + name: "2 lot, buy on cex, sell on dex, but cex quote balance only enough for 1", + books: arb2LotsSellOnDEXBooks, + dexMaxSell: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexMaxBuy: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + cexBalances: map[string]*libxc.ExchangeBalance{ + "btc": {Available: calc.BaseToQuote(2e6, lotSize*3/2)}, + "dcr": {Available: 1e19}, + }, + expectedDexOrder: &dexOrder{ + lots: 1, + rate: 2.2e6, + sell: true, + }, + expectedCexOrder: &cexOrder{ + baseSymbol: "dcr", + quoteSymbol: "btc", + qty: lotSize, + rate: 2e6, + sell: false, + }, + }, + + { + name: "self-match", + books: arbSellOnDEXBooks, + dexMaxSell: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexMaxBuy: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + + cexBalances: map[string]*libxc.ExchangeBalance{ + "btc": {Available: 1e19}, + "dcr": {Available: 1e19}, + }, + + existingBuys: []*Order{{Rate: 2.2e6}}, + activeArbs: []*arbSequence{{ + dexOrderID: orderIDs[0], + cexOrderID: cexTradeIDs[0], + sellOnDEX: false, + startEpoch: currEpoch - 2, + }}, + + expectedCEXCancels: []string{cexTradeIDs[0]}, + expectedDEXCancels: []order.OrderID{orderIDs[0]}, + }, + { + name: "remove expired active arbs", + books: noArbBooks, + dexMaxSell: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexMaxBuy: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + activeArbs: []*arbSequence{ + { + dexOrderID: orderIDs[0], + cexOrderID: cexTradeIDs[0], + sellOnDEX: false, + startEpoch: currEpoch - 2, + }, + { + dexOrderID: orderIDs[1], + cexOrderID: cexTradeIDs[1], + sellOnDEX: false, + startEpoch: currEpoch - (uint64(numEpochsLeaveOpen) + 2), + }, + { + dexOrderID: orderIDs[2], + cexOrderID: cexTradeIDs[2], + sellOnDEX: false, + cexOrderFilled: true, + startEpoch: currEpoch - (uint64(numEpochsLeaveOpen) + 2), + }, + { + dexOrderID: orderIDs[3], + cexOrderID: cexTradeIDs[3], + sellOnDEX: false, + dexOrderFilled: true, + startEpoch: currEpoch - (uint64(numEpochsLeaveOpen) + 2), + }, + }, + expectedCEXCancels: []string{cexTradeIDs[1], cexTradeIDs[3]}, + expectedDEXCancels: []order.OrderID{orderIDs[1], orderIDs[2]}, + cexBalances: map[string]*libxc.ExchangeBalance{ + "btc": {Available: 1e19}, + "dcr": {Available: 1e19}, + }, + }, + { + name: "already max active arbs", + books: arbBuyOnDEXBooks, + dexMaxSell: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexMaxBuy: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + cexBalances: map[string]*libxc.ExchangeBalance{ + "btc": {Available: 1e19}, + "dcr": {Available: 1e19}, + }, + activeArbs: []*arbSequence{ + { + dexOrderID: orderIDs[0], + cexOrderID: cexTradeIDs[0], + sellOnDEX: false, + startEpoch: currEpoch - 1, + }, + { + dexOrderID: orderIDs[1], + cexOrderID: cexTradeIDs[2], + sellOnDEX: false, + startEpoch: currEpoch - 2, + }, + { + dexOrderID: orderIDs[2], + cexOrderID: cexTradeIDs[2], + sellOnDEX: false, + startEpoch: currEpoch - 3, + }, + { + dexOrderID: orderIDs[3], + cexOrderID: cexTradeIDs[3], + sellOnDEX: false, + startEpoch: currEpoch - 4, + }, + { + dexOrderID: orderIDs[4], + cexOrderID: cexTradeIDs[4], + sellOnDEX: false, + startEpoch: currEpoch - 5, + }, + }, + }, + { + name: "cex no asks", + books: &testBooks{ + dexBidsAvg: []uint64{1.8e6, 1.7e6}, + dexBidsExtrema: []uint64{1.7e6, 1.6e6}, + + dexAsksAvg: []uint64{2e6, 2.5e6}, + dexAsksExtrema: []uint64{2e6, 3e6}, + + cexBidsAvg: []uint64{1.9e6, 1.8e6}, + cexBidsExtrema: []uint64{1.85e6, 1.75e6}, + + cexAsksAvg: []uint64{}, + cexAsksExtrema: []uint64{}, + }, + dexMaxSell: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexMaxBuy: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + + cexBalances: map[string]*libxc.ExchangeBalance{ + "btc": {Available: 1e19}, + "dcr": {Available: 1e19}, + }, + }, + { + name: "dex no asks", + books: &testBooks{ + dexBidsAvg: []uint64{1.8e6, 1.7e6}, + dexBidsExtrema: []uint64{1.7e6, 1.6e6}, + + dexAsksAvg: []uint64{}, + dexAsksExtrema: []uint64{}, + + cexBidsAvg: []uint64{1.9e6, 1.8e6}, + cexBidsExtrema: []uint64{1.85e6, 1.75e6}, + + cexAsksAvg: []uint64{2.1e6, 2.2e6}, + cexAsksExtrema: []uint64{2.2e6, 2.3e6}, + }, + dexMaxSell: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexMaxBuy: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + + cexBalances: map[string]*libxc.ExchangeBalance{ + "btc": {Available: 1e19}, + "dcr": {Available: 1e19}, + }, + }, + { + name: "cex trade error", + books: arbBuyOnDEXBooks, + dexMaxSell: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexMaxBuy: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + cexBalances: map[string]*libxc.ExchangeBalance{ + "btc": {Available: 1e19}, + "dcr": {Available: 1e19}, + }, + cexTradeErr: errors.New(""), + }, + { + name: "dex place order error", + books: arbBuyOnDEXBooks, + dexMaxSell: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexMaxBuy: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + cexBalances: map[string]*libxc.ExchangeBalance{ + "btc": {Available: 1e19}, + "dcr": {Available: 1e19}, + }, + expectedCexOrder: &cexOrder{ + baseSymbol: "dcr", + quoteSymbol: "btc", + qty: lotSize, + rate: 2.2e6, + sell: true, + }, + cexTradeID: cexTradeIDs[1], + expectedCEXCancels: []string{cexTradeIDs[1]}, + dexPlaceOrderErr: errors.New(""), + }, + { + name: "dex max sell error", + books: arbSellOnDEXBooks, + dexMaxBuy: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + cexBalances: map[string]*libxc.ExchangeBalance{ + "btc": {Available: 1e19}, + "dcr": {Available: 1e19}, + }, + dexMaxSellErr: errors.New(""), + }, + { + name: "dex max buy error", + books: arbBuyOnDEXBooks, + dexMaxSell: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + cexBalances: map[string]*libxc.ExchangeBalance{ + "btc": {Available: 1e19}, + "dcr": {Available: 1e19}, + }, + dexMaxBuyErr: errors.New(""), + }, + { + name: "dex vwap error", + books: arbBuyOnDEXBooks, + dexMaxBuy: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexMaxSell: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + cexBalances: map[string]*libxc.ExchangeBalance{ + "btc": {Available: 1e19}, + "dcr": {Available: 1e19}, + }, + dexVWAPErr: errors.New(""), + }, + { + name: "cex vwap error", + books: arbBuyOnDEXBooks, + dexMaxBuy: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + dexMaxSell: &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + }, + }, + cexBalances: map[string]*libxc.ExchangeBalance{ + "btc": {Available: 1e19}, + "dcr": {Available: 1e19}, + }, + cexVWAPErr: errors.New(""), + }, + } + + for _, test := range tests { + inputs := newTArbEngineInputs() + cex := newTCEX() + arbEngine := &arbEngine{ + inputs: inputs, + log: log, + cex: cex, + base: 42, + quote: 0, + activeArbs: test.activeArbs, + } + arbEngine.cfgV.Store(&ArbEngineCfg{ + ProfitTrigger: 0.01, + MaxActiveArbs: 5, + NumEpochsLeaveOpen: numEpochsLeaveOpen, + }) + + for i := range test.books.dexBidsAvg { + inputs.bidsVWAP[uint64(i+1)] = vwapResult{test.books.dexBidsAvg[i], test.books.dexBidsExtrema[i]} + } + for i := range test.books.dexAsksAvg { + inputs.asksVWAP[uint64(i+1)] = vwapResult{test.books.dexAsksAvg[i], test.books.dexAsksExtrema[i]} + } + for i := range test.books.cexBidsAvg { + cex.bidsVWAP[uint64(i+1)*lotSize] = vwapResult{test.books.cexBidsAvg[i], test.books.cexBidsExtrema[i]} + } + for i := range test.books.cexAsksAvg { + cex.asksVWAP[uint64(i+1)*lotSize] = vwapResult{test.books.cexAsksAvg[i], test.books.cexAsksExtrema[i]} + } + + inputs.buys = test.existingBuys + inputs.sells = test.existingSells + inputs.lotSizeV = lotSize + inputs.maxSellV = test.dexMaxSell + inputs.maxBuyV = test.dexMaxBuy + inputs.placeOrderErr = test.dexPlaceOrderErr + inputs.maxBuyErr = test.dexMaxBuyErr + inputs.maxSellErr = test.dexMaxSellErr + inputs.vwapErr = test.dexVWAPErr + + cex.balances = test.cexBalances + cex.tradeID = test.cexTradeID + cex.tradeErr = test.cexTradeErr + cex.vwapErr = test.cexVWAPErr + + arbEngine.rebalance(currEpoch) + + // Check dex trade + if (test.expectedDexOrder == nil) != (inputs.lastOrderPlaced == nil) { + t.Fatalf("%s: expected dex order %v but got %v", test.name, (test.expectedDexOrder != nil), (inputs.lastOrderPlaced != nil)) + } + if inputs.lastOrderPlaced != nil && + *inputs.lastOrderPlaced != *test.expectedDexOrder { + t.Fatalf("%s: dex order %+v != expected %+v", test.name, inputs.lastOrderPlaced, test.expectedDexOrder) + } + + // Check cex trade + if (test.expectedCexOrder == nil) != (cex.lastTrade == nil) { + t.Fatalf("%s: expected cex order %v but got %v", test.name, (test.expectedCexOrder != nil), (cex.lastTrade != nil)) + } + if cex.lastTrade != nil && + *cex.lastTrade != *test.expectedCexOrder { + t.Fatalf("%s: cex order %+v != expected %+v", test.name, cex.lastTrade, test.expectedCexOrder) + } + + // Check dex cancels + if len(inputs.cancelledOrders) != len(test.expectedDEXCancels) { + t.Fatalf("%s expected %d dex cancels but got %d", test.name, len(test.expectedDEXCancels), len(inputs.cancelledOrders)) + } + sort.Slice(inputs.cancelledOrders, func(i, j int) bool { + return bytes.Compare(inputs.cancelledOrders[i][:], inputs.cancelledOrders[j][:]) < 0 + }) + sort.Slice(test.expectedDEXCancels, func(i, j int) bool { + return bytes.Compare(test.expectedDEXCancels[i][:], test.expectedDEXCancels[j][:]) < 0 + }) + for i := range test.expectedDEXCancels { + if !bytes.Equal(test.expectedDEXCancels[i][:], inputs.cancelledOrders[i][:]) { + fmt.Printf("expected: %+v\n actual: %+v\n", test.expectedDEXCancels, inputs.cancelledOrders) + t.Fatalf("%s: dex cancelled order %s != expected %s", test.name, inputs.cancelledOrders[i], test.expectedDEXCancels[i]) + } + } + + // Check cex cancels + if len(cex.cancelledTrades) != len(test.expectedCEXCancels) { + t.Fatalf("%s expected %d cex cancels but got %d", test.name, len(test.expectedCEXCancels), len(cex.cancelledTrades)) + } + sort.Slice(test.expectedCEXCancels, func(i, j int) bool { + return strings.Compare(test.expectedCEXCancels[i][:], test.expectedCEXCancels[j][:]) < 0 + }) + sort.Slice(cex.cancelledTrades, func(i, j int) bool { + return strings.Compare(cex.cancelledTrades[i][:], cex.cancelledTrades[j][:]) < 0 + }) + for i := range cex.cancelledTrades { + if test.expectedCEXCancels[i][:] != cex.cancelledTrades[i][:] { + t.Fatalf("%s: cex cancelled order %s != expected %s", test.name, cex.cancelledTrades[i], test.expectedCEXCancels[i]) + } + } + } +} + +func TestArbEngineDEXOrderUpdate(t *testing.T) { + orderIDs := make([]order.OrderID, 5) + for i := 0; i < 5; i++ { + copy(orderIDs[i][:], encode.RandomBytes(32)) + } + + cexTradeIDs := make([]string, 0, 5) + for i := 0; i < 5; i++ { + cexTradeIDs = append(cexTradeIDs, fmt.Sprintf("%x", encode.RandomBytes(32))) + } + + tests := []struct { + name string + activeArbs []*arbSequence + updatedOrderID []byte + updatedOrderStatus order.OrderStatus + expectedActiveArbs []*arbSequence + }{ + { + name: "dex order still booked", + activeArbs: []*arbSequence{ + { + dexOrderID: orderIDs[0], + cexOrderID: cexTradeIDs[0], + }, + }, + updatedOrderID: orderIDs[0][:], + updatedOrderStatus: order.OrderStatusBooked, + expectedActiveArbs: []*arbSequence{ + { + dexOrderID: orderIDs[0], + cexOrderID: cexTradeIDs[0], + }, + }, + }, + { + name: "dex order executed, but cex not yet filled", + activeArbs: []*arbSequence{ + { + dexOrderID: orderIDs[0], + cexOrderID: cexTradeIDs[0], + }, + }, + updatedOrderID: orderIDs[0][:], + updatedOrderStatus: order.OrderStatusExecuted, + expectedActiveArbs: []*arbSequence{ + { + dexOrderID: orderIDs[0], + cexOrderID: cexTradeIDs[0], + dexOrderFilled: true, + }, + }, + }, + { + name: "dex order executed, but cex already filled", + activeArbs: []*arbSequence{ + { + dexOrderID: orderIDs[0], + cexOrderID: cexTradeIDs[0], + cexOrderFilled: true, + }, + }, + updatedOrderID: orderIDs[0][:], + updatedOrderStatus: order.OrderStatusExecuted, + expectedActiveArbs: []*arbSequence{}, + }, + } + + for _, test := range tests { + inputs := newTArbEngineInputs() + cex := newTCEX() + arbEngine := &arbEngine{ + inputs: inputs, + log: log, + cex: cex, + base: 42, + quote: 0, + activeArbs: test.activeArbs, + } + arbEngine.cfgV.Store(&ArbEngineCfg{ + ProfitTrigger: 0.01, + MaxActiveArbs: 5, + NumEpochsLeaveOpen: 10, + }) + + arbEngine.notify(orderUpdateEngineNote(&Order{ + Status: test.updatedOrderStatus, + ID: test.updatedOrderID, + })) + + if len(test.expectedActiveArbs) != len(arbEngine.activeArbs) { + t.Fatalf("%s: expected %d active arbs but got %d", test.name, len(test.expectedActiveArbs), len(arbEngine.activeArbs)) + } + + for i := range test.expectedActiveArbs { + if *arbEngine.activeArbs[i] != *test.expectedActiveArbs[i] { + t.Fatalf("%s: active arb %+v != expected active arb %+v", test.name, arbEngine.activeArbs[i], test.expectedActiveArbs[i]) + } + } + } +} diff --git a/client/core/botengine_gap.go b/client/core/botengine_gap.go new file mode 100644 index 0000000000..5fe6781694 --- /dev/null +++ b/client/core/botengine_gap.go @@ -0,0 +1,444 @@ +package core + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "sync" + "sync/atomic" + + "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/order" +) + +// gapEngineInputs are the input functions required a GapEngine to function. +type gapEngineInputs interface { + basisPrice(oracleBias, oracleWeighting, emptyMarketRate float64) uint64 + halfSpread(basisPrice uint64) (uint64, error) + sortedOrders() (buys, sells []*Order) + maxBuy(rate uint64) (*MaxOrderEstimate, error) + maxSell() (*MaxOrderEstimate, error) + conventionalRateToMsg(g float64) uint64 + rateStep() uint64 + placeOrder(lots, rate uint64, sell bool) (order.OrderID, error) + cancelOrder(oid order.OrderID) error + lotSize() uint64 +} + +// GapStrategy is a specifier for an algorithm to choose the maker bot's target +// spread. +type GapStrategy string + +const ( + // GapStrategyMultiplier calculates the spread by multiplying the + // break-even gap by the specified multiplier, 1 <= r <= 100. + GapStrategyMultiplier GapStrategy = "multiplier" + // GapStrategyAbsolute sets the spread to the rate difference. + GapStrategyAbsolute GapStrategy = "absolute" + // GapStrategyAbsolutePlus sets the spread to the rate difference plus the + // break-even gap. + GapStrategyAbsolutePlus GapStrategy = "absolute-plus" + // GapStrategyPercent sets the spread as a ratio of the mid-gap rate. + // 0 <= r <= 0.1 + GapStrategyPercent GapStrategy = "percent" + // GapStrategyPercentPlus sets the spread as a ratio of the mid-gap rate + // plus the break-even gap. + GapStrategyPercentPlus GapStrategy = "percent-plus" +) + +// GapEngineCfg is the configuration for a GapEngine. +type GapEngineCfg struct { + // Lots is the number of lots to allocate to each side of the market. This + // is an ideal allotment, but at any given time, a side could have up to + // 2 * Lots on order. + Lots uint64 `json:"lots"` + + // GapStrategy selects an algorithm for calculating the target spread. + GapStrategy GapStrategy `json:"gapStrategy"` + + // GapFactor controls the gap width in a way determined by the GapStrategy. + GapFactor float64 `json:"gapFactor"` + + // DriftTolerance is how far away from an ideal price an order can drift + // before it will replaced (units: ratio of price). Default: 0.1%. + // 0 <= x <= 0.01. + DriftTolerance float64 `json:"driftTolerance"` + + // OracleWeighting affects how the target price is derived based on external + // market data. OracleWeighting, r, determines the target price with the + // formula: + // target_price = dex_mid_gap_price * (1 - r) + oracle_price * r + // OracleWeighting is limited to 0 <= x <= 1.0. + // Fetching of price data is disabled if OracleWeighting = 0. + OracleWeighting float64 `json:"oracleWeighting"` + + // OracleBias applies a bias in the positive (higher price) or negative + // (lower price) direction. -0.05 <= x <= 0.05. + OracleBias float64 `json:"oracleBias"` + + // EmptyMarketRate can be set if there is no market data available, and is + // ignored if there is market data available. + EmptyMarketRate float64 `json:"manualRate"` +} + +// Validate checks that the configurations are valid. +func (cfg *GapEngineCfg) validate() error { + if cfg.Lots == 0 { + return errors.New("cannot run with lots = 0") + } + if cfg.OracleBias < -0.05 || cfg.OracleBias > 0.05 { + return fmt.Errorf("bias %f out of bounds", cfg.OracleBias) + } + if cfg.OracleWeighting < 0 || cfg.OracleWeighting > 1 { + return fmt.Errorf("oracle weighting %f out of bounds", cfg.OracleWeighting) + } + if cfg.DriftTolerance == 0 { + cfg.DriftTolerance = 0.001 + } + if cfg.DriftTolerance < 0 || cfg.DriftTolerance > 0.01 { + return fmt.Errorf("drift tolerance %f out of bounds", cfg.DriftTolerance) + } + + var limits [2]float64 + switch cfg.GapStrategy { + case GapStrategyMultiplier: + limits = [2]float64{1, 100} + case GapStrategyPercent, GapStrategyPercentPlus: + limits = [2]float64{0, 0.1} + case GapStrategyAbsolute, GapStrategyAbsolutePlus: + limits = [2]float64{0, math.MaxFloat64} // validate at < spot price at creation time + default: + return fmt.Errorf("unknown gap strategy %q", cfg.GapStrategy) + } + + if cfg.GapFactor < limits[0] || cfg.GapFactor > limits[1] { + return fmt.Errorf("%s gap factor %f is out of bounds %+v", cfg.GapStrategy, cfg.GapFactor, limits) + } + return nil +} + +// gapEngine is a botEngine that places orders at a certain distance above and +// below a market's "basis price". +// Given an order for L lots, every epoch the makerBot will... +// 1. Calculate a "basis price", which is based on DEX market data, +// optionally mixed (OracleWeight) with external market data. +// 2. Calculate a "break-even spread". This is the spread at which tx fee +// losses exactly match profits. +// 3. The break-even spread serves as a hard minimum, and is used to determine +// the target spread based on the specified gap strategy, giving the target +// buy and sell prices. +// 4. Scan existing orders to determine if their prices are still valid, +// within DriftTolerance of the buy or sell price. If not, schedule them +// for cancellation. +// 5. Calculate how many lots are needed to be ordered in order to meet the +// 2 x L commitment. If low balance restricts the maintenance of L lots on +// one side, allow the difference in lots to be added to the opposite side. +// 6. Place orders, cancels first, then buys and sells. +type gapEngine struct { + inputs gapEngineInputs + log dex.Logger + cfgV atomic.Value + ctx context.Context + + rebalanceRunning uint32 // atomic +} + +var _ botEngine = (*gapEngine)(nil) + +func newGapEngine(inputs gapEngineInputs, cfg *GapEngineCfg, log dex.Logger) (botEngine, error) { + err := cfg.validate() + if err != nil { + return nil, err + } + + g := &gapEngine{ + inputs: inputs, + log: log, + } + g.cfgV.Store(cfg) + + return (botEngine)(g), nil +} + +func (g *gapEngine) cfg() *GapEngineCfg { + return g.cfgV.Load().(*GapEngineCfg) +} + +func (g *gapEngine) run(ctx context.Context) (*sync.WaitGroup, error) { + g.ctx = ctx + return new(sync.WaitGroup), nil +} + +func (g *gapEngine) stop() {} + +// update updates the gapEngine's configuration. +func (g *gapEngine) update(cfgB []byte) error { + cfg := new(GapEngineCfg) + + err := json.Unmarshal(cfgB, &cfg) + if err != nil { + return fmt.Errorf("failed to unmarshal gap engine config: %w", err) + } + + err = cfg.validate() + if err != nil { + return fmt.Errorf("failed to validate gap engine config: %w", err) + } + + g.cfgV.Store(cfg) + + return nil +} + +// notify is called to let the engine know that the state of the market has +// changed. +func (g *gapEngine) notify(note interface{}) { + newEpochNote, is := note.(newEpochEngineNote) + if !is { + return + } + g.rebalance(uint64(newEpochNote)) +} + +// initialLotsRequired returns the total amount of lots of balance that is +// required to create a bot with this GapEngine. +func (g *gapEngine) initialLotsRequired() uint64 { + return 2 * g.cfg().Lots +} + +// rebalance rebalances the bot's orders. +// 1. Generate a basis price, p, adjusted for oracle weighting and bias. +// 2. Apply the gap strategy to get a target spread, s. +// 3. Check existing orders, if out of bounds +// [p +/- (s/2) - drift_tolerance, p +/- (s/2) + drift_tolerance], +// cancel the order +// 4. Compare remaining order counts to configured, lots, and place new +// orders. +func (g *gapEngine) rebalance(newEpoch uint64) { + if !atomic.CompareAndSwapUint32(&g.rebalanceRunning, 0, 1) { + return + } + defer atomic.StoreUint32(&g.rebalanceRunning, 0) + + cfg := g.cfg() + + basisPrice := g.inputs.basisPrice(cfg.OracleBias, cfg.OracleWeighting, cfg.EmptyMarketRate) + if basisPrice == 0 { + g.log.Errorf("No basis price available and no empty-market rate set") + return + } + + g.log.Tracef("rebalance: basis price = %d", basisPrice) + + // Three of the strategies will use a break-even half-gap. + var breakEven uint64 + switch cfg.GapStrategy { + case GapStrategyAbsolutePlus, GapStrategyPercentPlus, GapStrategyMultiplier: + var err error + breakEven, err = g.inputs.halfSpread(basisPrice) + if err != nil { + g.log.Errorf("Could not calculate break-even spread: %v", err) + return + } + } + + // Apply the base strategy. + var halfSpread uint64 + switch cfg.GapStrategy { + case GapStrategyMultiplier: + halfSpread = uint64(math.Round(float64(breakEven) * cfg.GapFactor)) + case GapStrategyPercent, GapStrategyPercentPlus: + halfSpread = uint64(math.Round(cfg.GapFactor * float64(basisPrice))) + case GapStrategyAbsolute, GapStrategyAbsolutePlus: + halfSpread = g.inputs.conventionalRateToMsg(cfg.GapFactor) + } + + // Add the break-even to the "-plus" strategies. + switch cfg.GapStrategy { + case GapStrategyAbsolutePlus, GapStrategyPercentPlus: + halfSpread += breakEven + } + + g.log.Tracef("rebalance: strategized half-spread = %d, strategy = %s", halfSpread, cfg.GapStrategy) + + halfSpread = steppedRate(halfSpread, g.inputs.rateStep()) + + g.log.Tracef("rebalance: step-resolved half-spread = %d", halfSpread) + + buyPrice := basisPrice - halfSpread + sellPrice := basisPrice + halfSpread + + g.log.Tracef("rebalance: buy price = %d, sell price = %d", buyPrice, sellPrice) + + buys, sells := g.inputs.sortedOrders() + + // Figure out the best existing sell and buy of existing monitored orders. + // These values are used to cancel order placement if there is a chance + // of self-matching, especially against a scheduled cancel order. + highestBuy, lowestSell := buyPrice, sellPrice + if len(sells) > 0 { + ord := sells[0] + if ord.Rate < lowestSell { + lowestSell = ord.Rate + } + } + if len(buys) > 0 { + ord := buys[0] + if ord.Rate > highestBuy { + highestBuy = ord.Rate + } + } + + // Check if order-placement might self-match. + var cantBuy, cantSell bool + if buyPrice >= lowestSell { + g.log.Tracef("rebalance: can't buy because delayed cancel sell order interferes. booked rate = %d, buy price = %d", + lowestSell, buyPrice) + cantBuy = true + } + if sellPrice <= highestBuy { + g.log.Tracef("rebalance: can't sell because delayed cancel sell order interferes. booked rate = %d, sell price = %d", + highestBuy, sellPrice) + cantSell = true + } + + var canceledBuyLots, canceledSellLots uint64 // for stats reporting + cancels := make([]*Order, 0) + addCancel := func(ord *Order) { + if newEpoch-ord.Epoch < 2 { + g.log.Tracef("rebalance: skipping cancel not past free cancel threshold") + } + + lotsRemaining := (ord.Qty - ord.Filled) / g.inputs.lotSize() + if ord.Sell { + canceledSellLots += lotsRemaining + } else { + canceledBuyLots += lotsRemaining + } + if ord.Status <= order.OrderStatusBooked { + cancels = append(cancels, ord) + } + } + + processSide := func(ords []*Order, price uint64, sell bool) (keptLots int) { + tol := uint64(math.Round(float64(price) * cfg.DriftTolerance)) + low, high := price-tol, price+tol + + // Limit large drift tolerances to their respective sides, i.e. mid-gap + // is a hard cutoff. + if !sell && high > basisPrice { + high = basisPrice - 1 + } + if sell && low < basisPrice { + low = basisPrice + 1 + } + + for _, ord := range ords { + lotsRemaining := (ord.Qty - ord.Filled) / g.inputs.lotSize() + g.log.Tracef("rebalance: processSide: sell = %t, order rate = %d, low = %d, high = %d", + sell, ord.Rate, low, high) + if ord.Rate < low || ord.Rate > high { + if newEpoch < ord.Epoch+2 { // https://github.com/decred/dcrdex/pull/1682 + g.log.Tracef("rebalance: postponing cancellation for order < 2 epochs old") + keptLots += int(lotsRemaining) + } else { + g.log.Tracef("rebalance: cancelling out-of-bounds order (%d lots remaining). rate %d is not in range %d < r < %d", + lotsRemaining, ord.Rate, low, high) + addCancel(ord) + } + } else { + keptLots += int(lotsRemaining) + } + } + return + } + + newBuyLots, newSellLots := int(cfg.Lots), int(cfg.Lots) + keptBuys := processSide(buys, buyPrice, false) + keptSells := processSide(sells, sellPrice, true) + newBuyLots -= keptBuys + newSellLots -= keptSells + + // Cancel out of bounds or over-stacked orders. + if len(cancels) > 0 { + g.log.Tracef("rebalance: cancelling %d orders", len(cancels)) + for _, cancel := range cancels { + var cancelID order.OrderID + copy(cancelID[:], cancel.ID) + err := g.inputs.cancelOrder(cancelID) + if err != nil { + g.log.Errorf("failed to cancel order: %v", err) + } + } + } + + if cantBuy { + newBuyLots = 0 + } + if cantSell { + newSellLots = 0 + } + + g.log.Tracef("rebalance: %d buy lots and %d sell lots scheduled after existing valid %d buy and %d sell lots accounted", + newBuyLots, newSellLots, keptBuys, keptSells) + + // Resolve requested lots against the current balance. If we come up short, + // we may be able to place extra orders on the other side to satisfy our + // lot commitment and shift our balance back. + var maxBuyLots int + if newBuyLots > 0 { + // TODO: MaxBuy and MaxSell shouldn't error for insufficient funds, but + // they do. Maybe consider a constant error asset.InsufficientBalance. + maxOrder, err := g.inputs.maxBuy(buyPrice) + if err != nil { + g.log.Errorf("MaxBuy error: %v", err) + } else { + maxBuyLots = int(maxOrder.Swap.Lots) + } + if maxBuyLots < newBuyLots { + // We don't have the balance. Add our shortcoming to the other side. + shortLots := newBuyLots - maxBuyLots + newSellLots += shortLots + newBuyLots = maxBuyLots + g.log.Tracef("rebalance: reduced buy lots to %d because of low balance", newBuyLots) + } + } + + if newSellLots > 0 { + var maxLots int + maxOrder, err := g.inputs.maxSell() + if err != nil { + g.log.Errorf("MaxSell error: %v", err) + } else { + maxLots = int(maxOrder.Swap.Lots) + } + if maxLots < newSellLots { + shortLots := newSellLots - maxLots + newBuyLots += shortLots + if newBuyLots > maxBuyLots { + g.log.Tracef("rebalance: increased buy lot order to %d lots because sell balance is low", newBuyLots) + newBuyLots = maxBuyLots + } + newSellLots = maxLots + g.log.Tracef("rebalance: reduced sell lots to %d because of low balance", newSellLots) + } + } + + // Place buy orders. + if newBuyLots > 0 { + _, err := g.inputs.placeOrder(uint64(newBuyLots), buyPrice, false) + if err != nil { + g.log.Errorf("failed to place order: %v", err) + } + } + + // Place sell orders. + if newSellLots > 0 { + _, err := g.inputs.placeOrder(uint64(newSellLots), sellPrice, true) + if err != nil { + g.log.Errorf("failed to place order: %v", err) + } + } +} diff --git a/client/core/botengine_gap_test.go b/client/core/botengine_gap_test.go new file mode 100644 index 0000000000..165c4818a9 --- /dev/null +++ b/client/core/botengine_gap_test.go @@ -0,0 +1,285 @@ +package core + +import ( + "errors" + "math" + "testing" + + "decred.org/dcrdex/client/asset" + "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/calc" + "decred.org/dcrdex/dex/order" +) + +type tOrder struct { + lots uint64 + rate uint64 + sell bool +} + +type tGapEngineInputs struct { + basisPriceV uint64 + halfSpreadV uint64 + halfSpreadErr error + sortedBuys []*Order + sortedSells []*Order + maxBuyV *MaxOrderEstimate + maxBuyErr error + maxSellV *MaxOrderEstimate + maxSellErr error + atomToConv float64 + rateStepV uint64 + placedOrders []tOrder + cancels []order.OrderID + lotSizeV uint64 +} + +func (t *tGapEngineInputs) basisPrice(oracleBias, oracleWeighting, emptyMarketRate float64) uint64 { + return t.basisPriceV +} +func (t *tGapEngineInputs) halfSpread(basisPrice uint64) (uint64, error) { + return t.halfSpreadV, t.halfSpreadErr +} +func (t *tGapEngineInputs) sortedOrders() (buys, sells []*Order) { + return t.sortedBuys, t.sortedSells +} +func (t *tGapEngineInputs) maxBuy(rate uint64) (*MaxOrderEstimate, error) { + return t.maxBuyV, t.maxBuyErr +} +func (t *tGapEngineInputs) maxSell() (*MaxOrderEstimate, error) { + return t.maxSellV, t.maxSellErr + +} +func (t *tGapEngineInputs) conventionalRateToMsg(p float64) uint64 { + return uint64(math.Round(p / t.atomToConv * calc.RateEncodingFactor)) +} +func (t *tGapEngineInputs) rateStep() uint64 { + return t.rateStepV +} +func (t *tGapEngineInputs) placeOrder(lots, rate uint64, sell bool) (order.OrderID, error) { + t.placedOrders = append(t.placedOrders, tOrder{lots, rate, sell}) + return order.OrderID{}, nil +} +func (t *tGapEngineInputs) cancelOrder(oid order.OrderID) error { + t.cancels = append(t.cancels, oid) + return nil +} +func (t *tGapEngineInputs) lotSize() uint64 { + return t.lotSizeV +} +func tMaxOrderEstimate(lots uint64, swapFees, redeemFees uint64) *MaxOrderEstimate { + return &MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + RealisticWorstCase: swapFees, + Lots: lots, + }, + Redeem: &asset.RedeemEstimate{ + RealisticWorstCase: redeemFees, + }, + } +} + +func TestGapRebalance(t *testing.T) { + const rateStep uint64 = 7e14 + const midGap uint64 = 1234 * rateStep + const lotSize uint64 = 50e8 + const breakEven uint64 = 8 * rateStep + const newEpoch = 123_456_789 + const spreadMultiplier = 3 + const driftTolerance = 0.001 + + inverseLot := calc.BaseToQuote(midGap, lotSize) + + maxBuy := func(lots uint64) *MaxOrderEstimate { + return tMaxOrderEstimate(lots, inverseLot/100, lotSize/200) + } + maxSell := func(lots uint64) *MaxOrderEstimate { + return tMaxOrderEstimate(lots, lotSize/100, inverseLot/200) + } + + log := dex.StdOutLogger("T", dex.LevelTrace) + + type test struct { + name string + + inputs tGapEngineInputs + cfg *GapEngineCfg + + expBuyLots int + expSellLots int + expCancels int + } + + newInputs := func(maxBuyLots, maxSellLots uint64, buyErr, sellErr error, existingBuys, existingSells []*Order) tGapEngineInputs { + return tGapEngineInputs{ + basisPriceV: midGap, + halfSpreadV: breakEven, + maxBuyV: maxBuy(maxBuyLots), + maxBuyErr: buyErr, + maxSellV: maxSell(maxSellLots), + maxSellErr: sellErr, + sortedBuys: existingBuys, + sortedSells: existingSells, + rateStepV: rateStep, + atomToConv: 1, + placedOrders: make([]tOrder, 0, 2), + cancels: make([]order.OrderID, 0, 2), + lotSizeV: lotSize, + } + } + + newCfg := func(lots uint64) *GapEngineCfg { + return &GapEngineCfg{ + Lots: lots, + DriftTolerance: driftTolerance, + GapStrategy: GapStrategyMultiplier, + GapFactor: spreadMultiplier, + } + } + + newOrder := func(lots, rate uint64, sell bool, freeCancel bool) *Order { + var epoch uint64 = newEpoch + if freeCancel { + epoch = newEpoch - 2 + } + return &Order{ + Sell: sell, + Status: order.OrderStatusBooked, + Epoch: epoch, + Rate: rate, + Qty: lots * lotSize, + } + } + + buyPrice := midGap - (breakEven * spreadMultiplier) + sellPrice := midGap + (breakEven * spreadMultiplier) + + sellTolerance := uint64(math.Round(float64(sellPrice) * driftTolerance)) + + tests := []*test{ + { + name: "1 lot per side", + inputs: newInputs(1, 1, nil, nil, nil, nil), + cfg: newCfg(1), + expBuyLots: 1, + expSellLots: 1, + }, + { + name: "1 sell, buy already exists", + inputs: newInputs(1, 1, nil, nil, []*Order{newOrder(1, buyPrice, false, true)}, nil), + cfg: newCfg(1), + expBuyLots: 0, + expSellLots: 1, + }, + { + name: "1 buy, sell already exists", + inputs: newInputs(1, 1, nil, nil, nil, []*Order{newOrder(1, sellPrice, true, true)}), + cfg: newCfg(1), + expBuyLots: 1, + expSellLots: 0, + }, + { + name: "1 buy, sell already exists, just within tolerance", + inputs: newInputs(1, 1, nil, nil, nil, []*Order{newOrder(1, sellPrice+sellTolerance, true, true)}), + cfg: newCfg(1), + expBuyLots: 1, + expSellLots: 0, + }, + { + name: "1 lot each, sell just out of tolerance, but doesn't interfere", + inputs: newInputs(1, 1, nil, nil, nil, []*Order{newOrder(1, sellPrice+sellTolerance+1, true, true)}), + cfg: newCfg(1), + expBuyLots: 1, + expSellLots: 1, + expCancels: 1, + }, + { + name: "no buy, because an existing order (cancellation) interferes", + inputs: newInputs(1, 1, nil, nil, nil, []*Order{newOrder(1, buyPrice, true, true)}), + cfg: newCfg(1), + expBuyLots: 0, // cuz interference + expSellLots: 1, + expCancels: 1, + }, + { + name: "no sell, because an existing order (cancellation) interferes", + inputs: newInputs(1, 1, nil, nil, []*Order{newOrder(1, sellPrice, true, true)}, nil), + cfg: newCfg(1), + expBuyLots: 1, + expSellLots: 0, + expCancels: 1, + }, + { + name: "1 lot each, existing order barely escapes interference", + inputs: newInputs(1, 1, nil, nil, nil, []*Order{newOrder(1, buyPrice+1, true, true)}), + cfg: newCfg(1), + expBuyLots: 1, + expSellLots: 1, + expCancels: 1, + }, + { + name: "1 sell and 3 buy lots on an unbalanced 2 lot program", + inputs: newInputs(10, 1, nil, nil, nil, nil), + cfg: newCfg(2), + expBuyLots: 3, + expSellLots: 1, + }, + { + name: "1 sell and 3 buy lots on an unbalanced 3 lot program with balance limitations", + inputs: newInputs(3, 1, nil, nil, nil, nil), + cfg: newCfg(3), + expBuyLots: 3, + expSellLots: 1, + }, + { + name: "2 sell and 1 buy lots on an unbalanced 2 lot program with balance limitations", + inputs: newInputs(1, 2, nil, nil, nil, nil), + cfg: newCfg(2), + expBuyLots: 1, + expSellLots: 2, + }, + { + name: "4 buy lots on an unbalanced 2 lot program with balance but MaxSell error", + inputs: newInputs(10, 1, nil, errors.New("test error"), nil, nil), + cfg: newCfg(2), + expBuyLots: 4, + expSellLots: 0, + }, + { + name: "1 lot sell on an unbalanced 2 lot program with limited balance but MaxBuy error", + inputs: newInputs(10, 1, errors.New("test error"), nil, nil, nil), + cfg: newCfg(2), + expBuyLots: 0, + expSellLots: 1, + }, + } + + for _, tt := range tests { + engine, err := newGapEngine(&tt.inputs, tt.cfg, log) + if err != nil { + t.Fatalf("%s: error creating gap engine: %v", tt.name, err) + } + gapEngine := engine.(*gapEngine) + + gapEngine.notify(newEpochEngineNote(newEpoch)) + + var buyLots, sellLots uint64 + for _, order := range tt.inputs.placedOrders { + if order.sell { + sellLots = order.lots + } else { + buyLots = order.lots + } + } + + if buyLots != uint64(tt.expBuyLots) { + t.Fatalf("%s: expected %d buy lots but got %d", tt.name, tt.expBuyLots, buyLots) + } + if sellLots != uint64(tt.expSellLots) { + t.Fatalf("%s: expected %d buy lots but got %d", tt.name, tt.expSellLots, sellLots) + } + if len(tt.inputs.cancels) != tt.expCancels { + t.Fatalf("%s: expected %d cancels but got %d", tt.name, tt.expCancels, len(tt.inputs.cancels)) + } + } +} diff --git a/client/core/core.go b/client/core/core.go index df1444629d..1324ca02c0 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -297,7 +297,7 @@ func coreMarketFromMsgMarket(dc *dexConnection, msgMkt *msgjson.Market) *Market EpochLen: msgMkt.EpochLen, StartEpoch: msgMkt.StartEpoch, MarketBuyBuffer: msgMkt.MarketBuyBuffer, - AtomToConv: float64(qconv) / float64(bconv), + AtomToConv: float64(bconv) / float64(qconv), } trades, inFlight := dc.marketTrades(mkt.marketName()) diff --git a/client/orderbook/orderbook.go b/client/orderbook/orderbook.go index b34b0326c0..25aa3e92bb 100644 --- a/client/orderbook/orderbook.go +++ b/client/orderbook/orderbook.go @@ -454,23 +454,17 @@ func (ob *OrderBook) Unbook(note *msgjson.UnbookOrderNote) error { } // BestNOrders returns the best n orders from the provided side. -// NOTE: This is UNUSED, and test coverage is a near dup of bookside_test.go. -func (ob *OrderBook) BestNOrders(n int, side uint8) ([]*Order, bool, error) { +func (ob *OrderBook) BestNOrders(n int, sell bool) ([]*Order, bool, error) { if !ob.isSynced() { return nil, false, fmt.Errorf("order book is unsynced") } var orders []*Order var filled bool - switch side { - case msgjson.BuyOrderNum: - orders, filled = ob.buys.BestNOrders(n) - - case msgjson.SellOrderNum: + if sell { orders, filled = ob.sells.BestNOrders(n) - - default: - return nil, false, fmt.Errorf("unknown side provided: %d", side) + } else { + orders, filled = ob.buys.BestNOrders(n) } return orders, filled, nil diff --git a/client/orderbook/orderbook_test.go b/client/orderbook/orderbook_test.go index 5fbe89aa13..114637b20c 100644 --- a/client/orderbook/orderbook_test.go +++ b/client/orderbook/orderbook_test.go @@ -643,7 +643,7 @@ func TestOrderBookBestNOrders(t *testing.T) { label string orderBook *OrderBook n int - side uint8 + sell bool expected []*Order wantErr bool }{ @@ -662,7 +662,7 @@ func TestOrderBookBestNOrders(t *testing.T) { true, ), n: 3, - side: msgjson.BuyOrderNum, + sell: false, expected: []*Order{ makeOrder([32]byte{'e'}, msgjson.BuyOrderNum, 8, 4, 12), makeOrder([32]byte{'d'}, msgjson.BuyOrderNum, 5, 3, 10), @@ -685,7 +685,7 @@ func TestOrderBookBestNOrders(t *testing.T) { true, ), n: 3, - side: msgjson.SellOrderNum, + sell: true, expected: []*Order{}, wantErr: false, }, @@ -704,7 +704,7 @@ func TestOrderBookBestNOrders(t *testing.T) { true, ), n: 5, - side: msgjson.SellOrderNum, + sell: true, expected: []*Order{ makeOrder([32]byte{'b'}, msgjson.SellOrderNum, 10, 1, 2), makeOrder([32]byte{'c'}, msgjson.SellOrderNum, 10, 2, 5), @@ -728,7 +728,7 @@ func TestOrderBookBestNOrders(t *testing.T) { false, ), n: 5, - side: msgjson.SellOrderNum, + sell: true, expected: nil, wantErr: true, }, @@ -747,7 +747,7 @@ func TestOrderBookBestNOrders(t *testing.T) { true, ), n: 3, - side: msgjson.SellOrderNum, + sell: true, expected: []*Order{ makeOrder([32]byte{'b'}, msgjson.SellOrderNum, 10, 1, 2), makeOrder([32]byte{'c'}, msgjson.SellOrderNum, 10, 2, 5), @@ -758,7 +758,7 @@ func TestOrderBookBestNOrders(t *testing.T) { } for idx, tc := range tests { - best, _, err := tc.orderBook.BestNOrders(tc.n, tc.side) + best, _, err := tc.orderBook.BestNOrders(tc.n, tc.sell) if (err != nil) != tc.wantErr { t.Fatalf("[OrderBook.BestNOrders] #%d: error: %v, wantErr: %v", idx+1, err, tc.wantErr) diff --git a/client/webserver/locales/en-us.go b/client/webserver/locales/en-us.go index d5f8076eb5..d593857cc4 100644 --- a/client/webserver/locales/en-us.go +++ b/client/webserver/locales/en-us.go @@ -319,4 +319,23 @@ var EnUS = map[string]string{ "connected": "Connected", "Remove": "Remove", "unready_wallets_msg": "Your wallets must be connected and unlocked before trades can be processed. Resolve this ASAP!", + "register_api_keys": "Register API Keys", + "update_api_keys": "Update API Keys", + "disconnect": "Disconnect", + "manage_cexes": "Manage CEXes", + "enter_api_keys": `Enter API Keys`, + "api_key": "API Key", + "api_secret": "API Secret", + "cex_balances": ` Balances`, + `cex_balances_2`: `