| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235 |
- '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<CandleBar[]>([]);
- const [volumes, setVolumes] = useState<VolumeBar[]>([]);
- const [loading, setLoading] = useState(true);
- const candlesRef = useRef<CandleBar[]>([]);
- const volumesRef = useRef<VolumeBar[]>([]);
- const tickerUpdateTimerRef = useRef<ReturnType<typeof setTimeout> | 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<CandleRestData[]>(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;
- }
|