
🔷 전체 구조 한 줄 요약
과거 캔들 데이터(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
다음엔 볼린저밴드도 추가해보자.
'✨ python > FastAPI' 카테고리의 다른 글
| [FastAPI, React] 골든크로스, 데드크로스 ( Telegram Bot) (1) | 2025.12.21 |
|---|---|
| [FastAPI, React] Telegram Bot 연동하기 (0) | 2025.12.21 |
| [FastAPI, Recat] 이전 데이터 + 실시간 데이터(WebSocket) 합하기 (2) (0) | 2025.12.12 |
| [FastAPI, Recat] 이전 데이터 + 실시간 데이터(WebSocket) 합하기 (1) (0) | 2025.12.12 |
| [FastApi, React] 업비트 WebSocket으로 실시간 시세 불러오기 (0) | 2025.12.10 |
댓글