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 IChartApi,
|
||||||
type ISeriesApi,
|
type ISeriesApi,
|
||||||
type LineData,
|
type LineData,
|
||||||
|
type LogicalRange,
|
||||||
type UTCTimestamp,
|
type UTCTimestamp,
|
||||||
} from "lightweight-charts";
|
} from "lightweight-charts";
|
||||||
import type { ChartPayload, LatestPredictionResponse } from "../lib/api";
|
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" },
|
{ 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) {
|
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);
|
||||||
const candleRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
|
const candleRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
|
||||||
const predRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
|
const predRef = useRef<ISeriesApi<"Candlestick"> | null>(null);
|
||||||
const smaRefs = useRef<Map<SmaWindow, ISeriesApi<"Line">>>(new Map());
|
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. 사용자 컨트롤이 너무 시끄럽지 않게.
|
// 토글 상태 — 기본 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 isIntraday = chart.interval === "10m";
|
const isIntraday = chart.interval === "10m";
|
||||||
const showSma = !isIntraday; // 10분봉에선 의미 적음 — UI 도 숨김.
|
const showSma = !isIntraday; // 10분봉에선 의미 적음 — UI 도 숨김.
|
||||||
|
const showRsi = !isIntraday && rsiOn;
|
||||||
|
|
||||||
// chart 생성 — interval 바뀌면 timeVisible 토글을 위해 재생성.
|
// chart 생성 — interval 바뀌면 timeVisible 토글을 위해 재생성.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -254,6 +295,98 @@ export function StockChart({ chart, prediction }: Props) {
|
|||||||
chartRef.current.timeScale().fitContent();
|
chartRef.current.timeScale().fitContent();
|
||||||
}, [prediction, isIntraday]);
|
}, [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) => {
|
const toggle = (w: SmaWindow) => {
|
||||||
setActive((cur) => {
|
setActive((cur) => {
|
||||||
const next = new Set(cur);
|
const next = new Set(cur);
|
||||||
@@ -266,7 +399,7 @@ export function StockChart({ chart, prediction }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900/30 p-2">
|
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900/30 p-2">
|
||||||
{showSma && (
|
{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) => {
|
{SMA_PRESETS.map((p) => {
|
||||||
const on = active.has(p.window);
|
const on = active.has(p.window);
|
||||||
return (
|
return (
|
||||||
@@ -289,11 +422,34 @@ export function StockChart({ chart, prediction }: Props) {
|
|||||||
</button>
|
</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>
|
||||||
)}
|
)}
|
||||||
<div className="h-[460px] w-full">
|
<div className="h-[460px] w-full">
|
||||||
<div ref={containerRef} className="h-full w-full" />
|
<div ref={containerRef} className="h-full w-full" />
|
||||||
</div>
|
</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 && (
|
{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