본문 바로가기
Python for Beginners

정규분포 기반 주가 분석 프로그램

by Records that rule memory 2025. 5. 21.
728x90

주요기능

Tkinter 기반 GUI

  • Python 표준 GUI 라이브러리인 Tkinter(ttk)를 사용해 윈도우, 탭, 콤보박스, 리스트박스, 버튼 등으로 구성된 인터페이스 제공
  • Notebook 탭으로 여러 차트를 손쉽게 전환 가능

설정 파일로 관리되는 종목 리스트

  • stocks.properties 파일을 읽어 [STOCK] 섹션에 정의된 종목 코드와 이름을 로드
  • 콤보박스에서 미리 정의된 종목 선택 또는 직접 코드 입력으로 조회

FinanceDataReader를 이용한 데이터 취득

  • 지정한 종목의 최근 2년치 일별 시세(종가)를 FinanceDataReader로 한 번에 받아옴
  • 데이터를 캐시에 저장해, 동일 종목을 다시 조회할 때 네트워크 호출을 최소화

4가지 핵심 기술적 지표 계산 및 시각화

  • Bollinger Bands: 이동평균±2표준편차 밴드로 과매수·과매도 구간 파악
  • Z-score: 가격이 이동평균으로부터 표준편차 단위로 얼마나 벗어났는지 수치화
  • Percent B (%B): 현재가가 밴드 내에서 어느 위치에 있는지 백분율로 표시
  • Mean Reversion Probability: 가격이 평균으로 회귀할 확률을 표준정규분포로 추정

차트 탭별 대화형 시각화

  • 각 탭마다 Matplotlib 차트를 Tkinter 위에 내장(TkAgg)
  • 툴바로 확대·이동·저장 기능 제공
  • 최신 지표 값을 제목 또는 라벨에 표시해 한눈에 파악

종목 순서 편집기

  • “종목 순서 변경” 버튼으로 별도 창을 띄워 종목 리스트를 추가·순서 변경·저장
  • 저장 시 stocks.properties를 덮어써서 다음 실행에 반영

코드 분석

모듈 및 라이브러리 로딩

Tkinter & ttk

  • tkinter와 ttk 위젯을 이용해 데스크탑 GUI를 구성합니다.
  • messagebox로 에러/정보 팝업을 띄웁니다.

Pandas & FinanceDataReader

  • pandas로 시계열 데이터 처리 (DataFrame).
  • FinanceDataReader (fdr) 로 한국 주식 시세를 손쉽게 가져옵니다.

날짜/시간, 설정 파일

  • datetime, timedelta로 날짜 계산.
  • configparser로 stocks.properties에서 종목 목록을 읽고 저장합니다.

Matplotlib 설정

  • 백엔드로 TkAgg를 사용해 Tkinter 위젯 안에 그래프를 그립니다.
  • 한글 폰트(맑은 고딕)와 음수 기호 깨짐 방지 설정을 수행합니다.

fetch_data 함수

def fetch_data(symbol: str, period: int = 20) -> pd.DataFrame:
    ...

입력:

  • symbol: "035420.KS" 또는 "035420" 형태
  • period: 기본 20일(이동평균 계산 기간)

처리 흐름:

  • .KS 등 마켓 구분자 제거
  • 오늘 날짜 기준 2년 전부터 데이터 조회
  • Close 종가 시리즈 추출
  • 이동평균(MA) & 표준편차(Std) 계산
  • Z-score, Percent B, Mean-Reversion Probability(MR Prob) 계산
    • Z-score = (현재가 – MA) / Std
    • %B = (현재가 – ( MA – 2 Std )) / (4 Std)
    • MR Prob = 2·Φ(|Z|) – 1 (표준 정규분포 CDF 이용)
  • 출력: MA, Std, Z-score, %B, MR Prob 컬럼이 추가된 DataFrame

StockApp 클래스: GUI 애플리케이션

1. 초기화 (__init__)

  • 윈도우 설정
    • 제목, 초기 크기(1000×700)
  • 설정 파일 로딩
    • stocks.properties의 [STOCK] 섹션을 읽어
      • self.stock_map: {코드: 이름}
      • self.symbols, self.names 리스트 생성
  • 스타일
    • ttk.Style로 탭 글꼴, 선택된 탭 색 지정
  • 데이터 캐시:
    • self.data_cache에 종목별 DataFrame 저장 → 재조회 방지
  • 설명 문구 매핑
    • 탭별로 아래 설명을 self.desc_map에 저장
      • 예) 'Bollinger': “볼린저 밴드는 이동평균선에서 ±2σ …”
  • 상단 컨트롤
    • 콤보박스: 설정 파일 기반 종목 선택
    • Entry: 직접 종목 코드 입력
    • 버튼: 조회 및 “종목 순서 변경”
  • 탭 위젯
    • Notebook에 4개 탭 등록: %B, Bollinger, Z-score, MR Prob
    • 각 탭에 차트 캔버스(FigureCanvasTkAgg), 툴바, 설명 라벨을 동적으로 생성

2. 데이터 캐시 관리 (get_cached_data)

  • 키(symbol_period)로 캐시 확인
  • 없으면 fetch_data 호출, 실패 시 빈 DataFrame 저장 및 에러 팝업

3. UI 갱신 (update)

콤보박스 선택 또는 엔터/버튼 클릭 시 호출

절차:

  • 캐시 초기화
  • 코드/이름 결정 (entry 우선)
  • 각 탭별로
    • 기존 캔버스·툴바·라벨 제거
    • 차트 함수 호출(create_*_figure) → Figure 반환
    • FigureCanvasTkAgg와 NavigationToolbar2Tk 배치
    • 하단에 설명 라벨 생성

4. 차트 생성 메서드

공통:

  • get_cached_data로 DataFrame 가져옴
  • Figure(figsize=(8,4), dpi=100) → ax = fig.add_subplot(111)
  • 선/영역 그리기, 제목·범례·그리드 설정, fig.tight_layout()

create_bollinger_figure

  • 종가, MA, ±2σ 선
  • 밴드 영역(fill_between) 반투명 채우기

create_zscore_figure

  • Z-score 선
  • ±2 기준선(axhline)

create_percentb_figure

  • %B 선, 기준선 1과 0
  • 제목에 최신 %B 값 표시

create_mrprob_figure

  • MR Prob 선, Y축 [0,1] 제한
  • 제목에 최신 확률 값 표시

5. OrderEditor: 종목 순서 편집기

  • Toplevel 창
    • 리스트박스에 현재 종목 순서 표시
  • 추가
    • 코드/이름 입력 후 “추가” 버튼
    • 중복 검사, 리스트 갱신
  • 순서 이동
    • “위로”/“아래로” 버튼으로 선택 항목 교환
  • 저장
    • configparser로 [STOCK] 섹션 재작성
    • 파일에 출력 → “프로그램 재시작 시 적용” 팝업

6. 실행 흐름

if __name__ == "__main__":
    main()
  • main()에서 tk.Tk() 생성 후 StockApp 인스턴스 초기화
  • root.mainloop()로 이벤트 루프 진입
728x90

%B 차트
Bollinger Bands 차트
종목 순서 변경

stock_chart.py

import tkinter as tk
from tkinter import ttk, messagebox
import pandas as pd

# 국내 주식 데이터를 편리하게 가져오기 위한 FinanceDataReader 라이브러리
import FinanceDataReader as fdr

from datetime import datetime, timedelta
import configparser

# Matplotlib 설정 (TkAgg 백엔드 사용)
import matplotlib
matplotlib.use("TkAgg")
from matplotlib.figure import Figure
import matplotlib.font_manager as fm
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk

import math

# ——— 설정 파일 읽기 (stocks.properties) ———
# 파일 예시 (stocks.properties):
# [STOCK]
# 035420=NAVER
# 323410=카카오뱅크
# 326030=SK바이오팜
# 005610=SPC삼립
# 017670=SK텔레콤


# ——— 한글 폰트 설정 (Windows) ———
font_path = r'C:\Windows\Fonts\malgun.ttf'  # '맑은 고딕' 폰트 경로
font_name = fm.FontProperties(fname=font_path).get_name()
# 전역 폰트 패밀리로 설정
matplotlib.rc('font', family=font_name)
# 음수 기호 깨짐 방지
matplotlib.rcParams['axes.unicode_minus'] = False

PROP_FILE = 'stocks.properties'


def fetch_data(symbol: str, period: int = 20) -> pd.DataFrame:
    """
    오늘까지 2년치 국내 주식 데이터를 가져오고,
    볼린저 밴드와 Z-score, %B, Mean Reversion Probability 계산
    """
    # symbol에 '.KS' 등 붙었으면 제거하여 종목 코드만 추출
    code = symbol.split('.')[0] if '.' in symbol else symbol

    # 날짜 설정: 오늘까지, 2년 전부터
    end_date   = datetime.today().date()
    start_date = end_date - pd.DateOffset(years=2)

    # FinanceDataReader로 국내 주식 시세 조회
    df = fdr.DataReader(code, start_date, end_date)
    if df.empty:
        raise ValueError(f"데이터를 불러올 수 없습니다: {code}")

    close = df['Close']  # 종가 Series

    # 이동평균과 표준편차 계산 (기본 기간=20일)
    ma  = close.rolling(window=period).mean()
    std = close.rolling(window=period).std()

    # DataFrame에 지표 컬럼 추가
    df['MA']      = ma
    df['Std']     = std
    df['Z-score'] = (close - ma) / std

    # Percent B: (Price - lower_band) / (upper_band - lower_band)
    df['%B'] = (close - (ma - 2 * std)) / (4 * std)

    # Mean Reversion Probability: 2*Phi(|Z|)-1
    # 표준 정규분포 CDF Phi(x) = 0.5*(1+erf(x/sqrt(2)))
    df['MR Prob'] = 2 * 0.5 * (1 + df['Z-score'].abs().apply(lambda x: math.erf(x / math.sqrt(2)))) - 1

    return df

class StockApp:
    """메인 GUI 애플리케이션 클래스"""
    def __init__(self, master):
        self.master = master
        master.title("주식 지표 대시보드")
        master.geometry('1000x700')  # 초기 창 크기 설정

        self.config = configparser.ConfigParser()
        self.config.read(PROP_FILE, encoding='utf-8')
        self.stock_map = dict(self.config.items('STOCK'))
        self.symbols = list(self.stock_map.keys())
        self.names = list(self.stock_map.values())

        style = ttk.Style()
        style.theme_use('default')

        style.configure(
            'TNotebook.Tab',
            padding=[10,5],
            font=('맑은 고딕', 11),
        )

        style.map(
            'TNotebook.Tab',
            background=[('selected','#0078D7')],
            forground=[('selected','white')],
        )

        # 탭별 툴바 및 캔버스 저장용 딕셔너리
        self.toolbars = {}
        self.canvases = {}

        self.labels = {}

        self.data_cache = {}  # 종목 코드별 데이터 캐시 저장소

        # 설명 문구 매핑
        self.desc_map = {
            'Bollinger': '볼린저 밴드는 이동평균선에서 ±2σ 범위를 표시하여 과매수/과매도 구간을 파악합니다.',
            'Z-score': 'Z-score는 현재 가격이 이동평균에서 몇 표준편차 떨어져 있는지를 나타냅니다.',
            '%B': '주가가 볼린저 밴드 내 어느 위치에 있는지를 퍼센트로 표시합니다.',
            'MR Prob': '평균 회귀 확률은 가격이 평균으로 회귀할 확률을 추정합니다.'
        }
        # 상단 프레임: 종목 선택 및 입력
        top = ttk.Frame(master)
        top.pack(fill='x', padx=10, pady=5)

        # 콤보박스: 설정 파일 기반 종목 리스트
        ttk.Label(top, text="종목 선택:", font=(None,11)).pack(side='left')

        # 콤보박스 설정
        self.combo = ttk.Combobox(
            top,
            values=self.names,
            state='readonly',
            font=('맑은 고딕', 11),
        )

        self.combo.current(0)
        self.combo.pack(side='left', padx=5)
        self.combo.bind('<<ComboboxSelected>>', self.update)

        # 직접 종목 코드 입력 가능
        ttk.Label(
            top,
            text=" 또는 코드 입력:",
            font=('맑은 고딕', 11),
        ).pack(side='left')

        self.entry = ttk.Entry(top, font=('맑은 고딕', 11), width=12)
        self.entry.pack(side='left', padx=5)
        self.entry.bind('<Return>', self.update)
        ttk.Button(top, text="조회", command=self.update).pack(side='left', padx=5)
        ttk.Button(
            top,
            text="종목 순서 변경",
            command=self.open_editor,
        ).pack(side='right', padx=5)

        # 탭 위젯 생성
        self.nb = ttk.Notebook(master)
        self.nb.pack(fill='both', expand=True)

        # 탭 구성: 제목과 Figure 생성 함수 매핑
        tabs = [
            ("%B", self.create_percentb_figure),
            ("Bollinger", self.create_bollinger_figure),
            ("Z-score",   self.create_zscore_figure),
            ("MR Prob",   self.create_mrprob_figure)
        ]
        # 각 탭 프레임 추가 및 초기화
        for title, _ in tabs:
            frame = ttk.Frame(self.nb)
            self.nb.add(frame, text=title)
            self.toolbars[title] = None
            self.canvases[title] = None
            self.labels[title] = None
        self.tabs = dict(tabs)

        # 초기 차트 로드
        self.update()

    def get_cached_data(self, symbol: str, period: int = 20) -> pd.DataFrame:
        """종목 데이터 캐시: 이미 있으면 재사용, 없으면 fetch"""
        key = f"{symbol}_{period}"
        if key not in self.data_cache:
            try:
                self.data_cache[key] = fetch_data(symbol, period)
            except Exception as e:
                messagebox.showerror("데이터 오류", str(e))
                self.data_cache[key] = pd.DataFrame()
        return self.data_cache[key]

    def update(self, event=None):
        """콤보박스 또는 입력창 변경 시 호출되어 모든 탭 차트 갱신"""
        # 입력창 우선, 없으면 콤보박스 선택값 사용

        self.data_cache.clear()

        code = self.entry.get().strip() or self.symbols[self.combo.current()]
        name = code if self.entry.get().strip() else self.names[self.combo.current()]

        # 각 탭 갱신
        for title, func in self.tabs.items():
            frame = self.nb.nametowidget(self.nb.tabs()[list(self.tabs.keys()).index(title)])
            # 기존 요소 제거
            if self.canvases[title]:
                self.canvases[title].get_tk_widget().destroy()
            if self.toolbars[title]:
                self.toolbars[title].destroy()
            if self.labels[title]:
                self.labels[title].destroy()

            # 차트 생성 및 배치
            fig = func(code, name)
            canvas = FigureCanvasTkAgg(fig, master=frame)
            toolbar = NavigationToolbar2Tk(canvas, frame)
            toolbar.update()
            toolbar.pack(side='top', fill='x')
            canvas.get_tk_widget().pack(fill='both', expand=True)

            # 설명 라벨 추가
            lbl = ttk.Label(frame, text=self.desc_map[title], wraplength=600, font=(None,10))
            lbl.pack(side='bottom', fill='x', pady=5)

            # 참조 저장
            self.canvases[title] = canvas
            self.toolbars[title] = toolbar
            self.labels[title] = lbl


    def create_bollinger_figure(self, symbol: str, name: str, period=20) -> Figure:
        """단일 종목의 볼린저 밴드 차트 Figure 생성"""
        df = self.get_cached_data(symbol, period)

        # Figure 객체 생성 (크기: 8x4인치, 해상도:100dpi)
        fig = Figure(figsize=(8, 4), dpi=100)
        ax = fig.add_subplot(111)  # 단일 플롯

        # 차트 그리기: 종가, 이동평균, 상단/하단 밴드
        ax.plot(df.index, df['Close'], label='종가')
        ax.plot(df.index, df['MA'],    label=f'MA{period}')
        ax.plot(df.index, df['MA'] + 2*df['Std'], linestyle='--', label='+2σ')
        ax.plot(df.index, df['MA'] - 2*df['Std'], linestyle='--', label='-2σ')
        ax.fill_between(
            df.index,
            df['MA'] - 2*df['Std'],
            df['MA'] + 2*df['Std'],
            alpha=0.1  # 반투명 채우기
        )

        # 제목, 범례, 격자 설정
        ax.set_title(f"{name} - Bollinger Bands", fontsize=14, fontweight='bold', pad=8)
        ax.legend(loc='upper left', fontsize='small')
        ax.grid(True, linestyle=':', linewidth=0.5, alpha=0.7)

        # 레이아웃 최적화
        fig.tight_layout(pad=2.0)
        return fig


    def create_zscore_figure(self, symbol: str, name: str, period=20) -> Figure:
        """단일 종목의 Z-score 차트 Figure 생성"""
        df = self.get_cached_data(symbol, period)

        fig = Figure(figsize=(8, 4), dpi=100)
        ax = fig.add_subplot(111)

        # Z-score 선 그리기
        ax.plot(df.index, df['Z-score'], label='Z-score', linewidth=1)
        # 과매수/과매도 기준선 (±2σ)
        ax.axhline(2, linestyle='--', label='+2σ', linewidth=0.8, color='red')
        ax.axhline(-2, linestyle='--', label='-2σ', linewidth=0.8, color='blue')

        # 제목, 축 레이블, 범례, 격자
        ax.set_title(f"{name} - Z-score", fontsize=14, fontweight='bold', pad=8)
        ax.set_ylabel('Z-score')
        ax.legend(loc='upper right', fontsize='small')
        ax.grid(True, linestyle=':', linewidth=0.5, alpha=0.7)

        fig.tight_layout(pad=2.0)
        return fig


    def create_percentb_figure(self, symbol: str, name: str, period=20) -> Figure:
        """Percent B 차트 Figure 생성"""
        df = self.get_cached_data(symbol, period)
        last = df.iloc[-1]  # 마지막 행 (오늘 데이터)

        fig = Figure(figsize=(8, 4), dpi=100)
        ax = fig.add_subplot(111)

        # %B 선 그리기 및 기준선
        ax.plot(df.index, df['%B'], label='%B')
        ax.axhline(1, linestyle='--', color='red',  label='1')
        ax.axhline(0, linestyle='--', color='blue', label='0')

        # 제목에 마지막 %B 값 표시
        ax.set_title(
            f"{name} - Percent B ({last.name.date()}: {last['%B']:.2f})",
            fontsize=14, fontweight='bold'
        )
        ax.set_ylabel('%B')
        ax.legend(loc='upper right', fontsize='small')
        ax.grid(True, linestyle=':', linewidth=0.5, alpha=0.7)

        fig.tight_layout()
        return fig


    def create_mrprob_figure(self, symbol: str, name: str, period=20) -> Figure:
        """평균 회귀 확률 차트 Figure 생성"""
        df = self.get_cached_data(symbol, period)
        last = df.iloc[-1]

        fig = Figure(figsize=(8, 4), dpi=100)
        ax = fig.add_subplot(111)

        # MR Prob 선 그리기
        ax.plot(df.index, df['MR Prob'], label='MR Prob')
        ax.set_ylim(0, 1)  # 확률 범위

        # 제목에 마지막 MR Prob 값 표시
        ax.set_title(
            f"{name} - Mean Reversion Prob ({last.name.date()}: {last['MR Prob']:.2f})",
            fontsize=14, fontweight='bold'
        )
        ax.set_ylabel('Probability')
        ax.legend(loc='upper right', fontsize='small')
        ax.grid(True, linestyle=':', linewidth=0.5, alpha=0.7)

        fig.tight_layout()
        return fig

    def open_editor(self):
        OrderEditor(self)

class OrderEditor:
    def __init__(self, app):
        self.app = app
        self.top = tk.Toplevel(app.master)
        self.top.title("종목 순서 편집기")
        self.top.geometry("350x600")

        self.stocks = list(self.app.stock_map.items())
        self.listbox = tk.Listbox(self.top, font=("맑은 고딕", 11))
        self.listbox.pack(fill='both', expand=True, padx=10, pady=10)
        for code, name in self.stocks:
            self.listbox.insert(tk.END, f"{code} - {name}")

        # 추가 입력창
        entry_frame = ttk.Frame(self.top)
        entry_frame.pack(pady=5)

        self.code_entry = ttk.Entry(entry_frame, width=10)
        self.code_entry.pack(side='left', padx=5)
        self.name_entry = ttk.Entry(entry_frame, width=15)
        self.name_entry.pack(side='left', padx=5)

        ttk.Button(entry_frame, text="추가", command=self.add_stock).pack(side='left')

        btn_frame = ttk.Frame(self.top)
        btn_frame.pack()
        ttk.Button(btn_frame, text="위로", command=self.move_up).pack(side='left', padx=5)
        ttk.Button(btn_frame, text="아래로", command=self.move_down).pack(side='left', padx=5)
        ttk.Button(btn_frame, text="저장", command=self.save).pack(side='left', padx=5)

    def add_stock(self):
        code = self.code_entry.get().strip()
        name = self.name_entry.get().strip()

        if not code or not name:
            messagebox.showwarning("입력 오류", "종목 코드와 이름을 모두 입력하세요.")
            return

        if code in dict(self.stocks):
            messagebox.showwarning("중복", f"{code}는 이미 존재합니다.")
            return

        self.stocks.append((code, name))
        self.refresh(len(self.stocks) - 1)
        self.code_entry.delete(0, tk.END)
        self.name_entry.delete(0, tk.END)

    def move_up(self):
        idx = self.listbox.curselection()
        if idx and idx[0] > 0:
            i = idx[0]
            self.stocks[i-1], self.stocks[i] = self.stocks[i], self.stocks[i-1]
            self.refresh(i-1)

    def move_down(self):
        idx = self.listbox.curselection()
        if idx and idx[0] < len(self.stocks) - 1:
            i = idx[0]
            self.stocks[i], self.stocks[i+1] = self.stocks[i+1], self.stocks[i]
            self.refresh(i+1)

    def refresh(self, selected_idx=0):
        self.listbox.delete(0, tk.END)
        for code, name in self.stocks:
            self.listbox.insert(tk.END, f"{code} - {name}")
        self.listbox.select_set(selected_idx)

    def save(self):
        config = self.app.config
        config.remove_section('STOCK')
        config.add_section('STOCK')
        for code, name in self.stocks:
            config.set('STOCK', code, name)
        with open(PROP_FILE, 'w', encoding='utf-8') as f:
            config.write(f)
        messagebox.showinfo("저장 완료", "종목 순서가 저장되었습니다.\n프로그램을 다시 시작하면 적용됩니다.")

def main():
    # 애플리케이션 실행 진입점
    root = tk.Tk()
    StockApp(root)
    root.mainloop()


if __name__ == "__main__":
    main()

stocks.properties

[STOCK]
035420 = NAVER
323410 = 카카오뱅크
005930 = 삼성전자
066570 = LG전자
...
728x90