본문 바로가기
✨ Front-end/리액트(React)

[React] ApexChart로 RSI 지수 데이터 그리기

by 환풍 2026. 1. 19.
728x90
반응형

 

결과화면

현재 진행하고 있는 코인 자동매매에서 핵심 지표인 RSI 지수를 차트에 추가해주었다.

https://bright-landscape.tistory.com/461

 

[React] ApexChart로 이동평균선(MA) 그리기

결과화면MA(이동평균)선을 5, 10, 20, 60, 120일 선을 다섯개 추가해주었다. MaCalc.js// period일 이동평균 계산 ( 종가 기준 )export default function MaCalc(data, period) { const result = []; // 여기에 최종 MA 값들이 들

bright-landscape.tistory.com

이전에 ApexChart로 이동평균선(MA)를 그려보았었다. 이번에는 RSI 지수를 그려볼 것이다.


RsiCalc.js

/**
 * RSI(Relative Strength Index) 계산 함수
 * @param {number[]} closes - 캔들 종가 배열
 * @param {number} period - RSI 기간 (기본값 14)
 * @returns {(number|null)[]} RSI 값 배열
 */
export default function RsiCalc(closes, period = 14) {

    // 🔹 RSI 계산을 위해 최소 (period + 1)개의 가격 데이터가 필요
    // 🔹 데이터가 부족하면 계산 불가 → 빈 배열 반환
    if (closes.length < period + 1) return [];

    // 🔹 상승폭(양수)만 저장할 배열
    const gains = [];

    // 🔹 하락폭(음수의 절대값)만 저장할 배열
    const losses = [];

    // 🔹 종가 배열을 순회하며 전 캔들과의 가격 차이 계산
    for (let i = 1; i < closes.length; i++) {

        // 현재 종가 - 이전 종가
        const diff = closes[i] - closes[i - 1];

        // 🔹 상승한 경우 diff 값 그대로 사용, 하락/보합이면 0
        gains.push(Math.max(diff, 0));

        // 🔹 하락한 경우 절대값으로 저장, 상승/보합이면 0
        losses.push(Math.max(-diff, 0));
    }

    // 🔹 최초 평균 상승폭(SMA 방식)
    // 🔹 RSI 첫 계산은 단순 이동평균으로 시작
    let avgGain =
        gains.slice(0, period).reduce((a, b) => a + b, 0) / period;

    // 🔹 최초 평균 하락폭(SMA 방식)
    let avgLoss =
        losses.slice(0, period).reduce((a, b) => a + b, 0) / period;

    // 🔹 RSI 결과 배열
    // 🔹 앞의 period 구간은 RSI가 존재하지 않으므로 null로 채움
    const rsi = new Array(period).fill(null);

    // 🔹 period 이후부터 RSI 계산 시작
    for (let i = period; i < gains.length; i++) {

        // 🔹 Wilder 방식 EMA 평균 갱신 공식
        // 🔹 이전 평균에 가중치를 주어 노이즈 감소
        avgGain = (avgGain * (period - 1) + gains[i]) / period;
        avgLoss = (avgLoss * (period - 1) + losses[i]) / period;

        // 🔹 RS(Relative Strength) 계산
        // 🔹 평균 하락이 0이면 RSI는 100으로 처리
        const rs = avgLoss === 0 ? 100 : avgGain / avgLoss;

        // 🔹 RSI 공식 적용
        // 🔹 RSI = 100 - (100 / (1 + RS))
        const rsiValue = 100 - 100 / (1 + rs);

        // 🔹 RSI 값 배열에 추가
        rsi.push(rsiValue);
    }

    // 🔹 계산된 RSI 배열 반환
    // 🔹 (앞부분은 null, 이후부터 실제 RSI 값)
    return rsi;
}

 

 

ApexChart.js

import { useEffect, useState, useRef } from "react";
import axios from "axios";
import Chart from "react-apexcharts";
import MaCalc from "../utils/MaCalc";
import RsiCalc from "../utils/RsiCalc";

import { useTradeConfig } from "../utils/Minutes";

import { useTradeSignal } from "../utils/TradeSignal";


const MAX_CANDLES = 100;

function ApexChart() {
    const [candles, setCandles] = useState([]);
    const wsRef = useRef(null);
    const minute = useTradeConfig((s) => s.minute); // 
    console.log("선택된 minute:", minute);

    const action = useTradeSignal((s) => s.action);
    console.log("액션은 ?", action)

    /* =========================
     * 1️⃣ 초기 캔들 로딩 (REST API)
     * ========================= */
    const loadInitialCandles = async () => {
        try {
            const res = await axios.get(`http://localhost:8000/chart?minute=${minute}`);

            const sorted = res.data.sort(
                (a, b) =>
                    new Date(a.candle_date_time_kst) - new Date(b.candle_date_time_kst)
            );

            const formatted = sorted.map(item => ({
                timestamp: new Date(item.candle_date_time_kst).getTime(),
                open: item.opening_price,
                high: item.high_price,
                low: item.low_price,
                close: item.trade_price,
            }));

            setCandles(formatted.slice(-MAX_CANDLES));
        } catch (err) {
            console.error("초기 캔들 로딩 실패", err);
        }
    };



    /* =========================
     * 2️⃣ WebSocket 실시간 업데이트
     * ========================= */
    const connectWs = () => {
        const ws = new WebSocket(`ws://localhost:8000/ws/charts?minute=${minute}`);
        wsRef.current = ws;

        ws.onopen = () => console.log("WS 연결됨");

        ws.onmessage = (event) => {
            try {
                const t = JSON.parse(event.data);
                const price = t.trade_price;
                const ts = t.trade_timestamp;
                console.log("price ->", price)

                setCandles(prev => {
                    if (prev.length === 0) return prev;

                    const list = [...prev];
                    const last = { ...list[list.length - 1] };

                    const lastMin = Math.floor(last.timestamp / 60000);
                    const nowMin = Math.floor(ts / 60000);

                    /* 새 분 시작 → 새 캔들 */
                    if (nowMin !== lastMin) {
                        if (list.length >= MAX_CANDLES) list.shift(); // 이전 캔들 삭제
                        list.push({
                            timestamp: ts,
                            open: price,
                            high: price,
                            low: price,
                            close: price,
                        });
                        return list;
                    }

                    /* 같은 분 → 기존 캔들 업데이트 */
                    last.high = Math.max(last.high, price);
                    last.low = Math.min(last.low, price);
                    last.close = price;

                    list[list.length - 1] = last;
                    return list;
                });

            } catch (err) {
                console.error("WS JSON 파싱 오류:", err);
            }
        };

        ws.onclose = () => {
            console.log("WS 종료 → 재연결");
            setTimeout(connectWs, 2000);
        };
    };

    /* =========================
     * 3️⃣ 컴포넌트 최초 실행
     * ========================= */
    useEffect(() => {
        loadInitialCandles();
        connectWs();
        return () => {
            if (wsRef.current) wsRef.current.close();
        };
    }, []);

    if (candles.length === 0) return <div>Loading...</div>;

    /* =========================
     * 4️⃣ 시리즈용 데이터 변환
     * ========================= */
    const seriesData = candles.map(c => ({
        x: new Date(c.timestamp),
        y: [c.open, c.high, c.low, c.close],
    }));

    /* =========================
    * RSI 계산
    * ========================= */
    const rsiPeriod = 14;

    const rsiValues = RsiCalc(
        candles.map(c => c.close),
        rsiPeriod
    );

    // RSI 값 
    const rsiSeriesData = rsiValues
        .map((v, i) =>
            v == null ? null : { x: seriesData[i].x, y: v }
        )
        .filter(Boolean);

    // 최신 RSI 값 구하기.
    const latestRsi = rsiSeriesData.length
        ? Math.round(rsiSeriesData[rsiSeriesData.length - 1].y)
        : null;

    /* =========================
     * 5️⃣ 이동평균
     * ========================= */
    const makeMa = (n) =>
        MaCalc(candles.map(c => ({ trade_price: c.close })), n)
            .map((v, i) =>
                v == null ? null : { x: seriesData[i].x, y: v }
            )
            .filter(Boolean);

    /* =========================
     * 6️⃣ 볼린저밴드 계산
     * ========================= */
    const makeBollinger = (n = 20, k = 2) => {
        if (candles.length < n) return { upper: [], lower: [], middle: [] };

        const closes = candles.map(c => c.close);
        const middle = MaCalc(candles.map(c => ({ trade_price: c.close })), n);

        const upper = [];
        const lower = [];
        const middleSeries = [];

        for (let i = 0; i < closes.length; i++) {
            const x = seriesData[i].x;
            if (i < n - 1 || middle[i] == null) {
                continue; // 아직 볼린저 계산 불가 → skip
            }

            const window = closes.slice(i - n + 1, i + 1);
            const mean = middle[i];
            const std = Math.sqrt(window.reduce((acc, v) => acc + (v - mean) ** 2, 0) / n);

            upper.push({ x, y: mean + k * std });
            lower.push({ x, y: mean - k * std });
            middleSeries.push({ x, y: mean });
        }

        return { upper, lower, middle: middleSeries };
    };

    const { upper: bbUpper, lower: bbLower, middle: bbMiddle } = makeBollinger(20, 2);

    /* =========================
 * 7️⃣ 차트 시리즈
 * ========================= */

    // 1. 캔들
    const candleSeries = [
        {
            name: "캔들",
            type: "candlestick",
            data: seriesData,
        },
    ];

    // 2. 이동평균선
    const maSeries = [
        { name: "MA5", type: "line", data: makeMa(5) },
        { name: "MA10", type: "line", data: makeMa(10) },
        { name: "MA20", type: "line", data: makeMa(20) },
        { name: "MA60", type: "line", data: makeMa(60) },
    ];

    // 3. 볼린저 밴드
    const bollingerSeries = [
        { name: "BB 상단", type: "line", data: bbUpper, color: "#ff1c1c" },
        { name: "BB 중간", type: "line", data: bbMiddle, color: "#00aa00" },
        { name: "BB 하단", type: "line", data: bbLower, color: "#0000ff" },
    ];

    // 4. 최종 시리즈
    const series = [
        ...candleSeries,
        ...maSeries,
        ...bollingerSeries,
    ];

    /* =========================
     * 8️⃣ 차트 옵션
     * ========================= */
    const options = {
        chart: {
            type: "line",
            height: 450,
            animations: { enabled: false },
        },
        xaxis: {
            type: "datetime",
            tickAmount: 6,
            labels: {
                formatter: (value) => {
                    const date = new Date(value);
                    return date.toLocaleTimeString("ko-KR", {
                        hour: "2-digit",
                        minute: "2-digit",
                        hour12: false,
                        timeZone: "Asia/Seoul",
                    });
                },
            },
        },
        yaxis: {
            labels: {
                formatter: (value) => value == null ? "" : (value / 100000000).toFixed(3) + "억",
            },
        },
        stroke: { width: [1, 2, 2, 2, 2, 1, 1, 1] },
        tooltip: {
            shared: true,
            y: {
                formatter: (value) => value == null ? "" : (value / 100000000).toFixed(2) + "억",
            },
        },
    };


    // 🔹 임시 조건 결과 (나중에 실제 계산으로 교체)
    const conditionState = {
        bbLowerTouch: action === "BUY",
        bbUpperTouch: action === "SELL",
        rsiOversold: false,
        rsiOversbuy: false,
        volumeIncrease: false,
        trendNotDown: true,
        noPosition: true,
    };

    const buyConditions = [
        { key: "bbLowerTouch", label: "볼린저 하단 터치 (📈 매수)", passed: conditionState.bbLowerTouch },
        { key: "bbUpperTouch", label: "볼린저 상단 터치 (📉 매도)", passed: conditionState.bbUpperTouch },
        { key: "rsiOversold", label: "RSI 과매도 (📈 매수)", passed: conditionState.rsiOversold },
        { key: "rsiOverbuy", label: "RSI 과매수 (📉 매도)", passed: conditionState.rsiOverbuy },
        { key: "volumeIncrease", label: "거래량 증가", passed: conditionState.volumeIncrease },
        { key: "trendNotDown", label: "하락 추세 아님", passed: conditionState.trendNotDown },
        { key: "noPosition", label: "미보유 상태", passed: conditionState.noPosition },
    ];

    // RSI 전용 차트
    const rsiOptions = {
        chart: {
            type: "line",
            height: 160,
            animations: { enabled: false },
            toolbar: { show: false },
        },
        xaxis: {
            type: "datetime",
            tickAmount: 6,
            labels: {
                formatter: () => "",
            },
        },
        yaxis: {
            min: 0,
            max: 100,
            tickAmount: 4,
            labels: {
                formatter: (value) => Math.round(value), // 소수점 제거
            },
        },
        stroke: { width: 2 },
        colors: ["#7e57c2"],
        annotations: {
            yaxis: [
                { y: 70, borderColor: "#ef5350", label: { text: "70" } },
                { y: 30, borderColor: "#42a5f5", label: { text: "30" } },

                // 🔥 RSI 마지막 값 우측 고정
                ...(latestRsi != null
                    ? [{
                        y: latestRsi,
                        borderColor: "#7e57c2",
                        label: {
                            text: `${latestRsi}`,
                            position: "right",
                            offsetX: 8,
                            style: {
                                background: "#7e57c2",
                                color: "#fff",
                                fontSize: "12px",
                                fontWeight: 600,
                            },
                        },
                    }]
                    : []),
            ],
        },
        tooltip: {
            x: {
                formatter: (value) => {
                    const date = new Date(value);
                    return date.toLocaleString("ko-KR", {
                        hour: "2-digit",
                        minute: "2-digit",
                        second: "2-digit",
                        hour12: false,
                        timeZone: "Asia/Seoul",
                    });
                },
            },
            y: {
                formatter: (v) => v?.toFixed(2),
            },
        },
    };


    return (
        <div>
            <Chart
                options={options}
                series={series}
                height={450}
            />

            <Chart
                options={rsiOptions}
                series={[
                    {
                        name: `RSI(${rsiPeriod})`,
                        data: rsiSeriesData,
                    },
                ]}
                height={160}
            />

            <div style={{ margin: "16px 0", borderTop: "1px solid #ba4b4bff" }} />
            <div
                style={{
                    marginTop: 20,
                    padding: "12px 16px",
                    border: "1px solid #eee",
                    borderRadius: 8,
                    backgroundColor: "#fff",
                }}
            >
                <div style={{
                    display: "flex",
                    alignItems: "center",
                    gap: 8,
                    fontSize: 14,
                    fontWeight: "bold",
                    marginBottom: 8,
                }}
                >
                    <span>주문 조건 체크</span>
                    <span style={{ color: "#e53935", fontWeight: 600 }}>
                        {minute}분봉 실시간
                    </span>
                </div>

                {buyConditions.map(cond => (
                    <div
                        key={cond.key}
                        style={{
                            display: "flex",
                            justifyContent: "space-between",
                            alignItems: "center",
                            padding: "4px 0",
                            fontSize: 13,
                            color: cond.passed ? "#2E7D32" : "#999",
                        }}
                    >
                        <span>{cond.label}</span>
                        <span>{cond.passed ? "✔" : "✖"}</span>
                    </div>
                ))}
            </div>
        </div >
    );
}

export default ApexChart;

 

  • RSI 계산에 사용되는 기간은 보통 14로 사용되므로 rsiPeriod를 14로 지정하였다.
  • rsiValues에서는 종가 배열과 RSI 기간을 RsiCalc 함수에 전달하여 각 캔들의 RSI 값을 계산하였다.
  • rsiSeriesData에서는 계산된 RSI 값을 ApexChart에서 사용 가능한 { x, y } 형태의 시리즈 데이터로 변환하였다.
  • filter(Boolean)을 사용하여 RSI 계산이 불가능한 구간의 null 값을 제거하고, 실제 표시 가능한 RSI 값만 차트에 렌더링하였다.

 

 

최종 흐름 요약 ( 캔들 종가 -> RSI 계산 -> Apex차트용 변환 -> null 제거 -> 최신 RSI 값 추출 )

 

 

728x90
반응형

댓글