본문 바로가기
✨ python/FastAPI

[FastAPI, Recat] 이전 데이터 + 실시간 데이터(WebSocket) 합하기 (3)

by 환풍 2025. 12. 14.
728x90
반응형

결과화면


🔷 전체 구조 한 줄 요약

과거 캔들 데이터(REST) + 실시간 체결 데이터(WebSocket)
캔들 배열을 계속 업데이트
그 배열로 이동평균(MA)을 계산
ApexCharts로 캔들 + MA를 동시에 그림

 


CandleChart.js

import React, { useState, useEffect, useRef } from "react";
// Chart 컴포넌트 (ApexCharts를 React에서 사용하기 위함)
import Chart from "react-apexcharts";
// HTTP 통신용 라이브러리
import axios from "axios";

// 이동평균 계산 함수 (직접 만든 유틸 함수)
import MaCalc from "../utils/MaCalc";

function CandleChart() {

    /* =====================================================
     * 1️⃣ 캔들 데이터 상태 (차트에 그릴 모든 캔들)
     * ===================================================== */
    // candles : 캔들 배열
    // setCandles : candles를 업데이트하는 함수
    const [candles, setCandles] = useState([]);

    /* =====================================================
     * 2️⃣ WebSocket 객체 보관용 (렌더링과 무관)
     * ===================================================== */
    // useRef를 쓰는 이유:
    // - WebSocket 객체는 화면에 보여줄 값이 아님
    // - 값이 바뀌어도 리렌더링할 필요가 없음
    const wsRef = useRef(null);

    /* =====================================================
     * 3️⃣ 과거 캔들 데이터 불러오기 (REST API)
     * ===================================================== */
    const loadInitialCandles = async () => {
        try {
            // FastAPI 서버에서 과거 캔들 요청
            const res = await axios.get("http://localhost:8000/upbit/candle");

            // 시간 기준으로 오름차순 정렬 (과거 → 최신)
            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(), // ms
                open: item.opening_price,
                high: item.high_price,
                low: item.low_price,
                close: item.trade_price, // 종가
            }));

            // 상태에 저장 → 차트 자동 렌더링
            setCandles(formatted);

        } catch (err) {
            console.log("초기 캔들 오류 :", err);
        }
    };

    /* =====================================================
     * 4️⃣ WebSocket 연결 + 실시간 캔들 업데이트
     * ===================================================== */
    const connectWs = () => {
        // FastAPI에서 중계하는 WebSocket 연결
        const ws = new WebSocket("ws://127.0.0.1:8000/chart");

        // WebSocket 객체를 ref에 저장
        wsRef.current = ws;

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

        ws.onmessage = (event) => {
            try {
                // 서버에서 받은 문자열(JSON)을 객체로 변환
                const t = JSON.parse(event.data);

                const price = t.trade_price;       // 체결가
                const ts = t.trade_timestamp;      // 체결 시간 (ms)

                // 이전 candles 기준으로 업데이트 (중요!)
                setCandles((prev) => {
                    // 데이터가 없으면 그대로 반환
                    if (prev.length === 0) return prev;

                    // 마지막 캔들 복사
                    const last = { ...prev[prev.length - 1] };
                    const list = [...prev];

                    // 분 단위로 시간 비교
                    const lastMin = Math.floor(last.timestamp / 60000);
                    const nowMin = Math.floor(ts / 60000);

                    /* ---------- 새 분이 시작되면 새 캔들 ---------- */
                    if (nowMin !== lastMin) {
                        const newCandle = {
                            timestamp: ts,
                            open: price,
                            high: price,
                            low: price,
                            close: price,
                        };

                        const updated = [...list, newCandle];

                        // 캔들 최대 개수 제한 (성능 보호)
                        const MAX_CANDLES = 100;

                        return updated.length > MAX_CANDLES
                            ? updated.slice(updated.length - MAX_CANDLES)
                            : updated;
                    }

                    /* ---------- 같은 분이면 기존 캔들 업데이트 ---------- */
                    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 끊김 → 2초 후 재연결");
            setTimeout(connectWs, 2000);
        };
    };

    /* =====================================================
     * 5️⃣ 컴포넌트 최초 실행 시
     * ===================================================== */
    useEffect(() => {
        loadInitialCandles(); // 과거 캔들 로딩
        connectWs();          // 실시간 WebSocket 연결

        // 컴포넌트 종료 시 WebSocket 정리
        return () => {
            if (wsRef.current) wsRef.current.close();
        };
    }, []);

    /* =====================================================
     * 6️⃣ ApexCharts 캔들 데이터 변환
     * ===================================================== */
    const seriesData = candles.map((item) => ({
        x: new Date(item.timestamp),                // 시간
        y: [item.open, item.high, item.low, item.close], // OHLC
    }));

    /* =====================================================
     * 7️⃣ 이동평균 계산 (종가 기준)
     * ===================================================== */
    // MaCalc는 trade_price를 사용하므로 형태 맞춰줌
    const ma5 = MaCalc(candles.map(c => ({ trade_price: c.close })), 5);
    const ma10 = MaCalc(candles.map(c => ({ trade_price: c.close })), 10);
    const ma20 = MaCalc(candles.map(c => ({ trade_price: c.close })), 20);
    const ma60 = MaCalc(candles.map(c => ({ trade_price: c.close })), 60);

    /* =====================================================
     * 8️⃣ MA 데이터를 ApexCharts 라인용으로 변환
     * ===================================================== */
    const makeMaSeries = (maArray) =>
        maArray
            .map((v, i) =>
                v == null ? null : { x: seriesData[i].x, y: v }
            )
            .filter(Boolean);

    const ma5Series = makeMaSeries(ma5);
    const ma10Series = makeMaSeries(ma10);
    const ma20Series = makeMaSeries(ma20);
    const ma60Series = makeMaSeries(ma60);

    /* =====================================================
     * 9️⃣ 차트에 들어갈 모든 시리즈
     * ===================================================== */
    const series = [
        { name: "캔들", type: "candlestick", data: seriesData },
        { name: "MA5", type: "line", data: ma5Series },
        { name: "MA10", type: "line", data: ma10Series },
        { name: "MA20", type: "line", data: ma20Series },
        { name: "MA60", type: "line", data: ma60Series },
    ];

    /* =====================================================
     * 🔟 차트 옵션
     * ===================================================== */
    const chartOptions = {
        chart: {
            type: "line",               // mixed chart 기본 타입
            height: 450,
            animations: { enabled: false }, // 실시간이므로 애니메이션 off
        },
        xaxis: { type: "datetime" },
        yaxis: { tooltip: { enabled: true } },
        stroke: {
            width: [1, 2, 2, 2, 2],     // 캔들 얇게, MA 두껍게
        },
    };

    /* =====================================================
     * 1️⃣1️⃣ 렌더링
     * ===================================================== */
    return (
        <div>
            <h2>실시간 업비트 캔들 차트</h2>

            <Chart
                options={chartOptions}
                series={series}
                type="line"
                height={450}
            />
        </div>
    );
}

export default CandleChart;

1️⃣ import 부분

import React, { useState, useEffect, useRef } from "react";
  • React → 리액트 자체
  • useState → 화면에 보여줄 값 저장용
  • useEffect → 컴포넌트가 시작 / 종료될 때 실행할 코드
  • useRef → 값은 저장하지만 화면은 다시 안 그리게 하고 싶을 때
import Chart from "react-apexcharts";
  • ApexCharts를 리액트 컴포넌트처럼 쓰기 위해 가져옴
  • <Chart /> 태그로 차트를 그릴 수 있음
import axios from "axios";
  • 서버에서 HTTP 요청(GET) 할 때 사용
  • 여기서는 과거 캔들 데이터를 가져오는 용도
import MaCalc from "../utils/MaCalc";
  • 이동평균 계산 전용 함수
  • 차트랑 분리해두는 게 좋음 (재사용 가능 + 코드 깔끔)

 

2️⃣ 컴포넌트 시작

function CandleChart() {

 

👉 이 파일 전체가 하나의 화면 컴포넌트

 

3️⃣ 캔들 데이터 상태(state)

const [candles, setCandles] = useState([]);
  • candles
    현재 차트에 그릴 모든 캔들 데이터
  • setCandles
    캔들 데이터를 새로 바꿀 때 사용하는 함수

📌 중요

candles가 바뀌면 →
React가 자동으로 다시 렌더링
차트도 자동으로 다시 그림

 

 

4️⃣ WebSocket 저장소 (useRef)

const wsRef = useRef(null);
  • WebSocket 객체는
    • 화면에 보여줄 데이터 ❌
    • 그냥 연결만 유지하면 됨
  • useState로 하면 렌더링이 불필요하게 계속 발생

👉 그래서 useRef
✔ 값은 유지
✔ 화면은 다시 안 그림

 

5️⃣ 과거 캔들 불러오기 (REST API)

const loadInitialCandles = async () => {

👉 컴포넌트 시작 시 1번만 실행

const res = await axios.get("http://localhost:8000/upbit/candle");
  • FastAPI에서 과거 캔들 데이터 요청
  • 결과는 배열 (res.data)
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);

💡 여기서부터 차트가 그려지기 시작

 

6️⃣ WebSocket 연결 (실시간)

const connectWs = () => {
  const ws = new WebSocket("ws://127.0.0.1:8000/chart");
  • 실시간 체결 데이터 수신
  • 업비트 WS를 FastAPI가 중계
wsRef.current = ws;

 

WebSocket 객체를 useRef에 저장

 

메시지 수신

ws.onmessage = (event) => {
const t = JSON.parse(event.data);
const price = t.trade_price;
const ts = t.trade_timestamp;
  • 실시간 체결 가격
  • 체결 시각 (ms)

🧠 캔들 생성 로직 핵심

setCandles((prev) => {

 

❗ 반드시 이전 상태(prev) 기준으로 업데이트 (실시간에서는 이게 매우 중요)

 

마지막 캔들 가져오기

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

 

분이 바뀌었는지 확인

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

 

새 분 → 새 캔들 생성

if (nowMin !== lastMin) {
const newCandle = {
  timestamp: ts,
  open: price,
  high: price,
  low: price,
  close: price,
};

 

캔들 개수 제한

const MAX_CANDLES = 100;
return updated.length > MAX_CANDLES
  ? updated.slice(updated.length - MAX_CANDLES)
  : updated;

📌 왜 자르냐?

  • 무한히 늘어나면 브라우저 터짐

같은 분 → 캔들 업데이트

last.high = Math.max(last.high, price);
last.low = Math.min(last.low, price);
last.close = price;

 

7️⃣ useEffect (시작 시 실행)

useEffect(() => {
  loadInitialCandles();
  connectWs();

  return () => {
    if (wsRef.current) wsRef.current.close();
  };
}, []);
  • [] → 딱 한 번만 실행
  • 컴포넌트 종료 시
    • WebSocket 닫기 (메모리 누수 방지)

8️⃣ ApexCharts 캔들 데이터

const seriesData = candles.map((item) => ({
  x: new Date(item.timestamp),
  y: [item.open, item.high, item.low, item.close],
}));

 

📌 ApexCharts 캔들 필수 구조

{
  x: 시간,
  y: [시가, 고가, 저가, 종가]
}

 

9️⃣ 이동평균 계산 (⭐ 핵심 ⭐)

const ma5 = MaCalc(candles.map(c => ({ trade_price: c.close })), 5);
  • MaCalc는 trade_price를 사용함
  • 그런데 우리는 close라는 이름으로 들고 있음

👉 그래서 형태 맞추기용 변환


MaCalc.js

// period일 이동평균 계산 함수 (종가 기준)
export default function MaCalc(data, period) {

    const result = [];
    // result 배열의 길이는 항상 data.length와 동일

    for (let i = 0; i < data.length; i++) {

        // 아직 period만큼 데이터가 없으면 평균 계산 불가
        if (i < period - 1) {
            result.push(null); // 차트에서 자연스럽게 끊김
            continue;
        }

        // 현재 index 기준으로 period개만큼 잘라냄
        const slice = data.slice(i - period + 1, i + 1);

        // 종가(trade_price) 합계
        const sum = slice.reduce(
            (acc, cur) => acc + cur.trade_price,
            0
        );

        // 평균
        const avg = sum / period;

        // 소수점 2자리로 저장
        result.push(Number(avg.toFixed(2)));
    }

    return result;
}

🔷 MaCalc.js 완전 해설

export default function MaCalc(data, period) {
  • data → { trade_price } 배열
  • period → 5, 10, 20, 60
for (let i = 0; i < data.length; i++) {

각 캔들마다 이평 하나 계산

 

if (i < period - 1) {
  result.push(null);
  continue;
}

📌 처음에는 평균 못 냄
👉 null → 차트에서 자연스럽게 안 그림

 

필요한 구간만 자르기

const slice = data.slice(i - period + 1, i + 1);

 

평균 계산

const sum = slice.reduce((acc, cur) => acc + cur.trade_price, 0);
const avg = sum / period;

 

 

🔟 MA를 차트용 데이터로 변환

const ma5Series = ma5
  .map((v, i) =>
    v == null ? null : { x: seriesData[i].x, y: v }
  )
  .filter(Boolean);

📌 왜 이렇게 복잡하냐?

  • ApexCharts는 {x, y} 구조 필요
  • null은 제거해야 에러 안 남

1️⃣1️⃣ 차트 시리즈 구성

const series = [
  { name: "캔들", type: "candlestick", data: seriesData },
  { name: "MA5", type: "line", data: ma5Series },
];

👉 캔들 + 선 차트 혼합

 

1️⃣2️⃣ 차트 옵션

chart: {
  type: "line",
  animations: { enabled: false },
}

📌 mixed chart에서는 line이 기본

 

✅ 최종 요약

✔ REST → 과거 데이터
✔ WS → 실시간 데이터
✔ candles 하나로 모든 계산
✔ MA는 항상 candles 기준
✔ null 처리 필수
✔ ApexCharts는 {x, y} 구조 필수


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

const MAX_CANDLES = 10;

function ApexChart() {
    const [candles, setCandles] = useState([]);
    const wsRef = useRef(null);

    /* =========================
     * 1️⃣ 초기 캔들 (REST)
     * ========================= */
    const loadInitialCandles = async () => {
        try {
            const res = await axios.get("http://localhost:8000/chart");

            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");
        wsRef.current = ws;

        ws.onmessage = (event) => {
            try {
                const t = JSON.parse(event.data);

                const price = t.trade_price;
                const ts = t.trade_timestamp;

                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️⃣ Apex 캔들 변환
     * ========================= */
    const seriesData = candles.map(c => ({
        x: new Date(c.timestamp),
        y: [c.open, c.high, c.low, c.close],
    }));

    /* =========================
     * 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);

    const series = [
        { name: "캔들", type: "candlestick", data: seriesData },
        { 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) },
    ];

    /* =========================
     * 6️⃣ 옵션
     * ========================= */
    const options = {
        chart: {
            type: "line",
            height: 450,
            animations: { enabled: false },
        },
        xaxis: { type: "datetime" },
        stroke: { width: [1, 2, 2, 2, 2] },
        tooltip: { shared: true },
    };

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

export default ApexChart;

-- 빗썸에서도 테스트

 

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

 

[FastAPI, Recat] 차트에 볼린저밴드 추가하기

결과화면ApexChart.jsimport { useEffect, useState, useRef } from "react";import axios from "axios";import Chart from "react-apexcharts";import MaCalc from "../utils/MaCalc";const MAX_CANDLES = 100;function ApexChart() { const [candles, setCandles] = use

bright-landscape.tistory.com

다음엔 볼린저밴드도 추가해보자.

728x90
반응형

댓글