feat: 대시보드 Circuit Breaker 게이지 추가 (#196) #197

Merged
jihoson merged 3 commits from feature/issue-196-cb-gauge into main 2026-02-22 11:49:57 +09:00
4 changed files with 172 additions and 0 deletions
Showing only changes of commit 342511a6ed - Show all commits

View File

@@ -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}")

View File

@@ -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 @@
<header>
<h1>&#x1F40D; The Ouroboros</h1>
<div class="header-right">
<div class="cb-gauge-wrap" id="cb-gauge" title="Circuit Breaker">
<span class="cb-dot unknown" id="cb-dot"></span>
<span id="cb-label">CB --</span>
<div class="cb-bar-wrap">
<div class="cb-bar-fill" id="cb-bar" style="width:0%;background:var(--accent)"></div>
</div>
</div>
<span id="last-updated">--</span>
<button class="refresh-btn" onclick="refreshAll()">&#x21BA; 새로고침</button>
</div>
@@ -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 {}
}

View File

@@ -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,

View File

@@ -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