Orderbook.tsx 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. 'use client';
  2. import { useMemo, useEffect, useRef, useState } from 'react';
  3. import { useCryptoContext } from '@/contexts/cryptoProvider';
  4. import useOrderbook from '@/hooks/useOrderbook';
  5. import useTrades from '@/hooks/useTrades';
  6. import './orderbook.scss';
  7. interface OrderbookProps {
  8. playOrderbookSound: (type: 'add' | 'remove') => void;
  9. }
  10. function formatPrice(price: number): string {
  11. if (price >= 1000) {
  12. return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
  13. }
  14. if (price >= 1) {
  15. return price.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
  16. }
  17. return price.toLocaleString('ko-KR', { maximumFractionDigits: 4 });
  18. }
  19. function formatSize(size: number): string {
  20. if (size >= 1) {
  21. return size.toFixed(4);
  22. }
  23. return size.toFixed(6);
  24. }
  25. export default function Orderbook({ playOrderbookSound }: OrderbookProps) {
  26. const { selectedMarket, tickers } = useCryptoContext();
  27. const orderbook = useOrderbook(selectedMarket);
  28. const trades = useTrades(selectedMarket);
  29. // 최근 체결 가격 추적 (플래시 효과용)
  30. const [flashPrice, setFlashPrice] = useState<{ price: number; side: string } | null>(null);
  31. const prevTradeRef = useRef<number>(0);
  32. // 호가 가격 목록 추적 (추가/제거 감지용)
  33. const prevPricesRef = useRef<Set<number>>(new Set());
  34. // LTP 5m ago 가이드선
  35. const [ltp5mAgo, setLtp5mAgo] = useState<number | null>(null);
  36. const priceHistoryRef = useRef<{ price: number; time: number }[]>([]);
  37. // 현재 체결가 기록 + 5분 전 가격 계산
  38. const ticker = tickers.get(selectedMarket);
  39. useEffect(() => {
  40. if (!ticker) return;
  41. const now = Date.now();
  42. priceHistoryRef.current.push({ price: ticker.tradePrice, time: now });
  43. // 5분 이상 된 데이터 정리 (6분까지 보관)
  44. const cutoff = now - 6 * 60 * 1000;
  45. priceHistoryRef.current = priceHistoryRef.current.filter((p) => p.time > cutoff);
  46. // 5분 전 가격 찾기
  47. const fiveMinAgo = now - 5 * 60 * 1000;
  48. const closest = priceHistoryRef.current.reduce<{ price: number; time: number } | null>((best, p) => {
  49. if (p.time <= fiveMinAgo) {
  50. if (!best || Math.abs(p.time - fiveMinAgo) < Math.abs(best.time - fiveMinAgo)) {
  51. return p;
  52. }
  53. }
  54. return best;
  55. }, null);
  56. setLtp5mAgo(closest?.price ?? null);
  57. }, [ticker, selectedMarket]);
  58. // 마켓 변경 시 가격 기록 초기화
  59. useEffect(() => {
  60. priceHistoryRef.current = [];
  61. setLtp5mAgo(null);
  62. }, [selectedMarket]);
  63. // 체결 플래시 효과
  64. useEffect(() => {
  65. if (trades.length === 0) return;
  66. const latest = trades[0];
  67. if (latest.sequentialId !== prevTradeRef.current) {
  68. prevTradeRef.current = latest.sequentialId;
  69. setFlashPrice({ price: latest.tradePrice, side: latest.askBid });
  70. const timer = setTimeout(() => setFlashPrice(null), 500);
  71. return () => clearTimeout(timer);
  72. }
  73. }, [trades]);
  74. const { asks, bids, maxSize, spread, spreadPct } = useMemo(() => {
  75. if (!orderbook || !orderbook.units.length) {
  76. return { asks: [], bids: [], maxSize: 0, spread: 0, spreadPct: '' };
  77. }
  78. // asks: 오름차순 정렬 (column-reverse로 표시되므로 낮은 가격이 아래/스프레드 근처)
  79. const askEntries = orderbook.units
  80. .filter((u) => u.askPrice > 0)
  81. .map((u) => ({ price: u.askPrice, size: u.askSize }))
  82. .sort((a, b) => a.price - b.price)
  83. .slice(0, 15);
  84. const bidEntries = orderbook.units
  85. .filter((u) => u.bidPrice > 0)
  86. .map((u) => ({ price: u.bidPrice, size: u.bidSize }))
  87. .sort((a, b) => b.price - a.price)
  88. .slice(0, 15);
  89. const allSizes = [...askEntries.map((e) => e.size), ...bidEntries.map((e) => e.size)];
  90. const max = Math.max(...allSizes, 0.0001);
  91. const sp = askEntries.length > 0 && bidEntries.length > 0
  92. ? askEntries[0].price - bidEntries[0].price
  93. : 0;
  94. const midPrice = bidEntries.length > 0 ? bidEntries[0].price : 1;
  95. const pct = midPrice > 0 ? ((sp / midPrice) * 100).toFixed(2) : '0.00';
  96. return {
  97. asks: askEntries, // reverse 제거 — CSS column-reverse로 처리
  98. bids: bidEntries,
  99. maxSize: max,
  100. spread: sp,
  101. spreadPct: pct,
  102. };
  103. }, [orderbook]);
  104. // 호가 사운드 — 매 업데이트마다 재생 (bitFlyer 스타일 연속 딸깍)
  105. useEffect(() => {
  106. if (asks.length === 0 && bids.length === 0) return;
  107. const currentPrices = new Set<number>();
  108. asks.forEach((e) => currentPrices.add(e.price));
  109. bids.forEach((e) => currentPrices.add(e.price));
  110. const prev = prevPricesRef.current;
  111. if (prev.size > 0) {
  112. let hasRemove = false;
  113. prev.forEach((p) => {
  114. if (!currentPrices.has(p)) hasRemove = true;
  115. });
  116. if (hasRemove) {
  117. playOrderbookSound('remove');
  118. } else {
  119. playOrderbookSound('add');
  120. }
  121. }
  122. prevPricesRef.current = currentPrices;
  123. }, [asks, bids, playOrderbookSound]);
  124. // LTP 가이드선이 asks/bids 범위에 있는지 확인
  125. const ltpInAsks = ltp5mAgo !== null && asks.length > 0 && ltp5mAgo >= asks[0]?.price;
  126. const ltpInBids = ltp5mAgo !== null && bids.length > 0 && ltp5mAgo <= bids[0]?.price;
  127. if (!orderbook) {
  128. return (
  129. <div className='orderbook'>
  130. <div className='orderbook-title'>
  131. <span>호가</span>
  132. </div>
  133. <div className='orderbook-loading'>준비 중...</div>
  134. </div>
  135. );
  136. }
  137. return (
  138. <div className='orderbook'>
  139. <div className='orderbook-title'>
  140. <span>호가</span>
  141. <span className='orderbook-total'>
  142. <span className='total-ask'>매도 {formatSize(orderbook.totalAskSize)}</span>
  143. <span className='total-bid'>매수 {formatSize(orderbook.totalBidSize)}</span>
  144. </span>
  145. </div>
  146. <div className='orderbook-body'>
  147. {/* 매도 호가 — column-reverse로 낮은 가격이 스프레드 근처 */}
  148. <div className='ask-section'>
  149. {asks.map((entry, i) => {
  150. const isFlash = flashPrice && flashPrice.side === 'ASK' && flashPrice.price === entry.price;
  151. const isLtp = ltpInAsks && ltp5mAgo === entry.price;
  152. return (
  153. <div key={`ask-${i}`} className={`orderbook-row ask ${isFlash ? 'flash-sell' : ''}`}>
  154. <div className='bar' style={{ width: `${(entry.size / maxSize) * 100}%` }} />
  155. <span className='size'>{formatSize(entry.size)}</span>
  156. <span className='price'>{formatPrice(entry.price)}</span>
  157. {isLtp && <div className='ltp-guide'>LTP 5m ago</div>}
  158. </div>
  159. );
  160. })}
  161. </div>
  162. {/* 스프레드 — 항상 중앙 고정 */}
  163. <div className='spread-row'>
  164. <span className='spread-price'>{ticker ? formatPrice(ticker.tradePrice) : ''}</span>
  165. <span className='spread-info'>스프레드 {formatPrice(spread)} ({spreadPct}%)</span>
  166. </div>
  167. {/* 매수 호가 */}
  168. <div className='bid-section'>
  169. {bids.map((entry, i) => {
  170. const isFlash = flashPrice && flashPrice.side === 'BID' && flashPrice.price === entry.price;
  171. const isLtp = ltpInBids && ltp5mAgo === entry.price;
  172. return (
  173. <div key={`bid-${i}`} className={`orderbook-row bid ${isFlash ? 'flash-buy' : ''}`}>
  174. <div className='bar' style={{ width: `${(entry.size / maxSize) * 100}%` }} />
  175. <span className='price'>{formatPrice(entry.price)}</span>
  176. <span className='size'>{formatSize(entry.size)}</span>
  177. {isLtp && <div className='ltp-guide'>LTP 5m ago</div>}
  178. </div>
  179. );
  180. })}
  181. </div>
  182. </div>
  183. </div>
  184. );
  185. }