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:
21in7
2026-03-20 00:00:16 +09:00
parent f14c521302
commit 9f0057e29d
7 changed files with 253 additions and 66 deletions

View File

@@ -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 .

View File

@@ -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 초기화 완료, 파서 재파싱 시작"}

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,3 @@
node_modules
dist
.git

View File

@@ -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 (
<div key={pos.id} style={{
background: "linear-gradient(135deg,rgba(99,102,241,0.08) 0%,rgba(99,102,241,0.02) 100%)",
@@ -436,7 +440,7 @@ export default function App() {
{fmt(pos.entry_price)}
</span>
{pnlPct !== null && (
<span style={{ fontSize: 13, fontWeight: 700, fontFamily: S.mono, color: pnlColor }}>
<span style={{ fontSize: 13, fontWeight: 700, fontFamily: S.mono, color: posPnlColor }}>
{pnlUsdt != null ? `${pnlUsdt > 0 ? "+" : ""}${pnlUsdt.toFixed(4)}` : ""}
{` (${pnlPct > 0 ? "+" : ""}${pnlPct.toFixed(2)}%)`}
</span>
@@ -594,7 +598,7 @@ export default function App() {
marginTop: 6,
}}
>
전체 {trades.length} 보기
전체 {tradesTotal} 보기
</div>
)}
</div>
@@ -607,7 +611,7 @@ export default function App() {
fontSize: 10, color: S.text3, letterSpacing: 1.2,
fontFamily: S.mono, textTransform: "uppercase", marginBottom: 12,
}}>
전체 거래 내역 ({trades.length})
전체 거래 내역 ({tradesTotal})
</div>
{trades.map((t) => (
<TradeRow
@@ -648,17 +652,22 @@ export default function App() {
display: "grid", gridTemplateColumns: "1fr 1fr",
gap: 10, marginTop: 12,
}}>
<ChartBox title="RSI">
<ChartBox title="OI 변화율">
<ResponsiveContainer width="100%" height={150}>
<LineChart data={candles.map((c) => ({ ts: fmtTime(c.ts), rsi: c.rsi }))}>
<AreaChart data={candles.map((c) => ({ ts: fmtTime(c.ts), oi_change: c.oi_change }))}>
<defs>
<linearGradient id="gOI" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={S.amber} stopOpacity={0.15} />
<stop offset="100%" stopColor={S.amber} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" />
<XAxis dataKey="ts" {...axisStyle} interval="preserveStartEnd" />
<YAxis domain={[0, 100]} {...axisStyle} />
<YAxis {...axisStyle} />
<Tooltip content={<ChartTooltip />} />
<Line type="monotone" dataKey={() => 70} stroke="rgba(248,113,113,0.2)" strokeDasharray="4 4" dot={false} name="과매수" />
<Line type="monotone" dataKey={() => 30} stroke="rgba(139,92,246,0.2)" strokeDasharray="4 4" dot={false} name="과매도" />
<Line type="monotone" dataKey="rsi" name="RSI" stroke={S.amber} strokeWidth={1.5} dot={false} />
</LineChart>
<Line type="monotone" dataKey={() => 0} stroke="rgba(255,255,255,0.1)" strokeDasharray="4 4" dot={false} name="기준선" />
<Area type="monotone" dataKey="oi_change" name="OI변화율" stroke={S.amber} strokeWidth={1.5} fill="url(#gOI)" dot={false} />
</AreaChart>
</ResponsiveContainer>
</ChartBox>
@@ -697,10 +706,16 @@ export default function App() {
</span>
<button
onClick={async () => {
const key = prompt("Reset API Key를 입력하세요:");
if (!key) return;
if (!confirm("DB를 초기화하고 로그를 처음부터 다시 파싱합니다. 계속할까요?")) return;
try {
const r = await fetch("/api/reset", { method: "POST" });
const r = await fetch("/api/reset", {
method: "POST",
headers: { "X-API-Key": key },
});
if (r.ok) { alert("초기화 완료. 잠시 후 데이터가 다시 채워집니다."); location.reload(); }
else if (r.status === 403) alert("API Key가 올바르지 않습니다.");
else alert("초기화 실패: " + r.statusText);
} catch (e) { alert("초기화 실패: " + e.message); }
}}

View File

@@ -6,10 +6,11 @@ import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "dashboard", "api"))
# DB_PATH를 테스트용 임시 파일로 설정 (import 전에)
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
os.environ["DB_PATH"] = _tmp_db.name
_tmp_db.close()
# DB_PATH와 DASHBOARD_RESET_KEY를 테스트용으로 설정 (import 전에)
_tmp_dir = tempfile.mkdtemp()
_tmp_db_path = os.path.join(_tmp_dir, "test_dashboard.db")
os.environ["DB_PATH"] = _tmp_db_path
os.environ["DASHBOARD_RESET_KEY"] = "test-reset-key"
import dashboard_api # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
@@ -90,9 +91,18 @@ def setup_db():
"INSERT INTO candles(symbol,ts,price,signal) VALUES(?,?,?,?)",
("TRXUSDT", "2026-03-06 00:00:00", 0.23, "SHORT"),
)
conn.execute(
"INSERT INTO daily_pnl(symbol,date,cumulative_pnl,trade_count,wins,losses) VALUES(?,?,?,?,?,?)",
("TRXUSDT", "2026-03-05", 10.0, 1, 1, 0),
)
conn.commit()
conn.close()
yield
# cleanup
try:
os.unlink(db_path)
except OSError:
pass
client = TestClient(dashboard_api.app)
@@ -122,8 +132,10 @@ def test_get_position_by_symbol():
def test_get_trades_by_symbol():
r = client.get("/api/trades?symbol=TRXUSDT")
assert r.status_code == 200
assert len(r.json()["trades"]) == 1
assert r.json()["trades"][0]["symbol"] == "TRXUSDT"
data = r.json()
assert len(data["trades"]) == 1
assert data["trades"][0]["symbol"] == "TRXUSDT"
assert data["total"] == 1
def test_get_candles_by_symbol():
@@ -142,3 +154,77 @@ def test_get_stats_by_symbol():
r = client.get("/api/stats?symbol=TRXUSDT")
assert r.status_code == 200
assert r.json()["total_trades"] == 1
# ── M6: 누락된 테스트 추가 ──────────────────────────────────────
def test_health():
r = client.get("/api/health")
assert r.status_code == 200
data = r.json()
assert data["status"] == "ok"
assert data["candles_count"] >= 0
def test_daily():
r = client.get("/api/daily?symbol=TRXUSDT")
assert r.status_code == 200
data = r.json()
assert len(data["daily"]) == 1
assert data["daily"][0]["net_pnl"] == 10.0
def test_daily_all():
r = client.get("/api/daily")
assert r.status_code == 200
assert "daily" in r.json()
def test_reset_requires_api_key():
"""C1: API key 없이 reset 호출 시 403."""
r = client.post("/api/reset")
assert r.status_code == 403
def test_reset_wrong_api_key():
"""C1: 잘못된 API key로 reset 호출 시 403."""
r = client.post("/api/reset", headers={"X-API-Key": "wrong-key"})
assert r.status_code == 403
def test_reset_with_valid_key():
"""C1+C2: 올바른 API key로 reset 호출 시 성공."""
r = client.post("/api/reset", headers={"X-API-Key": "test-reset-key"})
assert r.status_code == 200
assert r.json()["status"] == "ok"
# DB가 비워졌는지 확인
r2 = client.get("/api/trades")
assert r2.json()["total"] == 0
def test_trades_offset_validation():
"""I2: 음수 offset은 422 에러."""
r = client.get("/api/trades?offset=-1")
assert r.status_code == 422
def test_trades_pagination():
"""M6: 페이지네이션 동작 확인."""
r = client.get("/api/trades?limit=1&offset=0")
assert r.status_code == 200
data = r.json()
assert len(data["trades"]) <= 1
assert "total" in data
def test_health_error_no_detail_leak():
"""I6: health에서 에러 시 내부 경로 미노출."""
# 일시적으로 DB 경로를 존재하지 않는 곳으로 설정
original = dashboard_api.DB_PATH
dashboard_api.DB_PATH = "/nonexistent/path/db.sqlite"
r = client.get("/api/health")
dashboard_api.DB_PATH = original
data = r.json()
assert data["status"] == "error"
assert "/nonexistent" not in data.get("detail", "")