대시보드로 데이터 이관하기

kpark2000·2024년 7월 29일

대시보드 소개

시스템 트레이딩을 했었는데. 내 거래 내역을 조회용으로 Cloud-Hosted 웹 사이트에 올리곤 했었다. 실시간으로 쏘는 게 아니라 서버에 모아두고 한번에 대량 insert 했는데. 배치로 올리는 게 아니라 eyeball checking 한번 하고 버튼 눌러서 올렸다.

첫 번째 화면에서 거래한거 로우 데이터 출력하면. 좌측 하단 코너에 빨간 버튼 눌러서 자동 전처리하고. 한번 더 버튼을 눌러서 올리는 시스템이다. 그리고 가장 궁금한 게 그 날 얼마 벌었냐 였는데. 회사 퇴근하고 와서 확인하는 재미가 쏠쏠했다 ㅋㅋ 그래서 버튼 옆에 작게 일일 ROI도 표시되게 만듦.

거래량이 많고 간혹 불량 데이터도 있어서 전처리를 좀 하고 올렸다.

소스 공개

# Imports

from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
import sys
import json
import jwt
import hashlib
import uuid
import requests
from pymongo import MongoClient
import json
import pymongo
import time
import datetime
import pytz

requests 모듈을 썼었는데 안되는 건 아니지만 짜증나게시리 API 문서하고도 잘 안맞아서 뭐가 문제인지 들여다보진 않고 urllib랑 같이 쓰다 지금은 완전 갈아탔다.

if __name__ == "__main__":
    app = QApplication(sys.argv)
    GUI = GUI()
    GUI.show()
    app.exec_()

프로그램 진입점도 적어주고.

class GUI(QWidget):
    def __init__(self):
    	# QWidget 인스턴스 생성
        super().__init__()
        # 윈도우 사이즈 세팅
        self.setGeometry(50, 30, 1800, 800)
        # 윈도우 제목 세팅
        self.setWindowTitle("데이터 이관")
        # 기본 레이아웃 및 클래스 변수 생성
        self.layout = QVBoxLayout(self)
        # 레이아웃 적용
        self.setLayout(self.layout)
        # 몽고DB Connection 생성
        self.connectDatabase()
        # Table 그리기
        self.createTable()
        # 하단 메뉴 그리기
        self.createTopBar()
    
    def connectDatabase(self):
    	# 몽고DB Connection 생성
        self.client = MongoClient('mongodb://localhost:27017/')
        # 데이터베이스 레퍼런싱
        self.local = self.client['local']
        
    def createTable(self):
    	# Table 인스턴스 생성
        self.table = QTableWidget()
        # 컬럼 수 세팅
        self.table.setColumnCount(7)
        # 컬럼 명 세팅
        self.table.setHorizontalHeaderLabels(['ID', 'Ticker', 'Price', 'Position', 'PnL', 'Size', 'Datetime'])
        # 마지막 컬럼을 확장하여 넓이 채우기
        self.table.horizontalHeader().setStretchLastSection(True)
        # 컬럼 기본 넓이를 200으로 설정
        self.table.horizontalHeader().setDefaultSectionSize(200)
        # 셀을 클릭해도 전체 행이 선택하도록 설정
        self.table.setSelectionBehavior(QTableView.SelectRows)
        # 행 선택 시 focus / outline 숨김
        self.table.setFocusPolicy(Qt.NoFocus)
        # 테이블 데이터 채우는 메소드 호출
        self.fillTable()
        # 테이블 내용 편집 기능 비활성화
        self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
        # 메인 레이아웃에 테이블 추가
        self.layout.addWidget(self.table)

    def createTopBar(self):
    	# Widget 인스턴스 생성
        top_bar = QWidget()
        # 레이아웃 생성
        top_bar_layout = QHBoxLayout()
        # PushButton 객체 생성
        self.button = QPushButton("Stage")
        # PushButton 넓이 100으로 고정
        self.button.setFixedWidth(100)
        # PushButton 높이 30으로 고정
        self.button.setFixedHeight(30)
        # PushButton CSS 적용
        self.button.setStyleSheet("background-color: #FF5733; color: #FFFFFF; \
            border: none; margin-right: 5px;")
        # PushButton onClick 함수 세팅
        self.button.clicked.connect(self.stageData)
        # GroupBox 객체 생성
        self.dailyROI = QGroupBox("일일 ROI")
        # GroupBox 용 레이아웃 생성
        top_bar_groupbox_layout = QVBoxLayout()
        # Label 생성
        self.dailyRoi = QLabel("")
        # 레이아웃에 라벨 추가
        top_bar_groupbox_layout.addWidget(self.dailyRoi)
        # GroupBox에 레이아웃 적용
        self.dailyROI.setLayout(top_bar_groupbox_layout)
        # 위젯 레이아웃에 PushButton 추가
        top_bar_layout.addWidget(self.button)
        # 위젯 레이아웃에 GroupBox 추가
        top_bar_layout.addWidget(self.dailyROI)
        # 위젯에 레이아웃 적용
        top_bar.setLayout(top_bar_layout)
        # 메인 레이아웃에 위젯 추가
        self.layout.addWidget(top_bar)

틀을 짜준다. 그리고 테이블 데이터를 채워준다.

    def fillTable(self):
    	# 데이터 호출 --> self.data 객체에 담김
        self.bringData()
        # self.data의 개수만큼 테이블의 행 수 세팅
        self.table.setRowCount(len(self.data))
        # self.data를 루프하여 테이블에 추가
        for i, x in enumerate(self.data):
        	# 몽고DB에서 _id는 bson.ObjectId로 되어 있기 때문에 str 변환
            id = QTableWidgetItem(str(x['_id']))
            # TableWidgetItem 인스턴스에 값을 매개 변수로 보내서 객체 생성
            underlyingAsset = QTableWidgetItem(x['underlyingAsset'])
            # datetime도 마찬가지로 Date/Time으로 되어 있기 때문에 str 변환
            datetime = QTableWidgetItem(str(x['datetime']))
            # tradePnl은 float이기 때문에 소수점 2자리 수 까지 있는 str으로 변환
            tradePnl = QTableWidgetItem(f"{(x.get('tradePnl', 0)):,.2f}")
            # -1이면 SELL 표시
            if x['action'] == -1:
                action = QTableWidgetItem("SELL")
                size = QTableWidgetItem(f"{(x['consumption']):,.2f}")
            # 1이면 BUY 표시
            elif x['action'] == 1:
                action = QTableWidgetItem("BUY")
                size = QTableWidgetItem("")
            barePrice = QTableWidgetItem(f"{(x['barePrice']):,.2f}")
            # 포지션 i 잡고 행에 셀마다 값 매핑
            self.table.setItem(i, 0, id)
            self.table.setItem(i, 1, underlyingAsset)
            self.table.setItem(i, 2, barePrice)
            self.table.setItem(i, 3, action)
            self.table.setItem(i, 4, tradePnl)
            self.table.setItem(i, 5, size)
            self.table.setItem(i, 6, datetime)
     
    def bringData(self):
        # 빈 배열 생성
        self.data = []
        # 로컬 변수 생성
        tickers = []
        unclosed = []
        # 콜렉션 레퍼런싱
        mycol = self.local['performance']
        # 쿼리 실행
        self.data = list(mycol.find().sort("datetime", pymongo.DESCENDING))

가장 중요한 버튼 콜백 로직 붙이기.

    def stageData(self):
    	# 스테이징 할 데이터가 없으면 빠꾸줌
        if len(self.data) == 0:
            return
        # 조건별로 데이터 필터 걸기 (if/else 가 많아서 잘 모르겠음.. ㅈㅅ)
        self.data_new = []
        curr_pos = 0
        curr_ticker = self.data[0]['underlyingAsset']
        for i, d in enumerate(self.data):
            if d['action'] == -1:
                self.data_new.append(d)
                curr_pos = -1
            elif curr_pos == -1:
                self.data_new.append(d)
                curr_pos = 1
            elif curr_ticker != d['underlyingAsset']:
                self.data_new.append(d)
            elif i != len(self.data) - 1 and \
                self.data[i+1]['underlyingAsset'] == curr_ticker and \
                self.data[i+1]['action'] == -1:
                    self.data_new.append(d)
            curr_ticker = d['underlyingAsset']
        # Table 내용 바꾸기
        self.replaceTable()
        # PushButton 내용 바꾸기
        self.replaceButton()
        # GroupBox 내용 바꾸기
        self.updateROI()
    
    def updateROI(self):
    	"""
        일일 ROI 계산 후 UI 상에 업데이트
        """
        tradePnl = 0
        balance = []
        for d in self.data_new:
            tradePnl += float(d.get('tradePnl', 0))
            balance.append(float(d['balance']))
        dailyRoi = (tradePnl / (sum(balance) / len(balance))) * 100
        self.dailyRoi.setText(str(dailyRoi))
    
    def replaceButton(self):
    	"""
        빨간 Stage 버튼에서 녹색 Send 버튼으로 바꾸고 onClick 액션 교체
        """
        self.button.setText("Send")
        self.button.setStyleSheet("background-color: #006400; color: #FFFFFF; \
            border: none; margin-left: 5px;")
        self.button.clicked.connect(self.sendData)
        
    def replaceTable(self):
    	"""
        staging 버튼 눌러서 전처리된 data_new 배열대로 테이블 다시 그리기
        """
        self.table.setRowCount(len(self.data_new))
        for i, x in enumerate(self.data_new):
            id = QTableWidgetItem(str(x['_id']))
            underlyingAsset = QTableWidgetItem(x['underlyingAsset'])
            datetime = QTableWidgetItem(str(x['datetime']))
            tradePnl = QTableWidgetItem(f"{float(x.get('tradePnl', 0)):,.2f}")
            if x['action'] == -1:
                action = QTableWidgetItem("SELL")
                size = QTableWidgetItem(f"{float(x['consumption']):,.2f}")
            elif x['action'] == 1:
                action = QTableWidgetItem("BUY")
                size = QTableWidgetItem("")
            barePrice = QTableWidgetItem(f"{float(x['barePrice']):,.2f}")
            self.table.setItem(i, 0, id)
            self.table.setItem(i, 1, underlyingAsset)
            self.table.setItem(i, 2, barePrice)
            self.table.setItem(i, 3, action)
            self.table.setItem(i, 4, tradePnl)
            self.table.setItem(i, 5, size)
            self.table.setItem(i, 6, datetime)
    
    def sendData(self):
    	"""
        쿼리 스트링을 짜서 바로 보내는 거라 보안 상 취약하지만 혼자 쓰는 거라 노 상관
        """
        data = list()
        for row in self.data_new:
            x = "('" + str(row['datetime']) + "', '" + str(row['productType']) + "', '" + str(row['tradePnl']) + "', \
            '" + str(row['underlyingAsset'].split("-")[1]) + "', '" + str(row['action']) + "', '" + str(row['barePrice']) + "', '" + str(row['consumption']) + "', \
            '" + str(row['balance']) + "', '" + str(row['totalPrice']) + "', '" + str(row['_id']) + "'),"
            data.append(x)
        result = requests.post(url="https://www.dongkye.tech/py-gui/postData.php", data={'data': "".join(data)[:-1]})
        if result.json()['result'] == "success":
        	# 이미 처리한 데이터는 performance 콜렉션에서 history 콜렉션으로 복제하는 메소드 호출
            if self.duplicateData() == True:
            	# 복제 성공 시 performance 콜렉션에서는 데이터 삭제
                self.deleteData()
                # 테이블 다시 렌더링
                self.fillTable()
            # 복제 실패 시 에러를 Dialog 창으로 띄움
            else:
                self.localErrorMessage()
        # 외부 시스템 이관 실패 시 에러를 Dialog 창으로 띄움
        else:
            self.errorMessage()
    
    def duplicateData(self):
        """
        데이터 복제
        """
        collection = self.local['history']
        x = collection.insert_many(self.data)
        return x.acknowledged
    
    def deleteData(self):
    	"""
        데이터 삭제. delete_many도 사용 가능
        """
        collection = self.local['performance']
        for x in self.data:
            collection.delete_one(x)

Dialog 부분. 코드가 겹치기 때문에 리펙토링 가능.

	def localErrorMessage(self, err_code):
        dlg = QMessageBox(self)
        dlg.setWindowTitle("로컬 네트워크 오류")
        dlg.resize(QSize(500, 100))
        dlg.setText("로컬 서버에서 [이관] 오류가 생겼습니다.")
        dlg.addButton(QPushButton('확인'), QMessageBox.YesRole)
        dlg.setIcon(QMessageBox.Information)
        dlg.exec()

    def errorMessage(self):
        dlg = QMessageBox(self)
        dlg.setWindowTitle("네트워크 오류")
        dlg.resize(QSize(500, 100))
        dlg.setText("오류가 발생했습니다.")
        dlg.addButton(QPushButton('확인'), QMessageBox.YesRole)
        dlg.setIcon(QMessageBox.Information)
        dlg.exec()

🍪 쿠키

얘는 구 버전 스샷 떠논거

우측 하단 코너에 Last Refresh 저런 쓰잘떼기 없는 거 다 뺀 거. 집에서나 밖에서나 하루종일 쳐다보고 있는 것도 아닌데 있어보이는 거 그냥 갖다 붙인거다. 기존에는 누적 수익이랑 수익률, 수익금 등 별의 별거 다 써놨는데 지저분해서 필요한 ROI 하나 남겨둔 거다.

그리고 기존에 일일이 클릭해서 스테이징 넘겼는데 데이터가 많아지면서 귀찮아서 생략한 것. 근데 지금 보니까 색감 이쁜듯.

profile
초보자를 위한 프로그래밍 정리

0개의 댓글