WebSocket + Vanilla JS로
실시간 자동매매 UI 만들기
모바일 반응형 대시보드, TWR 수익률, 배당금 차트까지 — 프레임워크 없이 구현
3편에서 TWAP 분할 주문 엔진과 주문 큐를 완성했습니다. 이번 편에서는 웹 UI, WebSocket 실시간 통신, SQLite 이력 관리, TWR 수익률 계산, Chart.js 대시보드, 텔레그램 알림까지 다룹니다.
왜 Vanilla JS인가 — React 없이 만든 이유
프론트엔드에 React나 Vue 같은 프레임워크를 쓰지 않았습니다. 이유는 단순합니다.
- 빌드 과정 없이 index.html 파일 하나로 바로 실행
- Python 백엔드에서 정적 파일로 서빙하기 편함
- 개인 도구라 번들 크기 최적화 불필요
- Claude Code가 Vanilla JS를 더 정확하게 생성함
결과적으로 index.html + style.css + app.js 세 파일만으로 모든 UI를 구현했습니다.
FastAPI WebSocket — 실시간 로그 스트리밍
자동매매가 진행되는 동안 백엔드 로그가 실시간으로 브라우저에 표시됩니다. WebSocket으로 서버 → 클라이언트 방향으로 로그를 스트리밍합니다.
# web_server.py — WebSocket 엔드포인트 @app.websocket("/ws/logs") async def ws_logs(ws: WebSocket): await ws.accept() # 재연결 시 이전 로그 버퍼 전송 (최대 500개) for entry in self._log_buffer: await ws.send_text(json.dumps({ "type": "log", "ts": entry["ts"], "message": entry["message"] })) self._ws_clients.append(ws) try: while True: await ws.receive_text() # 연결 유지 except WebSocketDisconnect: self._ws_clients.remove(ws)
// app.js — WebSocket 연결 function connectWS() { const proto = location.protocol === 'https:' ? 'wss' : 'ws'; ws = new WebSocket(`${proto}://${location.host}/ws/logs`); ws.onmessage = (e) => { const msg = JSON.parse(e.data); if (msg.type === 'log') appendLog(msg.message, msg.ts); if (msg.type === 'status') updateRunStatus(msg.status); }; ws.onclose = () => setTimeout(connectWS, 3000); // 자동 재연결 }
모바일에서 페이지를 새로고침하면 WebSocket 연결이 끊깁니다. 서버에 최근 500개 로그를 버퍼에 보관해두고, 재연결 시 즉시 전송합니다. 덕분에 자동매매 중 잠깐 화면을 나갔다 와도 이전 로그를 볼 수 있습니다.
UI 패널 구성 — 탭 기반 SPA
React Router 없이 탭 전환만으로 SPA처럼 동작합니다. 각 탭이 활성화될 때만 해당 데이터를 API로 요청합니다.
| 패널 | 기능 | 데이터 소스 |
|---|---|---|
| 계좌 현황 | 보유 종목, 목표금액, 비중 설정 | /api/account |
| 스케줄 설정 | 매도/매수 시간, 분할 횟수 | /api/schedule |
| 주문 현황 | 접수된 주문, 체결수량, 회차 | /api/orders |
| 이력 | 자동매매 세션 이력, 주문 상세 | /api/trading/sessions |
| 포트폴리오 | TWR 수익률, 배당금 차트 | /api/dashboard |
| 알림 설정 | 텔레그램 Bot Token/Chat ID | /api/notification/settings |
주문 현황 — 주문번호 매칭 & 체결 추적
주문을 접수한 후 체결수량을 실시간으로 추적합니다. 핵심은 접수된 주문번호와 미체결 조회 결과를 매칭하는 것입니다.
# /api/orders — 주문 현황 + 체결수량 계산 async def get_orders(): submitted = self.om.get_submitted_orders() unfilled_map = {} # 미체결 조회 API raw = await self.am.fetch_unfilled_orders(self.am.selected_account) for o in raw: for key in ("odno", "ord_no", "ORD_NO", "ordNo"): val = str(o.get(key, "")).strip() if val and val != "0": unfilled_map[val] = int(o.get("rmnd_qty", 0)); break orders_out = [] for o in submitted: order_no = o.get("order_no", "") if order_no in unfilled_map: unfilled = unfilled_map[order_no] filled = o["quantity"] - unfilled elif order_no: unfilled, filled = 0, o["quantity"] # 전량 체결 else: unfilled, filled = None, None # 주문번호 미수신 orders_out.append({**o, "unfilled_qty": unfilled, "filled_qty": filled}) # 체결수량을 DB에 비동기 저장 asyncio.create_task(self.hm.update_order_fills(fills)) return {"orders": orders_out, "sell_total": cfg.sell_split, "buy_total": cfg.buy_split}
SQLite HistoryManager — 이력 & 수익률
모든 자동매매 이력은 SQLite에 저장합니다. aiosqlite로 비동기로 DB를 다룹니다.
# DB 테이블 구조 CREATE TABLE rebalancing_history ( id INTEGER PRIMARY KEY, account_no TEXT, session_id TEXT, executed_at TEXT ); CREATE TABLE order_execution_log ( id INTEGER PRIMARY KEY, session_id TEXT, stock_code TEXT, stock_name TEXT, order_type TEXT, -- "sell" / "buy" round_no INTEGER, total_rounds INTEGER, ordered_qty INTEGER, filled_qty INTEGER, order_no TEXT, submitted_at TEXT ); CREATE TABLE portfolio_snapshot ( id INTEGER PRIMARY KEY, account_no TEXT, snapshot_date TEXT, -- YYYY-MM-DD total_eval INTEGER, deposit INTEGER );
TWR — 시간가중수익률 계산
연금계좌는 매달 추가 납입이 있어 단순 수익률이 의미 없습니다. TWR(Time-Weighted Return)으로 입출금 영향을 제거한 순수 운용 성과를 측정합니다.
async def calculate_twr(self, account_no): snapshots = await self.get_portfolio_snapshots(account_no) cashflows = await self.get_cashflow_list(account_no) cf_map = {} # 날짜별 입출금 합계 for cf in cashflows: sign = 1 if cf["flow_type"] == "deposit" else -1 cf_map[cf["flow_date"]] = cf_map.get(cf["flow_date"], 0) + sign * cf["amount"] twr = 1.0 for i in range(1, len(snapshots)): prev = snapshots[i-1]["total_eval"] curr = snapshots[i]["total_eval"] cf = cf_map.get(snapshots[i]["snapshot_date"], 0) adjusted_prev = prev + cf if adjusted_prev > 0: twr *= (curr / adjusted_prev) twr_pct = round((twr - 1) * 100, 2) return {"twr": twr_pct, "annualized_twr": ...}
Chart.js 대시보드 — 수익률 & 배당금
포트폴리오 현황 패널에 두 가지 차트를 추가했습니다.
평가금액 추이 / 일별 수익률 차트
대시보드를 열 때마다 당일 스냅샷을 자동 저장(upsert)합니다. 쌓인 스냅샷으로 시간에 따른 평가금액 변화와 일별 수익률을 차트로 보여줍니다.
월별 배당금 바차트
키움 API로 배당금 수령 내역을 동기화합니다. 대시보드 진입 시 백그라운드에서 자동으로 동기화해 새 배당금이 있으면 차트를 즉시 갱신합니다.
// app.js — 대시보드 로드 (배당금 백그라운드 동기화) async function loadDashboard() { const [data, chartData] = await Promise.all([ api('GET', '/api/dashboard'), api('GET', '/api/portfolio/chart').catch(() => ({ snapshots: [], dividends: [] })), ]); // 화면 먼저 렌더링 renderPortfolioChart(_chartMode); renderDividendChart(chartData.dividends); // 배당금 동기화는 백그라운드에서 api('POST', '/api/dividend/sync').then(result => { if (result.saved > 0) renderDividendChart(result.dividends); }).catch(() => {}); }
텔레그램 알림 — 실시간 상황 공유
자동매매 중 스마트폰으로 상황을 확인하고 싶어 텔레그램 알림을 붙였습니다.
| 알림 종류 | 내용 |
|---|---|
| 리밸런싱 시작 | 매도 시작 / 매수 시작 |
| 완료 | 전체 리밸런싱 완료 |
| 주문 실패 | 종목명 + 실패 사유 |
| 배당금 | 종목명 + 수령액 |
| 장 시작 전 | 08:50 포트폴리오 현황 (ON/OFF) |
① @BotFather 에서 /newbot 명령으로 Bot Token 발급
② @userinfobot 에서 Chat ID 확인
③ 앱의 알림 설정 패널에 Bot Token, Chat ID 입력 후 연결 테스트
✔ FastAPI WebSocket 실시간 로그 스트리밍
✔ 로그 버퍼 (재연결 시 500개 복원)
✔ Vanilla JS 탭 기반 SPA
✔ 주문번호 매칭 체결수량 추적
✔ SQLite HistoryManager (이력, 스냅샷, 배당금)
✔ TWR 수익률 계산
✔ Chart.js 평가금액/수익률/배당금 차트
✔ 배당금 API 자동 동기화
✔ 텔레그램 알림 (5종)
1편 → Claude AI로 주식 자동매매 프로그램 기획한 이야기
2편 → Claude Code로 키움 REST API 클라이언트 구현하기
3편 → TWAP 분할 주문 & 자동 정정 주문 시스템 구현기
4편 ✅ WebSocket + Vanilla JS로 실시간 자동매매 UI 만들기 (현재 글)
5편 → 실제 운용하며 만난 버그들과 보안 강화 후기
6편 → 실제 구동 화면 전체 공개 — 로그인부터 대시보드까지
댓글 없음:
댓글 쓰기