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

     1  import {
     2    PageElement,
     3    BotConfig,
     4    OrderPlacement,
     5    app,
     6    Spot,
     7    MarketReport,
     8    OrderOption,
     9    CEXConfig,
    10    BasicMarketMakingConfig,
    11    ArbMarketMakingConfig,
    12    SimpleArbConfig,
    13    ArbMarketMakingPlacement,
    14    ExchangeBalance,
    15    MarketMakingStatus,
    16    MMCEXStatus,
    17    BalanceNote,
    18    BotAssetConfig,
    19    ApprovalStatus,
    20    SupportedAsset,
    21    WalletState,
    22    UnitInfo,
    23    ProjectedAlloc,
    24    AssetBookingFees
    25  } from './registry'
    26  import Doc, {
    27    NumberInput,
    28    MiniSlider,
    29    IncrementalInput,
    30    toFourSigFigs,
    31    toPrecision,
    32    parseFloatDefault
    33  } from './doc'
    34  import State from './state'
    35  import BasePage from './basepage'
    36  import { setOptionTemplates } from './opts'
    37  import {
    38    MM,
    39    CEXDisplayInfos,
    40    botTypeBasicArb,
    41    botTypeArbMM,
    42    botTypeBasicMM,
    43    runningBotInventory,
    44    setMarketElements,
    45    setCexElements,
    46    calculateQuoteLot,
    47    PlacementsChart,
    48    liveBotConfig,
    49    GapStrategyMultiplier,
    50    GapStrategyAbsolute,
    51    GapStrategyAbsolutePlus,
    52    GapStrategyPercent,
    53    GapStrategyPercentPlus,
    54    feesAndCommit
    55  } from './mmutil'
    56  import { Forms, bind as bindForm, NewWalletForm, TokenApprovalForm, DepositAddress, CEXConfigurationForm } from './forms'
    57  import * as intl from './locales'
    58  import * as OrderUtil from './orderutil'
    59  
    60  const specLK = 'lastMMSpecs'
    61  const lastBotsLK = 'lastBots'
    62  const lastArbExchangeLK = 'lastArbExchange'
    63  const arbMMRowCacheKey = 'arbmm'
    64  
    65  const defaultSwapReserves = {
    66    n: 50,
    67    prec: 0,
    68    inc: 10,
    69    minR: 0,
    70    maxR: 1000,
    71    range: 1000
    72  }
    73  const defaultOrderReserves = {
    74    factor: 1.0,
    75    minR: 0,
    76    maxR: 3,
    77    range: 3,
    78    prec: 3
    79  }
    80  const defaultTransfer = {
    81    factor: 0.1,
    82    minR: 0,
    83    maxR: 1,
    84    range: 1
    85  }
    86  const defaultSlippage = {
    87    factor: 0.05,
    88    minR: 0,
    89    maxR: 0.3,
    90    range: 0.3,
    91    prec: 3
    92  }
    93  const defaultDriftTolerance = {
    94    value: 0.002,
    95    minV: 0,
    96    maxV: 0.02,
    97    range: 0.02,
    98    prec: 5
    99  }
   100  const defaultOrderPersistence = {
   101    value: 20,
   102    minV: 0,
   103    maxV: 40, // 10 minutes @ 15 second epochs
   104    range: 40,
   105    prec: 0
   106  }
   107  const defaultProfit = {
   108    prec: 3,
   109    value: 0.01,
   110    minV: 0.001,
   111    maxV: 0.1,
   112    range: 0.1 - 0.001
   113  }
   114  const defaultLevelSpacing = {
   115    prec: 3,
   116    value: 0.005,
   117    minV: 0.001,
   118    maxV: 0.02,
   119    range: 0.02 - 0.0001
   120  }
   121  const defaultMatchBuffer = {
   122    value: 0,
   123    prec: 3,
   124    minV: 0,
   125    maxV: 1,
   126    range: 1
   127  }
   128  const defaultLevelsPerSide = {
   129    prec: 0,
   130    inc: 1,
   131    value: 1,
   132    minV: 1
   133  }
   134  const defaultLotsPerLevel = {
   135    prec: 0,
   136    value: 1,
   137    minV: 1,
   138    usdIncrement: 100
   139  }
   140  const defaultUSDPerSide = {
   141    prec: 2
   142  }
   143  
   144  const defaultMarketMakingConfig: ConfigState = {
   145    gapStrategy: GapStrategyPercentPlus,
   146    sellPlacements: [],
   147    buyPlacements: [],
   148    driftTolerance: defaultDriftTolerance.value,
   149    profit: 0.02,
   150    orderPersistence: defaultOrderPersistence.value,
   151    cexRebalance: true,
   152    simpleArbLots: 1
   153  } as any as ConfigState
   154  
   155  const defaultBotAssetConfig: BotAssetConfig = {
   156    swapFeeN: defaultSwapReserves.n,
   157    orderReservesFactor: defaultOrderReserves.factor,
   158    slippageBufferFactor: defaultSlippage.factor,
   159    transferFactor: defaultTransfer.factor
   160  }
   161  
   162  // cexButton stores parts of a CEX selection button.
   163  interface cexButton {
   164    name: string
   165    div: PageElement
   166    tmpl: Record<string, PageElement>
   167  }
   168  
   169  /*
   170   * ConfigState is an amalgamation of BotConfig, ArbMarketMakingCfg, and
   171   * BasicMarketMakingCfg. ConfigState tracks the global state of the options
   172   * presented on the page, with a single field for each option / control element.
   173   * ConfigState is necessary because there are duplicate fields in the various
   174   * config structs, and the placement types are not identical.
   175   */
   176  interface ConfigState {
   177    gapStrategy: string
   178    profit: number
   179    driftTolerance: number
   180    orderPersistence: number // epochs
   181    cexRebalance: boolean
   182    disabled: boolean
   183    buyPlacements: OrderPlacement[]
   184    sellPlacements: OrderPlacement[]
   185    baseOptions: Record<string, string>
   186    quoteOptions: Record<string, string>
   187    baseConfig: BotAssetConfig
   188    quoteConfig: BotAssetConfig
   189    simpleArbLots: number
   190  }
   191  
   192  interface BotSpecs {
   193    host: string
   194    baseID: number
   195    quoteID: number
   196    botType: string
   197    cexName?: string
   198  }
   199  
   200  interface MarketRow {
   201    tr: PageElement
   202    tmpl: Record<string, PageElement>
   203    host: string
   204    name: string
   205    baseID: number
   206    quoteID: number
   207    arbs: string[]
   208    spot: Spot
   209  }
   210  
   211  interface UIOpts {
   212    usingUSDPerSide?: boolean
   213  }
   214  
   215  export default class MarketMakerSettingsPage extends BasePage {
   216    page: Record<string, PageElement>
   217    forms: Forms
   218    opts: UIOpts
   219    newWalletForm: NewWalletForm
   220    approveTokenForm: TokenApprovalForm
   221    walletAddrForm: DepositAddress
   222    cexConfigForm: CEXConfigurationForm
   223    currentMarket: string
   224    originalConfig: ConfigState
   225    updatedConfig: ConfigState
   226    creatingNewBot: boolean
   227    marketReport: MarketReport
   228    qcProfit: NumberInput
   229    qcProfitSlider: MiniSlider
   230    qcLevelSpacing: NumberInput
   231    qcLevelSpacingSlider: MiniSlider
   232    qcMatchBuffer: NumberInput
   233    qcMatchBufferSlider: MiniSlider
   234    qcLevelsPerSide: IncrementalInput
   235    qcLotsPerLevel: IncrementalInput
   236    qcUSDPerSide: IncrementalInput
   237    cexBaseBalance: ExchangeBalance
   238    cexQuoteBalance: ExchangeBalance
   239    specs: BotSpecs
   240    mktID: string
   241    formSpecs: BotSpecs
   242    formCexes: Record<string, cexButton>
   243    placementsCache: Record<string, [OrderPlacement[], OrderPlacement[]]>
   244    botTypeSelectors: PageElement[]
   245    marketRows: MarketRow[]
   246    lotsPerLevelIncrement: number
   247    placementsChart: PlacementsChart
   248    basePane: AssetPane
   249    quotePane: AssetPane
   250    driftTolerance: NumberInput
   251    driftToleranceSlider: MiniSlider
   252    orderPersistence: NumberInput
   253    orderPersistenceSlider: MiniSlider
   254  
   255    constructor (main: HTMLElement, specs: BotSpecs) {
   256      super()
   257  
   258      this.placementsCache = {}
   259      this.opts = {}
   260  
   261      const page = this.page = Doc.idDescendants(main)
   262  
   263      this.forms = new Forms(page.forms, {
   264        closed: () => {
   265          if (!this.specs?.host || !this.specs?.botType) app().loadPage('mm')
   266        }
   267      })
   268  
   269      this.placementsChart = new PlacementsChart(page.placementsChart)
   270      this.approveTokenForm = new TokenApprovalForm(page.approveTokenForm, () => { this.submitBotType() })
   271      this.walletAddrForm = new DepositAddress(page.walletAddrForm)
   272      this.cexConfigForm = new CEXConfigurationForm(page.cexConfigForm, (cexName: string) => this.cexConfigured(cexName))
   273      page.quotePane = page.basePane.cloneNode(true) as PageElement
   274      page.assetPaneBox.appendChild(page.quotePane)
   275      this.basePane = new AssetPane(this, page.basePane)
   276      this.quotePane = new AssetPane(this, page.quotePane)
   277  
   278      app().headerSpace.appendChild(page.mmTitle)
   279  
   280      setOptionTemplates(page)
   281      Doc.cleanTemplates(
   282        page.orderOptTmpl, page.booleanOptTmpl, page.rangeOptTmpl, page.placementRowTmpl,
   283        page.oracleTmpl, page.cexOptTmpl, page.arbBttnTmpl, page.marketRowTmpl, page.needRegTmpl
   284      )
   285      page.basePane.removeAttribute('id') // don't remove from layout
   286  
   287      Doc.bind(page.resetButton, 'click', () => { this.setOriginalValues() })
   288      Doc.bind(page.updateButton, 'click', () => { this.saveSettings() })
   289      Doc.bind(page.createButton, 'click', async () => { this.saveSettings() })
   290      Doc.bind(page.deleteBttn, 'click', () => { this.delete() })
   291      bindForm(page.botTypeForm, page.botTypeSubmit, () => { this.submitBotType() })
   292      Doc.bind(page.noMarketBttn, 'click', () => { this.showMarketSelectForm() })
   293      Doc.bind(page.botTypeHeader, 'click', () => { this.reshowBotTypeForm() })
   294      Doc.bind(page.botTypeChangeMarket, 'click', () => { this.showMarketSelectForm() })
   295      Doc.bind(page.marketHeader, 'click', () => { this.showMarketSelectForm() })
   296      Doc.bind(page.marketFilterInput, 'input', () => { this.sortMarketRows() })
   297      Doc.bind(page.cexRebalanceCheckbox, 'change', () => { this.autoRebalanceChanged() })
   298      Doc.bind(page.switchToAdvanced, 'click', () => { this.showAdvancedConfig() })
   299      Doc.bind(page.switchToQuickConfig, 'click', () => { this.switchToQuickConfig() })
   300      Doc.bind(page.qcMatchBuffer, 'change', () => { this.matchBufferChanged() })
   301      Doc.bind(page.switchToUSDPerSide, 'click', () => { this.changeSideCommitmentDialog() })
   302      Doc.bind(page.switchToLotsPerLevel, 'click', () => { this.changeSideCommitmentDialog() })
   303      // Gap Strategy
   304      Doc.bind(page.gapStrategySelect, 'change', () => {
   305        if (!page.gapStrategySelect.value) return
   306        const gapStrategy = page.gapStrategySelect.value
   307        this.clearPlacements(this.updatedConfig.gapStrategy)
   308        this.loadCachedPlacements(gapStrategy)
   309        this.updatedConfig.gapStrategy = gapStrategy
   310        this.setGapFactorLabels(gapStrategy)
   311        this.updateModifiedMarkers()
   312      })
   313  
   314      // Buy/Sell placements
   315      Doc.bind(page.addBuyPlacementBtn, 'click', () => {
   316        this.addPlacement(true, null)
   317        page.addBuyPlacementLots.value = ''
   318        page.addBuyPlacementGapFactor.value = ''
   319        this.updateModifiedMarkers()
   320        this.placementsChart.render()
   321        this.updateAllocations()
   322      })
   323      Doc.bind(page.addSellPlacementBtn, 'click', () => {
   324        this.addPlacement(false, null)
   325        page.addSellPlacementLots.value = ''
   326        page.addSellPlacementGapFactor.value = ''
   327        this.updateModifiedMarkers()
   328        this.placementsChart.render()
   329        this.updateAllocations()
   330      })
   331  
   332      this.driftTolerance = new NumberInput(page.driftToleranceInput, {
   333        prec: defaultDriftTolerance.prec - 2, // converting to percent for display
   334        sigFigs: true,
   335        min: 0,
   336        changed: (rawV: number) => {
   337          const { minV, range, prec } = defaultDriftTolerance
   338          const [v] = toFourSigFigs(rawV / 100, prec)
   339          this.driftToleranceSlider.setValue((v - minV) / range)
   340          this.updatedConfig.driftTolerance = v
   341        }
   342      })
   343  
   344      this.driftToleranceSlider = new MiniSlider(page.driftToleranceSlider, (r: number) => {
   345        const { minV, range, prec } = defaultDriftTolerance
   346        const [v] = toFourSigFigs(minV + r * range, prec)
   347        this.updatedConfig.driftTolerance = v
   348        this.driftTolerance.setValue(v * 100)
   349      })
   350  
   351      this.orderPersistence = new NumberInput(page.orderPersistence, {
   352        changed: (v: number) => {
   353          const { minV, range } = defaultOrderPersistence
   354          this.updatedConfig.orderPersistence = v
   355          this.orderPersistenceSlider.setValue((v - minV) / range)
   356        }
   357      })
   358  
   359      this.orderPersistenceSlider = new MiniSlider(page.orderPersistenceSlider, (r: number) => {
   360        const { minV, range, prec } = defaultOrderPersistence
   361        const rawV = minV + r * range
   362        const [v] = toPrecision(rawV, prec)
   363        this.updatedConfig.orderPersistence = v
   364        this.orderPersistence.setValue(v)
   365      })
   366  
   367      this.qcProfit = new NumberInput(page.qcProfit, {
   368        prec: defaultProfit.prec - 2, // converting to percent
   369        sigFigs: true,
   370        min: defaultProfit.minV * 100,
   371        changed: (vPct: number) => {
   372          const { minV, range } = defaultProfit
   373          const v = vPct / 100
   374          this.updatedConfig.profit = v
   375          page.profitInput.value = this.qcProfit.input.value
   376          this.qcProfitSlider.setValue((v - minV) / range)
   377          this.quickConfigUpdated()
   378        }
   379      })
   380  
   381      this.qcProfitSlider = new MiniSlider(page.qcProfitSlider, (r: number) => {
   382        const { minV, range, prec } = defaultProfit
   383        const [v] = toFourSigFigs((minV + r * range) * 100, prec)
   384        this.updatedConfig.profit = v / 100
   385        this.qcProfit.setValue(v)
   386        page.profitInput.value = this.qcProfit.input.value
   387        this.quickConfigUpdated()
   388      })
   389  
   390      this.qcLevelSpacing = new NumberInput(page.qcLevelSpacing, {
   391        prec: defaultLevelSpacing.prec - 2, // converting to percent
   392        sigFigs: true,
   393        min: defaultLevelSpacing.minV * 100,
   394        changed: (vPct: number) => {
   395          const { minV, range } = defaultLevelSpacing
   396          this.qcLevelSpacingSlider.setValue((vPct / 100 - minV) / range)
   397          this.quickConfigUpdated()
   398        }
   399      })
   400  
   401      this.qcLevelSpacingSlider = new MiniSlider(page.qcLevelSpacingSlider, (r: number) => {
   402        const { minV, range } = defaultLevelSpacing
   403        this.qcLevelSpacing.setValue(minV + r * range * 100)
   404        this.quickConfigUpdated()
   405      })
   406  
   407      this.qcMatchBuffer = new NumberInput(page.qcMatchBuffer, {
   408        prec: defaultMatchBuffer.prec - 2, // converting to percent
   409        sigFigs: true,
   410        min: defaultMatchBuffer.minV * 100,
   411        changed: (vPct: number) => {
   412          const { minV, range } = defaultMatchBuffer
   413          this.qcMatchBufferSlider.setValue((vPct / 100 - minV) / range)
   414          this.quickConfigUpdated()
   415        }
   416      })
   417  
   418      this.qcMatchBufferSlider = new MiniSlider(page.qcMatchBufferSlider, (r: number) => {
   419        const { minV, range } = defaultMatchBuffer
   420        this.qcMatchBuffer.setValue(minV + r * range * 100)
   421        this.quickConfigUpdated()
   422      })
   423  
   424      this.qcLevelsPerSide = new IncrementalInput(page.qcLevelsPerSide, {
   425        prec: defaultLevelsPerSide.prec,
   426        min: defaultLevelsPerSide.minV,
   427        inc: defaultLevelsPerSide.inc,
   428        changed: (v: number) => {
   429          this.qcUSDPerSide.setValue(this.lotSizeUSD() * v * this.qcLotsPerLevel.value())
   430          this.quickConfigUpdated()
   431        }
   432      })
   433  
   434      this.qcLotsPerLevel = new IncrementalInput(page.qcLotsPerLevel, {
   435        prec: defaultLotsPerLevel.prec,
   436        min: defaultLotsPerLevel.minV,
   437        inc: 1, // set showQuickConfig
   438        changed: (v: number) => {
   439          this.qcUSDPerSide.setValue(this.lotSizeUSD() * v * this.qcLevelsPerSide.value())
   440          page.qcUSDPerSideEcho.textContent = this.qcUSDPerSide.input.value as string
   441          this.quickConfigUpdated()
   442        },
   443        set: (v: number) => {
   444          const [, s] = toFourSigFigs(v * this.qcLevelsPerSide.value() * this.lotSizeUSD(), 2)
   445          page.qcUSDPerSideEcho.textContent = s
   446          page.qcLotsPerLevelEcho.textContent = s
   447        }
   448      })
   449  
   450      this.qcUSDPerSide = new IncrementalInput(page.qcUSDPerSide, {
   451        prec: defaultUSDPerSide.prec,
   452        min: 1, // changed by showQuickConfig
   453        inc: 1, // changed by showQuickConfig
   454        changed: (v: number) => {
   455          this.qcLotsPerLevel.setValue(v / this.qcLevelsPerSide.value() / this.lotSizeUSD())
   456          page.qcLotsPerLevelEcho.textContent = this.qcLotsPerLevel.input.value as string
   457          this.quickConfigUpdated()
   458        },
   459        set: (v: number, s: string) => {
   460          page.qcUSDPerSideEcho.textContent = s
   461          page.qcLotsPerLevelEcho.textContent = String(Math.round(v / this.lotSizeUSD()))
   462        }
   463      })
   464  
   465      const maybeSubmitBuyRow = (e: KeyboardEvent) => {
   466        if (e.key !== 'Enter') return
   467        if (
   468          !isNaN(parseFloat(page.addBuyPlacementGapFactor.value || '')) &&
   469          !isNaN(parseFloat(page.addBuyPlacementLots.value || ''))
   470        ) {
   471          page.addBuyPlacementBtn.click()
   472        }
   473      }
   474      Doc.bind(page.addBuyPlacementGapFactor, 'keyup', (e: KeyboardEvent) => { maybeSubmitBuyRow(e) })
   475      Doc.bind(page.addBuyPlacementLots, 'keyup', (e: KeyboardEvent) => { maybeSubmitBuyRow(e) })
   476  
   477      const maybeSubmitSellRow = (e: KeyboardEvent) => {
   478        if (e.key !== 'Enter') return
   479        if (
   480          !isNaN(parseFloat(page.addSellPlacementGapFactor.value || '')) &&
   481          !isNaN(parseFloat(page.addSellPlacementLots.value || ''))
   482        ) {
   483          page.addSellPlacementBtn.click()
   484        }
   485      }
   486      Doc.bind(page.addSellPlacementGapFactor, 'keyup', (e: KeyboardEvent) => { maybeSubmitSellRow(e) })
   487      Doc.bind(page.addSellPlacementLots, 'keyup', (e: KeyboardEvent) => { maybeSubmitSellRow(e) })
   488  
   489      Doc.bind(page.profitInput, 'change', () => {
   490        Doc.hide(page.profitInputErr)
   491        const showError = (errID: string) => {
   492          Doc.show(page.profitInputErr)
   493          page.profitInputErr.textContent = intl.prep(errID)
   494        }
   495        const profit = parseFloat(page.profitInput.value || '') / 100
   496        if (isNaN(profit)) return showError(intl.ID_INVALID_VALUE)
   497        if (profit === 0) return showError(intl.ID_NO_ZERO)
   498        this.updatedConfig.profit = profit
   499        this.updateModifiedMarkers()
   500      })
   501  
   502      this.botTypeSelectors = Doc.applySelector(page.botTypeForm, '[data-bot-type]')
   503      for (const div of this.botTypeSelectors) {
   504        Doc.bind(div, 'click', () => {
   505          if (div.classList.contains('disabled')) return
   506          Doc.hide(page.botTypeErr)
   507          page.cexSelection.classList.toggle('disabled', div.dataset.botType === botTypeBasicMM)
   508          this.setBotTypeSelected(div.dataset.botType as string)
   509        })
   510      }
   511  
   512      this.newWalletForm = new NewWalletForm(
   513        page.newWalletForm,
   514        async () => {
   515          await app().fetchUser()
   516          this.submitBotType()
   517        }
   518      )
   519  
   520      app().registerNoteFeeder({
   521        balance: (note: BalanceNote) => { this.handleBalanceNote(note) }
   522      })
   523  
   524      this.initialize(specs)
   525    }
   526  
   527    unload () {
   528      this.forms.exit()
   529    }
   530  
   531    async initialize (specs?: BotSpecs) {
   532      this.setupCEXes()
   533      this.initializeMarketRows()
   534  
   535      const isRefresh = specs && Object.keys(specs).length === 0
   536      if (isRefresh) specs = State.fetchLocal(specLK)
   537      if (!specs || !app().walletMap[specs.baseID] || !app().walletMap[specs.quoteID]) {
   538        this.showMarketSelectForm()
   539        return
   540      }
   541  
   542      // If we have specs specifying only a market, make sure the cex name and
   543      // bot type are set.
   544      if (specs && !specs.botType) {
   545        const botCfg = liveBotConfig(specs.host, specs.baseID, specs.quoteID)
   546        specs.cexName = botCfg?.cexName ?? ''
   547        specs.botType = botTypeBasicMM
   548        if (botCfg?.arbMarketMakingConfig) specs.botType = botTypeArbMM
   549        else if (botCfg?.simpleArbConfig) specs.botType = botTypeBasicArb
   550      }
   551  
   552      // Must be a reconfig.
   553      this.specs = specs
   554      await this.fetchCEXBalances(specs)
   555      this.configureUI()
   556    }
   557  
   558    async configureUI () {
   559      const { page, specs } = this
   560      const { host, baseID, quoteID, cexName, botType } = specs
   561  
   562      const [{ symbol: baseSymbol, token: baseToken }, { symbol: quoteSymbol, token: quoteToken }] = [app().assets[baseID], app().assets[quoteID]]
   563      this.mktID = `${baseSymbol}_${quoteSymbol}`
   564      Doc.hide(
   565        page.botSettingsContainer, page.marketBox, page.updateButton, page.resetButton,
   566        page.createButton, page.noMarket, page.missingFiatRates
   567      )
   568  
   569      if ([baseID, quoteID, baseToken?.parentID ?? baseID, quoteToken?.parentID ?? quoteID].some((assetID: number) => !app().fiatRatesMap[assetID])) {
   570        Doc.show(page.missingFiatRates)
   571        return
   572      }
   573  
   574      Doc.show(page.marketLoading)
   575      State.storeLocal(specLK, specs)
   576  
   577      const mmStatus = app().mmStatus
   578      const viewOnly = isViewOnly(specs, mmStatus)
   579      let botCfg = liveBotConfig(host, baseID, quoteID)
   580      if (botCfg) {
   581        const oldBotType = botCfg.arbMarketMakingConfig ? botTypeArbMM : botCfg.basicMarketMakingConfig ? botTypeBasicMM : botTypeBasicArb
   582        if (oldBotType !== botType) botCfg = undefined
   583      }
   584      Doc.setVis(botCfg, page.deleteBttnBox)
   585  
   586      const oldCfg = this.originalConfig = Object.assign({}, defaultMarketMakingConfig, {
   587        disabled: viewOnly,
   588        baseOptions: this.defaultWalletOptions(baseID),
   589        quoteOptions: this.defaultWalletOptions(quoteID),
   590        buyPlacements: [],
   591        sellPlacements: [],
   592        baseConfig: Object.assign({}, defaultBotAssetConfig),
   593        quoteConfig: Object.assign({}, defaultBotAssetConfig)
   594      }) as ConfigState
   595  
   596      if (botCfg) {
   597        const { basicMarketMakingConfig: mmCfg, arbMarketMakingConfig: arbMMCfg, simpleArbConfig: arbCfg, uiConfig: { cexRebalance } } = botCfg
   598        this.creatingNewBot = false
   599        // This is kinda sloppy, but we'll copy any relevant issues from the
   600        // old config into the originalConfig.
   601        const idx = oldCfg as { [k: string]: any } // typescript
   602        for (const [k, v] of Object.entries(botCfg)) if (idx[k] !== undefined) idx[k] = v
   603  
   604        oldCfg.baseConfig = Object.assign({}, defaultBotAssetConfig, botCfg.uiConfig.baseConfig)
   605        oldCfg.quoteConfig = Object.assign({}, defaultBotAssetConfig, botCfg.uiConfig.quoteConfig)
   606        oldCfg.baseOptions = botCfg.baseWalletOptions || {}
   607        oldCfg.quoteOptions = botCfg.quoteWalletOptions || {}
   608        oldCfg.cexRebalance = cexRebalance
   609  
   610        if (mmCfg) {
   611          oldCfg.buyPlacements = mmCfg.buyPlacements
   612          oldCfg.sellPlacements = mmCfg.sellPlacements
   613          oldCfg.driftTolerance = mmCfg.driftTolerance
   614          oldCfg.gapStrategy = mmCfg.gapStrategy
   615        } else if (arbMMCfg) {
   616          const { buyPlacements, sellPlacements } = arbMMCfg
   617          oldCfg.buyPlacements = Array.from(buyPlacements, (p: ArbMarketMakingPlacement) => { return { lots: p.lots, gapFactor: p.multiplier } })
   618          oldCfg.sellPlacements = Array.from(sellPlacements, (p: ArbMarketMakingPlacement) => { return { lots: p.lots, gapFactor: p.multiplier } })
   619          oldCfg.profit = arbMMCfg.profit
   620          oldCfg.driftTolerance = arbMMCfg.driftTolerance
   621          oldCfg.orderPersistence = arbMMCfg.orderPersistence
   622        } else if (arbCfg) {
   623          // TODO: expose maxActiveArbs
   624          oldCfg.profit = arbCfg.profitTrigger
   625          oldCfg.orderPersistence = arbCfg.numEpochsLeaveOpen
   626          oldCfg.simpleArbLots = botCfg.uiConfig.simpleArbLots ?? 1
   627        }
   628        Doc.setVis(!viewOnly, page.updateButton, page.resetButton)
   629      } else {
   630        this.creatingNewBot = true
   631        Doc.setVis(!viewOnly, page.createButton)
   632      }
   633  
   634      // Now that we've updated the originalConfig, we'll copy it.
   635      this.updatedConfig = JSON.parse(JSON.stringify(oldCfg))
   636  
   637      switch (botType) {
   638        case botTypeBasicMM:
   639          page.botTypeDisplay.textContent = intl.prep(intl.ID_BOTTYPE_BASIC_MM)
   640          break
   641        case botTypeArbMM:
   642          page.botTypeDisplay.textContent = intl.prep(intl.ID_BOTTYPE_ARB_MM)
   643          break
   644        case botTypeBasicArb:
   645          page.botTypeDisplay.textContent = intl.prep(intl.ID_BOTTYPE_SIMPLE_ARB)
   646      }
   647  
   648      setMarketElements(document.body, baseID, quoteID, host)
   649      Doc.setVis(botType !== botTypeBasicArb, page.driftToleranceBox, page.switchToAdvanced)
   650      Doc.setVis(Boolean(cexName), ...Doc.applySelector(document.body, '[data-cex-show]'))
   651  
   652      Doc.setVis(viewOnly, page.viewOnlyRunning)
   653      Doc.setVis(cexName, page.cexRebalanceSettings)
   654      if (cexName) setCexElements(document.body, cexName)
   655  
   656      await this.fetchMarketReport()
   657  
   658      const lotSizeUSD = this.lotSizeUSD()
   659      this.lotsPerLevelIncrement = Math.round(Math.max(1, defaultLotsPerLevel.usdIncrement / lotSizeUSD))
   660      this.qcLotsPerLevel.inc = this.lotsPerLevelIncrement
   661      this.qcUSDPerSide.inc = this.lotsPerLevelIncrement * lotSizeUSD
   662      this.qcUSDPerSide.min = lotSizeUSD
   663  
   664      this.basePane.setAsset(baseID, false)
   665      this.quotePane.setAsset(quoteID, true)
   666      const { marketReport: { baseFiatRate } } = this
   667      this.placementsChart.setMarket({ cexName: cexName as string, botType, baseFiatRate, dict: this.updatedConfig })
   668  
   669      // If this is a new bot, show the quick config form.
   670      const isQuickPlacements = !botCfg || this.isQuickPlacements(this.updatedConfig.buyPlacements, this.updatedConfig.sellPlacements)
   671      const gapStrategy = botCfg?.basicMarketMakingConfig?.gapStrategy ?? GapStrategyPercentPlus
   672      page.gapStrategySelect.value = gapStrategy
   673      if (botType === botTypeBasicArb || (isQuickPlacements && gapStrategy === GapStrategyPercentPlus)) this.showQuickConfig()
   674      else this.showAdvancedConfig()
   675  
   676      this.setOriginalValues()
   677  
   678      Doc.hide(page.marketLoading)
   679      Doc.show(page.botSettingsContainer, page.marketBox)
   680    }
   681  
   682    initializeMarketRows () {
   683      this.marketRows = []
   684      Doc.empty(this.page.marketSelect)
   685      for (const { host, markets, assets, auth: { effectiveTier, pendingStrength } } of Object.values(app().exchanges)) {
   686        if (effectiveTier + pendingStrength === 0) {
   687          const { needRegTmpl, needRegBox } = this.page
   688          const bttn = needRegTmpl.cloneNode(true) as PageElement
   689          const tmpl = Doc.parseTemplate(bttn)
   690          Doc.bind(bttn, 'click', () => { app().loadPage('register', { host, backTo: 'mmsettings' }) })
   691          tmpl.host.textContent = host
   692          needRegBox.appendChild(bttn)
   693          Doc.show(needRegBox)
   694          continue
   695        }
   696        for (const { name, baseid: baseID, quoteid: quoteID, spot, basesymbol: baseSymbol, quotesymbol: quoteSymbol } of Object.values(markets)) {
   697          if (!app().assets[baseID] || !app().assets[quoteID]) continue
   698          const tr = this.page.marketRowTmpl.cloneNode(true) as PageElement
   699          const tmpl = Doc.parseTemplate(tr)
   700          const mr = { tr, tmpl, host: host, name, baseID, quoteID, spot: spot, arbs: [] } as MarketRow
   701          this.marketRows.push(mr)
   702          this.page.marketSelect.appendChild(tr)
   703          tmpl.baseIcon.src = Doc.logoPath(baseSymbol)
   704          tmpl.quoteIcon.src = Doc.logoPath(quoteSymbol)
   705          tmpl.baseSymbol.appendChild(Doc.symbolize(assets[baseID], true))
   706          tmpl.quoteSymbol.appendChild(Doc.symbolize(assets[quoteID], true))
   707          tmpl.host.textContent = host
   708          const cexHasMarket = this.cexMarketSupportFilter(baseID, quoteID)
   709          for (const [cexName, dinfo] of Object.entries(CEXDisplayInfos)) {
   710            if (cexHasMarket(cexName)) {
   711              const img = this.page.arbBttnTmpl.cloneNode(true) as PageElement
   712              img.src = dinfo.logo
   713              tmpl.arbs.appendChild(img)
   714              mr.arbs.push(cexName)
   715            }
   716          }
   717          Doc.bind(tr, 'click', () => { this.showBotTypeForm(host, baseID, quoteID) })
   718        }
   719      }
   720      if (this.marketRows.length === 0) {
   721        const { marketSelectionTable, marketFilterBox, noMarkets } = this.page
   722        Doc.hide(marketSelectionTable, marketFilterBox)
   723        Doc.show(noMarkets)
   724      } else Doc.hide(this.page.noMarkets)
   725      const fiatRates = app().fiatRatesMap
   726      this.marketRows.sort((a: MarketRow, b: MarketRow) => {
   727        let [volA, volB] = [a.spot?.vol24 ?? 0, b.spot?.vol24 ?? 0]
   728        if (fiatRates[a.baseID] && fiatRates[b.baseID]) {
   729          volA *= fiatRates[a.baseID]
   730          volB *= fiatRates[b.baseID]
   731        }
   732        return volB - volA
   733      })
   734    }
   735  
   736    runningBotInventory (assetID: number) {
   737      return runningBotInventory(assetID)
   738    }
   739  
   740    adjustedBalances (baseWallet: WalletState, quoteWallet: WalletState) {
   741      const { cexBaseBalance, cexQuoteBalance } = this
   742      const [bInv, qInv] = [this.runningBotInventory(baseWallet.assetID), this.runningBotInventory(quoteWallet.assetID)]
   743      const [cexBaseAvail, cexQuoteAvail] = [(cexBaseBalance?.available || 0) - bInv.cex.total, (cexQuoteBalance?.available || 0) - qInv.cex.total]
   744      const [dexBaseAvail, dexQuoteAvail] = [baseWallet.balance.available - bInv.dex.total, quoteWallet.balance.available - qInv.dex.total]
   745      const baseAvail = dexBaseAvail + cexBaseAvail
   746      const quoteAvail = dexQuoteAvail + cexQuoteAvail
   747      return { baseAvail, quoteAvail, dexBaseAvail, dexQuoteAvail, cexBaseAvail, cexQuoteAvail }
   748    }
   749  
   750    lotSizeUSD () {
   751      const { specs: { host, baseID }, mktID, marketReport: { baseFiatRate } } = this
   752      const xc = app().exchanges[host]
   753      const market = xc.markets[mktID]
   754      const { lotsize: lotSize } = market
   755      const { unitInfo: ui } = app().assets[baseID]
   756      return lotSize / ui.conventional.conversionFactor * baseFiatRate
   757    }
   758  
   759    /*
   760      * marketStuff is just a bunch of useful properties for the current specs
   761      * gathered in one place and with preferable names.
   762      */
   763    marketStuff () {
   764      const {
   765        page, specs: { host, baseID, quoteID, cexName, botType }, basePane, quotePane,
   766        marketReport: { baseFiatRate, quoteFiatRate, baseFees, quoteFees },
   767        lotsPerLevelIncrement, updatedConfig: cfg, originalConfig: oldCfg, mktID
   768      } = this
   769      const { symbol: baseSymbol, unitInfo: bui } = app().assets[baseID]
   770      const { symbol: quoteSymbol, unitInfo: qui } = app().assets[quoteID]
   771      const xc = app().exchanges[host]
   772      const market = xc.markets[mktID]
   773      const { lotsize: lotSize, spot } = market
   774      const lotSizeUSD = lotSize / bui.conventional.conversionFactor * baseFiatRate
   775      const atomicRate = 1 / bui.conventional.conversionFactor * baseFiatRate / quoteFiatRate * qui.conventional.conversionFactor
   776      const xcRate = {
   777        conv: quoteFiatRate / baseFiatRate,
   778        atomic: atomicRate,
   779        msg: Math.round(atomicRate * OrderUtil.RateEncodingFactor), // unadjusted
   780        spot
   781      }
   782  
   783      let [dexBaseLots, dexQuoteLots] = [cfg.simpleArbLots, cfg.simpleArbLots]
   784      if (botType !== botTypeBasicArb) {
   785        dexBaseLots = this.updatedConfig.sellPlacements.reduce((lots: number, p: OrderPlacement) => lots + p.lots, 0)
   786        dexQuoteLots = this.updatedConfig.buyPlacements.reduce((lots: number, p: OrderPlacement) => lots + p.lots, 0)
   787      }
   788      const quoteLot = calculateQuoteLot(lotSize, baseID, quoteID, spot)
   789      const walletStuff = this.walletStuff()
   790      const { baseFeeAssetID, quoteFeeAssetID, baseIsAccountLocker, quoteIsAccountLocker } = walletStuff
   791  
   792      const { commit, fees } = feesAndCommit(
   793        baseID, quoteID, baseFees, quoteFees, lotSize, dexBaseLots, dexQuoteLots,
   794        baseFeeAssetID, quoteFeeAssetID, baseIsAccountLocker, quoteIsAccountLocker,
   795        cfg.baseConfig.orderReservesFactor, cfg.quoteConfig.orderReservesFactor
   796      )
   797  
   798      return {
   799        page, cfg, oldCfg, host, xc, baseID, quoteID, botType, cexName, baseFiatRate, quoteFiatRate,
   800        xcRate, baseSymbol, quoteSymbol, mktID, lotSize, lotSizeUSD, lotsPerLevelIncrement,
   801        quoteLot, commit, basePane, quotePane, fees, ...walletStuff
   802      }
   803    }
   804  
   805    walletStuff () {
   806      const { specs: { baseID, quoteID } } = this
   807      const [baseWallet, quoteWallet] = [app().walletMap[baseID], app().walletMap[quoteID]]
   808      const [{ token: baseToken, unitInfo: bui }, { token: quoteToken, unitInfo: qui }] = [app().assets[baseID], app().assets[quoteID]]
   809      const baseFeeAssetID = baseToken ? baseToken.parentID : baseID
   810      const quoteFeeAssetID = quoteToken ? quoteToken.parentID : quoteID
   811      const [baseFeeUI, quoteFeeUI] = [app().assets[baseFeeAssetID].unitInfo, app().assets[quoteFeeAssetID].unitInfo]
   812      const traitAccountLocker = 1 << 14
   813      const baseIsAccountLocker = (baseWallet.traits & traitAccountLocker) > 0
   814      const quoteIsAccountLocker = (quoteWallet.traits & traitAccountLocker) > 0
   815      return {
   816        baseWallet, quoteWallet, baseFeeUI, quoteFeeUI, baseToken, quoteToken,
   817        bui, qui, baseFeeAssetID, quoteFeeAssetID, baseIsAccountLocker, quoteIsAccountLocker,
   818        ...this.adjustedBalances(baseWallet, quoteWallet)
   819      }
   820    }
   821  
   822    showAdvancedConfig () {
   823      const { page } = this
   824      Doc.show(page.advancedConfig)
   825      Doc.hide(page.quickConfig)
   826      this.placementsChart.render()
   827    }
   828  
   829    isQuickPlacements (buyPlacements: OrderPlacement[], sellPlacements: OrderPlacement[]) {
   830      if (buyPlacements.length === 0 || buyPlacements.length !== sellPlacements.length) return false
   831      for (let i = 0; i < buyPlacements.length; i++) {
   832        if (buyPlacements[i].gapFactor !== sellPlacements[i].gapFactor) return false
   833        if (buyPlacements[i].lots !== sellPlacements[i].lots) return false
   834      }
   835      return true
   836    }
   837  
   838    switchToQuickConfig () {
   839      const { cfg, botType, lotSizeUSD } = this.marketStuff()
   840      const { buyPlacements: buys, sellPlacements: sells } = cfg
   841      // If we have both buys and sells, get the best approximation quick config
   842      // approximation.
   843      if (buys.length > 0 && sells.length > 0) {
   844        const bestBuy = buys.reduce((prev: OrderPlacement, curr: OrderPlacement) => curr.gapFactor < prev.gapFactor ? curr : prev)
   845        const bestSell = sells.reduce((prev: OrderPlacement, curr: OrderPlacement) => curr.gapFactor < prev.gapFactor ? curr : prev)
   846        const placementCount = buys.length + sells.length
   847        const levelsPerSide = Math.max(1, Math.floor((placementCount) / 2))
   848        if (botType === botTypeBasicMM) {
   849          cfg.profit = (bestBuy.gapFactor + bestSell.gapFactor) / 2
   850          const worstBuy = buys.reduce((prev: OrderPlacement, curr: OrderPlacement) => curr.gapFactor > prev.gapFactor ? curr : prev)
   851          const worstSell = sells.reduce((prev: OrderPlacement, curr: OrderPlacement) => curr.gapFactor > prev.gapFactor ? curr : prev)
   852          const range = ((worstBuy.gapFactor - bestBuy.gapFactor) + (worstSell.gapFactor - bestSell.gapFactor)) / 2
   853          const inc = range / (levelsPerSide - 1)
   854          this.qcProfit.setValue(cfg.profit * 100)
   855          this.qcProfitSlider.setValue((cfg.profit - defaultProfit.minV) / defaultProfit.range)
   856          this.qcLevelSpacing.setValue(inc * 100)
   857          this.qcLevelSpacingSlider.setValue((inc - defaultLevelSpacing.minV) / defaultLevelSpacing.range)
   858        } else if (botType === botTypeArbMM) {
   859          const multSum = buys.reduce((v: number, p: OrderPlacement) => v + p.gapFactor, 0) + sells.reduce((v: number, p: OrderPlacement) => v + p.gapFactor, 0)
   860          const buffer = ((multSum / placementCount) - 1) || defaultMatchBuffer.value
   861          this.qcMatchBuffer.setValue(buffer * 100)
   862          this.qcMatchBufferSlider.setValue((buffer - defaultMatchBuffer.minV) / defaultMatchBuffer.range)
   863        }
   864        const lots = buys.reduce((v: number, p: OrderPlacement) => v + p.lots, 0) + sells.reduce((v: number, p: OrderPlacement) => v + p.lots, 0)
   865        const lotsPerLevel = Math.max(1, Math.round(lots / 2 / levelsPerSide))
   866        this.qcLotsPerLevel.setValue(lotsPerLevel)
   867        this.qcUSDPerSide.setValue(lotsPerLevel * levelsPerSide * lotSizeUSD)
   868        this.qcLevelsPerSide.setValue(levelsPerSide)
   869      } else if (botType === botTypeBasicArb) {
   870        this.qcLotsPerLevel.setValue(cfg.simpleArbLots)
   871      }
   872      this.showQuickConfig()
   873      this.quickConfigUpdated()
   874    }
   875  
   876    showQuickConfig () {
   877      const { page, lotSizeUSD, botType, lotsPerLevelIncrement } = this.marketStuff()
   878  
   879      if (!this.qcLevelsPerSide.input.value) {
   880        this.qcLevelsPerSide.setValue(defaultLevelsPerSide.value)
   881        this.qcUSDPerSide.setValue(defaultLevelsPerSide.value * (this.qcLotsPerLevel.value() || lotsPerLevelIncrement) * lotSizeUSD)
   882      }
   883      if (!this.qcLotsPerLevel.input.value) {
   884        this.qcLotsPerLevel.setValue(lotsPerLevelIncrement)
   885        this.qcUSDPerSide.setValue(lotSizeUSD * lotsPerLevelIncrement * this.qcLevelsPerSide.value())
   886      }
   887      if (!page.qcLevelSpacing.value) {
   888        this.qcLevelSpacing.setValue(defaultLevelSpacing.value * 100)
   889        this.qcLevelSpacingSlider.setValue((defaultLevelSpacing.value - defaultLevelSpacing.minV) / defaultLevelSpacing.range)
   890      }
   891      if (!page.qcMatchBuffer.value) page.qcMatchBuffer.value = String(defaultMatchBuffer.value * 100)
   892  
   893      Doc.hide(page.advancedConfig)
   894      Doc.show(page.quickConfig)
   895  
   896      this.showInputsForBot(botType)
   897    }
   898  
   899    showInputsForBot (botType: string) {
   900      const { page, opts: { usingUSDPerSide } } = this
   901      Doc.hide(
   902        page.matchMultiplierBox, page.placementsChartBox, page.placementChartLegend,
   903        page.lotsPerLevelLabel, page.levelSpacingBox, page.arbLotsLabel, page.qcLevelPerSideBox
   904      )
   905      Doc.setVis(usingUSDPerSide, page.qcUSDPerSideBox)
   906      Doc.setVis(!usingUSDPerSide, page.qcLotsBox)
   907      switch (botType) {
   908        case botTypeArbMM:
   909          Doc.show(
   910            page.qcLevelPerSideBox, page.matchMultiplierBox, page.placementsChartBox,
   911            page.placementChartLegend, page.lotsPerLevelLabel
   912          )
   913          break
   914        case botTypeBasicMM:
   915          Doc.show(
   916            page.qcLevelPerSideBox, page.levelSpacingBox, page.placementsChartBox,
   917            page.lotsPerLevelLabel
   918          )
   919          break
   920        case botTypeBasicArb:
   921          Doc.show(page.arbLotsLabel)
   922      }
   923    }
   924  
   925    quickConfigUpdated () {
   926      const { page, cfg, botType, cexName } = this.marketStuff()
   927  
   928      Doc.hide(page.qcError)
   929      const setError = (msg: string) => {
   930        page.qcError.textContent = msg
   931        Doc.show(page.qcError)
   932      }
   933  
   934      const levelsPerSide = botType === botTypeBasicArb ? 1 : this.qcLevelsPerSide.value()
   935      if (isNaN(levelsPerSide)) {
   936        setError('invalid value for levels per side')
   937      }
   938  
   939      const lotsPerLevel = this.qcLotsPerLevel.value()
   940      if (isNaN(lotsPerLevel)) {
   941        setError('invalid value for lots per level')
   942      }
   943  
   944      const profit = parseFloat(page.qcProfit.value ?? '') / 100
   945      if (isNaN(profit)) {
   946        setError('invalid value for profit')
   947      }
   948  
   949      const levelSpacing = botType === botTypeBasicMM ? parseFloat(page.qcLevelSpacing.value ?? '') / 100 : 0
   950      if (isNaN(levelSpacing)) {
   951        setError('invalid value for level spacing')
   952      }
   953  
   954      const matchBuffer = botType === botTypeArbMM ? parseFloat(page.qcMatchBuffer.value ?? '') / 100 : 0
   955      if (isNaN(matchBuffer)) {
   956        setError('invalid value for match buffer')
   957      }
   958      const multiplier = matchBuffer + 1
   959  
   960      const levelSpacingDisabled = levelsPerSide === 1
   961      page.levelSpacingBox.classList.toggle('disabled', levelSpacingDisabled)
   962      page.qcLevelSpacing.disabled = levelSpacingDisabled
   963      cfg.simpleArbLots = lotsPerLevel
   964  
   965      if (botType !== botTypeBasicArb) {
   966        this.clearPlacements(cexName ? arbMMRowCacheKey : cfg.gapStrategy)
   967        for (let levelN = 0; levelN < levelsPerSide; levelN++) {
   968          const placement = { lots: lotsPerLevel } as OrderPlacement
   969          placement.gapFactor = botType === botTypeBasicMM ? profit + levelSpacing * levelN : multiplier
   970          cfg.buyPlacements.push(placement)
   971          cfg.sellPlacements.push(placement)
   972          // Add rows in the advanced config table.
   973          this.addPlacement(true, placement)
   974          this.addPlacement(false, placement)
   975        }
   976  
   977        this.placementsChart.render()
   978      }
   979  
   980      this.updateAllocations()
   981    }
   982  
   983    updateAllocations () {
   984      this.updateBaseAllocations()
   985      this.updateQuoteAllocations()
   986    }
   987  
   988    updateBaseAllocations () {
   989      const { commit, lotSize, basePane, fees } = this.marketStuff()
   990  
   991      basePane.updateInventory(commit.dex.base.lots, commit.dex.quote.lots, lotSize, commit.dex.base.val, commit.cex.base.val, fees.base)
   992      basePane.updateCommitTotal()
   993    }
   994  
   995    updateQuoteAllocations () {
   996      const { commit, quoteLot: lotSize, quotePane, fees } = this.marketStuff()
   997  
   998      quotePane.updateInventory(commit.dex.quote.lots, commit.dex.base.lots, lotSize, commit.dex.quote.val, commit.cex.quote.val, fees.quote)
   999      quotePane.updateCommitTotal()
  1000    }
  1001  
  1002    matchBufferChanged () {
  1003      const { page } = this
  1004      page.qcMatchBuffer.value = Math.max(0, parseFloat(page.qcMatchBuffer.value ?? '') || defaultMatchBuffer.value * 100).toFixed(2)
  1005      this.quickConfigUpdated()
  1006    }
  1007  
  1008    showAddress (assetID: number) {
  1009      this.walletAddrForm.setAsset(assetID)
  1010      this.forms.show(this.page.walletAddrForm)
  1011    }
  1012  
  1013    changeSideCommitmentDialog () {
  1014      const { page, opts } = this
  1015      opts.usingUSDPerSide = !opts.usingUSDPerSide
  1016      Doc.setVis(opts.usingUSDPerSide, page.qcUSDPerSideBox)
  1017      Doc.setVis(!opts.usingUSDPerSide, page.qcLotsBox)
  1018    }
  1019  
  1020    async showBotTypeForm (host: string, baseID: number, quoteID: number, botType?: string, configuredCEX?: string) {
  1021      const { page } = this
  1022      this.formSpecs = { host, baseID, quoteID, botType: '' }
  1023      const viewOnly = isViewOnly(this.formSpecs, app().mmStatus)
  1024      if (viewOnly) {
  1025        const botCfg = liveBotConfig(host, baseID, quoteID)
  1026        const specs = this.specs = this.formSpecs
  1027        switch (true) {
  1028          case Boolean(botCfg?.simpleArbConfig):
  1029            specs.botType = botTypeBasicArb
  1030            break
  1031          case Boolean(botCfg?.arbMarketMakingConfig):
  1032            specs.botType = botTypeArbMM
  1033            break
  1034          default:
  1035            specs.botType = botTypeBasicMM
  1036        }
  1037        specs.cexName = botCfg?.cexName
  1038        await this.fetchCEXBalances(this.formSpecs)
  1039        await this.configureUI()
  1040        this.forms.close()
  1041        return
  1042      }
  1043      setMarketElements(page.botTypeForm, baseID, quoteID, host)
  1044      Doc.empty(page.botTypeBaseSymbol, page.botTypeQuoteSymbol)
  1045      const [b, q] = [app().assets[baseID], app().assets[quoteID]]
  1046      page.botTypeBaseSymbol.appendChild(Doc.symbolize(b, true))
  1047      page.botTypeQuoteSymbol.appendChild(Doc.symbolize(q, true))
  1048      for (const div of this.botTypeSelectors) div.classList.remove('selected')
  1049      for (const { div } of Object.values(this.formCexes)) div.classList.remove('selected')
  1050      this.setCEXAvailability(baseID, quoteID)
  1051      Doc.hide(page.noCexesConfigured, page.noCexMarket, page.noCexMarketConfigureMore, page.botTypeErr)
  1052      const cexHasMarket = this.cexMarketSupportFilter(baseID, quoteID)
  1053      const supportingCexes: Record<string, CEXConfig> = {}
  1054      for (const cex of Object.values(app().mmStatus.cexes)) {
  1055        if (cexHasMarket(cex.config.name)) supportingCexes[cex.config.name] = cex.config
  1056      }
  1057      const nCexes = Object.keys(supportingCexes).length
  1058      const arbEnabled = nCexes > 0
  1059      for (const div of this.botTypeSelectors) div.classList.toggle('disabled', div.dataset.botType !== botTypeBasicMM && !arbEnabled)
  1060      if (Object.keys(app().mmStatus.cexes).length === 0) {
  1061        Doc.show(page.noCexesConfigured)
  1062        this.setBotTypeSelected(botTypeBasicMM)
  1063      } else {
  1064        const lastBots = (State.fetchLocal(lastBotsLK) || {}) as Record<string, BotSpecs>
  1065        const lastBot = lastBots[`${baseID}_${quoteID}_${host}`]
  1066        let cex: CEXConfig | undefined
  1067        botType = botType ?? (lastBot ? lastBot.botType : botTypeArbMM)
  1068        if (botType !== botTypeBasicMM) {
  1069          // Four ways to auto-select a cex.
  1070          // 1. Coming back from the cex configuration form.
  1071          if (configuredCEX) cex = supportingCexes[configuredCEX]
  1072          // 2. We have a saved configuration.
  1073          if (!cex && lastBot) cex = supportingCexes[lastBot.cexName ?? '']
  1074          // 3. The last exchange that the user selected.
  1075          if (!cex) {
  1076            const lastCEX = State.fetchLocal(lastArbExchangeLK)
  1077            if (lastCEX) cex = supportingCexes[lastCEX]
  1078          }
  1079          // 4. Any supporting cex.
  1080          if (!cex && nCexes > 0) cex = Object.values(supportingCexes)[0]
  1081        }
  1082        if (cex) {
  1083          page.cexSelection.classList.remove('disabled')
  1084          this.setBotTypeSelected(botType ?? (lastBot ? lastBot.botType : botTypeArbMM))
  1085          this.selectFormCEX(cex.name)
  1086        } else {
  1087          page.cexSelection.classList.add('disabled')
  1088          Doc.show(page.noCexMarket)
  1089          this.setBotTypeSelected(botTypeBasicMM)
  1090          // If there are unconfigured cexes, show configureMore message.
  1091          const unconfigured = Object.keys(CEXDisplayInfos).filter((cexName: string) => !app().mmStatus.cexes[cexName])
  1092          const allConfigured = unconfigured.length === 0 || (unconfigured.length === 1 && (unconfigured[0] === 'Binance' || unconfigured[0] === 'BinanceUS'))
  1093          if (!allConfigured) Doc.show(page.noCexMarketConfigureMore)
  1094        }
  1095      }
  1096  
  1097      Doc.show(page.cexSelection)
  1098      // Check if we have any cexes configured.
  1099      this.forms.show(page.botTypeForm)
  1100    }
  1101  
  1102    reshowBotTypeForm () {
  1103      if (isViewOnly(this.specs, app().mmStatus)) this.showMarketSelectForm()
  1104      const { baseID, quoteID, host, cexName, botType } = this.specs
  1105      this.showBotTypeForm(host, baseID, quoteID, botType, cexName)
  1106    }
  1107  
  1108    setBotTypeSelected (selectedType: string) {
  1109      const { formSpecs: { baseID, quoteID, host }, botTypeSelectors, formCexes } = this
  1110      for (const { classList, dataset: { botType } } of botTypeSelectors) classList.toggle('selected', botType === selectedType)
  1111      // If we don't have a cex selected, attempt to select one
  1112      if (selectedType === botTypeBasicMM) return
  1113      const mmStatus = app().mmStatus
  1114      if (Object.keys(mmStatus.cexes).length === 0) return
  1115      const cexHasMarket = this.cexMarketSupportFilter(baseID, quoteID)
  1116      // If there is one currently selected and it supports this market, leave it.
  1117      const selecteds = Object.values(formCexes).filter((cex: cexButton) => cex.div.classList.contains('selected'))
  1118      if (selecteds.length && cexHasMarket(selecteds[0].name)) return
  1119      // See if we have a saved configuration.
  1120      const lastBots = (State.fetchLocal(lastBotsLK) || {}) as Record<string, BotSpecs>
  1121      const lastBot = lastBots[`${baseID}_${quoteID}_${host}`]
  1122      if (lastBot) {
  1123        const cex = mmStatus.cexes[lastBot.cexName ?? '']
  1124        if (cex && cexHasMarket(cex.config.name)) {
  1125          this.selectFormCEX(cex.config.name)
  1126          return
  1127        }
  1128      }
  1129      // 2. The last exchange that the user selected.
  1130      const lastCEX = State.fetchLocal(lastArbExchangeLK)
  1131      if (lastCEX) {
  1132        const cex = mmStatus.cexes[lastCEX]
  1133        if (cex && cexHasMarket(cex.config.name)) {
  1134          this.selectFormCEX(cex.config.name)
  1135          return
  1136        }
  1137      }
  1138      // 3. Any supporting cex.
  1139      const cexes = Object.values(mmStatus.cexes).filter((cex: MMCEXStatus) => cexHasMarket(cex.config.name))
  1140      if (cexes.length) this.selectFormCEX(cexes[0].config.name)
  1141    }
  1142  
  1143    showMarketSelectForm () {
  1144      this.page.marketFilterInput.value = ''
  1145      this.sortMarketRows()
  1146      this.forms.show(this.page.marketSelectForm)
  1147    }
  1148  
  1149    sortMarketRows () {
  1150      const page = this.page
  1151      const filter = page.marketFilterInput.value?.toLowerCase()
  1152      Doc.empty(page.marketSelect)
  1153      for (const mr of this.marketRows) {
  1154        mr.tr.classList.remove('selected')
  1155        if (filter && !mr.name.includes(filter)) continue
  1156        page.marketSelect.appendChild(mr.tr)
  1157      }
  1158    }
  1159  
  1160    handleBalanceNote (n: BalanceNote) {
  1161      this.approveTokenForm.handleBalanceNote(n)
  1162      if (!this.marketReport) return
  1163      const { baseID, quoteID, quoteToken, baseToken } = this.marketStuff()
  1164      if (n.assetID === baseID || n.assetID === baseToken?.parentID) {
  1165        this.basePane.updateBalances()
  1166      } else if (n.assetID === quoteID || n.assetID === quoteToken?.parentID) {
  1167        this.quotePane.updateBalances()
  1168      }
  1169    }
  1170  
  1171    autoRebalanceChanged () {
  1172      const { page, updatedConfig: cfg } = this
  1173      cfg.cexRebalance = page.cexRebalanceCheckbox?.checked ?? false
  1174      this.updateAllocations()
  1175    }
  1176  
  1177    async submitBotType () {
  1178      const loaded = app().loading(this.page.botTypeForm)
  1179      try {
  1180        await this.submitBotWithValidation()
  1181      } finally {
  1182        loaded()
  1183      }
  1184    }
  1185  
  1186    async submitBotWithValidation () {
  1187      // check for wallets
  1188      const { page, forms, formSpecs: { baseID, quoteID, host } } = this
  1189  
  1190      if (!app().walletMap[baseID]) {
  1191        this.newWalletForm.setAsset(baseID)
  1192        forms.show(this.page.newWalletForm)
  1193        return
  1194      }
  1195      if (!app().walletMap[quoteID]) {
  1196        this.newWalletForm.setAsset(quoteID)
  1197        forms.show(this.page.newWalletForm)
  1198        return
  1199      }
  1200      // Are tokens approved?
  1201      const [bApproval, qApproval] = tokenAssetApprovalStatuses(host, app().assets[baseID], app().assets[quoteID])
  1202      if (bApproval === ApprovalStatus.NotApproved) {
  1203        this.approveTokenForm.setAsset(baseID, host)
  1204        forms.show(page.approveTokenForm)
  1205        return
  1206      }
  1207      if (qApproval === ApprovalStatus.NotApproved) {
  1208        this.approveTokenForm.setAsset(quoteID, host)
  1209        forms.show(page.approveTokenForm)
  1210        return
  1211      }
  1212  
  1213      const { botTypeSelectors } = this
  1214      const selecteds = botTypeSelectors.filter((div: PageElement) => div.classList.contains('selected'))
  1215      if (selecteds.length < 1) {
  1216        page.botTypeErr.textContent = intl.prep(intl.ID_NO_BOTTYPE)
  1217        Doc.show(page.botTypeErr)
  1218        return
  1219      }
  1220      const botType = this.formSpecs.botType = selecteds[0].dataset.botType ?? ''
  1221      if (botType !== botTypeBasicMM) {
  1222        const selecteds = Object.values(this.formCexes).filter((cex: cexButton) => cex.div.classList.contains('selected'))
  1223        if (selecteds.length < 1) {
  1224          page.botTypeErr.textContent = intl.prep(intl.ID_NO_CEX)
  1225          Doc.show(page.botTypeErr)
  1226          return
  1227        }
  1228        const cexName = selecteds[0].name
  1229        this.formSpecs.cexName = cexName
  1230        await this.fetchCEXBalances(this.formSpecs)
  1231      }
  1232  
  1233      this.specs = this.formSpecs
  1234  
  1235      this.configureUI()
  1236      this.forms.close()
  1237    }
  1238  
  1239    async fetchCEXBalances (specs: BotSpecs) {
  1240      const { page } = this
  1241      const { baseID, quoteID, cexName, botType } = specs
  1242      if (botType === botTypeBasicMM || !cexName) return
  1243  
  1244      try {
  1245        // This won't work if we implement live reconfiguration, because locked
  1246        // funds would need to be considered.
  1247        this.cexBaseBalance = await MM.cexBalance(cexName, baseID)
  1248      } catch (e) {
  1249        page.botTypeErr.textContent = intl.prep(intl.ID_CEXBALANCE_ERR, { cexName, assetID: String(baseID), err: String(e) })
  1250        Doc.show(page.botTypeErr)
  1251        throw e
  1252      }
  1253  
  1254      try {
  1255        this.cexQuoteBalance = await MM.cexBalance(cexName, quoteID)
  1256      } catch (e) {
  1257        page.botTypeErr.textContent = intl.prep(intl.ID_CEXBALANCE_ERR, { cexName, assetID: String(quoteID), err: String(e) })
  1258        Doc.show(page.botTypeErr)
  1259        throw e
  1260      }
  1261    }
  1262  
  1263    defaultWalletOptions (assetID: number): Record<string, string> {
  1264      const walletDef = app().currentWalletDefinition(assetID)
  1265      if (!walletDef.multifundingopts) {
  1266        return {}
  1267      }
  1268      const options: Record<string, string> = {}
  1269      for (const opt of walletDef.multifundingopts) {
  1270        if (opt.quoteAssetOnly && assetID !== this.specs.quoteID) {
  1271          continue
  1272        }
  1273        options[opt.key] = `${opt.default}`
  1274      }
  1275      return options
  1276    }
  1277  
  1278    /*
  1279     * updateModifiedMarkers checks each of the input elements on the page and
  1280     * if the current value does not match the original value (since the last
  1281     * save), then the input will have a colored border.
  1282     */
  1283    updateModifiedMarkers () {
  1284      if (this.creatingNewBot) return
  1285      const { page, originalConfig: oldCfg, updatedConfig: newCfg } = this
  1286  
  1287      // Gap strategy input
  1288      const gapStrategyModified = oldCfg.gapStrategy !== newCfg.gapStrategy
  1289      page.gapStrategySelect.classList.toggle('modified', gapStrategyModified)
  1290  
  1291      const profitModified = oldCfg.profit !== newCfg.profit
  1292      page.profitInput.classList.toggle('modified', profitModified)
  1293  
  1294      // Buy placements Input
  1295      let buyPlacementsModified = false
  1296      if (oldCfg.buyPlacements.length !== newCfg.buyPlacements.length) {
  1297        buyPlacementsModified = true
  1298      } else {
  1299        for (let i = 0; i < oldCfg.buyPlacements.length; i++) {
  1300          if (oldCfg.buyPlacements[i].lots !== newCfg.buyPlacements[i].lots ||
  1301            oldCfg.buyPlacements[i].gapFactor !== newCfg.buyPlacements[i].gapFactor) {
  1302            buyPlacementsModified = true
  1303            break
  1304          }
  1305        }
  1306      }
  1307      page.buyPlacementsTableWrapper.classList.toggle('modified', buyPlacementsModified)
  1308  
  1309      // Sell placements input
  1310      let sellPlacementsModified = false
  1311      if (oldCfg.sellPlacements.length !== newCfg.sellPlacements.length) {
  1312        sellPlacementsModified = true
  1313      } else {
  1314        for (let i = 0; i < oldCfg.sellPlacements.length; i++) {
  1315          if (oldCfg.sellPlacements[i].lots !== newCfg.sellPlacements[i].lots ||
  1316            oldCfg.sellPlacements[i].gapFactor !== newCfg.sellPlacements[i].gapFactor) {
  1317            sellPlacementsModified = true
  1318            break
  1319          }
  1320        }
  1321      }
  1322      page.sellPlacementsTableWrapper.classList.toggle('modified', sellPlacementsModified)
  1323    }
  1324  
  1325    /*
  1326     * gapFactorHeaderUnit returns the header on the placements table and the
  1327     * units in the gap factor rows needed for each gap strategy.
  1328     */
  1329    gapFactorHeaderUnit (gapStrategy: string): [string, string] {
  1330      switch (gapStrategy) {
  1331        case GapStrategyMultiplier:
  1332          return ['Multiplier', 'x']
  1333        case GapStrategyAbsolute:
  1334        case GapStrategyAbsolutePlus: {
  1335          const rateUnit = `${app().assets[this.specs.quoteID].symbol}/${app().assets[this.specs.baseID].symbol}`
  1336          return ['Rate', rateUnit]
  1337        }
  1338        case GapStrategyPercent:
  1339        case GapStrategyPercentPlus:
  1340          return ['Percent', '%']
  1341        default:
  1342          throw new Error(`Unknown gap strategy ${gapStrategy}`)
  1343      }
  1344    }
  1345  
  1346    /*
  1347     * checkGapFactorRange returns an error string if the value input for a
  1348     * gap factor is valid for the currently selected gap strategy.
  1349     */
  1350    checkGapFactorRange (gapFactor: string, value: number): (string | null) {
  1351      switch (gapFactor) {
  1352        case GapStrategyMultiplier:
  1353          if (value < 1 || value > 100) {
  1354            return 'Multiplier must be between 1 and 100'
  1355          }
  1356          return null
  1357        case GapStrategyAbsolute:
  1358        case GapStrategyAbsolutePlus:
  1359          if (value <= 0) {
  1360            return 'Rate must be greater than 0'
  1361          }
  1362          return null
  1363        case GapStrategyPercent:
  1364        case GapStrategyPercentPlus:
  1365          if (value <= 0 || value > 10) {
  1366            return 'Percent must be between 0 and 10'
  1367          }
  1368          return null
  1369        default: {
  1370          throw new Error(`Unknown gap factor ${gapFactor}`)
  1371        }
  1372      }
  1373    }
  1374  
  1375    /*
  1376     * convertGapFactor converts between the displayed gap factor in the
  1377     * placement tables and the number that is passed to the market maker.
  1378     * For gap strategies that involve a percentage it converts between the
  1379     * decimal value required by the backend and a percentage displayed to
  1380     * the user.
  1381     */
  1382    convertGapFactor (gapFactor: number, gapStrategy: string, toDisplay: boolean): number {
  1383      switch (gapStrategy) {
  1384        case GapStrategyMultiplier:
  1385        case GapStrategyAbsolute:
  1386        case GapStrategyAbsolutePlus:
  1387          return gapFactor
  1388        case GapStrategyPercent:
  1389        case GapStrategyPercentPlus:
  1390          if (toDisplay) {
  1391            return gapFactor * 100
  1392          }
  1393          return gapFactor / 100
  1394        default:
  1395          throw new Error(`Unknown gap factor ${gapStrategy}`)
  1396      }
  1397    }
  1398  
  1399    /*
  1400     * addPlacement adds a row to a placement table. This is called both when
  1401     * the page is initially loaded, and when the "add" button is pressed on
  1402     * the placement table. initialLoadPlacement is non-nil if this is being
  1403     * called on the initial load.
  1404     */
  1405    addPlacement (isBuy: boolean, initialLoadPlacement: OrderPlacement | null, gapStrategy?: string) {
  1406      const { page, updatedConfig: cfg } = this
  1407  
  1408      let tableBody: PageElement = page.sellPlacementsTableBody
  1409      let addPlacementRow: PageElement = page.addSellPlacementRow
  1410      let lotsElement: PageElement = page.addSellPlacementLots
  1411      let gapFactorElement: PageElement = page.addSellPlacementGapFactor
  1412      let errElement: PageElement = page.sellPlacementsErr
  1413      if (isBuy) {
  1414        tableBody = page.buyPlacementsTableBody
  1415        addPlacementRow = page.addBuyPlacementRow
  1416        lotsElement = page.addBuyPlacementLots
  1417        gapFactorElement = page.addBuyPlacementGapFactor
  1418        errElement = page.buyPlacementsErr
  1419      }
  1420  
  1421      Doc.hide(errElement)
  1422  
  1423      // updateArrowVis updates the visibility of the move up/down arrows in
  1424      // each row of the placement table. The up arrow is not shown on the
  1425      // top row, and the down arrow is not shown on the bottom row. They
  1426      // are all hidden if market making is running.
  1427      const updateArrowVis = () => {
  1428        for (let i = 0; i < tableBody.children.length - 1; i++) {
  1429          const row = Doc.parseTemplate(tableBody.children[i] as HTMLElement)
  1430          Doc.setVis(i !== 0, row.upBtn)
  1431          Doc.setVis(i !== tableBody.children.length - 2, row.downBtn)
  1432        }
  1433      }
  1434  
  1435      Doc.hide(errElement)
  1436      const setErr = (err: string) => {
  1437        errElement.textContent = err
  1438        Doc.show(errElement)
  1439      }
  1440  
  1441      let lots: number
  1442      let actualGapFactor: number
  1443      let displayedGapFactor: number
  1444      if (!gapStrategy) gapStrategy = this.specs.cexName ? GapStrategyMultiplier : cfg.gapStrategy
  1445      const placements = isBuy ? cfg.buyPlacements : cfg.sellPlacements
  1446      const unit = this.gapFactorHeaderUnit(gapStrategy)[1]
  1447      if (initialLoadPlacement) {
  1448        lots = initialLoadPlacement.lots
  1449        actualGapFactor = initialLoadPlacement.gapFactor
  1450        displayedGapFactor = this.convertGapFactor(actualGapFactor, gapStrategy, true)
  1451      } else {
  1452        lots = parseInt(lotsElement.value || '0')
  1453        displayedGapFactor = parseFloat(gapFactorElement.value || '0')
  1454        actualGapFactor = this.convertGapFactor(displayedGapFactor, gapStrategy, false)
  1455        if (lots === 0) {
  1456          setErr('Lots must be greater than 0')
  1457          return
  1458        }
  1459  
  1460        const gapFactorErr = this.checkGapFactorRange(gapStrategy, displayedGapFactor)
  1461        if (gapFactorErr) {
  1462          setErr(gapFactorErr)
  1463          return
  1464        }
  1465  
  1466        if (placements.find((placement) => placement.gapFactor === actualGapFactor)
  1467        ) {
  1468          setErr('Duplicate placement')
  1469          return
  1470        }
  1471  
  1472        placements.push({ lots, gapFactor: actualGapFactor })
  1473      }
  1474  
  1475      const newRow = page.placementRowTmpl.cloneNode(true) as PageElement
  1476      const newRowTmpl = Doc.parseTemplate(newRow)
  1477      newRowTmpl.priority.textContent = `${tableBody.children.length}`
  1478      newRowTmpl.lots.textContent = `${lots}`
  1479      newRowTmpl.gapFactor.textContent = `${displayedGapFactor} ${unit}`
  1480      Doc.bind(newRowTmpl.removeBtn, 'click', () => {
  1481        const index = placements.findIndex((placement) => {
  1482          return placement.lots === lots && placement.gapFactor === actualGapFactor
  1483        })
  1484        if (index === -1) return
  1485        placements.splice(index, 1)
  1486        newRow.remove()
  1487        updateArrowVis()
  1488        this.updateModifiedMarkers()
  1489        this.placementsChart.render()
  1490        this.updateAllocations()
  1491      })
  1492  
  1493      Doc.bind(newRowTmpl.upBtn, 'click', () => {
  1494        const index = placements.findIndex((p: OrderPlacement) => p.lots === lots && p.gapFactor === actualGapFactor)
  1495        if (index === 0) return
  1496        const prevPlacement = placements[index - 1]
  1497        placements[index - 1] = placements[index]
  1498        placements[index] = prevPlacement
  1499        newRowTmpl.priority.textContent = `${index}`
  1500        newRow.remove()
  1501        tableBody.insertBefore(newRow, tableBody.children[index - 1])
  1502        const movedDownTmpl = Doc.parseTemplate(
  1503          tableBody.children[index] as HTMLElement
  1504        )
  1505        movedDownTmpl.priority.textContent = `${index + 1}`
  1506        updateArrowVis()
  1507        this.updateModifiedMarkers()
  1508      })
  1509  
  1510      Doc.bind(newRowTmpl.downBtn, 'click', () => {
  1511        const index = placements.findIndex((p) => p.lots === lots && p.gapFactor === actualGapFactor)
  1512        if (index === placements.length - 1) return
  1513        const nextPlacement = placements[index + 1]
  1514        placements[index + 1] = placements[index]
  1515        placements[index] = nextPlacement
  1516        newRowTmpl.priority.textContent = `${index + 2}`
  1517        newRow.remove()
  1518        tableBody.insertBefore(newRow, tableBody.children[index + 1])
  1519        const movedUpTmpl = Doc.parseTemplate(
  1520          tableBody.children[index] as HTMLElement
  1521        )
  1522        movedUpTmpl.priority.textContent = `${index + 1}`
  1523        updateArrowVis()
  1524        this.updateModifiedMarkers()
  1525      })
  1526  
  1527      tableBody.insertBefore(newRow, addPlacementRow)
  1528      updateArrowVis()
  1529    }
  1530  
  1531    setArbMMLabels () {
  1532      this.page.buyGapFactorHdr.textContent = intl.prep(intl.ID_MATCH_BUFFER)
  1533      this.page.sellGapFactorHdr.textContent = intl.prep(intl.ID_MATCH_BUFFER)
  1534    }
  1535  
  1536    /*
  1537     * setGapFactorLabels sets the headers on the gap factor column of each
  1538     * placement table.
  1539     */
  1540    setGapFactorLabels (gapStrategy: string) {
  1541      const page = this.page
  1542      const header = this.gapFactorHeaderUnit(gapStrategy)[0]
  1543      page.buyGapFactorHdr.textContent = header
  1544      page.sellGapFactorHdr.textContent = header
  1545      Doc.hide(page.percentPlusInfo, page.percentInfo, page.absolutePlusInfo, page.absoluteInfo, page.multiplierInfo)
  1546      switch (gapStrategy) {
  1547        case 'percent-plus':
  1548          return Doc.show(page.percentPlusInfo)
  1549        case 'percent':
  1550          return Doc.show(page.percentInfo)
  1551        case 'absolute-plus':
  1552          return Doc.show(page.absolutePlusInfo)
  1553        case 'absolute':
  1554          return Doc.show(page.absoluteInfo)
  1555        case 'multiplier':
  1556          return Doc.show(page.multiplierInfo)
  1557      }
  1558    }
  1559  
  1560    clearPlacements (cacheKey: string) {
  1561      const { page, updatedConfig: cfg } = this
  1562      while (page.buyPlacementsTableBody.children.length > 1) {
  1563        page.buyPlacementsTableBody.children[0].remove()
  1564      }
  1565      while (page.sellPlacementsTableBody.children.length > 1) {
  1566        page.sellPlacementsTableBody.children[0].remove()
  1567      }
  1568      this.placementsCache[cacheKey] = [cfg.buyPlacements, cfg.sellPlacements]
  1569      cfg.buyPlacements.splice(0, cfg.buyPlacements.length)
  1570      cfg.sellPlacements.splice(0, cfg.sellPlacements.length)
  1571    }
  1572  
  1573    loadCachedPlacements (cacheKey: string) {
  1574      const c = this.placementsCache[cacheKey]
  1575      if (!c) return
  1576      const { updatedConfig: cfg } = this
  1577      cfg.buyPlacements.splice(0, cfg.buyPlacements.length)
  1578      cfg.sellPlacements.splice(0, cfg.sellPlacements.length)
  1579      cfg.buyPlacements.push(...c[0])
  1580      cfg.sellPlacements.push(...c[1])
  1581      const gapStrategy = cacheKey === arbMMRowCacheKey ? GapStrategyMultiplier : cacheKey
  1582      for (const p of cfg.buyPlacements) this.addPlacement(true, p, gapStrategy)
  1583      for (const p of cfg.sellPlacements) this.addPlacement(false, p, gapStrategy)
  1584    }
  1585  
  1586    /*
  1587     * setOriginalValues sets the updatedConfig field to be equal to the
  1588     * and sets the values displayed buy each field input to be equal
  1589     * to the values since the last save.
  1590     */
  1591    setOriginalValues () {
  1592      const {
  1593        page, originalConfig: oldCfg, updatedConfig: cfg, specs: { cexName, botType }
  1594      } = this
  1595  
  1596      this.clearPlacements(cexName ? arbMMRowCacheKey : cfg.gapStrategy)
  1597  
  1598      const assign = (to: any, from: any) => { // not recursive
  1599        for (const [k, v] of Object.entries(from)) {
  1600          if (Array.isArray(v)) {
  1601            to[k].splice(0, to[k].length)
  1602            for (const i of v) to[k].push(i)
  1603          } else if (typeof v === 'object') Object.assign(to[k], v)
  1604          else to[k] = from[k]
  1605        }
  1606      }
  1607      assign(cfg, JSON.parse(JSON.stringify(oldCfg)))
  1608  
  1609      const tol = cfg.driftTolerance ?? defaultDriftTolerance.value
  1610      this.driftTolerance.setValue(tol * 100)
  1611      this.driftToleranceSlider.setValue(tol / defaultDriftTolerance.maxV)
  1612  
  1613      const persist = cfg.orderPersistence ?? defaultOrderPersistence.value
  1614      this.orderPersistence.setValue(persist)
  1615      this.orderPersistenceSlider.setValue(persist / defaultOrderPersistence.maxV)
  1616  
  1617      const profit = cfg.profit ?? defaultProfit.value
  1618      page.profitInput.value = String(profit * 100)
  1619      this.qcProfit.setValue(profit * 100)
  1620      this.qcProfitSlider.setValue((profit - defaultProfit.minV) / defaultProfit.range)
  1621  
  1622      if (cexName) {
  1623        page.cexRebalanceCheckbox.checked = cfg.cexRebalance
  1624        this.autoRebalanceChanged()
  1625      }
  1626  
  1627      // Gap strategy
  1628      if (!page.gapStrategySelect.options) return
  1629      Array.from(page.gapStrategySelect.options).forEach((opt: HTMLOptionElement) => { opt.selected = opt.value === cfg.gapStrategy })
  1630      this.setGapFactorLabels(cfg.gapStrategy)
  1631  
  1632      if (botType === botTypeBasicMM) {
  1633        Doc.show(page.gapStrategyBox)
  1634        Doc.hide(page.profitSelectorBox, page.orderPersistenceBox)
  1635        this.setGapFactorLabels(page.gapStrategySelect.value || '')
  1636      } else if (cexName && app().mmStatus.cexes[cexName]) {
  1637        Doc.hide(page.gapStrategyBox)
  1638        Doc.show(page.profitSelectorBox, page.orderPersistenceBox)
  1639        this.setArbMMLabels()
  1640      }
  1641  
  1642      // Buy/Sell placements
  1643      oldCfg.buyPlacements.forEach((p) => { this.addPlacement(true, p) })
  1644      oldCfg.sellPlacements.forEach((p) => { this.addPlacement(false, p) })
  1645  
  1646      this.basePane.setupWalletSettings()
  1647      this.quotePane.setupWalletSettings()
  1648  
  1649      this.updateModifiedMarkers()
  1650      if (Doc.isDisplayed(page.quickConfig)) this.switchToQuickConfig()
  1651    }
  1652  
  1653    /*
  1654     * validateFields validates configuration values and optionally shows error
  1655     * messages.
  1656     */
  1657    validateFields (showErrors: boolean): boolean {
  1658      let ok = true
  1659      const {
  1660        page, specs: { botType },
  1661        updatedConfig: { sellPlacements, buyPlacements, profit }
  1662      } = this
  1663      const setError = (errEl: PageElement, errID: string) => {
  1664        ok = false
  1665        if (!showErrors) return
  1666        errEl.textContent = intl.prep(errID)
  1667        Doc.show(errEl)
  1668      }
  1669      if (showErrors) {
  1670        Doc.hide(
  1671          page.buyPlacementsErr, page.sellPlacementsErr, page.profitInputErr
  1672        )
  1673      }
  1674      if (botType !== botTypeBasicArb && buyPlacements.length + sellPlacements.length === 0) {
  1675        setError(page.buyPlacementsErr, intl.ID_NO_PLACEMENTS)
  1676        setError(page.sellPlacementsErr, intl.ID_NO_PLACEMENTS)
  1677      }
  1678      if (botType !== botTypeBasicMM) {
  1679        if (isNaN(profit)) setError(page.profitInputErr, intl.ID_INVALID_VALUE)
  1680        else if (profit === 0) setError(page.profitInputErr, intl.ID_NO_ZERO)
  1681      }
  1682      return ok
  1683    }
  1684  
  1685    /*
  1686     * saveSettings updates the settings in the backend, and sets the originalConfig
  1687     * to be equal to the updatedConfig.
  1688     */
  1689    async saveSettings () {
  1690      // Make a copy and delete either the basic mm config or the arb-mm config,
  1691      // depending on whether a cex is selected.
  1692      if (!this.validateFields(true)) return
  1693      const { cfg, baseID, quoteID, host, botType, cexName } = this.marketStuff()
  1694  
  1695      const botCfg: BotConfig = {
  1696        host: host,
  1697        baseID: baseID,
  1698        quoteID: quoteID,
  1699        cexName: cexName ?? '',
  1700        uiConfig: {
  1701          simpleArbLots: cfg.simpleArbLots,
  1702          baseConfig: cfg.baseConfig,
  1703          quoteConfig: cfg.quoteConfig,
  1704          cexRebalance: cfg.cexRebalance
  1705        },
  1706        baseWalletOptions: cfg.baseOptions,
  1707        quoteWalletOptions: cfg.quoteOptions
  1708      }
  1709      switch (botType) {
  1710        case botTypeBasicMM:
  1711          botCfg.basicMarketMakingConfig = this.basicMMConfig()
  1712          break
  1713        case botTypeArbMM:
  1714          botCfg.arbMarketMakingConfig = this.arbMMConfig()
  1715          break
  1716        case botTypeBasicArb:
  1717          botCfg.simpleArbConfig = this.basicArbConfig()
  1718      }
  1719  
  1720      app().log('mm', 'saving bot config', botCfg)
  1721      await MM.updateBotConfig(botCfg)
  1722      await app().fetchMMStatus()
  1723      this.originalConfig = JSON.parse(JSON.stringify(cfg))
  1724      this.updateModifiedMarkers()
  1725      const lastBots = State.fetchLocal(lastBotsLK) || {}
  1726      lastBots[`${baseID}_${quoteID}_${host}`] = this.specs
  1727      State.storeLocal(lastBotsLK, lastBots)
  1728      if (cexName) State.storeLocal(lastArbExchangeLK, cexName)
  1729      app().loadPage('mm')
  1730    }
  1731  
  1732    async delete () {
  1733      const { page, specs: { host, baseID, quoteID } } = this
  1734      Doc.hide(page.deleteErr)
  1735      const loaded = app().loading(page.botSettingsContainer)
  1736      const resp = await MM.removeBotConfig(host, baseID, quoteID)
  1737      loaded()
  1738      if (!app().checkResponse(resp)) {
  1739        page.deleteErr.textContent = intl.prep(intl.ID_API_ERROR, { msg: resp.msg })
  1740        Doc.show(page.deleteErr)
  1741        return
  1742      }
  1743      await app().fetchMMStatus()
  1744      app().loadPage('mm')
  1745    }
  1746  
  1747    /*
  1748     * arbMMConfig parses the configuration for the arb-mm bot. Only one of
  1749     * arbMMConfig or basicMMConfig should be used when updating the bot
  1750     * configuration. Which is used depends on if the user has configured and
  1751     * selected a CEX or not.
  1752     */
  1753    arbMMConfig (): ArbMarketMakingConfig {
  1754      const { updatedConfig: cfg } = this
  1755      const arbCfg: ArbMarketMakingConfig = {
  1756        buyPlacements: [],
  1757        sellPlacements: [],
  1758        profit: cfg.profit,
  1759        driftTolerance: cfg.driftTolerance,
  1760        orderPersistence: cfg.orderPersistence
  1761      }
  1762      for (const p of cfg.buyPlacements) arbCfg.buyPlacements.push({ lots: p.lots, multiplier: p.gapFactor })
  1763      for (const p of cfg.sellPlacements) arbCfg.sellPlacements.push({ lots: p.lots, multiplier: p.gapFactor })
  1764      return arbCfg
  1765    }
  1766  
  1767    basicArbConfig (): SimpleArbConfig {
  1768      const { updatedConfig: cfg } = this
  1769      const arbCfg: SimpleArbConfig = {
  1770        profitTrigger: cfg.profit,
  1771        maxActiveArbs: 100, // TODO
  1772        numEpochsLeaveOpen: cfg.orderPersistence
  1773      }
  1774      return arbCfg
  1775    }
  1776  
  1777    /*
  1778     * basicMMConfig parses the configuration for the basic marketmaker. Only of
  1779     * of basidMMConfig or arbMMConfig should be used when updating the bot
  1780     * configuration.
  1781     */
  1782    basicMMConfig (): BasicMarketMakingConfig {
  1783      const { updatedConfig: cfg } = this
  1784      const mmCfg: BasicMarketMakingConfig = {
  1785        gapStrategy: cfg.gapStrategy,
  1786        sellPlacements: cfg.sellPlacements,
  1787        buyPlacements: cfg.buyPlacements,
  1788        driftTolerance: cfg.driftTolerance
  1789      }
  1790      return mmCfg
  1791    }
  1792  
  1793    /*
  1794     * fetchOracles fetches the current oracle rates and fiat rates, and displays
  1795     * them on the screen.
  1796     */
  1797    async fetchMarketReport (): Promise<void> {
  1798      const { page, specs: { host, baseID, quoteID } } = this
  1799  
  1800      const res = await MM.report(host, baseID, quoteID)
  1801      Doc.hide(page.oraclesLoading, page.oraclesTable, page.noOracles)
  1802  
  1803      if (!app().checkResponse(res)) {
  1804        page.oraclesErrMsg.textContent = res.msg
  1805        Doc.show(page.oraclesErr)
  1806        return
  1807      }
  1808  
  1809      const r = this.marketReport = res.report as MarketReport
  1810      if (!r.oracles || r.oracles.length === 0) {
  1811        Doc.show(page.noOracles)
  1812      } else {
  1813        Doc.hide(page.noOracles)
  1814        Doc.empty(page.oracles)
  1815        for (const o of r.oracles ?? []) {
  1816          const tr = page.oracleTmpl.cloneNode(true) as PageElement
  1817          page.oracles.appendChild(tr)
  1818          const tmpl = Doc.parseTemplate(tr)
  1819          tmpl.logo.src = 'img/' + o.host + '.png'
  1820          tmpl.host.textContent = ExchangeNames[o.host]
  1821          tmpl.volume.textContent = Doc.formatFourSigFigs(o.usdVol)
  1822          tmpl.price.textContent = Doc.formatFourSigFigs((o.bestBuy + o.bestSell) / 2)
  1823        }
  1824        page.avgPrice.textContent = r.price ? Doc.formatFourSigFigs(r.price) : '0'
  1825        Doc.show(page.oraclesTable)
  1826      }
  1827  
  1828      if (r.baseFiatRate > 0) {
  1829        page.baseFiatRate.textContent = Doc.formatFourSigFigs(r.baseFiatRate)
  1830      } else {
  1831        page.baseFiatRate.textContent = 'N/A'
  1832      }
  1833  
  1834      if (r.quoteFiatRate > 0) {
  1835        page.quoteFiatRate.textContent = Doc.formatFourSigFigs(r.quoteFiatRate)
  1836      } else {
  1837        page.quoteFiatRate.textContent = 'N/A'
  1838      }
  1839      Doc.show(page.fiatRates)
  1840    }
  1841  
  1842    /*
  1843     * handleCEXSubmit handles clicks on the CEX configuration submission button.
  1844     */
  1845    async cexConfigured (cexName: string) {
  1846      const { page, formSpecs: { host, baseID, quoteID } } = this
  1847      const dinfo = CEXDisplayInfos[cexName]
  1848      for (const { baseID, quoteID, tmpl, arbs } of this.marketRows) {
  1849        if (arbs.indexOf(cexName) !== -1) continue
  1850        const cexHasMarket = this.cexMarketSupportFilter(baseID, quoteID)
  1851        if (cexHasMarket(cexName)) {
  1852          const img = page.arbBttnTmpl.cloneNode(true) as PageElement
  1853          img.src = dinfo.logo
  1854          tmpl.arbs.appendChild(img)
  1855          arbs.push(cexName)
  1856        }
  1857      }
  1858      this.setCEXAvailability(baseID, quoteID, cexName)
  1859      this.showBotTypeForm(host, baseID, quoteID, botTypeArbMM, cexName)
  1860    }
  1861  
  1862    /*
  1863     * setupCEXes should be called during initialization.
  1864     */
  1865    setupCEXes () {
  1866      this.formCexes = {}
  1867      for (const name of Object.keys(CEXDisplayInfos)) this.addCEX(name)
  1868    }
  1869  
  1870    /*
  1871     * setCEXAvailability sets the coloring and messaging of the CEX selection
  1872     * buttons.
  1873     */
  1874    setCEXAvailability (baseID: number, quoteID: number, selectedCEX?: string) {
  1875      const cexHasMarket = this.cexMarketSupportFilter(baseID, quoteID)
  1876      for (const { name, div, tmpl } of Object.values(this.formCexes)) {
  1877        const has = cexHasMarket(name)
  1878        const cexStatus = app().mmStatus.cexes[name]
  1879        Doc.hide(tmpl.unavailable, tmpl.needsconfig, tmpl.disconnected)
  1880        Doc.setVis(Boolean(cexStatus), tmpl.reconfig)
  1881        tmpl.logo.classList.remove('greyscal')
  1882        div.classList.toggle('configured', Boolean(cexStatus) && !cexStatus.connectErr)
  1883        if (!cexStatus) {
  1884          Doc.show(tmpl.needsconfig)
  1885        } else if (cexStatus.connectErr) {
  1886          Doc.show(tmpl.disconnected)
  1887        } else if (!has) {
  1888          Doc.show(tmpl.unavailable)
  1889          tmpl.logo.classList.add('greyscal')
  1890        } else if (name === selectedCEX) this.selectFormCEX(name)
  1891      }
  1892    }
  1893  
  1894    selectFormCEX (cexName: string) {
  1895      for (const { name, div } of Object.values(this.formCexes)) {
  1896        div.classList.toggle('selected', name === cexName)
  1897      }
  1898    }
  1899  
  1900    addCEX (cexName: string) {
  1901      const dinfo = CEXDisplayInfos[cexName]
  1902      const div = this.page.cexOptTmpl.cloneNode(true) as PageElement
  1903      const tmpl = Doc.parseTemplate(div)
  1904      tmpl.name.textContent = dinfo.name
  1905      tmpl.logo.src = dinfo.logo
  1906      this.page.cexSelection.appendChild(div)
  1907      this.formCexes[cexName] = { name: cexName, div, tmpl }
  1908      Doc.bind(div, 'click', () => {
  1909        const cexStatus = app().mmStatus.cexes[cexName]
  1910        if (!cexStatus || cexStatus.connectErr) {
  1911          this.showCEXConfigForm(cexName)
  1912          return
  1913        }
  1914        const cex = this.formCexes[cexName]
  1915        if (cex.div.classList.contains('selected')) { // unselect
  1916          for (const cex of Object.values(this.formCexes)) cex.div.classList.remove('selected')
  1917          const { baseID, quoteID } = this.formSpecs
  1918          this.setCEXAvailability(baseID, quoteID)
  1919          return
  1920        }
  1921        for (const cex of Object.values(this.formCexes)) cex.div.classList.toggle('selected', cex.name === cexName)
  1922      })
  1923      Doc.bind(tmpl.reconfig, 'click', (e: MouseEvent) => {
  1924        e.stopPropagation()
  1925        this.showCEXConfigForm(cexName)
  1926      })
  1927    }
  1928  
  1929    showCEXConfigForm (cexName: string) {
  1930      const page = this.page
  1931      this.cexConfigForm.setCEX(cexName)
  1932      this.forms.show(page.cexConfigForm)
  1933    }
  1934  
  1935    /*
  1936     * cexMarketSupportFilter returns a lookup CEXes that have a matching market
  1937     * for the currently selected base and quote assets.
  1938     */
  1939    cexMarketSupportFilter (baseID: number, quoteID: number) {
  1940      const cexes: Record<string, boolean> = {}
  1941      for (const [cexName, cexStatus] of Object.entries(app().mmStatus.cexes)) {
  1942        for (const { baseID: b, quoteID: q } of Object.values(cexStatus.markets ?? [])) {
  1943          if (b === baseID && q === quoteID) {
  1944            cexes[cexName] = true
  1945            break
  1946          }
  1947        }
  1948      }
  1949      return (cexName: string) => Boolean(cexes[cexName])
  1950    }
  1951  }
  1952  
  1953  function isViewOnly (specs: BotSpecs, mmStatus: MarketMakingStatus): boolean {
  1954    const botStatus = mmStatus.bots.find(({ config: cfg }) => cfg.host === specs.host && cfg.baseID === specs.baseID && cfg.quoteID === specs.quoteID)
  1955    return Boolean(botStatus?.running)
  1956  }
  1957  
  1958  const ExchangeNames: Record<string, string> = {
  1959    'binance.com': 'Binance',
  1960    'coinbase.com': 'Coinbase',
  1961    'bittrex.com': 'Bittrex',
  1962    'hitbtc.com': 'HitBTC',
  1963    'exmo.com': 'EXMO'
  1964  }
  1965  
  1966  function tokenAssetApprovalStatuses (host: string, b: SupportedAsset, q: SupportedAsset) {
  1967    let baseAssetApprovalStatus = ApprovalStatus.Approved
  1968    let quoteAssetApprovalStatus = ApprovalStatus.Approved
  1969  
  1970    if (b?.token) {
  1971      const baseAsset = app().assets[b.id]
  1972      const baseVersion = app().exchanges[host].assets[b.id].version
  1973      if (baseAsset?.wallet?.approved && baseAsset.wallet.approved[baseVersion] !== undefined) {
  1974        baseAssetApprovalStatus = baseAsset.wallet.approved[baseVersion]
  1975      }
  1976    }
  1977    if (q?.token) {
  1978      const quoteAsset = app().assets[q.id]
  1979      const quoteVersion = app().exchanges[host].assets[q.id].version
  1980      if (quoteAsset?.wallet?.approved && quoteAsset.wallet.approved[quoteVersion] !== undefined) {
  1981        quoteAssetApprovalStatus = quoteAsset.wallet.approved[quoteVersion]
  1982      }
  1983    }
  1984  
  1985    return [
  1986      baseAssetApprovalStatus,
  1987      quoteAssetApprovalStatus
  1988    ]
  1989  }
  1990  
  1991  class AssetPane {
  1992    pg: MarketMakerSettingsPage
  1993    div: PageElement
  1994    page: Record<string, PageElement>
  1995    assetID: number
  1996    ui: UnitInfo
  1997    walletConfig: Record<string, string>
  1998    feeAssetID: number
  1999    feeUI: UnitInfo
  2000    isQuote: boolean
  2001    isToken: boolean
  2002    lotSize: number // might be quote converted
  2003    lotSizeConv: number
  2004    cfg: BotAssetConfig
  2005    inv: ProjectedAlloc
  2006    nSwapFees: IncrementalInput
  2007    nSwapFeesSlider: MiniSlider
  2008    orderReserves: NumberInput
  2009    orderReservesSlider: MiniSlider
  2010    slippageBuffer: NumberInput
  2011    slippageBufferSlider: MiniSlider
  2012    minTransfer: NumberInput
  2013    minTransferSlider: MiniSlider
  2014  
  2015    constructor (pg: MarketMakerSettingsPage, div: PageElement) {
  2016      this.pg = pg
  2017      this.div = div
  2018      const page = this.page = Doc.parseTemplate(div)
  2019  
  2020      this.nSwapFees = new IncrementalInput(page.nSwapFees, {
  2021        prec: defaultSwapReserves.prec,
  2022        inc: defaultSwapReserves.inc,
  2023        changed: (v: number) => {
  2024          const { minR, range } = defaultSwapReserves
  2025          this.cfg.swapFeeN = v
  2026          this.nSwapFeesSlider.setValue((v - minR) / range)
  2027          this.pg.updateAllocations()
  2028        }
  2029      })
  2030  
  2031      this.nSwapFeesSlider = new MiniSlider(page.nSwapFeesSlider, (r: number) => {
  2032        const { minR, range, prec } = defaultSwapReserves
  2033        const [v] = toPrecision(minR + r * range, prec)
  2034        this.cfg.swapFeeN = v
  2035        this.nSwapFees.setValue(v)
  2036        this.pg.updateAllocations()
  2037      })
  2038      this.orderReserves = new NumberInput(page.orderReservesFactor, {
  2039        prec: defaultOrderReserves.prec,
  2040        min: 0,
  2041        changed: (v: number) => {
  2042          const { minR, range } = defaultOrderReserves
  2043          this.cfg.orderReservesFactor = v
  2044          this.orderReservesSlider.setValue((v - minR) / range)
  2045          this.pg.updateAllocations()
  2046        }
  2047      })
  2048      this.orderReservesSlider = new MiniSlider(page.orderReservesSlider, (r: number) => {
  2049        const { minR, range, prec } = defaultOrderReserves
  2050        const [v] = toPrecision(minR + r * range, prec)
  2051        this.orderReserves.setValue(v)
  2052        this.cfg.orderReservesFactor = v
  2053        this.pg.updateAllocations()
  2054      })
  2055      this.slippageBuffer = new NumberInput(page.slippageBufferFactor, {
  2056        prec: defaultSlippage.prec,
  2057        min: 0,
  2058        changed: (v: number) => {
  2059          const { minR, range } = defaultSlippage
  2060          this.cfg.slippageBufferFactor = v
  2061          this.slippageBufferSlider.setValue((v - minR) / range)
  2062          this.pg.updateAllocations()
  2063        }
  2064      })
  2065      this.slippageBufferSlider = new MiniSlider(page.slippageBufferSlider, (r: number) => {
  2066        const { minR, range, prec } = defaultSlippage
  2067        const [v] = toPrecision(minR + r * range, prec)
  2068        this.slippageBuffer.setValue(minR + r * range)
  2069        this.cfg.slippageBufferFactor = v
  2070        this.pg.updateAllocations()
  2071      })
  2072      this.minTransfer = new NumberInput(page.minTransfer, {
  2073        sigFigs: true,
  2074        min: 0,
  2075        changed: (v: number) => {
  2076          const { cfg } = this
  2077          const totalInventory = this.commit()
  2078          const [minV, maxV] = [this.minTransfer.min, Math.max(this.minTransfer.min * 2, totalInventory)]
  2079          cfg.transferFactor = (v - minV) / (maxV - minV)
  2080          this.minTransferSlider.setValue(cfg.transferFactor)
  2081        }
  2082      })
  2083      this.minTransferSlider = new MiniSlider(page.minTransferSlider, (r: number) => {
  2084        const { cfg } = this
  2085        const totalInventory = this.commit()
  2086        const [minV, maxV] = [this.minTransfer.min, Math.max(this.minTransfer.min, totalInventory)]
  2087        cfg.transferFactor = r
  2088        this.minTransfer.setValue(minV + r * (maxV - minV))
  2089      })
  2090  
  2091      Doc.bind(page.showBalance, 'click', () => { pg.showAddress(this.assetID) })
  2092    }
  2093  
  2094    // lot size can change if this is the quote asset, keep it updated.
  2095    setLotSize (lotSize: number) {
  2096      const { ui } = this
  2097      this.lotSize = lotSize
  2098      this.lotSizeConv = lotSize / ui.conventional.conversionFactor
  2099    }
  2100  
  2101    setAsset (assetID: number, isQuote: boolean) {
  2102      this.assetID = assetID
  2103      this.isQuote = isQuote
  2104      const cfg = this.cfg = isQuote ? this.pg.updatedConfig.quoteConfig : this.pg.updatedConfig.baseConfig
  2105      const { page, div, pg: { specs: { botType, baseID, cexName }, mktID, updatedConfig: { baseOptions, quoteOptions } } } = this
  2106      const { symbol, name, token, unitInfo: ui } = app().assets[assetID]
  2107      this.ui = ui
  2108      this.walletConfig = assetID === baseID ? baseOptions : quoteOptions
  2109      const { conventional: { unit: ticker } } = ui
  2110      this.feeAssetID = token ? token.parentID : assetID
  2111      const { unitInfo: feeUI, name: feeName, symbol: feeSymbol } = app().assets[this.feeAssetID]
  2112      this.feeUI = feeUI
  2113      this.inv = { book: 0, bookingFees: 0, swapFeeReserves: 0, cex: 0, orderReserves: 0, slippageBuffer: 0 }
  2114      this.isToken = Boolean(token)
  2115      Doc.setVis(this.isToken, page.feeTotalBox, page.feeReservesBox, page.feeBalances)
  2116      Doc.setVis(isQuote, page.slippageBufferBox)
  2117      Doc.setSrc(div, '[data-logo]', Doc.logoPath(symbol))
  2118      Doc.setText(div, '[data-name]', name)
  2119      Doc.setText(div, '[data-ticker]', ticker)
  2120      const { conventional: { unit: feeTicker } } = feeUI
  2121      Doc.setText(div, '[data-fee-ticker]', feeTicker)
  2122      Doc.setText(div, '[data-fee-name]', feeName)
  2123      Doc.setSrc(div, '[data-fee-logo]', Doc.logoPath(feeSymbol))
  2124      Doc.setVis(botType !== botTypeBasicMM, page.cexMinInvBox)
  2125      Doc.setVis(botType !== botTypeBasicArb, page.orderReservesBox)
  2126      this.nSwapFees.setValue(cfg.swapFeeN ?? defaultSwapReserves.n)
  2127      this.nSwapFeesSlider.setValue(cfg.swapFeeN / defaultSwapReserves.maxR)
  2128      if (botType !== botTypeBasicArb) {
  2129        const [v] = toPrecision(cfg.orderReservesFactor ?? defaultOrderReserves.factor, defaultOrderReserves.prec)
  2130        this.orderReserves.setValue(v)
  2131        this.orderReservesSlider.setValue((v - defaultOrderReserves.minR) / defaultOrderReserves.range)
  2132      }
  2133      if (botType !== botTypeBasicMM) {
  2134        this.minTransfer.prec = Math.log10(ui.conventional.conversionFactor)
  2135        const mkt = app().mmStatus.cexes[cexName as string].markets[mktID]
  2136        this.minTransfer.min = ((isQuote ? mkt.quoteMinWithdraw : mkt.baseMinWithdraw) / ui.conventional.conversionFactor)
  2137      }
  2138      this.slippageBuffer.setValue(cfg.slippageBufferFactor)
  2139      const { minR, range } = defaultSlippage
  2140      this.slippageBufferSlider.setValue((cfg.slippageBufferFactor - minR) / range)
  2141      this.setupWalletSettings()
  2142      this.updateBalances()
  2143    }
  2144  
  2145    commit () {
  2146      const { inv, isToken } = this
  2147      let commit = inv.book + inv.cex + inv.orderReserves + inv.slippageBuffer
  2148      if (!isToken) commit += inv.bookingFees + inv.swapFeeReserves
  2149      return commit
  2150    }
  2151  
  2152    updateInventory (lots: number, counterLots: number, lotSize: number, dexCommit: number, cexCommit: number, fees: AssetBookingFees) {
  2153      this.setLotSize(lotSize)
  2154      const { page, cfg, lotSizeConv, inv, ui, feeUI, isToken, isQuote, pg: { specs: { cexName, botType } } } = this
  2155      page.bookLots.textContent = String(lots)
  2156      page.bookLotSize.textContent = Doc.formatFourSigFigs(lotSizeConv)
  2157      inv.book = lots * lotSizeConv
  2158      page.bookCommitment.textContent = Doc.formatFourSigFigs(inv.book)
  2159      const feesPerLotConv = fees.bookingFeesPerLot / feeUI.conventional.conversionFactor
  2160      page.bookingFeesPerLot.textContent = Doc.formatFourSigFigs(feesPerLotConv)
  2161      page.swapReservesFactor.textContent = fees.swapReservesFactor.toFixed(2)
  2162      page.bookingFeesLots.textContent = String(lots)
  2163      inv.bookingFees = fees.bookingFees / feeUI.conventional.conversionFactor
  2164      page.bookingFees.textContent = Doc.formatFourSigFigs(inv.bookingFees)
  2165      if (cexName) {
  2166        inv.cex = cexCommit / ui.conventional.conversionFactor
  2167        page.cexMinInv.textContent = Doc.formatFourSigFigs(inv.cex)
  2168      }
  2169      if (botType !== botTypeBasicArb) {
  2170        const totalInventory = Math.max(cexCommit, dexCommit) / ui.conventional.conversionFactor
  2171        page.orderReservesBasis.textContent = Doc.formatFourSigFigs(totalInventory)
  2172        const orderReserves = totalInventory * cfg.orderReservesFactor
  2173        inv.orderReserves = orderReserves
  2174        page.orderReserves.textContent = Doc.formatFourSigFigs(orderReserves)
  2175      }
  2176      if (isToken) {
  2177        const feesPerSwapConv = fees.tokenFeesPerSwap / feeUI.conventional.conversionFactor
  2178        page.feeReservesPerSwap.textContent = Doc.formatFourSigFigs(feesPerSwapConv)
  2179        inv.swapFeeReserves = feesPerSwapConv * cfg.swapFeeN
  2180        page.feeReserves.textContent = Doc.formatFourSigFigs(inv.swapFeeReserves)
  2181      }
  2182      if (isQuote) {
  2183        const basis = inv.book + inv.cex + inv.orderReserves
  2184        page.slippageBufferBasis.textContent = Doc.formatCoinValue(basis * ui.conventional.conversionFactor, ui)
  2185        inv.slippageBuffer = basis * cfg.slippageBufferFactor
  2186        page.slippageBuffer.textContent = Doc.formatCoinValue(inv.slippageBuffer * ui.conventional.conversionFactor, ui)
  2187      }
  2188      Doc.setVis(fees.bookingFeesPerCounterLot > 0, page.redemptionFeesBox)
  2189      if (fees.bookingFeesPerCounterLot > 0) {
  2190        const feesPerLotConv = fees.bookingFeesPerCounterLot / feeUI.conventional.conversionFactor
  2191        page.redemptionFeesPerLot.textContent = Doc.formatFourSigFigs(feesPerLotConv)
  2192        page.redemptionFeesLots.textContent = String(counterLots)
  2193        page.redeemReservesFactor.textContent = fees.redeemReservesFactor.toFixed(2)
  2194      }
  2195      this.updateCommitTotal()
  2196      this.updateTokenFees()
  2197      this.updateRebalance()
  2198    }
  2199  
  2200    updateCommitTotal () {
  2201      const { page, assetID, ui } = this
  2202      const commit = this.commit()
  2203      page.commitTotal.textContent = Doc.formatCoinValue(Math.round(commit * ui.conventional.conversionFactor), ui)
  2204      page.commitTotalFiat.textContent = Doc.formatFourSigFigs(commit * app().fiatRatesMap[assetID])
  2205    }
  2206  
  2207    updateTokenFees () {
  2208      const { page, inv, feeAssetID, feeUI, isToken } = this
  2209      if (!isToken) return
  2210      const feeReserves = inv.bookingFees + inv.swapFeeReserves
  2211      page.feeTotal.textContent = Doc.formatCoinValue(feeReserves * feeUI.conventional.conversionFactor, feeUI)
  2212      page.feeTotalFiat.textContent = Doc.formatFourSigFigs(feeReserves * app().fiatRatesMap[feeAssetID])
  2213    }
  2214  
  2215    updateRebalance () {
  2216      const { page, cfg, pg: { updatedConfig: { cexRebalance }, specs: { cexName } } } = this
  2217      const showRebalance = cexName && cexRebalance
  2218      Doc.setVis(showRebalance, page.rebalanceOpts)
  2219      if (!showRebalance) return
  2220      const totalInventory = this.commit()
  2221      const [minV, maxV] = [this.minTransfer.min, Math.max(this.minTransfer.min * 2, totalInventory)]
  2222      const rangeV = maxV - minV
  2223      this.minTransfer.setValue(minV + cfg.transferFactor * rangeV)
  2224      this.minTransferSlider.setValue((cfg.transferFactor - defaultTransfer.minR) / defaultTransfer.range)
  2225    }
  2226  
  2227    setupWalletSettings () {
  2228      const { page, assetID, walletConfig } = this
  2229      const walletSettings = app().currentWalletDefinition(assetID)
  2230      Doc.empty(page.walletSettings)
  2231      Doc.setVis(!walletSettings.multifundingopts, page.walletSettingsNone)
  2232      if (!walletSettings.multifundingopts) return
  2233      const optToDiv: Record<string, PageElement> = {}
  2234      const dependentOpts: Record<string, string[]> = {}
  2235      const addDependentOpt = (optKey: string, optSetting: PageElement, dependentOn: string) => {
  2236        if (!dependentOpts[dependentOn]) dependentOpts[dependentOn] = []
  2237        dependentOpts[dependentOn].push(optKey)
  2238        optToDiv[optKey] = optSetting
  2239      }
  2240      const setDependentOptsVis = (parentOptKey: string, vis: boolean) => {
  2241        const optKeys = dependentOpts[parentOptKey]
  2242        if (!optKeys) return
  2243        for (const optKey of optKeys) Doc.setVis(vis, optToDiv[optKey])
  2244      }
  2245      const addOpt = (opt: OrderOption) => {
  2246        if (opt.quoteAssetOnly && !this.isQuote) return
  2247        const currVal = walletConfig[opt.key]
  2248        let div: PageElement | undefined
  2249        if (opt.isboolean) {
  2250          div = page.boolSettingTmpl.cloneNode(true) as PageElement
  2251          const tmpl = Doc.parseTemplate(div)
  2252          tmpl.name.textContent = opt.displayname
  2253          tmpl.input.checked = currVal === 'true'
  2254          Doc.bind(tmpl.input, 'change', () => {
  2255            walletConfig[opt.key] = tmpl.input.checked ? 'true' : 'false'
  2256            setDependentOptsVis(opt.key, Boolean(tmpl.input.checked))
  2257          })
  2258          if (opt.description) tmpl.tooltip.dataset.tooltip = opt.description
  2259        } else if (opt.xyRange) {
  2260          const { start, end, xUnit } = opt.xyRange
  2261          const range = end.x - start.x
  2262          div = page.rangeSettingTmpl.cloneNode(true) as PageElement
  2263          const tmpl = Doc.parseTemplate(div)
  2264          tmpl.name.textContent = opt.displayname
  2265          if (opt.description) tmpl.tooltip.dataset.tooltip = opt.description
  2266          if (xUnit) tmpl.unit.textContent = xUnit
  2267          else Doc.hide(tmpl.unit)
  2268  
  2269          const input = new NumberInput(tmpl.value, {
  2270            prec: 1,
  2271            changed: (rawV: number) => {
  2272              const [v, s] = toFourSigFigs(rawV, 1)
  2273              walletConfig[opt.key] = s
  2274              slider.setValue((v - start.x) / range)
  2275            }
  2276          })
  2277          const slider = new MiniSlider(tmpl.slider, (r: number) => {
  2278            const rawV = start.x + r * range
  2279            const [v, s] = toFourSigFigs(rawV, 1)
  2280            walletConfig[opt.key] = s
  2281            input.setValue(v)
  2282          })
  2283          // TODO: default value should be smaller or none for base asset.
  2284          const [v, s] = toFourSigFigs(parseFloatDefault(currVal, start.x), 3)
  2285          walletConfig[opt.key] = s
  2286          slider.setValue((v - start.x) / range)
  2287          input.setValue(v)
  2288          tmpl.value.textContent = s
  2289        }
  2290        if (!div) return console.error("don't know how to handle opt", opt)
  2291        page.walletSettings.appendChild(div)
  2292        if (opt.dependsOn) {
  2293          addDependentOpt(opt.key, div, opt.dependsOn)
  2294          const parentOptVal = walletConfig[opt.dependsOn]
  2295          Doc.setVis(parentOptVal === 'true', div)
  2296        }
  2297      }
  2298  
  2299      if (walletSettings.multifundingopts && walletSettings.multifundingopts.length > 0) {
  2300        for (const opt of walletSettings.multifundingopts) addOpt(opt)
  2301      }
  2302      app().bindTooltips(page.walletSettings)
  2303    }
  2304  
  2305    updateBalances () {
  2306      const { page, assetID, ui, feeAssetID, feeUI, pg: { specs: { cexName, baseID }, cexBaseBalance, cexQuoteBalance } } = this
  2307      const { balance: { available } } = app().walletMap[assetID]
  2308      const botInv = this.pg.runningBotInventory(assetID)
  2309      const dexAvail = available - botInv.dex.total
  2310      let cexAvail = 0
  2311      Doc.setVis(cexName, page.balanceBreakdown)
  2312      if (cexName) {
  2313        page.dexAvail.textContent = Doc.formatFourSigFigs(dexAvail / ui.conventional.conversionFactor)
  2314        const { available: cexRawAvail } = assetID === baseID ? cexBaseBalance : cexQuoteBalance
  2315        cexAvail = cexRawAvail - botInv.cex.total
  2316        page.cexAvail.textContent = Doc.formatFourSigFigs(cexAvail / ui.conventional.conversionFactor)
  2317      }
  2318      page.avail.textContent = Doc.formatFourSigFigs((dexAvail + cexAvail) / ui.conventional.conversionFactor)
  2319      if (assetID === feeAssetID) return
  2320      const { balance: { available: feeAvail } } = app().walletMap[feeAssetID]
  2321      page.feeAvail.textContent = Doc.formatFourSigFigs(feeAvail / feeUI.conventional.conversionFactor)
  2322    }
  2323  }