feat: add trading dashboard with API and UI components

- Introduced a new trading dashboard consisting of a FastAPI backend (`dashboard-api`) for data retrieval and a React frontend (`dashboard-ui`) for visualization.
- Implemented a log parser to monitor and store bot logs in an SQLite database.
- Configured Docker setup for both API and UI, including necessary Dockerfiles and a docker-compose configuration.
- Added setup documentation for running the dashboard and accessing its features.
- Enhanced the Jenkins pipeline to build and push the new dashboard images.
This commit is contained in:
21in7
2026-03-05 20:25:45 +09:00
parent c555afbddc
commit 565414c5e0
15 changed files with 1444 additions and 3 deletions

15
Jenkinsfile vendored
View File

@@ -8,6 +8,9 @@ pipeline {
FULL_IMAGE = "${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}" FULL_IMAGE = "${REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
LATEST_IMAGE = "${REGISTRY}/${IMAGE_NAME}:latest" LATEST_IMAGE = "${REGISTRY}/${IMAGE_NAME}:latest"
DASH_API_IMAGE = "${REGISTRY}/gihyeon/cointrader-dashboard-api"
DASH_UI_IMAGE = "${REGISTRY}/gihyeon/cointrader-dashboard-ui"
// 젠킨스 자격 증명에 저장해둔 디스코드 웹훅 주소를 불러옵니다. // 젠킨스 자격 증명에 저장해둔 디스코드 웹훅 주소를 불러옵니다.
DISCORD_WEBHOOK = credentials('discord-webhook') DISCORD_WEBHOOK = credentials('discord-webhook')
} }
@@ -33,9 +36,11 @@ pipeline {
} }
} }
stage('Build Docker Image') { stage('Build Docker Images') {
steps { steps {
sh "docker build -t ${FULL_IMAGE} -t ${LATEST_IMAGE} ." sh "docker build -t ${FULL_IMAGE} -t ${LATEST_IMAGE} ."
sh "docker build -t ${DASH_API_IMAGE}:${IMAGE_TAG} -t ${DASH_API_IMAGE}:latest ./dashboard/api"
sh "docker build -t ${DASH_UI_IMAGE}:${IMAGE_TAG} -t ${DASH_UI_IMAGE}:latest ./dashboard/ui"
} }
} }
@@ -45,6 +50,10 @@ pipeline {
sh "echo \$GITEA_TOKEN | docker login ${REGISTRY} -u \$GITEA_USER --password-stdin" sh "echo \$GITEA_TOKEN | docker login ${REGISTRY} -u \$GITEA_USER --password-stdin"
sh "docker push ${FULL_IMAGE}" sh "docker push ${FULL_IMAGE}"
sh "docker push ${LATEST_IMAGE}" sh "docker push ${LATEST_IMAGE}"
sh "docker push ${DASH_API_IMAGE}:${IMAGE_TAG}"
sh "docker push ${DASH_API_IMAGE}:latest"
sh "docker push ${DASH_UI_IMAGE}:${IMAGE_TAG}"
sh "docker push ${DASH_UI_IMAGE}:latest"
} }
} }
} }
@@ -66,6 +75,10 @@ pipeline {
steps { steps {
sh "docker rmi ${FULL_IMAGE} || true" sh "docker rmi ${FULL_IMAGE} || true"
sh "docker rmi ${LATEST_IMAGE} || true" sh "docker rmi ${LATEST_IMAGE} || true"
sh "docker rmi ${DASH_API_IMAGE}:${IMAGE_TAG} || true"
sh "docker rmi ${DASH_API_IMAGE}:latest || true"
sh "docker rmi ${DASH_UI_IMAGE}:${IMAGE_TAG} || true"
sh "docker rmi ${DASH_UI_IMAGE}:latest || true"
} }
} }
} }

47
dashboard/SETUP.md Normal file
View File

@@ -0,0 +1,47 @@
# Trading Dashboard
봇과 통합된 대시보드. 봇 로그를 읽기 전용으로 마운트하여 실시간 시각화합니다.
## 구조
```
dashboard/
├── SETUP.md
├── api/
│ ├── Dockerfile
│ ├── log_parser.py
│ ├── dashboard_api.py
│ └── entrypoint.sh
└── ui/
├── Dockerfile
├── nginx.conf
├── package.json
├── vite.config.js
├── index.html
└── src/
├── main.jsx
└── App.jsx
```
## 실행
루트 디렉토리에서 봇과 함께 실행:
```bash
# 전체 (봇 + 대시보드)
docker compose up -d --build
# 대시보드만
docker compose up -d --build dashboard-api dashboard-ui
```
## 접속
`http://<서버IP>:8080`
## 동작 방식
- `dashboard-api`: 로그 파서 + FastAPI 서버 (봇 로그 → SQLite → REST API)
- `dashboard-ui`: React + Vite (빌드 후 nginx에서 서빙, API 프록시)
- 봇 로그 디렉토리를 `:ro` (읽기 전용) 마운트
- 대시보드 DB는 Docker named volume (`dashboard-data`)에 저장

9
dashboard/api/Dockerfile Normal file
View File

@@ -0,0 +1,9 @@
FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir fastapi uvicorn
COPY log_parser.py .
COPY dashboard_api.py .
COPY entrypoint.sh .
RUN chmod +x entrypoint.sh
EXPOSE 8080
CMD ["./entrypoint.sh"]

View File

@@ -0,0 +1,108 @@
"""
dashboard_api.py — 로그 파서가 채운 SQLite DB를 읽어서 대시보드 API 제공
"""
import sqlite3
import os
from fastapi import FastAPI, Query
from fastapi.middleware.cors import CORSMiddleware
from pathlib import Path
from contextlib import contextmanager
DB_PATH = os.environ.get("DB_PATH", "/app/data/dashboard.db")
app = FastAPI(title="Trading Dashboard API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@contextmanager
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()
@app.get("/api/position")
def get_position():
with get_db() as db:
row = db.execute(
"SELECT * FROM trades WHERE status='OPEN' ORDER BY id DESC LIMIT 1"
).fetchone()
status_rows = db.execute("SELECT key, value FROM bot_status").fetchall()
bot = {r["key"]: r["value"] for r in status_rows}
return {"position": dict(row) if row else None, "bot": bot}
@app.get("/api/trades")
def get_trades(limit: int = Query(50, ge=1, le=500), offset: int = 0):
with get_db() as db:
rows = db.execute(
"SELECT * FROM trades WHERE status='CLOSED' ORDER BY id DESC LIMIT ? OFFSET ?",
(limit, offset),
).fetchall()
total = db.execute("SELECT COUNT(*) as cnt FROM trades WHERE status='CLOSED'").fetchone()["cnt"]
return {"trades": [dict(r) for r in rows], "total": total}
@app.get("/api/daily")
def get_daily(days: int = Query(30, ge=1, le=365)):
with get_db() as db:
rows = db.execute("""
SELECT
date(exit_time) as date,
COUNT(*) as total_trades,
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,
ROUND(SUM(net_pnl), 4) as net_pnl,
ROUND(SUM(commission), 4) as total_fees
FROM trades
WHERE status='CLOSED' AND exit_time IS NOT NULL
GROUP BY date(exit_time)
ORDER BY date DESC
LIMIT ?
""", (days,)).fetchall()
return {"daily": [dict(r) for r in rows]}
@app.get("/api/stats")
def get_stats():
with get_db() as db:
row = db.execute("""
SELECT
COUNT(*) as total_trades,
COALESCE(SUM(CASE WHEN net_pnl > 0 THEN 1 ELSE 0 END), 0) as wins,
COALESCE(SUM(CASE WHEN net_pnl <= 0 THEN 1 ELSE 0 END), 0) as losses,
COALESCE(SUM(net_pnl), 0) as total_pnl,
COALESCE(SUM(commission), 0) as total_fees,
COALESCE(AVG(net_pnl), 0) as avg_pnl,
COALESCE(MAX(net_pnl), 0) as best_trade,
COALESCE(MIN(net_pnl), 0) as worst_trade
FROM trades WHERE status='CLOSED'
""").fetchone()
status_rows = db.execute("SELECT key, value FROM bot_status").fetchall()
bot = {r["key"]: r["value"] for r in status_rows}
result = dict(row)
result["current_price"] = bot.get("current_price")
result["balance"] = bot.get("balance")
return result
@app.get("/api/candles")
def get_candles(limit: int = Query(96, ge=1, le=1000)):
with get_db() as db:
rows = db.execute("SELECT * FROM candles ORDER BY ts DESC LIMIT ?", (limit,)).fetchall()
return {"candles": [dict(r) for r in reversed(rows)]}
@app.get("/api/health")
def health():
try:
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)}

View File

@@ -0,0 +1,18 @@
#!/bin/bash
set -e
echo "=== Trading Dashboard ==="
echo "LOG_DIR=${LOG_DIR:-/app/logs}"
echo "DB_PATH=${DB_PATH:-/app/data/dashboard.db}"
# 로그 파서를 백그라운드로 실행
python log_parser.py &
PARSER_PID=$!
echo "Log parser started (PID: $PARSER_PID)"
# 파서가 기존 로그를 처리할 시간 부여
sleep 3
# FastAPI 서버 실행
echo "Starting API server on :8080"
exec uvicorn dashboard_api:app --host 0.0.0.0 --port 8080 --log-level info

476
dashboard/api/log_parser.py Normal file
View File

@@ -0,0 +1,476 @@
"""
log_parser.py — 봇 로그 파일을 감시하고 파싱하여 SQLite에 저장
봇 코드 수정 없이 동작. logs/ 디렉토리만 마운트하면 됨.
실행: python log_parser.py
"""
import re
import sqlite3
import time
import glob
import os
import json
import threading
from datetime import datetime, date
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")) # 초
# ── 정규식 패턴 (실제 봇 로그 형식 기준) ──────────────────────────
PATTERNS = {
# 신호: HOLD | 현재가: 1.3889 USDT
"signal": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
r".*신호: (?P<signal>\w+) \| 현재가: (?P<price>[\d.]+) USDT"
),
# ADX: 24.4
"adx": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
r".*ADX: (?P<adx>[\d.]+)"
),
# OI=261103765.6, OI변화율=0.000692, 펀딩비=0.000039
"microstructure": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
r".*OI=(?P<oi>[\d.]+), OI변화율=(?P<oi_change>[-\d.]+), 펀딩비=(?P<funding>[-\d.]+)"
),
# 기존 포지션 복구: SHORT | 진입가=1.4126 | 수량=86.8
"position_recover": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
r".*기존 포지션 복구: (?P<direction>\w+) \| 진입가=(?P<entry_price>[\d.]+) \| 수량=(?P<qty>[\d.]+)"
),
# SHORT 진입: 가격=1.3940, 수량=86.8, SL=1.4040, TP=1.3840
"entry": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
r".*(?P<direction>SHORT|LONG) 진입: "
r"가격=(?P<entry_price>[\d.]+), "
r"수량=(?P<qty>[\d.]+), "
r"SL=(?P<sl>[\d.]+), "
r"TP=(?P<tp>[\d.]+)"
),
# 청산 감지(MANUAL): exit=1.3782, rp=+2.9859, commission=0.0598, net_pnl=+2.9261
"close_detect": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
r".*청산 감지\((?P<reason>\w+)\):\s*"
r"exit=(?P<exit_price>[\d.]+),\s*"
r"rp=(?P<expected>[+\-\d.]+),\s*"
r"commission=(?P<commission>[\d.]+),\s*"
r"net_pnl=(?P<net_pnl>[+\-\d.]+)"
),
# 오늘 누적 PnL: 2.9261 USDT
"daily_pnl": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
r".*오늘 누적 PnL: (?P<pnl>[+\-\d.]+) USDT"
),
# 봇 시작: XRPUSDT, 레버리지 10x
"bot_start": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
r".*봇 시작: (?P<symbol>\w+), 레버리지 (?P<leverage>\d+)x"
),
# 기준 잔고 설정: 24.46 USDT
"balance": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
r".*기준 잔고 설정: (?P<balance>[\d.]+) USDT"
),
# ML 필터 로드
"ml_filter": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
r".*ML 필터 로드.*임계값=(?P<threshold>[\d.]+)"
),
}
class LogParser:
def __init__(self):
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
self.conn = sqlite3.connect(DB_PATH)
self.conn.row_factory = sqlite3.Row
self.conn.execute("PRAGMA journal_mode=WAL")
self._init_db()
# 상태 추적
self._file_positions = {} # {파일경로: 마지막 읽은 위치}
self._current_position = None # 현재 열린 포지션 정보
self._pending_candle = {} # 타임스탬프 기준으로 지표를 모아두기
self._bot_config = {"symbol": "XRPUSDT", "leverage": 10}
self._balance = 0
def _init_db(self):
self.conn.executescript("""
CREATE TABLE IF NOT EXISTS trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL DEFAULT 'XRPUSDT',
direction TEXT NOT NULL,
entry_time TEXT NOT NULL,
exit_time TEXT,
entry_price REAL NOT NULL,
exit_price REAL,
quantity REAL,
leverage INTEGER DEFAULT 10,
sl REAL,
tp REAL,
rsi REAL,
macd_hist REAL,
atr REAL,
adx REAL,
expected_pnl REAL,
actual_pnl REAL,
commission REAL,
net_pnl REAL,
status TEXT NOT NULL DEFAULT 'OPEN',
close_reason TEXT,
extra TEXT
);
CREATE TABLE IF NOT EXISTS candles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL UNIQUE,
price REAL NOT NULL,
signal TEXT,
adx REAL,
oi REAL,
oi_change REAL,
funding_rate REAL
);
CREATE TABLE IF NOT EXISTS daily_pnl (
date TEXT PRIMARY KEY,
cumulative_pnl REAL DEFAULT 0,
trade_count INTEGER DEFAULT 0,
wins INTEGER DEFAULT 0,
losses INTEGER DEFAULT 0,
last_updated TEXT
);
CREATE TABLE IF NOT EXISTS bot_status (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS parse_state (
filepath TEXT PRIMARY KEY,
position INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_candles_ts ON candles(ts);
CREATE INDEX IF NOT EXISTS idx_trades_status ON trades(status);
""")
self.conn.commit()
self._load_state()
def _load_state(self):
"""이전 파싱 위치 복원"""
rows = self.conn.execute("SELECT filepath, position FROM parse_state").fetchall()
self._file_positions = {r["filepath"]: r["position"] for r in rows}
# 현재 열린 포지션 복원
row = self.conn.execute(
"SELECT * FROM trades WHERE status='OPEN' ORDER BY id DESC LIMIT 1"
).fetchone()
if row:
self._current_position = dict(row)
def _save_position(self, filepath, pos):
self.conn.execute(
"INSERT INTO parse_state(filepath, position) VALUES(?,?) "
"ON CONFLICT(filepath) DO UPDATE SET position=?",
(filepath, pos, pos)
)
self.conn.commit()
def _set_status(self, key, value):
now = datetime.now().isoformat()
self.conn.execute(
"INSERT INTO bot_status(key, value, updated_at) VALUES(?,?,?) "
"ON CONFLICT(key) DO UPDATE SET value=?, updated_at=?",
(key, str(value), now, str(value), now)
)
self.conn.commit()
# ── 메인 루프 ────────────────────────────────────────────────
def run(self):
print(f"[LogParser] 시작 — LOG_DIR={LOG_DIR}, DB={DB_PATH}, 폴링={POLL_INTERVAL}s")
while True:
try:
self._scan_logs()
except Exception as e:
print(f"[LogParser] 에러: {e}")
time.sleep(POLL_INTERVAL)
def _scan_logs(self):
"""로그 파일 목록을 가져와서 새 줄 파싱"""
# 날짜 형식 (bot_2026-03-01.log) + 현재 형식 (bot.log) 모두 스캔
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)
for filepath in log_files:
self._parse_file(filepath)
def _parse_file(self, filepath):
last_pos = self._file_positions.get(filepath, 0)
try:
file_size = os.path.getsize(filepath)
except OSError:
return
# 파일이 줄었으면 (로테이션) 처음부터
if file_size < last_pos:
last_pos = 0
if file_size == last_pos:
return # 새 내용 없음
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
f.seek(last_pos)
new_lines = f.readlines()
new_pos = f.tell()
for line in new_lines:
self._parse_line(line.strip())
self._file_positions[filepath] = new_pos
self._save_position(filepath, new_pos)
# ── 한 줄 파싱 ──────────────────────────────────────────────
def _parse_line(self, line):
if not line:
return
# 봇 시작
m = PATTERNS["bot_start"].search(line)
if m:
self._bot_config["symbol"] = m.group("symbol")
self._bot_config["leverage"] = int(m.group("leverage"))
self._set_status("symbol", m.group("symbol"))
self._set_status("leverage", m.group("leverage"))
self._set_status("last_start", m.group("ts"))
return
# 잔고
m = PATTERNS["balance"].search(line)
if m:
self._balance = float(m.group("balance"))
self._set_status("balance", m.group("balance"))
return
# ML 필터
m = PATTERNS["ml_filter"].search(line)
if m:
self._set_status("ml_threshold", m.group("threshold"))
return
# 포지션 복구 (재시작 시)
m = PATTERNS["position_recover"].search(line)
if m:
self._handle_entry(
ts=m.group("ts"),
direction=m.group("direction"),
entry_price=float(m.group("entry_price")),
qty=float(m.group("qty")),
is_recovery=True,
)
return
# 포지션 진입: SHORT 진입: 가격=X, 수량=Y, SL=Z, TP=W
m = PATTERNS["entry"].search(line)
if m:
self._handle_entry(
ts=m.group("ts"),
direction=m.group("direction"),
entry_price=float(m.group("entry_price")),
qty=float(m.group("qty")),
sl=float(m.group("sl")),
tp=float(m.group("tp")),
)
return
# OI/펀딩비 (캔들 데이터에 합침)
m = PATTERNS["microstructure"].search(line)
if m:
ts_key = m.group("ts")[:16] # 분 단위로 그룹
if ts_key not in self._pending_candle:
self._pending_candle[ts_key] = {}
self._pending_candle[ts_key].update({
"oi": float(m.group("oi")),
"oi_change": float(m.group("oi_change")),
"funding": float(m.group("funding")),
})
return
# ADX
m = PATTERNS["adx"].search(line)
if m:
ts_key = m.group("ts")[:16]
if ts_key not in self._pending_candle:
self._pending_candle[ts_key] = {}
self._pending_candle[ts_key]["adx"] = float(m.group("adx"))
return
# 신호 + 현재가 → 캔들 저장
m = PATTERNS["signal"].search(line)
if m:
ts = m.group("ts")
ts_key = ts[:16]
price = float(m.group("price"))
signal = m.group("signal")
extra = self._pending_candle.pop(ts_key, {})
self._set_status("current_price", str(price))
self._set_status("current_signal", signal)
self._set_status("last_candle_time", ts)
try:
self.conn.execute(
"""INSERT INTO candles(ts, price, signal, adx, oi, oi_change, funding_rate)
VALUES(?,?,?,?,?,?,?)
ON CONFLICT(ts) DO UPDATE SET
price=?, signal=?, adx=?, oi=?, oi_change=?, funding_rate=?""",
(ts, price, signal,
extra.get("adx"), extra.get("oi"), extra.get("oi_change"), extra.get("funding"),
price, signal,
extra.get("adx"), extra.get("oi"), extra.get("oi_change"), extra.get("funding")),
)
self.conn.commit()
except Exception as e:
print(f"[LogParser] 캔들 저장 에러: {e}")
return
# 청산 감지
m = PATTERNS["close_detect"].search(line)
if m:
self._handle_close(
ts=m.group("ts"),
exit_price=float(m.group("exit_price")),
expected_pnl=float(m.group("expected")),
commission=float(m.group("commission")),
net_pnl=float(m.group("net_pnl")),
reason=m.group("reason"),
)
return
# 일일 누적 PnL
m = PATTERNS["daily_pnl"].search(line)
if m:
ts = m.group("ts")
day = ts[:10]
pnl = float(m.group("pnl"))
self.conn.execute(
"""INSERT INTO daily_pnl(date, cumulative_pnl, last_updated)
VALUES(?,?,?)
ON CONFLICT(date) DO UPDATE SET cumulative_pnl=?, last_updated=?""",
(day, pnl, ts, pnl, ts)
)
self.conn.commit()
self._set_status("daily_pnl", str(pnl))
return
# ── 포지션 진입 핸들러 ───────────────────────────────────────
def _handle_entry(self, ts, direction, entry_price, qty,
leverage=None, sl=None, tp=None, is_recovery=False):
if leverage is None:
leverage = self._bot_config.get("leverage", 10)
# 메모리 내 중복 체크
if self._current_position:
if abs(self._current_position["entry_price"] - entry_price) < 0.0001:
return
# DB 내 중복 체크 — 같은 방향·가격의 OPEN 포지션이 이미 있으면 스킵
existing = self.conn.execute(
"SELECT id FROM trades WHERE status='OPEN' AND direction=? "
"AND ABS(entry_price - ?) < 0.0001",
(direction, entry_price),
).fetchone()
if existing:
self._current_position = {
"id": existing["id"],
"direction": direction,
"entry_price": entry_price,
"entry_time": ts,
}
return
cur = self.conn.execute(
"""INSERT INTO trades(symbol, direction, entry_time, entry_price,
quantity, leverage, sl, tp, status, extra)
VALUES(?,?,?,?,?,?,?,?,?,?)""",
(self._bot_config.get("symbol", "XRPUSDT"), direction, ts,
entry_price, qty, leverage, sl, tp, "OPEN",
json.dumps({"recovery": is_recovery})),
)
self.conn.commit()
self._current_position = {
"id": cur.lastrowid,
"direction": direction,
"entry_price": entry_price,
"entry_time": ts,
}
self._set_status("position_status", "OPEN")
self._set_status("position_direction", direction)
self._set_status("position_entry_price", str(entry_price))
print(f"[LogParser] 포지션 진입: {direction} @ {entry_price} (recovery={is_recovery})")
# ── 포지션 청산 핸들러 ───────────────────────────────────────
def _handle_close(self, ts, exit_price, expected_pnl, commission, net_pnl, reason):
if not self._current_position:
# 열린 포지션 없으면 DB에서 찾기
row = self.conn.execute(
"SELECT id, entry_price, direction FROM trades WHERE status='OPEN' ORDER BY id DESC LIMIT 1"
).fetchone()
if row:
self._current_position = dict(row)
else:
print(f"[LogParser] 경고: 청산 감지했으나 열린 포지션 없음")
return
trade_id = self._current_position["id"]
self.conn.execute(
"""UPDATE trades SET
exit_time=?, exit_price=?, expected_pnl=?,
actual_pnl=?, commission=?, net_pnl=?,
status='CLOSED', close_reason=?
WHERE id=?""",
(ts, exit_price, expected_pnl,
expected_pnl, commission, net_pnl,
reason, trade_id)
)
# 일별 요약 갱신
day = ts[:10]
win = 1 if net_pnl > 0 else 0
loss = 1 if net_pnl <= 0 else 0
self.conn.execute(
"""INSERT INTO daily_pnl(date, cumulative_pnl, trade_count, wins, losses, last_updated)
VALUES(?, ?, 1, ?, ?, ?)
ON CONFLICT(date) DO UPDATE SET
trade_count = trade_count + 1,
wins = wins + ?,
losses = losses + ?,
last_updated = ?""",
(day, net_pnl, win, loss, ts, win, loss, ts)
)
self.conn.commit()
self._set_status("position_status", "NONE")
print(f"[LogParser] 포지션 청산: {reason} @ {exit_price}, PnL={net_pnl}")
self._current_position = None
if __name__ == "__main__":
parser = LogParser()
parser.run()

11
dashboard/ui/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 3000

20
dashboard/ui/index.html Normal file
View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Trading Dashboard</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,700&family=JetBrains+Mono:wght@300;400;500;600&display=swap" rel="stylesheet" />
<link href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,700&display=swap" rel="stylesheet" />
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #08080f; }
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 3px; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

23
dashboard/ui/nginx.conf Normal file
View File

@@ -0,0 +1,23 @@
server {
listen 3000;
root /usr/share/nginx/html;
index index.html;
# SPA — 모든 경로를 index.html로
location / {
try_files $uri $uri/ /index.html;
}
# API 프록시 → 백엔드 컨테이너
location /api/ {
proxy_pass http://dashboard-api:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# 캐시 설정
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 7d;
add_header Cache-Control "public, immutable";
}
}

20
dashboard/ui/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "trading-dashboard",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"recharts": "^2.12.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.0",
"vite": "^5.4.0"
}
}

639
dashboard/ui/src/App.jsx Normal file
View File

@@ -0,0 +1,639 @@
import { useState, useEffect, useCallback, useRef } from "react";
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
AreaChart, Area, LineChart, Line, CartesianGrid, Cell,
} from "recharts";
/* ── API ──────────────────────────────────────────────────────── */
const api = async (path) => {
try {
const r = await fetch(`/api${path}`);
if (!r.ok) throw new Error(r.statusText);
return await r.json();
} catch (e) {
console.error(`API [${path}]:`, e);
return null;
}
};
/* ── 유틸 ─────────────────────────────────────────────────────── */
const fmt = (n, d = 4) => (n != null ? Number(n).toFixed(d) : "—");
const fmtTime = (iso) => {
if (!iso) return "—";
const d = new Date(iso);
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
};
const fmtDate = (s) => (s ? s.slice(5, 10).replace("-", "/") : "—");
const pnlColor = (v) => (v > 0 ? "#34d399" : v < 0 ? "#f87171" : "rgba(255,255,255,0.5)");
const pnlSign = (v) => (v > 0 ? `+${fmt(v)}` : fmt(v));
/* ── 스타일 변수 ──────────────────────────────────────────────── */
const S = {
sans: "'Satoshi','DM Sans',system-ui,sans-serif",
mono: "'JetBrains Mono','Fira Code',monospace",
bg: "#08080f",
surface: "rgba(255,255,255,0.015)",
surface2: "rgba(255,255,255,0.03)",
border: "rgba(255,255,255,0.06)",
text3: "rgba(255,255,255,0.35)",
text4: "rgba(255,255,255,0.2)",
green: "#34d399",
red: "#f87171",
indigo: "#818cf8",
amber: "#f59e0b",
};
/* ── Badge ────────────────────────────────────────────────────── */
const Badge = ({ children, bg = "rgba(255,255,255,0.06)", color = "rgba(255,255,255,0.5)" }) => (
<span style={{
display: "inline-block", fontSize: 10, fontWeight: 600, padding: "2px 8px",
borderRadius: 6, background: bg, color, fontFamily: S.mono,
letterSpacing: 0.5, marginLeft: 4,
}}>{children}</span>
);
/* ── StatCard ─────────────────────────────────────────────────── */
const StatCard = ({ icon, label, value, sub, accent }) => (
<div style={{
background: `linear-gradient(135deg, ${S.surface2} 0%, rgba(255,255,255,0.008) 100%)`,
border: `1px solid ${S.border}`, borderRadius: 14,
padding: "18px 20px", position: "relative", overflow: "hidden",
}}>
<div style={{
position: "absolute", top: -20, right: -20, width: 70, height: 70,
borderRadius: "50%", background: accent, filter: "blur(28px)",
}} />
<div style={{
fontSize: 10, color: S.text3, letterSpacing: 1.5,
textTransform: "uppercase", fontFamily: S.mono, marginBottom: 6,
}}>
{icon && <span style={{ marginRight: 5 }}>{icon}</span>}{label}
</div>
<div style={{ fontSize: 26, fontWeight: 700, color: "#fff", fontFamily: S.sans, letterSpacing: -0.5 }}>
{value}
</div>
{sub && (
<div style={{ fontSize: 11, color: accent, fontFamily: S.mono, marginTop: 2 }}>{sub}</div>
)}
</div>
);
/* ── ChartTooltip ─────────────────────────────────────────────── */
const ChartTooltip = ({ active, payload, label }) => {
if (!active || !payload?.length) return null;
return (
<div style={{
background: "rgba(10,10,18,0.95)", border: "1px solid rgba(255,255,255,0.08)",
borderRadius: 10, padding: "10px 14px", fontSize: 11, fontFamily: S.mono,
}}>
<div style={{ color: "rgba(255,255,255,0.4)", marginBottom: 4 }}>{label}</div>
{payload.filter(p => p.name !== "과매수" && p.name !== "과매도" && p.name !== "임계값").map((p, i) => (
<div key={i} style={{ color: p.color || "#fff", marginBottom: 1 }}>
{p.name}: {typeof p.value === "number" ? p.value.toFixed(4) : p.value}
</div>
))}
</div>
);
};
/* ── TradeRow ──────────────────────────────────────────────────── */
const TradeRow = ({ trade, isExpanded, onToggle }) => {
const pnl = trade.net_pnl || 0;
const isShort = trade.direction === "SHORT";
const priceDiff = trade.entry_price && trade.exit_price
? ((trade.entry_price - trade.exit_price) / trade.entry_price * 100 * (isShort ? 1 : -1)).toFixed(2)
: "—";
const sections = [
{
title: "리스크 관리",
items: [
["손절가 (SL)", trade.sl, S.red],
["익절가 (TP)", trade.tp, S.green],
["수량", trade.quantity, "rgba(255,255,255,0.6)"],
],
},
{
title: "기술 지표",
items: [
["RSI", trade.rsi, trade.rsi > 70 ? S.amber : S.indigo],
["MACD Hist", trade.macd_hist, trade.macd_hist >= 0 ? S.green : S.red],
["ATR", trade.atr, "rgba(255,255,255,0.6)"],
],
},
{
title: "손익 상세",
items: [
["예상 수익", trade.expected_pnl, S.green],
["순수익", trade.net_pnl, pnlColor(trade.net_pnl)],
["수수료", trade.commission ? -trade.commission : null, S.red],
],
},
];
return (
<div style={{ marginBottom: 6 }}>
<div
onClick={onToggle}
style={{
background: isExpanded ? "rgba(99,102,241,0.06)" : S.surface,
border: `1px solid ${isExpanded ? "rgba(99,102,241,0.15)" : "rgba(255,255,255,0.04)"}`,
borderRadius: isExpanded ? "14px 14px 0 0" : 14,
padding: "14px 18px", cursor: "pointer",
display: "grid",
gridTemplateColumns: "36px 1.5fr 0.8fr 0.8fr 0.8fr 32px",
alignItems: "center", gap: 10, transition: "all 0.15s ease",
}}
>
<div style={{
width: 30, height: 30, borderRadius: 8,
background: isShort ? "rgba(239,68,68,0.1)" : "rgba(52,211,153,0.1)",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 12, fontWeight: 700,
color: isShort ? S.red : S.green, fontFamily: S.mono,
}}>
{isShort ? "S" : "L"}
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: "#fff", fontFamily: S.sans }}>
{(trade.symbol || "XRPUSDT").replace("USDT", "/USDT")}
<Badge
bg={isShort ? "rgba(239,68,68,0.1)" : "rgba(52,211,153,0.1)"}
color={isShort ? S.red : S.green}
>
{trade.direction}
</Badge>
<Badge>{trade.leverage || 10}x</Badge>
</div>
<div style={{ fontSize: 10, color: S.text3, marginTop: 2, fontFamily: S.mono }}>
{fmtDate(trade.entry_time)} {fmtTime(trade.entry_time)} {fmtTime(trade.exit_time)}
{trade.close_reason && (
<span style={{ marginLeft: 6, color: S.text4 }}>({trade.close_reason})</span>
)}
</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: 12, color: "rgba(255,255,255,0.6)", fontFamily: S.mono }}>
{fmt(trade.entry_price)}
</div>
<div style={{ fontSize: 9, color: S.text4 }}>진입가</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: 12, color: "rgba(255,255,255,0.6)", fontFamily: S.mono }}>
{fmt(trade.exit_price)}
</div>
<div style={{ fontSize: 9, color: S.text4 }}>청산가</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: 14, fontWeight: 700, color: pnlColor(pnl), fontFamily: S.mono }}>
{pnlSign(pnl)}
</div>
<div style={{ fontSize: 9, color: pnlColor(pnl), opacity: 0.7 }}>{priceDiff}%</div>
</div>
<div style={{
textAlign: "center", color: S.text4, fontSize: 12,
transition: "transform 0.15s",
transform: isExpanded ? "rotate(180deg)" : "",
}}></div>
</div>
{isExpanded && (
<div style={{
background: "rgba(99,102,241,0.025)",
border: "1px solid rgba(99,102,241,0.15)",
borderTop: "none", borderRadius: "0 0 14px 14px",
padding: "18px 22px",
display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 14,
}}>
{sections.map((sec, si) => (
<div key={si}>
<div style={{
fontSize: 9, color: S.text4, letterSpacing: 1.2,
fontFamily: S.mono, textTransform: "uppercase", marginBottom: 10,
}}>
{sec.title}
</div>
{sec.items.map(([label, val, color], ii) => (
<div key={ii} style={{ display: "flex", justifyContent: "space-between", marginBottom: 5 }}>
<span style={{ fontSize: 11, color: "rgba(255,255,255,0.4)" }}>{label}</span>
<span style={{ fontSize: 11, color, fontFamily: S.mono }}>
{val != null ? fmt(val) : "—"}
</span>
</div>
))}
</div>
))}
</div>
)}
</div>
);
};
/* ── 차트 컨테이너 ────────────────────────────────────────────── */
const ChartBox = ({ title, children }) => (
<div style={{
background: S.surface, border: `1px solid rgba(255,255,255,0.05)`,
borderRadius: 14, padding: 18,
}}>
<div style={{
fontSize: 10, color: S.text3, letterSpacing: 1.2,
fontFamily: S.mono, textTransform: "uppercase", marginBottom: 14,
}}>
{title}
</div>
{children}
</div>
);
/* ── 탭 정의 ──────────────────────────────────────────────────── */
const TABS = [
{ id: "overview", label: "Overview", icon: "◆" },
{ id: "trades", label: "Trades", icon: "◈" },
{ id: "chart", label: "Chart", icon: "◇" },
];
/* ═══════════════════════════════════════════════════════════════ */
/* 메인 대시보드 */
/* ═══════════════════════════════════════════════════════════════ */
export default function App() {
const [tab, setTab] = useState("overview");
const [expanded, setExpanded] = useState(null);
const [isLive, setIsLive] = useState(false);
const [lastUpdate, setLastUpdate] = useState(null);
const [stats, setStats] = useState({
total_trades: 0, wins: 0, losses: 0,
total_pnl: 0, total_fees: 0, avg_pnl: 0,
best_trade: 0, worst_trade: 0,
});
const [position, setPosition] = useState(null);
const [botStatus, setBotStatus] = useState({});
const [trades, setTrades] = useState([]);
const [daily, setDaily] = useState([]);
const [candles, setCandles] = useState([]);
/* ── 데이터 폴링 ─────────────────────────────────────────── */
const fetchAll = useCallback(async () => {
const [sRes, pRes, tRes, dRes, cRes] = await Promise.all([
api("/stats"),
api("/position"),
api("/trades?limit=50"),
api("/daily?days=30"),
api("/candles?limit=96"),
]);
if (sRes && sRes.total_trades !== undefined) {
setStats(sRes);
setIsLive(true);
setLastUpdate(new Date());
}
if (pRes) {
setPosition(pRes.position);
if (pRes.bot) setBotStatus(pRes.bot);
}
if (tRes?.trades) setTrades(tRes.trades);
if (dRes?.daily) setDaily(dRes.daily);
if (cRes?.candles) setCandles(cRes.candles);
}, []);
useEffect(() => {
fetchAll();
const iv = setInterval(fetchAll, 15000);
return () => clearInterval(iv);
}, [fetchAll]);
/* ── 파생 데이터 ─────────────────────────────────────────── */
const winRate = stats.total_trades > 0
? ((stats.wins / stats.total_trades) * 100).toFixed(0) : "0";
// 일별 → 날짜순 정렬 (오래된 순)
const dailyAsc = [...daily].reverse();
const dailyLabels = dailyAsc.map((d) => fmtDate(d.date));
const dailyPnls = dailyAsc.map((d) => d.net_pnl || 0);
// 누적 수익
const cumData = [];
let cum = 0;
dailyAsc.forEach((d) => {
cum += d.net_pnl || 0;
cumData.push({ date: fmtDate(d.date), cumPnl: +cum.toFixed(4) });
});
// 캔들 차트용
const candleLabels = candles.map((c) => fmtTime(c.ts));
/* ── 현재 가격 (봇 상태 또는 마지막 캔들) ──────────────────── */
const currentPrice = botStatus.current_price
|| (candles.length ? candles[candles.length - 1].price : null);
/* ── 공통 차트 축 스타일 ─────────────────────────────────── */
const axisStyle = {
tick: { fill: "rgba(255,255,255,0.25)", fontSize: 10, fontFamily: "JetBrains Mono" },
axisLine: false, tickLine: false,
};
return (
<div style={{
minHeight: "100vh", background: S.bg, color: "#fff",
fontFamily: S.sans, padding: "28px 20px",
position: "relative", overflow: "hidden",
}}>
{/* BG glow */}
<div style={{
position: "fixed", inset: 0, pointerEvents: "none",
background: "radial-gradient(ellipse 50% 35% at 15% 5%,rgba(99,102,241,0.05) 0%,transparent 70%),radial-gradient(ellipse 40% 40% at 85% 90%,rgba(52,211,153,0.03) 0%,transparent 70%)",
}} />
<div style={{ maxWidth: 960, margin: "0 auto", position: "relative" }}>
{/* ═══ 헤더 ═══════════════════════════════════════════ */}
<div style={{
display: "flex", justifyContent: "space-between",
alignItems: "flex-start", marginBottom: 28, flexWrap: "wrap", gap: 16,
}}>
<div>
<div style={{
display: "flex", alignItems: "center", gap: 10, marginBottom: 6,
}}>
<div style={{
width: 8, height: 8, borderRadius: "50%",
background: isLive ? S.green : S.amber,
boxShadow: isLive
? "0 0 10px rgba(52,211,153,0.5)"
: "0 0 10px rgba(245,158,11,0.5)",
animation: "pulse 2s infinite",
}} />
<span style={{
fontSize: 10, color: S.text3, letterSpacing: 2,
textTransform: "uppercase", fontFamily: S.mono,
}}>
{isLive ? "Live" : "Connecting…"} · XRP/USDT
{currentPrice && (
<span style={{ color: "rgba(255,255,255,0.5)", marginLeft: 8 }}>
{fmt(currentPrice)}
</span>
)}
</span>
</div>
<h1 style={{ fontSize: 28, fontWeight: 700, margin: 0, letterSpacing: -0.8 }}>
Trading Dashboard
</h1>
</div>
{/* 오픈 포지션 */}
{position && (
<div style={{
background: "linear-gradient(135deg,rgba(99,102,241,0.08) 0%,rgba(99,102,241,0.02) 100%)",
border: "1px solid rgba(99,102,241,0.15)", borderRadius: 14,
padding: "12px 18px",
}}>
<div style={{
fontSize: 9, color: S.text3, letterSpacing: 1.2,
fontFamily: S.mono, marginBottom: 4,
}}>OPEN POSITION</div>
<div style={{ display: "flex", gap: 16, alignItems: "center", flexWrap: "wrap" }}>
<Badge
bg={position.direction === "SHORT" ? "rgba(239,68,68,0.12)" : "rgba(52,211,153,0.12)"}
color={position.direction === "SHORT" ? S.red : S.green}
>
{position.direction} {position.leverage || 10}x
</Badge>
<span style={{ fontSize: 16, fontWeight: 700, fontFamily: S.mono }}>
{fmt(position.entry_price)}
</span>
<span style={{ fontSize: 10, color: S.text3, fontFamily: S.mono }}>
SL {fmt(position.sl)} · TP {fmt(position.tp)}
</span>
</div>
</div>
)}
</div>
{/* ═══ 탭 ═════════════════════════════════════════════ */}
<div style={{
display: "flex", gap: 4, marginBottom: 24,
background: "rgba(255,255,255,0.02)", borderRadius: 12,
padding: 4, width: "fit-content",
}}>
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
style={{
background: tab === t.id ? "rgba(255,255,255,0.08)" : "transparent",
border: "none",
color: tab === t.id ? "#fff" : S.text3,
padding: "8px 18px", borderRadius: 9, cursor: "pointer",
fontSize: 12, fontWeight: 500, fontFamily: S.sans,
transition: "all 0.15s",
}}
>
<span style={{ marginRight: 6, fontSize: 10 }}>{t.icon}</span>
{t.label}
</button>
))}
</div>
{/* ═══ OVERVIEW ═══════════════════════════════════════ */}
{tab === "overview" && (
<div>
{/* Stats */}
<div style={{
display: "grid", gridTemplateColumns: "repeat(4,1fr)",
gap: 10, marginBottom: 24,
}}>
<StatCard icon="💰" label="총 수익" value={pnlSign(stats.total_pnl)} sub="USDT" accent="rgba(52,211,153,0.4)" />
<StatCard icon="📊" label="승률" value={`${winRate}%`} sub={`${stats.wins}W / ${stats.losses}L`} accent="rgba(129,140,248,0.4)" />
<StatCard icon="⚡" label="총 거래" value={stats.total_trades} sub={`평균 ${fmt(stats.avg_pnl)} USDT`} accent="rgba(251,191,36,0.3)" />
<StatCard icon="🎯" label="베스트" value={`+${fmt(stats.best_trade)}`} sub={`최저 ${fmt(stats.worst_trade)}`} accent="rgba(99,102,241,0.3)" />
</div>
{/* 차트 */}
<div style={{
display: "grid", gridTemplateColumns: "1fr 1fr",
gap: 10, marginBottom: 24,
}}>
<ChartBox title="일별 손익">
<ResponsiveContainer width="100%" height={180}>
<BarChart data={dailyAsc.map((d) => ({ date: fmtDate(d.date), pnl: d.net_pnl || 0 }))}>
<XAxis dataKey="date" {...axisStyle} />
<YAxis {...axisStyle} />
<Tooltip content={<ChartTooltip />} />
<Bar dataKey="pnl" name="순수익" radius={[5, 5, 0, 0]}>
{dailyAsc.map((d, i) => (
<Cell key={i} fill={(d.net_pnl || 0) >= 0 ? S.green : S.red} fillOpacity={0.75} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</ChartBox>
<ChartBox title="누적 수익 곡선">
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={cumData}>
<defs>
<linearGradient id="gCum" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={S.indigo} stopOpacity={0.25} />
<stop offset="100%" stopColor={S.indigo} stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="date" {...axisStyle} />
<YAxis {...axisStyle} />
<Tooltip content={<ChartTooltip />} />
<Area
type="monotone" dataKey="cumPnl" name="누적"
stroke={S.indigo} strokeWidth={2} fill="url(#gCum)"
dot={{ fill: S.indigo, r: 3.5, strokeWidth: 0 }}
/>
</AreaChart>
</ResponsiveContainer>
</ChartBox>
</div>
{/* 최근 거래 */}
<div style={{
fontSize: 10, color: S.text3, letterSpacing: 1.2,
fontFamily: S.mono, textTransform: "uppercase", marginBottom: 10,
}}>
최근 거래
</div>
{trades.length === 0 && (
<div style={{
textAlign: "center", color: S.text3, padding: 40,
fontFamily: S.mono, fontSize: 12,
}}>
거래 내역 없음 로그 파싱 대기
</div>
)}
{trades.slice(0, 3).map((t) => (
<TradeRow
key={t.id}
trade={t}
isExpanded={expanded === t.id}
onToggle={() => setExpanded(expanded === t.id ? null : t.id)}
/>
))}
{trades.length > 3 && (
<div
onClick={() => setTab("trades")}
style={{
textAlign: "center", padding: 12, color: S.indigo,
fontSize: 12, cursor: "pointer", fontFamily: S.mono,
background: "rgba(99,102,241,0.04)", borderRadius: 10,
marginTop: 6,
}}
>
전체 {trades.length} 보기
</div>
)}
</div>
)}
{/* ═══ TRADES ═════════════════════════════════════════ */}
{tab === "trades" && (
<div>
<div style={{
fontSize: 10, color: S.text3, letterSpacing: 1.2,
fontFamily: S.mono, textTransform: "uppercase", marginBottom: 12,
}}>
전체 거래 내역 ({trades.length})
</div>
{trades.map((t) => (
<TradeRow
key={t.id}
trade={t}
isExpanded={expanded === t.id}
onToggle={() => setExpanded(expanded === t.id ? null : t.id)}
/>
))}
</div>
)}
{/* ═══ CHART ══════════════════════════════════════════ */}
{tab === "chart" && (
<div>
<ChartBox title="XRP/USDT 15m 가격">
<ResponsiveContainer width="100%" height={240}>
<AreaChart data={candles.map((c) => ({ ts: fmtTime(c.ts), price: c.price || c.close }))}>
<defs>
<linearGradient id="gP" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#6366f1" stopOpacity={0.15} />
<stop offset="100%" stopColor="#6366f1" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" />
<XAxis dataKey="ts" {...axisStyle} interval="preserveStartEnd" />
<YAxis domain={["auto", "auto"]} {...axisStyle} />
<Tooltip content={<ChartTooltip />} />
<Area
type="monotone" dataKey="price" name="가격"
stroke="#6366f1" strokeWidth={1.5} fill="url(#gP)" dot={false}
/>
</AreaChart>
</ResponsiveContainer>
</ChartBox>
<div style={{
display: "grid", gridTemplateColumns: "1fr 1fr",
gap: 10, marginTop: 12,
}}>
<ChartBox title="RSI">
<ResponsiveContainer width="100%" height={150}>
<LineChart data={candles.map((c) => ({ ts: fmtTime(c.ts), rsi: c.rsi }))}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" />
<XAxis dataKey="ts" {...axisStyle} interval="preserveStartEnd" />
<YAxis domain={[0, 100]} {...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>
</ResponsiveContainer>
</ChartBox>
<ChartBox title="ADX">
<ResponsiveContainer width="100%" height={150}>
<AreaChart data={candles.map((c) => ({ ts: fmtTime(c.ts), adx: c.adx }))}>
<defs>
<linearGradient id="gA" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={S.green} stopOpacity={0.15} />
<stop offset="100%" stopColor={S.green} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" />
<XAxis dataKey="ts" {...axisStyle} interval="preserveStartEnd" />
<YAxis {...axisStyle} />
<Tooltip content={<ChartTooltip />} />
<Line type="monotone" dataKey={() => 25} stroke="rgba(52,211,153,0.3)" strokeDasharray="4 4" dot={false} name="임계값" />
<Area type="monotone" dataKey="adx" name="ADX" stroke={S.green} strokeWidth={1.5} fill="url(#gA)" dot={false} />
</AreaChart>
</ResponsiveContainer>
</ChartBox>
</div>
</div>
)}
{/* ═══ 푸터 ═══════════════════════════════════════════ */}
<div style={{
textAlign: "center", padding: "24px 0 8px", marginTop: 24,
borderTop: "1px solid rgba(255,255,255,0.03)",
}}>
<span style={{ fontSize: 10, color: "rgba(255,255,255,0.12)", fontFamily: S.mono }}>
{lastUpdate
? `Synced: ${lastUpdate.toLocaleTimeString("ko-KR")} · 15s polling`
: "API 연결 대기 중…"}
</span>
</div>
</div>
<style>{`
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.4; } }
button:hover { filter: brightness(1.1); }
`}</style>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8080'
}
}
})

View File

@@ -16,3 +16,40 @@ services:
options: options:
max-size: "10m" max-size: "10m"
max-file: "5" max-file: "5"
dashboard-api:
image: 10.1.10.28:3000/gihyeon/cointrader-dashboard-api:latest
container_name: dashboard-api
restart: unless-stopped
environment:
- TZ=Asia/Seoul
- LOG_DIR=/app/logs
- DB_PATH=/app/data/dashboard.db
- POLL_INTERVAL=5
volumes:
- ./logs:/app/logs:ro
- dashboard-data:/app/data
depends_on:
- cointrader
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
dashboard-ui:
image: 10.1.10.28:3000/gihyeon/cointrader-dashboard-ui:latest
container_name: dashboard-ui
restart: unless-stopped
ports:
- "8080:3000"
depends_on:
- dashboard-api
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
volumes:
dashboard-data:

View File

@@ -16,7 +16,7 @@ def setup_logger(log_level: str = "INFO"):
colorize=True, colorize=True,
) )
logger.add( logger.add(
"logs/bot_{time:YYYY-MM-DD}.log", "logs/bot.log",
rotation="00:00", rotation="00:00",
retention="30 days", retention="30 days",
level="DEBUG", level="DEBUG",