#!/usr/bin/env python3
"""비하이브 워치리스트 실시간 웹 뷰. 페이지 열기·새로고침 시점에 키움 ka10095 batch 호출.

GET / → watchlist 카드형 HTML 한 페이지로 응답. JS·외부 리소스 없음 (인라인 CSS만).
GET /api/panels[?owner=self|gahee] → 패널 JSON (자동 갱신·partial swap용).
POST /{delete,restore,purge} → 감시종목 lifecycle (watchlist).
POST /interests/{add,update,delete} → 관심종목 수동 관리. JSON 응답 (Accept: application/json).

서버 바인드는 Tailscale IP(`100.75.148.12`)로 제한 — 외부망 직접 도달 차단.
외부 노출은 NAS reverse proxy(`stock.hyowons.net` → mac:18790) 경유.
write 엔드포인트는 인증·CSRF 가드 없음 — Tailnet 내부망 한정 운영을 전제.

CLI:
  python3 behive_web.py serve            # 서버 기동 (launchd가 호출)
  python3 behive_web.py render [--out FILE]   # HTML 한 번 렌더 후 출력 (디버깅용)
"""
from __future__ import annotations

import argparse
import gzip
import html
import json
import sys
import threading
import time
import traceback
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from zoneinfo import ZoneInfo

WORKSPACE = Path(__file__).resolve().parent.parent
# 빌드 식별자 — 파일 mtime 기반, import 시 1회 계산. 코드 수정 후 데몬 재시작하면 값이 바뀐다.
# 클라이언트가 shell 로드 시 저장한 __build 와 /api/panels 응답의 build 가 다르면 1회 자동 리로드
# → SPA식 fetch 갱신(location.reload 없음)이 옛 인라인 JS/CSS를 계속 쓰던 문제 해소.
try:
    BUILD = str(int(Path(__file__).resolve().stat().st_mtime))
except Exception:
    BUILD = '0'
WATCHLIST = WORKSPACE / 'state' / 'behive_watchlist.json'
INTERESTS = WORKSPACE / 'state' / 'behive_interests.json'
STOCK_TAGS = WORKSPACE / 'state' / 'behive_stock_tags.json'
STOCK_NOTES = WORKSPACE / 'state' / 'behive_stock_notes.json'
CHART_HLINES = WORKSPACE / 'state' / 'behive_chart_hlines.json'
INTEREST_GROUPS = WORKSPACE / 'state' / 'behive_interest_groups.json'
HOLDEVAL_SAVED = WORKSPACE / 'state' / 'behive_holdeval_saved.json'
SURGE_TOGGLES = WORKSPACE / 'state' / 'behive_surge_toggles.json'
ALERTS_STATE = WORKSPACE / 'state' / 'watchlist_alerts.json'
HOLIDAYS_FILE = WORKSPACE / 'state' / 'market_holidays.json'
SNAPSHOT_FILE = WORKSPACE / 'state' / 'portfolio_daily_snapshot.json'
TRADE_JOURNAL_FILE = WORKSPACE / 'state' / 'trade_journal.jsonl'
MARKET_HISTORY_FILE = WORKSPACE / 'state' / 'market_indicators_history.jsonl'
ADR_TREND_DAYS = 30  # 시장정보 sub-tab ADR 추세 차트 default 기간 (영업일 기준 최근 N행)
# (label, days) — days=-1 전체. UI 버튼·서버 sanity check·localStorage 허용값 모두 단일 진실 출처.
ADR_TREND_PRESETS = [('1주', 7), ('1달', 30), ('3달', 90), ('6달', 180), ('1년', 365), ('전체', -1)]
ADR_MA_WINDOW = 20  # ADR 이동평균 윈도우. 카드 수치·차트 모두 이 값 사용. 가용 데이터 < window 면 평균 산출 안 함
NET_WORTH_CHART_DAYS = -2  # default = 이번달. 클라이언트가 ?chart_days로 override
# (label, days) — 클라이언트 UI 옵션. days=-1 전체, -2 이번달(KST 기준), -3 이번주(이번 ISO 월요일~오늘).
NET_WORTH_CHART_PRESETS = [('전체보기', -1), ('올해', -4), ('이번달', -2), ('이번주', -3)]
# 차트 단위 토글 — 각 구간 마지막 영업일 net_worth만 표시. 'day'=기본 일별.
NET_WORTH_UNIT_PRESETS = [('일별', 'day'), ('주별', 'week'), ('월별', 'month'), ('연별', 'year')]
NET_WORTH_CHART_UNIT = 'day'
# 단위 → tooltip 손익 라벨 매핑
_NET_WORTH_DELTA_LABEL = {'day': '그날 손익', 'week': '주간 손익', 'month': '월간 손익', 'year': '연간 손익'}
# 기간별 허용 단위 — 의미없는 조합(이번주+월별 등) 비활성용. 기간이 단위 너비보다 좁으면 비허용.
NET_WORTH_ALLOWED_UNITS_BY_DAYS: dict[int, tuple[str, ...]] = {
    -3: ('day',),                              # 이번주 — day 만 (주별은 한 점이라 의미 없음)
    -2: ('day', 'week'),                       # 이번달 — month 빼고 (월별은 한 점이라 의미 없음)
    -4: ('day', 'week', 'month'),              # 올해 — year 빼고 (연별은 한 점이라 의미 없음)
    -1: ('day', 'week', 'month', 'year'),      # 전체 — 모두
}
# 차트 표시 모드 — 'pnl'=시작점 baseline=0 누적손익(기본), 'net'=순자산 시계열.
# 'pnl'=시작점 baseline=0 누적손익(전부 누적), 'period'=각 점이 그 기간(일/주/월/연) 자체 손익, 'net'=순자산 시계열.
NET_WORTH_MODE_PRESETS = [('총자산', 'net'), ('손익누적', 'pnl'), ('기간손익', 'period')]
NET_WORTH_CHART_MODE = 'pnl'
KST = ZoneInfo('Asia/Seoul')
BIND_HOST = ''  # 모든 인터페이스 (Tailscale + LAN). 외부망 직접 차단은 공유기 NAT가 담당.
BIND_PORT = 18790
QUOTE_WORKERS = 8
QUOTE_CACHE_TTL = 30.0  # 첫 cold 8s 동안 분산 박힌 캐시가 다음 RENDER 만료(10s) 시점에 살아있도록. 가격 stale 최대 30s.

sys.path.insert(0, str(WORKSPACE / 'scripts'))
sys.path.insert(0, str(WORKSPACE))  # sim 패키지(from sim import signals, config) import 용


_holidays_cache: dict = {'mtime': 0.0, 'set': set()}
_traded_codes_cache: dict = {'mtime': 0.0, 'set': set()}
_holding_since_cache: dict = {'mtime': None, 'map': {}}
_quote_cache: dict[str, tuple[dict, float]] = {}
_quote_cache_lock = threading.Lock()

# 계좌별 미체결(ka10075) 캐시 — 거래 모달 max_qty 계산용. 미체결은 가격과 무관하므로
# 단가 keystroke마다 재조회하지 않도록 짧은 TTL로 묶는다. 주문 접수 후 프론트가 재조회하면
# 최대 TTL 만큼 stale할 수 있으나, propose/키움 서버가 최종 검증하므로 표시용으론 충분.
OPEN_ORDERS_CACHE_TTL = 15.0
_open_orders_cache: dict[str, tuple[list, float]] = {}
_open_orders_cache_lock = threading.Lock()


def _cached_open_orders(account_label: str) -> list:
    """계좌 미체결 주문 목록 (ka10075) — TTL 캐시. 실패 시 빈 리스트."""
    now = time.time()
    with _open_orders_cache_lock:
        hit = _open_orders_cache.get(account_label)
        if hit and (now - hit[1]) < OPEN_ORDERS_CACHE_TTL:
            return hit[0]
    try:
        import kiwoom_client as kc
        rows = kc.get_open_orders(account_label)
    except Exception:
        return []
    with _open_orders_cache_lock:
        _open_orders_cache[account_label] = (rows, now)
    return rows


def _pending_buy_lock(account_label: str) -> int:
    """계좌의 미체결 매수 지정가로 묶인 예수금 합계. d2_entra는 이 금액을 아직 포함하므로
    실제 매수가능액 = d2_entra − 이 값. 시장가(price=0) 미체결은 금액 미상이라 제외(render 경로와 동일)."""
    total = 0
    for o in _cached_open_orders(account_label):
        if o.get('side') == 'BUY':
            px = o.get('order_price') or 0
            if px > 0:
                total += px * (o.get('unfilled_qty') or 0)
    return total


def _pending_sell_qty(account_label: str, code: str) -> int:
    """계좌·종목의 미체결 매도 수량 합계."""
    total = 0
    for o in _cached_open_orders(account_label):
        if o.get('side') == 'SELL' and o.get('code') == code:
            total += o.get('unfilled_qty') or 0
    return total


# 기술적 권장가(매수/매도 판단 보조) 캐시 — 완성된 일봉 기반이라 장중 안정적.
# 키: f'{code}:{side}:{avg_price}'. 현재가 대비 비교는 클라이언트가 실시간 시세로 처리.
ADVICE_CACHE_TTL = 120.0
_advice_cache: dict[str, tuple[dict, float]] = {}
_advice_cache_lock = threading.Lock()


def _price_advice(code: str, side: str, avg_price: int | None = None) -> dict | None:
    """일봉 기반 기술적 권장 매수가/매도가. 근거는 이평·ATR만(sim.signals 규칙 재사용).

    반환: {'levels': [{label, price, hint, tap}], 'atr','sma20','rsi','price','recent_low','recent_high'}
    데이터 부족(신규주)·ETF 등으로 지표 산출 불가 시 None (예외 raise 안 함).
    ⚠️ 상수는 활성 sim.config 사용 — params.json 튜닝이 걸려있으면 그 값이 반영됨(현재는 문서화된 기본값).
    """
    side = (side or '').upper()
    cache_key = f'{code}:{side}:{avg_price or 0}'
    now = time.time()
    with _advice_cache_lock:
        hit = _advice_cache.get(cache_key)
        if hit and (now - hit[1]) < ADVICE_CACHE_TTL:
            return hit[0]
    try:
        from sim import signals, config
        import daily_candles_cache as dcc
        candles = dcc.get_candles(code, count=250)
        ind = signals.compute_indicators(candles) if candles else None
    except Exception:
        traceback.print_exc()
        ind = None
    if not ind:
        with _advice_cache_lock:
            _advice_cache[cache_key] = (None, now)
        return None

    price = ind['price']
    atr = ind['atr']
    sma20 = ind['sma_long']
    prev_close = ind['prev_close']
    recent_high = ind['recent_high']
    recent_low = ind['recent_low']
    rsi = ind.get('rsi')
    levels: list[dict] = []

    if side == 'BUY':
        pullback = int(round(max(sma20, prev_close - atr * config.PULLBACK_ATR_MULT)))
        p_stop, p_target = signals.compute_stop_target(pullback, atr, recent_low)
        levels.append({
            'label': '눌림목 매수가', 'price': pullback, 'tap': True,
            'hint': f'20일선({sma20:,.0f})과 전일종가−ATR({prev_close - atr * config.PULLBACK_ATR_MULT:,.0f}) 중 높은 값. '
                    f'여기까지 눌리면 매수 검토(지정가로 걸어둠).',
        })
        if recent_high:
            levels.append({
                'label': '돌파 기준가', 'price': int(recent_high), 'tap': True,
                'hint': f'최근 {config.BREAKOUT_LOOKBACK}일 전고점. 이 위로 거래량 실려 뚫으면 돌파 매수 신호.',
            })
        levels.append({
            'label': '(눌림목 매수 시) 예상 손절', 'price': p_stop, 'tap': False,
            'hint': f'매수가−ATR×{config.STOP_ATR_MULT:g}와 최근 {config.SWING_LOW_LOOKBACK}일 저점 중 위쪽. 여기 깨지면 손절.',
        })
        levels.append({
            'label': '(눌림목 매수 시) 목표가', 'price': p_target, 'tap': False,
            'hint': f'손익비 {config.RR_RATIO:g}:1 — 손절폭의 {config.RR_RATIO:g}배 위. 1차 익절 목표.',
        })
    else:  # SELL — 보유 청산. 평단 고정은 고수익주에서 붕괴 → 지지·저항·트레일링으로.
        trail = int(round(price - atr * config.TRAIL_ATR_MULT))
        levels.append({
            'label': '트레일링 손절', 'price': trail, 'tap': True,
            'hint': f'현재가−ATR×{config.TRAIL_ATR_MULT:g}. 고점 대비 이만큼 되돌리면 익절/이탈(수익 보호).',
        })
        levels.append({
            'label': '20일선 지지', 'price': int(round(sma20)), 'tap': True,
            'hint': '20일 이동평균선. 추세 지지선 — 종가로 깨지면 상승추세 이탈 신호.',
        })
        if recent_low:
            levels.append({
                'label': f'{config.SWING_LOW_LOOKBACK}일 스윙저점', 'price': int(recent_low), 'tap': True,
                'hint': f'최근 {config.SWING_LOW_LOOKBACK}일 최저가. 이 아래로 밀리면 하락 전환 경계.',
            })
        if recent_high:
            levels.append({
                'label': '전고점(저항/목표)', 'price': int(recent_high), 'tap': True,
                'hint': f'최근 {config.BREAKOUT_LOOKBACK}일 전고점. 1차 저항 — 도달 시 분할 익절 고려.',
            })
        # 평단 근처(±ATR)일 때만 평단 기준 손절선 추가 — 그 밖(고수익/큰손실)에선 무의미해 생략.
        if avg_price and abs(price - avg_price) <= atr:
            a_stop, _ = signals.compute_stop_target(int(avg_price), atr, recent_low)
            levels.append({
                'label': '평단 기준 손절', 'price': a_stop, 'tap': True,
                'hint': f'평단({avg_price:,})−ATR×{config.STOP_ATR_MULT:g}와 스윙저점 중 위쪽. 본전 근처에서 손실 방어선.',
            })

    result = {
        'levels': levels, 'price': int(price), 'atr': int(round(atr)),
        'sma20': int(round(sma20)), 'rsi': (round(rsi) if rsi is not None else None),
        'recent_low': int(recent_low) if recent_low else None,
        'recent_high': int(recent_high) if recent_high else None,
    }
    with _advice_cache_lock:
        _advice_cache[cache_key] = (result, now)
    return result


INDICES_CACHE_TTL = 30.0
_indices_cache: dict = {'data': None, 'expires_at': 0.0}
_indices_lock = threading.Lock()
_indices_refresh_in_flight = False

# ticker 회전 순서: 국내 → 미국 → 환율/원자재. ex.map으로 입력 순서 보존.
_INDEX_SOURCES = (
    ('naver_kr', 'KOSPI', 'KOSPI', None),
    ('naver_kr', 'KOSDAQ', 'KOSDAQ', None),
    ('naver_gl', '.INX', 'S&P500', None),
    ('naver_gl', '.IXIC', 'NASDAQ', None),
    ('naver_gl', '.N225', '닛케이225', None),
    ('yahoo', 'KRW=X', 'USD/KRW', 2),
    ('yahoo', 'CL=F', 'WTI', 2),
    ('yahoo', 'GC=F', '금', 2),
    ('yahoo', 'SI=F', '은', 2),
    ('yahoo', 'HG=F', '동', 3),
)


def _fmt_quote(label: str, price: float, change: float, pct: float, decimals: int = 2) -> dict:
    direction = 'up' if change > 0 else ('down' if change < 0 else 'flat')
    return {
        'kind': 'index',
        'label': label,
        'price': f'{price:,.{decimals}f}',
        'change': f'{abs(change):,.{decimals}f}',
        'pct': f'{abs(pct):.2f}',
        'direction': direction,
    }


def _fetch_naver_index(code: str, label: str, base: str) -> dict | None:
    """네이버 stock API로 지수 1개 fetch. base는 국내(`m.stock.naver.com/api`) 또는 해외(`api.stock.naver.com`)."""
    import urllib.request
    try:
        url = f'{base}/index/{code}/basic'
        req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
        with urllib.request.urlopen(req, timeout=3.0) as resp:
            raw = resp.read()
        data = json.loads(raw.decode('utf-8', 'ignore'))
        price_s = str(data.get('closePrice') or '').replace(',', '')
        change_s = str(data.get('compareToPreviousClosePrice') or '').replace(',', '')
        pct_s = str(data.get('fluctuationsRatio') or '').replace(',', '')
        if not price_s:
            return None
        return _fmt_quote(label, float(price_s), float(change_s or 0), float(pct_s or 0), 2)
    except Exception as e:
        sys.stderr.write(f'naver index fetch {code} failed: {e}\n')
        return None


def _fetch_yahoo_quote(symbol: str, label: str, decimals: int = 2) -> dict | None:
    """Yahoo Finance chart API로 종목·환율·원자재 1개 fetch. 마지막 두 close 비교로 등락."""
    import urllib.request
    try:
        url = f'https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?range=2d&interval=1d'
        req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
        with urllib.request.urlopen(req, timeout=3.0) as resp:
            raw = resp.read()
        data = json.loads(raw.decode('utf-8', 'ignore'))
        closes = [x for x in data['chart']['result'][0]['indicators']['quote'][0]['close'] if x is not None]
        if not closes:
            return None
        cur = closes[-1]
        prev = closes[-2] if len(closes) >= 2 else cur
        change = cur - prev
        pct = (change / prev * 100) if prev else 0.0
        return _fmt_quote(label, cur, change, pct, decimals)
    except Exception as e:
        sys.stderr.write(f'yahoo fetch {symbol} failed: {e}\n')
        return None


def _fetch_one_index(spec) -> dict | None:
    kind, code, label, decimals = spec
    if kind == 'naver_kr':
        return _fetch_naver_index(code, label, 'https://m.stock.naver.com/api')
    if kind == 'naver_gl':
        return _fetch_naver_index(code, label, 'https://api.stock.naver.com')
    if kind == 'yahoo':
        return _fetch_yahoo_quote(code, label, decimals)
    return None


def _fetch_indices_now() -> list[dict]:
    """10개 source 병렬 fetch (ex.map으로 순서 보존). 각 source는 3s timeout."""
    with ThreadPoolExecutor(max_workers=len(_INDEX_SOURCES)) as ex:
        results = list(ex.map(_fetch_one_index, _INDEX_SOURCES))
    return [r for r in results if r]


def _refresh_indices_async() -> None:
    """SWR 백그라운드 갱신. single-flight 가드로 다중 fetch 차단."""
    global _indices_refresh_in_flight
    with _indices_lock:
        if _indices_refresh_in_flight:
            return
        _indices_refresh_in_flight = True

    def _run():
        global _indices_refresh_in_flight
        try:
            data = _fetch_indices_now()
            if data:
                with _indices_lock:
                    _indices_cache['data'] = data
                    _indices_cache['expires_at'] = time.time() + INDICES_CACHE_TTL
        except Exception as e:
            sys.stderr.write(f'indices background refresh failed: {e}\n')
        finally:
            with _indices_lock:
                _indices_refresh_in_flight = False

    threading.Thread(target=_run, daemon=True, name='indices-swr').start()


def _fetch_indices() -> list[dict]:
    """ticker 회전용 시장 지표 — KOSPI/KOSDAQ + 미국 3대 지수 + USD/KRW + WTI/금.
    TTL 30s + stale-while-revalidate: 캐시 있으면 즉시 반환(만료여도 stale 반환 + 백그라운드 갱신).
    캐시 비었을 때만 동기 병렬 fetch. 첫 cold ~300ms, 이후 사용자는 indices 대기 없음."""
    now = time.time()
    with _indices_lock:
        cached = _indices_cache['data']
        fresh = cached is not None and _indices_cache['expires_at'] > now
    if cached:
        if not fresh:
            _refresh_indices_async()
        return cached
    data = _fetch_indices_now()
    if data:
        with _indices_lock:
            _indices_cache['data'] = data
            _indices_cache['expires_at'] = now + INDICES_CACHE_TTL
        return data
    return []


# 시장 상황 카드 — ADR(상승/하락 종목수)과 투자자별 매매(개인/외국인/기관) 데이터.
# 네이버 m.stock 비공식 API 한 콜로 KOSPI/KOSDAQ 둘 다 fetch.
# 분당 변화 폭이 작아 5분 TTL + stale-while-revalidate.
MARKET_INDICATORS_TTL = 300.0
_market_indicators_cache: dict = {'data': None, 'expires_at': 0.0}
_market_indicators_lock = threading.Lock()
_market_indicators_refresh_in_flight = False
_MARKET_INDICATOR_SYMBOLS = (('KOSPI', '코스피'), ('KOSDAQ', '코스닥'))


def _parse_signed_int(s) -> int | None:
    """네이버 응답은 '+13,930' / '-25,461' / '0' 형태. 콤마·부호 제거 후 정수."""
    if s is None:
        return None
    try:
        return int(str(s).replace(',', '').replace('+', '').strip())
    except (ValueError, TypeError):
        return None


def _parse_count(s) -> int:
    """upDownStockInfo 값은 '4' / '1,450' / '839' — 콤마 포함 가능. 실패 시 0."""
    if s is None:
        return 0
    try:
        return int(str(s).replace(',', '').strip())
    except (ValueError, TypeError):
        return 0


def _fetch_one_market_indicator(symbol_label: tuple[str, str]) -> dict | None:
    """네이버 m.stock integration API 한 콜로 시장 1개의 dealTrendInfo + upDownStockInfo 수집."""
    import urllib.request
    symbol, label = symbol_label
    try:
        url = f'https://m.stock.naver.com/api/index/{symbol}/integration'
        req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
        with urllib.request.urlopen(req, timeout=3.0) as resp:
            raw = resp.read()
        data = json.loads(raw.decode('utf-8', 'ignore'))
    except Exception as e:
        sys.stderr.write(f'market indicator fetch {symbol} failed: {e}\n')
        return None

    up_down = data.get('upDownStockInfo') or {}
    rise = _parse_count(up_down.get('riseCount'))
    upper = _parse_count(up_down.get('upperCount'))
    fall = _parse_count(up_down.get('fallCount'))
    lower = _parse_count(up_down.get('lowerCount'))
    steady = _parse_count(up_down.get('steadyCount'))
    adv = rise + upper
    dec = fall + lower
    adr = (adv / dec * 100.0) if dec else None

    deal = data.get('dealTrendInfo') or {}
    personal = _parse_signed_int(deal.get('personalValue'))
    foreign = _parse_signed_int(deal.get('foreignValue'))
    institutional = _parse_signed_int(deal.get('institutionalValue'))

    return {
        'symbol': symbol,
        'label': label,
        'rise': rise,
        'upper': upper,
        'fall': fall,
        'lower': lower,
        'steady': steady,
        'adr': adr,
        'bizdate': str(deal.get('bizdate') or '').strip() or None,
        'personal': personal,
        'foreign': foreign,
        'institutional': institutional,
    }


def _fetch_market_indicators_now() -> list[dict]:
    with ThreadPoolExecutor(max_workers=len(_MARKET_INDICATOR_SYMBOLS)) as ex:
        results = list(ex.map(_fetch_one_market_indicator, _MARKET_INDICATOR_SYMBOLS))
    return [r for r in results if r]


# 분기 영업이익 캐시 — (value_eok, period, fetched_at). 분기 데이터는 자주 안 변해 6h TTL.
_QUARTER_OP_CACHE: dict[str, tuple] = {}
_QUARTER_OP_TTL = 6 * 3600


def _fetch_quarter_op_profit(code: str) -> dict | None:
    """네이버 m.stock 분기 재무에서 '최근 확정 분기' 영업이익(억원) + 분기 라벨 반환.

    네이버는 응답에 미래 분기 컨센서스(증권사 추정)를 isConsensus='Y'로 섞어 준다.
    그 추정치만 제외하고, 확정(N) 분기 중 가장 최근 것을 채택한다.
    (값 자체에 대한 임계 가드는 두지 않는다 — 네이버의 확정 분류를 신뢰. 호황기 급증
     실적을 오탐 차단하던 과거 가드를 제거함.)
    반환: {'value': 억원(int), 'period': 'YYYY.MM'} 또는 None.
    """
    import time as _t
    now = _t.time()
    hit = _QUARTER_OP_CACHE.get(code)
    if hit and (now - hit[2]) < _QUARTER_OP_TTL:
        return {'value': hit[0], 'period': hit[1]} if hit[0] is not None else None
    import urllib.request
    try:
        url = f'https://m.stock.naver.com/api/stock/{code}/finance/quarter'
        req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
        with urllib.request.urlopen(req, timeout=3.0) as resp:
            data = json.loads(resp.read().decode('utf-8', 'ignore'))
        fi = data.get('financeInfo') or {}
        titles = fi.get('trTitleList') or []
        op_cols = {}
        for r in (fi.get('rowList') or []):
            if r.get('title') == '영업이익':
                op_cols = r.get('columns') or {}
                break
        # 컨센서스(추정) 제외, 확정 분기 key 내림차순(최근 우선)
        cand = [t for t in titles if t.get('isConsensus') != 'Y' and t.get('key')]
        cand.sort(key=lambda t: t['key'], reverse=True)
        chosen = None
        for t in cand:
            raw = (op_cols.get(t['key']) or {}).get('value')
            if raw in (None, '', '-'):
                continue
            try:
                val = int(str(raw).replace(',', ''))
            except ValueError:
                continue
            chosen = {'value': val, 'period': (t.get('title') or '').rstrip('.')}
            break
        _QUARTER_OP_CACHE[code] = (chosen['value'] if chosen else None,
                                   chosen['period'] if chosen else None, now)
        return chosen
    except Exception as e:
        sys.stderr.write(f'quarter op_profit fetch {code} failed: {e}\n')
        return None


def _refresh_market_indicators_async() -> None:
    global _market_indicators_refresh_in_flight
    with _market_indicators_lock:
        if _market_indicators_refresh_in_flight:
            return
        _market_indicators_refresh_in_flight = True

    def _run():
        global _market_indicators_refresh_in_flight
        try:
            data = _fetch_market_indicators_now()
            if data:
                with _market_indicators_lock:
                    _market_indicators_cache['data'] = data
                    _market_indicators_cache['expires_at'] = time.time() + MARKET_INDICATORS_TTL
        except Exception as e:
            sys.stderr.write(f'market indicators background refresh failed: {e}\n')
        finally:
            with _market_indicators_lock:
                _market_indicators_refresh_in_flight = False

    threading.Thread(target=_run, daemon=True, name='market-indicators-swr').start()


def _fetch_market_indicators() -> list[dict]:
    """KOSPI/KOSDAQ의 ADR·투자자별 매매. 5분 TTL + stale-while-revalidate.
    실패시 빈 리스트 — caller(_render_market_panel)가 graceful degrade 처리."""
    now = time.time()
    with _market_indicators_lock:
        cached = _market_indicators_cache['data']
        fresh = cached is not None and _market_indicators_cache['expires_at'] > now
    if cached:
        if not fresh:
            _refresh_market_indicators_async()
        return cached
    data = _fetch_market_indicators_now()
    if data:
        with _market_indicators_lock:
            _market_indicators_cache['data'] = data
            _market_indicators_cache['expires_at'] = now + MARKET_INDICATORS_TTL
        return data
    return []


def _load_holidays() -> set[str]:
    """state/market_holidays.json에서 KRX 휴장일 set(YYYY-MM-DD) 로드. 파일 없거나 깨지면 빈 set.
    파일 mtime 기반 lazy reload — holiday_sync가 갱신하면 다음 호출에서 자동 반영."""
    if not HOLIDAYS_FILE.exists():
        return set()
    try:
        mtime = HOLIDAYS_FILE.stat().st_mtime
    except OSError:
        return _holidays_cache.get('set', set())
    if mtime != _holidays_cache.get('mtime'):
        try:
            data = json.loads(HOLIDAYS_FILE.read_text())
            _holidays_cache['set'] = set((data.get('holidays') or {}).keys())
            _holidays_cache['mtime'] = mtime
        except Exception as e:
            sys.stderr.write(f'holidays load failed: {e}\n')
    return _holidays_cache.get('set', set())


def _load_traded_codes() -> set[str]:
    """trade_journal.jsonl 에서 distinct 종목코드 set 로드. mtime 기반 lazy reload.
    워치리스트·관심종목 행에서 '거래내역' 버튼 노출 여부 판정용."""
    if not TRADE_JOURNAL_FILE.exists():
        return set()
    try:
        mtime = TRADE_JOURNAL_FILE.stat().st_mtime
    except OSError:
        return _traded_codes_cache.get('set', set())
    if mtime != _traded_codes_cache.get('mtime'):
        codes: set[str] = set()
        try:
            for ln in TRADE_JOURNAL_FILE.read_text().splitlines():
                ln = ln.strip()
                if not ln:
                    continue
                try:
                    d = json.loads(ln)
                    c = d.get('code')
                    if c:
                        codes.add(c)
                except json.JSONDecodeError:
                    continue
            _traded_codes_cache['set'] = codes
            _traded_codes_cache['mtime'] = mtime
        except Exception as e:
            sys.stderr.write(f'trade codes load failed: {e}\n')
    return _traded_codes_cache.get('set', set())


def _holding_since_map() -> dict:
    """trade_journal.jsonl 재구성 → {code: 'YYYYMMDD'} 현재 연속 보유 시작일.
    보유종목 '매수후 고점대비' 낙폭 계산용. mtime 기반 lazy reload.
    계좌·owner 무관 code 단위 순매수 누적 — 보유수량이 0으로 떨어지면 streak 리셋,
    재진입 시 마지막 streak 시작일. (드물게 본인·가희 동시보유 시 더 이른 날로 근사)."""
    if not TRADE_JOURNAL_FILE.exists():
        return {}
    try:
        mtime = TRADE_JOURNAL_FILE.stat().st_mtime
    except OSError:
        return _holding_since_cache.get('map', {})
    if mtime != _holding_since_cache.get('mtime'):
        try:
            events: dict[str, list[tuple[str, int]]] = {}
            for ln in TRADE_JOURNAL_FILE.read_text().splitlines():
                ln = ln.strip()
                if not ln:
                    continue
                try:
                    d = json.loads(ln)
                except json.JSONDecodeError:
                    continue
                c = d.get('code')
                if not c:
                    continue
                day = (d.get('date') or '').replace('-', '')
                net = int(d.get('buy_qty') or 0) - int(d.get('sell_qty') or 0)
                events.setdefault(c, []).append((day, net))
            out: dict[str, str] = {}
            for c, evs in events.items():
                evs.sort(key=lambda x: x[0])
                run = 0
                start = None
                for day, net in evs:
                    if run <= 0 and net > 0:
                        start = day
                    run += net
                    if run <= 0:
                        run = 0
                        start = None
                if run > 0 and start:
                    out[c] = start
            _holding_since_cache['map'] = out
            _holding_since_cache['mtime'] = mtime
        except Exception as e:
            sys.stderr.write(f'holding_since load failed: {e}\n')
    return _holding_since_cache.get('map', {})


_US_EASTERN = ZoneInfo('America/New_York')


def _us_market_open() -> bool:
    """미국 야간/새벽 sentiment 블록 표시 기간 — KST 22:30 ~ 다음날 10:00.
    미국 정규장(ET 09:30~16:00 = KST 22:30~05:00 DST) 시간 + 미국장 마감 후 한국 정규장 개장 1시간까지.
    한국 정규장 시작 후 10:00 이전엔 야간 미국 마감 데이터가 여전히 sentiment 참조용으로 유효."""
    now_kst = datetime.now(KST)
    hm = now_kst.hour * 60 + now_kst.minute
    return hm >= 22 * 60 + 30 or hm < 10 * 60


# 미국 장 시간에 시장 카드 KOSPI/KOSDAQ 자리를 채울 KOSPI 200 + MSCI Korea(EWY) quote 캐시.
# 5분 TTL. _fetch_yahoo_quote 재사용. 한국 정규장 시간엔 fetch X (캐시도 만료 → 빈 결과).
_EXTRA_MARKET_QUOTES_TTL = 300.0
_extra_market_quotes_cache: dict = {'data': None, 'expires_at': 0.0}
_extra_market_quotes_lock = threading.Lock()


# 지수 차트 sparkline 캐시 — 모달의 lazy fetch. 5분 TTL. label → Yahoo symbol 매핑은 _IDX_CHART_SYMBOL.
_IDX_CHART_SYMBOL = {
    'KOSPI': '^KS11',
    'KOSDAQ': '^KQ11',
    'S&P500': '^GSPC',
    '필라델피아 반도체': '^SOX',
    'MSCI Korea (EWY)': 'EWY',
    'VIX (공포지수)': '^VIX',
    '미국채 10년 (%)': '^TNX',
}
_IDX_CHART_TTL = 300.0
_idx_chart_cache: dict = {}  # symbol -> {'data': list[float], 'expires_at': float}
_idx_chart_lock = threading.Lock()


def _fetch_idx_chart_series(symbol: str) -> list[dict] | None:
    """Yahoo chart API 로 최근 1개월 일별 close 시리즈. 5분 TTL per-symbol. 각 점은 {'d': iso_date, 'v': close}."""
    now = time.time()
    with _idx_chart_lock:
        c = _idx_chart_cache.get(symbol)
        if c and c.get('expires_at', 0) > now:
            return c['data']
    import urllib.request
    try:
        url = f'https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?range=1mo&interval=1d'
        req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
        with urllib.request.urlopen(req, timeout=4.0) as resp:
            data = json.loads(resp.read().decode('utf-8', 'ignore'))
        result = data.get('chart', {}).get('result') or []
        if not result:
            return None
        timestamps = result[0].get('timestamp') or []
        closes = result[0]['indicators']['quote'][0].get('close') or []
        series: list[dict] = []
        for ts, c in zip(timestamps, closes):
            if c is None:
                continue
            try:
                d_iso = datetime.fromtimestamp(int(ts), tz=KST).strftime('%Y-%m-%d')
            except Exception:
                continue
            series.append({'d': d_iso, 'v': float(c)})
        if len(series) < 2:
            return None
        with _idx_chart_lock:
            _idx_chart_cache[symbol] = {'data': series, 'expires_at': now + _IDX_CHART_TTL}
        return series
    except Exception as e:
        sys.stderr.write(f'idx chart fetch {symbol} failed: {e}\n')
        return None


def _fetch_extra_market_quotes() -> list[dict] | None:
    """미국 장 시간 전용: KOSPI 200 + MSCI Korea(EWY) 두 quote. 5분 캐시.
    fetch 실패하거나 부분 실패면 빈 quote 자리 None 으로. 모두 실패면 None 리턴."""
    now = time.time()
    with _extra_market_quotes_lock:
        cached = _extra_market_quotes_cache['data']
        fresh = cached is not None and _extra_market_quotes_cache['expires_at'] > now
    if cached and fresh:
        return cached
    results = []
    for sym, lbl, dec in (
        ('EWY', 'MSCI Korea (EWY)', 2),
        ('^SOX', '필라델피아 반도체', 2),
        ('^VIX', 'VIX (공포지수)', 2),
        ('^TNX', '미국채 10년 (%)', 3),
    ):
        q = _fetch_yahoo_quote(sym, lbl, dec)
        results.append(q)
    if not any(results):
        return None
    with _extra_market_quotes_lock:
        _extra_market_quotes_cache['data'] = results
        _extra_market_quotes_cache['expires_at'] = now + _EXTRA_MARKET_QUOTES_TTL
    return results


def _market_active_now() -> bool:
    """NXT 운영 시간대(평일 08:00~20:00 KST) 안 + KRX 휴장일 아님.
    이 조건 밖에선 자동 갱신 토글이 비활성화된다.
    휴장일 데이터는 holiday_sync가 주 1회 investing.com에서 갱신."""
    now = datetime.now(KST)
    if now.weekday() >= 5:  # 토(5)·일(6)
        return False
    if now.hour < 8 or now.hour >= 20:
        return False
    if now.strftime('%Y-%m-%d') in _load_holidays():
        return False
    return True


def _show_day_change_now() -> bool:
    """종목 등락률 표시 시간대 — 평일 08:00 ~ 다음날 06:00 (KST).
    NXT 마감(20:00) 이후에도 다음날 새벽 06:00까지 직전 거래일 등락률을 계속 표시.
    주말·휴장일은 표시 X (단, 직전 거래일이 평일 거래일이면 그 새벽 시간대까지는 ON)."""
    now = datetime.now(KST)
    today_str = now.strftime('%Y-%m-%d')
    holidays = _load_holidays()
    wd = now.weekday()
    hour = now.hour
    # 평일 08:00 이후 — 오늘 거래일 진행 (휴장 아닐 때)
    if wd < 5 and hour >= 8 and today_str not in holidays:
        return True
    # 평일 또는 토요일 00:00~06:00 — 직전 거래일이 평일·비휴장이면 그 결과 표시
    if hour < 6 and wd <= 5:
        prev = now - timedelta(days=1)
        if prev.weekday() < 5 and prev.strftime('%Y-%m-%d') not in holidays:
            return True
    return False


def _market_phase_state() -> dict:
    """현재 시각 기준 시장 phase 판정. 거래 모달 시간 가드용.

    - regular(정규장): 평일 09:00~15:30 — KRX+NXT 거래 가능
    - nxt(NXT): 평일 08:00~09:00 + 15:30~20:00 — NXT만 거래 가능
    - closed(장외): 평일 그 외 — 매매 불가
    - holiday(휴장): KRX 휴장일 — 매매 불가
    - weekend(주말): 토·일 — 매매 불가
    """
    now = datetime.now(KST)
    time_str = now.strftime('%H:%M')
    if now.weekday() >= 5:
        return {'phase': 'weekend', 'label': '주말 휴장', 'active': False, 'time': time_str}
    if now.strftime('%Y-%m-%d') in _load_holidays():
        return {'phase': 'holiday', 'label': '휴장일', 'active': False, 'time': time_str}
    hm = now.hour * 60 + now.minute
    if 9 * 60 <= hm < 15 * 60 + 30:
        return {'phase': 'regular', 'label': '정규장', 'active': True, 'time': time_str}
    if 8 * 60 <= hm < 20 * 60:
        return {'phase': 'nxt', 'label': 'NXT 시장', 'active': True, 'time': time_str}
    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)
    return (
        now.weekday() < 5
        and now.hour < 8
        and now.strftime('%Y-%m-%d') not in _load_holidays()
    )


def _load_watchlist() -> list[dict]:
    if not WATCHLIST.exists():
        return []
    data = json.loads(WATCHLIST.read_text())
    return sorted(data.values(), key=lambda e: e.get('saved_at', ''), reverse=True)


def _load_interests() -> list[dict]:
    if not INTERESTS.exists():
        return []
    data = json.loads(INTERESTS.read_text())
    return sorted(data.values(), key=lambda e: e.get('saved_at', ''), reverse=True)


def _fetch_quotes_batch(entries: list[dict]) -> list[dict]:
    """ka10095(관심종목정보) 한 콜로 워치리스트 시세 일괄 조회. 19종목 ~0.05s.
    캐시 히트인 entry는 캐시 사용, 미스인 entry만 batch에 포함.
    batch 실패·응답 누락 종목은 _fetch_quote 단건(ka10001) fallback."""
    import kiwoom_client as kc

    now = time.time()
    cards: list[dict] = []
    miss_codes: list[str] = []
    miss_indices: list[int] = []

    for e in entries:
        out = dict(e)
        code = (e.get('code') or '').strip()
        name = e.get('stock', '')
        token = code or name
        out['quote_error'] = None
        out['price'] = None
        out['day_change'] = None
        out['day_change_pct'] = None
        out['open'] = None
        out['high'] = None
        out['low'] = None
        cards.append(out)
        if not token:
            out['quote_error'] = '종목 식별 실패'
            continue
        with _quote_cache_lock:
            cached = _quote_cache.get(token)
        if cached and cached[1] > now:
            snap = cached[0]
            out['price'] = snap['price']
            out['day_change'] = snap['day_change']
            out['day_change_pct'] = snap['day_change_pct']
            out['quote_error'] = snap['quote_error']
            out['open'] = snap.get('open')
            out['high'] = snap.get('high')
            out['low'] = snap.get('low')
            out['volume'] = snap.get('volume')
            out['_nxt_volume'] = snap.get('_nxt_volume', 0)
            out['_krx_price'] = snap.get('_krx_price', 0)
            out['_nxt_price'] = snap.get('_nxt_price', 0)
            continue
        if code:
            miss_codes.append(code)
            miss_indices.append(len(cards) - 1)
        else:
            # 코드 없는 entry는 resolve 거쳐야 해서 단건 호출 — 거의 없음.
            cards[-1] = _fetch_quote(e)

    if not miss_codes:
        return cards

    nxt_probe_codes = list(miss_codes)
    nxt_probe_indices = list(miss_indices)

    # WS 허브 연결 시 — 미스 종목 시세를 허브 메모리(0B 실시간, KRX+NXT 통합)에서 우선 충당.
    # 서버가 동일 렌더러로 OHLC를 그린다. 단, NXT 거래가능 초록점은 허브 exchange가 아니라
    # 아래 ka10095 NX 단독 응답 가능 여부로만 판정한다.
    # 허브에 없는(아직 미체결) 종목만 ka10095 AL 폴백으로 남긴다. 캐시엔 안 넣음 — 매 페치 라이브 허브 사용.
    hub = _RT_HUB
    if hub is not None and hub.is_connected():
        hub_q = hub.get_quotes(miss_codes)
        rest_codes: list[str] = []
        rest_indices: list[int] = []
        for idx, code in zip(miss_indices, miss_codes):
            q = hub_q.get(kc._clean_code(code))
            price = (q or {}).get('price') or 0
            if not price:
                rest_codes.append(code)
                rest_indices.append(idx)
                continue
            out = cards[idx]
            out['price'] = price
            out['day_change'] = q.get('change')
            out['day_change_pct'] = q.get('pct')
            out['open'] = q.get('open') or None
            out['high'] = q.get('high') or None
            out['low'] = q.get('low') or None
            out['volume'] = q.get('volume')
            out['_krx_price'] = price
            out['_nxt_price'] = 0
        miss_codes, miss_indices = rest_codes, rest_indices

    batch: dict[str, dict] = {}
    if miss_codes:
        try:
            batch = kc.get_watchlist_quotes(miss_codes)
        except Exception as ex:
            sys.stderr.write(f'ka10095 batch 실패, ka10001 fallback: {ex}\n')
            for idx in miss_indices:
                cards[idx] = _fetch_quote(entries[idx])

    # NXT 비교용 batch — 마크(KRX/NXT) 판정에 사용. 실패해도 fallback X (마크만 KRX로 처리).
    # get_watchlist_quotes는 NXT 단독 suffix를 'NX'로 받는다. 'NXT'는 suffix 없이 KRX/기본값으로
    # 떨어져 NXT 미거래 종목도 거래 가능처럼 보이므로 쓰면 안 된다.
    nxt_batch: dict[str, dict] = {}
    try:
        nxt_batch = kc.get_watchlist_quotes(nxt_probe_codes, exchange='NX')
    except Exception as ex:
        sys.stderr.write(f'ka10095 NXT batch 실패 (마크 판정용): {ex}\n')

    for idx, code in zip(nxt_probe_indices, nxt_probe_codes):
        nxt_q = nxt_batch.get(code) if nxt_batch else None
        cards[idx]['_nxt_price'] = (nxt_q or {}).get('price', 0) if nxt_q else 0
        # NXT 거래량 — RVOL은 KRX 기준(일봉 평균과 동일)이라 KRX=AL−NXT 계산에 사용.
        cards[idx]['_nxt_volume'] = (nxt_q or {}).get('volume', 0) if nxt_q else 0

    for idx, code in zip(miss_indices, miss_codes):
        q = batch.get(code)
        out = cards[idx]
        if not q:
            cards[idx] = _fetch_quote(entries[idx])
            continue
        price = q.get('price') or 0
        if price:
            out['price'] = price
            out['day_change'] = q.get('change')
            out['day_change_pct'] = q.get('change_pct')
            out['open'] = q.get('open') or None
            out['high'] = q.get('high') or None
            out['low'] = q.get('low') or None
            out['volume'] = q.get('volume')
        else:
            out['quote_error'] = '가격 0'
        # 마크 판정: NXT batch 가격 vs 기본 batch 가격 비교.
        nxt_price = out.get('_nxt_price', 0) or 0
        out['_krx_price'] = price
        out['_nxt_price'] = nxt_price
        snap = {
            'price': out['price'],
            'day_change': out['day_change'],
            'day_change_pct': out['day_change_pct'],
            'quote_error': out['quote_error'],
            'open': out['open'],
            'high': out['high'],
            'low': out['low'],
            'volume': out.get('volume'),
            '_nxt_volume': out.get('_nxt_volume', 0),
            '_krx_price': price,
            '_nxt_price': nxt_price,
        }
        with _quote_cache_lock:
            _quote_cache[code] = (snap, time.time() + QUOTE_CACHE_TTL)

    return cards


def _fetch_quote(entry: dict) -> dict:
    """키움 ka10001 — 단일 종목 현재가 fallback. 평소 _fetch_quotes_batch가 처리.
    종목별 QUOTE_CACHE_TTL 캐시로 batch 실패 시에도 rate limit 완화."""
    import kiwoom_client as kc

    out = dict(entry)
    code = (entry.get('code') or '').strip()
    name = entry.get('stock', '')
    target_token = code or name
    out['quote_error'] = None
    out['price'] = None
    out['day_change'] = None
    out['day_change_pct'] = None
    out['open'] = None
    out['high'] = None
    out['low'] = None
    if not target_token:
        out['quote_error'] = '종목 식별 실패'
        return out

    now = time.time()
    with _quote_cache_lock:
        cached = _quote_cache.get(target_token)
    if cached and cached[1] > now:
        snap = cached[0]
        out['price'] = snap['price']
        out['day_change'] = snap['day_change']
        out['day_change_pct'] = snap['day_change_pct']
        out['quote_error'] = snap['quote_error']
        out['open'] = snap.get('open')
        out['high'] = snap.get('high')
        out['low'] = snap.get('low')
        out['volume'] = snap.get('volume')
        return out

    try:
        info = kc.resolve_stock_code(target_token) if not code else {'code': code}
        q = kc.get_stock_quote(info['code'])
        price = q.get('price') or 0
        if not price:
            out['quote_error'] = '가격 0'
        else:
            out['price'] = price
            out['day_change'] = q.get('change')
            out['day_change_pct'] = q.get('change_pct')
            out['volume'] = q.get('volume')
    except Exception as e:
        out['quote_error'] = str(e)[:80]

    snap = {
        'price': out['price'],
        'day_change': out['day_change'],
        'day_change_pct': out['day_change_pct'],
        'quote_error': out['quote_error'],
        'open': out['open'],
        'high': out['high'],
        'low': out['low'],
        'volume': out.get('volume'),
    }
    with _quote_cache_lock:
        _quote_cache[target_token] = (snap, time.time() + QUOTE_CACHE_TTL)
    return out


def _holdings_map_from_positions(positions_by_acc: dict) -> dict:
    """positions_by_acc → 종목코드 keyed dict (워치리스트 카드 annotate용)."""
    agg: dict = {}
    for label, positions in positions_by_acc.items():
        for p in positions:
            code = p.get('code') or ''
            if not code:
                continue
            cur = agg.get(code)
            if cur:
                new_qty = cur['qty'] + p['qty']
                new_pur = cur['pur_amt'] + p['pur_amt']
                cur['qty'] = new_qty
                cur['pur_amt'] = new_pur
                cur['avg_price'] = new_pur // new_qty if new_qty else 0
                cur['accounts'].append(label)
            else:
                agg[code] = {
                    'qty': p['qty'],
                    'pur_amt': p['pur_amt'],
                    'avg_price': p['avg_price'],
                    'name': p['name'],
                    'accounts': [label],
                }
    return agg


def _build_owner_data(positions_by_acc: dict, balances: dict, journal_by_label: dict, prev_snap_all: dict | None = None, cash_flow_by_label: dict | None = None) -> tuple[list[str], dict]:
    """계좌 데이터 → owner 그룹별 집계. stock_portfolio_report와 동일 로직.

    `당일 평가손익`은 prev_snap_all(전날 stock.briefing 스냅샷) 기반 `total_net - prev_net - net_cash_flow`.
    이 차이값은 보유분의 미실현 등락 + 오늘 매매의 실현손익 + 매수/매도 현금흐름까지 자연스럽게 포함하되,
    외부 입출금(cash flow)은 손익이 아니므로 net_cash_flow로 분리해 제거한다.
    스냅샷이 없으면 None — KPI에 '전날 스냅샷 없음'으로 표시.
    개별 카드의 day_change도 동일 스냅샷의 NXT 종가를 baseline으로 재계산해 합계와 일관성 유지.
    """
    if cash_flow_by_label is None:
        cash_flow_by_label = {}
    from stock_portfolio_report import consolidate, aggregate_journal, group_by_owner, rebase_to_nxt_close, enrich_journal_realized_pl

    rows: list[dict] = []
    for label, positions in positions_by_acc.items():
        for p in positions:
            rows.append({
                'account': label,
                'stock': p['name'],
                'code': p['code'],
                'qty': p['qty'],
                'avg': p['avg_price'],
                'price': p['cur_price'],
                'pred_close': p.get('pred_close_pric', 0),
                'day_change': p.get('day_change', 0),
                'day_change_pct': p.get('day_change_pct', 0.0),
                'buy_amount': p['pur_amt'],
                'eval_value': p['evlt_amt'],
                'profit': p['evltv_prft'],
                'profit_rate': p['prft_rt'],
                'tdy_buyq': p['tdy_buyq'],
                'tdy_sellq': p['tdy_sellq'],
            })

    # A-4: prev_snap 없을 때만 ka10001 보정. brifing 과 동일 정책.
    # 정상 운영(prev_snap 있음)에선 rebase_to_nxt_close 가 NXT-on-NXT 기준 더 정확하게 덮어쓰므로 불필요.
    if not prev_snap_all and rows:
        from stock_portfolio_report import apply_ka10001_corrections
        try:
            applied = apply_ka10001_corrections(rows)
            sys.stderr.write(f'[ka10001-fallback] prev_snap 없음 → {applied}개 종목 보정\n')
        except Exception as e:
            sys.stderr.write(f'ka10001 fallback failed: {e}\n')

    # 계좌 라벨 정렬 — set 변환의 비결정성 제거 + owner 안에서 일반→ISA 순서 고정.
    # suffix(언더바 뒤 또는 본인 라벨 자체)를 기준으로 일반=0, ISA=1, 그 외 사전순.
    def _account_label_sort_key(lb: str) -> tuple[int, str]:
        suffix = lb.split('_', 1)[1] if '_' in lb else lb
        return ({'일반': 0, 'ISA': 1}.get(suffix, 2), lb)
    labels = sorted({r['account'] for r in rows} | set(balances.keys()), key=_account_label_sort_key)
    owner_groups = group_by_owner(labels)

    owner_data: dict = {}
    for owner, lbls in owner_groups.items():
        owner_rows = [r for r in rows if r['account'] in lbls]
        consolidated = consolidate(owner_rows)
        owner_journal = aggregate_journal({lb: journal_by_label.get(lb, []) for lb in lbls})
        # ka10170이 어제부터 보유한 종목 매도분에 pl_amt=0 박는 케이스 보강 (스냅샷 avg_price 사용)
        prev_holdings_for_owner = (prev_snap_all or {}).get(owner, {}).get('holdings') or {}
        enrich_journal_realized_pl(owner_journal, prev_holdings_for_owner)
        # 당일 실현손익 합계 — 매도분만 (그로스 기준, 수수료·세금 별도)
        realized_pl_total = sum(j.get('pl_amt', 0) for j in owner_journal.values() if j.get('sell_qty', 0) > 0)
        realized_fees_total = sum(j.get('cmsn_tax', 0) for j in owner_journal.values() if j.get('sell_qty', 0) > 0)
        held_codes = {r['code'] for r in consolidated}
        for r in consolidated:
            j = owner_journal.get(r['code'])
            if j:
                r['journal'] = j
                r['tdy_buyq'] = j['buy_qty']
                r['tdy_sellq'] = j['sell_qty']
                r['traded_today'] = (j['buy_qty'] + j['sell_qty']) > 0
        for code, j in owner_journal.items():
            if code in held_codes:
                continue
            consolidated.append({
                'stock': j['name'],
                'code': code,
                'accounts': list(j['accounts']),
                'qty': 0,
                'avg': 0,
                'price': 0,
                'pred_close': 0,
                'day_change': 0,
                'day_change_pct': 0.0,
                'buy_amount': 0,
                'eval_value': 0,
                'profit': 0,
                'profit_rate': 0.0,
                'tdy_buyq': j['buy_qty'],
                'tdy_sellq': j['sell_qty'],
                'traded_today': True,
                'phantom': True,
                'journal': j,
            })

        # rebase 직전 raw KRX 종가(kt00018 pred_close_pric)를 별도 키로 보존.
        # 이후 ohlc-mini가 KRX/NXT 두 기준을 같이 표시할 때 사용.
        # NXT 미상장 코드는 별도 마커로 식별 — KRX==NXT 우연 일치 false-positive 방지.
        nxt_inactive_set = set((prev_snap_all or {}).get('_nxt_inactive') or [])
        for r in consolidated:
            r['pred_close_krx'] = r.get('pred_close', 0)
            if r.get('code') in nxt_inactive_set:
                r['_nxt_inactive'] = True
        # 개별 종목의 day_change/pred_close를 어제 NXT 종가 baseline으로 재계산.
        # kt00018 NXT 응답의 pred_close_pric가 KRX 종가로 채워져 raw day_change가 NXT-vs-KRX 갭으로
        # 부풀려지는 문제를 해결한다. 합계 KPI는 동일 NXT 스냅샷의 prev_eval과 비교하므로
        # 개별 합 ≈ 합계로 자연스럽게 일치한다.
        prev_holdings = (prev_snap_all or {}).get(owner, {}).get('holdings') or {}
        rebase_to_nxt_close(consolidated, prev_holdings)

        total_cost = sum(r['buy_amount'] for r in consolidated)
        total_value = sum(r['eval_value'] for r in consolidated)
        total_profit = sum(r['profit'] for r in consolidated)
        total_profit_rate = (total_profit / total_cost * 100) if total_cost else 0.0
        # 예수금은 d2_entra(D+2 정산예수금) — 매수 즉시 차감/매도 즉시 가산되어
        # 평가액 변동과 일관성 유지. entr은 D+2 결제 전까지 변하지 않아
        # 신규 매수 시 손익이 매수원가만큼 부풀려지는 버그가 있다.
        # 라벨별 dict 경유 — KPI 예수금 행의 [!] 버튼 노출 여부(계좌 2개 이상)를 여기서 판단한다.
        deposit_by_label = {lb: (balances.get(lb, {}).get('d2_entra', 0) or 0) for lb in lbls}
        deposit = sum(deposit_by_label.values())
        total_net = total_value + deposit
        traded_today = [r for r in consolidated if r.get('traded_today')]

        # 당일 평가손익 = (total_net - prev_net) - net_cash_flow (전날 stock.briefing 20:10 스냅샷 기준).
        prev_net = None
        if prev_snap_all:
            owner_prev = prev_snap_all.get(owner) or {}
            prev_eval = sum(v.get('eval_value', 0) for v in prev_holdings.values()) if prev_holdings else None
            prev_deposit = owner_prev.get('deposit')
            if prev_eval is not None and prev_deposit is not None:
                prev_net = prev_eval + prev_deposit

        # 당일 외부 입출금 — 손익이 아니므로 day_pl_total 에서 제거.
        owner_cash_flows: list[dict] = []
        cash_in = 0
        cash_out = 0
        for lb in lbls:
            for f in cash_flow_by_label.get(lb, []) or []:
                owner_cash_flows.append({**f, 'label': lb})
                if f['io_tp'] == 'IN':
                    cash_in += f['amount']
                elif f['io_tp'] == 'OUT':
                    cash_out += f['amount']
        net_cash_flow = cash_in - cash_out

        day_pl_total = (total_net - prev_net - net_cash_flow) if prev_net is not None else None
        day_pl_reason = None
        if _is_preopen_market_day():
            day_pl_total = None
            day_pl_reason = 'preopen'
        # 휴장일/주말은 시장 변동이 없어야 자연. 단 NXT 가격 보정으로 어제 스냅샷과 차이가 생겨
        # day_pl_total이 0이 아닌 값을 갖게 됨 → 사용자 혼란 차단 위해 None으로 강제(미표시).
        if _market_phase_state().get('phase') in ('holiday', 'weekend'):
            day_pl_total = None
            day_pl_reason = 'closed'

        # 투자원금(누적 입금−출금) — 캐시 파일 읽기만 (키움 콜 0).
        # principal None = 미집계(부트스트랩 전) → KPI 2행 미표시.
        try:
            import investment_principal as ip
            pr = ip.get_principal(lbls)
        except Exception as e:
            sys.stderr.write(f'principal load failed: {e}\n')
            pr = {'principal': None, 'cash_in': None, 'cash_out': None,
                  'qty_flow': False, 'by_label': {}}

        owner_data[owner] = {
            'labels': lbls,
            'consolidated': consolidated,
            'rows': owner_rows,
            'total_cost': total_cost,
            'total_value': total_value,
            'total_profit': total_profit,
            'total_profit_rate': total_profit_rate,
            'deposit': deposit,
            'deposit_by_label': deposit_by_label,
            'total_net': total_net,
            'traded_today': traded_today,
            'prev_net': prev_net,
            'day_pl_total': day_pl_total,
            'day_pl_reason': day_pl_reason,
            'realized_pl_total': realized_pl_total,
            'realized_fees_total': realized_fees_total,
            'cash_in': cash_in,
            'cash_out': cash_out,
            'net_cash_flow': net_cash_flow,
            'cash_flows': owner_cash_flows,
            'principal': pr['principal'],
            'principal_cash_in': pr['cash_in'],
            'principal_cash_out': pr['cash_out'],
            'principal_qty_flow': pr['qty_flow'],
            'principal_by_label': pr['by_label'],
        }

    # 당일정산(phantom) 종목에 현재가 주입 — 매도 시점과 시장 종가 비교용.
    # ka10095 batch 1콜로 처리. 실패해도 웹뷰는 정상 응답.
    phantom_codes = sorted({
        r['code']
        for d in owner_data.values()
        for r in d['consolidated']
        if r.get('phantom') and r.get('code')
    })
    if phantom_codes:
        try:
            import kiwoom_client as kc
            live_quotes = kc.get_watchlist_quotes(phantom_codes)
        except Exception as e:
            sys.stderr.write(f'[phantom-quote] fetch failed: {e}\n')
            live_quotes = {}
        for d in owner_data.values():
            for r in d['consolidated']:
                if r.get('phantom') and r.get('code'):
                    q = live_quotes.get(r['code'])
                    if q:
                        r['live_price'] = q.get('price', 0)
                        r['live_change_pct'] = q.get('change_pct', 0.0)

    ordered = sorted(owner_data.keys(), key=lambda o: (o != '본인', o))
    return ordered, owner_data


def _annotate_card(c: dict, holdings: dict) -> None:
    """카드에 보유 여부·기준가·diff·pct 부여. holdings는 코드 keyed dict.

    ref_price/diff는 손익 계산용:
      - 보유: 평단 기준 (P&L)
      - 감시: 매수가 기준 (entry 거리)
    """
    code = (c.get('code') or '').strip()
    held = holdings.get(code) if code else None
    price = c.get('price')
    if held:
        c['mode'] = 'held'
        c['held_qty'] = held['qty']
        c['held_avg_price'] = held['avg_price']
        c['held_accounts'] = held['accounts']
        c['ref_price'] = held['avg_price']
        c['ref_label'] = '평단'
    else:
        c['mode'] = 'watching'
        buy = c.get('buy') if isinstance(c.get('buy'), dict) else {}
        c['ref_price'] = (buy or {}).get('primary')
        c['ref_label'] = '매수가'
    ref = c.get('ref_price')
    if price is not None and isinstance(ref, (int, float)) and ref > 0:
        c['diff'] = price - ref
        c['pct'] = (c['diff'] / ref) * 100
    else:
        c['diff'] = None
        c['pct'] = None


def _fetch_all_data(entries: list[dict], only_owner: str | None = None) -> dict:
    """한 페이지 로드 단위로 키움 호출을 묶는다.

    병렬:
      - kt00018(보유종목 전체 계좌)
      - kt00001(예수금) × 계좌 수
      - ka10170(당일매매일지 전체 계좌)
      - ka10095(워치리스트 종목 현재가 batch 1콜, miss 시 ka10001 단건 fallback)

    only_owner: '본인' / '가희' 등 owner 지정 시 해당 라벨 계좌만 호출하고 워치리스트 quotes는 skip
      (owner 탭 자동 갱신용 — 보유종목 가격은 kt00018 평가가에 포함, 워치리스트와 무관).
    어느 분기 실패하든 다른 분기 데이터는 계속 흐른다. 워치리스트가 비어도 holdings 분기는 그대로.
    """
    import kiwoom_client as kc
    from stock_portfolio_report import derive_owner

    try:
        accounts = kc.list_accounts()
    except Exception as e:
        sys.stderr.write(f'list_accounts failed: {e}\n')
        accounts = []
    all_labels = [a['label'] for a in accounts]
    if only_owner:
        labels = [lb for lb in all_labels if derive_owner(lb) == only_owner]
    else:
        labels = all_labels

    quote_entries = [] if only_owner else entries

    with ThreadPoolExecutor(max_workers=max(len(labels), 1) + 6) as ex:
        # NXT 우선 가격 — 한국거래소 마감(15:30) 후에도 NXT 운영(20:00까지)으로 보유 종목 시세가 살아 움직임.
        # NXT 미상장 종목은 키움이 KRX 가격으로 자동 fallback. 잔고 평가(eval_value 등)도 NXT 기준 재계산.
        positions_f = ex.submit(kc.get_positions_all, exchange='NXT', labels=labels if only_owner else None)
        balance_fs = {lb: ex.submit(kc.get_balance, lb) for lb in labels}
        journal_f = ex.submit(kc.get_trade_journal_all, None, labels if only_owner else None)
        quotes_f = ex.submit(_fetch_quotes_batch, quote_entries)
        # 미체결 (ka10075) — 보유/감시 종목에 매수/매도대기 배지 표시용
        open_orders_f = ex.submit(kc.get_open_orders_all, labels=labels)
        # 당일 입출금 (kt00015 tp='1') — 당일 평가손익 보정용
        cash_flow_f = ex.submit(kc.get_cash_flow_all, None, labels if only_owner else None)

        try:
            positions_by_acc = positions_f.result()
        except Exception as e:
            sys.stderr.write(f'positions fetch failed: {e}\n')
            positions_by_acc = {}
        # 가격 보정 — KRX와 NXT 각각 ka10095 batch 호출.
        # kiwoom_client.get_watchlist_quotes 의 exchange: 'KRX'=KRX 단독 / 'NX'=NXT 단독 / 'AL'=통합.
        # ('NXT' 값은 함수에서 인식 안 되고 KRX 기본으로 떨어짐 — 사용 X)
        # phase별 우선 시장:
        # - regular(09:00~15:30): KRX 라이브 가격
        # - nxt(08:00~09:00, 15:30~20:00): NXT 라이브 가격
        # - 그 외(closed/holiday/weekend): kt00018 NXT 응답 그대로 유지
        held_codes_set: set[str] = set()
        for _items in positions_by_acc.values():
            for _p in _items:
                if _p.get('code'):
                    held_codes_set.add(_p['code'])
        ohlc_map_krx: dict[str, dict] = {}
        ohlc_map_nxt: dict[str, dict] = {}
        ohlc_map_al: dict[str, dict] = {}
        # KRX/NX 2콜은 가격 보정뿐 아니라 보유종목 행의 KRX·NXT 듀얼 OHLC 미니(r['ohlc_krx']·['ohlc_nxt'])
        # 데이터원이다. _AL 허브는 거래소별 단일 최신값만 들어 두 컬럼을 동시에 못 주므로 skip 불가 — WS 연결과 무관하게 호출.
        # AL(통합) 1콜은 요약 행 단일 캔들(r['ohlc']) 전용 — 시가는 NXT 프리마켓 시가, 고저는 두 시장 극값.
        if held_codes_set:
            try:
                ohlc_map_krx = kc.get_watchlist_quotes(list(held_codes_set), exchange='KRX')
            except Exception as e:
                sys.stderr.write(f'KRX positions price fetch failed: {e}\n')
            try:
                ohlc_map_nxt = kc.get_watchlist_quotes(list(held_codes_set), exchange='NX')
            except Exception as e:
                sys.stderr.write(f'NXT positions price fetch failed: {e}\n')
            try:
                ohlc_map_al = kc.get_watchlist_quotes(list(held_codes_set), exchange='AL')
            except Exception as e:
                sys.stderr.write(f'AL positions price fetch failed: {e}\n')
        _phase = _market_phase_state().get('phase')
        active_map = ohlc_map_nxt if _phase == 'nxt' else (ohlc_map_krx if _phase == 'regular' else None)
        if active_map:
            for _items in positions_by_acc.values():
                for _p in _items:
                    q = active_map.get(_p.get('code'))
                    if not q:
                        continue
                    px = q.get('price', 0)
                    if not px or px <= 0:
                        continue
                    pcp = _p.get('pred_close_pric', 0) or 0
                    # 활성 시장 응답이 거래가 없는 fallback (price == pred & change == 0)이면 덮어쓰기 X
                    if pcp and px == pcp and (q.get('change') or 0) == 0:
                        continue
                    _p['cur_price'] = px
                    qty = _p.get('qty', 0)
                    avg = _p.get('avg_price', 0)
                    _p['evlt_amt'] = px * qty
                    if avg > 0:
                        _p['evltv_prft'] = (px - avg) * qty
                        _p['prft_rt'] = ((px - avg) / avg) * 100
                    ch = q.get('change')
                    if ch is not None:
                        _p['day_change'] = ch
                        _p['day_change_pct'] = q.get('change_pct') or 0.0
        balances: dict = {}
        for lb, fut in balance_fs.items():
            try:
                balances[lb] = fut.result()
            except Exception as e:
                sys.stderr.write(f'balance fetch failed [{lb}]: {e}\n')
        try:
            journal_by_label = journal_f.result()
        except Exception as e:
            sys.stderr.write(f'journal fetch failed: {e}\n')
            journal_by_label = {}
        # 휴장일/주말엔 ka10170이 직전 영업일 데이터를 반환 → 당일매매·당일정산·라벨별 실현손익에 어제 거래가 잘못 표시.
        import trade_journal as tj
        if not tj.is_market_open(datetime.now(tj.KST)):
            journal_by_label = {}
        # 페이지 RENDER마다 ka10170 응답 그대로 trade_journal.jsonl 적재 (추가 API 호출 X).
        # 외부(HTS/MTS 등)에서 매매 후 자산웹 새로고침하면 즉시 거래내역 반영. 21:00 launchd가 최종 덮어씀.
        if journal_by_label:
            try:
                tj.record_trades(journal_by_label, quiet=True, tag='render')
            except Exception as e:
                sys.stderr.write(f'[render→journal] record_trades failed: {e}\n')
            # 당일 매수 동반 매도행의 pl을 '전일 보유 평단' 기준으로 재계산 (ka10170 round-trip 오표시 교정).
            # 전일 평단은 파일 이력 재생에서. raw 저장(record_trades) 직후라 prev_avg_map은 당일분 제외(date<오늘).
            try:
                _today_iso = datetime.now(tj.KST).strftime('%Y-%m-%d')
                _prev_avgs = tj.prev_avg_map(_today_iso)
                for _lb, _trs in journal_by_label.items():
                    for _t in _trs:
                        _pa = _prev_avgs.get((_lb, _t.get('code')), 0)
                        if (_t.get('sell_qty') or 0) > 0 and (_t.get('buy_qty') or 0) > 0 and _pa > 0:
                            tj._apply_rebase(_t, float(_t.get('sell_avg') or 0), int(_t['sell_qty']), _pa)
            except Exception as e:
                sys.stderr.write(f'[journal rebase] failed: {e}\n')
        cards = quotes_f.result()
        try:
            open_orders = open_orders_f.result()
        except Exception as e:
            sys.stderr.write(f'open orders fetch failed: {e}\n')
            open_orders = []
        try:
            cash_flow_by_label = cash_flow_f.result()
        except Exception as e:
            sys.stderr.write(f'cash flow fetch failed: {e}\n')
            cash_flow_by_label = {}

    # 투자원금 — 방금 받은 당일 입출금이 캐시에 미반영일 때만 올해 창 재조회(kt00016 라벨당 1콜).
    # 입출금 없는 평시엔 캐시 읽기만 하고 호출 0. 부트스트랩 전이면 needs_refresh가 False.
    try:
        import investment_principal as ip
        if ip.needs_refresh(labels, cash_flow_by_label):
            ip.refresh_current(labels, cash_flow_by_label)
    except Exception as e:
        sys.stderr.write(f'principal refresh failed: {e}\n')

    # 미체결 → owner별 + 전역 코드 keyed pending map
    # pending_by_owner[owner][code] = {'buy_qty': N, 'sell_qty': M, 'orders': [...]}
    # pending_global[code]          = {'buy_qty': N, 'sell_qty': M, 'orders': [...]}
    # orders 원소: {ord_no, side, qty, price, order_type, time, account, exchange}
    def _empty_bucket():
        return {'buy_qty': 0, 'sell_qty': 0, 'orders': []}

    pending_by_owner: dict[str, dict[str, dict]] = {}
    pending_global: dict[str, dict] = {}
    pending_names_by_code: dict[str, str] = {}  # 미보유 종목 카드 렌더용 종목명
    pending_buy_lock_by_label: dict[str, int] = {}  # 라벨별 매수 미체결 묶임 금액 (지정가만)
    for o in open_orders:
        code = o.get('code')
        if not code:
            continue
        if o.get('name') and code not in pending_names_by_code:
            pending_names_by_code[code] = o['name']
        owner = derive_owner(o.get('account', ''))
        side = o.get('side')
        qty = o.get('unfilled_qty', 0)
        if qty <= 0:
            continue
        ord_entry = {
            'ord_no': o.get('ord_no', ''),
            'side': side,
            'qty': qty,
            'price': o.get('order_price', 0),
            'order_type': o.get('order_type', ''),
            # 스톱지정가 조건단가 (ka10075 stop_pric) + 트레일링 예약이면 그 상태.
            # 상세 표시가 "지정가"로 뭉개지지 않게 주문 성격을 그대로 실어 보낸다.
            'stop_price': o.get('stop_price', 0),
            'trailing': _trailing_info_for(o.get('ord_no', '')),
            'time': o.get('order_time', ''),
            'account': o.get('account', ''),
            'exchange': o.get('exchange', ''),
        }
        bucket = pending_by_owner.setdefault(owner, {}).setdefault(code, _empty_bucket())
        g = pending_global.setdefault(code, _empty_bucket())
        if side == 'BUY':
            bucket['buy_qty'] += qty
            g['buy_qty'] += qty
            px = o.get('order_price', 0)
            if px > 0:
                pending_buy_lock_by_label[o.get('account', '')] = (
                    pending_buy_lock_by_label.get(o.get('account', ''), 0) + qty * px
                )
        elif side == 'SELL':
            bucket['sell_qty'] += qty
            g['sell_qty'] += qty
        bucket['orders'].append(ord_entry)
        g['orders'].append(ord_entry)

    holdings_map = _holdings_map_from_positions(positions_by_acc)
    for c in cards:
        _annotate_card(c, holdings_map)
        # 워치리스트 카드는 owner 구분 없음 — pending_global 사용
        p = pending_global.get(c.get('code') or '')
        if p:
            c['pending_buy_qty'] = p['buy_qty']
            c['pending_sell_qty'] = p['sell_qty']
            c['pending_orders'] = p['orders']

    prev_key = None
    prev_snap_all: dict = {}
    try:
        from stock_portfolio_report import SNAPSHOT_FILE, _normalize_prev_snap, load_json
        snapshots = load_json(SNAPSHOT_FILE, {})
        today_key = datetime.now(KST).strftime('%Y-%m-%d')
        prev_keys = sorted(k for k in snapshots if k < today_key)
        prev_key = prev_keys[-1] if prev_keys else None
        if prev_key:
            prev_snap_all = _normalize_prev_snap(snapshots.get(prev_key, {}))
        # `_watchlist_nxt` / `_nxt_inactive` 가 어제 스냅샷에 없으면 오늘 스냅샷에서 fallback —
        # 장중에 즉시 NXT 컬럼을 보기 위함. 오늘자는 "오늘 20:10 NXT 가격"이라 정확한 어제 마감가는 아니지만 NXT 흐름 확인엔 충분.
        today_raw = snapshots.get(today_key) or {}
        today_norm = _normalize_prev_snap(today_raw) if today_raw else {}
        if not (isinstance(prev_snap_all.get('_watchlist_nxt'), dict) and prev_snap_all.get('_watchlist_nxt')):
            if isinstance(today_norm.get('_watchlist_nxt'), dict) and today_norm['_watchlist_nxt']:
                prev_snap_all['_watchlist_nxt'] = today_norm['_watchlist_nxt']
        if not (isinstance(prev_snap_all.get('_nxt_inactive'), list) and prev_snap_all.get('_nxt_inactive')):
            if isinstance(today_norm.get('_nxt_inactive'), list) and today_norm['_nxt_inactive']:
                prev_snap_all['_nxt_inactive'] = today_norm['_nxt_inactive']
    except Exception as e:
        sys.stderr.write(f'snapshot load failed: {e}\n')

    # watchlist·interests 카드에 어제 NXT 종가 주입 — ohlc-mini의 '전일(NXT)' 컬럼 baseline.
    # stock.briefing 20:10 시점 NXT 가격이 _watchlist_nxt 키에 저장됨. 없는 코드는 0으로 두면 `-` placeholder.
    wl_nxt_map = prev_snap_all.get('_watchlist_nxt') if isinstance(prev_snap_all.get('_watchlist_nxt'), dict) else {}
    # NXT 미상장 코드 — KRX fallback 차단용. 보유 종목도 같이 표기되어 _pl_row가 lookup.
    nxt_inactive_raw = prev_snap_all.get('_nxt_inactive') if isinstance(prev_snap_all.get('_nxt_inactive'), list) else []
    nxt_inactive_set = set(nxt_inactive_raw)
    if wl_nxt_map or nxt_inactive_set:
        for c in cards:
            code = c.get('code')
            if not code:
                continue
            if code in nxt_inactive_set:
                c['_nxt_inactive'] = True
            elif code in wl_nxt_map:
                c['_pred_close_nxt'] = wl_nxt_map[code]

    # 매수 미체결 묶임 금액을 balances에 라벨별로 주입 (합산·계좌별 KPI 예수금 부연 표시용)
    for lb in balances:
        balances[lb]['pending_buy_locked'] = pending_buy_lock_by_label.get(lb, 0)

    try:
        ordered_owners, owner_data = _build_owner_data(positions_by_acc, balances, journal_by_label, prev_snap_all, cash_flow_by_label)
    except Exception as e:
        sys.stderr.write(f'owner aggregate failed: {e}\n')
        traceback.print_exc()
        ordered_owners, owner_data = [], {}

    # owner별 매수 묶임 합산
    for owner_name, od in owner_data.items():
        od['pending_buy_locked'] = sum(
            balances.get(lb, {}).get('pending_buy_locked', 0) for lb in od.get('labels', [])
        )

    # consolidated/phantom 행에 미체결 수량 주입 (owner 단위)
    for owner_name, od in owner_data.items():
        owner_pending = pending_by_owner.get(owner_name, {})
        for r in od.get('consolidated', []):
            p = owner_pending.get(r.get('code') or '')
            if p:
                r['pending_buy_qty'] = p['buy_qty']
                r['pending_sell_qty'] = p['sell_qty']
                r['pending_orders'] = p['orders']
        for r in od.get('phantom', []) if isinstance(od.get('phantom'), list) else []:
            p = owner_pending.get(r.get('code') or '')
            if p:
                r['pending_buy_qty'] = p['buy_qty']
                r['pending_sell_qty'] = p['sell_qty']
                r['pending_orders'] = p['orders']

    # 미체결 매수 주문 중 owner 미보유 종목 → owner 패널 별도 섹션 노출.
    # consolidated에 들어가지 않은 코드만 추림 (보유 + 당일정산 모두 제외).
    unheld_codes_all: set[str] = set()
    for owner_name, od in owner_data.items():
        present_codes = {r.get('code') for r in od.get('consolidated', []) if r.get('code')}
        owner_pending = pending_by_owner.get(owner_name, {})
        unheld_rows: list[dict] = []
        for code, p in owner_pending.items():
            if code in present_codes or p['buy_qty'] <= 0:
                continue
            unheld_rows.append({
                'code': code,
                'stock': pending_names_by_code.get(code, ''),
                'pending_buy_qty': p['buy_qty'],
                'pending_sell_qty': p['sell_qty'],
                'pending_orders': list(p['orders']),
            })
            unheld_codes_all.add(code)
        unheld_rows.sort(key=lambda r: -r.get('pending_buy_qty', 0))
        od['pending_unheld'] = unheld_rows

    # 미보유 미체결 종목 현재가 batch (ka10095). 캐시 hit이면 즉시 반환.
    if unheld_codes_all:
        try:
            unheld_quotes = kc.get_watchlist_quotes(sorted(unheld_codes_all))
        except Exception as e:
            sys.stderr.write(f'[pending-unheld] quote batch failed: {e}\n')
            unheld_quotes = {}
        for od in owner_data.values():
            for r in od.get('pending_unheld', []):
                q = unheld_quotes.get(r['code'])
                if q:
                    r['price'] = q.get('price') or 0
                    r['day_change'] = q.get('change')
                    r['day_change_pct'] = q.get('change_pct')
                    r['open'] = q.get('open')  # 미리보기 등락은 시가 대비 → open 필요

    # 보유 종목 OHLC batch — 카드 펼치면 보이는 오늘 단일 캔들 + 자세히 보기 mini-table용.
    # ohlc_map_krx (exchange='KRX')와 ohlc_map_nxt (exchange='NX'=NXT 단독)는 positions 보정에서 이미 호출 — 재사용.
    # 각 컬럼은 그 시장 데이터로 정확히 채움 (서로 섞이지 않도록). primary 단일 캔들은 통합(AL) 기준.
    if ohlc_map_krx or ohlc_map_nxt or ohlc_map_al:
        _phase = _market_phase_state().get('phase')
        for od in owner_data.values():
            for r in od.get('consolidated', []):
                code = r.get('code')
                qk = ohlc_map_krx.get(code) if code else None
                qn = ohlc_map_nxt.get(code) if code else None
                # price 만 있어도 valid — 거래 없는 시간대(시가/고가/저가=0)도 종가만 표시되도록.
                qk_valid = bool(qk and qk.get('price'))
                qn_valid = bool(qn and qn.get('price'))
                if qk_valid:
                    r['ohlc_krx'] = {
                        'open': qk['open'], 'high': qk['high'], 'low': qk['low'],
                        'price': qk.get('price', 0), 'change_pct': qk.get('change_pct', 0.0),
                        'change': qk.get('change', 0), 'volume': qk.get('volume', 0),
                    }
                if qn_valid:
                    r['ohlc_nxt'] = {
                        'open': qn['open'], 'high': qn['high'], 'low': qn['low'],
                        'price': qn.get('price', 0), 'change_pct': qn.get('change_pct', 0.0),
                        'change': qn.get('change', 0), 'volume': qn.get('volume', 0),
                    }
                # 단일 캔들 primary — 4값(open/high/low/price) 모두 있는 응답만 후보. 거래 없는 시간대 0값 응답 배제.
                # AL(통합) 우선 — 시간대에 따라 KRX↔NXT 로 캔들이 뒤바뀌지 않고 하루 한 개로 유지된다.
                # NXT 미거래 종목은 AL == KRX 라 결과가 같다. AL 응답이 불완전할 때만 phase 우선순위로 폴백.
                qa = ohlc_map_al.get(code) if code else None
                qk_full = qk_valid and qk.get('open') and qk.get('high') and qk.get('low')
                qn_full = qn_valid and qn.get('open') and qn.get('high') and qn.get('low')
                qa_full = bool(qa and qa.get('price') and qa.get('open') and qa.get('high') and qa.get('low'))
                if qa_full:
                    primary = qa
                elif _phase == 'regular':
                    primary = qk if qk_full else (qn if qn_full else None)
                else:
                    primary = qn if qn_full else (qk if qk_full else None)
                if primary:
                    r['ohlc'] = {'o': primary['open'], 'h': primary['high'], 'l': primary['low'], 'c': primary.get('price', 0) or r.get('price', 0)}

    return {
        'cards': cards,
        'balances': balances,
        'ordered_owners': ordered_owners,
        'owner_data': owner_data,
        'journal_by_label': journal_by_label,
        'only_owner': only_owner,
    }


def _apply_watchlist_action(action: str, stock: str) -> None:
    """delete = pending_delete로 표시. restore = active로 복귀. purge = 완전 삭제.

    behive_youtube_digest.watchlist_lock과 동일한 lockfile을 사용해 cron save와의 race를 차단한다.
    """
    import behive_youtube_digest as byd
    with byd.watchlist_lock():
        if not WATCHLIST.exists():
            raise KeyError(stock)
        data = json.loads(WATCHLIST.read_text())
        if stock not in data:
            raise KeyError(stock)
        if action == 'delete':
            data[stock]['status'] = 'pending_delete'
            data[stock]['pending_delete_at'] = datetime.now(KST).isoformat()
        elif action == 'restore':
            data[stock].pop('status', None)
            data[stock].pop('pending_delete_at', None)
        elif action == 'purge':
            data.pop(stock)
        else:
            raise ValueError(action)
        tmp = WATCHLIST.with_suffix('.json.tmp')
        tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2))
        tmp.replace(WATCHLIST)


def _load_alerts_map() -> dict[str, list[str]]:
    """state/watchlist_alerts.json → {stock: [conds]}. 신규/레거시 포맷 모두 호환.
    렌더링 전용 read-only — 락 안 잡음(다음 monitor가 신규 포맷으로 일관 정리)."""
    if not ALERTS_STATE.exists():
        return {}
    try:
        raw = json.loads(ALERTS_STATE.read_text())
    except Exception:
        return {}
    if not isinstance(raw, dict):
        return {}
    if isinstance(raw.get('alerts'), dict):
        return {k: list(v) for k, v in raw['alerts'].items() if isinstance(v, list)}
    # 레거시 date-keyed: 모든 날짜 머지
    merged: dict[str, list[str]] = {}
    for v in raw.values():
        if not isinstance(v, dict):
            continue
        for s, conds in v.items():
            if not isinstance(conds, list):
                continue
            bucket = merged.setdefault(s, [])
            for c in conds:
                if isinstance(c, str) and c not in bucket:
                    bucket.append(c)
    return merged


def _apply_alerts_reset(stock: str) -> None:
    """해당 종목의 알림 dedup 기록 전부 삭제 → 다음 monitor 사이클에서 재알림 가능.

    watchlist_monitor.alerts_lock과 동일 lockfile 사용. 종목이 state 에 없으면 no-op
    (404 아님 — 한 번도 알림 안 떴거나 이미 비어 있는 정상 상태)."""
    import watchlist_monitor as wm
    with wm.alerts_lock():
        state = wm.load_alerts_state()
        alerts = state.setdefault('alerts', {})
        alerts.pop(stock, None)
        tmp = ALERTS_STATE.with_suffix('.json.tmp')
        ALERTS_STATE.parent.mkdir(parents=True, exist_ok=True)
        tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2))
        tmp.replace(ALERTS_STATE)


class MultipleMatchError(Exception):
    """다중 매칭. do_POST에서 후보 JSON 응답으로 분기."""
    def __init__(self, query: str, candidates: list[dict]):
        self.query = query
        self.candidates = candidates
        super().__init__(query)


_MARKET_BY_CODE_CACHE = None


def _market_label_html(code: str) -> str:
    """종목코드 → 시장(코스피/코스닥) 작은 배지 HTML. 미상·ETF 등이면 ''.
    stock_codes.json(name→{code,market})을 code→market로 뒤집어 프로세스 1회 캐시."""
    global _MARKET_BY_CODE_CACHE
    code = (code or '').strip()
    if not code:
        return ''
    if _MARKET_BY_CODE_CACHE is None:
        import kiwoom_client as kc
        _MARKET_BY_CODE_CACHE = {
            info['code']: info['market']
            for info in kc._load_code_cache().values()
            if info.get('code') and info.get('market')
        }
    mk = _MARKET_BY_CODE_CACHE.get(code)
    if mk == 'KOSPI':
        return '<span class="detail-market mkt-kospi">KOSPI</span>'
    if mk == 'KOSDAQ':
        return '<span class="detail-market mkt-kosdaq">KOSDAQ</span>'
    return ''


def _resolve_stock_fuzzy(query: str) -> dict:
    """정확 매칭(case-sensitive) → substring(case-insensitive) 단일 → 다중이면 후보 응답.
    'lg' 입력 시 'LG' 단독 자동 통과하지 않고 'LG전자' 등도 함께 후보로 노출되도록
    case-insensitive exact 단계는 제외. 후보 정렬은 ci-exact > prefix > 나머지."""
    import kiwoom_client as kc
    q = (query or '').strip()
    if not q:
        raise ValueError('빈 입력')
    cache = kc._load_code_cache()
    if q in cache:
        return cache[q]
    if q.isdigit() and len(q) == 6:
        try:
            return kc.resolve_stock_code(q)
        except KeyError:
            raise ValueError(f'코드 "{q}" 조회 실패')
    ql = q.lower()
    matched = [info for name, info in cache.items() if ql in name.lower()]
    if len(matched) == 1:
        return matched[0]
    if len(matched) > 1:
        def sort_key(info: dict) -> tuple:
            n = info['name'].lower()
            if n == ql:
                return (0, n)
            if n.startswith(ql):
                return (1, n)
            return (2, n)
        matched.sort(key=sort_key)
        raise MultipleMatchError(q, matched[:30])
    try:
        return kc.resolve_stock_code(q)
    except KeyError:
        raise ValueError(f'"{q}"와 일치하는 종목을 찾을 수 없습니다. 종목명 또는 6자리 종목코드를 입력해주세요.')


_INTEREST_PRICE_LABEL = {'buy_price': '매수가', 'target_price': '목표가', 'stop_price': '손절가'}


def _interests_lock():
    """프로세스간 INTERESTS read-modify-write 직렬화. byd.watchlist_lock 패턴 동일."""
    import fcntl as _fcntl
    from contextlib import contextmanager as _cm

    @_cm
    def _ctx():
        lock_path = INTERESTS.with_suffix(INTERESTS.suffix + '.lock')
        lock_path.parent.mkdir(parents=True, exist_ok=True)
        f = open(lock_path, 'a')
        try:
            _fcntl.flock(f.fileno(), _fcntl.LOCK_EX)
            yield
        finally:
            try:
                _fcntl.flock(f.fileno(), _fcntl.LOCK_UN)
            finally:
                f.close()
    return _ctx()


def _stock_tags_lock():
    """STOCK_TAGS 파일 직렬화 — interests_lock 와 동일 패턴, 별도 lock 파일."""
    import fcntl as _fcntl
    from contextlib import contextmanager as _cm

    @_cm
    def _ctx():
        lock_path = STOCK_TAGS.with_suffix(STOCK_TAGS.suffix + '.lock')
        lock_path.parent.mkdir(parents=True, exist_ok=True)
        f = open(lock_path, 'a')
        try:
            _fcntl.flock(f.fileno(), _fcntl.LOCK_EX)
            yield
        finally:
            try:
                _fcntl.flock(f.fileno(), _fcntl.LOCK_UN)
            finally:
                f.close()
    return _ctx()


def _load_stock_tags() -> dict:
    """{'by_code': {code: tag}, 'by_name': {name: tag}}. 종목 공용 태그 저장소."""
    if not STOCK_TAGS.exists():
        return {'by_code': {}, 'by_name': {}}
    try:
        d = json.loads(STOCK_TAGS.read_text())
        if 'by_code' not in d:
            d['by_code'] = {}
        if 'by_name' not in d:
            d['by_name'] = {}
        return d
    except Exception:
        return {'by_code': {}, 'by_name': {}}


def _get_stock_tag(code: str | None, name: str | None) -> dict:
    """{'text': str, 'leader': bool} 반환. 항목 없으면 {}. code 우선 → name fallback.
    구버전(문자열) 호환: 'X' → {'text': 'X', 'leader': X=='대장'} 변환."""
    if not code and not name:
        return {}
    tags = _load_stock_tags()
    for src_key, key in (('by_code', code), ('by_name', name)):
        if not key:
            continue
        v = tags.get(src_key, {}).get(key)
        if not v:
            continue
        if isinstance(v, str):
            txt = v.strip()
            return {'text': txt, 'leader': txt == '대장'}
        if isinstance(v, dict):
            txt = (v.get('text') or '').strip()
            ldr = bool(v.get('leader'))
            if txt or ldr:
                return {'text': txt, 'leader': ldr}
    return {}


def _tag_chip_html(code: str, name: str, interactive: bool = False) -> str:
    """종목명 옆 태그 칩 — 표시 전용 (클릭 비활성). 수정은 detail 안 '+ 태그' 버튼에서만.
    - text 있을 때만 렌더 (leader 단독은 칩 미노출, 토글 상태만 보관)
    - leader=True → 빨강(tag-leader), 그 외 → 노랑(tag-chip)
    - interactive 인자는 후방 호환 위해 받지만 더 이상 사용 안 함 (항상 span)."""
    code_s = (code or '').strip()
    name_s = (name or '').strip()
    if not code_s and not name_s:
        return ''
    t = _get_stock_tag(code_s, name_s)
    text = t.get('text', '')
    leader = bool(t.get('leader'))
    if not text:
        return ''
    cls = 'tag-chip tag-leader' if leader else 'tag-chip'
    chip_text = html.escape(text)
    return f'<span class="{cls}">{chip_text}</span>'


def _tag_add_button_html(code: str, name: str) -> str:
    """detail 영역(자세히보기 펼침) actions의 '태그' 버튼. 칩 유무와 무관하게 항상 노출.
    클릭 시 tag-modal 열리며 현재 text/leader 가 prefill 됨."""
    code_s = (code or '').strip()
    name_s = (name or '').strip()
    if not code_s and not name_s:
        return ''
    t = _get_stock_tag(code_s, name_s)
    text = t.get('text', '')
    leader = bool(t.get('leader'))
    code_attr = html.escape(code_s, quote=True)
    name_attr = html.escape(name_s, quote=True)
    return (
        f'<button type="button" class="btn-add-tag" data-tag-edit="1"'
        f' data-tag-stock="{name_attr}" data-tag-code="{code_attr}"'
        f' data-tag-text="{html.escape(text, quote=True)}"'
        f' data-tag-leader="{"1" if leader else "0"}"'
        f' title="태그 설정">+ 태그</button>'
    )


def _info_button_html(code: str, name: str) -> str:
    """detail actions의 '상세' 버튼. 클릭 시 info-modal 열고 /api/stock_info fetch.
    종목코드 없으면(키움 조회 불가) 미노출."""
    code_s = (code or '').strip()
    if not code_s:
        return ''
    return (
        f'<button type="button" class="btn-info" data-info-code="{html.escape(code_s, quote=True)}"'
        f' data-info-stock="{html.escape((name or "").strip(), quote=True)}"'
        f' title="기업정보 (시총·PER·PBR 등)">📈 상세</button>'
    )


def _set_stock_tag(code: str | None, name: str | None, text: str, leader: bool) -> None:
    """{text, leader} 저장. text와 leader 둘 다 빈/False면 항목 제거.
    text는 # prefix 자동 strip + 20자 cap."""
    text = (text or '').strip().lstrip('#').strip()[:20]
    leader = bool(leader)
    code = (code or '').strip() or None
    name = (name or '').strip() or None
    if not code and not name:
        raise ValueError('종목코드 또는 종목명 중 하나는 필요합니다')
    STOCK_TAGS.parent.mkdir(parents=True, exist_ok=True)
    with _stock_tags_lock():
        d = _load_stock_tags()
        if text or leader:
            entry = {'text': text, 'leader': leader}
            if code:
                d['by_code'][code] = entry
            if name:
                d['by_name'][name] = entry
        else:
            if code:
                d['by_code'].pop(code, None)
            if name:
                d['by_name'].pop(name, None)
        tmp = STOCK_TAGS.with_suffix('.json.tmp')
        tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2))
        tmp.replace(STOCK_TAGS)


# ---- 트레일링 계단 프리셋 (읽기 전용 · 고정 4종) ----
# 계단 = [{'pct': 고점대비 하락률, 'cum': 그때까지 팔려 있어야 할 누적 비중}]
# 누적으로 적는 이유는 관리자님이 그렇게 생각하기 때문이다("30% 빠지면 전량") —
# 주문 수량인 추가분(20/30/50)으로의 환산은 orders/trailing.normalize_steps 가 한다.
# 순서 = 기본값이 맨 앞(단일) + 트레일 폭이 좁은 것부터. 화면에서 고른 뒤 그 자리에서
# 고칠 수 있으므로 저장 기능은 두지 않는다(2026-07-31 관리자님 지시로 제거).
TRAIL_PRESETS = [
    {'name': '단일', 'steps': [{'pct': 10, 'cum': 100}]},
    {'name': '보수', 'steps': [{'pct': 5, 'cum': 30}, {'pct': 10, 'cum': 70},
                              {'pct': 15, 'cum': 100}]},
    {'name': '표준', 'steps': [{'pct': 10, 'cum': 20}, {'pct': 20, 'cum': 50},
                              {'pct': 30, 'cum': 100}]},
    {'name': '느슨', 'steps': [{'pct': 15, 'cum': 25}, {'pct': 25, 'cum': 50},
                              {'pct': 30, 'cum': 100}]},
]


def _stock_notes_lock():
    """STOCK_NOTES 파일 직렬화 — _stock_tags_lock 과 동일 패턴, 별도 lock 파일."""
    import fcntl as _fcntl
    from contextlib import contextmanager as _cm

    @_cm
    def _ctx():
        lock_path = STOCK_NOTES.with_suffix(STOCK_NOTES.suffix + '.lock')
        lock_path.parent.mkdir(parents=True, exist_ok=True)
        f = open(lock_path, 'a')
        try:
            _fcntl.flock(f.fileno(), _fcntl.LOCK_EX)
            yield
        finally:
            try:
                _fcntl.flock(f.fileno(), _fcntl.LOCK_UN)
            finally:
                f.close()
    return _ctx()


def _load_stock_notes() -> dict:
    """{'by_code': {code: text}, 'by_name': {name: text}}. 종목별 자유 메모 저장소."""
    if not STOCK_NOTES.exists():
        return {'by_code': {}, 'by_name': {}}
    try:
        d = json.loads(STOCK_NOTES.read_text())
        if 'by_code' not in d:
            d['by_code'] = {}
        if 'by_name' not in d:
            d['by_name'] = {}
        return d
    except Exception:
        return {'by_code': {}, 'by_name': {}}


def _get_stock_note(code: str | None, name: str | None) -> str:
    """메모 텍스트 반환. 없으면 ''. code 우선 → name fallback."""
    if not code and not name:
        return ''
    notes = _load_stock_notes()
    for src_key, key in (('by_code', code), ('by_name', name)):
        if not key:
            continue
        v = notes.get(src_key, {}).get(key)
        if isinstance(v, str) and v.strip():
            return v.strip()
    return ''


def _set_stock_note(code: str | None, name: str | None, text: str) -> None:
    """메모 저장. 빈 텍스트면 항목 제거. 1000자 cap."""
    text = (text or '').strip()[:1000]
    code = (code or '').strip() or None
    name = (name or '').strip() or None
    if not code and not name:
        raise ValueError('종목코드 또는 종목명 중 하나는 필요합니다')
    STOCK_NOTES.parent.mkdir(parents=True, exist_ok=True)
    with _stock_notes_lock():
        d = _load_stock_notes()
        if text:
            if code:
                d['by_code'][code] = text
            if name:
                d['by_name'][name] = text
        else:
            if code:
                d['by_code'].pop(code, None)
            if name:
                d['by_name'].pop(name, None)
        tmp = STOCK_NOTES.with_suffix('.json.tmp')
        tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2))
        tmp.replace(STOCK_NOTES)


def _holdeval_lock():
    """HOLDEVAL_SAVED 파일 직렬화 — _stock_notes_lock 과 동일 패턴, 별도 lock 파일."""
    import fcntl as _fcntl
    from contextlib import contextmanager as _cm

    @_cm
    def _ctx():
        lock_path = HOLDEVAL_SAVED.with_suffix(HOLDEVAL_SAVED.suffix + '.lock')
        lock_path.parent.mkdir(parents=True, exist_ok=True)
        f = open(lock_path, 'a')
        try:
            _fcntl.flock(f.fileno(), _fcntl.LOCK_EX)
            yield
        finally:
            try:
                _fcntl.flock(f.fileno(), _fcntl.LOCK_UN)
            finally:
                f.close()
    return _ctx()


def _load_holdeval_saved() -> list:
    """보유 평가 계산기 저장 목록. [{id, owner, code, name, avg, qty}] 리스트."""
    if not HOLDEVAL_SAVED.exists():
        return []
    try:
        d = json.loads(HOLDEVAL_SAVED.read_text())
        return d if isinstance(d, list) else []
    except Exception:
        return []


def _save_holdeval_saved(items: list) -> None:
    HOLDEVAL_SAVED.parent.mkdir(parents=True, exist_ok=True)
    tmp = HOLDEVAL_SAVED.with_suffix('.json.tmp')
    tmp.write_text(json.dumps(items, ensure_ascii=False, indent=2))
    tmp.replace(HOLDEVAL_SAVED)


def _add_holdeval_saved(owner: str, code: str, name: str, avg: float, qty: float) -> list:
    """저장 항목 추가. code·avg·qty 필수. 새 id 부여 후 갱신된 목록 반환."""
    import uuid
    owner = (owner or '').strip()[:40]
    code = ''.join(ch for ch in (code or '').strip() if ch.isalnum())
    name = (name or '').strip()[:40]
    if not code:
        raise ValueError('종목을 먼저 검색·선택하세요')
    if avg <= 0 or qty <= 0:
        raise ValueError('평단가·주수는 0보다 커야 합니다')
    with _holdeval_lock():
        items = _load_holdeval_saved()
        items.append({'id': uuid.uuid4().hex[:8], 'owner': owner, 'code': code,
                      'name': name, 'avg': avg, 'qty': qty})
        _save_holdeval_saved(items)
        return items


def _delete_holdeval_saved(entry_id: str) -> list:
    """id로 저장 항목 삭제. 갱신된 목록 반환."""
    with _holdeval_lock():
        items = [it for it in _load_holdeval_saved() if it.get('id') != entry_id]
        _save_holdeval_saved(items)
        return items


def _update_holdeval_saved(entry_id: str, owner: str, avg: float, qty: float,
                           code: str = '', name: str = '') -> list:
    """id로 저장 항목 수정 (물타기·불타기 후 평단가·주수 갱신). owner·avg·qty 필수.
    code·name은 넘어오면 교체, 비면 기존 유지. 갱신된 목록 반환."""
    owner = (owner or '').strip()[:40]
    code = ''.join(ch for ch in (code or '').strip() if ch.isalnum())
    name = (name or '').strip()[:40]
    if avg <= 0 or qty <= 0:
        raise ValueError('평단가·주수는 0보다 커야 합니다')
    with _holdeval_lock():
        items = _load_holdeval_saved()
        found = False
        for it in items:
            if it.get('id') == entry_id:
                it['owner'] = owner
                it['avg'] = avg
                it['qty'] = qty
                if code:
                    it['code'] = code
                if name:
                    it['name'] = name
                found = True
                break
        if not found:
            raise ValueError('항목을 찾을 수 없습니다')
        _save_holdeval_saved(items)
        return items


def _note_button_html(code: str, name: str) -> str:
    """detail actions의 '메모' 버튼. 클릭 시 note-modal 열리며 현재 메모가 prefill.
    메모가 있으면 ● 표시 + has-note 클래스로 강조."""
    code_s = (code or '').strip()
    name_s = (name or '').strip()
    if not code_s and not name_s:
        return ''
    text = _get_stock_note(code_s, name_s)
    code_attr = html.escape(code_s, quote=True)
    name_attr = html.escape(name_s, quote=True)
    has = bool(text)
    cls = 'btn-note has-note' if has else 'btn-note'
    label = '📝 메모 ●' if has else '📝 메모'
    title = html.escape(text[:60], quote=True) if has else '메모 작성'
    return (
        f'<button type="button" class="{cls}" data-note-edit="1"'
        f' data-note-stock="{name_attr}" data-note-code="{code_attr}"'
        f' data-note-text="{html.escape(text, quote=True)}"'
        f' title="{title}">{label}</button>'
    )


def _surge_toggles_lock():
    """SURGE_TOGGLES 파일 직렬화 — _stock_notes_lock 과 동일 패턴, 별도 lock 파일."""
    import fcntl as _fcntl
    from contextlib import contextmanager as _cm

    @_cm
    def _ctx():
        lock_path = SURGE_TOGGLES.with_suffix(SURGE_TOGGLES.suffix + '.lock')
        lock_path.parent.mkdir(parents=True, exist_ok=True)
        f = open(lock_path, 'a')
        try:
            _fcntl.flock(f.fileno(), _fcntl.LOCK_EX)
            yield
        finally:
            try:
                _fcntl.flock(f.fileno(), _fcntl.LOCK_UN)
            finally:
                f.close()
    return _ctx()


def _load_surge_toggles() -> dict:
    """{'by_code': {code: {'name': str}}}. 급등락 알림 감시 대상 (surge_monitor.py 가 읽는다).

    ⚠️ 키는 종목코드 전용 — 메모(by_name fallback 있음)와 달리 코드 없는 종목은 감시할 수 없다
    (모니터가 시세 조회에 코드를 써야 하므로).
    """
    if not SURGE_TOGGLES.exists():
        return {'by_code': {}}
    try:
        d = json.loads(SURGE_TOGGLES.read_text())
        if not isinstance(d.get('by_code'), dict):
            d['by_code'] = {}
        return d
    except Exception:
        return {'by_code': {}}


def _is_surge_on(code: str | None) -> bool:
    code = (code or '').strip()
    if not code:
        return False
    return bool(_load_surge_toggles().get('by_code', {}).get(code))


def _set_surge_toggle(code: str, name: str, on: bool) -> bool:
    """급등락 알림 on/off. 반환값은 적용 후 상태."""
    code = ''.join(ch for ch in (code or '').strip() if ch.isalnum())
    name = (name or '').strip()[:40]
    if not code:
        raise ValueError('종목코드가 필요합니다 (급등락 감시는 코드 기준)')
    SURGE_TOGGLES.parent.mkdir(parents=True, exist_ok=True)
    with _surge_toggles_lock():
        d = _load_surge_toggles()
        if on:
            d['by_code'][code] = {'name': name}
        else:
            d['by_code'].pop(code, None)
        tmp = SURGE_TOGGLES.with_suffix('.json.tmp')
        tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2))
        tmp.replace(SURGE_TOGGLES)
    return on


def _surge_indicator_html(code: str) -> str:
    """detail-name 줄(종목명 옆)의 급등락 알림 on/off **표시**. 조작 불가 — 켜고 끄기는 알림 팝업에서.

    문턱은 그 종목 과거 이탈폭의 분위수(p90/p98)로 surge_monitor 가 계산한다 (고정 %도, ATR 배수도 아님).
    ⚠️ 2026-08-06 관리자님 지시로 토글 기능을 뺐다. `data-surge-toggle` 이 없어야 위임 핸들러가
    잡지 않는다 — 다시 버튼으로 만들지 말 것(아이콘 하나뿐이라 오조작 위험이 컸다).
    """
    code_s = (code or '').strip()
    if not code_s:
        return ''
    on = _is_surge_on(code_s)
    cls = 'surge-ind has-surge' if on else 'surge-ind'
    return (
        f'<span class="{cls}" data-surge-ind="1"'
        f' data-surge-code="{html.escape(code_s, quote=True)}"'
        f' aria-label="급등락 알림 {"켜짐" if on else "꺼짐"}"'
        f' title="급등락 알림 {"켜짐" if on else "꺼짐"}">{"🔔" if on else "🔕"}</span>'
    )


def _surge_info_button_html(code: str, name: str) -> str:
    """detail actions 의 '알림' 버튼 — kpi-modal 에 /api/surge 본문을 띄운다(구간 + 오늘 소진 + 토글).

    켜진 종목은 has-surge 로 강조 (메모 버튼의 has-note 와 같은 패턴).
    꺼진 종목에도 노출한다 — 켜는 입구가 이 팝업뿐이라서. 종목코드 없으면 미노출
    (모니터가 코드로 시세를 조회하므로 `_set_surge_toggle` 이 ValueError).
    """
    code_s = (code or '').strip()
    if not code_s:
        return ''
    on = _is_surge_on(code_s)
    cls = 'kpi-more-btn btn-surge-info' + (' has-surge' if on else '')
    # ⚠️ name 을 함께 넘긴다 — 꺼진 종목은 토글 저장소에 이름이 없어서, 팝업에서 켜면
    # 빈 이름으로 저장되고 알림에 종목코드가 찍힌다(2026-08-06 실측).
    src = html.escape(f'/api/surge?code={_urlq(code_s)}&name={_urlq((name or "").strip())}', quote=True)
    title = html.escape(f'{(name or "").strip()} 알림'.strip(), quote=True)
    return (
        f'<button type="button" class="{cls}" data-kpi-src="{src}" data-kpi-title="{title}"'
        f' data-surge-code="{html.escape(code_s, quote=True)}"'
        f' title="급등락 알림 설정·구간">🔔 알림</button>'
    )


def _chart_hlines_lock():
    """CHART_HLINES 파일 직렬화 — _stock_notes_lock 과 동일 패턴, 별도 lock 파일."""
    import fcntl as _fcntl
    from contextlib import contextmanager as _cm

    @_cm
    def _ctx():
        lock_path = CHART_HLINES.with_suffix(CHART_HLINES.suffix + '.lock')
        lock_path.parent.mkdir(parents=True, exist_ok=True)
        f = open(lock_path, 'a')
        try:
            _fcntl.flock(f.fileno(), _fcntl.LOCK_EX)
            yield
        finally:
            try:
                _fcntl.flock(f.fileno(), _fcntl.LOCK_UN)
            finally:
                f.close()
    return _ctx()


_HLINES_MAX = 30  # 종목당 수평선 개수 상한


def _load_chart_hlines() -> dict:
    """{code: [price, ...]}. 종목차트에 그릴 사용자 수평선(지지/저항 등) 저장소. 종목코드 단위 공용(계좌 무관)."""
    if not CHART_HLINES.exists():
        return {}
    try:
        d = json.loads(CHART_HLINES.read_text())
        return d if isinstance(d, dict) else {}
    except Exception:
        return {}


def _get_chart_hlines(code: str | None) -> list:
    """종목의 수평선 가격 리스트(정수, 내림차순). 없으면 []."""
    code = (code or '').strip()
    if not code:
        return []
    v = _load_chart_hlines().get(code)
    if not isinstance(v, list):
        return []
    out = set()
    for x in v:
        try:
            p = int(round(float(x)))
            if p > 0:
                out.add(p)
        except (TypeError, ValueError):
            pass
    return sorted(out, reverse=True)


def _add_chart_hline(code: str | None, price) -> None:
    """수평선 가격 추가. 중복은 무시. 상한(30) 초과 시 ValueError."""
    code = (code or '').strip()
    if not code:
        raise ValueError('종목코드가 필요합니다')
    try:
        p = int(round(float(price)))
    except (TypeError, ValueError):
        raise ValueError('가격이 올바르지 않습니다')
    if p <= 0:
        raise ValueError('가격은 0보다 커야 합니다')
    CHART_HLINES.parent.mkdir(parents=True, exist_ok=True)
    with _chart_hlines_lock():
        d = _load_chart_hlines()
        cur = _normalize_hline_list(d.get(code))
        if p not in cur and len(cur) >= _HLINES_MAX:
            raise ValueError(f'수평선은 최대 {_HLINES_MAX}개까지 등록할 수 있습니다')
        cur.add(p)
        d[code] = sorted(cur, reverse=True)
        _write_chart_hlines(d)


def _remove_chart_hline(code: str | None, price) -> None:
    """수평선 가격 제거. 리스트가 비면 종목 키 자체 삭제."""
    code = (code or '').strip()
    if not code:
        raise ValueError('종목코드가 필요합니다')
    try:
        p = int(round(float(price)))
    except (TypeError, ValueError):
        raise ValueError('가격이 올바르지 않습니다')
    with _chart_hlines_lock():
        d = _load_chart_hlines()
        cur = _normalize_hline_list(d.get(code))
        cur.discard(p)
        if cur:
            d[code] = sorted(cur, reverse=True)
        else:
            d.pop(code, None)
        _write_chart_hlines(d)


def _normalize_hline_list(v) -> set:
    out = set()
    if isinstance(v, list):
        for x in v:
            try:
                p = int(round(float(x)))
                if p > 0:
                    out.add(p)
            except (TypeError, ValueError):
                pass
    return out


def _write_chart_hlines(d: dict) -> None:
    tmp = CHART_HLINES.with_suffix('.json.tmp')
    tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2))
    tmp.replace(CHART_HLINES)


def _chart_hlines_ui(code: str, hlines: list) -> str:
    """차트 하단 수평선 UI — 컴팩트 행(등록된 선 색상 칩 + '+' 버튼).
    '+' 클릭 시 추가·삭제 모달. 색상은 정렬 인덱스로 SVG와 동일 매핑.
    /api/chart_svg 응답 HTML 블록에 포함돼 모든 차트(보유·관심·감시)에 동일하게 노출."""
    code_s = (code or '').strip()
    if not code_s:
        return ''
    import stock_analysis as sa
    colors = getattr(sa, 'HLINE_COLORS', None) or ['#e879f9']
    code_attr = html.escape(code_s, quote=True)
    if hlines:
        chips = ''.join(
            f'<span class="hl-chip" style="color:{colors[i % len(colors)]};'
            f'border-color:{colors[i % len(colors)]}">{p:,}</span>'
            for i, p in enumerate(hlines)
        )
        manage = ''.join(
            f'<div class="hl-mrow">'
            f'<span class="hl-swatch" style="background:{colors[i % len(colors)]}"></span>'
            f'<span class="hl-mprice">{p:,}</span>'
            f'<button type="button" class="hl-mdel" data-hl-remove="{p}" aria-label="삭제">삭제</button>'
            f'</div>'
            for i, p in enumerate(hlines)
        )
    else:
        chips = '<span class="hl-empty">수평선 없음</span>'
        manage = '<div class="hl-empty">등록된 수평선이 없어요</div>'
    return (
        f'<div class="chart-hlines" data-hl-code="{code_attr}">'
        '<div class="hl-row">'
        f'<div class="hl-list">{chips}</div>'
        '<button type="button" class="hl-add-open" data-hl-open aria-label="수평선 추가·관리">+</button>'
        '</div>'
        '<div class="hl-modal hidden" data-hl-modal>'
        '<div class="hl-modal-ov" data-hl-close></div>'
        '<div class="hl-modal-box">'
        '<div class="hl-modal-head"><span>수평선</span>'
        '<button type="button" class="hl-modal-x" data-hl-close aria-label="닫기">×</button></div>'
        '<form class="chart-hlines-add">'
        '<input type="number" name="price" inputmode="numeric" step="1" min="1" '
        'placeholder="가격 입력" autocomplete="off">'
        '<button type="submit" class="hl-add-btn">추가</button>'
        '</form>'
        f'<div class="hl-manage-list">{manage}</div>'
        '</div>'
        '</div>'
        '</div>'
    )


def _interest_groups_lock():
    """INTEREST_GROUPS 파일 직렬화 — _stock_notes_lock 과 동일 패턴, 별도 lock 파일."""
    import fcntl as _fcntl
    from contextlib import contextmanager as _cm

    @_cm
    def _ctx():
        lock_path = INTEREST_GROUPS.with_suffix(INTEREST_GROUPS.suffix + '.lock')
        lock_path.parent.mkdir(parents=True, exist_ok=True)
        f = open(lock_path, 'a')
        try:
            _fcntl.flock(f.fileno(), _fcntl.LOCK_EX)
            yield
        finally:
            try:
                _fcntl.flock(f.fileno(), _fcntl.LOCK_UN)
            finally:
                f.close()
    return _ctx()


def _load_interest_groups() -> dict:
    """관심종목 전용 그룹 레지스트리. {'groups': [순서있는 이름], 'members': {종목명: [그룹명]}}.
    stock_tags 와 독립. 멤버십 키는 종목명(interests 저장소가 종목명 키라 안정적)."""
    if not INTEREST_GROUPS.exists():
        return {'groups': [], 'members': {}}
    try:
        d = json.loads(INTEREST_GROUPS.read_text())
        if not isinstance(d.get('groups'), list):
            d['groups'] = []
        if not isinstance(d.get('members'), dict):
            d['members'] = {}
        return d
    except Exception:
        return {'groups': [], 'members': {}}


def _save_interest_groups_locked(d: dict) -> None:
    """atomic tmp write + replace. 반드시 _interest_groups_lock() 안에서 호출."""
    INTEREST_GROUPS.parent.mkdir(parents=True, exist_ok=True)
    tmp = INTEREST_GROUPS.with_suffix('.json.tmp')
    tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2))
    tmp.replace(INTEREST_GROUPS)


def _get_interest_group_names(stock: str) -> list[str]:
    """해당 종목이 속한 그룹명 리스트. 없으면 []."""
    stock = (stock or '').strip()
    if not stock:
        return []
    members = _load_interest_groups().get('members', {})
    v = members.get(stock)
    return list(v) if isinstance(v, list) else []


def _create_interest_group(name: str) -> None:
    """새 그룹 등록. trim·20자 cap. 빈/중복은 무시."""
    name = (name or '').strip().lstrip('#').strip()[:20]
    if not name:
        return
    with _interest_groups_lock():
        d = _load_interest_groups()
        if name not in d['groups']:
            d['groups'].append(name)
            _save_interest_groups_locked(d)


def _rename_interest_group(old: str, new: str) -> None:
    """그룹 이름 변경. groups 위치 보존 + members 전 리스트에서 old→new 치환."""
    old = (old or '').strip()
    new = (new or '').strip().lstrip('#').strip()[:20]
    if not old or not new or old == new:
        return
    with _interest_groups_lock():
        d = _load_interest_groups()
        if old not in d['groups']:
            return
        # groups: 위치 보존 교체 (new 이미 있으면 중복 제거)
        d['groups'] = [new if g == old else g for g in d['groups']]
        seen: list[str] = []
        for g in d['groups']:
            if g not in seen:
                seen.append(g)
        d['groups'] = seen
        # members: 각 리스트 old→new, 중복 제거
        for stock, names in list(d['members'].items()):
            if old in names:
                repl = [new if n == old else n for n in names]
                dedup: list[str] = []
                for n in repl:
                    if n not in dedup:
                        dedup.append(n)
                d['members'][stock] = dedup
        _save_interest_groups_locked(d)


def _delete_interest_group(name: str) -> None:
    """그룹 삭제. groups 에서 제거 + members 전 리스트에서 strip, 빈 리스트 키 삭제."""
    name = (name or '').strip()
    if not name:
        return
    with _interest_groups_lock():
        d = _load_interest_groups()
        d['groups'] = [g for g in d['groups'] if g != name]
        for stock, names in list(d['members'].items()):
            if name in names:
                remain = [n for n in names if n != name]
                if remain:
                    d['members'][stock] = remain
                else:
                    d['members'].pop(stock, None)
        _save_interest_groups_locked(d)


def _reorder_interest_groups(ordered: list[str]) -> None:
    """표시 순서 재배치. 기존과 동일한 이름집합만 허용(누락분은 기존 순서로 뒤에 append)."""
    with _interest_groups_lock():
        d = _load_interest_groups()
        existing = d['groups']
        new_order = [g for g in ordered if g in existing]
        for g in existing:
            if g not in new_order:
                new_order.append(g)
        d['groups'] = new_order
        _save_interest_groups_locked(d)


def _set_interest_groups(stock: str, names: list[str]) -> None:
    """종목의 그룹 소속 편집. groups 에 존재하는 이름만 반영. 빈이면 members 키 삭제."""
    stock = (stock or '').strip()
    if not stock:
        raise ValueError('종목명이 필요합니다')
    with _interest_groups_lock():
        d = _load_interest_groups()
        valid = [n for n in names if n in d['groups']]
        dedup: list[str] = []
        for n in valid:
            if n not in dedup:
                dedup.append(n)
        if dedup:
            d['members'][stock] = dedup
        else:
            d['members'].pop(stock, None)
        _save_interest_groups_locked(d)


def _interest_group_button_html(stock: str) -> str:
    """관심종목 행의 '그룹' 버튼. 클릭 시 group-assign-modal 열림.
    현재 소속 + 전체 그룹 목록을 data 속성으로 실어보냄. 디자인은 +태그 버튼(btn-add-tag)과 동일."""
    name_s = (stock or '').strip()
    if not name_s:
        return ''
    current = _get_interest_group_names(name_s)
    all_groups = _load_interest_groups().get('groups', [])
    name_attr = html.escape(name_s, quote=True)
    cur_attr = html.escape(','.join(current), quote=True)
    all_attr = html.escape(','.join(all_groups), quote=True)
    return (
        f'<button type="button" class="btn-add-tag" data-group-assign="1"'
        f' data-group-stock="{name_attr}" data-group-current="{cur_attr}"'
        f' data-group-all="{all_attr}" title="그룹 배정">+ 그룹</button>'
    )


def _apply_interests_action(action: str, stock: str, payload: dict | None = None) -> None:
    """관심종목 add/delete. 자동 적재 없는 수동 리스트라 pending_delete trash 단계 없이 즉시 삭제."""
    INTERESTS.parent.mkdir(parents=True, exist_ok=True)
    with _interests_lock():
        _apply_interests_action_locked(action, stock, payload)
    if action == 'delete':
        # 삭제 성공 후 그룹 멤버십 연쇄 정리. _interests_lock 밖에서 별도 파일 락 → 데드락 회피.
        try:
            _set_interest_groups(stock, [])
        except Exception:
            pass


def _apply_interests_action_locked(action: str, stock: str, payload: dict | None = None) -> None:
    data: dict = {}
    if INTERESTS.exists():
        try:
            data = json.loads(INTERESTS.read_text())
        except Exception as e:
            # 손상된 JSON은 timestamp 백업 후 빈 dict로 시작 — 다음 write로 silent 덮어쓰기 방지
            backup = INTERESTS.with_name(INTERESTS.name + f'.corrupt.{int(time.time())}')
            try:
                INTERESTS.rename(backup)
                sys.stderr.write(f'INTERESTS json 손상 — {backup}로 백업: {e}\n')
            except Exception:
                pass
            data = {}
    if action == 'add':
        payload = payload or {}
        code = (payload.get('code') or '').strip()
        if not stock and not code:
            raise ValueError('종목명 또는 종목코드 중 하나는 입력해주세요')
        if code:
            # 코드 우선 lookup → 정식 종목명으로 통일. stock+code 동시 입력 시 사용자 stock 오타도 교정.
            import kiwoom_client as kc
            try:
                info = kc.resolve_stock_code(code)
                stock = info.get('name') or stock or code
                code = info.get('code') or code
            except Exception:
                if not stock:
                    stock = code
        else:
            # 종목명만 입력 — fuzzy 매칭으로 코드 lookup
            info = _resolve_stock_fuzzy(stock)
            stock = info.get('name') or stock
            code = info.get('code', '')
        if stock in data:
            raise ValueError(f'이미 등록된 종목입니다: {stock} — 수정으로 변경하세요')
        entry: dict = {'stock': stock, 'saved_at': datetime.now(KST).isoformat()}
        if code:
            entry['code'] = code
        for src_key, dst_key, sub in (('buy_price', 'buy', 'primary'), ('target_price', 'target', 'low'), ('stop_price', 'stop', 'value')):
            raw = (payload.get(src_key) or '').strip()
            if not raw:
                continue
            try:
                num = float(raw.replace(',', ''))
            except ValueError:
                raise ValueError(f'{_INTEREST_PRICE_LABEL[src_key]} 형식 오류: "{raw}" — 숫자만 입력해주세요')
            entry[dst_key] = {sub: num, 'raw': f'{int(num):,}원' if num == int(num) else f'{num:,}원'}
        memo = (payload.get('memo') or '').strip()
        if memo:
            entry['memo'] = memo
            entry['notes'] = [memo]
        data[stock] = entry
    elif action == 'update':
        if stock not in data:
            raise KeyError(stock)
        payload = payload or {}
        entry = data[stock]
        for src_key, dst_key, sub in (('buy_price', 'buy', 'primary'), ('target_price', 'target', 'low'), ('stop_price', 'stop', 'value')):
            if src_key not in payload:
                continue
            raw = (payload.get(src_key) or '').strip()
            if not raw:
                entry.pop(dst_key, None)
                continue
            try:
                num = float(raw.replace(',', ''))
            except ValueError:
                raise ValueError(f'{_INTEREST_PRICE_LABEL[src_key]} 형식 오류: "{raw}" — 숫자만 입력해주세요')
            entry[dst_key] = {sub: num, 'raw': f'{int(num):,}원' if num == int(num) else f'{num:,}원'}
        if 'memo' in payload:
            memo = (payload.get('memo') or '').strip()
            if memo:
                entry['memo'] = memo
                entry['notes'] = [memo]
            else:
                entry.pop('memo', None)
                entry.pop('notes', None)
        data[stock] = entry
    elif action == 'delete':
        if stock not in data:
            raise KeyError(stock)
        data.pop(stock)
    else:
        raise ValueError(f'unknown action: {action}')
    tmp = INTERESTS.with_suffix('.json.tmp')
    tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2))
    tmp.replace(INTERESTS)


def _fmt_price_field(field) -> str:
    if not isinstance(field, dict):
        return '언급 없음'
    raw = (field.get('raw') or '').strip()
    return raw or '언급 없음'


def _near_buy_tier(c: dict) -> int:
    """감시 모드 매수 가능권 단계.
    0 = 해당없음 (매수가 위 5% 초과)
    1 = 매수가 위 3~5% (노랑, 멀리서 접근)
    2 = 매수가 위 0~3% (주황, 임박)
    3 = 매수가 도달/하회 (빨강, 매수권 진입)
    """
    if c.get('mode') != 'watching':
        return 0
    pct = c.get('pct')
    if not isinstance(pct, (int, float)):
        return 0
    if pct > 5:
        return 0
    if pct > 3:
        return 1
    if pct > 0:
        return 2
    return 3


def _color_class(mode: str, price, ref_price, stop_value) -> str:
    """mode 별 색상.
    held(보유): 초록=수익, 빨강=손실, 회색=동가
    watching(감시): 초록=매수가 도달, 빨강=손절가 하회, 회색=아직 매수가 위 대기
    """
    if price is None or not isinstance(ref_price, (int, float)) or ref_price <= 0:
        return 'neutral'
    if mode == 'held':
        if price > ref_price:
            return 'up'
        if price < ref_price:
            return 'down'
        return 'neutral'
    if isinstance(stop_value, (int, float)) and stop_value > 0 and price <= stop_value:
        return 'down'
    if price <= ref_price:
        return 'up'
    return 'neutral'


def _buy_dd_html(buy_field, alerted: set[str]) -> str:
    """매수가 dd 콘텐츠 — raw 라벨 + 레벨별 알림 마커.
    levels 가 없거나 raw 가 단일 가격이면 단일 줄. 다중 레벨이면 각 레벨에 마커."""
    if not isinstance(buy_field, dict):
        return '언급 없음'
    raw = (buy_field.get('raw') or '').strip()
    levels = [int(lv) for lv in (buy_field.get('levels') or []) if isinstance(lv, (int, float)) and lv > 0]
    if not levels:
        return html.escape(raw) if raw else '언급 없음'
    norm_raw = raw.replace(',', '').replace('원', '').replace(' ', '')
    single_simple = len(levels) == 1 and norm_raw.isdigit() and int(norm_raw) == levels[0]
    parts: list[str] = []
    if raw and not single_simple:
        parts.append(f'<div class="buy-raw">{html.escape(raw)}</div>')
    for lv in levels:
        mark = ' <span class="alert-mark">알림됨</span>' if f'buy@{lv}' in alerted else ''
        parts.append(f'<div class="buy-level">{lv:,}원{mark}</div>')
    return ''.join(parts)


def _vol_surge_badge(code: str, today_volume) -> str:
    """오늘 거래량이 직전 20거래일 평균 대비 1.5배 이상이면 🔥 배지(배수 표기).

    - 20일 평균: dcc._select_latest(code, 21) 순수 SQLite 읽기(렌더 중 키움 호출 X).
    - 장중 환산: sa.session_vol_frac로 정규장 오늘 누적 거래량을 하루치로 투영.
    - 데이터 부족(차트 미조회로 캐시 없음 등)·임계 미만이면 '' 반환.
    """
    if not code or not today_volume:
        return ''
    try:
        import daily_candles_cache as dcc
        import stock_analysis as sa
        today = datetime.now(KST).strftime('%Y%m%d')
        vols = [b['volume'] for b in dcc._select_latest(code, 21)
                if str(b.get('date') or '') != today and (b.get('volume') or 0) > 0]
        if len(vols) < 5:
            return ''
        avg20 = sum(vols[:20]) / min(len(vols), 20)
        if avg20 <= 0:
            return ''
        frac = sa.session_vol_frac([{'date': today}])
        proj = (today_volume / frac) if frac else today_volume
        rvol = proj / avg20
    except Exception:
        return ''
    if rvol < 1.5:
        return ''
    level = 'red' if rvol >= 3 else 'warm'
    return f'<span class="badge vol-hot vol-{level}">🔥{rvol:.1f}</span>'


def _render_row(c: dict, source: str = 'watchlist') -> str:
    stock = html.escape(c.get('stock', ''))
    code = html.escape(c.get('code', '') or '')
    price = c.get('price')
    err = c.get('quote_error')
    mode = c.get('mode', 'watching')
    ref_price = c.get('ref_price')
    ref_label = c.get('ref_label', '매수가')
    stop_dict = c.get('stop') if isinstance(c.get('stop'), dict) else {}
    cls = _color_class(mode, price, ref_price, (stop_dict or {}).get('value'))
    tier = _near_buy_tier(c)
    dot_html = f'<span class="dot dot-{tier}" title="매수 가능권 {tier}단계"></span>' if tier else ''

    if price is None:
        price_cell = '<span class="muted">-</span>'
        diff_cell = f'<span class="muted">{html.escape(err) if err else "조회 불가"}</span>'
    else:
        # 거래가능 표시 — 지금 거래시간이면 초록점, 아니면 점 없음.
        #   정규장: KRX+NXT 모두 거래 → 초록점
        #   NXT 시간대: NXT 거래되는 종목(_nxt_price 살아있음)만 초록점
        #   장마감·휴장·주말: 점 없음
        nxt_p = c.get('_nxt_price', 0) or 0
        _phase = _market_phase_state()['phase']
        if _phase == 'regular':
            _tradable = True
        elif _phase == 'nxt':
            _tradable = nxt_p > 0
        else:
            _tradable = False
        mark_html = '<span class="px-dot tradable" title="지금 거래 가능"></span>' if _tradable else ''
        # 미리보기 등락은 KRX 전일종가 대비 — ka10095의 change_pct가 이미 KRX 기준이라 그대로 사용.
        day_pct = c.get('day_change_pct')
        if c.get('_show_day_change') and isinstance(day_pct, (int, float)):
            d_sign = '+' if day_pct >= 0 else ''
            d_cls = 'day-pct up' if day_pct > 0 else ('day-pct down' if day_pct < 0 else 'day-pct neutral')
            price_cell = f'{mark_html}<span class="rt-px">{price:,}</span><span class="{d_cls} rt-wl-daypct">{d_sign}{day_pct:.2f}%</span>'
        else:
            price_cell = f'{mark_html}<span class="rt-px">{price:,}</span>'
        if mode == 'watching' and isinstance(ref_price, (int, float)) and ref_price > 0:
            diff_cell = f'<span class="muted small">매수가</span> {ref_price:,.0f}'
        else:
            diff_cell = ''

    if mode == 'held':
        qty = c.get('held_qty', 0)
        status_cell = f'<span class="badge held">📦 {qty:,}</span>'
        accounts = c.get('held_accounts') or []
        avg = c.get('held_avg_price', 0)
        held_row = (
            f'<span class="lbl">매입평단</span>'
            f'<span class="val num">{avg:,}원<span class="muted small"> · {", ".join(accounts)}</span></span>'
        )
    else:
        status_cell = ''
        held_row = ''
    status_cell += _pending_badges_html(c)

    # 당일 OHLC mini-table — ka10095는 pred_close 필드를 안 줘서 `price - day_change` 로 KRX 종가 역산.
    # NXT 종가는 stock.briefing 20:10 스냅샷의 `_watchlist_nxt`에서 주입된 `_pred_close_nxt`. 없으면 `-`.
    open_ref = c.get('open')
    high_ref = c.get('high')
    low_ref = c.get('low')
    price_ref = c.get('price')
    day_chg_ref = c.get('day_change')
    pred_close_krx = 0
    if isinstance(price_ref, (int, float)) and price_ref > 0 and isinstance(day_chg_ref, (int, float)):
        pc = int(price_ref) - int(day_chg_ref)
        if pc > 0:
            pred_close_krx = pc
    # NXT 미상장 종목은 baseline 무효화 → NXT 컬럼 `-` placeholder.
    if c.get('_nxt_inactive'):
        pred_close_nxt = 0
    else:
        nxt_ref = c.get('_pred_close_nxt')
        pred_close_nxt = int(nxt_ref) if isinstance(nxt_ref, (int, float)) and nxt_ref > 0 else 0
    ohlc_mini_html = ''
    if all(isinstance(v, (int, float)) and v > 0 for v in (open_ref, high_ref, low_ref, price_ref)):
        ohlc_mini_html = _render_ohlc_mini(
            int(open_ref), int(high_ref), int(low_ref), int(price_ref),
            pred_close_krx,
            pred_close_nxt,
        )

    alerted = set(c.get('_alerted') or []) if source == 'watchlist' else set()
    unheld_done = '_unheld_done' in alerted and mode == 'watching'
    # 요약 라벨: 발사된 조건 종류로 컴팩트하게. _ prefix(내부 마커) 제외.
    _alert_kinds: list[str] = []
    if any(a.startswith('buy@') for a in alerted) or unheld_done:
        _alert_kinds.append('매수')
    if 'target' in alerted:
        _alert_kinds.append('목표')
    if 'stop' in alerted:
        _alert_kinds.append('손절')
    if _alert_kinds:
        alert_badge = f'<span class="badge alerted">알림 {"·".join(_alert_kinds)}</span>'
    else:
        alert_badge = ''

    upside = c.get('upside_pct')
    _target_text = _fmt_price_field(c.get('target'))
    if upside is not None and _target_text != '언급 없음':
        sign = '+' if upside >= 0 else ''
        _target_text = f'{_target_text} ({sign}{upside:g}%)'
    target_raw = html.escape(_target_text)
    if 'target' in alerted and _target_text != '언급 없음':
        target_raw += ' <span class="alert-mark">알림됨</span>'

    if source == 'watchlist':
        buy_raw = _buy_dd_html(c.get('buy'), alerted)
    else:
        buy_raw = html.escape(_fmt_price_field(c.get('buy')))

    _stop_text = _fmt_price_field(c.get('stop'))
    stop_raw = html.escape(_stop_text)
    if 'stop' in alerted and _stop_text != '언급 없음':
        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 = ''
    if summary_lines:
        items = ''.join(f'<li>{html.escape(s)}</li>' for s in summary_lines)
        summary_block = f'<div class="block"><h3>주요내용</h3><ul>{items}</ul></div>'

    notes_lines = c.get('notes') or []
    notes_block = ''
    if notes_lines:
        items = ''.join(f'<li>{html.escape(n)}</li>' for n in notes_lines)
        notes_block = f'<div class="block"><h3>기타</h3><ul>{items}</ul></div>'

    video = c.get('video') or {}
    video_block = ''
    if video.get('url'):
        title = html.escape(video.get('title', '영상'))
        video_block = (
            f'<div class="block"><h3>출처</h3>'
            f'<a class="video-link" href="{html.escape(video["url"])}" target="_blank" rel="noopener">{title} ↗</a>'
            f'</div>'
        )

    saved = html.escape((c.get('saved_at') or '')[:10])
    is_pending = c.get('status') == 'pending_delete'
    pending_at = html.escape((c.get('pending_delete_at') or '')[:16].replace('T', ' '))
    stock_attr = html.escape(c.get('stock', ''), quote=True)

    raw_code = c.get('code') or ''
    has_trades = bool(raw_code) and raw_code in _load_traded_codes()
    trade_btn = (
        f'<button type="button" class="btn-trades" data-trade-code="{html.escape(raw_code, quote=True)}" data-trade-stock="{stock_attr}">거래내역</button>'
    ) if has_trades else ''
    if source == 'watchlist' and raw_code:
        analyze_btn = (
            f'<button type="button" class="btn-add-from-wl" data-modal-open="interest-modal"'
            f' data-prefill-stock="{stock_attr}" data-prefill-code="{html.escape(raw_code, quote=True)}"'
            f' title="관심종목 추가 모달 열기 (종목명·코드 자동 입력)">+ 관심추가</button>'
        )
    else:
        # 📊 분석은 상세(기업정보) 팝업 안으로 이동 — 카드 actions에선 미노출.
        analyze_btn = ''
    trade_mark = (
        f'<button type="button" class="badge badge-trade btn-trades" data-trade-code="{html.escape(raw_code, quote=True)}" data-trade-stock="{stock_attr}" title="거래내역 보기">거래내역</button>'
    ) if has_trades and source == 'interests' else ''
    # 매매 진입 버튼 — 항상 매수 default (오타 방지). 모달에서 매도로 토글 가능.
    order_side = 'BUY'
    order_btn = (
        f'<button type="button" class="btn-order" data-order-code="{html.escape(raw_code, quote=True)}" data-order-stock="{stock_attr}" data-order-side="{order_side}" title="매수/매도">💰 거래</button>'
    ) if raw_code else ''

    # +태그 버튼 — held 행과 동일하게 펼침 상세 종목명 줄 오른쪽에 둔다 (actions 아님).
    tag_add_btn = _tag_add_button_html(c.get('code') or '', c.get('stock') or '')
    # 관심종목은 그룹 버튼도 +태그 옆에 (actions 아님).
    group_add_btn = _interest_group_button_html(c.get('stock') or '') if source == 'interests' else ''
    # 급등락 알림 토글은 +태그 왼쪽 (관리자님 지정 위치).
    surge_btn = _surge_indicator_html(c.get('code') or '')
    surge_info_btn = _surge_info_button_html(c.get('code') or '', c.get('stock') or '')
    detail_name_html = ''
    if not is_pending:
        code_span = f'<span class="detail-code">{code}</span>' if code else ''
        detail_name_html = f'<div class="detail-name">{stock}{code_span}{surge_btn}{tag_add_btn}{group_add_btn}</div>'
    # 현재가 아래 메타 라인 — 관심·감시는 비중·계좌 없어 시장(KOSPI/KOSDAQ)만.
    _mkt_label = _market_label_html(code)
    market_meta_html = f'<div class="detail-meta">{_mkt_label}</div>' if _mkt_label else ''

    if source == 'interests':
        def _num_attr(field, sub):
            if not isinstance(field, dict):
                return ''
            v = (field or {}).get(sub)
            if isinstance(v, (int, float)) and v > 0:
                return str(int(v)) if v == int(v) else str(v)
            return ''
        buy_attr_v = _num_attr(c.get('buy'), 'primary')
        target_attr_v = _num_attr(c.get('target'), 'low')
        stop_attr_v = _num_attr(c.get('stop'), 'value')
        memo_val = c.get('memo') or ''
        if not memo_val:
            notes = c.get('notes') or []
            if notes and isinstance(notes[0], str):
                memo_val = notes[0]
        edit_attrs = (
            f' data-modal-open="interest-edit-modal"'
            f' data-edit-stock="{stock_attr}"'
            f' data-edit-code="{html.escape(c.get("code") or "", quote=True)}"'
            f' data-edit-buy="{html.escape(buy_attr_v, quote=True)}"'
            f' data-edit-target="{html.escape(target_attr_v, quote=True)}"'
            f' data-edit-stop="{html.escape(stop_attr_v, quote=True)}"'
            f' data-edit-memo="{html.escape(memo_val, quote=True)}"'
        )
        note_btn = _note_button_html(c.get('code') or '', c.get('stock') or '')
        info_btn = _info_button_html(c.get('code') or '', c.get('stock') or '')
        actions_html = (
            '<div class="actions">'
            f'{note_btn}'
            f'{surge_info_btn}'
            f'{analyze_btn}'
            f'{info_btn}'
            f'{trade_btn}'
            f'{order_btn}'
            f'<button type="button" class="btn-edit"{edit_attrs}>수정</button>'
            '</div>'
        )
    elif is_pending:
        actions_html = (
            '<div class="actions">'
            f'<span class="muted small">삭제예정 {pending_at}</span>'
            f'<form method="post" action="/restore"><input type="hidden" name="stock" value="{stock_attr}"><button type="submit" class="btn-restore">복원</button></form>'
            f'<form method="post" action="/purge" onsubmit="return confirm(\'완전 삭제하시겠습니까? 되돌릴 수 없습니다.\')"><input type="hidden" name="stock" value="{stock_attr}"><button type="submit" class="btn-purge">완전 삭제</button></form>'
            '</div>'
        )
        cls = f'{cls} trash'
    else:
        wl_note_btn = _note_button_html(c.get('code') or '', c.get('stock') or '')
        wl_info_btn = _info_button_html(c.get('code') or '', c.get('stock') or '')
        actions_html = (
            '<div class="actions">'
            f'{wl_note_btn}'
            f'{surge_info_btn}'
            f'{analyze_btn}'
            f'{wl_info_btn}'
            f'{trade_btn}'
            f'{order_btn}'
            f'<form method="post" action="/alerts/reset"><input type="hidden" name="stock" value="{stock_attr}"><button type="submit" class="btn-alerts-reset">알림 리셋</button></form>'
            f'<form method="post" action="/delete"><input type="hidden" name="stock" value="{stock_attr}"><button type="submit" class="btn-delete">감시 삭제</button></form>'
            '</div>'
        )

    tag_chip = _tag_chip_html(c.get('code') or '', c.get('stock') or '', interactive=True)

    row_key = code or stock
    return f'''<details class="row {cls} mode-{mode}" data-row-key="{row_key}">
  <summary>
    <div class="left">
      <div class="line1">{dot_html}<span class="stock">{stock}</span>{tag_chip}{status_cell}{trade_mark}{alert_badge}{_vol_surge_badge(code, (max(0, (c.get('volume') or 0) - (c.get('_nxt_volume') or 0)) or None))}</div>
    </div>
    <div class="right">
      <div class="price">{price_cell}<span class="caret" aria-hidden="true">▾</span></div>
      {f'<div class="diff">{diff_cell}</div>' if diff_cell else ''}
    </div>
  </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 ''}
    {market_meta_html}
    {'<div class="alert-notice">최초 알림 완료 — 리셋 전까지 매수 알림 없음</div>' if unheld_done else ''}
    <div class="grid2">
      {held_row}
      <span class="lbl">매수가</span><span class="val">{buy_raw}</span>
      <span class="lbl">목표가</span><span class="val">{target_raw}</span>
      <span class="lbl">손절가</span><span class="val">{stop_raw}</span>
      <span class="lbl">저장일</span><span class="val muted">{saved}</span>
    </div>
    {ohlc_mini_html}
    {_pending_detail_html(c)}
    {summary_block}
    {notes_block}
    {video_block}
    {actions_html}
    {flow_block}
    {chart_block}
  </div>
</details>'''


def _profit_class(value: int) -> str:
    """한국 시세 관례: 양수=빨강(up), 음수=파랑(down)."""
    if value is None:
        return 'neutral'
    if value > 0:
        return 'up'
    if value < 0:
        return 'down'
    return 'neutral'


# 이 라벨 행 아래에 구분선을 넣는다 (KPI 카드 공용). 라벨 기준이라 순서를 바꿔도 따라간다.
# 총수익 = 전체 요약 묶음(총자산·투자원금·총수익)의 끝.
_KPI_SEP_AFTER = {'총수익'}


def _urlq(s: str) -> str:
    """쿼리스트링용 URL 인코딩 (owner 이름이 한글이라 필요)."""
    from urllib.parse import quote
    return quote(s, safe='')


def _kpi_more_button(src: str, title: str) -> str:
    """KPI 라벨 옆 [!] 버튼 — kpi-modal 을 열고 src를 fetch해 본문을 innerHTML.

    값이 아니라 라벨(th) 쪽에 붙는다 — 금액 뒤에 두면 숫자 우측정렬이 흐트러진다.
    src는 `{'html': '...'}` 를 주는 엔드포인트 (본문 조립은 서버 몫, JS는 삽입만).
    """
    s = html.escape(src, quote=True)
    t = html.escape(title, quote=True)
    return (f'<button type="button" class="kpi-more-btn" data-kpi-src="{s}" data-kpi-title="{t}"'
            f' title="{t} 상세" aria-label="{t} 상세 보기">!</button>')


def _acct_short(label: str) -> str:
    """모달 안에서는 owner가 이미 정해져 있으니 '가희_' 접두를 떼고 상품만 보인다."""
    return label[3:] if label.startswith('가희_') else label


def _render_principal_body(owner: str) -> str:
    """투자원금 팝업 본문 — 요약 + 입출금 내역. 캐시만 읽어 키움 콜 0.

    내역은 kt00015 tp='1' 전체인데 대부분이 배당·이자라 실제 입금이 묻힌다
    → 기본은 원금 반영분만 보이고, 체크박스로 나머지까지 펼친다(CSS-only, JS 없음).
    """
    import investment_principal as ip
    from stock_portfolio_report import derive_owner
    import kiwoom_client as kc

    labels = [a['label'] for a in kc.list_accounts() if derive_owner(a['label']) == owner]
    pr = ip.get_principal(labels)
    if pr['principal'] is None:
        return '<div class="muted small">투자원금이 아직 집계되지 않았어요.</div>'
    fl = ip.get_flows(labels)

    def srow(label: str, value: str, cls: str = '') -> str:
        return (f'<div class="pm-srow{cls}"><span>{html.escape(label)}</span>'
                f'<b>{value}</b></div>')

    head = [
        srow('투자원금', f'{pr["principal"]:,}원', ' strong'),
        srow('누적 입금', f'{pr["cash_in"]:,}원'),
        srow('누적 출금', f'{pr["cash_out"]:,}원'),
    ]
    if len(pr['by_label']) > 1:
        head.append('<div class="pm-shead">계좌별</div>')
        head += [srow(_acct_short(lb), f'{v:,}원') for lb, v in pr['by_label'].items()]
        # 계좌 간 대체(내 일반↔ISA)는 owner 합계에선 상쇄되지만 계좌별 수치는 밀어놓는다.
        # 합계는 정확하니 계좌별만 오해하지 않게 알린다.
        if any('대체' in (r['rmrk'] or '') for r in fl['rows']):
            head.append('<div class="pm-note pm-note-in">계좌 간 이동(대체)이 있어 계좌별 금액은 '
                        '실제 외부 입금과 다를 수 있어요. 합계는 정확합니다.</div>')
    if pr['qty_flow']:
        head.append('<div class="pm-warn">⚠️ 주식 입출고(타사대체) 있음 — '
                    '현금 기준 원금이 실제보다 작을 수 있어요.</div>')
    if not fl['reconciled']:
        head.append('<div class="pm-warn">⚠️ 분류되지 않은 새 적요가 있어요 — '
                    '내역 합계와 원금이 어긋날 수 있습니다.</div>')

    if not fl['available']:
        return (f'<div class="pm-sum">{"".join(head)}</div>'
                f'<div class="muted small pm-empty">입출금 내역은 아직 수집되지 않았어요.</div>')

    n_pr = sum(1 for r in fl['rows'] if r['principal'])
    n_ot = len(fl['rows']) - n_pr
    items = []
    for r in fl['rows']:
        sign = '+' if r['io'] == 'IN' else '−'
        acls = 'in' if r['io'] == 'IN' else 'out'
        dt = r['date']
        dsp = f'{dt[:4]}.{dt[4:6]}.{dt[6:8]}' if len(dt) == 8 else dt
        items.append(
            f'<div class="pm-row{"" if r["principal"] else " other"}">'
            f'<span class="pm-d">{html.escape(dsp)}</span>'
            f'<span class="pm-rk">{html.escape(r["rmrk"] or "-")}</span>'
            f'<span class="pm-ac">{html.escape(_acct_short(r["label"]))}</span>'
            f'<b class="pm-amt {acls}">{sign}{r["amount"]:,}</b>'
            f'</div>'
        )
    other_note = ''
    if fl['other_in'] or fl['other_out']:
        parts = []
        if fl['other_in']:
            parts.append(f'입금 {fl["other_in"]:,}원')
        if fl['other_out']:
            parts.append(f'출금 {fl["other_out"]:,}원')
        other_note = (f'<div class="pm-note">배당·이자 등 {n_ot}건 ({" · ".join(parts)})은 '
                      f'투자 성과라서 원금에 넣지 않아요.</div>')
    return (
        f'<div class="pm-sum">{"".join(head)}</div>'
        f'{other_note}'
        f'<div class="pm-listhead">입출금 내역<span class="muted small"> · 원금 {n_pr}건</span></div>'
        f'<input type="checkbox" id="pm-all" class="pm-all-cb">'
        f'<label for="pm-all" class="pm-all-lb">배당·이자 등 {n_ot}건 함께 보기</label>'
        f'<div class="pm-list">{"".join(items)}</div>'
    )


def _render_deposit_body(owner: str) -> str:
    """예수금 팝업 본문 — 계좌별 D+2 예수금. 렌더 경로가 이미 조회한 balances 를 읽어 키움 콜 0.

    캐시를 쓰는 이유: 팝업 금액이 그걸 띄운 KPI 행과 항상 같고, 매수 묶임(ka10075 기반)도
    거기에만 들어 있다. 캐시가 비어 있으면(서버 재시작 직후) kt00001 을 계좌당 1콜 — 이 경로엔
    매수 묶임이 없다.
    """
    from stock_portfolio_report import derive_owner
    import kiwoom_client as kc

    bal_map = (_owner_data_cache.get(owner) or {}).get('balances') or {}
    if not bal_map:
        labels = [a['label'] for a in kc.list_accounts() if derive_owner(a['label']) == owner]
        bal_map = {lb: kc.get_balance(lb) for lb in labels}

    def srow(label: str, value: str, cls: str = '') -> str:
        return (f'<div class="pm-srow{cls}"><span>{html.escape(label)}</span>'
                f'<b>{value}</b></div>')

    deposits = {lb: (b.get('d2_entra', 0) or 0) for lb, b in bal_map.items()}
    head = [srow('예수금', f'{sum(deposits.values()):,}원', ' strong'),
            '<div class="pm-shead">계좌별</div>']
    head += [srow(_acct_short(lb), f'{v:,}원') for lb, v in deposits.items()]
    locked = {lb: (b.get('pending_buy_locked', 0) or 0) for lb, b in bal_map.items()}
    if any(locked.values()):
        head.append('<div class="pm-shead">매수 묶임</div>')
        head += [srow(_acct_short(lb), f'{v:,}원') for lb, v in locked.items() if v]
    return f'<div class="pm-sum">{"".join(head)}</div>'


def _render_cashflow_body(owner: str) -> str:
    """당일 입출금 팝업 본문 — 입금/출금 합계 + 건별 내역 (kt00015 tp='1' 당일).

    렌더 경로가 이미 부르는 조회지만 팝업은 드물게 열리므로 그때 다시 부른다(계좌당 1콜).
    """
    from stock_portfolio_report import derive_owner
    import kiwoom_client as kc

    labels = [a['label'] for a in kc.list_accounts() if derive_owner(a['label']) == owner]
    by_label = kc.get_cash_flow_all(None, labels)
    rows = [{**f, 'label': lb} for lb, fl in by_label.items() for f in (fl or [])]
    if not rows:
        return '<div class="muted small">오늘 입출금이 없어요.</div>'
    rows.sort(key=lambda r: (r.get('time') or '', r['label']))

    cash_in = sum(r['amount'] for r in rows if r['io_tp'] == 'IN')
    cash_out = sum(r['amount'] for r in rows if r['io_tp'] == 'OUT')

    def srow(label: str, value: str, cls: str = '') -> str:
        return f'<div class="pm-srow{cls}"><span>{html.escape(label)}</span><b>{value}</b></div>'

    head = []
    if cash_in:
        head.append(srow('입금', f'{cash_in:,}원'))
    if cash_out:
        head.append(srow('출금', f'{cash_out:,}원'))
    # 순액은 양방향 다 있을 때만 — 한쪽뿐이면 위 합계와 같은 값이다.
    if cash_in and cash_out:
        net = cash_in - cash_out
        head.append(srow('순 입출금', f'{net:+,}원', ' strong'))

    items = []
    for r in rows:
        sign = '+' if r['io_tp'] == 'IN' else '−'
        acls = 'in' if r['io_tp'] == 'IN' else 'out'
        tm = (r.get('time') or '')[:5]
        items.append(
            f'<div class="pm-row">'
            f'<span class="pm-d">{html.escape(tm)}</span>'
            f'<span class="pm-rk">{html.escape(r.get("rmrk") or "-")}</span>'
            f'<span class="pm-ac">{html.escape(_acct_short(r["label"]))}</span>'
            f'<b class="pm-amt {acls}">{sign}{r["amount"]:,}</b>'
            f'</div>'
        )
    return (
        f'<div class="pm-sum">{"".join(head)}</div>'
        f'<div class="pm-listhead">내역<span class="muted small"> · {len(rows)}건</span></div>'
        f'<div class="pm-list">{"".join(items)}</div>'
    )


# 분위수·상하한가 트리거 키 → 사람이 읽는 이름. VI 는 발동시각이 키에 붙는다(`vi:105814`).
_SURGE_KEY_NAMES = {
    'limit_up': '상한가', 'up2': '급등 확대', 'up': '급등 1차',
    'down': '급락 1차', 'down2': '급락 확대', 'limit_down': '하한가',
}


def _surge_key_label(key: str) -> str:
    if key.startswith('vi:'):
        t = key[3:]
        return f'VI {t[:2]}:{t[2:4]}' if len(t) >= 4 and t.isdigit() else 'VI'
    return _SURGE_KEY_NAMES.get(key, key)


def _render_surge_body(code: str, name_hint: str = '') -> str:
    """급등락 알림 팝업 본문 — on/off 토글 + 알림 구간 + 오늘 소진 현황.

    ⚠️ **키움 콜 0.** surge_monitor 의 캐시 파일을 읽기만 한다. `sm.get_threshold_map()` /
    `sm.get_limit_map()` 은 각각 ka10081(유량 초당 5건)·ka10095 를 때리므로 절대 부르지 않는다
    — 팝업을 열 때마다 조회가 나가고, 문턱 산출은 사이클당 12종목 페이싱이 있어 여기서 부르면
    그 설계가 무너진다. 캐시에 없으면 계산 대기로 표시한다.
    """
    import surge_monitor as sm

    code_s = (code or '').strip()
    if not code_s:
        return '<div class="muted small">종목코드가 없어 알림을 설정할 수 없어요.</div>'

    on = _is_surge_on(code_s)
    # 켜진 종목은 저장된 이름, 꺼진 종목은 호출측이 넘긴 이름 — 후자가 없으면 켤 때 이름이 빈 채로 저장된다.
    name = ((_load_surge_toggles().get('by_code', {}).get(code_s) or {}).get('name') or '').strip()
    name = name or (name_hint or '').strip()
    today = datetime.now(KST).strftime('%Y-%m-%d')

    thr_cache = sm.load_json(sm.THR_CACHE, {})
    lim_cache = sm.load_json(sm.LIMITS_CACHE, {})
    thr_fresh = thr_cache.get('date') == today
    thr_raw = (thr_cache.get('by_code') or {}) if thr_fresh else {}
    lim_raw = (lim_cache.get('by_code') or {}) if lim_cache.get('date') == today else {}
    thr = thr_raw.get(code_s)          # None=미산출, {}=이력부족(확정), dict=정상
    lim = lim_raw.get(code_s) or {}

    q = None
    try:
        if _RT_HUB is not None:
            q = _RT_HUB.get_quote(code_s)
    except Exception:
        q = None

    # ── 토글 (기존 [data-surge-toggle] 위임 핸들러가 그대로 잡는다) ──
    tog = (
        f'<button type="button" class="sg-toggle{" has-surge" if on else ""}" data-surge-toggle="1"'
        f' data-surge-code="{html.escape(code_s, quote=True)}"'
        f' data-surge-name="{html.escape(name, quote=True)}"'
        f' data-surge-on="{"1" if on else "0"}"'
        f' data-surge-refresh="/api/surge?code={_urlq(code_s)}&amp;name={_urlq(name)}">'
        f'{"🔔 알림 켜짐 — 누르면 끕니다" if on else "🔕 알림 꺼짐 — 누르면 켭니다"}</button>'
    )
    if not on:
        return (
            f'<div class="sg-body">{tog}'
            f'<div class="muted small sg-note">감시 중이 아니라 알림 구간이 산출되지 않았어요. '
            f'켜면 다음 감시 사이클(평일 09:00~15:35, 1분 간격)에 이 종목 과거 이력으로 구간을 계산합니다.</div>'
            f'</div>'
        )

    # ── 헤더: 전일종가 · 현재가 ──
    prev_close = (thr or {}).get('prev_close') or 0
    head = []
    if prev_close:
        head.append(f'<div class="pm-srow"><span>전일종가</span><b>{prev_close:,}원</b></div>')
    if q and q.get('price'):
        pct = q.get('pct')
        pcls = 'up' if (pct or 0) > 0 else ('down' if (pct or 0) < 0 else '')
        pstr = f' <span class="{pcls}">({pct:+.2f}%)</span>' if pct is not None else ''
        head.append(f'<div class="pm-srow"><span>현재가</span><b>{int(q["price"]):,}원{pstr}</b></div>')

    # ── 구간 사다리 (호가창처럼 위가 비쌈) ──
    def band(label: str, price: int, pct: float | None, cls: str) -> str:
        p = f'<span class="sg-p">{pct:+.1f}%</span>' if pct is not None else '<span class="sg-p"></span>'
        return (f'<div class="sg-band {cls}"><span class="sg-k">{html.escape(label)}</span>'
                f'<b>{price:,}</b>{p}</div>')

    bands: list[str] = []
    if lim.get('upl'):
        bands.append(band('⏫ 상한가', lim['upl'], (lim['upl'] / prev_close - 1) * 100 if prev_close else None, 'lim up'))
    if thr:
        t1, t2 = thr['thr'], thr.get('thr_big') or 0
        if t2:
            bands.append(band('급등 확대', round(prev_close * (1 + t2 / 100)), t2, 'up'))
        bands.append(band('급등 1차', round(prev_close * (1 + t1 / 100)), t1, 'up'))
        bands.append(band('전일종가', prev_close, None, 'mid'))
        bands.append(band('급락 1차', round(prev_close * (1 - t1 / 100)), -t1, 'down'))
        if t2:
            bands.append(band('급락 확대', round(prev_close * (1 - t2 / 100)), -t2, 'down'))
    if lim.get('lst'):
        bands.append(band('⏬ 하한가', lim['lst'], (lim['lst'] / prev_close - 1) * 100 if prev_close else None, 'lim down'))

    notes: list[str] = []
    if thr == {}:
        notes.append(f'이력 {sm.MIN_HISTORY}일 미만이라 문턱을 못 냅니다 — VI·상하한가만 감시해요.')
    elif not thr_fresh and thr_cache.get('date'):
        # 전일종가가 바뀌면 구간이 통째로 달라진다 → 어제 값을 '알림 구간'으로 보여주지 않는다.
        notes.append(f'⚠️ 마지막 산출이 {html.escape(str(thr_cache.get("date")))} 기준이라 표시하지 않아요 '
                     f'— 전일종가가 바뀌면 구간도 전부 달라집니다. 장 시작 후 갱신됩니다.')
    elif thr is None:
        # 감시 사이클과 같은 판정을 쓴다(평일 09:00~15:35 · 휴장일 제외).
        notes.append('구간 계산 대기 중이에요 — 사이클당 12종목씩 채워서 몇 분 안에 나옵니다.'
                     if sm.is_market_hours() else '다음 장 시작 후에 계산됩니다.')

    band_html = (f'<div class="pm-listhead">알림 구간</div><div class="sg-bands">{"".join(bands)}</div>'
                 if bands else '')

    # ── 오늘 소진 현황 ──
    day = (sm.load_json(sm.ALERTS_STATE, {}).get(today) or {})
    fired = [k for k in (day.get(code_s) or []) if isinstance(k, str)]
    pend = list(((day.get(sm.PENDING_KEY) or {}).get(code_s) or {}))
    order = list(_SURGE_KEY_NAMES)
    left = [k for k in order if k not in fired]

    empty = '<span class="muted small">없음</span>'

    def chips(keys: list, cls: str, blank: str = empty) -> str:
        if not keys:
            return blank
        return ''.join(f'<span class="sg-chip {cls}">{html.escape(_surge_key_label(k))}</span>' for k in keys)

    def trow(label: str, inner: str) -> str:
        return f'<div class="sg-trow"><span>{label}</span><div class="sg-chips">{inner}</div></div>'

    today_rows = [
        trow('나간 알림', chips(fired, 'fired')),
        trow('남은 알림', chips(left, 'left', '<span class="muted small">모두 소진</span>')),
    ]
    if pend:
        today_rows.append(trow('보류 중', chips(pend, 'pend')))

    foot = []
    if thr:
        top1 = round((1 - sm.SURGE_PCTL) * 100)
        top2 = round((1 - sm.SURGE_PCTL_BIG) * 100)
        foot.append(f'{thr.get("n", 0)}일 이력 기준 · 상위 {top1}%(1차) / {top2}%(확대)')
    foot.append('VI 발동은 구간과 별개로, 발동할 때마다 알림 (하루 상한 없음)')

    head_html = '<div class="pm-sum">' + ''.join(head) + '</div>' if head else ''
    notes_html = ''.join(f'<div class="muted small sg-note">{n}</div>' for n in notes)
    return (
        '<div class="sg-body">' + tog + head_html + band_html + notes_html
        + '<div class="pm-listhead">오늘 알림</div>' + ''.join(today_rows)
        + '<div class="sg-foot">' + '<br>'.join(foot) + '</div>'
        + '</div>'
    )


def _render_kpi_modal() -> str:
    """KPI 상세 공용 팝업 (투자원금·당일 입출금 등). shell HTML 직속이라 panels swap 영향 없음.
    라벨 옆 [!] 클릭 → 버튼의 data-kpi-src fetch → 본문 innerHTML."""
    return (
        '<div id="kpi-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="kpi-modal-title">'
        '<div class="modal-overlay" data-modal-close="1"></div>'
        '<div class="modal-box" role="document">'
        '<div class="modal-head">'
        '<div class="modal-title" id="kpi-modal-title"><span data-kpi-title>상세</span></div>'
        '<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
        '</div>'
        '<div class="principal-body" data-kpi-body><div class="muted small">불러오는 중…</div></div>'
        '<div class="modal-actions">'
        '<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
        '</div>'
        '</div>'
        '</div>'
    )


def _render_owner_kpi(owner: str, d: dict, owner_label_text: str, compact: bool = False) -> str:
    """owner 블록 상단 KPI 카드 (총자산/평가금액/예수금 + 매입·손익 + 원금·총수익 + 당일).

    compact=True: 자산정보 탭용 — 당일 매매·당일 실현손익 행 생략.
    """
    profit = d['total_profit']
    profit_rate = d['total_profit_rate']
    pcls = _profit_class(profit)
    held_count = len([r for r in d['consolidated'] if not r.get('phantom')])
    sign = '+' if profit >= 0 else ''
    labels_disp = ' + '.join(d['labels'])

    day_pl_total = d.get('day_pl_total')
    if day_pl_total is None:
        if d.get('day_pl_reason') == 'preopen':
            day_pl_html = '<span class="muted small">장 시작 전</span>'
        # 휴장일/주말은 시장 변동이 없는 정상 상태 — "전날 스냅샷 없음"과 구분된 문구.
        elif _market_phase_state().get('phase') in ('holiday', 'weekend'):
            day_pl_html = '<span class="muted small">휴장 — 변동 없음</span>'
        else:
            day_pl_html = '<span class="muted small">전날 스냅샷 없음</span>'
    else:
        dcls = _profit_class(day_pl_total)
        dsign = '+' if day_pl_total >= 0 else ''
        prev_net = d.get('prev_net') or 0
        pct_html = ''
        if prev_net:
            pct = (day_pl_total / prev_net) * 100
            pct_html = f' ({dsign}{pct:.2f}%)'
        day_pl_html = f'<span class="{dcls}">{dsign}{day_pl_total:,}원{pct_html}</span>'

    deposit_pct_html = ''
    if d['total_value']:
        dep_pct = (d['deposit'] / d['total_value']) * 100
        deposit_pct_html = f'<span class="muted small">({dep_pct:.1f}%)</span> '
    locked = d.get('pending_buy_locked', 0) or 0
    deposit_locked_html = (
        f'<span class="muted small"> · 매수 묶임 {locked:,}원</span>' if locked > 0 else ''
    )
    # 계좌별 예수금은 라벨 옆 [!] 팝업으로 — owner 탭 하단에 있던 '계좌별 예수금' 목록을 대체한다.
    # 계좌가 1개면 총액과 같은 값이 반복될 뿐이라 버튼을 달지 않는다.
    deposit_row = ('예수금', f'{deposit_pct_html}{d["deposit"]:,}원{deposit_locked_html}')
    if len(d.get('deposit_by_label') or {}) > 1:
        deposit_row += (_kpi_more_button(f'/api/deposit?owner={_urlq(owner)}', '예수금'),)
    # 구분 없는 단일 목록 (관리자님 지정 순서).
    # 라벨 규칙: '총'은 계좌 전체(현금 포함) 기준일 때만 쓴다.
    #   총자산 = 평가금액 + 예수금 / 총수익 = 총자산 − 투자원금
    #   평가금액·매입금액·평가손익은 보유종목 한정이라 '총'을 붙이지 않는다.
    # 맨 위는 계좌 전체 요약 3행 — 총자산(가진 돈) / 투자원금(넣은 돈) / 총수익(그 차이).
    # 아래 구분선은 이 묶음 끝(총수익)에 붙는다. 원금 미집계면 뒤 두 행과 선이 함께 사라진다.
    principal = d.get('principal')
    kpis = [
        ('총자산', f'<span class="net">{d["total_net"]:,}원</span>'),
    ]
    if principal:
        total_ret = d['total_net'] - principal
        tcls = _profit_class(total_ret)
        tsign = '+' if total_ret >= 0 else ''
        tpct = total_ret / principal * 100
        # 값은 금액만, 상세는 라벨 옆 [!] 버튼 → 팝업. (3번째 원소 = 라벨 뒤 HTML)
        kpis.append(('투자원금', f'{principal:,}원',
                     _kpi_more_button(f'/api/principal?owner={_urlq(owner)}', '투자원금')))
        kpis.append(('총수익',
                     f'<span class="{tcls}">{tsign}{total_ret:,}원 ({tsign}{tpct:.2f}%)</span>'))
    # 당일 입출금은 예수금 바로 아래 (현금 잔액 ↔ 그날 현금 이동). 없으면 행 자체가 없다.
    cash_in = d.get('cash_in', 0)
    cash_out = d.get('cash_out', 0)
    cashflow_row = None
    if cash_in or cash_out:
        net = cash_in - cash_out
        ncls = _profit_class(net)
        nsign = '+' if net >= 0 else ''
        # 값은 순액만 — 입금/출금 쪼갠 내역과 건별 목록은 라벨 옆 [!] 팝업으로.
        cashflow_row = ('당일 입출금',
                        f'<span class="{ncls}">{nsign}{net:,}원</span>',
                        _kpi_more_button(f'/api/cashflow?owner={_urlq(owner)}', '당일 입출금'))

    kpis += [
        ('매입금액', f'{d["total_cost"]:,}원'),
        ('평가금액', f'{d["total_value"]:,}원'),
        ('평가손익', f'<span class="{pcls}">{sign}{profit:,}원 ({sign}{profit_rate:.2f}%)</span>'),
        ('당일 평가손익', day_pl_html),
        deposit_row,
    ]
    if cashflow_row:
        kpis.append(cashflow_row)
    kpis.append(('보유종목/당일매매', f'{held_count}개 / {len(d["traded_today"])}개'))
    if not compact:
        realized_pl = d.get('realized_pl_total', 0)
        realized_fees = d.get('realized_fees_total', 0)
        if d.get('traded_today') and (realized_pl or realized_fees):
            rcls = _profit_class(realized_pl)
            rsign = '+' if realized_pl >= 0 else ''
            fee_html = f'<span class="muted small"> · 수수료·세금 {realized_fees:,}원</span>' if realized_fees else ''
            kpis.append(('당일 실현손익',
                         f'<span class="{rcls}">{rsign}{realized_pl:,}원</span>{fee_html}'))

    _rtk ={'평가금액': 'value', '평가손익': 'profit', '총자산': 'net', '당일 평가손익': 'daypl',
            '총수익': 'totret'}
    _row_list = []
    for i, item in enumerate(kpis):
        k, v = item[0], item[1]
        lbl_suffix = item[2] if len(item) > 2 else ''  # 라벨 뒤 버튼 등 (escape 대상 아님)
        attr = f' data-rt-kpi="{_rtk[k]}"' if k in _rtk else ''
        # 라벨 기준이라 조건부 행이 빠져도 선이 엉뚱한 데 붙지 않는다. 마지막 행엔 안 붙임.
        cls = ' class="kpi-sep"' if (k in _KPI_SEP_AFTER and i < len(kpis) - 1) else ''
        _row_list.append(
            f'<tr{cls}><th>{html.escape(k)}{lbl_suffix}</th><td{attr}>{v}</td></tr>'
        )
    rows = ''.join(_row_list)
    trade_btn_html = ''
    if not compact:
        owner_attr = html.escape(owner, quote=True)
        label_attr = html.escape(owner_label_text, quote=True)
        trade_btn_html = (
            f'<button type="button" class="btn-trades btn-trades-all" '
            f'data-trade-owner="{owner_attr}" data-trade-stock="{label_attr}">'
            f'📋 전체 거래내역</button>'
        )
    # data-rt-principal — setOwnerKpis가 순자산 틱마다 총수익을 재계산하는 데 쓴다(0=미집계).
    return f'''<section class="owner-card" data-rt-owner="{html.escape(owner, quote=True)}" data-rt-principal="{principal or 0}">
  <div class="owner-title-row">
    <div class="owner-title">{html.escape(owner_label_text)}<span class="owner-sub">· {html.escape(labels_disp)}</span></div>
    {trade_btn_html}
  </div>
  <table class="kpi">{rows}</table>
</section>'''


def _render_account_kpi(owner: str, label: str, owner_d: dict, balances: dict, journal_by_label: dict, owner_label_text: str, has_prev_snap: bool, label_disp: str | None = None) -> str:
    """단일 계좌 단위 compact KPI 카드. owner_d['rows'] 를 account==label 로 필터해 합산.

    합산 뷰의 `_render_owner_kpi` 와 동일한 라벨·순서지만, 데이터 출처가 owner 합 대신 단일 라벨이라
    당일 평가손익은 owner-level 전날 스냅샷(holdings는 owner 합)이라 정확한 분해가 불가 →
    `(day_change × qty)` 합으로 근사하고 `근사` 표기. 합계 뷰의 prev_net 기반 값은 owner 카드에서 확인.
    """
    rows = [r for r in owner_d.get('rows', []) if r.get('account') == label]
    total_value = sum(r.get('eval_value', 0) for r in rows)
    total_cost = sum(r.get('buy_amount', 0) for r in rows)
    total_profit = sum(r.get('profit', 0) for r in rows)
    total_profit_rate = (total_profit / total_cost * 100) if total_cost else 0.0
    # 종목 단위 unique count (한 라벨 내에선 중복 없지만 안전)
    held_count = len({r.get('code') for r in rows if r.get('qty', 0) > 0 and r.get('code')})

    bal = balances.get(label) or {}
    deposit = bal.get('d2_entra', 0) or 0
    total_net = total_value + deposit
    locked = bal.get('pending_buy_locked', 0) or 0

    # 당일 평가손익 — 라벨별 prev_snap이 없어 근사 (day_change × qty). 실현손익·cash_flow는 포함 안 됨.
    if has_prev_snap and rows:
        approx_day_pl = sum(int(r.get('day_change', 0) or 0) * int(r.get('qty', 0) or 0) for r in rows)
    else:
        approx_day_pl = None

    # 라벨별 실현손익·당일매매 — journal_by_label[label] 에서 직접 추출
    label_journal = journal_by_label.get(label, []) or []
    sell_entries = [j for j in label_journal if (j.get('sell_qty') or 0) > 0]
    realized_pl = sum(int(j.get('pl_amt', 0) or 0) for j in sell_entries)
    realized_fees = sum(int(j.get('cmsn_tax', 0) or 0) for j in sell_entries)
    traded_today_codes = {j.get('code') for j in label_journal if ((j.get('buy_qty') or 0) + (j.get('sell_qty') or 0)) > 0 and j.get('code')}

    sign = '+' if total_profit >= 0 else ''
    pcls = _profit_class(total_profit)

    if approx_day_pl is None:
        day_pl_html = '<span class="muted small">전날 스냅샷 없음</span>'
    else:
        dcls = _profit_class(approx_day_pl)
        dsign = '+' if approx_day_pl >= 0 else ''
        day_pl_html = f'<span class="{dcls}">{dsign}{approx_day_pl:,}원</span><span class="muted small"> · 근사</span>'

    deposit_pct_html = ''
    if total_value:
        dep_pct = (deposit / total_value) * 100
        deposit_pct_html = f'<span class="muted small">({dep_pct:.1f}%)</span> '
    deposit_locked_html = (
        f'<span class="muted small"> · 매수 묶임 {locked:,}원</span>' if locked > 0 else ''
    )

    # 라벨·순서는 _render_owner_kpi와 통일 ('총'=현금 포함 전체).
    kpis = [
        ('총자산', f'<span class="net">{total_net:,}원</span>'),
        ('매입금액', f'{total_cost:,}원'),
        ('평가금액', f'{total_value:,}원'),
        ('평가손익', f'<span class="{pcls}">{sign}{total_profit:,}원 ({sign}{total_profit_rate:.2f}%)</span>'),
        ('당일 평가손익', day_pl_html),
        ('예수금', f'{deposit_pct_html}{deposit:,}원{deposit_locked_html}'),
        ('보유종목/당일매매', f'{held_count}개 / {len(traded_today_codes)}개'),
    ]
    if traded_today_codes and (realized_pl or realized_fees):
        rcls = _profit_class(realized_pl)
        rsign = '+' if realized_pl >= 0 else ''
        fee_html = f'<span class="muted small"> · 수수료·세금 {realized_fees:,}원</span>' if realized_fees else ''
        kpis.append(('당일 실현손익', f'<span class="{rcls}">{rsign}{realized_pl:,}원</span>{fee_html}'))

    _rows = []
    for i, (k, v) in enumerate(kpis):
        cls = ' class="kpi-sep"' if (k in _KPI_SEP_AFTER and i < len(kpis) - 1) else ''
        _rows.append(f'<tr{cls}><th>{html.escape(k)}</th><td>{v}</td></tr>')
    rows_html = ''.join(_rows)
    sub_text = label_disp if label_disp is not None else label
    return f'''<section class="owner-card">
  <div class="owner-title-row">
    <div class="owner-title">{html.escape(owner_label_text)}<span class="owner-sub">· {html.escape(sub_text)}</span></div>
  </div>
  <table class="kpi">{rows_html}</table>
</section>'''


def _render_ohlc_mini_dual(krx: dict | None, nxt: dict | None, active_market: str = 'none', al_price: int = 0, krx_closed: bool = False, nxt_closed: bool = False) -> str:
    """보유종목 자세히 보기용 OHLC mini-table — KRX/NXT 두 거래소 가격·등락률 분리 표시.

    각 dict: {open, high, low, price, change_pct} (price=종가/현재가, change_pct=직전 영업일 종가 대비).
    한쪽이 None이면 해당 컬럼 전체 '-' placeholder. 4행(현재가/시가/고가/저가) × 2컬럼(KRX/NXT).

    active_market: 'krx' / 'nxt' / 'none'. 활성 시장 컬럼은 기본 색, 비활성은 회색 처리.
    """
    # price 만 있어도 컬럼 표시 — 거래 없는 시간대도 종가 노출. open/high/low 0 셀은 _cell 안에서 '-' 처리.
    has_krx = bool(krx and krx.get('price'))
    has_nxt = bool(nxt and nxt.get('price'))
    if not (has_krx or has_nxt):
        return ''

    # NXT 시가 baseline 은 호출처에서 어제 NXT 스냅샷 종가(r['pred_close'])로 미리 박아 전달.
    # 자동 baseline 계산(price - change)은 호출처가 못 박은 경우만 fallback.
    if has_nxt and nxt and not nxt.get('open'):
        nxt_baseline = (nxt.get('price', 0) or 0) - (nxt.get('change', 0) or 0)
        if nxt_baseline > 0:
            nxt = dict(nxt)
            nxt['open'] = nxt_baseline

    def _cell(d: dict | None, key: str, dim: bool, closed: bool = False) -> str:
        dim_cls = ' ohlc-dim' if dim else ''
        if not d:
            return f'<span class="ohlc-cell-empty{dim_cls}">-</span>'
        extra = ''  # 현재가 셀에만 WS 라이브 갱신 마커(data-rt-ohlc-cur) + 전일종가(data-prev) 부착.
        if key == 'price':
            # 현재가는 AL(통합) 가격을 양 컬럼 공통으로 표시(동일가). 등락률만 각 시장 전일종가 대비로 분리.
            price = al_price or d.get('price', 0)
            base = (d.get('prev_close', 0) or 0) if al_price else 0
            if al_price and base:
                pct = (price - base) / base * 100
            else:
                pct = d.get('change_pct', 0.0) or 0.0
            extra = f' data-rt-ohlc-cur data-prev="{int(base) if base else 0}"'
        elif key == 'close_today':
            # 당일종가 — 그 시장이 마감된 뒤에만 그 시장의 종가(=ka10095 price)를 표시. 장중엔 미확정(·).
            if not closed:
                return f'<span class="ohlc-cell-empty{dim_cls}">·</span>'
            price = d.get('price', 0)
            base = d.get('prev_close', 0) or 0
            pct = ((price - base) / base * 100) if base else (d.get('change_pct', 0.0) or 0.0)
        elif key == 'prev_close':
            # 전 거래일 종가 — baseline 자체라 등락률 표시 없음.
            price = d.get('prev_close', 0)
            if not price:
                return f'<span class="ohlc-cell-empty{dim_cls}">-</span>'
            return f'<span class="ohlc-cell{dim_cls}"><span class="ohlc-price">{price:,}원</span></span>'
        elif key == 'volume':
            # 거래량 — 등락률 없이 주 수만 표시.
            vol = d.get('volume', 0) or 0
            if not vol:
                return f'<span class="ohlc-cell-empty{dim_cls}">-</span>'
            return f'<span class="ohlc-cell{dim_cls}"><span class="ohlc-price">{vol:,}주</span></span>'
        else:
            price = d.get(key, 0)
            base = d.get('price', 0) - d.get('change', 0)
            pct = ((price - base) / base * 100) if base else 0.0
        if not price:
            return f'<span class="ohlc-cell-empty{dim_cls}">-</span>'
        cls = 'up' if pct > 0 else ('down' if pct < 0 else 'neutral')
        sgn = '+' if pct >= 0 else ''
        return (
            f'<span class="ohlc-cell{dim_cls}"{extra}><span class="ohlc-price">{price:,}원</span>'
            f'<span class="ohlc-pct {cls}">{sgn}{pct:.2f}%</span></span>'
        )

    krx_dim = active_market not in ('krx', 'none-active-krx')  # 'krx' 활성 또는 양쪽 비활성-krx강조 시만 밝게
    nxt_dim = active_market != 'nxt'
    # 'none' (양쪽 비활성, 휴장/심야) — 둘 다 dim
    if active_market == 'none':
        krx_dim = True
        nxt_dim = True
    elif active_market == 'krx':
        krx_dim = False
        nxt_dim = True
    elif active_market == 'nxt':
        krx_dim = True
        nxt_dim = False

    rows = [
        ('전일종가', 'prev_close'),
        ('현재가', 'price'),
        ('시가', 'open'),
        ('고가', 'high'),
        ('저가', 'low'),
        ('당일종가', 'close_today'),
        ('거래량', 'volume'),
    ]
    krx_th_dim = ' ohlc-dim' if krx_dim else ''
    nxt_th_dim = ' ohlc-dim' if nxt_dim else ''
    parts = [
        '<div class="ohlc-mini ohlc-dual">',
        '<span class="ohlc-th"></span>',
        f'<span class="ohlc-th{krx_th_dim}">KRX</span>',
        f'<span class="ohlc-th{nxt_th_dim}">NXT</span>',
    ]
    for lbl, key in rows:
        parts.append(f'<span class="ohlc-rl">{lbl}</span>')
        parts.append(_cell(krx if has_krx else None, key, krx_dim, krx_closed))
        parts.append(_cell(nxt if has_nxt else None, key, nxt_dim, nxt_closed))
    parts.append('</div>')
    return ''.join(parts)


def _render_ohlc_mini(o: int, h: int, l: int, current: int, pred_krx: int, pred_nxt: int) -> str:
    """OHLC 4행 mini-table — 라벨/가격/전일(KRX)/전일(NXT) 4-col grid.

    헤더 row 한 줄에 '전일(KRX)' '전일(NXT)' 컬럼명, 데이터 row는 4행(현재가/시가/고가/저가).
    pred_krx/pred_nxt 중 하나가 0이면 해당 컬럼은 모두 `-` placeholder — 컬럼 레이아웃 유지.
    KRX/NXT 기준이 같은 종목(NXT 미운영)에선 두 컬럼 값이 동일.
    """
    if not (o > 0 and h > 0 and l > 0 and current > 0):
        return ''

    def _pct_cell(p: int, base: int) -> str:
        if not base or not p:
            return '<span class="ohlc-pct neutral">-</span>'
        d = p - base
        pct = d / base * 100
        cls = 'up' if d > 0 else ('down' if d < 0 else 'neutral')
        sgn = '+' if d >= 0 else ''
        return f'<span class="ohlc-pct {cls}">{sgn}{pct:.2f}%</span>'

    rows = [
        ('현재가', current),
        ('시가',   o),
        ('고가',   h),
        ('저가',   l),
    ]
    parts = [
        '<div class="ohlc-mini">',
        '<span class="ohlc-th"></span>',
        '<span class="ohlc-th"></span>',
        '<span class="ohlc-th">KRX</span>',
        '<span class="ohlc-th">NXT</span>',
    ]
    for lbl, p in rows:
        parts.append(f'<span class="ohlc-rl">{lbl}</span>')
        parts.append(f'<span class="ohlc-price">{p:,}원</span>')
        parts.append(_pct_cell(p, pred_krx))
        parts.append(_pct_cell(p, pred_nxt))
    parts.append('</div>')
    return ''.join(parts)


def _render_single_candle(o: int, h: int, l: int, c: int, width: int = 64, height: int = 110) -> str:
    """오늘 하루 단일 캔들 SVG. 시/고/저/현 4값으로 박스+wick. 빨강=상승, 파랑=하락 (한국 관례)."""
    if not (o > 0 and h > 0 and l > 0 and c > 0):
        return ''
    if h == l:
        return ''
    pad_top, pad_bot = 6, 6
    plot_h = height - pad_top - pad_bot
    def y(price: int) -> float:
        return pad_top + (h - price) / (h - l) * plot_h
    cx = width / 2
    body_top = y(max(o, c))
    body_bot = y(min(o, c))
    body_h = max(body_bot - body_top, 1.0)
    wick_top = y(h)
    wick_bot = y(l)
    color = '#ff4d5e' if c >= o else '#4d8cff'
    body_w = max(width * 0.55, 14)
    body_x = cx - body_w / 2
    return (
        f'<svg viewBox="0 0 {width} {height}" width="{width}" height="{height}" class="candle-svg" aria-hidden="true">'
        f'<line x1="{cx:.1f}" y1="{wick_top:.1f}" x2="{cx:.1f}" y2="{wick_bot:.1f}" stroke="{color}" stroke-width="1.5"/>'
        f'<rect x="{body_x:.1f}" y="{body_top:.1f}" width="{body_w:.1f}" height="{body_h:.1f}" fill="{color}"/>'
        f'</svg>'
    )


_EMPTY_KV_PAIR = '<dt class="empty"></dt><dd class="empty"></dd>'


def _interleave_kv_pairs(left: list[str], right: list[str]) -> str:
    """좌·우 dt-dd 쌍 리스트를 4컬럼 row-major grid 순서로 인터리브."""
    n = max(len(left), len(right))
    out: list[str] = []
    for i in range(n):
        out.append(left[i] if i < len(left) else _EMPTY_KV_PAIR)
        out.append(right[i] if i < len(right) else _EMPTY_KV_PAIR)
    return ''.join(out)


def _pending_badges_html(r: dict) -> str:
    """미리보기 💰 배지 — 색만으로 매수(빨강)/매도(파랑) 구분.
    수량·상세 정보는 자세히보기(`_pending_detail_html`)에서.
    """
    buy = r.get('pending_buy_qty', 0)
    sell = r.get('pending_sell_qty', 0)
    parts: list[str] = []
    if buy:
        parts.append(
            f'<span class="badge pending-buy" title="키움 미체결 매수 {buy:,}주 — 자세히보기에서 확인">💰</span>'
        )
    if sell:
        parts.append(
            f'<span class="badge pending-sell" title="키움 미체결 매도 {sell:,}주 — 자세히보기에서 확인">💰</span>'
        )
    return ''.join(parts)


def _fmt_hhmmss(raw: str) -> str:
    """ka10075 'tm' 필드 'HHMMSS' → 'HH:MM:SS'. 이미 콜론이거나 비표준이면 그대로."""
    s = (raw or '').strip()
    if not s or ':' in s:
        return s
    if len(s) == 6 and s.isdigit():
        return f'{s[0:2]}:{s[2:4]}:{s[4:6]}'
    return s


_TRAILING_LOOKUP_TTL = 5.0
_trailing_lookup_cache: dict = {'ts': 0.0, 'by_ord': {}}


def _trailing_info_for(ord_no: str):
    """미체결 주문번호 → 트레일링 예약 요약 (없으면 None).

    `state/trailing_stops.json` 읽기라 키움 콜 0. 렌더 경로에서 주문마다 불리므로 5초 캐시.
    ⚠️ 예약의 ord_no 는 정정할 때마다 바뀌므로(kt10002 가 새 번호 발급) 항상 예약 파일의
    현재 ord_no 와 매칭한다 — 등록 시점 번호로 매칭하면 첫 상향 후 연결이 끊긴다.
    """
    if not ord_no:
        return None
    now = time.time()
    if now - _trailing_lookup_cache['ts'] > _TRAILING_LOOKUP_TTL:
        try:
            from orders import trailing as _trl
            # 예약 1건이 계단(레그) N개 = 미체결 주문 N건이라, 레그마다 (예약, 레그) 로 색인한다.
            by_ord = {}
            for r in _trl.list_active():
                for s in r.get('steps') or []:
                    by_ord[s['ord_no']] = (r, s)
        except Exception:
            by_ord = {}
        _trailing_lookup_cache['by_ord'] = by_ord
        _trailing_lookup_cache['ts'] = now
    hit = _trailing_lookup_cache['by_ord'].get(ord_no)
    if not hit:
        return None
    r, s = hit
    steps = r.get('steps') or []
    return {
        'id': r['id'], 'peak': r.get('peak', 0),
        'min_sell_price': r.get('min_sell_price'),
        # 이 주문이 몇 단계인지 + 예약 전체가 몇 단인지 — 미체결 목록엔 레그가 따로 뜨므로
        # "3단 중 2단계" 를 보여줘야 다른 행과의 관계가 읽힌다.
        'step_n': s['n'], 'step_total': len(steps),
        'trail_pct': s.get('pct'), 'cum': s.get('cum'),
        'step_qty': s.get('qty', 0),
        'modify_count': s.get('modify_count', 0),
        # 등록 시점 값 — 손절선·고점이 그 뒤로 얼마 올라왔는지 보여주는 데 쓴다.
        'entry_cond_uv': s.get('entry_cond_uv', 0),
        'entry_peak': r.get('entry_peak', 0),
        # 형제 계단 — 이 예약의 전체 그림을 한 행에서 보여준다.
        'siblings': [{'n': x['n'], 'pct': x.get('pct'), 'cond_uv': x.get('cond_uv', 0),
                      'qty': x.get('qty', 0), 'cum': x.get('cum')} for x in steps],
    }


def _stop_gap_html(stop_price: int, limit_price: int) -> str:
    """스톱 주문의 조건단가−지정가 갭 표기. 호가단위로 나눠 틱수로 보여준다.

    `orders/trailing.ORD_UV_GAP_TICKS` 값을 화면에 그대로 박지 않는 이유는 실제 주문에
    걸린 값과 어긋날 수 있기 때문(정정 중 값 변화·키움앱 수동 개입). 실측값으로 역산한다.
    틱으로 딱 나눠지지 않으면 원으로 폴백.
    """
    gap = (stop_price or 0) - (limit_price or 0)
    if gap <= 0:
        return ''
    try:
        from orders.trailing import tick_size
        t = tick_size(stop_price)
    except Exception:
        t = 0
    if t > 0 and gap % t == 0:
        return f' <span class="muted">−{gap // t}틱</span>'
    return f' <span class="muted">−{gap:,}원</span>'


def _pending_detail_html(r: dict) -> str:
    """자세히보기용 미체결 ord 단위 상세. 매수/매도 분리 표시.
    `r['pending_orders']` 가 ord별 dict 리스트.
    레이아웃: dt(라벨, 컬러) / dd 2~3줄(상단=수량@가격, 조건줄, 하단=계좌·시각·주문번호 muted).
    """
    orders = r.get('pending_orders') or []
    if not orders:
        return ''
    buys = [o for o in orders if o.get('side') == 'BUY']
    sells = [o for o in orders if o.get('side') == 'SELL']

    cur_price = r.get('price', 0) or 0  # 조건단가까지 남은 거리 계산용

    def _row(o: dict, kind: str) -> str:
        qty = o.get('qty', 0)
        price = o.get('price', 0)
        otype = o.get('order_type') or ''
        stop_p = o.get('stop_price', 0) or 0
        trl = o.get('trailing')
        acc = html.escape(o.get('account', ''))
        ord_no = html.escape(o.get('ord_no', ''))
        tm = _fmt_hhmmss(html.escape(o.get('time', '')))
        meta_parts = [acc]
        if tm:
            meta_parts.append(tm)
        if ord_no:
            meta_parts.append(f'주문번호 {ord_no}')

        label = '미체결 매수' if kind == 'buy' else '미체결 매도'
        if kind == 'sell' and trl:
            label = '트레일링 매도'
        elif kind == 'sell' and stop_p:
            label = '스톱 매도'

        # 스톱 계열은 가격이 두 개다 — 조건단가(방아쇠)와 지정가(실제 주문가).
        # 첫 줄에 지정가만 '@ N원' 으로 박으면 그게 방아쇠처럼 오해되고 둘의 관계도 안 보인다.
        # 그래서 트레일링은 첫 줄에 수량만 남기고 두 가격을 미니표 맨 위 단독 항으로 올린다.
        if trl or stop_p:
            main = f'{qty:,}주 매도 예약'
        elif price:
            main = f'{qty:,}주 @ {price:,}원 {otype}' if otype else f'{qty:,}주 @ {price:,}원 지정가'
        else:
            main = f'{qty:,}주 @ {otype or "시장가"}'

        cond_line = ''
        if stop_p and not trl:
            # 순수 스톱지정가 — 조건단가 고정. 표 없이 한 줄이지만 순서는 트레일링과 같게
            # (조건가 닿으면 → 지정가로 매도). 지정가가 먼저 오면 그게 방아쇠로 오해된다.
            tail = f' {price:,}원에 매도' if price else ' 매도'
            cond_line = (f'<div class="small pending-line-cond">'
                         f'{stop_p:,}원 닿으면{tail}{_stop_gap_html(stop_p, price)} '
                         f'<span class="muted">(고정)</span></div>')
        if trl:
            mini = []
            mini.append(f'<dt>손절선</dt><dd><b>{stop_p:,}원</b> 도달 시</dd>')
            if price:
                # 갭은 두 값의 차를 호가단위로 나눠 틱수로 표시 — ORD_UV_GAP_TICKS 를 그대로
                # 박지 않는 이유는 실제 주문 값과 어긋날 수 있어서다(정정·수동 개입).
                # 틱으로 딱 안 나눠지면 원으로 폴백.
                gap_part = _stop_gap_html(stop_p, price)
                mini.append(f'<dt>매도 지정가</dt><dd>{price:,}원{gap_part}</dd>')
            # 고점에 '등록 시점' 라벨을 붙이지 않는다 — peak 는 감시가 갱신한 현재 고점이라
            # 붙여 놓으면 지금 고점이 등록 시점 값인 것처럼 읽힌다
            # (실측 entry_peak 4,860 vs peak 4,950). 등록 후 얼마나 올랐는지는
            # 아래 '고점 상승' 행이 실제 값으로 보여준다.
            mini.append(f'<dt>고점</dt><dd><b>{trl.get("peak", 0):,}원</b></dd>')
            cum_v = trl.get('cum') or 0
            cum_part = '전량' if cum_v >= 100 else f'누적 {cum_v:g}%'
            mini.append(f'<dt>계단</dt><dd><b>{trl.get("step_n")}단계</b> / '
                        f'{trl.get("step_total")}단 · −{trl.get("trail_pct"):g}% '
                        f'<span class="muted">({cum_part})</span></dd>')
            if (trl.get('step_total') or 0) > 1:
                # 형제 계단 — 미체결 목록엔 레그가 각각 뜨므로, 이 예약 전체가 어떤 모양인지
                # 한 행에서 보이게 한다. 지금 행에 해당하는 계단은 굵게.
                sib = ' · '.join(
                    (f'<b>{x["n"]}:{x["cond_uv"]:,}</b>' if x['n'] == trl.get('step_n')
                     else f'{x["n"]}:{x["cond_uv"]:,}')
                    for x in (trl.get('siblings') or []))
                mini.append(f'<dt>전체 계단</dt><dd>{sib}</dd>')
            if trl.get('min_sell_price'):
                mini.append(f'<dt>최저 매도가</dt><dd>{trl["min_sell_price"]:,}원</dd>')
            if cur_price > 0 and stop_p:
                # 손절선을 기준으로 현재가가 몇 % 위에 있는지 = 팔리기까지 남은 여유.
                # 라벨이 '손절선 대비'이므로 분모도 손절선이어야 라벨과 계산이 어긋나지 않는다.
                room = (cur_price - stop_p) / stop_p * 100
                mini.append(f'<dt>손절선 대비</dt><dd>{room:+.1f}% '
                            f'<span class="muted">(현재 {cur_price:,}원)</span></dd>')
            # 상향 횟수는 표시하지 않는다(관리자님 지시). mc 는 상승 행들의 표시 조건으로만 쓴다.
            mc = trl.get('modify_count', 0)
            entry_peak = trl.get('entry_peak') or 0
            peak_now = trl.get('peak', 0)
            if entry_peak and peak_now > entry_peak:
                mini.append(f'<dt>고점 상승</dt><dd>{entry_peak:,} → {peak_now:,}원 '
                            f'<span class="up">+{peak_now - entry_peak:,}</span></dd>')
            entry_cond = trl.get('entry_cond_uv') or 0
            if mc and entry_cond and stop_p > entry_cond:
                mini.append(f'<dt>손절선 상승</dt><dd>{entry_cond:,} → {stop_p:,}원 '
                            f'<span class="up">+{stop_p - entry_cond:,}</span></dd>')
            cond_line += f'<dl class="trail-mini">{"".join(mini)}</dl>'

        return (
            f'<dt class="pending-{kind}-label">{label}</dt>'
            f'<dd>'
            f'<div class="num pending-line-main">{main}</div>'
            f'{cond_line}'
            f'<div class="muted small pending-line-meta">{" · ".join(meta_parts)}</div>'
            f'</dd>'
        )

    pairs: list[str] = []
    for o in buys:
        pairs.append(_row(o, 'buy'))
    for o in sells:
        pairs.append(_row(o, 'sell'))
    return f'<dl class="pending-detail">{"".join(pairs)}</dl>'


def _buy_rank_badge(buy_amount: int) -> str:
    """매입 총액 기반 등급 뱃지. 100/200/500/1000만원 경계로 1~5등급. 막대 stack(군대 계급 모티프)."""
    if not buy_amount or buy_amount <= 0:
        return ''
    M = 10_000
    if buy_amount <= 100 * M:
        tier = 1
    elif buy_amount <= 200 * M:
        tier = 2
    elif buy_amount <= 500 * M:
        tier = 3
    elif buy_amount <= 1000 * M:
        tier = 4
    else:
        tier = 5
    stripes = ''.join('<span class="stripe"></span>' for _ in range(tier))
    title = f'매입 {buy_amount:,}원 · 등급 {tier}'
    return f'<span class="rank-badge rank-{tier}" title="{title}" aria-label="등급 {tier}">{stripes}</span>'


def _render_holding_row(r: dict, total_value: int, show_day_change: bool = False, key_suffix: str = '') -> str:
    stock = html.escape(r['stock'])
    # 종목명 길이 기반 글씨크기 — 길면 줄여서 한 줄에 맞춤(ellipsis 최소화, 렌더 시 확정이라 깜빡임 없음).
    _nlen = len(r.get('stock', '') or '')
    stock_cls = 'stock stock-xs' if _nlen >= 14 else ('stock stock-sm' if _nlen >= 10 else 'stock')
    code = html.escape(r.get('code', '') or '')
    accounts = html.escape('+'.join(r.get('accounts', [])))
    # 표시용 계좌명 — '가희_' 등 owner 접두 떼고 유형(일반/ISA)만, 중복 제거, '·' 구분.
    _acct_types: list[str] = []
    for _lb in r.get('accounts', []):
        _t = (_lb or '').split('_')[-1]
        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)
    profit = r.get('profit', 0)
    profit_rate = r.get('profit_rate', 0.0)
    eval_value = r.get('eval_value', 0)
    buy_amount = r.get('buy_amount', 0)
    weight = (eval_value / total_value * 100) if total_value else 0.0
    pcls = _profit_class(profit)
    sign = '+' if profit >= 0 else ''
    # 오늘 매매 별표 — 매수/매도 구분. 둘 다 발생 시 두 개 표시.
    star_parts: list[str] = []
    if r.get('tdy_buyq'):
        star_parts.append(f'<span class="star buy" title="오늘 매수 {r.get("tdy_buyq",0):,}주">★</span>')
    if r.get('tdy_sellq'):
        star_parts.append(f'<span class="star sell" title="오늘 매도 {r.get("tdy_sellq",0):,}주">★</span>')
    star = ''.join(star_parts)
    day_change = r.get('day_change', 0)
    day_change_pct = r.get('day_change_pct', 0.0)

    candle_summary = ''
    ohlc = r.get('ohlc') or {}
    if ohlc:
        mini = _render_single_candle(ohlc.get('o', 0), ohlc.get('h', 0), ohlc.get('l', 0), ohlc.get('c', 0), width=24, height=35)
        if mini:
            candle_summary = f'<div class="candle-mini">{mini}</div>'

    j = r.get('journal') or {}
    journal_lines: list[str] = []
    summary_journal_html = ''
    if j.get('buy_qty'):
        journal_lines.append(
            f'<dt>매수</dt><dd class="num">{j["buy_avg"]:,}원 × {j["buy_qty"]:,}주 = {j["buy_amt"]:,}원</dd>'
        )
    if j.get('sell_qty'):
        sell_pl = j.get('pl_amt', 0)
        scls = _profit_class(sell_pl)
        ssign = '+' if sell_pl >= 0 else ''
        journal_lines.append(
            f'<dt>매도</dt><dd class="num">{j["sell_avg"]:,}원 × {j["sell_qty"]:,}주 = {j["sell_amt"]:,}원</dd>'
        )
        journal_lines.append(
            f'<dt>실현손익</dt><dd class="num"><span class="{scls}">{ssign}{sell_pl:,}원 '
            f'({ssign}{j["prft_rt"]:.2f}%)</span><span class="muted small"> · 수수료·세금 {j["cmsn_tax"]:,}원</span></dd>'
        )
        # 부분매도 케이스 — 기본 카드 뷰에서도 실현손익이 보이도록 별도 줄로 표시.
        # 금액이 크면 비중과 같은 줄에서 폭이 터지므로 line2 를 한 줄 더 분리.
        summary_journal_html = (
            f'<div class="line2"><span class="muted small">매도 {j["sell_qty"]:,}주 실현 '
            f'<span class="{scls}">{ssign}{sell_pl:,}원</span></span></div>'
        )

    pl_pairs: list[str] = [
        f'<dt>평가손익</dt><dd class="num"><span class="{pcls} rt-profit-detail">{sign}{profit:,}원 ({sign}{profit_rate:.2f}%)</span></dd>',
    ]
    # 수익률 마이너스(손실)일 때만 — 평단가 회복(본전)에 필요한 상승률.
    if profit_rate < 0 and price > 0 and avg > price:
        breakeven_pct = (avg - price) / price * 100
        pl_pairs.append(
            f'<dt>평단가대비</dt><dd class="num"><span class="muted small">(+{breakeven_pct:.2f}%)</span></dd>'
        )
    if day_change:
        dcls = _profit_class(day_change)
        dsign = '+' if day_change >= 0 else ''
        day_value = day_change * qty
        dvsign = '+' if day_value >= 0 else ''
        pl_pairs.append(
            f'<dt>평가금액 변동</dt><dd class="num"><span class="{dcls} rt-dayvalue">{dvsign}{day_value:,}원</span></dd>'
        )
        pl_pairs.append(
            f'<dt>당일 등락</dt><dd class="num"><span class="{dcls} rt-daychange">{dsign}{day_change:,}원 '
            f'({dsign}{day_change_pct:.2f}%)</span></dd>'
        )
    # 활성 시장 판정 — OHLC 미니테이블 컬럼 강조용(active_market). cur_price 가 어느 시장 가격과 일치하는지.
    nxt_p = (r.get('ohlc_nxt') or {}).get('price', 0)
    krx_p = (r.get('ohlc_krx') or {}).get('price', 0)
    mark = 'NXT' if (nxt_p and nxt_p == price and nxt_p != krx_p) else 'KRX'
    # 거래가능 표시 — 지금 거래시간이면 초록점, 아니면 점 없음. (미리보기 행과 동일 규칙)
    #   정규장: KRX+NXT 모두 거래 → 초록점 / NXT 시간대: NXT 거래종목(ohlc_nxt 가격 살아있음)만 / 장외·휴장·주말: 점 없음
    _phase = _market_phase_state()['phase']
    if _phase == 'regular':
        _tradable = True
    elif _phase == 'nxt':
        _tradable = bool(nxt_p)
    else:
        _tradable = False
    mark_html = '<span class="px-dot tradable" title="지금 거래 가능"></span>' if _tradable else ''

    # OHLC mini-table — ka10095 NX/NXT batch 결과를 각 컬럼에 분리 표시. 활성 시장만 밝게.
    # _nxt_inactive 마커는 stock_portfolio_report의 NX 응답 기반 판정이라 ETF처럼 NXT 정상거래 종목도
    # 잘못 잡힘 → 여기선 무시하고 실제 ohlc_nxt 데이터 유무로 표시 결정.
    # NXT 컬럼 baseline 을 어제 NXT 스냅샷 종가(r['pred_close']) 로 통일.
    # change/change_pct 도 스냅샷 baseline 기준으로 재계산 → cell 안 pct 표시도 일관.
    # 전 거래일 종가 행: KRX = pred_close_krx (KRX 전일종가), NXT = r['pred_close'] (어제 NXT 스냅샷).
    ohlc_krx_for_render = r.get('ohlc_krx')
    if ohlc_krx_for_render:
        ohlc_krx_for_render = dict(ohlc_krx_for_render)
        # KRX 전일종가 = ka10095(현재가 − 전일대비). kt00018 pred_close_krx는 장 마감 후
        # 현재가와 같은 값으로 채워지는 결함이 있어 등락률이 0%로 깔린다 → ka10095 기준으로 산출.
        _krx_prev = (ohlc_krx_for_render.get('price', 0) or 0) - (ohlc_krx_for_render.get('change', 0) or 0)
        if _krx_prev <= 0:
            _krx_prev = r.get('pred_close_krx', 0) or 0
        if _krx_prev > 0:
            ohlc_krx_for_render['prev_close'] = _krx_prev
    ohlc_nxt_for_render = r.get('ohlc_nxt')
    _snap_prev = r.get('pred_close', 0) or 0
    if ohlc_nxt_for_render and _snap_prev > 0:
        ohlc_nxt_for_render = dict(ohlc_nxt_for_render)
        ohlc_nxt_for_render['open'] = _snap_prev
        ohlc_nxt_for_render['prev_close'] = _snap_prev
        _nxt_px = ohlc_nxt_for_render.get('price', 0) or 0
        ohlc_nxt_for_render['change'] = _nxt_px - _snap_prev
        ohlc_nxt_for_render['change_pct'] = ((_nxt_px - _snap_prev) / _snap_prev * 100) if _snap_prev else 0.0
    # 당일종가: KRX는 정규장 마감(15:30) 후, NXT는 마감(20:00) 후에만 그 시장 종가 표시.
    _now_ohlc = datetime.now(KST)
    _hm_ohlc = _now_ohlc.hour * 60 + _now_ohlc.minute
    _trd_ohlc = _market_phase_state().get('phase') not in ('holiday', 'weekend')
    ohlc_mini_html = _render_ohlc_mini_dual(
        ohlc_krx_for_render,
        ohlc_nxt_for_render,
        active_market=mark.lower(),
        al_price=price,  # 현재가 행은 AL(통합) 현재가를 양 컬럼 공통 — 등락률만 각 시장 전일종가 대비.
        krx_closed=_trd_ohlc and _hm_ohlc >= 15 * 60 + 30,
        nxt_closed=_trd_ohlc and _hm_ohlc >= 20 * 60,
    )

    # 미리보기 등락은 rebase 후 day_change_pct 사용 — 어제 NXT 종가 baseline.
    # raw pred_close_krx 는 kt00018 결함으로 정규장 시작 전·마감 후 cur_price 와 같은 값이 들어와
    # 등락률이 항상 0% 으로 깔리는 문제가 있어 사용 X.
    d_pct_val = r.get('day_change_pct')
    # show_day_change(시간대 게이트) 동안엔 항상 rt-daypct span 을 그린다 — 값이 0/None이어도.
    # span 이 없으면 WS paintDay(if(sp)) 가 실시간으로 못 채워, 한 번 0으로 렌더되면 등락률이 사라진 채 복구 안 됨.
    if show_day_change:
        _dp = d_pct_val if isinstance(d_pct_val, (int, float)) else 0.0
        d_sign = '+' if _dp >= 0 else ''
        d_cls = 'day-pct up' if _dp > 0 else ('day-pct down' if _dp < 0 else 'day-pct neutral')
        price_html = f'{mark_html}<span class="rt-px">{price:,}</span><span class="{d_cls} rt-daypct">{d_sign}{_dp:.2f}%</span>'
    else:
        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"><span class="rt-px">{price:,}</span>원</dd>',
        f'<dt>평단가</dt><dd class="num">{avg:,}원</dd>',
    ]
    right_pairs = [
        f'<dt>보유수량</dt><dd class="num">{qty:,}주</dd>',
        f'<dt>매입금액</dt><dd class="num">{buy_amount:,}원</dd>',
        f'<dt>평가금액</dt><dd class="num"><span class="rt-eval">{eval_value:,}원</span></dd>',
    ]
    dl_inner = _interleave_kv_pairs(left_pairs, right_pairs)
    pl_pairs.extend(journal_lines)
    # 매수후 고점대비 — 보유 시작일 이후 일봉 고가 최댓값 대비 현재가 낙폭(%·평가액).
    # 매수/매도(journal_lines) 아래에 표시. 순수 캐시 읽기(_select_latest)만 — 렌더 중 키움 호출 방지.
    since = _holding_since_map().get(r.get('code') or '')
    peak_high = 0
    if since and code:
        try:
            import daily_candles_cache as dcc
            for cd in dcc._select_latest(code, 250):
                d = str(cd.get('date') or '')
                h = cd.get('high') or 0
                if d >= since and h > peak_high:
                    peak_high = h
        except Exception:
            peak_high = 0
    oh_today = r.get('ohlc') or {}
    if oh_today.get('h'):
        peak_high = max(peak_high, oh_today['h'])
    if price:
        peak_high = max(peak_high, price)
    if peak_high > 0 and price > 0:
        dd_pct = (price - peak_high) / peak_high * 100
        dd_won = (price - peak_high) * qty
        ddcls = _profit_class(dd_won)
        pl_pairs.append('<div class="pl-divider"></div>')
        pl_pairs.append(
            f'<dt>매수후 고점</dt><dd class="num">{peak_high:,}원 '
            f'<span class="{ddcls}">({dd_pct:.2f}%)</span></dd>'
        )
        recover_pct = (peak_high - price) / price * 100
        pl_pairs.append(
            f'<dt>고점대비</dt><dd class="num"><span class="muted small">{dd_won:,}원</span> '
            f'<span class="muted small">(+{recover_pct:.2f}%)</span></dd>'
        )
        pl_pairs.append('<div class="pl-divider"></div>')
    pl_dl_inner = ''.join(pl_pairs)

    row_key = (code or stock) + key_suffix
    tag_add_btn = _tag_add_button_html(r.get('code') or '', r.get('stock') or '')
    # 급등락 알림 on/off 표시 — +태그 왼쪽 (관심·감시 행과 같은 위치). 조작은 actions 의 알림 버튼에서.
    surge_btn = _surge_indicator_html(r.get('code') or '')
    surge_info_btn = _surge_info_button_html(r.get('code') or '', r.get('stock') or '')
    note_btn = _note_button_html(r.get('code') or '', r.get('stock') or '')
    trade_btn = (
        f'<div class="actions">'
        f'{note_btn}'
        f'{surge_info_btn}'
        f'{_info_button_html(r.get("code") or "", r.get("stock") or "")}'
        f'<button type="button" class="btn-trades" data-trade-code="{code}" data-trade-stock="{stock}">거래내역</button>'
        f'<button type="button" class="btn-order" data-order-code="{code}" data-order-stock="{html.escape(stock, quote=True)}" data-order-side="BUY" title="매수/매도">💰 거래</button>'
        f'</div>'
    ) if code else ''
    tag_chip = _tag_chip_html(r.get('code') or '', r.get('stock') or '', interactive=True)
    return f'''<details class="row {pcls} mode-held" data-row-key="{row_key}" data-rt-qty="{qty}" data-rt-cost="{eval_value - profit}" data-rt-px="{price}" data-rt-prevclose="{price - day_change}">
  <summary>
    <div class="left">
      <div class="line1 line1-held">{_buy_rank_badge(buy_amount)}<span class="{stock_cls}">{stock}</span><span class="code acct-mini">{acct_label}</span></div>
      <div class="line2"><span class="badge held">📦 {qty:,}</span>{_pending_badges_html(r)}{_vol_surge_badge(code, (r.get('ohlc_krx') or {}).get('volume'))}<span class="muted small wt-chip"><span class="rt-weight">{weight:.2f}</span>%</span>{star}</div>
      {summary_journal_html}
    </div>
    <div class="right">
      <div class="price">{price_html}<span class="caret" aria-hidden="true">▾</span></div>
      <div class="ref">평단 {avg:,}</div>
      <div class="diff"><span class="rt-profit">{sign}{profit:,}</span><span class="pct rt-profit-pct">{sign}{profit_rate:.2f}%</span></div>
    </div>
    {candle_summary}
  </summary>
  <div class="detail">
    <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>{surge_btn}{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 ''}
    <div class="detail-meta"><span class="dm-item">비중 {weight:.2f}%</span><span class="dm-item">{acct_label}</span>{_market_label_html(code)}</div>
    <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>'''


def _render_phantom_row(r: dict) -> str:
    """잔고에 없는 round-trip / 풀매도 거래. 실현손익만 표시."""
    stock = html.escape(r['stock'])
    code = html.escape(r.get('code', '') or '')
    accounts = html.escape('+'.join(r.get('accounts', [])))
    j = r.get('journal') or {}
    pl = j.get('pl_amt', 0)
    pcls = _profit_class(pl)
    sign = '+' if pl >= 0 else ''
    rate = j.get('prft_rt', 0.0)
    pairs: list[str] = [f'<dt>종목코드</dt><dd class="muted">{code}</dd>']
    live_price = r.get('live_price') or 0
    if live_price:
        lpct = r.get('live_change_pct', 0.0)
        lsign = '+' if lpct >= 0 else ''
        lcls = _profit_class(lpct)
        pairs.append(
            f'<dt>현재가</dt><dd class="num">{live_price:,}원 '
            f'<span class="{lcls} small">({lsign}{lpct:.2f}%)</span></dd>'
        )
    if j.get('sell_qty'):
        pairs.append(
            f'<dt>매도</dt><dd class="num">{j["sell_avg"]:,}원 × {j["sell_qty"]:,}주 = {j["sell_amt"]:,}원</dd>'
        )
        pairs.append(
            f'<dt>실현손익</dt><dd class="num"><span class="{pcls}">{sign}{pl:,}원 '
            f'({sign}{rate:.2f}%)</span><span class="muted small"> · 수수료·세금 {j["cmsn_tax"]:,}원</span></dd>'
        )
    # 어제 보유 → 오늘 풀매도 케이스: 어제 스냅샷의 평단을 '매수 평단'으로 노출
    prev_avg = j.get('prev_avg', 0)
    if prev_avg > 0 and not j.get('buy_qty'):
        pairs.append(
            f'<dt>매수 평단</dt><dd class="num">{prev_avg:,}원<span class="muted small"> · 어제 잔고 기준</span></dd>'
        )
    if j.get('buy_qty'):
        pairs.append(
            f'<dt>매수</dt><dd class="num">{j["buy_avg"]:,}원 × {j["buy_qty"]:,}주 = {j["buy_amt"]:,}원</dd>'
        )
    chart_block = ''
    if code:
        chart_block = f'<div class="block chart-svg" data-chart-code="{code}"></div>'

    row_key = code or stock
    kind_label = '단타' if j.get('buy_qty') else '전량매도'
    return f'''<details class="row {pcls} mode-phantom" data-row-key="{row_key}">
  <summary>
    <div class="left">
      <div class="line1"><span class="stock">{stock}</span><span class="badge phantom">당일정산</span>{_pending_badges_html(r)}<span class="code">{accounts}</span></div>
      <div class="line2"><span class="muted small">{kind_label} · 매도 {j.get("sell_avg", 0):,}원 × {j.get("sell_qty", 0):,}주</span></div>
      {f'<div class="line2"><span class="muted small">현재 {live_price:,}원</span></div>' if live_price else ''}
    </div>
    <div class="right">
      <div class="price">실현 {sign}{pl:,}원<span class="caret" aria-hidden="true">▾</span></div>
      <div class="diff"><span class="{pcls}">{sign}{rate:.2f}%</span></div>
    </div>
  </summary>
  <div class="detail">
    <dl>{''.join(pairs)}</dl>
    {_pending_detail_html(r)}
    {chart_block}
  </div>
</details>'''


def _render_pending_unheld_row(r: dict) -> str:
    """미보유 + 미체결 매수 주문 종목. 보유·당일정산 아닌 신규 매수 대기 행.
    summary는 종목명·미체결 합계, detail은 _pending_detail_html이 주문 단위로 출력."""
    stock = html.escape(r.get('stock') or r.get('code') or '')
    code = html.escape(r.get('code') or '')
    buy_qty = r.get('pending_buy_qty', 0)
    price = r.get('price')
    # 미체결 매수 평균가 (가중평균) — 시장가(price=0)는 제외
    orders = [o for o in (r.get('pending_orders') or []) if o.get('side') == 'BUY']
    priced = [o for o in orders if o.get('price', 0) > 0]
    if priced:
        total_qty = sum(o['qty'] for o in priced)
        avg_buy = sum(o['qty'] * o['price'] for o in priced) // total_qty if total_qty else 0
    else:
        avg_buy = 0
    accounts = sorted({o.get('account', '') for o in orders if o.get('account')})
    acc_text = html.escape('+'.join(accounts))

    if isinstance(price, (int, float)) and price:
        # 미리보기 등락은 KRX 전일종가 대비 — ka10095의 change_pct 그대로.
        day_pct = r.get('day_change_pct')
        if isinstance(day_pct, (int, float)):
            d_cls = 'day-pct up' if day_pct > 0 else ('day-pct down' if day_pct < 0 else 'day-pct neutral')
            d_sign = '+' if day_pct >= 0 else ''
            price_html = f'<span class="rt-px">{price:,}</span><span class="{d_cls} rt-wl-daypct">{d_sign}{day_pct:.2f}%</span>'
        else:
            price_html = f'<span class="rt-px">{price:,}</span>'
        if avg_buy:
            diff = price - avg_buy
            dpct = (diff / avg_buy * 100) if avg_buy else 0.0
            dcls = 'up' if diff > 0 else ('down' if diff < 0 else 'neutral')
            dsign = '+' if diff >= 0 else ''
            diff_html = f'<span class="{dcls}">{dsign}{diff:,.0f}<span class="pct">{dsign}{dpct:.2f}%</span></span>'
        else:
            diff_html = ''
    else:
        price_html = '<span class="muted">-</span>'
        diff_html = ''

    summary_line2 = f'<span class="muted small">매수 대기 {buy_qty:,}주'
    if avg_buy:
        summary_line2 += f' · 평균 주문가 {avg_buy:,}원'
    summary_line2 += '</span>'

    chart_block = ''
    if code:
        chart_block = f'<div class="block chart-svg" data-chart-code="{code}"></div>'

    detail_pairs: list[str] = [f'<dt>종목코드</dt><dd class="muted">{code}</dd>']
    if isinstance(price, (int, float)) and price:
        detail_pairs.append(f'<dt>현재가</dt><dd class="num">{price:,}원</dd>')
    if avg_buy:
        detail_pairs.append(f'<dt>주문 평균가</dt><dd class="num">{avg_buy:,}원</dd>')
    detail_pairs.append(f'<dt>매수 대기</dt><dd class="num">{buy_qty:,}주</dd>')

    row_key = code or stock
    return f'''<details class="row neutral mode-pending-unheld" data-row-key="{row_key}">
  <summary>
    <div class="left">
      <div class="line1"><span class="stock">{stock}</span><span class="badge pending-buy" title="키움 미체결 매수">매수등록</span><span class="code">{acc_text}</span></div>
      <div class="line2">{summary_line2}</div>
    </div>
    <div class="right">
      <div class="price">{price_html}<span class="caret" aria-hidden="true">▾</span></div>
      {f'<div class="diff">{diff_html}</div>' if diff_html else ''}
    </div>
  </summary>
  <div class="detail">
    <dl>{''.join(detail_pairs)}</dl>
    {_pending_detail_html(r)}
    {chart_block}
  </div>
</details>'''


def _aggregate_by_unit(series: list[tuple[str, int]], unit: str) -> list[tuple[str, int]]:
    """단위(day/week/month/year)별로 각 구간의 마지막 영업일 점만 남김.
    'day'는 그대로 반환. week=ISO week (월~일), month=YYYY-MM, year=YYYY."""
    if unit == 'day' or not series:
        return series
    from datetime import date as _date
    groups: dict = {}  # group key → (date_str, value)
    for d, v in series:
        try:
            dt = _date.fromisoformat(d)
        except Exception:
            continue
        if unit == 'week':
            iso = dt.isocalendar()
            key = (iso[0], iso[1])
        elif unit == 'month':
            key = (dt.year, dt.month)
        elif unit == 'year':
            key = (dt.year,)
        else:
            return series
        prev = groups.get(key)
        if prev is None or d > prev[0]:
            groups[key] = (d, v)
    return sorted(groups.values(), key=lambda x: x[0])


def _load_cash_flow_by_date(owner: str) -> dict[str, dict]:
    """portfolio_daily_snapshot에서 owner별 일자별 입출금 dict.

    {date: {'cash_in', 'cash_out'}} — 둘 다 0인 날은 제외. legacy 스냅샷(cash_in 키 없음)은 자연스럽게 0 처리.
    stock_portfolio_report가 매일 20:10 스냅샷 저장 시 누적. 적재 시작 이전 과거 데이터는 비어있음.
    """
    if not SNAPSHOT_FILE.exists():
        return {}
    try:
        snap = json.loads(SNAPSHOT_FILE.read_text())
    except Exception:
        return {}
    out: dict[str, dict] = {}
    for date_key, day_snap in snap.items():
        owner_snap = (day_snap or {}).get(owner) or {}
        ci = int(owner_snap.get('cash_in', 0) or 0)
        co = int(owner_snap.get('cash_out', 0) or 0)
        if ci or co:
            out[date_key] = {'cash_in': ci, 'cash_out': co}
    return out


def _aggregate_cash_flows_by_unit(daily_cf: dict[str, dict], aggregated_dates: list[str], unit: str) -> dict[str, dict]:
    """unit 단위 집계 시 cash flow 를 series 점에 매핑.

    aggregated_dates: 차트 series 의 날짜 리스트 (호출 시점 시리즈).
    'day' 또는 daily_cf 빈 dict면 그대로 반환.
    week/month/year:
      - 그룹에 series 점이 1개(=대표 영업일)면 그 그룹의 daily cash flow 들을 sum 해서 대표 점에 매핑.
      - 그룹에 series 점이 여러 개(=_expand_progress_group이 daily 로 펼친 진행 중 그룹)면
        각 daily 점에 daily_cf 그대로 매핑 — sum 매핑 시 모든 daily 점이 동일 cf 로 박히는 버그 방지.
    """
    if unit == 'day' or not daily_cf:
        return dict(daily_cf) if unit == 'day' else {d: dict(cf) for d, cf in daily_cf.items() if d in set(aggregated_dates)}
    from datetime import date as _date

    def _key(d: str):
        dt = _date.fromisoformat(d)
        if unit == 'week':
            iso = dt.isocalendar()
            return (iso[0], iso[1])
        if unit == 'month':
            return (dt.year, dt.month)
        if unit == 'year':
            return (dt.year,)
        return None

    # 그룹별 daily_cf sum (대표 매핑용)
    group_sum: dict = {}
    for d, cf in daily_cf.items():
        try:
            k = _key(d)
        except Exception:
            continue
        if k is None:
            continue
        s = group_sum.setdefault(k, {'cash_in': 0, 'cash_out': 0})
        s['cash_in'] += cf.get('cash_in', 0)
        s['cash_out'] += cf.get('cash_out', 0)

    # series 의 그룹별 점 개수 — 1점=대표, 2점 이상=daily 펼쳐짐.
    series_by_group: dict = {}
    for d in aggregated_dates:
        try:
            k = _key(d)
        except Exception:
            continue
        if k is None:
            continue
        series_by_group.setdefault(k, []).append(d)

    out: dict[str, dict] = {}
    for k, dates in series_by_group.items():
        if len(dates) == 1:
            s = group_sum.get(k)
            if s and (s['cash_in'] or s['cash_out']):
                out[dates[0]] = {'cash_in': s['cash_in'], 'cash_out': s['cash_out']}
        else:
            for d in dates:
                cf = daily_cf.get(d)
                if cf and (cf.get('cash_in', 0) or cf.get('cash_out', 0)):
                    out[d] = {'cash_in': cf.get('cash_in', 0), 'cash_out': cf.get('cash_out', 0)}
    return out


def _available_chart_periods() -> dict:
    """스냅샷에 데이터가 있는 연도·연월 목록 (연도별/월별 드롭다운 채움용). 최신이 앞.
    반환: {'years': ['2026','2025',...], 'months': {'2026': ['06','05',...], ...}}"""
    empty = {'years': [], 'months': {}}
    if not SNAPSHOT_FILE.exists():
        return empty
    try:
        snap = json.loads(SNAPSHOT_FILE.read_text())
    except Exception:
        return empty
    years: set = set()
    months: dict = {}
    for date_key in snap.keys():
        if len(date_key) < 7 or date_key[4] != '-':
            continue
        y, m = date_key[:4], date_key[5:7]
        years.add(y)
        months.setdefault(y, set()).add(m)
    # 현재 연·월은 스냅샷이 아직 없어도(월초 EOD 적재 전) 드롭다운에 노출 — 지정기간 선택 가능하게.
    _now = datetime.now(KST)
    cy, cm = _now.strftime('%Y'), _now.strftime('%m')
    years.add(cy)
    months.setdefault(cy, set()).add(cm)
    return {
        'years': sorted(years, reverse=True),
        'months': {y: sorted(ms, reverse=True) for y, ms in months.items()},
    }


def _load_net_worth_series(owner: str, days: int = NET_WORTH_CHART_DAYS, unit: str = 'day', with_prev_baseline: bool = False, period: str = ''):
    """portfolio_daily_snapshot에서 owner별 순자산 시계열을 추출.
    각 날짜의 net_worth = sum(holdings[*].eval_value) + deposit. 누락 필드는 0으로 처리.
    unit으로 일/주/월/연 단위 집계 (각 구간 마지막 영업일 점). 기간(days) 필터는 단위 집계 후 적용.

    with_prev_baseline=True 면 (series, prev_baseline) 튜플 반환. prev_baseline 은 PnL 모드용으로
    series 시작점 직전의 (date, nw) 점 — series[0] 손익도 표시되도록 _to_pnl_series 에 baseline 으로 넘긴다.
    series 가 이미 baseline 점을 prepend 한 케이스(기간 잘림 fallback, 진행중 그룹 fallback)에선 None.
    """
    if not SNAPSHOT_FILE.exists():
        return ([], None) if with_prev_baseline else []
    try:
        snap = json.loads(SNAPSHOT_FILE.read_text())
    except Exception as e:
        sys.stderr.write(f'snapshot load failed: {e}\n')
        return ([], None) if with_prev_baseline else []
    daily_series: list[tuple[str, int]] = []
    for date_key in sorted(snap.keys()):
        owner_snap = (snap.get(date_key) or {}).get(owner) or {}
        # v3/v4: {'holdings': {...}, 'deposit': int}, v1 legacy flat: {종목: {qty, ...}} (holdings/deposit 키 없음).
        if 'holdings' in owner_snap:
            holdings = owner_snap.get('holdings') or {}
            deposit = int(owner_snap.get('deposit', 0) or 0)
        else:
            holdings = {k: v for k, v in owner_snap.items() if isinstance(v, dict) and 'eval_value' in v}
            deposit = 0
        if not holdings and not deposit:
            continue
        eval_total = sum(int(v.get('eval_value', 0) or 0) for v in holdings.values())
        daily_series.append((date_key, eval_total + deposit))

    aggregated = _aggregate_by_unit(daily_series, unit)
    range_start: str | None = None  # 기간 필터의 시작 경계 — fallback도 이 안에서만
    # filtered 에 baseline 점이 prepend 되었으면 그게 series[0] = PnL baseline 역할 → prev_baseline 별도 안 넘김
    baseline_prepended = False

    if period:  # 절대 선택 — 'YYYY'(연도별·월별집계) 또는 'YYYY-MM'(월별·일별). caller가 unit을 맞춰 호출.
        range_start = (period + '-01-01') if len(period) == 4 else (period + '-01')
        filtered = [(d, v) for d, v in aggregated if d.startswith(period)]
    elif days == -2:  # 이번달 — KST 기준 이번 달 1일 이후만
        prefix = datetime.now(KST).strftime('%Y-%m')
        range_start = f'{prefix}-01'
        filtered = [(d, v) for d, v in aggregated if d.startswith(prefix)]
    elif days == -3:  # 이번주 — KST 기준 이번 ISO 월요일~오늘
        today = datetime.now(KST).date()
        monday = (today - timedelta(days=today.weekday())).isoformat()
        range_start = monday
        filtered = [(d, v) for d, v in aggregated if d >= monday]
    elif days == -4:  # 올해 — KST 기준 올해 1월 1일 이후
        prefix = datetime.now(KST).strftime('%Y')
        range_start = f'{prefix}-01-01'
        filtered = [(d, v) for d, v in aggregated if d.startswith(prefix)]
    elif days < 0:  # 전체 — 진행 그룹 daily fallback만 적용 (range 무한)
        filtered = list(aggregated)
    else:
        filtered = list(aggregated[-days:])

    # 첫날이 filter 시작점에 막 들어와 1점만 잡힌 케이스 — 직전 영업일 점을 baseline으로 prepend.
    # (이번주 월요일·이번달 1일·올해 1월 1일 등에 "데이터 부족" 안내 대신 비교선이 잡히도록.)
    # filtered 가 아예 빈 경우(이번주 시작이 휴장·월초 snapshot 적재 전)에도 boundary 기준 직전 점을
    # 1개만 앵커(기간 시작 기준값)로 들고 온다. 라이브 current_net 과 합쳐 2점 차트가 그려진다.
    # (2점 이상 당기면 6월 구간이 7월 차트에 그려져 오해 소지 — 앵커는 1점으로 제한.)
    if len(filtered) < 2:
        boundary = filtered[0][0] if filtered else (range_start or '9999-99-99')
        baseline = [s for s in aggregated if s[0] < boundary]
        if baseline:
            if filtered:
                filtered = [baseline[-1]] + filtered
            else:
                filtered = [baseline[-1]]
            baseline_prepended = True

    # unit이 주/월/연이고도 여전히 1점이면 진행 중 그룹의 daily 점들로 풀어줌.
    # (예: 올해+월별 → 5월만 1점 → 5/01~5/18 일별 점들 + 직전 daily 점 baseline)
    # range_start로 fallback 범위를 기간 필터 안으로 제한 — 이번주+월별이 5월 전체로 새지 않음.
    expanded_raw = _expand_progress_group(filtered, daily_series, unit, range_start=range_start)

    # day 단위 차트는 휴장일을 display 시리즈에서 제외 — 시장 무변동이라 점 표시가 무의미.
    # baseline 후보(aggregated)엔 그대로 남겨 휴장일의 net_worth 가 직전 baseline 으로 활용 가능 (예: 5/1 → 5/4 손익).
    holidays = _load_holidays() if unit == 'day' else set()
    expanded = [s for s in expanded_raw if s[0] not in holidays] if holidays else expanded_raw

    if not with_prev_baseline:
        return expanded

    # _expand_progress_group 가 baseline 1점 prepend 한 케이스 — expanded_raw[0] 가 filtered[0] 보다 앞이면 prepend 발생
    expand_prepended = bool(expanded_raw and filtered and expanded_raw[0][0] != filtered[0][0])
    if baseline_prepended or expand_prepended or not expanded:
        return expanded, None

    # 정상 case (기간 필터 내 2점 이상) — expanded[0] 직전의 aggregated 점이 PnL baseline
    first_dt = expanded[0][0]
    prev_candidates = [s for s in aggregated if s[0] < first_dt]
    if prev_candidates:
        return expanded, prev_candidates[-1]

    # 직전 그룹 데이터 없을 때 fallback:
    # - day 모드: first_dt holdings 의 buy_total + deposit (=owner 첫 적재일 누적평가손익 baseline)
    # - month/year/week 그룹 1개뿐: 그 그룹 안의 첫 영업일 daily 점 (그룹 안 시작→끝 변동 표시).
    #   ex) 5월만 있는데 본인 4월 empty → 5월 그룹 대표 1점 + 5/1 baseline 으로 한달 추세 그림.
    #   2점 이상일 땐 적용 X — series 첫 그룹 대표가 이미 baseline 역할.
    if unit == 'day':
        first_snap = (snap.get(first_dt) or {}).get(owner) or {}
        first_holdings = first_snap.get('holdings') or {}
        buy_total = sum(int(h.get('qty', 0) or 0) * int(h.get('avg_price', 0) or 0) for h in first_holdings.values())
        first_deposit = int(first_snap.get('deposit', 0) or 0)
        if buy_total > 0:
            return expanded, (first_dt, buy_total + first_deposit)
    elif len(expanded) == 1:
        from datetime import date as _date
        only_dt = _date.fromisoformat(first_dt)
        if unit == 'month':
            group_start = f'{only_dt.year:04d}-{only_dt.month:02d}-01'
        elif unit == 'week':
            group_start = (only_dt - timedelta(days=only_dt.weekday())).isoformat()
        elif unit == 'year':
            group_start = f'{only_dt.year:04d}-01-01'
        else:
            group_start = None
        if group_start:
            group_first = next((s for s in daily_series if group_start <= s[0] < first_dt), None)
            if group_first:
                return expanded, group_first
    return expanded, None


def _to_pnl_series(series: list[tuple[str, int]], cf_by_date: dict | None, prev_baseline_nw: int | None = None) -> tuple[list[tuple[str, int]], int, int]:
    """net_worth 시계열 → '시작 baseline=0' 누적손익 시계열로 변환.

    cumulative_pnl[i] = net_worth[i] - net_worth[start] - sum(net_cash_flow[after start..i]).
    첫 점은 baseline 이므로 cf 제외(이미 nw[start] 에 반영). cf_by_date 는 series 와 동일 unit 으로
    이미 집계된 {date: {cash_in, cash_out}}.

    prev_baseline_nw 가 주어지면 series 시작점 직전 영업일의 net_worth 를 baseline 으로 잡아 series[0]
    의 손익도 표시된다 (이번달/이번주 기간 필터로 첫날 손익이 baseline=0 으로 깔리는 문제 해결).
    cf_by_date 도 series[0] 의 cash flow 부터 누적에 포함된다.

    Returns: (pnl_series, baseline_nw, cumulative_cf_total) — current 점 변환에 baseline/cf 필요.
    """
    if not series:
        return [], 0, 0
    cf = cf_by_date or {}
    if prev_baseline_nw is not None:
        baseline_nw = int(prev_baseline_nw)
        cumulative_cf = 0
        out: list[tuple[str, int]] = []
        for d, nw in series:
            delta = cf.get(d) or {}
            cumulative_cf += int(delta.get('cash_in', 0) or 0) - int(delta.get('cash_out', 0) or 0)
            out.append((d, int(nw) - baseline_nw - cumulative_cf))
    else:
        baseline_nw = int(series[0][1])
        cumulative_cf = 0
        out = [(series[0][0], 0)]
        for d, nw in series[1:]:
            delta = cf.get(d) or {}
            cumulative_cf += int(delta.get('cash_in', 0) or 0) - int(delta.get('cash_out', 0) or 0)
            out.append((d, int(nw) - baseline_nw - cumulative_cf))
    return out, baseline_nw, cumulative_cf


def _expand_progress_group(filtered: list[tuple[str, int]], daily_series: list[tuple[str, int]], unit: str, *, range_start: str | None = None) -> list[tuple[str, int]]:
    """unit이 주이고 series가 1점뿐이면 그 진행 중 그룹의 daily 점들로 대체.
    range_start가 주어지면 group_start를 그것보다 뒤로 제한 (기간 필터와 교집합).
    그룹 직전 daily 점 1개를 baseline으로 prepend (있으면).
    month/year 단위는 daily 펼침 제외 — 단위 의도와 안 맞음. 그룹 1개뿐이면 prev_baseline fallback 으로
    그룹 첫 영업일을 baseline 으로 잡아 그룹 안 시작→끝 변동을 2점 차트로 표시."""
    if unit in ('day', 'month', 'year') or len(filtered) >= 2 or not filtered or not daily_series:
        return filtered
    from datetime import date as _date
    only_date = filtered[0][0]
    only_dt = _date.fromisoformat(only_date)
    if unit == 'week':
        group_start = (only_dt - timedelta(days=only_dt.weekday())).isoformat()
    else:
        return filtered
    if range_start and range_start > group_start:
        group_start = range_start
    in_group = [s for s in daily_series if group_start <= s[0] <= only_date]
    if len(in_group) < 2:
        return filtered
    before_group = [s for s in daily_series if s[0] < group_start]
    baseline = before_group[-1:] if before_group else []
    return baseline + in_group


def _render_net_worth_chart(series: list[tuple[str, int]], current_net: int | None = None, selected_days: int = NET_WORTH_CHART_DAYS, selected_unit: str = NET_WORTH_CHART_UNIT, cash_flow_by_date: dict | None = None, today_cash_flow: dict | None = None, mode: str = NET_WORTH_CHART_MODE, prev_baseline_point: tuple[str, int] | None = None, rt_owner: str | None = None, rt_base_net: int | None = None) -> str:
    """꺾은선 SVG (순자산 / 손익누적 공용). 외부 의존성 없음. 데이터 < 2점이면 안내문 반환.

    mode='net'(기본): series 는 raw 순자산 시계열. 입출금 마커·툴팁 행 표시.
    mode='pnl': series 는 _to_pnl_series 결과(첫 점=0 누적손익). 입출금 효과는 이미 제거,
                마커·툴팁 행 모두 숨김. 헤더 라벨도 '손익누적' 으로 분기.

    current_net 은 mode에 맞춰 caller가 변환해서 넘긴다(net=순자산, pnl=누적손익).
    selected_unit (day/week/month/year)에 따라 X축 라벨 형식과 tooltip 손익 라벨이 분기된다.

    prev_baseline_point=(date, nw) 가 주어지면 차트 series 앞에 baseline 점 prepend
    → 첫 영업일 변동이 0 기점에서 분기되는 모양으로 시각화. 그 baseline ↔ 첫 영업일 구간은 점선.
    """
    # pnl·period 모두 '입출금 제거 누적손익' 시계열을 입력으로 받는다(cf 마커·순자산 행 숨김 공통).
    # pnl=전부 누적 곡선, period=각 점이 그 기간(일/주/월/연) 자체 손익.
    is_pnl = mode in ('pnl', 'period')
    per_period = (mode == 'period')
    cash_flow_by_date = cash_flow_by_date or {}

    def _pp_key(ds: str):
        from datetime import date as _pp_date
        try:
            dt = _pp_date.fromisoformat(ds)
        except Exception:
            return None
        if selected_unit == 'week':
            iso = dt.isocalendar()
            return (iso[0], iso[1])
        if selected_unit == 'month':
            return (dt.year, dt.month)
        if selected_unit == 'year':
            return (dt.year,)
        return ds  # day — 각 날짜가 독립 기간
    # baseline 점 prepend — PnL 모드면 baseline 값 = 0 (series 자체가 baseline 대비 누적이라).
    # net 모드면 prev nw 그대로.
    prev_baseline_nw = prev_baseline_point[1] if prev_baseline_point else None  # day/total 계산용
    baseline_prepended_pt: tuple[str, int] | None = None
    if prev_baseline_point and series:
        prev_dt, prev_nw = prev_baseline_point
        prev_v_for_chart = 0 if is_pnl else int(prev_nw)
        baseline_prepended_pt = (prev_dt, prev_v_for_chart)
    # 기간·단위·모드 토글은 자산정보 탭 상단의 공통 select 컨트롤로 이동 (_render_chart_controls).
    # 차트 wrapper엔 data-chart-days/unit/mode 박아 swap 후 상태 인식 가능.
    if selected_unit == 'month':
        empty_msg = '월별 차트는 2개월 이상의 데이터가 필요합니다.'
    elif selected_unit == 'week':
        empty_msg = '주별 차트는 2주 이상의 데이터가 필요합니다.'
    elif selected_unit == 'year':
        empty_msg = '연별 차트는 2년 이상의 데이터가 필요합니다.'
    else:
        _empty_metric = '기간손익' if per_period else ('손익누적' if is_pnl else '총자산')
        empty_msg = f'최근 {_empty_metric} 추이 데이터가 부족합니다.'

    def _empty(msg: str) -> str:
        return (
            f'<div class="net-chart" data-chart-days="{selected_days}" data-chart-unit="{html.escape(selected_unit)}" data-chart-mode="{html.escape(mode)}">'
            f'<div class="chart-empty">{msg}</div>'
            '</div>'
        )

    if len(series) < 2 and current_net is None:
        return _empty(empty_msg)

    # stock.briefing 정규 적재 시각(20:10 KST) 이전이면 오늘 snapshot 행은 장중 추정치.
    # series 끝 행을 제거하고 live 점으로 교체 → 마지막 구간 점선 분기 보존.
    # 마감 후엔 confirmed로 간주, snapshot 오늘 행 유지 → 일반 실선.
    now = datetime.now(KST)
    today_kst = now.strftime('%Y-%m-%d')
    before_close = (now.hour, now.minute) < (20, 10)
    series = list(series)  # 외부 인자 mutate 방지 + 로컬 컷
    if before_close and series and series[-1][0] == today_kst:
        series = series[:-1]
    has_live = (
        current_net is not None
        and (not series or series[-1][0] != today_kst)
    )
    # 휴장일/주말은 시장 변동이 없어야 자연 — NXT 가격 보정으로 인한 가짜 점프(=오늘 라이브 점이 어제 스냅샷과 차이) 차단.
    if has_live and _market_phase_state().get('phase') in ('holiday', 'weekend'):
        has_live = False
    # per_period + 라이브 점이 있으면, 라이브와 같은 기간의 확정 점들은 중복이라 제거 (라이브가 그 기간 대표).
    # 예: 연별인데 2026 연 대표점(5/29) + 라이브(6/01) 둘 다 2026 → 5/29 버려 baseline→현재 2점으로 그림.
    if per_period and has_live and series:
        _live_key = _pp_key(today_kst)
        while series and _pp_key(series[-1][0]) == _live_key:
            series = series[:-1]
    # baseline 점이 있으면 series 앞에 prepend (시각화용). day/total tooltip 도 자연스레 baseline 기준이 됨.
    has_baseline = baseline_prepended_pt is not None
    full_series_with_baseline = ([baseline_prepended_pt] if has_baseline else []) + series
    full = full_series_with_baseline + ([(today_kst, int(current_net))] if has_live else [])
    if len(full) < 2:
        return _empty(empty_msg)

    values = [v for _, v in full]
    # per_period 라인 = '기간별 손익'(그 기간 자체 손익). values(누적)는 tooltip '누적 손익'·헤더 합계용 유지.
    # 각 점 = 그 점 누적값 − 직전 '다른 기간' 점의 누적값 (없으면 baseline 0).
    # 직전 '다른 기간' 기준이라, 같은 기간 점이 여러 개여도(올해+월별 진행 중 등) 그 기간 전체 손익이 잡힌다.
    # 단일 기간(예: 데이터가 2026 한 해뿐)이면 위에서 라이브 중복점을 정리해 baseline(0)→현재 2점으로 그린다.
    if per_period:
        _pp_keys = [_pp_key(d) for d, _ in full]
        plot_values = []
        for _i in range(len(values)):
            _base = 0
            for _j in range(_i - 1, -1, -1):
                if _pp_keys[_j] != _pp_keys[_i]:
                    _base = values[_j]
                    break
            plot_values.append(int(values[_i]) - _base)
    else:
        plot_values = values
    vmin, vmax = min(plot_values), max(plot_values)
    vrange = max(vmax - vmin, 1)
    pad = vrange * 0.10
    y_lo = vmin - pad
    y_hi = vmax + pad
    span = max(y_hi - y_lo, 1)

    # viewBox 800x280, padding: 좌72 우16 상14 하34 (날짜라벨 + 상단 cf 라벨 영역).
    # 좌여백은 모바일 axis 폰트(22px)에서 '−350K'·'1.5M' 류 라벨이 안 잘리게 넓게 잡음.
    W, H = 800, 360
    L, R, T, B = 72, 16, 14, 34
    iw = W - L - R
    ih = H - T - B
    n = len(full)

    def x_at(i: int) -> float:
        return L + (i / (n - 1)) * iw if n > 1 else L + iw / 2

    def y_at(v: int) -> float:
        return T + (1 - (v - y_lo) / span) * ih

    pts = [(x_at(i), y_at(v)) for i, v in enumerate(plot_values)]
    # 3-way path 분리:
    # - baseline_dashed_pts: baseline → 첫 영업일 (있는 경우)
    # - solid_pts: 첫 영업일 → 마지막 확정 snapshot
    # - dashed_pts: 마지막 snapshot → live 점 (있는 경우)
    base_offset = 1 if has_baseline else 0
    solid_end = len(full) - (1 if has_live else 0)
    baseline_dashed_pts = pts[:base_offset + 1] if has_baseline else []
    solid_pts = pts[base_offset:solid_end]
    dashed_pts = pts[solid_end - 1:] if has_live else []

    # per_period면 헤더·라인색을 '마지막 기간 손익' 기준으로 (그 외 pnl/net은 시작 대비 변동).
    delta = plot_values[-1] if per_period else values[-1] - values[0]
    up = delta >= 0
    line_color = '#ff4d5e' if up else '#4a8cf0'
    fill_id = f'nw-grad-{"up" if up else "dn"}'
    delta_sign = '+' if delta >= 0 else '−'
    if is_pnl:
        # per_period=마지막 기간 손익, 아니면 마지막 누적 손익. (pnl 첫 점=0 baseline → 비율 무의미)
        _hdr_val = plot_values[-1] if per_period else values[-1]
        delta_label = f'{delta_sign}{abs(_hdr_val):,}원'
    else:
        delta_pct = (delta / values[0] * 100) if values[0] else 0.0
        delta_label = f'{delta_sign}{abs(delta):,}원 ({delta_sign}{abs(delta_pct):.2f}%)'
    delta_class = 'up' if up else 'down'

    # 데이터 범위(vmin~vmax)를 3구간으로 균등 분할 → 가로선 4개 (vmax / +2/3 / +1/3 / vmin).
    grid_vals = [vmax - vrange * i / 3 for i in range(4)]
    # 눈금 간격(step)이 단위보다 작으면 floor 나눗셈이 라벨을 뭉개므로(예: 1M 1M 1M),
    # step 크기에 맞춰 소수 자릿수를 정해 인접 눈금이 항상 구별되게 한다.
    step = vrange / 3
    dec_m = 0 if step >= 1_000_000 else (1 if step >= 100_000 else 2)
    dec_k = 0 if step >= 1000 else 1
    grid_lines = []
    for gv in grid_vals:
        gy = y_at(int(gv))
        grid_lines.append(f'<line class="grid" x1="{L}" x2="{L + iw}" y1="{gy:.2f}" y2="{gy:.2f}" />')
        label = f'{gv / 1_000_000:,.{dec_m}f}M' if abs(gv) >= 1_000_000 else f'{gv / 1000:,.{dec_k}f}K'
        grid_lines.append(f'<text class="axis" x="{L - 6}" y="{gy + 3.5:.2f}" text-anchor="end">{html.escape(label)}</text>')

    # 단위 경계 세로선 — 일별=주 경계(ISO 주가 바뀌는 첫 점), 월별=연 경계(연도가 바뀌는 첫 점).
    # 그 외 단위는 미표시. per_period 모드는 period-sep(점마다 구분선)이 이미 있어 중복이라 생략.
    boundary_lines: list[str] = []
    if not per_period and selected_unit in ('day', 'month'):
        from datetime import date as _bd_date

        def _bkey(ds: str):
            if selected_unit == 'day':
                try:
                    iso = _bd_date.fromisoformat(ds).isocalendar()
                    return (iso[0], iso[1])  # (ISO연, 주차)
                except Exception:
                    return None
            return ds[:4]  # month → 연도
        _prev_bk = _bkey(full[0][0])
        for _bi in range(1, n):
            _bk = _bkey(full[_bi][0])
            if _bk is not None and _prev_bk is not None and _bk != _prev_bk:
                _bx = x_at(_bi)
                boundary_lines.append(
                    f'<line class="grid-v" x1="{_bx:.2f}" x2="{_bx:.2f}" y1="{T:.2f}" y2="{T + ih:.2f}" />'
                )
            if _bk is not None:
                _prev_bk = _bk

    # 0원 기준선 — 손익 0 높이가 차트 범위 안일 때만 (어디부터 적자/흑자인지 표시). 순자산은 항상 양수라 자동 미표시.
    # 기준선(가로선)은 유지하고 '0' 텍스트 라벨만 제거 (관리자님 요청 — M/K 라벨과 겹쳐 보기 번잡).
    zero_line = ''
    if y_lo <= 0 <= y_hi:
        zy = y_at(0)
        zero_line = (
            f'<line class="zero-line" x1="{L}" x2="{L + iw}" y1="{zy:.2f}" y2="{zy:.2f}" />'
        )

    label_idx = sorted({0, n // 2, n - 1})
    # 점들이 모두 같은 그룹(month/year)이면 라벨 중복이라 한 단계 작은 단위로 fallback.
    # 라벨 형식은 unit 그대로 — year=YYYY, month=YYYY-MM, week/day=MM-DD.
    # 한 단위 안에 모든 점이 들어가는 케이스(이번달·올해 진행 중)도 unit 라벨 유지 → 중복 라벨 보일 수 있으나 사용자 의도(단위 명확 표시) 우선.
    def _x_label_for(date_iso: str) -> str:
        if selected_unit == 'year':
            return date_iso[:4]
        if selected_unit == 'month':
            return date_iso[:7]
        return date_iso[5:]
    x_labels = []
    for i in label_idx:
        date_str = _x_label_for(full[i][0])
        if has_live and i == n - 1:
            date_str += '*'  # 장중 잠정 표시
        x_labels.append(
            f'<text class="axis" x="{x_at(i):.2f}" y="{H - 8:.2f}" text-anchor="middle">{html.escape(date_str)}</text>'
        )

    last_x, last_y = pts[-1]
    # per_period면 마지막 점 = 마지막 기간 손익(=마지막 dot 높이와 일치). 아니면 누적 마지막값.
    last_v = plot_values[-1] if per_period else values[-1]
    if is_pnl:
        last_sign = '+' if last_v >= 0 else '−'
        last_label_v = f'{last_sign}{abs(last_v):,}원'
    else:
        last_label_v = f'{last_v:,}원'
    if has_live:
        last_label_v += ' (장중)'

    range_label = f'{_x_label_for(full[0][0])} ~ {_x_label_for(full[-1][0])}'
    # per_period는 baseline 제외 실제 표시 기간 수(라이브 포함). 라이브 중복점 제거로 series가 비어도 정확.
    if per_period:
        title_n = len({k for k in _pp_keys[base_offset:] if k is not None})
    else:
        title_n = len(series)
    _unit_word = {'week': '주', 'month': '월', 'year': '연'}.get(selected_unit, '영업일')
    _title_metric = '손익' if per_period else ('손익누적' if is_pnl else '총자산')
    title = f'최근 {title_n}{_unit_word} {_title_metric}' + (' · 오늘 잠정' if has_live else '')

    # 영역 채우기는 종가 구간만 (실선 영역). 점선 구간은 area 미적용.
    area_d = ''
    if len(solid_pts) >= 2:
        area_d = (
            f'M {solid_pts[0][0]:.2f},{T + ih:.2f} '
            + 'L ' + ' L '.join(f'{x:.2f},{y:.2f}' for x, y in solid_pts)
            + f' L {solid_pts[-1][0]:.2f},{T + ih:.2f} Z'
        )

    solid_d = ''
    if len(solid_pts) >= 2:
        solid_d = 'M ' + ' L '.join(f'{x:.2f},{y:.2f}' for x, y in solid_pts)
    dashed_d = ''
    if len(dashed_pts) >= 2:
        dashed_d = 'M ' + ' L '.join(f'{x:.2f},{y:.2f}' for x, y in dashed_pts)
    baseline_dashed_d = ''
    if len(baseline_dashed_pts) >= 2:
        baseline_dashed_d = 'M ' + ' L '.join(f'{x:.2f},{y:.2f}' for x, y in baseline_dashed_pts)

    last_dot_class = 'last-dot live' if has_live else 'last-dot'

    # 호버/터치 인터랙션용 메타. 각 포인트의 viewBox 좌표·날짜·순자산·그날 손익·누적 손익·입출금.
    base_v = values[0]
    prev_v: int | None = None
    cum_cf_net = 0  # baseline 이후 누적 외부 입출금 (net 모드 누적손익 보정용)
    pts_meta: list[dict] = []
    today_kst_str = today_kst  # 위에서 산출됨
    for i, (d_str, v_i) in enumerate(full):
        x_i, y_i = pts[i]
        cf = cash_flow_by_date.get(d_str)
        # 오늘 라이브 포인트는 별도 today_cash_flow 우선 — snapshot 적재 전이라도 KPI와 일치하게.
        if d_str == today_kst_str and today_cash_flow and (today_cash_flow.get('cash_in', 0) or today_cash_flow.get('cash_out', 0)):
            cf = {'cash_in': int(today_cash_flow.get('cash_in', 0) or 0),
                  'cash_out': int(today_cash_flow.get('cash_out', 0) or 0)}
        # PnL 모드: v 자체가 이미 baseline 대비 누적손익 → total=v_i (재차감 X).
        # day 는 직전 점 대비 변동분이고, 첫 점은 baseline 대비라 v_i 자체가 첫날 손익 의미 (0 아님).
        # net 모드: v 가 raw 순자산. prev_baseline_nw 가 있으면 첫 점 day/total 도 그 대비 (휴장일 baseline 등).
        if is_pnl:
            # per_period면 '그 기간 손익' 행을 plot_values 와 일치시킨다(라이브점=이번 달 전체 손익).
            # 아니면(day) 직전 점 대비 변동분. total 은 항상 누적값.
            day_val = int(plot_values[i]) if per_period else (int(v_i - prev_v) if prev_v is not None else int(v_i))
            total_val = int(v_i)
        else:
            # 외부 입출금은 손익이 아니므로 day/total 에서 제거 (KPI '당일 평가손익'·pnl 모드와 동일 정책).
            # full[0]은 항상 baseline 점이라 그 점의 입출금은 이미 자기 순자산 값에 녹아 있다 → 누적은 i>0부터.
            cf_net_i = (int(cf.get('cash_in', 0) or 0) - int(cf.get('cash_out', 0) or 0)) if cf else 0
            if i > 0:
                cum_cf_net += cf_net_i
            if prev_v is not None:
                day_val = int(v_i - prev_v - cf_net_i)
            elif prev_baseline_nw is not None:
                day_val = int(v_i - prev_baseline_nw)
            else:
                day_val = 0
            base_nw = prev_baseline_nw if prev_baseline_nw is not None else base_v
            total_val = int(v_i - base_nw - cum_cf_net)
        meta = {
            'd': d_str,
            'v': int(v_i),
            'day': day_val,
            'total': total_val,
            'x': round(x_i, 2),
            'y': round(y_i, 2),
        }
        if cf and (cf.get('cash_in', 0) or cf.get('cash_out', 0)):
            ci_i = int(cf.get('cash_in', 0) or 0)
            co_i = int(cf.get('cash_out', 0) or 0)
            meta['cf_in'] = ci_i
            meta['cf_out'] = co_i
            meta['cf_net'] = ci_i - co_i
        pts_meta.append(meta)
        prev_v = v_i
    pts_attr = html.escape(json.dumps(pts_meta, ensure_ascii=False), quote=True)

    # 입출금 수직선 — 해당 포인트 x좌표에 차트 영역을 가로지르는 dashed 선.
    # 색상: net > 0 입금 우세 = #0a7 / net < 0 출금 우세 = #d33 / 0 (= 입+출 동일) = #888.
    # 작은 캡션 라벨(±N) 을 차트 상단에 살짝 띄움 — 텍스트로 명확히 표시.
    cf_markers: list[str] = []
    for meta in pts_meta:
        if 'cf_net' not in meta:
            continue
        cf_x = meta['x']
        cf_net = meta['cf_net']
        cf_color = '#0a7' if cf_net > 0 else ('#d33' if cf_net < 0 else '#888')
        cf_sign = '+' if cf_net >= 0 else '−'
        cf_abs = abs(cf_net)
        cf_label = f'{cf_sign}{cf_abs // 1_000_000}M' if cf_abs >= 1_000_000 else (
            f'{cf_sign}{cf_abs // 1000}K' if cf_abs >= 1000 else f'{cf_sign}{cf_abs}'
        )
        cf_markers.append(
            f'<line class="cf-line" x1="{cf_x:.2f}" x2="{cf_x:.2f}" y1="{T:.2f}" y2="{T + ih:.2f}" '
            f'stroke="{cf_color}" stroke-width="1" stroke-dasharray="3 3" opacity="0.55"/>'
        )
        cf_markers.append(
            f'<text class="cf-label" x="{cf_x:.2f}" y="{T - 2:.2f}" text-anchor="middle" '
            f'fill="{cf_color}" font-size="14" font-weight="600">{html.escape(cf_label)}</text>'
        )

    # per_period: 각 기간 점마다 세로 구분선 + 작은 마커 — 기간별 손익이 비슷해 라인이 평평해도 기간 경계를 읽을 수 있게.
    # (누적/순자산은 연속 곡선이라 구분선 불필요 — period 모드만)
    period_seps = []
    point_dots = []
    for _i, (px, py) in enumerate(pts):
        if per_period:
            # 구분선은 기간손익 전용. 점 색 = 그 기간 손익 부호 (이익=빨강 / 손실=파랑 / 0=회색).
            period_seps.append(
                f'<line class="period-sep" x1="{px:.2f}" x2="{px:.2f}" y1="{T:.2f}" y2="{T + ih:.2f}" />'
            )
            _pv = plot_values[_i]
            _dot_color = '#ff4d5e' if _pv > 0 else ('#4a8cf0' if _pv < 0 else '#6b7280')
        else:
            # 순자산·손익누적은 연속 곡선 — 점만 라인색으로 (부호색은 순자산이 항상 양수라 무의미).
            _dot_color = line_color
        point_dots.append(f'<circle class="pt-dot" cx="{px:.2f}" cy="{py:.2f}" r="2.6" fill="{_dot_color}"/>')

    svg_parts = [
        f'<svg viewBox="0 0 {W} {H}" preserveAspectRatio="xMidYMid meet" class="net-chart-svg" role="img" '
        f'aria-label="총자산 추이 {html.escape(range_label)}">',
        '<defs>'
        f'<linearGradient id="{fill_id}" x1="0" x2="0" y1="0" y2="1">'
        f'<stop offset="0%" stop-color="{line_color}" stop-opacity="0.32"/>'
        f'<stop offset="100%" stop-color="{line_color}" stop-opacity="0"/>'
        '</linearGradient>'
        '</defs>',
        ''.join(grid_lines),
        ''.join(boundary_lines),
        zero_line,
        ''.join(period_seps),
    ]
    if area_d:
        svg_parts.append(f'<path class="area" d="{area_d}" fill="url(#{fill_id})"/>')
    # 입출금 수직선은 line/area 위, dot 아래 — 추세선 위에 살짝 비치고 사용자 데이터 점은 가리지 않음.
    if cf_markers:
        svg_parts.append(''.join(cf_markers))
    if baseline_dashed_d:
        svg_parts.append(f'<path class="line dashed" d="{baseline_dashed_d}" stroke="{line_color}" />')
    if solid_d:
        svg_parts.append(f'<path class="line" d="{solid_d}" stroke="{line_color}" />')
    if dashed_d:
        svg_parts.append(f'<path class="line dashed live-seg" d="{dashed_d}" stroke="{line_color}" />')
    if point_dots:
        svg_parts.append(''.join(point_dots))
    if has_live:
        svg_parts.append(
            f'<circle class="live-halo" cx="{last_x:.2f}" cy="{last_y:.2f}" r="6" fill="{line_color}" fill-opacity="0.18"/>'
        )
    svg_parts.append(
        f'<circle class="{last_dot_class}" cx="{last_x:.2f}" cy="{last_y:.2f}" r="3.6" fill="{line_color}"/>'
    )
    # 고/저점 dot — 장중가(마지막 점)가 극값일 때도 표시. last_dot 위에 색 점이 덮인다.
    peak_i = max(range(len(plot_values)), key=lambda i: plot_values[i])
    low_i = min(range(len(plot_values)), key=lambda i: plot_values[i])
    _is_ath = n >= 2 and plot_values[-1] > max(plot_values[:-1])
    # 고점 — 단, 마지막 점이 신고가(ATH)면 신고가 링/라벨이 대신하므로 '고' 생략.
    if not (peak_i == n - 1 and _is_ath):
        peak_x, peak_y = pts[peak_i]
        svg_parts.append(
            f'<circle class="peak-dot" cx="{peak_x:.2f}" cy="{peak_y:.2f}" r="3.6" fill="#fbbf24"/>'
        )
        svg_parts.append(
            f'<text class="hl-label" x="{peak_x:.2f}" y="{max(peak_y - 10, T + 12):.2f}" text-anchor="middle" fill="#fbbf24" font-size="20" font-weight="700">고</text>'
        )
    # 저점 — 고점과 다른 점일 때 (장중가가 저점이어도 표시).
    if low_i != peak_i:
        low_x, low_y = pts[low_i]
        svg_parts.append(
            f'<circle class="low-dot" cx="{low_x:.2f}" cy="{low_y:.2f}" r="3.6" fill="#4a8cf0"/>'
        )
        svg_parts.append(
            f'<text class="hl-label" x="{low_x:.2f}" y="{min(low_y + 22, T + ih - 4):.2f}" text-anchor="middle" fill="#4a8cf0" font-size="20" font-weight="700">저</text>'
        )
    # 신고가 마커 — 오늘(마지막) 그려진 값이 이전 모든 포인트를 strict 하게 초과하면 last dot 위에 금색 ring + 라벨.
    # per_period면 '역대 최고 기간 손익', 아니면 누적/순자산 신고가.
    if _is_ath:
        svg_parts.append(
            f'<circle class="ath-ring" cx="{last_x:.2f}" cy="{last_y:.2f}" r="7" fill="none" '
            f'stroke="#fbbf24" stroke-width="1.6"/>'
        )
        _ath_label_y = max(last_y - 14, T + 12)
        svg_parts.append(
            f'<text class="ath-label" x="{last_x:.2f}" y="{_ath_label_y:.2f}" text-anchor="middle" '
            f'fill="#fbbf24" font-size="14" font-weight="700">신고가</text>'
        )
    svg_parts.append(''.join(x_labels))
    svg_parts.append(
        '<g class="nw-hover" opacity="0" pointer-events="none">'
        f'<line class="nw-hover-line" x1="0" x2="0" y1="{T}" y2="{T + ih}" />'
        '<circle class="nw-hover-dot" cx="0" cy="0" r="3.6" />'
        '</g>'
    )
    svg_parts.append('</svg>')

    _day_label = _NET_WORTH_DELTA_LABEL.get(selected_unit, '그날 손익')
    # pnl 모드는 '순자산'·'입출금' 행 의미 없음 (입출금 효과 이미 제거). 서버에서 hidden 처리.
    _net_row_hidden = ' hidden' if is_pnl else ''
    _cf_row_hidden = ' hidden' if is_pnl else ' hidden'  # cf-row 는 net 모드라도 cf_net 있을 때만 JS가 노출
    tip_html = (
        '<div class="net-chart-tip" aria-hidden="true">'
        '<button type="button" class="tip-close" data-nw-step="clear" aria-label="닫기">✕</button>'
        '<div class="tip-date"></div>'
        f'<div class="tip-row"><span class="muted">{html.escape(_day_label)}</span><b class="tip-day">—</b></div>'
        '<div class="tip-row"><span class="muted">누적 손익</span><b class="tip-total">—</b></div>'
        f'<div class="tip-row tip-net-row"{_net_row_hidden}><span class="muted">총자산</span><b class="tip-net">—</b></div>'
        # 입출금 행 — 클라이언트 JS가 cf_net 있을 때만 display 토글 (pnl 모드에선 영구 hidden).
        f'<div class="tip-row tip-cf-row"{_cf_row_hidden}><span class="muted">입출금</span><b class="tip-cf">—</b></div>'
        '</div>'
    )

    head_html = (
        '<div class="net-chart-head">'
        f'<span class="net-chart-title">{html.escape(title)}</span>'
        f'<span class="net-chart-delta {delta_class}">{html.escape(delta_label)}</span>'
        '</div>'
    )

    foot_html = (
        '<div class="net-chart-foot">'
        f'<span class="muted">{html.escape(range_label)}</span>'
        '<span class="net-chart-foot-r">'
        '<button type="button" class="nw-nav" data-nw-step="prev" aria-label="이전 날짜">◀</button>'
        '<button type="button" class="nw-nav" data-nw-step="next" aria-label="다음 날짜">▶</button>'
        f'<span class="net-chart-last"'
        + (f' data-rt-nw-owner="{html.escape(rt_owner, quote=True)}" data-rt-nw-basenet="{rt_base_net}" data-rt-nw-baseval="{last_v}" data-rt-nw-pnl="{1 if is_pnl else 0}" data-rt-nw-live="{1 if has_live else 0}"'
              f' data-rt-nw-ylo="{y_lo}" data-rt-nw-span="{span}" data-rt-nw-top="{T}" data-rt-nw-ih="{ih}" data-rt-nw-x="{last_x:.2f}"'
              # 헤더 값도 라벨과 같은 델타로 움직인다 — hdr=렌더 시점 헤더 수치, hdr0=net 모드 % 분모(pnl은 0=% 없음).
              f' data-rt-nw-hdr="{_hdr_val if is_pnl else delta}" data-rt-nw-hdr0="{0 if is_pnl else values[0]}"'
           if (rt_owner is not None and rt_base_net is not None and has_live) else '')
        + f'>{html.escape(last_label_v)}</span>'
        '</span>'
        '</div>'
    )
    return (
        f'<div class="net-chart" data-pts="{pts_attr}" data-chart-days="{selected_days}" data-chart-unit="{html.escape(selected_unit)}" data-chart-mode="{html.escape(mode)}">'
        + head_html
        + ''.join(svg_parts)
        + tip_html
        + foot_html
        + '</div>'
    )


def _render_owner_panel(owner: str, d: dict, balances: dict, owner_label_text: str, show_day_change: bool = False, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT) -> str:
    # 순자산 차트는 자산정보 탭(_render_summary_panel)로 통합 이동. owner 탭엔 KPI + 보유종목 흐름만.
    # chart_days/chart_unit 인자는 시그니처 호환 위해 보존 (호출처 영향 최소).
    # balances도 같은 이유로 보존 — 계좌별 예수금이 KPI 예수금 [!] 팝업으로 옮겨가며 여기선 미사용.
    parts = [_render_owner_kpi(owner, d, owner_label_text)]

    held = [r for r in d['consolidated'] if not r.get('phantom')]
    traded_held = [r for r in held if r.get('traded_today')]
    phantoms = [r for r in d['traded_today'] if r.get('phantom')]
    if traded_held or phantoms:
        parts.append(
            f'<div class="section-label">당일 매매<span class="count">{len(traded_held) + len(phantoms)}</span></div>'
        )
        for r in sorted(phantoms, key=lambda x: -((x.get('journal') or {}).get('pl_amt', 0))):
            parts.append(_render_phantom_row(r))
        for r in sorted(traded_held, key=lambda x: -x.get('eval_value', 0)):
            parts.append(_render_holding_row(r, d['total_value'], show_day_change))

    pending_unheld = d.get('pending_unheld') or []
    pending_buy_held = [r for r in (d.get('consolidated') or [])
                        if not r.get('phantom') and r.get('pending_buy_qty', 0) > 0]
    buy_total = len(pending_unheld) + len(pending_buy_held)
    if buy_total:
        parts.append(
            f'<div class="section-label">매수등록<span class="count">{buy_total}</span></div>'
        )
        # 보유 종목의 매수등록 카드도 보유종목 카드를 그대로 쓴다 (2026-07-30 관리자님 지시,
        # 매도등록과 동일한 이유·방식). pending_buy_held 원소가 consolidated 행 객체 자체다.
        # ⚠️ pending_unheld(미보유 + 매수 미체결)는 보유 수량·평단·손익이 없어 종목카드로
        #    대체할 수 없다 — 전용 카드 유지.
        for r in sorted(pending_buy_held, key=lambda x: -x.get('pending_buy_qty', 0)):
            parts.append(_render_holding_row(r, d['total_value'], show_day_change,
                                              key_suffix=':pending-buy'))
        for r in pending_unheld:
            parts.append(_render_pending_unheld_row(r))

    pending_sell_held = [r for r in (d.get('consolidated') or [])
                         if not r.get('phantom') and r.get('pending_sell_qty', 0) > 0]
    if pending_sell_held:
        parts.append(
            f'<div class="section-label">매도등록<span class="count">{len(pending_sell_held)}</span></div>'
        )
        # 매도등록 카드는 보유종목 카드를 그대로 쓴다 (2026-07-30 관리자님 지시).
        # pending_sell_held 원소가 consolidated(보유종목) 행 객체 자체라 필요한 필드가 다 있고,
        # 전용 카드는 summary 값을 detail 에서 되풀이하면서 손익·버튼·태그가 없어 축소판이었다.
        # 같은 종목이 자산정보 탭 보유종목과 겹치므로 key_suffix 로 row_key 를 분리한다
        # (바로 아래 ':held' 와 같은 방식 — mutex/open 복원 충돌 방지).
        for r in sorted(pending_sell_held, key=lambda x: -x.get('pending_sell_qty', 0)):
            parts.append(_render_holding_row(r, d['total_value'], show_day_change,
                                              key_suffix=':pending-sell'))

    # 당일매매 발생한 종목도 보유 종목 섹션에 그대로 표시 — 양쪽 다 보이게 함.
    # 같은 종목이 양쪽에 들어가니 row_key에 ':held' suffix를 붙여 mutex/open 복원 충돌 방지.
    # 합산 / 각계좌별도 토글:
    #  - 합산: consolidated 행 (현재 동작 유지)
    #  - 각계좌별도: raw rows를 평가금액 desc로 평탄 정렬, account 라벨은 각 행에 표시
    # 토글은 자산정보 탭과 in-memory state 공유 → 한쪽 토글하면 다른 쪽도 같이 바뀜.
    raw_rows = d.get('rows') or []
    # ohlc 같은 owner-level annotation을 raw row에 코드 단위로 전파.
    ohlc_by_code = {
        c.get('code'): c.get('ohlc') for c in d.get('consolidated', [])
        if c.get('code') and c.get('ohlc')
    }
    if held or raw_rows:
        consolidated_rows_html: list[str] = []
        for r in sorted(held, key=lambda x: -x.get('eval_value', 0)):
            consolidated_rows_html.append(
                _render_holding_row(r, d['total_value'], show_day_change, key_suffix=':held')
            )

        by_account_rows_html: list[str] = []
        for r in sorted(raw_rows, key=lambda x: -x.get('eval_value', 0)):
            if r.get('qty', 0) <= 0:
                continue
            synth = dict(r)
            synth['accounts'] = [r.get('account', '')]
            synth['traded_today'] = (r.get('tdy_buyq', 0) + r.get('tdy_sellq', 0)) > 0
            if r.get('code') in ohlc_by_code:
                synth['ohlc'] = ohlc_by_code[r['code']]
            acc_suffix = (r.get('account', '') or '').replace(':', '_')
            by_account_rows_html.append(
                _render_holding_row(synth, d['total_value'], show_day_change, key_suffix=f':held:{acc_suffix}')
            )

        held_count = len(held)
        acc_count = sum(1 for r in raw_rows if r.get('qty', 0) > 0)
        # 토글 버튼은 owner KPI 카드 상단 (전체 거래내역 옆)으로 이동 — panes만 여기에 prerender.
        parts.append(
            '<div class="holdings-toggle-wrap">'
            '<div class="account-view-pane" data-account-view-pane="consolidated">'
            f'<div class="section-label">보유 종목<span class="count">{held_count}</span></div>'
            + ('\n'.join(consolidated_rows_html) if consolidated_rows_html else '<div class="empty">보유 종목 데이터가 없습니다.</div>')
            + '</div>'
            '<div class="account-view-pane" data-account-view-pane="by-account" hidden>'
            f'<div class="section-label">보유 종목<span class="count">{acc_count}</span></div>'
            + ('\n'.join(by_account_rows_html) if by_account_rows_html else '<div class="empty">보유 종목 데이터가 없습니다.</div>')
            + '</div>'
            '</div>'
        )

    if not held and not phantoms:
        parts.append('<div class="empty">보유 종목 데이터가 없습니다.</div>')

    # rt-scope: 이 owner의 KPI 카드 + 보유행을 한 컨테이너로 묶음. 실시간 JS가 consolidated pane 행을
    # 합산해 같은 scope 안의 data-rt-kpi(총평가금액·총평가손익·순자산·당일평가손익) td를 갱신.
    # deposit은 순자산 계산용.
    # 당일평가손익 = total_net − prev_net − net_cash_flow 에서 실시간 변동분은 평가액(total_value)뿐 →
    #   당일평가손익 = 실시간평가액 + C,  C = day_pl_total − total_value (세션 중 상수).
    #   prev_net은 % 계산용. day_pl_total None(전날 스냅 없음·휴장)이면 속성 미부착 → JS가 스킵.
    deposit = d.get('deposit', 0) or 0
    _daypl = d.get('day_pl_total')
    _attrs = f'data-rt-owner="{html.escape(owner, quote=True)}" data-rt-deposit="{deposit}"'
    if _daypl is not None:
        _attrs += f' data-rt-daypl-c="{_daypl - d.get("total_value", 0)}" data-rt-prevnet="{d.get("prev_net", 0) or 0}"'
    return f'<div class="rt-scope" {_attrs}>' + '\n'.join(parts) + '</div>'


_CSS = '''
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body { margin: 0; padding: 0; overscroll-behavior-y: none; touch-action: manipulation; }
body { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Apple SD Gothic Neo", sans-serif; background: #0b0d12; color: #e6e6e6; -webkit-font-smoothing: antialiased; padding-bottom: calc(env(safe-area-inset-bottom) + 44px); -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }

/* 하단 고정 시장 지수. viewport 최하단에 박힘. iOS 홈 인디케이터 영역만 safe-area 패딩으로 양보. */
/* 지수 2개 = 한 묶음. 바를 탭할 때마다 다음 묶음으로 순환(마지막 → 처음).
   자동 스크롤·자동 회전 없음 — 흘러가는 마퀴는 2026-07-31 관리자님 지시로 제거됐다. 되살리지 말 것. */
.market-ticker {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 30;
  background: rgba(11,13,18,0.96);
  backdrop-filter: saturate(160%) blur(10px); -webkit-backdrop-filter: saturate(160%) blur(10px);
  border-top: 1px solid #1f2330;
  padding: 5px 0;
  padding-bottom: calc(5px + env(safe-area-inset-bottom));
  font-size: 10.5px;
  /* line-height 1.2 고정: 라벨줄이 한글(닛케이225·금·은·동)이라 normal이면 ~1.45em로 늘어나 바 높이가 어긋난다. */
  line-height: 1.2;
  font-variant-numeric: tabular-nums;
  color: #e6e6e6;
  overflow: hidden;
  white-space: nowrap;
  /* 세로 3줄: 지수명 / 가격+등락 / 묶음 점. 점을 옆이 아니라 아래 줄 가운데로 둔 배치(2026-07-31 관리자님 지시). */
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
  /* iOS Safari는 비대화형 div에 cursor:pointer가 없으면 click을 합성하지 않는다 — 탭 전환의 전제조건. 지우지 말 것. */
  cursor: pointer;
  user-select: none; -webkit-user-select: none;
}
/* 세로 flex라 flex-grow를 주면 높이가 늘어난다 → 0 0 auto + width로 가로만 채운다.
   max-width: PC·아이패드에서 두 칸이 화면 절반씩 벌어져 텅 비어 보이는 걸 막는 상한. */
.ticker-page { flex: 0 0 auto; width: 100%; max-width: 520px; display: flex; align-items: center; min-height: 26px; }
/* 탭 전환 때만 페이드. 20초 주기 자동 갱신(animate=false)은 값만 조용히 교체. */
.ticker-page.fade { animation: ticker-fade 140ms ease-out; }
@keyframes ticker-fade {
  from { opacity: 0.35; }
  to { opacity: 1; }
}
.market-ticker .idx { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 1px; }
/* max-width+overflow: 칸보다 긴 내용은 오른쪽만 잘린다. .idx에 걸면 가운데정렬 탓에 양끝이 깎여 앞글자까지 사라진다. */
.market-ticker .idx-label { max-width: 100%; overflow: hidden; text-overflow: ellipsis; color: #8b8f9a; font-weight: 500; font-size: 9.5px; }
.market-ticker .idx-val { max-width: 100%; min-width: 0; overflow: hidden; display: flex; align-items: baseline; gap: 4px; }
/* 좁은 화면에서 칸을 넘칠 때: 가격은 절대 안 줄이고(flex:0 0 auto) 등락만 말줄임.
   둘 다 기본 shrink로 두면 flex가 양쪽을 눌러 앞글자까지 깎이고 두 칸 폭도 어긋난다(320px 실측). */
.market-ticker .idx-price { flex: 0 0 auto; color: #f0f0f0; font-weight: 500; font-size: 10.5px; }
.market-ticker .idx-delta { min-width: 0; overflow: hidden; text-overflow: ellipsis; font-weight: 400; font-size: 10.5px; }
.market-ticker .idx-delta.up { color: #ff4d5e; }
.market-ticker .idx-delta.down { color: #4d9bff; }
.market-ticker .idx-delta.flat { color: #8b8f9a; }
/* 묶음 점 — 3번째 줄 가운데(부모 align-items:center가 가로 정렬). 지수 칸과 가로 폭을 나눠쓰지 않으므로
   묶음 수가 줄어도 칸 폭은 영향 없다(옆에 두던 시절 필요했던 min-width 고정폭은 그래서 제거됨). */
.ticker-dots { flex: 0 0 auto; display: flex; align-items: center; gap: 3px; }
.ticker-dots i { width: 4px; height: 4px; border-radius: 50%; background: #333844; }
.ticker-dots i.on { background: #c9ccd3; }
/* min-height/line-height를 표시 상태 content 높이(26 + gap 3 + 점 4 = 33)와 맞춰 로딩↔표시 전환 때 바가 안 튀게. */
.market-ticker .idx-empty { flex: 0 0 auto; width: 100%; text-align: center; min-height: 33px; line-height: 33px; color: #5a5f6c; font-size: 10px; }

/* ── pull-to-refresh ── */
/* .page가 손가락 따라 translateY로 내려감 → topbar+컨텐츠 다 함께 내려가고 그 위로 빈공간 노출.
   .page.dragging 일 때만 topbar sticky를 일시 해제(position:static)해서 wrapper 변환에 묶이게 함.
   평상시(non-dragging)엔 topbar 그대로 sticky → 정상 스크롤 시 헤더 박혀있음.
   .ptr은 .page 내부 absolute top:-50 → 평소엔 viewport 위에 숨어있다가 .page 내려가면 갭에 emerge. */
.page {
  position: relative;
  background: #0b0d12;
  transition: transform 0.32s cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* will-change: transform이 항상 켜져있으면 .page가 containing block이 되어
   자식 .topbar의 position:sticky가 viewport가 아닌 .page 좌표계에 갇혀 스크롤을 따라 올라간다.
   드래그 시점에만 promote — GPU 가속이 필요한 순간에만 적용. */
.page.dragging { transition: none; will-change: transform; }
.page.dragging .topbar { position: static; }
.ptr {
  position: absolute;
  /* 노치에선 env(safe-area-inset-top)만큼 더 아래로 내려서 시계·카메라 영역 회피 */
  top: calc(-52px + env(safe-area-inset-top, 0px));
  left: 50%;
  width: 40px; height: 40px; margin-left: -20px;
  background: rgba(28, 34, 48, 0.95);
  border: 1px solid #2a3142;
  border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  color: #8b8f9a;
  opacity: 0;
  pointer-events: none;
  z-index: 25;  /* topbar(20)보다 위 — ptr이 topbar 영역 위로 떠올라 가려지지 않음 */
  font-variant-numeric: tabular-nums;
  transition: color 0.18s, border-color 0.18s, background 0.18s, box-shadow 0.2s;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
  backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
}
.ptr .arrow { font-size: 17px; font-weight: 700; line-height: 1; }
.ptr .spinner {
  display: none;
  width: 18px; height: 18px;
  border: 2px solid currentColor;
  border-top-color: transparent;
  border-radius: 50%;
}
.ptr.committed {
  color: #ff4d5e;
  border-color: #ff4d5e;
  background: rgba(255, 77, 94, 0.20);
  box-shadow: 0 4px 16px rgba(255, 77, 94, 0.35);
}
.ptr.spinning .arrow { display: none; }
.ptr.spinning .spinner { display: block; animation: ptr-spin 0.7s linear infinite; }
@keyframes ptr-spin {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

.topbar {
  position: sticky; top: 0; z-index: 20;
  background: rgba(11,13,18,0.94); backdrop-filter: saturate(160%) blur(10px); -webkit-backdrop-filter: saturate(160%) blur(10px);
  border-bottom: 1px solid #1f2330;
}
header.top {
  padding: 10px 16px; padding-top: max(10px, env(safe-area-inset-top));
  display: flex; justify-content: space-between; align-items: center; gap: 10px;
}
header.top .titles { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
header.top h1 { margin: 0; font-size: 16px; font-weight: 600; letter-spacing: -0.01em; color: #f0f0f0; animation: refresh-flash 0.8s ease-out; }
header.top .meta { font-size: 11px; color: #8b8f9a; font-variant-numeric: tabular-nums; animation: refresh-flash-meta 0.8s ease-out; }
/* 페이지 로드(첫 진입·새로고침) 시 1회 발동. 탭 클릭(hashchange)은 reload 아니므로 발동 안 함. */
@keyframes refresh-flash {
  0%, 20% { color: #ff4d5e; }
  100% { color: #f0f0f0; }
}
@keyframes refresh-flash-meta {
  0%, 20% { color: #ff4d5e; }
  100% { color: #8b8f9a; }
}
.top-actions { display: flex; align-items: center; gap: 8px; flex: none; }
/* 자동 갱신 상태 배지 — 클릭 불가 표시 전용. 장중=초록+남은초, 장마감=흐림. */
.auto-badge {
  flex: none; display: inline-flex; align-items: center; justify-content: center;
  user-select: none;
}
/* 상태 점 — 장중 초록 / 장마감 어두운 회색. */
.auto-badge::before {
  content: ''; width: 7px; height: 7px; border-radius: 50%;
  background: #3a3f4a; transition: background 0.2s, box-shadow 0.2s;
}
.auto-badge.live::before { background: #22c55e; box-shadow: 0 0 5px rgba(34,197,94,0.6); }
.top-trade-btn {
  flex: none; display: inline-flex; align-items: center; justify-content: center;
  height: 36px; padding: 0 12px; border-radius: 8px;
  background: #2a1418; color: #ff8a95; border: 1px solid rgba(255,138,149,0.45);
  font-size: 13px; font-weight: 600; line-height: 1; font-family: inherit;
  cursor: pointer; user-select: none; white-space: nowrap;
  transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.1s;
}
.top-trade-btn:hover { background: rgba(255,138,149,0.16); border-color: #ff8a95; color: #fff; }
.top-trade-btn:active { transform: scale(0.95); }

.section-label {
  padding: 22px 18px 8px; margin-top: 10px;
  font-size: 12px; font-weight: 600; letter-spacing: 0.05em; color: #8b8f9a;
  border-top: 1px solid #2a3142;
}
/* row 바로 다음에 오는 section-label은 분리감 더 강조 — '당일 매매' 끝 → '보유 종목' 시작 경계. */
details.row + .section-label { margin-top: 14px; padding-top: 24px; border-top-color: #3a4154; }
.section-label .count { color: #5a5f6c; font-weight: 400; margin-left: 4px; }

.row { border-bottom: 1px solid #14171f; }
.row > summary {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 4px 16px;
  padding: 8px 18px;
  cursor: pointer;
  list-style: none;
  font-variant-numeric: tabular-nums;
  user-select: none;
  transition: background 0.1s;
}
/* candle-mini는 grid flow에서 빠지고 카드 row 우측 끝에 absolute 고정 — grid 충돌·implicit row 차단. */
.row > summary:has(.candle-mini) { position: relative; padding-right: 56px; }
.candle-mini { position: absolute; top: 50%; right: 14px; transform: translateY(-50%); display: flex; align-items: center; justify-content: center; pointer-events: none; }
.row > summary::-webkit-details-marker { display: none; }
.row > summary::marker { display: none; }
.row:hover > summary { background: #11141c; }
.row[open] > summary { background: #14171f; }

.left { min-width: 0; display: flex; flex-direction: column; gap: 6px; justify-content: center; }
.right { text-align: right; display: flex; flex-direction: column; gap: 2px; align-items: flex-end; justify-content: center; }

.line1 { display: flex; align-items: center; gap: 6px; min-width: 0; flex-wrap: wrap; row-gap: 4px; }
/* 보유행 1줄: 뱃지+당일마크+종목명+계좌 한 줄 고정(nowrap). 이름 길면 .stock 이 ellipsis 로 줄어듦. */
.line1-held { flex-wrap: nowrap; }
.acct-mini { font-size: 8.5px; color: #7a7f8c; flex: none; }  /* 종목명 오른쪽 계좌명 작게 */
.stock { font-size: 14px; font-weight: 600; color: #f0f0f0; letter-spacing: -0.01em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.stock.stock-sm { font-size: 12px; }   /* 이름 10자+ */
.stock.stock-xs { font-size: 10.5px; } /* 이름 14자+ */
.code { font-size: 11px; color: #5a5f6c; font-weight: 400; flex: none; }

.line2 { display: flex; align-items: center; gap: 8px; font-size: 12px; min-width: 0; }
/* 3줄 고정: 1줄=뱃지+종목명(line1), 2줄=보유+비중(line2), 3줄=태그+계좌종류(line3). 각 줄 flex 단일행(미래 wrap 안 함). */
.line3 { display: flex; align-items: center; gap: 6px; font-size: 11px; min-width: 0; }
.wt-chip { white-space: nowrap; }  /* '비중 xx.xx%' 가 좁은 폭에서 내부 줄바뀜 되는 것 방지 (삼성전자 등 보유수량 자릿수 큰 경우) */

.price { font-size: 18px; font-weight: 600; color: #f0f0f0; letter-spacing: -0.01em; display: flex; align-items: center; gap: 6px; }
.right .ref { font-size: 12px; color: #8b8f9a; font-weight: 400; font-variant-numeric: tabular-nums; line-height: 1.2; }
.diff { font-size: 13px; font-weight: 500; color: #8b8f9a; }
.diff .pct { font-size: 13px; margin-left: 4px; opacity: 0.95; }

.row.up .price, .row.up .diff { color: #ff4d5e; }
.row.down .price, .row.down .diff { color: #4a8cf0; }

.day-pct { font-size: 13px; font-weight: 500; }
.day-pct.up { color: #ff4d5e; }
.day-pct.down { color: #4a8cf0; }
.day-pct.neutral { color: #8b8f9a; }
.open-pct { display: inline-block; margin-left: 4px; font-size: 11px; font-weight: 500; font-variant-numeric: tabular-nums; }
.open-pct.up { color: #ff4d5e; }
.open-pct.down { color: #4a8cf0; }
.open-pct.neutral { color: #8b8f9a; }
/* OHLC mini-table — 라벨/가격/시가대비%/전일대비% 4-col grid.
   첫 줄에 시가/전일 컬럼 헤더가 한 번만 표시되고, 4행(현재가/시가/고가/저가)이 컬럼 단위로 정렬된다.
   `시가` 행의 시가 컬럼은 자기 자신이라 `-` placeholder. */
.ohlc-mini { display: grid; grid-template-columns: max-content max-content 1fr 1fr; column-gap: 14px; row-gap: 4px; margin: 8px 0 4px; width: 100%; font-variant-numeric: tabular-nums; align-items: baseline; }
.ohlc-mini .ohlc-th { font-size: 10px; color: #8b8f9a; font-weight: 500; text-align: right; padding-bottom: 2px; border-bottom: 1px solid #1f2330; }
.ohlc-mini .ohlc-th:first-child, .ohlc-mini .ohlc-th:nth-child(2) { text-align: left; border-bottom: none; }
.ohlc-mini .ohlc-rl { color: #8b8f9a; font-size: 12px; }
.ohlc-mini .ohlc-price { color: #d6d6d6; font-size: 13px; font-weight: 500; text-align: left; }
.ohlc-mini .ohlc-pct { font-size: 12px; font-weight: 500; text-align: right; min-width: 56px; }
.ohlc-mini .ohlc-pct.up { color: #ff4d5e; }
.ohlc-mini .ohlc-pct.down { color: #4a8cf0; }
.ohlc-mini .ohlc-pct.neutral { color: #8b8f9a; }
/* 보유종목 자세히 보기 — KRX/NXT 두 거래소 분리 표시. 3-col grid (라벨/KRX셀/NXT셀). */
/* 팝업 폭 대응 — 라벨(전일종가/현재가/…) 열을 고정폭으로 좁히고 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: 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; }
.ohlc-mini.ohlc-dual .ohlc-cell.ohlc-dim .ohlc-price { color: #5a5e69; }
.ohlc-mini.ohlc-dual .ohlc-cell.ohlc-dim .ohlc-pct { color: #5a5e69 !important; }
.ohlc-mini.ohlc-dual .ohlc-cell-empty.ohlc-dim { color: #3a3e48; }
/* 현재가 왼쪽 거래가능 표시 — 지금 거래시간이면 초록점, 아니면 점 없음. (미리보기·보유 행 공용) */
.px-dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 5px; vertical-align: 1px; }
.px-dot.tradable { background: #2ecc71; box-shadow: 0 0 4px rgba(46,204,113,0.6); }

.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex: none; box-shadow: 0 0 6px currentColor; }
.dot-1 { background: #f5d423; color: rgba(245,212,35,0.5); }
.dot-2 { background: #f59023; color: rgba(245,144,35,0.55); }
.dot-3 { background: #ff4d5e; color: rgba(255,77,94,0.6); }

/* 매입 총액 등급 뱃지 — 군대 계급 모티프(가로 막대 1~5개). 노랑→진한빨강 그라데이션. */
.rank-badge { display: inline-flex; flex-direction: column; justify-content: center; gap: 1.5px; flex: none; padding: 0 1px; }
.rank-badge .stripe { width: 9px; height: 1.5px; border-radius: 1px; }
.rank-1 .stripe { background: #ffe082; box-shadow: 0 0 4px rgba(255,224,130,0.45); }
.rank-2 .stripe { background: #ffc107; box-shadow: 0 0 4px rgba(255,193,7,0.5); }
.rank-3 .stripe { background: #ff9800; box-shadow: 0 0 5px rgba(255,152,0,0.55); }
.rank-4 .stripe { background: #f57c00; box-shadow: 0 0 5px rgba(245,124,0,0.6); }
.rank-5 .stripe { background: #d32f2f; box-shadow: 0 0 6px rgba(211,47,47,0.65); }

.muted { color: #5a5f6c; font-weight: 400; }

.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 10px; font-weight: 500; line-height: 1.6; font-variant-numeric: tabular-nums; white-space: nowrap; flex: none; letter-spacing: 0.02em; }
.tag-chip { display: inline-block; padding: 0 4px; border-radius: 3px; font-size: 8.5px; font-weight: 500; line-height: 1.4; color: #d6b34a; background: rgba(214,179,74,0.10); border: 1px solid rgba(214,179,74,0.30); white-space: nowrap; flex: none; letter-spacing: 0.01em; font-family: inherit; pointer-events: none; user-select: none; }
.tag-chip.tag-leader { color: #ff6b78; background: rgba(255,77,94,0.12); border-color: rgba(255,77,94,0.42); font-weight: 600; }
/* 펼친 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-market { align-self: center; font-size: 9px; font-weight: 500; color: #6b7280; }
/* 현재가 아래 메타 라인 — 비중 · 계좌명 · 시장 (작은 회색, · 구분) */
.detail .detail-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin: -4px 0 10px; font-size: 11px; color: #8b8f9a; }
.detail .detail-meta .dm-item { font-weight: 500; }
.detail .detail-meta > * + *::before { content: "·"; margin-right: 8px; color: #4b5563; }
.detail .detail-meta .detail-market { font-size: 11px; }
.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; }
.btn-tag-quick { padding: 4px 14px; border-radius: 6px; font-size: 12px; font-weight: 600; color: #b8bcc6; background: #1a1e2a; border: 1px solid #2a2f3c; cursor: pointer; font-family: inherit; letter-spacing: 0.02em; }
.btn-tag-quick:hover { background: #20242f; color: #f0f0f0; }
.btn-tag-quick.active { color: #ff6b78; background: rgba(255,77,94,0.14); border-color: rgba(255,77,94,0.50); }
.btn-tag-quick.active:hover { background: rgba(255,77,94,0.22); }
.badge.watching { background: #1a1e2a; color: #7a8090; }
.badge.held { background: #2a1820; color: #ff8a96; border: 1px solid rgba(255,77,94,0.3); font-size: 10px; padding: 2px 7px; }
.badge.pending-buy { background: #2a1822; color: #ff9fb3; border: 1px solid rgba(255,140,170,0.32); font-weight: 600; padding: 2px 6px; letter-spacing: 0; }
.badge.pending-sell { background: #182030; color: #7fb8ff; border: 1px solid rgba(122,180,255,0.32); font-weight: 600; padding: 2px 6px; letter-spacing: 0; }
dl.pending-detail { margin-top: 8px; padding-top: 8px; border-top: 1px dashed rgba(255,255,255,0.08); grid-template-columns: 92px minmax(0, 1fr); }
.row.mode-held .detail dl.pending-detail { grid-template-columns: 92px minmax(0, 1fr); }
dl.pending-detail dt { font-weight: 600; letter-spacing: 0; white-space: nowrap; }
dl.pending-detail dt.pending-buy-label { color: #ff9fb3; }
dl.pending-detail dt.pending-sell-label { color: #7fb8ff; }
dl.pending-detail dd { line-height: 1.45; }
dl.pending-detail .pending-line-main { font-variant-numeric: tabular-nums; }
dl.pending-detail .pending-line-meta { margin-top: 2px; }
/* 스톱·트레일링 조건단가 줄 — 좁은 폭에서 줄바꿈되게(가로 스크롤 방지) */
dl.pending-detail .pending-line-cond { margin-top: 2px; color: #8b8f9a; overflow-wrap: anywhere; }
dl.pending-detail .pending-line-cond.trailing { color: #d9a84a; }
/* 트레일링 근거 미니표 — dd 안에 중첩되는 dl. 라벨 좌측 / 값 우측정렬로 자리수를 맞춘다.
   두 열 모두 minmax(0,..) 라 좁은 폭에서도 안 넘친다. */
dl.pending-detail dl.trail-mini { display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr);
  gap: 1px 8px; margin: 4px 0 0; padding: 5px 7px; font-size: 10.5px;
  border: 1px solid rgba(217,168,74,0.22); border-radius: 6px; background: rgba(217,168,74,0.05); }
dl.pending-detail dl.trail-mini dt { color: #8b8f9a; font-weight: 400; white-space: nowrap; }
dl.pending-detail dl.trail-mini dd { margin: 0; text-align: right; color: #a8adb8; line-height: 1.45;
  font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
dl.pending-detail dl.trail-mini dd b { color: #d9a84a; font-weight: 600; }
/* ⚠️ 보유종목 행(.row.mode-held)의 `.detail dl` 은 4열 grid(80px 1fr 80px 1fr)이고 명시도가
   (0,3,1) 로 위 `dl.pending-detail dl.trail-mini` (0,2,2) 보다 높다. 되돌리지 않으면 미니표가
   4열로 펼쳐져 고정 80px 두 개 때문에 폭을 넘겨 잘린다. pending-detail 도 같은 이유로
   바로 위에서 2열로 되돌려 놓았다. 이 규칙은 (0,4,1) 이라 640px 미디어쿼리 안의
   4열 규칙(0,3,1)까지 함께 이긴다 — 미디어쿼리용 사본은 필요 없다. */
.row.mode-held .detail dl.trail-mini { grid-template-columns: minmax(0, auto) minmax(0, 1fr); }
/* 트레일링 미리보기 카드 — .order-inputs 실폭이 좁아(모바일 ~200px) 가로로 벌어지는 요소를
   두면 가로 스크롤이 생긴다. 전부 세로 쌓기 + minmax(0,..) + anywhere 로 묶는다. */
.trail-pv { border: 1px solid #1f2330; border-radius: 8px; padding: 8px 9px; background: #0f131c;
  display: flex; flex-direction: column; gap: 7px; min-width: 0; overflow-wrap: anywhere; }
.trail-pv-hero { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.trail-pv-hero .cap { font-size: 10px; color: #7c8290; letter-spacing: .02em; }
.trail-pv-hero .val { font-size: 17px; font-weight: 700; color: #cfd3dc; font-variant-numeric: tabular-nums; line-height: 1.15; }
.trail-pv-hero .sub { font-size: 10px; color: #7c8290; }
.trail-pv.bad { border-color: rgba(224,85,85,0.45); background: rgba(224,85,85,0.07); }
.trail-pv.bad .val { color: #e05555; font-size: 13px; }
.trail-pv-kv { display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr); gap: 2px 8px;
  font-size: 11px; border-top: 1px solid #1a1e28; padding-top: 6px; }
.trail-pv-kv dt { color: #7c8290; }
.trail-pv-kv dd { margin: 0; text-align: right; color: #a8adb8; font-variant-numeric: tabular-nums; }
.trail-pv-kv dd b { color: #cfd3dc; font-weight: 600; }
.trail-pv-note { font-size: 10px; color: #6f7480; line-height: 1.45; border-top: 1px solid #1a1e28; padding-top: 6px; }
/* 계단 편집기 — .order-inputs 실폭이 모바일 ~200px 라 열 하나하나가 아깝다.
   행마다 "하락/누적" 글자를 반복하는 대신 헤더 한 줄로 빼고, 화살표도 지웠다.
   그 결과 입력칸이 38px → 78px 로 넓어진다(고정폭 58 → 44, 라벨·단위 21px 회수).
   스피너를 없애는 것도 폭 확보의 일부 — 데스크톱에서 화살표가 숫자를 가리던 문제도 같이 해결. */
.trail-steps { display: flex; flex-direction: column; gap: 3px; margin-top: 5px; min-width: 0; }
.trail-step { display: grid; align-items: center; gap: 4px; min-width: 0;
  grid-template-columns: 12px minmax(0,1fr) minmax(0,1fr) 16px; font-size: 10px; color: #7c8290; }
.trail-step-head { color: #5a5f6c; font-size: 9px; letter-spacing: .02em; }
.trail-step-head span { text-align: center; }
.trail-step .tsn { color: #6f7480; font-variant-numeric: tabular-nums; text-align: center; }
.trail-step .tsw { display: flex; align-items: center; gap: 2px; min-width: 0; }
.trail-step input { width: 100%; min-width: 0; padding: 4px 5px; font-size: 12px;
  background: #0f131c; border: 1px solid #262b38; border-radius: 5px; color: #cfd3dc;
  font-variant-numeric: tabular-nums; text-align: right; }
.trail-step input::-webkit-outer-spin-button,
.trail-step input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.trail-step .tsu { color: #6f7480; flex: 0 0 auto; font-size: 9px; }
.trail-step .tsx { background: none; border: 0; color: #5a5f6c; cursor: pointer; font-size: 12px;
  padding: 0; line-height: 1; }
.trail-step .tsx:hover { color: #e05555; }
.trail-add-btn { width: 100%; margin-top: 5px; padding: 5px 6px; font-size: 10px; cursor: pointer;
  background: #171b26; border: 1px dashed #2c3140; border-radius: 6px; color: #8b8f9a; }
.trail-add-btn:hover { background: #1d2230; color: #cfd3dc; border-color: #3a4152; }
/* 미리보기 계단 목록 — 한 줄에 넷을 욱여넣으면(계단·가격·주수·누적) 200px 를 넘겨 접힌다.
   1행 "N단계 −P% … 가격", 2행 "주수 · 누적" 으로 나눠 가격을 우측 정렬로 세운다. */
.trail-pv-steps { display: flex; flex-direction: column; gap: 5px; font-size: 10px;
  border-top: 1px solid #1a1e28; padding-top: 6px; min-width: 0; }
.trail-pv-step { display: grid; grid-template-columns: minmax(0,1fr) auto; gap: 1px 6px;
  align-items: baseline; font-variant-numeric: tabular-nums; }
.trail-pv-step .sp { color: #7c8290; }
.trail-pv-step .sv { color: #cfd3dc; font-weight: 600; text-align: right; }
.trail-pv-step .sq { grid-column: 1 / -1; color: #6f7480; font-size: 9px; }
.trail-pv-step.tied .sv { color: #d9a84a; }
.trail-pv-badge { display: inline-block; font-size: 9px; padding: 1px 5px; border-radius: 999px;
  background: #2a2313; color: #d9a84a; margin-left: 4px; vertical-align: middle; }

.caret { color: #4a4f5a; font-size: 10px; transition: transform 0.2s; }
.row[open] .caret { transform: rotate(180deg); color: #8b8f9a; }

.detail {
  padding: 6px 16px 16px;
  background: #0d1018;
  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 { flex: 1 1 100%; padding-top: 11px; padding-bottom: 11px; 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);
  column-gap: 14px;
  row-gap: 6px;
}
.row.mode-held .detail dt.empty,
.row.mode-held .detail dd.empty { visibility: hidden; }
.row.mode-held .detail dl.pl-summary {
  grid-template-columns: 92px minmax(0, 1fr);
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid #1f2330;
}
.row.mode-held .detail dl.pl-summary dt { white-space: nowrap; }
.row.mode-held .detail dl.pl-summary .pl-divider { grid-column: 1 / -1; border-top: 1px solid #1f2330; margin: 4px 0; height: 0; }
@media (max-width: 640px) {
  .row.mode-held .detail dl.pl-summary { grid-template-columns: 92px minmax(0, 1fr); }
}
@media (max-width: 640px) {
  .row.mode-held .detail dl {
    grid-template-columns: 64px minmax(0, 1fr) 64px minmax(0, 1fr);
    column-gap: 8px;
  }
}
.detail dt { color: #8b8f9a; font-size: 12px; }
.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; }
.detail .grid2 > .lbl { color: #8b8f9a; font-size: 12px; white-space: nowrap; align-self: center; }
.detail .grid2 > .val { color: #d6d6d6; font-size: 13px; word-break: break-all; min-width: 0; align-self: center; }
.detail .grid2 > .val.num { font-variant-numeric: tabular-nums; }
.detail .grid2 > .val.muted { color: #8b8f9a; }
@media (max-width: 480px) {
  .detail .grid2 { column-gap: 8px; row-gap: 5px; }
  .detail .grid2 > .lbl { font-size: 11px; }
  .detail .grid2 > .val { font-size: 12px; }
}
.detail .block { margin: 12px 0 0; }
.detail h3 { margin: 0 0 6px; font-size: 11px; color: #8b8f9a; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
.detail ul { margin: 0; padding-left: 18px; line-height: 1.6; color: #c9ccd3; }
.detail li { margin-bottom: 3px; }
.detail .video-link { color: #7aa2ff; text-decoration: none; }
.detail .video-link:hover { text-decoration: underline; }
.detail .block.chart { margin: 8px 0 14px; }
.detail .block.chart img { display: block; width: 100%; max-width: 700px; height: auto; border-radius: 6px; background: #fff; }
.detail .block.chart-svg { margin: 8px 0 14px; min-height: 200px; border-radius: 6px; overflow: hidden; }
.detail .block.chart-svg svg { width: 100%; height: auto; display: block; }
.detail .block.chart-svg .chart-loading { padding: 60px 0; text-align: center; color: #7a8493; font-size: 12px; }
.detail .block.chart-svg .chart-error { padding: 30px 12px; text-align: center; color: #ef4444; font-size: 12px; }
.detail .block.chart-svg .chart-tabs { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
.detail .block.chart-svg .chart-tabs button { padding: 5px 12px; background: #1f2937; color: #7a8493; border: 1px solid #2a2f3a; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 600; font-family: inherit; }
.detail .block.chart-svg .chart-tabs button:hover { color: #cfd5df; border-color: #3f4654; }
.detail .block.chart-svg .chart-tabs button.active { background: rgba(251,191,36,0.12); color: #fbbf24; border-color: #fbbf24; }
.detail .block.chart-svg .chart-panel { display: none; }
.detail .block.chart-svg .chart-panel.active { display: block; }
.detail .block.chart-svg .chart-cap { margin-top: 6px; padding: 6px 4px 0; font-size: 11px; line-height: 1.5; color: #7a8493; }
.detail .block.chart-svg .chart-cap b { color: #9aa3b0; font-weight: 600; }
.detail .block.chart-svg .chart-subtabs { display: flex; gap: 4px; margin-bottom: 6px; flex-wrap: wrap; }
.detail .block.chart-svg .chart-subtabs button { padding: 3px 9px; background: transparent; color: #7a8493; border: 1px solid #2a2f3a; border-radius: 5px; cursor: pointer; font-size: 11px; font-weight: 600; font-family: inherit; }
.detail .block.chart-svg .chart-subtabs button:hover { color: #cfd5df; border-color: #3f4654; }
.detail .block.chart-svg .chart-subtabs button.active { background: rgba(52,211,153,0.10); color: #34d399; border-color: rgba(52,211,153,0.45); }
/* 차트 하단 수평선 컴팩트 행 — 색상 칩 나열 + '+' 버튼 (추가·관리는 모달) */
.detail .block.chart-svg .chart-hlines { margin-top: 8px; }
.detail .block.chart-svg .chart-hlines .hl-row { display: flex; align-items: center; gap: 6px; }
.detail .block.chart-svg .chart-hlines .hl-list { display: flex; flex-wrap: wrap; gap: 5px; flex: 1; min-width: 0; }
.detail .block.chart-svg .chart-hlines .hl-empty { font-size: 11px; color: #7a8493; }
.detail .block.chart-svg .chart-hlines .hl-chip { display: inline-flex; align-items: center; padding: 2px 9px; background: rgba(255,255,255,0.04); border: 1px solid; border-radius: 12px; font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }
.detail .block.chart-svg .chart-hlines .hl-add-open { flex: none; width: 30px; height: 30px; display: inline-flex; align-items: center; justify-content: center; padding: 0; background: rgba(232,121,249,0.14); color: #e879f9; border: 1px solid rgba(232,121,249,0.5); border-radius: 8px; font-size: 20px; font-weight: 700; line-height: 1; cursor: pointer; font-family: inherit; }
.detail .block.chart-svg .chart-hlines .hl-add-open:hover { background: rgba(232,121,249,0.24); }
/* 수평선 추가·관리 모달 (position:fixed 로 상세 팝업 위에 뜸) */
.hl-modal { position: fixed; inset: 0; z-index: 1200; display: flex; align-items: center; justify-content: center; padding: 40px 16px; }
.hl-modal.hidden { display: none; }
.hl-modal .hl-modal-ov { position: absolute; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px); }
.hl-modal .hl-modal-box { position: relative; width: 100%; max-width: 320px; max-height: 70vh; 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); }
.hl-modal .hl-modal-head { display: flex; align-items: center; justify-content: space-between; font-size: 13px; font-weight: 700; color: #f0f0f0; margin-bottom: 12px; }
.hl-modal .hl-modal-x { 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: 16px; line-height: 1; cursor: pointer; font-family: inherit; }
.hl-modal .hl-modal-x:hover { background: #22262f; }
.hl-modal .chart-hlines-add { display: flex; gap: 6px; margin-bottom: 12px; }
.hl-modal .chart-hlines-add input { flex: 1; min-width: 0; padding: 8px 10px; background: #0a0d12; color: #e6e9ef; border: 1px solid #2a2f3a; border-radius: 6px; font-size: 16px; font-family: inherit; }
.hl-modal .chart-hlines-add input:focus { outline: none; border-color: #e879f9; }
.hl-modal .chart-hlines-add .hl-add-btn { padding: 8px 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: 13px; font-weight: 700; font-family: inherit; white-space: nowrap; }
.hl-modal .chart-hlines-add .hl-add-btn:hover { background: rgba(232,121,249,0.24); }
.hl-modal .hl-manage-list { display: flex; flex-direction: column; gap: 6px; }
.hl-modal .hl-mrow { display: flex; align-items: center; gap: 8px; padding: 6px 8px; background: #131820; border: 1px solid #1f2330; border-radius: 6px; }
.hl-modal .hl-swatch { flex: none; width: 14px; height: 14px; border-radius: 3px; }
.hl-modal .hl-mprice { flex: 1; font-size: 13px; font-weight: 600; color: #e6e9ef; font-variant-numeric: tabular-nums; }
.hl-modal .hl-mdel { padding: 4px 10px; background: transparent; color: #ff8a96; border: 1px solid rgba(255,77,94,0.32); border-radius: 6px; font-size: 12px; cursor: pointer; font-family: inherit; }
.hl-modal .hl-mdel:hover { background: rgba(255,77,94,0.1); }
.hl-modal .hl-empty { font-size: 12px; color: #7a8493; text-align: center; padding: 8px; }
.detail .block.chart-svg .chart-subpanel { min-height: 200px; }

.actions {
  margin-top: 14px; padding-top: 12px;
  border-top: 1px solid #1a1e29;
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.actions form { display: inline; margin: 0; }
.actions button {
  font: inherit; cursor: pointer;
  padding: 6px 14px; border-radius: 6px;
  border: 1px solid #2a3142; background: transparent;
  color: #b0b4be; font-size: 12px; font-weight: 500;
  transition: background 0.12s, border-color 0.12s;
}
.actions button:hover { background: #1c2030; }
.actions button:active { transform: scale(0.97); }
.actions .btn-delete { color: #ff8a96; border-color: rgba(255,77,94,0.32); }
.actions .btn-delete:hover { background: rgba(255,77,94,0.08); }
.actions .btn-alerts-reset { color: #c9ccd3; border-color: rgba(201,204,211,0.28); }
.actions .btn-alerts-reset:hover { background: rgba(201,204,211,0.06); border-color: #6aa9ff; color: #6aa9ff; }
.alert-mark { display: inline-block; margin-left: 6px; padding: 1px 7px; border-radius: 10px; font-size: 11px; font-weight: 600; color: #ffd66e; background: rgba(255,214,110,0.10); border: 1px solid rgba(255,214,110,0.32); vertical-align: middle; }
.badge.alerted { color: #ffd66e; background: rgba(255,214,110,0.10); border: 1px solid rgba(255,214,110,0.32); padding: 1px 7px; border-radius: 10px; font-size: 11px; font-weight: 600; }
.badge.vol-hot { padding: 1px 5px; border-radius: 8px; font-size: 9px; font-weight: 700; font-variant-numeric: tabular-nums; }
.badge.vol-hot.vol-warm { color: #f5a623; background: rgba(245,166,35,0.14); border: 1px solid rgba(245,166,35,0.45); }
.badge.vol-hot.vol-red { color: #ef4444; background: rgba(239,68,68,0.16); border: 1px solid rgba(239,68,68,0.5); }
.alert-notice { margin: 0 0 10px; padding: 8px 10px; border-radius: 6px; font-size: 12px; color: #ffd66e; background: rgba(255,214,110,0.08); border-left: 3px solid #ffd66e; }
.buy-raw { color: #c9ccd3; margin-bottom: 4px; }
.buy-level { color: #e6e6e6; line-height: 1.5; }
.buy-level + .buy-level { margin-top: 2px; }
.actions .btn-restore { color: #5cd99a; border-color: rgba(46,194,126,0.32); }
.actions .btn-restore:hover { background: rgba(46,194,126,0.08); }
.actions .btn-purge { color: #fff; background: #c43c3c; border-color: #c43c3c; }
.actions .btn-purge:hover { background: #d34a4a; border-color: #d34a4a; }
.actions .btn-edit { color: #c9ccd3; border-color: rgba(201,204,211,0.28); }
.actions .btn-edit:hover { background: rgba(201,204,211,0.06); border-color: #ff4d5e; color: #ff4d5e; }
.actions .small { font-size: 11px; margin-right: auto; }

.section-label.trash-label { color: #6c7180; }
.row.trash > summary { opacity: 0.5; }
.row.trash[open] > summary { opacity: 0.85; }

.empty { padding: 60px 18px; text-align: center; color: #6c7180; }

/* 관심종목 그룹 선택 — 풀다운 + 오른쪽 '+' 메뉴. .sub-tab 과 별개 클래스. */
.igrp-select-wrap { display: flex; align-items: center; gap: 8px; margin: 8px 14px 6px; }
.igrp-select { flex: 1; min-width: 0; appearance: none; -webkit-appearance: none; background: #14171f url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'><path fill='%238b8f9a' d='M1 1l5 5 5-5'/></svg>") no-repeat right 12px center; color: #e6e6e6; border: 1px solid #1f2330; border-radius: 8px; padding: 9px 34px 9px 12px; font-size: 14px; font-weight: 500; font-family: inherit; cursor: pointer; }
.igrp-select:focus { outline: none; border-color: #ff4d5e; }
.igrp-flat-label { flex: 1; min-width: 0; display: flex; align-items: center; gap: 6px; color: #d6d8dd; font-size: 14px; font-weight: 600; padding: 9px 2px; }
.igrp-menu-wrap { position: relative; flex: 0 0 auto; }
.igrp-add-btn { width: 38px; height: 38px; display: inline-flex; align-items: center; justify-content: center; background: #14171f; color: #e6e6e6; border: 1px solid #2f3543; border-radius: 8px; font-size: 22px; line-height: 1; cursor: pointer; font-family: inherit; }
.igrp-add-btn:hover { border-color: #ff4d5e; color: #ff4d5e; }
.igrp-add-btn:active { transform: translateY(1px); }
.igrp-menu { position: absolute; right: 0; top: calc(100% + 4px); z-index: 60; min-width: 132px; background: #1a1d26; border: 1px solid #2f3543; border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); padding: 4px; }
.igrp-menu[hidden] { display: none; }
.igrp-menu-item { display: block; width: 100%; text-align: left; background: transparent; border: 0; color: #e6e6e6; padding: 10px 12px; font-size: 14px; font-family: inherit; cursor: pointer; border-radius: 6px; white-space: nowrap; }
.igrp-menu-item:hover { background: #232733; color: #ff6b78; }
.igrp-panel[hidden] { display: none; }
/* 그룹 배정 모달 체크박스 */
.group-checks { display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 10px; }
.group-check { display: inline-flex; align-items: center; gap: 6px; background: #14171f; border: 1px solid #1f2330; border-radius: 8px; padding: 8px 12px; font-size: 14px; color: #e6e6e6; cursor: pointer; }
.group-check input { accent-color: #ff4d5e; width: 16px; height: 16px; }
/* 그룹 관리 모달 — 그룹당 2줄 카드(이름·수 / 액션) */
.group-manage-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; max-height: 46vh; overflow-y: auto; }
.group-manage-row { display: flex; flex-direction: column; gap: 8px; background: #14171f; border: 1px solid #1f2330; border-radius: 8px; padding: 10px; }
.gmr-top { display: flex; align-items: center; gap: 10px; }
.gmr-actions { display: flex; align-items: center; gap: 6px; }
.gmr-spacer { flex: 1; }
.group-name-input { flex: 1; min-width: 0; background: #0e1017; border: 1px solid #1f2330; border-radius: 6px; padding: 8px 10px; color: #e6e6e6; font-size: 15px; font-family: inherit; }
.group-name-input:focus { outline: none; border-color: #ff4d5e; }
.group-count { flex: 0 0 auto; white-space: nowrap; }
.btn-mini { background: #0e1017; color: #cfd3dc; border: 1px solid #2f3543; border-radius: 6px; padding: 6px 10px; font-size: 12px; cursor: pointer; font-family: inherit; }
.btn-mini:hover { border-color: #ff4d5e; color: #ff4d5e; }
.btn-mini:disabled { opacity: 0.35; cursor: default; }
.btn-mini.btn-delete { color: #ff8a95; }
.group-new-row { display: flex; align-items: center; gap: 8px; }
.group-new-row input { flex: 1; min-width: 0; background: #14171f; border: 1px solid #1f2330; border-radius: 6px; padding: 9px 10px; color: #e6e6e6; font-size: 16px; font-family: inherit; }
.group-new-row input:focus { outline: none; border-color: #ff4d5e; }
.group-new-row .btn-add { flex: 0 0 auto; }

.modal { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: max(60px, calc(env(safe-area-inset-top) + 68px)) 20px 20px; }
.modal.modal-top { z-index: 1010; }
.modal.hidden { display: none; }
.modal-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(2px); }
.modal-box { position: relative; background: #0d1018; border: 1px solid #1f2330; border-radius: 12px; padding: 18px 18px 16px; width: 100%; max-width: 480px; max-height: calc(100dvh - env(safe-area-inset-top) - 96px); overflow-y: auto; box-shadow: 0 12px 40px rgba(0,0,0,0.45); }
.modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.modal-title { font-size: 14px; font-weight: 600; color: #f0f0f0; letter-spacing: 0.02em; }
.modal-close { background: transparent; border: 0; color: #8b8f9a; font-size: 22px; line-height: 1; cursor: pointer; padding: 4px 8px; font-family: inherit; }
.modal-close:hover { color: #f0f0f0; }
.form-feedback { margin-top: 12px; }
.feedback-error { background: #2a1418; border: 1px solid #5a1f28; color: #ff8a95; border-radius: 8px; padding: 10px 12px; font-size: 13px; line-height: 1.5; }
.feedback-title { color: #c9ccd3; font-size: 12px; margin-bottom: 8px; line-height: 1.5; }
.candidate-list { display: flex; flex-direction: column; gap: 6px; }
.candidate-btn { display: flex; align-items: center; justify-content: space-between; gap: 10px; background: #14171f; border: 1px solid #1f2330; color: #e6e6e6; border-radius: 8px; padding: 10px 12px; font-size: 14px; font-family: inherit; cursor: pointer; text-align: left; }
.candidate-btn:hover { border-color: #ff4d5e; }
.candidate-btn:active { transform: translateY(1px); }
.candidate-btn .cand-code { color: #6c7180; font-size: 12px; font-variant-numeric: tabular-nums; }
.modal-actions { display: flex; gap: 8px; margin-top: 14px; }
.modal-actions .info-analyze-link { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 4px; background: rgba(251,191,36,0.08); color: #fbbf24; border: 1px solid rgba(251,191,36,0.35); border-radius: 8px; padding: 10px 14px; font-size: 13px; font-weight: 600; text-decoration: none; font-family: inherit; }
.modal-actions .info-analyze-link:hover { background: rgba(251,191,36,0.16); border-color: #fbbf24; }
.modal-actions .info-analyze-link:active { transform: translateY(1px); }
.modal-actions .btn-cancel { flex: 1; background: #14171f; color: #c9ccd3; border: 1px solid #1f2330; border-radius: 8px; padding: 10px 14px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; }
.modal-actions .btn-add { flex: 2; background: #ff4d5e; color: #fff; border: 0; border-radius: 8px; padding: 10px 16px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; }
.modal-actions .btn-add:active,
.modal-actions .btn-cancel:active { transform: translateY(1px); }
.modal-head .btn-delete { background: transparent; color: #ff8a96; border: 1px solid rgba(255,77,94,0.32); border-radius: 6px; padding: 4px 10px; font-size: 12px; font-weight: 500; cursor: pointer; font-family: inherit; }
.modal-head .btn-delete:hover { background: rgba(255,77,94,0.08); }
.modal-head .btn-delete:active { transform: translateY(1px); }

.add-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 12px; }
.add-form-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: #8b8f9a; }
.add-form-grid input { background: #14171f; border: 1px solid #1f2330; border-radius: 6px; padding: 9px 10px; color: #e6e6e6; font-size: 16px; font-family: inherit; }
.add-form-grid input:focus { outline: none; border-color: #ff4d5e; }
.add-form-grid label:has(textarea) { grid-column: 1 / -1; }
.add-form-grid textarea { background: #14171f; border: 1px solid #1f2330; border-radius: 6px; padding: 9px 10px; color: #e6e6e6; font-size: 16px; font-family: inherit; resize: vertical; min-height: 96px; }
.add-form-grid textarea:focus { outline: none; border-color: #ff4d5e; }
@media (max-width: 480px) {
  .add-form-grid { grid-template-columns: 1fr; }
  .modal { padding: max(56px, calc(env(safe-area-inset-top) + 60px)) 12px 12px; }
  .modal-box { padding: 16px 14px 14px; }
}

.loading { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 60px 18px; color: #8b8f9a; font-size: 13px; }
.loading-spin { width: 16px; height: 16px; border: 2px solid #2a2e3a; border-top-color: #ff4d5e; border-radius: 50%; animation: behive-spin 0.8s linear infinite; }
@keyframes behive-spin { to { transform: rotate(360deg); } }

.actions .btn-trades { color: #c9ccd3; border-color: rgba(201,204,211,0.28); }
.actions .btn-trades:hover { background: rgba(201,204,211,0.06); border-color: #6aa9ff; color: #6aa9ff; }
.actions .btn-order { color: #ff8a95; border: 1px solid rgba(255,138,149,0.35); background: rgba(255,138,149,0.06); border-radius: 8px; padding: 5px 10px; font-size: 11px; font-weight: 600; cursor: pointer; font-family: inherit; }
.actions .btn-order:hover { background: rgba(255,138,149,0.14); border-color: #ff8a95; }
.actions .btn-order:active { transform: translateY(1px); }
.actions .btn-analyze { color: #fbbf24; border: 1px solid rgba(251,191,36,0.35); background: rgba(251,191,36,0.06); border-radius: 8px; padding: 5px 10px; font-size: 11px; font-weight: 600; text-decoration: none; display: inline-flex; align-items: center; gap: 3px; }
.actions .btn-analyze:hover { background: rgba(251,191,36,0.12); border-color: #fbbf24; }
.actions .btn-info { color: #7dd3a8; border: 1px solid rgba(125,211,168,0.35); background: rgba(125,211,168,0.06); border-radius: 8px; padding: 5px 10px; font-size: 11px; font-weight: 600; cursor: pointer; font-family: inherit; }
.actions .btn-info:hover { background: rgba(125,211,168,0.14); border-color: #7dd3a8; }
.actions .btn-info:active { transform: translateY(1px); }
.actions .btn-note { color: #c9b27d; border: 1px solid rgba(201,178,125,0.35); background: rgba(201,178,125,0.06); border-radius: 8px; padding: 5px 10px; font-size: 11px; font-weight: 600; cursor: pointer; font-family: inherit; }
.actions .btn-note:hover { background: rgba(201,178,125,0.14); border-color: #c9b27d; }
.actions .btn-note:active { transform: translateY(1px); }
.actions .btn-note.has-note { color: #f0c95a; border-color: rgba(240,201,90,0.5); background: rgba(240,201,90,0.12); }
/* 급등락 알림 on/off 표시 — detail-name 줄(종목명 옆, +태그 왼쪽). **조작 불가**(켜고 끄기는 알림 팝업).
   꺼짐은 흐리게(opacity), 켜짐은 선명 + 초록 배경으로 한눈에 구분.
   ⚠️ cursor/hover/active 를 주지 않는다 — 누를 수 있어 보이면 안 된다. */
.detail-name .surge-ind { align-self: center; flex: none; display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 6px; background: transparent; font-size: 13px; line-height: 1; opacity: 0.45; filter: grayscale(1); }
.detail-name .surge-ind.has-surge { opacity: 1; filter: none; background: rgba(111,211,163,0.16); }
/* actions 의 '🔔 알림' 버튼 — kpi-more-btn 을 재사용하되(위임 핸들러가 이 클래스를 잡는다)
   생김새는 .actions button 계열로. 켜짐이면 메모의 has-note 와 같은 방식으로 강조. */
.actions .btn-surge-info { padding: 5px 10px; border-radius: 8px; border: 1px solid rgba(143,168,201,0.3); background: transparent; color: #b0b4be; font-size: 11px; font-weight: 600; line-height: 1.4; width: auto; height: auto; cursor: pointer; font-family: inherit; transition: background .12s, border-color .12s, color .12s; }
.actions .btn-surge-info:hover { background: rgba(143,168,201,0.12); }
.actions .btn-surge-info:active { transform: translateY(1px); }
.actions .btn-surge-info.has-surge { color: #6fd3a3; border-color: rgba(111,211,163,0.5); background: rgba(111,211,163,0.12); }
.note-head-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
.note-del-wrap { position: relative; display: inline-flex; }
.note-confirm { position: absolute; top: calc(100% + 6px); left: 0; display: flex; align-items: center; gap: 6px; background: #1b1f2a; border: 1px solid #3a2030; border-radius: 8px; padding: 6px 8px; box-shadow: 0 6px 18px rgba(0,0,0,0.4); white-space: nowrap; z-index: 5; }
.note-confirm.hidden { display: none; }
.note-confirm-msg { font-size: 11px; color: #e6c0c0; }
.note-confirm-yes, .note-confirm-no { font-size: 11px; font-weight: 600; padding: 3px 9px; border-radius: 6px; cursor: pointer; font-family: inherit; border: 1px solid transparent; }
.note-confirm-yes { background: #ff4d5e; color: #fff; }
.note-confirm-yes:hover { background: #e23a4b; }
.note-confirm-no { background: transparent; color: #aab2c4; border-color: rgba(160,170,190,0.35); }
.note-confirm-no:hover { background: rgba(160,170,190,0.10); }
.info-body { display: flex; flex-direction: column; gap: 4px; }
.info-grid { display: grid; grid-template-columns: max-content 1fr; gap: 7px 16px; align-items: baseline; }
.info-grid dt { color: #8b8f9a; font-size: 12px; }
.info-grid dd { margin: 0; font-size: 13px; font-variant-numeric: tabular-nums; text-align: right; color: #e8eaed; }
.info-grid dd .up { color: #ff4d5e; } .info-grid dd .down { color: #6aa9ff; }
.info-sec-title { color: #6c7180; font-size: 11px; font-weight: 700; letter-spacing: 0.04em; margin: 12px 0 4px; }
.info-sec-title:first-child { margin-top: 0; }
/* 기업정보 모달 탭 (요약/기업정보·가치/성장성·컨센서스/투자리포트) — 자산탭 sub-tab과 분리된 전용 클래스 */
.info-tabs { display: flex; gap: 2px; margin: 0 0 12px; border-bottom: 1px solid #1f2330; flex-wrap: wrap; }
.info-tabbtn { appearance: none; background: transparent; color: #8b8f9a; border: 0; border-bottom: 2px solid transparent; padding: 8px 11px; font-size: 13px; cursor: pointer; font-family: inherit; font-weight: 600; white-space: nowrap; }
.info-tabbtn:hover { color: #d6d8dd; }
.info-tabbtn.active { color: #f0f0f0; border-bottom-color: #ff4d5e; }
.info-tabpanel[hidden] { display: none; }
/* 탭 상단 해석 박스 — 좋은 점 / 주의할 점 */
.info-summary { background: #11161f; border: 1px solid #232a36; border-radius: 8px; padding: 10px 12px; margin: 0 0 12px; }
.info-jd + .info-jd { margin-top: 8px; }
.info-jd > b { display: block; font-size: 12px; margin-bottom: 3px; }
.info-jd-s > b { color: #8ab4f8; }
.info-jd-s-b { font-size: 12.5px; color: #d6d8dd; line-height: 1.65; }
.info-jd-g > b { color: #34d399; }
.info-jd-c > b { color: #fbbf24; }
.info-summary ul { margin: 0; padding-left: 17px; }
.info-summary li { font-size: 12.5px; color: #cfd5df; line-height: 1.65; margin: 1px 0; }
.info-q { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; padding: 0; margin-left: 4px; border-radius: 50%; border: 1px solid rgba(125,211,168,0.4); background: rgba(125,211,168,0.08); color: #7dd3a8; font-size: 10px; font-weight: 700; line-height: 1; cursor: pointer; font-family: inherit; vertical-align: middle; }
.info-q:hover { background: rgba(125,211,168,0.2); border-color: #7dd3a8; }
.info-q:active { transform: translateY(1px); }
.info-desc-box { max-width: 420px; }
.info-desc-body { font-size: 13px; line-height: 1.7; color: #d4d7dd; padding: 4px 2px 8px; }
/* 기업비교 바 */
.info-compare-bar { display: flex; flex-direction: column; gap: 6px; margin: 2px 0 10px; }
.info-search-row { display: flex; gap: 6px; }
.info-search-input { flex: 1; min-width: 0; background: #14171f; color: #e8eaed; border: 1px solid #1f2330; border-radius: 8px; padding: 8px 10px; font-size: 13px; font-family: inherit; }
.info-search-input:focus { outline: none; border-color: #7dd3a8; }
.info-search-btn { flex: 0 0 auto; background: rgba(125,211,168,0.1); color: #7dd3a8; border: 1px solid rgba(125,211,168,0.35); border-radius: 8px; padding: 8px 14px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; }
.info-search-btn:hover { background: rgba(125,211,168,0.18); }
.info-symbols-select { width: 100%; background: #14171f; color: #c9ccd3; border: 1px solid #1f2330; border-radius: 8px; padding: 8px 10px; font-size: 13px; font-family: inherit; color-scheme: dark; }
.info-search-results { display: flex; flex-wrap: wrap; gap: 6px; margin: -4px 0 10px; }
.info-search-results.hidden { display: none; }
.info-search-hit { background: #14171f; color: #e8eaed; border: 1px solid #2a2e3a; border-radius: 8px; padding: 6px 10px; font-size: 12px; cursor: pointer; font-family: inherit; }
.info-search-hit:hover { border-color: #7dd3a8; color: #7dd3a8; }
/* 비교표 */
.info-cmp-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.info-cmp-table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; }
.info-cmp-table th, .info-cmp-table td { padding: 8px 10px; text-align: right; font-size: 13px; white-space: nowrap; border-bottom: 1px solid #1c2030; }
.info-cmp-table thead th { color: #e8eaed; font-weight: 700; border-bottom: 1px solid #2a2e3a; position: relative; }
.info-cmp-table .info-cmp-label { text-align: left; color: #8b8f9a; font-weight: 500; position: sticky; left: 0; background: #0d1018; z-index: 1; }
.info-cmp-table thead .info-cmp-label { background: #0d1018; }
.info-cmp-name { color: #e8eaed; }
.info-cmp-remove { margin-left: 6px; width: 16px; height: 16px; padding: 0; border-radius: 50%; border: 1px solid rgba(255,138,149,0.4); background: rgba(255,138,149,0.08); color: #ff8a95; font-size: 11px; line-height: 1; cursor: pointer; font-family: inherit; vertical-align: middle; }
.info-cmp-remove:hover { background: rgba(255,138,149,0.2); }
.badge.badge-trade { color: #6c7180; background: transparent; border: 1px solid rgba(120,124,135,0.20); cursor: pointer; font-family: inherit; }
.badge.badge-trade:hover { color: #c9ccd3; border-color: rgba(201,204,211,0.38); background: rgba(201,204,211,0.04); }
.modal-box-wide { max-width: 720px; }
.trade-summary { margin: -6px 0 10px; line-height: 1.5; font-size: 11px; }
.trade-body { overflow-x: auto; }
.trade-table { width: 100%; border-collapse: collapse; font-size: 12px; font-variant-numeric: tabular-nums; }
.trade-table th, .trade-table td { padding: 7px 8px; border-bottom: 1px solid #1f2330; text-align: right; white-space: nowrap; }
.trade-table th { color: #8b8f9a; font-weight: 600; background: #0d1018; position: sticky; top: 0; }
.trade-table td.l, .trade-table th.l { text-align: left; }
.trade-table tr.seed td { background: rgba(255,255,255,0.025); color: #8b8f9a; }
.trade-table .pl-pos { color: #ff4d5e; }
.trade-table .pl-neg { color: #2c7be5; }
.trade-table .muted { color: #6c7180; }
.trade-table .side-buy { color: #ff4d5e; font-weight: 600; }
.trade-table .side-sell { color: #2c7be5; font-weight: 600; }
.trade-table .trade-stock-col { max-width: 110px; overflow: hidden; text-overflow: ellipsis; }
.trade-table td.l, .trade-table th.l { font-size: 10.5px; }
.trade-pager-wrap { display: block; }
.trade-pager { display: flex; align-items: center; justify-content: center; gap: 14px; margin-top: 12px; user-select: none; }
.trade-pager .pg-btn { background: #14171f; color: #c9ccd3; border: 1px solid #1f2330; border-radius: 8px; padding: 6px 14px; font-size: 14px; font-weight: 600; cursor: pointer; font-family: inherit; line-height: 1; }
.trade-pager .pg-btn:hover:not(:disabled) { border-color: #6aa9ff; color: #6aa9ff; }
.trade-pager .pg-btn:active:not(:disabled) { transform: translateY(1px); }
.trade-pager .pg-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.trade-pager .pg-indicator { color: #c9ccd3; font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; min-width: 56px; text-align: center; }
.trade-stock-col { cursor: pointer; -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
.trade-list { list-style: none; padding: 0; margin: 0; }
.trade-item { padding: 8px 10px; border-bottom: 1px solid #1f2330; display: flex; flex-direction: column; gap: 3px; font-variant-numeric: tabular-nums; }
.trade-item.seed { background: rgba(255,255,255,0.025); color: #8b8f9a; }
.trade-item.placeholder { visibility: hidden; }
body.modal-open { position: fixed; left: 0; right: 0; width: 100%; overflow: hidden; }
.ti-row1 { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; font-size: 12px; color: #8b8f9a; }
.ti-meta { display: flex; align-items: baseline; gap: 6px; flex: 1 1 auto; min-width: 0; flex-wrap: wrap; }
.ti-row2 { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; font-size: 13px; color: #e6e6e6; }
.ti-data { display: flex; align-items: baseline; gap: 6px; flex: 1 1 auto; min-width: 0; flex-wrap: wrap; }
.ti-side { font-weight: 600; }
.ti-side.side-buy { color: #ff4d5e; }
.ti-side.side-sell { color: #2c7be5; }
.ti-pl { font-weight: 600; font-size: 12px; flex: 0 0 auto; white-space: nowrap; }
.ti-pl.pl-pos { color: #ff4d5e; }
.ti-pl.pl-neg { color: #2c7be5; }
.ti-pl.muted { color: #6c7180; }
.ti-stock { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ti-row1 .ti-stock { color: #c9ccd3; font-weight: 500; }
.ti-sep { color: #4a4f5a; }
.ti-label { color: #6c7180; font-size: 11px; font-weight: 500; margin-right: 2px; }
.ti-stock-line { color: #c9ccd3; font-weight: 600; font-size: 12.5px; line-height: 1.25; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-bottom: 2px; }
.stock-name-tip {
  position: fixed; z-index: 2000; display: none;
  max-width: 260px; padding: 7px 11px;
  background: #1c2230; color: #f0f0f0;
  font-size: 13px; font-weight: 600; line-height: 1.35;
  border: 1px solid #2d3340; border-radius: 8px;
  box-shadow: 0 6px 20px rgba(0,0,0,0.55);
  pointer-events: none; word-break: keep-all; white-space: normal;
}
.stock-name-tip.show { display: block; }
.stock-name-tip::after {
  content: ''; position: absolute; left: 50%; bottom: -6px;
  width: 0; height: 0; transform: translateX(-50%);
  border-left: 6px solid transparent; border-right: 6px solid transparent;
  border-top: 6px solid #1c2230;
}
.stock-name-tip.below::after { top: -6px; bottom: auto; border-top: 0; border-bottom: 6px solid #1c2230; }
.owner-title-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
.btn-trades-all { background: #14171f; color: #c9ccd3; border: 1px solid rgba(201,204,211,0.28); border-radius: 8px; padding: 5px 10px; font-size: 11px; font-weight: 600; cursor: pointer; font-family: inherit; flex: 0 0 auto; }
.btn-trades-all:hover { background: rgba(201,204,211,0.06); border-color: #6aa9ff; color: #6aa9ff; }
.btn-trades-all:active { transform: translateY(1px); }
.trade-tabs { display: flex; gap: 2px; margin: 0 0 10px; border-bottom: 1px solid #1f2330; }
.trade-tab { background: transparent; border: 0; padding: 8px 14px; color: #8b8f9a; font-size: 12px; font-weight: 600; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; }
.trade-tab:hover { color: #c9ccd3; }
.trade-tab[aria-selected="true"] { color: #c9ccd3; border-bottom-color: #ff4d5e; }
.trade-tab-panel[hidden] { display: none; }
.trade-group-sub { font-variant-numeric: tabular-nums; margin: 0 0 8px; font-size: 11px; }

@media (max-width: 600px) {
  .row > summary { padding: 8px 14px; }
  .stock { font-size: 13px; }
  .code { font-size: 10px; }
  .price { font-size: 17px; }
  .diff { font-size: 12px; }
  .diff .pct { font-size: 12px; }
  .line2 { gap: 6px; font-size: 11px; }
  .badge { font-size: 10px; padding: 2px 7px; }
  .detail { padding: 6px 14px 14px; }
  .detail dl { grid-template-columns: 72px 1fr; }
  .section-label { padding: 18px 14px 6px; }
  details.row + .section-label { padding-top: 20px; }
}

/* ── 탭 (URL fragment + html[data-tab] attribute, FOUC 방지) ── */
/* head의 inline script가 body 렌더 전에 location.hash → documentElement.dataset.tab 동기화.
   첫 paint 시점에 이미 올바른 탭이 표시되어 새로고침 깜빡임 없음.
   섹션은 data-tab 속성으로 매칭 (id 충돌 회피). 탭 anchor 타겟은 없으므로 클릭해도 스크롤 안 일어남. */
nav.tabs {
  display: flex; gap: 0;
  overflow-x: auto; -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
}
nav.tabs::-webkit-scrollbar { display: none; }
nav.tabs a.tab-label {
  flex: none;
  padding: 12px 16px;
  cursor: pointer; user-select: none;
  font-size: 13px; font-weight: 500; color: #8b8f9a;
  border-bottom: 2px solid transparent;
  white-space: nowrap;
  transition: color 0.12s, border-color 0.12s;
  font-variant-numeric: tabular-nums;
  text-decoration: none;
}
nav.tabs a.tab-label:hover { color: #c9ccd3; }
.tab-content { display: none; }
/* 짧은 탭(자산정보 등)도 viewport를 채워 모바일 Safari 주소창 토글이 일관되게 동작하도록.
   ticker(fixed bottom) 위치가 다른 탭과 시각적으로 동일해진다. */
section.tab-content { min-height: calc(100dvh - 126px); }
@supports not (height: 100dvh) {
  section.tab-content { min-height: calc(100vh - 126px); }
}

/* ── 계좌현황 패널 ── */
.owner-card {
  margin: 14px 14px 8px;
  padding: 14px 16px;
  background: #11141c;
  border: 1px solid #1f2330;
  border-radius: 10px;
}
.owner-title { font-size: 15px; font-weight: 600; color: #f0f0f0; letter-spacing: -0.01em; }
/* 계좌 라벨 — owner-title 안 인라인(제목과 한 줄). 하단 여백은 owner-title-row가 갖는다. */
.owner-sub { margin-left: 6px; font-size: 11px; font-weight: 400; letter-spacing: 0; color: #5a5f6c; white-space: nowrap; }
.owner-card table.kpi { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
.owner-card table.kpi th {
  text-align: left; font-weight: 400; color: #8b8f9a;
  padding: 4px 8px 4px 0; font-size: 12px; width: 110px; vertical-align: top;
  white-space: nowrap;
}
.owner-card table.kpi td { padding: 4px 0; font-size: 13px; color: #e6e6e6; font-weight: 500; text-align: right; }
.owner-card table.kpi td .net { color: #f0f0f0; font-weight: 600; }
.owner-card table.kpi td .up { color: #ff4d5e; font-weight: 600; }
.owner-card table.kpi td .down { color: #4a8cf0; font-weight: 600; }
.owner-card table.kpi td .neutral { color: #c9ccd3; }
/* KPI 행 구분선 — 표시한 행 아래에 선 (_KPI_SEP_AFTER: 투자원금·보유종목/당일매매) */
.owner-card table.kpi tr.kpi-sep > th,
.owner-card table.kpi tr.kpi-sep > td { border-bottom: 1px solid #1f2330; padding-bottom: 9px; }
.owner-card table.kpi tr.kpi-sep + tr > th,
.owner-card table.kpi tr.kpi-sep + tr > td { padding-top: 9px; }

/* KPI 라벨 옆 작은 [!] — kpi-modal 팝업 (투자원금·당일 입출금) */
.owner-card table.kpi th .kpi-more-btn { display: inline-block; margin-left: 5px; width: 15px; height: 15px; padding: 0; border-radius: 50%; font-size: 10px; font-weight: 700; line-height: 13px; text-align: center; color: #5a5f6c; background: transparent; border: 1px solid rgba(120,124,135,0.35); cursor: pointer; font-family: inherit; vertical-align: middle; }
.owner-card table.kpi th .kpi-more-btn:hover { color: #d6b34a; border-color: rgba(214,179,74,0.55); }
.owner-card table.kpi th .kpi-more-btn:active { transform: translateY(1px); }

/* ── 급등락 알림 팝업 ── */
.sg-body { font-variant-numeric: tabular-nums; display: flex; flex-direction: column; gap: 10px; }
.sg-toggle { width: 100%; padding: 10px 12px; border-radius: 8px; border: 1px solid #2a3142; background: #11141c; color: #b0b4be; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
.sg-toggle:hover { background: #1c2030; }
.sg-toggle:active { transform: translateY(1px); }
.sg-toggle.has-surge { color: #6fd3a3; border-color: rgba(111,211,163,0.5); background: rgba(111,211,163,0.12); }
.sg-bands { display: flex; flex-direction: column; gap: 2px; }
.sg-band { display: grid; grid-template-columns: 1fr auto 56px; align-items: baseline; gap: 10px; padding: 6px 10px; border-radius: 6px; font-size: 12px; background: #11141c; }
.sg-band .sg-k { color: #8b90a0; }
.sg-band b { font-size: 13px; }
.sg-band .sg-p { text-align: right; font-size: 11px; opacity: 0.7; }
.sg-band.up b, .sg-band.up .sg-p { color: #ff6b63; }
.sg-band.down b, .sg-band.down .sg-p { color: #74acff; }
.sg-band.lim { background: rgba(224,168,60,0.1); }
.sg-band.lim .sg-k { color: #e0a83c; }
.sg-band.mid { background: transparent; border-top: 1px dashed #2a3142; border-bottom: 1px dashed #2a3142; border-radius: 0; }
.sg-band.mid b { color: #b0b4be; }
.sg-trow { display: grid; grid-template-columns: 68px 1fr; gap: 10px; align-items: baseline; font-size: 12px; }
.sg-trow > span { color: #8b90a0; }
.sg-chips { display: flex; flex-wrap: wrap; gap: 4px; }
.sg-chip { padding: 2px 7px; border-radius: 10px; font-size: 11px; border: 1px solid #2a3142; color: #8b90a0; }
.sg-chip.fired { color: #e0a83c; border-color: rgba(224,168,60,0.45); background: rgba(224,168,60,0.1); }
.sg-chip.pend { color: #ff6b63; border-color: rgba(255,107,99,0.45); border-style: dashed; }
.sg-note { line-height: 1.6; }
.sg-foot { font-size: 11px; color: #6f7484; line-height: 1.7; border-top: 1px solid #1a1e29; padding-top: 8px; }

/* ── 투자원금 팝업 ── */
.principal-body { font-variant-numeric: tabular-nums; }
.pm-sum { padding: 10px 12px; background: #11141c; border: 1px solid #1f2330; border-radius: 8px; }
.pm-srow { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; line-height: 2; }
.pm-srow span { color: #8b8f9a; }
.pm-srow b { color: #d7dae1; font-weight: 500; }
.pm-srow.strong { font-size: 14px; padding-bottom: 4px; margin-bottom: 4px; border-bottom: 1px solid #1f2330; }
.pm-srow.strong span { color: #c9ccd3; }
.pm-srow.strong b { color: #f0f0f0; font-weight: 600; }
.pm-shead { margin-top: 6px; padding-top: 6px; border-top: 1px solid #1f2330; font-size: 10px; color: #6b7080; }
.pm-warn { margin-top: 8px; font-size: 11px; line-height: 1.5; color: #d6b34a; }
.pm-note { margin: 10px 2px 0; font-size: 11px; line-height: 1.55; color: #6b7080; }
.pm-note-in { margin: 6px 0 0; }  /* 요약 박스 안쪽 주석 */
.pm-empty { margin-top: 12px; }
.pm-listhead { margin: 14px 2px 8px; font-size: 12px; font-weight: 600; color: #c9ccd3; }
/* 배당·이자 등 포함 토글 — 체크박스 + 형제 선택자로 JS 없이 필터 */
.pm-all-cb { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; }
.pm-all-lb { display: inline-block; margin: 0 2px 8px; padding: 3px 9px; border-radius: 5px; font-size: 11px; color: #5a5f6c; border: 1px dashed rgba(120,124,135,0.30); cursor: pointer; }
.pm-all-lb:hover { color: #d6b34a; border-color: rgba(214,179,74,0.45); }
.pm-all-cb:checked ~ .pm-all-lb { color: #d6b34a; border-color: rgba(214,179,74,0.45); border-style: solid; }
.pm-all-cb:not(:checked) ~ .pm-list .pm-row.other { display: none; }
.pm-list { display: flex; flex-direction: column; max-height: 46dvh; overflow-y: auto; }
.pm-row { display: grid; grid-template-columns: 74px 1fr auto auto; gap: 8px; align-items: baseline; padding: 6px 2px; border-bottom: 1px solid #14171f; font-size: 11px; }
.pm-row.other { opacity: 0.55; }
.pm-row .pm-d { color: #6b7080; }
.pm-row .pm-rk { color: #c9ccd3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pm-row .pm-ac { color: #5a5f6c; font-size: 10px; }
.pm-row .pm-amt { font-weight: 600; white-space: nowrap; }
.pm-row .pm-amt.in { color: #ff4d5e; }
.pm-row .pm-amt.out { color: #4a8cf0; }

.detail dl .num { font-variant-numeric: tabular-nums; }
.detail .up { color: #ff4d5e; font-weight: 600; }
.detail .down { color: #4a8cf0; font-weight: 600; }
.detail .neutral { color: #c9ccd3; }

.star { color: #d97706; font-size: 12px; flex: none; }
.star.buy { color: #ff4d5e; }
.star.sell { color: #4a8cf0; }
.star.buy + .star.sell { margin-left: 2px; }
.badge.phantom { background: #2a2618; color: #f5b441; border: 1px solid rgba(245,180,65,0.3); }

.row.mode-held .right .ref { color: #8b8f9a; }
.row.mode-phantom .price { font-size: 15px; }

.net-chart {
  margin: 12px 14px 0;
  padding: 10px 12px 8px;
  background: #11141c;
  border: 1px solid #1f2330;
  border-radius: 10px;
  position: relative;
}
.net-chart-head {
  display: flex; justify-content: space-between; align-items: center; gap: 8px;
  margin-bottom: 4px;
}
.net-chart-title { font-size: 12px; color: #8b8f9a; letter-spacing: -0.01em; }
.net-chart-delta { font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }
.net-chart-delta.up { color: #ff4d5e; }
.net-chart-delta.down { color: #4a8cf0; }
.net-chart-options {
  display: flex; flex-wrap: nowrap; gap: 6px;
  margin-top: 8px;
  overflow-x: auto; overflow-y: hidden;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: thin; scrollbar-color: #2a2e36 transparent;
  padding-bottom: 2px;
}
.net-chart-options::-webkit-scrollbar { height: 4px; }
.net-chart-options::-webkit-scrollbar-track { background: transparent; }
.net-chart-options::-webkit-scrollbar-thumb { background: #2a2e36; border-radius: 2px; }
/* 단위 토글(일/주/월/연) — 기간 토글 바로 아래에 분리 표시. */
.net-chart-options.nw-units { margin-top: 4px; }
.nw-chip {
  appearance: none; background: transparent; color: #c9ccd3;
  border: 1px solid #2a2e36; border-radius: 999px;
  padding: 5px 12px; font-size: 11px; cursor: pointer;
  font-variant-numeric: tabular-nums; line-height: 1;
  flex: none; white-space: nowrap;
}
.nw-chip:hover, .nw-chip:active { border-color: #3a3f4a; color: #f0f0f0; }
.nw-chip.active { background: #2a2e36; color: #f0f0f0; border-color: #4a4f5a; font-weight: 600; }
.net-chart-foot-r { display: inline-flex; align-items: center; gap: 6px; }
.nw-nav {
  appearance: none; background: transparent; color: #c9ccd3;
  border: 1px solid #2a2e36; border-radius: 4px;
  width: 22px; height: 22px; padding: 0;
  font-size: 9px; line-height: 1; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
}
.nw-nav:hover, .nw-nav:active { color: #f0f0f0; border-color: #3a3f4a; background: #1a1d24; }
.net-chart-svg {
  display: block; width: 100%; height: auto;
  font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
  touch-action: pan-y;
  cursor: crosshair;
}
.net-chart-svg .grid { stroke: #353b4a; stroke-width: 1; stroke-dasharray: 3 3; }
.net-chart-svg .period-sep { stroke: #4a5266; stroke-width: 1; stroke-dasharray: 2 4; opacity: 0.85; }
.net-chart-svg .grid-v { stroke: #353b4a; stroke-width: 1; stroke-dasharray: 3 3; opacity: 0.8; }
.net-chart-svg .zero-line { stroke: #8b93a7; stroke-width: 1.2; stroke-dasharray: 5 3; opacity: 0.9; }
.net-chart-svg .pt-dot { stroke: #0b0d12; stroke-width: 1.2; }
.net-chart-svg .axis { fill: #6b7280; font-size: 14px; font-variant-numeric: tabular-nums; }
.net-chart-svg .area { stroke: none; }
.net-chart-svg .line { fill: none; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round; }
.net-chart-svg .line.dashed { stroke-dasharray: 5 4; stroke-opacity: 0.85; }
.net-chart-svg .last-dot { stroke: #0b0d12; stroke-width: 1.5; }
.net-chart-svg .peak-dot { stroke: #0b0d12; stroke-width: 1.5; }
.net-chart-svg .low-dot { stroke: #0b0d12; stroke-width: 1.5; }
.net-chart-svg .hl-label { paint-order: stroke; stroke: #0b0d12; stroke-width: 3; }
.net-chart-svg .last-dot.live { stroke: #0b0d12; stroke-width: 2; animation: live-pulse 1.6s ease-in-out infinite; }
.net-chart-svg .live-halo { animation: live-halo 1.6s ease-in-out infinite; }
.net-chart-svg .ath-ring { animation: ath-pulse 1.8s ease-in-out infinite; }
.net-chart-svg .ath-label { paint-order: stroke; stroke: #0b0d12; stroke-width: 2.5; stroke-linejoin: round; }
@keyframes live-pulse {
  0%, 100% { r: 3.6; }
  50% { r: 4.4; }
}
@keyframes live-halo {
  0%, 100% { fill-opacity: 0.18; r: 6; }
  50% { fill-opacity: 0.32; r: 8; }
}
@keyframes ath-pulse {
  0%, 100% { r: 7; stroke-opacity: 0.95; }
  50% { r: 8.5; stroke-opacity: 0.55; }
}
.net-chart-svg .nw-hover-line { stroke: #d6d8dd; stroke-width: 1; stroke-dasharray: 2 3; opacity: 0.7; }
.net-chart-svg .nw-hover-dot { fill: #f0f0f0; stroke: #0b0d12; stroke-width: 1.5; }
.net-chart-tip {
  position: absolute;
  top: 30px;
  pointer-events: none;
  background: rgba(20, 22, 28, 0.94);
  border: 1px solid #2a2e36;
  border-radius: 6px;
  padding: 6px 24px 6px 8px;
  font-size: 11px;
  color: #d6d8dd;
  opacity: 0;
  transition: opacity 0.12s ease;
  white-space: nowrap;
  z-index: 5;
  min-width: 140px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
}
.tip-close {
  position: absolute; top: 2px; right: 2px;
  appearance: none; background: transparent; border: none;
  color: #6b7280; font-size: 11px; line-height: 1;
  width: 18px; height: 18px; padding: 0; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
  border-radius: 3px;
  pointer-events: auto;
}
.tip-close:hover, .tip-close:active { color: #f0f0f0; background: rgba(255,255,255,0.08); }
.net-chart-tip .tip-date { font-weight: 600; color: #f0f0f0; font-variant-numeric: tabular-nums; margin-bottom: 4px; font-size: 11px; }
.net-chart-tip .tip-row { display: flex; justify-content: space-between; gap: 12px; font-variant-numeric: tabular-nums; }
.net-chart-tip .tip-row[hidden] { display: none; }
.net-chart-tip .tip-row + .tip-row { margin-top: 2px; }
.net-chart-tip .tip-row .muted { color: #6b7280; font-weight: 400; }
.net-chart-tip .tip-row b { font-weight: 600; }
.net-chart-tip .up { color: #ff4d5e; }
.net-chart-tip .down { color: #4a8cf0; }
.net-chart-tip .neutral { color: #c9ccd3; }
.net-chart-foot {
  display: flex; justify-content: space-between; align-items: baseline;
  margin-top: 2px; font-size: 11px;
}
.net-chart-foot .muted { color: #6b7280; }
.net-chart-foot .net-chart-last { color: #f0f0f0; font-weight: 600; font-variant-numeric: tabular-nums; }
.chart-empty {
  margin: 12px 14px 0;
  padding: 12px;
  background: #11141c;
  border: 1px solid #1f2330;
  border-radius: 10px;
  font-size: 12px; color: #8b8f9a; text-align: center;
}
/* 시장 상황 카드 — 자산정보 탭의 시장정보 sub-tab 내부 (ADR + 투자자별 매매). */
.market-card {
  margin: 14px 14px 8px;
  padding: 14px 16px;
  background: #11141c;
  border: 1px solid #1f2330;
  border-radius: 10px;
}
.market-card-error {
  font-size: 12px; color: #8b8f9a; text-align: center; padding: 12px;
}
.market-card-title {
  font-size: 15px; font-weight: 600; color: #f0f0f0; letter-spacing: -0.01em;
  margin-bottom: 10px;
  display: flex; align-items: baseline; gap: 8px;
}
.market-card .market-date {
  font-size: 11px; color: #5a5f6c; font-weight: 400; font-variant-numeric: tabular-nums;
}
.market-refresh-btn {
  margin-left: auto; padding: 2px 9px; background: #1a1d24; color: #c9ccd3; border: 1px solid #2a2f3a;
  border-radius: 999px; cursor: pointer; font-family: inherit; font-size: 13px; line-height: 1;
  font-weight: 600; transition: background 0.15s, color 0.15s;
}
.market-refresh-btn:hover, .market-refresh-btn:active { background: #2a2f3a; color: #f0f0f0; }
.market-refresh-btn.spinning { animation: market-spin 0.6s linear infinite; }
@keyframes market-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.market-idx-info {
  margin-left: 6px; padding: 0 5px; background: transparent; color: #6b7280; border: 1px solid #2a2f3a;
  border-radius: 50%; font-family: inherit; font-size: 10px; line-height: 16px; cursor: pointer; vertical-align: middle;
  font-weight: 600; transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.market-idx-info:hover { color: #ff8a95; border-color: #ff8a95; background: rgba(255,138,149,0.08); }
.idx-info-body {
  padding: 14px 16px; font-size: 14px; color: #c9ccd3; line-height: 1.6; letter-spacing: -0.01em;
}
.idx-info-stats {
  display: flex; align-items: baseline; gap: 12px; padding-bottom: 10px;
  margin-bottom: 10px; border-bottom: 1px solid #1f2330; font-variant-numeric: tabular-nums;
}
.idx-info-price { font-size: 22px; font-weight: 700; color: #f0f0f0; }
.idx-info-change { font-size: 14px; font-weight: 600; }
.idx-info-change.up { color: #ff4d5e; }
.idx-info-change.down { color: #4a8cf0; }
.idx-info-change.flat { color: #8b8f9a; }
.idx-info-desc {
  font-size: 13px; color: #c9ccd3; white-space: pre-line; line-height: 1.7;
}
.idx-info-chart {
  margin: 0 0 12px; padding: 8px 10px; background: #0e1117; border: 1px solid #1f2330; border-radius: 8px;
  position: relative;
}
.idx-chart-svg { display: block; width: 100%; height: auto; cursor: crosshair; }
.idx-chart-svg .idx-axis { fill: #6b7280; font-size: 9px; font-variant-numeric: tabular-nums; }
.idx-chart-svg .idx-grid { stroke: #1f2330; stroke-width: 1; stroke-dasharray: 2 3; }
.idx-chart-svg .idx-hover-line { stroke: #d6d8dd; stroke-width: 1; stroke-dasharray: 2 3; opacity: 0.75; }
.idx-chart-svg .idx-hover-dot { stroke: #0b0d12; stroke-width: 1.5; }
.idx-chart-tip {
  position: absolute; top: 6px; right: 14px; background: #14171f; border: 1px solid #2a2f3a;
  border-radius: 6px; padding: 4px 8px; font-size: 11px; color: #f0f0f0;
  display: flex; gap: 8px; align-items: center; font-variant-numeric: tabular-nums;
}
.idx-chart-tip[hidden] { display: none; }
.idx-chart-tip .tip-d { color: #8b8f9a; pointer-events: none; }
.idx-chart-tip .tip-v { font-weight: 600; pointer-events: none; }
.idx-chart-tip .tip-close {
  position: static; width: auto; height: auto;
  background: transparent; color: #8b8f9a; border: 0; padding: 0 4px; cursor: pointer;
  font-family: inherit; font-size: 14px; line-height: 1; margin-left: 4px;
  display: inline-flex; align-items: center;
}
.idx-chart-tip .tip-close:hover { color: #ff8a95; background: transparent; }
.idx-chart-loading, .idx-chart-err {
  padding: 24px 0; text-align: center; font-size: 12px; color: #8b8f9a;
}
.idx-chart-err { color: #ff8a95; }
.idx-chart-foot {
  display: flex; justify-content: space-between; margin-top: 6px;
  font-size: 11px; color: #5a5f6c; font-variant-numeric: tabular-nums;
}
.market-section { margin-top: 10px; }
.market-section:first-of-type { margin-top: 0; }
.market-section-head {
  display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
  margin-bottom: 4px;
}
.market-section-label { font-size: 12px; color: #d6d8dd; font-weight: 600; }
.market-section-hint { font-size: 10px; color: #5a5f6c; }
table.market-table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
table.market-table th {
  text-align: left; font-weight: 400; color: #8b8f9a;
  padding: 4px 8px 4px 0; font-size: 11px; vertical-align: middle;
}
table.market-table td { padding: 4px 0; font-size: 13px; color: #e6e6e6; font-weight: 500; text-align: right; }
table.market-table thead th { text-align: right; font-size: 10px; color: #6b7280; padding-bottom: 2px; }
table.market-table thead th:first-child { text-align: left; }
table.market-table .up { color: #ff4d5e; font-weight: 600; }
table.market-table .down { color: #4a8cf0; font-weight: 600; }
table.market-table .flat { color: #c9ccd3; }
table.market-table .neutral { color: #8b8f9a; }
table.market-adr th { width: 64px; }
table.market-adr td.adr-val { width: 104px; }
table.market-adr td.adr-val b { font-size: 14px; }
.adr-line { line-height: 1.45; white-space: nowrap; }
.adr-when { font-size: 10px; color: #8b8f9a; margin-right: 3px; }
table.market-adr td.adr-breakdown { font-size: 11px; color: #8b8f9a; }
.adr-tag {
  display: inline-block; padding: 1px 6px; border-radius: 999px;
  font-size: 9px; font-weight: 600; letter-spacing: 0.04em;
  margin-left: 4px; vertical-align: middle;
}
.adr-tag.up { background: rgba(255,77,94,0.16); color: #ff8a96; border: 1px solid rgba(255,77,94,0.3); }
.adr-tag.down { background: rgba(74,140,240,0.16); color: #8db4f4; border: 1px solid rgba(74,140,240,0.3); }
.adr-tag.flat { background: #1a1d24; color: #8b8f9a; border: 1px solid #2a2e36; }
.adr-sub { margin-top: 2px; font-size: 10px; line-height: 1.1; color: #8b8f9a; }
/* ADR 기본정보 표 — 시장별 20일/오늘 ADR + 종목수. market-table 기반, 시장 컬럼만 좌측정렬. */
table.adr-info-table { margin: 4px 0 2px; }
table.adr-info-table td { padding: 5px 0 5px 8px; vertical-align: middle; }
table.adr-info-table th { padding: 0 0 3px 8px; }
table.adr-info-table td.adr-mk, table.adr-info-table th:first-child { text-align: left; padding-left: 0; color: #c9ccd3; }
table.adr-info-table td.adr-mk { font-weight: 600; }
table.adr-info-table td b { font-size: 14px; }
table.adr-info-table .adr-tag { margin-left: 0; margin-right: 5px; }
table.adr-info-table td.adr-breakdown { font-size: 11px; color: #8b8f9a; white-space: nowrap; }
.adr-cnt-sub { font-size: 9px; color: #6b7280; font-weight: 400; }
@media (max-width: 600px) {
  table.adr-info-table td.adr-breakdown { font-size: 10px; }
  table.adr-info-table td b { font-size: 13px; }
}
@media (max-width: 600px) {
  .market-card { margin: 10px 10px 6px; padding: 12px 14px; }
  table.market-table td { font-size: 12px; }
  table.market-adr th { width: 56px; font-size: 11px; }
  table.market-adr td.adr-breakdown { font-size: 10px; }
}
/* ADR 추세 카드 — 코스피·코스닥 한 SVG 안에 통합. 시장정보 sub-tab 내부. */
.adr-trend-card .market-card-sub {
  font-size: 11px; color: #5a5f6c; font-weight: 400; margin-left: 4px;
}
.adr-trend-card .market-section-hint { margin-bottom: 8px; }
.adr-combo-meta {
  display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap;
  margin: 8px 0 4px;
  font-variant-numeric: tabular-nums;
}
.adr-legend {
  display: inline-flex; align-items: center; gap: 5px;
  font-size: 11px; color: #d6d8dd; font-weight: 500;
}
.adr-legend-swatch {
  display: inline-block; width: 10px; height: 10px; border-radius: 2px;
}
.adr-combo-range { margin-left: auto; }
.adr-trend-row {
  position: relative;
  margin-top: 6px;
  touch-action: pan-y;
}
.adr-trend-svg {
  width: 100%; height: 100px; display: block;
  font-variant-numeric: tabular-nums;
}
/* 호버 인디케이터 — 세로선 1 + 시장당 점. net-chart nw-hover 패턴 확장. */
.adr-trend-svg .adr-hover { transition: opacity 0.1s; }
.adr-trend-svg .adr-hover-line { stroke: #d6d8dd; stroke-width: 1; stroke-dasharray: 2 3; opacity: 0.7; }
.adr-trend-svg .adr-hover-dot { stroke-width: 1.2; }
/* 선택 지점 정보 — 차트 아래 정적 정보줄(구 플로팅 툴팁 대체). */
.adr-trend-info {
  display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 14px;
  margin-top: 7px; font-size: 12px; color: #d6d8dd;
  font-variant-numeric: tabular-nums;
}
.adr-trend-info .tip-d { font-weight: 600; color: #f0f0f0; }
.adr-trend-info .tip-line { display: inline-flex; align-items: baseline; gap: 5px; }
.adr-trend-info .tip-line[hidden] { display: none; }
.adr-trend-info .tip-mk { font-size: 11px; color: #8b8f9a; }
.adr-trend-info .tip-kospi .tip-mk { color: #fbbf24; }
.adr-trend-info .tip-kosdaq .tip-mk { color: #34d399; }
.adr-trend-info .tip-v { font-weight: 700; }
.adr-trend-info .tip-v.up { color: #ff8a96; }
.adr-trend-info .tip-v.down { color: #8db4f4; }
.adr-trend-info .tip-v.flat { color: #d6d8dd; }
.adr-trend-info .tip-v.live::after { content: " *"; color: #fbbf24; font-weight: 600; }
.adr-trend-empty {
  display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
  padding: 8px 0;
}
.adr-trend-controls {
  display: flex; gap: 6px; margin: 8px 0 4px; flex-wrap: wrap;
}
.adr-range-btn {
  appearance: none; -webkit-appearance: none;
  background: #11141c; color: #c9ccd3;
  border: 1px solid #2a2e36; border-radius: 8px;
  padding: 5px 11px; font-size: 11px; font-weight: 500; font-family: inherit;
  cursor: pointer;
  font-variant-numeric: tabular-nums;
}
.adr-range-btn:hover, .adr-range-btn:active { background: #1a1d26; color: #f0f0f0; }
.adr-range-btn.active {
  background: rgba(251,191,36,0.12);
  color: #fbbf24;
  border-color: rgba(251,191,36,0.4);
}
.adr-range-btn:focus-visible { outline: 1px solid #4a4f5a; outline-offset: 1px; }
/* 오늘 ADR 블록 — 카드 기본(접힘) 상태에 항상 노출. 시장별 한 줄(값·판정·종목수). */
.adr-today-block {
  display: flex; flex-direction: column; gap: 4px;
  margin: 6px 0 2px; font-variant-numeric: tabular-nums;
}
.adr-today-block .adr-today-when { font-size: 11px; color: #8b8f9a; font-weight: 500; }
.adr-today-item {
  display: flex; align-items: baseline; flex-wrap: wrap; gap: 3px 8px;
  font-size: 13px; color: #d6d8dd;
}
.adr-today-mk { font-size: 11px; color: #8b8f9a; min-width: 38px; }
.adr-today-item b { font-weight: 700; }
.adr-today-item b.up { color: #ff8a96; }
.adr-today-item b.down { color: #8db4f4; }
.adr-today-item b.neutral { color: #d6d8dd; }
.adr-today-breakdown { font-size: 11px; color: #8b8f9a; margin-left: auto; }
/* 추세 그래프 토글 버튼 — .adr-range-btn 톤 재사용. */
.adr-graph-toggle {
  appearance: none; -webkit-appearance: none;
  background: #11141c; color: #c9ccd3;
  border: 1px solid #2a2e36; border-radius: 8px;
  padding: 6px 12px; margin: 6px 0 2px; font-size: 11px; font-weight: 500; font-family: inherit;
  cursor: pointer;
}
.adr-graph-toggle:hover, .adr-graph-toggle:active { background: #1a1d26; color: #f0f0f0; }
.adr-graph-toggle[aria-expanded="true"] {
  background: rgba(251,191,36,0.12); color: #fbbf24; border-color: rgba(251,191,36,0.4);
}
.adr-graph-toggle:focus-visible { outline: 1px solid #4a4f5a; outline-offset: 1px; }
.adr-graph-wrap[hidden] { display: none; }
.adr-live-badge {
  display: inline-block; margin-left: 6px;
  font-size: 9px; font-weight: 600; letter-spacing: 0.04em;
  color: #fbbf24;
}
/* 자산정보 탭 내부 sub-tab — 자산보기/차트보기. 상단 배치는 밑줄 강조, 하단 배치는 일반 버튼 형태. */
.sub-tabs { display: flex; gap: 4px; margin: 8px 14px 0; border-bottom: 1px solid #1f2330; }
.sub-tab { appearance: none; background: transparent; color: #8b8f9a; border: 0; border-bottom: 2px solid transparent; padding: 8px 14px; font-size: 13px; cursor: pointer; font-family: inherit; font-weight: 500; }
.sub-tab:hover, .sub-tab:active { color: #d6d8dd; }
.sub-tab.active { color: #f0f0f0; border-bottom-color: #ff4d5e; }
.sub-tab-panel[hidden] { display: none; }
/* 하단 배치 — 탭 강조선 제거하고 둥근 버튼 형태. */
.sub-tabs.sub-tabs-bottom { margin: 14px 14px; border: 0; padding: 0; gap: 8px; justify-content: center; }
.sub-tabs.sub-tabs-bottom .sub-tab {
  border: 1px solid #2a2e36;
  border-radius: 8px;
  background: #11141c;
  color: #c9ccd3;
  padding: 8px 18px;
}
.sub-tabs.sub-tabs-bottom .sub-tab:hover, .sub-tabs.sub-tabs-bottom .sub-tab:active { background: #1a1d26; color: #f0f0f0; }
.sub-tabs.sub-tabs-bottom .sub-tab.active {
  background: #2a1820;
  color: #ff8a96;
  border-color: rgba(255,77,94,0.32);
}

/* 합산 / 각계좌별도 단일 토글 버튼 — 상단 자동토글 옆 1곳에만 배치. 클릭마다 두 상태 swap.
   data-account-view-state 속성으로 현재 상태 표시. 기본(합산)은 중성색, 각계좌별도일 때 강조. */
.account-view-toggle-btn {
  flex: none; display: inline-flex; align-items: center; justify-content: center;
  min-width: 36px; height: 36px; padding: 0 10px;
  background: #1c2230; border: 1px solid #2a3142; color: #e6e6e6;
  border-radius: 8px; font-size: 17px; line-height: 1; cursor: pointer;
  font-family: inherit; user-select: none;
  transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.1s;
}
.account-view-toggle-btn:hover { background: #232a3b; }
.account-view-toggle-btn:active { transform: scale(0.95); }
.account-view-toggle-btn[data-account-view-state="by-account"] {
  background: rgba(255,77,94,0.15); border-color: #ff4d5e; color: #ff4d5e;
}
.account-view-pane[hidden] { display: none; }

/* 옵션 버튼 + 작은 팝업 메뉴 — 상단 자동토글 왼쪽. 클릭하면 팝업 토글, 안에 계좌 나누기 버튼 등을 담는다. */
.opts-wrap { position: relative; flex: none; display: inline-flex; }
.opts-btn {
  flex: none; display: inline-flex; align-items: center; justify-content: center;
  min-width: 36px; height: 36px; padding: 0 10px;
  background: #1c2230; border: 1px solid #2a3142; color: #e6e6e6;
  border-radius: 8px; font-size: 13px; line-height: 1; cursor: pointer;
  font-family: inherit; user-select: none;
  transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.1s;
}
.opts-btn:hover { background: #232a3b; }
.opts-btn:active { transform: scale(0.95); }
.opts-btn[aria-expanded="true"] { background: #232a3b; border-color: #3a4256; }
.opts-popup {
  position: absolute; top: calc(100% + 6px); right: 0; z-index: 60;
  background: #161b27; border: 1px solid #2a3142; border-radius: 10px;
  padding: 6px; box-shadow: 0 8px 24px rgba(0,0,0,0.45);
  display: flex; flex-direction: column; gap: 4px; min-width: 168px;
}
.opts-popup[hidden] { display: none; }
/* 팝업 메뉴 항목 공통 스타일 — 가로 꽉 채우고 좌측 정렬 + 아이콘. 자립형(다른 버튼 클래스 없이도 동일). */
.opts-item {
  display: flex; align-items: center; justify-content: flex-start;
  width: 100%; min-width: 0; height: auto; padding: 9px 11px; gap: 9px;
  background: #1c2230; border: 1px solid #2a3142; color: #e6e6e6;
  border-radius: 8px; font-size: 13px; font-weight: 600; line-height: 1;
  font-family: inherit; cursor: pointer; user-select: none; text-align: left;
  transition: background 0.15s, color 0.15s, border-color 0.15s, transform 0.1s;
}
.opts-item:hover { background: #232a3b; }
.opts-item:active { transform: scale(0.95); }
.holdings-toggle-wrap { margin-top: 4px; }
/* 등락률 계산기 모달 결과 영역 */
.calc-result { margin-top: 14px; padding: 12px 14px; background: #14171f; border: 1px solid #232838; border-radius: 10px; }
.calc-line { display: flex; align-items: baseline; justify-content: space-between; padding: 4px 0; }
.calc-lbl { font-size: 13px; color: #8b8f9a; }
.calc-val { font-size: 22px; font-weight: 700; color: #8b8f9a; font-variant-numeric: tabular-nums; }
.calc-hint { margin-top: 6px; font-size: 12px; color: #7a8493; }
.calc-hint:empty { display: none; }
.calc-sym-row { display: block; margin-top: 12px; font-size: 13px; color: #8b8f9a; }
.calc-sym-row .order-symbol-select { margin-top: 5px; max-width: none; }
.calc-sym-status { margin-top: 6px; font-size: 12px; color: #8b8f9a; min-height: 1em; }
.calc-sym-status:empty { display: none; }
.calc-price-row { display: flex; gap: 6px; align-items: center; }
.calc-price-row input { flex: 1 1 auto; min-width: 0; }
.calc-curbtn { flex: 0 0 auto; padding: 0 10px; height: 34px; border: 1px solid #2f6df0; border-radius: 8px; background: #1a2436; color: #7fb0ff; font-size: 12px; font-weight: 600; cursor: pointer; white-space: nowrap; }
.calc-curbtn:active { background: #23324d; }
.calc-sub { font-size: 11px; color: #6b7280; font-weight: 400; }
.he-save-row { margin-top: 12px; }
.he-save-btn { width: 100%; padding: 10px; border: 1px solid rgba(125,211,168,0.35); border-radius: 8px; background: rgba(125,211,168,0.1); color: #7dd3a8; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; }
.he-save-btn:active { background: rgba(125,211,168,0.2); }
.he-saved-wrap { margin-top: 16px; }
.he-saved-title { font-size: 12px; color: #8b8f9a; margin-bottom: 6px; }
.he-saved-list { display: flex; flex-direction: column; gap: 6px; }
.he-saved-row { display: flex; align-items: center; gap: 8px; padding: 8px 10px; background: #14171f; border: 1px solid #232838; border-radius: 8px; }
.he-saved-pick { flex: 1 1 auto; min-width: 0; text-align: left; background: none; border: none; color: #e8eaed; font-size: 13px; cursor: pointer; font-family: inherit; padding: 0; }
.he-saved-pick .he-saved-meta { color: #8b8f9a; font-size: 12px; }
.he-saved-edit { flex: 0 0 auto; background: none; border: none; color: #6b7280; font-size: 14px; cursor: pointer; padding: 0 4px; }
.he-saved-edit:hover { color: #7dd3a8; }
.he-saved-del { flex: 0 0 auto; background: none; border: none; color: #6b7280; font-size: 16px; cursor: pointer; padding: 0 4px; }
.he-saved-del:hover { color: #ff4d5e; }
.he-saved-pick.active { color: #ffcc33; }
.he-add-row { margin-top: 10px; }
.he-add-btn { width: 100%; padding: 10px; border: 1px dashed #3a4152; border-radius: 8px; background: transparent; color: #8b8f9a; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; }
.he-add-btn:active { background: #14171f; }
.he-result-headrow { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
.he-result-head { font-size: 12px; color: #8b8f9a; min-width: 0; }
.he-refresh-btn { flex: 0 0 auto; }
.he-refresh-btn.hidden { display: none; }
.he-merge-wrap { margin-top: 12px; padding: 10px; border: 1px dashed #2f6df0; border-radius: 8px; background: rgba(47,109,240,0.06); }
.he-merge-wrap.hidden { display: none; }
.he-merge-title { font-size: 12px; color: #8b8f9a; margin-bottom: 8px; }
.he-detail-sep { height: 1px; background: #232838; margin: 8px 0; }
.calc-line.he-detail { padding: 2px 0; }
.calc-line.he-detail .calc-lbl { font-size: 12px; }
.calc-line.he-detail .calc-val { font-size: 14px; font-weight: 600; color: #c9ccd3; }

/* 차트 컨트롤 — 자산정보 탭 상단의 공통 기간·단위 select 묶음. */
.chart-controls { display: flex; flex-direction: column; gap: 6px; margin: 10px 14px 4px; }
.chart-controls .cc-line { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.chart-controls .cc-line-ym[hidden] { display: none; }  /* 지정기간 아닐 때 2번째 줄 숨김 ([hidden] UA 스타일이 flex에 가려지지 않도록 명시) */
.chart-controls .cc-field { display: flex; align-items: center; gap: 6px; }
.chart-controls .cc-label { color: #8b8f9a; font-size: 11px; }
.chart-controls .nw-select {
  appearance: none; -webkit-appearance: none;
  background: #11141c url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><path fill='%238b8f9a' d='M2 4l4 4 4-4'/></svg>") no-repeat right 8px center;
  background-size: 10px;
  color: #d6d8dd; border: 1px solid #2a2e36; border-radius: 8px;
  padding: 6px 26px 6px 10px; font-size: 12px; cursor: pointer; font-family: inherit;
  min-width: 96px;
}
.chart-controls .nw-select:focus { outline: 1px solid #4a4f5a; outline-offset: 0; }
/* 2번째 줄(유형/연도/월) — 연도/월은 내용 크기로(월 줄바뀜 방지), 유형은 기간과 동일 폭. 글씨는 1번째 줄과 동일. */
.chart-controls .cc-line-ym { gap: 8px; }
.chart-controls .cc-line-ym .nw-select { min-width: 0; }
.chart-controls .cc-line-ym .nw-select[data-nw-ymtype] { min-width: 96px; }  /* 유형 = 기간 select 와 동일 폭 */

@media (max-width: 600px) {
  nav.tabs .tab-label { padding: 10px 12px; font-size: 12px; }
  .owner-card { margin: 10px 10px 6px; padding: 12px 14px; }
  .owner-card table.kpi th { width: auto; font-size: 11px; }
  .owner-card table.kpi td { font-size: 12px; }
  .net-chart { margin: 10px 10px 0; padding: 8px 10px 6px; }
  /* 모바일은 viewBox(800) > viewport 라 scale 0.5 정도 — viewBox unit 폰트를 크게 잡아 화면에서 적당히 보이게. */
  .net-chart-svg .axis { font-size: 22px; }
}

/* 주문 모달 (order-modal) — 거래내역 보기용 trade-modal과 별개. 모달 박스 자체 스크롤 X (호가창만 스크롤) */
.order-box { max-width: 640px; max-height: calc(100dvh - env(safe-area-inset-top) - 56px); overflow-y: hidden; display: flex; flex-direction: column; transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; }
.order-box > [data-order-step="1"] { display: flex; flex-direction: column; flex: 1; min-height: 0; }
/* 매수/매도 모드 전체 색조 — 모달 어디를 봐도 side가 한눈에 (매수=빨강/매도=파랑) */
.order-box.side-buy  { border-color: #c9303e; box-shadow: 0 0 0 1px #c9303e, 0 12px 44px rgba(201,48,62,0.34); background: linear-gradient(180deg, rgba(201,48,62,0.12), rgba(201,48,62,0.02) 130px, #0d1018 300px); }
.order-box.side-sell { border-color: #2a5fb3; box-shadow: 0 0 0 1px #2a5fb3, 0 12px 44px rgba(42,95,179,0.34); background: linear-gradient(180deg, rgba(42,95,179,0.12), rgba(42,95,179,0.02) 130px, #0d1018 300px); }
.order-box.side-buy  .modal-head { border-bottom: 2px solid rgba(201,48,62,0.55); padding-bottom: 10px; }
.order-box.side-sell .modal-head { border-bottom: 2px solid rgba(42,95,179,0.55); padding-bottom: 10px; }
.order-box.side-buy  .orderbook { border-color: rgba(201,48,62,0.38); }
.order-box.side-sell .orderbook { border-color: rgba(42,95,179,0.38); }
/* 주문 방향 확인 팝업 — 매수·매도 버튼을 직접 골라 누르게 함(위치 랜덤). 오타 방지 */
#order-confirm-modal .btn-confirm { font-size: 16px; font-weight: 700; padding: 15px 14px; }
.order-symbol-select { background: #14171f; color: #f0f0f0; border: 1px solid #1f2330; border-radius: 6px; padding: 7px 9px; font-size: 14px; font-weight: 600; font-family: inherit; width: 100%; max-width: 360px; cursor: pointer; color-scheme: dark; }
.order-symbol-select:focus { outline: none; border-color: #ff4d5e; }
.order-symbol-select option { background: #14171f; color: #f0f0f0; }
/* native select 화살표(chevron) — 다크 테마로 밝은 색 강제 */
.order-inputs select, .open-orders-box select { color-scheme: dark; }
.order-meta { display: flex; gap: 8px; align-items: baseline; margin-bottom: 12px; flex-wrap: nowrap; }
.order-meta .cur-price { font-size: clamp(14px, 4.5vw, 22px); font-weight: 700; color: #f0f0f0; font-variant-numeric: tabular-nums; white-space: nowrap; flex-shrink: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.order-meta .change { font-size: clamp(10px, 2.8vw, 13px); font-variant-numeric: tabular-nums; white-space: nowrap; }
.order-meta .change.up { color: #ff5b69; }
.order-meta .change.down { color: #5b9bff; }
.order-market-phase { font-size: 10px; padding: 2px 7px; border-radius: 4px; margin-left: auto; font-variant-numeric: tabular-nums; }
.order-market-phase.regular { background: rgba(31,90,40,0.35); color: #8aff95; border: 1px solid rgba(31,90,40,0.6); }
.order-market-phase.nxt { background: rgba(86,42,138,0.35); color: #d3a7ff; border: 1px solid rgba(86,42,138,0.6); }
.order-market-phase.closed, .order-market-phase.holiday, .order-market-phase.weekend { background: rgba(90,31,40,0.35); color: #ff8a95; border: 1px solid rgba(90,31,40,0.6); }
.order-side-toggle { display: flex; gap: 0; margin-bottom: 12px; border-radius: 8px; overflow: hidden; border: 1px solid #1f2330; }
.order-side-toggle button { flex: 1; padding: 10px 12px; background: #14171f; color: #c9ccd3; border: 0; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; border-right: 1px solid #1f2330; }
.order-side-toggle button:last-child { border-right: 0; }
.order-side-toggle button.active[data-side="BUY"] { background: #c9303e; color: #fff; }
.order-side-toggle button.active[data-side="SELL"] { background: #2a5fb3; color: #fff; }
.order-side-toggle button.info-tab { color: #8db4ff; max-width: 100px; }
.order-side-toggle button.info-tab:hover { background: #1f2840; color: #c9e0ff; }
/* 좌우 2-컬럼 강제 (호가창 좁게, 인풋 영역 넓게). flex로 grid 안 남은 공간 채워 stretch */
.order-body { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.25fr); grid-template-rows: minmax(0, 1fr); gap: 10px; align-items: stretch; flex: 1; min-height: 0; }
/* 호가창만 폰트 ~70% 압축, 별도 스크롤. height 100%로 인풋 영역과 동일 stretch */
.orderbook { display: flex; flex-direction: column; justify-content: center; border: 1px solid #1f2330; border-radius: 6px; overflow-y: auto; overflow-x: hidden; height: 100%; min-height: 0; scrollbar-width: thin; }
.orderbook::-webkit-scrollbar { width: 4px; }
.orderbook::-webkit-scrollbar-thumb { background: #2a2e3a; border-radius: 2px; }
.orderbook .ob-row { display: grid; grid-template-columns: 1fr 1fr; padding: 1px 5px; font-size: 7.5px; line-height: 1.35; font-variant-numeric: tabular-nums; cursor: pointer; border-bottom: 1px solid #15171e; transition: box-shadow 0.08s; color: #565c68; }
.orderbook .ob-row:last-of-type { border-bottom: 0; }
.orderbook .ob-row.ask { background: linear-gradient(to left, rgba(42,95,179,0.16), transparent 60%); color: #8db4ff; }
.orderbook .ob-row.bid { background: linear-gradient(to left, rgba(201,48,62,0.16), transparent 60%); color: #ff8a95; }
.orderbook .ob-row:hover { background: #1c1f29; }
.orderbook .ob-row.ob-current { background-color: rgba(230,230,230,0.13); color: #fff; font-weight: 700; box-shadow: inset 3px 0 0 #e6e6e6; }
.orderbook .ob-row.ob-synthetic { background: rgba(230,230,230,0.10); }
.orderbook .ob-row.ob-synthetic .qty { opacity: 0.85; color: #d0d0d0; font-size: 6.5px; }
.orderbook .ob-row.ob-cap { background: #12141b; }
.orderbook .ob-cap-mark { display: inline-block; font-size: 6.5px; font-weight: 800; padding: 0 2px; border-radius: 2px; margin-right: 1px; vertical-align: middle; }
.orderbook .ob-cap-mark.up { color: #ff8a95; background: rgba(201,48,62,0.28); }
.orderbook .ob-cap-mark.down { color: #8db4ff; background: rgba(42,95,179,0.28); }
.orderbook .ob-row.selected { box-shadow: inset 0 0 0 1.5px #ffcc33; color: #fff !important; font-weight: 700; background-color: rgba(255,204,51,0.18) !important; }
.orderbook .ob-row .price { text-align: left; }
.orderbook .ob-row .qty { text-align: right; opacity: 0.8; font-size: 7px; }
.price-tick-buttons { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 4px; margin-top: 4px; }
.price-tick-buttons button { background: #14171f; color: #c9ccd3; border: 1px solid #1f2330; border-radius: 5px; padding: 5px 4px; font-size: 11px; cursor: pointer; font-family: inherit; }
.price-tick-buttons button:hover { border-color: #ff4d5e; color: #f0f0f0; }
.price-tick-buttons button:active { transform: translateY(1px); }
.qty-step-buttons { display: grid; grid-template-columns: repeat(5, 1fr); gap: 3px; margin-top: 4px; }
.qty-step-buttons button { background: #14171f; color: #c9ccd3; border: 1px solid #1f2330; border-radius: 5px; padding: 5px 2px; font-size: 10px; cursor: pointer; font-family: inherit; }
.qty-step-buttons button:hover { border-color: #ff4d5e; color: #f0f0f0; }
.qty-step-buttons button:active { transform: translateY(1px); }
.qty-step-buttons button[data-qty-step="100%"],
.qty-step-buttons button[data-qty-step="max"] { background: #2a1f24; color: #ffcc77; }
.qty-step-buttons button[data-qty-step="clear"] { background: #1d1418; color: #ff8a95; }
.order-inputs input:disabled, .order-inputs select:disabled { opacity: 0.5; cursor: not-allowed; }
/* overflow-x: hidden 필수 — overflow-y:auto 만 주면 브라우저가 가로도 auto 로 만들어,
   내부가 조금만 넘쳐도 가로 스크롤이 생긴다(.orderbook 도 같은 이유로 명시). */
.order-inputs { display: flex; flex-direction: column; gap: 8px; min-height: 0; overflow-y: auto; overflow-x: hidden; }
/* 폼 컨트롤이 부모보다 넓어지는 것 자체를 막는다 — select 는 내재 최소폭을 갖는다 */
.order-inputs input, .order-inputs select { max-width: 100%; box-sizing: border-box; }
/* 입력이 모달 높이를 넘쳐도 매수/취소 버튼은 항상 하단 고정 — 잘림 방지 */
.order-inputs .order-actions { margin-top: auto; position: sticky; bottom: 0; background: #0d1018; padding-top: 8px; }
.order-inputs label { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: #8b8f9a; }
.order-inputs label.inline-row { flex-direction: row; align-items: center; gap: 8px; }
.order-inputs label.inline-row > select { flex: 1; min-width: 0; }
.order-inputs input, .order-inputs select { background: #14171f; border: 1px solid #1f2330; border-radius: 6px; padding: 9px 10px; color: #e6e6e6; font-size: 16px; font-family: inherit; }
.order-inputs input:focus, .order-inputs select:focus { outline: none; border-color: #ff4d5e; }
.order-inputs .max-qty { font-size: 10.5px; color: #8b8f9a; font-weight: 400; margin-left: 4px; }
.order-info { font-size: clamp(9.5px, 2.6vw, 12px); color: #c9ccd3; padding: 8px 10px; background: #11141b; border: 1px solid #1f2330; border-radius: 6px; line-height: 1.55; word-break: keep-all; }
.order-info b { white-space: nowrap; }
.order-info .pl-up { color: #ff8a95; white-space: nowrap; }
.order-info .pl-down { color: #8db4ff; white-space: nowrap; }
.order-info b.qty-over { color: #ff8a95; }
input[data-order-qty].qty-over { color: #ff8a95; }
.price-tick-buttons .advice-open-btn { padding: 5px 4px; border-radius: 5px; background: #1a1710; border: 1px solid #4a3c1a; color: #e6c86b; font-size: 11px; font-weight: 700; cursor: pointer; text-align: center; font-family: inherit; white-space: nowrap; }
.price-tick-buttons .advice-open-btn:hover { border-color: #e6c86b; color: #f0d98a; }
.price-tick-buttons .advice-open-btn:active { transform: translateY(1px); }
.advice-box { max-width: 420px; }
.advice-body { padding: 4px 2px; }
.advice-sub { font-size: 11px; color: #9aa0ad; margin-bottom: 8px; line-height: 1.4; }
.advice-rows { display: flex; flex-direction: column; gap: 6px; }
.advice-row { display: flex; align-items: center; gap: 8px; width: 100%; padding: 9px 11px; border-radius: 8px; background: #1a1f2c; border: 1px solid #2a3040; text-align: left; cursor: default; }
.advice-row .advice-lbl { font-size: 12px; color: #c9ccd3; flex: 1; }
.advice-row .advice-px { font-size: 14px; font-weight: 700; color: #e8eaf0; font-variant-numeric: tabular-nums; white-space: nowrap; }
.advice-rel { font-size: 10px; }
.advice-rel.up { color: #ff8a95; }
.advice-rel.dn { color: #8ab6ff; }
.advice-tapmark { font-size: 10.5px; font-weight: 700; color: #8ab6ff; white-space: nowrap; margin-left: 2px; }
.advice-row.advice-tap { cursor: pointer; border-color: #34506e; background: #16202e; }
.advice-row.advice-tap:active { background: #1e2c3e; }
.advice-row.advice-info { opacity: 0.72; }
.advice-why { margin: 10px 0 0; background: none; border: none; color: #8ab6ff; font-size: 12px; font-weight: 600; cursor: pointer; padding: 4px 2px; }
.advice-hints { margin: 6px 0 2px; padding-left: 16px; font-size: 11.5px; color: #b7bcc7; line-height: 1.5; }
.advice-hints li { margin-bottom: 5px; }
.advice-hints b { color: #d6dae2; white-space: nowrap; }
.order-adr-banner { padding: 8px 12px; margin-bottom: 12px; border-radius: 8px; font-size: 12.5px; font-weight: 600; line-height: 1.4; }
.order-adr-banner.adr-over { background: #2a1418; border: 1px solid #5a1f28; color: #ff8a95; }
.order-adr-banner.adr-under { background: #101b2e; border: 1px solid #1f3a5a; color: #8ab6ff; }
.order-modal-msg { padding: 12px; margin-bottom: 12px; border-radius: 8px; font-size: 13px; line-height: 1.6; white-space: pre-wrap; font-variant-numeric: tabular-nums; }
.order-modal-msg.error { background: #2a1418; border: 1px solid #5a1f28; color: #ff8a95; }
.order-modal-msg.success { background: #142a18; border: 1px solid #1f5a28; color: #8aff95; }
.order-modal-msg.info { background: #14171f; border: 1px solid #1f2330; color: #c9ccd3; }
.order-modal-msg.hidden { display: none; }
.order-pin-row { display: flex; flex-direction: column; gap: 6px; margin: 12px 0; }
.order-pin-row label { font-size: 11px; color: #8b8f9a; }
.order-pin-row input { background: #14171f; border: 1px solid #1f2330; border-radius: 6px; padding: 12px 14px; color: #e6e6e6; font-size: 22px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0.2em; text-align: center; }
.order-pin-row input:focus { outline: none; border-color: #ff4d5e; }
.order-countdown { font-size: 13px; color: #8b8f9a; margin-top: 6px; text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; transition: color 0.15s; }
.order-countdown.warn { color: #ffcc77; }
.order-countdown.danger { color: #ff5b69; }
.order-actions { display: flex; gap: 8px; margin-top: 14px; }
.order-actions button { flex: 1; padding: 11px 14px; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; font-family: inherit; border: 0; }
.order-actions .btn-cancel { background: #14171f; color: #c9ccd3; border: 1px solid #1f2330; }
.order-actions .btn-confirm { background: #c9303e; color: #fff; }
.order-actions .btn-confirm[data-side="SELL"] { background: #2a5fb3; }
.order-actions button:active { transform: translateY(1px); }
.order-actions button:disabled { opacity: 0.5; cursor: not-allowed; }
.pin-box { max-width: 400px; }
.pin-box > .hidden { display: none; }
.open-orders-box { max-width: 560px; }
.open-orders-list { display: flex; flex-direction: column; gap: 8px; max-height: 50dvh; overflow-y: auto; margin-bottom: 12px; }
.open-orders-list .empty { padding: 32px 12px; text-align: center; color: #8b8f9a; font-size: 12px; }
.open-order-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 10px 12px; background: #14171f; border: 1px solid #1f2330; border-radius: 8px; align-items: center; }
.open-order-row.active-card { border-color: rgba(255,204,51,0.45); background: rgba(255,204,51,0.06); }
.open-order-row .row-info { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.open-order-row .row-line1 { display: flex; gap: 6px; align-items: baseline; font-size: 13px; font-weight: 600; color: #f0f0f0; }
.open-order-row .row-line1 .badge-side { font-size: 10px; padding: 1px 6px; border-radius: 3px; font-weight: 700; }
.open-order-row .row-line1 .badge-side.buy { background: #c9303e; color: #fff; }
.open-order-row .row-line1 .badge-side.sell { background: #2a5fb3; color: #fff; }
.open-order-row .row-line1 .badge-side.pin { background: #ffcc33; color: #14171f; }
.open-order-row .row-line2 { font-size: 11px; color: #8b8f9a; font-variant-numeric: tabular-nums; }
.open-order-row .row-action button { background: #2a1418; color: #ff8a95; border: 1px solid rgba(255,138,149,0.35); border-radius: 6px; padding: 6px 12px; font-size: 11px; font-weight: 600; cursor: pointer; font-family: inherit; }
.open-order-row .row-action button:hover { background: rgba(255,138,149,0.14); border-color: #ff8a95; }
.open-order-row .row-action button:disabled { opacity: 0.5; cursor: not-allowed; }
.open-order-row.active-card .row-action button { background: rgba(255,204,51,0.12); color: #ffcc77; border-color: rgba(255,204,51,0.45); }
.open-order-row.active-card .row-action button:hover { background: rgba(255,204,51,0.25); }
/* 매매 거부·검증 에러 토스트 — 화면 하단, 자동 사라짐 */
.order-toast { position: fixed; left: 50%; bottom: 80px; transform: translateX(-50%); z-index: 2000; background: #2a1418; border: 1px solid #5a1f28; color: #ff8a95; padding: 12px 18px; border-radius: 8px; font-size: 13px; max-width: 90vw; box-shadow: 0 6px 20px rgba(0,0,0,0.4); opacity: 0; transition: opacity 0.2s; pointer-events: none; white-space: pre-wrap; line-height: 1.55; font-variant-numeric: tabular-nums; }
.order-toast.show { opacity: 1; }
.order-toast.success { background: #142a18; border-color: #1f5a28; color: #8aff95; }
.order-toast.info { background: #14171f; border-color: #1f2330; color: #c9ccd3; }
/* iOS Safari는 포커스된 입력요소 font-size<16px면 자동 확대(줌) → 터치기기에서 폼 컨트롤 최소 16px 보장.
   blanket 규칙은 명시 font-size 없는 입력(UA 기본치)만 잡고, 아래 목록이 16px 미만 클래스 규칙을 소스순서로 덮는다.
   .order-pin-row input(22px) 등 의도적 큰 값은 특이도가 높아 불변, 데스크톱은 pointer:coarse 밖이라 그대로. */
@media (pointer: coarse) {
  input, select, textarea { font-size: 16px; }
  .igrp-select, .group-name-input, .info-search-input, .info-symbols-select,
  .order-symbol-select, .chart-controls .nw-select { font-size: 16px; }
}
'''


OWNER_TAB_IDS = {'본인': 'self', '가희': 'gahee'}


def _owner_tab_id(owner: str) -> str:
    return OWNER_TAB_IDS.get(owner, 'owner-' + owner.encode('ascii', 'replace').decode().replace('?', 'x'))


def _owner_full_title(owner: str) -> str:
    """stock_portfolio_report.OWNER_LABELS를 단일 진실 출처로 사용 (drift 방지)."""
    from stock_portfolio_report import OWNER_LABELS
    return OWNER_LABELS.get(owner, f'{owner} 자산')


def _render_chart_controls(selected_days: int, selected_unit: str, selected_mode: str = NET_WORTH_CHART_MODE, chart_ym: str = '', avail: dict | None = None) -> str:
    """차트용 공통 컨트롤 — 기간 / 단위 / 모드(순자산·손익누적·기간손익) select.
    기간에 '지정기간' 추가 → 유형(연도별/월별) + 연도 + 월(월별일 때) 보조 드롭다운.
    period(chart_ym) 활성 시 단위는 강제·비활성. 의미없는 단위(이번주+월별 등)는 disabled."""
    avail = avail or {'years': [], 'months': {}}
    period = (chart_ym or '').strip()
    is_year = len(period) == 4
    is_month = len(period) == 7
    period_active = is_year or is_month
    years = avail.get('years') or []
    months_map = avail.get('months') or {}

    allowed_units = NET_WORTH_ALLOWED_UNITS_BY_DAYS.get(
        selected_days, tuple(u for _, u in NET_WORTH_UNIT_PRESETS)
    )
    range_opts = ''.join(
        f'<option value="{d}"{" selected" if (not period_active and d == selected_days) else ""}>{html.escape(lbl)}</option>'
        for lbl, d in NET_WORTH_CHART_PRESETS
    )
    range_opts += f'<option value="custom"{" selected" if period_active else ""}>지정기간</option>'

    # 유형: 연도별(year) / 월별(month) — period_active 일 때만 노출.
    type_opts = (
        f'<option value="year"{" selected" if not is_month else ""}>연도별</option>'
        f'<option value="month"{" selected" if is_month else ""}>월별</option>'
    )

    cur_year = period[:4] if period_active else (years[0] if years else '')
    cur_month = period[5:7] if is_month else ''
    year_opts = ''.join(
        f'<option value="{y}"{" selected" if y == cur_year else ""}>{html.escape(y)}년</option>'
        for y in years
    )
    # 월 — 월별일 때만 실제 월 목록(활성), 연도별이면 '-' 한 칸 비활성.
    if is_month:
        month_opts = ''.join(
            f'<option value="{m}"{" selected" if m == cur_month else ""}>{int(m)}월</option>'
            for m in months_map.get(cur_year, [])
        )
        _month_dis = ''
    else:
        month_opts = '<option value="">-</option>'
        _month_dis = ' disabled'
    _customhide = '' if period_active else ' hidden'
    months_json = html.escape(json.dumps(months_map, ensure_ascii=False), quote=True)

    # 지정기간은 단위 일별 고정 → 단위 select 비활성, '일별' 선택 표시.
    unit_opts = ''.join(
        f'<option value="{u}"'
        f'{" selected" if u == selected_unit else ""}'
        f'{" disabled" if u not in allowed_units else ""}>'
        f'{html.escape(lbl)}</option>'
        for lbl, u in NET_WORTH_UNIT_PRESETS
    )
    mode_opts = ''.join(
        f'<option value="{m}"{" selected" if m == selected_mode else ""}>{html.escape(lbl)}</option>'
        for lbl, m in NET_WORTH_MODE_PRESETS
    )
    _unit_dis = ' disabled' if period_active else ''
    # 1번째 줄: 기간 / 단위 / 차트타입(모드). 2번째 줄(지정기간일 때만): 유형 / 연도 / 월.
    line1 = (
        f'<div class="cc-line">'
        f'<label class="cc-field"><span class="cc-label">기간</span>'
        f'<select class="nw-select" data-nw-range>{range_opts}</select></label>'
        f'<label class="cc-field cc-unit"><span class="cc-label">단위</span>'
        f'<select class="nw-select" data-nw-unit{_unit_dis}>{unit_opts}</select>'
        f'<select class="nw-select nw-mode-inline" data-nw-mode aria-label="차트 모드">{mode_opts}</select></label>'
        f'</div>'
    )
    line2 = (
        f'<div class="cc-line cc-line-ym"{_customhide}>'
        f'<label class="cc-field"><span class="cc-label">유형</span>'
        f'<select class="nw-select" data-nw-ymtype>{type_opts}</select></label>'
        f'<label class="cc-field"><span class="cc-label">연도</span>'
        f'<select class="nw-select" data-nw-year>{year_opts}</select></label>'
        f'<label class="cc-field"><span class="cc-label">월</span>'
        f'<select class="nw-select" data-nw-month{_month_dis}>{month_opts}</select></label>'
        f'</div>'
    )
    return f'<div class="chart-controls" data-ym-months="{months_json}">{line1}{line2}</div>'


def _format_signed_billion(v: int | None) -> tuple[str, str]:
    """투자자 매매 금액 포맷 — (표시문자열, direction 클래스). 단위는 억원."""
    if v is None:
        return '—', 'flat'
    if v > 0:
        return f'+{v:,}', 'up'
    if v < 0:
        return f'{v:,}', 'down'
    return '0', 'flat'


def _adr_class(adr: float | None) -> tuple[str, str]:
    """ADR 색상·해석 라벨. ≤80 침체(매수권), ≥120 과열(매도권). 그래프 기준선(80/100/120)과 동일."""
    if adr is None:
        return 'flat', ''
    if adr <= 80:
        return 'down', '침체'
    if adr >= 120:
        return 'up', '과열'
    return 'flat', ''


def _market_adr_signal(market: str) -> dict | None:
    """거래 모달용 — 시장(KOSPI/KOSDAQ) 20일 정통 ADR + 과매수/과매도 판정.
    _adr_class와 동일 임계(≥120 과매수·≤80 과매도). 중립·20일 미만 누적이면 None(배너 미표시).
    반환 {'value': float, 'zone': '과매수'|'과매도', 'label': '코스피'|'코스닥'}."""
    if market not in ('KOSPI', 'KOSDAQ'):
        return None
    try:
        live_rows = _fetch_market_indicators() or []
        today = next((r for r in live_rows if r.get('symbol') == market), None)
        ma, count, _ = _compute_adr_ma_latest(market, today)
        if ma is None or count < ADR_MA_WINDOW:
            return None
        if ma >= 120:
            zone = '과매수'
        elif ma <= 80:
            zone = '과매도'
        else:
            return None
        return {'value': round(ma, 1), 'zone': zone,
                'label': _ADR_MARKET_LABELS.get(market, market)}
    except Exception:
        return None


def _build_adr_series_with_live(history_rows: list[dict], live_pt: dict | None) -> list[dict]:
    """history(오래된 순) + 오늘 라이브 1점을 합쳐 [{date,adr,rise,fall,is_live}, ...] 반환.
    rise/fall(상승·하락 종목수)은 정통 합계식 ADR 산출용. 라이브는 history 마지막 date 와 다를 때만 append."""
    base: list[dict] = [
        {'date': r['date'], 'adr': float(r['adr']),
         'rise': r.get('rise'), 'fall': r.get('fall'), 'is_live': False}
        for r in history_rows
        if r.get('adr') is not None and r.get('date')
    ]
    if live_pt and live_pt.get('adr') is not None and live_pt.get('date'):
        last_date = base[-1]['date'] if base else None
        if live_pt['date'] != last_date:
            base.append({'date': live_pt['date'], 'adr': float(live_pt['adr']),
                         'rise': live_pt.get('rise'), 'fall': live_pt.get('fall'), 'is_live': True})
    return base


def _adr_window_value(win: list[dict]) -> float:
    """정통 ADR = 윈도우 내 Σ상승종목 ÷ Σ하락종목 × 100. 비율의 평균(구방식)은 급등일에 왜곡되므로 합계식 사용.
    윈도우 전 구간에 종목수(rise/fall)가 있고 Σ하락>0일 때만 합계식, 아니면 일별 비율 평균으로 폴백(구 데이터 graceful)."""
    rises = [w['rise'] for w in win if w.get('rise') is not None]
    falls = [w['fall'] for w in win if w.get('fall') is not None]
    if len(rises) == len(win) and len(falls) == len(win) and sum(falls) > 0:
        return sum(rises) / sum(falls) * 100
    return sum(w['adr'] for w in win) / len(win)


def _compute_adr_ma_latest(symbol: str, today_row: dict | None = None,
                           window: int = ADR_MA_WINDOW) -> tuple[float | None, int, bool]:
    """심볼별 최근 N일 정통 ADR. (value or None, available_count, used_live).
    today_row = 오늘 라이브 행(adr·rise·fall·bizdate). available_count < window 이면 '누적 중 count/window'."""
    history = _load_market_history(-1).get(symbol, [])
    live_pt = None
    if today_row and today_row.get('adr') is not None:
        bd = today_row.get('bizdate')
        iso = f'{bd[:4]}-{bd[4:6]}-{bd[6:]}' if (bd and len(bd) == 8) else today_row.get('date')
        if iso:
            live_pt = {'date': iso, 'adr': today_row['adr'],
                       'rise': today_row.get('rise'), 'fall': today_row.get('fall')}
    base = _build_adr_series_with_live(history, live_pt)
    if not base:
        return (None, 0, False)
    used = base[-window:]
    used_live = any(r.get('is_live') for r in used)
    # 확장 윈도우: window 미만이어도 가용한 만큼으로 계산(차트와 동일 정책). count로 누적 여부 표기.
    return (_adr_window_value(used), len(used), used_live)


def _compute_adr_ma_series(rows: list[dict], window: int = ADR_MA_WINDOW) -> list[tuple[str, float, bool]]:
    """rows(오래된 순, {date,adr,rise,fall,is_live}) → 각 시점 trailing window 정통 ADR 시리즈.
    [(date, adr, is_live), ...]. 값은 Σ상승÷Σ하락×100 (급등일 왜곡 없는 표준식).
    누적 < window(20일) 인 초반 구간은 정식 지표가 아니라 아예 점을 만들지 않는다 → 그래프 미표시."""
    series: list[tuple[str, float, bool]] = []
    for i in range(len(rows)):
        if i + 1 < window:  # 20일 미만 누적 — 정식 20일 ADR 불가, 그래프에서 제외
            continue
        win = rows[i + 1 - window : i + 1]
        val = _adr_window_value(win)
        is_live = any(r.get('is_live') for r in win)
        series.append((rows[i]['date'], val, is_live))
    return series


def _render_adr_info_table(rows: list[dict] | None) -> str:
    """ADR 기본정보 표 — 시장(코스피/코스닥)별 20일 ADR·오늘 ADR·종목수(상승/하락/보합).
    20일·오늘 각각 ≤80 침체·≥120 과열 배지. 20일은 누적 미만이면 배지 없이 (n/20). 추세 그래프 밖 상시 노출."""
    if not rows:
        return (
            '<div class="adr-today-block">'
            + '<div class="market-section-hint">시장 지표 미수신</div>'
            + '</div>'
        )
    body: list[str] = []
    for r in rows:
        # 20일 정통 ADR (배지·색은 20일 값 기준)
        ma, count, _ = _compute_adr_ma_latest(r['symbol'], r)
        if ma is None:
            ma_html = '<span class="neutral">—</span>'
        elif count >= ADR_MA_WINDOW:
            cls, tag = _adr_class(ma)
            ma_cls = cls if cls in ('up', 'down') else 'neutral'
            tag_html = f'<span class="adr-tag {cls}">{tag}</span>' if tag else ''
            ma_html = f'{tag_html}<b class="{ma_cls}">{ma:.1f}</b>'
        else:
            ma_html = f'<b class="neutral">{ma:.1f}</b> <span class="muted small">({count}/{ADR_MA_WINDOW})</span>'
        # 오늘 raw ADR (색은 방향만: 100 초과=빨강/미만=파랑)
        raw_adr = r.get('adr')
        if raw_adr is None:
            today_html = '<span class="neutral">—</span>'
        else:
            raw_cls = 'up' if raw_adr > 100 else ('down' if raw_adr < 100 else 'neutral')
            tag_cls, tag = _adr_class(raw_adr)
            tag_html = f'<span class="adr-tag {tag_cls}">{tag}</span>' if tag else ''
            today_html = f'{tag_html}<b class="{raw_cls}">{raw_adr:.1f}</b>'
        # 종목수 (상승↑/하락↓/보합→)
        rise, fall, steady = r.get('rise'), r.get('fall'), r.get('steady')
        if rise is not None and fall is not None and steady is not None:
            bd_html = (
                f'<span class="up">{rise:,}↑</span> / '
                f'<span class="down">{fall:,}↓</span> / '
                f'<span class="neutral">{steady:,}→</span>'
            )
        else:
            bd_html = '<span class="neutral">—</span>'
        body.append(
            f'<tr><td class="adr-mk">{html.escape(r["label"])}</td>'
            f'<td>{ma_html}</td><td>{today_html}</td>'
            f'<td class="adr-breakdown">{bd_html}</td></tr>'
        )
    return (
        '<table class="market-table adr-info-table">'
        '<thead><tr><th>시장</th><th>20일</th><th>오늘</th>'
        '<th>종목수 <span class="adr-cnt-sub">↑/↓/→</span></th></tr></thead>'
        '<tbody>' + ''.join(body) + '</tbody></table>'
    )


def _render_market_indicators_card() -> str:
    """자산정보 탭의 시장정보 sub-tab에 들어가는 오늘의 시장 카드.
    - 지수 섹션: 미국 정규장(ET 09:30~16:00) 시간이면 KOSPI 200 + MSCI Korea(EWY), 그 외 KOSPI/KOSDAQ 등락률.
    - 투자자별 매매: 시장 단위 (개인/외국인/기관) — 네이버 m.stock 비공식 API.
    실패시 에러 카드."""
    rows = _fetch_market_indicators()
    if not rows:
        return '<div class="market-card market-card-error">시장 지표를 불러올 수 없습니다.</div>'

    # bizdate는 두 시장 동일 — 첫 행 기준으로 헤더 라벨링.
    bizdate = next((r['bizdate'] for r in rows if r.get('bizdate')), None)
    if bizdate and len(bizdate) == 8:
        date_label = f'{bizdate[4:6]}/{bizdate[6:8]}'
    else:
        date_label = ''

    # === 지수 섹션 (시간 무관 상시 표시) ===
    # KOSPI / KOSDAQ (라이브 또는 종가, _fetch_indices) + MSCI Korea(EWY) + S&P 500 + 필라델피아 반도체(SOX).
    # KOSPI 200 은 라이브 데이터 부재로 KOSPI 와 거의 동일 → KOSPI 만 표시.
    all_indices = _fetch_indices()
    indices_by_label = {q.get('label'): q for q in all_indices if q.get('label')}
    extra = _fetch_extra_market_quotes() or [None] * 4
    extra = (extra + [None] * 4)[:4]
    ewy, sox, vix, tnx = extra
    index_quotes = [
        indices_by_label.get('KOSPI'),
        indices_by_label.get('KOSDAQ'),
        indices_by_label.get('S&P500'),
        sox,
        ewy,
        vix,
        tnx,
    ]
    index_hint = '국내·미국 주요 지수'

    index_descriptions = {
        'KOSPI': (
            '한국 유가증권시장에 상장된 보통주 전체로 산출하는 시가총액 가중 종합지수.\n\n'
            '• 1980년 1월 4일 = 100 기준\n'
            '• 약 800개 종목, 시총 약 2,500조원 규모\n'
            '• 한국 경제·대형주 sentiment 대표 지표\n'
            '• 외국인·기관 자금 흐름이 큰 영향\n\n'
            '거래시간 (KST)\n'
            '• 정규장: 09:00 ~ 15:30\n'
            '• NXT 야간: 08:00~09:00 + 15:30~20:00'
        ),
        'KOSDAQ': (
            '한국 코스닥시장 종합지수 — 중소·벤처·기술주 중심.\n\n'
            '• 1996년 7월 1일 = 1000 기준\n'
            '  (2004년 100 → 1000 재조정)\n'
            '• 약 1,700개 종목\n'
            '• 바이오·IT·2차전지 비중이 높음\n'
            '• 글로벌 기술주(나스닥·SOX) sentiment에 민감\n'
            '• 코스피보다 변동성·개인 비중이 큼'
        ),
        'S&P500': (
            'Standard & Poor\'s가 1957년 도입한 미국 대형주 500개 시가총액 가중 지수.\n\n'
            '• 미국 상장 기업 시총의 약 80% 커버\n'
            '• 기관 투자자 표준 벤치마크\n'
            '• SPY ETF 시총 약 5,000억 달러\n\n'
            '거래시간\n'
            '• 정규장 ET 09:30 ~ 16:00\n'
            '• KST 22:30 ~ 05:00 (DST)\n\n'
            '한국 시장 다음날 개장 sentiment에 가장 큰 영향.'
        ),
        '필라델피아 반도체': (
            'PHLX Semiconductor Sector Index (^SOX) — 1993년 도입.\n\n'
            '• 미국 상장 반도체 30개 종목 시총 가중\n'
            '• Nvidia · TSMC ADR · Intel · AMD\n'
            '  ASML · Broadcom · Qualcomm 등 포함\n\n'
            '한국 삼성전자·SK하이닉스와 0.7~0.8 상관관계.\n'
            '글로벌 반도체 사이클 선행 지표로\n'
            '한국 IT 비중이 큰 코스피에 직접 영향.'
        ),
        'MSCI Korea (EWY)': (
            'iShares MSCI South Korea ETF (NYSE 상장).\n\n'
            '• MSCI Korea Index 추종\n'
            '• 한국 시총 상위 large/mid cap 약 90종목 보유\n'
            '• 삼성전자·SK하이닉스·LG에너지솔루션\n'
            '  현대차 등 대표 종목 포함\n\n'
            '미국 거래소 상장이라\n'
            '미국 정규장 시간(KST 22:30~05:00 DST)에 라이브 거래.\n'
            'ADR·대표주의 미국 야간 등락이 EWY에 반영되어\n'
            '한국 시장 야간 sentiment 추정에 활용.'
        ),
        'VIX (공포지수)': (
            'CBOE Volatility Index — 1993년 도입.\n'
            'S&P 500 옵션의 30일 implied volatility를 연율화한 변동성 지수.\n'
            '"공포지수(Fear Gauge)" 별칭.\n\n'
            '구간 해석\n'
            '• 12 이하: 안일 (complacency)\n'
            '• 12 ~ 20: 안정\n'
            '• 20 ~ 30: 주의\n'
            '• 30 ~ 40: 공포 (시장 패닉)\n'
            '• 40 이상: 극단 위기 (2008·2020 수준)\n\n'
            'VIX 상승 = 위험자산 회피\n'
            '→ 한국 외국인 매도 압력 증가.'
        ),
        '미국채 10년 (%)': (
            '미국 10년 만기 국채(Treasury Note)의 연 수익률.\n\n'
            '연준 통화정책 · 인플레이션 기대 · 재정 적자가 반영되는\n'
            '글로벌 무위험 금리 기준.\n\n'
            '한국 시장 영향\n'
            '• 상승 → 자금이 채권으로 이동\n'
            '  → 한국 외국인 매도 압력 (위험자산 회피)\n'
            '• 하락 → 신흥국·한국 자금 유입 가능성 ↑\n\n'
            '구간 해석\n'
            '• 통상 3.5 ~ 5%대가 정상\n'
            '• 6% 이상은 시장 부담\n'
            '• 단기 금리와의 역전(yield curve inversion)은\n'
            '  경기 침체 신호로 해석'
        ),
    }
    index_rows: list[str] = []
    for q in index_quotes:
        if not q:
            continue
        direction = q.get('direction') or 'flat'
        sign = '+' if direction == 'up' else ('−' if direction == 'down' else '')
        price = q.get('price', '—')
        change = q.get('change', '0')
        pct = q.get('pct', '0.00')
        label = q.get('label', '')
        desc = index_descriptions.get(label)
        # 색상 — 양봉=빨강(.up), 음봉=파랑(.down), 보합=회색(.flat). market-table CSS 그대로 사용.
        # 설명 — ⓘ 버튼 클릭 시 idx-info-modal 팝업으로 표시. 가격/등락도 attr로 전달해 팝업 안에서 함께 표시.
        # 차트는 data-idx-symbol 가 있으면 모달에서 lazy fetch (/api/idx-chart?symbol=...) 후 SVG 렌더.
        symbol_attr = _IDX_CHART_SYMBOL.get(label, '')
        info_btn = (
            f'<button type="button" class="market-idx-info" data-idx-info '
            f'data-idx-label="{html.escape(label, quote=True)}" '
            f'data-idx-desc="{html.escape(desc, quote=True)}" '
            f'data-idx-price="{html.escape(price, quote=True)}" '
            f'data-idx-change="{html.escape(change, quote=True)}" '
            f'data-idx-pct="{html.escape(pct, quote=True)}" '
            f'data-idx-direction="{direction}" '
            f'data-idx-symbol="{html.escape(symbol_attr, quote=True)}" '
            f'aria-label="설명">ⓘ</button>'
        ) if desc else ''
        index_rows.append(
            f'<tr class="market-idx-row">'
            f'<th>{html.escape(label)}{info_btn}</th>'
            f'<td class="market-idx-price">{html.escape(price)}</td>'
            f'<td><span class="{direction}">{sign}{html.escape(change)} ({sign}{html.escape(pct)}%)</span></td>'
            f'</tr>'
        )
    index_section = ''
    if index_rows:
        index_section = (
            '<div class="market-section">'
            '<table class="market-table market-index">'
            f'<tbody>{"".join(index_rows)}</tbody>'
            '</table>'
            '</div>'
        )

    deal_rows: list[str] = []
    for r in rows:
        p_str, p_cls = _format_signed_billion(r.get('personal'))
        f_str, f_cls = _format_signed_billion(r.get('foreign'))
        i_str, i_cls = _format_signed_billion(r.get('institutional'))
        deal_rows.append(
            f'<tr>'
            f'<th>{html.escape(r["label"])}</th>'
            f'<td><span class="{p_cls}">{p_str}</span></td>'
            f'<td><span class="{f_cls}">{f_str}</span></td>'
            f'<td><span class="{i_cls}">{i_str}</span></td>'
            f'</tr>'
        )

    date_suffix = f' <span class="market-date">{date_label}</span>' if date_label else ''
    refresh_btn = (
        '<button type="button" class="market-refresh-btn" data-refresh-market '
        'title="시장 지표 새로고침" aria-label="시장 지표 새로고침">↻</button>'
    )
    return (
        '<div class="market-card">'
        f'<div class="market-card-title">오늘의 시장{date_suffix}{refresh_btn}</div>'
        + index_section +
        '<div class="market-section">'
        '<div class="market-section-head"><span class="market-section-label">투자자별 매매</span><span class="market-section-hint">순매수 금액 · 단위 억원</span></div>'
        '<table class="market-table market-deal">'
        '<thead><tr><th></th><th>개인</th><th>외국인</th><th>기관</th></tr></thead>'
        f'<tbody>{"".join(deal_rows)}</tbody>'
        '</table>'
        '</div>'
        '</div>'
    )


def _load_market_history(days: int = ADR_TREND_DAYS) -> dict[str, list[dict]]:
    """state/market_indicators_history.jsonl에서 시장별 최근 days 행 로드.
    days=-1 → 전체. 반환: {'KOSPI': [{...오래된 순...}], 'KOSDAQ': [...]}. 파일 없거나 깨지면 빈 dict."""
    if not MARKET_HISTORY_FILE.exists():
        return {}
    try:
        rows: list[dict] = []
        for ln in MARKET_HISTORY_FILE.read_text().splitlines():
            ln = ln.strip()
            if not ln:
                continue
            try:
                rows.append(json.loads(ln))
            except json.JSONDecodeError:
                continue
    except Exception as e:
        sys.stderr.write(f'market history load failed: {e}\n')
        return {}
    grouped: dict[str, list[dict]] = {}
    for r in rows:
        m = r.get('market')
        if not m:
            continue
        grouped.setdefault(m, []).append(r)
    for m in grouped:
        grouped[m].sort(key=lambda r: r.get('date', ''))
        if days >= 0 and len(grouped[m]) > days:
            grouped[m] = grouped[m][-days:]
    return grouped


def _render_adr_trend_controls(selected_days: int) -> str:
    """ADR 추세 카드 상단 기간 버튼 그룹. localStorage(behive.adrTrendDays) 키로 보존.
    클릭 시 클라이언트 JS의 document-level handler가 받아서 localStorage 저장 + load(fresh) 트리거."""
    btns = ''.join(
        f'<button type="button" class="adr-range-btn{" active" if d == selected_days else ""}"'
        f' data-adr-range="{d}" aria-pressed="{"true" if d == selected_days else "false"}">'
        f'{html.escape(lbl)}</button>'
        for lbl, d in ADR_TREND_PRESETS
    )
    return f'<div class="adr-trend-controls" role="group" aria-label="ADR 추세 기간">{btns}</div>'


_ADR_MARKET_COLORS = {'KOSPI': '#fbbf24', 'KOSDAQ': '#34d399'}
_ADR_MARKET_LABELS = {'KOSPI': '코스피', 'KOSDAQ': '코스닥'}


def _render_adr_trend_card(days: int = ADR_TREND_DAYS) -> str:
    """시장정보 sub-tab의 ADR 추세 카드. KOSPI/KOSDAQ 두 20일 이평선을 한 SVG 안에 합쳐 그린다.
    저장 raw daily ADR은 stock.trade-journal launchd가 평일 21:00 누적, 오늘치 라이브는 _fetch_market_indicators(5분 SWR)에서.
    차트는 각 시점의 trailing 20일 평균 — 누적 < 20일인 앞 구간은 평균 산출 불가라 점이 안 그려진다.
    가이드선·날짜축은 양쪽 시장이 공유. 호버 시 한 세로선 + 시장당 점, 툴팁에 두 값 동시 표시."""
    # 이평 산출엔 days 윈도우 밖의 과거 19일도 필요 → 전체 로드 후 trim.
    history_all = _load_market_history(-1)
    live_rows = _fetch_market_indicators() or []
    live_by_market: dict[str, dict] = {}
    for r in live_rows:
        sym = r.get('symbol')
        adr = r.get('adr')
        bd = r.get('bizdate')
        if not sym or adr is None:
            continue
        iso = f'{bd[:4]}-{bd[4:6]}-{bd[6:]}' if (bd and len(bd) == 8) else None
        if iso:
            # rise/fall 도 실어야 오늘 포함 윈도우가 정통 합계식으로 계산됨(없으면 그 구간만 비율평균 폴백).
            live_by_market[sym] = {'adr': adr, 'date': iso, 'rise': r.get('rise'), 'fall': r.get('fall')}

    # 시장별 trailing 20일 이평 시리즈 — (date, ma, is_live).
    # raw 시리즈(history+오늘 라이브)에서 시점별 trailing 평균 산출 후, days 파라미터로 마지막 N개 trim.
    market_pts: dict[str, list[tuple[str, float, bool]]] = {}
    raw_count_max = 0
    for sym in ('KOSPI', 'KOSDAQ'):
        raw_rows = history_all.get(sym, [])
        live_pt = live_by_market.get(sym)
        base = _build_adr_series_with_live(raw_rows, live_pt)
        raw_count_max = max(raw_count_max, len(base))
        ma_series = _compute_adr_ma_series(base)
        if days >= 0 and len(ma_series) > days:
            ma_series = ma_series[-days:]
        market_pts[sym] = ma_series

    # 20일 ADR·오늘 ADR·종목수를 표(그래프 밖 상시)로 표시.
    adr_table = _render_adr_info_table(live_rows)
    # 그래프 펼침 토글 버튼 — 클릭 시 .adr-graph-wrap hidden 토글(순수 클라이언트, localStorage 보존).
    graph_toggle = (
        '<button type="button" class="adr-graph-toggle" data-adr-graph-toggle aria-expanded="false">'
        '<span class="adr-graph-toggle-ic">📈</span> 추세 그래프</button>'
    )

    # 두 시장 합쳐 점이 하나도 없으면 누적 부족 안내.
    all_dates = sorted({d for pts in market_pts.values() for d, _, _ in pts if d})
    if not all_dates:
        hint = (f'{ADR_MA_WINDOW}일 누적 중 ({raw_count_max}/{ADR_MA_WINDOW}) — '
                f'매 영업일 21:00 raw ADR 적재, {ADR_MA_WINDOW}일 채워지면 이평선 표시')
        return (
            '<div class="market-card adr-trend-card">'
            '<div class="market-card-title">ADR 추세</div>'
            + adr_table
            + graph_toggle
            + '<div class="adr-graph-wrap" data-adr-graph hidden>'
            + f'<div class="market-section-hint">{html.escape(hint)}</div>'
            + _render_adr_trend_controls(days)
            + '</div>'
            + '</div>'
        )

    date_to_i = {d: i for i, d in enumerate(all_dates)}
    n_dates = len(all_dates)

    # y축 스케일 — 0~200 고정이면 데이터(통상 40~90)가 아래에 눌려 밋밋함. 데이터 범위에 맞추되
    # 기준선 80/100/120 이 항상 보이도록 그 밴드를 범위에 포함시킨다.
    _all_vals = [v for sym in ('KOSPI', 'KOSDAQ') for _, v, _ in market_pts[sym]]
    if _all_vals:
        _dmin, _dmax = min(_all_vals), max(_all_vals)
    else:
        _dmin, _dmax = 80.0, 120.0
    _lo, _hi = min(_dmin, 80.0), max(_dmax, 120.0)
    _pad = max(6.0, (_hi - _lo) * 0.1)
    Y_MIN, Y_MAX = max(0.0, _lo - _pad), _hi + _pad
    # 차트 면적 — 두 라인 + 가이드 + 마커 라벨 여유. height는 sparkline 단일 70 대비 95 로 확장.
    W, H = 360.0, 95.0
    PAD_L, PAD_R, PAD_T, PAD_B = 4.0, 38.0, 10.0, 14.0
    PLOT_W = W - PAD_L - PAD_R
    PLOT_H = H - PAD_T - PAD_B

    def x_for(i: int) -> float:
        if n_dates == 1:
            return PAD_L + PLOT_W / 2
        return PAD_L + (PLOT_W * i / (n_dates - 1))

    def y_for(v: float) -> float:
        v = max(Y_MIN, min(Y_MAX, v))
        return PAD_T + PLOT_H * (1 - (v - Y_MIN) / (Y_MAX - Y_MIN))

    # 가이드선 — 기준 80/100/120. y축이 이 밴드를 포함하므로 셋 다 표시.
    _guide_specs = [
        (100, '#8b8f9a', '2 4', 0.25, 0.4),
        (80, '#4a8cf0', '3 3', 0.25, 0.45),
        (120, '#ff4d5e', '3 3', 0.25, 0.45),
    ]
    guides = ''
    for gv, gcolor, gdash, gline_op, gtext_op in _guide_specs:
        if not (Y_MIN <= gv <= Y_MAX):
            continue
        gy = y_for(gv)
        guides += (
            f'<line x1="{PAD_L:.1f}" x2="{W-PAD_R:.1f}" y1="{gy:.1f}" y2="{gy:.1f}" stroke="{gcolor}" stroke-width="0.6" stroke-dasharray="{gdash}" opacity="{gline_op}"/>'
            f'<text x="{W-PAD_R+3:.1f}" y="{gy:.1f}" dominant-baseline="middle" fill="{gcolor}" font-size="9" opacity="{gtext_op}">{gv}</text>'
        )

    # 시장별 이평선 path + 마지막 마커. 이평은 매끄러운 단일 시리즈라 saved/live 분리 점선 불필요.
    # 마지막 점이 오늘 라이브 raw를 포함한 평균이면 (is_live) open circle + 별표 마커.
    line_parts: list[str] = []
    marker_parts: list[str] = []
    for sym in ('KOSPI', 'KOSDAQ'):
        pts = market_pts[sym]
        if not pts:
            continue
        color = _ADR_MARKET_COLORS[sym]
        if len(pts) >= 2:
            d_attr = ' L '.join(f'{x_for(date_to_i[d]):.1f} {y_for(v):.1f}' for d, v, _ in pts)
            line_parts.append(f'<path d="M {d_attr}" fill="none" stroke="{color}" stroke-width="1.4"/>')
        last_d, last_v, last_is_live = pts[-1]
        lx, ly = x_for(date_to_i[last_d]), y_for(last_v)
        if last_is_live:
            marker_parts.append(
                f'<circle cx="{lx:.1f}" cy="{ly:.1f}" r="3.0" fill="#11141c" stroke="{color}" stroke-width="1.6"/>'
                f'<text x="{lx+6:.1f}" y="{ly:.1f}" dominant-baseline="middle" fill="{color}" font-size="10" font-weight="600">{last_v:.1f}*</text>'
            )
        else:
            marker_parts.append(
                f'<circle cx="{lx:.1f}" cy="{ly:.1f}" r="2.4" fill="{color}"/>'
                f'<text x="{lx+5:.1f}" y="{ly:.1f}" dominant-baseline="middle" fill="{color}" font-size="10" font-weight="600">{last_v:.1f}</text>'
            )

    # 호버 인디케이터 — 세로선 1개 + 시장당 점 2개 (없는 시장은 JS가 hidden 처리).
    hover_g = (
        f'<g class="adr-hover" opacity="0" pointer-events="none">'
        f'<line class="adr-hover-line" x1="0" x2="0" y1="{PAD_T:.1f}" y2="{H-PAD_B:.1f}"/>'
        f'<circle class="adr-hover-dot adr-hover-dot-kospi" cx="0" cy="0" r="3.2" fill="{_ADR_MARKET_COLORS["KOSPI"]}" stroke="#0b0d12" stroke-width="1.2"/>'
        f'<circle class="adr-hover-dot adr-hover-dot-kosdaq" cx="0" cy="0" r="3.2" fill="{_ADR_MARKET_COLORS["KOSDAQ"]}" stroke="#0b0d12" stroke-width="1.2"/>'
        f'</g>'
    )

    # 포인트 메타 — 날짜축 i 기준. 각 날짜에 두 시장 값(있는 만큼) 포함.
    # JS는 가장 가까운 x 찾고 양쪽 시장 점에 dot 배치 + 툴팁 두 줄 표시.
    pts_meta: list[dict] = []
    for i, d in enumerate(all_dates):
        entry: dict = {'d': d, 'x': round(x_for(i), 2)}
        for sym in ('KOSPI', 'KOSDAQ'):
            found = next(((pv, plive) for pd, pv, plive in market_pts[sym] if pd == d), None)
            if found is not None:
                pv, plive = found
                key = 'kospi' if sym == 'KOSPI' else 'kosdaq'
                entry[key] = {'v': round(pv, 2), 'y': round(y_for(pv), 2), 'live': bool(plive)}
        pts_meta.append(entry)
    pts_json = html.escape(json.dumps(pts_meta, separators=(',', ':')), quote=True)

    # 선택 지점 정보 — 차트 아래 정적 정보줄(구 플로팅 툴팁 대체). 기본은 최신 지점, 차트를 짚으면 갱신.
    # 시장 값 비어있으면 JS가 해당 줄 hide.
    info_html = (
        '<div class="adr-trend-info" aria-live="polite">'
        '<span class="tip-d">—</span>'
        '<span class="tip-line tip-kospi"><span class="tip-mk">코스피</span><span class="tip-v">—</span></span>'
        '<span class="tip-line tip-kosdaq"><span class="tip-mk">코스닥</span><span class="tip-v">—</span></span>'
        '</div>'
    )

    # 시장 색 범례 — 기간 표시는 차트 하단 x축으로 이동(축 라벨로 더 적합).
    legend_html = (
        '<div class="adr-combo-meta muted small">'
        f'<span class="adr-legend"><span class="adr-legend-swatch" style="background:{_ADR_MARKET_COLORS["KOSPI"]}"></span>코스피</span>'
        f'<span class="adr-legend"><span class="adr-legend-swatch" style="background:{_ADR_MARKET_COLORS["KOSDAQ"]}"></span>코스닥</span>'
        '</div>'
    )

    # 차트 x축 하단 기간 라벨 — 시작/끝 날짜만 양 끝에 작게.
    first_dt, last_dt = all_dates[0], all_dates[-1]
    x_axis_y = H - 3
    if first_dt == last_dt:
        x_axis_label = (
            f'<text x="{PAD_L + PLOT_W/2:.1f}" y="{x_axis_y:.1f}" text-anchor="middle" '
            f'fill="#6b7280" font-size="8.5" opacity="0.7">{html.escape(first_dt[5:])}</text>'
        )
    else:
        x_axis_label = (
            f'<text x="{PAD_L:.1f}" y="{x_axis_y:.1f}" text-anchor="start" '
            f'fill="#6b7280" font-size="8.5" opacity="0.7">{html.escape(first_dt[5:])}</text>'
            f'<text x="{W-PAD_R:.1f}" y="{x_axis_y:.1f}" text-anchor="end" '
            f'fill="#6b7280" font-size="8.5" opacity="0.7">{html.escape(last_dt[5:])}</text>'
        )

    chart_row = (
        f'<div class="adr-trend-row adr-combo-row" data-adr-pts="{pts_json}" data-adr-vb="{W:.0f} {H:.0f}">'
        f'<svg class="adr-trend-svg" viewBox="0 0 {W:.0f} {H:.0f}" preserveAspectRatio="none" role="img" aria-label="코스피·코스닥 ADR 추세">'
        f'{guides}'
        f'{"".join(line_parts)}'
        f'{"".join(marker_parts)}'
        f'{x_axis_label}'
        f'{hover_g}'
        f'</svg>'
        f'{info_html}'
        f'</div>'
    )

    # 누적 < window 면 아직 정식 20일 이평이 아니라 '가용한 만큼'의 평균 → 제목에 명시.
    accum_note = f' · 누적 {raw_count_max}/{ADR_MA_WINDOW}일' if raw_count_max < ADR_MA_WINDOW else ''
    return (
        '<div class="market-card adr-trend-card">'
        f'<div class="market-card-title">ADR 추세<span class="market-card-sub">'
        + accum_note + '</span></div>'
        + adr_table
        + graph_toggle
        + '<div class="adr-graph-wrap" data-adr-graph hidden>'
        + f'<div class="market-section-hint">파란선=80 · 회색선=100(균형) · 빨간선=120 · *=오늘(라이브 포함)</div>'
        + _render_adr_trend_controls(days)
        + legend_html
        + chart_row
        + '</div>'
        + '</div>'
    )


def _render_summary_panel(ordered_owners: list[str], owner_data: dict, balances: dict | None = None, journal_by_label: dict | None = None, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS, chart_ym: str = '') -> str:
    """자산정보 탭 — sub-tab(자산보기/차트보기/시장정보)로 분리.
    - 자산보기(기본): owner별 compact KPI 카드 스택 (합산 / 각계좌별도 토글)
    - 차트보기: 기간·단위·모드 select + owner별 차트 (순자산 / 손익누적 토글)
    - 시장정보: 오늘의 ADR·투자자별 매매 카드 + ADR 추세 sparkline (기간 select, 라이브 포인트)
    추가 키움 호출 없음 (_fetch_all_data가 모은 owner_data 재사용). 시장 카드는 네이버 m.stock 별도 fetch (5분 SWR)."""
    if not ordered_owners:
        return '<div class="empty">계좌 데이터가 없습니다.</div>'

    balances = balances or {}
    journal_by_label = journal_by_label or {}

    # 연도별/월별 절대 선택 — chart_ym('YYYY'=연도별·월별집계 / 'YYYY-MM'=월별·일별) 있으면 단위 강제 + period 필터.
    period = (chart_ym or '').strip()
    eff_unit = 'day' if period else chart_unit  # 지정기간(연도별·월별)은 단위 일별 고정.
    # 절대 선택이 과거 기간(이번달·올해가 아님)이면 오늘 라이브 점·라이브 갱신을 끈다 —
    # 과거 차트(예: 4월)에 today 순자산이 마지막 점·델타·하단값으로 섞이는 것 방지. 프리셋은 항상 today 포함.
    _now_dt = datetime.now(KST)
    period_is_current = True
    if period:
        period_is_current = (period == _now_dt.strftime('%Y')) if len(period) == 4 else (period == _now_dt.strftime('%Y-%m'))

    consolidated_parts: list[str] = []
    by_account_parts: list[str] = []
    chart_parts: list[str] = [_render_chart_controls(chart_days, eff_unit, chart_mode, chart_ym=period, avail=_available_chart_periods())]
    for owner in ordered_owners:
        d = owner_data.get(owner)
        if not d:
            continue
        full_title = _owner_full_title(owner)
        consolidated_parts.append(_render_owner_kpi(owner, d, full_title, compact=True))
        # 각계좌별도: owner의 labels 순서로 단일 계좌 카드 누적. prev_net 유무로 day_pl 근사 토글.
        has_prev_snap = d.get('prev_net') is not None
        owner_short = full_title.replace('님 자산', '님').replace(' 자산', '')
        owner_prefix = f'{owner}_'
        for lb in d.get('labels', []):
            label_disp = lb[len(owner_prefix):] if lb.startswith(owner_prefix) else lb
            by_account_parts.append(_render_account_kpi(
                owner, lb, d, balances, journal_by_label,
                owner_label_text=owner_short,
                has_prev_snap=has_prev_snap,
                label_disp=label_disp,
            ))
        chart_series, prev_baseline = _load_net_worth_series(owner, days=chart_days, unit=eff_unit, with_prev_baseline=True, period=period)
        if chart_series:
            # 스냅샷에 누적된 일자별 입출금 → unit 집계에 맞춰 sum.
            daily_cf = _load_cash_flow_by_date(owner)
            cf_by_date = _aggregate_cash_flows_by_unit(daily_cf, [d_ for d_, _ in chart_series], eff_unit) if daily_cf else {}
            # 오늘 live 포인트용 — snapshot에 아직 안 들어간 오늘 입출금. owner_data에 이미 합산되어 있음.
            today_cf = None
            if d.get('cash_in', 0) or d.get('cash_out', 0):
                today_cf = {'cash_in': d.get('cash_in', 0), 'cash_out': d.get('cash_out', 0)}
            if chart_mode in ('pnl', 'period'):
                # pnl·period 모두 '입출금 제거 누적손익' 시계열을 입력으로 받는다 (period는 _render에서 기간별 증분으로 그림).
                # series[0] 직전 영업일 net_worth 를 baseline 으로 → 첫날 손익도 표시. fallback baseline 케이스(이번주 휴장, 1월 1일 등)는 prev_baseline=None.
                prev_baseline_nw = prev_baseline[1] if prev_baseline else None
                pnl_series, baseline_nw, cum_cf = _to_pnl_series(chart_series, cf_by_date, prev_baseline_nw=prev_baseline_nw)
                # 차트 시각화에 baseline 점(예: 5/1) prepend → 첫 영업일 변동이 0 기점에서 분기되는 모양. baseline ↔ first 구간은 점선.
                prev_baseline_point = prev_baseline if prev_baseline else None
                # live current_pnl = current_net - baseline_nw - (시작점 이후 누적 cf + 오늘 cf).
                # series 마지막 점이 오늘이면 today_cf 는 baseline 이후 누적에 안 들어있으니 추가로 빼줘야 일관.
                current_net = d.get('total_net') if period_is_current else None
                if current_net is None:
                    current_pnl = None
                else:
                    today_net_cf = 0
                    if today_cf:
                        today_net_cf = int(today_cf.get('cash_in', 0) or 0) - int(today_cf.get('cash_out', 0) or 0)
                    current_pnl = int(current_net) - baseline_nw - cum_cf - today_net_cf
                chart_parts.append(_render_net_worth_chart(
                    pnl_series,
                    current_net=current_pnl,
                    selected_days=chart_days,
                    selected_unit=eff_unit,
                    cash_flow_by_date=None,  # pnl·period 모두 cf 영향 이미 제거 — 마커 숨김
                    today_cash_flow=None,
                    mode=chart_mode,
                    prev_baseline_point=prev_baseline,
                    rt_owner=(owner if period_is_current else None), rt_base_net=d.get('total_net'),
                ))
            else:
                # net 모드도 baseline 점(예: 5/1) prepend → 첫 영업일 변동이 0 기점에서 분기되는 모양. baseline 구간은 점선.
                chart_parts.append(_render_net_worth_chart(
                    chart_series,
                    current_net=(d.get('total_net') if period_is_current else None),
                    selected_days=chart_days,
                    selected_unit=eff_unit,
                    cash_flow_by_date=cf_by_date,
                    today_cash_flow=today_cf,
                    mode='net',
                    prev_baseline_point=prev_baseline,
                    rt_owner=(owner if period_is_current else None), rt_base_net=d.get('total_net'),
                ))

    market_card = _render_market_indicators_card()
    adr_trend_card = _render_adr_trend_card(days=adr_days)
    # 자산보기 sub-panel 안 합산/각계좌별도 panes (양쪽 prerender). 토글 버튼은 페이지 상단(자동 토글 옆) 1곳으로 통일.
    assets_inner = (
        '<div class="account-view-pane" data-account-view-pane="consolidated">'
        + '\n'.join(consolidated_parts)
        + '</div>'
        '<div class="account-view-pane" data-account-view-pane="by-account" hidden>'
        + '\n'.join(by_account_parts)
        + '</div>'
    )
    return (
        '<div class="sub-tab-panel" data-sub-panel="assets" role="tabpanel">'
        + assets_inner
        + '</div>'
        '<div class="sub-tab-panel" data-sub-panel="chart" role="tabpanel" hidden>'
        + '\n'.join(chart_parts)
        + '</div>'
        '<div class="sub-tab-panel" data-sub-panel="market" role="tabpanel" hidden>'
        + market_card
        + adr_trend_card
        + '</div>'
        '<div class="sub-tabs sub-tabs-bottom" role="tablist">'
        '<button type="button" class="sub-tab active" data-sub-tab="assets" role="tab" aria-selected="true">자산보기</button>'
        '<button type="button" class="sub-tab" data-sub-tab="chart" role="tab" aria-selected="false">차트보기</button>'
        '<button type="button" class="sub-tab" data-sub-tab="market" role="tab" aria-selected="false">시장정보</button>'
        '</div>'
    )


def _render_watchlist_panel(cards: list[dict]) -> tuple[str, int]:
    if not cards:
        return '<div class="empty">감시종목이 비어있습니다.</div>', 0
    market_active = _market_active_now()
    show_day_change = _show_day_change_now()
    alerts_map = _load_alerts_map()
    for c in cards:
        c['_show_day_change'] = show_day_change
        c['_alerted'] = alerts_map.get(c.get('stock') or '', [])
    active = [c for c in cards if c.get('status') != 'pending_delete']
    trash = [c for c in cards if c.get('status') == 'pending_delete']
    held = sorted([c for c in active if c.get('mode') == 'held'],
                  key=lambda c: c.get('held_qty', 0) * (c.get('held_avg_price') or 0), reverse=True)
    watching = sorted([c for c in active if c.get('mode') != 'held'],
                      key=lambda c: c.get('saved_at', ''), reverse=True)
    trash.sort(key=lambda c: c.get('pending_delete_at', ''), reverse=True)
    sections: list[str] = []
    if held:
        sections.append(f'<div class="section-label">보유중<span class="count">{len(held)}</span></div>')
        sections.append('\n'.join(_render_row(c) for c in held))
    if watching:
        sections.append(f'<div class="section-label">감시<span class="count">{len(watching)}</span></div>')
        sections.append('\n'.join(_render_row(c) for c in watching))
    if trash:
        sections.append(f'<div class="section-label trash-label">삭제예정<span class="count">{len(trash)}</span></div>')
        sections.append('\n'.join(_render_row(c) for c in trash))
    return '\n'.join(sections), len(cards)


def _interest_menu_html(reg: dict) -> str:
    """풀다운 오른쪽 '+' 버튼 + 드롭다운 메뉴(종목 추가 / 그룹 관리). 관심종목 패널 상단 컨트롤 행에 둔다.
    그룹 관리 항목에 현재 그룹명·종목수를 실어보내 모달을 서버 왕복 없이 즉시 채운다."""
    all_groups = reg.get('groups', [])
    counts: dict = {}
    for names in reg.get('members', {}).values():
        for n in names:
            counts[n] = counts.get(n, 0) + 1
    all_attr = html.escape(','.join(all_groups), quote=True)
    counts_attr = html.escape(json.dumps(counts, ensure_ascii=True), quote=True)
    return (
        '<div class="igrp-menu-wrap">'
        '<button type="button" class="igrp-add-btn" data-igrp-menu-toggle aria-label="추가" aria-expanded="false">+</button>'
        '<div class="igrp-menu" data-igrp-menu hidden>'
        '<button type="button" class="igrp-menu-item" data-modal-open="interest-modal">종목 추가</button>'
        f'<button type="button" class="igrp-menu-item" data-group-manage="1" data-group-all="{all_attr}" data-group-counts="{counts_attr}">그룹 관리</button>'
        '</div>'
        '</div>'
    )


def _render_interests_edit_modal() -> str:
    """등록된 관심종목의 매수가/목표가/손절가/메모 수정 모달. shell HTML 직속에 둠.
    삭제 버튼은 모달 내부에 별도 form(interest-delete-form 클래스)로 두고
    HTML5 form attribute로 modal-actions의 submit 버튼에 연결 — 행 actions에서 빼냈다."""
    return (
        '<div id="interest-edit-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="interest-edit-modal-title">'
        '<div class="modal-overlay" data-modal-close="1"></div>'
        '<div class="modal-box" role="document">'
        '<div class="modal-head">'
        '<button type="submit" form="interest-edit-delete-form" class="btn-delete">삭제</button>'
        '<div class="modal-title" id="interest-edit-modal-title">관심종목 수정 — <span data-edit-title></span></div>'
        '<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
        '</div>'
        '<form class="add-form" id="interest-edit-form" method="post" action="/interests/update">'
        '<input type="hidden" name="stock">'
        '<div class="add-form-grid">'
        '<label>매수가 <input name="buy_price" inputmode="decimal" placeholder="비우면 제거" autocomplete="off"></label>'
        '<label>목표가 <input name="target_price" inputmode="decimal" placeholder="비우면 제거" autocomplete="off"></label>'
        '<label>손절가 <input name="stop_price" inputmode="decimal" placeholder="비우면 제거" autocomplete="off"></label>'
        '<label>메모 <input name="memo" maxlength="120" placeholder="비우면 제거" autocomplete="off"></label>'
        '</div>'
        '<div class="form-feedback" data-feedback hidden></div>'
        '</form>'
        '<form id="interest-edit-delete-form" class="interest-delete-form" method="post" action="/interests/delete">'
        '<input type="hidden" name="stock">'
        '</form>'
        '<div class="modal-actions">'
        '<button type="button" class="btn-cancel" data-modal-close="1">취소</button>'
        '<button type="submit" form="interest-edit-form" class="btn-add">저장</button>'
        '</div>'
        '</div>'
        '</div>'
    )


def _render_tag_modal() -> str:
    """종목 공용 태그 설정 모달. 어느 탭의 칩이든 +태그 버튼이든 동일하게 연다.
    대장 버튼은 토글 — 클릭 즉시 set/clear & submit. 자유 입력은 텍스트칸 + 저장."""
    return (
        '<div id="tag-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="tag-modal-title">'
        '<div class="modal-overlay" data-modal-close="1"></div>'
        '<div class="modal-box" role="document">'
        '<div class="modal-head">'
        '<div class="modal-title" id="tag-modal-title">종목 태그 — <span data-tag-title></span></div>'
        '<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
        '</div>'
        '<form class="add-form" id="tag-form" method="post" action="/tags/set">'
        '<input type="hidden" name="stock">'
        '<input type="hidden" name="code">'
        '<input type="hidden" name="leader" value="0">'
        '<div class="tag-quick-row">'
        '<button type="button" class="btn-tag-quick" data-tag-toggle="leader">대장</button>'
        '<span class="muted small">색깔 토글 — 텍스트는 그대로 두고 빨강만 on/off</span>'
        '</div>'
        '<div class="add-form-grid">'
        '<label>태그 <input name="tag" maxlength="20" placeholder="자유 입력 (예: 반도체, # 생략)" autocomplete="off"></label>'
        '</div>'
        '<div class="form-feedback" data-feedback hidden></div>'
        '</form>'
        '<div class="modal-actions">'
        '<button type="button" class="btn-cancel" data-modal-close="1">취소</button>'
        '<button type="button" class="btn-delete" data-tag-delete>삭제</button>'
        '<button type="submit" form="tag-form" class="btn-add">저장</button>'
        '</div>'
        '</div>'
        '</div>'
    )


def _render_note_modal() -> str:
    """종목별 자유 메모 모달. 어느 탭의 '메모' 버튼이든 동일하게 연다.
    저장은 텍스트 그대로, 삭제는 빈 텍스트 submit 으로 항목 제거."""
    return (
        '<div id="note-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="note-modal-title">'
        '<div class="modal-overlay" data-modal-close="1"></div>'
        '<div class="modal-box" role="document">'
        '<div class="modal-head">'
        '<div class="note-head-left">'
        '<div class="modal-title" id="note-modal-title">메모 — <span data-note-title></span></div>'
        '<span class="note-del-wrap">'
        '<button type="button" class="btn-delete" data-note-delete>삭제</button>'
        '<div class="note-confirm hidden" data-note-confirm>'
        '<span class="note-confirm-msg">삭제할까요?</span>'
        '<button type="button" class="note-confirm-yes" data-note-confirm-yes>예</button>'
        '<button type="button" class="note-confirm-no" data-note-confirm-no>아니오</button>'
        '</div>'
        '</span>'
        '</div>'
        '<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
        '</div>'
        '<form class="add-form" id="note-form" method="post" action="/notes/set">'
        '<input type="hidden" name="stock">'
        '<input type="hidden" name="code">'
        '<div class="add-form-grid">'
        '<label>메모 <textarea name="note" rows="6" maxlength="1000" placeholder="이 종목에 대한 메모 (최대 1000자)" autocomplete="off"></textarea></label>'
        '</div>'
        '<div class="form-feedback" data-feedback hidden></div>'
        '</form>'
        '<div class="modal-actions">'
        '<button type="button" class="btn-cancel" data-modal-close="1">취소</button>'
        '<button type="submit" form="note-form" class="btn-add">저장</button>'
        '</div>'
        '</div>'
        '</div>'
    )


def _render_calc_modal() -> str:
    """등락률 계산기 모달. ☰ 옵션 팝업의 '계산기' 항목이 연다.
    기준가·현재가를 입력하면 등락률(%)과 차이 금액을 클라이언트에서 즉시 계산.
    서버 왕복·키움 호출 없음."""
    return (
        '<div id="calc-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="calc-modal-title">'
        '<div class="modal-overlay" data-modal-close="1"></div>'
        '<div class="modal-box" role="document">'
        '<div class="modal-head">'
        '<div class="modal-title" id="calc-modal-title">등락률 계산기</div>'
        '<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
        '</div>'
        '<div class="add-form">'
        '<div class="add-form-grid">'
        '<label>기준가 <input type="number" inputmode="decimal" step="any" data-calc-base placeholder="예: 10000" autocomplete="off"></label>'
        '<label>현재가 <input type="number" inputmode="decimal" step="any" data-calc-cur placeholder="예: 11250" autocomplete="off"></label>'
        '<label>주수 <span class="calc-sub">(선택 · 수익금 계산)</span><input type="number" inputmode="numeric" step="any" data-calc-qty placeholder="예: 100" autocomplete="off"></label>'
        '</div>'
        '<label class="calc-sym-row">종목에서 현재가 가져오기 '
        '<select class="order-symbol-select" data-calc-symbol aria-label="관심·보유·감시 종목에서 현재가 불러오기">'
        '<option value="">종목 선택 (관심·보유·감시)…</option>'
        '</select></label>'
        '<div class="calc-sym-status" data-calc-sym-status></div>'
        '<div class="calc-result" data-calc-result>'
        '<div class="calc-line"><span class="calc-lbl">등락률</span><span class="calc-val" data-calc-pct>—</span></div>'
        '<div class="calc-line"><span class="calc-lbl">차이</span><span class="calc-val" data-calc-diff>—</span></div>'
        '<div class="calc-line"><span class="calc-lbl">수익금 <span class="calc-sub" data-calc-profit-sub></span></span><span class="calc-val" data-calc-profit>—</span></div>'
        '<div class="calc-line"><span class="calc-lbl">기준가 회복 필요</span><span class="calc-val" data-calc-recover>—</span></div>'
        '<div class="calc-hint" data-calc-hint>기준가·현재가를 입력하세요.</div>'
        '</div>'
        '</div>'
        '<div class="modal-actions">'
        '<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
        '</div>'
        '</div>'
        '</div>'
    )


def _render_avg_calc_modal() -> str:
    """평단 계산기(물타기) 모달. ☰ 옵션 팝업의 '평단 계산기' 항목이 연다.
    보유수량·현재평단가·추가매수 주수·추가매수가 → 새 평단·총수량·총매입금 즉시 계산(클라이언트).
    '내 계좌 종목'을 고르면 보유수량·현재평단가 자동 입력(/api/holdings, code별 전 계좌 가중평균)."""
    return (
        '<div id="avgcalc-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="avgcalc-modal-title">'
        '<div class="modal-overlay" data-modal-close="1"></div>'
        '<div class="modal-box" role="document">'
        '<div class="modal-head">'
        '<div class="modal-title" id="avgcalc-modal-title">평단 계산기</div>'
        '<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
        '</div>'
        '<div class="add-form">'
        '<label class="calc-sym-row">내 계좌 종목에서 불러오기 '
        '<select class="order-symbol-select" data-avg-symbol aria-label="보유 종목에서 평단·수량 불러오기">'
        '<option value="">보유 종목 선택…</option>'
        '</select></label>'
        '<div class="calc-sym-status" data-avg-sym-status></div>'
        '<div class="add-form-grid">'
        '<label>보유수량 <input type="number" inputmode="numeric" step="any" data-avg-hqty placeholder="예: 100" autocomplete="off"></label>'
        '<label>현재평단가 <input type="number" inputmode="decimal" step="any" data-avg-havg placeholder="예: 70000" autocomplete="off"></label>'
        '<label>추가매수 주수 <input type="number" inputmode="numeric" step="any" data-avg-aqty placeholder="예: 50" autocomplete="off"></label>'
        '<label>추가매수가 <span class="calc-price-row"><input type="number" inputmode="decimal" step="any" data-avg-aprice placeholder="예: 60000" autocomplete="off"><button type="button" class="calc-curbtn" data-avg-curprice>현재가</button></span></label>'
        '<label>추가매수 금액 <input type="number" inputmode="numeric" step="any" data-avg-aamt placeholder="예: 3000000" autocomplete="off"></label>'
        '</div>'
        '<div class="calc-result" data-avg-result>'
        '<div class="calc-line"><span class="calc-lbl">실 매수금액 <span class="calc-sub" data-avg-addqty></span></span><span class="calc-val" data-avg-addcost>—</span></div>'
        '<div class="calc-line"><span class="calc-lbl">새 평단가</span><span class="calc-val" data-avg-newavg>—</span></div>'
        '<div class="calc-line"><span class="calc-lbl">총 수량</span><span class="calc-val" data-avg-totqty>—</span></div>'
        '<div class="calc-line"><span class="calc-lbl">총 매입금</span><span class="calc-val" data-avg-totcost>—</span></div>'
        '<div class="calc-hint" data-avg-hint>보유수량·현재평단가·추가매수 주수(또는 금액)·추가매수가를 입력하세요.</div>'
        '</div>'
        '</div>'
        '<div class="modal-actions">'
        '<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
        '</div>'
        '</div>'
        '</div>'
    )


def _render_holdeval_modal() -> str:
    """보유 평가 계산기 모달. ☰ 옵션 팝업의 '보유 평가 계산기' 항목이 연다.
    저장된 목록을 먼저 보여주고, 항목을 누르면 현재가를 조회해 등락률·평가손익·매입/평가금액을 표시.
    하단 '추가' 버튼이 입력 팝업(holdeval-add-modal)을 연다. /api/holdeval/* + /api/quote_book 재사용."""
    return (
        '<div id="holdeval-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="holdeval-modal-title">'
        '<div class="modal-overlay" data-modal-close="1"></div>'
        '<div class="modal-box" role="document">'
        '<div class="modal-head">'
        '<div class="modal-title" id="holdeval-modal-title">보유 평가 계산기</div>'
        '<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
        '</div>'
        '<div class="add-form">'
        '<div class="he-saved-title">저장된 목록</div>'
        '<div class="he-saved-list" data-he-saved><div class="muted small">불러오는 중…</div></div>'
        '<div class="he-add-row"><button type="button" class="he-add-btn" data-modal-open="holdeval-add-modal">＋ 추가</button></div>'
        '<div class="calc-result" data-he-result>'
        '<div class="he-result-headrow"><span class="he-result-head" data-he-result-head>목록에서 항목을 누르면 현재 상태가 표시됩니다.</span>'
        '<button type="button" class="calc-curbtn he-refresh-btn hidden" data-he-refresh>갱신</button></div>'
        '<div class="calc-line"><span class="calc-lbl">등락률</span><span class="calc-val" data-he-pct>—</span></div>'
        '<div class="calc-line"><span class="calc-lbl">평가손익 <span class="calc-sub" data-he-pl-sub></span></span><span class="calc-val" data-he-pl>—</span></div>'
        '<div class="he-detail-sep"></div>'
        '<div class="calc-line he-detail"><span class="calc-lbl">종목</span><span class="calc-val" data-he-name>—</span></div>'
        '<div class="calc-line he-detail"><span class="calc-lbl">현재가</span><span class="calc-val" data-he-price>—</span></div>'
        '<div class="calc-line he-detail"><span class="calc-lbl">평단가</span><span class="calc-val" data-he-avgline>—</span></div>'
        '<div class="calc-line he-detail"><span class="calc-lbl">주수</span><span class="calc-val" data-he-qtyline>—</span></div>'
        '<div class="calc-line he-detail"><span class="calc-lbl">매입금액</span><span class="calc-val" data-he-cost>—</span></div>'
        '<div class="calc-line he-detail"><span class="calc-lbl">평가금액</span><span class="calc-val" data-he-value>—</span></div>'
        '</div>'
        '</div>'
        '<div class="modal-actions">'
        '<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
        '</div>'
        '</div>'
        '</div>'
    )


def _render_holdeval_add_modal() -> str:
    """보유 평가 계산기 '추가' 팝업(중첩 modal-top). 이름·종목검색·평단가·주수 입력 후 저장하면
    holdeval-modal 목록에 추가된다. 현재가는 저장하지 않고 조회 시점에 라이브로 가져오므로 여기선 안 받는다."""
    return (
        '<div id="holdeval-add-modal" class="modal modal-top hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="holdeval-add-modal-title">'
        '<div class="modal-overlay" data-modal-close="1"></div>'
        '<div class="modal-box" role="document">'
        '<div class="modal-head">'
        '<div class="modal-title" id="holdeval-add-modal-title">항목 추가</div>'
        '<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
        '</div>'
        '<div class="add-form">'
        '<label class="calc-sym-row">이름 <span class="calc-sub">(예: 방효원 · 선택)</span>'
        '<div class="info-search-row"><input type="text" class="info-search-input" data-he-owner placeholder="예: 방효원" autocomplete="off" maxlength="40"></div></label>'
        '<label class="calc-sym-row">종목 검색 <span class="calc-sub">(이름 또는 6자리 코드)</span>'
        '<div class="info-search-row">'
        '<input type="text" class="info-search-input" data-he-search placeholder="예: 삼성전자 / 005930" autocomplete="off" enterkeyhint="search">'
        '<button type="button" class="info-search-btn" data-he-search-btn>조회</button>'
        '</div></label>'
        '<div class="info-search-results hidden" data-he-results></div>'
        '<div class="calc-sym-status" data-he-sym-status></div>'
        '<div class="add-form-grid">'
        '<label>평단가 <input type="number" inputmode="decimal" step="any" data-he-avg placeholder="예: 60000" autocomplete="off"></label>'
        '<label>주수 <input type="number" inputmode="numeric" step="any" data-he-qty placeholder="예: 100" autocomplete="off"></label>'
        '</div>'
        '<div class="he-merge-wrap hidden" data-he-merge-wrap>'
        '<div class="he-merge-title">추가 매수 합산 <span class="calc-sub">(물타기·불타기 — 입력 시 위 평단가·주수 자동 갱신)</span></div>'
        '<div class="add-form-grid">'
        '<label>추가 단가 <input type="number" inputmode="decimal" step="any" data-he-addprice placeholder="예: 55000" autocomplete="off"></label>'
        '<label>추가 수량 <input type="number" inputmode="numeric" step="any" data-he-addqty placeholder="예: 20" autocomplete="off"></label>'
        '</div>'
        '<div class="calc-hint" data-he-merge-hint></div>'
        '</div>'
        '<div class="he-save-row"><button type="button" class="he-save-btn" data-he-save-btn>💾 저장</button></div>'
        '</div>'
        '<div class="modal-actions">'
        '<button type="button" class="btn-cancel" data-modal-close="1">취소</button>'
        '</div>'
        '</div>'
        '</div>'
    )


def _render_group_assign_modal() -> str:
    """관심종목의 그룹 소속 편집 모달. 어느 관심종목 행이든 '그룹' 버튼이 동일하게 연다.
    체크박스는 JS가 data-group-all 로 렌더·data-group-current 로 체크. 새 그룹 인라인 추가 가능."""
    return (
        '<div id="group-assign-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="group-assign-modal-title">'
        '<div class="modal-overlay" data-modal-close="1"></div>'
        '<div class="modal-box" role="document">'
        '<div class="modal-head">'
        '<div class="modal-title" id="group-assign-modal-title">그룹 배정 — <span data-group-title></span></div>'
        '<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
        '</div>'
        '<form class="add-form" id="group-assign-form" method="post" action="/interests/groups/set">'
        '<input type="hidden" name="stock">'
        '<div class="group-checks" data-group-checks><div class="muted small">그룹이 없습니다. 아래에서 새로 만들어보세요.</div></div>'
        '<div class="add-form-grid">'
        '<label>새 그룹 <input name="new_group" maxlength="20" placeholder="예: 반도체 (선택)" autocomplete="off"></label>'
        '</div>'
        '<div class="form-feedback" data-feedback hidden></div>'
        '</form>'
        '<div class="modal-actions">'
        '<button type="button" class="btn-cancel" data-modal-close="1">취소</button>'
        '<button type="submit" form="group-assign-form" class="btn-add">저장</button>'
        '</div>'
        '</div>'
        '</div>'
    )


def _render_group_manage_modal() -> str:
    """관심종목 그룹 레지스트리 관리 모달. 생성/이름변경/삭제/순서변경.
    각 op는 keepOpen fetch로 /interests/groups 호출 후 응답 groups 로 리스트 재렌더."""
    return (
        '<div id="group-manage-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="group-manage-modal-title">'
        '<div class="modal-overlay" data-modal-close="1"></div>'
        '<div class="modal-box" role="document">'
        '<div class="modal-head">'
        '<div class="modal-title" id="group-manage-modal-title">그룹 관리</div>'
        '<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
        '</div>'
        '<div class="group-manage-list" data-group-list></div>'
        '<div class="group-new-row">'
        '<input data-group-new-input maxlength="20" placeholder="새 그룹 이름" autocomplete="off">'
        '<button type="button" class="btn-add" data-group-new-add>추가</button>'
        '</div>'
        '<div class="form-feedback" data-feedback hidden></div>'
        '<div class="modal-actions">'
        '<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
        '</div>'
        '</div>'
        '</div>'
    )


def _render_candidate_modal() -> str:
    """다중 매칭 시 사용자가 종목을 선택하는 별도 팝업. 추가 모달과 분리된 z-index 위층."""
    return (
        '<div id="candidate-modal" class="modal modal-top hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="candidate-modal-title">'
        '<div class="modal-overlay" data-modal-close="1"></div>'
        '<div class="modal-box" role="document">'
        '<div class="modal-head">'
        '<div class="modal-title" id="candidate-modal-title">종목 선택</div>'
        '<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
        '</div>'
        '<div class="feedback-title" data-cand-title></div>'
        '<div class="candidate-list" data-cand-list></div>'
        '<div class="modal-actions">'
        '<button type="button" class="btn-cancel" data-modal-close="1">취소</button>'
        '</div>'
        '</div>'
        '</div>'
    )


def _render_stock_name_modal() -> str:
    """거래내역 표에서 종목 셀 길게 누르면 풀네임 표시 — press-and-hold tooltip.
    셀 위쪽으로 띄워 손가락에 가리지 않게. pointerdown=show / pointerup=hide."""
    return (
        '<div id="stock-name-tip" class="stock-name-tip" role="tooltip" aria-hidden="true"></div>'
    )


def _render_trade_modal() -> str:
    """종목별 거래내역 팝업. shell HTML 직속이라 panels swap 영향 없음. trigger 클릭 → fetch → table 렌더."""
    return (
        '<div id="trade-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="trade-modal-title">'
        '<div class="modal-overlay" data-modal-close="1"></div>'
        '<div class="modal-box modal-box-wide" role="document">'
        '<div class="modal-head">'
        '<div class="modal-title" id="trade-modal-title"><span data-trade-title></span></div>'
        '<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
        '</div>'
        '<div class="trade-summary muted small" data-trade-summary></div>'
        '<div class="trade-body" data-trade-body><div class="muted small">불러오는 중…</div></div>'
        '<div class="modal-actions">'
        '<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
        '</div>'
        '</div>'
        '</div>'
    )


def _render_info_modal() -> str:
    """종목별 기업정보 팝업 (시총·PER·PBR·EPS·BPS·ROE·52주·외국인비율 등).
    shell HTML 직속이라 panels swap 영향 없음. '상세' 버튼 클릭 → /api/stock_info fetch → 렌더."""
    return (
        '<div id="info-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="info-modal-title">'
        '<div class="modal-overlay" data-modal-close="1"></div>'
        '<div class="modal-box" role="document">'
        '<div class="modal-head">'
        '<div class="modal-title" id="info-modal-title"><span data-info-title>기업정보</span></div>'
        '<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
        '</div>'
        # 기업비교 바 — 종목 검색 + 내 목록(보유·관심·감시) 드롭다운으로 비교 대상 추가.
        '<div class="info-compare-bar">'
        '<div class="info-search-row">'
        '<input type="text" class="info-search-input" data-info-search placeholder="🔍 종목 검색 (이름·코드)" autocomplete="off">'
        '<button type="button" class="info-search-btn" data-info-search-btn>검색</button>'
        '</div>'
        '<select class="info-symbols-select" data-info-symbols aria-label="내 목록에서 비교 추가">'
        '<option value="">＋ 내 목록에서 추가</option>'
        '</select>'
        '</div>'
        '<div class="info-search-results hidden" data-info-search-results></div>'
        '<div class="info-body" data-info-body><div class="muted small">불러오는 중…</div></div>'
        '<div class="modal-actions">'
        '<a class="btn-analyze info-analyze-link" data-info-analyze href="#" target="_blank" rel="noopener" title="분석 보고서 페이지 열기">📊 분석 보고서</a>'
        '<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
        '</div>'
        '</div>'
        '</div>'
    )


def _render_info_desc_modal() -> str:
    """지수 설명 팝업. info-modal 위에 겹쳐 뜨는 modal-top. ? 버튼 클릭 시 용어·설명 표시."""
    return (
        '<div id="info-desc-modal" class="modal hidden modal-top" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="info-desc-modal-title">'
        '<div class="modal-overlay" data-modal-close="1"></div>'
        '<div class="modal-box info-desc-box" role="document">'
        '<div class="modal-head">'
        '<div class="modal-title" id="info-desc-modal-title"><span data-desc-term>지수 설명</span></div>'
        '<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
        '</div>'
        '<div class="info-desc-body" data-desc-body></div>'
        '<div class="modal-actions">'
        '<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
        '</div>'
        '</div>'
        '</div>'
    )


def _render_advice_modal() -> str:
    """기술적 권장가 팝업. order-modal 위에 겹쳐 뜨는 modal-top. [💡 권장가 보기] 클릭 시 표시.
    가격 행 탭 → 단가 자동입력(호가단위 스냅) 후 팝업 닫힘. ▸ 근거로 계산식 설명 토글."""
    return (
        '<div id="advice-modal" class="modal hidden modal-top" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="advice-modal-title">'
        '<div class="modal-overlay" data-modal-close="1"></div>'
        '<div class="modal-box advice-box" role="document">'
        '<div class="modal-head">'
        '<div class="modal-title" id="advice-modal-title"><span data-advice-title>💡 권장가</span></div>'
        '<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
        '</div>'
        '<div class="advice-body" data-advice-body><div class="muted small">불러오는 중…</div></div>'
        '<div class="modal-actions">'
        '<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
        '</div>'
        '</div>'
        '</div>'
    )


def _render_order_modal() -> str:
    """주문 진입 모달 (매수/매도). 호가창 1초 폴링, PIN OTP 흐름.

    1단계: 종목·계좌·수량·단가 입력 → /api/order/propose (PIN 텔레그램 발송)
    2단계: PIN 입력 → /api/order/verify (실주문 실행)
    3단계: 결과 표시
    """
    return (
        '<div id="order-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="order-modal-title">'
        '<div class="modal-overlay" data-modal-close="1"></div>'
        '<div class="modal-box order-box" role="document">'
        '<div class="modal-head">'
        '<div class="modal-title" id="order-modal-title" style="flex:1; min-width:0;">'
        '<select class="order-symbol-select" data-order-symbol-select aria-label="종목 변경">'
        '<option value="">종목 선택</option>'
        '</select>'
        '</div>'
        '</div>'
        '<div class="order-meta">'
        '<span class="cur-price" data-order-cur-price>—</span>'
        '<span class="change" data-order-change>—</span>'
        '<span class="order-market-phase" data-market-phase>—</span>'
        '</div>'
        '<div class="order-adr-banner hidden" data-order-adr></div>'
        '<div class="order-modal-msg hidden" data-order-msg></div>'
        # 1단계: 입력
        '<div data-order-step="1">'
        '<div class="order-side-toggle">'
        '<button type="button" data-side="BUY" class="active">매수</button>'
        '<button type="button" data-side="SELL">매도</button>'
        '<button type="button" class="info-tab" data-show-active title="진행 중인 카드(PIN 발급된 거래) + 미체결 주문 종목 수">📋 진행중<span data-active-count></span></button>'
        '</div>'
        '<div class="order-body">'
        '<div class="orderbook" data-orderbook>'
        '<div class="loading" style="padding:30px 0;"><div class="loading-spin"></div>호가 로딩중…</div>'
        '</div>'
        '<div class="order-inputs">'
        '<label class="inline-row">계좌<select data-order-account></select></label>'
        '<label class="inline-row">주문유형<select data-order-type>'
        '<option value="LIMIT">지정가</option>'
        '<option value="MARKET">시장가</option>'
        '<option value="STOP_LIMIT">스톱지정가 (하락 시 매도)</option>'
        '<option value="TRAILING_STOP">트레일링 스톱 (고점 따라 손절선 상승)</option>'
        '</select></label>'
        '<label data-order-stop-row style="display:none;">'
        '<span>조건단가 <span class="muted small" style="font-weight:400;">(이 가격 도달 시 매도)</span></span>'
        '<input type="number" inputmode="numeric" min="0" step="1" data-order-stop-input>'
        '</label>'
        # 트레일링: 폭(%)만 입력받고 조건단가·지정가는 서버가 현재가 기준으로 계산.
        # 미리보기는 클라이언트에서 같은 식으로 계산해 보여주되 실제 값은 서버 계산분이 승자.
        # 고점 기준 select 제거(2026-07-30) — 과거 고점(52주·매수후) 기준은 손절선이 현재가
        # 위로 올라가 등록 즉시 발동하는 구조라 쓸 수 없었다. 초기 고점은 등록 시점 현재가 고정.
        '<label data-order-trail-row style="display:none;">'
        # 단위(%·원)를 라벨에 박는다 — 입력칸 옆에 붙이면 가로로 벌어져 스크롤이 생기고,
        # number input 안쪽 absolute 접미사는 데스크톱 스피너와 겹친다.
        # 계단식(2026-07-31): 하락률이 깊어질수록 더 많이 파는 분할 매도. 프리셋으로 고르고
        # 그 자리에서 계단을 고칠 수 있다. 계단 1개짜리 프리셋이 종전의 단일 트레일링이다.
        '<span style="margin-top:8px;">매도 계단 '
        '<span class="muted small" style="font-weight:400;">고점 대비 얼마 빠지면 얼마나 팔지</span></span>'
        # 프리셋은 고정 4종 읽기 전용 — 고른 뒤 아래 표에서 그 자리에 고쳐 쓴다.
        '<select data-trail-preset></select>'
        '<div class="trail-steps" data-trail-steps></div>'
        '<button type="button" class="trail-add-btn" data-trail-add>+ 계단 추가</button>'
        '<span style="margin-top:8px;">최저 매도가 <b style="color:#d9a84a;">(원)</b> <span class="muted small" style="font-weight:400;">비우면 제한 없음 · 손절선 하한</span></span>'
        '<input type="number" inputmode="numeric" min="0" step="1" placeholder="예: 4500" data-order-trail-min>'
        '<div data-order-trail-preview style="margin-top:6px; min-width:0;"></div>'
        '</label>'
        '<label data-order-price-row><span data-order-price-label>단가</span>'
        '<input type="number" inputmode="numeric" min="0" step="1" data-order-price-input>'
        '<div class="price-tick-buttons">'
        '<button type="button" data-tick-dir="-1">▼ 한 호가</button>'
        '<button type="button" data-tick-dir="1">▲ 한 호가</button>'
        '<button type="button" class="advice-open-btn" data-advice-open>💡 권장가</button>'
        '</div>'
        '</label>'
        '<label data-order-budget-row>'
        '<span>금액 <span class="muted small" style="font-weight:400;">(매수액 → 수량 자동)</span></span>'
        '<input type="number" inputmode="numeric" min="0" step="1000" placeholder="예: 1000000" data-order-budget>'
        '</label>'
        '<label><span>수량 <span class="max-qty" data-order-max>(최대: —)</span></span>'
        '<input type="number" inputmode="numeric" min="0" step="1" data-order-qty>'
        '<div class="qty-step-buttons" data-qty-buttons></div>'
        '</label>'
        '<div class="order-info" data-order-info>—</div>'
        '<div class="order-actions">'
        '<button type="button" class="btn-cancel" data-modal-close="1">취소</button>'
        '<button type="button" class="btn-confirm" data-order-submit data-side="BUY">매수</button>'
        '</div>'
        '</div>'
        '</div>'
        '</div>'
        '</div>'
        '</div>'
    )


def _render_open_orders_modal() -> str:
    """매매등록된 미체결 주문 목록 팝업. [📋 진행중] 탭이 표시 — 4계좌 통합 + 행별 취소.

    각 행: 종목·계좌·방향·수량·단가·상태 + [취소] 버튼 → POST /api/orders/cancel.
    """
    return (
        '<div id="open-orders-modal" class="modal hidden modal-top" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="open-orders-modal-title">'
        '<div class="modal-overlay" data-modal-close="1"></div>'
        '<div class="modal-box open-orders-box" role="document">'
        '<div class="modal-head">'
        '<div class="modal-title" id="open-orders-modal-title">📋 진행중 매매</div>'
        '</div>'
        '<div class="open-orders-list" data-open-orders-list>'
        '<div class="loading" style="padding:24px 0;"><div class="loading-spin"></div>불러오는 중…</div>'
        '</div>'
        '<div class="modal-actions">'
        '<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
        '<button type="button" class="btn-add" data-open-orders-refresh>새로고침</button>'
        '</div>'
        '</div>'
        '</div>'
    )


def _render_pin_modal() -> str:
    """매매 카드 PIN 입력 팝업. order-modal의 propose 성공 직후 띄움.

    카드 요약 + PIN 입력 칸 + 만료 카운트다운 + [카드 취소][주문 실행] 액션.
    verify 결과는 같은 모달 안 결과 영역으로 swap.
    """
    return (
        '<div id="pin-modal" class="modal hidden modal-top" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="pin-modal-title">'
        '<div class="modal-overlay" data-modal-close="1"></div>'
        '<div class="modal-box pin-box" role="document">'
        '<div class="modal-head">'
        '<div class="modal-title" id="pin-modal-title">주문 확인 — <span data-pin-symbol>—</span></div>'
        '</div>'
        '<div class="order-modal-msg info" data-pin-card-summary>—</div>'
        # PIN 입력
        '<div data-pin-input-step>'
        '<div class="order-pin-row">'
        '<label for="pin-modal-input">PIN — 텔레그램으로 발송됨 (iOS 자동입력 지원)</label>'
        '<input id="pin-modal-input" type="text" autocomplete="one-time-code" inputmode="numeric" maxlength="8" data-pin-input>'
        '</div>'
        '<div class="order-countdown" data-pin-countdown>—</div>'
        '<div class="order-actions">'
        '<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
        '<button type="button" class="btn-confirm" data-pin-verify>주문 실행</button>'
        '</div>'
        '</div>'
        # 결과
        '<div class="hidden" data-pin-result-step>'
        '<div class="order-modal-msg info" data-pin-result>—</div>'
        '<div class="order-actions">'
        '<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
        '</div>'
        '</div>'
        '</div>'
        '</div>'
    )


def _render_sell_choice_modal() -> str:
    """매도 시 같은 소유자 그룹의 다른 계좌에도 같은 종목 보유 중일 때 띄우는 선택 팝업.

    [두 계좌 모두 매도] / [선택 계좌만 매도] / [취소] 3버튼. order-modal 위에 겹침(modal-top).
    """
    return (
        '<div id="sell-choice-modal" class="modal hidden modal-top" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="sell-choice-modal-title">'
        '<div class="modal-overlay" data-modal-close="1"></div>'
        '<div class="modal-box pin-box" role="document">'
        '<div class="modal-head">'
        '<div class="modal-title" id="sell-choice-modal-title">📌 두 계좌 모두 보유 중</div>'
        '</div>'
        '<div class="order-modal-msg info" data-sell-choice-summary>—</div>'
        '<div class="order-actions" style="flex-direction:column;gap:8px;">'
        '<button type="button" class="btn-confirm" data-sell-choice-both style="width:100%;">두 계좌 모두 매도</button>'
        '<button type="button" class="btn-add" data-sell-choice-single style="width:100%;">선택 계좌만 매도</button>'
        '<button type="button" class="btn-cancel" data-modal-close="1" style="width:100%;">취소</button>'
        '</div>'
        '</div>'
        '</div>'
    )


def _render_order_confirm_modal() -> str:
    """매수/매도 버튼 → propose 직전 방향 확인 팝업. 매수/매도 오타 방지용.

    매수·매도 버튼을 둘 다 띄우고 관리자님이 직접 맞는 방향을 찾아 누르게 함(위치 랜덤).
    주문 방향과 일치하면 진행, 다르면 팝업 닫고 취소. order-modal 위에 겹침(modal-top).
    """
    return (
        '<div id="order-confirm-modal" class="modal hidden modal-top" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="order-confirm-modal-title">'
        '<div class="modal-overlay" data-modal-close="1"></div>'
        '<div class="modal-box pin-box" role="document">'
        '<div class="modal-head">'
        '<div class="modal-title" id="order-confirm-modal-title">방향 확인 — 맞는 것을 누르세요</div>'
        '</div>'
        '<div class="order-modal-msg info" data-order-confirm-summary>—</div>'
        '<div class="order-actions" style="gap:8px;">'
        '<button type="button" class="btn-confirm" data-order-confirm-side="BUY" data-side="BUY">매수</button>'
        '<button type="button" class="btn-confirm" data-order-confirm-side="SELL" data-side="SELL">매도</button>'
        '</div>'
        '<div class="order-actions" style="margin-top:8px;">'
        '<button type="button" class="btn-cancel" data-modal-close="1" style="width:100%;">취소</button>'
        '</div>'
        '</div>'
        '</div>'
    )


def _render_interests_modal() -> str:
    """shell HTML 직속에 두는 종목 추가 모달. panels API의 swap 영역(section.tab-content) 밖이라
    자동 갱신 중에도 DOM·입력값이 보존된다."""
    return (
        '<div id="interest-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="interest-modal-title">'
        '<div class="modal-overlay" data-modal-close="1"></div>'
        '<div class="modal-box" role="document">'
        '<div class="modal-head">'
        '<div class="modal-title" id="interest-modal-title">관심종목 추가</div>'
        '<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
        '</div>'
        '<form class="add-form" id="interest-add-form" method="post" action="/interests/add">'
        '<div class="add-form-grid">'
        '<label>종목명 <input name="stock" maxlength="40" placeholder="예: 카카오 (코드 알면 생략)" autocomplete="off"></label>'
        '<label>종목코드 <input name="code" maxlength="20" placeholder="예: 005930" inputmode="numeric" autocomplete="off"></label>'
        '</div>'
        '<div class="form-feedback" data-feedback hidden></div>'
        '<div class="modal-actions">'
        '<button type="button" class="btn-cancel" data-modal-close="1">취소</button>'
        '<button type="submit" class="btn-add">추가</button>'
        '</div>'
        '</form>'
        '</div>'
        '</div>'
    )


def _render_interests_panel(cards: list[dict]) -> tuple[str, int]:
    sections: list[str] = []
    reg = _load_interest_groups()
    menu_html = _interest_menu_html(reg)  # 풀다운 오른쪽 '+' 메뉴 (종목 추가 / 그룹 관리)
    if not cards:
        sections.append(
            f'<div class="igrp-select-wrap"><span class="igrp-flat-label">관심<span class="count">0</span></span>{menu_html}</div>'
        )
        sections.append('<div class="empty">관심종목이 비어있습니다.</div>')
        return '\n'.join(sections), 0

    show_day_change = _show_day_change_now()
    for c in cards:
        c['_show_day_change'] = show_day_change
    held = sorted([c for c in cards if c.get('mode') == 'held'],
                  key=lambda c: c.get('held_qty', 0) * (c.get('held_avg_price') or 0), reverse=True)
    watching = sorted([c for c in cards if c.get('mode') != 'held'],
                      key=lambda c: c.get('saved_at', ''), reverse=True)
    ordered = held + watching  # 전체·그룹 공용 정렬 (보유중 먼저, 관심은 저장순)

    # 그룹 풀다운 — 레지스트리 순서대로 멤버 있는 그룹만 옵션으로. 다중 소속은 각 패널에 중복 노출.
    # 보유중은 mode=='held' 로 자동 채워지는 옵션(레지스트리 미등록). 선택 상태는 .sub-tab(자산탭)과
    # 충돌 없게 전용 클래스 .igrp-select / .igrp-panel 사용.
    members = reg.get('members', {})
    group_order = reg.get('groups', [])

    def _card_groups(c):
        return [g for g in (members.get(c.get('stock') or '') or []) if g in group_order]

    active_groups = [g for g in group_order if any(g in _card_groups(c) for c in ordered)]
    ungrouped = [c for c in watching if not _card_groups(c)]  # 미분류 = 미보유 & 그룹 미배정

    if not held and not active_groups:
        # 보유·그룹 없음 — 단일 목록 (풀다운 없이 라벨 + '+' 메뉴만).
        sections.append(
            f'<div class="igrp-select-wrap"><span class="igrp-flat-label">관심<span class="count">{len(watching)}</span></span>{menu_html}</div>'
        )
        sections.append('\n'.join(_render_row(c, source='interests') for c in watching))
    else:
        tabs = [('__all__', '전체', ordered)]
        if held:
            tabs.append(('__held__', '보유중', held))
        for g in active_groups:
            tabs.append((g, g, [c for c in ordered if g in _card_groups(c)]))
        if ungrouped:
            tabs.append(('__ungrouped__', '미분류', ungrouped))
        opts = ''.join(
            f'<option value="{html.escape(key, quote=True)}"{" selected" if i == 0 else ""}>'
            f'{html.escape(label)} ({len(bucket)})</option>'
            for i, (key, label, bucket) in enumerate(tabs)
        )
        sections.append(
            f'<div class="igrp-select-wrap"><select class="igrp-select" data-igrp-select'
            f' aria-label="그룹 선택">{opts}</select>{menu_html}</div>'
        )
        panels = ''.join(
            f'<div class="igrp-panel" data-igrp-panel="{html.escape(key, quote=True)}" role="tabpanel"'
            f'{"" if i == 0 else " hidden"}>'
            + '\n'.join(_render_row(c, source='interests') for c in bucket)
            + '</div>'
            for i, (key, label, bucket) in enumerate(tabs)
        )
        sections.append(panels)
    return '\n'.join(sections), len(cards)


# /api/panels?owner= 쿼리 값 → 내부 owner 키. None이면 전체.
TAB_KEY_TO_OWNER = {'self': '본인', 'gahee': '가희'}


def _build_panels_payload(owner: str | None = None, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS, chart_ym: str = '') -> dict:
    """fetch_all_data + 탭별 패널 HTML 생성. /api/panels와 캐시의 단위.
    owner 지정 시: 해당 owner 계좌만 호출하고 응답 tabs는 owner 탭 하나만 (summary·wl 제외).
    클라이언트가 partial swap으로 사용 — 보지 않는 owner 호출 폭주 방지.

    부수효과: fetched owner_data를 `_owner_data_cache`에 owner 단위로 적재해
    이후 partial fetch가 'all' 캐시의 summary 슬라이스를 in-place 갱신할 때 재사용한다."""
    wl_entries = _load_watchlist()
    in_entries = _load_interests()
    combined_entries = wl_entries + in_entries
    data = _fetch_all_data(combined_entries, only_owner=owner)
    all_cards = data['cards']
    wl_cards = all_cards[:len(wl_entries)]
    in_cards = all_cards[len(wl_entries):]
    owner_data = data['owner_data']
    ordered_owners = data['ordered_owners']
    balances = data['balances']
    journal_by_label = data.get('journal_by_label', {}) or {}
    _save_owner_slices(owner_data, balances, journal_by_label)

    now = datetime.now(KST).strftime('%Y-%m-%d %H:%M:%S')
    fetched_hms = now  # 셸 초기 렌더의 "갱신 YYYY-MM-DD HH:MM:SS" 포맷과 통일.
    market_active = _market_active_now()
    show_day_change = _show_day_change_now()

    tabs: list[dict] = []
    if owner is None:
        # 전체 — 모든 탭 채움. 모두 동일 fetched_at.
        wl_body, wl_count = _render_watchlist_panel(wl_cards)
        in_body, in_count = _render_interests_panel(in_cards)
        summary_body = _render_summary_panel(ordered_owners, owner_data, balances, journal_by_label, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
        tabs.append({'id': 'tab-summary', 'label': '자산정보', 'html': summary_body, 'fetched_at': fetched_hms})
        for o in ordered_owners:
            d = owner_data[o]
            tab_id = _owner_tab_id(o)
            full_title = _owner_full_title(o)
            title_short = full_title.replace('님 자산', '').replace(' 자산', '')
            tab_label = title_short
            panel = _render_owner_panel(o, d, balances, full_title, show_day_change, chart_days=chart_days, chart_unit=chart_unit)
            tabs.append({'id': f'tab-{tab_id}', 'label': tab_label, 'html': panel, 'fetched_at': fetched_hms})
        tabs.append({'id': 'tab-interests', 'label': f'관심종목 {in_count}', 'html': in_body, 'fetched_at': fetched_hms})
        tabs.append({'id': 'tab-wl', 'label': f'감시종목 {wl_count}', 'html': wl_body, 'fetched_at': fetched_hms})
    else:
        # owner 단일 — 그 owner 탭만 응답. 클라이언트 apply가 해당 탭만 swap.
        # 다른 탭들의 fetched_at은 클라이언트 측 tabFetched 누적 dict가 유지.
        d = owner_data.get(owner)
        if d:
            tab_id = _owner_tab_id(owner)
            full_title = _owner_full_title(owner)
            title_short = full_title.replace('님 자산', '').replace(' 자산', '')
            tab_label = title_short
            panel = _render_owner_panel(owner, d, balances, full_title, show_day_change, chart_days=chart_days, chart_unit=chart_unit)
            tabs.append({'id': f'tab-{tab_id}', 'label': tab_label, 'html': panel, 'fetched_at': fetched_hms})

    indices = _fetch_indices()
    return {
        'now': now,
        'build': BUILD,
        'market_active': market_active,
        'first_tab_id': 'tab-summary',
        'owner': owner,
        'tabs': tabs,
        'indices': indices,
        'ticker_items': indices,
    }


def _shell_tab_descriptors() -> list[tuple[str, str]]:
    """데이터 fetch 없이 (tab_id, placeholder_label) 순서. 셸 응답이 사용 — owner 라벨은 금액 없이 short form."""
    descs: list[tuple[str, str]] = [('tab-summary', '자산정보')]
    for owner, tid in OWNER_TAB_IDS.items():
        full = _owner_full_title(owner)
        short = full.replace('님 자산', '').replace(' 자산', '')
        descs.append((f'tab-{tid}', short))
    descs.append(('tab-interests', '관심종목'))
    descs.append(('tab-wl', '감시종목'))
    return descs


def render_html() -> str:
    """첫 진입용 셸 HTML — 데이터 fetch 없음, 즉시 응답.
    클라이언트 JS가 /api/panels를 fetch해 본문/라벨을 DOM swap. 자동 갱신·PTR·새로고침 버튼도 같은 fetch 경로로 통일 (location.reload 없음)."""
    descs = _shell_tab_descriptors()
    valid_tids = [tid for tid, _ in descs]
    first_tid = descs[0][0]
    market_active = _market_active_now()
    now = datetime.now(KST).strftime('%Y-%m-%d %H:%M:%S')
    # 계단 프리셋은 셸에 실어 보낸다 — 거래 모달을 열 때마다 fetch 하면 느리고,
    # 고정 4종이라 셸에 넣어도 부담이 없다.
    trail_presets_json = json.dumps(TRAIL_PRESETS, ensure_ascii=False)

    nav_html = '\n'.join(
        f'<a class="tab-label" href="#{tid}">{html.escape(label)}</a>'
        for tid, label in descs
    )
    spinner_placeholder = (
        '<div class="loading"><span class="loading-spin"></span>'
        '<span class="loading-text">불러오는 중…</span></div>'
    )
    content_html = '\n'.join(
        f'<section class="tab-content" data-tab="{tid}">{spinner_placeholder}</section>'
        for tid in valid_tids
    )

    show_rules = ',\n'.join(
        f'html[data-tab="{tid}"] section[data-tab="{tid}"]' for tid in valid_tids
    )
    active_rules = ',\n'.join(
        f'html[data-tab="{tid}"] nav.tabs a[href="#{tid}"]' for tid in valid_tids
    )
    dynamic_css = (
        f'{show_rules},\n'
        f'html:not([data-tab]) section[data-tab="{first_tid}"] {{ display: block; }}\n'
        f'{active_rules},\n'
        f'html:not([data-tab]) nav.tabs a[href="#{first_tid}"] '
        f'{{ color: #f0f0f0; border-bottom-color: #ff4d5e; }}\n'
    )

    valid_tids_js = ','.join(f"'{tid}'" for tid in valid_tids)

    # 탭 활성 동기화 — URL hash → html[data-tab]. CSS-only 전환이라 클릭은 즉시.
    # 자산정보 탭으로 진입할 때는 전체 fetch 트리거 — 모든 owner를 한 화면에 모아 보는 요약 탭이라 stale 최소화.
    head_script = (
        '<script>(function(){'
        f'var V=[{valid_tids_js}],FIRST="{first_tid}";'
        'function s(){var h=(location.hash||"").replace(/^#/,"");'
        'document.documentElement.setAttribute("data-tab",V.indexOf(h)>=0?h:FIRST);}'
        's();'
        # 탭 이동마다 강제 새로고침(fresh) — owner 탭이면 그 owner partial, 그 외(자산정보·관심·감시)는 전체.
        'addEventListener("hashchange",function(){'
            's();'
            'if(window.__behive_set_meta_active)window.__behive_set_meta_active();'
            'if(window.__behive_load){'
                'var a=document.documentElement.getAttribute("data-tab")||"";'
                'var ow=(a==="tab-self"||a==="tab-gahee")?a.slice(4):null;'
                'window.__behive_load(ow,true);'
            '}'
        '});'
        '})();</script>'
    )

    # 패널 fetch + DOM swap.
    #   load() — 전체 (모든 탭 채움, 첫 진입·새로고침·PTR·자산정보 탭 진입)
    #   load("self"|"gahee") — owner 탭 하나만 받아 partial swap (자동 갱신)
    # 응답 tabs는 받은 만큼만 swap하므로 보지 않는 owner의 stale 데이터는 그대로 둠.
    # inFlight를 key별로 분리해 owner 갱신과 전체 fetch가 충돌하지 않도록.
    panels_script = (
        '<script>(function(){'
        'function setMeta(s){var m=document.querySelector("header.top .meta");if(m)m.textContent=s?("갱신 "+s):"";}'
        # 탭별 fetched_at 누적 dict — partial 응답은 자기 탭만 갱신하므로 다른 탭은 이전 값 유지.
        'var tabFetched={};'
        'function setMetaActive(){'
            'var a=document.documentElement.getAttribute("data-tab")||"";'
            'setMeta(tabFetched[a]||"");'
        '}'
        'window.__behive_set_meta_active=setMetaActive;'
        'function setMarket(act){'
            'var prev=window.__behive_market_active;'
            'window.__behive_market_active=!!act;'
            'if(prev!==!!act&&window.__behive_auto_arm)window.__behive_auto_arm();'
        '}'
        'window.__behive_set_market=setMarket;'
        'var tickerItems=[];'
        # 현재 보고 있는 묶음 index. 자동 갱신(20초 주기)에도 유지돼 보던 묶음이 안 튄다.
        'var tickerPage=0;'
        'var TICKER_PER_PAGE=2;'
        'var arrowMap={up:"▲",down:"▼",flat:"-"};'
        'function tickerPages(){return Math.ceil(tickerItems.length/TICKER_PER_PAGE);}'
        # 2줄 칸: 윗줄 지수명 / 아랫줄 가격+등락. 1줄로는 닛케이225 같은 조합이 칸 폭을 넘는다.
        'function tickerCellHtml(item){'
            'var d=item.direction||"flat";'
            'return \'<div class="idx \'+(item.kind||"")+\'">\'+'
                '\'<span class="idx-label">\'+item.label+\'</span>\'+'
                '\'<span class="idx-val"><span class="idx-price">\'+item.price+\'</span>\'+'
                '\'<span class="idx-delta \'+d+\'">\'+arrowMap[d]+\' \'+item.change+\' (\'+item.pct+\'%)</span></span></div>\';'
        '}'
        'function tickerDotsHtml(){'
            'var n=tickerPages();if(n<2)return "";'
            'var s="";'
            'for(var i=0;i<n;i++){s+=(i===tickerPage?\'<i class="on"></i>\':"<i></i>");}'
            'return \'<span class="ticker-dots">\'+s+\'</span>\';'
        '}'
        # animate=true(탭)일 때만 페이드. 자동 갱신은 값만 조용히 교체.
        # 점(.ticker-dots)은 .ticker-page 바깥 형제로 둔다 — 안에 넣으면 탭할 때 같이 깜빡인다.
        'function renderTicker(animate){'
            'var el=document.getElementById("market-ticker");if(!el)return;'
            'if(!tickerItems.length){el.innerHTML=\'<span class="idx-empty">데이터 없음</span>\';return;}'
            'var n=tickerPages();'
            # 지수 조회 실패로 개수가 줄면 clamp. 0으로 되돌리면 보던 묶음에서 튕겨나간다.
            'if(tickerPage>n-1)tickerPage=n-1;'
            'if(tickerPage<0)tickerPage=0;'
            'var start=tickerPage*TICKER_PER_PAGE;'
            'var page=tickerItems.slice(start,start+TICKER_PER_PAGE);'
            'var cells=page.map(tickerCellHtml).join("");'
            # 홀수 개(조회 하나 실패)면 마지막 묶음이 한 칸뿐 → 빈 칸을 채워 왼쪽 절반에 고정.
            'if(page.length<TICKER_PER_PAGE)cells+=\'<div class="idx" aria-hidden="true"></div>\';'
            'el.innerHTML=\'<div class="ticker-page\'+(animate?" fade":"")+\'">\'+cells+\'</div>\'+tickerDotsHtml();'
        '}'
        # 빈 배열이면 기존 표시 유지 (owner-only 응답에서 ticker_items 비어도 stale 보존).
        'function setTicker(arr){'
            'if(!arr||!arr.length){if(!tickerItems.length)renderTicker(false);return;}'
            'tickerItems=arr;'
            'renderTicker(false);'
        '}'
        # 바 아무 곳이나 탭 → 다음 묶음. 마지막이면 처음으로.
        # touchend가 아니라 click을 쓴다: 스크롤 드래그(터치 시작점이 바여도 touchend는 바에 꽂힘)·
        # 멀티터치 중복발화·touchcancel 처리를 브라우저에 맡길 수 있다. 직접 처리하면 셋 다 오탐.
        # 대신 nodbltap_script가 300ms 내 두번째 탭의 click을 삼키므로 거기서 이 바만 예외 처리했다.
        '(function(){'
            'var el=document.getElementById("market-ticker");if(!el)return;'
            'function nextPage(){'
                'var n=tickerPages();if(n<2)return;'
                'tickerPage=(tickerPage+1)%n;'
                'renderTicker(true);'
            '}'
            'el.addEventListener("click",nextPage);'
            'el.addEventListener("keydown",function(e){'
                'if(e.key==="Enter"||e.key===" "){e.preventDefault();nextPage();}'
            '});'
        '})();'
        'function apply(p){'
            # 서버 코드가 갱신됐는데(=build 불일치, __build 없는 옛 페이지 포함) 옛 인라인 JS/CSS로 도는 경우
            # build 당 1회만 강제 리로드(sessionStorage 가드 — 리로드로도 안 맞으면 루프 없이 포기).
            'if(p.build&&window.__build!==p.build){'
                'var __rk="__bld_"+p.build;'
                'try{if(!sessionStorage.getItem(__rk)){sessionStorage.setItem(__rk,"1");location.reload();return;}}catch(_){location.reload();return;}'
            '}'
            'setMarket(!!p.market_active);'
            'setTicker(p.ticker_items||p.indices||[]);'
            'var byId={};(p.tabs||[]).forEach(function(t){'
                'byId[t.id]=t;'
                # partial 응답이라도 자기 탭만 fetched_at 갱신 — 다른 탭 값은 그대로.
                'if(t.fetched_at)tabFetched[t.id]=t.fetched_at;'
            '});'
            'setMetaActive();'
            'document.querySelectorAll("nav.tabs a.tab-label").forEach(function(a){'
                'var id=(a.getAttribute("href")||"").replace(/^#/,"");'
                'var t=byId[id];if(t)a.textContent=t.label;'
            '});'
            'document.querySelectorAll("section.tab-content").forEach(function(sec){'
                'var id=sec.getAttribute("data-tab");var t=byId[id];'
                'if(!t)return;'
                'var openKeys={};'
                'sec.querySelectorAll("details.row[open][data-row-key]").forEach(function(d){'
                    'openKeys[d.getAttribute("data-row-key")]=1;'
                '});'
                'sec.innerHTML=t.html;'
                'Object.keys(openKeys).forEach(function(k){'
                    # 그룹 탭에서 같은 종목이 전체·그룹 패널에 중복 존재 → 매칭되는 모든 행 복원.
                    'sec.querySelectorAll(\'details.row[data-row-key="\'+k.replace(/"/g,\'\\\\"\')+\'"]\').forEach(function(d){d.setAttribute("open","");});'
                '});'
            '});'
            'document.dispatchEvent(new CustomEvent("behive:panels-loaded",{detail:p}));'
        '}'
        'function failVisible(msg){'
            'document.querySelectorAll("section.tab-content").forEach(function(sec){'
                'if(sec.querySelector(".loading"))sec.innerHTML="<div class=\\"empty\\">"+msg+"</div>";'
            '});'
        '}'
        'var inFlight={};'
        # 마지막 성공 갱신 시각 — 5분 초과 시 다음 load는 첫 진입처럼 spinner 리셋 + 전체 fetch로 승격.
        'var lastLoadAt=0,STALE_MS=5*60*1000;'
        'var SPINNER=\'<div class="loading"><span class="loading-spin"></span><span class="loading-text">불러오는 중…</span></div>\';'
        'function resetShellLook(){'
            'document.querySelectorAll("section.tab-content").forEach(function(sec){sec.innerHTML=SPINNER;});'
            'var tk=document.getElementById("market-ticker");'
            'if(tk){tk.innerHTML=\'<span class="idx-empty">지수 불러오는 중…</span>\';tickerItems=[];}'
        '}'
        'function chartDays(){'
            'var v=parseInt(localStorage.getItem("behive.chartDaysV2"),10);'
            'if(isNaN(v))return null;'
            'return v;'
        '}'
        'function chartUnit(){'
            'var v=localStorage.getItem("behive.chartUnit");'
            'if(!v)return null;'
            'if(v!=="day"&&v!=="week"&&v!=="month"&&v!=="year")return null;'
            'return v;'
        '}'
        'function chartMode(){'
            'var v=localStorage.getItem("behive.chartMode");'
            'if(!v)return null;'
            'if(v!=="net"&&v!=="pnl"&&v!=="period")return null;'
            'return v;'
        '}'
        'function adrDays(){'
            'var v=parseInt(localStorage.getItem("behive.adrTrendDays"),10);'
            'if(isNaN(v))return null;'
            # 허용된 preset 값만 통과 (서버 sanity와 일치). preset 변경 시 양쪽 같이 갱신.
            'if(v!==7&&v!==30&&v!==90&&v!==180&&v!==365&&v!==-1)return null;'
            'return v;'
        '}'
        # chart_ym — 연도별('YYYY')/월별('YYYY-MM') 절대 선택. 빈값이면 프리셋(chartDays) 사용.
        'function chartYM(){'
            'var v=localStorage.getItem("behive.chartYM")||"";'
            'var p=v.split("-");'
            'if(p.length===1&&/^\\d{4}$/.test(p[0]))return v;'
            'if(p.length===2&&/^\\d{4}$/.test(p[0])&&/^\\d{2}$/.test(p[1]))return v;'
            'return "";'
        '}'
        'function setYM(v){try{if(v)localStorage.setItem("behive.chartYM",v);else localStorage.removeItem("behive.chartYM");}catch(_){}}'
        'window.__behive_chart_days=chartDays;'
        'window.__behive_chart_unit=chartUnit;'
        'window.__behive_chart_mode=chartMode;'
        'window.__behive_adr_days=adrDays;'
        'window.__behive_chart_ym=chartYM;'
        'window.__behive_set_ym=setYM;'
        'function load(owner,fresh){'
            # 5분 이상 stale 면 전체로 승격하되, 화면은 클리어하지 않고 기존 내용 유지 → fetch 완료 시 교체(swap).
            'if(lastLoadAt&&(Date.now()-lastLoadAt)>STALE_MS){owner=null;}'
            'var key=owner||"all";'
            'if(!fresh&&inFlight[key])return inFlight[key];'
            'var qs=[];if(owner)qs.push("owner="+encodeURIComponent(owner));if(fresh)qs.push("fresh=1");'
            'var cd=chartDays();if(cd!==null)qs.push("chart_days="+cd);'
            'var cu=chartUnit();if(cu!==null)qs.push("chart_unit="+cu);'
            'var cm=chartMode();if(cm!==null)qs.push("chart_mode="+cm);'
            'var ad=adrDays();if(ad!==null)qs.push("adr_days="+ad);'
            'var ym=chartYM();if(ym)qs.push("chart_ym="+encodeURIComponent(ym));'
            'var url="/api/panels"+(qs.length?("?"+qs.join("&")):"");'
            'var pr=fetch(url,{cache:"no-store",credentials:"same-origin"})'
                '.then(function(r){if(!r.ok)throw new Error("HTTP "+r.status);return r.json();})'
                '.then(function(j){apply(j);lastLoadAt=Date.now();})'
                '.catch(function(){failVisible("갱신 실패 — 다시 시도해주세요");});'
            'var done=function(){if(!fresh)delete inFlight[key];};'
            'pr.finally?pr.finally(done):pr.then(done,done);'
            'if(!fresh)inFlight[key]=pr;'
            'return pr;'
        '}'
        'function activeOwner(){'
            'var a=document.documentElement.getAttribute("data-tab")||"";'
            'return (a==="tab-self"||a==="tab-gahee")?a.slice(4):null;'
        '}'
        'var spinCount=0;'
        'function manualRefresh(){'
            'var btn=document.querySelector("a.refresh");'
            'spinCount++;'
            'if(btn)btn.classList.add("spinning");'
            'var p=load(activeOwner(),true);'
            'var done=function(){'
                'spinCount=Math.max(0,spinCount-1);'
                'if(spinCount===0&&btn)btn.classList.remove("spinning");'
                'if(window.__behive_auto_arm)window.__behive_auto_arm();'
            '};'
            'if(p&&p.finally)p.finally(done);'
            'else if(p&&p.then)p.then(done,done);'
            'else done();'
        '}'
        'window.__behive_load=load;'
        'window.__behive_active_owner=activeOwner;'
        'window.__behive_manual_refresh=manualRefresh;'
        'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",function(){load();});'
        'else load();'
        '})();</script>'
    )

    # 순자산 차트 인터랙션: 호버/터치 시 indicator 세로선 + 툴팁(그날 손익·누적 손익).
    # data-pts JSON 파싱 → pointermove로 최근접 포인트 찾아 indicator·tooltip 갱신.
    # MutationObserver로 swap 후 새 .net-chart 자동 재바인딩.
    netchart_script = (
        '<script>(function(){'
        'var W=800;'
        'function fmt(n){return (n<0?"-":"+")+Math.abs(n).toLocaleString()+"원";}'
        'function cls(n){return n>0?"up":(n<0?"down":"neutral");}'
        'function init(root){'
            'if(root.__nw_bound)return;root.__nw_bound=true;'
            'var svg=root.querySelector("svg.net-chart-svg");'
            'var tip=root.querySelector(".net-chart-tip");'
            'var hg=svg&&svg.querySelector("g.nw-hover");'
            'if(!svg||!tip||!hg)return;'
            'var line=hg.querySelector("line"),dot=hg.querySelector("circle");'
            'var pts;try{pts=JSON.parse(root.getAttribute("data-pts")||"[]");}catch(e){pts=[];}'
            'if(!pts.length)return;'
            # 1초 실시간 틱(setOwnerKpis)이 라이브 점을 제자리 갱신할 수 있게 배열·기준값 노출.
            'root.__nw_pts=pts;'
            'var lel=root.querySelector(".net-chart-last");'
            'root.__nw_liveIdx=(lel&&lel.getAttribute("data-rt-nw-live")==="1")?pts.length-1:-1;'
            'if(root.__nw_liveIdx>=0){var _lb=pts[root.__nw_liveIdx];root.__nw_liveBase={v:_lb.v,day:_lb.day,total:_lb.total};}'
            'var dEl=tip.querySelector(".tip-date");'
            'var dayEl=tip.querySelector(".tip-day");'
            'var totEl=tip.querySelector(".tip-total");'
            'var netEl=tip.querySelector(".tip-net");'
            'var cfRow=tip.querySelector(".tip-cf-row");'
            'var cfEl=tip.querySelector(".tip-cf");'
            'var hideTimer=null;'
            'function scheduleHide(){if(hideTimer)clearTimeout(hideTimer);hideTimer=setTimeout(clear,15000);}'
            'function applyPoint(p){'
                'line.setAttribute("x1",p.x);line.setAttribute("x2",p.x);'
                'dot.setAttribute("cx",p.x);dot.setAttribute("cy",p.y);'
                'hg.setAttribute("opacity","1");'
                # 날짜 라벨 — unit 단위에 맞춰: year=YYYY, month=YYYY-MM, week/day=YYYY-MM-DD (요일)
                'var unit=root.getAttribute("data-chart-unit")||"day";'
                'var dateLabel;'
                'if(unit==="year"){dateLabel=p.d.substring(0,4);}'
                'else if(unit==="month"){dateLabel=p.d.substring(0,7);}'
                'else{var dt=new Date(p.d+"T00:00:00+09:00");'
                'var dow=isNaN(dt.getTime())?"":["일","월","화","수","목","금","토"][dt.getDay()];'
                'dateLabel=p.d+(dow?" ("+dow+")":"");}'
                'dEl.textContent=dateLabel;'
                'dayEl.textContent=fmt(p.day);dayEl.className="tip-day "+cls(p.day);'
                'totEl.textContent=fmt(p.total);totEl.className="tip-total "+cls(p.total);'
                'netEl.textContent=p.v.toLocaleString()+"원";'
                # 입출금 행: cf_net 있으면 표시, 없으면 hide. 입금만/출금만/둘다 케이스 분기.
                'if(cfRow&&cfEl){'
                    'if(typeof p.cf_net==="number"){'
                        'var ci=p.cf_in||0,co=p.cf_out||0,parts=[];'
                        'if(ci)parts.push("입금 "+ci.toLocaleString()+"원");'
                        'if(co)parts.push("출금 "+co.toLocaleString()+"원");'
                        'cfEl.textContent=parts.join(" · ")||"—";'
                        'cfEl.className="tip-cf "+cls(p.cf_net);'
                        'cfRow.hidden=false;'
                    '}else{cfRow.hidden=true;}'
                '}'
                'tip.style.opacity="1";'
                'scheduleHide();'
                'requestAnimationFrame(function(){'
                    'var r=svg.getBoundingClientRect();if(!r.width)return;'
                    'var rr=root.getBoundingClientRect();'
                    'var pxX=p.x/W*r.width+(r.left-rr.left);'
                    'var tw=tip.offsetWidth;'
                    'var lx=pxX-tw/2;'
                    'var maxL=rr.width-tw-4;'
                    'if(lx<4)lx=4;if(lx>maxL)lx=maxL;'
                    'tip.style.left=lx+"px";'
                    # top: indicator 점(=사용자 손가락 근처)이 차트 위쪽 절반이면 tooltip을 하단,
                    # 아래쪽 절반이면 상단에 배치 → 터치 지점·indicator 라인 항상 피함.
                    'var svgTop=r.top-rr.top;'
                    'var th=tip.offsetHeight;'
                    'var ty=(p.y<90)?(svgTop+r.height-th-4):(svgTop+4);'
                    'tip.style.top=ty+"px";'
                '});'
            '}'
            'function moveByPx(cx){'
                'var r=svg.getBoundingClientRect();if(!r.width)return;'
                'var px=(cx-r.left)/r.width*W;'
                'var bi=0,bd=Math.abs(pts[0].x-px);'
                'for(var i=1;i<pts.length;i++){var d=Math.abs(pts[i].x-px);if(d<bd){bd=d;bi=i;}}'
                'root.__nw_idx=bi;applyPoint(pts[bi]);'
            '}'
            # 인덱스 단위 좌/우 이동. 첫 클릭이면 prev는 마지막 포인트, next는 첫 포인트에서 시작.
            'function step(dir){'
                'var idx=(typeof root.__nw_idx==="number")?root.__nw_idx:(dir>0?-1:pts.length);'
                'idx+=dir;'
                'if(idx<0)idx=0;'
                'if(idx>=pts.length)idx=pts.length-1;'
                'root.__nw_idx=idx;applyPoint(pts[idx]);'
            '}'
            'function clear(){if(hideTimer){clearTimeout(hideTimer);hideTimer=null;}hg.setAttribute("opacity","0");tip.style.opacity="0";delete root.__nw_idx;}'
            'root.__nw_step=step;root.__nw_clear=clear;'
            # 터치/마우스 떠나도 indicator·툴팁 유지. 다음 차트 터치/호버로만 위치 변경.
            'svg.addEventListener("pointermove",function(e){moveByPx(e.clientX);});'
            'svg.addEventListener("pointerdown",function(e){moveByPx(e.clientX);});'
        '}'
        # 기간 chip + 날짜 nav 버튼 — document 위임. swap/MutationObserver 타이밍 의존 없이 동작.
        # modal_script와는 closest selector가 달라 충돌 없음.
        # 지수 모달 안 sparkline 차트 — 축 라벨(Y min/max, X 시작/끝일) + 클릭 시 세로선 인디케이터 + 툴팁.
        'window.__behive_render_idx_chart=function(host,series){'
            'if(!series||series.length<2){host.innerHTML=\'<div class="idx-chart-err">차트 데이터 없음</div>\';return;}'
            'var W=320,H=110,L=44,R=10,T=8,B=20;var iw=W-L-R,ih=H-T-B;var n=series.length;'
            'var vs=series.map(function(p){return p.v;});'
            'var mn=Math.min.apply(null,vs),mx=Math.max.apply(null,vs);'
            'var sp=Math.max(mx-mn,1e-9);'
            'function xAt(i){return L+(i/(n-1))*iw;}'
            'function yAt(v){return T+(1-(v-mn)/sp)*ih;}'
            'var pts=series.map(function(p,i){return{d:p.d,v:p.v,x:+xAt(i).toFixed(2),y:+yAt(p.v).toFixed(2)};});'
            'var up=vs[vs.length-1]>=vs[0];var col=up?"#ff4d5e":"#4a8cf0";'
            'var dPath="M "+pts.map(function(p){return p.x+","+p.y;}).join(" L ");'
            # Y축 라벨 (max·mid·min) + X축 라벨 (시작·끝일 MM-DD)
            'function fmtNum(v){return (v>=1000?v.toFixed(0):(v>=10?v.toFixed(2):v.toFixed(3)));}'
            'var midV=(mn+mx)/2;'
            'var yLabels=\'\';'
            'yLabels+=\'<text class="idx-axis" x="\'+(L-4)+\'" y="\'+(T+3)+\'" text-anchor="end">\'+fmtNum(mx)+\'</text>\';'
            'yLabels+=\'<text class="idx-axis" x="\'+(L-4)+\'" y="\'+(yAt(midV)+3)+\'" text-anchor="end">\'+fmtNum(midV)+\'</text>\';'
            'yLabels+=\'<text class="idx-axis" x="\'+(L-4)+\'" y="\'+(T+ih+3)+\'" text-anchor="end">\'+fmtNum(mn)+\'</text>\';'
            'var startD=series[0].d.slice(5),endD=series[n-1].d.slice(5);'
            'var xLabels=\'\';'
            'xLabels+=\'<text class="idx-axis" x="\'+L+\'" y="\'+(H-6)+\'" text-anchor="start">\'+startD+\'</text>\';'
            'xLabels+=\'<text class="idx-axis" x="\'+(L+iw)+\'" y="\'+(H-6)+\'" text-anchor="end">\'+endD+\'</text>\';'
            'var grid=\'\';'
            'grid+=\'<line class="idx-grid" x1="\'+L+\'" x2="\'+(L+iw)+\'" y1="\'+(yAt(midV))+\'" y2="\'+(yAt(midV))+\'"/>\';'
            'var lx=pts[n-1].x,ly=pts[n-1].y;'
            'var svgHtml=\'<svg viewBox="0 0 \'+W+\' \'+H+\'" class="idx-chart-svg" preserveAspectRatio="xMidYMid meet">\'+'
                'grid+yLabels+xLabels+'
                '\'<path d="\'+dPath+\'" fill="none" stroke="\'+col+\'" stroke-width="1.8" stroke-linejoin="round" stroke-linecap="round"/>\'+'
                '\'<circle cx="\'+lx+\'" cy="\'+ly+\'" r="3" fill="\'+col+\'"/>\'+'
                # hover/click 인디케이터 — 초기 숨김
                '\'<g class="idx-hover" opacity="0" pointer-events="none">\'+'
                    '\'<line class="idx-hover-line" x1="0" x2="0" y1="\'+T+\'" y2="\'+(T+ih)+\'"/>\'+'
                    '\'<circle class="idx-hover-dot" cx="0" cy="0" r="3.4"/>\'+'
                '\'</g>\'+'
            '\'</svg>\';'
            'host.innerHTML=svgHtml+\'<div class="idx-chart-tip" hidden><span class="tip-d"></span><span class="tip-v"></span><button type="button" class="tip-close" data-tip-close aria-label="닫기">×</button></div>\'+\'<div class="idx-chart-foot"><span>1개월 추이</span><span>\'+n+\'개 영업일</span></div>\';'
            'var svg=host.querySelector("svg.idx-chart-svg");'
            'var hg=svg.querySelector("g.idx-hover"),hl=hg.querySelector("line"),hd=hg.querySelector("circle");'
            'var tip=host.querySelector(".idx-chart-tip");'
            'var tipD=tip.querySelector(".tip-d"),tipV=tip.querySelector(".tip-v");'
            'function apply(p){'
                'hl.setAttribute("x1",p.x);hl.setAttribute("x2",p.x);'
                'hd.setAttribute("cx",p.x);hd.setAttribute("cy",p.y);hd.setAttribute("fill",col);'
                'hg.setAttribute("opacity","1");'
                'tipD.textContent=p.d;tipV.textContent=fmtNum(p.v);tip.hidden=false;'
            '}'
            'function nearestByPx(cx){'
                'var r=svg.getBoundingClientRect();if(!r.width)return null;'
                'var px=(cx-r.left)/r.width*W;'
                'var bi=0,bd=Math.abs(pts[0].x-px);'
                'for(var i=1;i<pts.length;i++){var d=Math.abs(pts[i].x-px);if(d<bd){bd=d;bi=i;}}'
                'return pts[bi];'
            '}'
            'function onPtr(e){var p=nearestByPx(e.clientX);if(p)apply(p);}'
            'svg.addEventListener("click",onPtr);'
            'svg.addEventListener("pointermove",function(e){if(e.pressure>0||e.buttons>0)onPtr(e);});'
            # tip 닫기 버튼 — hover 인디케이터 + 툴팁 둘 다 숨김
            'var closeBtn=tip.querySelector("[data-tip-close]");'
            'if(closeBtn)closeBtn.addEventListener("click",function(e){e.preventDefault();e.stopPropagation();tip.hidden=true;hg.setAttribute("opacity","0");});'
        '};'
        # 시장 카드 지수 설명 ⓘ 버튼 — 클릭 시 idx-info-modal 팝업 (제목/본문 동적 채움)
        'document.addEventListener("click",function(e){'
            'var ib=e.target.closest&&e.target.closest("[data-idx-info]");'
            'if(!ib)return;'
            'e.preventDefault();'
            'var label=ib.getAttribute("data-idx-label")||"지수";'
            'var desc=ib.getAttribute("data-idx-desc")||"";'
            'var price=ib.getAttribute("data-idx-price")||"";'
            'var change=ib.getAttribute("data-idx-change")||"";'
            'var pct=ib.getAttribute("data-idx-pct")||"";'
            'var dir=ib.getAttribute("data-idx-direction")||"flat";'
            'var sign=dir==="up"?"+":(dir==="down"?"−":"");'
            'var modal=document.getElementById("idx-info-modal");if(!modal)return;'
            'var tEl=modal.querySelector("[data-idx-modal-title]");'
            'var bEl=modal.querySelector("[data-idx-modal-body]");'
            'var pEl=modal.querySelector("[data-idx-modal-price]");'
            'var cEl=modal.querySelector("[data-idx-modal-change]");'
            'if(tEl)tEl.textContent=label;'
            'if(bEl)bEl.textContent=desc;'
            'if(pEl)pEl.textContent=price||"—";'
            'if(cEl){cEl.textContent=change?(sign+change+" ("+sign+pct+"%)"):"—";cEl.className="idx-info-change "+dir;}'
            # 차트 lazy fetch — symbol 있으면 /api/idx-chart 호출 후 SVG 동적 렌더. window.__behive_render_idx_chart 호출.
            'var symbol=ib.getAttribute("data-idx-symbol")||"";'
            'var chEl=modal.querySelector("[data-idx-modal-chart]");'
            'if(chEl){'
                'if(!symbol){chEl.hidden=true;chEl.innerHTML="";}'
                'else{'
                    'chEl.hidden=false;'
                    'chEl.innerHTML=\'<div class="idx-chart-loading">차트 불러오는 중…</div>\';'
                    'fetch("/api/idx-chart?symbol="+encodeURIComponent(symbol),{cache:"default"})'
                        '.then(function(r){return r.ok?r.json():Promise.reject();})'
                        '.then(function(j){if(window.__behive_render_idx_chart)window.__behive_render_idx_chart(chEl,(j&&j.series)||[]);})'
                        '.catch(function(){chEl.innerHTML=\'<div class="idx-chart-err">차트 불러오기 실패</div>\';});'
                '}'
            '}'
            'modal.classList.remove("hidden");'
            'modal.setAttribute("aria-hidden","false");'
            'if(window.__behiveLockScroll)window.__behiveLockScroll();'
        '});'
        # 시장 카드 새로고침 버튼 — manual_refresh (fresh=1) 호출 → 서버 시장 캐시 무효화 + panels 다시 fetch
        'document.addEventListener("click",function(e){'
            'var rb=e.target.closest&&e.target.closest("[data-refresh-market]");'
            'if(!rb)return;'
            'e.preventDefault();'
            'rb.classList.add("spinning");rb.disabled=true;'
            'var done=function(){rb.classList.remove("spinning");rb.disabled=false;};'
            'var p=window.__behive_manual_refresh&&window.__behive_manual_refresh();'
            'if(p&&p.finally)p.finally(done);'
            'else if(p&&p.then)p.then(done,done);'
            'else setTimeout(done,1500);'
        '});'
        'document.addEventListener("click",function(e){'
            'var chip=e.target.closest&&e.target.closest(".nw-chip");'
            'if(chip){'
                'e.preventDefault();'
                'var rootC=chip.closest(".net-chart");if(!rootC)return;'
                'var d=chip.getAttribute("data-chart-days");'
                'var u=chip.getAttribute("data-chart-unit");'
                'if(d!==null){try{localStorage.setItem("behive.chartDaysV2",d);}catch(_){}}'
                'else if(u!==null){try{localStorage.setItem("behive.chartUnit",u);}catch(_){}}'
                'else{return;}'
                # 레거시 chip click 흐름은 유지(혹시 다른 곳 chip 남아있을 때 대비). 새 UI는 아래 select change로.
                # active 토글은 같은 chip 그룹(parentNode = 한 .net-chart-options)으로 제한 — 다른 그룹 active 보존.
                'var grp=chip.parentNode;'
                'if(grp)grp.querySelectorAll(".nw-chip").forEach(function(x){x.classList.remove("active");});'
                'chip.classList.add("active");'
                'var ow=window.__behive_active_owner?window.__behive_active_owner():null;'
                'if(window.__behive_load)window.__behive_load(ow,true);'
                'return;'
            '}'
            'var nav=e.target.closest&&e.target.closest("[data-nw-step]");'
            'if(nav){'
                'e.preventDefault();e.stopPropagation();'
                'var rootN=nav.closest(".net-chart");if(!rootN)return;'
                'var act=nav.getAttribute("data-nw-step");'
                'if(act==="clear"){if(rootN.__nw_clear)rootN.__nw_clear();}'
                'else if(rootN.__nw_step){rootN.__nw_step(act==="next"?1:-1);}'
                'return;'
            '}'
        '});'
        # 자산정보 탭의 sub-tab (자산보기/차트보기/시장정보) 클릭 핸들러 + swap 후 복원.
        'function applySub(root,key){'
            'if(!root)return;'
            'root.querySelectorAll(".sub-tab").forEach(function(x){'
                'var on=x.getAttribute("data-sub-tab")===key;'
                'x.classList.toggle("active",on);'
                'x.setAttribute("aria-selected",on?"true":"false");'
            '});'
            'root.querySelectorAll(".sub-tab-panel").forEach(function(p){'
                'if(p.getAttribute("data-sub-panel")===key)p.removeAttribute("hidden");'
                'else p.setAttribute("hidden","");'
            '});'
        '}'
        # 첫 페이지 진입 = '자산보기' default. 사용자가 차트보기 누른 뒤엔 자동 갱신 후에도 유지.
        # localStorage 대신 in-memory 변수 — 새로고침/재진입 시 자산보기로 초기화된다.
        'var subState="assets";'
        'function restoreSubs(){'
            'if(subState==="assets")return;'
            'document.querySelectorAll(".sub-tabs").forEach(function(t){applySub(t.parentNode||t,subState);});'
        '}'
        'document.addEventListener("click",function(e){'
            'var st=e.target.closest&&e.target.closest(".sub-tab");'
            'if(!st)return;'
            'var key=st.getAttribute("data-sub-tab");if(!key)return;'
            'e.preventDefault();'
            'var root=st.parentNode&&st.parentNode.parentNode;'
            'applySub(root,key);'
            'subState=key;'
        '});'
        # 관심종목 그룹 선택 (.igrp-select 풀다운 / .igrp-panel) — .sub-tab 과 상태 분리. 3s swap 후 restoreIgrp 복원.
        'function applyIgrp(root,key){'
            'if(!root)return false;'
            'var panels=root.querySelectorAll(".igrp-panel");'
            'if(!panels.length)return false;'
            'var has=false;'
            'panels.forEach(function(p){if(p.getAttribute("data-igrp-panel")===key)has=true;});'
            'if(!has)return false;'
            'var sel=root.querySelector(".igrp-select");'
            'if(sel&&sel.value!==key)sel.value=key;'
            'panels.forEach(function(p){'
                'if(p.getAttribute("data-igrp-panel")===key)p.removeAttribute("hidden");'
                'else p.setAttribute("hidden","");'
            '});'
            'return true;'
        '}'
        'var igrpState="__all__";'
        'function restoreIgrp(){'
            'document.querySelectorAll(".igrp-select").forEach(function(s){'
                'var root=(s.closest&&s.closest("section"))||s.parentNode.parentNode;'
                'if(!applyIgrp(root,igrpState))applyIgrp(root,"__all__");'
            '});'
        '}'
        'document.addEventListener("change",function(e){'
            'var s=e.target&&e.target.closest&&e.target.closest(".igrp-select");'
            'if(!s)return;'
            'var key=s.value;if(key===null)return;'
            'var root=(s.closest&&s.closest("section"))||s.parentNode.parentNode;'
            'applyIgrp(root,key);'
            'igrpState=key;'
        '});'
        # 풀다운 오른쪽 '+' 버튼 → 작은 메뉴 토글. 항목 클릭·바깥 클릭은 닫음(항목은 기존 openBtn/gManage 핸들러로 전파).
        'function closeIgrpMenus(){'
            'document.querySelectorAll("[data-igrp-menu]:not([hidden])").forEach(function(m){m.setAttribute("hidden","");});'
            'document.querySelectorAll("[data-igrp-menu-toggle][aria-expanded=\\"true\\"]").forEach(function(b){b.setAttribute("aria-expanded","false");});'
        '}'
        'document.addEventListener("click",function(e){'
            'var tg=e.target.closest&&e.target.closest("[data-igrp-menu-toggle]");'
            'if(tg){'
                'e.preventDefault();'
                'var wrap=tg.closest(".igrp-menu-wrap");'
                'var menu=wrap&&wrap.querySelector("[data-igrp-menu]");'
                'if(!menu)return;'
                'var willOpen=menu.hasAttribute("hidden");'
                'closeIgrpMenus();'
                'if(willOpen){menu.removeAttribute("hidden");tg.setAttribute("aria-expanded","true");}'
                'return;'
            '}'
            # 그 외 클릭(메뉴 항목 포함) → 메뉴 닫기. 항목은 preventDefault 안 해 기존 핸들러로 계속 전파.
            'closeIgrpMenus();'
        '});'
        # 합산/각계좌별도 단일 토글 — 클릭마다 두 상태 swap. owner KPI 카드·자산정보 sub-panel 어디서나
        # 동일 in-memory state 공유. 패널 swap 후 restoreAccountViews가 모든 토글·pane을 다시 동기화.
        'function applyAccountView(key){'
            'document.querySelectorAll(".account-view-toggle-btn").forEach(function(b){'
                'b.setAttribute("data-account-view-state",key);'
            '});'
            'document.querySelectorAll(".account-view-pane").forEach(function(p){'
                'if(p.getAttribute("data-account-view-pane")===key)p.removeAttribute("hidden");'
                'else p.setAttribute("hidden","");'
            '});'
        '}'
        'var accountViewState="consolidated";'
        'function restoreAccountViews(){'
            'if(accountViewState==="consolidated")return;'
            'applyAccountView(accountViewState);'
        '}'
        'document.addEventListener("click",function(e){'
            'var bt=e.target.closest&&e.target.closest(".account-view-toggle-btn");'
            'if(!bt)return;'
            'e.preventDefault();'
            'var cur=bt.getAttribute("data-account-view-state")||"consolidated";'
            'var next=cur==="consolidated"?"by-account":"consolidated";'
            'accountViewState=next;'
            'applyAccountView(next);'
        '});'
        # 옵션 버튼 → 작은 팝업 토글. 버튼 클릭은 열고/닫고, 바깥 클릭은 닫는다.
        # (팝업 안 계좌 나누기 버튼은 위 핸들러가 처리 — 팝업은 그대로 열어둠)
        'document.addEventListener("click",function(e){'
            'var pop=document.getElementById("opts-popup");'
            'var btn=document.getElementById("opts-toggle");'
            'if(!pop||!btn)return;'
            'var ob=e.target.closest&&e.target.closest("#opts-toggle");'
            'if(ob){'
                'e.preventDefault();'
                'var willOpen=pop.hasAttribute("hidden");'
                'if(willOpen){pop.removeAttribute("hidden");btn.setAttribute("aria-expanded","true");}'
                'else{pop.setAttribute("hidden","");btn.setAttribute("aria-expanded","false");}'
                'return;'
            '}'
            'if(!pop.hasAttribute("hidden")&&!(e.target.closest&&e.target.closest("#opts-popup"))){'
                'pop.setAttribute("hidden","");btn.setAttribute("aria-expanded","false");'
            '}'
        '});'
        # ADR 추세 카드의 기간 버튼 (1주/1달/3달/6달/1년/전체) — localStorage 저장 후 전체 재fetch.
        'document.addEventListener("click",function(e){'
            'var bt=e.target.closest&&e.target.closest(".adr-range-btn");'
            'if(!bt)return;'
            'var v=bt.getAttribute("data-adr-range");if(v===null)return;'
            'e.preventDefault();'
            'try{localStorage.setItem("behive.adrTrendDays",v);}catch(_){}'
            # 같은 그룹의 다른 버튼 active 해제 — 서버 응답 오기 전 즉시 시각 피드백.
            'var grp=bt.parentNode;'
            'if(grp){grp.querySelectorAll(".adr-range-btn").forEach(function(b){'
                'var on=b===bt;'
                'b.classList.toggle("active",on);'
                'b.setAttribute("aria-pressed",on?"true":"false");'
            '});}'
            # 시장정보 sub-tab은 자산정보 탭의 일부 → owner=null full fetch (fresh=true).
            'if(window.__behive_load)window.__behive_load(null,true);'
        '});'
        # ADR 추세 그래프 펼침/접힘 토글 — 순수 클라이언트(그래프 SVG는 이미 DOM). localStorage 보존.
        'function applyAdrGraph(open){'
            'document.querySelectorAll(".adr-graph-wrap").forEach(function(w){'
                'if(open)w.removeAttribute("hidden");else w.setAttribute("hidden","");'
            '});'
            'document.querySelectorAll(".adr-graph-toggle").forEach(function(b){'
                'b.setAttribute("aria-expanded",open?"true":"false");'
            '});'
        '}'
        'function restoreAdrGraph(){'
            'var open=false;try{open=localStorage.getItem("behive.adrGraphOpen")==="1";}catch(_){}'
            'if(open)applyAdrGraph(true);'
        '}'
        'document.addEventListener("click",function(e){'
            'var bt=e.target.closest&&e.target.closest(".adr-graph-toggle");'
            'if(!bt)return;'
            'e.preventDefault();'
            'var open=bt.getAttribute("aria-expanded")!=="true";'
            'try{localStorage.setItem("behive.adrGraphOpen",open?"1":"0");}catch(_){}'
            'applyAdrGraph(open);'
        '});'
        # select 드롭다운(기간·단위) 변경 핸들러 — 자산정보 탭 상단의 공통 컨트롤.
        # chip 흐름과 동일한 localStorage 키 사용 → 백엔드 변경 없음.
        'document.addEventListener("change",function(e){'
            'var sel=e.target;'
            'if(!sel||sel.tagName!=="SELECT"||!sel.classList||!sel.classList.contains("nw-select"))return;'
            'function ymFirstMonth(y){var cc=document.querySelector(".chart-controls");try{var mp=JSON.parse(cc.getAttribute("data-ym-months")||"{}");var a=mp[y]||[];return a.length?a[0]:"";}catch(_){return "";}}'
            'function ymSet(tp,y){if(!y)return;if(tp==="month"){var mm=ymFirstMonth(y);window.__behive_set_ym(mm?(y+"-"+mm):y);}else{window.__behive_set_ym(y);}}'
            'function curYear(){var e=document.querySelector(\'select.nw-select[data-nw-year]\');return e&&e.value?e.value:"";}'
            'function curType(){var e=document.querySelector(\'select.nw-select[data-nw-ymtype]\');return e&&e.value?e.value:"year";}'
            'if(sel.hasAttribute("data-nw-range")){'
                'var rv=sel.value;'
                'if(rv==="custom"){'
                    # 지정기간 진입 — 유형(기본 연도별)·연도(기본 최신)로 chartYM 세팅.
                    'var y=curYear();if(!y)return;'  # 데이터 없음
                    'ymSet(curType(),y);'
                '}else{'
                    'window.__behive_set_ym("");'  # 프리셋 → 절대선택 해제
                    'try{localStorage.setItem("behive.chartDaysV2",rv);}catch(_){}'
                    # 기간 변경 시 현재 단위가 비허용이면 첫 허용으로 자동 보정 (서버 sanity와 일치).
                    'var allowedMap={"-3":["day","week"],"-2":["day","week","month"],"-4":["day","week","month","year"],"-1":["day","week","month","year"]};'
                    'var allowed=allowedMap[rv]||["day","week","month","year"];'
                    'var unitSel=document.querySelector(\'select.nw-select[data-nw-unit]\');'
                    'var curU=unitSel?unitSel.value:"day";'
                    'if(allowed.indexOf(curU)<0){try{localStorage.setItem("behive.chartUnit",allowed[0]);}catch(_){}}'
                '}'
            '}else if(sel.hasAttribute("data-nw-ymtype")){'
                'ymSet(sel.value,curYear());'
            '}else if(sel.hasAttribute("data-nw-year")){'
                'ymSet(curType(),sel.value);'
            '}else if(sel.hasAttribute("data-nw-month")){'
                'var ry=document.querySelector(\'select.nw-select[data-nw-year]\');'
                'window.__behive_set_ym((ry?ry.value:"")+"-"+sel.value);'
            '}else if(sel.hasAttribute("data-nw-unit")){'
                'try{localStorage.setItem("behive.chartUnit",sel.value);}catch(_){}'
            '}else if(sel.hasAttribute("data-nw-mode")){'
                'try{localStorage.setItem("behive.chartMode",sel.value);}catch(_){}'
            '}else{return;}'
            'var ow=window.__behive_active_owner?window.__behive_active_owner():null;'
            'if(window.__behive_load)window.__behive_load(ow,true);'
        '});'
        'function scan(){document.querySelectorAll(".net-chart[data-pts]").forEach(init);restoreSubs();restoreAccountViews();restoreIgrp();restoreAdrGraph();}'
        'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",scan);'
        'else scan();'
        # swap 후 새 차트가 들어오면 재바인딩 + sub-tab 복원
        'new MutationObserver(scan).observe(document.body,{childList:true,subtree:true});'
        '})();</script>'
    )

    # ADR 통합 차트 인터랙션 — 터치/호버 시 세로선 + 시장당 점 + 듀얼 툴팁(날짜·코스피·코스닥).
    # data-adr-pts JSON 각 entry: {d, x, kospi?:{v,y,live}, kosdaq?:{v,y,live}}.
    # 영속화: hide 타이머 없음 + module 스코프 selectedDate 에 마지막 선택 날짜 저장 →
    #         자동 갱신(panels innerHTML swap) 후 init 시 동일 날짜로 indicator 복원.
    adr_hover_script = (
        '<script>(function(){'
        'function cls(v){return v>=120?"up":(v<=80?"down":"flat");}'
        # selectedDate — 마지막 선택 날짜 ISO('YYYY-MM-DD'). 단일 차트라 시장 키 불필요.
        'var selectedDate=null;'
        'function init(row){'
            'if(row.__adr_bound)return;row.__adr_bound=true;'
            'var svg=row.querySelector("svg.adr-trend-svg");'
            'var tip=row.querySelector(".adr-trend-info");'
            'var hg=svg&&svg.querySelector("g.adr-hover");'
            'if(!svg||!tip||!hg)return;'
            'var line=hg.querySelector("line");'
            'var dotK=hg.querySelector(".adr-hover-dot-kospi");'
            'var dotD=hg.querySelector(".adr-hover-dot-kosdaq");'
            'var pts;try{pts=JSON.parse(row.getAttribute("data-adr-pts")||"[]");}catch(e){pts=[];}'
            'if(!pts.length)return;'
            'var vb=(row.getAttribute("data-adr-vb")||"360 95").split(/\\s+/);'
            'var VW=parseFloat(vb[0])||360,VH=parseFloat(vb[1])||95;'
            'var dEl=tip.querySelector(".tip-d");'
            'var lineK=tip.querySelector(".tip-kospi");'
            'var lineD=tip.querySelector(".tip-kosdaq");'
            'var vK=lineK?lineK.querySelector(".tip-v"):null;'
            'var vD=lineD?lineD.querySelector(".tip-v"):null;'
            'function setDot(dot,pt){'
                'if(!dot)return;'
                'if(pt){dot.setAttribute("cx",pt.y!=null?pt.x:0);dot.setAttribute("cy",pt.y);dot.setAttribute("opacity","1");}'
                'else{dot.setAttribute("opacity","0");}'
            '}'
            'function setTipLine(lineEl,vEl,pt){'
                'if(!lineEl||!vEl)return;'
                'if(pt){'
                    'vEl.textContent=pt.v.toFixed(1);'
                    'vEl.className="tip-v "+cls(pt.v)+(pt.live?" live":"");'
                    'lineEl.hidden=false;'
                '}else{lineEl.hidden=true;}'
            '}'
            'function applyPoint(p,save){'
                'line.setAttribute("x1",p.x);line.setAttribute("x2",p.x);'
                'hg.setAttribute("opacity","1");'
                # 점 — 시장별 데이터 있는 쪽만 표시
                'var pk=p.kospi||null,pd=p.kosdaq||null;'
                'if(pk){dotK.setAttribute("cx",p.x);dotK.setAttribute("cy",pk.y);dotK.setAttribute("opacity","1");}'
                'else dotK.setAttribute("opacity","0");'
                'if(pd){dotD.setAttribute("cx",p.x);dotD.setAttribute("cy",pd.y);dotD.setAttribute("opacity","1");}'
                'else dotD.setAttribute("opacity","0");'
                # 날짜 — MM-DD (요일)
                'var dt=new Date((p.d||"")+"T00:00:00+09:00");'
                'var dow=isNaN(dt.getTime())?"":["일","월","화","수","목","금","토"][dt.getDay()];'
                'var dlbl=(p.d||"").length>=10?p.d.slice(5):(p.d||"");'
                'dEl.textContent=dlbl+(dow?" ("+dow+")":"");'
                'setTipLine(lineK,vK,pk);'
                'setTipLine(lineD,vD,pd);'
                'if(save!==false&&p.d)selectedDate=p.d;'
            '}'
            'function moveByPx(cx){'
                'var r=svg.getBoundingClientRect();if(!r.width)return;'
                'var px=(cx-r.left)/r.width*VW;'
                'var bi=0,bd=Math.abs(pts[0].x-px);'
                'for(var i=1;i<pts.length;i++){var d=Math.abs(pts[i].x-px);if(d<bd){bd=d;bi=i;}}'
                'applyPoint(pts[bi],true);'
            '}'
            'svg.addEventListener("pointermove",function(e){moveByPx(e.clientX);});'
            'svg.addEventListener("pointerdown",function(e){moveByPx(e.clientX);});'
            # 영속 복원 — 마지막 선택 날짜가 있으면 재배치, 없으면 최신 지점을 기본 표시.
            'var restored=false;'
            'if(selectedDate){'
                'for(var k=0;k<pts.length;k++){'
                    'if(pts[k].d===selectedDate){applyPoint(pts[k],false);restored=true;break;}'
                '}'
            '}'
            'if(!restored)applyPoint(pts[pts.length-1],false);'
        '}'
        'function scan(){document.querySelectorAll(".adr-trend-row[data-adr-pts]").forEach(init);}'
        'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",scan);'
        'else scan();'
        # swap(자동 갱신) 후 새 DOM 들어오면 재바인딩 + 선택 복원.
        'new MutationObserver(scan).observe(document.body,{childList:true,subtree:true});'
        '})();</script>'
    )

    # 보유 카드 SVG 봉차트 — 카드 펼침 시 /api/chart_svg fetch.
    # dailyCache: code -> 1Y/6M/1M HTML fragment.
    # minuteCache: code:unit -> {svg, ts}  (TTL 30s — 오늘 마지막 봉이 분 단위로 흐름).
    # activeRange: code -> 마지막 활성 range. panels swap 후 1Y 로 reset 되는 거 방지.
    # panels-loaded 이벤트마다 열린 카드 daily 복원 + activeRange 적용. 활성이 분봉이면 자동 재fetch.
    chart_svg_script = (
        '<script>(function(){'
        'var dailyCache=new Map();'
        # minuteCache key = "code:unit:subrange" (예: "005930:5m:1h"). value = {svg, ts}.
        'var minuteCache=new Map();'
        'var activeRange=new Map();'
        # activeSubRange key = "code:unit". value = "30min"|"1h"|"3h"|"5h"|"1d". 기본 "1h".
        'var activeSubRange=new Map();'
        'var inFlight=new Map();'
        'var MINUTE_TTL_MS=30000;'
        'var DEFAULT_SUBRANGE="1h";'
        'var Q=function(s){return s.replace(/"/g,\'\\\\"\');};'
        'function applyActive(scope,range){'
            'if(!scope||!range)return;'
            'scope.querySelectorAll(".chart-tabs button").forEach(function(b){'
                'b.classList.toggle("active",b.getAttribute("data-range")===range);'
            '});'
            'scope.querySelectorAll(".chart-panel").forEach(function(p){'
                'p.classList.toggle("active",p.getAttribute("data-range")===range);'
            '});'
        '}'
        'function applySubActive(panel,subrange){'
            'if(!panel||!subrange)return;'
            'panel.querySelectorAll(".chart-subtabs button").forEach(function(b){'
                'b.classList.toggle("active",b.getAttribute("data-subrange")===subrange);'
            '});'
        '}'
        # 일봉 → daily 복원 직후 activeRange 적용. 활성이 분봉이면 fetchMinute 자동.
        'function applyAndMaybeMinute(scope,code){'
            'var ar=activeRange.get(code);'
            'if(!ar)return;'
            'applyActive(scope,ar);'
            'if(ar==="5m"||ar==="1m"||ar==="15m"){'
                'var panel=scope.querySelector(\'.chart-panel[data-range="\'+Q(ar)+\'"]\');'
                'if(panel){'
                    'var sr=activeSubRange.get(code+":"+ar)||DEFAULT_SUBRANGE;'
                    'applySubActive(panel,sr);'
                    'var subpanel=panel.querySelector(".chart-subpanel");'
                    'if(subpanel)fetchMinute(code,ar,sr,subpanel);'
                '}'
            '}'
        '}'
        'function fetchDaily(code,container){'
            'if(!code||!container)return null;'
            'if(dailyCache.has(code)){'
                'container.innerHTML=dailyCache.get(code);'
                'applyAndMaybeMinute(container,code);'
            '}else if(!container.firstElementChild){'
                'container.innerHTML=\'<div class="chart-loading">차트 불러오는 중…</div>\';'
            '}'
            'var key="d:"+code;'
            'if(inFlight.has(key))return inFlight.get(key);'
            'var p=fetch("/api/chart_svg?code="+encodeURIComponent(code),{credentials:"same-origin",cache:"no-store"})'
                '.then(function(r){if(!r.ok)throw new Error("HTTP "+r.status);return r.text();})'
                '.then(function(html){'
                    'dailyCache.set(code,html);'
                    'document.querySelectorAll(\'.chart-svg[data-chart-code="\'+Q(code)+\'"]\').forEach(function(el){'
                        'if(el.closest("details[open]")){'
                            'el.innerHTML=html;'
                            'applyAndMaybeMinute(el,code);'
                        '}'
                    '});'
                '})'
                '.catch(function(){'
                    'if(!dailyCache.has(code))container.innerHTML=\'<div class="chart-error">차트 불러오기 실패</div>\';'
                '})'
                '.finally(function(){inFlight.delete(key);});'
            'inFlight.set(key,p);'
            'return p;'
        '}'
        'function fetchMinute(code,unit,subrange,subpanel){'
            'if(!code||!unit||!subpanel)return null;'
            'var sr=subrange||DEFAULT_SUBRANGE;'
            'var key=code+":"+unit+":"+sr;'
            'var cached=minuteCache.get(key);'
            'var now=Date.now();'
            'if(cached){'
                'subpanel.innerHTML=cached.svg;'
                'if(now-cached.ts<MINUTE_TTL_MS)return null;'
            '}else if(!subpanel.firstElementChild){'
                'subpanel.innerHTML=\'<div class="chart-loading">분봉 불러오는 중…</div>\';'
            '}'
            'var inKey="m:"+key;'
            'if(inFlight.has(inKey))return inFlight.get(inKey);'
            'var url="/api/chart_svg?code="+encodeURIComponent(code)+"&unit="+encodeURIComponent(unit)+"&range="+encodeURIComponent(sr);'
            'var p=fetch(url,{credentials:"same-origin",cache:"no-store"})'
                '.then(function(r){if(!r.ok)throw new Error("HTTP "+r.status);return r.text();})'
                '.then(function(svg){'
                    'minuteCache.set(key,{svg:svg,ts:Date.now()});'
                    # 같은 종목/유닛 모든 subpanel 갱신 — 단 사용자가 그 subrange 를 현재 보고 있을 때만.
                    'document.querySelectorAll(\'.chart-svg[data-chart-code="\'+Q(code)+\'"] .chart-panel[data-range="\'+Q(unit)+\'"] .chart-subpanel\').forEach(function(p){'
                        'var curSr=activeSubRange.get(code+":"+unit)||DEFAULT_SUBRANGE;'
                        'if(curSr===sr&&p.closest("details[open]"))p.innerHTML=svg;'
                    '});'
                '})'
                '.catch(function(){'
                    'if(!cached)subpanel.innerHTML=\'<div class="chart-error">분봉 불러오기 실패</div>\';'
                '})'
                '.finally(function(){inFlight.delete(inKey);});'
            'inFlight.set(inKey,p);'
            'return p;'
        '}'
        'function loadChartsInOpen(root){'
            '(root||document).querySelectorAll("details[open]").forEach(function(d){'
                'var c=d.querySelector(".chart-svg[data-chart-code]");'
                'if(c)fetchDaily(c.getAttribute("data-chart-code"),c);'
            '});'
        '}'
        # <details> toggle bubble 안 함 → capture=true.
        'document.addEventListener("toggle",function(ev){'
            'var d=ev.target;'
            'if(!d||d.tagName!=="DETAILS"||!d.open)return;'
            'var c=d.querySelector(".chart-svg[data-chart-code]");'
            'if(c)fetchDaily(c.getAttribute("data-chart-code"),c);'
        '},true);'
        # crosshair: SVG 안에 overlay group 추가. press-and-hold 시작, 이동 따라다님, 떼면 svg.__drag만 해제(표시 유지).
        # panels swap 으로 SVG 가 새 DOM 으로 갈아끼워져도 crosshairState 의 좌표·모드를 새 SVG 에 복원.
        'var SVG_NS="http://www.w3.org/2000/svg";'
        'var crosshairState=new Map();'
        'function _mk(t,a){var e=document.createElementNS(SVG_NS,t);for(var k in a)e.setAttribute(k,a[k]);return e;}'
        'function _stateKey(svg){'
            'var ch=svg.closest&&svg.closest(".chart-svg");if(!ch)return null;'
            'var code=ch.getAttribute("data-chart-code");if(!code)return null;'
            'var panel=svg.closest(".chart-panel");if(!panel)return code;'
            'var range=panel.getAttribute("data-range")||"";'
            'var subpanel=svg.closest(".chart-subpanel");'
            'if(subpanel){'
                'var sr=(activeSubRange.get(code+":"+range)||DEFAULT_SUBRANGE);'
                'return code+":"+range+":"+sr;'
            '}'
            'return code+":"+range;'
        '}'
        'function bindCrosshair(svg){'
            'if(svg.__xh)return;svg.__xh=true;'
            'var meta;try{meta=JSON.parse(svg.getAttribute("data-meta")||"null");}catch(e){return;}'
            'if(!meta||!meta.candles||!meta.candles.length)return;'
            'var ov=_mk("g",{"class":"crosshair-overlay"});'
            'ov.style.display="none";ov.style.pointerEvents="none";'
            'var vline=_mk("line",{stroke:"#cfd5df","stroke-width":"1","stroke-dasharray":"4 3",opacity:"0.65"});'
            'var hline=_mk("line",{stroke:"#cfd5df","stroke-width":"1","stroke-dasharray":"4 3",opacity:"0.65"});'
            # X 모드: 차트 내부 floating OHLC 팝업 (xTipG). Y 모드: hline 왼쪽 끝 가격 라벨 (yTipG).
            'var xTipG=_mk("g",{"class":"x-tip"});'
            'var xTipBg=_mk("rect",{x:"0",y:"0",width:"320",height:"176",fill:"rgba(15,20,25,0.95)",stroke:"#2a2f3a","stroke-width":"1",rx:"8"});'
            'var xTipTxt=_mk("text",{fill:"#cfd5df"});'
            'xTipG.appendChild(xTipBg);xTipG.appendChild(xTipTxt);'
            'xTipG.style.display="none";'
            'var yTipG=_mk("g",{"class":"y-tip"});'
            'var yTipBg=_mk("rect",{x:"0",y:"0",width:"152",height:"40",fill:"rgba(15,20,25,0.96)",stroke:"#cfd5df","stroke-width":"1",rx:"6"});'
            'var yTipTxt=_mk("text",{fill:"#cfd5df","text-anchor":"middle","dominant-baseline":"central","font-size":"26","font-weight":"700",x:"76",y:"20"});'
            'yTipG.appendChild(yTipBg);yTipG.appendChild(yTipTxt);'
            'yTipG.style.display="none";'
            # 닫기 버튼 — OHLC 팝업(xTipG) 우상단 안쪽에 부착. xTipG transform 따라 함께 이동.
            # net-chart-tip 의 .tip-close 와 동일 패턴(팝업 자체 우상단).
            # ov 는 pointerEvents:none 이라 closeG 만 auto 로 override 해 클릭 통과.
            'var closeG=_mk("g",{"class":"crosshair-close"});'
            # 투명 히트영역 — 보이는 원(r=14)보다 크게(r=26) 덧대 터치 타겟 확대. 손가락이 살짝 빗나가도 인식.
            'var closeHit=_mk("circle",{cx:"0",cy:"0",r:"26",fill:"transparent"});'
            'var closeBg=_mk("circle",{cx:"0",cy:"0",r:"14",fill:"rgba(15,20,25,0.95)",stroke:"#cfd5df","stroke-width":"1.2"});'
            'var closeTxt=_mk("text",{x:"0",y:"1","text-anchor":"middle","dominant-baseline":"central","font-size":"22","font-weight":"700",fill:"#cfd5df"});'
            'closeTxt.textContent="×";'
            'closeG.appendChild(closeHit);closeG.appendChild(closeBg);closeG.appendChild(closeTxt);'
            'closeG.style.cursor="pointer";'
            'closeG.style.pointerEvents="auto";'
            # xTipBg 320×176 안의 우상단 모서리. r=14 라 여백 18 두고 중심 (300,20).
            'closeG.setAttribute("transform","translate(300,20)");'
            'function closeOverlay(ev){'
                'if(ev){ev.stopPropagation();if(ev.cancelable)ev.preventDefault();}'
                'ov.style.display="none";'
                'svg.__drag=false;svg.__mode=null;'
                'var k=_stateKey(svg);'
                'if(k)crosshairState.delete(k);'
            '}'
            'closeG.addEventListener("click",closeOverlay);'
            'closeG.addEventListener("touchstart",closeOverlay,{passive:false});'
            # svg 의 mousedown=start 가 bubble 로 잡지 않도록 closeG 의 mousedown 도 차단.
            'closeG.addEventListener("mousedown",function(ev){ev.stopPropagation();});'
            'xTipG.appendChild(closeG);'
            'ov.appendChild(vline);ov.appendChild(hline);ov.appendChild(xTipG);ov.appendChild(yTipG);'
            'svg.appendChild(ov);'
            # mode: "x" = 세로선+OHLC, "y" = 가로선+가격. 시작 위치로 결정 후 드래그 중 유지.
            'svg.__mode=null;'
            'function pt(ev){'
                'var r=svg.getBoundingClientRect();'
                'var cx=ev.touches?ev.touches[0].clientX:ev.clientX;'
                'var cy=ev.touches?ev.touches[0].clientY:ev.clientY;'
                'return [(cx-r.left)/r.width*meta.chartW,(cy-r.top)/r.height*meta.chartH];'
            '}'
            # 시가 대비 색상 — up 빨강, down 파랑 (한국 관습).
            'function _col(v,base){return v>base?"#ef4444":(v<base?"#3b82f6":"#cfd5df");}'
            'function _mkTspan(attrs,txt,fill){'
                'var s=document.createElementNS(SVG_NS,"tspan");'
                'for(var k in attrs)s.setAttribute(k,attrs[k]);'
                'if(fill)s.setAttribute("fill",fill);'
                's.textContent=txt;'
                'return s;'
            '}'
            'function _pctStr(v,base){'
                'if(!base)return "";'
                'var p=(v-base)/base*100;'
                'return (p>=0?"+":"")+p.toFixed(2)+"%";'
            '}'
            'function _setOhlcText(d,c,xPos){'
                'while(xTipTxt.firstChild)xTipTxt.removeChild(xTipTxt.firstChild);'
                'var ccol=_col(c.c,c.o),hcol=_col(c.h,c.o),lcol=_col(c.l,c.o);'
                # 5행 팝업: 날짜 → 시 → 고 → 저 → 종(강조)
                'xTipTxt.appendChild(_mkTspan({x:16,y:28,"font-size":22,"font-weight":500},d,"#8a8f9a"));'
                'xTipTxt.appendChild(_mkTspan({x:16,dy:32,"font-size":24,"font-weight":500},"시 "+c.o.toLocaleString()));'
                'xTipTxt.appendChild(_mkTspan({x:16,dy:30,"font-size":24,"font-weight":500},"고 "+c.h.toLocaleString()+"  ",hcol));'
                'xTipTxt.appendChild(_mkTspan({"font-size":24,"font-weight":500},_pctStr(c.h,c.o),hcol));'
                'xTipTxt.appendChild(_mkTspan({x:16,dy:30,"font-size":24,"font-weight":500},"저 "+c.l.toLocaleString()+"  ",lcol));'
                'xTipTxt.appendChild(_mkTspan({"font-size":24,"font-weight":500},_pctStr(c.l,c.o),lcol));'
                'xTipTxt.appendChild(_mkTspan({x:16,dy:34,"font-size":28,"font-weight":700},"종 "+c.c.toLocaleString()+"  ",ccol));'
                'xTipTxt.appendChild(_mkTspan({"font-size":24,"font-weight":700},_pctStr(c.c,c.o),ccol));'
                # 분봉: 전일종가 대비 등락률 — 그 봉 종가 vs 전일종가 (현재 등락율과 일치).
                'var hasPC=meta.prevClose&&meta.prevClose>0;'
                'if(hasPC){xTipTxt.appendChild(_mkTspan({x:16,dy:32,"font-size":23,"font-weight":600},"전일대비 "+_pctStr(c.c,meta.prevClose),_col(c.c,meta.prevClose)));}'
                # 팝업 위치 — vline 의 반대 쪽으로 자동 배치 (좌/우). 화면 벗어남 방지 clamp.
                'var tipW=340,tipH=hasPC?212:176,gap=10;'
                # 당일시가 라인 유무에 따라 배경 박스·닫기버튼 위치 동적 조정.
                'xTipBg.setAttribute("width",tipW);xTipBg.setAttribute("height",tipH);'
                'closeG.setAttribute("transform","translate("+(tipW-20)+",20)");'
                'var tipX=xPos+gap;'
                'if(tipX+tipW>meta.chartW-meta.padR)tipX=xPos-gap-tipW;'
                'if(tipX<meta.padL)tipX=meta.padL+2;'
                'var tipY=meta.priceTop+8;'
                'xTipG.setAttribute("transform","translate("+tipX+","+tipY+")");'
            '}'
            'function updateAt(vx,vy,mode){'
                'svg.__mode=mode;'
                'if(mode==="y"){'
                    # 가격(가로선) 모드 — 가로선 + 왼쪽 끝 가격 라벨.
                    'vline.style.display="none";'
                    'hline.style.display="";'
                    'xTipG.style.display="none";'
                    'yTipG.style.display="";'
                    'var clampY=Math.max(meta.priceTop,Math.min(meta.priceBot,vy));'
                    'hline.setAttribute("x1",meta.padL);hline.setAttribute("x2",meta.chartW-meta.padR);'
                    'hline.setAttribute("y1",clampY);hline.setAttribute("y2",clampY);'
                    'var frac=(meta.priceBot-clampY)/(meta.priceBot-meta.priceTop);'
                    'var price=meta.ymin+frac*(meta.ymax-meta.ymin);'
                    'yTipTxt.textContent=Math.round(price).toLocaleString();'
                    # 가로선 왼쪽 끝 위치 — y는 가로선 중심에 맞춰 ±lblH/2. 차트 안쪽 priceTop~priceBot clamp.
                    'var lblW=152,lblH=40;'
                    'var lblX=meta.padL+2;'
                    'var lblY=clampY-lblH/2;'
                    'if(lblY<meta.priceTop)lblY=meta.priceTop;'
                    'if(lblY+lblH>meta.priceBot)lblY=meta.priceBot-lblH;'
                    'yTipG.setAttribute("transform","translate("+lblX+","+lblY+")");'
                '}else{'
                    # X(봉) 모드 — 세로선 + 차트내 OHLC 팝업.
                    'vline.style.display="";'
                    'hline.style.display="none";'
                    'yTipG.style.display="none";'
                    'xTipG.style.display="";'
                    'var n=meta.candles.length;'
                    'var plotW=meta.chartW-meta.padL-meta.padR;'
                    'var slot=plotW/n;'
                    'var i=Math.floor((vx-meta.padL)/slot);'
                    'if(i<0)i=0;if(i>=n)i=n-1;'
                    'var c=meta.candles[i];'
                    'var xPos=meta.padL+slot*(i+0.5);'
                    'vline.setAttribute("x1",xPos);vline.setAttribute("x2",xPos);'
                    'vline.setAttribute("y1",meta.priceTop);vline.setAttribute("y2",meta.volBot);'
                    'var d=c.d;'
                    'if(d&&d.length===8)d=d.slice(0,4)+"-"+d.slice(4,6)+"-"+d.slice(6,8);'
                    '_setOhlcText(d,c,xPos);'
                '}'
                'ov.style.display="";'
                # panels swap 후 새 SVG 에 복원할 좌표·모드 저장.
                'var key=_stateKey(svg);'
                'if(key)crosshairState.set(key,{mode:mode,vx:vx,vy:vy});'
            '}'
            'function update(ev){'
                'var p=pt(ev);'
                'updateAt(p[0],p[1],svg.__mode);'
            '}'
            'function start(ev){'
                'var p=pt(ev);'
                # 우측 Y축 라벨 영역(가격 라벨 자리) 누르면 Y(가격) 모드, 그 외는 X(봉) 모드.
                'svg.__mode=(p[0]>meta.chartW-meta.padR)?"y":"x";'
                'svg.__drag=true;'
                'updateAt(p[0],p[1],svg.__mode);'
                'ev.preventDefault();'
            '}'
            'function moveIfDrag(ev){if(svg.__drag)update(ev);}'
            # 손가락 떼도 overlay 유지 — drag flag 만 해제, 표시는 다음 누름 시 갱신.
            'function endFn(){svg.__drag=false;}'
            # panels swap 직후 새 SVG 에 이전 좌표·모드 복원.
            'var _sk=_stateKey(svg);'
            'if(_sk){'
                'var _st=crosshairState.get(_sk);'
                'if(_st)updateAt(_st.vx,_st.vy,_st.mode);'
            '}'
            'svg.addEventListener("mousedown",start);'
            'svg.addEventListener("touchstart",start,{passive:false});'
            # window 레벨 listen — 드래그가 SVG 영역 밖으로 벗어나도 떼지 않으면 유지.
            'window.addEventListener("mousemove",moveIfDrag);'
            'window.addEventListener("touchmove",function(ev){if(svg.__drag){update(ev);ev.preventDefault();}},{passive:false});'
            'window.addEventListener("mouseup",endFn);'
            'window.addEventListener("touchend",endFn);'
            'window.addEventListener("touchcancel",endFn);'
        '}'
        'function bindAllCharts(root){'
            '(root||document).querySelectorAll(".chart-svg svg[data-meta]").forEach(bindCrosshair);'
        '}'
        # MutationObserver — innerHTML 으로 들어오는 SVG 자동 바인딩.
        'new MutationObserver(function(ms){ms.forEach(function(m){m.addedNodes.forEach(function(n){if(n.nodeType===1)bindAllCharts(n);});});}).observe(document.body,{childList:true,subtree:true});'
        # apply() swap 완료 이벤트 — 열린 카드 daily 복원 + activeRange 분봉 자동 갱신.
        'document.addEventListener("behive:panels-loaded",function(){loadChartsInOpen();});'
        # chart-tabs 클릭 — activeRange 기록 + 패널 토글 + 분봉이면 lazy fetch.
        'document.addEventListener("click",function(ev){'
            'var btn=ev.target.closest(".chart-svg .chart-tabs button[data-range]");'
            'if(!btn)return;'
            'var scope=btn.closest(".chart-svg");'
            'if(!scope)return;'
            'var code=scope.getAttribute("data-chart-code");'
            'var range=btn.getAttribute("data-range");'
            'var unit=btn.getAttribute("data-unit");'
            'if(code)activeRange.set(code,range);'
            'applyActive(scope,range);'
            'if(unit&&code){'
                'var panel=scope.querySelector(\'.chart-panel[data-range="\'+Q(range)+\'"]\');'
                'if(panel){'
                    'var sr=activeSubRange.get(code+":"+unit)||DEFAULT_SUBRANGE;'
                    'applySubActive(panel,sr);'
                    'var subpanel=panel.querySelector(".chart-subpanel");'
                    'if(subpanel)fetchMinute(code,unit,sr,subpanel);'
                '}'
            '}'
        '},false);'
        # chart-subtabs 클릭 — subrange 기록 + fetch.
        'document.addEventListener("click",function(ev){'
            'var btn=ev.target.closest(".chart-svg .chart-subtabs button[data-subrange]");'
            'if(!btn)return;'
            'var panel=btn.closest(".chart-panel[data-unit]");'
            'var scope=btn.closest(".chart-svg");'
            'if(!panel||!scope)return;'
            'var code=scope.getAttribute("data-chart-code");'
            'var unit=panel.getAttribute("data-unit");'
            'var sr=btn.getAttribute("data-subrange");'
            'if(!code||!unit||!sr)return;'
            'activeSubRange.set(code+":"+unit,sr);'
            'applySubActive(panel,sr);'
            'var subpanel=panel.querySelector(".chart-subpanel");'
            'if(subpanel)fetchMinute(code,unit,sr,subpanel);'
        '},false);'
        # 수평선(hlines) 추가/제거 — 저장 후 dailyCache 무효화 + 재fetch로 차트·칩 갱신. promise 반환.
        'function refreshChart(code){'
            'if(!code)return null;'
            'dailyCache.delete(code);'
            'var pr=null;'
            'document.querySelectorAll(\'.chart-svg[data-chart-code="\'+Q(code)+\'"]\').forEach(function(el){'
                'if(el.closest("details[open]"))pr=fetchDaily(code,el);'
            '});'
            'return pr;'
        '}'
        # 재fetch 후 새로 그려진 모달을 다시 연다 (추가·삭제 반복 편의).
        'function reopenHl(code){'
            'var p=refreshChart(code);'
            'if(p&&p.then){p.then(function(){openHl(code);});}else{openHl(code);}'
        '}'
        # 포탈된 모달 제거. code 지정 시 해당 종목만, 없으면 전부(카드 닫힐 때).
        'function closeHlPortals(code){'
            'var sel=code?(\'.hl-modal[data-hl-portal="\'+Q(code)+\'"]\'):".hl-modal[data-hl-portal]";'
            'document.querySelectorAll(sel).forEach(function(m){m.remove();});'
        '}'
        # 모달 열기 — 상세 카드는 transform 조상이라 내부 position:fixed 가 뷰포트 기준이 안 됨.
        # 그래서 모달 clone 을 document.body 로 옮겨(포탈) 뷰포트 전체 오버레이로 띄운다. 원본은 블록에 hidden 유지.
        'function openHl(code){'
            'if(!code)return;'
            'closeHlPortals(code);'
            'var src=document.querySelector(\'.chart-hlines[data-hl-code="\'+Q(code)+\'"] [data-hl-modal]\');'
            'if(!src)return;'
            'var clone=src.cloneNode(true);'
            'clone.setAttribute("data-hl-portal",code);'
            'clone.classList.remove("hidden");'
            'document.body.appendChild(clone);'
        '}'
        # 이벤트 대상에서 종목코드 추출 — 블록(.chart-hlines) 또는 포탈된 모달(data-hl-portal) 양쪽 지원.
        'function hlCodeOf(el){'
            'var w=el.closest(".chart-hlines");if(w)return w.getAttribute("data-hl-code");'
            'var m=el.closest("[data-hl-modal]");if(m)return m.getAttribute("data-hl-portal");'
            'return null;'
        '}'
        'function postHline(url,code,price,cb){'
            'fetch(url,{method:"POST",credentials:"same-origin",'
                'headers:{"Content-Type":"application/x-www-form-urlencoded","Accept":"application/json"},'
                'body:"code="+encodeURIComponent(code)+"&price="+encodeURIComponent(price)})'
                '.then(function(r){return r.json();})'
                '.then(function(j){if(j&&j.ok){cb();}else{alert((j&&j.error)||"수평선 처리 실패");}})'
                '.catch(function(){alert("수평선 처리 실패");});'
        '}'
        'document.addEventListener("submit",function(ev){'
            'var f=ev.target;'
            'if(!f.classList||!f.classList.contains("chart-hlines-add"))return;'
            'ev.preventDefault();'
            'var code=hlCodeOf(f);if(!code)return;'
            'var inp=f.querySelector(\'input[name="price"]\');'
            'var price=inp?inp.value.trim():"";'
            'if(!price)return;'
            'postHline("/chart/hlines/add",code,price,function(){if(inp)inp.value="";reopenHl(code);});'
        '});'
        'document.addEventListener("click",function(ev){'
            # '+' 버튼 → 모달 열기
            'var op=ev.target.closest("[data-hl-open]");'
            'if(op){var w0=op.closest(".chart-hlines");if(w0){ev.preventDefault();openHl(w0.getAttribute("data-hl-code"));}return;}'
            # 모달 닫기 (오버레이·×) — 포탈 clone 은 제거
            'var cl=ev.target.closest("[data-hl-close]");'
            'if(cl){var m=cl.closest("[data-hl-modal]");if(m){ev.preventDefault();if(m.getAttribute("data-hl-portal")){m.remove();}else{m.classList.add("hidden");}}return;}'
            # 수평선 삭제
            'var b=ev.target.closest("[data-hl-remove]");if(!b)return;'
            'ev.preventDefault();'
            'var code=hlCodeOf(b);if(!code)return;'
            'var price=b.getAttribute("data-hl-remove");'
            'if(!price)return;'
            'postHline("/chart/hlines/remove",code,price,function(){reopenHl(code);});'
        '});'
        # 첫 페이지 로드 시 이미 열려있는 카드 (드물지만 안전).
        'if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",function(){loadChartsInOpen();});'
        'else loadChartsInOpen();'
        '})();</script>'
    )

    # pull-to-refresh: 임계 넘으면 fetch(__behive_load) + spinner 유지, 완료 후 reset. reload() 안 함.
    # iOS PWA엔 네이티브 PTR 부재라 직접 구현. resistance 0.55로 손맛.
    ptr_script = (
        '<script>(function(){'
        'var c=document.getElementById("page"),p=document.getElementById("ptr");if(!c||!p)return;'
        'var sy=0,dy=0,pull=false,ok=false,T=70,MAX=110;'
        # 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");'
            '}},{passive:true});'
        'document.addEventListener("touchmove",function(e){'
            'if(!pull)return;dy=e.touches[0].clientY-sy;'
            'if(dy<=0){reset();return;}'
            'var d=dy*0.55;if(d>MAX)d=MAX;'
            'var pct=Math.min(d/T,1),r=pct*180;'
            'c.style.transform="translateY("+d+"px)";'
            'p.style.transform="rotate("+r+"deg)";'
            'p.style.opacity=Math.min(d/25,1);'
            'if(d>T){if(!ok){ok=true;p.classList.add("committed");}}'
            'else if(ok){ok=false;p.classList.remove("committed");}'
        '},{passive:true});'
        'document.addEventListener("touchend",function(){'
            'if(!pull)return;'
            'var d=dy*0.55;if(d>MAX)d=MAX;'
            'if(d>T){'
                'c.style.transform="translateY("+(T+15)+"px)";'
                'p.style.transform="";'
                'p.style.opacity="1";'
                'p.classList.add("spinning");'
                'var done=function(){'
                    'reset();'
                    'if(window.__behive_auto_arm)window.__behive_auto_arm();'
                '};'
                'var ow=window.__behive_active_owner?window.__behive_active_owner():null;'
                'var f=window.__behive_load?window.__behive_load(ow):Promise.resolve();'
                'f.then(done,done);'
            '}else{reset();}'
            'pull=false;dy=0;'
        '});'
        '})();</script>'
    )

    # 시장 상태 배지 + 개장/마감 전환 감지 (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 MARKET={market_active_js};'
        'var SLOW_MS=60000,poll=null,cur=MARKET;'
        'var HSEQ_MS=20000,hpoll=null,hseq=null;'  # 외부 매매 감지 폴링(장중만)
        'var badge=document.getElementById("auto-badge");'
        # 배지 점: 장중 초록(.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;'
                '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);'
        '}'
        # 외부 매매(키움 앱 등) 감지 — 장중 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",dot);'
        'if(window.__behive_set_market)window.__behive_set_market(MARKET);'
        'dot();startPoll();startHPoll();'
        '})();</script>'
    )

    # 더블탭 zoom 차단 — iOS Safari가 `touch-action: manipulation`을 무시할 때 폴백.
    # 300ms 안에 두 번째 touchend가 들어오면 preventDefault → 시스템 zoom 동작·동시 발생하는 ghost click 둘 다 억제.
    # 첫 탭의 click은 정상 dispatch되므로 details 토글·새로고침 버튼 등 단일 탭 UX 영향 없음.
    # 예외: 하단 지수 바(#market-ticker)는 탭할 때마다 묶음을 넘기는 UI라 빠른 연타가 정상 사용이다.
    # 여기서 걸러내면 두 번째 탭부터 click이 안 와서 한 칸씩 건너뛴다. 그 바만 억제 대상에서 뺀다
    # (더블탭 zoom은 html,body의 touch-action: manipulation이 1차로 막고, 이 바는 36px 최하단 띠라 위험이 작다).
    nodbltap_script = (
        '<script>(function(){'
        'var lastT=0;'
        'document.addEventListener("touchend",function(e){'
            'var now=Date.now();'
            'var t=e.target;'
            'var onTicker=!!(t&&t.closest&&t.closest("#market-ticker"));'
            'if(now-lastT<=300&&!onTicker)e.preventDefault();'
            'lastT=now;'
        '},{passive:false});'
        '})();</script>'
    )

    # 등락률 계산기 모달 로직 — 기준가·현재가 입력 즉시 % / 차이 금액 계산 (순수 클라이언트).
    calc_script = (
        '<script>(function(){'
        'var UP="#ff4d5e",DOWN="#4a8cf0",NEU="#8b8f9a";'
        'function num(el){if(!el)return NaN;var v=parseFloat(el.value);return isFinite(v)?v:NaN;}'
        'function recalc(){'
            'var base=num(document.querySelector("[data-calc-base]"));'
            'var cur=num(document.querySelector("[data-calc-cur]"));'
            'var qty=num(document.querySelector("[data-calc-qty]"));'
            'var pctEl=document.querySelector("[data-calc-pct]");'
            'var diffEl=document.querySelector("[data-calc-diff]");'
            'var profEl=document.querySelector("[data-calc-profit]");'
            'var profSub=document.querySelector("[data-calc-profit-sub]");'
            'var recEl=document.querySelector("[data-calc-recover]");'
            'var hintEl=document.querySelector("[data-calc-hint]");'
            'if(!pctEl||!diffEl)return;'
            'if(isNaN(base)||isNaN(cur)||base===0){'
                'pctEl.textContent="—";pctEl.style.color=NEU;'
                'diffEl.textContent="—";diffEl.style.color=NEU;'
                'if(profEl){profEl.textContent="—";profEl.style.color=NEU;}if(profSub)profSub.textContent="";'
                'if(recEl){recEl.textContent="—";recEl.style.color=NEU;}'
                'if(hintEl)hintEl.textContent=(!isNaN(base)&&base===0)?"기준가는 0이 될 수 없어요.":"기준가·현재가를 입력하세요.";'
                'return;'
            '}'
            'var pct=(cur-base)/base*100;'
            'var diff=cur-base;'
            'var col=diff>0?UP:(diff<0?DOWN:NEU);'
            'var psign=pct>0?"+":(pct<0?"−":"");'
            'var dsign=diff>0?"+":(diff<0?"−":"");'
            'pctEl.textContent=psign+Math.abs(pct).toFixed(2)+"%";pctEl.style.color=col;'
            'diffEl.textContent=dsign+Math.abs(diff).toLocaleString()+"원";diffEl.style.color=col;'
            'if(profEl){'
                'if(isNaN(qty)||qty<=0){profEl.textContent="—";profEl.style.color=NEU;if(profSub)profSub.textContent="주수 입력 시";}'
                'else{var prof=diff*qty;var pfsign=prof>0?"+":(prof<0?"−":"");'
                    'profEl.textContent=pfsign+Math.abs(Math.round(prof)).toLocaleString()+"원";'
                    'profEl.style.color=prof>0?UP:(prof<0?DOWN:NEU);'
                    'if(profSub)profSub.textContent="· "+Math.round(qty).toLocaleString()+"주";}'
            '}'
            'if(recEl){'
                'if(cur===0){recEl.textContent="—";recEl.style.color=NEU;}'
                'else{var rec=(base-cur)/cur*100;'
                    'if(rec>0.005){recEl.textContent="+"+rec.toFixed(2)+"%";}'
                    'else{recEl.textContent="달성";}'
                    'recEl.style.color=NEU;}'
            '}'
            'if(hintEl)hintEl.textContent="";'
        '}'
        'document.addEventListener("input",function(e){'
            'if(e.target&&e.target.closest&&e.target.closest("[data-calc-base],[data-calc-cur],[data-calc-qty]"))recalc();'
        '});'
        # ── 관심·보유·감시 종목에서 현재가 자동 입력 ──
        'var symLoaded=false;'
        'function loadSymbols(){'
            'if(symLoaded)return;'
            'var sel=document.querySelector("[data-calc-symbol]");if(!sel)return;'
            'symLoaded=true;'
            'fetch("/api/symbols/all",{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(d){'
                'var items=(d&&d.items)||[];'
                'var groups={"보유":[],"관심":[],"감시":[]};'
                'items.forEach(function(it){(groups[it.source]||groups["관심"]).push(it);});'
                'var html=\'<option value="">종목 선택 (관심·보유·감시)…</option>\';'
                '["보유","관심","감시"].forEach(function(g){'
                    'var rows=groups[g];if(!rows||!rows.length)return;'
                    'html+=\'<optgroup label="\'+g+\'">\';'
                    'rows.forEach(function(it){'
                        'var nm=(it.name||it.code).replace(/</g,"&lt;");'
                        'html+=\'<option value="\'+it.code+\'" data-name="\'+nm.replace(/"/g,"&quot;")+\'">\'+nm+\'</option>\';'
                    '});'
                    'html+="</optgroup>";'
                '});'
                'sel.innerHTML=html;'
            '}).catch(function(){symLoaded=false;});'
        '}'
        'document.addEventListener("change",function(e){'
            'var sel=e.target.closest&&e.target.closest("[data-calc-symbol]");if(!sel)return;'
            'var opt=sel.selectedOptions&&sel.selectedOptions[0];'
            'var code=sel.value;'
            'var nm=(opt&&opt.getAttribute("data-name"))||"";'
            'var st=document.querySelector("[data-calc-sym-status]");'
            'var curEl=document.querySelector("[data-calc-cur]");'
            'if(!code){if(st)st.textContent="";return;}'
            'if(st){st.textContent=nm+" 현재가 불러오는 중…";st.style.color="#7a8493";}'
            'fetch("/api/quote_book?code="+encodeURIComponent(code),{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(d){'
                'var p=d&&d.price;'
                'if(!p||isNaN(p)){if(st){st.textContent=nm+" 현재가를 가져오지 못했어요.";st.style.color="#ff4d5e";}return;}'
                'if(curEl){curEl.value=p;}'
                'if(st){st.textContent=(nm||(d.name||""))+" 현재가 "+Number(p).toLocaleString()+"원 적용";st.style.color="#8b8f9a";}'
                'recalc();'
            '}).catch(function(){if(st){st.textContent=nm+" 현재가 조회 실패";st.style.color="#ff4d5e";}});'
        '});'
        # 계산기 런처는 ☰ 팝업 안이라 팝업이 안 닫힘 → 모달 열 때 팝업 닫고 종목목록 로드.
        'document.addEventListener("click",function(e){'
            'var b=e.target.closest&&e.target.closest("[data-modal-open=\\"calc-modal\\"]");'
            'if(!b)return;'
            'var pop=document.getElementById("opts-popup");'
            'var tg=document.getElementById("opts-toggle");'
            'if(pop)pop.setAttribute("hidden","");'
            'if(tg)tg.setAttribute("aria-expanded","false");'
            'loadSymbols();'
        '});'
        '})();</script>'
    )

    # 평단 계산기(물타기) 로직 — 보유수량·현재평단가·추가매수 주수·추가매수가 즉시 계산 (순수 클라이언트).
    avg_calc_script = (
        '<script>(function(){'
        'function num(el){if(!el)return NaN;var v=parseFloat(el.value);return isFinite(v)?v:NaN;}'
        'function won(n){return Math.round(n).toLocaleString()+"원";}'
        'function recalc(){'
            'var hq=num(document.querySelector("[data-avg-hqty]"));'
            'var ha=num(document.querySelector("[data-avg-havg]"));'
            'var aq=num(document.querySelector("[data-avg-aqty]"));'
            'var ap=num(document.querySelector("[data-avg-aprice]"));'
            'var naEl=document.querySelector("[data-avg-newavg]");'
            'var tqEl=document.querySelector("[data-avg-totqty]");'
            'var tcEl=document.querySelector("[data-avg-totcost]");'
            'var acEl=document.querySelector("[data-avg-addcost]");'
            'var aqLbl=document.querySelector("[data-avg-addqty]");'
            'var hintEl=document.querySelector("[data-avg-hint]");'
            'if(!naEl||!tqEl||!tcEl)return;'
            'if(isNaN(hq)||isNaN(ha)||isNaN(aq)||isNaN(ap)||(hq+aq)<=0){'
                'naEl.textContent="—";naEl.style.color="#8b8f9a";'
                'tqEl.textContent="—";tcEl.textContent="—";'
                'if(acEl)acEl.textContent="—";if(aqLbl)aqLbl.textContent="";'
                'if(hintEl)hintEl.textContent="보유수량·현재평단가·추가매수 주수(또는 금액)·추가매수가를 입력하세요.";'
                'return;'
            '}'
            'var totQty=hq+aq;var totCost=hq*ha+aq*ap;var newAvg=totCost/totQty;'
            'if(acEl)acEl.textContent=won(aq*ap);'
            'if(aqLbl)aqLbl.textContent=(aq>0)?("· "+Math.round(aq).toLocaleString()+"주"):"";'
            'naEl.textContent=won(newAvg);'
            'tqEl.textContent=Math.round(totQty).toLocaleString()+"주";'
            'tcEl.textContent=won(totCost);'
            'var col=newAvg<ha?"#4a8cf0":(newAvg>ha?"#ff4d5e":"#8b8f9a");naEl.style.color=col;'
            'if(hintEl){var d=newAvg-ha;var sign=d<0?"−":(d>0?"+":"");'
                'hintEl.textContent=(d===0)?"평단 동일":("평단 "+sign+Math.abs(Math.round(d)).toLocaleString()+"원 "+(d<0?"하락":"상승"));}'
        '}'
        # 추가매수 금액↔주수 양방향 동기화 (추가매수가 기준). 주수가 진실원천 — recalc이 주수로 계산.
        # .value 코드 세팅은 input 이벤트를 안 쏘므로 무한루프 없음.
        'function syncAdd(src){'
            'var apEl=document.querySelector("[data-avg-aprice]");'
            'var aqEl=document.querySelector("[data-avg-aqty]");'
            'var amEl=document.querySelector("[data-avg-aamt]");'
            'if(!apEl||!aqEl||!amEl)return;'
            'var ap=num(apEl);'
            'if(src==="amt"){var am=num(amEl);if(ap>0&&!isNaN(am))aqEl.value=Math.floor(am/ap);}'
            'else{var aq=num(aqEl);if(ap>0&&!isNaN(aq))amEl.value=Math.round(aq*ap);}'
        '}'
        'document.addEventListener("input",function(e){'
            'if(!e.target||!e.target.closest)return;'
            'if(e.target.closest("[data-avg-aamt]")){syncAdd("amt");recalc();return;}'
            'if(e.target.closest("[data-avg-aqty]")){syncAdd("qty");recalc();return;}'
            'if(e.target.closest("[data-avg-aprice]")){syncAdd("price");recalc();return;}'
            'if(e.target.closest("[data-avg-hqty],[data-avg-havg]"))recalc();'
        '});'
        # 현재가 버튼 — 선택된 보유종목 code로 /api/quote_book 실시간 조회 후 추가매수가에 주입.
        'document.addEventListener("click",function(e){'
            'var cb=e.target.closest&&e.target.closest("[data-avg-curprice]");if(!cb)return;'
            'var sel=document.querySelector("[data-avg-symbol]");'
            'var opt=sel&&sel.selectedOptions&&sel.selectedOptions[0];'
            'var code=opt&&opt.getAttribute("data-code");'
            'var st=document.querySelector("[data-avg-sym-status]");'
            'if(!code){if(st){st.textContent="종목을 먼저 선택하세요";st.style.color="#c9a227";}return;}'
            'cb.disabled=true;'
            'fetch("/api/quote_book?code="+encodeURIComponent(code),{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(d){'
                'var px=d&&d.price;var apEl=document.querySelector("[data-avg-aprice]");'
                'if(px>0&&apEl){apEl.value=px;syncAdd("price");recalc();if(st){st.textContent="현재가 "+Number(px).toLocaleString()+"원 적용";st.style.color="#8b8f9a";}}'
                'else if(st){st.textContent="현재가 조회 실패";st.style.color="#c9a227";}'
            '}).catch(function(){if(st){st.textContent="현재가 조회 실패";st.style.color="#c9a227";}}).then(function(){cb.disabled=false;});'
        '});'
        # ── 내 계좌 보유종목에서 보유수량·현재평단가 자동 입력 (code별 전 계좌 가중평균) ──
        'var hLoaded=false;'
        'function loadHoldings(){'
            'if(hLoaded)return;'
            'var sel=document.querySelector("[data-avg-symbol]");if(!sel)return;'
            'hLoaded=true;'
            'fetch("/api/holdings",{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(d){'
                'var items=(d&&d.items)||[];'
                'var html=\'<option value="">보유 종목 선택…</option>\';'
                'items.forEach(function(it,i){'
                    'var nm=(it.name||it.code).replace(/</g,"&lt;");'
                    'var lbl=nm+" ("+(it.owner||"")+" "+Number(it.qty).toLocaleString()+"주)";'
                    'html+=\'<option value="\'+i+\'" data-code="\'+(it.code||"")+\'" data-qty="\'+it.qty+\'" data-avg="\'+it.avg+\'" data-name="\'+(nm+" "+(it.owner||"")).replace(/"/g,"&quot;")+\'">\'+lbl+"</option>";'
                '});'
                'sel.innerHTML=html;'
            '}).catch(function(){hLoaded=false;});'
        '}'
        'document.addEventListener("change",function(e){'
            'var sel=e.target.closest&&e.target.closest("[data-avg-symbol]");if(!sel)return;'
            'var opt=sel.selectedOptions&&sel.selectedOptions[0];'
            'var st=document.querySelector("[data-avg-sym-status]");'
            'if(!sel.value||!opt){if(st)st.textContent="";return;}'
            'var q=opt.getAttribute("data-qty"),a=opt.getAttribute("data-avg");'
            'var nm=opt.getAttribute("data-name")||"";'
            'var hq=document.querySelector("[data-avg-hqty]");var ha=document.querySelector("[data-avg-havg]");'
            'if(hq)hq.value=q;if(ha)ha.value=a;'
            'if(st){st.textContent=nm+" 보유 "+Number(q).toLocaleString()+"주 · 평단 "+Number(a).toLocaleString()+"원 적용";st.style.color="#8b8f9a";}'
            'recalc();'
        '});'
        # 런처 — ☰ 팝업 안이라 팝업 닫고 보유목록 로드.
        'document.addEventListener("click",function(e){'
            'var b=e.target.closest&&e.target.closest("[data-modal-open=\\"avgcalc-modal\\"]");'
            'if(!b)return;'
            'var pop=document.getElementById("opts-popup");var tg=document.getElementById("opts-toggle");'
            'if(pop)pop.setAttribute("hidden","");if(tg)tg.setAttribute("aria-expanded","false");'
            'loadHoldings();'
        '});'
        '})();</script>'
    )

    # 보유 평가 계산기 로직 — 임의 종목 검색(현재가 자동) + 평단가·주수 → 등락률·평가손익·매입/평가금액 (순수 클라이언트).
    holdeval_script = (
        '<script>(function(){'
        'var UP="#ff4d5e",DOWN="#4a8cf0",NEU="#8b8f9a";'
        'var selCode="",selName="";'  # 추가/수정 팝업에서 검색·선택한 종목
        'var savedItems=[],curSel="",editId="";'  # curSel=결과영역 표시 항목, editId!=""면 수정 모드
        'var editBaseAvg=NaN,editBaseQty=NaN;'  # 수정 모드 기존 평단/주수 (추가매수 합산의 기준)
        'function num(el){if(!el)return NaN;var v=parseFloat(el.value);return isFinite(v)?v:NaN;}'
        'function won(n){var s=n<0?"−":(n>0?"+":"");return s+Math.abs(Math.round(n)).toLocaleString()+"원";}'
        'function esc(s){return String(s==null?"":s).replace(/[&<>"]/g,function(c){return{"&":"&amp;","<":"&lt;",">":"&gt;","\\"":"&quot;"}[c];});}'
        # ── 결과 영역(메인 모달): 저장항목+현재가로 등락률·손익·매입/평가 표시 ──
        'function resultEls(){return{'
            'head:document.querySelector("[data-he-result-head]"),refresh:document.querySelector("[data-he-refresh]"),'
            'pct:document.querySelector("[data-he-pct]"),pl:document.querySelector("[data-he-pl]"),plSub:document.querySelector("[data-he-pl-sub]"),'
            'nameL:document.querySelector("[data-he-name]"),price:document.querySelector("[data-he-price]"),'
            'avgL:document.querySelector("[data-he-avgline]"),qtyL:document.querySelector("[data-he-qtyline]"),'
            'cost:document.querySelector("[data-he-cost]"),val:document.querySelector("[data-he-value]")};}'
        'function clearResult(){curSel="";var E=resultEls();'
            'if(E.head)E.head.textContent="목록에서 항목을 누르면 현재 상태가 표시됩니다.";'
            'if(E.refresh)E.refresh.classList.add("hidden");'
            'if(E.pct){E.pct.textContent="—";E.pct.style.color=NEU;}'
            'if(E.pl){E.pl.textContent="—";E.pl.style.color=NEU;}if(E.plSub)E.plSub.textContent="";'
            'if(E.nameL)E.nameL.textContent="—";if(E.price)E.price.textContent="—";'
            'if(E.avgL)E.avgL.textContent="—";if(E.qtyL)E.qtyL.textContent="—";'
            'if(E.cost)E.cost.textContent="—";if(E.val)E.val.textContent="—";'
            'markActive();}'
        'function renderResult(it,price){var E=resultEls();if(!E.pct)return;'
            'var owner=it.owner?esc(it.owner)+" · ":"";'
            'if(E.head)E.head.innerHTML=owner+esc(it.name||it.code);'
            'if(E.refresh)E.refresh.classList.remove("hidden");'
            'var avg=it.avg,qty=it.qty;var pct=(price-avg)/avg*100;var col=pct>0?UP:(pct<0?DOWN:NEU);var psign=pct>0?"+":(pct<0?"−":"");'
            'E.pct.textContent=psign+Math.abs(pct).toFixed(2)+"%";E.pct.style.color=col;'
            'var cost=avg*qty,val=price*qty,pl=val-cost;'
            'E.pl.textContent=won(pl);E.pl.style.color=pl>0?UP:(pl<0?DOWN:NEU);'
            'if(E.plSub)E.plSub.textContent="· "+Math.round(qty).toLocaleString()+"주";'
            'if(E.nameL)E.nameL.textContent=(it.name||it.code)+(it.code?(" ("+it.code+")"):"");'
            'if(E.price){E.price.textContent=Number(price).toLocaleString()+"원";E.price.style.color=col;}'
            'if(E.avgL)E.avgL.textContent=Math.round(avg).toLocaleString()+"원";'
            'if(E.qtyL)E.qtyL.textContent=Math.round(qty).toLocaleString()+"주";'
            'E.cost.textContent=Math.round(cost).toLocaleString()+"원";'
            'E.val.textContent=Math.round(val).toLocaleString()+"원";}'
        'function fetchResult(it){var E=resultEls();'
            'if(E.head)E.head.textContent=(it.name||it.code)+" 현재가 불러오는 중…";'
            'fetch("/api/quote_book?code="+encodeURIComponent(it.code),{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(d){'
                'if(curSel!==it.id)return;'  # 조회 중 다른 항목 선택되면 무시
                'var p=d&&d.price;'
                'if(!p||isNaN(p)){if(E.head)E.head.textContent=(it.name||it.code)+" 현재가를 가져오지 못했어요.";return;}'
                'renderResult(it,p);'
            '}).catch(function(){if(curSel===it.id&&E.head)E.head.textContent="현재가 조회 실패";});}'
        'function selectSaved(id){var it=null;for(var i=0;i<savedItems.length;i++){if(savedItems[i].id===id){it=savedItems[i];break;}}'
            'if(!it)return;curSel=id;markActive();fetchResult(it);}'
        'function markActive(){var picks=document.querySelectorAll("[data-he-pick]");'
            'for(var i=0;i<picks.length;i++){picks[i].classList.toggle("active",picks[i].getAttribute("data-he-id")===curSel);}}'
        # ── 저장 목록 렌더/로드 ──
        'function renderSaved(items){var wrap=document.querySelector("[data-he-saved]");if(!wrap)return;'
            'if(!items||!items.length){wrap.innerHTML=\'<div class="muted small">저장된 항목이 없습니다. 아래 추가를 눌러 등록하세요.</div>\';return;}'
            'wrap.innerHTML=items.map(function(it){'
                'var owner=it.owner?esc(it.owner)+" · ":"";var nm=esc(it.name||it.code);'
                'var meta=Math.round(it.avg).toLocaleString()+"원 · "+Math.round(it.qty).toLocaleString()+"주";'
                'return \'<div class="he-saved-row">\'+'
                    '\'<button type="button" class="he-saved-pick" data-he-pick data-he-id="\'+esc(it.id)+\'">\'+owner+nm+\' <span class="he-saved-meta">\'+meta+\'</span></button>\'+'
                    '\'<button type="button" class="he-saved-edit" data-he-edit data-he-id="\'+esc(it.id)+\'" aria-label="수정" title="수정">✎</button>\'+'
                    '\'<button type="button" class="he-saved-del" data-he-del data-he-id="\'+esc(it.id)+\'" aria-label="삭제" title="삭제">×</button>\'+'
                '\'</div>\';'
            '}).join("");markActive();}'
        'function loadSaved(){'
            'fetch("/api/holdeval/list",{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(d){'
                'savedItems=(d&&d.items)||[];renderSaved(savedItems);'
            '}).catch(function(){var w=document.querySelector("[data-he-saved]");if(w)w.innerHTML=\'<div class="muted small">목록 조회 실패</div>\';});'
        '}'
        'function deleteSaved(id){var body=new URLSearchParams();body.set("id",id);'
            'fetch("/holdeval/delete",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded","Accept":"application/json"},body:body.toString(),credentials:"same-origin"}).then(function(r){return r.json();}).then(function(d){'
                'if(d&&d.ok){savedItems=d.items||[];renderSaved(savedItems);if(id===curSel)clearResult();}'
            '}).catch(function(){});}'
        # ── 추가 팝업(holdeval-add-modal): 검색/선택/저장 ──
        'function addStatus(){return document.querySelector("#holdeval-add-modal [data-he-sym-status]");}'
        'function resetAddForm(){selCode="";selName="";editId="";editBaseAvg=NaN;editBaseQty=NaN;'
            'var m=document.getElementById("holdeval-add-modal");if(!m)return;'
            'var f=m.querySelectorAll("[data-he-owner],[data-he-search],[data-he-avg],[data-he-qty],[data-he-addprice],[data-he-addqty]");'
            'for(var i=0;i<f.length;i++)f[i].value="";'
            'var rb=m.querySelector("[data-he-results]");if(rb){rb.classList.add("hidden");rb.innerHTML="";}'
            'var st=m.querySelector("[data-he-sym-status]");if(st)st.textContent="";'
            'var mw=m.querySelector("[data-he-merge-wrap]");if(mw)mw.classList.add("hidden");'
            'var mh=m.querySelector("[data-he-merge-hint]");if(mh)mh.textContent="";'
            'var title=document.getElementById("holdeval-add-modal-title");if(title)title.textContent="항목 추가";'
            'var sb=m.querySelector("[data-he-save-btn]");if(sb)sb.textContent="💾 저장";}'
        # 수정 — 저장항목을 팝업에 prefill 후 편집(물타기·불타기 후 평단가·주수 갱신).
        'function openEdit(id){var it=null;for(var i=0;i<savedItems.length;i++){if(savedItems[i].id===id){it=savedItems[i];break;}}'
            'if(!it)return;resetAddForm();'
            'editId=id;selCode=it.code||"";selName=it.name||"";editBaseAvg=Number(it.avg);editBaseQty=Number(it.qty);'
            'var m=document.getElementById("holdeval-add-modal");if(!m)return;'
            'var owner=m.querySelector("[data-he-owner]");if(owner)owner.value=it.owner||"";'
            'var search=m.querySelector("[data-he-search]");if(search)search.value=it.name||it.code||"";'
            'var avg=m.querySelector("[data-he-avg]");if(avg)avg.value=it.avg;'
            'var qty=m.querySelector("[data-he-qty]");if(qty)qty.value=it.qty;'
            'var mw=m.querySelector("[data-he-merge-wrap]");if(mw)mw.classList.remove("hidden");'  # 수정 모드에서만 추가매수 합산 표시
            'var st=m.querySelector("[data-he-sym-status]");if(st){st.textContent=(it.name||it.code)+" ("+it.code+") — 평단가·주수 직접 수정 또는 아래 추가매수 입력";st.style.color="#8b8f9a";}'
            'var title=document.getElementById("holdeval-add-modal-title");if(title)title.textContent="항목 수정";'
            'var sb=m.querySelector("[data-he-save-btn]");if(sb)sb.textContent="💾 수정 저장";'
            'm.classList.remove("hidden");m.setAttribute("aria-hidden","false");if(window.__behiveLockScroll)window.__behiveLockScroll();}'
        # 추가매수 합산 — 추가 단가·수량 입력 시 기존(base) 평단·주수와 가중평균으로 합산해 평단가·주수 필드 갱신.
        'function recomputeMerge(){var m=document.getElementById("holdeval-add-modal");if(!m)return;'
            'var addP=num(m.querySelector("[data-he-addprice]"));var addQ=num(m.querySelector("[data-he-addqty]"));'
            'var avgEl=m.querySelector("[data-he-avg]");var qtyEl=m.querySelector("[data-he-qty]");var hint=m.querySelector("[data-he-merge-hint]");'
            'if(isNaN(editBaseAvg)||isNaN(editBaseQty)){if(hint)hint.textContent="";return;}'
            'if(isNaN(addP)||isNaN(addQ)||addP<=0||addQ<=0){'  # 추가 비면 base로 복원
                'if(avgEl)avgEl.value=Math.round(editBaseAvg);if(qtyEl)qtyEl.value=Math.round(editBaseQty);'
                'if(hint)hint.textContent="";return;'
            '}'
            'var newQty=editBaseQty+addQ;var newAvg=(editBaseQty*editBaseAvg+addQ*addP)/newQty;'
            'if(avgEl)avgEl.value=Math.round(newAvg);if(qtyEl)qtyEl.value=Math.round(newQty);'
            'if(hint){var d=newAvg-editBaseAvg;var dir=d<0?"평단 하락(물타기)":(d>0?"평단 상승(불타기)":"평단 동일");'
                'hint.textContent="합산 후 평단 "+Math.round(newAvg).toLocaleString()+"원 · "+Math.round(newQty).toLocaleString()+"주 — "+dir;}}'
        'function closeAddModal(){var m=document.getElementById("holdeval-add-modal");if(!m)return;'
            'm.classList.add("hidden");m.setAttribute("aria-hidden","true");if(window.__behiveUnlockScroll)window.__behiveUnlockScroll();}'
        'function pickStock(code,name){selCode=code||"";selName=name||"";var st=addStatus();'
            'if(st){st.textContent=(name||code)+" 현재가 불러오는 중…";st.style.color="#7a8493";}'
            'fetch("/api/quote_book?code="+encodeURIComponent(code),{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(d){'
                'var p=d&&d.price;if(d&&d.name)selName=d.name;'
                'if(!p||isNaN(p)){if(st){st.textContent=(name||code)+" 현재가를 가져오지 못했어요.";st.style.color="#ff4d5e";}return;}'
                'if(st){st.textContent=(selName||code)+" ("+code+") 현재가 "+Number(p).toLocaleString()+"원";st.style.color="#8b8f9a";}'
            '}).catch(function(){if(st){st.textContent=(name||code)+" 현재가 조회 실패";st.style.color="#ff4d5e";}});}'
        'function doSearch(){var m=document.getElementById("holdeval-add-modal");if(!m)return;'
            'var inp=m.querySelector("[data-he-search]");var rb=m.querySelector("[data-he-results]");'
            'var q=inp?inp.value.trim():"";if(!q)return;'
            'if(rb){rb.classList.remove("hidden");rb.innerHTML=\'<div class="muted small">검색 중…</div>\';}'
            'fetch("/api/stock_search?q="+encodeURIComponent(q),{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(d){'
                'var res=(d&&d.results)||[];'
                'if(!res.length){if(rb)rb.innerHTML=\'<div class="muted small">\'+esc((d&&d.error)||"검색 결과 없음")+\'</div>\';return;}'
                'if(res.length===1){if(rb){rb.classList.add("hidden");rb.innerHTML="";}if(inp)inp.value=res[0].name||inp.value;pickStock(res[0].code,res[0].name);return;}'
                'if(rb)rb.innerHTML=res.map(function(it){return \'<button type="button" class="info-search-hit" data-he-hit data-he-code="\'+esc(it.code)+\'" data-he-name="\'+esc(it.name||"")+\'">\'+esc(it.name||it.code)+\' <span class="muted small">\'+esc(it.code)+\'</span></button>\';}).join("");'
            '}).catch(function(){if(rb)rb.innerHTML=\'<div class="muted small">검색 오류</div>\';});}'
        'function saveEntry(){var m=document.getElementById("holdeval-add-modal");if(!m)return;var st=addStatus();'
            'var avg=num(m.querySelector("[data-he-avg]"));var qty=num(m.querySelector("[data-he-qty]"));'
            'var owner=(m.querySelector("[data-he-owner]")||{}).value||"";'
            'if(!selCode){if(st){st.textContent="종목을 먼저 검색·선택하세요";st.style.color="#ff4d5e";}return;}'
            'if(isNaN(avg)||avg<=0||isNaN(qty)||qty<=0){if(st){st.textContent="평단가·주수를 입력하세요";st.style.color="#ff4d5e";}return;}'
            'var body=new URLSearchParams();body.set("owner",owner);body.set("code",selCode);body.set("name",selName||"");body.set("avg",avg);body.set("qty",qty);'
            'var url="/holdeval/save";if(editId){url="/holdeval/update";body.set("id",editId);}'
            'fetch(url,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded","Accept":"application/json"},body:body.toString(),credentials:"same-origin"}).then(function(r){return r.json();}).then(function(d){'
                'if(!d||!d.ok){if(st){st.textContent=(d&&d.error)||"저장 실패";st.style.color="#ff4d5e";}return;}'
                'savedItems=d.items||[];renderSaved(savedItems);'
                'var eid=editId;resetAddForm();closeAddModal();'
                'if(eid&&eid===curSel)selectSaved(curSel);'  # 수정한 항목이 결과영역에 떠있으면 새로 계산
            '}).catch(function(){if(st){st.textContent="저장 오류";st.style.color="#ff4d5e";}});}'
        # ── 이벤트 위임 ──
        'document.addEventListener("input",function(e){if(!e.target||!e.target.closest)return;'
            'if(e.target.closest("[data-he-addprice],[data-he-addqty]")){recomputeMerge();return;}'
            # 수정 모드에서 추가매수 비었을 때 평단가·주수 수동 수정 → base 갱신 (이후 합산 기준)
            'if(editId&&e.target.closest("[data-he-avg],[data-he-qty]")){'
                'var m=document.getElementById("holdeval-add-modal");if(!m)return;'
                'var ap=num(m.querySelector("[data-he-addprice]"));var aq=num(m.querySelector("[data-he-addqty]"));'
                'if(isNaN(ap)||isNaN(aq)||ap<=0||aq<=0){editBaseAvg=num(m.querySelector("[data-he-avg]"));editBaseQty=num(m.querySelector("[data-he-qty]"));}'
            '}'
        '});'
        'document.addEventListener("click",function(e){var t=e.target;'
            'if(t.closest&&t.closest("[data-he-search-btn]")){doSearch();return;}'
            'if(t.closest&&t.closest("[data-he-save-btn]")){saveEntry();return;}'
            'if(t.closest&&t.closest("[data-he-refresh]")){if(curSel)selectSaved(curSel);return;}'
            'var ed=t.closest&&t.closest("[data-he-edit]");if(ed){openEdit(ed.getAttribute("data-he-id"));return;}'
            'var pk=t.closest&&t.closest("[data-he-pick]");if(pk){selectSaved(pk.getAttribute("data-he-id"));return;}'
            'var dl=t.closest&&t.closest("[data-he-del]");if(dl){deleteSaved(dl.getAttribute("data-he-id"));return;}'
            'var hit=t.closest&&t.closest("[data-he-hit]");'
            'if(hit){var m=document.getElementById("holdeval-add-modal");var rb=m&&m.querySelector("[data-he-results]");var inp=m&&m.querySelector("[data-he-search]");'
                'var nm=hit.getAttribute("data-he-name")||"";var cd=hit.getAttribute("data-he-code")||"";'
                'if(inp)inp.value=nm||cd;if(rb){rb.classList.add("hidden");rb.innerHTML="";}pickStock(cd,nm);return;}'
            'if(t.closest&&t.closest("[data-modal-open=\\"holdeval-add-modal\\"]")){resetAddForm();return;}'  # 팝업 열 때 폼 초기화(열기는 generic 핸들러)
        '});'
        'document.addEventListener("keydown",function(e){'
            'if(e.key==="Enter"&&e.target&&e.target.closest&&e.target.closest("#holdeval-add-modal [data-he-search]")){e.preventDefault();doSearch();}'
        '});'
        # 런처 — ☰ 팝업 안이라 팝업 닫고 목록 로드·결과 초기화.
        'document.addEventListener("click",function(e){'
            'var b=e.target.closest&&e.target.closest("[data-modal-open=\\"holdeval-modal\\"]");'
            'if(!b)return;'
            'var pop=document.getElementById("opts-popup");var tg=document.getElementById("opts-toggle");'
            'if(pop)pop.setAttribute("hidden","");if(tg)tg.setAttribute("aria-expanded","false");'
            'clearResult();loadSaved();'
        '});'
        '})();</script>'
    )

    idx_info_modal_html = (
        '<div id="idx-info-modal" class="modal hidden" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="idx-info-modal-title">'
        '<div class="modal-overlay" data-modal-close="1"></div>'
        '<div class="modal-box" role="document">'
        '<div class="modal-head">'
        '<div class="modal-title" id="idx-info-modal-title" data-idx-modal-title>지수 설명</div>'
        '<button type="button" class="modal-close" data-modal-close="1" aria-label="닫기">×</button>'
        '</div>'
        '<div class="modal-body idx-info-body">'
        '<div class="idx-info-stats">'
        '<span class="idx-info-price" data-idx-modal-price>—</span>'
        '<span class="idx-info-change" data-idx-modal-change>—</span>'
        '</div>'
        '<div class="idx-info-chart" data-idx-modal-chart hidden></div>'
        '<div class="idx-info-desc" data-idx-modal-body>—</div>'
        '</div>'
        '<div class="modal-actions">'
        '<button type="button" class="btn-cancel" data-modal-close="1">닫기</button>'
        '</div>'
        '</div>'
        '</div>'
    )
    interest_modal_html = _render_interests_modal()
    candidate_modal_html = _render_candidate_modal()
    interest_edit_modal_html = _render_interests_edit_modal()
    tag_modal_html = _render_tag_modal()
    note_modal_html = _render_note_modal()
    calc_modal_html = _render_calc_modal()
    avg_calc_modal_html = _render_avg_calc_modal()
    holdeval_modal_html = _render_holdeval_modal()
    holdeval_add_modal_html = _render_holdeval_add_modal()
    group_assign_modal_html = _render_group_assign_modal()
    group_manage_modal_html = _render_group_manage_modal()
    trade_modal_html = _render_trade_modal()
    kpi_modal_html = _render_kpi_modal()
    info_modal_html = _render_info_modal()
    info_desc_modal_html = _render_info_desc_modal()
    advice_modal_html = _render_advice_modal()
    order_modal_html = _render_order_modal()
    pin_modal_html = _render_pin_modal()
    sell_choice_modal_html = _render_sell_choice_modal()
    order_confirm_modal_html = _render_order_confirm_modal()
    open_orders_modal_html = _render_open_orders_modal()
    stock_name_modal_html = _render_stock_name_modal()

    # 모달 열기/닫기 + 폼 fetch — 트리거 버튼은 panels swap으로 다시 그려지므로 이벤트 위임.
    # 모달 DOM 자체는 shell HTML 직속이라 swap 영향 받지 않음 → 입력값 보존.
    # /interests/add 응답은 Accept: application/json 시 JSON. 다중 매칭이면 후보 버튼 표시.
    modal_script = (
        '<script>(function(){'
        'var __savedScrollY=0;'
        'function lockScroll(){'
            'if(document.body.classList.contains("modal-open"))return;'
            '__savedScrollY=window.scrollY||window.pageYOffset||0;'
            'document.body.style.top=(-__savedScrollY)+"px";'
            'document.body.classList.add("modal-open");'
        '}'
        'function unlockScroll(){'
            'if(document.querySelector(".modal:not(.hidden)"))return;'
            'document.body.classList.remove("modal-open");'
            'document.body.style.top="";'
            'window.scrollTo(0,__savedScrollY);'
        '}'
        'window.__behiveLockScroll=lockScroll;window.__behiveUnlockScroll=unlockScroll;'
        'function openModal(id){var m=document.getElementById(id);if(!m)return;'
            'm.classList.remove("hidden");m.setAttribute("aria-hidden","false");'
            'lockScroll();'
            'var f=m.querySelector("input[name=stock]");if(f)setTimeout(function(){f.focus();},30);'
        '}'
        'function closeModal(m){if(!m)return;'
            'm.classList.add("hidden");m.setAttribute("aria-hidden","true");'
            'clearFeedback(m);'
            'unlockScroll();'
        '}'
        'function clearFeedback(m){var fb=m&&m.querySelector("[data-feedback]");if(!fb)return;'
            'fb.hidden=true;fb.innerHTML="";'
        '}'
        'function showError(m,msg){var fb=m&&m.querySelector("[data-feedback]");'
            'if(!fb){alert(msg);return;}'  # delete form은 modal 밖이라 fb 없음 — alert로 fallback
            'fb.hidden=false;fb.innerHTML=\'<div class="feedback-error"></div>\';'
            'fb.firstChild.textContent=msg;'
        '}'
        'function openCandidates(query,cands){'
            'var cm=document.getElementById("candidate-modal");if(!cm)return;'
            'var title=cm.querySelector("[data-cand-title]");'
            'if(title)title.textContent=\'"\'+query+\'"와 일치하는 종목이 여러 개입니다. 선택해주세요.\';'
            'var list=cm.querySelector("[data-cand-list]");'
            'if(list){'
                'list.innerHTML="";'
                'cands.forEach(function(c){'
                    'var btn=document.createElement("button");'
                    'btn.type="button";'
                    'btn.className="candidate-btn";'
                    'btn.setAttribute("data-cand-name",c.name||"");'
                    'btn.setAttribute("data-cand-code",c.code||"");'
                    'var lbl=document.createElement("span");lbl.textContent=c.name||"";'
                    'var code=document.createElement("span");code.className="cand-code";code.textContent=c.code||"";'
                    'btn.appendChild(lbl);btn.appendChild(code);'
                    'list.appendChild(btn);'
                '});'
            '}'
            'openModal("candidate-modal");'
        '}'
        'function submitForm(form,overrideName,overrideCode,keepOpen){'
            'var m=form.closest(".modal");'
            'clearFeedback(m);'
            'var fd=new FormData(form);'
            'if(overrideName)fd.set("stock",overrideName);'
            'if(overrideCode)fd.set("code",overrideCode);'
            'var data=new URLSearchParams();'
            'fd.forEach(function(v,k){data.append(k,v);});'
            'var submitBtn=form.querySelector(\'button[type="submit"]\');'
            'if(submitBtn)submitBtn.disabled=true;'
            'fetch(form.action,{method:"POST",headers:{"Accept":"application/json","Content-Type":"application/x-www-form-urlencoded"},body:data.toString(),credentials:"same-origin"})'
                '.then(function(r){return r.json().catch(function(){return {ok:false,error:"HTTP "+r.status};});})'
                '.then(function(j){'
                    'if(j&&j.ok){'
                        'if(!keepOpen){'
                            'form.reset();'
                            'closeModal(m);'
                        '}'
                        'closeModal(document.getElementById("candidate-modal"));'
                        'if(window.__behive_load)window.__behive_load(null,true);'
                    '}else if(j&&j.error==="multiple"){'
                        'openCandidates(j.query||"",j.candidates||[]);'
                    '}else{'
                        'showError(m,(j&&j.error)||"추가 실패");'
                    '}'
                '})'
                '.catch(function(){showError(m,"네트워크 오류");})'
                '.finally(function(){if(submitBtn)submitBtn.disabled=false;});'
        '}'
        'function escHtml(s){return String(s==null?"":s).replace(/[&<>"\\\']/g,function(c){'
            'return {"&":"&amp;","<":"&lt;",">":"&gt;","\\"":"&quot;","\\\'":"&#39;"}[c];'
        '});}'
        'function fmtInt(n){return (n==null?0:n).toLocaleString();}'
        'function fmtTradeDate(s){'
            'var m=String(s==null?"":s).match(/^\\d{2}(\\d{2})-(\\d{1,2})-(\\d{1,2})$/);'
            'return m?(m[1]+"/"+parseInt(m[2],10)+"/"+parseInt(m[3],10)):(s||"");'
        '}'
        'function setupTradePagers(root){'
            'root.querySelectorAll(".trade-pager-wrap").forEach(function(wrap){'
                'var listEl=wrap.querySelector(".trade-list");'
                'var realRows=listEl?Array.prototype.slice.call(listEl.querySelectorAll(":scope > li")):[];'
                # 종목명 헤더(ti-stock-line)가 붙은 owner 모드는 행 높이가 3줄로 늘어나 8개면 스크롤. 5개로 축소.
                'var hasStockLine=listEl && listEl.querySelector(".ti-stock-line")!==null;'
                'var PAGE_SIZE=hasStockLine?5:8;'
                'var totalReal=realRows.length;'
                'var totalPages=Math.max(1,Math.ceil(totalReal/PAGE_SIZE));'
                'var padNeeded=Math.max(0,totalPages*PAGE_SIZE-totalReal);'
                'for(var pi=0;pi<padNeeded && listEl;pi++){'
                    'var ph=document.createElement("li");'
                    'ph.className="trade-item placeholder";'
                    'ph.innerHTML=\'<div class="ti-row1">&nbsp;</div><div class="ti-row2">&nbsp;</div>\';'
                    'listEl.appendChild(ph);'
                '}'
                'var rows=listEl?Array.prototype.slice.call(listEl.querySelectorAll(":scope > li")):[];'
                'var total=totalPages;'
                'var ind=wrap.querySelector(".pg-indicator");'
                'var prev=wrap.querySelector(\'[data-pg="prev"]\');'
                'var next=wrap.querySelector(\'[data-pg="next"]\');'
                'var page=0;'
                'function render(){'
                    'for(var i=0;i<rows.length;i++){'
                        'rows[i].style.display=(i>=page*PAGE_SIZE && i<(page+1)*PAGE_SIZE)?"":"none";'
                    '}'
                    'if(ind)ind.textContent=(page+1)+" / "+total;'
                    'if(prev)prev.disabled=page<=0;'
                    'if(next)next.disabled=page>=total-1;'
                '}'
                'if(rows.length<=PAGE_SIZE){'
                    'var pg=wrap.querySelector(".trade-pager");'
                    'if(pg)pg.style.display="none";'
                '}'
                'if(prev)prev.addEventListener("click",function(e){e.preventDefault();e.stopPropagation();if(page>0){page--;render();}});'
                'if(next)next.addEventListener("click",function(e){e.preventDefault();e.stopPropagation();if(page<total-1){page++;render();}});'
                'render();'
            '});'
        '}'
        'function fmtNum(n,d){if(n==null||n==="")return "—";var v=Number(n);if(isNaN(v))return "—";return v.toLocaleString(undefined,{minimumFractionDigits:d,maximumFractionDigits:d});}'
        'var INFO_DESC={'
            '"현재가":"지금 시장에서 거래되는 주가입니다.",'
            '"시가총액":"회사 전체의 시장 가치. 현재가 × 상장주식수로, 회사 크기를 가늠하는 기준입니다.",'
            '"PER":"주가가 회사 1년 이익의 몇 배인지. 낮을수록 싼 편입니다.",'
            '"PBR":"주가가 회사 순자산의 몇 배인지. 1배 미만이면 자산보다 싸게 거래되는 셈입니다.",'
            '"EPS":"주식 한 주가 1년 동안 벌어들인 이익입니다.",'
            '"ROE":"회사가 가진 돈으로 얼마나 이익을 냈는지. 높을수록 좋습니다.",'
            '"영업이익":"회사가 본업으로 번 이익입니다. 괄호는 해당 분기(최근 확정 분기 기준)이며, 분기 데이터가 없으면 연간으로 표시됩니다.",'
            '"유통비율":"전체 주식 중 시장에서 실제 사고팔 수 있는 비율. 대주주·자사주처럼 묶인 물량은 빼고 봅니다.",'
            '"52주 최고/최저":"최근 1년간 가장 높았던 주가와 낮았던 주가입니다.",'
            '"상장주식수":"시장에 나와 있는 전체 주식 수입니다.",'
            '"외국인보유":"전체 주식 중 외국인이 가진 비율입니다.",'
            '"매출증가율":"직전 연도 대비 매출액이 얼마나 늘었는지(전년대비). 출처 FnGuide.",'
            '"영업이익증가율":"직전 연도 대비 영업이익 증가율(전년대비). 출처 FnGuide.",'
            '"EPS증가율":"직전 연도 대비 주당순이익 증가율(전년대비). 출처 FnGuide.",'
            '"목표주가":"증권사들이 제시한 목표주가의 컨센서스(평균). 출처 FnGuide.",'
            '"투자의견":"증권사 컨센서스 투자의견. 1(매도)~5(적극매수) 척도. 출처 FnGuide.",'
            '"추정PER":"증권사 추정(미래) 이익 기준 PER. 괄호 안 현재 PER보다 낮으면 앞으로 이익이 늘 거란 기대예요. 출처 FnGuide.",'
            '"목표가 리비전":"최근 3개월간 목표주가 컨센서스가 상향(+)됐는지 하향(−)됐는지. 출처 WISEreport.",'
            '"추정치 리비전":"최근 3개월간 추정 실적치 컨센서스 변화율. 상향이면 이익 전망이 좋아지는 중. 출처 WISEreport.",'
            '"어닝 서프라이즈":"최신 확정연도 실적이 직전 컨센서스 대비 얼마나 상회(+)/하회(−)했는지. 출처 WISEreport."'
        '};'
        'function openInfoDesc(term){'
            'var m=document.getElementById("info-desc-modal");if(!m)return;'
            'var t=m.querySelector("[data-desc-term]");if(t)t.textContent=term;'
            'var b=m.querySelector("[data-desc-body]");if(b)b.textContent=(INFO_DESC[term]||"설명이 없습니다.");'
            'm.classList.remove("hidden");m.setAttribute("aria-hidden","false");'
            'lockScroll();'
        '}'
        'function infoWon(n){return fmtInt(n)+"원";}'
        # 키움이 억원 단위로 주는 금액 → 1조=10,000억 환산. 1조 미만(음수 포함)은 억원, 1조 이상은 조.
        'function capDisp(eok){var v=Number(eok);if(isNaN(v))return "—";return Math.abs(v)>=10000?(fmtNum(v/10000,2)+"조원"):(fmtInt(v)+"억원");}'
        # 증가율(%) — 빨강=증가/파랑=감소 (국내 관행). null이면 dash.
        'function growthDisp(v){if(v==null||v==="")return "—";var n=Number(v);if(isNaN(n))return "—";var col=n>0?"#ef4444":(n<0?"#3b82f6":"#9ca3af");return \'<span style="color:\'+col+\';font-weight:700">\'+(n>0?"+":"")+n.toFixed(1)+"%</span>";}'
        # 비교 지표 스펙. cmpOnly=true 는 비교표에서만(단일 상세 제외). fn(j)→표시문자열.
        'var INFO_SPECS=['
            '{k:"현재가",d:"현재가",sec:"가치지표",cmpOnly:true,fn:function(j){return infoWon(j.price);}},'
            '{k:"시가총액",d:"시가총액",sec:"가치지표",cmpOnly:true,fn:function(j){return capDisp(j.market_cap);}},'
            '{k:"PER",d:"PER",sec:"가치지표",fn:function(j){return fmtNum(j.per,2);}},'
            '{k:"PBR",d:"PBR",sec:"가치지표",fn:function(j){return fmtNum(j.pbr,2);}},'
            '{k:"EPS",d:"EPS",sec:"가치지표",fn:function(j){return infoWon(j.eps);}},'
            '{k:"ROE",d:"ROE",sec:"가치지표",fn:function(j){return fmtNum(j.roe,2)+"%";}},'
            '{k:"영업이익",d:"영업이익",sec:"가치지표",fn:function(j){'
                'if(j.op_profit_q!=null)return capDisp(j.op_profit_q)+(j.op_profit_q_period?\' <span class="muted small">(\'+escHtml(j.op_profit_q_period)+\')</span>\':"");'
                'return capDisp(j.op_profit)+\' <span class="muted small">(연간)</span>\';'
            '}},'
            '{k:"매출증가율",d:"매출증가율",sec:"성장성·컨센서스",fn:function(j){return growthDisp(j.fundamentals&&j.fundamentals.growth?j.fundamentals.growth.sales_yoy:null);}},'
            '{k:"영업이익증가율",d:"영업이익증가율",sec:"성장성·컨센서스",fn:function(j){return growthDisp(j.fundamentals&&j.fundamentals.growth?j.fundamentals.growth.oper_profit_yoy:null);}},'
            '{k:"EPS증가율",d:"EPS증가율",sec:"성장성·컨센서스",fn:function(j){return growthDisp(j.fundamentals&&j.fundamentals.growth?j.fundamentals.growth.eps_yoy:null);}},'
            '{k:"목표주가",d:"목표주가",sec:"성장성·컨센서스",fn:function(j){var c=j.fundamentals&&j.fundamentals.consensus;return (c&&c.target_price!=null)?infoWon(c.target_price):"—";}},'
            '{k:"투자의견",d:"투자의견",sec:"성장성·컨센서스",fn:function(j){var c=j.fundamentals&&j.fundamentals.consensus;return (c&&c.opinion_label)?(escHtml(c.opinion_label)+(c.opinion!=null?\' <span class="muted small">(\'+c.opinion+\'/5)</span>\':"")):"—";}},'
            '{k:"추정PER",d:"추정PER",sec:"성장성·컨센서스",fn:function(j){var c=j.fundamentals&&j.fundamentals.consensus;if(!(c&&c.per!=null))return "—";var pv=Number(j.per);var cur=(j.per!=null&&j.per!==""&&!isNaN(pv))?(\' <span style="color:#7a8493;font-size:11px">(현재 \'+fmtNum(j.per,2)+\'배)</span>\'):"";return fmtNum(c.per,2)+"배"+cur;}},'
            '{k:"목표가 리비전(3M)",d:"목표가 리비전",sec:"성장성·컨센서스",fn:function(j){return growthDisp(j.consensus?j.consensus.target_change_pct:null);}},'
            '{k:"추정치 리비전(3M)",d:"추정치 리비전",sec:"성장성·컨센서스",fn:function(j){return growthDisp(j.consensus?j.consensus.est_change_pct:null);}},'
            '{k:"매출 서프라이즈",d:"어닝 서프라이즈",sec:"성장성·컨센서스",fn:function(j){return growthDisp(j.consensus?j.consensus.surprise_sales:null);}},'
            '{k:"영익 서프라이즈",d:"어닝 서프라이즈",sec:"성장성·컨센서스",fn:function(j){return growthDisp(j.consensus?j.consensus.surprise_op:null);}},'
            '{k:"상장주식수",d:"상장주식수",sec:"기본정보",fn:function(j){return fmtInt(j.listed_shares)+"주";}},'
            '{k:"유통비율",d:"유통비율",sec:"기본정보",fn:function(j){return fmtNum(j.dist_rate,2)+"%";}},'
            '{k:"외국인보유",d:"외국인보유",sec:"기본정보",fn:function(j){return fmtNum(j.foreign_holding_pct,2)+"%";}},'
            '{k:"52주 최고",d:"52주 최고/최저",sec:"기본정보",fn:function(j){return infoWon(j.high_52w)+(j.high_52w_dt?\' <span class="muted small">(\'+escHtml(j.high_52w_dt)+\')</span>\':"");}},'
            '{k:"52주 최저",d:"52주 최고/최저",sec:"기본정보",fn:function(j){return infoWon(j.low_52w)+(j.low_52w_dt?\' <span class="muted small">(\'+escHtml(j.low_52w_dt)+\')</span>\':"");}}'
        '];'
        'function infoQbtn(d){return d?(\' <button type="button" class="info-q" data-info-desc="\'+escHtml(d)+\'" aria-label="\'+escHtml(d)+\' 설명">?</button>\'):"";}'
        # CMP: 비교 상태. items=[{code,name}] (index 0 = 기준), data=종목별 stock_info 캐시.
        'var CMP={items:[],data:{},symbolsLoaded:false};'
        'function infoBodyEl(){var m=document.getElementById("info-modal");return m?m.querySelector("[data-info-body]"):null;}'
        # 단일 상세 — 기존 세로 레이아웃 (현재가·시총 제외).
        # 최근 증권사 리포트 블록 (단일 종목 뷰 전용). 목표가 상향=▲빨강/하향=▼파랑.
        'function infoReportsHtml(j){'
            'var rs=j.reports;if(!rs||!rs.length)return "";'
            'var h=\'<div class="info-sec-title">📰 최근 증권사 리포트 <span style="color:#5b6473;font-weight:normal;font-size:11px">(목표가·요약, 출처 WISEreport)</span></div>\';'
            'rs.forEach(function(r){'
                'var arrow="";'
                'if(r.target_action&&r.target_action.indexOf("상향")>=0)arrow=\' <span style="color:#ef4444">▲</span>\';'
                'else if(r.target_action&&r.target_action.indexOf("하향")>=0)arrow=\' <span style="color:#3b82f6">▼</span>\';'
                'var tgt=r.target?(\'<span style="color:#fbbf24;font-weight:600">목표 \'+escHtml(r.target)+arrow+\'</span>\'):"";'
                'var rec=r.recomm?(\'<span style="color:#9aa4b2">\'+escHtml(r.recomm)+\'</span>\'):"";'
                'var anl=r.analyst?(\'<span style="color:#5b6473;font-size:11px">\'+escHtml(r.analyst)+\'</span>\'):"";'
                'var sum="";if(r.summary&&r.summary.length){sum=\'<div style="font-size:11px;color:#7a8493;line-height:1.5;margin-top:3px">▶ \'+r.summary.slice(0,2).map(escHtml).join("<br>▶ ")+\'</div>\';}'
                'h+=\'<div style="padding:9px 0;border-bottom:1px solid #1f2937">\'+'
                   '\'<div style="display:flex;gap:7px;align-items:center;flex-wrap:wrap;font-size:12px"><span style="color:#7a8493">\'+escHtml(r.date||"")+\'</span><b style="color:#cfd5df">\'+escHtml(r.broker||"")+\'</b>\'+tgt+rec+anl+\'</div>\'+'
                   '\'<div style="font-size:13px;color:#fff;margin:3px 0 0">\'+escHtml(r.title||"")+\'</div>\'+sum+\'</div>\';'
            '});'
            'return h;'
        '}'
        'function renderSingleInfo(b,j){'
            'function row(k,v,d){return \'<dt>\'+escHtml(k)+infoQbtn(d)+\'</dt><dd>\'+v+\'</dd>\';}'
            # 섹션별 info-grid 생성 (해당 sec에 항목 없으면 빈 문자열)
            'function secGrid(sec){var g=\'<dl class="info-grid">\',n=0;INFO_SPECS.forEach(function(s){if(s.sec===sec&&!s.cmpOnly){g+=row(s.k,s.fn(j),s.d);n++;}});return n?(g+\'</dl>\'):"";}'
            'function titled(t,inner){return inner?(\'<div class="info-sec-title">\'+t+\'</div>\'+inner):"";}'
            # 요약 탭 — 현재가·목표주가·핵심 시그널·최신 리포트 1건
            'function sumPanel(){'
                'var fg=j.fundamentals||{},gr=fg.growth||{},fc=fg.consensus||{},cn=j.consensus||{};'
                'var chg=(j.change>0?"#ef4444":(j.change<0?"#3b82f6":"#9ca3af"));'
                'var pc=(j.change_pct!=null?(\' <span style="color:\'+chg+\';font-size:12px">(\'+(j.change_pct>0?"+":"")+fmtNum(j.change_pct,2)+\'%)</span>\'):"");'
                # 가치 + 성장·컨센서스 종합 해석 박스 (요약탭 최상단).
                'var _cg=companyGC(j),_gg=growthGC(j);'
                'var h=judgeBox(synthNote(j),_cg.good.concat(_gg.good),_cg.caution.concat(_gg.caution));'
                'h+=\'<dl class="info-grid">\';'
                'h+=row("현재가",\'<span style="color:\'+chg+\';font-weight:700">\'+infoWon(j.price)+\'</span>\'+pc,"현재가");'
                'h+=row("시가총액",capDisp(j.market_cap),"시가총액");'
                'if(fc.target_price!=null)h+=row("목표주가",\'<span style="color:#fbbf24;font-weight:700">\'+infoWon(fc.target_price)+\'</span>\'+(fc.opinion_label?\' <span style="color:#9aa4b2;font-size:12px">\'+escHtml(fc.opinion_label)+\'</span>\':""),"목표주가");'
                'if(cn.target_change_pct!=null)h+=row("목표가 리비전(3M)",growthDisp(cn.target_change_pct),"목표가 리비전");'
                'if(gr.sales_yoy!=null)h+=row("매출증가율",growthDisp(gr.sales_yoy),"매출증가율");'
                'if(gr.eps_yoy!=null)h+=row("EPS증가율",growthDisp(gr.eps_yoy),"EPS증가율");'
                'if(cn.surprise_sales!=null)h+=row("매출 서프라이즈",growthDisp(cn.surprise_sales),"어닝 서프라이즈");'
                'h+=\'</dl>\';'
                'if(j.reports&&j.reports.length){var r=j.reports[0];var ar="";if(r.target_action&&r.target_action.indexOf("상향")>=0)ar=\' <span style="color:#ef4444">▲</span>\';else if(r.target_action&&r.target_action.indexOf("하향")>=0)ar=\' <span style="color:#3b82f6">▼</span>\';h+=\'<div class="info-sec-title">최신 리포트</div><div style="font-size:12px;color:#9aa4b2"><span style="color:#7a8493">\'+escHtml(r.date||"")+\' \'+escHtml(r.broker||"")+\'</span> \'+(r.target?\'<span style="color:#fbbf24">목표 \'+escHtml(r.target)+ar+\'</span>\':"")+\'</div><div style="font-size:13px;color:#fff;margin-top:2px">\'+escHtml(r.title||"")+\'</div>\';}'
                'return h;'
            '}'
            # 탭 상단 해석 박스 — 지표를 ✅좋은 점 / ⚠️주의할 점으로 판단(규칙 기반). 특이점 없으면 미표시.
            'function _gnum(v){var n=Number(v);return (v==null||v===""||isNaN(n))?null:n;}'
            'function judgeBox(synth,good,caution){'
                'if(!synth&&!good.length&&!caution.length)return "";'
                'var h="";'
                'if(synth)h+=\'<div class="info-jd info-jd-s"><b>📌 종합</b><div class="info-jd-s-b">\'+synth+\'</div></div>\';'
                'if(good.length)h+=\'<div class="info-jd info-jd-g"><b>✅ 좋은 점</b><ul>\'+good.map(function(x){return "<li>"+x+"</li>";}).join("")+\'</ul></div>\';'
                'if(caution.length)h+=\'<div class="info-jd info-jd-c"><b>⚠️ 주의할 점</b><ul>\'+caution.map(function(x){return "<li>"+x+"</li>";}).join("")+\'</ul></div>\';'
                'return \'<div class="info-summary">\'+h+\'</div>\';'
            '}'
            # 가치/성장 지표를 {good,caution} 으로 분류 (요약탭에서 합쳐 표시).
            'function companyGC(j){'
                'var good=[],caution=[],per=_gnum(j.per),pbr=_gnum(j.pbr),roe=_gnum(j.roe);'
                'var fwd=_gnum((j.fundamentals&&j.fundamentals.consensus)?j.fundamentals.consensus.per:null);'
                'var estRoe=_gnum((j.consensus&&j.consensus.this_year)?j.consensus.this_year.roe:null);'
                # ROE: trailing 낮아도 추정ROE 급증 전망이면 그쪽을 우선 표시 (PER/추정PER과 동일 패턴).
                'if(roe!=null){'
                    'if(roe<0)caution.push("ROE "+roe.toFixed(1)+"% — 자기자본이 손실(수익성 부진)");'
                    'else if(roe>=15)good.push("ROE "+roe.toFixed(1)+"% — 자본 수익성 우수");'
                    'else if(estRoe!=null&&estRoe>=15&&estRoe>=roe*1.5)good.push("추정ROE "+estRoe.toFixed(1)+"% — 올해 수익성 급증 전망(현재 "+roe.toFixed(1)+"%)");'
                    'else if(roe<8)caution.push("ROE "+roe.toFixed(1)+"% — 자본 수익성 다소 낮음");'
                '}'
                # PER: trailing 높아도 추정(forward) PER이 크게 낮으면 이익 급증 기대 반영 → 고평가로 단정 X, 다만 추정치 미달 위험 경고.
                'if(per!=null){'
                    'if(per<=0)caution.push("PER 산정 불가 — 적자 또는 이익 미미");'
                    'else if(per>=30){'
                        'if(fwd!=null&&fwd>0&&fwd<per*0.6&&fwd<20){good.push("추정PER "+fwd.toFixed(1)+"배 — 이익 급증 기대로 forward 밸류는 낮음");caution.push("현재 PER "+per.toFixed(1)+"배 — 추정이익 실현 전이라 단기 밸류 부담(성장 미달 시 조정 위험)");}'
                        'else caution.push("PER "+per.toFixed(1)+"배 — 기대가 선반영된 고평가 부담");'
                    '}'
                    'else if(per<=10)good.push("PER "+per.toFixed(1)+"배 — 밸류에이션 부담이 작음");'
                '}'
                # PBR: 높아도 추정ROE가 충분히 높으면 정당화 가능 → 단정 대신 실현 여부 관건으로 표현.
                'if(pbr!=null){'
                    'if(pbr>=3){'
                        'if(estRoe!=null&&estRoe>=20)caution.push("PBR "+pbr.toFixed(1)+"배 — 자산가치 대비 높지만, 추정ROE "+estRoe.toFixed(1)+"% 실현 여부가 관건");'
                        'else caution.push("PBR "+pbr.toFixed(1)+"배 — 자산가치 대비 비싼 편");'
                    '}'
                    'else if(pbr>0&&pbr<1)good.push("PBR "+pbr.toFixed(1)+"배 — 자산가치(청산가치) 이하");'
                '}'
                'return {good:good,caution:caution};'
            '}'
            'function growthGC(j){'
                'var gr=(j.fundamentals&&j.fundamentals.growth)||{},fc=(j.fundamentals&&j.fundamentals.consensus)||{},cn=j.consensus||{},good=[],caution=[];'
                'var sy=_gnum(gr.sales_yoy),oy=_gnum(gr.oper_profit_yoy),ey=_gnum(gr.eps_yoy);'
                'if(oy!=null){if(oy>=20)good.push("영업이익 +"+oy.toFixed(0)+"% — 이익 고성장");else if(oy<0)caution.push("영업이익 "+oy.toFixed(0)+"% — 이익 역성장");}'
                'if(sy!=null){if(sy<0)caution.push("매출 "+sy.toFixed(0)+"% — 외형 역성장");else if(sy>=15)good.push("매출 +"+sy.toFixed(0)+"% — 외형 성장 견조");}'
                'if(ey!=null&&ey<0)caution.push("EPS "+ey.toFixed(0)+"% — 주당순이익 감소");'
                'var p=_gnum(j.price),tp=_gnum(fc.target_price);'
                'if(p&&tp){var up=(tp/p-1)*100;if(up>=15)good.push("목표주가까지 상승여력 +"+up.toFixed(0)+"%");else if(up<0)caution.push("현재가가 목표주가를 "+(-up).toFixed(0)+"% 웃돔 — 추가 상승여력 제한");}'
                'if(fc.opinion_label){if(fc.opinion_label.indexOf("매수")>=0)good.push("증권사 투자의견 "+escHtml(fc.opinion_label));else if(fc.opinion_label.indexOf("매도")>=0||fc.opinion_label.indexOf("중립")>=0||fc.opinion_label.indexOf("보유")>=0)caution.push("투자의견 "+escHtml(fc.opinion_label)+" — 적극 매수는 아님");}'
                'var tr=_gnum(cn.target_change_pct);if(tr!=null){if(tr>0)good.push("최근 3개월 목표가 상향(+"+tr.toFixed(1)+"%) — 전망 개선");else if(tr<0)caution.push("최근 3개월 목표가 하향("+tr.toFixed(1)+"%) — 전망 악화");}'
                # 추정EPS 리비전 — 목표가 리비전보다 직접적인 이익 전망 신호.
                'var er=_gnum(cn.est_change_pct);var ri=cn.rev_item||"EPS";'
                'if(er!=null){if(er>0)good.push("최근 추정"+escHtml(ri)+" 상향(+"+er.toFixed(1)+"%) — 이익 전망 개선");else if(er<0)caution.push("최근 추정"+escHtml(ri)+" 하향("+er.toFixed(1)+"%) — 이익 전망 악화");}'
                'var so=_gnum(cn.surprise_op);if(so!=null){if(so>0)good.push("최근 영업이익 어닝 서프라이즈(+"+so.toFixed(1)+"%)");else if(so<0)caution.push("최근 영업이익이 컨센서스 하회("+so.toFixed(1)+"%)");}'
                'return {good:good,caution:caution};'
            '}'
            # 가치(고평가 등) vs 성장·컨센서스(긍정 등) 상충을 한 문장으로 종합.
            'function synthNote(j){'
                'var per=_gnum(j.per),pbr=_gnum(j.pbr);'
                'var gr=(j.fundamentals&&j.fundamentals.growth)||{},fc=(j.fundamentals&&j.fundamentals.consensus)||{},cn=j.consensus||{};'
                'var oy=_gnum(gr.oper_profit_yoy),sy=_gnum(gr.sales_yoy),tr=_gnum(cn.target_change_pct);'
                'var p=_gnum(j.price),tp=_gnum(fc.target_price),up=(p&&tp)?(tp/p-1)*100:null;'
                'var fwd=_gnum(fc.per);var er=_gnum(cn.est_change_pct);'
                'var estRoe=_gnum((cn&&cn.this_year)?cn.this_year.roe:null);'
                'var growthPriced=(per!=null&&per>=30&&fwd!=null&&fwd>0&&fwd<per*0.6&&fwd<20);'
                # 추정ROE가 충분히 높으면 높은 PBR을 고평가로 단정하지 않음 (companyGC와 동일 기준).
                'var pbrJustified=(pbr!=null&&pbr>=3&&estRoe!=null&&estRoe>=20);'
                'var valHigh=(per!=null&&per>=30&&!growthPriced)||(pbr!=null&&pbr>=3&&!pbrJustified);'
                'var valCheap=(per!=null&&per>0&&per<=10)||(pbr!=null&&pbr>0&&pbr<1);'
                'var buy=fc.opinion_label&&fc.opinion_label.indexOf("매수")>=0;'
                'var grPos=(oy!=null&&oy>=20)||(up!=null&&up>=15)||buy||(tr!=null&&tr>0)||(er!=null&&er>0);'
                'var grNeg=(oy!=null&&oy<0)||(sy!=null&&sy<0)||(up!=null&&up<0)||(tr!=null&&tr<0)||(er!=null&&er<0);'
                'if(growthPriced)return "현재 PER은 "+per.toFixed(0)+"배로 높지만 추정이익이 급증해 추정(forward) PER은 "+fwd.toFixed(1)+"배로 낮아요. 시장이 가파른 이익 성장을 기대하는 구간으로, 성장이 실현되면 밸류가 정당화되지만 추정치에 못 미치면 조정 위험이 큽니다.";'
                'if(valHigh&&grPos)return "성장·실적 기대는 좋지만 현재 밸류에이션이 높아 그 기대가 주가에 이미 상당 부분 반영돼 있어요. 성장이 기대만큼 이어지는지가 관건입니다.";'
                'if(valCheap&&grNeg)return "밸류에이션은 저평가 구간이나 성장·전망이 부진해요. 싸다는 이유만으로 접근하기보다 실적 반등 신호를 함께 봐야 합니다.";'
                'if(valHigh&&grNeg)return "밸류에이션이 높은데 성장·전망도 약한 부담스러운 조합이에요. 신중히 접근하세요.";'
                'if(valCheap&&grPos)return "저평가 구간에 성장·전망까지 양호한 긍정적 조합이에요.";'
                'if(grPos)return "밸류 부담은 크지 않은 수준에서 성장·전망이 양호한 편이에요.";'
                'if(grNeg)return "성장·전망이 다소 부진한 편이에요.";'
                'return "";'
            '}'
            'var companyInner=titled("가치지표",secGrid("가치지표"))+titled("기본정보",secGrid("기본정보"));'
            'var growthInner=secGrid("성장성·컨센서스");'
            'var repInner=infoReportsHtml(j);'
            'var TB=[["summary","요약",sumPanel()],["company","기업정보·가치",companyInner],["growth","성장성·컨센서스",growthInner],["reports","투자리포트",repInner]];'
            'var bar=\'<div class="info-tabs" role="tablist">\';var pans="";'
            'TB.forEach(function(t,i){'
                'bar+=\'<button type="button" class="info-tabbtn\'+(i===0?" active":"")+\'" data-info-tab="\'+t[0]+\'">\'+t[1]+\'</button>\';'
                'pans+=\'<div class="info-tabpanel" data-info-panel="\'+t[0]+\'"\'+(i===0?"":" hidden")+\'>\'+(t[2]||\'<div class="muted small" style="padding:12px 0;color:#7a8493">표시할 데이터가 없어요.</div>\')+\'</div>\';'
            '});'
            'if(b)b.innerHTML=bar+\'</div>\'+pans;'
        '}'
        # 모달 탭 전환 (data-info-tab/panel) — info-body 안으로 스코프, 자산탭 sub-tab과 독립.
        'document.addEventListener("click",function(e){'
            'var bt=e.target.closest&&e.target.closest("[data-info-tab]");if(!bt)return;'
            'var wrap=bt.closest("[data-info-body]");if(!wrap)return;'
            'var key=bt.getAttribute("data-info-tab");'
            'wrap.querySelectorAll("[data-info-tab]").forEach(function(x){x.classList.toggle("active",x===bt);});'
            'wrap.querySelectorAll("[data-info-panel]").forEach(function(p){p.hidden=(p.getAttribute("data-info-panel")!==key);});'
        '});'
        # 비교표 — 행=지표(현재가·시총 포함), 열=종목. 첫 열(기준) 외엔 × 제거 버튼.
        'function renderCompareInfo(b){'
            'var its=CMP.items;'
            'var head=\'<th class="info-cmp-label"></th>\';'
            'its.forEach(function(it,idx){'
                'var rm=idx>0?(\'<button type="button" class="info-cmp-remove" data-cmp-remove="\'+escHtml(it.code)+\'" aria-label="비교 제거">×</button>\'):"";'
                'head+=\'<th class="info-cmp-col"><span class="info-cmp-name">\'+escHtml(it.name||it.code)+\'</span>\'+rm+\'</th>\';'
            '});'
            'var body="";'
            'INFO_SPECS.forEach(function(s){'
                'var cells=its.map(function(it){var j=CMP.data[it.code];return \'<td>\'+((j&&!j.error)?s.fn(j):"—")+\'</td>\';}).join("");'
                'body+=\'<tr><th class="info-cmp-label">\'+escHtml(s.k)+infoQbtn(s.d)+\'</th>\'+cells+\'</tr>\';'
            '});'
            'if(b)b.innerHTML=\'<div class="info-cmp-scroll"><table class="info-cmp-table"><thead><tr>\'+head+\'</tr></thead><tbody>\'+body+\'</tbody></table></div>\';'
        '}'
        'function infoRenderBody(){'
            'var b=infoBodyEl();if(!b)return;'
            'var missing=CMP.items.filter(function(it){return !CMP.data[it.code];});'
            # 이미 표/그리드가 그려져 있으면 유지(비교 열 추가 시 깜빡임 방지), 빈 상태에서만 로딩 표시.
            'if(missing.length&&!b.querySelector("table")&&!b.querySelector(".info-grid"))b.innerHTML=\'<div class="muted small info-loading">불러오는 중…</div>\';'
            'Promise.all(missing.map(function(it){'
                'return fetch("/api/stock_info?code="+encodeURIComponent(it.code),{credentials:"same-origin"})'
                    '.then(function(r){return r.json();})'
                    '.then(function(j){CMP.data[it.code]=(j||{error:"no data"});if(j&&j.name&&!it.name)it.name=j.name;})'
                    '.catch(function(){CMP.data[it.code]={error:"net"};});'
            '})).then(function(){'
                'var b2=infoBodyEl();if(!b2)return;'
                'if(CMP.items.length<=1){'
                    'var j0=CMP.data[CMP.items[0].code];'
                    'if(!j0||j0.error){b2.innerHTML=\'<div class="muted small">불러오기 실패\'+((j0&&j0.error&&j0.error!=="net")?(": "+escHtml(j0.error)):"")+\'</div>\';return;}'
                    'renderSingleInfo(b2,j0);'
                '}else{renderCompareInfo(b2);}'
            '});'
        '}'
        'function addInfoCompare(code,name){'
            'code=(code||"").trim();if(!code)return;'
            'if(CMP.items.some(function(it){return it.code===code;})){'
                'var rb=document.querySelector("#info-modal [data-info-search-results]");'
                'if(rb){rb.classList.remove("hidden");rb.innerHTML=\'<div class="muted small">이미 비교 중인 종목입니다.</div>\';}'
                'return;'
            '}'
            'CMP.items.push({code:code,name:name||""});'
            'infoRenderBody();'
        '}'
        'function removeInfoCompare(code){'
            'CMP.items=CMP.items.filter(function(it){return it.code!==code;});'
            'infoRenderBody();'
        '}'
        # 내 목록(보유·관심·감시) 드롭다운 1회 로드.
        'function loadInfoSymbols(){'
            'if(CMP.symbolsLoaded)return;'
            'var sel=document.querySelector("#info-modal [data-info-symbols]");if(!sel)return;'
            'fetch("/api/symbols/all",{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(d){'
                'var items=(d&&d.items)||[];if(!items.length)return;'
                'var seen={};items.forEach(function(it){'
                    'if(!it.code||seen[it.code])return;seen[it.code]=1;'
                    'var o=document.createElement("option");o.value=it.code;'
                    'o.textContent=(it.name||it.code)+(it.source?(" · "+it.source):"");'
                    'o.setAttribute("data-name",it.name||"");sel.appendChild(o);'
                '});'
                'CMP.symbolsLoaded=true;'
            '}).catch(function(){});'
        '}'
        # 종목 검색 — 단일이면 바로 추가, 다중이면 후보 버튼 표시.
        'function doInfoSearch(){'
            'var m=document.getElementById("info-modal");if(!m)return;'
            'var inp=m.querySelector("[data-info-search]");var rb=m.querySelector("[data-info-search-results]");'
            'var q=inp?inp.value.trim():"";if(!q){return;}'
            'if(rb){rb.classList.remove("hidden");rb.innerHTML=\'<div class="muted small">검색 중…</div>\';}'
            'fetch("/api/stock_search?q="+encodeURIComponent(q),{credentials:"same-origin"}).then(function(r){return r.json();}).then(function(d){'
                'var res=(d&&d.results)||[];'
                'if(!res.length){if(rb)rb.innerHTML=\'<div class="muted small">\'+escHtml((d&&d.error)||"검색 결과 없음")+\'</div>\';return;}'
                'if(res.length===1){addInfoCompare(res[0].code,res[0].name);if(rb){rb.classList.add("hidden");rb.innerHTML="";}if(inp)inp.value="";return;}'
                'if(rb)rb.innerHTML=res.map(function(it){return \'<button type="button" class="info-search-hit" data-cmp-add="\'+escHtml(it.code)+\'" data-cmp-name="\'+escHtml(it.name||"")+\'">\'+escHtml(it.name||it.code)+\' <span class="muted small">\'+escHtml(it.code)+\'</span></button>\';}).join("");'
            '}).catch(function(){if(rb)rb.innerHTML=\'<div class="muted small">검색 오류</div>\';});'
        '}'
        'function openInfoModal(code,name){'
            'var m=document.getElementById("info-modal");if(!m)return;'
            'var t=m.querySelector("[data-info-title]");'
            'if(t)t.textContent=(name||"기업정보")+(code?(" ("+code+")"):"");'
            'var al=m.querySelector("[data-info-analyze]");'
            'if(al){if(code){al.href="/stock/"+encodeURIComponent(code);al.style.display="";}else{al.style.display="none";}}'
            # 상태 초기화 — 기준 종목 1개로 시작.
            'CMP.items=[{code:code,name:name||""}];CMP.data={};'
            'var inp=m.querySelector("[data-info-search]");if(inp)inp.value="";'
            'var rb=m.querySelector("[data-info-search-results]");if(rb){rb.classList.add("hidden");rb.innerHTML="";}'
            'var sel=m.querySelector("[data-info-symbols]");if(sel)sel.value="";'
            'var b=m.querySelector("[data-info-body]");'
            'if(b)b.innerHTML=\'<div class="muted small info-loading">불러오는 중…</div>\';'
            'm.classList.remove("hidden");m.setAttribute("aria-hidden","false");'
            'lockScroll();'
            'loadInfoSymbols();'
            'infoRenderBody();'
        '}'
        # KPI 상세 공용 팝업 — 서버가 본문 HTML을 만들어 주므로 여기선 innerHTML만.
        'function openKpiModal(src,title){'
            'var m=document.getElementById("kpi-modal");if(!m||!src)return;'
            'var t=m.querySelector("[data-kpi-title]");'
            'if(t)t.textContent=title||"상세";'
            'var b=m.querySelector("[data-kpi-body]");'
            'if(b)b.innerHTML=\'<div class="muted small">불러오는 중…</div>\';'
            'm.classList.remove("hidden");m.setAttribute("aria-hidden","false");'
            'lockScroll();'
            'fetch(src,{credentials:"same-origin"})'
                '.then(function(r){return r.json();})'
                '.then(function(j){'
                    'if(!b)return;'
                    'if(!j||j.error||!j.html){b.innerHTML=\'<div class="muted small">불러오기 실패</div>\';return;}'
                    'b.innerHTML=j.html;'
                '})'
                '.catch(function(){if(b)b.innerHTML=\'<div class="muted small">네트워크 오류</div>\';});'
        '}'
        'function openTradeModal(code,name,owner){'
            'var m=document.getElementById("trade-modal");if(!m)return;'
            'var ownerMode=!code && !!owner;'
            'var t=m.querySelector("[data-trade-title]");'
            'if(t){t.textContent=ownerMode?(name+" 전체 거래내역"):(name+(code?(" ("+code+")"):""));}'
            'var b=m.querySelector("[data-trade-body]");if(b)b.innerHTML=\'<div class="muted small">불러오는 중…</div>\';'
            'var s=m.querySelector("[data-trade-summary]");if(s)s.textContent="";'
            'm.classList.remove("hidden");m.setAttribute("aria-hidden","false");'
            'lockScroll();'
            'var qs=ownerMode?("owner="+encodeURIComponent(owner)):("code="+encodeURIComponent(code));'
            'fetch("/api/trades?"+qs,{credentials:"same-origin"})'
                '.then(function(r){return r.json();})'
                '.then(function(j){'
                    'if(!j||j.error){if(b)b.innerHTML=\'<div class="muted small">불러오기 실패</div>\';return;}'
                    'if(!j.rows||!j.rows.length){if(b)b.innerHTML=\'<div class="muted small">기록이 없습니다.</div>\';return;}'
                    'var showStock=(j.mode==="owner");'
                    'function fmtAcct(a){'
                        'if(!showStock)return escHtml(a||"");'
                        'var s=String(a||"");'
                        'if(s.indexOf("가희_")===0)s=s.slice(3);'
                        'return escHtml(s);'
                    '}'
                    'function metaOf(r){'
                        'var d=escHtml(fmtTradeDate(r.date));'
                        'var acc=fmtAcct(r.account);'
                        'return \'<span class="ti-date">\'+d+\'</span><span class="ti-sep">·</span><span class="ti-acct">\'+acc+\'</span>\';'
                    '}'
                    'function stockLineOf(r){'
                        'if(!showStock)return "";'
                        'var nm=r.name||"";'
                        'return \'<div class="ti-stock-line trade-stock-col" title="\'+escHtml(nm)+\'">\'+escHtml(nm)+\'</div>\';'
                    '}'
                    'function renderRow(r){'
                        'var seedCls=r.seed?" seed":"";'
                        'var meta=metaOf(r);'
                        'var stockLine=stockLineOf(r);'
                        'var out="";'
                        'if(r.buy_qty){'
                            'out+=\'<li class="trade-item trade-item-buy\'+seedCls+\'">\'+stockLine+'
                                '\'<div class="ti-row1"><div class="ti-meta">\'+'
                                    '\'<span class="ti-side side-buy">매수</span>\'+'
                                    '\'<span class="ti-sep">·</span>\'+meta+\'</div>\'+'
                                    '\'<span class="ti-pl muted">-</span></div>\'+'
                                '\'<div class="ti-row2"><div class="ti-data">\'+'
                                    '\'<span class="ti-price"><span class="ti-label">매수가</span> \'+fmtInt(r.buy_avg)+\'원</span><span class="ti-sep">·</span>\'+'
                                    '\'<span class="ti-qty"><span class="ti-label">수량</span> \'+fmtInt(r.buy_qty)+\'주</span><span class="ti-sep">·</span>\'+'
                                    '\'<span class="ti-amt"><span class="ti-label">금액</span> \'+fmtInt(r.buy_amt)+\'원</span></div></div>\'+'
                                '\'</li>\';'
                        '}'
                        'if(r.sell_qty){'
                            'var pl=r.pl_amt||0;'
                            'var plCls=pl>0?"pl-pos":(pl<0?"pl-neg":"muted");'
                            'var sgn=pl>0?"+":"";'
                            'var costAvg=r._pl_rebased?r.prev_avg:Math.round(r.sell_avg-(pl+(r.cmsn_tax||0))/r.sell_qty);'
                            'var costPct=costAvg>0?(r.sell_avg-costAvg)/costAvg*100:0;'
                            'var costCol=costPct>0?"#ff4d5e":(costPct<0?"#2c7be5":"#6c7180");'
                            'var costSgn=costPct>0?"+":"";'
                            'var reNote=r._pl_rebased?\'<div class="muted small" style="margin-top:2px;font-size:11px">원가 \'+fmtInt(r.prev_avg)+\'원(전일 보유 평단) 기준 · 당일 매수분 제외</div>\':"";'
                            'out+=\'<li class="trade-item trade-item-sell\'+seedCls+\'">\'+stockLine+'
                                '\'<div class="ti-row1"><div class="ti-meta">\'+'
                                    '\'<span class="ti-side side-sell">매도</span>\'+'
                                    '\'<span class="ti-sep">·</span>\'+meta+\'</div>\'+'
                                    '\'<span class="ti-pl \'+plCls+\'">\'+sgn+fmtInt(pl)+\'원</span></div>\'+'
                                '\'<div class="ti-row2"><div class="ti-data">\'+'
                                    '\'<span class="ti-price"><span class="ti-label">매도가</span> \'+fmtInt(r.sell_avg)+\'원</span><span class="ti-sep">·</span>\'+'
                                    '\'<span class="ti-qty"><span class="ti-label">수량</span> \'+fmtInt(r.sell_qty)+\'주</span><span class="ti-sep">·</span>\'+'
                                    '\'<span class="ti-amt"><span class="ti-label">금액</span> \'+fmtInt(r.sell_amt)+\'원</span></div></div>\'+'
                                '\'<div class="ti-row2"><div class="ti-data">\'+'
                                    '\'<span class="ti-price"><span class="ti-label">평단가</span> \'+fmtInt(costAvg)+\'원</span><span class="ti-sep">·</span>\'+'
                                    '\'<span class="ti-price" style="color:\'+costCol+\'">\'+costSgn+costPct.toFixed(2)+\'%</span></div></div>\'+reNote+'
                                '\'</li>\';'
                        '}'
                        'return out;'
                    '}'
                    'function renderTable(rs){'
                        'var list=\'<ul class="trade-list">\'+rs.map(renderRow).join("")+\'</ul>\';'
                        'var pager=\'<div class="trade-pager"><button type="button" class="pg-btn" data-pg="prev" aria-label="이전 페이지">◀</button><span class="pg-indicator">1 / 1</span><button type="button" class="pg-btn" data-pg="next" aria-label="다음 페이지">▶</button></div>\';'
                        'return \'<div class="trade-pager-wrap">\'+list+pager+\'</div>\';'
                    '}'
                    'var groups=Array.isArray(j.groups)?j.groups:[];'
                    # 활성 owner 탭(관리자=본인, 가희) 기준으로 처음 선택된 탭만 결정 — 탭 순서는 서버 응답(본인 먼저) 유지.
                    'var aoKey=window.__behive_active_owner?window.__behive_active_owner():null;'
                    'var prefer=aoKey==="gahee"?"가희":(aoKey==="self"?"본인":null);'
                    'var preferIdx=0;'
                    'if(prefer){'
                        'for(var gi=0;gi<groups.length;gi++){if((groups[gi].owner||"")===prefer){preferIdx=gi;break;}}'
                    '}'
                    'if(b){'
                        'if(groups.length>=2){'
                            'var tabsHtml=\'<div class="trade-tabs" role="tablist">\'+groups.map(function(g,idx){'
                                'var sel=idx===preferIdx?"true":"false";'
                                'return \'<button type="button" class="trade-tab" role="tab" data-trade-owner="\'+escHtml(g.owner||"")+\'" aria-selected="\'+sel+\'">\'+escHtml(g.label||g.owner||"")+\'</button>\';'
                            '}).join("")+\'</div>\';'
                            'var panelsHtml=groups.map(function(g,idx){'
                                'var hidden=idx===preferIdx?"":" hidden";'
                                'var sub=[];'
                                'if(g.total_buy_amt)sub.push("매수 "+fmtInt(g.total_buy_amt)+"원");'
                                'if(g.total_sell_amt)sub.push("매도 "+fmtInt(g.total_sell_amt)+"원");'
                                'if(g.total_pl_amt){var ss=g.total_pl_amt>0?"+":"";sub.push("실현 "+ss+fmtInt(g.total_pl_amt)+"원");}'
                                'var subHtml=sub.length?\'<div class="trade-group-sub muted small">\'+escHtml(sub.join(" · "))+\'</div>\':"";'
                                'return \'<div class="trade-tab-panel" data-trade-panel="\'+escHtml(g.owner||"")+\'"\'+hidden+\'>\'+subHtml+renderTable(g.rows||[])+\'</div>\';'
                            '}).join("");'
                            'b.innerHTML=tabsHtml+panelsHtml;'
                        '}else{'
                            'b.innerHTML=renderTable(groups.length===1?(groups[0].rows||[]):j.rows);'
                        '}'
                        'setupTradePagers(b);'
                    '}'
                    'if(s){'
                        'var parts=[];'
                        'if(j.total_buy_amt)parts.push("총 매수 "+fmtInt(j.total_buy_amt)+"원");'
                        'if(j.total_sell_amt)parts.push("총 매도 "+fmtInt(j.total_sell_amt)+"원");'
                        'if(j.total_pl_amt){var sgn2=j.total_pl_amt>0?"+":"";parts.push("실현손익 "+sgn2+fmtInt(j.total_pl_amt)+"원");}'
                        's.textContent=parts.join(" · ");'
                    '}'
                '})'
                '.catch(function(){if(b)b.innerHTML=\'<div class="muted small">네트워크 오류</div>\';});'
        '}'
        'function populateEditForm(trigger){'
            'var em=document.getElementById("interest-edit-modal");if(!em)return;'
            'var name=trigger.getAttribute("data-edit-stock")||"";'
            'var code=trigger.getAttribute("data-edit-code")||"";'
            'var title=em.querySelector("[data-edit-title]");'
            'if(title)title.textContent=name+(code?(" ("+code+")"):"");'
            'var f=em.querySelector("#interest-edit-form");if(!f)return;'
            'f.querySelector("input[name=stock]").value=name;'
            'f.querySelector("input[name=buy_price]").value=trigger.getAttribute("data-edit-buy")||"";'
            'f.querySelector("input[name=target_price]").value=trigger.getAttribute("data-edit-target")||"";'
            'f.querySelector("input[name=stop_price]").value=trigger.getAttribute("data-edit-stop")||"";'
            'f.querySelector("input[name=memo]").value=trigger.getAttribute("data-edit-memo")||"";'
            'var df=em.querySelector("#interest-edit-delete-form");'
            'if(df)df.querySelector("input[name=stock]").value=name;'
        '}'
        'function populateTagForm(trigger){'
            'var tm=document.getElementById("tag-modal");if(!tm)return;'
            'var name=trigger.getAttribute("data-tag-stock")||"";'
            'var code=trigger.getAttribute("data-tag-code")||"";'
            'var curText=trigger.getAttribute("data-tag-text")||"";'
            'var curLeader=trigger.getAttribute("data-tag-leader")==="1";'
            'var title=tm.querySelector("[data-tag-title]");'
            'if(title)title.textContent=name+(code?(" ("+code+")"):"");'
            'var f=tm.querySelector("#tag-form");if(!f)return;'
            'f.querySelector("input[name=stock]").value=name;'
            'f.querySelector("input[name=code]").value=code;'
            'f.querySelector("input[name=tag]").value=curText;'
            'f.querySelector("input[name=leader]").value=curLeader?"1":"0";'
            'tm.querySelectorAll(".btn-tag-quick[data-tag-toggle=leader]").forEach(function(b){'
                'if(curLeader)b.classList.add("active");else b.classList.remove("active");'
            '});'
        '}'
        # 급등락 알림 상태 즉시 반영. 같은 종목이 여러 패널에 중복 노출되므로 code로 전 인스턴스를 칠한다.
        # 칠할 곳 3종: ①종목명 옆 표시자(span, 조작 불가) ②actions 의 '🔔 알림' 버튼 강조 ③팝업 안 토글.
        'function paintSurge(code,on){'
            'if(!code)return;'
            'document.querySelectorAll(\'[data-surge-code="\'+code+\'"]\').forEach(function(b){'
                'b.classList.toggle("has-surge",!!on);'
                'if(b.hasAttribute("data-surge-ind")){'
                    'b.textContent=on?"\\uD83D\\uDD14":"\\uD83D\\uDD15";'
                    'b.title="급등락 알림 "+(on?"켜짐":"꺼짐");'
                    'b.setAttribute("aria-label","급등락 알림 "+(on?"켜짐":"꺼짐"));'
                '}else if(b.hasAttribute("data-surge-toggle")){'
                    'b.dataset.surgeOn=on?"1":"0";'
                    'b.textContent=on?"\\uD83D\\uDD14 알림 켜짐 — 누르면 끕니다":"\\uD83D\\uDD15 알림 꺼짐 — 누르면 켭니다";'
                '}'
            '});'
        '}'
        'function populateNoteForm(trigger){'
            'var nm=document.getElementById("note-modal");if(!nm)return;'
            'var name=trigger.getAttribute("data-note-stock")||"";'
            'var code=trigger.getAttribute("data-note-code")||"";'
            'var curText=trigger.getAttribute("data-note-text")||"";'
            'var title=nm.querySelector("[data-note-title]");'
            'if(title)title.textContent=name+(code?(" ("+code+")"):"");'
            'var f=nm.querySelector("#note-form");if(!f)return;'
            'f.querySelector("input[name=stock]").value=name;'
            'f.querySelector("input[name=code]").value=code;'
            'f.querySelector("textarea[name=note]").value=curText;'
            'var pop=nm.querySelector("[data-note-confirm]");'
            'if(pop)pop.classList.add("hidden");'
        '}'
        'function populateGroupAssignForm(trigger){'
            'var gm=document.getElementById("group-assign-modal");if(!gm)return;'
            'var name=trigger.getAttribute("data-group-stock")||"";'
            'var curStr=trigger.getAttribute("data-group-current")||"";'
            'var allStr=trigger.getAttribute("data-group-all")||"";'
            'var cur=curStr?curStr.split(","):[];'
            'var all=allStr?allStr.split(","):[];'
            'var title=gm.querySelector("[data-group-title]");'
            'if(title)title.textContent=name;'
            'var f=gm.querySelector("#group-assign-form");if(!f)return;'
            'f.querySelector("input[name=stock]").value=name;'
            'var ni=f.querySelector("input[name=new_group]");if(ni)ni.value="";'
            'var box=gm.querySelector("[data-group-checks]");if(!box)return;'
            'if(!all.length){box.innerHTML=\'<div class="muted small">그룹이 없습니다. 아래에서 새로 만들어보세요.</div>\';return;}'
            'box.innerHTML="";'
            'all.forEach(function(g){'
                'var lab=document.createElement("label");lab.className="group-check";'
                'var cb=document.createElement("input");cb.type="checkbox";cb.name="groups";cb.value=g;'
                'if(cur.indexOf(g)>=0)cb.checked=true;'
                'var sp=document.createElement("span");sp.textContent=g;'
                'lab.appendChild(cb);lab.appendChild(sp);'
                'box.appendChild(lab);'
            '});'
        '}'
        'function renderGroupManageList(groups,counts){'
            'var gm=document.getElementById("group-manage-modal");if(!gm)return;'
            'var box=gm.querySelector("[data-group-list]");if(!box)return;'
            'window.__groupOrder=(groups||[]).slice();'
            'box.innerHTML="";'
            'if(!groups||!groups.length){box.innerHTML=\'<div class="muted small">아직 그룹이 없습니다. 아래에서 만들어보세요.</div>\';return;}'
            'groups.forEach(function(g,i){'
                'var row=document.createElement("div");row.className="group-manage-row";'
                'var top=document.createElement("div");top.className="gmr-top";'
                'var inp=document.createElement("input");inp.type="text";inp.className="group-name-input";inp.value=g;inp.setAttribute("data-group-orig",g);inp.maxLength=20;'
                'var cnt=document.createElement("span");cnt.className="muted small group-count";var n=(counts&&counts[g])||0;cnt.textContent=n+"종목";'
                'top.appendChild(inp);top.appendChild(cnt);'
                'var act=document.createElement("div");act.className="gmr-actions";'
                'var up=document.createElement("button");up.type="button";up.className="btn-mini";up.textContent="▲";up.title="위로";up.setAttribute("data-group-up",g);if(i===0)up.disabled=true;'
                'var dn=document.createElement("button");dn.type="button";dn.className="btn-mini";dn.textContent="▼";dn.title="아래로";dn.setAttribute("data-group-down",g);if(i===groups.length-1)dn.disabled=true;'
                'var spacer=document.createElement("span");spacer.className="gmr-spacer";'
                'var rn=document.createElement("button");rn.type="button";rn.className="btn-mini";rn.textContent="이름변경";rn.setAttribute("data-group-rename",g);'
                'var del=document.createElement("button");del.type="button";del.className="btn-mini btn-delete";del.textContent="삭제";del.setAttribute("data-group-delete",g);'
                'act.appendChild(up);act.appendChild(dn);act.appendChild(spacer);act.appendChild(rn);act.appendChild(del);'
                'row.appendChild(top);row.appendChild(act);'
                'box.appendChild(row);'
            '});'
        '}'
        'function doGroupOp(payload){'
            'var gm=document.getElementById("group-manage-modal");'
            'var data=new URLSearchParams();'
            'for(var k in payload){if(payload.hasOwnProperty(k))data.append(k,payload[k]);}'
            'return fetch("/interests/groups",{method:"POST",headers:{"Accept":"application/json","Content-Type":"application/x-www-form-urlencoded"},body:data.toString(),credentials:"same-origin"})'
                '.then(function(r){return r.json().catch(function(){return {ok:false,error:"HTTP "+r.status};});})'
                '.then(function(j){'
                    'if(j&&j.ok){'
                        'renderGroupManageList(j.groups||[],j.counts||{});'
                        'if(window.__behive_load)window.__behive_load(null,true);'
                    '}else if(gm){showError(gm,(j&&j.error)||"실패");}'
                    'return j;'
                '})'
                '.catch(function(){if(gm)showError(gm,"네트워크 오류");});'
        '}'
        'document.addEventListener("click",function(e){'
            'var t=e.target;'
            'var closeAttr=t.closest&&t.closest("[data-modal-close]");'
            'if(closeAttr){e.preventDefault();closeModal(closeAttr.closest(".modal"));return;}'
            'var tradeBtn=t.closest&&t.closest(".btn-trades");'
            'if(tradeBtn){'
                'e.preventDefault();'
                'e.stopPropagation();'
                'var tc=tradeBtn.getAttribute("data-trade-code")||"";'
                'var tn=tradeBtn.getAttribute("data-trade-stock")||"";'
                'var to=tradeBtn.getAttribute("data-trade-owner")||"";'
                'openTradeModal(tc,tn,to);'
                'return;'
            '}'
            'var infoBtn=t.closest&&t.closest(".btn-info");'
            'if(infoBtn){'
                'e.preventDefault();'
                'e.stopPropagation();'
                'openInfoModal(infoBtn.getAttribute("data-info-code")||"",infoBtn.getAttribute("data-info-stock")||"");'
                'return;'
            '}'
            'var prBtn=t.closest&&t.closest(".kpi-more-btn");'
            'if(prBtn){'
                'e.preventDefault();'
                'e.stopPropagation();'
                'openKpiModal(prBtn.getAttribute("data-kpi-src")||"",prBtn.getAttribute("data-kpi-title")||"");'
                'return;'
            '}'
            'var infoQ=t.closest&&t.closest(".info-q");'
            'if(infoQ){'
                'e.preventDefault();'
                'e.stopPropagation();'
                'openInfoDesc(infoQ.getAttribute("data-info-desc")||"");'
                'return;'
            '}'
            'var infoSearchBtn=t.closest&&t.closest("[data-info-search-btn]");'
            'if(infoSearchBtn){e.preventDefault();e.stopPropagation();doInfoSearch();return;}'
            'var infoHit=t.closest&&t.closest(".info-search-hit");'
            'if(infoHit){'
                'e.preventDefault();e.stopPropagation();'
                'var rb=document.querySelector("#info-modal [data-info-search-results]");'
                'addInfoCompare(infoHit.getAttribute("data-cmp-add")||"",infoHit.getAttribute("data-cmp-name")||"");'
                'if(rb){rb.classList.add("hidden");rb.innerHTML="";}'
                'var inp=document.querySelector("#info-modal [data-info-search]");if(inp)inp.value="";'
                'return;'
            '}'
            'var cmpRm=t.closest&&t.closest(".info-cmp-remove");'
            'if(cmpRm){e.preventDefault();e.stopPropagation();removeInfoCompare(cmpRm.getAttribute("data-cmp-remove")||"");return;}'
            'var tradeTab=t.closest&&t.closest(".trade-tab");'
            'if(tradeTab){'
                'e.preventDefault();'
                'var tm=tradeTab.closest("#trade-modal");if(!tm)return;'
                'var tgt=tradeTab.getAttribute("data-trade-owner")||"";'
                'tm.querySelectorAll(".trade-tab").forEach(function(btn){'
                    'btn.setAttribute("aria-selected",btn.getAttribute("data-trade-owner")===tgt?"true":"false");'
                '});'
                'tm.querySelectorAll(".trade-tab-panel").forEach(function(p){'
                    'if(p.getAttribute("data-trade-panel")===tgt)p.removeAttribute("hidden");'
                    'else p.setAttribute("hidden","");'
                '});'
                'return;'
            '}'
            'var tagToggleBtn=t.closest&&t.closest("[data-tag-toggle=leader]");'
            'if(tagToggleBtn){'
                'e.preventDefault();'
                'var ttm=document.getElementById("tag-modal");if(!ttm)return;'
                'var ttf=ttm.querySelector("#tag-form");if(!ttf)return;'
                'var leaderInp=ttf.querySelector("input[name=leader]");'
                'var newVal=leaderInp.value==="1"?"0":"1";'
                'leaderInp.value=newVal;'
                'if(newVal==="1")tagToggleBtn.classList.add("active");'
                'else tagToggleBtn.classList.remove("active");'
                # 텍스트(name=tag) 인풋은 절대 건드리지 않음 — 사용자가 입력한 값 그대로 유지.
                # 토글은 색깔(leader)만 뒤집고 즉시 서버 반영. 모달은 닫지 않음(keepOpen=true).
                'submitForm(ttf,null,null,true);'
                'return;'
            '}'
            'var tagDelBtn=t.closest&&t.closest("[data-tag-delete]");'
            'if(tagDelBtn){'
                'e.preventDefault();'
                'var tdm=document.getElementById("tag-modal");if(!tdm)return;'
                'var tdf=tdm.querySelector("#tag-form");if(!tdf)return;'
                'tdf.querySelector("input[name=tag]").value="";'
                'tdf.querySelector("input[name=leader]").value="0";'
                'submitForm(tdf);'
                'return;'
            '}'
            'var tagEditBtn=t.closest&&t.closest("[data-tag-edit]");'
            'if(tagEditBtn){'
                'e.preventDefault();'
                'e.stopPropagation();'
                'populateTagForm(tagEditBtn);'
                'openModal("tag-modal");'
                'return;'
            '}'
            'var noteDelBtn=t.closest&&t.closest("[data-note-delete]");'
            'if(noteDelBtn){'
                'e.preventDefault();'
                'var ndm=document.getElementById("note-modal");if(!ndm)return;'
                'var pop=ndm.querySelector("[data-note-confirm]");'
                'if(pop)pop.classList.remove("hidden");'
                'return;'
            '}'
            'var noteConfNo=t.closest&&t.closest("[data-note-confirm-no]");'
            'if(noteConfNo){'
                'e.preventDefault();'
                'var ndmN=document.getElementById("note-modal");if(!ndmN)return;'
                'var popN=ndmN.querySelector("[data-note-confirm]");'
                'if(popN)popN.classList.add("hidden");'
                'return;'
            '}'
            'var noteConfYes=t.closest&&t.closest("[data-note-confirm-yes]");'
            'if(noteConfYes){'
                'e.preventDefault();'
                'var ndmY=document.getElementById("note-modal");if(!ndmY)return;'
                'var popY=ndmY.querySelector("[data-note-confirm]");'
                'if(popY)popY.classList.add("hidden");'
                'var ndf=ndmY.querySelector("#note-form");if(!ndf)return;'
                'ndf.querySelector("textarea[name=note]").value="";'
                'submitForm(ndf);'
                'return;'
            '}'
            'var noteEditBtn=t.closest&&t.closest("[data-note-edit]");'
            'if(noteEditBtn){'
                'e.preventDefault();'
                'e.stopPropagation();'
                'populateNoteForm(noteEditBtn);'
                'openModal("note-modal");'
                'return;'
            '}'
            # 급등락 알림 토글 — 낙관적 갱신(즉시 반영) 후 POST. 실패하면 되돌린다.
            # ⚠️ 패널 전체 새로고침(__behive_load)을 부르지 않는다 — 키움 조회까지 다시 돌아
            # 버튼이 몇 초 뒤에야 바뀐다. 토글 말고 달라지는 게 없어서 DOM만 고치면 충분하고,
            # 서버는 이미 _invalidate_panels_cache 했으니 다음 자동 새로고침이 알아서 일치시킨다.
            'var surgeBtn=t.closest&&t.closest("[data-surge-toggle]");'
            'if(surgeBtn){'
                'e.preventDefault();e.stopPropagation();'
                'if(surgeBtn.dataset.busy==="1")return;'
                'surgeBtn.dataset.busy="1";'
                'var sCode=surgeBtn.dataset.surgeCode||"";'
                'var sName=surgeBtn.dataset.surgeName||"";'
                'var wasOn=surgeBtn.dataset.surgeOn==="1";'
                'paintSurge(sCode,!wasOn);'
                'var body=new URLSearchParams();'
                'body.append("code",sCode);'
                'body.append("stock",sName);'
                'body.append("on",wasOn?"0":"1");'
                'fetch("/surge/toggle",{method:"POST",headers:{"Accept":"application/json","Content-Type":"application/x-www-form-urlencoded"},body:body.toString(),credentials:"same-origin"})'
                    '.then(function(r){return r.json().catch(function(){return {ok:false,error:"HTTP "+r.status};});})'
                    '.then(function(j){'
                        'if(j&&j.ok){'
                            'paintSurge(sCode,!!j.on);'
                            'showToast((sName||"종목")+" 급등락 알림 "+(j.on?"켰습니다":"껐습니다"));'
                            # 팝업 안에서 토글한 경우 본문(구간·안내문)이 상태와 어긋나므로 다시 받아온다.
                            'var rsrc=surgeBtn.getAttribute("data-surge-refresh");'
                            'var kb=document.querySelector("[data-kpi-body]");'
                            'if(rsrc&&kb){'
                                'fetch(rsrc,{credentials:"same-origin"}).then(function(r){return r.json();})'
                                    '.then(function(d){if(d&&d.html)kb.innerHTML=d.html;}).catch(function(){});'
                            '}'
                        '}else{'
                            'paintSurge(sCode,wasOn);'
                            'showToast("급등락 알림 변경 실패: "+((j&&j.error)||"오류"));'
                        '}'
                    '})'
                    '.catch(function(){paintSurge(sCode,wasOn);showToast("네트워크 오류");})'
                    '.finally(function(){surgeBtn.dataset.busy="";});'
                'return;'
            '}'
            'var gAssign=t.closest&&t.closest("[data-group-assign]");'
            'if(gAssign){'
                'e.preventDefault();e.stopPropagation();'
                'populateGroupAssignForm(gAssign);'
                'openModal("group-assign-modal");'
                'return;'
            '}'
            'var gManage=t.closest&&t.closest("[data-group-manage]");'
            'if(gManage){'
                'e.preventDefault();e.stopPropagation();'
                'var mAll=gManage.getAttribute("data-group-all")||"";'
                'var mNames=mAll?mAll.split(","):[];'
                'var mCounts={};try{mCounts=JSON.parse(gManage.getAttribute("data-group-counts")||"{}");}catch(_){}'
                'renderGroupManageList(mNames,mCounts);'
                'openModal("group-manage-modal");'
                'return;'
            '}'
            'var gNew=t.closest&&t.closest("[data-group-new-add]");'
            'if(gNew){'
                'e.preventDefault();'
                'var gmn=document.getElementById("group-manage-modal");'
                'var ip=gmn?gmn.querySelector("[data-group-new-input]"):null;'
                'var nvn=ip?ip.value.trim():"";if(!nvn)return;'
                'doGroupOp({op:"create",name:nvn}).then(function(){if(ip)ip.value="";});'
                'return;'
            '}'
            'var gRn=t.closest&&t.closest("[data-group-rename]");'
            'if(gRn){'
                'e.preventDefault();'
                'var orig=gRn.getAttribute("data-group-rename");'
                'var rrow=gRn.closest(".group-manage-row");'
                'var rinp=rrow?rrow.querySelector(".group-name-input"):null;'
                'var nvr=rinp?rinp.value.trim():"";'
                'if(!nvr||nvr===orig)return;'
                'doGroupOp({op:"rename",name:orig,new_name:nvr});'
                'return;'
            '}'
            'var gDel=t.closest&&t.closest("[data-group-delete]");'
            'if(gDel){'
                'e.preventDefault();'
                'var gd=gDel.getAttribute("data-group-delete");'
                'if(!confirm("그룹 \\""+gd+"\\" 을(를) 삭제할까요? (종목은 유지, 그룹 배정만 해제됩니다)"))return;'
                'doGroupOp({op:"delete",name:gd});'
                'return;'
            '}'
            'var gUp=t.closest&&t.closest("[data-group-up]");'
            'if(gUp){'
                'e.preventDefault();'
                'var gu=gUp.getAttribute("data-group-up");var ord=(window.__groupOrder||[]).slice();'
                'var iu=ord.indexOf(gu);if(iu>0){ord.splice(iu,1);ord.splice(iu-1,0,gu);doGroupOp({op:"reorder",order:ord.join(",")});}'
                'return;'
            '}'
            'var gDn=t.closest&&t.closest("[data-group-down]");'
            'if(gDn){'
                'e.preventDefault();'
                'var gdn=gDn.getAttribute("data-group-down");var ord2=(window.__groupOrder||[]).slice();'
                'var idn=ord2.indexOf(gdn);if(idn>=0&&idn<ord2.length-1){ord2.splice(idn,1);ord2.splice(idn+1,0,gdn);doGroupOp({op:"reorder",order:ord2.join(",")});}'
                'return;'
            '}'
            'var openBtn=t.closest&&t.closest("[data-modal-open]");'
            'if(openBtn){'
                'e.preventDefault();'
                'var oid=openBtn.getAttribute("data-modal-open");'
                'if(oid==="interest-edit-modal"&&openBtn.hasAttribute("data-edit-stock")){'
                    'populateEditForm(openBtn);'
                '}'
                'if(oid==="interest-modal"&&openBtn.hasAttribute("data-prefill-stock")){'
                    'var addForm=document.getElementById("interest-add-form");'
                    'if(addForm){'
                        'var psName=openBtn.getAttribute("data-prefill-stock")||"";'
                        'var psCode=openBtn.getAttribute("data-prefill-code")||"";'
                        'var pi=addForm.querySelector("input[name=stock]");if(pi)pi.value=psName;'
                        'var pc=addForm.querySelector("input[name=code]");if(pc)pc.value=psCode;'
                    '}'
                '}'
                'openModal(oid);'
                'return;'
            '}'
            'var candBtn=t.closest&&t.closest(".candidate-btn");'
            'if(candBtn){'
                'e.preventDefault();'
                'var name=candBtn.getAttribute("data-cand-name")||"";'
                'var code=candBtn.getAttribute("data-cand-code")||"";'
                'var form=document.getElementById("interest-add-form");'
                'if(form){'
                    'var ni=form.querySelector("input[name=stock]");if(ni)ni.value=name;'
                    'var ci=form.querySelector("input[name=code]");if(ci)ci.value=code;'
                    'closeModal(document.getElementById("candidate-modal"));'
                    'submitForm(form,name,code);'
                '}'
                'return;'
            '}'
        '});'
        # 기업비교 — 내 목록 드롭다운 선택 시 추가, 검색창 Enter 시 검색.
        'document.addEventListener("change",function(e){'
            'var sel=e.target&&e.target.closest&&e.target.closest("[data-info-symbols]");'
            'if(!sel||!sel.value)return;'
            'var opt=sel.options[sel.selectedIndex];'
            'addInfoCompare(sel.value,opt?(opt.getAttribute("data-name")||""):"");'
            'sel.value="";'
        '});'
        'document.addEventListener("keydown",function(e){'
            'if(e.key!=="Enter")return;'
            'var inp=e.target&&e.target.closest&&e.target.closest("[data-info-search]");'
            'if(!inp)return;'
            'e.preventDefault();doInfoSearch();'
        '});'
        'document.addEventListener("submit",function(e){'
            'var form=e.target;'
            'if(!form)return;'
            'if(form.id==="interest-add-form"||form.id==="interest-edit-form"||form.id==="tag-form"||form.id==="note-form"||form.id==="group-assign-form"){'
                'e.preventDefault();'
                'submitForm(form);'
                'return;'
            '}'
            'if(form.classList&&form.classList.contains("interest-delete-form")){'
                'e.preventDefault();'
                'if(!confirm("관심종목에서 삭제하시겠습니까?"))return;'
                'submitForm(form);'
                'return;'
            '}'
        '});'
        'document.addEventListener("keydown",function(e){'
            'if(e.key==="Escape"){'
                'document.querySelectorAll(".modal:not(.hidden)").forEach(closeModal);'
            '}'
        '});'
        '})();</script>'
    )

    # 거래내역 종목 셀 press-and-hold tooltip — pointerdown=show, pointerup/cancel/scroll=hide.
    # 셀 상단에 띄워 손가락에 안 가리게. 화면 위 공간 부족하면 셀 하단으로 fallback.
    stock_name_tip_script = (
        '<script>(function(){'
        'var tip=document.getElementById("stock-name-tip");if(!tip)return;'
        'function place(cell){'
            'var r=cell.getBoundingClientRect();'
            'tip.classList.remove("below");'
            'tip.classList.add("show");'
            'var w=tip.offsetWidth,h=tip.offsetHeight;'
            'var cx=r.left+r.width/2;'
            'var left=cx-w/2;'
            'var maxLeft=window.innerWidth-w-8;'
            'if(left<8)left=8;else if(left>maxLeft)left=maxLeft;'
            'var top=r.top-h-10;'
            'if(top<8){top=r.bottom+10;tip.classList.add("below");}'
            'tip.style.left=left+"px";'
            'tip.style.top=top+"px";'
        '}'
        'function show(cell){'
            'tip.textContent=cell.getAttribute("title")||cell.textContent||"";'
            'place(cell);'
            'tip.setAttribute("aria-hidden","false");'
        '}'
        'function hide(){'
            'tip.classList.remove("show","below");'
            'tip.setAttribute("aria-hidden","true");'
        '}'
        'document.addEventListener("pointerdown",function(e){'
            'var cell=e.target.closest&&e.target.closest(".trade-stock-col");'
            'if(!cell)return;'
            'e.preventDefault();'
            'show(cell);'
        '},{passive:false});'
        'document.addEventListener("pointerup",hide);'
        'document.addEventListener("pointercancel",hide);'
        'document.addEventListener("pointerleave",hide);'
        'window.addEventListener("scroll",hide,true);'
        '})();</script>'
    )

    # details.row[data-row-key] 그룹 mutex — 새로 열리면 다른 열려있던 것 자동 닫음.
    # capture phase 사용: toggle 이벤트는 bubble 안 함. panels apply가 다시 그릴 때 setAttribute('open')
    # 호출이 다시 trigger되어도 한 개만 남는다.
    # ⚠️ 관심종목 그룹 탭은 같은 종목(=같은 data-row-key)이 전체·보유중·그룹 패널에 중복 존재한다.
    # 같은 키끼리는 닫지 않는다(탭 간 동기화, 새로고침 시 보이는 탭 행이 닫히던 버그). 다른 종목만 닫음.
    details_mutex_script = (
        '<script>(function(){'
        'document.addEventListener("toggle",function(e){'
            'var d=e.target;'
            'if(!d||d.nodeName!=="DETAILS")return;'
            'if(!d.classList.contains("row"))return;'
            'if(!d.hasAttribute("data-row-key"))return;'
            'var k=d.getAttribute("data-row-key");'
            'if(d.open){'
                # 열림: 같은 키(그룹 탭 중복 행) 전부 열어 동기화 + 다른 키는 닫음(아코디언).
                'document.querySelectorAll("details.row[data-row-key]").forEach(function(o){'
                    'if(o===d)return;'
                    'if(o.getAttribute("data-row-key")===k){if(!o.open)o.setAttribute("open","");}'
                    'else if(o.open)o.removeAttribute("open");'
                '});'
            '}else{'
                # 닫힘: 같은 키 중복 행 전부 닫아 동기화 — 숨은 탭 사본이 열린 채 남아 새로고침때 되살아나던 문제.
                'document.querySelectorAll("details.row[data-row-key][open]").forEach(function(o){'
                    'if(o!==d&&o.getAttribute("data-row-key")===k)o.removeAttribute("open");'
                '});'
            '}'
        '},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");});'
                # 포탈된 수평선 모달도 함께 정리 (body 직속이라 카드 닫혀도 안 사라짐).
                'document.querySelectorAll(".hl-modal[data-hl-portal]").forEach(function(m){m.remove();});'
                '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>'
    )

    # 주문 모달 + PIN 모달 JS — 호가 1초 폴링, 매수/매도, 호가 클릭 → 단가, 계좌 select → /api/order/check.
    # propose 성공 시 별도 PIN 모달(pin-modal)로 swap. verify는 PIN 모달 안에서 처리.
    # window.openOrderModal({code, name, side?, account?, accounts?, price?}) — trigger 진입점.
    order_modal_script = r'''<script>(function(){
var modal = document.getElementById('order-modal');
var pinModal = document.getElementById('pin-modal');
var sellChoiceModal = document.getElementById('sell-choice-modal');
var orderConfirmModal = document.getElementById('order-confirm-modal');
var openOrdersModal = document.getElementById('open-orders-modal');
var adviceModal = document.getElementById('advice-modal');
if(!modal) return;
var ACCOUNTS = [
  {label: '일반', display: '본인 일반', owner: '본인'},
  {label: 'ISA', display: '본인 ISA', owner: '본인'},
  {label: '가희_일반', display: '가희 일반', owner: '가희'},
  {label: '가희_ISA', display: '가희 ISA', owner: '가희'}
];
var state = { code:'', name:'', side:'BUY', pollTimer:null, countdownTimer:null, expiryAt:0, isOpen:false, lastBook:null, bookCentered:false, lastCheck:null, marketActive:true, marketPhase:null, symbolsCache:null, pendingMaxOnSell:false, pendingMaxOnBuy:false, accStatus:null, sellChoice:null, advice:null, adviceKey:null };
function $(sel, root){ return (root||modal).querySelector(sel); }
function $$(sel, root){ return (root||modal).querySelectorAll(sel); }
function $p(sel){ return pinModal ? pinModal.querySelector(sel) : null; }
function $a(sel){ return adviceModal ? adviceModal.querySelector(sel) : null; }
function fmt(n){ if(!isFinite(n)) return '—'; return (Math.round(n)||0).toLocaleString('ko-KR'); }
function highlightSelectedTick(){
  var inp = $('[data-order-price-input]');
  var cur = parseInt((inp||{}).value || '0', 10);
  $$('.ob-row').forEach(function(row){
    var p = parseInt(row.getAttribute('data-ob-price') || '0', 10);
    row.classList.toggle('selected', p > 0 && p === cur);
  });
}
var __toastEl = null, __toastTimer = null;
function showToast(text, kind, durationMs){
  if(!text) return;
  if(!__toastEl){
    __toastEl = document.createElement('div');
    __toastEl.className = 'order-toast';
    document.body.appendChild(__toastEl);
  }
  __toastEl.textContent = text;
  __toastEl.className = 'order-toast ' + (kind || 'error');
  void __toastEl.offsetWidth;
  __toastEl.classList.add('show');
  if(__toastTimer) clearTimeout(__toastTimer);
  __toastTimer = setTimeout(function(){ if(__toastEl) __toastEl.classList.remove('show'); }, durationMs || 3500);
}
window.showToast = showToast;  // 다른 스크립트(실시간 상태 점 등)에서 재사용
function setMsg(text, kind){
  // 모달 내 메시지 영역은 hide, 알림은 토스트로
  var el = $('[data-order-msg]'); if(el) el.classList.add('hidden');
  if(text) showToast(text, kind);
}
function setSide(side){
  state.side = side;
  $$('.order-side-toggle button[data-side]').forEach(function(b){ b.classList.toggle('active', b.getAttribute('data-side')===side); });
  // 모달 박스 전체에 side 색조 — 테두리·헤더 구분선·호가창 테두리가 매수=빨강/매도=파랑으로 물듦
  var obox = $('.order-box');
  if(obox){ obox.classList.toggle('side-buy', side==='BUY'); obox.classList.toggle('side-sell', side==='SELL'); }
  decorateAccounts();  // 매수/매도에 따라 계좌 색·표기 전환
  var sb = $('[data-order-submit]'); if(sb){ sb.setAttribute('data-side', side); sb.textContent = (side==='BUY'?'매수':'매도'); }
  renderQtyButtons();
  // 매수일 때만 금액 input 표시
  var budgetRow = $('[data-order-budget-row]');
  if(budgetRow) budgetRow.style.display = (side === 'BUY') ? '' : 'none';
  // 토글 시 budget input 초기화 — 이전 모드 잔존 방지 (양방향 동기화 fresh 시작)
  var budgetInp = $('[data-order-budget]');
  if(budgetInp) budgetInp.value = '';
  // 토글 시 자동 max 플래그 — updateCheck 응답에서 max로 채움
  // SELL: 보유주수 100%, BUY: 매수가능액/단가
  if(side === 'SELL') state.pendingMaxOnSell = true;
  else {
    state.pendingMaxOnBuy = true;
    // BUY max 계산엔 price 필요 — 비어있으면 호가창 현재가로 즉시 채움
    _resolveOrderPrice();
  }
  refreshStopUI();  // 스톱지정가(매도 전용) 옵션·입력행 side에 맞게 갱신
  updateCheck();
  fetchAdvice();  // 매수/매도 방향에 따라 권장가 세트가 다름
}
// 스톱지정가·트레일링은 매도 전용 — 매수면 옵션 비활성+선택돼 있으면 LIMIT로 되돌림.
// 조건단가 행·트레일 폭 행·단가 라벨 갱신. 트레일링은 단가를 서버가 계산하므로 단가 행을 숨긴다.
function refreshStopUI(){
  var otSel = $('[data-order-type]'); if(!otSel) return;
  var stopOpt = otSel.querySelector('option[value="STOP_LIMIT"]');
  if(stopOpt) stopOpt.disabled = (state.side === 'BUY');
  var trailOpt = otSel.querySelector('option[value="TRAILING_STOP"]');
  if(trailOpt) trailOpt.disabled = (state.side === 'BUY');
  if(state.side === 'BUY' && (otSel.value === 'STOP_LIMIT' || otSel.value === 'TRAILING_STOP')) otSel.value = 'LIMIT';
  var isStop = (otSel.value === 'STOP_LIMIT');
  var isTrail = (otSel.value === 'TRAILING_STOP');
  var stopRow = $('[data-order-stop-row]');
  if(stopRow) stopRow.style.display = isStop ? '' : 'none';
  var trailRow = $('[data-order-trail-row]');
  if(trailRow) trailRow.style.display = isTrail ? '' : 'none';
  var priceRow = $('[data-order-price-row]');
  if(priceRow) priceRow.style.display = isTrail ? 'none' : '';
  var priceLabel = $('[data-order-price-label]');
  if(priceLabel) priceLabel.textContent = isStop ? '매도 단가' : '단가';
  if(isTrail){
    // 처음 트레일링을 고른 순간에만 프리셋을 채운다 — 이미 편집 중이면 덮어쓰지 않는다.
    var psel = $('[data-trail-preset]');
    if(psel && !psel.options.length){ renderTrailPresetSelect(); applyTrailPreset(); }
    else renderTrailPreview();
  }
  else refreshSubmitGate();  // 트레일링에서 빠져나오면 트레일 게이트를 풀어야 한다
}
// ---- 계단식 트레일링 (2026-07-31) ----
// 계단 = {pct: 고점 대비 하락률, cum: 그때까지 팔려 있어야 할 누적 비중}.
// 초기 고점은 항상 등록 시점 현재가 — 과거 고점 기준 옵션은 2026-07-30 제거됨.
function trailPresets(){ return window.__trailPresets || []; }
function trailSteps(){
  // :not(.trail-step-head) — 헤더도 같은 .trail-step 이라 빼지 않으면 입력칸 없는 행을 읽는다.
  var out = [],
      rows = document.querySelectorAll('[data-trail-steps] .trail-step:not(.trail-step-head)');
  for(var i=0;i<rows.length;i++){
    out.push({
      pct: parseFloat(rows[i].querySelector('[data-ts-pct]').value || '0'),
      cum: parseFloat(rows[i].querySelector('[data-ts-cum]').value || '0')
    });
  }
  return out;
}
function trailMinPrice(){
  return parseInt(($('[data-order-trail-min]')||{}).value || '0', 10) || 0;
}
// 계단별 주수 — 서버 orders/trailing.allocate_step_qty(최대잔여법)와 같은 식이어야 한다.
function allocStepQty(total, weights){
  if(!(total > 0)) return weights.map(function(){ return 0; });
  var exact = weights.map(function(w){ return total * w / 100; });
  var base = exact.map(function(x){ return Math.floor(x); });
  var sumE = 0, sumB = 0;
  exact.forEach(function(x){ sumE += x; });
  base.forEach(function(x){ sumB += x; });
  var remain = Math.round(sumE) - sumB;
  var order = exact.map(function(x,i){ return {i:i, f:x - base[i]}; })
                   .sort(function(a,b){ return b.f - a.f; });
  for(var k=0; k<remain && k<order.length; k++) base[order[k].i] += 1;
  return base;
}
// 누적 비중 → 계단별 추가 비중 (서버 normalize_steps 와 같은 환산).
function trailWeights(steps){
  var w = [], prev = 0;
  steps.forEach(function(s){ w.push(s.cum - prev); prev = s.cum; });
  return w;
}
// 손절선 계산 — 서버 `orders/trailing.compute_levels` 와 **같은 식**이어야 한다.
// 실제 발주값은 서버 계산분이 승자이므로, 한쪽만 바꾸면 표시와 발주가 어긋난다.
// Math.round(_*1e6)/1e6 은 서버의 round(_, 6) 과 짝 — 5500×(1−30/100) 이 3849.9999999999995
// 로 나오는 부동소수점 잡음 때문에 1틱 낮게 잡히던 것을 막는다.
function trailCondPrice(peak, pct, minPrice){
  var cond = floorTick(Math.floor(Math.round(peak * (1 - pct/100) * 1e6) / 1e6));
  var floorUv = minPrice > 0 ? floorTick(minPrice) : 0;
  return { cond: Math.max(cond, floorUv), floorApplied: floorUv > cond };
}
// 라벨은 헤더 한 줄로만 — 행마다 "하락/누적" 을 반복하면 좁은 폭에서 입력칸이 절반으로 줄어든다.
// 계단이 0개면 헤더도 그리지 않는다(빈 표 머리만 떠 있으면 뭐가 빠진 것처럼 보인다).
function renderTrailStepRows(steps){
  var box = $('[data-trail-steps]'); if(!box) return;
  if(!steps.length){ box.innerHTML = ''; return; }
  var head = '<div class="trail-step trail-step-head" aria-hidden="true">' +
    '<span></span><span>하락</span><span>누적</span><span></span></div>';
  box.innerHTML = head + steps.map(function(s, i){
    return '<div class="trail-step">' +
      '<span class="tsn">' + (i+1) + '</span>' +
      '<span class="tsw"><input type="number" inputmode="decimal" min="0.5" max="30" ' +
        'step="0.5" value="' + s.pct + '" data-ts-pct aria-label="' + (i+1) + '단계 하락률(%)">' +
        '<span class="tsu">%</span></span>' +
      '<span class="tsw"><input type="number" inputmode="numeric" min="1" max="100" ' +
        'step="5" value="' + s.cum + '" data-ts-cum aria-label="' + (i+1) + '단계 누적 비중(%)">' +
        '<span class="tsu">%</span></span>' +
      '<button type="button" class="tsx" data-ts-del="' + i + '" aria-label="계단 삭제">✕</button>' +
      '</div>';
  }).join('');
}
// 첫 항목(단일)이 기본값 — TRAIL_PRESETS 순서가 곧 표시 순서다.
// 옵션은 이름만 — 계단 수치는 바로 아래 편집 표에 그대로 보이므로 중복이다.
function renderTrailPresetSelect(){
  var sel = $('[data-trail-preset]'); if(!sel) return;
  sel.innerHTML = '';
  trailPresets().forEach(function(p, i){
    var opt = document.createElement('option');
    opt.value = String(i);
    opt.textContent = p.name;
    sel.appendChild(opt);
  });
  sel.value = '0';
}
function applyTrailPreset(){
  var sel = $('[data-trail-preset]'); if(!sel) return;
  var p = trailPresets()[parseInt(sel.value, 10)];
  if(p) renderTrailStepRows(p.steps);
  renderTrailPreview();
}
function renderTrailPreview(){
  // 게이트 갱신은 조기 반환보다 먼저 — 계단이 비었거나 시세 로딩 중일 때도 버튼이 잠겨야 한다.
  // trailBlockReason 이 DOM 을 직접 읽으므로 호출 순서에 의존하지 않는다.
  refreshSubmitGate();
  var el = $('[data-order-trail-preview]'); if(!el) return;
  var steps = trailSteps();
  var minPrice = trailMinPrice();
  var cur = (state.lastCheck && state.lastCheck.cur_price) || (state.lastBook && state.lastBook.price) || 0;
  if(!steps.length || !(cur > 0)){ el.textContent = ''; return; }
  var qty = parseInt(($('[data-order-qty]')||{}).value || '0', 10) || 0;
  var reason = trailBlockReason();
  var peak = cur;   // 초기 고점 = 등록 시점 현재가 (서버 handler 와 동일)
  var qtys = allocStepQty(qty, trailWeights(steps));

  var hero, note;
  if(reason){
    // 서버도 같은 조건으로 거부한다 — PIN 발급 왕복 전에 여기서 먼저 알려준다.
    hero = '<div class="cap">등록할 수 없습니다</div>' +
           '<div class="val">' + reason + '</div>';
    note = '위 입력을 고쳐주세요.';
  } else {
    var r0 = trailCondPrice(peak, steps[0].pct, minPrice);
    var pctFromCur = ((cur - r0.cond) / cur * 100);
    hero = '<div class="cap">지금 등록하면 1단계 손절선</div>' +
           '<div class="val">' + fmt(r0.cond) + '원' +
             (r0.floorApplied ? '<span class="trail-pv-badge">최저가 적용</span>' : '') + '</div>' +
           '<div class="sub">현재가보다 ' + pctFromCur.toFixed(1) + '% 아래 · 여기 닿으면 ' +
             (qtys[0] > 0 ? fmt(qtys[0]) + '주' : '') + ' 매도</div>';
    note = '고점이 오르면 계단이 <b>통째로 따라 올라갑니다</b>. 내려가지는 않습니다.';
  }

  var rows = steps.map(function(s, i){
    var r = trailCondPrice(peak, s.pct, minPrice);
    var cum = (s.cum >= 100 ? '전량' : '누적 ' + s.cum + '%');
    var q = qtys[i] > 0 ? fmt(qtys[i]) + '주' : '0주';
    return '<div class="trail-pv-step' + (r.floorApplied ? ' tied' : '') + '">' +
      '<span class="sp">' + (i+1) + '단계 −' + s.pct + '%</span>' +
      '<span class="sv">' + fmt(r.cond) + '원</span>' +
      '<span class="sq">' + q + ' · ' + cum + '</span></div>';
  }).join('');
  var allotted = 0;
  qtys.forEach(function(q){ allotted += q; });
  if(qty > 0 && allotted < qty){
    // 이 행은 1행짜리 — 계단 행처럼 2행으로 쪼개면 "나머지" 아래 숫자가 떠 어색하다.
    rows += '<div class="trail-pv-step"><span class="sp">나머지 ' + fmt(qty - allotted) +
            '주</span><span class="sv">스톱 없음</span></div>';
  }

  var kv = ['<dt>시작 고점</dt><dd><b>' + fmt(peak) + '원</b><br>등록 시점 현재가</dd>'];
  if(minPrice > 0) kv.push('<dt>최저 매도가</dt><dd>' + fmt(minPrice) + '원</dd>');

  el.innerHTML = '<div class="trail-pv' + (reason ? ' bad' : '') + '">' +
    '<div class="trail-pv-hero">' + hero + '</div>' +
    '<div class="trail-pv-steps">' + rows + '</div>' +
    '<dl class="trail-pv-kv">' + kv.join('') + '</dl>' +
    '<div class="trail-pv-note">' + note + '</div>' +
    '</div>';
}
function _resolveOrderPrice(){
  // 단가 우선, 없으면 현재가 fallback. 단가 input 비어있으면 자동 채움 (disabled 아닌 경우)
  var priceInp = $('[data-order-price-input]');
  var price = parseInt((priceInp||{}).value || '0', 10);
  if(price <= 0 && state.lastBook && state.lastBook.price){
    price = state.lastBook.price;
    if(priceInp && !priceInp.disabled){
      priceInp.value = price;
      highlightSelectedTick();
    }
  }
  return price;
}
function recalcQtyFromBudget(){
  if(state.side !== 'BUY') return;
  var budgetInp = $('[data-order-budget]');
  var qtyInp = $('[data-order-qty]');
  if(!budgetInp || !qtyInp) return;
  var budget = parseInt(budgetInp.value || '0', 10);
  var price = _resolveOrderPrice();
  if(budget > 0 && price > 0){
    qtyInp.value = Math.floor(budget / price);
    renderOrderInfo();
  }
}
function recalcBudgetFromQty(){
  if(state.side !== 'BUY') return;
  var budgetInp = $('[data-order-budget]');
  var qtyInp = $('[data-order-qty]');
  if(!budgetInp || !qtyInp) return;
  var qty = parseInt(qtyInp.value || '0', 10);
  var price = _resolveOrderPrice();
  if(qty > 0 && price > 0){
    budgetInp.value = qty * price;
  }
}
function renderQtyButtons(){
  var container = $('[data-qty-buttons]'); if(!container) return;
  var btns;
  if(state.side === 'BUY'){
    btns = [['clear','C'], ['-1','-1'], ['1','+1'], ['10','+10'], ['max','전부']];
  } else {
    btns = [['0%','0%'], ['25%','25%'], ['50%','50%'], ['75%','75%'], ['100%','100%']];
  }
  container.innerHTML = btns.map(function(b){
    return '<button type="button" data-qty-step="'+b[0]+'">'+b[1]+'</button>';
  }).join('');
}
function renderBook(book){
  state.lastBook = book;
  // 트레일링 미리보기는 현재가·52주 전고점을 이 응답에서 읽는다 — 호가 갱신마다 다시 그린다.
  var otNow = $('[data-order-type]');
  if(otNow && otNow.value === 'TRAILING_STOP') renderTrailPreview();
  if(book.name){ var ne = $('[data-order-name]'); if(ne) ne.textContent = book.name; state.name = book.name; }
  var ce = $('[data-order-cur-price]'); if(ce) ce.textContent = fmt(book.price)+'원';
  var che = $('[data-order-change]');
  if(che){
    var sign = book.change>0?'+':'';
    che.textContent = sign + fmt(book.change) + ' (' + (book.change_pct||0).toFixed(2) + '%)';
    che.classList.remove('up','down');
    if(book.change>0) che.classList.add('up'); else if(book.change<0) che.classList.add('down');
  }
  var adrEl = $('[data-order-adr]');
  if(adrEl){
    var a = book.adr;
    if(a && a.zone){
      var isOver = a.zone === '과매수';
      adrEl.className = 'order-adr-banner ' + (isOver ? 'adr-over' : 'adr-under');
      adrEl.textContent = (isOver?'🔴 ':'🔵 ') + a.label + ' ' + a.zone + ' 구간입니다 (20일 ADR ' + a.value + ')';
    } else {
      adrEl.className = 'order-adr-banner hidden';
      adrEl.textContent = '';
    }
  }
  var ob = $('[data-orderbook]'); if(!ob) return;
  var prevScroll = ob.scrollTop;   // 가격 변동에도 스크롤 고정
  var cur = book.price || 0;
  var asks = (book.asks||[]).slice().reverse();   // 높은 매도호가 위 → 최우선(낮은) 아래
  var bids = book.bids||[];
  var inBook = false;
  for(var t=0;t<asks.length;t++){ if(asks[t].price===cur) inBook=true; }
  for(var u=0;u<bids.length;u++){ if(bids[u].price===cur) inBook=true; }
  var specs = [];
  if(book.upper_limit > 0) specs.push(capRowSpec(book.upper_limit, 'up'));   // 맨 위 상한가 캡
  if(cur > 0 && !inBook){
    // 현재가가 20호가 어디에도 없음 → 현재가 중심 사다리로 재구성(잔량은 겹치는 호가에 표시).
    specs = specs.concat(buildCenteredSpecs(asks, bids, cur));
  } else {
    // 평소: 매도10 + 매수10 (=20). 현재가는 일치 호가에 하이라이트.
    for(var i2=0;i2<asks.length;i2++){ var a=asks[i2];
      specs.push({key:String(a.price), cls:'ob-row ask'+(a.price===cur?' ob-current':''), priceHtml:fmt(a.price), qty:fmt(a.qty)}); }
    for(var j2=0;j2<bids.length;j2++){ var b=bids[j2];
      specs.push({key:String(b.price), cls:'ob-row bid'+(b.price===cur?' ob-current':''), priceHtml:fmt(b.price), qty:fmt(b.qty)}); }
  }
  if(book.lower_limit > 0) specs.push(capRowSpec(book.lower_limit, 'down')); // 맨 아래 하한가 캡
  // 호가 구조(행/가격)가 같으면 바뀐 셀만 제자리 갱신 → 노드·스크롤 유지, 깜빡임 제거
  applyRows(ob, specs);
  syncMarketPriceInput(book);
  highlightSelectedTick(); updateMarketPhaseDisplay(); decorateAccounts();
  if(!state.bookCentered){
    var anchor = ob.querySelector('.ob-current') || ob.querySelector('.ob-row.bid');
    if(anchor){ ob.scrollTop = Math.max(0, anchor.offsetTop + anchor.offsetHeight/2 - ob.clientHeight/2); state.bookCentered = true; }
  } else { ob.scrollTop = prevScroll; }
}
function syncMarketPriceInput(book){
  var otSel = $('[data-order-type]');
  if(otSel && otSel.value === 'MARKET' && book.price){
    var inp = $('[data-order-price-input]');
    if(inp && parseInt(inp.value||'0',10) !== book.price) inp.value = book.price;
  }
}
// 한국 주식 호가단위 (2023 KRX 개정, KOSPI/KOSDAQ 통일)
function tickSize(p){
  if(p < 2000) return 1;
  if(p < 5000) return 5;
  if(p < 20000) return 10;
  if(p < 50000) return 50;
  if(p < 200000) return 100;
  if(p < 500000) return 500;
  return 1000;
}
function snapToTick(p){ if(!(p>0)) return p; var t = tickSize(p); return Math.round(p/t)*t; }
// 호가단위 내림 — 트레일링 손절선은 내림이 안전(더 빨리 발동). orders/trailing.floor_to_tick 과 동일.
function floorTick(p){ if(!(p>0)) return p; var t = tickSize(p); return p - (p % t); }
// 기술적 권장가(매수/매도 판단 보조). code·side(·평단) 바뀔 때만 fetch, keystroke엔 캐시 재사용.
function fetchAdvice(){
  var code = state.code; if(!code){ renderAdvice(null); return; }
  var side = state.side;
  var avg = (side === 'SELL' && state.lastCheck && state.lastCheck.avg_price) ? state.lastCheck.avg_price : 0;
  var key = code + ':' + side + ':' + avg;
  if(state.adviceKey === key){ if(state.advice) renderAdvice(state.advice); return; }
  state.adviceKey = key;
  var url = '/api/order/advice?code=' + encodeURIComponent(code) + '&side=' + side + (avg ? '&avg_price=' + avg : '');
  fetch(url).then(function(r){ return r.json(); }).then(function(d){
    if(state.code !== code || state.side !== side) return;  // 그새 종목·방향 바뀌면 무시
    state.advice = d.advice || null;
    renderAdvice(state.advice);
  }).catch(function(){ renderAdvice(null); });
}
function renderAdvice(a){
  var body = $a('[data-advice-body]');
  var titleEl = $a('[data-advice-title]');
  if(titleEl) titleEl.textContent = '💡 권장 ' + (state.side==='BUY'?'매수가':'매도가');
  if(!body) return;
  if(!a || !a.levels || !a.levels.length){
    body.innerHTML = '<div class="muted small">권장가 데이터 없음 (신규·ETF 등)</div>';
    return;
  }
  var cur = (state.lastBook && state.lastBook.price) || a.price || 0;
  var rsiTag = '';
  if(a.rsi != null){ var rz = a.rsi>=70?' 과열':(a.rsi<=30?' 과매도':''); rsiTag = 'RSI ' + a.rsi + rz + ' · '; }
  var rows = a.levels.map(function(l){
    var rel = cur>0 ? (l.price>cur?' <span class="advice-rel up">▲</span>':(l.price<cur?' <span class="advice-rel dn">▼</span>':'')) : '';
    var cls = 'advice-row' + (l.tap?' advice-tap':' advice-info');
    var attr = l.tap ? ' data-adv-price="' + l.price + '"' : '';
    var tapMark = l.tap ? '<span class="advice-tapmark">▶ 탭</span>' : '';
    return '<button type="button" class="' + cls + '"' + attr + '>' +
      '<span class="advice-lbl">' + l.label + '</span>' +
      '<span class="advice-px">' + fmt(l.price) + rel + '</span>' + tapMark + '</button>';
  }).join('');
  var hints = a.levels.map(function(l){
    return '<li><b>' + l.label + ' ' + fmt(l.price) + '</b> — ' + l.hint + '</li>';
  }).join('');
  body.innerHTML =
    '<div class="advice-sub">' + rsiTag + '현재가 ' + fmt(cur) + ' · 가격을 탭하면 단가에 채워져요</div>' +
    '<div class="advice-rows">' + rows + '</div>' +
    '<button type="button" class="advice-why" data-adv-why>▸ 근거 설명</button>' +
    '<ul class="advice-hints hidden" data-adv-hints>' + hints + '</ul>';
}
function openAdviceModal(){
  if(!adviceModal) return;
  renderAdvice(state.advice);  // 이미 로드된 값 즉시 렌더
  fetchAdvice();               // 미로드/오래됐으면 갱신 (key 동일하면 no-op)
  adviceModal.classList.remove('hidden');
  adviceModal.setAttribute('aria-hidden', 'false');
  document.body.classList.add('modal-open');
}
function closeAdviceModal(){
  if(!adviceModal) return;
  adviceModal.classList.add('hidden');
  adviceModal.setAttribute('aria-hidden', 'true');
  if(modal.classList.contains('hidden')) document.body.classList.remove('modal-open');
}
// 현재가가 호가에 없을 때: 현재가 중심 ±10틱 사다리(20행). 잔량은 겹치는 호가에만, 현재가 하이라이트.
function buildCenteredSpecs(asks, bids, cur){
  var qmap = {};
  for(var i=0;i<asks.length;i++) qmap[asks[i].price] = {q:asks[i].qty, side:'ask'};
  for(var j=0;j<bids.length;j++) qmap[bids[j].price] = {q:bids[j].qty, side:'bid'};
  var up = [], p = cur;
  for(var a=0;a<10;a++){ p += tickSize(p); up.push(p); }        // 현재가 위 10틱
  var dn = [], q = cur;
  for(var b=0;b<10;b++){ q -= tickSize(q>1?q-1:q); if(q<1) break; dn.push(q); }  // 현재가 아래 10틱
  var rows = up.reverse().concat([cur]).concat(dn);             // 높은가격 위 → 낮은가격 아래
  var specs = [];
  for(var r=0;r<rows.length;r++){
    var pr = rows[r], m = qmap[pr];
    var cls = pr===cur ? 'bid ob-current' : (m ? m.side : '');
    specs.push({ key:String(pr), cls:'ob-row '+cls, priceHtml:fmt(pr), qty: m ? fmt(m.q) : '' });
  }
  return specs;
}
function capRowSpec(price, dir){
  var mk = dir==='up' ? '<b class="ob-cap-mark up">상</b> ' : '<b class="ob-cap-mark down">하</b> ';
  var lab = dir==='up' ? '상한' : '하한';
  return { key:String(price), cls:'ob-row ob-cap ob-cap-'+dir, priceHtml: mk+fmt(price), qty: lab };
}
// 호가 행 반영. 구조(행수·가격열)가 직전과 같으면 바뀐 셀만 제자리 갱신(노드 유지 → 깜빡임·스크롤 튐 없음), 다르면 1회 전체 재생성.
function applyRows(ob, specs){
  var kids = ob.children;
  var same = kids.length === specs.length;
  if(same){
    for(var i=0;i<specs.length;i++){ if(kids[i].getAttribute('data-ob-price') !== specs[i].key){ same=false; break; } }
  }
  if(!same){
    var h='';
    for(var k=0;k<specs.length;k++){ var s=specs[k];
      h += '<div class="'+s.cls+'" data-ob-price="'+s.key+'"><span class="price">'+s.priceHtml+'</span><span class="qty">'+s.qty+'</span></div>'; }
    ob.innerHTML = h;
    return;
  }
  for(var j=0;j<specs.length;j++){
    var row=kids[j], sp=specs[j];
    if(row.className !== sp.cls) row.className = sp.cls;
    var pe=row.children[0], qe=row.children[1];
    if(pe && pe.innerHTML !== sp.priceHtml) pe.innerHTML = sp.priceHtml;
    if(qe && qe.textContent !== sp.qty) qe.textContent = sp.qty;
  }
}
function pollOnce(){
  if(document.hidden || !state.isOpen || !state.code) return;
  fetch('/api/quote_book?code=' + encodeURIComponent(state.code))
    .then(function(r){ return r.json(); })
    .then(renderBook).catch(function(){});
}
function startPolling(){ stopPolling(); pollOnce(); state.pollTimer = setInterval(pollOnce, 1000); }
function stopPolling(){ if(state.pollTimer){ clearInterval(state.pollTimer); state.pollTimer=null; } }
function updateCheck(){
  var acc = $('[data-order-account]'); if(!acc || !acc.value || !state.code) return;
  var account = acc.value;
  var price = parseInt(($('[data-order-price-input]')||{}).value || '0', 10);
  var orderType = (($('[data-order-type]')||{}).value || 'LIMIT');
  fetch('/api/order/check?code='+encodeURIComponent(state.code)+'&account='+encodeURIComponent(account)+'&side='+state.side+'&order_type='+orderType+'&price='+price)
    .then(function(r){ return r.json(); })
    .then(function(d){
      if(d.error){ state.lastCheck=null; var info=$('[data-order-info]'); if(info) info.textContent = d.error; return; }
      state.lastCheck = d;
      var maxEl = $('[data-order-max]');
      if(maxEl) maxEl.textContent = '(최대: ' + fmt(d.max_qty||0) + '주)';
      // 매도 전환 시 수량 자동 100% (max_qty) — 사용자가 직접 변경 후엔 안 덮어씀
      if(state.side === 'SELL' && state.pendingMaxOnSell && (d.max_qty||0) > 0){
        var qtyInp = $('[data-order-qty]');
        if(qtyInp){ qtyInp.value = d.max_qty; state.pendingMaxOnSell = false; }
      }
      // 매수 전환 시 수량 자동 max (매수가능액/단가) — budget도 동기화
      if(state.side === 'BUY' && state.pendingMaxOnBuy && (d.max_qty||0) > 0){
        var qtyInpB = $('[data-order-qty]');
        if(qtyInpB){ qtyInpB.value = d.max_qty; state.pendingMaxOnBuy = false; recalcBudgetFromQty(); }
      }
      renderOrderInfo();
      fetchAdvice();  // SELL 평단(avg_price) 확보 후 권장가 갱신 — key 동일하면 재fetch 안 함
    }).catch(function(){});
}
function renderOrderInfo(){
  var d = state.lastCheck; if(!d) return;
  var qty = parseInt(($('[data-order-qty]')||{}).value || '0', 10);
  var price = parseInt(($('[data-order-price-input]')||{}).value || '0', 10);
  var infoEl = $('[data-order-info]'); if(!infoEl) return;
  var amount = price * qty;
  var orderType = (($('[data-order-type]')||{}).value || 'LIMIT');
  if(state.side === 'BUY'){
    var avail = (typeof d.avail_cash === 'number') ? d.avail_cash : (d.d2_entra||0);
    var lines = [
      '매수가능액: <b>'+fmt(avail)+'</b>원'
    ];
    if((d.pending_buy_locked||0) > 0){
      lines.push('<span class="muted small">D+2 예수금 '+fmt(d.d2_entra||0)+'원 − 매수대기 '+fmt(d.pending_buy_locked)+'원</span>');
    }
    if(orderType === 'MARKET' && d.upper_limit){
      var needed = (d.upper_limit||0) * qty;
      var overCls = (needed > avail) ? ' class="qty-over"' : '';
      lines.push('필요증거금<span class="muted small"> (상한가 '+fmt(d.upper_limit)+'원 기준)</span>: <b'+overCls+'>'+fmt(needed)+'</b>원 ('+fmt(qty)+'주)');
    } else {
      lines.push('주문예상액: <b>'+fmt(amount)+'</b>원 ('+fmt(qty)+'주)');
    }
    infoEl.innerHTML = lines.join('<br>');
  } else {
    var avg = d.avg_price||0;
    var pl = (price - avg) * qty;
    var plCls = pl>=0?'pl-up':'pl-down';
    var plSign = pl>=0?'+':'';
    // 같은 소유자 그룹(본인 일반↔ISA / 가희끼리) 다른 계좌도 보유 중이면 현재계좌/전체 병기
    var selAcc = (($('[data-order-account]')||{}).value) || '';
    var sibAcc = selAcc ? siblingAccount(selAcc) : null;
    var sibHold = (sibAcc && state.accStatus && state.accStatus.byLabel && state.accStatus.byLabel[sibAcc]) ? (state.accStatus.byLabel[sibAcc].hold_qty||0) : 0;
    var holdPart = '보유: <b>'+fmt(d.hold_qty||0)+'</b>주';
    if(sibHold > 0) holdPart += ' / 전체 <b>'+fmt((d.hold_qty||0)+sibHold)+'</b>주';
    infoEl.innerHTML =
      holdPart+' (매도가능 '+fmt(d.trde_able_qty||0)+'주)<br>'+
      '평단가: <b>'+fmt(avg)+'</b>원<br>'+
      '예상수익: <span class="'+plCls+'">'+plSign+fmt(pl)+'원</span> ('+fmt(qty)+'주 매도)<br>'+
      '주문예상액: <b>'+fmt(amount)+'</b>원';
  }
  // 수량 입력 — 최대치 초과 시 글자색 빨강 (예수금/보유 부족 시각화)
  var qtyInp = $('[data-order-qty]');
  if(qtyInp){
    var maxq = parseInt((d.max_qty||0), 10);
    if(qty > maxq && qty > 0) qtyInp.classList.add('qty-over');
    else qtyInp.classList.remove('qty-over');
  }
}
function gotoQty(step){
  var inp = $('[data-order-qty]'); if(!inp) return;
  var max = parseInt((state.lastCheck && state.lastCheck.max_qty) || 0, 10);
  if(step === 'clear'){
    inp.value = '0';
  } else if(typeof step === 'string' && step.slice(-1) === '%'){
    var pct = parseInt(step.slice(0, -1), 10);
    inp.value = Math.floor(max * pct / 100);
  } else if(step === 'max'){
    inp.value = max;
  } else {
    var cur = parseInt(inp.value || '0', 10);
    var delta = parseInt(step, 10);
    inp.value = Math.max(0, cur + (isNaN(delta)?0:delta));
  }
  recalcBudgetFromQty();
  renderOrderInfo();
}
function refreshActiveCount(){
  fetch('/api/order/active').then(function(r){ return r.json(); }).then(function(d){
    state.lastActive = d;
    var el = $('[data-active-count]');
    if(el){
      var n = d.unique_count || 0;
      el.textContent = n > 0 ? ' (' + n + ')' : '';
    }
  }).catch(function(){});
}
function showProgressTab(){
  // [📋 진행중] 탭 — 활성 카드 있으면 PIN 모달, 없고 미체결만 있으면 open-orders 모달, 둘 다 없으면 토스트
  fetch('/api/order/active').then(function(r){ return r.json(); }).then(function(d){
    state.lastActive = d;
    var el = $('[data-active-count]');
    if(el){
      var n = d.unique_count || 0;
      el.textContent = n > 0 ? ' (' + n + ')' : '';
    }
    if(d.active){
      openPinModal({
        card_message: d.card_message || '',
        expiry_seconds: d.expiry_seconds || 0,
        symbol_name: d.symbol_name || ''
      });
      return;
    }
    if((d.open_orders_count || 0) > 0){
      openOpenOrdersModal();
      return;
    }
    showToast('진행 중인 거래 없음', 'info');
  }).catch(function(e){ showToast('진행중 거래 조회 실패: ' + e, 'error'); });
}
function openOpenOrdersModal(){
  if(!openOrdersModal) return;
  openOrdersModal.classList.remove('hidden');
  openOrdersModal.setAttribute('aria-hidden', 'false');
  document.body.classList.add('modal-open');
  loadOpenOrders();
}
function closeOpenOrdersModal(){
  if(!openOrdersModal) return;
  openOrdersModal.classList.add('hidden');
  openOrdersModal.setAttribute('aria-hidden', 'true');
  if(modal.classList.contains('hidden') && (!pinModal || pinModal.classList.contains('hidden'))) document.body.classList.remove('modal-open');
  refreshActiveCount();
}
function loadOpenOrders(){
  var list = openOrdersModal.querySelector('[data-open-orders-list]');
  if(!list) return;
  list.innerHTML = '<div class="loading" style="padding:24px 0;"><div class="loading-spin"></div>불러오는 중…</div>';
  fetch('/api/orders/open').then(function(r){ return r.json(); }).then(function(d){
    var rows = d.rows || [];
    if(!rows.length){
      list.innerHTML = '<div class="empty">미체결 주문 없음</div>';
      return;
    }
    list.innerHTML = rows.map(function(r){
      var sideCls = r.side === 'BUY' ? 'buy' : 'sell';
      var sideLabel = r.side === 'BUY' ? '매수' : '매도';
      // 주문유형을 항상 붙인다 — 가격만 쓰면 스톱지정가·트레일링이 지정가처럼 보인다.
      // 트레일링은 지정가가 방아쇠로 오해되지 않게 첫 줄에서 빼고 아래 줄에 조건가와 함께 쓴다.
      var priceLabel = r.trailing
        ? '매도 예약'
        : (r.order_price
            ? (r.order_price.toLocaleString('ko-KR') + '원' + (r.order_type ? ' ' + r.order_type : ''))
            : (r.order_type || '시장가'));
      var condLine = '';
      if(r.trailing){
        // 서버 렌더(_pending_detail_html)와 같은 정보·같은 순서. 모달은 폭이 넓어 한 줄 요약.
        var tb = [];
        if(r.stop_price){
          var head = fmt(r.stop_price) + '원 닿으면';
          if(r.order_price){
            // 갭은 틱수로 — 서버 _stop_gap_html 과 같은 규칙(안 나눠지면 원으로 폴백).
            var gap = r.stop_price - r.order_price;
            var tk = tickSize(r.stop_price);
            var gapTxt = '';
            if(gap > 0) gapTxt = (tk > 0 && gap % tk === 0)
              ? ' (−' + (gap / tk) + '틱)' : ' (−' + fmt(gap) + '원)';
            head += ' ' + fmt(r.order_price) + '원에 매도' + gapTxt;
          } else { head += ' 매도'; }
          tb.push(head);
        }
        // 고점에 기준 라벨을 붙이지 않는다 — 서버 렌더와 같은 이유(peak 는 갱신된 현재 고점).
        tb.push('고점 ' + fmt(r.trailing.peak) + '원 대비 −' + r.trailing.trail_pct + '%');
        // 계단 위치 — 미체결 목록엔 레그가 각각 뜨므로 "3단 중 2단계" 를 밝혀야 관계가 읽힌다.
        if((r.trailing.step_total || 0) > 1){
          var cum = r.trailing.cum || 0;
          tb.push(r.trailing.step_n + '단계/' + r.trailing.step_total + '단 (' +
                  (cum >= 100 ? '전량' : '누적 ' + cum + '%') + ')');
        }
        if(r.trailing.min_sell_price) tb.push('최저 ' + fmt(r.trailing.min_sell_price) + '원');
        if(r.cur_price && r.stop_price){
          // 손절선 기준 여유폭 — 분모는 손절선(라벨과 계산 기준을 일치시킴).
          tb.push('손절선 대비 ' + ((r.cur_price - r.stop_price) / r.stop_price * 100).toFixed(1) + '%');
        }
        // 상향 횟수는 표시하지 않는다(관리자님 지시). 얼마나 올랐는지만 보여준다.
        var mc = r.trailing.modify_count || 0;
        if(r.trailing.entry_peak && r.trailing.peak > r.trailing.entry_peak){
          tb.push('고점 상승 ' + fmt(r.trailing.entry_peak) + ' → ' + fmt(r.trailing.peak) +
                  '원 (+' + fmt(r.trailing.peak - r.trailing.entry_peak) + ')');
        }
        if(mc && r.trailing.entry_cond_uv && r.stop_price > r.trailing.entry_cond_uv){
          tb.push('손절선 상승 ' + fmt(r.trailing.entry_cond_uv) + ' → ' + fmt(r.stop_price) +
                  '원 (+' + fmt(r.stop_price - r.trailing.entry_cond_uv) + ')');
        }
        condLine = '<div class="row-line2" style="color:#d9a84a; overflow-wrap:anywhere;">' + tb.join(' · ') + '</div>';
      } else if(r.stop_price){
        condLine = '<div class="row-line2">' + fmt(r.stop_price) + '원 도달 시 매도 (고정)</div>';
      }
      var safeOrd = String(r.ord_no || '').replace(/"/g,'&quot;');
      var safeAcc = String(r.account || '').replace(/"/g,'&quot;');
      return (
        '<div class="open-order-row" data-ord-no="' + safeOrd + '" data-account="' + safeAcc + '">' +
          '<div class="row-info">' +
            '<div class="row-line1">' +
              '<span class="badge-side ' + sideCls + '">' + sideLabel + '</span>' +
              '<span>' + (r.name || r.code) + '</span>' +
              '<span style="color:#8b8f9a; font-weight:400; font-size:11px;">(' + r.code + ')</span>' +
            '</div>' +
            '<div class="row-line2">' +
              '[' + r.account + '] ' + r.order_qty + '주 @ ' + priceLabel +
              ' · 미체결 ' + r.unfilled_qty + '주 · ' + r.status +
              ' · ' + r.exchange + ' · ' + (r.order_time || '') +
            '</div>' +
            condLine +
          '</div>' +
          '<div class="row-action">' +
            '<button type="button" data-cancel-open-order>취소</button>' +
          '</div>' +
        '</div>'
      );
    }).join('');
  }).catch(function(e){
    list.innerHTML = '<div class="empty">조회 실패: ' + e + '</div>';
  });
}
function cancelOpenOrder(ordNo, account, btn){
  if(!confirm('주문 #' + ordNo + ' 취소하시겠습니까?')) return;
  if(btn){ btn.disabled = true; btn.textContent = '취소중…'; }
  var body = new URLSearchParams();
  body.set('ord_no', ordNo);
  body.set('account', account);
  fetch('/api/orders/cancel', {
    method: 'POST',
    headers: {'Content-Type':'application/x-www-form-urlencoded','Accept':'application/json'},
    body: body.toString()
  }).then(function(r){ return r.json(); })
    .then(function(d){
      if(d.ok){
        showToast(d.message || '✅ 취소 접수됨', 'success', 4000);
        loadOpenOrders();  // 모달 안 리스트 갱신
      } else {
        showToast(d.message || d.error || '취소 실패', 'error');
        if(btn){ btn.disabled = false; btn.textContent = '취소'; }
      }
    }).catch(function(e){
      showToast('취소 오류: ' + e, 'error');
      if(btn){ btn.disabled = false; btn.textContent = '취소'; }
    });
}
function fetchMarketPhase(){
  fetch('/api/market_state').then(function(r){ return r.json(); }).then(function(d){
    state.marketActive = !!d.active;
    state.marketPhase = d;
    updateMarketPhaseDisplay();
  }).catch(function(){});
}
function updateMarketPhaseDisplay(){
  var phase = state.marketPhase; if(!phase) return;
  var book = state.lastBook;
  var label = phase.label || '—';
  var cls = phase.phase || '';
  var canTrade = !!phase.active;
  // NXT 시간대 + 종목 nxt_enable=false → 거래 불가 알림
  if(phase.phase === 'nxt' && book && book.nxt_enable === false){
    label = '📵 NXT 거래불가';
    cls = 'closed';
    canTrade = false;
  }
  // 실시간 VI 발동 종목이면 모달 상태줄에 표시 (window.__rtState 는 realtime_script 가 채움)
  try {
    var _rt = window.__rtState;
    if(_rt && _rt.vi && state.code && _rt.vi[state.code] && _rt.vi[state.code].active){
      label = '⚡VI 발동 · ' + label;
    }
  } catch(e){}
  var el = $('[data-market-phase]');
  if(el){
    el.textContent = label + ' ' + (phase.time || '');
    el.className = 'order-market-phase ' + cls;
  }
  // 버튼 disabled 는 refreshSubmitGate 한 곳에서만 결정한다 — 여기서 직접 건드리면
  // 트레일링 게이트(입력값 기반)를 주기 호출이 덮어써 즉시발동 입력이 통과된다.
  state.phaseCanTrade = canTrade;
  state.phaseLabel = label;
  refreshSubmitGate();
}
// 트레일링 입력이 등록 불가 상태면 그 이유를 반환, 문제 없으면 빈 문자열.
// 즉시발동(손절선 ≥ 현재가)은 서버도 TRAIL_IMMEDIATE 로 거부하지만, 매도 버튼을 누른 뒤가
// 아니라 입력하는 시점에 막는다.
function trailBlockReason(){
  var otSel = $('[data-order-type]');
  if(!otSel || otSel.value !== 'TRAILING_STOP') return '';
  // 계단 검증은 서버 orders/trailing.normalize_steps 와 같은 규칙 — 순서·범위가 어긋나면
  // 서버가 TRAIL_STEPS 로 거부하므로, PIN 왕복 전에 여기서 같은 문구로 막는다.
  var steps = trailSteps();
  if(!steps.length) return '계단을 하나 이상 추가하세요';
  if(steps.length > 5) return '계단은 최대 5개입니다';
  var prevP = 0, prevC = 0;
  for(var i=0;i<steps.length;i++){
    var s = steps[i], n = i + 1;
    if(!(s.pct >= 0.5 && s.pct <= 30)) return n + '단계 하락률은 0.5~30% 범위로 입력하세요';
    if(s.pct <= prevP) return n + '단계 하락률이 앞 계단(' + prevP + '%)보다 깊어야 합니다';
    if(!(s.cum > 0 && s.cum <= 100)) return n + '단계 누적 비중은 1~100% 범위로 입력하세요';
    if(s.cum <= prevC) return n + '단계 누적 비중이 앞 계단(' + prevC + '%)보다 커야 합니다';
    prevP = s.pct; prevC = s.cum;
  }
  var cur = (state.lastCheck && state.lastCheck.cur_price) || (state.lastBook && state.lastBook.price) || 0;
  if(!(cur > 0)) return '현재가를 받는 중입니다';
  // 가장 얕은 계단이 제일 먼저 발동한다 — 이게 현재가 이상이면 등록 즉시 터진다.
  var minPrice = trailMinPrice();
  var r = trailCondPrice(cur, steps[0].pct, minPrice);   // 초기 고점 = 현재가
  if(r.cond >= cur){
    return r.floorApplied
      ? ('최저 매도가 ' + fmt(minPrice) + '원이 현재가 ' + fmt(cur) + '원보다 높아 즉시 발동됩니다')
      : ('1단계 손절선 ' + fmt(r.cond) + '원이 현재가 ' + fmt(cur) + '원 이상이라 즉시 발동됩니다');
  }
  var qty = parseInt(($('[data-order-qty]')||{}).value || '0', 10) || 0;
  if(qty > 0){
    var qs = allocStepQty(qty, trailWeights(steps));
    var any = false;
    qs.forEach(function(q){ if(q > 0) any = true; });
    if(!any) return fmt(qty) + '주로는 계단을 나눌 수 없습니다 (수량을 늘리거나 계단을 줄이세요)';
  }
  return '';
}
// 매도/매수 버튼 활성 여부의 단일 결정 지점. 시장 phase·트레일링 입력 게이트를 합친다.
function refreshSubmitGate(){
  var sb = $('[data-order-submit]'); if(!sb) return;
  if(sb.dataset.busy === '1') return;  // propose 진행 중 — 끝나고 다시 계산한다
  var phaseBlocked = (state.phaseCanTrade === false);
  var trailMsg = trailBlockReason();
  sb.disabled = phaseBlocked || !!trailMsg;
  sb.title = phaseBlocked ? ('매매 비활성: ' + (state.phaseLabel || '')) : (trailMsg || '');
}
function populateSymbolSelect(currentCode, currentName){
  var sel = $('[data-order-symbol-select]'); if(!sel) return;
  function render(items){
    // 현재 종목이 리스트에 없으면 최상단에 추가
    var has = items.some(function(it){ return it.code === currentCode; });
    var opts = [];
    if(currentCode && !has){
      opts.push({code: currentCode, name: currentName || currentCode, source: '현재'});
    }
    // 그룹별 분리 (보유/관심/감시 순)
    var groups = {'보유':[], '관심':[], '감시':[], '현재':[]};
    opts.concat(items).forEach(function(it){ (groups[it.source]||groups['관심']).push(it); });
    var html = '<option value="">종목 선택…</option>';
    // 종목코드 단위 통합 — 같은 종목이 여러 계좌(본인·가희)에 있어도 하나만. 매매 계좌는 별도 select.
    // 라벨은 종목명만 (owner·종목코드 제거). 중복 시 우선순위 그룹(현재>보유>관심>감시)만 노출.
    var seen = {};
    ['현재','보유','관심','감시'].forEach(function(g){
      if(!groups[g] || !groups[g].length) return;
      var rows = groups[g].filter(function(it){ if(seen[it.code]) return false; seen[it.code]=1; return true; });
      if(!rows.length) return;
      html += '<optgroup label="'+g+'">';
      rows.forEach(function(it){
        var label = (it.name||it.code);
        var selAttr = (it.code === currentCode) ? ' selected' : '';
        html += '<option value="'+it.code+'" data-name="'+(it.name||'').replace(/"/g,'&quot;')+'"'+selAttr+'>'+label+'</option>';
      });
      html += '</optgroup>';
    });
    sel.innerHTML = html;
  }
  if(state.symbolsCache){ render(state.symbolsCache); return; }
  fetch('/api/symbols/all').then(function(r){ return r.json(); }).then(function(d){
    state.symbolsCache = d.items || [];
    render(state.symbolsCache);
  }).catch(function(){
    render([]);
  });
}
function onSymbolChange(){
  var sel = $('[data-order-symbol-select]'); if(!sel) return;
  var opt = sel.selectedOptions[0]; if(!opt) return;
  var code = opt.value;
  if(!code || code === state.code) return;
  var name = opt.getAttribute('data-name') || opt.textContent.split(' [')[0].split(' (')[0];
  state.code = code;
  state.name = name;
  state.lastBook = null;
  state.lastCheck = null;
  state.bookCentered = false;
  state.advice = null; state.adviceKey = null; renderAdvice(null);
  // 모달 안 표시 갱신
  var ne = $('[data-order-name]'); if(ne) ne.textContent = name;
  var cd = $('[data-order-code-display]'); if(cd) cd.textContent = '('+code+')';
  // 호가창 클리어 + 새 종목 fetch
  var ob = $('[data-orderbook]');
  if(ob) ob.innerHTML = '<div class="loading" style="padding:30px 0;"><div class="loading-spin"></div>호가 로딩중…</div>';
  $('[data-order-price-input]').value = '';
  $('[data-order-qty]').value = '';
  var stopInp = $('[data-order-stop-input]'); if(stopInp) stopInp.value = '';
  // 종목 변경 시 스톱지정가는 해제 — 이전 종목 트리거가 새 종목에 잘못 적용 방지
  var otSelR = $('[data-order-type]'); if(otSelR && otSelR.value === 'STOP_LIMIT'){ otSelR.value = 'LIMIT'; refreshStopUI(); }
  var budgetInp = $('[data-order-budget]');
  if(budgetInp) budgetInp.value = '';
  // 종목 변경 시 새 max로 자동 채움 (SELL: 보유 100%, BUY: 매수가능 max)
  if(state.side === 'SELL') state.pendingMaxOnSell = true;
  else state.pendingMaxOnBuy = true;
  state.accStatus = null;
  decorateAccounts();
  fetchAccountStatus();
  pollOnce();
  updateCheck();
  fetchAdvice();
}
function fillAccounts(allowed){
  var sel = $('[data-order-account]'); if(!sel) return;
  sel.innerHTML = '';
  var list = ACCOUNTS;
  if(allowed && allowed.length){ list = ACCOUNTS.filter(function(a){ return allowed.indexOf(a.label) >= 0; }); }
  list.forEach(function(a){ var opt = document.createElement('option'); opt.value=a.label; opt.textContent=a.display; sel.appendChild(opt); });
}
// 계좌 옵션에 색 + 텍스트 병행 표시. 매수: 예수금<현재가(1주)면 붉은색·'⚠ 부족'. 매도: 매도가능 주수 표기, 0이면 붉은색.
// iOS Safari는 option 글자색 무시 → 텍스트(주수·부족)가 폰 fallback.
var ACC_DISPLAY = {}; ACCOUNTS.forEach(function(a){ ACC_DISPLAY[a.label] = a.display; });
function decorateAccounts(){
  var sel = $('[data-order-account]'); if(!sel) return;
  var st = state.accStatus;
  var price = (state.lastBook && state.lastBook.price) || (st && st.cur_price) || 0;
  Array.prototype.forEach.call(sel.options, function(opt){
    var base = ACC_DISPLAY[opt.value] || opt.value;
    if(!st || !st.byLabel || !st.byLabel[opt.value]){ opt.textContent = base; opt.style.color=''; return; }
    var a = st.byLabel[opt.value];
    if(state.side === 'SELL'){
      var q = a.trde_able_qty || 0;  // 매도가능 수량 (미체결 매도로 묶인 분 제외)
      opt.textContent = base + (q > 0 ? ' · ' + fmt(q) + '주' : ' · 미보유');
      opt.style.color = q > 0 ? '#f0f0f0' : '#ff6b75';
    } else {
      var avail = (typeof a.avail_cash === 'number') ? a.avail_cash : (a.d2_entra || 0);
      var ok = price > 0 ? (avail >= price) : (avail > 0);
      opt.textContent = base + (ok ? '' : ' · ⚠ 부족');
      opt.style.color = ok ? '#f0f0f0' : '#ff6b75';
    }
  });
}
function fetchAccountStatus(){
  if(!state.code){ state.accStatus = null; decorateAccounts(); return; }
  var code = state.code;
  fetch('/api/order/account_status?code=' + encodeURIComponent(code)).then(function(r){ return r.json(); }).then(function(d){
    if(state.code !== code) return;  // 그새 종목 바뀌면 무시
    var byLabel = {}; (d.accounts||[]).forEach(function(a){ byLabel[a.label] = a; });
    state.accStatus = { byLabel: byLabel, cur_price: d.cur_price || 0 };
    decorateAccounts();
  }).catch(function(){});
}
function reset(){
  setMsg('');
  $('[data-order-type]').value = 'LIMIT';
  var priceInput = $('[data-order-price-input]');
  if(priceInput){ priceInput.disabled = false; priceInput.value = ''; }
  $('[data-order-qty]').value = '';
  var budgetInput = $('[data-order-budget]');
  if(budgetInput) budgetInput.value = '';
  $('[data-order-max]').textContent = '(최대: —)';
  $('[data-order-info]').textContent = '—';
}
function gotoTick(direction){
  var book = state.lastBook;
  if(!book || !book.asks || !book.bids) return;
  var prices = [];
  for(var i=book.bids.length-1; i>=0; i--) prices.push(book.bids[i].price);
  for(var j=0; j<book.asks.length; j++) prices.push(book.asks[j].price);
  if(!prices.length) return;
  var inp = $('[data-order-price-input]');
  var cur = parseInt((inp||{}).value || '0', 10);
  if(!cur){
    inp.value = book.bids[0] ? book.bids[0].price : prices[0];
    updateCheck(); highlightSelectedTick(); return;
  }
  var idx = prices.indexOf(cur);
  if(idx < 0){
    var closestIdx = 0, closestDiff = Math.abs(prices[0] - cur);
    for(var k=1; k<prices.length; k++){
      var d = Math.abs(prices[k] - cur);
      if(d < closestDiff){ closestIdx = k; closestDiff = d; }
    }
    idx = closestIdx;
  }
  var newIdx = Math.max(0, Math.min(prices.length-1, idx + direction));
  inp.value = prices[newIdx];
  updateCheck();
  highlightSelectedTick();
  recalcQtyFromBudget();
}
function openModal(opts){
  opts = opts || {};
  reset();
  state.code = String(opts.code || '');
  state.name = String(opts.name || '');
  state.isOpen = true;
  state.bookCentered = false;
  var ne = $('[data-order-name]'); if(ne) ne.textContent = state.name || '—';
  var cd = $('[data-order-code-display]'); if(cd) cd.textContent = state.code ? '('+state.code+')' : '';
  fillAccounts(opts.accounts);
  if(opts.account){ var sel = $('[data-order-account]'); if(sel) sel.value = opts.account; }
  populateSymbolSelect(state.code, state.name);
  state.accStatus = null;
  fetchAccountStatus();
  setSide(opts.side === 'SELL' ? 'SELL' : 'BUY');
  if(opts.price){ $('[data-order-price-input]').value = opts.price; }
  modal.classList.remove('hidden');
  modal.setAttribute('aria-hidden', 'false');
  document.body.classList.add('modal-open');
  startPolling();
  updateCheck();
  fetchMarketPhase();
  refreshActiveCount();
}
function closeModal(){
  state.isOpen = false; stopPolling();
  if(adviceModal && !adviceModal.classList.contains('hidden')) closeAdviceModal();
  modal.classList.add('hidden');
  modal.setAttribute('aria-hidden', 'true');
  // pin-modal이 안 떠 있을 때만 body unlock
  if(!pinModal || pinModal.classList.contains('hidden')) document.body.classList.remove('modal-open');
}
// ── PIN 모달 ──
function resetPinModal(){
  if(!pinModal) return;
  var inStep = $p('[data-pin-input-step]'); if(inStep) inStep.classList.remove('hidden');
  var rStep = $p('[data-pin-result-step]'); if(rStep) rStep.classList.add('hidden');
  var pinInp = $p('[data-pin-input]'); if(pinInp){ pinInp.value=''; pinInp.disabled=false; }
  var verifyBtn = $p('[data-pin-verify]'); if(verifyBtn) verifyBtn.disabled=false;
  var cancelBtn = $p('[data-pin-cancel]'); if(cancelBtn) cancelBtn.disabled=false;
}
function openPinModal(opts){
  if(!pinModal) return;
  resetPinModal();
  var sym = $p('[data-pin-symbol]'); if(sym) sym.textContent = opts.symbol_name || '';
  var sum = $p('[data-pin-card-summary]'); if(sum) sum.textContent = (opts.card_message || '').replace(/\*/g, '');
  startCountdown(opts.expiry_seconds || 120);
  pinModal.classList.remove('hidden');
  pinModal.setAttribute('aria-hidden', 'false');
  document.body.classList.add('modal-open');
  setTimeout(function(){ var p=$p('[data-pin-input]'); if(p) p.focus(); }, 100);
}
function closePinModal(){
  if(!pinModal) return;
  // verify 안 하고 닫으면 활성 카드 자동 취소 (이미 consumed면 백엔드가 무동작)
  fetch('/api/order/cancel', {method:'POST', headers:{'Accept':'application/json'}, body:''}).catch(function(){});
  if(state.countdownTimer){ clearInterval(state.countdownTimer); state.countdownTimer=null; }
  pinModal.classList.add('hidden');
  pinModal.setAttribute('aria-hidden', 'true');
  if(modal.classList.contains('hidden')) document.body.classList.remove('modal-open');
}
function startCountdown(seconds){
  state.expiryAt = Date.now() + seconds*1000;
  var cd = $p('[data-pin-countdown]');
  function tick(){
    var rem = Math.max(0, Math.floor((state.expiryAt - Date.now())/1000));
    if(cd){
      var mm = Math.floor(rem/60);
      var ss = rem % 60;
      cd.textContent = '⏱ ' + (mm>0?(mm+'분 '):'') + ss + '초 남음';
      cd.classList.remove('warn','danger');
      if(rem <= 10) cd.classList.add('danger');
      else if(rem <= 30) cd.classList.add('warn');
    }
    if(rem <= 0){
      clearInterval(state.countdownTimer); state.countdownTimer=null;
      closePinModal();
      showToast('⏰ PIN 만료됨 — 카드 자동 취소', 'info');
    }
  }
  if(state.countdownTimer) clearInterval(state.countdownTimer);
  tick(); state.countdownTimer = setInterval(tick, 500);
}
function doPropose(){
  var account = $('[data-order-account]').value;
  var orderType = $('[data-order-type]').value;
  var qty = parseInt($('[data-order-qty]').value || '0', 10);
  var price = parseInt($('[data-order-price-input]').value || '0', 10);
  var stopPrice = parseInt(($('[data-order-stop-input]')||{}).value || '0', 10);
  var trailStepList = trailSteps();
  var trailMin = trailMinPrice();
  if(!account){ setMsg('계좌를 선택하세요', 'error'); return; }
  if(qty <= 0){ setMsg('수량을 입력하세요', 'error'); return; }
  if(orderType === 'LIMIT' && price <= 0){ setMsg('지정가는 단가가 필요합니다', 'error'); return; }
  if(orderType === 'TRAILING_STOP'){
    if(state.side !== 'SELL'){ setMsg('트레일링 스톱은 매도만 가능합니다', 'error'); return; }
    // 계단 검증·즉시발동 검사는 입력 시점에 trailBlockReason 이 하고, 걸리면 매도 버튼이
    // 비활성이라 여기 도달하지 않는다. 서버(TRAIL_STEPS/TRAIL_IMMEDIATE)가 최후 방어선.
  }
  if(orderType === 'STOP_LIMIT'){
    if(state.side !== 'SELL'){ setMsg('스톱지정가는 매도만 가능합니다', 'error'); return; }
    if(price <= 0){ setMsg('스톱지정가는 매도 단가가 필요합니다', 'error'); return; }
    if(stopPrice <= 0){ setMsg('조건단가(트리거 가격)를 입력하세요', 'error'); return; }
    var curP = (state.lastCheck && state.lastCheck.cur_price) || (state.lastBook && state.lastBook.price) || 0;
    if(curP && stopPrice >= curP){ setMsg('조건단가는 현재가(' + fmt(curP) + '원)보다 낮아야 합니다', 'error'); return; }
  }
  // 클라이언트 사전 차단 — 서버 왕복 없이 즉시 에러 (PIN 발급 중 토스트 깜빡임 방지)
  var lc = state.lastCheck;
  if(lc && typeof lc.max_qty === 'number' && lc.max_qty >= 0 && qty > lc.max_qty){
    if(state.side === 'BUY'){
      var basis = (orderType === 'MARKET' && lc.upper_limit) ? '상한가' : '단가';
      var ref = (orderType === 'MARKET' && lc.upper_limit) ? lc.upper_limit : price;
      var needed = ref * qty;
      var availC = (typeof lc.avail_cash === 'number') ? lc.avail_cash : (lc.d2_entra||0);
      setMsg('예수금 부족 — 필요 ' + needed.toLocaleString() + '원 (' + basis + ' ' + (ref||0).toLocaleString() + '원 × ' + qty + '주) / 가용 ' + availC.toLocaleString() + '원\n최대 ' + lc.max_qty + '주까지 매수 가능', 'error');
    } else {
      setMsg('보유 부족 — 매도 ' + qty + '주 / 매도가능 ' + lc.max_qty + '주', 'error');
    }
    return;
  }
  // 검증 통과 — 매수/매도 방향 최종 확인 팝업 (오타 방지). 확인 시에만 실제 매매로 진행.
  state.pendingOrder = {account: account, orderType: orderType, qty: qty, price: price,
                        stopPrice: stopPrice, trailSteps: trailStepList,
                        trailMinPrice: trailMin};
  openOrderConfirm();
}
// 방향 확인 후 실제 매매 진행 — 전량매도 sell-choice 분기 포함.
function doProposeConfirmed(){
  var po = state.pendingOrder; if(!po) return;
  var account = po.account, orderType = po.orderType, qty = po.qty, price = po.price;
  var lc = state.lastCheck;
  // 전량매도(입력 수량 = 매도가능 전량)이고 같은 소유자 그룹의 다른 계좌에도 같은 종목 보유
  // → 선택 팝업. 스톱지정가·트레일링은 단일 계좌 예약이라 2계좌 동시 매도 대상 아님.
  if(state.side === 'SELL' && orderType !== 'STOP_LIMIT' && orderType !== 'TRAILING_STOP'){
    var fullSell = lc && typeof lc.max_qty === 'number' && lc.max_qty > 0 && qty === lc.max_qty;
    var sib = siblingAccount(account);
    var sibInfo = (sib && state.accStatus && state.accStatus.byLabel) ? state.accStatus.byLabel[sib] : null;
    var sibQty = sibInfo ? (sibInfo.trde_able_qty || 0) : 0;
    if(fullSell && sibQty > 0){
      openSellChoice({account: account, qty: qty, sibling: sib, siblingQty: sibQty, orderType: orderType, price: price});
      return;
    }
  }
  proposeSingle(account, orderType, qty, price, po.stopPrice, po.trailSteps, po.trailMinPrice);
}
// ── 매수/매도 방향 확인 모달 ──
// 매수·매도 버튼을 둘 다 띄우고 관리자님이 직접 맞는 방향을 누르게 함(위치 랜덤).
// 주문 방향(state.side)과 일치하면 진행, 다르면 팝업 닫고 취소.
function $oc(sel){ return orderConfirmModal ? orderConfirmModal.querySelector(sel) : null; }
function openOrderConfirm(){
  if(!orderConfirmModal){ doProposeConfirmed(); return; }
  var po = state.pendingOrder; if(!po) return;
  var disp = {}; ACCOUNTS.forEach(function(a){ disp[a.label] = a.display; });
  var isStop = (po.orderType === 'STOP_LIMIT');
  var isTrail = (po.orderType === 'TRAILING_STOP');
  var typeStr = (po.orderType === 'MARKET') ? '시장가'
              : isTrail ? '트레일링 스톱(예약)'
              : isStop ? '스톱지정가(예약)' : '지정가';
  var curNow = (state.lastCheck && state.lastCheck.cur_price) || (state.lastBook && state.lastBook.price) || 0;
  // 트레일링은 단가를 서버가 계산 — 확인 팝업엔 예상 손절선을 보여준다.
  var trailPeak = isTrail ? curNow : 0;   // 초기 고점 = 등록 시점 현재가
  var trailStepsPo = (po.trailSteps || []);
  var trailRes = (isTrail && trailStepsPo.length)
    ? trailCondPrice(trailPeak, trailStepsPo[0].pct, po.trailMinPrice)
    : {cond: 0, floorApplied: false};
  var trailCond = trailRes.cond;   // 1단계(가장 먼저 발동할 계단) 손절선
  var priceStr = (po.orderType === 'MARKET') ? '시장가' : (fmt(po.price) + '원');
  var sum = $oc('[data-order-confirm-summary]');
  if(sum){
    // 매도 예상 수수료(수수료+증권거래세) — 표시용 근사. 요율은 sim/config.py와 동일.
    var feeLine = '';
    if(state.side === 'SELL'){
      var refPrice = (po.orderType === 'MARKET') ? curNow : (isTrail ? trailCond : po.price);
      var amount = refPrice * po.qty;
      var fee = Math.round(amount * (0.00015 + 0.0018));  // 수수료 0.015% + 거래세 0.18%
      if(amount > 0){
        feeLine = '· 예상 수수료: <b>' + fmt(fee) + '원</b> <span class="muted small">(체결 시 확정)</span><br>';
      }
    }
    var stopLine = isStop ? ('· 조건단가: <b>' + fmt(po.stopPrice) + '원 도달 시</b> <span class="muted small">(예약)</span><br>') : '';
    var priceLine;
    if(isTrail){
      var poQtys = allocStepQty(po.qty, trailWeights(trailStepsPo));
      var stepLines = trailStepsPo.map(function(s, i){
        var r = trailCondPrice(trailPeak, s.pct, po.trailMinPrice);
        return '&nbsp;&nbsp;' + (i+1) + '단계 −' + s.pct + '% <b>' + fmt(r.cond) + '원</b> → ' +
          fmt(poQtys[i]) + '주 ' +
          '<span class="muted small">(' + (s.cum >= 100 ? '전량' : '누적 ' + s.cum + '%') + ')</span><br>';
      }).join('');
      priceLine = '· 시작 고점: <b>' + fmt(trailPeak) + '원</b> <span class="muted small">(등록 시점 현재가)</span><br>' +
        (po.trailMinPrice > 0 ? ('· 최저 매도가: <b>' + fmt(po.trailMinPrice) + '원</b> <span class="muted small">(' +
          (trailRes.floorApplied ? '최저가 적용' : '트레일 적용') + ')</span><br>') : '') +
        '· 매도 계단 <b>' + trailStepsPo.length + '단</b> <span class="muted small">(현재가 ' + fmt(curNow) + '원)</span><br>' +
        stepLines +
        '· <span class="muted small">고점이 오르면 계단이 통째로 따라 올라갑니다</span><br>';
    } else {
      priceLine = '· ' + (isStop ? '매도 단가' : '단가') + ': <b>' + priceStr + '</b><br>';
    }
    // 방향은 일부러 숨김 — 관리자님이 직접 고르게 해야 오타가 걸러짐.
    sum.innerHTML = '<b>' + (state.name || state.code) + '</b><br>' +
      '· 계좌: <b>' + (disp[po.account]||po.account) + '</b><br>' +
      '· 유형: <b>' + typeStr + '</b><br>' +
      stopLine +
      priceLine +
      '· 수량: <b>' + fmt(po.qty) + '주</b><br>' +
      feeLine + '<br>' +
      '<b>매수 / 매도</b> 중 하려던 것을 눌러주세요.';
  }
  // 매수·매도 버튼 위치를 랜덤으로 섞어 습관적 오탭 방지 (CSS order)
  var buyBtn = $oc('[data-order-confirm-side="BUY"]');
  var sellBtn = $oc('[data-order-confirm-side="SELL"]');
  if(buyBtn && sellBtn){
    var buyFirst = Math.random() < 0.5;
    buyBtn.style.order = buyFirst ? '1' : '2';
    sellBtn.style.order = buyFirst ? '2' : '1';
  }
  orderConfirmModal.classList.remove('hidden');
  orderConfirmModal.setAttribute('aria-hidden', 'false');
}
function closeOrderConfirm(){
  if(!orderConfirmModal) return;
  orderConfirmModal.classList.add('hidden');
  orderConfirmModal.setAttribute('aria-hidden', 'true');
  if(modal.classList.contains('hidden') && (!pinModal || pinModal.classList.contains('hidden'))) document.body.classList.remove('modal-open');
}
function siblingAccount(label){
  var me = ACCOUNTS.find(function(a){ return a.label === label; });
  if(!me) return null;
  var sib = ACCOUNTS.find(function(a){ return a.owner === me.owner && a.label !== label; });
  return sib ? sib.label : null;
}
function proposeSingle(account, orderType, qty, price, stopPrice, trailStepList, trailMinPrice){
  var body = new URLSearchParams();
  body.set('account', account);
  body.set('side', state.side);
  body.set('symbol', state.code);
  body.set('symbol_name', state.name);
  body.set('qty', String(qty));
  body.set('order_type', orderType);
  if(orderType === 'LIMIT' || orderType === 'STOP_LIMIT') body.set('price', String(price));
  if(orderType === 'STOP_LIMIT') body.set('stop_price', String(stopPrice || 0));
  // 트레일링은 계단 정의(하락률·누적 비중)와 최저 매도가만 보낸다 —
  // 계단별 조건단가·지정가·주수는 서버가 현재가·보유수량 기준으로 계산한다.
  if(orderType === 'TRAILING_STOP'){
    // 승인 팝업에서 확정된 계단을 그대로 보낸다 — 팝업이 떠 있는 사이 편집기를 건드려도
    // 관리자님이 확인한 내용과 발주가 어긋나지 않는다.
    body.set('trail_steps', JSON.stringify(trailStepList || []));
    if(trailMinPrice > 0) body.set('min_sell_price', String(trailMinPrice));
  }
  proposeAndOpenPin('/api/order/propose', body);
}
function doProposeMulti(opts){
  var body = new URLSearchParams();
  body.set('side', 'SELL');
  body.set('symbol', state.code);
  body.set('symbol_name', state.name);
  body.set('accounts', opts.account + ',' + opts.sibling);
  body.set('qtys', String(opts.qty) + ',' + String(opts.siblingQty));
  body.set('order_type', opts.orderType);
  if(opts.orderType === 'LIMIT') body.set('price', String(opts.price));
  proposeAndOpenPin('/api/order/propose_multi', body);
}
function proposeAndOpenPin(url, body){
  setMsg('주문 검증 중…', 'info');
  // busy 플래그로 잠근다 — 주기 호출(updateMarketPhaseDisplay)이 진행 중에 버튼을 되살리지 못하게.
  var btn = $('[data-order-submit]'); if(btn){ btn.dataset.busy = '1'; btn.disabled = true; }
  // 이전 활성 카드 자동 정리 후 새 propose — "이전 카드가 아직 활성" 거부 방지
  fetch('/api/order/cancel', {method:'POST', headers:{'Accept':'application/json'}, body:''})
    .catch(function(){})
    .then(function(){
      return fetch(url, {
        method: 'POST',
        headers: {'Content-Type':'application/x-www-form-urlencoded','Accept':'application/json'},
        body: body.toString()
      });
    })
    .then(function(r){ return r.json(); })
    .then(function(d){
      if(btn) btn.dataset.busy = '0';
      refreshSubmitGate();
      if(!d.ok){ setMsg(d.message || d.error || 'PIN 발급 실패', 'error'); return; }
      setMsg('');
      // 별도 PIN 모달 띄움 (order-modal은 그대로 뒤에 남음)
      openPinModal({
        card_message: d.card_message || '',
        expiry_seconds: d.expiry_seconds || 120,
        symbol_name: state.name
      });
    }).catch(function(e){
      if(btn) btn.dataset.busy = '0';
      refreshSubmitGate();
      setMsg('네트워크 오류: ' + e, 'error');
    });
}
// ── 매도 계좌 선택 모달 (두 계좌 모두 / 선택 계좌만 / 취소) ──
function $sc(sel){ return sellChoiceModal ? sellChoiceModal.querySelector(sel) : null; }
function openSellChoice(opts){
  if(!sellChoiceModal){ proposeSingle(opts.account, opts.orderType, opts.qty, opts.price); return; }
  state.sellChoice = opts;
  var disp = {}; ACCOUNTS.forEach(function(a){ disp[a.label] = a.display; });
  var priceStr = (opts.orderType === 'MARKET') ? '시장가' : (fmt(opts.price) + '원');
  var sum = $sc('[data-sell-choice-summary]');
  if(sum){
    sum.innerHTML = '<b>' + (state.name || state.code) + '</b> 종목을 <b>' + (disp[opts.account]||opts.account) +
      '</b> · <b>' + (disp[opts.sibling]||opts.sibling) + '</b> 두 계좌 모두 보유하고 있습니다.<br><br>' +
      '· ' + (disp[opts.account]||opts.account) + ': <b>' + fmt(opts.qty) + '주</b> 매도 (매도가능 전량)<br>' +
      '· ' + (disp[opts.sibling]||opts.sibling) + ': <b>' + fmt(opts.siblingQty) + '주</b> 매도 (매도가능 전량)<br>' +
      '· 단가: ' + priceStr + ' (두 계좌 동일)';
  }
  var sb = $sc('[data-sell-choice-single]');
  if(sb) sb.textContent = (disp[opts.account]||opts.account) + '만 매도';
  sellChoiceModal.classList.remove('hidden');
  sellChoiceModal.setAttribute('aria-hidden', 'false');
  document.body.classList.add('modal-open');
}
function closeSellChoice(){
  if(!sellChoiceModal) return;
  sellChoiceModal.classList.add('hidden');
  sellChoiceModal.setAttribute('aria-hidden', 'true');
  if(modal.classList.contains('hidden') && (!pinModal || pinModal.classList.contains('hidden'))) document.body.classList.remove('modal-open');
}
function doVerify(){
  var pinInp = $p('[data-pin-input]');
  var pin = (pinInp && pinInp.value || '').trim();
  if(!pin){ showToast('PIN을 입력하세요', 'error'); return; }
  var body = new URLSearchParams(); body.set('pin', pin);
  var btn = $p('[data-pin-verify]'); if(btn) btn.disabled = true;
  var cancelBtn = $p('[data-pin-cancel]'); if(cancelBtn) cancelBtn.disabled = true;
  fetch('/api/order/verify', {
    method: 'POST',
    headers: {'Content-Type':'application/x-www-form-urlencoded','Accept':'application/json'},
    body: body.toString()
  }).then(function(r){ return r.json(); })
    .then(function(d){
      if(state.countdownTimer){ clearInterval(state.countdownTimer); state.countdownTimer=null; }
      if(d.ok){
        // 매매등록 성공 — PIN 모달 자동 닫기 + 토스트로 결과 알림
        showToast(d.message || '✅ 주문 접수됨', 'success', 5000);
        closePinModal();
        return;
      }
      // 실패 — 모달 안 결과 step swap
      var resultEl = $p('[data-pin-result]');
      if(resultEl){
        resultEl.textContent = d.message || d.error || '주문 실패';
        resultEl.classList.remove('info','success');
        resultEl.classList.add('error');
      }
      var inStep = $p('[data-pin-input-step]'); if(inStep) inStep.classList.add('hidden');
      var rStep = $p('[data-pin-result-step]'); if(rStep) rStep.classList.remove('hidden');
    }).catch(function(e){
      if(btn) btn.disabled = false;
      if(cancelBtn) cancelBtn.disabled = false;
      showToast('네트워크 오류: ' + e, 'error');
    });
}
// 주문 모달 click 핸들러
modal.addEventListener('click', function(e){
  var t = e.target;
  if(t.getAttribute && t.getAttribute('data-modal-close')==='1'){ closeModal(); return; }
  if(t.classList && t.classList.contains('modal-close')){ closeModal(); return; }
  var infoBtn = t.closest && t.closest('[data-show-active]');
  if(infoBtn){ showProgressTab(); return; }
  var sideBtn = t.closest && t.closest('.order-side-toggle button[data-side]');
  if(sideBtn){ setSide(sideBtn.getAttribute('data-side')); return; }
  if(t.matches && t.matches('[data-order-submit]')){ doPropose(); return; }
  var tickBtn = t.closest && t.closest('[data-tick-dir]');
  if(tickBtn){
    var dir = parseInt(tickBtn.getAttribute('data-tick-dir') || '0', 10);
    gotoTick(dir); return;
  }
  var qtyBtn = t.closest && t.closest('[data-qty-step]');
  if(qtyBtn){ gotoQty(qtyBtn.getAttribute('data-qty-step')); return; }
  var advOpen = t.closest && t.closest('[data-advice-open]');
  if(advOpen){ openAdviceModal(); return; }
  var obRow = t.closest && t.closest('.ob-row');
  if(obRow){
    var p = parseInt(obRow.getAttribute('data-ob-price') || '0', 10);
    if(p > 0){ var inp = $('[data-order-price-input]'); if(inp && !inp.disabled){ inp.value = p; updateCheck(); highlightSelectedTick(); recalcQtyFromBudget(); } }
  }
});
// open-orders 모달 click 핸들러
if(openOrdersModal) openOrdersModal.addEventListener('click', function(e){
  var t = e.target;
  if(t.getAttribute && t.getAttribute('data-modal-close')==='1'){ closeOpenOrdersModal(); return; }
  if(t.classList && t.classList.contains('modal-close')){ closeOpenOrdersModal(); return; }
  var refBtn = t.closest && t.closest('[data-open-orders-refresh]');
  if(refBtn){ loadOpenOrders(); return; }
  var cancelBtn = t.closest && t.closest('[data-cancel-open-order]');
  if(cancelBtn){
    var row = cancelBtn.closest('.open-order-row');
    if(!row) return;
    cancelOpenOrder(row.getAttribute('data-ord-no'), row.getAttribute('data-account'), cancelBtn);
    return;
  }
});
// PIN 모달 click 핸들러
if(pinModal) pinModal.addEventListener('click', function(e){
  var t = e.target;
  if(t.getAttribute && t.getAttribute('data-modal-close')==='1'){ closePinModal(); return; }
  if(t.classList && t.classList.contains('modal-close')){ closePinModal(); return; }
  if(t.matches && t.matches('[data-pin-verify]')){ doVerify(); return; }
});
// 권장가 팝업 click 핸들러 — 가격 탭 시 단가 채우고 팝업 닫기, 근거 토글
if(adviceModal) adviceModal.addEventListener('click', function(e){
  var t = e.target;
  if(t.getAttribute && t.getAttribute('data-modal-close')==='1'){ closeAdviceModal(); return; }
  if(t.classList && t.classList.contains('modal-close')){ closeAdviceModal(); return; }
  var advWhy = t.closest && t.closest('[data-adv-why]');
  if(advWhy){
    var hints = $a('[data-adv-hints]');
    if(hints){ hints.classList.toggle('hidden'); advWhy.textContent = hints.classList.contains('hidden') ? '▸ 근거 설명' : '▾ 근거 설명'; }
    return;
  }
  var advTap = t.closest && t.closest('.advice-tap');
  if(advTap){
    var ap = parseInt(advTap.getAttribute('data-adv-price') || '0', 10);
    if(ap > 0){
      // 권장가는 특정 단가 → 지정가로 채움 (시장가면 LIMIT로 전환해 입력칸 활성화)
      var typeSel = $('[data-order-type]');
      if(typeSel && typeSel.value !== 'LIMIT'){ typeSel.value = 'LIMIT'; typeSel.dispatchEvent(new Event('change')); }
      var inp = $('[data-order-price-input]');
      if(inp){ inp.disabled = false; inp.value = snapToTick(ap); updateCheck(); highlightSelectedTick(); recalcQtyFromBudget(); }
    }
    closeAdviceModal();
    return;
  }
});
// 매도 계좌 선택 모달 click 핸들러
if(sellChoiceModal) sellChoiceModal.addEventListener('click', function(e){
  var t = e.target;
  if(t.getAttribute && t.getAttribute('data-modal-close')==='1'){ closeSellChoice(); return; }
  var opts = state.sellChoice;
  if(t.matches && t.matches('[data-sell-choice-both]')){
    closeSellChoice();
    if(opts) doProposeMulti(opts);
    return;
  }
  if(t.matches && t.matches('[data-sell-choice-single]')){
    closeSellChoice();
    if(opts) proposeSingle(opts.account, opts.orderType, opts.qty, opts.price);
    return;
  }
});
// 매수/매도 방향 확인 모달 click 핸들러
if(orderConfirmModal) orderConfirmModal.addEventListener('click', function(e){
  var t = e.target;
  if(t.getAttribute && t.getAttribute('data-modal-close')==='1'){ closeOrderConfirm(); return; }
  var sideBtn = t.closest && t.closest('[data-order-confirm-side]');
  if(sideBtn){
    var picked = sideBtn.getAttribute('data-order-confirm-side');
    closeOrderConfirm();
    if(picked === state.side){
      doProposeConfirmed();
    } else {
      // 방향 불일치 — 오타로 판단, 매매 취소
      showToast('⚠️ 방향이 주문과 달라 취소했어요. 다시 확인해 주세요.', 'error', 4500);
    }
    return;
  }
});
var accSel = $('[data-order-account]'); if(accSel) accSel.addEventListener('change', updateCheck);
var symSel = $('[data-order-symbol-select]'); if(symSel) symSel.addEventListener('change', onSymbolChange);
var otSel = $('[data-order-type]');
if(otSel) otSel.addEventListener('change', function(){
  // 스톱지정가는 매도 전용 — 선택 시 매도로 전환 (setSide가 refreshStopUI+updateCheck 수행)
  if(otSel.value === 'STOP_LIMIT' && state.side !== 'SELL'){ setSide('SELL'); return; }
  var isMarket = (otSel.value === 'MARKET');
  var inp = $('[data-order-price-input]');
  if(inp) inp.disabled = isMarket;
  $$('[data-tick-dir]').forEach(function(b){ b.disabled = isMarket; });
  refreshStopUI();
  // 주문유형 토글 시 max_qty 기준이 바뀜 (MARKET=상한가, LIMIT=단가) — 재조회
  updateCheck();
});
var priceInpEl = $('[data-order-price-input]');
if(priceInpEl) priceInpEl.addEventListener('input', function(){ updateCheck(); highlightSelectedTick(); recalcQtyFromBudget(); });
var qtyInpEl = $('[data-order-qty]');
if(qtyInpEl) qtyInpEl.addEventListener('input', function(){
  recalcBudgetFromQty(); renderOrderInfo();
  // 계단별 주수는 수량에서 나온다 — 수량이 바뀌면 미리보기도 다시 그린다.
  var ot = $('[data-order-type]');
  if(ot && ot.value === 'TRAILING_STOP') renderTrailPreview();
});
var budgetInpEl = $('[data-order-budget]');
if(budgetInpEl) budgetInpEl.addEventListener('input', recalcQtyFromBudget);
// 프리셋은 select — change 로 잡는다(select 의 정식 이벤트).
var trailPresetEl = $('[data-trail-preset]');
if(trailPresetEl) trailPresetEl.addEventListener('change', applyTrailPreset);
var trailMinEl = $('[data-order-trail-min]');
if(trailMinEl) trailMinEl.addEventListener('input', renderTrailPreview);
// 계단 입력·삭제는 행이 동적으로 다시 그려지므로 컨테이너에 위임한다.
var trailStepsEl = $('[data-trail-steps]');
if(trailStepsEl){
  trailStepsEl.addEventListener('input', function(e){
    if(e.target.matches('[data-ts-pct],[data-ts-cum]')) renderTrailPreview();
  });
  trailStepsEl.addEventListener('click', function(e){
    var b = e.target.closest('[data-ts-del]'); if(!b) return;
    var steps = trailSteps();
    steps.splice(parseInt(b.getAttribute('data-ts-del'), 10), 1);
    renderTrailStepRows(steps);
    renderTrailPreview();
  });
}
var trailAddEl = $('[data-trail-add]');
if(trailAddEl) trailAddEl.addEventListener('click', function(){
  var steps = trailSteps();
  if(steps.length >= 5){ showToast('계단은 최대 5개입니다', 'error'); return; }
  // 새 계단은 마지막보다 5%p 깊게·누적 100% 로 — 대개 "마지막에 전량" 을 덧붙이는 흐름이다.
  var last = steps.length ? steps[steps.length-1] : null;
  steps.push({pct: Math.min(30, last ? last.pct + 5 : 10), cum: 100});
  renderTrailStepRows(steps);
  renderTrailPreview();
});
document.addEventListener('visibilitychange', function(){
  if(document.hidden) stopPolling();
  else if(state.isOpen) startPolling();
});
// 보유종목·관심·감시종목 행의 .btn-order + 자산보기 [💰 거래] sub-tab 버튼 — panels swap 대응 위임
document.addEventListener('click', function(e){
  // [💰 거래] sub-tab 버튼 — 본인 계좌 첫 보유종목 자동 선택 후 모달 열기
  var subOrderBtn = e.target.closest && e.target.closest('[data-open-order-modal]');
  if(subOrderBtn){
    function openWithFirst(items){
      var first = (items || []).find(function(it){ return it.source === '보유' && it.owner === '본인'; });
      // 보유종목을 자동선택하되 항상 매수 기본(오타 방지). 매도는 모달에서 토글. 보유분 없으면 빈 모달.
      if(first){ openModal({code: first.code, name: first.name, side: 'BUY'}); }
      else { openModal({code: '', name: ''}); }
    }
    if(state.symbolsCache){ openWithFirst(state.symbolsCache); }
    else {
      fetch('/api/symbols/all').then(function(r){ return r.json(); }).then(function(d){
        state.symbolsCache = d.items || [];
        openWithFirst(state.symbolsCache);
      }).catch(function(){ openModal({code: '', name: ''}); });
    }
    return;
  }
  // 보유/관심/감시 행의 거래 버튼 — 종목·side 자동 설정
  var btn = e.target.closest && e.target.closest('.btn-order');
  if(!btn) return;
  var code = btn.getAttribute('data-order-code') || '';
  var stock = btn.getAttribute('data-order-stock') || '';
  var side = btn.getAttribute('data-order-side') || 'BUY';
  if(code) openModal({code: code, name: stock, side: side});
});
window.openOrderModal = openModal;
window.closeOrderModal = closeModal;
window.openPinModal = openPinModal;
})();</script>'''

    # 실시간 시세 갱신 — /api/realtime/quotes(허브 메모리) 1초 폴링으로 행 가격·VI 배지를 라이브 갱신.
    # 허브 콜드/끊김이면 응답이 비어 아무것도 안 함(REST로 그려진 값 유지). visibility 가드.
    realtime_script = r'''<script>(function(){
  function codesOnPage(){
    var s={};
    document.querySelectorAll('details.row[data-row-key]').forEach(function(r){
      // ':held'·':held:계좌'·':pending-*' 접미사 제거 → 보유행 코드도 실시간/VI 대상에 포함.
      var k=(r.getAttribute('data-row-key')||'').split(':')[0];
      // 숫자+영문 6자리 — 0193W0·0154F0 등 영문 섞인 ETF/ETN 코드까지 포함. 종목명(한글) 키는 자동 제외.
      if(/^[0-9A-Z]{6}$/.test(k)) s[k]=1;
    });
    return Object.keys(s);
  }
  // 종목코드로 행 찾기 — 같은 코드가 바 키(005930·당일매매·관심)와 접미사 키(005930:held·:held:계좌)로 동시 존재.
  // 정확매칭만 하면 메인 보유목록(:held)을 놓치므로, 바 키 + "code:" 접두 키를 함께 매칭.
  function selByCode(code){ return 'details.row[data-row-key="'+code+'"],details.row[data-row-key^="'+code+':"]'; }
  function paintPrice(code,q){
    // 현재가 숫자 span(.rt-px)만 갱신 — 등락률·매매표시·당일정산(실현손익) 셀은 .rt-px가 없어 자동 제외.
    var t=q.price.toLocaleString();
    var pctstr=(q.pct>=0?'+':'')+q.pct.toFixed(2)+'%';
    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가 따로 처리).
      r.querySelectorAll('.rt-wl-daypct').forEach(function(dp){ dp.textContent=pctstr; setUD(dp,q.pct); });  // summary+심플정보 팝업 사본 모두 갱신
    });
  }
  function paintVI(code,active){
    document.querySelectorAll(selByCode(code)).forEach(function(r){
      var l1=r.querySelector('.line1'); if(!l1) return;
      var b=l1.querySelector('.rt-vi-badge');
      if(active && !b){ b=document.createElement('span'); b.className='rt-vi-badge'; b.textContent='⚡VI'; l1.appendChild(b); }
      else if(!active && b){ b.remove(); }
    });
  }
  function sgn(n){ return (n>=0?'+':'')+Math.round(n).toLocaleString(); }  // 음수는 toLocaleString이 '-' 포함
  function setUD(el,v){ el.classList.remove('up','down','neutral'); el.classList.add(v>0?'up':(v<0?'down':'neutral')); }
  function rowCode(r){ return (r.getAttribute('data-row-key')||'').replace(/:.*$/,''); }
  // 보유행 평가손익/평가금액 = 현재가×수량 − 매입금액(정밀). 서버 손익 공식과 동일.
  // data-rt-qty 있는 모든 사본(요약·당일매매·계좌별)에 적용.
  function paintProfit(code,price){
    document.querySelectorAll(selByCode(code)).forEach(function(r){
      var qty=parseFloat(r.getAttribute('data-rt-qty')||'0');
      var cost=parseFloat(r.getAttribute('data-rt-cost')||'0');
      if(!qty||!cost) return;  // 보유행 아닌 사본(관심·감시)은 data-rt-qty/cost 없어 자동 스킵
      var ev=price*qty, profit=ev-cost, rate=profit/cost*100;
      var pf=r.querySelector('.rt-profit'); if(pf) pf.textContent=sgn(profit);
      var pp=r.querySelector('.rt-profit-pct'); if(pp) pp.textContent=(rate>=0?'+':'')+rate.toFixed(2)+'%';
      var pd=r.querySelector('.rt-profit-detail'); if(pd){ pd.textContent=sgn(profit)+'원 ('+(rate>=0?'+':'')+rate.toFixed(2)+'%)'; setUD(pd,profit); }
      var ee=r.querySelector('.rt-eval'); if(ee) ee.textContent=Math.round(ev).toLocaleString()+'원';
      setUD(r,profit);  // .row.up/.down → 현재가·손익 색 일괄
    });
  }
  // 행 등락률·당일등락·평가금액변동 = 현재가 vs NXT 종가(data-rt-prevclose). 화면 표시와 동일 기준(0B의 KRX 등락율 아님).
  function paintDay(code,price){
    document.querySelectorAll(selByCode(code)).forEach(function(r){
      var pc=parseFloat(r.getAttribute('data-rt-prevclose')||'0');  // 없는 행(관심·감시)은 0 → 아래서 스킵
      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;
      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); }
    });
  }
  function udcls(v){ return v>0?'up':(v<0?'down':'neutral'); }
  // owner의 KPI를 해당 owner의 모든 카드(owner 탭 + 자산정보 탭)에 적용. data-rt-owner로 연결.
  function setOwnerKpis(owner,T){
    document.querySelectorAll('[data-rt-owner="'+owner+'"]').forEach(function(card){
      var tv=card.querySelector('[data-rt-kpi="value"]'); if(tv) tv.textContent=Math.round(T.value).toLocaleString()+'원';
      var tp=card.querySelector('[data-rt-kpi="profit"]'); if(tp) tp.innerHTML='<span class="'+udcls(T.profit)+'">'+sgn(T.profit)+'원 ('+(T.rate>=0?'+':'')+T.rate.toFixed(2)+'%)</span>';
      var tn=card.querySelector('[data-rt-kpi="net"]'); if(tn) tn.innerHTML='<span class="net">'+Math.round(T.net).toLocaleString()+'원</span>';
      if(T.daypl!==null){
        var td=card.querySelector('[data-rt-kpi="daypl"]');
        if(td) td.innerHTML='<span class="'+udcls(T.daypl)+'">'+sgn(T.daypl)+'원'+(T.dpct!==null?' ('+(T.dpct>=0?'+':'')+T.dpct.toFixed(2)+'%)':'')+'</span>';
      }
      // 총수익 = 순자산 − 투자원금. 순자산이 틱마다 움직이니 같이 갱신해야 두 값이 어긋나지 않는다.
      var pr=parseFloat(card.getAttribute('data-rt-principal')||'0');
      var tr=card.querySelector('[data-rt-kpi="totret"]');
      if(tr&&pr){var v=T.net-pr,p=v/pr*100;
        tr.innerHTML='<span class="'+udcls(v)+'">'+sgn(v)+'원 ('+(p>=0?'+':'')+p.toFixed(2)+'%)</span>';}
    });
    // 순자산 차트 오늘(장중) 포인트 — 값 라벨 + SVG 점·마지막 선분을 1초마다 함께 이동(라벨↔점 불일치 제거).
    document.querySelectorAll('.net-chart-last[data-rt-nw-owner="'+owner+'"][data-rt-nw-live="1"]').forEach(function(el){
      var basenet=parseFloat(el.getAttribute('data-rt-nw-basenet')||'0');
      var baseval=parseFloat(el.getAttribute('data-rt-nw-baseval')||'0');
      var v=Math.round(baseval+(T.net-basenet));  // 순자산 변동분 = 손익 변동분(baseline·cf는 세션 중 고정)
      var s=(el.getAttribute('data-rt-nw-pnl')==='1') ? ((v>=0?'+':'−')+Math.abs(v).toLocaleString()+'원') : (v.toLocaleString()+'원');
      el.textContent=s+' (장중)';
      // 차트 헤더 값·오늘 점 툴팁도 같은 델타로 이동 — 이 둘은 서버 렌더 산출물이라
      // 여기서 안 옮기면 15:30~20:00(NXT)처럼 패널 swap 트리거가 없는 구간에서 렌더 시점 값에 멈춘다.
      var ch=el.closest('.net-chart');
      if(ch){
        var dv=Math.round(T.net-basenet);
        var hd=ch.querySelector('.net-chart-delta'), hb=parseFloat(el.getAttribute('data-rt-nw-hdr'));
        if(hd&&!isNaN(hb)){
          var hv=Math.round(hb+dv), h0=parseFloat(el.getAttribute('data-rt-nw-hdr0'))||0;
          var ht=(hv>=0?'+':'−')+Math.abs(hv).toLocaleString()+'원';
          if(h0) ht+=' ('+(hv>=0?'+':'−')+Math.abs(hv/h0*100).toFixed(2)+'%)';
          hd.textContent=ht;
          hd.classList.remove('up','down'); hd.classList.add(hv>=0?'up':'down');
        }
        // netchart_script 가 init 때 캐시한 pts 배열의 라이브 점을 제자리 갱신(툴팁이 그 배열을 읽는다).
        if(ch.__nw_pts&&ch.__nw_liveIdx>=0&&ch.__nw_liveBase){
          var lp=ch.__nw_pts[ch.__nw_liveIdx], bs=ch.__nw_liveBase;
          lp.v=bs.v+dv; lp.day=bs.day+dv; lp.total=bs.total+dv;
        }
      }
      // 점 위치도 같은 값으로 재계산 — 서버 렌더와 동일 스케일 y = top + (1 − (v−ylo)/span)·ih.
      var ylo=parseFloat(el.getAttribute('data-rt-nw-ylo'));
      var span=parseFloat(el.getAttribute('data-rt-nw-span'));
      var top=parseFloat(el.getAttribute('data-rt-nw-top'));
      var ih=parseFloat(el.getAttribute('data-rt-nw-ih'));
      var lastx=parseFloat(el.getAttribute('data-rt-nw-x'));
      if([ylo,span,top,ih,lastx].some(isNaN)) return;
      var chart=el.closest('.net-chart'); if(!chart) return;
      var svg=chart.querySelector('svg.net-chart-svg'); if(!svg) return;
      var ny=top+(1-(v-ylo)/span)*ih;
      ny=Math.max(top, Math.min(top+ih, ny));  // 렌더 당시 범위 밖이면 차트 가장자리로 클램프(다음 페치가 스케일 재계산)
      var nys=ny.toFixed(2);
      ['.last-dot','.live-halo','.ath-ring'].forEach(function(sel){
        var c=svg.querySelector(sel); if(c) c.setAttribute('cy', nys);
      });
      var al=svg.querySelector('.ath-label'); if(al) al.setAttribute('y', Math.max(ny-14, top+12).toFixed(2));
      var seg=svg.querySelector('.line.dashed.live-seg');  // 마지막(확정점→라이브) 점선 끝점 y 교체
      if(seg){ var d=seg.getAttribute('d')||''; seg.setAttribute('d', d.replace(/L\s*([\d.]+),[\d.]+\s*$/, 'L $1,'+nys)); }
      var dots=svg.querySelectorAll('.pt-dot');  // 라이브 점과 같은 x의 pt-dot도 이동
      if(dots.length){ var ld=dots[dots.length-1]; if(Math.abs(parseFloat(ld.getAttribute('cx'))-lastx)<0.5) ld.setAttribute('cy', nys); }
    });
  }
  // KPI 총합 — owner 탭 scope의 consolidated pane 행만 합산(중복합산 방지). 시세 미수신 종목은 data-rt-px(서버값) fallback.
  function updateScopes(px){
    document.querySelectorAll('.rt-scope[data-rt-owner]').forEach(function(scope){
      var rows=scope.querySelectorAll('[data-account-view-pane="consolidated"] details.row[data-rt-qty]');
      if(!rows.length) return;
      var value=0, cost=0, rv=[];
      rows.forEach(function(r){
        var qty=parseFloat(r.getAttribute('data-rt-qty')||'0');
        var c=parseFloat(r.getAttribute('data-rt-cost')||'0');
        var p=px[rowCode(r)]; if(p==null) p=parseFloat(r.getAttribute('data-rt-px')||'0');
        var v=p*qty; value+=v; cost+=c; rv.push([r,v]);
      });
      if(!cost) return;
      // 비중 = 행 평가액 / 총평가액. consolidated(:held) + 당일매매 중복행(바 키)에 적용.
      // 둘 다 "전체 보유 비중"이라 같은 값. 계좌별 행(:held:계좌)은 분모 달라 제외(여기서 안 건드림).
      // scope 한정 → 다른 소유자(본인/가희)의 동일 종목 교차오염 방지.
      if(value) rv.forEach(function(pair){
        var wtxt=(pair[1]/value*100).toFixed(2);
        var w=pair[0].querySelector('.rt-weight'); if(w) w.textContent=wtxt;
        // 당일매매 섹션 중복행(바 코드 키) — 같은 scope 안에서만 (계좌별 :held:계좌 는 exact match라 자동 제외)
        scope.querySelectorAll('details.row[data-row-key="'+rowCode(pair[0])+'"] .rt-weight').forEach(function(d){ d.textContent=wtxt; });
      });
      var T={value:value, profit:value-cost, rate:(value/cost-1)*100,
             net:value+parseFloat(scope.getAttribute('data-rt-deposit')||'0'),
             daypl:null, dpct:null};
      var dc=scope.getAttribute('data-rt-daypl-c');  // 당일평가손익 = 실시간평가액 + C
      if(dc!==null){
        T.daypl=value+parseFloat(dc);
        var pn=parseFloat(scope.getAttribute('data-rt-prevnet')||'0');
        T.dpct=pn?(T.daypl/pn*100):null;
      }
      setOwnerKpis(scope.getAttribute('data-rt-owner'), T);
    });
  }
  // 수신 상태를 화면에 페인팅 — 1초 틱과 패널 swap 직후 재적용이 공유.
  // 보유 상세 듀얼 OHLC 미니의 '현재가' 행(KRX/NXT) 라이브 갱신 — 현재가=AL 라이브, 등락=각 셀 전일종가(data-prev) 대비.
  function paintOhlcCur(code,price){
    document.querySelectorAll(selByCode(code)).forEach(function(r){
      r.querySelectorAll('[data-rt-ohlc-cur]').forEach(function(cell){
        var pe=cell.querySelector('.ohlc-price'); if(pe) pe.textContent=price.toLocaleString()+'원';
        var prev=parseFloat(cell.getAttribute('data-prev')||'0');
        var pc=cell.querySelector('.ohlc-pct');
        if(pc&&prev>0){ var pct=(price/prev-1)*100; pc.textContent=(pct>=0?'+':'')+pct.toFixed(2)+'%'; pc.classList.remove('up','down','neutral'); pc.classList.add(pct>0?'up':(pct<0?'down':'neutral')); }
      });
    });
  }
  function applyState(d){
    if(!d || !d.connected) return;
    var codes=codesOnPage();
    var qs=d.quotes||{}, vis=d.vi||{}, px={};
    Object.keys(qs).forEach(function(c){ px[c]=qs[c].price; paintPrice(c,qs[c]); paintProfit(c,qs[c].price); paintDay(c,qs[c].price); paintOhlcCur(c,qs[c].price); });
    codes.forEach(function(c){ paintVI(c, !!(vis[c] && vis[c].active)); });
    window.__rtPx=px;
    updateScopes(px);
  }
  // 상단 WS 연결 상태 점 — 초록(on)/빨강(off)/회색(미정). tick 성공·실패마다 갱신.
  function setWsStatus(ok){
    window.__wsConnected = !!ok;
    var el=document.getElementById('ws-status'); if(!el) return;
    el.classList.toggle('on', !!ok);
    el.classList.toggle('off', !ok);
  }
  // 점 탭 → 현재 연결 상태 토스트. 헤더는 패널 swap 대상이 아니라 한 번만 바인딩.
  (function(){
    var dot=document.getElementById('ws-status'); if(!dot) return;
    dot.addEventListener('click',function(){
      var ok=window.__wsConnected;
      var msg=ok?'실시간 시세 연결됨 ✓ (WebSocket)':'실시간 시세 끊김 — REST 폴백 중';
      if(window.showToast) window.showToast(msg, ok?'success':'info');
    });
  })();
  // ⚡VI 배지 탭 → 그 종목 실제 VI 데이터 + 설명 토스트. 배지는 동적 생성이라 document 위임. 행 토글은 막음.
  function _fmtViTime(t){ return (t&&t.length>=6)?(t.slice(0,2)+':'+t.slice(2,4)+':'+t.slice(4,6)):(t||''); }
  document.addEventListener('click',function(e){
    var b=(e.target&&e.target.closest)?e.target.closest('.rt-vi-badge'):null;
    if(!b)return;
    e.preventDefault();e.stopPropagation();
    if(!window.showToast)return;
    var row=b.closest('details.row');
    var code=row?((row.getAttribute('data-row-key')||'').split(':')[0]):'';
    var v=(window.__rtState&&window.__rtState.vi)?window.__rtState.vi[code]:null;
    var head='';
    if(v){
      var L=['⚡VI 발동'+(v.apply_kind?(' · '+v.apply_kind):'')];
      if(v.direction) L.push('방향: '+(v.direction==='1'?'상승 ▲':(v.direction==='2'?'하락 ▼':v.direction)));
      if(v.trigger_price) L.push('발동가: '+Number(v.trigger_price).toLocaleString()+'원');
      if(v.trigger_pct!=null&&v.trigger_pct!==0) L.push('발동 등락률: '+(v.trigger_pct>=0?'+':'')+v.trigger_pct+'%');
      if(v.count) L.push('당일 발동: '+v.count+'회');
      if(v.trigger_time&&v.trigger_time!=='000000') L.push('발동시각: '+_fmtViTime(v.trigger_time));
      if(v.release_time&&v.release_time!=='000000') L.push('해제: '+_fmtViTime(v.release_time));
      head=L.join('\n')+'\n\n─────\n';
    }
    var desc='⚡VI (변동성완화장치)\n\n주가가 짧은 시간에 급변하면 약 2분간\n단일가매매로 전환해 과열을 식히는 장치예요.\n\n• 동적VI: 직전 체결가 대비\n• 정적VI: 당일 기준가 대비\n(코스피200 3%·그 외 6% 등에서 발동)';
    window.showToast(head+desc,'info',8000);
  });
  function tick(){
    if(document.hidden) return;
    var codes=codesOnPage();
    fetch('/api/realtime/quotes?codes='+codes.join(','))
      .then(function(r){ return r.json(); })
      .then(function(d){
        setWsStatus(d && d.connected);
        if(!d || !d.connected) return;
        window.__rtState = d;
        if(codes.length) applyState(d);
        // 체결 신호 변화 → 패널 새로고침(수량·예수금·당일정산 등 사건 기반 값). 첫 관측은 기준값만 저장(공회전 방지).
        // 빈 문자열('')도 유효 기준으로 취급 — 신호파일이 비어있다 첫 체결로 채워질 때도 reload 되게.
        var fs=d.fill_seq||'';
        if(window.__rtFillSeq===undefined){ window.__rtFillSeq=fs; }
        else if(window.__rtFillSeq!==fs){
          window.__rtFillSeq=fs;
          if(window.__behive_load){ window.__behive_load(window.__behive_active_owner?window.__behive_active_owner():null, true); }
        }
      }).catch(function(){ setWsStatus(false); });
  }
  // 자동갱신 swap 직후 서버 HTML엔 VI 배지·실시간 가격이 빠져있다 → 다음 1초 틱 전까지 깜빡임.
  // 마지막 수신 상태를 즉시 재적용해 배지가 사라졌다 나타나는 현상 제거.
  document.addEventListener('behive:panels-loaded',function(){ if(window.__rtState) applyState(window.__rtState); });
  setInterval(tick,1000); tick();
})();</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>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  <meta name="theme-color" content="#0f1115">
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
  <meta name="apple-mobile-web-app-title" content="자산현황">
  <meta name="robots" content="noindex, nofollow">
  <title>자산현황</title>
  <script>window.__build="{BUILD}";window.__trailPresets={trail_presets_json};</script>
  <style>{_CSS}{dynamic_css}</style>
  {head_script}
</head>
<body>
  <div class="page" id="page">
    <div class="ptr" id="ptr" aria-hidden="true"><span class="arrow">↓</span><span class="spinner"></span></div>
    <div class="topbar">
      <header class="top">
        <div class="titles">
          <h1>자산현황</h1>
          <span class="meta">갱신 {now}</span>
        </div>
        <div class="top-actions">
          <span id="auto-badge" class="auto-badge" title="초록 = 장중 3초마다 자동 갱신 / 회색 = 장마감"></span>
          <button type="button" class="top-trade-btn" data-open-order-modal title="거래 — 종목은 모달에서 선택">거래</button>
          <div class="opts-wrap">
            <button type="button" id="opts-toggle" class="opts-btn" aria-haspopup="true" aria-expanded="false" aria-label="옵션" title="옵션">☰</button>
            <div class="opts-popup" id="opts-popup" hidden role="menu" aria-label="옵션">
              <button type="button" class="account-view-toggle-btn opts-item" data-account-view-state="consolidated" role="menuitem" aria-label="계좌 보기 방식 (합산 / 각계좌별도) 토글" title="합산 / 각계좌별도 토글"><span>계좌 나누기</span></button>
              <button type="button" class="opts-item" data-modal-open="calc-modal" role="menuitem" aria-label="등락률 계산기" title="기준가·현재가 등락률 계산기"><span>등락률 계산기</span></button>
              <button type="button" class="opts-item" data-modal-open="avgcalc-modal" role="menuitem" aria-label="평단 계산기" title="물타기 평단 계산기 (보유+추가매수)"><span>평단 계산기</span></button>
              <button type="button" class="opts-item" data-modal-open="holdeval-modal" role="menuitem" aria-label="보유 평가 계산기" title="임의 종목 검색 + 평단가·주수로 등락률·손익 계산"><span>보유 평가 계산기</span></button>
            </div>
          </div>
        </div>
      </header>
      <nav class="tabs" role="tablist">
        {nav_html}
      </nav>
    </div>
    {content_html}
  </div>
  <div class="market-ticker" id="market-ticker" role="button" tabindex="0" aria-label="시장 지수 — 탭하면 다음 지수"><span class="idx-empty">지수 불러오는 중…</span></div>
  {idx_info_modal_html}
  {interest_modal_html}
  {interest_edit_modal_html}
  {tag_modal_html}
  {note_modal_html}
  {calc_modal_html}
  {avg_calc_modal_html}
  {holdeval_modal_html}
  {holdeval_add_modal_html}
  {group_assign_modal_html}
  {group_manage_modal_html}
  {candidate_modal_html}
  {trade_modal_html}
  {kpi_modal_html}
  {info_modal_html}
  {info_desc_modal_html}
  {advice_modal_html}
  {order_modal_html}
  {pin_modal_html}
  {sell_choice_modal_html}
  {order_confirm_modal_html}
  {open_orders_modal_html}
  {stock_name_modal_html}
  {ptr_script}
  {panels_script}
  {netchart_script}
  {adr_hover_script}
  {chart_svg_script}
  {auto_reload_script}
  {nodbltap_script}
  {calc_script}
  {avg_calc_script}
  {holdeval_script}
  {details_mutex_script}
  {modal_script}
  {order_modal_script}
  {stock_name_tip_script}
  {flow_script}
  {realtime_script}
</body>
</html>'''


# ── 패널 페이로드 인메모리 캐시 ──
# /api/panels GET마다 키움 호출 발생 → owner별 캐시로 사용자가 보는 탭만 갱신.
#   key=None('all'): 전체 — kt00018·kt00001·ka10170 × 4계좌 + ka10095 = ~13콜
#   key='본인': 본인 2계좌 — kt00018·kt00001·ka10170 × 2 = ~6콜 (워치리스트 quotes skip)
#   key='가희': 가희 2계좌 — 동일 ~6콜
# 자동 갱신(10s) 주기에 맞춰 TTL=10s — 락이 동시 요청을 한 fetch로 모아 모바일 Safari timeout 흡수.
# 셸 HTML은 데이터 fetch 없이 매번 새로 만들어도 무료라 캐시 안 함. 워치리스트 변경(POST) 시 모든 owner 캐시 즉시 invalidate.
PANELS_CACHE_TTL = 10.0
_panels_cache: dict[str, dict] = {}  # cache_key('all'|owner) → {'json': bytes, 'expires_at': float}
_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로
# 갈아끼워 자산정보-관리자 탭 간 KPI 불일치를 제거한다. TTL 별도 없음 — 'all'이 만료되면
# 어차피 full fetch로 동기화돼 누적 stale 위험 제한적.
_owner_data_cache: dict[str, dict] = {}  # owner → {'owner_data': dict, 'balances': dict, 'fetched_at': float}


def _panels_lock_for(key: str) -> threading.Lock:
    with _panels_locks_master:
        lk = _panels_locks.get(key)
        if lk is None:
            lk = threading.Lock()
            _panels_locks[key] = lk
        return lk


def _save_owner_slices(owner_data: dict, balances: dict, journal_by_label: dict | None = None) -> None:
    """fetched owner_data를 owner 단위로 분리해 _owner_data_cache에 적재.
    balances는 owner의 labels로 필터링 — 다른 owner 계좌 데이터가 섞이지 않도록.
    journal_by_label도 동일하게 labels로 필터링해 보관 (자산보기 각계좌별도 토글 KPI용)."""
    now_ts = time.time()
    journal_by_label = journal_by_label or {}
    for owner, d in owner_data.items():
        owner_labels = d.get('labels') or []
        owner_balances = {lb: balances[lb] for lb in owner_labels if lb in balances}
        owner_journal = {lb: journal_by_label[lb] for lb in owner_labels if lb in journal_by_label}
        _owner_data_cache[owner] = {
            'owner_data': d,
            'balances': owner_balances,
            'journal_by_label': owner_journal,
            'fetched_at': now_ts,
        }


def _patch_all_cache_for_owner(owner: str, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS, chart_ym: str = '') -> None:
    """partial fetch 직후 'all' 캐시의 summary 패널과 해당 owner 패널만 in-place로 갈아끼운다.
    다른 owner와 watchlist/interests 슬라이스는 cached 'all'에 있던 그대로 — TTL 만료 시 full fetch로 동기화.
    핵심 목표: 자산정보 탭의 본인 KPI가 관리자 탭과 같은 시점이 되도록."""
    all_key = f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}:y{chart_ym}'
    all_lock = _panels_lock_for(all_key)
    with all_lock:
        slot = _panels_cache.get(all_key)
        if not slot or slot['expires_at'] <= time.time():
            return  # 캐시 없거나 만료 → 다음 자산정보 진입에서 어차피 full fetch
        try:
            payload = json.loads(slot['json'].decode('utf-8'))
        except Exception:
            traceback.print_exc()
            return
        merged = {o: e['owner_data'] for o, e in _owner_data_cache.items()}
        if not merged:
            return
        ordered = sorted(merged.keys(), key=lambda o: (o != '본인', o))
        # 각계좌별도 토글 KPI에 필요 — slice된 balances·journal_by_label을 owner 단위로 다시 union.
        merged_balances: dict = {}
        merged_journal: dict = {}
        for e in _owner_data_cache.values():
            merged_balances.update(e.get('balances') or {})
            merged_journal.update(e.get('journal_by_label') or {})
        new_summary = _render_summary_panel(ordered, merged, merged_balances, merged_journal, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
        entry = _owner_data_cache.get(owner)
        if not entry:
            return
        show_day_change = _show_day_change_now()
        full_title = _owner_full_title(owner)
        new_owner_panel = _render_owner_panel(owner, entry['owner_data'], entry['balances'], full_title, show_day_change, chart_days=chart_days, chart_unit=chart_unit)
        owner_tab_id = f'tab-{_owner_tab_id(owner)}'
        fetched_hms = datetime.now(KST).strftime('%Y-%m-%d %H:%M:%S')
        for tab in payload.get('tabs', []):
            tid = tab.get('id')
            if tid == 'tab-summary':
                tab['html'] = new_summary
                tab['fetched_at'] = fetched_hms
            elif tid == owner_tab_id:
                tab['html'] = new_owner_panel
                tab['fetched_at'] = fetched_hms
        payload['now'] = datetime.now(KST).strftime('%Y-%m-%d %H:%M:%S')
        payload['market_active'] = _market_active_now()
        new_body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
        # TTL 유지 — patch는 fetch가 아니라 일부 슬라이스만 갱신했으니 원래 만료 시각 그대로.
        _panels_cache[all_key] = {'json': new_body, 'expires_at': slot['expires_at']}


def _get_cached_panels_json(owner: str | None = None, fresh: bool = False, chart_days: int = NET_WORTH_CHART_DAYS, chart_unit: str = NET_WORTH_CHART_UNIT, chart_mode: str = NET_WORTH_CHART_MODE, adr_days: int = ADR_TREND_DAYS, chart_ym: str = '') -> bytes:
    """fresh=True 시 캐시 우회 — 명시적 새로고침(?fresh=1)에서 호출.
    응답을 새로 만든 뒤에는 캐시에 덮어쓰므로 그 후 자동 갱신은 새 캐시를 본다.

    chart_days × chart_unit × chart_mode × adr_days 별로 캐시 키 분리 — 옵션마다 다른 series라 같은 owner라도 별도 슬롯.

    owner partial fetch 직후엔 'all' 캐시의 summary 슬라이스와 해당 owner 탭을 in-place로
    갈아끼운다(_patch_all_cache_for_owner). 자산정보 = 본인+가희 KPI 카드 스택이라 owner_data만
    있으면 재렌더 가능 — 다시 fetch할 필요 없음. 패치 실패 시 안전망으로 'all' 캐시 비워
    다음 자산정보 진입에서 full fetch."""
    key = f'{owner or "all"}:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}:y{chart_ym}'
    if not fresh:
        slot = _panels_cache.get(key)
        if slot and slot['expires_at'] > time.time():
            return slot['json']
    lock = _panels_lock_for(key)
    with lock:
        if not fresh:
            slot = _panels_cache.get(key)
            if slot and slot['expires_at'] > time.time():
                return slot['json']
        payload = _build_panels_payload(owner=owner, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
        body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
        _panels_cache[key] = {'json': body, 'expires_at': time.time() + PANELS_CACHE_TTL}
        if owner is not None:
            try:
                _patch_all_cache_for_owner(owner, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
            except Exception:
                traceback.print_exc()
                _panels_cache.pop(f'all:d{chart_days}:u{chart_unit}:m{chart_mode}:a{adr_days}:y{chart_ym}', None)
        return body


def _invalidate_panels_cache() -> None:
    with _panels_locks_master:
        _panels_cache.clear()


GZIP_MIN_BYTES = 1024  # 1KB 미만은 압축 오버헤드가 절감보다 큼 — 303 redirect·작은 ok 응답 등 skip


def _maybe_gzip(body: bytes, accept_encoding: str | None) -> tuple[bytes, str | None]:
    """클라이언트가 gzip 수용하면 압축본 반환. compresslevel=6은 디폴트(속도/크기 균형)."""
    if not body or len(body) < GZIP_MIN_BYTES:
        return body, None
    ae = (accept_encoding or '').lower()
    if 'gzip' not in ae:
        return body, None
    try:
        return gzip.compress(body, compresslevel=6, mtime=0), 'gzip'
    except Exception:
        return body, None


class Handler(BaseHTTPRequestHandler):
    server_version = 'BehiveWeb/1.0'

    def log_message(self, fmt, *args):  # type: ignore[override]
        sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} {fmt % args}\n')
        sys.stdout.flush()

    def _send_json(self, code: int, payload: dict) -> None:
        body = json.dumps(payload, ensure_ascii=False).encode('utf-8')
        body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
        self.send_response(code)
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.send_header('Content-Length', str(len(body)))
        if enc:
            self.send_header('Content-Encoding', enc)
            self.send_header('Vary', 'Accept-Encoding')
        self.send_header('Cache-Control', 'no-store')
        self.end_headers()
        try:
            self.wfile.write(body)
        except (BrokenPipeError, ConnectionResetError):
            pass

    def do_GET(self):  # noqa: N802
        from urllib.parse import urlparse, parse_qs
        parsed = urlparse(self.path)
        if parsed.path == '/api/realtime/quotes':
            # 실시간 허브 메모리의 최신 현재가/VI (키움 호출 0). 허브 콜드/끊김이면 빈 응답 → JS가 무시.
            qs = parse_qs(parsed.query)
            raw = (qs.get('codes') or [''])[0]
            codes = [c for c in raw.split(',') if c.strip()]
            quotes, vi, sub_count, connected = {}, {}, 0, False
            hub = _RT_HUB
            if hub is not None:
                import kiwoom_client as kc
                connected = hub.is_connected()
                sub_count = len(hub._subs)
                if not codes:
                    codes = sorted(hub._subs)
                quotes = hub.get_quotes(codes)
                for c in codes:
                    v = hub.get_vi(c)
                    if v:
                        vi[kc._clean_code(c)] = v
            # 체결 신호 — fill_watcher가 체결 시 갱신. 브라우저가 값 변하면 패널 자동 새로고침.
            fill_seq = ''
            try:
                _fp = WORKSPACE / 'state' / 'fill_signal'
                if _fp.exists():
                    fill_seq = _fp.read_text().strip()
            except Exception:
                pass
            self._send_json(200, {'connected': connected, 'sub_count': sub_count, 'quotes': quotes, 'vi': vi, 'fill_seq': fill_seq})
            return
        # KPI 라벨 [!] 팝업 본문 — 서버가 HTML 조립, 클라이언트는 innerHTML만.
        if parsed.path == '/api/surge':
            # 급등락 알림 팝업 본문 (구간 + 오늘 소진 + 토글). 캐시 읽기뿐이라 키움 콜 0.
            qs = parse_qs(parsed.query)
            code_q = (qs.get('code') or [''])[0].strip()
            name_q = (qs.get('name') or [''])[0].strip()
            if not code_q:
                self._send_json(400, {'error': 'code required'})
                return
            try:
                self._send_json(200, {'html': _render_surge_body(code_q, name_q)})
            except Exception as e:
                sys.stderr.write(f'/api/surge failed: {e}\n')
                self._send_json(500, {'error': str(e)})
            return

        if parsed.path in ('/api/principal', '/api/cashflow', '/api/deposit'):
            qs = parse_qs(parsed.query)
            owner_q = (qs.get('owner') or [''])[0].strip()
            if not owner_q:
                self._send_json(400, {'error': 'owner required'})
                return
            render = {'/api/principal': _render_principal_body,
                      '/api/cashflow': _render_cashflow_body,
                      '/api/deposit': _render_deposit_body}[parsed.path]
            try:
                self._send_json(200, {'html': render(owner_q)})
            except Exception as e:
                sys.stderr.write(f'{parsed.path} failed: {e}\n')
                self._send_json(500, {'error': str(e)})
            return

        if parsed.path == '/api/trades':
            qs = parse_qs(parsed.query)
            code = (qs.get('code') or [''])[0].strip()
            account = (qs.get('account') or [''])[0].strip()
            owner_q = (qs.get('owner') or [''])[0].strip()
            if not code and not owner_q:
                self._send_json(400, {'error': 'code or owner required'})
                return
            try:
                import trade_journal as tj
                from stock_portfolio_report import OWNER_LABELS as _OWNER_LABELS

                def _row_owner(r: dict) -> str:
                    return r.get('owner') or ('가희' if r.get('account', '').startswith('가희_') else '본인')

                all_rows = tj._load_all()
                if code:
                    rows = [r for r in all_rows if r.get('code') == code]
                else:
                    rows = [r for r in all_rows if _row_owner(r) == owner_q]
                if account:
                    rows = [r for r in rows if r.get('account') == account]
                # 매도 실현손익을 '전일 보유 평단' 기준으로 재계산 (ka10170 round-trip 오표시 교정).
                # rows는 해당 (account,code)의 전체 이력을 포함하므로 재생이 정확하다.
                rows = tj.rebase_pl(rows)
                rows.sort(key=lambda r: (r.get('date', ''), r.get('account', '')), reverse=True)
                name = rows[0]['name'] if rows and code else ''
                mode = 'owner' if (not code and owner_q) else 'code'
                total_buy = sum(r.get('buy_amt', 0) for r in rows)
                total_sell = sum(r.get('sell_amt', 0) for r in rows)
                total_pl = sum(r.get('pl_amt', 0) for r in rows)
                total_fee = sum(r.get('cmsn_tax', 0) for r in rows)

                def _owner_short(o: str) -> str:
                    full = _OWNER_LABELS.get(o, f'{o} 자산')
                    return full.replace('님 자산', '').replace(' 자산', '')

                groups_map: dict[str, dict] = {}
                for r in rows:
                    o = r.get('owner') or ('가희' if r.get('account', '').startswith('가희_') else '본인')
                    g = groups_map.setdefault(o, {
                        'owner': o,
                        'label': _owner_short(o),
                        'rows': [],
                        'total_buy_amt': 0,
                        'total_sell_amt': 0,
                        'total_pl_amt': 0,
                        'total_cmsn_tax': 0,
                    })
                    g['rows'].append(r)
                    g['total_buy_amt'] += r.get('buy_amt', 0)
                    g['total_sell_amt'] += r.get('sell_amt', 0)
                    g['total_pl_amt'] += r.get('pl_amt', 0)
                    g['total_cmsn_tax'] += r.get('cmsn_tax', 0)
                ordered_groups = sorted(groups_map.values(), key=lambda g: (g['owner'] != '본인', g['owner']))
            except Exception:
                traceback.print_exc()
                self._send_json(500, {'error': 'trades load failed'})
                return
            self._send_json(200, {
                'code': code,
                'name': name,
                'owner': owner_q,
                'mode': mode,
                'account': account,
                'rows': rows,
                'groups': ordered_groups,
                'total_buy_amt': total_buy,
                'total_sell_amt': total_sell,
                'total_pl_amt': total_pl,
                'total_cmsn_tax': total_fee,
            })
            return
        if parsed.path == '/api/symbols/all':
            # 거래 모달 상단 종목 변경 드롭다운용 — 보유+관심+감시 통합 (dedup by code)
            try:
                import kiwoom_client as kc
                # 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:
                    pos_by_acc = kc.get_positions_all()
                    for label, positions in (pos_by_acc or {}).items():
                        owner = '가희' if label.startswith('가희_') else '본인'
                        for p in positions:
                            code = p.get('code')
                            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})
                except Exception:
                    traceback.print_exc()
                # 관심종목
                try:
                    for c in _load_interests():
                        code = c.get('code')
                        if not code or code in seen:
                            continue
                        seen.add(code)
                        items.append({'code': code, 'name': c.get('stock', ''), 'source': '관심', 'owner': ''})
                except Exception:
                    traceback.print_exc()
                # 감시종목
                try:
                    for c in _load_watchlist():
                        code = c.get('code')
                        if not code or code in seen:
                            continue
                        seen.add(code)
                        items.append({'code': code, 'name': c.get('stock', ''), 'source': '감시', 'owner': ''})
                except Exception:
                    traceback.print_exc()
                self._send_json(200, {'items': items, 'count': len(items)})
            except Exception as e:
                traceback.print_exc()
                self._send_json(500, {'error': f'symbols failed: {e}'})
            return
        if parsed.path == '/api/holdings':
            # 평단 계산기용 — 소유자(본인/가희)별로 보유종목 합산(일반+ISA 합침, 가중평균 평단).
            # 같은 종목을 본인·가희 둘 다 보유하면 각각 별도 항목.
            try:
                import kiwoom_client as kc
                agg: dict = {}
                pos_by_acc = kc.get_positions_all()
                for label, positions in (pos_by_acc or {}).items():
                    owner = '가희' if label.startswith('가희_') else '본인'
                    for p in positions:
                        code = p.get('code')
                        qty = int(p.get('qty') or 0)
                        avg = float(p.get('avg_price') or 0)
                        if not code or qty <= 0:
                            continue
                        a = agg.setdefault((owner, code), {'code': code, 'name': p.get('name', ''),
                                                           'owner': owner, 'qty': 0, 'cost': 0.0})
                        a['qty'] += qty
                        a['cost'] += qty * avg
                items = []
                for a in agg.values():
                    items.append({'code': a['code'], 'name': a['name'], 'owner': a['owner'],
                                  'qty': a['qty'], 'avg': int(round(a['cost'] / a['qty'])) if a['qty'] else 0})
                items.sort(key=lambda x: x['qty'] * x['avg'], reverse=True)
                self._send_json(200, {'items': items, 'count': len(items)})
            except Exception as e:
                traceback.print_exc()
                self._send_json(500, {'error': f'holdings failed: {e}'})
            return
        if parsed.path == '/api/orders/open':
            # 4계좌 통합 미체결 주문 — open-orders-modal이 표시
            try:
                import kiwoom_client as kc
                rows = kc.get_open_orders_all()
                # 트레일링 예약이면 그 상태를 실어 보낸다 (예약 파일 읽기, 키움 콜 0).
                for _r in rows:
                    _r['trailing'] = _trailing_info_for(_r.get('ord_no', ''))
                self._send_json(200, {'rows': rows, 'count': len(rows)})
            except Exception as e:
                traceback.print_exc()
                self._send_json(500, {'error': f'open orders failed: {e}'})
            return
        if parsed.path == '/api/order/active':
            # 현재 활성 카드 (PinStore.peek) + 미체결 주문 정보 — 거래 모달 [📋 진행중] 탭이 표시
            try:
                import time as _time
                from orders.pin import PinStore
                pending = PinStore().peek()
                result: dict = {'active': False}
                if pending is not None and not pending.is_expired() and not pending.consumed:
                    payload = pending.payload or {}
                    elapsed = _time.time() - pending.issued_at
                    remaining = max(0, int(pending.expiry_seconds - elapsed))
                    side_label = '매수' if payload.get('side') == 'BUY' else '매도'
                    price_val = payload.get('price')
                    price_str = f"{price_val:,}원" if price_val else '시장가'
                    card_message = (
                        f"💳 활성 카드 [{pending.card_id}]\n"
                        f"종목: {payload.get('symbol_name','')} ({payload.get('symbol','')})\n"
                        f"{side_label} {payload.get('qty',0):,}주 @ {price_str}\n"
                        f"계좌: {payload.get('account','')}"
                    )
                    result = {
                        'active': True,
                        'card_id': pending.card_id,
                        'symbol_name': payload.get('symbol_name', ''),
                        'symbol': payload.get('symbol', ''),
                        'card_message': card_message,
                        'expiry_seconds': remaining,
                    }
                # 4계좌 미체결 주문 ka10075 통합
                open_orders_count = 0
                try:
                    import kiwoom_client as kc
                    rows = kc.get_open_orders_all()
                    open_orders_count = len(rows)
                except Exception:
                    traceback.print_exc()
                result['open_orders_count'] = open_orders_count
                # 진행중 아이템 수 = 미체결 주문 건수(팝업 행과 동일) + 활성 PIN 카드(있으면 1).
                # 종목코드 중복제거 X — 같은 종목 2계좌 매도도 2건으로 표시(팝업과 일치).
                result['unique_count'] = open_orders_count + (1 if result.get('active') else 0)
                self._send_json(200, result)
            except Exception as e:
                traceback.print_exc()
                self._send_json(500, {'error': f'active card lookup failed: {e}'})
            return
        if parsed.path == '/api/market_state':
            try:
                self._send_json(200, _market_phase_state())
            except Exception as e:
                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 가드는 클라이언트 측 책임.
            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
            try:
                import kiwoom_client as kc
                # NXT 거래 가능 여부 먼저 판정 (거래소 선택에 사용). 캐시 없거나 모르면 보수적 True.
                try:
                    meta = kc.lookup_stock_meta(code)
                    nxt_enable = True if (not meta) else bool(meta.get('nxt_enable', True))
                except Exception:
                    nxt_enable = True
                # NXT 시간대엔 KRX 호가창이 15:30 종가로 고정 → 현재가(NXT)와 갭 발생.
                # 그 시간대엔 NXT 호가창·NXT 현재가로 정합시킨다. 단 NXT 미상장 종목은 NXT 호가가
                # 없으므로 그 시간대에도 KRX 기준으로 표시.
                _ph = _market_phase_state().get('phase')
                use_nxt = (_ph == 'nxt') and nxt_enable
                if use_nxt:
                    book = kc.get_quote_book(code + '_NX')
                    quote = kc.get_stock_quote(code, exchange='NX')
                else:
                    book = kc.get_quote_book(code)
                    quote = kc.get_stock_quote(code, exchange='KRX')
                book['price'] = quote.get('price', 0)
                book['change'] = quote.get('change', 0)
                book['change_pct'] = quote.get('change_pct', 0.0)
                book['name'] = quote.get('name', '')
                # 상한가·하한가 (ka10001 upl_pric/lst_pric) — 사다리 호가 생성 범위
                qraw = quote.get('raw') if isinstance(quote.get('raw'), dict) else {}
                try:
                    book['upper_limit'] = abs(int(str(qraw.get('upl_pric') or '0').strip() or '0'))
                    book['lower_limit'] = abs(int(str(qraw.get('lst_pric') or '0').strip() or '0'))
                except (ValueError, TypeError):
                    book['upper_limit'] = 0
                    book['lower_limit'] = 0
                # ⚠️ 52주 최고가(250hgst)는 여기 싣지 않는다 — 트레일링 '전고점 기준'
                #    미리보기용으로 추가했다가 옵션 제거와 함께 제거(2026-07-30).
                #    기업정보 모달의 '52주 최고'는 /api/stock_info(get_stock_basic) 경로다.
                book['nxt_enable'] = nxt_enable   # 위에서 판정한 값 재사용
                # 종목이 속한 시장(KOSPI/KOSDAQ)의 20일 ADR 과매수/과매도 — 극단일 때만 실림.
                _mkt = (meta or {}).get('market') or ''
                book['market'] = _mkt
                _adr_sig = _market_adr_signal(_mkt)
                if _adr_sig:
                    book['adr'] = _adr_sig
                book.pop('raw', None)
            except Exception as e:
                traceback.print_exc()
                self._send_json(500, {'error': f'quote_book failed: {e}'})
                return
            self._send_json(200, book)
            return
        if parsed.path == '/api/stock_info':
            # 기업정보 모달용. ka10001 기반 시총·PER·PBR·EPS·BPS·ROE·52주·외국인비율.
            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
            try:
                import kiwoom_client as kc
                info = kc.get_stock_basic(code)
                info.pop('raw', None)
                # 분기 영업이익(네이버) — 확정 분기 중 최신. 실패해도 키움 데이터는 유지.
                q = _fetch_quarter_op_profit(code)
                info['op_profit_q'] = q['value'] if q else None
                info['op_profit_q_period'] = q['period'] if q else None
                # FnGuide 성장성·컨센서스 보강 (조회 실패·ETF면 None).
                try:
                    import fnguide_client as fg
                    fund = fg.get_fundamentals(code)
                    info['fundamentals'] = {
                        'growth': fund.get('growth'),
                        'latest_period': fund.get('latest_period'),
                        'consensus': fund.get('consensus'),
                    } if fund else None
                except Exception:
                    info['fundamentals'] = None
                # WISEreport 컨센서스(컴팩트) — 리비전 방향·서프라이즈·올해 추정.
                try:
                    import wisereport_client as wr
                    cons = wr.get_consensus(code)
                    if cons:
                        rev = cons.get('revision') or {}
                        sup = (cons.get('surprise') or {}).get('items') or {}
                        est_row = next((a for a in (cons.get('annual') or [])
                                        if a.get('is_estimate')), None)
                        info['consensus'] = {
                            'target_change_pct': rev.get('target_change_pct'),
                            'est_change_pct': rev.get('est_change_pct'),
                            'rev_item': rev.get('item_name'),
                            'rev_weeks': rev.get('n_points'),
                            'this_year': ({
                                'period': est_row.get('period'),
                                'sales': est_row.get('sales'),
                                'op': est_row.get('op'),
                                'eps': est_row.get('eps'),
                                'roe': est_row.get('roe'),
                            } if est_row else None),
                            'surprise_sales': (sup.get('매출액') or {}).get('fy0_surprise_pct'),
                            'surprise_op': (sup.get('영업이익') or {}).get('fy0_surprise_pct'),
                            'surprise_year': (sup.get('매출액') or {}).get('fy0_year'),
                        }
                    else:
                        info['consensus'] = None
                except Exception:
                    info['consensus'] = None
                # 최근 증권사 분석리포트 (메타+요약, PDF 제외). 없으면 None.
                try:
                    import wisereport_client as wr2
                    info['reports'] = wr2.get_reports(code, limit=6)
                except Exception:
                    info['reports'] = None
            except Exception as e:
                traceback.print_exc()
                self._send_json(500, {'error': f'stock_info failed: {e}'})
                return
            self._send_json(200, info)
            return
        if parsed.path == '/api/stock_search':
            # 기업비교용 종목 검색. 단일 매칭이면 1건, 다중이면 후보 리스트 반환.
            qs = parse_qs(parsed.query)
            q = (qs.get('q') or [''])[0].strip()
            if not q:
                self._send_json(400, {'error': 'q required'})
                return
            try:
                hit = _resolve_stock_fuzzy(q)
                self._send_json(200, {'results': [{'code': hit['code'], 'name': hit['name']}]})
            except MultipleMatchError as e:
                self._send_json(200, {'results': [{'code': c['code'], 'name': c['name']} for c in e.candidates[:20]]})
            except Exception as e:
                self._send_json(200, {'results': [], 'error': str(e)})
            return
        if parsed.path == '/api/holdeval/list':
            # 보유 평가 계산기 저장 목록 (라이브 시세 없이 저장 필드만; 현재가는 선택 시 quote_book로 조회).
            try:
                self._send_json(200, {'items': _load_holdeval_saved()})
            except Exception as e:
                traceback.print_exc()
                self._send_json(500, {'error': f'holdeval list failed: {e}'})
            return
        if parsed.path == '/api/order/advice':
            # 거래 모달용: 종목·side 기술적 권장 매수가/매도가 + 근거(이평·ATR). 매도는 avg_price(평단) 선택.
            qs = parse_qs(parsed.query)
            code = ''.join(ch for ch in (qs.get('code') or [''])[0].strip() if ch.isalnum())
            side = (qs.get('side') or [''])[0].strip().upper()
            try:
                avg_price = int((qs.get('avg_price') or ['0'])[0].strip() or '0') or None
            except ValueError:
                avg_price = None
            if not code or side not in ('BUY', 'SELL'):
                self._send_json(400, {'error': 'code required, side=BUY|SELL'})
                return
            try:
                advice = _price_advice(code, side, avg_price)
            except Exception as e:
                traceback.print_exc()
                self._send_json(500, {'error': f'order/advice failed: {e}'})
                return
            self._send_json(200, {'code': code, 'side': side, 'advice': advice})
            return
        if parsed.path == '/api/order/check':
            # 거래 모달용: 계좌·종목·side(BUY/SELL)·order_type·단가(선택) 받아서
            # 최대 거래 가능 주수, 잔액·보유주수·평단가, 매도 시 손익 미리보기 반환.
            # MARKET BUY 는 키움 증거금 기준(상한가)로 max_qty 계산해 사전 차단.
            qs = parse_qs(parsed.query)
            code = ''.join(ch for ch in (qs.get('code') or [''])[0].strip() if ch.isalnum())
            account = (qs.get('account') or [''])[0].strip()
            side = (qs.get('side') or [''])[0].strip().upper()
            order_type = (qs.get('order_type') or ['LIMIT'])[0].strip().upper() or 'LIMIT'
            if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT', 'STOP_LIMIT'):
                order_type = 'LIMIT'
            try:
                price = int((qs.get('price') or ['0'])[0].strip() or '0')
            except ValueError:
                price = 0
            if not code or not account or side not in ('BUY', 'SELL'):
                self._send_json(400, {'error': 'code/account required, side=BUY|SELL'})
                return
            try:
                import kiwoom_client as kc
                result = {'code': code, 'account': account, 'side': side,
                          'order_type': order_type, 'price': price}
                if side == 'BUY':
                    bal = kc.get_balance(account)
                    # 매수가능 기준은 d2_entra(D+2 정산예수금) — propose 가드(validate_balance_for_buy)와
                    # 동일. kt00001 ord_alow_amt는 오늘 즉시현금(entr)만 잡혀 매도결제분(D+2)을 빠뜨려
                    # max_qty가 과소 계산되고 "예수금 부족" 오탐을 냈다. ord_alow_amt는 참고 표시용만 유지.
                    d2 = int(bal.get('d2_entra') or 0)
                    # d2_entra는 미체결 매수 지정가로 묶인 예수금을 아직 포함 → 실제 가용액에서 차감.
                    locked = _pending_buy_lock(account)
                    avail = max(0, d2 - locked)
                    result['ord_alow_amt'] = int(bal.get('ord_alow_amt') or 0)
                    result['d2_entra'] = d2
                    result['pending_buy_locked'] = locked
                    result['avail_cash'] = avail
                    if order_type == 'MARKET':
                        # 키움 시장가 매수 증거금 = 상한가 × qty. 같은 기준으로 max_qty 표시.
                        try:
                            q = kc.get_stock_quote(code, account_label=account, exchange='AL')
                            raw = q.get('raw') if isinstance(q, dict) and isinstance(q.get('raw'), dict) else (q or {})
                            upper = abs(int(raw.get('upl_pric') or 0))
                        except Exception:
                            upper = 0
                        result['upper_limit'] = upper
                        result['max_qty'] = (avail // upper) if upper > 0 else 0
                    else:
                        result['max_qty'] = (avail // price) if price > 0 else 0
                else:  # SELL
                    positions = kc.get_positions(account)
                    pos = next((p for p in positions if p['code'] == code), None)
                    if pos:
                        # trde_able_qty가 미체결 매도를 이미 제외하는지 불확실 → min으로 이중차감 방지.
                        # (제외됨: trde_able ≤ 보유−미체결, min=trde_able 유지 / 미제외: min=보유−미체결)
                        pend_sell = _pending_sell_qty(account, code)
                        sellable = max(0, min(pos['trde_able_qty'], pos['qty'] - pend_sell))
                        result['hold_qty'] = pos['qty']
                        result['trde_able_qty'] = sellable
                        result['pending_sell_qty'] = pend_sell
                        result['avg_price'] = pos['avg_price']
                        result['cur_price'] = pos['cur_price']
                        result['max_qty'] = sellable
                        # 선택 단가 기준 손익 미리보기 — 단가 0이면 현재가 fallback
                        ref_price = price or pos['cur_price']
                        result['pl_preview'] = (ref_price - pos['avg_price']) * result['max_qty']
                    else:
                        result.update({'hold_qty': 0, 'trde_able_qty': 0, 'pending_sell_qty': 0,
                                       'avg_price': 0, 'cur_price': 0, 'max_qty': 0, 'pl_preview': 0})
            except Exception as e:
                traceback.print_exc()
                self._send_json(500, {'error': f'order/check failed: {e}'})
                return
            self._send_json(200, result)
            return
        if parsed.path == '/api/order/account_status':
            # 계좌 select 색/보유주수 표시용 — 한 종목에 대해 4계좌 d2_entra·보유주수 일괄 반환.
            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
            try:
                import kiwoom_client as kc
                labels = [a['label'] for a in kc.list_accounts()]
                with ThreadPoolExecutor(max_workers=len(labels) + 2) as ex:
                    pos_f = ex.submit(kc.get_positions_all, exchange='NXT', labels=labels)
                    bal_fs = {lb: ex.submit(kc.get_balance, lb) for lb in labels}
                    pos_by = pos_f.result()
                    bals = {lb: bal_fs[lb].result() for lb in labels}
                cur_price = 0
                accounts = []
                for lb in labels:
                    p = next((x for x in (pos_by.get(lb, []) or []) if x['code'] == code), None)
                    if p and p.get('cur_price') and not cur_price:
                        cur_price = p['cur_price']
                    d2 = int(bals[lb].get('d2_entra') or 0)
                    locked = _pending_buy_lock(lb)  # 미체결 매수 묶임 차감
                    hold = p['qty'] if p else 0
                    # 매도가능 = min(trde_able, 보유−미체결매도) — check 핸들러와 동일 로직
                    pend_sell = _pending_sell_qty(lb, code) if p else 0
                    sellable = max(0, min(p['trde_able_qty'], hold - pend_sell)) if p else 0
                    accounts.append({
                        'label': lb,
                        'd2_entra': d2,
                        'avail_cash': max(0, d2 - locked),
                        'pending_buy_locked': locked,
                        'hold_qty': hold,
                        'trde_able_qty': sellable,
                    })
                self._send_json(200, {'code': code, 'cur_price': cur_price, 'accounts': accounts})
            except Exception as e:
                traceback.print_exc()
                self._send_json(500, {'error': f'account_status failed: {e}'})
            return
        if parsed.path == '/api/chart_svg':
            qs = parse_qs(parsed.query)
            code = ''.join(ch for ch in (qs.get('code') or [''])[0].strip() if ch.isalnum())
            unit = (qs.get('unit') or [''])[0].strip()
            if not code:
                self._send_json(400, {'error': 'code required'})
                return
            # 분봉 분기 — 캐시 미사용 (장중 매 호출마다 ka10080 한 콜). 응답은 SVG 단일.
            if unit in ('1m', '5m', '15m'):
                tic_scope = {'1m': 1, '5m': 5, '15m': 15}[unit]
                # range 파라미터 — 30min/1h/3h/5h/1d. 분 단위로 변환. 1d=None(오늘 전체).
                range_str = (qs.get('range') or ['1d'])[0].strip()
                range_minutes_map = {'30min': 30, '1h': 60, '3h': 180, '5h': 300, '1d': None}
                if range_str not in range_minutes_map:
                    range_str = '1d'
                range_minutes = range_minutes_map[range_str]
                try:
                    import stock_analysis as sa
                    import kiwoom_client as kc
                    # _AL(통합) — 정규장(KRX)→NXT(15:30~20:00)가 한 차트에 연속.
                    mins = kc.get_minute_candles(code, tic_scope=tic_scope, today_only=True, exchange='AL')
                    if not mins:
                        self.send_error(404, 'no minute candles')
                        return
                    # range 슬라이스 — 최근 N 봉만.
                    if range_minutes is not None:
                        num_bars = max(1, range_minutes // tic_scope)
                        mins = mins[-num_bars:]
                    # render_svg_chart 호환 — date 필드에 HH:MM 박아 X축 라벨로 사용.
                    formatted = []
                    for m in mins:
                        t = m.get('time', '')
                        hhmm = f'{t[:2]}:{t[2:4]}' if len(t) >= 4 else t
                        formatted.append({**m, 'date': hhmm, 'value': 0, 'turnover_rate': None})
                    closes = [c['close'] for c in formatted]
                    snap = {
                        'candles_asc': formatted,
                        'sma5': sa.sma(closes, 5),
                        'sma20': sa.sma(closes, 20),
                        'sma60': sa.sma(closes, 60),
                    }
                    # 전일종가 — 일봉 캐시에서 오늘 이전 마지막 종가 → 기준선+전일대비 등락률 표시
                    prev_close = None
                    try:
                        import daily_candles_cache as dcc
                        _today = datetime.now(KST).strftime('%Y%m%d')
                        for b in dcc._select_latest(code, 3):
                            if b.get('date') and str(b['date']) < _today and b.get('close'):
                                prev_close = b['close']
                                break
                    except Exception:
                        prev_close = None
                    if not prev_close:
                        # 일봉 캐시 미스 → ka10095(현재가−전일대비)로 전일종가 보강. 모든 종목에 전일대비 % 표시.
                        try:
                            _q = kc.get_watchlist_quotes([code]).get(code) or {}
                            _pc = (_q.get('price') or 0) - (_q.get('change') or 0)
                            if _pc > 0:
                                prev_close = _pc
                        except Exception:
                            pass
                    svg = sa.render_svg_chart(snap, range_key='1Y', ref_price=prev_close,
                                              intraday=True, hlines=_get_chart_hlines(code))
                    body = svg.encode('utf-8')
                    body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
                    self.send_response(200)
                    self.send_header('Content-Type', 'image/svg+xml; charset=utf-8')
                    self.send_header('Content-Length', str(len(body)))
                    if enc:
                        self.send_header('Content-Encoding', enc)
                        self.send_header('Vary', 'Accept-Encoding')
                    self.send_header('Cache-Control', 'no-store')
                    self.end_headers()
                    try:
                        self.wfile.write(body)
                    except (BrokenPipeError, ConnectionResetError):
                        pass
                except Exception:
                    traceback.print_exc()
                    self.send_error(500, 'minute chart render failed')
                return
            try:
                import daily_candles_cache as dcc
                import stock_analysis as sa
                import kiwoom_client as kc

                candles_asc = dcc.get_candles(code, count=250)
                today = datetime.now(KST).strftime('%Y%m%d')
                try:
                    qmap = kc.get_watchlist_quotes([code], exchange='AL')
                    tq = qmap.get(code) if isinstance(qmap, dict) else None
                except Exception:
                    tq = None
                # 오늘 봉 거래량은 KRX 기준(일봉 히스토리와 동일 기준 → 막대·RVOL·회전율 일관).
                # 가격 OHLC는 통합(AL) 유지, 거래량만 KRX.
                krx_vol = 0
                try:
                    _qk = kc.get_watchlist_quotes([code], exchange='KRX').get(code) or {}
                    krx_vol = _qk.get('volume', 0) or 0
                except Exception:
                    krx_vol = 0

                live_today_ok = bool(
                    tq and tq.get('price') and tq.get('open')
                    and tq.get('high') and tq.get('low')
                )
                if (live_today_ok
                        and (not candles_asc or candles_asc[-1]['date'] != today)):
                    candles_asc = candles_asc + [{
                        'date': today,
                        'open': tq['open'],
                        'high': tq['high'],
                        'low': tq['low'],
                        'close': tq['price'],
                        'volume': krx_vol or tq.get('volume', 0),
                        'value': 0,
                        'turnover_rate': None,
                    }]

                if not candles_asc:
                    self.send_error(404, 'no candles')
                    return

                closes = [c['close'] for c in candles_asc]
                snap = {
                    'candles_asc': candles_asc,
                    'sma5': sa.sma(closes, 5),
                    'sma20': sa.sma(closes, 20),
                    'sma60': sa.sma(closes, 60),
                    'sma120': sa.sma(closes, 120),
                }
                # 마지막 봉이 오늘·정규장 진행형이면 세션경과 비율로 오늘 거래량을 하루치 환산(회전율 '예상')
                vfrac = None
                if candles_asc[-1]['date'] == today and _market_phase_state().get('phase') == 'regular':
                    now = datetime.now(KST)
                    hm = now.hour * 60 + now.minute
                    vfrac = min(1.0, max(0.15, (hm - 9 * 60) / 390.0))  # 09:00~15:30 = 390분
                # 오늘 회전율·RVOL 값 — 차트 아래 표시 (텍스트 라벨은 차트에 안 그림)
                _turn, _tscaled = sa.turnover_today(candles_asc, vfrac)
                _rvol, _rscaled = sa.rvol_today(candles_asc, vfrac)
                _cap_bits = []
                if _rvol is not None:
                    _cap_bits.append(f'<b>RVOL</b> x{_rvol:.1f}')
                if _turn is not None:
                    _cap_bits.append(f'<b>회전율</b> {_turn:.2f}%')
                _turn_cap = (
                    f'<div class="chart-cap">{" · ".join(_cap_bits)}{" (예상)" if (_tscaled or _rscaled) else ""}</div>'
                    if _cap_bits else ''
                )
                # 분석 페이지와 동일 패턴 — 1M/3M/6M/1Y 4 개 panel + 탭. 250 봉 캐시 슬라이스만 다름.
                # 분봉 panel 은 빈 placeholder. 탭 클릭 시 클라이언트가 lazy fetch.
                hlines = _get_chart_hlines(code)
                svg_1y = sa.render_svg_chart(snap, range_key='1Y', hlines=hlines)
                svg_6m = sa.render_svg_chart(snap, range_key='6M', hlines=hlines)
                svg_3m = sa.render_svg_chart(snap, range_key='3M', hlines=hlines)
                svg_1m = sa.render_svg_chart(snap, range_key='1M', hlines=hlines)
                # 분봉 panel 안에 기간 sub-tabs (30분/1h/3h/5h/하루). default = '1h'.
                # subpanel 은 클라이언트가 sub-toggle 이벤트로 lazy fetch.
                def _minute_panel(unit_id: str) -> str:
                    return (
                        f'<div class="chart-panel" data-range="{unit_id}" data-unit="{unit_id}">'
                        '<div class="chart-subtabs">'
                        '<button type="button" data-subrange="30min">30분</button>'
                        '<button type="button" data-subrange="1h" class="active">1시간</button>'
                        '<button type="button" data-subrange="3h">3시간</button>'
                        '<button type="button" data-subrange="5h">5시간</button>'
                        '<button type="button" data-subrange="1d">하루</button>'
                        '</div>'
                        '<div class="chart-subpanel"></div>'
                        '</div>'
                    )
                html_block = (
                    '<div class="chart-tabs">'
                    '<button type="button" data-range="1m" data-unit="1m">1분봉</button>'
                    '<button type="button" data-range="5m" data-unit="5m">5분봉</button>'
                    '<button type="button" data-range="1M" class="active">1개월</button>'
                    '<button type="button" data-range="3M">3개월</button>'
                    '<button type="button" data-range="6M">6개월</button>'
                    '<button type="button" data-range="1Y">1년</button>'
                    '</div>'
                    + _minute_panel('1m')
                    + _minute_panel('5m')
                    + f'<div class="chart-panel active" data-range="1M">{svg_1m}</div>'
                    f'<div class="chart-panel" data-range="3M">{svg_3m}</div>'
                    f'<div class="chart-panel" data-range="6M">{svg_6m}</div>'
                    f'<div class="chart-panel" data-range="1Y">{svg_1y}</div>'
                    + _turn_cap
                    + _chart_hlines_ui(code, hlines)
                )
                body = html_block.encode('utf-8')
                body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
                self.send_response(200)
                self.send_header('Content-Type', 'text/html; charset=utf-8')
                self.send_header('Content-Length', str(len(body)))
                if enc:
                    self.send_header('Content-Encoding', enc)
                    self.send_header('Vary', 'Accept-Encoding')
                self.send_header('Cache-Control', 'no-store')
                self.end_headers()
                try:
                    self.wfile.write(body)
                except (BrokenPipeError, ConnectionResetError):
                    pass
            except Exception:
                traceback.print_exc()
                self.send_error(500, 'chart render failed')
            return
        if parsed.path == '/api/idx-chart':
            qs = parse_qs(parsed.query)
            sym = (qs.get('symbol') or [''])[0]
            if sym not in _IDX_CHART_SYMBOL.values():
                self.send_error(400, 'bad symbol')
                return
            series = _fetch_idx_chart_series(sym)
            if not series:
                self.send_error(502, 'fetch failed')
                return
            body = json.dumps({'symbol': sym, 'series': series}, ensure_ascii=False).encode('utf-8')
            body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
            self.send_response(200)
            self.send_header('Content-Type', 'application/json; charset=utf-8')
            self.send_header('Content-Length', str(len(body)))
            if enc:
                self.send_header('Content-Encoding', enc)
                self.send_header('Vary', 'Accept-Encoding')
            self.send_header('Cache-Control', 'max-age=60')
            self.end_headers()
            try:
                self.wfile.write(body)
            except (BrokenPipeError, ConnectionResetError):
                pass
            return
        if parsed.path == '/api/panels':
            qs = parse_qs(parsed.query)
            tab_key = (qs.get('owner') or [None])[0]
            fresh = (qs.get('fresh') or ['0'])[0] == '1'
            # 명시적 새로고침(fresh=1)일 땐 시장 지수 캐시도 무효화 — 시장 카드 새로고침 버튼이 fresh=1 사용.
            if fresh:
                with _indices_lock:
                    _indices_cache['expires_at'] = 0
                with _market_indicators_lock:
                    _market_indicators_cache['expires_at'] = 0
                with _extra_market_quotes_lock:
                    _extra_market_quotes_cache['expires_at'] = 0
            owner = TAB_KEY_TO_OWNER.get(tab_key) if tab_key else None
            try:
                chart_days = int((qs.get('chart_days') or [str(NET_WORTH_CHART_DAYS)])[0])
            except (TypeError, ValueError):
                chart_days = NET_WORTH_CHART_DAYS
            chart_unit = (qs.get('chart_unit') or [NET_WORTH_CHART_UNIT])[0]
            if chart_unit not in {u for _, u in NET_WORTH_UNIT_PRESETS}:
                chart_unit = NET_WORTH_CHART_UNIT
            # 기간×단위 비허용 조합은 첫 허용 단위로 강제 (stale localStorage 방어)
            _allowed = NET_WORTH_ALLOWED_UNITS_BY_DAYS.get(chart_days)
            if _allowed and chart_unit not in _allowed:
                chart_unit = _allowed[0]
            chart_mode = (qs.get('chart_mode') or [NET_WORTH_CHART_MODE])[0]
            if chart_mode not in {m for _, m in NET_WORTH_MODE_PRESETS}:
                chart_mode = NET_WORTH_CHART_MODE
            try:
                adr_days = int((qs.get('adr_days') or [str(ADR_TREND_DAYS)])[0])
            except (TypeError, ValueError):
                adr_days = ADR_TREND_DAYS
            if adr_days not in {d for _, d in ADR_TREND_PRESETS}:
                adr_days = ADR_TREND_DAYS
            # chart_ym — 'YYYY'(연도별) 또는 'YYYY-MM'(월별). 형식 위반은 무시(빈값=프리셋).
            chart_ym = (qs.get('chart_ym') or [''])[0]
            _ymp = (chart_ym or '').split('-')
            _ym_ok = (
                (len(_ymp) == 1 and len(_ymp[0]) == 4 and _ymp[0].isdigit())
                or (len(_ymp) == 2 and len(_ymp[0]) == 4 and _ymp[0].isdigit() and len(_ymp[1]) == 2 and _ymp[1].isdigit())
            )
            if not _ym_ok:
                chart_ym = ''
            try:
                body = _get_cached_panels_json(owner=owner, fresh=fresh, chart_days=chart_days, chart_unit=chart_unit, chart_mode=chart_mode, adr_days=adr_days, chart_ym=chart_ym)
            except Exception:
                traceback.print_exc()
                self.send_error(500, 'panels failed')
                return
            body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
            self.send_response(200)
            self.send_header('Content-Type', 'application/json; charset=utf-8')
            self.send_header('Content-Length', str(len(body)))
            if enc:
                self.send_header('Content-Encoding', enc)
                self.send_header('Vary', 'Accept-Encoding')
            self.send_header('Cache-Control', 'no-store')
            self.send_header('Referrer-Policy', 'no-referrer')
            self.end_headers()
            try:
                self.wfile.write(body)
            except (BrokenPipeError, ConnectionResetError):
                pass
            return
        # /stock/<code> — 종목 분석 상세 페이지
        if parsed.path.startswith('/stock/'):
            tail = parsed.path[len('/stock/'):].strip('/')
            if not tail:
                self.send_error(404)
                return
            code = ''.join(ch for ch in tail.split('/')[0] if ch.isalnum())
            if not code:
                self.send_error(400, 'bad stock code')
                return
            qs = parse_qs(parsed.query)
            selected = (qs.get('r') or [None])[0]
            err_msg = (qs.get('err') or [None])[0]
            try:
                import stock_analysis as sa
                page_html = sa.render_stock_page(code, selected_filename=selected, error_msg=err_msg)
            except Exception:
                traceback.print_exc()
                self.send_error(500, 'analysis page failed')
                return
            body = page_html.encode('utf-8')
            body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
            self.send_response(200)
            self.send_header('Content-Type', 'text/html; charset=utf-8')
            self.send_header('Content-Length', str(len(body)))
            if enc:
                self.send_header('Content-Encoding', enc)
                self.send_header('Vary', 'Accept-Encoding')
            self.send_header('Cache-Control', 'no-store')
            self.send_header('Referrer-Policy', 'no-referrer')
            self.end_headers()
            try:
                self.wfile.write(body)
            except (BrokenPipeError, ConnectionResetError):
                pass
            return

        if parsed.path not in ('/', '/index.html'):
            self.send_error(404)
            return
        try:
            # 셸 HTML은 데이터 fetch 없이 즉시 응답 — 키움 호출은 /api/panels에서.
            html_body = render_html()
        except Exception:
            traceback.print_exc()
            self.send_error(500, 'render failed')
            return
        body = html_body.encode('utf-8')
        body, enc = _maybe_gzip(body, self.headers.get('Accept-Encoding'))
        self.send_response(200)
        self.send_header('Content-Type', 'text/html; charset=utf-8')
        self.send_header('Content-Length', str(len(body)))
        if enc:
            self.send_header('Content-Encoding', enc)
            self.send_header('Vary', 'Accept-Encoding')
        self.send_header('Cache-Control', 'no-store')
        self.send_header('Referrer-Policy', 'no-referrer')
        self.end_headers()
        try:
            self.wfile.write(body)
        except (BrokenPipeError, ConnectionResetError):
            pass

    def do_HEAD(self):  # noqa: N802
        # PWA navigation preload·link preview·NAS reverse proxy health check 등이 HEAD를 보낸다.
        # 키움 호출 트리거 없이 200만 돌려준다.
        if self.path not in ('/', '/index.html'):
            self.send_error(404)
            return
        self.send_response(200)
        self.send_header('Content-Type', 'text/html; charset=utf-8')
        self.send_header('Cache-Control', 'no-store')
        self.send_header('Referrer-Policy', 'no-referrer')
        self.end_headers()

    def do_POST(self):  # noqa: N802
        wl_actions = {'/delete': 'delete', '/restore': 'restore', '/purge': 'purge'}
        in_actions = {'/interests/add': 'add', '/interests/update': 'update', '/interests/delete': 'delete'}
        length = int(self.headers.get('Content-Length', 0))
        body = self.rfile.read(length).decode('utf-8') if length else ''
        from urllib.parse import parse_qs
        # keep_blank_values=True: edit modal의 "비우면 제거" UX가 동작하려면 빈 값도 payload에 살아있어야 한다.
        params = parse_qs(body, keep_blank_values=True)
        stock = (params.get('stock') or [''])[0].strip()
        wants_json = 'application/json' in (self.headers.get('Accept') or '')

        if self.path == '/api/order/propose':
            # 거래 모달 1단계: 주문 정보 → handler.propose_and_send → 카드 발급, PIN은 텔레그램.
            account = (params.get('account') or [''])[0].strip()
            side = (params.get('side') or [''])[0].strip().upper()
            symbol = ''.join(ch for ch in (params.get('symbol') or [''])[0].strip() if ch.isalnum())
            symbol_name = (params.get('symbol_name') or [''])[0].strip()
            order_type = (params.get('order_type') or ['LIMIT'])[0].strip().upper()
            try:
                qty = int((params.get('qty') or ['0'])[0].strip() or '0')
            except ValueError:
                qty = 0
            price_raw = (params.get('price') or [''])[0].strip()
            try:
                price = int(price_raw) if price_raw else None
            except ValueError:
                price = None
            stop_raw = (params.get('stop_price') or [''])[0].strip()
            try:
                stop_price = int(stop_raw) if stop_raw else None
            except ValueError:
                stop_price = None
            steps_raw = (params.get('trail_steps') or [''])[0].strip()
            try:
                trail_steps = json.loads(steps_raw) if steps_raw else None
            except ValueError:
                trail_steps = None
            min_sell_raw = (params.get('min_sell_price') or [''])[0].strip()
            try:
                min_sell_price = int(min_sell_raw) if min_sell_raw else None
            except ValueError:
                min_sell_price = None
            if not account or side not in ('BUY', 'SELL') or not symbol or qty <= 0:
                self._send_json(400, {'ok': False, 'error': 'account/side/symbol/qty required'})
                return
            if order_type not in ('LIMIT', 'MARKET', 'STOP_LIMIT', 'TRAILING_STOP'):
                self._send_json(400, {'ok': False,
                                       'error': 'order_type must be LIMIT|MARKET|STOP_LIMIT|TRAILING_STOP'})
                return
            if order_type == 'LIMIT' and (price is None or price <= 0):
                self._send_json(400, {'ok': False, 'error': 'LIMIT requires positive price'})
                return
            if order_type == 'STOP_LIMIT':
                if side != 'SELL':
                    self._send_json(400, {'ok': False, 'error': '스톱지정가는 매도만 지원'})
                    return
                if price is None or price <= 0 or stop_price is None or stop_price <= 0:
                    self._send_json(400, {'ok': False, 'error': '스톱지정가는 단가와 조건단가가 모두 필요'})
                    return
            if order_type == 'TRAILING_STOP':
                # 계단별 조건단가·지정가·주수는 handler 가 현재가·보유수량 기준으로 계산 —
                # 여기선 계단 정의(하락률·누적 비중)만 받는다. 형식 검증은 handler 가
                # orders/trailing.normalize_steps 로 하므로 여기선 최소한만 본다.
                if side != 'SELL':
                    self._send_json(400, {'ok': False, 'error': '트레일링 스톱은 매도만 지원'})
                    return
                if not isinstance(trail_steps, list) or not trail_steps:
                    self._send_json(400, {'ok': False, 'error': '매도 계단을 입력하세요'})
                    return
                if min_sell_price is not None and min_sell_price <= 0:
                    self._send_json(400, {'ok': False, 'error': '최저 매도가는 0보다 커야 합니다'})
                    return
            try:
                if not symbol_name:
                    import kiwoom_client as kc
                    try:
                        meta = kc.resolve_stock_code(symbol)
                        symbol_name = meta.get('name') or symbol
                    except Exception:
                        symbol_name = symbol
                from orders import handler
                # propose_trade 직접 호출 — 거부 메시지는 web 토스트로만 (텔레그램 중복 발송 X).
                # 성공 시에만 카드+PIN을 텔레그램으로 발송 (사용자가 PIN 받아야 함).
                res = handler.propose_trade(
                    account=account, side=side, symbol=symbol, symbol_name=symbol_name,
                    qty=qty, order_type=order_type,
                    price=(price if order_type in ('LIMIT', 'STOP_LIMIT') else None),
                    stop_price=(stop_price if order_type == 'STOP_LIMIT' else None),
                    trail_steps=(trail_steps if order_type == 'TRAILING_STOP' else None),
                    min_sell_price=(min_sell_price if order_type == 'TRAILING_STOP' else None),
                )
                if res.get('ok'):
                    # 매수/매도 미리보기 카드 메시지·PIN 메시지 모두 텔레그램 발송 X.
                    # 텔레그램은 매매등록(submit_with_pin)·매매체결(fill_watcher) 시점만 발송.
                    # PIN은 iMessage로 iOS 자동입력 지원.
                    try:
                        from orders.pin import PinStore as _PinStore
                        _pending = _PinStore().peek()
                        if _pending and not _pending.consumed:
                            side_label = '매수' if side == 'BUY' else '매도'
                            handler.send_imessage_pin(
                                pin=_pending.pin,
                                card_id=_pending.card_id,
                                side_label=side_label,
                                symbol_name=symbol_name,
                            )
                    except Exception:
                        traceback.print_exc()
            except Exception as e:
                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

        if self.path == '/api/order/propose_multi':
            # 거래 모달 매도 — 같은 소유자 그룹 2계좌 동시 매도. 카드 1장 + PIN 1개가 두 레그 승인.
            # accounts/qtys 는 콤마 구분 동수 리스트 (예: accounts=일반,ISA & qtys=5,12).
            side = (params.get('side') or ['SELL'])[0].strip().upper()
            symbol = ''.join(ch for ch in (params.get('symbol') or [''])[0].strip() if ch.isalnum())
            symbol_name = (params.get('symbol_name') or [''])[0].strip()
            order_type = (params.get('order_type') or ['LIMIT'])[0].strip().upper()
            accounts = [a.strip() for a in (params.get('accounts') or [''])[0].split(',') if a.strip()]
            try:
                qtys = [int(q.strip()) for q in (params.get('qtys') or [''])[0].split(',') if q.strip()]
            except ValueError:
                qtys = []
            price_raw = (params.get('price') or [''])[0].strip()
            try:
                price = int(price_raw) if price_raw else None
            except ValueError:
                price = None
            if side != 'SELL' or not symbol or len(accounts) < 2 \
                    or len(accounts) != len(qtys) or any(q <= 0 for q in qtys):
                self._send_json(400, {'ok': False, 'error': 'side=SELL + accounts/qtys(2개 이상, 동수) 필요'})
                return
            if order_type not in ('LIMIT', 'MARKET'):
                self._send_json(400, {'ok': False, 'error': 'order_type must be LIMIT|MARKET'})
                return
            if order_type == 'LIMIT' and (price is None or price <= 0):
                self._send_json(400, {'ok': False, 'error': 'LIMIT requires positive price'})
                return
            try:
                if not symbol_name:
                    import kiwoom_client as kc
                    try:
                        meta = kc.resolve_stock_code(symbol)
                        symbol_name = meta.get('name') or symbol
                    except Exception:
                        symbol_name = symbol
                from orders import handler
                # 단일 propose 와 동일 정책 — 거부는 web 토스트만, PIN 은 iMessage.
                res = handler.propose_trade_multi(
                    legs=[{'account': a, 'qty': q} for a, q in zip(accounts, qtys)],
                    side='SELL', symbol=symbol, symbol_name=symbol_name,
                    order_type=order_type,
                    price=(price if order_type == 'LIMIT' else None),
                )
                if res.get('ok'):
                    try:
                        from orders.pin import PinStore as _PinStore
                        _pending = _PinStore().peek()
                        if _pending and not _pending.consumed:
                            handler.send_imessage_pin(
                                pin=_pending.pin,
                                card_id=_pending.card_id,
                                side_label='매도(2계좌)',
                                symbol_name=symbol_name,
                            )
                    except Exception:
                        traceback.print_exc()
            except Exception as e:
                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

        if self.path == '/api/order/verify':
            # 거래 모달 2단계: PIN 검증 → 키움 실주문 (dry_run=False).
            pin = (params.get('pin') or [''])[0].strip()
            if not pin:
                self._send_json(400, {'ok': False, 'error': 'pin required'})
                return
            try:
                from orders import handler
                # submit_with_pin 직접 호출 — 거부 시 텔레그램 중복 발송 X.
                # 성공·체결 시에만 텔레그램 결과 발송.
                res = handler.submit_with_pin(pin, dry_run=False)
                if res.get('ok') and res.get('message'):
                    try:
                        handler.send_telegram(res['message'], parse_mode='Markdown')
                    except Exception:
                        traceback.print_exc()
            except Exception as e:
                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

        if self.path == '/api/orders/cancel':
            # 미체결 주문 취소 (kt10003). ord_no + account 필수.
            ord_no = (params.get('ord_no') or [''])[0].strip()
            account = (params.get('account') or [''])[0].strip()
            if not ord_no or not account:
                self._send_json(400, {'ok': False, 'error': 'ord_no/account required'})
                return
            try:
                from orders import handler
                res = handler.cancel_open_order(ord_no=ord_no, account=account)
                # 매매체결(취소) 알림은 텔레그램으로 (handler.cancel_open_order는 send 안 함)
                if res.get('ok') and res.get('message'):
                    try:
                        handler.send_telegram(res['message'], parse_mode=None)
                    except Exception:
                        traceback.print_exc()
            except Exception as e:
                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':
            # 거래 모달 닫기 시 활성 카드 정리 (자동). 텔레그램 알림 X — 웹 자동 cancel은 사용자가 명시 액션 아님.
            try:
                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()
                self._send_json(500, {'ok': False, 'error': f'cancel failed: {e}'})
                return
            return

        if self.path in wl_actions:
            action = wl_actions[self.path]
            if not stock:
                self.send_error(400, 'Bad Request', explain='종목명 누락')
                return
            try:
                _apply_watchlist_action(action, stock)
                _invalidate_panels_cache()
            except KeyError:
                self.send_error(404, 'Not Found', explain=f'감시종목에 없음: {stock}')
                return
            except Exception as e:
                traceback.print_exc()
                self.send_error(500, 'Internal Error', explain=str(e))
                return
            sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} stock={stock} → ok\n')
            sys.stdout.flush()
            self.send_response(303)
            self.send_header('Location', '/#tab-wl')
            self.end_headers()
            return

        if self.path == '/alerts/reset':
            if not stock:
                self.send_error(400, 'Bad Request', explain='종목명 누락')
                return
            try:
                _apply_alerts_reset(stock)
            except Exception as e:
                traceback.print_exc()
                self.send_error(500, 'Internal Error', explain=str(e))
                return
            sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} stock={stock} → ok\n')
            sys.stdout.flush()
            self.send_response(303)
            self.send_header('Location', '/#tab-wl')
            self.end_headers()
            return

        if self.path == '/interests/groups/set':
            # 관심종목 그룹 멤버십 편집 — stock(name) 필수. groups 는 체크박스 다중값(반복 필드).
            # new_group 있으면 먼저 생성 후 포함.
            if not stock:
                if wants_json:
                    self._send_json(400, {'ok': False, 'error': '종목명 누락'})
                else:
                    self.send_error(400, 'Bad Request', explain='종목명 누락')
                return
            group_names = [g.strip() for g in (params.get('groups') or []) if g.strip()]
            new_group = (params.get('new_group') or [''])[0].strip()
            try:
                if new_group:
                    _create_interest_group(new_group)
                    group_names.append(new_group)
                _set_interest_groups(stock, group_names)
                _invalidate_panels_cache()
            except ValueError as e:
                if wants_json:
                    self._send_json(400, {'ok': False, 'error': str(e)})
                else:
                    self.send_error(400, 'Bad Request', explain=str(e))
                return
            except Exception as e:
                traceback.print_exc()
                if wants_json:
                    self._send_json(500, {'ok': False, 'error': str(e)})
                else:
                    self.send_error(500, 'Internal Error', explain=str(e))
                return
            sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} stock={stock} groups={group_names} → ok\n')
            sys.stdout.flush()
            if wants_json:
                self._send_json(200, {'ok': True})
            else:
                self.send_response(303)
                self.send_header('Location', '/#tab-interests')
                self.end_headers()
            return

        if self.path == '/interests/groups':
            # 그룹 레지스트리 관리 — op ∈ {create, rename, delete, reorder}.
            # 관리 모달은 shell 직속이라 panels 리로드로 자동 갱신 안 됨 → 응답에 갱신 목록 동봉.
            op = (params.get('op') or [''])[0].strip()
            name = (params.get('name') or [''])[0].strip()
            new_name = (params.get('new_name') or [''])[0].strip()
            order_raw = (params.get('order') or [''])[0]
            try:
                if op == 'create':
                    _create_interest_group(name)
                elif op == 'rename':
                    _rename_interest_group(name, new_name)
                elif op == 'delete':
                    _delete_interest_group(name)
                elif op == 'reorder':
                    order = [x.strip() for x in order_raw.split(',') if x.strip()]
                    _reorder_interest_groups(order)
                elif op == 'list':
                    pass  # 순수 조회 — 관리 모달 초기 로드용
                else:
                    raise ValueError(f'unknown op: {op}')
                if op != 'list':
                    _invalidate_panels_cache()
            except ValueError as e:
                if wants_json:
                    self._send_json(400, {'ok': False, 'error': str(e)})
                else:
                    self.send_error(400, 'Bad Request', explain=str(e))
                return
            except Exception as e:
                traceback.print_exc()
                if wants_json:
                    self._send_json(500, {'ok': False, 'error': str(e)})
                else:
                    self.send_error(500, 'Internal Error', explain=str(e))
                return
            reg = _load_interest_groups()
            counts: dict = {}
            for names in reg.get('members', {}).values():
                for n in names:
                    counts[n] = counts.get(n, 0) + 1
            sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} op={op} name={name!r} → ok\n')
            sys.stdout.flush()
            if wants_json:
                self._send_json(200, {'ok': True, 'groups': reg.get('groups', []), 'counts': counts})
            else:
                self.send_response(303)
                self.send_header('Location', '/#tab-interests')
                self.end_headers()
            return

        if self.path in in_actions:
            action = in_actions[self.path]
            # add는 stock 또는 code 둘 중 하나면 OK — _apply_interests_action 안에서 검증.
            if not stock and action != 'add':
                if wants_json:
                    self._send_json(400, {'ok': False, 'error': '종목명 누락'})
                else:
                    self.send_error(400, 'Bad Request', explain='종목명 누락')
                return
            payload = {k: (v[0] if v else '') for k, v in params.items() if k != 'stock'}
            try:
                _apply_interests_action(action, stock, payload)
                _invalidate_panels_cache()
            except MultipleMatchError as e:
                cands = [{'name': c['name'], 'code': c['code']} for c in e.candidates]
                if wants_json:
                    self._send_json(200, {'ok': False, 'error': 'multiple', 'query': e.query, 'candidates': cands})
                else:
                    names = ', '.join(c['name'] for c in e.candidates[:5])
                    self.send_error(409, 'Conflict', explain=f'"{e.query}"와 일치하는 종목이 여러 개입니다. 후보: {names}')
                return
            except KeyError:
                msg = f'관심종목에 없음: {stock}'
                if wants_json:
                    self._send_json(404, {'ok': False, 'error': msg})
                else:
                    self.send_error(404, 'Not Found', explain=msg)
                return
            except ValueError as e:
                if wants_json:
                    self._send_json(400, {'ok': False, 'error': str(e)})
                else:
                    self.send_error(400, 'Bad Request', explain=str(e))
                return
            except Exception as e:
                traceback.print_exc()
                if wants_json:
                    self._send_json(500, {'ok': False, 'error': str(e)})
                else:
                    self.send_error(500, 'Internal Error', explain=str(e))
                return
            sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} stock={stock} → ok\n')
            sys.stdout.flush()
            if wants_json:
                self._send_json(200, {'ok': True})
            else:
                self.send_response(303)
                self.send_header('Location', '/#tab-interests')
                self.end_headers()
            return

        if self.path == '/tags/set':
            # 종목 공용 태그 — stock(name) 또는 code 둘 중 하나는 필수.
            # tag(text)+leader 모두 비어/false면 항목 제거.
            code = (params.get('code') or [''])[0].strip()
            tag_raw = (params.get('tag') or [''])[0]
            leader_raw = (params.get('leader') or ['0'])[0].strip().lower()
            leader = leader_raw in ('1', 'true', 'on', 'yes')
            if not stock and not code:
                if wants_json:
                    self._send_json(400, {'ok': False, 'error': '종목 식별자 없음 (stock 또는 code 필요)'})
                else:
                    self.send_error(400, 'Bad Request', explain='종목 식별자 없음')
                return
            try:
                _set_stock_tag(code or None, stock or None, tag_raw, leader)
                _invalidate_panels_cache()
            except ValueError as e:
                if wants_json:
                    self._send_json(400, {'ok': False, 'error': str(e)})
                else:
                    self.send_error(400, 'Bad Request', explain=str(e))
                return
            except Exception as e:
                traceback.print_exc()
                if wants_json:
                    self._send_json(500, {'ok': False, 'error': str(e)})
                else:
                    self.send_error(500, 'Internal Error', explain=str(e))
                return
            sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} stock={stock} code={code} tag={tag_raw!r} leader={leader} → ok\n')
            sys.stdout.flush()
            if wants_json:
                self._send_json(200, {'ok': True})
            else:
                self.send_response(303)
                self.send_header('Location', '/')
                self.end_headers()
            return

        if self.path in ('/chart/hlines/add', '/chart/hlines/remove'):
            # 종목차트 사용자 수평선 추가/제거 — code+price 필수. 응답은 JSON(갱신된 리스트 포함).
            code = ''.join(ch for ch in (params.get('code') or [''])[0].strip() if ch.isalnum())
            price_raw = (params.get('price') or [''])[0].strip()
            if not code or not price_raw:
                self._send_json(400, {'ok': False, 'error': 'code/price 필요'})
                return
            try:
                if self.path.endswith('/add'):
                    _add_chart_hline(code, price_raw)
                else:
                    _remove_chart_hline(code, price_raw)
            except ValueError as e:
                self._send_json(400, {'ok': False, 'error': str(e)})
                return
            except Exception as e:
                traceback.print_exc()
                self._send_json(500, {'ok': False, 'error': str(e)})
                return
            sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} code={code} price={price_raw} → ok\n')
            sys.stdout.flush()
            self._send_json(200, {'ok': True, 'lines': _get_chart_hlines(code)})
            return

        if self.path == '/holdeval/save':
            # 보유 평가 계산기 저장 — owner/code/name/avg/qty. code·avg·qty 필수.
            owner = (params.get('owner') or [''])[0].strip()
            code = (params.get('code') or [''])[0].strip()
            name = (params.get('name') or [''])[0].strip()
            try:
                avg = float((params.get('avg') or ['0'])[0].strip() or '0')
                qty = float((params.get('qty') or ['0'])[0].strip() or '0')
            except ValueError:
                avg = qty = 0
            try:
                items = _add_holdeval_saved(owner, code, name, avg, qty)
                self._send_json(200, {'ok': True, 'items': items})
            except ValueError as e:
                self._send_json(400, {'ok': False, 'error': str(e)})
            except Exception as e:
                traceback.print_exc()
                self._send_json(500, {'ok': False, 'error': str(e)})
            return

        if self.path == '/holdeval/update':
            # 저장 항목 수정 — id 필수, owner/avg/qty 갱신. code/name은 있으면 교체.
            entry_id = (params.get('id') or [''])[0].strip()
            owner = (params.get('owner') or [''])[0].strip()
            code = (params.get('code') or [''])[0].strip()
            name = (params.get('name') or [''])[0].strip()
            try:
                avg = float((params.get('avg') or ['0'])[0].strip() or '0')
                qty = float((params.get('qty') or ['0'])[0].strip() or '0')
            except ValueError:
                avg = qty = 0
            if not entry_id:
                self._send_json(400, {'ok': False, 'error': 'id required'})
                return
            try:
                items = _update_holdeval_saved(entry_id, owner, avg, qty, code, name)
                self._send_json(200, {'ok': True, 'items': items})
            except ValueError as e:
                self._send_json(400, {'ok': False, 'error': str(e)})
            except Exception as e:
                traceback.print_exc()
                self._send_json(500, {'ok': False, 'error': str(e)})
            return

        if self.path == '/holdeval/delete':
            entry_id = (params.get('id') or [''])[0].strip()
            if not entry_id:
                self._send_json(400, {'ok': False, 'error': 'id required'})
                return
            try:
                items = _delete_holdeval_saved(entry_id)
                self._send_json(200, {'ok': True, 'items': items})
            except Exception as e:
                traceback.print_exc()
                self._send_json(500, {'ok': False, 'error': str(e)})
            return

        if self.path == '/notes/set':
            # 종목 자유 메모 — stock(name) 또는 code 둘 중 하나는 필수.
            # note 가 빈 텍스트면 항목 제거.
            code = (params.get('code') or [''])[0].strip()
            note_raw = (params.get('note') or [''])[0]
            if not stock and not code:
                if wants_json:
                    self._send_json(400, {'ok': False, 'error': '종목 식별자 없음 (stock 또는 code 필요)'})
                else:
                    self.send_error(400, 'Bad Request', explain='종목 식별자 없음')
                return
            try:
                _set_stock_note(code or None, stock or None, note_raw)
                _invalidate_panels_cache()
            except ValueError as e:
                if wants_json:
                    self._send_json(400, {'ok': False, 'error': str(e)})
                else:
                    self.send_error(400, 'Bad Request', explain=str(e))
                return
            except Exception as e:
                traceback.print_exc()
                if wants_json:
                    self._send_json(500, {'ok': False, 'error': str(e)})
                else:
                    self.send_error(500, 'Internal Error', explain=str(e))
                return
            sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} stock={stock} code={code} note_len={len(note_raw.strip())} → ok\n')
            sys.stdout.flush()
            if wants_json:
                self._send_json(200, {'ok': True})
            else:
                self.send_response(303)
                self.send_header('Location', '/')
                self.end_headers()
            return

        if self.path == '/surge/toggle':
            # 급등락 알림 on/off. code 필수 (모니터가 코드로 시세를 조회하므로 name fallback 없음).
            # on 파라미터 없으면 현재 상태를 뒤집는다.
            code = (params.get('code') or [''])[0].strip()
            on_raw = (params.get('on') or [''])[0].strip()
            if not code:
                if wants_json:
                    self._send_json(400, {'ok': False, 'error': '종목코드 없음'})
                else:
                    self.send_error(400, 'Bad Request', explain='종목코드 없음')
                return
            try:
                on = (on_raw in ('1', 'true', 'on')) if on_raw else (not _is_surge_on(code))
                on = _set_surge_toggle(code, stock, on)
                _invalidate_panels_cache()
            except ValueError as e:
                if wants_json:
                    self._send_json(400, {'ok': False, 'error': str(e)})
                else:
                    self.send_error(400, 'Bad Request', explain=str(e))
                return
            except Exception as e:
                traceback.print_exc()
                if wants_json:
                    self._send_json(500, {'ok': False, 'error': str(e)})
                else:
                    self.send_error(500, 'Internal Error', explain=str(e))
                return
            sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} code={code} stock={stock} on={on} → ok\n')
            sys.stdout.flush()
            if wants_json:
                self._send_json(200, {'ok': True, 'on': on})
            else:
                self.send_response(303)
                self.send_header('Location', '/')
                self.end_headers()
            return

        # /stock/<code>/generate — 새 보고서 큐 등록
        if self.path.startswith('/stock/') and self.path.endswith('/generate'):
            tail = self.path[len('/stock/'):-len('/generate')]
            code = ''.join(ch for ch in tail.split('/')[0] if ch.isalnum())
            if not code:
                self.send_error(400, 'bad stock code')
                return
            try:
                import stock_analysis as sa
                result = sa.enqueue(code)
            except Exception as e:
                traceback.print_exc()
                self.send_error(500, f'enqueue failed: {e}')
                return
            sys.stdout.write(f'[{self.log_date_time_string()}] {self.address_string()} POST {self.path} code={code} → {result["status"]}\n')
            sys.stdout.flush()
            self.send_response(303)
            self.send_header('Location', f'/stock/{code}')
            self.end_headers()
            return

        # /stock/<code>/peers/add | /peers/delete
        if self.path.startswith('/stock/') and ('/peers/' in self.path):
            tail = self.path[len('/stock/'):]
            parts = tail.split('/')
            code = ''.join(ch for ch in parts[0] if ch.isalnum())
            action = parts[2] if len(parts) >= 3 else ''
            if not code or action not in ('add', 'delete'):
                self.send_error(400, 'bad peers route')
                return
            import stock_analysis as sa
            if action == 'add':
                query = (params.get('stock') or [''])[0].strip()
                if not query:
                    self.send_error(400, 'stock empty')
                    return
                from urllib.parse import quote as _urlquote
                err_redirect: str | None = None
                try:
                    p = sa.add_peer(code, query)
                    sys.stdout.write(f'[{self.log_date_time_string()}] POST peers/add code={code} → {p["code"]} {p["name"]}\n')
                except sa.MultipleMatchError as e:
                    names = ', '.join(c['name'] for c in e.candidates[:5])
                    err_redirect = f'"{e.query}"와 일치하는 종목이 여러 개예요. 더 정확히 입력해주세요. 후보: {names}'
                except ValueError as e:
                    err_redirect = str(e)
                except Exception as e:
                    traceback.print_exc()
                    err_redirect = f'추가 실패: {e}'
                if err_redirect:
                    sys.stdout.flush()
                    self.send_response(303)
                    self.send_header('Location', f'/stock/{code}?err={_urlquote(err_redirect)}')
                    self.end_headers()
                    return
            else:  # delete
                peer_code = (params.get('peer_code') or [''])[0].strip()
                if not peer_code:
                    self.send_error(400, 'peer_code empty')
                    return
                try:
                    sa.remove_peer(code, peer_code)
                    sys.stdout.write(f'[{self.log_date_time_string()}] POST peers/delete code={code} → {peer_code}\n')
                except Exception as e:
                    traceback.print_exc()
                    self.send_error(500, f'peers delete failed: {e}')
                    return
            sys.stdout.flush()
            self.send_response(303)
            self.send_header('Location', f'/stock/{code}')
            self.end_headers()
            return

        self.send_error(404)


# ---------------- 실시간 시세 허브 (realtime_hub 연동) ----------------
# 키움 WebSocket 실시간 현재가(0B)·VI(1h)를 메모리에 들고 화면에 라이브로 표시.
# kill-switch: 환경변수 BEHIVE_REALTIME=0 이면 비활성(REST 폴백만). 기본 활성.
# hub 시작·연결 실패가 페이지 서빙을 막지 않도록 전부 fail-safe.
_RT_HUB = None
_RT_MAX_CODES = 200  # 폭주 방지 상한. 키움은 59종목(=118등록) 수락 확인. 초과분은 우선순위 컷+로깅.


def _rt_enabled() -> bool:
    import os
    return os.environ.get('BEHIVE_REALTIME', '1') not in ('0', 'false', 'no')


def _rt_gather_codes() -> list:
    """구독 대상: 보유(4계좌) > 관심 > 감시 우선순위. 상한 초과분은 잘라내고 로깅."""
    import kiwoom_client as kc
    seen: set = set()
    codes: list = []

    def _add(code):
        if not code:
            return
        cc = kc._clean_code(code)
        if cc and cc not in seen:
            seen.add(cc)
            codes.append(cc)

    try:
        for _label, positions in (kc.get_positions_all() or {}).items():
            for p in positions:
                _add(p.get('code'))
    except Exception:
        traceback.print_exc()
    try:
        for c in _load_interests():
            _add(c.get('code'))
    except Exception:
        pass
    try:
        for c in _load_watchlist():
            _add(c.get('code'))
    except Exception:
        pass
    if len(codes) > _RT_MAX_CODES:
        dropped = codes[_RT_MAX_CODES:]
        sys.stderr.write(f'[realtime] 구독 상한 {_RT_MAX_CODES} 초과 → {len(dropped)}종목 제외: {dropped}\n')
        codes = codes[:_RT_MAX_CODES]
    return codes


def _rt_resubscribe_loop():
    while True:
        try:
            if _RT_HUB is not None:
                codes = _rt_gather_codes()
                if codes:
                    _RT_HUB.set_subscriptions(codes)
        except Exception:
            traceback.print_exc()
        time.sleep(60)


def _rt_start():
    """실시간 허브 기동 (fail-safe). 실패해도 서버는 정상 서빙(REST 폴백)."""
    global _RT_HUB
    if not _rt_enabled():
        print('[realtime] BEHIVE_REALTIME=0 → 실시간 허브 비활성 (REST 폴백)', flush=True)
        return
    try:
        import realtime_hub as rh
        _RT_HUB = rh.RealtimeHub()
        codes = _rt_gather_codes()
        _RT_HUB.set_subscriptions(codes)
        _RT_HUB.start()
        threading.Thread(target=_rt_resubscribe_loop, name='realtime-resub', daemon=True).start()
        print(f'[realtime] 허브 기동 — {len(codes)}종목 구독', flush=True)
    except Exception:
        traceback.print_exc()
        _RT_HUB = None
        print('[realtime] 허브 기동 실패 → REST 폴백으로 계속', flush=True)


def cmd_serve() -> int:
    _rt_start()
    server = ThreadingHTTPServer((BIND_HOST, BIND_PORT), Handler)
    print(f'serving on http://{BIND_HOST}:{BIND_PORT}', flush=True)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    return 0


def cmd_render(out: str | None) -> int:
    body = render_html()
    if out:
        Path(out).write_text(body)
        print(f'wrote {out} ({len(body)} bytes)')
    else:
        sys.stdout.write(body)
    return 0


def main(argv: list[str]) -> int:
    p = argparse.ArgumentParser(prog='behive_web')
    sub = p.add_subparsers(dest='cmd', required=True)
    sub.add_parser('serve')
    pr = sub.add_parser('render')
    pr.add_argument('--out')
    args = p.parse_args(argv)
    if args.cmd == 'serve':
        return cmd_serve()
    if args.cmd == 'render':
        return cmd_render(args.out)
    return 2


if __name__ == '__main__':
    sys.exit(main(sys.argv[1:]))
