feat(chart): RSI(14) 보조지표 sub-pane + 토글 추가

- 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 도 깔끔.
This commit is contained in:
claude-owner
2026-05-28 18:35:19 +09:00
parent d5acf79e05
commit 5aeea1a559

View File

@@ -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<HTMLDivElement | null>(null);
const chartRef = useRef<IChartApi | null>(null);
const candleRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
const predRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
const smaRefs = useRef<Map<SmaWindow, ISeriesApi<"Line">>>(new Map());
// RSI 보조차트 — 별도 chart 인스턴스로 메인 아래에 붙여 timeScale 동기화.
const rsiContainerRef = useRef<HTMLDivElement | null>(null);
const rsiChartRef = useRef<IChartApi | null>(null);
const rsiSeriesRef = useRef<ISeriesApi<"Line"> | null>(null);
// 토글 상태 — 기본 MA20 만 ON. 사용자 컨트롤이 너무 시끄럽지 않게.
const [active, setActive] = useState<Set<SmaWindow>>(() => new Set<SmaWindow>([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 (
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900/30 p-2">
{showSma && (
<div className="mb-1 flex items-center gap-1.5 px-1">
<div className="mb-1 flex flex-wrap items-center gap-1.5 px-1">
{SMA_PRESETS.map((p) => {
const on = active.has(p.window);
return (
@@ -289,11 +422,34 @@ export function StockChart({ chart, prediction }: Props) {
</button>
);
})}
<span className="mx-1 text-zinc-700">|</span>
<button
type="button"
onClick={() => setRsiOn((v) => !v)}
className={`flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] transition ${
rsiOn
? "border-zinc-600 bg-zinc-800/80 text-zinc-100"
: "border-zinc-800 bg-transparent text-zinc-500 hover:text-zinc-300"
}`}
aria-pressed={rsiOn}
title="상대강도지수 (Relative Strength Index, 14일)"
>
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: rsiOn ? "#e879f9" : "transparent", border: "1px solid #e879f9" }}
/>
RSI
</button>
</div>
)}
<div className="h-[460px] w-full">
<div ref={containerRef} className="h-full w-full" />
</div>
{showRsi && (
<div className="mt-2 h-[110px] w-full border-t border-zinc-800 pt-1">
<div ref={rsiContainerRef} 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" />