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

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

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