/** * @file chart-utils.ts * @description StockLineChart에서 사용하는 유틸리티 함수 모음 */ import type { TickMarkType, Time, UTCTimestamp, } from "lightweight-charts"; import type { DashboardChartTimeframe, DashboardRealtimeTradeTick, StockCandlePoint, } from "@/features/trade/types/trade.types"; const KRW_FORMATTER = new Intl.NumberFormat("ko-KR"); const KST_TIME_ZONE = "Asia/Seoul"; const KST_TIME_FORMATTER = new Intl.DateTimeFormat("ko-KR", { timeZone: KST_TIME_ZONE, hour: "2-digit", minute: "2-digit", hour12: false, }); const KST_TIME_SECONDS_FORMATTER = new Intl.DateTimeFormat("ko-KR", { timeZone: KST_TIME_ZONE, hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, }); const KST_DATE_FORMATTER = new Intl.DateTimeFormat("ko-KR", { timeZone: KST_TIME_ZONE, month: "2-digit", day: "2-digit", }); const KST_MONTH_FORMATTER = new Intl.DateTimeFormat("ko-KR", { timeZone: KST_TIME_ZONE, month: "short", }); const KST_YEAR_FORMATTER = new Intl.DateTimeFormat("ko-KR", { timeZone: KST_TIME_ZONE, year: "numeric", }); const KST_CROSSHAIR_FORMATTER = new Intl.DateTimeFormat("ko-KR", { timeZone: KST_TIME_ZONE, month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, }); // ─── 타입 ────────────────────────────────────────────────── 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), }, ]; } /** * @description 실시간 체결 틱을 차트용 ChartBar로 변환합니다. (KST 날짜 + tickTime 기준) * @see features/trade/hooks/useKisTradeWebSocket.ts latestTick * @see features/trade/components/chart/StockLineChart.tsx 실시간 캔들 반영 */ export function toRealtimeTickBar( tick: DashboardRealtimeTradeTick, timeframe: DashboardChartTimeframe, now = new Date(), ): ChartBar | null { if (!Number.isFinite(tick.price) || tick.price <= 0) return null; const hhmmss = normalizeTickTime(tick.tickTime); if (!hhmmss) return null; const ymd = getKstYmd(now); const baseTimestamp = toKstTimestamp(ymd, hhmmss); const alignedTimestamp = alignTimestamp(baseTimestamp, timeframe); const minuteFrame = isMinuteTimeframe(timeframe); return { time: alignedTimestamp, open: minuteFrame ? tick.price : Math.max(tick.open, tick.price), high: minuteFrame ? tick.price : Math.max(tick.high, tick.price), low: minuteFrame ? tick.price : Math.min(tick.low || tick.price, tick.price), close: tick.price, volume: minuteFrame ? Math.max(tick.tradeVolume, 0) : Math.max(tick.accumulatedVolume, 0), }; } /** * @description lightweight-charts X축 라벨을 KST 기준으로 강제 포맷합니다. * @see features/trade/components/chart/StockLineChart.tsx createChart options.timeScale.tickMarkFormatter */ export function formatKstTickMark(time: Time, tickMarkType: TickMarkType) { const date = toDateFromChartTime(time); if (!date) return null; if (tickMarkType === 0) return KST_YEAR_FORMATTER.format(date); if (tickMarkType === 1) return KST_MONTH_FORMATTER.format(date); if (tickMarkType === 2) return KST_DATE_FORMATTER.format(date); if (tickMarkType === 4) return KST_TIME_SECONDS_FORMATTER.format(date); return KST_TIME_FORMATTER.format(date); } /** * @description crosshair 시간 라벨을 KST로 포맷합니다. * @see features/trade/components/chart/StockLineChart.tsx createChart options.localization.timeFormatter */ export function formatKstCrosshairTime(time: Time) { const date = toDateFromChartTime(time); if (!date) return ""; return KST_CROSSHAIR_FORMATTER.format(date); } // ─── 포맷터 ─────────────────────────────────────────────── 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"; } function normalizeTickTime(value?: string) { if (!value) return null; const normalized = value.trim(); return /^\d{6}$/.test(normalized) ? normalized : null; } function getKstYmd(now = new Date()) { const parts = new Intl.DateTimeFormat("en-CA", { timeZone: KST_TIME_ZONE, year: "numeric", month: "2-digit", day: "2-digit", }).formatToParts(now); const map = new Map(parts.map((part) => [part.type, part.value])); return `${map.get("year")}${map.get("month")}${map.get("day")}`; } function toKstTimestamp(yyyymmdd: string, hhmmss: string) { const y = Number(yyyymmdd.slice(0, 4)); const m = Number(yyyymmdd.slice(4, 6)); const d = Number(yyyymmdd.slice(6, 8)); const hh = Number(hhmmss.slice(0, 2)); const mm = Number(hhmmss.slice(2, 4)); const ss = Number(hhmmss.slice(4, 6)); return Math.floor(Date.UTC(y, m - 1, d, hh - 9, mm, ss) / 1000); } function toDateFromChartTime(time: Time) { if (typeof time === "number" && Number.isFinite(time)) { return new Date(time * 1000); } if (typeof time === "string") { const parsed = Date.parse(time); return Number.isFinite(parsed) ? new Date(parsed) : null; } if (time && typeof time === "object" && "year" in time) { const { year, month, day } = time; return new Date(Date.UTC(year, month - 1, day)); } return null; }