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

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