diff --git a/Jenkinsfile b/Jenkinsfile index 3487fdd..bd3ccab 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -7,7 +7,10 @@ pipeline { IMAGE_TAG = "${env.BUILD_NUMBER}" FULL_IMAGE = "${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}" LATEST_IMAGE = "${REGISTRY}/${IMAGE_NAME}:latest" - + + DASH_API_IMAGE = "${REGISTRY}/gihyeon/cointrader-dashboard-api" + DASH_UI_IMAGE = "${REGISTRY}/gihyeon/cointrader-dashboard-ui" + // 젠킨스 자격 증명에 저장해둔 디스코드 웹훅 주소를 불러옵니다. DISCORD_WEBHOOK = credentials('discord-webhook') } @@ -33,9 +36,11 @@ pipeline { } } - stage('Build Docker Image') { + stage('Build Docker Images') { steps { sh "docker build -t ${FULL_IMAGE} -t ${LATEST_IMAGE} ." + sh "docker build -t ${DASH_API_IMAGE}:${IMAGE_TAG} -t ${DASH_API_IMAGE}:latest ./dashboard/api" + sh "docker build -t ${DASH_UI_IMAGE}:${IMAGE_TAG} -t ${DASH_UI_IMAGE}:latest ./dashboard/ui" } } @@ -45,6 +50,10 @@ pipeline { sh "echo \$GITEA_TOKEN | docker login ${REGISTRY} -u \$GITEA_USER --password-stdin" sh "docker push ${FULL_IMAGE}" sh "docker push ${LATEST_IMAGE}" + sh "docker push ${DASH_API_IMAGE}:${IMAGE_TAG}" + sh "docker push ${DASH_API_IMAGE}:latest" + sh "docker push ${DASH_UI_IMAGE}:${IMAGE_TAG}" + sh "docker push ${DASH_UI_IMAGE}:latest" } } } @@ -66,6 +75,10 @@ pipeline { steps { sh "docker rmi ${FULL_IMAGE} || true" sh "docker rmi ${LATEST_IMAGE} || true" + sh "docker rmi ${DASH_API_IMAGE}:${IMAGE_TAG} || true" + sh "docker rmi ${DASH_API_IMAGE}:latest || true" + sh "docker rmi ${DASH_UI_IMAGE}:${IMAGE_TAG} || true" + sh "docker rmi ${DASH_UI_IMAGE}:latest || true" } } } diff --git a/dashboard/SETUP.md b/dashboard/SETUP.md new file mode 100644 index 0000000..adc530f --- /dev/null +++ b/dashboard/SETUP.md @@ -0,0 +1,47 @@ +# Trading Dashboard + +봇과 통합된 대시보드. 봇 로그를 읽기 전용으로 마운트하여 실시간 시각화합니다. + +## 구조 + +``` +dashboard/ +├── SETUP.md +├── api/ +│ ├── Dockerfile +│ ├── log_parser.py +│ ├── dashboard_api.py +│ └── entrypoint.sh +└── ui/ + ├── Dockerfile + ├── nginx.conf + ├── package.json + ├── vite.config.js + ├── index.html + └── src/ + ├── main.jsx + └── App.jsx +``` + +## 실행 + +루트 디렉토리에서 봇과 함께 실행: + +```bash +# 전체 (봇 + 대시보드) +docker compose up -d --build + +# 대시보드만 +docker compose up -d --build dashboard-api dashboard-ui +``` + +## 접속 + +`http://<서버IP>:8080` + +## 동작 방식 + +- `dashboard-api`: 로그 파서 + FastAPI 서버 (봇 로그 → SQLite → REST API) +- `dashboard-ui`: React + Vite (빌드 후 nginx에서 서빙, API 프록시) +- 봇 로그 디렉토리를 `:ro` (읽기 전용) 마운트 +- 대시보드 DB는 Docker named volume (`dashboard-data`)에 저장 diff --git a/dashboard/api/Dockerfile b/dashboard/api/Dockerfile new file mode 100644 index 0000000..33b1541 --- /dev/null +++ b/dashboard/api/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.11-slim +WORKDIR /app +RUN pip install --no-cache-dir fastapi uvicorn +COPY log_parser.py . +COPY dashboard_api.py . +COPY entrypoint.sh . +RUN chmod +x entrypoint.sh +EXPOSE 8080 +CMD ["./entrypoint.sh"] diff --git a/dashboard/api/dashboard_api.py b/dashboard/api/dashboard_api.py new file mode 100644 index 0000000..7fdd765 --- /dev/null +++ b/dashboard/api/dashboard_api.py @@ -0,0 +1,108 @@ +""" +dashboard_api.py — 로그 파서가 채운 SQLite DB를 읽어서 대시보드 API 제공 +""" + +import sqlite3 +import os +from fastapi import FastAPI, Query +from fastapi.middleware.cors import CORSMiddleware +from pathlib import Path +from contextlib import contextmanager + +DB_PATH = os.environ.get("DB_PATH", "/app/data/dashboard.db") + +app = FastAPI(title="Trading Dashboard API") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +@contextmanager +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + try: + yield conn + finally: + conn.close() + +@app.get("/api/position") +def get_position(): + with get_db() as db: + row = db.execute( + "SELECT * FROM trades WHERE status='OPEN' ORDER BY id DESC LIMIT 1" + ).fetchone() + status_rows = db.execute("SELECT key, value FROM bot_status").fetchall() + bot = {r["key"]: r["value"] for r in status_rows} + return {"position": dict(row) if row else None, "bot": bot} + +@app.get("/api/trades") +def get_trades(limit: int = Query(50, ge=1, le=500), offset: int = 0): + with get_db() as db: + rows = db.execute( + "SELECT * FROM trades WHERE status='CLOSED' ORDER BY id DESC LIMIT ? OFFSET ?", + (limit, offset), + ).fetchall() + total = db.execute("SELECT COUNT(*) as cnt FROM trades WHERE status='CLOSED'").fetchone()["cnt"] + return {"trades": [dict(r) for r in rows], "total": total} + +@app.get("/api/daily") +def get_daily(days: int = Query(30, ge=1, le=365)): + with get_db() as db: + rows = db.execute(""" + SELECT + date(exit_time) as date, + COUNT(*) as total_trades, + SUM(CASE WHEN net_pnl > 0 THEN 1 ELSE 0 END) as wins, + SUM(CASE WHEN net_pnl <= 0 THEN 1 ELSE 0 END) as losses, + ROUND(SUM(net_pnl), 4) as net_pnl, + ROUND(SUM(commission), 4) as total_fees + FROM trades + WHERE status='CLOSED' AND exit_time IS NOT NULL + GROUP BY date(exit_time) + ORDER BY date DESC + LIMIT ? + """, (days,)).fetchall() + return {"daily": [dict(r) for r in rows]} + +@app.get("/api/stats") +def get_stats(): + with get_db() as db: + row = db.execute(""" + SELECT + COUNT(*) as total_trades, + COALESCE(SUM(CASE WHEN net_pnl > 0 THEN 1 ELSE 0 END), 0) as wins, + COALESCE(SUM(CASE WHEN net_pnl <= 0 THEN 1 ELSE 0 END), 0) as losses, + COALESCE(SUM(net_pnl), 0) as total_pnl, + COALESCE(SUM(commission), 0) as total_fees, + COALESCE(AVG(net_pnl), 0) as avg_pnl, + COALESCE(MAX(net_pnl), 0) as best_trade, + COALESCE(MIN(net_pnl), 0) as worst_trade + FROM trades WHERE status='CLOSED' + """).fetchone() + status_rows = db.execute("SELECT key, value FROM bot_status").fetchall() + bot = {r["key"]: r["value"] for r in status_rows} + result = dict(row) + result["current_price"] = bot.get("current_price") + result["balance"] = bot.get("balance") + return result + +@app.get("/api/candles") +def get_candles(limit: int = Query(96, ge=1, le=1000)): + with get_db() as db: + rows = db.execute("SELECT * FROM candles ORDER BY ts DESC LIMIT ?", (limit,)).fetchall() + return {"candles": [dict(r) for r in reversed(rows)]} + +@app.get("/api/health") +def health(): + try: + with get_db() as db: + cnt = db.execute("SELECT COUNT(*) as c FROM candles").fetchone()["c"] + return {"status": "ok", "candles_count": cnt} + except Exception as e: + return {"status": "error", "detail": str(e)} + + diff --git a/dashboard/api/entrypoint.sh b/dashboard/api/entrypoint.sh new file mode 100644 index 0000000..dea5994 --- /dev/null +++ b/dashboard/api/entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/bash +set -e + +echo "=== Trading Dashboard ===" +echo "LOG_DIR=${LOG_DIR:-/app/logs}" +echo "DB_PATH=${DB_PATH:-/app/data/dashboard.db}" + +# 로그 파서를 백그라운드로 실행 +python log_parser.py & +PARSER_PID=$! +echo "Log parser started (PID: $PARSER_PID)" + +# 파서가 기존 로그를 처리할 시간 부여 +sleep 3 + +# FastAPI 서버 실행 +echo "Starting API server on :8080" +exec uvicorn dashboard_api:app --host 0.0.0.0 --port 8080 --log-level info diff --git a/dashboard/api/log_parser.py b/dashboard/api/log_parser.py new file mode 100644 index 0000000..c2c84a9 --- /dev/null +++ b/dashboard/api/log_parser.py @@ -0,0 +1,476 @@ +""" +log_parser.py — 봇 로그 파일을 감시하고 파싱하여 SQLite에 저장 +봇 코드 수정 없이 동작. logs/ 디렉토리만 마운트하면 됨. + +실행: python log_parser.py +""" + +import re +import sqlite3 +import time +import glob +import os +import json +import threading +from datetime import datetime, date +from pathlib import Path + +# ── 설정 ────────────────────────────────────────────────────────── +LOG_DIR = os.environ.get("LOG_DIR", "/app/logs") +DB_PATH = os.environ.get("DB_PATH", "/app/data/dashboard.db") +POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "5")) # 초 + +# ── 정규식 패턴 (실제 봇 로그 형식 기준) ────────────────────────── +PATTERNS = { + # 신호: HOLD | 현재가: 1.3889 USDT + "signal": re.compile( + r"(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" + r".*신호: (?P\w+) \| 현재가: (?P[\d.]+) USDT" + ), + + # ADX: 24.4 + "adx": re.compile( + r"(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" + r".*ADX: (?P[\d.]+)" + ), + + # OI=261103765.6, OI변화율=0.000692, 펀딩비=0.000039 + "microstructure": re.compile( + r"(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" + r".*OI=(?P[\d.]+), OI변화율=(?P[-\d.]+), 펀딩비=(?P[-\d.]+)" + ), + + # 기존 포지션 복구: SHORT | 진입가=1.4126 | 수량=86.8 + "position_recover": re.compile( + r"(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" + r".*기존 포지션 복구: (?P\w+) \| 진입가=(?P[\d.]+) \| 수량=(?P[\d.]+)" + ), + + # SHORT 진입: 가격=1.3940, 수량=86.8, SL=1.4040, TP=1.3840 + "entry": re.compile( + r"(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" + r".*(?PSHORT|LONG) 진입: " + r"가격=(?P[\d.]+), " + r"수량=(?P[\d.]+), " + r"SL=(?P[\d.]+), " + r"TP=(?P[\d.]+)" + ), + + # 청산 감지(MANUAL): exit=1.3782, rp=+2.9859, commission=0.0598, net_pnl=+2.9261 + "close_detect": re.compile( + r"(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" + r".*청산 감지\((?P\w+)\):\s*" + r"exit=(?P[\d.]+),\s*" + r"rp=(?P[+\-\d.]+),\s*" + r"commission=(?P[\d.]+),\s*" + r"net_pnl=(?P[+\-\d.]+)" + ), + + # 오늘 누적 PnL: 2.9261 USDT + "daily_pnl": re.compile( + r"(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" + r".*오늘 누적 PnL: (?P[+\-\d.]+) USDT" + ), + + # 봇 시작: XRPUSDT, 레버리지 10x + "bot_start": re.compile( + r"(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" + r".*봇 시작: (?P\w+), 레버리지 (?P\d+)x" + ), + + # 기준 잔고 설정: 24.46 USDT + "balance": re.compile( + r"(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" + r".*기준 잔고 설정: (?P[\d.]+) USDT" + ), + + # ML 필터 로드 + "ml_filter": re.compile( + r"(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" + r".*ML 필터 로드.*임계값=(?P[\d.]+)" + ), +} + + +class LogParser: + def __init__(self): + Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) + self.conn = sqlite3.connect(DB_PATH) + self.conn.row_factory = sqlite3.Row + self.conn.execute("PRAGMA journal_mode=WAL") + self._init_db() + + # 상태 추적 + self._file_positions = {} # {파일경로: 마지막 읽은 위치} + self._current_position = None # 현재 열린 포지션 정보 + self._pending_candle = {} # 타임스탬프 기준으로 지표를 모아두기 + self._bot_config = {"symbol": "XRPUSDT", "leverage": 10} + self._balance = 0 + + def _init_db(self): + self.conn.executescript(""" + CREATE TABLE IF NOT EXISTS trades ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + symbol TEXT NOT NULL DEFAULT 'XRPUSDT', + direction TEXT NOT NULL, + entry_time TEXT NOT NULL, + exit_time TEXT, + entry_price REAL NOT NULL, + exit_price REAL, + quantity REAL, + leverage INTEGER DEFAULT 10, + sl REAL, + tp REAL, + rsi REAL, + macd_hist REAL, + atr REAL, + adx REAL, + expected_pnl REAL, + actual_pnl REAL, + commission REAL, + net_pnl REAL, + status TEXT NOT NULL DEFAULT 'OPEN', + close_reason TEXT, + extra TEXT + ); + + CREATE TABLE IF NOT EXISTS candles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL UNIQUE, + price REAL NOT NULL, + signal TEXT, + adx REAL, + oi REAL, + oi_change REAL, + funding_rate REAL + ); + + CREATE TABLE IF NOT EXISTS daily_pnl ( + date TEXT PRIMARY KEY, + cumulative_pnl REAL DEFAULT 0, + trade_count INTEGER DEFAULT 0, + wins INTEGER DEFAULT 0, + losses INTEGER DEFAULT 0, + last_updated TEXT + ); + + CREATE TABLE IF NOT EXISTS bot_status ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TEXT + ); + + CREATE TABLE IF NOT EXISTS parse_state ( + filepath TEXT PRIMARY KEY, + position INTEGER DEFAULT 0 + ); + + CREATE INDEX IF NOT EXISTS idx_candles_ts ON candles(ts); + CREATE INDEX IF NOT EXISTS idx_trades_status ON trades(status); + """) + self.conn.commit() + self._load_state() + + def _load_state(self): + """이전 파싱 위치 복원""" + rows = self.conn.execute("SELECT filepath, position FROM parse_state").fetchall() + self._file_positions = {r["filepath"]: r["position"] for r in rows} + + # 현재 열린 포지션 복원 + row = self.conn.execute( + "SELECT * FROM trades WHERE status='OPEN' ORDER BY id DESC LIMIT 1" + ).fetchone() + if row: + self._current_position = dict(row) + + def _save_position(self, filepath, pos): + self.conn.execute( + "INSERT INTO parse_state(filepath, position) VALUES(?,?) " + "ON CONFLICT(filepath) DO UPDATE SET position=?", + (filepath, pos, pos) + ) + self.conn.commit() + + def _set_status(self, key, value): + now = datetime.now().isoformat() + self.conn.execute( + "INSERT INTO bot_status(key, value, updated_at) VALUES(?,?,?) " + "ON CONFLICT(key) DO UPDATE SET value=?, updated_at=?", + (key, str(value), now, str(value), now) + ) + self.conn.commit() + + # ── 메인 루프 ──────────────────────────────────────────────── + def run(self): + print(f"[LogParser] 시작 — LOG_DIR={LOG_DIR}, DB={DB_PATH}, 폴링={POLL_INTERVAL}s") + while True: + try: + self._scan_logs() + except Exception as e: + print(f"[LogParser] 에러: {e}") + time.sleep(POLL_INTERVAL) + + def _scan_logs(self): + """로그 파일 목록을 가져와서 새 줄 파싱""" + # 날짜 형식 (bot_2026-03-01.log) + 현재 형식 (bot.log) 모두 스캔 + log_files = sorted(glob.glob(os.path.join(LOG_DIR, "bot_*.log"))) + main_log = os.path.join(LOG_DIR, "bot.log") + if os.path.exists(main_log): + log_files.append(main_log) + for filepath in log_files: + self._parse_file(filepath) + + def _parse_file(self, filepath): + last_pos = self._file_positions.get(filepath, 0) + + try: + file_size = os.path.getsize(filepath) + except OSError: + return + + # 파일이 줄었으면 (로테이션) 처음부터 + if file_size < last_pos: + last_pos = 0 + + if file_size == last_pos: + return # 새 내용 없음 + + with open(filepath, "r", encoding="utf-8", errors="ignore") as f: + f.seek(last_pos) + new_lines = f.readlines() + new_pos = f.tell() + + for line in new_lines: + self._parse_line(line.strip()) + + self._file_positions[filepath] = new_pos + self._save_position(filepath, new_pos) + + # ── 한 줄 파싱 ────────────────────────────────────────────── + def _parse_line(self, line): + if not line: + return + + # 봇 시작 + m = PATTERNS["bot_start"].search(line) + if m: + self._bot_config["symbol"] = m.group("symbol") + self._bot_config["leverage"] = int(m.group("leverage")) + self._set_status("symbol", m.group("symbol")) + self._set_status("leverage", m.group("leverage")) + self._set_status("last_start", m.group("ts")) + return + + # 잔고 + m = PATTERNS["balance"].search(line) + if m: + self._balance = float(m.group("balance")) + self._set_status("balance", m.group("balance")) + return + + # ML 필터 + m = PATTERNS["ml_filter"].search(line) + if m: + self._set_status("ml_threshold", m.group("threshold")) + return + + # 포지션 복구 (재시작 시) + m = PATTERNS["position_recover"].search(line) + if m: + self._handle_entry( + ts=m.group("ts"), + direction=m.group("direction"), + entry_price=float(m.group("entry_price")), + qty=float(m.group("qty")), + is_recovery=True, + ) + return + + # 포지션 진입: SHORT 진입: 가격=X, 수량=Y, SL=Z, TP=W + m = PATTERNS["entry"].search(line) + if m: + self._handle_entry( + ts=m.group("ts"), + direction=m.group("direction"), + entry_price=float(m.group("entry_price")), + qty=float(m.group("qty")), + sl=float(m.group("sl")), + tp=float(m.group("tp")), + ) + return + + # OI/펀딩비 (캔들 데이터에 합침) + m = PATTERNS["microstructure"].search(line) + if m: + ts_key = m.group("ts")[:16] # 분 단위로 그룹 + if ts_key not in self._pending_candle: + self._pending_candle[ts_key] = {} + self._pending_candle[ts_key].update({ + "oi": float(m.group("oi")), + "oi_change": float(m.group("oi_change")), + "funding": float(m.group("funding")), + }) + return + + # ADX + m = PATTERNS["adx"].search(line) + if m: + ts_key = m.group("ts")[:16] + if ts_key not in self._pending_candle: + self._pending_candle[ts_key] = {} + self._pending_candle[ts_key]["adx"] = float(m.group("adx")) + return + + # 신호 + 현재가 → 캔들 저장 + m = PATTERNS["signal"].search(line) + if m: + ts = m.group("ts") + ts_key = ts[:16] + price = float(m.group("price")) + signal = m.group("signal") + extra = self._pending_candle.pop(ts_key, {}) + + self._set_status("current_price", str(price)) + self._set_status("current_signal", signal) + self._set_status("last_candle_time", ts) + + try: + self.conn.execute( + """INSERT INTO candles(ts, price, signal, adx, oi, oi_change, funding_rate) + VALUES(?,?,?,?,?,?,?) + ON CONFLICT(ts) DO UPDATE SET + price=?, signal=?, adx=?, oi=?, oi_change=?, funding_rate=?""", + (ts, price, signal, + extra.get("adx"), extra.get("oi"), extra.get("oi_change"), extra.get("funding"), + price, signal, + extra.get("adx"), extra.get("oi"), extra.get("oi_change"), extra.get("funding")), + ) + self.conn.commit() + except Exception as e: + print(f"[LogParser] 캔들 저장 에러: {e}") + return + + # 청산 감지 + m = PATTERNS["close_detect"].search(line) + if m: + self._handle_close( + ts=m.group("ts"), + exit_price=float(m.group("exit_price")), + expected_pnl=float(m.group("expected")), + commission=float(m.group("commission")), + net_pnl=float(m.group("net_pnl")), + reason=m.group("reason"), + ) + return + + # 일일 누적 PnL + m = PATTERNS["daily_pnl"].search(line) + if m: + ts = m.group("ts") + day = ts[:10] + pnl = float(m.group("pnl")) + self.conn.execute( + """INSERT INTO daily_pnl(date, cumulative_pnl, last_updated) + VALUES(?,?,?) + ON CONFLICT(date) DO UPDATE SET cumulative_pnl=?, last_updated=?""", + (day, pnl, ts, pnl, ts) + ) + self.conn.commit() + self._set_status("daily_pnl", str(pnl)) + return + + # ── 포지션 진입 핸들러 ─────────────────────────────────────── + def _handle_entry(self, ts, direction, entry_price, qty, + leverage=None, sl=None, tp=None, is_recovery=False): + if leverage is None: + leverage = self._bot_config.get("leverage", 10) + + # 메모리 내 중복 체크 + if self._current_position: + if abs(self._current_position["entry_price"] - entry_price) < 0.0001: + return + + # DB 내 중복 체크 — 같은 방향·가격의 OPEN 포지션이 이미 있으면 스킵 + existing = self.conn.execute( + "SELECT id FROM trades WHERE status='OPEN' AND direction=? " + "AND ABS(entry_price - ?) < 0.0001", + (direction, entry_price), + ).fetchone() + if existing: + self._current_position = { + "id": existing["id"], + "direction": direction, + "entry_price": entry_price, + "entry_time": ts, + } + return + + cur = self.conn.execute( + """INSERT INTO trades(symbol, direction, entry_time, entry_price, + quantity, leverage, sl, tp, status, extra) + VALUES(?,?,?,?,?,?,?,?,?,?)""", + (self._bot_config.get("symbol", "XRPUSDT"), direction, ts, + entry_price, qty, leverage, sl, tp, "OPEN", + json.dumps({"recovery": is_recovery})), + ) + self.conn.commit() + self._current_position = { + "id": cur.lastrowid, + "direction": direction, + "entry_price": entry_price, + "entry_time": ts, + } + self._set_status("position_status", "OPEN") + self._set_status("position_direction", direction) + self._set_status("position_entry_price", str(entry_price)) + print(f"[LogParser] 포지션 진입: {direction} @ {entry_price} (recovery={is_recovery})") + + # ── 포지션 청산 핸들러 ─────────────────────────────────────── + def _handle_close(self, ts, exit_price, expected_pnl, commission, net_pnl, reason): + if not self._current_position: + # 열린 포지션 없으면 DB에서 찾기 + row = self.conn.execute( + "SELECT id, entry_price, direction FROM trades WHERE status='OPEN' ORDER BY id DESC LIMIT 1" + ).fetchone() + if row: + self._current_position = dict(row) + else: + print(f"[LogParser] 경고: 청산 감지했으나 열린 포지션 없음") + return + + trade_id = self._current_position["id"] + self.conn.execute( + """UPDATE trades SET + exit_time=?, exit_price=?, expected_pnl=?, + actual_pnl=?, commission=?, net_pnl=?, + status='CLOSED', close_reason=? + WHERE id=?""", + (ts, exit_price, expected_pnl, + expected_pnl, commission, net_pnl, + reason, trade_id) + ) + + # 일별 요약 갱신 + day = ts[:10] + win = 1 if net_pnl > 0 else 0 + loss = 1 if net_pnl <= 0 else 0 + self.conn.execute( + """INSERT INTO daily_pnl(date, cumulative_pnl, trade_count, wins, losses, last_updated) + VALUES(?, ?, 1, ?, ?, ?) + ON CONFLICT(date) DO UPDATE SET + trade_count = trade_count + 1, + wins = wins + ?, + losses = losses + ?, + last_updated = ?""", + (day, net_pnl, win, loss, ts, win, loss, ts) + ) + self.conn.commit() + + self._set_status("position_status", "NONE") + print(f"[LogParser] 포지션 청산: {reason} @ {exit_price}, PnL={net_pnl}") + self._current_position = None + + +if __name__ == "__main__": + parser = LogParser() + parser.run() diff --git a/dashboard/ui/Dockerfile b/dashboard/ui/Dockerfile new file mode 100644 index 0000000..6b2b106 --- /dev/null +++ b/dashboard/ui/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json . +RUN npm install +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 3000 diff --git a/dashboard/ui/index.html b/dashboard/ui/index.html new file mode 100644 index 0000000..7417f6b --- /dev/null +++ b/dashboard/ui/index.html @@ -0,0 +1,20 @@ + + + + + + Trading Dashboard + + + + + +
+ + + diff --git a/dashboard/ui/nginx.conf b/dashboard/ui/nginx.conf new file mode 100644 index 0000000..d55ac91 --- /dev/null +++ b/dashboard/ui/nginx.conf @@ -0,0 +1,23 @@ +server { + listen 3000; + root /usr/share/nginx/html; + index index.html; + + # SPA — 모든 경로를 index.html로 + location / { + try_files $uri $uri/ /index.html; + } + + # API 프록시 → 백엔드 컨테이너 + location /api/ { + proxy_pass http://dashboard-api:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # 캐시 설정 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { + expires 7d; + add_header Cache-Control "public, immutable"; + } +} diff --git a/dashboard/ui/package.json b/dashboard/ui/package.json new file mode 100644 index 0000000..7dbe671 --- /dev/null +++ b/dashboard/ui/package.json @@ -0,0 +1,20 @@ +{ + "name": "trading-dashboard", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "recharts": "^2.12.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.0", + "vite": "^5.4.0" + } +} diff --git a/dashboard/ui/src/App.jsx b/dashboard/ui/src/App.jsx new file mode 100644 index 0000000..a51c61c --- /dev/null +++ b/dashboard/ui/src/App.jsx @@ -0,0 +1,639 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { + BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, + AreaChart, Area, LineChart, Line, CartesianGrid, Cell, +} from "recharts"; + +/* ── API ──────────────────────────────────────────────────────── */ +const api = async (path) => { + try { + const r = await fetch(`/api${path}`); + if (!r.ok) throw new Error(r.statusText); + return await r.json(); + } catch (e) { + console.error(`API [${path}]:`, e); + return null; + } +}; + +/* ── 유틸 ─────────────────────────────────────────────────────── */ +const fmt = (n, d = 4) => (n != null ? Number(n).toFixed(d) : "—"); +const fmtTime = (iso) => { + if (!iso) return "—"; + const d = new Date(iso); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; +}; +const fmtDate = (s) => (s ? s.slice(5, 10).replace("-", "/") : "—"); +const pnlColor = (v) => (v > 0 ? "#34d399" : v < 0 ? "#f87171" : "rgba(255,255,255,0.5)"); +const pnlSign = (v) => (v > 0 ? `+${fmt(v)}` : fmt(v)); + +/* ── 스타일 변수 ──────────────────────────────────────────────── */ +const S = { + sans: "'Satoshi','DM Sans',system-ui,sans-serif", + mono: "'JetBrains Mono','Fira Code',monospace", + bg: "#08080f", + surface: "rgba(255,255,255,0.015)", + surface2: "rgba(255,255,255,0.03)", + border: "rgba(255,255,255,0.06)", + text3: "rgba(255,255,255,0.35)", + text4: "rgba(255,255,255,0.2)", + green: "#34d399", + red: "#f87171", + indigo: "#818cf8", + amber: "#f59e0b", +}; + +/* ── Badge ────────────────────────────────────────────────────── */ +const Badge = ({ children, bg = "rgba(255,255,255,0.06)", color = "rgba(255,255,255,0.5)" }) => ( + {children} +); + +/* ── StatCard ─────────────────────────────────────────────────── */ +const StatCard = ({ icon, label, value, sub, accent }) => ( +
+
+
+ {icon && {icon}}{label} +
+
+ {value} +
+ {sub && ( +
{sub}
+ )} +
+); + +/* ── ChartTooltip ─────────────────────────────────────────────── */ +const ChartTooltip = ({ active, payload, label }) => { + if (!active || !payload?.length) return null; + return ( +
+
{label}
+ {payload.filter(p => p.name !== "과매수" && p.name !== "과매도" && p.name !== "임계값").map((p, i) => ( +
+ {p.name}: {typeof p.value === "number" ? p.value.toFixed(4) : p.value} +
+ ))} +
+ ); +}; + +/* ── TradeRow ──────────────────────────────────────────────────── */ +const TradeRow = ({ trade, isExpanded, onToggle }) => { + const pnl = trade.net_pnl || 0; + const isShort = trade.direction === "SHORT"; + const priceDiff = trade.entry_price && trade.exit_price + ? ((trade.entry_price - trade.exit_price) / trade.entry_price * 100 * (isShort ? 1 : -1)).toFixed(2) + : "—"; + + const sections = [ + { + title: "리스크 관리", + items: [ + ["손절가 (SL)", trade.sl, S.red], + ["익절가 (TP)", trade.tp, S.green], + ["수량", trade.quantity, "rgba(255,255,255,0.6)"], + ], + }, + { + title: "기술 지표", + items: [ + ["RSI", trade.rsi, trade.rsi > 70 ? S.amber : S.indigo], + ["MACD Hist", trade.macd_hist, trade.macd_hist >= 0 ? S.green : S.red], + ["ATR", trade.atr, "rgba(255,255,255,0.6)"], + ], + }, + { + title: "손익 상세", + items: [ + ["예상 수익", trade.expected_pnl, S.green], + ["순수익", trade.net_pnl, pnlColor(trade.net_pnl)], + ["수수료", trade.commission ? -trade.commission : null, S.red], + ], + }, + ]; + + return ( +
+
+
+ {isShort ? "S" : "L"} +
+ +
+
+ {(trade.symbol || "XRPUSDT").replace("USDT", "/USDT")} + + {trade.direction} + + {trade.leverage || 10}x +
+
+ {fmtDate(trade.entry_time)} {fmtTime(trade.entry_time)} → {fmtTime(trade.exit_time)} + {trade.close_reason && ( + ({trade.close_reason}) + )} +
+
+ +
+
+ {fmt(trade.entry_price)} +
+
진입가
+
+ +
+
+ {fmt(trade.exit_price)} +
+
청산가
+
+ +
+
+ {pnlSign(pnl)} +
+
{priceDiff}%
+
+ +
+
+ + {isExpanded && ( +
+ {sections.map((sec, si) => ( +
+
+ {sec.title} +
+ {sec.items.map(([label, val, color], ii) => ( +
+ {label} + + {val != null ? fmt(val) : "—"} + +
+ ))} +
+ ))} +
+ )} +
+ ); +}; + +/* ── 차트 컨테이너 ────────────────────────────────────────────── */ +const ChartBox = ({ title, children }) => ( +
+
+ {title} +
+ {children} +
+); + +/* ── 탭 정의 ──────────────────────────────────────────────────── */ +const TABS = [ + { id: "overview", label: "Overview", icon: "◆" }, + { id: "trades", label: "Trades", icon: "◈" }, + { id: "chart", label: "Chart", icon: "◇" }, +]; + +/* ═══════════════════════════════════════════════════════════════ */ +/* 메인 대시보드 */ +/* ═══════════════════════════════════════════════════════════════ */ +export default function App() { + const [tab, setTab] = useState("overview"); + const [expanded, setExpanded] = useState(null); + const [isLive, setIsLive] = useState(false); + const [lastUpdate, setLastUpdate] = useState(null); + + const [stats, setStats] = useState({ + total_trades: 0, wins: 0, losses: 0, + total_pnl: 0, total_fees: 0, avg_pnl: 0, + best_trade: 0, worst_trade: 0, + }); + const [position, setPosition] = useState(null); + const [botStatus, setBotStatus] = useState({}); + const [trades, setTrades] = useState([]); + const [daily, setDaily] = useState([]); + const [candles, setCandles] = useState([]); + + /* ── 데이터 폴링 ─────────────────────────────────────────── */ + const fetchAll = useCallback(async () => { + const [sRes, pRes, tRes, dRes, cRes] = await Promise.all([ + api("/stats"), + api("/position"), + api("/trades?limit=50"), + api("/daily?days=30"), + api("/candles?limit=96"), + ]); + + if (sRes && sRes.total_trades !== undefined) { + setStats(sRes); + setIsLive(true); + setLastUpdate(new Date()); + } + if (pRes) { + setPosition(pRes.position); + if (pRes.bot) setBotStatus(pRes.bot); + } + if (tRes?.trades) setTrades(tRes.trades); + if (dRes?.daily) setDaily(dRes.daily); + if (cRes?.candles) setCandles(cRes.candles); + }, []); + + useEffect(() => { + fetchAll(); + const iv = setInterval(fetchAll, 15000); + return () => clearInterval(iv); + }, [fetchAll]); + + /* ── 파생 데이터 ─────────────────────────────────────────── */ + const winRate = stats.total_trades > 0 + ? ((stats.wins / stats.total_trades) * 100).toFixed(0) : "0"; + + // 일별 → 날짜순 정렬 (오래된 순) + const dailyAsc = [...daily].reverse(); + const dailyLabels = dailyAsc.map((d) => fmtDate(d.date)); + const dailyPnls = dailyAsc.map((d) => d.net_pnl || 0); + + // 누적 수익 + const cumData = []; + let cum = 0; + dailyAsc.forEach((d) => { + cum += d.net_pnl || 0; + cumData.push({ date: fmtDate(d.date), cumPnl: +cum.toFixed(4) }); + }); + + // 캔들 차트용 + const candleLabels = candles.map((c) => fmtTime(c.ts)); + + /* ── 현재 가격 (봇 상태 또는 마지막 캔들) ──────────────────── */ + const currentPrice = botStatus.current_price + || (candles.length ? candles[candles.length - 1].price : null); + + /* ── 공통 차트 축 스타일 ─────────────────────────────────── */ + const axisStyle = { + tick: { fill: "rgba(255,255,255,0.25)", fontSize: 10, fontFamily: "JetBrains Mono" }, + axisLine: false, tickLine: false, + }; + + return ( +
+ {/* BG glow */} +
+ +
+ {/* ═══ 헤더 ═══════════════════════════════════════════ */} +
+
+
+
+ + {isLive ? "Live" : "Connecting…"} · XRP/USDT + {currentPrice && ( + + {fmt(currentPrice)} + + )} + +
+

+ Trading Dashboard +

+
+ + {/* 오픈 포지션 */} + {position && ( +
+
OPEN POSITION
+
+ + {position.direction} {position.leverage || 10}x + + + {fmt(position.entry_price)} + + + SL {fmt(position.sl)} · TP {fmt(position.tp)} + +
+
+ )} +
+ + {/* ═══ 탭 ═════════════════════════════════════════════ */} +
+ {TABS.map((t) => ( + + ))} +
+ + {/* ═══ OVERVIEW ═══════════════════════════════════════ */} + {tab === "overview" && ( +
+ {/* Stats */} +
+ + + + +
+ + {/* 차트 */} +
+ + + ({ date: fmtDate(d.date), pnl: d.net_pnl || 0 }))}> + + + } /> + + {dailyAsc.map((d, i) => ( + = 0 ? S.green : S.red} fillOpacity={0.75} /> + ))} + + + + + + + + + + + + + + + + + } /> + + + + +
+ + {/* 최근 거래 */} +
+ 최근 거래 +
+ {trades.length === 0 && ( +
+ 거래 내역 없음 — 로그 파싱 대기 중 +
+ )} + {trades.slice(0, 3).map((t) => ( + setExpanded(expanded === t.id ? null : t.id)} + /> + ))} + {trades.length > 3 && ( +
setTab("trades")} + style={{ + textAlign: "center", padding: 12, color: S.indigo, + fontSize: 12, cursor: "pointer", fontFamily: S.mono, + background: "rgba(99,102,241,0.04)", borderRadius: 10, + marginTop: 6, + }} + > + 전체 {trades.length}건 보기 → +
+ )} +
+ )} + + {/* ═══ TRADES ═════════════════════════════════════════ */} + {tab === "trades" && ( +
+
+ 전체 거래 내역 ({trades.length}건) +
+ {trades.map((t) => ( + setExpanded(expanded === t.id ? null : t.id)} + /> + ))} +
+ )} + + {/* ═══ CHART ══════════════════════════════════════════ */} + {tab === "chart" && ( +
+ + + ({ ts: fmtTime(c.ts), price: c.price || c.close }))}> + + + + + + + + + + } /> + + + + + +
+ + + ({ ts: fmtTime(c.ts), rsi: c.rsi }))}> + + + + } /> + 70} stroke="rgba(248,113,113,0.2)" strokeDasharray="4 4" dot={false} name="과매수" /> + 30} stroke="rgba(139,92,246,0.2)" strokeDasharray="4 4" dot={false} name="과매도" /> + + + + + + + + ({ ts: fmtTime(c.ts), adx: c.adx }))}> + + + + + + + + + + } /> + 25} stroke="rgba(52,211,153,0.3)" strokeDasharray="4 4" dot={false} name="임계값" /> + + + + +
+
+ )} + + {/* ═══ 푸터 ═══════════════════════════════════════════ */} +
+ + {lastUpdate + ? `Synced: ${lastUpdate.toLocaleTimeString("ko-KR")} · 15s polling` + : "API 연결 대기 중…"} + +
+
+ + +
+ ); +} diff --git a/dashboard/ui/src/main.jsx b/dashboard/ui/src/main.jsx new file mode 100644 index 0000000..3885300 --- /dev/null +++ b/dashboard/ui/src/main.jsx @@ -0,0 +1,9 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +) diff --git a/dashboard/ui/vite.config.js b/dashboard/ui/vite.config.js new file mode 100644 index 0000000..caec31c --- /dev/null +++ b/dashboard/ui/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + proxy: { + '/api': 'http://localhost:8080' + } + } +}) diff --git a/docker-compose.yml b/docker-compose.yml index 7025dcf..6c21fa3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,3 +16,40 @@ services: options: max-size: "10m" max-file: "5" + + dashboard-api: + image: 10.1.10.28:3000/gihyeon/cointrader-dashboard-api:latest + container_name: dashboard-api + restart: unless-stopped + environment: + - TZ=Asia/Seoul + - LOG_DIR=/app/logs + - DB_PATH=/app/data/dashboard.db + - POLL_INTERVAL=5 + volumes: + - ./logs:/app/logs:ro + - dashboard-data:/app/data + depends_on: + - cointrader + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + + dashboard-ui: + image: 10.1.10.28:3000/gihyeon/cointrader-dashboard-ui:latest + container_name: dashboard-ui + restart: unless-stopped + ports: + - "8080:3000" + depends_on: + - dashboard-api + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + +volumes: + dashboard-data: diff --git a/src/logger_setup.py b/src/logger_setup.py index b513c31..9bca8a1 100644 --- a/src/logger_setup.py +++ b/src/logger_setup.py @@ -16,7 +16,7 @@ def setup_logger(log_level: str = "INFO"): colorize=True, ) logger.add( - "logs/bot_{time:YYYY-MM-DD}.log", + "logs/bot.log", rotation="00:00", retention="30 days", level="DEBUG",