feat(chart): MACD(12/26/9) 보조지표 sub-pane + 토글 추가

- 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 와 동일.
This commit is contained in:
claude-owner
2026-05-28 18:38:18 +09:00
parent 9e43663b23
commit 1a6d953d17

View File

@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { import {
createChart, createChart,
type CandlestickData, type CandlestickData,
type HistogramData,
type IChartApi, type IChartApi,
type ISeriesApi, type ISeriesApi,
type LineData, type LineData,
@@ -114,6 +115,48 @@ function rsi(closes: (number | null)[], period = 14): (number | null)[] {
return out; 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) { export function StockChart({ chart, prediction }: Props) {
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<IChartApi | null>(null); const chartRef = useRef<IChartApi | null>(null);
@@ -124,14 +167,22 @@ export function StockChart({ chart, prediction }: Props) {
const rsiContainerRef = useRef<HTMLDivElement | null>(null); const rsiContainerRef = useRef<HTMLDivElement | null>(null);
const rsiChartRef = useRef<IChartApi | null>(null); const rsiChartRef = useRef<IChartApi | null>(null);
const rsiSeriesRef = useRef<ISeriesApi<"Line"> | null>(null); const rsiSeriesRef = useRef<ISeriesApi<"Line"> | null>(null);
// MACD 보조차트 — RSI 와 같은 패턴 (별 인스턴스, 단방향 sync).
const macdContainerRef = useRef<HTMLDivElement | null>(null);
const macdChartRef = useRef<IChartApi | null>(null);
const macdLineRef = useRef<ISeriesApi<"Line"> | null>(null);
const macdSignalRef = useRef<ISeriesApi<"Line"> | null>(null);
const macdHistRef = useRef<ISeriesApi<"Histogram"> | null>(null);
// 토글 상태 — 기본 MA20 만 ON. 사용자 컨트롤이 너무 시끄럽지 않게. // 토글 상태 — 기본 MA20 만 ON. 사용자 컨트롤이 너무 시끄럽지 않게.
const [active, setActive] = useState<Set<SmaWindow>>(() => new Set<SmaWindow>([20])); const [active, setActive] = useState<Set<SmaWindow>>(() => new Set<SmaWindow>([20]));
const [rsiOn, setRsiOn] = useState(false); const [rsiOn, setRsiOn] = useState(false);
const [macdOn, setMacdOn] = useState(false);
const isIntraday = chart.interval === "10m"; const isIntraday = chart.interval === "10m";
const showSma = !isIntraday; // 10분봉에선 의미 적음 — UI 도 숨김. const showSma = !isIntraday; // 10분봉에선 의미 적음 — UI 도 숨김.
const showRsi = !isIntraday && rsiOn; const showRsi = !isIntraday && rsiOn;
const showMacd = !isIntraday && macdOn;
// chart 생성 — interval 바뀌면 timeVisible 토글을 위해 재생성. // chart 생성 — interval 바뀌면 timeVisible 토글을 위해 재생성.
useEffect(() => { useEffect(() => {
@@ -368,24 +419,109 @@ export function StockChart({ chart, prediction }: Props) {
rsiSeriesRef.current.setData(data); rsiSeriesRef.current.setData(data);
}, [closes, chart, showRsi]); }, [closes, chart, showRsi]);
// 메인 ↔ RSI timeScale 동기화. 메인이 source, RSI 가 follower. // MACD 보조 차트 생성/제거.
// 핸들 스크롤은 RSI 쪽 꺼져 있어 단방향이면 충분. 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(() => { useEffect(() => {
const main = chartRef.current; const main = chartRef.current;
const sub = rsiChartRef.current; if (!main) return;
if (!main || !sub || !showRsi) 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) => { const handler = (range: LogicalRange | null) => {
if (!range) return; if (!range) return;
sub.timeScale().setVisibleLogicalRange(range); for (const f of followers) f.timeScale().setVisibleLogicalRange(range);
}; };
// 초기 동기화.
const initial = main.timeScale().getVisibleLogicalRange(); 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); main.timeScale().subscribeVisibleLogicalRangeChange(handler);
return () => { return () => {
main.timeScale().unsubscribeVisibleLogicalRangeChange(handler); main.timeScale().unsubscribeVisibleLogicalRangeChange(handler);
}; };
}, [showRsi, chart]); }, [showRsi, showMacd, chart]);
const toggle = (w: SmaWindow) => { const toggle = (w: SmaWindow) => {
setActive((cur) => { setActive((cur) => {
@@ -440,6 +576,23 @@ export function StockChart({ chart, prediction }: Props) {
/> />
RSI RSI
</button> </button>
<button
type="button"
onClick={() => setMacdOn((v) => !v)}
className={`flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] transition ${
macdOn
? "border-zinc-600 bg-zinc-800/80 text-zinc-100"
: "border-zinc-800 bg-transparent text-zinc-500 hover:text-zinc-300"
}`}
aria-pressed={macdOn}
title="MACD (12/26/9) — 추세 전환 신호"
>
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: macdOn ? "#60a5fa" : "transparent", border: "1px solid #60a5fa" }}
/>
MACD
</button>
</div> </div>
)} )}
<div className="h-[460px] w-full"> <div className="h-[460px] w-full">
@@ -450,6 +603,11 @@ export function StockChart({ chart, prediction }: Props) {
<div ref={rsiContainerRef} className="h-full w-full" /> <div ref={rsiContainerRef} className="h-full w-full" />
</div> </div>
)} )}
{showMacd && (
<div className="mt-2 h-[110px] w-full border-t border-zinc-800 pt-1">
<div ref={macdContainerRef} className="h-full w-full" />
</div>
)}
{prediction?.found && !isIntraday && ( {prediction?.found && !isIntraday && (
<div className="mt-1 flex items-center justify-end gap-2 px-2 text-[11px] text-zinc-500"> <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-rose-300" />