'use client'; import { useEffect, useState, useCallback, useRef, useMemo } from 'react'; import { useSignalRContext } from '@/contexts/signalrProvider'; import { fetchApi } from '@/lib/utils/client'; import type { CandleData, CandleRestData, CandleBar, VolumeBar, MAData, TickerData } from '@/types/crypto'; type IntervalType = 'sec' | '1' | '3' | '5' | '10' | '15' | '30' | '60' | '240' | 'day' | 'week' | 'month'; const UP_COLOR = 'rgba(239, 83, 80, 0.5)'; const DOWN_COLOR = 'rgba(66, 133, 244, 0.5)'; function toUnixTime(dateStr: string): number { return Math.floor(new Date(dateStr).getTime() / 1000); } function getEndpointForInterval(market: string, interval: IntervalType, count: number): string { switch (interval) { case 'sec': return `/api/crypto/${market}/candles/seconds?count=${count}`; case 'day': return `/api/crypto/${market}/candles/days?count=${count}`; case 'week': return `/api/crypto/${market}/candles/weeks?count=${count}`; case 'month': return `/api/crypto/${market}/candles/months?count=${count}`; default: return `/api/crypto/${market}/candles/minutes/${interval}?count=${count}`; } } export default function useCandles(market: string, interval: IntervalType = '1') { const { cryptoConnection, cryptoConnected } = useSignalRContext(); const [candles, setCandles] = useState([]); const [volumes, setVolumes] = useState([]); const [loading, setLoading] = useState(true); const candlesRef = useRef([]); const volumesRef = useRef([]); const tickerUpdateTimerRef = useRef | null>(null); // REST 초기 로드 useEffect(() => { if (!market) { return; } setLoading(true); candlesRef.current = []; volumesRef.current = []; const load = async () => { try { const endpoint = getEndpointForInterval(market, interval, 200); const res = await fetchApi(endpoint); if (res.success && res.data) { const sorted = [...res.data].sort((a, b) => toUnixTime(a.candleDateTimeUtc) - toUnixTime(b.candleDateTimeUtc) ); const bars: CandleBar[] = sorted.map((c) => ({ time: toUnixTime(c.candleDateTimeUtc), open: c.openingPrice, high: c.highPrice, low: c.lowPrice, close: c.tradePrice, })); const vols: VolumeBar[] = sorted.map((c) => ({ time: toUnixTime(c.candleDateTimeUtc), value: c.candleAccTradeVolume, color: c.tradePrice >= c.openingPrice ? UP_COLOR : DOWN_COLOR, })); candlesRef.current = bars; volumesRef.current = vols; setCandles(bars); setVolumes(vols); } } catch (error) { console.error('Failed to load candles:', error); } finally { setLoading(false); } }; load(); }, [market, interval]); // SignalR 실시간 캔들 업데이트 (1분봉만) const handleCandle = useCallback((data: CandleData) => { if (data.market !== market || interval !== '1') { return; } const time = toUnixTime(data.candleDateTimeUtc); const bar: CandleBar = { time, open: data.openingPrice, high: data.highPrice, low: data.lowPrice, close: data.tradePrice, }; const vol: VolumeBar = { time, value: data.candleAccTradeVolume, color: data.tradePrice >= data.openingPrice ? UP_COLOR : DOWN_COLOR, }; const bars = [...candlesRef.current]; const vols = [...volumesRef.current]; const lastIdx = bars.length - 1; if (lastIdx >= 0 && bars[lastIdx].time === time) { bars[lastIdx] = bar; vols[lastIdx] = vol; } else { bars.push(bar); vols.push(vol); } candlesRef.current = bars; volumesRef.current = vols; setCandles(bars); setVolumes(vols); }, [market, interval]); // SignalR 실시간 티커 → 마지막 캔들 close 업데이트 (모든 인터벌) const scheduleTickerUpdate = useCallback(() => { if (tickerUpdateTimerRef.current) { return; } tickerUpdateTimerRef.current = setTimeout(() => { setCandles([...candlesRef.current]); setVolumes([...volumesRef.current]); tickerUpdateTimerRef.current = null; }, 250); }, []); const handleTicker = useCallback((ticker: TickerData) => { if (ticker.market !== market || candlesRef.current.length === 0) { return; } const bars = candlesRef.current; const vols = volumesRef.current; const lastIdx = bars.length - 1; const last = bars[lastIdx]; if (interval === 'sec') { // 초봉: 새로운 초가 되면 새 캔들 바 생성 const nowSec = Math.floor(ticker.timestamp / 1000); if (nowSec !== last.time) { bars.push({ time: nowSec, open: ticker.tradePrice, high: ticker.tradePrice, low: ticker.tradePrice, close: ticker.tradePrice, }); vols.push({ time: nowSec, value: 0, color: UP_COLOR, }); } else { bars[lastIdx] = { ...last, close: ticker.tradePrice, high: Math.max(last.high, ticker.tradePrice), low: Math.min(last.low, ticker.tradePrice), }; vols[lastIdx] = { ...vols[lastIdx], color: ticker.tradePrice >= last.open ? UP_COLOR : DOWN_COLOR, }; } } else { // 분봉 이상: 마지막 캔들 close/high/low 업데이트 bars[lastIdx] = { ...last, close: ticker.tradePrice, high: Math.max(last.high, ticker.tradePrice), low: Math.min(last.low, ticker.tradePrice), }; vols[lastIdx] = { ...vols[lastIdx], color: ticker.tradePrice >= last.open ? UP_COLOR : DOWN_COLOR, }; } scheduleTickerUpdate(); }, [market, interval, scheduleTickerUpdate]); useEffect(() => { if (!cryptoConnection || !cryptoConnected) { return; } cryptoConnection.on('ReceiveCandle', handleCandle); cryptoConnection.on('ReceiveTicker', handleTicker); return () => { cryptoConnection.off('ReceiveCandle', handleCandle); cryptoConnection.off('ReceiveTicker', handleTicker); if (tickerUpdateTimerRef.current) { clearTimeout(tickerUpdateTimerRef.current); tickerUpdateTimerRef.current = null; } }; }, [cryptoConnection, cryptoConnected, handleCandle, handleTicker]); // MA 계산 const ma5 = useMemo(() => calcMA(candles, 5), [candles]); const ma10 = useMemo(() => calcMA(candles, 10), [candles]); const ma20 = useMemo(() => calcMA(candles, 20), [candles]); return { candles, volumes, ma5, ma10, ma20, loading }; } function calcMA(data: CandleBar[], period: number): MAData[] { if (data.length < period) return []; const result: MAData[] = []; let sum = 0; for (let i = 0; i < data.length; i++) { sum += data[i].close; if (i >= period) { sum -= data[i - period].close; } if (i >= period - 1) { result.push({ time: data[i].time, value: sum / period }); } } return result; }