PageSourceSearch

https://heattab.app/app/app.js?v=1.4.1

js heattab.app collected 2026-10-02 01:13:54 UTC 378,917 bytes, 6,630 lines download raw bytes

1    const stocks = [
2      { n: '삼성전자', c: '005930', s: '삼전', sec: 'IT', m: 420, p: 74200, d: 2.42, w: 4.9, mo: 7.8, y: 18.4 },
3      { n: 'SK하이닉스', c: '000660', s: '하이닉스', sec: 'IT', m: 145, p: 218500, d: 3.15, w: 6.2, mo: 9.1, y: 42.0 },
4      { n: 'LG에너지솔루션', c: '373220', s: 'LG엔솔', sec: '2차전지', m: 92, p: 394000, d: -1.84, w: -4.2, mo: -8.5, y: -12.7 },
5      { n: '삼성바이오로직스', c: '207940', s: '삼바', sec: '헬스케어', m: 71, p: 998000, d: 0.64, w: 1.2, mo: 6.4, y: 16.6 },
6      { n: '현대차', c: '005380', s: '현대차', sec: '자동차', m: 58, p: 276000, d: -0.92, w: 2.0, mo: 4.5, y: 22.4 },
7      { n: '기아', c: '000270', s: '기아', sec: '자동차', m: 47, p: 119300, d: -1.18, w: -0.7, mo: 3.2, y: 19.1 },
8      { n: 'NAVER', c: '035420', s: '네이버', sec: '커뮤니케이션', m: 34, p: 204000, d: 1.55, w: 2.8, mo: -1.7, y: 6.2 },
9      { n: '카카오', c: '035720', s: '카카오', sec: '커뮤니케이션', m: 19, p: 42750, d: -2.36, w: -5.4, mo: -11.0, y: -18.2 },
10      { n: 'POSCO홀딩스', c: '005490', s: '포스코홀딩스', sec: '소재', m: 32, p: 379500, d: -0.34, w: -1.1, mo: -3.9, y: 2.4 },
11      { n: 'LG화학', c: '051910', s: 'LG화학', sec: '소재', m: 29, p: 412000, d: -2.74, w: -6.1, mo: -9.6, y: -21.2 },
12      { n: 'KB금융', c: '105560', s: 'KB금융', sec: '금융', m: 31, p: 78900, d: 1.08, w: 1.6, mo: 8.9, y: 34.5 },
13      { n: '신한지주', c: '055550', s: '신한지주', sec: '금융', m: 24, p: 46700, d: 0.38, w: 0.9, mo: 5.2, y: 23.4 },
14      { n: '셀트리온', c: '068270', s: '셀트리온', sec: '헬스케어', m: 41, p: 188000, d: 2.05, w: 3.8, mo: 10.1, y: 27.7 },
15      { n: '삼성SDI', c: '006400', s: '삼성SDI', sec: '2차전지', m: 26, p: 371500, d: -3.18, w: -7.2, mo: -12.4, y: -24.0 },
16      { n: '현대모비스', c: '012330', s: '모비스', sec: '자동차', m: 24, p: 257000, d: 0.78, w: 1.5, mo: 4.1, y: 12.2 },
17      { n: '삼성물산', c: '028260', s: '삼성물산', sec: '산업재', m: 28, p: 151000, d: 1.72, w: 2.4, mo: 6.9, y: 31.4 },
18      { n: '한화에어로스페이스', c: '012450', s: '한화에어로', sec: '산업재', m: 23, p: 456000, d: 4.32, w: 8.4, mo: 18.7, y: 72.3 },
19      { n: 'HD현대중공업', c: '329180', s: '현대중공업', sec: '산업재', m: 19, p: 214000, d: 2.91, w: 4.8, mo: 14.5, y: 45.2 },
20      { n: 'SK이노베이션', c: '096770', s: 'SK이노', sec: '에너지', m: 15, p: 109500, d: -1.52, w: -3.8, mo: -8.2, y: -15.3 },
21      { n: 'S-Oil', c: '010950', s: 'S-Oil', sec: '에너지', m: 11, p: 70400, d: 0.22, w: -0.5, mo: 2.0, y: 9.4 }
22    ];
23
24    // 카테고리별 시가총액(순자산) 상위 한국 상장 ETF — 코드·이름·가격은 네이버 API로 검증된 스냅샷 (2026-07-03)
25    // m: 순자산 추정치(조원, 타일 크기용) · w/mo/y: 무료 데이터에 기간 수익률이 없어 0 (1D 중심 사용)
26    const etfs = [
27      // 금리·채권
28      { n: 'KODEX CD금리액티브(합성)', s: 'CD금리액티브', c: '459580', cat: '금리·채권', m: 9.2, p: 1073855, d: 0.01, w: 0, mo: 0, y: 0 },
29      { n: 'TIGER CD금리투자KIS(합성)', s: 'CD금리KIS', c: '357870', cat: '금리·채권', m: 7.1, p: 57710, d: 0.02, w: 0, mo: 0, y: 0 },
30      { n: 'KODEX KOFR금리액티브(합성)', s: 'KOFR금리', c: '423160', cat: '금리·채권', m: 5.8, p: 110680, d: 0.02, w: 0, mo: 0, y: 0 },
31      { n: 'KODEX 종합채권(AA-이상)액티브', s: '종합채권', c: '273130', cat: '금리·채권', m: 2.6, p: 110130, d: -0.04, w: 0, mo: 0, y: 0 },
32      { n: 'KODEX 단기채권', s: '단기채권', c: '153130', cat: '금리·채권', m: 1.4, p: 113300, d: 0.01, w: 0, mo: 0, y: 0 },
33      // 국내 지수
34      { n: 'KODEX 200', s: 'KODEX 200', c: '069500', cat: '국내 지수', m: 7.8, p: 131010, d: 6.06, w: 0, mo: 0, y: 0, cons: [['삼성전자', 28], ['SK하이닉스', 10], ['현대차', 4], ['KB금융', 3.2], ['셀트리온', 2.8], ['기타', 52]] },
35      { n: 'TIGER 200', s: 'TIGER 200', c: '102110', cat: '국내 지수', m: 2.4, p: 131155, d: 5.99, w: 0, mo: 0, y: 0, cons: [['삼성전자', 28], ['SK하이닉스', 10], ['현대차', 4], ['KB금
35융', 3.2], ['셀트리온', 2.8], ['기타', 52]] },
36      { n: 'KODEX 레버리지', s: '레버리지', c: '122630', cat: '국내 지수', m: 2.4, p: 179260, d: 11.87, w: 0, mo: 0, y: 0 },
37      { n: 'KODEX 200TR', s: '200TR', c: '278530', cat: '국내 지수', m: 2.2, p: 47305, d: 6.09, w: 0, mo: 0, y: 0 },
38      { n: 'KODEX 200선물인버스2X', s: '인버스2X', c: '252670', cat: '국내 지수', m: 1.4, p: 75, d: -10.71, w: 0, mo: 0, y: 0 },
39      { n: 'KODEX 코스닥150', s: '코스닥150', c: '229200', cat: '국내 지수', m: 0.9, p: 15230, d: -1.61, w: 0, mo: 0, y: 0 },
40      { n: 'KODEX 코스닥150레버리지', s: '코스닥레버리지', c: '233740', cat: '국내 지수', m: 0.6, p: 9050, d: -2.9, w: 0, mo: 0, y: 0 },
41      { n: 'KODEX 인버스', s: '인버스', c: '114800', cat: '국내 지수', m: 0.5, p: 943, d: -5.79, w: 0, mo: 0, y: 0 },
42      // 해외 지수
43      { n: 'TIGER 미국S&P500', s: '미국S&P500', c: '360750', cat: '해외 지수', m: 8.2, p: 28605, d: -0.94, w: 0, mo: 0, y: 0, cons: [['AAPL', 7.2], ['MSFT', 6.8], ['NVDA', 6.1], ['AMZN', 3.9], ['기타', 76]] },
44      { n: 'TIGER 미국나스닥100', s: '미국나스닥100', c: '133690', cat: '해외 지수', m: 4.6, p: 201105, d: -1.71, w: 0, mo: 0, y: 0, cons: [['NVDA', 8.8], ['MSFT', 8.1], ['AAPL', 7.6], ['AMZN', 5.0], ['기타', 70.5]] },
45      { n: 'TIGER 미국테크TOP10 INDXX', s: '미국테크TOP10', c: '381170', cat: '해외 지수', m: 2.7, p: 32715, d: -1.01, w: 0, mo: 0, y: 0, cons: [['AAPL', 15], ['MSFT', 15], ['NVDA', 
4514], ['GOOGL', 9], ['META', 8], ['AMZN', 8], ['기타', 31]] },
46      { n: 'KODEX 미국S&P500', s: '미S&P500', c: '379800', cat: '해외 지수', m: 2.6, p: 25970, d: -1.33, w: 0, mo: 0, y: 0 },
47      { n: 'ACE 미국S&P500', s: 'ACE S&P500', c: '360200', cat: '해외 지수', m: 2.1, p: 28800, d: -1.29, w: 0, mo: 0, y: 0 },
48      { n: 'KODEX 미국나스닥100', s: '미나스닥100', c: '379810', cat: '해외 지수', m: 2.1, p: 30065, d: -1.81, w: 0, mo: 0, y: 0 },
49      { n: 'ACE 미국나스닥100', s: 'ACE 나스닥', c: '367380', cat: '해외 지수', m: 1.6, p: 34485, d: -1.95, w: 0, mo: 0, y: 0 },
50      { n: 'TIGER 차이나전기차SOLACTIVE', s: '차이나전기차', c: '371460', cat: '해외 지수', m: 1.2, p: 12850, d: 0.94, w: 0, mo: 0, y: 0 },
51      // 반도체·테크
52      { n: 'TIGER 미국필라델피아반도체나스닥', s: '필라델피아반도체', c: '381180', cat: '반도체·테크', m: 2.9, p: 51645, d: -4.03, w: 0, mo: 0, y: 0, cons: [['NVDA', 10], ['AVGO', 9], ['AMD', 8], ['QCOM', 6], ['TXN', 5], ['기타', 62]] },
53      { n: 'KODEX 미국반도체', s: '미국반도체', c: '390390', cat: '반도체·테크', m: 1.7, p: 63145, d: -3.26, w: 0, mo: 0, y: 0, cons: [['NVDA', 15], ['AVGO', 9], ['AMD', 6], ['기타', 70]] },
54      { n: 'KODEX 반도체', s: 'KODEX 반도체', c: '091160', cat: '반도체·테크', m: 0.9, p: 157225, d: 5.34, w: 0, mo: 0, y: 0, cons: [['SK하이닉스', 25], ['기타', 75]] },
55      { n: 'TIGER 반도체', s: 'TIGER 반도체', c: '091230', cat: '반도체·테크', m: 0.4, p: 165165, d: 6.0, w: 0, mo: 0, y: 0, cons: [['삼성전자', 31], ['SK하이닉스', 29], ['한화에어로스페이스', 3.5], ['기타', 36.5]] },
56      // 2차전지·소재
57      { n: 'KODEX 2차전지산업', s: '2차전지산업', c: '305720', cat: '2차전지·소재', m: 1.1, p: 14395, d: 1.37, w: 0, mo: 0, y: 0, cons: [['LG에너지솔루션', 22], ['삼성SDI', 19], ['LG화학', 12], ['POSCO홀딩스', 9], ['기타', 38]] },
58      { n: 'TIGER 2차전지테마', s: '2차전지테마', c: '305540', cat: '2차전지·소재', m: 0.8, p: 18000, d: 0.73, w: 0, mo: 0, y: 0, cons: [['LG에너지솔루션', 18], ['삼성SDI', 15], ['POSCO홀딩스', 12], ['기타', 55]] },
59      { n: 'KODEX 에너지화학', s: '에너지화학', c: '117460', cat: '2차전지·소재', m: 0.3, p: 13140, d: 0.08, w: 0, mo: 0, y: 0, cons: [['LG화학', 21], ['SK이노베이션', 15], ['S-Oil', 12], ['기타', 52]] },
60      // 섹터
61      { n: 'TIGER 은행', s: 'TIGER 은행', c: '091220', cat: '섹터', m: 0.9, p: 16590, d: 3.46, w: 0, mo: 0, y: 0, cons: [['KB금융', 24], ['신한지주', 19], ['기타', 57]] },
62      { n: 'KODEX 자동차', s: '자동차', c: '091180', cat: '섹터', m: 0.8, p: 31110, d: 1.42, w: 0, mo: 0, y: 0, cons: [['현대차', 25], ['기아', 22], ['현대모비스', 12], ['기타', 41]] },
63      { n: 'TIGER 헬스케어', s: '헬스케어', c: '143860', cat: '섹터', m: 0.6, p: 39450, d: 0.2, w: 0, mo: 0, y: 0, cons: [['삼성바이오로직스', 24], ['셀트리온', 18], ['기타', 58]] },
64      { n: 'KODEX 은행', s: 'KODEX 은행', c: '091170', cat: '섹터', m: 0.5, p: 15900, d: 2.78, w: 0, mo: 0, y: 0, cons: [['KB금융', 22], ['신한지주', 18], ['기타', 60]] },
65      { n: 'KODEX 바이오', s: '바이오', c: '244580', cat: '섹터', m: 0.3, p: 9060, d: -0.33, w: 0, mo: 0, y: 0 },
66      // 배당
67      { n: 'TIGER 미국배당다우존스', s: '미국배당다우', c: '458730', cat: '배당', m: 2.3, p: 15735, d: 0.06, w: 0, mo: 0, y: 0 },
68      { n: 'PLUS 고배당주', s: '고배당주', c: '161510', cat: '배당', m: 0.4, p: 25165, d: 1.86, w: 0, mo: 0, y: 0 },
69      { n: 'KODEX 고배당주', s: 'KODEX 고배당', c: '279530', cat: '배당', m: 0.3, p: 17570, d: 2.12, w: 0, mo: 0, y: 0 },
70      // 원자재
71      { n: 'ACE KRX금현물', s: 'KRX금현물', c: '411060', cat: '원자재', m: 2.0, p: 28435, d: 1.59, w: 0, mo: 0, y: 0 },
72      { n: 'KODEX 골드선물(H)', s: '골드선물', c: '132030', cat: '원자재', m: 0.8, p: 23475, d: 2.31, w: 0, mo: 0, y: 0 },
73      { n: 'KODEX WTI원유선물(H)', s: 'WTI원유', c: '261220', cat: '원자재', m: 0.4, p: 19370, d: 2.73, w: 0, mo: 0, y: 0 }
74    ];
75
76    const usStocks = [
77      { n: 'Apple', c: 'AAPL', s: 'AAPL', sec: 'Technology', m: 3300, p: 212.4, d: 0.64, w: 1.8, mo: 6.2, y: 18.4 },
78      { n: 'Microsoft', c: 'MSFT', s: 'MSFT', sec: 'Technology', m: 3500, p: 497.1, d: 0.38, w: 1.4, mo: 5.1, y: 21.2 },
79      { n: 'NVIDIA', c: 'NVDA', s: 'NVDA', sec: 'Semiconductors', m: 3900, p: 162.9, d: 1.95, w: 5.6, mo: 15.4, y: 74.0 },
80      { n: 'Amazon', c: 'AMZN', s: 'AMZN', sec: 'Consumer', m: 2300, p: 224.2, d: -0.22, w: 0.8, mo: 4.7, y: 19.0 },
81      { n: 'Meta', c: 'META', s: 'META', sec: 'Communication', m: 1800, p: 718.6, d: 0.92, w: 2.9, mo: 9.8, y: 36.5 },
82      { n: 'Alphabet A', c: 'GOOGL', s: 'GOOGL', sec: 'Communication', m: 2500, p: 184.5, d: -0.41, w: 1.1, mo: 3.6, y: 12.4 },
83      { n: 'Tesla', c: 'TSLA', s: 'TSLA', sec: 'Consumer', m: 1050, p: 322.8, d: -1.68, w: -3.4, mo: 8.1, y: 28.0 },
84      { n: 'Broadcom', c: 'AVGO', s: 'AVGO', sec: 'Semiconductors', m: 1400, p: 287.3, d: 2.18, w: 6.7, mo: 18.2, y: 55.1 },
85      { n: 'AMD', c: 'AMD', s: 'AMD', sec: 'Semiconductors', m: 260, p: 162.1, d: 1.24, w: 4.2, mo: 11.8, y: 30.6 },
86      { n: 'JPMorgan', c: 'JPM', s: 'JPM', sec: 'Financials', m: 760, p: 276.4, d: 0.31, w: 1.2, mo: 6.7, y: 24.2 },
87      { n: 'Eli Lilly', c: 'LLY', s: 'LLY', sec: 'Healthcare', m: 780, p: 872.5, d: -0.58, w: -1.4, mo: 2.8, y: 17.9 },
88      { n: 'Exxon Mobil', c: 'XOM', s: 'XOM', sec: 'Energy', m: 520, p: 115.2, d: -0.16, w: 0.6, mo: 3.2, y: 10.3 }
89    ];
90
91    const nasdaqStocks = usStocks.filter(x => ['AAPL', 'MSFT', 'NVDA', 'AMZN', 'META', 'GOOGL', 'TSLA', 'AVGO', 'AMD'].includes(x.c));
92
93    // src: free-data source — windex(네이버 해외지수), dindex(네이버 국내지수),
94    // bond/metals/energy/fx(네이버 marketIndex), upbit(업비트 KRW 시세)
95    // cs: 5-min candle source — 'naver'(해외지수 분봉 API), 'upbit'(업비트 분봉), 'agg'(폴링 샘플 자체 집계)
96    const marketPulse = [
97      { group: 'US', n: '다우', c: '.DJI', src: 'windex', v: 44920, d: 0.18 },
98      { group: 'US', n: 'S&P500', c: '.INX', src: 'windex', v: 6458.25, d: 0.31 },
99      { group: 'US', n: '나스닥', c: '.IXIC', src: 'windex', cs: 'naver', v: 22940.5, d: 0.42 },
100      { group: 'Risk', n: 'SOX', c: '.SOX', src: 'windex', cs: 'naver', v: 267.8, d: 1.12 },
101      { group: 'Risk', n: 'VIX', c: '.VIX', src: 'windex', cs: 'naver', v: 16.4, d: -3.24, inverse: true },
102      { group: 'Macro', n: 'DXY', c: '.DXY', src: 'windex', cs: 'naver', v: 104.1, d: -0.12, inverse: true },
103      { group: 'Rates', n: '미10Y', c: 'US10YT=RR', src: 'bond', v: 4.28, d: 0.03, unit: '%' },
104      { group: 'Rates', n: '미2Y', c: 'US2YT=RR', src: 'bond', v: 4.14, d: -0.65, unit: '%' },
105      { group: 'Macro', n: '금', c: 'GCcv1', src: 'metals', cs: 'agg', v: 2365.4, d: 0.46 },
106      { group: 'Macro', n: '은', c: 'SIcv1', src: 'metals', cs: 'agg', v: 30.8, d: 0.72 },
107      { group: 'Macro', n: 'WTI', c: 'CLcv1', src: 'energy', cs: 'agg', v: 82.1, d: -0.38 },
108      { group: 'Crypto', n: 'BTC', c: 'BTCUSDT', src: 'binance', cs: 'binance', unit: '$', v: 64200, d: 1.84 },
109      { group: 'Crypto', n: 'ETH', c: 'ETHUSDT', src: 'binance', cs: 'binance', unit: '$', v: 3450, d: 1.21 },
110      { group: 'Korea', n: 'KOSPI', c: 'KOSPI', src: 'dindex', v: 2810.4, d: -0.22 },
111      { group: 'Korea', n: 'KOSDAQ', c: 'KOSDAQ', src: 'dindex', v: 845.2, d: 0.15 },
112      { group: 'Korea', n: '원달러', c: 'FX_USDKRW', src: 'fx', cs: 'agg', v: 1378.5, d: 0.28, inverse: true }
113    ];
114
115    const holdings = [
116      { n: '삼성전자', qty: 42, avg: 68100 },
117      { n: 'SK하이닉스', qty: 8, avg: 158000 },
118      { n: 'KODEX 200', qty: 55, avg: 118000 },
119      { n: 'TIGER 반도체', qty: 38, avg: 142000 },
120      { n: 'KODEX 2차전지산업', qty: 60, avg: 15800 },
121      { n: 'NAVER', qty: 7, avg: 199000 },
122      { n: '현대차', qty: 5, avg: 214000 },
123      { n: 'KB금융', qty: 18, avg: 60200 },
124      { n: '한화에어로스페이스', qty: 2, avg: 288000 }
125    ];
126
127    const state = {
128      tab: 'market',
129      provider: 'kis',
130      period: '1D',
131      index: 'KOSPI200',
132      colorBasis: 'change',
133      dataMode: 'eod',
134      sideMode: false,
135      colorblind: false,
136      theme: 'dark', // 'dark'(앱 기본) | 'light' — data-theme 속성으로 팔레트 전환. 랜딩은 라이트 기본이나 앱은 다크 기본.
137      viewMode: 'heatmap',
138      pfBasis: 'day',
139      hideAmounts: false,
140      pfEmpty: false,
141      remoteHoldings: null,
142      remoteOverview: null,
143      priceOverrides: {},
144      prevCloseCache: {},
145      candleCache: {},
146      pfPanelOpen: true,
147      proUnlocked: false,
148      licenseDeviceLimit: false,
149      license: null,
150      licenseInputOpen: false,
151      drill: null,
152      etfCat: null,
153      catThermo: null, // ETF 카테고리 온도 패널 (카테고리명 문자열)
154      etfGroup: 'cat', // ETF 분석 그룹핑 (cat=카테고리 · brand=운용사), 설정 저장
155      customBoard: { layout: '2h', panes: [{ src: 'KOSPI200', period: '1D', view: 'map' }, { src: 'S&P500', period: '1D', view: 'map' }] }, // 내 히트맵 (설정 저장)
156      boardMenu: null, // 소스 선택 메뉴가 열린 pane 인덱스
157      boardEtfFocus: false, // ETF 검색 입력 포커스 요청 플래그
158      etfDynamic: null, // 네이버 etfItemList로 로드한 시총 상위 ETF (없으면 하드코딩 fallback)
159      etfSavedAt: 0,
160      etfFull: null, // 세션 메모리 전체 ETF 목록 (더 가져ì˜
160¤ê¸°ìš©, 영속화 안 함)
161      etfBrandTotals: null, // 브랜드별 전체 개수 (더 가져오기 라벨, 영속화)
162      etfCatTotals: null, // 카테고리별 전체 개수 (영속화)
163      menu: null,
164      modal: false,
165      step: 1,
166      test: 'idle',
167      key: '',
168      secret: '',
169      pin: '',
170      secureBlob: null,
171      pinPrompt: false,
172      pinError: '',
173      pulseWidth: 172,
174      pulseHeights: { ticker: 40, cards: 118, hero: 96, columns: 96, side: 116 }, // 스타일별 사용자 조절 높이 (리사이저)
175      watchlistSymbols: ['005930', '000660', 'AAPL', 'NVDA'],
176      settingsOpen: false,
177      defaultTab: 'market',
178      defaultIndex: 'KOSPI200',
179      pulseVisible: true,
180      usPalette: 'us',
181      colorScale: 3,
182      hideAmountsDefault: false,
183      stealth: false,
184      stealthDefault: false,
185      stealthTheme: 'google',
186      badgeEnabled: true,
187      briefingEnabled: false,
188      pulseStyle: 'ticker', // ticker | cards | hero | columns
189      pulseTab: 'main',
190      alertsCfg: { enabled: false, movePct: 3, targets: {} },
191      tier2: null,
192      kosdaqStocks: null,
193      kosdaqSavedAt: 0,
194      myHoldings: null, // null = 미설정(데모 표시) · [] = 비움 · [{c, qty, avg}]
195      indexDaily: {},
196      pfEditorOpen: false,
197      autoRefreshSec: 60,
198      lastUpdatedAt: 0,
199      autoFailures: 0,
200      usCodeCache: {},
201      pulseCandles: {},
202      flowCache: {},
203      thermoServer: {}, // 서버 계산 온도 캐시 (시장별)
204      investorFlow: null, // 시장 전체 투자자별 순매수 서버 응답 캐시 (KOSPI·KOSDAQ)
205      sectorFlow: null, // 섹터 수급 로테이션 서버 응답 캐시
206      sectorWindow: 's', // 섹터 수급 기간 (s=1주 · m=1개월 · q=3개월 · y=연초, Pro)
207      sectorView: 'rank', // 섹터 로테이션 뷰 (rank=랭킹 · river=물길 주별 히트맵, Pro)
208      riverWeeks: 12, // 물길 히트맵 표시 주 수 (12 · 26)
209      usSectors: null, // 미국 섹터 ETF(SPDR) 1D 시세 캐시 { quotes, fetchedAt, asof }
210      usSectorWindow: 'd', // 미국 섹터 로테이션 기간 (d=1D · w=1주 · m=1개월, Pro)
211      stockFlowDays: 10, // 종목 수급 차트 선택 기간 (10 무료 · 21/63 Pro)
212      stockFlowCache: {}, // 종목 장기 수급 서버 응답 캐시 (key: `code:days`)
213      flowLong: null, // Pro 수급 누적 (1개월·YTD) 서버 집계 캐시
214      periodBase: {}, // 종목별 1W/1M/YTD 기준 종가 (전일까지 일봉 기반, 24h 캐시)
215      flowLoading: false,
216      toolbarCollapsed: false,
217      signalsOpen: false,
218      signalsTab: 'current',
219      signalsDetail: null,
220      thermo: null,
221      thermoHistory: [],
222      thermoHistoryUs: [],
223      stockPanel: null,
224      stockPanelLoading: false,
225      dailyCache: {},
226      stockMetaCache: {},
227      stockSignalCache: {},
228      stockScanDone: false
229    };
230
231    // --- Entitlement: 무료/Pro 기능 경계 (스펙 문서와 1:1 대응) ---
232    // 결제 연동 시 can()의 마지막 줄만 실제 라이선스 검사로 교체하면 된다.
233    const PRO_FEATURES = new Set([
234      'byok',             // 증권사 API 연결 (실시간 시세·잔고 불러오기)
235      'flowLong',         // 수급 누적 1개월·YTD (서버 집계)
236      'investorFlow',     // 시장 전체 투자자별 순매수 20일 추이 (무료: 최신일)
237      'sectorRotation',   // 섹터별 수급 로테이션 전체 순위 (무료: 상위 3)
238      'fastRefresh',       // 30초·1분 자동 갱신 (무료: 5분)
239      'contribBreakdown',  // 시장 온도 기여도 분해
240      'commentary',        // 시장 코멘트 요인 분석 문장 (무료: 상태 요약 1문장)
241      'consensus',         // 애널리스트 목표주가 컨센서스
242      'thermoHistory',     // 온도 90일 히스토리 탭
243      'indicatorDetail',   // 지표 상세 (일별 차트·백분위)
244      'indicatorBand',     // 지표 카드 20일 밴드 핀
245      'signalFeedAll',     // 시장 시그널 피드 전체 (무료: 최신 1건)
246      'stockSignalsAll',   // 종목 시그널 전체 (무료: 1건)
247      'stockMa',           // 종목 차트 이동평균선 (MA20/60)
248      'stockScan',         // 종목 시그널 백그라운드 스캔 (타일 점·팝오버)
249      'etfExposure',       // ETF 중복 노출 분석
250      'alerts'             // 급등락·목표가 알림
251    ]);
252
253    function can(feature) {
254      if (!PRO_FEATURES.has(feature)) return true;
255      return licenseActive() || state.proUnlocked; // proUnlocked = 개발용 미리보기
256    }
257
258    // --- 라이선스: 결제 사이트에서 발급된 토큰을 서버 검증 (오프라인 유예 7일) ---
259    const LICENSE_SERVER = 'https://heattab.app'; // 커스텀 도메인 (heattab-pay.vercel.app 별칭 유지)
260    const licenseStorageKey = 'heattab.license.v1';
261    const deviceStorageKey = 'heattab.device.v1';
262    const licenseGraceMs = 7 * 24 * 60 * 60 * 1000;
263
264    // 실행 환경: 크롬 확장(권한으로 직접 호출) vs 웹(heattab.app/app — 네이버 계열은 서버 프록시 경유)
265    const IS_WEB = !(window.chrome && window.chrome.runtime && window.chrome.runtime.id);
266    const PROXY_HOSTS = ['polling.finance.naver.com', 'm.stock.naver.com', 'api.stock.naver.com', 'fchart.stock.naver.com', 'f
266inance.naver.com'];
267    function routed(url) {
268      if (!IS_WEB) return url;
269      try {
270        if (PROXY_HOSTS.includes(new URL(url).hostname)) return LICENSE_SERVER + '/api/q?u=' + encodeURIComponent(url);
271      } catch {}
272      return url;
273    }
274
275    // --- Extension shell storage bridge (embedded mode) — SPEC-SLIM-001 M2 ---
276    // When app.js runs inside the extension's shell iframe, chrome.storage.local is
277    // unreachable and this origin's localStorage is partitioned away from background.js.
278    // The shell (same extension origin) relays the SPEC §5 whitelist keys to/from
279    // chrome.storage.local so it stays the single source of truth. Cache keys are NOT
280    // bridged (they stay in this origin's localStorage), and heattab.secure.v1 is never
281    // sent across the bridge.
282    // ID-agnostic bridge: the trusted extension origin is DISCOVERED from the shell's ack, not
283    // hardcoded — a Web Store install gets a new extension ID (the store strips manifest "key").
284    // The server's frame-ancestors CSP is the real gate: only allowed origins can iframe
285    // heattab.app/app, so any chrome-extension:// parent that framed us and completed the
286    // handshake is trusted for this session.
287    const SHELL_ORIGIN = 'https://heattab.app';
288    const BRIDGE_NS = 'heattab';
289    const BRIDGE_KEYS = new Set([
290      'heattab.api.settings.v1',   // UI 설정 (watchlist 포함)
291      'heattab.kis.settings.v1',   // legacy 설정 — settings와 함께 읽기/삭제
292      'heattab.holdings.v1',       // 보유
293      'heattab.license.v1',        // 라이선스
294      'heattab.device.v1',         // 기기 ID
295      'heattab.alerts.v1',         // 경보 설정 (symbols 스냅샷이 watchlist를 추종해야 함)
296      'heattab.thermo.latest'      // 배지가 읽는 최신 온도
297    ]);
298
299    const bridge = {
300      active: false,               // true only after a successful shell handshake
301      peerOrigin: null,            // chrome-extension:// origin discovered from the ack — used for all messaging
302      cache: Object.create(null),  // key -> value, seeded by the boot snapshot for sync reads
303      _seq: 0,
304      _pending: new Map(),         // id -> { resolve, timer }
305
306      _post(msg) {
307        if (!this.peerOrigin) return; // only after the ack recorded the trusted extension origin
308        try { window.parent.postMessage(Object.assign({ ns: BRIDGE_NS }, msg), this.peerOrigin); } catch {}
309      },
310
311      _onResponse(event) {
312        if (event.origin !== this.peerOrigin) return;       // X3: only the handshake-recorded origin
313        const msg = event.data;
314        if (!msg || msg.ns !== BRIDGE_NS || msg.type !== 'ht-storage-res') return;
315        const p = this._pending.get(msg.id);
316        if (!p) return;                                     // E2: unknown/stale id ignored
317        clearTimeout(p.timer);
318        this._pending.delete(msg.id);
319        p.resolve(msg);
320      },
321
322      request(op, payload) {
323        if (!this.active) return Promise.resolve({ ok: false });
324        const id = 'r' + (++this._seq);
325        return new Promise(resolve => {
326          const timer = setTimeout(() => {                  // X4: never hang on a slow shell
327            this._pending.delete(id);
328            resolve({ ok: false, timeout: true });
329          }, 3000);
330          this._pending.set(id, { resolve, timer });
331          this._post(Object.assign({ type: 'ht-storage', id, op }, payload));
332        });
333      },
334
335      // Early handshake: announce readiness, receive the bulk snapshot of bridge keys
336      // before the boot chain runs (E3). Resolves false for the plain web tab.
337      init() {
338        if (window.parent === window) return Promise.resolve(false);
339        window.addEventListener('message', e => this._onResponse(e));
340        return new Promise(resolve => {
341          let done = false;
342          const onAck = event => {
343            // Trust the observed parent origin: must be a chrome-extension:// parent that framed us (X3).
344            if (!String(event.origin).startsWith('chrome-extension://') || event.source !== window.parent) return;
345            const msg = event.data;
346            if (!msg || msg.ns !== BRIDGE_NS || msg.type !== 'ht-shell-ack') return;
347            if (done) return;
348            done = true;
349            window.removeEventListener('message', onAck);
350            this.peerOrigin = event.origin; // record the discovered origin for all subsequent messaging
351            const snap = msg.snapshot || {};
352            for (const k of Object.keys(snap)) if (BRIDGE_KEYS.has(k)) this.cache[k] = snap[k];
353            this.active = true;
354            resolve(true);
355          };
356          window.addEventListener('message', onAck);
357          // ht-ready carries no data ({ns,type}
357 only), so targetOrigin '*' is safe before the peer origin is known.
358          try { window.parent.postMessage({ ns: BRIDGE_NS, type: 'ht-ready' }, '*'); } catch {}
359          setTimeout(() => {                                // no shell -> fall back to web behavior
360            if (done) return;
361            done = true;
362            window.removeEventListener('message', onAck);
363            resolve(false);
364          }, 5000);
365        });
366      },
367
368      // Async accessors for chrome.storage-backed keys (license/device/alerts/thermo).
369      async get(key) {
370        if (this.cache[key] !== undefined) return this.cache[key];
371        const res = await this.request('get', { key });
372        const val = res.ok ? (res.value ?? null) : null;
373        this.cache[key] = val;
374        return val;
375      },
376      set(key, value) {
377        this.cache[key] = value;
378        return this.request('set', { key, value });
379      },
380      remove(key) {
381        delete this.cache[key];
382        return this.request('remove', { key });
383      }
384    };
385
386    // localStorage-compatible facade for the string-valued bridge keys (settings/holdings).
387    // Reads are served synchronously from the boot snapshot; writes update the cache and
388    // fire a bridged set. Non-bridge keys (all caches) fall straight through to localStorage.
389    const lstore = {
390      getItem(key) {
391        if (bridge.active && BRIDGE_KEYS.has(key)) {
392          const v = bridge.cache[key];
393          if (v === undefined || v === null) return null;
394          return typeof v === 'string' ? v : JSON.stringify(v);
395        }
396        return localStorage.getItem(key);
397      },
398      setItem(key, val) {
399        if (bridge.active && BRIDGE_KEYS.has(key)) {
400          let parsed; try { parsed = JSON.parse(val); } catch { parsed = val; }
401          bridge.cache[key] = parsed;
402          bridge.request('set', { key, value: parsed });
403          return;
404        }
405        localStorage.setItem(key, val);
406      },
407      removeItem(key) {
408        if (bridge.active && BRIDGE_KEYS.has(key)) {
409          delete bridge.cache[key];
410          bridge.request('remove', { key });
411          return;
412        }
413        localStorage.removeItem(key);
414      }
415    };
416
417    // 이 설치본의 고유 기기 ID (라이선스 기기 수 제한용) — 최초 1회 생성 후 저장
418    function getDeviceId() {
419      const make = () => (self.crypto?.randomUUID?.() || ('dev_' + Math.random().toString(36).slice(2) + Date.now().toString(36)));
420      return new Promise(resolve => {
421        if (bridge.active) {
422          bridge.get(deviceStorageKey).then(id => {
423            if (!id) { id = make(); bridge.set(deviceStorageKey, id); }
424            resolve(id);
425          });
426        } else if (chromeStorageAvailable()) {
427          chrome.storage.local.get(deviceStorageKey, out => {
428            let id = out?.[deviceStorageKey];
429            if (!id) { id = make(); chrome.storage.local.set({ [deviceStorageKey]: id }); }
430            resolve(id);
431          });
432        } else {
433          let id = localStorage.getItem(deviceStorageKey);
434          if (!id) { id = make(); localStorage.setItem(deviceStorageKey, id); }
435          resolve(id);
436        }
437      });
438    }
439
440    function licenseActive() {
441      const lic = state.license;
442      if (!lic || lic.plan !== 'pro') return false;
443      if (lic.exp * 1000 < Date.now()) return false;
444      return Date.now() - (lic.verifiedAt || 0) < licenseGraceMs;
445    }
446
447    function licenseStorageGet() {
448      return new Promise(resolve => {
449        if (bridge.active) bridge.get(licenseStorageKey).then(v => resolve(v || null));
450        else if (chromeStorageAvailable()) chrome.storage.local.get(licenseStorageKey, out => resolve(out?.[licenseStorageKey] || null));
451        else {
452          try { resolve(JSON.parse(localStorage.getItem(licenseStorageKey) || 'null')); } catch { resolve(null); }
453        }
454      });
455    }
456
457    function licenseStorageSet(value) {
458      return new Promise(resolve => {
459        if (bridge.active) bridge.set(licenseStorageKey, value).then(() => resolve());
460        else if (chromeStorageAvailable()) chrome.storage.local.set({ [licenseStorageKey]: value }, () => resolve());
461        else { localStorage.setItem(licenseStorageKey, JSON.stringify(value)); resolve(); }
462      });
463    }
464
465    async function verifyLicense(stored, claim = false) {
466      if (!stored?.token) return null;
467      try {
468        const deviceId = await getDeviceId();
469        const res = await fetch(LICENSE_SERVER + '/api/license/verify', {
470          method: 'POST',
471          headers: { 'Content-Type': 'application/json' },
472          body: JSON.stringify({ token: stored.token, deviceId, claim }),
473          signal: AbortSignal.timeout(8000)
474        });
475        if (res.status === 401) return { revoked: true }; // 서버가 명시적으로 무효 판정 (만료·환불)
476        if (!res.ok) throw new Error('HTTP ' + res.status);
477        const data = await res.json();
478        if (data.reason === 'device_limit') return { deviceLimit: true, max: data.max || 2 };
479        if (!data.valid) return { revoked: true };
480        return { token: stored.token, email: data.email, plan: data.plan, exp: data.exp, verifiedAt: Date.now() };
481      } catch {
482        // 네트워크 실패: 기존 검증 기록이 유예 기간 내면 유지
483        return stored.verifiedAt && Date.now() - stored.verifiedAt < licenseGraceMs ? stored : null;
484      }
485    }
486
487    async function loadLicense(showToast = false, claim = false) {
488      const stored = await licenseStorageGet();
489      if (!stored?.token) {
490        state.license = null;
491        state.licenseDeviceLimit = false;
492        return;
493      }
494      const result = await verifyLicense(stored, claim);
495      if (result?.deviceLimit) {
496        // 라이선스는 유효하나 기기 한도 초과 — 토큰은 보관(다시 시도/해제 가능), Pro는 이 기기에서 비활성
497        state.license = null;
498        state.licenseDeviceLimit = true;
499        if (showToast) toast(`이 라이선스는 이미 ${result.max}대에서 사용 중입니다 · 기존 기기에서 해제 후 다시 시도하세요`);
500      } else if (result?.revoked) {
501        state.license = null;
502        state.licenseDeviceLimit = false;
503        await licenseStorageSet(null);
504        if (showToast) toast('라이선스가 만료되었거나 회수되었습니다');
505      } else if (result) {
506        state.license = result;
507        state.licenseDeviceLimit = false;
508        await licenseStorageSet(result);
509        if (showToast) toast(`Pro 라이선스 확인됨 · ${result.email} · ${new Date(result.exp * 1000).toLocaleDateString('ko-KR')}까지`);
510      } else {
511        state.license = null;
512        if (showToast) toast('라이선스 서버에 연결하지 못했습니다');
513      }
514      render();
515    }
516
517    async function applyLicenseToken(token) {
518      await licenseStorageSet({ token, receivedAt: Date.now() });
519      // chrome.storage 환경에서는 onChanged 리스너가 loadLicense(claim)를 호출 — 중복 방지
520      if (!chromeStorageAvailable()) await loadLicense(true, true);
521    }
522
523    // 이 기기에서 라이선스 해제 — 서버 슬롯도 반납해 다른 기기가 쓸 수 있게 함
524    async function releaseLicense() {
525      const stored = await licenseStorageGet();
526      if (stored?.token) {
527        try {
528          const deviceId = await getDeviceId();
529          await fetch(LICENSE_SERVER + '/api/license/release', {
530            method: 'POST',
531            headers: { 'Content-Type': 'application/json' },
532            body: JSON.stringify({ token: stored.token, deviceId }),
533            signal: AbortSignal.timeout(8000)
534          });
535        } catch {}
536      }
537      await licenseStorageSet(null);
538      state.license = null;
539      state.licenseDeviceLimit = false;
540    }
541
542    // --- 알림 설정: background(감시)와 chrome.storage로 공유 ---
543    const alertsStorageKey = 'heattab.alerts.v1';
544    const holdingsStorageKey = 'heattab.holdings.v1';
545
546    function saveMyHoldings() {
547      lstore.setItem(holdingsStorageKey, JSON.stringify(state.myHoldings || []));
548    }
549
550    async function loadAlertsCfg() {
551      const stored = await new Promise(resolve => {
552        if (bridge.active) bridge.get(alertsStorageKey).then(v => resolve(v || null));
553        else if (chromeStorageAvailable()) chrome.storage.local.get(alertsStorageKey, out => resolve(out?.[alertsStorageKey] || null));
554        else { try { resolve(JSON.parse(localStorage.getItem(alertsStorageKey) || 'null')); } catch { resolve(null); } }
555      });
556      if (stored) state.alertsCfg = { enabled: false, movePct: 3, targets: {}, ...stored };
557    }
558
559    async function saveAlertsCfg() {
560      // 감시 대상 = 관심종목 + 보유종목 (국내 코드만) 스냅샷
561      const holdingCodes = portfolio().items.map(h => String(h.ref?.c || '')).filter(isKoreanCode);
562      const cfg = {
563        ...state.alertsCfg,
564        symbols: [...new Set([...state.watchlistSymbols.filter(isKoreanCode), ...holdingCodes])]
565      };
566      state.alertsCfg = cfg;
567      // alerts.v1 is native-edited (§5), but the symbols snapshot must track the embedded
568      // app's watchlist for background move-alerts, so the snapshot write is bridged too.
569      if (bridge.active) await bridge.set(alertsStorageKey, cfg);
570      else if (chromeStorageAvailable()) await new Promise(r => chrome.storage.local.set({ [alertsStorageKey]: cfg }, r));
571      else localStorage.setItem(alertsStorageKey, JSON.stringify(cfg));
572    }
573
574    // 결제 완료 시 background가 storage에 토큰을 쓰면 즉시 반영.
575    // 주의: loadLicense 자신도 검증 결과(verifiedAt 포함)를 storage에 다시 쓰므로,
576    // verifiedAt이 없는 "새 토큰" 형태(background가 쓴 것)일 때만 반응 — 무한 루프 방지.
577    if (window.chrome?.storage?.onChanged) {
578      chrome.storage.onChanged.addListener((changes, area) => {
579        if (area !== 'local' || !changes[licenseStorageKey]) return;
580        const next = changes[licenseStorageKey].newValue;
581        // 새로 받은 토큰(결제 자동전달·수동입력)은 claim=true로 슬롯 확보
582        if (next && next.token && !next.verifiedAt) loadLicense(true, true);
583      });
584    }
585
586    const $ = (id) => document.getElementById(id);
587    const fmt = new Intl.NumberFormat('ko-KR');
588    const storageKey = 'heattab.api.settings.v1';
589    const legacyStorageKey = 'heattab.kis.settings.v1';
590    const secureStorageKey = 'heattab.secure.v1';
591    const usCodeCacheKey = 'heattab.uscodes.v1';
592    const pulseCandleStorageKey = 'heattab.pulsecandles.v1';
593    const flowStorageKey = 'heattab.flows.v1';
594    const periodBaseKey = 'heattab.periodbase.v1';
595    const periodBaseTtlMs = 24 * 60 * 60 * 1000;
596    const flowTtlMs = 10 * 60 * 1000;
597    const thermoStorageKey = 'heattab.thermo.v1';
598    const thermoUsStorageKey = 'heattab.thermo.us.v1';
599    const dailyStorageKey = 'heattab.daily.v1';
600    const stockMetaStorageKey = 'heattab.stockmeta.v1';
601    const dailyTtlMs = 12 * 60 * 60 * 1000;
602    const thermoTopCodes = ['005930', '000660', '373220', '207940', '005380', '000270', '035420', '105560', '055550', '068270', '006400', '012330', '028260', '012450', '051910', '005490', '096770', '066570', '033780', '015760'];
603    const prevCloseStorageKey = 'heattab.prevclose.v1';
604    const candleStorageKey = 'heattab.candles.v1';
605    const dailyCandleCount = 200;
606    const kospi200HeatmapLimit = 100;
607    const sp500FocusSymbols = 'NVDA MSFT AAPL AVGO ORCL PLTR AMD CSCO IBM ACN ADBE AMAT QCOM TXN INTU NOW CRM MU LRCX KLAC PANW ANET SNPS CDNS CRWD ADI APH AMZN TSLA HD MCD BKNG TJX LOW SBUX NKE CMG AZO MAR RCL GM F META GOOGL GOOG NFLX DIS CMCSA T VZ TMUS EA BRK.B JPM V MA BAC WFC GS MS C BLK SCHW PGR AXP CB CME ICE SPGI MCO COF USB LLY UNH JNJ ABBV MRK TMO ISRG DHR PFE AMGN GILD BSX SYK MDT ELV CI CVS REGN VRTX ABT XOM CVX COP EOG SLB PSX MPC VLO OXY WMB KMI BKR COST WMT PG KO PEP PM MDLZ CL KMB GIS KDP MNST TGT KR GE CAT RTX HON UPS ETN DE BA LMT ADP MMM ITW PH NEE SO DUK EXC SRE AEP D CEG PCG ED XEL PEG LIN SHW ECL NEM FCX DOW APD NUE CTVA MLM VMC ALB PLD AMT EQIX WELL SPG O PSA DLR CCI CBRE'.split(' ');
608    const sp500HeatmapLimit = sp500FocusSymbols.length;
609    // NASDAQ-100 constituents present in the sp500 dataset (roughly market-cap ordered)
610    const nasdaq100Symbols = 'NVDA MSFT AAPL AMZN GOOGL GOOG META AVGO TSLA NFLX COST PLTR AMD CSCO TMUS INTU QCOM TXN AMAT ISRG BKNG AMGN PANW ADBE GILD HON MU ADP ADI VRTX LRCX SBUX KLAC MDLZ INTC CTAS SNPS CEG PYPL CDNS CRWD ORLY MAR CSX MRVL ABNB NXPI ADSK FTNT ROP WDAY PCAR CPRT MNST ROST PAYX ODFL AEP FAST KDP EA CTSH DDOG BKR GEHC VRSK EXC XEL TTWO CSGP IDXX DXCM ON FANG WBD BIIB MRNA DASH AXON KHC LULU MCHP CDW TTD APP PEP LIN CMCSA REGN'.split(' ');
611
612    const urlParams = new URLSearchParams(location.search);
613    // index.html이 로드되는 경로는 둘뿐: 새 탭 iframe(?mode=full) 또는 사이드 패널(파라미터 없음).
614    // 따라서 mode=full이 아니면 사이드 패널로 간주한다. (이전엔 기본 false라 사이드 패널에서 side-mode 전용 UI가 동작하지 않았음)
615    state.sideMode = IS_WEB ? urlParams.get('mode') === 'side' : urlParams.get('mode') !== 'full';
616    const urlIndexOverride = ['KOSPI200', 'KOSDAQ150', 'S&P500', 'NASDAQ100', '관심종목'].includes(urlParams.get('index'));
617    if (urlIndexOverride) {
618      state.index = urlParams.get('index');
619    }
620    if (['change', '52w', 'peak'].includes(urlParams.get('color'))) {
621      state.colorBasis = urlParams.get('color');
622    }
623
624    function loadSavedSettings() {
625      try {
626        const saved = JSON.parse(lstore.getItem(storageKey) || lstore.getItem(legacyStorageKey) || '{}');
627        if (saved.provider === 'kis' || saved.provider === 'toss') state.provider = saved.provider;
628        if (typeof saved.key === 'string') state.key = saved.key;
629        if (typeof saved.secret === 'string') state.secret = saved.secret;
630        if (typeof saved.pin === 'string') state.pin = saved.pin;
631        if (Number(saved.pulseWidth) >= 140 && Number(saved.pulseWidth) <= 260) state.pulseWidth = Number(saved.pulseWidth);
632        if (saved.pulseHeights && typeof saved.pulseHeights === 'object') {
633          for (const k of Object.keys(state.pulseHeights)) {
634            const v = Number(saved.pulseHeights[k]);
635            if (v >= 28 && v <= 320) state.pulseHeights[k] = Math.round(v);
636          }
637        }
638        if (Array.isArray(saved.watchlistSymbols)) state.watchlistSymbols = saved.watchlistSymbols.map(String).filter(Boolean);
639        if (['market', 'portfolio', 'etf'].includes(saved.defaultTab)) {
640          state.defaultTab = saved.defaultTab;
641          state.tab = saved.defaultTab;
642        }
643        if (['KOSPI200', 'KOSDAQ150', 'S&P500', 'NASDAQ100', '관심종목'].includes(saved.defaultIndex)) {
644          state.defaultIndex = saved.defaultIndex;
645          if (!urlIndexOverride) state.index = saved.defaultIndex;
646        }
647        if (typeof saved.pulseVisible === 'boolean') state.pulseVisible = saved.pulseVisible;
648        if (saved.usPalette === 'kr' || saved.usPalette === 'us') state.usPalette = saved.usPalette;
649        if ([3, 5, 10].includes(Number(saved.colorScale))) state.colorScale = Number(saved.colorScale);
650        if (typeof saved.hideAmountsDefault === 'boolean') {
651          state.hideAmountsDefault = saved.hideAmountsDefault;
652          state.hideAmounts = saved.hideAmountsDefault;
653        }
654        if (typeof saved.colorblind === 'boolean') state.colorblind = saved.colorblind;
655        if (saved.etfGroup === 'cat' || saved.etfGroup === 'brand') state.etfGroup = saved.etfGroup;
656        if (saved.customBoard && Array.isArray(saved.customBoard.panes)) {
657          // 저장된 보드 복원 (섹터/ETF 포함 유효 소스만; 알 수 없는 필드는 기본값 처리)
658          const validSrc = p => BOARD_SOURCES.some(([v]) => v === p.src)
659            || (p.src === 'sector' && typeof p.sec === 'string' && p.sec)
660            || (p.src === 'etf' && typeof p.code === 'string' && p.code);
661          const panes = saved.customBoard.panes.filter(p => p && validSrc(p)).slice(0, BOARD_MAX_PANES).map(p => {
662            const out = { src: p.src, period: ['1D', '1W', '1M', 'YTD'].includes(p.period) ? p.period : '1D', view: p.view === 'list' ? 'list' : 'map' };
663            if (p.src === 'sector') out.sec = p.sec;
664            if (p.src === 'etf') { out.code = p.code; out.name = typeof p.name === 'string' ? p.name : p.code; }
665            return out;
666          });
667          const layout = (BOARD_LAYOUTS[saved.customBoard.layout] || saved.customBoard.layout === 'auto') ? saved.customBoard.layout : 'auto';
668          if (panes.length) state.customBoard = { layout, panes };
669        }
670        if (saved.theme === 'light' || saved.theme === 'dark') state.theme = saved.theme;
671        if ([0, 30, 60, 300].includes(Number(saved.autoRefreshSec))) state.autoRefreshSec = Number(saved.autoRefreshSec);
672        // 과거 버그 치유: 예전 버전이 연속 실패 시 '끄기(0)'를 설정에 저장해버렸음.
673        // arHealed 마커가 없는 0은 버그 잔재로 보고 기본값(1분)으로 복구. 이후 사용자가 끄면 마커와 함께 저장됨.
674        if (Number(saved.autoRefreshSec) === 0 && !saved.arHealed) state.autoRefreshSec = 60;
675        if (typeof saved.toolbarCollapsed === 'boolean') state.toolbarCollapsed = saved.toolbarCollapsed;
676        if (typeof saved.stealthDefault === 'boolean') {
677          state.stealthDefault = saved.stealthDefault;
678          state.stealth = saved.stealthDefault;
679        }
680        if (typeof saved.badgeEnabled === 'boolean') state.badgeEnabled = saved.badgeEnabled;
681        if (typeof saved.briefingEnabled === 'boolean') state.briefingEnabled = saved.briefingEnabled;
682        if (['ticker', 'cards', 'hero', 'columns'].includes(saved.pulseStyle)) state.pulseStyle = saved.pulseStyle;
683        if (['google', 'naver', 'ide', 'claude'].includes(saved.stealthTheme)) state.stealthTheme = saved.stealthTheme;
684        if (saved.connected === true) state.dataMode = 'connected';
685        state.prevCloseCache = JSON.parse(localStorage.getItem(prevCloseStorageKey) || '{}');
686        state.candleCache = JSON.parse(localStorage.getItem(candleStorageKey) || '{}');
687        state.usCodeCache = JSON.parse(localStorage.getItem(usCodeCacheKey) || '{}');
688        state.pulseCandles = JSON.parse(localStorage.getItem(pulseCandleStorageKey) || '{}');
689        state.flowCache = JSON.parse(localStorage.getItem(flowStorageKey) || '{}');
690        state.periodBase = JSON.parse(localStorage.getItem(periodBaseKey) || '{}');
691        state.thermoServer = JSON.parse(localStorage.getItem('heattab.thermoserver.v1') || '{}');
692        state.flowLong = JSON.parse(localStorage.getItem('heattab.flowlong.v1') || 'null');
693        state.thermoHistory = JSON.parse(localStorage.getItem(thermoStorageKey) || '[]');
694        state.thermoHistoryUs = JSON.parse(localStorage.getItem(thermoUsStorageKey) || '[]');
695        const savedHoldings = JSON.parse(lstore.getItem(holdingsStorageKey) || 'null');
696        if (Array.isArray(savedHoldings)) {
697          state.myHoldings = savedHoldings.filter(h => h && h.c && Number(h.qty) > 0);
698          state.pfEmpty = state.myHoldings.length === 0;
699        }
700        state.dailyCache = JSON.parse(localStorage.getItem(dailyStorageKey) || '{}');
701        state.stockMetaCache = JSON.parse(localStorage.getItem(stockMetaStorageKey) || '{}');
702      } catch {
703        lstore.removeItem(storageKey);
704      }
705    }
706
707    function saveSettings(extra = {}) {
708      // Secrets (key/secret/pin) are never written here — they live encrypted in secure storage.
709      lstore.setItem(storageKey, JSON.stringify({
710        provider: state.provider,
711        pulseWidth: state.pulseWidth,
712        pulseHeights: state.pulseHeights,
713        watchlistSymbols: state.watchlistSymbols,
714        defaultTab: state.defaultTab,
715        defaultIndex: state.defaultIndex,
716        pulseVisible: state.pulseVisible,
717        usPalette: state.usPalette,
718        colorScale: state.colorScale,
719        hideAmountsDefault: state.hideAmountsDefault,
720        colorblind: state.colorblind,
721        etfGroup: state.etfGroup,
722        customBoard: state.customBoard,
723        theme: state.theme,
724        autoRefreshSec: state.autoRefreshSec,
725        arHealed: true,
726        toolbarCollapsed: state.toolbarCollapsed,
727        stealthDefault: state.stealthDefault,
728        stealthTheme: state.stealthTheme,
729        badgeEnabled: state.badgeEnabled,
730        briefingEnabled: state.briefingEnabled,
731        pulseStyle: state.pulseStyle,
732        connected: ['connected', 'api', 'live'].includes(state.dataMode),
733        savedAt: new Date().toISOString(),
734        ...extra
735      }));
736    }
737
738    function clearSavedSettings() {
739      lstore.removeItem(storageKey);
740      lstore.removeItem(legacyStorageKey);
741    }
742
743    // 라이트/다크 팔레트 적용 — documentElement의 data-theme 속성으로 CSS 변수 전환.
744    function applyTheme() {
745      document.documentElement.setAttribute('data-theme', state.theme === 'dark' ? 'dark' : 'light');
746    }
747
748    // --- Secure secret storage: PBKDF2(PIN) -> AES-GCM, kept in chrome.storage.local ---
749
750    function chromeStorageAvailable() {
751      return !!window.chrome?.storage?.local;
752    }
753
754    function secureBlobGet() {
755      return new Promise(resolve => {
756        if (chromeStorageAvailable()) {
757          chrome.storage.local.get(secureStorageKey, out => resolve(out?.[secureStorageKey] || null));
758        } else {
759          try {
760            resolve(JSON.parse(localStorage.getItem(secureStorageKey) || 'null'));
761          } catch {
762            resolve(null);
763          }
764        }
765      });
766    }
767
768    function secureBlobSet(blob) {
769      return new Promise(resolve => {
770        if (chromeStorageAvailable()) {
771          chrome.storage.local.set({ [secureStorageKey]: blob }, () => resolve());
772        } else {
773          localStorage.setItem(secureStorageKey, JSON.stringify(blob));
774          resolve();
775        }
776      });
777    }
778
779    function secureBlobClear() {
780      return new Promise(resolve => {
781        if (chromeStorageAvailable()) {
782          chrome.storage.local.remove(secureStorageKey, () => resolve());
783        } else {
784          localStorage.removeItem(secureStorageKey);
785          resolve();
786        }
787      });
788    }
789
790    const textEncoder = new TextEncoder();
791    const textDecoder = new TextDecoder();
792
793    function bufToB64(buf) {
794      return btoa(String.fromCharCode(...new Uint8Array(buf)));
795    }
796
797    function b64ToBuf(b64) {
798      return Uint8Array.from(atob(b64), ch => ch.charCodeAt(0));
799    }
800
801    // 웹/임베드 아티팩트의 인라인 복제본 — crypto.js(네이티브)와 동작을 동일하게 유지.
802    // PBKDF2 iterations: v1 blob = 310k(레거시), v2 = 600k(현재 기본).
803    async function deriveAesKey(pin, salt, iterations = 600000) {
804      const base = await crypto.subtle.importKey('raw', textEncoder.encode('heattab:' + pin), 'PBKDF2', false, ['deriveKey']);
805      return crypto.subtle.deriveKey(
806        { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
807        base,
808        { name: 'AES-GCM', length: 256 },
809        false,
810        ['encrypt', 'decrypt']
811      );
812    }
813
814    async function encryptSecrets(pin, payload) {
815      const salt = crypto.getRandomValues(new Uint8Array(16));
816      const iv = crypto.getRandomValues(new Uint8Array(12));
817      const aesKey = await deriveAesKey(pin, salt, 600000);
818      const data = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, aesKey, textEncoder.encode(JSON.stringify(payload)));
819      return { v: 2, salt: bufToB64(salt), iv: bufToB64(iv), data: bufToB64(data), savedAt: new Date().toISOString() };
820    }
821
822    async function decryptSecrets(pin, blob) {
823      const iterations = blob.v === 1 ? 310000 : 600000; // v1=310k 레거시, v2/누락=600k
824      const aesKey = await deriveAesKey(pin, b64ToBuf(blob.salt), iterations);
825      const data = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: b64ToBuf(blob.iv) }, aesKey, b64ToBuf(blob.data));
826      const payload = JSON.parse(textDecoder.decode(data));
827      // v1 복호화 성공 시 같은 PIN으로 v2 재암호화 후 저장 (자동 마이그레이션, fire-and-forget)
828      if (blob.v === 1) {
829        encryptSecrets(pin, payload).then(secureBlobSet).catch(() => {});
830      }
831      return payload;
832    }
833
834    async function persistSecrets() {
835      // 4~12자리 허용 — 신규 생성 시 6~12 강제는 UI 검증에서, 여기선 레거시 4자리 재암호화도 통과시킨다.
836      if (!state.key || !state.secret || state.pin.length < 4 || state.pin.length > 12) return false;
837      const blob = await encryptSecrets(state.pin, { provider: state.provider, key: state.key, secret: state.secret });
838      await secureBlobSet(blob);
839      state.secureBlob = blob;
840      return true;
841    }
842
843    let pinUnlockResolver = null;
844
845    function requestPinUnlock() {
846      return new Promise((resolve, reject) => {
847        pinUnlockResolver = { resolve, reject };
848        state.pinPrompt = true;
849        state.pinError = '';
850        render();
851        setTimeout(() => $('pinUnlockInput')?.focus(), 60);
852      });
853    }
854
855    async function submitPinUnlock() {
856      const input = $('pinUnlockInput');
857      const pin = (input?.value || '').replace(/\D/g, '').slice(0, 12);
858      if (pin.length < 4 || pin.length > 12) { // 언락은 레거시 4자리도 허용
859        state.pinError = 'PIN을 입력하세요';
860        renderModal();
861        return;
862      }
863      try {
864        const payload = await decryptSecrets(pin, state.secureBlob);
865        if (pin.length < 6) toast('보안을 위해 6자리 이상 PIN으로 재연결을 권장합니다'); // 부드러운 업그레이드 권유
866        state.key = payload.key || '';
867        state.secret = payload.secret || '';
868        state.pin = pin;
869        if (payload.provider === 'kis' || payload.provider === 'toss') state.provider = payload.provider;
870        state.pinPrompt = false;
871        state.pinError = '';
872        if (input) input.value = '';
873        pinUnlockResolver?.resolve();
874        pinUnlockResolver = null;
875        render();
876      } catch {
877        state.pinError = 'PIN이 올바르지 않습니다';
878        if (input) {
879          input.value = '';
880          input.focus();
881        }
882        renderModal();
883      }
884    }
885
886    function cancelPinUnlock() {
887      state.pinPrompt = false;
888      state.pinError = '';
889      pinUnlockResolver?.reject(new Error('PIN 입력이 취소되었습니다'));
890      pinUnlockResolver = null;
891    }
892
893    async function ensureSecrets() {
894      if (state.key && state.secret) return;
895      if (!state.secureBlob) state.secureBlob = await secureBlobGet();
896      if (!state.secureBlob) return;
897      await requestPinUnlock();
898    }
899
900    async function initSecureStorage() {
901      state.secureBlob = await secureBlobGet();
902      const hasLegacyPlaintext = Boolean(state.key && state.secret);
903      if (hasLegacyPlaintext && !state.secureBlob && state.pin.length >= 4 && state.pin.length <= 12) {
904        // One-time migration: encrypt legacy plaintext keys, then strip them from localStorage.
905        try {
906          await persistSecrets();
907          saveSettings();
908          lstore.removeItem(legacyStorageKey);
909          toast('저장된 API 키를 암호화 저장소로 이전했습니다');
910        } catch {
911          // Keys stay in memory for this session; next onboarding pass will re-encrypt.
912        }
913      } else if (hasLegacyPlaintext && state.secureBlob) {
914        saveSettings();
915        lstore.removeItem(legacyStorageKey);
916      }
917      if (state.secureBlob && state.dataMode === 'eod') state.dataMode = 'connected';
918      renderControls();
919    }
920
921    function savePrevCloseCache() {
922      localStorage.setItem(prevCloseStorageKey, JSON.stringify(state.prevCloseCache));
923    }
924
925    function saveCandleCache() {
926      localStorage.setItem(candleStorageKey, JSON.stringify(state.candleCache));
927    }
928
929    function periodValue(item) {
930      if (state.period === '1D') return item.d;
931      // 1W/1M/YTD: 일봉 기준가(전일 종가 기준) 대비 현재가 수익률 — 실데이터 없으면 null('-' 표시)
932      const baseKey = { '1W': 'w', '1M': 'mo', 'YTD': 'y' }[state.period];
933      const base = state.periodBase[String(item.c || '')];
934      const price = Number(item.p || 0);
935      if (base && base[baseKey] > 0 && price > 0) return (price / base[baseKey] - 1) * 100;
936      return null;
937    }
938
939    function pct(v) {
940      if (v == null || Number.isNaN(v)) return '-';
941      return (v > 0 ? '+' : '') + v.toFixed(2) + '%';
942    }
943
944    // ETF 순자산(시총) 표기: m은 조원. 1조 이상은 X.X조원, 미만은 N억원.
945    function fmtAum(m) {
946      const n = Number(m) || 0;
947      return n >= 1 ? n.toFixed(1) + '조원' : Math.round(n * 10000) + '억원';
948    }
949
950    function money(v) {
951      return fmt.format(Math.round(v)) + '원';
952    }
953
954    function moneyByCurrency(v, currency = 'KRW') {
955      const n = Number(v || 0);
956      if (currency === 'USD') return '$' + n.toLocaleString('en-US', { maximumFractionDigits: 2 });
957      return fmt.format(Math.round(n)) + '원';
958    }
959
960    function compactValue(v, unit = '') {
961      const n = Number(v || 0);
962      if (unit === '%') return n.toFixed(2) + '%';
963      if (unit === '$') return '$' + (Math.abs(n) >= 1000 ? n.toLocaleString('en-US', { maximumFractionDigits: 0 }) : n.toLocaleString('en-US', { maximumFractionDigits: 1 }));
964      if (Math.abs(n) >= 10000) return n.toLocaleString('en-US', { maximumFractionDigits: 0 });
965      if (Math.abs(n) >= 1000) return n.toLocaleString('en-US', { maximumFractionDigits: 1 });
966      if (Math.abs(n) >= 100) return n.toFixed(1);
967      return n.toFixed(2);
968    }
969
970    function lookup(name) {
971      const sp500 = window.sp500Stocks || [];
972      const kosdaq = state.kosdaqStocks || [];
973      // Full KOSPI200 list too — search can add symbols beyond the hardcoded top-20,
974      // and without this they render as bare codes in the watchlist view.
975      const k200 = window.kospi200Stocks || [];
976      return stocks.find(x => x.n === name || x.c === name) || k200.find(x => x.n === name || x.c === name) || usStocks.find(x => x.n === name || x.c === name) || sp500.find(x => x.n === name || x.c === name) || kosdaq.find(x => x.n === name || x.c === name) || activeEtfs().find(x => x.n === name || x.c === name);
977    }
978
979    // --- 업종 맵: 네이버 업종 목록 + 업종별 종목 (7일 캐시) → 대분류 롤업 ---
980    const industryStorageKey = 'heattab.industry.v1';
981    let industryMapLoading = false;
982
983    async function ensureIndustryMap() {
984      if (state.industryMap && Object.keys(state.industryMap).length) return state.industryMap;
985      try {
986        const stored = JSON.parse(localStorage.getItem(industryStorageKey) || 'null');
987        if (stored?.map && Date.now() - stored.savedAt < 7 * 24 * 3600 * 1000) {
988          state.industryMap = stored.map;
989          return stored.map;
990        }
991      } catch {}
992      if (industryMapLoading) return null;
993      industryMapLoading = true;
994      try {
995        const groupsRes = await fetchJson('https://m.stock.naver.com/api/stocks/industry?page=1&pageSize=100');
996        const groups = groupsRes.groups || [];
997        const map = {};
998        for (const chunk of chunkArray(groups, 4)) {
999          await Promise.allSettled(chunk.map(async g => {
1000            // pageSize 상한 100 — totalCount만큼 페이지네이션 (최대 5쪽)
1001            for (let page = 1; page <= 5; page++) {
1002              const d = await fetchJson(`https://m.stock.naver.com/api/stocks/industry/${g.no}?page=${page}&pageSize=100`);
1003              (d.stocks || []).forEach(row => { if (row.itemCode) map[row.itemCode] = g.name; });
1004              if (page * 100 >= Number(d.totalCount || 0)) break;
1005              await sleep(80);
1006            }
1007          }));
1008          await sleep(150);
1009        }
1010        if (Object.keys(map).length > 500) {
1011          state.industryMap = map;
1012          try { localStorage.setItem(industryStorageKey, JSON.stringify({ map, savedAt: Date.now() })); } catch {}
1013        }
1014        return state.industryMap || null;
1015      } finally {
1016        industryMapLoading = false;
1017      }
1018    }
1019
1020    // 네이버 세부 업종명(80여 개) → 히트맵용 대분류 롤업
1021    const SECTOR_BUCKETS = [
1022      ['헬스케어', /제약|바이오|생명과학|건강|의료|생물공학/],
1023      ['반도체·IT', /반도체|디스플레이|전자장비|전자제품|IT서비스|소프트웨어|컴퓨터|사무용|통신장비|핸드셋/],
1024      ['콘텐츠·미디어', /게임|엔터|방송|미디어|광고|출판|교육|인터넷|양방향/],
1025      ['2차전지·화학', /전기제품|화학|에너지장비|석유|가스/],
1026      ['소재·금속', /금속|광물|철강|종이|목재|포장재|건축자재/],
1027      ['산업재', /기계|건설|조선|운송|해운|항공|우주|방위|전기장비|복합기업|상사|자본재|상업서비스|건축제품|부동산/],
1028      ['자동차', /자동차/],
1029      ['소비재', /음식료|식품|담배|화장품|섬유|의류|호텔|레저|유통|판매|가정용|내구소비재|음료|가구|백화점|상점|문구|소매|소비자서비스/],
1030      ['금융', /은행|증권|보험|금융|카드|자산|창업투자/],
1031      ['통신·유틸', /통신서비스|전력|유틸리티|수도|가스유틸/]
1032    ];
1033
1034    function rollupSector(industryName) {
1035      if (!industryName) return null;
1036      for (const [bucket, re] of SECTOR_BUCKETS) {
1037        if (re.test(industryName)) return bucket;
1038      }
1039      return '기타';
1040    }
1041
1042    // --- 코스닥150: 시총 상위 150 종목을 런타임 로드 (24시간 캐시) ---
1043    const kosdaqStorageKey = 'heattab.kosdaq.v1';
1044
1045    async function loadKosdaq150(force = false) {
1046      if (!force && state.kosdaqStocks?.length && Date.now() - (state.kosdaqSavedAt || 0) < 24 * 3600 * 1000) return state.kosdaqStocks;
1047      if (!force) {
1048        try {
1049          const stored = JSON.parse(localStorage.getItem(kosdaqStorageKey) || 'null');
1050          if (stored?.list?.length && Date.now() - stored.savedAt < 24 * 3600 * 1000) {
1051            state.kosdaqStocks = stored.list;
1052            state.kosdaqSavedAt = stored.savedAt;
1053            return stored.list;
1054          }
1055        } catch {}
1056      }
1057      const list = [];
1058      for (const page of [1, 2]) {
1059        const data = await fetchJson(`https://m.stock.naver.com/api/stocks/marketValue/KOSDAQ?page=${page}&pageSize=100`);
1060        (data.stocks || []).forEach(row => {
1061          if (row.stockEndType !== 'stock') return;
1062          const mcapEok = parseNaverNumber(row.marketValue);
1063          const price = parseNaverNumber(row.closePrice);
1064          if (!row.itemCode || !(mcapEok > 0) || !(price > 0)) return;
1065          const change = naverRatio(row) ?? 0;
1066          list.push({ n: String(row.stockName), c: String(row.itemCode), s: String(row.stockName), sec: '코스닥', m: mcapEok / 10000, p: price, d: change, w: change, mo: 0, y: 0 });
1067        });
1068      }
1069      state.kosdaqStocks = list.slice(0, 150);
1070      state.kosdaqSavedAt = Date.now();
1071      try { localStorage.setItem(kosdaqStorageKey, JSON.stringify({ list: state.kosdaqStocks, savedAt: state.kosdaqSavedAt })); } catch {}
1072      return state.kosdaqStocks;
1073    }
1074
1075    // --- ETF universe: 시총 상위 ~150 ETF를 네이버 etfItemList로 런타임 로드 (24시간 캐시) ---
1076    // 하드코딩 etfs는 로드 실패 시 fallback으로 유지된다.
1077    const etfStorageKey = 'heattab.etfs.v1';
1078
1079    // etfItemList.nhn(레거시 finance.naver.com)은 EUC-KR 인코딩이라 프록시가 바이트를 그대로
1080    // 넘긴다. response.json()은 UTF-8 가정이라 한글이 깨지므로 직접 EUC-KR로 디코드한다.
1081    async function fetchEucKrJson(url, timeoutMs = 10000) {
1082      const response = await fetch(routed(url), { signal: AbortSignal.timeout(timeoutMs) });
1083      if (!response.ok) throw new Error('HTTP ' + response.status);
1084      const buf = await response.arrayBuffer();
1085      return JSON.parse(new TextDecoder('euc-kr').decode(buf));
1086    }
1087
1088    // 네이버 etfTabCode(1-7) → 앱 카테고리 fallback. 이름 규칙이 우선, 매칭 실패 시 사용.
1089    const ETF_TAB_CAT = { 1: '국내 지수', 2: '섹터', 3: '국내 지수', 4: '해외 지수', 5: '원자재', 6: '금리·채권', 7: '섹터' };
1090    // 이름 키워드 → 앱 카테고리 (하드코딩 스냅샷의 세분류를 최대한 재현). 순서 = 우선순위.
1091    const ETF_CAT_RULES = [
1092      ['금리·채권', /금리|채권|국고|통안|CD금리|KOFR|SOFR|단기채|회사채|크레딧|만기매칭|국채/],
1093      ['원자재', /금현물|골드|금선물|은선물|원유|WTI|천연가스|구리|농산물|원자재|팔라듐|플래티넘|커머디티|광물/],
1094      ['반도체·테크', /반도체|필라델피아|소부장|AI반도체|인공지능|빅테크|클라우드|소프트웨어|테크TOP/],
1095      ['2차전지·소재', /2차전지|배터리|전기차|에너지화학|리튬|이차전지/],
1096      ['배당', /배당|고배당|디비던드|월배당|리츠|리얼티|dividend/i],
1097      ['해외 지수', /미국|S&P|SP500|나스닥|다우|차이나|중국|일본|인도|베트남|유럽|글로벌|선진|신흥|해외|월드|MSCI|위안|항셍|필리핀|대만|독일/i],
1098      ['국내 지수', /200|코스피|코스닥|KRX|레버리지|인버스|한국형|초단기/],
1099    ];
1100    function etfCategory(name, tabCode) {
1101      for (const [cat, re] of ETF_CAT_RULES) if (re.test(name)) return cat;
1102      return ETF_TAB_CAT[tabCode] || '섹터';
1103    }
1104
1105    const ETF_BRAND_RE = /^(KODEX|TIGER|KBSTAR|ACE|SOL|PLUS|ARIRANG|KOSEF|HANARO|RISE|WOORI|TIMEFOLIO|BNK|KIWOOM|히어로즈|파워|마이다스|한국투자)\s*/;
1106
1107    // etfAnalysis 섹터 코드(영문) → 한글 라벨 (구성종목 비중 패널의 섹터 라인용)
1108    const ETF_SECTOR_LABEL = {
1109      IT: 'IT', INDUSTRIALS: '산업재', FINANCIALS: '금융', HEALTHCARE: '헬스케어', MATERIALS: '소재',
1110      CONSUMER_DISCRETIONARY: '자유소비재', CONSUMER_STAPLES: '필수소비재', ENERGY: '에너지',
1111      UTILITIES: '유틸리티', COMMUNICATION_SERVICES: '커뮤니케이션', TELECOM: '통신', REAL_ESTATE: '리츠',
1112      CONSUMER: '소비재', TECH: '테크', UNCLASSIFIED: '기타', ETC: '기타', CASH: '현금'
1113    };
1114    const etfSectorLabel = code => ETF_SECTOR_LABEL[code] || code;
1115
1116    // 운용사(브랜드) = ETF 이름의 첫 토큰 (KODEX/TIGER/RISE/ACE …). 이상한 이름은 '기타'.
1117    function etfBrand(name) {
1118      const tok = String(name || '').trim().split(/\s+/)[0] || '';
1119      return tok && /[A-Za-z가-힣0-9]/.test(tok) ? tok : '기타';
1120    }
1121    // 하드코딩 fallback ETF에도 brand 부여 (동적 목록은 loadEtfs에서 부여)
1122    etfs.forEach(e => { if (!e.brand) e.brand = etfBrand(e.n); });
1123
1124    // 현재 그룹 모드에 따른 롤업 키: 카테고리 또는 운용사(브랜드).
1125    function etfGroupKeyOf(e) {
1126      return state.etfGroup === 'brand' ? (e.brand || etfBrand(e.n)) : e.cat;
1127    }
1128
1129    // 현재 사용할 ETF 배열: 동적 로드 성공 시 그걸, 아니면 하드코딩 fallback.
1130    function activeEtfs() {
1131      return state.etfDynamic?.length ? state.etfDynamic : etfs;
1132    }
1133
1134    // etfItemList 한 행 → 앱 ETF 객체 (loadEtfs와 loadEtfGroupRest가 공유 → 동일 shape 보장).
1135    function mapEtfRow(row) {
1136      const marketSum = Number(row.marketSum) || 0; // 억원
1137      const price = Number(row.nowVal) || 0;
1138      const name = String(row.itemname || '').trim();
1139      if (!row.itemcode || !name || !(marketSum > 0)) return null;
1140      // 브랜드 접두어 제거 후 짧은 라벨. "KODEX 200"류가 "200"만 남는 문제 → 순수 숫자/2자 미만이면 전체 이름.
1141      let short = name.replace(ETF_BRAND_RE, '').trim();
1142      if (!short || /^\d+$/.test(short) || short.length < 2) short = name;
1143      return {
1144        n: name,
1145        s: short.length > 14 ? short.slice(0, 14) : short,
1146        c: String(row.itemcode),
1147        cat: etfCategory(name, Number(row.etfTabCode)),
1148        brand: etfBrand(name), // 운용사(브랜드) 그룹핑용
1149        m: marketSum / 10000, // 억원 → 조원 (타일 크기용)
1150        p: price,
1151        d: Number(row.changeRate) || 0,
1152        m3: Number(row.threeMonthEarnRate) || 0, // 3개월 수익률 — 카테고리 온도 모멘텀용
1153        w: 0, mo: 0, y: 0 // 무료 데이터에 기간 수익률 없음 → 1D(d) 중심
1154      };
1155    }
1156
1157    // 하드코딩 스냅샷 cons를 코드로 매칭해 채운다 (즉시 드릴다운/중복노출 동작).
1158    function carryHardcodedCons(arr) {
1159      const consByCode = new Map(etfs.filter(e => e.cons).map(e => [e.c, e.cons]));
1160      arr.forEach(e => { const c = consByCode.get(e.c); if (c && !e.cons) e.cons = c; });
1161    }
1162
1163    // 브랜드/카테고리별 전체 개수 (더 가져오기 라벨용). 전체 피드 기준.
1164    function etfGroupTotal(key) {
1165      return state.etfGroup === 'brand' ? (state.etfBrandTotals?.[key] || 0) : (state.etfCatTotals?.[key] || 0);
1166    }
1167
1168    async function loadEtfs(force = false) {
1169      if (!force && state.etfDynamic?.length && Date.now() - (state.etfSavedAt || 0) < 24 * 3600 * 1000) return state.etfDynamic;
1170      if (!force) {
1171        try {
1172          const stored = JSON.parse(localStorage.getItem(etfStorageKey) || 'null');
1173          // brandTotals 없는 구버전 캐시는 무효화 — 더 가져오기 라벨/버튼이 안 뜨는 원인
1174          if (stored?.list?.length && stored.brandTotals && Date.now() - stored.savedAt < 24 * 3600 * 1000) {
1175            state.etfDynamic = stored.list;
1176            state.etfSavedAt = stored.savedAt;
1177            if (stored.brandTotals) state.etfBrandTotals = stored.brandTotals; // 더 가져오기 라벨 복원
1178            if (stored.catTotals) state.etfCatTotals = stored.catTotals;
1179            return stored.list;
1180          }
1181        } catch {}
1182      }
1183      const data = await fetchEucKrJson('https://finance.naver.com/api/sise/etfItemList.nhn');
1184      const raw = data?.result?.etfItemList || [];
1185      const list = raw.map(mapEtfRow).filter(Boolean);
1186      list.sort((a, b) => b.m - a.m);
1187      state.etfFull = list; // 세션 메모리에 전체 목록 보관 → 더 가져오기 시 재요청 회피
1188      // 브랜드/카테고리 전체 개수 집계 (라벨용, 영속화)
1189      const brandTotals = {}, catTotals = {};
1190      list.forEach(e => { brandTotals[e.brand] = (brandTotals[e.brand] || 0) + 1; catTotals[e.cat] = (catTotals[e.cat] || 0) + 1; });
1191      state.etfBrandTotals = brandTotals;
1192      state.etfCatTotals = catTotals;
1193      const top = list.slice(0, 150);
1194      carryHardcodedCons(top);
1195      state.etfDynamic = top;
1196      state.etfSavedAt = Date.now();
1197      try { localStorage.setItem(etfStorageKey, JSON.stringify({ list: top, savedAt: state.etfSavedAt, brandTotals, catTotals })); } catch {}
1198      return top;
1199    }
1200
1201    // 특정 브랜드/카테고리의 나머지 ETF(top-150 밖)를 전체 피드에서 병합. 세션 전체목록 없으면 재요청(엣지캐시).
1202    async function loadEtfGroupRest(key) {
1203      let full = state.etfFull;
1204      if (!full?.length) {
1205        const data = await fetchEucKrJson('https://finance.naver.com/api/sise/etfItemList.nhn');
1206        full = (data?.result?.etfItemList || []).map(mapEtfRow).filter(Boolean);
1207        full.sort((a, b) => b.m - a.m);
1208        state.etfFull = full;
1209      }
1210      if (!state.etfDynamic?.length) state.etfDynamic = activeEtfs().slice(); // 폴백 상태면 가변 배열 확보
1211      const existing = new Set(state.etfDynamic.map(e => e.c));
1212      const groupKeyOf = e => state.etfGroup === 'brand' ? e.brand : e.cat;
1213      const extras = full.filter(e => groupKeyOf(e) === key && !existing.has(e.c));
1214      if (!extras.length) return 0;
1215      carryHardcodedCons(extras);
1216      state.etfDynamic.push(...extras);
1217      try { localStorage.setItem(etfStorageKey, JSON.stringify({ list: state.etfDynamic, savedAt: state.etfSavedAt, brandTotals: state.etfBrandTotals, catTotals: state.etfCatTotals })); } catch {}
1218      return extras.length;
1219    }
1220
1221    // 특정 ETF의 실제 구성종목(상위 10)을 etfAnalysis에서 지연 로드해 cons를 채운다.
1222    // cons 형태: [[종목명, 비중%], ..., ['기타', 나머지%]] — 드릴다운·중복노출이 소비하는 shape.
1223    async function loadEtfCons(etf) {
1224      if (!etf) return false;
1225      // cons(드릴다운·중복노출용 [이름,비중])와 consDetail(코드 포함 상세, 비중 패널용) 둘 다 있으면 스킵.
1226      if (etf.cons?.length && etf.consDetail?.length) return true;
1227      if (etf._consNone || etf._consLoading) return false;
1228      etf._consLoading = true;
1229      try {
1230        const data = await fetchJson(`https://m.stock.naver.com/api/stock/${etf.c}/etfAnalysis`);
1231        const top = data?.etfTop10MajorConstituentAssets || [];
1232        const cons = [], detail = [];
1233        let sum = 0;
1234        top.forEach(x => {
1235          const w = parseFloat(String(x.etfWeight)) || 0; // etfWeight는 "33.03%" 문자열
1236          const nm = String(x.itemName || '').trim();
1237          const c = String(x.itemCode || '').trim();
1238          if (!nm || !(w > 0)) return;
1239          const wr = Math.round(w * 10) / 10;
1240          cons.push([nm, wr]);
1241          detail.push({ n: nm, c, w: wr }); // 코드 포함 — 국내 종목이면 클릭해 패널 열기
1242          sum += w;
1243        });
1244        if (!cons.length) { etf._consNone = true; return false; }
1245        const rest = Math.round((100 - sum) * 10) / 10;
1246        if (rest > 0.5) cons.push(['기타', rest]);
1247        // etfAnalysis의 최신 비중으로 cons를 항상 교체 → 드릴 트리맵/리스트가 상세 패널과 일치.
1248        // (하드코딩 cons는 fetch 전 fallback일 뿐, 분석 데이터 도착 시 대체)
1249        etf.cons = cons;
1250        etf.consDetail = detail;
1251        etf.consRest = rest > 0.5 ? rest : 0;
1252        // 섹터 구성 (보너스, 같은 payload): [{detailTypeCode:'IT', weight:67.6}]
1253        etf.sectors = (data?.sectorPortfolioList || [])
1254          .map(s => ({ code: String(s.detailTypeCode || '').trim(), w: Number(s.weight) || 0 }))
1255          .filter(s => s.code && s.w > 0);
1256        // 갱신된 cons를 캐시에도 반영 (totals 유지) → 다음 세션에서 최신 비중 재사용
1257        if (state.etfDynamic?.length) {
1258          try { localStorage.setItem(etfStorageKey, JSON.stringify({ list: state.etfDynamic, savedAt: state.etfSavedAt, brandTotals: state.etfBrandTotals, catTotals: state.etfCatTotals })); } catch {}
1259        }
1260        return true;
1261      } finally {
1262        etf._consLoading = false;
1263      }
1264    }
1265
1266    // 상세 패널용 ETF ref: 유니버스(activeEtfs)에 있으면 그걸, 없으면(관심종목 등 top-150 밖) 코드만 든 stub.
1267    // stub에도 loadEtfCons가 consDetail을 채운다 (세션 캐시).
1268    const etfPanelCache = {};
1269    function etfRefForPanel(code) {
1270      return activeEtfs().find(e => e.c === code) || (etfPanelCache[code] || (etfPanelCache[code] = { c: String(code) }));
1271    }
1272
1273    // 보유 ETF의 구성종목을 백그라운드로 채워 중복노출(Pro) 분석이 동적 ETF도 커버하게 한다.
1274    // _consNone/_consLoading 가드로 렌더 루프·재요청을 막는다.
1275    function prefetchHeldEtfCons() {
1276      if (!state.proUnlocked) return;
1277      const pf = portfolio();
1278      const targets = pf.items
1279        .map(h => activeEtfs().find(e => e.n === h.n))
1280        .filter(e => e && !e.cons?.length && !e._consLoading && !e._consNone);
1281      if (!targets.length) return;
1282      Promise.allSettled(targets.slice(0, 8).map(e => loadEtfCons(e))).then(rs => {
1283        if (rs.some(r => r.status === 'fulfilled' && r.value)) render();
1284      });
1285    }
1286
1287    function isFullTabView() {
1288      return !state.sideMode && window.innerWidth > 900;
1289    }
1290
1291    function marketItems() {
1292      if (state.index === 'S&P500') {
1293        if (window.sp500Stocks && window.sp500Stocks.length) {
1294          if (state.viewMode === 'list') return window.sp500Stocks;
1295          return isFullTabView() ? sp500FocusItems() : usStocks;
1296        }
1297        return usStocks;
1298      }
1299      if (state.index === 'NASDAQ100') {
1300        if (window.sp500Stocks && window.sp500Stocks.length) {
1301          const items = usFocusItems(nasdaq100Symbols);
1302          if (items.length) {
1303            if (state.viewMode === 'list') return items;
1304            return isFullTabView() ? items : nasdaqStocks;
1305          }
1306        }
1307        return nasdaqStocks;
1308      }
1309      if (state.index === 'KOSDAQ150') {
1310        const list = (state.kosdaqStocks || []).map(item => ({
1311          ...item,
1312          sec: rollupSector(state.industryMap?.[item.c]) || '코스닥'
1313        }));
1314        if (state.viewMode === 'list') return list;
1315        return isFullTabView() ? list.slice(0, kospi200HeatmapLimit) : list.slice(0, 20);
1316      }
1317      if (state.index === '관심종목') {
1318        // Mixed KR/US watchlist: KR m is 조원, US m is 조 달러 — convert US to KRW
1319        // at the live rate. Log scale (not sqrt): real caps span 400x+ (Apple 8,500ì¡°
1320        // vs KOSDAQ mid-caps ~20ì¡°), and a personal watchlist should keep every tile
1321        // visible while preserving size order.
1322        const usdKrw = marketPulse.find(x => x.c === 'FX_USDKRW')?.v || 1380;
1323        return state.watchlistSymbols.map((symbol, i) => {
1324          const ref = lookup(symbol);
1325          if (ref) {
1326            const mKrw = isKoreanCode(String(ref.c)) ? ref.m : ref.m * usdKrw;
1327            return { ...ref, m: mKrw, heatM: Math.log2(2 + Math.max(0, mKrw)) };
1328          }
1329          const lastChange = state.prevCloseCache[symbol]?.lastChange;
1330          const change = Number.isFinite(lastChange) ? lastChange : 0;
1331          const fallbackM = Math.max(1, 20 - i);
1332          return { n: symbol, c: symbol, s: symbol, sec: '관심종목', m: fallbackM, heatM: Math.log2(2 + fallbackM), p: state.priceOverrides[symbol] || 0, d: change, w: change, mo: change, y: change };
1333        });
1334      }
1335      if (window.kospi200Stocks && window.kospi200Stocks.length) {
1336        if (state.viewMode === 'list') return window.kospi200Stocks;
1337        return isFullTabView() ? window.kospi200Stocks.slice(0, kospi200HeatmapLimit) : stocks;
1338      }
1339      return stocks;
1340    }
1341
1342    function usFocusItems(symbols) {
1343      const all = window.sp500Stocks || [];
1344      const bySymbol = new Map(all.map(item => [item.c, item]));
1345      const focused = [];
1346      symbols.forEach((symbol, rank) => {
1347        const item = bySymbol.get(symbol);
1348        if (!item) return;
1349        const adjustedM = item.m >= 0.6 ? item.m : Math.max(0.08, 0.56 * Math.pow(0.988, rank));
1350        const lastChange = state.prevCloseCache[symbol]?.lastChange;
1351        focused.push({
1352          ...item,
1353          p: state.priceOverrides[symbol] ?? item.p,
1354          d: Number.isFinite(lastChange) ? lastChange : item.d,
1355          w: Number.isFinite(lastChange) ? lastChange : item.w,
1356          m: adjustedM,
1357          heatM: Math.sqrt(adjustedM)
1358        });
1359      });
1360      return focused;
1361    }
1362
1363    function sp500FocusItems() {
1364      return usFocusItems(sp500FocusSymbols);
1365    }
1366
1367    function mix(a, b, t) {
1368      const ah = a.match(/\w\w/g).map(x => parseInt(x, 16));
1369      const bh = b.match(/\w\w/g).map(x => parseInt(x, 16));
1370      return '#' + ah.map((v, i) => Math.round(v + (bh[i] - v) * t).toString(16).padStart(2, '0')).join('');
1371    }
1372
1373    function usesUsPalette() {
1374      return state.usPalette !== 'kr' && state.tab === 'market' && ['S&P500', 'NASDAQ100'].includes(state.index);
1375    }
1376
1377    function colorFor(v, max = 3, forceUsPalette = false) {
1378      if (v == null || Math.abs(v) < 0.05) return getComputedStyle(document.documentElement).getPropertyValue('--neutral').trim();
1379      const root = getComputedStyle(document.documentElement);
1380      const usPalette = forceUsPalette || usesUsPalette();
1381      const up = state.colorblind
1382        ? root.getPropertyValue('--orange').trim()
1383        : root.getPropertyValue(usPalette ? '--us-up' : '--up').trim();
1384      const down = state.colorblind
1385        ? root.getPropertyValue('--cyan').trim()
1386        : root.getPropertyValue(usPalette ? '--us-down' : '--down').trim();
1387      const neutral = root.getPropertyValue('--neutral').trim();
1388      const t = Math.min(1, Math.abs(v) / max);
1389      return v > 0 ? mix(neutral, up, 0.38 + 0.62 * t) : mix(neutral, down, 0.38 + 0.62 * t);
1390    }
1391
1392    function changeTextColor(v, forceUsPalette = false) {
1393      const usPalette = forceUsPalette || usesUsPalette();
1394      if (v >= 0) return state.colorblind ? 'var(--orange)' : `var(${usPalette ? '--us-up' : '--up'})`;
1395      return state.colorblind ? 'var(--cyan)' : `var(${usPalette ? '--us-down' : '--down'})`;
1396    }
1397
1398    // 52주 가격 범위 — 1순위: 종목 메타 실제값 · 2순위: 1년 일봉 · 3순위: 등락률 추정(est)
1399    function range52(item) {
1400      const symbol = String(item.c || '');
1401      const ref = symbol ? lookup(symbol) : null;
1402      const price = Number(item.p || item.cur || item.holding?.cur || item.ref?.p || state.priceOverrides[symbol] || ref?.p || 100);
1403      const meta = state.stockMetaCache[symbol];
1404      let low = Number(meta?.low52 || item.low52 || item.ref?.low52 || 0);
1405      let high = Number(meta?.high52 || item.high52 || item.ref?.high52 || 0);
1406      let est = false;
1407      if ((!low || !high || high <= low) && state.dailyCache[symbol]) {
1408        const daily = state.dailyCache[symbol];
1409        low = Math.min(...daily.lows.filter(v => v > 0));
1410        high = Math.max(...daily.highs.filter(v => v > 0));
1411      }
1412      if (!low || !high || high <= low) {
1413        est = true;
1414        const move = Math.max(-35, Math.min(80, Number(item.y || item.mo || item.d || ref?.y || ref?.mo || ref?.d || 0)));
1415        const center = 0.5 + move / 220;
1416        const range = Math.max(0.18, Math.min(0.55, Math.abs(move) / 140 + 0.24));
1417        low = price * (1 - Math.min(0.75, center * range));
1418        high = price * (1 + Math.min(1.2, (1 - center) * range));
1419      }
1420      if (!low || !high || high <= low) return null;
1421      return { price, low, high, est };
1422    }
1423
1424    function weeklyPosition(item) {
1425      const r = range52(item);
1426      if (!r) return null;
1427      return Math.max(0, Math.min(100, (r.price - r.low) / (r.high - r.low) * 100));
1428    }
1429
1430    // 52주 최고가 대비 낙폭 (0 ~ -%) — 고점 부근일수록 0에 가깝다
1431    function peakDrawdown(item) {
1432      const r = range52(item);
1433      if (!r || !(r.high > 0)) return null;
1434      return Math.min(0, (r.price - r.high) / r.high * 100);
1435    }
1436
1437    // --- Investor flow (수급): per-stock foreign/institution net buy from Naver trend API ---
1438
1439    function isFlowBasis() {
1440      return state.colorBasis === 'frgn' || state.colorBasis === 'inst' || state.colorBasis === 'ind';
1441    }
1442
1443    // 수급 합산 일수 — 기간 버튼 연동. 1M/YTD는 Pro 서버 집계(flowLong) 사용, 무료는 10일 누적 폴백
1444    function flowPeriodDays() {
1445      return state.period === '1D' ? 1 : state.period === '1W' ? 5 : 10;
1446    }
1447
1448    function flowLongReady() {
1449      return can('flowLong') && state.flowLong && Date.now() - (state.flowLong.fetchedAt || 0) < 6 * 3600 * 1000;
1450    }
1451
1452    function flowPeriodLabel() {
1453      if (state.period === '1D') return '당일';
1454      if (state.period === '1W') return '5일';
1455      // 개인은 1개월 축적이 완료(pReady)되기 전·YTD에는 10일 폴백
1456      const longOk = flowLongReady() && (state.colorBasis !== 'ind' || (state.period === '1M' && state.flowLong.pReady));
1457      if (longOk) return state.period === '1M' ? '1개월' : '연초↑';
1458      return '10일';
1459    }
1460
1461    async function fetchFlowLong() {
1462      if (!can('flowLong') || !state.license?.token) return;
1463      if (state.flowLong && Date.now() - (state.flowLong.fetchedAt || 0) < 60 * 60 * 1000) return;
1464      if (fetchFlowLong.busy) return;
1465      fetchFlowLong.busy = true;
1466      try {
1467        const res = await fetch(LICENSE_SERVER + '/api/market/flows', {
1468          method: 'POST',
1469          headers: { 'Content-Type': 'application/json' },
1470          body: JSON.stringify({ token: state.license.token }),
1471          signal: AbortSignal.timeout(12000)
1472        });
1473        if (res.ok) {
1474          const data = await res.json();
1475          if (data?.m) {
1476            state.flowLong = { ...data, fetchedAt: Date.now() };
1477            try { localStorage.setItem('heattab.flowlong.v1', JSON.stringify(state.flowLong)); } catch {}
1478            if (isFlowBasis()) renderMap();
1479          }
1480        }
1481      } catch {} finally { fetchFlowLong.busy = false; }
1482    }
1483
1484    function flowValue(item) {
1485      const code = String(item.c || '');
1486      // 1M/YTD: Pro 서버 집계 (연초부터 축적된 실데이터). 개인은 1개월 축적 완료 후부터
1487      if ((state.period === '1M' || state.period === 'YTD') && flowLongReady()
1488        && (state.colorBasis !== 'ind' || (state.period === '1M' && state.flowLong.pReady))) {
1489        const agg = state.flowLong[state.period === '1M' ? 'm' : 'y']?.[code];
1490        if (!agg) return null;
1491        if (state.colorBasis === 'ind') return (agg.pn || 0) >= 21 ? Number(agg.p) : null;
1492        return Number(state.colorBasis === 'inst' ? agg.i : agg.f);
1493      }
1494      const flow = state.flowCache[code];
1495      if (!flow) return null;
1496      const days = flowPeriodDays();
1497      if (days === 1) {
1498        const v = state.colorBasis === 'inst' ? flow.instAmt : state.colorBasis === 'ind' ? flow.indAmt : flow.frgnAmt;
1499        return Number.isFinite(v) ? v : null;
1500      }
1501      const rows = (flow.frgnDays || []).slice(0, days); // 최신순 저장
1502      if (!rows.length) return null;
1503      return rows.reduce((s, r) => s + (Number(state.colorBasis === 'inst' ? r.inst : state.colorBasis === 'ind' ? r.ind : r.amt) || 0), 0);
1504    }
1505
1506    function formatEok(v) {
1507      if (v == null || !Number.isFinite(v)) return '-';
1508      const sign = v > 0 ? '+' : v < 0 ? '-' : '';
1509      const abs = Math.abs(v);
1510      if (abs >= 10000) return sign + (abs / 10000).toFixed(1) + 'ì¡°';
1511      return sign + Math.round(abs).toLocaleString('ko-KR') + '억';
1512    }
1513
1514    async function fetchFlow(code) {
1515      const rows = await fetchJson(`https://m.stock.naver.com/api/stock/${code}/trend`);
1516      const row = Array.isArray(rows) ? rows[0] : null;
1517      if (!row) return null;
1518      const price = parseNaverNumber(row.closePrice) || 0;
1519      const frgnQty = parseNaverNumber(row.foreignerPureBuyQuant) || 0;
1520      const instQty = parseNaverNumber(row.organPureBuyQuant) || 0;
1521      const indQty = parseNaverNumber(row.individualPureBuyQuant) || 0;
1522      // 최근 10거래일 외국인/기관 순매수대금 (최신순으로 수신 → 그대로 저장, 사용처에서 정렬)
1523      const frgnDays = rows.slice(0, 10).map(r => ({
1524        d: String(r.bizdate || ''),
1525        amt: (parseNaverNumber(r.foreignerPureBuyQuant) || 0) * (parseNaverNumber(r.closePrice) || 0) / 1e8,
1526        inst: (parseNaverNumber(r.organPureBuyQuant) || 0) * (parseNaverNumber(r.closePrice) || 0) / 1e8,
1527        ind: (parseNaverNumber(r.individualPureBuyQuant) || 0) * (parseNaverNumber(r.closePrice) || 0) / 1e8
1528      }));
1529      return {
1530        frgnAmt: frgnQty * price / 1e8,
1531        instAmt: instQty * price / 1e8,
1532        indAmt: indQty * price / 1e8,
1533        frgnDays,
1534        holdRatio: String(row.foreignerHoldRatio || ''),
1535        bizdate: String(row.bizdate || ''),
1536        savedAt: Date.now()
1537      };
1538    }
1539
1540    async function ensureFlows(codes) {
1541      const stale = [...new Set(codes)].filter(code => {
1542        const flow = state.flowCache[code];
1543        return !flow || Date.now() - flow.savedAt > flowTtlMs;
1544      });
1545      if (!stale.length || state.flowLoading) return;
1546      state.flowLoading = true;
1547      let done = 0;
1548      try {
1549        // 동시 4개 제한 — 브라우저 호스트당 연결(6개)을 새로고침용으로 남겨둠
1550        for (const group of chunkArray(stale, 4)) {
1551          await Promise.allSettled(group.map(code => fetchFlow(code)
1552            .then(flow => { if (flow) { state.flowCache[code] = flow; done++; } })
1553            .catch(() => {})));
1554          await sleep(150);
1555        }
1556        if (done) {
1557          localStorage.setItem(flowStorageKey, JSON.stringify(state.flowCache));
1558          renderMap();
1559          if (state.signalsOpen) renderSignals();
1560          toast(`수급 데이터 ${done}건 갱신 (당일 순매수대금)`);
1561        }
1562      } finally {
1563        state.flowLoading = false;
1564      }
1565    }
1566
1567    // --- 기간(1W/1M/YTD) 기준 종가 수집 — 새로고침과 분리된 백그라운드, 하루 1회 캐시 ---
1568    // 기준가만 4개 숫자로 저장(일봉 원본은 저장 안 함) → localStorage 부담·dailyCache 축출 없음
1569    async function computePeriodBase(code) {
1570      const end = new Date();
1571      const start = new Date(end.getTime() - 400 * 86400000);
1572      let pairs = null; // [{ d: 'YYYYMMDD', c: close }]
1573      if (isKoreanCode(code)) {
1574        const url = `https://fchart.stock.naver.com/siseJson.naver?symbol=${code}&requestType=1&startTime=${yyyymmdd(start)}&endTime=${yyyymmdd(end)}&timeframe=day`;
1575        const response = await fetch(routed(url), { signal: AbortSignal.timeout(10000) });
1576        if (!response.ok) throw new Error('HTTP ' + response.status);
1577        pairs = JSON.parse((await response.text()).replace(/'/g, '"'))
1578          .filter(r => /^\d{8}$/.test(String(r[0])))
1579          .map(r => ({ d: String(r[0]), c: Number(r[4]) }))
1580          .filter(x => x.c > 0);
1581      } else {
1582        const candidates = [...new Set([state.usCodeCache[code], code + '.O', code].filter(Boolean))];
1583        for (const reuters of candidates) {
1584          try {
1585            const rows = await fetchJson(`https://api.stock.naver.com/chart/foreign/item/${encodeURIComponent(reuters)}/day?startDateTime=${yyyymmdd(start)}&endDateTime=${yyyymmdd(end)}`);
1586            if (Array.isArray(rows) && rows.length >= 2) {
1587              if (state.usCodeCache[code] !== reuters) {
1588                state.usCodeCache[code] = reuters;
1589                localStorage.setItem(usCodeCacheKey, JSON.stringify(state.usCodeCache));
1590              }
1591              pairs = rows.map(r => ({ d: String(r.localDate), c: Number(r.closePrice) })).filter(x => /^\d{8}$/.test(x.d) && x.c > 0);
1592              break;
1593            }
1594          } catch {}
1595        }
1596      }
1597      if (!pairs || pairs.length < 6) throw new Error('일봉 없음');
1598      const today = yyyymmdd(new Date());
1599      if (pairs[pairs.length - 1].d === today) pairs.pop(); // 진행 중인 오늘 봉 제외 → 전일 종가까지
1600      if (pairs.length < 6) throw new Error('일봉 부족');
1601      const closes = pairs.map(x => x.c);
1602      const n = closes.length;
1603      const yearStart = today.slice(0, 4) + '0101';
1604      let yBase = closes[0];
1605      for (let i = pairs.length - 1; i >= 0; i--) {
1606        if (pairs[i].d < yearStart) { yBase = pairs[i].c; break; }
1607      }
1608      return { w: closes[Math.max(0, n - 5)], mo: closes[Math.max(0, n - 21)], y: yBase, savedAt: Date.now() };
1609    }
1610
1611    // 현재 화면(탭·지수)의 기준가 수집 대상 코드
1612    function periodCodes() {
1613      const items = state.tab === 'etf' ? activeEtfs() : marketItems();
1614      return [...new Set(items.map(x =>
1614 String(x.c || '')).filter(Boolean))].slice(0, 250);
1615    }
1616
1617    let periodBaseRun = 0; // 화면 전환 시 이전 수집 루프 중단용
1618    async function ensurePeriodBases() {
1619      if (state.period === '1D') return;
1620      const codes = periodCodes();
1621      const stale = codes.filter(c => !(state.periodBase[c] && Date.now() - state.periodBase[c].savedAt < periodBaseTtlMs));
1622      if (!stale.length) return;
1623      const run = ++periodBaseRun;
1624      let done = 0;
1625      // 동시 4개 + 간격 — 새로고침 연결을 침범하지 않는 저속 백그라운드
1626      for (const group of chunkArray(stale, 4)) {
1627        if (run !== periodBaseRun || state.period === '1D') break;
1628        await Promise.allSettled(group.map(code => computePeriodBase(code)
1629          .then(b => { state.periodBase[code] = b; done++; })
1630          .catch(() => {})));
1631        await sleep(160);
1632        if (done && done % 32 === 0) renderMap(); // 점진 반영
1633      }
1634      if (done) {
1635        try { localStorage.setItem(periodBaseKey, JSON.stringify(state.periodBase)); } catch {}
1636        renderMap();
1637      }
1638    }
1639
1640    let flowScale = 300; // adaptive ±억원 color scale, recomputed per render
1641
1642    function activeMetric(item) {
1643      if (isFlowBasis()) return flowValue(item);
1644      if (state.colorBasis === '52w') return weeklyPosition(item);
1645      if (state.colorBasis === 'peak') return peakDrawdown(item);
1646      return item.chg;
1647    }
1648
1649    function colorForMetric(item, maxColor) {
1650      if (isFlowBasis()) {
1651        const v = flowValue(item);
1652        if (v == null) return colorFor(0, maxColor);
1653        return colorFor(v, flowScale);
1654      }
1655      if (state.colorBasis === '52w') {
1656        const pos = weeklyPosition(item);
1657        if (pos == null) return colorFor(0, maxColor);
1658        return colorFor((pos - 50) / 50 * maxColor, maxColor);
1659      }
1660      if (state.colorBasis === 'peak') {
1661        // 고점 부근(0%) = 빨강, -15% = 중립, 그 이하 = 파랑 (낙폭 클수록 진한 파랑)
1662        const dd = peakDrawdown(item);
1663        if (dd == null) return colorFor(0, maxColor);
1664        return colorFor(clamp((15 + dd) / 15, -3, 1) * maxColor, maxColor);
1665      }
1666      return colorFor(item.chg, maxColor);
1667    }
1668
1669    function metricText(item) {
1670      if (isFlowBasis()) return formatEok(flowValue(item));
1671      if (state.colorBasis === '52w') {
1672        const pos = weeklyPosition(item);
1673        return pos == null ? '-' : pos.toFixed(0) + '%';
1674      }
1675      if (state.colorBasis === 'peak') {
1676        const dd = peakDrawdown(item);
1677        return dd == null ? '-' : dd.toFixed(0) + '%';
1678      }
1679      return pct(item.chg);
1680    }
1681
1682    function metricTextColor(item) {
1683      if (isFlowBasis()) return changeTextColor(flowValue(item) || 0);
1684      const metric = activeMetric(item);
1685      if (state.colorBasis === '52w') return changeTextColor((metric ?? 50) - 50);
1686      if (state.colorBasis === 'peak') return changeTextColor((metric ?? -15) + 15);
1687      return changeTextColor(item.chg || 0);
1688    }
1689
1690    function pulseColor(item) {
1691      const value = item.inverse ? -item.d : item.d;
1692      return changeTextColor(value || 0, true);
1693    }
1694
1695    function updateSymbolReferences(symbol, price, change = null) {
1696      // etfDynamic 필수: ETF는 보통 동적 로드되어 activeEtfs()가 state.etfDynamic을 쓴다.
1697      // 여기서 누락하면 무료 시세 경로(applyFreeQuote)가 하드코딩 etfs만 갱신해 ETF 탭 시세가 안 돈다.
1698      const sources = [stocks, usStocks, nasdaqStocks, etfs, state.etfDynamic || [], window.kospi200Stocks || [], window.sp500Stocks || [], state.kosdaqStocks || []];
1699      sources.forEach(source => {
1700        source.forEach(item => {
1701          if (String(item.c || '') !== symbol) return;
1702          if (Number.isFinite(price)) item.p = price;
1703          if (Number.isFinite(change)) item.d = change; // 주간(w) 덮어쓰기 버그 제거 — 기간 수익률은 periodBase로 계산
1704        });
1705      });
1706    }
1707
1708    function pulseSparkPrices(item) {
1709      const cached = candlePrices(item.c, item.v);
1710      if (cached.length >= 2) return cached;
1711      const points = [];
1712      let seed = 0;
1713      String(item.c || item.n).split('').forEach(ch =>
1713 { seed = (seed * 33 + ch.charCodeAt(0)) >>> 0; });
1714      const end = Number(item.v || 100);
1715      const drift = Number(item.d || 0) / 100;
1716      for (let i = 0; i < 18; i++) {
1717        seed = (1664525 * seed + 1013904223) >>> 0;
1718        const noise = (seed / 4294967295 - 0.5) * 0.012;
1719        const t = i / 17;
1720        points.push(end * (1 - drift * (1 - t) + noise));
1721      }
1722      return points;
1723    }
1724
1725    // 5-min candlestick body for a pulse card; returns null when fewer than 2 candles exist
1726    function pulseCandleSvg(item) {
1727      const candles = (state.pulseCandles[item.c] || []).slice(-36);
1728      if (candles.length < 2) return null;
1729      const w = 110;
1730      const h = 28;
1731      let min = Infinity;
1732      let max = -Infinity;
1733      candles.forEach(cd => {
1734        min = Math.min(min, cd.l);
1735        max = Math.max(max, cd.h);
1736      });
1737      if (!(max > min)) max = min + (Math.abs(min) * 0.001 || 1);
1738      const y = v => h - ((v - min) / (max - min) * (h - 4) + 2);
1739      const root = getComputedStyle(document.documentElement);
1740      const up = state.colorblind ? root.getPropertyValue('--orange').trim() : root.getPropertyValue('--us-up').trim();
1741      const down = state.colorblind ? root.getPropertyValue('--cyan').trim() : root.getPropertyValue('--us-down').trim();
1742      const cw = w / candles.length;
1743      const bodyW = Math.max(1.4, cw * 0.62);
1744      return candles.map((cd, i) => {
1745        const x = i * cw + cw / 2;
1746        const color = cd.c >= cd.o ? up : down;
1747        const top = y(Math.max(cd.o, cd.c));
1748        const height = Math.max(0.8, y(Math.min(cd.o, cd.c)) - top);
1749        return `<line x1="${x.toFixed(1)}" y1="${y(cd.h).toFixed(1)}" x2="${x.toFixed(1)}" y2="${y(cd.l).toFixed(1)}" stroke="${color}" stroke-width="0.9"/>`
1750          + `<rect x="${(x - bodyW / 2).toFixed(1)}" y="${top.toFixed(1)}" width="${bodyW.toFixed(1)}" height="${height.toFixed(1)}" fill="${color}"/>`;
1751      }).join('');
1752    }
1753
1754    // --- 지수 스트립: 4가지 표시 스타일 (티커/카드/히어로/컬럼) ---
1755    const PULSE_GROUPS = [
1756      { key: 'kr', label: 'KR', codes: ['KOSPI', 'KOSDAQ', 'FX_USDKRW'] },
1757      { key: 'us', label: 'US', codes: ['.DJI', '.INX', '.IXIC'] },
1758      { key: 'risk', label: 'RISK', codes: ['.VIX', '.SOX'] },
1759      { key: 'rates', label: 'RATES', codes: ['US10YT=RR', 'US2YT=RR'] },
1760      { key: 'macro', label: 'MACRO', codes: ['GCcv1', 'SIcv1', 'CLcv1', '.DXY'] },
1761      { key: 'crypto', label: 'CRYPTO', codes: ['BTCUSDT', 'ETHUSDT'] }
1762    ];
1763    const PULSE_STYLE_HEIGHT = { ticker: 40, cards: 118, hero: 96, columns: 96 };
1764    const PULSE_STYLE_RANGE = { ticker: [32, 68], cards: [86, 300], hero: [64, 280], columns: [64, 280], side: [64, 300] };
1765
1766    function currentPulseStyle() {
1767      return state.sideMode || window.innerWidth <= 900 ? 'ticker' : state.pulseStyle;
1768    }
1769
1770    // 저장·조절 키: 사이드 패널은 'side'로 별도 관리(폭이 좁아 풀 뷰 티커와 독립)
1771    function pulseHeightKey() {
1772      return state.sideMode ? 'side' : currentPulseStyle();
1773    }
1774
1775    function pulseChangeText(item) {
1776      return (item.d > 0 ? '+' : '') + Number(item.d || 0).toFixed(Math.abs(item.d) < 1 ? 2 : 1) + '%';
1777    }
1778
1779    function pulseChartSvg(item, h = 18) {
1780      const candleBody = item.cs ? pulseCandleSvg(item) : null;
1781      if (candleBody) return `<svg viewBox="0 0 110 28" preserveAspectRatio="none" style="width:100%;height:${h}px;display:block">${candleBody}</svg>`;
1782      const path = pricesToPath(pulseSparkPrices(item), 110, 28);
1783      return `<svg viewBox="0 0 110 28" preserveAspectRatio="none" style="width:100%;height:${h}px;display:block"><path d="${path}" fill="none" stroke="${pulseColor(item)}" stroke-width="1.8" opacity="0.85"/></svg>`;
1784    }
1785
1786    function pulseHasCandle(item) {
1787      return !!(item.cs && (state.pulseCandles[item.c] || []).length >= 2);
1788    }
1789
1790    function pulseQuoteCard(item) {
1791      const stroke = pulseColor(item);
1792      return `<div class="ps-card">
1793        <span class="ps-card-name"><span class="ps-g">${(PULSE_GROUPS.find(g => g.codes.includes(item.c)) || {}).label || ''}</span>${item.n}${pulseHasCandle(item) ? '<span class="ps-badge">5분봉</span>' : ''}</span>
1794        <span class="ps-card-val tnum"><strong>${compactValue(item.v, item.unit)}</strong><em style="color:${stroke}">${pulseChangeText(item)}</em></span>
1795        ${pulseChartSvg(item, 16)}
1796      </div>`;
1797    }
1798
1799    // 온도 미니 (카드/히어로/컬럼용): 점수 + 라벨 + 미니 밴드
1800    function thermoMiniHtml(cls) {
1801      const t = state.thermo;
1802      const score = t?.score;
1803      const color = t ? t.color : 'var(--muted)';
1804      const label = t ? t.label : '수집 중';
1805      const deltaTxt = t?.delta == null ? '' : (t.delta >= 0 ? '▲' : '▼') + Math.abs(t.delta);
1806      const grad = state.colorblind
1807        ? 'linear-gradient(90deg,#0e8fa6,var(--cyan) 26%,var(--neutral) 50%,var(--orange) 74%,#e0662a)'
1808        : 'linear-gradient(90deg,var(--down-2),var(--down) 26%,var(--neutral) 50%,var(--up) 74%,var(--up-2))';
1809      return `<button class="${cls}" id="thermoChip" type="button" data-tip="시장 지표를 합성한 종합 온도 — 클릭하면 시그널 패널이 열립니다" style="border-color:rgba(49,130,246,0.3)">
1810        <span class="ps-card-name">시장 온도</span>
1811        <span class="ps-card-val tnum"><strong style="color:${color};font-size:20px">${score ?? '—'}</strong><em style="color:${color}">${label} ${deltaTxt}</em></span>
1812        <span class="ps-thermo-band" style="background:${grad}"><i style="left:${clamp(score ?? 50, 2, 98)}%"></i></span>
1813      </button>`;
1814    }
1815
1816    function renderMarketPulse() {
1817      const el = $('marketPulse');
1818      if (!el) return;
1819      $('app').classList.toggle('no-pulse', !state.pulseVisible);
1820      if (!state.pulseVisible) {
1821        el.innerHTML = '';
1822        return;
1823      }
1824      computeThermo();
1825      const byCode = new Map(marketPulse.map(item => [item.c, item]));
1826      const pick = codes => codes.map(c => byCode.get(c)).filter(Boolean);
1827      const style = currentPulseStyle();
1828      applyPulseScale();
1829      el.dataset.style = style;
1830      const t = state.thermo;
1831
1832      // 사이드 패널 전용: 온도 헤더 + 2열 지표 그리드 (리사이저로 높이를 키우면 더 많은 행 노출)
1833      if (state.sideMode) {
1834        const grad = state.colorblind
1835          ? 'linear-gradient(90deg,#0e8fa6,var(--cyan) 26%,var(--neutral) 50%,var(--orange) 74%,#e0662a)'
1836          : 'linear-gradient(90deg,var(--down-2),var(--down) 26%,var(--neutral) 50%,var(--up) 74%,var(--up-2))';
1837        const bandColor = t ? t.color : 'var(--muted)';
1838        const cells = PULSE_GROUPS.flatMap(g => pick(g.codes)).map(item =>
1839          `<div class="pside-cell"><span class="pside-name">${item.n}</span><span class="pside-val tnum">${compactValue(item.v, item.unit)}</span><span class="pside-chg tnum" style="color:${pulseColor(item)}">${pulseChangeText(item)}</span></div>`
1840        ).join('');
1841        el.innerHTML = `<div class="pulse-side">
1842          <button class="pside-thermo" id="thermoChip" type="button" data-tip="시장 온도 — 클릭하면 시그널 패널">
1843            <span class="pside-th-ico">🌡</span>
1844            <strong style="color:${bandColor}">${t?.score ?? '—'}</strong>
1845            <em style="color:${bandColor}">${t ? t.label : '수집 중'}${t?.delta != null ? ` ${t.delta >= 0 ? '▲' : '▼'}${Math.abs(t.delta)}` : ''}</em>
1846            <span class="pside-band" style="background:${grad}"><i style="left:${clamp(t?.score ?? 50, 2, 98)}%"></i></span>
1847          </button>
1848          <div class="pside-grid tnum">${cells}</div>
1849        </div>`;
1850        return;
1851      }
1852
1853      if (style === 'ticker') {
1854        const thermoTxt = t
1855          ? `<span style="color:${t.color};font-weight:800;font-size:14px">${t.score}</span><span style="color:${t.color};font-weight:700">${t.label}</span>${t.delta != null ? `<span style="color:${t.color};font-weight:700;font-size:11px">${t.delta >= 0 ? '▲' : '▼'}${Math.abs(t.delta)}</span>` : ''}`
1856          : '<span style="color:var(--muted)">온도 수집 중</span>';
1857        const groupsHtml = PULSE_GROUPS.map(g => {
1858          const items = pick(g.codes).map(item => `<span class="pt-q"><span class="pt-name">${item.n}</span><span class="pt-val tnum">${compactValue(item.v, item.unit)}</span><span class="pt-chg tnum" style="color:${pulseColor(item)}">${pulseChangeText(item)}</span></span>`).join('');
1859          return `<span class="pt-group"><span class="pt-g">${g.label}</span>${items}</span>`;
1860        }).join('');
1861        el.innerHTML = `<div class="pulse-ticker tnum">
1862          <button class="pt-thermo" id="thermoChip" type="button" data-tip="시장 온도 — 클릭하면 시그널 패널">🌡 ${thermoTxt}</button>
1863          ${groupsHtml}
1864        </div>`;
1865        return;
1866      }
1867
1868      if (style === 'cards') {
1869        const tabs = [['main', '주요'], ['kr', '한국'], ['us', '미국'], ['risk', '금리·리스크'], ['macro', '매크로'], ['crypto', '크립토']];
1870        // 앞 6개가 첫 행 — 높이를 키우면(리사이저) 이후 항목이 다음 행으로 노출된다
1871        const tabCards = {
1872          main: pick(['KOSPI', 'KOSDAQ', 'FX_USDKRW', '.IXIC', 'US10YT=RR', '.VIX', '.INX', '.DJI', '.SOX', 'GCcv1', 'BTCUSDT', 'US2YT=RR']),
1873          kr: pick(PULSE_GROUPS[0].codes),
1874          us: pick(PULSE_GROUPS[1].codes),
1875          risk: pick([...PULSE_GROUPS[2].codes, ...PULSE_GROUPS[3].codes]),
1876          macro: pick(PULSE_GROUPS[4].codes),
1877          crypto: pick(PULSE_GROUPS[5].codes)
1878        }[state.pulseTab] || [];
1879        el.innerHTML = `<div class="pulse-cards">
1880          <div class="pc-tabs">${tabs.map(([k, label]) => `<button class="pc-tab ${state.pulseTab === k ? 'on' : ''}" data-pulse-tab="${k}" type="button">${label}</button>`).join('')}</div>
1881          <div class="pc-grid">${thermoMiniHtml('ps-card ps-thermo-card')}${tabCards.map(pulseQuoteCard).join('')}</div>
1882        </div>`;
1883        return;
1884      }
1885
1886      if (style === 'hero') {
1887        const heroItems = pick(['KOSPI', 'FX_USDKRW']);
1888        // 전체 렌더 — 기본 높이에선 3행까지 보이고, 리사이저로 키우면 나머지 행이 노출된다
1889        const dense = marketPulse.filter(item => !['KOSPI', 'FX_USDKRW'].includes(item.c));
1890        el.innerHTML = `<div class="pulse-hero">
1891          <div class="ph-cards">${thermoMiniHtml('ps-card ps-thermo-card')}${heroItems.map(pulseQuoteCard).join('')}</div>
1892          <div class="ph-grid tnum">${dense.map(item => `
1893            <div class="ph-row">
1894              <span class="pt-g" style="width:38px;flex:none">${(PULSE_GROUPS.find(g => g.codes.includes(item.c)) || {}).label || ''}</span>
1895              <span class="ph-name">${item.n}</span>
1896              <span class="ph-val">${compactValue(item.v, item.unit)}</span>
1897              <span class="ph-chg" style="color:${pulseColor(item)}">${pulseChangeText(item)}</span>
1898            </div>`).join('')}</div>
1899        </div>`;
1900        return;
1901      }
1902
1903      // columns
1904      // 기본 높이에선 각 컬럼 2행까지 보이고, 리사이저로 키우면 나머지 행이 노출된다
1905      const colDefs = [
1906        ['KOREA', pick(['KOSPI', 'KOSDAQ', 'FX_USDKRW'])],
1907        ['US', pick(['.IXIC', '.INX', '.DJI'])],
1908        ['RISK', pick(PULSE_GROUPS[2].codes)],
1909        ['RATES', pick(PULSE_GROUPS[3].codes)],
1910        ['MACRO', pick(PULSE_GROUPS[4].codes)],
1911        ['CRYPTO', pick(PULSE_GROUPS[5].codes)]
1912      ];
1913      const thermoCol = `<button class="pcol-thermo" id="thermoChip" type="button" data-tip="시장 온도 — 클릭하면 시그널 패널">
1914        <span class="pt-g">TEMP</span>
1915        <span class="tnum" style="display:flex;align-items:baseline;gap:6px"><strong style="font-size:24px;font-weight:800;color:${t ? t.color : 'var(--muted)'}">${t?.score ?? '—'}</strong><span style="font-size:12px;font-weight:700;color:${t ? t.color : 'var(--muted)'}">${t ? t.label : '수집 중'} ${t?.delta != null ? (t.delta >= 0 ? '▲' : '▼') + Math.abs(t.delta) : ''}</span></span>
1916        <span class="ps-thermo-band" style="background:${state.colorblind ? 'linear-gradient(90deg,#0e8fa6,var(--cyan) 26%,var(--neutral) 50%,var(--orange) 74%,#e0662a)' : 'linear-gradient(90deg,var(--down-2),var(--down) 26%,var(--neutral) 50%,var(--up) 74%,var(--up-2))'}"><i style="left:${clamp(t?.score ?? 50, 2, 98)}%"></i></span>
1917      </button>`;
1918      el.innerHTML = `<div class="pulse-columns">${thermoCol}${colDefs.map(([label, items]) => `
1919        <div class="pcol">
1920          <span class="pt-g">${label}</span>
1921          ${items.map(item => `<div class="pcol-row tnum">
1922            <span class="pt-name" style="flex:none">${item.n}</span>
1923            <span class="pcol-spark">${pulseChartSvg(item, 13)}</span>
1924            <span style="flex:1"></span>
1925            <span class="pcol-val">${compactValue(item.v, item.unit)}</span>
1926            <span class="pcol-chg" style="color:${pulseColor(item)}">${pulseChangeText(item)}</span>
1927          </div>`).join('')}
1928        </div>`).join('')}</div>`;
1929    }
1930
1931    function squarify(items, x, y, w, h) {
1932      const total = items.reduce((s, it) => s + it.value, 0) || 1;
1933      const scaled = items.map(it => ({ ...it, area: it.value / total * w * h }));
1934      const rects = [];
1935      let row = [];
1936      let xx = x, yy = y, ww = w, hh = h;
1937
1938      function worst(row, side) {
1939        if (!row.length) return Infinity;
1940        const sum = row.reduce((s, it) => s + it.area, 0);
1941        const max = Math.max(...row.map(it => it.area));
1942        const min = Math.min(...row.map(it => it.area));
1943        return Math.max((side * side * max) / (sum * sum), (sum * sum) / (side * side * min));
1944      }
1945
1946      function layout(row) {
1947        const sum = row.reduce((s, it) => s + it.area, 0);
1948        if (ww >= hh) {
1949          const rowW = sum / hh;
1950          let cy = yy;
1951          row.forEach(it => {
1952            const rh = it.area / rowW;
1953            rects.push({ x: xx, y: cy, w: rowW, h: rh, item: it });
1954            cy += rh;
1955          });
1956          xx += rowW;
1957          ww -= rowW;
1958        } else {
1959          const rowH = sum / ww;
1960          let cx = xx;
1961          row.forEach(it => {
1962            const rw = it.area / rowH;
1963            rects.push({ x: cx, y: yy, w: rw, h: rowH, item: it });
1964            cx += rw;
1965          });
1966          yy += rowH;
1967          hh -= rowH;
1968        }
1969      }
1970
1971      scaled.forEach(it => {
1972        const side = Math.min(ww, hh);
1973        if (!row.length || worst(row.concat(it), side) <= worst(row, side)) row.push(it);
1974        else {
1975          layout(row);
1976          row = [it];
1977        }
1978      });
1979      if (row.length) layout(row);
1980      return rects;
1981    }
1982
1983    function shouldGroupMarketBySector() {
1984      if (state.tab !== 'market' || state.viewMode !== 'heatmap' || !isFullTabView()) return false;
1985      if (state.index === 'KOSDAQ150') return !!state.industryMap;
1986      return ['KOSPI200', 'S&P500', 'NASDAQ100'].includes(state.index);
1987    }
1988
1989    function marketSectorRects(tiles, W, H) {
1990      const bySector = new Map();
1991      tiles.forEach(item => {
1992        const name = item.sec || '기타';
1993        if (!bySector.has(name)) bySector.set(name, []);
1994        bySector.get(name).push(item);
1995      });
1996
1997      const groups = Array.from(bySector, ([sec, items]) => ({
1998        sec,
1999        items: items.slice().sort((a, b) => b.value - a.value),
2000        value: items.reduce((sum, item) => sum + (item.value || 1), 0)
2001      })).sort((a, b) => b.value - a.value);
2002
2003      const sectorRects = squarify(groups, 0, 0, W, H);
2004      const rects = [];
2005      const labels = [];
2006
2007      const regions = [];
2008      sectorRects.forEach(sr => {
2009        const labelH = Math.min(22, Math.max(16, sr.h * 0.16));
2010        const pad = sr.w > 180 && sr.h > 120 ? 7 : 4;
2011        labels.push({
2012          name: sr.item.sec,
2013          x: sr.x + pad,
2014          y: sr.y + 4,
2015          w: sr.w - pad * 2,
2016          h: sr.h // 보드 pane 라벨 크기 게이팅용 (시장 탭은 무시)
2017        });
2018        regions.push({ x: sr.x, y: sr.y, w: sr.w, h: sr.h, sec: sr.item.sec }); // 보드 pane 섹터 구분선용
2019
2020        const innerX = sr.x + pad;
2021        const innerY = sr.y + labelH;
2022        const innerW = Math.max(8, sr.w - pad * 2);
2023        const innerH = Math.max(8, sr.h - labelH - pad);
2024        rects.push(...squarify(sr.item.items, innerX, innerY, innerW, innerH));
2025      });
2026
2027      return { rects, labels, regions };
2028    }
2029
2030    function pricesToPath(prices, w = 120, h = 24) {
2031      const vals = prices.map(Number).filter(v => Number.isFinite(v) && v > 0);
2032      if (vals.length < 2) return null;
2033      const min = Math.min(...vals);
2034      const max = Math.max(...vals);
2035      const range = max - min || max * 0.01 || 1;
2036      const pts = vals.map((v, i) => {
2037        const x = vals.length === 1 ? 0 : i / (vals.length - 1) * w;
2038        const y = h - ((v - min) / range * (h - 4) + 2);
2039        return [x, y];
2040      });
2041      return 'M ' + pts.map(p => p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' L ');
2042    }
2043
2044    function candlePrices(symbol, currentPrice = 0) {
2045      const key = String(symbol || '');
2046      // 토스 캔들(연동 시) → 무료 일봉 캐시 순서로 사용
2047      const cached = state.candleCache[key]?.closes || state.dailyCache[key]?.closes || [];
2048      const prices = cached.map(Number).filter(v => Number.isFinite(v) && v > 0);
2049      const live = Number(currentPrice || 0);
2050      if (prices.length && live > 0 && Math.abs(prices[prices.length - 1] - live) / live > 0.001) {
2051        prices.push(live);
2052      }
2053      return prices;
2054    }
2055
2056    function sparkModelForItem(item, w, h) {
2057      const prices = candlePrices(item.c || item.n, item.p);
2058      return {
2059        path: pricesToPath(prices, w, h),
2060        stroke: prices.length >= 2 ? changeTextColor((prices[prices.length - 1] - prices[0]) / prices[0] * 100) : 'rgba(255,255,255,0.7)'
2061      };
2062    }
2063
2064    function getTiles() {
2065      if (state.tab === 'market') {
2066        return marketItems().slice().sort((a, b) => b.m - a.m).map(x => ({ ...x, kind: 'stock', value: x.heatM || x.m, chg: periodValue(x) }));
2067      }
2068      if (state.tab === 'etf') {
2069        if (state.drill) {
2070          const etf = activeEtfs().find(e => e.n === state.drill);
2071          return (etf?.cons || []).map(([name, weight]) => {
2072            const ref = lookup(name);
2073            return { ...(ref || { n: name, c: '', s: '', p: 0, m: weight, d: 0, w: 0, mo: 0, y: 0 }), kind: name === '기타' ? 'other' : 'cons', value: weight, weight, chg: ref ? periodValue(ref) : null };
2074          });
2075        }
2076        if (state.etfCat) {
2077          return activeEtfs().filter(e => etfGroupKeyOf(e) === state.etfCat).slice().sort((a, b) => b.m - a.m)
2078            .map(x => ({ ...x, sec: etfGroupKeyOf(x), kind: 'etf', value: x.m, chg: periodValue(x) }));
2079        }
2080        // Level 1: rollup heatmap (카테고리 또는 운용사) — tile size = AUM sum, color = AUM-weighted return
2081        const byCat = new Map();
2082        activeEtfs().forEach(e => {
2083          const key = etfGroupKeyOf(e);
2084          const group = byCat.get(key) || { sum: 0, weighted: 0, count: 0 };
2085          const chg = periodValue(e) || 0;
2086          group.sum += e.m;
2087          group.weighted += e.m * chg;
2088          group.count++;
2089          byCat.set(key, group);
2090        });
2091        return Array.from(byCat, ([cat, g]) => ({
2092          n: cat,
2093          s: cat,
2094          c: '',
2095          cat,
2096          kind: 'etfcat',
2097          m: g.sum,
2098          count: g.count,
2099          value: g.sum,
2100          chg: g.sum ? g.weighted / g.sum : 0
2101        })).sort((a, b) => b.value - a.value);
2102      }
2103      if (state.pfEmpty) return [];
2104      return portfolio().items.map(h => ({
2105        ...h.ref,
2106        kind: 'holding',
2107        value: h.value,
2108        holding: h,
2109        chg: state.pfBasis === 'day' ? h.day : h.totalReturn
2110      }));
2111    }
2112
2113    function portfolio() {
2114      if (state.remoteHoldings) {
2115        const items = state.remoteHoldings.map(h => {
2116          const price = Number(h.lastPrice || 0);
2117          const qty = Number(h.quantity || 0);
2118          const avg = Number(h.averagePurchasePrice || 0);
2119          const value = qty * price;
2120          const purchase = qty * avg;
2121          const day = Number(h.dailyProfitLoss?.rate || 0) * 100;
2122          const totalReturn = Number(h.profitLoss?.rate || 0) * 100;
2123          return {
2124            n: h.name,
2125            qty,
2126            avg,
2127            ref: {
2128              n: h.name,
2129              c: h.symbol,
2130              s: h.name.length > 4 ? h.name.slice(0, 4) : h.name,
2131              p: price,
2132              currency: h.currency,
2133              marketCountry: h.marketCountry,
2134              d: day,
2135              w: day,
2136              mo: totalReturn,
2137              y: totalReturn,
2138              m: Math.max(1, value / 1000000000000)
2139            },
2140            value,
2141            cost: purchase,
2142            day,
2143            dayPnl: Number(h.dailyProfitLoss?.amount || 0),
2144            totalReturn,
2145            currency: h.currency,
2146            marketCountry: h.marketCountry
2147          };
2148        });
2149        const total = items.reduce((s, h) => s + h.value, 0);
2150        const cost = items.reduce((s, h) => s + h.cost, 0);
2151        const dayPnl = items.reduce((s, h) => s + h.dayPnl, 0);
2152        return { items, total, cost, dayPnl, totalPnl: total - cost };
2153      }
2154      // 사용자가 직접 입력한 보유 종목 (미설정 시 데모 데이터)
2155      const source = state.myHoldings !== null
2156        ? state.myHoldings.map(h => {
2157            const ref = lookup(h.c);
2158            return { n: ref?.n || h.n || h.c, c: h.c, qty: Number(h.qty), avg: Number(h.avg) || 0, ref };
2159          })
2160        : holdings.map(h => ({ ...h, ref: lookup(h.n) }));
2161      const items = source.map(h => {
2162        const ref = h.ref || { n: h.n, c: h.c || '', s: h.n, p: state.priceOverrides[h.c] || h.avg || 0, d: state.prevCloseCache[h.c]?.lastChange || 0, w: 0, mo: 0, y: 0, m: 1 };
2163        const price = Number(ref.p) || Number(h.avg) || 0;
2164        const value = h.qty * price;
2165        const cost = h.qty * (Number(h.avg) || price);
2166        return {
2167          ...h,
2168          ref,
2169          value,
2170          cost,
2171          day: Number(ref.d) || 0,
2172          dayPnl: value * (Number(ref.d) || 0) / 100,
2173          totalReturn: h.avg > 0 ? (price - h.avg) / h.avg * 100 : 0
2174        };
2175      });
2176      const total = items.reduce((s, h) => s + h.value, 0);
2177      const cost = items.reduce((s, h) => s + h.cost, 0);
2178      const dayPnl = items.reduce((s, h) => s + h.dayPnl, 0);
2179      return { items, total, cost, dayPnl, totalPnl: total - cost };
2180    }
2181
2182    function exposures() {
2183      const pf = portfolio();
2184      const out = {};
2185      pf.items.forEach(h => {
2186        const weight = h.value / pf.total * 100;
2187        const etf = activeEtfs().find(e => e.n === h.n);
2188        if (etf?.cons?.length) {
2189          etf.cons.forEach(([name, pct]) => {
2190            if (name === '기타') return;
2191            out[name] ||= { direct: 0, parts: [] };
2192            out[name].parts.push([etf.n, weight * pct / 100]);
2193          });
2194        } else {
2195          out[h.n] ||= { direct: 0, parts: [] };
2196          out[h.n].direct += weight;
2197        }
2198      });
2199      return Object.entries(out).map(([name, e]) => {
2200        const etfSum = e.parts.reduce((s, p) => s + p[1], 0);
2201        return { name, direct: e.direct, etfSum, total: e.direct + etfSum, parts: e.parts };
2202      }).sort((a, b) => b.total - a.total);
2203    }
2204
2205    // --- Market thermometer + signals panel (design: HeatTab Signals) ---
2206
2207    const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
2208
2209    function pulseItem(code) {
2210      return marketPulse.find(item => item.c === code);
2211    }
2212
2213    function tempColor(score) {
2214      if (state.colorblind) {
2215        if (score < 40) return 'var(--cyan)';
2216        if (score < 60) return 'var(--muted)';
2217        return 'var(--orange)';
2218      }
2219      if (score < 20) return 'var(--down-2)';
2220      if (score < 40) return 'var(--down)';
2221      if (score < 60) return 'var(--muted)';
2222      if (score < 80) return 'var(--up)';
2223      return 'var(--up-2)';
2224    }
2225
2226    function tempLabel(score) {
2227      if (score < 20) return '극단공포';
2228      if (score < 40) return '공포';
2229      if (score < 60) return '중립';
2230      if (score < 80) return '과열';
2231      return '극단과열';
2232    }
2233
2234    function todayKey() {
2235      const d = new Date();
2236      return d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate();
2237    }
2238
2239
2240
2241
2242
2243    // 다음 선물·옵션 만기일 (매월 둘째 목요일 · 3/6/9/12월은 선물 동시만기 = 네 마녀)
2244    function nextExpiry() {
2245      const today = new Date();
2246      today.setHours(0, 0, 0, 0);
2247      for (let k = 0; k < 3; k++) {
2248        const base = new Date(today.getFullYear(), today.getMonth() + k, 1);
2249        const offset = (4 - base.getDay() + 7) % 7; // 첫 목요일까지
2250        const secondThu = new Date(base.getFullYear(), base.getMonth(), 1 + offset + 7);
2251        if (secondThu >= today) {
2252          const quad = [2, 5, 8, 11].includes(secondThu.getMonth());
2253          return {
2254            date: secondThu,
2255            dday: Math.round((secondThu - today) / 86400000),
2256            quad,
2257            label: `${secondThu.getMonth() + 1}/${secondThu.getDate()} ${quad ? '선물·옵션 동시만기' : '옵션 만기'}`
2258          };
2259        }
2260      }
2261      return null;
2262    }
2263
2264    // 화면 지수에 따라 온도계 시장 결정: S&P500/나스닥 → 미장, 그 외 → 국장
2265    function thermoMarket() {
2266      return viewUsesUsMarket() ? 'us' : 'kr';
2267    }
2268
2269    function thermoHist(market) {
2270      return (market || state.thermo?.market || thermoMarket()) === 'us' ? state.thermoHistoryUs : state.thermoHistory;
2271    }
2272
2273
2274
2275    // 시장 온도 — 서버 계산 결과(state.thermoServer)를 소비만 한다.
2276    // 공식·가중치·시그널 규칙은 서버(heattab.app)에 있으며, 클라이언트에는 ê²°ê
2276³¼ JSON만 존재한다.
2277    const thermoServerKey = 'heattab.thermoserver.v1';
2278    const thermoStaleMs = 30 * 60 * 1000;
2279
2280    async function fetchThermoFromServer(force = false) {
2281      const market = thermoMarket();
2282      const cached = state.thermoServer[market];
2283      const wantPro = Boolean(state.license?.token);
2284      if (!force && cached && Date.now() - (cached.fetchedAt || 0) < 60000 && (!wantPro || cached.inds)) return;
2285      if (fetchThermoFromServer.busy) return;
2286      fetchThermoFromServer.busy = true;
2287      try {
2288        let data = null;
2289        if (wantPro) {
2290          const res = await fetch(LICENSE_SERVER + '/api/market/thermo', {
2291            method: 'POST',
2292            headers: { 'Content-Type': 'application/json' },
2293            body: JSON.stringify({ market, token: state.license.token }),
2294            signal: AbortSignal.timeout(12000)
2295          });
2296          if (res.ok) data = await res.json();
2297        }
2298        if (!data) {
2299          const res = await fetch(LICENSE_SERVER + '/api/market/thermo?market=' + market, { signal: AbortSignal.timeout(12000) });
2300          if (res.ok) data = await res.json();
2301        }
2302        if (data && Number.isFinite(data.score)) {
2303          state.thermoServer[market] = { ...data, fetchedAt: Date.now() };
2304          try { localStorage.setItem(thermoServerKey, JSON.stringify(state.thermoServer)); } catch {}
2305          renderMarketPulse();
2306          if (state.signalsOpen) renderSignals();
2307        }
2308      } catch {} finally { fetchThermoFromServer.busy = false; }
2309    }
2310
2311    // 시장 전체 투자자별 순매수 — 서버(heattab.app)에서 파싱한 결과만 소비.
2312    // 무료 GET: 최신 거래일. Pro POST(token): 20거래일 시리즈. 결과는 state.investorFlow에 캐시.
2313    async function fetchInvestorsFromServer(force = false) {
2314      const cached = state.investorFlow;
2315      const wantPro = Boolean(state.license?.token);
2316      if (!force && cached && !cached.error && Date.now() - (cached.fetchedAt || 0) < 10 * 60 * 1000 && (!wantPro || cached.pro)) return;
2317      if (fetchInvestorsFromServer.busy) return;
2318      fetchInvestorsFromServer.busy = true;
2319      try {
2320        let data = null, pro = false;
2321        if (wantPro) {
2322          const res = await fetch(LICENSE_SERVER + '/api/market/investors', {
2323            method: 'POST',
2324            headers: { 'Content-Type': 'application/json' },
2325            body: JSON.stringify({ token: state.license.token }),
2326            signal: AbortSignal.timeout(12000)
2327          });
2328          if (res.ok) { data = await res.json(); pro = true; }
2329        }
2330        if (!data) {
2331          const res = await fetch(LICENSE_SERVER + '/api/market/investors', { signal: AbortSignal.timeout(12000) });
2332          if (res.ok) data = await res.json();
2333        }
2334        if (data && (data.kospi || data.kosdaq)) {
2335          const norm = s => Array.isArray(s) ? s : (s ? [s] : []); // 무료는 단일 객체, Pro는 배열
2336          state.investorFlow = { asof: data.asof || '', kospi: norm(data.kospi), kosdaq: norm(data.kosdaq), pro, fetchedAt: Date.now() };
2337          if (state.signalsOpen) renderSignals();
2338        } else throw new Error('empty');
2339      } catch {
2340        if (!state.investorFlow || state.investorFlow.error) {
2341          state.investorFlow = { error: true, fetchedAt: Date.now() };
2342          if (state.signalsOpen) renderSignals();
2343        }
2344      } finally { fetchInvestorsFromServer.busy = false; }
2345    }
2346
2347    // 섹터 수급 로테이션 — 서버 sf:agg 소비. 무료 GET: 상위 3 섹터. Pro POST(token): 전체 순위.
2348    async function fetchSectorFlowFromServer(force = false) {
2349      const cached = state.sectorFlow;
2350      const wantPro = Boolean(state.license?.token);
2351      if (!force && cached && !cached.error && Date.now() - (cached.fetchedAt || 0) < 10 * 60 * 1000 && (!wantPro || cached.pro)) return;
2352      if (fetchSectorFlowFromServer.busy) return;
2353      fetchSectorFlowFromServer.busy = true;
2354      try {
2355        let data = null, pro = false;
2356        if (wantPro) {
2357          const res = await fetch(LICENSE_SERVER + '/api/market/sectorflow', {
2358            method: 'POST',
2359            headers: { 'Content-Type': 'application/json' },
2360            body: JSON.stringify({ token: state.license.token }),
2361            signal: AbortSignal.timeout(12000)
2362          });
2363          if (res.ok) { data = await res.json(); pro = true; }
2364        }
2365        if (!data) {
2366          const res = await fetch(LICENSE_SERVER + '/api/market/sectorflow', { signal: AbortSignal.timeout(12000) });
2367          if (res.ok) data = await res.json();
2368        }
2369        if (data && (data.sectors || data.top)) {
2370          state.sectorFlow = { asof: data.asof || '', sectors: data.sectors || null, top: data.top || null, series: data.series || null, pro, fetchedAt: Date.now() };
2371          if (state.signalsOpen) renderSignals();
2372        } else throw new Error('empty');
2373      } catch {
2374        if (!state.sectorFlow || state.sectorFlow.error) {
2375          state.sectorFlow = { error: true, fetchedAt: Date.now() };
2376          if (state.signalsOpen) renderSignals();
2377        }
2378      } finally { fetchSectorFlowFromServer.busy = false; }
2379    }
2380
2381    // 시장 시그널 패널의 "투자자별 수급" 섹션 HTML. 무료: 최신일 3주체. Pro: 20일 스파크라인.
2382    function renderInvestorSection() {
2383      const fl = state.investorFlow;
2384      const upC = state.colorblind ? 'var(--orange)' : 'var(--up)';
2385      const downC = state.colorblind ? 'var(--cyan)' : 'var(--down)';
2386      const unlocked = can('investorFlow');
2387      const fmtEok = v => (v >
2387 0 ? '+' : '') + Number(v).toLocaleString('ko-KR');
2388      const fmtDate = d => /^\d{8}$/.test(String(d)) ? `${String(d).slice(4, 6)}.${String(d).slice(6, 8)}` : '';
2389
2390      const marketRow = (name, series) => {
2391        const latest = series && series[0];
2392        const cell = (label, v) => `<div class="inv-cell"><div class="inv-k">${label}</div><div class="inv-v" style="color:${(v || 0) >= 0 ? upC : downC}">${v == null ? '-' : fmtEok(v)}</div></div>`;
2393        return `<div class="inv-row">
2394          <div class="inv-mkt">${name}${latest ? `<small>${fmtDate(latest.date)}</small>` : ''}</div>
2395          ${cell('개인', latest?.indiv)}${cell('외국인', latest?.frgn)}${cell('기관', latest?.inst)}
2396        </div>`;
2397      };
2398
2399      // maxAbs is passed in (shared per market) so bar heights are comparable across parties.
2400      // dates (oldest→newest, aligned with vals) drive native <title> hover tooltips.
2401      const spark = (vals, maxAbs, dates) => {
2402        if (!vals || vals.length < 2) return '';
2403        const w = 128, h = 34, n = vals.length, bw = w / n, mid = h / 2;
2404        const scale = Math.max(1, maxAbs);
2405        const bars = vals.map((v, i) => {
2406          const bh = Math.max(1, Math.abs(v) / scale * (mid - 1));
2407          const title = dates ? `<title>${fmtDate(dates[i])} ${fmtEok(v)}</title>` : '';
2408          return `<rect x="${(i * bw + 0.4).toFixed(1)}" y="${(v >= 0 ? mid - bh : mid).toFixed(1)}" width="${Math.max(1, bw - 0.8).toFixed(1)}" height="${bh.toFixed(1)}" fill="${v >= 0 ? upC : downC}" opacity="0.85">${title}</rect>`;
2409        }).join('');
2410        return `<svg class="inv-spark" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none"><line x1="0" y1="${mid}" x2="${w}" y2="${mid}" stroke="rgba(255,255,255,0.12)" stroke-width="0.5"/>${bars}</svg>`;
2411      };
2412      // 무료(잠금) 상태의 블러 티저용 고정 플레이스홀더 패턴 (장식용 — 자체 스케일 유지)
2413      const PLACEHOLDER = [3, -2, 5, -4, 2, 6, -3, 1, -5, 4, 2, -1, 3, -6, 2, 5, -2, 4, -3, 1];
2414      const phMax = Math.max(1, ...PLACEHOLDER.map(v => Math.abs(v)));
2415      const trendBlock = (name, series) => {
2416        const live = unlocked && series && series.length >= 2;
2417        const rows = live ? [...series].reverse() : null; // oldest→newest
2418        const dates = live ? rows.map(d => d.date) : null;
2419        // 시장 단위 공유 스케일 — 개인/외국인/기관 3주체 20일 값의 최대 절댓값
2420        const maxAbs = live ? Math.max(1, ...rows.flatMap(d => [Math.abs(d.indiv), Math.abs(d.frgn), Math.abs(d.inst)])) : phMax;
2421        const rangeLabel = live ? `${fmtDate(dates[0])}~${fmtDate(dates[dates.length - 1])}` : ''; // 실제 시리즈 날짜 범위
2422        const scaleHint = live ? ` <span class="inv-scale-hint">${rangeLabel}
2422 · 눈금 ±${maxAbs.toLocaleString('ko-KR')}</span>` : '';
2423        const body = [['indiv', '개인'], ['frgn', '외국인'], ['inst', '기관']].map(([key, label]) => {
2424          const vals = live ? rows.map(d => d[key]) : PLACEHOLDER;
2425          const latest = live ? vals[vals.length - 1] : null; // 최신일 값 (series[0])
2426          return `<div class="inv-spark-row"><span class="inv-spark-k">${label}</span>${spark(vals, maxAbs, dates)}${
2427            live ? `<span class="inv-spark-latest" style="color:${latest >= 0 ? upC : downC}">${fmtEok(latest)}</span>` : ''
2428          }</div>`;
2429        }).join('');
2430        // 누적 순매수 라인 (Pro) — 3주체 running sum, 0선 공유 스케일. 무료(placeholder)엔 표시 안 함.
2431        const cumHtml = live ? (() => {
2432          const parties = [['frgn', '외국인', '#5b9bf0'], ['inst', '기관', '#e0a13c'], ['indiv', '개인', '#8b93a3']];
2433          const cum = {};
2434          parties.forEach(([k]) => { let s = 0; cum[k] = rows.map(d => (s += (d[k] || 0))); });
2435          const allV = [0, ...parties.flatMap(([k]) => cum[k])];
2436          const mn = Math.min(...allV), rng = (Math.max(...allV) - mn) || 1;
2437          const CW = 128, CH = 34, n = rows.length;
2438          const xAt = i => n <= 1 ? 0 : i / (n - 1) * CW;
2439          const yAt = v => (CH - (v - mn) / rng * CH);
2440          const lines = parties.map(([k, , color]) => `<polyline points="${cum[k].map((v, i) => `${xAt(i).toFixed(1)},${yAt(v).toFixed(1)}`).join(' ')}" fill="none" stroke="${color}" stroke-width="1.2" stroke-linejoin="round"/>`).join('');
2441          const hover = rows.map((d, i) => `<rect x="${(i / n * CW).toFixed(1)}" y="0" width="${(CW / n).toFixed(1)}" height="${CH}" fill="transparent"><title>${fmtDate(d.date)} 누적 · 외 ${fmtEok(cum.frgn[i])} · 기 ${fmtEok(cum.inst[i])} · 개 ${fmtEok(cum.indiv[i])}</title></rect>`).join('');
2442          const legend = parties.map(([, label, color]) => `<span class="inv-cum-key"><i style="background:${color}"></i>${label}</span>`).join('');
2443          return `<div class="inv-cum-head">누적 순매수 ${legend} <span class="inv-scale-hint">${rangeLabel}</span></div>
2444            <svg class="inv-cum" viewBox="0 0 ${CW} ${CH}" preserveAspectRatio="none"><line x1="0" y1="${yAt(0).toFixed(1)}" x2="${CW}" y2="${yAt(0).toFixed(1)}" stroke="rgba(255,255,255,0.15)" stroke-width="0.5"/>${lines}${hover}</svg>`;
2445        })() : '';
2446        return `<div class="inv-trend"><div class="inv-trend-mkt">${name} 20일${scaleHint}</div>${body}${cumHtml}</div>`;
2447      };
2448
2449      const body = (!fl || fl.error)
2450        ? `<div style="padding:8px 0;color:var(--muted-2);font-size:12px">${fl?.error ? '수급 데이터를 불러오지 못했습니다' : '수급 데이터를 불러오는 중…'}</div>`
2451        : `${marketRow('KOSPI', fl.kospi)}${marketRow('KOSDAQ', fl.kosdaq)}
2452          <div class="inv-trend-head">20일 추이 ${!unlocked ? '<span class="pro-pill">PRO</span>' : ''}</div>
2453          <div class="inv-lock-wrap">
2454            <div class="${!unlocked ? 'sig-blur' : ''}">${trendBlock('KOSPI', fl.kospi)}${trendBlock('KOSDAQ', fl.kosdaq)}</div>
2455            ${!unlocked ? `<div class="sig-lock">
2456              <div class="sig-lock-icon">🔒</div>
2457              <p>개인·외국인·기관 20일 순매수 추이는 Pro에서 제공됩니다</p>
2458              <button class="primary" id="sigStartProInv" type="button">Pro 시작하기</button>
2459            </div>` : ''}
2460          </div>`;
2461
2462      return `<div class="sig-section">
2463        <div class="sig-sec-title">투자자별 수급 <span style="font-size:10.5px;color:var(--muted-2);font-weight:600">순매수 · 억원</span></div>
2464        ${body}
2465      </div>`;
2466    }
2467
2468    // 섹터 수급 로테이션 섹션 (시장 시그널 패널). 무료: 상위 3 섹터. Pro: 전체 순위 + 1주/1개월 토글.
2469    // 물길 히트맵 공용 렌더러 (KR 수급 · US 수익률 공유). series=[{end, sectors:{name:entry}}],
2470    // getV(entry)=값(억 or %), fmtVal(v)=표시 문자열, footprefix=각주 앞부분(주 수는 자동 부착).
2471    // state.riverWeeks만큼 슬라이스, 강도는 표시 구간의 max|값| 기준 정규화. 26주는 카드 내 가로 스크롤.
2472    function renderRiverGrid(series, getV, fmtVal, footPrefix) {
2473      const upC = state.colorblind ? 'var(--orange)' : 'var(--up)';
2474      const downC = state.colorblind ? 'var(--cyan)' : 'var(--down)';
2475      const fmtDate = d => /^\d{8}$/.test(String(d)) ? `${String(d).slice(4, 6)}.${String(d).slice(6, 8)}` : '';
2476      const s12 = series.slice(-state.riverWeeks);
2477      const weeks = s12.map(w => w.end);
2478      const wide = weeks.length > 13;
2479      const labelEvery = wide ? 4 : 1;
2480      const latest = s12[s12.length - 1]?.sectors || {};
2481      const names = [...new Set(s12.flatMap(w => Object.keys(w.sectors || {})))]
2482        .sort((a, b) => (getV(latest[b] || {}) || 0) - (getV(latest[a] || {}) || 0));
2483      let maxAbs = 1;
2484      s12.forEach(w => Object.values(w.sectors || {}).forEach(e => { const n = getV(e); if (n != null) maxAbs = Math.max(maxAbs, Math.abs(n)); }));
2485      const cell = (entry, end, name, latestCol) => {
2486        const val = entry ? getV(entry) : null;
2487        if (val == null) return `<div class="river-cell${latestCol ? ' river-latest' : ''}"></div>`;
2488        const a = Math.min(1, Math.abs(val) / maxAbs);
2489        return `<div class="river-cell${latestCol ? ' river-latest' : ''}" title="${fmtDate(end)} · ${name} · ${fmtVal(val)}" style="background:${val >= 0 ? upC : downC};opacity:${(0.1 + a * 0.9).toFixed(2)}"></div>`;
2490      };
2491      const colTmpl = wide
2492        ? `minmax(58px,auto) repeat(${weeks.length},18px)`
2493        : `minmax(58px,auto) repeat(${weeks.length},minmax(16px,1fr))`;
2494      return `<div class="river-scroll"><div class="river-grid" style="grid-template-columns:${colTmpl}">
2495        <div class="river-corner"></div>
2496        ${weeks.map((end, i) => `<div class="river-head${i === weeks.length - 1 ? ' river-latest-head' : ''}">${(weeks.length - 1 - i) % labelEvery === 0 ? fmtDate(end) : ''}</div>`).join('')}
2497        ${names.map(name => `<div class="river-label" title="${name}">${name}</div>${s12.map((w, i) => cell(w.sectors?.[name], w.end, name, i === s12.length - 1)).join('')}`).join('')}
2498      </div></div>
2499      <div style="font-size:9px;color:var(--muted-2);margin-top:6px">${footPrefix} · 최근 ${weeks.length}주</div>`;
2500    }
2501
2502    function renderSectorSection() {
2503      const sf = state.sectorFlow;
2504      const upC = state.colorblind ? 'var(--orange)' : 'var(--up)';
2505      const downC = state.colorblind ? 'var(--cyan)' : 'var(--down)';
2506      const unlocked = can('sectorRotation');
2507      const win = ['s', 'm', 'q', 'y'].includes(state.sectorWindow) ? state.sectorWindow : 's';
2508      const fmtFlow = v => { const a = Math.abs(v), g = v > 0 ? '+' : v < 0 ? '-' : ''; return a >= 10000 ? g + (a / 10000).toFixed(1) + '조' : g + Math.round(a).toLocaleString('ko-KR') + '억'; };
2509      const fmtDate = d => /^\d{8}$/.test(String(d)) ? `${String(d).slice(4, 6)}.${String(d).slice(6, 8)}` : '';
2510      // 기간별 net/외인/기관 (구버전 sf:agg엔 63/YTD 없어 nearest fallback — agg 재빌드 후 정확).
2511      const netOf = s => ({ s: s.net5, m: s.net21 ?? s.net5, q: s.net63 ?? s.net21 ?? s.net5, y: s.netYtd ?? s.net63 ?? s.net5 }[win]);
2512      const frgnOf = s => ({ s: s.frgn5, m: s.frgn21, q: s.frgn63, y: s.frgnYtd }[win]) ?? s.frgn5;
2513      const instOf = s => ({ s: s.inst5, m: s.inst21, q: s.inst63, y: s.instYtd }[win]) ?? s.inst5;
2514      const winLabel = { s: '이번 주', m: '1개월', q: '3개월', y: '연초 이후' }[win];
2515
2516      const bar = (net, maxAbs) => {
2517        const pct = Math.min(50, Math.abs(net) / Math.max(1, maxAbs) * 50);
2518        return `<div class="sec-bar"><span class="sec-bar-fill" style="width:${pct.toFixed(1)}%;background:${net >= 0 ? upC : downC};${net >= 0 ? 'left:50%' : 'right:50%'}"></span></div>`;
2519      };
2520      const row = (s, maxAbs, showContrib) => {
2521        const net = netOf(s);
2522        const contrib = showContrib && s.top?.length ? `<small>${s.top.map(t => t.n).join(' · ')}</small>` : '';
2523        const tip = s.frgn5 != null ? `${s.sector} · 외인 ${fmtFlow(frgnOf(s))} · 기관 ${fmtFlow(instOf(s))}` : s.sector;
2524        return `<div class="sec-row" title="${tip}"><div class="sec-name">${s.sector}${contrib}</div>${bar(net, maxAbs)}<div class="sec-val" style="color:${net >= 0 ? upC : downC}">${fmtFlow(net)}</div></div>`;
2525      };
2526
2527      if (!sf || sf.error) {
2528        return `<div class="sig-section"><div class="sig-sec-title">섹터 수급 로테이션</div><div style="padding:8px 0;color:var(--muted-2);font-size:12px">${sf?.error ? '섹터 수급을 불러오지 못했습니다' : '섹터 수급을 불러오는 중…'}</div></div>`;
2529      }
2530
2531      const river = unlocked && state.sectorView === 'river';
2532      const rangeTxt = sf.asof ? ` · ${fmtDate(sf.asof)} 기준` : '';
2533      // Pro: [랭킹|물길] 뷰 seg + (랭킹 뷰) 1주/1개월/3개월/연초 창 토글 · (물길 뷰) 12주/26주 토글
2534      const viewSeg = unlocked ? `<div class="flow-period-seg" style="margin-left:auto">${[['rank', '랭킹'], ['river', '물길']].map(([v, l]) => `<button class="flow-period-btn ${state.sectorView === v ? 'active' : ''}" data-sec-view="${v}" type="button">${l}</button>`).join('')}</div>` : '';
2535      const winSeg = (unlocked && !river) ? `<div class="flow-period-seg" style="margin-left:6px">${[['s', '1주'], ['m', '1개월'], ['q', '3개월'], ['y', '연초']].map(([w, l]) => `<button class="flow-period-btn ${win === w ? 'active' : ''}" data-sec-window="${w}" type="button">${l}</button>`).join('')}</div>` : '';
2536      const weeksSeg = (unlocked && river) ? `<div class="flow-period-seg" style="margin-left:6px">${[[12, '12주'], [26, '26주']].map(([w, l]) => `<button class="flow-period-btn ${state.riverWeeks === w ? 'active' : ''}" data-river-weeks="${w}" type="button">${l}</button>`).join('')}</div>` : '';
2537      const toggle = unlocked ? `${winSeg}${weeksSeg}${viewSeg}` : '';
2538
2539      // 물길 히트맵 (KR): 섹터(행) × 주(열), 셀 색 = 주별 외인+기관 순매수. 공용 renderRiverGrid 사용.
2540      const riverHeat = () => {
2541        if (!sf.series?.length) return `<div style="padding:10px 0;color:var(--muted-2);font-size:12px">주별 물길 데이터를 준비 중입니다 · 다음 집계부터 표시됩니다</div>`;
2542        return renderRiverGrid(sf.series, e => e.net, fmtFlow, '주별 외국인+기관 순매수 합산 기준 · 진할수록 순매수 강함');
2543      };
2544
2545      let content;
2546      if (river) {
2547        content = riverHeat();
2548      } else if (unlocked && sf.sectors?.length) {
2549        const maxAbs = Math.max(1, ...sf.sectors.map(s => Math.abs(netOf(s))));
2550        content = `${sf.sectors.map(s => row(s, maxAbs, true)).join('')}
2551          <div style="font-size:9px;color:var(--muted-2);margin-top:5px">순매수(외인+기관) ${winLabel} 합 · 코스피200+코스닥150 기준</div>`;
2552      } else {
2553        const top = sf.top || (sf.sectors ? sf.sectors.slice(0, 3).map(s => ({ sector: s.sector, net5: s.net5 })) : []);
2554        const maxAbs = Math.max(1, ...top.map(s => Math.abs(s.net5)));
2555        const PH = [['반도체·IT', 6], ['자동차', 3.5], ['금융', -2], ['헬스케어', -4], ['소비재', 2.5], ['산업재', -1.5]];
2556        content = `${top.map(s => row({ sector: s.sector, net5: s.net5 }, maxAbs, false)).join('')}
2557          <div class="sec-lock-wrap">
2558            <div class="sig-blur">${PH.map(([n, v]) => row({ sector: n, net5: v * 1000 }, 6000, false)).join('')}</div>
2559            <div class="sig-lock"><div class="sig-lock-icon">🔒</div><p>전체 섹터 순위 · 외인/기관 분해 · 주도 종목은 Pro에서 제공됩니다</p><button class="primary" id="sigStartProSector" type="button">Pro 시작하기</button></div>
2560          </div>`;
2561      }
2562
2563      return `<div class="sig-section">
2564        <div class="sig-sec-title" style="display:flex;align-items:center;gap:8px">섹터 수급 로테이션 <span style="font-size:10.5px;color:var(--muted-2);font-weight:600">${river ? '물길' : winLabel}${rangeTxt}</span>${toggle}</div>
2565        ${content}
2566      </div>`;
2567    }
2568
2569    // --- 미국 섹터 로테이션 (SPDR 11섹터 → 10) : 수익률 기준, 수급(flow) 아님 ---
2570    // XLRE(리츠)는 네이버 worldstock 미커버 → 제외. 1D는 worldstock 시세, 1주/1개월은 일봉에서 계산.
2571    const US_SECTORS = [
2572      ['XLK', '기술'], ['XLF', '금융'], ['XLE', '에너지'], ['XLV', '헬스케어'],
2573      ['XLI', '산업재'], ['XLY', '자유소비재'], ['XLP', '필수소비재'],
2574      ['XLU', '유틸리티'], ['XLB', '소재'], ['XLC', '커뮤니케이션']
2575    ];
2576
2577    async function loadUsSectors(force = false) {
2578      if (!force && state.usSectors && Date.now() - (state.usSectors.fetchedAt || 0) < 5 * 60 * 1000) return state.usSectors;
2579      if (loadUsSectors.busy) return state.usSectors;
2580      loadUsSectors.busy = true;
2581      try {
2582        const map = await fetchNaverWorldStocks(US_SECTORS.map(s => s[0]));
2583        const quotes = {};
2584        US_SECTORS.forEach(([sym]) => { const q = map.get(sym); if (q && q.price > 0) quotes[sym] = { price: q.price, d: q.change ?? 0 }; });
2585        if (Object.keys(quotes).length) state.usSectors = { quotes, fetchedAt: Date.now(), asof: yyyymmdd(new Date()) };
2586        return state.usSectors;
2587      } finally {
2588        loadUsSectors.busy = false;
2589      }
2590    }
2591
2592    // 1주/1개월 계산용 일봉 지연 로드 (섹터별 캐시). 필요할 때만 호출.
2593    async function loadUsSectorCandles() {
2594      const targets = US_SECTORS.map(s => s[0]).filter(sym => !state.dailyCache[sym]);
2595      if (!targets.length) return false;
2596      let any = false;
2597      for (const group of chunkArray(targets, 4)) {
2598        await Promise.allSettled(group.map(sym => fetchStockDaily(sym).then(() => { any = true; }).catch(() => {})));
2599        await sleep(120);
2600      }
2601      return any;
2602    }
2603
2604    function usSectorReturn(sym, win) {
2605      if (win === 'd') return state.usSectors?.quotes?.[sym]?.d ?? null;
2606      const daily = state.dailyCache[sym];
2607      if (!daily?.closes?.length) return null;
2608      const c = daily.closes, n = c.length, back = win === 'm' ? 21 : 5;
2609      if (n - 1 - back < 0) return null;
2610      const past = c[n - 1 - back];
2611      if (!(past > 0)) return null;
2612      return (c[n - 1] - past) / past * 100;
2613    }
2614
2615    // YYYYMMDD → ISO 주 키 (서버 flows.mjs와 동일 규칙). US 물길 주간 수익률 버킷용.
2616    function isoWeekKey(ymd) {
2617      const s = String(ymd);
2618      const date = new Date(Date.UTC(+s.slice(0, 4), +s.slice(4, 6) - 1, +s.slice(6, 8)));
2619      const day = date.getUTCDay() || 7;
2620      date.setUTCDate(date.getUTCDate() + 4 - day);
2621      const yearStart = Date.UTC(date.getUTCFullYear(), 0, 1);
2622      return date.getUTCFullYear() * 100 + Math.ceil(((date.getTime() - yearStart) / 86400000 + 1) / 7);
2623    }
2624
2625    // US 물길 시리즈: SPDR 10섹터의 주간 수익률 (마지막 종가/직전 주 마지막 종가 − 1). 일봉 캐시에서 계산.
2626    // 반환: [{ end:주 종료일, sectors:{ 섹터명:{ val:주간수익률% } } }] (오름차순). 첫 주는 직전 없어 제외.
2627    function buildUsRiverSeries() {
2628      const weekMap = {};
2629      US_SECTORS.forEach(([sym, name]) => {
2630        const daily = state.dailyCache[sym];
2631        if (!daily?.closes?.length) return;
2632        const { dates, closes } = daily;
2633        const wkLast = {}; // wk -> {d, close} (주별 최종 거래일 종가)
2634        for (let i = 0; i < dates.length; i++) {
2635          const d = String(dates[i]), close = closes[i];
2636          if (!(close > 0) || !/^\d{8}$/.test(d)) continue;
2637          const wk = isoWeekKey(d);
2638          if (!wkLast[wk] || d > wkLast[wk].d) wkLast[wk] = { d, close };
2639        }
2640        const wks = Object.keys(wkLast).sort((a, b) => Number(a) - Number(b));
2641        for (let j = 1; j < wks.length; j++) {
2642          const cur = wkLast[wks[j]], prev = wkLast[wks[j - 1]];
2643          if (!(prev.close > 0)) continue;
2644          const ret = (cur.close / prev.close - 1) * 100;
2645          const wm = weekMap[wks[j]] || (weekMap[wks[j]] = { end: cur.d, sectors: {} });
2646          if (cur.d > wm.end) wm.end = cur.d;
2647          wm.sectors[name] = { val: ret };
2648        }
2649      });
2650      return Object.entries(weekMap).sort((a, b) => Number(a[0]) - Number(b[0])).map(([, w]) => w);
2651    }
2652
2653    // 시장 시그널 패널의 미국 섹터 로테이션 섹션 (수익률 랭킹). 무료: 상위 3, Pro: 전체 + 기간 토글.
2654    function renderUsSectorSection() {
2655      const upC = state.colorblind ? 'var(--orange)' : 'var(--up)';
2656      const downC = state.colorblind ? 'var(--cyan)' : 'var(--down)';
2657      const unlocked = can('sectorRotation');
2658      const win = unlocked ? state.usSectorWindow : 'd'; // 무료는 1D 고정
2659      const river = unlocked && state.sectorView === 'river';
2660      const us = state.usSectors;
2661      const fmtPct = v => `${v > 0 ? '+' : ''}${v.toFixed(2)}%`;
2662
2663      // Pro: [랭킹|물길] 뷰 seg + (랭킹) 1D/1주/1개월 · (물길) 12주/26주. KR와 동일 핸들러(data-sec-view/river-weeks) 공유.
2664      const viewSeg = unlocked ? `<div class="flow-period-seg" style="margin-left:auto">${[['rank', '랭킹'], ['river', '물길']].map(([v, l]) => `<button class="flow-period-btn ${state.sectorView === v ? 'active' : ''}" data-sec-view="${v}" type="button">${l}</button>`).join('')}</div>` : '';
2665      const usWinSeg = (unlocked && !river) ? `<div class="flow-period-seg" style="margin-left:6px">${[['d', '1D'], ['w', '1주'], ['m', '1개월']].map(([w, l]) => `<button class="flow-period-btn ${win === w ? 'active' : ''}" data-us-sec-window="${w}" type="button">${l}</button>`).join('')}</div>` : '';
2666      const weeksSeg = (unlocked && river) ? `<div class="flow-period-seg" style="margin-left:6px">${[[12, '12주'], [26, '26주']].map(([w, l]) => `<button class="flow-period-btn ${state.riverWeeks === w ? 'active' : ''}" data-river-weeks="${w}" type="button">${l}</button>`).join('')}</div>` : '';
2667      const toggle = unlocked ? `${usWinSeg}${weeksSeg}${viewSeg}` : '';
2668      const usFoot = '주별 섹터 ETF 수익률 기준 · 수급 데이터가 아닙니다'; // 정직 라벨 (수급 아님)
2669      const usWrap = (content, subtitle) => `<div class="sig-section">
2670        <div class="sig-sec-title" style="display:flex;align-items:center;gap:8px">미국 섹터 로테이션 <span style="font-size:10.5px;color:var(--muted-2);font-weight:600">${subtitle}</span>${toggle}</div>
2671        ${content}
2672      </div>`;
2673
2674      // 물길 뷰 (Pro): SPDR 주간 수익률 히트맵. 일봉에서 계산 (수급 없음 → 수익률 기반, 정직 라벨).
2675      if (river) {
2676        if (!US_SECTORS.every(([sym]) => state.dailyCache[sym]?.closes?.length)) {
2677          loadUsSectorCandles().then(ok => { if (ok && state.signalsOpen) renderSignals(); }).catch(() => {});
2678        }
2679        const series = buildUsRiverSeries();
2680        if (!series.length) return usWrap(`<div style="padding:10px 0;color:var(--muted-2);font-size:12px">주별 수익률을 계산하는 중…</div>`, usFoot);
2681        return usWrap(renderRiverGrid(series, e => e.val, fmtPct, usFoot), usFoot);
2682      }
2683
2684      if (!us) {
2685        loadUsSectors().then(v => { if (v && state.signalsOpen) renderSignals(); }).catch(() => {});
2686        return `<div class="sig-section"><div class="sig-sec-title">미국 섹터 로테이션</div><div style="padding:8px 0;color:var(--muted-2);font-size:12px">섹터 ETF 수익률을 불러오는 중…</div></div>`;
2687      }
2688
2689      const rows = US_SECTORS.map(([sym, name]) => ({ sym, name, r: usSectorReturn(sym, win) }))
2690        .filter(x => x.r != null).sort((a, b) => b.r - a.r);
2691
2692      // 1주/1개월인데 일봉이 아직 없으면 로드 트리거 후 로딩 표시
2693      if (win !== 'd' && rows.length < US_SECTORS.length) {
2694        loadUsSectorCandles().then(ok => { if (ok && state.signalsOpen) renderSignals(); }).catch(() => {});
2695        if (!rows.length) {
2696          return `<div class="sig-section"><div class="sig-sec-title">미국 섹터 로테이션</div><div style="padding:8px 0;color:var(--muted-2);font-size:12px">${win === 'm' ? '1개월' : '1주'} 수익률을 계산하는 중…</div></div>`;
2697        }
2698      }
2699
2700      const maxAbs = Math.max(0.1, ...rows.map(s => Math.abs(s.r)));
2701      const bar = r => {
2702        const pct = Math.min(50, Math.abs(r) / maxAbs * 50);
2703        return `<div class="sec-bar"><span class="sec-bar-fill" style="width:${pct.toFixed(1)}%;background:${r >= 0 ? upC : downC};${r >= 0 ? 'left:50%' : 'right:50%'}"></span></div>`;
2704      };
2705      const row = s => `<div class="sec-row" title="${s.sym} · ${s.name}"><div class="sec-name">${s.name}<small>${s.sym}</small></div>${bar(s.r)}<div class="sec-val" style="color:${s.r >= 0 ? upC : downC}">${s.r > 0 ? '+' : ''}${s.r.toFixed(2)}%</div></div>`;
2706
2707      const winLabel = win === 'm' ? '1개월' : win === 'w' ? '1주' : '1D';
2708      const rangeTxt = us.asof ? ` · ${String(us.asof).slice(4, 6)}.${String(us.asof).slice(6, 8)} 기준` : '';
2709
2710      let content;
2711      if (unlocked) {
2712        content = `${rows.map(row).join('')}
2713          <div style="font-size:9px;color:var(--muted-2);margin-top:5px">SPDR 섹터 ETF ${winLabel} 수익률 기준 · 수급이 아닌 가격 변동</div>`;
2714      } else {
2715        const PH = [['기술', 1.2], ['금융', 0.6], ['헬스케어', -0.5], ['에너지', 0.9], ['유틸리티', -0.3], ['소재', 0.4]];
2716        content = `${rows.slice(0, 3).map(row).join('')}
2717          <div class="sec-lock-wrap">
2718            <div class="sig-blur">${PH.map(([n, v]) => `<div class="sec-row"><div class="sec-name">${n}</div><div class="sec-bar"><span class="sec-bar-fill" style="width:${Math.min(50, Math.abs(v) / 1.5 * 50)}%;background:${v >= 0 ? upC : downC};${v >= 0 ? 'left:50%' : 'right:50%'}"></span></div><div class="sec-val" style="color:${v >= 0 ? upC : downC}">${v > 0 ? '+' : ''}${v.toFixed(2)}%</div></div>`).join('')}</div>
2719            <div class="sig-lock"><div class="sig-lock-icon">🔒</div><p>전체 섹터 순위 · 1주/1개월 기간 비교는 Pro에서 제공됩니다</p><button class="primary" id="sigStartProSector" type="button">Pro 시작하기</button></div>
2720          </div>`;
2721      }
2722
2723      return usWrap(content, `섹터 ETF 수익률 기준${rangeTxt}`);
2724    }
2725
2726    // --- ETF 카테고리 온도 (클라이언트 로컬 합성 지표, 서버 온도와 별개) ---
2727    // 카테고리 구성 ETF의 시장폭·AUM가중 1D·3개월 모멘텀을 50 중심 가산해 0-100 산출.
2728    function computeCategoryThermo(cat) {
2729      // 현재 그룹 모드(카테고리/운용사)에 맞춰 멤버 필터 — 브랜드 타일도 같은 패널 재사용.
2730      const members = activeEtfs().filter(e => etfGroupKeyOf(e) === cat);
2731      if (!members.length) return null;
2732      const n = members.length;
2733      const up = members.filter(e => (e.d || 0) > 0).length;
2734      const down = members.filter(e => (e.d || 0) < 0).length;
2735      const upRatio = up / n * 100;
2736      const aumSum = members.reduce((s, e) => s + (e.m || 0), 0);
2737      const wavgD = aumSum > 0
2738        ? members.reduce((s, e) => s + (e.d || 0) * (e.m || 0), 0) / aumSum
2739        : members.reduce((s, e) => s + (e.d || 0), 0) / n;
2740      const m3vals = members.map(e => e.m3).filter(v => Number.isFinite(v) && v !== 0);
2741      const avgM3 = m3vals.length ? m3vals.reduce((a, b) => a + b, 0) / m3vals.length : null;
2742
2743      const cBreadth = clamp((upRatio - 50) * 0.4, -15, 15);
2744      const cDaily = clamp(wavgD * 6, -15, 15);
2745      const cMom = avgM3 != null ? clamp(avgM3 * 0.8, -12, 12) : 0;
2746      const score = Math.round(clamp(50 + cBreadth + cDaily + cMom, 0, 100));
2747
2748      return { cat, n, up, down, upRatio, aumSum, wavgD, avgM3, m3Count: m3vals.length, score, members };
2749    }
2750
2751    function openCategoryThermo(cat) {
2752      state.catThermo = cat;
2753      state.stockPanel = null;
2754      state.signalsOpen = false;
2755      state.pfEditorOpen = false;
2756      hidePopover();
2757      renderCatThermo();
2758    }
2759
2760    function closeCategoryThermo() {
2761      state.catThermo = null;
2762      $('signalsPanel')?.classList.remove('open');
2763      $('signalsBackdrop')?.classList.remove('show');
2764    }
2765
2766    function renderCatThermo() {
2767      const panel = $('signalsPanel');
2768      const backdrop = $('signalsBackdrop');
2769      if (!panel || !backdrop || !state.catThermo) return;
2770      panel.classList.add('open');
2771      backdrop.classList.add('show');
2772
2773      const c = computeCategoryThermo(state.catThermo);
2774      if (!c) {
2775        panel.innerHTML = `<div class="sig-head"><span>${state.catThermo} 온도</span><div style="flex:1"></div><button class="icon-btn" id="catThermoClose" type="button" aria-label="닫기">×</button></div>
2776          <div class="sig-body" style="padding:40px 20px;text-align:center;color:var(--muted)">이 카테고리의 ETF 데이터가 없습니다</div>`;
2777        return;
2778      }
2779
2780      const color = tempColor(c.score);
2781      const gaugeGrad = state.colorblind
2782        ? 'linear-gradient(90deg,#0e8fa6 0%,var(--cyan) 26%,var(--neutral) 50%,var(--orange) 74%,#e0662a 100%)'
2783        : 'linear-gradient(90deg,var(--down-2) 0%,var(--down) 26%,var(--neutral) 50%,var(--up) 74%,var(--up-2) 100%)';
2784      const ticks = [['10', '극단공포'], ['30', '공포'], ['50', '중립'], ['70', '과열'], ['90', '극단과열']];
2785      const upC = state.colorblind ? 'var(--orange)' : 'var(--up)';
2786      const downC = state.colorblind ? 'var(--cyan)' : 'var(--down)';
2787      const label = tempLabel(c.score);
2788      const sd = v => (v > 0 ? '+' : '') + v.toFixed(2) + '%';
2789
2790      // 한 줄 요약 (비인과적 톤)
2791      const zone = c.score >= 80 ? '극단과열' : c.score >= 60 ? '과열' : c.score >= 40 ? '중립' : c.score >= 20 ? '공포' : '극단공포';
2792      const summary = `${c.cat} ETF ${c.n}개 중 ${c.up}개 상승 — ${zone} 구간입니다`;
2793
2794      const stat = (k, v, col) => `<div class="sig-stat"><div class="k">${k}</div><div class="v" style="font-size:13px${col ? `;color:${col}` : ''}">${v}</div></div>`;
2795      const m3Txt = c.avgM3 != null
2796        ? `${sd(c.avgM3)}${c.m3Count < c.n ? ` <span style="color:var(--muted-2)">(${c.m3Count}/${c.n})</span>` : ''}`
2797        : '<span style="color:var(--muted-2)">데이터 수집 중</span>';
2798
2799      const byMove = c.members.slice().sort((a, b) => (b.d || 0) - (a.d || 0));
2800      const movers = arr => arr.map(e => `<button class="cat-mover" data-open-etf="${encodeURIComponent(e.n)}" type="button"><span class="cat-mover-n">${e.s || e.n}</span><span class="cat-mover-d" style="color:${(e.d || 0) >= 0 ? upC : downC}">${sd(e.d || 0)}</span></button>`).join('');
2801      const gainers = byMove.filter(e => (e.d || 0) > 0).slice(0, 3);
2802      const losers = byMove.filter(e => (e.d || 0) < 0).slice(-3).reverse();
2803
2804      panel.innerHTML = `
2805        <div class="sig-head">
2806          <span>${c.cat} 온도</span><div style="flex:1"></div>
2807          <button class="icon-btn" id="catThermoClose" type="button" aria-label="닫기">×</button>
2808        </div>
2809        <div class="sig-body">
2810          <div class="sig-hero">
2811            <div class="sig-score-row">
2812              <span class="sig-score" style="color:${color}">${c.score}</span>
2813              <span style="font-size:15px;font-weight:750;color:${color};padding-bottom:3px">${label}</span>
2814            </div>
2815            <div class="sig-band" style="background:${gaugeGrad}">
2816              <span class="sig-band-pin" style="left:${clamp(c.score, 2, 98)}%"></span>
2817            </div>
2818            <div class="sig-ticks">${ticks.map(([left, l]) => `<span style="left:${left}%">${l}</span>`).join('')}</div>
2819            <div style="margin-top:10px;font-size:12.5px;color:var(--text-2);font-weight:600">${summary}</div>
2820            <button class="secondary" id="catThermoDrill" type="button" style="width:100%;margin-top:12px">${c.cat} ETF ${c.n}개 펼쳐보기</button>
2821          </div>
2822          <div class="sig-section">
2823            <div class="sig-sec-title">지표</div>
2824            <div class="sig-grid">
2825              ${stat('ETF 개수', `${c.n}개`)}
2826              ${stat('순자산 합계', `${c.aumSum.toFixed(1)}조원`)}
2827              ${stat('상승 / 하락', `<span style="color:${upC}">${c.up}</span> / <span style="color:${downC}">${c.down}</span>`)}
2828              ${stat('AUM가중 1D', sd(c.wavgD), c.wavgD >= 0 ? upC : downC)}
2829              ${stat('평균 3개월', m3Txt)}
2830            </div>
2831          </div>
2832          ${gainers.length || losers.length ? `<div class="sig-section">
2833            <div class="sig-sec-title">주요 등락</div>
2834            ${gainers.length ? `<div class="cat-mover-group"><div class="cat-mover-lbl" style="color:${upC}">상승 상위</div>
2834${movers(gainers)}</div>` : ''}
2835            ${losers.length ? `<div class="cat-mover-group" style="margin-top:6px"><div class="cat-mover-lbl" style="color:${downC}">하락 상위</div>${movers(losers)}</div>` : ''}
2836          </div>` : ''}
2837          <div class="sig-section" style="border-bottom:0">
2838            <div class="sig-note">카테고리 구성 ETF 기준 합성 지표입니다 · 시장 온도와 다른 로컬 계산이며 참고용입니다</div>
2839          </div>
2840        </div>`;
2841    }
2842
2843    function computeThermo() {
2844      const market = thermoMarket();
2845      const sv = state.thermoServer[market];
2846      if (!sv || !Number.isFinite(sv.score) || Date.now() - (sv.fetchedAt || sv.at || 0) > thermoStaleMs) {
2847        state.thermo = null;
2848        return null;
2849      }
2850      const upC = state.colorblind ? 'var(--orange)' : 'var(--up)';
2851      const downC = state.colorblind ? 'var(--cyan)' : 'var(--down)';
2852      const score = sv.score;
2853      const inds = sv.inds || {};
2854
2855      // 히스토리 (시장별 · 하루 1샘플) — delta/30일 평균은 로컬 축적으로 계산
2856      const hist = market === 'us' ? state.thermoHistoryUs : state.thermoHistory;
2857      const histKey = market === 'us' ? thermoUsStorageKey : thermoStorageKey;
2858      const refIdx = market === 'us' ? pulseItem('.INX') : pulseItem('KOSPI');
2859      const key = todayKey();
2860      const sample = { d: key, score, kospi: refIdx?.v || null };
2861      Object.keys(inds).forEach(id => { sample[id] = inds[id].val ?? null; });
2862      const idx = hist.findIndex(h => h.d === key);
2863      if (idx >= 0) hist[idx] = sample; else hist.push(sample);
2864      if (hist.length > 120) hist.splice(0, hist.length - 120);
2865      localStorage.setItem(histKey, JSON.stringify(hist));
2866      if (market === 'kr') {
2867        const latest = { score, label: tempLabel(score), at: Date.now() };
2868        if (bridge.active) bridge.set('heattab.thermo.latest', latest);
2869        else if (window.chrome?.storage?.local) chrome.storage.local.set({ 'heattab.thermo.latest': latest });
2870      }
2871
2872      const prevSample = hist.filter(h => h.d !== key).slice(-1)[0];
2873      const scores = hist.map(h => h.score);
2874      state.thermo = {
2875        market,
2876        score,
2877        label: sv.label || tempLabel(score),
2878        color: tempColor(score),
2879        delta: prevSample ? score - prevSample.score : null,
2880        avg30: scores.length ? Math.round(scores.slice(-30).reduce((s, v) => s + v, 0) / Math.min(scores.length, 30)) : null,
2881        at: sv.at || sv.fetchedAt,
2882        inds,
2883        serverSignals: sv.signals || [],
2884        signalsTotal: sv.signalsTotal ?? (sv.signals || []).length,
2885        serverComment: sv.comment || [],
2886        upC,
2887        downC
2888      };
2889      return state.thermo;
2890    }
2891
2892    // 시그널 — 서버가 규칙 평가 후 내려준 목록을 표시 형식으로만 변환
2893    function thermoSignals(t) {
2894      const at = new Date(t.at || Date.now());
2895      const hm = String(at.getHours()).padStart(2, '0') + ':' + String(at.getMinutes()).padStart(2, '0');
2896      const sevColor = sev => sev === 'warn' ? 'var(--warn)' : 'var(--muted)';
2897      const list = (t.serverSignals || []).map(sg => ({ time: hm, sev: sevColor(sg.sev), text: sg.text, tags: sg.tags || [] }));
2898      if (!list.length) list.push({ time: hm, sev: 'var(--muted)', text: '현재 조건을 충족하는 시그널이 없습니다', tags: [] });
2899      return list;
2900    }
2901
2902    // 종합 코멘트 — 첫 줄(점수·전일 대비)만 로컬 생성, 요인 분석은 서버 제공(Pro)
2903    function thermoComment(t) {
2904      const out = [];
2905      const dir = t.delta == null ? '' : t.delta > 0 ? `전일보다 ${t.delta}p 오른 ` : t.delta < 0 ? `전일보다 ${Math.abs(t.delta)}p 내린 ` : '전일과 같은 ';
2906      const avgNote = t.avg30 != null && Math.abs(t.score - t.avg30) >= 5
2907        ? ` 30일 평균(${t.avg30})을 ${t.score > t.avg30 ? '웃돌고' : '밑돌고'} 있습니다.`
2908        : t.avg30 != null ? ` 30일 평균(${t.avg30}) 부근입니다.` : '';
2909      out.push({ free: true, text: `${t.market === 'us' ? '미국' : '한국'} 시장 온도는 ${dir}${t.score}점, ${t.label} 구간입니다.${avgNote}` });
2910      const server = (t.serverComment || []).map(text => ({ text }));
2911      if (server.length) out.push(...server);
2912      else out.push({ text: '요인 분석 코멘트는 Pro에서 제공됩니다 — 어떤 지표가 온도를 끌어올렸고 끌어내렸는지 문장으로 풀어드립니다.' });
2913      return out;
2914    }
2915
2916    function sigSparkPath(values, w, h) {
2917      if (values.length < 2) return null;
2918      const min = Math.min(...values);
2919      return pricesToPath(values.map(v => v - min + 1), w, h);
2920    }
2921
2922    function thermoChipHtml() {
2923      const t = state.thermo;
2924      if (!t) {
2925        return `<button class="pulse-chip thermo" id="thermoChip" type="button" data-tip="수급·시장폭·VIX·환율·금리차·금은비·SOX 7개 지표를 합성한 0~100 종합 온도입니다. 시장 새로고침 후 계산됩니다.">
2926          <span class="pulse-name"><span class="pulse-group">Thermo</span>시장 온도</span>
2927          <span class="pulse-value" style="color:var(--muted)"><span>—</span><span class="pulse-change">수집 중</span></span>
2928          <svg class="pulse-spark" viewBox="0 0 110 28"></svg>
2929        </button>`;
2930      }
2931      const deltaTxt = t.delta == null ? '' : (t.delta >= 0 ? '▲' : '▼') + Math.abs(t.delta);
2932      const spark = sigSparkPath(thermoHist().slice(-30).map(hh => hh.score), 110, 28);
2933      const bgColor = t.score < 40 ? 'rgba(49,130,246,0.08)' : t.score >= 60 ? 'rgba(240,68,82,0.08)' : 'rgba(255,255,255,0.02)';
2934      return `<button class="pulse-chip thermo" id="thermoChip" type="button" data-tip="${t.market === 'us' ? '미국' : '한국'} 시장 지표를 합성한 종합 온도 (50 중립 · 40 미만 공포 · 60 이상 과열). 보는 지수에 따라 국장/미장 온도로 자동 전환됩니다. 클릭하면 상세가 열립니다." style="background:${bgColor};border-color:${t.color.startsWith('var') ? 'rgba(255,255,255,0.14)' : t.color}">
2935        <span class="pulse-name"><span class="pulse-group">${t.market === 'us' ? 'US' : 'KR'} 🌡</span>시장 온도<span style="float:right;color:${tempColor(50 + (t.delta || 0))}">${deltaTxt}</span></span>
2936        <span class="pulse-value" style="color:${t.color}"><span>${t.score}</span><span class="pulse-change">${t.label}</span></span>
2937        <svg class="pulse-spark" viewBox="0 0 110 28" preserveAspectRatio="none">${spark ? `<path d="${spark}" fill="none" stroke="${t.color}" stroke-width="1.8" opacity="0.9"/>` : ''}</svg>
2938      </button>`;
2939    }
2940
2941    function fmtVal(id, v) {
2942      if (v == null) return '-';
2943      if (id === 'frgn' || id === 'pension') return formatEok(v);
2944      if (id === 'credit' || id === 'deposit') return (v / 10000).toFixed(1) + 'ì¡°';
2945      if (id === 'expiry') return 'D-' + v;
2946      if (id === 'fx') return Math.round(v).toLocaleString('ko-KR');
2947      if (id === 'yc') return (v > 0 ? '+' : '') + v.toFixed(2) + '%p';
2948      if (id === 'adr' || id === 'adrUs') return v.toFixed(0) + '%';
2949      if (id === 'gs' || id === 'vix' || id === 'dxy') return v.toFixed(1);
2950      if (id === 'sox' || id === 'soxUs') return (v > 0 ? '+' : '') + v.toFixed(1) + '%p';
2951      if (id === 'mom') return (v > 0 ? '+' : '') + v.toFixed(1) + '%';
2952      if (id === 'idxdd') return v.toFixed(1) + '%';
2953      return String(v);
2954    }
2955
2956    const IND_META = {
2957      frgn: { name: '외국인 수급', cat: '수급', desc: '시가총액 상위 20개 대형주의 외국인 순매수 대금 합계(당일). 순매수(+)는 온도를 올리고 순매도(−)는 내립니다.' },
2958      adr: { name: '상승/하락 비율 (1D)', cat: '시장 폭', desc: 'KOSPI200 중 상승 종목이 차지하는 비율. 50% 위면 상승 우위. 지수가 올라도 이 비율이 낮으면 소수 종목만 오르는 장세입니다.' },
2959      vix: { name: 'VIX', cat: '변동성', desc: 'S&P500 옵션 가격으로 계산되는 변동성 지수. 높을수록 시장 불안이 크며, 통상 20 이상을 변동성 확대 구간으로 봅니다.' },
2960      fx: { name: '원달러', cat: '변동성', desc: '원/달러 환율의 당일 변동률. 환율 상승(원화 약세)은 외국인 자금 이탈 압력으로 작용해 온도를 내립니다.' },
2961      yc: { name: '10Y-2Y 금리차', cat: '금리', desc: '미국 10년물 − 2년물 국채 금리. 음수(장단기 역전)는 역사적으로 경기 침체에 앞서 나타났던 신호입니다.' },
2962      gs: { name: '금/은 비율', cat: '밸류에이션', desc: '금 가격 ÷ 은 가격. 비율이 오르면 산업 수요(은)보다 안전자산(금) 선호가 강한 것 — 위험 회피 심리를 뜻합니다.' },
2963      sox: { name: 'SOX 상대강도 (1D)', cat: '로테이션', desc: '필라델피아 반도체지수와 KOSPI의 당일 등락률 격차. 반도체 강세는 수출·IT 비중이 큰 한국 시장의 선행 신호로 통용됩니다.' },
2964      idxdd: { name: '지수 고점 대비', cat: '추세', desc: '지수가 52주 최고가에서 얼마나 내려와 있는지(%). 정보성 지표로 온도 점수에는 반영하지 않습니다.' },
2965      mom: { name: '지수 모멘텀', cat: '추세', desc: '지수의 20일 이동평균선 대비 이격도. 양수면 단기 추세 위, 과도하게 크면 과열·되돌림 가능 구간입니다. 20/60일선 위치와 돌파도 함께 표시합니다.' },
2966      pension: { name: '연기금 수급 (5일)', cat: '수급', desc: '국민연금 등 연기금의 최근 5일 순매수 합계(코스피 전체). 장기 자금이라 연속 순매수는 저평가 구간 신호로 자주 인용됩니다.' },
2967      credit: { name: '신용잔고', cat: '레버리지', desc: '개인 신용융자 잔고(빚투). 급증은 과열·조정 취약 신호, 급감은 반대매매·투매 이후를 시사합니다. 전일 대비 증감으로 온도에 반영합니다. (D+2 집계)' },
2968      deposit: { name: '고객예탁금', cat: '대기자금', desc: '증권계좌 대기 현금. 증가는 매수 여력 유입, 감소는 자금 이탈을 뜻합니다. (D+2 집계)' },
2969      expiry: { name: '선물·옵션 만기', cat: '이벤트', desc: '매월 둘째 목요일 옵션 만기, 3·6·9·12월은 선물 동시만기(네 마녀). 만기 주간에는 수급 왜곡과 변동성 확대가 잦습니다. 온도 점수에는 반영하지 않는 참고 지표입니다.' },
2970      adrUs: { name: '상승/하락 비율 (1D)', cat: '시장 폭', desc: 'S&P500 중 상승 종목이 차지하는 비율. 50% 위면 상승 우위. 지수가 올라도 이 비율이 낮으면 소수 대형주만 오르는 장세입니다.' },
2971      dxy: { name: '달러인덱스 (DXY)', cat: '변동성', desc: '주요 6개 통화 대비 달러 가치. 달러 강세는 글로벌 유동성 축소·위험자산 회피와 함께 나타나는 경향이 있어 온도를 내립니다.' },
2972      soxUs: { name: 'SOX 상대강도 (1D)', cat: '로테이션', desc: '필라델피아 반도체지수와 S&P500의 당일 등락률 격차. 반도체는 위험 선호의 선행 신호로 통용됩니다.' }
2973    };
2974
2975    function indHistory(id) {
2976      return thermoHist().map(h => h[id]).filter(v => Number.isFinite(v));
2977    }
2978
2979    function sigCardViz(id, ind, locked) {
2980      const t = state.thermo;
2981      if (id === 'adr' || id === 'adrUs') {
2982        const total = ind.up + ind.down + ind.flat || 1;
2983        return `<div class="sig-stack">
2984            <span style="width:${ind.up / total * 100}%;background:${t.upC}"></span>
2985            <span style="width:${ind.flat / total * 100}%;background:var(--neutral)"></span>
2986            <span style="width:${ind.down / total * 100}%;background:${t.downC}"></span>
2987          </div>
2988          <div style="display:flex;justify-content:space-between;font-size:9px;margin-top:3px">
2989            <span style="color:${t.upC}">▲${ind.up}</span><span style="color:var(--muted-2)">–${ind.flat}</span><span style="color:${t.downC}">▼${ind.down}</span>
2990          </div>`;
2991      }
2992      if (id === 'frgn' || id === 'pension') {
2993        const days = ind.days || [];
2994        const maxAbs = Math.max(1, ...days.map(day => Math.abs(day.amt)));
2995        return `<div class="sig-cols">${days.map(day => {
2996          const h = Math.max(12, Math.abs(day.amt) / maxAbs * 100);
2997          return `<span style="height:${h}%;background:${day.amt >= 0 ? t.upC : t.downC};align-self:flex-end"></span>`;
2998        }).join('')}</div><div class="sig-note">5일 일별 순매수 · ${id === 'frgn' ? '상위 ' + ind.n + '종목' : '코스피 전체'}</div>`;
2999      }
3000      if (id === 'credit' || id === 'deposit') {
3001        const hist = indHistory(id);
3002        const path = sigSparkPath(hist.slice(-30), 180, 28);
3003        return `<div class="sig-note" style="font-size:10px;margin:0 0 3px">전일 대비 <span style="color:${(ind.delta || 0) >= 0 ? t.upC : t.downC};font-weight:700">${formatEok(ind.delta)}</span></div>
3004          ${path ? `<svg viewBox="0 0 180 28" preserveAspectRatio="none" style="width:100%;height:24px;display:block"><path d="${path}" fill="none" stroke="${(ind.delta || 0) >= 0 ? t.upC : t.downC}" stroke-width="1.5"/></svg>` : `<div class="sig-skel" style="width:70%"></div>`}
3005          <div class="sig-note">D+2 집계 · ${hist.length}일 축적</div>`;
3006      }
3007      if (id === 'expiry') {
3008        return `<div class="sig-note" style="font-size:11px;margin:0">${ind.label}</div>
3009          <div class="sig-note">${ind.quad ? '네 마녀의 날 — 변동성 확대 주의 구간' : '월물 옵션 만기'}</div>`;
3010      }
3011      if (id === 'mom') {
3012        const hist = indHistory('mom');
3013        const path = sigSparkPath(hist.slice(-30), 180, 24);
3014        return `${path ? `<svg viewBox="0 0 180 24" preserveAspectRatio="none" style="width:100%;height:22px;display:block"><path d="${path}" fill="none" stroke="${ind.c >= 0 ? t.upC : t.downC}" stroke-width="1.5"/></svg>` : `<div class="sig-skel" style="width:70%"></div>`}
3015          <div class="sig-note">20일선 ${ind.above20 ? '위' : '아래'} · 60일선 ${ind.above60 ? '위' : '아래'}${ind.cross ? ` · 20일선 ${ind.cross === 'up' ? '상향 돌파' : '하향 이탈'}` : ''}</div>`;
3016      }
3017      if (id === 'yc' || id === 'sox' || id === 'soxUs' || id === 'gs') {
3018        const hist = indHistory(id);
3019        const path = sigSparkPath(hist.slice(-30), 180, 28);
3020        return path
3021          ? `<svg viewBox="0 0 180 28" preserveAspectRatio="none" style="width:100%;height:28px;display:block"><path d="${path}" fill="none" stroke="${ind.c >= 0 ? t.upC : t.downC}" stroke-width="1.5"/></svg><div class="sig-note">${id === 'yc' && ind.val < 0 ? '역전 구간' : '일별 추이 (' + hist.length + '일 축적)'}</div>`
3022          : `<div class="sig-skel" style="width:70%"></div><div class="sig-note">일별 추이 축적 중</div>`;
3023      }
3024      // band: vix, fx — 축적된 일별 값 범위 내 현재 위치
3025      const hist = indHistory(id);
3026      if (hist.length >= 5) {
3027        const lo = Math.min(...hist);
3028        const hi = Math.max(...hist);
3029        const pctPos = hi > lo ? clamp((ind.val - lo) / (hi - lo) * 100, 2, 98) : 50;
3030        const grad = `linear-gradient(90deg,${t.downC},var(--neutral) 50%,${t.upC})`;
3031        return `<div style="${locked ? 'filter:blur(3px)' : ''}">
3032          <div style="display:flex;justify-content:space-between;font-size:9px;color:var(--muted-2);margin-bottom:3px"><span>${fmtVal(id, lo)}</span><span>${fmtVal(id, hi)}</span></div>
3033          <div class="sig-miniband" style="background:${grad}"><span style="left:${pctPos}%"></span></div>
3034          <div class="sig-note">${hist.length}일 밴드${locked ? ' · PRO' : ''}</div>
3035        </div>`;
3036      }
3037      return `<div class="sig-skel" style="width:70%"></div><div class="sig-note">밴드 축적 중 (${hist.length}/5일)</div>`;
3038    }
3039
3040    function renderSignals() {
3041      if (state.catThermo) { renderCatThermo(); return; }
3042      if (state.stockPanel) { renderStockPanel(); return; }
3043      if (state.pfEditorOpen) { renderPfEditor(); return; }
3044      const panel = $('signalsPanel');
3045      const backdrop = $('signalsBackdrop');
3046      if (!panel || !backdrop) return;
3047      panel.classList.toggle('open', state.signalsOpen);
3048      backdrop.classList.toggle('show', state.signalsOpen);
3049      if (!state.signalsOpen) return;
3050
3051      computeThermo();
3052      const t = state.thermo;
3053      const lockedContrib = !can('contribBreakdown');
3054      const lockedHistory = !can('thermoHistory');
3055      const lockedBand = !can('indicatorBand');
3056      const lockedFeed = !can('signalFeedAll');
3057
3058      if (!t) {
3059        panel.innerHTML = `<div class="sig-head"><span>🌡 시장 시그널</span><div style="flex:1"></div><button class="icon-btn" id="signalsClose" type="button" aria-label="닫기">×</button></div>
3060          <div class="sig-body"><div style="padding:40px 20px;text-align:center;color:var(--muted)">지표 데이터가 아직 없습니다.<br><br>시장 새로고침 후 다시 열어 주세요.</div></div>`;
3061        return;
3062      }
3063
3064      // 상세 푸시 뷰
3065      if (state.signalsDetail && can('indicatorDetail')) {
3066        const id = state.signalsDetail;
3067        const ind = t.inds[id];
3068        const meta = IND_META[id];
3069        const hist = indHistory(id);
3070        const histPath = sigSparkPath(hist.slice(-90), 424, 130);
3071        const scoreHist = state.thermoHistory.map(h => h.score);
3072        const pctRank = hist.length >= 10 && ind
3073          ? Math.round(hist.filter(v => v <= ind.val).length / hist.length * 100)
3074          : null;
3075        const tagFor = { frgn: '수급', yc: '금리차', adr: '시장 폭', adrUs: '시장 폭', fx: '환율', dxy: '달러', gs: '금은비', sox: '로테이션', soxUs: '로테이션', vix: 'VIX' };
3076        const relatedSignals = thermoSignals(t).filter(sg => sg.tags.includes(tagFor[id]));
3077        panel.innerHTML = `
3078          <div class="sig-head">
3079            <button class="icon-btn" id="sigBack" type="button" aria-label="뒤로">‹</button>
3080            <span>${meta.name}</span>
3081            <div style="flex:1"></div>
3082            <button class="icon-btn" id="signalsClose" type="button" aria-label="닫기">×</button>
3083          </div>
3084          <div class="sig-body" style="padding:16px">
3085            <div style="font-size:11px;font-weight:650;color:var(--muted-2)">${meta.cat}</div>
3086            <div style="display:flex;align-items:baseline;gap:8px;margin:4px 0 14px">
3087              <span style="font-size:26px;font-weight:850">${ind ? fmtVal(id, ind.val) : '-'}</span>
3088              <span style="font-size:12px;font-weight:750;color:${ind && ind.c >= 0 ? t.upC : t.downC}">기여도 ${ind ? (ind.c > 0 ? '+' : '') + ind.c.toFixed(0) : '-'}</span>
3089            </div>
3090            <div class="sig-chart-box">
3091              <div style="font-size:11px;font-weight:650;color:var(--muted);margin-bottom:8px">일별 히스토리 (${hist.length}일 축적)</div>
3092              ${histPath
3093                ? `<svg viewBox="0 0 424 130" style="width:100%;height:130px;display:block"><path d="${histPath}" fill="none" stroke="${t.color}" stroke-width="1.8"/></svg>`
3094                : `<div style="padding:30px 0;text-align:center;color:var(--muted-2);font-size:12px">데이터는 사용 시작일부터 매일 축적됩니다<br>(현재 ${hist.length}일)</div>`}
3095            </div>
3096            <div style="margin-top:14px;padding:13px 14px;border:1px solid var(--line);border-radius:10px;background:var(--field)">
3097              <div style="font-size:11px;font-weight:650;color:var(--muted);margin-bottom:4px">백분위</div>
3098              <div style="font-size:14px;font-weight:650;line-height:1.5">${pctRank != null ? `현재 값은 축적된 ${hist.length}일 중 하위 ${pctRank}% 수준입니다` : `10일 이상 축적되면 백분위가 표시됩니다 (현재 ${hist.length}일)`}</div>
3099            </div>
3100            <div style="margin-top:16px">
3101              <div style="font-size:11px;font-weight:800;color:var(--muted);margin-bottom:4px">이 지표의 현재 시그널</div>
3102              ${relatedSignals.length ? relatedSignals.map(sg => `
3103                <div class="sig-feed-row">
3104                  <span class="sig-feed-time">${sg.time}</span>
3105                  <span class="sig-sev" style="background:${sg.sev}"></span>
3106                  <span class="sig-feed-text">${sg.text}</span>
3107                </div>`).join('') : '<div style="padding:9px 0;color:var(--muted-2);font-size:12px">현재 발생한 시그널이 없습니다</div>'}
3108            </div>
3109          </div>`;
3110        return;
3111      }
3112
3113      const gaugeGrad = state.colorblind
3114        ? 'linear-gradient(90deg,#0e8fa6 0%,var(--cyan) 26%,var(--neutral) 50%,var(--orange) 74%,#e0662a 100%)'
3115        : 'linear-gradient(90deg,var(--down-2) 0%,var(--down) 26%,var(--neutral) 50%,var(--up) 74%,var(--up-2) 100%)';
3116      const ticks = [['10', '극단공포'], ['30', '공포'], ['50', '중립'], ['70', '과열'], ['90', '극단과열']];
3117      const order = Object.keys(t.inds).filter(id => id !== 'expiry' && id !== 'idxdd').sort((a, b) => Math.abs(t.inds[b].c) - Math.abs(t.inds[a].c));
3118      const maxAbs = Math.max(1, ...order.map(k => Math.abs(t.inds[k].c)));
3119      const signals = thermoSignals(t);
3120      const deltaTxt = t.delta == null ? '' : (t.delta >= 0 ? '▲' : '▼') + Math.abs(t.delta);
3121      const isHistory = state.signalsTab === 'history';
3122
3123      const contribHtml = order.map(id => {
3124        const ind = t.inds[id];
3125        const pos = ind.c >= 0;
3126        return `<button class="sig-contrib-row" data-sig-detail="${id}" data-tip="${IND_META[id].desc}" type="button">
3127          <div class="sig-contrib-top">
3128            <span style="font-weight:650;color:var(--text-2)">${IND_META[id].name}</span>
3129            <span style="color:var(--muted-2);font-size:11.5px">${fmtVal(id, ind.val)}</span>
3130            <div style="flex:1"></div>
3131            <span style="font-weight:750;color:${pos ? t.upC : t.downC}">${(ind.c > 0 ? '+' : '') + ind.c.toFixed(0)}</span>
3132          </div>
3133          <div class="sig-contrib-bar">
3134            <span class="mid"></span>
3135            <span class="fill" style="${pos ? 'left' : 'right'}:50%;width:${Math.abs(ind.c) / maxAbs * 46}%;background:${pos ? t.upC : t.downC}"></span>
3136          </div>
3137        </button>`;
3138      }).join('');
3139
3140      const allCards = t.market === 'us'
3141        ? ['mom', 'adrUs', 'vix', 'dxy', 'yc', 'gs', 'soxUs']
3142        : ['mom', 'frgn', 'pension', 'adr', 'vix', 'fx', 'yc', 'gs', 'sox', 'credit', 'deposit', 'expiry'];
3143      const cardsHtml = allCards.map(id => {
3144        const ind = t.inds[id];
3145        const meta = IND_META[id];
3146        if (!ind) {
3147          return `<div class="sig-card" data-tip="${meta.desc}">
3148            <div class="sig-card-cat"><span>${meta.cat}</span><span class="sig-dot" style="background:var(--line)"></span></div>
3149            <div class="sig-card-name" style="color:var(--muted-2)">${meta.name}</div>
3150            <div class="sig-skel" style="width:70%"></div>
3151            <div class="sig-skel" style="width:45%"></div>
3152            <span class="sig-collect">${can('indicatorDetail') ? '데이터 준비 중' : 'Pro에서 제공'}</span>
3153          </div>`;
3154        }
3155        const dc = ind.c >= 0 ? t.upC : t.downC;
3156        const deltaHtml = id === 'vix' || id === 'fx' || id === 'dxy'
3157          ? `<small style="color:${(ind.delta || 0) >= 0 ? t.upC : t.downC}">${(ind.delta || 0) >= 0 ? '▲' : '▼'}${Math.abs(ind.delta || 0).toFixed(2)}%</small>`
3158          : id === 'frgn' && ind.streak >= 2
3159            ? `<small style="color:${dc}">${ind.streak}일째 ${ind.days[ind.days.length - 1].amt > 0 ? '순매수' : '순매도'}</small>`
3160            : '';
3161        return `<button class="sig-card" data-sig-detail="${id}" data-tip="${meta.desc}" type="button">
3162          <div class="sig-card-cat"><span>${meta.cat}</span><span class="sig-dot" style="background:${dc}"></span></div>
3163          <div class="sig-card-name">${meta.name}</div>
3164          <div class="sig-card-val"><strong>${fmtVal(id, ind.val)}</strong>${deltaHtml}</div>
3165          <div class="sig-card-viz">${sigCardViz(id, ind, lockedBand)}</div>
3166        </button>`;
3167      }).join('');
3168
3169      const feedHtml = signals.map((sg, i) => `
3170        <div class="sig-feed-row" style="${lockedFeed && i > 0 ? 'filter:blur(4px)' : ''}">
3171          <span class="sig-feed-time">${sg.time}</span>
3172          <span class="sig-sev" style="background:${sg.sev}"></span>
3173          <div style="flex:1">
3174            <span class="sig-feed-text">${sg.text}</span>
3175            <div>${sg.tags.map(tag => `<span class="sig-tag">${tag}</span>`).join('')}</div>
3176          </div>
3177        </div>`).join('');
3178
3179      let historyHtml = '';
3180      if (isHistory) {
3181        const days = thermoHist().slice(-90);
3182        const scorePath = sigSparkPath(days.map(h => h.score), 424, 140);
3183        const kospiVals = days.map(h => h.kospi).filter(Boolean);
3184        const kospiPath = sigSparkPath(kospiVals, 424, 140);
3185        const hi = days.length ? Math.max(...days.map(h => h.score)) : null;
3186        const lo = days.length ? Math.min(...days.map(h => h.score)) : null;
3187        historyHtml = `<div class="sig-section" style="position:relative">
3188          <div style="${lockedHistory ? 'filter:blur(4px);pointer-events:none' : ''}">
3189            <div class="sig-chart-box">
3190              <div style="display:flex;justify-content:space-between;margin-bottom:8px">
3191                <span style="font-size:11px;font-weight:650;color:var(--muted)">90일 온도 · ${t.market === 'us' ? 'S&P500' : 'KOSPI'}</span>
3192                <span style="font-size:10px;color:var(--muted-2)">축적 ${days.length}일</span>
3193              </div>
3194              ${scorePath ? `<svg viewBox="0 0 424 140" style="width:100%;height:140px;display:block">
3195                <rect x="0" y="0" width="424" height="22" fill="${t.upC}" opacity="0.07"/>
3196                <rect x="0" y="118" width="424" height="22" fill="${t.downC}" opacity="0.07"/>
3197                ${kospiPath ? `<path d="${kospiPath}" fill="none" stroke="var(--neutral)" stroke-width="1.4" opacity="0.7"/>` : ''}
3198                <path d="${scorePath}" fill="none" stroke="${t.color}" stroke-width="1.9"/>
3199              </svg>` : `<div style="padding:40px 0;text-align:center;color:var(--muted-2);font-size:12px">온도 히스토리는 사용 시작일부터 매일 축적됩니다<br>(현재 ${days.length}일)</div>`}
3200            </div>
3201            <div class="sig-stats">
3202              <div class="sig-stat"><div class="k">30일 평균</div><div class="v">${t.avg30 ?? '-'}</div></div>
3203              <div class="sig-stat"><div class="k">90일 최고</div><div class="v">${hi ?? '-'}</div></div>
3204              <div class="sig-stat"><div class="k">90일 최저</div><div class="v">${lo ?? '-'}</div></div>
3205            </div>
3206          </div>
3207          ${lockedHistory ? `<div class="sig-lock" style="inset:16px">
3208            <div class="sig-lock-icon">🔒</div>
3209            <p>90일 온도 추이와 지수 관계는 Pro에서 제공돼요</p>
3210            <button class="primary" id="sigStartPro" type="button">Pro 시작하기</button>
3211          </div>` : ''}
3212        </div>`;
3213      }
3214
3215      panel.innerHTML = `
3216        <div class="sig-head">
3217          <span style="color:${t.color}">🌡</span><span>시장 시그널</span>
3218          <span class="sig-tag" style="margin:0" data-tip="보고 있는 지수 화면 기준으로 국장(KOSPI)/미장(S&P500·나스닥) 온도가 자동 전환됩니다">${t.market === 'us' ? '미국 시장' : '한국 시장'}</span>
3219          <div style="flex:1"></div>
3220          <button class="icon-btn" id="signalsClose" type="button" aria-label="닫기">×</button>
3221        </div>
3222        <div class="sig-tabs">
3223          <button class="sig-tab ${!isHistory ? 'active' : ''}" data-sig-tab="current" type="button">현재</button>
3224          <button class="sig-tab ${isHistory ? 'active' : ''}" data-sig-tab="history" type="button">히스토리 <span class="pro-pill">PRO</span></button>
3225        </div>
3226        <div class="sig-body">
3227        ${isHistory ? historyHtml : `
3228          <div class="sig-hero">
3229            <div class="sig-score-row">
3230              <span class="sig-score" style="color:${t.color}">${t.score}</span>
3231              <span style="font-size:15px;font-weight:750;color:${t.color};padding-bottom:3px">${t.label}</span>
3232              <span style="font-size:12px;font-weight:750;color:${tempColor(50 + (t.delta || 0))};padding-bottom:4px">${deltaTxt}</span>
3233            </div>
3234            <div class="sig-band" style="background:${gaugeGrad}">
3235              <span class="sig-band-pin" style="left:${clamp(t.score, 2, 98)}%"></span>
3236            </div>
3237            <div class="sig-ticks">${ticks.map(([left, label]) => `<span style="left:${left}%">${label}</span>`).join('')}</div>
3238            <div style="margin-top:8px;font-size:12px;color:var(--muted)">${t.delta != null ? `전일 ${t.score - t.delta} → 오늘 ${t.score}` : '오늘부터 히스토리 축적'}${t.avg30 != null ? ` · 30일 평균 ${t.avg30}` : ''} · 지표 ${Object.keys(t.inds).length}/${allCards.length}</div>
3239          </div>
3240          <div class="sig-section">
3241            <div class="sig-sec-title">코멘트 ${!can('commentary') ? '<span class="pro-pill">PRO</span>' : ''}</div>
3242            ${thermoComment(t).map((c, i) => `<p style="margin:${i ? '8px' : '0'} 0 0;font-size:13px;line-height:1.75;color:var(--text-2);${!c.free && !can('commentary') ? 'filter:blur(4px)' : ''}">${c.text}</p>`).join('')}
3243            ${!can('commentary') ? '<div style="margin-top:10px;font-size:11.5px;color:var(--muted-2)">요인 분석 코멘트는 Pro에서 제공됩니다</div>' : ''}
3244          </div>
3245          <div class="sig-section">
3246            <div class="sig-sec-title">기여도 분해 <span class="pro-pill">PRO</span>
3247              <div style="flex:1"></div>
3248              <span style="font-size:10.5px;color:var(--muted-2)">냉각</span><span style="width:8px;height:8px;border-radius:2px;background:${t.downC}"></span>
3249              <span style="width:8px;height:8px;border-radius:2px;background:${t.upC};margin-left:4px"></span><span style="font-size:10.5px;color:var(--muted-2)">가열</span>
3250            </div>
3251            <div class="${lockedContrib ? 'sig-blur' : ''}">${contribHtml}</div>
3252            ${lockedContrib ? `<div class="sig-lock">
3253              <div class="sig-lock-icon">🔒</div>
3254              <p>어떤 지표가 온도를 움직였는지 Pro에서 분해해 보여드려요</p>
3255              <button class="primary" id="sigStartPro" type="button">Pro 시작하기</button>
3256            </div>` : ''}
3257          </div>
3258          <div class="sig-section" style="border-bottom:0">
3259            <div class="sig-sec-title">지표 <span style="color:var(--muted-2);font-weight:600">${Object.keys(t.inds).length}/${allCards.length}</span></div>
3260            <div class="sig-grid">${cardsHtml}</div>
3261          </div>
3262          ${thermoMarket() === 'us' ? '' : renderInvestorSection()}
3263          ${thermoMarket() === 'us' ? renderUsSectorSection() : renderSectorSection()}
3264          <div class="sig-section" style="border-top:1px solid var(--line)">
3265            <div class="sig-sec-title">시그널 피드 ${lockedFeed ? '<span class="pro-pill">PRO</span>' : ''}</div>
3266            ${feedHtml}
3267            ${lockedFeed && (t.signalsTotal || signals.length) > signals.length ? `<button class="secondary" id="sigStartPro2" type="button" style="width:100%;margin-top:10px">🔒 시그널 ${(t.signalsTotal || signals.length) - signals.length}건 더보기 — Pro</button>` : ''}
3268            <div class="sig-disclaimer">본 정보는 투자 판단의 참고 자료이며 투자 권유가 아닙니다.</div>
3269          </div>
3270        `}
3271        </div>`;
3272    }
3273
3274    // --- Per-stock signals: daily candles (fchart) + meta (integration) + rules + drawer ---
3275
3276    function yyyymmdd(d) {
3277      return d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate();
3278    }
3279
3280    function stockCacheTtl() {
3281      // 장중에는 30분, 장외에는 12시간 — 밸류에이션·일봉이 장중에 묵지 않도록
3282      return marketSession().krxOpen ? 30 * 60 * 1000 : dailyTtlMs;
3283    }
3284
3285    function saveDailyCache(code, daily) {
3286      state.dailyCache[code] = daily;
3287      // 캐시 상한: 오래된 것부터 정리 (localStorage 용량 보호)
3288      const keys = Object.keys(state.dailyCache);
3289      if (keys.length > 90) {
3290        keys.sort((a, b) => state.dailyCache[a].savedAt - state.dailyCache[b].savedAt)
3291          .slice(0, keys.length - 90).forEach(k => delete state.dailyCache[k]);
3292      }
3293      try { localStorage.setItem(dailyStorageKey, JSON.stringify(state.dailyCache)); } catch {}
3294    }
3295
3296    // 일봉 1년치: 국내 = fchart, 미국 = api.stock.naver.com foreign/item (로이터 코드)
3297    async function fetchStockDaily(code, force = false) {
3298      const cached = state.dailyCache[code];
3299      if (!force && cached && Date.now() - cached.savedAt < stockCacheTtl()) return cached;
3300      const end = new Date();
3301      const start = new Date(end.getTime() - 372 * 86400000);
3302      let rows;
3303      if (isKoreanCode(code)) {
3304        const url = `https://fchart.stock.naver.com/siseJson.naver?symbol=${code}&requestType=1&startTime=${yyyymmdd(start)}&endTime=${yyyymmdd(end)}&timeframe=day`;
3305        const response = await fetch(routed(url), { signal: AbortSignal.timeout(10000) });
3306        if (!response.ok) throw new Error('HTTP ' + response.status);
3307        rows = JSON.parse((await response.text()).replace(/'/g, '"'))
3308          .filter(r => /^\d{8}$/.test(String(r[0])))
3309          .map(r => ({ localDate: String(r[0]), openPrice: r[1], highPrice: r[2], lowPrice: r[3], closePrice: r[4], accumulatedTradingVolume: r[5] }));
3310      } else {
3311        // 미국: 로이터 코드(캐시) 우선, 없으면 나스닥(.O) → 원심볼 순으로 시도
3312        const candidates = [...new Set([state.usCodeCache[code], code + '.O', code].filter(
3312Boolean))];
3313        for (const reuters of candidates) {
3314          try {
3315            rows = await fetchJson(`https://api.stock.naver.com/chart/foreign/item/${encodeURIComponent(reuters)}/day?startDateTime=${yyyymmdd(start)}&endDateTime=${yyyymmdd(end)}`);
3316            if (Array.isArray(rows) && rows.length >= 2) {
3317              if (state.usCodeCache[code] !== reuters) {
3318                state.usCodeCache[code] = reuters;
3319                localStorage.setItem(usCodeCacheKey, JSON.stringify(state.usCodeCache));
3320              }
3321              break;
3322            }
3323            rows = null;
3324          } catch { rows = null; }
3325        }
3326      }
3327      if (!rows || rows.length < 2) throw new Error('일봉 데이터 없음');
3328      const daily = {
3329        dates: rows.map(r => String(r.localDate)),
3330        opens: rows.map(r => Number(r.openPrice)),
3331        highs: rows.map(r => Number(r.highPrice)),
3332        lows: rows.map(r => Number(r.lowPrice)),
3333        closes: rows.map(r => Number(r.closePrice)),
3334        volumes: rows.map(r => Number(r.accumulatedTradingVolume)),
3335        savedAt: Date.now()
3336      };
3337      saveDailyCache(code, daily);
3338      return daily;
3339    }
3340
3341    async function fetchStockMeta(code, force = false) {
3342      const cached = state.stockMetaCache[code];
3343      if (!force && cached && Date.now() - cached.savedAt < stockCacheTtl()) return cached;
3344      const meta = isKoreanCode(code) ? await fetchDomesticStockMeta(code) : await fetchUsStockMeta(code);
3345      state.stockMetaCache[code] = meta;
3346      const keys = Object.keys(state.stockMetaCache);
3347      if (keys.length > 80) {
3348        keys.sort((a, b) => state.stockMetaCache[a].savedAt - state.stockMetaCache[b].savedAt)
3349          .slice(0, keys.length - 80).forEach(k => delete state.stockMetaCache[k]);
3350      }
3351      localStorage.setItem(stockMetaStorageKey, JSON.stringify(state.stockMetaCache));
3352      return meta;
3353    }
3354
3355    async function fetchDomesticStockMeta(code) {
3356      const data = await fetchJson(`https://m.stock.naver.com/api/stock/${code}/integration`);
3357      const byKey = k => (data.totalInfos || []).find(t => t.key === k)?.value;
3358      const isEtf = data.stockEndType === 'etf';
3359      return {
3360        name: data.stockName || code,
3361        isEtf,
3362        high52: parseNaverNumber(byKey('52주 최고')),
3363        low52: parseNaverNumber(byKey('52주 최저')),
3364        per: byKey('PER') || '-',
3365        pbr: byKey('PBR') || '-',
3366        divYield: byKey('배당수익률') || '-',
3367        marketCap: byKey('시총') || '-',
3368        frgnRate: byKey('외인소진율') || '-',
3369        // ETF 전용 필드 (일반 종목은 undefined)
3370        nav: byKey('NAV') || '-',
3371        fee: byKey('펀드보수') || '-',
3372        baseIndex: byKey('기초지수') || '-',
3373        manager: byKey('운용사') || '-',
3374        ret1m: byKey('최근 1개월 수익률') || '-',
3375        ret1y: byKey('최근 1년 수익률') || '-',
3376        // 컨센서스 (애널리스트 목표주가·투자의견 평균)
3377        targetMean: parseNaverNumber(data.consensusInfo?.priceTargetMean),
3378        recommMean: parseNaverNumber(data.consensusInfo?.recommMean),
3379        consensusAsOf: String(data.consensusInfo?.createDate || ''),
3380        // 동종업계 비교 (상위 4)
3381        peers: (data.industryCompareInfo || []).slice(0, 4).map(p => ({
3382          c: String(p.itemCode || ''),
3383          n: String(p.stockName || ''),
3384          price: parseNaverNumber(p.closePrice),
3385          chg: (() => {
3386            let r = parseNaverNumber(p.fluctuationsRatio);
3387            const cmp = String(p.compareToPreviousPrice?.code || '');
3388            if ((cmp === '4' || cmp === '5') && r > 0) r = -r;
3389            return r;
3390          })()
3391        })).filter(p => p.c),
3392        savedAt: Date.now()
3393      };
3394    }
3395
3396    // 미국 종목: worldstock basic 엔드포인트(로이터 코드)로 밸류에이션을 채운다.
3397    // 도메인(api.stock.naver.com)은 /api/q 프록시 허용 목록에 있어 웹/임베드에서도 routed()로 동작.
3398    // basic 응답 키는 국내 integration과 동일(52주 최고/최저·PER·PBR·시총·배당수익률)이라 매핑이 대칭.
3399    async function fetchUsStockMeta(code) {
3400      // 로이터 코드 후보 순서는 fetchStockDaily와 동일: 캐시 → 나스닥(.O) → 원심볼.
3401      // 네이버 로이터 코드는 나스닥이 "AAPL.O", NYSE 등은 접미사 없는 원심볼("JPM","XOM")이라
3402      // 원심볼 후보가 NYSE 콜드 케이스를 커버한다. (".N"은 네이버에서 매칭되지 않아 제외)
3403      const candidates = [...new Set([state.usCodeCache[code], code + '.O', code].filter(Boolean))];
3404      let data = null, usedReuters = null;
3405      for (const reuters of candidates) {
3406        try {
3407          const res = await fetchJson(`https://api.stock.naver.com/stock/${encodeURIComponent(reuters)}/basic`);
3408          if (res && Array.isArray(res.stockItemTotalInfos) && res.stockItemTotalInfos.length) {
3409            data = res;
3410            usedReuters = reuters;
3411            break;
3412          }
3413        } catch { /* 다음 후보 시도 */ }
3414      }
3415      if (!data) throw new Error('미국 종목 메타 없음');
3416      // 성공한 로이터 코드를 캐시에 반영 (fetchStockDaily와 동일한 소스 공유)
3417      if (usedReuters && state.usCodeCache[code] !== usedReuters) {
3418        state.usCodeCache[code] = usedReuters;
3419        localStorage.setItem(usCodeCacheKey, JSON.stringify(state.usCodeCache));
3420      }
3421      // 컨센서스(애널리스트 목표주가·투자의견)는 basic엔 없어 별도 엔드포인트(USD 표기). 실패 시 무시.
3422      // 국내와 동일한 meta.targetMean/recommMean/consensusAsOf 필드로 매핑 → 같은 렌더러가 소비.
3423      let targetMean = null, recommMean = null, consensusAsOf = '';
3424      try {
3425        const cons = await fetchJson(`https://api.stock.naver.com/stock/${encodeURIComponent(usedReuters)}/consensus`);
3426        targetMean = parseNaverNumber(cons?.priceTargetMean) || null;
3427        recommMean = parseNaverNumber(cons?.recommMean) || null;
3428        consensusAsOf = String(cons?.createDate || '');
3429      } catch { /* 컨센서스 없는 종목/ETF → 기본값 유지 */ }
3430      const byKey = k => (data.stockItemTotalInfos || []).find(t => t.key === k)?.value;
3431      const isEtf = String(data.stockEndType || '').toLowerCase() === 'etf';
3432      return {
3433        name: data.stockName || data.stockNameEng || code, // 한국어 종목명 우선
3434        isEtf,
3435        high52: parseNaverNumber(byKey('52주 최고')),
3436        low52: parseNaverNumber(byKey('52주 최저')),
3437        per: byKey('PER') || '-',
3438        pbr: byKey('PBR') || '-',
3439        eps: byKey('EPS') || undefined, // 미국 전용 — 렌더러에서 값이 있을 때만 표시
3440        bps: byKey('BPS') || undefined,
3441        divYield: byKey('배당수익률') || '-',
3442        marketCap: byKey('시총') || '-',
3443        frgnRate: '-', // 미국: 외인소진율 개념 없음
3444        // ETF/동종업계 정보는 basic 엔드포인트에 없음 → 기본값(렌더러가 부재를 처리)
3445        nav: '-', fee: '-', baseIndex: '-', manager: '-', ret1m: '-', ret1y: '-',
3446        targetMean, // USD 목표주가 평균 (consensus 엔드포인트)
3447        recommMean,
3448        consensusAsOf,
3449        peers: [],
3450        savedAt: Date.now()
3451      };
3452    }
3453
3454    function sma(values, n, at) {
3455      const idx = at == null ? values.length - 1 : at;
3456      if (idx + 1 < n) return null;
3457      let sum = 0;
3458      for (let i = idx - n + 1; i <= idx; i++) sum += values[i];
3459      return sum / n;
3460    }
3461
3462    // 낙폭과대 추세전환 신호 튜닝 상수 (게이트 + 확인신호 임계값) — 값만 바꿔 조정
3463    const REVERSAL_DRAWDOWN_GATE = 0.30;   // 52주 고점 대비 하락률 게이트 (30%)
3464    const REVERSAL_FLOW_WINDOW = 5;        // 수급 전환 판정 구간 (거래일)
3465    const REVERSAL_VOL_SURGE = 1.8;        // 거래량 급증 배수 (최신 vs 20일 평균)
3466    const REVERSAL_HOLD_REBOUND_PP = 0.3;  // 보유율 반등 최소 폭 (%p, Pro 시리즈 필요)
3467    const REVERSAL_MIN_CONFIRMS = 2;       // 게이트 외 최소 확인신호 수
3468
3469    // 종목 시그널 규칙 — 사실 서술만, 판단·권유 표현 금지
3470    function computeStockSignals(code, daily, meta, flow) {
3471      const out = [];
3472      const warn = 'var(--warn)';
3473      const info = 'var(--muted)';
3474      const fmtP = v => isKoreanCode(code) ? money(v) : moneyByCurrency(v, 'USD'); // 미국 종목은 $ 표기
3475      const push = (sev, cat, text) => out.push({ sev, cat, text });
3476      const closes = daily?.closes || [];
3477      const last = closes[closes.length - 1];
3478
3479      if (flow?.frgnDays?.length) {
3480        const days = flow.frgnDays.slice().sort((a, b) => a.d < b.d ? -1 : 1);
3481        let streak = 0;
3482        const lastSign = days[days.length - 1].amt > 0;
3483        for (let i = days.length - 1; i >= 0 && (days[i].amt > 0) === lastSign && Math.abs(days[i].amt) > 1; i--) streak++;
3484        if (streak >= 3) push(streak >= 5 ? warn : info, '수급', `외국인 ${streak}일 연속 ${lastSign ? '순매수' : '순매도'} (10일 누적 ${formatEok(days.reduce((s, d) => s + d.amt, 0))})`);
3485        const instSum = days.reduce((s, d) => s + (d.inst || 0), 0);
3486        if (Math.abs(instSum) >= 500) push(info, '수급', `기관 10일 누적 ${instSum > 0 ? '순매수' : '순매도'} ${formatEok(Math.abs(instSum))}`);
3487      }
3488
3489      if (closes.length >= 21 && last > 0) {
3490        const ma20 = sma(closes, 20);
3491        const ma20prev = sma(closes, 20, closes.length - 2);
3492        const prev = closes[closes.length - 2];
3493        if (ma20 && prev <= ma20prev && last > ma20) push(info, '가격', `20일 이동평균선 상향 돌파 (${fmtP(Math.round(ma20))})`);
3494        if (ma20 && prev >= ma20prev && last < ma20) push(warn, '가격', `20일 이동평균선 하향 이탈 (${fmtP(Math.round(ma20))})`);
3495        if (closes.length >= 61) {
3496          const ma60 = sma(closes, 60);
3497          const ma20p = sma(closes, 20, closes.length - 4);
3498          const ma60p = sma(closes, 60, closes.length - 4);
3499          if (ma20 && ma60 && ma20p != null && ma60p != null) {
3500            if (ma20p <= ma60p && ma20 > ma60) push(info, '가격', '골든크로스 발생 (20일선이 60일선 상회, 최근 3거래일 내)');
3501            if (ma20p >= ma60p && ma20 < ma60) push(warn, '가격', '데드크로스 발생 (20일선이 60일선 하회, 최근 3거래일 내)');
3502          }
3503        }
3504        const gap = (daily.opens[daily.opens.length - 1] - prev) / prev * 100;
3505        if (Math.abs(gap) >= 3) push(warn, '변동성', `${gap > 0 ? '갭 상승' : '갭 하락'} ${Math.abs(gap).toFixed(1)}% 출발`);
3506      }
3507
3508      if (meta?.high52 && last > 0 && last >= meta.high52 * 0.97) push(info, '가격', `52주 신고가 3% 이내 (최고 ${fmtP(meta.high52)})`);
3509      if (meta?.low52 && last > 0 && last <= meta.low52 * 1.03) push(warn, '가격', `52주 신저가 3% 이내 (최저 ${fmtP(meta.low52)})`);
3510
3511      if (daily?.volumes?.length >= 21) {
3512        const vol = daily.volumes[daily.volumes.length - 1];
3513        const avg = sma(daily.volumes.slice(0, -1), 20);
3514        if (avg && vol >= avg * 3) push(warn, '거래량', `거래량 20일 평균 대비 ${(vol / avg).toFixed(1)}배 급증`);
3515      }
3516
3517      // 낙폭과대 · 수급 전환 후보: 게이트(고점 대비 -30%↑) + 확인신호 2개 이상.
3518      // 스캔 루프에 이미 있는 데이터(daily·meta·flow·로드된 Pro 시리즈)만 사용 — 추가 fetch 없음.
3519      if (meta?.high52 > 0 && last > 0) {
3520        const drawdown = (meta.high52 - last) / meta.high52;
3521        if (drawdown >= REVERSAL_DRAWDOWN_GATE) {
3522          const confirms = [];
3523          if (flow?.frgnDays?.length >= REVERSAL_FLOW_WINDOW * 2) {
3524            const fd = flow.frgnDays.slice().sort((a, b) => a.d < b.d ? -1 : 1); // 오름차순
3525            const last5 = fd.slice(-REVERSAL_FLOW_WINDOW);
3526            const prev5 = fd.slice(-REVERSAL_FLOW_WINDOW * 2, -REVERSAL_FLOW_WINDOW);
3527            const frgn5 = last5.reduce((s, d) => s + (d.amt || 0), 0);
3528            const prevF5 = prev5.reduce((s, d) => s + (d.amt || 0), 0);
3529            const inst5 = last5.reduce((s, d) =>
3529 s + (d.inst || 0), 0);
3530            if (frgn5 > 0 && prevF5 < 0) confirms.push(`외국인 5일 순매수 전환 ${formatEok(frgn5)}`); // (a)
3531            if (inst5 > 0) confirms.push(`기관 5일 순매수 ${formatEok(inst5)}`); // (b)
3532          }
3533          if (daily?.volumes?.length >= 21) { // (c)
3534            const vol = daily.volumes[daily.volumes.length - 1];
3535            const avg = sma(daily.volumes.slice(0, -1), 20);
3536            if (avg > 0 && vol >= avg * REVERSAL_VOL_SURGE) confirms.push(`거래량 ${(vol / avg).toFixed(1)}배`);
3537          }
3538          if (closes.length >= 21) { // (d) MA20 상향 돌파
3539            const ma20 = sma(closes, 20), ma20prev = sma(closes, 20, closes.length - 2), prev = closes[closes.length - 2];
3540            if (ma20 && ma20prev != null && prev < ma20prev && last > ma20) confirms.push('MA20 상향 돌파');
3541          }
3542          // (e) 보유율 반등 — Pro 시리즈(stockFlowCache에 r 포함)가 이미 로드된 경우만, 옵셔널
3543          const proRows = Object.keys(state.stockFlowCache)
3544            .filter(k => k.startsWith(code + ':'))
3545            .map(k => state.stockFlowCache[k]?.rows)
3546            .find(r => Array.isArray(r) && r.some(x => x.r != null));
3547          if (proRows) {
3548            const rs = proRows.map(x => x.r).filter(v => v != null);
3549            if (rs.length >= 3) {
3550              const minR = Math.min(...rs), lastR = rs[rs.length - 1];
3551              if (lastR >= minR + REVERSAL_HOLD_REBOUND_PP) confirms.push(`보유율 반등 +${(lastR - minR).toFixed(1)}%p`);
3552            }
3553          }
3554          if (confirms.length >= REVERSAL_MIN_CONFIRMS) {
3555            // 맨 앞에 배치 — 낙폭과대 종목에서 가장 결정적인 신호 (무료 티저 후보)
3556            out.unshift({ sev: info, cat: '반등후보', text: `고점 대비 -${(drawdown * 100).toFixed(0)}% · ${confirms.join(', ')} — 수급이 돌아서는 정황입니다 (추세 전환을 보장하지 않습니다)` });
3557          }
3558        }
3559      }
3560
3561      return out;
3562    }
3563
3564    // 단일/더블 클릭 중재: 둘 다 액션이 있는 타일(etfcat·etf)은 단일클릭을 ~300ms 지연시키고,
3565    // 그 사이 더블클릭이 오면 취소한다 → 더블클릭 시 패널이 잠깐 떴다 사라지는 플래시 방지.
3566    const CLICK_ARBITRATION_MS = 300;
3567    let pendingClickTimer = null, pendingClickKey = null;
3568    function clearPendingClick() {
3569      if (pendingClickTimer) clearTimeout(pendingClickTimer);
3570      pendingClickTimer = null;
3571      pendingClickKey = null;
3572    }
3573
3574    // 지도 타일과 리스트 행 공용 클릭/더블클릭 배선 — 두 뷰가 갈라지지 않도록 단일 소스.
3575    // etfcat/etf는 단일클릭을 300ms 중재(더블클릭 오면 취소)하고, 나머지는 즉시 단일 액션.
3576    function wireDualClick(el, item) {
3577      el.addEventListener('click', () => {
3578        clearPendingClick(); // 다른 타일/행 클릭 시 대기 중이던 단일클릭 액션 취소
3579        const runSingle = () => {
3580          if (item.kind === 'etfcat') openCategoryThermo(item.n);
3581          else if (['stock', 'holding', 'cons', 'etf'].includes(item.kind) && dailyFetchable(String(item.c || ''))) openStockPanel(String(item.c));
3582        };
3583        if (item.kind === 'etfcat' || item.kind === 'etf') {
3584          const key = item.kind + ':' + (item.c || item.n); // 재렌더 후 stale 방지 가드
3585          pendingClickKey = key;
3586          pendingClickTimer = setTimeout(() => { if (pendingClickKey === key) { clearPendingClick(); runSingle(); } }, CLICK_ARBITRATION_MS);
3587        } else {
3588          runSingle();
3589        }
3590      });
3591      el.addEventListener('dblclick', () => {
3592        clearPendingClick(); // 대기 중 단일클릭(패널 오픈) 취소 → 플래시 없음
3593        if (item.kind === 'etfcat') {
3594          if (state.catThermo) closeCategoryThermo(); // 온도 패널 열려 있으면 닫고 드릴
3595          state.etfCat = item.n;
3596          hidePopover();
3597          render();
3598          toast(`${item.n} ETF ${item.count}개를 펼쳤습니다`);
3599        } else if (item.kind === 'etf') {
3600          const etf = activeEtfs().find(e => e.n === item.n);
3601          if (!etf) return;
3602          if (state.stockPanel) state.stockPanel = null; // 싱글클릭으로 열린 상세 패널 닫고 드릴
3603          if (etf.cons?.length) {
3604            state.drill = item.n;
3605            hidePopover();
3606            render();
3607            toast(`${item.n}
3607 구성종목을 펼쳤습니다`);
3608          } else {
3609            hidePopover();
3610            render(); // 상세 패널 닫고 상위 뷰 유지하며 구성종목 로드
3611            toast(`${item.n} 구성종목을 불러오는 중입니다`);
3612            loadEtfCons(etf).then(ok => {
3613              if (ok && etf.cons?.length) {
3614                state.drill = item.n;
3615                render();
3616                toast(`${item.n} 구성종목을 펼쳤습니다`);
3617              } else {
3618                toast('구성 정보를 제공하지 않는 ETF입니다');
3619              }
3620            }).catch(() => toast('구성종목 로드 실패 · 다시 시도해 주세요'));
3621          }
3622        }
3623      });
3624    }
3625
3626    // 현재 화면 상위 종목의 1년 일봉을 저속 프리페치 (타일 스파크·52주 위치·팝오버 차트용)
3627    const prefetchedIndexes = new Set();
3628
3629    async function prefetchVisibleDaily() {
3630      if (state.tab !== 'market' || prefetchedIndexes.has(state.index)) return;
3631      prefetchedIndexes.add(state.index);
3632      const codes = marketItems().slice()
3633        .sort((a, b) => (b.m || 0) - (a.m || 0))
3634        .map(item => String(item.c || ''))
3635        .filter(code => dailyFetchable(code) && !state.dailyCache[code])
3636        .slice(0, 30);
3637      for (const group of chunkArray(codes, 3)) {
3638        await Promise.allSettled(group.map(code => fetchStockDaily(code).catch(() => {})));
3639        await sleep(260);
3640      }
3641      if (codes.length) renderMap(); // 큰 타일 스파크라인 반영
3642    }
3643
3644    // 그래프를 실제로 그리는 큰 타일(area>5200) 중 일봉이 없는 종목만 뒤이어 프리페치.
3645    // prefetchVisibleDaily의 상위 30개 상한과 달리, 화면 크기에 맞춰 "보이는데 비어있는" 그래프를 정확히 채운다.
3646    let graphPrefetchRun = 0;
3647    const graphPrefetchAttempted = new Set();
3648    async function prefetchGraphTiles(codes) {
3649      const targets = [...new Set(codes)].filter(code => dailyFetchable(code) && !state.dailyCache[code] && !graphPrefetchAttempted.has(code));
3650      if (!targets.length) return;
3651      const run = ++graphPrefetchRun;
3652      let done = 0;
3653      for (const group of chunkArray(targets, 3)) {
3654        if (run !== graphPrefetchRun) return; // 화면 전환 시 이전 루프 중단 (미시도 종목은 다음 렌더가 재수집)
3655        group.forEach(code => graphPrefetchAttempted.add(code)); // 실제 요청 직전에만 시도 표시
3656        // 실패는 세션 내 재시도하지 않음(렌더 폭주 방지) — 재시도는 hover 경로가 담당
3657        await Promise.allSettled(group.map(code => fetchStockDaily(code).then(() => { done++; }).catch(() => {})));
3658        await sleep(260);
3659      }
3660      if (done) renderMap(); // 스파크라인 반영
3661    }
3662
3663    // Pro: 관심·보유·상위 종목 백그라운드 스캔 → 타일 점 + 팝오버
3664    async function scanStockSignals() {
3665      if (!can('stockScan') || state.stockScanDone) return;
3666      state.stockScanDone = true;
3667      const holdingCodes = portfolio().items.map(h => String(h.ref?.c || '')).filter(isKoreanCode);
3668      const codes = [...new Set([...state.watchlistSymbols.filter(isKoreanCode), ...holdingCodes, ...thermoTopCodes])];
3669      // 새로고침 직후 트래픽과 겹치지 않도록 잠시 대기 후 저속 스캔
3670      await sleep(4000);
3671      await ensureFlows(codes);
3672      let found = 0, reversal = 0;
3673      for (const group of chunkArray(codes, 3)) {
3674        await Promise.allSettled(group.map(async code => {
3675          const [daily, meta] = await Promise.all([fetchStockDaily(code), fetchStockMeta(code).catch(() => null)]);
3676          const sigs = computeStockSignals(code, daily, meta, state.flowCache[code]);
3677          state.stockSignalCache[code] = { sigs, savedAt: Date.now() };
3678          if (sigs.length) found++;
3679          if (sigs.some(s => s.cat === '반등후보')) reversal++;
3680        }));
3681        await sleep(220);
3682      }
3683      renderMap();
3684      toast(`종목 시그널 스캔 완료 · 시그널 ${found}종목${reversal ? ` (반등후보 ${reversal}종목)` : ''}`);
3685    }
3686
3687    function stockPanelChart(daily, w = 424, h = 150) {
3688      const closes = daily.closes.slice(-90);
3689      const offset = daily.closes.length - closes.length;
3690      const all = closes.slice();
3691      const ma20s = [];
3692      const ma60s = [];
3693      closes.forEach((_, i) => {
3694        const m20 = sma(daily.closes, 20, offset + i);
3695        const m60 = sma(daily.closes, 60, offset + i);
3696        if (m20 != null) { ma20s.push(m20); all.push(m20); }
3697        if (m60 != null) { ma60s.push(m60); all.push(m60); }
3698      });
3699      const min = Math.min(...all);
3700      const max = Math.max(...all);
3701      const range = max - min || 1;
3702      const path = arr => arr.map((v, i) => {
3703        const x = 6 + (w - 12) * ((closes.length - arr.length + i) / Math.max(1, closes.length - 1));
3704        const y = h - 8 - (h - 16) * ((v - min) / range);
3705        return (i ? 'L' : 'M') + x.toFixed(1) + ' ' + y.toFixed(1);
3706      }).join(' ');
3707      return { price: path(closes), ma20: ma20s.length >= 2 ? path(ma20s) : null, ma60: ma60s.length >= 2 ? path(ma60s) : null };
3708    }
3709
3710    // 종목 장기 수급 시리즈 (Pro) — 서버 fl:{code}에서 최근 N일. {d,f,i} → 차트 형식 {d,amt,inst}로 매핑.
3711    async function fetchStockFlowSeries(code, days) {
3712      const key = code + ':' + days;
3713      try {
3714        const res = await fetch(LICENSE_SERVER + '/api/market/stockflow', {
3715          method: 'POST',
3716          headers: { 'Content-Type': 'application/json' },
3717          body: JSON.stringify({ token: state.license?.token, code, days }),
3718          signal: AbortSignal.timeout(12000)
3719        });
3720        if (!res.ok) throw new Error('HTTP ' + res.status);
3721        const data = await res.json();
3722        const rows = Array.isArray(data.series) ? data.series.map(x => ({ d: x.d, amt: x.f, inst: x.i, r: x.r })) : null;
3723        state.stockFlowCache[key] = { rows, at: Date.now() };
3724      } catch {
3725        state.stockFlowCache[key] = { rows: null, at: Date.now() };
3726      }
3727      if (state.stockPanel === code) renderStockPanel();
3728    }
3729
3730    async function openStockPanel(code, force = false) {
3731      state.stockPanel = code;
3732      state.stockFlowDays = 10; // 새 종목은 무료 10일부터 시작
3733      state.signalsOpen = false;
3734      state.stockPanelLoading = true;
3735      hidePopover();
3736      renderStockPanel();
3737      try {
3738        const cachedFlow = state.flowCache[code];
3739        // 구버전 캐시(기관 데이터·10일 이력 없음)는 신선해도 다시 받는다
3740        const flowShapeOk = cachedFlow?.frgnDays?.length >= 8 && cachedFlow.frgnDays[0]?.inst !== undefined;
3741        const flowFresh = cachedFlow && flowShapeOk && Date.now() - cachedFlow.savedAt < flowTtlMs;
3742        await Promise.allSettled([
3743          fetchStockDaily(code, force),
3744          fetchStockMeta(code, force).catch(() => null),
3745          !force && flowFresh
3746            ? Promise.resolve()
3747            : fetchFlow(code).then(flow => { if (flow) { state.flowCache[code] = flow; localStorage.setItem(flowStorageKey, JSON.stringify(state.flowCache)); } }),
3748          // 현재가·등락률도 함께 갱신
3749          fetchNaverDomestic([code]).then(map => { const q = map.get(code); if (q) applyFreeQuote(code, q); }).catch(() => {}),
3750          fetchStockNews(code).catch(() => {})
3751        ]);
3752        const daily = state.dailyCache[code];
3753        if (daily) {
3754          const sigs = computeStockSignals(code, daily, state.stockMetaCache[code], state.flowCache[code]);
3755          state.stockSignalCache[code] = { sigs, savedAt: Date.now() };
3756        }
3757      } catch {}
3758      state.stockPanelLoading = false;
3759      if (state.stockPanel === code) {
3760        renderStockPanel();
3761        if (force) toast('종목 데이터를 다시 불러왔습니다');
3762      }
3763    }
3764
3765    function renderStockPanel() {
3766      const panel = $('signalsPanel');
3767      const backdrop = $('signalsBackdrop');
3768      if (!panel || !backdrop || !state.stockPanel) return;
3769      const code = state.stockPanel;
3770      const ref = lookup(code);
3771      const meta = state.stockMetaCache[code];
3772      const daily = state.dailyCache[code];
3773      const flow = state.flowCache[code];
3774      const sigs = state.stockSignalCache[code]?.sigs || [];
3775      const lockedSigs = !can('stockSignalsAll');
3776      const lockedMa = !can('stockMa');
3777      const name = meta?.name || ref?.n || code;
3778      const price = state.priceOverrides[code] || ref?.p || (daily ? daily.closes[daily.closes.length - 1] : 0);
3779      const chg = state.prevCloseCache[code]?.lastChange ?? ref?.d ?? 0;
3780      const upC = state.colorblind ? 'var(--orange)' : 'var(--up)';
3781      const downC = state.colorblind ? 'var(--cyan)' : 'var(--down)';
3782      // 가격 표기: 국내는 원, 미국은 $ — 히어로/52주밴드/컨센서스가 공유 (US 목표가 USD 대응)
3783      const fmtPrice = v => isKoreanCode(code) ? money(v) : moneyByCurrency(v, 'USD');
3784
3785      panel.classList.add('open');
3786      backdrop.classList.add('show');
3787
3788      if (state.stockPanelLoading && !daily) {
3789        panel.innerHTML = `<div class="sig-head"><span>${name}</span><div style="flex:1"></div><button class="icon-btn" id="stockPanelClose" type="button">×</button></div>
3790          <div class="sig-body" style="padding:40px 20px;text-align:center;color:var(--muted)"><div class="spinner"></div>종목 데이터를 불러오는 중…</div>`;
3791        return;
3792      }
3793
3794      // 52주 밴드 (+ 고점 대비 낙폭 병기)
3795      let bandHtml = '';
3796      if (meta?.high52 && meta?.low52 && meta.high52 > meta.low52 && price > 0) {
3797        const pos = clamp((price - meta.low52) / (meta.high52 - meta.low52) * 100, 2, 98);
3798        const dd = Math.min(0, (price - meta.high52) / meta.high52 * 100);
3799        bandHtml = `<div style="margin-top:10px">
3800          <div style="display:flex;justify-content:space-between;font-size:9.5px;color:var(--muted-2);margin-bottom:3px"><span>52주 최저 ${fmtPrice(meta.low52)}</span><span style="font-weight:700;color:${dd <= -30 ? downC : 'var(--muted-2)'}">고점 대비 ${dd.toFixed(1)}%</span><span>최고 ${fmtPrice(meta.high52)}</span></div>
3801          <div class="sig-miniband" style="background:linear-gradient(90deg,${downC},var(--neutral) 50%,${upC})"><span style="left:${pos}%"></span></div>
3802        </div>`;
3803      }
3804
3805      // 시그널 목록 (무료: 1건 + 블러)
3806      const sigsHtml = sigs.length ? sigs.map((sg, i) => `
3807        <div class="sig-feed-row" style="${lockedSigs && i > 0 ? 'filter:blur(4px)' : ''}">
3808          <span class="sig-tag" style="margin:0;flex:0 0 auto">${sg.cat}</span>
3809          <span class="sig-sev" style="background:${sg.sev}"></span>
3810          <span class="sig-feed-text">${sg.text}</span>
3811        </div>`).join('')
3812        : `<div style="padding:9px 0;color:var(--muted-2);font-size:12px">현재 발생한 시그널이 없습니다</div>`;
3813
3814      // 차트
3815      let chartHtml = '<div style="padding:24px 0;text-align:center;color:var(--muted-2);font-size:12px">일봉 데이터를 불러오지 못했습니다<br><button class="secondary" data-stock-retry="1" type="button" style="margin-top:10px">다시 불러오기</button></div>';
3816      if (daily) {
3817        const paths = stockPanelChart(daily);
3818        chartHtml = `<svg viewBox="0 0 424 150" style="width:100%;height:150px;display:block">
3819          ${!lockedMa && paths.ma60 ? `<path d="${paths.ma60}" fill="none" stroke="#8a63d2" stroke-width="1.2" opacity="0.8"/>` : ''}
3820          ${!lockedMa && paths.ma20 ? `<path d="${paths.ma20}" fill="none" stroke="var(--warn)" stroke-width="1.2" opacity="0.9"/>` : ''}
3821          <path d="${paths.price}" fill="none" stroke="${chg >= 0 ? upC : downC}" stroke-width="1.8"/>
3822        </svg>
3823        <div style="display:flex;gap:12px;font-size:10px;color:var(--muted-2);margin-top:4px">
3824          <span>— 종가 (90일)</span>
3825          ${lockedMa ? '<span>이동평균선 <span class="pro-pill">PRO</span></span>' : '<span style="color:var(--warn)">— MA20</span><span style="color:#8a63d2">— MA60</span>'}
3826        </div>`;
3827      }
3828
3829      // 수급 차트 (개인·외인/기관) — 기간: 10일(무료, frgn.naver) · 1개월/3개월(Pro, 서버 fl:{code}).
3830      // 개인은 소스에 없어 -(외국인+기관)으로 추정한다 (기타법인 미분리 → 근사값).
3831      const flowPeriod = isKoreanCode(code) ? (state.stockFlowDays || 10) : 10;
3832      let flowRows = null, flowState = '';
3833      if (flowPeriod === 10) {
3834        flowRows = flow?.frgnDays?.length ? flow.frgnDays : null; // {d, amt, inst}
3835        if (!flowRows) flowState = 'nodata';
3836      } else {
3837        const c = state.stockFlowCache[code + ':' + flowPeriod]; // Pro 서버 시리즈 (1시간 캐시)
3838        if (c && Date.now() - c.at < 3600000) { if (c.rows?.length) flowRows = c.rows; else flowState = 'nolong'; }
3839        else flowState = 'loading';
3840      }
3841      // 실제 로드된 행의 날짜 범위 (MM.DD ~ MM.DD) — 헤더에 표시 (짧은 서버 시리즈는 실제 범위 반영)
3842      let flowRangeLabel = '';
3843      if (flowRows?.length) {
3844        const dts = flowRows.map(r => r.d).filter(d => /^\d{8}$/.test(d));
3845        if (dts.length) {
3846          const md = d => `${d.slice(4, 6)}.${d.slice(6, 8)}`;
3847          flowRangeLabel = `${md(dts.reduce((a, b) => a < b ? a : b))} ~ ${md(dts.reduce((a, b) => a > b ? a : b))}`;
3848        }
3849      }
3850      let flowHtml;
3851      if (flowState === 'loading') flowHtml = '<div style="padding:16px 0;text-align:center;color:var(--muted-2);font-size:12px">장기 수급 불러오는 중…</div>';
3852      else if (flowState === 'nolong') flowHtml = '<div style="padding:12px 0;color:var(--muted-2);font-size:12px">이 종목은 장기 수급 데이터가 없습니다</div>';
3853      else if (!flowRows) flowHtml = '<div style="padding:12px 0;color:var(--muted-2);font-size:12px">수급 데이터 없음 <button class="ghost-btn" data-stock-retry="1" type="button" style="margin-left:8px">다시 불러오기</button></div>';
3854      else {
3855        const days = flowRows.slice().sort((a, b) => a.d < b.d ? -1 : 1);
3856        const indivOf = d =>
3856 -(d.amt + (d.inst || 0)); // 개인(추정) = 외국인+기관의 반대값
3857        const maxAbs = Math.max(1, ...days.map(d => Math.max(Math.abs(d.amt), Math.abs(d.inst || 0), Math.abs(indivOf(d)))));
3858        const W = 424;
3859        const H = 96;
3860        const mid = H / 2;
3861        const scale = (mid - 12) / maxAbs;
3862        const slot = W / days.length;
3863        const barW = Math.min(9, slot * 0.24); // 3주체를 나란히 배치하므로 폭을 좁힌다
3864        const frgnSum = days.reduce((s, d) => s + d.amt, 0);
3865        const instSum = days.reduce((s, d) => s + (d.inst || 0), 0);
3866        const indivSum = -(frgnSum + instSum);
3867        // estimate=true 면 개인(추정) — 채움 대신 테두리 막대로 그려 근사값임을 시각적으로 구분
3868        const bar = (i, v, offset, opacity, estimate) => {
3869          if (!Number.isFinite(v)) return '';
3870          const h = Math.max(1.5, Math.abs(v) * scale);
3871          const x = i * slot + slot / 2 + offset;
3872          const y = v >= 0 ? mid - h : mid;
3873          const c = v >= 0 ? upC : downC;
3874          const style = estimate
3875            ? `fill="${c}" fill-opacity="0.16" stroke="${c}" stroke-width="1" stroke-opacity="0.8"`
3876            : `fill="${c}" opacity="${opacity}"`;
3877          return `<rect x="${(x - barW / 2).toFixed(1)}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}" height="${h.toFixed(1)}" rx="1.5" ${style}/>`;
3878        };
3879        const dateLabel = d => d.length === 8 ? `${Number(d.slice(4, 6))}/${Number(d.slice(6, 8))}` : d;
3880        // 외국인 보유율 vs 주가 오버레이 (Pro 기간만) — 보유율 축적된 날짜만 라인, 주가는 dailyCache에서 날짜 매칭.
3881        let overlayHtml = '';
3882        if (flowPeriod > 10) {
3883          const closeByDate = {};
3884          (daily?.dates || []).forEach((dt, k) => { closeByDate[dt] = daily.closes[k]; });
3885          const ratePts = days.map((d, i) => d.r != null ? { i, v: d.r } : null).filter(Boolean);
3886          const pricePts = days.map((d, i) => { const p = closeByDate[d.d]; return p > 0 ? { i, v: p } : null; }).filter(Boolean);
3887          if (ratePts.length >= 2) {
3888            const OW = 424, OH = 60, padY = 9, span2 = Math.max(1, days.length - 1);
3889            const rateColor = '#5b9bf0', priceColor = '#c8a24a';
3890            const lineFor = (pts, color) => {
3891              const vs = pts.map(p => p.v), mn = Math.min(...vs), rng = (Math.max(...vs) - mn) || 1;
3892              const poly = pts.map(p => `${(p.i / span2 * OW).toFixed(1)},${(OH - padY - (p.v - mn) / rng * (OH - 2 * padY)).toFixed(1)}`).join(' ');
3893              return `<polyline points="${poly}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>`;
3894            };
3895            const hover = days.map((d, i) => {
3896              const p = closeByDate[d.d], w = OW / days.length;
3897              return `<rect x="${(i * w).toFixed(1)}" y="0" width="${w.toFixed(1)}" height="${OH}" fill="transparent"><title>${d.d} · 보유율 ${d.r != null ? d.r + '%' : '-'} · 종가 ${p > 0 ? money(p) : '-'}</title></rect>`;
3898            }).join('');
3899            overlayHtml = `
3900            <div style="display:flex;align-items:center;gap:10px;margin-top:12px;font-size:10px;color:var(--muted-2)">
3901              <span style="font-weight:800;color:var(--text-2)">외국인 보유율 vs 주가</span>
3902              <span><span style="display:inline-block;width:11px;height:2px;background:${rateColor};vertical-align:2px"></span> 보유율(%)</span>
3903              <span><span style="display:inline-block;width:11px;height:2px;background:${priceColor};vertical-align:2px"></span> 주가</span>
3904            </div>
3905            <svg viewBox="0 0 ${OW} ${OH}" style="width:100%;display:block;margin-top:3px">
3906              ${pricePts.length >= 2 ? lineFor(pricePts, priceColor) : ''}
3907              ${lineFor(ratePts, rateColor)}
3908              ${hover}
3909            </svg>
3910            <div style="font-size:9px;color:var(--muted-2);margin-top:2px">보유율은 주가와의 동행 지표이며, 인과를 뜻하지 않습니다.</div>`;
3911          } else {
3912            overlayHtml = '<div style="font-size:10.5px;color:var(--muted-2);margin-top:12px">외국인 보유율 데이터 축적 중입니다 · 다음 갱신부터 표시됩니다</div>';
3913          }
3914        }
3915        flowHtml = `
3916        <div style="display:flex;gap:8px;margin-bottom:10px">
3917          <div class="sig-stat" style="flex:1"><div class="k">외국인 ${days.length}일 누적</div><div class="v" style="color:${frgnSum >= 0 ? upC : downC}">${formatEok(frgnSum)}</div></div>
3918          <div class="sig-stat" style="flex:1"><div class="k">기관 ${days.length}일 누적</div><div class="v" style="color:${instSum >= 0 ? upC : downC}">${formatEok(instSum)}</div></div>
3919          <div class="sig-stat" style="flex:1"><div class="k">개인(추정) 누적</div><div class="v" style="color:${indivSum >= 0 ? upC : downC}">${formatEok(indivSum)}</div></div>
3920        </div>
3921        <svg viewBox="0 0 ${W} ${H}" style="width:100%;display:block">
3922          <line x1="0" y1="${mid}" x2="${W}" y2="${mid}" stroke="var(--line)" stroke-width="1"/>
3923          <text x="2" y="12" fill="var(--muted-2)" font-size="9">순매수 ↑</text>
3924          <text x="2" y="${H - 5}" fill="var(--muted-2)" font-size="9">순매도 ↓</text>
3925          ${days.map((d, i) => `
3926            ${bar(i, d.amt, -barW, 1)}
3927            ${bar(i, d.inst, 0, 0.45)}
3928            ${bar(i, indivOf(d), barW, 0.9, true)}
3929            <rect x="${(i * slot).toFixed(1)}" y="0" width="${slot.toFixed(1)}" height="${H}" fill="transparent"><title>${d.d} · 외국인 ${formatEok(d.amt)} · 기관 ${formatEok(d.inst || 0)} · 개인(추정) ${formatEok(indivOf(d))}</title></rect>
3930          `).join('')}
3931        </svg>
3932        <div style="display:flex;justify-content:space-between;font-size:9px;color:var(--muted-2);margin-top:2px"><span>${dateLabel(days[0].d)}</span><span>${dateLabel(days[days.length - 1].d)}</span></div>
3933        <div style="display:flex;gap:10px;flex-wrap:wrap;font-size:10px;color:var(--muted-2);margin-top:6px">
3934          <span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:${upC};vertical-align:-1px"></span> 외국인</span>
3935          <span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;background:${upC};opacity:0.45;vertical-align:-1px"></span> 기관</span>
3936          <span><span style="display:inline-block;width:8px;height:8px;border-radius:2px;border:1px solid ${upC};background:transparent;vertical-align:-1px"></span> 개인(추정)</span>
3937        </div>
3938        <div style="font-size:9.5px;color:var(--muted-2);margin-top:5px">개인(추정) = 외국인+기관 순매수의 반대값 (기타법인 제외한 근사)</div>
3939        ${overlayHtml}`;
3940      }
3941
3942      const statRow = (k, v) => `<div class="sig-stat"><div class="k">${k}</div><div class="v" style="font-size:13px">${v}</div></div>`;
3943
3944      // ETF 구성종목 비중 섹션 — loadEtfCons 캐시/페치 재사용 (etfAnalysis 1회). 국내 종목행은 클릭 시 패널.
3945      // 국내 ETF만 (etfAnalysis는 국내 엔드포인트라 미국 ETF는 데이터 없음 → 섹션 생략).
3946      const etfRef = (meta?.isEtf && isKoreanCode(code)) ? etfRefForPanel(code) : null;
3947      const consWeightHtml = (() => {
3948        if (!etfRef) return '';
3949        if (etfRef.consDetail?.length) {
3950          const det = etfRef.consDetail;
3951          const maxW = Math.max(1, ...det.map(d => d.w));
3952          const rows = det.map(d => {
3953            const clickable = /^\d{6}$/.test(d.c);
3954            const barW = Math.min(100, d.w / maxW * 100);
3955            const inner = `<span class="etf-cons-n">${d.n}</span><span class="etf-cons-bar"><span style="width:${barW.toFixed(1)}%"></span></span><span class="etf-cons-w">${d.w.toFixed(1)}%</span>`;
3956            return clickable
3957              ? `<button class="etf-cons-row" data-open-stock="${d.c}" type="button">${inner}</button>`
3958              : `<div class="etf-cons-row nolink">${inner}</div>`;
3959          }).join('');
3960          const restRow = etfRef.consRest > 0.5 ? `<div class="etf-cons-rest">기타 ${etfRef.consRest.toFixed(1)}%</div>` : '';
3961          const sectorLine = etfRef.sectors?.length
3962            ? `<div class="etf-sector-line">섹터 · ${etfRef.sectors.slice(0, 3).map(s => `${etfSectorLabel(s.code)} ${s.w.toFixed(0)}%`).join(' · ')}</div>`
3963            : '';
3964          return `<div class="etf-cons-wrap"><div class="sig-sec-title" style="font-size:12px">구성종목 비중</div>${rows}${restRow}${sectorLine}</div>`;
3965        }
3966        if (etfRef._consNone) return ''; // 구성 데이터 없는 ETF → 섹션 숨김
3967        loadEtfCons(etfRef).then(() =>
3967 { if (state.stockPanel === code) renderStockPanel(); }).catch(() => {});
3968        return `<div class="etf-cons-wrap"><div class="sig-sec-title" style="font-size:12px">구성종목 비중</div><div style="padding:6px 0;color:var(--muted-2);font-size:12px">구성 정보를 불러오는 중…</div></div>`;
3969      })();
3970
3971      const savedAt = Math.max(daily?.savedAt || 0, flow?.savedAt || 0, meta?.savedAt || 0);
3972      const agoLabel = savedAt ? (() => {
3973        const min = Math.round((Date.now() - savedAt) / 60000);
3974        return min < 1 ? '방금 갱신' : min < 60 ? min + '분 전 갱신' : Math.floor(min / 60) + '시간 전 갱신';
3975      })() : '';
3976      panel.innerHTML = `
3977        <div class="sig-head">
3978          <span>${name}</span><span style="color:var(--muted-2);font-size:11px;font-weight:600">${code}</span>
3979          <div style="flex:1"></div>
3980          <span style="color:var(--muted-2);font-size:10.5px;font-weight:600">${agoLabel}</span>
3981          <button class="icon-btn" id="stockPanelRefresh" type="button" aria-label="다시 불러오기" ${state.stockPanelLoading ? 'disabled' : ''}>${state.stockPanelLoading ? '…' : '↻'}</button>
3982          <button class="icon-btn" id="stockPanelClose" type="button" aria-label="닫기">×</button>
3983        </div>
3984        <div class="sig-body">
3985          <div class="sig-hero" style="padding-bottom:14px">
3986            <div style="display:flex;align-items:baseline;gap:9px">
3987              <span class="sig-score" style="font-size:26px;color:var(--text)">${price ? fmtPrice(price) : '-'}</span>
3988              <span style="font-size:14px;font-weight:750;color:${chg >= 0 ? upC : downC}">${pct(chg)}</span>
3989            </div>
3990            ${bandHtml}
3991          </div>
3992          <div class="sig-section">
3993            <div class="sig-sec-title">시그널 <span style="color:var(--muted-2);font-weight:600">${sigs.length}</span> ${lockedSigs && sigs.length > 1 ? '<span class="pro-pill">PRO</span>' : ''}</div>
3994            ${sigsHtml}
3995            ${lockedSigs && sigs.length > 1 ? `<button class="secondary" id="sigStartPro" type="button" style="width:100%;margin-top:10px">🔒 시그널 ${sigs.length - 1}건 더보기</button>` : ''}
3996          </div>
3997          ${isKoreanCode(code) ? `<div class="sig-section">
3998            <div class="sig-sec-title">🔔 목표가 알림 ${can('alerts') ? '' : '<span class="pro-pill">PRO</span>'}</div>
3999            <div style="display:flex;gap:8px;align-items:center">
4000              <input id="alertAbove" placeholder="상한가" inputmode="numeric" value="${state.alertsCfg.targets?.[code]?.above || ''}" style="flex:1;min-width:0;height:34px;border-radius:7px;border:1px solid var(--line);background:var(--field);color:var(--text);padding:0 10px;outline:none">
4001              <input id="alertBelow" placeholder="하한가" inputmode="numeric" value="${state.alertsCfg.targets?.[code]?.below || ''}" style="flex:1;min-width:0;height:34px;border-radius:7px;border:1px solid var(--line);background:var(--field);color:var(--text);padding:0 10px;outline:none">
4002              <button class="secondary" id="alertTargetSave" type="button">저장</button>
4003            </div>
4004            <div class="sig-note" style="margin-top:6px">도달 시 시스템 알림 1회 후 자동 해제 · 5분 감시${state.alertsCfg.enabled ? '' : ' · <span style="color:var(--warn)">설정에서 알림을 켜야 동작합니다</span>'}</div>
4005          </div>` : ''}
4006          <div class="sig-section">
4007            <div class="sig-sec-title">가격 · 이동평균</div>
4008            <div class="sig-chart-box">${chartHtml}</div>
4009          </div>
4010          ${meta?.targetMean && price > 0 ? (() => {
4011            const gap = (meta.targetMean - price) / price * 100;
4012            const lockedCons = !can('consensus');
4013            return `<div class="sig-section">
4014              <div class="sig-sec-title">컨센서스 ${lockedCons ? '<span class="pro-pill">PRO</span>' : `<span style="font-size:10px;color:var(--muted-2);font-weight:600">${meta.consensusAsOf} 기준</span>`}</div>
4015              <div style="${lockedCons ? 'filter:blur(4px)' : ''}">
4016                <div style="display:flex;align-items:baseline;gap:10px">
4017                  <span style="font-size:18px;font-weight:850">${fmtPrice(meta.targetMean)}</span>
4018                  <span style="font-size:13px;font-weight:750;color:${gap >= 0 ? upC : downC}">현재가 대비 ${gap > 0 ? '+' : ''}${gap.toFixed(1)}%</span>
4019                </div>
4020                <div class="sig-note" style="margin-top:5px">애널리스트 목표주가 평균${Number.isFinite(meta.recommMean) ? ` · 투자의견 ${meta.recommMean.toFixed(2)}/5.0` : ''} — 참고 지표이며 실제 주가를 보장하지 않습니다</div>
4021              </div>
4022              ${lockedCons ? '<div style="margin-top:8px;font-size:11.5px;color:var(--muted-2)">목표주가 컨센서스는 Pro에서 제공됩니다</div>' : ''}
4023            </div>`;
4024          })() : ''}
4025          ${meta?.peers?.length ? `<div class="sig-section">
4026            <div class="sig-sec-title">동종업계</div>
4027            ${meta.peers.map(p => `
4028              <button class="sig-contrib-row" data-open-stock="${p.c}" type="button" style="display:flex;align-items:baseline;gap:8px">
4029                <span style="font-weight:650;color:var(--text-2)">${p.n}</span>
4030                <span style="flex:1"></span>
4031                <span style="color:var(--muted-2);font-size:11.5px">${p.price ? money(p.price) : '-'}</span>
4032                <span style="font-weight:750;min-width:56px;text-align:right;color:${(p.chg || 0) >= 0 ? upC : downC}">${pct(p.chg)}</span>
4033              </button>`).join('')}
4034          </div>` : ''}
4035          ${isKoreanCode(code) ? `<div class="sig-section">
4036            <div class="sig-sec-title" style="display:flex;align-items:center;gap:8px">수급${
4037              flowRangeLabel ? ` <span style="font-size:10px;font-weight:600;color:var(--muted-2)">${flowRangeLabel}</span>` : ''
4038            }<div class="flow-period-seg">${
4039                [[10, '10일'], [21, '1개월'], [63, '3개월']].map(([d, lbl]) =>
4040                  `<button class="flow-period-btn ${flowPeriod === d ? 'active' : ''}" data-flow-days="${d}" type="button">${lbl}${d > 10 && !can('flowLong') ? ' <span class="pro-pill">PRO</span>' : ''}</button>`
4041                ).join('')
4042              }</div></div>
4043            ${flowHtml}
4044          </div>` : ''}
4045          <div class="sig-section">
4046            <div class="sig-sec-title">뉴스 ${!isKoreanCode(code) ? '<span style="font-size:10px;color:var(--muted-2);font-weight:600">로이터 한국어</span>' : ''}</div>
4047            ${(stockNewsCache[code]?.items || []).length
4048              ? stockNewsCache[code].items.map(article => `
4049                <a href="${article.url}" target="_blank" rel="noopener" style="display:block;padding:8px 0;border-top:1px solid var(--panel-2);text-decoration:none">
4050                  <div style="font-size:12.5px;color:var(--text-2);line-height:1.5">${article.title}</div>
4051                  <div style="font-size:10.5px;color:var(--muted-2);margin-top:3px">${article.press} · ${newsTimeLabel(article.dt)}</div>
4052                </a>`).join('')
4053              : '<div style="padding:8px 0;color:var(--muted-2);font-size:12px">최근 뉴스가 없습니다</div>'}
4054          </div>
4055          <div class="sig-section" style="border-bottom:0">
4056            <div class="sig-sec-title">${meta?.isEtf ? 'ETF 정보' : '밸류에이션'}</div>
4057            <div class="sig-stats" style="grid-template-columns:1fr 1fr 1fr">
4058              ${meta?.isEtf
4059                ? statRow('NAV', meta.nav) + statRow('펀드보수', meta.fee) + statRow('1개월 수익률', meta.ret1m)
4060                  + statRow('1년 수익률', meta.ret1y) + statRow('운용사', meta.manager) + statRow('기초지수', meta.baseIndex)
4061                : statRow('PER', meta?.per || '-') + statRow('PBR', meta?.pbr || '-')
4062                  + (meta?.eps ? statRow('EPS', meta.eps) : '') + (meta?.bps ? statRow('BPS', meta.bps) : '')
4063                  + statRow('배당수익률', meta?.divYield || '-')
4064                  + statRow('시가총액', meta?.marketCap || '-') + statRow('외인소진율', meta?.frgnRate || '-') + statRow('데이터', daily ? daily.dates.length + '일' : '-')}
4065            </div>
4066            ${consWeightHtml}
4067            ${meta?.isEtf && activeEtfs().some(e => e.c === code) ? `<button class="secondary" id="etfConsDrill" data-etf-code="${code}" type="button" style="width:100%;margin-top:10px">구성종목 펼쳐보기</button>` : ''}
4068            <div class="sig-disclaimer">본 정보는 투자 판단의 참고 자료이며 투자 권유가 아닙니다.</div>
4069          </div>
4070        </div>`;
4071    }
4072
4073    // --- 보유 종목 수동 편집기 (드로어 재사용) ---
4074    function openPfEditor() {
4075      state.pfDraft = (state.myHoldings || []).map(h => ({ ...h }));
4076      state.pfEditorOpen = true;
4077      state.signalsOpen = false;
4078      state.stockPanel = null;
4079      hidePopover();
4080      renderPfEditor();
4081    }
4082
4083    function closePfEditor() {
4084      state.pfEditorOpen = false;
4085      $('signalsPanel')?.classList.remove('open');
4086      $('signalsBackdrop')?.classList.remove('show');
4087    }
4088
4089    function renderPfEditor() {
4090      const panel = $('signalsPanel');
4091      const backdrop = $('signalsBackdrop');
4092      if (!panel || !backdrop) return;
4093      panel.classList.add('open');
4094      backdrop.classList.add('show');
4095      const draft = state.pfDraft || [];
4096      const rows = draft.map((h, i) => {
4097        const ref = lookup(h.c);
4098        return `<div style="display:flex;gap:6px;align-items:center;padding:7px 0;border-top:1px solid var(--panel-2)">
4099          <div style="flex:1.2;min-width:0"><div style="font-size:12.5px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${ref?.n || h.n || h.c}</div><div style="font-size:10px;color:var(--muted-2)">${h.c}</div></div>
4100          <input data-pf-qty="${i}" value="${h.qty || ''}" placeholder="수량" inputmode="decimal" style="flex:0.7;min-width:0;height:32px;border-radius:6px;border:1px solid var(--line);background:var(--field);color:var(--text);padding:0 8px;outline:none;text-align:right">
4101          <input data-pf-avg="${i}" value="${h.avg || ''}" placeholder="평단가" inputmode="decimal" style="flex:1;min-width:0;height:32px;border-radius:6px;border:1px solid var(--line);background:var(--field);color:var(--text);padding:0 8px;outline:none;text-align:right">
4102          <button class="watch-remove" data-pf-del="${i}" type="button" aria-label="삭제">×</button>
4103        </div>`;
4104      }).join('');
4105      panel.innerHTML = `
4106        <div class="sig-head">
4107          <span>보유 종목 편집</span>
4108          <div style="flex:1"></div>
4109          <button class="icon-btn" id="pfEditorClose" type="button" aria-label="닫기">×</button>
4110        </div>
4111        <div class="sig-body" style="padding:16px">
4112          ${state.myHoldings === null ? '<div class="sig-note" style="font-size:11.5px;margin-bottom:12px;color:var(--warn)">지금 보이는 포트폴리오는 샘플입니다 — 실제 보유 종목을 입력하면 교체됩니다.</div>' : ''}
4113          <div class="sig-sec-title">종목 추가</div>
4114          <div style="display:flex;gap:6px">
4115            <input id="pfAddSymbol" placeholder="심볼·종목명 (예: 005930, 삼성전자, AAPL)" autocomplete="off" spellcheck="false" style="flex:1.4;min-width:0;height:34px;border-radius:7px;border:1px solid var(--line);background:var(--field);color:var(--text);padding:0 10px;outline:none">
4116            <input id="pfAddQty" placeholder="수량" inputmode="decimal" style="flex:0.7;min-width:0;height:34px;border-radius:7px;border:1px solid var(--line);background:var(--field);color:var(--text);padding:0 8px;outline:none;text-align:right">
4117            <input id="pfAddAvg" placeholder="평단가" inputmode="decimal" style="flex:1;min-width:0;height:34px;border-radius:7px;border:1px solid var(--line);background:var(--field);color:var(--text);padding:0 8px;outline:none;text-align:right">
4118            <button class="secondary" id="pfAddBtn" type="button">추가</button>
4119          </div>
4120          <div class="sig-sec-title" style="margin-top:18px">보유 목록 <span style="color:var(--muted-2);font-weight:600">${draft.length}</span></div>
4121          ${rows || '<div style="padding:10px 0;color:var(--muted-2);font-size:12.5px">아직 종목이 없습니다 — 위에서 추가해 주세요.</div>'}
4122          <button class="primary" id="pfSave" type="button" style="width:100%;margin-top:16px">저장</button>
4123          <div class="sig-note" style="margin-top:8px">저장하면 포트폴리오 히트맵·집중도·ETF 중복 노출 분석에 바로 반영됩니다. 데이터는 이 기기에만 저장됩니다.</div>
4124        </div>`;
4125    }
4126
4127    function pfAddFromInputs() {
4128      const raw = ($('pfAddSymbol')?.value || '').trim();
4129      const qty = Number(($('pfAddQty')?.value || '').replace(/[^\d.]/g, ''));
4130      const avg = Number(($('pfAddAvg')?.value || '').replace(/[^\d.]/g, ''));
4131      if (!raw) { toast('심볼 또는 종목명을 입력해 주세요'); return; }
4132      if (!(qty > 0)) { toast('수량을 입력해 주세요'); return; }
4133      const ref = lookup(raw) || lookup(raw.toUpperCase());
4134      const code = String(ref?.c || raw.toUpperCase());
4135      if (!dailyFetchable(code)) { toast('지원하지 않는 심볼 형식입니다'); return; }
4136      if (state.pfDraft.some(h => h.c === code)) { toast('이미 목록에 있는 종목입니다 — 수량을 수정해 주세요'); return; }
4137      state.pfDraft.push({ c: code, n: ref?.n, qty, avg });
4138      renderPfEditor();
4139      setTimeout(() => $('pfAddSymbol')?.focus(), 50);
4140    }
4141
4142    function pfSaveDraft() {
4143      state.myHoldings = state.pfDraft.filter(h => h.c && h.qty > 0);
4144      state.pfEmpty = state.myHoldings.length === 0;
4145      saveMyHoldings();
4146      closePfEditor();
4147      state.tab = 'portfolio';
4148      toast(`보유 종목 ${state.myHoldings.length}건 저장됨`);
4149      render();
4150      // 새로 추가된 종목의 시세를 바로 갱신
4151      refreshVisiblePrices({ silent: true }).then(render).catch(() => {});
4152    }
4153
4154    function renderControls() {
4155      $('periodSegment').innerHTML = ['1D', '1W', '1M', 'YTD'].map(p => `<button class="seg-btn ${state.period === p ? 'active' : ''}" data-period="${p}" type="button">${p}</button>`).join('');
4156      $('basisSegment').innerHTML = [['day', '당일'], ['total', '총수익']].map(([k, t]) => `<button class="seg-btn ${state.pfBasis === k ? 'active' : ''}" data-basis="${k}" type="button">${t}</button>`).join('');
4157      $('viewModeSegment').innerHTML = [['heatmap', '맵'], ['list', '리스트']].map(([k, t]) => `<button class="seg-btn ${state.viewMode === k ? 'active' : ''}" data-view-mode="${k}" type="button">${t}</button>`).join('');
4158      // ETF 분석 탭에서만 카테고리/운용사 그룹핑 seg 노출
4159      const etfGroupSeg = $('etfGroupSegment');
4160      if (etfGroupSeg) {
4161        const showEtfGroup = state.tab === 'etf';
4162        etfGroupSeg.style.display = showEtfGroup ? '' : 'none';
4163        if (showEtfGroup) etfGroupSeg.innerHTML = [['cat', '카테고리'], ['brand', '운용사']].map(([k, t]) => `<button class="seg-btn ${state.etfGroup === k ? 'active' : ''}" data-etf-group="${k}" type="button">${t}</button>`).join('');
4164      }
4165      $('indexLabel').textContent = state.index;
4166      const flowSuffix = flowPeriodLabel();
4167      $('colorLabel').textContent = { change: '등락률', '52w': '52주 위치', peak: '고점 대비', frgn: `외국인 순매수(${flowSuffix})`, inst: `기관 순매수(${flowSuffix})`, ind: `개인 순매수(${flowSuffix})` }[state.colorBasis] || '등락률';
4168      $('viewBtn').textContent = state.sideMode ? '새 탭' : '사이드패널';
4169      // Embedded (extension shell iframe): keep the view switch — new tab opens via bridge.
4170      $('viewBtn').style.display = (IS_WEB && !bridge.active) ? 'none' : '';
4171      $('importNow').textContent = state.tab === 'portfolio' ? '잔고 불러오기' : '시장 새로고침';
4172      $('refreshFloating').textContent = state.tab === 'portfolio' ? '잔고 새로고침' : '시장 새로고침';
4173
4174      const status = $('statusBtn');
4175      status.className = 'status ' + (state.dataMode === 'live' ? 'live' : ['delayed', 'api', 'connected', 'free'].includes(state.dataMode) ? 'delayed' : '');
4176      $('statusText').textContent = dataModeText();
4177
4178      $('tabs').innerHTML = [['market', '전체시장'], ['portfolio', '내 포트폴리오'], ['etf', 'ETF 분석'], ['custom', '내 히트맵']]
4179        .map(([k, t]) => `<button class="tab ${state.tab === k ? 'active' : ''}" data-tab="${k}" type="button">${t}</button>`).join('');
4180
4181      $('indexMenu').innerHTML = [
4182        ['KOSPI200', `상위 ${kospi200HeatmapLimit}`],
4183        ['KOSDAQ150', '상위 150'],
4184        ['S&P500', `상위 ${sp500HeatmapLimit}`],
4185        ['NASDAQ100', `구성 ${nasdaq100Symbols.length}`],
4186        ['관심종목', String(state.watchlistSymbols.length) + '개']
4187      ].map(([label, note]) => `<button class="menu-item ${label === state.index ? 'active' : ''}" data-index="${label}" type="button"><span>${label}</span><small>${note}</small></button>`).join('');
4188
4189      $('colorMenu').innerHTML = [
4190        ['change', '등락률'],
4191        ['52w', '52주 위치'],
4192        ['peak', '고점 대비 낙폭'],
4193        ['frgn', '외국인 순매수'],
4194        ['inst', '기관 순매수'],
4195        ['ind', '개인 순매수']
4196      ].map(([k, label]) => `<button class="menu-item ${state.colorBasis === k ? 'active' : ''}" data-color="${k}" type="button">${label}</button>`).join('');
4197
4198      document.querySelectorAll('.menu').forEach(el => el.classList.remove('show'));
4199      if (state.menu) $(state.menu + 'Menu')?.classList.add('show');
4200    }
4201
4202    function marketSession() {
4203      const zoneDate = tz =>
4203 new Date(new Date().toLocaleString('en-US', { timeZone: tz }));
4204      const kst = zoneDate('Asia/Seoul');
4205      const et = zoneDate('America/New_York');
4206      const mins = d => d.getHours() * 60 + d.getMinutes();
4207      const weekday = d => d.getDay() >= 1 && d.getDay() <= 5;
4208      return {
4209        krxOpen: weekday(kst) && mins(kst) >= 540 && mins(kst) < 930,   // 09:00–15:30 KST
4210        usOpen: weekday(et) && mins(et) >= 570 && mins(et) < 960        // 09:30–16:00 ET
4211      };
4212    }
4213
4214    function viewUsesUsMarket() {
4215      return state.tab === 'market' && ['S&P500', 'NASDAQ100'].includes(state.index);
4216    }
4217
4218    function sessionOpenForView() {
4219      const session = marketSession();
4220      if (viewUsesUsMarket()) return session.usOpen;
4221      if (state.tab === 'portfolio' || (state.tab === 'market' && state.index === '관심종목')) {
4222        return session.krxOpen || session.usOpen;
4223      }
4224      return session.krxOpen;
4225    }
4226
4227    function sessionLabel() {
4228      const session = marketSession();
4229      if (viewUsesUsMarket()) return session.usOpen ? '미국장중' : '미국장마감';
4230      return session.krxOpen ? '장중' : '장마감';
4231    }
4232
4233    function agoText() {
4234      if (!state.lastUpdatedAt) return '';
4235      const sec = Math.max(0, Math.round((Date.now() - state.lastUpdatedAt) / 1000));
4236      if (sec < 10) return '방금';
4237      if (sec < 60) return sec + '초 전';
4238      if (sec < 3600) return Math.floor(sec / 60) + '분 전';
4239      return Math.floor(sec / 3600) + '시간 전';
4240    }
4241
4242    function dataModeText() {
4243      const ago = agoText();
4244      return state.dataMode === 'live' ? 'LIVE · 실시간'
4245        : state.dataMode === 'api' ? `API 시세${ago ? ' · ' + ago : ''}`
4246        : state.dataMode === 'free' ? `무료 시세${ago ? ' · ' + ago : ''}`
4247        : state.dataMode === 'delayed' ? '지연 · 10초 전'
4248        : state.dataMode === 'connected' ? 'API 키 저장됨'
4249        : `전일 종가 · ${sessionLabel()}`;
4250    }
4251
4252    function renderSettings() {
4253      const panel = $('settingsPanel');
4254      const backdrop = $('settingsPanelBackdrop');
4255      if (!panel || !backdrop) return;
4256      panel.classList.toggle('open', state.settingsOpen);
4257      backdrop.classList.toggle('show', state.settingsOpen);
4258      if (!state.settingsOpen) return;
4259
4260      const draft = $('watchlistAddInput')?.value ?? '';
4261      const connected = state.dataMode !== 'eod';
4262      const seg = (options, activeValue, attr) => `<div class="segment">${options
4263        .map(([value, label]) => `<button class="seg-btn ${String(activeValue) === String(value) ? 'active' : ''}" ${attr}="${value}" type="button">${label}</button>`)
4264        .join('')}</div>`;
4265      const switchRow = (id, label, on, hint = '') => `<button class="sp-row sp-switch" id="${id}" type="button"><span class="sp-label">${label}${hint ? `<small>${hint}</small>` : ''}</span><span class="switch ${on ? 'on' : ''}"><span></span></span></button>`;
4266      const watchRows = state.watchlistSymbols.map(symbol => {
4267        const ref = lookup(symbol);
4268        return `<div class="watch-item"><span><strong>${ref?.n || symbol}</strong><small>${symbol}</small></span><button class="watch-remove" data-remove-watch="${symbol}" type="button" aria-label="${symbol} 삭제">×</button></div>`;
4269      }).join('') || '<div style="color: var(--muted-2); font-size: 12px;">등록된 관심종목이 없습니다</div>';
4270
4271      const licActive = licenseActive();
4272      const planLabel = licActive
4273        ? `Pro · ${new Date(state.license.exp * 1000).toLocaleDateString('ko-KR')}까지`
4274        : state.proUnlocked ? 'Pro 미리보기 (개발용)' : '무료';
4275      panel.innerHTML = `
4276        <div class="sp-head"><span>설정</span><button class="icon-btn" id="settingsClose" type="button" aria-label="설정 닫기">×</button></div>
4277        <div class="sp-body">
4278          <section class="sp-section">
4279            <h3>계정</h3>
4280            <div class="sp-row"><span class="sp-label">플랜${licActive && state.license.email ? `<small>${state.license.email}</small>` : ''}</span><span style="font-weight:800;color:${licActive ? 'var(--warn)' : 'var(--text)'}">${planLabel}</span></div>
4281            <div class="sp-actions">
4282              ${licActive
4283                ? '<button class="secondary" id="licRefresh" type="button">라이선스 확인</button><button class="secondary" id="licRemove" type="button">이 기기에서 해제</button>'
4284                : state.licenseDeviceLimit
4285                  ? '<button class="primary" id="licRetry" type="button">이 기기에서 사용하기</button><button class="secondary" id="licRemove" type="button">토큰 삭제</button>'
4286                  : '<button class="primary" id="licBuy" type="button">Pro 시작하기</button><button class="secondary" id="licInputToggle" type="button">토큰 입력</button>'}
4287            </div>
4288            ${state.licenseDeviceLimit ? '<div class="sp-note" style="margin-top:10px;color:var(--warn);font-size:12px;line-height:1.6">이 라이선스는 이미 2대에서 사용 중입니다. 최대 2대까지만 동시에 쓸 수 있어요. \'이 기기에서 사용하기\'를 누르면 가장 오래 안 쓴 기기가 자동 해제되고 이 기기가 등록됩니다.</div>' : ''}
4289            ${state.licenseInputOpen && !licActive ? `
4290            <div class="watch-add" style="margin-top:10px">
4291              <input id="licTokenInput" placeholder="결제 완료 화면의 라이선스 토큰 붙여넣기" autocomplete="off" spellcheck="false">
4292              <button class="secondary" id="licTokenApply" type="button">적용</button>
4293            </div>` : ''}
4294          </section>
4295          <section class="sp-section">
4296            <h3>데이터 연결 ${can('byok') ? '' : '<span class="pro-pill">PRO</span>'}</h3>
4297            ${bridge.active
4298              // 임베드(확장 셸): BYOK 키 입력·해제는 iframe에서 불가(secure.v1은 브릿지 미전송) → 네이티브 설정으로만 안내.
4299              ? `<div class="sp-row"><span class="sp-label">실시간 시세 연결(BYOK)<small>보안을 위해 확장 설정 페이지에서 진행합니다 · 키 입력과 해제 모두 그곳에서</small></span></div>
4300            <div class="sp-actions"><button class="secondary" id="byokOpenSettings" type="button">확장 설정 열기</button></div>`
4301              : `<div class="provider-grid">
4302              <button class="provider-btn ${state.provider === 'kis' ? 'active' : ''}" data-provider="kis" type="button">
4302KIS 한국투자</button>
4303              <button class="provider-btn ${state.provider === 'toss' ? 'active' : ''}" data-provider="toss" type="button">토스증권</button>
4304            </div>
4305            <div class="sp-row" style="margin-top: 12px;"><span class="sp-label">연결 상태</span><span style="font-weight: 800;">${dataModeText()}</span></div>`}
4306            <div class="sp-row sp-stack"><span class="sp-label">자동 새로고침<small>무료는 5분 주기 · 30초/1분은 Pro${can('fastRefresh') ? ' (활성)' : ''} · 장 마감 시 Pulse 지표만 갱신</small></span>${seg([[0, '끄기'], [30, can('fastRefresh') ? '30초' : '30초 <span class="pro-pill">PRO</span>'], [60, can('fastRefresh') ? '1분' : '1분 <span class="pro-pill">PRO</span>'], [300, '5분']], effectiveRefreshSec(), 'data-auto-refresh')}</div>
4307            ${bridge.active ? '' : `<div class="sp-actions">
4308              <button class="secondary" id="openByok" type="button">${connected ? 'API 키 변경' : 'API 키 연결'}</button>
4309              ${connected ? '<button class="secondary" id="disconnect" type="button">연결 해제</button>' : ''}
4310            </div>`}
4311          </section>
4312          <section class="sp-section">
4313            <h3>관심종목 (${state.watchlistSymbols.length})</h3>
4314            <div class="watch-add">
4315              <input id="watchlistAddInput" placeholder="심볼·종목명 검색 (예: 삼성, 005930, AAPL)" autocomplete="off" spellcheck="false">
4316              <button class="secondary" id="watchlistAddBtn" type="button">추가</button>
4317              <div id="watchlistDropdown" class="wl-dd" hidden></div>
4318            </div>
4319            <div class="watch-list">${watchRows}</div>
4320          </section>
4321          <section class="sp-section">
4322            <h3>화면</h3>
4323            <div class="sp-row sp-stack"><span class="sp-label">시작 탭</span>${seg([['market', '전체시장'], ['portfolio', '포트폴리오'], ['etf', 'ETF']], state.defaultTab, 'data-set-default-tab')}</div>
4324            <div class="sp-row sp-stack"><span class="sp-label">시작 지수</span>${seg([['KOSPI200', 'KOSPI'], ['KOSDAQ150', 'KOSDAQ'], ['S&P500', 'S&P500'], ['NASDAQ100', 'NASDAQ'], ['관심종목', '관심종목']], state.defaultIndex, 'data-set-default-index')}</div>
4325            ${switchRow('pulseVisibleToggle', 'Market Pulse 표시', state.pulseVisible, '상단 시장 지표 스트립')}
4326            <div class="sp-row sp-stack"><span class="sp-label">지표 표시 스타일<small>티커(한 줄) · 카드(탭) · 히어로(핵심+밀집) · 컬럼(그룹 보드)</small></span>${seg([['ticker', '티커'], ['cards', '카드'], ['hero', '히어로'], ['columns', '컬럼']], state.pulseStyle, 'data-pulse-style')}</div>
4327          </section>
4328          <section class="sp-section">
4329            <h3>색상</h3>
4330            ${switchRow('themeToggle', '밝은 테마', state.theme === 'light', '배경을 밝게(라이트) 또는 어둡게(다크)')}
4331            ${switchRow('colorblindToggle', '색약 모드', state.colorblind, '청록↔주황 팔레트')}
4332            <div class="sp-row sp-stack"><span class="sp-label">미국 종목 색상</span>${seg([['us', '미국식 (초록↑)'], ['kr', '한국식 (빨강↑)']], state.usPalette, 'data-us-palette')}</div>
4333            <div class="sp-row sp-stack"><span class="sp-label">색상 강도 범위</span>${seg([[3, '±3%'], [5, '±5%'], [10, '±10%']], state.colorScale, 'data-color-scale')}</div>
4334          </section>
4335          ${!IS_WEB ? `<section class="sp-section">
4336            <h3>알림 ${can('alerts') ? '' : '<span class="pro-pill">PRO</span>'}</h3>
4337            ${switchRow('alertsToggle', '급등락·목표가 알림', state.alertsCfg.enabled, '관심·보유 종목 급등락과 목표가 도달 시 시스템 알림 · 5분 감시 (탭 안 열려 있어도 동작)')}
4338            ${switchRow('briefingToggle', '아침 브리핑 (평일 08:50)', state.briefingEnabled, '미국 마감 · 시장 온도 · 만기 일정을 장 시작 전에 알림으로')}
4339            ${state.alertsCfg.enabled ? `<div class="sp-row sp-stack"><span class="sp-label">급등락 기준</span>${seg([[2, '±2%'], [3, '±3%'], [5, '±5%']], state.alertsCfg.movePct, 'data-alert-move')}</div>
4340            <div class="sp-row"><span class="sp-label">목표가 알림<small>종목 화면(타일 클릭)에서 종목별로 설정</small></span><span style="font-weight:800">${Object.keys(state.alertsCfg.targets || {}).length}종목</span></div>` : ''}
4341          </section>` : bridge.active ? `<section class="sp-section">
4342            <h3>알림 · 브리핑 · 뱃지</h3>
4343            <div class="sp-row"><span class="sp-label">확장 전용 기능<small>급등락·목표가 알림, 아침 브리핑, 아이콘 뱃지는 확장 설정에서 켜고 끕니다</small></span></div>
4344            <div class="sp-actions"><button class="secondary" id="openNativeSettingsBtn" type="button">설정에서 연결</button></div>
4345          </section>` : ''}
4346          <section class="sp-section">
4347            <h3>프라이버시</h3>
4348            ${switchRow('hideDefaultToggle', '시작 시 금액 숨김', state.hideAmountsDefault, '금액 대신 %만 표시하고 시작')}
4349            ${switchRow('stealthDefaultToggle', '스텔스 모드로 시작', state.stealthDefault, '새 탭이 위장 화면으로 열림 · 스페이스바로 전환')}
4350            <div class="sp-row sp-stack"><span class="sp-label">위장 화면 테마<small>스텔스 모드에서 보여줄 화면</small></span>${seg([['google', '구글'], ['naver', '네이버'], ['ide', '코드 편집기'], ['claude', 'AI 챗']], state.stealthTheme, 'data-stealth-theme')}</div>
4351            ${IS_WEB ? '' : switchRow('badgeToggle', '아이콘 뱃지 (KOSPI 등락)', state.badgeEnabled, '브라우저 툴바 아이콘에 등락률 표시 · 5분 갱신')}
4352          </section>
4353          <section class="sp-section">
4354            <h3>지원</h3>
4355            <div class="sp-row"><span class="sp-label">개선사항·버그 요청<small>
4355기능 제안이나 오류를 알려주세요 · 개발자가 직접 확인합니다</small></span></div>
4356            <div class="sp-actions">
4357              <button class="primary" id="feedbackBtn" type="button" style="flex:1">개선사항 요청하기</button>
4358            </div>
4359            <div class="sp-row" style="margin-top:12px"><span class="sp-label">버전<small>문의 시 참고용</small></span><span style="font-weight:800">v${(window.chrome?.runtime?.getManifest?.().version) || '—'}</span></div>
4360          </section>
4361          <section class="sp-section">
4362            <h3>데모</h3>
4363            ${switchRow('emptyToggle', '빈 포트폴리오 보기', state.pfEmpty, '빈 상태 화면 미리보기')}
4364          </section>
4365        </div>
4366      `;
4367      const input = $('watchlistAddInput');
4368      if (input && draft) input.value = draft;
4369    }
4370
4371    // 관심종목 검색 자동완성 상태 (로컬 유니버스, 네트워크 없음)
4372    const wlAuto = { items: [], active: -1 };
4373    const escHtml = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
4374
4375    // 임베디드 심볼 유니버스에서 부분일치 검색. 코드/이름/약칭을 대소문자 무시로 substring 매칭.
4376    // 소스: 코스피200 · 코스닥 · S&P500 · 미국 대형주 · 국내 ETF (lookup()의 데이터 소스 확장판).
4377    function searchSymbols(query, limit = 8) {
4378      const q = String(query || '').trim().toLowerCase();
4379      if (!q) return [];
4380      const sources = [
4381        [window.kospi200Stocks || stocks, 'KOSPI'],
4382        [state.kosdaqStocks || [], 'KOSDAQ'],
4383        [window.sp500Stocks || [], 'US'],
4384        [usStocks, 'US'],
4385        [etfs, 'ETF']
4386      ];
4387      const seen = new Set();
4388      const out = [];
4389      for (const [list, tag] of sources) {
4390        for (const item of list) {
4391          const code = String(item.c || '');
4392          if (!code || seen.has(code)) continue;
4393          seen.add(code); // 코드 기준 1행 (먼저 등장한 소스 우선)
4394          const name = String(item.n || '');
4395          const short = String(item.s || '');
4396          const codeL = code.toLowerCase(), nameL = name.toLowerCase(), shortL = short.toLowerCase();
4397          if (!codeL.includes(q) && !nameL.includes(q) && !shortL.includes(q)) continue;
4398          const rank = (codeL.startsWith(q) || nameL.startsWith(q) || shortL.startsWith(q)) ? 0 : 1;
4399          out.push({ n: name || code, c: code, tag, rank });
4400        }
4401      }
4402      out.sort((a, b) => a.rank - b.rank); // 접두 일치 우선, 그 외 소스 순서 유지
4403      return out.slice(0, limit);
4404    }
4405
4406    function renderWlDropdown(query) {
4407      const dd = $('watchlistDropdown');
4408      if (!dd) return;
4409      const results = searchSymbols(query);
4410      wlAuto.items = results;
4411      wlAuto.active = -1;
4412      if (!String(query || '').trim()) { dd.hidden = true; dd.innerHTML = ''; return; }
4413      dd.innerHTML = results.length
4414        ? results.map((r, i) => `<div class="wl-dd-row" data-wl-code="${escHtml(r.c)}" data-wl-idx="${i}"><span class="wl-dd-name">${escHtml(r.n)}</span><span class="wl-dd-meta"><span class="wl-dd-sym">${escHtml(r.c)}</span><span class="wl-dd-tag">${r.tag}</span></span></div>`).join('')
4415        : '<div class="wl-dd-empty">검색 결과 없음</div>';
4416      dd.hidden = false;
4417    }
4418
4419    function moveWlActive(delta) {
4420      const dd = $('watchlistDropdown');
4421      if (!dd || dd.hidden || !wlAuto.items.length) return;
4422      wlAuto.active = (wlAuto.active + delta + wlAuto.items.length) % wlAuto.items.length;
4423      dd.querySelectorAll('.wl-dd-row').forEach((row, i) => {
4424        row.classList.toggle('active', i === wlAuto.active);
4425        if (i === wlAuto.active) row.scrollIntoView({ block: 'nearest' });
4426      });
4427    }
4428
4429    function closeWlDropdown() {
4430      const dd = $('watchlistDropdown');
4431      if (dd) { dd.hidden = true; dd.innerHTML = ''; }
4432      wlAuto.items = [];
4433      wlAuto.active = -1;
4434    }
4435
4436    // 공통 추가 경로: 중복 확인 → 저장 → 입력 비우기 → 드롭다운 닫기 → 렌더
4437    function addWatchlistByRef(ref) {
4438      const symbol = String(ref?.c || '').toUpperCase();
4439      if (!symbol) return;
4440      if (state.watchlistSymbols.includes(symbol)) {
4441        toast('이미 등록된 종목입니다');
4442        return;
4443      }
4444      state.watchlistSymbols.push(symbol);
4445      saveSettings();
4446      const input = $('watchlistAddInput');
4447      if (input) input.value = '';
4448      closeWlDropdown();
4449      toast(`관심종목에 ${ref?.n || symbol}을(를) 추가했습니다`);
4450      render();
4451    }
4452
4453    function addWatchlistSymbol() {
4454      // 드롭다운이 열려 있으면 하이라이트된 후보(없으면 첫 후보)를 추가
4455      const dd = $('watchlistDropdown');
4456      if (dd && !dd.hidden && wlAuto.items.length) {
4457        addWatchlistByRef(wlAuto.items[wlAuto.active >= 0 ? wlAuto.active : 0]);
4458        return;
4459      }
4460      const input = $('watchlistAddInput');
4461      const raw = (input?.value || '').trim();
4462      if (!raw) return;
4463      // 정확 일치 우선, 없으면 유니버스 최상위 후보. 일치 없으면 등록하지 않음(원문 강제 등록 제거).
4464      const ref = lookup(raw) || lookup(raw.toUpperCase()) || searchSymbols(raw, 1)[0];
4465      if (!ref) { toast('검색 결과에서 종목을 선택하세요'); return; }
4466      addWatchlistByRef(ref);
4467    }
4468
4469    // --- 내 히트맵 (custom board): 여러 소스 트리맵을 한 화면에 조합 ---
4470    // pane.src: 'KOSPI200'|'KOSDAQ150'|'S&P500'|'NASDAQ100'|'관심종목'|'보유'|'sector'(+sec)|'etf'(+code,name)
4471    const BOARD_SOURCES = [['KOSPI200', 'KOSPI200'], ['KOSDAQ150', 'KOSDAQ150'], ['S&P500', 'S&P500'], ['NASDAQ100', 'NASDAQ100'], ['관심종목', '관심종목'], ['보유', '내 보유']];
4472    const BOARD_LAYOUTS = { '1': { cols: 1, rows: 1, n: 1 }, '2h': { cols: 2, rows: 1, n: 2 }, '2v': { cols: 1, rows: 2, n: 2 }, '4': { cols: 2, rows: 2, n: 4 } };
4473    const BOARD_LAYOUT_LABEL = { '1': '단일', '2h': '좌우', '2v': '상하', '4': '4분할' };
4474    const BOARD_MAX_PANES = 8;
4475    function boardSrcLabel(p) {
4476      if (p.src === 'sector') return p.sec || '섹터';
4477      if (p.src === 'etf') return p.name || 'ETF';
4478      return (BOARD_SOURCES.find(([v]) => v === p.src) || [p.src, p.src])[1];
4479    }
4480    // 자동 그리드 (프리셋 외 pane 수): 1→1 · 2→2 · 3→3 · 4→2×2 · 5-6→3×2 · 7-8→4×2
4481    function boardGridFor(n) {
4482      return ({ 1: [1, 1], 2: [2, 1], 3: [3, 1], 4: [2, 2], 5: [3, 2], 6: [3, 2], 7: [4, 2], 8: [4, 2] }[Math.min(BOARD_MAX_PANES, Math.max(1, n))]) || [2, 2];
4483    }
4484    // KOSPI200(+로드된 KOSDAQ)의 실제 섹터 목록 (소스 메뉴 섹터 그룹용)
4485    function boardSectors() {
4486      const set = new Set((window.kospi200Stocks || []).map(x => x.sec).filter(Boolean));
4487      (state.kosdaqStocks || []).forEach(x => { const s = rollupSector(state.industryMap?.[x.c]); if (s) set.add(s); });
4488      return [...set];
4489    }
4490
4491    // pane 리스트 정합성 (1..8, view/period 기본값, 프리셋이면 개수 맞춤 / auto면 유지).
4492    function ensureBoardPanes() {
4493      const b = state.customBoard;
4494      b.panes = (b.panes || []).slice(0, BOARD_MAX_PANES);
4495      if (!b.panes.length) b.panes = [{ src: 'KOSPI200', period: '1D', view: 'map' }];
4496      b.panes.forEach(p => {
4497        if (!['1D', '1W', '1M', 'YTD'].includes(p.period)) p.period = '1D';
4498        if (p.view !== 'list') p.view = 'map';
4499      });
4500      if (BOARD_LAYOUTS[b.layout]) {
4501        const need = BOARD_LAYOUTS[b.layout].n;
4502        while (b.panes.length < need) b.panes.push({ src: b.panes.length % 2 ? 'S&P500' : 'KOSPI200', period: '1D', view: 'map' });
4503        if (b.panes.length > need) b.panes.length = need;
4504      } else {
4505        b.layout = 'auto';
4506      }
4507    }
4508
4509    // 소스별 원본 아이템 (순수 — set/restore 불필요). 섹터/ETF 직접 계산.
4510    function sectorPaneStocks(sec) {
4511      const kd = (state.kosdaqStocks || []).map(x => ({ ...x, sec: rollupSector(state.industryMap?.[x.c]) || '코스닥' }));
4512      return [...(window.kospi200Stocks || []), ...kd].filter(x => x.sec === sec);
4513    }
4514    function etfPaneItems(code) {
4515      const etf = activeEtfs().find(e => e.c === code) || etfPanelCache[code];
4516      const items = (etf?.consDetail || []).map(d => {
4517        const ref = lookup(d.n);
4518        return ref ? { ...ref, weight: d.w } : { n: d.n, c: d.c || '', s: d.n, p: 0, m: d.w, d: 0, w: 0, mo: 0, y: 0, weight: d.w };
4519      });
4520      if (etf?.consRest > 0.5) items.push({ n: '기타', c: '', s: '기타', m: etf.consRest, d: 0, w: 0, mo: 0, y: 0, weight: etf.consRest, _other: true });
4521      return items;
4522    }
4523
4524    // pane의 아이템 목록 (시세 갱신 대상 수집용).
4525    function paneItems(pane) {
4526      if (pane.src === 'sector') return sectorPaneStocks(pane.sec);
4527      if (pane.src === 'etf') return etfPaneItems(pane.code);
4528      const saved = { tab: state.tab, index: state.index, viewMode: state.viewMode };
4529      state.viewMode = 'heatmap';
4530      if (pane.src === '보유') state.tab = 'portfolio';
4531      else { state.tab = 'market'; state.index = pane.src; }
4532      try {
4533        return pane.src === '보유' ? portfolio().items.map(it =>
4533 it.ref).filter(Boolean) : marketItems();
4534      } finally { Object.assign(state, saved); }
4535    }
4536
4537    // 임의 컨테이너에 pane 트리맵/리스트를 렌더 — 전역 state를 pane 컨텍스트로 잠깐 바꿔 기존 로직 재사용.
4538    function renderTreemapInto(el, pane, W, H) {
4539      const saved = { tab: state.tab, index: state.index, period: state.period, viewMode: state.viewMode, drill: state.drill, etfCat: state.etfCat, colorBasis: state.colorBasis, pfBasis: state.pfBasis };
4540      state.period = ['1D', '1W', '1M', 'YTD'].includes(pane.period) ? pane.period : '1D';
4541      state.colorBasis = 'change';
4542      state.viewMode = 'heatmap';
4543      state.drill = null; state.etfCat = null;
4544      if (pane.src === '보유') { state.tab = 'portfolio'; state.pfBasis = 'day'; }
4545      else { state.tab = 'market'; state.index = ['sector', 'etf'].includes(pane.src) ? 'KOSPI200' : pane.src; } // 섹터/ETF는 KR 팔레트
4546      try {
4547        let tiles;
4548        if (pane.src === 'sector') {
4549          tiles = sectorPaneStocks(pane.sec).slice().sort((a, b) => b.m - a.m).map(x => ({ ...x, kind: 'stock', value: x.heatM || x.m, chg: periodValue(x) }));
4550        } else if (pane.src === 'etf') {
4551          tiles = etfPaneItems(pane.code).map(it => ({ ...it, kind: it._other ? 'other' : 'cons', value: it.weight, chg: it._other ? null : periodValue(it) }));
4552        } else {
4553          tiles = getTiles();
4554        }
4555        el.innerHTML = '';
4556        if (!tiles.length) { el.innerHTML = '<div class="pane-empty">데이터 준비 중…</div>'; return; }
4557        const maxColor = state.colorScale;
4558        if (pane.view === 'list') { renderPaneList(el, tiles, maxColor); return; }
4559        // 지수/관심/보유 소스는 시장 탭과 동일한 섹터 그룹 레이아웃 (섹터/ETF 소스는 평면 squarify).
4560        const grouped = !['sector', 'etf'].includes(pane.src) && tiles.some(t => t.sec);
4561        let rects, labels = [], regions = [];
4562        if (grouped) { const sl = marketSectorRects(tiles, W, H); rects = sl.rects; labels = sl.labels; regions = sl.regions; }
4563        else rects = squarify(tiles, 0, 0, W, H);
4564        // 섹터 구분선 (라벨 숨겨져도 그룹 경계 유지)
4565        regions.forEach(rg => {
4566          const d = document.createElement('div');
4567          d.className = 'pane-sector-region';
4568          d.style.left = rg.x + 'px'; d.style.top = rg.y + 'px';
4569          d.style.width = Math.max(0, rg.w - 1) + 'px'; d.style.height = Math.max(0, rg.h - 1) + 'px';
4570          el.appendChild(d);
4571        });
4572        rects.forEach(r => {
4573          const item = r.item, area = r.w * r.h;
4574          const cls = area < 300 ? 'tiny' : area < 720 ? 'micro' : area < 1500 ? 'small' : '';
4575          const tile = document.createElement('button');
4576          tile.type = 'button';
4577          tile.className = 'tile ' + cls;
4578          tile.style.left = r.x + 'px';
4579          tile.style.top = r.y + 'px';
4580          tile.style.width = Math.max(0, r.w) + 'px';
4581          tile.style.height = Math.max(0, r.h) + 'px';
4582          tile.style.background = colorForMetric(item, maxColor);
4583          tile.dataset.name = item.n;
4584          tile.innerHTML = `<div><div class="tile-name">${item.s || item.n}</div><div class="tile-code">${item.c || ''}</div></div><div><div class="tile-change">${metricText(item)}</div></div>`;
4585          tile.addEventListener('mouseenter', e => { showPopover(e, item); hoverLoadDaily(item, e); });
4586          tile.addEventListener('mousemove', e => movePopover(e));
4587          tile.addEventListener('mouseleave', hidePopover);
4588          wireDualClick(tile, item);
4589          el.appendChild(tile);
4590        });
4591        // 섹터 라벨 — 구역이 충분히 클 때만 (작으면 생략, 구분선은 유지). pane는 작은 폰트.
4592        labels.forEach(s => {
4593          if (s.w < 46 || s.h < 40) return;
4594          const lbl = document.createElement('div');
4595          lbl.className = 'pane-sector-label';
4596          lbl.textContent = s.name;
4597          lbl.style.left = s.x + 'px'; lbl.style.top = s.y + 'px'; lbl.style.maxWidth = Math.max(0, s.w) + 'px';
4598          el.appendChild(lbl);
4599        });
4600      } finally {
4601        Object.assign(state, saved);
4602      }
4603    }
4604
4605    // pane 리스트 뷰 (컴팩트 행: 이름 + 등락%, |chg| 내림차순). state는 호출부가 pane 컨텍스트로 세팅한 상태.
4606    function renderPaneList(el, tiles, maxColor) {
4607      const upC = state.colorblind ? 'var(--orange)' : 'var(--up)';
4608      const downC = state.colorblind ? 'var(--cyan)' : 'var(--down)';
4609      const rows = tiles.slice().sort((a, b) => Math.abs(b.chg ?? -999) - Math.abs(a.chg ?? -999)).map(item => {
4610        const clickable = ['stock', 'holding', 'cons'].includes(item.kind) && dailyFetchable(String(item.c || ''));
4611        const col = (item.chg || 0) >= 0 ? upC : downC;
4612        return `<button class="pane-list-row${clickable ? '' : ' nolink'}" ${clickable ? `data-open-stock="${item.c}"` : ''} type="button">
4613          <span class="plr-sw" style="background:${colorForMetric(item, maxColor)}"></span>
4614          <span class="plr-name">${item.s || item.n}</span>
4615          <span class="plr-chg" style="color:${col}">${metricText(item)}</span>
4616        </button>`;
4617      }).join('');
4618      el.innerHTML = `<div class="pane-list">${rows}</div>`;
4619    }
4620
4621    // 소스 선택 메뉴 (지수·섹터·ETF 그룹). ETF는 인라인 검색.
4622    function boardEtfResultsHtml(q) {
4623      if (!q) return '<div class="board-etf-hint">ETF 이름을 검색하세요</div>';
4624      const matches = activeEtfs().filter(e => e.n.toLowerCase().includes(q)).slice(0, 12);
4625      return matches.length
4626        ? matches.map(e => `<button data-pane-setetf="${e.c}" type="button" title="${e.n}">${e.n}</button>`).join('')
4627        : '<div class="board-etf-hint">결과 없음</div>';
4628    }
4629    function sourceMenuHtml(p, i) {
4630      const idx = BOARD_SOURCES.map(([v, l]) => `<button data-pane-setsrc="${i}:${v}" type="button" class="${p.src === v ? 'active' : ''}">${l}</button>`).join('');
4631      const secs = boardSectors().map(s => `<button data-pane-setsec="${i}:${s}" type="button" class="${p.src === 'sector' && p.sec === s ? 'active' : ''}">${s}</button>`).join('');
4632      return `<div class="pane-src-panel">
4633        <div class="psm-group">지수·시장</div><div class="psm-row">${idx}</div>
4634        <div class="psm-group">섹터</div><div class="psm-row">${secs || '<span class="board-etf-hint">섹터 로딩 중</span>'}</div>
4635        <div class="psm-group">ETF 구성종목</div>
4636        <input id="boardEtfSearch" class="psm-search" placeholder="ETF 검색 (예: KODEX 200)" autocomplete="off" spellcheck="false">
4637        <div id="boardEtfResults" class="psm-etf-results">${boardEtfResultsHtml('')}</div>
4638      </div>`;
4639    }
4640
4641    function renderBoard(H) {
4642      ensureBoardPanes();
4643      const b = state.customBoard;
4644      const board = $('board');
4645      const n = b.panes.length;
4646      const preset = BOARD_LAYOUTS[b.layout];
4647      const stack = state.sideMode || window.innerWidth <= 900;
4648      const [gcols, grows] = preset ? [preset.cols, preset.rows] : boardGridFor(n);
4649      const cols = stack ? 1 : gcols;
4650      const rows = stack ? n : grows;
4651      const arrow = stack ? ['↑', '↓'] : ['◀', '▶'];
4652      const moveBtns = i => `${i > 0 ? `<button class="pane-mini" data-pane-move="${i}:prev" title="앞으로" type="button">${arrow[0]}</button>` : ''}${i < n - 1 ? `<button class="pane-mini" data-pane-move="${i}:next" title="뒤로" type="button">${arrow[1]}</button>` : ''}`;
4653      const layoutSeg = `${Object.keys(BOARD_LAYOUTS).map(k => `<button class="flow-period-btn ${b.layout === k ? 'active' : ''}" data-board-layout="${k}" type="button">${BOARD_LAYOUT_LABEL[k]}</button>`).join('')}`;
4654      const addBtn = n < BOARD_MAX_PANES ? `<button class="board-add" data-board-add="1" type="button" title="패널 추가">+ 패널</button>` : '';
4655      const paneShell = (p, i) => `<div class="board-pane">
4656        <div class="pane-head">
4657          <span class="pane-move">${moveBtns(i)}</span>
4658          <button class="pane-src" data-pane-src="${i}" type="button">${boardSrcLabel(p)} <span style="opacity:.6">▾</span></button>
4659          <div class="flow-period-seg pane-period">${['1D', '1W', '1M', 'YTD'].map(pr => `<button class="flow-period-btn ${p.period === pr ? 'active' : ''}" data-pane-period="${i}:${pr}" type="button">${pr}</button>`).join('')}</div>
4660          <button class="pane-mini" data-pane-view="${i}" title="${p.view === 'list' ? '맵 보기' : '리스트 보기'}" type="button">${p.view === 'list' ? '▦' : '≣'}</button>
4661          ${n > 1 ? `<button class="pane-mini pane-del" data-pane-del="${i}" title="패널 삭제" type="button">✕</button>` : ''}
4662        </div>
4663        <div class="pane-map" data-pane="${i}"></div>
4664      </div>`;
4665      board.innerHTML = `<div class="board-bar"><span class="board-title">내 히트맵</span><div class="flow-period-seg" style="margin-left:auto">${layoutSeg}</div>${addBtn}</div>
4666        <div class="board-grid" style="grid-template-columns:repeat(${cols},1fr);grid-template-rows:repeat(${rows},1fr)">${b.panes.map(paneShell).join('')}</div>`;
4667
4668      // 지연 로드: KOSDAQ(지수/섹터), ETF 유니버스 + 구성종목
4669      if (b.panes.some(p => p.src === 'KOSDAQ150') && !state.kosdaqStocks?.length) {
4670        loadKosdaq150().then(() => { if (state.tab === 'custom') renderBoard(H); }).catch(() => {});
4671      }
4672      // 코스닥150·섹터 패널(또는 소스 메뉴)은 산업 구분에 industryMap이 필요 — 시장 탭과 달리
4673      // 커스텀 보드에서는 자동 로드되지 않으므로 여기서 확보 (미로드 시 sec가 '코스닥' 단일값으로 떨어짐)
4674      const needsIndustry = state.boardMenu != null || b.panes.some(p => p.src === 'KOSDAQ150' || p.src === 'sector');
4675      if (needsIndustry && !(state.industryMap && Object.keys(state.industryMap).length)) {
4676        ensureIndustryMap().then(map => { if (map && state.tab === 'custom') renderBoard(H); }).catch(() => {});
4677      }
4678      if ((state.boardMenu != null || b.panes.some(p => p.src === 'etf')) && !state.etfDynamic?.length) {
4679        loadEtfs().then(() => { if (state.tab === 'custom') renderBoard(H); }).catch(() => {});
4680      }
4681      b.panes.filter(p => p.src === 'etf').forEach(p => {
4682        const etf = etfRefForPanel(p.code);
4683        if (!etf.consDetail?.length && !etf._consNone) loadEtfCons(etf).then(() => { if (state.tab === 'custom') renderBoard(H); }).catch(() => {});
4684      });
4685
4686      // 각 pane 렌더: 소스 메뉴 열린 pane은 본문을 메뉴로 대체(클리핑 회피), 나머지는 실측 후 트리맵/리스트
4687      b.panes.forEach((p, i) => {
4688        const mapEl = board.querySelector(`.pane-map[data-pane="${i}"]`);
4689        if (!mapEl) return;
4690        if (state.boardMenu === i) {
4691          mapEl.innerHTML = sourceMenuHtml(p, i);
4692          const inp = mapEl.querySelector('#boardEtfSearch');
4693          if (inp) inp.addEventListener('keyup', () => {
4694            const res = mapEl.querySelector('#boardEtfResults');
4695            if (res) res.innerHTML = boardEtfResultsHtml(inp.value.trim().toLowerCase());
4696          });
4697          return;
4698        }
4699        const rc = mapEl.getBoundingClientRect();
4700        renderTreemapInto(mapEl, p, Math.max(40, rc.width - 2), Math.max(40, rc.height - 2));
4701      });
4702    }
4703
4704    function renderMap() {
4705      const app = $('app');
4706      app.classList.toggle('side-mode', state.sideMode);
4707      app.classList.toggle('board-mode', state.tab === 'custom');
4708      // 툴바 접힘은 사이드패널/좁은 화면 전용 — 전체 화면에서는 항상 펼침 (전체 화면엔 복구 버튼이 없음)
4709      app.classList.toggle('tb-collapsed', state.toolbarCollapsed && (state.sideMode || window.innerWidth <= 900));
4710      const toolbarToggle = $('toolbarToggle');
4711      if (toolbarToggle) toolbarToggle.textContent = state.toolbarCollapsed ? '▾' : '▴';
4712      $('main').className = 'main ' + (state.tab === 'portfolio' ? 'portfolio ' : '') + (state.pfPanelOpen ? 'panel-open' : '');
4713      $('emptyState').classList.toggle('show', state.tab === 'portfolio' && state.pfEmpty);
4714      $('byokBanner').classList.toggle('show', state.dataMode === 'eod' && !state.modal);
4715      const drillActive = state.tab === 'etf' && (!!state.drill || !!state.etfCat);
4716      $('drillBar').classList.toggle('show', drillActive);
4717      const loadMoreBtn = $('drillLoadMore');
4718      if (loadMoreBtn) loadMoreBtn.style.display = 'none';
4719      if (state.drill) {
4720        const etf = activeEtfs().find(e => e.n === state.drill);
4721        $('drillTitle').textContent = state.drill;
4722        $('drillSub').textContent = etf?.cons?.length ? `순자산 ${etf.m.toFixed(1)}조원 · 상위 ${etf.cons.length - 1}종목 + 기타` : '';
4723        $('drillBack').textContent = state.etfCat ? `${state.etfCat} ETF로` : 'ETF 목록으로';
4724      } else if (state.etfCat) {
4725        const list = activeEtfs().filter(e => etfGroupKeyOf(e) === state.etfCat);
4726        $('drillTitle').textContent = state.etfCat;
4727        $('drillSub').textContent = `ETF ${list.length}개 · 순자산 합계 ${list.reduce((s, e) => s + e.m, 0).toFixed(1)}조원`;
4728        $('drillBack').textContent = '카테고리로 돌아가기';
4729        // 이 그룹에 top-150 밖 ETF가 남았으면 더 가져오기 노출 (브랜드 우선, 카테고리도 동일)
4730        const remaining = etfGroupTotal(state.etfCat) - list.length;
4731        if (loadMoreBtn && remaining > 0) {
4732          loadMoreBtn.textContent = `${state.etfCat} ETF ${remaining}개 더 가져오기`;
4733          loadMoreBtn.disabled = false;
4734          loadMoreBtn.style.display = '';
4735        }
4736      }
4737
4738      const map = $('treemap');
4739      const listView = $('listView');
4740      const compact = state.sideMode || window.innerWidth <= 900;
4741      const toolbarH = document.querySelector('.toolbar')?.getBoundingClientRect().height || (compact ? 86 : 48);
4742      const pulseH = state.pulseVisible ? ($('marketPulse')?.getBoundingClientRect().height || state.pulseHeights[pulseHeightKey()] || (compact ? 44 : 92)) : 0;
4743      const resizerH = $('pulseResizer')?.offsetParent ? $('pulseResizer').getBoundingClientRect().height : 0;
4744      const tabsH = $('tabs')?.getBoundingClientRect().height || 44;
4745      const chromeH = toolbarH + pulseH + resizerH + tabsH;
4746      const summaryH = state.tab === 'portfolio' ? $('summary').getBoundingClientRect().height : 0;
4747      const panelW = state.tab === 'portfolio' && state.pfPanelOpen && !compact ? 280 : 0;
4748      // 사이드패널은 유동 폭: 트리맵이 패널 전체 폭을 채운다 (resize 리스너가 재레이아웃).
4749      // 풀 모드 좁은 창(≤900px, side 아님)은 기존 400px 캡 유지.
4750      const viewW = state.sideMode ? window.innerWidth : (compact ? Math.min(400, window.innerWidth) : window.innerWidth - panelW);
4751      const W = Math.max(60, viewW - 2);
4752      const H = Math.max(120, window.innerHeight - chromeH - summaryH - 2);
4753      $('mapWrap').style.height = H + 'px';
4754      // 내 히트맵 탭: 단일 트리맵/리스트 대신 보드(여러 pane) 렌더 후 종료.
4755      if (state.tab === 'custom') {
4756        map.style.display = 'none';
4757        listView.classList.remove('show');
4758        $('legend').style.display = 'none';
4759        $('board').style.display = 'flex'; // CSS inset:1px가 map-wrap(H)를 채움
4760        renderBoard(H);
4761        return;
4762      }
4763      $('board').style.display = 'none';
4764      map.style.width = W + 'px';
4765      map.style.height = H + 'px';
4766      const tiles = getTiles();
4767      if (isFlowBasis()) {
4768        flowScale = Math.max(50, ...tiles.map(t => Math.abs(flowValue(t) ?? 0)));
4769        const krCodes = tiles.map(t => String(t.c || '')).filter(isKoreanCode);
4770        if (krCodes.length) setTimeout(() => ensureFlows(krCodes), 0);
4771      }
4772      const sectorLayout = shouldGroupMarketBySector() ? marketSectorRects(tiles, W, H) : null;
4773      const rects = sectorLayout ? sectorLayout.rects : squarify(tiles, 0, 0, W, H);
4774      const maxColor = state.tab === 'portfolio' && state.pfBasis === 'total' ? 20 : state.colorScale;
4775      map.innerHTML = '';
4776      map.style.display = state.viewMode === 'list' ? 'none' : 'block';
4777      listView.classList.toggle('show', state.viewMode === 'list');
4778      $('legend').style.display = state.viewMode === 'list' ? 'none' : 'flex';
4779      renderListView(tiles, maxColor);
4780
4781      if (sectorLayout) {
4782        sectorLayout.labels.forEach(s => {
4783          const el = document.createElement('div');
4784          el.className = 'sector-label sector-label-block';
4785          el.textContent = s.name;
4786          el.style.left = s.x + 'px';
4787          el.style.top = s.y + 'px';
4788          el.style.maxWidth = Math.max(0, s.w) + 'px';
4789          map.appendChild(el);
4790        });
4791      } else if (state.tab === 'market') {
4792        const sectors = {};
4793        rects.forEach(r => {
4794          const s = r.item.sec;
4795          sectors[s] ||= { x: r.x, y: r.y, r: r.x + r.w, b: r.y + r.h };
4796          sectors[s].x = Math.min(sectors[s].x, r.x);
4797          sectors[s].y = Math.min(sectors[s].y, r.y);
4798          sectors[s].r = Math.max(sectors[s].r, r.x + r.w);
4799          sectors[s].b = Math.max(sectors[s].b, r.y + r.h);
4800        });
4801        Object.entries(sectors).forEach(([name, s]) => {
4802          const el = document.createElement('div');
4803          el.className = 'sector-label';
4804          el.textContent = name;
4805          el.style.left = s.x + 5 + 'px';
4806          el.style.top = s.y + 5 + 'px';
4807          map.appendChild(el);
4808        });
4809      }
4810
4811      const needGraphData = []; // 그래프 표시 타일(area>5200)인데 일봉이 없는 종목 → 이후 프리페치
4812      rects.forEach(r => {
4813        const item = r.item;
4814        const area = r.w * r.h;
4815        const spark = sparkModelForItem(item, 260, 76);
4816        if (!spark.path && area > 5200) needGraphData.push(String(item.c || ''));
4817        const isFullTab = !state.sideMode && window.innerWidth > 900;
4818        const cls = isFullTab
4819          ? (area < 360 ? 'tiny' : area < 760 ? 'micro' : area < 1500 ? 'small' : '')
4820          : (area < 1600 ? 'tiny' : area < 3000 ? 'micro' : area < 5200 ? 'small' : '');
4821        const tile = document.createElement('button');
4822        tile.type = 'button';
4823        tile.className = 'tile ' + cls;
4824        tile.style.left = r.x + 'px';
4825        tile.style.top = r.y + 'px';
4826        tile.style.width = Math.max(0, r.w) + 'px';
4827        tile.style.height = Math.max(0, r.h) + 'px';
4828        tile.style.background = colorForMetric(item, maxColor);
4829        tile.dataset.name = item.n;
4830        const tileSigs = can('stockScan') ? (state.stockSignalCache[String(item.c || '')]?.sigs || []) : [];
4831        const sigDot = tileSigs.length && area > 760
4832          ? `<span class="tile-sig-dot" style="background:${tileSigs.some(sg => sg.sev === 'var(--warn)') ? 'var(--warn)' : 'rgba(255,255,255,0.55)'}" title="시그널 ${tileSigs.length}건"></span>`
4833          : '';
4834        tile.innerHTML = `
4835          ${sigDot}
4836          <div>
4837            <div class="tile-name">${item.s || item.n}</div>
4838            <div class="tile-code">${item.c || ''}</div>
4839          </div>
4840          <div>
4841            <div class="tile-change">${metricText(item)}</div>
4842            ${item.kind === 'holding' && !state.hideAmounts ? `<div class="tile-sub">${money(item.holding.value)}</div>` : ''}
4843            ${item.kind === 'etfcat' ? `<div class="tile-sub">ETF ${item.count}개 · ${item.m.toFixed(1)}조원</div>` : ''}
4844            ${item.kind === 'etf' && cls !== 'tiny' && cls !== 'micro' ? `<div class="tile-sub">${fmtAum(item.m)}</div>` : ''}
4845            ${spark.path && area > 5200 ? `<svg class="spark" viewBox="0 0 260 76" preserveAspectRatio="none"><path d="${spark.path}" fill="none" stroke="${spark.stroke}" stroke-width="5"/></svg>` : ''}
4846          </div>
4847        `;
4848        tile.addEventListener('mouseenter', e => {
4849          const latestSpark = sparkModelForItem(item, 260, 76);
4850          const path = tile.querySelector('.spark path');
4851          if (path && latestSpark.path) {
4852            path.setAttribute('d', latestSpark.path);
4853            path.setAttribute('stroke', latestSpark.stroke);
4854          }
4855          showPopover(e, item);
4856          // 차트 데이터가 없으면 호버 시점에 일봉을 받아와 팝오버 갱신
4857          hoverLoadDaily(item, e);
4858        });
4859        tile.addEventListener('mousemove', e => movePopover(e));
4860        tile.addEventListener('mouseleave', hidePopover);
4861        wireDualClick(tile, item); // 지도·리스트 공용 클릭/더블클릭 (중재 포함)
4862        map.appendChild(tile);
4863      });
4864
4865      if (needGraphData.length) prefetchGraphTiles(needGraphData).catch(() => {}); // 빈 그래프 채우기
4866
4867      const legendMax = isFlowBasis() ? flowScale : maxColor;
4868      const steps = [];
4869      for (let i = -5; i <= 5; i++) steps.push(colorFor(i / 5 * legendMax, legendMax));
4870      $('legend').innerHTML = state.colorBasis === '52w'
4871        ? `<span>52주 저점</span><span class="legend-scale">${steps.map(c => `<i style="background:${c}"></i>`).join('')}</span><span>52주 고점</span>`
4872        : isFlowBasis()
4873          ? `<span>순매도 ${formatEok(-flowScale)}</span><span class="legend-scale">${steps.map(c => `<i style="background:${c}"></i>`).join('')}</span><span>순매수 ${formatEok(flowScale)}</span>`
4874          : `<span>-${maxColor}%</span><span class="legend-scale">${steps.map(c => `<i style="background:${c}"></i>`).join('')}</span><span>+${maxColor}%</span>`;
4875    }
4876
4877    function renderListView(tiles, maxColor) {
4878      const peakList = state.colorBasis === 'peak' && state.tab !== 'portfolio'; // 고점 대비 기준: 평가액/섹터 자리에 52주 최고가 표시
4879      const consView = state.tab === 'etf' && !!state.drill; // 드릴다운 구성종목 뷰 → 마지막 열을 편입 비중으로
4880      const etfListView = state.tab === 'etf' && !state.drill; // ETF/카테고리·운용사 롤업 뷰 → 마지막 열을 순자산으로
4881      // 구성종목 뷰는 편입 비중 내림차순(지도 타일 크기와 동일 기준). '기타'(잔여분)는 비중과 무관하게 맨 아래.
4882      const sorted = consView
4883        ? tiles.slice().sort((a, b) => {
4884          const ao = a.kind === 'other', bo = b.kind === 'other';
4885          if (ao !== bo) return ao ? 1 : -1;
4886          return (b.weight || 0) - (a.weight || 0);
4887        })
4888        : tiles.slice().sort((a, b) => Math.abs(activeMetric(b) || 0) - Math.abs(activeMetric(a) || 0));
4889      const rows = sorted.map(item => {
4890        const currency = item.currency || item.holding?.currency || item.ref?.currency || (String(item.c || '').match(/^[A-Z.\\-]+$/) ? 'USD' : 'KRW');
4891        const price = item.p || item.cur || item.holding?.cur || item.ref?.p || 0;
4892        const value = item.holding?.value || item.value || item.m || 0;
4893        const weight = item.holding?.wt != null ? item.holding.wt : item.wt;
4894        // 리스트 행도 지도 타일과 동일하게 클릭 가능 (아래에서 wireDualClick 배선).
4895        // etf/etfcat=단일 패널·더블 드릴 / 종목·보유·구성=단일클릭 상세(패널 열리는 코드만).
4896        const canPanel = ['stock', 'holding', 'cons'].includes(item.kind) && dailyFetchable(String(item.c || ''));
4897        const interactive = item.kind === 'etf' || item.kind === 'etfcat' || canPanel;
4898        const hint = item.kind === 'etf' ? '클릭하면 상세 · 더블클릭하면 구성종목'
4899          : item.kind === 'etfcat' ? `클릭하면 ${state.etfGroup === 'brand' ? '운용사' : '카테고리'} 온도 · 더블클릭하면 펼치기`
4900            : canPanel ? '클릭하면 상세' : '';
4901        return `<tr${interactive ? ` class="list-row-interactive" title="${hint}"` : ''}>
4902          <td>
4903            <div class="list-name"><span class="list-swatch" style="background:${colorForMetric(item, maxColor)}"></span><div><strong>${item.n || item.name}</strong><div class="list-symbol">${item.c || ''}${currency === 'USD' ? ' · USD' : ''}</div></div></div>
4904          </td>
4905          <td style="color:${metricTextColor(item)}">${metricText(item)}</td>
4906          <td>${price ? moneyByCurrency(price, currency) : '-'}</td>
4907          <td>${peakList
4908            ? (() => { const r = range52(item); return r && !r.est && r.high > 0 ? moneyByCurrency(r.high, currency) : '-'; })()
4909            : state.tab === 'portfolio' ? moneyByCurrency(value, currency) : (item.sec || '-')}</td>
4910          <td>${(item.kind === 'cons' || item.kind === 'other') && item.weight != null
4911            ? Number(item.weight).toFixed(1) + '%'
4912            : (item.kind === 'etf' || item.kind === 'etfcat') ? fmtAum(item.m)
4913              : weight != null ? Number(weight).toFixed(1) + '%' : (item.marketCountry || item.sec || '-')}</td>
4914        </tr>`;
4915      }).join('');
4916      $('listView').innerHTML = `<table class="list-table">
4917        <thead><tr><th>종목</th><th>${{ '52w': '52주 위치', peak: '고점 대비', frgn: `외국인 순매수(${flowPeriodLabel()})`, inst: `기관 순매수(${flowPeriodLabel()})`, ind: `개인 순매수(${flowPeriodLabel()})` }[state.colorBasis] || '등락률'}</th><th>현재가</th><th>${peakList ? '52주 최고' : state.tab === 'portfolio' ? '평가액' : '섹터'}</th><th>${consView ? '편입 비중' : etfListView ? '순자산' : state.tab === 'portfolio' ? '비중' : '구분'}</th></tr></thead>
4918        <tbody>${rows || '<tr><td colspan="5">표시할 종목이 없습니다</td></tr>'}</tbody>
4919      </table>`;
4920      // 모든 상호작용 행에 지도 타일과 동일한 wireDualClick 배선 (단일 소스 → 두 뷰 동작 일치).
4921      // etf/etfcat은 중재 후 패널/드릴, 종목·보유·구성은 즉시 상세 패널 (wireDualClick 내부에서 분기).
4922      const trs = $('listView').querySelectorAll('tbody tr');
4923      sorted.forEach((item, i) => {
4924        const canPanel = ['stock', 'holding', 'cons'].includes(item.kind) && dailyFetchable(String(item.c || ''));
4925        if ((item.kind === 'etf' || item.kind === 'etfcat' || canPanel) && trs[i]) wireDualClick(trs[i], item);
4926      });
4927    }
4928
4929    function renderPortfolio() {
4930      const pf = portfolio();
4931      const dayPct = pf.dayPnl / (pf.total - pf.dayPnl) * 100;
4932      const totalPct = pf.totalPnl / pf.cost * 100;
4933      const hidden = state.hideAmounts;
4934      const stats = state.pfEmpty ? [
4935        ['총 평가액', '0원', 'var(--text)'],
4936        ['당일 손익', '-', 'var(--muted)'],
4937        ['총 수익률', '-', 'var(--muted)']
4938      ] : [
4939        ['총 평가액', hidden ? '••••••••' : money(pf.total), 'var(--text)'],
4940        ['당일 손익', hidden ? pct(dayPct) : `${pf.dayPnl >= 0 ? '+' : '-'}${money(Math.abs(pf.dayPnl))} (${pct(dayPct)})`, changeTextColor(pf.dayPnl)],
4941        ['총 수익률', hidden ? pct(totalPct) : `${pf.totalPnl >= 0 ? '+' : '-'}${money(Math.abs(pf.totalPnl))} (${pct(totalPct)})`, changeTextColor(pf.totalPnl)]
4942      ];
4943      $('stats').innerHTML = stats.map(([k, v, c]) => `<div><span class="stat-label">${k}</span><span class="stat-value" style="color:${c}">${v}</span></div>`).join('');
4944      $('hideBtn').textContent = hidden ? '🙈' : '👁';
4945      const pfEditBtn = $('pfEditBtn');
4946      if (pfEditBtn) pfEditBtn.style.display = state.remoteHoldings ? 'none' : ''; // 증권사 연동 중엔 수동 편집 숨김
4947      $('panelBtn').style.transform = state.pfPanelOpen ? 'rotate(0deg)' : 'rotate(180deg)';
4948
4949      prefetchHeldEtfCons(); // 보유 ETF 구성종목을 백그라운드로 채워 중복노출 정확도 향상
4950      const ex = exposures();
4951      const max = ex[0]?.total || 0;
4952      $('gaugeValue').textContent = max.toFixed(1) + '%';
4953      $('gaugeValue').style.color = max > 25 ? 'var(--warn)' : 'var(--text)';
4954      $('gaugeBar').style.width = Math.min(100, max / 40 * 100) + '%';
4955      $('gaugeBar').style.background = max > 25 ? 'var(--warn)' : 'var(--ok)';
4956      $('gaugeWarn').textContent = max > 25 ? `${ex[0].name} 실질 노출이 25%를 넘었습니다. ETF 편입분을 포함한 수치입니다.` : '';
4957
4958      const list = $('exposureList');
4959      list.classList.toggle('locked', !can('etfExposure'));
4960      const cards = ex.filter(e => e.etfSum > 0.05).slice(0, 5).map(e => {
4961        const parts = [];
4962        if (e.direct > 0.05) parts.push(`직접 ${e.direct.toFixed(1)}%`);
4963        e.parts.forEach(p => parts.push(`${p[0]} ${p[1].toFixed(1)}%`));
4964        return `<div class="exposure-card">
4965          <div class="exposure-row"><span class="exposure-name">${e.name}</span><span class="exposure-pct">${e.total.toFixed(1)}%</span></div>
4966          <div class="exposure-source">${parts.join(' + ')}</div>
4967        </div>`;
4968      }).join('');
4969      const overlay = list.querySelector('.lock-overlay')?.outerHTML || '';
4970      list.innerHTML = cards + overlay;
4971    }
4972
4973    // --- 종목 뉴스: 네이버 종목 뉴스 API (세션 캐시 10분) ---
4974    const stockNewsCache = {};
4975
4976    async function fetchStockNews(code) {
4977      const cached = stockNewsCache[code];
4978      if (cached && Date.now() - cached.savedAt < 10 * 60 * 1000) return cached.items;
4979      const items = [];
4980      if (isKoreanCode(code)) {
4981        const data = await fetchJson(`https://m.stock.naver.com/api/news/stock/${code}?pageSize=6&page=1`);
4982        (Array.isArray(data) ? data : []).forEach(group => (group.items || []).forEach(article => {
4983          if (article.title && article.officeId && article.articleId) items.push({
4984            title: String(article.title).replace(/<[^>]*>/g, ''),
4985            press: String(article.officeName || ''),
4986            dt: String(article.datetime || ''),
4987            url: `https://n.news.naver.com/article/${article.officeId}/${article.articleId}`
4988          });
4989        }));
4990      } else {
4991        // 미국 종목: 네이버 worldStock 뉴스 — 로이터 기사 한국어 번역 제공
4992        const reuters = state.usCodeCache[code] || code + '.O';
4993        const data = await fetchJson(`https://api.stock.naver.com/news/worldStock/${encodeURIComponent(reuters)}?pageSize=6`);
4994        (Array.isArray(data) ? data : []).forEach(article => {
4995          if (article.tit) items.push({
4996            title: String(article.tit).replace(/<[^>]*>/g, ''),
4997            press: String(article.ohnm || '로이터'),
4998            dt: String(article.dt || '').slice(0, 12),
4999            url: `https://m.stock.naver.com/worldstock/stock/${encodeURIComponent(reuters)}/news`
5000          });
5001        });
5002      }
5003      const top = items.slice(0, 6);
5004      stockNewsCache[code] = { items: top, savedAt: Date.now() };
5005      return top;
5006    }
5007
5008    function newsTimeLabel(dt) {
5009      if (!/^\d{12}/.test(dt)) return '';
5010      const t = new Date(+dt.slice(0, 4), +dt.slice(4, 6) - 1, +dt.slice(6, 8), +dt.slice(8, 10), +dt.slice(10, 12));
5011      const mins = Math.max(0, Math.round((Date.now() - t.getTime()) / 60000));
5012      if (mins < 60) return mins + '분 전';
5013      if (mins < 24 * 60) return Math.floor(mins / 60) + '시간 전';
5014      return `${+dt.slice(4, 6)}/${+dt.slice(6, 8)}`;
5015    }
5016
5017    const hoverDailyLoading = new Set();
5018
5019    function dailyFetchable(symbol) {
5020      return isKoreanCode(symbol) || /^[A-Z][A-Z0-9.\-]*$/.test(symbol);
5021    }
5022
5023    function hoverLoadDaily(item, e) {
5024      const symbol = String(item.c || '');
5025      if (!symbol || !dailyFetchable(symbol)) return;
5026      if (candlePrices(symbol, item.p).length >= 2) return;
5027      if (hoverDailyLoading.has(symbol)) return;
5028      hoverDailyLoading.add(symbol);
5029      fetchStockDaily(symbol)
5030        .then(() => {
5031          // 팝오버가 아직 같은 종목을 보고 있으면 갱신
5032          if ($('popover').classList.contains('show') && $('popover').dataset.symbol === symbol) {
5033            showPopover(e, item);
5034          }
5035        })
5036        .catch(() => {})
5037        .finally(() => hoverDailyLoading.delete(symbol));
5038    }
5039
5040    function showPopover(e, item) {
5041      const pop = $('popover');
5042      const value = item.holding?.value;
5043      const pos52 = weeklyPosition(item);
5044      const popSpark = sparkModelForItem(item, 260, 76);
5045      const rows = item.kind === 'holding'
5046        ? [['현재가', money(item.p)], ['평가액', state.hideAmounts ? '••••••' : money(value)], ['당일 손익률', pct(item.holding.day)], ['52주 위치', pos52 == null ? '-' : pos52.toFixed(0) + '%'], ['총 수익률', pct(item.holding.totalReturn)]]
5047        : item.kind === 'etfcat'
5048          ? [['ETF 수', item.count + '개'], ['순자산 합계', item.m.toFixed(1) + '조원'], ['가중 평균 등락률', pct(item.chg)]]
5049          : item.kind === 'etf'
5050            ? [['현재가 (NAV)', money(item.p)], ['등락률', pct(item.chg)], ['순자산', item.m.toFixed(1) + '조원']]
5051            : item.kind === 'cons'
5052              ? [['편입 비중', item.weight.toFixed(1) + '%'], ['등락률', pct(item.chg)]]
5053              : [['현재가', money(item.p || 0)], ['등락률', pct(item.chg)], ['52주 위치', pos52 == null ? '-' : pos52.toFixed(0) + '%'], ['시가총액', item.m.toFixed(1) + '조원']];
5054      // 52주 최고가 + 고점 대비 (실데이터 있을 때만 — 추정치는 표시하지 않음)
5055      const r52 = ['stock', 'holding'].includes(item.kind) ? range52(item) : null;
5056      if (r52 && !r52.est && r52.high > 0) {
5057        const dd = Math.min(0, (r52.price - r52.high) / r52.high * 100);
5058        rows.push(['52주 최고 · 고점 대비', `${money(r52.high)} · ${dd.toFixed(1)}%`]);
5059      }
5060      const flow = state.flowCache[String(item.c || '')];
5061      if (flow && ['stock', 'holding', 'etf'].includes(item.kind)) {
5062        rows.push(['외국인 순매수(당일)', formatEok(flow.frgnAmt)], ['기관 순매수(당일)', formatEok(flow.instAmt)]);
5063        if (flow.holdRatio) rows.push(['외국인 보유율', flow.holdRatio]);
5064      }
5065      const hoverSigs = (state.stockSignalCache[String(item.c || '')]?.sigs || []).slice(0, 2);
5066      const bandPct = pos52 == null ? 50 : Math.max(4, Math.min(96, pos52));
5067      pop.dataset.symbol = String(item.c || '');
5068      pop.innerHTML = `
5069        <div class="pop-head"><div><div class="pop-name">${item.n}</div><div class="pop-code">${item.c || ''}</div></div><div class="pop-change" style="color:${metricTextColor(item)}">${metricText(item)}</div></div>
5070        <svg class="pop-chart" viewBox="0 0 260 76" preserveAspectRatio="none">
5071          ${popSpark.path ? `<path d="${popSpark.path}" fill="none" stroke="${popSpark.stroke}" stroke-width="3"/>` : `<text x="130" y="42" text-anchor="middle" fill="rgba(255,255,255,0.52)" font-size="12">${dailyFetchable(String(item.c || '')) ? '1년 차트 불러오는 중…' : '차트 데이터 없음'}</text>`}
5072        </svg>
5073        ${rows.map(([k, v]) => `<div class="pop-row"><span>${k}</span><strong>${v}</strong></div>`).join('')}
5074        ${item.kind === 'stock' || item.kind === 'holding' ? `<div class="band"><div class="band-track"><span class="band-pin" style="left:${bandPct}%"></span></div><div class="band-labels"><span>52주 저점</span><span>52주 고점</span></div></div>` : ''}
5075        ${hoverSigs.length ? hoverSigs.map(sg => `<div class="pop-row"><span style="color:${sg.sev}">● ${sg.cat}</span><strong style="font-weight:600;font-size:11px;text-align:right">${sg.text}</strong></div>`).join('') : ''}
5076        ${['stock', 'holding', 'cons'].includes(item.kind) && /^\d{6}$/.test(String(item.c || '')) ? `<div style="margin-top:10px;color:var(--muted);font-size:11px;">클릭하면 종목 시그널이 열립니다</div>` : ''}
5077        ${item.kind === 'etfcat' ? `<div style="margin-top:10px;color:var(--muted);font-size:11px;">클릭하면 ${state.etfGroup === 'brand' ? '운용사' : '카테고리'} 온도 · 더블클릭 또는 패널에서 펼쳐보기</div>` : ''}
5078        ${item.kind === 'etf' ? `<div style="margin-top:10px;color:var(--muted);font-size:11px;">클릭하면 상세 · 더블클릭 또는 패널에서 구성종목</div>` : ''}
5079      `;
5080      pop.classList.add('show');
5081      movePopover(e);
5082    }
5083
5084    function movePopover(e) {
5085      const pop = $('popover');
5086      const pad = 14;
5087      const rect = pop.getBoundingClientRect();
5088      // Near the right/bottom edges, FLIP to the opposite side of the cursor instead of
5089      // clamping — clamping parks the card over the very tiles the user is aiming at.
5090      let x = e.clientX + 16;
5091      if (x + rect.width + pad > window.innerWidth) x = e.clientX - rect.width - 16;
5092      let y = e.clientY + 16;
5093      if (y + rect.height + pad > window.innerHeight) y = e.clientY - rect.height - 16;
5094      pop.style.left = Math.max(pad, x) + 'px';
5095      pop.style.top = Math.max(pad, y) + 'px';
5096    }
5097
5098    function hidePopover() {
5099      const pop = $('popover');
5100      pop.classList.remove('show');
5101      pop.style.left = '-9999px';
5102      pop.style.top = '-9999px';
5103    }
5104
5105    function renderModal() {
5106      $('pinModal')?.classList.toggle('show', state.pinPrompt);
5107      const pinError = $('pinUnlockError');
5108      if (pinError) pinError.textContent = state.pinError || '';
5109      $('modal').classList.toggle('show', state.modal);
5110      document.querySelectorAll('.step').forEach(el => el.classList.toggle('active', Number(el.dataset.step) === state.step));
5111      document.querySelectorAll('.provider-btn').forEach(el => el.classList.toggle('active', el.dataset.provider === state.provider));
5112      const providerName = state.provider === 'toss' ? '토스증권' : 'KIS';
5113      $('appKeyLabel').textContent = state.provider === 'toss' ? '토스 API Key' : 'KIS App Key';
5114      $('appSecretLabel').textContent = state.provider === 'toss' ? '토스 API Secret' : 'KIS App Secret';
5115      $('stepDots').innerHTML = [1,2,3,4,5].map(i => `<span class="${i === state.step ? 'active' : ''}"></span>`).join('');
5116      $('prevStep').style.visibility = state.step === 1 ? 'hidden' : 'visible';
5117      $('skipImport').style.display = state.step === 5 ? 'inline-block' : 'none';
5118      $('nextStep').textContent = state.step === 1 ? '시작하기' : state.step === 4 ? (state.test === 'ok' ? '다음' : providerName + ' 테스트') : state.step === 5 ? '가져오기' : '다음';
5119      $('appKey').value = state.key;
5120      $('appSecret').value = state.secret;
5121      $('pin').value = state.pin;
5122      renderTestBox();
5123    }
5124
5125    function renderTestBox() {
5126      const box = $('testBox');
5127      if (!box) return;
5128      if (state.test === 'loading') box.innerHTML = '<div><div class="spinner"></div><strong>연결 테스트 중</strong></div>';
5129      else if (state.test === 'ok') box.innerHTML = '<div><div class="result-icon ok">✓</div><strong>키 저장 준비 완료</strong><p style="margin-top:6px;">실제 잔고/시세 조회는 API 문서 기준으로 호출부를 연결해야 합니다.</p></div>';
5130      else if (state.test === 'fail') box.innerHTML = '<div><div class="result-icon fail">!</div><strong>연결 실패</strong><p style="margin-top:6px;">App Key, Secret, PIN을 다시 확인하세요.</p></div>';
5131      else box.innerHTML = '<div><strong>아직 테스트하지 않았습니다</strong><p style="margin-top:6px;">아래 버튼으로 연결 상태를 확인하세요.</p></div>';
5132    }
5133
5134    function toast(text) {
5135      // 같은 문구가 이미 떠 있으면 중복 생성하지 않음 (반복 알림 누적 방지)
5136      const existing = [...$('toasts').children].find(el => el.textContent === text);
5137      if (existing) {
5138        clearTimeout(existing._t);
5139        existing._t = setTimeout(() => existing.remove(), 3600);
5140        return;
5141      }
5142      const el = document.createElement('div');
5143      el.className = 'toast';
5144      el.textContent = text;
5145      $('toasts').appendChild(el);
5146      el._t = setTimeout(() => el.remove(), 3600);
5147    }
5148
5149    async function tossRequest(path, options = {}) {
5150      const response = await fetch('https://openapi.tossinvest.com' + path, options);
5151      const text = await response.text();
5152      const data = text ? JSON.parse(text) : {};
5153      if (!response.ok) {
5154        const message = data.error?.message || data.error_description || data.message || `HTTP ${response.status}`;
5155        throw new Error(message);
5156      }
5157      return data;
5158    }
5159
5160    async function issueTossToken() {
5161      await ensureSecrets();
5162      if (!state.key || !state.secret) throw new Error('토스 API Key와 Secret을 입력하세요');
5163      const body = new URLSearchParams({
5164        grant_type: 'client_credentials',
5165        client_id: state.key,
5166        client_secret: state.secret
5167      });
5168      const data = await tossRequest('/oauth2/token', {
5169        method: 'POST',
5170        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
5171        body
5172      });
5173      return data.access_token;
5174    }
5175
5176    function chunkArray(items, size) {
5177      const chunks = [];
5178      for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size));
5179      return chunks;
5180    }
5181
5182    function sleep(ms) {
5183      return new Promise(resolve => setTimeout(resolve, ms));
5184    }
5185
5186    async function withRetry(work, attempts = 2) {
5187      let lastError;
5188      for (let i = 0; i < attempts; i++) {
5189        try {
5190          return await work();
5191        } catch (error) {
5192          lastError = error;
5193          if (i < attempts - 1) await sleep(220 + i * 280);
5194        }
5195      }
5196      throw lastError;
5197    }
5198
5199    async function fetchTossPrices(token, symbols) {
5200      const uniqueSymbols = [...new Set(symbols.filter(Boolean).map(String))];
5201      const priceMap = new Map();
5202      const failures = [];
5203
5204      async function requestGroup(group) {
5205        try {
5206          const data = await withRetry(() => tossRequest('/api/v1/prices?symbols=' + encodeURIComponent(group.join(',')), {
5207            headers: { Authorization: `Bearer ${token}` }
5208          }));
5209          (data.result || []).forEach(price => priceMap.set(String(price.symbol), price));
5210        } catch (error) {
5211          if (group.length <= 1) {
5212            failures.push({ symbol: group[0], message: error.message });
5213            return;
5214          }
5215          const mid = Math.ceil(group.length / 2);
5216          await requestGroup(group.slice(0, mid));
5217          await sleep(60);
5218          await requestGroup(group.slice(mid));
5219        }
5220      }
5221
5222      for (const group of chunkArray(uniqueSymbols, 50)) {
5223        await requestGroup(group);
5224        await sleep(80);
5225      }
5226      return { priceMap, failed: failures.length };
5227    }
5228
5229    async function fetchTossPriceMap(token, symbols) {
5230      const { priceMap } = await fetchTossPrices(token, symbols);
5231      return priceMap;
5232    }
5233
5234    async function fetchTossDailyCandles(token, symbols) {
5235      const uniqueSymbols = [...new Set(symbols.filter(Boolean).map(String))];
5236      const candleMap = new Map();
5237      const failures = [];
5238      for (const group of chunkArray(uniqueSymbols, 6)) {
5239        const results = await Promise.allSettled(group.map(symbol => withRetry(async () => {
5240          const data = await tossRequest('/api/v1/candles?symbol=' + encodeURIComponent(symbol) + '&interval=1d&count=' + dailyCandleCount, {
5241            headers: { Authorization: `Bearer ${token}` }
5242          });
5243          return [symbol, data.result?.candles || []];
5244        })));
5245        results.forEach(result => {
5246          if (result.status === 'fulfilled') candleMap.set(result.value[0], result.value[1]);
5247          else failures.push(result.reason?.message || '캔들 조회 실패');
5248        });
5249        await sleep(140);
5250      }
5251      return { candleMap, failed: failures.length };
5252    }
5253
5254    // --- Free data provider (Naver polling/marketIndex + Upbit) — no API key needed ---
5255
5256    async function fetchJson(url, timeoutMs = 10000) {
5257      const response = await fetch(routed(url), { signal: AbortSignal.timeout(timeoutMs) });
5258      if (!response.ok) throw new Error('HTTP ' + response.status);
5259      return response.json();
5260    }
5261
5262    function parseNaverNumber(v) {
5263      const n = Number(String(v ?? '').replace(/,/g, ''));
5264      return Number.isFinite(n) ? n : null;
5265    }
5266
5267    function naverRatio(row) {
5268      let ratio = parseNaverNumber(row.fluctuationsRatio);
5269      if (ratio == null) return null;
5270      const code = String(row.compareToPreviousPrice?.code || '');
5271      if ((code === '4' || code === '5') && ratio > 0) ratio = -ratio;
5272      return ratio;
5273    }
5274
5275    function isKoreanCode(symbol) {
5276      return /^\d{6}$/.test(String(symbol));
5277    }
5278
5279    async function fetchNaverDomestic(codes) {
5280      const map = new Map();
5281      for (const group of chunkArray([...new Set(codes)], 60)) {
5282        const data = await fetchJson('https://polling.finance.naver.com/api/realtime/domestic/stock/' + group.join(','));
5283        (data.datas || []).forEach(row => {
5284          const price = parseNaverNumber(row.closePrice);
5285          if (row.itemCode && price > 0) map.set(String(row.itemCode), { price, change: naverRatio(row) });
5286        });
5287        await sleep(90);
5288      }
5289      return map;
5290    }
5291
5292    async function fetchNaverWorldStocks(symbols) {
5293      const map = new Map();
5294      const queries = [];
5295      [...new Set(symbols)].forEach(symbol => {
5296        const cached = state.usCodeCache[symbol];
5297        if (cached) queries.push(cached);
5298        else queries.push(symbol + '.O', symbol); // NASDAQ needs .O suffix, NYSE resolves plain
5299      });
5300      let resolvedNew = false;
5301      for (const group of chunkArray([...new Set(queries)], 40)) {
5302        const data = await fetchJson('https://polling.finance.naver.com/api/realtime/worldstock/stock/' + group.map(encodeURIComponent).join(','));
5303        (data.datas || []).forEach(row => {
5304          const symbol = String(row.symbolCode || '');
5305          const price = parseNaverNumber(row.closePrice);
5306          if (!symbol || !(price > 0)) return;
5307          map.set(symbol, { price, change: naverRatio(row) });
5308          if (row.reutersCode && state.usCodeCache[symbol] !== row.reutersCode) {
5309            state.usCodeCache[symbol] = String(row.reutersCode);
5310            resolvedNew = true;
5311          }
5312        });
5313        await sleep(90);
5314      }
5315      if (resolvedNew) localStorage.setItem(usCodeCacheKey, JSON.stringify(state.usCodeCache));
5316      return map;
5317    }
5318
5319    async function fetchNaverProductDetail(category, code) {
5320      const data = await fetchJson(`https://m.stock.naver.com/front-api/marketIndex/productDetail?category=${category}&reutersCode=${encodeURIComponent(code)}`);
5321      const row = data?.result || {};
5322      const price = parseNaverNumber(row.closePrice);
5323      let change = parseNaverNumber(row.fluctuationsRatio);
5324      if (change == null) {
5325        const diff = parseNaverNumber(row.fluctuations);
5326        if (diff != null && price > 0 && price !== diff) change = diff / (price - diff) * 100;
5327      }
5328      return price > 0 ? { price, change } : null;
5329    }
5330
5331    async function fetchPulseQuotes() {
5332      const out = new Map();
5333      const windexCodes = marketPulse.filter(item => item.src === 'windex').map(item => item.c);
5334      const dindexCodes = marketPulse.filter(item => item.src === 'dindex').map(item => item.c);
5335      const detailItems = marketPulse.filter(item => ['bond', 'metals', 'energy', 'fx'].includes(item.src));
5336      const upbitCodes = marketPulse.filter(item => item.src === 'upbit').map(item => item.c);
5337
5338      const jobs = [];
5339      if (windexCodes.length) jobs.push(
5340        fetchJson('https://polling.finance.naver.com/api/realtime/worldstock/index/' + windexCodes.map(encodeURIComponent).join(','))
5341          .then(data => (data.datas || []).forEach(row => {
5342            const key = String(row.reutersCode || ('.' + (row.itemCode || '')));
5343            const price = parseNaverNumber(row.closePrice);
5344            if (price > 0) out.set(key, { price, change: naverRatio(row) });
5345          }))
5346      );
5347      if (dindexCodes.length) jobs.push(
5348        fetchJson('https://polling.finance.naver.com/api/realtime/domestic/index/' + dindexCodes.join(','))
5349          .then(data => (data.datas || []).forEach(row => {
5350            const price = parseNaverNumber(row.closePrice);
5351            if (row.itemCode && price > 0) out.set(String(row.itemCode), { price, change: naverRatio(row) });
5352          }))
5353      );
5354      detailItems.forEach(item => jobs.push(
5355        fetchNaverProductDetail(item.src === 'fx' ? 'exchange' : item.src, item.c)
5356          .then(q => { if (q) out.set(item.c, q); })
5357      ));
5358      if (upbitCodes.length) jobs.push(
5359        fetchJson('https://api.upbit.com/v1/ticker?markets=' + upbitCodes.join(','))
5360          .then(rows => (rows || []).forEach(t => {
5361            if (t.market && Number(t.trade_price) > 0) out.set(String(t.market), { price: Number(t.trade_price), change: Number(t.signed_change_rate || 0) * 100 });
5362          }))
5363      );
5364
5365      const binanceCodes = marketPulse.filter(item => item.src === 'binance').map(item => item.c);
5366      if (binanceCodes.length) jobs.push(
5367        fetchJson('https://api.binance.com/api/v3/ticker/24hr?symbols=' + encodeURIComponent(JSON.stringify(binanceCodes)))
5368          .then(rows => (rows || []).forEach(t => {
5369            if (t.symbol && Number(t.lastPrice) > 0) out.set(String(t.symbol), { price: Number(t.lastPrice), change: Number(t.priceChangePercent) });
5370          }))
5371      );
5372
5373      // 5-min candle backfill: Naver foreign-index minute5 + Upbit minutes/5
5374      // 매 새로고침마다 돌릴 필요 없음 — 5분에 한 번만 (새로고침 응답성 우선)
5375      const backfillDue = Date.now() - (fetchPulseQuotes.lastBackfill || 0) > 5 * 60 * 1000;
5376      if (backfillDue) fetchPulseQuotes.lastBackfill = Date.now();
5377      (backfillDue ? marketPulse.filter(item =>
5377 item.cs === 'naver') : []).forEach(item => jobs.push(
5378        fetchJson(`https://api.stock.naver.com/chart/foreign/index/${encodeURIComponent(item.c)}/minute5?count=48`)
5379          .then(rows => {
5380            const candles = (rows || []).map(r => ({
5381              t: String(r.localDateTime || ''),
5382              o: Number(r.openPrice), h: Number(r.highPrice), l: Number(r.lowPrice), c: Number(r.currentPrice)
5383            })).filter(cd => cd.o > 0 && cd.h > 0 && cd.l > 0 && cd.c > 0);
5384            if (candles.length >= 2) state.pulseCandles[item.c] = candles.slice(-48);
5385          }).catch(() => {})
5386      ));
5387      const upbitCandleItems = backfillDue ? marketPulse.filter(item => item.cs === 'upbit') : [];
5388      if (upbitCandleItems.length) jobs.push((async () => {
5389        // Sequential with a small gap — Upbit throttles concurrent candle bursts.
5390        for (const item of upbitCandleItems) {
5391          try {
5392            const rows = await fetchJson(`https://api.upbit.com/v1/candles/minutes/5?market=${item.c}&count=48`);
5393            const candles = (rows || []).slice().reverse().map(r => ({
5394              t: String(r.candle_date_time_kst || ''),
5395              o: Number(r.opening_price), h: Number(r.high_price), l: Number(r.low_price), c: Number(r.trade_price)
5396            })).filter(cd => cd.o > 0 && cd.c > 0);
5397            if (candles.length >= 2) state.pulseCandles[item.c] = candles;
5398          } catch {}
5399          await sleep(140);
5400        }
5401      })());
5402
5403      (backfillDue ? marketPulse.filter(item => item.cs === 'binance') : []).forEach(item => jobs.push(
5404        fetchJson(`https://api.binance.com/api/v3/klines?symbol=${item.c}&interval=5m&limit=48`)
5405          .then(rows => {
5406            const candles = (rows || []).map(r => ({
5407              t: Number(r[0]), o: Number(r[1]), h: Number(r[2]), l: Number(r[3]), c: Number(r[4])
5408            })).filter(cd => cd.o > 0 && cd.c > 0);
5409            if (candles.length >= 2) state.pulseCandles[item.c] = candles;
5410          }).catch(() => {})
5411      ));
5412
5413      await Promise.allSettled(jobs);
5414      return out;
5415    }
5416
5417    function savePulseCandles() {
5418      localStorage.setItem(pulseCandleStorageKey, JSON.stringify(state.pulseCandles));
5419    }
5420
5421    // Client-side 5-min OHLC aggregation for assets without a minute-candle API (metals/energy/fx).
5422    function samplePulseCandle(code, price) {
5423      const bucket = Math.floor(Date.now() / 300000) * 300000;
5424      const arr = state.pulseCandles[code] || (state.pulseCandles[code] = []);
5425      const last = arr[arr.length - 1];
5426      if (last && last.t === bucket) {
5427        last.h = Math.max(last.h, price);
5428        last.l = Math.min(last.l, price);
5429        last.c = price;
5430      } else {
5431        arr.push({ t: bucket, o: price, h: price, l: price, c: price });
5432        if (arr.length > 48) arr.splice(0, arr.length - 48);
5433      }
5434    }
5435
5436    function applyPulseQuotes(pulseMap) {
5437      marketPulse.forEach(item => {
5438        const quote = pulseMap.get(item.c);
5439        if (quote?.price > 0) {
5440          item.v = quote.price;
5441          if (Number.isFinite(quote.change)) item.d = quote.change;
5442          if (item.cs === 'agg') samplePulseCandle(item.c, quote.price);
5443        }
5444      });
5445      savePulseCandles();
5446      renderMarketPulse();
5447    }
5448
5449    function applyFreeQuote(symbol, quote) {
5450      if (!quote || !(quote.price > 0)) return false;
5451      state.priceOverrides[symbol] = quote.price;
5452      updateSymbolReferences(symbol, quote.price, Number.isFinite(quote.change) ? quote.change : null);
5453      if (Number.isFinite(quote.change)) {
5454        state.prevCloseCache[symbol] = {
5455          ...(state.prevCloseCache[symbol] || {}),
5456          lastChange: quote.change,
5457          savedAt: new Date().toISOString()
5458        };
5459      }
5460      return true;
5461    }
5462
5463    async function refreshFreePrices(opts = {}) {
5464      const startedAt = Date.now();
5465      const targets = currentPriceTargets().filter(item => item && item.c);
5466      const codes = [...new Set(targets.map(item => String(item.c)))];
5467      const krCodes = codes.filter(isKoreanCode);
5468      const usCodes = codes.filter(code => !isKoreanCode(code) && /^[A-Z][A-Z0-9.\-]*$/.test(code));
5469
5470      const [krRes, usRes, pulseRes] = await Promise.allSettled([
5471        krCodes.length ? fetchNaverDomestic(krCodes) : Promise.resolve(new Map()),
5472        usCodes.length ? fetchNaverWorldStocks(usCodes) : Promise.resolve(new Map()),
5473        fetchPulseQuotes()
5474      ]);
5475
5476      let updated = 0;
5477      const fails = [];
5478      if (krRes.status === 'fulfilled') krRes.value.forEach((quote, symbol) =>
5478 { if (applyFreeQuote(symbol, quote)) updated++; });
5479      else if (krCodes.length) fails.push('국내');
5480      if (usRes.status === 'fulfilled') usRes.value.forEach((quote, symbol) => { if (applyFreeQuote(symbol, quote)) updated++; });
5481      else if (usCodes.length) fails.push('해외');
5482
5483      if (pulseRes.status === 'fulfilled') {
5484        applyPulseQuotes(pulseRes.value); // repaints pulse even if stock quotes fail below
5485      }
5486
5487      // 2단계 수급(연기금·신용·예탁금)·지수 일봉은 비차단으로 갱신 — 도착하면 다음 렌더에 반영
5488      fetchThermoFromServer().catch(() => {}); // 온도·시그널은 서버 계산 결과 수신
5489
5490      if (state.remoteHoldings) {
5491        for (const holding of state.remoteHoldings) {
5492          const symbol = String(holding.symbol || '');
5493          const quote = (krRes.status === 'fulfilled' && krRes.value.get(symbol)) || (usRes.status === 'fulfilled' && usRes.value.get(symbol));
5494          if (quote?.price > 0) holding.lastPrice = quote.price;
5495        }
5496      }
5497
5498      if (!updated && fails.length) throw new Error('무료 시세 조회 실패 (' + fails.join('/') + ')');
5499      state.dataMode = 'free';
5500      state.lastUpdatedAt = Date.now();
5501      savePrevCloseCache();
5502      if (!opts.silent) toast(`무료 시세 ${updated}건 갱신 · ${((Date.now() - startedAt) / 1000).toFixed(1)}초${fails.length ? ' · ' + fails.join('/') + ' 실패' : ''}${sessionOpenForView() ? '' : ' · 장 마감 (종가 기준)'}`);
5503    }
5504
5505    function cacheCandles(candleMap) {
5506      let changed = 0;
5507      candleMap.forEach((candles, symbol) => {
5508        const closes = (candles || [])
5509          .slice()
5510          .reverse()
5511          .map(candle => Number(candle.closePrice))
5512          .filter(v => Number.isFinite(v) && v > 0);
5513        if (closes.length >= 2) {
5514          state.candleCache[symbol] = {
5515            closes,
5516            interval: '1d',
5517            count: dailyCandleCount,
5518            savedAt: new Date().toISOString()
5519          };
5520          changed++;
5521        }
5522      });
5523      if (changed) saveCandleCache();
5524      return changed;
5525    }
5526
5527    function previousCloseFromCandles(candles) {
5528      return Number(candles?.[1]?.closePrice || candles?.[0]?.openPrice || 0);
5529    }
5530
5531    function applyPriceMap(targets, priceMap, candleMap = null) {
5532      let updated = 0;
5533      let changed = 0;
5534      for (const target of targets) {
5535        const symbol = String(target.c || '');
5536        const price = priceMap.get(symbol);
5537        if (!price?.lastPrice) continue;
5538        target.p = Number(price.lastPrice);
5539        state.priceOverrides[symbol] = target.p;
5540        const ref = lookup(symbol);
5541        if (ref) ref.p = target.p;
5542        updateSymbolReferences(symbol, target.p);
5543
5544        const prevClose = candleMap
5545          ? previousCloseFromCandles(candleMap.get(symbol) || [])
5546          : Number(state.prevCloseCache[symbol]?.prevClose || 0);
5547        if (prevClose > 0) {
5548          const dayChange = (target.p - prevClose) / prevClose * 100;
5549          target.d = dayChange;
5550          if (ref) ref.d = dayChange;
5551          updateSymbolReferences(symbol, target.p, dayChange);
5552          state.prevCloseCache[symbol] = {
5553            ...(state.prevCloseCache[symbol] || {}),
5554            prevClose,
5555            lastChange: dayChange,
5556            savedAt: new Date().toISOString()
5557          };
5558          changed++;
5559        }
5560        if (target.holding) target.holding.ref.p = target.p;
5561        updated++;
5562      }
5563      return { updated, changed };
5564    }
5565
5566    function updatePrevCloseCache(candleMap) {
5567      let changed = 0;
5568      candleMap.forEach((candles, symbol) => {
5569        const prevClose = previousCloseFromCandles(candles);
5570        if (prevClose > 0) {
5571          state.prevCloseCache[symbol] = {
5572            ...(state.prevCloseCache[symbol] || {}),
5573            prevClose,
5574            savedAt: new Date().toISOString()
5575          };
5576          changed++;
5577        }
5578      });
5579      if (changed) savePrevCloseCache();
5580      return changed;
5581    }
5582
5583    async function importTossPortfolio() {
5584      if (!can('byok')) throw new Error('잔고 불러오기는 Pro 기능입니다 — 설정에서 Pro를 시작해 주세요');
5585      const token = await issueTossToken();
5586      const authHeaders = { Authorization: `Bearer ${token}` };
5587      const accounts = await tossRequest('/api/v1/accounts', { headers: authHeaders });
5588      const account = accounts.result?.[0];
5589      if (!account) throw new Error('조회 가능한 토스증권 종합매매 계좌가 없습니다');
5590
5591      const accountHeaders = {
5592        ...authHeaders,
5593        'X-Tossinvest-Account': String(account.accountSeq)
5594      };
5595      const holdingsResponse = await tossRequest('/api/v1/holdings', { headers: accountHeaders });
5596      const overview = holdingsResponse.result;
5597      const items = overview?.items || [];
5598
5599      if (items.length) {
5600        const symbols = items.map(item =>
5600 item.symbol).filter(Boolean).slice(0, 200);
5601        if (symbols.length) {
5602          const { priceMap } = await fetchTossPrices(token, symbols);
5603          const { candleMap } = await fetchTossDailyCandles(token, symbols);
5604          cacheCandles(candleMap);
5605          updatePrevCloseCache(candleMap);
5606          for (const item of items) {
5607            const price = priceMap.get(String(item.symbol));
5608            if (price?.lastPrice) item.lastPrice = price.lastPrice;
5609            const candles = candleMap.get(String(item.symbol)) || [];
5610            const prevClose = previousCloseFromCandles(candles);
5611            const latest = Number(item.lastPrice || 0);
5612            if (prevClose > 0 && latest > 0) {
5613              item.dailyProfitLoss = item.dailyProfitLoss || {};
5614              item.dailyProfitLoss.rate = String((latest - prevClose) / prevClose);
5615            }
5616          }
5617        }
5618      }
5619
5620      state.remoteOverview = overview;
5621      state.remoteHoldings = items;
5622      state.pfEmpty = items.length === 0;
5623      state.tab = 'portfolio';
5624      state.dataMode = 'api';
5625      state.lastUpdatedAt = Date.now();
5626      saveSettings({ connected: true });
5627      toast(items.length ? `토스증권 보유종목 ${items.length}건을 가져왔습니다` : '토스증권 보유종목이 없습니다');
5628    }
5629
5630    async function importPortfolioFromApi() {
5631      if (state.provider !== 'toss') {
5632        throw new Error('현재 실제 가져오기는 토스증권 API만 연결되어 있습니다');
5633      }
5634      await importTossPortfolio();
5635    }
5636
5637    function openFullTab() {
5638      if (bridge.active) { bridge._post({ type: 'ht-open-newtab' }); return; }
5639      if (window.chrome?.tabs?.create && window.chrome?.runtime?.getURL) {
5640        chrome.tabs.create({ url: chrome.runtime.getURL('index.html?mode=full') });
5641      } else {
5642        window.open('index.html?mode=full', '_blank');
5643      }
5644    }
5645
5646    function currentPriceTargets() {
5647      if (state.tab === 'custom') {
5648        // 보드 모든 pane 아이템의 합집합(코드 중복 제거) → 자동 새로고침이 US 포함 전 pane을 갱신
5649        const seen = new Set(), out = [];
5650        state.customBoard.panes.forEach(p => paneItems(p).forEach(it => {
5651          const c = String(it.c || '');
5652          if (c && !seen.has(c)) { seen.add(c); out.push(it); }
5653        }));
5654        return out;
5655      }
5656      if (state.tab === 'etf') return activeEtfs();
5657      if (state.tab === 'market') return marketItems();
5658      return portfolio().items.map(item =>
5658 item.ref).filter(Boolean);
5659    }
5660
5661    // 임베드(확장 셸) 모드에서 네이티브 설정 표면을 열도록 셸에 요청 (E4/S1)
5662    function openNativeSettings() {
5663      if (bridge.active) bridge._post({ type: 'ht-open-settings' });
5664    }
5665
5666    // BYOK(증권사 API 연결)는 Pro 전용 — 진입 시 가드
5667    function guardByok() {
5668      // 임베드 모드: BYOK는 네이티브 전용 — 인앱 모달 대신 네이티브 설정 표면으로 안내 (S1/E4)
5669      if (bridge.active) { openNativeSettings(); toast('확장 설정에서 실시간 연결을 진행하세요'); return false; }
5670      if (IS_WEB) { toast('실시간 연결(BYOK)은 크롬 확장에서 지원합니다'); return false; }
5671      if (can('byok')) return true;
5672      toast('실시간 연결(BYOK)은 Pro 기능입니다 · 결제 페이지를 열게요');
5673      window.open(LICENSE_SERVER + '/pay.html', '_blank');
5674      return false;
5675    }
5676
5677    function hasBrokerSecrets() {
5678      return Boolean((state.key && state.secret) || state.secureBlob);
5679    }
5680
5681    async function refreshVisiblePrices(opts = {}) {
5682      // Toss route needs keys (may prompt PIN — never during silent auto refresh); otherwise free route.
5683      const tossReady = can('byok') && state.provider === 'toss' && ((state.key && state.secret) || (!opts.silent && hasBrokerSecrets()));
5684      if (tossReady) return refreshTossPrices(opts);
5685      return refreshFreePrices(opts);
5686    }
5687
5688    async function refreshTossPrices(opts = {}) {
5689      const targets = currentPriceTargets().filter(item => item && item.c);
5690      if (!targets.length) throw new Error('새로고침할 종목이 없습니다');
5691      const token = await issueTossToken();
5692      const symbols = targets.map(item => item.c);
5693      const candleSymbols = symbols;
5694      const { priceMap, failed: priceFailed } = await fetchTossPrices(token, symbols);
5695      const quick = applyPriceMap(targets, priceMap);
5696      if (state.remoteHoldings) {
5697        for (const holding of state.remoteHoldings) {
5698          const price = priceMap.get(String(holding.symbol));
5699          if (price?.lastPrice) holding.lastPrice = price.lastPrice;
5700        }
5701      }
5702      // Pulse indicators are not covered by Toss symbols — refresh them from free sources.
5703      fetchPulseQuotes().then(applyPulseQuotes).catch(() => {});
5704      state.dataMode = 'api';
5705      state.lastUpdatedAt = Date.now();
5706      if (!opts.silent) toast(`현재가 ${quick.updated}건 즉시 반영${priceFailed ? ` · 가격 실패 ${priceFailed}건` : ''}${quick.changed ? ` · 등락률 ${quick.changed}건` : ' · 등락률/그래프 보정 중'}`);
5707
5708      fetchTossDailyCandles(token, candleSymbols).then(({ candleMap, failed }) => {
5709        const charted = cacheCandles(candleMap);
5710        updatePrevCloseCache(candleMap);
5711        const checked = applyPriceMap(targets, priceMap, candleMap);
5712        render();
5713        if (!opts.silent) toast(`등락률 ${checked.changed}건 · 그래프 ${charted}건 보정 완료${failed ? ` · 실패 ${failed}건` : ''}`);
5714      }).catch(error => { if (!opts.silent) toast('등락률 보정 실패 · ' + error.message); });
5715    }
5716
5717    function unlockPro() {
5718      state.proUnlocked = true;
5719      toast('Pro 미리보기가 잠금 해제되었습니다');
5720      render();
5721      scanStockSignals().catch(() => {});
5722    }
5723
5724    function finishOnboarding(importBalance) {
5725      state.modal = false;
5726      state.dataMode = 'connected';
5727      saveSettings({ connected: true });
5728      if (importBalance) {
5729        state.tab = 'portfolio';
5730        state.pfEmpty = false;
5731      }
5732      toast(importBalance ? 'API 키를 저장했습니다 · 현재 포트폴리오는 샘플 데이터입니다' : 'API 키를 저장했습니다 · 실제 조회 호출은 아직 연결되지 않았습니다');
5733      render();
5734    }
5735
5736    // --- 스텔스 모드: 스페이스바/툴바 버튼으로 위장 화면 전환 (테마 4종) ---
5737    const STEALTH_THEMES = {
5738      google: () => `
5739        <div class="stealth-inner">
5740          <div class="stealth-clock" id="stealthClock">--:--</div>
5741          <div class="stealth-date" id="stealthDate"></div>
5742          <form class="stealth-search" action="https://www.google.com/search" method="get">
5743            <input name="q" placeholder="Google 검색 또는 URL 입력" autocomplete="off">
5744          </form>
5745          <div class="stealth-links">
5746            <a href="https://mail.google.com">메일</a>
5747            <a href="https://calendar.google.com">캘린더</a>
5748            <a href="https://drive.google.com">드라이브</a>
5749            <a href="https://docs.google.com">문서</a>
5750          </div>
5751        </div>`,
5752      naver: () => `
5753        <div class="stealth-inner">
5754          <div class="nv-mark">NAVER</div>
5755          <form class="stealth-search" action="https://search.naver.com/search.naver" method="get">
5756            <input name="query" placeholder="검색어를 입력해 주세요" autocomplete="off">
5757          </form>
5758          <div class="stealth-links">
5759            <a href="https://mail.naver.com">메일</a>
5760            <a href="https://news.naver.com">뉴스</a>
5761            <a href="https://cafe.naver.com">카페</a>
5762            <a href="https://blog.naver.com">블로그</a>
5763          </div>
5764        </div>`,
5765      ide: () => {
5766        const code = [
5767          ['1', '<span class="ide-cm">// utils/date-format.ts</span>'],
5768          ['2', '<span class="ide-kw">
5768import</span> { <span class="ide-va">Locale</span> } <span class="ide-kw">from</span> <span class="ide-str">"./locale"</span>;'],
5769          ['3', ''],
5770          ['4', '<span class="ide-kw">export function</span> <span class="ide-fn">formatRelative</span>(date: <span class="ide-ty">Date</span>, now: <span class="ide-ty">Date</span> = <span class="ide-kw">new</span> <span class="ide-ty">Date</span>()): <span class="ide-ty">string</span> {'],
5771          ['5', '  <span class="ide-kw">const</span> <span class="ide-va">diffMs</span> = now.<span class="ide-fn">getTime</span>() - date.<span class="ide-fn">getTime</span>();'],
5772          ['6', '  <span class="ide-kw">const</span> <span class="ide-va">minutes</span> = <span class="ide-ty">Math</span>.<span class="ide-fn">floor</span>(diffMs / <span class="ide-str">60_000</span>);'],
5773          ['7', ''],
5774          ['8', '  <span class="ide-kw">if</span> (minutes < <span class="ide-str">1</span>) <span class="ide-kw">return</span> <span class="ide-str">"방금 전"</span>;'],
5775          ['9', '  <span class="ide-kw">if</span> (minutes < <span class="ide-str">60</span>) <span class="ide-kw">return</span> <span class="ide-str">`${minutes}분 전`</span>;'],
5776          ['10', ''],
5777          ['11', '  <span class="ide-kw">const</span> <span class="ide-va">hours</span> = <span class="ide-ty">Math</span>.<span class="ide-fn">floor</span>(minutes / <span class="ide-str">60</span>);'],
5778          ['12', '  <span class="ide-kw">if</span> (hours < <span class="ide-str">24</span>) <span class="ide-kw">return</span> <span class="ide-str">`${hours}시간 전`</span>;<span class="ide-cursor"></span>'],
5779          ['13', ''],
5780          ['14', '  <span class="ide-cm">// TODO: 주 단위 표시는 로케일 옵션과 함께 처리</span>'],
5781          ['15', '  <span class="ide-kw">return</span> date.<span class="ide-fn">toLocaleDateString</span>(<span class="ide-va">Locale</span>.<span class="ide-va">current</span>);'],
5782          ['16', '}']
5783        ].map(([ln, html]) => `<div class="ide-line"><span class="ide-ln">${ln}</span><span>${html}</span></div>`).join('');
5784        return `
5785        <div class="ide-window">
5786          <div class="ide-titlebar">
5787            <span class="ide-dot" style="background:#ff5f57"></span><span class="ide-dot" style="background:#febc2e"></span><span class="ide-dot" style="background:#28c840"></span>
5788            <span class="ide-title">date-format.ts — workspace</span>
5789          </div>
5790          <div class="ide-body">
5791            <div class="ide-side">
5792              <div>▾ src</div>
5793              <div>&nbsp;&nbsp;▾ utils</div>
5794              <div class="on">&nbsp;&nbsp;&nbsp;&nbsp;date-format.ts</div>
5795              <div>&nbsp;&nbsp;&nbsp;&nbsp;locale.ts</div>
5796              <div>&nbsp;&nbsp;&nbsp;&nbsp;string.ts</div>
5797              <div>&nbsp;&nbsp;▸ components</div>
5798              <div>&nbsp;&nbsp;▸ hooks</div>
5799              <div>&nbsp;&nbsp;index.ts</div>
5800              <div>▸ tests</div>
5801              <div>package.json</div>
5802              <div>tsconfig.json</div>
5803            </div>
5804            <div class="ide-code">${code}</div>
5805          </div>
5806          <div class="ide-status"><span>main*</span><span>TypeScript</span><span>UTF-8</span><span style="flex:1"></span><span>Ln 12, Col 41</span></div>
5807        </div>`;
5808      },
5809      claude: () => `
5810        <div class="stealth-inner">
5811          <div class="cl-greet">무엇을 도와드릴까요?</div>
5812          <form class="cl-box" action="https://claude.ai/new" method="get">
5813            <input name="q" placeholder="무엇이든 물어보세요" autocomplete="off">
5814            <div class="cl-foot"><span class="cl-model">Claude</span><button class="cl-send" type="submit">↑</button></div>
5815          </form>
5816          <div class="cl-chips"><span>이메일 초안 작성</span><span>회의록 요약</span><span>코드 리뷰</span><span>보고서 개요</span></div>
5817        </div>`
5818    };
5819
5820    function renderStealth() {
5821      const el = $('stealth');
5822      if (!el) return;
5823      el.classList.toggle('show', state.stealth);
5824      if (state.stealth) {
5825        const theme = STEALTH_THEMES[state.stealthTheme] ? state.stealthTheme : 'google';
5826        if (el.dataset.theme !== theme || !el.innerHTML) {
5827          el.dataset.theme = theme;
5828          el.innerHTML = STEALTH_THEMES[theme]();
5829        }
5830        updateStealthClock();
5831      }
5832    }
5833
5834    function updateStealthClock() {
5835      const clock = $('stealthClock');
5836      const date = $('stealthDate');
5837      if (!clock || !state.stealth) return;
5838      const now = new Date();
5839      clock.textContent = String(now.getHours()).padStart(2, '0') + ':' + String(now.getMinutes()).padStart(2, '0');
5840      date.textContent = now.toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' });
5841    }
5842
5843    setInterval(updateStealthClock, 15000);
5844
5845    function toggleStealth() {
5846      state.stealth = !state.stealth;
5847      hidePopover();
5848      renderStealth();
5849    }
5850
5851    function render() {
5852      renderStealth();
5853      renderControls();
5854      renderMarketPulse();
5855      renderPortfolio();
5856      renderMap();
5857      renderSettings();
5858      renderSignals();
5859      renderModal();
5860    }
5861
5862    function setPulseHeight(height) {
5863      const key = pulseHeightKey();
5864      const [min, max] = PULSE_STYLE_RANGE[key] || [32, 300];
5865      state.pulseHeights[key] = clamp(Math.round(height), min, max);
5866      applyPulseScale();
5867    }
5868
5869    // 스트립 높이와 글자 배율을 CSS 변수로만 반영 — 드래그 중에도 가볍게 동작
5870    function applyPulseScale() {
5871      const app = $('app');
5872      const pulse = $('marketPulse');
5873      if (!app || !pulse) return;
5874      const key = pulseHeightKey();
5875      const base = key === 'side' ? 116 : (PULSE_STYLE_HEIGHT[currentPulseStyle()] || 40);
5876      const h = state.pulseHeights[key] || base;
5877      // 사이드 패널은 배율을 완만하게(가독성) 두고, 높이는 그리드 행 증가로 더 많은 지표를 노출
5878      const scale = key === 'side'
5879        ? clamp(0.96 + (h - base) / 520, 0.96, 1.18)
5880        : clamp(Math.sqrt(h / base), 0.9, 1.35);
5881      app.style.setProperty('--pulse-height', h + 'px');
5882      pulse.style.setProperty('--pulse-scale', scale.toFixed(3));
5883    }
5884
5885    document.addEventListener('click', e => {
5886      // 관심종목 검색 드롭다운: 후보 클릭 시 추가, 바깥 클릭 시 닫기 (버튼 체인보다 먼저 — 후보는 div라 closest('button')에 안 걸림)
5887      const wlRow = e.target.closest?.('[data-wl-code]');
5888      if (wlRow) { addWatchlistByRef(wlAuto.items[Number(wlRow.dataset.wlIdx)] || { c: wlRow.dataset.wlCode }); return; }
5889      if (!e.target.closest?.('.watch-add')) closeWlDropdown();
5890      // 보드 소스 메뉴: 헤더/메뉴 바깥 클릭 시 닫기 (메뉴 내부 클릭·소스 버튼은 유지)
5891      if (state.boardMenu != null && !e.target.closest?.('.pane-head') && !e.target.closest?.('.pane-src-panel')) { state.boardMenu = null; renderMap(); }
5892
5893      if (e.target.id === 'settingsPanelBackdrop') {
5894        state.settingsOpen = false;
5895        render();
5896        return;
5897      }
5898      if (e.target.id === 'signalsBackdrop') {
5899        state.signalsOpen = false;
5900        state.signalsDetail = null;
5901        if (state.catThermo) { closeCategoryThermo(); return; }
5902        if (state.pfEditorOpen) { closePfEditor(); return; }
5903        if (state.stockPanel) {
5904          state.stockPanel = null;
5905          $('signalsPanel')?.classList.remove('open');
5906          $('signalsBackdrop')?.classList.remove('show');
5907        }
5908        renderSignals();
5909        return;
5910      }
5911      const target = e.target.closest('button');
5912      if (!target) {
5913        state.menu = null;
5914        renderControls();
5915        return;
5916      }
5917      if (target.dataset.period) {
5918        state.period = target.dataset.period;
5919        ensurePeriodBases();
5920        if (isFlowBasis() && ['1M', 'YTD'].includes(state.period)) {
5921          if (can('flowLong')) fetchFlowLong();
5922          else if (!state.flowLongNoticeShown) { state.flowLongNoticeShown = true; toast('1개월·연초 누적 수급은 Pro에서 제공됩니다 (무료: 10일 누적)'); }
5923        }
5924      }
5925      if (target.dataset.basis) state.pfBasis = target.dataset.basis;
5926      if (target.dataset.viewMode) state.viewMode = target.dataset.viewMode;
5927      if (target.dataset.etfGroup) {
5928        state.etfGroup = target.dataset.etfGroup === 'brand' ? 'brand' : 'cat';
5929        state.drill = null; state.etfCat = null; // 그룹 기준이 바뀌므로 드릴 상태 초기화
5930        saveSettings();
5931        hidePopover();
5932      }
5933      if (target.dataset.tab) {
5934        state.tab = target.dataset.tab; state.drill = null; state.etfCat = null; state.boardMenu = null; hidePopover(); ensurePeriodBases();
5935        if (state.tab === 'etf' && !state.etfDynamic?.length) {
5936          loadEtfs().then(list => { if (list?.length) render(); }).catch(() => {}); // 실패 시 하드코딩 fallback 유지
5937        }
5938        if (state.tab === 'custom') {
5939          // 보드 진입 시 1회 무료 시세 갱신 (US 등 미방문 소스도 최신화). 이후 자동 갱신 인터벌이 유지.
5940          refreshVisiblePrices({ silent: true }).then(() => { if (state.tab === 'custom') renderMap(); }).catch(() => {});
5941        }
5942      }
5943      // --- 내 히트맵 보드 컨트롤 ---
5944      const boardRefresh = () => refreshVisiblePrices({ silent: true }).then(() => { if (state.tab === 'custom') renderMap(); }).catch(() => {});
5945      const cutInt = s => [s.slice(0, s.indexOf(':')), s.slice(s.indexOf(':') + 1)]; // 첫 ':'로만 분리 (섹터명에 ·는 있어도 :는 없음)
5946      if (target.dataset.boardLayout) {
5947        state.customBoard.layout = BOARD_LAYOUTS[target.dataset.boardLayout] ? target.dataset.boardLayout : '2h';
5948        ensureBoardPanes();
5949        state.boardMenu = null;
5950        saveSettings();
5951        renderMap();
5952        return;
5953      }
5954      if (target.dataset.boardAdd) {
5955        if (state.customBoard.panes.length < BOARD_MAX_PANES) {
5956          state.customBoard.panes.push({ src: 'KOSPI200', period: '1D', view: 'map' });
5957          state.customBoard.layout = 'auto'; // 수동 개수 변경 → 자동 그리드
5958          saveSettings(); renderMap(); boardRefresh();
5959        }
5960        return;
5961      }
5962      if (target.dataset.paneDel != null) {
5963        const i = Number(target.dataset.paneDel);
5964        if (state.customBoard.panes.length > 1) {
5965          state.customBoard.panes.splice(i, 1);
5966          state.customBoard.layout = 'auto';
5967          state.boardMenu = null;
5968          saveSettings(); renderMap();
5969        }
5970        return;
5971      }
5972      if (target.dataset.paneMove) {
5973        const [i, dir] = cutInt(target.dataset.paneMove);
5974        const idx = Number(i), j = dir === 'prev' ? idx - 1 : idx + 1;
5975        const panes = state.customBoard.panes;
5976        if (j >= 0 && j < panes.length) { [panes[idx], panes[j]] = [panes[j], panes[idx]]; saveSettings(); renderMap(); }
5977        return;
5978      }
5979      if (target.dataset.paneView != null) {
5980        const pane = state.customBoard.panes[Number(target.dataset.paneView)];
5981        if (pane) { pane.view = pane.view === 'list' ? 'map' : 'list'; saveSettings(); renderMap(); }
5982        return;
5983      }
5984      if (target.dataset.panePeriod) {
5985        const [i, pr] = target.dataset.panePeriod.split(':');
5986        const pane = state.customBoard.panes[Number(i)];
5987        if (pane && ['1D', '1W', '1M', 'YTD'].includes(pr)) { pane.period = pr; saveSettings(); renderMap(); }
5988        return;
5989      }
5990      if (target.dataset.paneSrc != null) {
5991        const i = Number(target.dataset.paneSrc);
5992        state.boardMenu = state.boardMenu === i ? null : i; // 소스 메뉴 토글 (renderBoard가 ETF 유니버스 로드)
5993        renderMap();
5994        return;
5995      }
5996      if (target.dataset.paneSetsrc) {
5997        const [i, v] = cutInt(target.dataset.paneSetsrc);
5998        const pane = state.customBoard.panes[Number(i)];
5999        if (pane && BOARD_SOURCES.some(([s]) => s === v)) { pane.src = v; delete pane.sec; delete pane.code; delete pane.name; }
6000        state.boardMenu = null;
6001        saveSettings(); renderMap(); boardRefresh();
6002        return;
6003      }
6004      if (target.dataset.paneSetsec) {
6005        const [i, sec] = cutInt(target.dataset.paneSetsec);
6006        const pane = state.customBoard.panes[Number(i)];
6007        if (pane && sec) { pane.src = 'sector'; pane.sec = sec; delete pane.code; delete pane.name; }
6008        state.boardMenu = null;
6009        saveSettings(); renderMap(); boardRefresh();
6010        return;
6011      }
6012      if (target.dataset.paneSetetf) {
6013        const code = target.dataset.paneSetetf;
6014        const pane = state.customBoard.panes[state.boardMenu];
6015        const etf = activeEtfs().find(e => e.c === code);
6016        if (pane && etf) { pane.src = 'etf'; pane.code = code; pane.name = etf.n; delete pane.sec; }
6017        state.boardMenu = null;
6018        saveSettings(); renderMap(); boardRefresh();
6019        return;
6020      }
6021      if (target.dataset.index) {
6022        state.index = target.dataset.index;
6023        if (state.index === 'KOSDAQ150') {
6024          if (!state.kosdaqStocks?.length) {
6025            toast('코스닥150 구성종목을 불러오는 중입니다');
6026            loadKosdaq150().then(() => {
6027              render();
6028              refreshVisiblePrices({ silent: true }).then(render).catch(() => {});
6029              ensurePeriodBases();
6030            }).catch(() => toast('코스닥150 로드 실패 · 다시 시도해 주세요'));
6031          }
6032          ensureIndustryMap().then(map => { if (map) renderMap(); }).catch(() => {});
6033        }
6034        ensurePeriodBases();
6035        fetchThermoFromServer().catch(() => {});
6036      }
6037      if (target.dataset.color) {
6038        state.colorBasis = target.dataset.color;
6039        if (isFlowBasis() && ['1M', 'YTD'].includes(state.period) && can('flowLong')) fetchFlowLong();
6040        if (isFlowBasis() && state.tab === 'market' && ['S&P500', 'NASDAQ100'].includes(state.index)) {
6041          toast('수급 데이터는 국내 종목에서만 제공됩니다');
6042        }
6043      }
6044      if (target.dataset.provider) {
6045        state.provider = target.dataset.provider;
6046        state.test = 'idle';
6047        saveSettings();
6048      }
6049
6050      if (target.id === 'pinUnlockConfirm') { submitPinUnlock(); return; }
6051      if (target.id === 'pinUnlockCancel') { cancelPinUnlock(); render(); return; }
6052
6053      // 시그널 패널
6054      if (target.id === 'thermoChip') {
6055        state.signalsOpen = true;
6056        fetchThermoFromServer().catch(() => {});
6057        fetchInvestorsFromServer().catch(() => {});
6058        fetchSectorFlowFromServer().catch(() => {});
6059        state.signalsDetail = null;
6060        ensureFlows(thermoTopCodes);
6061        renderSignals();
6062        return;
6063      }
6064      if (target.id === 'signalsClose') {
6065        state.signalsOpen = false;
6066        state.signalsDetail = null;
6067        renderSignals();
6068        return;
6069      }
6070      if (target.id === 'stockPanelClose') {
6071        state.stockPanel = null;
6072        $('signalsPanel')?.classList.remove('open');
6073        $('signalsBackdrop')?.classList.remove('show');
6074        return;
6075      }
6076      if (target.id === 'catThermoClose') {
6077        closeCategoryThermo();
6078        return;
6079      }
6080      if (target.id === 'catThermoDrill') {
6081        // 온도 패널에서 바로 카테고리/운용사 ETF 펼치기 (더블클릭 없이 접근)
6082        const label = state.catThermo;
6083        closeCategoryThermo();
6084        if (label) { state.tab = 'etf'; state.etfCat = label; hidePopover(); render(); }
6085        return;
6086      }
6087      if (target.dataset.etfCode) {
6088        // ETF 상세 패널에서 구성종목 펼치기 (더블클릭 없이 접근)
6089        const etf = activeEtfs().find(e => e.c === target.dataset.etfCode);
6090        if (state.stockPanel) {
6091          state.stockPanel = null;
6092          $('signalsPanel')?.classList.remove('open');
6093          $('signalsBackdrop')?.classList.remove('show');
6094        }
6095        if (etf) {
6096          state.tab = 'etf';
6097          state.etfCat = etfGroupKeyOf(etf);
6098          if (etf.cons?.length) { state.drill = etf.n; render(); }
6099          else { toast(`${etf.n} 구성종목을 불러오는 중입니다`); loadEtfCons(etf).then(ok => { if (ok) state.drill = etf.n; render(); }).catch(() => render()); render(); }
6100        }
6101        return;
6102      }
6103      if (target.dataset.openEtf) {
6104        // 카테고리 온도 패널의 상승/하락 종목 클릭 → 해당 ETF 드릴
6105        const name = decodeURIComponent(target.dataset.openEtf);
6106        const etf = activeEtfs().find(e => e.n === name);
6107        closeCategoryThermo();
6108        if (etf) {
6109          state.tab = 'etf';
6110          state.etfCat = etfGroupKeyOf(etf); // 현재 그룹 모드의 상위 레벨로 back 되도록
6111          if (etf.cons?.length) { state.drill = name; render(); }
6112          else { loadEtfCons(etf).then(ok => { if (ok) state.drill = name; render(); }).catch(() => render()); render(); }
6113        }
6114        return;
6115      }
6116      if ((target.id === 'stockPanelRefresh' || target.dataset.stockRetry) && state.stockPanel) {
6117        if (!state.stockPanelLoading) openStockPanel(state.stockPanel, true);
6118        return;
6119      }
6120      if (target.dataset.flowDays && state.stockPanel) {
6121        const d = Number(target.dataset.flowDays);
6122        if (d > 10 && !can('flowLong')) { toast('장기 수급 추이는 Pro에서 제공됩니다 (무료: 10일)'); return; }
6123        state.stockFlowDays = d;
6124        const key = state.stockPanel + ':' + d;
6125        const cached = state.stockFlowCache[key];
6126        if (d > 10 && (!cached || Date.now() - cached.at >= 3600000)) fetchStockFlowSeries(state.stockPanel, d); // 비동기 로드 후 재렌더
6127        renderStockPanel();
6128        return;
6129      }
6130      if (target.id === 'alertTargetSave' && state.stockPanel) {
6131        if (!can('alerts')) { toast('목표가 알림은 Pro 기능입니다'); return; }
6132        const above = Number(($('alertAbove')?.value || '').replace(/[^\d]/g, ''));
6133        const below = Number(($('alertBelow')?.value || '').replace(/[^\d]/g, ''));
6134        const code = state.stockPanel;
6135        state.alertsCfg.targets = state.alertsCfg.targets || {};
6136        if (above || below) {
6137          state.alertsCfg.targets[code] = { ...(above ? { above } : {}), ...(below ? { below } : {}) };
6138          toast(`목표가 저장 · ${above ? '상한 ' + above.toLocaleString('ko-KR') + '원' : ''}${above && below ? ' / ' : ''}${below ? '하한 ' + below.toLocaleString('k
6138o-KR') + '원' : ''}`);
6139        } else {
6140          delete state.alertsCfg.targets[code];
6141          toast('목표가 알림을 해제했습니다');
6142        }
6143        saveAlertsCfg();
6144        return;
6145      }
6146      if (target.id === 'sigBack') { state.signalsDetail = null; renderSignals(); return; }
6147      if (target.dataset.sigTab) {
6148        state.signalsTab = target.dataset.sigTab;
6149        state.signalsDetail = null;
6150        renderSignals();
6151        return;
6152      }
6153      if (target.dataset.openStock) {
6154        openStockPanel(target.dataset.openStock);
6155        return;
6156      }
6157      if (target.dataset.sigDetail) {
6158        if (can('indicatorDetail')) {
6159          state.signalsDetail = target.dataset.sigDetail;
6160          renderSignals();
6161        } else {
6162          toast('지표 상세는 Pro에서 열려요 · 미리보기로 체험할 수 있습니다');
6163        }
6164        return;
6165      }
6166      if (target.dataset.secWindow) {
6167        state.sectorWindow = ['s', 'm', 'q', 'y'].includes(target.dataset.secWindow) ? target.dataset.secWindow : 's';
6168        renderSignals();
6169        return;
6170      }
6171      if (target.dataset.secView) {
6172        state.sectorView = target.dataset.secView === 'river' ? 'river' : 'rank';
6173        renderSignals();
6174        return;
6175      }
6176      if (target.dataset.riverWeeks) {
6177        state.riverWeeks = Number(target.dataset.riverWeeks) === 26 ? 26 : 12;
6178        renderSignals();
6179        return;
6180      }
6181      if (target.dataset.usSecWindow) {
6182        state.usSectorWindow = ['d', 'w', 'm'].includes(target.dataset.usSecWindow) ? target.dataset.usSecWindow : 'd';
6183        renderSignals();
6184        return;
6185      }
6186      if (target.id === 'sigStartPro' || target.id === 'sigStartPro2' || target.id === 'sigStartProInv' || target.id === 'sigStartProSector') {
6187        window.open(LICENSE_SERVER + '/pay.html', '_blank');
6188        return;
6189      }
6190
6191      if (target.id === 'indexBtn') state.menu = state.menu === 'index' ? null : 'index';
6192      else if (target.id === 'colorBtn') state.menu = state.menu === 'color' ? null : 'color';
6193      else if (target.id === 'settingsBtn') { state.settingsOpen = !state.settingsOpen; state.menu = null; }
6194      else if (target.id === 'settingsClose') state.settingsOpen = false;
6195      else if (target.id === 'stealthBtn') { toggleStealth(); return; }
6196      else if (target.id === 'toolbarToggle') { state.toolbarCollapsed = !state.toolbarCollapsed; saveSettings(); }
6197      else if (target.id === 'statusBtn') {
6198        if (state.dataMode === 'eod') { if (!guardByok()) return; state.modal = true; state.step = 1; }
6199        else { state.settingsOpen = true; state.menu = null; }
6200      }
6201      else if (target.id === 'byokBanner') { if (!guardByok()) return; state.modal = true; state.step = 1; hidePopover(); }
6202      else if (target.id === 'viewBtn') {
6203        if (state.sideMode) openFullTab();
6204        else state.sideMode = true;
6205      }
6206      else if (target.id === 'refreshPortfolio' || target.id === 'importNow' || target.id === 'refreshFloating') {
6207        if (refreshInFlight) { toast('이미 새로고침 중입니다'); return; }
6208        target.disabled = true;
6209        setRefreshInFlight(true);
6210        const work = withRefreshTimeout(state.tab === 'portfolio' ? importPortfolioFromApi() : refreshVisiblePrices());
6211        toast(state.tab === 'portfolio' ? '최신 포트폴리오를 조회하는 중입니다' : '현재가를 새로고침하는 중입니다');
6212        work.then(render).catch(err => toast('새로고침 실패 · ' + err.message)).finally(() => { target.disabled = false; setRefreshInFlight(false); });
6213        return;
6214      }
6215      else if (target.id === 'hideBtn') state.hideAmounts = !state.hideAmounts;
6216      else if (target.id === 'panelBtn') state.pfPanelOpen = !state.pfPanelOpen;
6217      else if (target.id === 'addHolding' || target.id === 'pfEditBtn') { openPfEditor(); return; }
6218      else if (target.id === 'pfEditorClose') { closePfEditor(); return; }
6219      else if (target.id === 'pfAddBtn') { pfAddFromInputs(); return; }
6220      else if (target.id === 'pfSave') { pfSaveDraft(); return; }
6221      else if (target.dataset.pfDel !== undefined) {
6222        state.pfDraft.splice(Number(target.dataset.pfDel), 1);
6223        renderPfEditor();
6224        return;
6225      }
6226      else if (target.id === 'importHolding') {
6227        target.disabled = true;
6228        toast('포트폴리오를 가져오는 중입니다');
6229        importPortfolioFromApi().then(render).catch(err => toast('가져오기 실패 · ' + err.message)).finally(() => { target.disabled = false; });
6230        return;
6231      }
6232      else if (target.id === 'proUpsell') { window.open(LICENSE_SERVER + '/pay.html', '_blank'); return; }
6233      else if (target.id === 'drillBack') {
6234        if (state.drill) state.drill = null;
6235        else state.etfCat = null;
6236      }
6237      else if (target.id === 'drillLoadMore') {
6238        const key = state.etfCat;
6239        if (!key) return;
6240        target.disabled = true;
6241        target.textContent = '불러오는 중…';
6242        loadEtfGroupRest(key).then(n => {
6243          render();
6244          toast(n > 0 ? `${key} ETF ${n}개를 더 가져왔습니다` : '추가할 ETF가 없습니다');
6245        }).catch(() => { render(); toast('추가 로드 실패 · 다시 시도해 주세요'); });
6246        return;
6247      }
6248      else if (target.id === 'licBuy') { window.open(LICENSE_SERVER + '/', '_blank'); return; }
6249      else if (target.id === 'feedbackBtn') {
6250        const ver = (window.chrome?.runtime?.getManifest?.().version) || '';
6251        const ctx = state.sideMode ? 'side' : 'newtab';
6252        window.open(`${LICENSE_SERVER}/feedback.html?v=${encodeURIComponent(ver)}&ctx=${ctx}`, '_blank');
6253        return;
6254      }
6255      else if (target.id === 'licInputToggle') { state.licenseInputOpen = !state.licenseInputOpen; renderSettings(); return; }
6256      else if (target.id === 'licTokenApply') {
6257        const token = ($('licTokenInput')?.value || '').trim();
6258        if (token) applyLicenseToken(token);
6259        return;
6260      }
6261      else if (target.id === 'licRefresh') { loadLicense(true); return; }
6262      else if (target.id === 'licRetry') { loadLicense(true, true).then(() => renderSettings()); return; }
6263      else if (target.id === 'licRemove') {
6264        releaseLicense().then(() => { toast('이 기기에서 라이선스를 해제했습니다'); renderSettings(); });
6265      }
6266      else if (target.id === 'openByok') { if (!guardByok()) return; state.modal = true; state.step = 1; state.menu = null; state.settingsOpen = false; }
6267      else if (target.id === 'openNativeSettingsBtn' || target.id === 'byokOpenSettings') { openNativeSettings(); toast('확장 설정을 새 탭에서 엽니다'); }
6268      else if (target.id === 'watchlistAddBtn') { addWatchlistSymbol(); return; }
6269      else if (target.dataset.removeWatch) {
6270        state.watchlistSymbols = state.watchlistSymbols.filter(s => s !== target.dataset.removeWatch);
6271        saveSettings();
6272      }
6273      else if (target.dataset.autoRefresh !== undefined) {
6274        const sec = Number(target.dataset.autoRefresh);
6275        if (!can('fastRefresh') && sec > 0 && sec < 300) {
6276          toast('30초·1분 주기는 Pro 기능입니다 · 무료는 5분 주기로 제공됩니다');
6277          return;
6278        }
6279        state.autoRefreshSec = sec;
6280        state.autoFailures = 0;
6281        saveSettings();
6282      }
6283      else if (target.dataset.setDefaultTab) { state.defaultTab = target.dataset.setDefaultTab; saveSettings(); }
6284      else if (target.dataset.setDefaultIndex) { state.defaultIndex = target.dataset.setDefaultIndex; saveSettings(); }
6285      else if (target.id === 'pulseVisibleToggle') { state.pulseVisible = !state.pulseVisible; saveSettings(); }
6286      else if (target.dataset.pulseStyle) { state.pulseStyle = target.dataset.pulseStyle; saveSettings(); }
6287      else if (target.dataset.pulseTab) { state.pulseTab = target.dataset.pulseTab; renderMarketPulse(); renderSettings(); return; }
6288      else if (target.dataset.usPalette) { state.usPalette = target.dataset.usPalette; saveSettings(); }
6289      else if (target.dataset.colorScale) { state.colorScale = Number(target.dataset.colorScale); saveSettings(); }
6290      else if (target.id === 'hideDefaultToggle') {
6291        state.hideAmountsDefault = !state.hideAmountsDefault;
6292        state.hideAmounts = state.hideAmountsDefault;
6293        saveSettings();
6294      }
6295      else if (target.id === 'briefingToggle') {
6296        if (!can('alerts')) { toast('아침 브리핑은 Pro 기능입니다'); return; }
6297        state.briefingEnabled = !state.briefingEnabled;
6298        saveSettings();
6299        if (window.chrome?.storage?.local) chrome.storage.local.set({ 'heattab.briefing.enabled': state.briefingEnabled });
6300        toast(state.briefingEnabled ? '아침 브리핑을 켰습니다 · 평일 08:50에 도착합니다' : '아침 브리핑을 껐습니다');
6301        renderSettings();
6302        return;
6303      }
6304      else if (target.id === 'alertsToggle') {
6305        if (!can('alerts')) { toast('알림은 Pro 기능입니다'); return; }
6306        state.alertsCfg.enabled = !state.alertsCfg.enabled;
6307        saveAlertsCfg().then(() => renderSettings());
6308        return;
6309      }
6310      else if (target.dataset.alertMove) {
6311        state.alertsCfg.movePct = Number(target.dataset.alertMove);
6312        saveAlertsCfg().then(() => renderSettings());
6313        return;
6314      }
6315      else if (target.id === 'stealthDefaultToggle') { state.stealthDefault = !state.stealthDefault; saveSettings(); }
6316      else if (target.dataset.stealthTheme) {
6317        state.stealthTheme = target.dataset.stealthTheme;
6318        $('stealth').innerHTML = ''; // 테마 강제 재렌더
6319        saveSettings();
6320      }
6321      else if (target.id === 'badgeToggle') {
6322        state.badgeEnabled = !state.badgeEnabled;
6323        saveSettings();
6324        if (window.chrome?.storage?.local) chrome.storage.local.set({ 'heattab.badge.enabled': state.badgeEnabled });
6325        if (window.chrome?.runtime?.sendMessage) chrome.runtime.sendMessage({ type: 'heattab-badge-refresh' }).catch?.(() => {});
6326      }
6327      else if (target.id === 'themeToggle') { state.theme = state.theme === 'light' ? 'dark' : 'light'; saveSettings(); applyTheme(); }
6328      else if (target.id === 'colorblindToggle') { state.colorblind = !state.colorblind; saveSettings(); }
6329      else if (target.id === 'emptyToggle') { state.pfEmpty = !state.pfEmpty; state.tab = 'portfolio'; }
6330      else if (target.id === 'disconnect') {
6331        state.dataMode = 'eod';
6332        state.menu = null;
6333        state.key = '';
6334        state.secret = '';
6335        state.pin = '';
6336        state.secureBlob = null;
6337        clearSavedSettings();
6338        secureBlobClear();
6339        toast('연결 해제됨 · 저장된 키를 삭제하고 전일 종가 모드로 전환합니다');
6340      }
6341      else if (target.id === 'modalClose') state.modal = false;
6342      else if (target.id === 'prevStep') state.step = Math.max(1, state.step - 1);
6343      else if (target.id === 'sampleKey') { state.key = state.provider === 'toss' ? 'toss-demo-api-key' : 'PSa3f9E2xkQ81LmZvB4t7YcW0dHnRj6UgIoK'; state.secret = state.provider === 'toss' ? 'toss-demo-api-secret' : 'demo-secret-2f8a1c9e4b7d3f6a0e5c8b2d9f4a7c1e'; state.pin = '040208'; }
6344      else if (target.id === 'skipImport') finishOnboarding(false);
6345      else if (target.id === 'nextStep') {
6346        if (state.step === 4 && state.test !== 'ok') {
6347          state.test = 'loading';
6348          render();
6349          setTimeout(() => {
6350            const valid = state.key.length > 10 && state.secret.length > 10 && state.pin.length >= 6 && state.pin.length <= 12;
6351            state.test = valid ? 'ok' : 'fail';
6352            if (valid) {
6353              persistSecrets()
6354                .then(saved => { if (saved) toast('API 키를 PIN으로 암호화해 저장했습니다'); })
6355                .catch(() => toast('키 암호화 저장에 실패했습니다'));
6356            }
6357            render();
6358          }, 1000);
6359          return;
6360        } else if (state.step === 5) {
6361          target.disabled = true;
6362          importPortfolioFromApi()
6363            .then(() => { state.modal = false; render(); })
6364            .catch(err => toast('가져오기 실패 · ' + err.message))
6365            .finally(() => { target.disabled = false; });
6366          return;
6367        }
6368        else state.step = Math.min(5, state.step + 1);
6369      }
6370
6371      if (!['indexBtn', 'colorBtn', 'settingsBtn', 'statusBtn'].includes(target.id) && !target.closest('.menu')) state.menu = null;
6372      render();
6373    });
6374
6375    document.addEventListener('input', e => {
6376      if (e.target.id === 'pulseWidth') {
6377        state.pulseWidth = Number(e.target.value);
6378        $('marketPulse')?.style.setProperty('--pulse-card-width', state.pulseWidth + 'px');
6379        saveSettings();
6380        return;
6381      }
6382      if (e.target.id === 'appKey') state.key = e.target.value;
6383      if (e.target.id === 'appSecret') state.secret = e.target.value;
6384      if (e.target.id === 'pin') {
6385        state.pin = e.target.value.replace(/\D/g, '').slice(0, 12);
6386        e.target.value = state.pin;
6387      }
6388      if (e.target.id === 'pinUnlockInput') {
6389        e.target.value = e.target.value.replace(/\D/g, '').slice(0, 12);
6390      }
6391      if (e.target.id === 'watchlistAddInput') { renderWlDropdown(e.target.value); return; }
6392      // 포트폴리오 편집기 행 입력은 드래프트에 즉시 반영
6393      if (e.target.dataset?.pfQty !== undefined) {
6394        const h = state.pfDraft?.[Number(e.target.dataset.pfQty)];
6395        if (h) h.qty = Number(e.target.value.replace(/[^\d.]/g, '')) || 0;
6396      }
6397      if (e.target.dataset?.pfAvg !== undefined) {
6398        const h = state.pfDraft?.[Number(e.target.dataset.pfAvg)];
6399        if (h) h.avg = Number(e.target.value.replace(/[^\d.]/g, '')) || 0;
6400      }
6401    });
6402
6403    document.addEventListener('keydown', e => {
6404      // 보드 소스 메뉴가 열려 있으면 Esc로 닫기 (입력창 포커스 여부 무관)
6405      if (e.key === 'Escape' && state.boardMenu != null) { state.boardMenu = null; renderMap(); return; }
6406      // 스페이스바(또는 백틱) = 스텔스 토글, 스텔스 중 Esc = 해제 (입력창 포커스 중엔 무시)
6407      const typing = ['INPUT', 'TEXTAREA'].includes(e.target?.tagName);
6408      if ((e.key === ' ' || e.key === '`') && !typing) {
6409        e.preventDefault();
6410        toggleStealth();
6411        return;
6412      }
6413      if (state.stealth) {
6414        if (e.key === 'Escape') toggleStealth();
6415        return;
6416      }
6417      if (state.pinPrompt) {
6418        if (e.key === 'Enter') submitPinUnlock();
6419        if (e.key === 'Escape') { cancelPinUnlock(); render(); }
6420        return;
6421      }
6422      if (e.target?.id === 'watchlistAddInput' && !($('watchlistDropdown')?.hidden ?? true)) {
6423        if (e.key === 'ArrowDown') { e.preventDefault(); moveWlActive(1); return; }
6424        if (e.key === 'ArrowUp') { e.preventDefault(); moveWlActive(-1); return; }
6425        if (e.key === 'Escape') { e.preventDefault(); closeWlDropdown(); return; }
6426      }
6427      if (e.key === 'Enter' && e.target?.id === 'watchlistAddInput') addWatchlistSymbol();
6428      else if (e.key === 'Enter' && ['pfAddSymbol', 'pfAddQty', 'pfAddAvg'].includes(e.target?.id)) pfAddFromInputs();
6429      else if (e.key === 'Escape' && state.pfEditorOpen) closePfEditor();
6430      else if (e.key === 'Escape' && state.stockPanel) {
6431        state.stockPanel = null;
6432        $('signalsPanel')?.classList.remove('open');
6433        $('signalsBackdrop')?.classList.remove('show');
6434      }
6435      else if (e.key === 'Escape' && state.signalsOpen) {
6436        if (state.signalsDetail) state.signalsDetail = null;
6437        else state.signalsOpen = false;
6438        renderSignals();
6439      }
6440      else if (e.key === 'Escape' && state.settingsOpen) { state.settingsOpen = false; render(); }
6441    });
6442
6443    document.addEventListener('pointerdown', e => {
6444      const handle = e.target.closest('#pulseResizer');
6445      if (!handle || !state.pulseVisible) return;
6446      e.preventDefault();
6447      hidePopover();
6448      const startY = e.clientY;
6449      const key = pulseHeightKey();
6450      const startHeight = state.pulseHeights[key] || (key === 'side' ? 116 : PULSE_STYLE_HEIGHT[currentPulseStyle()] || 92);
6451      handle.classList.add('dragging');
6452      handle.setPointerCapture?.(e.pointerId);
6453
6454      const onMove = event => {
6455        setPulseHeight(startHeight + event.clientY - startY);
6456        requestAnimationFrame(renderMap);
6457      };
6458      const onUp = () => {
6459        handle.classList.remove('dragging');
6460        saveSettings();
6461        renderMarketPulse();
6462        renderMap();
6463        document.removeEventListener('pointermove', onMove);
6464        document.removeEventListener('pointerup', onUp);
6465        document.removeEventListener('pointercancel', onUp);
6466      };
6467
6468      document.addEventListener('pointermove', onMove);
6469      document.addEventListener('pointerup', onUp);
6470      document.addEventListener('pointercancel', onUp);
6471    });
6472
6473    window.addEventListener('resize', renderMap);
6474    window.addEventListener('blur', hidePopover);
6475    window.addEventListener('mouseleave', hidePopover);
6476    window.addEventListener('mouseout', e => {
6477      if (!e.relatedTarget && !e.toElement) hidePopover();
6478    });
6479    document.addEventListener('visibilitychange', () => {
6480      if (document.hidden) hidePopover();
6481    });
6482    document.addEventListener('pointerdown', e => {
6483      if (!e.target.closest('.tile') && !e.target.closest('.popover')) hidePopover();
6484    });
6485
6486    // data-tip 커스텀 툴팁 (시그널 지표 설명 등)
6487    document.addEventListener('mouseover', e => {
6488      const tip = $('sigTip');
6489      if (!tip) return;
6490      const el = e.target.closest?.('[data-tip]');
6491      if (!el) {
6492        tip.classList.remove('show');
6493        return;
6494      }
6495      tip.textContent = el.dataset.tip;
6496      tip.classList.add('show');
6497      const rect = el.getBoundingClientRect();
6498      const tipRect = tip.getBoundingClientRect();
6499      let x = Math.min(rect.left, window.innerWidth - tipRect.width - 12);
6500      let y = rect.bottom + 8;
6501      if (y + tipRect.height > window.innerHeight - 8) y = rect.top - tipRect.height - 8;
6502      tip.style.left = Math.max(8, x) + 'px';
6503      tip.style.top = Math.max(8, y) + 'px';
6504    });
6505
6506    // 레이아웃이 바뀌면(스크롤·리사이즈) data-tip 툴팁이 앵커를 잃고 화면에 남는 것을 방지 — 즉시 숨김
6507    ['scroll', 'resize'].forEach(ev => window.addEventListener(ev, () => {
6508      $('sigTip')?.classList.remove('show');
6509    }, true));
6510
6511    let refreshInFlight = false;
6512    let refreshStartedAt = 0;
6513
6514    function setRefreshInFlight(on) {
6515      refreshInFlight = on;
6516      refreshStartedAt = on ? Date.now() : 0;
6517    }
6518
6519    // 워치독: 새로고침이 45초 넘게 안 끝나면 잠금 강제 해제 (요청 hang 자가 복구)
6520    setInterval(() => {
6521      if (refreshInFlight && Date.now() - refreshStartedAt > 45000) {
6522        setRefreshInFlight(false);
6523        document.querySelectorAll('#refreshFloating, #importNow, #refreshPortfolio').forEach(b => { b.disabled = false; });
6524        toast('새로고침이 응답하지 않아 초기화했습니다 · 다시 시도해 주세요');
6525      }
6526    }, 5000);
6527
6528    // 45초 상한 — 어떤 경로가 hang해도 promise가 반드시 정리되도록
6529    function withRefreshTimeout(promise) {
6530      return Promise.race([
6531        promise,
6532        new Promise((_, reject) => setTimeout(() => reject(new Error('시간 초과 (45초)')), 45000))
6533      ]);
6534    }
6535
6536    // 요금제별 갱신 주기: 무료 = 최소 5분, Pro = 30초/1분 허용.
6537    // 저장값은 유지하고 적용 시에만 클램프 — Pro 전환 시 원래 선택이 살아난다.
6538    function effectiveRefreshSec() {
6539      if (!state.autoRefreshSec) return 0;
6540      return can('fastRefresh') ? state.autoRefreshSec : Math.max(state.autoRefreshSec, 300);
6541    }
6542
6543    async function autoRefreshTick() {
6544      if (['free', 'api'].includes(state.dataMode)) {
6545        const statusText = $('statusText');
6546        if (statusText) statusText.textContent = dataModeText();
6547      }
6548      const intervalSec = effectiveRefreshSec();
6549      if (!intervalSec || refreshInFlight || document.hidden) return;
6550      if (state.modal || state.pinPrompt) return;
6551      if (state.lastUpdatedAt && Date.now() - state.lastUpdatedAt < intervalSec * 1000) return;
6552      // 장 마감 중: 종목 시세는 멈추지만 Pulse(선물·환율·금·크립토·해외지수)는 계속 갱신
6553      if (!sessionOpenForView() && state.lastUpdatedAt) {
6554        setRefreshInFlight(true);
6555        try {
6556          applyPulseQuotes(await withRefreshTimeout(fetchPulseQuotes()));
6557          state.lastUpdatedAt = Date.now();
6558          renderControls();
6559        } catch {} finally {
6560          setRefreshInFlight(false);
6561        }
6562        return;
6563      }
6564      setRefreshInFlight(true);
6565      try {
6566        await withRefreshTimeout(refreshVisiblePrices({ silent: true }));
6567        state.autoFailures = 0;
6568        render();
6569        scanStockSignals().catch(() => {});
6570        setTimeout(() => prefetchVisibleDaily().catch(() => {}), 2500);
6571      } catch (error) {
6572        state.autoFailures = (state.autoFailures || 0) + 1;
6573        if (state.autoFailures === 1) toast('자동 새로고침 실패 (재시도 예정) · ' + error.message);
6574        if (state.autoFailures === 3) {
6575          // Stop in-memory only (not persisted) — next launch retries automatically.
6576          state.autoRefreshSec = 0;
6577          toast('자동 새로고침이 연속 실패로 중지되었습니다 · ' + error.message);
6578          render();
6579        }
6580      } finally {
6581        setRefreshInFlight(false);
6582      }
6583    }
6584
6585    setInterval(autoRefreshTick, 5000);
6586
6587    // Dev/debug hook (used by automated UI tests; harmless in production)
6588    // 개발용 콘솔 훅 (사용자 UI에는 노출 안 함). Pro 미리보기는 __ht.unlockPro()로만 가능.
6589    window.__ht = { state, marketPulse, render, computeThermo, renderSignals, openStockPanel, renderStockPanel, computeStockSignals, unlockPro };
6590
6591    (async function boot() {
6592      // Embedded: complete the shell handshake and load the bridge-key snapshot BEFORE
6593      // the boot chain so the first render has real data (E3). Plain web tab: no-op.
6594      await bridge.init();
6595
6596      loadSavedSettings();
6597      applyTheme(); // 저장된 테마 반영 (임베드는 브릿지 스냅샷 로드 후이므로 첫 렌더부터 정확)
6598      initSecureStorage();
6599      loadLicense();
6600      loadAlertsCfg().then(() => { if (state.settingsOpen) renderSettings(); });
6601      if (state.index === 'KOSDAQ150') {
6602        loadKosdaq150().then(render).catch(() => {});
6603        ensureIndustryMap().then(map => { if (map) renderMap(); }).catch(() => {});
6604      } else {
6605        // Preload KOSDAQ150 in the background (24h-cached, 2 requests) so watchlist
6606        // search and name lookup cover KOSDAQ without visiting that tab first.
6607        setTimeout(() => loadKosdaq150().then(() => { if (state.settingsOpen) render(); }).catch(() => {}), 2500);
6608      }
6609      render();
6610      requestAnimationFrame(renderMap);
6611      setTimeout(renderMap, 100);
6612
6613      // Tell the shell we have rendered — it hides the loader and reveals the iframe (E1/S2).
6614      if (bridge.active) bridge._post({ type: 'ht-shown' });
6615
6616      // 시작 시 무조건 1회 시세 조회 — 자동 새로고침 설정과 무관, 첫 화면부터 실데이터
6617      await sleep(300); // 첫 페인트 후 실행
6618      if (refreshInFlight) return;
6619      setRefreshInFlight(true);
6620      try {
6621        await withRefreshTimeout(refreshVisiblePrices({ silent: true }));
6622        render();
6623        scanStockSignals().catch(() => {});
6624        setTimeout(() => prefetchVisibleDaily().catch(() => {}), 2500);
6625      } catch (error) {
6626        toast('시작 시세 조회 실패 · ' + error.message);
6627      } finally {
6628        setRefreshInFlight(false);
6629      }
6630    })();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.