feat: add multi-symbol dashboard support (parser, API, UI)
- Add [SYMBOL] prefix to all bot/user_data_stream log messages - Rewrite log_parser.py with multi-symbol regex, per-symbol state tracking, symbol columns in DB schema - Rewrite dashboard_api.py with /api/symbols endpoint, symbol query params on all endpoints, SQL injection fix - Update App.jsx with symbol filter tabs, multi-position display, dynamic header - Add tests for log parser (8 tests) and dashboard API (7 tests) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -129,3 +129,4 @@ All design documents and implementation plans are stored in `docs/plans/` with t
|
||||
| 2026-03-03 | `demo-1m-125x` (design + plan) | In Progress |
|
||||
| 2026-03-04 | `oi-derived-features` (design + plan) | Completed |
|
||||
| 2026-03-05 | `multi-symbol-trading` (design + plan) | Completed |
|
||||
| 2026-03-06 | `multi-symbol-dashboard` (design + plan) | Completed |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
dashboard_api.py — 로그 파서가 채운 SQLite DB를 읽어서 대시보드 API 제공
|
||||
dashboard_api.py — 멀티심볼 대시보드 API
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
@@ -9,6 +9,7 @@ from fastapi import FastAPI, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pathlib import Path
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional
|
||||
|
||||
DB_PATH = os.environ.get("DB_PATH", "/app/data/dashboard.db")
|
||||
|
||||
@@ -30,73 +31,135 @@ def get_db():
|
||||
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):
|
||||
@app.get("/api/symbols")
|
||||
def get_symbols():
|
||||
"""활성 심볼 목록 반환."""
|
||||
with get_db() as db:
|
||||
rows = db.execute(
|
||||
"SELECT * FROM trades WHERE status='CLOSED' ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
"SELECT key FROM bot_status WHERE key LIKE '%:last_start'"
|
||||
).fetchall()
|
||||
total = db.execute("SELECT COUNT(*) as cnt FROM trades WHERE status='CLOSED'").fetchone()["cnt"]
|
||||
symbols = [r["key"].split(":")[0] for r in rows]
|
||||
return {"symbols": sorted(symbols)}
|
||||
|
||||
|
||||
@app.get("/api/position")
|
||||
def get_position(symbol: Optional[str] = None):
|
||||
with get_db() as db:
|
||||
if symbol:
|
||||
rows = db.execute(
|
||||
"SELECT * FROM trades WHERE status='OPEN' AND symbol=? ORDER BY id DESC",
|
||||
(symbol,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = db.execute(
|
||||
"SELECT * FROM trades WHERE status='OPEN' ORDER BY id DESC"
|
||||
).fetchall()
|
||||
status_rows = db.execute("SELECT key, value FROM bot_status").fetchall()
|
||||
bot = {r["key"]: r["value"] for r in status_rows}
|
||||
return {"positions": [dict(r) for r in rows], "bot": bot}
|
||||
|
||||
|
||||
@app.get("/api/trades")
|
||||
def get_trades(
|
||||
symbol: Optional[str] = None,
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
offset: int = 0,
|
||||
):
|
||||
with get_db() as db:
|
||||
if symbol:
|
||||
rows = db.execute(
|
||||
"SELECT * FROM trades WHERE status='CLOSED' AND symbol=? ORDER BY id DESC LIMIT ? OFFSET ?",
|
||||
(symbol, limit, offset),
|
||||
).fetchall()
|
||||
total = db.execute(
|
||||
"SELECT COUNT(*) as cnt FROM trades WHERE status='CLOSED' AND symbol=?",
|
||||
(symbol,),
|
||||
).fetchone()["cnt"]
|
||||
else:
|
||||
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)):
|
||||
def get_daily(symbol: Optional[str] = None, 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()
|
||||
if symbol:
|
||||
rows = db.execute("""
|
||||
SELECT date,
|
||||
SUM(trade_count) as total_trades,
|
||||
SUM(wins) as wins,
|
||||
SUM(losses) as losses,
|
||||
ROUND(SUM(cumulative_pnl), 4) as net_pnl
|
||||
FROM daily_pnl
|
||||
WHERE symbol=?
|
||||
GROUP BY date ORDER BY date DESC LIMIT ?
|
||||
""", (symbol, days)).fetchall()
|
||||
else:
|
||||
rows = db.execute("""
|
||||
SELECT date,
|
||||
SUM(trade_count) as total_trades,
|
||||
SUM(wins) as wins,
|
||||
SUM(losses) as losses,
|
||||
ROUND(SUM(cumulative_pnl), 4) as net_pnl
|
||||
FROM daily_pnl
|
||||
GROUP BY date ORDER BY date DESC LIMIT ?
|
||||
""", (days,)).fetchall()
|
||||
return {"daily": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@app.get("/api/stats")
|
||||
def get_stats():
|
||||
def get_stats(symbol: Optional[str] = None):
|
||||
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()
|
||||
if symbol:
|
||||
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' AND symbol=?
|
||||
""", (symbol,)).fetchone()
|
||||
else:
|
||||
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")
|
||||
if symbol:
|
||||
result["current_price"] = bot.get(f"{symbol}:current_price")
|
||||
result["balance"] = bot.get("balance")
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/candles")
|
||||
def get_candles(limit: int = Query(96, ge=1, le=1000)):
|
||||
def get_candles(symbol: str = Query(...), 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()
|
||||
rows = db.execute(
|
||||
"SELECT * FROM candles WHERE symbol=? ORDER BY ts DESC LIMIT ?",
|
||||
(symbol, limit),
|
||||
).fetchall()
|
||||
return {"candles": [dict(r) for r in reversed(rows)]}
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
try:
|
||||
@@ -109,15 +172,12 @@ def health():
|
||||
|
||||
@app.post("/api/reset")
|
||||
def reset_db():
|
||||
"""DB 전체 초기화 후 파서 재시작 (로그를 처음부터 다시 파싱)"""
|
||||
with get_db() as db:
|
||||
for table in ["trades", "daily_pnl", "parse_state", "bot_status", "candles"]:
|
||||
db.execute(f"DELETE FROM {table}")
|
||||
db.commit()
|
||||
|
||||
# 파서 프로세스 재시작 (entrypoint.sh의 백그라운드 프로세스)
|
||||
import subprocess, os, signal
|
||||
# 기존 파서 종료 (pkill 대신 Python-native 방식)
|
||||
import subprocess, signal
|
||||
for pid_str in os.listdir("/proc") if os.path.isdir("/proc") else []:
|
||||
if not pid_str.isdigit():
|
||||
continue
|
||||
@@ -128,9 +188,6 @@ def reset_db():
|
||||
os.kill(int(pid_str), signal.SIGTERM)
|
||||
except (FileNotFoundError, PermissionError, ProcessLookupError, OSError):
|
||||
pass
|
||||
# 새 파서 시작
|
||||
subprocess.Popen(["python", "log_parser.py"])
|
||||
|
||||
return {"status": "ok", "message": "DB 초기화 완료, 파서 재시작됨"}
|
||||
|
||||
|
||||
|
||||
@@ -20,36 +20,31 @@ 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")) # 초
|
||||
|
||||
# ── 정규식 패턴 (실제 봇 로그 형식 기준) ──────────────────────────
|
||||
# ── 정규식 패턴 (멀티심볼 [SYMBOL] 프리픽스 포함) ─────────────────
|
||||
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"
|
||||
r".*\[(?P<symbol>\w+)\] 신호: (?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.]+)"
|
||||
r".*\[(?P<symbol>\w+)\] 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.]+)"
|
||||
r".*\[(?P<symbol>\w+)\] 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.]+)"
|
||||
r".*\[(?P<symbol>\w+)\] 기존 포지션 복구: (?P<direction>\w+) \| 진입가=(?P<entry_price>[\d.]+) \| 수량=(?P<qty>[\d.]+)"
|
||||
),
|
||||
|
||||
# SHORT 진입: 가격=1.3940, 수량=86.8, SL=1.4040, TP=1.3840, RSI=42.31, MACD_H=-0.001234, ATR=0.005678
|
||||
"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<symbol>\w+)\] (?P<direction>SHORT|LONG) 진입: "
|
||||
r"가격=(?P<entry_price>[\d.]+), "
|
||||
r"수량=(?P<qty>[\d.]+), "
|
||||
r"SL=(?P<sl>[\d.]+), "
|
||||
@@ -59,35 +54,30 @@ PATTERNS = {
|
||||
r"(?:, ATR=(?P<atr>[\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".*\[(?P<symbol>\w+)\] 청산 감지\((?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"
|
||||
r".*\[(?P<symbol>\w+)\] 오늘 누적 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"
|
||||
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"
|
||||
r".*\[(?P<symbol>\w+)\] 기준 잔고 설정: (?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.]+)"
|
||||
@@ -103,18 +93,22 @@ class LogParser:
|
||||
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._file_positions = {}
|
||||
self._current_positions = {} # {symbol: position_dict}
|
||||
self._pending_candles = {} # {symbol: {ts_key: {data}}}
|
||||
self._balance = 0
|
||||
|
||||
def _init_db(self):
|
||||
self.conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS trades (
|
||||
DROP TABLE IF EXISTS trades;
|
||||
DROP TABLE IF EXISTS candles;
|
||||
DROP TABLE IF EXISTS daily_pnl;
|
||||
DROP TABLE IF EXISTS bot_status;
|
||||
DROP TABLE IF EXISTS parse_state;
|
||||
|
||||
CREATE TABLE trades (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL DEFAULT 'XRPUSDT',
|
||||
symbol TEXT NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
entry_time TEXT NOT NULL,
|
||||
exit_time TEXT,
|
||||
@@ -137,54 +131,60 @@ class LogParser:
|
||||
extra TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS candles (
|
||||
CREATE TABLE candles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL UNIQUE,
|
||||
symbol TEXT NOT NULL,
|
||||
ts TEXT NOT NULL,
|
||||
price REAL NOT NULL,
|
||||
signal TEXT,
|
||||
adx REAL,
|
||||
oi REAL,
|
||||
oi_change REAL,
|
||||
funding_rate REAL
|
||||
funding_rate REAL,
|
||||
UNIQUE(symbol, ts)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_pnl (
|
||||
date TEXT PRIMARY KEY,
|
||||
CREATE TABLE daily_pnl (
|
||||
symbol TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
cumulative_pnl REAL DEFAULT 0,
|
||||
trade_count INTEGER DEFAULT 0,
|
||||
wins INTEGER DEFAULT 0,
|
||||
losses INTEGER DEFAULT 0,
|
||||
last_updated TEXT
|
||||
last_updated TEXT,
|
||||
PRIMARY KEY(symbol, date)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bot_status (
|
||||
CREATE TABLE bot_status (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS parse_state (
|
||||
CREATE TABLE 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);
|
||||
CREATE INDEX idx_candles_symbol_ts ON candles(symbol, ts);
|
||||
CREATE INDEX idx_trades_status ON trades(status);
|
||||
CREATE INDEX idx_trades_symbol ON trades(symbol);
|
||||
""")
|
||||
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)
|
||||
# 심볼별 열린 포지션 복원
|
||||
open_trades = self.conn.execute(
|
||||
"SELECT * FROM trades WHERE status='OPEN' ORDER BY id DESC"
|
||||
).fetchall()
|
||||
for row in open_trades:
|
||||
sym = row["symbol"]
|
||||
if sym not in self._current_positions:
|
||||
self._current_positions[sym] = dict(row)
|
||||
|
||||
def _save_position(self, filepath, pos):
|
||||
self.conn.execute(
|
||||
@@ -214,8 +214,6 @@ class LogParser:
|
||||
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):
|
||||
@@ -231,12 +229,11 @@ class LogParser:
|
||||
except OSError:
|
||||
return
|
||||
|
||||
# 파일이 줄었으면 (로테이션) 처음부터
|
||||
if file_size < last_pos:
|
||||
last_pos = 0
|
||||
|
||||
if file_size == last_pos:
|
||||
return # 새 내용 없음
|
||||
return
|
||||
|
||||
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
|
||||
f.seek(last_pos)
|
||||
@@ -257,11 +254,9 @@ class LogParser:
|
||||
# 봇 시작
|
||||
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"))
|
||||
symbol = m.group("symbol")
|
||||
self._set_status(f"{symbol}:leverage", m.group("leverage"))
|
||||
self._set_status(f"{symbol}:last_start", m.group("ts"))
|
||||
return
|
||||
|
||||
# 잔고
|
||||
@@ -282,6 +277,7 @@ class LogParser:
|
||||
if m:
|
||||
self._handle_entry(
|
||||
ts=m.group("ts"),
|
||||
symbol=m.group("symbol"),
|
||||
direction=m.group("direction"),
|
||||
entry_price=float(m.group("entry_price")),
|
||||
qty=float(m.group("qty")),
|
||||
@@ -289,11 +285,12 @@ class LogParser:
|
||||
)
|
||||
return
|
||||
|
||||
# 포지션 진입: SHORT 진입: 가격=X, 수량=Y, SL=Z, TP=W, RSI=R, MACD_H=M, ATR=A
|
||||
# 포지션 진입
|
||||
m = PATTERNS["entry"].search(line)
|
||||
if m:
|
||||
self._handle_entry(
|
||||
ts=m.group("ts"),
|
||||
symbol=m.group("symbol"),
|
||||
direction=m.group("direction"),
|
||||
entry_price=float(m.group("entry_price")),
|
||||
qty=float(m.group("qty")),
|
||||
@@ -308,10 +305,13 @@ class LogParser:
|
||||
# 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({
|
||||
symbol = m.group("symbol")
|
||||
ts_key = m.group("ts")[:16]
|
||||
if symbol not in self._pending_candles:
|
||||
self._pending_candles[symbol] = {}
|
||||
if ts_key not in self._pending_candles[symbol]:
|
||||
self._pending_candles[symbol][ts_key] = {}
|
||||
self._pending_candles[symbol][ts_key].update({
|
||||
"oi": float(m.group("oi")),
|
||||
"oi_change": float(m.group("oi_change")),
|
||||
"funding": float(m.group("funding")),
|
||||
@@ -321,32 +321,36 @@ class LogParser:
|
||||
# ADX
|
||||
m = PATTERNS["adx"].search(line)
|
||||
if m:
|
||||
symbol = m.group("symbol")
|
||||
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"))
|
||||
if symbol not in self._pending_candles:
|
||||
self._pending_candles[symbol] = {}
|
||||
if ts_key not in self._pending_candles[symbol]:
|
||||
self._pending_candles[symbol][ts_key] = {}
|
||||
self._pending_candles[symbol][ts_key]["adx"] = float(m.group("adx"))
|
||||
return
|
||||
|
||||
# 신호 + 현재가 → 캔들 저장
|
||||
m = PATTERNS["signal"].search(line)
|
||||
if m:
|
||||
symbol = m.group("symbol")
|
||||
ts = m.group("ts")
|
||||
ts_key = ts[:16]
|
||||
price = float(m.group("price"))
|
||||
signal = m.group("signal")
|
||||
extra = self._pending_candle.pop(ts_key, {})
|
||||
extra = self._pending_candles.get(symbol, {}).pop(ts_key, {})
|
||||
|
||||
self._set_status("current_price", str(price))
|
||||
self._set_status("current_signal", signal)
|
||||
self._set_status("last_candle_time", ts)
|
||||
self._set_status(f"{symbol}:current_price", str(price))
|
||||
self._set_status(f"{symbol}:current_signal", signal)
|
||||
self._set_status(f"{symbol}: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
|
||||
"""INSERT INTO candles(symbol, ts, price, signal, adx, oi, oi_change, funding_rate)
|
||||
VALUES(?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(symbol, ts) DO UPDATE SET
|
||||
price=?, signal=?, adx=?, oi=?, oi_change=?, funding_rate=?""",
|
||||
(ts, price, signal,
|
||||
(symbol, 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")),
|
||||
@@ -361,6 +365,7 @@ class LogParser:
|
||||
if m:
|
||||
self._handle_close(
|
||||
ts=m.group("ts"),
|
||||
symbol=m.group("symbol"),
|
||||
exit_price=float(m.group("exit_price")),
|
||||
expected_pnl=float(m.group("expected")),
|
||||
commission=float(m.group("commission")),
|
||||
@@ -372,37 +377,38 @@ class LogParser:
|
||||
# 일일 누적 PnL
|
||||
m = PATTERNS["daily_pnl"].search(line)
|
||||
if m:
|
||||
symbol = m.group("symbol")
|
||||
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)
|
||||
"""INSERT INTO daily_pnl(symbol, date, cumulative_pnl, last_updated)
|
||||
VALUES(?,?,?,?)
|
||||
ON CONFLICT(symbol, date) DO UPDATE SET cumulative_pnl=?, last_updated=?""",
|
||||
(symbol, day, pnl, ts, pnl, ts)
|
||||
)
|
||||
self.conn.commit()
|
||||
self._set_status("daily_pnl", str(pnl))
|
||||
self._set_status(f"{symbol}:daily_pnl", str(pnl))
|
||||
return
|
||||
|
||||
# ── 포지션 진입 핸들러 ───────────────────────────────────────
|
||||
def _handle_entry(self, ts, direction, entry_price, qty,
|
||||
def _handle_entry(self, ts, symbol, direction, entry_price, qty,
|
||||
leverage=None, sl=None, tp=None, is_recovery=False,
|
||||
rsi=None, macd_hist=None, atr=None):
|
||||
if leverage is None:
|
||||
leverage = self._bot_config.get("leverage", 10)
|
||||
leverage = 10
|
||||
|
||||
# 중복 체크 — 같은 방향의 OPEN 포지션이 이미 있으면 스킵
|
||||
# (봇은 동시에 같은 방향 포지션을 2개 이상 열지 않음)
|
||||
if self._current_position and self._current_position.get("direction") == direction:
|
||||
# 중복 체크 — 같은 심볼+방향의 OPEN 포지션이 이미 있으면 스킵
|
||||
current = self._current_positions.get(symbol)
|
||||
if current and current.get("direction") == direction:
|
||||
return
|
||||
|
||||
existing = self.conn.execute(
|
||||
"SELECT id, entry_price FROM trades WHERE status='OPEN' AND direction=?",
|
||||
(direction,),
|
||||
"SELECT id, entry_price FROM trades WHERE status='OPEN' AND symbol=? AND direction=?",
|
||||
(symbol, direction),
|
||||
).fetchone()
|
||||
if existing:
|
||||
self._current_position = {
|
||||
self._current_positions[symbol] = {
|
||||
"id": existing["id"],
|
||||
"direction": direction,
|
||||
"entry_price": existing["entry_price"],
|
||||
@@ -414,35 +420,35 @@ class LogParser:
|
||||
"""INSERT INTO trades(symbol, direction, entry_time, entry_price,
|
||||
quantity, leverage, sl, tp, status, extra, rsi, macd_hist, atr)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(self._bot_config.get("symbol", "XRPUSDT"), direction, ts,
|
||||
(symbol, direction, ts,
|
||||
entry_price, qty, leverage, sl, tp, "OPEN",
|
||||
json.dumps({"recovery": is_recovery}),
|
||||
rsi, macd_hist, atr),
|
||||
)
|
||||
self.conn.commit()
|
||||
self._current_position = {
|
||||
self._current_positions[symbol] = {
|
||||
"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})")
|
||||
self._set_status(f"{symbol}:position_status", "OPEN")
|
||||
self._set_status(f"{symbol}:position_direction", direction)
|
||||
self._set_status(f"{symbol}:position_entry_price", str(entry_price))
|
||||
print(f"[LogParser] {symbol} 포지션 진입: {direction} @ {entry_price} (recovery={is_recovery})")
|
||||
|
||||
# ── 포지션 청산 핸들러 ───────────────────────────────────────
|
||||
def _handle_close(self, ts, exit_price, expected_pnl, commission, net_pnl, reason):
|
||||
# 모든 OPEN 거래를 닫음 (봇은 동시에 1개 포지션만 보유)
|
||||
def _handle_close(self, ts, symbol, exit_price, expected_pnl, commission, net_pnl, reason):
|
||||
# 해당 심볼의 OPEN 거래만 닫음
|
||||
open_trades = self.conn.execute(
|
||||
"SELECT id FROM trades WHERE status='OPEN' ORDER BY id DESC"
|
||||
"SELECT id FROM trades WHERE status='OPEN' AND symbol=? ORDER BY id DESC",
|
||||
(symbol,),
|
||||
).fetchall()
|
||||
|
||||
if not open_trades:
|
||||
print(f"[LogParser] 경고: 청산 감지했으나 열린 포지션 없음")
|
||||
print(f"[LogParser] 경고: {symbol} 청산 감지했으나 열린 포지션 없음")
|
||||
return
|
||||
|
||||
# 가장 최근 OPEN에 실제 PnL 기록
|
||||
primary_id = open_trades[0]["id"]
|
||||
self.conn.execute(
|
||||
"""UPDATE trades SET
|
||||
@@ -455,34 +461,33 @@ class LogParser:
|
||||
reason, primary_id)
|
||||
)
|
||||
|
||||
# 나머지 OPEN 거래는 중복이므로 삭제
|
||||
if len(open_trades) > 1:
|
||||
stale_ids = [r["id"] for r in open_trades[1:]]
|
||||
self.conn.execute(
|
||||
f"DELETE FROM trades WHERE id IN ({','.join('?' * len(stale_ids))})",
|
||||
stale_ids,
|
||||
)
|
||||
print(f"[LogParser] 중복 OPEN 거래 {len(stale_ids)}건 삭제")
|
||||
print(f"[LogParser] {symbol} 중복 OPEN 거래 {len(stale_ids)}건 삭제")
|
||||
|
||||
# 일별 요약 갱신
|
||||
# 심볼별 일별 요약
|
||||
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
|
||||
"""INSERT INTO daily_pnl(symbol, date, cumulative_pnl, trade_count, wins, losses, last_updated)
|
||||
VALUES(?, ?, ?, 1, ?, ?, ?)
|
||||
ON CONFLICT(symbol, date) DO UPDATE SET
|
||||
trade_count = trade_count + 1,
|
||||
wins = wins + ?,
|
||||
losses = losses + ?,
|
||||
last_updated = ?""",
|
||||
(day, net_pnl, win, loss, ts, win, loss, ts)
|
||||
(symbol, 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
|
||||
self._set_status(f"{symbol}:position_status", "NONE")
|
||||
print(f"[LogParser] {symbol} 포지션 청산: {reason} @ {exit_price}, PnL={net_pnl}")
|
||||
self._current_positions.pop(symbol, None)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -266,12 +266,15 @@ export default function App() {
|
||||
const [isLive, setIsLive] = useState(false);
|
||||
const [lastUpdate, setLastUpdate] = useState(null);
|
||||
|
||||
const [symbols, setSymbols] = useState([]);
|
||||
const [selectedSymbol, setSelectedSymbol] = useState(null); // null = ALL
|
||||
|
||||
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 [positions, setPositions] = useState([]);
|
||||
const [botStatus, setBotStatus] = useState({});
|
||||
const [trades, setTrades] = useState([]);
|
||||
const [daily, setDaily] = useState([]);
|
||||
@@ -279,27 +282,32 @@ export default function App() {
|
||||
|
||||
/* ── 데이터 폴링 ─────────────────────────────────────────── */
|
||||
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"),
|
||||
const sym = selectedSymbol ? `?symbol=${selectedSymbol}` : "";
|
||||
const symRequired = selectedSymbol || symbols[0] || "XRPUSDT";
|
||||
|
||||
const [symRes, sRes, pRes, tRes, dRes, cRes] = await Promise.all([
|
||||
api("/symbols"),
|
||||
api(`/stats${sym}`),
|
||||
api(`/position${sym}`),
|
||||
api(`/trades${sym}${sym ? "&" : "?"}limit=50`),
|
||||
api(`/daily${sym}`),
|
||||
api(`/candles?symbol=${symRequired}&limit=96`),
|
||||
]);
|
||||
|
||||
if (symRes?.symbols) setSymbols(symRes.symbols);
|
||||
if (sRes && sRes.total_trades !== undefined) {
|
||||
setStats(sRes);
|
||||
setIsLive(true);
|
||||
setLastUpdate(new Date());
|
||||
}
|
||||
if (pRes) {
|
||||
setPosition(pRes.position);
|
||||
setPositions(pRes.positions || []);
|
||||
if (pRes.bot) setBotStatus(pRes.bot);
|
||||
}
|
||||
if (tRes?.trades) setTrades(tRes.trades);
|
||||
if (dRes?.daily) setDaily(dRes.daily);
|
||||
if (cRes?.candles) setCandles(cRes.candles);
|
||||
}, []);
|
||||
}, [selectedSymbol, symbols]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll();
|
||||
@@ -328,8 +336,9 @@ export default function App() {
|
||||
const candleLabels = candles.map((c) => fmtTime(c.ts));
|
||||
|
||||
/* ── 현재 가격 (봇 상태 또는 마지막 캔들) ──────────────────── */
|
||||
const currentPrice = botStatus.current_price
|
||||
|| (candles.length ? candles[candles.length - 1].price : null);
|
||||
const currentPrice = selectedSymbol
|
||||
? (botStatus[`${selectedSymbol}:current_price`] || (candles.length ? candles[candles.length - 1].price : null))
|
||||
: (candles.length ? candles[candles.length - 1].price : null);
|
||||
|
||||
/* ── 공통 차트 축 스타일 ─────────────────────────────────── */
|
||||
const axisStyle = {
|
||||
@@ -371,7 +380,10 @@ export default function App() {
|
||||
fontSize: 10, color: S.text3, letterSpacing: 2,
|
||||
textTransform: "uppercase", fontFamily: S.mono,
|
||||
}}>
|
||||
{isLive ? "Live" : "Connecting…"} · XRP/USDT
|
||||
{isLive ? "Live" : "Connecting…"}
|
||||
{selectedSymbol
|
||||
? ` · ${selectedSymbol.replace("USDT", "/USDT")}`
|
||||
: ` · ${symbols.length} symbols`}
|
||||
{currentPrice && (
|
||||
<span style={{ color: "rgba(255,255,255,0.5)", marginLeft: 8 }}>
|
||||
{fmt(currentPrice)}
|
||||
@@ -384,35 +396,66 @@ export default function App() {
|
||||
</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>
|
||||
{/* 오픈 포지션 — 복수 표시 */}
|
||||
{positions.length > 0 && (
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
{positions.map((pos) => (
|
||||
<div key={pos.id} 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 }}>
|
||||
{(pos.symbol || "").replace("USDT", "/USDT")}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
|
||||
<Badge
|
||||
bg={pos.direction === "SHORT" ? "rgba(239,68,68,0.12)" : "rgba(52,211,153,0.12)"}
|
||||
color={pos.direction === "SHORT" ? S.red : S.green}
|
||||
>
|
||||
{pos.direction} {pos.leverage || 10}x
|
||||
</Badge>
|
||||
<span style={{ fontSize: 14, fontWeight: 700, fontFamily: S.mono }}>
|
||||
{fmt(pos.entry_price)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 심볼 필터 ═══════════════════════════════════════ */}
|
||||
<div style={{
|
||||
display: "flex", gap: 4, marginBottom: 12,
|
||||
background: "rgba(255,255,255,0.02)", borderRadius: 12,
|
||||
padding: 4, width: "fit-content",
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setSelectedSymbol(null)}
|
||||
style={{
|
||||
background: selectedSymbol === null ? "rgba(99,102,241,0.15)" : "transparent",
|
||||
border: "none",
|
||||
color: selectedSymbol === null ? S.indigo : S.text3,
|
||||
padding: "6px 14px", borderRadius: 8, cursor: "pointer",
|
||||
fontSize: 11, fontWeight: 600, fontFamily: S.mono,
|
||||
}}
|
||||
>ALL</button>
|
||||
{symbols.map((sym) => (
|
||||
<button
|
||||
key={sym}
|
||||
onClick={() => setSelectedSymbol(sym)}
|
||||
style={{
|
||||
background: selectedSymbol === sym ? "rgba(99,102,241,0.15)" : "transparent",
|
||||
border: "none",
|
||||
color: selectedSymbol === sym ? S.indigo : S.text3,
|
||||
padding: "6px 14px", borderRadius: 8, cursor: "pointer",
|
||||
fontSize: 11, fontWeight: 600, fontFamily: S.mono,
|
||||
}}
|
||||
>{sym.replace("USDT", "")}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ═══ 탭 ═════════════════════════════════════════════ */}
|
||||
<div style={{
|
||||
display: "flex", gap: 4, marginBottom: 24,
|
||||
@@ -556,7 +599,7 @@ export default function App() {
|
||||
{/* ═══ CHART ══════════════════════════════════════════ */}
|
||||
{tab === "chart" && (
|
||||
<div>
|
||||
<ChartBox title="XRP/USDT 15m 가격">
|
||||
<ChartBox title={`${(selectedSymbol || symbols[0] || "XRP").replace("USDT", "")}/USDT 15m 가격`}>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<AreaChart data={candles.map((c) => ({ ts: fmtTime(c.ts), price: c.price || c.close }))}>
|
||||
<defs>
|
||||
|
||||
26
src/bot.py
26
src/bot.py
@@ -64,7 +64,7 @@ class TradingBot:
|
||||
self._entry_quantity = abs(amt)
|
||||
entry = float(position["entryPrice"])
|
||||
logger.info(
|
||||
f"기존 포지션 복구: {self.current_trade_side} | "
|
||||
f"[{self.symbol}] 기존 포지션 복구: {self.current_trade_side} | "
|
||||
f"진입가={entry:.4f} | 수량={abs(amt)}"
|
||||
)
|
||||
self.notifier.notify_info(
|
||||
@@ -72,7 +72,7 @@ class TradingBot:
|
||||
f"진입가={entry:.4f} 수량={abs(amt)}"
|
||||
)
|
||||
else:
|
||||
logger.info("기존 포지션 없음 - 신규 진입 대기")
|
||||
logger.info(f"[{self.symbol}] 기존 포지션 없음 - 신규 진입 대기")
|
||||
|
||||
async def _init_oi_history(self) -> None:
|
||||
"""봇 시작 시 최근 OI 변화율 히스토리를 조회하여 deque를 채운다."""
|
||||
@@ -82,7 +82,7 @@ class TradingBot:
|
||||
self._oi_history.append(c)
|
||||
if changes:
|
||||
self._prev_oi = None
|
||||
logger.info(f"OI 히스토리 초기화: {len(self._oi_history)}개")
|
||||
logger.info(f"[{self.symbol}] OI 히스토리 초기화: {len(self._oi_history)}개")
|
||||
except Exception as e:
|
||||
logger.warning(f"OI 히스토리 초기화 실패 (무시): {e}")
|
||||
|
||||
@@ -107,7 +107,7 @@ class TradingBot:
|
||||
oi_price_spread = oi_change - self._latest_ret_1
|
||||
|
||||
logger.debug(
|
||||
f"OI={oi_val}, OI변화율={oi_change:.6f}, 펀딩비={fr_float:.6f}, "
|
||||
f"[{self.symbol}] OI={oi_val}, OI변화율={oi_change:.6f}, 펀딩비={fr_float:.6f}, "
|
||||
f"OI_MA5={oi_ma5:.6f}, OI_Price_Spread={oi_price_spread:.6f}"
|
||||
)
|
||||
return oi_change, fr_float, oi_ma5, oi_price_spread
|
||||
@@ -134,7 +134,7 @@ class TradingBot:
|
||||
oi_change, funding_rate, oi_ma5, oi_price_spread = await self._fetch_market_microstructure()
|
||||
|
||||
if not self.risk.is_trading_allowed():
|
||||
logger.warning("리스크 한도 초과 - 거래 중단")
|
||||
logger.warning(f"[{self.symbol}] 리스크 한도 초과 - 거래 중단")
|
||||
return
|
||||
|
||||
ind = Indicators(df)
|
||||
@@ -142,7 +142,7 @@ class TradingBot:
|
||||
raw_signal = ind.get_signal(df_with_indicators)
|
||||
|
||||
current_price = df_with_indicators["close"].iloc[-1]
|
||||
logger.info(f"신호: {raw_signal} | 현재가: {current_price:.4f} USDT")
|
||||
logger.info(f"[{self.symbol}] 신호: {raw_signal} | 현재가: {current_price:.4f} USDT")
|
||||
|
||||
position = await self.exchange.get_position()
|
||||
|
||||
@@ -160,7 +160,7 @@ class TradingBot:
|
||||
)
|
||||
if self.ml_filter.is_model_loaded():
|
||||
if not self.ml_filter.should_enter(features):
|
||||
logger.info(f"ML 필터 차단: {signal} 신호 무시")
|
||||
logger.info(f"[{self.symbol}] ML 필터 차단: {signal} 신호 무시")
|
||||
return
|
||||
await self._open_position(signal, df_with_indicators)
|
||||
|
||||
@@ -221,7 +221,7 @@ class TradingBot:
|
||||
signal_data=signal_snapshot,
|
||||
)
|
||||
logger.success(
|
||||
f"{signal} 진입: 가격={price}, 수량={quantity}, "
|
||||
f"[{self.symbol}] {signal} 진입: 가격={price}, 수량={quantity}, "
|
||||
f"SL={stop_loss:.4f}, TP={take_profit:.4f}, "
|
||||
f"RSI={signal_snapshot['rsi']:.2f}, "
|
||||
f"MACD_H={signal_snapshot['macd_hist']:.6f}, "
|
||||
@@ -275,7 +275,7 @@ class TradingBot:
|
||||
)
|
||||
|
||||
logger.success(
|
||||
f"포지션 청산({close_reason}): 예상={estimated_pnl:+.4f}, "
|
||||
f"[{self.symbol}] 포지션 청산({close_reason}): 예상={estimated_pnl:+.4f}, "
|
||||
f"순수익={net_pnl:+.4f}, 차이={diff:+.4f} USDT"
|
||||
)
|
||||
|
||||
@@ -303,7 +303,7 @@ class TradingBot:
|
||||
cost = self._entry_price * self._entry_quantity
|
||||
pnl_pct = (pnl / cost * 100) if cost > 0 else 0.0
|
||||
logger.info(
|
||||
f"포지션 모니터 | {self.current_trade_side} | "
|
||||
f"[{self.symbol}] 포지션 모니터 | {self.current_trade_side} | "
|
||||
f"현재가={price:.4f} | PnL={pnl:+.4f} USDT ({pnl_pct:+.2f}%) | "
|
||||
f"진입가={self._entry_price:.4f}"
|
||||
)
|
||||
@@ -314,7 +314,7 @@ class TradingBot:
|
||||
side = "SELL" if float(position["positionAmt"]) > 0 else "BUY"
|
||||
await self.exchange.cancel_all_orders()
|
||||
await self.exchange.place_order(side=side, quantity=amt, reduce_only=True)
|
||||
logger.info(f"청산 주문 전송 완료 (side={side}, qty={amt})")
|
||||
logger.info(f"[{self.symbol}] 청산 주문 전송 완료 (side={side}, qty={amt})")
|
||||
|
||||
async def _close_and_reenter(
|
||||
self,
|
||||
@@ -346,7 +346,7 @@ class TradingBot:
|
||||
oi_change_ma5=oi_change_ma5, oi_price_spread=oi_price_spread,
|
||||
)
|
||||
if not self.ml_filter.should_enter(features):
|
||||
logger.info(f"ML 필터 차단: {signal} 재진입 무시")
|
||||
logger.info(f"[{self.symbol}] ML 필터 차단: {signal} 재진입 무시")
|
||||
return
|
||||
|
||||
await self._open_position(signal, df)
|
||||
@@ -359,7 +359,7 @@ class TradingBot:
|
||||
await self._init_oi_history()
|
||||
balance = await self.exchange.get_balance()
|
||||
self.risk.set_base_balance(balance)
|
||||
logger.info(f"기준 잔고 설정: {balance:.2f} USDT (동적 증거금 비율 기준점)")
|
||||
logger.info(f"[{self.symbol}] 기준 잔고 설정: {balance:.2f} USDT (동적 증거금 비율 기준점)")
|
||||
|
||||
user_stream = UserDataStream(
|
||||
symbol=self.symbol,
|
||||
|
||||
@@ -102,7 +102,7 @@ class UserDataStream:
|
||||
close_reason = "MANUAL"
|
||||
|
||||
logger.info(
|
||||
f"청산 감지({close_reason}): exit={exit_price:.4f}, "
|
||||
f"[{self._symbol}] 청산 감지({close_reason}): exit={exit_price:.4f}, "
|
||||
f"rp={realized_pnl:+.4f}, commission={commission:.4f}, "
|
||||
f"net_pnl={net_pnl:+.4f}"
|
||||
)
|
||||
|
||||
144
tests/test_dashboard_api.py
Normal file
144
tests/test_dashboard_api.py
Normal file
@@ -0,0 +1,144 @@
|
||||
import sys
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "dashboard", "api"))
|
||||
|
||||
# DB_PATH를 테스트용 임시 파일로 설정 (import 전에)
|
||||
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
os.environ["DB_PATH"] = _tmp_db.name
|
||||
_tmp_db.close()
|
||||
|
||||
import dashboard_api # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db():
|
||||
"""각 테스트 전에 DB를 초기화하고 테스트 데이터를 삽입."""
|
||||
db_path = os.environ["DB_PATH"]
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.executescript("""
|
||||
DROP TABLE IF EXISTS trades;
|
||||
DROP TABLE IF EXISTS candles;
|
||||
DROP TABLE IF EXISTS daily_pnl;
|
||||
DROP TABLE IF EXISTS bot_status;
|
||||
DROP TABLE IF EXISTS parse_state;
|
||||
|
||||
CREATE TABLE trades (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
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 candles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
ts TEXT NOT NULL,
|
||||
price REAL NOT NULL,
|
||||
signal TEXT, adx REAL, oi REAL, oi_change REAL, funding_rate REAL,
|
||||
UNIQUE(symbol, ts)
|
||||
);
|
||||
CREATE TABLE daily_pnl (
|
||||
symbol TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
cumulative_pnl REAL DEFAULT 0,
|
||||
trade_count INTEGER DEFAULT 0,
|
||||
wins INTEGER DEFAULT 0,
|
||||
losses INTEGER DEFAULT 0,
|
||||
last_updated TEXT,
|
||||
PRIMARY KEY(symbol, date)
|
||||
);
|
||||
CREATE TABLE bot_status (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TEXT
|
||||
);
|
||||
CREATE TABLE parse_state (filepath TEXT PRIMARY KEY, position INTEGER DEFAULT 0);
|
||||
""")
|
||||
|
||||
# 테스트 데이터
|
||||
conn.execute(
|
||||
"INSERT INTO trades(symbol,direction,entry_time,entry_price,quantity,status) VALUES(?,?,?,?,?,?)",
|
||||
("XRPUSDT", "LONG", "2026-03-06 00:00:00", 2.30, 100.0, "OPEN"),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO trades(symbol,direction,entry_time,entry_price,exit_time,exit_price,quantity,net_pnl,commission,status,close_reason) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("TRXUSDT", "SHORT", "2026-03-05 12:00:00", 0.23, "2026-03-05 14:00:00", 0.22, 1000.0, 10.0, 0.1, "CLOSED", "TP"),
|
||||
)
|
||||
conn.execute("INSERT INTO bot_status(key,value,updated_at) VALUES(?,?,?)", ("XRPUSDT:last_start", "2026-03-06 00:00:00", "2026-03-06 00:00:00"))
|
||||
conn.execute("INSERT INTO bot_status(key,value,updated_at) VALUES(?,?,?)", ("TRXUSDT:last_start", "2026-03-06 00:00:00", "2026-03-06 00:00:00"))
|
||||
conn.execute("INSERT INTO bot_status(key,value,updated_at) VALUES(?,?,?)", ("XRPUSDT:current_price", "2.35", "2026-03-06 00:00:00"))
|
||||
conn.execute(
|
||||
"INSERT INTO candles(symbol,ts,price,signal) VALUES(?,?,?,?)",
|
||||
("XRPUSDT", "2026-03-06 00:00:00", 2.35, "LONG"),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO candles(symbol,ts,price,signal) VALUES(?,?,?,?)",
|
||||
("TRXUSDT", "2026-03-06 00:00:00", 0.23, "SHORT"),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
yield
|
||||
|
||||
|
||||
client = TestClient(dashboard_api.app)
|
||||
|
||||
|
||||
def test_get_symbols():
|
||||
r = client.get("/api/symbols")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert set(data["symbols"]) == {"XRPUSDT", "TRXUSDT"}
|
||||
|
||||
|
||||
def test_get_position_all():
|
||||
r = client.get("/api/position")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert len(data["positions"]) == 1
|
||||
assert data["positions"][0]["symbol"] == "XRPUSDT"
|
||||
|
||||
|
||||
def test_get_position_by_symbol():
|
||||
r = client.get("/api/position?symbol=XRPUSDT")
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()["positions"]) == 1
|
||||
|
||||
|
||||
def test_get_trades_by_symbol():
|
||||
r = client.get("/api/trades?symbol=TRXUSDT")
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()["trades"]) == 1
|
||||
assert r.json()["trades"][0]["symbol"] == "TRXUSDT"
|
||||
|
||||
|
||||
def test_get_candles_by_symbol():
|
||||
r = client.get("/api/candles?symbol=XRPUSDT")
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()["candles"]) == 1
|
||||
assert r.json()["candles"][0]["symbol"] == "XRPUSDT"
|
||||
|
||||
|
||||
def test_get_stats_all():
|
||||
r = client.get("/api/stats")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_get_stats_by_symbol():
|
||||
r = client.get("/api/stats?symbol=TRXUSDT")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["total_trades"] == 1
|
||||
117
tests/test_log_parser.py
Normal file
117
tests/test_log_parser.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import sys
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import pytest
|
||||
|
||||
# dashboard/api를 import path에 추가
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "dashboard", "api"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parser():
|
||||
"""임시 DB로 LogParser 인스턴스 생성."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
db_path = f.name
|
||||
|
||||
import log_parser as lp
|
||||
lp.DB_PATH = db_path
|
||||
p = lp.LogParser()
|
||||
yield p
|
||||
p.conn.close()
|
||||
os.unlink(db_path)
|
||||
|
||||
|
||||
def test_parse_signal_with_symbol(parser):
|
||||
"""[SYMBOL] 프리픽스가 있는 신호 로그를 파싱한다."""
|
||||
line = "2026-03-06 00:15:00 | INFO | [XRPUSDT] 신호: LONG | 현재가: 2.3456 USDT"
|
||||
parser._parse_line(line)
|
||||
row = parser.conn.execute("SELECT * FROM candles WHERE symbol='XRPUSDT'").fetchone()
|
||||
assert row is not None
|
||||
assert row["price"] == 2.3456
|
||||
assert row["signal"] == "LONG"
|
||||
|
||||
|
||||
def test_parse_entry_with_symbol(parser):
|
||||
"""[SYMBOL] 프리픽스가 있는 진입 로그를 파싱한다."""
|
||||
line = (
|
||||
"2026-03-06 00:15:00 | SUCCESS | [TRXUSDT] SHORT 진입: "
|
||||
"가격=0.2345, 수량=1000.0, SL=0.2380, TP=0.2240, "
|
||||
"RSI=72.31, MACD_H=-0.001234, ATR=0.005678"
|
||||
)
|
||||
parser._parse_line(line)
|
||||
row = parser.conn.execute("SELECT * FROM trades WHERE symbol='TRXUSDT'").fetchone()
|
||||
assert row is not None
|
||||
assert row["direction"] == "SHORT"
|
||||
assert row["entry_price"] == 0.2345
|
||||
|
||||
|
||||
def test_parse_close_with_symbol(parser):
|
||||
"""[SYMBOL] 프리픽스가 있는 청산 로그를 심볼별로 처리한다."""
|
||||
# 먼저 두 심볼의 포지션을 열어놓음
|
||||
entry1 = "2026-03-06 00:00:00 | SUCCESS | [XRPUSDT] LONG 진입: 가격=2.3000, 수량=100.0, SL=2.2600, TP=2.4000"
|
||||
entry2 = "2026-03-06 00:00:00 | SUCCESS | [TRXUSDT] SHORT 진입: 가격=0.2345, 수량=1000.0, SL=0.2380, TP=0.2240"
|
||||
parser._parse_line(entry1)
|
||||
parser._parse_line(entry2)
|
||||
|
||||
# XRPUSDT만 청산
|
||||
close_line = (
|
||||
"2026-03-06 01:00:00 | INFO | [XRPUSDT] 청산 감지(TP): "
|
||||
"exit=2.4000, rp=+10.0000, commission=0.1000, net_pnl=+9.9000"
|
||||
)
|
||||
parser._parse_line(close_line)
|
||||
|
||||
# XRPUSDT는 CLOSED, TRXUSDT는 여전히 OPEN
|
||||
xrp = parser.conn.execute("SELECT status FROM trades WHERE symbol='XRPUSDT'").fetchone()
|
||||
trx = parser.conn.execute("SELECT status FROM trades WHERE symbol='TRXUSDT'").fetchone()
|
||||
assert xrp["status"] == "CLOSED"
|
||||
assert trx["status"] == "OPEN"
|
||||
|
||||
|
||||
def test_parse_bot_start_multi_symbol(parser):
|
||||
"""멀티심볼 봇 시작 로그를 각각 파싱한다."""
|
||||
lines = [
|
||||
"2026-03-06 00:04:54 | INFO | [XRPUSDT] 봇 시작, 레버리지 10x",
|
||||
"2026-03-06 00:04:54 | INFO | [TRXUSDT] 봇 시작, 레버리지 10x",
|
||||
"2026-03-06 00:04:54 | INFO | [DOGEUSDT] 봇 시작, 레버리지 10x",
|
||||
]
|
||||
for line in lines:
|
||||
parser._parse_line(line)
|
||||
|
||||
symbols = parser.conn.execute(
|
||||
"SELECT value FROM bot_status WHERE key LIKE '%:last_start'"
|
||||
).fetchall()
|
||||
assert len(symbols) == 3
|
||||
|
||||
|
||||
def test_candles_table_has_symbol_column(parser):
|
||||
"""candles 테이블에 symbol 컬럼이 있어야 한다."""
|
||||
info = parser.conn.execute("PRAGMA table_info(candles)").fetchall()
|
||||
col_names = [row[1] for row in info]
|
||||
assert "symbol" in col_names
|
||||
|
||||
|
||||
def test_daily_pnl_table_has_symbol_column(parser):
|
||||
"""daily_pnl 테이블에 symbol 컬럼이 있어야 한다."""
|
||||
info = parser.conn.execute("PRAGMA table_info(daily_pnl)").fetchall()
|
||||
col_names = [row[1] for row in info]
|
||||
assert "symbol" in col_names
|
||||
|
||||
|
||||
def test_balance_log_with_symbol(parser):
|
||||
"""[SYMBOL] 프리픽스가 있는 잔고 로그를 파싱한다."""
|
||||
line = "2026-03-06 00:04:54 | INFO | [XRPUSDT] 기준 잔고 설정: 44.81 USDT (동적 증거금 비율 기준점)"
|
||||
parser._parse_line(line)
|
||||
row = parser.conn.execute("SELECT value FROM bot_status WHERE key='balance'").fetchone()
|
||||
assert row is not None
|
||||
assert row["value"] == "44.81"
|
||||
|
||||
|
||||
def test_position_recover_with_symbol(parser):
|
||||
"""[SYMBOL] 프리픽스가 있는 포지션 복구 로그를 파싱한다."""
|
||||
line = "2026-03-06 00:04:54 | INFO | [DOGEUSDT] 기존 포지션 복구: LONG | 진입가=0.1800 | 수량=500.0"
|
||||
parser._parse_line(line)
|
||||
row = parser.conn.execute("SELECT * FROM trades WHERE symbol='DOGEUSDT'").fetchone()
|
||||
assert row is not None
|
||||
assert row["direction"] == "LONG"
|
||||
assert row["entry_price"] == 0.1800
|
||||
Reference in New Issue
Block a user