From 0aa4849e7698894da8e6d764c0e0401123181255 Mon Sep 17 00:00:00 2001 From: Face <69168154+face-hh@users.noreply.github.com> Date: Tue, 27 May 2025 14:54:19 +0300 Subject: [PATCH] feat: live price updates on coin page --- .../src/lib/components/self/TradeModal.svelte | 9 +- website/src/lib/stores/websocket.ts | 51 ++++++++ website/src/lib/utils.ts | 19 +++ .../api/coin/[coinSymbol]/trade/+server.ts | 8 +- .../src/routes/coin/[coinSymbol]/+page.svelte | 112 +++++++++++++++--- 5 files changed, 176 insertions(+), 23 deletions(-) diff --git a/website/src/lib/components/self/TradeModal.svelte b/website/src/lib/components/self/TradeModal.svelte index 68aa438..417d6e7 100644 --- a/website/src/lib/components/self/TradeModal.svelte +++ b/website/src/lib/components/self/TradeModal.svelte @@ -5,7 +5,7 @@ import { Label } from '$lib/components/ui/label'; import { Badge } from '$lib/components/ui/badge'; import { TrendingUp, TrendingDown, Loader2 } from 'lucide-svelte'; - import { USER_DATA } from '$lib/stores/user-data'; + import { PORTFOLIO_DATA } from '$lib/stores/portfolio-data'; import { toast } from 'svelte-sonner'; let { @@ -33,10 +33,9 @@ ? Math.min(userHolding, Math.floor(Number(coin.poolCoinAmount) * 0.995)) : userHolding ); - let estimatedResult = $derived(calculateEstimate(numericAmount, type, currentPrice)); let hasValidAmount = $derived(numericAmount > 0); - let userBalance = $derived($USER_DATA ? Number($USER_DATA.baseCurrencyBalance) : 0); + let userBalance = $derived($PORTFOLIO_DATA ? $PORTFOLIO_DATA.baseCurrencyBalance : 0); let hasEnoughFunds = $derived( type === 'BUY' ? numericAmount <= userBalance : numericAmount <= userHolding ); @@ -114,7 +113,7 @@ function setMaxAmount() { if (type === 'SELL') { amount = maxSellableAmount.toString(); - } else if ($USER_DATA) { + } else if ($PORTFOLIO_DATA) { // For BUY, max is user's balance amount = userBalance.toString(); } @@ -164,7 +163,7 @@
Max sellable: {maxSellableAmount.toFixed(0)} {coin.symbol} (pool limit) {/if}

- {:else if $USER_DATA} + {:else if $PORTFOLIO_DATA}

Balance: ${userBalance.toFixed(6)}

diff --git a/website/src/lib/stores/websocket.ts b/website/src/lib/stores/websocket.ts index 4d220dc..8d2a0ba 100644 --- a/website/src/lib/stores/websocket.ts +++ b/website/src/lib/stores/websocket.ts @@ -16,6 +16,16 @@ export interface LiveTrade { userImage?: string; } +export interface PriceUpdate { + coinSymbol: string; + currentPrice: number; + marketCap: number; + change24h: number; + volume24h: number; + poolCoinAmount?: number; + poolBaseCurrencyAmount?: number; +} + // Constants const WEBSOCKET_URL = PUBLIC_WEBSOCKET_URL; const RECONNECT_DELAY = 5000; @@ -32,10 +42,14 @@ export const liveTradesStore = writable([]); export const allTradesStore = writable([]); export const isConnectedStore = writable(false); export const isLoadingTrades = writable(false); +export const priceUpdatesStore = writable>({}); // Comment callbacks const commentSubscriptions = new Map void>(); +// Price update callbacks +const priceUpdateSubscriptions = new Map void>(); + async function loadInitialTrades(): Promise { if (!browser) return; @@ -115,6 +129,29 @@ function handleCommentMessage(message: any): void { } } +function handlePriceUpdateMessage(message: any): void { + const priceUpdate: PriceUpdate = { + coinSymbol: message.coinSymbol, + currentPrice: message.currentPrice, + marketCap: message.marketCap, + change24h: message.change24h, + volume24h: message.volume24h, + poolCoinAmount: message.poolCoinAmount, + poolBaseCurrencyAmount: message.poolBaseCurrencyAmount + }; + + priceUpdatesStore.update(updates => ({ + ...updates, + [message.coinSymbol]: priceUpdate + })); + + // Call specific coin callback if subscribed + const callback = priceUpdateSubscriptions.get(message.coinSymbol); + if (callback) { + callback(priceUpdate); + } +} + function handleWebSocketMessage(event: MessageEvent): void { try { const message = JSON.parse(event.data); @@ -125,6 +162,10 @@ function handleWebSocketMessage(event: MessageEvent): void { handleTradeMessage(message); break; + case 'price_update': + handlePriceUpdateMessage(message); + break; + case 'ping': sendMessage({ type: 'pong' }); break; @@ -202,11 +243,21 @@ function unsubscribeFromComments(coinSymbol: string): void { commentSubscriptions.delete(coinSymbol); } +function subscribeToPriceUpdates(coinSymbol: string, callback: (priceUpdate: PriceUpdate) => void): void { + priceUpdateSubscriptions.set(coinSymbol, callback); +} + +function unsubscribeFromPriceUpdates(coinSymbol: string): void { + priceUpdateSubscriptions.delete(coinSymbol); +} + export const websocketController = { connect, disconnect, setCoin, subscribeToComments, unsubscribeFromComments, + subscribeToPriceUpdates, + unsubscribeFromPriceUpdates, loadInitialTrades }; \ No newline at end of file diff --git a/website/src/lib/utils.ts b/website/src/lib/utils.ts index 6774004..13bbe9a 100644 --- a/website/src/lib/utils.ts +++ b/website/src/lib/utils.ts @@ -183,4 +183,23 @@ export function getExpirationDate(option: string): string | null { } } +export function getTimeframeInSeconds(timeframe: string): number { + switch (timeframe) { + case '1m': + return 60; + case '5m': + return 300; + case '15m': + return 900; + case '1h': + return 3600; + case '4h': + return 14400; + case '1d': + return 86400; + default: + return 60; + } +} + export const formatMarketCap = formatValue; diff --git a/website/src/routes/api/coin/[coinSymbol]/trade/+server.ts b/website/src/routes/api/coin/[coinSymbol]/trade/+server.ts index c6b8d4c..bc1e841 100644 --- a/website/src/routes/api/coin/[coinSymbol]/trade/+server.ts +++ b/website/src/routes/api/coin/[coinSymbol]/trade/+server.ts @@ -185,7 +185,9 @@ export async function POST({ params, request }) { currentPrice: newPrice, marketCap: Number(coinData.circulatingSupply) * newPrice, change24h: metrics.change24h, - volume24h: metrics.volume24h + volume24h: metrics.volume24h, + poolCoinAmount: newPoolCoin, + poolBaseCurrencyAmount: newPoolBaseCurrency })); }); @@ -325,7 +327,9 @@ export async function POST({ params, request }) { currentPrice: newPrice, marketCap: Number(coinData.circulatingSupply) * newPrice, change24h: metrics.change24h, - volume24h: metrics.volume24h + volume24h: metrics.volume24h, + poolCoinAmount: newPoolCoin, + poolBaseCurrencyAmount: newPoolBaseCurrency })); }); diff --git a/website/src/routes/coin/[coinSymbol]/+page.svelte b/website/src/routes/coin/[coinSymbol]/+page.svelte index 837de37..5451bf9 100644 --- a/website/src/routes/coin/[coinSymbol]/+page.svelte +++ b/website/src/routes/coin/[coinSymbol]/+page.svelte @@ -22,12 +22,11 @@ import CoinIcon from '$lib/components/self/CoinIcon.svelte'; import { USER_DATA } from '$lib/stores/user-data'; import { fetchPortfolioData } from '$lib/stores/portfolio-data'; - import { getPublicUrl } from '$lib/utils.js'; - import { websocketController } from '$lib/stores/websocket'; + import { getPublicUrl, getTimeframeInSeconds } from '$lib/utils.js'; + import { websocketController, type PriceUpdate, isConnectedStore } from '$lib/stores/websocket'; const { data } = $props(); const coinSymbol = data.coinSymbol; - let coin = $state(null); let loading = $state(true); let chartData = $state([]); @@ -36,12 +35,21 @@ let buyModalOpen = $state(false); let sellModalOpen = $state(false); let selectedTimeframe = $state('1m'); + let lastPriceUpdateTime = 0; onMount(async () => { await loadCoinData(); await loadUserHolding(); websocketController.setCoin(coinSymbol.toUpperCase()); + + websocketController.subscribeToPriceUpdates(coinSymbol.toUpperCase(), handlePriceUpdate); + }); + + $effect(() => { + return () => { + websocketController.unsubscribeFromPriceUpdates(coinSymbol.toUpperCase()); + }; }); async function loadCoinData() { @@ -79,10 +87,70 @@ console.error('Failed to load user holding:', e); } } - async function handleTradeSuccess() { await Promise.all([loadCoinData(), loadUserHolding(), fetchPortfolioData()]); } + function handlePriceUpdate(priceUpdate: PriceUpdate) { + if (coin && priceUpdate.coinSymbol === coinSymbol.toUpperCase()) { + // throttle updates to prevent excessive UI updates, 1s interval + const now = Date.now(); + if (now - lastPriceUpdateTime < 1000) { + return; + } + lastPriceUpdateTime = now; + + coin = { + ...coin, + currentPrice: priceUpdate.currentPrice, + marketCap: priceUpdate.marketCap, + change24h: priceUpdate.change24h, + volume24h: priceUpdate.volume24h, + ...(priceUpdate.poolCoinAmount !== undefined && { + poolCoinAmount: priceUpdate.poolCoinAmount + }), + ...(priceUpdate.poolBaseCurrencyAmount !== undefined && { + poolBaseCurrencyAmount: priceUpdate.poolBaseCurrencyAmount + }) + }; + + updateChartRealtime(priceUpdate.currentPrice); + } + } + + function updateChartRealtime(newPrice: number) { + if (!candlestickSeries || !chart || chartData.length === 0) return; + + const timeframeSeconds = getTimeframeInSeconds(selectedTimeframe); + const currentTime = Math.floor(Date.now() / 1000); + + const currentCandleTime = Math.floor(currentTime / timeframeSeconds) * timeframeSeconds; + + const lastCandle = chartData[chartData.length - 1]; + + if (lastCandle && lastCandle.time === currentCandleTime) { + const updatedCandle = { + time: currentCandleTime, + open: lastCandle.open, + high: Math.max(lastCandle.high, newPrice), + low: Math.min(lastCandle.low, newPrice), + close: newPrice + }; + + candlestickSeries.update(updatedCandle); + chartData[chartData.length - 1] = updatedCandle; + } else if (currentCandleTime > (lastCandle?.time || 0)) { + const newCandle = { + time: currentCandleTime, + open: newPrice, + high: newPrice, + low: newPrice, + close: newPrice + }; + + candlestickSeries.update(newCandle); + chartData.push(newCandle); + } + } async function handleTimeframeChange(timeframe: string) { selectedTimeframe = timeframe; @@ -110,9 +178,10 @@ }; }); } - let chartContainer = $state(); let chart: IChartApi | null = null; + let candlestickSeries: any = null; + let volumeSeries: any = null; $effect(() => { if (chart && chartData.length > 0) { @@ -155,8 +224,7 @@ horzLine: { color: '#758696', width: 1, style: 2, visible: true, labelVisible: true } } }); - - const candlestickSeries = chart.addSeries(CandlestickSeries, { + candlestickSeries = chart.addSeries(CandlestickSeries, { upColor: '#26a69a', downColor: '#ef5350', borderVisible: true, @@ -167,7 +235,7 @@ priceFormat: { type: 'price', precision: 8, minMove: 0.00000001 } }); - const volumeSeries = chart.addSeries( + volumeSeries = chart.addSeries( HistogramSeries, { priceFormat: { type: 'volume' }, @@ -286,6 +354,11 @@

{coin.name}

*{coin.symbol} + {#if $isConnectedStore} + + ● LIVE + + {/if} {#if !coin.isListed} Delisted {/if} @@ -293,9 +366,11 @@
-

- ${formatPrice(coin.currentPrice)} -

+
+

+ ${formatPrice(coin.currentPrice)} +

+
{#if coin.change24h >= 0} @@ -416,11 +491,10 @@ {/if} - - Liquidity Pool + Liquidity Pool
@@ -429,7 +503,9 @@
{coin.symbol}: - {formatSupply(coin.poolCoinAmount)} + + {formatSupply(coin.poolCoinAmount)} +
Base Currency: @@ -471,7 +547,9 @@ -

{formatMarketCap(coin.marketCap)}

+

+ {formatMarketCap(coin.marketCap)} +

@@ -484,7 +562,9 @@ -

{formatMarketCap(coin.volume24h)}

+

+ {formatMarketCap(coin.volume24h)} +