useCandles.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. 'use client';
  2. import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
  3. import { useSignalRContext } from '@/contexts/signalrProvider';
  4. import { fetchApi } from '@/lib/utils/client';
  5. import type { CandleData, CandleRestData, CandleBar, VolumeBar, MAData, TickerData } from '@/types/crypto';
  6. type IntervalType = 'sec' | '1' | '3' | '5' | '10' | '15' | '30' | '60' | '240' | 'day' | 'week' | 'month';
  7. const UP_COLOR = 'rgba(239, 83, 80, 0.5)';
  8. const DOWN_COLOR = 'rgba(66, 133, 244, 0.5)';
  9. function toUnixTime(dateStr: string): number {
  10. return Math.floor(new Date(dateStr).getTime() / 1000);
  11. }
  12. function getEndpointForInterval(market: string, interval: IntervalType, count: number): string {
  13. switch (interval) {
  14. case 'sec':
  15. return `/api/crypto/${market}/candles/seconds?count=${count}`;
  16. case 'day':
  17. return `/api/crypto/${market}/candles/days?count=${count}`;
  18. case 'week':
  19. return `/api/crypto/${market}/candles/weeks?count=${count}`;
  20. case 'month':
  21. return `/api/crypto/${market}/candles/months?count=${count}`;
  22. default:
  23. return `/api/crypto/${market}/candles/minutes/${interval}?count=${count}`;
  24. }
  25. }
  26. export default function useCandles(market: string, interval: IntervalType = '1') {
  27. const { cryptoConnection, cryptoConnected } = useSignalRContext();
  28. const [candles, setCandles] = useState<CandleBar[]>([]);
  29. const [volumes, setVolumes] = useState<VolumeBar[]>([]);
  30. const [loading, setLoading] = useState(true);
  31. const candlesRef = useRef<CandleBar[]>([]);
  32. const volumesRef = useRef<VolumeBar[]>([]);
  33. const tickerUpdateTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  34. // REST 초기 로드
  35. useEffect(() => {
  36. if (!market) {
  37. return;
  38. }
  39. setLoading(true);
  40. candlesRef.current = [];
  41. volumesRef.current = [];
  42. const load = async () => {
  43. try {
  44. const endpoint = getEndpointForInterval(market, interval, 200);
  45. const res = await fetchApi<CandleRestData[]>(endpoint);
  46. if (res.success && res.data) {
  47. const sorted = [...res.data].sort((a, b) =>
  48. toUnixTime(a.candleDateTimeUtc) - toUnixTime(b.candleDateTimeUtc)
  49. );
  50. const bars: CandleBar[] = sorted.map((c) => ({
  51. time: toUnixTime(c.candleDateTimeUtc),
  52. open: c.openingPrice,
  53. high: c.highPrice,
  54. low: c.lowPrice,
  55. close: c.tradePrice,
  56. }));
  57. const vols: VolumeBar[] = sorted.map((c) => ({
  58. time: toUnixTime(c.candleDateTimeUtc),
  59. value: c.candleAccTradeVolume,
  60. color: c.tradePrice >= c.openingPrice ? UP_COLOR : DOWN_COLOR,
  61. }));
  62. candlesRef.current = bars;
  63. volumesRef.current = vols;
  64. setCandles(bars);
  65. setVolumes(vols);
  66. }
  67. } catch (error) {
  68. console.error('Failed to load candles:', error);
  69. } finally {
  70. setLoading(false);
  71. }
  72. };
  73. load();
  74. }, [market, interval]);
  75. // SignalR 실시간 캔들 업데이트 (1분봉만)
  76. const handleCandle = useCallback((data: CandleData) => {
  77. if (data.market !== market || interval !== '1') {
  78. return;
  79. }
  80. const time = toUnixTime(data.candleDateTimeUtc);
  81. const bar: CandleBar = {
  82. time,
  83. open: data.openingPrice,
  84. high: data.highPrice,
  85. low: data.lowPrice,
  86. close: data.tradePrice,
  87. };
  88. const vol: VolumeBar = {
  89. time,
  90. value: data.candleAccTradeVolume,
  91. color: data.tradePrice >= data.openingPrice ? UP_COLOR : DOWN_COLOR,
  92. };
  93. const bars = [...candlesRef.current];
  94. const vols = [...volumesRef.current];
  95. const lastIdx = bars.length - 1;
  96. if (lastIdx >= 0 && bars[lastIdx].time === time) {
  97. bars[lastIdx] = bar;
  98. vols[lastIdx] = vol;
  99. } else {
  100. bars.push(bar);
  101. vols.push(vol);
  102. }
  103. candlesRef.current = bars;
  104. volumesRef.current = vols;
  105. setCandles(bars);
  106. setVolumes(vols);
  107. }, [market, interval]);
  108. // SignalR 실시간 티커 → 마지막 캔들 close 업데이트 (모든 인터벌)
  109. const scheduleTickerUpdate = useCallback(() => {
  110. if (tickerUpdateTimerRef.current) {
  111. return;
  112. }
  113. tickerUpdateTimerRef.current = setTimeout(() => {
  114. setCandles([...candlesRef.current]);
  115. setVolumes([...volumesRef.current]);
  116. tickerUpdateTimerRef.current = null;
  117. }, 250);
  118. }, []);
  119. const handleTicker = useCallback((ticker: TickerData) => {
  120. if (ticker.market !== market || candlesRef.current.length === 0) {
  121. return;
  122. }
  123. const bars = candlesRef.current;
  124. const vols = volumesRef.current;
  125. const lastIdx = bars.length - 1;
  126. const last = bars[lastIdx];
  127. if (interval === 'sec') {
  128. // 초봉: 새로운 초가 되면 새 캔들 바 생성
  129. const nowSec = Math.floor(ticker.timestamp / 1000);
  130. if (nowSec !== last.time) {
  131. bars.push({
  132. time: nowSec,
  133. open: ticker.tradePrice,
  134. high: ticker.tradePrice,
  135. low: ticker.tradePrice,
  136. close: ticker.tradePrice,
  137. });
  138. vols.push({
  139. time: nowSec,
  140. value: 0,
  141. color: UP_COLOR,
  142. });
  143. } else {
  144. bars[lastIdx] = {
  145. ...last,
  146. close: ticker.tradePrice,
  147. high: Math.max(last.high, ticker.tradePrice),
  148. low: Math.min(last.low, ticker.tradePrice),
  149. };
  150. vols[lastIdx] = {
  151. ...vols[lastIdx],
  152. color: ticker.tradePrice >= last.open ? UP_COLOR : DOWN_COLOR,
  153. };
  154. }
  155. } else {
  156. // 분봉 이상: 마지막 캔들 close/high/low 업데이트
  157. bars[lastIdx] = {
  158. ...last,
  159. close: ticker.tradePrice,
  160. high: Math.max(last.high, ticker.tradePrice),
  161. low: Math.min(last.low, ticker.tradePrice),
  162. };
  163. vols[lastIdx] = {
  164. ...vols[lastIdx],
  165. color: ticker.tradePrice >= last.open ? UP_COLOR : DOWN_COLOR,
  166. };
  167. }
  168. scheduleTickerUpdate();
  169. }, [market, interval, scheduleTickerUpdate]);
  170. useEffect(() => {
  171. if (!cryptoConnection || !cryptoConnected) {
  172. return;
  173. }
  174. cryptoConnection.on('ReceiveCandle', handleCandle);
  175. cryptoConnection.on('ReceiveTicker', handleTicker);
  176. return () => {
  177. cryptoConnection.off('ReceiveCandle', handleCandle);
  178. cryptoConnection.off('ReceiveTicker', handleTicker);
  179. if (tickerUpdateTimerRef.current) {
  180. clearTimeout(tickerUpdateTimerRef.current);
  181. tickerUpdateTimerRef.current = null;
  182. }
  183. };
  184. }, [cryptoConnection, cryptoConnected, handleCandle, handleTicker]);
  185. // MA 계산
  186. const ma5 = useMemo(() => calcMA(candles, 5), [candles]);
  187. const ma10 = useMemo(() => calcMA(candles, 10), [candles]);
  188. const ma20 = useMemo(() => calcMA(candles, 20), [candles]);
  189. return { candles, volumes, ma5, ma10, ma20, loading };
  190. }
  191. function calcMA(data: CandleBar[], period: number): MAData[] {
  192. if (data.length < period) return [];
  193. const result: MAData[] = [];
  194. let sum = 0;
  195. for (let i = 0; i < data.length; i++) {
  196. sum += data[i].close;
  197. if (i >= period) {
  198. sum -= data[i - period].close;
  199. }
  200. if (i >= period - 1) {
  201. result.push({ time: data[i].time, value: sum / period });
  202. }
  203. }
  204. return result;
  205. }