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 {
|
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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user