feat: 핫종목 렌즈 + 뉴스 파서 견고화 + 텔레그램 알림 2회 축소

- news-collector: EXAONE JSON 파서 견고화(max_tokens 800 + 관대 파싱 → 파싱실패율 9.3%→3.3%),
  평일 18:30 종목뉴스 스위프(중형주 신선도 사각지대 해소), /process/raw reprocess 플래그,
  파싱실패→Gemini 폴백 라우팅(일일캡 1→50 상수화)
- score-engine: /hot 모멘텀 렌즈(뉴스+거래량, 백테스트로 가격 제외) + /hot/backtest,
  텔레그램 정기 브리핑 2회로 축소(중복 send_briefing 자동스케줄 제거)
- dashboard-api: /api/hot 프록시 + 🔥 지금뜨는 탭
- telegram-bot: /hot 명령 + 한글(뜨는/핫/급등) 라우팅

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
kyu
2026-06-02 01:22:23 +09:00
parent a81feb48d8
commit 8b200e6cc6
5 changed files with 3989 additions and 367 deletions
+534 -71
View File
@@ -7,7 +7,7 @@
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;800;900&family=JetBrains+Mono:wght@400;600;700&display=swap');
*{box-sizing:border-box;margin:0;padding:0}
body{background:#0D1117;color:#E0E0E0;font-family:'Noto Sans KR',sans-serif;padding:0 24px 40px}
body{background:#0D1117;color:#E0E0E0;font-family:'Noto Sans KR',sans-serif;padding:0}
::-webkit-scrollbar{width:6px}::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.1);border-radius:3px}
.wrap{max-width:1400px;margin:0 auto}
.header{display:flex;justify-content:space-between;align-items:center;padding:20px 0;border-bottom:1px solid rgba(255,255,255,0.06);margin-bottom:24px}
@@ -15,9 +15,26 @@ body{background:#0D1117;color:#E0E0E0;font-family:'Noto Sans KR',sans-serif;padd
.header .sub{font-size:12px;color:#546E7A;margin-top:4px}
.header .time{font-size:13px;color:#607D8B;font-family:'JetBrains Mono',monospace}
.btn{background:rgba(0,230,118,0.1);border:1px solid rgba(0,230,118,0.2);border-radius:8px;padding:8px 16px;color:#00E676;font-size:12px;cursor:pointer;font-weight:600}
.tabs{display:flex;gap:4px;margin-bottom:24px}
.tab{background:transparent;border:1px solid transparent;border-radius:8px;padding:8px 16px;color:#607D8B;font-size:13px;font-weight:500;cursor:pointer}
.sidebar{position:fixed;left:0;top:0;bottom:0;width:212px;background:#0A0E14;border-right:1px solid rgba(255,255,255,0.06);padding:18px 12px;overflow-y:auto;z-index:200;transition:transform .25s ease}
.brand{font-size:16px;font-weight:800;letter-spacing:-0.5px;padding:4px 10px 16px;display:flex;align-items:center;gap:7px;color:#E0E0E0}
.brand span{color:#00E676}
.tabs{display:flex;flex-direction:column;gap:2px;margin:0}
.tab{display:flex;align-items:center;gap:8px;width:100%;text-align:left;background:transparent;border:1px solid transparent;border-radius:8px;padding:10px 12px;color:#90A4AE;font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;transition:background .15s}
.tab:hover{background:rgba(255,255,255,0.04);color:#CFD8DC}
.tab.active{background:rgba(0,230,118,0.1);border-color:rgba(0,230,118,0.2);color:#00E676;font-weight:700}
.main{margin-left:212px;padding:0 24px 40px;min-height:100vh}
.hamburger{display:none;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);border-radius:8px;width:40px;height:40px;color:#CFD8DC;font-size:18px;cursor:pointer;flex-shrink:0;align-items:center;justify-content:center}
.sidebar-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:150}
@media(max-width:860px){
.sidebar{transform:translateX(-100%);width:248px;box-shadow:6px 0 28px rgba(0,0,0,0.55)}
.sidebar.open{transform:translateX(0)}
.main{margin-left:0;padding:0 14px 40px}
.hamburger{display:flex}
.sidebar-overlay.show{display:block}
.header{flex-wrap:wrap}
.header h1{font-size:17px}
.header .sub{display:none}
}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:24px}
.card{background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06);border-radius:16px;padding:24px 20px}
.card .label{font-size:13px;color:#90A4AE;letter-spacing:1px;margin-bottom:8px}
@@ -25,6 +42,7 @@ body{background:#0D1117;color:#E0E0E0;font-family:'Noto Sans KR',sans-serif;padd
.card .sub{font-size:12px;color:#607D8B;margin-top:8px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
@media(max-width:900px){.grid2{grid-template-columns:1fr}}
@media(max-width:680px){.usbrief-grid{grid-template-columns:1fr !important}}
.panel{background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.05);border-radius:16px;padding:20px;max-height:600px;overflow:auto}
.panel h3{font-size:14px;font-weight:700;color:#90A4AE;margin-bottom:16px}
.rank-item{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid rgba(255,255,255,0.03)}
@@ -108,7 +126,7 @@ tr[data-stock]:hover{background:rgba(0,230,118,0.06) !important}
body.auth-required .wrap, body.auth-required #chat-btn, body.auth-required #chat-panel { display:none !important; }
body:not(.auth-required) #login-screen { display:none !important; }
#login-screen{position:fixed;inset:0;background:#0E141A;display:flex;align-items:center;justify-content:center;padding:20px;z-index:100;overflow-y:auto}
#login-screen{position:fixed;inset:0;background:#0E141A;display:flex;align-items:center;justify-content:center;padding:20px;z-index:300;overflow-y:auto}
.login-card{background:#141B22;border:1px solid rgba(0,230,118,0.18);border-radius:20px;padding:40px;width:420px;max-width:100%;box-shadow:0 12px 60px rgba(0,0,0,0.6)}
.login-logo{font-size:24px;font-weight:800;color:#E0E0E0;margin-bottom:6px;letter-spacing:0.5px}
.login-logo span{color:#00E676}
@@ -174,8 +192,33 @@ body:not(.auth-required) #login-screen { display:none !important; }
</div>
</div>
<aside class="sidebar" id="sidebar">
<div class="brand"><span></span> Trading AI</div>
<div class="tabs">
<button class="tab active" onclick="showTab('overview',this)">📊 종합</button>
<button class="tab" onclick="showTab('performance',this)">🎯 추천성과</button>
<button class="tab" onclick="showTab('signals',this)">💰 매매시그널</button>
<button class="tab" onclick="showTab('surge',this)">🚀 거래량급증</button>
<button class="tab" onclick="showTab('hot',this)">🔥 지금뜨는</button>
<button class="tab" onclick="showTab('portfolio',this)">💼 포트폴리오</button>
<button class="tab" onclick="showTab('mystock',this)">📂 내 종목 분석</button>
<button class="tab" onclick="showTab('recommend',this)">🏆 추천종목</button>
<button class="tab" onclick="showTab('news',this)">📰 뉴스피드</button>
<button class="tab" onclick="showTab('supplydemand',this)">💹 수급</button>
<button class="tab" onclick="showTab('alerts',this)">🚨 알림</button>
<button class="tab" onclick="showTab('formulas',this)">🧮 10공식</button>
<button class="tab" onclick="showTab('backtest',this)">📈 백테스트</button>
<button class="tab" onclick="showTab('usmarket',this)">🌎 미증시</button>
<button class="tab" onclick="showTab('macro',this)">🌐 매크로·섹터</button>
<button class="tab" onclick="showTab('glossary',this)">📖 용어설명</button>
</div>
</aside>
<div class="sidebar-overlay" id="sbOverlay" onclick="toggleSidebar(false)"></div>
<div class="main">
<div class="wrap">
<div class="header">
<button class="hamburger" onclick="toggleSidebar()"></button>
<div><h1><span></span> Trading AI Dashboard</h1><div class="sub">뉴스 + DART 공시 기반 종목 분석 시스템</div></div>
<div style="display:flex;align-items:center;gap:16px">
<div class="time" id="clock"></div>
@@ -190,26 +233,11 @@ body:not(.auth-required) #login-screen { display:none !important; }
<button onclick="doSearch()" style="background:rgba(0,230,118,0.1);border:1px solid rgba(0,230,118,0.2);border-radius:8px;padding:10px 20px;color:#00E676;font-size:13px;cursor:pointer;font-weight:600;white-space:nowrap">🔍 검색</button>
</div>
<div id="searchResults" style="display:none;margin-bottom:24px"></div>
<div class="tabs">
<button class="tab active" onclick="showTab('overview',this)">📊 종합</button>
<button class="tab" onclick="showTab('performance',this)">🎯 추천성과</button>
<button class="tab" onclick="showTab('signals',this)">💰 매매시그널</button>
<button class="tab" onclick="showTab('surge',this)">🚀 거래량급증</button>
<button class="tab" onclick="showTab('portfolio',this)">💼 포트폴리오</button>
<button class="tab" onclick="showTab('mystock',this)">📂 내 종목 분석</button>
<button class="tab" onclick="showTab('recommend',this)">🏆 추천종목</button>
<button class="tab" onclick="showTab('news',this)">📰 뉴스피드</button>
<button class="tab" onclick="showTab('supplydemand',this)">💹 수급</button>
<button class="tab" onclick="showTab('alerts',this)">🚨 알림</button>
<button class="tab" onclick="showTab('formulas',this)">🧮 10공식</button>
<button class="tab" onclick="showTab('backtest',this)">📈 백테스트</button>
<button class="tab" onclick="showTab('usmarket',this)">🌎 미증시</button>
<button class="tab" onclick="showTab('macro',this)">🌐 매크로·섹터</button>
</div>
<div id="overview"></div>
<div id="performance" style="display:none"></div>
<div id="signals" style="display:none"></div>
<div id="surge" style="display:none"></div>
<div id="hot" style="display:none"></div>
<div id="portfolio" style="display:none"></div>
<div id="mystock" style="display:none"></div>
<div id="recommend" style="display:none"></div>
@@ -220,8 +248,10 @@ body:not(.auth-required) #login-screen { display:none !important; }
<div id="backtest" style="display:none"></div>
<div id="usmarket" style="display:none"></div>
<div id="macro" style="display:none"></div>
<div id="glossary" style="display:none"></div>
<div class="footer">Trading AI System • 뉴스/공시 기반 자동 분석 • 투자 판단의 참고 자료로만 활용하세요</div>
</div>
</div>
<script>
const API = window.location.origin + '/api';
const SCOL = {호재:'#00E676',악재:'#FF5252',중립:'#78909C'};
@@ -231,20 +261,117 @@ const RCOL = {강력매수:'#00E676',매수관심:'#69F0AE',관망:'#78909C',매
function ftime(ts){if(!ts)return'-';const d=new Date(ts),m=Math.floor((Date.now()-d)/60000);if(m<60)return m+'분 전';if(m<1440)return Math.floor(m/60)+'시간 전';return d.toLocaleDateString('ko-KR',{month:'short',day:'numeric'})}
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
function toggleSidebar(force){
const sb=document.getElementById('sidebar'), ov=document.getElementById('sbOverlay');
if(!sb)return;
const open = force===undefined ? !sb.classList.contains('open') : !!force;
sb.classList.toggle('open',open);
if(ov)ov.classList.toggle('show',open);
}
function showTab(id,el){
['overview','performance','signals','surge','portfolio','mystock','recommend','news','supplydemand','alerts','formulas','backtest','usmarket','macro'].forEach(t=>{
['overview','performance','signals','surge','hot','portfolio','mystock','recommend','news','supplydemand','alerts','formulas','backtest','usmarket','macro','glossary'].forEach(t=>{
document.getElementById(t).style.display=t===id?'block':'none'});
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
if(el)el.classList.add('active');
toggleSidebar(false);
if(id==='mystock'){ if(isLoggedIn()) loadPortfolio().then(()=>renderMyStock()); else renderMyStock(); }
if(id==='portfolio') renderPortfolio();
if(id==='performance') renderPerformance();
if(id==='supplydemand') renderSupplyDemand();
if(id==='surge') renderVolumeSurge();
if(id==='hot') renderHot();
if(id==='formulas') renderFormulas();
if(id==='backtest') renderBacktest();
if(id==='usmarket') renderUsMarket();
if(id==='macro') renderMacro();
if(id==='glossary') renderGlossary();
}
// ── 용어 설명 ────────────────────────────────────────────────
const GLOSSARY=[
{t:'📊 차트 보는 법',items:[
['캔들(봉)','하루 주가 움직임을 막대 하나로 표시. 끝가가 시작가보다 <b>높으면 빨강(양봉=올랐다)</b>, <b>낮으면 파랑(음봉=내렸다)</b>. 위·아래로 삐져나온 가는 선은 그날의 최고가·최저가.'],
['거래량','그날 사고판 주식 수. 많을수록 시장 관심이 크다는 뜻. 거래량 없이 슬금슬금 오르는 상승은 힘이 약하다.'],
['시가/종가/고가/저가','시가=장 시작 가격, 종가=장 끝 가격, 고가=그날 최고가, 저가=그날 최저가.'],
]},
{t:'📈 이동평균선 (MA = Moving Average)',items:[
['MA5','최근 <b>5일</b> 종가의 평균선. 약 1주일치 단기 흐름.'],
['MA20','최근 <b>20일</b> 평균선. 약 한 달 흐름이라 "생명선"이라 부른다. 주가가 이 위면 단기 분위기 양호.'],
['MA60','최근 <b>60일</b> 평균선. 약 3개월, 중기 추세.'],
['MA120','최근 <b>120일</b> 평균선. 약 6개월, 장기 추세라 "경기선"이라 부른다.'],
['정배열 / 역배열','MA5>MA20>MA60 순으로 짧은 선이 위에 있으면 <b>정배열=상승 추세</b>. 거꾸로면 역배열=하락 추세.'],
['골든크로스 / 데드크로스','짧은 선이 긴 선을 아래→위로 뚫으면 <b>골든크로스(매수 신호)</b>, 위→아래로 뚫으면 데드크로스(매도 신호).'],
]},
{t:'🎯 기술적 지표 (매매 타이밍)',items:[
['RSI','0~100 숫자. <b>70 이상이면 과열</b>(너무 많이 사서 비쌈), <b>30 이하면 과매도</b>(너무 많이 팔려 쌈). 되돌림 가능성을 본다.'],
['MACD','단기·장기 이동평균의 차이로 추세 전환을 잡는 지표. 0선 위로 올라오면 상승 힘이 붙는 것.'],
['볼린저밴드','주가가 보통 움직이는 범위를 상단·중간·하단 띠로 표시. 상단에 닿으면 단기 비쌈, 하단이면 쌈.'],
['스토캐스틱','RSI처럼 과열/과매도를 보는 지표. 20 이하 과매도, 80 이상 과열.'],
['OBV','거래량으로 돈이 들어오는지(매집)·나가는지(분산)를 추적. 주가보다 먼저 움직이기도 한다.'],
['VWAP','거래량 가중 평균가. 기관 투자자가 "본전" 기준으로 삼는 가격.'],
['일목균형표','구름대(저항·지지 구간)로 추세를 보는 일본식 종합 지표.'],
]},
{t:'💰 재무 지표 (회사가 돈을 잘 버나)',items:[
['ROE','자기자본이익률. 주주 돈 100원으로 1년에 얼마 벌었나. <b>높을수록 좋고 15% 이상이면 우수</b>. 워런 버핏이 가장 중시하는 숫자.'],
['영업이익률','매출 100원 중 본업으로 남긴 이익 비율. 높을수록 사업 경쟁력이 강하다.'],
['순이익률','매출에서 모든 비용·이자·세금을 다 빼고 최종으로 남은 비율.'],
['부채비율','자기자본 대비 빚의 비율. <b>100% 이하면 안전</b>, 200%를 넘으면 위험 신호.'],
['FCF비율','잉여현금흐름(Free Cash Flow). 회사가 벌어서 쓸 거 다 쓰고 <b>실제로 남긴 진짜 현금</b>. 회계상 이익보다 정직한 숫자라 플러스면 건강.'],
['매출성장률','작년 대비 올해 매출이 얼마나 늘었나. 회사가 커가고 있는지를 본다.'],
]},
{t:'🏷️ 가치 지표 (주가가 싼가 비싼가)',items:[
['PER','주가 ÷ 주당순이익. 회사 이익 대비 주가가 몇 배인가. <b>낮을수록 싸다</b>. 보통 10배 이하면 저평가 경향.'],
['PBR','주가 ÷ 주당순자산. <b>1배 미만이면 회사가 가진 자산보다 주가가 싸게</b> 거래되는 것.'],
['EPS / BPS','EPS=주당순이익(1주가 1년에 번 돈). BPS=주당순자산(회사 청산 시 1주가 받을 몫).'],
['시가총액','주가 × 총 주식 수 = 회사 전체의 시장 몸값.'],
['배당수익률','주가 대비 1년간 받는 배당금 비율. 은행 이자처럼 보면 된다.'],
]},
{t:'🧮 이 시스템의 점수',items:[
['종합점수 (-100~100)','재무·뉴스·기술·공시·수급을 다 합친 최종 점수. <b>높을수록 매수 매력</b>이 크다.'],
['펀더멘털 점수','회사의 본질 가치 점수 (ROE·이익률·부채·현금흐름 등 기반).'],
['기술점수','차트·기술 지표 기반의 단기 매매 타이밍 점수.'],
['뉴스점수','최근 7일 뉴스의 호재/악재를 종합한 점수.'],
['10공식 보팅','매직포뮬러·F-Score 등 학계·실전 검증된 10개 투자 공식이 각자 매수/매도를 투표한 결과.'],
['추천등급','강력매수 &gt; 매수관심 &gt; 관망 &gt; 매도관심 &gt; 강력매도 순. 점수와 공식 투표를 함께 본다.'],
]},
{t:'🏛️ 시장 제도·거래 용어',items:[
['공매도','내가 갖고 있지도 않은 주식을 <b>빌려서 먼저 판 뒤</b>, 값이 떨어지면 싸게 되사서 갚아 차익을 먹는 기법. 주가 <b>하락에 베팅</b>하는 것. 공매도 물량이 많으면 하락 압력 신호로 본다 (이 시스템의 공매도 점수에 반영).'],
['사이드카','선물 가격이 ±5~6% 급등락하면 <b>프로그램 매매(컴퓨터 자동매매)를 5분간 정지</b>시키는 장치. 시장 쏠림을 잠깐 식히는 가벼운 브레이크. 5분 후 자동 해제.'],
['서킷브레이커','사이드카보다 센 비상정지. 지수가 8%·15%·20% 급락하면 <b>주식 거래 전체를 20분간 멈춤</b>. 패닉 매도를 막는 장치.'],
['VI (변동성완화장치)','개별 종목이 갑자기 급등락하면 2~3분간 단일가 매매로 바꿔 진정시키는 장치. 사이드카의 개별 종목 버전.'],
['상한가 / 하한가','하루에 주가가 오를·내릴 수 있는 한계. 한국은 전일 종가 대비 <b>±30%</b>.'],
['ISA','개인종합자산관리계좌. 주식·펀드·예금을 한 계좌에 담아 굴리는 <b>절세 계좌</b>. 수익 일정액까지 세금 면제·저율 과세. 의무 보유기간(보통 3년) 있음. "세금 혜택 받는 장기투자용 계좌".'],
['배당','회사가 번 이익의 일부를 주주에게 나눠주는 돈. 1년에 한 번(결산배당)이 보통이고 분기마다 주는 회사도 있다.'],
]},
{t:'💡 투자 스타일·철학',items:[
['가치투자','회사의 본질가치보다 <b>싸게 거래되는 주식</b>을 사서 제값을 찾아갈 때까지 기다리는 방식. 대표: 벤저민 그레이엄·워런 버핏. 이 시스템의 기본 관점.'],
['성장투자','지금 좀 비싸도 <b>매출·이익이 빠르게 크는 회사</b>면 산다. 미래 성장이 현재 가격을 정당화한다는 관점. 대표: 필립 피셔·피터 린치.'],
['모멘텀투자','<b>오르는 주식이 더 오른다</b>는 경험칙에 기대 추세에 올라타는 방식(추세추종). 대표: AQR·제시 리버모어. 이 시스템의 12-1 모멘텀 공식이 여기 해당.'],
['퀀트투자','감정을 배제하고 <b>데이터·통계로 패턴을 찾아 기계적으로</b> 매매. 대표: 짐 사이먼스(르네상스). 이 시스템의 10공식 엔진 자체가 퀀트 방식.'],
['매크로투자','개별 종목보다 <b>금리·환율·경기 같은 큰 흐름</b>에 베팅. 대표: 조지 소로스·레이 달리오.'],
['역발상투자','남들이 공포에 팔 때 사고 열광할 때 판다 — <b>대중과 반대로</b>. 대표: 존 템플턴.'],
['인덱스투자','시장을 이기기 어려우니 <b>지수 전체를 싸게 사서 장기 보유</b>(패시브). 대표: 존 보글. 버핏도 일반인에겐 이걸 권한다.'],
['기술적분석','재무가 아니라 <b>차트·거래량·지표로 매매 타이밍</b>을 잡는 방식. 가격이 모든 정보를 반영한다고 본다.'],
['배당투자','<b>배당을 꾸준히·많이 주는 회사</b>로 안정적인 현금흐름을 추구. 주가 등락보다 배당 수입을 중시.'],
['멀티팩터','가치·성장·모멘텀·퀄리티 등 <b>여러 방식을 데이터로 섞는</b> 현대 퀀트 접근. 한 스타일도 항상 이기진 못하기 때문 — <b>이 시스템이 실제로 쓰는 방식.</b>'],
]},
];
function renderGlossary(){
const el=document.getElementById('glossary');
let h=`<div style="font-size:13px;color:#90A4AE;margin-bottom:16px;line-height:1.6">
📖 주식 표·차트에 나오는 용어를 <b style="color:#00E676">쉬운 말</b>로 풀이했습니다. 모르는 단어가 보이면 여기서 찾아보세요.</div>`;
GLOSSARY.forEach(g=>{
h+=`<div class="panel" style="max-height:none;margin-bottom:14px"><h3 style="margin-bottom:10px">${g.t}</h3>`;
g.items.forEach(it=>{
h+=`<div style="display:flex;gap:14px;padding:11px 2px;border-bottom:1px solid rgba(255,255,255,0.05)">
<div style="flex:0 0 96px;color:#00E676;font-weight:700;font-size:13px">${it[0]}</div>
<div style="flex:1;color:#B0BEC5;font-size:12.5px;line-height:1.65">${it[1]}</div>
</div>`;
});
h+=`</div>`;
});
el.innerHTML=h;
}
async function renderVolumeSurge(){
@@ -281,6 +408,41 @@ async function renderVolumeSurge(){
}
}
async function renderHot(){
const el=document.getElementById('hot');
el.innerHTML=`<div style="text-align:center;color:#546E7A;padding:30px">불러오는 중...</div>`;
try{
const list=await api('/hot')||[];
if(!list.length){el.innerHTML='<div class="empty">지금 뜨는 종목 없음</div>';return;}
let h=`<div style="font-size:12px;color:#FFB74D;background:rgba(255,152,0,0.08);border:1px solid rgba(255,152,0,0.2);border-radius:8px;padding:10px 12px;margin-bottom:14px">
🔥 <b>지금 뜨는 종목</b> — 최근 뉴스 호재 + 거래량 급증 기준. <b style="color:#FF8A65">※ 관찰용입니다.</b> 백테스트상 핫종목은 5~10일 들면 시장에 지므로(되돌림) 매수신호가 아니라 "지금 뭐가 움직이나" 보는 창으로만 쓰세요.</div>`;
h+=`<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:10px">`;
list.forEach((r,i)=>{
const recoC=r.value_reco==='강력매수'?'#00E676':r.value_reco==='매수관심'?'#69F0AE':(r.value_reco||'').includes('매도')?'#FF5252':'#90A4AE';
const p5=r.price_5d_pct||0, p5C=p5>=0?'#00E676':'#FF5252';
h+=`<div data-stock="${r.stock_code}" data-stock-name="${esc(r.stock_name)}" style="cursor:pointer;background:rgba(255,255,255,0.03);border:1px solid rgba(255,152,0,0.12);border-radius:10px;padding:12px 14px;transition:background .15s" onmouseover="this.style.background='rgba(255,255,255,0.06)'" onmouseout="this.style.background='rgba(255,255,255,0.03)'">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px">
<div>
<div style="font-size:13px;font-weight:700;color:#E0E0E0">${i+1}. ${esc(r.stock_name||r.stock_code)}</div>
<div style="font-size:10px;color:#546E7A;font-family:'JetBrains Mono',monospace">${r.stock_code}</div>
</div>
<div style="font-size:15px;color:#FFB74D;font-weight:700">🔥 ${(r.hot_score||0).toFixed(0)}</div>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;font-size:11px;color:#90A4AE">
${r.pos_news_3d?`<span>호재 ${r.pos_news_3d}건</span>`:''}
<span>5일 <b style="color:${p5C}">${p5>=0?'+':''}${p5.toFixed(1)}%</b></span>
${r.vol_ratio>=1.5?`<span>거래량 ${(r.vol_ratio||0).toFixed(1)}배</span>`:''}
</div>
<div style="margin-top:6px;font-size:11px;color:#78909C">가치판단: <b style="color:${recoC}">${r.value_reco||'-'}</b>${r.value_score!=null?` (${r.value_score}점)`:''}</div>
</div>`;
});
h+=`</div>`;
el.innerHTML=h;
}catch(e){
el.innerHTML='<div class="empty">로드 실패: '+esc(e.message)+'</div>';
}
}
async function api(path){try{const r=await fetch(API+path);return await r.json()}catch(e){return null}}
async function loadAll(){
@@ -1012,11 +1174,9 @@ async function renderPortfolio(){
const el=document.getElementById('portfolio');
const list=getPortfolio();
if(!list.length){
el.innerHTML=`<div style="text-align:center;padding:80px 20px;color:#546E7A">
<div style="font-size:48px;margin-bottom:16px">💼</div>
<div style="font-size:16px;margin-bottom:8px;color:#78909C">보유 종목이 없습니다</div>
<div style="font-size:13px">📂 내 종목 분석 탭에서 종목을 추가하면 여기에 표시됩니다</div>
</div>`;
el.innerHTML='<div class="empty">AI 추천 포트폴리오 불러오는 중...</div>';
const reco=await api('/portfolio/recommended'+(recoAmount>0?'?amount='+recoAmount:'')).catch(()=>null);
el.innerHTML=`<div style="background:rgba(64,196,255,0.06);border:1px solid rgba(64,196,255,0.2);border-radius:12px;padding:16px 18px;margin-bottom:16px;color:#CFD8DC;font-size:13px;line-height:1.6">💼 아직 등록된 보유 종목이 없습니다. 아래 <b style="color:#69F0AE">AI 추천 포트폴리오</b>를 참고하거나, <b>📂 내 종목 분석</b> 탭에서 종목을 추가하세요.</div>`+recoPortfolioHTML(reco);
return;
}
el.innerHTML='<div class="empty">현재가 · AI점수 불러오는 중...</div>';
@@ -1035,7 +1195,7 @@ async function renderPortfolio(){
return{...p,cur,changePct:pd.change_pct||0,cost,val,
pnl:val-cost,pnlPct:cost>0?(val-cost)/cost*100:0,
techScore:pd.tech_score||0,signal:pd.signal||'관망',
aiScore:pd.ai_score,recommendation:pd.recommendation};
aiScore:pd.ai_score,recommendation:pd.recommendation,sector:pd.sector||'기타'};
});
const totalPnl=totalValue-totalCost;
const totalPnlPct=totalCost>0?totalPnl/totalCost*100:0;
@@ -1109,9 +1269,134 @@ async function renderPortfolio(){
</tr>`;
});
h+=`</tbody></table></div></div>`;
h+=portfolioSectorHTML(holdings);
h+=portfolioRebalanceHTML(holdings);
const reco=await api('/portfolio/recommended'+(recoAmount>0?'?amount='+recoAmount:'')).catch(()=>null);
h+=recoPortfolioHTML(reco);
el.innerHTML=h;
}
let recoAmount=0;
async function applyRecoAmount(){
const inp=document.getElementById('reco-amt');
const v=parseInt((inp&&inp.value)||'0',10);
recoAmount=isNaN(v)?0:v;
renderPortfolio();
}
function portfolioSectorHTML(holdings){
const sec={};
holdings.forEach(s=>{ if(s.weight>0) sec[s.sector]=(sec[s.sector]||0)+s.weight; });
const arr=Object.entries(sec).sort((a,b)=>b[1]-a[1]);
if(!arr.length) return '';
let h=`<div class="panel" style="max-height:none;margin-bottom:16px"><h3>📊 섹터(업종) 비중</h3>`;
arr.forEach(([name,w])=>{
const over=w>30, c=over?'#FF8A80':'#40C4FF';
h+=`<div style="margin-bottom:10px">
<div style="display:flex;justify-content:space-between;font-size:12px;margin-bottom:4px">
<span style="color:#CFD8DC">${esc(name)}${over?' ⚠️':''}</span>
<span style="color:${c};font-weight:700">${w.toFixed(1)}%</span></div>
<div style="height:8px;background:rgba(255,255,255,0.06);border-radius:4px;overflow:hidden">
<div style="height:100%;width:${Math.min(100,w).toFixed(0)}%;background:${c};border-radius:4px"></div></div></div>`;
});
if(arr[0][1]>30) h+=`<div style="font-size:11.5px;color:#FF8A80;margin-top:4px">⚠️ <b>${esc(arr[0][0])}</b> 업종이 ${arr[0][1].toFixed(0)}%로 한쪽에 쏠렸습니다. 한 업종 30% 이내로 분산하면 위험이 줄어듭니다.</div>`;
return h+`</div>`;
}
function portfolioRebalanceHTML(holdings){
const acts=[];
holdings.forEach(s=>{
if(s.recommendation==='강력매도'||s.recommendation==='매도관심')
acts.push([s,'sell',`AI가 <b>${esc(s.recommendation)}</b> 신호를 냈습니다 — 매도를 검토하세요`]);
else if(s.cur>0&&s.pnlPct<=-10)
acts.push([s,'cut',`손실 ${s.pnlPct.toFixed(1)}% — 손절 기준을 점검하세요`]);
else if(s.aiScore!=null&&s.aiScore<=-30)
acts.push([s,'sell',`AI점수 ${s.aiScore.toFixed(0)}점으로 부진 — 매도를 검토하세요`]);
else if(s.aiScore!=null&&s.aiScore>=70&&s.weight<7)
acts.push([s,'add',`AI점수 ${s.aiScore.toFixed(0)}점 우량인데 비중이 작습니다 — 추가 매수 고려`]);
});
let h=`<div class="panel" style="max-height:none;margin-bottom:16px"><h3>🔧 리밸런싱 진단</h3>`;
if(!acts.length){
h+=`<div style="color:#69F0AE;font-size:12.5px">✅ 보유 종목에 특별한 조치 신호가 없습니다. 그대로 유지해도 무난합니다.</div>`;
}else{
acts.forEach(([s,kind,msg])=>{
const c=kind==='add'?'#69F0AE':kind==='sell'?'#FF8A80':'#FFD740';
const ic=kind==='add'?'':kind==='sell'?'⚠️':'🔻';
h+=`<div data-stock="${s.code}" data-stock-name="${esc(s.name||'')}" style="display:flex;gap:10px;align-items:center;padding:9px 0;border-bottom:1px solid rgba(255,255,255,0.05);cursor:pointer">
<span style="font-size:15px">${ic}</span>
<div style="flex:1;min-width:0"><div style="color:#CFD8DC;font-size:12.5px;font-weight:600">${esc(s.name||s.code)}</div>
<div style="color:${c};font-size:11px;line-height:1.5">${msg}</div></div>
<span style="color:${s.pnlPct>=0?'#69F0AE':'#FF8A80'};font-size:11px;font-family:'JetBrains Mono',monospace">${s.cur>0?(s.pnlPct>=0?'+':'')+s.pnlPct.toFixed(1)+'%':'-'}</span></div>`;
});
}
return h+`</div>`;
}
function recoPortfolioHTML(reco){
if(!reco||!reco.holdings||!reco.holdings.length)
return `<div class="panel" style="max-height:none"><h3>🤖 AI 추천 포트폴리오</h3><div style="color:#546E7A;font-size:12px">${esc((reco&&reco.msg)||'추천 데이터를 불러오지 못했습니다.')}</div></div>`;
let h=`<div class="panel" style="max-height:none"><h3>🤖 AI 추천 포트폴리오 (${esc(reco.as_of||'')})</h3>
<div style="font-size:11.5px;color:#90A4AE;margin-bottom:12px;line-height:1.6">오늘 <b style="color:#69F0AE">강력매수·매수관심</b> 종목을 변동성·점수로 비중을 매기고, 한 업종 30% 이내로 분산한 구성입니다. 투자금액을 넣으면 종목별 매수 주수를 계산합니다.</div>
<div style="display:flex;gap:8px;margin-bottom:14px;align-items:center;flex-wrap:wrap">
<input id="reco-amt" type="number" placeholder="투자금액 입력 (원)" value="${recoAmount||''}"
style="background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);border-radius:8px;padding:8px 12px;color:#E0E0E0;font-size:13px;width:180px;outline:none"
onkeypress="if(event.key==='Enter')applyRecoAmount()">
<button onclick="applyRecoAmount()" style="background:rgba(0,230,118,0.1);border:1px solid rgba(0,230,118,0.25);border-radius:8px;padding:8px 16px;color:#00E676;font-size:12px;font-weight:600;cursor:pointer">계산</button>
</div>`;
const sbArr=Object.entries(reco.sector_breakdown||{});
if(sbArr.length){
h+=`<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:12px">`;
sbArr.forEach(([n,w])=>{ h+=`<span style="background:rgba(64,196,255,0.1);border:1px solid rgba(64,196,255,0.25);border-radius:6px;padding:3px 9px;font-size:10.5px;color:#CFD8DC">${esc(n)} ${w.toFixed(0)}%</span>`; });
h+=`</div>`;
}
const hasAmt=reco.input_amount>0;
h+=`<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:12px">
<thead><tr style="color:#78909C;border-bottom:1px solid rgba(255,255,255,0.08)">
<th style="text-align:left;padding:8px 6px;font-weight:600">종목</th>
<th style="text-align:center;padding:8px 6px;font-weight:600">AI점수</th>
<th style="text-align:right;padding:8px 6px;font-weight:600">비중</th>
${hasAmt?'<th style="text-align:right;padding:8px 6px;font-weight:600">매수금액</th><th style="text-align:right;padding:8px 6px;font-weight:600">주수</th>':''}
</tr></thead><tbody>`;
reco.holdings.forEach((s,i)=>{
const aic=s.total_score>=70?'#00E676':s.total_score>=40?'#69F0AE':'#90A4AE';
h+=`<tr data-stock="${s.stock_code}" data-stock-name="${esc(s.stock_name)}" style="border-bottom:1px solid rgba(255,255,255,0.04);cursor:pointer;${i%2?'background:rgba(255,255,255,0.01)':''}">
<td style="padding:9px 6px"><div style="color:#CFD8DC;font-weight:600">${esc(s.stock_name)}</div>
<div style="font-size:10px;color:#546E7A">${esc(s.sector)} · ${esc(s.recommendation)}</div></td>
<td style="text-align:center;padding:9px 6px;color:${aic};font-weight:700;font-family:'JetBrains Mono',monospace">${s.total_score.toFixed(0)}</td>
<td style="text-align:right;padding:9px 6px"><span style="color:#40C4FF;font-weight:700">${s.weight_pct.toFixed(1)}%</span></td>
${hasAmt?`<td style="text-align:right;padding:9px 6px;color:#CFD8DC;font-family:'JetBrains Mono',monospace">${fmt(s.invest_amount)}</td><td style="text-align:right;padding:9px 6px;color:#E0E0E0;font-weight:600">${s.shares.toLocaleString()}</td>`:''}
</tr>`;
});
h+=`</tbody></table></div>`;
if(hasAmt)
h+=`<div style="margin-top:10px;font-size:11.5px;color:#90A4AE">투자금 ${fmt(reco.input_amount)}원 중 <b style="color:#69F0AE">${fmt(reco.invested_amount)}원</b> 매수 · 현금 ${fmt(reco.cash_remaining)}원 남음</div>`;
return h+`</div>`;
}
function equityCurveSVG(curve){
if(!curve||curve.length<2) return '';
const W=640,H=180,pad=30;
const allv=curve.flatMap(p=>[p.strategy_pct,p.kospi_pct]).concat([0]);
let mn=Math.min(...allv), mx=Math.max(...allv);
if(mx===mn)mx=mn+1;
const X=i=>pad+(W-2*pad)*i/(curve.length-1);
const Y=v=>H-pad-(H-2*pad)*(v-mn)/(mx-mn);
const path=k=>curve.map((p,i)=>`${i?'L':'M'}${X(i).toFixed(1)},${Y(p[k]).toFixed(1)}`).join('');
const last=curve[curve.length-1];
const sC=last.strategy_pct>=0?'#00E676':'#FF5252';
return `<div class="panel" style="margin-top:14px"><h3>📈 자산곡선 — 추천을 따랐을 때 누적 수익률</h3>
<div style="font-size:11px;color:#90A4AE;margin-bottom:8px">매일 추천 종목을 등비중으로 샀다고 가정한 누적 수익률(거래비용 차감). <b style="color:${sC}">초록=전략</b> · <b style="color:#FFB74D">주황=KOSPI</b></div>
<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:auto">
<line x1="${pad}" y1="${Y(0).toFixed(1)}" x2="${W-pad}" y2="${Y(0).toFixed(1)}" stroke="rgba(255,255,255,0.15)" stroke-dasharray="3"/>
<path d="${path('kospi_pct')}" fill="none" stroke="#FFB74D" stroke-width="1.6"/>
<path d="${path('strategy_pct')}" fill="none" stroke="${sC}" stroke-width="2.2"/>
</svg>
<div style="display:flex;justify-content:space-between;font-size:11px;color:#78909C;margin-top:4px">
<span>${esc(curve[0].date)}</span>
<span style="color:${sC};font-weight:700">전략 ${last.strategy_pct>=0?'+':''}${last.strategy_pct}% vs KOSPI ${last.kospi_pct>=0?'+':''}${last.kospi_pct}%</span>
<span>${esc(last.date)}</span></div></div>`;
}
function renderSignals(signals,cands){
let h=`<div style="margin-bottom:20px">
<div style="font-size:12px;color:#546E7A;margin-bottom:16px">
@@ -1871,6 +2156,7 @@ async function renderBacktest(){
${_btCard('MDD', oa.max_drawdown_pct, '%', true)}
${_btCard('알파', oa.avg_alpha_pct, '%', true)}
</div>
${equityCurveSVG(d.equity_curve)}
<div class="panel" style="margin-top:14px"><h3>등급별 성과 (7일)</h3>${_btTable(d.by_recommendation_7d||{})}</div>`;
if(oa30.n){
h += `<h3 style="color:#90A4AE;font-size:13px;margin:18px 0 10px">30일 보유</h3>
@@ -1892,64 +2178,241 @@ async function renderBacktest(){
// ─────────────────────────────────────────────────────────────
// 🌎 미증시 동조
// ─────────────────────────────────────────────────────────────
// 미국 주요 티커 → 한글 회사명/설명 매핑 (페어·ETF 표시용)
const US_TICKER_KR = {
// 반도체
NVDA:'엔비디아', AMD:'AMD', MU:'마이크론', INTC:'인텔', TSM:'TSMC(대만 반도체)',
AVGO:'브로드컴', QCOM:'퀄컴', TXN:'TI', AMAT:'어플라이드머티리얼', LRCX:'램리서치', KLAC:'KLA',
// 빅테크
AAPL:'애플', MSFT:'마이크로소프트', GOOGL:'구글', AMZN:'아마존', META:'메타(페북)',
TSLA:'테슬라', NFLX:'넷플릭스', ORCL:'오라클', ADBE:'어도비', CRM:'세일즈포스',
// 금융
JPM:'JP모건', BAC:'뱅크오브아메리카', GS:'골드만삭스', MS:'모건스탠리',
WFC:'웰스파고', C:'씨티그룹', BLK:'블랙록',
// 에너지/소재
XOM:'엑손모빌', CVX:'쉐브론', COP:'코노코필립스', NUE:'뉴코어(철강)', FCX:'프리포트(구리)',
// 헬스/바이오
JNJ:'존슨앤존슨', PFE:'화이자', MRK:'머크', ABBV:'애브비', LLY:'일라이릴리',
BMY:'BMS', GILD:'길리어드',
// 소비재/유통
WMT:'월마트', COST:'코스트코', HD:'홈디포', MCD:'맥도날드', NKE:'나이키',
SBUX:'스타벅스', DIS:'디즈니',
// 산업/방산
BA:'보잉', LMT:'록히드마틴', RTX:'레이시온', GD:'제너럴다이내믹스', CAT:'캐터필러', DE:'존디어',
// 자동차
F:'포드', GM:'GM', TM:'도요타',
// 2차전지/리튬
ALB:'알버말(리튬)', RIVN:'리비안',
// 화학
DOW:'다우', LYB:'라이언델바젤',
// 게임/엔터
NTES:'넷이즈(중국게임)',
// ETF
SOXX:'반도체 ETF', SMH:'반도체 ETF(VanEck)', XLK:'미국 기술주 ETF', QQQ:'나스닥100 ETF',
XBI:'바이오 ETF', IBB:'바이오 ETF(나스닥)', LIT:'리튬·전기차 ETF',
XLE:'에너지 ETF', XLF:'금융 ETF', XLV:'헬스케어 ETF', XLI:'산업재 ETF',
XLP:'필수소비재 ETF', XLY:'경기소비재 ETF', ITA:'항공·방산 ETF',
};
function usName(t){ return US_TICKER_KR[t] || ''; }
async function renderUsMarket(){
const el=document.getElementById('usmarket');
el.innerHTML=`<div style="font-size:12px;color:#546E7A;margin-bottom:14px">미국 섹터 ETF·KR↔US 페어 회귀로 한국 종목별 동조 시그널 산출 (매일 KST 08:00)</div><div style="text-align:center;color:#546E7A;padding:30px">불러오는 중...</div>`;
el.innerHTML=`<div style="text-align:center;color:#546E7A;padding:30px">불러오는 중...</div>`;
try{
const [etfs, pairs, signals] = await Promise.all([
api('/usmarket/etfs'), api('/usmarket/pairs'), api('/usmarket/signals')
const [etfs, pairs, signals, brief] = await Promise.all([
api('/usmarket/etfs'), api('/usmarket/pairs'), api('/usmarket/signals'), api('/usmarket/briefing')
]);
let h = `<div class="grid2">`;
// 섹터 ETF
h += `<div class="panel"><h3>섹터 ETF (${Array.isArray(etfs)?etfs.length:0})</h3>`;
if(Array.isArray(etfs) && etfs.length){
h += `<table style="width:100%;font-size:11px;border-collapse:separate;border-spacing:1px">
<thead><tr style="color:#90A4AE"><th style="text-align:left;padding:6px">티커</th><th style="text-align:left">설명</th><th style="text-align:left">한국 섹터</th></tr></thead><tbody>`;
etfs.slice(0,30).forEach(e=>{
const kw = Array.isArray(e.sector_keywords)?e.sector_keywords.join(', '):'';
h += `<tr><td style="padding:5px 6px;color:#69F0AE;font-family:'JetBrains Mono',monospace;font-weight:700">${esc(e.etf_ticker)}</td><td style="color:#CFD8DC">${esc(e.description||'-')}</td><td style="color:#90A4AE;font-size:10px">${esc(kw)}</td></tr>`;
});
h += `</tbody></table>`;
} else h += `<div class="empty">데이터 없음</div>`;
h += `</div>`;
// 페어
h += `<div class="panel"><h3>KR ↔ US 페어 (${Array.isArray(pairs)?pairs.length:0})</h3>`;
if(Array.isArray(pairs) && pairs.length){
h += `<table style="width:100%;font-size:11px;border-collapse:separate;border-spacing:1px">
<thead><tr style="color:#90A4AE"><th style="text-align:left;padding:6px">US</th><th>KR</th><th title="60일 회귀 베타">β</th><th title="60일 상관">ρ</th><th>N</th></tr></thead><tbody>`;
pairs.slice(0,30).forEach(p=>{
const beta = p.beta_60d!=null?(+p.beta_60d).toFixed(2):'-';
const corr = p.correlation_60d!=null?(+p.correlation_60d).toFixed(2):'-';
h += `<tr><td style="padding:5px 6px;color:#69F0AE;font-family:'JetBrains Mono',monospace">${esc(p.us_ticker)}</td><td style="color:#CFD8DC;font-family:'JetBrains Mono',monospace">${esc(p.kr_code)}</td><td style="text-align:center;font-family:'JetBrains Mono',monospace;color:#FFD740">${beta}</td><td style="text-align:center;font-family:'JetBrains Mono',monospace;color:#90A4AE">${corr}</td><td style="text-align:center;color:#546E7A">${p.sample_size||'-'}</td></tr>`;
});
h += `</tbody></table>`;
} else h += `<div class="empty">데이터 없음</div>`;
h += `</div></div>`;
// 시그널
h += `<div class="panel" style="margin-top:14px"><h3>최신 동조 시그널 (${Array.isArray(signals)?signals.length:0})</h3>`;
// ── 0. 탭 안내 (이 탭은 뭐 하는 곳?) ──
let h = `
<div style="background:linear-gradient(135deg,rgba(64,196,255,0.07),rgba(105,240,174,0.05));border:1px solid rgba(64,196,255,0.25);border-radius:12px;padding:14px 18px;margin-bottom:16px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<span style="font-size:18px">🌎</span>
<span style="font-weight:700;color:#40C4FF;font-size:14px">이 탭은 뭐예요?</span>
</div>
<div style="color:#CFD8DC;font-size:12.5px;line-height:1.7">
<b style="color:#69F0AE">미국 증시의 움직임</b>이 한국 종목에 어떤 영향을 줄지 미리 보여주는 곳입니다.
예) <span style="color:#FFD740">엔비디아가 어제 +5% 올랐다 → SK하이닉스도 오를 가능성 ↑</span>
<br>
매일 한국 시장 시작 전(KST 08:00)에 미국 마감을 보고 자동 계산됩니다.
</div>
<div style="background:rgba(255,255,255,0.04);border-radius:8px;padding:10px 12px;margin-top:10px;font-size:11.5px;color:#90A4AE;line-height:1.7">
<b style="color:#FFD740">📖 보는 법</b><br>
① <b style="color:#CFD8DC">최신 동조 시그널</b>: 한국 종목별로 "오늘 미국 영향을 +/- 몇 점 받았는지". <b style="color:#69F0AE">+5 이상이면 강한 호재</b>, <b style="color:#FF8A80">-5 이하면 강한 악재</b>. 종합점수에 반영됨.<br>
② <b style="color:#CFD8DC">섹터 ETF</b>: 미국 업종 ETF가 한국 어떤 업종에 영향 주는지. 예) 미국 반도체 ETF(SOXX) ↑ → 한국 반도체 ↑<br>
③ <b style="color:#CFD8DC">KR↔US 페어</b>: 개별 종목 간 동조 (엔비디아↔SK하이닉스 등). β(민감도)가 클수록 강하게 따라감.
</div>
</div>`;
// ── 0.5. 오늘 새벽 미국증시 브리핑 (핫/저조 + 관련 KOSPI) ──
if(brief && !brief.err && ((brief.hot&&brief.hot.length)||(brief.cold&&brief.cold.length))){
const aiC=(v)=> v==null?'#546E7A': v>=30?'#69F0AE': v<=-30?'#FF8A80':'#90A4AE';
const krLine=(k)=>{
const sc=(k.ai_score!=null)?`<span style="color:${aiC(k.ai_score)};font-family:'JetBrains Mono',monospace;font-size:11px">${k.ai_score>=0?'+':''}${k.ai_score}</span>`:'<span style="color:#546E7A;font-size:11px">점수없음</span>';
const rec=(k.recommendation&&k.recommendation!=='-')?`<span style="color:${RCOL[k.recommendation]||'#78909C'};font-size:10px;font-weight:600">${esc(k.recommendation)}</span>`:'';
return `<div data-stock="${esc(k.kr_code)}" data-stock-name="${esc(k.kr_name)}" style="display:flex;gap:6px;align-items:center;padding:3px 0;cursor:pointer">
<span style="color:#CFD8DC;font-size:11.5px;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(k.kr_name)}</span>${sc}${rec}</div>`;
};
const stockCard=(s,isHot)=>{
const c=isHot?'#69F0AE':'#FF8A80';
const krs=(s.related_kr||[]).map(krLine).join('')||'<span style="color:#546E7A;font-size:11px">연결된 한국 종목 없음</span>';
return `<div style="background:rgba(255,255,255,0.03);border:1px solid ${c}33;border-radius:10px;padding:10px 12px;margin-bottom:8px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;gap:8px">
<span style="color:#E0E0E0;font-weight:700;font-size:12.5px">${esc(s.label)}</span>
<span style="color:${c};font-weight:700;font-family:'JetBrains Mono',monospace;font-size:14px;white-space:nowrap">${s.change_pct>=0?'+':''}${s.change_pct}%</span>
</div>
<div style="border-top:1px solid rgba(255,255,255,0.05);padding-top:5px">
<div style="font-size:10px;color:#607D8B;margin-bottom:2px">관련 KOSPI 종목 (AI점수 높은 순)</div>${krs}
</div></div>`;
};
h += `<div class="panel"><h3>🌙 오늘 새벽 미국증시 (${esc(brief.trade_date||'')} 기준)</h3>
<div style="font-size:11.5px;color:#90A4AE;margin-bottom:14px;line-height:1.6">간밤 미국에서 <b style="color:#69F0AE">오른 종목</b>·<b style="color:#FF8A80">내린 종목</b>과, 같이 움직이는 한국(KOSPI) 관련주입니다. 관련주의 <b style="color:#FFD740">AI점수가 높으면(+30↑)</b> 오늘 주목해볼 만합니다.</div>
<div class="usbrief-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
<div><div style="color:#69F0AE;font-weight:700;font-size:12px;margin-bottom:8px">🔥 어젯밤 강했던 종목</div>
${(brief.hot||[]).map(s=>stockCard(s,true)).join('')||'<div style="color:#546E7A;font-size:11px">데이터 없음</div>'}</div>
<div><div style="color:#FF8A80;font-weight:700;font-size:12px;margin-bottom:8px">❄️ 어젯밤 약했던 종목</div>
${(brief.cold||[]).map(s=>stockCard(s,false)).join('')||'<div style="color:#546E7A;font-size:11px">데이터 없음</div>'}</div>
</div>`;
if(brief.sector_heat&&brief.sector_heat.length){
h += `<div style="margin-top:14px"><div style="font-size:11px;color:#607D8B;margin-bottom:6px">미국 업종 ETF 등락 — 한국 같은 업종에 영향</div>
<div style="display:flex;flex-wrap:wrap;gap:6px">`;
brief.sector_heat.forEach(e=>{
const c=e.change_pct>=0?'#69F0AE':'#FF8A80';
h += `<span style="background:${c}1a;border:1px solid ${c}40;border-radius:6px;padding:3px 9px;font-size:10.5px;color:#CFD8DC">${esc(e.label)} <b style="color:${c}">${e.change_pct>=0?'+':''}${e.change_pct}%</b></span>`;
});
h += `</div></div>`;
}
h += `</div>`;
}
// ── 1. 최신 동조 시그널 (가장 중요 — 맨 위로) ──
h += `<div class="panel"><h3>📡 오늘의 미국→한국 영향 (${Array.isArray(signals)?signals.length:0}개 종목)</h3>`;
h += `<div style="font-size:11px;color:#90A4AE;margin-bottom:12px">
🟢 양수(+) = 미국 영향으로 <b style="color:#69F0AE">올라갈 압력</b> · 🔴 음수(-) = <b style="color:#FF8A80">내려갈 압력</b><br>
"섹터"는 미국 업종 ETF, "페어"는 짝지어진 미국 개별주의 영향. 둘을 합친 게 총점.
</div>`;
if(Array.isArray(signals) && signals.length){
h += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:8px">`;
// 절대값 큰 순으로 정렬
const sorted = signals.slice().sort((a,b)=>Math.abs(+b.total_adj||0) - Math.abs(+a.total_adj||0));
sorted.slice(0,60).forEach(s=>{
// 강도 그룹별로 분류
const strong = sorted.filter(s=>Math.abs(+s.total_adj||0) >= 5);
const medium = sorted.filter(s=>{ const a=Math.abs(+s.total_adj||0); return a>=2 && a<5; });
const weak = sorted.filter(s=>Math.abs(+s.total_adj||0) < 2);
const renderCard = (s)=>{
const total = +s.total_adj || 0;
const sec = +s.sector_adj || 0;
const pair = +s.pair_adj || 0;
const c = total>0?'#69F0AE':total<0?'#FF8A80':'#90A4AE';
h += `<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.05);border-radius:10px;padding:10px 12px">
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="color:#CFD8DC;font-family:'JetBrains Mono',monospace;font-weight:700">${esc(s.kr_code||'-')}</span>
<span style="color:${c};font-weight:700;font-family:'JetBrains Mono',monospace">${total>0?'+':''}${total.toFixed(2)}</span>
const arrow = total>=5?'🚀':total<=-5?'⚠️':total>0?'▲':total<0?'▼':'·';
const krName = esc(s.kr_name || s.kr_code || '-');
// contributing_pairs에서 어떤 미국 종목이 주된 원인인지 표시
let topReason = '';
try{
const cp = s.contributing_pairs;
const pairs = (cp && cp.pairs) ? cp.pairs : (Array.isArray(cp)?cp:[]);
if(pairs && pairs.length){
const top = pairs.slice().sort((a,b)=>Math.abs(+b.contribution||0)-Math.abs(+a.contribution||0))[0];
if(top){
const usK = usName(top.us) || '';
topReason = `${esc(top.us)}${usK?`(${esc(usK)})`:''} ${(+top.pct||0)>=0?'+':''}${(+top.pct||0).toFixed(1)}%`;
}
}
}catch(e){}
return `<div style="background:rgba(255,255,255,0.03);border:1px solid ${c}33;border-radius:10px;padding:12px 14px">
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
<div style="flex:1;min-width:0">
<div style="color:#CFD8DC;font-weight:700;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${krName}</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:10px;color:#546E7A">${esc(s.kr_code||'')}</div>
</div>
<div style="text-align:right">
<div style="color:${c};font-weight:700;font-family:'JetBrains Mono',monospace;font-size:18px">${arrow} ${total>0?'+':''}${total.toFixed(1)}</div>
</div>
</div>
<div style="font-size:10px;color:#90A4AE;margin-top:4px;display:flex;gap:10px">
<span>섹터 ${sec>=0?'+':''}${sec.toFixed(2)}</span>
<span>페어 ${pair>=0?'+':''}${pair.toFixed(2)}</span>
<div style="font-size:10.5px;color:#78909C;margin-top:6px;display:flex;gap:10px;flex-wrap:wrap">
<span title="섹터 ETF 영향">섹터 <span style="color:${sec>=0?'#69F0AE':'#FF8A80'}">${sec>=0?'+':''}${sec.toFixed(1)}</span></span>
<span title="개별 페어 영향">페어 <span style="color:${pair>=0?'#69F0AE':'#FF8A80'}">${pair>=0?'+':''}${pair.toFixed(1)}</span></span>
</div>
${topReason?`<div style="font-size:10px;color:#90A4AE;margin-top:5px;border-top:1px solid rgba(255,255,255,0.05);padding-top:5px">📌 주원인: ${topReason}</div>`:''}
</div>`;
};
const renderGroup = (title, color, arr) => {
if(!arr.length) return '';
return `<div style="margin-bottom:14px">
<div style="font-size:12px;color:${color};font-weight:700;margin-bottom:8px">${title} <span style="color:#546E7A;font-weight:400">${arr.length}개</span></div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px">
${arr.slice(0,40).map(renderCard).join('')}
</div>
</div>`;
});
h += `</div>`;
} else h += `<div class="empty">시그널 없음 (아직 계산 전)</div>`;
};
h += renderGroup('🔥 강한 영향 (|점수| ≥ 5)', '#FFD740', strong);
h += renderGroup('➡️ 보통 영향 (2 ~ 5)', '#40C4FF', medium);
if(weak.length && (strong.length + medium.length) < 5){
h += renderGroup('약한 영향 (< 2)', '#78909C', weak.slice(0,20));
}
} else h += `<div class="empty">시그널 없음 — 매일 08:00에 자동 계산됨. 처음 배포 직후라면 아직 생성 전입니다.</div>`;
h += `</div>`;
// ── 2. 섹터 ETF (어떤 업종이 어떻게 매칭되는지) ──
h += `<div class="grid2" style="margin-top:14px">`;
h += `<div class="panel"><h3>🏭 미국 섹터 ETF → 한국 업종 매칭 (${Array.isArray(etfs)?etfs.length:0})</h3>`;
h += `<div style="font-size:11px;color:#90A4AE;margin-bottom:10px">미국 ETF가 오르내리면 매칭된 한국 업종도 같이 영향받습니다.</div>`;
if(Array.isArray(etfs) && etfs.length){
h += `<table style="width:100%;font-size:11.5px;border-collapse:separate;border-spacing:0 3px">
<thead><tr style="color:#90A4AE;font-size:10.5px">
<th style="text-align:left;padding:4px 6px">미국 ETF</th>
<th style="text-align:left">설명</th>
<th style="text-align:left">한국 업종</th>
</tr></thead><tbody>`;
etfs.slice(0,30).forEach(e=>{
const kw = Array.isArray(e.sector_keywords)?e.sector_keywords.join(' · '):'';
const krDesc = usName(e.etf_ticker) || esc(e.description||'-');
h += `<tr style="background:rgba(255,255,255,0.02)">
<td style="padding:7px 8px;color:#69F0AE;font-family:'JetBrains Mono',monospace;font-weight:700">${esc(e.etf_ticker)}</td>
<td style="color:#CFD8DC">${esc(krDesc)}</td>
<td style="color:#FFD740;font-size:11px">${esc(kw)}</td>
</tr>`;
});
h += `</tbody></table>`;
} else h += `<div class="empty">데이터 없음</div>`;
h += `</div>`;
// ── 3. 개별 페어 ──
h += `<div class="panel"><h3>🔗 개별 종목 짝짓기 (${Array.isArray(pairs)?pairs.length:0})</h3>`;
h += `<div style="font-size:11px;color:#90A4AE;margin-bottom:10px">
<b>민감도(β)</b>: 미국 종목이 1% 오를 때 한국 종목이 몇% 따라가는지<br>
<b>상관계수(ρ)</b>: 같이 움직이는 정도. 0.7↑면 매우 강함, 0.5↑면 의미 있음, 0.3↓면 약함
</div>`;
if(Array.isArray(pairs) && pairs.length){
// 한국명 매칭되는 페어를 우선 정렬
const sorted = pairs.slice().sort((a,b)=>{
const corA = Math.abs(+a.correlation_60d||0);
const corB = Math.abs(+b.correlation_60d||0);
return corB - corA;
});
h += `<table style="width:100%;font-size:11.5px;border-collapse:separate;border-spacing:0 3px">
<thead><tr style="color:#90A4AE;font-size:10.5px">
<th style="text-align:left;padding:4px 6px">미국</th>
<th style="text-align:left">한국</th>
<th title="민감도: 미국 +1%일 때 한국 +β%" style="text-align:center">민감도(β)</th>
<th title="상관: 같이 움직이는 정도 (0~1)" style="text-align:center">상관(ρ)</th>
</tr></thead><tbody>`;
sorted.slice(0,40).forEach(p=>{
const beta = p.beta_60d!=null?(+p.beta_60d).toFixed(2):'-';
const corr = p.correlation_60d!=null?(+p.correlation_60d).toFixed(2):'-';
const corrN = +p.correlation_60d || 0;
const corrC = Math.abs(corrN)>=0.7?'#69F0AE':Math.abs(corrN)>=0.5?'#FFD740':Math.abs(corrN)>=0.3?'#90A4AE':'#546E7A';
const usK = usName(p.us_ticker) || '';
const krN = esc(p.kr_name || '');
h += `<tr style="background:rgba(255,255,255,0.02)">
<td style="padding:6px 8px;color:#69F0AE;font-family:'JetBrains Mono',monospace">${esc(p.us_ticker)}${usK?`<span style="color:#90A4AE;font-family:'Pretendard',sans-serif;font-size:10px;margin-left:4px">${esc(usK)}</span>`:''}</td>
<td style="color:#CFD8DC">${krN || `<span style="font-family:'JetBrains Mono',monospace">${esc(p.kr_code)}</span>`}</td>
<td style="text-align:center;font-family:'JetBrains Mono',monospace;color:#FFD740">${beta}</td>
<td style="text-align:center;font-family:'JetBrains Mono',monospace;color:${corrC};font-weight:700">${corr}</td>
</tr>`;
});
h += `</tbody></table>`;
} else h += `<div class="empty">페어 없음</div>`;
h += `</div></div>`;
el.innerHTML = h;
}catch(e){el.innerHTML=`<div class="empty">오류: ${esc(String(e))}</div>`}
}