"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { createChart, type CandlestickData, type IChartApi, type ISeriesApi, type LineData, type LogicalRange, 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"; // 예측 캔들은 같은 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; } export function StockChart({ chart, prediction }: Props) { const containerRef = useRef(null); const chartRef = useRef(null); const candleRef = 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); // 토글 상태 — 기본 MA20 만 ON. 사용자 컨트롤이 너무 시끄럽지 않게. const [active, setActive] = useState>(() => new Set([20])); const [rsiOn, setRsiOn] = useState(false); const isIntraday = chart.interval === "10m"; const showSma = !isIntraday; // 10분봉에선 의미 적음 — UI 도 숨김. const showRsi = !isIntraday && rsiOn; // 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, }); chartRef.current = c; candleRef.current = candle; const smaMap = smaRefs.current; return () => { c.remove(); chartRef.current = null; candleRef.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]); // 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]); // 메인 ↔ RSI timeScale 동기화. 메인이 source, RSI 가 follower. // 핸들 스크롤은 RSI 쪽 꺼져 있어 단방향이면 충분. useEffect(() => { const main = chartRef.current; const sub = rsiChartRef.current; if (!main || !sub || !showRsi) return; const handler = (range: LogicalRange | null) => { if (!range) return; sub.timeScale().setVisibleLogicalRange(range); }; // 초기 동기화. const initial = main.timeScale().getVisibleLogicalRange(); if (initial) sub.timeScale().setVisibleLogicalRange(initial); main.timeScale().subscribeVisibleLogicalRangeChange(handler); return () => { main.timeScale().unsubscribeVisibleLogicalRangeChange(handler); }; }, [showRsi, 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 && (
)} {prediction?.found && !isIntraday && (
예측 (꼬리 = q10~q90 신뢰구간)
)}
); }