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:
144
tests/test_dashboard_api.py
Normal file
144
tests/test_dashboard_api.py
Normal file
@@ -0,0 +1,144 @@
|
||||
import sys
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "dashboard", "api"))
|
||||
|
||||
# DB_PATH를 테스트용 임시 파일로 설정 (import 전에)
|
||||
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
os.environ["DB_PATH"] = _tmp_db.name
|
||||
_tmp_db.close()
|
||||
|
||||
import dashboard_api # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db():
|
||||
"""각 테스트 전에 DB를 초기화하고 테스트 데이터를 삽입."""
|
||||
db_path = os.environ["DB_PATH"]
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.executescript("""
|
||||
DROP TABLE IF EXISTS trades;
|
||||
DROP TABLE IF EXISTS candles;
|
||||
DROP TABLE IF EXISTS daily_pnl;
|
||||
DROP TABLE IF EXISTS bot_status;
|
||||
DROP TABLE IF EXISTS parse_state;
|
||||
|
||||
CREATE TABLE trades (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
entry_time TEXT NOT NULL,
|
||||
exit_time TEXT,
|
||||
entry_price REAL NOT NULL,
|
||||
exit_price REAL,
|
||||
quantity REAL,
|
||||
leverage INTEGER DEFAULT 10,
|
||||
sl REAL, tp REAL,
|
||||
rsi REAL, macd_hist REAL, atr REAL, adx REAL,
|
||||
expected_pnl REAL, actual_pnl REAL,
|
||||
commission REAL, net_pnl REAL,
|
||||
status TEXT NOT NULL DEFAULT 'OPEN',
|
||||
close_reason TEXT, extra TEXT
|
||||
);
|
||||
CREATE TABLE candles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
symbol TEXT NOT NULL,
|
||||
ts TEXT NOT NULL,
|
||||
price REAL NOT NULL,
|
||||
signal TEXT, adx REAL, oi REAL, oi_change REAL, funding_rate REAL,
|
||||
UNIQUE(symbol, ts)
|
||||
);
|
||||
CREATE TABLE daily_pnl (
|
||||
symbol TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
cumulative_pnl REAL DEFAULT 0,
|
||||
trade_count INTEGER DEFAULT 0,
|
||||
wins INTEGER DEFAULT 0,
|
||||
losses INTEGER DEFAULT 0,
|
||||
last_updated TEXT,
|
||||
PRIMARY KEY(symbol, date)
|
||||
);
|
||||
CREATE TABLE bot_status (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
updated_at TEXT
|
||||
);
|
||||
CREATE TABLE parse_state (filepath TEXT PRIMARY KEY, position INTEGER DEFAULT 0);
|
||||
""")
|
||||
|
||||
# 테스트 데이터
|
||||
conn.execute(
|
||||
"INSERT INTO trades(symbol,direction,entry_time,entry_price,quantity,status) VALUES(?,?,?,?,?,?)",
|
||||
("XRPUSDT", "LONG", "2026-03-06 00:00:00", 2.30, 100.0, "OPEN"),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO trades(symbol,direction,entry_time,entry_price,exit_time,exit_price,quantity,net_pnl,commission,status,close_reason) VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
("TRXUSDT", "SHORT", "2026-03-05 12:00:00", 0.23, "2026-03-05 14:00:00", 0.22, 1000.0, 10.0, 0.1, "CLOSED", "TP"),
|
||||
)
|
||||
conn.execute("INSERT INTO bot_status(key,value,updated_at) VALUES(?,?,?)", ("XRPUSDT:last_start", "2026-03-06 00:00:00", "2026-03-06 00:00:00"))
|
||||
conn.execute("INSERT INTO bot_status(key,value,updated_at) VALUES(?,?,?)", ("TRXUSDT:last_start", "2026-03-06 00:00:00", "2026-03-06 00:00:00"))
|
||||
conn.execute("INSERT INTO bot_status(key,value,updated_at) VALUES(?,?,?)", ("XRPUSDT:current_price", "2.35", "2026-03-06 00:00:00"))
|
||||
conn.execute(
|
||||
"INSERT INTO candles(symbol,ts,price,signal) VALUES(?,?,?,?)",
|
||||
("XRPUSDT", "2026-03-06 00:00:00", 2.35, "LONG"),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO candles(symbol,ts,price,signal) VALUES(?,?,?,?)",
|
||||
("TRXUSDT", "2026-03-06 00:00:00", 0.23, "SHORT"),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
yield
|
||||
|
||||
|
||||
client = TestClient(dashboard_api.app)
|
||||
|
||||
|
||||
def test_get_symbols():
|
||||
r = client.get("/api/symbols")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert set(data["symbols"]) == {"XRPUSDT", "TRXUSDT"}
|
||||
|
||||
|
||||
def test_get_position_all():
|
||||
r = client.get("/api/position")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert len(data["positions"]) == 1
|
||||
assert data["positions"][0]["symbol"] == "XRPUSDT"
|
||||
|
||||
|
||||
def test_get_position_by_symbol():
|
||||
r = client.get("/api/position?symbol=XRPUSDT")
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()["positions"]) == 1
|
||||
|
||||
|
||||
def test_get_trades_by_symbol():
|
||||
r = client.get("/api/trades?symbol=TRXUSDT")
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()["trades"]) == 1
|
||||
assert r.json()["trades"][0]["symbol"] == "TRXUSDT"
|
||||
|
||||
|
||||
def test_get_candles_by_symbol():
|
||||
r = client.get("/api/candles?symbol=XRPUSDT")
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()["candles"]) == 1
|
||||
assert r.json()["candles"][0]["symbol"] == "XRPUSDT"
|
||||
|
||||
|
||||
def test_get_stats_all():
|
||||
r = client.get("/api/stats")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_get_stats_by_symbol():
|
||||
r = client.get("/api/stats?symbol=TRXUSDT")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["total_trades"] == 1
|
||||
117
tests/test_log_parser.py
Normal file
117
tests/test_log_parser.py
Normal file
@@ -0,0 +1,117 @@
|
||||
import sys
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import pytest
|
||||
|
||||
# dashboard/api를 import path에 추가
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "dashboard", "api"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parser():
|
||||
"""임시 DB로 LogParser 인스턴스 생성."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||
db_path = f.name
|
||||
|
||||
import log_parser as lp
|
||||
lp.DB_PATH = db_path
|
||||
p = lp.LogParser()
|
||||
yield p
|
||||
p.conn.close()
|
||||
os.unlink(db_path)
|
||||
|
||||
|
||||
def test_parse_signal_with_symbol(parser):
|
||||
"""[SYMBOL] 프리픽스가 있는 신호 로그를 파싱한다."""
|
||||
line = "2026-03-06 00:15:00 | INFO | [XRPUSDT] 신호: LONG | 현재가: 2.3456 USDT"
|
||||
parser._parse_line(line)
|
||||
row = parser.conn.execute("SELECT * FROM candles WHERE symbol='XRPUSDT'").fetchone()
|
||||
assert row is not None
|
||||
assert row["price"] == 2.3456
|
||||
assert row["signal"] == "LONG"
|
||||
|
||||
|
||||
def test_parse_entry_with_symbol(parser):
|
||||
"""[SYMBOL] 프리픽스가 있는 진입 로그를 파싱한다."""
|
||||
line = (
|
||||
"2026-03-06 00:15:00 | SUCCESS | [TRXUSDT] SHORT 진입: "
|
||||
"가격=0.2345, 수량=1000.0, SL=0.2380, TP=0.2240, "
|
||||
"RSI=72.31, MACD_H=-0.001234, ATR=0.005678"
|
||||
)
|
||||
parser._parse_line(line)
|
||||
row = parser.conn.execute("SELECT * FROM trades WHERE symbol='TRXUSDT'").fetchone()
|
||||
assert row is not None
|
||||
assert row["direction"] == "SHORT"
|
||||
assert row["entry_price"] == 0.2345
|
||||
|
||||
|
||||
def test_parse_close_with_symbol(parser):
|
||||
"""[SYMBOL] 프리픽스가 있는 청산 로그를 심볼별로 처리한다."""
|
||||
# 먼저 두 심볼의 포지션을 열어놓음
|
||||
entry1 = "2026-03-06 00:00:00 | SUCCESS | [XRPUSDT] LONG 진입: 가격=2.3000, 수량=100.0, SL=2.2600, TP=2.4000"
|
||||
entry2 = "2026-03-06 00:00:00 | SUCCESS | [TRXUSDT] SHORT 진입: 가격=0.2345, 수량=1000.0, SL=0.2380, TP=0.2240"
|
||||
parser._parse_line(entry1)
|
||||
parser._parse_line(entry2)
|
||||
|
||||
# XRPUSDT만 청산
|
||||
close_line = (
|
||||
"2026-03-06 01:00:00 | INFO | [XRPUSDT] 청산 감지(TP): "
|
||||
"exit=2.4000, rp=+10.0000, commission=0.1000, net_pnl=+9.9000"
|
||||
)
|
||||
parser._parse_line(close_line)
|
||||
|
||||
# XRPUSDT는 CLOSED, TRXUSDT는 여전히 OPEN
|
||||
xrp = parser.conn.execute("SELECT status FROM trades WHERE symbol='XRPUSDT'").fetchone()
|
||||
trx = parser.conn.execute("SELECT status FROM trades WHERE symbol='TRXUSDT'").fetchone()
|
||||
assert xrp["status"] == "CLOSED"
|
||||
assert trx["status"] == "OPEN"
|
||||
|
||||
|
||||
def test_parse_bot_start_multi_symbol(parser):
|
||||
"""멀티심볼 봇 시작 로그를 각각 파싱한다."""
|
||||
lines = [
|
||||
"2026-03-06 00:04:54 | INFO | [XRPUSDT] 봇 시작, 레버리지 10x",
|
||||
"2026-03-06 00:04:54 | INFO | [TRXUSDT] 봇 시작, 레버리지 10x",
|
||||
"2026-03-06 00:04:54 | INFO | [DOGEUSDT] 봇 시작, 레버리지 10x",
|
||||
]
|
||||
for line in lines:
|
||||
parser._parse_line(line)
|
||||
|
||||
symbols = parser.conn.execute(
|
||||
"SELECT value FROM bot_status WHERE key LIKE '%:last_start'"
|
||||
).fetchall()
|
||||
assert len(symbols) == 3
|
||||
|
||||
|
||||
def test_candles_table_has_symbol_column(parser):
|
||||
"""candles 테이블에 symbol 컬럼이 있어야 한다."""
|
||||
info = parser.conn.execute("PRAGMA table_info(candles)").fetchall()
|
||||
col_names = [row[1] for row in info]
|
||||
assert "symbol" in col_names
|
||||
|
||||
|
||||
def test_daily_pnl_table_has_symbol_column(parser):
|
||||
"""daily_pnl 테이블에 symbol 컬럼이 있어야 한다."""
|
||||
info = parser.conn.execute("PRAGMA table_info(daily_pnl)").fetchall()
|
||||
col_names = [row[1] for row in info]
|
||||
assert "symbol" in col_names
|
||||
|
||||
|
||||
def test_balance_log_with_symbol(parser):
|
||||
"""[SYMBOL] 프리픽스가 있는 잔고 로그를 파싱한다."""
|
||||
line = "2026-03-06 00:04:54 | INFO | [XRPUSDT] 기준 잔고 설정: 44.81 USDT (동적 증거금 비율 기준점)"
|
||||
parser._parse_line(line)
|
||||
row = parser.conn.execute("SELECT value FROM bot_status WHERE key='balance'").fetchone()
|
||||
assert row is not None
|
||||
assert row["value"] == "44.81"
|
||||
|
||||
|
||||
def test_position_recover_with_symbol(parser):
|
||||
"""[SYMBOL] 프리픽스가 있는 포지션 복구 로그를 파싱한다."""
|
||||
line = "2026-03-06 00:04:54 | INFO | [DOGEUSDT] 기존 포지션 복구: LONG | 진입가=0.1800 | 수량=500.0"
|
||||
parser._parse_line(line)
|
||||
row = parser.conn.execute("SELECT * FROM trades WHERE symbol='DOGEUSDT'").fetchone()
|
||||
assert row is not None
|
||||
assert row["direction"] == "LONG"
|
||||
assert row["entry_price"] == 0.1800
|
||||
Reference in New Issue
Block a user