diff --git a/src/dashboard/app.py b/src/dashboard/app.py index b77ae93..b1b6832 100644 --- a/src/dashboard/app.py +++ b/src/dashboard/app.py @@ -4,7 +4,7 @@ from __future__ import annotations import json import sqlite3 -from datetime import UTC, datetime +from datetime import UTC, datetime, timezone from pathlib import Path from typing import Any @@ -341,12 +341,68 @@ def create_dashboard_app(db_path: str) -> FastAPI: ) return {"market": market, "date": date_str, "count": len(matches), "matches": matches} + @app.get("/api/positions") + def get_positions() -> dict[str, Any]: + """Return all currently open positions (last trade per symbol is BUY).""" + with _connect(db_path) as conn: + rows = conn.execute( + """ + SELECT stock_code, market, exchange_code, + price AS entry_price, quantity, timestamp AS entry_time, + decision_id + FROM ( + SELECT stock_code, market, exchange_code, price, quantity, + timestamp, decision_id, action, + ROW_NUMBER() OVER ( + PARTITION BY stock_code, market + ORDER BY timestamp DESC + ) AS rn + FROM trades + ) + WHERE rn = 1 AND action = 'BUY' + ORDER BY entry_time DESC + """ + ).fetchall() + + now = datetime.now(timezone.utc) + positions = [] + for row in rows: + entry_time_str = row["entry_time"] + try: + entry_dt = datetime.fromisoformat(entry_time_str.replace("Z", "+00:00")) + held_seconds = int((now - entry_dt).total_seconds()) + held_hours = held_seconds // 3600 + held_minutes = (held_seconds % 3600) // 60 + if held_hours >= 1: + held_display = f"{held_hours}h {held_minutes}m" + else: + held_display = f"{held_minutes}m" + except (ValueError, TypeError): + held_display = "--" + + positions.append( + { + "stock_code": row["stock_code"], + "market": row["market"], + "exchange_code": row["exchange_code"], + "entry_price": row["entry_price"], + "quantity": row["quantity"], + "entry_time": entry_time_str, + "held": held_display, + "decision_id": row["decision_id"], + } + ) + + return {"count": len(positions), "positions": positions} + return app def _connect(db_path: str) -> sqlite3.Connection: conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA busy_timeout=8000") return conn diff --git a/src/dashboard/static/index.html b/src/dashboard/static/index.html index 04cd6ea..4c02f17 100644 --- a/src/dashboard/static/index.html +++ b/src/dashboard/static/index.html @@ -123,6 +123,32 @@ .rationale-cell { max-width: 200px; overflow: hidden; text-overflow: ellipsis; color: var(--muted); } .empty-row td { text-align: center; color: var(--muted); padding: 24px; } + /* Positions panel */ + .positions-panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px; + margin-bottom: 20px; + } + .positions-table { width: 100%; border-collapse: collapse; margin-top: 14px; } + .positions-table th { + text-align: left; color: var(--muted); font-size: 11px; font-weight: 600; + padding: 6px 8px; border-bottom: 1px solid var(--border); white-space: nowrap; + } + .positions-table td { + padding: 8px 8px; border-bottom: 1px solid rgba(40, 69, 95, 0.5); + vertical-align: middle; white-space: nowrap; + } + .positions-table tr:last-child td { border-bottom: none; } + .positions-table tr:hover td { background: rgba(255,255,255,0.02); } + .pos-empty { color: var(--muted); text-align: center; padding: 20px 0; font-size: 12px; } + .pos-count { + display: inline-block; background: rgba(60, 179, 113, 0.12); + color: var(--accent); font-size: 11px; font-weight: 700; + padding: 2px 8px; border-radius: 10px; margin-left: 8px; + } + /* Spinner */ .spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } @@ -163,6 +189,30 @@ + +
+
+ + 현재 보유 포지션 + 0 + +
+ + + + + + + + + + + + + +
종목시장수량진입가보유 시간
+
+
@@ -242,6 +292,39 @@
`; } + function fmtPrice(v, market) { + if (v === null || v === undefined) return '--'; + const n = parseFloat(v); + const sym = market === 'KR' ? '₩' : market === 'JP' ? '¥' : market === 'HK' ? 'HK$' : '$'; + return sym + n.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 4 }); + } + + async function fetchPositions() { + const tbody = document.getElementById('positions-body'); + const countEl = document.getElementById('positions-count'); + try { + const r = await fetch('/api/positions'); + if (!r.ok) throw new Error('fetch failed'); + const d = await r.json(); + countEl.textContent = d.count ?? 0; + if (!d.positions || d.positions.length === 0) { + tbody.innerHTML = '현재 보유 중인 포지션 없음'; + return; + } + tbody.innerHTML = d.positions.map(p => ` + + ${p.stock_code || '--'} + ${p.market || '--'} + ${p.quantity ?? '--'} + ${fmtPrice(p.entry_price, p.market)} + ${p.held || '--'} + + `).join(''); + } catch { + tbody.innerHTML = '데이터 로드 실패'; + } + } + async function fetchStatus() { try { const r = await fetch('/api/status'); @@ -383,6 +466,7 @@ await Promise.all([ fetchStatus(), fetchPerformance(), + fetchPositions(), fetchPnlHistory(currentDays), fetchDecisions(currentMarket), ]); diff --git a/src/db.py b/src/db.py index d798a45..ade215b 100644 --- a/src/db.py +++ b/src/db.py @@ -131,6 +131,13 @@ def init_db(db_path: str) -> sqlite3.Connection: conn.execute( "CREATE INDEX IF NOT EXISTS idx_decision_logs_confidence ON decision_logs(confidence)" ) + + # Index for open-position queries (partition by stock_code, market, ordered by timestamp) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_trades_stock_market_ts" + " ON trades (stock_code, market, timestamp DESC)" + ) + conn.commit() return conn diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index f9e5040..2e86256 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -316,3 +316,38 @@ def test_pnl_history_market_filter(tmp_path: Path) -> None: # KR has 1 trade with pnl=2.0 assert len(body["labels"]) >= 1 assert body["pnl"][0] == 2.0 + + +def test_positions_returns_open_buy(tmp_path: Path) -> None: + """BUY가 마지막 거래인 종목은 포지션으로 반환되어야 한다.""" + app = _app(tmp_path) + get_positions = _endpoint(app, "/api/positions") + body = get_positions() + # seed_db: 005930은 BUY (오픈), AAPL은 SELL (마지막) + assert body["count"] == 1 + pos = body["positions"][0] + assert pos["stock_code"] == "005930" + assert pos["market"] == "KR" + assert pos["quantity"] == 1 + assert pos["entry_price"] == 70000 + + +def test_positions_excludes_closed_sell(tmp_path: Path) -> None: + """마지막 거래가 SELL인 종목은 포지션에 나타나지 않아야 한다.""" + app = _app(tmp_path) + get_positions = _endpoint(app, "/api/positions") + body = get_positions() + codes = [p["stock_code"] for p in body["positions"]] + assert "AAPL" not in codes + + +def test_positions_empty_when_no_trades(tmp_path: Path) -> None: + """거래 내역이 없으면 빈 포지션 목록을 반환해야 한다.""" + db_path = tmp_path / "empty.db" + conn = init_db(str(db_path)) + conn.close() + app = create_dashboard_app(str(db_path)) + get_positions = _endpoint(app, "/api/positions") + body = get_positions() + assert body["count"] == 0 + assert body["positions"] == []