Merge pull request 'feat: 대시보드 Circuit Breaker 게이지 추가 (#196)' (#197) from feature/issue-196-cb-gauge into main
Some checks failed
CI / test (push) Has been cancelled
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #197
This commit was merged in pull request #197.
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import UTC, datetime, timezone
|
from datetime import UTC, datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -79,6 +80,35 @@ def create_dashboard_app(db_path: str) -> FastAPI:
|
|||||||
total_pnl += market_status[market]["total_pnl"]
|
total_pnl += market_status[market]["total_pnl"]
|
||||||
total_decisions += market_status[market]["decision_count"]
|
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 {
|
return {
|
||||||
"date": today,
|
"date": today,
|
||||||
"markets": market_status,
|
"markets": market_status,
|
||||||
@@ -87,6 +117,11 @@ def create_dashboard_app(db_path: str) -> FastAPI:
|
|||||||
"total_pnl": round(total_pnl, 2),
|
"total_pnl": round(total_pnl, 2),
|
||||||
"decision_count": total_decisions,
|
"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}")
|
@app.get("/api/playbook/{date_str}")
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
--muted: #9fb3c8;
|
--muted: #9fb3c8;
|
||||||
--accent: #3cb371;
|
--accent: #3cb371;
|
||||||
--red: #e05555;
|
--red: #e05555;
|
||||||
|
--warn: #e8a040;
|
||||||
--border: #28455f;
|
--border: #28455f;
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
@@ -43,6 +44,25 @@
|
|||||||
}
|
}
|
||||||
.refresh-btn:hover { border-color: var(--accent); color: var(--accent); }
|
.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 */
|
/* Summary cards */
|
||||||
.cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
|
.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); } }
|
@media (max-width: 700px) { .cards { grid-template-columns: repeat(2, 1fr); } }
|
||||||
@@ -160,6 +180,13 @@
|
|||||||
<header>
|
<header>
|
||||||
<h1>🐍 The Ouroboros</h1>
|
<h1>🐍 The Ouroboros</h1>
|
||||||
<div class="header-right">
|
<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>
|
<span id="last-updated">--</span>
|
||||||
<button class="refresh-btn" onclick="refreshAll()">↺ 새로고침</button>
|
<button class="refresh-btn" onclick="refreshAll()">↺ 새로고침</button>
|
||||||
</div>
|
</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() {
|
async function fetchStatus() {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/status');
|
const r = await fetch('/api/status');
|
||||||
@@ -341,6 +400,7 @@
|
|||||||
pnlEl.className = `card-value ${n > 0 ? 'positive' : n < 0 ? 'negative' : 'neutral'}`;
|
pnlEl.className = `card-value ${n > 0 ? 'positive' : n < 0 ? 'negative' : 'neutral'}`;
|
||||||
}
|
}
|
||||||
document.getElementById('card-pnl-sub').textContent = `결정 ${t.decision_count ?? 0}건`;
|
document.getElementById('card-pnl-sub').textContent = `결정 ${t.decision_count ?? 0}건`;
|
||||||
|
renderCbGauge(d.circuit_breaker);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
12
src/db.py
12
src/db.py
@@ -138,6 +138,18 @@ def init_db(db_path: str) -> sqlite3.Connection:
|
|||||||
" ON trades (stock_code, market, timestamp DESC)"
|
" 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()
|
conn.commit()
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|||||||
11
src/main.py
11
src/main.py
@@ -430,6 +430,17 @@ async def trading_cycle(
|
|||||||
{"volume_ratio": candidate.volume_ratio},
|
{"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
|
# Build portfolio data for global rule evaluation
|
||||||
portfolio_data = {
|
portfolio_data = {
|
||||||
"portfolio_pnl_pct": pnl_pct,
|
"portfolio_pnl_pct": pnl_pct,
|
||||||
|
|||||||
@@ -351,3 +351,65 @@ def test_positions_empty_when_no_trades(tmp_path: Path) -> None:
|
|||||||
body = get_positions()
|
body = get_positions()
|
||||||
assert body["count"] == 0
|
assert body["count"] == 0
|
||||||
assert body["positions"] == []
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user