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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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> â¾ utils</div> 5794 <div class="on"> date-format.ts</div> 5795 <div> locale.ts</div> 5796 <div> string.ts</div> 5797 <div> ⸠components</div> 5798 <div> ⸠hooks</div> 5799 <div> 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.