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