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