diff --git a/web/components/StockChart.tsx b/web/components/StockChart.tsx index ce077be..ba0c499 100644 --- a/web/components/StockChart.tsx +++ b/web/components/StockChart.tsx @@ -1,11 +1,12 @@ "use client"; -import { useEffect, useRef } from "react"; +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"; @@ -43,13 +44,53 @@ const COLOR_DOWN = "#3b82f6"; 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(null); const chartRef = useRef(null); const candleRef = useRef | null>(null); const predRef = useRef | null>(null); + const smaRefs = useRef>>(new Map()); + + // 토글 상태 — 기본 MA20 만 ON. 사용자 컨트롤이 너무 시끄럽지 않게. + const [active, setActive] = useState>(() => new Set([20])); const isIntraday = chart.interval === "10m"; + const showSma = !isIntraday; // 10분봉에선 의미 적음 — UI 도 숨김. // chart 생성 — interval 바뀌면 timeVisible 토글을 위해 재생성. useEffect(() => { @@ -82,11 +123,13 @@ export function StockChart({ chart, prediction }: Props) { }); 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]); @@ -106,6 +149,50 @@ export function StockChart({ chart, prediction }: Props) { 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 @@ -167,8 +254,43 @@ export function StockChart({ chart, prediction }: Props) { 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 (
+ {showSma && ( +
+ {SMA_PRESETS.map((p) => { + const on = active.has(p.window); + return ( + + ); + })} +
+ )}