feat: 대시보드 Circuit Breaker 게이지 추가 (#196)
Some checks failed
CI / test (pull_request) Has been cancelled
Some checks failed
CI / test (pull_request) Has been cancelled
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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}")
|
||||
|
||||
@@ -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>🐍 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()">↺ 새로고침</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 {}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user