- Added support for multi-symbol trading (XRP, TRX, DOGE) in the dashboard. - Updated bot log messages to include [SYMBOL] prefix for better tracking. - Enhanced log parser for multi-symbol state tracking and updated database schema. - Introduced new API endpoints and UI components for symbol filtering and display. - Added new model files and backtest results for multi-symbol strategies. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
41 KiB
Multi-Symbol Dashboard Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: 대시보드(파서/API/UI)를 멀티심볼(XRP, TRX, DOGE) 동시 지원으로 업그레이드
Architecture: 봇 로그에 [SYMBOL] 프리픽스 일관 추가 → 파서가 심볼별 상태 추적 → DB에 symbol 컬럼 추가 → API에 symbol 쿼리 파라미터 → UI에 심볼 필터 탭
Tech Stack: Python (loguru, FastAPI, SQLite), React (recharts), 기존 스택 유지
Design Doc: docs/plans/2026-03-06-multi-symbol-dashboard-design.md
Task 1: 봇 로그에 [SYMBOL] 프리픽스 일관 추가
Files:
- Modify:
src/bot.py(로그 메시지에[{self.symbol}]추가) - Modify:
src/user_data_stream.py(청산 감지 로그에 심볼 추가) - Modify:
tests/test_bot.py(기존 테스트가 깨지지 않는지 확인)
Step 1: src/bot.py 로그 메시지 수정
아래 로그 라인들에 [{self.symbol}] 프리픽스 추가 (이미 있는 것은 그대로):
# line 67: 포지션 복구
logger.info(
f"[{self.symbol}] 기존 포지션 복구: {self.current_trade_side} | "
f"진입가={entry:.4f} | 수량={abs(amt)}"
)
# line 75: 포지션 없음
logger.info(f"[{self.symbol}] 기존 포지션 없음 - 신규 진입 대기")
# line 85: OI 히스토리
logger.info(f"[{self.symbol}] OI 히스토리 초기화: {len(self._oi_history)}개")
# line 109: OI/펀딩비 debug 로그
logger.debug(
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}"
)
# line 137: 리스크 한도
logger.warning(f"[{self.symbol}] 리스크 한도 초과 - 거래 중단")
# line 145: 신호
logger.info(f"[{self.symbol}] 신호: {raw_signal} | 현재가: {current_price:.4f} USDT")
# line 163: ML 필터 차단
logger.info(f"[{self.symbol}] ML 필터 차단: {signal} 신호 무시")
# line 223-228: 진입
logger.success(
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}, "
f"ATR={signal_snapshot['atr']:.6f}"
)
# line 277-279: 포지션 청산
logger.success(
f"[{self.symbol}] 포지션 청산({close_reason}): 예상={estimated_pnl:+.4f}, "
f"순수익={net_pnl:+.4f}, 차이={diff:+.4f} USDT"
)
# line 305-308: 포지션 모니터
logger.info(
f"[{self.symbol}] 포지션 모니터 | {self.current_trade_side} | "
f"현재가={price:.4f} | PnL={pnl:+.4f} USDT ({pnl_pct:+.2f}%) | "
f"진입가={self._entry_price:.4f}"
)
# line 317: 청산 주문
logger.info(f"[{self.symbol}] 청산 주문 전송 완료 (side={side}, qty={amt})")
# line 349: ML 필터 재진입 차단
logger.info(f"[{self.symbol}] ML 필터 차단: {signal} 재진입 무시")
# line 362: 기준 잔고
logger.info(f"[{self.symbol}] 기준 잔고 설정: {balance:.2f} USDT (동적 증거금 비율 기준점)")
Step 2: src/user_data_stream.py 로그 메시지 수정
# line 104-107: 청산 감지 로그에 심볼 추가
logger.info(
f"[{self._symbol}] 청산 감지({close_reason}): exit={exit_price:.4f}, "
f"rp={realized_pnl:+.4f}, commission={commission:.4f}, "
f"net_pnl={net_pnl:+.4f}"
)
Step 3: 기존 테스트 실행
Run: bash scripts/run_tests.sh -k "bot"
Expected: 모든 테스트 PASS (로그 메시지 변경은 테스트에 영향 없음)
Step 4: 커밋
git add src/bot.py src/user_data_stream.py
git commit -m "feat: add [SYMBOL] prefix to all bot log messages for multi-symbol dashboard"
Task 2: Log Parser 멀티심볼 대응
Files:
- Modify:
dashboard/api/log_parser.py(정규식, 상태 추적, 핸들러) - Create:
tests/test_log_parser.py(파서 단위 테스트)
Step 1: 파서 테스트 작성
# tests/test_log_parser.py
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
Step 2: 테스트 실행 — 실패 확인
Run: pytest tests/test_log_parser.py -v
Expected: FAIL (아직 파서 수정 전)
Step 3: log_parser.py 수정 — 정규식에 [SYMBOL] 프리픽스 추가
모든 정규식 패턴에 \[(?P<symbol>\w+)\] 추가:
PATTERNS = {
"signal": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
r".*\[(?P<symbol>\w+)\] 신호: (?P<signal>\w+) \| 현재가: (?P<price>[\d.]+) USDT"
),
"adx": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
r".*\[(?P<symbol>\w+)\] ADX: (?P<adx>[\d.]+)"
),
"microstructure": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
r".*\[(?P<symbol>\w+)\] OI=(?P<oi>[\d.]+), OI변화율=(?P<oi_change>[-\d.]+), 펀딩비=(?P<funding>[-\d.]+)"
),
"position_recover": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
r".*\[(?P<symbol>\w+)\] 기존 포지션 복구: (?P<direction>\w+) \| 진입가=(?P<entry_price>[\d.]+) \| 수량=(?P<qty>[\d.]+)"
),
"entry": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
r".*\[(?P<symbol>\w+)\] (?P<direction>SHORT|LONG) 진입: "
r"가격=(?P<entry_price>[\d.]+), "
r"수량=(?P<qty>[\d.]+), "
r"SL=(?P<sl>[\d.]+), "
r"TP=(?P<tp>[\d.]+)"
r"(?:, RSI=(?P<rsi>[\d.]+))?"
r"(?:, MACD_H=(?P<macd_hist>[+\-\d.]+))?"
r"(?:, ATR=(?P<atr>[\d.]+))?"
),
"close_detect": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
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.]+)"
),
"daily_pnl": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
r".*\[(?P<symbol>\w+)\] 오늘 누적 PnL: (?P<pnl>[+\-\d.]+) USDT"
),
"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"
),
"balance": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
r".*\[(?P<symbol>\w+)\] 기준 잔고 설정: (?P<balance>[\d.]+) USDT"
),
"ml_filter": re.compile(
r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})"
r".*ML 필터 로드.*임계값=(?P<threshold>[\d.]+)"
),
}
Step 4: DB 스키마 변경
_init_db() 메서드의 CREATE TABLE 문 수정:
def _init_db(self):
# 기존 테이블 삭제 후 재생성 (데이터는 로그 재파싱으로 복구)
self.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
);
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()
Step 5: 상태 추적 멀티심볼 대응
__init__ 수정:
def __init__(self):
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
self.conn = sqlite3.connect(DB_PATH)
self.conn.row_factory = sqlite3.Row
self.conn.execute("PRAGMA journal_mode=WAL")
self._init_db()
self._file_positions = {}
self._current_positions = {} # {symbol: position_dict}
self._pending_candles = {} # {symbol: {ts_key: {data}}}
self._balance = 0
_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}
# 심볼별 열린 포지션 복원
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)
Step 6: _parse_line 핸들러 수정
bot_start 핸들러 — 심볼별 bot_status:
m = PATTERNS["bot_start"].search(line)
if m:
symbol = m.group("symbol")
self._set_status(f"{symbol}:leverage", m.group("leverage"))
self._set_status(f"{symbol}:last_start", m.group("ts"))
return
balance 핸들러 — 전역 잔고 유지:
m = PATTERNS["balance"].search(line)
if m:
self._balance = float(m.group("balance"))
self._set_status("balance", m.group("balance"))
return
position_recover 핸들러:
m = PATTERNS["position_recover"].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")),
is_recovery=True,
)
return
entry 핸들러:
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")),
sl=float(m.group("sl")),
tp=float(m.group("tp")),
rsi=float(m.group("rsi")) if m.group("rsi") else None,
macd_hist=float(m.group("macd_hist")) if m.group("macd_hist") else None,
atr=float(m.group("atr")) if m.group("atr") else None,
)
return
microstructure 핸들러:
m = PATTERNS["microstructure"].search(line)
if m:
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")),
})
return
adx 핸들러:
m = PATTERNS["adx"].search(line)
if m:
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]["adx"] = float(m.group("adx"))
return
signal 핸들러:
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_candles.get(symbol, {}).pop(ts_key, {})
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(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=?""",
(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")),
)
self.conn.commit()
except Exception as e:
print(f"[LogParser] 캔들 저장 에러: {e}")
return
close_detect 핸들러:
m = PATTERNS["close_detect"].search(line)
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")),
net_pnl=float(m.group("net_pnl")),
reason=m.group("reason"),
)
return
daily_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(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(f"{symbol}:daily_pnl", str(pnl))
return
Step 7: _handle_entry 수정
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 = 10
# 중복 체크 — 같은 심볼+방향의 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 symbol=? AND direction=?",
(symbol, direction),
).fetchone()
if existing:
self._current_positions[symbol] = {
"id": existing["id"],
"direction": direction,
"entry_price": existing["entry_price"],
"entry_time": ts,
}
return
cur = self.conn.execute(
"""INSERT INTO trades(symbol, direction, entry_time, entry_price,
quantity, leverage, sl, tp, status, extra, rsi, macd_hist, atr)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(symbol, direction, ts,
entry_price, qty, leverage, sl, tp, "OPEN",
json.dumps({"recovery": is_recovery}),
rsi, macd_hist, atr),
)
self.conn.commit()
self._current_positions[symbol] = {
"id": cur.lastrowid,
"direction": direction,
"entry_price": entry_price,
"entry_time": ts,
}
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})")
Step 8: _handle_close 수정
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' AND symbol=? ORDER BY id DESC",
(symbol,),
).fetchall()
if not open_trades:
print(f"[LogParser] 경고: {symbol} 청산 감지했으나 열린 포지션 없음")
return
primary_id = open_trades[0]["id"]
self.conn.execute(
"""UPDATE trades SET
exit_time=?, exit_price=?, expected_pnl=?,
actual_pnl=?, commission=?, net_pnl=?,
status='CLOSED', close_reason=?
WHERE id=?""",
(ts, exit_price, expected_pnl,
expected_pnl, commission, net_pnl,
reason, primary_id)
)
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] {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(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 = ?""",
(symbol, day, net_pnl, win, loss, ts, win, loss, ts)
)
self.conn.commit()
self._set_status(f"{symbol}:position_status", "NONE")
print(f"[LogParser] {symbol} 포지션 청산: {reason} @ {exit_price}, PnL={net_pnl}")
self._current_positions.pop(symbol, None)
Step 9: 테스트 실행 — 통과 확인
Run: pytest tests/test_log_parser.py -v
Expected: 모든 테스트 PASS
Step 10: 커밋
git add dashboard/api/log_parser.py tests/test_log_parser.py
git commit -m "feat: update log parser for multi-symbol support"
Task 3: API 멀티심볼 대응
Files:
- Modify:
dashboard/api/dashboard_api.py - Create:
tests/test_dashboard_api.py
Step 1: API 테스트 작성
# tests/test_dashboard_api.py
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
os.unlink(db_path) if os.path.exists(db_path) else None
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
Step 2: 테스트 실행 — 실패 확인
Run: pytest tests/test_dashboard_api.py -v
Expected: FAIL
Step 3: dashboard_api.py 수정
"""
dashboard_api.py — 멀티심볼 대시보드 API
"""
import sqlite3
import os
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")
app = FastAPI(title="Trading Dashboard API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@contextmanager
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()
@app.get("/api/symbols")
def get_symbols():
"""활성 심볼 목록 반환."""
with get_db() as db:
rows = db.execute(
"SELECT key FROM bot_status WHERE key LIKE '%:last_start'"
).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()
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(symbol: Optional[str] = None, days: int = Query(30, ge=1, le=365)):
with get_db() as db:
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(symbol: Optional[str] = None):
with get_db() as db:
where = "WHERE status='CLOSED'" + (f" AND symbol='{symbol}'" if symbol else "")
row = db.execute(f"""
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}
""").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)
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(symbol: str = Query(...), limit: int = Query(96, ge=1, le=1000)):
with get_db() as db:
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:
with get_db() as db:
cnt = db.execute("SELECT COUNT(*) as c FROM candles").fetchone()["c"]
return {"status": "ok", "candles_count": cnt}
except Exception as e:
return {"status": "error", "detail": str(e)}
@app.post("/api/reset")
def reset_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()
import subprocess, signal
for pid_str in os.listdir("/proc") if os.path.isdir("/proc") else []:
if not pid_str.isdigit():
continue
try:
with open(f"/proc/{pid_str}/cmdline", "r") as f:
cmdline = f.read()
if "log_parser.py" in cmdline and str(os.getpid()) != pid_str:
os.kill(int(pid_str), signal.SIGTERM)
except (FileNotFoundError, PermissionError, ProcessLookupError, OSError):
pass
subprocess.Popen(["python", "log_parser.py"])
return {"status": "ok", "message": "DB 초기화 완료, 파서 재시작됨"}
주의:
/api/stats의symbol파라미터는 쿼리 파라미터이므로 SQL injection 위험이 있음. 실제 구현 시 파라미터 바인딩 사용. 위 코드에서는 f-string을 사용했지만, 구현 시 반드시?바인딩으로 교체할 것.
Step 4: 테스트 실행 — 통과 확인
Run: pytest tests/test_dashboard_api.py -v
Expected: 모든 테스트 PASS
Step 5: 커밋
git add dashboard/api/dashboard_api.py tests/test_dashboard_api.py
git commit -m "feat: add multi-symbol support to dashboard API"
Task 4: UI 멀티심볼 대응
Files:
- Modify:
dashboard/ui/src/App.jsx
Step 1: 상태 및 데이터 페칭에 심볼 지원 추가
주요 변경사항:
symbols상태 추가,/api/symbols에서 로드selectedSymbol상태 추가 (기본값null= ALL)fetchAll에서 선택된 심볼을 쿼리 파라미터로 전달position→positions(배열)로 변경
const [symbols, setSymbols] = useState([]);
const [selectedSymbol, setSelectedSymbol] = useState(null); // null = ALL
const [positions, setPositions] = useState([]);
fetchAll 수정:
const fetchAll = useCallback(async () => {
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}&limit=50`.replace("?&", "?")),
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) {
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]);
Step 2: 심볼 필터 탭 추가
기존 탭(Overview/Trades/Chart) 위에 심볼 필터 추가:
{/* 심볼 필터 */}
<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>
Step 3: 헤더 동적 변경
{/* "Live · XRP/USDT" → "Live · 3 symbols" 또는 "Live · XRP/USDT" */}
<span style={{ ... }}>
{isLive ? "Live" : "Connecting…"}
{selectedSymbol
? ` · ${selectedSymbol.replace("USDT", "/USDT")}`
: ` · ${symbols.length} symbols`}
{selectedSymbol && botStatus[`${selectedSymbol}:current_price`] && (
<span style={{ color: "rgba(255,255,255,0.5)", marginLeft: 8 }}>
{fmt(botStatus[`${selectedSymbol}:current_price`])}
</span>
)}
</span>
Step 4: 오픈 포지션 복수 표시
{/* 오픈 포지션 — 복수 표시 */}
{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>
)}
Step 5: Chart 탭 — ALL일 때 첫 번째 심볼 사용
{/* Chart 탭 제목 */}
<ChartBox title={`${(selectedSymbol || symbols[0] || "XRP").replace("USDT", "")}/USDT 15m 가격`}>
Step 6: 수동 확인
npm run dev또는 Docker 빌드 후 UI 확인- 심볼 탭 전환 시 데이터가 올바르게 필터링되는지
- ALL 탭에서 전체 통계가 합산되는지
- 오픈 포지션이 복수 표시되는지
Step 7: 커밋
git add dashboard/ui/src/App.jsx
git commit -m "feat: add multi-symbol UI with symbol filter tabs"
Task 5: 전체 통합 테스트 및 마무리
Files:
- Verify: 전체 테스트 스위트
Step 1: 전체 테스트 실행
Run: bash scripts/run_tests.sh
Expected: 모든 테스트 PASS
Step 2: 기존 봇 테스트가 깨지지 않는지 확인
Run: bash scripts/run_tests.sh -k "bot"
Expected: 모든 테스트 PASS
Step 3: Jenkins CI/CD 변경 확인
Jenkinsfile의 변경 감지 로직이 dashboard/ 디렉토리와 src/bot.py, src/user_data_stream.py 변경을 인식하는지 확인. 봇 이미지와 대시보드 이미지 모두 재빌드 트리거 필요.
Step 4: 운영 배포 후 확인
- Docker 이미지 재빌드 (봇 + dashboard-api + dashboard-ui)
- 운영 서버에서
docker compose down && docker compose up -d - 대시보드 UI에서 심볼 탭 확인
- DB 초기화 (Reset DB 버튼) → 로그 재파싱 → 데이터 확인
Step 5: 최종 커밋 및 CLAUDE.md 업데이트
CLAUDE.md의 plan 테이블에서 multi-symbol-dashboard status를 Completed로 변경.
git add CLAUDE.md
git commit -m "docs: mark multi-symbol-dashboard plan as completed"