decred.org/dcrdex@v1.0.5/client/webserver/site/src/js/mmlogs.ts (about) 1 import { 2 app, 3 PageElement, 4 MarketMakingEvent, 5 DEXOrderEvent, 6 CEXOrderEvent, 7 RunEventNote, 8 RunStatsNote, 9 DepositEvent, 10 WithdrawalEvent, 11 MarketMakingRunOverview, 12 SupportedAsset, 13 BalanceEffects, 14 MarketWithHost, 15 ProfitLoss 16 } from './registry' 17 import { Forms } from './forms' 18 import { postJSON } from './http' 19 import Doc, { setupCopyBtn } from './doc' 20 import BasePage from './basepage' 21 import { setMarketElements, liveBotStatus } from './mmutil' 22 import * as intl from './locales' 23 import * as wallets from './wallets' 24 import { CoinExplorers } from './coinexplorers' 25 26 interface LogsPageParams { 27 host: string 28 quoteID: number 29 baseID: number 30 startTime: number 31 returnPage: string 32 } 33 34 let net = 0 35 36 const logsBatchSize = 50 37 38 interface logFilters { 39 dexSells: boolean 40 dexBuys: boolean 41 cexSells: boolean 42 cexBuys: boolean 43 deposits: boolean 44 withdrawals: boolean 45 } 46 47 function eventPassesFilter (e: MarketMakingEvent, filters: logFilters): boolean { 48 if (e.dexOrderEvent) { 49 if (e.dexOrderEvent.sell) return filters.dexSells 50 return filters.dexBuys 51 } 52 if (e.cexOrderEvent) { 53 if (e.cexOrderEvent.sell) return filters.cexSells 54 return filters.cexBuys 55 } 56 if (e.depositEvent) return filters.deposits 57 if (e.withdrawalEvent) return filters.withdrawals 58 return false 59 } 60 61 export default class MarketMakerLogsPage extends BasePage { 62 page: Record<string, PageElement> 63 mkt: MarketWithHost 64 startTime: number 65 fiatRates: Record<number, number> 66 liveBot: boolean 67 overview: MarketMakingRunOverview 68 events: Record<number, [MarketMakingEvent, HTMLElement]> 69 forms: Forms 70 dexOrderIDCopyListener: () => void | undefined 71 cexOrderIDCopyListener: () => void | undefined 72 depositIDCopyListener: () => void | undefined 73 withdrawalIDCopyListener: () => void | undefined 74 filters: logFilters 75 loading: boolean 76 refID: number | undefined 77 doneScrolling: boolean 78 statsRows: Record<number, HTMLElement> 79 80 constructor (main: HTMLElement, params: LogsPageParams) { 81 super() 82 const page = this.page = Doc.idDescendants(main) 83 net = app().user.net 84 Doc.cleanTemplates(page.eventTableRowTmpl, page.dexOrderTxRowTmpl, page.performanceTableRowTmpl) 85 Doc.bind(this.page.backButton, 'click', () => { app().loadPage(params.returnPage ?? 'mm') }) 86 Doc.bind(this.page.filterButton, 'click', () => { this.applyFilters() }) 87 if (params?.host) { 88 const url = new URL(window.location.href) 89 url.searchParams.set('host', params.host) 90 url.searchParams.set('baseID', String(params.baseID)) 91 url.searchParams.set('quoteID', String(params.quoteID)) 92 url.searchParams.set('startTime', String(params.startTime)) 93 window.history.replaceState({ page: 'mmsettings', ...params }, '', url) 94 } else { 95 const urlParams = new URLSearchParams(window.location.search) 96 if (!params) params = {} as LogsPageParams 97 params.host = urlParams.get('host') || '' 98 params.baseID = parseInt(urlParams.get('baseID') || '0') 99 params.quoteID = parseInt(urlParams.get('quoteID') || '0') 100 params.startTime = parseInt(urlParams.get('startTime') || '0') 101 } 102 const { baseID, quoteID, host, startTime } = params 103 this.startTime = startTime 104 this.forms = new Forms(page.forms) 105 this.events = {} 106 this.statsRows = {} 107 this.mkt = { baseID: baseID, quoteID: quoteID, host } 108 setMarketElements(main, baseID, quoteID, host) 109 Doc.bind(main, 'scroll', () => { 110 if (this.loading) return 111 if (this.doneScrolling) return 112 const belowBottom = page.eventsTable.offsetHeight - main.offsetHeight - main.scrollTop 113 if (belowBottom < 0) { 114 this.nextPage() 115 } 116 }) 117 this.setup(host, baseID, quoteID) 118 } 119 120 async nextPage () { 121 this.loading = true 122 const [events, updatedLogs, overview] = await this.getRunLogs() 123 const assets = this.mktAssets() 124 for (const event of events) { 125 if (this.events[event.id]) continue 126 const row = this.newEventRow(event, false, assets) 127 this.events[event.id] = [event, row] 128 } 129 this.populateStats(overview.profitLoss, overview.endTime) 130 this.updateExistingRows(updatedLogs) 131 this.loading = false 132 } 133 134 async getRunLogs (): Promise<[MarketMakingEvent[], MarketMakingEvent[], MarketMakingRunOverview]> { 135 const { mkt, startTime } = this 136 const req: any = { market: mkt, startTime, n: logsBatchSize, filters: this.filters, refID: this.refID } 137 const res = await postJSON('/api/mmrunlogs', req) 138 if (!app().checkResponse(res)) { 139 console.error('failed to get bot logs', res) 140 } 141 if (res.logs.length <= 1) { 142 this.doneScrolling = true 143 } 144 if (res.logs.length > 0) { 145 this.refID = res.logs[res.logs.length - 1].id 146 } 147 return [res.logs, res.updatedLogs || [], res.overview] 148 } 149 150 async applyFilters () { 151 const page = this.page 152 this.filters = { 153 dexSells: !!page.dexSellsCheckbox.checked, 154 dexBuys: !!page.dexBuysCheckbox.checked, 155 cexSells: !!page.cexSellsCheckbox.checked, 156 cexBuys: !!page.cexBuysCheckbox.checked, 157 deposits: !!page.depositsCheckbox.checked, 158 withdrawals: !!page.withdrawalsCheckbox.checked 159 } 160 this.refID = undefined 161 const [events, , overview] = await this.getRunLogs() 162 this.populateTable(events) 163 this.populateStats(overview.profitLoss, overview.endTime) 164 } 165 166 setFilters () { 167 const page = this.page 168 page.dexSellsCheckbox.checked = true 169 page.dexBuysCheckbox.checked = true 170 page.cexSellsCheckbox.checked = true 171 page.cexBuysCheckbox.checked = true 172 page.depositsCheckbox.checked = true 173 page.withdrawalsCheckbox.checked = true 174 this.filters = { 175 dexSells: true, 176 dexBuys: true, 177 cexSells: true, 178 cexBuys: true, 179 deposits: true, 180 withdrawals: true 181 } 182 } 183 184 async setup (host: string, baseID: number, quoteID: number) { 185 const page = this.page 186 this.setFilters() 187 const { startTime } = this 188 let profitLoss: ProfitLoss 189 let endTime = 0 190 const botStatus = liveBotStatus(host, baseID, quoteID) 191 const [events, , overview] = await this.getRunLogs() 192 if (botStatus?.runStats?.startTime === startTime) { 193 this.liveBot = true 194 this.fiatRates = app().fiatRatesMap 195 profitLoss = botStatus.runStats.profitLoss 196 } else { 197 this.fiatRates = overview.finalState.fiatRates 198 profitLoss = overview.profitLoss 199 endTime = overview.endTime 200 } 201 this.populateStats(profitLoss, endTime) 202 const assets = this.mktAssets() 203 const parentHeader = page.sumUSDHeader.parentElement 204 for (const asset of assets) { 205 const th = document.createElement('th') as PageElement 206 th.textContent = `${asset.symbol.toUpperCase()} Delta` 207 if (parentHeader) { 208 parentHeader.insertBefore(th, page.sumUSDHeader) 209 } 210 } 211 this.populateTable(events) 212 213 app().registerNoteFeeder({ 214 runevent: (note: RunEventNote) => { this.handleRunEventNote(note) }, 215 runstats: (note: RunStatsNote) => { this.handleRunStatsNote(note) } 216 }) 217 } 218 219 handleRunEventNote (note: RunEventNote) { 220 const { baseID, quoteID, host } = this.mkt 221 if (note.host !== host || note.baseID !== baseID || note.quoteID !== quoteID) return 222 if (!eventPassesFilter(note.event, this.filters)) return 223 const event = note.event 224 const cachedEvent = this.events[event.id] 225 if (cachedEvent) { 226 this.setRowContents(cachedEvent[1], event, this.mktAssets()) 227 cachedEvent[0] = event 228 return 229 } 230 const row = this.newEventRow(event, true, this.mktAssets()) 231 this.events[event.id] = [event, row] 232 } 233 234 handleRunStatsNote (note: RunStatsNote) { 235 const { mkt: { baseID, quoteID, host }, startTime } = this 236 if (note.host !== host || 237 note.baseID !== baseID || 238 note.quoteID !== quoteID) return 239 if (!note.stats || note.stats.startTime !== startTime) return 240 this.populateStats(note.stats.profitLoss, 0) 241 } 242 243 populateStats (pl: ProfitLoss, endTime: number) { 244 const page = this.page 245 page.startTime.textContent = new Date(this.startTime * 1000).toLocaleString() 246 if (endTime === 0) { 247 Doc.hide(page.endTimeRow) 248 } else { 249 page.endTime.textContent = new Date(endTime * 1000).toLocaleString() 250 } 251 for (const assetID in pl.diffs) { 252 const asset = app().assets[parseInt(assetID)] 253 let row = this.statsRows[assetID] 254 if (!row) { 255 row = page.performanceTableRowTmpl.cloneNode(true) as HTMLElement 256 const tmpl = Doc.parseTemplate(row) 257 tmpl.logo.src = Doc.logoPath(asset.symbol) 258 tmpl.ticker.textContent = asset.symbol.toUpperCase() 259 this.statsRows[assetID] = row 260 page.performanceTableBody.appendChild(row) 261 } 262 const diff = pl.diffs[assetID] 263 const tmpl = Doc.parseTemplate(row) 264 tmpl.diff.textContent = diff.fmt 265 tmpl.usdDiff.textContent = diff.fmtUSD 266 tmpl.fiatRate.textContent = `${Doc.formatFiatValue(this.fiatRates[asset.id])} USD` 267 } 268 page.profitLoss.textContent = `${Doc.formatFiatValue(pl.profit)} USD` 269 } 270 271 mktAssets () : SupportedAsset[] { 272 const baseAsset = app().assets[this.mkt.baseID] 273 const quoteAsset = app().assets[this.mkt.quoteID] 274 275 const assets = [baseAsset, quoteAsset] 276 const assetIDs = { [baseAsset.id]: true, [quoteAsset.id]: true } 277 278 if (baseAsset.token && !assetIDs[baseAsset.token.parentID]) { 279 const baseTokenAsset = app().assets[baseAsset.token.parentID] 280 assetIDs[baseTokenAsset.id] = true 281 assets.push(baseTokenAsset) 282 } 283 284 if (quoteAsset.token && !assetIDs[quoteAsset.token.parentID]) { 285 const quoteTokenAsset = app().assets[quoteAsset.token.parentID] 286 assets.push(quoteTokenAsset) 287 } 288 289 return assets 290 } 291 292 updateExistingRows (updatedLogs: MarketMakingEvent[]) { 293 for (const event of updatedLogs) { 294 const cachedEvent = this.events[event.id] 295 if (!cachedEvent) continue 296 this.setRowContents(cachedEvent[1], event, this.mktAssets()) 297 cachedEvent[0] = event 298 } 299 } 300 301 populateTable (events: MarketMakingEvent[]) { 302 const page = this.page 303 Doc.empty(page.eventsTableBody) 304 this.events = {} 305 this.doneScrolling = false 306 const assets = this.mktAssets() 307 for (const event of events) { 308 const row = this.newEventRow(event, false, assets) 309 this.events[event.id] = [event, row] 310 } 311 } 312 313 setRowContents (row: HTMLElement, event: MarketMakingEvent, assets: SupportedAsset[]) { 314 const tmpl = Doc.parseTemplate(row) 315 tmpl.time.textContent = (new Date(event.timestamp * 1000)).toLocaleString() 316 tmpl.eventType.textContent = this.eventType(event) 317 let id 318 if (event.depositEvent) { 319 id = event.depositEvent.transaction.id 320 } else if (event.withdrawalEvent) { 321 id = event.withdrawalEvent.id 322 } else if (event.dexOrderEvent) { 323 id = event.dexOrderEvent.id 324 } else if (event.cexOrderEvent) { 325 id = event.cexOrderEvent.id 326 } 327 if (id) { 328 tmpl.eventID.textContent = trimStringWithEllipsis(id, 30) 329 tmpl.eventID.setAttribute('title', id) 330 } 331 let usd = 0 332 for (const asset of assets) { 333 const be = event.balanceEffects 334 const sum = sumBalanceEffects(asset.id, be) 335 const tmplID = `sum${asset.symbol.toUpperCase()}` 336 let el : PageElement 337 if (tmpl[tmplID]) { 338 el = tmpl[tmplID] 339 } else { 340 el = document.createElement('td') 341 el.dataset.tmpl = tmplID 342 const parent = tmpl.sumUSD.parentElement 343 if (parent) { 344 parent.insertBefore(el, tmpl.sumUSD) 345 } 346 } 347 el.textContent = Doc.formatCoinValue(sum, asset.unitInfo) 348 const factor = asset.unitInfo.conventional.conversionFactor 349 usd += sum / factor * this.fiatRates[asset.id] || 0 350 } 351 tmpl.sumUSD.textContent = Doc.formatFourSigFigs(usd) 352 Doc.bind(tmpl.details, 'click', () => { this.showEventDetails(event.id) }) 353 } 354 355 newEventRow (event: MarketMakingEvent, prepend: boolean, assets: SupportedAsset[]) : HTMLElement { 356 const page = this.page 357 const row = page.eventTableRowTmpl.cloneNode(true) as HTMLElement 358 row.id = event.id.toString() 359 this.setRowContents(row, event, assets) 360 if (prepend) { 361 page.eventsTableBody.insertBefore(row, page.eventsTableBody.firstChild) 362 } else { 363 page.eventsTableBody.appendChild(row) 364 } 365 return row 366 } 367 368 eventType (event: MarketMakingEvent) : string { 369 if (event.depositEvent) { 370 return 'Deposit' 371 } else if (event.withdrawalEvent) { 372 return 'Withdrawal' 373 } else if (event.dexOrderEvent) { 374 return event.dexOrderEvent.sell ? 'DEX Sell' : 'DEX Buy' 375 } else if (event.cexOrderEvent) { 376 return event.cexOrderEvent.sell ? 'CEX Sell' : 'CEX Buy' 377 } 378 379 return '' 380 } 381 382 showDexOrderEventDetails (event: DEXOrderEvent) { 383 const { page, mkt: { baseID, quoteID } } = this 384 const baseAsset = app().assets[baseID] 385 const quoteAsset = app().assets[quoteID] 386 const [bui, qui] = [baseAsset.unitInfo, quoteAsset.unitInfo] 387 const [baseTicker, quoteTicker] = [bui.conventional.unit, qui.conventional.unit] 388 if (this.dexOrderIDCopyListener !== undefined) { 389 page.copyDexOrderID.removeEventListener('click', this.dexOrderIDCopyListener) 390 } 391 this.dexOrderIDCopyListener = () => { setupCopyBtn(event.id, page.dexOrderID, page.copyDexOrderID, '#1e7d11') } 392 page.copyDexOrderID.addEventListener('click', this.dexOrderIDCopyListener) 393 page.dexOrderID.textContent = trimStringWithEllipsis(event.id, 20) 394 page.dexOrderID.setAttribute('title', event.id) 395 const rate = app().conventionalRate(baseID, quoteID, event.rate) 396 397 page.dexOrderRate.textContent = `${rate} ${baseTicker}/${quoteTicker}` 398 page.dexOrderQty.textContent = `${event.qty / bui.conventional.conversionFactor} ${baseTicker}` 399 if (event.sell) { 400 page.dexOrderSide.textContent = intl.prep(intl.ID_SELL) 401 } else { 402 page.dexOrderSide.textContent = intl.prep(intl.ID_BUY) 403 } 404 Doc.empty(page.dexOrderTxsTableBody) 405 Doc.setVis(event.transactions && event.transactions.length > 0, page.dexOrderTxsTable) 406 const txAsset = (txType: number, sell: boolean) : SupportedAsset | undefined => { 407 switch (txType) { 408 case wallets.txTypeSwap: 409 case wallets.txTypeRefund: 410 case wallets.txTypeSplit: 411 return sell ? baseAsset : quoteAsset 412 case wallets.txTypeRedeem: 413 return sell ? quoteAsset : baseAsset 414 } 415 } 416 417 for (let i = 0; event.transactions && i < event.transactions.length; i++) { 418 const tx = event.transactions[i] 419 const row = page.dexOrderTxRowTmpl.cloneNode(true) as HTMLElement 420 const tmpl = Doc.parseTemplate(row) 421 tmpl.id.textContent = trimStringWithEllipsis(tx.id, 20) 422 tmpl.id.setAttribute('title', tx.id) 423 tmpl.type.textContent = wallets.txTypeString(tx.type) 424 const asset = txAsset(tx.type, event.sell) 425 if (!asset) { 426 console.error('unexpected tx type in dex order event', tx.type) 427 continue 428 } 429 const assetExplorer = CoinExplorers[asset.id] 430 if (assetExplorer && assetExplorer[net]) { 431 tmpl.explorerLink.href = assetExplorer[net](tx.id) 432 } 433 tmpl.amt.textContent = `${Doc.formatCoinValue(tx.amount, asset.unitInfo)} ${asset.unitInfo.conventional.unit.toLowerCase()}` 434 tmpl.fees.textContent = `${Doc.formatCoinValue(tx.fees, asset.unitInfo)} ${asset.unitInfo.conventional.unit.toLowerCase()}` 435 page.dexOrderTxsTableBody.appendChild(row) 436 } 437 this.forms.show(page.dexOrderDetailsForm) 438 } 439 440 showCexOrderEventDetails (event: CEXOrderEvent) { 441 const { page, mkt: { baseID, quoteID } } = this 442 const baseAsset = app().assets[baseID] 443 const quoteAsset = app().assets[quoteID] 444 const [bui, qui] = [baseAsset.unitInfo, quoteAsset.unitInfo] 445 const [baseTicker, quoteTicker] = [bui.conventional.unit, qui.conventional.unit] 446 447 page.cexOrderID.textContent = trimStringWithEllipsis(event.id, 20) 448 if (this.cexOrderIDCopyListener !== undefined) { 449 page.copyCexOrderID.removeEventListener('click', this.cexOrderIDCopyListener) 450 } 451 this.cexOrderIDCopyListener = () => { setupCopyBtn(event.id, page.cexOrderID, page.copyCexOrderID, '#1e7d11') } 452 page.copyCexOrderID.addEventListener('click', this.cexOrderIDCopyListener) 453 page.cexOrderID.setAttribute('title', event.id) 454 const rate = app().conventionalRate(baseID, quoteID, event.rate) 455 page.cexOrderRate.textContent = `${rate} ${baseTicker}/${quoteTicker}` 456 page.cexOrderQty.textContent = `${event.qty / bui.conventional.conversionFactor} ${baseTicker}` 457 if (event.sell) { 458 page.cexOrderSide.textContent = intl.prep(intl.ID_SELL) 459 } else { 460 page.cexOrderSide.textContent = intl.prep(intl.ID_BUY) 461 } 462 page.cexOrderBaseFilled.textContent = `${event.baseFilled / bui.conventional.conversionFactor} ${baseTicker}` 463 page.cexOrderQuoteFilled.textContent = `${event.quoteFilled / qui.conventional.conversionFactor} ${quoteTicker}` 464 this.forms.show(page.cexOrderDetailsForm) 465 } 466 467 showDepositEventDetails (event: DepositEvent, pending: boolean) { 468 const page = this.page 469 page.depositID.textContent = trimStringWithEllipsis(event.transaction.id, 20) 470 if (this.depositIDCopyListener !== undefined) { 471 page.copyDepositID.removeEventListener('click', this.depositIDCopyListener) 472 } 473 this.depositIDCopyListener = () => { setupCopyBtn(event.transaction.id, page.depositID, page.copyDepositID, '#1e7d11') } 474 page.copyDepositID.addEventListener('click', this.depositIDCopyListener) 475 page.depositID.setAttribute('title', event.transaction.id) 476 const unitInfo = app().assets[event.assetID].unitInfo 477 const unit = unitInfo.conventional.unit 478 page.depositAmt.textContent = `${Doc.formatCoinValue(event.transaction.amount, unitInfo)} ${unit}` 479 page.depositFees.textContent = `${Doc.formatCoinValue(event.transaction.fees, unitInfo)} ${unit}` 480 page.depositStatus.textContent = pending ? intl.prep(intl.ID_PENDING) : intl.prep(intl.ID_COMPLETE) 481 Doc.setVis(!pending, page.depositCreditSection) 482 if (!pending) { 483 page.depositCredit.textContent = `${Doc.formatCoinValue(event.cexCredit, unitInfo)} ${unit}` 484 } 485 this.forms.show(page.depositDetailsForm) 486 } 487 488 showWithdrawalEventDetails (event: WithdrawalEvent, pending: boolean) { 489 const page = this.page 490 page.withdrawalID.textContent = trimStringWithEllipsis(event.id, 20) 491 if (this.withdrawalIDCopyListener !== undefined) { 492 page.copyWithdrawalID.removeEventListener('click', this.withdrawalIDCopyListener) 493 } 494 this.withdrawalIDCopyListener = () => { setupCopyBtn(event.id, page.withdrawalID, page.copyWithdrawalID, '#1e7d11') } 495 page.copyWithdrawalID.addEventListener('click', this.withdrawalIDCopyListener) 496 page.withdrawalID.setAttribute('title', event.id) 497 const unitInfo = app().assets[event.assetID].unitInfo 498 const unit = unitInfo.conventional.unit 499 page.withdrawalAmt.textContent = `${Doc.formatCoinValue(event.cexDebit, unitInfo)} ${unit}` 500 page.withdrawalStatus.textContent = pending ? intl.prep(intl.ID_PENDING) : intl.prep(intl.ID_COMPLETE) 501 if (event.transaction) { 502 page.withdrawalTxID.textContent = trimStringWithEllipsis(event.transaction.id, 20) 503 page.withdrawalTxID.setAttribute('title', event.transaction.id) 504 page.withdrawalReceived.textContent = `${Doc.formatCoinValue(event.transaction.amount, unitInfo)} ${unit}` 505 } 506 this.forms.show(page.withdrawalDetailsForm) 507 } 508 509 showEventDetails (eventID: number) { 510 const [event] = this.events[eventID] 511 if (event.dexOrderEvent) this.showDexOrderEventDetails(event.dexOrderEvent) 512 if (event.cexOrderEvent) this.showCexOrderEventDetails(event.cexOrderEvent) 513 if (event.depositEvent) this.showDepositEventDetails(event.depositEvent, event.pending) 514 if (event.withdrawalEvent) this.showWithdrawalEventDetails(event.withdrawalEvent, event.pending) 515 } 516 } 517 518 function trimStringWithEllipsis (str: string, maxLen: number): string { 519 if (str.length <= maxLen) return str 520 return `${str.substring(0, maxLen / 2)}...${str.substring(str.length - maxLen / 2)}` 521 } 522 523 function sumBalanceEffects (assetID: number, be: BalanceEffects): number { 524 let sum = 0 525 if (be.settled[assetID]) sum += be.settled[assetID] 526 if (be.pending[assetID]) sum += be.pending[assetID] 527 if (be.locked[assetID]) sum += be.locked[assetID] 528 if (be.reserved[assetID]) sum += be.reserved[assetID] 529 return sum 530 }