diff --git a/src/dashboard/app.py b/src/dashboard/app.py index b1b6832..40ba5c3 100644 --- a/src/dashboard/app.py +++ b/src/dashboard/app.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import os import sqlite3 from datetime import UTC, datetime, timezone from pathlib import Path @@ -79,6 +80,35 @@ def create_dashboard_app(db_path: str) -> FastAPI: total_pnl += market_status[market]["total_pnl"] total_decisions += market_status[market]["decision_count"] + cb_threshold = float(os.getenv("CIRCUIT_BREAKER_PCT", "-3.0")) + pnl_pct_rows = conn.execute( + """ + SELECT key, value + FROM system_metrics + WHERE key LIKE 'portfolio_pnl_pct_%' + ORDER BY updated_at DESC + LIMIT 20 + """ + ).fetchall() + current_pnl_pct: float | None = None + if pnl_pct_rows: + values = [ + json.loads(row["value"]).get("pnl_pct") + for row in pnl_pct_rows + if json.loads(row["value"]).get("pnl_pct") is not None + ] + if values: + current_pnl_pct = round(min(values), 4) + + if current_pnl_pct is None: + cb_status = "unknown" + elif current_pnl_pct <= cb_threshold: + cb_status = "tripped" + elif current_pnl_pct <= cb_threshold + 1.0: + cb_status = "warning" + else: + cb_status = "ok" + return { "date": today, "markets": market_status, @@ -87,6 +117,11 @@ def create_dashboard_app(db_path: str) -> FastAPI: "total_pnl": round(total_pnl, 2), "decision_count": total_decisions, }, + "circuit_breaker": { + "threshold_pct": cb_threshold, + "current_pnl_pct": current_pnl_pct, + "status": cb_status, + }, } @app.get("/api/playbook/{date_str}") diff --git a/src/dashboard/static/index.html b/src/dashboard/static/index.html index 4c02f17..ee6eaed 100644 --- a/src/dashboard/static/index.html +++ b/src/dashboard/static/index.html @@ -13,6 +13,7 @@ --muted: #9fb3c8; --accent: #3cb371; --red: #e05555; + --warn: #e8a040; --border: #28455f; } * { box-sizing: border-box; margin: 0; padding: 0; } @@ -43,6 +44,25 @@ } .refresh-btn:hover { border-color: var(--accent); color: var(--accent); } + /* CB Gauge */ + .cb-gauge-wrap { + display: flex; align-items: center; gap: 8px; + font-size: 11px; color: var(--muted); + } + .cb-dot { + width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; + } + .cb-dot.ok { background: var(--accent); } + .cb-dot.warning { background: var(--warn); animation: pulse-warn 1.2s ease-in-out infinite; } + .cb-dot.tripped { background: var(--red); animation: pulse-warn 0.6s ease-in-out infinite; } + .cb-dot.unknown { background: var(--border); } + @keyframes pulse-warn { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } + } + .cb-bar-wrap { width: 64px; height: 5px; background: rgba(255,255,255,0.08); border-radius: 3px; overflow: hidden; } + .cb-bar-fill { height: 100%; border-radius: 3px; transition: width 0.4s, background 0.4s; } + /* Summary cards */ .cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; } @media (max-width: 700px) { .cards { grid-template-columns: repeat(2, 1fr); } } @@ -160,6 +180,13 @@

🐍 The Ouroboros

+
+ + CB -- +
+
+
+
--
@@ -325,6 +352,38 @@ } } + function renderCbGauge(cb) { + if (!cb) return; + const dot = document.getElementById('cb-dot'); + const label = document.getElementById('cb-label'); + const bar = document.getElementById('cb-bar'); + + const status = cb.status || 'unknown'; + const threshold = cb.threshold_pct ?? -3.0; + const current = cb.current_pnl_pct; + + // dot color + dot.className = `cb-dot ${status}`; + + // label + if (current !== null && current !== undefined) { + const sign = current > 0 ? '+' : ''; + label.textContent = `CB ${sign}${current.toFixed(2)}%`; + } else { + label.textContent = 'CB --'; + } + + // bar: fill = how much of the threshold has been consumed (0%=safe, 100%=tripped) + const colorMap = { ok: 'var(--accent)', warning: 'var(--warn)', tripped: 'var(--red)', unknown: 'var(--border)' }; + bar.style.background = colorMap[status] || 'var(--border)'; + if (current !== null && current !== undefined && threshold < 0) { + const fillPct = Math.min(Math.max((current / threshold) * 100, 0), 100); + bar.style.width = `${fillPct}%`; + } else { + bar.style.width = '0%'; + } + } + async function fetchStatus() { try { const r = await fetch('/api/status'); @@ -341,6 +400,7 @@ pnlEl.className = `card-value ${n > 0 ? 'positive' : n < 0 ? 'negative' : 'neutral'}`; } document.getElementById('card-pnl-sub').textContent = `결정 ${t.decision_count ?? 0}건`; + renderCbGauge(d.circuit_breaker); } catch {} } diff --git a/src/db.py b/src/db.py index ade215b..60dae11 100644 --- a/src/db.py +++ b/src/db.py @@ -138,6 +138,18 @@ def init_db(db_path: str) -> sqlite3.Connection: " ON trades (stock_code, market, timestamp DESC)" ) + # Lightweight key-value store for trading system runtime metrics (dashboard use only) + # Intentionally separate from the AI context tree to preserve separation of concerns. + conn.execute( + """ + CREATE TABLE IF NOT EXISTS system_metrics ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + conn.commit() return conn diff --git a/src/main.py b/src/main.py index 3399fbe..3a06b13 100644 --- a/src/main.py +++ b/src/main.py @@ -430,6 +430,17 @@ async def trading_cycle( {"volume_ratio": candidate.volume_ratio}, ) + # Write pnl_pct to system_metrics (dashboard-only table, separate from AI context tree) + db_conn.execute( + "INSERT OR REPLACE INTO system_metrics (key, value, updated_at) VALUES (?, ?, ?)", + ( + f"portfolio_pnl_pct_{market.code}", + json.dumps({"pnl_pct": round(pnl_pct, 4)}), + datetime.now(UTC).isoformat(), + ), + ) + db_conn.commit() + # Build portfolio data for global rule evaluation portfolio_data = { "portfolio_pnl_pct": pnl_pct, diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index 2e86256..dd3b8cb 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -351,3 +351,65 @@ def test_positions_empty_when_no_trades(tmp_path: Path) -> None: body = get_positions() assert body["count"] == 0 assert body["positions"] == [] + + +def _seed_cb_context(conn: sqlite3.Connection, pnl_pct: float, market: str = "KR") -> None: + import json as _json + conn.execute( + "INSERT OR REPLACE INTO system_metrics (key, value, updated_at) VALUES (?, ?, ?)", + ( + f"portfolio_pnl_pct_{market}", + _json.dumps({"pnl_pct": pnl_pct}), + "2026-02-22T10:00:00+00:00", + ), + ) + conn.commit() + + +def test_status_circuit_breaker_ok(tmp_path: Path) -> None: + """pnl_pct가 -2.0%보다 높으면 status=ok를 반환해야 한다.""" + db_path = tmp_path / "cb_ok.db" + conn = init_db(str(db_path)) + _seed_cb_context(conn, -1.0) + conn.close() + app = create_dashboard_app(str(db_path)) + get_status = _endpoint(app, "/api/status") + body = get_status() + cb = body["circuit_breaker"] + assert cb["status"] == "ok" + assert cb["current_pnl_pct"] == -1.0 + assert cb["threshold_pct"] == -3.0 + + +def test_status_circuit_breaker_warning(tmp_path: Path) -> None: + """pnl_pct가 -2.0% 이하이면 status=warning을 반환해야 한다.""" + db_path = tmp_path / "cb_warn.db" + conn = init_db(str(db_path)) + _seed_cb_context(conn, -2.5) + conn.close() + app = create_dashboard_app(str(db_path)) + get_status = _endpoint(app, "/api/status") + body = get_status() + assert body["circuit_breaker"]["status"] == "warning" + + +def test_status_circuit_breaker_tripped(tmp_path: Path) -> None: + """pnl_pct가 임계값(-3.0%) 이하이면 status=tripped를 반환해야 한다.""" + db_path = tmp_path / "cb_tripped.db" + conn = init_db(str(db_path)) + _seed_cb_context(conn, -3.5) + conn.close() + app = create_dashboard_app(str(db_path)) + get_status = _endpoint(app, "/api/status") + body = get_status() + assert body["circuit_breaker"]["status"] == "tripped" + + +def test_status_circuit_breaker_unknown_when_no_data(tmp_path: Path) -> None: + """L7 context에 pnl_pct 데이터가 없으면 status=unknown을 반환해야 한다.""" + app = _app(tmp_path) # seed_db에는 portfolio_pnl_pct 없음 + get_status = _endpoint(app, "/api/status") + body = get_status() + cb = body["circuit_breaker"] + assert cb["status"] == "unknown" + assert cb["current_pnl_pct"] is None