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:
@@ -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<HTMLDivElement | 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 rsiChartRef = useRef<IChartApi | 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. 사용자 컨트롤이 너무 시끄럽지 않게.
|
||||
const [active, setActive] = useState<Set<SmaWindow>>(() => new Set<SmaWindow>([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
|
||||
</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 className="h-[460px] w-full">
|
||||
@@ -450,6 +603,11 @@ export function StockChart({ chart, prediction }: Props) {
|
||||
<div ref={rsiContainerRef} className="h-full w-full" />
|
||||
</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 && (
|
||||
<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" />
|
||||
|
||||
Reference in New Issue
Block a user