From 0b05462b995841630dbcc1c5f7521c4cbd95fa81 Mon Sep 17 00:00:00 2001 From: Brian Stafford Date: Fri, 24 Nov 2023 09:21:43 -0600 Subject: [PATCH 1/4] ui: arb + market maker ui Implement arbmm ui. This required a fair amount refactoring on both the front and back ends. For the back end, the primary changes are about when and how cexes are loaded. Since we need to know what markets are available before the bot it started, we need to call Markets() on cex initialization, before the CEX is running. This occurs either during startup (MarketMaker is now a dex.Connector), or when the cex is added (through the new UpdateCEXConfig method). In order to facilitate ui testing, I've made the MarketMaker an interface for use by WebServer (like clientCore), and then implemented the interface in live_test.go. On the front end, most effort is focused around mmsettings, and there was a lot of refactoring done to facilitate the changes and clean things up. --- .gitignore | 1 + client/app/config.go | 22 +- client/cmd/dexc-desktop/app.go | 10 +- client/cmd/dexc-desktop/app_darwin.go | 11 +- client/cmd/dexc/main.go | 13 +- client/cmd/testbinance/main.go | 8 + client/mm/config.go | 24 +- client/mm/libxc/binance.go | 137 +- client/mm/libxc/interface.go | 6 +- client/mm/mm.go | 404 ++++-- client/mm/mm_arb_market_maker.go | 8 +- client/mm/mm_basic.go | 8 +- client/mm/mm_simple_arb.go | 6 +- client/mm/mm_simple_arb_test.go | 4 +- client/mm/mm_test.go | 377 +++--- client/mm/price_oracle.go | 10 +- client/mm/sample-config.json | 8 +- client/rpcserver/handlers.go | 2 +- client/webserver/api.go | 41 +- client/webserver/live_test.go | 153 ++- client/webserver/locales/en-us.go | 10 + .../webserver/site/src/css/application.scss | 2 +- client/webserver/site/src/css/forms.scss | 33 +- client/webserver/site/src/css/main.scss | 10 +- client/webserver/site/src/css/main_dark.scss | 7 +- client/webserver/site/src/css/market.scss | 37 - client/webserver/site/src/css/mm.scss | 17 + .../css/{mm_settings.scss => mmsettings.scss} | 66 +- client/webserver/site/src/css/order.scss | 32 - client/webserver/site/src/html/forms.tmpl | 26 +- client/webserver/site/src/html/markets.tmpl | 4 +- client/webserver/site/src/html/mm.tmpl | 13 +- .../webserver/site/src/html/mmsettings.tmpl | 131 +- client/webserver/site/src/html/orders.tmpl | 2 +- client/webserver/site/src/html/settings.tmpl | 4 +- client/webserver/site/src/html/wallets.tmpl | 30 +- client/webserver/site/src/img/binance.us.png | Bin 0 -> 4499 bytes client/webserver/site/src/js/app.ts | 69 +- client/webserver/site/src/js/forms.ts | 46 + client/webserver/site/src/js/locales.ts | 20 +- client/webserver/site/src/js/markets.ts | 3 +- client/webserver/site/src/js/mm.ts | 208 ++- client/webserver/site/src/js/mmsettings.ts | 1165 ++++++++++------- client/webserver/site/src/js/opts.ts | 39 +- client/webserver/site/src/js/registry.ts | 35 +- client/webserver/webserver.go | 22 +- 46 files changed, 2159 insertions(+), 1125 deletions(-) rename client/webserver/site/src/css/{mm_settings.scss => mmsettings.scss} (63%) create mode 100644 client/webserver/site/src/img/binance.us.png diff --git a/.gitignore b/.gitignore index c49aba4c52..3454fe80da 100644 --- a/.gitignore +++ b/.gitignore @@ -39,5 +39,6 @@ client/asset/btc/electrum/example/wallet/wallet client/asset/eth/cmd/getgas/getgas client/asset/eth/cmd/deploy/deploy client/cmd/dexc-desktop/pkg/installers +client/cmd/testbinance/testbinance server/noderelay/cmd/sourcenode/sourcenode tatanka/cmd/demo/demo diff --git a/client/app/config.go b/client/app/config.go index fe108c1f61..02d44dd532 100644 --- a/client/app/config.go +++ b/client/app/config.go @@ -33,6 +33,7 @@ const ( defaultWebPort = "5758" defaultLogLevel = "debug" configFilename = "dexc.conf" + mmConfigFilename = "mm.conf" ) var ( @@ -211,15 +212,6 @@ func (cfg *Config) Core(log dex.Logger) *core.Config { } } -// MarketMakerConfigPath returns the path to the market maker config file. -func (cfg *Config) MarketMakerConfigPath() string { - if cfg.MMConfig.BotConfigPath != "" { - return cfg.MMConfig.BotConfigPath - } - _, _, mmCfgPath := setNet(cfg.AppData, cfg.Net.String()) - return mmCfgPath -} - var DefaultConfig = Config{ AppData: defaultApplicationDirectory, ConfigPath: defaultConfigPath, @@ -303,17 +295,17 @@ func ResolveConfig(appData string, cfg *Config) error { cfg.AppData = appData - var defaultDBPath, defaultLogPath string + var defaultDBPath, defaultLogPath, defaultMMConfigPath string switch { case cfg.Testnet: cfg.Net = dex.Testnet - defaultDBPath, defaultLogPath, _ = setNet(appData, "testnet") + defaultDBPath, defaultLogPath, defaultMMConfigPath = setNet(appData, "testnet") case cfg.Simnet: cfg.Net = dex.Simnet - defaultDBPath, defaultLogPath, _ = setNet(appData, "simnet") + defaultDBPath, defaultLogPath, defaultMMConfigPath = setNet(appData, "simnet") default: cfg.Net = dex.Mainnet - defaultDBPath, defaultLogPath, _ = setNet(appData, "mainnet") + defaultDBPath, defaultLogPath, defaultMMConfigPath = setNet(appData, "mainnet") } defaultHost := DefaultHostByNetwork(cfg.Net) @@ -342,6 +334,10 @@ func ResolveConfig(appData string, cfg *Config) error { cfg.LogPath = defaultLogPath } + if cfg.MMConfig.BotConfigPath == "" { + cfg.MMConfig.BotConfigPath = defaultMMConfigPath + } + if cfg.ReloadHTML { fmt.Println("The --reload-html switch is deprecated. Use --no-embed-site instead, which has the same reloading effect.") cfg.NoEmbedSite = cfg.ReloadHTML diff --git a/client/cmd/dexc-desktop/app.go b/client/cmd/dexc-desktop/app.go index d86d80bea1..c2b47b28f2 100644 --- a/client/cmd/dexc-desktop/app.go +++ b/client/cmd/dexc-desktop/app.go @@ -184,10 +184,18 @@ func mainCore() error { if cfg.Experimental { // TODO: on shutdown, stop market making and wait for trades to be // canceled. - marketMaker, err = mm.NewMarketMaker(clientCore, cfg.MarketMakerConfigPath(), logMaker.Logger("MM")) + marketMaker, err = mm.NewMarketMaker(clientCore, cfg.BotConfigPath, logMaker.Logger("MM")) if err != nil { return fmt.Errorf("error creating market maker: %w", err) } + cm := dex.NewConnectionMaster(marketMaker) + if err := cm.ConnectOnce(appCtx); err != nil { + return fmt.Errorf("error connecting market maker") + } + defer func() { + cancel() + cm.Wait() + }() } if cfg.RPCOn { diff --git a/client/cmd/dexc-desktop/app_darwin.go b/client/cmd/dexc-desktop/app_darwin.go index 32db8136dd..6e03607316 100644 --- a/client/cmd/dexc-desktop/app_darwin.go +++ b/client/cmd/dexc-desktop/app_darwin.go @@ -244,13 +244,22 @@ func mainCore() error { <-clientCore.Ready() var marketMaker *mm.MarketMaker + var mmCM *dex.ConnectionMaster if cfg.Experimental { // TODO: on shutdown, stop market making and wait for trades to be // canceled. - marketMaker, err = mm.NewMarketMaker(clientCore, cfg.MarketMakerConfigPath(), logMaker.Logger("MM")) + marketMaker, err = mm.NewMarketMaker(clientCore, cfg.BotConfigPath, logMaker.Logger("MM")) if err != nil { return fmt.Errorf("error creating market maker: %w", err) } + cm := dex.NewConnectionMaster(marketMaker) + if err := cm.ConnectOnce(appCtx); err != nil { + return fmt.Errorf("error connecting market maker") + } + defer func() { + cancel() + cm.Wait() + }() } if cfg.RPCOn { diff --git a/client/cmd/dexc/main.go b/client/cmd/dexc/main.go index a5af5c5c8d..4d3219c52a 100644 --- a/client/cmd/dexc/main.go +++ b/client/cmd/dexc/main.go @@ -101,7 +101,7 @@ func runCore(cfg *app.Config) error { if cfg.Experimental { // TODO: on shutdown, stop market making and wait for trades to be // canceled. - marketMaker, err = mm.NewMarketMaker(clientCore, cfg.MarketMakerConfigPath(), logMaker.Logger("MM")) + marketMaker, err = mm.NewMarketMaker(clientCore, cfg.MMConfig.BotConfigPath, logMaker.Logger("MM")) if err != nil { return fmt.Errorf("error creating market maker: %w", err) } @@ -131,12 +131,23 @@ func runCore(cfg *app.Config) error { <-clientCore.Ready() + var mmCM *dex.ConnectionMaster defer func() { log.Info("Exiting dexc main.") cancel() // no-op with clean rpc/web server setup wg.Wait() // no-op with clean setup and shutdown + if mmCM != nil { + mmCM.Wait() + } }() + if marketMaker != nil { + mmCM = dex.NewConnectionMaster(marketMaker) + if err := mmCM.ConnectOnce(appCtx); err != nil { + return fmt.Errorf("Error connecting market maker") + } + } + if cfg.RPCOn { rpcSrv, err := rpcserver.New(cfg.RPC(clientCore, marketMaker, logMaker.Logger("RPC"))) if err != nil { diff --git a/client/cmd/testbinance/main.go b/client/cmd/testbinance/main.go index 920856e867..6c2e5381d5 100644 --- a/client/cmd/testbinance/main.go +++ b/client/cmd/testbinance/main.go @@ -112,6 +112,14 @@ func runServer() error { free: 1000.2358249, locked: 0, }, + "zec": { + free: 1000.2358249, + locked: 0, + }, + "polygon": { + free: 1000.2358249, + locked: 0, + }, }, withdrawalHistory: make([]*transfer, 0), depositHistory: make([]*transfer, 0), diff --git a/client/mm/config.go b/client/mm/config.go index 92c19e7ed4..daeede8970 100644 --- a/client/mm/config.go +++ b/client/mm/config.go @@ -17,6 +17,16 @@ type MarketMakingConfig struct { CexConfigs []*CEXConfig `json:"cexConfigs"` } +func (cfg *MarketMakingConfig) Copy() *MarketMakingConfig { + c := &MarketMakingConfig{ + BotConfigs: make([]*BotConfig, len(cfg.BotConfigs)), + CexConfigs: make([]*CEXConfig, len(cfg.CexConfigs)), + } + copy(c.BotConfigs, cfg.BotConfigs) + copy(c.CexConfigs, cfg.CexConfigs) + return c +} + // CEXConfig is a configuration for connecting to a CEX API. type CEXConfig struct { // Name is the name of the cex. @@ -41,11 +51,13 @@ type BotCEXCfg struct { // 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"` + Host string `json:"host"` + BaseID uint32 `json:"baseID"` + QuoteID uint32 `json:"quoteID"` + + BaseBalanceType BalanceType `json:"baseBalanceType"` + BaseBalance uint64 `json:"baseBalance"` + QuoteBalanceType BalanceType `json:"quoteBalanceType"` QuoteBalance uint64 `json:"quoteBalance"` @@ -55,7 +67,7 @@ type BotConfig struct { // Only one of the following configs should be set BasicMMConfig *BasicMarketMakingConfig `json:"basicMarketMakingConfig,omitempty"` SimpleArbConfig *SimpleArbConfig `json:"simpleArbConfig,omitempty"` - ArbMarketMakerConfig *ArbMarketMakerConfig `json:"arbMarketMakerConfig,omitempty"` + ArbMarketMakerConfig *ArbMarketMakerConfig `json:"arbMarketMakingConfig,omitempty"` Disabled bool `json:"disabled"` } diff --git a/client/mm/libxc/binance.go b/client/mm/libxc/binance.go index c64d36d7ad..f4a626fa12 100644 --- a/client/mm/libxc/binance.go +++ b/client/mm/libxc/binance.go @@ -285,6 +285,7 @@ func binanceCoinNetworkToDexSymbol(coin, network string) string { } type bncAssetConfig struct { + assetID uint32 // symbol is the DEX asset symbol, always lower case symbol string // coin is the asset symbol on binance, always upper case. @@ -317,6 +318,7 @@ func bncAssetCfg(assetID uint32) (*bncAssetConfig, error) { } return &bncAssetConfig{ + assetID: assetID, symbol: symbol, coin: mapDexToBinanceSymbol(coin), chain: mapDexToBinanceSymbol(chain), @@ -369,7 +371,7 @@ type binance struct { tokenIDs atomic.Value // map[string][]uint32 balanceMtx sync.RWMutex - balances map[string]*bncBalance + balances map[uint32]*bncBalance marketStreamMtx sync.RWMutex marketStream comms.WsConn @@ -410,7 +412,7 @@ func newBinance(apiKey, secretKey string, log dex.Logger, net dex.Network, binan apiKey: apiKey, secretKey: secretKey, knownAssets: knownAssets, - balances: make(map[string]*bncBalance), + balances: make(map[uint32]*bncBalance), books: make(map[string]*binanceOrderBook), net: net, tradeInfo: make(map[string]*tradeInfo), @@ -431,10 +433,19 @@ func (bnc *binance) setBalances(coinsData []*binanceCoinInfo) { defer bnc.balanceMtx.Unlock() for _, nfo := range coinsData { - bnc.balances[nfo.Coin] = &bncBalance{ - available: nfo.Free, - locked: nfo.Locked, + for _, net := range nfo.NetworkList { + assetID, found := dex.BipSymbolID(binanceCoinNetworkToDexSymbol(nfo.Coin, net.Coin)) + if !found { + bnc.log.Tracef("no dex asset known for binance coin %q, network %q", nfo.Coin, net.Coin) + continue + } + + bnc.balances[assetID] = &bncBalance{ + available: nfo.Free, + locked: nfo.Locked, + } } + } } @@ -492,7 +503,7 @@ func (bnc *binance) getCoinInfo(ctx context.Context) error { return nil } -func (bnc *binance) getMarkets(ctx context.Context) error { +func (bnc *binance) getMarkets(ctx context.Context) (map[string]*binanceMarket, error) { var exchangeInfo struct { Timezone string `json:"timezone"` ServerTime int64 `json:"serverTime"` @@ -506,7 +517,7 @@ func (bnc *binance) getMarkets(ctx context.Context) error { } err := bnc.getAPI(ctx, "/api/v3/exchangeInfo", nil, false, false, &exchangeInfo) if err != nil { - return fmt.Errorf("error getting markets from Binance: %w", err) + return nil, fmt.Errorf("error getting markets from Binance: %w", err) } marketsMap := make(map[string]*binanceMarket, len(exchangeInfo.Symbols)) @@ -516,7 +527,7 @@ func (bnc *binance) getMarkets(ctx context.Context) error { bnc.markets.Store(marketsMap) - return nil + return marketsMap, nil } // Connect connects to the binance API. @@ -527,7 +538,7 @@ func (bnc *binance) Connect(ctx context.Context) (*sync.WaitGroup, error) { return nil, fmt.Errorf("error getting coin info: %v", err) } - if err := bnc.getMarkets(ctx); err != nil { + if _, err := bnc.getMarkets(ctx); err != nil { return nil, fmt.Errorf("error getting markets: %v", err) } @@ -543,7 +554,7 @@ func (bnc *binance) Connect(ctx context.Context) (*sync.WaitGroup, error) { for { select { case <-nextTick: - err := bnc.getMarkets(ctx) + _, err := bnc.getMarkets(ctx) if err != nil { bnc.log.Errorf("Error fetching markets: %v", err) nextTick = time.After(time.Minute) @@ -609,7 +620,7 @@ func (bnc *binance) Balance(assetID uint32) (*ExchangeBalance, error) { bnc.balanceMtx.RLock() defer bnc.balanceMtx.RUnlock() - bal, found := bnc.balances[assetConfig.coin] + bal, found := bnc.balances[assetConfig.assetID] if !found { return nil, fmt.Errorf("no %q balance found", assetConfig.coin) } @@ -933,11 +944,37 @@ func (bnc *binance) CancelTrade(ctx context.Context, baseID, quoteID uint32, tra return bnc.requestInto(req, &struct{}{}) } -func (bnc *binance) Markets() ([]*Market, error) { - binanceMarkets := bnc.markets.Load().(map[string]*binanceMarket) - markets := make([]*Market, 0, 16) +// func (bnc *binance) Markets() ([]*Market, error) { +// binanceMarkets := bnc.markets.Load().(map[string]*binanceMarket) +// markets := make([]*Market, 0, 16) +// } + +func (bnc *binance) Balances() (map[uint32]*ExchangeBalance, error) { + bnc.balanceMtx.RLock() + defer bnc.balanceMtx.RUnlock() + + balances := make(map[uint32]*ExchangeBalance) + + for assetID, bal := range bnc.balances { + assetConfig, err := bncAssetCfg(assetID) + if err != nil { + continue + } + + balances[assetConfig.assetID] = &ExchangeBalance{ + Available: uint64(bal.available * float64(assetConfig.conversionFactor)), + Locked: uint64(bal.locked * float64(assetConfig.conversionFactor)), + } + } + + return balances, nil +} + +func (bnc *binance) Markets(ctx context.Context) (_ []*Market, err error) { + bnMarkets := bnc.markets.Load().(map[string]*binanceMarket) + markets := make([]*Market, 0, len(bnMarkets)) tokenIDs := bnc.tokenIDs.Load().(map[string][]uint32) - for _, mkt := range binanceMarkets { + for _, mkt := range bnMarkets { dexMarkets := binanceMarketToDexMarkets(mkt.BaseAsset, mkt.QuoteAsset, tokenIDs) markets = append(markets, dexMarkets...) } @@ -1057,10 +1094,28 @@ func (bnc *binance) handleOutboundAccountPosition(update *binanceStreamUpdate) { bnc.log.Tracef("balance: %+v", bal) } + supportedTokens := bnc.tokenIDs.Load().(map[string][]uint32) + bnc.balanceMtx.Lock() for _, bal := range update.Balances { symbol := strings.ToLower(bal.Asset) - bnc.balances[symbol] = &bncBalance{ + // DRAFT TODO: Need to convert with binanceToDexSymbol or something, but + // with consideration for how binance combines assets. + if parentIDs := dex.TokenChains[symbol]; parentIDs != nil { + supported := supportedTokens[symbol] + for _, tokenID := range supported { + bnc.balances[tokenID] = &bncBalance{ + available: bal.Free, + locked: bal.Locked, + } + } + continue + } + assetID, found := dex.BipSymbolID(symbol) + if !found { + continue + } + bnc.balances[assetID] = &bncBalance{ available: bal.Free, locked: bal.Locked, } @@ -1557,6 +1612,56 @@ func (bnc *binance) VWAP(baseID, quoteID uint32, sell bool, qty uint64) (avgPric return book.vwap(!sell, qty) } +// type binanceNetworkInfo struct { +// AddressRegex string `json:"addressRegex"` +// Coin string `json:"coin"` +// DepositEnable bool `json:"depositEnable"` +// IsDefault bool `json:"isDefault"` +// MemoRegex string `json:"memoRegex"` +// MinConfirm int `json:"minConfirm"` +// Name string `json:"name"` +// Network string `json:"network"` +// ResetAddressStatus bool `json:"resetAddressStatus"` +// SpecialTips string `json:"specialTips"` +// UnLockConfirm int `json:"unLockConfirm"` +// WithdrawEnable bool `json:"withdrawEnable"` +// WithdrawFee float64 `json:"withdrawFee,string"` +// WithdrawIntegerMultiple float64 `json:"withdrawIntegerMultiple,string"` +// WithdrawMax float64 `json:"withdrawMax,string"` +// WithdrawMin float64 `json:"withdrawMin,string"` +// SameAddress bool `json:"sameAddress"` +// EstimatedArrivalTime int `json:"estimatedArrivalTime"` +// Busy bool `json:"busy"` +// } + +// type binanceCoinInfo struct { +// Coin string `json:"coin"` +// DepositAllEnable bool `json:"depositAllEnable"` +// Free float64 `json:"free,string"` +// Freeze float64 `json:"freeze,string"` +// Ipoable float64 `json:"ipoable,string"` +// Ipoing float64 `json:"ipoing,string"` +// IsLegalMoney bool `json:"isLegalMoney"` +// Locked float64 `json:"locked,string"` +// Name string `json:"name"` +// Storage float64 `json:"storage,string"` +// Trading bool `json:"trading"` +// WithdrawAllEnable bool `json:"withdrawAllEnable"` +// Withdrawing float64 `json:"withdrawing,string"` +// NetworkList []*binanceNetworkInfo `json:"networkList"` +// } + +// type bnMarket struct { +// Symbol string `json:"symbol"` +// Status string `json:"status"` +// BaseAsset string `json:"baseAsset"` +// BaseAssetPrecision int `json:"baseAssetPrecision"` +// QuoteAsset string `json:"quoteAsset"` +// QuoteAssetPrecision int `json:"quoteAssetPrecision"` +// OrderTypes []string `json:"orderTypes"` +// Permissions []string `json:"permissions"` +// } + // dexMarkets returns all the possible dex markets for this binance market. // A symbol represents a single market on the CEX, but tokens on the DEX // have a different assetID for each network they are on, therefore they will diff --git a/client/mm/libxc/interface.go b/client/mm/libxc/interface.go index 0faebfa215..22473280e5 100644 --- a/client/mm/libxc/interface.go +++ b/client/mm/libxc/interface.go @@ -25,8 +25,8 @@ type TradeUpdate struct { // Market is the base and quote assets of a market on a CEX. type Market struct { - BaseID uint32 `json:"base"` - QuoteID uint32 `json:"quote"` + BaseID uint32 `json:"baseID"` + QuoteID uint32 `json:"quoteID"` } // CEX implements a set of functions that can be used to interact with a @@ -40,7 +40,7 @@ type CEX interface { // CancelTrade cancels a trade on the CEX. CancelTrade(ctx context.Context, baseID, quoteID uint32, tradeID string) error // Markets returns the list of markets at the CEX. - Markets() ([]*Market, error) + Markets(ctx context.Context) ([]*Market, error) // SubscribeCEXUpdates returns a channel which sends an empty struct when // the balance of an asset on the CEX has been updated. SubscribeCEXUpdates() (updates <-chan interface{}, unsubscribe func()) diff --git a/client/mm/mm.go b/client/mm/mm.go index 02d5dceac2..6f76362206 100644 --- a/client/mm/mm.go +++ b/client/mm/mm.go @@ -170,17 +170,30 @@ func (m *MarketWithHost) String() string { return fmt.Sprintf("%s-%d-%d", m.Host, m.BaseID, m.QuoteID) } +type centralizedExchange struct { + libxc.CEX + *CEXConfig + + mtx sync.RWMutex + cm *dex.ConnectionMaster + mkts []*libxc.Market +} + // MarketMaker handles the market making process. It supports running different // strategies on different markets. type MarketMaker struct { ctx context.Context - die context.CancelFunc + die atomic.Value // context.CancelFunc running atomic.Bool log dex.Logger core clientCore doNotKillWhenBotsStop bool // used for testing botBalances map[string]*botBalances cfgPath string + + cfgMtx sync.RWMutex + cfg *MarketMakingConfig + // syncedOracle is only available while the MarketMaker is running. It // periodically refreshes the prices for the markets that have bots // running on them. @@ -197,31 +210,36 @@ type MarketMaker struct { ordersMtx sync.RWMutex orders map[order.OrderID]*orderInfo + + cexMtx sync.RWMutex + cexes map[string]*centralizedExchange } // NewMarketMaker creates a new MarketMaker. func NewMarketMaker(c clientCore, cfgPath string, log dex.Logger) (*MarketMaker, error) { - if _, err := os.Stat(cfgPath); err != nil { - cfg := new(MarketMakingConfig) - cfgB, err := json.Marshal(cfg) - if err != nil { - return nil, fmt.Errorf("failed to marshal empty config file: %w", err) - } - err = os.WriteFile(cfgPath, cfgB, 0644) - if err != nil { - return nil, fmt.Errorf("failed to write empty config file: %w", err) + var cfg MarketMakingConfig + if b, err := os.ReadFile(cfgPath); err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("error reading config file from %q: %w", cfgPath, err) + } else if len(b) > 0 { + if err := json.Unmarshal(b, &cfg); err != nil { + return nil, fmt.Errorf("error unmarshaling config file: %v", err) } } - return &MarketMaker{ + m := &MarketMaker{ core: c, log: log, cfgPath: cfgPath, + cfg: &cfg, running: atomic.Bool{}, orders: make(map[order.OrderID]*orderInfo), runningBots: make(map[MarketWithHost]interface{}), unsyncedOracle: newUnsyncedPriceOracle(log), - }, nil + cexes: make(map[string]*centralizedExchange), + } + var dummyCancel context.CancelFunc = func() {} + m.die.Store(dummyCancel) + return m, nil } // Running returns true if the MarketMaker is running. @@ -247,13 +265,29 @@ func marketsRequiringPriceOracle(cfgs []*BotConfig) []*mkt { for _, cfg := range cfgs { if cfg.requiresPriceOracle() { - mkts = append(mkts, &mkt{base: cfg.BaseAsset, quote: cfg.QuoteAsset}) + mkts = append(mkts, &mkt{baseID: cfg.BaseID, quoteID: cfg.QuoteID}) } } return mkts } +// duplicateBotConfig returns an error if there is more than one bot config for +// the same market on the same dex host. +func duplicateBotConfig(cfgs []*BotConfig) error { + mkts := make(map[string]struct{}) + + for _, cfg := range cfgs { + mkt := dexMarketID(cfg.Host, cfg.BaseID, cfg.QuoteID) + if _, found := mkts[mkt]; found { + return fmt.Errorf("duplicate bot config for market %s", mkt) + } + mkts[mkt] = struct{}{} + } + + return nil +} + func priceOracleFromConfigs(ctx context.Context, cfgs []*BotConfig, log dex.Logger) (*priceOracle, error) { var oracle *priceOracle var err error @@ -278,7 +312,7 @@ func (m *MarketMaker) markBotAsRunning(mkt MarketWithHost, running bool) { } if len(m.runningBots) == 0 { - m.die() + m.Stop() } } @@ -327,47 +361,31 @@ func (m *MarketMaker) loginAndUnlockWallets(pw []byte, cfgs []*BotConfig) error } unlocked := make(map[uint32]any) for _, cfg := range cfgs { - if _, done := unlocked[cfg.BaseAsset]; !done { - err := m.core.OpenWallet(cfg.BaseAsset, pw) + if _, done := unlocked[cfg.BaseID]; !done { + err := m.core.OpenWallet(cfg.BaseID, pw) if err != nil { - return fmt.Errorf("failed to unlock wallet for asset %d: %w", cfg.BaseAsset, err) + return fmt.Errorf("failed to unlock wallet for asset %d: %w", cfg.BaseID, err) } - unlocked[cfg.BaseAsset] = true + unlocked[cfg.BaseID] = true } - if _, done := unlocked[cfg.QuoteAsset]; !done { - err := m.core.OpenWallet(cfg.QuoteAsset, pw) + if _, done := unlocked[cfg.QuoteID]; !done { + err := m.core.OpenWallet(cfg.QuoteID, pw) if err != nil { - return fmt.Errorf("failed to unlock wallet for asset %d: %w", cfg.QuoteAsset, err) + return fmt.Errorf("failed to unlock wallet for asset %d: %w", cfg.QuoteID, err) } - unlocked[cfg.QuoteAsset] = true + unlocked[cfg.QuoteID] = true } } return nil } -// duplicateBotConfig returns an error if there is more than one bot config for -// the same market on the same dex host. -func duplicateBotConfig(cfgs []*BotConfig) error { - mkts := make(map[string]struct{}) - - for _, cfg := range cfgs { - mkt := dexMarketID(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) - if _, found := mkts[mkt]; found { - return fmt.Errorf("duplicate bot config for market %s", mkt) - } - mkts[mkt] = struct{}{} - } - - return nil -} - func validateAndFilterEnabledConfigs(cfgs []*BotConfig) ([]*BotConfig, error) { enabledCfgs := make([]*BotConfig, 0, len(cfgs)) for _, cfg := range cfgs { if cfg.requiresCEX() && cfg.CEXCfg == nil { - mktID := dexMarketID(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) + mktID := dexMarketID(cfg.Host, cfg.BaseID, cfg.QuoteID) return nil, fmt.Errorf("bot at %s requires cex config", mktID) } if !cfg.Disabled { @@ -432,11 +450,9 @@ func (m *MarketMaker) setupBalances(cfgs []*BotConfig, cexes map[string]libxc.CE if err != nil { return err } - cexBalances[assetSymbol] = &trackedBalance{ available: balance.Available, } - return nil } @@ -448,40 +464,40 @@ func (m *MarketMaker) setupBalances(cfgs []*BotConfig, cexes map[string]libxc.CE } for _, cfg := range cfgs { - err := trackAssetOnDEX(cfg.BaseAsset) + err := trackAssetOnDEX(cfg.BaseID) if err != nil { return err } - err = trackAssetOnDEX(cfg.QuoteAsset) + err = trackAssetOnDEX(cfg.QuoteID) if err != nil { return err } - mktID := dexMarketID(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) + mktID := dexMarketID(cfg.Host, cfg.BaseID, cfg.QuoteID) // Calculate DEX balances - baseBalance := dexBalanceTracker[cfg.BaseAsset] - quoteBalance := dexBalanceTracker[cfg.QuoteAsset] + baseBalance := dexBalanceTracker[cfg.BaseID] + quoteBalance := dexBalanceTracker[cfg.QuoteID] baseRequired := calcBalance(cfg.BaseBalanceType, cfg.BaseBalance, baseBalance.available) quoteRequired := calcBalance(cfg.QuoteBalanceType, cfg.QuoteBalance, quoteBalance.available) if baseRequired == 0 && quoteRequired == 0 { return fmt.Errorf("both base and quote balance are zero for market %s", mktID) } if baseRequired > baseBalance.available-baseBalance.reserved { - return fmt.Errorf("insufficient balance for asset %d", cfg.BaseAsset) + return fmt.Errorf("insufficient balance for asset %d", cfg.BaseID) } if quoteRequired > quoteBalance.available-quoteBalance.reserved { - return fmt.Errorf("insufficient balance for asset %d", cfg.QuoteAsset) + return fmt.Errorf("insufficient balance for asset %d", cfg.QuoteID) } baseBalance.reserved += baseRequired quoteBalance.reserved += quoteRequired m.botBalances[mktID] = &botBalances{ balances: map[uint32]*botBalance{ - cfg.BaseAsset: { + cfg.BaseID: { Available: baseRequired, }, - cfg.QuoteAsset: { + cfg.QuoteID: { Available: quoteRequired, }, }, @@ -489,23 +505,23 @@ func (m *MarketMaker) setupBalances(cfgs []*BotConfig, cexes map[string]libxc.CE // Calculate CEX balances if cfg.CEXCfg != nil { - baseSymbol := dex.BipIDSymbol(cfg.BaseAsset) + baseSymbol := dex.BipIDSymbol(cfg.BaseID) if baseSymbol == "" { - return fmt.Errorf("unknown asset ID %d", cfg.BaseAsset) + return fmt.Errorf("unknown asset ID %d", cfg.BaseID) } baseAssetSymbol := dex.TokenSymbol(baseSymbol) - quoteSymbol := dex.BipIDSymbol(cfg.QuoteAsset) + quoteSymbol := dex.BipIDSymbol(cfg.QuoteID) if quoteSymbol == "" { - return fmt.Errorf("unknown asset ID %d", cfg.QuoteAsset) + return fmt.Errorf("unknown asset ID %d", cfg.QuoteID) } quoteAssetSymbol := dex.TokenSymbol(quoteSymbol) - err = trackAssetOnCEX(baseAssetSymbol, cfg.BaseAsset, cfg.CEXCfg.Name) + err = trackAssetOnCEX(baseAssetSymbol, cfg.BaseID, cfg.CEXCfg.Name) if err != nil { return err } - err = trackAssetOnCEX(quoteAssetSymbol, cfg.QuoteAsset, cfg.CEXCfg.Name) + err = trackAssetOnCEX(quoteAssetSymbol, cfg.QuoteID, cfg.CEXCfg.Name) if err != nil { return err } @@ -517,16 +533,16 @@ func (m *MarketMaker) setupBalances(cfgs []*BotConfig, cexes map[string]libxc.CE return fmt.Errorf("both base and quote CEX balances are zero for market %s", mktID) } if cexBaseRequired > baseCEXBalance.available-baseCEXBalance.reserved { - return fmt.Errorf("insufficient CEX base balance for asset %d", cfg.BaseAsset) + return fmt.Errorf("insufficient CEX base balance for asset %d", cfg.BaseID) } if cexQuoteRequired > quoteCEXBalance.available-quoteCEXBalance.reserved { - return fmt.Errorf("insufficient CEX quote balance for asset %d", cfg.QuoteAsset) + return fmt.Errorf("insufficient CEX quote balance for asset %d", cfg.QuoteID) } baseCEXBalance.reserved += cexBaseRequired quoteCEXBalance.reserved += cexQuoteRequired m.botBalances[mktID].cexBalances = map[uint32]uint64{ - cfg.BaseAsset: cexBaseRequired, - cfg.QuoteAsset: cexQuoteRequired, + cfg.BaseID: cexBaseRequired, + cfg.QuoteID: cexQuoteRequired, } } } @@ -983,43 +999,138 @@ func (m *MarketMaker) initCEXConnections(cfgs []*CEXConfig) (map[string]libxc.CE cexes := make(map[string]libxc.CEX) cexCMs := make(map[string]*dex.ConnectionMaster) - for _, cfg := range cfgs { - if _, found := cexes[cfg.Name]; !found { - logger := m.log.SubLogger(fmt.Sprintf("CEX-%s", cfg.Name)) - cex, err := libxc.NewCEX(cfg.Name, cfg.APIKey, cfg.APISecret, logger, dex.Simnet) - if err != nil { - m.log.Errorf("Failed to create %s: %v", cfg.Name, err) - continue + findCEXConfig := func(cexName string) *CEXConfig { + for _, cfg := range cfgs { + if cfg.Name == cexName { + return cfg } + } + return nil + } - cm := dex.NewConnectionMaster(cex) - err = cm.Connect(m.ctx) + getConnectedCEX := func(cexName string) (cex libxc.CEX, err error) { + var found bool + if cex, found = cexes[cexName]; !found { + cexCfg := findCEXConfig(cexName) + if cexCfg == nil { + return nil, fmt.Errorf("no CEX config provided for %s", cexName) + } + c, err := m.loadCEX(m.ctx, cexCfg) if err != nil { - m.log.Errorf("Failed to connect to %s: %v", cfg.Name, err) - continue + return nil, fmt.Errorf("error loading CEX: %w", err) + } + var cm *dex.ConnectionMaster + c.mtx.Lock() + if c.cm == nil || !c.cm.On() { + cm = dex.NewConnectionMaster(c) + c.cm = cm + cexCMs[cexName] = cm } + c.mtx.Unlock() + if cm != nil { + if err = cm.Connect(m.ctx); err != nil { + return nil, fmt.Errorf("failed to connect to CEX: %v", err) + } + } + cex = c.CEX + cexes[cexName] = cex + } + return cex, nil + } - cexes[cfg.Name] = cex - cexCMs[cfg.Name] = cm + for _, cfg := range cfgs { + if _, err := getConnectedCEX(cfg.Name); err != nil { + m.log.Errorf("Failed to create %s: %v", cfg.Name, err) } } return cexes, cexCMs } -// Run starts the MarketMaker. There can only be one BotConfig per dex market. -func (m *MarketMaker) Run(ctx context.Context, pw []byte, alternateConfigPath *string) error { +func (m *MarketMaker) loadCEX(ctx context.Context, cfg *CEXConfig) (*centralizedExchange, error) { + m.cexMtx.Lock() + defer m.cexMtx.Unlock() + var success bool + if cex := m.cexes[cfg.Name]; cex != nil { + if cex.Name == cfg.Name && cex.APISecret == cfg.APISecret { + return cex, nil + } + // New credentials. Delete the old cex. + defer func() { + if success { + cex.mtx.Lock() + cex.cm.Disconnect() + cex.cm = nil + cex.mtx.Unlock() + } + }() + } + logger := m.log.SubLogger(fmt.Sprintf("CEX-%s", cfg.Name)) + cex, err := libxc.NewCEX(cfg.Name, cfg.APIKey, cfg.APISecret, logger, dex.Simnet) + if err != nil { + return nil, fmt.Errorf("failed to create CEX: %v", err) + } + mkts, err := cex.Markets(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get markets for %s", err) + } + c := ¢ralizedExchange{ + CEX: cex, + CEXConfig: cfg, + mkts: mkts, + } + m.cexes[cfg.Name] = c + success = true + return c, nil +} + +func (m *MarketMaker) config() *MarketMakingConfig { + m.cfgMtx.RLock() + defer m.cfgMtx.RUnlock() + return m.cfg.Copy() +} + +func (m *MarketMaker) Connect(ctx context.Context) (*sync.WaitGroup, error) { + m.ctx = ctx + cfg := m.config() + for _, cexCfg := range cfg.CexConfigs { + if _, err := m.loadCEX(ctx, cexCfg); err != nil { + return nil, fmt.Errorf("error adding %s", cexCfg.Name) + } + } + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + <-ctx.Done() + m.cexMtx.Lock() + for cexName, cex := range m.cexes { + cex.mtx.RLock() + cm := cex.cm + cex.mtx.RUnlock() + if cm != nil { + cm.Disconnect() + } + delete(m.cexes, cexName) + } + m.cexMtx.Unlock() + }() + return &wg, nil +} + +// Start the MarketMaker. There can only be one BotConfig per dex market. +func (m *MarketMaker) Start(pw []byte, alternateConfigPath *string) (err error) { if !m.running.CompareAndSwap(false, true) { return errors.New("market making is already running") } - path := m.cfgPath + cfg := m.config() if alternateConfigPath != nil { - path = *alternateConfigPath - } - cfg, err := getMarketMakingConfig(path) - if err != nil { - return fmt.Errorf("error getting market making config: %v", err) + cfg, err = getMarketMakingConfig(*alternateConfigPath) + if err != nil { + return fmt.Errorf("error loading custom market making config: %v", err) + } } var startedMarketMaking bool @@ -1029,7 +1140,8 @@ func (m *MarketMaker) Run(ctx context.Context, pw []byte, alternateConfigPath *s } }() - m.ctx, m.die = context.WithCancel(ctx) + ctx, die := context.WithCancel(m.ctx) + m.die.Store(die) enabledBots, err := validateAndFilterEnabledConfigs(cfg.BotConfigs) if err != nil { @@ -1040,7 +1152,7 @@ func (m *MarketMaker) Run(ctx context.Context, pw []byte, alternateConfigPath *s return err } - oracle, err := priceOracleFromConfigs(m.ctx, enabledBots, m.log.SubLogger("PriceOracle")) + oracle, err := priceOracleFromConfigs(ctx, enabledBots, m.log.SubLogger("PriceOracle")) if err != nil { return err } @@ -1054,7 +1166,6 @@ func (m *MarketMaker) Run(ctx context.Context, pw []byte, alternateConfigPath *s }() cexes, cexCMs := m.initCEXConnections(cfg.CexConfigs) - if err := m.setupBalances(enabledBots, cexes); err != nil { return err } @@ -1074,7 +1185,7 @@ func (m *MarketMaker) Run(ctx context.Context, pw []byte, alternateConfigPath *s for { select { - case <-m.ctx.Done(): + case <-ctx.Done(): return case n := <-feed.C: m.handleNotification(n) @@ -1096,34 +1207,34 @@ func (m *MarketMaker) Run(ctx context.Context, pw []byte, alternateConfigPath *s wg.Add(1) go func(cfg *BotConfig) { defer wg.Done() - mkt := MarketWithHost{cfg.Host, cfg.BaseAsset, cfg.QuoteAsset} + mkt := MarketWithHost{cfg.Host, cfg.BaseID, cfg.QuoteID} m.markBotAsRunning(mkt, true) defer func() { m.markBotAsRunning(mkt, false) }() - m.core.Broadcast(newBotStartStopNote(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset, true)) + m.core.Broadcast(newBotStartStopNote(cfg.Host, cfg.BaseID, cfg.QuoteID, true)) defer func() { - m.core.Broadcast(newBotStartStopNote(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset, false)) + m.core.Broadcast(newBotStartStopNote(cfg.Host, cfg.BaseID, cfg.QuoteID, false)) }() - logger := m.log.SubLogger(fmt.Sprintf("MarketMaker-%s-%d-%d", cfg.Host, cfg.BaseAsset, cfg.QuoteAsset)) - mktID := dexMarketID(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) - baseFiatRate := fiatRates[cfg.BaseAsset] - quoteFiatRate := fiatRates[cfg.QuoteAsset] + logger := m.log.SubLogger(fmt.Sprintf("MarketMaker-%s-%d-%d", cfg.Host, cfg.BaseID, cfg.QuoteID)) + mktID := dexMarketID(cfg.Host, cfg.BaseID, cfg.QuoteID) + baseFiatRate := fiatRates[cfg.BaseID] + quoteFiatRate := fiatRates[cfg.QuoteID] RunBasicMarketMaker(m.ctx, cfg, m.wrappedCoreForBot(mktID), oracle, baseFiatRate, quoteFiatRate, logger) }(cfg) case cfg.SimpleArbConfig != nil: wg.Add(1) go func(cfg *BotConfig) { defer wg.Done() - logger := m.log.SubLogger(fmt.Sprintf("SimpleArbitrage-%s-%d-%d", cfg.Host, cfg.BaseAsset, cfg.QuoteAsset)) - mktID := dexMarketID(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) + logger := m.log.SubLogger(fmt.Sprintf("SimpleArbitrage-%s-%d-%d", cfg.Host, cfg.BaseID, cfg.QuoteID)) + mktID := dexMarketID(cfg.Host, cfg.BaseID, cfg.QuoteID) cex, found := cexes[cfg.CEXCfg.Name] if !found { logger.Errorf("Cannot start %s bot due to CEX not starting", mktID) return } - mkt := MarketWithHost{cfg.Host, cfg.BaseAsset, cfg.QuoteAsset} + mkt := MarketWithHost{cfg.Host, cfg.BaseID, cfg.QuoteID} m.markBotAsRunning(mkt, true) defer func() { m.markBotAsRunning(mkt, false) @@ -1134,14 +1245,14 @@ func (m *MarketMaker) Run(ctx context.Context, pw []byte, alternateConfigPath *s wg.Add(1) go func(cfg *BotConfig) { defer wg.Done() - logger := m.log.SubLogger(fmt.Sprintf("ArbMarketMaker-%s-%d-%d", cfg.Host, cfg.BaseAsset, cfg.QuoteAsset)) + logger := m.log.SubLogger(fmt.Sprintf("ArbMarketMaker-%s-%d-%d", cfg.Host, cfg.BaseID, cfg.QuoteID)) cex, found := cexes[cfg.CEXCfg.Name] - mktID := dexMarketID(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) + mktID := dexMarketID(cfg.Host, cfg.BaseID, cfg.QuoteID) if !found { logger.Errorf("Cannot start %s bot due to CEX not starting", mktID) return } - mkt := MarketWithHost{cfg.Host, cfg.BaseAsset, cfg.QuoteAsset} + mkt := MarketWithHost{cfg.Host, cfg.BaseID, cfg.QuoteID} m.markBotAsRunning(mkt, true) defer func() { m.markBotAsRunning(mkt, false) @@ -1149,7 +1260,7 @@ func (m *MarketMaker) Run(ctx context.Context, pw []byte, alternateConfigPath *s RunArbMarketMaker(m.ctx, cfg, m.core, m.wrappedCEXForBot(mktID, cex), logger) }(cfg) default: - m.log.Errorf("No bot config provided. Skipping %s-%d-%d", cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) + m.log.Errorf("No bot config provided. Skipping %s-%d-%d", cfg.Host, cfg.BaseID, cfg.QuoteID) } } @@ -1186,21 +1297,50 @@ func getMarketMakingConfig(path string) (*MarketMakingConfig, error) { return cfg, nil } +func (m *MarketMaker) getCexes() []*centralizedExchange { + m.cexMtx.RLock() + defer m.cexMtx.RUnlock() + cs := make([]*centralizedExchange, 0, len(m.cexes)) + for _, cex := range m.cexes { + cs = append(cs, cex) + } + return cs +} + // GetMarketMakingConfig returns the market making config. -func (m *MarketMaker) GetMarketMakingConfig() (*MarketMakingConfig, error) { - return getMarketMakingConfig(m.cfgPath) +func (m *MarketMaker) GetMarketMakingConfig() (*MarketMakingConfig, map[string][]*libxc.Market, error) { + mkts := make(map[string][]*libxc.Market) + for _, cex := range m.getCexes() { + cex.mtx.RLock() + mkts[cex.Name] = cex.mkts + cex.mtx.RUnlock() + } + return m.config(), mkts, nil } -// UpdateMarketMakingConfig updates the configuration for one of the bots. -func (m *MarketMaker) UpdateBotConfig(updatedCfg *BotConfig) (*MarketMakingConfig, error) { - cfg, err := m.GetMarketMakingConfig() +func (m *MarketMaker) writeConfigFile(cfg *MarketMakingConfig) error { + data, err := json.MarshalIndent(cfg, "", " ") if err != nil { - return nil, fmt.Errorf("error getting market making config: %v", err) + return fmt.Errorf("error marshalling market making config: %v", err) } + err = os.WriteFile(m.cfgPath, data, 0644) + if err != nil { + return fmt.Errorf("error writing market making config: %v", err) + } + m.cfgMtx.Lock() + m.cfg = cfg + m.cfgMtx.Unlock() + return nil +} + +// UpdateBotConfig updates the configuration for one of the bots. +func (m *MarketMaker) UpdateBotConfig(updatedCfg *BotConfig) (*MarketMakingConfig, error) { + cfg := m.config() + var updated bool for i, c := range cfg.BotConfigs { - if c.Host == updatedCfg.Host && c.QuoteAsset == updatedCfg.QuoteAsset && c.BaseAsset == updatedCfg.BaseAsset { + if c.Host == updatedCfg.Host && c.QuoteID == updatedCfg.QuoteID && c.BaseID == updatedCfg.BaseID { cfg.BotConfigs[i] = updatedCfg updated = true break @@ -1210,28 +1350,43 @@ func (m *MarketMaker) UpdateBotConfig(updatedCfg *BotConfig) (*MarketMakingConfi cfg.BotConfigs = append(cfg.BotConfigs, updatedCfg) } - data, err := json.MarshalIndent(cfg, "", " ") + return cfg, m.writeConfigFile(cfg) +} + +func (m *MarketMaker) UpdateCEXConfig(updatedCfg *CEXConfig) (*MarketMakingConfig, []*libxc.Market, error) { + cfg := m.config() + + cex, err := m.loadCEX(m.ctx, updatedCfg) if err != nil { - return nil, fmt.Errorf("error marshalling market making config: %v", err) + return nil, nil, fmt.Errorf("error loading %s with updated config: %w", updatedCfg.Name, err) } - err = os.WriteFile(m.cfgPath, data, 0644) - if err != nil { - return nil, fmt.Errorf("error writing market making config: %v", err) + cex.mtx.RLock() + mkts := cex.mkts + cex.mtx.RUnlock() + + var updated bool + for i, c := range cfg.CexConfigs { + if c.Name == updatedCfg.Name { + cfg.CexConfigs[i] = updatedCfg + updated = true + break + } } - return cfg, nil + if !updated { + cfg.CexConfigs = append(cfg.CexConfigs, updatedCfg) + } + + return cfg, mkts, m.writeConfigFile(cfg) } // RemoveConfig removes a bot config from the market making config. func (m *MarketMaker) RemoveBotConfig(host string, baseID, quoteID uint32) (*MarketMakingConfig, error) { - cfg, err := m.GetMarketMakingConfig() - if err != nil { - return nil, fmt.Errorf("error getting market making config: %v", err) - } + cfg := m.config() var updated bool for i, c := range cfg.BotConfigs { - if c.Host == host && c.QuoteAsset == quoteID && c.BaseAsset == baseID { + if c.Host == host && c.QuoteID == quoteID && c.BaseID == baseID { cfg.BotConfigs = append(cfg.BotConfigs[:i], cfg.BotConfigs[i+1:]...) updated = true break @@ -1241,21 +1396,10 @@ func (m *MarketMaker) RemoveBotConfig(host string, baseID, quoteID uint32) (*Mar return nil, fmt.Errorf("config not found") } - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return nil, fmt.Errorf("error marshalling market making config: %v", err) - } - - err = os.WriteFile(m.cfgPath, data, 0644) - if err != nil { - return nil, fmt.Errorf("error writing market making config: %v", err) - } - return cfg, nil + return cfg, m.writeConfigFile(cfg) } // Stop stops the MarketMaker. func (m *MarketMaker) Stop() { - if m.die != nil { - m.die() - } + m.die.Load().(context.CancelFunc)() } diff --git a/client/mm/mm_arb_market_maker.go b/client/mm/mm_arb_market_maker.go index a6cab2a0b9..6a7d2f6726 100644 --- a/client/mm/mm_arb_market_maker.go +++ b/client/mm/mm_arb_market_maker.go @@ -77,7 +77,7 @@ type ArbMarketMakerConfig struct { SellPlacements []*ArbMarketMakingPlacement `json:"sellPlacements"` Profit float64 `json:"profit"` DriftTolerance float64 `json:"driftTolerance"` - NumEpochsLeaveOpen uint64 `json:"numEpochsLeaveOpen"` + NumEpochsLeaveOpen uint64 `json:"orderPersistence"` BaseOptions map[string]string `json:"baseOptions"` QuoteOptions map[string]string `json:"quoteOptions"` // AutoRebalance determines how the bot will handle rebalancing of the @@ -951,7 +951,7 @@ func RunArbMarketMaker(ctx context.Context, cfg *BotConfig, c clientCore, cex ce return } - mkt, err := c.ExchangeMarket(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) + mkt, err := c.ExchangeMarket(cfg.Host, cfg.BaseID, cfg.QuoteID) if err != nil { log.Errorf("Failed to get market: %v", err) return @@ -960,8 +960,8 @@ func RunArbMarketMaker(ctx context.Context, cfg *BotConfig, c clientCore, cex ce (&arbMarketMaker{ ctx: ctx, host: cfg.Host, - baseID: cfg.BaseAsset, - quoteID: cfg.QuoteAsset, + baseID: cfg.BaseID, + quoteID: cfg.QuoteID, cex: cex, core: c, log: log, diff --git a/client/mm/mm_basic.go b/client/mm/mm_basic.go index b4f92b7b13..33e022f573 100644 --- a/client/mm/mm_basic.go +++ b/client/mm/mm_basic.go @@ -869,11 +869,11 @@ func RunBasicMarketMaker(ctx context.Context, cfg *BotConfig, c clientCore, orac err := cfg.BasicMMConfig.Validate() if err != nil { - c.Broadcast(newValidationErrorNote(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset, fmt.Sprintf("invalid market making config: %v", err))) + c.Broadcast(newValidationErrorNote(cfg.Host, cfg.BaseID, cfg.QuoteID, fmt.Sprintf("invalid market making config: %v", err))) return } - mkt, err := c.ExchangeMarket(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) + mkt, err := c.ExchangeMarket(cfg.Host, cfg.BaseID, cfg.QuoteID) if err != nil { log.Errorf("Failed to get market: %v. Not starting market maker.", err) return @@ -885,8 +885,8 @@ func RunBasicMarketMaker(ctx context.Context, cfg *BotConfig, c clientCore, orac log: log, cfg: cfg.BasicMMConfig, host: cfg.Host, - base: cfg.BaseAsset, - quote: cfg.QuoteAsset, + base: cfg.BaseID, + quote: cfg.QuoteID, oracle: oracle, mkt: mkt, ords: make(map[order.OrderID]*core.Order), diff --git a/client/mm/mm_simple_arb.go b/client/mm/mm_simple_arb.go index ceec31d01f..b3c6e6a3cb 100644 --- a/client/mm/mm_simple_arb.go +++ b/client/mm/mm_simple_arb.go @@ -624,7 +624,7 @@ func RunSimpleArbBot(ctx context.Context, cfg *BotConfig, c clientCore, cex cex, return } - mkt, err := c.ExchangeMarket(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) + mkt, err := c.ExchangeMarket(cfg.Host, cfg.BaseID, cfg.QuoteID) if err != nil { log.Errorf("Failed to get market: %v", err) return @@ -633,8 +633,8 @@ func RunSimpleArbBot(ctx context.Context, cfg *BotConfig, c clientCore, cex cex, (&simpleArbMarketMaker{ ctx: ctx, host: cfg.Host, - baseID: cfg.BaseAsset, - quoteID: cfg.QuoteAsset, + baseID: cfg.BaseID, + quoteID: cfg.QuoteID, cex: cex, core: c, log: log, diff --git a/client/mm/mm_simple_arb_test.go b/client/mm/mm_simple_arb_test.go index b70ba2f4b5..d9e1453d25 100644 --- a/client/mm/mm_simple_arb_test.go +++ b/client/mm/mm_simple_arb_test.go @@ -76,12 +76,12 @@ func newTCEX() *tCEX { var _ libxc.CEX = (*tCEX)(nil) func (c *tCEX) Connect(ctx context.Context) (*sync.WaitGroup, error) { - return nil, nil + return &sync.WaitGroup{}, nil } func (c *tCEX) Balances() (map[uint32]*libxc.ExchangeBalance, error) { return nil, nil } -func (c *tCEX) Markets() ([]*libxc.Market, error) { +func (c *tCEX) Markets(ctx context.Context) ([]*libxc.Market, error) { return nil, nil } func (c *tCEX) Balance(assetID uint32) (*libxc.ExchangeBalance, error) { diff --git a/client/mm/mm_test.go b/client/mm/mm_test.go index bf9e60bf1b..eb4ded1d06 100644 --- a/client/mm/mm_test.go +++ b/client/mm/mm_test.go @@ -437,8 +437,8 @@ func TestSetupBalances(t *testing.T) { cfgs: []*BotConfig{ { Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -446,8 +446,8 @@ func TestSetupBalances(t *testing.T) { }, { Host: "host1", - BaseAsset: 42, - QuoteAsset: 60, + BaseID: 42, + QuoteID: 60, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -478,8 +478,8 @@ func TestSetupBalances(t *testing.T) { cfgs: []*BotConfig{ { Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -487,8 +487,8 @@ func TestSetupBalances(t *testing.T) { }, { Host: "host1", - BaseAsset: 42, - QuoteAsset: 60, + BaseID: 42, + QuoteID: 60, BaseBalanceType: Percentage, BaseBalance: 51, QuoteBalanceType: Percentage, @@ -510,8 +510,8 @@ func TestSetupBalances(t *testing.T) { cfgs: []*BotConfig{ { Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 499, QuoteBalanceType: Percentage, @@ -519,8 +519,8 @@ func TestSetupBalances(t *testing.T) { }, { Host: "host1", - BaseAsset: 42, - QuoteAsset: 60, + BaseID: 42, + QuoteID: 60, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -551,8 +551,8 @@ func TestSetupBalances(t *testing.T) { cfgs: []*BotConfig{ { Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 501, QuoteBalanceType: Percentage, @@ -560,8 +560,8 @@ func TestSetupBalances(t *testing.T) { }, { Host: "host1", - BaseAsset: 42, - QuoteAsset: 60, + BaseID: 42, + QuoteID: 60, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -583,8 +583,8 @@ func TestSetupBalances(t *testing.T) { cfgs: []*BotConfig{ { Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -599,8 +599,8 @@ func TestSetupBalances(t *testing.T) { }, { Host: "host1", - BaseAsset: 42, - QuoteAsset: 60, + BaseID: 42, + QuoteID: 60, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -660,8 +660,8 @@ func TestSetupBalances(t *testing.T) { cfgs: []*BotConfig{ { Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -676,8 +676,8 @@ func TestSetupBalances(t *testing.T) { }, { Host: "host1", - BaseAsset: 42, - QuoteAsset: 60, + BaseID: 42, + QuoteID: 60, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -714,8 +714,8 @@ func TestSetupBalances(t *testing.T) { cfgs: []*BotConfig{ { Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -731,8 +731,8 @@ func TestSetupBalances(t *testing.T) { }, { Host: "host1", - BaseAsset: 42, - QuoteAsset: 60, + BaseID: 42, + QuoteID: 60, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -795,8 +795,8 @@ func TestSetupBalances(t *testing.T) { cfgs: []*BotConfig{ { Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -812,8 +812,8 @@ func TestSetupBalances(t *testing.T) { }, { Host: "host1", - BaseAsset: 42, - QuoteAsset: 60, + BaseID: 42, + QuoteID: 60, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -856,8 +856,8 @@ func TestSetupBalances(t *testing.T) { cfgs: []*BotConfig{ { Host: "host1", - BaseAsset: 60001, - QuoteAsset: 0, + BaseID: 60001, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -873,8 +873,8 @@ func TestSetupBalances(t *testing.T) { }, { Host: "host1", - BaseAsset: 966001, - QuoteAsset: 60, + BaseID: 966001, + QuoteID: 60, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -936,8 +936,8 @@ func TestSetupBalances(t *testing.T) { cfgs: []*BotConfig{ { Host: "host1", - BaseAsset: 60001, - QuoteAsset: 0, + BaseID: 60001, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -953,8 +953,8 @@ func TestSetupBalances(t *testing.T) { }, { Host: "host1", - BaseAsset: 966001, - QuoteAsset: 60, + BaseID: 966001, + QuoteID: 60, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -1103,8 +1103,8 @@ func TestSegregatedCoreMaxSell(t *testing.T) { name: "ok", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -1132,8 +1132,8 @@ func TestSegregatedCoreMaxSell(t *testing.T) { name: "1 lot", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 1e6 + 1000, QuoteBalanceType: Amount, @@ -1161,8 +1161,8 @@ func TestSegregatedCoreMaxSell(t *testing.T) { name: "not enough for 1 swap", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 1e6 + 999, QuoteBalanceType: Amount, @@ -1183,8 +1183,8 @@ func TestSegregatedCoreMaxSell(t *testing.T) { name: "not enough for 1 lot of redeem fees", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 60, + BaseID: 42, + QuoteID: 60, BaseBalanceType: Amount, BaseBalance: 1e6 + 1000, QuoteBalanceType: Amount, @@ -1205,8 +1205,8 @@ func TestSegregatedCoreMaxSell(t *testing.T) { name: "redeem fees don't matter if not account locker", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 1e6 + 1000, QuoteBalanceType: Amount, @@ -1234,8 +1234,8 @@ func TestSegregatedCoreMaxSell(t *testing.T) { name: "2 lots with refund fees, not account locker", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 2e6 + 2000, QuoteBalanceType: Amount, @@ -1264,8 +1264,8 @@ func TestSegregatedCoreMaxSell(t *testing.T) { name: "1 lot with refund fees, account locker", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 60, + BaseID: 42, + QuoteID: 60, BaseBalanceType: Amount, BaseBalance: 2e6 + 2000, QuoteBalanceType: Amount, @@ -1311,12 +1311,12 @@ func TestSegregatedCoreMaxSell(t *testing.T) { } mkt := dcrBtcID - if test.cfg.QuoteAsset == 60 { + if test.cfg.QuoteID == 60 { mkt = dcrEthID } segregatedCore := mm.wrappedCoreForBot(mkt) - res, err := segregatedCore.MaxSell("host1", test.cfg.BaseAsset, test.cfg.QuoteAsset) + res, err := segregatedCore.MaxSell("host1", test.cfg.BaseID, test.cfg.QuoteID) if test.wantErr { if err == nil { t.Fatalf("%s: expected error but did not get", test.name) @@ -1396,8 +1396,8 @@ func TestSegregatedCoreMaxBuy(t *testing.T) { name: "ok", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -1427,8 +1427,8 @@ func TestSegregatedCoreMaxBuy(t *testing.T) { name: "1 lot", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 1000, QuoteBalanceType: Amount, @@ -1458,8 +1458,8 @@ func TestSegregatedCoreMaxBuy(t *testing.T) { name: "not enough for 1 swap", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 1000, QuoteBalanceType: Amount, @@ -1481,8 +1481,8 @@ func TestSegregatedCoreMaxBuy(t *testing.T) { name: "not enough for 1 lot of redeem fees", cfg: &BotConfig{ Host: "host1", - BaseAsset: 60, - QuoteAsset: 0, + BaseID: 60, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 999, QuoteBalanceType: Amount, @@ -1504,8 +1504,8 @@ func TestSegregatedCoreMaxBuy(t *testing.T) { name: "only account locker affected by redeem fees", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 999, QuoteBalanceType: Amount, @@ -1535,8 +1535,8 @@ func TestSegregatedCoreMaxBuy(t *testing.T) { name: "2 lots with refund fees, not account locker", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 1000, QuoteBalanceType: Amount, @@ -1567,8 +1567,8 @@ func TestSegregatedCoreMaxBuy(t *testing.T) { name: "1 lot with refund fees, account locker", cfg: &BotConfig{ Host: "host1", - BaseAsset: 60, - QuoteAsset: 0, + BaseID: 60, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 1000, QuoteBalanceType: Amount, @@ -1615,11 +1615,11 @@ func TestSegregatedCoreMaxBuy(t *testing.T) { } mkt := dcrBtcID - if test.cfg.BaseAsset != 42 { + if test.cfg.BaseID != 42 { mkt = ethBtcID } segregatedCore := mm.wrappedCoreForBot(mkt) - res, err := segregatedCore.MaxBuy("host1", test.cfg.BaseAsset, test.cfg.QuoteAsset, test.rate) + res, err := segregatedCore.MaxBuy("host1", test.cfg.BaseID, test.cfg.QuoteID, test.rate) if test.wantErr { if err == nil { t.Fatalf("%s: expected error but did not get", test.name) @@ -1703,8 +1703,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "cancelled order, 1/2 lots filled, sell", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -1869,8 +1869,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "cancelled order, 1/2 lots filled, buy", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -2031,8 +2031,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "fully filled order, sell", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -2297,8 +2297,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "fully filled order, buy", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -2510,8 +2510,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "fully filled order, sell, accountLocker", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -2778,8 +2778,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "fully filled order, buy, accountLocker", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -2995,8 +2995,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "buy, 1 refunded, 1 revoked before swap, 1 redeemed match, not accountLocker", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -3210,8 +3210,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "sell, 1 refunded, 1 revoked before swap, 1 redeemed match, not accountLocker", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -3425,8 +3425,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "buy, 1 refunded, 1 revoked before swap, 1 redeemed match, accountLocker", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -3641,8 +3641,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "sell, 1 refunded, 1 revoked before swap, 1 redeemed match, accountLocker", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, @@ -3857,8 +3857,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "edge enough balance for single buy", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 5e6, QuoteBalanceType: Amount, @@ -3906,8 +3906,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "edge not enough balance for single buy", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 5e6, QuoteBalanceType: Amount, @@ -3939,8 +3939,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "edge enough balance for single sell", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 5e6 + 1500, QuoteBalanceType: Amount, @@ -3990,8 +3990,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "edge not enough balance for single sell", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 5e6 + 1499, QuoteBalanceType: Amount, @@ -4023,8 +4023,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "edge enough balance for single buy with redeem fees", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 1000, QuoteBalanceType: Amount, @@ -4069,8 +4069,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "edge not enough balance for single buy due to redeem fees", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 999, QuoteBalanceType: Amount, @@ -4101,8 +4101,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "edge enough balance for single sell with redeem fees", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 5e6 + 1000, QuoteBalanceType: Amount, @@ -4148,8 +4148,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "edge not enough balance for single sell due to redeem fees", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 5e6 + 1000, QuoteBalanceType: Amount, @@ -4181,8 +4181,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "edge enough balance for multi buy", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 5e6, QuoteBalanceType: Amount, @@ -4244,8 +4244,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "edge not enough balance for multi buy", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 5e6, QuoteBalanceType: Amount, @@ -4284,8 +4284,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "edge enough balance for multi sell", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 1e7 + 2500, QuoteBalanceType: Amount, @@ -4348,8 +4348,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "edge not enough balance for multi sell", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 1e7 + 2499, QuoteBalanceType: Amount, @@ -4389,8 +4389,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "edge enough balance for multi buy with redeem fees", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 2000, QuoteBalanceType: Amount, @@ -4450,8 +4450,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "edge not enough balance for multi buy due to redeem fees", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 1999, QuoteBalanceType: Amount, @@ -4490,8 +4490,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "edge enough balance for multi sell with redeem fees", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 1e7 + 2000, QuoteBalanceType: Amount, @@ -4552,8 +4552,8 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { name: "edge enough balance for multi sell with redeem fees", cfg: &BotConfig{ Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Amount, BaseBalance: 1e7 + 2000, QuoteBalanceType: Amount, @@ -4595,7 +4595,7 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { return } - mktID := dexMarketID(test.cfg.Host, test.cfg.BaseAsset, test.cfg.QuoteAsset) + mktID := dexMarketID(test.cfg.Host, test.cfg.BaseID, test.cfg.QuoteID) tCore := newTCore() tCore.setAssetBalances(test.assetBalances) @@ -4641,9 +4641,12 @@ func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() mm.UpdateBotConfig(test.cfg) - err := mm.Run(ctx, []byte{}, nil) + _, err := mm.Connect(ctx) if err != nil { - t.Fatalf("%s: unexpected error: %v", test.name, err) + t.Fatalf("%s: Connect error: %v", test.name, err) + } + if err := mm.Start([]byte{}, nil); err != nil { + t.Fatalf("%s: Start error: %v", test.name, err) } segregatedCore := mm.wrappedCoreForBot(mktID) @@ -4725,8 +4728,8 @@ func TestSegregatedCEXTrade(t *testing.T) { cfg *BotConfig assetBalances map[uint32]uint64 cexBalances map[uint32]uint64 - baseAsset uint32 - quoteAsset uint32 + baseID uint32 + quoteID uint32 sell bool rate uint64 qty uint64 @@ -4740,8 +4743,8 @@ func TestSegregatedCEXTrade(t *testing.T) { name: "sell trade fully filled", cfg: &BotConfig{ Host: "host", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 100, QuoteBalanceType: Percentage, @@ -4762,11 +4765,11 @@ func TestSegregatedCEXTrade(t *testing.T) { 42: 1e7, 0: 1e7, }, - baseAsset: 42, - quoteAsset: 0, - sell: true, - rate: 5e7, - qty: 2e6, + baseID: 42, + quoteID: 0, + sell: true, + rate: 5e7, + qty: 2e6, postTradeBals: map[uint32]uint64{ 42: 1e7 - 2e6, 0: 1e7, @@ -4802,8 +4805,8 @@ func TestSegregatedCEXTrade(t *testing.T) { name: "buy trade fully filled", cfg: &BotConfig{ Host: "host", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 100, QuoteBalanceType: Percentage, @@ -4824,11 +4827,11 @@ func TestSegregatedCEXTrade(t *testing.T) { 42: 1e7, 0: 1e7, }, - baseAsset: 42, - quoteAsset: 0, - sell: false, - rate: 5e7, - qty: 2e6, + baseID: 42, + quoteID: 0, + sell: false, + rate: 5e7, + qty: 2e6, postTradeBals: map[uint32]uint64{ 42: 1e7, 0: 1e7 - calc.BaseToQuote(5e7, 2e6), @@ -4864,8 +4867,8 @@ func TestSegregatedCEXTrade(t *testing.T) { name: "sell trade partially filled", cfg: &BotConfig{ Host: "host", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 100, QuoteBalanceType: Percentage, @@ -4886,11 +4889,11 @@ func TestSegregatedCEXTrade(t *testing.T) { 42: 1e7, 0: 1e7, }, - baseAsset: 42, - quoteAsset: 0, - sell: true, - rate: 5e7, - qty: 2e6, + baseID: 42, + quoteID: 0, + sell: true, + rate: 5e7, + qty: 2e6, postTradeBals: map[uint32]uint64{ 42: 1e7 - 2e6, 0: 1e7, @@ -4926,8 +4929,8 @@ func TestSegregatedCEXTrade(t *testing.T) { name: "buy trade partially filled", cfg: &BotConfig{ Host: "host", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 100, QuoteBalanceType: Percentage, @@ -4948,11 +4951,11 @@ func TestSegregatedCEXTrade(t *testing.T) { 42: 1e7, 0: 1e7, }, - baseAsset: 42, - quoteAsset: 0, - sell: false, - rate: 5e7, - qty: 2e6, + baseID: 42, + quoteID: 0, + sell: false, + rate: 5e7, + qty: 2e6, postTradeBals: map[uint32]uint64{ 42: 1e7, 0: 1e7 - calc.BaseToQuote(5e7, 2e6), @@ -5007,7 +5010,7 @@ func TestSegregatedCEXTrade(t *testing.T) { mm.setupBalances(botCfgs, cexes) - mktID := dexMarketID(tt.cfg.Host, tt.cfg.BaseAsset, tt.cfg.QuoteAsset) + mktID := dexMarketID(tt.cfg.Host, tt.cfg.BaseID, tt.cfg.QuoteID) wrappedCEX := mm.wrappedCEXForBot(mktID, cex) _, unsubscribe := wrappedCEX.SubscribeTradeUpdates() @@ -5016,7 +5019,7 @@ func TestSegregatedCEXTrade(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - _, err := wrappedCEX.Trade(ctx, tt.baseAsset, tt.quoteAsset, tt.sell, tt.rate, tt.qty) + _, err := wrappedCEX.Trade(ctx, tt.baseID, tt.quoteID, tt.sell, tt.rate, tt.qty) if err != nil { t.Fatalf("%s: unexpected Trade error: %v", tt.name, err) } @@ -5076,8 +5079,8 @@ func TestSegregatedCEXDeposit(t *testing.T) { }, cfg: &BotConfig{ Host: "dex.com", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 100, QuoteBalanceType: Percentage, @@ -5120,8 +5123,8 @@ func TestSegregatedCEXDeposit(t *testing.T) { }, cfg: &BotConfig{ Host: "dex.com", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 100, QuoteBalanceType: Percentage, @@ -5153,8 +5156,8 @@ func TestSegregatedCEXDeposit(t *testing.T) { }, cfg: &BotConfig{ Host: "dex.com", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 100, QuoteBalanceType: Percentage, @@ -5205,7 +5208,7 @@ func TestSegregatedCEXDeposit(t *testing.T) { mm, done := tNewMarketMaker(t, tCore) defer done() mm.setupBalances([]*BotConfig{tt.cfg}, map[string]libxc.CEX{cexName: cex}) - mktID := dexMarketID(tt.cfg.Host, tt.cfg.BaseAsset, tt.cfg.QuoteAsset) + mktID := dexMarketID(tt.cfg.Host, tt.cfg.BaseID, tt.cfg.QuoteID) wrappedCEX := mm.wrappedCEXForBot(mktID, cex) wg := sync.WaitGroup{} @@ -5278,8 +5281,8 @@ func TestSegregatedCEXWithdraw(t *testing.T) { }, cfg: &BotConfig{ Host: "dex.com", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 100, QuoteBalanceType: Percentage, @@ -5320,8 +5323,8 @@ func TestSegregatedCEXWithdraw(t *testing.T) { }, cfg: &BotConfig{ Host: "dex.com", - BaseAsset: 42, - QuoteAsset: 0, + BaseID: 42, + QuoteID: 0, BaseBalanceType: Percentage, BaseBalance: 100, QuoteBalanceType: Percentage, @@ -5359,15 +5362,45 @@ func TestSegregatedCEXWithdraw(t *testing.T) { mm, done := tNewMarketMaker(t, tCore) defer done() mm.setupBalances([]*BotConfig{tt.cfg}, map[string]libxc.CEX{cexName: cex}) - mktID := dexMarketID(tt.cfg.Host, tt.cfg.BaseAsset, tt.cfg.QuoteAsset) + mktID := dexMarketID(tt.cfg.Host, tt.cfg.BaseID, tt.cfg.QuoteID) wrappedCEX := mm.wrappedCEXForBot(mktID, cex) + // mm.doNotKillWhenBotsStop = true + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cexCM := dex.NewConnectionMaster(cex) + if err := cexCM.Connect(ctx); err != nil { + t.Fatalf("error connecting tCEX: %v", err) + } + cexConfig := &CEXConfig{ + Name: libxc.Binance, + } + mm.UpdateBotConfig(tt.cfg) + mm.UpdateCEXConfig(cexConfig) + mm.cexes[libxc.Binance] = ¢ralizedExchange{ + CEX: cex, + CEXConfig: cexConfig, + cm: cexCM, + mkts: []*libxc.Market{}, + } + _, err := mm.Connect(ctx) + if err != nil { + t.Fatalf("%s: Connect error: %v", tt.name, err) + } + if err := mm.Start([]byte{}, nil); err != nil { + t.Fatalf("%s: Start error: %v", tt.name, err) + } + + // segregatedCore := mm.wrappedCoreForBot(mktID) + + // if testMultiTrade { wg := sync.WaitGroup{} wg.Add(1) onConfirm := func() { wg.Done() } - err := wrappedCEX.Withdraw(context.Background(), tt.withdrawAsset, tt.withdrawAmt, onConfirm) + err = wrappedCEX.Withdraw(context.Background(), tt.withdrawAsset, tt.withdrawAmt, onConfirm) if err != nil { if tt.expError { return diff --git a/client/mm/price_oracle.go b/client/mm/price_oracle.go index daf66043e5..133514719d 100644 --- a/client/mm/price_oracle.go +++ b/client/mm/price_oracle.go @@ -170,11 +170,11 @@ func (o *priceOracle) getOracleInfo(base, quote uint32) (float64, []*OracleRepor } type mkt struct { - base, quote uint32 + baseID, quoteID uint32 } func (mkt *mkt) String() string { - return fmt.Sprintf("%d-%d", mkt.base, mkt.quote) + return fmt.Sprintf("%d-%d", mkt.baseID, mkt.quoteID) } func newCachedPrice(baseID, quoteID uint32, registeredAssets map[uint32]*asset.RegisteredAsset) (*cachedPrice, error) { @@ -207,7 +207,7 @@ func newAutoSyncPriceOracle(ctx context.Context, markets []*mkt, log dex.Logger) continue } - cachedPrice, err := newCachedPrice(mkt.base, mkt.quote, registeredAssets) + cachedPrice, err := newCachedPrice(mkt.baseID, mkt.quoteID, registeredAssets) if err != nil { return nil, err } @@ -431,6 +431,10 @@ func oracleMarketReport(ctx context.Context, b, q *asset.RegisteredAsset, log de return false } + if mkt.MarketURL == "" { + return false + } + if time.Since(mkt.LastUpdated) > time.Minute*30 { return false } diff --git a/client/mm/sample-config.json b/client/mm/sample-config.json index e734577a3e..f6a8bf219d 100644 --- a/client/mm/sample-config.json +++ b/client/mm/sample-config.json @@ -2,8 +2,8 @@ "botConfigs": [ { "host": "127.0.0.1:17273", - "baseAsset": 60, - "quoteAsset": 0, + "baseID": 60, + "quoteID": 0, "baseBalanceType": 0, "quoteBalanceType": 0, "baseBalance": 100, @@ -15,7 +15,7 @@ "baseBalance": 100, "quoteBalance": 100 }, - "arbMarketMakerConfig": { + "arbMarketMakingConfig": { "cexName": "Binance", "buyPlacements": [ { @@ -55,7 +55,7 @@ ], "profit": 0.01, "driftTolerance" : 0.001, - "numEpochsLeaveOpen": 10, + "orderPersistence": 10, "baseOptions": { "multisplit": "true" }, diff --git a/client/rpcserver/handlers.go b/client/rpcserver/handlers.go index 052ac455e2..cd8128c495 100644 --- a/client/rpcserver/handlers.go +++ b/client/rpcserver/handlers.go @@ -930,7 +930,7 @@ func handleStartMarketMaking(s *RPCServer, params *RawParams) *msgjson.ResponseP return usage(startMarketMakingRoute, err) } - err = s.mm.Run(s.ctx, form.appPass, &form.cfgFilePath) + err = s.mm.Start(form.appPass, &form.cfgFilePath) if err != nil { errMsg := fmt.Sprintf("unable to start market making: %v", err) resErr := msgjson.NewError(msgjson.RPCStartMarketMakingError, errMsg) diff --git a/client/webserver/api.go b/client/webserver/api.go index b4ece852da..e28f176d8a 100644 --- a/client/webserver/api.go +++ b/client/webserver/api.go @@ -14,6 +14,7 @@ import ( "decred.org/dcrdex/client/core" "decred.org/dcrdex/client/db" "decred.org/dcrdex/client/mm" + "decred.org/dcrdex/client/mm/libxc" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/config" "decred.org/dcrdex/dex/encode" @@ -1909,7 +1910,7 @@ func (s *WebServer) apiStartMarketMaking(w http.ResponseWriter, r *http.Request) s.writeAPIError(w, fmt.Errorf("password error: %w", err)) return } - if err = s.mm.Run(s.ctx, appPW, nil); err != nil { + if err = s.mm.Start(appPW, nil); err != nil { s.writeAPIError(w, fmt.Errorf("Error starting market making: %v", err)) return } @@ -1923,22 +1924,48 @@ func (s *WebServer) apiStopMarketMaking(w http.ResponseWriter, r *http.Request) } func (s *WebServer) apiMarketMakingConfig(w http.ResponseWriter, r *http.Request) { - cfg, err := s.mm.GetMarketMakingConfig() + cfg, mkts, err := s.mm.GetMarketMakingConfig() if err != nil { s.writeAPIError(w, fmt.Errorf("error getting market making config: %v", err)) return } writeJSON(w, &struct { - OK bool `json:"ok"` - Cfg *mm.MarketMakingConfig `json:"cfg"` + OK bool `json:"ok"` + Cfg *mm.MarketMakingConfig `json:"cfg"` + Mkts map[string][]*libxc.Market `json:"mkts"` }{ - OK: true, - Cfg: cfg, + OK: true, + Cfg: cfg, + Mkts: mkts, + }, s.indent) +} + +func (s *WebServer) apiUpdateCEXConfig(w http.ResponseWriter, r *http.Request) { + var updatedCfg *mm.CEXConfig + if !readPost(w, r, &updatedCfg) { + s.writeAPIError(w, fmt.Errorf("failed to read config")) + return + } + + cfg, mkts, err := s.mm.UpdateCEXConfig(updatedCfg) + if err != nil { + s.writeAPIError(w, err) + return + } + + writeJSON(w, &struct { + OK bool `json:"ok"` + Cfg *mm.MarketMakingConfig `json:"cfg"` + Mkts []*libxc.Market `json:"mkts"` + }{ + OK: true, + Cfg: cfg, + Mkts: mkts, }, s.indent) } -func (s *WebServer) apiUpdateMarketMakingConfig(w http.ResponseWriter, r *http.Request) { +func (s *WebServer) apiUpdateBotConfig(w http.ResponseWriter, r *http.Request) { var updatedCfg *mm.BotConfig if !readPost(w, r, &updatedCfg) { s.writeAPIError(w, fmt.Errorf("failed to read config")) diff --git a/client/webserver/live_test.go b/client/webserver/live_test.go index 72351ea5d0..73f2cf9ec5 100644 --- a/client/webserver/live_test.go +++ b/client/webserver/live_test.go @@ -29,6 +29,8 @@ import ( "decred.org/dcrdex/client/comms" "decred.org/dcrdex/client/core" "decred.org/dcrdex/client/db" + "decred.org/dcrdex/client/mm" + "decred.org/dcrdex/client/mm/libxc" "decred.org/dcrdex/client/orderbook" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/calc" @@ -2065,6 +2067,137 @@ func (c *TCore) DisableFundsMixer(assetID uint32) error { return nil } +var binanceMarkets = []*libxc.Market{ + {BaseID: 42, QuoteID: 0}, + {BaseID: 145, QuoteID: 42}, + {BaseID: 60, QuoteID: 42}, + {BaseID: 2, QuoteID: 42}, + // {3, 0}, + // {3, 42}, + // {22, 42}, + // {28, 0}, + // {60000, 42}, +} + +type TMarketMaker struct { + core *TCore + running atomic.Bool + cfg *mm.MarketMakingConfig + mkts map[string][]*libxc.Market +} + +func (m *TMarketMaker) MarketReport(baseID, quoteID uint32) (*mm.MarketReport, error) { + baseFiatRate := math.Pow10(3 - rand.Intn(6)) + quoteFiatRate := math.Pow10(3 - rand.Intn(6)) + price := baseFiatRate / quoteFiatRate + mktID := dex.BipIDSymbol(baseID) + "_" + dex.BipIDSymbol(quoteID) + midGap, _ := getMarketStats(mktID) + return &mm.MarketReport{ + BaseFiatRate: baseFiatRate, + QuoteFiatRate: quoteFiatRate, + Price: price, + Oracles: []*mm.OracleReport{ + { + Host: "bittrex.com", + USDVol: math.Pow10(rand.Intn(7)), + BestBuy: midGap * 99 / 100, + BestSell: midGap * 101 / 100, + }, + { + Host: "bittrex.com", + USDVol: math.Pow10(rand.Intn(7)), + BestBuy: midGap * 98 / 100, + BestSell: midGap * 102 / 100, + }, + }, + }, nil +} + +func (m *TMarketMaker) Start(pw []byte, alternateConfigPath *string) (err error) { + m.running.Store(true) + m.core.noteFeed <- &struct { + db.Notification + Running bool `json:"running"` + }{ + Notification: db.NewNotification("mmstartstop", "", "", "", db.Data), + Running: true, + } + return nil +} + +func (m *TMarketMaker) Stop() { + m.running.Store(false) + m.core.noteFeed <- &struct { + db.Notification + Running bool `json:"running"` + }{ + Notification: db.NewNotification("mmstartstop", "", "", "", db.Data), + Running: false, + } +} + +func (m *TMarketMaker) GetMarketMakingConfig() (*mm.MarketMakingConfig, map[string][]*libxc.Market, error) { + return m.cfg, m.mkts, nil +} + +func (m *TMarketMaker) UpdateCEXConfig(updatedCfg *mm.CEXConfig) (*mm.MarketMakingConfig, []*libxc.Market, error) { + switch updatedCfg.Name { + case libxc.Binance, libxc.BinanceUS: + m.mkts[updatedCfg.Name] = binanceMarkets + } + for i := 0; i < len(m.cfg.CexConfigs); i++ { + cfg := m.cfg.CexConfigs[i] + if cfg.Name == updatedCfg.Name { + m.cfg.CexConfigs[i] = updatedCfg + return m.cfg, m.mkts[updatedCfg.Name], nil + } + } + m.cfg.CexConfigs = append(m.cfg.CexConfigs, updatedCfg) + return m.cfg, m.mkts[updatedCfg.Name], nil +} + +func (m *TMarketMaker) UpdateBotConfig(updatedCfg *mm.BotConfig) (*mm.MarketMakingConfig, error) { + for i := 0; i < len(m.cfg.BotConfigs); i++ { + botCfg := m.cfg.BotConfigs[i] + if botCfg.Host == updatedCfg.Host && botCfg.BaseID == updatedCfg.BaseID && botCfg.QuoteID == updatedCfg.QuoteID { + m.cfg.BotConfigs[i] = updatedCfg + return m.cfg, nil + } + } + m.cfg.BotConfigs = append(m.cfg.BotConfigs, updatedCfg) + return m.cfg, nil +} + +func (m *TMarketMaker) RemoveBotConfig(host string, baseID, quoteID uint32) (*mm.MarketMakingConfig, error) { + for i := 0; i < len(m.cfg.BotConfigs); i++ { + botCfg := m.cfg.BotConfigs[i] + if botCfg.Host == host && botCfg.BaseID == baseID && botCfg.QuoteID == quoteID { + copy(m.cfg.BotConfigs[i:], m.cfg.BotConfigs[i+1:]) + m.cfg.BotConfigs = m.cfg.BotConfigs[:len(m.cfg.BotConfigs)-1] + } + } + return m.cfg, nil +} + +func (m *TMarketMaker) Running() bool { + return m.running.Load() +} + +func (m *TMarketMaker) RunningBots() []mm.MarketWithHost { + if !m.running.Load() { + return []mm.MarketWithHost{} + } + ms := make([]mm.MarketWithHost, 0) + for _, botCfg := range m.cfg.BotConfigs { + ms = append(ms, mm.MarketWithHost{ + Host: botCfg.Host, + BaseID: botCfg.BaseID, + QuoteID: botCfg.QuoteID, + }) + } + return ms +} + func TestServer(t *testing.T) { // Register dummy drivers for unimplemented assets. asset.Register(22, &TDriver{}) // mona @@ -2079,8 +2212,8 @@ func TestServer(t *testing.T) { numBuys = 10 numSells = 10 feedPeriod = 5000 * time.Millisecond - initialize := false - register := false + initialize := true + register := true forceDisconnectWallet = true gapWidthFactor = 0.2 randomPokes = false @@ -2110,11 +2243,17 @@ func TestServer(t *testing.T) { } s, err := New(&Config{ - Core: tCore, - Addr: "127.0.0.1:54321", - Logger: logger, - NoEmbed: true, // use files on disk, and reload on each page load - HttpProf: true, + Core: tCore, + MarketMaker: &TMarketMaker{ + core: tCore, + cfg: &mm.MarketMakingConfig{}, + mkts: make(map[string][]*libxc.Market), + }, + Experimental: true, + Addr: "127.0.0.1:54321", + Logger: logger, + NoEmbed: true, // use files on disk, and reload on each page load + HttpProf: true, }) if err != nil { t.Fatalf("error creating server: %v", err) diff --git a/client/webserver/locales/en-us.go b/client/webserver/locales/en-us.go index dd4ebe31c1..7d05e914a4 100644 --- a/client/webserver/locales/en-us.go +++ b/client/webserver/locales/en-us.go @@ -364,6 +364,7 @@ var EnUS = map[string]string{ "Paused": "Paused", "Start": "Start", "Drift tolerance": "Drift Tolerance", + "Order persistence": "Order persistence", "Oracle bias": "Oracle Bias", "Multiplier": "Multiplier", "Oracle weight": "Oracle Weight", @@ -447,6 +448,7 @@ var EnUS = map[string]string{ "base_asset_balance_tooltip": "The amount of the base asset to allocate for this bot. If the upper end of this range is < 100%, it means that the rest of the balance was allocated to other bots.", "quote_asset_balance_tooltip": "The amount of the quote asset to allocate for this bot. If the upper end of this range is < 100%, it means that the rest of the balance was allocated to other bots.", "drift_tolerance_tooltip": "How far from the ideal price orders are allowed to drift before they are cancelled and re-booked.", + "order_persistence_tooltip": "How long CEX orders that don't immediately match be are allowed to remain booked", "no_balance_available": "No balance available", "quote_asset_balance": "Quote Asset Balance", "base_wallet_settings": "Base Wallet Settings", @@ -529,4 +531,12 @@ var EnUS = map[string]string{ "privacy_intro": "When privacy is enabled, all of your funds will be sent through an address history obfuscation service using a protocol called CoinShuffle++.", "decred_privacy": "Decred's form of privacy is especially powerful because Decred wallets integrate privacy with staking, which facilitates a consistently large anonymity set, a critical feature for privacy.", "privacy_optional": "Privacy is completely optional, and can be disabled at any time. There are increased transaction fees associated with privacy, but these fees have historically been relatively negligible.", + "bots_running_view_only": "Bots are running. You are in view-only mode.", + "select_a_cex_prompt": "Select an exchange to enable arbitrage", + "Market not available": "Market not available", + "bot_profit_title": "Choose your profit threshold", + "bot_profit_explainer": "The arbitrage bot will only attempt trades which would result in this minimum level of profit.", + "configure_cex_prompt": "Configure your exchange API to enable arbitrage features.", + "API Key": "API Key", + "API Secret": "API Secret", } diff --git a/client/webserver/site/src/css/application.scss b/client/webserver/site/src/css/application.scss index e0383ac860..fe5e732e40 100644 --- a/client/webserver/site/src/css/application.scss +++ b/client/webserver/site/src/css/application.scss @@ -86,4 +86,4 @@ $grid-breakpoints: ( @import "./forms_dark.scss"; @import "./dex_settings.scss"; @import "./mm.scss"; -@import "./mm_settings.scss"; +@import "./mmsettings.scss"; diff --git a/client/webserver/site/src/css/forms.scss b/client/webserver/site/src/css/forms.scss index 12d8e245c9..8c35cc4328 100644 --- a/client/webserver/site/src/css/forms.scss +++ b/client/webserver/site/src/css/forms.scss @@ -355,12 +355,30 @@ a[data-tmpl=walletCfgGuide] { border: 1px solid $light_border_color; } +.order-opt { + opacity: 0.7; + + &:not(.selected) { + cursor: pointer; + } + + &.selected { + opacity: 1; + background-color: #e8ebed; + + div.opt-check { + background-color: #2cce9c; + } + } +} + +.slider-container, .order-opt { border: 1px solid #424242; border-radius: 3px; padding: 4px; margin-top: 8px; - opacity: 0.7; + opacity: 0.9; div.opt-check { width: 15px; @@ -394,19 +412,6 @@ a[data-tmpl=walletCfgGuide] { cursor: pointer; } } - - &:not(.selected) { - cursor: pointer; - } - - &.selected { - opacity: 1; - background-color: #e8ebed; - - div.opt-check { - background-color: #2cce9c; - } - } } div[data-tmpl=scoreTray] { diff --git a/client/webserver/site/src/css/main.scss b/client/webserver/site/src/css/main.scss index 08bc90a326..7fc8b79e58 100644 --- a/client/webserver/site/src/css/main.scss +++ b/client/webserver/site/src/css/main.scss @@ -489,12 +489,13 @@ hr.dashed { } div.form-closer { - display: flex; - width: 30px; + @extend .flex-center; + @extend .hoverbg; + + width: 40px; margin-left: auto; - padding: 10px; - font-size: 11px; cursor: pointer; + padding: 10px 0; span { opacity: 0.8; @@ -569,6 +570,7 @@ div[data-handler=init], div[data-handler=login], div[data-handler=markets], div[data-handler=mm], +div[data-handler=mmsettings], div[data-handler=order], div[data-handler=orders], div[data-handler=register], diff --git a/client/webserver/site/src/css/main_dark.scss b/client/webserver/site/src/css/main_dark.scss index 8cb00a9d34..f23f6afeae 100644 --- a/client/webserver/site/src/css/main_dark.scss +++ b/client/webserver/site/src/css/main_dark.scss @@ -114,10 +114,12 @@ body.dark { border: 1px solid $dark_input_border; } - input:disabled { + input:disabled, + .form-select:disabled { color: $font-color-dark; - background-color: #343232; + background-color: $dark_body_bg; opacity: 1; + cursor: not-allowed; } div.popup-notes > span { @@ -168,6 +170,7 @@ body.dark { div[data-handler=dexsettings], div[data-handler=markets], div[data-handler=mm], + div[data-handler=mmsettings], div[data-handler=order], div[data-handler=orders], div[data-handler=settings], diff --git a/client/webserver/site/src/css/market.scss b/client/webserver/site/src/css/market.scss index 6b823b01de..3cc18fca36 100644 --- a/client/webserver/site/src/css/market.scss +++ b/client/webserver/site/src/css/market.scss @@ -540,45 +540,8 @@ div[data-handler=markets] { } .order-opt { - border: 1px solid #424242; - border-radius: 3px; - padding: 4px; - margin-top: 8px; opacity: 0.7; - div.opt-check { - width: 15px; - height: 15px; - border-radius: 7.5px; - border: 2px solid #424242; - margin-top: 5px; - cursor: pointer; - } - - .xy-range-input { - width: 35px; - font-size: 14px; - height: 16px; - } - - .slider { - margin: 10px 10px 5px; - height: 2px; - background-color: grey; - position: relative; - - .slider-handle { - position: absolute; - height: 20px; - width: 14px; - top: -9px; - border-radius: 7px; - background-color: #2cce9c; - border: 2px solid #424242; - cursor: pointer; - } - } - &:not(.selected) { cursor: pointer; } diff --git a/client/webserver/site/src/css/mm.scss b/client/webserver/site/src/css/mm.scss index e33d6a351e..03bcdde809 100644 --- a/client/webserver/site/src/css/mm.scss +++ b/client/webserver/site/src/css/mm.scss @@ -220,6 +220,23 @@ } } +div[data-handler=mm], +div[data-handler=mmsettings] { + .on-indicator { + width: 15px; + height: 15px; + border-radius: 8px; + + &.on { + background-color: $buycolor_light; + } + + &.off { + background-color: #777; + } + } +} + body.dark #main.mm { #marketSelect option { background-color: $dark_body_bg; diff --git a/client/webserver/site/src/css/mm_settings.scss b/client/webserver/site/src/css/mmsettings.scss similarity index 63% rename from client/webserver/site/src/css/mm_settings.scss rename to client/webserver/site/src/css/mmsettings.scss index dc6338df7b..44fee9737a 100644 --- a/client/webserver/site/src/css/mm_settings.scss +++ b/client/webserver/site/src/css/mmsettings.scss @@ -17,38 +17,6 @@ } } - .slider-container { - border: 1px solid #424242; - border-radius: 3px; - padding: 4px; - margin-top: 8px; - opacity: 0.9; - - .xy-range-input { - width: 35px; - font-size: 14px; - height: 16px; - } - - .slider { - margin: 10px 10px 5px; - height: 2px; - background-color: grey; - position: relative; - - .slider-handle { - position: absolute; - height: 20px; - width: 14px; - top: -9px; - border-radius: 7px; - background-color: #2cce9c; - border: 2px solid #424242; - cursor: pointer; - } - } - } - .modified { border: 1px solid orange !important; } @@ -110,10 +78,36 @@ font-size: 16px; border: 1px solid $dark_border_color; } -} -body.dark #main.mmsettings { - #gapStrategySelect:disabled { - background-color: #343232; + .xclogo { + &.large { + width: 60px; + height: 60px; + } + + &.medium { + width: 30px; + height: 30px; + } + + &.off { + filter: grayscale(1); + } + } + + .cex-selector { + &:not(.selected) { + opacity: 0.8; + transform: scale(0.95); + } + + &.selected { + border-color: green; + border-width: 2px; + } + } + + #profitInput { + width: 70px; } } diff --git a/client/webserver/site/src/css/order.scss b/client/webserver/site/src/css/order.scss index 73b3b23b3c..ca53305452 100644 --- a/client/webserver/site/src/css/order.scss +++ b/client/webserver/site/src/css/order.scss @@ -47,35 +47,3 @@ div.match-card { #accelerateForm { width: 500px; } - -.slider-container { - border: 1px solid #424242; - border-radius: 3px; - padding: 4px; - margin-top: 8px; - opacity: 0.9; - - .xy-range-input { - width: 35px; - font-size: 14px; - height: 16px; - } - - .slider { - margin: 10px 10px 5px; - height: 2px; - background-color: grey; - position: relative; - - .slider-handle { - position: absolute; - height: 20px; - width: 14px; - top: -9px; - border-radius: 7px; - background-color: #2cce9c; - border: 2px solid #424242; - cursor: pointer; - } - } -} diff --git a/client/webserver/site/src/html/forms.tmpl b/client/webserver/site/src/html/forms.tmpl index 464d6d7357..0af1815b8b 100644 --- a/client/webserver/site/src/html/forms.tmpl +++ b/client/webserver/site/src/html/forms.tmpl @@ -51,7 +51,7 @@ {{end}} {{define "newWalletForm"}} -
+
@@ -109,7 +109,7 @@ {{end}} {{define "unlockWalletForm"}} -
+
[[[Unlock]]] @@ -132,7 +132,7 @@ {{end}} {{define "depositAddress"}} -
+
[[[Deposit]]] @@ -166,7 +166,7 @@ {{end}} {{define "dexAddrForm"}} -
+
[[[Add a DEX]]]
[[[update dex host]]]
@@ -441,7 +441,7 @@ {{end}} {{define "authorizeAccountExportForm"}} -
+
[[[Authorize Export]]]
@@ -460,7 +460,7 @@ {{end}} {{define "disableAccountForm"}} -
+
[[[Disable Account]]]
@@ -484,7 +484,7 @@ {{define "authorizeAccountImportForm"}} {{$passwordIsCached := .UserInfo.PasswordIsCached}} -
+
[[[Authorize Import]]]
@@ -512,7 +512,7 @@ {{end}} {{define "changeAppPWForm"}} -
+
[[[Change Application Password]]]
@@ -536,7 +536,7 @@ {{define "cancelOrderForm"}} {{$passwordIsCached := .UserInfo.PasswordIsCached}} -
+
[[[:title:cancel_order]]]
@@ -557,7 +557,7 @@ {{define "accelerateForm"}} {{$passwordIsCached := .UserInfo.PasswordIsCached}} -
+
[[[:title:accelerate_order]]]
@@ -692,7 +692,7 @@
-
+
@@ -713,7 +713,7 @@ {{end}} {{define "toggleWalletStatusConfirm"}} -
+
[[[disable_wallet]]] [[[enable_wallet]]] @@ -729,7 +729,7 @@ {{end}} {{define "appPassResetForm"}} -
+
[[[Reset App Password]]]
[[[reset_app_pw_msg]]]

diff --git a/client/webserver/site/src/html/markets.tmpl b/client/webserver/site/src/html/markets.tmpl index 674929945c..fa43fc2240 100644 --- a/client/webserver/site/src/html/markets.tmpl +++ b/client/webserver/site/src/html/markets.tmpl @@ -590,7 +590,7 @@ {{- /* VERIFY FORM */ -}}
-
+
@@ -820,7 +820,7 @@
-
+
[[[:title:Approve]]]
diff --git a/client/webserver/site/src/html/mm.tmpl b/client/webserver/site/src/html/mm.tmpl index 06a564779b..7ef509141e 100644 --- a/client/webserver/site/src/html/mm.tmpl +++ b/client/webserver/site/src/html/mm.tmpl @@ -68,7 +68,14 @@ -
- + + + + + + + + @@ -96,7 +103,7 @@ {{- /* APP PASSWORD */ -}} -
+
@@ -117,7 +124,7 @@ {{- /* ABSOLUTELY-POSITIONED CUSTOM ASSET SELECT */ -}}
-
+
[[[add_new_bot_market]]]
diff --git a/client/webserver/site/src/html/mmsettings.tmpl b/client/webserver/site/src/html/mmsettings.tmpl index b658454e64..03c310b7a8 100644 --- a/client/webserver/site/src/html/mmsettings.tmpl +++ b/client/webserver/site/src/html/mmsettings.tmpl @@ -17,39 +17,78 @@
-
-
[[[gap_strategy]]]
- + {{- /* VIEW-ONLY MODE */ -}} +
+ [[[bots_running_view_only]]]
- {{- /* STRATEGY DESCRIPTIONS */ -}} -
- [[[strategy_percent_plus]]] -
-
- [[[strategy_percent]]] -
-
- [[[strategy_absolute_plus]]] -
-
- [[[strategy_absolute]]] + {{- /* BOT TYPE CONFIGURATION */ -}} +
+
+
[[[select_a_cex_prompt]]]
+
+
+
+ + +
+ [[[Configure]]] + [[[Market not available]]] +
+
+
-
- [[[strategy_multiplier]]] + {{- /* STRATEGY SELECTION */ -}} +
+
+
+
[[[gap_strategy]]]
+ +
+ {{- /* STRATEGY DESCRIPTIONS */ -}} +
+ [[[strategy_percent_plus]]] +
+
+ [[[strategy_percent]]] +
+
+ [[[strategy_absolute_plus]]] +
+
+ [[[strategy_absolute]]] +
+
+ [[[strategy_multiplier]]] +
- +
+
+ [[[bot_profit_title]]] + + [[[bot_profit_explainer]]] + +
+
+
+ + % +
+
+
+
+
[[[buy_placements]]] @@ -135,7 +174,7 @@
-
+
+
+
+ [[[Order persistence]]] + +
+
+
+
-
+
[[[base_asset_balance]]]
[[[no_balance_available]]]
+
@@ -197,7 +246,7 @@
-
+
[[[quote_asset_balance]]] @@ -205,6 +254,7 @@
[[[no_balance_available]]]
+
@@ -264,6 +314,29 @@
+
+ +
+
+ [[[configure_cex_prompt]]] +
+
+ +
+
+
+ + +
+
+ + +
+
+
[[[Submit]]]
+ +
+
{{template "orderOptionTemplates"}}
{{template "bottom"}} {{end}} diff --git a/client/webserver/site/src/html/orders.tmpl b/client/webserver/site/src/html/orders.tmpl index 5579f2df2e..ae44fbb14b 100644 --- a/client/webserver/site/src/html/orders.tmpl +++ b/client/webserver/site/src/html/orders.tmpl @@ -89,7 +89,7 @@ {{- /* POP UP FORMS */ -}}
{{- /* DELETE ARCHIVED RECORDS FORM */ -}} -
+
[[[delete_archived_records]]]
diff --git a/client/webserver/site/src/html/settings.tmpl b/client/webserver/site/src/html/settings.tmpl index d3886603ba..cb483a7fd0 100644 --- a/client/webserver/site/src/html/settings.tmpl +++ b/client/webserver/site/src/html/settings.tmpl @@ -120,7 +120,7 @@ {{- /* EXPORT SEED AUTHORIZATION */ -}} -
+
[[[Export Seed]]]
@@ -139,7 +139,7 @@ {{- /* SEED DISPLAY */ -}} -
+
[[[dont_share]]]

diff --git a/client/webserver/site/src/html/wallets.tmpl b/client/webserver/site/src/html/wallets.tmpl
index a78e5a4d1b..958c6bfff3 100644
--- a/client/webserver/site/src/html/wallets.tmpl
+++ b/client/webserver/site/src/html/wallets.tmpl
@@ -421,7 +421,7 @@
 
     {{- /* SEND */ -}}
     
-      
+
[[[Send]]] @@ -481,7 +481,7 @@ {{- /* Verify Send */ -}} -
+
[[[confirm]]] @@ -550,7 +550,7 @@ {{- /* RECONFIGURE WALLET */ -}} -
+
[[[Reconfigure]]] @@ -620,7 +620,7 @@ {{- /* MANAGE PEERS */ -}} -
+
[[[manage_peers]]]
@@ -654,7 +654,7 @@ {{- /* UNAPPROVE TOKEN VERSIONS TABLE FORM */ -}} -
+
[[[unapprove_token_for]]]
@@ -681,7 +681,7 @@ {{- /* UNAPPROVE SPECIFIC TOKEN VERSION FORM */ -}} -
+
[[[unapprove_token_version]]]
@@ -708,7 +708,7 @@ {{- /* CONFIRM FORCE FORM */ -}} -
+
[[[wallet_actively_used]]]
@@ -724,7 +724,7 @@ {{- /* RECOVER WALLET AUTHORIZATION */ -}} -
+
[[[recover_wallet]]]
@@ -743,7 +743,7 @@ {{- /* EXPORT WALLET AUTHORIZATION */ -}} -
+
[[[export_wallet]]]
@@ -765,7 +765,7 @@ {{- /* RESTORE WALLET INFO */ -}} -
+
[[[export_wallet]]]
@@ -795,14 +795,14 @@ {{- /* Error Modal */ -}} -
+
[[[Error]]]
{{- /* PICK A VOTING SERVICE PROVIDER */ -}}
-
+
[[[select_vsp_from_list]]]
@@ -840,7 +840,7 @@ {{- /* PURCHASE TICKETS */ -}} -
+
[[[Purchase Tickets]]]
@@ -878,7 +878,7 @@ {{- /* TICKET HISTORY */ -}} -
+
[[[Ticket History]]]
@@ -913,7 +913,7 @@ {{- /* SET VOTES */ -}} -
+
{{- /* AGENDAS */ -}}
[[[Agendas]]]
diff --git a/client/webserver/site/src/img/binance.us.png b/client/webserver/site/src/img/binance.us.png new file mode 100644 index 0000000000000000000000000000000000000000..a510b321c2016bbe9a2d5b2f690c5ce6719900dd GIT binary patch literal 4499 zcmX|FWmME(wEYbXT|>%%NSEXg0)o=rsfbF$(5ax*NC-oWBQbzT{^=Y+I;AC4Qo36j zL7F$b_161v?)`Gsx$B<0@80`fq=B9~1t}9L000!48Y)J(+3q%o32~$6Qj!U7BJtEP z_W=OPj@tnBcog1dKz&u8`WkyU`1)CU+XH@ne!@=fE(@QC7?Msmw6DBx*O$kS+h<8iSYU><1KUcx7b-5UvN1fOcIaMd{O zUbD+e*KqyR5RVT;$%jme@686}RjfI!H3ww&E_zy_`V{&60H4nTpV{cU|1Z=`>+$(8 za0#^{m7Qn(zdSRm=3z#={?Ybkmh40DSfhg}^>3pIl_|E&yRm~JemY%ZE(FL2U7gBa zKE!PEr`hymRB3)E{wG3$E>@~y0tCp{oyG43*NE}U`3ts+*C_U8X3h_DD#7{qReET+ z@jxPzIW`VqI{AZk!^lvsrLn?x-zN{HOXZNncDsVUkk}qDA)`DJvtqcK*VB9;u1%AI z8emckH5zEz$G#yVj&q)(U2ZbWd6eePa@sWXW%GL}B)9g8Tbsgp87O6H%&?H;*(R%) zKIT>Hv zkCOk{v`dD6~l|ke5X{9N@{}jLTv{z5pQcoZbB~ z!Z9H4bd38XEuoT(=8)${GJP--;jQa4N}N@o)N(3me;7hzPtP@auN95pLX7l5i2z!d z8`YcT-xwLxAqfp;>CQ(bO~`$FvvC!bX(gkk(l>_o)Vcahb>J*TpkT}HFerUHh4`3Sy;hDM4?M&3nPy|SQ< zuLFJhLXRbPd|%6=8u7;)Q#UWpCsZX#nuL~gB{uE!CAn$2Mlq$ld_VEOD)1&u3u(Vk z`}|KvjhE6tbo`jS;R){r2kkjWOQ&=Cc2N#js7mfBDUE*t%I2?y(PkP?4D21RRdZxY zYe@p@M`(6bVv`|nfVfQ^8@1)77U3g&s|-D!Ol-=quw$$8kFhrTuOU?7jlPFZx)yqr z=2WWmP7ZAf*@BD$xvR?mx8m3jy`BjjirL_4d|>z2_X>^hIdo#mr_~(U_arn2T@@w@ zhJa)PqurF94EkllUSCSItW`i1G^8RXM1d>o_Xh9V4zOXdZUu0Pd^}#*bGY^K!Mb8( zPIUnz`w-gL> zew9R9IeQW$2N~5-(7%i=&)V9ZXl;{tcqzzuOyC>xiv>Gd^hD6w0GC@{$L%|5&1sverMX`Dc^0*d;Z}DCtSvAjq*2`Z zZ6_7ab{OJ?w+^Rk#Qr0gRMpH?AMV!{A~XnRv*w>;!o&~3)pbUcGmjqk6x#F6g0uXF zty7^=jaMhaWnL;x2PMVb;r}>{hNXP|BpiU8)4sDD8Q+6`?T?T8Y9_0UU(Fm<7^mR!9Q23w6CMb1 z!qZByR0b4-L=|>+5t3PBdY#F2NN5o7$WfE!tY&<;4(?LQ!!7KOw{Rep@K%rrp#gK54QF=B!tML68>{AZX9sg?7IR%=({>!Q@P)L3W599`JKFODM~p1LX~ zyx^Bzt`<1-W^p5wdfB@jzd$Pd&UW6G#jS9S9xU0!#>%N@X;-B?F4TAB|2@Wm7*_qj zG$>MK4`o`wkappNgzaAbR7ak;VhCFb$KSB4>c-@*W4gQ38#_KvmmS(XdaGJ;ilt|>b4JVCMyaKibqeYd$FYG-!% zlbrr5R(h2tBoCoHAw@OruJdkdZ=CCh^HX1nNcfhNK%v2kx)Gjwx1AWtRwb(mdA*)> z5AR2!>Rj2AM`{|;wW8K!CeNWGRx^3Yd|h0j;-2+I4i1Kuq%>}EW8~%o`r=+NpPPnJ z+wh=+G?LLUn|n5|AbB3M3&}CMV8@*PTN&zmQj>nHN{OtdqQO=g^dv~*Xq%y-qaCLJ zZ>+XL?}?Mf$||*le}aWD4WXVUaR|s}DRW4_6Q=H5X7)+{d99?If6V92V*6QwxZHx( z^R7_t(I@vm7S9|VecISgaEBch;rsga!|t{j@WM@WEIu&D!ug|qF4GjE?|6mgNZAHn z3e1SGT1S{QTs^23Z!-6~*h!1}LimOU&yf_Ib)uASuNTb;zagP51+ERkQ~iolE9$yD zZi31QWg)NEoNV9U-H<4lhM}$c)OAHg4|-KD zeDSBaeKblIK1-O-3>uI)OQzCZR7JV`2+s!(5LYM)8Tf zf4J9za2yE_{;GI9h}fFeoOkHKs%l!MxpjRY@rXnAe-YqV zyIj}hyYd#eN}#-Hd@3|L+mvnCpF#>moyPBoo&Js(J*@^apWw{^-JJ%Wi76F67iH7c zQ=|d3X67rujY*Ey;Kn*5u5CDAu-er-KoQBjDC!M$2AQDhaY>G>Hi;~p&r&{^(!&dS zfpID4=OSU-)}A5hzFNLPVU?ubG?e~?SC8;g1gpNI!M9D9g=;fFB3WV?#R#$h>$KI; zzs_ZUI1(6&Q0AE2wCa=0tc9jhT*V30mePX^^0WAVG)L$Bk?M+Hi_!Z2IKDFI-FmJP zUD2CCtOtt;Tv5B-U%vD%%5+(#w~Ol-n!zWPIzXATM)d8p@vd%n)URzEVKcF4XVO^~ zAu^P9XO>??-pQ{J@Wo^sXklK(L)Ql62RI1<6PRtxkog+5$7M0J6F{uqTEL{Y#)I+1 zG;YzzNWy_;aG<*Ckq)L_Y7xePL3^})&wVHmQsgu@w64t`y%?&JX0yZ|Q!h1t=)nNy za7E$T`?i|~6Ca{2D2LR+MBituYkE63G)ukb-hBW45l*^?@>8T+(p}ic)H?^5IgE@3 zO(i+4Ef3!=sPv)DTE27fYJC|gvJQ5*G5bl#kjYhh)IQaAtgK-asue zeCSEEbtt*eb8IDgbzTq>CaXNB(vtS>Gme`ibvBgQ`&&oq6{ziMDPMcrJpZPbd<jlZh992i?=pm>=B>}Y_$z#`PgOJHw#3g< zvC?t+)>6pPIX2OsZMq&y=Rv>+G{0nC4AyTeb}AHnAOEPRF|;O_u7*4= z#r`9^kBozipmH4${EWYm4Ok#~YH0@cB0v}=vwslfm#V|7>CxVn;-zON2EMwwP81Ng zCjna3F6}TpS-I*}JPOPO zHB7N!xydUn>F9Zh(ci`Hun7u@_RF#IgQ9T;#(6rmFg$Tp^Mz2*AHvqsUM(8Ly}a?v z`1UxQmP9rokC1hJm3F6tu1L@9lhcS?1?*kM>&=y!(w0|Y+H|qcihIKU6Hnqa`CFkX zB|o(vgZ)}U2_XV~et~80?h$NT+pWA~q9_TXrUBAV4tT3G)iEW>bZ;pXB)3FaiM#}F zUGFN*KQ z76hStXsh+_hRiU_CxEZ15i!8E2r&I|GVG8{$9GRIW$(K~;}QV~Q9T>iS`u%l-uAQl zFXzHPyzp}j;#mF_X#_}tQBpUPE(qT=MPfwm{x2pV257R95e+PtwQBsvf_=e-_LOPU zr;WM!JgBHjWu9O@YkPsMu*GJ45b%Nsn+&bCezQ^b!O`&B9uBnFPSC|Haj~A=2n#`# zMs?!QhsOfl{sYWx43R1jKJTbn?z1UK7$;I5rWcO;pX?=CF-Wd{5Qm<@!TrQf1`J0`@3hqX!z0(g2fEE$`Zf1$=osV7+W8EnbD z=npBY_27xcL-6s9Z0+`}eHcaE$ZW}N7<_7Enj;u-23XmR=Yxq-UQ%fzEmd(hxjs6~ zA)QSjzjK#wSfJ9i`0Rww>uO42*@MDsAazRZJv`%WpM;U*m-+$ZnY7=phCz;kpEgS_ zpe=_5e85pW+XLPfcwxfaCP)uvoHwcBX{Ko-TaFCo3EesxL;PtwO)I;c9HrIqUSkX~ zBW8;0?w#C`-aVN1tHU3!>Czr{)V=H`p|+om_Z%n118yCf_Wd33GUywZpam`Vy5i$C z;{*`^UhH#P2eVhvlN^*N@FYvqOSSG#G;~6FmzsaVw4xcIdmknLYstwR?blU_4oOyI zVVhe%nQk3hj=3?E9I*fd$%7*hFF79mh&2T;_|oNkrzOUe1@oHb1AFY^9ca9|OvMW7 z13A7?!uE(xVjOQcao?#rCxtQbe4<*Q^IVIBi*>NilC!0s1^G>N#tQXDc@gWBJZo_E zczD6WDaT_;*=>e{OFS`g7=H?N*gt;Bv0D2h8NY~??Ak{MlJ7PJ22Oivu^EMxvmD&| z$=S~bAuV7+ void>[] - marketMakingCfg: MarketMakingConfig - marketMakingStatus: MarketMakingStatus | undefined txHistoryMap: Record constructor () { @@ -679,9 +674,7 @@ export default class Application { break } case 'mmstartstop': { - const n = note as MMStartStopNote - if (!this.marketMakingStatus) return - this.marketMakingStatus.running = n.running + MM.handleStartStopNote(note as MMStartStopNote) break } case 'walletnote': { @@ -1013,64 +1006,6 @@ export default class Application { window.location.href = '/login' } - async getMarketMakingConfig () : Promise { - if (this.marketMakingCfg) return this.marketMakingCfg - const res = await getJSON('/api/marketmakingconfig') - if (!this.checkResponse(res)) { - throw new Error('failed to fetch market making config') - } - this.marketMakingCfg = res.cfg - return this.marketMakingCfg - } - - async updateMarketMakingConfig (cfg: BotConfig) : Promise { - const res = await postJSON('/api/updatemarketmakingconfig', cfg) - if (res.err) { - throw new Error(res.err) - } - this.marketMakingCfg = res.cfg - } - - async removeMarketMakingConfig (cfg: BotConfig) : Promise { - const res = await postJSON('/api/removemarketmakingconfig', { - host: cfg.host, - baseAsset: cfg.baseAsset, - quoteAsset: cfg.quoteAsset - }) - if (res.err) { - throw new Error(res.err) - } - this.marketMakingCfg = res.cfg - } - - async setMarketMakingEnabled (host: string, baseAsset: number, quoteAsset: number, enabled: boolean) : Promise { - const botCfgs = this.marketMakingCfg.botConfigs || [] - const mktCfg = botCfgs.find((cfg : BotConfig) => { - return cfg.host === host && cfg.baseAsset === baseAsset && cfg.quoteAsset === quoteAsset - }) - if (!mktCfg) { - throw new Error('market making config not found') - } - mktCfg.disabled = !enabled - await this.updateMarketMakingConfig(mktCfg) - } - - async stopMarketMaking () : Promise { - await postJSON('/api/stopmarketmaking') - } - - async getMarketMakingStatus () : Promise { - if (this.marketMakingStatus !== undefined) return this.marketMakingStatus - const res = await getJSON('/api/marketmakingstatus') - if (!this.checkResponse(res)) { - throw new Error('failed to fetch market making status') - } - const status = {} as MarketMakingStatus - status.running = !!res.running - status.runningBots = res.runningBots - return status - } - /* * txHistory loads the tx history for an asset. If the results are not * already cached, they are cached. If we have reached the oldest tx, diff --git a/client/webserver/site/src/js/forms.ts b/client/webserver/site/src/js/forms.ts index 461f00dab7..2b2abde0ad 100644 --- a/client/webserver/site/src/js/forms.ts +++ b/client/webserver/site/src/js/forms.ts @@ -61,6 +61,52 @@ interface WalletConfig { walletType: string } +export class Forms { + formsDiv: PageElement + currentForm: PageElement + keyup: (e: KeyboardEvent) => void + + constructor (formsDiv: PageElement) { + this.formsDiv = formsDiv + + formsDiv.querySelectorAll('.form-closer').forEach(el => { + Doc.bind(el, 'click', () => { this.close() }) + }) + + Doc.bind(formsDiv, 'mousedown', (e: MouseEvent) => { + if (!Doc.mouseInElement(e, this.currentForm)) { this.close() } + }) + + this.keyup = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + this.close() + } + } + Doc.bind(document, 'keyup', this.keyup) + } + + /* showForm shows a modal form with a little animation. */ + async show (form: HTMLElement): Promise { + this.currentForm = form + Doc.hide(...Array.from(this.formsDiv.children)) + form.style.right = '10000px' + Doc.show(this.formsDiv, form) + const shift = (this.formsDiv.offsetWidth + form.offsetWidth) / 2 + await Doc.animate(animationLength, progress => { + form.style.right = `${(1 - progress) * shift}px` + }, 'easeOutHard') + form.style.right = '0' + } + + close (): void { + Doc.hide(this.formsDiv) + } + + exit () { + Doc.unbind(document, 'keyup', this.keyup) + } +} + /* * NewWalletForm should be used with the "newWalletForm" template. The enclosing * element should be the first argument of the constructor. diff --git a/client/webserver/site/src/js/locales.ts b/client/webserver/site/src/js/locales.ts index 9983402a6c..d9b6c8f2ab 100644 --- a/client/webserver/site/src/js/locales.ts +++ b/client/webserver/site/src/js/locales.ts @@ -5,6 +5,7 @@ export const ID_NO_APP_PASS_ERROR_MSG = 'ID_NO_APP_PASS_ERROR_MSG' export const ID_SET_BUTTON_BUY = 'ID_SET_BUTTON_BUY' export const ID_SET_BUTTON_SELL = 'ID_SET_BUTTON_SELL' export const ID_OFF = 'ID_OFF' +export const ID_MAX = 'ID_MAX' export const ID_READY = 'ID_READY' export const ID_NO_WALLET = 'ID_NO_WALLET' export const ID_DISABLED_MSG = 'ID_DISABLED_MSG' @@ -173,6 +174,13 @@ export const ID_TX_TYPE_REVOKE_TOKEN_APPROVAL = 'TX_TYPE_REVOKE_TOKEN_APPROVAL' export const ID_TX_TYPE_TICKET_PURCHASE = 'TX_TYPE_TICKET_PURCHASE' export const ID_TX_TYPE_TICKET_VOTE = 'TX_TYPE_TICKET_VOTE' export const ID_TX_TYPE_TICKET_REVOCATION = 'TX_TYPE_TICKET_REVOCATION' +export const ID_MISSING_CEX_CREDS = 'MISSING_CEX_CREDS' +export const ID_MULTIPLIER = 'MULTIPLIER' +export const ID_NO_PLACEMENTS = 'NO_PLACEMENTS' +export const ID_INVALID_VALUE = 'INVALID_VALUE' +export const ID_NO_ZERO = 'NO_ZERO' +export const ID_BOTTYPE_BASIC_MM = 'BOTTYPE_BASIC_MM' +export const ID_BOTTYPE_ARB_MM = 'BOTTYPE_ARB_MM' export const enUS: Locale = { [ID_NO_PASS_ERROR_MSG]: 'password cannot be empty', @@ -347,7 +355,14 @@ export const enUS: Locale = { [ID_TX_TYPE_REVOKE_TOKEN_APPROVAL]: 'Revoke token approval', [ID_TX_TYPE_TICKET_PURCHASE]: 'Ticket purchase', [ID_TX_TYPE_TICKET_VOTE]: 'Ticket vote', - [ID_TX_TYPE_TICKET_REVOCATION]: 'Ticket revocation' + [ID_TX_TYPE_TICKET_REVOCATION]: 'Ticket revocation', + [ID_MISSING_CEX_CREDS]: 'specify both key and secret', + [ID_MULTIPLIER]: 'Multiplier', + [ID_NO_PLACEMENTS]: 'must specify 1 or more placements', + [ID_INVALID_VALUE]: 'invalid value', + [ID_NO_ZERO]: 'zero not allowed', + [ID_BOTTYPE_BASIC_MM]: 'Market Maker', + [ID_BOTTYPE_ARB_MM]: 'Market Maker + Arbitrage' } export const ptBR: Locale = { @@ -401,7 +416,8 @@ export const ptBR: Locale = { [WALLET_READY]: 'Carteira Pronta', [SETUP_NEEDED]: 'Configuração Necessária', [ID_AVAILABLE]: 'disponível', - [ID_IMMATURE]: 'imaturo' + [ID_IMMATURE]: 'imaturo', + [ID_MAX]: 'max' } export const zhCN: Locale = { diff --git a/client/webserver/site/src/js/markets.ts b/client/webserver/site/src/js/markets.ts index a0301e0738..75d1fcaf1f 100644 --- a/client/webserver/site/src/js/markets.ts +++ b/client/webserver/site/src/js/markets.ts @@ -65,6 +65,7 @@ import { } from './registry' import { setOptionTemplates } from './opts' import { CoinExplorers } from './coinexplorers' +import { MM } from './mm' const bind = Doc.bind @@ -766,7 +767,7 @@ export default class MarketsPage extends BasePage { } if (app().user.experimental && this.mmRunning === undefined) { - const marketMakingStatus = await app().getMarketMakingStatus() + const marketMakingStatus = await MM.status() this.mmRunning = marketMakingStatus.running } diff --git a/client/webserver/site/src/js/mm.ts b/client/webserver/site/src/js/mm.ts index 6a097267ce..1a4512bc1f 100644 --- a/client/webserver/site/src/js/mm.ts +++ b/client/webserver/site/src/js/mm.ts @@ -4,18 +4,39 @@ import { Market, Exchange, BotConfig, + CEXConfig, MarketWithHost, BotStartStopNote, MMStartStopNote, BalanceNote, - SupportedAsset + SupportedAsset, + MarketMakingConfig, + MarketMakingStatus, + CEXMarket } from './registry' -import { postJSON } from './http' +import { getJSON, postJSON } from './http' import Doc from './doc' import State from './state' import BasePage from './basepage' import { setOptionTemplates } from './opts' import { bind as bindForm, NewWalletForm } from './forms' +import * as intl from './locales' + +interface CEXDisplayInfo { + name: string + logo: string +} + +export const CEXDisplayInfos: Record = { + 'Binance': { + name: 'Binance', + logo: 'binance.com.png' + }, + 'BinanceUS': { + name: 'Binance U.S.', + logo: 'binance.us.png' + } +} interface HostedMarket extends Market { host: string @@ -58,7 +79,7 @@ export default class MarketMakerPage extends BasePage { Doc.bind(page.addBotBtnNoExisting, 'click', () => { this.showAddBotForm() }) Doc.bind(page.addBotBtnWithExisting, 'click', () => { this.showAddBotForm() }) Doc.bind(page.startBotsBtn, 'click', () => { this.start() }) - Doc.bind(page.stopBotsBtn, 'click', () => { this.stopBots() }) + Doc.bind(page.stopBotsBtn, 'click', () => { MM.stop() }) Doc.bind(page.hostSelect, 'change', () => { this.selectMarketHost() }) bindForm(page.addBotForm, page.addBotSubmit, () => { this.addBotSubmit() }) bindForm(page.pwForm, page.pwSubmit, () => this.startBots()) @@ -77,9 +98,10 @@ export default class MarketMakerPage extends BasePage { async setup () { const page = this.page - const status = await app().getMarketMakingStatus() + const status = await MM.status() const running = status.running - const marketMakingCfg = await app().getMarketMakingConfig() + const marketMakingCfg = await MM.config() + const botConfigs = this.botConfigs = marketMakingCfg.botConfigs || [] app().registerNoteFeeder({ botstartstop: (note: BotStartStopNote) => { this.handleBotStartStopNote(note) }, @@ -130,7 +152,7 @@ export default class MarketMakerPage extends BasePage { handleBalanceNote (note: BalanceNote) { const getBotConfig = (mktID: string): BotConfig | undefined => { for (const cfg of this.botConfigs) { - if (marketStr(cfg.host, cfg.baseAsset, cfg.quoteAsset) === mktID) return cfg + if (marketStr(cfg.host, cfg.baseID, cfg.quoteID) === mktID) return cfg } } const tableRows = this.page.botTableBody.children @@ -139,9 +161,9 @@ export default class MarketMakerPage extends BasePage { const rowTmpl = Doc.parseTemplate(row) const cfg = getBotConfig(row.id) if (!cfg) continue - if (cfg.baseAsset === note.assetID) { + if (cfg.baseID === note.assetID) { rowTmpl.baseBalance.textContent = this.walletBalanceStr(note.assetID, cfg.baseBalance) - } else if (cfg.quoteAsset === note.assetID) { + } else if (cfg.quoteID === note.assetID) { rowTmpl.quoteBalance.textContent = this.walletBalanceStr(note.assetID, cfg.quoteBalance) } } @@ -188,6 +210,7 @@ export default class MarketMakerPage extends BasePage { const addOptions = (symbols: string[], avail: boolean): void => { for (const symbol of symbols) { const assetID = Doc.bipIDFromSymbol(symbol) + if (assetID === undefined) continue // unsupported asset const asset = app().assets[assetID] const row = this.assetRow(asset) Doc.bind(row, 'click', (e: MouseEvent) => { @@ -352,11 +375,11 @@ export default class MarketMakerPage extends BasePage { } for (const xc of Object.values(app().user.exchanges)) mkts.push(...convertMarkets(xc)) - const mmCfg = await app().getMarketMakingConfig() + const mmCfg = await MM.config() const botCfgs = mmCfg.botConfigs || [] const existingMarkets : Record = {} for (const cfg of botCfgs) { - existingMarkets[marketStr(cfg.host, cfg.baseAsset, cfg.quoteAsset)] = true + existingMarkets[marketStr(cfg.host, cfg.baseID, cfg.quoteID)] = true } const filteredMkts = mkts.filter((mkt) => { return !existingMarkets[marketStr(mkt.host, mkt.baseid, mkt.quoteid)] @@ -431,44 +454,53 @@ export default class MarketMakerPage extends BasePage { for (const botCfg of botConfigs) { const row = page.botTableRowTmpl.cloneNode(true) as PageElement - row.id = marketStr(botCfg.host, botCfg.baseAsset, botCfg.quoteAsset) + row.id = marketStr(botCfg.host, botCfg.baseID, botCfg.quoteID) const rowTmpl = Doc.parseTemplate(row) const thisBotRunning = runningBots.some((bot) => { return bot.host === botCfg.host && - bot.base === botCfg.baseAsset && - bot.quote === botCfg.quoteAsset + bot.base === botCfg.baseID && + bot.quote === botCfg.quoteID }) this.setTableRowRunning(rowTmpl, running, thisBotRunning) - const baseSymbol = app().assets[botCfg.baseAsset].symbol - const quoteSymbol = app().assets[botCfg.quoteAsset].symbol + const baseSymbol = app().assets[botCfg.baseID].symbol + const quoteSymbol = app().assets[botCfg.quoteID].symbol const baseLogoPath = Doc.logoPath(baseSymbol) const quoteLogoPath = Doc.logoPath(quoteSymbol) rowTmpl.enabledCheckbox.checked = !botCfg.disabled Doc.bind(rowTmpl.enabledCheckbox, 'click', async () => { - app().setMarketMakingEnabled(botCfg.host, botCfg.baseAsset, botCfg.quoteAsset, !!rowTmpl.enabledCheckbox.checked) + MM.setEnabled(botCfg.host, botCfg.baseID, botCfg.quoteID, !!rowTmpl.enabledCheckbox.checked) }) rowTmpl.host.textContent = botCfg.host rowTmpl.baseMktLogo.src = baseLogoPath rowTmpl.quoteMktLogo.src = quoteLogoPath rowTmpl.baseSymbol.textContent = baseSymbol.toUpperCase() rowTmpl.quoteSymbol.textContent = quoteSymbol.toUpperCase() - rowTmpl.botType.textContent = 'Market Maker' - rowTmpl.baseBalance.textContent = this.walletBalanceStr(botCfg.baseAsset, botCfg.baseBalance) - rowTmpl.quoteBalance.textContent = this.walletBalanceStr(botCfg.quoteAsset, botCfg.quoteBalance) + rowTmpl.baseBalance.textContent = this.walletBalanceStr(botCfg.baseID, botCfg.baseBalance) + rowTmpl.quoteBalance.textContent = this.walletBalanceStr(botCfg.quoteID, botCfg.quoteBalance) rowTmpl.baseBalanceLogo.src = baseLogoPath rowTmpl.quoteBalanceLogo.src = quoteLogoPath + if (botCfg.arbMarketMakingConfig) { + rowTmpl.botType.textContent = intl.prep(intl.ID_BOTTYPE_ARB_MM) + Doc.show(rowTmpl.cexLink) + const dinfo = CEXDisplayInfos[botCfg.arbMarketMakingConfig.cexName] + rowTmpl.cexLogo.src = '/img/' + dinfo.logo + rowTmpl.cexName.textContent = dinfo.name + } else { + rowTmpl.botType.textContent = intl.prep(intl.ID_BOTTYPE_BASIC_MM) + } + Doc.bind(rowTmpl.removeTd, 'click', async () => { - await app().removeMarketMakingConfig(botCfg) + await MM.removeMarketMakingConfig(botCfg) row.remove() - const mmCfg = await app().getMarketMakingConfig() + const mmCfg = await MM.config() const noBots = !mmCfg || !mmCfg.botConfigs || mmCfg.botConfigs.length === 0 Doc.setVis(noBots, page.noBots) Doc.setVis(!noBots, page.botTable, page.onOff) }) Doc.bind(rowTmpl.settings, 'click', () => { - app().loadPage(`mmsettings?host=${botCfg.host}&base=${botCfg.baseAsset}"e=${botCfg.quoteAsset}`) + app().loadPage(`mmsettings?host=${botCfg.host}&base=${botCfg.baseID}"e=${botCfg.quoteID}`) }) page.botTableBody.appendChild(row) } @@ -477,10 +509,8 @@ export default class MarketMakerPage extends BasePage { async showAddBotForm () { const sortedMarkets = await this.sortedMarkets() if (sortedMarkets.length === 0) return - const initialMarkets = [] - const base = sortedMarkets[0].basesymbol - const quote = sortedMarkets[0].quotesymbol - for (const mkt of sortedMarkets) if (mkt.basesymbol === base && mkt.quotesymbol === quote) initialMarkets.push(mkt) + const { baseid: baseID, quoteid: quoteID } = sortedMarkets.filter((mkt: HostedMarket) => Boolean(app().assets[mkt.baseid]) && Boolean(app().assets[mkt.quoteid]))[0] + const initialMarkets = sortedMarkets.filter((mkt: HostedMarket) => mkt.baseid === baseID && mkt.quoteid === quoteID) this.setMarket(initialMarkets) this.showForm(this.page.addBotForm) } @@ -493,20 +523,16 @@ export default class MarketMakerPage extends BasePage { async startBots () { const page = this.page Doc.hide(page.mmErr) - const appPW = page.pwInput.value + const appPW = page.pwInput.value || '' this.page.pwInput.value = '' this.closePopups() - const res = await postJSON('/api/startmarketmaking', { appPW }) + const res = await MM.start(appPW) if (!app().checkResponse(res)) { page.mmErr.textContent = res.msg Doc.show(page.mmErr) } } - async stopBots () { - await app().stopMarketMaking() - } - /* showForm shows a modal form with a little animation. */ async showForm (form: HTMLElement): Promise { this.currentForm = form @@ -525,3 +551,121 @@ export default class MarketMakerPage extends BasePage { Doc.hide(this.page.forms) } } + +/* + * MarketMakerBot is the front end representaion of the server's mm.MarketMaker. + * MarketMakerBot is a singleton assigned to MM below. + */ +class MarketMakerBot { + cfg: MarketMakingConfig + st: MarketMakingStatus | undefined + mkts: Record + + constructor () { + this.mkts = {} + } + + handleStartStopNote (n: MMStartStopNote) { + if (!this.st) return + this.st.running = n.running + } + + /* + * config returns the curret MarketMakingConfig. config will fetch the + * MarketMakingConfig once. Future updates occur in updateCEXConfig and + * updateBotConfig. after a page handler calls config during intitialization, + * the the config can be accessed directly thr MM.cfg to avoid the async + * function. + */ + async config () : Promise { + if (this.cfg) return this.cfg + const res = await getJSON('/api/marketmakingconfig') + if (!app().checkResponse(res)) { + throw new Error('failed to fetch market making config') + } + this.cfg = res.cfg + this.mkts = res.mkts + return this.cfg + } + + /* + * updateBotConfig appends or updates the specified BotConfig, then updates + * the cached MarketMakingConfig, + */ + async updateBotConfig (cfg: BotConfig) : Promise { + const res = await postJSON('/api/updatebotconfig', cfg) + if (!app().checkResponse(res)) { + throw res.msg || Error(res) + } + this.cfg = res.cfg + } + + /* + * updateCEXConfig appends or updates the specified CEXConfig, then updates + * the cached MarketMakingConfig, + */ + async updateCEXConfig (cfg: CEXConfig) : Promise { + const res = await postJSON('/api/updatecexconfig', cfg) + if (res.err) { + throw new Error(res.err) + } + this.cfg = res.cfg + this.mkts[cfg.name] = res.mkts || [] + } + + async removeMarketMakingConfig (cfg: BotConfig) : Promise { + const res = await postJSON('/api/removemarketmakingconfig', { + host: cfg.host, + baseAsset: cfg.baseID, + quoteAsset: cfg.quoteID + }) + if (res.err) { + throw new Error(res.err) + } + this.cfg = res.cfg + } + + async report (baseID: number, quoteID: number) { + return postJSON('/api/marketreport', { baseID, quoteID }) + } + + async setEnabled (host: string, baseID: number, quoteID: number, enabled: boolean) : Promise { + const botCfgs = this.cfg.botConfigs || [] + const mktCfg = botCfgs.find((cfg : BotConfig) => { + return cfg.host === host && cfg.baseID === baseID && cfg.quoteID === quoteID + }) + if (!mktCfg) { + throw new Error('market making config not found') + } + mktCfg.disabled = !enabled + await this.updateBotConfig(mktCfg) + } + + async start (appPW: string) { + return await postJSON('/api/startmarketmaking', { appPW }) + } + + async stop () : Promise { + await postJSON('/api/stopmarketmaking') + } + + async status () : Promise { + if (this.st !== undefined) return this.st + const res = await getJSON('/api/marketmakingstatus') + if (!app().checkResponse(res)) { + throw new Error('failed to fetch market making status') + } + const status = {} as MarketMakingStatus + status.running = !!res.running + status.runningBots = res.runningBots + this.st = status + return status + } + + cexConfigured (cexName: string) { + return (this.cfg.cexConfigs || []).some((cfg: CEXConfig) => cfg.name === cexName) + } +} + +// MM is the front end representation of the server's mm.MarketMaker. +export const MM = new MarketMakerBot() diff --git a/client/webserver/site/src/js/mmsettings.ts b/client/webserver/site/src/js/mmsettings.ts index 2a51139571..a2268207de 100644 --- a/client/webserver/site/src/js/mmsettings.ts +++ b/client/webserver/site/src/js/mmsettings.ts @@ -7,18 +7,23 @@ import { app, MarketReport, OrderOption, - BasicMarketMakingCfg + BasicMarketMakingCfg, + ArbMarketMakingCfg, + ArbMarketMakingPlacement } from './registry' -import { postJSON } from './http' import Doc from './doc' import BasePage from './basepage' import { setOptionTemplates, XYRangeHandler } from './opts' +import { MM, CEXDisplayInfos } from './mm' +import { Forms } from './forms' +import * as intl from './locales' const GapStrategyMultiplier = 'multiplier' const GapStrategyAbsolute = 'absolute' const GapStrategyAbsolutePlus = 'absolute-plus' const GapStrategyPercent = 'percent' const GapStrategyPercentPlus = 'percent-plus' +const arbMMRowCacheKey = 'arbmm' const driftToleranceRange: XYRange = { start: { @@ -35,6 +40,21 @@ const driftToleranceRange: XYRange = { yUnit: '%' } +const orderPersistenceRange: XYRange = { + start: { + label: '0', + x: 0, + y: 0 + }, + end: { + label: 'max', + x: 21, + y: 21 + }, + xUnit: '', + yUnit: 'epochs' +} + const oracleBiasRange: XYRange = { start: { label: '-1%', @@ -75,6 +95,12 @@ const defaultMarketMakingConfig : BasicMarketMakingCfg = { emptyMarketRate: 0 } +const defaultArbMarketMakingConfig : any /* so I don't have to define all fields */ = { + cexName: '', + profit: 3, + orderPersistence: 20 +} + // walletSettingControl is used by the modified highlighting and // reset values functionalities to manage the wallet settings // defined in walletDefinition.multifundingopts @@ -83,53 +109,169 @@ interface walletSettingControl { setValue: (value: string) => void } +// cexButton stores parts of a CEX selection button. +interface cexButton { + name: string + div: PageElement + tmpl: Record +} + +/* + * ConfigState is an amalgamation of BotConfig, ArbMarketMakingCfg, and + * BasicMarketMakingCfg. ConfigState tracks the global state of the options + * presented on the page, with a single field for each option / control element. + * ConfigState is necessary because there are duplicate fields in the various + * config structs, and the placement types are not identical. + */ +interface ConfigState { + gapStrategy: string + cexName: string + useOracles: boolean + profit: number + useEmptyMarketRate: boolean + emptyMarketRate: number + driftTolerance: number + orderPersistence: number // epochs + oracleWeighting: number + oracleBias: number + baseBalanceType: BalanceType + baseBalance: number + quoteBalanceType: BalanceType + quoteBalance: number + disabled: boolean + buyPlacements: OrderPlacement[] + sellPlacements: OrderPlacement[] + baseOptions: Record + quoteOptions: Record +} + +/* + * RangeOption adds some functionality to XYRangeHandler. It will keep the + * ConfigState up to date, recognizes a modified state, and adds default + * callbacks for the XYRangeHandler. RangeOption is similar in function to an + * XYRangeOption. The two types may be able to be merged. + */ +class RangeOption { + // Args + cfg: XYRange + initVal: number + lastVal: number + settingsDict: {[key: string]: any} + settingsKey: string + // Set in constructor + div: PageElement + xyRange: XYRangeHandler + // Set with setters + update: (x: number, y: number) => any + changed: () => void + selected: () => void + convert: (x: any) => any + + constructor (cfg: XYRange, initVal: number, roundX: boolean, roundY: boolean, disabled: boolean, settingsDict: {[key:string]: any}, settingsKey: string) { + this.cfg = cfg + this.initVal = initVal + this.lastVal = initVal + this.settingsDict = settingsDict + this.settingsKey = settingsKey + this.convert = (x: any) => x + + this.xyRange = new XYRangeHandler( + cfg, + initVal, + (x: number, y: number) => { this.handleUpdate(x, y) }, + () => { this.handleChange() }, + () => { this.handleSelected() }, + roundX, + roundY, + disabled + ) + this.div = this.xyRange.control + } + + setUpdate (f: (x: number, y: number) => number) { + this.update = f + } + + setChanged (f: () => void) { + this.changed = f + } + + setSelected (f: () => void) { + this.selected = f + } + + stringify () { + this.convert = (x: any) => String(x) + } + + handleUpdate (x:number, y:number) { + this.lastVal = this.update ? this.update(x, y) : x + this.settingsDict[this.settingsKey] = this.convert(this.lastVal) + } + + handleChange () { + if (this.changed) this.changed() + } + + handleSelected () { + if (this.selected) this.selected() + } + + modified (): boolean { + return this.lastVal !== this.initVal + } + + setValue (x: number) { + this.xyRange.setValue(x, false) + } + + reset () { + this.xyRange.setValue(this.initVal, true) + } +} + export default class MarketMakerSettingsPage extends BasePage { page: Record + forms: Forms currentMarket: string - originalConfig: BotConfig - updatedConfig: BotConfig + originalConfig: ConfigState + updatedConfig: ConfigState creatingNewBot: boolean host: string baseID: number quoteID: number - oracleBiasRangeHandler: XYRangeHandler - oracleWeightingRangeHandler: XYRangeHandler - driftToleranceRangeHandler: XYRangeHandler - baseBalanceRangeHandler?: XYRangeHandler - quoteBalanceRangeHandler?: XYRangeHandler + oracleBias: RangeOption + oracleWeighting: RangeOption + driftTolerance: RangeOption + orderPersistence: RangeOption + baseBalance?: RangeOption + quoteBalance?: RangeOption baseWalletSettingControl: Record = {} quoteWalletSettingControl: Record = {} + cexes: Record + placementsCache: Record constructor (main: HTMLElement) { super() - const page = (this.page = Doc.idDescendants(main)) + const page = this.page = Doc.idDescendants(main) + + this.forms = new Forms(page.forms) + this.placementsCache = {} app().headerSpace.appendChild(page.mmTitle) setOptionTemplates(page) Doc.cleanTemplates( - page.orderOptTmpl, - page.booleanOptTmpl, - page.rangeOptTmpl, - page.placementRowTmpl, - page.oracleTmpl, - page.boolSettingTmpl, - page.rangeSettingTmpl + page.orderOptTmpl, page.booleanOptTmpl, page.rangeOptTmpl, page.placementRowTmpl, + page.oracleTmpl, page.boolSettingTmpl, page.rangeSettingTmpl, page.cexOptTmpl ) Doc.bind(page.resetButton, 'click', () => { this.setOriginalValues(false) }) - Doc.bind(page.updateButton, 'click', () => { - this.saveSettings() - // TODO: Show success message/checkmark after #2575 is in. - }) - Doc.bind(page.createButton, 'click', async () => { - await this.saveSettings() - app().loadPage('mm') - }) - Doc.bind(page.backButton, 'click', () => { - app().loadPage('mm') - }) + Doc.bind(page.updateButton, 'click', () => { this.saveSettings() }) + Doc.bind(page.createButton, 'click', async () => { this.saveSettings() }) + Doc.bind(page.backButton, 'click', () => { app().loadPage('mm') }) + Doc.bind(page.cexSubmit, 'click', () => { this.handleCEXSubmit() }) const urlParams = new URLSearchParams(window.location.search) const host = urlParams.get('host') @@ -139,21 +281,11 @@ export default class MarketMakerSettingsPage extends BasePage { console.log("Missing 'host', 'base', or 'quote' URL parameter") return } - this.baseID = parseInt(base) - this.quoteID = parseInt(quote) - this.host = host - page.baseHeader.textContent = app().assets[this.baseID].symbol.toUpperCase() - page.quoteHeader.textContent = app().assets[this.quoteID].symbol.toUpperCase() - page.hostHeader.textContent = host - - page.baseBalanceLogo.src = Doc.logoPathFromID(this.baseID) - page.quoteBalanceLogo.src = Doc.logoPathFromID(this.quoteID) - page.baseSettingsLogo.src = Doc.logoPathFromID(this.baseID) - page.quoteSettingsLogo.src = Doc.logoPathFromID(this.quoteID) - page.baseLogo.src = Doc.logoPathFromID(this.baseID) - page.quoteLogo.src = Doc.logoPathFromID(this.quoteID) + this.setup(host, parseInt(base), parseInt(quote)) + } - this.setup() + unload () { + this.forms.exit() } defaultWalletOptions (assetID: number) : Record { @@ -171,55 +303,91 @@ export default class MarketMakerSettingsPage extends BasePage { return options } - async setup () { - const page = this.page - const mmCfg = await app().getMarketMakingConfig() - const botConfigs = mmCfg.botConfigs || [] - const status = await app().getMarketMakingStatus() - - for (const cfg of botConfigs) { - if (cfg.host === this.host && cfg.baseAsset === this.baseID && cfg.quoteAsset === this.quoteID) { - this.originalConfig = JSON.parse(JSON.stringify(cfg)) - this.updatedConfig = JSON.parse(JSON.stringify(cfg)) - break + async setup (host: string, baseID: number, quoteID: number) { + this.baseID = baseID + this.quoteID = quoteID + this.host = host + + await MM.config() + const status = await MM.status() + const botCfg = this.currentBotConfig() + const dmm = defaultMarketMakingConfig + + const oldCfg = this.originalConfig = Object.assign({}, defaultMarketMakingConfig, defaultArbMarketMakingConfig, { + useOracles: dmm.oracleWeighting > 0, + useEmptyMarketRate: dmm.emptyMarketRate > 0, + baseBalanceType: BalanceType.Percentage, + quoteBalanceType: BalanceType.Percentage, + baseBalance: 0, + quoteBalance: 0, + disabled: status.running, + baseOptions: this.defaultWalletOptions(baseID), + quoteOptions: this.defaultWalletOptions(quoteID), + buyPlacements: [], + sellPlacements: [] + }) + + const { page } = this + + Doc.hide(page.updateButton, page.resetButton, page.createButton) + + page.baseHeader.textContent = app().assets[this.baseID].symbol.toUpperCase() + page.quoteHeader.textContent = app().assets[this.quoteID].symbol.toUpperCase() + page.hostHeader.textContent = host + page.baseBalanceLogo.src = Doc.logoPathFromID(this.baseID) + page.quoteBalanceLogo.src = Doc.logoPathFromID(this.quoteID) + page.baseSettingsLogo.src = Doc.logoPathFromID(this.baseID) + page.quoteSettingsLogo.src = Doc.logoPathFromID(this.quoteID) + page.baseLogo.src = Doc.logoPathFromID(this.baseID) + page.quoteLogo.src = Doc.logoPathFromID(this.quoteID) + + if (botCfg) { + const { basicMarketMakingConfig: mmCfg, arbMarketMakingConfig: arbCfg } = botCfg + this.creatingNewBot = false + // This is kinda sloppy, but we'll copy any relevant issues from the + // old config into the originalConfig. + const idx = oldCfg as {[k: string]: any} // typescript + for (const [k, v] of Object.entries(botCfg)) if (idx[k] !== undefined) idx[k] = v + if (mmCfg) { + oldCfg.buyPlacements = mmCfg.buyPlacements + oldCfg.sellPlacements = mmCfg.sellPlacements + } else if (arbCfg) { + const { buyPlacements, sellPlacements, cexName } = arbCfg + oldCfg.cexName = cexName + oldCfg.buyPlacements = Array.from(buyPlacements, (p: ArbMarketMakingPlacement) => { return { lots: p.lots, gapFactor: p.multiplier } }) + oldCfg.sellPlacements = Array.from(sellPlacements, (p: ArbMarketMakingPlacement) => { return { lots: p.lots, gapFactor: p.multiplier } }) } - } - this.creatingNewBot = !this.updatedConfig - - if (this.creatingNewBot) { - const newConfig: BotConfig = - { - host: this.host, - baseAsset: this.baseID, - quoteAsset: this.quoteID, - baseBalanceType: BalanceType.Percentage, - baseBalance: 0, - quoteBalanceType: BalanceType.Percentage, - quoteBalance: 0, - basicMarketMakingConfig: defaultMarketMakingConfig, - disabled: false - } - this.originalConfig = JSON.parse(JSON.stringify(newConfig)) - this.originalConfig.basicMarketMakingConfig.baseOptions = this.defaultWalletOptions(this.baseID) - this.originalConfig.basicMarketMakingConfig.quoteOptions = this.defaultWalletOptions(this.quoteID) - this.updatedConfig = JSON.parse(JSON.stringify(this.originalConfig)) - Doc.hide(page.updateButton, page.resetButton) - Doc.show(page.createButton) + Doc.setVis(!status.running, page.updateButton, page.resetButton) + } else { + this.creatingNewBot = true + Doc.setVis(!status.running, page.createButton) } - if (status.running) { - Doc.hide(page.updateButton, page.createButton, page.resetButton) - } + Doc.setVis(!status.running, page.cexPrompt, page.profitPrompt) + Doc.setVis(status.running, page.viewOnly) + page.profitInput.disabled = status.running + + // Now that we've updated the originalConfig, we'll copy it. + this.updatedConfig = JSON.parse(JSON.stringify(oldCfg)) + + this.setupCEXes() + if (oldCfg.cexName) this.selectCEX(oldCfg.cexName) this.setupMMConfigSettings(status.running) - this.setupBalanceSelectors(botConfigs, status.running) + this.setupBalanceSelectors(status.running) this.setupWalletSettings(status.running) this.setOriginalValues(status.running) Doc.show(page.botSettingsContainer) - this.fetchOracles() } + currentBotConfig (): BotConfig | null { + const { baseID, quoteID, host } = this + const cfgs = (MM.cfg.botConfigs || []).filter((cfg: BotConfig) => cfg.baseID === baseID && cfg.quoteID === quoteID && cfg.host === host) + if (cfgs.length) return cfgs[0] + return null + } + /* * updateModifiedMarkers checks each of the input elements on the page and * if the current value does not match the original value (since the last @@ -227,22 +395,23 @@ export default class MarketMakerSettingsPage extends BasePage { */ updateModifiedMarkers () { if (this.creatingNewBot) return - const page = this.page - const originalMMCfg = this.originalConfig.basicMarketMakingConfig - const updatedMMCfg = this.updatedConfig.basicMarketMakingConfig + const { page, originalConfig: oldCfg, updatedConfig: newCfg } = this // Gap strategy input - const gapStrategyModified = originalMMCfg.gapStrategy !== updatedMMCfg.gapStrategy + const gapStrategyModified = oldCfg.gapStrategy !== newCfg.gapStrategy page.gapStrategySelect.classList.toggle('modified', gapStrategyModified) + const profitModified = oldCfg.profit !== newCfg.profit + page.profitInput.classList.toggle('modified', profitModified) + // Buy placements Input let buyPlacementsModified = false - if (originalMMCfg.buyPlacements.length !== updatedMMCfg.buyPlacements.length) { + if (oldCfg.buyPlacements.length !== newCfg.buyPlacements.length) { buyPlacementsModified = true } else { - for (let i = 0; i < originalMMCfg.buyPlacements.length; i++) { - if (originalMMCfg.buyPlacements[i].lots !== updatedMMCfg.buyPlacements[i].lots || - originalMMCfg.buyPlacements[i].gapFactor !== updatedMMCfg.buyPlacements[i].gapFactor) { + for (let i = 0; i < oldCfg.buyPlacements.length; i++) { + if (oldCfg.buyPlacements[i].lots !== newCfg.buyPlacements[i].lots || + oldCfg.buyPlacements[i].gapFactor !== newCfg.buyPlacements[i].gapFactor) { buyPlacementsModified = true break } @@ -252,69 +421,36 @@ export default class MarketMakerSettingsPage extends BasePage { // Sell placements input let sellPlacementsModified = false - if (originalMMCfg.sellPlacements.length !== updatedMMCfg.sellPlacements.length) { + if (oldCfg.sellPlacements.length !== newCfg.sellPlacements.length) { sellPlacementsModified = true } else { - for (let i = 0; i < originalMMCfg.sellPlacements.length; i++) { - if (originalMMCfg.sellPlacements[i].lots !== updatedMMCfg.sellPlacements[i].lots || - originalMMCfg.sellPlacements[i].gapFactor !== updatedMMCfg.sellPlacements[i].gapFactor) { + for (let i = 0; i < oldCfg.sellPlacements.length; i++) { + if (oldCfg.sellPlacements[i].lots !== newCfg.sellPlacements[i].lots || + oldCfg.sellPlacements[i].gapFactor !== newCfg.sellPlacements[i].gapFactor) { sellPlacementsModified = true break } } } page.sellPlacementsTableWrapper.classList.toggle('modified', sellPlacementsModified) - - // Drift tolerance input - const driftToleranceModified = originalMMCfg.driftTolerance !== updatedMMCfg.driftTolerance - page.driftToleranceContainer.classList.toggle('modified', driftToleranceModified) - - // Oracle bias input - const oracleBiasModified = originalMMCfg.oracleBias !== updatedMMCfg.oracleBias - page.oracleBiasContainer.classList.toggle('modified', oracleBiasModified) - - // Use oracles input - const originalUseOracles = originalMMCfg.oracleWeighting !== 0 - const updatedUseOracles = updatedMMCfg.oracleWeighting !== 0 - const useOraclesModified = originalUseOracles !== updatedUseOracles - page.useOracleCheckbox.classList.toggle('modified', useOraclesModified) - - // Oracle weighting input - const oracleWeightingModified = originalMMCfg.oracleWeighting !== updatedMMCfg.oracleWeighting - page.oracleWeightingContainer.classList.toggle('modified', oracleWeightingModified) - - // Empty market rates inputs - const emptyMarketRateModified = originalMMCfg.emptyMarketRate !== updatedMMCfg.emptyMarketRate - page.emptyMarketRateInput.classList.toggle('modified', emptyMarketRateModified) - const emptyMarketRateCheckboxModified = (originalMMCfg.emptyMarketRate === 0) !== !page.emptyMarketRateCheckbox.checked - page.emptyMarketRateCheckbox.classList.toggle('modified', emptyMarketRateCheckboxModified) - - // Base balance input - const baseBalanceModified = this.originalConfig.baseBalance !== this.updatedConfig.baseBalance - page.baseBalanceContainer.classList.toggle('modified', baseBalanceModified) - - // Quote balance input - const quoteBalanceModified = this.originalConfig.quoteBalance !== this.updatedConfig.quoteBalance - page.quoteBalanceContainer.classList.toggle('modified', quoteBalanceModified) + page.driftToleranceContainer.classList.toggle('modified', this.driftTolerance.modified()) + page.oracleBiasContainer.classList.toggle('modified', this.oracleBias.modified()) + page.useOracleCheckbox.classList.toggle('modified', oldCfg.useOracles !== newCfg.useOracles) + page.oracleWeightingContainer.classList.toggle('modified', this.oracleWeighting.modified()) + page.emptyMarketRateInput.classList.toggle('modified', oldCfg.emptyMarketRate !== newCfg.emptyMarketRate) + page.emptyMarketRateCheckbox.classList.toggle('modified', oldCfg.useEmptyMarketRate !== newCfg.useEmptyMarketRate) + page.baseBalanceContainer.classList.toggle('modified', this.baseBalance && this.baseBalance.modified()) + page.quoteBalanceContainer.classList.toggle('modified', this.quoteBalance && this.quoteBalance.modified()) + page.orderPersistenceContainer.classList.toggle('modified', this.orderPersistence.modified()) // Base wallet settings for (const opt of Object.keys(this.baseWalletSettingControl)) { - if (!this.updatedConfig.basicMarketMakingConfig.baseOptions) break - if (!this.originalConfig.basicMarketMakingConfig.baseOptions) break - const originalValue = this.originalConfig.basicMarketMakingConfig.baseOptions[opt] - const updatedValue = this.updatedConfig.basicMarketMakingConfig.baseOptions[opt] - const modified = originalValue !== updatedValue - this.baseWalletSettingControl[opt].toHighlight.classList.toggle('modified', modified) + this.baseWalletSettingControl[opt].toHighlight.classList.toggle('modified', oldCfg.baseOptions[opt] !== newCfg.baseOptions[opt]) } // Quote wallet settings for (const opt of Object.keys(this.quoteWalletSettingControl)) { - if (!this.updatedConfig.basicMarketMakingConfig.quoteOptions) break - if (!this.originalConfig.basicMarketMakingConfig.quoteOptions) break - const originalValue = this.originalConfig.basicMarketMakingConfig.quoteOptions[opt] - const updatedValue = this.updatedConfig.basicMarketMakingConfig.quoteOptions[opt] - const modified = originalValue !== updatedValue - this.quoteWalletSettingControl[opt].toHighlight.classList.toggle('modified', modified) + this.quoteWalletSettingControl[opt].toHighlight.classList.toggle('modified', oldCfg.quoteOptions[opt] !== newCfg.quoteOptions[opt]) } } @@ -398,8 +534,8 @@ export default class MarketMakerSettingsPage extends BasePage { * the placement table. initialLoadPlacement is non-nil if this is being * called on the initial load. */ - addPlacement (isBuy: boolean, initialLoadPlacement: OrderPlacement | null, running: boolean) { - const page = this.page + addPlacement (isBuy: boolean, initialLoadPlacement: OrderPlacement | null, running: boolean, gapStrategy?: string) { + const { page, updatedConfig: cfg } = this let tableBody: PageElement = page.sellPlacementsTableBody let addPlacementRow: PageElement = page.addSellPlacementRow @@ -414,13 +550,6 @@ export default class MarketMakerSettingsPage extends BasePage { errElement = page.buyPlacementsErr } - const getPlacementsList = (buy: boolean) : OrderPlacement[] => { - if (buy) { - return this.updatedConfig.basicMarketMakingConfig.buyPlacements - } - return this.updatedConfig.basicMarketMakingConfig.sellPlacements - } - // updateArrowVis updates the visibility of the move up/down arrows in // each row of the placement table. The up arrow is not shown on the // top row, and the down arrow is not shown on the bottom row. They @@ -446,7 +575,8 @@ export default class MarketMakerSettingsPage extends BasePage { let lots : number let actualGapFactor : number let displayedGapFactor : number - const gapStrategy = this.updatedConfig.basicMarketMakingConfig.gapStrategy + if (!gapStrategy) gapStrategy = this.selectedCEX() ? GapStrategyMultiplier : cfg.gapStrategy + const placements = isBuy ? cfg.buyPlacements : cfg.sellPlacements const unit = this.gapFactorHeaderUnit(gapStrategy)[1] if (initialLoadPlacement) { lots = initialLoadPlacement.lots @@ -467,7 +597,6 @@ export default class MarketMakerSettingsPage extends BasePage { return } - const placements = getPlacementsList(isBuy) if (placements.find((placement) => placement.gapFactor === actualGapFactor) ) { setErr('Duplicate placement') @@ -483,7 +612,6 @@ export default class MarketMakerSettingsPage extends BasePage { newRowTmpl.lots.textContent = `${lots}` newRowTmpl.gapFactor.textContent = `${displayedGapFactor} ${unit}` Doc.bind(newRowTmpl.removeBtn, 'click', () => { - const placements = getPlacementsList(isBuy) const index = placements.findIndex((placement) => { return placement.lots === lots && placement.gapFactor === actualGapFactor }) @@ -498,11 +626,7 @@ export default class MarketMakerSettingsPage extends BasePage { } Doc.bind(newRowTmpl.upBtn, 'click', () => { - const placements = getPlacementsList(isBuy) - const index = placements.findIndex( - (placement) => - placement.lots === lots && placement.gapFactor === actualGapFactor - ) + const index = placements.findIndex((p: OrderPlacement) => p.lots === lots && p.gapFactor === actualGapFactor) if (index === 0) return const prevPlacement = placements[index - 1] placements[index - 1] = placements[index] @@ -519,11 +643,7 @@ export default class MarketMakerSettingsPage extends BasePage { }) Doc.bind(newRowTmpl.downBtn, 'click', () => { - const placements = getPlacementsList(isBuy) - const index = placements.findIndex( - (placement) => - placement.lots === lots && placement.gapFactor === actualGapFactor - ) + const index = placements.findIndex((p) => p.lots === lots && p.gapFactor === actualGapFactor) if (index === placements.length - 1) return const nextPlacement = placements[index + 1] placements[index + 1] = placements[index] @@ -543,6 +663,11 @@ export default class MarketMakerSettingsPage extends BasePage { updateArrowVis() } + setArbMMLabels () { + this.page.buyGapFactorHdr.textContent = intl.prep(intl.ID_MULTIPLIER) + this.page.sellGapFactorHdr.textContent = intl.prep(intl.ID_MULTIPLIER) + } + /* * setGapFactorLabels sets the headers on the gap factor column of each * placement table. @@ -572,21 +697,15 @@ export default class MarketMakerSettingsPage extends BasePage { * the market making config. */ setupMMConfigSettings (running: boolean) { - const page = this.page + const { page, updatedConfig: cfg } = this // Gap Strategy Doc.bind(page.gapStrategySelect, 'change', () => { if (!page.gapStrategySelect.value) return const gapStrategy = page.gapStrategySelect.value - this.updatedConfig.basicMarketMakingConfig.gapStrategy = gapStrategy - while (page.buyPlacementsTableBody.children.length > 1) { - page.buyPlacementsTableBody.children[0].remove() - } - while (page.sellPlacementsTableBody.children.length > 1) { - page.sellPlacementsTableBody.children[0].remove() - } - this.updatedConfig.basicMarketMakingConfig.buyPlacements = [] - this.updatedConfig.basicMarketMakingConfig.sellPlacements = [] + this.clearPlacements(cfg.gapStrategy) + this.loadCachedPlacements(gapStrategy) + cfg.gapStrategy = gapStrategy this.setGapFactorLabels(gapStrategy) this.updateModifiedMarkers() }) @@ -633,44 +752,40 @@ export default class MarketMakerSettingsPage extends BasePage { Doc.bind(page.addSellPlacementGapFactor, 'keyup', (e: KeyboardEvent) => { maybeSubmitSellRow(e) }) Doc.bind(page.addSellPlacementLots, 'keyup', (e: KeyboardEvent) => { maybeSubmitSellRow(e) }) + const handleChanged = () => { this.updateModifiedMarkers() } + + // Profit + page.profitInput.value = String(cfg.profit) + Doc.bind(page.profitInput, 'change', () => { + Doc.hide(page.profitInputErr) + const showError = (errID: string) => { + Doc.show(page.profitInputErr) + page.profitInputErr.textContent = intl.prep(errID) + } + cfg.profit = parseFloat(page.profitInput.value || '') + if (isNaN(cfg.profit)) return showError(intl.ID_INVALID_VALUE) + if (cfg.profit === 0) return showError(intl.ID_NO_ZERO) + }) + // Drift tolerance - const updatedDriftTolerance = (x: number) => { - this.updatedConfig.basicMarketMakingConfig.driftTolerance = x - } - const changed = () => { - this.updateModifiedMarkers() - } - const doNothing = () => { - /* do nothing */ - } - const currDriftTolerance = this.updatedConfig.basicMarketMakingConfig.driftTolerance - this.driftToleranceRangeHandler = new XYRangeHandler( - driftToleranceRange, - currDriftTolerance, - updatedDriftTolerance, - changed, - doNothing, - false, - false, - running - ) - page.driftToleranceContainer.appendChild( - this.driftToleranceRangeHandler.control - ) + this.driftTolerance = new RangeOption(driftToleranceRange, cfg.driftTolerance, false, false, running, cfg, 'driftTolerance') + this.driftTolerance.setChanged(handleChanged) + page.driftToleranceContainer.appendChild(this.driftTolerance.div) + + // CEX order persistence + this.orderPersistence = new RangeOption(orderPersistenceRange, cfg.orderPersistence, true, true, running, cfg, 'orderPersistence') + this.orderPersistence.setChanged(handleChanged) + this.orderPersistence.setUpdate((x: number) => { + this.orderPersistence.xyRange.setXLabel('') + x = Math.round(x) + this.orderPersistence.xyRange.setYLabel(x === 21 ? '∞' : String(x)) + return x + }) + page.orderPersistenceContainer.appendChild(this.orderPersistence.div) - // User oracle + // Use oracle Doc.bind(page.useOracleCheckbox, 'change', () => { - if (page.useOracleCheckbox.checked) { - Doc.show(page.oracleBiasSection, page.oracleWeightingSection) - this.updatedConfig.basicMarketMakingConfig.oracleWeighting = defaultMarketMakingConfig.oracleWeighting - this.updatedConfig.basicMarketMakingConfig.oracleBias = defaultMarketMakingConfig.oracleBias - this.oracleWeightingRangeHandler.setValue(defaultMarketMakingConfig.oracleWeighting) - this.oracleBiasRangeHandler.setValue(defaultMarketMakingConfig.oracleBias) - } else { - Doc.hide(page.oracleBiasSection, page.oracleWeightingSection) - this.updatedConfig.basicMarketMakingConfig.oracleWeighting = 0 - this.updatedConfig.basicMarketMakingConfig.oracleBias = 0 - } + this.useOraclesChanged() this.updateModifiedMarkers() }) if (running) { @@ -678,58 +793,28 @@ export default class MarketMakerSettingsPage extends BasePage { } // Oracle Bias - const currOracleBias = this.originalConfig.basicMarketMakingConfig.oracleBias - const updatedOracleBias = (x: number) => { - this.updatedConfig.basicMarketMakingConfig.oracleBias = x - } - this.oracleBiasRangeHandler = new XYRangeHandler( - oracleBiasRange, - currOracleBias, - updatedOracleBias, - changed, - doNothing, - false, - false, - running - ) - page.oracleBiasContainer.appendChild(this.oracleBiasRangeHandler.control) + this.oracleBias = new RangeOption(oracleBiasRange, cfg.oracleBias, false, false, running, cfg, 'oracleBias') + this.oracleBias.setChanged(handleChanged) + page.oracleBiasContainer.appendChild(this.oracleBias.div) // Oracle Weighting - const currOracleWeighting = this.originalConfig.basicMarketMakingConfig.oracleWeighting - const updatedOracleWeighting = (x: number) => { - this.updatedConfig.basicMarketMakingConfig.oracleWeighting = x - } - this.oracleWeightingRangeHandler = new XYRangeHandler( - oracleWeightRange, - currOracleWeighting, - updatedOracleWeighting, - changed, - doNothing, - false, - false, - running - ) - page.oracleWeightingContainer.appendChild( - this.oracleWeightingRangeHandler.control - ) + this.oracleWeighting = new RangeOption(oracleWeightRange, cfg.oracleWeighting, false, false, running, cfg, 'oracleWeighting') + this.oracleWeighting.setChanged(handleChanged) + page.oracleWeightingContainer.appendChild(this.oracleWeighting.div) // Empty Market Rate Doc.bind(page.emptyMarketRateCheckbox, 'change', () => { - if (page.emptyMarketRateCheckbox.checked) { - this.updatedConfig.basicMarketMakingConfig.emptyMarketRate = this.originalConfig.basicMarketMakingConfig.emptyMarketRate - page.emptyMarketRateInput.value = `${this.updatedConfig.basicMarketMakingConfig.emptyMarketRate}` - Doc.show(page.emptyMarketRateInputBox) - this.updateModifiedMarkers() - } else { - this.updatedConfig.basicMarketMakingConfig.emptyMarketRate = 0 - Doc.hide(page.emptyMarketRateInputBox) - this.updateModifiedMarkers() - } + this.useEmptyMarketRateChanged() + this.updateModifiedMarkers() }) Doc.bind(page.emptyMarketRateInput, 'change', () => { - const emptyMarketRate = parseFloat(page.emptyMarketRateInput.value || '0') - this.updatedConfig.basicMarketMakingConfig.emptyMarketRate = emptyMarketRate + Doc.hide(page.emptyMarketRateErr) + cfg.emptyMarketRate = parseFloat(page.emptyMarketRateInput.value || '0') this.updateModifiedMarkers() + if (cfg.emptyMarketRate === 0) { + Doc.show(page.emptyMarketRateErr) + page.emptyMarketRateErr.textContent = intl.prep(intl.ID_NO_ZERO) + } }) if (running) { page.emptyMarketRateCheckbox.setAttribute('disabled', 'true') @@ -737,157 +822,272 @@ export default class MarketMakerSettingsPage extends BasePage { } } - /* - * setOriginalValues sets the updatedConfig field to be equal to the - * and sets the values displayed buy each field input to be equal - * to the values since the last save. - */ - setOriginalValues (running: boolean) { - const page = this.page - this.updatedConfig = JSON.parse(JSON.stringify(this.originalConfig)) - - // Gap strategy - if (!page.gapStrategySelect.options) return - Array.from(page.gapStrategySelect.options).forEach( - (opt: HTMLOptionElement) => { - if (opt.value === this.originalConfig.basicMarketMakingConfig.gapStrategy) { - opt.selected = true - } - } - ) - this.setGapFactorLabels(this.originalConfig.basicMarketMakingConfig.gapStrategy) - - // Buy/Sell placements + clearPlacements (cacheKey: string) { + const { page, updatedConfig: cfg } = this while (page.buyPlacementsTableBody.children.length > 1) { page.buyPlacementsTableBody.children[0].remove() } while (page.sellPlacementsTableBody.children.length > 1) { page.sellPlacementsTableBody.children[0].remove() } - this.originalConfig.basicMarketMakingConfig.buyPlacements.forEach((placement) => { - this.addPlacement(true, placement, running) - }) - this.originalConfig.basicMarketMakingConfig.sellPlacements.forEach((placement) => { - this.addPlacement(false, placement, running) - }) + this.placementsCache[cacheKey] = [cfg.buyPlacements, cfg.sellPlacements] + cfg.buyPlacements = [] + cfg.sellPlacements = [] + } - // Empty market rate - page.emptyMarketRateCheckbox.checked = this.originalConfig.basicMarketMakingConfig.emptyMarketRate > 0 - Doc.setVis(page.emptyMarketRateCheckbox.checked, page.emptyMarketRateInputBox) - page.emptyMarketRateInput.value = `${this.originalConfig.basicMarketMakingConfig.emptyMarketRate || 0}` + loadCachedPlacements (cacheKey: string) { + const c = this.placementsCache[cacheKey] + if (!c) return + const { updatedConfig: cfg } = this + cfg.buyPlacements = c[0] + cfg.sellPlacements = c[1] + const gapStrategy = cacheKey === arbMMRowCacheKey ? GapStrategyMultiplier : cacheKey + for (const p of cfg.buyPlacements) this.addPlacement(true, p, false, gapStrategy) + for (const p of cfg.sellPlacements) this.addPlacement(false, p, false, gapStrategy) + } - // Use oracles - if (this.originalConfig.basicMarketMakingConfig.oracleWeighting === 0) { - page.useOracleCheckbox.checked = false + useOraclesChanged () { + const { page, updatedConfig: cfg } = this + if (page.useOracleCheckbox.checked) { + Doc.show(page.oracleBiasSection, page.oracleWeightingSection) + cfg.useOracles = true + this.oracleWeighting.setValue(cfg.oracleWeighting || defaultMarketMakingConfig.oracleWeighting) + this.oracleBias.setValue(cfg.oracleBias || defaultMarketMakingConfig.oracleBias) + } else { Doc.hide(page.oracleBiasSection, page.oracleWeightingSection) + cfg.useOracles = false } + } - // Oracle bias - this.oracleBiasRangeHandler.setValue(this.originalConfig.basicMarketMakingConfig.oracleBias) - - // Oracle weight - this.oracleWeightingRangeHandler.setValue(this.originalConfig.basicMarketMakingConfig.oracleWeighting) + useEmptyMarketRateChanged () { + const { page, updatedConfig: cfg } = this + if (page.emptyMarketRateCheckbox.checked) { + cfg.useEmptyMarketRate = true + const r = cfg.emptyMarketRate ?? this.originalConfig.emptyMarketRate ?? 0 + page.emptyMarketRateInput.value = String(r) + cfg.emptyMarketRate = r + Doc.show(page.emptyMarketRateInputBox) + this.updateModifiedMarkers() + } else { + cfg.useEmptyMarketRate = false + Doc.hide(page.emptyMarketRateInputBox) + } + } - // Drift tolerance - this.driftToleranceRangeHandler.setValue(this.originalConfig.basicMarketMakingConfig.driftTolerance) + /* + * setOriginalValues sets the updatedConfig field to be equal to the + * and sets the values displayed buy each field input to be equal + * to the values since the last save. + */ + setOriginalValues (running: boolean) { + const { + page, originalConfig: oldCfg, updatedConfig: cfg, + oracleBias, oracleWeighting, driftTolerance, orderPersistence, + baseBalance, quoteBalance + } = this + + this.clearPlacements(oldCfg.cexName ? arbMMRowCacheKey : cfg.gapStrategy) + + // The RangeOptions maintain references to the options object, so we'll + // need to preserve those. + const [bOpts, qOpts] = [cfg.baseOptions, cfg.quoteOptions] + + Object.assign(cfg, oldCfg) + + // Re-assing the wallet options. + for (const k of Object.keys(bOpts)) delete bOpts[k] + for (const k of Object.keys(qOpts)) delete qOpts[k] + Object.assign(bOpts, oldCfg.baseOptions) + Object.assign(qOpts, oldCfg.quoteOptions) + cfg.baseOptions = bOpts + cfg.quoteOptions = qOpts + + oracleBias.reset() + oracleWeighting.reset() + driftTolerance.reset() + orderPersistence.reset() + if (baseBalance) baseBalance.reset() + if (quoteBalance) quoteBalance.reset() + page.profitInput.value = String(cfg.profit) + page.useOracleCheckbox.checked = cfg.useOracles && oldCfg.oracleWeighting > 0 + this.useOraclesChanged() + page.emptyMarketRateCheckbox.checked = cfg.useEmptyMarketRate && cfg.emptyMarketRate > 0 + this.useEmptyMarketRateChanged() - // Base balance - if (this.baseBalanceRangeHandler) { - this.baseBalanceRangeHandler.setValue(this.originalConfig.baseBalance) - } + // Gap strategy + if (!page.gapStrategySelect.options) return + Array.from(page.gapStrategySelect.options).forEach((opt: HTMLOptionElement) => { opt.selected = opt.value === cfg.gapStrategy }) + this.setGapFactorLabels(cfg.gapStrategy) - // Quote balance - if (this.quoteBalanceRangeHandler) { - this.quoteBalanceRangeHandler.setValue(this.originalConfig.quoteBalance) - } + if (cfg.cexName) this.selectCEX(cfg.cexName) + else this.unselectCEX() - // Base wallet options - if (this.updatedConfig.basicMarketMakingConfig.baseOptions && this.originalConfig.basicMarketMakingConfig.baseOptions) { - for (const opt of Object.keys(this.updatedConfig.basicMarketMakingConfig.baseOptions)) { - const value = this.originalConfig.basicMarketMakingConfig.baseOptions[opt] - this.updatedConfig.basicMarketMakingConfig.baseOptions[opt] = value - if (this.baseWalletSettingControl[opt]) { - this.baseWalletSettingControl[opt].setValue(value) - } + // Buy/Sell placements + oldCfg.buyPlacements.forEach((p) => { this.addPlacement(true, p, running) }) + oldCfg.sellPlacements.forEach((p) => { this.addPlacement(false, p, running) }) + + for (const opt of Object.keys(cfg.baseOptions)) { + const value = oldCfg.baseOptions[opt] + cfg.baseOptions[opt] = value + if (this.baseWalletSettingControl[opt]) { + this.baseWalletSettingControl[opt].setValue(value) } } // Quote wallet options - if (this.updatedConfig.basicMarketMakingConfig.quoteOptions && this.originalConfig.basicMarketMakingConfig.quoteOptions) { - for (const opt of Object.keys(this.updatedConfig.basicMarketMakingConfig.quoteOptions)) { - const value = this.originalConfig.basicMarketMakingConfig.quoteOptions[opt] - this.updatedConfig.basicMarketMakingConfig.quoteOptions[opt] = value - if (this.quoteWalletSettingControl[opt]) { - this.quoteWalletSettingControl[opt].setValue(value) - } + for (const opt of Object.keys(cfg.quoteOptions)) { + const value = oldCfg.quoteOptions[opt] + cfg.quoteOptions[opt] = value + if (this.quoteWalletSettingControl[opt]) { + this.quoteWalletSettingControl[opt].setValue(value) } } this.updateModifiedMarkers() } + /* + * validateFields validates configuration values and optionally shows error + * messages. + */ + validateFields (showErrors: boolean): boolean { + let ok = true + const { page, updatedConfig: { sellPlacements, buyPlacements, profit, useEmptyMarketRate, emptyMarketRate, baseBalance, quoteBalance } } = this + const setError = (errEl: PageElement, errID: string) => { + ok = false + if (!showErrors) return + errEl.textContent = intl.prep(errID) + Doc.show(errEl) + } + if (showErrors) { + Doc.hide( + page.buyPlacementsErr, page.sellPlacementsErr, page.profitInputErr, + page.emptyMarketRateErr, page.baseBalanceErr, page.quoteBalanceErr + ) + } + if (buyPlacements.length === 0) setError(page.buyPlacementsErr, intl.ID_NO_PLACEMENTS) + if (sellPlacements.length === 0) setError(page.sellPlacementsErr, intl.ID_NO_PLACEMENTS) + if (this.selectedCEX()) { + if (isNaN(profit)) setError(page.profitInputErr, intl.ID_INVALID_VALUE) + else if (profit === 0) setError(page.profitInputErr, intl.ID_NO_ZERO) + } else { // basic mm + // DRAFT TODO: Should we enforce an empty market rate if there are no + // oracles? + if (useEmptyMarketRate && emptyMarketRate === 0) setError(page.emptyMarketRateErr, intl.ID_NO_ZERO) + if (baseBalance === 0) setError(page.baseBalanceErr, intl.ID_NO_ZERO) + if (quoteBalance === 0) setError(page.quoteBalanceErr, intl.ID_NO_ZERO) + } + return ok + } + /* * saveSettings updates the settings in the backend, and sets the originalConfig * to be equal to the updatedConfig. */ async saveSettings () { - await app().updateMarketMakingConfig(this.updatedConfig) - this.originalConfig = JSON.parse(JSON.stringify(this.updatedConfig)) + // Make a copy and delete either the basic mm config or the arb-mm config, + // depending on whether a cex is selected. + if (!this.validateFields(true)) return + const { updatedConfig: cfg, baseID, quoteID, host } = this + const botCfg: BotConfig = { + host: host, + baseID: baseID, + quoteID: quoteID, + baseBalanceType: cfg.baseBalanceType, + baseBalance: cfg.baseBalance, + quoteBalanceType: cfg.quoteBalanceType, + quoteBalance: cfg.quoteBalance, + disabled: cfg.disabled + } + if (this.selectedCEX()) botCfg.arbMarketMakingConfig = this.arbMMConfig() + else botCfg.basicMarketMakingConfig = this.basicMMConfig() + await MM.updateBotConfig(botCfg) + this.originalConfig = JSON.parse(JSON.stringify(cfg)) this.updateModifiedMarkers() app().loadPage('mm') } + /* + * arbMMConfig parses the configuration for the arb-mm bot. Only one of + * arbMMConfig or basicMMConfig should be used when updating the bot + * configuration. Which is used depends on if the user has configured and + * selected a CEX or not. + */ + arbMMConfig (): ArbMarketMakingCfg { + const { updatedConfig: cfg } = this + const arbCfg: ArbMarketMakingCfg = { + cexName: cfg.cexName, + buyPlacements: [], + sellPlacements: [], + profit: cfg.profit, + driftTolerance: cfg.driftTolerance, + orderPersistence: cfg.orderPersistence, + baseOptions: cfg.baseOptions, + quoteOptions: cfg.quoteOptions + } + for (const p of cfg.buyPlacements) arbCfg.buyPlacements.push({ lots: p.lots, multiplier: p.gapFactor }) + for (const p of cfg.sellPlacements) arbCfg.sellPlacements.push({ lots: p.lots, multiplier: p.gapFactor }) + return arbCfg + } + + /* + * basicMMConfig parses the configuration for the basic marketmaker. Only of + * of basidMMConfig or arbMMConfig should be used when updating the bot + * configuration. + */ + basicMMConfig (): BasicMarketMakingCfg { + const { updatedConfig: cfg } = this + const mmCfg: BasicMarketMakingCfg = { + gapStrategy: cfg.gapStrategy, + sellPlacements: cfg.sellPlacements, + buyPlacements: cfg.buyPlacements, + driftTolerance: cfg.driftTolerance, + oracleWeighting: cfg.useOracles ? cfg.oracleWeighting : 0, + oracleBias: cfg.useOracles ? cfg.oracleBias : 0, + emptyMarketRate: cfg.useEmptyMarketRate ? cfg.emptyMarketRate : 0, + baseOptions: cfg.baseOptions, + quoteOptions: cfg.quoteOptions + } + return mmCfg + } + + /* + * selectedCEX returns the currently selected CEX, else null if none are + * selected. + */ + selectedCEX (): cexButton | null { + const cexes = Object.entries(this.cexes).filter((v: [string, cexButton]) => v[1].div.classList.contains('selected')) + if (cexes.length) return cexes[0][1] + return null + } + /* * setupBalanceSelectors sets up the balance selection sections. If an asset * has no balance available, or of other market makers have claimed the entire * balance, a message communicating this is displayed. */ - setupBalanceSelectors (allConfigs: BotConfig[], running: boolean) { - const page = this.page - - const baseAsset = app().assets[this.updatedConfig.baseAsset] - const quoteAsset = app().assets[this.updatedConfig.quoteAsset] - const availableBaseBalance = baseAsset.wallet.balance.available - const availableQuoteBalance = quoteAsset.wallet.balance.available + setupBalanceSelectors (running: boolean) { + const { page, updatedConfig: cfg, host, baseID, quoteID } = this + const { wallet: { balance: { available: bAvail } }, unitInfo: bui } = app().assets[baseID] + const { wallet: { balance: { available: qAvail } }, unitInfo: qui } = app().assets[quoteID] let baseReservedByOtherBots = 0 - let quoteReservedByOtherBots = 0 - allConfigs.forEach((market) => { - if (market.baseAsset === this.updatedConfig.baseAsset && market.quoteAsset === this.updatedConfig.quoteAsset && - market.host === this.updatedConfig.host) { + let quoteReservedByOtherBots = 0; + (MM.cfg.botConfigs || []).forEach((botCfg: BotConfig) => { + if (botCfg.baseID === baseID && botCfg.quoteID === quoteID && + botCfg.host === host) { return } - if (market.baseAsset === this.updatedConfig.baseAsset) { - baseReservedByOtherBots += market.baseBalance - } - if (market.quoteAsset === this.updatedConfig.baseAsset) { - baseReservedByOtherBots += market.quoteBalance - } - if (market.baseAsset === this.updatedConfig.quoteAsset) { - quoteReservedByOtherBots += market.baseBalance - } - if (market.quoteAsset === this.updatedConfig.quoteAsset) { - quoteReservedByOtherBots += market.quoteBalance - } + if (botCfg.baseID === baseID) baseReservedByOtherBots += botCfg.baseBalance + if (botCfg.quoteID === baseID) baseReservedByOtherBots += botCfg.quoteBalance + if (botCfg.baseID === quoteID) quoteReservedByOtherBots += botCfg.baseBalance + if (botCfg.quoteID === quoteID) quoteReservedByOtherBots += botCfg.quoteBalance }) - let baseMaxPercent = 0 - let quoteMaxPercent = 0 - if (baseReservedByOtherBots < 100) { - baseMaxPercent = 100 - baseReservedByOtherBots - } - if (quoteReservedByOtherBots < 100) { - quoteMaxPercent = 100 - quoteReservedByOtherBots - } - - const baseMaxAvailable = Doc.conventionalCoinValue( - (availableBaseBalance * baseMaxPercent) / 100, - baseAsset.unitInfo - ) - const quoteMaxAvailable = Doc.conventionalCoinValue( - (availableQuoteBalance * quoteMaxPercent) / 100, - quoteAsset.unitInfo - ) + const baseMaxPercent = baseReservedByOtherBots < 100 ? 100 - baseReservedByOtherBots : 0 + const quoteMaxPercent = quoteReservedByOtherBots < 100 ? 100 - quoteReservedByOtherBots : 0 + const baseMaxAvailable = Doc.conventionalCoinValue(bAvail * baseMaxPercent / 100, bui) + const quoteMaxAvailable = Doc.conventionalCoinValue(qAvail * quoteMaxPercent / 100, qui) const baseXYRange: XYRange = { start: { @@ -901,7 +1101,7 @@ export default class MarketMakerSettingsPage extends BasePage { y: baseMaxAvailable }, xUnit: '%', - yUnit: baseAsset.symbol + yUnit: bui.conventional.unit } const quoteXYRange: XYRange = { @@ -916,60 +1116,29 @@ export default class MarketMakerSettingsPage extends BasePage { y: quoteMaxAvailable }, xUnit: '%', - yUnit: quoteAsset.symbol + yUnit: qui.conventional.unit } - Doc.hide( - page.noBaseBalance, - page.noQuoteBalance, - page.baseBalanceContainer, - page.quoteBalanceContainer - ) + Doc.hide(page.noBaseBalance, page.noQuoteBalance, page.baseBalanceContainer, page.quoteBalanceContainer) Doc.empty(page.baseBalanceContainer, page.quoteBalanceContainer) if (baseMaxAvailable > 0) { - const updatedBase = (x: number) => { - this.updatedConfig.baseBalance = x - this.updateModifiedMarkers() - } - const currBase = this.originalConfig.baseBalance - const baseRangeHandler = new XYRangeHandler( - baseXYRange, - currBase, - updatedBase, - () => { /* do nothing */ }, - () => { /* do nothing */ }, - false, - true, - running - ) - page.baseBalanceContainer.appendChild(baseRangeHandler.control) - this.baseBalanceRangeHandler = baseRangeHandler + this.baseBalance = new RangeOption(baseXYRange, cfg.baseBalance, false, true, running, cfg, 'baseBalance') + this.baseBalance.setChanged(() => { this.updateModifiedMarkers() }) + page.baseBalanceContainer.appendChild(this.baseBalance.div) Doc.show(page.baseBalanceContainer) } else { + this.baseBalance = undefined Doc.show(page.noBaseBalance) } if (quoteMaxAvailable > 0) { - const updatedQuote = (x: number) => { - this.updatedConfig.quoteBalance = x - this.updateModifiedMarkers() - } - const currQuote = this.originalConfig.quoteBalance - const quoteRangeHandler = new XYRangeHandler( - quoteXYRange, - currQuote, - updatedQuote, - () => { /* do nothing */ }, - () => { /* do nothing */ }, - false, - true, - running - ) - page.quoteBalanceContainer.appendChild(quoteRangeHandler.control) - this.quoteBalanceRangeHandler = quoteRangeHandler + this.quoteBalance = new RangeOption(quoteXYRange, cfg.quoteBalance, false, true, running, cfg, 'quoteBalance') + this.quoteBalance.setChanged(() => { this.updateModifiedMarkers() }) + page.quoteBalanceContainer.appendChild(this.quoteBalance.div) Doc.show(page.quoteBalanceContainer) } else { + this.quoteBalance = undefined Doc.show(page.noQuoteBalance) } } @@ -979,7 +1148,7 @@ export default class MarketMakerSettingsPage extends BasePage { These are based on the multi funding settings in the wallet definition. */ setupWalletSettings (running: boolean) { - const page = this.page + const { page, updatedConfig: { quoteOptions, baseOptions } } = this const baseWalletSettings = app().currentWalletDefinition(this.baseID) const quoteWalletSettings = app().currentWalletDefinition(this.quoteID) Doc.setVis(baseWalletSettings.multifundingopts, page.baseWalletSettings) @@ -1032,36 +1201,25 @@ export default class MarketMakerSettingsPage extends BasePage { } } const setWalletOption = (quote: boolean, key: string, value: string) => { - if (quote) { - if (!this.updatedConfig.basicMarketMakingConfig.quoteOptions) return - this.updatedConfig.basicMarketMakingConfig.quoteOptions[key] = value - } else { - if (!this.updatedConfig.basicMarketMakingConfig.baseOptions) return - this.updatedConfig.basicMarketMakingConfig.baseOptions[key] = value - } + if (quote) quoteOptions[key] = value + else baseOptions[key] = value } const getWalletOption = (quote: boolean, key: string) : string | undefined => { - if (quote) { - if (!this.updatedConfig.basicMarketMakingConfig.quoteOptions) return - return this.updatedConfig.basicMarketMakingConfig.quoteOptions[key] - } else { - if (!this.updatedConfig.basicMarketMakingConfig.baseOptions) return - return this.updatedConfig.basicMarketMakingConfig.baseOptions[key] - } + if (quote) return quoteOptions[key] + else return baseOptions[key] } const addOpt = (opt: OrderOption, quote: boolean) => { - let currVal let container + let optionsDict if (quote) { - if (!this.updatedConfig.basicMarketMakingConfig.quoteOptions) return - currVal = this.updatedConfig.basicMarketMakingConfig.quoteOptions[opt.key] container = page.quoteWalletSettingsContainer + optionsDict = quoteOptions } else { if (opt.quoteAssetOnly) return - if (!this.updatedConfig.basicMarketMakingConfig.baseOptions) return - currVal = this.updatedConfig.basicMarketMakingConfig.baseOptions[opt.key] container = page.baseWalletSettingsContainer + optionsDict = baseOptions } + const currVal = optionsDict[opt.key] let setting : PageElement | undefined if (opt.isboolean) { setting = page.boolSettingTmpl.cloneNode(true) as PageElement @@ -1086,22 +1244,12 @@ export default class MarketMakerSettingsPage extends BasePage { const tmpl = Doc.parseTemplate(setting) tmpl.name.textContent = opt.displayname if (opt.description) tmpl.tooltip.dataset.tooltip = opt.description - const currValNum = parseInt(currVal) - const handler = new XYRangeHandler( - opt.xyRange, - currValNum, - (x: number) => { setWalletOption(quote, opt.key, `${x}`) }, - () => { this.updateModifiedMarkers() }, - () => { /* do nothing */ }, - opt.xyRange.roundX, - opt.xyRange.roundY, - running - ) - const setValue = (x: string) => { - handler.setValue(parseInt(x)) - } + const handler = new RangeOption(opt.xyRange, parseInt(currVal), Boolean(opt.xyRange.roundX), Boolean(opt.xyRange.roundY), running, optionsDict, opt.key) + handler.stringify() + handler.setChanged(() => { this.updateModifiedMarkers() }) + const setValue = (x: string) => { handler.setValue(parseInt(x)) } storeWalletSettingControl(opt.key, tmpl.sliderContainer, setValue, quote) - tmpl.sliderContainer.appendChild(handler.control) + tmpl.sliderContainer.appendChild(handler.div) container.appendChild(setting) } if (!setting) return @@ -1127,10 +1275,9 @@ export default class MarketMakerSettingsPage extends BasePage { * them on the screen. */ async fetchOracles (): Promise { - const page = this.page - const { baseAsset, quoteAsset } = this.originalConfig + const { page, baseID, quoteID } = this - const res = await postJSON('/api/marketreport', { baseID: baseAsset, quoteID: quoteAsset }) + const res = await MM.report(baseID, quoteID) Doc.hide(page.oraclesLoading) if (!app().checkResponse(res)) { @@ -1157,16 +1304,16 @@ export default class MarketMakerSettingsPage extends BasePage { Doc.show(page.oraclesTable) } - page.baseFiatRateSymbol.textContent = app().assets[baseAsset].symbol.toUpperCase() - page.baseFiatRateLogo.src = Doc.logoPathFromID(baseAsset) + page.baseFiatRateSymbol.textContent = app().assets[baseID].symbol.toUpperCase() + page.baseFiatRateLogo.src = Doc.logoPathFromID(baseID) if (r.baseFiatRate > 0) { page.baseFiatRate.textContent = Doc.formatFourSigFigs(r.baseFiatRate) } else { page.baseFiatRate.textContent = 'N/A' } - page.quoteFiatRateSymbol.textContent = app().assets[quoteAsset].symbol.toUpperCase() - page.quoteFiatRateLogo.src = Doc.logoPathFromID(quoteAsset) + page.quoteFiatRateSymbol.textContent = app().assets[quoteID].symbol.toUpperCase() + page.quoteFiatRateLogo.src = Doc.logoPathFromID(quoteID) if (r.quoteFiatRate > 0) { page.quoteFiatRate.textContent = Doc.formatFourSigFigs(r.quoteFiatRate) } else { @@ -1174,6 +1321,150 @@ export default class MarketMakerSettingsPage extends BasePage { } Doc.show(page.fiatRates) } + + /* + * handleCEXSubmit handles clicks on the CEX configuration submission button. + */ + async handleCEXSubmit () { + const page = this.page + Doc.hide(page.cexFormErr) + const cexName = page.cexConfigForm.dataset.cexName || '' + const apiKey = page.cexApiKeyInput.value + const apiSecret = page.cexSecretInput.value + if (!apiKey || !apiSecret) { + Doc.show(page.cexFormErr) + page.cexFormErr.textContent = intl.prep(intl.ID_NO_PASS_ERROR_MSG) + return + } + try { + await MM.updateCEXConfig({ + name: cexName, + apiKey: apiKey, + apiSecret: apiSecret + }) + } catch (e) { + Doc.show(page.cexFormErr) + page.cexFormErr.textContent = intl.prep(intl.ID_API_ERROR, { msg: e.msg ?? String(e) }) + return + } + if (!this.cexes[cexName]) this.cexes[cexName] = this.addCEX(cexName) + this.setCEXAvailability() + this.forms.close() + this.selectCEX(cexName) + } + + /* + * setupCEXes should be called during initialization. + */ + setupCEXes () { + const page = this.page + this.cexes = {} + Doc.empty(page.cexOpts) + for (const name of Object.keys(CEXDisplayInfos)) this.cexes[name] = this.addCEX(name) + this.setCEXAvailability() + } + + /* + * setCEXAvailability sets the coloring and messaging of the CEX selection + * buttons. + */ + setCEXAvailability () { + const hasMarket = this.supportingCEXes() + for (const { name, tmpl } of Object.values(this.cexes)) { + const has = hasMarket[name] + const configured = MM.cexConfigured(name) + Doc.hide(tmpl.unavailable, tmpl.needsconfig) + tmpl.logo.classList.remove('off') + if (!configured) { + Doc.show(tmpl.needsconfig) + } else if (!has) { + Doc.show(tmpl.unavailable) + tmpl.logo.classList.add('off') + } + } + } + + /* + * addCEX adds a button for the specified CEX, returning a cexButton. + */ + addCEX (cexName: string): cexButton { + const { page, updatedConfig: cfg } = this + const div = page.cexOptTmpl.cloneNode(true) as PageElement + Doc.bind(div, 'click', () => { + const cex = this.cexes[cexName] + if (cex.div.classList.contains('selected')) { + this.clearPlacements(arbMMRowCacheKey) + this.loadCachedPlacements(page.gapStrategySelect.value || '') + this.unselectCEX() + return + } + if (!cfg.cexName) { + this.clearPlacements(cfg.gapStrategy) + this.loadCachedPlacements(arbMMRowCacheKey) + } + this.selectCEX(cexName) + }) + page.cexOpts.appendChild(div) + const tmpl = Doc.parseTemplate(div) + const dinfo = CEXDisplayInfos[cexName] + tmpl.name.textContent = dinfo.name + tmpl.logo.src = '/img/' + dinfo.logo + return { name: cexName, div, tmpl } + } + + unselectCEX () { + const { page, updatedConfig: cfg } = this + for (const cex of Object.values(this.cexes)) cex.div.classList.remove('selected') + Doc.show(page.gapStrategyBox, page.oraclesSettingBox, page.baseBalanceBox, page.quoteBalanceBox, page.cexPrompt) + Doc.hide(page.profitSelectorBox) + cfg.cexName = '' + this.setGapFactorLabels(page.gapStrategySelect.value || '') + } + + /* + * selectCEX sets the specified CEX as selected, hiding basicmm-only settings + * and displaying settings specific to the arbmm. + */ + selectCEX (cexName: string) { + const { page, updatedConfig: cfg } = this + cfg.cexName = cexName + // Check if we already have the cex configured. + for (const cexCfg of (MM.cfg.cexConfigs || [])) { + if (cexCfg.name !== cexName) continue + for (const cex of Object.values(this.cexes)) { + cex.div.classList.toggle('selected', cex.name === cexName) + } + Doc.hide(page.gapStrategyBox, page.oraclesSettingBox, page.baseBalanceBox, page.quoteBalanceBox, page.cexPrompt) + Doc.show(page.profitSelectorBox) + this.setArbMMLabels() + return + } + const dinfo = CEXDisplayInfos[cexName] + page.cexConfigLogo.src = '/img/' + dinfo.logo + page.cexConfigName.textContent = dinfo.name + page.cexConfigForm.dataset.cexName = cexName + page.cexApiKeyInput.value = '' + page.cexSecretInput.value = '' + this.forms.show(page.cexConfigForm) + } + + /* + * supportingCEXes returns a lookup CEXes that have a matching market for the + * currently selected base and quote assets. + */ + supportingCEXes (): Record { + const cexes: Record = {} + const { baseID, quoteID } = this + for (const [cexName, mkts] of Object.entries(MM.mkts)) { + for (const { baseID: b, quoteID: q } of mkts) { + if (b === baseID && q === quoteID) { + cexes[cexName] = true + break + } + } + } + return cexes + } } const ExchangeNames: Record = { diff --git a/client/webserver/site/src/js/opts.ts b/client/webserver/site/src/js/opts.ts index 0c1a407a8a..02d7ab6214 100644 --- a/client/webserver/site/src/js/opts.ts +++ b/client/webserver/site/src/js/opts.ts @@ -182,7 +182,16 @@ export class XYRangeHandler { selected: () => void setConfig: (cfg: XYRange) => void - constructor (cfg: XYRange, initVal: number, updated: (x:number, y:number) => void, changed: () => void, selected: () => void, roundY?: boolean, roundX?: boolean, disabled?: boolean) { + constructor ( + cfg: XYRange, + initVal: number, + updated: (x:number, y:number) => void, + changed: () => void, + selected: () => void, + roundY?: boolean, + roundX?: boolean, + disabled?: boolean + ) { const control = this.control = rangeOptTmpl.cloneNode(true) as HTMLElement const tmpl = this.tmpl = Doc.parseTemplate(control) this.roundX = Boolean(roundX) @@ -290,6 +299,7 @@ export class XYRangeHandler { Doc.bind(handle, 'mousedown', (e: MouseEvent) => { if (e.button !== 0) return e.preventDefault() + e.stopPropagation() this.selected() const startX = e.pageX const w = slider.clientWidth - handle.offsetWidth @@ -311,6 +321,24 @@ export class XYRangeHandler { Doc.bind(document, 'mousemove', trackMouse) Doc.bind(document, 'mouseup', mouseUp) }) + + Doc.bind(tmpl.sliderBox, 'click', (e: MouseEvent) => { + if (e.button !== 0) return + const x = e.pageX + const m = Doc.layoutMetrics(tmpl.slider) + this.r = clamp((x - m.bodyLeft) / m.width, 0, 1) + this.scrollingX = this.r * rangeX + cfg.start.x + this.y = this.r * rangeY + cfg.start.y + this.accept(this.scrollingX) + }) + } + + setXLabel (s: string) { + this.tmpl.x.textContent = s + } + + setYLabel (s: string) { + this.tmpl.y.textContent = s } accept (x: number, skipUpdate?: boolean): void { @@ -323,14 +351,17 @@ export class XYRangeHandler { tmpl.handle.style.left = `calc(${this.r * 100}% - ${this.r * 14}px)` this.x = x this.scrollingX = x - if (!skipUpdate) this.updated(x, this.y) + if (!skipUpdate) { + this.updated(x, this.y) + this.changed() + } } - setValue (x: number) { + setValue (x: number, skipUpdate?: boolean) { const cfg = this.cfg this.r = (x - cfg.start.x) / (cfg.end.x - cfg.start.x) this.y = cfg.start.y + this.r * (cfg.end.y - cfg.start.y) - this.accept(x, true) + this.accept(x, skipUpdate) } } diff --git a/client/webserver/site/src/js/registry.ts b/client/webserver/site/src/js/registry.ts index b7f5df05b4..7bb007df7e 100644 --- a/client/webserver/site/src/js/registry.ts +++ b/client/webserver/site/src/js/registry.ts @@ -534,6 +534,7 @@ export interface PageElement extends HTMLElement { name?: string options?: HTMLOptionElement[] selectedIndex?: number + disabled?: boolean } export interface BooleanConfig { @@ -683,6 +684,22 @@ export interface BasicMarketMakingCfg { quoteOptions?: Record } +export interface ArbMarketMakingPlacement { + lots: number + multiplier: number +} + +export interface ArbMarketMakingCfg { + cexName: string + buyPlacements: ArbMarketMakingPlacement[] + sellPlacements: ArbMarketMakingPlacement[] + profit: number + driftTolerance: number + orderPersistence: number + baseOptions?: Record + quoteOptions?: Record +} + export enum BalanceType { Percentage, Amount @@ -690,13 +707,14 @@ export enum BalanceType { export interface BotConfig { host: string - baseAsset: number - quoteAsset: number + baseID: number + quoteID: number baseBalanceType: BalanceType baseBalance: number quoteBalanceType: BalanceType quoteBalance: number - basicMarketMakingConfig: BasicMarketMakingCfg + basicMarketMakingConfig?: BasicMarketMakingCfg + arbMarketMakingConfig?: ArbMarketMakingCfg disabled: boolean } @@ -722,6 +740,11 @@ export interface MarketMakingStatus { runningBots: MarketWithHost[] } +export interface CEXMarket { + baseID: number + quoteID: number +} + export interface OracleReport { host: string usdVol: number @@ -902,12 +925,6 @@ export interface Application { checkResponse (resp: APIResponse): boolean signOut (): Promise registerNoteFeeder (receivers: Record void>): void - getMarketMakingStatus (): Promise - stopMarketMaking (): Promise - getMarketMakingConfig (): Promise - updateMarketMakingConfig (cfg: BotConfig): Promise - removeMarketMakingConfig (cfg: BotConfig): Promise - setMarketMakingEnabled (host: string, baseAsset: number, quoteAsset: number, enabled: boolean): void txHistory(assetID: number, n: number, after?: string): Promise getWalletTx(assetID: number, txid: string): WalletTransaction | undefined clearTxHistory(assetID: number): void diff --git a/client/webserver/webserver.go b/client/webserver/webserver.go index fe8421a70a..8e7136bc84 100644 --- a/client/webserver/webserver.go +++ b/client/webserver/webserver.go @@ -31,6 +31,7 @@ import ( "decred.org/dcrdex/client/core" "decred.org/dcrdex/client/db" "decred.org/dcrdex/client/mm" + "decred.org/dcrdex/client/mm/libxc" "decred.org/dcrdex/client/webserver/locales" "decred.org/dcrdex/client/websocket" "decred.org/dcrdex/dex" @@ -175,6 +176,18 @@ type clientCore interface { DisableFundsMixer(assetID uint32) error } +type mmCore interface { + MarketReport(base, quote uint32) (*mm.MarketReport, error) + Start(pw []byte, alternateConfigPath *string) (err error) + Stop() + GetMarketMakingConfig() (*mm.MarketMakingConfig, map[string][]*libxc.Market, error) + UpdateCEXConfig(updatedCfg *mm.CEXConfig) (*mm.MarketMakingConfig, []*libxc.Market, error) + UpdateBotConfig(updatedCfg *mm.BotConfig) (*mm.MarketMakingConfig, error) + RemoveBotConfig(host string, baseID, quoteID uint32) (*mm.MarketMakingConfig, error) + Running() bool + RunningBots() []mm.MarketWithHost +} + // genCertPair generates a key/cert pair to the paths provided. func genCertPair(certFile, keyFile string, altDNSNames []string) error { log.Infof("Generating TLS certificates...") @@ -210,8 +223,8 @@ type cachedPassword struct { } type Config struct { - Core clientCore - MarketMaker *mm.MarketMaker + Core clientCore // *core.Core + MarketMaker mmCore // *mm.MarketMaker MMCfgPath string Addr string CustomSiteDir string @@ -243,7 +256,7 @@ type WebServer struct { wsServer *websocket.Server mux *chi.Mux core clientCore - mm *mm.MarketMaker + mm mmCore mmCfgPath string addr string csp string @@ -553,7 +566,8 @@ func New(cfg *Config) (*WebServer, error) { apiAuth.Post("/startmarketmaking", s.apiStartMarketMaking) apiAuth.Post("/stopmarketmaking", s.apiStopMarketMaking) apiAuth.Get("/marketmakingconfig", s.apiMarketMakingConfig) - apiAuth.Post("/updatemarketmakingconfig", s.apiUpdateMarketMakingConfig) + apiAuth.Post("/updatebotconfig", s.apiUpdateBotConfig) + apiAuth.Post("/updatecexconfig", s.apiUpdateCEXConfig) apiAuth.Post("/removemarketmakingconfig", s.apiRemoveMarketMakingConfig) apiAuth.Get("/marketmakingstatus", s.apiMarketMakingStatus) apiAuth.Post("/marketreport", s.apiMarketReport) From 049a27c28c97bde1bfa61eea4ae2141902dd8eb0 Mon Sep 17 00:00:00 2001 From: Brian Stafford Date: Sun, 26 Nov 2023 06:33:33 -0600 Subject: [PATCH 2/4] implement simple arb. move market selection to mmsettings Adapt to new configuration style and new rebalance settings. --- client/asset/btc/spv_wrapper.go | 6 +- client/mm/libxc/binance.go | 50 - client/mm/mm.go | 109 +- client/mm/mm_test.go | 17 +- client/webserver/api.go | 22 + client/webserver/live_test.go | 8 + client/webserver/locales/en-us.go | 22 +- client/webserver/site/src/css/main.scss | 11 + client/webserver/site/src/css/mm.scss | 131 +- client/webserver/site/src/css/mmsettings.scss | 95 +- client/webserver/site/src/html/mm.tmpl | 40 - .../webserver/site/src/html/mmsettings.tmpl | 431 +++++-- client/webserver/site/src/js/doc.ts | 4 +- client/webserver/site/src/js/locales.ts | 10 +- client/webserver/site/src/js/markets.ts | 9 +- client/webserver/site/src/js/mm.ts | 347 +----- client/webserver/site/src/js/mmsettings.ts | 1094 +++++++++++++---- client/webserver/site/src/js/opts.ts | 2 +- client/webserver/site/src/js/registry.ts | 41 +- client/webserver/webserver.go | 2 + 20 files changed, 1523 insertions(+), 928 deletions(-) diff --git a/client/asset/btc/spv_wrapper.go b/client/asset/btc/spv_wrapper.go index 385d454140..3f259b3943 100644 --- a/client/asset/btc/spv_wrapper.go +++ b/client/asset/btc/spv_wrapper.go @@ -395,8 +395,10 @@ func (w *spvWallet) sendRawTransaction(tx *wire.MsgTx) (*chainhash.Hash, error) res <- err return } - defer w.log.Tracef("PublishTransaction(%v) completed in %v", tx.TxHash(), - time.Since(tStart)) // after outpoint unlocking and signalling + defer func() { + w.log.Tracef("PublishTransaction(%v) completed in %v", tx.TxHash(), + time.Since(tStart)) // after outpoint unlocking and signalling + }() res <- nil }() diff --git a/client/mm/libxc/binance.go b/client/mm/libxc/binance.go index f4a626fa12..e694a72695 100644 --- a/client/mm/libxc/binance.go +++ b/client/mm/libxc/binance.go @@ -1612,56 +1612,6 @@ func (bnc *binance) VWAP(baseID, quoteID uint32, sell bool, qty uint64) (avgPric return book.vwap(!sell, qty) } -// type binanceNetworkInfo struct { -// AddressRegex string `json:"addressRegex"` -// Coin string `json:"coin"` -// DepositEnable bool `json:"depositEnable"` -// IsDefault bool `json:"isDefault"` -// MemoRegex string `json:"memoRegex"` -// MinConfirm int `json:"minConfirm"` -// Name string `json:"name"` -// Network string `json:"network"` -// ResetAddressStatus bool `json:"resetAddressStatus"` -// SpecialTips string `json:"specialTips"` -// UnLockConfirm int `json:"unLockConfirm"` -// WithdrawEnable bool `json:"withdrawEnable"` -// WithdrawFee float64 `json:"withdrawFee,string"` -// WithdrawIntegerMultiple float64 `json:"withdrawIntegerMultiple,string"` -// WithdrawMax float64 `json:"withdrawMax,string"` -// WithdrawMin float64 `json:"withdrawMin,string"` -// SameAddress bool `json:"sameAddress"` -// EstimatedArrivalTime int `json:"estimatedArrivalTime"` -// Busy bool `json:"busy"` -// } - -// type binanceCoinInfo struct { -// Coin string `json:"coin"` -// DepositAllEnable bool `json:"depositAllEnable"` -// Free float64 `json:"free,string"` -// Freeze float64 `json:"freeze,string"` -// Ipoable float64 `json:"ipoable,string"` -// Ipoing float64 `json:"ipoing,string"` -// IsLegalMoney bool `json:"isLegalMoney"` -// Locked float64 `json:"locked,string"` -// Name string `json:"name"` -// Storage float64 `json:"storage,string"` -// Trading bool `json:"trading"` -// WithdrawAllEnable bool `json:"withdrawAllEnable"` -// Withdrawing float64 `json:"withdrawing,string"` -// NetworkList []*binanceNetworkInfo `json:"networkList"` -// } - -// type bnMarket struct { -// Symbol string `json:"symbol"` -// Status string `json:"status"` -// BaseAsset string `json:"baseAsset"` -// BaseAssetPrecision int `json:"baseAssetPrecision"` -// QuoteAsset string `json:"quoteAsset"` -// QuoteAssetPrecision int `json:"quoteAssetPrecision"` -// OrderTypes []string `json:"orderTypes"` -// Permissions []string `json:"permissions"` -// } - // dexMarkets returns all the possible dex markets for this binance market. // A symbol represents a single market on the CEX, but tokens on the DEX // have a different assetID for each network they are on, therefore they will diff --git a/client/mm/mm.go b/client/mm/mm.go index 6f76362206..dff150d4ba 100644 --- a/client/mm/mm.go +++ b/client/mm/mm.go @@ -170,6 +170,7 @@ func (m *MarketWithHost) String() string { return fmt.Sprintf("%s-%d-%d", m.Host, m.BaseID, m.QuoteID) } +// centralizedExchange is used to manage an exchange API connection. type centralizedExchange struct { libxc.CEX *CEXConfig @@ -237,8 +238,7 @@ func NewMarketMaker(c clientCore, cfgPath string, log dex.Logger) (*MarketMaker, unsyncedOracle: newUnsyncedPriceOracle(log), cexes: make(map[string]*centralizedExchange), } - var dummyCancel context.CancelFunc = func() {} - m.die.Store(dummyCancel) + m.die.Store(context.CancelFunc(func() {})) return m, nil } @@ -316,6 +316,14 @@ func (m *MarketMaker) markBotAsRunning(mkt MarketWithHost, running bool) { } } +func (m *MarketMaker) CEXBalance(cexName string, assetID uint32) (*libxc.ExchangeBalance, error) { + cex, err := m.connectedCEX(cexName) + if err != nil { + return nil, fmt.Errorf("error getting connected CEX: %w", err) + } + return cex.Balance(assetID) +} + // MarketReport returns information about the oracle rates on a market // pair and the fiat rates of the base and quote assets. func (m *MarketMaker) MarketReport(base, quote uint32) (*MarketReport, error) { @@ -995,58 +1003,60 @@ func (m *MarketMaker) handleNotification(n core.Notification) { } } -func (m *MarketMaker) initCEXConnections(cfgs []*CEXConfig) (map[string]libxc.CEX, map[string]*dex.ConnectionMaster) { - cexes := make(map[string]libxc.CEX) - cexCMs := make(map[string]*dex.ConnectionMaster) - - findCEXConfig := func(cexName string) *CEXConfig { - for _, cfg := range cfgs { - if cfg.Name == cexName { - return cfg - } +// loadAndConnectCEX initializes the centralizedExchange if required, and +// connects if not already connected. +func (m *MarketMaker) loadAndConnectCEX(cfg *CEXConfig) (*centralizedExchange, *dex.ConnectionMaster, error) { + c, err := m.loadCEX(m.ctx, cfg) + if err != nil { + return nil, nil, fmt.Errorf("error loading CEX: %w", err) + } + var cm *dex.ConnectionMaster + c.mtx.Lock() + if c.cm == nil || !c.cm.On() { + cm = dex.NewConnectionMaster(c) + c.cm = cm + } else { + cm = c.cm + } + c.mtx.Unlock() + if !cm.On() { + if err = cm.Connect(m.ctx); err != nil { + return nil, nil, fmt.Errorf("failed to connect to CEX: %v", err) } - return nil } + return c, cm, nil +} - getConnectedCEX := func(cexName string) (cex libxc.CEX, err error) { - var found bool - if cex, found = cexes[cexName]; !found { - cexCfg := findCEXConfig(cexName) - if cexCfg == nil { - return nil, fmt.Errorf("no CEX config provided for %s", cexName) - } - c, err := m.loadCEX(m.ctx, cexCfg) - if err != nil { - return nil, fmt.Errorf("error loading CEX: %w", err) - } - var cm *dex.ConnectionMaster - c.mtx.Lock() - if c.cm == nil || !c.cm.On() { - cm = dex.NewConnectionMaster(c) - c.cm = cm - cexCMs[cexName] = cm - } - c.mtx.Unlock() - if cm != nil { - if err = cm.Connect(m.ctx); err != nil { - return nil, fmt.Errorf("failed to connect to CEX: %v", err) - } - } - cex = c.CEX - cexes[cexName] = cex - } - return cex, nil +// connectedCEX returns the connected centralizedExchange, initializing and +// connecting if required. +func (m *MarketMaker) connectedCEX(cexName string) (*centralizedExchange, error) { + cfg := m.cexConfig(cexName) + if cfg == nil { + return nil, fmt.Errorf("CEX %q not known", cexName) } + cex, _, err := m.loadAndConnectCEX(cfg) + return cex, err +} + +// initCEXConnections initializes and connects to the specified cexes. +func (m *MarketMaker) initCEXConnections(cfgs []*CEXConfig) (map[string]libxc.CEX, map[string]*dex.ConnectionMaster) { + cexes := make(map[string]libxc.CEX) + cexCMs := make(map[string]*dex.ConnectionMaster) for _, cfg := range cfgs { - if _, err := getConnectedCEX(cfg.Name); err != nil { + c, cm, err := m.loadAndConnectCEX(cfg) + if err != nil { m.log.Errorf("Failed to create %s: %v", cfg.Name, err) + continue } + cexes[c.Name] = c.CEX + cexCMs[c.Name] = cm } return cexes, cexCMs } +// loadCEX initializes the cex if required and returns the centralizedExchange.“ func (m *MarketMaker) loadCEX(ctx context.Context, cfg *CEXConfig) (*centralizedExchange, error) { m.cexMtx.Lock() defer m.cexMtx.Unlock() @@ -1090,6 +1100,17 @@ func (m *MarketMaker) config() *MarketMakingConfig { return m.cfg.Copy() } +func (m *MarketMaker) cexConfig(cexName string) *CEXConfig { + m.cfgMtx.RLock() + defer m.cfgMtx.RUnlock() + for _, cfg := range m.cfg.CexConfigs { + if cfg.Name == cexName { + return cfg + } + } + return nil +} + func (m *MarketMaker) Connect(ctx context.Context) (*sync.WaitGroup, error) { m.ctx = ctx cfg := m.config() @@ -1356,7 +1377,7 @@ func (m *MarketMaker) UpdateBotConfig(updatedCfg *BotConfig) (*MarketMakingConfi func (m *MarketMaker) UpdateCEXConfig(updatedCfg *CEXConfig) (*MarketMakingConfig, []*libxc.Market, error) { cfg := m.config() - cex, err := m.loadCEX(m.ctx, updatedCfg) + cex, _, err := m.loadAndConnectCEX(updatedCfg) if err != nil { return nil, nil, fmt.Errorf("error loading %s with updated config: %w", updatedCfg.Name, err) } @@ -1368,13 +1389,17 @@ func (m *MarketMaker) UpdateCEXConfig(updatedCfg *CEXConfig) (*MarketMakingConfi var updated bool for i, c := range cfg.CexConfigs { if c.Name == updatedCfg.Name { + m.cfgMtx.Lock() cfg.CexConfigs[i] = updatedCfg + m.cfgMtx.Unlock() updated = true break } } if !updated { + m.cfgMtx.Lock() cfg.CexConfigs = append(cfg.CexConfigs, updatedCfg) + m.cfgMtx.Unlock() } return cfg, mkts, m.writeConfigFile(cfg) diff --git a/client/mm/mm_test.go b/client/mm/mm_test.go index eb4ded1d06..50a6fdcd1b 100644 --- a/client/mm/mm_test.go +++ b/client/mm/mm_test.go @@ -5364,29 +5364,32 @@ func TestSegregatedCEXWithdraw(t *testing.T) { mm.setupBalances([]*BotConfig{tt.cfg}, map[string]libxc.CEX{cexName: cex}) mktID := dexMarketID(tt.cfg.Host, tt.cfg.BaseID, tt.cfg.QuoteID) wrappedCEX := mm.wrappedCEXForBot(mktID, cex) - // mm.doNotKillWhenBotsStop = true ctx, cancel := context.WithCancel(context.Background()) defer cancel() + _, err := mm.Connect(ctx) + if err != nil { + t.Fatalf("%s: Connect error: %v", tt.name, err) + } + cexCM := dex.NewConnectionMaster(cex) if err := cexCM.Connect(ctx); err != nil { t.Fatalf("error connecting tCEX: %v", err) } + + mm.UpdateBotConfig(tt.cfg) cexConfig := &CEXConfig{ Name: libxc.Binance, } - mm.UpdateBotConfig(tt.cfg) - mm.UpdateCEXConfig(cexConfig) mm.cexes[libxc.Binance] = ¢ralizedExchange{ CEX: cex, CEXConfig: cexConfig, cm: cexCM, mkts: []*libxc.Market{}, } - _, err := mm.Connect(ctx) - if err != nil { - t.Fatalf("%s: Connect error: %v", tt.name, err) - } + + mm.cfg.CexConfigs = append(mm.cfg.CexConfigs, cexConfig) + if err := mm.Start([]byte{}, nil); err != nil { t.Fatalf("%s: Start error: %v", tt.name, err) } diff --git a/client/webserver/api.go b/client/webserver/api.go index e28f176d8a..8b5cfe509c 100644 --- a/client/webserver/api.go +++ b/client/webserver/api.go @@ -1592,6 +1592,28 @@ func (s *WebServer) apiMarketReport(w http.ResponseWriter, r *http.Request) { }, s.indent) } +func (s *WebServer) apiCEXBalance(w http.ResponseWriter, r *http.Request) { + var req struct { + CEXName string `json:"cexName"` + AssetID uint32 `json:"assetID"` + } + if !readPost(w, r, &req) { + return + } + bal, err := s.mm.CEXBalance(req.CEXName, req.AssetID) + if err != nil { + s.writeAPIError(w, fmt.Errorf("error getting cex balance: %w", err)) + return + } + writeJSON(w, &struct { + OK bool `json:"ok"` + CEXBalance *libxc.ExchangeBalance `json:"cexBalance"` + }{ + OK: true, + CEXBalance: bal, + }, s.indent) +} + func (s *WebServer) apiShieldedStatus(w http.ResponseWriter, r *http.Request) { var assetID uint32 if !readPost(w, r, &assetID) { diff --git a/client/webserver/live_test.go b/client/webserver/live_test.go index 73f2cf9ec5..8cec498453 100644 --- a/client/webserver/live_test.go +++ b/client/webserver/live_test.go @@ -2179,6 +2179,14 @@ func (m *TMarketMaker) RemoveBotConfig(host string, baseID, quoteID uint32) (*mm return m.cfg, nil } +func (m *TMarketMaker) CEXBalance(cexName string, assetID uint32) (*libxc.ExchangeBalance, error) { + bal := randomBalance(assetID) + return &libxc.ExchangeBalance{ + Available: bal.Available, + Locked: bal.Locked, + }, nil +} + func (m *TMarketMaker) Running() bool { return m.running.Load() } diff --git a/client/webserver/locales/en-us.go b/client/webserver/locales/en-us.go index 7d05e914a4..5ee4761059 100644 --- a/client/webserver/locales/en-us.go +++ b/client/webserver/locales/en-us.go @@ -412,7 +412,6 @@ var EnUS = map[string]string{ "Skip this step for now": "Skip this step for now", "save_seed_instructions": "Save the following seed somewhere safe. Do not share this with anybody.", "Done": "Done", - "add_new_bot_market": "Add a bot for market", "gap_strategy_multiplier": "Multiplier", "gap_strategy_absolute": "Absolute", "gap_strategy_absolute_plus": "Absolute Plus", @@ -427,7 +426,14 @@ var EnUS = map[string]string{ "bot_type": "Bot Type", "base_balance": "Base Balance", "quote_balance": "Quote Balance", - "add_new_bot": "Add New Bot", + "enable_rebalance_tooltip": "Transfer funds between wallet and CEX as needed and without prompt", + "cex_alloc_tooltip": "The amount of your balance to initially allocate to this bot on the exchange", + "arb_minbal_tooltip": "The minimum balance allowed before a deposit or withdraw is initiated", + "arb_transfer_tooltip": "The mimimum amount that can be transferred", + "Select a Market": "Select a Market", + "Arbitrage Rebalance": "Arbitrage Rebalance", + "Minimum Balance": "Minimum Balance", + "Minimum Transfer": "Minimum Transfer", "update_settings": "Update Settings", "create_bot": "Create Bot", "reset_settings": "Reset Settings", @@ -444,15 +450,19 @@ var EnUS = map[string]string{ "oracle_bias_tooltip": "Adjust the oracle rates by this amount before using them to determine the order placement rates.", "empty_market_rate": "Empty Market Rate", "empty_market_rate_tooltip": "Optionally provide a rate to use if the DEX market is empty and no oracles are available.", - "base_asset_balance": "Base Asset Balance", "base_asset_balance_tooltip": "The amount of the base asset to allocate for this bot. If the upper end of this range is < 100%, it means that the rest of the balance was allocated to other bots.", "quote_asset_balance_tooltip": "The amount of the quote asset to allocate for this bot. If the upper end of this range is < 100%, it means that the rest of the balance was allocated to other bots.", "drift_tolerance_tooltip": "How far from the ideal price orders are allowed to drift before they are cancelled and re-booked.", "order_persistence_tooltip": "How long CEX orders that don't immediately match be are allowed to remain booked", "no_balance_available": "No balance available", - "quote_asset_balance": "Quote Asset Balance", - "base_wallet_settings": "Base Wallet Settings", - "quote_wallet_settings": "Quote Wallet Settings", + "dex_base_asset_balance": `DEX Allocation`, + "cex_base_asset_balance": `CEX Allocation`, + "dex_quote_asset_balance": `DEX Allocation`, + "cex_quote_asset_balance": `CEX Allocation`, + "base_wallet_settings": ` Wallet Settings`, + "quote_wallet_settings": ` Wallet Settings`, + "cex_base_rebalance": ` Rebalance Settings`, + "cex_quote_rebalance": ` Rebalance Settings`, "Oracles": "Oracles", "loading_oracles": "Loading oracles...", "no_oracles": "There are no oracles available for this market", diff --git a/client/webserver/site/src/css/main.scss b/client/webserver/site/src/css/main.scss index 7fc8b79e58..4efbc8ec6f 100644 --- a/client/webserver/site/src/css/main.scss +++ b/client/webserver/site/src/css/main.scss @@ -116,6 +116,7 @@ div.clear { div.feature, button.feature { + cursor: pointer; background-color: #6bc5ff; &:hover { @@ -123,6 +124,16 @@ button.feature { } } +button.selected { + background-color: #38cfb0; + border: none; +} + + &:hover { + background-color: #a3dbff; + } +} + .dynamicopts { display: flex; align-items: stretch; diff --git a/client/webserver/site/src/css/mm.scss b/client/webserver/site/src/css/mm.scss index 03bcdde809..1161f006cb 100644 --- a/client/webserver/site/src/css/mm.scss +++ b/client/webserver/site/src/css/mm.scss @@ -1,90 +1,4 @@ -#main.mm { - .asset-select { - .asset-row .ico-arrowdown { - display: inline; - } - } - - .order-opt { - margin-top: 10px; - } - - .asset-row { - img { - width: 30px; - height: 30px; - } - - &.ghost { - opacity: 0.4; - } - - &.ghost:hover { - opacity: 1; - } - - &:hover .ico-arrowdown { - color: inherit; - } - - .ico-arrowdown { - @extend .grey; - - display: none; - } - } - - .asset-logo { - width: 15px; - height: 15px; - } - - #botCreator { - width: 350px; - - ul { - max-width: 350px; - } - - #lotBullets { - border: 2px solid #7777; - } - } - - #assetDropdown { - @extend .stylish-overflow; - - background-color: $light_body_bg; - z-index: 99; - max-height: 350px; - } - - #marketOneChoice { - max-width: 90%; - text-overflow: ellipsis; - } - - #marketSelect { - max-width: 90%; - background-color: transparent; - - option { - text-overflow: ellipsis; - background-color: $light_body_bg; - } - } - - #lotsInput { - width: 100px; - font-size: 22px; - padding: 10px 20px; - } - - #absInput { - font-size: 18px; - padding: 7px 14px; - } - +div[data-handler=mm] { #runningPrograms { .running-program { border: 2px solid #7777; @@ -125,49 +39,6 @@ } } - #createBox, - #programsBox { - .edit-show { - display: none; - } - - .edit-hide { - display: block; - } - - .edit-hide-flex { - display: flex; - } - - .orange { - color: orange; - } - - &.edit { - .edit-show { - display: block; - } - - .edit-hide, - .edit-hide-flex { - display: none !important; - } - - #botCreator { - border: 1px solid orange; - } - - .running-program { - outline: solid 2px orange; - } - } - } - - #startErr, - #createErr { - max-width: 300px; - } - #botTable { td, th { diff --git a/client/webserver/site/src/css/mmsettings.scss b/client/webserver/site/src/css/mmsettings.scss index 44fee9737a..fd4d62cb61 100644 --- a/client/webserver/site/src/css/mmsettings.scss +++ b/client/webserver/site/src/css/mmsettings.scss @@ -1,5 +1,5 @@ -#main.mmsettings { +div[data-handler=mmsettings] { .placement-table { width: 100%; @@ -95,19 +95,94 @@ } } - .cex-selector { - &:not(.selected) { - opacity: 0.8; - transform: scale(0.95); + #profitInput { + width: 70px; + } + + .bot-type-selector { + @extend .brdr; + @extend .rounded3; + + display: flex; + flex-direction: column; + align-items: stretch; + user-select: none; + + &.disabled { + opacity: 0.5; } - &.selected { - border-color: green; - border-width: 2px; + &:not(.disabled) { + @extend .hoverbg; + + cursor: pointer; + + &.selected { + border: 2px solid green; + background-color: #7772; + } } + + } - #profitInput { - width: 70px; + table#marketSelectionTable { + td, + th { + padding: 10px 10px 10px 0; + border-bottom: 1px solid $dark-border-color; + } + } + + #marketFilterIcon { + position: absolute; + left: 5px; + top: 50%; + transform: translateY(-50%); + opacity: 0.5; + } + + #botTypeForm { + min-width: 375px; + } + + #cexSelection { + .cex-selector { + user-select: none; + + &.configured:not(.selected) { + opacity: 0.8; + } + } + + &.disabled { + .cex-selector.configured { + opacity: 0.5; + } + + .cex-selector:not(.configured) { + cursor: pointer; + } + } + + &:not(.disabled) { + .cex-selector { + @extend .hoverbg; + + cursor: pointer; + + &.selected { + border-color: green; + border-width: 2px; + background-color: #7772; + } + } + } } } + +body.dark div[data-handler=mmsettings] { + .bot-type-selector:not(.disabled).selected { + border: 2px solid green; + } +} \ No newline at end of file diff --git a/client/webserver/site/src/html/mm.tmpl b/client/webserver/site/src/html/mm.tmpl index 7ef509141e..627ffe9303 100644 --- a/client/webserver/site/src/html/mm.tmpl +++ b/client/webserver/site/src/html/mm.tmpl @@ -112,46 +112,6 @@ - - {{- /* NEW WALLET */ -}} -
- {{template "newWalletForm"}} - - - {{- /* ADD BOT FORM */ -}} -
- - {{- /* ABSOLUTELY-POSITIONED CUSTOM ASSET SELECT */ -}} -
- -
-
- [[[add_new_bot_market]]] -
- -
- - -
-
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
- -
- {{- /* END FORMS */ -}} {{template "bottom"}} diff --git a/client/webserver/site/src/html/mmsettings.tmpl b/client/webserver/site/src/html/mmsettings.tmpl index 03c310b7a8..1f8c35d709 100644 --- a/client/webserver/site/src/html/mmsettings.tmpl +++ b/client/webserver/site/src/html/mmsettings.tmpl @@ -7,40 +7,29 @@
[[[Market Maker Settings]]]
-
+
+
+
using
+ on @ +
- +
+ +
{{- /* VIEW-ONLY MODE */ -}}
[[[bots_running_view_only]]]
- {{- /* BOT TYPE CONFIGURATION */ -}} -
-
-
[[[select_a_cex_prompt]]]
-
-
-
- - -
- [[[Configure]]] - [[[Market not available]]] -
-
-
-
{{- /* STRATEGY SELECTION */ -}}
-
[[[gap_strategy]]]
- - - - - - - - - - - - - - - - - - - - - - - -
[[[priority]]][[[Lots]]]
- - - - - -
- -
- +
+
+ [[[buy_placements]]] + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
[[[priority]]][[[Lots]]]
+ + + + + +
+ +
+ +
-
- [[[sell_placements]]] - -
-
- - - - - - - - - - - - - - - - - - - -
[[[priority]]][[[Lots]]]
- -
- +
+
+ [[[sell_placements]]] + +
+
+ + + + + + + + + + + + + + + + + + + +
[[[priority]]][[[Lots]]]
+ +
+ +
+ +
+ +
+
-
- [[[Drift tolerance]]] +
+
+ [[[Drift tolerance]]] +
-
-
- [[[Order persistence]]] +
+ [[[Order persistence]]]
-