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:
47
dashboard/SETUP.md
Normal file
47
dashboard/SETUP.md
Normal 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
9
dashboard/api/Dockerfile
Normal 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"]
|
||||
108
dashboard/api/dashboard_api.py
Normal file
108
dashboard/api/dashboard_api.py
Normal 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)}
|
||||
|
||||
|
||||
18
dashboard/api/entrypoint.sh
Normal file
18
dashboard/api/entrypoint.sh
Normal 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
476
dashboard/api/log_parser.py
Normal 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
11
dashboard/ui/Dockerfile
Normal 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
20
dashboard/ui/index.html
Normal 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
23
dashboard/ui/nginx.conf
Normal 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
20
dashboard/ui/package.json
Normal 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
639
dashboard/ui/src/App.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
dashboard/ui/src/main.jsx
Normal file
9
dashboard/ui/src/main.jsx
Normal 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>
|
||||
)
|
||||
11
dashboard/ui/vite.config.js
Normal file
11
dashboard/ui/vite.config.js
Normal 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'
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user