2026-05-20 21:33:56 +09:00
<!DOCTYPE html>
< html lang = "ko" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > Trading AI Dashboard</ title >
< 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 24 px 40 px }
:: -webkit-scrollbar { width : 6 px }:: -webkit-scrollbar-thumb { background : rgba ( 255 , 255 , 255 , 0.1 ); border-radius : 3 px }
. wrap { max-width : 1400 px ; margin : 0 auto }
. header { display : flex ; justify-content : space-between ; align-items : center ; padding : 20 px 0 ; border-bottom : 1 px solid rgba ( 255 , 255 , 255 , 0.06 ); margin-bottom : 24 px }
. header h1 { font-size : 22 px ; font-weight : 800 ; letter-spacing : -0.5 px }. header h1 span { color : #00E676 }
. header . sub { font-size : 12 px ; color : #546E7A ; margin-top : 4 px }
. header . time { font-size : 13 px ; color : #607D8B ; font-family : 'JetBrains Mono' , monospace }
. btn { background : rgba ( 0 , 230 , 118 , 0.1 ); border : 1 px solid rgba ( 0 , 230 , 118 , 0.2 ); border-radius : 8 px ; padding : 8 px 16 px ; color : #00E676 ; font-size : 12 px ; cursor : pointer ; font-weight : 600 }
. tabs { display : flex ; gap : 4 px ; margin-bottom : 24 px }
. tab { background : transparent ; border : 1 px solid transparent ; border-radius : 8 px ; padding : 8 px 16 px ; color : #607D8B ; font-size : 13 px ; font-weight : 500 ; cursor : pointer }
. tab . active { background : rgba ( 0 , 230 , 118 , 0.1 ); border-color : rgba ( 0 , 230 , 118 , 0.2 ); color : #00E676 ; font-weight : 700 }
. cards { display : grid ; grid-template-columns : repeat ( auto - fit , minmax ( 180 px , 1 fr )); gap : 12 px ; margin-bottom : 24 px }
. card { background : rgba ( 255 , 255 , 255 , 0.03 ); border : 1 px solid rgba ( 255 , 255 , 255 , 0.06 ); border-radius : 16 px ; padding : 24 px 20 px }
. card . label { font-size : 13 px ; color : #90A4AE ; letter-spacing : 1 px ; margin-bottom : 8 px }
. card . val { font-size : 36 px ; font-weight : 800 ; font-family : 'JetBrains Mono' , monospace ; line-height : 1 }
. card . sub { font-size : 12 px ; color : #607D8B ; margin-top : 8 px }
. grid2 { display : grid ; grid-template-columns : 1 fr 1 fr ; gap : 16 px }
@ media ( max-width : 900px ) {. grid2 { grid-template-columns : 1 fr }}
. panel { background : rgba ( 255 , 255 , 255 , 0.02 ); border : 1 px solid rgba ( 255 , 255 , 255 , 0.05 ); border-radius : 16 px ; padding : 20 px ; max-height : 600 px ; overflow : auto }
. panel h3 { font-size : 14 px ; font-weight : 700 ; color : #90A4AE ; margin-bottom : 16 px }
. rank-item { display : flex ; align-items : center ; gap : 12 px ; padding : 10 px 0 ; border-bottom : 1 px solid rgba ( 255 , 255 , 255 , 0.03 )}
. rank-num { width : 24 px ; height : 24 px ; border-radius : 50 % ; display : flex ; align-items : center ; justify-content : center ; font-size : 11 px ; font-weight : 700 }
. rank-num . top { background : rgba ( 0 , 230 , 118 , 0.15 ); color : #00E676 }
. rank-num . normal { background : rgba ( 255 , 255 , 255 , 0.04 ); color : #546E7A }
. news-item { display : flex ; gap : 12 px ; padding : 12 px 0 ; border-bottom : 1 px solid rgba ( 255 , 255 , 255 , 0.03 )}
. news-icon { min-width : 40 px ; text-align : center }
. news-icon . arrow { font-size : 18 px }. news-icon . score { font-size : 10 px ; font-weight : 700 }
. news-body { flex : 1 ; min-width : 0 }
. news-title { font-size : 13 px ; color : #CFD8DC ; line-height : 1.4 ; overflow : hidden ; text-overflow : ellipsis ; white-space : nowrap }
. news-meta { display : flex ; gap : 8 px ; margin-top : 4 px ; font-size : 11 px }
. news-reason { font-size : 11 px ; color : #607D8B ; margin-top : 4 px ; line-height : 1.4 }
. badge { border-radius : 6 px ; padding : 4 px 10 px ; font-size : 12 px ; font-weight : 600 ; height : fit-content ; white-space : nowrap }
. rec-card { background : rgba ( 255 , 255 , 255 , 0.02 ); border : 1 px solid rgba ( 255 , 255 , 255 , 0.05 ); border-radius : 12 px ; padding : 16 px }
. rec-grid { display : grid ; grid-template-columns : repeat ( auto - fill , minmax ( 320 px , 1 fr )); gap : 12 px }
. score-bar { width : 100 % ; height : 6 px ; background : rgba ( 255 , 255 , 255 , 0.05 ); border-radius : 3 px ; overflow : hidden ; margin-top : 8 px }
. score-fill { height : 100 % ; border-radius : 3 px ; transition : width 0.8 s ease }
. alert-item { border-radius : 10 px ; padding : 12 px ; margin-bottom : 8 px }
. empty { color : #546E7A ; font-size : 13 px ; padding : 40 px ; text-align : center }
. footer { margin-top : 40 px ; padding : 16 px 0 ; border-top : 1 px solid rgba ( 255 , 255 , 255 , 0.04 ); text-align : center ; font-size : 11 px ; color : #37474F }
. signal-card { background : rgba ( 255 , 255 , 255 , 0.02 ); border-radius : 14 px ; padding : 18 px ; margin-bottom : 12 px }
. signal-grid { display : grid ; grid-template-columns : repeat ( auto - fill , minmax ( 360 px , 1 fr )); gap : 12 px }
. price-row { display : grid ; grid-template-columns : repeat ( 4 , 1 fr ); gap : 8 px ; margin-top : 10 px ; font-size : 12 px }
. price-box { background : rgba ( 255 , 255 , 255 , 0.04 ); border-radius : 8 px ; padding : 8 px ; text-align : center }
. price-box . label { font-size : 10 px ; color : #78909C ; margin-bottom : 4 px }
. price-box . val { font-size : 15 px ; font-weight : 700 ; font-family : 'JetBrains Mono' , monospace }
. ta-row { display : flex ; gap : 8 px ; flex-wrap : wrap ; margin-top : 8 px }
. ta-badge { background : rgba ( 255 , 255 , 255 , 0.05 ); border-radius : 6 px ; padding : 3 px 8 px ; font-size : 10 px ; color : #90A4AE }
. ta-badge . bull { background : rgba ( 0 , 230 , 118 , 0.08 ); color : #69F0AE }
. ta-badge . bear { background : rgba ( 255 , 82 , 82 , 0.08 ); color : #FF8A80 }
. candidates-grid { display : grid ; grid-template-columns : repeat ( auto - fill , minmax ( 340 px , 1 fr )); gap : 14 px }
. cand-card { background : rgba ( 255 , 255 , 255 , 0.02 ); border : 1 px solid rgba ( 0 , 230 , 118 , 0.12 ); border-radius : 14 px ; padding : 18 px }
/* ── 챗봇 ── */
# chat-btn { position : fixed ; bottom : 28 px ; right : 28 px ; width : 52 px ; height : 52 px ; border-radius : 50 % ; background : linear-gradient ( 135 deg , #00C853 , #00897B ); border : none ; cursor : pointer ; font-size : 22 px ; box-shadow : 0 4 px 20 px rgba ( 0 , 200 , 83 , 0.35 ); z-index : 1000 ; transition : transform .2 s }
# chat-btn : hover { transform : scale ( 1.08 )}
# chat-panel { position : fixed ; bottom : 92 px ; right : 28 px ; width : 380 px ; height : 560 px ; background : #141B22 ; border : 1 px solid rgba ( 0 , 230 , 118 , 0.2 ); border-radius : 20 px ; box-shadow : 0 8 px 40 px rgba ( 0 , 0 , 0 , 0.6 ); z-index : 999 ; display : none ; flex-direction : column ; overflow : hidden }
# chat-header { padding : 16 px 18 px ; border-bottom : 1 px solid rgba ( 255 , 255 , 255 , 0.06 ); display : flex ; align-items : center ; justify-content : space-between }
# chat-header . title { font-size : 14 px ; font-weight : 700 ; color : #00E676 }
# chat-header . subtitle { font-size : 10 px ; color : #546E7A ; margin-top : 2 px }
# chat-close { background : none ; border : none ; color : #546E7A ; cursor : pointer ; font-size : 18 px ; padding : 0 }
# chat-messages { flex : 1 ; overflow-y : auto ; padding : 14 px ; display : flex ; flex-direction : column ; gap : 10 px }
. msg { max-width : 85 % ; padding : 10 px 13 px ; border-radius : 14 px ; font-size : 13 px ; line-height : 1.55 ; word-break : break-word }
. msg . user { align-self : flex-end ; background : rgba ( 0 , 230 , 118 , 0.15 ); border : 1 px solid rgba ( 0 , 230 , 118 , 0.2 ); color : #E0E0E0 ; border-bottom-right-radius : 4 px }
. msg . ai { align-self : flex-start ; background : rgba ( 255 , 255 , 255 , 0.05 ); border : 1 px solid rgba ( 255 , 255 , 255 , 0.06 ); color : #CFD8DC ; border-bottom-left-radius : 4 px }
. msg . ai a { color : #00E676 }. msg p { margin : 4 px 0 }. msg ul , . msg ol { padding-left : 16 px ; margin : 4 px 0 }
. msg strong { color : #fff }. msg code { background : rgba ( 0 , 0 , 0 , 0.3 ); padding : 1 px 5 px ; border-radius : 4 px ; font-family : 'JetBrains Mono' , monospace ; font-size : 11 px }
. typing { display : flex ; gap : 4 px ; padding : 8 px 13 px ; align-self : flex-start }
. typing span { width : 6 px ; height : 6 px ; border-radius : 50 % ; background : #546E7A ; animation : bounce .8 s infinite }
. typing span : nth-child ( 2 ) { animation-delay : .15 s }. typing span : nth-child ( 3 ) { animation-delay : .3 s }
@ keyframes bounce { 0 %, 60 %, 100 % { transform : translateY ( 0 )} 30 % { transform : translateY ( -6 px )}}
# chat-input-row { padding : 12 px 14 px ; border-top : 1 px solid rgba ( 255 , 255 , 255 , 0.06 ); display : flex ; gap : 8 px }
# chat-input { flex : 1 ; background : rgba ( 255 , 255 , 255 , 0.05 ); border : 1 px solid rgba ( 255 , 255 , 255 , 0.08 ); border-radius : 10 px ; padding : 9 px 12 px ; color : #E0E0E0 ; font-size : 13 px ; font-family : 'Noto Sans KR' , sans-serif ; outline : none ; resize : none }
# chat-input : focus { border-color : rgba ( 0 , 230 , 118 , 0.3 )}
# chat-send { background : rgba ( 0 , 230 , 118 , 0.15 ); border : 1 px solid rgba ( 0 , 230 , 118 , 0.25 ); border-radius : 10 px ; padding : 9 px 14 px ; color : #00E676 ; cursor : pointer ; font-size : 16 px }
# chat-send : hover { background : rgba ( 0 , 230 , 118 , 0.25 )}
# chat-send : disabled { opacity : 0.4 ; cursor : default }
. kospi-panel { background : rgba ( 255 , 255 , 255 , 0.03 ); border : 1 px solid rgba ( 255 , 255 , 255 , 0.07 ); border-radius : 12 px ; padding : 20 px ; margin-bottom : 20 px }
. kospi-header { display : flex ; justify-content : space-between ; align-items : flex-start ; margin-bottom : 16 px }
. kospi-price { font-size : 28 px ; font-weight : 800 ; font-family : 'JetBrains Mono' , monospace }
. kospi-change { font-size : 14 px ; font-weight : 600 ; margin-top : 4 px }
. kospi-tip { background : rgba ( 0 , 230 , 118 , 0.06 ); border : 1 px solid rgba ( 0 , 230 , 118 , 0.15 ); border-radius : 10 px ; padding : 14 px 18 px ; margin-bottom : 20 px }
. kospi-tip . tip-tag { font-size : 10 px ; font-weight : 700 ; color : #00E676 ; letter-spacing : 1 px ; text-transform : uppercase ; margin-bottom : 6 px }
. kospi-tip . tip-title { font-size : 15 px ; font-weight : 700 ; color : #E0E0E0 ; margin-bottom : 6 px }
. kospi-tip . tip-body { font-size : 13 px ; color : #90A4AE ; line-height : 1.7 }
. market-tip-box { background : rgba ( 255 , 193 , 7 , 0.06 ); border : 1 px solid rgba ( 255 , 193 , 7 , 0.2 ); border-radius : 10 px ; padding : 14 px 18 px ; margin-top : 12 px }
. market-tip-box . mtip-label { font-size : 11 px ; font-weight : 700 ; color : #FFC107 ; margin-bottom : 6 px }
. market-tip-box . mtip-body { font-size : 13 px ; color : #CFD8DC ; line-height : 1.7 }
canvas # kospiChart { width : 100 % !important }
/* 클릭 가능한 종목 셀 호버 효과 */
[ data-stock ] { transition : background .15 s , transform .1 s }
[ data-stock ] : hover { background : rgba ( 0 , 230 , 118 , 0.06 ) !important }
tr [ data-stock ] : hover { background : rgba ( 0 , 230 , 118 , 0.06 ) !important }
. rec-card [ data-stock ] : hover , . cand-card [ data-stock ] : hover , . rank-item [ data-stock ] : hover , . signal-card [ data-stock ] : hover {
border-color : rgba ( 0 , 230 , 118 , 0.35 ) !important ;
transform : translateY ( -1 px );
}
/* 로그인 게이트: 비인증 시 .wrap, #chat-btn 숨김 */
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 : 20 px ; z-index : 100 ; overflow-y : auto }
. login-card { background : #141B22 ; border : 1 px solid rgba ( 0 , 230 , 118 , 0.18 ); border-radius : 20 px ; padding : 40 px ; width : 420 px ; max-width : 100 % ; box-shadow : 0 12 px 60 px rgba ( 0 , 0 , 0 , 0.6 )}
. login-logo { font-size : 24 px ; font-weight : 800 ; color : #E0E0E0 ; margin-bottom : 6 px ; letter-spacing : 0.5 px }
. login-logo span { color : #00E676 }
. login-sub { font-size : 12 px ; color : #78909C ; margin-bottom : 28 px }
. login-tab { display : flex ; gap : 0 ; margin-bottom : 24 px ; border-bottom : 1 px solid rgba ( 255 , 255 , 255 , 0.08 )}
. login-tab button { flex : 1 ; background : none ; border : none ; border-bottom : 2 px solid transparent ; padding : 12 px 0 ; color : #78909C ; font-size : 13 px ; font-weight : 600 ; cursor : pointer ; transition : all .2 s }
. login-tab button . active { color : #00E676 ; border-bottom-color : #00E676 }
. login-field { margin-bottom : 16 px }
. login-field label { display : block ; font-size : 11 px ; color : #78909C ; margin-bottom : 6 px }
. login-field input { width : 100 % ; background : rgba ( 255 , 255 , 255 , 0.05 ); border : 1 px solid rgba ( 255 , 255 , 255 , 0.1 ); border-radius : 10 px ; padding : 12 px 14 px ; color : #E0E0E0 ; font-size : 14 px ; outline : none ; box-sizing : border-box ; transition : border-color .15 s }
. login-field input : focus { border-color : rgba ( 0 , 230 , 118 , 0.4 )}
. login-submit { width : 100 % ; background : linear-gradient ( 135 deg , #00C853 , #00897B ); border : none ; border-radius : 12 px ; padding : 13 px ; color : #fff ; font-size : 14 px ; font-weight : 700 ; cursor : pointer ; margin-top : 8 px ; transition : transform .1 s , opacity .15 s }
. login-submit : hover { transform : translateY ( -1 px )}
. login-submit : disabled { opacity : 0.6 ; cursor : not-allowed }
. login-error { background : rgba ( 255 , 82 , 82 , 0.1 ); border : 1 px solid rgba ( 255 , 82 , 82 , 0.3 ); border-radius : 10 px ; padding : 10 px 14 px ; color : #FF8A80 ; font-size : 12 px ; margin-bottom : 14 px ; line-height : 1.5 }
. login-info { background : rgba ( 0 , 230 , 118 , 0.08 ); border : 1 px solid rgba ( 0 , 230 , 118 , 0.25 ); border-radius : 10 px ; padding : 12 px 14 px ; color : #69F0AE ; font-size : 12 px ; margin-bottom : 14 px ; line-height : 1.5 }
</ style >
< script src = "https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js" ></ script >
< script src = "https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3/dist/chartjs-adapter-date-fns.bundle.min.js" ></ script >
< script src = "https://cdn.jsdelivr.net/npm/chartjs-chart-financial@0.2.1/dist/chartjs-chart-financial.min.js" ></ script >
</ head >
< body class = "auth-required" >
<!-- 로그인/회원가입 풀페이지 (비인증 기본 표시) -->
< div id = "login-screen" >
< div class = "login-card" >
< div class = "login-logo" > ◆ < span > Trading AI</ span ></ div >
< div class = "login-sub" > 로그인 후 이용하실 수 있습니다</ div >
< div class = "login-tab" >
< button id = "lg-tab-login" class = "active" onclick = "setAuthMode('login')" > 로그인</ button >
< button id = "lg-tab-reg" onclick = "setAuthMode('register')" > 회원가입</ button >
</ div >
< div id = "lg-info" class = "login-info" style = "display:none" ></ div >
< div id = "lg-error" class = "login-error" style = "display:none" ></ div >
< div class = "login-field" >
< label > 이메일</ label >
< input id = "lg-email" type = "email" autocomplete = "email" placeholder = "you@example.com" >
</ div >
< div class = "login-field" >
< label id = "lg-pw-label" > 비밀번호</ label >
< input id = "lg-password" type = "password" autocomplete = "current-password" placeholder = "••••••••"
onkeypress = "if(event.key==='Enter')submitAuth()" >
</ div >
< button id = "lg-submit" class = "login-submit" onclick = "submitAuth()" > 로그인</ button >
< div style = "text-align:center;margin-top:16px;font-size:11px;color:#546E7A" >
Trading AI · 워렌 버핏 스타일 가치투자 분석
</ div >
</ div >
</ div >
<!-- 챗봇 -->
< button id = "chat-btn" onclick = "toggleChat()" title = "AI 애널리스트" > 💬</ button >
< div id = "chat-panel" >
< div id = "chat-header" >
< div >< div class = "title" > ◆ AI 애널리스트</ div >< div class = "subtitle" > exaone3.5 · 버핏 스타일 분석</ div ></ div >
< button id = "chat-close" onclick = "toggleChat()" > ✕</ button >
</ div >
< div id = "chat-messages" >
< div class = "msg ai" > 안녕하세요! 오늘 시장 현황이나 종목에 대해 물어보세요.< br >< small style = "color:#546E7A" > 예: "오늘 추천종목 알려줘" / "SK하이닉스 분석해줘"</ small ></ div >
</ div >
< div id = "chat-input-row" >
< textarea id = "chat-input" rows = "1" placeholder = "메시지 입력..." onkeydown = "chatKey(event)" ></ textarea >
< button id = "chat-send" onclick = "sendChat()" > ↑</ button >
</ div >
</ div >
< div class = "wrap" >
< div class = "header" >
< 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 >
< div id = "auth-area" style = "display:flex;align-items:center;gap:8px;font-size:12px" ></ div >
< button class = "btn" onclick = "loadAll()" > ↻ 새로고침</ button >
</ div >
</ div >
< div style = "margin-bottom:16px;display:flex;gap:8px" >
< input id = "searchInput" type = "text" placeholder = "종목명 또는 종목코드 검색 (예: 삼성전자, 005930)"
style = "flex:1;background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.1);border-radius:8px;padding:10px 16px;color:#E0E0E0;font-size:14px;font-family:'Noto Sans KR',sans-serif;outline:none"
onkeypress = "if(event.key==='Enter')doSearch()" >
< 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 >
2026-05-20 22:09:32 +09:00
< 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 >
2026-05-20 21:33:56 +09:00
</ 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 = "portfolio" style = "display:none" ></ div >
< div id = "mystock" style = "display:none" ></ div >
< div id = "recommend" style = "display:none" ></ div >
< div id = "news" style = "display:none" ></ div >
< div id = "supplydemand" style = "display:none" ></ div >
< div id = "alerts" style = "display:none" ></ div >
2026-05-20 22:09:32 +09:00
< div id = "formulas" style = "display:none" ></ div >
< div id = "backtest" style = "display:none" ></ div >
< div id = "usmarket" style = "display:none" ></ div >
< div id = "macro" style = "display:none" ></ div >
2026-05-20 21:33:56 +09:00
< div class = "footer" > Trading AI System • 뉴스/공시 기반 자동 분석 • 투자 판단의 참고 자료로만 활용하세요</ div >
</ div >
< script >
const API = window . location . origin + '/api' ;
const SCOL = { 호재 : '#00E676' , 악재 : '#FF5252' , 중립 : '#78909C' };
const SICON = { 호재 : '▲' , 악재 : '▼' , 중립 : '●' };
const RCOL = { 강력매수 : '#00E676' , 매수관심 : '#69F0AE' , 관망 : '#78909C' , 매도관심 : '#FF8A80' , 강력매도 : '#FF5252' };
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 showTab ( id , el ){
2026-05-20 22:09:32 +09:00
[ 'overview' , 'performance' , 'signals' , 'surge' , 'portfolio' , 'mystock' , 'recommend' , 'news' , 'supplydemand' , 'alerts' , 'formulas' , 'backtest' , 'usmarket' , 'macro' ]. forEach ( t =>{
2026-05-20 21:33:56 +09:00
document . getElementById ( t ). style . display = t === id ? 'block' : 'none' });
document . querySelectorAll ( '.tab' ). forEach ( t => t . classList . remove ( 'active' ));
if ( el ) el . classList . add ( 'active' );
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 ();
2026-05-20 22:09:32 +09:00
if ( id === 'formulas' ) renderFormulas ();
if ( id === 'backtest' ) renderBacktest ();
if ( id === 'usmarket' ) renderUsMarket ();
if ( id === 'macro' ) renderMacro ();
2026-05-20 21:33:56 +09:00
}
async function renderVolumeSurge (){
const el = document . getElementById ( 'surge' );
el . innerHTML = `<div style="font-size:12px;color:#546E7A;margin-bottom:14px">키움증권 실시간 거래량 급증 상위 50종목 (5분 캐시)</div>
<div style="text-align:center;color:#546E7A;padding:30px">불러오는 중...</div>` ;
try {
const d = await api ( '/volume-surge' );
const list = d ? . data || [];
if ( ! list . length ){ el . innerHTML = '<div class="empty">데이터가 없습니다 (장 마감 상태일 수 있음)</div>' ; return ;}
let h = `<div style="font-size:12px;color:#546E7A;margin-bottom:14px">키움증권 실시간 거래량 급증 상위 ${ list . length } 종목 ${ d . cached ? '<span style="color:#90A4AE">(캐시)</span>' : '<span style="color:#69F0AE">(실시간)</span>' } </div>` ;
h += `<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px">` ;
list . forEach (( r , i )=>{
const chgC = r . change_pct >= 0 ? '#00E676' : '#FF5252' ;
const surgeC = r . surge_rate >= 500 ? '#FFD740' : r . surge_rate >= 200 ? '#69F0AE' : '#90A4AE' ;
h += `<div data-stock=" ${ r . code } " data-stock-name=" ${ esc ( r . name ) } " style="cursor:pointer;background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06);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"> ${ esc ( r . name || r . code ) } </div>
<div style="font-size:10px;color:#546E7A;font-family:'JetBrains Mono',monospace"> ${ r . code } </div>
</div>
<div style="font-size:11px;color: ${ surgeC } ;font-weight:700">× ${ ( r . surge_rate || 0 ). toFixed ( 0 ) } %</div>
</div>
<div style="display:flex;justify-content:space-between;font-size:11px;color:#90A4AE">
<span> ${ fmt ( r . price ) } 원 <b style="color: ${ chgC } "> ${ r . change_pct >= 0 ? '+' : '' }${ ( r . change_pct || 0 ). toFixed ( 2 ) } %</b></span>
<span>거래량 ${ ( r . volume / 10000 ). toFixed ( 0 ) } 만</span>
</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 (){
const [ summary , ranking , recs , avoid , recent , alerts , signals , cands , kospi , tip ] = await Promise . all ([
api ( '/summary' ), api ( '/ranking' ), api ( '/recommendations' ), api ( '/avoid' ),
api ( '/recent?limit=40' ), api ( '/alerts' ), api ( '/signals?days=3&limit=60' ), api ( '/buy-candidates?limit=20' ),
api ( '/kospi?days=30' ), api ( '/daily-tip' )
]);
renderOverview ( summary , ranking , recent , kospi , tip );
renderSignals ( signals , cands );
renderRecs ( recs , avoid );
renderNews ( recent );
renderAlerts ( alerts );
}
function fmt ( n ){ return n ? Number ( n ). toLocaleString () : '0' }
function pct ( n ){ return n ? ( n > 0 ? '+' : '' ) + Number ( n ). toFixed ( 1 ) + '%' : '0%' }
const INP_STYLE = 'width:100%;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.12);border-radius:8px;padding:10px 14px;color:#E0E0E0;font-size:14px;outline:none;box-sizing:border-box' ;
// ── 인증 (JWT) ─────────────────────────────────────────────
let _authToken = localStorage . getItem ( 'auth_token' ) || '' ;
let _authUser = JSON . parse ( localStorage . getItem ( 'auth_user' ) || 'null' );
let _authMode = 'login' ; // 'login' | 'register'
function isLoggedIn (){ return !! _authToken }
function isAdmin (){ return _authUser ? . role === 'admin' }
function setAuth ( token , user ){ _authToken = token ; _authUser = user ; localStorage . setItem ( 'auth_token' , token ); localStorage . setItem ( 'auth_user' , JSON . stringify ( user )); renderAuthArea ()}
function clearAuth (){ _authToken = '' ; _authUser = null ; localStorage . removeItem ( 'auth_token' ); localStorage . removeItem ( 'auth_user' ); _portfolio = []; renderAuthArea ()}
function authHeaders (){ return _authToken ? { 'Authorization' : 'Bearer ' + _authToken } : {}}
async function authedJson ( path , opts = {}){
const headers = { 'Content-Type' : 'application/json' ,... authHeaders (),...( opts . headers || {})};
const r = await fetch ( API + path ,{... opts , headers });
if ( r . status === 401 ){ clearAuth (); showLoginPage (); throw new Error ( 'unauthorized' );}
if ( ! r . ok ){ const d = await r . json (). catch (()=>({})); throw new Error ( d . detail || ( 'HTTP ' + r . status ));}
return r . json ();
}
// 화면 게이트
function showDashboard (){ document . body . classList . remove ( 'auth-required' ); renderAuthArea ();}
function showLoginPage (){
document . body . classList . add ( 'auth-required' );
setAuthMode ( 'login' );
document . getElementById ( 'lg-info' ). style . display = 'none' ;
document . getElementById ( 'lg-error' ). style . display = 'none' ;
document . getElementById ( 'lg-password' ). value = '' ;
setTimeout (()=> document . getElementById ( 'lg-email' ) ? . focus (), 50 );
}
function renderAuthArea (){
const el = document . getElementById ( 'auth-area' );
if ( ! el ||! isLoggedIn ()) return ;
const adminBtn = isAdmin () ? `<button class="btn" onclick="showAdminModal()" style="font-size:11px;padding:4px 10px;background:rgba(255,193,7,0.1);border-color:rgba(255,193,7,0.3);color:#FFC107">👥 회원관리</button>` : '' ;
el . innerHTML = `<span style="color:#90A4AE"> ${ esc ( _authUser ? . email || '' ) } </span>
${ adminBtn }
<button class="btn" onclick="logoutUser()" style="font-size:11px;padding:4px 10px">로그아웃</button>` ;
}
function setAuthMode ( mode ){
_authMode = mode ;
const isReg = mode === 'register' ;
document . getElementById ( 'lg-tab-login' ). classList . toggle ( 'active' , ! isReg );
document . getElementById ( 'lg-tab-reg' ). classList . toggle ( 'active' , isReg );
document . getElementById ( 'lg-submit' ). textContent = isReg ? '회원가입' : '로그인' ;
document . getElementById ( 'lg-pw-label' ). textContent = isReg ? '비밀번호 (8자 이상)' : '비밀번호' ;
document . getElementById ( 'lg-password' ). setAttribute ( 'autocomplete' , isReg ? 'new-password' : 'current-password' );
document . getElementById ( 'lg-info' ). style . display = 'none' ;
document . getElementById ( 'lg-error' ). style . display = 'none' ;
}
async function submitAuth (){
const email = document . getElementById ( 'lg-email' ). value . trim ();
const password = document . getElementById ( 'lg-password' ). value ;
const errEl = document . getElementById ( 'lg-error' );
const infoEl = document . getElementById ( 'lg-info' );
const btn = document . getElementById ( 'lg-submit' );
errEl . style . display = 'none' ; infoEl . style . display = 'none' ;
if ( ! email ||! password ){ errEl . textContent = '이메일과 비밀번호를 입력하세요' ; errEl . style . display = 'block' ; return ;}
if ( _authMode === 'register' && password . length < 8 ){ errEl . textContent = '비밀번호는 8자 이상이어야 합니다' ; errEl . style . display = 'block' ; return ;}
btn . disabled = true ;
try {
const path = _authMode === 'register' ? '/auth/register' : '/auth/login' ;
const r = await fetch ( API + path ,{ method : 'POST' , headers : { 'Content-Type' : 'application/json' }, body : JSON . stringify ({ email , password })});
const d = await r . json ();
if ( ! r . ok ){ errEl . textContent = d . detail || '요청 실패' ; errEl . style . display = 'block' ; return ;}
if ( d . pending ){
infoEl . textContent = d . message || '관리자 승인 대기 중입니다' ;
infoEl . style . display = 'block' ;
setAuthMode ( 'login' );
document . getElementById ( 'lg-password' ). value = '' ;
return ;
}
setAuth ( d . access_token , d . user );
showDashboard ();
await loadPortfolio ();
loadAll ();
maybeOfferImport ();
} catch ( e ){ errEl . textContent = '네트워크 오류: ' + e . message ; errEl . style . display = 'block' ;}
finally { btn . disabled = false ;}
}
async function logoutUser (){
clearAuth ();
showLoginPage ();
}
// ── 관리자: 회원 관리 모달 ────────────────────────────────
async function showAdminModal (){
document . getElementById ( 'admin-modal' ). style . display = 'flex' ;
await loadAdminUsers ();
}
function hideAdminModal (){ document . getElementById ( 'admin-modal' ). style . display = 'none' ;}
async function loadAdminUsers (){
const el = document . getElementById ( 'admin-users-list' );
el . textContent = '불러오는 중...' ;
try {
const users = await authedJson ( '/admin/users' );
if ( ! users . length ){ el . innerHTML = '<div style="text-align:center;padding:40px;color:#546E7A">등록된 회원이 없습니다</div>' ; return ;}
let h = `<div style="display:grid;grid-template-columns:1fr;gap:8px">` ;
users . forEach ( u =>{
const status = u . is_approved ? ( u . role === 'admin' ? '<span style="color:#FFC107">관리자</span>' : '<span style="color:#69F0AE">승인됨</span>' ) : '<span style="color:#FFC107">대기중</span>' ;
const lastLogin = u . last_login_at ? new Date ( u . last_login_at . replace ( ' ' , 'T' )). toLocaleString ( 'ko-KR' ) : '없음' ;
const locked = u . locked_until ? ` <span style="color:#FF5252;font-size:10px">잠김</span>` : '' ;
h += `<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.08);border-radius:10px;padding:12px 14px;display:flex;align-items:center;gap:10px">
<div style="flex:1;min-width:0">
<div style="color:#E0E0E0;font-weight:600;font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"> ${ esc ( u . email ) } ${ locked } </div>
<div style="color:#78909C;font-size:11px;margin-top:2px"> ${ status } · 가입 ${ new Date ( u . created_at . replace ( ' ' , 'T' )). toLocaleDateString ( 'ko-KR' ) } · 최근 ${ lastLogin } </div>
</div>
${ ! u . is_approved ? `<button onclick="approveUser( ${ u . id } )" style="background:rgba(0,230,118,0.15);border:1px solid rgba(0,230,118,0.3);color:#00E676;border-radius:6px;padding:6px 12px;font-size:11px;cursor:pointer">승인</button>` : '' }
${ u . locked_until ? `<button onclick="unlockUser( ${ u . id } )" style="background:rgba(255,193,7,0.1);border:1px solid rgba(255,193,7,0.3);color:#FFC107;border-radius:6px;padding:6px 12px;font-size:11px;cursor:pointer">잠금해제</button>` : '' }
${ u . role !== 'admin' ? `<button onclick="deleteUser( ${ u . id } ,' ${ esc ( u . email ) } ')" style="background:rgba(255,82,82,0.08);border:1px solid rgba(255,82,82,0.2);color:#FF8A80;border-radius:6px;padding:6px 12px;font-size:11px;cursor:pointer">삭제</button>` : '' }
</div>` ;
});
h += `</div>` ;
el . innerHTML = h ;
} catch ( e ){ el . innerHTML = `<div style="color:#FF8A80">로드 실패: ${ esc ( e . message ) } </div>` ;}
}
async function approveUser ( id ){
try { await authedJson ( '/admin/users/' + id + '/approve' ,{ method : 'POST' }); await loadAdminUsers ();}
catch ( e ){ alert ( '승인 실패: ' + e . message );}
}
async function unlockUser ( id ){
try { await authedJson ( '/admin/users/' + id + '/unlock' ,{ method : 'POST' }); await loadAdminUsers ();}
catch ( e ){ alert ( '잠금해제 실패: ' + e . message );}
}
async function deleteUser ( id , email ){
if ( ! confirm ( ` ${ email } 계정을 삭제할까요? (포트폴리오도 함께 삭제됩니다)` )) return ;
try { await authedJson ( '/admin/users/' + id ,{ method : 'DELETE' }); await loadAdminUsers ();}
catch ( e ){ alert ( '삭제 실패: ' + e . message );}
}
// ── 종목 차트 모달 ────────────────────────────────────────
let _scCharts = { candle : null , volume : null , flow : null };
let _scState = { code : '' , name : '' , timeframe : 'day' };
// 클릭 위임: data-stock 속성이 있는 요소(또는 그 자식)를 클릭하면 차트 열림
document . addEventListener ( 'click' ,( e )=>{
const el = e . target . closest ( '[data-stock]' );
if ( ! el ) return ;
// 카드 내부 버튼/링크는 차트 열지 않음
if ( e . target . closest ( 'button,a,input,select,textarea' )) return ;
e . preventDefault ();
openStockChart ( el . dataset . stock , el . dataset . stockName || '' );
});
function hideStockChart (){
document . getElementById ( 'stock-chart-modal' ). style . display = 'none' ;
[ 'candle' , 'volume' , 'flow' ]. forEach ( k =>{ if ( _scCharts [ k ]){ _scCharts [ k ]. destroy (); _scCharts [ k ] = null ;} });
}
async function changeTimeframe ( tf ){
_scState . timeframe = tf ;
document . querySelectorAll ( '.tf-btn' ). forEach ( b =>{
const active = b . dataset . tf === tf ;
b . style . background = active ? 'rgba(0,230,118,0.15)' : 'rgba(255,255,255,0.04)' ;
b . style . borderColor = active ? 'rgba(0,230,118,0.3)' : 'rgba(255,255,255,0.08)' ;
b . style . color = active ? '#00E676' : '#90A4AE' ;
b . classList . toggle ( 'active' , active );
});
await drawStockChart ( null );
}
// 일봉 또는 분봉 데이터로 차트 3개 그리기
async function drawStockChart ( prefetchedOhlcv ){
const tf = _scState . timeframe ;
const code = _scState . code ;
if ( ! code ) return ;
[ 'candle' , 'volume' , 'flow' ]. forEach ( k =>{ if ( _scCharts [ k ]){ _scCharts [ k ]. destroy (); _scCharts [ k ] = null ;} });
const flowWrap = document . getElementById ( 'sc-flow-wrap' );
let rows = [];
let isMinute = tf !== 'day' ;
if ( isMinute ){
const d = await api ( '/minute/' + code + '?scope=' + tf ). catch (()=> null );
if ( ! d ? . data ? . length ){
flowWrap . style . display = 'none' ;
return ;
}
// 분봉 dt = "20260508153000" → ms
rows = [... d . data ]. reverse (). slice ( - 200 ). map ( r =>{
const s = String ( r . dt );
const iso = ` ${ s . slice ( 0 , 4 ) } - ${ s . slice ( 4 , 6 ) } - ${ s . slice ( 6 , 8 ) } T ${ s . slice ( 8 , 10 ) } : ${ s . slice ( 10 , 12 ) } : ${ s . slice ( 12 , 14 ) || '00' } ` ;
return { x : new Date ( iso ). valueOf (), o : r . open , h : r . high , l : r . low , c : r . close , v : r . volume , fnet : 0 , inet : 0 };
});
flowWrap . style . display = 'none' ; // 분봉엔 외국인 순매수 데이터 없음
} else {
const ohlcv = prefetchedOhlcv || await api ( '/ohlcv/' + code + '?days=90' ). catch (()=> null );
if ( ! ohlcv ? . data ? . length ){
flowWrap . style . display = 'none' ;
return ;
}
rows = [... ohlcv . data ]. reverse (). map ( d =>{
const ds = String ( d . dt ); const iso = ` ${ ds . slice ( 0 , 4 ) } - ${ ds . slice ( 4 , 6 ) } - ${ ds . slice ( 6 , 8 ) } ` ;
return { x : new Date ( iso ). valueOf (),
o : d . open || d . open_price || 0 , h : d . high || d . high_price || 0 , l : d . low || d . low_price || 0 , c : d . close || d . close_price || 0 ,
v : d . volume , fnet : d . foreign_net || 0 , inet : d . institution_net || 0 };
});
flowWrap . style . display = 'block' ;
}
const closes = rows . map ( r => r . c );
const ma = ( n )=> closes . map (( _ , i )=> i < n - 1 ? null : closes . slice ( i - n + 1 , i + 1 ). reduce (( s , v )=> s + v , 0 ) / n );
const ma5 = ma ( 5 ), ma20 = ma ( 20 ), ma60 = ma ( 60 );
const candleData = rows . map ( r =>({ x : r . x , o : r . o , h : r . h , l : r . l , c : r . c }));
const volColors = rows . map ( r => r . c >= r . o ? 'rgba(0,230,118,0.6)' : 'rgba(255,82,82,0.6)' );
const xUnit = isMinute ? ( tf === '1' || tf === '5' ? 'minute' : 'hour' ) : 'day' ;
const xFmt = isMinute ? { minute : 'HH:mm' , hour : 'M/d HH:mm' } : { day : 'M/d' };
const baseScales = {
x : { type : 'time' , time : { unit : xUnit , displayFormats : xFmt }, grid : { color : 'rgba(255,255,255,0.04)' }, ticks : { color : '#546E7A' , font : { size : 10 }, maxTicksLimit : 10 }},
y : { position : 'right' , grid : { color : 'rgba(255,255,255,0.04)' }, ticks : { color : '#546E7A' , font : { size : 10 }, callback : v => v . toLocaleString ()}}
};
_scCharts . candle = new Chart ( document . getElementById ( 'sc-candle' ),{
type : 'candlestick' ,
data : { datasets : [
{ label : '캔들' , data : candleData , color : { up : '#00E676' , down : '#FF5252' , unchanged : '#90A4AE' }, borderColor : { up : '#00E676' , down : '#FF5252' , unchanged : '#90A4AE' }},
{ type : 'line' , label : 'MA5' , data : rows . map (( r , i )=>({ x : r . x , y : ma5 [ i ]})), borderColor : '#FFD740' , borderWidth : 1.2 , pointRadius : 0 , fill : false , tension : 0.2 , spanGaps : true },
{ type : 'line' , label : 'MA20' , data : rows . map (( r , i )=>({ x : r . x , y : ma20 [ i ]})), borderColor : '#40C4FF' , borderWidth : 1.5 , pointRadius : 0 , fill : false , tension : 0.2 , spanGaps : true },
{ type : 'line' , label : 'MA60' , data : rows . map (( r , i )=>({ x : r . x , y : ma60 [ i ]})), borderColor : '#B388FF' , borderWidth : 1.5 , pointRadius : 0 , fill : false , tension : 0.2 , spanGaps : true },
]},
options : { responsive : true , maintainAspectRatio : false ,
plugins : { legend : { display : true , labels : { color : '#90A4AE' , font : { size : 10 }, boxWidth : 14 }}, tooltip : { mode : 'index' , intersect : false }},
scales : baseScales }
});
_scCharts . volume = new Chart ( document . getElementById ( 'sc-volume' ),{
type : 'bar' ,
data : { labels : rows . map ( r => r . x ), datasets : [{ label : '거래량' , data : rows . map ( r => r . v ), backgroundColor : volColors , borderWidth : 0 }]},
options : { responsive : true , maintainAspectRatio : false ,
plugins : { legend : { display : false }, tooltip : { callbacks : { label : ctx => '거래량 ' + Number ( ctx . parsed . y ). toLocaleString ()}}},
scales : {... baseScales , y : {... baseScales . y , ticks : {... baseScales . y . ticks , callback : v =>( v / 10000 ). toFixed ( 0 ) + '만' }}}}
});
if ( ! isMinute ){
_scCharts . flow = new Chart ( document . getElementById ( 'sc-flow' ),{
type : 'bar' ,
data : { labels : rows . map ( r => r . x ), datasets : [
{ label : '외국인' , data : rows . map ( r => r . fnet ), backgroundColor : 'rgba(64,196,255,0.7)' },
{ label : '기관' , data : rows . map ( r => r . inet ), backgroundColor : 'rgba(255,167,38,0.7)' },
]},
options : { responsive : true , maintainAspectRatio : false ,
plugins : { legend : { display : true , labels : { color : '#90A4AE' , font : { size : 10 }}}},
scales : {... baseScales , y : {... baseScales . y , ticks : {... baseScales . y . ticks , callback : v =>( v / 10000 ). toFixed ( 0 ) + '만' }}}}
});
}
}
async function loadOrderbook ( code ){
const el = document . getElementById ( 'sc-orderbook' );
el . innerHTML = '<div style="text-align:center;color:#546E7A;padding:20px">호가 불러오는 중...</div>' ;
try {
const d = await api ( '/orderbook/' + code );
const ask = ( d . ask || []). slice (). reverse (); // 매도 위에서부터 높은 가격
const bid = d . bid || [];
const askMax = Math . max (... ask . map ( x => x . qty ), 1 );
const bidMax = Math . max (... bid . map ( x => x . qty ), 1 );
const max = Math . max ( askMax , bidMax );
let h = `<div style="font-size:10px;color:#78909C;margin-bottom:6px;text-align:center">호가 (10단계)</div>` ;
ask . forEach ( a =>{
const w = ( a . qty / max * 100 ). toFixed ( 0 );
h += `<div style="display:flex;justify-content:space-between;padding:3px 6px;background:linear-gradient(to left,rgba(255,82,82,0.15) ${ w } %,transparent ${ w } %);border-radius:3px;margin-bottom:1px">
<span style="color:#FF8A80"> ${ fmt ( a . price ) } </span>
<span style="color:#90A4AE"> ${ fmt ( a . qty ) } </span></div>` ;
});
h += `<div style="height:1px;background:rgba(255,255,255,0.1);margin:4px 0"></div>` ;
bid . forEach ( b =>{
const w = ( b . qty / max * 100 ). toFixed ( 0 );
h += `<div style="display:flex;justify-content:space-between;padding:3px 6px;background:linear-gradient(to right,rgba(0,230,118,0.15) ${ w } %,transparent ${ w } %);border-radius:3px;margin-bottom:1px">
<span style="color:#69F0AE"> ${ fmt ( b . price ) } </span>
<span style="color:#90A4AE"> ${ fmt ( b . qty ) } </span></div>` ;
});
h += `<div style="margin-top:8px;padding-top:8px;border-top:1px solid rgba(255,255,255,0.06);font-size:10px;color:#78909C">
매도잔량 <span style="color:#FF8A80"> ${ fmt ( d . ask_total ) } </span><br>
매수잔량 <span style="color:#69F0AE"> ${ fmt ( d . bid_total ) } </span>
</div>` ;
el . innerHTML = h ;
} catch ( e ){
el . innerHTML = '<div style="text-align:center;color:#546E7A;padding:20px;font-size:11px">호가 데이터 없음</div>' ;
}
}
async function openStockChart ( code , name ){
if ( ! code ) return ;
_scState = { code , name : name || '' , timeframe : 'day' };
// 시간프레임 버튼 초기화
document . querySelectorAll ( '.tf-btn' ). forEach ( b =>{
const active = b . dataset . tf === 'day' ;
b . style . background = active ? 'rgba(0,230,118,0.15)' : 'rgba(255,255,255,0.04)' ;
b . style . borderColor = active ? 'rgba(0,230,118,0.3)' : 'rgba(255,255,255,0.08)' ;
b . style . color = active ? '#00E676' : '#90A4AE' ;
b . classList . toggle ( 'active' , active );
});
const modal = document . getElementById ( 'stock-chart-modal' );
modal . style . display = 'flex' ;
document . getElementById ( 'sc-title' ). textContent = name || code ;
document . getElementById ( 'sc-subtitle' ). textContent = code ;
document . getElementById ( 'sc-info' ). innerHTML = '<div style="grid-column:1/-1;text-align:center;color:#546E7A;padding:30px">데이터 불러오는 중...</div>' ;
document . getElementById ( 'sc-fundamentals' ). innerHTML = '' ;
loadOrderbook ( code ); // 호가 비동기로
const [ stock , ohlcvResp , fundi , nameResp ] = await Promise . all ([
api ( '/stock/' + code ). catch (()=> null ),
api ( '/ohlcv/' + code + '?days=90' ). catch (()=> null ),
api ( '/fundamentals/' + code ). catch (()=> null ),
name ? null : api ( '/name/' + code ). catch (()=> null ),
]);
// 종목명 보정
if ( nameResp ? . name ) document . getElementById ( 'sc-title' ). textContent = nameResp . name ;
// 정보 카드 (stock 응답: {price, technical, scores[]})
const price = stock ? . price || {};
const ta = stock ? . technical || {};
const sc0 = ( stock ? . scores && stock . scores [ 0 ]) || {};
const ch = parseFloat ( price . change_pct || 0 );
const chC = ch >= 0 ? '#00E676' : '#FF5252' ;
document . getElementById ( 'sc-info' ). innerHTML = `
<div class="price-box"><div class="label">현재가</div><div class="val"> ${ fmt ( price . price ) } </div></div>
<div class="price-box"><div class="label">등락</div><div class="val" style="color: ${ chC } "> ${ ch >= 0 ? '+' : '' }${ ch . toFixed ( 2 ) } %</div></div>
<div class="price-box"><div class="label">시가총액</div><div class="val"> ${ fmt ( price . market_cap ) } </div></div>
<div class="price-box"><div class="label">PER</div><div class="val"> ${ parseFloat ( price . per || 0 ). toFixed ( 2 ) } </div></div>
<div class="price-box"><div class="label">PBR</div><div class="val"> ${ parseFloat ( price . pbr || 0 ). toFixed ( 2 ) } </div></div>
<div class="price-box"><div class="label">기술점수</div><div class="val" style="color: ${ ( ta . tech_score || 0 ) >= 30 ? '#69F0AE' : ( ta . tech_score || 0 ) <=- 30 ? '#FF8A80' : '#E0E0E0' } "> ${ ( ta . tech_score || 0 ). toFixed ( 0 ) } </div></div>
<div class="price-box"><div class="label">AI점수</div><div class="val"> ${ sc0 . total_score != null ? Number ( sc0 . total_score ). toFixed ( 1 ) : '-' } </div></div>
<div class="price-box"><div class="label">추천</div><div class="val" style="font-size:12px;color: ${ RCOL [ sc0 . recommendation ] || '#90A4AE' } "> ${ sc0 . recommendation || ta . signal || '-' } </div></div>` ;
const ohlcv = ohlcvResp ;
// 캔들/거래량/외국인 차트
if ( ! ohlcv ? . data ? . length ){
document . getElementById ( 'sc-fundamentals' ). innerHTML = '<div style="text-align:center;color:#FF8A80;padding:20px">OHLCV 데이터 없음</div>' ;
return ;
}
await drawStockChart ( ohlcv );
// 펀더멘털 요약
if ( fundi ? . length ){
const f = fundi [ 0 ];
document . getElementById ( 'sc-fundamentals' ). innerHTML = `
<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06);border-radius:10px;padding:14px">
<div style="font-size:12px;font-weight:700;color:#FFD740;margin-bottom:8px">📊 재무 ( ${ f . bsns_year } 년 ${ f . reprt_name || '' } )</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px;font-size:11px">
<div>매출 <b style="color:#E0E0E0"> ${ fmt ( f . revenue ) } 억</b></div>
<div>영업이익 <b style="color:#E0E0E0"> ${ fmt ( f . operating_profit ) } 억</b></div>
<div>순이익 <b style="color:#E0E0E0"> ${ fmt ( f . net_income ) } 억</b></div>
<div>ROE <b style="color: ${ ( f . roe || 0 ) >= 10 ? '#69F0AE' : '#FF8A80' } "> ${ ( f . roe || 0 ). toFixed ( 1 ) } %</b></div>
<div>영업이익률 <b style="color:#E0E0E0"> ${ ( f . operating_margin || 0 ). toFixed ( 1 ) } %</b></div>
<div>부채비율 <b style="color: ${ ( f . debt_ratio || 0 ) <= 200 ? '#69F0AE' : '#FF8A80' } "> ${ ( f . debt_ratio || 0 ). toFixed ( 1 ) } %</b></div>
<div>매출성장 <b style="color:#E0E0E0"> ${ ( f . revenue_growth || 0 ). toFixed ( 1 ) } %</b></div>
<div>FCF비율 <b style="color:#E0E0E0"> ${ ( f . fcf_ratio || 0 ). toFixed ( 1 ) } %</b></div>
</div>
</div>` ;
}
}
// ── 포트폴리오 (서버 연동) ─────────────────────────────────
let _portfolio = []; // 서버 정규화: {id, code, name, buy_price, qty, memo, pnl, pnl_pct}
async function loadPortfolio (){
if ( ! isLoggedIn ()){ _portfolio = []; return _portfolio ;}
try {
const list = await authedJson ( '/portfolio' );
let priceMap = {};
const codes = list . map ( p => p . stock_code ). filter ( Boolean ). join ( ',' );
if ( codes ){
const prices = await api ( '/portfolio/prices?codes=' + codes );
if ( Array . isArray ( prices )) priceMap = Object . fromEntries ( prices . map ( p =>[ p . code , p ]));
}
_portfolio = list . map ( p =>{
const cur = ( priceMap [ p . stock_code ] || {}). price || 0 ;
const pnl = cur ? ( cur - p . buy_price ) * p . qty : 0 ;
const pnl_pct = cur ? ( cur - p . buy_price ) / p . buy_price * 100 : 0 ;
return { id : p . id , code : p . stock_code , name : p . stock_name , buy_price : p . buy_price , qty : p . qty , memo : p . memo || '' , pnl , pnl_pct };
});
} catch ( e ){ _portfolio = [];}
return _portfolio ;
}
function getPortfolio (){ return _portfolio }
async function maybeOfferImport (){
const legacy = JSON . parse ( localStorage . getItem ( 'portfolio' ) || '[]' );
if ( ! legacy . length ||! isLoggedIn ()) return ;
if ( ! confirm ( `이전에 브라우저에 저장된 포지션 ${ legacy . length } 건이 있습니다. 서버 계정으로 가져올까요?` )) return ;
const items = legacy . filter ( p => p . code && p . buy_price && p . qty ). map ( p =>({
stock_code : String ( p . code ), stock_name : p . name || '' , buy_price : Number ( p . buy_price ), qty : Number ( p . qty ), memo : ''
}));
if ( ! items . length ){ alert ( '가져올 유효한 항목이 없습니다' ); return ;}
try {
const r = await authedJson ( '/portfolio/import' ,{ method : 'POST' , body : JSON . stringify ( items )});
alert ( `가져오기 완료 — 추가 ${ r . inserted } 건 / 중복 스킵 ${ r . skipped } 건` );
localStorage . removeItem ( 'portfolio' );
await loadPortfolio ();
renderPortfolioList ();
} catch ( e ){ alert ( '가져오기 실패: ' + e . message );}
}
function renderMyStock (){
const el = document . getElementById ( 'mystock' );
if ( ! isLoggedIn ()){
el . innerHTML = `<div style="max-width:540px;margin:60px auto;text-align:center;padding:40px 24px;background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.08);border-radius:16px">
<div style="font-size:32px;margin-bottom:12px">🔒</div>
<h3 style="font-size:16px;color:#E0E0E0;margin-bottom:8px">로그인이 필요합니다</h3>
<div style="font-size:13px;color:#90A4AE;line-height:1.6;margin-bottom:20px">내 종목 분석 데이터는<br>로그인 계정에 안전하게 저장됩니다</div>
<button onclick="showLoginModal()" style="background:rgba(0,230,118,0.15);border:1px solid rgba(0,230,118,0.3);border-radius:10px;padding:10px 28px;color:#00E676;font-size:14px;font-weight:700;cursor:pointer">로그인 / 회원가입</button>
</div>` ;
return ;
}
el . innerHTML = `
<div style="max-width:760px;margin:0 auto">
<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.08);border-radius:16px;padding:24px;margin-bottom:20px">
<h3 style="font-size:14px;color:#90A4AE;margin-bottom:16px">📂 보유 종목 입력</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
<div>
<div style="font-size:11px;color:#78909C;margin-bottom:4px">종목코드 (6자리)</div>
<input id="ms-code" type="text" maxlength="6" placeholder="예: 329180"
style=" ${ INP_STYLE } ;font-family:'JetBrains Mono',monospace"
oninput="onCodeInput(this.value)">
</div>
<div>
<div style="font-size:11px;color:#78909C;margin-bottom:4px">종목명</div>
<input id="ms-name" type="text" placeholder="코드 입력 시 자동 조회"
style=" ${ INP_STYLE } ;color:#69F0AE" readonly>
</div>
<div>
<div style="font-size:11px;color:#78909C;margin-bottom:4px">매입단가 (원)</div>
<input id="ms-price" type="number" placeholder="예: 698000"
style=" ${ INP_STYLE } ;font-family:'JetBrains Mono',monospace">
</div>
<div>
<div style="font-size:11px;color:#78909C;margin-bottom:4px">보유수량 (주)</div>
<input id="ms-qty" type="number" placeholder="예: 17"
style=" ${ INP_STYLE } ;font-family:'JetBrains Mono',monospace">
</div>
</div>
<button onclick="analyzeMyStock()"
style="width:100%;background:rgba(0,230,118,0.12);border:1px solid rgba(0,230,118,0.25);
border-radius:10px;padding:12px;color:#00E676;font-size:14px;font-weight:700;cursor:pointer">
🔍 AI 포지션 분석 + 저장
</button>
</div>
<div id="ms-portfolio" style="margin-bottom:20px"></div>
<div id="ms-result"></div>
</div>` ;
renderPortfolioList ();
}
async function onCodeInput ( v ){
if ( v . length === 6 && /^\d{6}$/ . test ( v )){
const nameEl = document . getElementById ( 'ms-name' );
nameEl . value = '조회 중...' ;
const d = await api ( '/name/' + v );
nameEl . value = d && d . name ? d . name : '알 수 없음' ;
}
}
function renderPortfolioList (){
const list = getPortfolio ();
const el = document . getElementById ( 'ms-portfolio' );
if ( ! el ) return ;
if ( ! list . length ){ el . innerHTML = '' ; return ;}
let h = `<div style="background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.06);border-radius:14px;padding:16px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h3 style="font-size:14px;color:#90A4AE">💼 저장된 포지션 ( ${ list . length } 개)</h3>
<button onclick="clearPortfolio()" style="background:rgba(255,82,82,0.08);border:1px solid rgba(255,82,82,0.2);border-radius:6px;padding:4px 12px;color:#FF8A80;font-size:11px;cursor:pointer">전체 삭제</button>
</div>` ;
list . forEach (( p , i )=>{
const pnlC = p . pnl >= 0 ? '#00E676' : '#FF5252' ;
h += `<div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid rgba(255,255,255,0.04)">
<div style="flex:1">
<div style="font-size:14px;font-weight:700;color:#E0E0E0"> ${ esc ( p . name ) } <span style="font-size:11px;color:#546E7A;font-family:'JetBrains Mono',monospace"> ${ p . code } </span></div>
<div style="font-size:11px;color:#78909C;margin-top:2px">매입 ${ fmt ( p . buy_price ) } 원 × ${ p . qty } 주 | 평가손익 <span style="color: ${ pnlC } "> ${ p . pnl >= 0 ? '+' : '' }${ fmt ( p . pnl ) } 원 ( ${ p . pnl >= 0 ? '+' : '' }${ ( p . pnl_pct || 0 ). toFixed ( 2 ) } %)</span></div>
</div>
<button onclick="reanalyze( ${ i } )" style="background:rgba(0,230,118,0.08);border:1px solid rgba(0,230,118,0.2);border-radius:6px;padding:4px 10px;color:#00E676;font-size:11px;cursor:pointer;white-space:nowrap">재분석</button>
<button onclick="deletePortfolio( ${ p . id } )" style="background:rgba(255,82,82,0.08);border:1px solid rgba(255,82,82,0.2);border-radius:6px;padding:4px 10px;color:#FF8A80;font-size:11px;cursor:pointer">삭제</button>
</div>` ;
});
h += `</div>` ;
el . innerHTML = h ;
}
async function deletePortfolio ( id ){
if ( ! id ||! confirm ( '이 포지션을 삭제할까요?' )) return ;
try {
await authedJson ( '/portfolio/' + id ,{ method : 'DELETE' });
await loadPortfolio ();
renderPortfolioList ();
} catch ( e ){ alert ( '삭제 실패: ' + e . message );}
}
async function clearPortfolio (){
if ( ! confirm ( '저장된 포지션을 모두 삭제할까요?' )) return ;
const list = getPortfolio ();
try {
await Promise . all ( list . map ( p => authedJson ( '/portfolio/' + p . id ,{ method : 'DELETE' }). catch (()=>{})));
await loadPortfolio ();
renderPortfolioList ();
} catch ( e ){ alert ( '삭제 실패: ' + e . message );}
}
function reanalyze ( i ){
const p = getPortfolio ()[ i ];
if ( ! p ) return ;
document . getElementById ( 'ms-code' ). value = p . code ;
document . getElementById ( 'ms-name' ). value = p . name ;
document . getElementById ( 'ms-price' ). value = p . buy_price ;
document . getElementById ( 'ms-qty' ). value = p . qty ;
analyzeMyStock ( false );
}
async function analyzeMyStock ( saveResult = true ){
const code = document . getElementById ( 'ms-code' ). value . trim ();
const name = document . getElementById ( 'ms-name' ). value . trim ();
const buyPrice = parseInt ( document . getElementById ( 'ms-price' ). value );
const qty = parseInt ( document . getElementById ( 'ms-qty' ). value );
if ( ! code ||! buyPrice ||! qty ){ alert ( '종목코드, 매입단가, 수량을 모두 입력하세요' ); return ;}
document . getElementById ( 'ms-result' ). scrollIntoView ({ behavior : 'smooth' });
const el = document . getElementById ( 'ms-result' );
el . innerHTML = `<div style="text-align:center;padding:40px;color:#546E7A">분석 중... (10~30초 소요)</div>` ;
try {
const r = await fetch ( API + '/position?ai=true' ,{
method : 'POST' , headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ code , name , buy_price : buyPrice , qty })
});
const d = await r . json ();
if ( d . error ){ el . innerHTML = `<div class="empty"> ${ esc ( d . error ) } </div>` ; return ;}
// 포트폴리오 저장 (서버)
if ( saveResult && isLoggedIn ()){
try {
const list = getPortfolio ();
const existing = list . find ( p => p . code === code );
const body = { stock_code : code , stock_name : d . name || name || code , buy_price : buyPrice , qty , memo : '' };
if ( existing ){
await authedJson ( '/portfolio/' + existing . id ,{ method : 'PUT' , body : JSON . stringify ( body )});
} else {
await authedJson ( '/portfolio' ,{ method : 'POST' , body : JSON . stringify ( body )});
}
await loadPortfolio ();
renderPortfolioList ();
} catch ( e ){ console . error ( 'save failed' , e );}
}
const pos = d . position || {};
const ind = d . indicators || {};
const tg = d . targets || {};
const sig = d . signal || '관망' ;
const sc = sig === '매수' ? '#00E676' : sig === '매도' ? '#FF5252' : '#78909C' ;
const pnlC = pos . pnl >= 0 ? '#00E676' : '#FF5252' ;
el . innerHTML = `
<div style="background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.06);border-radius:16px;padding:20px;margin-bottom:12px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<div>
<div style="font-size:18px;font-weight:700;color:#E0E0E0"> ${ esc ( d . name || code ) } </div>
<div style="font-size:11px;color:#546E7A;font-family:'JetBrains Mono',monospace"> ${ code } </div>
</div>
<div style="text-align:right">
<div style="font-size:13px;color: ${ sc } ;font-weight:700"> ${ sig } ( ${ ( d . tech_score || 0 ). toFixed ( 0 ) } 점)</div>
</div>
</div>
<div class="price-row" style="margin-bottom:16px">
<div class="price-box"><div class="label">매입단가</div><div class="val" style="color:#78909C"> ${ fmt ( buyPrice ) } </div></div>
<div class="price-box"><div class="label">현재가</div><div class="val" style="color:#E0E0E0"> ${ fmt ( d . current_price ) } </div></div>
<div class="price-box"><div class="label">평가손익</div><div class="val" style="color: ${ pnlC } "> ${ pos . pnl >= 0 ? '+' : '' }${ fmt ( pos . pnl ) } </div></div>
<div class="price-box"><div class="label">수익률</div><div class="val" style="color: ${ pnlC } "> ${ pos . pnl_pct >= 0 ? '+' : '' }${ ( pos . pnl_pct || 0 ). toFixed ( 2 ) } %</div></div>
</div>
<div class="price-row" style="margin-bottom:12px">
<div class="price-box" style="border:1px solid #FF8A8030"><div class="label">손절가</div><div class="val" style="color:#FF8A80"> ${ fmt ( pos . stop_loss ) } </div><div style="font-size:9px;color:#607D8B">손실 ${ fmt ( pos . stop_pnl ) } </div></div>
<div class="price-box" style="border:1px solid #69F0AE30"><div class="label">1차 목표</div><div class="val" style="color:#69F0AE"> ${ fmt ( pos . t1 ) } </div><div style="font-size:9px;color:#607D8B">수익 + ${ fmt ( pos . t1_pnl ) } </div></div>
<div class="price-box" style="border:1px solid #00E67630"><div class="label">2차 목표</div><div class="val" style="color:#00E676"> ${ fmt ( pos . t2 ) } </div><div style="font-size:9px;color:#607D8B">수익 + ${ fmt ( pos . t2_pnl ) } </div></div>
<div class="price-box" style="border:1px solid #00BFA530"><div class="label">3차 목표</div><div class="val" style="color:#00BFA5"> ${ fmt ( pos . t3 ) } </div><div style="font-size:9px;color:#607D8B">수익 + ${ fmt ( pos . t3_pnl ) } </div></div>
</div>
<div style="background:rgba(255,255,255,0.03);border-radius:10px;padding:12px;margin-bottom:12px">
<div style="font-size:12px;font-weight:700;color:#90A4AE;margin-bottom:8px">기술적 지표</div>
<div style="display:flex;flex-wrap:wrap;gap:12px;font-size:11px;color:#78909C">
<span>MA5 <b style="color: ${ ind . price > ind . ma5 ? '#69F0AE' : '#FF8A80' } "> ${ fmt ( ind . ma5 ) } </b></span>
<span>MA20 <b style="color: ${ ind . price > ind . ma20 ? '#69F0AE' : '#FF8A80' } "> ${ fmt ( ind . ma20 ) } </b></span>
<span>MA60 <b style="color: ${ ind . price > ind . ma60 ? '#69F0AE' : '#FF8A80' } "> ${ fmt ( ind . ma60 ) } </b></span>
<span>RSI <b style="color: ${ ( ind . rsi || 50 ) < 40 ? '#69F0AE' : ( ind . rsi || 50 ) > 70 ? '#FF8A80' : '#E0E0E0' } "> ${ ( ind . rsi || 0 ). toFixed ( 1 ) } </b></span>
<span>MACD <b style="color: ${ ( ind . macd_hist || 0 ) > 0 ? '#69F0AE' : '#FF8A80' } "> ${ ( ind . macd_hist || 0 ) > 0 ? '골든' : '데드' } </b></span>
<span>볼밴%B <b style="color:#FFD740"> ${ (( ind . pct_b || 0.5 ) * 100 ). toFixed ( 0 ) } %</b></span>
<span>거래량비 <b style="color:#40C4FF"> ${ ( ind . vol_ratio || 1 ). toFixed ( 1 ) } x</b></span>
</div>
<div class="ta-row" style="margin-top:8px"> ${ ( d . signals || []). map ( s => `<span class="ta-badge"> ${ esc ( s ) } </span>` ). join ( '' ) } </div>
</div>
${ ( pos . avg_down || []). length ? `
<div style="background:rgba(255,255,255,0.03);border-radius:10px;padding:12px;margin-bottom:12px">
<div style="font-size:12px;font-weight:700;color:#90A4AE;margin-bottom:8px">분할매수 시나리오</div>
${ pos . avg_down . map ( ad => `
<div style="display:flex;justify-content:space-between;font-size:11px;color:#78909C;padding:4px 0;border-bottom:1px solid rgba(255,255,255,0.04)">
<span> ${ fmt ( ad . price ) } 원에 ${ ad . add_qty } 주 추가 ( ${ fmt ( ad . add_cost ) } 원)</span>
<span>→ 평균단가 <b style="color:#FFD740"> ${ fmt ( ad . new_avg ) } 원</b></span>
</div>` ). join ( '' ) }
</div>` : '' }
<div style="background:rgba(255,165,0,0.05);border:1px solid rgba(255,165,0,0.15);border-radius:10px;padding:14px;margin-bottom:12px">
<div style="font-size:12px;font-weight:700;color:#FFD740;margin-bottom:6px">🤖 AI 판단</div>
<div style="font-size:12px;color:#CFD8DC;line-height:1.7"> ${ esc ( d . ai_opinion || 'vLLM 서버 연결 후 활성화됩니다.' ) } </div>
</div>
<div style="text-align:center;padding:10px;background:rgba( ${ pos . action === '손절 고려' ? '255,82,82' : '0,230,118' } ,0.08);border-radius:8px">
<span style="font-size:14px;font-weight:700;color: ${ pos . action === '손절 고려' ? '#FF5252' : '#00E676' } ">
권장 액션: ${ pos . action || '홀드' }
</span>
</div>
</div>` ;
} catch ( e ){ el . innerHTML = `<div class="empty">분석 오류: ${ esc ( e . message ) } </div>` ;}
}
async function renderPerformance (){
const el = document . getElementById ( 'performance' );
el . innerHTML = '<div class="empty">성과 데이터 불러오는 중...</div>' ;
const [ perf , sector ] = await Promise . all ([ api ( '/performance' ), api ( '/sector-ranking' )]);
if ( ! perf ){ el . innerHTML = '<div class="empty">데이터 없음</div>' ; return ;}
const s = perf . summary || {};
const winRate7 = s . measured_7d > 0 ? Math . round ( s . wins_7d / s . measured_7d * 100 ) : null ;
const winRate30 = s . measured_30d > 0 ? Math . round ( s . wins_30d / s . measured_30d * 100 ) : null ;
const ret7C = ( s . avg_return_7d || 0 ) >= 0 ? '#00E676' : '#FF5252' ;
const ret30C = ( s . avg_return_30d || 0 ) >= 0 ? '#00E676' : '#FF5252' ;
let h = `<div style="background:rgba(0,230,118,0.05);border:1px solid rgba(0,230,118,0.15);border-radius:12px;padding:14px 18px;margin-bottom:18px">
<div style="font-size:13px;font-weight:700;color:#00E676;margin-bottom:6px">🎯 추천 성과 추적</div>
<div style="font-size:12px;color:#90A4AE;line-height:1.6">
과거 AI가 추천한 종목들이 <b style="color:#E0E0E0">7일/30일 후 실제로 어떤 수익률</b>을 냈는지 추적합니다.<br>
평균 수익률·승률을 통해 AI 추천의 신뢰도를 검증하는 지표입니다. 종목명을 클릭하면 차트 모달이 열립니다.
</div>
</div>
<div class="cards">
<div class="card"><div class="label">📊 7일 평균수익률</div>
<div class="val" style="font-size:22px;color: ${ ret7C } "> ${ s . avg_return_7d != null ? ( s . avg_return_7d >= 0 ? '+' : '' ) + s . avg_return_7d + '%' : '데이터없음' } </div>
<div class="sub"> ${ winRate7 != null ? '승률 ' + winRate7 + '% (' + s . measured_7d + '건)' : '측정 전' } </div></div>
<div class="card"><div class="label">📈 30일 평균수익률</div>
<div class="val" style="font-size:22px;color: ${ ret30C } "> ${ s . avg_return_30d != null ? ( s . avg_return_30d >= 0 ? '+' : '' ) + s . avg_return_30d + '%' : '데이터없음' } </div>
<div class="sub"> ${ winRate30 != null ? '승률 ' + winRate30 + '% (' + s . measured_30d + '건)' : '측정 전' } </div></div>
<div class="card"><div class="label">🔥 강력매수 7일</div>
<div class="val" style="font-size:22px;color: ${ ( s . strong_buy_avg_7d || 0 ) >= 0 ? '#00E676' : '#FF5252' } "> ${ s . strong_buy_avg_7d != null ? ( s . strong_buy_avg_7d >= 0 ? '+' : '' ) + s . strong_buy_avg_7d + '%' : '데이터없음' } </div>
<div class="sub">강력매수 단독 성과</div></div>
<div class="card"><div class="label">📋 총 추천 건수</div>
<div class="val"> ${ s . total || 0 } </div><div class="sub">최근 90일</div></div>
</div>` ;
// 섹터별 상대 강도
if ( sector && sector . length ){
h += `<div class="panel" style="max-height:none;margin-bottom:16px">
<h3 style="margin-bottom:16px">🏭 섹터별 AI 점수 순위</h3>
<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">섹터</th>
<th style="text-align:right;padding:8px 6px">종목수</th>
<th style="text-align:right;padding:8px 6px">평균점수</th>
<th style="text-align:right;padding:8px 6px">뉴스</th>
<th style="text-align:right;padding:8px 6px">기술</th>
<th style="text-align:right;padding:8px 6px">매수종목</th>
<th style="text-align:right;padding:8px 6px">매도종목</th>
</tr></thead><tbody>` ;
sector . forEach (( sec , i )=>{
const sc = sec . avg_score >= 30 ? '#00E676' : sec . avg_score <=- 30 ? '#FF5252' : '#78909C' ;
h += `<tr style="border-bottom:1px solid rgba(255,255,255,0.04); ${ i % 2 ? 'background:rgba(255,255,255,0.01)' : '' } ">
<td style="padding:9px 6px;font-weight:600;color:#CFD8DC"> ${ esc ( sec . sector ) } </td>
<td style="text-align:right;padding:9px 6px;color:#78909C"> ${ sec . stock_count } </td>
<td style="text-align:right;padding:9px 6px;color: ${ sc } ;font-weight:700"> ${ sec . avg_score } </td>
<td style="text-align:right;padding:9px 6px;color:#69F0AE"> ${ sec . avg_news } </td>
<td style="text-align:right;padding:9px 6px;color:#CE93D8"> ${ sec . avg_tech } </td>
<td style="text-align:right;padding:9px 6px;color:#00E676"> ${ sec . buy_count } </td>
<td style="text-align:right;padding:9px 6px;color:#FF8A80"> ${ sec . sell_count } </td>
</tr>` ;
});
h += `</tbody></table></div></div>` ;
}
// 최근 성과 이력
const recent = perf . recent || [];
if ( recent . length ){
h += `<div class="panel" style="max-height:none">
<h3 style="margin-bottom:16px">📜 추천 성과 이력</h3>
<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">종목</th>
<th style="text-align:center;padding:8px 6px">추천등급</th>
<th style="text-align:right;padding:8px 6px">진입가</th>
<th style="text-align:right;padding:8px 6px">7일수익률</th>
<th style="text-align:right;padding:8px 6px">30일수익률</th>
<th style="text-align:right;padding:8px 6px">추천일</th>
</tr></thead><tbody>` ;
recent . forEach (( r , i )=>{
const rc = r . recommendation === '강력매수' ? '#00E676' : r . recommendation === '매수관심' ? '#69F0AE' : r . recommendation === '매도관심' ? '#FF8A80' : '#FF5252' ;
const r7c = ( r . return_7d || 0 ) >= 0 ? '#00E676' : '#FF5252' ;
const r30c = ( r . return_30d || 0 ) >= 0 ? '#00E676' : '#FF5252' ;
h += `<tr data-stock=" ${ r . stock_code } " data-stock-name=" ${ esc ( r . 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="font-weight:600;color:#CFD8DC"> ${ esc ( r . stock_name ) } </div>
<div style="font-size:10px;color:#546E7A"> ${ r . stock_code } </div></td>
<td style="text-align:center;padding:9px 6px"><span style="color: ${ rc } ;font-weight:700;font-size:11px"> ${ esc ( r . recommendation ) } </span></td>
<td style="text-align:right;padding:9px 6px;color:#78909C;font-family:'JetBrains Mono',monospace"> ${ fmt ( r . entry_price ) } </td>
<td style="text-align:right;padding:9px 6px;color: ${ r7c } ;font-weight:700">
${ r . return_7d != null ? ( r . return_7d >= 0 ? '+' : '' ) + r . return_7d . toFixed ( 2 ) + '%' : '-' } </td>
<td style="text-align:right;padding:9px 6px;color: ${ r30c } ;font-weight:700">
${ r . return_30d != null ? ( r . return_30d >= 0 ? '+' : '' ) + r . return_30d . toFixed ( 2 ) + '%' : '-' } </td>
<td style="text-align:right;padding:9px 6px;color:#546E7A;font-size:11px"> ${ r . rec_date } </td>
</tr>` ;
});
h += `</tbody></table></div></div>` ;
} else {
h += `<div class="panel"><div class="empty">오늘부터 추천 성과가 기록됩니다. 7일 후 수익률이 표시됩니다.</div></div>` ;
}
el . innerHTML = h ;
}
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>` ;
return ;
}
el . innerHTML = '<div class="empty">현재가 · AI점수 불러오는 중...</div>' ;
const codes = list . map ( p => p . code ). join ( ',' );
const priceData = await api ( '/portfolio/prices?codes=' + codes ) || [];
const priceMap = {};
priceData . forEach ( d => priceMap [ d . code ] = d );
let totalCost = 0 , totalValue = 0 ;
const holdings = list . map ( p =>{
const pd = priceMap [ p . code ] || {};
const cur = pd . price || 0 ;
const cost = p . buy_price * p . qty ;
const val = cur > 0 ? cur * p . qty : 0 ;
totalCost += cost ;
if ( cur > 0 ) totalValue += val ;
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 };
});
const totalPnl = totalValue - totalCost ;
const totalPnlPct = totalCost > 0 ? totalPnl / totalCost * 100 : 0 ;
holdings . forEach ( h =>{ h . weight = totalValue > 0 && h . val > 0 ? h . val / totalValue * 100 : 0 ;});
holdings . sort (( a , b )=> b . val - a . val );
const tC = totalPnl >= 0 ? '#00E676' : '#FF5252' ;
let h = `<div class="cards">
<div class="card"><div class="label">💰 총 매입금액</div><div class="val" style="font-size:22px"> ${ fmt ( totalCost ) } </div><div class="sub">원</div></div>
<div class="card"><div class="label">📊 총 평가금액</div><div class="val" style="font-size:22px;color: ${ tC } "> ${ totalValue > 0 ? fmt ( totalValue ) : '-' } </div><div class="sub">원</div></div>
<div class="card"><div class="label">💹 평가손익</div>
<div class="val" style="font-size:22px;color: ${ tC } "> ${ totalValue > 0 ? ( totalPnl >= 0 ? '+' : '' ) + fmt ( totalPnl ) : '-' } </div>
<div class="sub" style="color: ${ tC } "> ${ totalValue > 0 ? ( totalPnlPct >= 0 ? '+' : '' ) + totalPnlPct . toFixed ( 2 ) + '%' : '-' } </div></div>
<div class="card"><div class="label">📂 보유 종목</div><div class="val"> ${ holdings . length } </div><div class="sub">개 종목</div></div>
</div>
<div class="panel" style="max-height:none;margin-bottom:16px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h3>💼 보유 종목 현황</h3>
<button onclick="renderPortfolio()" style="background:rgba(0,230,118,0.08);border:1px solid rgba(0,230,118,0.2);border-radius:6px;padding:4px 12px;color:#00E676;font-size:11px;cursor:pointer">↻ 새로고침</button>
</div>
<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:right;padding:8px 6px;font-weight:600">매입가</th>
<th style="text-align:right;padding:8px 6px;font-weight:600">현재가</th>
<th style="text-align:right;padding:8px 6px;font-weight:600">수량</th>
<th style="text-align:right;padding:8px 6px;font-weight:600">평가손익</th>
<th style="text-align:right;padding:8px 6px;font-weight:600">수익률</th>
<th style="text-align:right;padding:8px 6px;font-weight:600">비중</th>
<th style="text-align:center;padding:8px 6px;font-weight:600">AI점수</th>
<th style="text-align:center;padding:8px 6px;font-weight:600">신호</th>
</tr></thead>
<tbody>` ;
holdings . forEach (( s , i )=>{
const pC = s . pnl >= 0 ? '#00E676' : '#FF5252' ;
const sigC = s . signal === '매수' ? '#00E676' : s . signal === '매도' ? '#FF5252' : '#78909C' ;
const aiC = s . aiScore >= 30 ? '#00E676' : s . aiScore <=- 30 ? '#FF5252' : '#78909C' ;
const chgC = s . changePct >= 0 ? '#00E676' : '#FF5252' ;
h += `<tr data-stock=" ${ s . code } " data-stock-name=" ${ esc ( s . 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:10px 6px">
<div style="font-weight:600;color:#CFD8DC"> ${ esc ( s . name ) } </div>
<div style="font-size:10px;color:#546E7A;font-family:'JetBrains Mono',monospace"> ${ s . code } </div>
</td>
<td style="text-align:right;padding:10px 6px;color:#78909C;font-family:'JetBrains Mono',monospace"> ${ fmt ( s . buy_price ) } </td>
<td style="text-align:right;padding:10px 6px;font-family:'JetBrains Mono',monospace">
<div style="color:#E0E0E0"> ${ s . cur > 0 ? fmt ( s . cur ) : '-' } </div>
${ s . cur > 0 ? `<div style="font-size:10px;color: ${ chgC } "> ${ s . changePct >= 0 ? '+' : '' }${ s . changePct . toFixed ( 2 ) } %</div>` : '' }
</td>
<td style="text-align:right;padding:10px 6px;color:#90A4AE"> ${ s . qty . toLocaleString () } </td>
<td style="text-align:right;padding:10px 6px;color: ${ pC } ;font-family:'JetBrains Mono',monospace;font-weight:600">
${ s . cur > 0 ? ( s . pnl >= 0 ? '+' : '' ) + fmt ( s . pnl ) : '-' }
</td>
<td style="text-align:right;padding:10px 6px;color: ${ pC } ;font-weight:700">
${ s . cur > 0 ? ( s . pnlPct >= 0 ? '+' : '' ) + s . pnlPct . toFixed ( 2 ) + '%' : '-' }
</td>
<td style="text-align:right;padding:10px 6px">
<div style="display:flex;align-items:center;justify-content:flex-end;gap:6px">
<div style="width:40px;height:4px;background:rgba(255,255,255,0.08);border-radius:2px;overflow:hidden">
<div style="height:100%;width: ${ Math . min ( 100 , s . weight ). toFixed ( 0 ) } %;background:#40C4FF;border-radius:2px"></div>
</div>
<span style="color:#40C4FF;font-size:11px"> ${ s . weight . toFixed ( 1 ) } %</span>
</div>
</td>
<td style="text-align:center;padding:10px 6px">
${ s . aiScore != null ? `<span style="color: ${ aiC } ;font-weight:700;font-family:'JetBrains Mono',monospace"> ${ s . aiScore . toFixed ( 0 ) } </span>
<div style="font-size:9px;color: ${ aiC } "> ${ s . recommendation || '' } </div>` : '<span style="color:#455A64">-</span>' }
</td>
<td style="text-align:center;padding:10px 6px">
<span style="color: ${ sigC } ;font-weight:700;font-size:11px"> ${ esc ( s . signal ) } </span>
</td>
</tr>` ;
});
h += `</tbody></table></div></div>` ;
el . innerHTML = h ;
}
function renderSignals ( signals , cands ){
let h = `<div style="margin-bottom:20px">
<div style="font-size:12px;color:#546E7A;margin-bottom:16px">
MA/RSI/MACD/볼린저밴드 기반 기술적 분석 + AI 뉴스 감성 종합 추천
</div>` ;
// 매수 후보 (통합)
h += `<h3 style="font-size:14px;font-weight:700;color:#69F0AE;margin-bottom:12px">🎯 통합 매수 후보 (기술적+펀더멘탈)</h3>` ;
if ( cands && cands . length ){
h += `<div class="candidates-grid">` ;
cands . forEach ( c =>{
const tg = c . targets || {};
const sigs = ( c . signals || []). slice ( 0 , 4 );
const ts = c . tech_score || 0 ;
const fs = c . fundamental_score || 0 ;
const combined = (( ts + fs ) / 2 ). toFixed ( 1 );
h += `<div class="cand-card" data-stock=" ${ c . stock_code } " data-stock-name=" ${ esc ( c . stock_name || c . stock_code ) } " style="cursor:pointer">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:12px">
<div>
<div style="font-size:16px;font-weight:700;color:#E0E0E0"> ${ esc ( c . stock_name || c . stock_code ) } </div>
<div style="font-size:11px;color:#546E7A;font-family:'JetBrains Mono',monospace"> ${ c . stock_code } · 현재가 <b style="color:#E0E0E0"> ${ fmt ( c . price ) } 원</b></div>
</div>
<div style="text-align:right">
<div style="font-size:12px;font-weight:700;color:#00E676">종합 ${ combined } </div>
<div style="font-size:10px;color:#69F0AE">기술 ${ ts . toFixed ( 0 ) } / 펀더 ${ fs . toFixed ( 0 ) } </div>
</div>
</div>
<div class="price-row">
<div class="price-box"><div class="label">진입 추천가</div><div class="val" style="color:#40C4FF"> ${ fmt ( tg . entry_price ) } </div></div>
<div class="price-box"><div class="label">목표1 ( ${ pct ( tg . t1_pct ) } )</div><div class="val" style="color:#69F0AE"> ${ fmt ( tg . t1 ) } </div></div>
<div class="price-box"><div class="label">목표2 ( ${ pct ( tg . t2_pct ) } )</div><div class="val" style="color:#00E676"> ${ fmt ( tg . t2 ) } </div></div>
<div class="price-box"><div class="label">손절가 ( ${ pct ( tg . stop_pct ) } )</div><div class="val" style="color:#FF8A80"> ${ fmt ( tg . stop_loss ) } </div></div>
</div>
<div style="display:flex;gap:12px;font-size:11px;color:#78909C;margin-top:10px;flex-wrap:wrap">
<span>RSI <b style="color: ${ ( c . rsi || 50 ) < 40 ? '#69F0AE' : ( c . rsi || 50 ) > 70 ? '#FF8A80' : '#E0E0E0' } "> ${ ( c . rsi || 0 ). toFixed ( 0 ) } </b></span>
<span>MACD히스 <b style="color: ${ ( c . macd_hist || 0 ) > 0 ? '#69F0AE' : '#FF8A80' } "> ${ ( c . macd_hist || 0 ). toFixed ( 3 ) } </b></span>
<span>볼밴%B <b style="color:#FFD740"> ${ (( c . pct_b || 0.5 ) * 100 ). toFixed ( 0 ) } %</b></span>
<span>거래량비 <b style="color:#40C4FF"> ${ ( c . vol_ratio || 1 ). toFixed ( 1 ) } x</b></span>
<span>리스크비 <b style="color:#E0E0E0"> ${ tg . risk_reward || 0 } :1</b></span>
</div>
<div class="ta-row"> ${ sigs . map ( s =>{
const bull = s . includes ( '상승' ) || s . includes ( '골든' ) || s . includes ( '과매도' ) || s . includes ( '바닥' ) || s . includes ( '정배열' ) || s . includes ( '위' ) || s . includes ( '반등' ) || s . includes ( '매수' );
const bear = s . includes ( '하락' ) || s . includes ( '데드' ) || s . includes ( '과매수' ) || s . includes ( '역배열' ) || s . includes ( '아래' ) || s . includes ( '매도' );
return `<span class="ta-badge ${ bull ? 'bull' : bear ? 'bear' : '' } "> ${ esc ( s ) } </span>` ;
} ).join('')}</div>
</div>` ;
});
h += `</div>` ;
} else {
h += `<div class="empty">기술적 분석 데이터 수집 중... (첫 실행 후 30분 이내 생성됩니다)</div>` ;
}
// 매매 시그널 (KIS 기반)
h += `<h3 style="font-size:14px;font-weight:700;color:#90A4AE;margin:24px 0 12px">📈 최근 매매 시그널 (3일)</h3>` ;
const buySigs = ( signals || []). filter ( s => s . signal_type === '매수' ). slice ( 0 , 10 );
const sellSigs = ( signals || []). filter ( s => s . signal_type === '매도' ). slice ( 0 , 5 );
if ( buySigs . length || sellSigs . length ){
h += `<div class="signal-grid">` ;
[... buySigs ,... sellSigs ]. forEach ( s =>{
const isBuy = s . signal_type === '매수' ;
const sc = isBuy ? '#00E676' : '#FF5252' ;
const tg = s . targets || {};
const taSigs = ( s . ta_signals || []). slice ( 0 , 3 );
h += `<div class="signal-card" data-stock=" ${ s . stock_code } " data-stock-name=" ${ esc ( s . stock_name || '' ) } " style="border:1px solid ${ sc } 20;cursor:pointer">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<div>
<span style="font-size:15px;font-weight:700;color: ${ sc } "> ${ s . signal_type } </span>
<span style="font-size:14px;color:#E0E0E0;margin-left:8px"> ${ esc ( s . stock_name ) } </span>
<span style="font-size:11px;color:#546E7A;font-family:'JetBrains Mono',monospace;margin-left:6px"> ${ s . stock_code } </span>
</div>
<span style="font-size:12px;color:#78909C">신뢰도 <b style="color:#FFD740"> ${ ( s . confidence || 0 ). toFixed ( 1 ) } %</b></span>
</div>
<div class="price-row">
<div class="price-box"><div class="label">현재가</div><div class="val" style="color:#E0E0E0"> ${ fmt ( s . current_price ) } </div></div>
<div class="price-box"><div class="label"> ${ isBuy ? '목표가' : '목표가' } </div><div class="val" style="color: ${ sc } "> ${ fmt ( s . target_price ) } </div></div>
<div class="price-box"><div class="label">손절가</div><div class="val" style="color:#FF8A80"> ${ fmt ( s . stop_loss ) } </div></div>
<div class="price-box"><div class="label">예상수익</div><div class="val" style="color:#FFD740"> ${ pct ( s . expected_return_pct ) } </div></div>
</div>
${ tg . t1 ? `<div style="font-size:11px;color:#78909C;margin-top:8px">
T1 <b style="color:#69F0AE"> ${ fmt ( tg . t1 ) } </b>( ${ pct ( tg . t1_pct ) } )
T2 <b style="color:#00E676"> ${ fmt ( tg . t2 ) } </b>( ${ pct ( tg . t2_pct ) } )
T3 <b style="color:#00BFA5"> ${ fmt ( tg . t3 ) } </b>( ${ pct ( tg . t3_pct ) } )
</div>` : '' }
${ taSigs . length ? `<div class="ta-row"> ${ taSigs . map ( sg => `<span class="ta-badge"> ${ esc ( sg ) } </span>` ). join ( '' ) } </div>` : '' }
${ s . reason ? `<div style="font-size:11px;color:#607D8B;margin-top:8px"> ${ esc ( s . reason . substring ( 0 , 120 )) } </div>` : '' }
</div>` ;
});
h += `</div>` ;
} else {
h += `<div class="empty">최근 3일간 시그널이 없습니다. (장 중 5분마다 생성됩니다)</div>` ;
}
h += `</div>` ;
document . getElementById ( 'signals' ). innerHTML = h ;
}
function getMarketTip ( close , changePct ){
if ( ! close ) return null ;
if ( changePct >= 1.5 ) return { icon : '🚀' , label : '강세장' , color : '#00E676' , text : '코스피가 강하게 상승 중입니다. 추세 추종 전략이 유효한 구간이지만, 과매수 지표(RSI 70↑)가 나타나면 신규 매수보다 기존 보유 종목 관리에 집중하세요.' };
if ( changePct >= 0.5 ) return { icon : '📈' , label : '완만한 상승' , color : '#69F0AE' , text : '시장이 우호적입니다. 실적 개선 종목과 외국인 순매수 종목을 중심으로 관심 가져볼 만한 구간입니다.' };
if ( changePct <= - 1.5 ) return { icon : '⚠️' , label : '급락 주의' , color : '#FF5252' , text : '코스피가 급락 중입니다. 신규 매수보다 손절 라인 점검이 우선입니다. 시장 전반이 흔들릴 때는 현금 비중을 높이고 낙폭 과대 우량주 저점 매수 기회를 기다리세요.' };
if ( changePct <= - 0.5 ) return { icon : '📉' , label : '약세 흐름' , color : '#FF7043' , text : '시장이 약세입니다. 실적 대비 낙폭이 과도한 종목을 발굴할 기회이기도 합니다. PBR 1 미만, ROE 10% 이상 종목을 관심 목록에 추가해 보세요.' };
if ( close >= 2700 ) return { icon : '💹' , label : '박스권 상단' , color : '#FFC107' , text : '코스피 2700 이상은 역사적 고평가 구간 진입 신호일 수 있습니다. 신규 진입보다 보유 종목 비중 조절과 이익 실현을 고려하세요.' };
if ( close <= 2400 ) return { icon : '💎' , label : '저점 구간' , color : '#40C4FF' , text : '코스피 2400 이하는 역사적으로 장기 투자자에게 좋은 진입 구간이었습니다. 우량 가치주를 분할 매수하는 전략이 유효합니다.' };
return { icon : '⚖️' , label : '중립 구간' , color : '#90A4AE' , text : '코스피가 횡보 중입니다. 시장 전체보다 개별 종목의 실적과 모멘텀에 집중하는 보텀업 전략이 효과적인 구간입니다.' };
}
function renderOverview ( s , ranking , recent , kospi , tip ){
if ( ! s ) return ;
let h = '' ;
// KOSPI 차트 + 시장 팁
if ( kospi && kospi . data && kospi . data . length ){
const cur = kospi . current || {};
const chg = parseFloat ( cur . change_pct || 0 );
const chgColor = chg >= 0 ? '#00E676' : '#FF5252' ;
const chgSign = chg >= 0 ? '+' : '' ;
const mTip = getMarketTip ( cur . close , chg );
h += `<div class="kospi-panel">
<div class="kospi-header">
<div>
<div style="font-size:12px;color:#546E7A;font-weight:600;letter-spacing:1px;margin-bottom:6px">📊 KOSPI</div>
<div class="kospi-price" style="color: ${ chgColor } "> ${ cur . close ? Number ( cur . close ). toLocaleString ( 'ko' ,{ minimumFractionDigits : 2 } ):'--'}</div>
<div class="kospi-change" style="color: ${ chgColor } "> ${ chgSign }${ chg . toFixed ( 2 ) } % ${ chg >= 0 ? '▲' : '▼' } ${ Math . abs ( cur . change || 0 ). toFixed ( 2 ) } </div>
</div>
<div style="text-align:right;font-size:11px;color:#546E7A"> ${ cur . date || '' } 기준</div>
</div>
<canvas id="kospiChart" height="80"></canvas>
${ mTip ? `<div class="market-tip-box" style="border-color: ${ mTip . color } 33;background: ${ mTip . color } 0D">
<div class="mtip-label" style="color: ${ mTip . color } "> ${ mTip . icon } 시장 상황: ${ mTip . label } </div>
<div class="mtip-body"> ${ mTip . text } </div>
</div>` : '' }
</div>` ;
}
// 오늘의 투자 팁
if ( tip && tip . title ){
h += `<div class="kospi-tip">
<div class="tip-tag">💡 오늘의 투자 지식 · ${ tip . tag || '' } </div>
<div class="tip-title"> ${ esc ( tip . title ) } </div>
<div class="tip-body"> ${ esc ( tip . body ) } </div>
</div>` ;
}
// 요약 카드
h += `<div class="cards">
<div class="card"><div class="label">📰 분석 뉴스</div><div class="val"> ${ s . total || 0 } </div><div class="sub">최근 7일</div></div>
<div class="card"><div class="label">▲ 호재</div><div class="val" style="color:#00E676"> ${ s . positive || 0 } </div><div class="sub"> ${ s . sentiment_ratio || 50 } %</div></div>
<div class="card"><div class="label">▼ 악재</div><div class="val" style="color:#FF5252"> ${ s . negative || 0 } </div></div>
<div class="card"><div class="label">📋 DART 공시</div><div class="val"> ${ s . dart || 0 } </div></div>
<div class="card"><div class="label">🏢 분석 종목</div><div class="val"> ${ s . stocks_analyzed || 0 } </div></div>
</div><div class="grid2">` ;
// 랭킹
h += `<div class="panel"><h3>🏆 종목 랭킹</h3>` ;
if ( ranking && ranking . length ){
ranking . slice ( 0 , 15 ). forEach (( item , i )=>{
const rc = RCOL [ item . recommendation ] || '#78909C' ;
h += `<div class="rank-item" data-stock=" ${ item . stock_code } " data-stock-name=" ${ esc ( item . stock_name || '' ) } " style="cursor:pointer"><div class="rank-num ${ i < 3 ? 'top' : 'normal' } "> ${ i + 1 } </div>
<div style="flex:1"><div style="font-size:13px;font-weight:600;color:#CFD8DC"> ${ esc ( item . stock_name ) } </div><div style="font-size:10px;color:#546E7A"> ${ item . stock_code } </div></div>
<div style="text-align:right"><div style="font-size:14px;font-weight:700;color: ${ rc } "> ${ ( item . total_score || 0 ). toFixed ( 1 ) } </div><div style="font-size:10px;color: ${ rc } "> ${ item . recommendation || '관망' } </div></div></div>` ;
});
} else h += `<div class="empty">데이터 수집 중...</div>` ;
h += `</div>` ;
// 최근 뉴스
h += `<div class="panel"><h3>📰 최근 분석</h3>` ;
if ( recent && recent . length ){
recent . slice ( 0 , 15 ). forEach ( item =>{ h += newsItemHTML ( item )});
} else h += `<div class="empty">데이터 수집 중...</div>` ;
h += `</div></div>` ;
document . getElementById ( 'overview' ). innerHTML = h ;
// KOSPI 차트 그리기
if ( kospi && kospi . data && kospi . data . length && document . getElementById ( 'kospiChart' )){
const labels = kospi . data . map ( d => d . date . slice ( 5 ));
const closes = kospi . data . map ( d => d . close );
const last = closes [ closes . length - 1 ] || 0 ;
const borderColor = last >= closes [ 0 ] ? '#00E676' : '#FF5252' ;
new Chart ( document . getElementById ( 'kospiChart' ),{
type : 'line' ,
data : {
labels ,
datasets : [{
data : closes ,
borderColor ,
backgroundColor : borderColor + '18' ,
borderWidth : 2 ,
fill : true ,
tension : 0.3 ,
pointRadius : 0 ,
pointHoverRadius : 4 ,
}]
},
options : {
responsive : true ,
plugins : { legend : { display : false }, tooltip : {
mode : 'index' , intersect : false ,
callbacks : { label : ctx => `KOSPI ${ Number ( ctx . parsed . y ). toLocaleString ( 'ko' ,{ minimumFractionDigits : 2 } )}` }
}},
scales : {
x : { grid : { color : 'rgba(255,255,255,0.04)' }, ticks : { color : '#546E7A' , font : { size : 10 }, maxTicksLimit : 10 }},
y : { grid : { color : 'rgba(255,255,255,0.04)' }, ticks : { color : '#546E7A' , font : { size : 10 }, callback : v => v . toLocaleString ()}, position : 'right' }
}
}
});
}
}
function newsItemHTML ( item ){
const sc = SCOL [ item . sentiment ] || '#78909C' , si = SICON [ item . sentiment ] || '●' ;
const titleHtml = item . url
? `<a href=" ${ esc ( item . url ) } " target="_blank" rel="noopener" style="color:#CFD8DC;text-decoration:none" onmouseover="this.style.color='#00E676'" onmouseout="this.style.color='#CFD8DC'"> ${ esc ( item . title || '' ) } </a>`
: esc ( item . title || '' );
// 종목 코드가 있으면 클릭 가능한 칩으로
const stockChip = item . primary_stock
? `<span data-stock=" ${ item . primary_stock } " style="color:#40C4FF;cursor:pointer;text-decoration:underline;text-decoration-style:dotted;text-underline-offset:2px"> ${ esc ( item . primary_stock ) } </span>`
: `<span style="color:#546E7A">-</span>` ;
return `<div class="news-item"><div class="news-icon"><div class="arrow" style="color: ${ sc } "> ${ si } </div><div class="score" style="color: ${ sc } "> ${ item . intensity || 0 } </div></div>
<div class="news-body"><div class="news-title"> ${ titleHtml } </div>
<div class="news-meta"> ${ stockChip } <span style="color:#546E7A"> ${ esc ( item . source || '' ) } </span><span style="color:#455A64"> ${ ftime ( item . analyzed_at ) } </span></div>
${ item . reason ? `<div class="news-reason"> ${ esc (( item . reason || '' ). substring ( 0 , 100 )) } </div>` : '' } </div>
<div class="badge" style="background: ${ sc } 12;border:1px solid ${ sc } 30;color: ${ sc } "> ${ item . investment_action || '관망' } </div></div>` ;
}
function recCardHTML ( item ){
const rc = RCOL [ item . recommendation ] || '#78909C' ;
const pctVal = Math . min ( 100 , Math . max ( 0 ,(( item . total_score || 0 ) + 100 ) / 2 ));
const bc = item . total_score >= 30 ? '#00E676' : item . total_score >=- 30 ? '#78909C' : '#FF5252' ;
return `<div class="rec-card" data-stock=" ${ item . stock_code } " data-stock-name=" ${ esc ( item . stock_name || '' ) } " style="cursor:pointer"><div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:10px">
<div><div style="font-size:15px;font-weight:700;color:#E0E0E0"> ${ esc ( item . stock_name ) } </div><div style="font-size:11px;color:#546E7A;font-family:'JetBrains Mono',monospace"> ${ item . stock_code } </div></div>
<div class="badge" style="background: ${ rc } 15;border:1px solid ${ rc } 30;color: ${ rc } "> ${ item . recommendation } </div></div>
<div style="display:flex;gap:10px;font-size:11px;color:#78909C;flex-wrap:wrap">
<span>종합 <b style="color:#E0E0E0;font-size:14px"> ${ ( item . total_score || 0 ). toFixed ( 1 ) } </b></span>
<span>뉴스 <b style="color:#69F0AE"> ${ ( item . news_score || 0 ). toFixed ( 0 ) } </b></span>
<span>기술 <b style="color:#CE93D8"> ${ ( item . technical_score || 0 ). toFixed ( 0 ) } </b></span>
<span>공시 <b style="color:#40C4FF"> ${ ( item . dart_score || 0 ). toFixed ( 0 ) } </b></span>
<span>외국인 <b style="color: ${ ( item . foreign_score || 0 ) >= 0 ? '#00E676' : '#FF5252' } "> ${ ( item . foreign_score || 0 ). toFixed ( 0 ) } </b></span>
<span>공매도 <b style="color: ${ ( item . short_score || 0 ) >= 0 ? '#78909C' : '#FF8A80' } "> ${ ( item . short_score || 0 ). toFixed ( 0 ) } </b></span></div>
${ ( item . foreign_ratio || 0 ) > 0 ? `<div style="font-size:10px;color:#546E7A;margin-top:4px">외국인보유 ${ ( item . foreign_ratio || 0 ). toFixed ( 2 ) } % · 공매도비중 ${ ( item . short_weight || 0 ). toFixed ( 2 ) } %</div>` : '' }
<div class="score-bar"><div class="score-fill" style="width: ${ pctVal } %;background: ${ bc } "></div></div>
${ item . top_reasons ? `<div style="font-size:11px;color:#607D8B;line-height:1.5;border-top:1px solid rgba(255,255,255,0.04);padding-top:8px;margin-top:10px"> ${ esc (( item . top_reasons || '' ). split ( '|' )[ 0 ]. trim (). substring ( 0 , 120 )) } ...</div>` : '' } </div>` ;
}
function renderRecs ( recs , avoid ){
let h = `<div style="font-size:12px;color:#546E7A;margin-bottom:16px">뉴스 감성 + DART 공시 + 가격 모멘텀 + 기술적 분석 종합 점수 (종목별 중복 제거)</div>` ;
h += `<h3 style="font-size:14px;font-weight:700;color:#69F0AE;margin-bottom:12px">🟢 매수 추천 종목</h3><div class="rec-grid">` ;
if ( recs && recs . length ){
recs . forEach ( item =>{ h += recCardHTML ( item )});
} else h += `<div class="empty" style="grid-column:1/-1">추천 데이터가 아직 없습니다.</div>` ;
h += `</div>` ;
h += `<h3 style="font-size:14px;font-weight:700;color:#FF8A80;margin:28px 0 12px">⚠️ 매도관심 / 회피 종목</h3>` ;
h += `<div style="font-size:12px;color:#546E7A;margin-bottom:12px">악재 뉴스·공시가 누적된 종목입니다. 신규 매수보다 관망을 권장합니다.</div>` ;
h += `<div class="rec-grid">` ;
if ( avoid && avoid . length ){
avoid . forEach ( item =>{ h += recCardHTML ( item )});
} else h += `<div class="empty" style="grid-column:1/-1">현재 회피 종목 없음</div>` ;
h += `</div>` ;
document . getElementById ( 'recommend' ). innerHTML = h ;
}
let _newsOnlyStock = ( localStorage . getItem ( 'news_only_stock' ) ?? '1' ) === '1' ;
async function renderNews ( recent ){
// 토글 상태에 따라 다시 fetch (loadAll의 첫 호출은 only_stock=true 기본값)
const list = recent || await api ( '/recent?limit=80&only_stock=' + ( _newsOnlyStock ? 'true' : 'false' ));
let h = `<div class="panel" style="max-height:none">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h3 style="margin:0"> ${ _newsOnlyStock ? '📰 종목 관련 뉴스' : '📰 전체 뉴스' } </h3>
<label style="display:flex;align-items:center;gap:6px;font-size:11px;color:#90A4AE;cursor:pointer">
<input type="checkbox" id="news-only-stock" ${ _newsOnlyStock ? 'checked' : '' } onchange="toggleNewsFilter(this.checked)" style="cursor:pointer">
종목 관련만 보기
</label>
</div>` ;
if ( list && list . length ){ list . forEach ( item =>{ h += newsItemHTML ( item )})} else h += `<div class="empty">뉴스 데이터 수집 중...</div>` ;
h += `</div>` ;
document . getElementById ( 'news' ). innerHTML = h ;
}
async function toggleNewsFilter ( checked ){
_newsOnlyStock = checked ;
localStorage . setItem ( 'news_only_stock' , checked ? '1' : '0' );
const list = await api ( '/recent?limit=80&only_stock=' + ( checked ? 'true' : 'false' ));
renderNews ( list );
}
function renderAlerts ( alerts ){
let h = `<div style="font-size:12px;color:#546E7A;margin-bottom:16px">강도 3 이상 뉴스/공시 (최근 24시간) · 종목코드 클릭 시 차트 모달이 열립니다</div>` ;
if ( alerts && alerts . length ){
alerts . forEach ( item =>{
const sc = SCOL [ item . sentiment ] || '#78909C' , si = SICON [ item . sentiment ] || '●' ;
const stockChip = item . primary_stock
? `<span data-stock=" ${ item . primary_stock } " style="color:#40C4FF;cursor:pointer;font-family:'JetBrains Mono',monospace;background:rgba(64,196,255,0.08);padding:2px 6px;border-radius:4px;font-size:10px;margin-right:6px"> ${ esc ( item . primary_stock ) } </span>`
: '' ;
h += `<div class="alert-item" style="background: ${ sc } 08;border:1px solid ${ sc } 20">
<div style="display:flex;justify-content:space-between;align-items:center">
<div style="font-size:13px;font-weight:600;color: ${ sc } "> ${ si } ${ item . sentiment } (강도 ${ item . intensity } )</div>
<div style="font-size:10px;color:#546E7A"> ${ ftime ( item . analyzed_at ) } </div></div>
<div style="font-size:12px;color:#CFD8DC;margin-top:6px;line-height:1.4"> ${ stockChip }${ esc ( item . title || '' ) } </div>
<div style="font-size:11px;color:#78909C;margin-top:4px"> ${ esc (( item . reason || '' ). substring ( 0 , 100 )) } </div></div>` ;
});
} else h += `<div class="panel"><div class="empty">현재 긴급 알림이 없습니다.</div></div>` ;
document . getElementById ( 'alerts' ). innerHTML = h ;
}
async function renderSupplyDemand (){
const el = document . getElementById ( 'supplydemand' );
el . innerHTML = '<div class="empty">로딩 중...</div>' ;
const [ supplyData , shortData ] = await Promise . all ([ api ( '/supply-demand?limit=20' ), api ( '/short-ranking?limit=20' )]);
let h = `<div style="font-size:12px;color:#546E7A;margin-bottom:16px">키움증권 API 기반 외국인/기관 수급 + 공매도 현황 (장 종료 후 17시 갱신)</div>` ;
h += `<div class="grid2">` ;
// 외국인 순매수 상위
h += `<div class="panel"><h3>🌏 외국인 순매수 상위 (5일 누적)</h3>` ;
if ( supplyData && supplyData . length ){
supplyData . slice ( 0 , 15 ). forEach (( item , i )=>{
const net = item . net_5d || 0 ;
const col = net >= 0 ? '#00E676' : '#FF5252' ;
const fscore = ( item . foreign_score || 0 ). toFixed ( 1 );
const fscoreCol = ( item . foreign_score || 0 ) >= 0 ? '#69F0AE' : '#FF8A80' ;
h += `<div class="rank-item" data-stock=" ${ item . stock_code } " data-stock-name=" ${ esc ( item . stock_name || '' ) } " style="cursor:pointer">
<div class="rank-num ${ i < 3 ? 'top' : 'normal' } "> ${ i + 1 } </div>
<div style="flex:1">
<div style="font-size:13px;font-weight:600;color:#CFD8DC"> ${ esc ( item . stock_name || item . stock_code ) } </div>
<div style="font-size:10px;color:#546E7A;font-family:'JetBrains Mono',monospace"> ${ item . stock_code } · 보유 ${ ( item . hold_ratio || 0 ). toFixed ( 2 ) } %</div>
</div>
<div style="text-align:right">
<div style="font-size:13px;font-weight:700;color: ${ col } ;font-family:'JetBrains Mono',monospace"> ${ net >= 0 ? '+' : '' }${ Number ( net ). toLocaleString () } 주</div>
<div style="font-size:10px;color: ${ fscoreCol } ">수급점수 ${ fscore } </div>
</div>
</div>` ;
});
} else h += `<div class="empty">외국인 데이터 수집 중...<br><span style="font-size:11px">장 종료 후 17시 자동 수집됩니다</span></div>` ;
h += `</div>` ;
// 공매도 비중 상위
h += `<div class="panel"><h3>📉 공매도 비중 상위 (최근일 기준)</h3>` ;
if ( shortData && shortData . length ){
shortData . slice ( 0 , 15 ). forEach (( item , i )=>{
const w = item . trade_weight || 0 ;
const col = w >= 5 ? '#FF5252' : w >= 2 ? '#FF8A80' : '#78909C' ;
const sscore = ( item . short_score || 0 ). toFixed ( 1 );
const sscoreCol = ( item . short_score || 0 ) >= 0 ? '#78909C' : '#FF8A80' ;
h += `<div class="rank-item" data-stock=" ${ item . stock_code } " data-stock-name=" ${ esc ( item . stock_name || '' ) } " style="cursor:pointer">
<div class="rank-num ${ i < 3 ? 'top' : 'normal' } " style=" ${ i < 3 ? 'background:rgba(255,82,82,0.12);color:#FF5252' : '' } "> ${ i + 1 } </div>
<div style="flex:1">
<div style="font-size:13px;font-weight:600;color:#CFD8DC"> ${ esc ( item . stock_name || item . stock_code ) } </div>
<div style="font-size:10px;color:#546E7A;font-family:'JetBrains Mono',monospace"> ${ item . stock_code } · 잔고 ${ Number ( item . balance || 0 ). toLocaleString () } 주</div>
</div>
<div style="text-align:right">
<div style="font-size:13px;font-weight:700;color: ${ col } "> ${ w . toFixed ( 2 ) } %</div>
<div style="font-size:10px;color: ${ sscoreCol } ">공매도점수 ${ sscore } </div>
</div>
</div>` ;
});
} else h += `<div class="empty">공매도 데이터 수집 중...<br><span style="font-size:11px">장 종료 후 17시 자동 수집됩니다</span></div>` ;
h += `</div>` ;
h += `</div>` ;
// 설명 카드
h += `<div style="background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.05);border-radius:14px;padding:20px;margin-top:16px">
<div style="font-size:13px;font-weight:700;color:#90A4AE;margin-bottom:12px">📌 수급 점수 해석</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;font-size:12px;color:#78909C;line-height:1.8">
<div>
<div style="color:#69F0AE;font-weight:600;margin-bottom:4px">외국인 점수 (+)</div>
<div>순매수 연속 3일 이상 → +40</div>
<div>보유비율 5일 상승 추세 → +40</div>
<div>절대 보유비율 10%↑ → +20</div>
</div>
<div>
<div style="color:#FF8A80;font-weight:600;margin-bottom:4px">공매도 점수 (-)</div>
<div>공매도 비중 10%↑ → -60</div>
<div>공매도 비중 5~10% → -35</div>
<div>잔고 5일 20%↑ → -20</div>
</div>
</div>
</div>` ;
el . innerHTML = h ;
}
setInterval (()=>{ const c = document . getElementById ( 'clock' ); if ( c ) c . textContent = new Date (). toLocaleTimeString ( 'ko-KR' )}, 1000 );
// 인증 후에만 동작하는 자동 새로고침
let _refreshTimer = null ;
function startAutoRefresh (){ if ( _refreshTimer ) return ; _refreshTimer = setInterval (()=>{ if ( isLoggedIn ()) loadAll ();}, 30000 );}
function stopAutoRefresh (){ if ( _refreshTimer ){ clearInterval ( _refreshTimer ); _refreshTimer = null ;}}
// 페이지 로드: 토큰 검증 → 대시보드 또는 로그인
( async ()=>{
if ( ! isLoggedIn ()){ showLoginPage (); return ;}
try {
const r = await fetch ( API + '/auth/me' ,{ headers : authHeaders ()});
if ( ! r . ok ){ clearAuth (); showLoginPage (); return ;}
const me = await r . json ();
setAuth ( _authToken , me ); // role 포함 갱신
showDashboard ();
await Promise . all ([ loadPortfolio (), loadAll ()]);
startAutoRefresh ();
setTimeout ( maybeOfferImport , 500 );
} catch ( e ){ clearAuth (); showLoginPage ();}
})();
// 로그인 직후 자동 새로고침 시작
const _origShowDashboard = showDashboard ;
showDashboard = function (){ _origShowDashboard (); startAutoRefresh ();};
const _origLogout = logoutUser ;
logoutUser = async function (){ stopAutoRefresh (); await _origLogout ();};
async function doSearch (){
const q = document . getElementById ( 'searchInput' ). value . trim ();
if ( ! q ){ document . getElementById ( 'searchResults' ). style . display = 'none' ; return }
const code6 = q . match ( /^\d{6}$/ ) ? q : '' ;
const [ news , sigs , ta ] = await Promise . all ([
api ( '/search?q=' + encodeURIComponent ( q )),
fetch ( API . replace ( '8989' , '8585' ) + '/signals/' + q ). then ( r => r . json ()). catch (()=>[]),
code6 ? api ( '/technical/' + code6 ) : Promise . resolve ( null )
]);
let h = `<div class="panel" style="max-height:none"><h3>🔍 " ${ esc ( q ) } " 검색 결과</h3>` ;
// 기술적 분석 상세
if ( ta && ta . indicators ){
const ind = ta . indicators , tg = ta . targets || {};
const sc = ta . signal === '매수' ? '#00E676' : ta . signal === '매도' ? '#FF5252' : '#78909C' ;
h += `<div style="background: ${ sc } 06;border:1px solid ${ sc } 20;border-radius:12px;padding:16px;margin-bottom:16px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h4 style="font-size:14px;color: ${ sc } ">📊 기술적 분석 ( ${ ta . signal || '관망' } )</h4>
<span style="font-size:12px;color:#78909C">기술점수 <b style="color: ${ sc } ;font-size:16px"> ${ ( ta . tech_score || 0 ). toFixed ( 1 ) } </b></span>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;font-size:12px;margin-bottom:12px">
<div style="color:#78909C">현재가 <b style="color:#E0E0E0"> ${ fmt ( ind . price ) } </b></div>
<div style="color:#78909C">MA5 <b style="color: ${ ind . price > ind . ma5 ? '#69F0AE' : '#FF8A80' } "> ${ fmt ( ind . ma5 ) } </b></div>
<div style="color:#78909C">MA20 <b style="color: ${ ind . price > ind . ma20 ? '#69F0AE' : '#FF8A80' } "> ${ fmt ( ind . ma20 ) } </b></div>
<div style="color:#78909C">MA60 <b style="color: ${ ind . price > ind . ma60 ? '#69F0AE' : '#FF8A80' } "> ${ fmt ( ind . ma60 ) } </b></div>
<div style="color:#78909C">RSI <b style="color: ${ ind . rsi < 40 ? '#69F0AE' : ind . rsi > 70 ? '#FF8A80' : '#E0E0E0' } "> ${ ( ind . rsi || 0 ). toFixed ( 1 ) } </b></div>
<div style="color:#78909C">MACD <b style="color: ${ ( ind . macd_hist || 0 ) > 0 ? '#69F0AE' : '#FF8A80' } "> ${ ( ind . macd_hist || 0 ). toFixed ( 3 ) } </b></div>
<div style="color:#78909C">볼밴%B <b style="color:#FFD740"> ${ (( ind . pct_b || 0.5 ) * 100 ). toFixed ( 0 ) } %</b></div>
<div style="color:#78909C">거래량비 <b style="color:#40C4FF"> ${ ( ind . vol_ratio || 1 ). toFixed ( 1 ) } x</b></div>
</div>
${ tg . t1 ? `<div class="price-row">
<div class="price-box"><div class="label">진입 추천가</div><div class="val" style="color:#40C4FF"> ${ fmt ( tg . entry_price ) } </div></div>
<div class="price-box"><div class="label">목표1 ( ${ pct ( tg . t1_pct ) } )</div><div class="val" style="color:#69F0AE"> ${ fmt ( tg . t1 ) } </div></div>
<div class="price-box"><div class="label">목표2 ( ${ pct ( tg . t2_pct ) } )</div><div class="val" style="color:#00E676"> ${ fmt ( tg . t2 ) } </div></div>
<div class="price-box"><div class="label">손절가 ( ${ pct ( tg . stop_pct ) } )</div><div class="val" style="color:#FF8A80"> ${ fmt ( tg . stop_loss ) } </div></div>
</div>` : '' }
<div class="ta-row" style="margin-top:10px"> ${ ( ta . signals || []). map ( s => `<span class="ta-badge"> ${ esc ( s ) } </span>` ). join ( '' ) } </div>
</div>` ;
}
if ( sigs && sigs . length ){
h += `<div style="margin-bottom:16px"><h4 style="font-size:13px;color:#69F0AE;margin-bottom:8px">💰 매매 시그널</h4>` ;
sigs . forEach ( s =>{
const sc = s . signal_type === '매수' ? '#00E676' : '#FF5252' ;
h += `<div data-stock=" ${ s . stock_code } " data-stock-name=" ${ esc ( s . stock_name || '' ) } " style="background: ${ sc } 08;border:1px solid ${ sc } 20;border-radius:10px;padding:12px;margin-bottom:8px;cursor:pointer">
<div style="display:flex;justify-content:space-between;align-items:center">
<span style="font-size:14px;font-weight:700;color: ${ sc } "> ${ s . signal_type } ${ esc ( s . stock_name ) } ( ${ s . stock_code } )</span>
<span style="font-size:12px;color:#78909C">신뢰도 ${ ( s . confidence || 0 ). toFixed ( 1 ) } %</span></div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:8px;font-size:12px">
<div style="color:#78909C">현재가 <b style="color:#E0E0E0"> ${ fmt ( s . current_price ) } 원</b></div>
<div style="color:#78909C">목표가 <b style="color:#69F0AE"> ${ fmt ( s . target_price ) } 원</b></div>
<div style="color:#78909C">손절가 <b style="color:#FF8A80"> ${ fmt ( s . stop_loss ) } 원</b></div>
<div style="color:#78909C">예상수익 <b style="color:#FFD740"> ${ pct ( s . expected_return_pct ) } </b></div></div>
${ s . reason ? `<div style="font-size:11px;color:#607D8B;margin-top:8px"> ${ esc ( s . reason . substring ( 0 , 150 )) } </div>` : '' } </div>` ;
});
h += `</div>` ;
}
if ( news && news . length ){
h += `<h4 style="font-size:13px;color:#90A4AE;margin:12px 0 8px">📰 관련 뉴스/공시 ( ${ news . length } 건)</h4>` ;
news . forEach ( item =>{ h += newsItemHTML ( item )});
} else if ( ! sigs ||! sigs . length ){
h += `<div class="empty">검색 결과가 없습니다.</div>` ;
}
h += `</div>` ;
const el = document . getElementById ( 'searchResults' );
el . innerHTML = h ; el . style . display = 'block' ;
// 검색 결과가 종목코드면 차트 자동 열기 옵션
if ( code6 ){
const ctaBtn = `<div style="margin-bottom:12px"><button onclick="openStockChart(' ${ code6 } ')" style="background:linear-gradient(135deg,#00C853,#00897B);border:none;border-radius:8px;padding:8px 18px;color:#fff;font-size:12px;font-weight:700;cursor:pointer">📊 ${ code6 } 차트 모달 열기</button></div>` ;
el . querySelector ( '.panel' ). insertAdjacentHTML ( 'afterbegin' , ctaBtn );
}
}
// ── 챗봇 ──────────────────────────────────────────────────
let chatHistory = [], chatOpen = false ;
function toggleChat (){
chatOpen =! chatOpen ;
const p = document . getElementById ( 'chat-panel' );
p . style . display = chatOpen ? 'flex' : 'none' ;
if ( chatOpen ) document . getElementById ( 'chat-input' ). focus ();
}
function chatKey ( e ){
if ( e . key === 'Enter' &&! e . shiftKey ){ e . preventDefault (); sendChat ();}
}
function mdToHtml ( t ){
return t
. replace ( /&/g , '&' ). replace ( /</g , '<' ). replace ( />/g , '>' )
. replace ( /\*\*(.+?)\*\*/g , '<strong>$1</strong>' )
. replace ( /`(.+?)`/g , '<code>$1</code>' )
. replace ( /^### (.+)$/gm , '<strong>$1</strong>' )
. replace ( /^## (.+)$/gm , '<strong>$1</strong>' )
. replace ( /^- (.+)$/gm , '• $1' )
. replace ( /\n/g , '<br>' );
}
async function sendChat (){
const input = document . getElementById ( 'chat-input' );
const msg = input . value . trim ();
if ( ! msg ) return ;
input . value = '' ;
const msgs = document . getElementById ( 'chat-messages' );
// 유저 메시지
msgs . innerHTML += `<div class="msg user"> ${ msg . replace ( /</g , '<' ) } </div>` ;
// 타이핑 표시
const typingId = 'typing-' + Date . now ();
msgs . innerHTML += `<div class="typing" id=" ${ typingId } "><span></span><span></span><span></span></div>` ;
msgs . scrollTop = msgs . scrollHeight ;
document . getElementById ( 'chat-send' ). disabled = true ;
chatHistory . push ({ role : 'user' , content : msg });
// AI 답변 버블 생성
const aiId = 'ai-' + Date . now ();
let fullText = '' ;
try {
const resp = await fetch ( '/api/chat' ,{
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ message : msg , history : chatHistory . slice ( - 10 )})
});
const reader = resp . body . getReader ();
const dec = new TextDecoder ();
let buf = '' ;
// 타이핑 제거, AI 버블 추가
document . getElementById ( typingId ) ? . remove ();
msgs . innerHTML += `<div class="msg ai" id=" ${ aiId } "></div>` ;
while ( true ){
const { done , value } = await reader . read ();
if ( done ) break ;
buf += dec . decode ( value ,{ stream : true });
const lines = buf . split ( '\n' );
buf = lines . pop ();
for ( const line of lines ){
if ( ! line . startsWith ( 'data: ' )) continue ;
const payload = line . slice ( 6 );
if ( payload === '[DONE]' ) break ;
try {
const delta = JSON . parse ( payload ). content || '' ;
fullText += delta ;
document . getElementById ( aiId ). innerHTML = mdToHtml ( fullText );
msgs . scrollTop = msgs . scrollHeight ;
} catch {}
}
}
} catch ( e ){
document . getElementById ( typingId ) ? . remove ();
msgs . innerHTML += `<div class="msg ai" style="color:#FF8A80">오류가 발생했습니다. 다시 시도해주세요.</div>` ;
}
if ( fullText ) chatHistory . push ({ role : 'assistant' , content : fullText });
document . getElementById ( 'chat-send' ). disabled = false ;
msgs . scrollTop = msgs . scrollHeight ;
}
2026-05-20 22:09:32 +09:00
// ─────────────────────────────────────────────────────────────
// 🧮 10공식 신호 매트릭스
// ─────────────────────────────────────────────────────────────
function _voteColor ( v , pos ){ // pos=true=매수신호, false=매도신호
if ( v == null ) return '#37474F' ;
return pos ? '#69F0AE' : '#FF8A80' ;
}
function _cell ( label , val , hit , tooltip ){
const c = hit ? ( hit === 'buy' ? '#69F0AE' : '#FF8A80' ) : '#546E7A' ;
const bg = hit ? ( hit === 'buy' ? 'rgba(0,230,118,0.10)' : 'rgba(255,82,82,0.10)' ) : 'rgba(255,255,255,0.03)' ;
return `<td title=" ${ tooltip || '' } " style="padding:6px 8px;text-align:center;font-family:'JetBrains Mono',monospace;font-size:11px;color: ${ c } ;background: ${ bg } ;border-radius:6px"> ${ val } </td>` ;
}
async function renderFormulas (){
const el = document . getElementById ( 'formulas' );
el . innerHTML = `<div style="font-size:12px;color:#546E7A;margin-bottom:14px">10공식(매직포뮬러·F-Score·Altman Z·PEG·12-1 모멘텀·Beneish·GP/A·G-Score·Amihud·BAB) 신호 매트릭스 — 오늘 점수 상위</div><div style="text-align:center;color:#546E7A;padding:30px">불러오는 중...</div>` ;
try {
const [ rows , weights ] = await Promise . all ([
api ( '/formulas/matrix?limit=50' ),
api ( '/weights' )
]);
if ( ! Array . isArray ( rows ) ||! rows . length ){ el . innerHTML = '<div class="empty">데이터가 없습니다</div>' ; return ;}
const w = ( weights && weights . weights ) || {};
let wStr = Object . entries ( w ). map (([ k , v ])=> `<span style="margin-right:10px"><b style="color:#69F0AE"> ${ k } </b> <span style="font-family:'JetBrains Mono',monospace"> ${ ( + v ). toFixed ( 2 ) } </span></span>` ). join ( '' );
let h = `<div style="background:rgba(0,230,118,0.04);border:1px solid rgba(0,230,118,0.12);border-radius:10px;padding:10px 14px;margin-bottom:14px;font-size:11px;color:#90A4AE">학습 가중치: ${ wStr || '<i>미학습</i>' } </div>` ;
h += `<div style="overflow:auto;background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.05);border-radius:14px;padding:10px"><table style="width:100%;border-collapse:separate;border-spacing:2px;font-size:11px">
<thead><tr style="color:#90A4AE">
<th style="text-align:left;padding:8px 6px">종목</th>
<th style="text-align:right;padding:8px 6px">점수</th>
<th>등급</th>
<th title="매수 / 매도 보팅 합">B/S</th>
<th title="Greenblatt 매직포뮬러 (ROC+EY)">Magic</th>
<th title="Piotroski F-Score (0~7)">F</th>
<th title="Altman Z">Z</th>
<th title="Lynch PEG">PEG</th>
<th title="12-1개월 모멘텀">12-1%</th>
<th title="Beneish M-Score (분식의심도)">Bnsh</th>
<th title="Novy-Marx GP/A">GP/A%</th>
<th title="Mohanram G-Score (vs 섹터 중앙값)">G</th>
<th title="Amihud 비유동성">Amh</th>
<th title="저베타 알파 (Frazzini-Pedersen BAB)">β</th>
2026-05-20 22:54:17 +09:00
<th title="catalyst×감쇠×중복 가중 뉴스점수 (-100~100)">뉴스</th>
<th title="감정 모멘텀: 최근 3일 vs 이전 4일 변화율">M</th>
<th title="시장 평균 대비 sentiment alpha">α </th>
<th title="최근 7일 뉴스건수 log 스케일 관심도 (0~100)">관심</th>
<th title="평소 대비 뉴스 폭증 배율 (x)">× </th>
2026-05-20 22:09:32 +09:00
</tr></thead><tbody>` ;
rows . forEach ( r =>{
let sig = r . signals || {};
if ( typeof sig === 'string' ){ try { sig = JSON . parse ( sig ); } catch { sig = {}; } }
const hit = ( k )=>{
const v = sig [ k ];
if ( v === 'buy' || v === '매수' ) return 'buy' ;
if ( v === 'sell' || v === '매도' ) return 'sell' ;
return null ;
};
const recC = RCOL [ r . recommendation ] || '#90A4AE' ;
const code6 = r . stock_code ;
h += `<tr style="background:rgba(255,255,255,0.02);cursor:pointer" onclick="openStockChart(' ${ code6 } ')">
<td style="padding:8px 6px;color:#CFD8DC"><b> ${ esc ( r . stock_name || code6 ) } </b> <span style="color:#546E7A;font-size:10px;font-family:'JetBrains Mono',monospace"> ${ code6 } </span></td>
<td style="padding:8px 6px;text-align:right;font-family:'JetBrains Mono',monospace;font-weight:700;color: ${ recC } "> ${ ( + r . total_score ). toFixed ( 1 ) } </td>
<td style="text-align:center"><span class="badge" style="background: ${ recC } 22;color: ${ recC } ;font-size:10px;padding:3px 7px"> ${ r . recommendation || '-' } </span></td>
<td style="text-align:center;color:#90A4AE;font-family:'JetBrains Mono',monospace"> ${ r . buy_votes || 0 } / ${ r . sell_votes || 0 } </td>
${ _cell ( 'Magic' , r . magic_score != null ? ( + r . magic_score ). toFixed ( 0 ) : '-' , hit ( 'magic' ), 'ROC + EY 임계값 합' ) }
${ _cell ( 'F' , r . f_score != null ? ( + r . f_score ). toFixed ( 0 ) : '-' , hit ( 'fscore' ), '7신호 합' ) }
${ _cell ( 'Z' , r . altman_z != null ? ( + r . altman_z ). toFixed ( 2 ) : '-' , hit ( 'altman' ), 'Altman Z (≥2.6 안전)' ) }
${ _cell ( 'PEG' , r . peg != null ? ( + r . peg ). toFixed ( 2 ) : '-' , hit ( 'peg' ), 'PER/성장률 (≤1.5 매수)' ) }
${ _cell ( '12-1%' , r . momentum_pct != null ? ( + r . momentum_pct ). toFixed ( 1 ) : '-' , hit ( 'momentum' ), '12-1개월 모멘텀' ) }
${ _cell ( 'Bnsh' , r . beneish_score != null ? ( + r . beneish_score ). toFixed ( 0 ) : '-' , hit ( 'beneish' ), 'Beneish M (분식의심도)' ) }
${ _cell ( 'GP/A%' , r . gpa_pct != null ? ( + r . gpa_pct ). toFixed ( 1 ) : '-' , hit ( 'gpa' ), '영업이익/총자산' ) }
${ _cell ( 'G' , r . g_score != null ? ( + r . g_score ). toFixed ( 0 ) : '-' , hit ( 'gscore' ), 'Mohanram G (vs 섹터)' ) }
${ _cell ( 'Amh' , r . amihud_illiq != null ? ( + r . amihud_illiq ). toFixed ( 0 ) : '-' , hit ( 'amihud' ), 'Amihud 비유동성' ) }
${ _cell ( 'β' , r . market_beta != null ? ( + r . market_beta ). toFixed ( 2 ) : '-' , hit ( 'beta' ), '60일 KOSPI 회귀 베타' ) }
2026-05-20 22:54:17 +09:00
${ (()=>{ // 뉴스점수
const v = r . news_score ;
if ( v == null ) return '<td style="text-align:center;color:#37474F">-</td>' ;
const c = v >= 10 ? '#69F0AE' : v <=- 10 ? '#FF8A80' : '#90A4AE' ;
return `<td style="text-align:center;font-family:'JetBrains Mono',monospace;color: ${ c } ;font-weight:700"> ${ ( + v ). toFixed ( 0 ) } </td>` ;
} )()}
${ (()=>{ // 모멘텀
const v = r . sentiment_momentum ;
if ( v == null ) return '<td style="text-align:center;color:#37474F">-</td>' ;
const c = v >= 5 ? '#69F0AE' : v <=- 5 ? '#FF8A80' : '#90A4AE' ;
return `<td style="text-align:center;font-family:'JetBrains Mono',monospace;color: ${ c } "> ${ ( + v ). toFixed ( 1 ) } </td>` ;
} )()}
${ (()=>{ // alpha
const v = r . sentiment_alpha ;
if ( v == null ) return '<td style="text-align:center;color:#37474F">-</td>' ;
const c = v >= 5 ? '#69F0AE' : v <=- 5 ? '#FF8A80' : '#90A4AE' ;
return `<td style="text-align:center;font-family:'JetBrains Mono',monospace;color: ${ c } "> ${ v > 0 ? '+' : '' }${ ( + v ). toFixed ( 0 ) } </td>` ;
} )()}
${ (()=>{ // attention
const v = r . attention_score ;
if ( v == null ) return '<td style="text-align:center;color:#37474F">-</td>' ;
const c = v >= 70 ? '#FFD740' : v >= 40 ? '#69F0AE' : '#90A4AE' ;
return `<td style="text-align:center;font-family:'JetBrains Mono',monospace;color: ${ c } "> ${ ( + v ). toFixed ( 0 ) } </td>` ;
} )()}
${ (()=>{ // surge
const v = r . news_surge_ratio ;
if ( v == null ) return '<td style="text-align:center;color:#37474F">-</td>' ;
const c = v >= 3 ? '#FFD740' : v >= 1.5 ? '#69F0AE' : '#546E7A' ;
return `<td style="text-align:center;font-family:'JetBrains Mono',monospace;color: ${ c } "> ${ ( + v ). toFixed ( 1 ) } × </td>`;
} )()}
2026-05-20 22:09:32 +09:00
</tr>` ;
});
h += `</tbody></table></div>` ;
h += `<div style="margin-top:14px;font-size:11px;color:#546E7A;line-height:1.6">초록=매수 신호, 빨강=매도 신호, 회색=중립/미산출. 종목 행 클릭 시 차트 모달. <br>📚 매트릭스 셀 위에 마우스를 올리면 공식 설명이 표시됩니다.</div>` ;
el . innerHTML = h ;
} catch ( e ){ el . innerHTML = `<div class="empty">오류: ${ esc ( String ( e )) } </div>` }
}
// ─────────────────────────────────────────────────────────────
// 📈 백테스트 성과
// ─────────────────────────────────────────────────────────────
function _btCard ( label , v , suffix , danger ){
if ( v == null || isNaN ( v )) return `<div class="card"><div class="label"> ${ label } </div><div class="val" style="font-size:24px;color:#546E7A">-</div></div>` ;
const c = danger ? ( v < 0 ? '#FF5252' : '#00E676' ) : '#69F0AE' ;
return `<div class="card"><div class="label"> ${ label } </div><div class="val" style="color: ${ c } "> ${ ( + v ). toFixed ( 2 ) }${ suffix || '' } </div></div>` ;
}
function _btTable ( byRec ){
const order = [ '강력매수' , '매수관심' , '관망' , '매도관심' , '강력매도' ];
let h = `<table style="width:100%;border-collapse:separate;border-spacing:2px;font-size:12px">
<thead><tr style="color:#90A4AE">
<th style="text-align:left;padding:8px 6px">등급</th>
<th>표본 N</th><th>평균 수익률</th><th>승률</th><th>샤프</th><th>MDD</th><th>알파</th>
</tr></thead><tbody>` ;
order . forEach ( rec =>{
const d = byRec [ rec ] || { n : 0 };
const recC = RCOL [ rec ] || '#90A4AE' ;
if ( ! d . n ){
h += `<tr style="background:rgba(255,255,255,0.02)"><td style="padding:8px"><span class="badge" style="background: ${ recC } 22;color: ${ recC } "> ${ rec } </span></td><td colspan="6" style="text-align:center;color:#546E7A">표본 없음</td></tr>` ;
} else {
const rC = d . avg_return_pct >= 0 ? '#69F0AE' : '#FF8A80' ;
const aC = ( d . avg_alpha_pct || 0 ) >= 0 ? '#69F0AE' : '#FF8A80' ;
h += `<tr style="background:rgba(255,255,255,0.02)">
<td style="padding:8px"><span class="badge" style="background: ${ recC } 22;color: ${ recC } "> ${ rec } </span></td>
<td style="text-align:center;color:#90A4AE"> ${ d . n } </td>
<td style="text-align:center;color: ${ rC } ;font-family:'JetBrains Mono',monospace;font-weight:700"> ${ ( + d . avg_return_pct ). toFixed ( 2 ) } %</td>
<td style="text-align:center;color:#CFD8DC;font-family:'JetBrains Mono',monospace"> ${ ( + d . win_rate_pct ). toFixed ( 1 ) } %</td>
<td style="text-align:center;color:#CFD8DC;font-family:'JetBrains Mono',monospace"> ${ ( + d . sharpe ). toFixed ( 2 ) } </td>
<td style="text-align:center;color:#FF8A80;font-family:'JetBrains Mono',monospace"> ${ ( + d . max_drawdown_pct ). toFixed ( 2 ) } %</td>
<td style="text-align:center;color: ${ aC } ;font-family:'JetBrains Mono',monospace"> ${ ( + d . avg_alpha_pct ). toFixed ( 2 ) } %</td>
</tr>` ;
}
});
h += `</tbody></table>` ;
return h ;
}
async function renderBacktest (){
const el = document . getElementById ( 'backtest' );
el . innerHTML = `<div style="font-size:12px;color:#546E7A;margin-bottom:14px">추천 등급별 백테스트 성과 (기간/홀딩 슬라이드)</div>
<div style="margin-bottom:14px;display:flex;gap:8px;align-items:center">
<span style="color:#90A4AE;font-size:12px">기간:</span>
<select id="bt-days" onchange="renderBacktest()" style="background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);color:#CFD8DC;padding:6px 10px;border-radius:6px;font-size:12px">
<option value="30">30일</option><option value="60">60일</option><option value="90">90일</option><option value="180" selected>180일</option><option value="360">360일</option>
</select>
</div>
<div style="text-align:center;color:#546E7A;padding:30px">불러오는 중...</div>` ;
// re-read select if exists
const daysSel = document . getElementById ( 'bt-days' );
const days = daysSel ? + daysSel . value : 180 ;
try {
const d = await api ( `/backtest?days= ${ days } ` );
if ( d . error ){ el . innerHTML = `<div class="empty">오류: ${ esc ( d . error ) } </div>` ; return ;}
const oa = ( d . overall && ( d . overall [ '7d' ] || d . overall [ '30d' ])) || {};
const oa30 = ( d . overall && d . overall [ '30d' ]) || {};
let h = `<div style="font-size:12px;color:#546E7A;margin-bottom:14px">기간 ${ d . period_days || days } 일 · 표본 ${ d . total_recommendations || 0 } 건</div>
<div style="margin-bottom:14px;display:flex;gap:8px;align-items:center">
<span style="color:#90A4AE;font-size:12px">기간:</span>
<select id="bt-days" onchange="renderBacktest()" style="background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.08);color:#CFD8DC;padding:6px 10px;border-radius:6px;font-size:12px">
${ [ 30 , 60 , 90 , 180 , 360 ]. map ( x => `<option value=" ${ x } " ${ x === days ? 'selected' : '' } > ${ x } 일</option>` ). join ( '' ) }
</select>
</div>
<h3 style="color:#90A4AE;font-size:13px;margin:8px 0 10px">7일 보유</h3>
<div class="cards">
${ _btCard ( 'N (표본)' , oa . n , '' ) }
${ _btCard ( '평균 수익률' , oa . avg_return_pct , '%' , true ) }
${ _btCard ( '승률' , oa . win_rate_pct , '%' ) }
${ _btCard ( '샤프' , oa . sharpe , '' , true ) }
${ _btCard ( 'MDD' , oa . max_drawdown_pct , '%' , true ) }
${ _btCard ( '알파' , oa . avg_alpha_pct , '%' , true ) }
</div>
<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>
<div class="cards">
${ _btCard ( 'N' , oa30 . n , '' ) }
${ _btCard ( '평균 수익률' , oa30 . avg_return_pct , '%' , true ) }
${ _btCard ( '승률' , oa30 . win_rate_pct , '%' ) }
${ _btCard ( '샤프' , oa30 . sharpe , '' , true ) }
${ _btCard ( 'MDD' , oa30 . max_drawdown_pct , '%' , true ) }
${ _btCard ( '알파' , oa30 . avg_alpha_pct , '%' , true ) }
</div>
<div class="panel" style="margin-top:14px"><h3>등급별 성과 (30일)</h3> ${ _btTable ( d . by_recommendation_30d || { } )}</div>` ;
}
h += `<div style="margin-top:14px;font-size:11px;color:#546E7A">알파 = 추천 평균 수익률 − 같은 기간 KOSPI 수익률. 음수면 시장 대비 underperform.</div>` ;
el . innerHTML = h ;
} catch ( e ){ el . innerHTML = `<div class="empty">오류: ${ esc ( String ( e )) } </div>` }
}
// ─────────────────────────────────────────────────────────────
// 🌎 미증시 동조
// ─────────────────────────────────────────────────────────────
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>` ;
try {
const [ etfs , pairs , signals ] = await Promise . all ([
api ( '/usmarket/etfs' ), api ( '/usmarket/pairs' ), api ( '/usmarket/signals' )
]);
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>` ;
if ( Array . isArray ( signals ) && signals . length ){
h += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:8px">` ;
2026-05-20 22:25:44 +09:00
// 절대값 큰 순으로 정렬
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 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' ;
2026-05-20 22:09:32 +09:00
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">
2026-05-20 22:25:44 +09:00
<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>
</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>
2026-05-20 22:09:32 +09:00
</div>` ;
});
h += `</div>` ;
} else h += `<div class="empty">시그널 없음 (아직 계산 전)</div>` ;
h += `</div>` ;
el . innerHTML = h ;
} catch ( e ){ el . innerHTML = `<div class="empty">오류: ${ esc ( String ( e )) } </div>` }
}
// ─────────────────────────────────────────────────────────────
// 🌐 매크로 + 섹터 집중도
// ─────────────────────────────────────────────────────────────
async function renderMacro (){
const el = document . getElementById ( 'macro' );
el . innerHTML = `<div style="font-size:12px;color:#546E7A;margin-bottom:14px">한국 매크로 지표 (ECOS) + 추천 종목 섹터 집중도 + 학습 가중치</div><div style="text-align:center;color:#546E7A;padding:30px">불러오는 중...</div>` ;
try {
const [ m , conc , w ] = await Promise . all ([
api ( '/macro/ecos' ), api ( '/sector-concentration' ), api ( '/weights' )
]);
let h = `<h3 style="color:#90A4AE;font-size:13px;margin-bottom:10px">한국 매크로</h3>` ;
if ( m && ! m . error ){
h += `<div class="cards">` ;
const items = [
[ 'KOSPI' , m . kospi , '' ],
[ 'USD/KRW' , m . usdkrw , '원' ],
[ '국고채 3년' , m . kor_3y , '%' ],
[ '국고채 10년' , m . kor_10y , '%' ],
];
items . forEach (([ label , obj , unit ])=>{
if ( ! obj ){ h += `<div class="card"><div class="label"> ${ label } </div><div class="val" style="font-size:24px;color:#546E7A">-</div></div>` ; return ;}
h += `<div class="card"><div class="label"> ${ label } </div><div class="val" style="font-size:28px;color:#69F0AE"> ${ ( + obj . value ). toLocaleString ( 'ko-KR' ,{ maximumFractionDigits : 3 } )}<span style="font-size:14px;color:#90A4AE"> ${ unit } </span></div><div class="sub"> ${ obj . date || '' } </div></div>` ;
});
h += `</div>` ;
} else h += `<div class="empty">매크로 데이터 없음 (aux-signal 미실행?)</div>` ;
// 섹터 집중도
h += `<h3 style="color:#90A4AE;font-size:13px;margin:20px 0 10px">추천 종목 섹터 집중도</h3>` ;
if ( conc && ! conc . error && Array . isArray ( conc . sectors )){
if ( Array . isArray ( conc . warnings ) && conc . warnings . length ){
h += `<div style="background:rgba(255,82,82,0.08);border:1px solid rgba(255,82,82,0.25);border-radius:10px;padding:12px 14px;margin-bottom:12px;color:#FF8A80;font-size:12px">⚠ ${ conc . warnings . map ( esc ). join ( ' / ' ) } </div>` ;
}
h += `<div class="panel"><table style="width:100%;font-size:12px;border-collapse:separate;border-spacing:1px">
<thead><tr style="color:#90A4AE"><th style="text-align:left;padding:6px">섹터</th><th>종목 수</th><th>비중</th><th>평균 점수</th><th style="width:40%">분포</th></tr></thead><tbody>` ;
conc . sectors . slice ( 0 , 20 ). forEach ( s =>{
const pct = + s . share_pct ;
const c = pct >= 30 ? '#FF8A80' : pct >= 15 ? '#FFD740' : '#69F0AE' ;
h += `<tr><td style="padding:6px 8px;color:#CFD8DC"> ${ esc ( s . sector || '-' ) } </td>
<td style="text-align:center;color:#90A4AE"> ${ s . count } </td>
<td style="text-align:center;color: ${ c } ;font-family:'JetBrains Mono',monospace;font-weight:700"> ${ pct . toFixed ( 1 ) } %</td>
<td style="text-align:center;color:#90A4AE;font-family:'JetBrains Mono',monospace"> ${ ( + s . avg_score ). toFixed ( 1 ) } </td>
<td><div style="background:rgba(255,255,255,0.05);border-radius:3px;height:6px;overflow:hidden"><div style="background: ${ c } ;height:100%;width: ${ Math . min ( pct , 100 ) } %"></div></div></td>
</tr>` ;
});
h += `</tbody></table></div>` ;
} else h += `<div class="empty">섹터 집중도 데이터 없음</div>` ;
// 가중치
h += `<h3 style="color:#90A4AE;font-size:13px;margin:20px 0 10px">10공식 학습 가중치</h3>` ;
if ( w && w . weights ){
const ws = Object . entries ( w . weights ). sort (( a , b )=> b [ 1 ] - a [ 1 ]);
const maxW = Math . max (... ws . map ( x => + x [ 1 ]), 1 );
h += `<div class="panel"><div style="font-size:11px;color:#546E7A;margin-bottom:10px">학습일 ${ w . config_date || '-' } · 백테스트 ${ w . period_days || 90 } 일 · 표본 ${ w . sample_size || 0 } </div>` ;
ws . forEach (([ k , v ])=>{
const ratio = ( + v / maxW * 100 );
h += `<div style="display:flex;align-items:center;gap:10px;padding:6px 0">
<div style="width:80px;color:#CFD8DC;font-size:12px"><b> ${ k } </b></div>
<div style="flex:1;background:rgba(255,255,255,0.05);border-radius:3px;height:8px;overflow:hidden"><div style="background:#69F0AE;height:100%;width: ${ ratio } %"></div></div>
<div style="width:50px;text-align:right;font-family:'JetBrains Mono',monospace;color:#69F0AE;font-size:12px"> ${ ( + v ). toFixed ( 2 ) } </div>
</div>` ;
});
h += `</div>` ;
} else h += `<div class="empty">학습된 가중치 없음 (POST /learn-weights 호출 필요)</div>` ;
el . innerHTML = h ;
} catch ( e ){ el . innerHTML = `<div class="empty">오류: ${ esc ( String ( e )) } </div>` }
}
2026-05-20 21:33:56 +09:00
</ script >
<!-- 종목 차트 모달 -->
< div id = "stock-chart-modal" style = "display:none;position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:1500;align-items:center;justify-content:center;padding:20px" onclick = "if(event.target===this)hideStockChart()" >
< div style = "background:#141B22;border:1px solid rgba(0,230,118,0.18);border-radius:16px;padding:24px;width:1100px;max-width:100%;max-height:92vh;overflow:auto;box-shadow:0 12px 60px rgba(0,0,0,0.7)" >
< div style = "display:flex;justify-content:space-between;align-items:center;margin-bottom:14px" >
< div >
< div id = "sc-title" style = "font-size:18px;font-weight:700;color:#E0E0E0" > 종목 차트</ div >
< div id = "sc-subtitle" style = "font-size:11px;color:#546E7A;font-family:'JetBrains Mono',monospace;margin-top:2px" ></ div >
</ div >
< button onclick = "hideStockChart()" style = "background:none;border:none;color:#78909C;font-size:22px;cursor:pointer" > × </ button >
</ div >
< div id = "sc-info" style = "display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:8px;margin-bottom:12px" ></ div >
<!-- 시간프레임 토글 -->
< div style = "display:flex;gap:6px;margin-bottom:8px;flex-wrap:wrap" >
< button class = "tf-btn active" data-tf = "day" onclick = "changeTimeframe('day')" style = "background:rgba(0,230,118,0.15);border:1px solid rgba(0,230,118,0.3);color:#00E676;border-radius:6px;padding:5px 12px;font-size:11px;cursor:pointer" > 일봉</ button >
< button class = "tf-btn" data-tf = "60" onclick = "changeTimeframe('60')" style = "background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);color:#90A4AE;border-radius:6px;padding:5px 12px;font-size:11px;cursor:pointer" > 60분</ button >
< button class = "tf-btn" data-tf = "30" onclick = "changeTimeframe('30')" style = "background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);color:#90A4AE;border-radius:6px;padding:5px 12px;font-size:11px;cursor:pointer" > 30분</ button >
< button class = "tf-btn" data-tf = "15" onclick = "changeTimeframe('15')" style = "background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);color:#90A4AE;border-radius:6px;padding:5px 12px;font-size:11px;cursor:pointer" > 15분</ button >
< button class = "tf-btn" data-tf = "5" onclick = "changeTimeframe('5')" style = "background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);color:#90A4AE;border-radius:6px;padding:5px 12px;font-size:11px;cursor:pointer" > 5분</ button >
< button class = "tf-btn" data-tf = "1" onclick = "changeTimeframe('1')" style = "background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);color:#90A4AE;border-radius:6px;padding:5px 12px;font-size:11px;cursor:pointer" > 1분</ button >
</ div >
<!-- 차트 + 호가 좌우 -->
< div style = "display:grid;grid-template-columns:1fr 220px;gap:10px;align-items:start" >
< div >
< div style = "position:relative;height:340px;background:rgba(0,0,0,0.2);border-radius:10px;padding:8px" >< canvas id = "sc-candle" ></ canvas ></ div >
< div style = "position:relative;height:110px;margin-top:8px;background:rgba(0,0,0,0.2);border-radius:10px;padding:8px" >< canvas id = "sc-volume" ></ canvas ></ div >
< div id = "sc-flow-wrap" style = "position:relative;height:110px;margin-top:8px;background:rgba(0,0,0,0.2);border-radius:10px;padding:8px" >< canvas id = "sc-flow" ></ canvas ></ div >
</ div >
< div id = "sc-orderbook" style = "background:rgba(0,0,0,0.25);border:1px solid rgba(255,255,255,0.06);border-radius:10px;padding:10px;font-size:11px;font-family:'JetBrains Mono',monospace" ></ div >
</ div >
< div id = "sc-fundamentals" style = "margin-top:14px;font-size:12px;color:#90A4AE" ></ div >
</ div >
</ div >
<!-- 관리자 회원관리 모달 -->
< div id = "admin-modal" style = "display:none;position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:2000;align-items:center;justify-content:center;padding:20px" >
< div style = "background:#141B22;border:1px solid rgba(0,230,118,0.2);border-radius:16px;padding:28px;width:680px;max-width:100%;max-height:84vh;overflow:auto;box-shadow:0 8px 40px rgba(0,0,0,0.6)" >
< div style = "display:flex;justify-content:space-between;align-items:center;margin-bottom:20px" >
< h2 style = "font-size:18px;color:#E0E0E0;margin:0" > 👥 회원 관리</ h2 >
< button onclick = "hideAdminModal()" style = "background:none;border:none;color:#78909C;font-size:22px;cursor:pointer" > × </ button >
</ div >
< div id = "admin-users-list" style = "font-size:13px;color:#90A4AE" > 불러오는 중...</ div >
</ div >
</ div >
</ body >
</ html >