import useInterval from '@components/shared/useInterval' import mangoStore from '@store/mangoStore' import { useEffect, useMemo, useState } from 'react' import { formatNumericValue, getDecimalCount } from 'utils/numbers' import { useTranslation } from 'next-i18next' import useSelectedMarket from 'hooks/useSelectedMarket' import { Howl } from 'howler' import useLocalStorageState from 'hooks/useLocalStorageState' import { MANGO_DATA_API_URL, SOUND_SETTINGS_KEY, TRADE_VOLUME_ALERT_KEY, } from 'utils/constants' import { IconButton } from '@components/shared/Button' import { BellAlertIcon, BellSlashIcon } from '@heroicons/react/20/solid' import Tooltip from '@components/shared/Tooltip' import { INITIAL_SOUND_SETTINGS } from '@components/settings/SoundSettings' import TradeVolumeAlertModal, { DEFAULT_VOLUME_ALERT_SETTINGS, } from '@components/modals/TradeVolumeAlertModal' import dayjs from 'dayjs' import ErrorBoundary from '@components/ErrorBoundary' import { useQuery } from '@tanstack/react-query' import { PerpMarket } from '@blockworks-foundation/mango-v4' import { EmptyObject, isPerpFillEvent, PerpTradeHistory } from 'types' import { Market } from '@project-serum/serum' const volumeAlertSound = new Howl({ src: ['/sounds/trade-buy.mp3'], volume: 0.8, }) type Test = { buys: number; sells: number } const formatPrice = ( market: Market | PerpMarket | undefined, price: number | string ) => { return market?.tickSize ? formatNumericValue(price, getDecimalCount(market.tickSize)) : 0 } const formatSize = ( market: Market | PerpMarket | undefined, size: number | string ) => { return market?.minOrderSize ? formatNumericValue(size, getDecimalCount(market.minOrderSize)) : 0 } const fetchMarketTradeHistory = async (marketAddress: string) => { const response = await fetch( `${MANGO_DATA_API_URL}/stats/perp-market-history?perp-market=${marketAddress}` ) return response.json() } const RecentTrades = () => { const { t } = useTranslation(['common', 'trade']) const fills = mangoStore((s) => s.selectedMarket.fills) const [latestFillId, setLatestFillId] = useState('') const [soundSettings] = useLocalStorageState( SOUND_SETTINGS_KEY, INITIAL_SOUND_SETTINGS ) const [alertSettings] = useLocalStorageState( TRADE_VOLUME_ALERT_KEY, DEFAULT_VOLUME_ALERT_SETTINGS ) const [showVolumeAlertModal, setShowVolumeAlertModal] = useState(false) const { selectedMarket, serumOrPerpMarket: market, baseSymbol, quoteBank, quoteSymbol, selectedMarketAddress, } = useSelectedMarket() const perpMarketQuery = useQuery( ['market-trade-history', selectedMarketAddress], () => fetchMarketTradeHistory(selectedMarketAddress!), { cacheTime: 1000 * 60 * 15, staleTime: 0, enabled: !!selectedMarketAddress && market instanceof PerpMarket, refetchOnWindowFocus: true, refetchInterval: 1000 * 10, } ) useEffect(() => { const actions = mangoStore.getState().actions if (selectedMarket) { actions.loadMarketFills() } }, [selectedMarket]) useEffect(() => { if (!fills.length) return const latesetFill = fills[0] if (!latestFillId) { const fillId = isPerpFillEvent(latesetFill) ? latesetFill.takerClientOrderId : latesetFill.orderId setLatestFillId(fillId.toString()) } }, [fills]) useInterval(() => { const latesetFill = fills[0] if (!soundSettings['recent-trades'] || !quoteBank || !latesetFill) return const fillId = isPerpFillEvent(latesetFill) ? latesetFill.takerClientOrderId : latesetFill.orderId setLatestFillId(fillId.toString()) const fillsLimitIndex = fills.findIndex((f) => { const id = isPerpFillEvent(f) ? f.takerClientOrderId : f.orderId return id.toString() === fillId.toString() }) const newFillsVolumeValue = fills .slice(0, fillsLimitIndex) .reduce((a, c) => { const size = isPerpFillEvent(c) ? c.quantity : c.size return a + size * c.price }, 0) if (newFillsVolumeValue * quoteBank.uiPrice > Number(alertSettings.value)) { volumeAlertSound.play() } }, alertSettings.seconds * 1000) const [buyRatio, sellRatio] = useMemo(() => { if (!fills.length) return [0, 0] const vol = fills.reduce( (acc: Test, fill) => { let side let size if (isPerpFillEvent(fill)) { side = fill.takerSide === 0 ? 'buy' : 'sell' size = fill.quantity } else { side = fill.side size = fill.size } if (side === 'buy') { acc.buys = acc.buys + size } else { acc.sells = acc.sells + size } return acc }, { buys: 0, sells: 0 } ) const totalVol = vol.buys + vol.sells return [vol.buys / totalVol, vol.sells / totalVol] }, [fills]) return (
setShowVolumeAlertModal(true)} size="small" hideBg > {soundSettings['recent-trades'] ? ( ) : ( )} {t('trade:buys')}:{' '} {(buyRatio * 100).toFixed(1)}% | {t('trade:sells')}:{' '} {(sellRatio * 100).toFixed(1)}%
{selectedMarket instanceof PerpMarket ? perpMarketQuery?.data && Array.isArray(perpMarketQuery?.data) && perpMarketQuery?.data.map((t) => { return ( ) }) : !!fills.length && fills.map((trade, i: number) => { let side let size let time if (isPerpFillEvent(trade)) { side = trade.takerSide === 0 ? 'bid' : 'ask' size = trade.quantity time = trade.timestamp.toString() } else { side = trade.side size = trade.size time = '' } const formattedPrice = formatPrice(market, trade.price) const formattedSize = formatSize(market, size) return ( ) })}
{`${t( 'price' )} (${quoteSymbol})`} {t('trade:size')} ({baseSymbol}) {t('time')}
{formatPrice(market, t.price)} {formatSize(market, t.quantity)} {t.block_datetime ? ( {new Date(t.block_datetime).toLocaleTimeString()} ) : ( '-' )}
{formattedPrice} {formattedSize} {time ? dayjs(Number(time) * 1000).format('hh:mma') : '-'}
{showVolumeAlertModal ? ( setShowVolumeAlertModal(false)} /> ) : null}
) } export default RecentTrades