From e0c8cdd697397531ddb6f22387030db76ce9adb9 Mon Sep 17 00:00:00 2001 From: natsoni Date: Tue, 20 May 2025 15:20:09 +0200 Subject: [PATCH 1/2] Add historical price endpoint to Liquid backend --- backend/src/api/liquid/liquid.routes.ts | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/backend/src/api/liquid/liquid.routes.ts b/backend/src/api/liquid/liquid.routes.ts index 388038f7f..4976d1694 100644 --- a/backend/src/api/liquid/liquid.routes.ts +++ b/backend/src/api/liquid/liquid.routes.ts @@ -4,6 +4,7 @@ import config from '../../config'; import elementsParser from './elements-parser'; import icons from './icons'; import { handleError } from '../../utils/api'; +import PricesRepository from '../../repositories/PricesRepository'; class LiquidRoutes { public initRoutes(app: Application) { @@ -31,6 +32,7 @@ class LiquidRoutes { .get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/utxos/emergency-spent', this.$getEmergencySpentUtxos) .get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/utxos/emergency-spent/stats', this.$getEmergencySpentUtxosStats) .get(config.MEMPOOL.API_URL_PREFIX + 'liquid/reserves/status', this.$getFederationAuditStatus) + .get(config.MEMPOOL.API_URL_PREFIX + 'historical-price', this.$getHistoricalPrice) ; } } @@ -255,6 +257,34 @@ class LiquidRoutes { } } + private async $getHistoricalPrice(req: Request, res: Response): Promise { + try { + res.header('Pragma', 'public'); + res.header('Cache-control', 'public'); + res.setHeader('Expires', new Date(Date.now() + 1000 * 300).toUTCString()); + if (['testnet', 'signet', 'liquidtestnet'].includes(config.MEMPOOL.NETWORK)) { + handleError(req, res, 400, 'Prices are not available on testnets.'); + return; + } + const timestamp = parseInt(req.query.timestamp as string, 10) || 0; + const currency = req.query.currency as string; + + let response; + if (timestamp && currency) { + response = await PricesRepository.$getNearestHistoricalPrice(timestamp, currency); + } else if (timestamp) { + response = await PricesRepository.$getNearestHistoricalPrice(timestamp); + } else if (currency) { + response = await PricesRepository.$getHistoricalPrices(currency); + } else { + response = await PricesRepository.$getHistoricalPrices(); + } + res.status(200).send(response); + } catch (e) { + handleError(req, res, 500, 'Failed to get historical prices'); + } + } + } export default new LiquidRoutes(); From a87cdff36b7bab15e703a8413b052371c6929b23 Mon Sep 17 00:00:00 2001 From: natsoni Date: Tue, 20 May 2025 15:20:19 +0200 Subject: [PATCH 2/2] Add USD series to Liquid reserves graph --- .../lbtc-pegs-graph.component.html | 2 +- .../lbtc-pegs-graph.component.ts | 141 ++++++++++++++++-- frontend/src/app/services/price.service.ts | 2 +- 3 files changed, 130 insertions(+), 15 deletions(-) diff --git a/frontend/src/app/components/lbtc-pegs-graph/lbtc-pegs-graph.component.html b/frontend/src/app/components/lbtc-pegs-graph/lbtc-pegs-graph.component.html index 841d6b45c..61b09d198 100644 --- a/frontend/src/app/components/lbtc-pegs-graph/lbtc-pegs-graph.component.html +++ b/frontend/src/app/components/lbtc-pegs-graph/lbtc-pegs-graph.component.html @@ -1,4 +1,4 @@ -
+
\ No newline at end of file diff --git a/frontend/src/app/components/lbtc-pegs-graph/lbtc-pegs-graph.component.ts b/frontend/src/app/components/lbtc-pegs-graph/lbtc-pegs-graph.component.ts index 063280898..6ccdc59cd 100644 --- a/frontend/src/app/components/lbtc-pegs-graph/lbtc-pegs-graph.component.ts +++ b/frontend/src/app/components/lbtc-pegs-graph/lbtc-pegs-graph.component.ts @@ -1,7 +1,10 @@ -import { Component, Inject, LOCALE_ID, ChangeDetectionStrategy, Input, OnChanges, OnInit } from '@angular/core'; +import { Component, Inject, LOCALE_ID, ChangeDetectionStrategy, Input, OnChanges, OnInit, ChangeDetectorRef } from '@angular/core'; import { formatDate, formatNumber } from '@angular/common'; import { EChartsOption } from '@app/graphs/echarts'; import { StateService } from '@app/services/state.service'; +import { map, Subscription, switchMap } from 'rxjs'; +import { PriceService } from '../../services/price.service'; +import { AmountShortenerPipe } from '@app/shared/pipes/amount-shortener.pipe'; @Component({ selector: 'app-lbtc-pegs-graph', @@ -21,19 +24,31 @@ export class LbtcPegsGraphComponent implements OnInit, OnChanges { @Input() data: any; @Input() height: number | string = '360'; pegsChartOptions: EChartsOption; + subscription: Subscription; - right: number | string = '10'; + right: number | string = '5'; top: number | string = '20'; - left: number | string = '50'; + left: number | string = '60'; template: ('widget' | 'advanced') = 'widget'; isLoading = true; - + chartInstance: any = undefined; pegsChartInitOption = { renderer: 'svg' }; + adjustedLeft: number; + adjustedRight: number; + selected = { + 'L-BTC': true, + 'BTC': true, + 'USD': false, + }; + constructor( public stateService: StateService, + public priceService: PriceService, + public amountShortenerPipe: AmountShortenerPipe, + public cd: ChangeDetectorRef, @Inject(LOCALE_ID) private locale: string, ) { } @@ -42,14 +57,25 @@ export class LbtcPegsGraphComponent implements OnInit, OnChanges { } ngOnChanges() { - if (!this.data?.liquidPegs) { + if (!this.data?.liquidReserves) { return; } - if (!this.data.liquidReserves) { - this.pegsChartOptions = this.createChartOptions(this.data.liquidPegs.series, this.data.liquidPegs.labels); - } else { - this.pegsChartOptions = this.createChartOptions(this.data.liquidPegs.series, this.data.liquidPegs.labels, this.data.liquidReserves.series); - } + + this.subscription = this.stateService.conversions$.pipe( + switchMap(conversions => + this.priceService.getPriceByBulk$(this.data.liquidPegs.labels.map((date: string) => Math.floor(new Date(date).getTime() / 1000)).slice(0, -1), 'USD') + .pipe( + map(prices => this.data.liquidReserves.series.map((value, i) => value * (prices[i]?.price.USD || conversions['USD']))) + ) + ) + ).subscribe((usdBanlance: any) => { + if (!this.data.liquidReserves) { + this.pegsChartOptions = this.createChartOptions(this.data.liquidPegs.series, this.data.liquidPegs.labels); + } else { + this.pegsChartOptions = this.createChartOptions(this.data.liquidPegs.series, this.data.liquidPegs.labels, this.data.liquidReserves.series, usdBanlance); + } + this.cd.markForCheck(); + }); } rendered() { @@ -59,7 +85,7 @@ export class LbtcPegsGraphComponent implements OnInit, OnChanges { this.isLoading = false; } - createChartOptions(pegSeries: number[], labels: string[], reservesSeries?: number[],): EChartsOption { + createChartOptions(pegSeries: number[], labels: string[], reservesSeries?: number[], usdBalance?: number[]): EChartsOption { return { grid: { height: this.height, @@ -89,6 +115,35 @@ export class LbtcPegsGraphComponent implements OnInit, OnChanges { } } }], + legend: { + data: [ + { + name: 'L-BTC', + inactiveColor: 'var(--grey)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + { + name: 'BTC', + inactiveColor: 'var(--grey)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + }, + { + name: 'USD', + inactiveColor: 'var(--grey)', + textStyle: { + color: 'white', + }, + icon: 'roundRect', + } + ], + selected: this.selected, + }, tooltip: { trigger: 'axis', position: (pos, params, el, elRect, size) => { @@ -109,10 +164,12 @@ export class LbtcPegsGraphComponent implements OnInit, OnChanges { for (let index = params.length - 1; index >= 0; index--) { const item = params[index]; if (index < 26) { + let formattedValue; + item.seriesName === 'USD' ? formattedValue = this.amountShortenerPipe.transform(item.value, 3, undefined, true, true) : formattedValue = formatNumber(item.value, this.locale, '1.2-2'); itemFormatted += `
${colorSpan(item.color)}
-
${formatNumber(item.value, this.locale, '1.2-2')} ${item.seriesName}
+
${formattedValue} ${item.seriesName}
`; } } @@ -129,10 +186,13 @@ export class LbtcPegsGraphComponent implements OnInit, OnChanges { boundaryGap: false, data: labels.map((value: any) => `${formatDate(value, 'MMM\ny', this.locale)}`), }, - yAxis: { + yAxis: [{ type: 'value', axisLabel: { fontSize: 11, + formatter: (val): string => { + return `${this.amountShortenerPipe.transform(Math.round(val), 0, undefined, true)} BTC`; + } }, splitLine: { lineStyle: { @@ -142,10 +202,23 @@ export class LbtcPegsGraphComponent implements OnInit, OnChanges { } } }, + { + type: 'value', + axisLabel: { + color: 'rgb(110, 112, 121)', + formatter: function(val) { + return `$${this.amountShortenerPipe.transform(val, 3, undefined, true, true)}`; + }.bind(this) + }, + splitLine: { + show: false, + }, + }], series: [ { data: pegSeries, name: 'L-BTC', + yAxisIndex: 0, color: '#116761', type: 'line', stack: 'total', @@ -163,6 +236,7 @@ export class LbtcPegsGraphComponent implements OnInit, OnChanges { { data: reservesSeries, name: 'BTC', + yAxisIndex: 0, color: '#EA983B', type: 'line', smooth: true, @@ -172,8 +246,49 @@ export class LbtcPegsGraphComponent implements OnInit, OnChanges { color: '#EA983B', }, }, + { + data: usdBalance, + name: 'USD', + yAxisIndex: 1, + color: '#4CAF50', + type: 'line', + smooth: true, + showSymbol: false, + lineStyle: { + width: 2, + color: '#3BCC49', + }, + }, ], }; } + + onLegendSelectChanged(e) { + this.selected = e.selected; + this.adjustedRight = this.selected['USD'] ? +this.right + 40 : +this.right; + this.adjustedLeft = this.selected['L-BTC'] || this.selected['BTC'] ? +this.left : +this.left - 40; + + this.pegsChartOptions = { + ...this.pegsChartOptions, + grid: { + ...this.pegsChartOptions.grid, + right: this.adjustedRight, + left: this.adjustedLeft, + }, + legend: { + ...this.pegsChartOptions.legend, + selected: this.selected, + }, + }; + } + + onChartInit(ec) { + this.chartInstance = ec; + this.chartInstance.on('legendselectchanged', this.onLegendSelectChanged.bind(this)); + } + + ngOnDestroy(): void { + this.subscription?.unsubscribe(); + } } diff --git a/frontend/src/app/services/price.service.ts b/frontend/src/app/services/price.service.ts index e5a0c86c8..f4a1717a9 100644 --- a/frontend/src/app/services/price.service.ts +++ b/frontend/src/app/services/price.service.ts @@ -251,7 +251,7 @@ export class PriceService { } getPriceByBulk$(timestamps: number[], currency: string): Observable { - if (this.stateService.env.BASE_MODULE !== 'mempool' || !this.stateService.env.HISTORICAL_PRICE) { + if (!this.stateService.env.HISTORICAL_PRICE) { return of([]); }