├── src ├── vite-env.d.ts ├── .DS_Store ├── types │ └── liquidation.ts ├── main.tsx ├── components │ ├── FundingRates.tsx │ ├── Achievements.tsx │ ├── Stats.tsx │ ├── MarketInformation.tsx │ ├── WhaleAlert.tsx │ ├── LiquidationTable.tsx │ └── FundingRatesTab.tsx ├── theme.ts ├── providers │ ├── BybitSymbols.tsx │ └── WebSocketProvider.tsx ├── store │ └── liquidationStore.ts └── App.tsx ├── .vscode └── settings.json ├── .DS_Store ├── dist ├── ping.mp3 ├── .DS_Store ├── snapshot.png ├── okx.svg ├── bybit.svg ├── bnb.svg └── index.html ├── public ├── .DS_Store ├── ping.mp3 ├── snapshot.png ├── okx.svg ├── bybit.svg └── bnb.svg ├── tsconfig.node.json ├── .api ├── apis │ └── fundingrates │ │ ├── package.json │ │ ├── types.ts │ │ └── index.ts └── api.json ├── .gitignore ├── vite.config.ts ├── tsconfig.json ├── netlify.toml ├── README.md ├── package.json ├── liqData.js ├── binance.csv ├── index.html └── styles.css /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "CodeGPT.apiKey": "CodeGPT Plus Beta" 3 | } -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mohamed1756/Crypto-Liquidation-Feed/HEAD/.DS_Store -------------------------------------------------------------------------------- /dist/ping.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mohamed1756/Crypto-Liquidation-Feed/HEAD/dist/ping.mp3 -------------------------------------------------------------------------------- /src/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mohamed1756/Crypto-Liquidation-Feed/HEAD/src/.DS_Store -------------------------------------------------------------------------------- /dist/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mohamed1756/Crypto-Liquidation-Feed/HEAD/dist/.DS_Store -------------------------------------------------------------------------------- /public/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mohamed1756/Crypto-Liquidation-Feed/HEAD/public/.DS_Store -------------------------------------------------------------------------------- /public/ping.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mohamed1756/Crypto-Liquidation-Feed/HEAD/public/ping.mp3 -------------------------------------------------------------------------------- /dist/snapshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mohamed1756/Crypto-Liquidation-Feed/HEAD/dist/snapshot.png -------------------------------------------------------------------------------- /public/snapshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mohamed1756/Crypto-Liquidation-Feed/HEAD/public/snapshot.png -------------------------------------------------------------------------------- /dist/okx.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/okx.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } -------------------------------------------------------------------------------- /.api/apis/fundingrates/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@api/fundingrates", 3 | "version": "0.0.0", 4 | "main": "./index.ts", 5 | "types": "./index.d.ts", 6 | "dependencies": { 7 | "api": "^6.1.3", 8 | "json-schema-to-ts": "^2.8.0-beta.0", 9 | "oas": "^20.11.0" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/types/liquidation.ts: -------------------------------------------------------------------------------- 1 | import { DateTime } from 'luxon'; 2 | 3 | export interface Liquidation { 4 | exchange: 'BINANCE' | 'BYBIT' | 'OKX'; 5 | symbol: string; 6 | side: 'BUY' | 'SELL'; 7 | orderType: string; 8 | quantity: number; 9 | price: number; 10 | orderStatus: string; 11 | timestamp: DateTime; 12 | value: number; 13 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Local Netlify folder 2 | .netlify 3 | node_modules 4 | 5 | #api 6 | /src/api/ 7 | 8 | #dist 9 | /dist 10 | 11 | # binance csv 12 | /binance.csv 13 | 14 | # liq data 15 | liqData.js 16 | 17 | 18 | # netlify toml 19 | netlify.toml 20 | 21 | # ,-------- VSCode ---------, 22 | .vscode 23 | 24 | # vite-env.d.ts 25 | .vite-env.d.ts 26 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import tailwindcss from '@tailwindcss/vite' 4 | 5 | export default defineConfig({ 6 | plugins: [react(), tailwindcss()], 7 | build: { 8 | rollupOptions: { 9 | input: { 10 | main: 'index.html' 11 | } 12 | } 13 | } 14 | }); -------------------------------------------------------------------------------- /.api/api.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "apis": [ 4 | { 5 | "identifier": "fundingrates", 6 | "source": "https://apibetadocs.lighter.xyz/openapi/66478b1a41f7300010f8aac2", 7 | "integrity": "sha512-zsvsd5qBCZ1Easaa+xhebEmWNqvVdASwNjJqlMV/l/he6KVq/sLGd4qNHzue2hEBcxiYgM+xHysIj+utqrl+5w==", 8 | "installerVersion": "6.1.3" 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App'; 4 | import { ColorModeScript, ChakraProvider } from '@chakra-ui/react' 5 | import { theme } from './theme' 6 | 7 | ReactDOM.createRoot(document.getElementById('root')!).render( 8 | 9 | 10 | 11 | 12 | 13 | 14 | ); -------------------------------------------------------------------------------- /src/components/FundingRates.tsx: -------------------------------------------------------------------------------- 1 | import sdk from '@api/fundingrates'; 2 | 3 | export interface SdkFundingRate { 4 | market_id: number; 5 | exchange: string; 6 | symbol: string; 7 | rate: number; 8 | } 9 | 10 | export async function fetchSdkFundingRates(): Promise { 11 | try { 12 | const { data } = await sdk.fundingRates(); 13 | if (data.code === 200 && data.funding_rates) { 14 | return data.funding_rates; 15 | } 16 | return []; 17 | } catch (error) { 18 | console.error('Error fetching from fundingrates SDK:', error); 19 | return []; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | import { extendTheme, type ThemeConfig } from '@chakra-ui/react' 2 | 3 | const config: ThemeConfig = { 4 | initialColorMode: 'dark', 5 | useSystemColorMode: false, 6 | } 7 | 8 | const theme = extendTheme({ 9 | config, 10 | styles: { 11 | global: (props: any) => ({ 12 | body: { 13 | bg: props.colorMode === 'dark' ? 'gray.900' : 'white', 14 | }, 15 | }), 16 | }, 17 | colors: { 18 | customBg: { 19 | light: 'white', 20 | dark: 'gray.800', 21 | }, 22 | customText: { 23 | light: 'gray.800', 24 | dark: 'whiteAlpha.900', 25 | } 26 | } 27 | }) 28 | 29 | export { theme } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "noEmit": true, 13 | "jsx": "react-jsx", 14 | "strict": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noFallthroughCasesInSwitch": true 18 | }, 19 | "include": ["src"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | # example netlify.toml 2 | [build] 3 | command = "npm run build" 4 | functions = "netlify/functions" 5 | publish = "dist" 6 | 7 | ## Uncomment to use this redirect for Single Page Applications like create-react-app. 8 | ## Not needed for static site generators. 9 | #[[redirects]] 10 | # from = "/*" 11 | # to = "/index.html" 12 | # status = 200 13 | 14 | ## (optional) Settings for Netlify Dev 15 | ## https://github.com/netlify/cli/blob/main/docs/netlify-dev.md#project-detection 16 | #[dev] 17 | # command = "yarn start" # Command to start your dev server 18 | # port = 3000 # Port that the dev server will be listening on 19 | # publish = "dist" # Folder with the static content for _redirect file 20 | 21 | ## more info on configuring this file: https://ntl.fyi/file-based-build-config 22 | -------------------------------------------------------------------------------- /src/providers/BybitSymbols.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | interface BybitSymbol { 4 | symbol: string; 5 | category: string; 6 | } 7 | 8 | export const useBybitSymbols = () => { 9 | const [symbols, setSymbols] = useState([]); 10 | 11 | useEffect(() => { 12 | const fetchSymbols = async () => { 13 | try { 14 | // Fetch linear USDT perpetual symbols 15 | const response = await fetch('https://api.bybit.com/v5/market/instruments-info?category=linear'); 16 | const data = await response.json(); 17 | 18 | if (data.retCode === 0) { 19 | const perpetualSymbols = data.result.list 20 | .filter((item: BybitSymbol) => item.symbol.endsWith('USDT')) 21 | .map((item: BybitSymbol) => item.symbol); 22 | 23 | setSymbols(perpetualSymbols); 24 | } 25 | } catch (error) { 26 | console.error('Failed to fetch Bybit symbols:', error); 27 | } 28 | }; 29 | 30 | fetchSymbols(); 31 | }, []); 32 | 33 | return symbols; 34 | }; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Crypto Liquidation Feed 2 | 3 | Real-time dashboard tracking crypto liquidations. 4 | 5 | ## 🚀 Live Website 6 | [View Live Website](https://cryptoliqfeed.netlify.app) 7 | 8 | 9 | 10 | 11 | ## ✨ Features 12 | - Live liquidation data for crypto perpetuals 13 | - Filter by symbol and amount 14 | - Gamified exp 15 | - Dark/Light mode toggle 16 | - Responsive design 17 | - Statistical overview of liquidations 18 | - Sound notifs for liquidations 19 | 20 | ## ✨ SUPPORTED EXCHANGES 21 | - Binance 22 | - Bybit 23 | - OKX 24 | 25 | ## ✨ TODO 26 | - ~~improve audio~~ 27 | - ~~improve search function~~ 28 | - ~~allow users to customise feed to thier liking~~ 29 | - make feed resizeable & draggable & portable. 30 | - ~~Add more Exchange feeds~~ 31 | - ~~Improve UI on liq table~~ 32 | - more stats ? 33 | 34 | 35 | ## 📦 Installation 36 | 37 | ```bash 38 | # Clone repository 39 | git clone https://github.com/Mohamed1756/Crypto-Liquidation-Feed/ 40 | 41 | # Install dependencies 42 | npm install 43 | 44 | # Run App locally 45 | npm run dev 46 | 47 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "binance-liquidation-feed", 3 | "private": true, 4 | "version": "1.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@api/fundingrates": "file:.api/apis/fundingrates", 13 | "@chakra-ui/icons": "^2.1.1", 14 | "@chakra-ui/react": "^2.8.1", 15 | "@emotion/react": "^11.11.1", 16 | "@emotion/styled": "^11.11.0", 17 | "@tailwindcss/vite": "^4.1.4", 18 | "bybit-api": "^4.1.3", 19 | "date-fns": "^4.1.0", 20 | "framer-motion": "^10.16.4", 21 | "lucide": "^0.476.0", 22 | "lucide-react": "^0.476.0", 23 | "luxon": "^3.4.3", 24 | "react": "^18.3.1", 25 | "react-dom": "^18.2.0", 26 | "react-draggable": "^4.4.5", 27 | "react-icons": "^4.11.0", 28 | "recharts": "^2.8.0", 29 | "tailwindcss": "^4.1.4", 30 | "use-sound": "^4.0.3", 31 | "ws": "^8.13.0", 32 | "zustand": "^4.4.1" 33 | }, 34 | "devDependencies": { 35 | "@types/luxon": "^3.3.2", 36 | "@types/react": "^18.2.15", 37 | "@types/react-dom": "^18.2.7", 38 | "@types/ws": "^8.5.5", 39 | "@vitejs/plugin-react": "^4.0.3", 40 | "ts-node": "^10.9.2", 41 | "typescript": "^5.8.3", 42 | "vite": "^5.4.18" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /dist/bybit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /public/bybit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /dist/bnb.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 14 | 19 | 22 | 25 | 28 | 31 | 34 | 35 | -------------------------------------------------------------------------------- /liqData.js: -------------------------------------------------------------------------------- 1 | const WebSocket = require('ws'); 2 | const fs = require('fs'); 3 | const { DateTime } = require('luxon'); 4 | 5 | const websocketUri = 'wss://fstream.binance.com/ws/!forceOrder@arr'; 6 | const filename = 'binance.csv'; 7 | 8 | if (!fs.existsSync(filename)) { 9 | fs.writeFileSync( 10 | filename, 11 | ['Symbol', 'Side', 'Order Type', 'Original Quantity', 'Liq Price', 'Order Status', 'TimeStamp', 'Value'].join(',') + '\n' 12 | ); 13 | } 14 | 15 | async function binanceLiquidations(uri, filename) { 16 | const ws = new WebSocket(uri); 17 | 18 | ws.on('open', () => { 19 | console.log('WebSocket connected'); 20 | }); 21 | 22 | ws.on('message', (message) => { 23 | try { 24 | const msg = JSON.parse(message)['o']; 25 | const symbol = msg['s']; 26 | const side = msg['S']; 27 | const orderType = msg['o']; 28 | const quantity = parseFloat(msg['q']); 29 | const averagePrice = parseFloat(msg['ap']); 30 | const orderStatus = msg['X']; 31 | const timestamp = parseInt(msg['T']); 32 | const value = quantity * averagePrice; 33 | 34 | // Convert timestamp to UTC datetime 35 | const tradeTime = DateTime.fromMillis(timestamp).toUTC().toFormat('HH:mm:ss'); 36 | 37 | const data = [ 38 | symbol, 39 | side, 40 | orderType, 41 | quantity.toString(), 42 | averagePrice.toString(), 43 | orderStatus, 44 | tradeTime, 45 | value.toString(), 46 | ]; 47 | 48 | fs.appendFileSync(filename, data.join(',') + '\n'); 49 | } catch (error) { 50 | console.error(error); 51 | } 52 | }); 53 | 54 | ws.on('close', () => { 55 | console.log('WebSocket closed'); 56 | }); 57 | 58 | ws.on('error', (error) => { 59 | console.error('WebSocket error:', error); 60 | }); 61 | } 62 | 63 | binanceLiquidations(websocketUri, filename).catch((error) => { 64 | console.error('An error occurred:', error); 65 | }); 66 | -------------------------------------------------------------------------------- /public/bnb.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 14 | 19 | 22 | 25 | 28 | 31 | 34 | 35 | -------------------------------------------------------------------------------- /binance.csv: -------------------------------------------------------------------------------- 1 | Symbol,Side,Order Type,Original Quantity,Liq Price,Order Status,TimeStamp,Value 2 | 1INCHUSDT,BUY,LIMIT,299,0.3505,FILLED,15:54:29,104.7995 3 | DOGEUSDT,SELL,LIMIT,1243,0.07243,FILLED,15:54:55,90.03048999999999 4 | DOGEUSDT,SELL,LIMIT,52957,0.07236,FILLED,15:55:06,3831.9685199999994 5 | 1000PEPEUSDT,SELL,LIMIT,1697693,0.0017995,FILLED,15:55:14,3054.9985535 6 | 1000PEPEUSDT,SELL,LIMIT,35382,0.0017943,FILLED,15:55:16,63.4859226 7 | 1000PEPEUSDT,SELL,LIMIT,768134,0.0017993,FILLED,15:55:17,1382.1035062 8 | 1000PEPEUSDT,SELL,LIMIT,53911,0.0017931,FILLED,15:55:21,96.6678141 9 | 1000PEPEUSDT,SELL,LIMIT,35102,0.0017938,FILLED,15:55:22,62.9659676 10 | GALAUSDT,BUY,LIMIT,30721,0.02667,FILLED,15:55:24,819.32907 11 | 1000PEPEUSDT,SELL,LIMIT,4664,0.001796,FILLED,15:55:24,8.376544 12 | GALAUSDT,BUY,LIMIT,4851,0.02669,FILLED,15:55:29,129.47319 13 | GALAUSDT,BUY,LIMIT,227,0.02669,FILLED,15:55:31,6.05863 14 | GALAUSDT,BUY,LIMIT,15470,0.02671,FILLED,15:55:33,413.2037 15 | GALAUSDT,BUY,LIMIT,165926,0.02671,FILLED,15:55:39,4431.88346 16 | GALAUSDT,BUY,LIMIT,170452,0.0268,FILLED,15:55:42,4568.113600000001 17 | GALABUSD,BUY,LIMIT,7152,0.0267898,FILLED,15:55:43,191.6006496 18 | GALAUSDT,BUY,LIMIT,42672,0.02676,FILLED,15:55:43,1141.90272 19 | DOTUSDT,SELL,LIMIT,3.6,5.64,FILLED,15:55:43,20.304 20 | BCHUSDT,SELL,LIMIT,1.213,252.04,FILLED,11:39:39,305.72452 21 | 1000SHIBUSDT,SELL,LIMIT,39466,0.008316,FILLED,11:39:40,328.199256 22 | BNBUSDT,SELL,LIMIT,0.1,250.44,FILLED,11:39:40,25.044 23 | BCHUSDT,SELL,LIMIT,2.305,252,FILLED,11:39:46,580.86 24 | BCHUSDT,SELL,LIMIT,19,251.38,FILLED,11:39:49,4776.22 25 | BCHUSDT,SELL,LIMIT,0.048,251.72,FILLED,11:39:50,12.08256 26 | COMPUSDT,BUY,LIMIT,0.35,76.96,FILLED,11:47:55,26.935999999999996 27 | COMPUSDT,BUY,LIMIT,6.881,77.36,FILLED,11:47:56,532.31416 28 | COMPUSDT,BUY,LIMIT,0.509,77.78,FILLED,11:47:57,39.59002 29 | STORJUSDT,SELL,LIMIT,120,0.3085,FILLED,11:47:57,37.019999999999996 30 | XVGUSDT,SELL,LIMIT,3378,0.006094,FILLED,11:47:57,20.585531999999997 31 | BCHUSDT,SELL,LIMIT,0.086,253.91,FILLED,11:47:57,21.83626 32 | COMPUSDT,BUY,LIMIT,11.513,77.33,FILLED,11:47:58,890.30029 33 | SOLUSDT,BUY,LIMIT,54,29.01,FILLED,11:47:58,1566.5400000000002 34 | FOOTBALLUSDT,SELL,LIMIT,0.97,400.78,FILLED,11:47:58,388.75659999999993 35 | SOLUSDT,BUY,LIMIT,1176,29.0273,FILLED,11:48:04,34136.1048 36 | SOLUSDT,BUY,LIMIT,1,29.044,FILLED,11:48:08,29.044 37 | -------------------------------------------------------------------------------- /src/components/Achievements.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Box, 4 | VStack, 5 | Text, 6 | Badge, 7 | useColorModeValue, 8 | Tooltip, 9 | Grid 10 | } from '@chakra-ui/react'; 11 | import { useLiquidationStore } from '../store/liquidationStore'; 12 | import { motion } from 'framer-motion'; 13 | 14 | const MotionBox = motion(Box); 15 | 16 | export const Achievements: React.FC = () => { 17 | const achievements = useLiquidationStore((state) => state.achievements); 18 | const bgColor = useColorModeValue('white', 'gray.800'); 19 | 20 | return ( 21 | 22 | 23 | 24 | {achievements.map((achievement) => ( 25 | 32 | 36 | 43 | 49 | {achievement.unlocked ? 'Unlocked' : 'Locked'} 50 | 51 | 52 | 53 | {achievement.title} 54 | 55 | 56 | {achievement.description} 57 | 58 | 59 | 60 | 61 | 62 | ))} 63 | 64 | 65 | ); 66 | }; 67 | 68 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Crypto Liquidation Feed 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 48 | 49 | 50 |
51 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Crypto Liquidation Feed 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 47 | 48 | 49 | 50 | 51 |
52 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /src/components/Stats.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { SimpleGrid, Stat, StatLabel, StatNumber, StatHelpText, Icon, Tooltip } from '@chakra-ui/react'; 3 | import { FaChartLine, FaFire, FaInfoCircle } from 'react-icons/fa'; 4 | import { useLiquidationStore } from '../store/liquidationStore'; 5 | 6 | export const Stats: React.FC = () => { 7 | const { totalValue, stats, highScore } = useLiquidationStore(); 8 | 9 | return ( 10 | 11 | 19 | 20 | Total Value $ 21 | 22 | 23 | {Math.round(totalValue).toLocaleString()} 24 | 25 | High Score: {highScore.toFixed(2).toLocaleString()} USDT 26 | 27 | 28 | 36 | 37 | Buy Liquidations 38 | 39 | 40 | 41 | 42 | 43 | 44 | {stats.buyCount} 45 | 46 | 47 | 55 | 56 | Sell Liquidations 57 | 58 | 59 | 60 | 61 | 62 | 63 | {stats.sellCount} 64 | 65 | 73 | 74 | Daily Streak 75 | 76 | {stats.dailyStreak} days 77 | 78 | 79 | ); 80 | }; -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | * { 4 | margin: 0; 5 | padding: 0; 6 | box-sizing: border-box; 7 | } 8 | 9 | body { 10 | font-family: 'Roboto Condensed', Arial, sans-serif; 11 | background-color: #2b2b2b; 12 | color: #e0e0e0; 13 | margin: 0; 14 | padding: 0; 15 | } 16 | 17 | .container { 18 | max-width: 800px; /* Adjust the max-width as needed */ 19 | margin: 0 auto; 20 | padding: 20px; 21 | } 22 | 23 | h1 { 24 | text-align: center; 25 | margin-bottom: 20px; 26 | color: #f0f0f0; 27 | font-family: 'Oswald', Arial, sans-serif; 28 | } 29 | 30 | table { 31 | width: 100%; 32 | border-collapse: collapse; 33 | margin-top: 20px; 34 | font-size: 14px; /* Reduce font size */ 35 | } 36 | 37 | thead { 38 | background-color: #444; 39 | } 40 | 41 | th, td { 42 | padding: 8px; /* Reduce padding */ 43 | border-bottom: 1px solid #666; 44 | text-align: center; 45 | } 46 | 47 | tbody tr:hover { 48 | background-color: #333; 49 | } 50 | 51 | .table-header { 52 | font-weight: bold; 53 | } 54 | 55 | .sell-lightRed-row { 56 | background-color: #d54343; 57 | } 58 | 59 | .sell-redOne-row { 60 | background-color: #b52e2e; 61 | } 62 | 63 | .sell-Red-row { 64 | background-color: #942020; 65 | } 66 | 67 | .sell-default-row { 68 | background-color: #6e0000; 69 | } 70 | 71 | .buy-lightGreen-row { 72 | background-color: #43a047; 73 | } 74 | 75 | .buy-greenOne-row { 76 | background-color: #2e7d32; 77 | } 78 | 79 | .buy-Green-row { 80 | background-color: #205723; 81 | } 82 | 83 | .buy-default-row { 84 | background-color: #004000; 85 | } 86 | 87 | /* Align values below each category */ 88 | td { 89 | vertical-align: top; 90 | } 91 | 92 | /* Set width for each column */ 93 | th:nth-child(1), 94 | td:nth-child(1) { 95 | width: 10%; 96 | } 97 | 98 | th:nth-child(2), 99 | td:nth-child(2) { 100 | width: 10%; 101 | } 102 | 103 | th:nth-child(3), 104 | td:nth-child(3) { 105 | width: 10%; 106 | } 107 | 108 | th:nth-child(4), 109 | td:nth-child(4) { 110 | width: 15%; 111 | } 112 | 113 | th:nth-child(5), 114 | td:nth-child(5) { 115 | width: 15%; 116 | } 117 | 118 | th:nth-child(6), 119 | td:nth-child(6) { 120 | width: 15%; 121 | } 122 | 123 | th:nth-child(7), 124 | td:nth-child(7) { 125 | width: 15%; 126 | } 127 | 128 | th:nth-child(8), 129 | td:nth-child(8) { 130 | width: 10%; 131 | } 132 | /* Styles for filter section */ 133 | .container { 134 | margin: 20px auto; 135 | padding: 10px; 136 | display: flex; 137 | justify-content: space-between; 138 | align-items: center; 139 | } 140 | 141 | .container label { 142 | font-weight: bold; 143 | margin-right: 5px; /* Adjust spacing between labels and inputs */ 144 | } 145 | 146 | .container select, 147 | .container input[type="number"] { 148 | padding: 8px; 149 | border: 1px solid #ccc; 150 | border-radius: 4px; 151 | -webkit-appearance: none; 152 | -moz-appearance: none; 153 | appearance: none; 154 | } 155 | 156 | .container select { 157 | margin-right: 20px; /* Adjust spacing between elements */ 158 | background-image: url('data:image/svg+xml;utf8,'); 159 | background-repeat: no-repeat; 160 | background-position: right 8px center; 161 | background-size: 12px; 162 | } 163 | 164 | .container input[type="number"]::-webkit-inner-spin-button, 165 | .container input[type="number"]::-webkit-outer-spin-button { 166 | -webkit-appearance: none; 167 | } 168 | 169 | .container input[type="number"] { 170 | -moz-appearance: textfield; 171 | appearance: textfield; 172 | } 173 | 174 | 175 | /* Fonts */ 176 | @import url('https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@300&family=Oswald:wght@500&display=swap'); 177 | 178 | @media screen and (max-width: 768px) { 179 | .container { 180 | padding: 8px; 181 | } 182 | 183 | table { 184 | min-width: 100%; 185 | font-size: 14px; 186 | } 187 | 188 | th, td { 189 | white-space: nowrap; 190 | padding: 8px 4px; 191 | } 192 | } -------------------------------------------------------------------------------- /src/store/liquidationStore.ts: -------------------------------------------------------------------------------- 1 | import create from 'zustand'; 2 | import { DateTime } from 'luxon'; 3 | import { Liquidation } from '../types/liquidation'; 4 | 5 | interface Achievement { 6 | id: string; 7 | title: string; 8 | description: string; 9 | unlocked: boolean; 10 | timestamp?: DateTime; 11 | } 12 | 13 | interface LiquidationState { 14 | liquidations: Liquidation[]; 15 | totalValue: number; 16 | highScore: number; 17 | achievements: Achievement[]; 18 | addLiquidation: (liquidation: Liquidation) => void; 19 | stats: { 20 | buyCount: number; 21 | sellCount: number; 22 | largestLiquidation: Liquidation | null; 23 | dailyStreak: number; 24 | lastActive: DateTime | null; 25 | }; 26 | } 27 | 28 | const ACHIEVEMENTS: Achievement[] = [ 29 | { 30 | id: 'first_million', 31 | title: '🏆 First Million', 32 | description: 'Witness 1M USDT in total liquidations', 33 | unlocked: false 34 | }, 35 | { 36 | id: 'whale_hunter', 37 | title: '🐋 Whale Hunter', 38 | description: 'Spot a single liquidation worth over 100k USDT', 39 | unlocked: false 40 | }, 41 | { 42 | id: 'balanced_view', 43 | title: '⚖️ Balanced View', 44 | description: 'See equal number buy/sell liquidations (min 10 each)', 45 | unlocked: false 46 | } 47 | ]; 48 | 49 | export const useLiquidationStore = create((set) => ({ 50 | liquidations: [], 51 | totalValue: 0, 52 | highScore: 0, 53 | achievements: ACHIEVEMENTS, 54 | stats: { 55 | buyCount: 0, 56 | sellCount: 0, 57 | largestLiquidation: null, 58 | dailyStreak: 0, 59 | lastActive: null 60 | }, 61 | addLiquidation: (liquidation) => 62 | set((state) => { 63 | const newLiquidations = [liquidation, ...state.liquidations].slice(0, 100); 64 | const newTotalValue = state.totalValue + liquidation.value; 65 | const newHighScore = Math.max(state.highScore, newTotalValue); 66 | 67 | // Update achievements 68 | const newAchievements = state.achievements.map(achievement => { 69 | if (achievement.unlocked) return achievement; 70 | 71 | let shouldUnlock = false; 72 | 73 | switch (achievement.id) { 74 | case 'first_million': 75 | shouldUnlock = newTotalValue >= 1000000; 76 | break; 77 | case 'whale_hunter': 78 | shouldUnlock = liquidation.value >= 100000; 79 | break; 80 | case 'balanced_view': 81 | const { buyCount, sellCount } = state.stats; 82 | shouldUnlock = buyCount >= 10 && sellCount >= 10 && buyCount === sellCount; 83 | break; 84 | } 85 | 86 | if (shouldUnlock) { 87 | return { ...achievement, unlocked: true, timestamp: DateTime.now() }; 88 | } 89 | return achievement; 90 | }); 91 | 92 | // Update streak 93 | const now = DateTime.now(); 94 | const lastActive = state.stats.lastActive; 95 | let dailyStreak = state.stats.dailyStreak; 96 | 97 | if (!lastActive) { 98 | dailyStreak = 1; 99 | } else { 100 | const daysDiff = now.diff(lastActive, 'days').days; 101 | if (daysDiff >= 1 && daysDiff < 2) { 102 | dailyStreak += 1; 103 | } else if (daysDiff >= 2) { 104 | dailyStreak = 1; 105 | } 106 | } 107 | 108 | const newStats = { 109 | buyCount: state.stats.buyCount + (liquidation.side === 'BUY' ? 1 : 0), 110 | sellCount: state.stats.sellCount + (liquidation.side === 'SELL' ? 1 : 0), 111 | largestLiquidation: 112 | !state.stats.largestLiquidation || liquidation.value > state.stats.largestLiquidation.value 113 | ? liquidation 114 | : state.stats.largestLiquidation, 115 | dailyStreak, 116 | lastActive: now 117 | }; 118 | 119 | return { 120 | liquidations: newLiquidations, 121 | totalValue: newTotalValue, 122 | highScore: newHighScore, 123 | achievements: newAchievements, 124 | stats: newStats 125 | }; 126 | }), 127 | })); -------------------------------------------------------------------------------- /src/providers/WebSocketProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { DateTime } from 'luxon'; 3 | import { useLiquidationStore } from '../store/liquidationStore'; 4 | import { useBybitSymbols } from './BybitSymbols'; 5 | import { Liquidation } from '../types/liquidation'; 6 | 7 | const ENDPOINTS = { 8 | BINANCE: 'wss://fstream.binance.com/ws/!forceOrder@arr', 9 | BYBIT: 'wss://stream.bybit.com/v5/public/linear', 10 | OKX: 'wss://ws.okx.com:8443/ws/v5/public', 11 | }; 12 | 13 | interface BybitLiquidation { 14 | topic: string; 15 | type: string; 16 | ts: number; 17 | data: [{ 18 | T: number; 19 | s: string; 20 | S: string; 21 | v: string; 22 | p: string; 23 | }]; 24 | } 25 | 26 | interface OkxLiquidation { 27 | arg: { 28 | channel: string; 29 | instType: string; 30 | }; 31 | data: Array<{ 32 | details: Array<{ 33 | bkLoss: string; 34 | bkPx: string; 35 | ccy: string; 36 | posSide: 'long' | 'short'; 37 | side: 'buy' | 'sell'; 38 | sz: string; 39 | ts: string; 40 | }>; 41 | instFamily: string; 42 | instId: string; 43 | instType: string; 44 | uly: string; 45 | }>; 46 | } 47 | 48 | export const WebSocketProvider: React.FC<{ 49 | children: React.ReactNode, 50 | exchanges?: ('BINANCE' | 'BYBIT' | 'OKX')[] 51 | }> = ({ 52 | children, 53 | exchanges = ['BINANCE', 'BYBIT', 'OKX'] 54 | }) => { 55 | const addLiquidation = useLiquidationStore((state) => state.addLiquidation); 56 | const bybitSymbols = useBybitSymbols(); 57 | 58 | useEffect(() => { 59 | console.log('WebSocketProvider useEffect running with exchanges:', exchanges); 60 | 61 | const connections = new Map(); 62 | const pingIntervals = new Map(); 63 | 64 | exchanges.forEach(exchange => { 65 | console.log(`Attempting to create WebSocket for ${exchange} at ${ENDPOINTS[exchange]}`); 66 | 67 | let ws: WebSocket; 68 | try { 69 | ws = new WebSocket(ENDPOINTS[exchange]); 70 | } catch (error) { 71 | console.error(`Failed to instantiate WebSocket for ${exchange}:`, error); 72 | return; 73 | } 74 | 75 | ws.onopen = () => { 76 | console.log(`${exchange} WebSocket connected`); 77 | if (exchange === 'BYBIT') { 78 | const subscriptionArgs = bybitSymbols.map(symbol => `allLiquidation.${symbol}`); 79 | console.log('BYBIT sending subscription:', subscriptionArgs); 80 | ws.send(JSON.stringify({ 81 | op: 'subscribe', 82 | args: subscriptionArgs 83 | })); 84 | } else if (exchange === 'OKX') { 85 | const subscription = { 86 | op: 'subscribe', 87 | args: [{ 88 | channel: 'liquidation-orders', 89 | instType: 'SWAP' 90 | }] 91 | }; 92 | console.log('OKX sending subscription:', JSON.stringify(subscription)); 93 | ws.send(JSON.stringify(subscription)); 94 | 95 | // Set up ping (using simple string 'ping' as per your friend's working code) 96 | const pingInterval = setInterval(() => { 97 | if (ws.readyState === WebSocket.OPEN) { 98 | console.log('OKX sending ping...'); 99 | ws.send('ping'); 100 | } 101 | }, 25000); 102 | pingIntervals.set(exchange, pingInterval); 103 | } 104 | }; 105 | 106 | ws.onclose = () => { 107 | console.log(`${exchange} WebSocket closed`); 108 | const interval = pingIntervals.get(exchange); 109 | if (interval) clearInterval(interval); 110 | }; 111 | 112 | ws.onerror = (error) => { 113 | console.error(`${exchange} WebSocket error:`, error); 114 | }; 115 | 116 | ws.onmessage = (event) => { 117 | try { 118 | const message = event.data.toString(); 119 | console.log(`${exchange} received raw message:`, message); 120 | 121 | // Handle OKX pong response (as plain string) 122 | if (exchange === 'OKX' && message === 'pong') { 123 | console.log('OKX pong received'); 124 | return; 125 | } 126 | 127 | const data = JSON.parse(message); 128 | let liquidation: Liquidation | undefined; 129 | 130 | if (exchange === 'BINANCE') { 131 | const msg = data.o; 132 | if (msg) { 133 | liquidation = { 134 | exchange: 'BINANCE', 135 | symbol: msg.s, 136 | side: msg.S as 'BUY' | 'SELL', 137 | orderType: msg.o, 138 | quantity: parseFloat(msg.q), 139 | price: parseFloat(msg.ap), 140 | orderStatus: msg.X, 141 | timestamp: DateTime.fromMillis(parseInt(msg.T)), 142 | value: parseFloat(msg.q) * parseFloat(msg.ap), 143 | }; 144 | } 145 | } else if (exchange === 'BYBIT') { 146 | const bybitMsg = data as BybitLiquidation; 147 | if (bybitMsg.topic?.includes('allLiquidation') && bybitMsg.data?.[0]) { 148 | const msg = bybitMsg.data[0]; 149 | if (!msg.s || !msg.S || !msg.v || !msg.p) { 150 | console.warn('Invalid Bybit message structure:', msg); 151 | return; 152 | } 153 | const side = msg.S.toUpperCase() === 'BUY' ? 'SELL' : 'BUY'; 154 | liquidation = { 155 | exchange: 'BYBIT', 156 | symbol: msg.s, 157 | side: side as 'BUY' | 'SELL', 158 | orderType: 'LIMIT', 159 | quantity: parseFloat(msg.v), 160 | price: parseFloat(msg.p), 161 | orderStatus: 'FILLED', 162 | timestamp: DateTime.fromMillis(msg.T), 163 | value: parseFloat(msg.v) * parseFloat(msg.p), 164 | }; 165 | } 166 | } else if (exchange === 'OKX') { 167 | console.log('OKX processing data:', data); 168 | 169 | // Handle subscription confirmation 170 | if ('event' in data && data.event === 'subscribe') { 171 | console.log('OKX subscription confirmed:', data.arg); 172 | return; 173 | } 174 | 175 | // Process liquidation data with the correct structure 176 | const okxMsg = data as OkxLiquidation; 177 | if (okxMsg.arg?.channel === 'liquidation-orders' && okxMsg.data?.[0]?.details?.[0]) { 178 | const detail = okxMsg.data[0].details[0]; 179 | const instrument = okxMsg.data[0]; 180 | 181 | // Convert position side to order side 182 | // When a long position is liquidated, the exchange performs a sell 183 | // When a short position is liquidated, the exchange performs a buy 184 | const side = detail.posSide === 'short' ? 'BUY' : 'SELL'; 185 | 186 | liquidation = { 187 | exchange: 'OKX', 188 | symbol: instrument.instId, 189 | side: side as 'BUY' | 'SELL', 190 | orderType: 'MARKET', 191 | quantity: parseFloat(detail.sz), 192 | price: parseFloat(detail.bkPx), 193 | orderStatus: 'FILLED', 194 | timestamp: DateTime.fromMillis(parseInt(detail.ts)), 195 | value: parseFloat(detail.sz) * parseFloat(detail.bkPx), 196 | }; 197 | console.log('OKX parsed liquidation:', liquidation); 198 | } else { 199 | console.log('OKX no liquidation data found in structure:', okxMsg); 200 | } 201 | } 202 | 203 | if (liquidation) { 204 | console.log(`${exchange} adding liquidation to store:`, liquidation); 205 | addLiquidation(liquidation); 206 | } 207 | } catch (error) { 208 | console.error(`${exchange} message parsing error:`, error); 209 | } 210 | }; 211 | 212 | connections.set(exchange, ws); 213 | }); 214 | 215 | return () => { 216 | console.log('Cleaning up WebSocket connections'); 217 | connections.forEach(ws => ws.close()); 218 | pingIntervals.forEach(interval => clearInterval(interval)); 219 | }; 220 | }, [addLiquidation, exchanges, bybitSymbols]); 221 | 222 | return <>{children}; 223 | }; -------------------------------------------------------------------------------- /src/components/MarketInformation.tsx: -------------------------------------------------------------------------------- 1 | // components/MarketInformation.tsx 2 | import React, { useState, useEffect } from 'react'; 3 | import { 4 | Box, VStack, HStack, Stat, StatLabel, StatNumber, StatHelpText, 5 | StatArrow, SimpleGrid, Skeleton, Text, Badge, Tooltip, Divider 6 | } from '@chakra-ui/react'; 7 | import { InfoIcon } from '@chakra-ui/icons'; 8 | 9 | // Define types for market data 10 | interface MarketData { 11 | totalOI: number; 12 | dailyVolume: number; 13 | dominanceData: { symbol: string; percentage: number }[]; 14 | marketTrend: 'bullish' | 'bearish' | 'neutral'; 15 | fundingHealth: 'positive' | 'negative' | 'neutral'; 16 | lastUpdated: Date; 17 | } 18 | 19 | // API endpoints configuration 20 | const API_ENDPOINTS = { 21 | OPEN_INTEREST: 'https://api.coingecko.com/api/v3/derivatives/exchanges', 22 | VOLUME: 'https://api.coingecko.com/api/v3/global', 23 | DOMINANCE: 'https://api.coingecko.com/api/v3/global', 24 | FUNDING_RATES: 'https://fapi.binance.com/fapi/v1/premiumIndex' 25 | }; 26 | 27 | export const MarketInformation: React.FC = () => { 28 | const [isLoading, setIsLoading] = useState(true); 29 | const [marketData, setMarketData] = useState(null); 30 | const [error, setError] = useState(null); 31 | 32 | useEffect(() => { 33 | const fetchMarketData = async () => { 34 | setIsLoading(true); 35 | setError(null); 36 | 37 | try { 38 | // Fetch data from multiple APIs in parallel 39 | const [volumeData, dominanceData, fundingData] = await Promise.all([ 40 | fetch(API_ENDPOINTS.VOLUME).then(res => res.json()), 41 | fetch(API_ENDPOINTS.DOMINANCE).then(res => res.json()), 42 | fetch(API_ENDPOINTS.FUNDING_RATES).then(res => res.json()) 43 | ]); 44 | 45 | // Calculate market trend based on 24h price changes 46 | const btcChange = dominanceData.data.market_cap_percentage.btc_24h_change; 47 | const ethChange = dominanceData.data.market_cap_percentage.eth_24h_change; 48 | const marketTrend = (btcChange > 2 && ethChange > 2) ? 'bullish' : 49 | (btcChange < -2 || ethChange < -2) ? 'bearish' : 'neutral'; 50 | 51 | // Calculate funding health (simplified) 52 | const averageFunding = fundingData.reduce((acc: number, curr: any) => 53 | acc + parseFloat(curr.lastFundingRate), 0) / fundingData.length; 54 | const fundingHealth = averageFunding > 0 ? 'positive' : averageFunding < 0 ? 'negative' : 'neutral'; 55 | 56 | setMarketData({ 57 | totalOI: volumeData.data.total_derivatives_volume_24h / 1000000000, // Convert to billions 58 | dailyVolume: volumeData.data.total_volume / 1000000000, // Convert to billions 59 | dominanceData: [ 60 | { symbol: 'BTC', percentage: Math.round(dominanceData.data.market_cap_percentage.btc * 10) / 10 }, 61 | { symbol: 'ETH', percentage: Math.round(dominanceData.data.market_cap_percentage.eth * 10) / 10 }, 62 | { symbol: 'SOL', percentage: Math.round(dominanceData.data.market_cap_percentage.sol * 10) / 10 || 0 }, 63 | { symbol: 'Others', percentage: Math.round( 64 | (100 - 65 | dominanceData.data.market_cap_percentage.btc - 66 | dominanceData.data.market_cap_percentage.eth - 67 | (dominanceData.data.market_cap_percentage.sol || 0)) * 10 68 | ) / 10 } 69 | ], 70 | marketTrend, 71 | fundingHealth, 72 | lastUpdated: new Date() 73 | }); 74 | } catch (err) { 75 | console.error('Failed to fetch market data:', err); 76 | setError('Failed to load market data. Please try again later.'); 77 | // Fallback to dummy data if API fails 78 | setMarketData({ 79 | totalOI: 12.7, 80 | dailyVolume: 45.3, 81 | dominanceData: [ 82 | { symbol: 'BTC', percentage: 42.3 }, 83 | { symbol: 'ETH', percentage: 28.7 }, 84 | { symbol: 'SOL', percentage: 8.2 }, 85 | { symbol: 'Others', percentage: 20.8 } 86 | ], 87 | marketTrend: 'neutral', 88 | fundingHealth: 'neutral', 89 | lastUpdated: new Date() 90 | }); 91 | } finally { 92 | setIsLoading(false); 93 | } 94 | }; 95 | 96 | fetchMarketData(); 97 | // Refresh every 5 minutes 98 | const interval = setInterval(fetchMarketData, 5 * 60 * 1000); 99 | return () => clearInterval(interval); 100 | }, []); 101 | 102 | return ( 103 | 104 | {error && ( 105 | 106 | {error} Using cached data. 107 | 108 | )} 109 | 110 | {isLoading ? ( 111 | 112 | {[...Array(4)].map((_, i) => ( 113 | 114 | ))} 115 | 116 | ) : marketData && ( 117 | 118 | 119 | 120 | Total Open Interest 121 | 122 | ${marketData.totalOI.toFixed(1)}B 123 | 124 | 125 | 126 | 127 | 128 | {marketData.marketTrend === 'bullish' ? ( 129 | 130 | ) : marketData.marketTrend === 'bearish' ? ( 131 | 132 | ) : null} 133 | {marketData.marketTrend !== 'neutral' ? 'Market is ' + marketData.marketTrend : 'Market is neutral'} 134 | 135 | 136 | 137 | 138 | 24h Trading Volume 139 | 140 | ${marketData.dailyVolume.toFixed(1)}B 141 | 142 | 143 | 144 | 145 | 146 | {marketData.fundingHealth === 'positive' ? ( 147 | 148 | ) : marketData.fundingHealth === 'negative' ? ( 149 | 150 | ) : null} 151 | {marketData.fundingHealth !== 'neutral' ? 'Funding rates are ' + marketData.fundingHealth : 'Funding rates are neutral'} 152 | 153 | 154 | 155 | 156 | 157 | Market Dominance 158 | 159 | {marketData.dominanceData.map(item => ( 160 | 161 | {item.symbol} 162 | {item.percentage}% 163 | 164 | ))} 165 | 166 | 167 | 168 | 169 | 170 | Market Sentiment 171 | 172 | {marketData.marketTrend.toUpperCase()} 173 | 174 | 175 | 176 | 177 | Funding Rate Health 178 | 179 | {marketData.fundingHealth.toUpperCase()} 180 | 181 | 182 | 183 | 184 | 185 | Last updated: {marketData.lastUpdated.toLocaleTimeString()} 186 | 187 | 188 | )} 189 | 190 | ); 191 | }; -------------------------------------------------------------------------------- /.api/apis/fundingrates/types.ts: -------------------------------------------------------------------------------- 1 | import type { FromSchema } from 'json-schema-to-ts'; 2 | import * as schemas from './schemas'; 3 | 4 | export type AccountInactiveOrdersMetadataParam = FromSchema; 5 | export type AccountInactiveOrdersResponse200 = FromSchema; 6 | export type AccountInactiveOrdersResponse400 = FromSchema; 7 | export type AccountLimitsMetadataParam = FromSchema; 8 | export type AccountLimitsResponse200 = FromSchema; 9 | export type AccountLimitsResponse400 = FromSchema; 10 | export type AccountMetadataMetadataParam = FromSchema; 11 | export type AccountMetadataParam = FromSchema; 12 | export type AccountMetadataResponse200 = FromSchema; 13 | export type AccountMetadataResponse400 = FromSchema; 14 | export type AccountResponse200 = FromSchema; 15 | export type AccountResponse400 = FromSchema; 16 | export type AccountTxsMetadataParam = FromSchema; 17 | export type AccountTxsResponse200 = FromSchema; 18 | export type AccountTxsResponse400 = FromSchema; 19 | export type AccountsByL1AddressMetadataParam = FromSchema; 20 | export type AccountsByL1AddressResponse200 = FromSchema; 21 | export type AccountsByL1AddressResponse400 = FromSchema; 22 | export type AnnouncementResponse200 = FromSchema; 23 | export type AnnouncementResponse400 = FromSchema; 24 | export type ApikeysMetadataParam = FromSchema; 25 | export type ApikeysResponse200 = FromSchema; 26 | export type ApikeysResponse400 = FromSchema; 27 | export type BlockMetadataParam = FromSchema; 28 | export type BlockResponse200 = FromSchema; 29 | export type BlockResponse400 = FromSchema; 30 | export type BlockTxsMetadataParam = FromSchema; 31 | export type BlockTxsResponse200 = FromSchema; 32 | export type BlockTxsResponse400 = FromSchema; 33 | export type BlocksMetadataParam = FromSchema; 34 | export type BlocksResponse200 = FromSchema; 35 | export type BlocksResponse400 = FromSchema; 36 | export type CandlesticksMetadataParam = FromSchema; 37 | export type CandlesticksResponse200 = FromSchema; 38 | export type CandlesticksResponse400 = FromSchema; 39 | export type CurrentHeightResponse200 = FromSchema; 40 | export type CurrentHeightResponse400 = FromSchema; 41 | export type DepositHistoryMetadataParam = FromSchema; 42 | export type DepositHistoryResponse200 = FromSchema; 43 | export type DepositHistoryResponse400 = FromSchema; 44 | export type ExchangeStatsResponse200 = FromSchema; 45 | export type ExchangeStatsResponse400 = FromSchema; 46 | export type ExportMetadataParam = FromSchema; 47 | export type ExportResponse200 = FromSchema; 48 | export type ExportResponse400 = FromSchema; 49 | export type FastbridgeInfoResponse200 = FromSchema; 50 | export type FastbridgeInfoResponse400 = FromSchema; 51 | export type FundingRatesResponse200 = FromSchema; 52 | export type FundingRatesResponse400 = FromSchema; 53 | export type FundingsMetadataParam = FromSchema; 54 | export type FundingsResponse200 = FromSchema; 55 | export type FundingsResponse400 = FromSchema; 56 | export type InfoResponse200 = FromSchema; 57 | export type InfoResponse400 = FromSchema; 58 | export type L1MetadataMetadataParam = FromSchema; 59 | export type L1MetadataResponse200 = FromSchema; 60 | export type L1MetadataResponse400 = FromSchema; 61 | export type LiquidationsMetadataParam = FromSchema; 62 | export type LiquidationsResponse200 = FromSchema; 63 | export type LiquidationsResponse400 = FromSchema; 64 | export type NextNonceMetadataParam = FromSchema; 65 | export type NextNonceResponse200 = FromSchema; 66 | export type NextNonceResponse400 = FromSchema; 67 | export type NotificationAckBodyParam = FromSchema; 68 | export type NotificationAckMetadataParam = FromSchema; 69 | export type NotificationAckResponse200 = FromSchema; 70 | export type NotificationAckResponse400 = FromSchema; 71 | export type OrderBookDetailsMetadataParam = FromSchema; 72 | export type OrderBookDetailsResponse200 = FromSchema; 73 | export type OrderBookDetailsResponse400 = FromSchema; 74 | export type OrderBooksMetadataParam = FromSchema; 75 | export type OrderBooksResponse200 = FromSchema; 76 | export type OrderBooksResponse400 = FromSchema; 77 | export type PnlMetadataParam = FromSchema; 78 | export type PnlResponse200 = FromSchema; 79 | export type PnlResponse400 = FromSchema; 80 | export type PositionFundingMetadataParam = FromSchema; 81 | export type PositionFundingResponse200 = FromSchema; 82 | export type PositionFundingResponse400 = FromSchema; 83 | export type PublicPoolsMetadataParam = FromSchema; 84 | export type PublicPoolsResponse200 = FromSchema; 85 | export type PublicPoolsResponse400 = FromSchema; 86 | export type RecentTradesMetadataParam = FromSchema; 87 | export type RecentTradesResponse200 = FromSchema; 88 | export type RecentTradesResponse400 = FromSchema; 89 | export type ReferralPointsMetadataParam = FromSchema; 90 | export type ReferralPointsResponse200 = FromSchema; 91 | export type ReferralPointsResponse400 = FromSchema; 92 | export type SendTxBatchBodyParam = FromSchema; 93 | export type SendTxBatchResponse200 = FromSchema; 94 | export type SendTxBatchResponse400 = FromSchema; 95 | export type SendTxBodyParam = FromSchema; 96 | export type SendTxResponse200 = FromSchema; 97 | export type SendTxResponse400 = FromSchema; 98 | export type StatusResponse200 = FromSchema; 99 | export type StatusResponse400 = FromSchema; 100 | export type TradesMetadataParam = FromSchema; 101 | export type TradesResponse200 = FromSchema; 102 | export type TradesResponse400 = FromSchema; 103 | export type TxFromL1TxHashMetadataParam = FromSchema; 104 | export type TxFromL1TxHashResponse200 = FromSchema; 105 | export type TxFromL1TxHashResponse400 = FromSchema; 106 | export type TxMetadataParam = FromSchema; 107 | export type TxResponse200 = FromSchema; 108 | export type TxResponse400 = FromSchema; 109 | export type TxsMetadataParam = FromSchema; 110 | export type TxsResponse200 = FromSchema; 111 | export type TxsResponse400 = FromSchema; 112 | export type WithdrawHistoryMetadataParam = FromSchema; 113 | export type WithdrawHistoryResponse200 = FromSchema; 114 | export type WithdrawHistoryResponse400 = FromSchema; 115 | -------------------------------------------------------------------------------- /src/components/WhaleAlert.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useMemo } from 'react'; 2 | import { 3 | Box, 4 | Heading, 5 | Table, 6 | Tbody, 7 | Tr, 8 | Td, 9 | Text, 10 | Flex, 11 | Badge, 12 | VStack, 13 | useColorModeValue, 14 | Tooltip, 15 | TableContainer, 16 | Thead, 17 | Th, 18 | HStack 19 | } from '@chakra-ui/react'; 20 | import { motion, AnimatePresence } from 'framer-motion'; 21 | import { useLiquidationStore } from '../store/liquidationStore'; 22 | import { Fish } from "lucide-react"; 23 | import useSound from 'use-sound'; 24 | 25 | const MotionBox = motion(Box); 26 | const MotionTr = motion(Tr); 27 | 28 | interface Props { 29 | compact?: boolean; 30 | soundEnabled: boolean; 31 | threshold?: number; 32 | height?: string; 33 | retention?: number; // How long to keep entries in minutes 34 | } 35 | 36 | export const WhaleAlertTable: React.FC = React.memo(({ 37 | 38 | soundEnabled, 39 | threshold = 250000, 40 | height = "500px", 41 | retention = 60 // Default: keep whale alerts for 60 minutes 42 | }) => { 43 | const liquidations = useLiquidationStore((state) => state.liquidations); 44 | const [currentTime, setCurrentTime] = useState(Date.now()); 45 | const [lastSoundPlayed, setLastSoundPlayed] = useState(0); 46 | const [playWhaleSound] = useSound('/whale.mp3', { volume: 0.5 }); 47 | 48 | // NEW: Local state to persist whale liquidations longer 49 | const [persistedWhaleLiquidations, setPersistedWhaleLiquidations] = useState([]); 50 | 51 | // Theme colors 52 | const bgColor = useColorModeValue('white', 'gray.800'); 53 | const borderColor = useColorModeValue('gray.200', 'gray.700'); 54 | const headerBg = useColorModeValue('purple.50', 'purple.900'); 55 | const headerColor = useColorModeValue('purple.700', 'purple.200'); 56 | const placeholderColor = useColorModeValue('gray.500', 'gray.400'); 57 | const scrollbarTrack = useColorModeValue('rgba(0,0,0,0.03)', 'rgba(255,255,255,0.03)'); 58 | const scrollbarThumb = useColorModeValue('rgba(0,0,0,0.15)', 'rgba(255,255,255,0.15)'); 59 | 60 | // NEW: Update our persisted whale liquidations when new ones arrive 61 | useEffect(() => { 62 | // Find high-value liquidations 63 | const newWhaleLiquidations = liquidations.filter(l => l.value >= threshold); 64 | 65 | if (newWhaleLiquidations.length === 0) return; 66 | 67 | // Update our persisted list with new whale liquidations 68 | setPersistedWhaleLiquidations(prevWhales => { 69 | // Get existing IDs to avoid duplicates 70 | const existingIds = new Set(prevWhales.map(whale => 71 | `${whale.timestamp.toISO()}-${whale.symbol}-${whale.value}`)); 72 | 73 | // Add only new whale liquidations 74 | const newWhales = newWhaleLiquidations.filter(whale => 75 | !existingIds.has(`${whale.timestamp.toISO()}-${whale.symbol}-${whale.value}`)); 76 | 77 | // Merge without duplicates 78 | return [...prevWhales, ...newWhales]; 79 | }); 80 | }, [liquidations, threshold]); 81 | 82 | // NEW: Clean up old whale liquidations based on retention period 83 | useEffect(() => { 84 | const cleanupInterval = setInterval(() => { 85 | const retentionMs = retention * 60 * 1000; // Convert minutes to ms 86 | const cutoffTime = Date.now() - retentionMs; 87 | 88 | setPersistedWhaleLiquidations(prevWhales => 89 | prevWhales.filter(whale => 90 | whale.timestamp.toMillis() > cutoffTime 91 | ) 92 | ); 93 | }, 60000); // Check every minute 94 | 95 | return () => clearInterval(cleanupInterval); 96 | }, [retention]); 97 | 98 | // Use our persisted list instead of filtering the store directly 99 | const whaleLiquidations = useMemo(() => 100 | [...persistedWhaleLiquidations] 101 | .sort((a, b) => b.timestamp.toMillis() - a.timestamp.toMillis()) 102 | .slice(0, 100), // Limit to improve performance 103 | [persistedWhaleLiquidations] 104 | ); 105 | 106 | // Sound effect with debounce 107 | useEffect(() => { 108 | if (!soundEnabled || whaleLiquidations.length === 0) return; 109 | 110 | const latestLiquidation = whaleLiquidations[0]; 111 | const timeDiff = Date.now() - latestLiquidation.timestamp.toMillis(); 112 | const soundCooldown = 5000; // 5 seconds cooldown 113 | 114 | if (timeDiff < 10000 && Date.now() - lastSoundPlayed > soundCooldown) { 115 | playWhaleSound(); 116 | setLastSoundPlayed(Date.now()); 117 | } 118 | }, [whaleLiquidations, playWhaleSound, soundEnabled, lastSoundPlayed]); 119 | 120 | // Time update 121 | useEffect(() => { 122 | const interval = setInterval(() => { 123 | setCurrentTime(Date.now()); 124 | }, 1000); 125 | return () => clearInterval(interval); 126 | }, []); 127 | 128 | const formatLargeNumber = (num: number) => { 129 | if (num >= 1000000) return `$${(num / 1000000).toFixed(1)}M`; 130 | if (num >= 1000) return `$${(num / 1000).toFixed(0)}K`; 131 | return `$${num.toFixed(0)}`; 132 | }; 133 | 134 | const formatTimeAgo = (timestamp: any) => { 135 | const timeAgoInSeconds = Math.floor((currentTime - timestamp.toMillis()) / 1000); 136 | const timeAgoInMinutes = Math.floor(timeAgoInSeconds / 60); 137 | 138 | if (timeAgoInSeconds < 60) return `${timeAgoInSeconds}s`; 139 | if (timeAgoInMinutes < 60) return `${timeAgoInMinutes}m`; 140 | return `${Math.floor(timeAgoInMinutes / 60)}h${timeAgoInMinutes % 60}m`; 141 | }; 142 | 143 | const formatSymbol = (symbol: string) => { 144 | return symbol.replace('USDT', '').replace(/--?SWAP/, ''); 145 | }; 146 | 147 | // Highlight new whales (less than 30s old) 148 | const isNewWhale = (timestamp: any) => { 149 | return (currentTime - timestamp.toMillis()) < 30000; 150 | }; 151 | 152 | // Row animation variants 153 | const rowVariants = { 154 | initial: { 155 | opacity: 0, 156 | transform: 'translateY(-5px)' 157 | }, 158 | animate: { 159 | opacity: 1, 160 | transform: 'translateY(0px)', 161 | transition: { 162 | duration: 0.12, 163 | ease: [0.16, 1, 0.3, 1], // Fast-out, slow-in curve 164 | } 165 | }, 166 | exit: { 167 | opacity: 0, 168 | transition: { duration: 0.08 } 169 | } 170 | }; 171 | 172 | return ( 173 | 185 | {/* Simplified Header */} 186 | 194 | 198 | 199 | 200 | 201 | Whale Alerts 202 | 203 | 204 | 205 | 212 | {formatLargeNumber(threshold)}+ 213 | 214 | 222 | {retention}m history 223 | 224 | 225 | 226 | 227 | 228 | {/* Table Container with Fixed Height */} 229 | 245 | {whaleLiquidations.length > 0 ? ( 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | {whaleLiquidations.map((liquidation) => { 257 | const isNew = isNewWhale(liquidation.timestamp); 258 | // Calculate time for visual indication (newer entries are more vibrant) 259 | const entryAge = (currentTime - liquidation.timestamp.toMillis()) / 1000; 260 | const fadeLevel = Math.min(1, entryAge / (retention * 60)); 261 | const opacity = 1 - (fadeLevel * 0.4); // Max fade to 60% opacity 262 | 263 | return ( 264 | 288 | 317 | 336 | 351 | 352 | ); 353 | })} 354 | 355 | 356 |
AssetValueTime
289 | 290 | {liquidation.exchange === 'BINANCE' && ( 291 | 292 | Binance 293 | 294 | )} 295 | {liquidation.exchange === 'BYBIT' && ( 296 | 297 | Bybit 298 | 299 | )} 300 | {liquidation.exchange === 'OKX' && ( 301 | 302 | OKX 303 | 304 | )} 305 | 313 | {formatSymbol(liquidation.symbol)} 314 | 315 | 316 | 318 | 324 | 332 | {formatLargeNumber(liquidation.value)} 333 | 334 | 335 | 337 | 348 | {formatTimeAgo(liquidation.timestamp)} 349 | 350 |
357 | ) : ( 358 | 364 | 365 | 376 | 377 | 378 | 383 | Watching for whale liquidations
over {formatLargeNumber(threshold)} 384 |
385 |
386 |
387 | )} 388 |
389 | 390 | {/* Simplified Footer */} 391 | 401 | {whaleLiquidations.length > 0 ? ( 402 | `${whaleLiquidations.length} whale liquidations displayed • Retained for ${retention} minutes` 403 | ) : ( 404 | "Monitoring market for large liquidations" 405 | )} 406 | 407 |
408 | ); 409 | }); 410 | -------------------------------------------------------------------------------- /.api/apis/fundingrates/index.ts: -------------------------------------------------------------------------------- 1 | import type * as types from './types'; 2 | import type { ConfigOptions, FetchResponse } from 'api/dist/core' 3 | import Oas from 'oas'; 4 | import APICore from 'api/dist/core'; 5 | import definition from './openapi.json'; 6 | 7 | class SDK { 8 | spec: Oas; 9 | core: APICore; 10 | 11 | constructor() { 12 | this.spec = Oas.init(definition); 13 | this.core = new APICore(this.spec, 'fundingrates/ (api/6.1.3)'); 14 | } 15 | 16 | /** 17 | * Optionally configure various options that the SDK allows. 18 | * 19 | * @param config Object of supported SDK options and toggles. 20 | * @param config.timeout Override the default `fetch` request timeout of 30 seconds. This number 21 | * should be represented in milliseconds. 22 | */ 23 | config(config: ConfigOptions) { 24 | this.core.setConfig(config); 25 | } 26 | 27 | /** 28 | * If the API you're using requires authentication you can supply the required credentials 29 | * through this method and the library will magically determine how they should be used 30 | * within your API request. 31 | * 32 | * With the exception of OpenID and MutualTLS, it supports all forms of authentication 33 | * supported by the OpenAPI specification. 34 | * 35 | * @example HTTP Basic auth 36 | * sdk.auth('username', 'password'); 37 | * 38 | * @example Bearer tokens (HTTP or OAuth 2) 39 | * sdk.auth('myBearerToken'); 40 | * 41 | * @example API Keys 42 | * sdk.auth('myApiKey'); 43 | * 44 | * @see {@link https://spec.openapis.org/oas/v3.0.3#fixed-fields-22} 45 | * @see {@link https://spec.openapis.org/oas/v3.1.0#fixed-fields-22} 46 | * @param values Your auth credentials for the API; can specify up to two strings or numbers. 47 | */ 48 | auth(...values: string[] | number[]) { 49 | this.core.setAuth(...values); 50 | return this; 51 | } 52 | 53 | /** 54 | * If the API you're using offers alternate server URLs, and server variables, you can tell 55 | * the SDK which one to use with this method. To use it you can supply either one of the 56 | * server URLs that are contained within the OpenAPI definition (along with any server 57 | * variables), or you can pass it a fully qualified URL to use (that may or may not exist 58 | * within the OpenAPI definition). 59 | * 60 | * @example Server URL with server variables 61 | * sdk.server('https://{region}.api.example.com/{basePath}', { 62 | * name: 'eu', 63 | * basePath: 'v14', 64 | * }); 65 | * 66 | * @example Fully qualified server URL 67 | * sdk.server('https://eu.api.example.com/v14'); 68 | * 69 | * @param url Server URL 70 | * @param variables An object of variables to replace into the server URL. 71 | */ 72 | server(url: string, variables = {}) { 73 | this.core.setServer(url, variables); 74 | } 75 | 76 | /** 77 | * Get status of zklighter 78 | * 79 | * @summary status 80 | * @throws FetchError<400, types.StatusResponse400> Bad request 81 | */ 82 | status(): Promise> { 83 | return this.core.fetch('/', 'get'); 84 | } 85 | 86 | /** 87 | * Get account by account's index.
More details about account index: [Account 88 | * Index](https://apidocs.lighter.xyz/docs/account-index)
**Response 89 | * Description:**

1) **Status:** 1 is active 0 is inactive.
2) **Collateral:** 90 | * The amount of collateral in the account.
**Position Details Description:**
1) 91 | * **OOC:** Open order count in that market.
2) **Sign:** 1 for Long, -1 for 92 | * Short.
3) **Position:** The amount of position in that market.
4) **Avg Entry 93 | * Price:** The average entry price of the position.
5) **Position Value:** The value of 94 | * the position.
6) **Unrealized PnL:** The unrealized profit and loss of the 95 | * position.
7) **Realized PnL:** The realized profit and loss of the position. 96 | * 97 | * @summary account 98 | * @throws FetchError<400, types.AccountResponse400> Bad request 99 | */ 100 | account(metadata: types.AccountMetadataParam): Promise> { 101 | return this.core.fetch('/api/v1/account', 'get', metadata); 102 | } 103 | 104 | /** 105 | * Get account inactive orders 106 | * 107 | * @summary accountInactiveOrders 108 | * @throws FetchError<400, types.AccountInactiveOrdersResponse400> Bad request 109 | */ 110 | accountInactiveOrders(metadata: types.AccountInactiveOrdersMetadataParam): Promise> { 111 | return this.core.fetch('/api/v1/accountInactiveOrders', 'get', metadata); 112 | } 113 | 114 | /** 115 | * Get account limits 116 | * 117 | * @summary accountLimits 118 | * @throws FetchError<400, types.AccountLimitsResponse400> Bad request 119 | */ 120 | accountLimits(metadata: types.AccountLimitsMetadataParam): Promise> { 121 | return this.core.fetch('/api/v1/accountLimits', 'get', metadata); 122 | } 123 | 124 | /** 125 | * Get account metadatas 126 | * 127 | * @summary accountMetadata 128 | * @throws FetchError<400, types.AccountMetadataResponse400> Bad request 129 | */ 130 | accountMetadata(metadata: types.AccountMetadataMetadataParam): Promise> { 131 | return this.core.fetch('/api/v1/accountMetadata', 'get', metadata); 132 | } 133 | 134 | /** 135 | * Get transactions of a specific account 136 | * 137 | * @summary accountTxs 138 | * @throws FetchError<400, types.AccountTxsResponse400> Bad request 139 | */ 140 | accountTxs(metadata: types.AccountTxsMetadataParam): Promise> { 141 | return this.core.fetch('/api/v1/accountTxs', 'get', metadata); 142 | } 143 | 144 | /** 145 | * Get accounts by l1_address returns all accounts associated with the given L1 address 146 | * 147 | * @summary accountsByL1Address 148 | * @throws FetchError<400, types.AccountsByL1AddressResponse400> Bad request 149 | */ 150 | accountsByL1Address(metadata: types.AccountsByL1AddressMetadataParam): Promise> { 151 | return this.core.fetch('/api/v1/accountsByL1Address', 'get', metadata); 152 | } 153 | 154 | /** 155 | * Get announcement 156 | * 157 | * @summary announcement 158 | * @throws FetchError<400, types.AnnouncementResponse400> Bad request 159 | */ 160 | announcement(): Promise> { 161 | return this.core.fetch('/api/v1/announcement', 'get'); 162 | } 163 | 164 | /** 165 | * Get account api key. Set `api_key_index` to 255 to retrieve all api keys associated with 166 | * the account. 167 | * 168 | * @summary apikeys 169 | * @throws FetchError<400, types.ApikeysResponse400> Bad request 170 | */ 171 | apikeys(metadata: types.ApikeysMetadataParam): Promise> { 172 | return this.core.fetch('/api/v1/apikeys', 'get', metadata); 173 | } 174 | 175 | /** 176 | * Get block by its height or commitment 177 | * 178 | * @summary block 179 | * @throws FetchError<400, types.BlockResponse400> Bad request 180 | */ 181 | block(metadata: types.BlockMetadataParam): Promise> { 182 | return this.core.fetch('/api/v1/block', 'get', metadata); 183 | } 184 | 185 | /** 186 | * Get transactions in a block 187 | * 188 | * @summary blockTxs 189 | * @throws FetchError<400, types.BlockTxsResponse400> Bad request 190 | */ 191 | blockTxs(metadata: types.BlockTxsMetadataParam): Promise> { 192 | return this.core.fetch('/api/v1/blockTxs', 'get', metadata); 193 | } 194 | 195 | /** 196 | * Get blocks 197 | * 198 | * @summary blocks 199 | * @throws FetchError<400, types.BlocksResponse400> Bad request 200 | */ 201 | blocks(metadata: types.BlocksMetadataParam): Promise> { 202 | return this.core.fetch('/api/v1/blocks', 'get', metadata); 203 | } 204 | 205 | /** 206 | * Get candlesticks 207 | * 208 | * @summary candlesticks 209 | * @throws FetchError<400, types.CandlesticksResponse400> Bad request 210 | */ 211 | candlesticks(metadata: types.CandlesticksMetadataParam): Promise> { 212 | return this.core.fetch('/api/v1/candlesticks', 'get', metadata); 213 | } 214 | 215 | /** 216 | * Get current height 217 | * 218 | * @summary currentHeight 219 | * @throws FetchError<400, types.CurrentHeightResponse400> Bad request 220 | */ 221 | currentHeight(): Promise> { 222 | return this.core.fetch('/api/v1/currentHeight', 'get'); 223 | } 224 | 225 | /** 226 | * Get deposit history 227 | * 228 | * @summary deposit_history 229 | * @throws FetchError<400, types.DepositHistoryResponse400> Bad request 230 | */ 231 | deposit_history(metadata: types.DepositHistoryMetadataParam): Promise> { 232 | return this.core.fetch('/api/v1/deposit/history', 'get', metadata); 233 | } 234 | 235 | /** 236 | * Get exchange stats 237 | * 238 | * @summary exchangeStats 239 | * @throws FetchError<400, types.ExchangeStatsResponse400> Bad request 240 | */ 241 | exchangeStats(): Promise> { 242 | return this.core.fetch('/api/v1/exchangeStats', 'get'); 243 | } 244 | 245 | /** 246 | * Export data 247 | * 248 | * @summary export 249 | * @throws FetchError<400, types.ExportResponse400> Bad request 250 | */ 251 | export(metadata: types.ExportMetadataParam): Promise> { 252 | return this.core.fetch('/api/v1/export', 'get', metadata); 253 | } 254 | 255 | /** 256 | * Get fast bridge info 257 | * 258 | * @summary fastbridge_info 259 | * @throws FetchError<400, types.FastbridgeInfoResponse400> Bad request 260 | */ 261 | fastbridge_info(): Promise> { 262 | return this.core.fetch('/api/v1/fastbridge/info', 'get'); 263 | } 264 | 265 | /** 266 | * Get funding rates 267 | * 268 | * @summary funding-rates 269 | * @throws FetchError<400, types.FundingRatesResponse400> Bad request 270 | */ 271 | fundingRates(): Promise> { 272 | return this.core.fetch('/api/v1/funding-rates', 'get'); 273 | } 274 | 275 | /** 276 | * Get fundings 277 | * 278 | * @summary fundings 279 | * @throws FetchError<400, types.FundingsResponse400> Bad request 280 | */ 281 | fundings(metadata: types.FundingsMetadataParam): Promise> { 282 | return this.core.fetch('/api/v1/fundings', 'get', metadata); 283 | } 284 | 285 | /** 286 | * Get L1 metadata 287 | * 288 | * @summary l1Metadata 289 | * @throws FetchError<400, types.L1MetadataResponse400> Bad request 290 | */ 291 | l1Metadata(metadata: types.L1MetadataMetadataParam): Promise> { 292 | return this.core.fetch('/api/v1/l1Metadata', 'get', metadata); 293 | } 294 | 295 | /** 296 | * Get liquidation infos 297 | * 298 | * @summary liquidations 299 | * @throws FetchError<400, types.LiquidationsResponse400> Bad request 300 | */ 301 | liquidations(metadata: types.LiquidationsMetadataParam): Promise> { 302 | return this.core.fetch('/api/v1/liquidations', 'get', metadata); 303 | } 304 | 305 | /** 306 | * Get next nonce for a specific account and api key 307 | * 308 | * @summary nextNonce 309 | * @throws FetchError<400, types.NextNonceResponse400> Bad request 310 | */ 311 | nextNonce(metadata: types.NextNonceMetadataParam): Promise> { 312 | return this.core.fetch('/api/v1/nextNonce', 'get', metadata); 313 | } 314 | 315 | /** 316 | * Ack notification 317 | * 318 | * @summary notification_ack 319 | * @throws FetchError<400, types.NotificationAckResponse400> Bad request 320 | */ 321 | notification_ack(body: types.NotificationAckBodyParam, metadata?: types.NotificationAckMetadataParam): Promise> { 322 | return this.core.fetch('/api/v1/notification/ack', 'post', body, metadata); 323 | } 324 | 325 | /** 326 | * Get order books metadata 327 | * 328 | * @summary orderBookDetails 329 | * @throws FetchError<400, types.OrderBookDetailsResponse400> Bad request 330 | */ 331 | orderBookDetails(metadata?: types.OrderBookDetailsMetadataParam): Promise> { 332 | return this.core.fetch('/api/v1/orderBookDetails', 'get', metadata); 333 | } 334 | 335 | /** 336 | * Get order books metadata.
**Response Description:**

1) **Taker and maker 337 | * fees** are in percentage.
2) **Min base amount:** The amount of base token that can 338 | * be traded in a single order.
3) **Min quote amount:** The amount of quote token that 339 | * can be traded in a single order.
4) **Supported size decimals:** The number of 340 | * decimal places that can be used for the size of the order.
5) **Supported price 341 | * decimals:** The number of decimal places that can be used for the price of the 342 | * order.
6) **Supported quote decimals:** Size Decimals + Quote Decimals. 343 | * 344 | * @summary orderBooks 345 | * @throws FetchError<400, types.OrderBooksResponse400> Bad request 346 | */ 347 | orderBooks(metadata?: types.OrderBooksMetadataParam): Promise> { 348 | return this.core.fetch('/api/v1/orderBooks', 'get', metadata); 349 | } 350 | 351 | /** 352 | * Get account PnL chart 353 | * 354 | * @summary pnl 355 | * @throws FetchError<400, types.PnlResponse400> Bad request 356 | */ 357 | pnl(metadata: types.PnlMetadataParam): Promise> { 358 | return this.core.fetch('/api/v1/pnl', 'get', metadata); 359 | } 360 | 361 | /** 362 | * Get accounts position fundings 363 | * 364 | * @summary positionFunding 365 | * @throws FetchError<400, types.PositionFundingResponse400> Bad request 366 | */ 367 | positionFunding(metadata: types.PositionFundingMetadataParam): Promise> { 368 | return this.core.fetch('/api/v1/positionFunding', 'get', metadata); 369 | } 370 | 371 | /** 372 | * Get public pools 373 | * 374 | * @summary publicPools 375 | * @throws FetchError<400, types.PublicPoolsResponse400> Bad request 376 | */ 377 | publicPools(metadata: types.PublicPoolsMetadataParam): Promise> { 378 | return this.core.fetch('/api/v1/publicPools', 'get', metadata); 379 | } 380 | 381 | /** 382 | * Get recent trades 383 | * 384 | * @summary recentTrades 385 | * @throws FetchError<400, types.RecentTradesResponse400> Bad request 386 | */ 387 | recentTrades(metadata: types.RecentTradesMetadataParam): Promise> { 388 | return this.core.fetch('/api/v1/recentTrades', 'get', metadata); 389 | } 390 | 391 | /** 392 | * Get referral points 393 | * 394 | * @summary referral_points 395 | * @throws FetchError<400, types.ReferralPointsResponse400> Bad request 396 | */ 397 | referral_points(metadata: types.ReferralPointsMetadataParam): Promise> { 398 | return this.core.fetch('/api/v1/referral/points', 'get', metadata); 399 | } 400 | 401 | /** 402 | * You need to sign the transaction body before sending it to the server. More details can 403 | * be found in the Get Started docs: [Get Started For 404 | * Programmers](https://apidocs.lighter.xyz/docs/get-started-for-programmers) 405 | * 406 | * @summary sendTx 407 | * @throws FetchError<400, types.SendTxResponse400> Bad request 408 | */ 409 | sendTx(body: types.SendTxBodyParam): Promise> { 410 | return this.core.fetch('/api/v1/sendTx', 'post', body); 411 | } 412 | 413 | /** 414 | * You need to sign the transaction body before sending it to the server. More details can 415 | * be found in the Get Started docs: [Get Started For 416 | * Programmers](https://apidocs.lighter.xyz/docs/get-started-for-programmers) 417 | * 418 | * @summary sendTxBatch 419 | * @throws FetchError<400, types.SendTxBatchResponse400> Bad request 420 | */ 421 | sendTxBatch(body: types.SendTxBatchBodyParam): Promise> { 422 | return this.core.fetch('/api/v1/sendTxBatch', 'post', body); 423 | } 424 | 425 | /** 426 | * Get trades 427 | * 428 | * @summary trades 429 | * @throws FetchError<400, types.TradesResponse400> Bad request 430 | */ 431 | trades(metadata: types.TradesMetadataParam): Promise> { 432 | return this.core.fetch('/api/v1/trades', 'get', metadata); 433 | } 434 | 435 | /** 436 | * Get transaction by hash or sequence index 437 | * 438 | * @summary tx 439 | * @throws FetchError<400, types.TxResponse400> Bad request 440 | */ 441 | tx(metadata: types.TxMetadataParam): Promise> { 442 | return this.core.fetch('/api/v1/tx', 'get', metadata); 443 | } 444 | 445 | /** 446 | * Get L1 transaction by L1 transaction hash 447 | * 448 | * @summary txFromL1TxHash 449 | * @throws FetchError<400, types.TxFromL1TxHashResponse400> Bad request 450 | */ 451 | txFromL1TxHash(metadata: types.TxFromL1TxHashMetadataParam): Promise> { 452 | return this.core.fetch('/api/v1/txFromL1TxHash', 'get', metadata); 453 | } 454 | 455 | /** 456 | * Get transactions which are already packed into blocks 457 | * 458 | * @summary txs 459 | * @throws FetchError<400, types.TxsResponse400> Bad request 460 | */ 461 | txs(metadata: types.TxsMetadataParam): Promise> { 462 | return this.core.fetch('/api/v1/txs', 'get', metadata); 463 | } 464 | 465 | /** 466 | * Get withdraw history 467 | * 468 | * @summary withdraw_history 469 | * @throws FetchError<400, types.WithdrawHistoryResponse400> Bad request 470 | */ 471 | withdraw_history(metadata: types.WithdrawHistoryMetadataParam): Promise> { 472 | return this.core.fetch('/api/v1/withdraw/history', 'get', metadata); 473 | } 474 | 475 | /** 476 | * Get info of zklighter 477 | * 478 | * @summary info 479 | * @throws FetchError<400, types.InfoResponse400> Bad request 480 | */ 481 | info(): Promise> { 482 | return this.core.fetch('/info', 'get'); 483 | } 484 | } 485 | 486 | const createSDK = (() => { return new SDK(); })() 487 | ; 488 | 489 | export default createSDK; 490 | 491 | export type { AccountInactiveOrdersMetadataParam, AccountInactiveOrdersResponse200, AccountInactiveOrdersResponse400, AccountLimitsMetadataParam, AccountLimitsResponse200, AccountLimitsResponse400, AccountMetadataMetadataParam, AccountMetadataParam, AccountMetadataResponse200, AccountMetadataResponse400, AccountResponse200, AccountResponse400, AccountTxsMetadataParam, AccountTxsResponse200, AccountTxsResponse400, AccountsByL1AddressMetadataParam, AccountsByL1AddressResponse200, AccountsByL1AddressResponse400, AnnouncementResponse200, AnnouncementResponse400, ApikeysMetadataParam, ApikeysResponse200, ApikeysResponse400, BlockMetadataParam, BlockResponse200, BlockResponse400, BlockTxsMetadataParam, BlockTxsResponse200, BlockTxsResponse400, BlocksMetadataParam, BlocksResponse200, BlocksResponse400, CandlesticksMetadataParam, CandlesticksResponse200, CandlesticksResponse400, CurrentHeightResponse200, CurrentHeightResponse400, DepositHistoryMetadataParam, DepositHistoryResponse200, DepositHistoryResponse400, ExchangeStatsResponse200, ExchangeStatsResponse400, ExportMetadataParam, ExportResponse200, ExportResponse400, FastbridgeInfoResponse200, FastbridgeInfoResponse400, FundingRatesResponse200, FundingRatesResponse400, FundingsMetadataParam, FundingsResponse200, FundingsResponse400, InfoResponse200, InfoResponse400, L1MetadataMetadataParam, L1MetadataResponse200, L1MetadataResponse400, LiquidationsMetadataParam, LiquidationsResponse200, LiquidationsResponse400, NextNonceMetadataParam, NextNonceResponse200, NextNonceResponse400, NotificationAckBodyParam, NotificationAckMetadataParam, NotificationAckResponse200, NotificationAckResponse400, OrderBookDetailsMetadataParam, OrderBookDetailsResponse200, OrderBookDetailsResponse400, OrderBooksMetadataParam, OrderBooksResponse200, OrderBooksResponse400, PnlMetadataParam, PnlResponse200, PnlResponse400, PositionFundingMetadataParam, PositionFundingResponse200, PositionFundingResponse400, PublicPoolsMetadataParam, PublicPoolsResponse200, PublicPoolsResponse400, RecentTradesMetadataParam, RecentTradesResponse200, RecentTradesResponse400, ReferralPointsMetadataParam, ReferralPointsResponse200, ReferralPointsResponse400, SendTxBatchBodyParam, SendTxBatchResponse200, SendTxBatchResponse400, SendTxBodyParam, SendTxResponse200, SendTxResponse400, StatusResponse200, StatusResponse400, TradesMetadataParam, TradesResponse200, TradesResponse400, TxFromL1TxHashMetadataParam, TxFromL1TxHashResponse200, TxFromL1TxHashResponse400, TxMetadataParam, TxResponse200, TxResponse400, TxsMetadataParam, TxsResponse200, TxsResponse400, WithdrawHistoryMetadataParam, WithdrawHistoryResponse200, WithdrawHistoryResponse400 } from './types'; 492 | -------------------------------------------------------------------------------- /src/components/LiquidationTable.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useMemo } from 'react'; 2 | import { 3 | Table, Tbody, Tr, Td, Box, HStack, Input, IconButton, 4 | Text, Tooltip, Grid, VStack, Popover, PopoverTrigger, PopoverContent, 5 | PopoverBody, PopoverHeader, PopoverArrow, PopoverCloseButton, Button, 6 | useDisclosure, Flex, Badge, Thead, Th, TableContainer, useColorModeValue, 7 | InputGroup, InputLeftElement, MenuButton, Menu, MenuItem, 8 | MenuList, Divider, Tag, TagLabel, TagCloseButton 9 | } from '@chakra-ui/react'; 10 | import { motion, AnimatePresence } from 'framer-motion'; 11 | import { useLiquidationStore } from '../store/liquidationStore'; 12 | import { CloseIcon, SearchIcon, ChevronDownIcon } from '@chakra-ui/icons'; 13 | import { FaCoins, FaDollarSign, FaFilter } from 'react-icons/fa'; 14 | import useSound from 'use-sound'; 15 | import { WhaleAlertTable } from './WhaleAlert'; 16 | 17 | const MotionTr = motion(Tr); 18 | 19 | interface Props { 20 | compact?: boolean; 21 | soundEnabled: boolean; 22 | onNewLiquidation: (data: { amount: number; symbol: string }) => void; 23 | 24 | } 25 | 26 | export const LiquidationTable: React.FC = ({ compact = false, soundEnabled }) => { 27 | const liquidations = useLiquidationStore((state) => state.liquidations); 28 | const [selectedCoins, setSelectedCoins] = useState([]); 29 | const [filterInput, setFilterInput] = useState(''); 30 | const [minLiquidationSize, setMinLiquidationSize] = useState(0); 31 | const [currentTime, setCurrentTime] = useState(Date.now()); 32 | const { isOpen, onOpen, onClose } = useDisclosure(); 33 | const [selectedSize, setSelectedSize] = useState(minLiquidationSize || null); 34 | 35 | // Color mode values 36 | const cardBg = useColorModeValue('white', 'gray.800'); 37 | const borderColor = useColorModeValue('gray.200', 'gray.700'); 38 | const filterBg = useColorModeValue('gray.50', 'gray.900'); 39 | 40 | 41 | const [playPing] = useSound('/ping.mp3', { 42 | sprite: { secondPart: [1000, 1000] }, 43 | }); 44 | 45 | const sortedLiquidations = useMemo(() => 46 | [...liquidations].sort((a, b) => b.timestamp.toMillis() - a.timestamp.toMillis()), 47 | [liquidations] 48 | ); 49 | 50 | const availableCoins = useMemo(() => 51 | Array.from(new Set(liquidations.map(l => l.symbol))).sort(), 52 | [liquidations] 53 | ); 54 | 55 | const filteredLiquidations = useMemo(() => { 56 | const searchTerm = filterInput.toLowerCase(); 57 | return sortedLiquidations.filter(l => { 58 | const matchesSelectedCoins = selectedCoins.length === 0 || selectedCoins.includes(l.symbol); 59 | const matchesSearch = searchTerm === '' || 60 | l.symbol.toLowerCase().includes(searchTerm) || 61 | l.price.toString().includes(searchTerm) || 62 | l.value.toString().includes(searchTerm); 63 | const matchesSize = l.value >= minLiquidationSize; 64 | return matchesSelectedCoins && matchesSearch && matchesSize; 65 | }); 66 | }, [sortedLiquidations, selectedCoins, filterInput, minLiquidationSize]); 67 | 68 | useEffect(() => { 69 | if (soundEnabled && filteredLiquidations.length > 0) { 70 | playPing({ id: 'secondPart' }); 71 | } 72 | }, [filteredLiquidations, playPing, soundEnabled]); 73 | 74 | useEffect(() => { 75 | const interval = setInterval(() => { 76 | setCurrentTime(Date.now()); 77 | }, 1000); 78 | return () => clearInterval(interval); 79 | }, []); 80 | 81 | const formatCurrency = (num: number) => { 82 | if (num >= 1000000) return `$${(num / 1000000).toFixed(1)}M`; 83 | if (num >= 1000) return `$${(num / 1000).toFixed(0)}K`; 84 | return `$${num}`; 85 | }; 86 | 87 | return ( 88 | 96 | 108 | 114 | 115 | 120 | 121 | 122 | 123 | 124 | 125 | setFilterInput(e.target.value)} 129 | borderRadius="md" 130 | _focus={{ borderColor: "purple.300", boxShadow: "0 0 0 1px var(--chakra-colors-purple-300)" }} 131 | /> 132 | 133 | 134 | 135 | } 140 | width={{ base: "full", md: "auto" }} 141 | > 142 | 143 | 144 | Filter 145 | {selectedCoins.length > 0 && ( 146 | 147 | {selectedCoins.length} 148 | 149 | )} 150 | 151 | 152 | 153 | 154 | ASSETS 155 | 156 | 157 | {availableCoins.map(coin => ( 158 | 159 | 160 | {coin} 161 | : } 164 | size="xs" 165 | colorScheme={selectedCoins.includes(coin) ? "purple" : "gray"} 166 | variant={selectedCoins.includes(coin) ? "solid" : "ghost"} 167 | onClick={() => { 168 | if (selectedCoins.includes(coin)) { 169 | setSelectedCoins(coins => coins.filter(c => c !== coin)); 170 | } else { 171 | setSelectedCoins([...selectedCoins, coin]); 172 | } 173 | }} 174 | /> 175 | 176 | 177 | ))} 178 | 179 | 180 | 181 | { 182 | if (selectedSize === null) setMinLiquidationSize(0); 183 | onClose(); 184 | }}> 185 | 186 | 196 | 197 | 198 | 199 | 200 | Min Size 201 | 202 | 203 | 216 | {[10000, 50000, 100000, 250000, 500000].map((amount) => ( 217 | 231 | ))} 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | {selectedCoins.length > 0 && ( 240 | 241 | {selectedCoins.map(coin => ( 242 | 249 | {coin} 250 | setSelectedCoins(coins => coins.filter(c => c !== coin))} 252 | /> 253 | 254 | ))} 255 | {selectedCoins.length > 1 && ( 256 | 264 | )} 265 | 266 | )} 267 | 268 | 269 | 270 | {/* Optimize the AnimatePresence with presenceAffectsLayout={false} */} 271 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | {/* Limit the maximum rendered items and optimize animations */} 291 | 292 | {filteredLiquidations.slice(0, 100).map((liquidation, index) => ( 293 | 300 | ))} 301 | 302 | {filteredLiquidations.length === 0 && ( 303 | 304 | 307 | 308 | )} 309 | 310 |
AssetPriceValueTime
305 | No liquidations match your filters 306 |
311 |
312 | 313 | 314 | 323 | {filteredLiquidations.length > 100 324 | ? '100+ liquidations displayed' 325 | : `${filteredLiquidations.length} liquidations displayed`} 326 | {selectedCoins.length > 0 || minLiquidationSize > 0 || filterInput 327 | ? ` (filtered from ${sortedLiquidations.length} total)` 328 | : ''} 329 | 330 |
331 | 332 | 341 | 346 | 347 |
348 | ); 349 | }; 350 | 351 | // Memoize LiquidationRow to prevent unnecessary re-renders 352 | const LiquidationRow: React.FC<{ liquidation: any, currentTime: number, index: number, compact: boolean }> = React.memo(({ liquidation, currentTime, compact }) => { 353 | const [isHovered, setIsHovered] = useState(false); 354 | const [lastDisplayedMilestone, setLastDisplayedMilestone] = useState(null); 355 | const borderColor = useColorModeValue('gray.200', 'gray.700'); 356 | 357 | const timeAgoInSeconds = Math.floor((currentTime - liquidation.timestamp.toMillis()) / 1000); 358 | const timeAgoInMinutes = Math.floor(timeAgoInSeconds / 60); 359 | const displayTime = timeAgoInSeconds < 60 ? `${timeAgoInSeconds}s` : `${timeAgoInMinutes}m`; 360 | const isHighValue = liquidation.value > 3000; 361 | const isRecent = timeAgoInSeconds < 30; 362 | 363 | const shouldDisplayTime = timeAgoInSeconds < 60 || timeAgoInSeconds % 60 === 0; 364 | 365 | useEffect(() => { 366 | if (shouldDisplayTime) { 367 | setLastDisplayedMilestone(timeAgoInMinutes); 368 | } 369 | }, [shouldDisplayTime, timeAgoInMinutes]); 370 | 371 | const isMilestoneLeader = timeAgoInSeconds < 60 || (timeAgoInSeconds % 60 === 0 && timeAgoInMinutes !== lastDisplayedMilestone); 372 | 373 | const formatNumber = (num: number, isPrice: boolean = false) => { 374 | if (isPrice) { 375 | if (num === 0) return '0'; 376 | if (num < 0.01) return num.toFixed(4); 377 | if (num < 1) return num.toFixed(2).replace('0.', '.'); 378 | if (num < 100) return num.toFixed(2); 379 | if (num < 1000) return num.toFixed(1); 380 | return Math.round(num).toString(); 381 | } 382 | if (num >= 1000) return Math.round(num).toString(); 383 | if (num >= 100) return num.toFixed(1); 384 | if (num < 1) return num.toFixed(2).replace('0.', '.'); 385 | return num.toFixed(2); 386 | }; 387 | 388 | const formatSymbol = (symbol: string) => { 389 | return symbol.replace('USDT', '').replace(/--?SWAP/, ''); 390 | }; 391 | 392 | const highValueStyle = { 393 | fontWeight: "700", 394 | fontFamily: "Rajdhani", 395 | fontSize: "1.05em", 396 | color: "purple.500", 397 | _dark: { color: "purple.300" } 398 | }; 399 | 400 | const regularStyle = { 401 | fontWeight: "400", 402 | fontSize: "0.85em", 403 | color: "gray.700", 404 | _dark: { color: "gray.300" } 405 | }; 406 | 407 | const rowVariants = { 408 | initial: { 409 | opacity: 0, 410 | y: -5 411 | }, 412 | animate: { 413 | opacity: 1, 414 | y: 0, 415 | transition: { 416 | duration: 0.15, 417 | ease: "easeOut" 418 | } 419 | }, 420 | exit: { 421 | opacity: 0, 422 | transition: { 423 | duration: 0.1 424 | } 425 | } 426 | }; 427 | 428 | // Lighter on entry (isRecent = true), darker after 30s (isRecent = false) 429 | // Lighter on entry (isRecent = true), darker after 30s (isRecent = false) 430 | const bgColor = isRecent 431 | ? liquidation.side === 'BUY' ? 'green.50' : 'red.50' // Very light on entry 432 | : liquidation.side === 'BUY' ? 'green.200' : 'red.200'; // Darker after 30s 433 | 434 | const darkBgColor = isRecent 435 | ? liquidation.side === 'BUY' ? 'green.900' : 'red.900' // Lightest dark mode on entry 436 | : liquidation.side === 'BUY' ? 'green.700' : 'red.700'; // Darker after 30s 437 | return ( 438 | setIsHovered(true)} 450 | onMouseLeave={() => setIsHovered(false)} 451 | position="relative" 452 | width="100%" 453 | layout 454 | borderLeftWidth="2px" 455 | borderLeftColor={liquidation.side === 'BUY' ? 'green.400' : 'red.400'} 456 | borderLeftStyle={isRecent ? 'solid' : 'hidden'} 457 | > 458 | 470 | 471 | {liquidation.exchange === 'BINANCE' ? ( 472 | 473 | Binance 474 | 475 | ) : liquidation.exchange === 'BYBIT' ? ( 476 | 477 | Bybit 478 | 479 | ) : liquidation.exchange === 'OKX' ? ( 480 | 481 | OKX 482 | 483 | ) : null} 484 | 490 | {formatSymbol(liquidation.symbol)} 491 | 492 | 493 | 494 | 495 | 508 | 513 | {formatNumber(liquidation.price, true)} 514 | 515 | 516 | 517 | 530 | 536 | 537 | {isHovered 538 | ? <> 539 | 540 | 541 | {(() => { 542 | const quantity = parseFloat(liquidation.quantity.toString()); 543 | return quantity < 1 ? 544 | quantity.toFixed(4).replace('0.', '.') : 545 | Number.isInteger(quantity) ? 546 | quantity.toString() : 547 | quantity.toFixed(2); 548 | })()} 549 | 550 | 551 | : <> 552 | 560 | 565 | {formatNumber(liquidation.value)} 566 | 567 | 568 | } 569 | 570 | 571 | 572 | 573 | 585 | 586 | {isMilestoneLeader && timeAgoInSeconds >= 0 ? ( 587 | 595 | {displayTime} 596 | 597 | ) : null} 598 | 599 | 600 | 601 | ); 602 | }, (prevProps, nextProps) => { 603 | return prevProps.liquidation === nextProps.liquidation && 604 | Math.floor(prevProps.currentTime / 1000) === Math.floor(nextProps.currentTime / 1000); 605 | }); -------------------------------------------------------------------------------- /src/components/FundingRatesTab.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Flex, 4 | Grid, 5 | Text, 6 | VStack, 7 | Button, 8 | ButtonGroup, 9 | useColorModeValue, 10 | Badge, 11 | Skeleton, 12 | HStack, 13 | Icon, 14 | Tooltip, 15 | Card, 16 | CardBody, 17 | Stat, 18 | StatLabel, 19 | StatNumber, 20 | Divider, 21 | useBreakpointValue, 22 | Container, 23 | } from '@chakra-ui/react'; 24 | import { useEffect, useMemo, useState } from 'react'; 25 | import { 26 | TrendingUp, 27 | TrendingDown, 28 | ArrowUpDown, 29 | Clock, 30 | RefreshCw, 31 | ArrowUp, 32 | ArrowDown 33 | } from 'lucide-react'; 34 | 35 | interface FundingRate { 36 | market_id: number; 37 | exchange: string; 38 | symbol: string; 39 | rate: number; 40 | scaled?: number; 41 | } 42 | 43 | type FundingPeriod = '8h' | '1d' | '7d' | '30d' | '180d' | '1y'; 44 | 45 | // Exchange configurations 46 | const EXCHANGE_CONFIG = { 47 | binance: { name: 'Binance', color: '#F0B90B' }, 48 | bybit: { name: 'Bybit', color: '#F7931A' }, 49 | hyperliquid: { name: 'Hyperliquid', color: '#0066CC' }, 50 | lighter: { name: 'Lighter', color: '#6366F1' }, 51 | okx: { name: 'OKX', color: '#3396FF' }, 52 | }; 53 | 54 | const okxSymbols = [ 55 | 56 | "AAVE-USDT-SWAP", "ADA-USDT-SWAP", "AI-USDT-SWAP", "APT-USDT-SWAP", "AVAX-USDT-SWAP", 57 | "BERA-USDT-SWAP", "BNB-USDT-SWAP", "BTC-USDT-SWAP", "CRV-USDT-SWAP", "DOGE-USDT-SWAP", 58 | "DOT-USDT-SWAP", "ENA-USDT-SWAP", "ETH-USDT-SWAP", "FARTCOIN-USDT-SWAP", "HYPE-USDT-SWAP", 59 | "IP-USDT-SWAP", "JUP-USDT-SWAP", "KAITO-USDT-SWAP", "LINK-USDT-SWAP", "LTC-USDT-SWAP", 60 | "MKR-USDT-SWAP", "NEAR-USDT-SWAP", "ONDO-USDT-SWAP", "PENDLE-USDT-SWAP", "POL-USDT-SWAP", 61 | "POPCAT-USDT-SWAP", "PUMP-USDT-SWAP", "S-USDT-SWAP", "SEI-USDT-SWAP", "SOL-USDT-SWAP", 62 | "SPX-USDT-SWAP", "SUI-USDT-SWAP", "SYRUP-USDT-SWAP", "TAO-USDT-SWAP", "TON-USDT-SWAP", 63 | "TRUMP-USDT-SWAP", "TRX-USDT-SWAP", "UNI-USDT-SWAP", "VIRTUAL-USDT-SWAP", "WIF-USDT-SWAP", 64 | "WLD-USDT-SWAP", "XRP-USDT-SWAP" 65 | ]; 66 | 67 | const okxAdditionalSymbols = [ "BONK-USDT-SWAP", "FLOKI-USDT-SWAP", "PEPE-USDT-SWAP", "SHIB-USDT-SWAP",]; 68 | 69 | const thousandSymbols = new Set(["BONK", "FLOKI", "PEPE", "SHIB"]); 70 | 71 | 72 | const allOkxSymbols = [...okxSymbols, ...okxAdditionalSymbols]; 73 | 74 | const extractCoin = (instId: string) => { 75 | const parts = instId.split('-'); 76 | const asset = parts[0]; 77 | return thousandSymbols.has(asset) ? `1000${asset}` : asset; 78 | }; 79 | 80 | export function FundingRatesTab() { 81 | const [rates, setRates] = useState([]); 82 | const [loading, setLoading] = useState(true); 83 | const [error, setError] = useState(null); 84 | const [period, setPeriod] = useState('8h'); 85 | const [showDelta, setShowDelta] = useState(false); 86 | const [sort, setSort] = useState<{ col: string; dir: 1 | -1 } | null>(null); 87 | const [lastUpdated, setLastUpdated] = useState(new Date()); 88 | const [isAutoRefreshing, setIsAutoRefreshing] = useState(true); 89 | const [selectedSymbol, setSelectedSymbol] = useState(null); 90 | 91 | // Responsive layout 92 | const isMobile = useBreakpointValue({ base: true, lg: false }); 93 | 94 | // Color scheme - professional and minimal 95 | const bg = useColorModeValue('white', 'gray.900'); 96 | const cardBg = useColorModeValue('gray.50', 'gray.800'); 97 | const headerBg = useColorModeValue('gray.100', 'gray.700'); 98 | 99 | const subtleBorderColor = useColorModeValue('gray.100', 'gray.700'); 100 | const hoverBg = useColorModeValue('gray.50', 'gray.750'); 101 | const textPrimary = useColorModeValue('gray.900', 'gray.100'); 102 | const textSecondary = useColorModeValue('gray.600', 'gray.400'); 103 | 104 | // Calculate next funding reset time (every 8 hours at 00:00, 08:00, 16:00 UTC) 105 | const getNextFundingResetTime = () => { 106 | const now = new Date(); 107 | const currentHour = now.getUTCHours(); 108 | const nextResetHour = Math.floor(currentHour / 8) * 8 + 8; 109 | const nextReset = new Date(now); 110 | 111 | if (nextResetHour >= 24) { 112 | nextReset.setUTCDate(nextReset.getUTCDate() + 1); 113 | nextReset.setUTCHours(0, 0, 0, 0); 114 | } else { 115 | nextReset.setUTCHours(nextResetHour, 0, 0, 0); 116 | } 117 | 118 | return nextReset; 119 | }; 120 | 121 | const [nextFundingReset, setNextFundingReset] = useState(getNextFundingResetTime()); 122 | 123 | // Update countdown every second 124 | useEffect(() => { 125 | const timer = setInterval(() => { 126 | setNextFundingReset(getNextFundingResetTime()); 127 | }, 1000); 128 | return () => clearInterval(timer); 129 | }, []); 130 | 131 | // Format countdown timer 132 | const formatCountdown = (targetDate: Date) => { 133 | const now = new Date(); 134 | const diff = targetDate.getTime() - now.getTime(); 135 | 136 | if (diff <= 0) { 137 | return "00:00:00"; 138 | } 139 | 140 | const hours = Math.floor(diff / (1000 * 60 * 60)); 141 | const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); 142 | const seconds = Math.floor((diff % (1000 * 60)) / 1000); 143 | 144 | return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; 145 | }; 146 | 147 | // fetch & poll 148 | const fetchData = async (signal?: AbortSignal) => { 149 | try { 150 | setLoading(true); 151 | const allRates: FundingRate[] = []; 152 | 153 | // 1. Fetch ZKLighter funding rates 154 | try { 155 | const res = await fetch( 156 | 'https://mainnet.zklighter.elliot.ai/api/v1/funding-rates', 157 | { signal, headers: { accept: 'application/json' } } 158 | ); 159 | const json = await res.json(); 160 | if (!res.ok || json.code !== 200) throw new Error('Bad response from ZKLighter'); 161 | allRates.push(...json.funding_rates); 162 | } catch (e: any) { 163 | console.error('Error fetching ZKLighter data:', e); 164 | setError(e.message ?? 'Network error fetching ZKLighter data'); 165 | } 166 | 167 | // 2. Fetch OKX funding rates 168 | const okxRates: FundingRate[] = []; 169 | for (const symbol of allOkxSymbols) { 170 | try { 171 | const okxResponse = await fetch(`https://www.okx.com/api/v5/public/funding-rate?instId=${symbol}`); 172 | const okxData = await okxResponse.json(); 173 | 174 | if (okxData.data && okxData.data.length > 0) { 175 | const rateData = okxData.data[0]; 176 | okxRates.push({ 177 | market_id: 0, // Placeholder, as OKX API doesn't provide this 178 | exchange: 'okx', 179 | symbol: extractCoin(rateData.instId), 180 | rate: parseFloat(rateData.fundingRate), 181 | }); 182 | } 183 | } catch (error) { 184 | console.error(`Error fetching OKX data for ${symbol}:`, error); 185 | } 186 | } 187 | allRates.push(...okxRates); 188 | 189 | setRates(allRates); 190 | setLastUpdated(new Date()); 191 | setError(null); 192 | } catch (e: any) { 193 | if (e.name !== 'AbortError') setError(e.message ?? 'Network error'); 194 | } finally { 195 | setLoading(false); 196 | } 197 | }; 198 | 199 | useEffect(() => { 200 | const controller = new AbortController(); 201 | fetchData(controller.signal); 202 | 203 | let id: NodeJS.Timeout; 204 | if (isAutoRefreshing) { 205 | id = setInterval(() => fetchData(), 5 * 60 * 1000); 206 | } 207 | 208 | return () => { 209 | controller.abort(); 210 | if (id) clearInterval(id); 211 | }; 212 | }, [isAutoRefreshing]); 213 | 214 | // scale rate to chosen period 215 | const scaledRates = useMemo(() => { 216 | const mult: Record = { 217 | '8h': 1, 218 | '1d': 3, 219 | '7d': 21, 220 | '30d': 90, 221 | '180d': 540, 222 | '1y': 1095, 223 | }; 224 | return rates.map((r) => ({ 225 | ...r, 226 | scaled: r.rate * mult[period], 227 | })); 228 | }, [rates, period]); 229 | 230 | const EXCHANGES = ['binance', 'bybit', 'hyperliquid', 'lighter', 'okx']; 231 | 232 | // build pivoted data 233 | const pivoted = useMemo(() => { 234 | const map = new Map>(); 235 | scaledRates.forEach((r) => { 236 | if (!map.has(r.symbol)) map.set(r.symbol, {}); 237 | map.get(r.symbol)![r.exchange] = r.scaled; 238 | }); 239 | return Array.from(map.entries()) 240 | .sort(([a], [b]) => a.localeCompare(b)) 241 | .map(([symbol, cells]) => ({ symbol, cells })); 242 | }, [scaledRates]); 243 | 244 | // arbitrage opportunities 245 | const podium = useMemo(() => { 246 | const arbMap = new Map(); 247 | pivoted.forEach(({ symbol, cells }) => { 248 | const entries = Object.entries(cells).filter(([, v]) => v !== undefined) as [string, number][]; 249 | if (entries.length < 2) return; 250 | const min = entries.reduce((a, b) => (a[1] < b[1] ? a : b)); 251 | const max = entries.reduce((a, b) => (a[1] > b[1] ? a : b)); 252 | arbMap.set(symbol, { 253 | diff: max[1] - min[1], 254 | long: min[0], 255 | short: max[0], 256 | }); 257 | }); 258 | 259 | return [...arbMap.entries()] 260 | .sort(([, a], [, b]) => b.diff - a.diff) 261 | .slice(0, 10) 262 | .map(([symbol, data], i) => ({ 263 | symbol, 264 | ...data, 265 | rank: i + 1, 266 | })); 267 | }, [pivoted]); 268 | 269 | // delta-neutral opportunities 270 | const deltaPodium = useMemo(() => { 271 | const map = new Map(); 272 | pivoted.forEach(({ symbol, cells }) => { 273 | const perpEntries = Object.entries(cells).filter(([, v]) => v !== undefined) as [string, number][]; 274 | if (perpEntries.length < 1) return; 275 | 276 | // Find the venue with the highest funding rate to short 277 | const [shortPerp, shortRate] = perpEntries.reduce((a, b) => (a[1] > b[1] ? a : b)); 278 | 279 | // Only consider positive carry trades 280 | if (shortRate <= 0) return; 281 | 282 | const longSpot = 'Spot'; 283 | const carry = shortRate; 284 | map.set(symbol, { carry, longSpot, shortPerp }); 285 | }); 286 | 287 | return [...map.entries()] 288 | .sort(([, a], [, b]) => b.carry - a.carry) 289 | .slice(0, 10) 290 | .map(([symbol, data], i) => ({ symbol, ...data, rank: i + 1 })); 291 | }, [pivoted]); 292 | 293 | const sorted = useMemo(() => { 294 | let data = pivoted; 295 | if (selectedSymbol) { 296 | data = data.filter(item => item.symbol === selectedSymbol); 297 | } 298 | if (!sort) return data; 299 | return [...data].sort((a, b) => { 300 | const av = a.cells[sort.col] ?? -Infinity; 301 | const bv = b.cells[sort.col] ?? -Infinity; 302 | return (av - bv) * sort.dir; 303 | }); 304 | }, [pivoted, sort, selectedSymbol]); 305 | 306 | // Calculate market stats 307 | const marketStats = useMemo(() => { 308 | const allRates = scaledRates.map(r => r.scaled); 309 | const positiveRates = allRates.filter(r => r > 0); 310 | const negativeRates = allRates.filter(r => r < 0); 311 | 312 | return { 313 | totalMarkets: pivoted.length, 314 | avgRate: allRates.reduce((a, b) => a + b, 0) / allRates.length, 315 | maxArb: podium[0]?.diff || 0, 316 | bullishMarkets: positiveRates.length, 317 | bearishMarkets: negativeRates.length, 318 | }; 319 | }, [scaledRates, pivoted, podium]); 320 | 321 | const getRateColor = (rate: number) => { 322 | const abs = Math.abs(rate * 100); 323 | if (rate > 0) return abs >= 0.1 ? 'red.500' : abs >= 0.05 ? 'red.400' : 'red.300'; 324 | return abs >= 0.1 ? 'green.500' : abs >= 0.05 ? 'green.400' : 'green.300'; 325 | }; 326 | 327 | const getRankBadge = (rank: number) => { 328 | const colors: { [key: number]: string } = { 1: 'purple', 2: 'blue', 3: 'cyan' }; 329 | const colorScheme = rank <= 3 ? colors[rank] : 'gray'; 330 | return ( 331 | 338 | #{rank} 339 | 340 | ); 341 | }; 342 | 343 | if (error) 344 | return ( 345 | 346 | 347 | 348 | ); 349 | 350 | if (loading && !rates.length) 351 | return ( 352 | 353 | 354 | 355 | {Array.from({ length: 4 }).map((_, i) => ( 356 | 357 | ))} 358 | 359 | 360 | 361 | 362 | ); 363 | 364 | return ( 365 | 366 | 367 | 368 | {/* Header */} 369 | 370 | 371 | 372 | Cross-exchange arbitrage opportunities and funding rate analysis 373 | 374 | 375 | 376 | 377 | 378 | Last updated: {lastUpdated.toLocaleTimeString()} 379 | LIVE 380 | 381 | 382 | Next reset: 383 | {formatCountdown(nextFundingReset)} 384 | 385 | 386 | 395 | 403 | 404 | 405 | 406 | {/* Market Overview */} 407 | 408 | 409 | 410 | 411 | 412 | Active Markets 413 | 414 | 415 | {marketStats.totalMarkets} 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | Average Rate 426 | 427 | 432 | {marketStats.avgRate >= 0 ? '+' : ''}{(marketStats.avgRate * 100).toFixed(3)}% 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | Max Spread 443 | 444 | 445 | {(marketStats.maxArb * 100).toFixed(3)}% 446 | 447 | 448 | 449 | 450 | 451 | 452 | 453 | 454 | 455 | Market Sentiment 456 | 457 | 458 | 459 | 460 | 461 | 462 | {marketStats.bullishMarkets} 463 | 464 | 465 | Long Pay 466 | 467 | 468 | 469 | 470 | 471 | {marketStats.bearishMarkets} 472 | 473 | 474 | Short Pay 475 | 476 | 477 | 478 | 479 | 480 | 481 | 482 | {/* Top Opportunities */} 483 | {podium.length > 0 && ( 484 | 485 | 486 | 487 | 488 | 489 | 490 | 491 | Top Arbitrage Opportunities 492 | 493 | 494 | 502 | 503 | 504 | 505 | 506 | {(showDelta ? deltaPodium : podium).map((opp) => ( 507 | setSelectedSymbol(opp.symbol)} 516 | cursor="pointer" 517 | > 518 | 519 | 520 | 521 | 522 | {opp.symbol} 523 | 524 | {getRankBadge(opp.rank)} 525 | 526 | 527 | {showDelta ? ( 528 | <> 529 | 530 | +{((opp as any).carry * 100).toFixed(3)}% 531 | 532 | 533 | 534 | 535 | 536 | Buy Spot 537 | 538 | 539 | 540 | 541 | 542 | Short {EXCHANGE_CONFIG[(opp as any).shortPerp as keyof typeof EXCHANGE_CONFIG]?.name || (opp as any).shortPerp} 543 | 544 | 545 | 546 | 547 | ) : ( 548 | <> 549 | 550 | +{((opp as any).diff * 100).toFixed(3)}% 551 | 552 | 553 | 554 | 555 | 556 | Long {EXCHANGE_CONFIG[(opp as any).long as keyof typeof EXCHANGE_CONFIG]?.name || (opp as any).long} 557 | 558 | 559 | 560 | 561 | 562 | Short {EXCHANGE_CONFIG[(opp as any).short as keyof typeof EXCHANGE_CONFIG]?.name || (opp as any).short} 563 | 564 | 565 | 566 | 567 | )} 568 | 569 | 570 | 571 | ))} 572 | 573 | 574 | 575 | 576 | 577 | )} 578 | 579 | {/* Period Selection */} 580 | 581 | 582 | Funding Rates 583 | 584 | {selectedSymbol && ( 585 | 588 | )} 589 | 590 | {(['8h', '1d', '7d', '30d', '180d', '1y'] as FundingPeriod[]).map((p) => ( 591 | 600 | ))} 601 | 602 | 603 | 604 | 605 | {/* Funding Rates Table */} 606 | 607 | 608 | {/* Table Header - Sticky */} 609 | 619 | 625 | Asset 626 | 627 | {(isMobile ? ['binance', 'bybit'] : EXCHANGES).map((ex) => ( 628 | 635 | setSort( 636 | sort?.col === ex 637 | ? { col: ex, dir: (-sort.dir as 1 | -1) } 638 | : { col: ex, dir: 1 } 639 | ) 640 | } 641 | _hover={{ color: 'blue.500' }} 642 | > 643 | 649 | {EXCHANGE_CONFIG[ex as keyof typeof EXCHANGE_CONFIG]?.name || ex} 650 | 651 | {sort?.col === ex && ( 652 | 658 | )} 659 | 660 | ))} 661 | 662 | 663 | {/* Table Body with fixed height and scroll */} 664 | 665 | {sorted.map(({ symbol, cells }) => ( 666 | 678 | 685 | {symbol} 686 | 687 | 688 | {(isMobile ? ['binance', 'bybit'] : EXCHANGES).map((ex) => { 689 | const rate = cells[ex]; 690 | 691 | 692 | return ( 693 | 699 | {rate === undefined ? ( 700 | 701 | ) : ( 702 | 703 | 709 | 715 | {(rate * 100).toFixed(3)}% 716 | 717 | 718 | )} 719 | 720 | ); 721 | })} 722 | 723 | ))} 724 | 725 | 726 | 727 | 728 | {/* Footer */} 729 | 730 | 731 | Positive rates: Long pays Short funding 732 | Negative rates: Short pays Long funding 733 | Rates annualized for selected period 734 | 735 | 736 | 737 | ); 738 | } -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Container, 4 | Flex, 5 | 6 | Heading, 7 | Text, 8 | HStack, 9 | VStack, 10 | IconButton, 11 | Button, 12 | useColorMode, 13 | useColorModeValue, 14 | Switch, 15 | Menu, 16 | MenuButton, 17 | MenuList, 18 | MenuItem, 19 | Tooltip, 20 | Badge, 21 | Tag, 22 | TagLabel, 23 | TagLeftIcon, 24 | Skeleton, 25 | useToast, 26 | useDisclosure, 27 | Collapse, 28 | Alert, 29 | AlertIcon, 30 | AlertTitle, 31 | AlertDescription, 32 | CloseButton, 33 | Fade, 34 | Divider, 35 | Spacer, 36 | Tabs, 37 | TabList, 38 | TabPanels, 39 | Tab, 40 | TabPanel, 41 | SimpleGrid, 42 | TabIndicator, 43 | Stat, 44 | StatLabel, 45 | StatNumber, 46 | StatHelpText, 47 | StatArrow, 48 | useBreakpointValue, 49 | FormControl, 50 | FormLabel, 51 | UnorderedList, 52 | ListItem, 53 | Circle, 54 | Link, 55 | 56 | } from '@chakra-ui/react'; 57 | import { 58 | SunIcon, 59 | MoonIcon, 60 | InfoOutlineIcon, 61 | ChevronDownIcon, 62 | BellIcon, 63 | CheckCircleIcon, 64 | TimeIcon, 65 | SettingsIcon, 66 | RepeatIcon, 67 | HamburgerIcon, 68 | CloseIcon, 69 | SearchIcon, 70 | InfoIcon, 71 | } from '@chakra-ui/icons'; 72 | import { FaDiscord } from 'react-icons/fa'; 73 | import { keyframes } from '@emotion/react'; 74 | import { LiquidationTable } from './components/LiquidationTable'; 75 | import { WebSocketProvider } from './providers/WebSocketProvider'; 76 | import { useLiquidationStore } from './store/liquidationStore'; 77 | import { useState, useEffect, useRef, useMemo } from 'react'; 78 | import { motion } from 'framer-motion'; 79 | import { FundingRatesTab } from './components/FundingRatesTab'; 80 | // Define motion variants for animations 81 | const MotionBox = motion(Box); 82 | 83 | // Animation for notification pulse 84 | const pulse = keyframes` 85 | 0% { box-shadow: 0 0 0 0 rgba(49, 151, 149, 0.6); } 86 | 70% { box-shadow: 0 0 0 10px rgba(49, 151, 149, 0); } 87 | 100% { box-shadow: 0 0 0 0 rgba(49, 151, 149, 0); } 88 | `; 89 | 90 | // Define FundingRate type (copied from FundingRates.tsx for context) 91 | 92 | 93 | function App() { 94 | // Core states 95 | const { colorMode, toggleColorMode } = useColorMode(); 96 | const [soundEnabled, setSoundEnabled] = useState(true); 97 | const [alertVolume] = useState(70); 98 | const [isLoading, setIsLoading] = useState(true); 99 | const [hasError] = useState(false); 100 | const [connectionStatus, setConnectionStatus] = useState<'connected' | 'connecting' | 'disconnected'>('connecting'); 101 | const [activeView, setActiveView] = useState<'dashboard' | 'analytics' | 'settings'>('dashboard'); 102 | const [minLiquidationAlert] = useState(500000); 103 | const [showMobileMenu, setShowMobileMenu] = useState(false); 104 | const [animateNotification, setAnimateNotification] = useState(false); 105 | 106 | 107 | // Remove the unused state variable 108 | const [themeAccent] = useState<'teal' | 'purple' | 'blue' | 'cyan'>('teal'); 109 | 110 | // Exchange states 111 | type Exchange = 'BINANCE' | 'BYBIT' | 'OKX'; 112 | const [selectedExchanges, setSelectedExchanges] = useState(['BINANCE', 'BYBIT', 'OKX']); 113 | const availableExchanges: Exchange[] = ['BINANCE', 'BYBIT', 'OKX']; 114 | 115 | // UI Hooks 116 | const toast = useToast(); 117 | const { isOpen: isAlertOpen, onClose: onAlertClose } = useDisclosure({ defaultIsOpen: hasError }); 118 | const { onOpen: onSettingsDrawerOpen } = useDisclosure(); 119 | 120 | // Dynamic color values based on color mode and theme 121 | const bgColor = useColorModeValue('gray.50', 'gray.900'); 122 | const headerBg = useColorModeValue('white', 'gray.800'); 123 | const borderColor = useColorModeValue('gray.200', 'gray.700'); 124 | const accentColorMap = { 125 | teal: useColorModeValue('teal.500', 'teal.300'), 126 | purple: useColorModeValue('purple.500', 'purple.300'), 127 | blue: useColorModeValue('blue.500', 'blue.300'), 128 | cyan: useColorModeValue('cyan.500', 'cyan.300'), 129 | }; 130 | const accentColor = accentColorMap[themeAccent]; 131 | 132 | const cardBg = useColorModeValue('white', 'gray.800'); 133 | const mutedText = useColorModeValue('gray.600', 'gray.400'); 134 | const hoverBg = useColorModeValue('gray.100', 'gray.700'); 135 | const statsCardBg = useColorModeValue('white', 'gray.800'); 136 | const pulseAnimation = `${pulse} 2s infinite`; 137 | 138 | // Responsive values 139 | const headingSize = useBreakpointValue({ base: 'md', md: 'lg' }); 140 | const headerPadding = useBreakpointValue({ base: 3, md: 5 }); 141 | const containerPadding = useBreakpointValue({ base: 2, md: 4 }); 142 | 143 | // References 144 | const lastLiquidationRef = useRef<{ amount: number; symbol: string } | null>(null); 145 | const audioRef = useRef(null); 146 | 147 | 148 | 149 | // Toggle exchange selection 150 | const toggleExchange = (exchange: Exchange) => { 151 | if (selectedExchanges.includes(exchange)) { 152 | // Don't allow deselecting all exchanges 153 | if (selectedExchanges.length > 1) { 154 | setSelectedExchanges(selectedExchanges.filter(ex => ex !== exchange)); 155 | 156 | toast({ 157 | title: `${exchange} removed`, 158 | description: `No longer monitoring ${exchange}`, 159 | status: 'info', 160 | duration: 3000, 161 | isClosable: true, 162 | position: 'bottom-right', 163 | variant: 'subtle', 164 | icon: , 165 | }); 166 | } 167 | } else { 168 | setSelectedExchanges([...selectedExchanges, exchange] as Exchange[]); 169 | 170 | toast({ 171 | title: `${exchange} added`, 172 | description: `Now monitoring ${exchange}`, 173 | status: 'success', 174 | duration: 3000, 175 | isClosable: true, 176 | position: 'bottom-right', 177 | variant: 'subtle', 178 | icon: , 179 | }); 180 | } 181 | }; 182 | 183 | // Handle connection status changes 184 | useEffect(() => { 185 | // Simulate connection process 186 | setConnectionStatus('connecting'); 187 | 188 | const timer = setTimeout(() => { 189 | setIsLoading(false); 190 | setConnectionStatus('connected'); 191 | 192 | toast({ 193 | title: 'Connected', 194 | description: `Monitoring ${selectedExchanges.length} exchanges`, 195 | status: 'success', 196 | duration: 4000, 197 | isClosable: true, 198 | position: 'top', 199 | variant: 'subtle', 200 | icon: , 201 | }); 202 | }, 1500); 203 | 204 | return () => clearTimeout(timer); 205 | }, []); 206 | 207 | // Initialize audio element 208 | useEffect(() => { 209 | // This would normally be a sound file 210 | audioRef.current = new Audio('alert-sound.mp3'); 211 | if (audioRef.current) { 212 | audioRef.current.volume = alertVolume / 100; 213 | } 214 | }, []); 215 | 216 | // Update audio volume when changed 217 | useEffect(() => { 218 | if (audioRef.current) { 219 | audioRef.current.volume = alertVolume / 100; 220 | } 221 | }, [alertVolume]); 222 | 223 | // Simulated periodic data updates 224 | useEffect(() => { 225 | const interval = setInterval(() => { 226 | // Simulate new liquidation events coming in 227 | const randomAmount = Math.floor(Math.random() * 1000000) + 10000; 228 | const randomSymbol = ['BTC', 'ETH', 'SOL', 'LTC', 'XRP', 'BNB', 'DOGE', 'ADA', 'SUI'][Math.floor(Math.random() * 9)]; 229 | 230 | if (Math.random() > 0.5) { 231 | handleNewLiquidation({ 232 | amount: randomAmount, 233 | symbol: randomSymbol, 234 | }); 235 | } 236 | }, 10000); // Simulate events every 10 seconds 237 | 238 | return () => clearInterval(interval); 239 | }, [soundEnabled, minLiquidationAlert]); 240 | 241 | // Simulate handling a new liquidation 242 | const handleNewLiquidation = (data: { amount: number; symbol: string }) => { 243 | // Update the last liquidation ref and increment the counter 244 | 245 | // Track largest liquidation 246 | if (!lastLiquidationRef.current || data.amount > lastLiquidationRef.current.amount) { 247 | lastLiquidationRef.current = data; 248 | } 249 | 250 | // Animate notification badge 251 | setAnimateNotification(true); 252 | setTimeout(() => setAnimateNotification(false), 2000); 253 | 254 | if (soundEnabled && data.amount > minLiquidationAlert) { 255 | // Play sound logic 256 | if (audioRef.current) { 257 | audioRef.current.play().catch(e => console.error('Audio play failed:', e)); 258 | } 259 | 260 | // Show a notification for large liquidations 261 | } 262 | }; 263 | 264 | // Connection status indicator properties 265 | const getConnectionStatusProps = () => { 266 | switch (connectionStatus) { 267 | case 'connected': 268 | return { 269 | colorScheme: 'green', 270 | text: 'LIVE', 271 | icon: CheckCircleIcon, 272 | tooltip: 'Real-time data connection active', 273 | }; 274 | case 'connecting': 275 | return { 276 | colorScheme: 'yellow', 277 | text: 'CONNECTING', 278 | icon: TimeIcon, 279 | tooltip: 'Establishing connection...', 280 | }; 281 | case 'disconnected': 282 | return { 283 | colorScheme: 'red', 284 | text: 'OFFLINE', 285 | icon: InfoOutlineIcon, 286 | tooltip: 'Connection lost. Trying to reconnect...', 287 | }; 288 | } 289 | }; 290 | 291 | 292 | // Apply status properties 293 | const statusProps = getConnectionStatusProps(); 294 | 295 | const { stats, totalValue } = useLiquidationStore(); 296 | const { buyCount, sellCount, largestLiquidation } = stats; 297 | 298 | const quickStats = useMemo(() => { 299 | const totalLiquidations = buyCount + sellCount; 300 | const buyRatio = totalLiquidations > 0 ? buyCount / totalLiquidations : 0.5; 301 | const sellRatio = totalLiquidations > 0 ? sellCount / totalLiquidations : 0.5; 302 | 303 | // Calculate position of the indicator based on the ratio 304 | const indicatorPosition = `${buyRatio * 100}%`; 305 | 306 | // Pulse animation for the active side 307 | const pulseKeyframes = keyframes` 308 | 0%, 100% { opacity: 1; } 309 | 50% { opacity: 0.7; } 310 | `; 311 | 312 | const pulseAnimation = `${pulseKeyframes} 1.5s ease-in-out infinite`; 313 | 314 | return [ 315 | { 316 | label: 'Liquidation Battle', 317 | value: totalLiquidations, 318 | customContent: true, 319 | render: ( 320 | 321 | Liquidation Tug of War 322 | 323 | {totalLiquidations.toLocaleString()} 324 | 325 | 326 | 337 | {/* Buy side (green) */} 338 | 0.5 ? pulseAnimation : undefined} 345 | borderRightRadius="md" 346 | /> 347 | 348 | {/* Sell side (red) */} 349 | 0.5 ? pulseAnimation : undefined} 356 | borderLeftRadius="md" 357 | /> 358 | 359 | {/* Center battle line/indicator */} 360 | 371 | 372 | 373 | 374 | ), 375 | direction: 'increase', 376 | }, 377 | { 378 | label: 'Largest Seen', 379 | customContent: true, 380 | render: ( 381 | 382 | Largest Seen 383 | 384 | {largestLiquidation 385 | ? largestLiquidation.value >= 1000000 386 | ? `$${(largestLiquidation.value / 1000000).toFixed(2)}M` 387 | : largestLiquidation.value >= 1000 388 | ? `$${(largestLiquidation.value / 1000).toFixed(2)}K` 389 | : `$${largestLiquidation.value.toFixed(2)}` 390 | : '-'} 391 | 392 | {largestLiquidation?.symbol && ( 393 | 394 | {largestLiquidation.symbol} 395 | 396 | )} 397 | 398 | ), 399 | direction: 'increase', 400 | }, 401 | { 402 | label: 'Total Value', 403 | value: `$${(totalValue / 1000000).toFixed(1)}M`, 404 | customContent: true, 405 | render: ( 406 | 407 | 408 | 409 | Total Liquidation Value 410 | 415 | 416 | 417 | 418 | 419 | 420 | {totalValue >= 1000000 421 | ? `$${(totalValue / 1000000).toFixed(1)}M` 422 | : `$${(totalValue / 1000).toFixed(1)}K`} 423 | 424 | 425 | ), 426 | }, 427 | { 428 | label: 'Active Exchanges', 429 | value: selectedExchanges.length, 430 | max: availableExchanges.length, 431 | direction: 'none', 432 | }, 433 | ]; 434 | }, [buyCount, sellCount, largestLiquidation, totalValue, selectedExchanges.length, borderColor, accentColor, mutedText]); 435 | 436 | return ( 437 | 438 | 439 | 440 | {/* Header Bar */} 441 | 459 | 460 | 461 | {/* Mobile menu button */} 462 | : } 468 | onClick={() => setShowMobileMenu(!showMobileMenu)} 469 | mr={2} 470 | /> 471 | 472 | 473 | Crypto Liquidation Feed 474 | 475 | 482 | 483 | {statusProps.text} 484 | 485 | 486 | 487 | 488 | 489 | 490 | {/* Desktop Navigation */} 491 | 492 | 501 | 510 | 511 | 512 | 513 | {/* Exchange selection dropdown */} 514 | 515 | } 519 | colorScheme={themeAccent} 520 | variant="outline" 521 | _hover={{ bg: hoverBg }} 522 | transition="all 0.2s" 523 | > 524 | Exchanges ({selectedExchanges.length}) 525 | 526 | 527 | {availableExchanges.map(exchange => ( 528 | toggleExchange(exchange)} 531 | closeOnSelect={false} 532 | _hover={{ bg: hoverBg }} 533 | transition="all 0.2s" 534 | > 535 | 536 | 537 | {exchange} 538 | 539 | 540 | 545 | 546 | 547 | ))} 548 | 549 | 550 | 551 | {/* Sound Toggle */} 552 | 553 | 554 | 555 | 556 | 557 | 558 | Sound 559 | 560 | 561 | 562 | { 566 | setSoundEnabled(!soundEnabled); 567 | toast({ 568 | title: `Sound alerts ${!soundEnabled ? 'enabled' : 'disabled'}`, 569 | status: !soundEnabled ? 'success' : 'info', 570 | duration: 2000, 571 | isClosable: true, 572 | position: 'bottom-right', 573 | }); 574 | }} 575 | colorScheme={themeAccent} 576 | size="md" 577 | /> 578 | 579 | 580 | 581 | 582 | 583 | {/* Theme toggle */} 584 | : } 587 | onClick={toggleColorMode} 588 | variant="outline" 589 | colorScheme={themeAccent} 590 | size="md" 591 | _hover={{ bg: hoverBg }} 592 | transition="all 0.2s" 593 | /> 594 | 595 | {/* Settings button */} 596 | } 599 | onClick={onSettingsDrawerOpen} 600 | variant="outline" 601 | colorScheme={themeAccent} 602 | size="md" 603 | _hover={{ bg: hoverBg }} 604 | transition="all 0.2s" 605 | /> 606 | 607 | 608 | 609 | {/* Mobile Navigation Menu */} 610 | 611 | 612 | 624 | 636 | 637 | 638 | 639 | 640 | {/* Error state with animation */} 641 | 642 | 643 | 644 | 645 | Connection Error 646 | 647 | We're having trouble connecting to the live feed. Data may be delayed. 648 | 649 | 650 | 651 | 652 | 653 | 654 | {/* Dashboard View */} 655 | {activeView === 'dashboard' && ( 656 | 657 | {/* Quick Stats Section */} 658 | 663 | 664 | {quickStats.map((stat, index) => ( 665 | 676 | {stat.customContent ? ( 677 | stat.render 678 | ) : ( 679 | 680 | {stat.label} 681 | 682 | {typeof stat.value === 'number' ? stat.value.toLocaleString() : stat.value} 683 | {'symbol' in stat && {stat.symbol as string}} 684 | 685 | {'change' in stat && ( 686 | 687 | 688 | {stat.change as React.ReactNode} 689 | 690 | )} 691 | {'detail' in stat && {stat.detail as React.ReactNode}} 692 | {stat.max && of {stat.max} available} 693 | 694 | )} 695 | 696 | ))} 697 | 698 | 699 | 700 | {/* Main Content Tabs */} 701 | 706 | 716 | 717 | 718 | Liquidation Feed 719 | Funding Rates 720 | 721 | 722 | 723 | {/* Liquidation Feed Panel */} 724 | 725 | 731 | Liquidation Events 732 | 733 | 734 | 735 | Real-time 736 | 737 | 738 | 745 | document.querySelector('.chakra-menu__menu-button')?.dispatchEvent( 746 | new MouseEvent('click', { bubbles: true }) 747 | ) 748 | } 749 | > 750 | {selectedExchanges.length} Exchanges 751 | 752 | 753 | 754 | setSoundEnabled(!soundEnabled)} 761 | > 762 | Sound {soundEnabled ? 'ON' : 'OFF'} 763 | 764 | 765 | 766 | 767 | 768 | {isLoading ? ( 769 | 775 | ) : ( 776 | 777 | )} 778 | 779 | 780 | 781 | 782 | 783 | 784 | 785 | 786 | 787 | 788 | 789 | 790 | )} 791 | 792 | {/* Analytics View */} 793 | {activeView === 'analytics' && ( 794 | 799 | 809 | 810 | 811 | 812 | Advanced Analytics 813 | 814 | 815 | Coming Soon 816 | 817 | 818 | 819 | 820 | 821 | 822 | Analytics Dashboard Under Development 823 | 824 | This section will include: 825 | 826 | Historical liquidation trends 827 | Exchange-wise distribution analysis 828 | Price correlation insights 829 | Market sentiment indicators 830 | 831 | 832 | 833 | 834 | 835 | 836 | 843 | 844 | 845 | 846 | 847 | Historical Analysis 848 | 849 | Temporal patterns and trend analysis of liquidation events 850 | 851 | 852 | 853 | 854 | 861 | 862 | 863 | 864 | 865 | Market Correlation 866 | 867 | Cross-market liquidation impact analysis 868 | 869 | 870 | 871 | 872 | 873 | 874 | 875 | 876 | Analytics features are currently in development. Check back soon for updates. 877 | 878 | 879 | 880 | 881 | )} 882 | 883 | {/* Footer */} 884 | 894 | 895 | 896 | 897 | 898 | {connectionStatus} 899 | 900 | 901 | 902 | 903 | {selectedExchanges.join(', ')} 904 | 905 | 906 | 907 | 908 | 909 | {soundEnabled ? 'On' : 'Off'} 910 | 911 | 912 | {/* Discord Link */} 913 | 914 | 915 | 916 | 917 | Discord 918 | 919 | 920 | 921 | 922 | © {new Date().getFullYear()} Crypto Liquidation Feed 923 | 924 | 925 | 926 | 927 | ); 928 | } 929 | 930 | export default App; --------------------------------------------------------------------------------