Files
stock_chart_site/web/components/StockChart.tsx
claude-owner 01e5eba3c1 feat(chart): SMA(5/20/60) 토글 오버레이
- lightweight-charts addLineSeries 로 종가 기반 단순이동평균 라인 추가
- 칩 토글 (기본 MA20 ON), intraday(10분봉)에선 UI/시리즈 모두 숨김
- fitContent 는 호출 안 함 — 사용자 줌 유지
2026-05-28 15:23:34 +09:00

307 lines
9.8 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import {
createChart,
type CandlestickData,
type IChartApi,
type ISeriesApi,
type LineData,
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" },
];
export function StockChart({ chart, prediction }: Props) {
const containerRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<IChartApi | null>(null);
const candleRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
const predRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
const smaRefs = useRef<Map<SmaWindow, ISeriesApi<"Line">>>(new Map());
// 토글 상태 — 기본 MA20 만 ON. 사용자 컨트롤이 너무 시끄럽지 않게.
const [active, setActive] = useState<Set<SmaWindow>>(() => new Set<SmaWindow>([20]));
const isIntraday = chart.interval === "10m";
const showSma = !isIntraday; // 10분봉에선 의미 적음 — UI 도 숨김.
// 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]);
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 (
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900/30 p-2">
{showSma && (
<div className="mb-1 flex items-center gap-1.5 px-1">
{SMA_PRESETS.map((p) => {
const on = active.has(p.window);
return (
<button
key={p.window}
type="button"
onClick={() => toggle(p.window)}
className={`flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] transition ${
on
? "border-zinc-600 bg-zinc-800/80 text-zinc-100"
: "border-zinc-800 bg-transparent text-zinc-500 hover:text-zinc-300"
}`}
aria-pressed={on}
>
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: on ? p.color : "transparent", border: `1px solid ${p.color}` }}
/>
{p.label}
</button>
);
})}
</div>
)}
<div className="h-[460px] w-full">
<div ref={containerRef} className="h-full w-full" />
</div>
{prediction?.found && !isIntraday && (
<div className="mt-1 flex items-center justify-end gap-2 px-2 text-[11px] text-zinc-500">
<span className="inline-block h-2 w-2 rounded-sm bg-rose-300" />
<span className="inline-block h-2 w-2 rounded-sm bg-sky-300" />
<span> ( = q10~q90 )</span>
</div>
)}
</div>
);
}