Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 81 additions & 1 deletion monitoring/web_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,12 @@ def _html_page() -> str:
<p class="meta" id="runtimeMeta"></p>
</section>

<section>
<h2>바스켓 paper 승격 진행률</h2>
<div class="grid" id="basketEval"></div>
<p class="meta" id="basketEvalIssues"></p>
</section>

<section>
<h2>웹소켓 갭 모니터링</h2>
<div class="grid" id="wsGapSummary"></div>
Expand Down Expand Up @@ -448,6 +454,27 @@ def _html_page() -> str:
} catch (e) {
renderRuntime(null); renderWsGap(null);
}
try {
const evRes = await fetch('/api/basket_evaluation');
const evEl = document.getElementById('basketEval');
const evIssues = document.getElementById('basketEvalIssues');
if (evRes.ok) {
const evData = await evRes.json();
const evs = evData.evaluations || [];
if (!evs.length) { evEl.innerHTML = '<p class="muted">enabled 바스켓 없음</p>'; evIssues.textContent = ''; }
else {
evEl.innerHTML = evs.map(ev => {
const cls = ev.verdict === 'PASS_CANDIDATE' ? 'positive' : (ev.verdict === 'FAIL_REVIEW' ? 'negative' : '');
const cov = ev.snapshot_coverage != null ? Math.round(ev.snapshot_coverage * 100) + '%' : '-';
return card(escHtml(ev.basket) + ' 판정', '<span class="' + cls + '">' + escHtml(ev.verdict || '-') + '</span>')
+ card('진행', escHtml(String(ev.progress_days ?? '-')) + ' / ' + escHtml(String(ev.min_trading_days ?? '-')) + '일')
+ card('스냅샷 커버리지', cov);
}).join('');
const allIssues = evs.flatMap(ev => (ev.issues || []).map(i => escHtml(ev.basket) + ': ' + escHtml(i)));
evIssues.textContent = allIssues.join(' · ');
}
} else { evEl.innerHTML = '<p class="error">평가 조회 불가</p>'; }
} catch (e) { /* 평가 카드만 스킵 */ }
lastUpdate.textContent = ts;
}

Expand All @@ -459,7 +486,10 @@ def _html_page() -> str:


async def handle_index(_request: web.Request) -> web.Response:
return web.Response(text=_html_page(), content_type="text/html; charset=utf-8")
# aiohttp 3.13+: content_type에 charset을 섞으면 ValueError — 분리 인자로 전달.
# (기존 표기는 메인 페이지 '/'를 500으로 죽이는 운영 결함이었다 — API만 검증하고
# 페이지 서빙은 검증하지 않아 가려져 있었다.)
return web.Response(text=_html_page(), content_type="text/html", charset="utf-8")


async def handle_api_portfolio(_request: web.Request) -> web.Response:
Expand Down Expand Up @@ -490,13 +520,63 @@ async def handle_api_snapshots(request: web.Request) -> web.Response:
return web.json_response({"error": str(e)}, status=500)


# 평가 결과 캐시 (TTL 60초) — 수집기가 호출마다 TradingHours를 새로 만들어
# 10초 폴링이면 INFO 로그 2줄×8,640회/일 스팸이 되고, holidays.yaml이 사라진
# 환경에서는 pykrx 네트워크 갱신이 sync-in-async 핸들러를 매 폴링 블로킹할 수
# 있다(잠재). 진행률은 하루 단위로 변하는 값이라 60초 캐시는 충분히 신선하다.
_BASKET_EVAL_CACHE: dict = {"at": 0.0, "data": None}
_BASKET_EVAL_TTL_SEC = 60.0


async def handle_api_basket_evaluation(_request: web.Request) -> web.Response:
"""바스켓 paper 운영 평가(승격 진행률) — 게이트와 같은 수집기라 판정이 동일하다.

read-only. include_benchmark=False로 네트워크(KS11 조회)를 피한다 — 대시보드는
10초 폴링이므로 외부 조회를 섞으면 안 된다. 결과는 60초 TTL 캐시.
"""
import time as _time

try:
now = _time.monotonic()
if (
_BASKET_EVAL_CACHE["data"] is not None
and now - _BASKET_EVAL_CACHE["at"] < _BASKET_EVAL_TTL_SEC
):
return web.json_response(_BASKET_EVAL_CACHE["data"])

from core.basket_evaluation import collect_basket_paper_evaluation
from core.basket_rebalancer import BasketRebalancer

out = []
for name in BasketRebalancer.get_enabled_baskets():
result, basket_name = collect_basket_paper_evaluation(
include_benchmark=False, basket_name=name,
)
out.append({
"basket": basket_name,
"verdict": result.get("verdict"),
"progress_days": result.get("progress_days"),
"min_trading_days": result.get("min_trading_days"),
"snapshot_coverage": result.get("snapshot_coverage"),
"issues": result.get("issues", []),
})
payload = {"evaluations": out}
_BASKET_EVAL_CACHE["at"] = now
_BASKET_EVAL_CACHE["data"] = payload
return web.json_response(payload)
except Exception as e:
logger.exception("API /api/basket_evaluation 오류: {}", e)
return web.json_response({"error": str(e)}, status=500)


def create_app() -> web.Application:
web_mod = _require_aiohttp_web()
app = web_mod.Application()
app.router.add_get("/", handle_index)
app.router.add_get("/api/portfolio", handle_api_portfolio)
app.router.add_get("/api/runtime", handle_api_runtime)
app.router.add_get("/api/snapshots", handle_api_snapshots)
app.router.add_get("/api/basket_evaluation", handle_api_basket_evaluation)
return app


Expand Down
145 changes: 145 additions & 0 deletions tests/test_dashboard_basket_evaluation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""대시보드 /api/basket_evaluation 회귀 테스트 — 승격 진행률 웹 노출(read-only).

게이트와 같은 수집기(collect_basket_paper_evaluation)를 쓰므로 웹 판정 = 게이트 판정.
include_benchmark=False(외부 조회 없음 — 10초 폴링 경로) 계약도 고정한다.
"""
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

import pytest
from unittest.mock import patch

try:
import aiohttp # noqa: F401
_has_aiohttp = True
except ImportError:
_has_aiohttp = False


@pytest.mark.skipif(not _has_aiohttp, reason="aiohttp 미설치")
def test_basket_evaluation_endpoint_returns_progress():
from aiohttp.test_utils import TestClient, TestServer
import asyncio
from monitoring import web_dashboard as wd

fake_result = {
"verdict": "WAIT",
"progress_days": 2,
"min_trading_days": 60,
"snapshot_coverage": 1.0,
"issues": [],
}

from monitoring import web_dashboard as wd0
wd0._BASKET_EVAL_CACHE.update({"at": 0.0, "data": None})

async def run():
with patch(
"core.basket_rebalancer.BasketRebalancer.get_enabled_baskets",
return_value=["kr_diversified_hold"],
), patch(
"core.basket_evaluation.collect_basket_paper_evaluation",
return_value=(fake_result, "kr_diversified_hold"),
) as collect:
app = wd.create_app()
client = TestClient(TestServer(app))
await client.start_server()
try:
res = await client.get("/api/basket_evaluation")
assert res.status == 200
data = await res.json()
finally:
await client.close()
assert collect.call_args.kwargs["include_benchmark"] is False
assert collect.call_args.kwargs["basket_name"] == "kr_diversified_hold"
ev = data["evaluations"][0]
assert ev["verdict"] == "WAIT"
assert ev["progress_days"] == 2
assert ev["min_trading_days"] == 60
assert ev["snapshot_coverage"] == 1.0

asyncio.run(run())


@pytest.mark.skipif(not _has_aiohttp, reason="aiohttp 미설치")
def test_basket_evaluation_endpoint_fails_soft():
"""수집 실패 시 500 + error JSON (대시보드 다른 카드에 영향 없음)."""
from aiohttp.test_utils import TestClient, TestServer
import asyncio
from monitoring import web_dashboard as wd

from monitoring import web_dashboard as wd0
wd0._BASKET_EVAL_CACHE.update({"at": 0.0, "data": None})

async def run():
with patch(
"core.basket_rebalancer.BasketRebalancer.get_enabled_baskets",
side_effect=RuntimeError("db down"),
):
app = wd.create_app()
client = TestClient(TestServer(app))
await client.start_server()
try:
res = await client.get("/api/basket_evaluation")
assert res.status == 500
data = await res.json()
assert "error" in data
finally:
await client.close()

asyncio.run(run())


@pytest.mark.skipif(not _has_aiohttp, reason="aiohttp 미설치")
def test_index_page_serves_200_with_progress_section():
"""메인 페이지 '/' 서빙 회귀 — aiohttp 3.13+에서 content_type에 charset을 섞으면
ValueError로 페이지 전체가 500이 된다(실제로 그렇게 죽어 있던 운영 결함).
API만 검증하고 페이지를 안 보면 이런 결함이 가려진다."""
from aiohttp.test_utils import TestClient, TestServer
import asyncio
from monitoring import web_dashboard as wd

async def run():
client = TestClient(TestServer(wd.create_app()))
await client.start_server()
try:
res = await client.get("/")
assert res.status == 200
html = await res.text()
finally:
await client.close()
assert "basketEval" in html and "승격 진행률" in html

asyncio.run(run())


@pytest.mark.skipif(not _has_aiohttp, reason="aiohttp 미설치")
def test_basket_evaluation_endpoint_caches_for_ttl():
"""60초 TTL 캐시 — 10초 폴링이 매번 수집기(TradingHours 생성 포함)를 부르지 않는다."""
from aiohttp.test_utils import TestClient, TestServer
import asyncio
from monitoring import web_dashboard as wd

wd._BASKET_EVAL_CACHE.update({"at": 0.0, "data": None})
fake_result = {"verdict": "WAIT", "progress_days": 2, "min_trading_days": 60,
"snapshot_coverage": 1.0, "issues": []}

async def run():
with patch(
"core.basket_rebalancer.BasketRebalancer.get_enabled_baskets",
return_value=["kr_diversified_hold"],
), patch(
"core.basket_evaluation.collect_basket_paper_evaluation",
return_value=(fake_result, "kr_diversified_hold"),
) as collect:
client = TestClient(TestServer(wd.create_app()))
await client.start_server()
try:
r1 = await client.get("/api/basket_evaluation")
r2 = await client.get("/api/basket_evaluation")
assert r1.status == 200 and r2.status == 200
finally:
await client.close()
assert collect.call_count == 1 # 두 번째 요청은 캐시

asyncio.run(run())
Loading