"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { createChart, type CandlestickData, type HistogramData, type IChartApi, type ISeriesApi, type LineData, type LogicalRange, type MouseEventParams, type UTCTimestamp, } from "lightweight-charts"; import type { ChartPayload, LatestPredictionResponse } from "../lib/api"; type Props = { chart: ChartPayload; prediction?: LatestPredictionResponse | null; }; // KST naive ISO ('YYYY-MM-DD' 또는 'YYYY-MM-DDTHH:MM:SS') 를 UTCTimestamp 로. // lightweight-charts 는 UTC 라고 가정하지만 KST wall-clock 을 UTC 처럼 넣어 // timeScale 표시가 한국 사용자에게 자연스럽게 KST 그대로 나오도록 함. function isoToUtcTs(s: string): UTCTimestamp { if (s.length <= 10) { return (Date.UTC( Number(s.slice(0, 4)), Number(s.slice(5, 7)) - 1, Number(s.slice(8, 10)), ) / 1000) as UTCTimestamp; } return (Date.UTC( Number(s.slice(0, 4)), Number(s.slice(5, 7)) - 1, Number(s.slice(8, 10)), Number(s.slice(11, 13)), Number(s.slice(14, 16)), Number(s.slice(17, 19) || "0"), ) / 1000) as UTCTimestamp; } // 한국 거래소 관행: 상승=빨강, 하락=파랑. const COLOR_UP = "#ef4444"; const COLOR_DOWN = "#3b82f6"; // crosshair overlay 용 — 토스 차트는 마우스 hover 시 좌상단에 한국어 정보 박스를 띄움. // UTCTimestamp(초) → 'YYYY-MM-DD(요일)' / intraday 면 시:분 까지. const WEEKDAYS = ["일", "월", "화", "수", "목", "금", "토"]; function tsToKLabel(ts: UTCTimestamp, intraday: boolean): string { const d = new Date((ts as number) * 1000); const y = d.getUTCFullYear(); const m = String(d.getUTCMonth() + 1).padStart(2, "0"); const day = String(d.getUTCDate()).padStart(2, "0"); const wd = WEEKDAYS[d.getUTCDay()]; const base = `${y}-${m}-${day}(${wd})`; if (!intraday) return base; const hh = String(d.getUTCHours()).padStart(2, "0"); const mm = String(d.getUTCMinutes()).padStart(2, "0"); return `${base} ${hh}:${mm}`; } // 거래량 한국식 단위 (억/만). 토스 사이드바 fmtCompact 와 동일. function fmtKVolume(n: number | null | undefined): string { if (n == null || !Number.isFinite(n)) return "-"; if (n >= 1e8) return `${(n / 1e8).toFixed(1)}억`; if (n >= 1e4) return `${(n / 1e4).toFixed(1)}만`; return n.toLocaleString(); } function fmtKPrice(n: number | null | undefined): string { if (n == null || !Number.isFinite(n)) return "-"; return n.toLocaleString(undefined, { maximumFractionDigits: 0 }); } type CrosshairInfo = { label: string; // '2025-11-28(금)' 또는 '2025-11-28(금) 14:30' open: number | null; high: number | null; low: number | null; close: number | null; volume: number | null; // 시가 → 종가 등락률 (%). null 이면 안 그림. pct: number | null; }; // 예측 캔들은 같은 up/down 색 옅은 톤. "어우러지되 미래 데이터임이 명확" 한 시각 신호. const PRED_UP = "#fda4af"; const PRED_DOWN = "#93c5fd"; // 단순 이동평균. 윈도우 미만은 null (lightweight-charts 는 whitespace 로 처리됨). function sma(values: (number | null)[], window: number): (number | null)[] { const out: (number | null)[] = new Array(values.length).fill(null); let sum = 0; let count = 0; const buf: (number | null)[] = []; for (let i = 0; i < values.length; i++) { const v = values[i]; buf.push(v); if (v != null) { sum += v; count += 1; } if (buf.length > window) { const drop = buf.shift(); if (drop != null) { sum -= drop; count -= 1; } } if (buf.length === window && count === window) { out[i] = sum / window; } } return out; } type SmaWindow = 5 | 20 | 60; const SMA_PRESETS: { window: SmaWindow; color: string; label: string }[] = [ { window: 5, color: "#f59e0b", label: "MA5" }, { window: 20, color: "#22d3ee", label: "MA20" }, { window: 60, color: "#a78bfa", label: "MA60" }, ]; // Wilder's smoothing RSI. period=14 가 표준. 워밍업 구간은 null. // 첫 평균은 SMA, 이후는 ((prev*(n-1)) + cur) / n 점화식. function rsi(closes: (number | null)[], period = 14): (number | null)[] { const out: (number | null)[] = new Array(closes.length).fill(null); if (closes.length <= period) return out; let gainSum = 0; let lossSum = 0; // 첫 period 구간으로 초기 평균. null 이 끼면 직전 값 기준으로 변화량 산출하되, // 둘 중 하나라도 null 이면 그 step 은 0 으로 취급 (보수적). for (let i = 1; i <= period; i++) { const a = closes[i]; const b = closes[i - 1]; if (a == null || b == null) continue; const ch = a - b; if (ch >= 0) gainSum += ch; else lossSum -= ch; } let avgGain = gainSum / period; let avgLoss = lossSum / period; out[period] = avgLoss === 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss); for (let i = period + 1; i < closes.length; i++) { const a = closes[i]; const b = closes[i - 1]; let ch = 0; if (a != null && b != null) ch = a - b; const g = ch > 0 ? ch : 0; const l = ch < 0 ? -ch : 0; avgGain = (avgGain * (period - 1) + g) / period; avgLoss = (avgLoss * (period - 1) + l) / period; out[i] = avgLoss === 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss); } return out; } // 지수이동평균. 첫 유효 값에서 시드 시작, 이후 alpha=2/(n+1) 점화식. // null 은 그대로 통과 (해당 시점 계산 안 함, 직전 ema 유지). function ema(values: (number | null)[], period: number): (number | null)[] { const out: (number | null)[] = new Array(values.length).fill(null); const alpha = 2 / (period + 1); let cur: number | null = null; for (let i = 0; i < values.length; i++) { const v = values[i]; if (v == null) { out[i] = cur; continue; } if (cur == null) cur = v; else cur = alpha * v + (1 - alpha) * cur; out[i] = cur; } return out; } // MACD = EMA12 - EMA26, signal = EMA9(MACD), hist = MACD - signal. function macd( closes: (number | null)[], fast = 12, slow = 26, signal = 9, ): { macd: (number | null)[]; signal: (number | null)[]; hist: (number | null)[] } { const ef = ema(closes, fast); const es = ema(closes, slow); const m: (number | null)[] = ef.map((a, i) => { const b = es[i]; if (a == null || b == null) return null; return a - b; }); const sig = ema(m, signal); const hist: (number | null)[] = m.map((a, i) => { const b = sig[i]; if (a == null || b == null) return null; return a - b; }); return { macd: m, signal: sig, hist }; } export function StockChart({ chart, prediction }: Props) { const containerRef = useRef(null); const chartRef = useRef(null); const candleRef = useRef | null>(null); const volumeRef = useRef | null>(null); const predRef = useRef | null>(null); const smaRefs = useRef>>(new Map()); // RSI 보조차트 — 별도 chart 인스턴스로 메인 아래에 붙여 timeScale 동기화. const rsiContainerRef = useRef(null); const rsiChartRef = useRef(null); const rsiSeriesRef = useRef | null>(null); // MACD 보조차트 — RSI 와 같은 패턴 (별 인스턴스, 단방향 sync). const macdContainerRef = useRef(null); const macdChartRef = useRef(null); const macdLineRef = useRef | null>(null); const macdSignalRef = useRef | null>(null); const macdHistRef = useRef | null>(null); // 토글 상태 — 기본 MA20 만 ON. 사용자 컨트롤이 너무 시끄럽지 않게. const [active, setActive] = useState>(() => new Set([20])); const [rsiOn, setRsiOn] = useState(false); const [macdOn, setMacdOn] = useState(false); // crosshair 위에 떠 있는 한국어 라벨 — null 이면 hover 안 함 (기본 마지막 봉 표시). const [hover, setHover] = useState(null); const isIntraday = chart.interval === "10m"; const showSma = !isIntraday; // 10분봉에선 의미 적음 — UI 도 숨김. const showRsi = !isIntraday && rsiOn; const showMacd = !isIntraday && macdOn; // chart 생성 — interval 바뀌면 timeVisible 토글을 위해 재생성. useEffect(() => { if (!containerRef.current) return; const c = createChart(containerRef.current, { layout: { background: { color: "transparent" }, textColor: "#cbd5e1", }, grid: { vertLines: { color: "#1f2937" }, horzLines: { color: "#1f2937" }, }, rightPriceScale: { borderColor: "#374151" }, timeScale: { borderColor: "#374151", timeVisible: isIntraday, secondsVisible: false, }, autoSize: true, crosshair: { mode: 1 }, }); const candle = c.addCandlestickSeries({ upColor: COLOR_UP, downColor: COLOR_DOWN, borderUpColor: COLOR_UP, borderDownColor: COLOR_DOWN, wickUpColor: COLOR_UP, wickDownColor: COLOR_DOWN, }); // 캔들 priceScale 을 위쪽 78% 로 좁혀서 거래량 막대를 아래쪽에 둘 공간 확보. c.priceScale("right").applyOptions({ scaleMargins: { top: 0.05, bottom: 0.22 }, }); // 거래량 막대 — overlay priceScale("volume") 로 같은 chart 안에 띄움. // bottom 0/top 0.82 → 차트 영역의 아래 18% 만 사용. 가격축 라벨은 숨김. const volume = c.addHistogramSeries({ priceFormat: { type: "volume" }, priceScaleId: "volume", priceLineVisible: false, lastValueVisible: false, }); c.priceScale("volume").applyOptions({ scaleMargins: { top: 0.82, bottom: 0 }, }); chartRef.current = c; candleRef.current = candle; volumeRef.current = volume; const smaMap = smaRefs.current; return () => { c.remove(); chartRef.current = null; candleRef.current = null; volumeRef.current = null; predRef.current = null; smaMap.clear(); }; }, [isIntraday]); // 실 데이터 push. useEffect(() => { if (!candleRef.current) return; const data: CandlestickData[] = chart.ohlcv .filter((p) => p.open !== null && p.high !== null && p.low !== null && p.close !== null) .map((p) => ({ time: isoToUtcTs(p.date), open: p.open as number, high: p.high as number, low: p.low as number, close: p.close as number, })); candleRef.current.setData(data); chartRef.current?.timeScale().fitContent(); }, [chart, isIntraday]); // 거래량 막대 — 종가 ≥ 시가면 한국 관행으로 빨강(상승), 아니면 파랑(하락). // null volume 은 그 시점 캔들 자체가 빠진 케이스(상장폐지/거래정지) 라 건너뛴다. useEffect(() => { if (!volumeRef.current) return; const data: HistogramData[] = chart.ohlcv .filter((p) => p.volume != null && p.volume > 0) .map((p) => { const up = p.close != null && p.open != null ? p.close >= p.open : true; return { time: isoToUtcTs(p.date), value: p.volume as number, color: up ? "#ef444466" : "#3b82f666", }; }); volumeRef.current.setData(data); }, [chart, isIntraday]); // SMA 시리즈 — chart.ohlcv 종가 기준. 토글 변화 또는 데이터 변화 시 갱신. // 비활성 윈도우는 제거하고, 활성은 재계산. 마지막에 fitContent 는 호출 안 함 — 사용자 줌 유지. const closes = useMemo(() => chart.ohlcv.map((p) => p.close), [chart]); useEffect(() => { const c = chartRef.current; if (!c) return; if (!showSma) { // intraday: 다 지움. for (const s of smaRefs.current.values()) c.removeSeries(s); smaRefs.current.clear(); return; } // 비활성 윈도우 제거. for (const [w, s] of smaRefs.current) { if (!active.has(w)) { c.removeSeries(s); smaRefs.current.delete(w); } } // 활성 윈도우 생성/갱신. for (const preset of SMA_PRESETS) { if (!active.has(preset.window)) continue; const values = sma(closes, preset.window); const lineData: LineData[] = []; for (let i = 0; i < values.length; i++) { const v = values[i]; if (v == null) continue; lineData.push({ time: isoToUtcTs(chart.ohlcv[i].date), value: v }); } let s = smaRefs.current.get(preset.window); if (!s) { s = c.addLineSeries({ color: preset.color, lineWidth: 1, priceLineVisible: false, lastValueVisible: false, title: preset.label, }); smaRefs.current.set(preset.window, s); } s.setData(lineData); } }, [active, closes, chart, showSma]); // 예측 오버레이를 캔들 시리즈로 렌더. // 합성 OHLC: // open[i] = i==0 ? base_close : prev.point_close // close[i] = point_close // high[i] = max(ci_high, open, close) // low[i] = min(ci_low, open, close) // → 몸통 = 기간 사이 예상 변동, 위/아래 꼬리 = 신뢰구간 폭 (불확실성). // 10분봉(intraday) 에서는 예측 표시 안 함 — 모델은 일봉 기반. useEffect(() => { if (!chartRef.current) return; if (predRef.current) { chartRef.current.removeSeries(predRef.current); predRef.current = null; } if (isIntraday) return; if (!prediction?.found || !prediction.steps?.length) return; const baseDate = prediction.base_date; const baseClose = prediction.base_close; if (!baseDate || baseClose == null) return; const sorted = [...prediction.steps] .filter( (s) => s.point_close != null && s.ci_low != null && s.ci_high != null && s.target_date, ) .sort((a, b) => a.horizon - b.horizon); if (!sorted.length) return; const data: CandlestickData[] = []; let prevClose = baseClose; for (const s of sorted) { const open = prevClose; const close = s.point_close as number; const ciHi = s.ci_high as number; const ciLo = s.ci_low as number; data.push({ time: isoToUtcTs(s.target_date), open, close, high: Math.max(ciHi, open, close), low: Math.min(ciLo, open, close), }); prevClose = close; } const pred = chartRef.current.addCandlestickSeries({ upColor: PRED_UP, downColor: PRED_DOWN, borderUpColor: PRED_UP, borderDownColor: PRED_DOWN, wickUpColor: PRED_UP, wickDownColor: PRED_DOWN, priceLineVisible: false, lastValueVisible: true, title: "예측", }); pred.setData(data); predRef.current = pred; chartRef.current.timeScale().fitContent(); }, [prediction, isIntraday]); // RSI 보조 차트 생성/제거 — toggle 또는 interval 변경 시. 메인 chart 와 별 인스턴스. // priceScale fix (0-100) + 30/70 가이드 라인. useEffect(() => { if (!showRsi || !rsiContainerRef.current) return; const c = createChart(rsiContainerRef.current, { layout: { background: { color: "transparent" }, textColor: "#cbd5e1", }, grid: { vertLines: { color: "#1f2937" }, horzLines: { color: "#1f2937" }, }, rightPriceScale: { borderColor: "#374151", scaleMargins: { top: 0.1, bottom: 0.1 }, }, timeScale: { borderColor: "#374151", timeVisible: false, secondsVisible: false, }, autoSize: true, crosshair: { mode: 1 }, handleScroll: false, handleScale: false, }); const s = c.addLineSeries({ color: "#e879f9", lineWidth: 1, priceLineVisible: false, lastValueVisible: true, title: "RSI(14)", priceFormat: { type: "price", precision: 1, minMove: 0.1 }, }); s.createPriceLine({ price: 70, color: "#ef4444", lineWidth: 1, lineStyle: 2, axisLabelVisible: true, title: "70", }); s.createPriceLine({ price: 30, color: "#3b82f6", lineWidth: 1, lineStyle: 2, axisLabelVisible: true, title: "30", }); rsiChartRef.current = c; rsiSeriesRef.current = s; return () => { c.remove(); rsiChartRef.current = null; rsiSeriesRef.current = null; }; }, [showRsi]); // RSI 데이터 push. useEffect(() => { if (!rsiSeriesRef.current) return; const values = rsi(closes, 14); const data: LineData[] = []; for (let i = 0; i < values.length; i++) { const v = values[i]; if (v == null) continue; data.push({ time: isoToUtcTs(chart.ohlcv[i].date), value: v }); } rsiSeriesRef.current.setData(data); }, [closes, chart, showRsi]); // MACD 보조 차트 생성/제거. useEffect(() => { if (!showMacd || !macdContainerRef.current) return; const c = createChart(macdContainerRef.current, { layout: { background: { color: "transparent" }, textColor: "#cbd5e1" }, grid: { vertLines: { color: "#1f2937" }, horzLines: { color: "#1f2937" }, }, rightPriceScale: { borderColor: "#374151", scaleMargins: { top: 0.1, bottom: 0.1 }, }, timeScale: { borderColor: "#374151", timeVisible: false, secondsVisible: false, }, autoSize: true, crosshair: { mode: 1 }, handleScroll: false, handleScale: false, }); const hist = c.addHistogramSeries({ priceLineVisible: false, lastValueVisible: false, priceFormat: { type: "price", precision: 2, minMove: 0.01 }, }); const macdLine = c.addLineSeries({ color: "#60a5fa", lineWidth: 1, priceLineVisible: false, lastValueVisible: true, title: "MACD", priceFormat: { type: "price", precision: 2, minMove: 0.01 }, }); const signalLine = c.addLineSeries({ color: "#fb923c", lineWidth: 1, priceLineVisible: false, lastValueVisible: true, title: "Signal", priceFormat: { type: "price", precision: 2, minMove: 0.01 }, }); macdChartRef.current = c; macdHistRef.current = hist; macdLineRef.current = macdLine; macdSignalRef.current = signalLine; return () => { c.remove(); macdChartRef.current = null; macdHistRef.current = null; macdLineRef.current = null; macdSignalRef.current = null; }; }, [showMacd]); // MACD 데이터 push. useEffect(() => { const hSeries = macdHistRef.current; const mSeries = macdLineRef.current; const sSeries = macdSignalRef.current; if (!hSeries || !mSeries || !sSeries) return; const { macd: m, signal: sig, hist } = macd(closes, 12, 26, 9); const mLine: LineData[] = []; const sLine: LineData[] = []; const hBars: HistogramData[] = []; for (let i = 0; i < closes.length; i++) { const t = isoToUtcTs(chart.ohlcv[i].date); if (m[i] != null) mLine.push({ time: t, value: m[i] as number }); if (sig[i] != null) sLine.push({ time: t, value: sig[i] as number }); if (hist[i] != null) { const h = hist[i] as number; // 한국 관행: 양수 막대=상승=빨강, 음수=하락=파랑. 컬러 옅게 (배경 톤). hBars.push({ time: t, value: h, color: h >= 0 ? "#ef444488" : "#3b82f688" }); } } hSeries.setData(hBars); mSeries.setData(mLine); sSeries.setData(sLine); }, [closes, chart, showMacd]); // crosshair → 한국어 오버레이. // - 같은 time(UTCTimestamp) 의 ohlcv 포인트를 O(1) 조회용 Map 으로 미리 만든다. // - hover 안 했을 때는 마지막 봉을 기본으로 표시 (토스 패턴 — 차트에 들어오자마자 정보 보임). const ohlcvByTs = useMemo(() => { const m = new Map(); for (const p of chart.ohlcv) m.set(isoToUtcTs(p.date) as number, p); return m; }, [chart]); const lastSummary = useMemo(() => { const pts = chart.ohlcv.filter((p) => p.close != null); if (!pts.length) return null; const last = pts[pts.length - 1]; const pct = last.open != null && last.close != null && last.open > 0 ? ((last.close - last.open) / last.open) * 100 : null; return { label: tsToKLabel(isoToUtcTs(last.date), isIntraday), open: last.open, high: last.high, low: last.low, close: last.close, volume: last.volume, pct, }; }, [chart, isIntraday]); useEffect(() => { const c = chartRef.current; if (!c) return; const handler = (param: MouseEventParams) => { // 차트 영역 밖이면 hover 해제 → lastSummary 가 자동 표시됨. if (!param.time || !param.point) { setHover(null); return; } const t = param.time as UTCTimestamp; const p = ohlcvByTs.get(t as number); if (!p) { setHover(null); return; } const pct = p.open != null && p.close != null && p.open > 0 ? ((p.close - p.open) / p.open) * 100 : null; setHover({ label: tsToKLabel(t, isIntraday), open: p.open, high: p.high, low: p.low, close: p.close, volume: p.volume, pct, }); }; c.subscribeCrosshairMove(handler); return () => { c.unsubscribeCrosshairMove(handler); }; }, [ohlcvByTs, isIntraday]); // 메인 ↔ 보조패널 timeScale 단방향 동기화. RSI/MACD 둘 다 처리. useEffect(() => { const main = chartRef.current; if (!main) return; const followers: IChartApi[] = []; if (showRsi && rsiChartRef.current) followers.push(rsiChartRef.current); if (showMacd && macdChartRef.current) followers.push(macdChartRef.current); if (!followers.length) return; const handler = (range: LogicalRange | null) => { if (!range) return; for (const f of followers) f.timeScale().setVisibleLogicalRange(range); }; const initial = main.timeScale().getVisibleLogicalRange(); if (initial) { for (const f of followers) f.timeScale().setVisibleLogicalRange(initial); } main.timeScale().subscribeVisibleLogicalRangeChange(handler); return () => { main.timeScale().unsubscribeVisibleLogicalRangeChange(handler); }; }, [showRsi, showMacd, chart]); const toggle = (w: SmaWindow) => { setActive((cur) => { const next = new Set(cur); if (next.has(w)) next.delete(w); else next.add(w); return next; }); }; return (
{showSma && (
{SMA_PRESETS.map((p) => { const on = active.has(p.window); return ( ); })} |
)}
{showRsi && (
)} {showMacd && (
)} {prediction?.found && !isIntraday && (
예측 (꼬리 = q10~q90 신뢰구간)
)}
); } // Toss 차트의 hover 정보 박스. 좌상단 absolute 로 띄우고 pointer-events-none // 으로 마우스 이벤트는 그대로 차트가 받음. info=null 이면 표시 안 함. function CrosshairLabel({ info, isHover, }: { info: CrosshairInfo | null; isHover: boolean; }) { if (!info) return null; const { label, open, high, low, close, volume, pct } = info; // 등락률 색 — 한국 관행. 0 또는 null 은 회색. const pctCls = pct == null || pct === 0 ? "text-zinc-400" : pct > 0 ? "text-rose-300" : "text-sky-300"; const pctText = pct == null ? "" : `${pct >= 0 ? "+" : ""}${pct.toFixed(2)}%`; return (
{label} {pctText && {pctText}}
); } function CrosshairCell({ k, v, tone, strong, }: { k: string; v: string; tone?: "up" | "down"; strong?: boolean; }) { const tcls = tone === "up" ? "text-rose-300" : tone === "down" ? "text-sky-300" : strong ? "text-zinc-100" : "text-zinc-300"; return ( {k} {v} ); }