From 1a6d953d1779821d6e78c0ac33ce5fcf35758584 Mon Sep 17 00:00:00 2001 From: claude-owner Date: Thu, 28 May 2026 18:38:18 +0900 Subject: [PATCH] =?UTF-8?q?feat(chart):=20MACD(12/26/9)=20=EB=B3=B4?= =?UTF-8?q?=EC=A1=B0=EC=A7=80=ED=91=9C=20sub-pane=20+=20=ED=86=A0=EA=B8=80?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EMA + MACD(line/signal/hist) 계산 함수 추가 - MACD 라인(파랑) + Signal 라인(주황) + Histogram (양수=빨강 / 음수=파랑 옅게) - 별도 lightweight-charts 인스턴스 (RSI 와 동일 패턴, handleScroll/Scale off) - MA chip 줄에 MACD 토글 chip 추가, 기본 OFF, intraday 에선 숨김 - timeScale 동기화 effect 를 단일 follower 리스트로 일반화 (RSI + MACD 동시 가능) EMA 헬퍼는 null-tolerant — 결측 시점은 직전 ema 유지. Wilder 가 아닌 표준 EMA (alpha=2/(n+1)) 채택, 트레이딩뷰/Toss 와 동일. --- web/components/StockChart.tsx | 174 ++++++++++++++++++++++++++++++++-- 1 file changed, 166 insertions(+), 8 deletions(-) diff --git a/web/components/StockChart.tsx b/web/components/StockChart.tsx index 89de469..67fd462 100644 --- a/web/components/StockChart.tsx +++ b/web/components/StockChart.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { createChart, type CandlestickData, + type HistogramData, type IChartApi, type ISeriesApi, type LineData, @@ -114,6 +115,48 @@ function rsi(closes: (number | null)[], period = 14): (number | null)[] { return out; } +// 지수이동평균. 첫 유효 값에서 시드 시작, 이후 alpha=2/(n+1) 점화식. +// null 은 그대로 통과 (해당 시점 계산 안 함, 직전 ema 유지). +function ema(values: (number | null)[], period: number): (number | null)[] { + const out: (number | null)[] = new Array(values.length).fill(null); + const alpha = 2 / (period + 1); + let cur: number | null = null; + for (let i = 0; i < values.length; i++) { + const v = values[i]; + if (v == null) { + out[i] = cur; + continue; + } + if (cur == null) cur = v; + else cur = alpha * v + (1 - alpha) * cur; + out[i] = cur; + } + return out; +} + +// MACD = EMA12 - EMA26, signal = EMA9(MACD), hist = MACD - signal. +function macd( + closes: (number | null)[], + fast = 12, + slow = 26, + signal = 9, +): { macd: (number | null)[]; signal: (number | null)[]; hist: (number | null)[] } { + const ef = ema(closes, fast); + const es = ema(closes, slow); + const m: (number | null)[] = ef.map((a, i) => { + const b = es[i]; + if (a == null || b == null) return null; + return a - b; + }); + const sig = ema(m, signal); + const hist: (number | null)[] = m.map((a, i) => { + const b = sig[i]; + if (a == null || b == null) return null; + return a - b; + }); + return { macd: m, signal: sig, hist }; +} + export function StockChart({ chart, prediction }: Props) { const containerRef = useRef(null); const chartRef = useRef(null); @@ -124,14 +167,22 @@ export function StockChart({ chart, prediction }: Props) { const rsiContainerRef = useRef(null); const rsiChartRef = useRef(null); const rsiSeriesRef = useRef | null>(null); + // MACD 보조차트 — RSI 와 같은 패턴 (별 인스턴스, 단방향 sync). + const macdContainerRef = useRef(null); + const macdChartRef = useRef(null); + const macdLineRef = useRef | null>(null); + const macdSignalRef = useRef | null>(null); + const macdHistRef = useRef | null>(null); // 토글 상태 — 기본 MA20 만 ON. 사용자 컨트롤이 너무 시끄럽지 않게. const [active, setActive] = useState>(() => new Set([20])); const [rsiOn, setRsiOn] = useState(false); + const [macdOn, setMacdOn] = useState(false); const isIntraday = chart.interval === "10m"; const showSma = !isIntraday; // 10분봉에선 의미 적음 — UI 도 숨김. const showRsi = !isIntraday && rsiOn; + const showMacd = !isIntraday && macdOn; // chart 생성 — interval 바뀌면 timeVisible 토글을 위해 재생성. useEffect(() => { @@ -368,24 +419,109 @@ export function StockChart({ chart, prediction }: Props) { rsiSeriesRef.current.setData(data); }, [closes, chart, showRsi]); - // 메인 ↔ RSI timeScale 동기화. 메인이 source, RSI 가 follower. - // 핸들 스크롤은 RSI 쪽 꺼져 있어 단방향이면 충분. + // MACD 보조 차트 생성/제거. + useEffect(() => { + if (!showMacd || !macdContainerRef.current) return; + const c = createChart(macdContainerRef.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 hist = c.addHistogramSeries({ + priceLineVisible: false, + lastValueVisible: false, + priceFormat: { type: "price", precision: 2, minMove: 0.01 }, + }); + const macdLine = c.addLineSeries({ + color: "#60a5fa", + lineWidth: 1, + priceLineVisible: false, + lastValueVisible: true, + title: "MACD", + priceFormat: { type: "price", precision: 2, minMove: 0.01 }, + }); + const signalLine = c.addLineSeries({ + color: "#fb923c", + lineWidth: 1, + priceLineVisible: false, + lastValueVisible: true, + title: "Signal", + priceFormat: { type: "price", precision: 2, minMove: 0.01 }, + }); + macdChartRef.current = c; + macdHistRef.current = hist; + macdLineRef.current = macdLine; + macdSignalRef.current = signalLine; + return () => { + c.remove(); + macdChartRef.current = null; + macdHistRef.current = null; + macdLineRef.current = null; + macdSignalRef.current = null; + }; + }, [showMacd]); + + // MACD 데이터 push. + useEffect(() => { + const hSeries = macdHistRef.current; + const mSeries = macdLineRef.current; + const sSeries = macdSignalRef.current; + if (!hSeries || !mSeries || !sSeries) return; + const { macd: m, signal: sig, hist } = macd(closes, 12, 26, 9); + const mLine: LineData[] = []; + const sLine: LineData[] = []; + const hBars: HistogramData[] = []; + for (let i = 0; i < closes.length; i++) { + const t = isoToUtcTs(chart.ohlcv[i].date); + if (m[i] != null) mLine.push({ time: t, value: m[i] as number }); + if (sig[i] != null) sLine.push({ time: t, value: sig[i] as number }); + if (hist[i] != null) { + const h = hist[i] as number; + // 한국 관행: 양수 막대=상승=빨강, 음수=하락=파랑. 컬러 옅게 (배경 톤). + hBars.push({ time: t, value: h, color: h >= 0 ? "#ef444488" : "#3b82f688" }); + } + } + hSeries.setData(hBars); + mSeries.setData(mLine); + sSeries.setData(sLine); + }, [closes, chart, showMacd]); + + // 메인 ↔ 보조패널 timeScale 단방향 동기화. RSI/MACD 둘 다 처리. useEffect(() => { const main = chartRef.current; - const sub = rsiChartRef.current; - if (!main || !sub || !showRsi) return; + if (!main) return; + const followers: IChartApi[] = []; + if (showRsi && rsiChartRef.current) followers.push(rsiChartRef.current); + if (showMacd && macdChartRef.current) followers.push(macdChartRef.current); + if (!followers.length) return; const handler = (range: LogicalRange | null) => { if (!range) return; - sub.timeScale().setVisibleLogicalRange(range); + for (const f of followers) f.timeScale().setVisibleLogicalRange(range); }; - // 초기 동기화. const initial = main.timeScale().getVisibleLogicalRange(); - if (initial) sub.timeScale().setVisibleLogicalRange(initial); + if (initial) { + for (const f of followers) f.timeScale().setVisibleLogicalRange(initial); + } main.timeScale().subscribeVisibleLogicalRangeChange(handler); return () => { main.timeScale().unsubscribeVisibleLogicalRangeChange(handler); }; - }, [showRsi, chart]); + }, [showRsi, showMacd, chart]); const toggle = (w: SmaWindow) => { setActive((cur) => { @@ -440,6 +576,23 @@ export function StockChart({ chart, prediction }: Props) { /> RSI + )}
@@ -450,6 +603,11 @@ export function StockChart({ chart, prediction }: Props) {
)} + {showMacd && ( +
+
+
+ )} {prediction?.found && !isIntraday && (