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:
17
Jenkinsfile
vendored
17
Jenkinsfile
vendored
@@ -7,7 +7,10 @@ pipeline {
|
|||||||
IMAGE_TAG = "${env.BUILD_NUMBER}"
|
IMAGE_TAG = "${env.BUILD_NUMBER}"
|
||||||
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
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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user