From 5aeea1a55935306bcd72e881a7a19d7efda9cd3e Mon Sep 17 00:00:00 2001 From: claude-owner Date: Thu, 28 May 2026 18:35:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(chart):=20RSI(14)=20=EB=B3=B4=EC=A1=B0?= =?UTF-8?q?=EC=A7=80=ED=91=9C=20sub-pane=20+=20=ED=86=A0=EA=B8=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wilder's smoothing 기반 RSI 14일 계산 (워밍업 구간 null) - 메인 차트 아래 110px 별도 lightweight-charts 인스턴스로 sub-pane - 30/70 가이드 라인 (axisLabel 표시) - 메인 ↔ RSI timeScale 단방향 동기화 (RSI scroll/scale off) - MA chip 옆 RSI 토글 chip — 기본 OFF, intraday 에선 숨김 별도 인스턴스 방식 채택 이유: 같은 chart 에 priceScaleId/scaleMargins 로 영역 분할 시 캔들 priceScale 까지 영향받아 메인 줌이 망가짐. sub-chart 패턴이 lightweight-charts 공식 권장 + react cleanup 도 깔끔. --- web/components/StockChart.tsx | 158 +++++++++++++++++++++++++++++++++- 1 file changed, 157 insertions(+), 1 deletion(-) diff --git a/web/components/StockChart.tsx b/web/components/StockChart.tsx index ba0c499..89de469 100644 --- a/web/components/StockChart.tsx +++ b/web/components/StockChart.tsx @@ -7,6 +7,7 @@ import { type IChartApi, type ISeriesApi, type LineData, + type LogicalRange, type UTCTimestamp, } from "lightweight-charts"; import type { ChartPayload, LatestPredictionResponse } from "../lib/api"; @@ -79,18 +80,58 @@ const SMA_PRESETS: { window: SmaWindow; color: string; label: string }[] = [ { 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(() => { @@ -254,6 +295,98 @@ export function StockChart({ chart, prediction }: Props) { 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); @@ -266,7 +399,7 @@ export function StockChart({ chart, prediction }: Props) { return (
{showSma && ( -
+
{SMA_PRESETS.map((p) => { const on = active.has(p.window); return ( @@ -289,11 +422,34 @@ export function StockChart({ chart, prediction }: Props) { ); })} + | +
)}
+ {showRsi && ( +
+
+
+ )} {prediction?.found && !isIntraday && (