feat: 핫종목 렌즈 + 뉴스 파서 견고화 + 텔레그램 알림 2회 축소
- news-collector: EXAONE JSON 파서 견고화(max_tokens 800 + 관대 파싱 → 파싱실패율 9.3%→3.3%), 평일 18:30 종목뉴스 스위프(중형주 신선도 사각지대 해소), /process/raw reprocess 플래그, 파싱실패→Gemini 폴백 라우팅(일일캡 1→50 상수화) - score-engine: /hot 모멘텀 렌즈(뉴스+거래량, 백테스트로 가격 제외) + /hot/backtest, 텔레그램 정기 브리핑 2회로 축소(중복 send_briefing 자동스케줄 제거) - dashboard-api: /api/hot 프록시 + 🔥 지금뜨는 탭 - telegram-bot: /hot 명령 + 한글(뜨는/핫/급등) 라우팅 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+531
-68
@@ -7,7 +7,7 @@
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;800;900&family=JetBrains+Mono:wght@400;600;700&display=swap');
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:#0D1117;color:#E0E0E0;font-family:'Noto Sans KR',sans-serif;padding:0 24px 40px}
|
||||
body{background:#0D1117;color:#E0E0E0;font-family:'Noto Sans KR',sans-serif;padding:0}
|
||||
::-webkit-scrollbar{width:6px}::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.1);border-radius:3px}
|
||||
.wrap{max-width:1400px;margin:0 auto}
|
||||
.header{display:flex;justify-content:space-between;align-items:center;padding:20px 0;border-bottom:1px solid rgba(255,255,255,0.06);margin-bottom:24px}
|
||||
@@ -15,9 +15,26 @@ body{background:#0D1117;color:#E0E0E0;font-family:'Noto Sans KR',sans-serif;padd
|
||||
.header .sub{font-size:12px;color:#546E7A;margin-top:4px}
|
||||
.header .time{font-size:13px;color:#607D8B;font-family:'JetBrains Mono',monospace}
|
||||
.btn{background:rgba(0,230,118,0.1);border:1px solid rgba(0,230,118,0.2);border-radius:8px;padding:8px 16px;color:#00E676;font-size:12px;cursor:pointer;font-weight:600}
|
||||
.tabs{display:flex;gap:4px;margin-bottom:24px}
|
||||
.tab{background:transparent;border:1px solid transparent;border-radius:8px;padding:8px 16px;color:#607D8B;font-size:13px;font-weight:500;cursor:pointer}
|
||||
.sidebar{position:fixed;left:0;top:0;bottom:0;width:212px;background:#0A0E14;border-right:1px solid rgba(255,255,255,0.06);padding:18px 12px;overflow-y:auto;z-index:200;transition:transform .25s ease}
|
||||
.brand{font-size:16px;font-weight:800;letter-spacing:-0.5px;padding:4px 10px 16px;display:flex;align-items:center;gap:7px;color:#E0E0E0}
|
||||
.brand span{color:#00E676}
|
||||
.tabs{display:flex;flex-direction:column;gap:2px;margin:0}
|
||||
.tab{display:flex;align-items:center;gap:8px;width:100%;text-align:left;background:transparent;border:1px solid transparent;border-radius:8px;padding:10px 12px;color:#90A4AE;font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;transition:background .15s}
|
||||
.tab:hover{background:rgba(255,255,255,0.04);color:#CFD8DC}
|
||||
.tab.active{background:rgba(0,230,118,0.1);border-color:rgba(0,230,118,0.2);color:#00E676;font-weight:700}
|
||||
.main{margin-left:212px;padding:0 24px 40px;min-height:100vh}
|
||||
.hamburger{display:none;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);border-radius:8px;width:40px;height:40px;color:#CFD8DC;font-size:18px;cursor:pointer;flex-shrink:0;align-items:center;justify-content:center}
|
||||
.sidebar-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:150}
|
||||
@media(max-width:860px){
|
||||
.sidebar{transform:translateX(-100%);width:248px;box-shadow:6px 0 28px rgba(0,0,0,0.55)}
|
||||
.sidebar.open{transform:translateX(0)}
|
||||
.main{margin-left:0;padding:0 14px 40px}
|
||||
.hamburger{display:flex}
|
||||
.sidebar-overlay.show{display:block}
|
||||
.header{flex-wrap:wrap}
|
||||
.header h1{font-size:17px}
|
||||
.header .sub{display:none}
|
||||
}
|
||||
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:24px}
|
||||
.card{background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06);border-radius:16px;padding:24px 20px}
|
||||
.card .label{font-size:13px;color:#90A4AE;letter-spacing:1px;margin-bottom:8px}
|
||||
@@ -25,6 +42,7 @@ body{background:#0D1117;color:#E0E0E0;font-family:'Noto Sans KR',sans-serif;padd
|
||||
.card .sub{font-size:12px;color:#607D8B;margin-top:8px}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||
@media(max-width:900px){.grid2{grid-template-columns:1fr}}
|
||||
@media(max-width:680px){.usbrief-grid{grid-template-columns:1fr !important}}
|
||||
.panel{background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.05);border-radius:16px;padding:20px;max-height:600px;overflow:auto}
|
||||
.panel h3{font-size:14px;font-weight:700;color:#90A4AE;margin-bottom:16px}
|
||||
.rank-item{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid rgba(255,255,255,0.03)}
|
||||
@@ -108,7 +126,7 @@ tr[data-stock]:hover{background:rgba(0,230,118,0.06) !important}
|
||||
body.auth-required .wrap, body.auth-required #chat-btn, body.auth-required #chat-panel { display:none !important; }
|
||||
body:not(.auth-required) #login-screen { display:none !important; }
|
||||
|
||||
#login-screen{position:fixed;inset:0;background:#0E141A;display:flex;align-items:center;justify-content:center;padding:20px;z-index:100;overflow-y:auto}
|
||||
#login-screen{position:fixed;inset:0;background:#0E141A;display:flex;align-items:center;justify-content:center;padding:20px;z-index:300;overflow-y:auto}
|
||||
.login-card{background:#141B22;border:1px solid rgba(0,230,118,0.18);border-radius:20px;padding:40px;width:420px;max-width:100%;box-shadow:0 12px 60px rgba(0,0,0,0.6)}
|
||||
.login-logo{font-size:24px;font-weight:800;color:#E0E0E0;margin-bottom:6px;letter-spacing:0.5px}
|
||||
.login-logo span{color:#00E676}
|
||||
@@ -174,8 +192,33 @@ body:not(.auth-required) #login-screen { display:none !important; }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="brand"><span>◆</span> Trading AI</div>
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="showTab('overview',this)">📊 종합</button>
|
||||
<button class="tab" onclick="showTab('performance',this)">🎯 추천성과</button>
|
||||
<button class="tab" onclick="showTab('signals',this)">💰 매매시그널</button>
|
||||
<button class="tab" onclick="showTab('surge',this)">🚀 거래량급증</button>
|
||||
<button class="tab" onclick="showTab('hot',this)">🔥 지금뜨는</button>
|
||||
<button class="tab" onclick="showTab('portfolio',this)">💼 포트폴리오</button>
|
||||
<button class="tab" onclick="showTab('mystock',this)">📂 내 종목 분석</button>
|
||||
<button class="tab" onclick="showTab('recommend',this)">🏆 추천종목</button>
|
||||
<button class="tab" onclick="showTab('news',this)">📰 뉴스피드</button>
|
||||
<button class="tab" onclick="showTab('supplydemand',this)">💹 수급</button>
|
||||
<button class="tab" onclick="showTab('alerts',this)">🚨 알림</button>
|
||||
<button class="tab" onclick="showTab('formulas',this)">🧮 10공식</button>
|
||||
<button class="tab" onclick="showTab('backtest',this)">📈 백테스트</button>
|
||||
<button class="tab" onclick="showTab('usmarket',this)">🌎 미증시</button>
|
||||
<button class="tab" onclick="showTab('macro',this)">🌐 매크로·섹터</button>
|
||||
<button class="tab" onclick="showTab('glossary',this)">📖 용어설명</button>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="sidebar-overlay" id="sbOverlay" onclick="toggleSidebar(false)"></div>
|
||||
|
||||
<div class="main">
|
||||
<div class="wrap">
|
||||
<div class="header">
|
||||
<button class="hamburger" onclick="toggleSidebar()">☰</button>
|
||||
<div><h1><span>◆</span> Trading AI Dashboard</h1><div class="sub">뉴스 + DART 공시 기반 종목 분석 시스템</div></div>
|
||||
<div style="display:flex;align-items:center;gap:16px">
|
||||
<div class="time" id="clock"></div>
|
||||
@@ -190,26 +233,11 @@ body:not(.auth-required) #login-screen { display:none !important; }
|
||||
<button onclick="doSearch()" style="background:rgba(0,230,118,0.1);border:1px solid rgba(0,230,118,0.2);border-radius:8px;padding:10px 20px;color:#00E676;font-size:13px;cursor:pointer;font-weight:600;white-space:nowrap">🔍 검색</button>
|
||||
</div>
|
||||
<div id="searchResults" style="display:none;margin-bottom:24px"></div>
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="showTab('overview',this)">📊 종합</button>
|
||||
<button class="tab" onclick="showTab('performance',this)">🎯 추천성과</button>
|
||||
<button class="tab" onclick="showTab('signals',this)">💰 매매시그널</button>
|
||||
<button class="tab" onclick="showTab('surge',this)">🚀 거래량급증</button>
|
||||
<button class="tab" onclick="showTab('portfolio',this)">💼 포트폴리오</button>
|
||||
<button class="tab" onclick="showTab('mystock',this)">📂 내 종목 분석</button>
|
||||
<button class="tab" onclick="showTab('recommend',this)">🏆 추천종목</button>
|
||||
<button class="tab" onclick="showTab('news',this)">📰 뉴스피드</button>
|
||||
<button class="tab" onclick="showTab('supplydemand',this)">💹 수급</button>
|
||||
<button class="tab" onclick="showTab('alerts',this)">🚨 알림</button>
|
||||
<button class="tab" onclick="showTab('formulas',this)">🧮 10공식</button>
|
||||
<button class="tab" onclick="showTab('backtest',this)">📈 백테스트</button>
|
||||
<button class="tab" onclick="showTab('usmarket',this)">🌎 미증시</button>
|
||||
<button class="tab" onclick="showTab('macro',this)">🌐 매크로·섹터</button>
|
||||
</div>
|
||||
<div id="overview"></div>
|
||||
<div id="performance" style="display:none"></div>
|
||||
<div id="signals" style="display:none"></div>
|
||||
<div id="surge" style="display:none"></div>
|
||||
<div id="hot" style="display:none"></div>
|
||||
<div id="portfolio" style="display:none"></div>
|
||||
<div id="mystock" style="display:none"></div>
|
||||
<div id="recommend" style="display:none"></div>
|
||||
@@ -220,8 +248,10 @@ body:not(.auth-required) #login-screen { display:none !important; }
|
||||
<div id="backtest" style="display:none"></div>
|
||||
<div id="usmarket" style="display:none"></div>
|
||||
<div id="macro" style="display:none"></div>
|
||||
<div id="glossary" style="display:none"></div>
|
||||
<div class="footer">Trading AI System • 뉴스/공시 기반 자동 분석 • 투자 판단의 참고 자료로만 활용하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const API = window.location.origin + '/api';
|
||||
const SCOL = {호재:'#00E676',악재:'#FF5252',중립:'#78909C'};
|
||||
@@ -231,20 +261,117 @@ const RCOL = {강력매수:'#00E676',매수관심:'#69F0AE',관망:'#78909C',매
|
||||
function ftime(ts){if(!ts)return'-';const d=new Date(ts),m=Math.floor((Date.now()-d)/60000);if(m<60)return m+'분 전';if(m<1440)return Math.floor(m/60)+'시간 전';return d.toLocaleDateString('ko-KR',{month:'short',day:'numeric'})}
|
||||
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
|
||||
function toggleSidebar(force){
|
||||
const sb=document.getElementById('sidebar'), ov=document.getElementById('sbOverlay');
|
||||
if(!sb)return;
|
||||
const open = force===undefined ? !sb.classList.contains('open') : !!force;
|
||||
sb.classList.toggle('open',open);
|
||||
if(ov)ov.classList.toggle('show',open);
|
||||
}
|
||||
|
||||
function showTab(id,el){
|
||||
['overview','performance','signals','surge','portfolio','mystock','recommend','news','supplydemand','alerts','formulas','backtest','usmarket','macro'].forEach(t=>{
|
||||
['overview','performance','signals','surge','hot','portfolio','mystock','recommend','news','supplydemand','alerts','formulas','backtest','usmarket','macro','glossary'].forEach(t=>{
|
||||
document.getElementById(t).style.display=t===id?'block':'none'});
|
||||
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
|
||||
if(el)el.classList.add('active');
|
||||
toggleSidebar(false);
|
||||
if(id==='mystock'){ if(isLoggedIn()) loadPortfolio().then(()=>renderMyStock()); else renderMyStock(); }
|
||||
if(id==='portfolio') renderPortfolio();
|
||||
if(id==='performance') renderPerformance();
|
||||
if(id==='supplydemand') renderSupplyDemand();
|
||||
if(id==='surge') renderVolumeSurge();
|
||||
if(id==='hot') renderHot();
|
||||
if(id==='formulas') renderFormulas();
|
||||
if(id==='backtest') renderBacktest();
|
||||
if(id==='usmarket') renderUsMarket();
|
||||
if(id==='macro') renderMacro();
|
||||
if(id==='glossary') renderGlossary();
|
||||
}
|
||||
|
||||
// ── 용어 설명 ────────────────────────────────────────────────
|
||||
const GLOSSARY=[
|
||||
{t:'📊 차트 보는 법',items:[
|
||||
['캔들(봉)','하루 주가 움직임을 막대 하나로 표시. 끝가가 시작가보다 <b>높으면 빨강(양봉=올랐다)</b>, <b>낮으면 파랑(음봉=내렸다)</b>. 위·아래로 삐져나온 가는 선은 그날의 최고가·최저가.'],
|
||||
['거래량','그날 사고판 주식 수. 많을수록 시장 관심이 크다는 뜻. 거래량 없이 슬금슬금 오르는 상승은 힘이 약하다.'],
|
||||
['시가/종가/고가/저가','시가=장 시작 가격, 종가=장 끝 가격, 고가=그날 최고가, 저가=그날 최저가.'],
|
||||
]},
|
||||
{t:'📈 이동평균선 (MA = Moving Average)',items:[
|
||||
['MA5','최근 <b>5일</b> 종가의 평균선. 약 1주일치 단기 흐름.'],
|
||||
['MA20','최근 <b>20일</b> 평균선. 약 한 달 흐름이라 "생명선"이라 부른다. 주가가 이 위면 단기 분위기 양호.'],
|
||||
['MA60','최근 <b>60일</b> 평균선. 약 3개월, 중기 추세.'],
|
||||
['MA120','최근 <b>120일</b> 평균선. 약 6개월, 장기 추세라 "경기선"이라 부른다.'],
|
||||
['정배열 / 역배열','MA5>MA20>MA60 순으로 짧은 선이 위에 있으면 <b>정배열=상승 추세</b>. 거꾸로면 역배열=하락 추세.'],
|
||||
['골든크로스 / 데드크로스','짧은 선이 긴 선을 아래→위로 뚫으면 <b>골든크로스(매수 신호)</b>, 위→아래로 뚫으면 데드크로스(매도 신호).'],
|
||||
]},
|
||||
{t:'🎯 기술적 지표 (매매 타이밍)',items:[
|
||||
['RSI','0~100 숫자. <b>70 이상이면 과열</b>(너무 많이 사서 비쌈), <b>30 이하면 과매도</b>(너무 많이 팔려 쌈). 되돌림 가능성을 본다.'],
|
||||
['MACD','단기·장기 이동평균의 차이로 추세 전환을 잡는 지표. 0선 위로 올라오면 상승 힘이 붙는 것.'],
|
||||
['볼린저밴드','주가가 보통 움직이는 범위를 상단·중간·하단 띠로 표시. 상단에 닿으면 단기 비쌈, 하단이면 쌈.'],
|
||||
['스토캐스틱','RSI처럼 과열/과매도를 보는 지표. 20 이하 과매도, 80 이상 과열.'],
|
||||
['OBV','거래량으로 돈이 들어오는지(매집)·나가는지(분산)를 추적. 주가보다 먼저 움직이기도 한다.'],
|
||||
['VWAP','거래량 가중 평균가. 기관 투자자가 "본전" 기준으로 삼는 가격.'],
|
||||
['일목균형표','구름대(저항·지지 구간)로 추세를 보는 일본식 종합 지표.'],
|
||||
]},
|
||||
{t:'💰 재무 지표 (회사가 돈을 잘 버나)',items:[
|
||||
['ROE','자기자본이익률. 주주 돈 100원으로 1년에 얼마 벌었나. <b>높을수록 좋고 15% 이상이면 우수</b>. 워런 버핏이 가장 중시하는 숫자.'],
|
||||
['영업이익률','매출 100원 중 본업으로 남긴 이익 비율. 높을수록 사업 경쟁력이 강하다.'],
|
||||
['순이익률','매출에서 모든 비용·이자·세금을 다 빼고 최종으로 남은 비율.'],
|
||||
['부채비율','자기자본 대비 빚의 비율. <b>100% 이하면 안전</b>, 200%를 넘으면 위험 신호.'],
|
||||
['FCF비율','잉여현금흐름(Free Cash Flow). 회사가 벌어서 쓸 거 다 쓰고 <b>실제로 남긴 진짜 현금</b>. 회계상 이익보다 정직한 숫자라 플러스면 건강.'],
|
||||
['매출성장률','작년 대비 올해 매출이 얼마나 늘었나. 회사가 커가고 있는지를 본다.'],
|
||||
]},
|
||||
{t:'🏷️ 가치 지표 (주가가 싼가 비싼가)',items:[
|
||||
['PER','주가 ÷ 주당순이익. 회사 이익 대비 주가가 몇 배인가. <b>낮을수록 싸다</b>. 보통 10배 이하면 저평가 경향.'],
|
||||
['PBR','주가 ÷ 주당순자산. <b>1배 미만이면 회사가 가진 자산보다 주가가 싸게</b> 거래되는 것.'],
|
||||
['EPS / BPS','EPS=주당순이익(1주가 1년에 번 돈). BPS=주당순자산(회사 청산 시 1주가 받을 몫).'],
|
||||
['시가총액','주가 × 총 주식 수 = 회사 전체의 시장 몸값.'],
|
||||
['배당수익률','주가 대비 1년간 받는 배당금 비율. 은행 이자처럼 보면 된다.'],
|
||||
]},
|
||||
{t:'🧮 이 시스템의 점수',items:[
|
||||
['종합점수 (-100~100)','재무·뉴스·기술·공시·수급을 다 합친 최종 점수. <b>높을수록 매수 매력</b>이 크다.'],
|
||||
['펀더멘털 점수','회사의 본질 가치 점수 (ROE·이익률·부채·현금흐름 등 기반).'],
|
||||
['기술점수','차트·기술 지표 기반의 단기 매매 타이밍 점수.'],
|
||||
['뉴스점수','최근 7일 뉴스의 호재/악재를 종합한 점수.'],
|
||||
['10공식 보팅','매직포뮬러·F-Score 등 학계·실전 검증된 10개 투자 공식이 각자 매수/매도를 투표한 결과.'],
|
||||
['추천등급','강력매수 > 매수관심 > 관망 > 매도관심 > 강력매도 순. 점수와 공식 투표를 함께 본다.'],
|
||||
]},
|
||||
{t:'🏛️ 시장 제도·거래 용어',items:[
|
||||
['공매도','내가 갖고 있지도 않은 주식을 <b>빌려서 먼저 판 뒤</b>, 값이 떨어지면 싸게 되사서 갚아 차익을 먹는 기법. 주가 <b>하락에 베팅</b>하는 것. 공매도 물량이 많으면 하락 압력 신호로 본다 (이 시스템의 공매도 점수에 반영).'],
|
||||
['사이드카','선물 가격이 ±5~6% 급등락하면 <b>프로그램 매매(컴퓨터 자동매매)를 5분간 정지</b>시키는 장치. 시장 쏠림을 잠깐 식히는 가벼운 브레이크. 5분 후 자동 해제.'],
|
||||
['서킷브레이커','사이드카보다 센 비상정지. 지수가 8%·15%·20% 급락하면 <b>주식 거래 전체를 20분간 멈춤</b>. 패닉 매도를 막는 장치.'],
|
||||
['VI (변동성완화장치)','개별 종목이 갑자기 급등락하면 2~3분간 단일가 매매로 바꿔 진정시키는 장치. 사이드카의 개별 종목 버전.'],
|
||||
['상한가 / 하한가','하루에 주가가 오를·내릴 수 있는 한계. 한국은 전일 종가 대비 <b>±30%</b>.'],
|
||||
['ISA','개인종합자산관리계좌. 주식·펀드·예금을 한 계좌에 담아 굴리는 <b>절세 계좌</b>. 수익 일정액까지 세금 면제·저율 과세. 의무 보유기간(보통 3년) 있음. "세금 혜택 받는 장기투자용 계좌".'],
|
||||
['배당','회사가 번 이익의 일부를 주주에게 나눠주는 돈. 1년에 한 번(결산배당)이 보통이고 분기마다 주는 회사도 있다.'],
|
||||
]},
|
||||
{t:'💡 투자 스타일·철학',items:[
|
||||
['가치투자','회사의 본질가치보다 <b>싸게 거래되는 주식</b>을 사서 제값을 찾아갈 때까지 기다리는 방식. 대표: 벤저민 그레이엄·워런 버핏. 이 시스템의 기본 관점.'],
|
||||
['성장투자','지금 좀 비싸도 <b>매출·이익이 빠르게 크는 회사</b>면 산다. 미래 성장이 현재 가격을 정당화한다는 관점. 대표: 필립 피셔·피터 린치.'],
|
||||
['모멘텀투자','<b>오르는 주식이 더 오른다</b>는 경험칙에 기대 추세에 올라타는 방식(추세추종). 대표: AQR·제시 리버모어. 이 시스템의 12-1 모멘텀 공식이 여기 해당.'],
|
||||
['퀀트투자','감정을 배제하고 <b>데이터·통계로 패턴을 찾아 기계적으로</b> 매매. 대표: 짐 사이먼스(르네상스). 이 시스템의 10공식 엔진 자체가 퀀트 방식.'],
|
||||
['매크로투자','개별 종목보다 <b>금리·환율·경기 같은 큰 흐름</b>에 베팅. 대표: 조지 소로스·레이 달리오.'],
|
||||
['역발상투자','남들이 공포에 팔 때 사고 열광할 때 판다 — <b>대중과 반대로</b>. 대표: 존 템플턴.'],
|
||||
['인덱스투자','시장을 이기기 어려우니 <b>지수 전체를 싸게 사서 장기 보유</b>(패시브). 대표: 존 보글. 버핏도 일반인에겐 이걸 권한다.'],
|
||||
['기술적분석','재무가 아니라 <b>차트·거래량·지표로 매매 타이밍</b>을 잡는 방식. 가격이 모든 정보를 반영한다고 본다.'],
|
||||
['배당투자','<b>배당을 꾸준히·많이 주는 회사</b>로 안정적인 현금흐름을 추구. 주가 등락보다 배당 수입을 중시.'],
|
||||
['멀티팩터','가치·성장·모멘텀·퀄리티 등 <b>여러 방식을 데이터로 섞는</b> 현대 퀀트 접근. 한 스타일도 항상 이기진 못하기 때문 — <b>이 시스템이 실제로 쓰는 방식.</b>'],
|
||||
]},
|
||||
];
|
||||
function renderGlossary(){
|
||||
const el=document.getElementById('glossary');
|
||||
let h=`<div style="font-size:13px;color:#90A4AE;margin-bottom:16px;line-height:1.6">
|
||||
📖 주식 표·차트에 나오는 용어를 <b style="color:#00E676">쉬운 말</b>로 풀이했습니다. 모르는 단어가 보이면 여기서 찾아보세요.</div>`;
|
||||
GLOSSARY.forEach(g=>{
|
||||
h+=`<div class="panel" style="max-height:none;margin-bottom:14px"><h3 style="margin-bottom:10px">${g.t}</h3>`;
|
||||
g.items.forEach(it=>{
|
||||
h+=`<div style="display:flex;gap:14px;padding:11px 2px;border-bottom:1px solid rgba(255,255,255,0.05)">
|
||||
<div style="flex:0 0 96px;color:#00E676;font-weight:700;font-size:13px">${it[0]}</div>
|
||||
<div style="flex:1;color:#B0BEC5;font-size:12.5px;line-height:1.65">${it[1]}</div>
|
||||
</div>`;
|
||||
});
|
||||
h+=`</div>`;
|
||||
});
|
||||
el.innerHTML=h;
|
||||
}
|
||||
|
||||
async function renderVolumeSurge(){
|
||||
@@ -281,6 +408,41 @@ async function renderVolumeSurge(){
|
||||
}
|
||||
}
|
||||
|
||||
async function renderHot(){
|
||||
const el=document.getElementById('hot');
|
||||
el.innerHTML=`<div style="text-align:center;color:#546E7A;padding:30px">불러오는 중...</div>`;
|
||||
try{
|
||||
const list=await api('/hot')||[];
|
||||
if(!list.length){el.innerHTML='<div class="empty">지금 뜨는 종목 없음</div>';return;}
|
||||
let h=`<div style="font-size:12px;color:#FFB74D;background:rgba(255,152,0,0.08);border:1px solid rgba(255,152,0,0.2);border-radius:8px;padding:10px 12px;margin-bottom:14px">
|
||||
🔥 <b>지금 뜨는 종목</b> — 최근 뉴스 호재 + 거래량 급증 기준. <b style="color:#FF8A65">※ 관찰용입니다.</b> 백테스트상 핫종목은 5~10일 들면 시장에 지므로(되돌림) 매수신호가 아니라 "지금 뭐가 움직이나" 보는 창으로만 쓰세요.</div>`;
|
||||
h+=`<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:10px">`;
|
||||
list.forEach((r,i)=>{
|
||||
const recoC=r.value_reco==='강력매수'?'#00E676':r.value_reco==='매수관심'?'#69F0AE':(r.value_reco||'').includes('매도')?'#FF5252':'#90A4AE';
|
||||
const p5=r.price_5d_pct||0, p5C=p5>=0?'#00E676':'#FF5252';
|
||||
h+=`<div data-stock="${r.stock_code}" data-stock-name="${esc(r.stock_name)}" style="cursor:pointer;background:rgba(255,255,255,0.03);border:1px solid rgba(255,152,0,0.12);border-radius:10px;padding:12px 14px;transition:background .15s" onmouseover="this.style.background='rgba(255,255,255,0.06)'" onmouseout="this.style.background='rgba(255,255,255,0.03)'">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px">
|
||||
<div>
|
||||
<div style="font-size:13px;font-weight:700;color:#E0E0E0">${i+1}. ${esc(r.stock_name||r.stock_code)}</div>
|
||||
<div style="font-size:10px;color:#546E7A;font-family:'JetBrains Mono',monospace">${r.stock_code}</div>
|
||||
</div>
|
||||
<div style="font-size:15px;color:#FFB74D;font-weight:700">🔥 ${(r.hot_score||0).toFixed(0)}</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;font-size:11px;color:#90A4AE">
|
||||
${r.pos_news_3d?`<span>호재 ${r.pos_news_3d}건</span>`:''}
|
||||
<span>5일 <b style="color:${p5C}">${p5>=0?'+':''}${p5.toFixed(1)}%</b></span>
|
||||
${r.vol_ratio>=1.5?`<span>거래량 ${(r.vol_ratio||0).toFixed(1)}배</span>`:''}
|
||||
</div>
|
||||
<div style="margin-top:6px;font-size:11px;color:#78909C">가치판단: <b style="color:${recoC}">${r.value_reco||'-'}</b>${r.value_score!=null?` (${r.value_score}점)`:''}</div>
|
||||
</div>`;
|
||||
});
|
||||
h+=`</div>`;
|
||||
el.innerHTML=h;
|
||||
}catch(e){
|
||||
el.innerHTML='<div class="empty">로드 실패: '+esc(e.message)+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function api(path){try{const r=await fetch(API+path);return await r.json()}catch(e){return null}}
|
||||
|
||||
async function loadAll(){
|
||||
@@ -1012,11 +1174,9 @@ async function renderPortfolio(){
|
||||
const el=document.getElementById('portfolio');
|
||||
const list=getPortfolio();
|
||||
if(!list.length){
|
||||
el.innerHTML=`<div style="text-align:center;padding:80px 20px;color:#546E7A">
|
||||
<div style="font-size:48px;margin-bottom:16px">💼</div>
|
||||
<div style="font-size:16px;margin-bottom:8px;color:#78909C">보유 종목이 없습니다</div>
|
||||
<div style="font-size:13px">📂 내 종목 분석 탭에서 종목을 추가하면 여기에 표시됩니다</div>
|
||||
</div>`;
|
||||
el.innerHTML='<div class="empty">AI 추천 포트폴리오 불러오는 중...</div>';
|
||||
const reco=await api('/portfolio/recommended'+(recoAmount>0?'?amount='+recoAmount:'')).catch(()=>null);
|
||||
el.innerHTML=`<div style="background:rgba(64,196,255,0.06);border:1px solid rgba(64,196,255,0.2);border-radius:12px;padding:16px 18px;margin-bottom:16px;color:#CFD8DC;font-size:13px;line-height:1.6">💼 아직 등록된 보유 종목이 없습니다. 아래 <b style="color:#69F0AE">AI 추천 포트폴리오</b>를 참고하거나, <b>📂 내 종목 분석</b> 탭에서 종목을 추가하세요.</div>`+recoPortfolioHTML(reco);
|
||||
return;
|
||||
}
|
||||
el.innerHTML='<div class="empty">현재가 · AI점수 불러오는 중...</div>';
|
||||
@@ -1035,7 +1195,7 @@ async function renderPortfolio(){
|
||||
return{...p,cur,changePct:pd.change_pct||0,cost,val,
|
||||
pnl:val-cost,pnlPct:cost>0?(val-cost)/cost*100:0,
|
||||
techScore:pd.tech_score||0,signal:pd.signal||'관망',
|
||||
aiScore:pd.ai_score,recommendation:pd.recommendation};
|
||||
aiScore:pd.ai_score,recommendation:pd.recommendation,sector:pd.sector||'기타'};
|
||||
});
|
||||
const totalPnl=totalValue-totalCost;
|
||||
const totalPnlPct=totalCost>0?totalPnl/totalCost*100:0;
|
||||
@@ -1109,9 +1269,134 @@ async function renderPortfolio(){
|
||||
</tr>`;
|
||||
});
|
||||
h+=`</tbody></table></div></div>`;
|
||||
h+=portfolioSectorHTML(holdings);
|
||||
h+=portfolioRebalanceHTML(holdings);
|
||||
const reco=await api('/portfolio/recommended'+(recoAmount>0?'?amount='+recoAmount:'')).catch(()=>null);
|
||||
h+=recoPortfolioHTML(reco);
|
||||
el.innerHTML=h;
|
||||
}
|
||||
|
||||
let recoAmount=0;
|
||||
async function applyRecoAmount(){
|
||||
const inp=document.getElementById('reco-amt');
|
||||
const v=parseInt((inp&&inp.value)||'0',10);
|
||||
recoAmount=isNaN(v)?0:v;
|
||||
renderPortfolio();
|
||||
}
|
||||
|
||||
function portfolioSectorHTML(holdings){
|
||||
const sec={};
|
||||
holdings.forEach(s=>{ if(s.weight>0) sec[s.sector]=(sec[s.sector]||0)+s.weight; });
|
||||
const arr=Object.entries(sec).sort((a,b)=>b[1]-a[1]);
|
||||
if(!arr.length) return '';
|
||||
let h=`<div class="panel" style="max-height:none;margin-bottom:16px"><h3>📊 섹터(업종) 비중</h3>`;
|
||||
arr.forEach(([name,w])=>{
|
||||
const over=w>30, c=over?'#FF8A80':'#40C4FF';
|
||||
h+=`<div style="margin-bottom:10px">
|
||||
<div style="display:flex;justify-content:space-between;font-size:12px;margin-bottom:4px">
|
||||
<span style="color:#CFD8DC">${esc(name)}${over?' ⚠️':''}</span>
|
||||
<span style="color:${c};font-weight:700">${w.toFixed(1)}%</span></div>
|
||||
<div style="height:8px;background:rgba(255,255,255,0.06);border-radius:4px;overflow:hidden">
|
||||
<div style="height:100%;width:${Math.min(100,w).toFixed(0)}%;background:${c};border-radius:4px"></div></div></div>`;
|
||||
});
|
||||
if(arr[0][1]>30) h+=`<div style="font-size:11.5px;color:#FF8A80;margin-top:4px">⚠️ <b>${esc(arr[0][0])}</b> 업종이 ${arr[0][1].toFixed(0)}%로 한쪽에 쏠렸습니다. 한 업종 30% 이내로 분산하면 위험이 줄어듭니다.</div>`;
|
||||
return h+`</div>`;
|
||||
}
|
||||
|
||||
function portfolioRebalanceHTML(holdings){
|
||||
const acts=[];
|
||||
holdings.forEach(s=>{
|
||||
if(s.recommendation==='강력매도'||s.recommendation==='매도관심')
|
||||
acts.push([s,'sell',`AI가 <b>${esc(s.recommendation)}</b> 신호를 냈습니다 — 매도를 검토하세요`]);
|
||||
else if(s.cur>0&&s.pnlPct<=-10)
|
||||
acts.push([s,'cut',`손실 ${s.pnlPct.toFixed(1)}% — 손절 기준을 점검하세요`]);
|
||||
else if(s.aiScore!=null&&s.aiScore<=-30)
|
||||
acts.push([s,'sell',`AI점수 ${s.aiScore.toFixed(0)}점으로 부진 — 매도를 검토하세요`]);
|
||||
else if(s.aiScore!=null&&s.aiScore>=70&&s.weight<7)
|
||||
acts.push([s,'add',`AI점수 ${s.aiScore.toFixed(0)}점 우량인데 비중이 작습니다 — 추가 매수 고려`]);
|
||||
});
|
||||
let h=`<div class="panel" style="max-height:none;margin-bottom:16px"><h3>🔧 리밸런싱 진단</h3>`;
|
||||
if(!acts.length){
|
||||
h+=`<div style="color:#69F0AE;font-size:12.5px">✅ 보유 종목에 특별한 조치 신호가 없습니다. 그대로 유지해도 무난합니다.</div>`;
|
||||
}else{
|
||||
acts.forEach(([s,kind,msg])=>{
|
||||
const c=kind==='add'?'#69F0AE':kind==='sell'?'#FF8A80':'#FFD740';
|
||||
const ic=kind==='add'?'➕':kind==='sell'?'⚠️':'🔻';
|
||||
h+=`<div data-stock="${s.code}" data-stock-name="${esc(s.name||'')}" style="display:flex;gap:10px;align-items:center;padding:9px 0;border-bottom:1px solid rgba(255,255,255,0.05);cursor:pointer">
|
||||
<span style="font-size:15px">${ic}</span>
|
||||
<div style="flex:1;min-width:0"><div style="color:#CFD8DC;font-size:12.5px;font-weight:600">${esc(s.name||s.code)}</div>
|
||||
<div style="color:${c};font-size:11px;line-height:1.5">${msg}</div></div>
|
||||
<span style="color:${s.pnlPct>=0?'#69F0AE':'#FF8A80'};font-size:11px;font-family:'JetBrains Mono',monospace">${s.cur>0?(s.pnlPct>=0?'+':'')+s.pnlPct.toFixed(1)+'%':'-'}</span></div>`;
|
||||
});
|
||||
}
|
||||
return h+`</div>`;
|
||||
}
|
||||
|
||||
function recoPortfolioHTML(reco){
|
||||
if(!reco||!reco.holdings||!reco.holdings.length)
|
||||
return `<div class="panel" style="max-height:none"><h3>🤖 AI 추천 포트폴리오</h3><div style="color:#546E7A;font-size:12px">${esc((reco&&reco.msg)||'추천 데이터를 불러오지 못했습니다.')}</div></div>`;
|
||||
let h=`<div class="panel" style="max-height:none"><h3>🤖 AI 추천 포트폴리오 (${esc(reco.as_of||'')})</h3>
|
||||
<div style="font-size:11.5px;color:#90A4AE;margin-bottom:12px;line-height:1.6">오늘 <b style="color:#69F0AE">강력매수·매수관심</b> 종목을 변동성·점수로 비중을 매기고, 한 업종 30% 이내로 분산한 구성입니다. 투자금액을 넣으면 종목별 매수 주수를 계산합니다.</div>
|
||||
<div style="display:flex;gap:8px;margin-bottom:14px;align-items:center;flex-wrap:wrap">
|
||||
<input id="reco-amt" type="number" placeholder="투자금액 입력 (원)" value="${recoAmount||''}"
|
||||
style="background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);border-radius:8px;padding:8px 12px;color:#E0E0E0;font-size:13px;width:180px;outline:none"
|
||||
onkeypress="if(event.key==='Enter')applyRecoAmount()">
|
||||
<button onclick="applyRecoAmount()" style="background:rgba(0,230,118,0.1);border:1px solid rgba(0,230,118,0.25);border-radius:8px;padding:8px 16px;color:#00E676;font-size:12px;font-weight:600;cursor:pointer">계산</button>
|
||||
</div>`;
|
||||
const sbArr=Object.entries(reco.sector_breakdown||{});
|
||||
if(sbArr.length){
|
||||
h+=`<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:12px">`;
|
||||
sbArr.forEach(([n,w])=>{ h+=`<span style="background:rgba(64,196,255,0.1);border:1px solid rgba(64,196,255,0.25);border-radius:6px;padding:3px 9px;font-size:10.5px;color:#CFD8DC">${esc(n)} ${w.toFixed(0)}%</span>`; });
|
||||
h+=`</div>`;
|
||||
}
|
||||
const hasAmt=reco.input_amount>0;
|
||||
h+=`<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:12px">
|
||||
<thead><tr style="color:#78909C;border-bottom:1px solid rgba(255,255,255,0.08)">
|
||||
<th style="text-align:left;padding:8px 6px;font-weight:600">종목</th>
|
||||
<th style="text-align:center;padding:8px 6px;font-weight:600">AI점수</th>
|
||||
<th style="text-align:right;padding:8px 6px;font-weight:600">비중</th>
|
||||
${hasAmt?'<th style="text-align:right;padding:8px 6px;font-weight:600">매수금액</th><th style="text-align:right;padding:8px 6px;font-weight:600">주수</th>':''}
|
||||
</tr></thead><tbody>`;
|
||||
reco.holdings.forEach((s,i)=>{
|
||||
const aic=s.total_score>=70?'#00E676':s.total_score>=40?'#69F0AE':'#90A4AE';
|
||||
h+=`<tr data-stock="${s.stock_code}" data-stock-name="${esc(s.stock_name)}" style="border-bottom:1px solid rgba(255,255,255,0.04);cursor:pointer;${i%2?'background:rgba(255,255,255,0.01)':''}">
|
||||
<td style="padding:9px 6px"><div style="color:#CFD8DC;font-weight:600">${esc(s.stock_name)}</div>
|
||||
<div style="font-size:10px;color:#546E7A">${esc(s.sector)} · ${esc(s.recommendation)}</div></td>
|
||||
<td style="text-align:center;padding:9px 6px;color:${aic};font-weight:700;font-family:'JetBrains Mono',monospace">${s.total_score.toFixed(0)}</td>
|
||||
<td style="text-align:right;padding:9px 6px"><span style="color:#40C4FF;font-weight:700">${s.weight_pct.toFixed(1)}%</span></td>
|
||||
${hasAmt?`<td style="text-align:right;padding:9px 6px;color:#CFD8DC;font-family:'JetBrains Mono',monospace">${fmt(s.invest_amount)}</td><td style="text-align:right;padding:9px 6px;color:#E0E0E0;font-weight:600">${s.shares.toLocaleString()}</td>`:''}
|
||||
</tr>`;
|
||||
});
|
||||
h+=`</tbody></table></div>`;
|
||||
if(hasAmt)
|
||||
h+=`<div style="margin-top:10px;font-size:11.5px;color:#90A4AE">투자금 ${fmt(reco.input_amount)}원 중 <b style="color:#69F0AE">${fmt(reco.invested_amount)}원</b> 매수 · 현금 ${fmt(reco.cash_remaining)}원 남음</div>`;
|
||||
return h+`</div>`;
|
||||
}
|
||||
|
||||
function equityCurveSVG(curve){
|
||||
if(!curve||curve.length<2) return '';
|
||||
const W=640,H=180,pad=30;
|
||||
const allv=curve.flatMap(p=>[p.strategy_pct,p.kospi_pct]).concat([0]);
|
||||
let mn=Math.min(...allv), mx=Math.max(...allv);
|
||||
if(mx===mn)mx=mn+1;
|
||||
const X=i=>pad+(W-2*pad)*i/(curve.length-1);
|
||||
const Y=v=>H-pad-(H-2*pad)*(v-mn)/(mx-mn);
|
||||
const path=k=>curve.map((p,i)=>`${i?'L':'M'}${X(i).toFixed(1)},${Y(p[k]).toFixed(1)}`).join('');
|
||||
const last=curve[curve.length-1];
|
||||
const sC=last.strategy_pct>=0?'#00E676':'#FF5252';
|
||||
return `<div class="panel" style="margin-top:14px"><h3>📈 자산곡선 — 추천을 따랐을 때 누적 수익률</h3>
|
||||
<div style="font-size:11px;color:#90A4AE;margin-bottom:8px">매일 추천 종목을 등비중으로 샀다고 가정한 누적 수익률(거래비용 차감). <b style="color:${sC}">초록=전략</b> · <b style="color:#FFB74D">주황=KOSPI</b></div>
|
||||
<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:auto">
|
||||
<line x1="${pad}" y1="${Y(0).toFixed(1)}" x2="${W-pad}" y2="${Y(0).toFixed(1)}" stroke="rgba(255,255,255,0.15)" stroke-dasharray="3"/>
|
||||
<path d="${path('kospi_pct')}" fill="none" stroke="#FFB74D" stroke-width="1.6"/>
|
||||
<path d="${path('strategy_pct')}" fill="none" stroke="${sC}" stroke-width="2.2"/>
|
||||
</svg>
|
||||
<div style="display:flex;justify-content:space-between;font-size:11px;color:#78909C;margin-top:4px">
|
||||
<span>${esc(curve[0].date)}</span>
|
||||
<span style="color:${sC};font-weight:700">전략 ${last.strategy_pct>=0?'+':''}${last.strategy_pct}% vs KOSPI ${last.kospi_pct>=0?'+':''}${last.kospi_pct}%</span>
|
||||
<span>${esc(last.date)}</span></div></div>`;
|
||||
}
|
||||
|
||||
function renderSignals(signals,cands){
|
||||
let h=`<div style="margin-bottom:20px">
|
||||
<div style="font-size:12px;color:#546E7A;margin-bottom:16px">
|
||||
@@ -1871,6 +2156,7 @@ async function renderBacktest(){
|
||||
${_btCard('MDD', oa.max_drawdown_pct, '%', true)}
|
||||
${_btCard('알파', oa.avg_alpha_pct, '%', true)}
|
||||
</div>
|
||||
${equityCurveSVG(d.equity_curve)}
|
||||
<div class="panel" style="margin-top:14px"><h3>등급별 성과 (7일)</h3>${_btTable(d.by_recommendation_7d||{})}</div>`;
|
||||
if(oa30.n){
|
||||
h += `<h3 style="color:#90A4AE;font-size:13px;margin:18px 0 10px">30일 보유</h3>
|
||||
@@ -1892,64 +2178,241 @@ async function renderBacktest(){
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 🌎 미증시 동조
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 미국 주요 티커 → 한글 회사명/설명 매핑 (페어·ETF 표시용)
|
||||
const US_TICKER_KR = {
|
||||
// 반도체
|
||||
NVDA:'엔비디아', AMD:'AMD', MU:'마이크론', INTC:'인텔', TSM:'TSMC(대만 반도체)',
|
||||
AVGO:'브로드컴', QCOM:'퀄컴', TXN:'TI', AMAT:'어플라이드머티리얼', LRCX:'램리서치', KLAC:'KLA',
|
||||
// 빅테크
|
||||
AAPL:'애플', MSFT:'마이크로소프트', GOOGL:'구글', AMZN:'아마존', META:'메타(페북)',
|
||||
TSLA:'테슬라', NFLX:'넷플릭스', ORCL:'오라클', ADBE:'어도비', CRM:'세일즈포스',
|
||||
// 금융
|
||||
JPM:'JP모건', BAC:'뱅크오브아메리카', GS:'골드만삭스', MS:'모건스탠리',
|
||||
WFC:'웰스파고', C:'씨티그룹', BLK:'블랙록',
|
||||
// 에너지/소재
|
||||
XOM:'엑손모빌', CVX:'쉐브론', COP:'코노코필립스', NUE:'뉴코어(철강)', FCX:'프리포트(구리)',
|
||||
// 헬스/바이오
|
||||
JNJ:'존슨앤존슨', PFE:'화이자', MRK:'머크', ABBV:'애브비', LLY:'일라이릴리',
|
||||
BMY:'BMS', GILD:'길리어드',
|
||||
// 소비재/유통
|
||||
WMT:'월마트', COST:'코스트코', HD:'홈디포', MCD:'맥도날드', NKE:'나이키',
|
||||
SBUX:'스타벅스', DIS:'디즈니',
|
||||
// 산업/방산
|
||||
BA:'보잉', LMT:'록히드마틴', RTX:'레이시온', GD:'제너럴다이내믹스', CAT:'캐터필러', DE:'존디어',
|
||||
// 자동차
|
||||
F:'포드', GM:'GM', TM:'도요타',
|
||||
// 2차전지/리튬
|
||||
ALB:'알버말(리튬)', RIVN:'리비안',
|
||||
// 화학
|
||||
DOW:'다우', LYB:'라이언델바젤',
|
||||
// 게임/엔터
|
||||
NTES:'넷이즈(중국게임)',
|
||||
// ETF
|
||||
SOXX:'반도체 ETF', SMH:'반도체 ETF(VanEck)', XLK:'미국 기술주 ETF', QQQ:'나스닥100 ETF',
|
||||
XBI:'바이오 ETF', IBB:'바이오 ETF(나스닥)', LIT:'리튬·전기차 ETF',
|
||||
XLE:'에너지 ETF', XLF:'금융 ETF', XLV:'헬스케어 ETF', XLI:'산업재 ETF',
|
||||
XLP:'필수소비재 ETF', XLY:'경기소비재 ETF', ITA:'항공·방산 ETF',
|
||||
};
|
||||
function usName(t){ return US_TICKER_KR[t] || ''; }
|
||||
|
||||
async function renderUsMarket(){
|
||||
const el=document.getElementById('usmarket');
|
||||
el.innerHTML=`<div style="font-size:12px;color:#546E7A;margin-bottom:14px">미국 섹터 ETF·KR↔US 페어 회귀로 한국 종목별 동조 시그널 산출 (매일 KST 08:00)</div><div style="text-align:center;color:#546E7A;padding:30px">불러오는 중...</div>`;
|
||||
el.innerHTML=`<div style="text-align:center;color:#546E7A;padding:30px">불러오는 중...</div>`;
|
||||
try{
|
||||
const [etfs, pairs, signals] = await Promise.all([
|
||||
api('/usmarket/etfs'), api('/usmarket/pairs'), api('/usmarket/signals')
|
||||
const [etfs, pairs, signals, brief] = await Promise.all([
|
||||
api('/usmarket/etfs'), api('/usmarket/pairs'), api('/usmarket/signals'), api('/usmarket/briefing')
|
||||
]);
|
||||
let h = `<div class="grid2">`;
|
||||
// 섹터 ETF
|
||||
h += `<div class="panel"><h3>섹터 ETF (${Array.isArray(etfs)?etfs.length:0})</h3>`;
|
||||
if(Array.isArray(etfs) && etfs.length){
|
||||
h += `<table style="width:100%;font-size:11px;border-collapse:separate;border-spacing:1px">
|
||||
<thead><tr style="color:#90A4AE"><th style="text-align:left;padding:6px">티커</th><th style="text-align:left">설명</th><th style="text-align:left">한국 섹터</th></tr></thead><tbody>`;
|
||||
etfs.slice(0,30).forEach(e=>{
|
||||
const kw = Array.isArray(e.sector_keywords)?e.sector_keywords.join(', '):'';
|
||||
h += `<tr><td style="padding:5px 6px;color:#69F0AE;font-family:'JetBrains Mono',monospace;font-weight:700">${esc(e.etf_ticker)}</td><td style="color:#CFD8DC">${esc(e.description||'-')}</td><td style="color:#90A4AE;font-size:10px">${esc(kw)}</td></tr>`;
|
||||
|
||||
// ── 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 += `</tbody></table>`;
|
||||
} else h += `<div class="empty">데이터 없음</div>`;
|
||||
h += `</div>`;
|
||||
// 페어
|
||||
h += `<div class="panel"><h3>KR ↔ US 페어 (${Array.isArray(pairs)?pairs.length:0})</h3>`;
|
||||
if(Array.isArray(pairs) && pairs.length){
|
||||
h += `<table style="width:100%;font-size:11px;border-collapse:separate;border-spacing:1px">
|
||||
<thead><tr style="color:#90A4AE"><th style="text-align:left;padding:6px">US</th><th>KR</th><th title="60일 회귀 베타">β</th><th title="60일 상관">ρ</th><th>N</th></tr></thead><tbody>`;
|
||||
pairs.slice(0,30).forEach(p=>{
|
||||
const beta = p.beta_60d!=null?(+p.beta_60d).toFixed(2):'-';
|
||||
const corr = p.correlation_60d!=null?(+p.correlation_60d).toFixed(2):'-';
|
||||
h += `<tr><td style="padding:5px 6px;color:#69F0AE;font-family:'JetBrains Mono',monospace">${esc(p.us_ticker)}</td><td style="color:#CFD8DC;font-family:'JetBrains Mono',monospace">${esc(p.kr_code)}</td><td style="text-align:center;font-family:'JetBrains Mono',monospace;color:#FFD740">${beta}</td><td style="text-align:center;font-family:'JetBrains Mono',monospace;color:#90A4AE">${corr}</td><td style="text-align:center;color:#546E7A">${p.sample_size||'-'}</td></tr>`;
|
||||
});
|
||||
h += `</tbody></table>`;
|
||||
} else h += `<div class="empty">데이터 없음</div>`;
|
||||
h += `</div></div>`;
|
||||
// 시그널
|
||||
h += `<div class="panel" style="margin-top:14px"><h3>최신 동조 시그널 (${Array.isArray(signals)?signals.length:0})</h3>`;
|
||||
}
|
||||
h += `</div>`;
|
||||
}
|
||||
|
||||
// ── 1. 최신 동조 시그널 (가장 중요 — 맨 위로) ──
|
||||
h += `<div class="panel"><h3>📡 오늘의 미국→한국 영향 (${Array.isArray(signals)?signals.length:0}개 종목)</h3>`;
|
||||
h += `<div style="font-size:11px;color:#90A4AE;margin-bottom:12px">
|
||||
🟢 양수(+) = 미국 영향으로 <b style="color:#69F0AE">올라갈 압력</b> · 🔴 음수(-) = <b style="color:#FF8A80">내려갈 압력</b><br>
|
||||
"섹터"는 미국 업종 ETF, "페어"는 짝지어진 미국 개별주의 영향. 둘을 합친 게 총점.
|
||||
</div>`;
|
||||
if(Array.isArray(signals) && signals.length){
|
||||
h += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:8px">`;
|
||||
// 절대값 큰 순으로 정렬
|
||||
const sorted = signals.slice().sort((a,b)=>Math.abs(+b.total_adj||0) - Math.abs(+a.total_adj||0));
|
||||
sorted.slice(0,60).forEach(s=>{
|
||||
// 강도 그룹별로 분류
|
||||
const strong = sorted.filter(s=>Math.abs(+s.total_adj||0) >= 5);
|
||||
const medium = sorted.filter(s=>{ const a=Math.abs(+s.total_adj||0); return a>=2 && a<5; });
|
||||
const weak = sorted.filter(s=>Math.abs(+s.total_adj||0) < 2);
|
||||
const renderCard = (s)=>{
|
||||
const total = +s.total_adj || 0;
|
||||
const sec = +s.sector_adj || 0;
|
||||
const pair = +s.pair_adj || 0;
|
||||
const c = total>0?'#69F0AE':total<0?'#FF8A80':'#90A4AE';
|
||||
h += `<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.05);border-radius:10px;padding:10px 12px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span style="color:#CFD8DC;font-family:'JetBrains Mono',monospace;font-weight:700">${esc(s.kr_code||'-')}</span>
|
||||
<span style="color:${c};font-weight:700;font-family:'JetBrains Mono',monospace">${total>0?'+':''}${total.toFixed(2)}</span>
|
||||
const arrow = total>=5?'🚀':total<=-5?'⚠️':total>0?'▲':total<0?'▼':'·';
|
||||
const krName = esc(s.kr_name || s.kr_code || '-');
|
||||
// contributing_pairs에서 어떤 미국 종목이 주된 원인인지 표시
|
||||
let topReason = '';
|
||||
try{
|
||||
const cp = s.contributing_pairs;
|
||||
const pairs = (cp && cp.pairs) ? cp.pairs : (Array.isArray(cp)?cp:[]);
|
||||
if(pairs && pairs.length){
|
||||
const top = pairs.slice().sort((a,b)=>Math.abs(+b.contribution||0)-Math.abs(+a.contribution||0))[0];
|
||||
if(top){
|
||||
const usK = usName(top.us) || '';
|
||||
topReason = `${esc(top.us)}${usK?`(${esc(usK)})`:''} ${(+top.pct||0)>=0?'+':''}${(+top.pct||0).toFixed(1)}%`;
|
||||
}
|
||||
}
|
||||
}catch(e){}
|
||||
return `<div style="background:rgba(255,255,255,0.03);border:1px solid ${c}33;border-radius:10px;padding:12px 14px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="color:#CFD8DC;font-weight:700;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${krName}</div>
|
||||
<div style="font-family:'JetBrains Mono',monospace;font-size:10px;color:#546E7A">${esc(s.kr_code||'')}</div>
|
||||
</div>
|
||||
<div style="font-size:10px;color:#90A4AE;margin-top:4px;display:flex;gap:10px">
|
||||
<span>섹터 ${sec>=0?'+':''}${sec.toFixed(2)}</span>
|
||||
<span>페어 ${pair>=0?'+':''}${pair.toFixed(2)}</span>
|
||||
<div style="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>`;
|
||||
if(Array.isArray(etfs) && etfs.length){
|
||||
h += `<table style="width:100%;font-size:11.5px;border-collapse:separate;border-spacing:0 3px">
|
||||
<thead><tr style="color:#90A4AE;font-size:10.5px">
|
||||
<th style="text-align:left;padding:4px 6px">미국 ETF</th>
|
||||
<th style="text-align:left">설명</th>
|
||||
<th style="text-align:left">한국 업종</th>
|
||||
</tr></thead><tbody>`;
|
||||
etfs.slice(0,30).forEach(e=>{
|
||||
const kw = Array.isArray(e.sector_keywords)?e.sector_keywords.join(' · '):'';
|
||||
const krDesc = usName(e.etf_ticker) || esc(e.description||'-');
|
||||
h += `<tr style="background:rgba(255,255,255,0.02)">
|
||||
<td style="padding:7px 8px;color:#69F0AE;font-family:'JetBrains Mono',monospace;font-weight:700">${esc(e.etf_ticker)}</td>
|
||||
<td style="color:#CFD8DC">${esc(krDesc)}</td>
|
||||
<td style="color:#FFD740;font-size:11px">${esc(kw)}</td>
|
||||
</tr>`;
|
||||
});
|
||||
h += `</tbody></table>`;
|
||||
} else h += `<div class="empty">데이터 없음</div>`;
|
||||
h += `</div>`;
|
||||
} else h += `<div class="empty">시그널 없음 (아직 계산 전)</div>`;
|
||||
h += `</div>`;
|
||||
|
||||
// ── 3. 개별 페어 ──
|
||||
h += `<div class="panel"><h3>🔗 개별 종목 짝짓기 (${Array.isArray(pairs)?pairs.length:0})</h3>`;
|
||||
h += `<div style="font-size:11px;color:#90A4AE;margin-bottom:10px">
|
||||
<b>민감도(β)</b>: 미국 종목이 1% 오를 때 한국 종목이 몇% 따라가는지<br>
|
||||
<b>상관계수(ρ)</b>: 같이 움직이는 정도. 0.7↑면 매우 강함, 0.5↑면 의미 있음, 0.3↓면 약함
|
||||
</div>`;
|
||||
if(Array.isArray(pairs) && pairs.length){
|
||||
// 한국명 매칭되는 페어를 우선 정렬
|
||||
const sorted = pairs.slice().sort((a,b)=>{
|
||||
const corA = Math.abs(+a.correlation_60d||0);
|
||||
const corB = Math.abs(+b.correlation_60d||0);
|
||||
return corB - corA;
|
||||
});
|
||||
h += `<table style="width:100%;font-size:11.5px;border-collapse:separate;border-spacing:0 3px">
|
||||
<thead><tr style="color:#90A4AE;font-size:10.5px">
|
||||
<th style="text-align:left;padding:4px 6px">미국</th>
|
||||
<th style="text-align:left">한국</th>
|
||||
<th title="민감도: 미국 +1%일 때 한국 +β%" style="text-align:center">민감도(β)</th>
|
||||
<th title="상관: 같이 움직이는 정도 (0~1)" style="text-align:center">상관(ρ)</th>
|
||||
</tr></thead><tbody>`;
|
||||
sorted.slice(0,40).forEach(p=>{
|
||||
const beta = p.beta_60d!=null?(+p.beta_60d).toFixed(2):'-';
|
||||
const corr = p.correlation_60d!=null?(+p.correlation_60d).toFixed(2):'-';
|
||||
const corrN = +p.correlation_60d || 0;
|
||||
const corrC = Math.abs(corrN)>=0.7?'#69F0AE':Math.abs(corrN)>=0.5?'#FFD740':Math.abs(corrN)>=0.3?'#90A4AE':'#546E7A';
|
||||
const usK = usName(p.us_ticker) || '';
|
||||
const krN = esc(p.kr_name || '');
|
||||
h += `<tr style="background:rgba(255,255,255,0.02)">
|
||||
<td style="padding:6px 8px;color:#69F0AE;font-family:'JetBrains Mono',monospace">${esc(p.us_ticker)}${usK?`<span style="color:#90A4AE;font-family:'Pretendard',sans-serif;font-size:10px;margin-left:4px">${esc(usK)}</span>`:''}</td>
|
||||
<td style="color:#CFD8DC">${krN || `<span style="font-family:'JetBrains Mono',monospace">${esc(p.kr_code)}</span>`}</td>
|
||||
<td style="text-align:center;font-family:'JetBrains Mono',monospace;color:#FFD740">${beta}</td>
|
||||
<td style="text-align:center;font-family:'JetBrains Mono',monospace;color:${corrC};font-weight:700">${corr}</td>
|
||||
</tr>`;
|
||||
});
|
||||
h += `</tbody></table>`;
|
||||
} else h += `<div class="empty">페어 없음</div>`;
|
||||
h += `</div></div>`;
|
||||
|
||||
el.innerHTML = h;
|
||||
}catch(e){el.innerHTML=`<div class="empty">오류: ${esc(String(e))}</div>`}
|
||||
}
|
||||
|
||||
+76
-12
@@ -317,7 +317,9 @@ async def recent(limit: int = Query(default=40), only_stock: bool = Query(defaul
|
||||
async with pg_pool.acquire() as c:
|
||||
rows = await c.fetch(f"""
|
||||
SELECT title, sentiment, intensity, primary_stock, reason,
|
||||
investment_action, source, analyzed_at, url, catalyst, stock_codes
|
||||
investment_action, source,
|
||||
COALESCE(published_at, analyzed_at) AS analyzed_at,
|
||||
url, catalyst, stock_codes
|
||||
FROM news_analysis {where}
|
||||
ORDER BY analyzed_at DESC LIMIT $1
|
||||
""", limit)
|
||||
@@ -473,7 +475,7 @@ async def buy_candidates(limit: int = Query(default=20)):
|
||||
ON t.stock_code = s.stock_code
|
||||
AND s.score_date = (SELECT MAX(score_date) FROM stock_scores)
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT roe, operating_margin, debt_ratio, revenue_growth, net_margin,
|
||||
SELECT stock_code, roe, operating_margin, debt_ratio, revenue_growth, net_margin,
|
||||
operating_profit, revenue
|
||||
FROM dart_financials
|
||||
WHERE stock_code = t.stock_code
|
||||
@@ -507,9 +509,11 @@ async def alerts():
|
||||
async with pg_pool.acquire() as c:
|
||||
rows = await c.fetch("""
|
||||
SELECT title, sentiment, intensity, primary_stock,
|
||||
reason, investment_action, source, analyzed_at
|
||||
reason, investment_action, source,
|
||||
COALESCE(published_at, analyzed_at) AS analyzed_at
|
||||
FROM news_analysis
|
||||
WHERE intensity >= 3 AND analyzed_at >= NOW() - INTERVAL '24 hours'
|
||||
WHERE intensity >= 3
|
||||
AND COALESCE(published_at, analyzed_at) >= NOW() - INTERVAL '24 hours'
|
||||
ORDER BY intensity DESC, analyzed_at DESC LIMIT 20
|
||||
""")
|
||||
return [dict(r) for r in rows]
|
||||
@@ -520,12 +524,12 @@ async def alerts():
|
||||
async def timeline(hours: int = Query(default=24)):
|
||||
async with pg_pool.acquire() as c:
|
||||
rows = await c.fetch("""
|
||||
SELECT date_trunc('hour', analyzed_at) AS hour,
|
||||
SELECT date_trunc('hour', COALESCE(published_at, analyzed_at)) AS hour,
|
||||
SUM(CASE WHEN sentiment='호재' THEN 1 ELSE 0 END) AS pos,
|
||||
SUM(CASE WHEN sentiment='악재' THEN 1 ELSE 0 END) AS neg,
|
||||
COUNT(*) AS total
|
||||
FROM news_analysis
|
||||
WHERE analyzed_at >= NOW() - INTERVAL '%s hours'
|
||||
WHERE COALESCE(published_at, analyzed_at) >= NOW() - INTERVAL '%s hours'
|
||||
GROUP BY hour ORDER BY hour
|
||||
""" % hours)
|
||||
return [{"hour": str(r["hour"]), "positive": r["pos"],
|
||||
@@ -537,7 +541,8 @@ async def timeline(hours: int = Query(default=24)):
|
||||
async def stock(code: str):
|
||||
async with pg_pool.acquire() as c:
|
||||
news = await c.fetch("""
|
||||
SELECT title, sentiment, intensity, reason, source, analyzed_at
|
||||
SELECT title, sentiment, intensity, reason, source,
|
||||
COALESCE(published_at, analyzed_at) AS analyzed_at
|
||||
FROM news_analysis WHERE primary_stock=$1
|
||||
ORDER BY analyzed_at DESC LIMIT 20
|
||||
""", code)
|
||||
@@ -766,11 +771,23 @@ async def portfolio_prices(codes: str = Query(default="")):
|
||||
except: pass
|
||||
async with pg_pool.acquire() as c:
|
||||
rows = await c.fetch("""
|
||||
SELECT stock_code, total_score, recommendation, news_score, technical_score
|
||||
SELECT stock_code, total_score, recommendation, news_score, technical_score, sector
|
||||
FROM stock_scores
|
||||
WHERE stock_code = ANY($1)
|
||||
AND score_date = (SELECT MAX(score_date) FROM stock_scores)
|
||||
""", code_list)
|
||||
# Redis 가격캐시 미스 시 stock_prices 테이블에서 폴백 (캐시 비어도 손익 계산되게)
|
||||
missing = [x for x in code_list if not price_map.get(x)]
|
||||
if missing:
|
||||
prows = await c.fetch("""
|
||||
SELECT DISTINCT ON (stock_code) stock_code, price, change_pct
|
||||
FROM stock_prices
|
||||
WHERE stock_code = ANY($1) AND price > 0
|
||||
ORDER BY stock_code, collected_at DESC
|
||||
""", missing)
|
||||
for pr in prows:
|
||||
price_map[pr["stock_code"]] = {
|
||||
"price": pr["price"], "change_pct": float(pr["change_pct"] or 0)}
|
||||
score_map = {r["stock_code"]: dict(r) for r in rows}
|
||||
result = []
|
||||
for code in code_list:
|
||||
@@ -786,6 +803,7 @@ async def portfolio_prices(codes: str = Query(default="")):
|
||||
"signal": ta.get("signal") or "관망",
|
||||
"ai_score": sc.get("total_score"),
|
||||
"recommendation": sc.get("recommendation"),
|
||||
"sector": sc.get("sector") or "기타",
|
||||
})
|
||||
return result
|
||||
|
||||
@@ -1487,6 +1505,17 @@ async def volume_surge():
|
||||
except Exception as e:
|
||||
return JSONResponse(content={"data": [], "error": str(e)})
|
||||
|
||||
|
||||
@app.get("/api/hot")
|
||||
async def hot():
|
||||
"""지금 뜨는 종목 — 뉴스+거래량 모멘텀 (score-engine /hot 프록시)"""
|
||||
async with httpx.AsyncClient() as c:
|
||||
try:
|
||||
r = await c.get(f"{SCORE_ENGINE_URL}/hot?limit=20", timeout=15)
|
||||
return JSONResponse(content=r.json())
|
||||
except Exception as e:
|
||||
return JSONResponse(content=[], status_code=200)
|
||||
|
||||
# ── 사용자 인증 + 포트폴리오 ────────────────────────────────
|
||||
|
||||
class RegisterReq(BaseModel):
|
||||
@@ -2042,12 +2071,39 @@ async def proxy_backtest(days: int = Query(default=180, ge=30, le=720)):
|
||||
return await _proxy_get(f"{SCORE_ENGINE_URL}/backtest?days={days}", timeout=30.0)
|
||||
|
||||
|
||||
@app.get("/api/portfolio/recommended")
|
||||
async def proxy_portfolio_recommended(amount: int = Query(default=0, ge=0)):
|
||||
"""AI 추천 포트폴리오 구성 (score-engine /portfolio/recommended)"""
|
||||
return await _proxy_get(
|
||||
f"{SCORE_ENGINE_URL}/portfolio/recommended?amount={amount}", timeout=20.0)
|
||||
|
||||
|
||||
@app.get("/api/sector-concentration")
|
||||
async def proxy_sector_concentration():
|
||||
"""섹터 집중도 + 30% 초과 경고 (score-engine /sector/concentration)"""
|
||||
return await _proxy_get(f"{SCORE_ENGINE_URL}/sector/concentration")
|
||||
|
||||
|
||||
async def _enrich_kr_names(rows):
|
||||
"""rows의 kr_code/stock_code에 dart_corps.corp_name을 kr_name으로 첨부"""
|
||||
if not isinstance(rows, list) or not rows:
|
||||
return rows
|
||||
codes = list({(r.get("kr_code") or r.get("stock_code") or "") for r in rows if isinstance(r, dict)})
|
||||
codes = [c for c in codes if c]
|
||||
if not codes:
|
||||
return rows
|
||||
async with pg_pool.acquire() as c:
|
||||
name_rows = await c.fetch(
|
||||
"SELECT stock_code, corp_name FROM dart_corps WHERE stock_code = ANY($1::text[])", codes)
|
||||
name_map = {r["stock_code"]: r["corp_name"] for r in name_rows}
|
||||
for r in rows:
|
||||
if not isinstance(r, dict): continue
|
||||
code = r.get("kr_code") or r.get("stock_code")
|
||||
if code and name_map.get(code):
|
||||
r["kr_name"] = name_map[code]
|
||||
return rows
|
||||
|
||||
|
||||
@app.get("/api/usmarket/etfs")
|
||||
async def proxy_us_etfs():
|
||||
"""미국 섹터 ETF 목록"""
|
||||
@@ -2056,14 +2112,22 @@ async def proxy_us_etfs():
|
||||
|
||||
@app.get("/api/usmarket/pairs")
|
||||
async def proxy_us_pairs():
|
||||
"""KR↔US 페어 + 60일 회귀 베타"""
|
||||
return await _proxy_get(f"{US_MARKET_URL}/pairs")
|
||||
"""KR↔US 페어 + 60일 회귀 베타 + 한국 종목명"""
|
||||
rows = await _proxy_get(f"{US_MARKET_URL}/pairs")
|
||||
return await _enrich_kr_names(rows)
|
||||
|
||||
|
||||
@app.get("/api/usmarket/signals")
|
||||
async def proxy_us_signals():
|
||||
"""미증시 동조 시그널 (전체)"""
|
||||
return await _proxy_get(f"{US_MARKET_URL}/signal/latest")
|
||||
"""미증시 동조 시그널 (전체) + 한국 종목명"""
|
||||
rows = await _proxy_get(f"{US_MARKET_URL}/signal/latest")
|
||||
return await _enrich_kr_names(rows)
|
||||
|
||||
|
||||
@app.get("/api/usmarket/briefing")
|
||||
async def proxy_us_briefing():
|
||||
"""미증시 새벽 핫/저조 종목 + 관련 KOSPI 추천 (us-market /overnight-briefing)"""
|
||||
return await _proxy_get(f"{US_MARKET_URL}/overnight-briefing")
|
||||
|
||||
|
||||
@app.get("/api/macro/ecos")
|
||||
|
||||
+402
-37
@@ -8,13 +8,15 @@
|
||||
import asyncio, hashlib, json, os, re, random, time
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from typing import Optional, Literal
|
||||
import asyncpg, httpx, redis.asyncio as aioredis, structlog
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from bs4 import BeautifulSoup
|
||||
from fastapi import FastAPI, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, Field
|
||||
from sentiment_rules import keyword_rule, hallucination_match_ratio
|
||||
|
||||
structlog.configure(processors=[
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
@@ -33,6 +35,94 @@ PG_PASS = os.getenv("POSTGRES_PASSWORD", "7895123")
|
||||
BAREUN_URL = os.getenv("BAREUN_API_URL", "http://bareunaapi:5757")
|
||||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://ollama:11434")
|
||||
QDRANT_URL = os.getenv("QDRANT_URL", "http://qdrant:6333")
|
||||
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
|
||||
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-pro")
|
||||
GEMINI_DAILY_LIMIT = int(os.getenv("GEMINI_DAILY_LIMIT", "50")) # 파싱실패 폴백 포함 일일 한도(비용통제)
|
||||
|
||||
|
||||
class _GeminiNewsClf(BaseModel):
|
||||
sentiment: Literal["호재", "악재", "중립"]
|
||||
intensity: int = Field(ge=1, le=5)
|
||||
catalyst: Literal["실적", "수주", "배당", "리스크", "모멘텀", "기타"]
|
||||
reason: str
|
||||
confidence: float = Field(ge=0.0, le=1.0)
|
||||
|
||||
|
||||
async def _gemini_news_classify(title: str, content: str, exaone: dict,
|
||||
rule: dict) -> tuple[dict, int]:
|
||||
"""Gemini 짧은 프롬프트로 뉴스 sentiment 재판정 (애매한 건만).
|
||||
response_schema로 JSON 강제, max_output_tokens=1500.
|
||||
returns: (parsed_dict_or_empty, cost_krw)
|
||||
"""
|
||||
if not GEMINI_API_KEY:
|
||||
return {}, 0
|
||||
# === 글로벌 일일 1회 한도 (score-engine과 공유) ===
|
||||
try:
|
||||
async with pg_pool.acquire() as conn:
|
||||
cnt = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM gemini_call_log WHERE called_at::date = CURRENT_DATE")
|
||||
if cnt and int(cnt) >= GEMINI_DAILY_LIMIT:
|
||||
logger.warning("gemini.daily_limit", source="news_collector", today_count=int(cnt))
|
||||
return {}, 0
|
||||
except Exception as e:
|
||||
logger.warning("gemini.limit_check_err", error=str(e))
|
||||
try:
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
gclient = genai.Client(api_key=GEMINI_API_KEY)
|
||||
system = (
|
||||
"한국 주식 뉴스 한 건의 호재/악재/중립 여부를 판단합니다. "
|
||||
"본문에 명시된 사실만 근거로 하고, 본문에 없는 정보는 절대 지어내지 마세요. "
|
||||
"키워드 카운트와 EXAONE 1차 판단이 충돌하거나 신뢰도가 낮은 경우 호출됩니다."
|
||||
)
|
||||
prompt = (
|
||||
f"[제목]\n{title}\n\n"
|
||||
f"[본문(요약)]\n{(content or '')[:600]}\n\n"
|
||||
f"[EXAONE 1차] {exaone.get('sentiment')}/"
|
||||
f"강도{exaone.get('intensity',0)}/conf{float(exaone.get('confidence',0) or 0):.2f}\n"
|
||||
f"[키워드 카운트] 호재 {rule['pos']} / 악재 {rule['neg']}"
|
||||
)
|
||||
config = types.GenerateContentConfig(
|
||||
system_instruction=system,
|
||||
temperature=0.1,
|
||||
response_mime_type="application/json",
|
||||
response_schema=_GeminiNewsClf,
|
||||
max_output_tokens=1500,
|
||||
)
|
||||
resp = await asyncio.to_thread(
|
||||
gclient.models.generate_content,
|
||||
model=GEMINI_MODEL,
|
||||
contents=prompt,
|
||||
config=config,
|
||||
)
|
||||
text = getattr(resp, "text", "") or ""
|
||||
parsed = {}
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except Exception:
|
||||
pass
|
||||
usage = getattr(resp, "usage_metadata", None)
|
||||
cost_krw = 0
|
||||
if usage:
|
||||
in_tok = getattr(usage, "prompt_token_count", 0) or 0
|
||||
total_tok = getattr(usage, "total_token_count", 0) or 0
|
||||
cand_tok = getattr(usage, "candidates_token_count", 0) or 0
|
||||
# 출력 전체(candidates + thinking) = total - prompt
|
||||
out_tok = max(total_tok - in_tok, cand_tok)
|
||||
cost_usd = (in_tok / 1_000_000 * 1.25) + (out_tok / 1_000_000 * 10.0)
|
||||
cost_krw = int(cost_usd * 1400)
|
||||
# 일일 한도 카운터 기록
|
||||
try:
|
||||
async with pg_pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO gemini_call_log (source, cost_krw) VALUES ($1, $2)",
|
||||
"news_collector", cost_krw)
|
||||
except Exception:
|
||||
pass
|
||||
return parsed, cost_krw
|
||||
except Exception as e:
|
||||
logger.warning("news.gemini_err", error=str(e))
|
||||
return {}, 0
|
||||
|
||||
HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
|
||||
|
||||
@@ -42,12 +132,76 @@ scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
||||
|
||||
class S:
|
||||
collected = 0; processed = 0; duplicates = 0; errors = 0
|
||||
noise = 0; off_topic = 0
|
||||
noise = 0; off_topic = 0; stale = 0
|
||||
last_run = ""; running = False
|
||||
stats = S()
|
||||
|
||||
def nhash(title, url=""): return hashlib.sha256(f"{title.strip()}{url.strip()}".encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def _loads_lenient(s: str):
|
||||
"""EXAONE 출력에서 JSON 객체를 관대하게 파싱. 실패 시 None.
|
||||
마크다운 펜스 제거 + {..} 추출 + 트레일링 콤마 보정."""
|
||||
if not s:
|
||||
return None
|
||||
s = s.replace("```json", "").replace("```", "").strip()
|
||||
if not s.startswith("{"):
|
||||
sx, ex = s.find("{"), s.rfind("}")
|
||||
if sx != -1 and ex > sx:
|
||||
s = s[sx:ex + 1]
|
||||
try:
|
||||
return json.loads(s)
|
||||
except Exception:
|
||||
pass
|
||||
s2 = re.sub(r",\s*([}\]])", r"\1", s) # 트레일링 콤마 제거
|
||||
try:
|
||||
return json.loads(s2)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# ── 출처 신뢰도 (정적 기본값 — score-engine 사후검증이 news_source_credibility 자동 갱신) ──
|
||||
# 0.0~1.0. 메이저 통신사·전문지일수록 높음. score-engine이 가격반응 기반으로 자동 보정.
|
||||
SOURCE_CREDIBILITY = {
|
||||
"DART공시": 1.00,
|
||||
"연합뉴스": 0.95, "한국경제": 0.92, "매일경제": 0.90, "조선비즈": 0.90,
|
||||
"이데일리": 0.88, "머니투데이": 0.85, "파이낸셜뉴스": 0.85,
|
||||
"서울경제": 0.85, "SBS Biz": 0.85, "더벨": 0.82,
|
||||
"비즈니스포스트": 0.80, "전자신문": 0.80, "인포스탁": 0.78,
|
||||
"헤럴드경제": 0.78, "디지털타임스": 0.75, "뉴스1": 0.78,
|
||||
"뉴시스": 0.75, "이투데이": 0.72, "아시아경제": 0.72,
|
||||
"네이버금융": 0.75, "뉴스핌": 0.70, "글로벌이코노믹": 0.65,
|
||||
"스탁데일리": 0.60, "세계일보": 0.65,
|
||||
}
|
||||
async def get_source_credibility(source: str) -> float:
|
||||
"""static dict + DB 사후 학습 결과 융합. DB에 sample≥20이면 그 값으로 덮어씀."""
|
||||
base = SOURCE_CREDIBILITY.get(source, 0.50)
|
||||
if not pg_pool: return base
|
||||
try:
|
||||
row = await pg_pool.fetchrow(
|
||||
"SELECT credibility, sample_size FROM news_source_credibility WHERE source=$1",
|
||||
source)
|
||||
if row and (row["sample_size"] or 0) >= 20:
|
||||
return float(row["credibility"])
|
||||
except Exception:
|
||||
pass
|
||||
return base
|
||||
|
||||
# ── 제목 강도 (제목이 본문 대비 얼마나 강한 신호인지) ──
|
||||
_STRONG_TITLE_WORDS = (
|
||||
"폭락","폭등","급락","급등","충격","역대급","쇼크","서프라이즈","어닝",
|
||||
"사상최대","사상최저","신고가","신저가","상한가","하한가","적자전환","흑자전환",
|
||||
"어닝쇼크","어닝서프라이즈","대규모","돌파","무너","급증","급감",
|
||||
)
|
||||
def measure_title_strength(title: str) -> float:
|
||||
"""0.3~1.5. 제목 강조어 ↑ 가중, 추측성("?"·"…")은 감점."""
|
||||
if not title: return 0.5
|
||||
s = 1.0
|
||||
s += 0.10 * sum(1 for w in _STRONG_TITLE_WORDS if w in title)
|
||||
if "!" in title: s += 0.05
|
||||
if title.endswith("?") or "?" in title[-3:]: s -= 0.15
|
||||
if "…" in title or "..." in title: s -= 0.05
|
||||
return max(0.3, min(1.5, s))
|
||||
|
||||
def normalize_title(title: str) -> str:
|
||||
"""[속보][단독](종합) 등 제거 후 특수문자·공백 제거 → 유사 제목 중복 감지용"""
|
||||
t = re.sub(r'[\[\(【〔][^\]\)】〕]{0,10}[\]\)】〕]', '', title)
|
||||
@@ -105,7 +259,6 @@ RSS_SOURCES = [
|
||||
("전자신문", "https://www.etnews.com/news/latest_news.xml"),
|
||||
("디지털타임스", "https://www.dt.co.kr/rss/rss_economy.html"),
|
||||
("더벨", "https://www.thebell.co.kr/free/content/xmlService.asp"),
|
||||
("스탁데일리", "https://www.stockdaily.kr/rss/rss.xml"),
|
||||
("세계일보", "https://www.segye.com/RSS/economyRss.xml"),
|
||||
("SBS Biz", "https://news.sbs.co.kr/news/SectionRssFeed.do?sectionId=EC"),
|
||||
]
|
||||
@@ -273,12 +426,26 @@ async def _corp_name(code: str) -> str:
|
||||
return nm
|
||||
|
||||
|
||||
async def pipeline(item, client):
|
||||
async def pipeline(item, client, reprocess: bool = False):
|
||||
try:
|
||||
# 0. 비주식 카테고리 prefix 차단 (바른API 호출 전)
|
||||
if _is_noise_title(item.get("title", "")):
|
||||
return "noise"
|
||||
|
||||
# 0.5. published_at이 30일 이상 과거면 차단.
|
||||
# 일부 RSS 사이트가 오래된 기사를 재노출해서 점수 왜곡 유발 → LLM 호출 전에 컷.
|
||||
# 명시적 백필 엔드포인트(/collect/historical-raw)는 별도 경로라 영향 없음.
|
||||
pub_str = item.get("published_at") or ""
|
||||
if pub_str and not item.get("allow_stale"):
|
||||
try:
|
||||
pub_dt = datetime.fromisoformat(pub_str.replace("Z", "+00:00"))
|
||||
# tz-aware 비교를 위해 둘 다 aware로 맞춤
|
||||
now_dt = datetime.now(pub_dt.tzinfo) if pub_dt.tzinfo else datetime.now()
|
||||
if (now_dt - pub_dt).days > 30:
|
||||
return "stale"
|
||||
except Exception:
|
||||
pass # 파싱 실패는 정상 진행 (parse_rss_date가 fallback으로 NOW 채움)
|
||||
|
||||
# 1. 바른API
|
||||
br = await client.post(f"{BAREUN_URL}/analyze", json={
|
||||
"title": item["title"], "content": item.get("content",""),
|
||||
@@ -312,9 +479,36 @@ async def pipeline(item, client):
|
||||
sr = await client.post(f"{QDRANT_URL}/collections/news_vectors/points/search",
|
||||
json={"vector":emb,"limit":6,"score_threshold":0.80,"with_payload":True}, timeout=15)
|
||||
hits = sr.json().get("result",[])
|
||||
if any(h["score"]>=0.92 for h in hits): return "sim_dup" # ≥0.99 근접중복도 차단
|
||||
if not reprocess and any(h["score"]>=0.92 for h in hits): return "sim_dup" # ≥0.99 근접중복도 차단
|
||||
except: hits = []
|
||||
|
||||
# 3.1. 사건 클러스터링: 유사도 0.85~0.92 안에 있는 hit이 있으면 그 클러스터로 합류.
|
||||
# 첫 뉴스만 풀 가중, 후속은 score-engine에서 감쇠 (is_event_seed=True 인 것만 큰 신호로).
|
||||
event_cluster_id = ""
|
||||
is_event_seed = True
|
||||
try:
|
||||
for h in hits:
|
||||
if 0.85 <= h.get("score", 0) < 0.92:
|
||||
pl = h.get("payload", {}) or {}
|
||||
cid = pl.get("event_cluster_id") or pl.get("hash") or ""
|
||||
if cid:
|
||||
event_cluster_id = cid
|
||||
is_event_seed = False
|
||||
try:
|
||||
await pg_pool.execute("""
|
||||
UPDATE news_event_cluster
|
||||
SET last_seen_at=NOW(), member_count=member_count+1
|
||||
WHERE cluster_id=$1
|
||||
""", cid)
|
||||
except Exception: pass
|
||||
break
|
||||
if not event_cluster_id:
|
||||
event_cluster_id = hashlib.sha256(
|
||||
normalize_title(data["title"]).encode()).hexdigest()[:32]
|
||||
except Exception:
|
||||
event_cluster_id = ""
|
||||
is_event_seed = True
|
||||
|
||||
# 3.5. RAG 컨텍스트: 유사 과거뉴스 + 종목 재무·추세·점수 (버핏 판단 근거 주입)
|
||||
ctx = []
|
||||
rel = [h for h in hits if 0.80 <= h.get("score", 0) < 0.92][:4]
|
||||
@@ -373,7 +567,35 @@ async def pipeline(item, client):
|
||||
logger.debug("rag.ctx_err", code=focus["code"], error=str(e))
|
||||
context_block = "\n".join(ctx)
|
||||
|
||||
# 4. Ollama EXAONE 분석 (버핏 관점 강화 프롬프트)
|
||||
# 3.9. 키워드 룰 1차 판정 — 명확한 호재/악재면 LLM 스킵 (비용 0)
|
||||
rule_text = (data.get("title", "") or "") + " " + (data.get("filtered_text", "") or "")
|
||||
rule = keyword_rule(rule_text)
|
||||
if rule["sentiment"] is not None:
|
||||
# 키워드 룰로 sentiment 확정 — LLM 스킵 (비용 0)
|
||||
primary = (data["stocks"][0]["code"] if data["stocks"] else "")
|
||||
affected = [s["code"] for s in data["stocks"][1:5]]
|
||||
investment = ("매수관심" if rule["sentiment"] == "호재" and rule["intensity"] >= 3
|
||||
else "매도관심" if rule["sentiment"] == "악재" and rule["intensity"] >= 3
|
||||
else "관망")
|
||||
hits = rule["pos_hits"] if rule["sentiment"] == "호재" else rule["neg_hits"]
|
||||
stock_impacts = {primary: 1.0} if primary else {}
|
||||
for c in affected:
|
||||
stock_impacts[c] = 0.5
|
||||
a = {
|
||||
"sentiment": rule["sentiment"], "intensity": rule["intensity"],
|
||||
"primary_stock": primary, "affected_stocks": affected,
|
||||
"reason": f"키워드 룰 매칭: {'/'.join(hits)}"[:500],
|
||||
"investment_action": investment,
|
||||
"catalyst": rule["catalyst"],
|
||||
"time_horizon": "단기", "impact_scope": "종목",
|
||||
"confidence": 1.0, "stock_impacts": stock_impacts,
|
||||
"sector_hint": "",
|
||||
}
|
||||
llm_conf = 1.0
|
||||
logger.info("news.path", path="rule", sentiment=rule["sentiment"],
|
||||
pos=rule["pos"], neg=rule["neg"], hits=hits[:3])
|
||||
else:
|
||||
# 4. Ollama EXAONE 분석 (버핏 관점 + 라벨 세분화) — 키워드 룰 애매 케이스
|
||||
stocks_str = ", ".join([f'{s["name"]}({s["code"]})' for s in data["stocks"][:5]])
|
||||
source = item.get("source", "")
|
||||
content_preview = (data.get("filtered_text") or item.get("content") or "")[:400]
|
||||
@@ -396,6 +618,21 @@ async def pipeline(item, client):
|
||||
' "모멘텀" = 본질가치 변화 없는 가격 동인(외국인/기관 수급, 목표주가 조정, 테마·정책 기대)\n'
|
||||
' "기타" = 위 어디에도 명확히 속하지 않는 정보성 뉴스\n'
|
||||
" 호재/악재면 catalyst를 '기타'로 두지 말고 실적·수주·배당·리스크·모멘텀 중 가장 가까운 것을 고르세요.\n\n"
|
||||
"[time_horizon] 영향이 발현되는 시간프레임\n"
|
||||
' "즉시" = 당일~3일 내 주가 반응 (수주공시·실적 발표·대형 사건)\n'
|
||||
' "단기" = 1~4주 (분기실적·수급 변화·일시적 모멘텀)\n'
|
||||
' "중기" = 1~6개월 (업황·정책 변화·신제품 출시 효과)\n'
|
||||
' "장기" = 6개월 이상 (구조적 변화·신사업 본격화·체질개선)\n\n'
|
||||
"[impact_scope] 영향 받는 대상 범위\n"
|
||||
' "종목" = 특정 종목(들)에만 영향\n'
|
||||
' "섹터" = 동일 업종/테마 전체 영향 (예: 반도체 업황·2차전지 정책)\n'
|
||||
' "시장" = 코스피/코스닥 전체 또는 매크로 (금리·환율·지정학)\n\n'
|
||||
"[stock_impacts] {종목코드: 영향가중치 0~1} 매핑. primary_stock=1.0, 보조 종목은 0.3~0.8.\n"
|
||||
" affected_stocks와 일치하는 모든 종목에 가중치를 매김. 단일 종목이면 {primary:1.0}.\n\n"
|
||||
"[sector_hint] 관련 업종 한 단어 (예: 반도체, 2차전지, 바이오, 조선, 방산, 자동차, 금융,\n"
|
||||
" 통신, 화학, 철강, 건설, 유통, AI, 로봇, 게임, 미디어, 에너지, 정유, 기타).\n\n"
|
||||
"[confidence] 0.0~1.0 — 본인 판단의 신뢰도. 사실/숫자가 명확하면 0.8+, 추측·소문이면 0.3-0.5,\n"
|
||||
" 맥락 부족·해석 모호하면 0.5 이하. 정직하게 평가하세요.\n\n"
|
||||
"[참고] 블록은 보조 자료일 뿐 — 본 기사 내용으로 판단하고 과거 감성 흐름에 휩쓸리지 마세요.\n"
|
||||
"동일 내용이 [참고]의 유사 과거뉴스에 반복되면 신규성이 낮으니 intensity를 보수적으로.\n\n"
|
||||
"반드시 스키마에 맞는 유효한 JSON 객체 하나만 출력. 마크다운·설명문 금지."
|
||||
@@ -408,59 +645,154 @@ async def pipeline(item, client):
|
||||
+ (f"\n[참고]\n{context_block}\n" if context_block else "")
|
||||
+ "\ninvestment_action 규칙: 호재+intensity≥3→매수관심, 악재+intensity≥3→매도관심, 그 외→관망\n"
|
||||
"primary_stock은 6자리 숫자코드(시장 전체 뉴스면 빈 문자열), "
|
||||
"affected_stocks는 영향받는 다른 종목코드 배열, reason은 한 문장 핵심 근거.\n\n"
|
||||
"affected_stocks는 영향받는 다른 종목코드 배열.\n\n"
|
||||
"JSON 스키마:\n"
|
||||
'{"sentiment":"호재|악재|중립","intensity":1~5,"primary_stock":"005930",'
|
||||
'"affected_stocks":["000660"],"reason":"핵심 근거 한 문장",'
|
||||
'"affected_stocks":["000660"],"stock_impacts":{"005930":1.0,"000660":0.5},'
|
||||
'"reason":"핵심 근거 한 문장",'
|
||||
'"investment_action":"매수관심|매도관심|관망",'
|
||||
'"catalyst":"실적|수주|배당|리스크|모멘텀|기타"}\n'
|
||||
'"catalyst":"실적|수주|배당|리스크|모멘텀|기타",'
|
||||
'"time_horizon":"즉시|단기|중기|장기","impact_scope":"종목|섹터|시장",'
|
||||
'"sector_hint":"반도체|2차전지|바이오|조선|방산|자동차|금융|통신|화학|철강|건설|유통|AI|로봇|게임|미디어|에너지|정유|기타",'
|
||||
'"confidence":0.85}\n'
|
||||
"예시: "
|
||||
'{"sentiment":"호재","intensity":4,"primary_stock":"005930",'
|
||||
'"affected_stocks":["000660"],"reason":"HBM 대형 수주 확정으로 연간 반도체 실적 큰 폭 개선 기대",'
|
||||
'"investment_action":"매수관심","catalyst":"수주"}'
|
||||
'"affected_stocks":["000660"],"stock_impacts":{"005930":1.0,"000660":0.6},'
|
||||
'"reason":"HBM 대형 수주 확정으로 연간 반도체 실적 큰 폭 개선 기대",'
|
||||
'"investment_action":"매수관심","catalyst":"수주",'
|
||||
'"time_horizon":"중기","impact_scope":"섹터",'
|
||||
'"sector_hint":"반도체","confidence":0.85}'
|
||||
)
|
||||
# EXAONE 호출 + JSON 파싱 (실패 시 1회 재시도 → ~15% 유실 감소)
|
||||
# EXAONE 호출 + JSON 파싱 (1차 정상, 2차 파싱실패/저신뢰 재시도)
|
||||
a = None
|
||||
raw_prev = ""
|
||||
for attempt in range(2):
|
||||
msgs = [{"role":"system","content": system_prompt},
|
||||
{"role":"user","content": user_prompt}]
|
||||
if attempt == 1:
|
||||
hint = ("위 응답이 유효한 JSON이 아니거나 신뢰도가 낮습니다. "
|
||||
"설명·마크다운 없이 스키마에 맞는 JSON 객체 하나만 출력하고, "
|
||||
"확실하지 않으면 sentiment='중립', intensity=1로 보수적으로 표기하세요.")
|
||||
msgs += [{"role":"assistant","content": raw_prev[:600]},
|
||||
{"role":"user","content":
|
||||
"위 응답이 유효한 JSON이 아닙니다. 설명·마크다운 없이 "
|
||||
"스키마에 맞는 JSON 객체 하나만 출력하세요."}]
|
||||
{"role":"user","content": hint}]
|
||||
try:
|
||||
vr = await client.post(f"{OLLAMA_URL}/v1/chat/completions", json={
|
||||
"model":"exaone3.5:7.8b","messages": msgs,
|
||||
"max_tokens":400,"temperature":0.0 if attempt else 0.05},
|
||||
"max_tokens":800,"temperature":0.0 if attempt else 0.05},
|
||||
timeout=120)
|
||||
raw_prev = vr.json()["choices"][0]["message"]["content"]
|
||||
c = raw_prev.replace("```json","").replace("```","").strip()
|
||||
if not c.startswith("{"): # 앞뒤 설명 제거
|
||||
s, e = c.find("{"), c.rfind("}")
|
||||
if s != -1 and e > s: c = c[s:e+1]
|
||||
a = json.loads(c)
|
||||
cand = _loads_lenient(raw_prev)
|
||||
if cand is None:
|
||||
continue
|
||||
conf = float(cand.get("confidence", 0.5) or 0.5)
|
||||
if attempt == 0 and conf < 0.40 and cand.get("sentiment") in ("호재","악재") \
|
||||
and int(cand.get("intensity", 0) or 0) >= 3:
|
||||
a = cand
|
||||
continue
|
||||
a = cand
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if a is None:
|
||||
a = {"sentiment":"중립","intensity":0,"primary_stock":"","affected_stocks":[],"reason":"파싱실패","investment_action":"관망"}
|
||||
|
||||
# catalyst 6개 enum 강제 (score-engine CATALYST_WEIGHTS 정합 — 일탈값은 '기타')
|
||||
a = {"sentiment":"중립","intensity":0,"primary_stock":"","affected_stocks":[],
|
||||
"reason":"파싱실패","investment_action":"관망","confidence":0.0}
|
||||
# enum 강제 / confidence clamp / stock_impacts 정합
|
||||
if a.get("catalyst") not in ("실적","수주","배당","리스크","모멘텀","기타"):
|
||||
a["catalyst"] = "기타"
|
||||
if a.get("time_horizon") not in ("즉시","단기","중기","장기"):
|
||||
a["time_horizon"] = "단기"
|
||||
if a.get("impact_scope") not in ("종목","섹터","시장"):
|
||||
a["impact_scope"] = "종목"
|
||||
try:
|
||||
llm_conf = max(0.0, min(1.0, float(a.get("confidence", 0.5) or 0.5)))
|
||||
except Exception:
|
||||
llm_conf = 0.5
|
||||
if a.get("reason") == "파싱실패":
|
||||
llm_conf = 0.0
|
||||
si = a.get("stock_impacts")
|
||||
if not isinstance(si, dict): si = {}
|
||||
ps = a.get("primary_stock") or ""
|
||||
if ps and ps not in si: si[ps] = 1.0
|
||||
for c in (a.get("affected_stocks") or []):
|
||||
if c and c not in si: si[c] = 0.5
|
||||
si = {k: max(0.0, min(1.0, float(v or 0)))
|
||||
for k, v in si.items() if k and isinstance(k, str) and len(k) <= 10}
|
||||
a["stock_impacts"] = si
|
||||
|
||||
# 5. Qdrant 저장
|
||||
# === 환각 / 키워드 충돌 검증 → 필요 시 Gemini fallback ===
|
||||
# 임계값을 보수적으로 (Gemini 과호출 방지 — Pro thinking 비용 큼)
|
||||
halluc = hallucination_match_ratio(a.get("reason", ""), rule_text)
|
||||
conflict = ((rule["pos"] >= 2 and a.get("sentiment") == "악재") or
|
||||
(rule["neg"] >= 2 and a.get("sentiment") == "호재"))
|
||||
sent_strong = (a.get("sentiment") in ("호재", "악재")
|
||||
and int(a.get("intensity") or 0) >= 4) # 4→매우 강한 시그널만
|
||||
parse_failed = (a.get("reason") == "파싱실패")
|
||||
need_gem = (parse_failed # EXAONE 파싱실패 → Gemini 재판정(중립 방치 대신)
|
||||
or (sent_strong and llm_conf < 0.4) # 0.6→0.4 (정말 저신뢰만)
|
||||
or halluc < 0.25 # 0.5→0.25 (명백한 환각만)
|
||||
or conflict)
|
||||
if need_gem and GEMINI_API_KEY:
|
||||
gem, gem_cost = await _gemini_news_classify(
|
||||
data.get("title", ""), content_preview,
|
||||
{"sentiment": a.get("sentiment"),
|
||||
"intensity": a.get("intensity"),
|
||||
"confidence": llm_conf}, rule)
|
||||
if gem:
|
||||
a["sentiment"] = gem.get("sentiment", a["sentiment"])
|
||||
try:
|
||||
a["intensity"] = int(gem.get("intensity", a["intensity"]) or a["intensity"])
|
||||
except Exception:
|
||||
pass
|
||||
if gem.get("catalyst") in ("실적","수주","배당","리스크","모멘텀","기타"):
|
||||
a["catalyst"] = gem["catalyst"]
|
||||
a["reason"] = f"[Gemini검증] {gem.get('reason','')}"[:500]
|
||||
try:
|
||||
llm_conf = float(gem.get("confidence", llm_conf) or llm_conf)
|
||||
except Exception:
|
||||
pass
|
||||
a["investment_action"] = (
|
||||
"매수관심" if a["sentiment"] == "호재" and (a.get("intensity") or 0) >= 3
|
||||
else "매도관심" if a["sentiment"] == "악재" and (a.get("intensity") or 0) >= 3
|
||||
else "관망"
|
||||
)
|
||||
logger.info("news.path", path="gemini", final=a["sentiment"],
|
||||
halluc=round(halluc, 2), conflict=conflict,
|
||||
cost_krw=gem_cost)
|
||||
else:
|
||||
logger.info("news.path", path="exaone_lowconf",
|
||||
halluc=round(halluc, 2), conflict=conflict,
|
||||
conf=round(llm_conf, 2))
|
||||
else:
|
||||
logger.info("news.path", path="exaone",
|
||||
sentiment=a.get("sentiment"),
|
||||
conf=round(llm_conf, 2), halluc=round(halluc, 2))
|
||||
|
||||
# 파싱실패 복구분: EXAONE/Gemini는 종목을 안 주므로(스키마 없음), 바른 감지 종목으로
|
||||
# primary_stock·stock_impacts 채움 — 안 그러면 score-engine 뉴스점수에 안 잡힘.
|
||||
if parse_failed and not a.get("primary_stock") and not a.get("stock_impacts"):
|
||||
_codes = [st["code"] for st in data.get("stocks", []) if st.get("code")]
|
||||
if _codes:
|
||||
_w = 1.0 if len(_codes) == 1 else 0.6
|
||||
a["primary_stock"] = _codes[0]
|
||||
a["stock_impacts"] = {c: _w for c in _codes[:5]}
|
||||
|
||||
# 출처 신뢰도 + 제목 강도 (분석 후 DB INSERT 직전에 계산)
|
||||
src_cred = await get_source_credibility(item.get("source", ""))
|
||||
title_str = measure_title_strength(data["title"])
|
||||
|
||||
# 5. Qdrant 저장 (event_cluster_id 포함 — 다음 뉴스의 클러스터 매칭에 사용)
|
||||
try:
|
||||
await client.put(f"{QDRANT_URL}/collections/news_vectors/points", json={
|
||||
"points":[{"id":random.randint(1,999999999),"vector":emb,
|
||||
"payload":{"title":data["title"],"hash":data["hash"],
|
||||
"sentiment":a.get("sentiment",""),"intensity":a.get("intensity",0),
|
||||
"primary_stock":a.get("primary_stock","")}}]}, timeout=15)
|
||||
"primary_stock":a.get("primary_stock",""),
|
||||
"event_cluster_id": event_cluster_id,
|
||||
"catalyst": a.get("catalyst", "기타"),
|
||||
"time_horizon": a.get("time_horizon","단기")}}]}, timeout=15)
|
||||
except: pass
|
||||
|
||||
# 6. PostgreSQL 저장 (파라미터 바인딩 — 인젝션·이스케이프 유실 방지)
|
||||
# 6. PostgreSQL 저장
|
||||
pub = data.get("published_at") or ""
|
||||
try:
|
||||
pub_dt = datetime.fromisoformat(pub.replace("Z", "+00:00")) if pub else datetime.now()
|
||||
@@ -474,8 +806,11 @@ async def pipeline(item, client):
|
||||
await pg_pool.execute("""
|
||||
INSERT INTO news_analysis (title,url,source,published_at,hash,sentiment,intensity,
|
||||
primary_stock,affected_stocks,reason,investment_action,keywords,stock_names,stock_codes,
|
||||
catalyst,similar_count,analyzed_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10,$11,$12::jsonb,$13::jsonb,$14::jsonb,$15,0,NOW())
|
||||
catalyst,similar_count,analyzed_at,
|
||||
time_horizon,impact_scope,llm_confidence,source_credibility,title_strength,
|
||||
stock_impacts,event_cluster_id,is_event_seed,sector_hint)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10,$11,$12::jsonb,$13::jsonb,$14::jsonb,$15,0,NOW(),
|
||||
$16,$17,$18,$19,$20,$21::jsonb,$22,$23,$24)
|
||||
ON CONFLICT (hash) DO NOTHING
|
||||
""",
|
||||
s(data.get("title"))[:500], s(data.get("url"))[:500], s(data.get("source"))[:100],
|
||||
@@ -485,7 +820,26 @@ async def pipeline(item, client):
|
||||
json.dumps(data.get("keywords", [])[:20], ensure_ascii=False),
|
||||
json.dumps([s_["name"] for s_ in data.get("stocks", [])], ensure_ascii=False),
|
||||
json.dumps([s_["code"] for s_ in data.get("stocks", [])], ensure_ascii=False),
|
||||
s(a.get("catalyst", "기타"))[:20],
|
||||
s(a.get("time_horizon","단기"))[:10],
|
||||
s(a.get("impact_scope","종목"))[:10],
|
||||
llm_conf, src_cred, title_str,
|
||||
json.dumps(a.get("stock_impacts", {}), ensure_ascii=False),
|
||||
event_cluster_id[:32], is_event_seed,
|
||||
s(a.get("sector_hint",""))[:40])
|
||||
|
||||
# 이벤트 클러스터 시드 등록 (첫 뉴스만)
|
||||
if is_event_seed and event_cluster_id:
|
||||
try:
|
||||
await pg_pool.execute("""
|
||||
INSERT INTO news_event_cluster (cluster_id, member_count,
|
||||
seed_sentiment, seed_intensity, seed_catalyst)
|
||||
VALUES ($1, 1, $2, $3, $4)
|
||||
ON CONFLICT (cluster_id) DO NOTHING
|
||||
""", event_cluster_id[:32], s(a.get("sentiment","중립"))[:10], iv,
|
||||
s(a.get("catalyst","기타"))[:20])
|
||||
except Exception: pass
|
||||
|
||||
return "ok"
|
||||
except Exception as e:
|
||||
logger.warning("pipeline.err", title=item.get("title","")[:50], error=str(e))
|
||||
@@ -511,6 +865,8 @@ async def job_rss():
|
||||
stats.processed += 1
|
||||
elif r == "noise":
|
||||
stats.noise += 1
|
||||
elif r == "stale":
|
||||
stats.stale += 1
|
||||
elif r == "off_topic":
|
||||
stats.off_topic += 1
|
||||
stats.last_run = datetime.now().isoformat()
|
||||
@@ -655,7 +1011,7 @@ async def job_historical_raw(count: int = 0, max_pages: int = 100):
|
||||
stats.running = False
|
||||
|
||||
|
||||
async def job_process_raw(batch_size: int = 200):
|
||||
async def job_process_raw(batch_size: int = 200, reprocess: bool = False):
|
||||
"""news_raw의 미처리 행을 batch_size만큼 EXAONE 분석 → news_analysis로 이동.
|
||||
OLLAMA_NUM_PARALLEL=4와 매칭되는 4-동시 병렬 처리."""
|
||||
async with pg_pool.acquire() as conn:
|
||||
@@ -680,11 +1036,11 @@ async def job_process_raw(batch_size: int = 200):
|
||||
"stock_code": r["stock_code"],
|
||||
}
|
||||
try:
|
||||
if await is_dup(item["title"], item["url"]):
|
||||
if not reprocess and await is_dup(item["title"], item["url"]):
|
||||
stats.duplicates += 1
|
||||
else:
|
||||
stats.collected += 1
|
||||
res = await pipeline(item, c)
|
||||
res = await pipeline(item, c, reprocess=reprocess)
|
||||
if res == "ok": ok += 1; stats.processed += 1
|
||||
elif res == "noise": stats.noise += 1
|
||||
elif res == "off_topic": stats.off_topic += 1
|
||||
@@ -741,6 +1097,7 @@ async def job_historical(count: int = 500, max_pages: int = 20):
|
||||
if r == "ok":
|
||||
ok += 1; stats.processed += 1
|
||||
elif r == "noise": stats.noise += 1
|
||||
elif r == "stale": stats.stale += 1
|
||||
elif r == "off_topic": stats.off_topic += 1
|
||||
if (i + 1) % 50 == 0:
|
||||
logger.info("historical.progress", done=i+1, of=len(codes),
|
||||
@@ -774,6 +1131,8 @@ async def job_market():
|
||||
stats.processed += 1
|
||||
elif r == "noise":
|
||||
stats.noise += 1
|
||||
elif r == "stale":
|
||||
stats.stale += 1
|
||||
elif r == "off_topic":
|
||||
stats.off_topic += 1
|
||||
stats.last_run = datetime.now().isoformat()
|
||||
@@ -817,8 +1176,8 @@ async def startup():
|
||||
pg_pool = await asyncpg.create_pool(host=PG_HOST,port=PG_PORT,database=PG_DB,user=PG_USER,password=PG_PASS,min_size=2,max_size=5)
|
||||
redis_cl = aioredis.Redis(host=REDIS_HOST,port=6379,password=REDIS_PASSWORD,db=4,decode_responses=True)
|
||||
await init_news_tables()
|
||||
# 평일 RSS: 8-18시 5분마다
|
||||
scheduler.add_job(job_rss,"cron",day_of_week="mon-fri",hour="8-18",minute="*/5",id="rss_weekday",replace_existing=True)
|
||||
# 평일 RSS: 8-18시 2분마다 (자동매매 latency 단축)
|
||||
scheduler.add_job(job_rss,"cron",day_of_week="mon-fri",hour="8-18",minute="*/2",id="rss_weekday",replace_existing=True)
|
||||
# 주말 RSS: 8-22시 15분마다 (누적 학습용)
|
||||
scheduler.add_job(job_rss,"cron",day_of_week="sat,sun",hour="8-22",minute="*/15",id="rss_weekend",replace_existing=True)
|
||||
# 평일 마켓 (네이버 금융): 9-17시 10분마다
|
||||
@@ -831,6 +1190,11 @@ async def startup():
|
||||
scheduler.add_job(job_historical_raw,"cron",day_of_week="sun",hour="2",minute="0",
|
||||
id="historical_raw_weekly",replace_existing=True,
|
||||
kwargs={"count":0,"max_pages":50})
|
||||
# 평일 18:30 전 종목 "최신 1페이지" 스위프 — RSS/마켓메인에 안 잡히는 중형주
|
||||
# 신선뉴스가 주1회 백필까지 누락되던 사각지대 해소 (전 종목×1페이지 ≈ 25분)
|
||||
scheduler.add_job(job_historical_raw,"cron",day_of_week="mon-fri",hour="18",minute="30",
|
||||
id="fresh_stock_sweep",replace_existing=True,
|
||||
kwargs={"count":0,"max_pages":1})
|
||||
scheduler.start()
|
||||
logger.info("news-collector.started", sources=len(RSS_SOURCES))
|
||||
|
||||
@@ -866,9 +1230,10 @@ async def m_historical_raw(count: int = 0, max_pages: int = 100):
|
||||
return {"status":"started","type":"historical_raw","count":count or "전체","max_pages":max_pages}
|
||||
|
||||
@app.post("/process/raw")
|
||||
async def m_process_raw(batch_size: int = 200):
|
||||
"""news_raw 미처리분 batch 분석 → news_analysis로 이동"""
|
||||
n = await job_process_raw(batch_size)
|
||||
async def m_process_raw(batch_size: int = 200, reprocess: bool = False):
|
||||
"""news_raw 미처리분 batch 분석 → news_analysis로 이동.
|
||||
reprocess=true: is_dup·Qdrant sim_dup 우회 (파싱실패 복구 재분석용)."""
|
||||
n = await job_process_raw(batch_size, reprocess=reprocess)
|
||||
return {"status":"done","processed":n}
|
||||
|
||||
@app.get("/raw/stats")
|
||||
|
||||
+2597
-167
File diff suppressed because it is too large
Load Diff
+312
-12
@@ -23,7 +23,7 @@ import httpx
|
||||
import structlog
|
||||
from telegram import Update, BotCommand
|
||||
from telegram.constants import ParseMode
|
||||
from telegram.ext import (Application, CommandHandler, MessageHandler,
|
||||
from telegram.ext import (Application, CommandHandler, MessageHandler, CallbackQueryHandler,
|
||||
filters, ContextTypes)
|
||||
|
||||
logging.basicConfig(level=logging.INFO,
|
||||
@@ -254,16 +254,25 @@ async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
if not is_allowed(update): return
|
||||
msg = (
|
||||
"👋 <b>Trading AI 봇</b>\n\n"
|
||||
"명령어:\n"
|
||||
"/buy — 오늘 매수 추천\n"
|
||||
"/sell — 오늘 매도 신호\n"
|
||||
"/stock 005930 — 특정 종목 상세\n"
|
||||
"/deep 005930 — AI 심층분석(RAG+EXAONE)\n"
|
||||
"/market — 시장 상황\n"
|
||||
"/help — 도움말\n\n"
|
||||
"자유 텍스트로 질문해도 됩니다. 예:\n"
|
||||
"<b>📊 추천 조회</b>\n"
|
||||
"• /buy 또는 '추천' — 오늘 매수 추천(가치투자)\n"
|
||||
"• /hot 또는 '뜨는' — 지금 뜨는 종목(모멘텀)\n"
|
||||
"• /sell 또는 '매도종목' — 오늘 매도 신호\n"
|
||||
"• /stock 005930 — 종목 상세\n"
|
||||
"• /deep 005930 — AI 심층분석\n"
|
||||
"• /market 또는 '시장' — 시장 상황\n\n"
|
||||
"<b>🤖 자동매매</b>\n"
|
||||
"• /status 또는 '상태' — 시스템 상태\n"
|
||||
"• /portfolio 또는 '보유' — 보유 종목 손익\n"
|
||||
"• /orders 또는 '주문' — 매매 주문 목록\n"
|
||||
"• /settings 또는 '설정' — 안전 설정\n"
|
||||
"• /autotrade on|off 또는 '자동매매꺼' — 자동매매 토글\n\n"
|
||||
"<b>💱 주문 처리</b> (텔레그램 알림 도착 시)\n"
|
||||
"• 버튼: [✅ 매수/매도 실행] [❌ 취소]\n"
|
||||
"• 텍스트: '매수 2', '매도 2', '취소 2', '승인 2'\n"
|
||||
"• 명령: /confirm 2, /cancel 2\n\n"
|
||||
"<b>💬 자유 질문</b>\n"
|
||||
"<i>\"에스엠 사도 돼?\"</i>\n"
|
||||
"<i>\"오늘 추천 알려줘\"</i>\n"
|
||||
"<i>\"삼성전자 어때?\"</i>"
|
||||
)
|
||||
await update.message.reply_text(msg, parse_mode=ParseMode.HTML)
|
||||
@@ -285,6 +294,40 @@ async def cmd_sells(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
await update.message.reply_text(fmt_sells(rows), parse_mode=ParseMode.HTML)
|
||||
|
||||
|
||||
def fmt_hot(rows: list) -> str:
|
||||
if not rows:
|
||||
return "📭 지금 뜨는 종목 없음"
|
||||
lines = ["🔥 <b>지금 뜨는 종목</b> <i>(뉴스·가격·거래량 모멘텀)</i>"]
|
||||
for i, r in enumerate(rows, 1):
|
||||
lines.append(f"🔥 <b>{i}. {r['stock_name']}({r['stock_code']})</b> {r['hot_score']:.0f}점")
|
||||
parts = []
|
||||
if r.get("pos_news_3d"):
|
||||
parts.append(f"호재 {r['pos_news_3d']}건")
|
||||
if r.get("price_5d_pct"):
|
||||
parts.append(f"5일 {r['price_5d_pct']:+.1f}%")
|
||||
if r.get("vol_ratio") and r["vol_ratio"] >= 1.5:
|
||||
parts.append(f"거래량 {r['vol_ratio']:.1f}배")
|
||||
if parts:
|
||||
lines.append(f" ▸ {' · '.join(parts)}")
|
||||
vs, vr = r.get("value_score"), r.get("value_reco")
|
||||
if vr:
|
||||
lines.append(f" ▸ 가치판단: {vr} ({vs}점)")
|
||||
lines.append("\n<i>※ 모멘텀 기반이라 가치투자 추천(/buy)과 별개입니다. 변동성·추격매수 주의</i>")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def cmd_hot(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
if not is_allowed(update): return
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
r = await c.get(f"{SCORE_API}/hot?limit=12")
|
||||
rows = r.json()
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"오류: {e}")
|
||||
return
|
||||
await update.message.reply_text(fmt_hot(rows), parse_mode=ParseMode.HTML)
|
||||
|
||||
|
||||
async def cmd_stock(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
if not is_allowed(update): return
|
||||
args = ctx.args
|
||||
@@ -352,6 +395,208 @@ async def cmd_deep(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
await update.message.reply_text(fmt_deep(r), parse_mode=ParseMode.HTML)
|
||||
|
||||
|
||||
SCORE_ENGINE_URL = os.getenv("SCORE_ENGINE_URL", "http://score-engine:8686")
|
||||
|
||||
|
||||
async def cmd_confirm(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
"""/confirm <order_id> — 자동매매 매수 신호 승인"""
|
||||
if not is_allowed(update): return
|
||||
if not ctx.args:
|
||||
await update.message.reply_text("사용법: /confirm <주문번호>")
|
||||
return
|
||||
try:
|
||||
oid = int(ctx.args[0])
|
||||
except ValueError:
|
||||
await update.message.reply_text("주문번호는 숫자")
|
||||
return
|
||||
import httpx
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
r = await c.post(f"{SCORE_ENGINE_URL}/trade/confirm/{oid}")
|
||||
res = r.json()
|
||||
if "error" in res:
|
||||
await update.message.reply_text(f"❌ {res['error']}")
|
||||
else:
|
||||
await update.message.reply_text(f"✅ 주문 #{oid} 체결 완료")
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"오류: {e}")
|
||||
|
||||
|
||||
async def cmd_cancel(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
"""/cancel <order_id> — 매수 신호 거부"""
|
||||
if not is_allowed(update): return
|
||||
if not ctx.args:
|
||||
await update.message.reply_text("사용법: /cancel <주문번호>")
|
||||
return
|
||||
try:
|
||||
oid = int(ctx.args[0])
|
||||
except ValueError:
|
||||
await update.message.reply_text("주문번호는 숫자")
|
||||
return
|
||||
import httpx
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
r = await c.post(f"{SCORE_ENGINE_URL}/trade/cancel/{oid}")
|
||||
await update.message.reply_text(f"🚫 주문 #{oid} 취소")
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"오류: {e}")
|
||||
|
||||
|
||||
async def on_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
"""inline 버튼 콜백 처리: confirm:<id> / cancel:<id>"""
|
||||
q = update.callback_query
|
||||
if not q or not q.data:
|
||||
return
|
||||
await q.answer()
|
||||
try:
|
||||
action, oid_str = q.data.split(":", 1)
|
||||
oid = int(oid_str)
|
||||
except Exception:
|
||||
await q.edit_message_text(f"{q.message.text}\n\n❌ 잘못된 콜백")
|
||||
return
|
||||
import httpx
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
r = await c.post(f"{SCORE_ENGINE_URL}/trade/{action}/{oid}")
|
||||
res = r.json()
|
||||
if "error" in res:
|
||||
new_text = f"{q.message.text_html}\n\n❌ {res['error']}"
|
||||
elif action == "confirm":
|
||||
new_text = f"{q.message.text_html}\n\n✅ 승인됨 (#{oid} 체결)"
|
||||
else:
|
||||
new_text = f"{q.message.text_html}\n\n🚫 거부됨 (#{oid} 취소)"
|
||||
# 버튼 제거 + 결과 표시
|
||||
await q.edit_message_text(new_text, parse_mode=ParseMode.HTML)
|
||||
except Exception as e:
|
||||
await q.edit_message_text(f"{q.message.text}\n\n⚠️ 오류: {e}")
|
||||
|
||||
|
||||
async def cmd_portfolio(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
"""/portfolio — 보유 종목 + 손익"""
|
||||
if not is_allowed(update): return
|
||||
import httpx
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
r = await c.get(f"{SCORE_ENGINE_URL}/portfolio")
|
||||
rows = r.json()
|
||||
if not rows:
|
||||
await update.message.reply_text("보유 종목 없음")
|
||||
return
|
||||
lines = ["👜 <b>보유 종목</b>\n"]
|
||||
total_buy = 0
|
||||
total_cur = 0
|
||||
for p in rows:
|
||||
cur = int(p.get("current_price") or 0)
|
||||
buy = p["buy_price"]
|
||||
qty = p["qty"]
|
||||
pnl_pct = p.get("pnl_pct") or 0
|
||||
pnl_won = p.get("pnl_won") or 0
|
||||
total_buy += buy * qty
|
||||
total_cur += cur * qty
|
||||
sign = "🟢" if pnl_pct > 0 else "🔴" if pnl_pct < -3 else "🟡"
|
||||
lines.append(
|
||||
f"{sign} <b>{p.get('stock_name') or p['stock_code']}</b>({p['stock_code']})\n"
|
||||
f" {qty}주 @{buy:,}원 → {cur:,}원 <b>{pnl_pct:+.2f}%</b> ({pnl_won:+,}원)"
|
||||
)
|
||||
if total_buy:
|
||||
tot_pct = (total_cur - total_buy) / total_buy * 100
|
||||
lines.append(f"\n<b>합계 {tot_pct:+.2f}% ({total_cur - total_buy:+,}원)</b>")
|
||||
await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.HTML)
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"오류: {e}")
|
||||
|
||||
|
||||
async def cmd_autotrade(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
"""/autotrade on|off — 자동매매 토글"""
|
||||
if not is_allowed(update): return
|
||||
if not ctx.args or ctx.args[0].lower() not in ("on","off"):
|
||||
await update.message.reply_text("사용법: /autotrade on 또는 /autotrade off")
|
||||
return
|
||||
enabled = ctx.args[0].lower() == "on"
|
||||
import httpx
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
await c.post(f"{SCORE_ENGINE_URL}/trade/autotrade?enabled={'true' if enabled else 'false'}")
|
||||
await update.message.reply_text(
|
||||
f"⚙️ 자동매매 {'ON ✅' if enabled else 'OFF 🛑'}",
|
||||
parse_mode=ParseMode.HTML)
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"오류: {e}")
|
||||
|
||||
|
||||
async def cmd_settings(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
"""/settings — 자동매매 안전설정 조회"""
|
||||
if not is_allowed(update): return
|
||||
import httpx
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.get(f"{SCORE_ENGINE_URL}/trade/settings")
|
||||
s = r.json().get("current", {})
|
||||
lines = ["⚙️ <b>자동매매 설정</b>\n"]
|
||||
for k, v in s.items():
|
||||
lines.append(f" • {k}: <code>{v}</code>")
|
||||
await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.HTML)
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"오류: {e}")
|
||||
|
||||
|
||||
async def cmd_status(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
"""/status — 시스템 상태 한눈에"""
|
||||
if not is_allowed(update): return
|
||||
import httpx
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.get(f"{SCORE_ENGINE_URL}/system/status")
|
||||
s = r.json()
|
||||
t = s.get("trading", {})
|
||||
a = s.get("analysis", {})
|
||||
sc = s.get("scoring", {})
|
||||
p = s.get("portfolio", {})
|
||||
o = t.get("orders_today", {})
|
||||
auto_icon = "✅" if t.get("enabled") else "🛑"
|
||||
halt_icon = "🛑 HALTED" if t.get("halted") else ""
|
||||
lines = [
|
||||
f"📊 <b>시스템 상태</b>",
|
||||
f"\n<b>자동매매</b> {auto_icon} {halt_icon}",
|
||||
f" 오늘 주문: pending {o.get('pending',0)} / filled {o.get('filled',0)} / cancelled {o.get('cancelled',0)} / expired {o.get('expired',0)}",
|
||||
f" 실현손익: {t.get('realized_pnl_today', 0):+,}원 / 평가손익: {t.get('unrealized_pnl_today', 0):+,}원",
|
||||
f"\n<b>점수 ({sc.get('latest_score_date')})</b>",
|
||||
f" 분석 종목: {sc.get('scored_today',0)}건 / 강력매수: {sc.get('strong_buy_count',0)}건",
|
||||
f"\n<b>LLM 분석</b>",
|
||||
f" 오늘: {a.get('today_analyses',0)}건 ({a.get('today_llm_cost_krw',0)}원)",
|
||||
f" 이번 달: {a.get('month_llm_cost_krw',0)}원",
|
||||
f"\n<b>보유</b>",
|
||||
f" 활성 포지션: {p.get('active_positions',0)}건",
|
||||
]
|
||||
await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.HTML)
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"오류: {e}")
|
||||
|
||||
|
||||
async def cmd_orders(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
"""/orders — 최근 매매 주문 목록"""
|
||||
if not is_allowed(update): return
|
||||
import httpx
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
r = await c.get(f"{SCORE_ENGINE_URL}/trade/orders?days=7")
|
||||
rows = r.json()
|
||||
if not rows:
|
||||
await update.message.reply_text("최근 주문 없음")
|
||||
return
|
||||
lines = ["📋 <b>최근 매매 주문 (7일)</b>\n"]
|
||||
for r in rows[:15]:
|
||||
icon = {"pending":"⏳","confirmed":"✅","filled":"✅",
|
||||
"cancelled":"🚫","expired":"⌛"}.get(r["status"], "·")
|
||||
lines.append(
|
||||
f"{icon} #{r['id']} {r['stock_name']}({r['stock_code']}) "
|
||||
f"{r['side']} {r['qty']}주 @{r['price']:,}원 [{r['status']}]"
|
||||
)
|
||||
await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.HTML)
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"오류: {e}")
|
||||
|
||||
|
||||
async def cmd_market(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
if not is_allowed(update): return
|
||||
m = await db_market_regime()
|
||||
@@ -378,17 +623,63 @@ async def on_text(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
return
|
||||
|
||||
# 한글 단축 키워드 → 명령 라우팅 (빠른 응답)
|
||||
if text in ("추천", "매수", "오늘추천", "buy"):
|
||||
if text in ("추천", "오늘추천", "buy"):
|
||||
rows = await db_top_buys(10)
|
||||
await update.message.reply_text(fmt_buys(rows), parse_mode=ParseMode.HTML)
|
||||
return
|
||||
if text in ("매도", "팔것", "sell"):
|
||||
if text in ("팔것", "매도종목", "sell"):
|
||||
rows = await db_top_sells(5)
|
||||
await update.message.reply_text(fmt_sells(rows), parse_mode=ParseMode.HTML)
|
||||
return
|
||||
if text in ("시장", "장", "market"):
|
||||
await cmd_market(update, ctx)
|
||||
return
|
||||
if text in ("뜨는", "핫", "급등", "뜨는종목", "핫종목", "hot"):
|
||||
await cmd_hot(update, ctx)
|
||||
return
|
||||
|
||||
# 자동매매 한글 명령 라우팅
|
||||
if text in ("상태", "status"):
|
||||
await cmd_status(update, ctx); return
|
||||
if text in ("보유", "포트폴리오", "내주식", "손익"):
|
||||
await cmd_portfolio(update, ctx); return
|
||||
if text in ("주문", "주문목록", "오더"):
|
||||
await cmd_orders(update, ctx); return
|
||||
if text in ("설정", "셋팅"):
|
||||
await cmd_settings(update, ctx); return
|
||||
if text in ("자동매매켜", "자동매매on", "자동매매시작"):
|
||||
ctx.args = ["on"]
|
||||
await cmd_autotrade(update, ctx); return
|
||||
if text in ("자동매매꺼", "자동매매off", "자동매매중단", "자동매매정지"):
|
||||
ctx.args = ["off"]
|
||||
await cmd_autotrade(update, ctx); return
|
||||
|
||||
# 주문 confirm/cancel 자연어 — "매수 2", "매도 2", "승인 2", "취소 2"
|
||||
import re as _re, httpx as _httpx
|
||||
m = _re.match(r"^(매수|매도|승인|확인|실행)\s+(\d+)$", text)
|
||||
if m:
|
||||
oid = int(m.group(2))
|
||||
try:
|
||||
async with _httpx.AsyncClient(timeout=30) as c:
|
||||
r = await c.post(f"{SCORE_ENGINE_URL}/trade/confirm/{oid}")
|
||||
res = r.json()
|
||||
if "error" in res:
|
||||
await update.message.reply_text(f"❌ {res['error']}")
|
||||
else:
|
||||
await update.message.reply_text(f"✅ 주문 #{oid} 체결 완료")
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"오류: {e}")
|
||||
return
|
||||
m = _re.match(r"^(취소|거부|중단)\s+(\d+)$", text)
|
||||
if m:
|
||||
oid = int(m.group(2))
|
||||
try:
|
||||
async with _httpx.AsyncClient(timeout=30) as c:
|
||||
await c.post(f"{SCORE_ENGINE_URL}/trade/cancel/{oid}")
|
||||
await update.message.reply_text(f"🚫 주문 #{oid} 취소")
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"오류: {e}")
|
||||
return
|
||||
|
||||
await update.message.chat.send_action("typing")
|
||||
|
||||
@@ -488,8 +779,17 @@ def main():
|
||||
app.add_handler(CommandHandler(["buy", "buys"], cmd_buys))
|
||||
app.add_handler(CommandHandler(["sell", "sells"], cmd_sells))
|
||||
app.add_handler(CommandHandler(["stock"], cmd_stock))
|
||||
app.add_handler(CommandHandler(["hot"], cmd_hot))
|
||||
app.add_handler(CommandHandler(["deep"], cmd_deep))
|
||||
app.add_handler(CommandHandler(["market"], cmd_market))
|
||||
app.add_handler(CommandHandler(["confirm"], cmd_confirm))
|
||||
app.add_handler(CommandHandler(["cancel"], cmd_cancel))
|
||||
app.add_handler(CommandHandler(["orders"], cmd_orders))
|
||||
app.add_handler(CommandHandler(["portfolio", "pnl"], cmd_portfolio))
|
||||
app.add_handler(CommandHandler(["autotrade"], cmd_autotrade))
|
||||
app.add_handler(CommandHandler(["settings"], cmd_settings))
|
||||
app.add_handler(CommandHandler(["status"], cmd_status))
|
||||
app.add_handler(CallbackQueryHandler(on_callback))
|
||||
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, on_text))
|
||||
logger.info("telegram-bot.start", model=EXAONE_MODEL)
|
||||
app.run_polling(close_loop=False)
|
||||
|
||||
Reference in New Issue
Block a user