auto: 일일 백업 2026-07-23 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-07-23 02:00:04 +09:00
parent 2cf5b38da1
commit 8c23849952
156 changed files with 3497 additions and 1389 deletions
+428 -64
View File
@@ -806,6 +806,16 @@ def _market_phase_state() -> dict:
return {'phase': 'closed', 'label': '장외 시간', 'active': False, 'time': time_str}
def _bump_panel_signal() -> None:
"""주문 접수/취소 등 이벤트 발생 시 state/fill_signal 갱신 → 브라우저 1초 틱(realtime)이
fill_seq 변화를 감지해 패널을 1 갱신. 3 주기 전면 swap 폐지(2026-07-22, 이벤트 기반 전환)
미체결·PIN 배지 신선도 유지용. fill_watcher 체결 쓰는 같은 파일. 실패 무시."""
try:
(WORKSPACE / 'state' / 'fill_signal').write_text(str(time.time()), encoding='utf-8')
except Exception:
pass
def _is_preopen_market_day() -> bool:
"""거래일 08:00 전. 장전 가격으로 당일 손익을 만들지 않는다."""
now = datetime.now(KST)
@@ -2662,8 +2672,11 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
stop_raw += ' <span class="alert-mark">알림됨</span>'
chart_block = ''
flow_block = ''
if code:
chart_block = f'<div class="block chart-svg" data-chart-code="{code}"></div>'
# 외국인·기관·개인 수급 (ka10059) — 팝업 열 때 client lazy fetch (flow_script)
flow_block = f'<div class="block flow-box" data-flow-code="{code}"></div>'
summary_lines = c.get('summary') or []
summary_block = ''
@@ -2800,6 +2813,7 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
</summary>
<div class="detail">
{detail_name_html}
{f'<div class="detail-simple"><span class="ds-lbl">현재가</span><span class="ds-val">{price_cell}</span></div>' if price is not None else ''}
{'<div class="alert-notice">최초 알림 완료 — 리셋 전까지 매수 알림 없음</div>' if unheld_done else ''}
<div class="grid2">
{held_row}
@@ -2814,6 +2828,7 @@ def _render_row(c: dict, source: str = 'watchlist') -> str:
{notes_block}
{video_block}
{actions_html}
{flow_block}
{chart_block}
</div>
</details>'''
@@ -3297,6 +3312,10 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
if _t and _t not in _acct_types:
_acct_types.append(_t)
acct_label = html.escape('·'.join(_acct_types))
# 소유자 — 계좌 라벨 '가희_' 접두로 판별. 팝업(탭 안 보임)에서 누구 보유인지 배지로 표시.
_is_gahee = any((lb or '').startswith('가희') for lb in r.get('accounts', []))
owner_disp = '가희' if _is_gahee else '관리자'
owner_cls = 'gahee' if _is_gahee else 'self'
qty = r.get('qty', 0)
avg = r.get('avg', 0)
price = r.get('price', 0)
@@ -3438,14 +3457,17 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
price_html = f'{mark_html}<span class="rt-px">{price:,}</span>'
chart_block = ''
flow_block = ''
if code:
# 카드 펼침 이벤트에 클라이언트 JS 가 /api/chart_svg?code= 를 fetch 해 채움.
# data-chart-code 속성으로 식별. panels swap 후 chartCache 복원도 같은 셀렉터로.
chart_block = f'<div class="block chart-svg" data-chart-code="{code}"></div>'
# 외국인·기관·개인 수급 (ka10059) — 팝업 열 때 client lazy fetch (flow_script)
flow_block = f'<div class="block flow-box" data-flow-code="{code}"></div>'
left_pairs = [
f'<dt>종목코드</dt><dd class="muted">{code}</dd>',
f'<dt>현재가</dt><dd class="num">{price:,}원</dd>',
f'<dt>현재가</dt><dd class="num"><span class="rt-px">{price:,}</span>원</dd>',
f'<dt>평단가</dt><dd class="num">{avg:,}원</dd>',
]
right_pairs = [
@@ -3518,13 +3540,15 @@ def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False
{candle_summary}
</summary>
<div class="detail">
<div class="detail-name">{stock}{f'<span class="detail-code">{code}</span>' if code else ''}{tag_add_btn}</div>
<div class="detail-name">{stock}{f'<span class="detail-code">{code}</span>' if code else ''}<span class="detail-owner owner-{owner_cls}">{owner_disp}</span>{tag_add_btn}</div>
{f'<div class="detail-tag">{tag_chip}</div>' if tag_chip else ''}
{f'<div class="detail-simple"><span class="ds-lbl">현재가</span><span class="ds-val">{price_html}</span></div>' if price else ''}
<dl>{dl_inner}</dl>
<dl class="pl-summary">{pl_dl_inner}</dl>
{ohlc_mini_html}
{_pending_detail_html(r)}
{trade_btn}
{flow_block}
{chart_block}
</div>
</details>'''
@@ -4965,12 +4989,15 @@ details.row + .section-label { margin-top: 14px; padding-top: 24px; border-top-c
.ohlc-mini .ohlc-pct.down { color: #4a8cf0; }
.ohlc-mini .ohlc-pct.neutral { color: #8b8f9a; }
/* 보유종목 자세히 보기 KRX/NXT 거래소 분리 표시. 3-col grid (라벨/KRX셀/NXT셀). */
.ohlc-mini.ohlc-dual { grid-template-columns: max-content 1fr 1fr; column-gap: 16px; }
/* 팝업 대응 라벨(전일종가/현재가/) 열을 고정폭으로 좁히고 KRX/NXT 열을 넓혀 폰트(13/12) 유지.
단가 종목(: 하이닉스) nowrap 으로 유지. */
.ohlc-mini.ohlc-dual { grid-template-columns: 48px 1fr 1fr; column-gap: 6px; }
.ohlc-mini.ohlc-dual .ohlc-rl { font-size: 11px; white-space: nowrap; }
.ohlc-mini.ohlc-dual .ohlc-th { text-align: center; border-bottom: 1px solid #1f2330; }
.ohlc-mini.ohlc-dual .ohlc-th:first-child { text-align: left; border-bottom: none; }
.ohlc-mini.ohlc-dual .ohlc-cell { display: flex; flex-direction: row; align-items: baseline; justify-content: flex-end; gap: 8px; }
.ohlc-mini.ohlc-dual .ohlc-cell .ohlc-price { font-size: 13px; text-align: right; }
.ohlc-mini.ohlc-dual .ohlc-cell .ohlc-pct { font-size: 12px; min-width: auto; }
.ohlc-mini.ohlc-dual .ohlc-cell { display: flex; flex-direction: row; align-items: baseline; justify-content: flex-end; gap: 4px; min-width: 0; }
.ohlc-mini.ohlc-dual .ohlc-cell .ohlc-price { font-size: 13px; text-align: right; white-space: nowrap; }
.ohlc-mini.ohlc-dual .ohlc-cell .ohlc-pct { font-size: 12px; min-width: auto; white-space: nowrap; }
.ohlc-mini.ohlc-dual .ohlc-cell-empty { color: #5a5e69; text-align: right; font-size: 12px; }
/* 비활성 시장 컬럼 회색 처리 헤더··가격·등락률 모두 흐리게. */
.ohlc-mini.ohlc-dual .ohlc-th.ohlc-dim { color: #4a4e58; }
@@ -5003,9 +5030,21 @@ details.row + .section-label { margin-top: 14px; padding-top: 24px; border-top-c
/* 펼친 detail의 태그는 크게 (미리보기·관심행 칩은 작게 유지). */
.detail-name { font-size: 15px; font-weight: 700; color: #f0f0f0; margin: 0 0 8px; display: flex; align-items: baseline; gap: 8px; }
.detail-name .detail-code { font-size: 11px; font-weight: 500; color: #8b8f9a; font-variant-numeric: tabular-nums; }
.detail-name .detail-owner { font-size: 11px; font-weight: 600; border-radius: 4px; padding: 1px 6px; align-self: center; }
.detail-name .detail-owner.owner-self { color: #6aa9ff; background: rgba(106,169,240,0.12); border: 1px solid rgba(106,169,240,0.4); }
.detail-name .detail-owner.owner-gahee { color: #e879f9; background: rgba(232,121,249,0.12); border: 1px solid rgba(232,121,249,0.4); }
.detail-name .btn-add-tag { align-self: center; }
.detail-tag { margin: 0 0 8px; }
.detail-tag .tag-chip { font-size: 11px; padding: 2px 8px; border-radius: 4px; }
/* 심플정보 팝업 상단에 접힌 (summary) 종목 기본정보(현재가·등락) 재노출. 팝업 전환으로 summary가 백드롭 뒤로 가려져 보이던 정보. */
.detail .detail-simple { display: flex; align-items: baseline; gap: 8px; margin: 0 0 10px; padding: 8px 10px; background: #131820; border: 1px solid #1f2330; border-radius: 8px; }
.detail .detail-simple .ds-lbl { font-size: 11px; font-weight: 700; color: #8b8f9a; }
.detail .detail-simple .ds-val { font-size: 16px; font-weight: 700; color: #f0f0f0; font-variant-numeric: tabular-nums; display: inline-flex; align-items: baseline; gap: 6px; }
.detail .detail-simple .ds-val .day-pct { font-size: 13px; font-weight: 600; }
/* 팝업 상단 sticky 헤더 (JS가 detail-name + detail-simple 묶어 최상단 고정) 종목명·소유자·현재가 항상 노출 */
.detail .detail-header { position: sticky; top: 0; z-index: 4; margin: 0 -16px 8px; padding: 12px 16px 8px; background: #0d1018; border-bottom: 1px solid #1f2330; }
.detail .detail-header .detail-name { margin: 0; }
.detail .detail-header .detail-simple { margin: 6px 0 0; }
.btn-add-tag { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 500; line-height: 1.5; color: #5a5f6c; background: transparent; border: 1px dashed rgba(120,124,135,0.30); white-space: nowrap; flex: none; cursor: pointer; font-family: inherit; }
.btn-add-tag:hover { color: #d6b34a; border-color: rgba(214,179,74,0.45); }
.tag-quick-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
@@ -5035,6 +5074,50 @@ dl.pending-detail .pending-line-meta { margin-top: 2px; }
border-top: 1px solid #14171f;
font-size: 13px;
}
/* 종목 상세 팝업(모달) <details> open .detail 화면 중앙 오버레이 카드로 띄움.
PC·모바일 공통 중앙 카드(작게). 닫기() 팝업 내부 sticky 크기·스크롤과 무관하게 우상단 고정.
차트/크로스헤어/mutex/상태보존 JS는 그대로. */
#detail-backdrop { position: fixed; inset: 0; z-index: 900; background: rgba(0,0,0,0.62); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); }
#detail-backdrop[hidden] { display: none; }
body.detail-open { overflow: hidden; }
details.row[open] > .detail {
position: fixed; z-index: 950;
top: 50%; left: 50%; transform: translate(-50%, -50%);
width: min(520px, calc(100% - 32px));
max-height: calc(100dvh - 120px); overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch;
border: 2px solid #ffffff; border-radius: 14px;
box-shadow: 0 18px 50px rgba(0,0,0,0.5);
padding-top: 0; padding-bottom: 0; /* 상단 헤더·하단 액션 바가 가장자리에 붙도록 ·아래 여백 제거 */
}
/* 팝업 하단 sticky 액션 (JS가 열린 .detail .actions 끝으로 옮기고 클래스 부여) 메모·상세·거래내역·거래 + 닫기, 항상 노출 */
.detail .actions.detail-actions-footer { position: sticky; bottom: 0; z-index: 3; margin: 8px -16px 0; padding: 8px 16px 10px; gap: 6px; background: #0d1018; border-top: 1px solid #1f2330; }
/* 닫기 버튼 하단 마지막 항목. 우측으로 밀어 다른 버튼과 구분 */
.detail .actions .btn-detail-close { margin-left: auto; color: #e6e9ef; border-color: #3a4154; }
.detail .actions .btn-detail-close:hover { background: #22262f; border-color: #6aa9ff; color: #fff; }
/* 종목 변경 드롭다운 하단 버튼 (자기 차지) */
.detail .actions .detail-stock-switch { flex: 1 1 100%; min-width: 0; padding: 7px 10px; background: #0a0d12; color: #e6e9ef; border: 1px solid #2a3142; border-radius: 6px; font-family: inherit; font-size: 14px; }
.detail .actions .detail-stock-switch:focus { outline: none; border-color: #6aa9ff; }
/* 외국인·기관·개인 수급 블록 (팝업 lazy fetch) */
.detail .flow-box { margin-top: 14px; }
.flow-title { font-size: 12px; font-weight: 700; color: #cfd3db; margin-bottom: 6px; }
.flow-title .flow-unit { font-size: 10px; font-weight: 500; color: #7a8493; }
.flow-title.flow-err { color: #8b8f9a; font-weight: 500; }
.flow-tbl { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
.flow-tbl th { font-size: 10px; font-weight: 500; color: #8b8f9a; text-align: right; padding: 3px 4px; border-bottom: 1px solid #1f2330; }
.flow-tbl th:first-child { text-align: left; }
.flow-tbl td { font-size: 12px; padding: 4px; text-align: right; border-bottom: 1px solid #14171f; }
.flow-tbl td.flow-d { text-align: left; color: #8b8f9a; }
.flow-tbl td.up { color: #ff4d5e; }
.flow-tbl td.down { color: #4a8cf0; }
.flow-tbl td.neu { color: #8b8f9a; }
.flow-more { margin-top: 8px; width: 100%; padding: 7px; background: #14171f; color: #c9ccd3; border: 1px solid #2a3142; border-radius: 6px; font-family: inherit; font-size: 12px; cursor: pointer; }
.flow-more:hover { background: #1c2030; border-color: #6aa9ff; }
.flow-modal { position: fixed; inset: 0; z-index: 1100; display: flex; align-items: center; justify-content: center; padding: 40px 16px; }
.flow-modal-ov { position: absolute; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); }
.flow-modal-box { position: relative; width: 100%; max-width: 460px; max-height: 80vh; overflow-y: auto; background: #0d1018; border: 1px solid #2f3543; border-radius: 12px; padding: 14px 16px; box-shadow: 0 18px 50px rgba(0,0,0,0.55); }
.flow-modal-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-size: 13px; font-weight: 700; color: #f0f0f0; margin: -14px -16px 10px; padding: 12px 16px 8px; position: sticky; top: -14px; background: #0d1018; border-bottom: 1px solid #1f2330; }
.flow-modal-x { flex: none; width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center; padding: 0; background: #1a1d26; color: #e6e9ef; border: 1px solid #2f3543; border-radius: 50%; font-size: 14px; line-height: 1; cursor: pointer; font-family: inherit; }
.flow-modal-x:hover { background: #22262f; color: #fff; }
.detail dl { display: grid; grid-template-columns: 80px 1fr; gap: 6px 14px; margin: 6px 0 14px; }
.row.mode-held .detail dl {
grid-template-columns: 80px minmax(0, 1fr) 80px minmax(0, 1fr);
@@ -5061,7 +5144,7 @@ dl.pending-detail .pending-line-meta { margin-top: 2px; }
}
}
.detail dt { color: #8b8f9a; font-size: 12px; }
.detail dd { margin: 0; color: #d6d6d6; }
.detail dd { margin: 0; color: #d6d6d6; min-width: 0; overflow-wrap: anywhere; }
/* 워치리스트·관심종목 카드 본문 라벨/ 2-col grid (매수가/목표가/손절가/저장일·매입평단).
OHLC는 별도 `.ohlc-mini` 블록으로 분리. */
.detail .grid2 { display: grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 12px; row-gap: 6px; margin: 6px 0 14px; }
@@ -5106,7 +5189,7 @@ dl.pending-detail .pending-line-meta { margin-top: 2px; }
.detail .block.chart-svg .chart-hlines .hl-chip-x { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; padding: 0; background: transparent; color: #e879f9; border: none; border-radius: 50%; cursor: pointer; font-size: 14px; line-height: 1; font-family: inherit; }
.detail .block.chart-svg .chart-hlines .hl-chip-x:hover { background: rgba(232,121,249,0.25); }
.detail .block.chart-svg .chart-hlines-add { display: flex; gap: 6px; }
.detail .block.chart-svg .chart-hlines-add input { flex: 1; min-width: 0; padding: 6px 10px; background: #0a0d12; color: #e6e9ef; border: 1px solid #2a2f3a; border-radius: 6px; font-size: 13px; font-family: inherit; }
.detail .block.chart-svg .chart-hlines-add input { flex: 1; min-width: 0; padding: 6px 10px; background: #0a0d12; color: #e6e9ef; border: 1px solid #2a2f3a; border-radius: 6px; font-size: 16px; font-family: inherit; }
.detail .block.chart-svg .chart-hlines-add input:focus { outline: none; border-color: #e879f9; }
.detail .block.chart-svg .chart-hlines-add .hl-add-btn { padding: 6px 14px; background: rgba(232,121,249,0.14); color: #e879f9; border: 1px solid rgba(232,121,249,0.5); border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 700; font-family: inherit; white-space: nowrap; }
.detail .block.chart-svg .chart-hlines-add .hl-add-btn:hover { background: rgba(232,121,249,0.24); }
@@ -7760,9 +7843,6 @@ def render_html() -> str:
)
valid_tids_js = ','.join(f"'{tid}'" for tid in valid_tids)
# 자동 갱신 동작 탭 — 전체 탭(자산정보·본인·가희·관심·감시). 감시종목도 카운트다운 동작(2026-06-08 관리자 요청).
# 시세는 이미 실시간(rt-px)이라 풀패널 자동갱신은 미체결·시세외 데이터 재동기화 목적.
auto_refresh_tids_js = ','.join(f"'{tid}'" for tid in valid_tids)
# 탭 활성 동기화 — URL hash → html[data-tab]. CSS-only 전환이라 클릭은 즉시.
# 자산정보 탭으로 진입할 때는 전체 fetch 트리거 — 모든 owner를 한 화면에 모아 보는 요약 탭이라 stale 최소화.
@@ -8934,6 +9014,8 @@ def render_html() -> str:
# dragging 클래스 해제는 반드시 transform 클리어와 동시에. 분리되면 sticky topbar가 .page transform 좌표계에 갇혀 스크롤 따라옴.
'function reset(){c.classList.remove("dragging");c.style.transform="";p.style.transform="";p.style.opacity="";p.classList.remove("committed","spinning");}'
'document.addEventListener("touchstart",function(e){'
# 종목 상세 팝업 열림 중엔 PTR 비활성 — 팝업 내부 스크롤이 전체 새로고침을 부르지 않게.
'if(document.body.classList.contains("detail-open"))return;'
'if(window.scrollY<=0&&e.touches.length===1){'
'sy=e.touches[0].clientY;dy=0;pull=true;ok=false;'
'c.classList.add("dragging");'
@@ -8970,66 +9052,67 @@ def render_html() -> str:
'})();</script>'
)
# 자동 갱신 — 토글 없이 장 열림 중 항상 3s 주기(백그라운드). 계좌/관심 탭 보일 때만 __behive_load.
# 상단 배지는 상태 점 하나만(클릭 불가): 🟢 장중 자동갱신 / ⚪ 장마감. 카운트다운 없음.
# 장 닫힘 중엔 갱신 멈추고 저속 폴링으로 시장 열림만 감지.
# location.reload() 제거 → 깜빡임·탭 클릭 묻힘 없음. fetch 완료 후에 재무장(arm)해 호출 중첩 방지.
# 시장 상태 배지 + 개장/마감 전환 감지 (2026-07-22 이벤트 기반 전환).
# ⚠️ 3초 주기 전면 패널 swap 폐지 — 매 3초 <section> innerHTML 통째 교체가 스크롤·열린 UI를
# 파괴해 기능마다 "3초에도 유지" 처리를 반복해야 했음. 갱신은 이제 이벤트가 담당:
# · 시세·손익·KPI → realtime_script 1초 틱(WS)
# · 체결·주문(접수/취소) → state/fill_signal bump → 1초 틱이 감지해 필요할 때만 1회 갱신
# 여기 스크립트는 상단 상태 점(🟢장중/⚪장마감)만 유지 + 개장/마감 전환 시 1회 재렌더
# (day-change 게이트·시간대 표시 반영). 정상 상태에선 주기적 full swap 없음.
market_active_js = 'true' if market_active else 'false'
auto_reload_script = (
'<script>(function(){'
f'var O=[{auto_refresh_tids_js}];'
f'var MARKET={market_active_js};'
'var SEC=3,TICK=1000,t=null,remain=0;'
# 장 닫힘 동안 시장 열림 감지용 저속 폴링. visible 일 때만 동작.
'var SLOW_MS=60000,slow=null;'
'var SLOW_MS=60000,poll=null,cur=MARKET;'
'var HSEQ_MS=20000,hpoll=null,hseq=null;' # 외부 매매 감지 폴링(장중만)
'var badge=document.getElementById("auto-badge");'
# 배지 점: 장중 초록(.live) / 장마감 어두운 회색. 상태는 marketNow()에만 의존(탭·카운트 무관).
'function dot(){if(badge)badge.classList.toggle("live",marketNow());}'
'function active(){return document.documentElement.getAttribute("data-tab")||"";}'
'function marketNow(){return (typeof window.__behive_market_active!=="undefined")?window.__behive_market_active:MARKET;}'
'function ok(){return marketNow()&&document.visibilityState==="visible"&&O.indexOf(active())>=0;}'
'function stop(){if(t){clearInterval(t);t=null;}}'
'function stopSlow(){if(slow){clearInterval(slow);slow=null;}}'
'function startSlow(){'
'stopSlow();'
'slow=setInterval(function(){'
'if(marketNow()){stopSlow();return;}'
# 배지 점: 장중 초록(.live) / 장마감 어두운 회색. 공유 플래그(apply·폴링이 함께 갱신) 우선, 없으면 cur.
'function dot(){var m=(typeof window.__behive_market_active!=="undefined")?!!window.__behive_market_active:cur;if(badge)badge.classList.toggle("live",m);}'
'function stopPoll(){if(poll){clearInterval(poll);poll=null;}}'
# 60초마다 /api/market_state(키움 미경유·시각+휴장일만) 확인 → 배지 갱신 + 개장/마감 전환 시에만 1회 재렌더.
'function startPoll(){'
'stopPoll();'
'poll=setInterval(function(){'
'if(document.visibilityState!=="visible")return;'
'if(window.__behive_load)window.__behive_load();'
'fetch("/api/market_state",{cache:"no-store",credentials:"same-origin"})'
'.then(function(r){return r.json();})'
'.then(function(s){'
'var act=!!(s&&s.active);'
'if(act===cur)return;'
'cur=act;'
# set_market 이 공유 플래그 갱신 + arm(=dot) 호출. 없을 때만 직접 dot.
'if(window.__behive_set_market)window.__behive_set_market(act);else dot();'
# 전환(장 시작/마감) 시에만 1회 전체 재렌더 — day-change 게이트·시간대 라벨 반영.
'if(window.__behive_load)window.__behive_load();'
'}).catch(function(){});'
'},SLOW_MS);'
'}'
'function arm(){'
'stop();'
'if(marketNow())stopSlow();else startSlow();'
'dot();'
'if(!ok())return;'
'remain=SEC;'
't=setInterval(function(){'
'if(!ok()){stop();dot();return;}'
# 차트 툴팁 떠 있는 동안 자동 갱신 미룸 — swap으로 사라지는 거 방지.
'var tt=document.querySelectorAll(".net-chart-tip");'
'for(var ti=0;ti<tt.length;ti++){if(tt[ti].style.opacity==="1")return;}'
# select 드롭다운 열림 상태(=focus)에서도 갱신 미룸 — 사용자가 옵션 고르는 중.
'var ae=document.activeElement;'
'if(ae&&ae.tagName==="SELECT")return;'
# 관심종목 '+' 메뉴 열림 중엔 갱신 미룸 — 패널 swap 으로 메뉴가 닫히는 것 방지.
'if(document.querySelector("[data-igrp-menu]:not([hidden])"))return;'
'remain-=1;'
'if(remain<=0){'
'stop();'
'var a=active();'
'var ow=(a&&a.indexOf("tab-")===0)?a.slice(4):null;'
'var p=window.__behive_load?window.__behive_load(ow):Promise.resolve();'
'p.then(arm,arm);'
'return;'
'}'
'},TICK);'
# 외부 매매(키움 앱 등) 감지 — 장중 20s 간격 /api/holdings_seq 폴링. 보유 시그니처가 바뀌면
# (앱 직거래·타 기기 체결 등 fill_signal 안 오는 변화 포함) 패널 1회 fresh 갱신. 첫 관측은 기준만.
'function stopHPoll(){if(hpoll){clearInterval(hpoll);hpoll=null;}}'
'function startHPoll(){'
'stopHPoll();'
'hpoll=setInterval(function(){'
'if(document.visibilityState!=="visible")return;'
'if(window.__behive_market_active===false)return;' # 장외엔 외부 매매 없음 → 키움 콜 아낌
'fetch("/api/holdings_seq",{cache:"no-store",credentials:"same-origin"})'
'.then(function(r){return r.json();})'
'.then(function(d){'
'if(!d||!d.seq)return;'
'if(hseq===null){hseq=d.seq;return;}' # 첫 관측은 기준값만 저장
'if(hseq!==d.seq){'
'hseq=d.seq;'
'if(window.__behive_load)window.__behive_load(window.__behive_active_owner?window.__behive_active_owner():null,true);'
'}'
'}).catch(function(){});'
'},HSEQ_MS);'
'}'
# __behive_auto_arm — 수동 새로고침·PTR 완료 후 호출됨. 이제 배지만 재확인(주기 재무장 없음).
'function arm(){dot();}'
'window.__behive_auto_arm=arm;'
'document.addEventListener("visibilitychange",arm);'
'addEventListener("hashchange",arm);'
'document.addEventListener("visibilitychange",dot);'
'if(window.__behive_set_market)window.__behive_set_market(MARKET);'
'arm();'
'dot();startPoll();startHPoll();'
'})();</script>'
)
@@ -10240,6 +10323,131 @@ def render_html() -> str:
'});'
'}'
'},true);'
'})();'
# 종목 상세 팝업 — <details> open 시 백드롭 + 하단 sticky 액션 바(마지막 항목=닫기 ✕) 표시. 백드롭/✕/ESC 로 닫음.
# 표시 전환만 담당(차트·상태 JS 불변). body 스크롤은 detail-open 클래스로 잠금.
'(function(){'
'var bd=null;'
'function closeAll(){'
'document.querySelectorAll("details.row[open]").forEach(function(d){d.removeAttribute("open");});'
'sync();'
'}'
'function anyOpen(){return !!document.querySelector("details.row[open]");}'
'function ensureBackdrop(){'
'if(bd)return;'
'bd=document.createElement("div");bd.id="detail-backdrop";bd.hidden=true;'
'bd.addEventListener("click",closeAll);'
'document.body.appendChild(bd);'
'}'
# 종목 변경 드롭다운 데이터(보유/관심/감시) — 1회 fetch 후 캐시, 모든 select 에 주입.
'var _symHtml=null,_symLoading=false;'
'function fillSelects(){'
'if(!_symHtml)return;'
'document.querySelectorAll("select.detail-stock-switch:not([data-filled])").forEach(function(sel){'
'sel.innerHTML=_symHtml;sel.setAttribute("data-filled","1");'
'var cur=sel.getAttribute("data-cur")||"";if(cur)sel.value=cur;'
'});'
'}'
'function loadSymbols(){'
'if(_symHtml||_symLoading)return;'
'_symLoading=true;'
'fetch("/api/symbols/all?split_owner=1",{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(dat){'
'var items=(dat&&dat.items)||[];'
# 보유는 소유자(관리자/가희)별로 분리, 관심·감시는 그대로. 각 그룹의 탭으로 data-tab 지정.
'var G=[{lb:"관리자 보유",tab:"tab-self",f:function(it){return it.source==="보유"&&it.owner!=="가희";}},'
'{lb:"가희 보유",tab:"tab-gahee",f:function(it){return it.source==="보유"&&it.owner==="가희";}},'
'{lb:"관심",tab:"tab-interests",f:function(it){return it.source==="관심";}},'
'{lb:"감시",tab:"tab-wl",f:function(it){return it.source==="감시";}}];'
'var h=\'<option value="">종목 변경…</option>\';'
'G.forEach(function(g){'
'var rows=items.filter(g.f);if(!rows.length)return;'
'h+=\'<optgroup label="\'+g.lb+\'">\';'
'rows.forEach(function(it){'
'var nm=(it.name||it.code).replace(/</g,"&lt;").replace(/"/g,"&quot;");'
'h+=\'<option value="\'+it.code+\'" data-tab="\'+g.tab+\'">\'+nm+\'</option>\';'
'});'
'h+="</optgroup>";'
'});'
'_symHtml=h;_symLoading=false;fillSelects();'
'}).catch(function(){_symLoading=false;});'
'}'
# 종목 전환 — 대상 탭으로 바꾼 뒤 해당 행을 열어 팝업 교체. (숨은 탭 행은 display:none 이라 탭 전환 필수)
'function openStock(code,tab){'
'document.querySelectorAll("details.row[open]").forEach(function(d){d.removeAttribute("open");});'
'if(tab){document.documentElement.setAttribute("data-tab",tab);if(window.__behive_set_meta_active)window.__behive_set_meta_active();}'
'var rows=document.querySelectorAll(\'details.row[data-row-key="\'+code+\'"],details.row[data-row-key^="\'+code+\':"]\');'
'rows.forEach(function(r){r.setAttribute("open","");});'
'sync();'
'}'
# 열린 팝업의 액션 버튼(.actions)을 맨 끝으로 옮겨 sticky footer 로. 버튼 위=종목변경 select, 마지막=닫기.
# 원래 DOM 순서상 차트 위에 있어 sticky 만으로는 하단 고정 안 됨 → 위치 이동. .actions 없는 팝업(phantom/pending)엔 생성.
'function ensureActionFooters(){'
'document.querySelectorAll("details.row[open] > .detail").forEach(function(d){'
'var act=d.querySelector(":scope > .actions");'
'if(!act){act=document.createElement("div");act.className="actions";d.appendChild(act);}'
'if(!act.classList.contains("detail-actions-footer")){act.classList.add("detail-actions-footer");d.appendChild(act);}'
# 종목 변경 드롭다운 — 버튼 위(첫 항목)
'if(!act.querySelector(":scope > .detail-stock-switch")){'
'var sw=document.createElement("select");sw.className="detail-stock-switch";'
'var rk=(d.parentNode&&d.parentNode.getAttribute)?(d.parentNode.getAttribute("data-row-key")||""):"";'
'sw.setAttribute("data-cur",rk.split(":")[0]);'
'sw.innerHTML=\'<option value="">종목 변경…</option>\';'
'act.insertBefore(sw,act.firstChild);'
'}'
# 닫기 버튼 — 마지막 항목 (중복 방지)
'if(!act.querySelector(":scope > .btn-detail-close")){'
'var x=document.createElement("button");x.type="button";x.className="btn-detail-close";'
'x.setAttribute("aria-label","닫기");x.textContent="✕ 닫기";'
'x.addEventListener("click",closeAll);'
'act.appendChild(x);'
'}'
'});'
'loadSymbols();fillSelects();'
'}'
# 팝업 상단 sticky 헤더 — 종목명(+소유자 배지)·현재가를 묶어 최상단 고정. 스크롤해도 유지.
'function ensureHeaders(){'
'document.querySelectorAll("details.row[open] > .detail").forEach(function(d){'
'if(d.querySelector(":scope > .detail-header"))return;'
'var name=d.querySelector(":scope > .detail-name");'
'var simple=d.querySelector(":scope > .detail-simple");'
'if(!name&&!simple)return;'
'var hdr=document.createElement("div");hdr.className="detail-header";'
'd.insertBefore(hdr,d.firstChild);'
'if(name)hdr.appendChild(name);'
'if(simple)hdr.appendChild(simple);'
'});'
'}'
'function sync(){'
'ensureBackdrop();'
'var on=anyOpen();'
'bd.hidden=!on;'
'if(on){ensureHeaders();ensureActionFooters();}'
'document.body.classList.toggle("detail-open",on);'
'}'
# 종목 변경 select 선택 → 전환.
'document.addEventListener("change",function(e){'
'var sel=e.target;'
'if(!sel||!sel.classList||!sel.classList.contains("detail-stock-switch"))return;'
'var code=sel.value;if(!code)return;'
'var opt=sel.selectedOptions&&sel.selectedOptions[0];'
'openStock(code,opt?opt.getAttribute("data-tab"):null);'
'});'
'document.addEventListener("toggle",function(e){'
'var d=e.target;'
'if(!d||d.nodeName!=="DETAILS"||!d.classList.contains("row"))return;'
'sync();'
# 팝업 열릴 때 스크롤 맨 위로 초기화 (이전 스크롤 위치 잔존 방지)
'if(d.open){var det=d.querySelector(":scope > .detail");if(det)det.scrollTop=0;}'
'},true);'
# ESC — 단, 위에 뜬 정식 모달(.modal)이 있으면 그쪽 우선.
'document.addEventListener("keydown",function(e){'
'if(e.key!=="Escape"||!anyOpen())return;'
'if(document.querySelector(".modal:not(.hidden)"))return;'
'closeAll();'
'});'
'document.addEventListener("behive:panels-loaded",sync);'
'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",sync);'
'else sync();'
'})();</script>'
)
@@ -11450,7 +11658,7 @@ window.openPinModal = openPinModal;
document.querySelectorAll(selByCode(code)).forEach(function(r){
r.querySelectorAll('.rt-px').forEach(function(el){ if(el.textContent!==t) el.textContent=t; }); // 바뀔 때만 깜빡임 방지
// 관심·감시 등락률 KRX 기준이라 0B 피드 pct를 그대로 사용(보유행은 NXT 기준이라 paintDay가 따로 처리).
var dp=r.querySelector('.rt-wl-daypct'); if(dp){ dp.textContent=pctstr; setUD(dp,q.pct); }
r.querySelectorAll('.rt-wl-daypct').forEach(function(dp){ dp.textContent=pctstr; setUD(dp,q.pct); }); // summary+심플정보 팝업 사본 모두 갱신
});
}
function paintVI(code,active){
@@ -11486,7 +11694,7 @@ window.openPinModal = openPinModal;
var qty=parseFloat(r.getAttribute('data-rt-qty')||'0');
if(pc<=0) return;
var dc=price-pc, pct=(price/pc-1)*100, dv=dc*qty;
var sp=r.querySelector('.rt-daypct'); if(sp){ sp.textContent=(pct>=0?'+':'')+pct.toFixed(2)+'%'; setUD(sp,dc); }
r.querySelectorAll('.rt-daypct').forEach(function(sp){ sp.textContent=(pct>=0?'+':'')+pct.toFixed(2)+'%'; setUD(sp,dc); }); // summary+심플정보 팝업 사본 모두 갱신
var sv=r.querySelector('.rt-dayvalue'); if(sv){ sv.textContent=sgn(dv)+''; setUD(sv,dc); }
var sd=r.querySelector('.rt-daychange'); if(sd){ sd.textContent=sgn(dc)+'원 ('+(pct>=0?'+':'')+pct.toFixed(2)+'%)'; setUD(sd,dc); }
});
@@ -11654,6 +11862,64 @@ window.openPinModal = openPinModal;
})();</script>
<style>.rt-vi-badge{display:inline-block;margin-left:6px;padding:0 5px;border-radius:4px;font-size:10px;font-weight:700;color:#1a1205;background:#ffd166;vertical-align:middle;cursor:pointer}</style>'''
# 수급 블록 — 팝업 .flow-box 를 열 때 lazy fetch(/api/investor_flow). 5일 표 + 더보기(20일 오버레이).
flow_script = r'''<script>(function(){
var cache={};
function fmt(n){n=n||0;var s=n>0?'+':(n<0?'':'');var a=Math.abs(n);var t;
if(a>=100000000)t=(a/100000000).toFixed(2)+'';
else if(a>=10000)t=(a/10000).toFixed(1)+'';
else t=a.toLocaleString();
return s+t;}
function cls(n){return n>0?'up':(n<0?'down':'neu');}
function fmtDate(d){d=(d||'').replace(/[^0-9]/g,'');return d.length>=8?(d.slice(4,6)+'/'+d.slice(6,8)):d;}
function rowHtml(r){return '<tr><td class="flow-d">'+fmtDate(r.date)+'</td>'+
'<td class="flow-n '+cls(r.foreign)+'">'+fmt(r.foreign)+'</td>'+
'<td class="flow-n '+cls(r.institution)+'">'+fmt(r.institution)+'</td>'+
'<td class="flow-n '+cls(r.individual)+'">'+fmt(r.individual)+'</td></tr>';}
function tableHtml(rows){var h='<table class="flow-tbl"><thead><tr><th>날짜</th><th>외국인</th><th>기관</th><th>개인</th></tr></thead><tbody>';
rows.forEach(function(r){h+=rowHtml(r);});return h+'</tbody></table>';}
function render(box,rows){
var code=box.getAttribute('data-flow-code');cache[code]=rows;
if(!rows.length){box.innerHTML='<div class="flow-title flow-err">수급 데이터 없음</div>';return;}
var h='<div class="flow-title">외국인·기관·개인 수급 <span class="flow-unit">(주, +매수 · 최근 5일)</span></div>';
h+=tableHtml(rows.slice(0,5));
if(rows.length>5)h+='<button type="button" class="flow-more" data-flow-more="'+code+'">더보기 (20일)</button>';
box.innerHTML=h;
}
function loadOpen(){
document.querySelectorAll('details.row[open] .flow-box[data-flow-code]').forEach(function(box){
if(box.getAttribute('data-loaded'))return;
var code=box.getAttribute('data-flow-code');if(!code)return;
box.setAttribute('data-loaded','1');
if(cache[code]){render(box,cache[code]);return;}
box.innerHTML='<div class="flow-title">수급 불러오는 중…</div>';
fetch('/api/investor_flow?code='+encodeURIComponent(code),{credentials:'same-origin'})
.then(function(r){return r.json();})
.then(function(d){if(d&&d.rows)render(box,d.rows);else box.innerHTML='<div class="flow-title flow-err">수급 데이터 없음</div>';})
.catch(function(){box.removeAttribute('data-loaded');box.innerHTML='<div class="flow-title flow-err">수급 조회 실패</div>';});
});
}
function openFlowModal(code,rows){
var ov=document.createElement('div');ov.className='flow-modal';
ov.innerHTML='<div class="flow-modal-ov"></div><div class="flow-modal-box">'+
'<div class="flow-modal-head"><span>외국인·기관·개인 수급 · 20일 <span class="flow-unit">(주, +매수)</span></span>'+
'<button type="button" class="flow-modal-x" aria-label="닫기">✕</button></div>'+tableHtml(rows)+'</div>';
function close(){document.removeEventListener('keydown',esc,true);ov.remove();}
function esc(e){if(e.key==='Escape'){e.stopPropagation();close();}}
ov.querySelector('.flow-modal-ov').addEventListener('click',close);
ov.querySelector('.flow-modal-x').addEventListener('click',close);
document.addEventListener('keydown',esc,true);
document.body.appendChild(ov);
}
document.addEventListener('click',function(e){
var b=e.target.closest&&e.target.closest('[data-flow-more]');if(!b)return;
var code=b.getAttribute('data-flow-more');var rows=cache[code];if(rows)openFlowModal(code,rows);
});
document.addEventListener('behive:panels-loaded',loadOpen);
document.addEventListener('toggle',function(e){var d=e.target;if(d&&d.nodeName==='DETAILS'&&d.classList&&d.classList.contains('row')&&d.open)loadOpen();},true);
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',loadOpen);else loadOpen();
})();</script>'''
return f'''<!doctype html>
<html lang="ko">
<head>
@@ -11731,6 +11997,7 @@ window.openPinModal = openPinModal;
{modal_script}
{order_modal_script}
{stock_name_tip_script}
{flow_script}
{realtime_script}
</body>
</html>'''
@@ -11748,6 +12015,46 @@ _panels_cache: dict[str, dict] = {} # cache_key('all'|owner) → {'json': bytes
_panels_locks: dict[str, threading.Lock] = {}
_panels_locks_master = threading.Lock()
# 외부 매매(키움 앱 등) 감지 — fill_watcher는 웹/orders 경유 주문만 fill_signal을 bump하므로,
# 앱에서 직접 매매해 바뀐 보유수량은 이벤트 신호가 없다. 전 계좌 보유 시그니처를 브라우저가 짧게
# 폴링(20s)해 값이 바뀌면 패널을 1회 갱신. 서버는 TTL 캐시로 kt00018 콜을 20s당 1회로 제한
# (다중 탭·기기 공유). 폐지된 3초 전면 swap 대비 훨씬 가볍고, 브라우저는 장중에만 폴링.
_HOLDINGS_SEQ_TTL = 20.0
_holdings_seq_cache: dict = {'seq': None, 'expires_at': 0.0}
_holdings_seq_lock = threading.Lock()
# 종목별 외국인·기관·개인 수급 (ka10059) — 팝업 lazy fetch용. code별 TTL 캐시(재열람 시 재조회 방지).
_FLOW_TTL = 120.0
_flow_cache: dict = {} # code -> (rows, expires_at)
_flow_lock = threading.Lock()
def _holdings_seq() -> str:
"""전 계좌 보유 (code:qty:당일매수:당일매도) 해시. 외부 매매·체결로 수량이 변하면 값이 바뀐다.
당일매수/매도 수량까지 넣어 net 무변동 round-trip도 포착. TTL 캐시로 kt00018 폭주 방지.
실패 마지막 (또는 '') 반환 폴링이 조용히 재시도."""
now = time.time()
with _holdings_seq_lock:
if _holdings_seq_cache['seq'] is not None and now < _holdings_seq_cache['expires_at']:
return _holdings_seq_cache['seq']
try:
import kiwoom_client as kc
import hashlib
pos = kc.get_positions_all() # {label: [positions]}
parts: list[str] = []
for label in sorted(pos):
for p in sorted(pos[label], key=lambda x: x.get('code') or ''):
parts.append(f"{label}|{p.get('code')}:{p.get('qty')}:{p.get('tdy_buyq')}:{p.get('tdy_sellq')}")
seq = hashlib.md5('\n'.join(parts).encode('utf-8')).hexdigest()
except Exception:
traceback.print_exc()
with _holdings_seq_lock:
return _holdings_seq_cache['seq'] or ''
with _holdings_seq_lock:
_holdings_seq_cache['seq'] = seq
_holdings_seq_cache['expires_at'] = time.time() + _HOLDINGS_SEQ_TTL
return seq
# 데이터 레이어 캐시 — owner별 raw owner_data + balances 보존.
# 자산정보 패널은 owner별 KPI 카드 스택이라 owner_data만으로 재렌더 가능. partial fetch가
# 자기 owner 슬라이스를 새로 채울 때 'all' 캐시의 summary 슬라이스와 해당 owner 탭만 in-place로
@@ -12017,7 +12324,12 @@ class Handler(BaseHTTPRequestHandler):
# 거래 모달 상단 종목 변경 드롭다운용 — 보유+관심+감시 통합 (dedup by code)
try:
import kiwoom_client as kc
seen: set = set()
# split_owner=1: 보유를 소유자(본인/가희)별로 분리(같은 종목도 소유자마다 1건). 팝업 종목변경 드롭다운용.
# 기본(0): code 전역 dedup — 계산기·주문모달 등 기존 소비자 호환.
_sq = parse_qs(parsed.query)
split_owner = (_sq.get('split_owner') or ['0'])[0] == '1'
seen: set = set() # 관심/감시 dedup (보유 코드 포함)
held_owner_seen: set = set() # split_owner 시 (owner, code)
items: list = []
# 보유 (4계좌)
try:
@@ -12026,7 +12338,13 @@ class Handler(BaseHTTPRequestHandler):
owner = '가희' if label.startswith('가희_') else '본인'
for p in positions:
code = p.get('code')
if not code or code in seen:
if not code:
continue
if split_owner:
if (owner, code) in held_owner_seen:
continue
held_owner_seen.add((owner, code))
elif code in seen:
continue
seen.add(code)
items.append({'code': code, 'name': p.get('name', ''), 'source': '보유', 'owner': owner})
@@ -12148,6 +12466,42 @@ class Handler(BaseHTTPRequestHandler):
traceback.print_exc()
self._send_json(500, {'error': f'market_state failed: {e}'})
return
if parsed.path == '/api/holdings_seq':
# 외부 매매 감지용 보유 시그니처 (TTL 캐시). 브라우저가 장중 20s 폴링 → 변화 시 패널 1회 갱신.
try:
self._send_json(200, {'seq': _holdings_seq()})
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'holdings_seq failed: {e}'})
return
if parsed.path == '/api/investor_flow':
# 종목별 외국인·기관·개인 일별 순매수 (ka10059, 최신순 20일). 팝업 수급 블록 lazy fetch.
qs = parse_qs(parsed.query)
code = ''.join(ch for ch in (qs.get('code') or [''])[0].strip() if ch.isalnum())
if not code:
self._send_json(400, {'error': 'code required'})
return
now = time.time()
with _flow_lock:
hit = _flow_cache.get(code)
if hit and now < hit[1]:
self._send_json(200, {'rows': hit[0]})
return
try:
import kiwoom_client as kc
raw = kc.get_investor_flow(code, days=20) or []
# get_investor_flow 는 천주 단위(unit_tp=1000) → 주 단위로 환산해 반환.
rows = [{'date': r.get('date'),
'foreign': (r.get('foreign') or 0) * 1000,
'institution': (r.get('institution') or 0) * 1000,
'individual': (r.get('individual') or 0) * 1000} for r in raw]
with _flow_lock:
_flow_cache[code] = (rows, time.time() + _FLOW_TTL)
self._send_json(200, {'rows': rows})
except Exception as e:
traceback.print_exc()
self._send_json(500, {'error': f'investor_flow failed: {e}'})
return
if parsed.path == '/api/quote_book':
# 거래 모달 호가창용. ka10004(호가 10단계+잔량) + ka10001(현재가·등락) 묶음.
# 1초 polling 가정 — visibility 가드는 클라이언트 측 책임.
@@ -12872,6 +13226,8 @@ class Handler(BaseHTTPRequestHandler):
traceback.print_exc()
self._send_json(500, {'ok': False, 'error': f'propose failed: {e}'})
return
if res.get('ok'):
_bump_panel_signal() # PIN 대기 배지(매수예정/매도예정) 즉시 반영
status = 200 if res.get('ok') else 400
self._send_json(status, res)
return
@@ -12936,6 +13292,8 @@ class Handler(BaseHTTPRequestHandler):
traceback.print_exc()
self._send_json(500, {'ok': False, 'error': f'propose_multi failed: {e}'})
return
if res.get('ok'):
_bump_panel_signal() # PIN 대기 배지 즉시 반영
self._send_json(200 if res.get('ok') else 400, res)
return
@@ -12959,6 +13317,8 @@ class Handler(BaseHTTPRequestHandler):
traceback.print_exc()
self._send_json(500, {'ok': False, 'error': f'verify failed: {e}'})
return
if res.get('ok'):
_bump_panel_signal() # 주문 접수 → 미체결 배지(매수대기/매도대기) 반영 (체결은 fill_watcher가 별도 bump)
status = 200 if res.get('ok') else 400
self._send_json(status, res)
return
@@ -12983,6 +13343,8 @@ class Handler(BaseHTTPRequestHandler):
traceback.print_exc()
self._send_json(500, {'ok': False, 'error': f'open order cancel failed: {e}'})
return
if res.get('ok'):
_bump_panel_signal() # 미체결 취소 → 배지 제거 반영
self._send_json(200 if res.get('ok') else 400, res)
return
if self.path == '/api/order/cancel':
@@ -12991,6 +13353,8 @@ class Handler(BaseHTTPRequestHandler):
from orders import handler
# cancel_active_card 반환은 dict {'ok','message'} — 활성 카드 없어도 베스트에포트라 200.
res = handler.cancel_active_card()
if res.get('ok'):
_bump_panel_signal() # 실제 활성 카드 취소 시만 → PIN 대기 배지 제거 (카드 없이 닫으면 reload 안 함)
self._send_json(200, {'ok': True, 'message': res.get('message') or '카드 취소됨'})
except Exception as e:
traceback.print_exc()