/** * @file chart-utils.ts * @description StockLineChart에서 사용하는 유틸리티 함수 모음 */ import type { UTCTimestamp } from "lightweight-charts"; import type { DashboardChartTimeframe, StockCandlePoint, } from "@/features/dashboard/types/dashboard.types"; const KRW_FORMATTER = new Intl.NumberFormat("ko-KR"); // ─── 타입 ────────────────────────────────────────────────── export type ChartBar = { time: UTCTimestamp; open: number; high: number; low: number; close: number; volume: number; }; // ─── StockCandlePoint → ChartBar 변환 ───────────────────── /** * candles 배열을 ChartBar 배열로 정규화 (무효값 필터 + 병합 + 정렬) */ export function normalizeCandles( candles: StockCandlePoint[], timeframe: DashboardChartTimeframe, ): ChartBar[] { const rows = candles .map((c) => convertCandleToBar(c, timeframe)) .filter((b): b is ChartBar => Boolean(b)); return mergeBars([], rows); } /** * 단일 candle → ChartBar 변환. 유효하지 않으면 null */ export function convertCandleToBar( candle: StockCandlePoint, timeframe: DashboardChartTimeframe, ): ChartBar | null { const close = candle.close ?? candle.price; if (!Number.isFinite(close) || close <= 0) return null; const open = candle.open ?? close; const high = candle.high ?? Math.max(open, close); const low = candle.low ?? Math.min(open, close); const volume = candle.volume ?? 0; const time = resolveBarTimestamp(candle, timeframe); if (!time) return null; return { time, open, high: Math.max(high, open, close), low: Math.min(low, open, close), close, volume, }; } // ─── 타임스탬프 해석/정렬 ───────────────────────────────── function resolveBarTimestamp( candle: StockCandlePoint, timeframe: DashboardChartTimeframe, ): UTCTimestamp | null { // timestamp 필드가 있으면 우선 사용 if ( typeof candle.timestamp === "number" && Number.isFinite(candle.timestamp) ) { return alignTimestamp(candle.timestamp, timeframe); } const text = typeof candle.time === "string" ? candle.time.trim() : ""; if (!text) return null; // "MM/DD" 형식 (일봉) if (/^\d{2}\/\d{2}$/.test(text)) { const [mm, dd] = text.split("/"); const year = new Date().getFullYear(); const ts = Math.floor( new Date(`${year}-${mm}-${dd}T09:00:00+09:00`).getTime() / 1000, ); return alignTimestamp(ts, timeframe); } // "HH:MM" 또는 "HH:MM:SS" 형식 (분봉) if (/^\d{2}:\d{2}(:\d{2})?$/.test(text)) { const [hh, mi, ss] = text.split(":"); const now = new Date(); const y = now.getFullYear(); const m = `${now.getMonth() + 1}`.padStart(2, "0"); const d = `${now.getDate()}`.padStart(2, "0"); const ts = Math.floor( new Date(`${y}-${m}-${d}T${hh}:${mi}:${ss ?? "00"}+09:00`).getTime() / 1000, ); return alignTimestamp(ts, timeframe); } return null; } /** * 타임스탬프를 타임프레임 버킷 경계에 정렬 * - 1m: 그대로 * - 30m/1h: 분 단위를 버킷에 정렬 * - 1d: 00:00:00 * - 1w: 월요일 00:00:00 */ function alignTimestamp( timestamp: number, timeframe: DashboardChartTimeframe, ): UTCTimestamp { const d = new Date(timestamp * 1000); if (timeframe === "30m" || timeframe === "1h") { const bucket = timeframe === "30m" ? 30 : 60; d.setUTCMinutes(Math.floor(d.getUTCMinutes() / bucket) * bucket, 0, 0); } else if (timeframe === "1d") { d.setUTCHours(0, 0, 0, 0); } else if (timeframe === "1w") { const day = d.getUTCDay(); d.setUTCDate(d.getUTCDate() + (day === 0 ? -6 : 1 - day)); d.setUTCHours(0, 0, 0, 0); } return Math.floor(d.getTime() / 1000) as UTCTimestamp; } // ─── 봉 병합 ────────────────────────────────────────────── /** * 두 ChartBar 배열을 시간 기준으로 병합. 같은 시간대는 OHLCV 통합 */ export function mergeBars(left: ChartBar[], right: ChartBar[]): ChartBar[] { const map = new Map(); for (const bar of [...left, ...right]) { const prev = map.get(bar.time); if (!prev) { map.set(bar.time, bar); continue; } map.set(bar.time, { time: bar.time, open: prev.open, high: Math.max(prev.high, bar.high), low: Math.min(prev.low, bar.low), close: bar.close, volume: Math.max(prev.volume, bar.volume), }); } return [...map.values()].sort((a, b) => a.time - b.time); } /** * 실시간 봉 업데이트: 같은 시간이면 기존 봉에 병합, 새 시간이면 추가 */ export function upsertRealtimeBar( prev: ChartBar[], incoming: ChartBar, ): ChartBar[] { if (prev.length === 0) return [incoming]; const last = prev[prev.length - 1]; if (incoming.time > last.time) return [...prev, incoming]; if (incoming.time < last.time) return prev; return [ ...prev.slice(0, -1), { time: last.time, open: last.open, high: Math.max(last.high, incoming.high), low: Math.min(last.low, incoming.low), close: incoming.close, volume: Math.max(last.volume, incoming.volume), }, ]; } // ─── 포맷터 ─────────────────────────────────────────────── export function formatPrice(value: number) { return KRW_FORMATTER.format(Math.round(value)); } export function formatSignedPercent(value: number) { const sign = value > 0 ? "+" : ""; return `${sign}${value.toFixed(2)}%`; } /** * 분봉 타임프레임인지 판별 */ export function isMinuteTimeframe(tf: DashboardChartTimeframe) { return tf === "1m" || tf === "30m" || tf === "1h"; }