diff --git a/CLAUDE.md b/CLAUDE.md index c8608d1..16f9c4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 | diff --git a/dashboard/api/dashboard_api.py b/dashboard/api/dashboard_api.py index f11c1ab..40723be 100644 --- a/dashboard/api/dashboard_api.py +++ b/dashboard/api/dashboard_api.py @@ -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 초기화 완료, 파서 재시작됨"} - - diff --git a/dashboard/api/log_parser.py b/dashboard/api/log_parser.py index e32f0f4..baede7a 100644 --- a/dashboard/api/log_parser.py +++ b/dashboard/api/log_parser.py @@ -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\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" - r".*신호: (?P\w+) \| 현재가: (?P[\d.]+) USDT" + r".*\[(?P\w+)\] 신호: (?P\w+) \| 현재가: (?P[\d.]+) USDT" ), - # ADX: 24.4 "adx": re.compile( r"(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" - r".*ADX: (?P[\d.]+)" + r".*\[(?P\w+)\] ADX: (?P[\d.]+)" ), - # OI=261103765.6, OI변화율=0.000692, 펀딩비=0.000039 "microstructure": re.compile( r"(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" - r".*OI=(?P[\d.]+), OI변화율=(?P[-\d.]+), 펀딩비=(?P[-\d.]+)" + r".*\[(?P\w+)\] OI=(?P[\d.]+), OI변화율=(?P[-\d.]+), 펀딩비=(?P[-\d.]+)" ), - # 기존 포지션 복구: SHORT | 진입가=1.4126 | 수량=86.8 "position_recover": re.compile( r"(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" - r".*기존 포지션 복구: (?P\w+) \| 진입가=(?P[\d.]+) \| 수량=(?P[\d.]+)" + r".*\[(?P\w+)\] 기존 포지션 복구: (?P\w+) \| 진입가=(?P[\d.]+) \| 수량=(?P[\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\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" - r".*(?PSHORT|LONG) 진입: " + r".*\[(?P\w+)\] (?PSHORT|LONG) 진입: " r"가격=(?P[\d.]+), " r"수량=(?P[\d.]+), " r"SL=(?P[\d.]+), " @@ -59,35 +54,30 @@ PATTERNS = { r"(?:, ATR=(?P[\d.]+))?" ), - # 청산 감지(MANUAL): exit=1.3782, rp=+2.9859, commission=0.0598, net_pnl=+2.9261 "close_detect": re.compile( r"(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" - r".*청산 감지\((?P\w+)\):\s*" + r".*\[(?P\w+)\] 청산 감지\((?P\w+)\):\s*" r"exit=(?P[\d.]+),\s*" r"rp=(?P[+\-\d.]+),\s*" r"commission=(?P[\d.]+),\s*" r"net_pnl=(?P[+\-\d.]+)" ), - # 오늘 누적 PnL: 2.9261 USDT "daily_pnl": re.compile( r"(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" - r".*오늘 누적 PnL: (?P[+\-\d.]+) USDT" + r".*\[(?P\w+)\] 오늘 누적 PnL: (?P[+\-\d.]+) USDT" ), - # 봇 시작: XRPUSDT, 레버리지 10x "bot_start": re.compile( r"(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" - r".*봇 시작: (?P\w+), 레버리지 (?P\d+)x" + r".*\[(?P\w+)\] 봇 시작, 레버리지 (?P\d+)x" ), - # 기준 잔고 설정: 24.46 USDT "balance": re.compile( r"(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" - r".*기준 잔고 설정: (?P[\d.]+) USDT" + r".*\[(?P\w+)\] 기준 잔고 설정: (?P[\d.]+) USDT" ), - # ML 필터 로드 "ml_filter": re.compile( r"(?P\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" r".*ML 필터 로드.*임계값=(?P[\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__": diff --git a/dashboard/ui/src/App.jsx b/dashboard/ui/src/App.jsx index 7cf7faa..a863d18 100644 --- a/dashboard/ui/src/App.jsx +++ b/dashboard/ui/src/App.jsx @@ -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 && ( {fmt(currentPrice)} @@ -384,35 +396,66 @@ export default function App() { - {/* 오픈 포지션 */} - {position && ( -
-
OPEN POSITION
-
- - {position.direction} {position.leverage || 10}x - - - {fmt(position.entry_price)} - - - SL {fmt(position.sl)} · TP {fmt(position.tp)} - -
+ {/* 오픈 포지션 — 복수 표시 */} + {positions.length > 0 && ( +
+ {positions.map((pos) => ( +
+
+ {(pos.symbol || "").replace("USDT", "/USDT")} +
+
+ + {pos.direction} {pos.leverage || 10}x + + + {fmt(pos.entry_price)} + +
+
+ ))}
)}
+ {/* ═══ 심볼 필터 ═══════════════════════════════════════ */} +
+ + {symbols.map((sym) => ( + + ))} +
+ {/* ═══ 탭 ═════════════════════════════════════════════ */}
- + ({ ts: fmtTime(c.ts), price: c.price || c.close }))}> diff --git a/src/bot.py b/src/bot.py index 12a01d8..98a5290 100644 --- a/src/bot.py +++ b/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, diff --git a/src/user_data_stream.py b/src/user_data_stream.py index e5bbe65..f07ed21 100644 --- a/src/user_data_stream.py +++ b/src/user_data_stream.py @@ -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}" ) diff --git a/tests/test_dashboard_api.py b/tests/test_dashboard_api.py new file mode 100644 index 0000000..2ecc09a --- /dev/null +++ b/tests/test_dashboard_api.py @@ -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 diff --git a/tests/test_log_parser.py b/tests/test_log_parser.py new file mode 100644 index 0000000..e0010f3 --- /dev/null +++ b/tests/test_log_parser.py @@ -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