From 9237fb4b461645c655d7935d82922aaa27487de9 Mon Sep 17 00:00:00 2001 From: aldin4u Date: Tue, 23 Dec 2025 09:53:44 +0000 Subject: [PATCH 001/111] fix(pulse): token selector responsiveness and daily price change data - Refactored token selector in Buy and Sell components to use a flexible flexbox layout, resolving text overlap and width issues. - Fixed dailyPriceChange data mapping in Search.tsx to correctly handle both 'price_change_24h' and 'priceChange24h' properties. - Updated Buy.tsx to include price change data when pre-selecting tokens from URL. --- src/apps/pulse/components/Buy/Buy.tsx | 112 +++++++++--------- src/apps/pulse/components/Search/Search.tsx | 32 +++-- src/apps/pulse/components/Sell/Sell.tsx | 125 ++++++++++---------- 3 files changed, 133 insertions(+), 136 deletions(-) diff --git a/src/apps/pulse/components/Buy/Buy.tsx b/src/apps/pulse/components/Buy/Buy.tsx index 9e56be2c..0e800a8c 100644 --- a/src/apps/pulse/components/Buy/Buy.tsx +++ b/src/apps/pulse/components/Buy/Buy.tsx @@ -200,17 +200,17 @@ export default function Buy(props: BuyProps) { } = useTokenPnL( token && accountAddress && portfolioToken ? { - token: { - contract: token.address || '', - symbol: token.symbol, - decimals: token.decimals || 18, - balance: portfolioToken.balance || 0, - price: portfolioToken.price || 0, - }, - transactionsData, - walletAddress: accountAddress, - chainId: token.chainId, - } + token: { + contract: token.address || '', + symbol: token.symbol, + decimals: token.decimals || 18, + balance: portfolioToken.balance || 0, + price: portfolioToken.price || 0, + }, + transactionsData, + walletAddress: accountAddress, + chainId: token.chainId, + } : null ); @@ -245,7 +245,7 @@ export default function Buy(props: BuyProps) { const nativeToken = portfolioTokens.find( (t) => Number(getChainId(t.blockchain as MobulaChainNames)) === - maxStableCoinBalance.chainId && isNativeToken(t.contract) + maxStableCoinBalance.chainId && isNativeToken(t.contract) ); if (!nativeToken) { @@ -598,7 +598,7 @@ export default function Buy(props: BuyProps) { ? foundToken.decimals[0] || 18 : foundToken.decimals || 18, usdValue: foundToken.price?.toString() || '0', - dailyPriceChange: 0, + dailyPriceChange: foundToken.price_change_24h || 0, }; setBuyToken(tokenToSelect as SelectedToken); @@ -642,11 +642,11 @@ export default function Buy(props: BuyProps) { > {token ? (
{/* Logo */} -
+
{token.logo ? (
- {/* Top Row: Symbol and Name */} -
-

- {token.symbol} -

-

- {token.name} -

-
+ {/* Text Container */} +
+ {/* Top Row: Symbol and Name */} +
+

+ {token.symbol} +

+

+ {token.name} +

+
+ + {/* Bottom Row: Price and Change */} +
+

+ ${token.usdValue} +

- {/* Bottom Row: Price and Change */} -
-

- ${token.usdValue} -

- -
- {/* Triangle Indicator */} - {token.dailyPriceChange !== 0 && ( -
= 0 - ? 'border-b-[6px] border-b-[#5CFF93]' - : 'border-t-[6px] border-t-[#FF366C]' - } opacity-50`} - /> - )} - -

= 0 +

+ {/* Triangle Indicator */} + {token.dailyPriceChange !== 0 && ( +
= 0 + ? 'border-b-[4px] border-b-[#5CFF93]' + : 'border-t-[4px] border-t-[#FF366C]' + } opacity-50`} + /> + )} + +

= 0 ? 'text-[#5CFF93]' : 'text-[#FF366C]' - }`} - > - {Math.abs(token.dailyPriceChange).toFixed(2)}% -

+ }`} + > + {Math.abs(token.dailyPriceChange).toFixed(2)}% +

+
{/* Chevron */} -
+
arrow-down
@@ -852,11 +853,10 @@ export default function Buy(props: BuyProps) { className="flex bg-black ml-2.5 mr-2.5 w-[75px] h-[30px] rounded-[10px] p-0.5 pb-1 pt-0.5" > +
+ + {agentPrivateKey && ( +
+
+ + {showPrivateKey && ( + <> + + + + )} +
+ + {showPrivateKey && ( +
+
+ {agentPrivateKey} +
+
+ + Never share your private key! Anyone with access can control this wallet. +
+
+ )} +
+ )} +
+ )} +
+
+
+ + {agentStatus === 'none' && ( + <> + {!address ? ( +
+ Please connect your wallet to create an agent +
+ ) : isLoadingAgent ? ( +
+ Loading agent wallet... +
+ ) : ( + <> +
+ + +
+
+ 💡 Import your existing Hyperliquid agent or create a new one +
+ + )} + + )} + + + + + Import Existing Agent + + Enter the private key of your Hyperliquid agent wallet (e.g., the one you created as trading-agent) + + +
+
+ + setImportPrivateKey(e.target.value)} + /> +
+
+ + +
+
+
+
+ + {agentStatus === 'created' && ( +
+ + +
+ )} + + {agentStatus === 'approved' && ( +
+
+ ✓ Agent is active and ready to trade +
+ +
+ )} + + ); +} diff --git a/src/apps/perps/components/AssetSelector.tsx b/src/apps/perps/components/AssetSelector.tsx new file mode 100644 index 00000000..354d8141 --- /dev/null +++ b/src/apps/perps/components/AssetSelector.tsx @@ -0,0 +1,147 @@ +import { useState, useEffect, useMemo } from 'react'; +import { Search, Download } from 'lucide-react'; +import { Input } from './ui/input'; +import { Card } from './ui/card'; +import { ScrollArea } from './ui/scroll-area'; +import { Button } from './ui/button'; +import { getAllAssets } from '../lib/hyperliquid/client'; +import type { AssetInfo } from '../lib/hyperliquid/types'; +import { Skeleton } from './ui/skeleton'; +import { toast } from 'sonner'; + +interface AssetSelectorProps { + selectedSymbol: string | null; + onSelect: (symbol: string, asset: AssetInfo) => void; +} + +export function AssetSelector({ selectedSymbol, onSelect }: AssetSelectorProps) { + const [assets, setAssets] = useState([]); + const [search, setSearch] = useState(''); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + loadAssets(); + }, []); + + const loadAssets = async () => { + setIsLoading(true); + try { + const data = await getAllAssets(); + setAssets(data); + } catch (error) { + console.error('Failed to load assets:', error); + } finally { + setIsLoading(false); + } + }; + + const filteredAssets = useMemo(() => { + if (!search) return assets; + const searchLower = search.toLowerCase(); + return assets.filter(asset => + asset.symbol.toLowerCase().includes(searchLower) + ); + }, [assets, search]); + + const exportToCSV = () => { + if (assets.length === 0) { + toast.error('No assets to export'); + return; + } + + // Create CSV content + const headers = ['Symbol', 'ID', 'Max Leverage', 'Size Decimals']; + const rows = assets.map(asset => [ + asset.symbol, + asset.id, + asset.maxLeverage, + asset.szDecimals + ]); + + const csvContent = [ + headers.join(','), + ...rows.map(row => row.join(',')) + ].join('\n'); + + // Create blob and download + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + const date = new Date().toISOString().split('T')[0]; + + link.setAttribute('href', url); + link.setAttribute('download', `hyperliquid-assets-${date}.csv`); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + toast.success(`Exported ${assets.length} assets to CSV`); + }; + + if (isLoading) { + return ( + +
+ + +
+
+ ); + } + + return ( + +
+
+
+ + setSearch(e.target.value)} + className="pl-10" + /> +
+ +
+ + +
+ {filteredAssets.map((asset) => ( + + ))} + {filteredAssets.length === 0 && ( +
+ No assets found +
+ )} +
+
+
+
+ ); +} diff --git a/src/apps/perps/components/BalanceCard.tsx b/src/apps/perps/components/BalanceCard.tsx new file mode 100644 index 00000000..0eb467f1 --- /dev/null +++ b/src/apps/perps/components/BalanceCard.tsx @@ -0,0 +1,71 @@ +import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; +import { DollarSign, TrendingUp, RefreshCw } from 'lucide-react'; +import { Button } from './ui/button'; +import { DepositModal } from './DepositModal'; +import type { UserState } from '../lib/hyperliquid/types'; + +interface BalanceCardProps { + userState: UserState; + isLoading: boolean; + onRefresh?: () => void; +} + +export function BalanceCard({ userState, isLoading, onRefresh }: BalanceCardProps) { + const availableUSDC = parseFloat(userState.marginSummary?.totalRawUsd || '0'); + const accountEquity = parseFloat(userState.marginSummary?.accountValue || '0'); + + return ( + + +
+ Account Balance + {onRefresh && ( + + )} +
+
+ +
+
+
+ +
+
+

Available USDC

+

+ ${availableUSDC.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +

+
+
+
+ +
+
+
+ +
+
+

Account Equity

+

+ ${accountEquity.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +

+
+
+
+ +
+ +
+
+
+ ); +} diff --git a/src/apps/perps/components/ConnectButton.tsx b/src/apps/perps/components/ConnectButton.tsx new file mode 100644 index 00000000..ad08a96e --- /dev/null +++ b/src/apps/perps/components/ConnectButton.tsx @@ -0,0 +1,60 @@ +import { useAccount, useConnect, useDisconnect } from 'wagmi'; +import { Button } from './ui/button'; +import { Wallet, LogOut, AlertTriangle } from 'lucide-react'; +import { Badge } from './ui/badge'; + +export function ConnectButton() { + const { address, isConnected, chain } = useAccount(); + const { connect, connectors, isPending } = useConnect(); + const { disconnect } = useDisconnect(); + + if (isConnected && address) { + const isArbitrum = chain?.id === 42161; + + return ( +
+
+ + {address.slice(0, 6)}...{address.slice(-4)} + +
+ + {chain?.name || 'Unknown Network'} + + {!isArbitrum && ( + + + Switch to Arbitrum + + )} +
+
+ +
+ ); + } + + return ( +
+ {connectors.map((connector) => ( + + ))} +
+ ); +} diff --git a/src/apps/perps/components/CopyTile.tsx b/src/apps/perps/components/CopyTile.tsx new file mode 100644 index 00000000..591d8cb0 --- /dev/null +++ b/src/apps/perps/components/CopyTile.tsx @@ -0,0 +1,114 @@ +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from './ui/card'; +import { Badge } from './ui/badge'; +import { Button } from './ui/button'; +import { TrendingUp, TrendingDown, Target, Shield, Trophy } from 'lucide-react'; +import type { CopyTile as CopyTileType } from '../lib/hyperliquid/types'; +import { getEntryPrice } from '../lib/hyperliquid/math'; + +interface CopyTileProps { + tile: CopyTileType; + onExecute: () => void; + isExecuting: boolean; + disabled: boolean; +} + +export function CopyTile({ tile, onExecute, isExecuting, disabled }: CopyTileProps) { + const entryPrice = getEntryPrice(tile.entry); + const isLong = tile.side === 'long'; + + const formatPrice = (price: number | number[]) => { + if (Array.isArray(price)) { + return `$${price[0]} - $${price[1]}`; + } + return `$${price}`; + }; + + const formatTakeProfits = () => { + if (typeof tile.takeProfits === 'number') { + return `$${tile.takeProfits}`; + } + if (tile.takeProfits.length === 2 && !Array.isArray(tile.takeProfits[0])) { + return `$${tile.takeProfits[0]} - $${tile.takeProfits[1]}`; + } + return tile.takeProfits.map(tp => `$${tp}`).join(', '); + }; + + return ( + + +
+
+ + {tile.symbol} + + {isLong ? ( + <> + + LONG + + ) : ( + <> + + SHORT + + )} + + + Copy Trade • $10 Notional • 5× Leverage +
+
+
+ +
+
+
+ +
+
+

Entry

+

{formatPrice(tile.entry)}

+
+
+ +
+
+ +
+
+

Stop Loss

+

${tile.stopLoss}

+
+
+ +
+
+ +
+
+

Take Profits

+

{formatTakeProfits()}

+
+
+
+ + + + {disabled && ( +

+ Connect wallet and setup Hyperliquid to trade +

+ )} +
+
+ ); +} diff --git a/src/apps/perps/components/DepositModal.tsx b/src/apps/perps/components/DepositModal.tsx new file mode 100644 index 00000000..82ce27c8 --- /dev/null +++ b/src/apps/perps/components/DepositModal.tsx @@ -0,0 +1,329 @@ +import { useState } from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../components/ui/dialog'; +import { Button } from '../components/ui/button'; +import { Input } from '../components/ui/input'; +import { Label } from '../components/ui/label'; +import { ArrowDownUp, ExternalLink } from 'lucide-react'; +import { useToast } from '../hooks/use-toast'; +import { ethers } from 'ethers'; +import { checkUSDCBalance, depositUSDC } from '../lib/hyperliquid/bridge'; +import useTransactionKit from '../../../hooks/useTransactionKit'; + +interface DepositModalProps { + userState: any; +} + +export function DepositModal({ userState }: DepositModalProps) { + const [open, setOpen] = useState(false); + const [amount, setAmount] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [arbitrumBalance, setArbitrumBalance] = useState(null); + const [txHash, setTxHash] = useState(null); + const { toast } = useToast(); + const { walletAddress: address, kit, walletProvider } = useTransactionKit(); + + // Re-export contract addresses from bridge logic or define them here + const BRIDGE_CONTRACT_ADDRESS = '0x2Df1c51E09aECF9cacB7bc98cB1742757f163dF7'; + const USDC_CONTRACT_ADDRESS = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831'; + + const fetchArbitrumBalance = async () => { + if (!address || !kit) return; + // ... + // Note: for balance we can still use kit.provider/ethers. But kit doesn't expose provider directly easily. + // Fallback to walletProvider for reading balance or use kit.getAccount() for general balance? + // Actually, keep using walletProvider for balance check is fine as it's read-only. + try { + if (!walletProvider) return; + const provider = new ethers.providers.Web3Provider(walletProvider as any); + const balance = await checkUSDCBalance(address, provider); + setArbitrumBalance(balance); + } catch (error) { + // ... + } + }; + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen); + if (newOpen) { + fetchArbitrumBalance(); + setTxHash(null); + } + }; + + const handleMaxClick = () => { + if (arbitrumBalance) { + setAmount(arbitrumBalance); + } + }; + + const handleDeposit = async () => { + if (!amount || parseFloat(amount) <= 0) { + toast({ + title: 'Invalid Amount', + description: 'Please enter a valid amount', + variant: 'destructive', + }); + return; + } + + if (parseFloat(amount) < 5) { + toast({ + title: 'Amount Too Low', + description: 'Minimum deposit is 5 USDC', + variant: 'destructive', + }); + return; + } + + if (!address || !kit) { + toast({ + title: 'Wallet Not Connected', + description: 'Please connect your wallet', + variant: 'destructive', + }); + return; + } + + setIsLoading(true); + try { + // Switch to Arbitrum before depositing + if (walletProvider && 'request' in walletProvider) { + try { + // @ts-ignore + const chainId = await walletProvider.request({ method: 'eth_chainId' }); + if (chainId !== '0xa4b1') { + console.log('Switching to Arbitrum...'); + // @ts-ignore + await walletProvider.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0xa4b1' }], + }); + } + } catch (e: any) { + console.error('Chain switch error:', e); + // If chain not found, add it + if (e.code === 4902) { + // @ts-ignore + await walletProvider.request({ + method: 'wallet_addEthereumChain', + params: [{ + chainId: '0xa4b1', + chainName: 'Arbitrum One', + rpcUrls: ['https://arb1.arbitrum.io/rpc'], + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + blockExplorerUrls: ['https://arbiscan.io'] + }], + }); + } else { + toast({ + title: 'Wrong Network', + description: 'Please switch to Arbitrum manually', + variant: 'destructive' + }); + setIsLoading(false); + return; + } + } + } + + // Check ETH Balance for gas + try { + if (walletProvider) { + const provider = new ethers.providers.Web3Provider(walletProvider as any); + const ethBalance = await provider.getBalance(address); + console.log('Arbitrum ETH Balance:', ethers.utils.formatEther(ethBalance)); + if (ethBalance.lt(ethers.utils.parseEther("0.001"))) { + toast({ + title: 'Insufficient ETH', + description: 'You need ETH on Arbitrum for gas fees.', + variant: 'destructive', + }); + setIsLoading(false); + return; + } + } + } catch (e) { + console.warn('Failed to check ETH balance:', e); + } + + const amountInWei = ethers.utils.parseUnits(amount, 6); + const batchName = `perps-deposit-${Date.now()}`; + + console.log(`Preparing deposit of ${amount} USDC (${amountInWei.toString()} wei)`); + + // Clean up any existing batch + try { + kit.batch({ batchName }).remove(); + } catch (e) { + // ignore + } + + // Step 1: Approve USDC + // Encode approve function call + const erc20Interface = new ethers.utils.Interface([ + 'function approve(address spender, uint256 amount) public returns (bool)' + ]); + const approveData = erc20Interface.encodeFunctionData('approve', [ + BRIDGE_CONTRACT_ADDRESS, + amountInWei + ]); + + kit.transaction({ + to: USDC_CONTRACT_ADDRESS, + data: approveData, + value: '0', + chainId: 42161 // Arbitrum One + }) + .name({ transactionName: 'approveUSDC' }) + .addToBatch({ batchName }); + + // Step 2: Deposit to Bridge + const bridgeInterface = new ethers.utils.Interface([ + 'function deposit(uint64 usd) external' + ]); + const depositData = bridgeInterface.encodeFunctionData('deposit', [ + amountInWei + ]); + + kit.transaction({ + to: BRIDGE_CONTRACT_ADDRESS, + data: depositData, + value: '0', + chainId: 42161 // Arbitrum One + }) + .name({ transactionName: 'depositUSDC' }) + .addToBatch({ batchName }); + + toast({ + title: 'Confirming Transaction', + description: 'Please sign the transaction in your wallet...', + }); + + // Send batch + const batchSend = await kit.sendBatches({ onlyBatchNames: [batchName] }); + + const sentBatch = batchSend.batches[batchName]; + if (batchSend.isSentSuccessfully && !sentBatch?.errorMessage) { + // Success + // Chain ID for Arbitrum is 42161 + const userOpHash = sentBatch.chainGroups?.['42161']?.userOpHash || sentBatch.chainGroups?.['1']?.userOpHash; // Adjust chain ID logic if not hardcoded + + // In this environment, we might get a tx hash or user op hash + // Just show success + toast({ + title: 'Success!', + description: `Bridging ${amount} USDC. It will arrive in 5-10 minutes.`, + }); + setTxHash(userOpHash || 'submitted'); + setAmount(''); + } else { + throw new Error(sentBatch?.errorMessage || 'Batch send failed'); + } + + } catch (error: any) { + console.error('Bridge error:', error); + toast({ + title: 'Bridge Failed', + description: error.message || 'Failed to bridge USDC', + variant: 'destructive', + }); + } finally { + // Cleanup + try { + // kit.batch({ batchName }).remove(); // variable scope issue, need to define batchName outside or ignore + } catch { } + setIsLoading(false); + } + }; + + const currentBalance = userState?.marginSummary?.accountValue || '0'; + + return ( + + + + + + + Deposit USDC + +
+
+ +

${parseFloat(currentBalance).toFixed(2)}

+
+ + {arbitrumBalance !== null && ( +
+ +

{parseFloat(arbitrumBalance).toFixed(2)} USDC

+
+ )} + +
+
+ + +
+ setAmount(e.target.value)} + disabled={isLoading} + step="0.01" + min="0" + /> +
+ +
+
+ Network: + Arbitrum One +
+
+ Estimated Time: + 5-10 minutes +
+
+ + + + {txHash && ( +
+ Transaction submitted + + View on Arbiscan + + +
+ )} +
+
+
+ ); +} diff --git a/src/apps/perps/components/PositionCard.tsx b/src/apps/perps/components/PositionCard.tsx new file mode 100644 index 00000000..412f36da --- /dev/null +++ b/src/apps/perps/components/PositionCard.tsx @@ -0,0 +1,233 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; +import { Badge } from './ui/badge'; +import { TrendingUp, TrendingDown, Target, Shield, Trophy, RefreshCw } from 'lucide-react'; +import { getUserState, getOpenOrders, getMarkPrice } from '../lib/hyperliquid/client'; +import { parsePositionForSymbol, parseReduceOnlyOrders } from '../lib/hyperliquid/parsers'; +import { computePnl, formatPrice, formatPnl } from '../lib/hyperliquid/pnl'; +import { cn } from '../lib/utils'; + +interface PositionCardProps { + symbol: string; + address?: `0x${string}` | string; +} + +export function PositionCard({ symbol, address }: PositionCardProps) { + const [loading, setLoading] = useState(false); + const [side, setSide] = useState<"long" | "short" | null>(null); + const [size, setSize] = useState(0); + const [entryPx, setEntryPx] = useState(0); + const [markPx, setMarkPx] = useState(0); + const [stopLoss, setStopLoss] = useState(); + const [takeProfits, setTakeProfits] = useState([]); + const [lastUpdate, setLastUpdate] = useState(null); + + useEffect(() => { + if (!address || !symbol) return; + + let alive = true; + + async function load() { + try { + setLoading(true); + + const [state, orders, mark] = await Promise.all([ + getUserState(address as string), + getOpenOrders(address as string, symbol), + getMarkPrice(symbol), + ]); + + if (!alive) return; + + // Parse position for the symbol + const pos = state ? parsePositionForSymbol(state, symbol) : null; + + if (pos) { + setSide(pos.side); + setSize(pos.size); + setEntryPx(pos.entryPx); + + // Parse SL/TP from reduce-only orders + const { sl, tps } = parseReduceOnlyOrders(orders, symbol, pos.side, pos.entryPx); + setStopLoss(sl); + setTakeProfits(tps); + } else { + setSide(null); + setSize(0); + setEntryPx(0); + setStopLoss(undefined); + setTakeProfits([]); + } + + setMarkPx(mark ?? 0); + setLastUpdate(new Date()); + } catch (error) { + console.error('Error loading position:', error); + } finally { + setLoading(false); + } + } + + load(); + const id = setInterval(load, 2000); + + return () => { + alive = false; + clearInterval(id); + }; + }, [address, symbol]); + + const pnl = useMemo(() => { + if (!side || !size || !entryPx || !markPx) { + return { pnlUsd: 0, pnlPct: 0 }; + } + return computePnl(side, size, entryPx, markPx); + }, [side, size, entryPx, markPx]); + + if (!address) { + return null; + } + + if (!side) { + return ( + + + + Position: {symbol} + + No Position + + + + +
+ No open {symbol} position +
+
+
+ ); + } + + const isProfitable = pnl.pnlUsd > 0; + const isLong = side === 'long'; + + return ( + + +
+ + Position: {symbol} + + {isLong ? ( + <> + + LONG + + ) : ( + <> + + SHORT + + )} + + +
+ + {lastUpdate && ( + + {lastUpdate.toLocaleTimeString()} + + )} +
+
+
+ + {/* Size and Prices */} +
+
+

Size

+

{formatPrice(size, 4)}

+
+
+

Entry Price

+

${formatPrice(entryPx, 2)}

+
+
+ + {/* Mark Price */} +
+

Current Mark Price

+

${formatPrice(markPx, 2)}

+
+ + {/* PnL */} +
+

Unrealized PnL

+
+

+ {formatPnl(pnl.pnlUsd)} +

+

+ ({formatPnl(pnl.pnlPct, true)}) +

+
+
+ + {/* SL and TP */} +
+ {stopLoss && ( +
+
+ +
+
+

Stop Loss

+

+ ${formatPrice(stopLoss, 2)} +

+
+
+ )} + + {takeProfits.length > 0 && ( +
+
+ +
+
+

Take Profit Targets

+
+ {takeProfits.map((tp, idx) => ( + + ${formatPrice(tp, 2)} + + ))} +
+
+
+ )} + + {!stopLoss && takeProfits.length === 0 && ( +
+ No SL/TP orders detected +
+ )} +
+
+
+ ); +} diff --git a/src/apps/perps/components/PositionsCard.tsx b/src/apps/perps/components/PositionsCard.tsx new file mode 100644 index 00000000..63f67409 --- /dev/null +++ b/src/apps/perps/components/PositionsCard.tsx @@ -0,0 +1,136 @@ +import { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card'; +import { Button } from '../components/ui/button'; +import { RefreshCw } from 'lucide-react'; +import { getUserState } from '../lib/hyperliquid/client'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../components/ui/collapsible'; +import { ChevronDown } from 'lucide-react'; + +interface PositionsCardProps { + masterAddress: string; +} + +export function PositionsCard({ masterAddress }: PositionsCardProps) { + const [positions, setPositions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isOpen, setIsOpen] = useState(true); + + const fetchPositions = async () => { + if (!masterAddress) return; + + setIsLoading(true); + try { + const userState = await getUserState(masterAddress); + + if (userState?.assetPositions) { + const openPositions = userState.assetPositions + .map((pos: any) => pos.position) + .filter((p: any) => parseFloat(p.szi) !== 0); + + setPositions(openPositions); + } + } catch (error) { + console.error('Error fetching positions:', error); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchPositions(); + const interval = setInterval(fetchPositions, 10000); // Refresh every 10 seconds + return () => clearInterval(interval); + }, [masterAddress]); + + const formatNumber = (value: string | number, decimals: number = 2): string => { + return parseFloat(value.toString()).toFixed(decimals); + }; + + const formatPnl = (pnl: string) => { + const pnlNum = parseFloat(pnl); + const formatted = formatNumber(pnlNum, 2); + const className = pnlNum >= 0 ? 'text-green-600' : 'text-red-600'; + return { formatted, className }; + }; + + const calculateLeverage = (position: any): string => { + const positionValue = Math.abs(parseFloat(position.szi)) * parseFloat(position.entryPx); + const marginUsed = parseFloat(position.marginUsed); + if (marginUsed === 0) return '0x'; + return `${formatNumber(positionValue / marginUsed, 1)}x`; + }; + + return ( + + + + + Open Positions + + + + + + + {positions.length === 0 ? ( +

+ No open positions +

+ ) : ( +
+ + + + + + + + + + + + + + {positions.map((position, index) => { + const pnl = formatPnl(position.unrealizedPnl); + const roe = (parseFloat(position.unrealizedPnl) / parseFloat(position.marginUsed)) * 100; + const isLong = parseFloat(position.szi) > 0; + + return ( + + + + + + + + + + ); + })} + +
CoinSizeEntryMarkPNL (ROE%)Liq. PriceLeverage
{position.coin} + + {isLong ? '+' : ''}{formatNumber(position.szi, 4)} + + ${formatNumber(position.entryPx)}${formatNumber(position.returnOnEquity)} + ${pnl.formatted} ({formatNumber(roe, 2)}%) + + {position.liquidationPx ? `$${formatNumber(position.liquidationPx)}` : '-'} + {calculateLeverage(position)}
+
+ )} +
+
+
+
+ ); +} diff --git a/src/apps/perps/components/PriceTicker.tsx b/src/apps/perps/components/PriceTicker.tsx new file mode 100644 index 00000000..7ec62d91 --- /dev/null +++ b/src/apps/perps/components/PriceTicker.tsx @@ -0,0 +1,177 @@ +import { useEffect, useState } from 'react'; +import type { AssetInfo } from '../lib/hyperliquid/types'; + +interface PriceTickerProps { + selectedAsset: AssetInfo | null; +} + +interface TickerData { + markPrice: string; + oraclePrice: string; + change24h: string; + changePercent24h: string; + volume24h: string; + openInterest: string; + fundingRate: string; + nextFundingTime: string; +} + +export function PriceTicker({ selectedAsset }: PriceTickerProps) { + const [tickerData, setTickerData] = useState(null); + + useEffect(() => { + if (!selectedAsset) return; + + let ws: WebSocket | null = null; + + const connect = () => { + ws = new WebSocket('wss://api.hyperliquid.xyz/ws'); + + ws.onopen = () => { + console.log('[Ticker] WebSocket connected'); + // Subscribe to all mids (prices) + const midsMsg = { + method: 'subscribe', + subscription: { type: 'allMids' } + }; + console.log('[Ticker] Sending allMids subscription:', JSON.stringify(midsMsg)); + ws?.send(JSON.stringify(midsMsg)); + }; + + ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + console.log('[Ticker] WebSocket message:', message); + + if (message.channel === 'allMids' && message.data) { + const price = message.data.mids?.[selectedAsset.symbol]; + if (price) { + setTickerData(prev => prev ? { + ...prev, + markPrice: price, + } : null); + } + } + } catch (e) { + console.error('[Ticker] WebSocket message error:', e); + } + }; + + ws.onerror = (err) => { + console.error('[Ticker] WebSocket error:', err); + }; + + ws.onclose = () => { + console.log('[Ticker] WebSocket disconnected'); + setTimeout(connect, 3000); + }; + }; + + connect(); + + return () => { + if (ws) { + ws.onclose = null; + ws.close(); + } + }; + }, [selectedAsset]); + + // Fetch initial market data from REST API + useEffect(() => { + if (!selectedAsset) return; + + const fetchMarketData = async () => { + try { + const response = await fetch('https://api.hyperliquid.xyz/info', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'metaAndAssetCtxs' }) + }); + const data = await response.json(); + + if (data && data.length >= 2) { + const assetCtx = data[1]?.find((ctx: any) => ctx.coin === selectedAsset.symbol); + if (assetCtx) { + const prevDayPx = parseFloat(assetCtx.prevDayPx || '0'); + const markPx = parseFloat(assetCtx.markPx || '0'); + const change24h = markPx - prevDayPx; + const changePercent24h = prevDayPx > 0 ? ((change24h / prevDayPx) * 100).toFixed(2) : '0.00'; + + setTickerData({ + markPrice: assetCtx.markPx || '0', + oraclePrice: assetCtx.oraclePx || '0', + change24h: change24h.toFixed(2), + changePercent24h, + volume24h: assetCtx.dayNtlVlm || '0', + openInterest: assetCtx.openInterest || '0', + fundingRate: assetCtx.funding || '0', + nextFundingTime: '00:00:00', // TODO: Calculate from funding time + }); + } + } + } catch (error) { + console.error('[Ticker] Failed to fetch market data:', error); + } + }; + + fetchMarketData(); + }, [selectedAsset]); + + if (!selectedAsset || !tickerData) { + return null; + } + + const isPositive = parseFloat(tickerData.changePercent24h) >= 0; + + return ( +
+ {/* Symbol with icon */} +
+
+ {selectedAsset.symbol.charAt(0)} +
+ {selectedAsset.symbol}-USDC + {selectedAsset.maxLeverage}x +
+ + {/* Mark Price */} +
+ Mark + {parseFloat(tickerData.markPrice).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
+ + {/* Oracle Price */} +
+ Oracle + {parseFloat(tickerData.oraclePrice).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
+ + {/* 24h Change */} +
+ 24H Change + + {tickerData.change24h} / {isPositive ? '+' : ''}{tickerData.changePercent24h}% + +
+ + {/* 24h Volume */} +
+ 24H Volume + ${parseFloat(tickerData.volume24h).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} +
+ + {/* Open Interest */} +
+ Open Interest + ${parseFloat(tickerData.openInterest).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
+ + {/* Funding Rate */} +
+ Funding / Countdown + {(parseFloat(tickerData.fundingRate) * 100).toFixed(4)}% {tickerData.nextFundingTime} +
+
+ ); +} diff --git a/src/apps/perps/components/StatusBanner.tsx b/src/apps/perps/components/StatusBanner.tsx new file mode 100644 index 00000000..6293e1d7 --- /dev/null +++ b/src/apps/perps/components/StatusBanner.tsx @@ -0,0 +1,68 @@ +import { Badge } from './ui/badge'; +import { AlertCircle, CheckCircle2, HelpCircle } from 'lucide-react'; +import { cn } from '../lib/utils'; + +type Status = 'unknown' | 'not-setup' | 'setup'; + +interface StatusBannerProps { + status: Status; + onSetup?: () => void; + isSettingUp?: boolean; +} + +export function StatusBanner({ status, onSetup, isSettingUp }: StatusBannerProps) { + const statusConfig = { + unknown: { + icon: HelpCircle, + label: 'Unknown', + color: 'text-muted-foreground', + bgColor: 'bg-muted', + description: 'Connect your wallet to check status', + }, + 'not-setup': { + icon: AlertCircle, + label: 'Not Set Up', + color: 'text-warning', + bgColor: 'bg-warning/10 border-warning/30', + description: 'Setup required to use Hyperliquid', + }, + setup: { + icon: CheckCircle2, + label: 'Connected', + color: 'text-success', + bgColor: 'bg-success/10 border-success/30', + description: 'Ready to trade', + }, + }; + + const config = statusConfig[status]; + const Icon = config.icon; + + return ( +
+
+
+ +
+
+ Hyperliquid Status: + + {config.label} + +
+

{config.description}

+
+
+ {status === 'not-setup' && onSetup && ( + + )} +
+
+ ); +} diff --git a/src/apps/perps/components/TradeForm.tsx b/src/apps/perps/components/TradeForm.tsx new file mode 100644 index 00000000..f241ddf3 --- /dev/null +++ b/src/apps/perps/components/TradeForm.tsx @@ -0,0 +1,435 @@ +import { useState, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { Button } from './ui/button'; +import { Card } from './ui/card'; +import { Input } from './ui/input'; +import { Label } from './ui/label'; +import { Switch } from './ui/switch'; +import { toast } from 'sonner'; +import { getAgentWallet } from '../lib/hyperliquid/keystore'; +import { getMarkPrice, getUserState } from '../lib/hyperliquid/client'; +import { useWalletClient } from 'wagmi'; +import useTransactionKit from '../../../hooks/useTransactionKit'; +import { computeSizeUSD, splitTPs, roundToSzDecimals } from '../lib/hyperliquid/order'; +import { placeMarketOrderAgent, placeLimitOrderAgent } from '../lib/hyperliquid/sdk'; +import { parsePositionForSymbol } from '../lib/hyperliquid/parsers'; +import type { AssetInfo } from '../lib/hyperliquid/types'; + +const tradeSchema = z.object({ + side: z.enum(['long', 'short']), + entryPrice: z.number().positive().optional(), + amountUSD: z.number().positive(), + leverage: z.number().min(1).max(50), + stopLoss: z.number().positive().optional(), + takeProfits: z.string().optional(), +}).refine((data) => { + // Only validate if values are provided + if (data.entryPrice && data.stopLoss) { + if (data.side === 'long') { + return data.stopLoss < data.entryPrice; + } else { + return data.stopLoss > data.entryPrice; + } + } + if (data.entryPrice && data.takeProfits) { + const tps = data.takeProfits.split(',').map(tp => parseFloat(tp.trim())).filter(n => !isNaN(n)); + if (data.side === 'long') { + return tps.every(tp => tp > data.entryPrice!); + } else { + return tps.every(tp => tp < data.entryPrice!); + } + } + return true; +}, { + message: "Stop loss and take profits must be valid for the trade direction", + path: ['stopLoss'], +}); + +type TradeFormData = z.infer; + +interface TradeFormProps { + selectedAsset: AssetInfo | null; + onTradeComplete?: () => void; + prefilledData?: { + side?: 'long' | 'short'; + entryPrice?: number; + stopLoss?: number; + takeProfits?: string; + }; +} + +export function TradeForm({ selectedAsset, onTradeComplete, prefilledData }: TradeFormProps) { + const { walletAddress: masterAddress } = useTransactionKit(); + const [isMarketOrder, setIsMarketOrder] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [marketPrice, setMarketPrice] = useState(null); + const [minUSD, setMinUSD] = useState(null); + + const { register, handleSubmit, formState: { errors }, watch, setValue } = useForm({ + resolver: zodResolver(tradeSchema), + defaultValues: { + side: 'long', + amountUSD: 25, + leverage: 5, + }, + }); + + const side = watch('side'); + const amountUSD = watch('amountUSD'); + const leverage = watch('leverage'); + + // Fetch market price for minimum calculation + useEffect(() => { + if (selectedAsset && isMarketOrder) { + getMarkPrice(selectedAsset.symbol).then(price => { + if (price) setMarketPrice(price); + }); + } + }, [selectedAsset, isMarketOrder]); + + // Calculate minimum USD required + useEffect(() => { + if (!selectedAsset) return; + + const price = marketPrice || 1; // Use 1 as fallback for estimation + const minSize = Math.pow(10, -selectedAsset.szDecimals); + const minRequired = (minSize * price) / (leverage || 1); + setMinUSD(minRequired); + }, [selectedAsset, marketPrice, leverage]); + + // Apply prefilled data when it changes + useEffect(() => { + if (prefilledData) { + if (prefilledData.side) { + setValue('side', prefilledData.side); + } + if (prefilledData.entryPrice) { + setValue('entryPrice', prefilledData.entryPrice); + setIsMarketOrder(false); + } + if (prefilledData.stopLoss) { + setValue('stopLoss', prefilledData.stopLoss); + } + if (prefilledData.takeProfits) { + setValue('takeProfits', prefilledData.takeProfits); + } + } + }, [prefilledData, setValue]); + + // Check if amount is below minimum + const isBelowMinimum = minUSD !== null && amountUSD > 0 && amountUSD < minUSD; + + // Verify position was opened after trade (check master wallet, not agent) + const verifyPositionOpened = async ( + symbol: string, + masterWalletAddress: string, + maxAttempts = 5, + delayMs = 1000 + ): Promise => { + for (let i = 0; i < maxAttempts; i++) { + await new Promise(resolve => setTimeout(resolve, delayMs)); + + const state = await getUserState(masterWalletAddress); + if (!state) continue; + + const position = parsePositionForSymbol(state, symbol); + if (position && position.size > 0) { + return true; // Position found! + } + } + return false; // Position not found after all attempts + }; + + const onSubmit = async (data: TradeFormData) => { + console.log('Form submitted with data:', data); + toast.info('Submitting trade...'); + + if (!selectedAsset) { + toast.error('Please select an asset'); + return; + } + + if (!masterAddress) { + toast.error('Please connect your wallet'); + return; + } + + const agent = await getAgentWallet(masterAddress); + console.log('Agent wallet:', agent); + + if (!agent) { + toast.error('Please create and approve an agent wallet first'); + return; + } + + if (!agent.approved) { + toast.error('Please approve the agent wallet first'); + return; + } + + setIsSubmitting(true); + try { + // Get entry price + let entryPrice = data.entryPrice; + if (isMarketOrder || !entryPrice) { + toast.info('Fetching market price...'); + entryPrice = await getMarkPrice(selectedAsset.symbol); + if (!entryPrice) { + throw new Error('Failed to fetch market price'); + } + } + + // Calculate size + const size = computeSizeUSD(data.amountUSD, data.leverage, entryPrice, selectedAsset.szDecimals); + + if (size <= 0) { + const minSize = Math.pow(10, -selectedAsset.szDecimals); + const minRequired = (minSize * entryPrice) / data.leverage; + toast.error(`Amount too small for ${selectedAsset.symbol}`, { + description: `Minimum required: $${minRequired.toFixed(2)} at ${data.leverage}x leverage`, + }); + return; + } + + // Parse take profits if provided + const tpPrices = data.takeProfits + ? data.takeProfits.split(',').map(tp => parseFloat(tp.trim())).filter(n => !isNaN(n)) + : []; + + // Place entry order via SDK + toast.info('Placing entry order...'); + + if (isMarketOrder) { + await placeMarketOrderAgent(agent.privateKey, { + coinId: selectedAsset.id, + isBuy: data.side === 'long', + size, + currentPrice: entryPrice, + }); + } else { + await placeLimitOrderAgent(agent.privateKey, { + coinId: selectedAsset.id, + isBuy: data.side === 'long', + size, + limitPrice: entryPrice, + reduceOnly: false, + }); + } + + // Place stop loss if provided + if (data.stopLoss) { + toast.info('Placing stop loss...'); + await placeLimitOrderAgent(agent.privateKey, { + coinId: selectedAsset.id, + isBuy: data.side === 'short', // Opposite side for reduce-only + size, + limitPrice: data.stopLoss, + reduceOnly: true, + }); + } + + // Place take profits if provided + if (tpPrices.length > 0) { + const tpSplits = splitTPs(size, tpPrices); + for (let i = 0; i < tpSplits.length; i++) { + const tp = tpSplits[i]; + toast.info(`Placing take profit ${i + 1}/${tpSplits.length}...`); + + const tpSize = roundToSzDecimals(tp.size, selectedAsset.szDecimals); + await placeLimitOrderAgent(agent.privateKey, { + coinId: selectedAsset.id, + isBuy: data.side === 'short', // Opposite side for reduce-only + size: tpSize, + limitPrice: tp.price, + reduceOnly: true, + }); + } + } + + toast.success('Trade placed successfully!', { + description: `${data.side.toUpperCase()} ${size} ${selectedAsset.symbol}`, + }); + + // Verify position was opened (check master wallet) + if (masterAddress) { + toast.info('Verifying position...', { id: 'verify-position' }); + + const positionOpened = await verifyPositionOpened( + selectedAsset.symbol, + masterAddress + ); + + if (positionOpened) { + toast.success('Position confirmed on exchange', { id: 'verify-position' }); + onTradeComplete?.(); + } else { + toast.warning('Position not found on exchange', { + id: 'verify-position', + description: 'The order was submitted but position is not visible yet. Check your orders manually.', + duration: 8000, + }); + onTradeComplete?.(); // Still call this to refresh UI + } + } else { + onTradeComplete?.(); // No master address, still refresh + } + } catch (error: any) { + console.error('Trade error:', error); + toast.error(error.message || 'Failed to place trade'); + } finally { + setIsSubmitting(false); + } + }; + + if (!selectedAsset) { + return ( + +
+ Select an asset to start trading +
+
+ ); + } + + return ( + +
toast.error('Please fix the form errors'))} className="space-y-4"> +
+

Trade {selectedAsset.symbol}

+ Max {selectedAsset.maxLeverage}x +
+ +
+ +
+ + +
+ +
+ +
+ + +
+ + {!isMarketOrder && ( +
+ + v === '' ? undefined : parseFloat(v) + })} + /> + {errors.entryPrice && ( +

{errors.entryPrice.message}

+ )} +
+ )} + +
+
+ + + {errors.amountUSD && ( +

{errors.amountUSD.message}

+ )} + {isBelowMinimum && minUSD && ( +

+ Minimum: ${minUSD.toFixed(2)} at {leverage}x leverage +

+ )} + {!isBelowMinimum && minUSD && ( +

+ Min: ~${minUSD.toFixed(2)} +

+ )} +
+ +
+ + + {errors.leverage && ( +

{errors.leverage.message}

+ )} +
+
+ +
+ + Entry (optional)'} + {...register('stopLoss', { + setValueAs: (v) => v === '' ? undefined : parseFloat(v) + })} + /> + {errors.stopLoss && ( +

{errors.stopLoss.message}

+ )} +
+ +
+ + + {errors.takeProfits && ( +

{errors.takeProfits.message}

+ )} +
+ + +
+
+ ); +} diff --git a/src/apps/perps/components/TradeSignals.tsx b/src/apps/perps/components/TradeSignals.tsx new file mode 100644 index 00000000..55a0cb53 --- /dev/null +++ b/src/apps/perps/components/TradeSignals.tsx @@ -0,0 +1,191 @@ +import { useState, useEffect } from 'react'; +import { Card } from './ui/card'; +import { Button } from './ui/button'; +import { ScrollArea } from './ui/scroll-area'; +import { Badge } from './ui/badge'; +import { Copy, RefreshCw, TrendingUp, TrendingDown } from 'lucide-react'; +import { toast } from 'sonner'; +import { Skeleton } from './ui/skeleton'; + +interface TradeSignal { + symbol: string; + side: 'long' | 'short'; + entry: number | [number, number]; + stopLoss: number; + takeProfits: number[]; + timestamp?: string; +} + +interface TradeSignalsProps { + onCopySignal: (signal: TradeSignal) => void; +} + +export function TradeSignals({ onCopySignal }: TradeSignalsProps) { + const [signals, setSignals] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + loadSignals(); + }, []); + + const loadSignals = async () => { + setIsLoading(true); + try { + const response = await fetch('https://wussashljunaxrfuinbn.supabase.co/functions/v1/webhook-receiver'); + + if (!response.ok) { + throw new Error('Failed to fetch signals'); + } + + const data = await response.json(); + + // Handle different response formats + if (Array.isArray(data)) { + setSignals(data); + } else if (data.signals && Array.isArray(data.signals)) { + setSignals(data.signals); + } else if (data.data && Array.isArray(data.data)) { + setSignals(data.data); + } else { + console.warn('Unexpected data format:', data); + setSignals([]); + } + } catch (error: any) { + console.error('Failed to load signals:', error); + toast.error('Failed to load trade signals'); + setSignals([]); + } finally { + setIsLoading(false); + } + }; + + const handleCopySignal = (signal: TradeSignal) => { + onCopySignal(signal); + toast.success('Signal copied to trade form!', { + description: `${signal.side.toUpperCase()} ${signal.symbol}`, + }); + }; + + const getEntryDisplay = (entry: number | [number, number]) => { + if (Array.isArray(entry)) { + return `${entry[0]} - ${entry[1]}`; + } + return entry.toFixed(2); + }; + + if (isLoading) { + return ( + +
+
+ + +
+ +
+
+ ); + } + + return ( + +
+
+

Trade Signals

+ +
+ + +
+ {signals.length === 0 ? ( +
+ No trade signals available +
+ ) : ( + signals.map((signal, index) => ( +
+
+
+
+ {signal.side === 'long' ? ( + + ) : ( + + )} +
+
+

{signal.symbol}

+ + {signal.side.toUpperCase()} + +
+
+ +
+ +
+
+ Entry: + + {getEntryDisplay(signal.entry)} + +
+
+ Stop Loss: + + {signal.stopLoss.toFixed(2)} + +
+
+ Take Profits: +
+ {signal.takeProfits.map((tp, tpIndex) => ( + + {tp.toFixed(2)} + + ))} +
+
+
+ + {signal.timestamp && ( +
+ {new Date(signal.timestamp).toLocaleString()} +
+ )} +
+ )) + )} +
+
+
+
+ ); +} diff --git a/src/apps/perps/components/TradingChart.tsx b/src/apps/perps/components/TradingChart.tsx new file mode 100644 index 00000000..4e9d363c --- /dev/null +++ b/src/apps/perps/components/TradingChart.tsx @@ -0,0 +1,288 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import { createChart, type IChartApi, type ISeriesApi, type CandlestickData, type Time } from 'lightweight-charts'; +import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card'; +import { Button } from '../components/ui/button'; +import type { AssetInfo } from '../lib/hyperliquid/types'; +import { PriceTicker } from './PriceTicker'; + +interface TradingChartProps { + selectedAsset: AssetInfo | null; +} + +type Interval = '1m' | '5m' | '15m' | '1h' | '4h' | '1d'; + +interface CandleResponse { + t: number; // timestamp + o: string; // open (API returns as string) + h: string; // high (API returns as string) + l: string; // low (API returns as string) + c: string; // close (API returns as string) + v: string; // volume (API returns as string) +} + +export function TradingChart({ selectedAsset }: TradingChartProps) { + const chartContainerRef = useRef(null); + const chartRef = useRef(null); + const candlestickSeriesRef = useRef | null>(null); + const [interval, setInterval] = useState('1h'); + const [isLoading, setIsLoading] = useState(false); + + const fetchCandles = useCallback(async (symbol: string, intervalStr: Interval) => { + try { + setIsLoading(true); + const now = Date.now(); + const intervalMs: Record = { + '1m': 60 * 1000, + '5m': 5 * 60 * 1000, + '15m': 15 * 60 * 1000, + '1h': 60 * 60 * 1000, + '4h': 4 * 60 * 60 * 1000, + '1d': 24 * 60 * 60 * 1000, + }; + + const startTime = now - (300 * intervalMs[intervalStr]); // Last 300 candles in milliseconds + + const response = await fetch('https://api.hyperliquid.xyz/info', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + type: 'candleSnapshot', + req: { + coin: symbol, + interval: intervalStr, + startTime, + endTime: now, + }, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('[Chart] API error:', { status: response.status, error: errorText, symbol, intervalStr }); + throw new Error(`Candles API error: ${response.status} ${errorText}`); + } + + const raw: CandleResponse[] = await response.json(); + + if (!Array.isArray(raw)) { + console.error('[Chart] Invalid response format:', raw); + return []; + } + + const candlestickData: CandlestickData