From 342511a6ed378cd945101bc6df85dc45b72ebd6e Mon Sep 17 00:00:00 2001 From: agentson Date: Sat, 21 Feb 2026 21:13:53 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?Circuit=20Breaker=20=EA=B2=8C=EC=9D=B4=EC=A7=80=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#196)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - trading_cycle()의 L7 context에 portfolio_pnl_pct_{market} 저장 추가 → 대시보드가 최신 pnl_pct를 DB에서 직접 조회 가능해짐 - /api/status 응답에 circuit_breaker 섹션 추가 (threshold_pct, current_pnl_pct, status: ok/warning/tripped/unknown) - warning: CB 임계값까지 1% 이내 (-2.0% 이하) - tripped: 임계값(-3.0%) 이하 - 대시보드 헤더에 CB 게이지 추가 (점멸 도트 + 진행 바 + 수치) - ok: 녹색, warning: 오렌지 점멸, tripped: 빨간 점멸 - CB 상태 테스트 4개 추가 (ok/warning/tripped/unknown) Co-Authored-By: Claude Sonnet 4.6 --- src/dashboard/app.py | 36 +++++++++++++++++ src/dashboard/static/index.html | 60 +++++++++++++++++++++++++++++ src/main.py | 8 ++++ tests/test_dashboard.py | 68 +++++++++++++++++++++++++++++++++ 4 files changed, 172 insertions(+) diff --git a/src/dashboard/app.py b/src/dashboard/app.py index b1b6832..623a64d 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,36 @@ 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 contexts + WHERE layer = 'L7_REALTIME' + AND 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 +118,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/main.py b/src/main.py index 3399fbe..ca7d519 100644 --- a/src/main.py +++ b/src/main.py @@ -430,6 +430,14 @@ async def trading_cycle( {"volume_ratio": candidate.volume_ratio}, ) + # Store latest pnl_pct in L7 so the dashboard can display the CB gauge + context_store.set_context( + ContextLayer.L7_REALTIME, + timeframe, + f"portfolio_pnl_pct_{market.code}", + {"pnl_pct": round(pnl_pct, 4)}, + ) + # 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..f870dd2 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -351,3 +351,71 @@ 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 contexts (layer, timeframe, key, value, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + "L7_REALTIME", + "2026-02-21T10:00:00+00:00", + f"portfolio_pnl_pct_{market}", + _json.dumps({"pnl_pct": pnl_pct}), + "2026-02-21T10:00:00+00:00", + "2026-02-21T10: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