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
|
WORKDIR /app
|
||||||
RUN pip install --no-cache-dir fastapi uvicorn
|
RUN pip install --no-cache-dir fastapi uvicorn
|
||||||
COPY log_parser.py .
|
COPY log_parser.py .
|
||||||
|
|||||||
@@ -5,27 +5,31 @@ dashboard_api.py — 멀티심볼 대시보드 API
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
from fastapi import FastAPI, Query
|
from fastapi import FastAPI, Query, Header, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from pathlib import Path
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
DB_PATH = os.environ.get("DB_PATH", "/app/data/dashboard.db")
|
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 = FastAPI(title="Trading Dashboard API")
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=CORS_ORIGINS,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def get_db():
|
def get_db():
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH, timeout=10)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA busy_timeout=5000")
|
||||||
try:
|
try:
|
||||||
yield conn
|
yield conn
|
||||||
finally:
|
finally:
|
||||||
@@ -64,7 +68,7 @@ def get_position(symbol: Optional[str] = None):
|
|||||||
def get_trades(
|
def get_trades(
|
||||||
symbol: Optional[str] = None,
|
symbol: Optional[str] = None,
|
||||||
limit: int = Query(50, ge=1, le=500),
|
limit: int = Query(50, ge=1, le=500),
|
||||||
offset: int = 0,
|
offset: int = Query(0, ge=0),
|
||||||
):
|
):
|
||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
if symbol:
|
if symbol:
|
||||||
@@ -166,28 +170,28 @@ def health():
|
|||||||
with get_db() as db:
|
with get_db() as db:
|
||||||
cnt = db.execute("SELECT COUNT(*) as c FROM candles").fetchone()["c"]
|
cnt = db.execute("SELECT COUNT(*) as c FROM candles").fetchone()["c"]
|
||||||
return {"status": "ok", "candles_count": cnt}
|
return {"status": "ok", "candles_count": cnt}
|
||||||
except Exception as e:
|
except Exception:
|
||||||
return {"status": "error", "detail": str(e)}
|
return {"status": "error", "detail": "database unavailable"}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/reset")
|
@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:
|
with get_db() as db:
|
||||||
for table in ["trades", "daily_pnl", "parse_state", "bot_status", "candles"]:
|
for table in ["trades", "daily_pnl", "parse_state", "bot_status", "candles"]:
|
||||||
db.execute(f"DELETE FROM {table}")
|
db.execute(f"DELETE FROM {table}")
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
import subprocess, signal
|
# C2: PID file + SIGHUP으로 파서에 재파싱 요청 (프로세스 재시작 불필요)
|
||||||
for pid_str in os.listdir("/proc") if os.path.isdir("/proc") else []:
|
try:
|
||||||
if not pid_str.isdigit():
|
with open(PARSER_PID_FILE) as f:
|
||||||
continue
|
pid = int(f.read().strip())
|
||||||
try:
|
os.kill(pid, signal.SIGHUP)
|
||||||
with open(f"/proc/{pid_str}/cmdline", "r") as f:
|
except (FileNotFoundError, ValueError, ProcessLookupError, OSError):
|
||||||
cmdline = f.read()
|
pass
|
||||||
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"])
|
|
||||||
|
|
||||||
return {"status": "ok", "message": "DB 초기화 완료, 파서 재시작됨"}
|
return {"status": "ok", "message": "DB 초기화 완료, 파서 재파싱 시작"}
|
||||||
|
|||||||
@@ -13,6 +13,22 @@ echo "Log parser started (PID: $PARSER_PID)"
|
|||||||
# 파서가 기존 로그를 처리할 시간 부여
|
# 파서가 기존 로그를 처리할 시간 부여
|
||||||
sleep 3
|
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"
|
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 glob
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
import threading
|
import signal
|
||||||
|
import sys
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ from pathlib import Path
|
|||||||
LOG_DIR = os.environ.get("LOG_DIR", "/app/logs")
|
LOG_DIR = os.environ.get("LOG_DIR", "/app/logs")
|
||||||
DB_PATH = os.environ.get("DB_PATH", "/app/data/dashboard.db")
|
DB_PATH = os.environ.get("DB_PATH", "/app/data/dashboard.db")
|
||||||
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "5")) # 초
|
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "5")) # 초
|
||||||
|
PID_FILE = os.environ.get("PARSER_PID_FILE", "/tmp/parser.pid")
|
||||||
|
|
||||||
# ── 정규식 패턴 (멀티심볼 [SYMBOL] 프리픽스 포함) ─────────────────
|
# ── 정규식 패턴 (멀티심볼 [SYMBOL] 프리픽스 포함) ─────────────────
|
||||||
PATTERNS = {
|
PATTERNS = {
|
||||||
@@ -94,15 +96,26 @@ PATTERNS = {
|
|||||||
class LogParser:
|
class LogParser:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
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.row_factory = sqlite3.Row
|
||||||
self.conn.execute("PRAGMA journal_mode=WAL")
|
self.conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
self.conn.execute("PRAGMA busy_timeout=5000")
|
||||||
self._init_db()
|
self._init_db()
|
||||||
|
|
||||||
self._file_positions = {}
|
self._file_positions = {}
|
||||||
self._current_positions = {} # {symbol: position_dict}
|
self._current_positions = {} # {symbol: position_dict}
|
||||||
self._pending_candles = {} # {symbol: {ts_key: {data}}}
|
self._pending_candles = {} # {symbol: {ts_key: {data}}}
|
||||||
self._balance = 0
|
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):
|
def _init_db(self):
|
||||||
self.conn.executescript("""
|
self.conn.executescript("""
|
||||||
@@ -215,8 +228,48 @@ class LogParser:
|
|||||||
"ON CONFLICT(filepath) DO UPDATE SET position=?",
|
"ON CONFLICT(filepath) DO UPDATE SET position=?",
|
||||||
(filepath, pos, pos)
|
(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()
|
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):
|
def _set_status(self, key, value):
|
||||||
now = datetime.now().isoformat()
|
now = datetime.now().isoformat()
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
@@ -224,12 +277,12 @@ class LogParser:
|
|||||||
"ON CONFLICT(key) DO UPDATE SET value=?, updated_at=?",
|
"ON CONFLICT(key) DO UPDATE SET value=?, updated_at=?",
|
||||||
(key, str(value), now, str(value), now)
|
(key, str(value), now, str(value), now)
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
self._dirty = True
|
||||||
|
|
||||||
# ── 메인 루프 ────────────────────────────────────────────────
|
# ── 메인 루프 ────────────────────────────────────────────────
|
||||||
def run(self):
|
def run(self):
|
||||||
print(f"[LogParser] 시작 — LOG_DIR={LOG_DIR}, DB={DB_PATH}, 폴링={POLL_INTERVAL}s")
|
print(f"[LogParser] 시작 — LOG_DIR={LOG_DIR}, DB={DB_PATH}, 폴링={POLL_INTERVAL}s")
|
||||||
while True:
|
while not self._shutdown:
|
||||||
try:
|
try:
|
||||||
self._scan_logs()
|
self._scan_logs()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -237,12 +290,11 @@ class LogParser:
|
|||||||
time.sleep(POLL_INTERVAL)
|
time.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
def _scan_logs(self):
|
def _scan_logs(self):
|
||||||
log_files = sorted(glob.glob(os.path.join(LOG_DIR, "bot*.log")))
|
log_files = sorted(set(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:
|
for filepath in log_files:
|
||||||
self._parse_file(filepath)
|
self._parse_file(filepath)
|
||||||
|
self._batch_commit()
|
||||||
|
self._cleanup_pending_candles()
|
||||||
|
|
||||||
def _parse_file(self, filepath):
|
def _parse_file(self, filepath):
|
||||||
last_pos = self._file_positions.get(filepath, 0)
|
last_pos = self._file_positions.get(filepath, 0)
|
||||||
@@ -387,7 +439,7 @@ class LogParser:
|
|||||||
price, signal,
|
price, signal,
|
||||||
extra.get("adx"), extra.get("oi"), extra.get("oi_change"), extra.get("funding")),
|
extra.get("adx"), extra.get("oi"), extra.get("oi_change"), extra.get("funding")),
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
self._dirty = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[LogParser] 캔들 저장 에러: {e}")
|
print(f"[LogParser] 캔들 저장 에러: {e}")
|
||||||
return
|
return
|
||||||
@@ -419,7 +471,7 @@ class LogParser:
|
|||||||
ON CONFLICT(symbol, date) DO UPDATE SET cumulative_pnl=?, last_updated=?""",
|
ON CONFLICT(symbol, date) DO UPDATE SET cumulative_pnl=?, last_updated=?""",
|
||||||
(symbol, day, pnl, ts, pnl, ts)
|
(symbol, day, pnl, ts, pnl, ts)
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
self._dirty = True
|
||||||
self._set_status(f"{symbol}:daily_pnl", str(pnl))
|
self._set_status(f"{symbol}:daily_pnl", str(pnl))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -461,7 +513,7 @@ class LogParser:
|
|||||||
json.dumps({"recovery": is_recovery}),
|
json.dumps({"recovery": is_recovery}),
|
||||||
rsi, macd_hist, atr),
|
rsi, macd_hist, atr),
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
self._dirty = True
|
||||||
self._current_positions[symbol] = {
|
self._current_positions[symbol] = {
|
||||||
"id": cur.lastrowid,
|
"id": cur.lastrowid,
|
||||||
"direction": direction,
|
"direction": direction,
|
||||||
@@ -498,6 +550,8 @@ class LogParser:
|
|||||||
reason, primary_id)
|
reason, primary_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._dirty = True
|
||||||
|
|
||||||
if len(open_trades) > 1:
|
if len(open_trades) > 1:
|
||||||
stale_ids = [r["id"] for r in open_trades[1:]]
|
stale_ids = [r["id"] for r in open_trades[1:]]
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
@@ -506,21 +560,24 @@ class LogParser:
|
|||||||
)
|
)
|
||||||
print(f"[LogParser] {symbol} 중복 OPEN 거래 {len(stale_ids)}건 삭제")
|
print(f"[LogParser] {symbol} 중복 OPEN 거래 {len(stale_ids)}건 삭제")
|
||||||
|
|
||||||
# 심볼별 일별 요약
|
# 심볼별 일별 요약 (trades 테이블에서 재계산 — idempotent)
|
||||||
day = ts[:10]
|
day = ts[:10]
|
||||||
win = 1 if net_pnl > 0 else 0
|
row = self.conn.execute(
|
||||||
loss = 1 if net_pnl <= 0 else 0
|
"""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(
|
self.conn.execute(
|
||||||
"""INSERT INTO daily_pnl(symbol, date, cumulative_pnl, trade_count, wins, losses, last_updated)
|
"""INSERT INTO daily_pnl(symbol, date, trade_count, wins, losses, last_updated)
|
||||||
VALUES(?, ?, ?, 1, ?, ?, ?)
|
VALUES(?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(symbol, date) DO UPDATE SET
|
ON CONFLICT(symbol, date) DO UPDATE SET
|
||||||
trade_count = trade_count + 1,
|
trade_count=?, wins=?, losses=?, last_updated=?""",
|
||||||
wins = wins + ?,
|
(symbol, day, row["cnt"], row["wins"], row["losses"], ts,
|
||||||
losses = losses + ?,
|
row["cnt"], row["wins"], row["losses"], ts),
|
||||||
last_updated = ?""",
|
|
||||||
(symbol, day, net_pnl, win, loss, ts, win, loss, ts)
|
|
||||||
)
|
)
|
||||||
self.conn.commit()
|
self._dirty = True
|
||||||
|
|
||||||
self._set_status(f"{symbol}:position_status", "NONE")
|
self._set_status(f"{symbol}:position_status", "NONE")
|
||||||
print(f"[LogParser] {symbol} 포지션 청산: {reason} @ {exit_price}, PnL={net_pnl}")
|
print(f"[LogParser] {symbol} 포지션 청산: {reason} @ {exit_price}, PnL={net_pnl}")
|
||||||
@@ -529,4 +586,10 @@ class LogParser:
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = LogParser()
|
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 [positions, setPositions] = useState([]);
|
||||||
const [botStatus, setBotStatus] = useState({});
|
const [botStatus, setBotStatus] = useState({});
|
||||||
const [trades, setTrades] = useState([]);
|
const [trades, setTrades] = useState([]);
|
||||||
|
const [tradesTotal, setTradesTotal] = useState(0);
|
||||||
const [daily, setDaily] = useState([]);
|
const [daily, setDaily] = useState([]);
|
||||||
const [candles, setCandles] = useState([]);
|
const [candles, setCandles] = useState([]);
|
||||||
|
|
||||||
@@ -308,7 +309,10 @@ export default function App() {
|
|||||||
setPositions(pRes.positions || []);
|
setPositions(pRes.positions || []);
|
||||||
if (pRes.bot) setBotStatus(pRes.bot);
|
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 (dRes?.daily) setDaily(dRes.daily);
|
||||||
if (cRes?.candles) setCandles(cRes.candles);
|
if (cRes?.candles) setCandles(cRes.candles);
|
||||||
}, [selectedSymbol]);
|
}, [selectedSymbol]);
|
||||||
@@ -415,7 +419,7 @@ export default function App() {
|
|||||||
? ((isShort ? entP - curP : curP - entP) / entP * 100 * (pos.leverage || 10))
|
? ((isShort ? entP - curP : curP - entP) / entP * 100 * (pos.leverage || 10))
|
||||||
: null);
|
: null);
|
||||||
const pnlUsdt = uPnl != null ? parseFloat(uPnl) : 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 (
|
return (
|
||||||
<div key={pos.id} style={{
|
<div key={pos.id} style={{
|
||||||
background: "linear-gradient(135deg,rgba(99,102,241,0.08) 0%,rgba(99,102,241,0.02) 100%)",
|
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)}
|
{fmt(pos.entry_price)}
|
||||||
</span>
|
</span>
|
||||||
{pnlPct !== null && (
|
{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)}` : ""}
|
{pnlUsdt != null ? `${pnlUsdt > 0 ? "+" : ""}${pnlUsdt.toFixed(4)}` : ""}
|
||||||
{` (${pnlPct > 0 ? "+" : ""}${pnlPct.toFixed(2)}%)`}
|
{` (${pnlPct > 0 ? "+" : ""}${pnlPct.toFixed(2)}%)`}
|
||||||
</span>
|
</span>
|
||||||
@@ -594,7 +598,7 @@ export default function App() {
|
|||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
전체 {trades.length}건 보기 →
|
전체 {tradesTotal}건 보기 →
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -607,7 +611,7 @@ export default function App() {
|
|||||||
fontSize: 10, color: S.text3, letterSpacing: 1.2,
|
fontSize: 10, color: S.text3, letterSpacing: 1.2,
|
||||||
fontFamily: S.mono, textTransform: "uppercase", marginBottom: 12,
|
fontFamily: S.mono, textTransform: "uppercase", marginBottom: 12,
|
||||||
}}>
|
}}>
|
||||||
전체 거래 내역 ({trades.length}건)
|
전체 거래 내역 ({tradesTotal}건)
|
||||||
</div>
|
</div>
|
||||||
{trades.map((t) => (
|
{trades.map((t) => (
|
||||||
<TradeRow
|
<TradeRow
|
||||||
@@ -648,17 +652,22 @@ export default function App() {
|
|||||||
display: "grid", gridTemplateColumns: "1fr 1fr",
|
display: "grid", gridTemplateColumns: "1fr 1fr",
|
||||||
gap: 10, marginTop: 12,
|
gap: 10, marginTop: 12,
|
||||||
}}>
|
}}>
|
||||||
<ChartBox title="RSI">
|
<ChartBox title="OI 변화율">
|
||||||
<ResponsiveContainer width="100%" height={150}>
|
<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)" />
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" />
|
||||||
<XAxis dataKey="ts" {...axisStyle} interval="preserveStartEnd" />
|
<XAxis dataKey="ts" {...axisStyle} interval="preserveStartEnd" />
|
||||||
<YAxis domain={[0, 100]} {...axisStyle} />
|
<YAxis {...axisStyle} />
|
||||||
<Tooltip content={<ChartTooltip />} />
|
<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={() => 0} stroke="rgba(255,255,255,0.1)" strokeDasharray="4 4" dot={false} name="기준선" />
|
||||||
<Line type="monotone" dataKey={() => 30} stroke="rgba(139,92,246,0.2)" 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} />
|
||||||
<Line type="monotone" dataKey="rsi" name="RSI" stroke={S.amber} strokeWidth={1.5} dot={false} />
|
</AreaChart>
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</ChartBox>
|
</ChartBox>
|
||||||
|
|
||||||
@@ -697,10 +706,16 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
const key = prompt("Reset API Key를 입력하세요:");
|
||||||
|
if (!key) return;
|
||||||
if (!confirm("DB를 초기화하고 로그를 처음부터 다시 파싱합니다. 계속할까요?")) return;
|
if (!confirm("DB를 초기화하고 로그를 처음부터 다시 파싱합니다. 계속할까요?")) return;
|
||||||
try {
|
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(); }
|
if (r.ok) { alert("초기화 완료. 잠시 후 데이터가 다시 채워집니다."); location.reload(); }
|
||||||
|
else if (r.status === 403) alert("API Key가 올바르지 않습니다.");
|
||||||
else alert("초기화 실패: " + r.statusText);
|
else alert("초기화 실패: " + r.statusText);
|
||||||
} catch (e) { alert("초기화 실패: " + e.message); }
|
} catch (e) { alert("초기화 실패: " + e.message); }
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import pytest
|
|||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "dashboard", "api"))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "dashboard", "api"))
|
||||||
|
|
||||||
# DB_PATH를 테스트용 임시 파일로 설정 (import 전에)
|
# DB_PATH와 DASHBOARD_RESET_KEY를 테스트용으로 설정 (import 전에)
|
||||||
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
_tmp_dir = tempfile.mkdtemp()
|
||||||
os.environ["DB_PATH"] = _tmp_db.name
|
_tmp_db_path = os.path.join(_tmp_dir, "test_dashboard.db")
|
||||||
_tmp_db.close()
|
os.environ["DB_PATH"] = _tmp_db_path
|
||||||
|
os.environ["DASHBOARD_RESET_KEY"] = "test-reset-key"
|
||||||
|
|
||||||
import dashboard_api # noqa: E402
|
import dashboard_api # noqa: E402
|
||||||
from fastapi.testclient import TestClient # noqa: E402
|
from fastapi.testclient import TestClient # noqa: E402
|
||||||
@@ -90,9 +91,18 @@ def setup_db():
|
|||||||
"INSERT INTO candles(symbol,ts,price,signal) VALUES(?,?,?,?)",
|
"INSERT INTO candles(symbol,ts,price,signal) VALUES(?,?,?,?)",
|
||||||
("TRXUSDT", "2026-03-06 00:00:00", 0.23, "SHORT"),
|
("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.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
yield
|
yield
|
||||||
|
# cleanup
|
||||||
|
try:
|
||||||
|
os.unlink(db_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
client = TestClient(dashboard_api.app)
|
client = TestClient(dashboard_api.app)
|
||||||
@@ -122,8 +132,10 @@ def test_get_position_by_symbol():
|
|||||||
def test_get_trades_by_symbol():
|
def test_get_trades_by_symbol():
|
||||||
r = client.get("/api/trades?symbol=TRXUSDT")
|
r = client.get("/api/trades?symbol=TRXUSDT")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert len(r.json()["trades"]) == 1
|
data = r.json()
|
||||||
assert r.json()["trades"][0]["symbol"] == "TRXUSDT"
|
assert len(data["trades"]) == 1
|
||||||
|
assert data["trades"][0]["symbol"] == "TRXUSDT"
|
||||||
|
assert data["total"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_get_candles_by_symbol():
|
def test_get_candles_by_symbol():
|
||||||
@@ -142,3 +154,77 @@ def test_get_stats_by_symbol():
|
|||||||
r = client.get("/api/stats?symbol=TRXUSDT")
|
r = client.get("/api/stats?symbol=TRXUSDT")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json()["total_trades"] == 1
|
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