diff --git a/dashboard/api/Dockerfile b/dashboard/api/Dockerfile index 33b1541..9796bff 100644 --- a/dashboard/api/Dockerfile +++ b/dashboard/api/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11-slim +FROM python:3.12-slim WORKDIR /app RUN pip install --no-cache-dir fastapi uvicorn COPY log_parser.py . diff --git a/dashboard/api/dashboard_api.py b/dashboard/api/dashboard_api.py index 752c5f2..c392219 100644 --- a/dashboard/api/dashboard_api.py +++ b/dashboard/api/dashboard_api.py @@ -5,27 +5,31 @@ dashboard_api.py — 멀티심볼 대시보드 API import sqlite3 import os import signal -from fastapi import FastAPI, Query +from fastapi import FastAPI, Query, Header, HTTPException from fastapi.middleware.cors import CORSMiddleware -from pathlib import Path from contextlib import contextmanager from typing import Optional DB_PATH = os.environ.get("DB_PATH", "/app/data/dashboard.db") +PARSER_PID_FILE = os.environ.get("PARSER_PID_FILE", "/tmp/parser.pid") +DASHBOARD_RESET_KEY = os.environ.get("DASHBOARD_RESET_KEY", "") +CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "").split(",") if os.environ.get("CORS_ORIGINS") else ["*"] app = FastAPI(title="Trading Dashboard API") app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=CORS_ORIGINS, allow_methods=["*"], allow_headers=["*"], ) @contextmanager def get_db(): - conn = sqlite3.connect(DB_PATH) + conn = sqlite3.connect(DB_PATH, timeout=10) conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=5000") try: yield conn finally: @@ -64,7 +68,7 @@ def get_position(symbol: Optional[str] = None): def get_trades( symbol: Optional[str] = None, limit: int = Query(50, ge=1, le=500), - offset: int = 0, + offset: int = Query(0, ge=0), ): with get_db() as db: if symbol: @@ -166,28 +170,28 @@ def health(): 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)} + except Exception: + return {"status": "error", "detail": "database unavailable"} @app.post("/api/reset") -def reset_db(): +def reset_db(x_api_key: Optional[str] = Header(None)): + """DB 초기화 + 파서에 SIGHUP으로 재파싱 요청.""" + # C1: API key 인증 (DASHBOARD_RESET_KEY가 설정된 경우) + if DASHBOARD_RESET_KEY and x_api_key != DASHBOARD_RESET_KEY: + raise HTTPException(status_code=403, detail="invalid api key") + with get_db() as db: for table in ["trades", "daily_pnl", "parse_state", "bot_status", "candles"]: db.execute(f"DELETE FROM {table}") db.commit() - import subprocess, signal - for pid_str in os.listdir("/proc") if os.path.isdir("/proc") else []: - if not pid_str.isdigit(): - continue - try: - with open(f"/proc/{pid_str}/cmdline", "r") as f: - cmdline = f.read() - if "log_parser.py" in cmdline and str(os.getpid()) != pid_str: - os.kill(int(pid_str), signal.SIGTERM) - except (FileNotFoundError, PermissionError, ProcessLookupError, OSError): - pass - subprocess.Popen(["python", "log_parser.py"]) + # C2: PID file + SIGHUP으로 파서에 재파싱 요청 (프로세스 재시작 불필요) + try: + with open(PARSER_PID_FILE) as f: + pid = int(f.read().strip()) + os.kill(pid, signal.SIGHUP) + except (FileNotFoundError, ValueError, ProcessLookupError, OSError): + pass - return {"status": "ok", "message": "DB 초기화 완료, 파서 재시작됨"} + return {"status": "ok", "message": "DB 초기화 완료, 파서 재파싱 시작"} diff --git a/dashboard/api/entrypoint.sh b/dashboard/api/entrypoint.sh index f6b6257..bc9d8ad 100644 --- a/dashboard/api/entrypoint.sh +++ b/dashboard/api/entrypoint.sh @@ -13,6 +13,22 @@ echo "Log parser started (PID: $PARSER_PID)" # 파서가 기존 로그를 처리할 시간 부여 sleep 3 -# FastAPI 서버 실행 +# SIGTERM/SIGINT → 파서에도 전달 후 대기 +cleanup() { + echo "Shutting down..." + kill -TERM "$PARSER_PID" 2>/dev/null + wait "$PARSER_PID" 2>/dev/null + kill -TERM "$UVICORN_PID" 2>/dev/null + wait "$UVICORN_PID" 2>/dev/null + exit 0 +} +trap cleanup SIGTERM SIGINT + +# FastAPI 서버를 백그라운드로 실행 (exec 대신 — 셸이 PID 1을 유지해야 signal forwarding 가능) echo "Starting API server on :8080" -exec uvicorn dashboard_api:app --host 0.0.0.0 --port 8080 --log-level info +uvicorn dashboard_api:app --host 0.0.0.0 --port 8080 --log-level info & +UVICORN_PID=$! + +# 자식 프로세스 중 하나라도 종료되면 전체 종료 +wait -n "$PARSER_PID" "$UVICORN_PID" 2>/dev/null +cleanup diff --git a/dashboard/api/log_parser.py b/dashboard/api/log_parser.py index 3af6b06..43ce869 100644 --- a/dashboard/api/log_parser.py +++ b/dashboard/api/log_parser.py @@ -11,7 +11,8 @@ import time import glob import os import json -import threading +import signal +import sys from datetime import datetime, date from pathlib import Path @@ -19,6 +20,7 @@ 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")) # 초 +PID_FILE = os.environ.get("PARSER_PID_FILE", "/tmp/parser.pid") # ── 정규식 패턴 (멀티심볼 [SYMBOL] 프리픽스 포함) ───────────────── PATTERNS = { @@ -94,15 +96,26 @@ PATTERNS = { class LogParser: def __init__(self): Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) - self.conn = sqlite3.connect(DB_PATH) + self.conn = sqlite3.connect(DB_PATH, timeout=10) self.conn.row_factory = sqlite3.Row self.conn.execute("PRAGMA journal_mode=WAL") + self.conn.execute("PRAGMA busy_timeout=5000") self._init_db() self._file_positions = {} self._current_positions = {} # {symbol: position_dict} self._pending_candles = {} # {symbol: {ts_key: {data}}} self._balance = 0 + self._shutdown = False + self._dirty = False # batch commit 플래그 + + # PID 파일 기록 + with open(PID_FILE, "w") as f: + f.write(str(os.getpid())) + + # 시그널 핸들러 + signal.signal(signal.SIGTERM, self._handle_sigterm) + signal.signal(signal.SIGHUP, self._handle_sighup) def _init_db(self): self.conn.executescript(""" @@ -215,8 +228,48 @@ class LogParser: "ON CONFLICT(filepath) DO UPDATE SET position=?", (filepath, pos, pos) ) + self._dirty = True + + def _handle_sigterm(self, signum, frame): + """Graceful shutdown — DB 커넥션을 안전하게 닫음.""" + print("[LogParser] SIGTERM 수신 — 종료") + self._shutdown = True + try: + if self._dirty: + self.conn.commit() + self.conn.close() + except Exception: + pass + try: + os.unlink(PID_FILE) + except OSError: + pass + sys.exit(0) + + def _handle_sighup(self, signum, frame): + """SIGHUP → 파싱 상태 초기화, 처음부터 재파싱.""" + print("[LogParser] SIGHUP 수신 — 상태 초기화, 재파싱 시작") + self._file_positions = {} + self._current_positions = {} + self._pending_candles = {} + self.conn.execute("DELETE FROM parse_state") self.conn.commit() + def _batch_commit(self): + """배치 커밋 — _dirty 플래그가 설정된 경우에만 커밋.""" + if self._dirty: + self.conn.commit() + self._dirty = False + + def _cleanup_pending_candles(self, max_per_symbol=50): + """오래된 pending candle 데이터 정리 (I4: 메모리 누적 방지).""" + for symbol in list(self._pending_candles): + pending = self._pending_candles[symbol] + if len(pending) > max_per_symbol: + keys = sorted(pending.keys()) + for k in keys[:-max_per_symbol]: + del pending[k] + def _set_status(self, key, value): now = datetime.now().isoformat() self.conn.execute( @@ -224,12 +277,12 @@ class LogParser: "ON CONFLICT(key) DO UPDATE SET value=?, updated_at=?", (key, str(value), now, str(value), now) ) - self.conn.commit() + self._dirty = True # ── 메인 루프 ──────────────────────────────────────────────── def run(self): print(f"[LogParser] 시작 — LOG_DIR={LOG_DIR}, DB={DB_PATH}, 폴링={POLL_INTERVAL}s") - while True: + while not self._shutdown: try: self._scan_logs() except Exception as e: @@ -237,12 +290,11 @@ class LogParser: time.sleep(POLL_INTERVAL) def _scan_logs(self): - 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) + log_files = sorted(set(glob.glob(os.path.join(LOG_DIR, "bot*.log")))) for filepath in log_files: self._parse_file(filepath) + self._batch_commit() + self._cleanup_pending_candles() def _parse_file(self, filepath): last_pos = self._file_positions.get(filepath, 0) @@ -387,7 +439,7 @@ class LogParser: price, signal, extra.get("adx"), extra.get("oi"), extra.get("oi_change"), extra.get("funding")), ) - self.conn.commit() + self._dirty = True except Exception as e: print(f"[LogParser] 캔들 저장 에러: {e}") return @@ -419,7 +471,7 @@ class LogParser: ON CONFLICT(symbol, date) DO UPDATE SET cumulative_pnl=?, last_updated=?""", (symbol, day, pnl, ts, pnl, ts) ) - self.conn.commit() + self._dirty = True self._set_status(f"{symbol}:daily_pnl", str(pnl)) return @@ -461,7 +513,7 @@ class LogParser: json.dumps({"recovery": is_recovery}), rsi, macd_hist, atr), ) - self.conn.commit() + self._dirty = True self._current_positions[symbol] = { "id": cur.lastrowid, "direction": direction, @@ -498,6 +550,8 @@ class LogParser: reason, primary_id) ) + self._dirty = True + if len(open_trades) > 1: stale_ids = [r["id"] for r in open_trades[1:]] self.conn.execute( @@ -506,21 +560,24 @@ class LogParser: ) print(f"[LogParser] {symbol} 중복 OPEN 거래 {len(stale_ids)}건 삭제") - # 심볼별 일별 요약 + # 심볼별 일별 요약 (trades 테이블에서 재계산 — idempotent) day = ts[:10] - win = 1 if net_pnl > 0 else 0 - loss = 1 if net_pnl <= 0 else 0 + row = self.conn.execute( + """SELECT COUNT(*) as cnt, + 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 + FROM trades WHERE status='CLOSED' AND symbol=? AND exit_time LIKE ?""", + (symbol, f"{day}%"), + ).fetchone() self.conn.execute( - """INSERT INTO daily_pnl(symbol, date, cumulative_pnl, trade_count, wins, losses, last_updated) - VALUES(?, ?, ?, 1, ?, ?, ?) + """INSERT INTO daily_pnl(symbol, date, trade_count, wins, losses, last_updated) + VALUES(?, ?, ?, ?, ?, ?) ON CONFLICT(symbol, date) DO UPDATE SET - trade_count = trade_count + 1, - wins = wins + ?, - losses = losses + ?, - last_updated = ?""", - (symbol, day, net_pnl, win, loss, ts, win, loss, ts) + trade_count=?, wins=?, losses=?, last_updated=?""", + (symbol, day, row["cnt"], row["wins"], row["losses"], ts, + row["cnt"], row["wins"], row["losses"], ts), ) - self.conn.commit() + self._dirty = True self._set_status(f"{symbol}:position_status", "NONE") print(f"[LogParser] {symbol} 포지션 청산: {reason} @ {exit_price}, PnL={net_pnl}") @@ -529,4 +586,10 @@ class LogParser: if __name__ == "__main__": parser = LogParser() - parser.run() + try: + parser.run() + finally: + try: + os.unlink(PID_FILE) + except OSError: + pass diff --git a/dashboard/ui/.dockerignore b/dashboard/ui/.dockerignore new file mode 100644 index 0000000..a21f178 --- /dev/null +++ b/dashboard/ui/.dockerignore @@ -0,0 +1,3 @@ +node_modules +dist +.git diff --git a/dashboard/ui/src/App.jsx b/dashboard/ui/src/App.jsx index f69340c..4bdac94 100644 --- a/dashboard/ui/src/App.jsx +++ b/dashboard/ui/src/App.jsx @@ -278,6 +278,7 @@ export default function App() { const [positions, setPositions] = useState([]); const [botStatus, setBotStatus] = useState({}); const [trades, setTrades] = useState([]); + const [tradesTotal, setTradesTotal] = useState(0); const [daily, setDaily] = useState([]); const [candles, setCandles] = useState([]); @@ -308,7 +309,10 @@ export default function App() { setPositions(pRes.positions || []); if (pRes.bot) setBotStatus(pRes.bot); } - if (tRes?.trades) setTrades(tRes.trades); + if (tRes?.trades) { + setTrades(tRes.trades); + setTradesTotal(tRes.total || tRes.trades.length); + } if (dRes?.daily) setDaily(dRes.daily); if (cRes?.candles) setCandles(cRes.candles); }, [selectedSymbol]); @@ -415,7 +419,7 @@ export default function App() { ? ((isShort ? entP - curP : curP - entP) / entP * 100 * (pos.leverage || 10)) : null); const pnlUsdt = uPnl != null ? parseFloat(uPnl) : null; - const pnlColor = pnlPct > 0 ? S.green : pnlPct < 0 ? S.red : S.text3; + const posPnlColor = pnlPct > 0 ? S.green : pnlPct < 0 ? S.red : S.text3; return (