decred.org/dcrdex@v1.0.5/client/webserver/site/src/js/mm.ts (about)

     1  import {
     2    app,
     3    PageElement,
     4    MMBotStatus,
     5    RunStatsNote,
     6    RunEventNote,
     7    StartConfig,
     8    OrderPlacement,
     9    AutoRebalanceConfig,
    10    CEXNotification,
    11    EpochReportNote,
    12    CEXProblemsNote,
    13    MarketWithHost
    14  } from './registry'
    15  import {
    16    MM,
    17    CEXDisplayInfo,
    18    CEXDisplayInfos,
    19    botTypeBasicArb,
    20    botTypeArbMM,
    21    botTypeBasicMM,
    22    setMarketElements,
    23    setCexElements,
    24    PlacementsChart,
    25    BotMarket,
    26    hostedMarketID,
    27    RunningMarketMakerDisplay,
    28    RunningMMDisplayElements
    29  } from './mmutil'
    30  import Doc, { MiniSlider } from './doc'
    31  import BasePage from './basepage'
    32  import * as OrderUtil from './orderutil'
    33  import { Forms, CEXConfigurationForm } from './forms'
    34  import * as intl from './locales'
    35  import { StatusBooked } from './orderutil'
    36  const mediumBreakpoint = 768
    37  
    38  interface FundingSlider {
    39    left: {
    40      cex: number
    41      dex: number
    42    }
    43    right: {
    44      cex: number
    45      dex: number
    46    }
    47    cexRange: number
    48    dexRange: number
    49  }
    50  
    51  const newSlider = () => {
    52    return {
    53      left: {
    54        cex: 0,
    55        dex: 0
    56      },
    57      right: {
    58        cex: 0,
    59        dex: 0
    60      },
    61      cexRange: 0,
    62      dexRange: 0
    63    }
    64  }
    65  
    66  interface FundingSource {
    67    avail: number
    68    req: number
    69    funded: boolean
    70  }
    71  
    72  interface FundingOutlook {
    73    dex: FundingSource
    74    cex: FundingSource
    75    transferable: number
    76    fees: {
    77      avail: number
    78      req: number
    79      funded: boolean
    80    },
    81    fundedAndBalanced: boolean
    82    fundedAndNotBalanced: boolean
    83  }
    84  
    85  function parseFundingOptions (f: FundingOutlook): [number, number, FundingSlider | undefined] {
    86    const { cex: { avail: cexAvail, req: cexReq }, dex: { avail: dexAvail, req: dexReq }, transferable } = f
    87  
    88    let proposedDex = Math.min(dexAvail, dexReq)
    89    let proposedCex = Math.min(cexAvail, cexReq)
    90    let slider: FundingSlider | undefined
    91    if (f.fundedAndNotBalanced) {
    92      // We have everything we need, but not where we need it, and we can
    93      // deposit and withdraw.
    94      if (dexAvail > dexReq) {
    95        // We have too much dex-side, so we'll have to draw on dex to balance
    96        // cex's shortcomings.
    97        const cexShort = cexReq - cexAvail
    98        const dexRemain = dexAvail - dexReq
    99        if (dexRemain < cexShort) {
   100          // We did something really bad with math to get here.
   101          throw Error('bad math has us with dex surplus + cex underfund invalid remains')
   102        }
   103        proposedDex += cexShort + transferable
   104      } else {
   105        // We don't have enough on dex, but we have enough on cex to cover the
   106        // short.
   107        const dexShort = dexReq - dexAvail
   108        const cexRemain = cexAvail - cexReq
   109        if (cexRemain < dexShort) {
   110          throw Error('bad math got us with cex surplus + dex underfund invalid remains')
   111        }
   112        proposedCex += dexShort + transferable
   113      }
   114    } else if (f.fundedAndBalanced) {
   115      // This asset is fully funded, but the user may choose to fund order
   116      // reserves either cex or dex.
   117      if (transferable > 0) {
   118        const dexRemain = dexAvail - dexReq
   119        const cexRemain = cexAvail - cexReq
   120  
   121        slider = newSlider()
   122  
   123        if (cexRemain > transferable && dexRemain > transferable) {
   124          // Either one could fully fund order reserves. Let the user choose.
   125          slider.left.cex = transferable + cexReq
   126          slider.left.dex = dexReq
   127          slider.right.cex = cexReq
   128          slider.right.dex = transferable + dexReq
   129        } else if (dexRemain < transferable && cexRemain < transferable) {
   130          // => implied that cexRemain + dexRemain > transferable.
   131          // CEX can contribute SOME and DEX can contribute SOME.
   132          slider.left.cex = transferable - dexRemain + cexReq
   133          slider.left.dex = dexRemain + dexReq
   134          slider.right.cex = cexRemain + cexReq
   135          slider.right.dex = transferable - cexRemain + dexReq
   136        } else if (dexRemain > transferable) {
   137          // So DEX has enough to cover reserves, but CEX could potentially
   138          // constribute SOME. NOT ALL.
   139          slider.left.cex = cexReq
   140          slider.left.dex = transferable + dexReq
   141          slider.right.cex = cexRemain + cexReq
   142          slider.right.dex = transferable - cexRemain + dexReq
   143        } else {
   144          // CEX has enough to cover reserves, but DEX could contribute SOME,
   145          // NOT ALL.
   146          slider.left.cex = transferable - dexRemain + cexReq
   147          slider.left.dex = dexRemain + dexReq
   148          slider.right.cex = transferable + cexReq
   149          slider.right.dex = dexReq
   150        }
   151        // We prefer the slider right in the center.
   152        slider.cexRange = slider.right.cex - slider.left.cex
   153        slider.dexRange = slider.right.dex - slider.left.dex
   154        proposedDex = slider.left.dex + (slider.dexRange / 2)
   155        proposedCex = slider.left.cex + (slider.cexRange / 2)
   156      }
   157    } else { // starved
   158      if (cexAvail < cexReq) {
   159        proposedDex = Math.min(dexAvail, dexReq + transferable + (cexReq - cexAvail))
   160      } else if (dexAvail < dexReq) {
   161        proposedCex = Math.min(cexAvail, cexReq + transferable + (dexReq - dexAvail))
   162      } else { // just transferable wasn't covered
   163        proposedDex = Math.min(dexAvail, dexReq + transferable)
   164        proposedCex = Math.min(cexAvail, dexReq + cexReq + transferable - proposedDex)
   165      }
   166    }
   167    return [proposedDex, proposedCex, slider]
   168  }
   169  
   170  interface CEXRow {
   171    cexName: string
   172    tr: PageElement
   173    tmpl: Record<string, PageElement>
   174    dinfo: CEXDisplayInfo
   175  }
   176  
   177  export default class MarketMakerPage extends BasePage {
   178    page: Record<string, PageElement>
   179    forms: Forms
   180    currentForm: HTMLElement
   181    keyup: (e: KeyboardEvent) => void
   182    cexConfigForm: CEXConfigurationForm
   183    bots: Record<string, Bot>
   184    sortedBots: Bot[]
   185    cexes: Record<string, CEXRow>
   186    twoColumn: boolean
   187    runningMMDisplayElements: RunningMMDisplayElements
   188    removingCfg: MarketWithHost | undefined
   189  
   190    constructor (main: HTMLElement) {
   191      super()
   192  
   193      this.bots = {}
   194      this.sortedBots = []
   195      this.cexes = {}
   196  
   197      const page = this.page = Doc.idDescendants(main)
   198  
   199      Doc.cleanTemplates(page.botTmpl, page.botRowTmpl, page.exchangeRowTmpl)
   200  
   201      this.forms = new Forms(page.forms)
   202      this.cexConfigForm = new CEXConfigurationForm(page.cexConfigForm, (cexName: string, success: boolean) => this.cexConfigured(cexName, success))
   203      this.runningMMDisplayElements = {
   204        orderReportForm: page.orderReportForm,
   205        dexBalancesRowTmpl: page.dexBalancesRowTmpl,
   206        placementRowTmpl: page.placementRowTmpl,
   207        placementAmtRowTmpl: page.placementAmtRowTmpl
   208      }
   209      Doc.cleanTemplates(page.dexBalancesRowTmpl, page.placementRowTmpl, page.placementAmtRowTmpl)
   210  
   211      Doc.bind(page.newBot, 'click', () => { this.newBot() })
   212      Doc.bind(page.archivedLogsBtn, 'click', () => { app().loadPage('mmarchives') })
   213      Doc.bind(page.confirmRemoveConfigBttn, 'click', () => { this.removeCfg() })
   214  
   215      this.twoColumn = window.innerWidth >= mediumBreakpoint
   216      const ro = new ResizeObserver(() => { this.resized() })
   217      ro.observe(main)
   218  
   219      for (const [cexName, dinfo] of Object.entries(CEXDisplayInfos)) {
   220        const tr = page.exchangeRowTmpl.cloneNode(true) as PageElement
   221        page.cexRows.appendChild(tr)
   222        const tmpl = Doc.parseTemplate(tr)
   223        const configure = () => {
   224          this.cexConfigForm.setCEX(cexName)
   225          this.forms.show(page.cexConfigForm)
   226        }
   227        Doc.bind(tmpl.configureBttn, 'click', configure)
   228        Doc.bind(tmpl.reconfigBttn, 'click', configure)
   229        Doc.bind(tmpl.errConfigureBttn, 'click', configure)
   230        const row = this.cexes[cexName] = { tr, tmpl, dinfo, cexName }
   231        this.updateCexRow(row)
   232      }
   233  
   234      this.setup()
   235    }
   236  
   237    resized () {
   238      const useTwoColumn = window.innerWidth >= 768
   239      if (useTwoColumn !== this.twoColumn) {
   240        this.twoColumn = useTwoColumn
   241        this.clearBotBoxes()
   242        for (const { div } of this.sortedBots) this.appendBotBox(div)
   243      }
   244    }
   245  
   246    async setup () {
   247      const page = this.page
   248      const mmStatus = app().mmStatus
   249  
   250      const botConfigs = mmStatus.bots.map((s: MMBotStatus) => s.config)
   251      app().registerNoteFeeder({
   252        runstats: (note: RunStatsNote) => { this.handleRunStatsNote(note) },
   253        runevent: (note: RunEventNote) => {
   254          const bot = this.bots[hostedMarketID(note.host, note.baseID, note.quoteID)]
   255          if (bot) return bot.handleRunStats()
   256        },
   257        epochreport: (note: EpochReportNote) => {
   258          const bot = this.bots[hostedMarketID(note.host, note.baseID, note.quoteID)]
   259          if (bot) bot.handleEpochReportNote(note)
   260        },
   261        cexproblems: (note: CEXProblemsNote) => {
   262          const bot = this.bots[hostedMarketID(note.host, note.baseID, note.quoteID)]
   263          if (bot) bot.handleCexProblemsNote(note)
   264        },
   265        cexnote: (note: CEXNotification) => { this.handleCEXNote(note) }
   266        // TODO bot start-stop notification
   267      })
   268  
   269      const noBots = !botConfigs || botConfigs.length === 0
   270      Doc.setVis(noBots, page.noBots)
   271      if (noBots) return
   272      page.noBots.remove()
   273  
   274      const sortedBots = [...mmStatus.bots].sort((a: MMBotStatus, b: MMBotStatus) => {
   275        if (a.running && !b.running) return -1
   276        if (b.running && !a.running) return 1
   277        // If none are running, just do something to get a resonably reproducible
   278        // sort.
   279        if (!a.running && !b.running) return (a.config.baseID + a.config.quoteID) - (b.config.baseID + b.config.quoteID)
   280        // Both are running. Sort by run time.
   281        return (b.runStats?.startTime ?? 0) - (a.runStats?.startTime ?? 0)
   282      })
   283  
   284      for (const botStatus of sortedBots) this.addBot(botStatus)
   285    }
   286  
   287    async handleCEXNote (n: CEXNotification) {
   288      switch (n.topic) {
   289        case 'BalanceUpdate':
   290          return this.handleCEXBalanceUpdate(n.cexName /* , n.note */)
   291      }
   292    }
   293  
   294    async handleCEXBalanceUpdate (cexName: string /* , note: CEXBalanceUpdate */) {
   295      const cexRow = this.cexes[cexName]
   296      if (cexRow) this.updateCexRow(cexRow)
   297    }
   298  
   299    async handleRunStatsNote (note: RunStatsNote) {
   300      const { baseID, quoteID, host } = note
   301      const bot = this.bots[hostedMarketID(host, baseID, quoteID)]
   302      if (bot) return bot.handleRunStats()
   303      this.addBot(app().botStatus(host, baseID, quoteID) as MMBotStatus)
   304    }
   305  
   306    unload (): void {
   307      Doc.unbind(document, 'keyup', this.keyup)
   308    }
   309  
   310    addBot (botStatus: MMBotStatus) {
   311      const { page, bots, sortedBots } = this
   312      // Make sure the market still exists.
   313      const { config: { baseID, quoteID, host } } = botStatus
   314      const [baseSymbol, quoteSymbol] = [app().assets[baseID].symbol, app().assets[quoteID].symbol]
   315      const mktID = `${baseSymbol}_${quoteSymbol}`
   316      if (!app().exchanges[host]?.markets[mktID]) return
   317      const bot = new Bot(this, this.runningMMDisplayElements, botStatus)
   318      page.botRows.appendChild(bot.row.tr)
   319      sortedBots.push(bot)
   320      bots[bot.id] = bot
   321      this.appendBotBox(bot.div)
   322    }
   323  
   324    confirmRemoveCfg (mwh: MarketWithHost) {
   325      const page = this.page
   326      this.removingCfg = mwh
   327      Doc.hide(page.removeCfgErr)
   328      const { unitInfo: { conventional: { unit: baseTicker } } } = app().assets[mwh.baseID]
   329      const { unitInfo: { conventional: { unit: quoteTicker } } } = app().assets[mwh.quoteID]
   330      page.confirmRemoveCfgMsg.textContent = intl.prep(intl.ID_DELETE_BOT, { host: mwh.host, baseTicker, quoteTicker })
   331      this.forms.show(this.page.confirmRemoveForm)
   332    }
   333  
   334    async removeCfg () {
   335      const page = this.page
   336      if (!this.removingCfg) { this.forms.close(); return }
   337      const resp = await MM.removeBotConfig(this.removingCfg.host, this.removingCfg.baseID, this.removingCfg.quoteID)
   338      if (!app().checkResponse(resp)) {
   339        page.removeCfgErr.textContent = intl.prep(intl.ID_API_ERROR, { msg: resp.msg })
   340        Doc.show(page.removeCfgErr)
   341        return
   342      }
   343      await app().fetchMMStatus()
   344      app().loadPage('mm')
   345    }
   346  
   347    appendBotBox (div: PageElement) {
   348      const { page: { boxZero, boxOne }, twoColumn } = this
   349      const useZeroth = !twoColumn || (boxZero.children.length + boxOne.children.length) % 2 === 0
   350      const box = useZeroth ? boxZero : boxOne
   351      box.append(div)
   352    }
   353  
   354    clearBotBoxes () {
   355      const { page: { boxOne, boxZero } } = this
   356      while (boxZero.children.length > 1) boxZero.removeChild(boxZero.lastChild as Element)
   357      while (boxOne.children.length > 0) boxOne.removeChild(boxOne.lastChild as Element)
   358    }
   359  
   360    showBot (botID: string) {
   361      const { sortedBots } = this
   362      const idx = sortedBots.findIndex((bot: Bot) => bot.id === botID)
   363      sortedBots.splice(idx, 1)
   364      sortedBots.unshift(this.bots[botID])
   365      this.clearBotBoxes()
   366      for (const { div } of sortedBots) this.appendBotBox(div)
   367      const div = this.bots[botID].div
   368      Doc.animate(250, (p: number) => {
   369        div.style.opacity = `${p}`
   370        div.style.transform = `scale(${0.8 + 0.2 * p})`
   371      })
   372    }
   373  
   374    newBot () {
   375      app().loadPage('mmsettings')
   376    }
   377  
   378    async cexConfigured (cexName: string, success: boolean) {
   379      await app().fetchMMStatus()
   380      this.updateCexRow(this.cexes[cexName])
   381      if (success) this.forms.close()
   382    }
   383  
   384    updateCexRow (row: CEXRow) {
   385      const { tmpl, dinfo, cexName } = row
   386      tmpl.logo.src = dinfo.logo
   387      tmpl.name.textContent = dinfo.name
   388      const status = app().mmStatus.cexes[cexName]
   389      Doc.setVis(!status, tmpl.unconfigured)
   390      Doc.setVis(status && !status.connectErr, tmpl.configured)
   391      Doc.setVis(status?.connectErr, tmpl.connectErrBox)
   392      if (status?.connectErr) {
   393        tmpl.connectErr.textContent = 'connection error'
   394        tmpl.connectErr.dataset.tooltip = status.connectErr
   395      }
   396      tmpl.logo.classList.toggle('greyscale', !status)
   397      if (!status) return
   398      let usdBal = 0
   399      const cexSymbolAdded : Record<string, boolean> = {} // avoid double counting tokens or counting both eth and weth
   400      for (const [assetIDStr, bal] of Object.entries(status.balances)) {
   401        const assetID = parseInt(assetIDStr)
   402        const cexSymbol = Doc.bipCEXSymbol(assetID)
   403        if (cexSymbolAdded[cexSymbol]) continue
   404        cexSymbolAdded[cexSymbol] = true
   405        const { unitInfo } = app().assets[assetID]
   406        const fiatRate = app().fiatRatesMap[assetID]
   407        if (fiatRate) usdBal += fiatRate * (bal.available + bal.locked) / unitInfo.conventional.conversionFactor
   408      }
   409      tmpl.usdBalance.textContent = Doc.formatFourSigFigs(usdBal)
   410    }
   411  
   412    percentageBalanceStr (assetID: number, balance: number, percentage: number): string {
   413      const asset = app().assets[assetID]
   414      const unitInfo = asset.unitInfo
   415      const assetValue = Doc.formatCoinValue((balance * percentage) / 100, unitInfo)
   416      return `${Doc.formatFourSigFigs(percentage)}% - ${assetValue} ${asset.symbol.toUpperCase()}`
   417    }
   418  
   419    /*
   420     * walletBalanceStr returns a string like "50% - 0.0001 BTC" representing
   421     * the percentage of a wallet's balance selected in the market maker setting,
   422     * and the amount of that asset in the wallet.
   423     */
   424    walletBalanceStr (assetID: number, percentage: number): string {
   425      const { wallet: { balance: { available } } } = app().assets[assetID]
   426      return this.percentageBalanceStr(assetID, available, percentage)
   427    }
   428  }
   429  
   430  interface BotRow {
   431    tr: PageElement
   432    tmpl: Record<string, PageElement>
   433  }
   434  
   435  class Bot extends BotMarket {
   436    pg: MarketMakerPage
   437    div: PageElement
   438    page: Record<string, PageElement>
   439    placementsChart: PlacementsChart
   440    baseAllocSlider: MiniSlider
   441    quoteAllocSlider: MiniSlider
   442    row: BotRow
   443    runDisplay: RunningMarketMakerDisplay
   444  
   445    constructor (pg: MarketMakerPage, runningMMElements: RunningMMDisplayElements, status: MMBotStatus) {
   446      super(status.config)
   447      this.pg = pg
   448      const { baseID, quoteID, host, botType, nBuyPlacements, nSellPlacements, cexName } = this
   449      this.id = hostedMarketID(host, baseID, quoteID)
   450  
   451      const div = this.div = pg.page.botTmpl.cloneNode(true) as PageElement
   452      const page = this.page = Doc.parseTemplate(div)
   453  
   454      this.runDisplay = new RunningMarketMakerDisplay(page.onBox, pg.forms, runningMMElements, 'mm')
   455  
   456      setMarketElements(div, baseID, quoteID, host)
   457      if (cexName) setCexElements(div, cexName)
   458  
   459      if (botType === botTypeArbMM) {
   460        page.botTypeDisplay.textContent = intl.prep(intl.ID_BOTTYPE_ARB_MM)
   461      } else if (botType === botTypeBasicArb) {
   462        page.botTypeDisplay.textContent = intl.prep(intl.ID_BOTTYPE_SIMPLE_ARB)
   463      } else if (botType === botTypeBasicMM) {
   464        page.botTypeDisplay.textContent = intl.prep(intl.ID_BOTTYPE_BASIC_MM)
   465      }
   466  
   467      Doc.setVis(botType !== botTypeBasicArb, page.placementsChartBox, page.baseTokenSwapFeesBox)
   468      if (botType !== botTypeBasicArb) {
   469        this.placementsChart = new PlacementsChart(page.placementsChart)
   470        page.buyPlacementCount.textContent = String(nBuyPlacements)
   471        page.sellPlacementCount.textContent = String(nSellPlacements)
   472      }
   473  
   474      Doc.bind(page.startBttn, 'click', () => this.start())
   475      Doc.bind(page.allocationBttn, 'click', () => this.allocate())
   476      Doc.bind(page.reconfigureBttn, 'click', () => this.reconfigure())
   477      Doc.bind(page.removeBttn, 'click', () => this.pg.confirmRemoveCfg(status.config))
   478      Doc.bind(page.goBackFromAllocation, 'click', () => this.hideAllocationDialog())
   479      Doc.bind(page.marketLink, 'click', () => app().loadPage('markets', { host, baseID, quoteID }))
   480  
   481      this.baseAllocSlider = new MiniSlider(page.baseAllocSlider, () => { /* callback set later */ })
   482      this.quoteAllocSlider = new MiniSlider(page.quoteAllocSlider, () => { /* callback set later */ })
   483  
   484      const tr = pg.page.botRowTmpl.cloneNode(true) as PageElement
   485      setMarketElements(tr, baseID, quoteID, host)
   486      const tmpl = Doc.parseTemplate(tr)
   487      this.row = { tr, tmpl }
   488      Doc.bind(tmpl.allocateBttn, 'click', (e: MouseEvent) => {
   489        e.stopPropagation()
   490        this.allocate()
   491        pg.showBot(this.id)
   492      })
   493      Doc.bind(tr, 'click', () => pg.showBot(this.id))
   494  
   495      this.initialize()
   496    }
   497  
   498    async initialize () {
   499      await super.initialize()
   500      this.runDisplay.setBotMarket(this)
   501      const {
   502        page, host, cexName, botType, div,
   503        cfg: { arbMarketMakingConfig, basicMarketMakingConfig }, mktID,
   504        baseFactor, quoteFactor, marketReport: { baseFiatRate }
   505      } = this
   506  
   507      if (botType !== botTypeBasicArb) {
   508        let buyPlacements: OrderPlacement[] = []
   509        let sellPlacements: OrderPlacement[] = []
   510        let profit = 0
   511        if (arbMarketMakingConfig) {
   512          buyPlacements = arbMarketMakingConfig.buyPlacements.map((p) => ({ lots: p.lots, gapFactor: p.multiplier }))
   513          sellPlacements = arbMarketMakingConfig.sellPlacements.map((p) => ({ lots: p.lots, gapFactor: p.multiplier }))
   514          profit = arbMarketMakingConfig.profit
   515        } else if (basicMarketMakingConfig) {
   516          buyPlacements = basicMarketMakingConfig.buyPlacements
   517          sellPlacements = basicMarketMakingConfig.sellPlacements
   518          let bestBuy: OrderPlacement | undefined
   519          let bestSell : OrderPlacement | undefined
   520          if (buyPlacements.length > 0) bestBuy = buyPlacements.reduce((prev: OrderPlacement, curr: OrderPlacement) => curr.gapFactor < prev.gapFactor ? curr : prev)
   521          if (sellPlacements.length > 0) bestSell = sellPlacements.reduce((prev: OrderPlacement, curr: OrderPlacement) => curr.gapFactor < prev.gapFactor ? curr : prev)
   522          if (bestBuy && bestSell) {
   523            profit = (bestBuy.gapFactor + bestSell.gapFactor) / 2
   524          } else if (bestBuy) {
   525            profit = bestBuy.gapFactor
   526          } else if (bestSell) {
   527            profit = bestSell.gapFactor
   528          }
   529        }
   530        const marketConfig = { cexName: cexName as string, botType, baseFiatRate: baseFiatRate, dict: { profit, buyPlacements, sellPlacements } }
   531        this.placementsChart.setMarket(marketConfig)
   532      }
   533  
   534      Doc.setVis(botType !== botTypeBasicMM, page.cexDataBox)
   535      if (botType !== botTypeBasicMM) {
   536        const cex = app().mmStatus.cexes[cexName]
   537        if (cex) {
   538          const mkt = cex.markets ? cex.markets[mktID] : undefined
   539          Doc.setVis(mkt?.day, page.cexDataBox)
   540          if (mkt?.day) {
   541            const day = mkt.day
   542            page.cexPrice.textContent = Doc.formatFourSigFigs(day.lastPrice)
   543            page.cexVol.textContent = Doc.formatFourSigFigs(baseFiatRate * day.vol)
   544          }
   545        }
   546      }
   547      Doc.setVis(Boolean(cexName), ...Doc.applySelector(div, '[data-cex-show]'))
   548  
   549      const { spot } = app().exchanges[host].markets[mktID]
   550      if (spot) {
   551        Doc.show(page.dexDataBox)
   552        const c = OrderUtil.RateEncodingFactor / baseFactor * quoteFactor
   553        page.dexPrice.textContent = Doc.formatFourSigFigs(spot.rate / c)
   554        page.dexVol.textContent = Doc.formatFourSigFigs(spot.vol24 / baseFactor * baseFiatRate)
   555      }
   556  
   557      this.updateDisplay()
   558      this.updateTableRow()
   559      Doc.hide(page.loadingBg)
   560    }
   561  
   562    updateTableRow () {
   563      const { row: { tmpl } } = this
   564      const { running, runStats } = this.status()
   565      Doc.setVis(running, tmpl.profitLossBox)
   566      Doc.setVis(!running, tmpl.allocateBttnBox)
   567      if (runStats) {
   568        tmpl.profitLoss.textContent = Doc.formatFourSigFigs(runStats.profitLoss.profit, 2)
   569      }
   570    }
   571  
   572    updateDisplay () {
   573      const { page, marketReport: { baseFiatRate, quoteFiatRate }, baseFeeFiatRate, quoteFeeFiatRate } = this
   574      if ([baseFiatRate, quoteFiatRate, baseFeeFiatRate, quoteFeeFiatRate].some((r: number) => !r)) {
   575        Doc.hide(page.onBox, page.offBox)
   576        Doc.show(page.noFiatDisplay)
   577        return
   578      }
   579      const { running } = this.status()
   580      Doc.setVis(running, page.onBox)
   581      Doc.setVis(!running, page.offBox)
   582      if (running) this.updateRunningDisplay()
   583      else this.updateIdleDisplay()
   584    }
   585  
   586    updateRunningDisplay () {
   587      this.runDisplay.update()
   588    }
   589  
   590    updateIdleDisplay () {
   591      const {
   592        page, proj: { alloc, qProj, bProj }, baseID, quoteID, cexName, bui, qui, baseFeeID,
   593        quoteFeeID, baseFactor, quoteFactor, baseFeeFactor, quoteFeeFactor,
   594        marketReport: { baseFiatRate, quoteFiatRate }, cfg: { uiConfig: { baseConfig, quoteConfig } },
   595        quoteFeeUI, baseFeeUI
   596      } = this
   597      page.baseAlloc.textContent = Doc.formatFullPrecision(alloc[baseID], bui)
   598      const baseUSD = alloc[baseID] / baseFactor * baseFiatRate
   599      let totalUSD = baseUSD
   600      page.baseAllocUSD.textContent = Doc.formatFourSigFigs(baseUSD)
   601      page.baseBookAlloc.textContent = Doc.formatFullPrecision(bProj.book * baseFactor, bui)
   602      page.baseOrderReservesAlloc.textContent = Doc.formatFullPrecision(bProj.orderReserves * baseFactor, bui)
   603      page.baseOrderReservesPct.textContent = String(Math.round(baseConfig.orderReservesFactor * 100))
   604      Doc.setVis(cexName, page.baseCexAllocBox)
   605      if (cexName) page.baseCexAlloc.textContent = Doc.formatFullPrecision(bProj.cex * baseFactor, bui)
   606      Doc.setVis(baseFeeID === baseID, page.baseBookingFeesAllocBox)
   607      Doc.setVis(baseFeeID !== baseID, page.baseTokenFeesAllocBox)
   608      if (baseFeeID === baseID) {
   609        const bookingFees = baseID === quoteFeeID ? bProj.bookingFees + qProj.bookingFees : bProj.bookingFees
   610        page.baseBookingFeesAlloc.textContent = Doc.formatFullPrecision(bookingFees * baseFeeFactor, baseFeeUI)
   611      } else {
   612        const feeAlloc = alloc[baseFeeID]
   613        page.baseTokenFeeAlloc.textContent = Doc.formatFullPrecision(feeAlloc, baseFeeUI)
   614        const baseFeeUSD = feeAlloc / baseFeeFactor * app().fiatRatesMap[baseFeeID]
   615        totalUSD += baseFeeUSD
   616        page.baseTokenAllocUSD.textContent = Doc.formatFourSigFigs(baseFeeUSD)
   617        const withQuote = baseFeeID === quoteFeeID
   618        const bookingFees = bProj.bookingFees + (withQuote ? qProj.bookingFees : 0)
   619        page.baseTokenBookingFees.textContent = Doc.formatFullPrecision(bookingFees * baseFeeFactor, baseFeeUI)
   620        page.baseTokenSwapFeeN.textContent = String(baseConfig.swapFeeN + (withQuote ? quoteConfig.swapFeeN : 0))
   621        const swapReserves = bProj.swapFeeReserves + (withQuote ? qProj.swapFeeReserves : 0)
   622        page.baseTokenSwapFees.textContent = Doc.formatFullPrecision(swapReserves * baseFeeFactor, baseFeeUI)
   623      }
   624  
   625      page.quoteAlloc.textContent = Doc.formatFullPrecision(alloc[quoteID], qui)
   626      const quoteUSD = alloc[quoteID] / quoteFactor * quoteFiatRate
   627      totalUSD += quoteUSD
   628      page.quoteAllocUSD.textContent = Doc.formatFourSigFigs(quoteUSD)
   629      page.quoteBookAlloc.textContent = Doc.formatFullPrecision(qProj.book * quoteFactor, qui)
   630      page.quoteOrderReservesAlloc.textContent = Doc.formatFullPrecision(qProj.orderReserves * quoteFactor, qui)
   631      page.quoteOrderReservesPct.textContent = String(Math.round(quoteConfig.orderReservesFactor * 100))
   632      page.quoteSlippageAlloc.textContent = Doc.formatFullPrecision(qProj.slippageBuffer * quoteFactor, qui)
   633      page.slippageBufferFactor.textContent = String(Math.round(quoteConfig.slippageBufferFactor * 100))
   634      Doc.setVis(cexName, page.quoteCexAllocBox)
   635      if (cexName) page.quoteCexAlloc.textContent = Doc.formatFullPrecision(qProj.cex * quoteFactor, qui)
   636      Doc.setVis(quoteID === quoteFeeID, page.quoteBookingFeesAllocBox)
   637      Doc.setVis(quoteFeeID !== quoteID && quoteFeeID !== baseFeeID, page.quoteTokenFeesAllocBox)
   638      if (quoteID === quoteFeeID) {
   639        const bookingFees = quoteID === baseFeeID ? bProj.bookingFees + qProj.bookingFees : qProj.bookingFees
   640        page.quoteBookingFeesAlloc.textContent = Doc.formatFullPrecision(bookingFees * quoteFeeFactor, quoteFeeUI)
   641      } else if (quoteFeeID !== baseFeeID) {
   642        page.quoteTokenFeeAlloc.textContent = Doc.formatFullPrecision(alloc[quoteFeeID], quoteFeeUI)
   643        const quoteFeeUSD = alloc[quoteFeeID] / quoteFeeFactor * app().fiatRatesMap[quoteFeeID]
   644        totalUSD += quoteFeeUSD
   645        page.quoteTokenAllocUSD.textContent = Doc.formatFourSigFigs(quoteFeeUSD)
   646        page.quoteTokenBookingFees.textContent = Doc.formatFullPrecision(qProj.bookingFees * quoteFeeFactor, quoteFeeUI)
   647        page.quoteTokenSwapFeeN.textContent = String(quoteConfig.swapFeeN)
   648        page.quoteTokenSwapFees.textContent = Doc.formatFullPrecision(qProj.swapFeeReserves * quoteFeeFactor, quoteFeeUI)
   649      }
   650      page.totalAllocUSD.textContent = Doc.formatFourSigFigs(totalUSD)
   651    }
   652  
   653    /*
   654     * allocate opens a dialog to choose funding sources (if applicable) and
   655     * confirm allocations and start the bot.
   656     */
   657    allocate () {
   658      const {
   659        page, marketReport: { baseFiatRate, quoteFiatRate }, baseID, quoteID,
   660        baseFeeID, quoteFeeID, baseFeeFiatRate, quoteFeeFiatRate, cexName,
   661        baseFactor, quoteFactor, baseFeeFactor, quoteFeeFactor, host, mktID
   662      } = this
   663  
   664      if (cexName) {
   665        const cex = app().mmStatus.cexes[cexName]
   666        if (!cex || !cex.connected) {
   667          page.offError.textContent = intl.prep(intl.ID_CEX_NOT_CONNECTED, { cexName })
   668          Doc.showTemporarily(3000, page.offError)
   669          return
   670        }
   671      }
   672  
   673      const f = this.fundingState()
   674  
   675      const [proposedDexBase, proposedCexBase, baseSlider] = parseFundingOptions(f.base)
   676      const [proposedDexQuote, proposedCexQuote, quoteSlider] = parseFundingOptions(f.quote)
   677  
   678      const alloc = this.alloc = {
   679        dex: {
   680          [baseID]: proposedDexBase * baseFactor,
   681          [quoteID]: proposedDexQuote * quoteFactor
   682        },
   683        cex: {
   684          [baseID]: proposedCexBase * baseFactor,
   685          [quoteID]: proposedCexQuote * quoteFactor
   686        }
   687      }
   688  
   689      alloc.dex[baseFeeID] = Math.min((alloc.dex[baseFeeID] ?? 0) + (f.base.fees.req * baseFeeFactor), f.base.fees.avail * baseFeeFactor)
   690      alloc.dex[quoteFeeID] = Math.min((alloc.dex[quoteFeeID] ?? 0) + (f.quote.fees.req * quoteFeeFactor), f.quote.fees.avail * quoteFeeFactor)
   691  
   692      let totalUSD = (alloc.dex[baseID] / baseFactor * baseFiatRate) + (alloc.dex[quoteID] / quoteFactor * quoteFiatRate)
   693      totalUSD += (alloc.cex[baseID] / baseFactor * baseFiatRate) + (alloc.cex[quoteID] / quoteFactor * quoteFiatRate)
   694      if (baseFeeID !== baseID) totalUSD += alloc.dex[baseFeeID] / baseFeeFactor * baseFeeFiatRate
   695      if (quoteFeeID !== quoteID && quoteFeeID !== baseFeeID) totalUSD += alloc.dex[quoteFeeID] / quoteFeeFactor * quoteFeeFiatRate
   696      page.allocUSD.textContent = Doc.formatFourSigFigs(totalUSD)
   697  
   698      Doc.setVis(cexName, ...Doc.applySelector(page.allocationDialog, '[data-cex-only]'))
   699      Doc.setVis(f.fundedAndBalanced, page.fundedAndBalancedBox)
   700      Doc.setVis(f.base.transferable + f.quote.transferable > 0, page.hasTransferable)
   701      Doc.setVis(f.fundedAndNotBalanced, page.fundedAndNotBalancedBox)
   702      Doc.setVis(f.starved, page.starvedBox)
   703      page.startBttn.classList.toggle('go', f.fundedAndBalanced)
   704      page.startBttn.classList.toggle('warning', !f.fundedAndBalanced)
   705      page.proposedDexBaseAlloc.classList.toggle('text-warning', !(f.base.fundedAndBalanced || f.base.fundedAndNotBalanced))
   706      page.proposedDexQuoteAlloc.classList.toggle('text-warning', !(f.quote.fundedAndBalanced || f.quote.fundedAndNotBalanced))
   707  
   708      const setBaseProposal = (dex: number, cex: number) => {
   709        page.proposedDexBaseAlloc.textContent = Doc.formatFourSigFigs(dex)
   710        page.proposedDexBaseAllocUSD.textContent = Doc.formatFourSigFigs(dex * baseFiatRate)
   711        page.proposedCexBaseAlloc.textContent = Doc.formatFourSigFigs(cex)
   712        page.proposedCexBaseAllocUSD.textContent = Doc.formatFourSigFigs(cex * baseFiatRate)
   713      }
   714      setBaseProposal(proposedDexBase, proposedCexBase)
   715  
   716      Doc.setVis(baseSlider, page.baseAllocSlider)
   717      if (baseSlider) {
   718        const dexRange = baseSlider.right.dex - baseSlider.left.dex
   719        const cexRange = baseSlider.right.cex - baseSlider.left.cex
   720        this.baseAllocSlider.setValue(0.5)
   721        this.baseAllocSlider.changed = (r: number) => {
   722          const dexAlloc = baseSlider.left.dex + r * dexRange
   723          const cexAlloc = baseSlider.left.cex + r * cexRange
   724          alloc.dex[baseID] = dexAlloc * baseFactor
   725          alloc.cex[baseID] = cexAlloc * baseFactor
   726          setBaseProposal(dexAlloc, cexAlloc)
   727        }
   728      }
   729  
   730      const setQuoteProposal = (dex: number, cex: number) => {
   731        page.proposedDexQuoteAlloc.textContent = Doc.formatFourSigFigs(dex)
   732        page.proposedDexQuoteAllocUSD.textContent = Doc.formatFourSigFigs(dex * quoteFiatRate)
   733        page.proposedCexQuoteAlloc.textContent = Doc.formatFourSigFigs(cex)
   734        page.proposedCexQuoteAllocUSD.textContent = Doc.formatFourSigFigs(cex * quoteFiatRate)
   735      }
   736      setQuoteProposal(proposedDexQuote, proposedCexQuote)
   737  
   738      Doc.setVis(quoteSlider, page.quoteAllocSlider)
   739      if (quoteSlider) {
   740        const dexRange = quoteSlider.right.dex - quoteSlider.left.dex
   741        const cexRange = quoteSlider.right.cex - quoteSlider.left.cex
   742        this.quoteAllocSlider.setValue(0.5)
   743        this.quoteAllocSlider.changed = (r: number) => {
   744          const dexAlloc = quoteSlider.left.dex + r * dexRange
   745          const cexAlloc = quoteSlider.left.cex + r * cexRange
   746          alloc.dex[quoteID] = dexAlloc * quoteFactor
   747          alloc.cex[quoteID] = cexAlloc * quoteFactor
   748          setQuoteProposal(dexAlloc, cexAlloc)
   749        }
   750      }
   751  
   752      Doc.setVis(baseFeeID !== baseID, ...Doc.applySelector(page.allocationDialog, '[data-base-token-fees]'))
   753      if (baseFeeID !== baseID) {
   754        const reqFees = f.base.fees.req + (baseFeeID === quoteFeeID ? f.quote.fees.req : 0)
   755        const proposedFees = Math.min(reqFees, f.base.fees.avail)
   756        page.proposedDexBaseFeeAlloc.textContent = Doc.formatFourSigFigs(proposedFees)
   757        page.proposedDexBaseFeeAllocUSD.textContent = Doc.formatFourSigFigs(proposedFees * baseFeeFiatRate)
   758        page.proposedDexBaseFeeAlloc.classList.toggle('text-warning', !f.base.fees.funded)
   759      }
   760  
   761      const needQuoteTokenFees = quoteFeeID !== quoteID && quoteFeeID !== baseFeeID
   762      Doc.setVis(needQuoteTokenFees, ...Doc.applySelector(page.allocationDialog, '[data-quote-token-fees]'))
   763      if (needQuoteTokenFees) {
   764        const proposedFees = Math.min(f.quote.fees.req, f.quote.fees.avail)
   765        page.proposedDexQuoteFeeAlloc.textContent = Doc.formatFourSigFigs(proposedFees)
   766        page.proposedDexQuoteFeeAllocUSD.textContent = Doc.formatFourSigFigs(proposedFees * quoteFeeFiatRate)
   767        page.proposedDexQuoteFeeAlloc.classList.toggle('text-warning', !f.quote.fees.funded)
   768      }
   769  
   770      const mkt = app().exchanges[host]?.markets[mktID]
   771      let existingOrders = false
   772      if (mkt && mkt.orders) {
   773        for (let i = 0; i < mkt.orders.length; i++) {
   774          if (mkt.orders[i].status <= StatusBooked) {
   775            existingOrders = true
   776            break
   777          }
   778        }
   779      }
   780      Doc.setVis(existingOrders, page.existingOrdersBox)
   781  
   782      Doc.show(page.allocationDialog)
   783      const closeDialog = (e: MouseEvent) => {
   784        if (Doc.mouseInElement(e, page.allocationDialog)) return
   785        this.hideAllocationDialog()
   786        Doc.unbind(document, 'click', closeDialog)
   787      }
   788      Doc.bind(document, 'click', closeDialog)
   789    }
   790  
   791    hideAllocationDialog () {
   792      Doc.hide(this.page.allocationDialog)
   793    }
   794  
   795    async start () {
   796      const { page, alloc, baseID, quoteID, host, cexName, cfg: { uiConfig: { cexRebalance } } } = this
   797  
   798      Doc.hide(page.errMsg)
   799      if (cexName && !app().mmStatus.cexes[cexName]?.connected) {
   800        page.errMsg.textContent = `${cexName} not connected`
   801        Doc.show(page.errMsg)
   802        return
   803      }
   804  
   805      // round allocations values.
   806      for (const m of [alloc.dex, alloc.cex]) {
   807        for (const [assetID, v] of Object.entries(m)) m[parseInt(assetID)] = Math.round(v)
   808      }
   809  
   810      const startConfig: StartConfig = {
   811        baseID: baseID,
   812        quoteID: quoteID,
   813        host: host,
   814        alloc: alloc
   815      }
   816      if (cexName && cexRebalance) startConfig.autoRebalance = this.autoRebalanceSettings()
   817  
   818      try {
   819        app().log('mm', 'starting mm bot', startConfig)
   820        const res = await MM.startBot(startConfig)
   821        if (!app().checkResponse(res)) throw res
   822      } catch (e) {
   823        page.errMsg.textContent = intl.prep(intl.ID_API_ERROR, e)
   824        Doc.show(page.errMsg)
   825        return
   826      }
   827      this.hideAllocationDialog()
   828    }
   829  
   830    autoRebalanceSettings (): AutoRebalanceConfig {
   831      const {
   832        proj: { bProj, qProj, alloc }, baseFeeID, quoteFeeID, cfg: { uiConfig: { baseConfig, quoteConfig } },
   833        baseID, quoteID, cexName, mktID
   834      } = this
   835  
   836      const totalBase = alloc[baseID]
   837      let dexMinBase = bProj.book
   838      if (baseID === baseFeeID) dexMinBase += bProj.bookingFees
   839      if (baseID === quoteFeeID) dexMinBase += qProj.bookingFees
   840      let dexMinQuote = qProj.book
   841      if (quoteID === quoteFeeID) dexMinQuote += qProj.bookingFees
   842      if (quoteID === baseFeeID) dexMinQuote += bProj.bookingFees
   843      const maxBase = Math.max(totalBase - dexMinBase, totalBase - bProj.cex)
   844      const totalQuote = alloc[quoteID]
   845      const maxQuote = Math.max(totalQuote - dexMinQuote, totalQuote - qProj.cex)
   846      if (maxBase < 0 || maxQuote < 0) {
   847        throw Error(`rebalance math doesn't work: ${JSON.stringify({ bProj, qProj, maxBase, maxQuote })}`)
   848      }
   849      const cex = app().mmStatus.cexes[cexName]
   850      const mkt = cex.markets[mktID]
   851      const [minB, maxB] = [mkt.baseMinWithdraw, Math.max(mkt.baseMinWithdraw * 2, maxBase)]
   852      const minBaseTransfer = Math.round(minB + baseConfig.transferFactor * (maxB - minB))
   853      const [minQ, maxQ] = [mkt.quoteMinWithdraw, Math.max(mkt.quoteMinWithdraw * 2, maxQuote)]
   854      const minQuoteTransfer = Math.round(minQ + quoteConfig.transferFactor * (maxQ - minQ))
   855      return { minBaseTransfer, minQuoteTransfer }
   856    }
   857  
   858    reconfigure () {
   859      const { host, baseID, quoteID, cexName, botType, page } = this
   860      if (cexName) {
   861        const cex = app().mmStatus.cexes[cexName]
   862        if (!cex || !cex.connected) {
   863          page.offError.textContent = intl.prep(intl.ID_CEX_NOT_CONNECTED, { cexName })
   864          Doc.showTemporarily(3000, page.offError)
   865          return
   866        }
   867      }
   868      app().loadPage('mmsettings', { host, baseID, quoteID, cexName, botType })
   869    }
   870  
   871    handleEpochReportNote (note: EpochReportNote) {
   872      this.runDisplay.handleEpochReportNote(note)
   873    }
   874  
   875    handleCexProblemsNote (note: CEXProblemsNote) {
   876      this.runDisplay.handleCexProblemsNote(note)
   877    }
   878  
   879    handleRunStats () {
   880      this.updateDisplay()
   881      this.updateTableRow()
   882      this.runDisplay.readBook()
   883    }
   884  }