fix(dashboard): address code review — auth, DB stability, idempotency, UI fixes
C1: /api/reset에 API key 인증 추가 (DASHBOARD_RESET_KEY 환경변수) C2: /proc 스캐닝 제거, PID file + SIGHUP 기반 파서 재파싱으로 교체 C3: daily_pnl 업데이트를 trades 테이블에서 재계산하여 idempotent하게 변경 I1: CORS origins를 CORS_ORIGINS 환경변수로 설정 가능하게 변경 I2: offset 파라미터에 ge=0 검증 추가 I3: 매 줄 commit → 파일 단위 배치 commit으로 성능 개선 I4: _pending_candles 크기 제한으로 메모리 누적 방지 I5: bot.log glob 중복 파싱 제거 (sorted(set(...))) I6: /api/health 에러 메시지에서 내부 경로 미노출 I7: RSI 차트(데이터 없음)를 OI 변화율 차트로 교체 M1: pnlColor 변수 shadowing 수정 (posPnlColor) M2: 거래 목록에 API total 필드 사용 M3: dashboard/ui/.dockerignore 추가 M4: API Dockerfile Python 3.11→3.12 M5: 테스트 fixture에서 temp DB cleanup 추가 M6: 누락 테스트 9건 추가 (health, daily, reset 인증, offset, pagination) M7: 파서 SIGTERM graceful shutdown + entrypoint.sh signal forwarding DB: 양쪽 busy_timeout=5000 + WAL pragma 설정 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user