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:
@@ -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 .
|
||||
|
||||
@@ -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 초기화 완료, 파서 재파싱 시작"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
3
dashboard/ui/.dockerignore
Normal file
3
dashboard/ui/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
@@ -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); }
|
||||
}}
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
Reference in New Issue
Block a user