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