"use client"; import { useEffect, useRef } from "react"; import { createChart, type CandlestickData, type IChartApi, type ISeriesApi, 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"; 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 isIntraday = chart.interval === "10m"; // 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; return () => { c.remove(); chartRef.current = null; candleRef.current = null; predRef.current = null; }; }, [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]); // 예측 오버레이를 캔들 시리즈로 렌더. // 합성 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]); return (
{prediction?.found && !isIntraday && (
예측 (꼬리 = q10~q90 신뢰구간)
)}
); }