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:
21in7
2026-03-06 16:19:16 +09:00
parent 2b3f39b5d1
commit 15fb9c158a
8 changed files with 575 additions and 208 deletions

View File

@@ -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-03 | `demo-1m-125x` (design + plan) | In Progress |
| 2026-03-04 | `oi-derived-features` (design + plan) | Completed | | 2026-03-04 | `oi-derived-features` (design + plan) | Completed |
| 2026-03-05 | `multi-symbol-trading` (design + plan) | Completed | | 2026-03-05 | `multi-symbol-trading` (design + plan) | Completed |
| 2026-03-06 | `multi-symbol-dashboard` (design + plan) | Completed |

View File

@@ -1,5 +1,5 @@
""" """
dashboard_api.py — 로그 파서가 채운 SQLite DB를 읽어서 대시보드 API 제공 dashboard_api.py — 멀티심볼 대시보드 API
""" """
import sqlite3 import sqlite3
@@ -9,6 +9,7 @@ from fastapi import FastAPI, Query
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pathlib import Path from pathlib import Path
from contextlib import contextmanager from contextlib import contextmanager
from typing import Optional
DB_PATH = os.environ.get("DB_PATH", "/app/data/dashboard.db") DB_PATH = os.environ.get("DB_PATH", "/app/data/dashboard.db")
@@ -30,19 +31,52 @@ def get_db():
finally: finally:
conn.close() conn.close()
@app.get("/api/position")
def get_position(): @app.get("/api/symbols")
def get_symbols():
"""활성 심볼 목록 반환."""
with get_db() as db: with get_db() as db:
row = db.execute( rows = db.execute(
"SELECT * FROM trades WHERE status='OPEN' ORDER BY id DESC LIMIT 1" "SELECT key FROM bot_status WHERE key LIKE '%:last_start'"
).fetchone() ).fetchall()
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() status_rows = db.execute("SELECT key, value FROM bot_status").fetchall()
bot = {r["key"]: r["value"] for r in status_rows} bot = {r["key"]: r["value"] for r in status_rows}
return {"position": dict(row) if row else None, "bot": bot} return {"positions": [dict(r) for r in rows], "bot": bot}
@app.get("/api/trades") @app.get("/api/trades")
def get_trades(limit: int = Query(50, ge=1, le=500), offset: int = 0): def get_trades(
symbol: Optional[str] = None,
limit: int = Query(50, ge=1, le=500),
offset: int = 0,
):
with get_db() as db: 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( rows = db.execute(
"SELECT * FROM trades WHERE status='CLOSED' ORDER BY id DESC LIMIT ? OFFSET ?", "SELECT * FROM trades WHERE status='CLOSED' ORDER BY id DESC LIMIT ? OFFSET ?",
(limit, offset), (limit, offset),
@@ -50,28 +84,51 @@ def get_trades(limit: int = Query(50, ge=1, le=500), offset: int = 0):
total = db.execute("SELECT COUNT(*) as cnt FROM trades WHERE status='CLOSED'").fetchone()["cnt"] total = db.execute("SELECT COUNT(*) as cnt FROM trades WHERE status='CLOSED'").fetchone()["cnt"]
return {"trades": [dict(r) for r in rows], "total": total} return {"trades": [dict(r) for r in rows], "total": total}
@app.get("/api/daily") @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: with get_db() as db:
if symbol:
rows = db.execute(""" rows = db.execute("""
SELECT SELECT date,
date(exit_time) as date, SUM(trade_count) as total_trades,
COUNT(*) as total_trades, SUM(wins) as wins,
SUM(CASE WHEN net_pnl > 0 THEN 1 ELSE 0 END) as wins, SUM(losses) as losses,
SUM(CASE WHEN net_pnl <= 0 THEN 1 ELSE 0 END) as losses, ROUND(SUM(cumulative_pnl), 4) as net_pnl
ROUND(SUM(net_pnl), 4) as net_pnl, FROM daily_pnl
ROUND(SUM(commission), 4) as total_fees WHERE symbol=?
FROM trades GROUP BY date ORDER BY date DESC LIMIT ?
WHERE status='CLOSED' AND exit_time IS NOT NULL """, (symbol, days)).fetchall()
GROUP BY date(exit_time) else:
ORDER BY date DESC rows = db.execute("""
LIMIT ? 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() """, (days,)).fetchall()
return {"daily": [dict(r) for r in rows]} return {"daily": [dict(r) for r in rows]}
@app.get("/api/stats") @app.get("/api/stats")
def get_stats(): def get_stats(symbol: Optional[str] = None):
with get_db() as db: with get_db() as db:
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(""" row = db.execute("""
SELECT SELECT
COUNT(*) as total_trades, COUNT(*) as total_trades,
@@ -87,16 +144,22 @@ def get_stats():
status_rows = db.execute("SELECT key, value FROM bot_status").fetchall() status_rows = db.execute("SELECT key, value FROM bot_status").fetchall()
bot = {r["key"]: r["value"] for r in status_rows} bot = {r["key"]: r["value"] for r in status_rows}
result = dict(row) 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") result["balance"] = bot.get("balance")
return result return result
@app.get("/api/candles") @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: 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)]} return {"candles": [dict(r) for r in reversed(rows)]}
@app.get("/api/health") @app.get("/api/health")
def health(): def health():
try: try:
@@ -109,15 +172,12 @@ def health():
@app.post("/api/reset") @app.post("/api/reset")
def reset_db(): def reset_db():
"""DB 전체 초기화 후 파서 재시작 (로그를 처음부터 다시 파싱)"""
with get_db() as db: with get_db() as db:
for table in ["trades", "daily_pnl", "parse_state", "bot_status", "candles"]: for table in ["trades", "daily_pnl", "parse_state", "bot_status", "candles"]:
db.execute(f"DELETE FROM {table}") db.execute(f"DELETE FROM {table}")
db.commit() db.commit()
# 파서 프로세스 재시작 (entrypoint.sh의 백그라운드 프로세스) import subprocess, signal
import subprocess, os, signal
# 기존 파서 종료 (pkill 대신 Python-native 방식)
for pid_str in os.listdir("/proc") if os.path.isdir("/proc") else []: for pid_str in os.listdir("/proc") if os.path.isdir("/proc") else []:
if not pid_str.isdigit(): if not pid_str.isdigit():
continue continue
@@ -128,9 +188,6 @@ def reset_db():
os.kill(int(pid_str), signal.SIGTERM) os.kill(int(pid_str), signal.SIGTERM)
except (FileNotFoundError, PermissionError, ProcessLookupError, OSError): except (FileNotFoundError, PermissionError, ProcessLookupError, OSError):
pass pass
# 새 파서 시작
subprocess.Popen(["python", "log_parser.py"]) subprocess.Popen(["python", "log_parser.py"])
return {"status": "ok", "message": "DB 초기화 완료, 파서 재시작됨"} return {"status": "ok", "message": "DB 초기화 완료, 파서 재시작됨"}

View File

@@ -20,36 +20,31 @@ LOG_DIR = os.environ.get("LOG_DIR", "/app/logs")
DB_PATH = os.environ.get("DB_PATH", "/app/data/dashboard.db") DB_PATH = os.environ.get("DB_PATH", "/app/data/dashboard.db")
POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "5")) # 초 POLL_INTERVAL = int(os.environ.get("POLL_INTERVAL", "5")) # 초
# ── 정규식 패턴 (실제 봇 로그 형식 기준) ────────────────────────── # ── 정규식 패턴 (멀티심볼 [SYMBOL] 프리픽스 포함) ─────────────────
PATTERNS = { PATTERNS = {
# 신호: HOLD | 현재가: 1.3889 USDT
"signal": re.compile( "signal": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" 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( "adx": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" 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( "microstructure": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" 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( "position_recover": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" 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( "entry": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" 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<entry_price>[\d.]+), "
r"수량=(?P<qty>[\d.]+), " r"수량=(?P<qty>[\d.]+), "
r"SL=(?P<sl>[\d.]+), " r"SL=(?P<sl>[\d.]+), "
@@ -59,35 +54,30 @@ PATTERNS = {
r"(?:, ATR=(?P<atr>[\d.]+))?" r"(?:, ATR=(?P<atr>[\d.]+))?"
), ),
# 청산 감지(MANUAL): exit=1.3782, rp=+2.9859, commission=0.0598, net_pnl=+2.9261
"close_detect": re.compile( "close_detect": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" 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"exit=(?P<exit_price>[\d.]+),\s*"
r"rp=(?P<expected>[+\-\d.]+),\s*" r"rp=(?P<expected>[+\-\d.]+),\s*"
r"commission=(?P<commission>[\d.]+),\s*" r"commission=(?P<commission>[\d.]+),\s*"
r"net_pnl=(?P<net_pnl>[+\-\d.]+)" r"net_pnl=(?P<net_pnl>[+\-\d.]+)"
), ),
# 오늘 누적 PnL: 2.9261 USDT
"daily_pnl": re.compile( "daily_pnl": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" 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( "bot_start": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" 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( "balance": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" 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( "ml_filter": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})" r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
r".*ML 필터 로드.*임계값=(?P<threshold>[\d.]+)" r".*ML 필터 로드.*임계값=(?P<threshold>[\d.]+)"
@@ -103,18 +93,22 @@ class LogParser:
self.conn.execute("PRAGMA journal_mode=WAL") self.conn.execute("PRAGMA journal_mode=WAL")
self._init_db() self._init_db()
# 상태 추적 self._file_positions = {}
self._file_positions = {} # {파일경로: 마지막 읽은 위치} self._current_positions = {} # {symbol: position_dict}
self._current_position = None # 현재 열린 포지션 정보 self._pending_candles = {} # {symbol: {ts_key: {data}}}
self._pending_candle = {} # 타임스탬프 기준으로 지표를 모아두기
self._bot_config = {"symbol": "XRPUSDT", "leverage": 10}
self._balance = 0 self._balance = 0
def _init_db(self): def _init_db(self):
self.conn.executescript(""" 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, id INTEGER PRIMARY KEY AUTOINCREMENT,
symbol TEXT NOT NULL DEFAULT 'XRPUSDT', symbol TEXT NOT NULL,
direction TEXT NOT NULL, direction TEXT NOT NULL,
entry_time TEXT NOT NULL, entry_time TEXT NOT NULL,
exit_time TEXT, exit_time TEXT,
@@ -137,54 +131,60 @@ class LogParser:
extra TEXT extra TEXT
); );
CREATE TABLE IF NOT EXISTS candles ( CREATE TABLE candles (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL UNIQUE, symbol TEXT NOT NULL,
ts TEXT NOT NULL,
price REAL NOT NULL, price REAL NOT NULL,
signal TEXT, signal TEXT,
adx REAL, adx REAL,
oi REAL, oi REAL,
oi_change REAL, oi_change REAL,
funding_rate REAL funding_rate REAL,
UNIQUE(symbol, ts)
); );
CREATE TABLE IF NOT EXISTS daily_pnl ( CREATE TABLE daily_pnl (
date TEXT PRIMARY KEY, symbol TEXT NOT NULL,
date TEXT NOT NULL,
cumulative_pnl REAL DEFAULT 0, cumulative_pnl REAL DEFAULT 0,
trade_count INTEGER DEFAULT 0, trade_count INTEGER DEFAULT 0,
wins INTEGER DEFAULT 0, wins INTEGER DEFAULT 0,
losses 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, key TEXT PRIMARY KEY,
value TEXT, value TEXT,
updated_at TEXT updated_at TEXT
); );
CREATE TABLE IF NOT EXISTS parse_state ( CREATE TABLE parse_state (
filepath TEXT PRIMARY KEY, filepath TEXT PRIMARY KEY,
position INTEGER DEFAULT 0 position INTEGER DEFAULT 0
); );
CREATE INDEX IF NOT EXISTS idx_candles_ts ON candles(ts); CREATE INDEX idx_candles_symbol_ts ON candles(symbol, ts);
CREATE INDEX IF NOT EXISTS idx_trades_status ON trades(status); CREATE INDEX idx_trades_status ON trades(status);
CREATE INDEX idx_trades_symbol ON trades(symbol);
""") """)
self.conn.commit() self.conn.commit()
self._load_state() self._load_state()
def _load_state(self): def _load_state(self):
"""이전 파싱 위치 복원"""
rows = self.conn.execute("SELECT filepath, position FROM parse_state").fetchall() rows = self.conn.execute("SELECT filepath, position FROM parse_state").fetchall()
self._file_positions = {r["filepath"]: r["position"] for r in rows} self._file_positions = {r["filepath"]: r["position"] for r in rows}
# 현재 열린 포지션 복원 # 심볼별 열린 포지션 복원
row = self.conn.execute( open_trades = self.conn.execute(
"SELECT * FROM trades WHERE status='OPEN' ORDER BY id DESC LIMIT 1" "SELECT * FROM trades WHERE status='OPEN' ORDER BY id DESC"
).fetchone() ).fetchall()
if row: for row in open_trades:
self._current_position = dict(row) sym = row["symbol"]
if sym not in self._current_positions:
self._current_positions[sym] = dict(row)
def _save_position(self, filepath, pos): def _save_position(self, filepath, pos):
self.conn.execute( self.conn.execute(
@@ -214,8 +214,6 @@ class LogParser:
time.sleep(POLL_INTERVAL) time.sleep(POLL_INTERVAL)
def _scan_logs(self): def _scan_logs(self):
"""로그 파일 목록을 가져와서 새 줄 파싱"""
# 날짜 형식 (bot_2026-03-01.log) + 현재 형식 (bot.log) 모두 스캔
log_files = sorted(glob.glob(os.path.join(LOG_DIR, "bot_*.log"))) log_files = sorted(glob.glob(os.path.join(LOG_DIR, "bot_*.log")))
main_log = os.path.join(LOG_DIR, "bot.log") main_log = os.path.join(LOG_DIR, "bot.log")
if os.path.exists(main_log): if os.path.exists(main_log):
@@ -231,12 +229,11 @@ class LogParser:
except OSError: except OSError:
return return
# 파일이 줄었으면 (로테이션) 처음부터
if file_size < last_pos: if file_size < last_pos:
last_pos = 0 last_pos = 0
if file_size == last_pos: if file_size == last_pos:
return # 새 내용 없음 return
with open(filepath, "r", encoding="utf-8", errors="ignore") as f: with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
f.seek(last_pos) f.seek(last_pos)
@@ -257,11 +254,9 @@ class LogParser:
# 봇 시작 # 봇 시작
m = PATTERNS["bot_start"].search(line) m = PATTERNS["bot_start"].search(line)
if m: if m:
self._bot_config["symbol"] = m.group("symbol") symbol = m.group("symbol")
self._bot_config["leverage"] = int(m.group("leverage")) self._set_status(f"{symbol}:leverage", m.group("leverage"))
self._set_status("symbol", m.group("symbol")) self._set_status(f"{symbol}:last_start", m.group("ts"))
self._set_status("leverage", m.group("leverage"))
self._set_status("last_start", m.group("ts"))
return return
# 잔고 # 잔고
@@ -282,6 +277,7 @@ class LogParser:
if m: if m:
self._handle_entry( self._handle_entry(
ts=m.group("ts"), ts=m.group("ts"),
symbol=m.group("symbol"),
direction=m.group("direction"), direction=m.group("direction"),
entry_price=float(m.group("entry_price")), entry_price=float(m.group("entry_price")),
qty=float(m.group("qty")), qty=float(m.group("qty")),
@@ -289,11 +285,12 @@ class LogParser:
) )
return return
# 포지션 진입: SHORT 진입: 가격=X, 수량=Y, SL=Z, TP=W, RSI=R, MACD_H=M, ATR=A # 포지션 진입
m = PATTERNS["entry"].search(line) m = PATTERNS["entry"].search(line)
if m: if m:
self._handle_entry( self._handle_entry(
ts=m.group("ts"), ts=m.group("ts"),
symbol=m.group("symbol"),
direction=m.group("direction"), direction=m.group("direction"),
entry_price=float(m.group("entry_price")), entry_price=float(m.group("entry_price")),
qty=float(m.group("qty")), qty=float(m.group("qty")),
@@ -308,10 +305,13 @@ class LogParser:
# OI/펀딩비 (캔들 데이터에 합침) # OI/펀딩비 (캔들 데이터에 합침)
m = PATTERNS["microstructure"].search(line) m = PATTERNS["microstructure"].search(line)
if m: if m:
ts_key = m.group("ts")[:16] # 분 단위로 그룹 symbol = m.group("symbol")
if ts_key not in self._pending_candle: ts_key = m.group("ts")[:16]
self._pending_candle[ts_key] = {} if symbol not in self._pending_candles:
self._pending_candle[ts_key].update({ 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": float(m.group("oi")),
"oi_change": float(m.group("oi_change")), "oi_change": float(m.group("oi_change")),
"funding": float(m.group("funding")), "funding": float(m.group("funding")),
@@ -321,32 +321,36 @@ class LogParser:
# ADX # ADX
m = PATTERNS["adx"].search(line) m = PATTERNS["adx"].search(line)
if m: if m:
symbol = m.group("symbol")
ts_key = m.group("ts")[:16] ts_key = m.group("ts")[:16]
if ts_key not in self._pending_candle: if symbol not in self._pending_candles:
self._pending_candle[ts_key] = {} self._pending_candles[symbol] = {}
self._pending_candle[ts_key]["adx"] = float(m.group("adx")) 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 return
# 신호 + 현재가 → 캔들 저장 # 신호 + 현재가 → 캔들 저장
m = PATTERNS["signal"].search(line) m = PATTERNS["signal"].search(line)
if m: if m:
symbol = m.group("symbol")
ts = m.group("ts") ts = m.group("ts")
ts_key = ts[:16] ts_key = ts[:16]
price = float(m.group("price")) price = float(m.group("price"))
signal = m.group("signal") 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(f"{symbol}:current_price", str(price))
self._set_status("current_signal", signal) self._set_status(f"{symbol}:current_signal", signal)
self._set_status("last_candle_time", ts) self._set_status(f"{symbol}:last_candle_time", ts)
try: try:
self.conn.execute( self.conn.execute(
"""INSERT INTO candles(ts, price, signal, adx, oi, oi_change, funding_rate) """INSERT INTO candles(symbol, ts, price, signal, adx, oi, oi_change, funding_rate)
VALUES(?,?,?,?,?,?,?) VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(ts) DO UPDATE SET ON CONFLICT(symbol, ts) DO UPDATE SET
price=?, signal=?, adx=?, oi=?, oi_change=?, funding_rate=?""", 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"), extra.get("adx"), extra.get("oi"), extra.get("oi_change"), extra.get("funding"),
price, signal, price, signal,
extra.get("adx"), extra.get("oi"), extra.get("oi_change"), extra.get("funding")), extra.get("adx"), extra.get("oi"), extra.get("oi_change"), extra.get("funding")),
@@ -361,6 +365,7 @@ class LogParser:
if m: if m:
self._handle_close( self._handle_close(
ts=m.group("ts"), ts=m.group("ts"),
symbol=m.group("symbol"),
exit_price=float(m.group("exit_price")), exit_price=float(m.group("exit_price")),
expected_pnl=float(m.group("expected")), expected_pnl=float(m.group("expected")),
commission=float(m.group("commission")), commission=float(m.group("commission")),
@@ -372,37 +377,38 @@ class LogParser:
# 일일 누적 PnL # 일일 누적 PnL
m = PATTERNS["daily_pnl"].search(line) m = PATTERNS["daily_pnl"].search(line)
if m: if m:
symbol = m.group("symbol")
ts = m.group("ts") ts = m.group("ts")
day = ts[:10] day = ts[:10]
pnl = float(m.group("pnl")) pnl = float(m.group("pnl"))
self.conn.execute( self.conn.execute(
"""INSERT INTO daily_pnl(date, cumulative_pnl, last_updated) """INSERT INTO daily_pnl(symbol, date, cumulative_pnl, last_updated)
VALUES(?,?,?) VALUES(?,?,?,?)
ON CONFLICT(date) DO UPDATE SET cumulative_pnl=?, last_updated=?""", ON CONFLICT(symbol, date) DO UPDATE SET cumulative_pnl=?, last_updated=?""",
(day, pnl, ts, pnl, ts) (symbol, day, pnl, ts, pnl, ts)
) )
self.conn.commit() self.conn.commit()
self._set_status("daily_pnl", str(pnl)) self._set_status(f"{symbol}:daily_pnl", str(pnl))
return 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, leverage=None, sl=None, tp=None, is_recovery=False,
rsi=None, macd_hist=None, atr=None): rsi=None, macd_hist=None, atr=None):
if leverage is None: if leverage is None:
leverage = self._bot_config.get("leverage", 10) leverage = 10
# 중복 체크 — 같은 방향의 OPEN 포지션이 이미 있으면 스킵 # 중복 체크 — 같은 심볼+방향의 OPEN 포지션이 이미 있으면 스킵
# (봇은 동시에 같은 방향 포지션을 2개 이상 열지 않음) current = self._current_positions.get(symbol)
if self._current_position and self._current_position.get("direction") == direction: if current and current.get("direction") == direction:
return return
existing = self.conn.execute( existing = self.conn.execute(
"SELECT id, entry_price FROM trades WHERE status='OPEN' AND direction=?", "SELECT id, entry_price FROM trades WHERE status='OPEN' AND symbol=? AND direction=?",
(direction,), (symbol, direction),
).fetchone() ).fetchone()
if existing: if existing:
self._current_position = { self._current_positions[symbol] = {
"id": existing["id"], "id": existing["id"],
"direction": direction, "direction": direction,
"entry_price": existing["entry_price"], "entry_price": existing["entry_price"],
@@ -414,35 +420,35 @@ class LogParser:
"""INSERT INTO trades(symbol, direction, entry_time, entry_price, """INSERT INTO trades(symbol, direction, entry_time, entry_price,
quantity, leverage, sl, tp, status, extra, rsi, macd_hist, atr) quantity, leverage, sl, tp, status, extra, rsi, macd_hist, atr)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(self._bot_config.get("symbol", "XRPUSDT"), direction, ts, (symbol, direction, ts,
entry_price, qty, leverage, sl, tp, "OPEN", entry_price, qty, leverage, sl, tp, "OPEN",
json.dumps({"recovery": is_recovery}), json.dumps({"recovery": is_recovery}),
rsi, macd_hist, atr), rsi, macd_hist, atr),
) )
self.conn.commit() self.conn.commit()
self._current_position = { self._current_positions[symbol] = {
"id": cur.lastrowid, "id": cur.lastrowid,
"direction": direction, "direction": direction,
"entry_price": entry_price, "entry_price": entry_price,
"entry_time": ts, "entry_time": ts,
} }
self._set_status("position_status", "OPEN") self._set_status(f"{symbol}:position_status", "OPEN")
self._set_status("position_direction", direction) self._set_status(f"{symbol}:position_direction", direction)
self._set_status("position_entry_price", str(entry_price)) self._set_status(f"{symbol}:position_entry_price", str(entry_price))
print(f"[LogParser] 포지션 진입: {direction} @ {entry_price} (recovery={is_recovery})") print(f"[LogParser] {symbol} 포지션 진입: {direction} @ {entry_price} (recovery={is_recovery})")
# ── 포지션 청산 핸들러 ─────────────────────────────────────── # ── 포지션 청산 핸들러 ───────────────────────────────────────
def _handle_close(self, ts, exit_price, expected_pnl, commission, net_pnl, reason): def _handle_close(self, ts, symbol, exit_price, expected_pnl, commission, net_pnl, reason):
# 모든 OPEN 거래 닫음 (봇은 동시에 1개 포지션만 보유) # 해당 심볼의 OPEN 거래 닫음
open_trades = self.conn.execute( 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() ).fetchall()
if not open_trades: if not open_trades:
print(f"[LogParser] 경고: 청산 감지했으나 열린 포지션 없음") print(f"[LogParser] 경고: {symbol} 청산 감지했으나 열린 포지션 없음")
return return
# 가장 최근 OPEN에 실제 PnL 기록
primary_id = open_trades[0]["id"] primary_id = open_trades[0]["id"]
self.conn.execute( self.conn.execute(
"""UPDATE trades SET """UPDATE trades SET
@@ -455,34 +461,33 @@ class LogParser:
reason, primary_id) reason, primary_id)
) )
# 나머지 OPEN 거래는 중복이므로 삭제
if len(open_trades) > 1: if len(open_trades) > 1:
stale_ids = [r["id"] for r in open_trades[1:]] stale_ids = [r["id"] for r in open_trades[1:]]
self.conn.execute( self.conn.execute(
f"DELETE FROM trades WHERE id IN ({','.join('?' * len(stale_ids))})", f"DELETE FROM trades WHERE id IN ({','.join('?' * len(stale_ids))})",
stale_ids, stale_ids,
) )
print(f"[LogParser] 중복 OPEN 거래 {len(stale_ids)}건 삭제") print(f"[LogParser] {symbol} 중복 OPEN 거래 {len(stale_ids)}건 삭제")
# 일별 요약 갱신 # 심볼별 일별 요약
day = ts[:10] day = ts[:10]
win = 1 if net_pnl > 0 else 0 win = 1 if net_pnl > 0 else 0
loss = 1 if net_pnl <= 0 else 0 loss = 1 if net_pnl <= 0 else 0
self.conn.execute( self.conn.execute(
"""INSERT INTO daily_pnl(date, cumulative_pnl, trade_count, wins, losses, last_updated) """INSERT INTO daily_pnl(symbol, date, cumulative_pnl, trade_count, wins, losses, last_updated)
VALUES(?, ?, 1, ?, ?, ?) VALUES(?, ?, ?, 1, ?, ?, ?)
ON CONFLICT(date) DO UPDATE SET ON CONFLICT(symbol, date) DO UPDATE SET
trade_count = trade_count + 1, trade_count = trade_count + 1,
wins = wins + ?, wins = wins + ?,
losses = losses + ?, losses = losses + ?,
last_updated = ?""", 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.conn.commit()
self._set_status("position_status", "NONE") self._set_status(f"{symbol}:position_status", "NONE")
print(f"[LogParser] 포지션 청산: {reason} @ {exit_price}, PnL={net_pnl}") print(f"[LogParser] {symbol} 포지션 청산: {reason} @ {exit_price}, PnL={net_pnl}")
self._current_position = None self._current_positions.pop(symbol, None)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -266,12 +266,15 @@ export default function App() {
const [isLive, setIsLive] = useState(false); const [isLive, setIsLive] = useState(false);
const [lastUpdate, setLastUpdate] = useState(null); const [lastUpdate, setLastUpdate] = useState(null);
const [symbols, setSymbols] = useState([]);
const [selectedSymbol, setSelectedSymbol] = useState(null); // null = ALL
const [stats, setStats] = useState({ const [stats, setStats] = useState({
total_trades: 0, wins: 0, losses: 0, total_trades: 0, wins: 0, losses: 0,
total_pnl: 0, total_fees: 0, avg_pnl: 0, total_pnl: 0, total_fees: 0, avg_pnl: 0,
best_trade: 0, worst_trade: 0, best_trade: 0, worst_trade: 0,
}); });
const [position, setPosition] = useState(null); const [positions, setPositions] = useState([]);
const [botStatus, setBotStatus] = useState({}); const [botStatus, setBotStatus] = useState({});
const [trades, setTrades] = useState([]); const [trades, setTrades] = useState([]);
const [daily, setDaily] = useState([]); const [daily, setDaily] = useState([]);
@@ -279,27 +282,32 @@ export default function App() {
/* ── 데이터 폴링 ─────────────────────────────────────────── */ /* ── 데이터 폴링 ─────────────────────────────────────────── */
const fetchAll = useCallback(async () => { const fetchAll = useCallback(async () => {
const [sRes, pRes, tRes, dRes, cRes] = await Promise.all([ const sym = selectedSymbol ? `?symbol=${selectedSymbol}` : "";
api("/stats"), const symRequired = selectedSymbol || symbols[0] || "XRPUSDT";
api("/position"),
api("/trades?limit=50"), const [symRes, sRes, pRes, tRes, dRes, cRes] = await Promise.all([
api("/daily?days=30"), api("/symbols"),
api("/candles?limit=96"), 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) { if (sRes && sRes.total_trades !== undefined) {
setStats(sRes); setStats(sRes);
setIsLive(true); setIsLive(true);
setLastUpdate(new Date()); setLastUpdate(new Date());
} }
if (pRes) { if (pRes) {
setPosition(pRes.position); setPositions(pRes.positions || []);
if (pRes.bot) setBotStatus(pRes.bot); if (pRes.bot) setBotStatus(pRes.bot);
} }
if (tRes?.trades) setTrades(tRes.trades); if (tRes?.trades) setTrades(tRes.trades);
if (dRes?.daily) setDaily(dRes.daily); if (dRes?.daily) setDaily(dRes.daily);
if (cRes?.candles) setCandles(cRes.candles); if (cRes?.candles) setCandles(cRes.candles);
}, []); }, [selectedSymbol, symbols]);
useEffect(() => { useEffect(() => {
fetchAll(); fetchAll();
@@ -328,8 +336,9 @@ export default function App() {
const candleLabels = candles.map((c) => fmtTime(c.ts)); const candleLabels = candles.map((c) => fmtTime(c.ts));
/* ── 현재 가격 (봇 상태 또는 마지막 캔들) ──────────────────── */ /* ── 현재 가격 (봇 상태 또는 마지막 캔들) ──────────────────── */
const currentPrice = botStatus.current_price const currentPrice = selectedSymbol
|| (candles.length ? candles[candles.length - 1].price : null); ? (botStatus[`${selectedSymbol}:current_price`] || (candles.length ? candles[candles.length - 1].price : null))
: (candles.length ? candles[candles.length - 1].price : null);
/* ── 공통 차트 축 스타일 ─────────────────────────────────── */ /* ── 공통 차트 축 스타일 ─────────────────────────────────── */
const axisStyle = { const axisStyle = {
@@ -371,7 +380,10 @@ export default function App() {
fontSize: 10, color: S.text3, letterSpacing: 2, fontSize: 10, color: S.text3, letterSpacing: 2,
textTransform: "uppercase", fontFamily: S.mono, textTransform: "uppercase", fontFamily: S.mono,
}}> }}>
{isLive ? "Live" : "Connecting…"} · XRP/USDT {isLive ? "Live" : "Connecting…"}
{selectedSymbol
? ` · ${selectedSymbol.replace("USDT", "/USDT")}`
: ` · ${symbols.length} symbols`}
{currentPrice && ( {currentPrice && (
<span style={{ color: "rgba(255,255,255,0.5)", marginLeft: 8 }}> <span style={{ color: "rgba(255,255,255,0.5)", marginLeft: 8 }}>
{fmt(currentPrice)} {fmt(currentPrice)}
@@ -384,35 +396,66 @@ export default function App() {
</h1> </h1>
</div> </div>
{/* 오픈 포지션 */} {/* 오픈 포지션 — 복수 표시 */}
{position && ( {positions.length > 0 && (
<div style={{ <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%)", 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, border: "1px solid rgba(99,102,241,0.15)", borderRadius: 14,
padding: "12px 18px", padding: "12px 18px",
}}> }}>
<div style={{ <div style={{ fontSize: 9, color: S.text3, letterSpacing: 1.2, fontFamily: S.mono, marginBottom: 4 }}>
fontSize: 9, color: S.text3, letterSpacing: 1.2, {(pos.symbol || "").replace("USDT", "/USDT")}
fontFamily: S.mono, marginBottom: 4, </div>
}}>OPEN POSITION</div> <div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<div style={{ display: "flex", gap: 16, alignItems: "center", flexWrap: "wrap" }}>
<Badge <Badge
bg={position.direction === "SHORT" ? "rgba(239,68,68,0.12)" : "rgba(52,211,153,0.12)"} bg={pos.direction === "SHORT" ? "rgba(239,68,68,0.12)" : "rgba(52,211,153,0.12)"}
color={position.direction === "SHORT" ? S.red : S.green} color={pos.direction === "SHORT" ? S.red : S.green}
> >
{position.direction} {position.leverage || 10}x {pos.direction} {pos.leverage || 10}x
</Badge> </Badge>
<span style={{ fontSize: 16, fontWeight: 700, fontFamily: S.mono }}> <span style={{ fontSize: 14, fontWeight: 700, fontFamily: S.mono }}>
{fmt(position.entry_price)} {fmt(pos.entry_price)}
</span>
<span style={{ fontSize: 10, color: S.text3, fontFamily: S.mono }}>
SL {fmt(position.sl)} · TP {fmt(position.tp)}
</span> </span>
</div> </div>
</div> </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={{ <div style={{
display: "flex", gap: 4, marginBottom: 24, display: "flex", gap: 4, marginBottom: 24,
@@ -556,7 +599,7 @@ export default function App() {
{/* ═══ CHART ══════════════════════════════════════════ */} {/* ═══ CHART ══════════════════════════════════════════ */}
{tab === "chart" && ( {tab === "chart" && (
<div> <div>
<ChartBox title="XRP/USDT 15m 가격"> <ChartBox title={`${(selectedSymbol || symbols[0] || "XRP").replace("USDT", "")}/USDT 15m 가격`}>
<ResponsiveContainer width="100%" height={240}> <ResponsiveContainer width="100%" height={240}>
<AreaChart data={candles.map((c) => ({ ts: fmtTime(c.ts), price: c.price || c.close }))}> <AreaChart data={candles.map((c) => ({ ts: fmtTime(c.ts), price: c.price || c.close }))}>
<defs> <defs>

View File

@@ -64,7 +64,7 @@ class TradingBot:
self._entry_quantity = abs(amt) self._entry_quantity = abs(amt)
entry = float(position["entryPrice"]) entry = float(position["entryPrice"])
logger.info( logger.info(
f"기존 포지션 복구: {self.current_trade_side} | " f"[{self.symbol}] 기존 포지션 복구: {self.current_trade_side} | "
f"진입가={entry:.4f} | 수량={abs(amt)}" f"진입가={entry:.4f} | 수량={abs(amt)}"
) )
self.notifier.notify_info( self.notifier.notify_info(
@@ -72,7 +72,7 @@ class TradingBot:
f"진입가={entry:.4f} 수량={abs(amt)}" f"진입가={entry:.4f} 수량={abs(amt)}"
) )
else: else:
logger.info("기존 포지션 없음 - 신규 진입 대기") logger.info(f"[{self.symbol}] 기존 포지션 없음 - 신규 진입 대기")
async def _init_oi_history(self) -> None: async def _init_oi_history(self) -> None:
"""봇 시작 시 최근 OI 변화율 히스토리를 조회하여 deque를 채운다.""" """봇 시작 시 최근 OI 변화율 히스토리를 조회하여 deque를 채운다."""
@@ -82,7 +82,7 @@ class TradingBot:
self._oi_history.append(c) self._oi_history.append(c)
if changes: if changes:
self._prev_oi = None 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: except Exception as e:
logger.warning(f"OI 히스토리 초기화 실패 (무시): {e}") logger.warning(f"OI 히스토리 초기화 실패 (무시): {e}")
@@ -107,7 +107,7 @@ class TradingBot:
oi_price_spread = oi_change - self._latest_ret_1 oi_price_spread = oi_change - self._latest_ret_1
logger.debug( 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}" f"OI_MA5={oi_ma5:.6f}, OI_Price_Spread={oi_price_spread:.6f}"
) )
return oi_change, fr_float, oi_ma5, oi_price_spread 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() oi_change, funding_rate, oi_ma5, oi_price_spread = await self._fetch_market_microstructure()
if not self.risk.is_trading_allowed(): if not self.risk.is_trading_allowed():
logger.warning("리스크 한도 초과 - 거래 중단") logger.warning(f"[{self.symbol}] 리스크 한도 초과 - 거래 중단")
return return
ind = Indicators(df) ind = Indicators(df)
@@ -142,7 +142,7 @@ class TradingBot:
raw_signal = ind.get_signal(df_with_indicators) raw_signal = ind.get_signal(df_with_indicators)
current_price = df_with_indicators["close"].iloc[-1] 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() position = await self.exchange.get_position()
@@ -160,7 +160,7 @@ class TradingBot:
) )
if self.ml_filter.is_model_loaded(): if self.ml_filter.is_model_loaded():
if not self.ml_filter.should_enter(features): if not self.ml_filter.should_enter(features):
logger.info(f"ML 필터 차단: {signal} 신호 무시") logger.info(f"[{self.symbol}] ML 필터 차단: {signal} 신호 무시")
return return
await self._open_position(signal, df_with_indicators) await self._open_position(signal, df_with_indicators)
@@ -221,7 +221,7 @@ class TradingBot:
signal_data=signal_snapshot, signal_data=signal_snapshot,
) )
logger.success( logger.success(
f"{signal} 진입: 가격={price}, 수량={quantity}, " f"[{self.symbol}] {signal} 진입: 가격={price}, 수량={quantity}, "
f"SL={stop_loss:.4f}, TP={take_profit:.4f}, " f"SL={stop_loss:.4f}, TP={take_profit:.4f}, "
f"RSI={signal_snapshot['rsi']:.2f}, " f"RSI={signal_snapshot['rsi']:.2f}, "
f"MACD_H={signal_snapshot['macd_hist']:.6f}, " f"MACD_H={signal_snapshot['macd_hist']:.6f}, "
@@ -275,7 +275,7 @@ class TradingBot:
) )
logger.success( logger.success(
f"포지션 청산({close_reason}): 예상={estimated_pnl:+.4f}, " f"[{self.symbol}] 포지션 청산({close_reason}): 예상={estimated_pnl:+.4f}, "
f"순수익={net_pnl:+.4f}, 차이={diff:+.4f} USDT" f"순수익={net_pnl:+.4f}, 차이={diff:+.4f} USDT"
) )
@@ -303,7 +303,7 @@ class TradingBot:
cost = self._entry_price * self._entry_quantity cost = self._entry_price * self._entry_quantity
pnl_pct = (pnl / cost * 100) if cost > 0 else 0.0 pnl_pct = (pnl / cost * 100) if cost > 0 else 0.0
logger.info( 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"현재가={price:.4f} | PnL={pnl:+.4f} USDT ({pnl_pct:+.2f}%) | "
f"진입가={self._entry_price:.4f}" f"진입가={self._entry_price:.4f}"
) )
@@ -314,7 +314,7 @@ class TradingBot:
side = "SELL" if float(position["positionAmt"]) > 0 else "BUY" side = "SELL" if float(position["positionAmt"]) > 0 else "BUY"
await self.exchange.cancel_all_orders() await self.exchange.cancel_all_orders()
await self.exchange.place_order(side=side, quantity=amt, reduce_only=True) 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( async def _close_and_reenter(
self, self,
@@ -346,7 +346,7 @@ class TradingBot:
oi_change_ma5=oi_change_ma5, oi_price_spread=oi_price_spread, oi_change_ma5=oi_change_ma5, oi_price_spread=oi_price_spread,
) )
if not self.ml_filter.should_enter(features): if not self.ml_filter.should_enter(features):
logger.info(f"ML 필터 차단: {signal} 재진입 무시") logger.info(f"[{self.symbol}] ML 필터 차단: {signal} 재진입 무시")
return return
await self._open_position(signal, df) await self._open_position(signal, df)
@@ -359,7 +359,7 @@ class TradingBot:
await self._init_oi_history() await self._init_oi_history()
balance = await self.exchange.get_balance() balance = await self.exchange.get_balance()
self.risk.set_base_balance(balance) self.risk.set_base_balance(balance)
logger.info(f"기준 잔고 설정: {balance:.2f} USDT (동적 증거금 비율 기준점)") logger.info(f"[{self.symbol}] 기준 잔고 설정: {balance:.2f} USDT (동적 증거금 비율 기준점)")
user_stream = UserDataStream( user_stream = UserDataStream(
symbol=self.symbol, symbol=self.symbol,

View File

@@ -102,7 +102,7 @@ class UserDataStream:
close_reason = "MANUAL" close_reason = "MANUAL"
logger.info( 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"rp={realized_pnl:+.4f}, commission={commission:.4f}, "
f"net_pnl={net_pnl:+.4f}" f"net_pnl={net_pnl:+.4f}"
) )

144
tests/test_dashboard_api.py Normal file
View 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
View 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