From de675a60f99c13ef40214dac5c1a190fc5151785 Mon Sep 17 00:00:00 2001 From: Marton Date: Sun, 6 Aug 2023 16:49:15 -0400 Subject: [PATCH] client/{mm,core}: Market maker balance segregation (#2332) * client/{mm,core}: Market maker balance segregation New configurations are added to the market maker config specifying what part of the wallet's balance each market maket will have control over. Using this config, a `balanceHandler` object is created, which tracks each of the orders made by the market makers, decreasing and increasing their balances as needed over the lifecycle of the order. The `balanceHandler` is not called directly by the market makers, instead a `coreWithSegregatedBalance` is created which wraps the core methods so that from the point of view of the bot, they behave as if the entire balance of the wallet is only what has been allocated to that bot by the `balanceHandler`. --- client/asset/btc/btc.go | 30 + client/asset/btc/btc_test.go | 44 + client/asset/dcr/dcr.go | 17 + client/asset/dcr/dcr_test.go | 29 + client/asset/eth/eth.go | 5 + client/asset/interface.go | 13 + client/core/core.go | 42 +- client/core/core_test.go | 4 +- client/core/trade.go | 15 +- client/core/types.go | 14 +- client/mm/config.go | 25 + client/mm/mm.go | 691 ++++++++- client/mm/mm_basic.go | 4 +- client/mm/mm_test.go | 2597 ++++++++++++++++++++++++++++++++++ client/mm/sample-config.json | 6 +- client/mm/wrapped_core.go | 449 ++++++ 16 files changed, 3937 insertions(+), 48 deletions(-) create mode 100644 client/mm/mm_test.go create mode 100644 client/mm/wrapped_core.go diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index 16c7515587..1f77cc1cc8 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -6148,6 +6148,36 @@ func (btc *baseWallet) FundMultiOrder(mo *asset.MultiOrder, maxLock uint64) ([]a return btc.fundMulti(maxLock, mo.Values, mo.FeeSuggestion, mo.MaxFeeRate, useSplit, splitBuffer) } +// MaxFundingFees returns the maximum funding fees for an order/multi-order. +func (btc *baseWallet) MaxFundingFees(numTrades uint32, options map[string]string) uint64 { + useSplit := btc.useSplitTx() + if options != nil { + if split, ok := options[splitKey]; ok { + useSplit, _ = strconv.ParseBool(split) + } + if split, ok := options[multiSplitKey]; ok { + useSplit, _ = strconv.ParseBool(split) + } + } + if !useSplit { + return 0 + } + + var inputSize, outputSize uint64 + if btc.segwit { + inputSize = dexbtc.RedeemP2WPKHInputTotalSize + outputSize = dexbtc.P2WPKHOutputSize + } else { + inputSize = dexbtc.RedeemP2PKHInputSize + outputSize = dexbtc.P2PKHOutputSize + } + + const numInputs = 12 // plan for lots of inputs to get a safe estimate + + txSize := dexbtc.MinimumTxOverhead + numInputs*inputSize + uint64(numTrades+1)*outputSize + return btc.feeRateLimit() * txSize +} + type utxo struct { txHash *chainhash.Hash vout uint32 diff --git a/client/asset/btc/btc_test.go b/client/asset/btc/btc_test.go index 27ff2f6d32..54da5e1569 100644 --- a/client/asset/btc/btc_test.go +++ b/client/asset/btc/btc_test.go @@ -2018,6 +2018,50 @@ func testFundMultiOrder(t *testing.T, segwit bool, walletType string) { } } +func TestMaxFundingFees(t *testing.T) { + runRubric(t, testMaxFundingFees) +} + +func testMaxFundingFees(t *testing.T, segwit bool, walletType string) { + wallet, _, shutdown := tNewWallet(segwit, walletType) + defer shutdown() + + feeRateLimit := uint64(100) + + wallet.cfgV.Store(&baseWalletConfig{ + feeRateLimit: feeRateLimit, + }) + + useSplitOptions := map[string]string{ + splitKey: "true", + } + noSplitOptions := map[string]string{ + splitKey: "false", + } + + var inputSize, outputSize uint64 + if segwit { + inputSize = dexbtc.RedeemP2WPKHInputTotalSize + outputSize = dexbtc.P2WPKHOutputSize + } else { + inputSize = dexbtc.RedeemP2PKHInputSize + outputSize = dexbtc.P2PKHOutputSize + } + + const maxSwaps = 3 + const numInputs = 12 + maxFundingFees := wallet.MaxFundingFees(maxSwaps, useSplitOptions) + expectedFees := feeRateLimit * (inputSize*numInputs + outputSize*(maxSwaps+1) + dexbtc.MinimumTxOverhead) + if maxFundingFees != expectedFees { + t.Fatalf("unexpected max funding fees. expected %d, got %d", expectedFees, maxFundingFees) + } + + maxFundingFees = wallet.MaxFundingFees(maxSwaps, noSplitOptions) + if maxFundingFees != 0 { + t.Fatalf("unexpected max funding fees. expected 0, got %d", maxFundingFees) + } +} + func TestAvailableFund(t *testing.T) { runRubric(t, testAvailableFund) } diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index 83bd57fea0..0cdc968584 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -1695,6 +1695,23 @@ func (dcr *ExchangeWallet) SingleLotSwapFees(_ uint32, feeSuggestion uint64, opt return totalTxSize * bumpedNetRate, nil } +// MaxFundingFees returns the maximum funding fees for an order/multi-order. +func (dcr *ExchangeWallet) MaxFundingFees(numTrades uint32, options map[string]string) uint64 { + useSplit := dcr.config().useSplitTx + if options != nil { + if split, ok := options[splitKey]; ok { + useSplit, _ = strconv.ParseBool(split) + } + } + if !useSplit { + return 0 + } + + const numInputs = 12 // plan for lots of inputs to get a safe estimate + splitTxSize := dexdcr.MsgTxOverhead + (numInputs * dexdcr.P2PKHInputSize) + (uint64(numTrades+1) * dexdcr.P2PKHOutputSize) + return splitTxSize * dcr.config().feeRateLimit +} + // splitOption constructs an *asset.OrderOption with customized text based on the // difference in fees between the configured and test split condition. func (dcr *ExchangeWallet) splitOption(req *asset.PreSwapForm, utxos []*compositeUTXO, bump float64) *asset.OrderOption { diff --git a/client/asset/dcr/dcr_test.go b/client/asset/dcr/dcr_test.go index 0d7fa2f139..003fcff6c1 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -683,6 +683,35 @@ func TestMain(m *testing.M) { os.Exit(doIt()) } +func TestMaxFundingFees(t *testing.T) { + wallet, _, shutdown := tNewWallet() + defer shutdown() + + feeRateLimit := uint64(100) + + wallet.cfgV.Store(&exchangeWalletConfig{ + feeRateLimit: feeRateLimit, + }) + + useSplitOptions := map[string]string{ + splitKey: "true", + } + noSplitOptions := map[string]string{ + splitKey: "false", + } + + maxFundingFees := wallet.MaxFundingFees(3, useSplitOptions) + expectedFees := feeRateLimit * (dexdcr.P2PKHInputSize*12 + dexdcr.P2PKHOutputSize*4 + dexdcr.MsgTxOverhead) + if maxFundingFees != expectedFees { + t.Fatalf("unexpected max funding fees. expected %d, got %d", expectedFees, maxFundingFees) + } + + maxFundingFees = wallet.MaxFundingFees(3, noSplitOptions) + if maxFundingFees != 0 { + t.Fatalf("unexpected max funding fees. expected 0, got %d", maxFundingFees) + } +} + func TestAvailableFund(t *testing.T) { wallet, node, shutdown := tNewWallet() defer shutdown() diff --git a/client/asset/eth/eth.go b/client/asset/eth/eth.go index 73e09b1108..5ead25cae7 100644 --- a/client/asset/eth/eth.go +++ b/client/asset/eth/eth.go @@ -1286,6 +1286,11 @@ func (w *assetWallet) preSwap(req *asset.PreSwapForm, feeWallet *assetWallet) (* }, nil } +// MaxFundingFees returns 0 because ETH does not have funding fees. +func (w *baseWallet) MaxFundingFees(_ uint32, _ map[string]string) uint64 { + return 0 +} + // SingleLotSwapFees returns the fees for a swap transaction for a single lot. func (w *assetWallet) SingleLotSwapFees(version uint32, feeSuggestion uint64, _ map[string]string) (fees uint64, err error) { g := w.gases(version) diff --git a/client/asset/interface.go b/client/asset/interface.go index d02c799cd5..5172074e50 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -30,6 +30,7 @@ const ( WalletTraitAuthenticator // The wallet require authentication. WalletTraitShielded // The wallet is ShieldedWallet (e.g. ZCash) WalletTraitTokenApprover // The wallet is a TokenApprover + WalletTraitAccountLocker // The wallet must have enough balance for redemptions before a trade. ) // IsRescanner tests if the WalletTrait has the WalletTraitRescanner bit set. @@ -116,6 +117,12 @@ func (wt WalletTrait) IsTokenApprover() bool { return wt&WalletTraitTokenApprover != 0 } +// IsAccountLocker test if WalletTrait has WalletTraitAccountLocker bit set, +// which indicates the wallet implements the AccountLocker interface. +func (wt WalletTrait) IsAccountLocker() bool { + return wt&WalletTraitAccountLocker != 0 +} + // DetermineWalletTraits returns the WalletTrait bitset for the provided Wallet. func DetermineWalletTraits(w Wallet) (t WalletTrait) { if _, is := w.(Rescanner); is { @@ -160,6 +167,10 @@ func DetermineWalletTraits(w Wallet) (t WalletTrait) { if _, is := w.(TokenApprover); is { t |= WalletTraitTokenApprover } + if _, is := w.(AccountLocker); is { + t |= WalletTraitAccountLocker + } + return t } @@ -504,6 +515,8 @@ type Wallet interface { // than the number of orders, then the orders at the end of the list were // not about to be funded. FundMultiOrder(ord *MultiOrder, maxLock uint64) (coins []Coins, redeemScripts [][]dex.Bytes, fundingFees uint64, err error) + // MaxFundingFees returns the max fees that could be paid for funding a swap. + MaxFundingFees(numTrades uint32, options map[string]string) uint64 } // Authenticator is a wallet implementation that require authentication. diff --git a/client/core/core.go b/client/core/core.go index d310dbecff..02eb462572 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -5513,22 +5513,36 @@ func (c *Core) SingleLotFees(form *SingleLotFeesForm) (uint64, uint64, error) { return 0, 0, fmt.Errorf("client and server asset versions are incompatible for %v", form.Host) } - swapFeeSuggestion := c.feeSuggestionAny(wallets.fromWallet.AssetID) // server rates only for the swap init - if swapFeeSuggestion == 0 { - return 0, 0, fmt.Errorf("failed to get swap fee suggestion for %s at %s", wallets.fromWallet.Symbol, form.Host) - } + var swapFeeRate, redeemFeeRate uint64 - redeemFeeSuggestion := c.feeSuggestionAny(wallets.toWallet.AssetID) // wallet rate or server rate - if redeemFeeSuggestion == 0 { - return 0, 0, fmt.Errorf("failed to get redeem fee suggestion for %s at %s", wallets.toWallet.Symbol, form.Host) + if form.UseMaxFeeRate { + dc.assetsMtx.Lock() + swapAsset, redeemAsset := dc.assets[wallets.fromWallet.AssetID], dc.assets[wallets.toWallet.AssetID] + dc.assetsMtx.Unlock() + if swapAsset == nil { + return 0, 0, fmt.Errorf("no asset found for %d", wallets.fromWallet.AssetID) + } + if redeemAsset == nil { + return 0, 0, fmt.Errorf("no asset found for %d", wallets.toWallet.AssetID) + } + swapFeeRate, redeemFeeRate = swapAsset.MaxFeeRate, redeemAsset.MaxFeeRate + } else { + swapFeeRate = c.feeSuggestionAny(wallets.fromWallet.AssetID) // server rates only for the swap init + if swapFeeRate == 0 { + return 0, 0, fmt.Errorf("failed to get swap fee suggestion for %s at %s", wallets.fromWallet.Symbol, form.Host) + } + redeemFeeRate = c.feeSuggestionAny(wallets.toWallet.AssetID) // wallet rate or server rate + if redeemFeeRate == 0 { + return 0, 0, fmt.Errorf("failed to get redeem fee suggestion for %s at %s", wallets.toWallet.Symbol, form.Host) + } } - swapFees, err := wallets.fromWallet.SingleLotSwapFees(assetConfigs.fromAsset.Version, swapFeeSuggestion, form.Options) + swapFees, err := wallets.fromWallet.SingleLotSwapFees(assetConfigs.fromAsset.Version, swapFeeRate, form.Options) if err != nil { return 0, 0, fmt.Errorf("error calculating swap fees: %w", err) } - redeemFees, err := wallets.toWallet.SingleLotRedeemFees(assetConfigs.toAsset.Version, redeemFeeSuggestion, form.Options) + redeemFees, err := wallets.toWallet.SingleLotRedeemFees(assetConfigs.toAsset.Version, redeemFeeRate, form.Options) if err != nil { return 0, 0, fmt.Errorf("error calculating redeem fees: %w", err) } @@ -5536,6 +5550,16 @@ func (c *Core) SingleLotFees(form *SingleLotFeesForm) (uint64, uint64, error) { return swapFees, redeemFees, nil } +// MaxFundingFees gives the max fees required to fund a Trade or MultiTrade. +func (c *Core) MaxFundingFees(fromAsset uint32, numTrades uint32, options map[string]string) (uint64, error) { + wallet, found := c.wallet(fromAsset) + if !found { + return 0, newError(missingWalletErr, "no wallet found for %s", unbip(fromAsset)) + } + + return wallet.MaxFundingFees(numTrades, options), nil +} + // PreOrder calculates fee estimates for a trade. func (c *Core) PreOrder(form *TradeForm) (*OrderEstimate, error) { dc, err := c.registeredDEX(form.Host) diff --git a/client/core/core_test.go b/client/core/core_test.go index 4b75fb45bc..fc3910c2b5 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -1064,10 +1064,12 @@ func (w *TXCWallet) AccelerationEstimate(swapCoins, accelerationCoins []dex.Byte func (w *TXCWallet) ReturnRedemptionAddress(addr string) { w.returnedAddr = addr } - func (w *TXCWallet) ReturnRefundContracts(contracts [][]byte) { w.returnedContracts = contracts } +func (w *TXCWallet) MaxFundingFees(_ uint32, _ map[string]string) uint64 { + return 0 +} func (*TXCWallet) FundMultiOrder(ord *asset.MultiOrder, maxLock uint64) (coins []asset.Coins, redeemScripts [][]dex.Bytes, fundingFees uint64, err error) { return nil, nil, 0, nil diff --git a/client/core/trade.go b/client/core/trade.go index dc20b0c616..177aacdcb9 100644 --- a/client/core/trade.go +++ b/client/core/trade.go @@ -645,14 +645,23 @@ func (t *trackedTrade) coreOrderInternal() *Order { corder.Epoch = t.dc.marketEpoch(t.mktID, t.Prefix().ServerTime) corder.LockedAmt = t.lockedAmount() corder.ReadyToTick = t.readyToTick + corder.RedeemLockedAmt = t.redemptionLocked + corder.RefundLockedAmt = t.refundLocked + allFeesConfirmed := true for _, mt := range t.matches { + if !mt.MetaData.Proof.SwapFeeConfirmed || !mt.MetaData.Proof.RedemptionFeeConfirmed { + allFeesConfirmed = false + } swapConfs, counterConfs := mt.confirms() corder.Matches = append(corder.Matches, matchFromMetaMatchWithConfs(t, &mt.MetaMatch, swapConfs, int64(t.metaData.FromSwapConf), counterConfs, int64(t.metaData.ToSwapConf), int64(mt.redemptionConfs), int64(mt.redemptionConfsReq))) } + + corder.AllFeesConfirmed = allFeesConfirmed + return corder } @@ -1485,7 +1494,7 @@ func (t *trackedTrade) updateDynamicSwapOrRedemptionFeesPaid(ctx context.Context if !isInit { checkFees = feeChecker.DynamicRedemptionFeesPaid } - actualSwapFees, secrets, err := checkFees(ctx, coinID, contractData) + actualFees, secrets, err := checkFees(ctx, coinID, contractData) if err != nil { if errors.Is(err, asset.CoinNotFoundError) || errors.Is(err, asset.ErrNotEnoughConfirms) { return @@ -1500,9 +1509,9 @@ func (t *trackedTrade) updateDynamicSwapOrRedemptionFeesPaid(ctx context.Context return } if isInit { - t.metaData.SwapFeesPaid += actualSwapFees + t.metaData.SwapFeesPaid += actualFees } else { - t.metaData.RedemptionFeesPaid += actualSwapFees + t.metaData.RedemptionFeesPaid += actualFees } stopChecks() t.notify(newOrderNote(TopicOrderStatusUpdate, "", "", db.Data, t.coreOrderInternal())) diff --git a/client/core/types.go b/client/core/types.go index ac00b881c3..b2106147df 100644 --- a/client/core/types.go +++ b/client/core/types.go @@ -376,8 +376,11 @@ type Order struct { Cancelling bool `json:"cancelling"` Canceled bool `json:"canceled"` FeesPaid *FeeBreakdown `json:"feesPaid"` + AllFeesConfirmed bool `json:"allFeesConfirmed"` FundingCoins []*Coin `json:"fundingCoins"` LockedAmt uint64 `json:"lockedamt"` + RedeemLockedAmt uint64 `json:"redeemLockedAmt"` + RefundLockedAmt uint64 `json:"refundLockedAmt"` AccelerationCoins []*Coin `json:"accelerationCoins"` Rate uint64 `json:"rate"` // limit only TimeInForce order.TimeInForce `json:"tif"` // limit only @@ -1009,11 +1012,12 @@ type MultiTradeForm struct { // SingleLotFeesForm is used to determine the fees for a single lot trade. type SingleLotFeesForm struct { - Host string `json:"host"` - Base uint32 `json:"base"` - Quote uint32 `json:"quote"` - Sell bool `json:"sell"` - Options map[string]string `json:"options"` + Host string `json:"host"` + Base uint32 `json:"base"` + Quote uint32 `json:"quote"` + Sell bool `json:"sell"` + Options map[string]string `json:"options"` + UseMaxFeeRate bool `json:"useMaxFeeRate"` } // marketName is a string ID constructed from the asset IDs. diff --git a/client/mm/config.go b/client/mm/config.go index 67323391e1..1b5407f984 100644 --- a/client/mm/config.go +++ b/client/mm/config.go @@ -1,5 +1,9 @@ package mm +import ( + "fmt" +) + // MarketMakingWithCEXConfig is the configuration for a market // maker that places orders on both sides of the order book, but // only if there is profitable counter-trade on the CEX @@ -12,16 +16,33 @@ type MarketMakingWithCEXConfig struct { type ArbitrageConfig struct { } +type BalanceType uint8 + +const ( + Percentage BalanceType = iota + Amount +) + // BotConfig is the configuration for a market making bot. +// The balance fields are the initial amounts that will be reserved to use for +// this bot. As the bot trades, the amounts reserved for it will be updated. type BotConfig struct { Host string `json:"host"` BaseAsset uint32 `json:"baseAsset"` QuoteAsset uint32 `json:"quoteAsset"` + BaseBalanceType BalanceType `json:"baseBalanceType"` + BaseBalance uint64 `json:"baseBalance"` + + QuoteBalanceType BalanceType `json:"quoteBalanceType"` + QuoteBalance uint64 `json:"quoteBalance"` + // Only one of the following configs should be set MMCfg *MarketMakingConfig `json:"marketMakingConfig,omitempty"` MMWithCEXCfg *MarketMakingWithCEXConfig `json:"marketMakingWithCEXConfig,omitempty"` ArbCfg *ArbitrageConfig `json:"arbitrageConfig,omitempty"` + + Disabled bool `json:"disabled"` } func (c *BotConfig) requiresPriceOracle() bool { @@ -30,3 +51,7 @@ func (c *BotConfig) requiresPriceOracle() bool { } return false } + +func dexMarketID(host string, base, quote uint32) string { + return fmt.Sprintf("%s-%d-%d", host, base, quote) +} diff --git a/client/mm/mm.go b/client/mm/mm.go index 1c4b000b4b..5d3e08038b 100644 --- a/client/mm/mm.go +++ b/client/mm/mm.go @@ -5,6 +5,7 @@ package mm import ( "context" + "encoding/hex" "errors" "fmt" "sync" @@ -13,6 +14,8 @@ import ( "decred.org/dcrdex/client/core" "decred.org/dcrdex/client/orderbook" "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/calc" + "decred.org/dcrdex/dex/order" ) // clientCore is satisfied by core.Core. @@ -26,18 +29,153 @@ type clientCore interface { Trade(pw []byte, form *core.TradeForm) (*core.Order, error) MaxBuy(host string, base, quote uint32, rate uint64) (*core.MaxOrderEstimate, error) MaxSell(host string, base, quote uint32) (*core.MaxOrderEstimate, error) + AssetBalance(assetID uint32) (*core.WalletBalance, error) + PreOrder(form *core.TradeForm) (*core.OrderEstimate, error) + WalletState(assetID uint32) *core.WalletState + MultiTrade(pw []byte, form *core.MultiTradeForm) ([]*core.Order, error) + MaxFundingFees(fromAsset uint32, numTrades uint32, options map[string]string) (uint64, error) + User() *core.User + Login(pw []byte) error + OpenWallet(assetID uint32, appPW []byte) error } var _ clientCore = (*core.Core)(nil) +// dexOrderBook is satisfied by orderbook.OrderBook. +// Avoids having to mock the entire orderbook in tests. +type dexOrderBook interface { + MidGap() (uint64, error) +} + +var _ dexOrderBook = (*orderbook.OrderBook)(nil) + +// botBalance keeps track of the amount of funds available for a +// bot's use, and the amount that is currently locked/pending for +// various reasons. Only the Available balance matters for the +// behavior of the bots. The others are just tracked to inform the +// user. +type botBalance struct { + Available uint64 `json:"available"` + FundingOrder uint64 `json:"fundingOrder"` + PendingRedeem uint64 `json:"pendingRedeem"` + PendingRefund uint64 `json:"pendingRefund"` +} + +// botBalance keeps track of the bot balances. +// When the MarketMaker is created, it will allocate the proper amount of +// funds for each bot. Then, as the bot makes trades, each bot's balances +// will be increased and decreased as needed. +// Below is of how the balances are adjusted during trading. +// +// 1. A trade is made: +// +// - FromAsset: +// DECREASE: LockedFunds + FundingFees +// if isAccountLocker, RefundFeesLockedFunds +// +// - ToAsset: +// DECREASE: if isAccountLocker, RedeemFeesLockedFunds +// +// 2. MatchConfirmed: +// +// - FromAsset: +// INCREASE: RefundedAmount - RefundFees +// if isAccountLocker, RefundFeesLockedFunds +// +// - ToAsset: +// INCREASE: if isAccountLocker, RedeemedAmount +// else RedeemedAmount - MaxRedeemFeesForLotsRedeemed +// (the redeemed amount is tracked on the core.Order, so we +// do not know the exact amount used for this match. The +// difference is handled later.) +// +// 3. Match Refunded: +// +// - FromAsset: +// INCREASE: if isAccountLocker, RefundedAmount +// else RefundedAmount - MaxRefundFees +// +// 4. Order Status > Booked: +// +// - FromAsset: +// INCREASE: OverLockedAmount (LockedFunds - FilledAmount - MaxSwapFees) +// +// 5. All Fees Confirmed: +// +// - FromAsset: +// INCREASE: ExcessSwapFees (MaxSwapFees - ActualSwapFees) +// if isAccountLocker, ExcessRefundFees (RefundFeesLockedFunds - ActualRefundFees) +// else ExcessRefundFees (MaxRefundFees - ActualRefundFees) +// +// - ToAsset: +// INCREASE: if isAccountLocker, ExcessRedeemFees (RedeemFeesLockedFunds - ActualRedeemFees) +// else ExcessRedeemFees (MaxRedeemFeesForLotsRedeemed - ActualRedeemFees) +type botBalances struct { + mtx sync.RWMutex + balances map[uint32]*botBalance +} + +// orderInfo stores the necessary information the MarketMaker needs for a +// particular order. +type orderInfo struct { + bot string + order *core.Order + initialFundsLocked uint64 + lotSize uint64 + // initialRedeemFeesLocked will be > 0 for assets that are account lockers + // (ETH). This means that the redeem fees will be initially locked, then + // the complete redeemed amount will be sent on redemption. + initialRedeemFeesLocked uint64 + initialRefundFeesLocked uint64 + singleLotSwapFees uint64 + singleLotRedeemFees uint64 + unusedLockedFundsReturned bool + excessFeesReturned bool + matchesSeen map[order.MatchID]struct{} + matchesSettled map[order.MatchID]struct{} +} + +// finishedProcessing returns true when the MarketMaker no longer needs to +// track an order. +func (o *orderInfo) finishedProcessing() bool { + if !o.unusedLockedFundsReturned || !o.excessFeesReturned { + return false + } + + for _, match := range o.order.Matches { + var matchID order.MatchID + copy(matchID[:], match.MatchID) + if _, found := o.matchesSettled[matchID]; !found { + return false + } + } + + return true +} + // MarketMaker handles the market making process. It supports running different // strategies on different markets. type MarketMaker struct { - ctx context.Context - die context.CancelFunc - running atomic.Bool - log dex.Logger - core *core.Core + ctx context.Context + die context.CancelFunc + running atomic.Bool + log dex.Logger + core clientCore + doNotKillWhenBotsStop bool // used for testing + botBalances map[string]*botBalances + + ordersMtx sync.RWMutex + orders map[order.OrderID]*orderInfo +} + +// NewMarketMaker creates a new MarketMaker. +func NewMarketMaker(c clientCore, log dex.Logger) (*MarketMaker, error) { + return &MarketMaker{ + core: c, + log: log, + running: atomic.Bool{}, + orders: make(map[order.OrderID]*orderInfo), + }, nil } // Running returns true if the MarketMaker is running. @@ -63,7 +201,7 @@ func duplicateBotConfig(cfgs []*BotConfig) error { mkts := make(map[string]struct{}) for _, cfg := range cfgs { - mkt := fmt.Sprintf("%s-%d-%d", cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) + mkt := dexMarketID(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) if _, found := mkts[mkt]; found { return fmt.Errorf("duplicate bot config for market %s", mkt) } @@ -73,6 +211,494 @@ func duplicateBotConfig(cfgs []*BotConfig) error { return nil } +func priceOracleFromConfigs(ctx context.Context, cfgs []*BotConfig, log dex.Logger) (*priceOracle, error) { + var oracle *priceOracle + var err error + marketsRequiringOracle := marketsRequiringPriceOracle(cfgs) + if len(marketsRequiringOracle) > 0 { + oracle, err = newPriceOracle(ctx, marketsRequiringOracle, log) + if err != nil { + return nil, fmt.Errorf("failed to create PriceOracle: %v", err) + } + } + + return oracle, nil +} + +func (m *MarketMaker) loginAndUnlockWallets(pw []byte, cfgs []*BotConfig) error { + err := m.core.Login(pw) + if err != nil { + return fmt.Errorf("failed to login: %w", err) + } + unlocked := make(map[uint32]interface{}) + for _, cfg := range cfgs { + if _, done := unlocked[cfg.BaseAsset]; !done { + err := m.core.OpenWallet(cfg.BaseAsset, pw) + if err != nil { + return fmt.Errorf("failed to unlock wallet for asset %d: %w", cfg.BaseAsset, err) + } + unlocked[cfg.BaseAsset] = true + } + + if _, done := unlocked[cfg.QuoteAsset]; !done { + err := m.core.OpenWallet(cfg.QuoteAsset, pw) + if err != nil { + return fmt.Errorf("failed to unlock wallet for asset %d: %w", cfg.QuoteAsset, err) + } + unlocked[cfg.QuoteAsset] = true + } + } + + return nil +} + +func validateAndFilterEnabledConfigs(cfgs []*BotConfig) ([]*BotConfig, error) { + enabledCfgs := make([]*BotConfig, 0, len(cfgs)) + for _, cfg := range cfgs { + if !cfg.Disabled { + enabledCfgs = append(enabledCfgs, cfg) + } + } + if len(enabledCfgs) == 0 { + return nil, errors.New("no enabled bots") + } + if err := duplicateBotConfig(enabledCfgs); err != nil { + return nil, err + } + return enabledCfgs, nil +} + +// setupBalances makes sure there is sufficient balance to cover all the bots, +// and populates the botBalances map. +func (m *MarketMaker) setupBalances(cfgs []*BotConfig) error { + m.botBalances = make(map[string]*botBalances, len(cfgs)) + + type trackedBalance struct { + balanceAvailable uint64 + balanceReserved uint64 + } + + balanceTracker := make(map[uint32]*trackedBalance) + trackAsset := func(assetID uint32) error { + if _, found := balanceTracker[assetID]; found { + return nil + } + bal, err := m.core.AssetBalance(assetID) + if err != nil { + return fmt.Errorf("failed to get balance for asset %d: %v", assetID, err) + } + balanceTracker[assetID] = &trackedBalance{ + balanceAvailable: bal.Available, + } + return nil + } + + for _, cfg := range cfgs { + err := trackAsset(cfg.BaseAsset) + if err != nil { + return err + } + err = trackAsset(cfg.QuoteAsset) + if err != nil { + return err + } + + baseBalance := balanceTracker[cfg.BaseAsset] + quoteBalance := balanceTracker[cfg.QuoteAsset] + + var baseRequired, quoteRequired uint64 + if cfg.BaseBalanceType == Percentage { + baseRequired = baseBalance.balanceAvailable * cfg.BaseBalance / 100 + } else { + baseRequired = cfg.BaseBalance + } + + if cfg.QuoteBalanceType == Percentage { + quoteRequired = quoteBalance.balanceAvailable * cfg.QuoteBalance / 100 + } else { + quoteRequired = cfg.QuoteBalance + } + + if baseRequired == 0 && quoteRequired == 0 { + return fmt.Errorf("both base and quote balance are zero for market %s-%d-%d", + cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) + } + + if baseRequired > baseBalance.balanceAvailable-baseBalance.balanceReserved { + return fmt.Errorf("insufficient balance for asset %d", cfg.BaseAsset) + } + if quoteRequired > quoteBalance.balanceAvailable-quoteBalance.balanceReserved { + return fmt.Errorf("insufficient balance for asset %d", cfg.QuoteAsset) + } + + baseBalance.balanceReserved += baseRequired + quoteBalance.balanceReserved += quoteRequired + + mktID := dexMarketID(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) + m.botBalances[mktID] = &botBalances{ + balances: map[uint32]*botBalance{ + cfg.BaseAsset: { + Available: baseRequired, + }, + cfg.QuoteAsset: { + Available: quoteRequired, + }, + }, + } + } + + return nil +} + +// isAccountLocker returns if the asset is an account locker. +func (m *MarketMaker) isAccountLocker(assetID uint32) bool { + walletState := m.core.WalletState(assetID) + if walletState == nil { + m.log.Errorf("isAccountLocker: wallet state not found for asset %d", assetID) + return false + } + + return walletState.Traits.IsAccountLocker() +} + +type botBalanceType uint8 + +const ( + balTypeAvailable botBalanceType = iota + balTypeFundingOrder + balTypePendingRedeem + balTypePendingRefund +) + +const ( + balanceModIncrease = true + balanceModDecrease = false +) + +// balanceMod is passed to modifyBotBalance to increase or decrease one +// of the bot's balances for an asset. +type balanceMod struct { + increase bool + assetID uint32 + typ botBalanceType + amount uint64 +} + +// modifyBotBalance does modifications to the various bot balances. +func (m *MarketMaker) modifyBotBalance(botID string, mods []*balanceMod) { + bb := m.botBalances[botID] + if bb == nil { + m.log.Errorf("increaseBotBalance: bot %s not found", botID) + return + } + + bb.mtx.Lock() + defer bb.mtx.Unlock() + + for _, mod := range mods { + assetBalance, found := bb.balances[mod.assetID] + if !found { + m.log.Errorf("modifyBotBalance: asset %d not found for bot %s", mod.assetID, botID) + continue + } + + newFieldValue := func(balanceType string, initialValue uint64) uint64 { + if mod.increase { + return initialValue + mod.amount + } else { + if assetBalance.Available < mod.amount { + m.log.Errorf("modifyBotBalance: bot %s has insufficient %s for asset %d. "+ + "balance: %d, amount: %d", botID, balanceType, mod.assetID, initialValue, mod.amount) + return 0 + } + return initialValue - mod.amount + } + } + + switch mod.typ { + case balTypeAvailable: + assetBalance.Available = newFieldValue("available balance", assetBalance.Available) + case balTypeFundingOrder: + assetBalance.FundingOrder = newFieldValue("funding order", assetBalance.FundingOrder) + case balTypePendingRedeem: + assetBalance.PendingRedeem = newFieldValue("pending redeem", assetBalance.PendingRedeem) + case balTypePendingRefund: + assetBalance.PendingRefund = newFieldValue("pending refund", assetBalance.PendingRefund) + } + } +} + +// botBalance returns a bot's balance of an asset. +func (m *MarketMaker) botBalance(botID string, assetID uint32) uint64 { + bb := m.botBalances[botID] + if bb == nil { + m.log.Errorf("balance: bot %s not found", botID) + return 0 + } + + bb.mtx.RLock() + defer bb.mtx.RUnlock() + + if _, found := bb.balances[assetID]; found { + return bb.balances[assetID].Available + } + + m.log.Errorf("balance: asset %d not found for bot %s", assetID, botID) + return 0 +} + +func (m *MarketMaker) getOrderInfo(id dex.Bytes) *orderInfo { + var oid order.OrderID + copy(oid[:], id) + + m.ordersMtx.RLock() + defer m.ordersMtx.RUnlock() + return m.orders[oid] +} + +func (m *MarketMaker) removeOrderInfo(id dex.Bytes) { + m.log.Tracef("oid %s - finished handling", hex.EncodeToString(id)) + + var oid order.OrderID + copy(oid[:], id[:]) + + m.ordersMtx.Lock() + defer m.ordersMtx.Unlock() + delete(m.orders, oid) +} + +// handleMatchUpdate adds the redeem/refund amount to the bot's balance if the +// match is in the confirmed state. +func (m *MarketMaker) handleMatchUpdate(match *core.Match, oid dex.Bytes) { + var matchID order.MatchID + copy(matchID[:], match.MatchID) + + orderInfo := m.getOrderInfo(oid) + if orderInfo == nil { + m.log.Errorf("did not find order info for order %s", oid) + return + } + + if _, seen := orderInfo.matchesSeen[matchID]; !seen { + orderInfo.matchesSeen[matchID] = struct{}{} + + var maxRedeemFees uint64 + if orderInfo.initialRedeemFeesLocked == 0 { + numLots := match.Qty / orderInfo.lotSize + maxRedeemFees = numLots * orderInfo.singleLotRedeemFees + } + + var balanceMods []*balanceMod + if orderInfo.order.Sell { + balanceMods = []*balanceMod{ + {balanceModDecrease, orderInfo.order.BaseID, balTypeFundingOrder, match.Qty}, + {balanceModIncrease, orderInfo.order.QuoteID, balTypePendingRedeem, calc.BaseToQuote(match.Rate, match.Qty) - maxRedeemFees}, + } + } else { + balanceMods = []*balanceMod{ + {balanceModDecrease, orderInfo.order.QuoteID, balTypeFundingOrder, calc.BaseToQuote(match.Rate, match.Qty)}, + {balanceModIncrease, orderInfo.order.BaseID, balTypePendingRedeem, match.Qty - maxRedeemFees}, + } + } + + m.modifyBotBalance(orderInfo.bot, balanceMods) + } + + if match.Status != order.MatchConfirmed && match.Refund == nil { + return + } + + if _, handled := orderInfo.matchesSettled[matchID]; handled { + return + } + + orderInfo.matchesSettled[matchID] = struct{}{} + + if match.Refund != nil { + // TODO: Currently refunds are not handled properly. Core gives no way to + // retrieve the refund fee. Core will need to make this information available, + // and then the fee will need to be taken into account before increasing the + // bot's balance. Also, currently we are not detecting that a refund will happen, + // only that it has already happened. When a match has been revoked, the bot's + // PendingRefund balance must be increased, and the PendingRedeem amount must be + // decreased. + + var maxRedeemFees uint64 + if orderInfo.initialRedeemFeesLocked == 0 { + numLots := match.Qty / orderInfo.lotSize + maxRedeemFees = numLots * orderInfo.singleLotRedeemFees + } + + var balanceMods []*balanceMod + if orderInfo.order.Sell { + balanceMods = []*balanceMod{ + {balanceModDecrease, orderInfo.order.QuoteID, balTypePendingRedeem, calc.BaseToQuote(match.Rate, match.Qty) - maxRedeemFees}, + {balanceModIncrease, orderInfo.order.BaseID, balTypeAvailable, match.Qty}, + } + } else { + balanceMods = []*balanceMod{ + {balanceModDecrease, orderInfo.order.BaseID, balTypePendingRedeem, match.Qty - maxRedeemFees}, + {balanceModIncrease, orderInfo.order.QuoteID, balTypeAvailable, calc.BaseToQuote(match.Rate, match.Qty)}, + } + } + m.log.Tracef("oid: %s, increasing balance due to refund") + m.modifyBotBalance(orderInfo.bot, balanceMods) + } else { + redeemAsset := orderInfo.order.BaseID + redeemQty := match.Qty + if orderInfo.order.Sell { + redeemAsset = orderInfo.order.QuoteID + redeemQty = calc.BaseToQuote(match.Rate, redeemQty) + } + + var maxRedeemFees uint64 + if orderInfo.initialRedeemFeesLocked == 0 { + numLots := match.Qty / orderInfo.lotSize + maxRedeemFees = numLots * orderInfo.singleLotRedeemFees + } + m.log.Tracef("oid: %s, increasing balance due to redeem, redeemQty - %v, maxRedeemFees - %v", oid, redeemQty, maxRedeemFees) + + balanceMods := []*balanceMod{ + {balanceModDecrease, redeemAsset, balTypePendingRedeem, redeemQty - maxRedeemFees}, + {balanceModIncrease, redeemAsset, balTypeAvailable, redeemQty - maxRedeemFees}, + } + m.modifyBotBalance(orderInfo.bot, balanceMods) + } + + if orderInfo.finishedProcessing() { + m.removeOrderInfo(oid) + } +} + +// handleOrderNotification checks if any funds are ready to be made available +// for use by a bot depending on the order's state. +// - If any matches are +// in the confirmed state that have not yet been processed, the redeemed +// or refunded amount is added to the bot's balance. +// - If the order is no longer booked, the difference between the order's +// quantity and the amount that was matched can be returned to the bot. +// - If all fees have been confirmed, the rest of the difference between +// the amount the at was initially locked and the amount that was used +// can be returned. +func (m *MarketMaker) handleOrderUpdate(o *core.Order) { + orderInfo := m.getOrderInfo(o.ID) + if orderInfo == nil { + return + } + + orderInfo.order = o + + // Step 2/3 (from botBalance doc): add redeem/refund amount to balance + for _, match := range o.Matches { + m.handleMatchUpdate(match, o.ID) + } + + if o.Status <= order.OrderStatusBooked || // Not ready to process + (orderInfo.unusedLockedFundsReturned && orderInfo.excessFeesReturned) || // Complete + (orderInfo.unusedLockedFundsReturned && !orderInfo.excessFeesReturned && !o.AllFeesConfirmed) { // Step 1 complete, but not ready for step 2 + return + } + + fromAsset, toAsset := o.BaseID, o.QuoteID + if !o.Sell { + fromAsset, toAsset = toAsset, fromAsset + } + + var filledQty, filledLots uint64 + for _, match := range o.Matches { + if match.IsCancel { + continue + } + + filledLots += match.Qty / orderInfo.lotSize + if fromAsset == o.QuoteID { + filledQty += calc.BaseToQuote(match.Rate, match.Qty) + } else { + filledQty += match.Qty + } + } + + // Step 4 (from botBalance doc): OrderStatus > Booked - return over locked amount + if !orderInfo.unusedLockedFundsReturned { + maxSwapFees := filledLots * orderInfo.singleLotSwapFees + usedFunds := filledQty + maxSwapFees + if usedFunds < orderInfo.initialFundsLocked { + m.log.Tracef("oid: %s, returning unused locked funds, initialFundsLocked %v, filledQty %v, filledLots %v, maxSwapFees %v", + o.ID, orderInfo.initialFundsLocked, filledQty, filledLots, maxSwapFees) + + balanceMods := []*balanceMod{ + {balanceModIncrease, fromAsset, balTypeAvailable, orderInfo.initialFundsLocked - usedFunds}, + {balanceModDecrease, fromAsset, balTypeFundingOrder, orderInfo.initialFundsLocked - usedFunds}, + } + m.modifyBotBalance(orderInfo.bot, balanceMods) + } else { + m.log.Errorf("oid: %v - usedFunds %d >= initialFundsLocked %d", + hex.EncodeToString(o.ID), orderInfo.initialFundsLocked) + } + + orderInfo.unusedLockedFundsReturned = true + } + + // Step 5 (from botBalance doc): All Fees Confirmed - return excess swap and redeem fees + if !orderInfo.excessFeesReturned && o.AllFeesConfirmed { + // Return excess swap fees + maxSwapFees := filledLots * orderInfo.singleLotSwapFees + if maxSwapFees > o.FeesPaid.Swap { + m.log.Tracef("oid: %s, return excess swap fees, maxSwapFees %v, swap fees %v", o.ID, maxSwapFees, o.FeesPaid.Swap) + balanceMods := []*balanceMod{ + {balanceModIncrease, fromAsset, balTypeAvailable, maxSwapFees - o.FeesPaid.Swap}, + {balanceModDecrease, fromAsset, balTypeFundingOrder, maxSwapFees}, + } + m.modifyBotBalance(orderInfo.bot, balanceMods) + } else if maxSwapFees < o.FeesPaid.Swap { + m.log.Errorf("oid: %v - maxSwapFees %d < swap fees %d", hex.EncodeToString(o.ID), maxSwapFees, o.FeesPaid.Swap) + } + + // Return excess redeem fees + if orderInfo.initialRedeemFeesLocked > 0 { // AccountLocker + if orderInfo.initialRedeemFeesLocked > o.FeesPaid.Redemption { + m.log.Tracef("oid: %s, return excess redeem fees (accountLocker), initialRedeemFeesLocked %v, redemption fees %v", + o.ID, orderInfo.initialRedeemFeesLocked, o.FeesPaid.Redemption) + balanceMods := []*balanceMod{ + {balanceModIncrease, toAsset, balTypeAvailable, orderInfo.initialRedeemFeesLocked - o.FeesPaid.Redemption}, + {balanceModDecrease, toAsset, balTypeFundingOrder, orderInfo.initialRedeemFeesLocked}, + } + m.modifyBotBalance(orderInfo.bot, balanceMods) + } else { + m.log.Errorf("oid: %v - initialRedeemFeesLocked %d > redemption fees %d", + hex.EncodeToString(o.ID), orderInfo.initialRedeemFeesLocked, o.FeesPaid.Redemption) + } + } else { + maxRedeemFees := filledLots * orderInfo.singleLotRedeemFees + if maxRedeemFees > o.FeesPaid.Redemption { + m.log.Tracef("oid: %s, return excess redeem fees, maxRedeemFees %v, redemption fees %v", o.ID, maxRedeemFees, o.FeesPaid.Redemption) + balanceMods := []*balanceMod{ + {balanceModIncrease, toAsset, balTypeAvailable, maxRedeemFees - o.FeesPaid.Redemption}, + } + m.modifyBotBalance(orderInfo.bot, balanceMods) + } else if maxRedeemFees < o.FeesPaid.Redemption { + m.log.Errorf("oid: %v - maxRedeemFees %d < redemption fees %d", + hex.EncodeToString(o.ID), maxRedeemFees, o.FeesPaid.Redemption) + } + } + + orderInfo.excessFeesReturned = true + } + + if orderInfo.finishedProcessing() { + m.removeOrderInfo(o.ID) + } +} + +func (m *MarketMaker) handleNotification(n core.Notification) { + switch note := n.(type) { + case *core.OrderNote: + m.handleOrderUpdate(note.Order) + case *core.MatchNote: + m.handleMatchUpdate(note.Match, note.OrderID) + } +} + // Run starts the MarketMaker. There can only be one BotConfig per dex market. func (m *MarketMaker) Run(ctx context.Context, cfgs []*BotConfig, pw []byte) error { if !m.running.CompareAndSwap(false, true) { @@ -88,34 +714,54 @@ func (m *MarketMaker) Run(ctx context.Context, cfgs []*BotConfig, pw []byte) err m.ctx, m.die = context.WithCancel(ctx) - if err := duplicateBotConfig(cfgs); err != nil { + enabledCfgs, err := validateAndFilterEnabledConfigs(cfgs) + if err != nil { + return err + } + + if err := m.loginAndUnlockWallets(pw, enabledCfgs); err != nil { return err } - err := m.core.Login(pw) + oracle, err := priceOracleFromConfigs(m.ctx, enabledCfgs, m.log.SubLogger("PriceOracle")) if err != nil { - return fmt.Errorf("failed to login: %w", err) + return err } - var oracle *priceOracle - marketsRequiringOracle := marketsRequiringPriceOracle(cfgs) - if len(marketsRequiringOracle) > 0 { - oracle, err = newPriceOracle(m.ctx, marketsRequiringOracle, m.log.SubLogger("PriceOracle")) - if err != nil { - return fmt.Errorf("failed to create PriceOracle: %v", err) - } + if err := m.setupBalances(enabledCfgs); err != nil { + return err } startedMarketMaking = true wg := new(sync.WaitGroup) - for _, cfg := range cfgs { + + // Listen for core notifications. + wg.Add(1) + go func() { + defer wg.Done() + feed := m.core.NotificationFeed() + defer feed.ReturnFeed() + + for { + select { + case <-m.ctx.Done(): + return + case n := <-feed.C: + m.handleNotification(n) + } + } + }() + + // Start each bot. + for _, cfg := range enabledCfgs { switch { case cfg.MMCfg != nil: wg.Add(1) go func(cfg *BotConfig) { logger := m.log.SubLogger(fmt.Sprintf("MarketMaker-%s-%d-%d", cfg.Host, cfg.BaseAsset, cfg.QuoteAsset)) - RunBasicMarketMaker(m.ctx, cfg, m.core, oracle, pw, logger) + mktID := dexMarketID(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) + RunBasicMarketMaker(m.ctx, cfg, m.wrappedCoreForBot(mktID), oracle, pw, logger) wg.Done() }(cfg) default: @@ -138,12 +784,3 @@ func (m *MarketMaker) Stop() { m.die() } } - -// NewMarketMaker creates a new MarketMaker. -func NewMarketMaker(core *core.Core, log dex.Logger) (*MarketMaker, error) { - return &MarketMaker{ - core: core, - log: log, - running: atomic.Bool{}, - }, nil -} diff --git a/client/mm/mm_basic.go b/client/mm/mm_basic.go index e6b1569d44..ba253b1f55 100644 --- a/client/mm/mm_basic.go +++ b/client/mm/mm_basic.go @@ -513,7 +513,7 @@ func (m *basicMarketMaker) rebalance(newEpoch uint64) { // they do. Maybe consider a constant error asset.InsufficientBalance. maxOrder, err := m.core.MaxBuy(m.host, m.base, m.quote, buyPrice) if err != nil { - m.log.Tracef("MaxBuy error: %v", err) + m.log.Errorf("MaxBuy error: %v", err) } else { maxBuyLots = int(maxOrder.Swap.Lots) } @@ -530,7 +530,7 @@ func (m *basicMarketMaker) rebalance(newEpoch uint64) { var maxLots int maxOrder, err := m.core.MaxSell(m.host, m.base, m.quote) if err != nil { - m.log.Tracef("MaxSell error: %v", err) + m.log.Errorf("MaxSell error: %v", err) } else { maxLots = int(maxOrder.Swap.Lots) } diff --git a/client/mm/mm_test.go b/client/mm/mm_test.go new file mode 100644 index 0000000000..636de40ac3 --- /dev/null +++ b/client/mm/mm_test.go @@ -0,0 +1,2597 @@ +package mm + +import ( + "context" + "fmt" + "math/rand" + "reflect" + "testing" + "time" + + "decred.org/dcrdex/client/asset" + "decred.org/dcrdex/client/core" + "decred.org/dcrdex/client/db" + "decred.org/dcrdex/client/orderbook" + "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/calc" + "decred.org/dcrdex/dex/encode" + "decred.org/dcrdex/dex/order" +) + +var ( + tUTXOAssetA = &dex.Asset{ + ID: 42, + Symbol: "dcr", + Version: 0, // match the stubbed (*TXCWallet).Info result + SwapSize: 251, + SwapSizeBase: 85, + RedeemSize: 200, + MaxFeeRate: 10, + SwapConf: 1, + } + + tUTXOAssetB = &dex.Asset{ + ID: 0, + Symbol: "btc", + Version: 0, // match the stubbed (*TXCWallet).Info result + SwapSize: 225, + SwapSizeBase: 76, + RedeemSize: 260, + MaxFeeRate: 2, + SwapConf: 1, + } + tACCTAsset = &dex.Asset{ + ID: 60, + Symbol: "eth", + Version: 0, // match the stubbed (*TXCWallet).Info result + SwapSize: 135000, + SwapSizeBase: 135000, + RedeemSize: 68000, + MaxFeeRate: 20, + SwapConf: 1, + } + tWalletInfo = &asset.WalletInfo{ + Version: 0, + SupportedVersions: []uint32{0}, + UnitInfo: dex.UnitInfo{ + Conventional: dex.Denomination{ + ConversionFactor: 1e8, + }, + }, + AvailableWallets: []*asset.WalletDefinition{{ + Type: "type", + }}, + } +) + +type tCreator struct { + *tDriver + doesntExist bool + existsErr error + createErr error +} + +func (ctr *tCreator) Exists(walletType, dataDir string, settings map[string]string, net dex.Network) (bool, error) { + return !ctr.doesntExist, ctr.existsErr +} + +func (ctr *tCreator) Create(*asset.CreateWalletParams) error { + return ctr.createErr +} + +func init() { + asset.Register(tUTXOAssetA.ID, &tDriver{ + decodedCoinID: tUTXOAssetA.Symbol + "-coin", + winfo: tWalletInfo, + }) + asset.Register(tUTXOAssetB.ID, &tCreator{ + tDriver: &tDriver{ + decodedCoinID: tUTXOAssetB.Symbol + "-coin", + winfo: tWalletInfo, + }, + }) + asset.Register(tACCTAsset.ID, &tCreator{ + tDriver: &tDriver{ + decodedCoinID: tACCTAsset.Symbol + "-coin", + winfo: tWalletInfo, + }, + }) + rand.Seed(time.Now().UnixNano()) +} + +type tDriver struct { + wallet asset.Wallet + decodedCoinID string + winfo *asset.WalletInfo +} + +func (drv *tDriver) Open(cfg *asset.WalletConfig, logger dex.Logger, net dex.Network) (asset.Wallet, error) { + return drv.wallet, nil +} + +func (drv *tDriver) DecodeCoinID(coinID []byte) (string, error) { + return drv.decodedCoinID, nil +} + +func (drv *tDriver) Info() *asset.WalletInfo { + return drv.winfo +} + +type tCore struct { + assetBalances map[uint32]*core.WalletBalance + assetBalanceErr error + market *core.Market + orderEstimate *core.OrderEstimate + sellSwapFees, sellRedeemFees uint64 + buySwapFees, buyRedeemFees uint64 + singleLotFeesErr error + preOrderParam *core.TradeForm + tradeResult *core.Order + multiTradeResult []*core.Order + noteFeed chan core.Notification + isAccountLocker map[uint32]bool + maxBuyEstimate *core.MaxOrderEstimate + maxBuyErr error + maxSellEstimate *core.MaxOrderEstimate + maxSellErr error + cancelsPlaced []dex.Bytes + buysPlaced []*core.TradeForm + sellsPlaced []*core.TradeForm + maxFundingFees uint64 +} + +func (c *tCore) NotificationFeed() *core.NoteFeed { + return &core.NoteFeed{C: c.noteFeed} +} +func (c *tCore) ExchangeMarket(host string, base, quote uint32) (*core.Market, error) { + return c.market, nil +} + +type tBookFeed struct{} + +func (t *tBookFeed) Next() <-chan *core.BookUpdate { return make(<-chan *core.BookUpdate) } +func (t *tBookFeed) Close() {} +func (t *tBookFeed) Candles(string) error { return nil } + +var _ core.BookFeed = (*tBookFeed)(nil) + +func (*tCore) SyncBook(host string, base, quote uint32) (*orderbook.OrderBook, core.BookFeed, error) { + return nil, &tBookFeed{}, nil +} +func (*tCore) SupportedAssets() map[uint32]*core.SupportedAsset { + return nil +} +func (c *tCore) SingleLotFees(form *core.SingleLotFeesForm) (uint64, uint64, error) { + if c.singleLotFeesErr != nil { + return 0, 0, c.singleLotFeesErr + } + if form.Sell { + return c.sellSwapFees, c.sellRedeemFees, nil + } + return c.buySwapFees, c.buyRedeemFees, nil +} +func (*tCore) Cancel(oidB dex.Bytes) error { + return nil +} +func (c *tCore) Trade(pw []byte, form *core.TradeForm) (*core.Order, error) { + return c.tradeResult, nil +} +func (*tCore) MaxBuy(host string, base, quote uint32, rate uint64) (*core.MaxOrderEstimate, error) { + return nil, nil +} +func (*tCore) MaxSell(host string, base, quote uint32) (*core.MaxOrderEstimate, error) { + return nil, nil +} +func (c *tCore) AssetBalance(assetID uint32) (*core.WalletBalance, error) { + return c.assetBalances[assetID], c.assetBalanceErr +} +func (c *tCore) PreOrder(form *core.TradeForm) (*core.OrderEstimate, error) { + c.preOrderParam = form + return c.orderEstimate, nil +} +func (c *tCore) MultiTrade(pw []byte, forms *core.MultiTradeForm) ([]*core.Order, error) { + return c.multiTradeResult, nil +} +func (c *tCore) WalletState(assetID uint32) *core.WalletState { + isAccountLocker := c.isAccountLocker[assetID] + + var traits asset.WalletTrait + if isAccountLocker { + traits |= asset.WalletTraitAccountLocker + } + + return &core.WalletState{ + Traits: traits, + } +} +func (c *tCore) MaxFundingFees(fromAsset uint32, numTrades uint32, options map[string]string) (uint64, error) { + return c.maxFundingFees, nil +} +func (c *tCore) User() *core.User { + return nil +} +func (c *tCore) Login(pw []byte) error { + return nil +} +func (c *tCore) OpenWallet(assetID uint32, pw []byte) error { + return nil +} + +var _ clientCore = (*tCore)(nil) + +func (c *tCore) setAssetBalances(balances map[uint32]uint64) { + c.assetBalances = make(map[uint32]*core.WalletBalance) + for assetID, bal := range balances { + c.assetBalances[assetID] = &core.WalletBalance{ + Balance: &db.Balance{ + Balance: asset.Balance{ + Available: bal, + }, + }, + } + } +} + +func newTCore() *tCore { + return &tCore{ + assetBalances: make(map[uint32]*core.WalletBalance), + noteFeed: make(chan core.Notification), + isAccountLocker: make(map[uint32]bool), + } +} + +type tOrderBook struct { + midGap uint64 + midGapErr error +} + +func (o *tOrderBook) MidGap() (uint64, error) { + if o.midGapErr != nil { + return 0, o.midGapErr + } + return o.midGap, nil +} + +type tOracle struct { + marketPrice float64 +} + +func (o *tOracle) getMarketPrice(base, quote uint32) float64 { + return o.marketPrice +} + +var tLogger = dex.StdOutLogger("mm_TEST", dex.LevelTrace) + +func TestSetupBalances(t *testing.T) { + tCore := newTCore() + + dcrBtcID := fmt.Sprintf("%s-%d-%d", "host1", 42, 0) + dcrEthID := fmt.Sprintf("%s-%d-%d", "host1", 42, 60) + + tests := []struct { + name string + cfgs []*BotConfig + assetBalances map[uint32]uint64 + + wantReserves map[string]map[uint32]uint64 + wantErr bool + }{ + { + name: "percentages only, ok", + cfgs: []*BotConfig{ + { + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + }, + { + Host: "host1", + BaseAsset: 42, + QuoteAsset: 60, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + }, + }, + + assetBalances: map[uint32]uint64{ + 0: 1000, + 42: 1000, + 60: 2000, + }, + + wantReserves: map[string]map[uint32]uint64{ + dcrBtcID: { + 0: 500, + 42: 500, + }, + dcrEthID: { + 42: 500, + 60: 2000, + }, + }, + }, + + { + name: "50% + 51% error", + cfgs: []*BotConfig{ + { + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + }, + { + Host: "host1", + BaseAsset: 42, + QuoteAsset: 60, + BaseBalanceType: Percentage, + BaseBalance: 51, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + }, + }, + + assetBalances: map[uint32]uint64{ + 0: 1000, + 42: 1000, + 60: 2000, + }, + + wantErr: true, + }, + + { + name: "combine amount and percentages, ok", + cfgs: []*BotConfig{ + { + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 499, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + }, + { + Host: "host1", + BaseAsset: 42, + QuoteAsset: 60, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + }, + }, + + assetBalances: map[uint32]uint64{ + 0: 1000, + 42: 1000, + 60: 2000, + }, + + wantReserves: map[string]map[uint32]uint64{ + dcrBtcID: { + 0: 500, + 42: 499, + }, + dcrEthID: { + 42: 500, + 60: 2000, + }, + }, + }, + { + name: "combine amount and percentages, too high error", + cfgs: []*BotConfig{ + { + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 501, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + }, + { + Host: "host1", + BaseAsset: 42, + QuoteAsset: 60, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + }, + }, + + assetBalances: map[uint32]uint64{ + 0: 1000, + 42: 1000, + 60: 2000, + }, + + wantErr: true, + }, + } + + for _, test := range tests { + tCore.setAssetBalances(test.assetBalances) + + mm, err := NewMarketMaker(tCore, tLogger) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + + err = mm.setupBalances(test.cfgs) + if test.wantErr { + if err == nil { + t.Fatalf("%s: expected error, got nil", test.name) + } + continue + } + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + + for botID, wantReserve := range test.wantReserves { + botReserves := mm.botBalances[botID] + for assetID, wantReserve := range wantReserve { + if botReserves.balances[assetID].Available != wantReserve { + t.Fatalf("%s: unexpected reserve for bot %s, asset %d. "+ + "want %d, got %d", test.name, botID, assetID, wantReserve, + botReserves.balances[assetID]) + } + } + } + } +} + +func TestSegregatedCoreMaxSell(t *testing.T) { + tCore := newTCore() + tCore.isAccountLocker[60] = true + dcrBtcID := fmt.Sprintf("%s-%d-%d", "host1", 42, 0) + dcrEthID := fmt.Sprintf("%s-%d-%d", "host1", 42, 60) + + // Whatever is returned from PreOrder is returned from this function. + // What we need to test is what is passed to PreOrder. + orderEstimate := &core.OrderEstimate{ + Swap: &asset.PreSwap{ + Estimate: &asset.SwapEstimate{ + Lots: 5, + Value: 5e8, + MaxFees: 1600, + RealisticWorstCase: 12010, + RealisticBestCase: 6008, + }, + }, + Redeem: &asset.PreRedeem{ + Estimate: &asset.RedeemEstimate{ + RealisticBestCase: 2800, + RealisticWorstCase: 6500, + }, + }, + } + tCore.orderEstimate = orderEstimate + + expectedResult := &core.MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + Value: 5e8, + MaxFees: 1600, + RealisticWorstCase: 12010, + RealisticBestCase: 6008, + }, + Redeem: &asset.RedeemEstimate{ + RealisticBestCase: 2800, + RealisticWorstCase: 6500, + }, + } + + tests := []struct { + name string + cfg *BotConfig + assetBalances map[uint32]uint64 + market *core.Market + swapFees uint64 + redeemFees uint64 + + expectPreOrderParam *core.TradeForm + wantErr bool + }{ + { + name: "ok", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + }, + assetBalances: map[uint32]uint64{ + 0: 1e7, + 42: 1e7, + }, + market: &core.Market{ + LotSize: 1e6, + }, + expectPreOrderParam: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 0, + Sell: true, + Qty: 4 * 1e6, + }, + swapFees: 1000, + redeemFees: 1000, + }, + { + name: "1 lot", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 1e6 + 1000, + QuoteBalanceType: Amount, + QuoteBalance: 1000, + }, + assetBalances: map[uint32]uint64{ + 0: 1e7, + 42: 1e7, + }, + market: &core.Market{ + LotSize: 1e6, + }, + expectPreOrderParam: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 0, + Sell: true, + Qty: 1e6, + }, + swapFees: 1000, + redeemFees: 1000, + }, + { + name: "not enough for 1 swap", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 1e6 + 999, + QuoteBalanceType: Amount, + QuoteBalance: 1000, + }, + assetBalances: map[uint32]uint64{ + 0: 1e7, + 42: 1e7, + }, + market: &core.Market{ + LotSize: 1e6, + }, + swapFees: 1000, + redeemFees: 1000, + wantErr: true, + }, + { + name: "not enough for 1 lot of redeem fees", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 60, + BaseBalanceType: Amount, + BaseBalance: 1e6 + 1000, + QuoteBalanceType: Amount, + QuoteBalance: 999, + }, + assetBalances: map[uint32]uint64{ + 42: 1e7, + 60: 1e7, + }, + market: &core.Market{ + LotSize: 1e6, + }, + swapFees: 1000, + redeemFees: 1000, + wantErr: true, + }, + { + name: "redeem fees don't matter if not account locker", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 1e6 + 1000, + QuoteBalanceType: Amount, + QuoteBalance: 999, + }, + assetBalances: map[uint32]uint64{ + 42: 1e7, + 0: 1e7, + }, + market: &core.Market{ + LotSize: 1e6, + }, + swapFees: 1000, + redeemFees: 1000, + expectPreOrderParam: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 0, + Sell: true, + Qty: 1e6, + }, + }, + } + + for _, test := range tests { + tCore.setAssetBalances(test.assetBalances) + tCore.market = test.market + tCore.sellSwapFees = test.swapFees + tCore.sellRedeemFees = test.redeemFees + + mm, err := NewMarketMaker(tCore, tLogger) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + + err = mm.setupBalances([]*BotConfig{test.cfg}) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + + mkt := dcrBtcID + if test.cfg.QuoteAsset == 60 { + mkt = dcrEthID + } + + segregatedCore := mm.wrappedCoreForBot(mkt) + res, err := segregatedCore.MaxSell("host1", test.cfg.BaseAsset, test.cfg.QuoteAsset) + if test.wantErr { + if err == nil { + t.Fatalf("%s: expected error but did not get", test.name) + } + continue + } + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + + if !reflect.DeepEqual(tCore.preOrderParam, test.expectPreOrderParam) { + t.Fatalf("%s: expected pre order param %+v != actual %+v", test.name, test.expectPreOrderParam, tCore.preOrderParam) + } + + if !reflect.DeepEqual(res, expectedResult) { + t.Fatalf("%s: expected max sell result %+v != actual %+v", test.name, expectedResult, res) + } + } +} + +func TestSegregatedCoreMaxBuy(t *testing.T) { + tCore := newTCore() + + tCore.isAccountLocker[60] = true + dcrBtcID := fmt.Sprintf("%s-%d-%d", "host1", 42, 0) + ethBtcID := fmt.Sprintf("%s-%d-%d", "host1", 60, 0) + + // Whatever is returned from PreOrder is returned from this function. + // What we need to test is what is passed to PreOrder. + orderEstimate := &core.OrderEstimate{ + Swap: &asset.PreSwap{ + Estimate: &asset.SwapEstimate{ + Lots: 5, + Value: 5e8, + MaxFees: 1600, + RealisticWorstCase: 12010, + RealisticBestCase: 6008, + }, + }, + Redeem: &asset.PreRedeem{ + Estimate: &asset.RedeemEstimate{ + RealisticBestCase: 2800, + RealisticWorstCase: 6500, + }, + }, + } + tCore.orderEstimate = orderEstimate + + expectedResult := &core.MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + Value: 5e8, + MaxFees: 1600, + RealisticWorstCase: 12010, + RealisticBestCase: 6008, + }, + Redeem: &asset.RedeemEstimate{ + RealisticBestCase: 2800, + RealisticWorstCase: 6500, + }, + } + + tests := []struct { + name string + cfg *BotConfig + assetBalances map[uint32]uint64 + market *core.Market + rate uint64 + swapFees uint64 + redeemFees uint64 + + expectPreOrderParam *core.TradeForm + wantErr bool + }{ + { + name: "ok", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + }, + rate: 5e7, + assetBalances: map[uint32]uint64{ + 0: 1e7, + 42: 1e7, + }, + market: &core.Market{ + LotSize: 1e6, + }, + expectPreOrderParam: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 0, + Sell: false, + Rate: 5e7, + Qty: 9 * 1e6, + }, + swapFees: 1000, + redeemFees: 1000, + }, + { + name: "1 lot", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 1000, + QuoteBalanceType: Amount, + QuoteBalance: (1e6 * 5e7 / 1e8) + 1000, + }, + rate: 5e7, + assetBalances: map[uint32]uint64{ + 0: 1e7, + 42: 1e7, + }, + market: &core.Market{ + LotSize: 1e6, + }, + expectPreOrderParam: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 0, + Sell: false, + Qty: 1e6, + Rate: 5e7, + }, + swapFees: 1000, + redeemFees: 1000, + }, + { + name: "not enough for 1 swap", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 1000, + QuoteBalanceType: Amount, + QuoteBalance: (1e6 * 5e7 / 1e8) + 999, + }, + rate: 5e7, + assetBalances: map[uint32]uint64{ + 0: 1e7, + 42: 1e7, + }, + market: &core.Market{ + LotSize: 1e6, + }, + swapFees: 1000, + redeemFees: 1000, + wantErr: true, + }, + { + name: "not enough for 1 lot of redeem fees", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 60, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 999, + QuoteBalanceType: Amount, + QuoteBalance: (1e6 * 5e7 / 1e8) + 1000, + }, + rate: 5e7, + assetBalances: map[uint32]uint64{ + 0: 1e7, + 60: 1e7, + }, + market: &core.Market{ + LotSize: 1e6, + }, + swapFees: 1000, + redeemFees: 1000, + wantErr: true, + }, + { + name: "only account locker affected by redeem fees", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 999, + QuoteBalanceType: Amount, + QuoteBalance: (1e6 * 5e7 / 1e8) + 1000, + }, + rate: 5e7, + assetBalances: map[uint32]uint64{ + 0: 1e7, + 42: 1e7, + }, + market: &core.Market{ + LotSize: 1e6, + }, + swapFees: 1000, + redeemFees: 1000, + expectPreOrderParam: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 0, + Sell: false, + Qty: 1e6, + Rate: 5e7, + }, + }, + } + + for _, test := range tests { + tCore.setAssetBalances(test.assetBalances) + tCore.market = test.market + tCore.buySwapFees = test.swapFees + tCore.buyRedeemFees = test.redeemFees + + mm, err := NewMarketMaker(tCore, tLogger) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + + err = mm.setupBalances([]*BotConfig{test.cfg}) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + + mkt := dcrBtcID + if test.cfg.BaseAsset != 42 { + mkt = ethBtcID + } + segregatedCore := mm.wrappedCoreForBot(mkt) + res, err := segregatedCore.MaxBuy("host1", test.cfg.BaseAsset, test.cfg.QuoteAsset, test.rate) + if test.wantErr { + if err == nil { + t.Fatalf("%s: expected error but did not get", test.name) + } + continue + } + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + + if !reflect.DeepEqual(tCore.preOrderParam, test.expectPreOrderParam) { + t.Fatalf("%s: expected pre order param %+v != actual %+v", test.name, test.expectPreOrderParam, tCore.preOrderParam) + } + + if !reflect.DeepEqual(res, expectedResult) { + t.Fatalf("%s: expected max buy result %+v != actual %+v", test.name, expectedResult, res) + } + } +} + +func assetBalancesMatch(expected map[uint32]*botBalance, botName string, mm *MarketMaker) error { + for assetID, exp := range expected { + actual := mm.botBalances[botName].balances[assetID] + if !reflect.DeepEqual(exp, actual) { + return fmt.Errorf("asset %d expected %+v != actual %+v\n", assetID, exp, actual) + } + } + return nil +} + +func TestSegregatedCoreTrade(t *testing.T) { + t.Run("single trade", func(t *testing.T) { + testSegregatedCoreTrade(t, false) + }) + t.Run("multi trade", func(t *testing.T) { + testSegregatedCoreTrade(t, true) + }) +} + +func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { + dcrBtcID := fmt.Sprintf("%s-%d-%d", "host1", 42, 0) + + id := encode.RandomBytes(order.OrderIDSize) + id2 := encode.RandomBytes(order.OrderIDSize) + + matchIDs := make([]order.MatchID, 5) + for i := range matchIDs { + var matchID order.MatchID + copy(matchID[:], encode.RandomBytes(order.MatchIDSize)) + matchIDs[i] = matchID + } + + type noteAndBalances struct { + note core.Notification + balance map[uint32]*botBalance + } + + type test struct { + name string + multiTradeOnly bool + + cfg *BotConfig + multiTrade *core.MultiTradeForm + trade *core.TradeForm + assetBalances map[uint32]uint64 + postTradeBalances map[uint32]*botBalance + market *core.Market + swapFees uint64 + redeemFees uint64 + tradeRes *core.Order + multiTradeRes []*core.Order + notifications []*noteAndBalances + isAccountLocker map[uint32]bool + maxFundingFees uint64 + + wantErr bool + } + + tests := []test{ + // "cancelled order, 1/2 lots filled, sell" + { + name: "cancelled order, 1/2 lots filled, sell", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + }, + assetBalances: map[uint32]uint64{ + 0: 1e7, + 42: 1e7, + }, + trade: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 0, + Sell: true, + Qty: 2e6, + Rate: 5e7, + }, + market: &core.Market{ + LotSize: 1e6, + }, + swapFees: 1000, + redeemFees: 1000, + tradeRes: &core.Order{ + ID: id, + LockedAmt: 2e6 + 2000, + RedeemLockedAmt: 2000, + Sell: true, + }, + postTradeBalances: map[uint32]*botBalance{ + 0: { + Available: (1e7 / 2) - 2000, + FundingOrder: 2000, + }, + 42: { + Available: (1e7 / 2) - 2e6 - 2000, + FundingOrder: 2e6 + 2000, + }, + }, + notifications: []*noteAndBalances{ + { + note: &core.OrderNote{ + Order: &core.Order{ + ID: id, + Status: order.OrderStatusBooked, + BaseID: 42, + QuoteID: 0, + Qty: 2e6, + Sell: true, + Filled: 1e6, + Matches: []*core.Match{ + { + MatchID: matchIDs[0][:], + Qty: 1e6, + Rate: 5e7, + Status: order.MakerSwapCast, + }, + }, + }, + }, + balance: map[uint32]*botBalance{ + 0: { + Available: (1e7 / 2) - 2000, + PendingRedeem: calc.BaseToQuote(5e7, 1e6), + FundingOrder: 2000, + }, + 42: { + Available: (1e7 / 2) - 2e6 - 2000, + FundingOrder: 1e6 + 2000, + }, + }, + }, + { + note: &core.MatchNote{ + OrderID: id, + Match: &core.Match{ + MatchID: matchIDs[0][:], + Qty: 1e6, + Rate: 5e7, + Status: order.MatchComplete, + }, + }, + balance: map[uint32]*botBalance{ + 0: { + Available: (1e7 / 2) - 2000, + PendingRedeem: calc.BaseToQuote(5e7, 1e6), + FundingOrder: 2000, + }, + 42: { + Available: (1e7 / 2) - 2e6 - 2000, + FundingOrder: 1e6 + 2000, + }, + }, + }, + { + note: &core.MatchNote{ + OrderID: id, + Match: &core.Match{ + MatchID: matchIDs[0][:], + Qty: 1e6, + Rate: 5e7, + Status: order.MatchConfirmed, + Redeem: &core.Coin{}, + }, + }, + balance: map[uint32]*botBalance{ + 0: { + Available: (1e7 / 2) - 2000 + calc.BaseToQuote(5e7, 1e6), + FundingOrder: 2000, + }, + 42: { + Available: (1e7 / 2) - 2e6 - 2000, + FundingOrder: 1e6 + 2000, + }, + }, + }, + { + note: &core.OrderNote{ + Order: &core.Order{ + ID: id, + Status: order.OrderStatusCanceled, + BaseID: 42, + QuoteID: 0, + Qty: 2e6, + Sell: true, + Filled: 2e6, + AllFeesConfirmed: true, + FeesPaid: &core.FeeBreakdown{ + Swap: 800, + Redemption: 800, + }, + Matches: []*core.Match{ + { + MatchID: matchIDs[0][:], + Qty: 1e6, + Rate: 5e7, + Status: order.MakerSwapCast, + }, + }, + }, + }, + balance: map[uint32]*botBalance{ + 0: { + Available: (1e7 / 2) + calc.BaseToQuote(5e7, 1e6) - 800, + }, + 42: { + Available: (1e7 / 2) - 1e6 - 800, + }, + }, + }, + }, + }, + // "cancelled order, 1/2 lots filled, buy" + { + name: "cancelled order, 1/2 lots filled, buy", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + }, + assetBalances: map[uint32]uint64{ + 0: 1e7, + 42: 1e7, + }, + trade: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 0, + Sell: false, + Qty: 2e6, + Rate: 5e7, + }, + market: &core.Market{ + LotSize: 1e6, + }, + swapFees: 1000, + redeemFees: 1000, + tradeRes: &core.Order{ + ID: id, + LockedAmt: calc.BaseToQuote(5e7, 2e6) + 2000, + Sell: false, + }, + postTradeBalances: map[uint32]*botBalance{ + 0: { + Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 2e6), + FundingOrder: calc.BaseToQuote(5e7, 2e6) + 2000, + }, + 42: { + Available: (1e7 / 2), + }, + }, + notifications: []*noteAndBalances{ + { + note: &core.OrderNote{ + Order: &core.Order{ + ID: id, + Status: order.OrderStatusBooked, + BaseID: 42, + QuoteID: 0, + Qty: 2e6, + Sell: false, + Filled: 1e6, + Matches: []*core.Match{ + { + MatchID: matchIDs[0][:], + Qty: 1e6, + Rate: 5e7, + Status: order.MakerSwapCast, + }, + }, + }, + }, + balance: map[uint32]*botBalance{ + 0: { + Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 2e6), + FundingOrder: calc.BaseToQuote(5e7, 1e6) + 2000, + }, + 42: { + Available: (1e7 / 2), + PendingRedeem: 1e6 - 1000, + }, + }, + }, + { + note: &core.MatchNote{ + OrderID: id, + Match: &core.Match{ + MatchID: matchIDs[0][:], + Qty: 1e6, + Rate: 5e7, + Status: order.MatchComplete, + }, + }, + balance: map[uint32]*botBalance{ + 0: { + Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 2e6), + FundingOrder: calc.BaseToQuote(5e7, 1e6) + 2000, + }, + 42: { + Available: (1e7 / 2), + PendingRedeem: 1e6 - 1000, + }, + }, + }, + { + note: &core.MatchNote{ + OrderID: id, + Match: &core.Match{ + MatchID: matchIDs[0][:], + Qty: 1e6, + Rate: 5e7, + Status: order.MatchConfirmed, + Redeem: &core.Coin{}, + }, + }, + balance: map[uint32]*botBalance{ + 0: { + Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 2e6), + FundingOrder: calc.BaseToQuote(5e7, 1e6) + 2000, + }, + 42: { + Available: (1e7 / 2) + 1e6 - 1000, + }, + }, + }, + { + note: &core.OrderNote{ + Order: &core.Order{ + ID: id, + Status: order.OrderStatusCanceled, + BaseID: 42, + QuoteID: 0, + Qty: 2e6, + Sell: false, + Filled: 2e6, + AllFeesConfirmed: true, + Rate: 5e7, + FeesPaid: &core.FeeBreakdown{ + Swap: 800, + Redemption: 800, + }, + Matches: []*core.Match{ + { + MatchID: matchIDs[0][:], + Qty: 1e6, + Rate: 5e7, + Status: order.MatchConfirmed, + }, + }, + }, + }, + balance: map[uint32]*botBalance{ + 0: { + Available: (1e7 / 2) - 800 - calc.BaseToQuote(5e7, 1e6), + }, + 42: { + Available: (1e7 / 2) + 1e6 - 800, + }, + }, + }, + }, + }, + // "fully filled order, sell" + { + name: "fully filled order, sell", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + }, + assetBalances: map[uint32]uint64{ + 0: 1e7, + 42: 1e7, + }, + trade: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 0, + Sell: true, + Qty: 2e6, + Rate: 5e7, + }, + market: &core.Market{ + LotSize: 1e6, + }, + swapFees: 1000, + redeemFees: 1000, + tradeRes: &core.Order{ + ID: id, + LockedAmt: 2e6 + 2000, + RedeemLockedAmt: 2000, + Sell: true, + }, + postTradeBalances: map[uint32]*botBalance{ + 0: { + Available: (1e7 / 2) - 2000, + FundingOrder: 2000, + }, + 42: { + Available: (1e7 / 2) - 2e6 - 2000, + FundingOrder: 2e6 + 2000, + }, + }, + notifications: []*noteAndBalances{ + { + note: &core.OrderNote{ + Order: &core.Order{ + ID: id, + Status: order.OrderStatusBooked, + BaseID: 42, + QuoteID: 0, + Qty: 2e6, + Sell: true, + Filled: 1e6, + Matches: []*core.Match{ + { + MatchID: matchIDs[0][:], + Qty: 1e6, + Rate: 5e7, + Status: order.MakerSwapCast, + }, + }, + }, + }, + balance: map[uint32]*botBalance{ + 0: { + Available: (1e7 / 2) - 2000, + FundingOrder: 2000, + PendingRedeem: calc.BaseToQuote(5e7, 1e6), + }, + 42: { + Available: (1e7 / 2) - 2e6 - 2000, + FundingOrder: 1e6 + 2000, + }, + }, + }, + { + note: &core.MatchNote{ + OrderID: id, + Match: &core.Match{ + MatchID: matchIDs[0][:], + Qty: 1e6, + Rate: 5e7, + Status: order.MatchComplete, + }, + }, + balance: map[uint32]*botBalance{ + 0: { + Available: (1e7 / 2) - 2000, + FundingOrder: 2000, + PendingRedeem: calc.BaseToQuote(5e7, 1e6), + }, + 42: { + Available: (1e7 / 2) - 2e6 - 2000, + FundingOrder: 1e6 + 2000, + }, + }, + }, + { + note: &core.MatchNote{ + OrderID: id, + Match: &core.Match{ + MatchID: matchIDs[0][:], + Qty: 1e6, + Rate: 5e7, + Status: order.MatchConfirmed, + Redeem: &core.Coin{}, + }, + }, + balance: map[uint32]*botBalance{ + 0: { + Available: (1e7 / 2) - 2000 + calc.BaseToQuote(5e7, 1e6), + FundingOrder: 2000, + }, + 42: { + Available: (1e7 / 2) - 2e6 - 2000, + FundingOrder: 1e6 + 2000, + }, + }, + }, + { + note: &core.OrderNote{ + Order: &core.Order{ + ID: id, + Status: order.OrderStatusExecuted, + BaseID: 42, + QuoteID: 0, + Qty: 2e6, + Sell: true, + Filled: 2e6, + FeesPaid: &core.FeeBreakdown{ + Swap: 1600, + Redemption: 1600, + }, + Matches: []*core.Match{ + { + MatchID: matchIDs[0][:], + Qty: 1e6, + Rate: 5e7, + Status: order.MatchConfirmed, + }, + { + MatchID: matchIDs[1][:], + Qty: 1e6, + Rate: 55e6, + Status: order.MakerSwapCast, + }, + }, + }, + }, + balance: map[uint32]*botBalance{ + 0: { + Available: (1e7 / 2) - 2000 + calc.BaseToQuote(5e7, 1e6), + FundingOrder: 2000, + PendingRedeem: calc.BaseToQuote(55e6, 1e6), + }, + 42: { + Available: (1e7 / 2) - 2e6 - 2000, + FundingOrder: 2000, + }, + }, + }, + { + note: &core.MatchNote{ + OrderID: id, + Match: &core.Match{ + MatchID: matchIDs[1][:], + Qty: 1e6, + Rate: 55e6, + Status: order.MatchComplete, + Redeem: &core.Coin{}, + }, + }, + balance: map[uint32]*botBalance{ + 0: { + Available: (1e7 / 2) - 2000 + calc.BaseToQuote(5e7, 1e6), + FundingOrder: 2000, + PendingRedeem: calc.BaseToQuote(55e6, 1e6), + }, + 42: { + Available: (1e7 / 2) - 2e6 - 2000, + FundingOrder: 2000, + }, + }, + }, + { + note: &core.MatchNote{ + OrderID: id, + Match: &core.Match{ + MatchID: matchIDs[1][:], + Qty: 1e6, + Rate: 55e6, + Status: order.MatchConfirmed, + Redeem: &core.Coin{}, + }, + }, + balance: map[uint32]*botBalance{ + 0: { + Available: (1e7 / 2) - 2000 + calc.BaseToQuote(5e7, 1e6) + calc.BaseToQuote(55e6, 1e6), + FundingOrder: 2000, + }, + 42: { + Available: (1e7 / 2) - 2e6 - 2000, + FundingOrder: 2000, + }, + }, + }, + { + note: &core.OrderNote{ + Order: &core.Order{ + ID: id, + Status: order.OrderStatusExecuted, + BaseID: 42, + QuoteID: 0, + Qty: 2e6, + Sell: true, + Filled: 2e6, + AllFeesConfirmed: true, + FeesPaid: &core.FeeBreakdown{ + Swap: 1600, + Redemption: 1600, + }, + Matches: []*core.Match{ + { + MatchID: matchIDs[0][:], + Qty: 1e6, + Rate: 5e7, + Status: order.MatchConfirmed, + }, + { + MatchID: matchIDs[1][:], + Qty: 1e6, + Rate: 55e6, + Status: order.MatchConfirmed, + }, + }, + }, + }, + balance: map[uint32]*botBalance{ + 0: { + Available: (1e7 / 2) - 1600 + calc.BaseToQuote(5e7, 1e6) + calc.BaseToQuote(55e6, 1e6), + }, + 42: { + Available: (1e7 / 2) - 2e6 - 1600, + }, + }, + }, + }, + }, + // "fully filled order, buy" + { + name: "fully filled order, buy", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + }, + assetBalances: map[uint32]uint64{ + 0: 1e7, + 42: 1e7, + }, + trade: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 0, + Sell: false, + Qty: 2e6, + Rate: 5e7, + }, + market: &core.Market{ + LotSize: 1e6, + }, + swapFees: 1000, + redeemFees: 1000, + tradeRes: &core.Order{ + ID: id, + LockedAmt: calc.BaseToQuote(5e7, 2e6) + 2000, + Sell: true, + }, + postTradeBalances: map[uint32]*botBalance{ + 0: { + Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 2e6), + FundingOrder: calc.BaseToQuote(5e7, 2e6) + 2000, + }, + 42: { + Available: (1e7 / 2), + }, + }, + notifications: []*noteAndBalances{ + { + note: &core.OrderNote{ + Order: &core.Order{ + ID: id, + Status: order.OrderStatusBooked, + BaseID: 42, + QuoteID: 0, + Qty: 2e6, + Sell: false, + Filled: 1e6, + Matches: []*core.Match{ + { + MatchID: matchIDs[0][:], + Qty: 1e6, + Rate: 5e7, + Status: order.MakerSwapCast, + }, + }, + }, + }, + balance: map[uint32]*botBalance{ + 0: { + Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 2e6), + FundingOrder: calc.BaseToQuote(5e7, 1e6) + 2000, + }, + 42: { + Available: (1e7 / 2), + PendingRedeem: 1e6 - 1000, + }, + }, + }, + { + note: &core.MatchNote{ + OrderID: id, + Match: &core.Match{ + MatchID: matchIDs[0][:], + Qty: 1e6, + Rate: 5e7, + Status: order.MatchComplete, + }, + }, + balance: map[uint32]*botBalance{ + 0: { + Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 2e6), + FundingOrder: calc.BaseToQuote(5e7, 1e6) + 2000, + }, + 42: { + Available: (1e7 / 2), + PendingRedeem: 1e6 - 1000, + }, + }, + }, + { + note: &core.MatchNote{ + OrderID: id, + Match: &core.Match{ + MatchID: matchIDs[0][:], + Qty: 1e6, + Rate: 5e7, + Status: order.MatchConfirmed, + Redeem: &core.Coin{}, + }, + }, + balance: map[uint32]*botBalance{ + 0: { + Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 2e6), + FundingOrder: calc.BaseToQuote(5e7, 1e6) + 2000, + }, + 42: { + Available: (1e7 / 2) - 1000 + 1e6, + }, + }, + }, + { + note: &core.OrderNote{ + Order: &core.Order{ + ID: id, + Status: order.OrderStatusExecuted, + BaseID: 42, + QuoteID: 0, + Qty: 2e6, + Rate: 5e7, + Sell: false, + Filled: 2e6, + FeesPaid: &core.FeeBreakdown{ + Swap: 1600, + Redemption: 1600, + }, + Matches: []*core.Match{ + { + MatchID: matchIDs[0][:], + Qty: 1e6, + Rate: 5e7, + Status: order.MatchConfirmed, + }, + { + MatchID: matchIDs[1][:], + Qty: 1e6, + Rate: 45e6, + Status: order.MakerSwapCast, + }, + }, + }, + }, + balance: map[uint32]*botBalance{ + 0: { + Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 1e6) - calc.BaseToQuote(45e6, 1e6), + FundingOrder: 2000, + }, + 42: { + Available: (1e7 / 2) + 1e6 - 1000, + PendingRedeem: 1e6 - 1000, + }, + }, + }, + { + note: &core.MatchNote{ + OrderID: id, + Match: &core.Match{ + MatchID: matchIDs[1][:], + Qty: 1e6, + Rate: 45e6, + Status: order.MatchComplete, + Redeem: &core.Coin{}, + }, + }, + balance: map[uint32]*botBalance{ + 0: { + Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 1e6) - calc.BaseToQuote(45e6, 1e6), + FundingOrder: 2000, + }, + 42: { + Available: (1e7 / 2) + 1e6 - 1000, + PendingRedeem: 1e6 - 1000, + }, + }, + }, + { + note: &core.MatchNote{ + OrderID: id, + Match: &core.Match{ + MatchID: matchIDs[1][:], + Qty: 1e6, + Rate: 45e6, + Status: order.MatchConfirmed, + Redeem: &core.Coin{}, + }, + }, + balance: map[uint32]*botBalance{ + 0: { + Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 1e6) - calc.BaseToQuote(45e6, 1e6), + FundingOrder: 2000, + }, + 42: { + Available: (1e7 / 2) + 2e6 - 2000, + }, + }, + }, + }, + }, + // "edge enough balance for single buy" + { + name: "edge enough balance for single buy", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 5e6, + QuoteBalanceType: Amount, + QuoteBalance: calc.BaseToQuote(5e7, 5e6) + 1500, + }, + assetBalances: map[uint32]uint64{ + 0: 1e7, + 42: 1e7, + }, + trade: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 0, + Sell: false, + Qty: 5e6, + Rate: 5e7, + }, + market: &core.Market{ + LotSize: 5e6, + }, + swapFees: 1000, + redeemFees: 1000, + maxFundingFees: 500, + tradeRes: &core.Order{ + ID: id, + LockedAmt: calc.BaseToQuote(5e7, 5e6) + 1000, + Sell: false, + FeesPaid: &core.FeeBreakdown{ + Funding: 400, + }, + }, + postTradeBalances: map[uint32]*botBalance{ + 0: { + Available: 100, + FundingOrder: calc.BaseToQuote(5e7, 5e6) + 1000, + }, + 42: { + Available: 5e6, + }, + }, + }, + // "edge not enough balance for single buy, with maxFundingFee > 0" + { + name: "edge not enough balance for single buy", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 5e6, + QuoteBalanceType: Amount, + QuoteBalance: calc.BaseToQuote(5e7, 5e6) + 1499, + }, + assetBalances: map[uint32]uint64{ + 0: 1e7, + 42: 1e7, + }, + trade: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 0, + Sell: false, + Qty: 5e6, + Rate: 5e7, + }, + market: &core.Market{ + LotSize: 5e6, + }, + swapFees: 1000, + redeemFees: 1000, + maxFundingFees: 500, + wantErr: true, + }, + // "edge enough balance for single sell" + { + name: "edge enough balance for single sell", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 5e6 + 1500, + QuoteBalanceType: Amount, + QuoteBalance: 5e6, + }, + assetBalances: map[uint32]uint64{ + 0: 1e7, + 42: 1e7, + }, + trade: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 0, + Sell: true, + Qty: 5e6, + Rate: 1e8, + }, + market: &core.Market{ + LotSize: 5e6, + }, + swapFees: 1000, + redeemFees: 1000, + maxFundingFees: 500, + tradeRes: &core.Order{ + ID: id, + LockedAmt: 5e6 + 1000, + RedeemLockedAmt: 0, + Sell: true, + FeesPaid: &core.FeeBreakdown{ + Funding: 400, + }, + }, + + postTradeBalances: map[uint32]*botBalance{ + 0: { + Available: 5e6, + }, + 42: { + Available: 100, + FundingOrder: 5e6 + 1000, + }, + }, + }, + // "edge not enough balance for single sell" + { + name: "edge not enough balance for single sell", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 5e6 + 1499, + QuoteBalanceType: Amount, + QuoteBalance: 5e6, + }, + assetBalances: map[uint32]uint64{ + 0: 1e7, + 42: 1e7, + }, + trade: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 0, + Sell: true, + Qty: 5e6, + Rate: 1e8, + }, + market: &core.Market{ + LotSize: 5e6, + }, + swapFees: 1000, + redeemFees: 1000, + maxFundingFees: 500, + wantErr: true, + }, + // "edge enough balance for single buy with redeem fees" + { + name: "edge enough balance for single buy with redeem fees", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 1000, + QuoteBalanceType: Amount, + QuoteBalance: calc.BaseToQuote(52e7, 5e6) + 1000, + }, + assetBalances: map[uint32]uint64{ + 0: 1e8, + 42: 1e7, + }, + trade: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 0, + Qty: 5e6, + Rate: 52e7, + }, + market: &core.Market{ + LotSize: 5e6, + }, + swapFees: 1000, + redeemFees: 1000, + tradeRes: &core.Order{ + ID: id, + LockedAmt: calc.BaseToQuote(52e7, 5e6) + 1000, + RedeemLockedAmt: 1000, + }, + postTradeBalances: map[uint32]*botBalance{ + 0: { + Available: 0, + FundingOrder: calc.BaseToQuote(52e7, 5e6) + 1000, + }, + 42: { + Available: 0, + FundingOrder: 1000, + }, + }, + isAccountLocker: map[uint32]bool{42: true}, + }, + // "edge not enough balance for single buy due to redeem fees" + { + name: "edge not enough balance for single buy due to redeem fees", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 999, + QuoteBalanceType: Amount, + QuoteBalance: calc.BaseToQuote(52e7, 5e6) + 1000, + }, + assetBalances: map[uint32]uint64{ + 0: 1e8, + 42: 1e7, + }, + trade: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 0, + Qty: 5e6, + Rate: 52e7, + }, + market: &core.Market{ + LotSize: 5e6, + }, + swapFees: 1000, + redeemFees: 1000, + isAccountLocker: map[uint32]bool{42: true}, + wantErr: true, + }, + // "edge enough balance for single sell with redeem fees" + { + name: "edge enough balance for single sell with redeem fees", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 5e6 + 1000, + QuoteBalanceType: Amount, + QuoteBalance: 1000, + }, + assetBalances: map[uint32]uint64{ + 0: 1e8, + 42: 1e7, + }, + trade: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Sell: true, + Base: 42, + Quote: 0, + Qty: 5e6, + Rate: 52e7, + }, + market: &core.Market{ + LotSize: 5e6, + }, + swapFees: 1000, + redeemFees: 1000, + tradeRes: &core.Order{ + ID: id, + LockedAmt: 5e6 + 1000, + RedeemLockedAmt: 1000, + }, + postTradeBalances: map[uint32]*botBalance{ + 0: { + Available: 0, + FundingOrder: 1000, + }, + 42: { + Available: 0, + FundingOrder: 5e6 + 1000, + }, + }, + isAccountLocker: map[uint32]bool{0: true}, + }, + // "edge not enough balance for single buy due to redeem fees" + { + name: "edge not enough balance for single sell due to redeem fees", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 5e6 + 1000, + QuoteBalanceType: Amount, + QuoteBalance: 999, + }, + assetBalances: map[uint32]uint64{ + 0: 1e8, + 42: 1e7, + }, + trade: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Sell: true, + Base: 42, + Quote: 0, + Qty: 5e6, + Rate: 52e7, + }, + market: &core.Market{ + LotSize: 5e6, + }, + swapFees: 1000, + redeemFees: 1000, + isAccountLocker: map[uint32]bool{0: true}, + wantErr: true, + }, + // "edge enough balance for multi buy" + { + name: "edge enough balance for multi buy", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 5e6, + QuoteBalanceType: Amount, + QuoteBalance: calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(52e7, 5e6) + 2500, + }, + assetBalances: map[uint32]uint64{ + 0: 1e8, + 42: 1e7, + }, + multiTradeOnly: true, + multiTrade: &core.MultiTradeForm{ + Host: "host1", + Base: 42, + Quote: 0, + Placements: []*core.QtyRate{ + { + Qty: 5e6, + Rate: 52e7, + }, + { + Qty: 5e6, + Rate: 5e7, + }, + }, + }, + market: &core.Market{ + LotSize: 5e6, + }, + swapFees: 1000, + redeemFees: 1000, + maxFundingFees: 500, + multiTradeRes: []*core.Order{{ + ID: id, + LockedAmt: calc.BaseToQuote(5e7, 5e6) + 1000, + RedeemLockedAmt: 0, + Sell: true, + FeesPaid: &core.FeeBreakdown{ + Funding: 400, + }, + }, { + ID: id2, + LockedAmt: calc.BaseToQuote(52e7, 5e6) + 1000, + RedeemLockedAmt: 0, + Sell: true, + }, + }, + postTradeBalances: map[uint32]*botBalance{ + 0: { + Available: 100, + FundingOrder: calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(52e7, 5e6) + 2000, + }, + 42: { + Available: 5e6, + }, + }, + }, + // "edge not enough balance for multi buy" + { + name: "edge not enough balance for multi buy", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 5e6, + QuoteBalanceType: Amount, + QuoteBalance: calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(52e7, 5e6) + 2499, + }, + assetBalances: map[uint32]uint64{ + 0: 1e8, + 42: 1e7, + }, + multiTradeOnly: true, + multiTrade: &core.MultiTradeForm{ + Host: "host1", + Base: 42, + Quote: 0, + Placements: []*core.QtyRate{ + { + Qty: 5e6, + Rate: 52e7, + }, + { + Qty: 5e6, + Rate: 5e7, + }, + }, + }, + market: &core.Market{ + LotSize: 5e6, + }, + swapFees: 1000, + redeemFees: 1000, + maxFundingFees: 500, + wantErr: true, + }, + // "edge enough balance for multi sell" + { + name: "edge enough balance for multi sell", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 1e7 + 2500, + QuoteBalanceType: Amount, + QuoteBalance: 5e6, + }, + assetBalances: map[uint32]uint64{ + 0: 1e8, + 42: 1e8, + }, + multiTradeOnly: true, + multiTrade: &core.MultiTradeForm{ + Host: "host1", + Base: 42, + Quote: 0, + Sell: true, + Placements: []*core.QtyRate{ + { + Qty: 5e6, + Rate: 52e7, + }, + { + Qty: 5e6, + Rate: 5e7, + }, + }, + }, + market: &core.Market{ + LotSize: 5e6, + }, + swapFees: 1000, + redeemFees: 1000, + maxFundingFees: 500, + multiTradeRes: []*core.Order{{ + ID: id, + LockedAmt: 5e6 + 1000, + RedeemLockedAmt: 0, + Sell: true, + FeesPaid: &core.FeeBreakdown{ + Funding: 400, + }, + }, { + ID: id2, + LockedAmt: 5e6 + 1000, + RedeemLockedAmt: 0, + Sell: true, + }, + }, + postTradeBalances: map[uint32]*botBalance{ + 0: { + Available: 5e6, + }, + 42: { + Available: 100, + FundingOrder: 1e7 + 2000, + }, + }, + }, + // "edge not enough balance for multi sell" + { + name: "edge not enough balance for multi sell", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 1e7 + 2499, + QuoteBalanceType: Amount, + QuoteBalance: 5e6, + }, + assetBalances: map[uint32]uint64{ + 0: 1e8, + 42: 1e8, + }, + multiTradeOnly: true, + multiTrade: &core.MultiTradeForm{ + Host: "host1", + Base: 42, + Quote: 0, + Sell: true, + Placements: []*core.QtyRate{ + { + Qty: 5e6, + Rate: 52e7, + }, + { + Qty: 5e6, + Rate: 5e7, + }, + }, + }, + market: &core.Market{ + LotSize: 5e6, + }, + swapFees: 1000, + redeemFees: 1000, + maxFundingFees: 500, + wantErr: true, + }, + // "edge enough balance for multi buy with redeem fees" + { + name: "edge enough balance for multi buy with redeem fees", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 2000, + QuoteBalanceType: Amount, + QuoteBalance: calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(52e7, 5e6) + 2000, + }, + assetBalances: map[uint32]uint64{ + 0: 1e8, + 42: 1e7, + }, + multiTradeOnly: true, + multiTrade: &core.MultiTradeForm{ + Host: "host1", + Base: 42, + Quote: 0, + Placements: []*core.QtyRate{ + { + Qty: 5e6, + Rate: 52e7, + }, + { + Qty: 5e6, + Rate: 5e7, + }, + }, + }, + market: &core.Market{ + LotSize: 5e6, + }, + swapFees: 1000, + redeemFees: 1000, + multiTradeRes: []*core.Order{{ + ID: id, + LockedAmt: calc.BaseToQuote(5e7, 5e6) + 1000, + RedeemLockedAmt: 1000, + Sell: true, + }, { + ID: id2, + LockedAmt: calc.BaseToQuote(52e7, 5e6) + 1000, + RedeemLockedAmt: 1000, + Sell: true, + }, + }, + postTradeBalances: map[uint32]*botBalance{ + 0: { + Available: 0, + FundingOrder: calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(52e7, 5e6) + 2000, + }, + 42: { + Available: 0, + FundingOrder: 2000, + }, + }, + isAccountLocker: map[uint32]bool{42: true}, + }, + // "edge not enough balance for multi buy due to redeem fees" + { + name: "edge not enough balance for multi buy due to redeem fees", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 1999, + QuoteBalanceType: Amount, + QuoteBalance: calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(52e7, 5e6) + 2000, + }, + assetBalances: map[uint32]uint64{ + 0: 1e8, + 42: 1e7, + }, + multiTradeOnly: true, + multiTrade: &core.MultiTradeForm{ + Host: "host1", + Base: 42, + Quote: 0, + Placements: []*core.QtyRate{ + { + Qty: 5e6, + Rate: 52e7, + }, + { + Qty: 5e6, + Rate: 5e7, + }, + }, + }, + market: &core.Market{ + LotSize: 5e6, + }, + swapFees: 1000, + redeemFees: 1000, + wantErr: true, + isAccountLocker: map[uint32]bool{42: true}, + }, + // "edge enough balance for multi sell with redeem fees" + { + name: "edge enough balance for multi sell with redeem fees", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 1e7 + 2000, + QuoteBalanceType: Amount, + QuoteBalance: 2000, + }, + assetBalances: map[uint32]uint64{ + 0: 1e8, + 42: 1e8, + }, + multiTradeOnly: true, + multiTrade: &core.MultiTradeForm{ + Host: "host1", + Base: 42, + Quote: 0, + Sell: true, + Placements: []*core.QtyRate{ + { + Qty: 5e6, + Rate: 52e7, + }, + { + Qty: 5e6, + Rate: 5e7, + }, + }, + }, + market: &core.Market{ + LotSize: 5e6, + }, + swapFees: 1000, + redeemFees: 1000, + multiTradeRes: []*core.Order{{ + ID: id, + LockedAmt: 5e6 + 1000, + RedeemLockedAmt: 1000, + Sell: true, + }, { + ID: id2, + LockedAmt: 5e6 + 1000, + RedeemLockedAmt: 1000, + Sell: true, + }, + }, + isAccountLocker: map[uint32]bool{0: true}, + postTradeBalances: map[uint32]*botBalance{ + 0: { + Available: 0, + FundingOrder: 2000, + }, + 42: { + Available: 0, + FundingOrder: 1e7 + 2000, + }, + }, + }, + // "edge not enough balance for multi sell due to redeem fees" + { + name: "edge enough balance for multi sell with redeem fees", + cfg: &BotConfig{ + Host: "host1", + BaseAsset: 42, + QuoteAsset: 0, + BaseBalanceType: Amount, + BaseBalance: 1e7 + 2000, + QuoteBalanceType: Amount, + QuoteBalance: 1999, + }, + assetBalances: map[uint32]uint64{ + 0: 1e8, + 42: 1e8, + }, + multiTradeOnly: true, + multiTrade: &core.MultiTradeForm{ + Host: "host1", + Base: 42, + Quote: 0, + Sell: true, + Placements: []*core.QtyRate{ + { + Qty: 5e6, + Rate: 52e7, + }, + { + Qty: 5e6, + Rate: 5e7, + }, + }, + }, + market: &core.Market{ + LotSize: 5e6, + }, + swapFees: 1000, + redeemFees: 1000, + isAccountLocker: map[uint32]bool{0: true}, + wantErr: true, + }, + } + + runTest := func(test *test) { + if test.multiTradeOnly && !testMultiTrade { + return + } + + tCore := newTCore() + tCore.setAssetBalances(test.assetBalances) + tCore.market = test.market + if !test.multiTradeOnly { + if test.trade.Sell { + tCore.sellSwapFees = test.swapFees + tCore.sellRedeemFees = test.redeemFees + } else { + tCore.buySwapFees = test.swapFees + tCore.buyRedeemFees = test.redeemFees + } + } else { + if test.multiTrade.Sell { + tCore.sellSwapFees = test.swapFees + tCore.sellRedeemFees = test.redeemFees + } else { + tCore.buySwapFees = test.swapFees + tCore.buyRedeemFees = test.redeemFees + } + } + if test.isAccountLocker == nil { + tCore.isAccountLocker = make(map[uint32]bool) + } else { + tCore.isAccountLocker = test.isAccountLocker + } + tCore.maxFundingFees = test.maxFundingFees + + if testMultiTrade { + if test.multiTradeOnly { + tCore.multiTradeResult = test.multiTradeRes + } else { + tCore.multiTradeResult = []*core.Order{test.tradeRes} + } + } else { + tCore.tradeResult = test.tradeRes + } + tCore.noteFeed = make(chan core.Notification) + + mm, err := NewMarketMaker(tCore, tLogger) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + mm.doNotKillWhenBotsStop = true + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + err = mm.Run(ctx, []*BotConfig{test.cfg}, []byte{}) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + + segregatedCore := mm.wrappedCoreForBot(dcrBtcID) + + if testMultiTrade { + + if test.multiTradeOnly { + _, err = segregatedCore.MultiTrade([]byte{}, test.multiTrade) + } else { + _, err = segregatedCore.MultiTrade([]byte{}, &core.MultiTradeForm{ + Host: test.trade.Host, + Sell: test.trade.Sell, + Base: test.trade.Base, + Quote: test.trade.Quote, + Placements: []*core.QtyRate{ + { + Qty: test.trade.Qty, + Rate: test.trade.Rate, + }, + }, + Options: test.trade.Options, + }) + } + } else { + _, err = segregatedCore.Trade([]byte{}, test.trade) + } + if test.wantErr { + if err == nil { + t.Fatalf("%s: expected error but did not get", test.name) + } + return + } + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + + if err := assetBalancesMatch(test.postTradeBalances, dcrBtcID, mm); err != nil { + t.Fatalf("%s: unexpected post trade balance: %v", test.name, err) + } + + dummyNote := &core.BondRefundNote{} + for i, noteAndBalances := range test.notifications { + tCore.noteFeed <- noteAndBalances.note + tCore.noteFeed <- dummyNote + + if err := assetBalancesMatch(noteAndBalances.balance, dcrBtcID, mm); err != nil { + t.Fatalf("%s: unexpected balances after note %d: %v", test.name, i, err) + } + } + } + + for _, test := range tests { + runTest(&test) + } +} diff --git a/client/mm/sample-config.json b/client/mm/sample-config.json index 436c7ffa75..794e70712e 100644 --- a/client/mm/sample-config.json +++ b/client/mm/sample-config.json @@ -2,8 +2,12 @@ "host": "127.0.0.1:17273", "baseAsset": 42, "quoteAsset": 0, + "baseBalanceType": 0, + "baseBalance": 50, + "quoteBalanceType": 0, + "quoteBalance": 50, "marketMakingConfig": { - "lots": 1, + "lots": 2, "gapStrategy": "multiplier", "gapFactor": 1, "driftTolerance": 0.001, diff --git a/client/mm/wrapped_core.go b/client/mm/wrapped_core.go new file mode 100644 index 0000000000..479252e222 --- /dev/null +++ b/client/mm/wrapped_core.go @@ -0,0 +1,449 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package mm + +import ( + "fmt" + + "decred.org/dcrdex/client/asset" + "decred.org/dcrdex/client/core" + "decred.org/dcrdex/client/db" + "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/calc" + "decred.org/dcrdex/dex/order" +) + +// wrappedCore implements the clientCore interface. A separate +// instance should be created for each bot, and the core functions will behave +// as if the entire balance of the wallet is the amount that has been reserved +// for the bot. +type wrappedCore struct { + clientCore + + mm *MarketMaker + botID string + log dex.Logger +} + +var _ clientCore = (*wrappedCore)(nil) + +func (c *wrappedCore) maxBuyQty(host string, base, quote uint32, rate uint64, options map[string]string) (uint64, error) { + baseBalance := c.mm.botBalance(c.botID, base) + quoteBalance := c.mm.botBalance(c.botID, quote) + + mkt, err := c.ExchangeMarket(host, base, quote) + if err != nil { + return 0, err + } + + fundingFees, err := c.MaxFundingFees(quote, 1, options) + if err != nil { + return 0, err + } + + swapFees, redeemFees, err := c.SingleLotFees(&core.SingleLotFeesForm{ + Host: host, + Base: base, + Quote: quote, + UseMaxFeeRate: true, + }) + if err != nil { + return 0, err + } + + if quoteBalance > fundingFees { + quoteBalance -= fundingFees + } else { + quoteBalance = 0 + } + + lotSizeQuote := calc.BaseToQuote(rate, mkt.LotSize) + maxLots := quoteBalance / (lotSizeQuote + swapFees) + + if redeemFees > 0 && c.mm.isAccountLocker(base) { + maxBaseLots := baseBalance / redeemFees + if maxLots > maxBaseLots { + maxLots = maxBaseLots + } + } + + return maxLots * mkt.LotSize, nil +} + +func (c *wrappedCore) maxSellQty(host string, base, quote, numTrades uint32, options map[string]string) (uint64, error) { + baseBalance := c.mm.botBalance(c.botID, base) + quoteBalance := c.mm.botBalance(c.botID, quote) + + mkt, err := c.ExchangeMarket(host, base, quote) + if err != nil { + return 0, err + } + + fundingFees, err := c.MaxFundingFees(base, numTrades, options) + if err != nil { + return 0, err + } + + swapFees, redeemFees, err := c.SingleLotFees(&core.SingleLotFeesForm{ + Host: host, + Base: base, + Quote: quote, + Sell: true, + UseMaxFeeRate: true, + }) + if err != nil { + return 0, err + } + + baseBalance -= fundingFees + maxLots := baseBalance / (mkt.LotSize + swapFees) + if c.mm.isAccountLocker(quote) && redeemFees > 0 { + maxQuoteLots := quoteBalance / redeemFees + if maxLots > maxQuoteLots { + maxLots = maxQuoteLots + } + } + + return maxLots * mkt.LotSize, nil +} + +func (c *wrappedCore) sufficientBalanceForTrade(host string, base, quote uint32, sell bool, rate, qty uint64, options map[string]string) (bool, error) { + var maxQty uint64 + if sell { + var err error + maxQty, err = c.maxSellQty(host, base, quote, 1, options) + if err != nil { + return false, err + } + } else { + var err error + maxQty, err = c.maxBuyQty(host, base, quote, rate, options) + if err != nil { + return false, err + } + } + + return maxQty >= qty, nil +} + +func (c *wrappedCore) sufficientBalanceForMultiSell(host string, base, quote uint32, placements []*core.QtyRate, options map[string]string) (bool, error) { + var totalQty uint64 + for _, placement := range placements { + totalQty += placement.Qty + } + maxQty, err := c.maxSellQty(host, base, quote, uint32(len(placements)), options) + if err != nil { + return false, err + } + return maxQty >= totalQty, nil +} + +func (c *wrappedCore) sufficientBalanceForMultiBuy(host string, base, quote uint32, placements []*core.QtyRate, options map[string]string) (bool, error) { + baseBalance := c.mm.botBalance(c.botID, base) + quoteBalance := c.mm.botBalance(c.botID, quote) + + mkt, err := c.ExchangeMarket(host, base, quote) + if err != nil { + return false, err + } + + swapFees, redeemFees, err := c.SingleLotFees(&core.SingleLotFeesForm{ + Host: host, + Base: base, + Quote: quote, + UseMaxFeeRate: true, + }) + if err != nil { + return false, err + } + + fundingFees, err := c.MaxFundingFees(quote, uint32(len(placements)), options) + if err != nil { + return false, err + } + if quoteBalance < fundingFees { + return false, nil + } + + var totalLots uint64 + remainingBalance := quoteBalance - fundingFees + for _, placement := range placements { + quoteQty := calc.BaseToQuote(placement.Rate, placement.Qty) + numLots := placement.Qty / mkt.LotSize + totalLots += numLots + req := quoteQty + (numLots * swapFees) + if remainingBalance < req { + return false, nil + } + remainingBalance -= req + } + + if c.mm.isAccountLocker(base) && baseBalance < redeemFees*totalLots { + return false, nil + } + + return true, nil +} + +func (c *wrappedCore) sufficientBalanceForMultiTrade(host string, base, quote uint32, sell bool, placements []*core.QtyRate, options map[string]string) (bool, error) { + if sell { + return c.sufficientBalanceForMultiSell(host, base, quote, placements, options) + } + return c.sufficientBalanceForMultiBuy(host, base, quote, placements, options) +} + +// Trade checks that the bot has enough balance for the trade, and if not, +// immediately returns an error. Otherwise, it forwards the call to the +// underlying core. Then, the bot's balance in the balance handler is +// updated to reflect the trade, and the balanceHandler will start tracking +// updates to the order to ensure that the bot's balance is updated. +func (c *wrappedCore) Trade(pw []byte, form *core.TradeForm) (*core.Order, error) { + if !form.IsLimit { + return nil, fmt.Errorf("only limit orders are supported") + } + + enough, err := c.sufficientBalanceForTrade(form.Host, form.Base, form.Quote, form.Sell, form.Rate, form.Qty, form.Options) + if err != nil { + return nil, err + } + if !enough { + return nil, fmt.Errorf("insufficient balance") + } + + singleLotSwapFees, singleLotRedeemFees, err := c.SingleLotFees(&core.SingleLotFeesForm{ + Host: form.Host, + Base: form.Base, + Quote: form.Quote, + Sell: form.Sell, + UseMaxFeeRate: true, + }) + if err != nil { + return nil, err + } + + mkt, err := c.ExchangeMarket(form.Host, form.Base, form.Quote) + if err != nil { + return nil, err + } + + o, err := c.clientCore.Trade(pw, form) + if err != nil { + return nil, err + } + + var orderID order.OrderID + copy(orderID[:], o.ID) + + c.mm.ordersMtx.Lock() + c.mm.orders[orderID] = &orderInfo{ + bot: c.botID, + order: o, + initialFundsLocked: o.LockedAmt, + initialRedeemFeesLocked: o.RedeemLockedAmt, + initialRefundFeesLocked: o.RefundLockedAmt, + singleLotSwapFees: singleLotSwapFees, + singleLotRedeemFees: singleLotRedeemFees, + lotSize: mkt.LotSize, + matchesSettled: make(map[order.MatchID]struct{}), + matchesSeen: make(map[order.MatchID]struct{}), + } + c.mm.ordersMtx.Unlock() + + fromAsset, toAsset := form.Quote, form.Base + if form.Sell { + fromAsset, toAsset = toAsset, fromAsset + } + + var fundingFees uint64 + if o.FeesPaid != nil { + fundingFees = o.FeesPaid.Funding + } + + balMods := []*balanceMod{ + {balanceModDecrease, fromAsset, balTypeAvailable, o.LockedAmt + fundingFees}, + {balanceModIncrease, fromAsset, balTypeFundingOrder, o.LockedAmt}, + } + if o.RedeemLockedAmt > 0 { + balMods = append(balMods, &balanceMod{balanceModDecrease, toAsset, balTypeAvailable, o.RedeemLockedAmt}) + balMods = append(balMods, &balanceMod{balanceModIncrease, toAsset, balTypeFundingOrder, o.RedeemLockedAmt}) + } + c.mm.modifyBotBalance(c.botID, balMods) + + return o, nil +} + +func (c *wrappedCore) MultiTrade(pw []byte, form *core.MultiTradeForm) ([]*core.Order, error) { + enough, err := c.sufficientBalanceForMultiTrade(form.Host, form.Base, form.Quote, form.Sell, form.Placements, form.Options) + if err != nil { + return nil, err + } + if !enough { + return nil, fmt.Errorf("insufficient balance") + } + + singleLotSwapFees, singleLotRedeemFees, err := c.SingleLotFees(&core.SingleLotFeesForm{ + Host: form.Host, + Base: form.Base, + Quote: form.Quote, + Sell: form.Sell, + UseMaxFeeRate: true, + }) + if err != nil { + return nil, err + } + + mkt, err := c.ExchangeMarket(form.Host, form.Base, form.Quote) + if err != nil { + return nil, err + } + + fromAsset := form.Quote + toAsset := form.Base + if form.Sell { + fromAsset = form.Base + toAsset = form.Quote + } + form.MaxLock = c.mm.botBalance(c.botID, fromAsset) + + orders, err := c.clientCore.MultiTrade(pw, form) + if err != nil { + return nil, err + } + + var totalFromLocked, totalToLocked, fundingFeesPaid uint64 + for _, o := range orders { + var orderID order.OrderID + copy(orderID[:], o.ID) + + c.mm.ordersMtx.Lock() + c.mm.orders[orderID] = &orderInfo{ + bot: c.botID, + order: o, + initialFundsLocked: o.LockedAmt, + initialRedeemFeesLocked: o.RedeemLockedAmt, + initialRefundFeesLocked: o.RefundLockedAmt, + singleLotSwapFees: singleLotSwapFees, + singleLotRedeemFees: singleLotRedeemFees, + lotSize: mkt.LotSize, + matchesSettled: make(map[order.MatchID]struct{}), + matchesSeen: make(map[order.MatchID]struct{}), + } + c.mm.ordersMtx.Unlock() + + totalFromLocked += o.LockedAmt + totalToLocked += o.RedeemLockedAmt + if o.FeesPaid != nil { + fundingFeesPaid += o.FeesPaid.Funding + } + } + + balMods := []*balanceMod{ + {false, fromAsset, balTypeAvailable, totalFromLocked + fundingFeesPaid}, + {true, fromAsset, balTypeFundingOrder, totalFromLocked}, + } + if totalToLocked > 0 { + balMods = append(balMods, &balanceMod{balanceModDecrease, toAsset, balTypeAvailable, totalToLocked}) + balMods = append(balMods, &balanceMod{balanceModIncrease, toAsset, balTypeFundingOrder, totalToLocked}) + } + c.mm.modifyBotBalance(c.botID, balMods) + + return orders, nil +} + +// MayBuy returns the maximum quantity of the base asset that the bot can +// buy for rate using its balance of the quote asset. +func (c *wrappedCore) MaxBuy(host string, base, quote uint32, rate uint64) (*core.MaxOrderEstimate, error) { + maxQty, err := c.maxBuyQty(host, base, quote, rate, nil) + if err != nil { + return nil, err + } + if maxQty == 0 { + return nil, fmt.Errorf("insufficient balance") + } + + orderEstimate, err := c.clientCore.PreOrder(&core.TradeForm{ + Host: host, + IsLimit: true, + Base: base, + Quote: quote, + Qty: maxQty, + Rate: rate, + // TODO: handle options. need new option for split if remaining balance < certain amount. + }) + if err != nil { + return nil, err + } + + return &core.MaxOrderEstimate{ + Swap: orderEstimate.Swap.Estimate, + Redeem: orderEstimate.Redeem.Estimate, + }, nil +} + +// MaxSell returned the maximum quantity of the base asset that the bot can +// sell. +func (c *wrappedCore) MaxSell(host string, base, quote uint32) (*core.MaxOrderEstimate, error) { + qty, err := c.maxSellQty(host, base, quote, 1, nil) + if err != nil { + return nil, err + } + if qty == 0 { + return nil, fmt.Errorf("insufficient balance") + } + + orderEstimate, err := c.clientCore.PreOrder(&core.TradeForm{ + Host: host, + IsLimit: true, + Sell: true, + Base: base, + Quote: quote, + Qty: qty, + }) + if err != nil { + return nil, err + } + + return &core.MaxOrderEstimate{ + Swap: orderEstimate.Swap.Estimate, + Redeem: orderEstimate.Redeem.Estimate, + }, nil +} + +// AssetBalance returns the bot's balance for a specific asset. +func (c *wrappedCore) AssetBalance(assetID uint32) (*core.WalletBalance, error) { + bal := c.mm.botBalance(c.botID, assetID) + + return &core.WalletBalance{ + Balance: &db.Balance{ + Balance: asset.Balance{ + Available: bal, + }, + }, + }, nil +} + +// PreOrder checks if the bot's balance is sufficient for the trade, and if it +// is, forwards the request to the underlying core. +func (c *wrappedCore) PreOrder(form *core.TradeForm) (*core.OrderEstimate, error) { + enough, err := c.sufficientBalanceForTrade(form.Host, form.Base, form.Quote, form.Sell, form.Rate, form.Qty, form.Options) + if err != nil { + return nil, err + } + + if !enough { + return nil, fmt.Errorf("insufficient balance") + } + + return c.clientCore.PreOrder(form) +} + +// wrappedCoreForBot returns a wrappedCore for the specified bot. +func (m *MarketMaker) wrappedCoreForBot(botID string) *wrappedCore { + return &wrappedCore{ + clientCore: m.core, + botID: botID, + log: m.log, + mm: m, + } +}