본문 바로가기
✨ python/FastAPI

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

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

 

결과화면


ApexChart.js

import { 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] = useState([]);
    const wsRef = useRef(null);

    /* =========================
     * 1️⃣ 초기 캔들 로딩 (REST API)
     * ========================= */
    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.onopen = () => console.log("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️⃣ 시리즈용 데이터 변환
     * ========================= */
    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);

    /* =========================
     * 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️⃣ ApexCharts 시리즈
     * ========================= */
    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) },
        { name: "BB 상단", type: "line", data: bbUpper, color: "#ff1c1cff" },
        { name: "BB 중간", type: "line", data: bbMiddle, color: "#00aa00" },
        { name: "BB 하단", type: "line", data: bbLower, color: "#0000ff" },
    ];

    /* =========================
     * 8️⃣ 차트 옵션
     * ========================= */
    const options = {
        chart: {
            type: "line",
            height: 450,
            animations: { enabled: false },
        },
        xaxis: { type: "datetime" },
        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) + "억",
            },
        },
    };

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

export default ApexChart;

 

① 과매수 / 과매도 판단

  • 가격이 상단선 위로 치솟으면 과매수
  • 가격이 하단선 아래로 떨어지면 과매도

② 변동성 확인

  • 밴드 폭이 넓으면 변동성 ↑, 좁으면 변동성 ↓
  • 밴드가 좁아지는 구간 → 이후 급격한 변동 가능성

③ 추세 추정

  • 가격이 상단 밴드를 타고 상승 → 강한 상승 추세
  • 가격이 하단 밴드를 타고 하락 → 강한 하락 추세

④ 트레이딩 전략 예시

  • 반등 전략: 밴드 하단에서 매수 → 중앙선까지 상승
  • 돌파 전략: 밴드 수축 → 상단 밴드 돌파 → 매수

볼린저 밴드의 중앙선 기준은 20일 이동평균을 가장 흔히 사용한다. 

왜냐, 20일은 한 달(거래일 기준 약 한 달) 정도를 반영한다. 가격 평균이 과도하게 단기적이거나 장기적이지 않다. 또한, 표준편차의 2배로, 가격의 95퍼 정도가 밴드 안에서 들어오는 통계적 이유이다.


 

1️⃣ 함수 정의

const makeBollinger = (n = 20, k = 2) => { ... }

 

  • n = 20 → 기간. 보통 20일(혹은 20분 등) 이동평균을 기준으로 볼린저밴드를 계산한다.
  • k = 2 → 표준편차 배수. 상단 밴드 = 평균 + 2 * 표준편차, 하단 밴드 = 평균 - 2 * 표준편차.
  • 이 함수는 candles 배열을 바탕으로 볼린저밴드 상단, 하단, 중간값을 계산해서 리턴한다.

 

2️⃣ 초기 체크

if (candles.length < n) return { upper: [], lower: [], middle: [] };

 

 

  • 캔들 데이터가 n개보다 적으면 볼린저밴드 계산 불가
  • 안전하게 빈 배열 반환

 

3️⃣ 데이터 준비

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

 

 

  • closes → 모든 캔들의 종가만 뽑아서 배열로 준비
  • middle → 중간 이동평균(MA) 계산.
    • MaCalc는 이전에 만든 함수로, n기간 동안 종가의 이동평균을 계산한다.
    • 볼린저밴드의 중앙선이 이 값이다.

 

4️⃣ 결과 배열 준비

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

 

 

  • 계산된 각 캔들별 볼린저 상단, 하단, 중앙 데이터를 저장할 배열이다.
  • ApexCharts는 {x, y} 형태로 받으므로 이 배열에 {x: 시간, y: 값} 형태로 넣는다.

 

5️⃣ 메인 루프 (볼린저 계산)

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 });
}

 

 

  1. x = seriesData[i].x
    • 각 캔들의 시간 정보(Date)를 가져옵니다.
    • ApexCharts 시리즈는 {x: Date, y: 값} 형태여야 하므로 필요합니다.
  2. if (i < n - 1 || middle[i] == null) continue;
    • n개 미만이면 이동평균 계산 불가 → skip
    • middle[i]가 null이면 계산 불가 → skip
  3. const window = closes.slice(i - n + 1, i + 1);
    • 현재 캔들을 포함한 n개 구간 종가
    • 표준편차 계산에 필요
  4. const mean = middle[i];
    • 중앙선(중간 이동평균)
  5. 표준편차 계산:
const std = Math.sqrt(window.reduce((acc, v) => acc + (v - mean) ** 2, 0) / n);

 

  • n기간 동안 각 종가가 평균에서 얼마나 떨어져 있는지 계산
  • Math.sqrt → 제곱근 → 표준편차

  6. 볼린저밴드 상/하단 계산:

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

 

  • 상단선 = 중앙선 + k * 표준편차
  • 하단선 = 중앙선 - k * 표준편차
  • 중앙선 = 중간 이동평균

 

6️⃣ 최종 반환

return { upper, lower, middle: middleSeries };

 

 

  • 계산된 상단, 하단, 중앙선 배열을 객체로 반환
  • ApexCharts에서 각각 시리즈로 사용할 수 있음

 

728x90
반응형

댓글