Files
cointrader/docs/plans/2026-03-06-multi-symbol-dashboard-plan.md
21in7 cd9d379bc2 feat: implement multi-symbol dashboard with updated data handling
- 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>
2026-03-06 23:43:41 +09:00

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/statssymbol 파라미터는 쿼리 파라미터이므로 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: 상태 및 데이터 페칭에 심볼 지원 추가

주요 변경사항:

  1. symbols 상태 추가, /api/symbols에서 로드
  2. selectedSymbol 상태 추가 (기본값 null = ALL)
  3. fetchAll에서 선택된 심볼을 쿼리 파라미터로 전달
  4. positionpositions (배열)로 변경
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: 운영 배포 후 확인

  1. Docker 이미지 재빌드 (봇 + dashboard-api + dashboard-ui)
  2. 운영 서버에서 docker compose down && docker compose up -d
  3. 대시보드 UI에서 심볼 탭 확인
  4. 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"