diff --git a/CLAUDE.md b/CLAUDE.md
index bb32899..a2e90f4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -468,11 +468,12 @@ config_date(PK), weights(JSONB), period_days, sample_size
※ 최신 row가 calculate_daily_scores에서 자동 로드 → ensemble_bonus 가중
```
-### `pricing_model` (D+E 학습 결과 저장)
+### `pricing_model_v2` (가격예측 모델 학습 결과 저장)
```
-model_date(PK), linear_coef, linear_intercept, linear_r2,
-rf_features(JSONB), rf_r2, sample_size, period_days, created_at
-※ /learn-pricing 호출 시 갱신, /predict-price 호출 시 최신 row 사용
+id, model_date, segment, model_type(linear/rf/xgb), target(return_7d/30d/alpha_30d)
+feature_names(JSONB), feature_importance(JSONB), coef(JSONB), intercept
+r2_oos, ic_spearman, hit_ratio, sample_size, period_days, model_blob(bytea), created_at
+※ /learn-pricing 호출 시 모델별 row 갱신, /predict-price가 model_blob 역직렬화로 추론
```
### `news_raw` (수집/분석 분리용 임시 저장소)
@@ -570,7 +571,7 @@ docker exec trading-postgres psql -U kyu -d trading_ai -c "
## 현재 이슈 / TODO
- [ ] fstab NAS 항목이 두 줄로 분리됨 — sudo로 직접 수정 필요
-- [ ] n8n 워크플로우 재구성 필요 (재부팅 후 소실)
+- [x] n8n 워크플로우 재구성 완료 (워크플로우 16개 / 활성 14개)
- [x] vLLM 제거 → Ollama 단일 운영으로 전환 (EXAONE 3.5 7.8B + bge-m3)
- [x] n8n DB 마이그레이션 오류 수정 완료 (51개 마이그레이션 등록)
- [x] SSL 인증서 자동 갱신 설정 완료 (certbot.timer + deploy hook → nginx reload)
diff --git a/PROJECT.md b/PROJECT.md
new file mode 100644
index 0000000..e093a9e
--- /dev/null
+++ b/PROJECT.md
@@ -0,0 +1,134 @@
+# Trading AI — 프로젝트 구조 정리
+
+> 한국 주식 AI 투자 분석 시스템. 뉴스·공시·주가·미국증시를 수집해
+> 워런 버핏식 가치투자 + 퀀트 멀티팩터로 분석하고 매수/매도를 추천한다.
+> (개발자 상세 가이드는 `CLAUDE.md`, 이 파일은 전체 구조 요약)
+
+---
+
+## 1. 한눈에
+
+- **무엇을**: 약 1,500개 한국 상장사를 매일 0~100점으로 스코어링 → 등급(강력매수~강력매도)
+- **어떻게**: 정형 데이터(재무·주가·수급)와 비정형 데이터(뉴스·공시 텍스트)를 결합
+- **결과**: 대시보드(웹) + 텔레그램 알림으로 추천 전달
+- **철학**: 가치투자가 기본 관점, 실제 엔진은 가치·성장·모멘텀·퀄리티를 섞은 멀티팩터
+
+## 2. 데이터 흐름
+
+```
+뉴스/공시 수집 → 형태소분석(바른) → 임베딩(bge-m3) → 중복제거(Qdrant)
+ → EXAONE LLM 감성분석(호재/악재·강도·catalyst)
+주가·수급·미국증시 수집 ─────────────────────────────┐
+재무제표(DART) 수집 ────────────────────────────────┤
+ ▼
+ 종합 스코어링 (score-engine)
+ ▼
+ 매수/매도 추천 → 대시보드 + 텔레그램
+```
+
+## 3. 인프라
+
+| 항목 | 값 |
+|------|-----|
+| 서버 | Ubuntu 22.04, GPU 2장 (RTX 3060 12G / RTX 3070 8G) |
+| DB | PostgreSQL `trading_ai` |
+| 캐시 | Redis (중복제거·가격캐시) |
+| 벡터DB | Qdrant (뉴스 유사도) |
+| 컨테이너 | docker compose, 16개 서비스 |
+| LLM | Ollama — EXAONE 3.5 7.8B(추론) + bge-m3(임베딩) |
+
+## 4. 서비스 구조 (16개 컨테이너)
+
+| 서비스 | 포트 | 역할 |
+|--------|------|------|
+| news-collector | 8787 | 뉴스 수집 + 형태소 + 임베딩 + EXAONE 감성분석 |
+| bareunaapi / bareun | 5757 / 5656 | 한국어 형태소분석 |
+| dart-collector | 8888 | DART 공시·재무제표·배당·자사주·섹터 수집 |
+| score-engine | 8686 | 종합 스코어링 + 학습 + 텔레그램 + 백테스트 |
+| ta-engine | 8484 | 기술적 분석 (MA·RSI·MACD·볼린저 등) |
+| dashboard-api | 8989 | 대시보드 웹 + REST API |
+| kis-api | 8585 | 한국 주가 수집 + 매매시그널 |
+| aux-signal | 8282 | 컨센서스·기관/외국인 수급 + 매크로(ECOS) |
+| us-market | 8383 | 미국증시 → 한국 동조 시그널 + 새벽 브리핑 |
+| graph-engine | 9090 | GAT 그래프 신경망 → graph_score |
+| telegram-bot | - | 텔레그램 명령 처리 (/buy /sell /stock 등) |
+| ollama | 11434 | LLM 추론·임베딩 |
+| postgres / redis / qdrant | - | DB·캐시·벡터DB |
+| n8n / n8n-worker | 5678 | 워크플로우 자동화 |
+
+## 5. 종합점수 산정
+
+```
+종합점수 = 펀더멘털통합 ×0.24 + 뉴스 ×0.18 + 기술 ×0.15
+ + DART공시 ×0.10 + 외국인수급 ×0.14 + 공매도 ×0.06
+ + 가격모멘텀 ×0.03 + DCF안전마진 ×0.10
+ + 시장레짐 보정 + 앙상블 보팅(±18) + 미국증시(±15)
+ + 보조신호(컨센서스·수급·매크로 등)
+ + 사이클 고점 가드(경기민감주 실적정점 함정 −6~−12)
+```
+
+- **펀더멘털통합** = 버핏점수 + 추세 + 이익품질 + 매직포뮬러 + F-Score + **주주환원율 보너스**
+- **추천 등급**: 강력매수(≥70·매수보팅≥3) / 매수관심 / 관망 / 매도관심 / 강력매도
+
+## 6. 10공식 앙상블 + 학습
+
+매직포뮬러 · F-Score · 알트만Z · PEG · 12-1모멘텀 · Beneish · GP/A · G-Score · Amihud · 베타(BAB)
+— 각 공식이 매수/매도/관망 투표 → 학습 가중치로 결합.
+
+**학습 시스템**
+- `/learn-weights` — 공식별 가중치를 백테스트 edge로 학습 (국면별 segment 지원)
+- `/learn-pricing` — 26+1개 피처로 7d/30d 수익률 예측 모델 학습 (Ridge·RF·XGBoost, walk-forward CV)
+ - 학습 데이터: 전 종목 stock_scores 이력 × stock_ohlcv 실현수익률 (약 9,400 표본)
+ - RF/XGB 모델은 DB에 직렬화 저장 → `/predict-price`가 직접 추론
+- 일요일 자동 재학습 (04:00 가중치 / 05:00 가격모델)
+
+## 7. 핵심 DB 테이블
+
+| 테이블 | 내용 |
+|--------|------|
+| `news_analysis` | 뉴스별 감성·강도·catalyst·종목매핑 |
+| `stock_scores` | 종목별 일일 종합점수·세부점수·추천등급·신호 |
+| `dart_financials` | 재무제표 + ROE·부채비율 등 비율 + **자사주매입액** |
+| `dart_dividends` | 배당금(DPS·총액) |
+| `stock_technical` | 기술적 지표 |
+| `stock_ohlcv` | 일봉 시계열 (수익률·베타·학습용) |
+| `recommendation_performance` | 추천 성과 추적 (7d/30d 수익률·알파) |
+| `weight_config` / `pricing_model_v2` | 학습된 가중치 / 가격예측 모델(직렬화 포함) |
+| `us_overnight_signal` | 미국증시 → 한국 동조 보정값 |
+
+## 8. 대시보드 (15개 탭)
+
+종합 · 추천성과 · 매매시그널 · 거래량급증 · **포트폴리오** · 내 종목 분석 ·
+추천종목 · 뉴스피드 · 수급 · 알림 · 10공식 · 백테스트 · **미증시** · 매크로 · **용어설명**
+
+- 좌측 사이드바 네비게이션, 모바일 대응(햄버거 메뉴)
+- 포트폴리오 탭: 보유종목 손익·섹터비중·리밸런싱 진단 + AI 추천 포트폴리오 구성
+- 미증시 탭: 새벽 미국 핫/저조 종목 → 관련 KOSPI 추천
+- 용어설명 탭: 차트·지표·재무·시장제도·투자스타일 용어 풀이
+
+## 9. 주요 운영 명령
+
+```bash
+docker compose ps # 서비스 상태
+docker logs trading-<서비스> --tail 50 -f # 로그
+docker compose build <서비스> && docker compose up -d <서비스> # 재배포
+
+curl -X POST http://localhost:8686/score/calculate # 전체 재스코어링
+curl -X POST 'http://localhost:8686/learn-weights?days=90' # 가중치 학습
+curl -X POST 'http://localhost:8686/learn-pricing?target=return_7d' # 가격모델 학습
+curl http://localhost:8686/backtest # 백테스트
+curl -X POST 'http://localhost:8888/collect/financials?count=3000' # 재무 수집
+curl -X POST http://localhost:8888/collect/sectors # 섹터 수집
+```
+
+## 10. 개선 이력 (2026-05)
+
+- **백테스트 신뢰성**: 수익률 라벨을 OHLCV 정확한 날짜 종가로 정정, 거래비용 반영, 실제 MDD(자산곡선) 계산, 10공식 가중치 동기화
+- **학습 데이터 140배 확장**: 추천종목 63건 → 전 종목 단면 9,394건. 모델 R² −0.40 → +0.38
+- **모델 직렬화**: RF/XGB 모델을 DB에 저장 → `/predict-price`가 실제 모델로 추론
+- **주주환원율 팩터**: (배당 + 자사주매입) / 시총 → 펀더멘털 보너스 (코리아 디스카운트 방어)
+- **사이클 고점 가드**: 경기민감주 실적정점 함정 방어
+- **미증시 새벽 브리핑**: 미국 핫/저조 종목 → 관련 KOSPI 추천
+- **UI**: 좌측 사이드바 + 모바일 대응, 용어설명 탭 신설
+- **포트폴리오 4종**: 추천 구성 · 자산곡선 백테스트 · 보유 관리 · 통합 탭
+- **텔레그램**: 하루 4회 → 3회로 축소
diff --git a/bareunaapi/finance_dict.py b/bareunaapi/finance_dict.py
index 89dd6a6..08448e4 100644
--- a/bareunaapi/finance_dict.py
+++ b/bareunaapi/finance_dict.py
@@ -107,10 +107,134 @@ INVESTMENT_STRATEGY = {
"현금창출력","잉여현금흐름","재투자수익률","ROIC","WACC",
}
+# ── 투자자 신조어 / 시장 은어 (한국 개미 투자자 언어) ──────────────────────────
+INVESTOR_SLANG = {
+ # IPO·공모
+ "따상","따따상","따따따상","공모청약","경쟁률","의무보유","락업","락업해제",
+ "유통가능물량","오버행","오버행우려",
+ # 매매행위
+ "물타기","불타기","손절","익절","반익절","분할매수","분할매도","평단가",
+ "물려","물렸","익절각","손절각","손절매","고점매수","저점매수","바닥잡기",
+ "패닉셀","패닉바잉","공포매도","욕심매수","추격매수","역추세",
+ # 개미·세력 표현
+ "개미","개미투자자","동학개미","서학개미","슈퍼개미","큰손","세력","작전세력",
+ "메기","고래","왕개미","주식초보","주린이","주식러","개미털기","손바뀜",
+ # 차트·캔들 은어
+ "쌍바닥","쌍봉","찐바닥","찐고점","역대급","바닥다지기","박스권","상승추세",
+ "하락추세","눌림목","돌파매수","이평선타고","대량거래","점상","점하",
+ # 시장 분위기
+ "광풍","열풍","불장","대세상승","대세하락","연속음봉","연속양봉","비명",
+ "지옥장","천국장","호황","불황","조정","조정장","약세장","강세장","급반등",
+ # 종목 분류
+ "대장주","대장","주도주","테마주","관련주","수혜주","피해주","낙폭과대",
+ "저평가","고평가","우량주","잡주","동전주","천원주","고가주",
+}
+
+# ── 2026년 최신 테마 / 신기술 ─────────────────────────────────────────────────
+LATEST_THEMES = {
+ # AI 인프라
+ "AI인프라","AI반도체","AI가속기","AI칩","NPU","TPU","ASIC","HBM4","HBM3E",
+ "추론칩","트레이닝칩","온디바이스AI","엣지AI","에이전트","AI에이전트",
+ "생성AI","멀티모달","파인튜닝","RAG","임베딩","벡터DB",
+ # 양자컴퓨팅
+ "양자컴퓨팅","양자컴퓨터","양자컴","큐비트","양자내성암호","PQC",
+ # 휴머노이드·로봇
+ "휴머노이드","휴머노이드로봇","피지컬AI","임바디드AI","협동로봇","서비스로봇",
+ "물류로봇","수술로봇","무인배송",
+ # 우주·방산
+ "위성통신","스타링크","원웹","저궤도위성","우주여행","재사용로켓","민간우주",
+ "K-방산","수출수주","폴란드수출","K2전차","FA-50","천궁","KF-21","호위함",
+ # 에너지·자원
+ "대왕고래","동해유전","SMR","소형원전","원전르네상스","원전수출","수소경제",
+ "그린수소","블루수소","액화수소","수전해","해상풍력","육상풍력","태양광",
+ "ESS","에너지저장장치","V2G","V2H","CCUS","탄소포집",
+ # 바이오 신트렌드
+ "GLP-1","위고비","오젬픽","마운자로","비만신약","치매신약","레켐비",
+ "도나네맙","CGT","유전자가위","CRISPR","mRNA백신","에임드","ADC",
+ "이중항체","삼중항체","바이스페시픽","TPD",
+ # 디지털 자산
+ "비트코인","이더리움","스테이블코인","현물ETF","RWA","토큰증권","STO",
+ "CBDC","디지털원화","블록체인","NFT",
+ # 모빌리티
+ "전기차","수소차","UAM","도심항공모빌리티","자율주행","로보택시","FSD",
+ "PBV","목적기반차량","전기버스","전기트럭",
+ # 반도체 신기술
+ "GAA","3D낸드","CXL","Compute Express Link","온칩메모리","Chiplet",
+ "어드밴스드패키징","TSV","마이크로LED","유리기판","FOPLP","FCBGA",
+}
+
+# ── 정책 / 법 / 정부 이슈 ─────────────────────────────────────────────────────
+POLICY_NAMES = {
+ # 한국
+ "밸류업","밸류업프로그램","코리아디스카운트","주주환원확대","배당확대정책",
+ "K-칩스법","반도체특별법","반도체클러스터","용인반도체클러스터",
+ "K-바이오","K-방산","K-콘텐츠","K-원전","K-배터리",
+ "금투세","금융투자소득세","대주주양도세","증권거래세","공매도금지","공매도재개",
+ # 미국
+ "IRA","인플레감축법","칩스법","CHIPS법","Section232","Section301",
+ "관세","상호관세","트럼프관세","수입제한","수출통제","ECRA","FEOC",
+ # 글로벌
+ "탄소국경세","CBAM","ESG","탄소중립","넷제로","RE100","CF100",
+ "공급망실사법","EUDR","핵심광물법","CRMA",
+ # 통화·재정
+ "추경","추가경정예산","재정정책","감세","증세","법인세","상속세",
+ # 정치 이벤트
+ "FOMC","점도표","연준의장","파월의장","파월","ECB의장","라가르드",
+ "한은총재","이창용","금통위",
+}
+
+# ── 글로벌 인물 / 기업 (한글 표기) ──────────────────────────────────────────────
+GLOBAL_NAMES = {
+ # 미국 정치인
+ "트럼프","바이든","해리스","머스크","일론머스크","오바마","파월","옐런",
+ # 빅테크 CEO
+ "젠슨황","팀쿡","사티아나델라","순다르피차이","마크저커버그","저커버그",
+ "사이러스","마윈","머스크",
+ # 글로벌 빅테크 한글
+ "엔비디아","애플","마이크로소프트","구글","아마존","메타","페이스북",
+ "테슬라","넷플릭스","오라클","어도비","세일즈포스","인텔","AMD","마이크론",
+ "TSMC","대만반도체","ASML","ASM인터내셔널","어플라이드머티리얼",
+ "보잉","록히드마틴","레이시온","제너럴다이내믹스","캐터필러",
+ "JP모건","골드만삭스","모건스탠리","뱅크오브아메리카","씨티그룹","블랙록",
+ "존슨앤존슨","화이자","머크","애브비","일라이릴리","길리어드",
+ "월마트","코스트코","홈디포","스타벅스","나이키","맥도날드","디즈니",
+ "엑손모빌","쉐브론","코노코필립스","BP","쉘",
+ "포드","GM","제너럴모터스","도요타","폭스바겐","현대차",
+ # 중국·기타
+ "BYD","비야디","CATL","화웨이","샤오미","알리바바","텐센트","바이두",
+ "지정학","미중갈등","대만해협","러우전쟁","우크라이나","이스라엘",
+}
+
+# ── 매크로 추가 약어 / 지표 ────────────────────────────────────────────────────
+MACRO_EXTRA = {
+ "NFP","비농업고용","ADP","베이지북","점도표","Dot Plot","SLOOS",
+ "GDPNow","Nowcast","어닝시즌","Q4실적","Q3실적","연간가이던스",
+ "달러강세","달러약세","원화강세","원화약세","엔저","엔고",
+ "수익률곡선","장단기금리차","역수익률곡선","R*","중립금리",
+ "DXY","달러인덱스","JOLTS","ECI","PCE물가","코어CPI","근원CPI",
+ "기대인플레이션","BEI","TIPS","리세션","경기침체","연착륙","경착륙",
+ "노랜딩","스태그플레이션","리플레이션",
+}
+
+# ── 공시 / 기업 이벤트 추가 ────────────────────────────────────────────────────
+DISCLOSURE_EXTRA = {
+ "주요사항보고서","정기공시","수시공시","자율공시","공정공시","조회공시",
+ "기재정정","정정공시","연결재무제표","별도재무제표","감사보고서",
+ "반기보고서","분기보고서","사업보고서","증권신고서","합병신고서",
+ "주식교환","주식이전","주식분할","액면분할","액면병합","주식소각",
+ "유상소각","무상소각","제3자배정","주주배정","일반공모",
+ "전환청구","전환가액조정","리픽싱","리픽싱조정","희석",
+ "최대주주변경","경영진교체","임원교체","사임","해임",
+ "특허출원","특허등록","품목허가","임상승인","조건부허가",
+ "기술이전","라이센스아웃","라이센스인","마일스톤",
+}
+
# 동사/형용사 형태 (형태소 분석에서 VV/VA 태그로 뽑히는 것들)
FINANCE_VERBS: set[str] = {
"급등","급락","폭등","폭락","상승","하락","반등","돌파","이탈","회복",
"초과","달성","하회","상회","급성장","개선","악화","감소","증가",
+ "물려","물렸","손절","익절","수익","흑자","적자","감익","증익",
+ "체결","계약","수주","해지","무산","연기","승인","불승인","거절",
}
# 전체 통합 사전 (lookup용)
@@ -123,14 +247,87 @@ ALL_FINANCE_TERMS: set[str] = (
| SECTOR_KEYWORDS
| PRICE_MOVEMENT
| INVESTMENT_STRATEGY
+ | INVESTOR_SLANG
+ | LATEST_THEMES
+ | POLICY_NAMES
+ | GLOBAL_NAMES
+ | MACRO_EXTRA
+ | DISCLOSURE_EXTRA
)
-# 감성 판단에 중요한 우선순위 높은 단어들
-HIGH_PRIORITY_TERMS: set[str] = {
- "어닝서프라이즈","어닝쇼크","급등","급락","폭등","폭락","상한가","하한가",
- "수주","임상성공","임상실패","FDA승인","횡령","배임","분식회계","상장폐지",
- "관리종목","거래정지","배당증가","자사주소각","M&A","합병","분할",
- "골든크로스","데드크로스","신고가","신저가","52주신고가","52주신저가",
- "외국인순매수","기관순매수","공매도잔고감소","실적발표","가이던스상향",
- "가이던스하향","CEO교체","대표이사교체","감사의견거절",
+# 키워드 룰 기반 sentiment 사전판정용 (LLM 호출 전 단계)
+# 명확한 호재 — 매칭 시 sentiment=호재 후보
+POSITIVE_TERMS: set[str] = {
+ "어닝서프라이즈","수주","대형수주","계약체결","계약수주","임상성공","FDA승인",
+ "품목허가","신고가","52주신고가","역사적신고가","급등","폭등","상한가",
+ "따상","따따상","골든크로스","외국인순매수","기관순매수","배당증가",
+ "자사주매입","자사주소각","M&A","인수합병","기술이전","라이센스아웃",
+ "마일스톤","가이던스상향","실적개선","흑자전환","증익","대왕고래발견",
+ "특허등록","품목허가","임상승인","조건부허가","수출수주","폴란드수출",
+}
+
+# 명확한 악재 — 매칭 시 sentiment=악재 후보
+NEGATIVE_TERMS: set[str] = {
+ "어닝쇼크","어닝미스","임상실패","FDA거절","품목허가취소","리콜","급락",
+ "폭락","하한가","신저가","52주신저가","상장폐지","관리종목","거래정지",
+ "데드크로스","외국인순매도","기관순매도","공매도잔고증가","감자","유상증자",
+ "유상감자","최대주주변경","CEO교체","대표이사사임","감사의견거절","의견거절",
+ "한정의견","부적정의견","횡령","배임","분식회계","회계부정","피소","제재",
+ "영업정지","과징금","가이던스하향","적자전환","감익","파산","법정관리",
+ "특허침해","임상실패","불승인","무산","해지",
+}
+
+# 단어 → catalyst 카테고리 자동 매핑 (LLM 스킵 시 catalyst 채우기용)
+CATALYST_MAP: dict[str, str] = {
+ # 수주
+ "수주":"수주","대형수주":"수주","계약체결":"수주","계약수주":"수주",
+ "MOU":"수주","LOI":"수주","수출수주":"수주","폴란드수출":"수주",
+ # 실적
+ "어닝서프라이즈":"실적","어닝쇼크":"실적","어닝미스":"실적",
+ "가이던스상향":"실적","가이던스하향":"실적","실적개선":"실적",
+ "흑자전환":"실적","적자전환":"실적","증익":"실적","감익":"실적",
+ # 배당
+ "배당증가":"배당","자사주매입":"배당","자사주소각":"배당",
+ # 리스크
+ "분식회계":"리스크","회계부정":"리스크","리콜":"리스크","감자":"리스크",
+ "유상증자":"리스크","유상감자":"리스크","상장폐지":"리스크","관리종목":"리스크",
+ "거래정지":"리스크","감사의견거절":"리스크","의견거절":"리스크",
+ "부적정의견":"리스크","한정의견":"리스크","횡령":"리스크","배임":"리스크",
+ "피소":"리스크","제재":"리스크","영업정지":"리스크","과징금":"리스크",
+ "임상실패":"리스크","FDA거절":"리스크","품목허가취소":"리스크","특허침해":"리스크",
+ "파산":"리스크","법정관리":"리스크","최대주주변경":"리스크","CEO교체":"리스크",
+ # 모멘텀
+ "신고가":"모멘텀","52주신고가":"모멘텀","역사적신고가":"모멘텀",
+ "급등":"모멘텀","폭등":"모멘텀","상한가":"모멘텀","따상":"모멘텀","따따상":"모멘텀",
+ "신저가":"모멘텀","52주신저가":"모멘텀","급락":"모멘텀","폭락":"모멘텀","하한가":"모멘텀",
+ "골든크로스":"모멘텀","데드크로스":"모멘텀",
+ "외국인순매수":"모멘텀","기관순매수":"모멘텀","외국인순매도":"모멘텀","기관순매도":"모멘텀",
+ "공매도잔고증가":"모멘텀",
+ # FDA/임상 등 (실적성)
+ "FDA승인":"실적","임상성공":"실적","품목허가":"실적","임상승인":"실적",
+ "기술이전":"실적","라이센스아웃":"실적","마일스톤":"실적","특허등록":"실적",
+}
+
+# 감성 판단에 중요한 우선순위 높은 단어들 (기존 호환용)
+HIGH_PRIORITY_TERMS: set[str] = {
+ # 강한 호재
+ "어닝서프라이즈","수주","대형수주","계약체결","임상성공","FDA승인","품목허가",
+ "신고가","52주신고가","역사적신고가","급등","폭등","상한가","따상","따따상",
+ "골든크로스","외국인순매수","기관순매수","배당증가","자사주매입","자사주소각",
+ "M&A","인수합병","합병","분할","기술이전","라이센스아웃","마일스톤",
+ "가이던스상향","실적개선","흑자전환","증익","대왕고래발견","계약수주",
+ # 강한 악재
+ "어닝쇼크","어닝미스","임상실패","FDA거절","품목허가취소","리콜","급락",
+ "폭락","하한가","신저가","52주신저가","상장폐지","관리종목","거래정지",
+ "데드크로스","외국인순매도","기관순매도","공매도잔고증가","감자","유상증자",
+ "유상감자","최대주주변경","CEO교체","대표이사사임","감사의견거절","의견거절",
+ "한정의견","부적정의견","횡령","배임","분식회계","회계부정","피소","제재",
+ "영업정지","과징금","가이던스하향","적자전환","감익","파산","법정관리",
+ # 시장 이벤트
+ "FOMC","금리인상","금리인하","금리동결","점도표","파월","연준","한은총재",
+ "기준금리","공매도금지","공매도재개","서킷브레이커","사이드카",
+ # 정책 변수
+ "밸류업","K-칩스법","IRA","CHIPS법","관세","수출통제","FEOC",
+ # 글로벌 충격
+ "지정학","대만해협","리세션","경기침체","경착륙","연착륙","스태그플레이션",
}
diff --git a/bareunaapi/stock_loader.py b/bareunaapi/stock_loader.py
index cbf543e..f36322d 100644
--- a/bareunaapi/stock_loader.py
+++ b/bareunaapi/stock_loader.py
@@ -32,13 +32,159 @@ FALLBACK_STOCKS = {
"알테오젠": "196170", "한미반도체": "042700",
}
-# 약칭/별칭
+# 약칭/별칭 — 시장에서 통용되는 줄임말·구명·영문표기
+# 주의: 2글자 이하 약칭은 일반어 충돌 위험 (예: "삼전" → "삼전자" 같은 일반어 거의 없어 OK,
+# 하지만 "엔솔" 같은 건 위험 → "LG엔솔"처럼 회사명 포함 별칭만 유지)
ALIASES = {
- "삼전": "005930", "하닉": "000660", "현차": "005380",
- "카뱅": "323410", "삼바": "207940", "삼성바이오": "207940",
- "한에솔": "012450", "한화에어": "012450", "LG엔솔": "373220",
- "SK하닉": "000660", "포홀": "005490", "에프엔에프": "383220",
- "현대자동차": "005380", "네이버": "035420",
+ # ─── 시총 상위 한국 종목 ───
+ # 반도체
+ "삼전": "005930", "삼성전자": "005930",
+ "하닉": "000660", "SK하닉": "000660", "SK하이닉스": "000660", "에스케이하이닉스": "000660",
+ "한미반도": "042700", "한미반": "042700",
+ "디비하이텍": "000990", "DB하이텍": "000990",
+ "리노공업": "058470",
+ "이수페타시스": "007660",
+ "주성엔지니어링": "036930", "주성": "036930",
+ # 2차전지/배터리
+ "LG엔솔": "373220", "엘지엔솔": "373220", "LG에너지": "373220",
+ "삼성SDI": "006400", "삼성에스디아이": "006400",
+ "SK이노": "096770", "에스케이이노베이션": "096770",
+ "에코프로비엠": "247540", "에코비엠": "247540",
+ "에코프로": "086520",
+ "포스코퓨처엠": "003670", "퓨처엠": "003670", "포퓨엠": "003670",
+ "엘앤에프": "066970", "L&F": "066970",
+ "엔켐": "348370",
+ # 자동차/모빌리티
+ "현차": "005380", "현대차": "005380", "현대자동차": "005380",
+ "기아차": "000270", "기아자동차": "000270", "기아": "000270",
+ "현모비스": "012330", "모비스": "012330", "현대모비스": "012330",
+ "현대글로비스": "086280", "글로비스": "086280",
+ "현대위아": "011210",
+ # 바이오/제약
+ "삼바": "207940", "삼성바이오": "207940", "삼성바이오로직스": "207940",
+ "셀트리": "068270", "셀트리온": "068270",
+ "셀트리온헬스케어": "091990",
+ "한미약": "128940", "한미약품": "128940",
+ "유한양": "000100", "유한양행": "000100",
+ "녹십자": "006280",
+ "알테오젠": "196170",
+ "에이비엘": "298380", "에이비엘바이오": "298380",
+ "리가켐": "141080", "리가켐바이오": "141080",
+ # 인터넷/IT
+ "네이버": "035420", "NAVER": "035420",
+ "카카오": "035720",
+ "카뱅": "323410", "카카오뱅크": "323410",
+ "카페이": "377300", "카카오페이": "377300",
+ "카게임": "293490", "카카오게임즈": "293490",
+ # 게임/엔터
+ "엔씨": "036570", "엔씨소프트": "036570",
+ "넷마블": "251270",
+ "크래프톤": "259960", "크라프톤": "259960",
+ "위메이드": "112040",
+ "펄어비스": "263750",
+ "하이브": "352820", "BTS하이브": "352820",
+ "SM": "041510", "에스엠": "041510",
+ "JYP": "035900", "JYPEnt": "035900",
+ "와이지": "122870", "YG": "122870",
+ # 방산/조선
+ "한에솔": "012450", "한화에어": "012450", "한화에어로": "012450", "한화에어로스페이스": "012450",
+ "LIG넥": "079550", "LIG넥스원": "079550",
+ "현대로템": "064350", "로템": "064350",
+ "HD현중": "329180", "HD현대중공업": "329180", "현중": "329180",
+ "한국조선해양": "009540", "HD한국조선": "009540", "HD현대": "267250",
+ "삼성중공업": "010140", "삼중": "010140",
+ "한화오션": "042660", "대우조선": "042660",
+ "한화시스템": "272210",
+ # 화학/소재
+ "LG화학": "051910",
+ "롯데케미칼": "011170", "롯데케": "011170",
+ "한화솔루션": "009830", "한솔": "009830",
+ "효성첨단": "298050", "효성첨단소재": "298050",
+ "코오롱인": "120110", "코오롱인더": "120110",
+ "금호석화": "011780", "금호석유": "011780",
+ "OCI": "010060",
+ # 철강/소재
+ "포홀": "005490", "POSCO": "005490", "포스코홀딩스": "005490",
+ "POSCO홀딩스": "005490", "포스코": "005490",
+ "현대제철": "004020", "현제철": "004020",
+ "고려아연": "010130",
+ # 금융/지주
+ "KB금융": "105560", "케이비금융": "105560",
+ "신한지주": "055550", "신한금융": "055550",
+ "하나금융": "086790", "하나금융지주": "086790",
+ "우리금융": "316140", "우리금융지주": "316140",
+ "기업은행": "024110", "IBK기업은행": "024110",
+ "메리츠금융": "138040", "메리츠금융지주": "138040",
+ "삼성생명": "032830", "삼생": "032830",
+ "삼성화재": "000810", "삼화": "000810",
+ "DB손보": "005830", "DB손해보험": "005830",
+ "현대해상": "001450",
+ "한투지": "071050", "한국금융지주": "071050",
+ "키움증권": "039490", "키움": "039490",
+ "미래에셋": "006800", "미래에셋증권": "006800",
+ # 통신
+ "SK텔": "017670", "SKT": "017670", "SK텔레콤": "017670",
+ "KT": "030200",
+ "LGU+": "032640", "LG유플러스": "032640", "엘지유플": "032640",
+ # 유통/소비재
+ "이마트": "139480",
+ "롯데쇼핑": "023530",
+ "신세계": "004170",
+ "현대백화점": "069960",
+ "GS리테일": "007070",
+ "BGF리테일": "282330", "씨유": "282330", "CU": "282330",
+ "오리온": "271560",
+ "농심": "004370",
+ "삼양식품": "003230", "삼양": "003230",
+ "CJ제일제당": "097950", "CJ제일": "097950",
+ "오뚜기": "007310",
+ "하이트진로": "000080",
+ "롯데칠성": "005300",
+ "코카콜라음료": "005440",
+ "아모레퍼시픽": "090430", "아모레": "090430",
+ "LG생건": "051900", "LG생활건강": "051900",
+ "코스맥스": "192820",
+ "한국콜마": "161890",
+ # 건설
+ "삼성물산": "028260",
+ "현대건설": "000720", "현건": "000720",
+ "GS건설": "006360",
+ "대우건설": "047040",
+ "DL이앤씨": "375500", "디엘이앤씨": "375500",
+ "HDC현대산업개발": "294870", "HDC현산": "294870",
+ # 에너지/유틸리티
+ "한전": "015760", "한국전력": "015760",
+ "한국가스공사": "036460", "한가공": "036460",
+ "두산에너빌리티": "034020", "두산에너": "034020", "두에빌": "034020",
+ "두산퓨얼셀": "336260", "두산퓨얼": "336260",
+ "두산밥캣": "241560",
+ "두산": "000150",
+ "S-Oil": "010950", "S오일": "010950", "에쓰오일": "010950",
+ "SK이노베이션": "096770",
+ "GS": "078930",
+ "한화": "000880",
+ # 항공/운송
+ "대한항공": "003490", "대항": "003490",
+ "아시아나": "020560", "아시아나항공": "020560",
+ "제주항공": "089590",
+ "진에어": "272450",
+ "티웨이항공": "091810",
+ "HMM": "011200",
+ "팬오션": "028670",
+ "현대글로비스": "086280",
+ # 우주/위성
+ "한화에어로스페이스": "012450",
+ "쎄트렉아이": "099320", "쎄트렉": "099320",
+ "인텔리안테크": "189300", "인텔리안": "189300",
+ "AP위성": "211270",
+ # AI/디스플레이
+ "LG디스플": "034220", "LG디스플레이": "034220", "엘지디스플레이": "034220",
+ "더존비즈온": "012510", "더존": "012510",
+ "삼성전기": "009150", "삼전기": "009150",
+ "LG이노텍": "011070",
+ # 보안/SI
+ "안랩": "053800",
+ "케이아이엔엑스": "093320", "KINX": "093320",
}
diff --git a/dart-collector/main.py b/dart-collector/main.py
index 0e1239e..e5892be 100644
--- a/dart-collector/main.py
+++ b/dart-collector/main.py
@@ -578,6 +578,8 @@ async def collect_financials_for_top_stocks(count: int = 300, years: int = 2, an
"부채총계": ("부채총계",),
"자본총계": ("자본총계",),
"영업활동현금흐름": ("영업활동현금흐름", "영업활동으로 인한 현금흐름"),
+ "자기주식취득": ("자기주식의 취득", "자기주식의취득",
+ "자기주식 취득", "자기주식취득"),
}
key_items: dict = {}
for item in items:
@@ -628,14 +630,14 @@ async def save_financial(
revenue, operating_profit, net_income, total_assets,
total_liabilities, total_equity, operating_cashflow,
roe, operating_margin, net_margin, debt_ratio, fcf_ratio, revenue_growth,
- collected_at)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)
+ treasury_acquired, collected_at)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)
ON CONFLICT (stock_code, bsns_year, reprt_code) DO UPDATE SET
revenue=$7, operating_profit=$8, net_income=$9,
total_assets=$10, total_liabilities=$11, total_equity=$12,
operating_cashflow=$13, roe=$14, operating_margin=$15,
net_margin=$16, debt_ratio=$17, fcf_ratio=$18, revenue_growth=$19,
- collected_at=$20
+ treasury_acquired=$20, collected_at=$21
""",
stock_code, corp_code, corp_name, bsns_year, reprt_code, reprt_name,
key_items.get("매출액", 0),
@@ -651,6 +653,7 @@ async def save_financial(
ratios.get("debt_ratio", 0.0),
ratios.get("fcf_ratio", 0.0),
ratios.get("revenue_growth", 0.0),
+ abs(key_items.get("자기주식취득", 0)),
datetime.now(),
)
except Exception as e:
@@ -1033,6 +1036,7 @@ async def init_db():
""")
await conn.execute("CREATE INDEX IF NOT EXISTS idx_fin_stock ON dart_financials(stock_code)")
await conn.execute("CREATE INDEX IF NOT EXISTS idx_fin_year ON dart_financials(bsns_year DESC)")
+ await conn.execute("ALTER TABLE dart_financials ADD COLUMN IF NOT EXISTS treasury_acquired BIGINT DEFAULT 0")
await conn.execute("""
CREATE TABLE IF NOT EXISTS dart_dividends (
diff --git a/docker-compose.yml b/docker-compose.yml
index 11ddcac..959b7e8 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -54,6 +54,8 @@ services:
BAREUN_API_URL: "http://bareunaapi:5757"
OLLAMA_URL: "http://ollama:11434"
QDRANT_URL: "http://qdrant:6333"
+ GEMINI_API_KEY: "${GEMINI_API_KEY:-}"
+ GEMINI_MODEL: "${GEMINI_MODEL:-gemini-2.5-pro}"
TZ: "Asia/Seoul"
networks:
trading-net:
@@ -195,6 +197,8 @@ services:
TELEGRAM_BOT_TOKEN: "${TELEGRAM_BOT_TOKEN:-}"
TELEGRAM_CHAT_ID: "${TELEGRAM_CHAT_ID:-}"
ECOS_API_KEY: "${ECOS_API_KEY:-}"
+ GEMINI_API_KEY: "${GEMINI_API_KEY:-}"
+ GEMINI_MODEL: "${GEMINI_MODEL:-gemini-2.5-pro}"
TZ: "Asia/Seoul"
networks:
trading-net:
@@ -510,11 +514,40 @@ services:
networks:
trading-net:
ipv4_address: 172.30.0.9
+ # ── 메모리 튜닝 (32GB 호스트, 로컬 SSD 기준) ──
+ # shared_buffers 4GB (컨테이너 limit의 ~67% — Postgres hot cache)
+ # effective_cache_size 12GB (OS 페이지캐시 포함 추정 가용 캐시)
+ # work_mem 32MB (단일 sort/hash 메모리. max_conn × 동시쿼리 곱 주의)
+ # maintenance_work_mem 512MB (VACUUM/CREATE INDEX)
+ # random_page_cost 1.1 (SSD 비용 모델)
+ # effective_io_concurrency 200 (SSD)
+ # parallel_workers 병렬쿼리 활성화 (백테스트/학습용 큰 JOIN에 효과)
+ # WAL/checkpoint 쓰기 부하 줄이고 회복 시간 살짝 늘림 (적절 균형)
+ command: >
+ postgres
+ -c shared_buffers=4GB
+ -c effective_cache_size=12GB
+ -c work_mem=32MB
+ -c maintenance_work_mem=512MB
+ -c random_page_cost=1.1
+ -c effective_io_concurrency=200
+ -c max_parallel_workers_per_gather=4
+ -c max_parallel_workers=8
+ -c max_worker_processes=12
+ -c checkpoint_timeout=15min
+ -c max_wal_size=4GB
+ -c checkpoint_completion_target=0.9
+ -c wal_compression=on
+ -c log_min_duration_statement=1000
+ -c log_checkpoints=on
+ -c log_temp_files=10MB
+ -c jit=off
+ shm_size: 512mb
deploy:
resources:
limits:
- cpus: "2.0"
- memory: 2G
+ cpus: "4.0"
+ memory: 12G
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
@@ -583,7 +616,7 @@ services:
resources:
limits:
cpus: "4.0"
- memory: 512M
+ memory: 1G
healthcheck:
test: ["CMD-SHELL", "true"]
interval: 10s
@@ -655,7 +688,7 @@ services:
resources:
limits:
cpus: "4.0"
- memory: 512M
+ memory: 1G
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5757/health"]
interval: 15s
@@ -826,5 +859,5 @@ services:
resources:
limits:
cpus: "4.0"
- memory: 512M
+ memory: 1G
logging: *default-logging
diff --git a/kis-api/main.py b/kis-api/main.py
index c69625b..251f521 100644
--- a/kis-api/main.py
+++ b/kis-api/main.py
@@ -32,6 +32,8 @@ PG_PASS = os.getenv("POSTGRES_PASSWORD", "")
KIWOOM_APP_KEY = os.getenv("KIWOOM_APP_KEY", "")
KIWOOM_SECRET_KEY = os.getenv("KIWOOM_SECRET_KEY", "")
KIWOOM_BASE_URL = os.getenv("KIWOOM_BASE_URL", "https://api.kiwoom.com")
+TG_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
+TG_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
pg_pool: Optional[asyncpg.Pool] = None
redis_cl: Optional[aioredis.Redis] = None
@@ -68,6 +70,25 @@ class KiwoomToken:
kiwoom_token = KiwoomToken()
+
+class _RateLimiter:
+ """Kiwoom 글로벌 레이트리밋 — 초당 max_rate건으로 직렬 페이싱 (429 방지)."""
+ def __init__(self, max_rate: float):
+ self.min_gap = 1.0 / max_rate
+ self._last = 0.0
+ self._lock = asyncio.Lock()
+
+ async def acquire(self):
+ async with self._lock:
+ loop = asyncio.get_running_loop()
+ wait = self.min_gap - (loop.time() - self._last)
+ if wait > 0:
+ await asyncio.sleep(wait)
+ self._last = loop.time()
+
+# 실측: 페이싱 시 10/s도 정상. 보수적으로 6/s (≈2,300종목 6분 주기).
+kiwoom_rate = _RateLimiter(6.0)
+
# ── 키움 API 호출 헬퍼 ────────────────────────────────────
async def kiwoom_post(client: httpx.AsyncClient, endpoint: str, api_id: str,
@@ -82,8 +103,15 @@ async def kiwoom_post(client: httpx.AsyncClient, endpoint: str, api_id: str,
}
if next_key:
headers["next-key"] = next_key
- r = await client.post(f"{KIWOOM_BASE_URL}{endpoint}",
- headers=headers, json=body, timeout=15)
+ # 글로벌 페이싱 + 429(요청한도 초과) 재시도
+ for attempt in range(3):
+ await kiwoom_rate.acquire()
+ r = await client.post(f"{KIWOOM_BASE_URL}{endpoint}",
+ headers=headers, json=body, timeout=15)
+ if r.status_code == 429 and attempt < 2:
+ await asyncio.sleep(0.6 * (attempt + 1))
+ continue
+ break
if return_headers: # 연속조회용 cont-yn/next-key 응답헤더 필요
return r.json(), r.headers
return r.json()
@@ -440,6 +468,28 @@ async def get_stock_codes(limit: int = 0) -> list:
pass
return []
+
+async def get_priority_codes(min_mcap: int = 30_000_000_000) -> list:
+ """5분 수집 우선군 — 시총 min_mcap(기본 300억) 이상 활성종목 (잡주 제외).
+ 조회 실패·표본 부족 시 전체 종목으로 폴백."""
+ if pg_pool:
+ try:
+ rows = await pg_pool.fetch("""
+ SELECT d.stock_code FROM dart_corps d
+ JOIN LATERAL (
+ SELECT market_cap FROM stock_prices
+ WHERE stock_code=d.stock_code AND market_cap>0
+ ORDER BY collected_at DESC LIMIT 1) p ON true
+ WHERE d.is_active=TRUE AND p.market_cap >= $1
+ ORDER BY d.stock_code
+ """, min_mcap)
+ codes = [r["stock_code"] for r in rows if r["stock_code"]]
+ if len(codes) >= 50:
+ return codes
+ except Exception:
+ pass
+ return await get_stock_codes(0)
+
# ── 저장 함수 ─────────────────────────────────────────────
async def save_price(info: dict):
@@ -551,29 +601,88 @@ class Stats:
stats = Stats()
+_job_price_running = False
+
async def job_price():
- """평일 9~16시 5분마다: 현재가·재무지표 수집 (ka10001)"""
- codes = await get_stock_codes(0)
- if not codes:
- logger.warning("job_price.no_codes")
+ """평일 9~15시: 시총 300억+ 우선군 현재가 수집 (ka10001).
+ 잡주(시총<300억)는 후순위 — job_full(17시)에서 일봉으로 커버.
+ 글로벌 6/s 페이싱으로 한 사이클 수 분 소요 → 중복 실행 가드."""
+ global _job_price_running
+ if _job_price_running:
+ logger.info("job_price.skip_running")
return
- sem = asyncio.Semaphore(10) # 동시 10개 제한
- ok = 0
- async with httpx.AsyncClient() as client:
- tasks = [fetch_basic_info(client, code, sem) for code in codes]
- results = await asyncio.gather(*tasks, return_exceptions=True)
- for info in results:
- if isinstance(info, dict) and info.get("price", 0) > 0:
- await save_price(info)
- ok += 1
- else:
- stats.errors += 1
- await gen_signals()
- stats.collected += ok
- stats.last_run = datetime.now().isoformat()
- if redis_cl:
- await redis_cl.set("prices:last_update", datetime.now().isoformat())
- logger.info("job_price.done", ok=ok, total=len(codes))
+ _job_price_running = True
+ try:
+ codes = await get_priority_codes()
+ if not codes:
+ logger.warning("job_price.no_codes")
+ return
+ sem = asyncio.Semaphore(8) # 동시 in-flight 한도 (실제 속도는 kiwoom_rate가 제어)
+ ok = 0
+ async with httpx.AsyncClient() as client:
+ tasks = [fetch_basic_info(client, code, sem) for code in codes]
+ results = await asyncio.gather(*tasks, return_exceptions=True)
+ for info in results:
+ if isinstance(info, dict) and info.get("price", 0) > 0:
+ await save_price(info)
+ ok += 1
+ else:
+ stats.errors += 1
+ await gen_signals()
+ stats.collected += ok
+ stats.last_run = datetime.now().isoformat()
+ if redis_cl:
+ await redis_cl.set("prices:last_update", datetime.now().isoformat())
+ try:
+ await check_price_alerts()
+ except Exception as e:
+ logger.warning("alert.check_err", error=str(e))
+ logger.info("job_price.done", ok=ok, total=len(codes))
+ finally:
+ _job_price_running = False
+
+_job_priority_running = False
+
+async def job_price_priority():
+ """30초마다: 보유 + 강력매수 + 매수관심 종목만 빠른 수집 (자동매매·알림 latency 단축).
+ 키움 rate limit 안전 — 우선순위 종목은 보통 30~50건이라 6/s × 10초 이내 완료."""
+ global _job_priority_running
+ if _job_priority_running:
+ return
+ _job_priority_running = True
+ try:
+ async with pg_pool.acquire() as conn:
+ rows = await conn.fetch("""
+ SELECT DISTINCT stock_code FROM (
+ SELECT stock_code FROM user_portfolio WHERE active=true
+ UNION
+ SELECT stock_code FROM user_alerts WHERE active=true
+ UNION
+ SELECT stock_code FROM stock_scores
+ WHERE score_date=(SELECT MAX(score_date) FROM stock_scores)
+ AND recommendation IN ('강력매수','매수관심')
+ ) AS t LIMIT 60
+ """)
+ codes = [r["stock_code"] for r in rows]
+ if not codes:
+ return
+ sem = asyncio.Semaphore(8)
+ ok = 0
+ async with httpx.AsyncClient() as client:
+ tasks = [fetch_basic_info(client, c, sem) for c in codes]
+ results = await asyncio.gather(*tasks, return_exceptions=True)
+ for info in results:
+ if isinstance(info, dict) and info.get("price", 0) > 0:
+ await save_price(info)
+ ok += 1
+ try:
+ await check_price_alerts() # 알림 즉시 평가
+ except Exception as e:
+ logger.warning("priority.alert_err", error=str(e))
+ logger.info("job_price_priority.done", ok=ok, total=len(codes))
+ finally:
+ _job_priority_running = False
+
async def job_full(days: int = 10):
"""평일 17:00: 일봉·외국인·공매도 전체 수집 (ka10005·ka10008·ka10014).
@@ -736,9 +845,12 @@ async def startup():
redis_cl = aioredis.Redis(
host=REDIS_HOST, port=6379, password=REDIS_PASSWORD, db=3, decode_responses=True)
await init_db()
- # 현재가: 평일 9~16시 5분마다
+ # 현재가: 평일 9~16시 2분마다 (전체)
scheduler.add_job(job_price, "cron", day_of_week="mon-fri",
- hour="9-15", minute="*/5", id="price", replace_existing=True)
+ hour="9-15", minute="*/2", id="price", replace_existing=True)
+ # Fast track: 보유 + 강력매수 + 매수관심만 30초마다 (자동매매 latency)
+ scheduler.add_job(job_price_priority, "cron", day_of_week="mon-fri",
+ hour="9-15", second="*/30", id="price_priority", replace_existing=True)
# 장 마감 후 전체 수집: 평일 17:00
scheduler.add_job(job_full, "cron", day_of_week="mon-fri",
hour="17", minute="0", id="full", replace_existing=True)
@@ -858,6 +970,229 @@ async def summary(code: str):
result[field] = json.loads(c) if field != "price" else json.loads(c)
return JSONResponse(content=result)
+# ── 가격 알림 (user_alerts 트리거 + 5가지 물타기 진단) ────────
+
+async def send_telegram(msg: str):
+ if not TG_TOKEN or not TG_CHAT_ID:
+ return
+ try:
+ async with httpx.AsyncClient() as c:
+ await c.post(
+ f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage",
+ json={"chat_id": TG_CHAT_ID, "text": msg, "parse_mode": "HTML"},
+ timeout=10)
+ except Exception as e:
+ logger.warning("telegram.err", error=str(e))
+
+
+async def diagnose_avg_down(conn, code: str, current_price: float) -> tuple[int, list[str]]:
+ """물타기 진단 5가지 조건. (충족 개수, 표시줄 리스트) 반환."""
+ score = 0
+ lines = []
+
+ # 1. RSI ≤ 30 (과매도)
+ rsi = await conn.fetchval("""
+ SELECT rsi FROM stock_technical WHERE stock_code=$1
+ """, code)
+ rsi_disp = f"{rsi:.0f}" if rsi is not None else "N/A"
+ if rsi is not None and rsi <= 30:
+ score += 1
+ lines.append(f"✅ RSI {rsi_disp} (과매도)")
+ elif rsi is not None and rsi <= 40:
+ lines.append(f"⚠️ RSI {rsi_disp} (약한 과매도)")
+ else:
+ lines.append(f"❌ RSI {rsi_disp} (과매도 아님)")
+
+ # 2. 거래량 비율 ≤ 1.5 (패닉 매도 끝)
+ vol_ratio = await conn.fetchval("""
+ SELECT vol_ratio FROM stock_technical WHERE stock_code=$1
+ """, code)
+ vol_disp = f"{vol_ratio:.2f}" if vol_ratio is not None else "N/A"
+ if vol_ratio is not None and vol_ratio <= 1.5:
+ score += 1
+ lines.append(f"✅ 거래량비율 {vol_disp} (안정)")
+ else:
+ lines.append(f"❌ 거래량비율 {vol_disp} (패닉 진행)")
+
+ # 3. 공매도 잔고 감소 추세 (최근 5일)
+ short_rows = await conn.fetch("""
+ SELECT short_balance_qty FROM stock_short_sale
+ WHERE stock_code=$1 ORDER BY dt DESC LIMIT 5
+ """, code)
+ if len(short_rows) >= 2:
+ latest = short_rows[0]["short_balance_qty"] or 0
+ older = short_rows[-1]["short_balance_qty"] or 0
+ if older > 0 and latest < older:
+ chg = (latest - older) / older * 100
+ score += 1
+ lines.append(f"✅ 공매도잔고 {chg:+.1f}% (감소)")
+ else:
+ lines.append(f"❌ 공매도잔고 증가/유지")
+ else:
+ lines.append("⚠️ 공매도 데이터 부족")
+
+ # 4. 최근 24시간 악재(intensity≥3) 없음
+ bad_news = await conn.fetchval("""
+ SELECT COUNT(*) FROM news_analysis
+ WHERE primary_stock=$1
+ AND sentiment='악재' AND intensity >= 3
+ AND analyzed_at >= NOW() - INTERVAL '24 hours'
+ """, code)
+ if bad_news == 0:
+ score += 1
+ lines.append("✅ 최근 악재 없음")
+ else:
+ lines.append(f"❌ 최근 악재 {bad_news}건")
+
+ # 5. 시장 레짐 = 강세/중립 (약세장 회피)
+ regime = await conn.fetchval("""
+ SELECT regime FROM market_regime ORDER BY dt DESC LIMIT 1
+ """)
+ if regime in ("강세", "중립"):
+ score += 1
+ lines.append(f"✅ 시장레짐 {regime}")
+ else:
+ lines.append(f"❌ 시장레짐 {regime} (약세장 회피)")
+
+ return score, lines
+
+
+async def check_price_alerts():
+ """수집 후 호출: active=true 알림 중 임계값 도달한 것 처리."""
+ if not pg_pool:
+ return
+ async with pg_pool.acquire() as conn:
+ alerts = await conn.fetch("""
+ SELECT a.id, a.stock_code, a.alert_type, a.threshold,
+ d.corp_name
+ FROM user_alerts a
+ LEFT JOIN dart_corps d ON d.stock_code=a.stock_code
+ WHERE a.active = true
+ """)
+ for a in alerts:
+ code = a["stock_code"]
+ price = 0.0
+ # 1차: Redis price:{code} (평일 수집 직후)
+ cached = await redis_cl.get(f"price:{code}") if redis_cl else None
+ if cached:
+ try:
+ price = float(json.loads(cached).get("price", 0))
+ except Exception:
+ price = 0.0
+ # 2차 폴백: stock_ohlcv 최신 종가 (장 닫힘·주말·실패 대비)
+ if price <= 0:
+ p = await conn.fetchval("""
+ SELECT close_price FROM stock_ohlcv
+ WHERE stock_code=$1 ORDER BY dt DESC LIMIT 1
+ """, code)
+ price = float(p) if p else 0.0
+ if price <= 0:
+ continue
+ hit = False
+ if a["alert_type"] == "price_below" and price <= a["threshold"]:
+ hit = True
+ elif a["alert_type"] == "price_above" and price >= a["threshold"]:
+ hit = True
+ if not hit:
+ continue
+
+ score, lines = await diagnose_avg_down(conn, code, price)
+ verdict = "🟢 물타기 OK" if score >= 4 else ("🟡 신중" if score >= 3 else "🔴 NO")
+ name = a["corp_name"] or code
+
+ # LLM hybrid 분석 동봉 (score-engine /deep-analysis 호출)
+ llm_block = ""
+ try:
+ async with httpx.AsyncClient(timeout=120) as c:
+ r = await c.get(
+ f"http://score-engine:8686/deep-analysis/{code}",
+ params={"refresh": "true", "model": "hybrid", "notify": "false"})
+ if r.status_code == 200:
+ d = r.json()
+ if not d.get("error"):
+ agr = d.get("agreement")
+ agr_tag = "✅두AI일치" if agr is True else ("⚠️AI의견갈림" if agr is False else "")
+ llm_block = (
+ f"\n\n🤖 AI 하이브리드 분석 {agr_tag}\n"
+ f"판단: {d.get('recommendation','-')} (확신도 {d.get('conviction',0)}/5)\n"
+ f"논거: {d.get('thesis','')[:250]}\n"
+ f"🎯 목표 {int(d.get('target_price') or 0):,}원 / "
+ f"손절 {int(d.get('stop_loss') or 0):,}원"
+ )
+ if agr is False and d.get("exaone"):
+ e = d["exaone"]
+ llm_block += (
+ f"\n🔄 EXAONE 의견: {e.get('recommendation')} "
+ f"{e.get('conviction',0)}/5 — {e.get('thesis','')[:120]}"
+ )
+ except Exception as e:
+ logger.warning("alert.llm_err", code=code, error=str(e))
+
+ msg = (
+ f"🔔 가격 알림: {name} ({code})\n"
+ f"현재가: {int(price):,}원\n"
+ f"임계값: {int(a['threshold']):,}원 ({a['alert_type']})\n\n"
+ f"📊 물타기 진단 ({score}/5)\n"
+ + "\n".join(lines)
+ + f"\n\n종합: {verdict}"
+ + llm_block
+ )
+ await send_telegram(msg)
+ await conn.execute("""
+ UPDATE user_alerts
+ SET active=false, last_triggered=NOW()
+ WHERE id=$1
+ """, a["id"])
+ logger.info("alert.fired", code=code, price=price,
+ threshold=a["threshold"], verdict=verdict)
+
+
+@app.post("/alerts/register")
+async def register_alert(code: str = Query(...),
+ alert_type: str = Query(..., regex="^(price_below|price_above)$"),
+ threshold: float = Query(..., gt=0),
+ user_id: int = Query(default=1)):
+ async with pg_pool.acquire() as c:
+ row = await c.fetchrow("""
+ INSERT INTO user_alerts (user_id, stock_code, alert_type, threshold, active)
+ VALUES ($1, $2, $3, $4, true)
+ RETURNING id
+ """, user_id, code, alert_type, threshold)
+ return {"id": row["id"], "code": code, "alert_type": alert_type, "threshold": threshold}
+
+
+@app.get("/alerts")
+async def list_alerts(active_only: bool = Query(default=True)):
+ async with pg_pool.acquire() as c:
+ if active_only:
+ rows = await c.fetch("""
+ SELECT a.*, d.corp_name FROM user_alerts a
+ LEFT JOIN dart_corps d ON d.stock_code=a.stock_code
+ WHERE a.active=true ORDER BY a.created_at DESC
+ """)
+ else:
+ rows = await c.fetch("""
+ SELECT a.*, d.corp_name FROM user_alerts a
+ LEFT JOIN dart_corps d ON d.stock_code=a.stock_code
+ ORDER BY a.created_at DESC LIMIT 50
+ """)
+ return [dict(r) for r in rows]
+
+
+@app.post("/alerts/check")
+async def trigger_alert_check():
+ """수동 알림 체크 — 주말·장마감·테스트 시 사용 (job_price 내 자동 호출 외 추가 경로)."""
+ await check_price_alerts()
+ return {"status": "checked"}
+
+
+@app.delete("/alerts/{alert_id}")
+async def delete_alert(alert_id: int):
+ async with pg_pool.acquire() as c:
+ await c.execute("UPDATE user_alerts SET active=false WHERE id=$1", alert_id)
+ return {"status": "deactivated", "id": alert_id}
+
+
@app.post("/collect/price")
async def collect_price():
asyncio.create_task(job_price())
diff --git a/news-collector/requirements.txt b/news-collector/requirements.txt
index 2f2ab80..a7d8fbe 100644
--- a/news-collector/requirements.txt
+++ b/news-collector/requirements.txt
@@ -8,3 +8,5 @@ lxml==5.2.2
apscheduler==3.10.4
orjson==3.10.3
structlog==24.2.0
+google-genai==0.8.0
+pydantic>=2.0
diff --git a/news-collector/sentiment_rules.py b/news-collector/sentiment_rules.py
new file mode 100644
index 0000000..b5529c8
--- /dev/null
+++ b/news-collector/sentiment_rules.py
@@ -0,0 +1,96 @@
+"""뉴스 sentiment 키워드 룰 사전 + 매칭 함수.
+
+bareunaapi/finance_dict.py와 동일 사전 (컨테이너 분리로 직접 임포트 불가 → 중복 허용).
+"""
+import re
+
+POSITIVE_TERMS: set[str] = {
+ "어닝서프라이즈","수주","대형수주","계약체결","계약수주","임상성공","FDA승인",
+ "품목허가","신고가","52주신고가","역사적신고가","급등","폭등","상한가",
+ "따상","따따상","골든크로스","외국인순매수","기관순매수","배당증가",
+ "자사주매입","자사주소각","M&A","인수합병","기술이전","라이센스아웃",
+ "마일스톤","가이던스상향","실적개선","흑자전환","증익","대왕고래발견",
+ "특허등록","임상승인","조건부허가","수출수주","폴란드수출",
+}
+
+NEGATIVE_TERMS: set[str] = {
+ "어닝쇼크","어닝미스","임상실패","FDA거절","품목허가취소","리콜","급락",
+ "폭락","하한가","신저가","52주신저가","상장폐지","관리종목","거래정지",
+ "데드크로스","외국인순매도","기관순매도","공매도잔고증가","감자","유상증자",
+ "유상감자","최대주주변경","CEO교체","대표이사사임","감사의견거절","의견거절",
+ "한정의견","부적정의견","횡령","배임","분식회계","회계부정","피소","제재",
+ "영업정지","과징금","가이던스하향","적자전환","감익","파산","법정관리",
+ "특허침해","불승인","무산","해지",
+}
+
+CATALYST_MAP: dict[str, str] = {
+ "수주":"수주","대형수주":"수주","계약체결":"수주","계약수주":"수주",
+ "MOU":"수주","LOI":"수주","수출수주":"수주","폴란드수출":"수주",
+ "어닝서프라이즈":"실적","어닝쇼크":"실적","어닝미스":"실적",
+ "가이던스상향":"실적","가이던스하향":"실적","실적개선":"실적",
+ "흑자전환":"실적","적자전환":"실적","증익":"실적","감익":"실적",
+ "FDA승인":"실적","임상성공":"실적","품목허가":"실적","임상승인":"실적",
+ "기술이전":"실적","라이센스아웃":"실적","마일스톤":"실적","특허등록":"실적",
+ "배당증가":"배당","자사주매입":"배당","자사주소각":"배당",
+ "분식회계":"리스크","회계부정":"리스크","리콜":"리스크","감자":"리스크",
+ "유상증자":"리스크","유상감자":"리스크","상장폐지":"리스크","관리종목":"리스크",
+ "거래정지":"리스크","감사의견거절":"리스크","의견거절":"리스크",
+ "부적정의견":"리스크","한정의견":"리스크","횡령":"리스크","배임":"리스크",
+ "피소":"리스크","제재":"리스크","영업정지":"리스크","과징금":"리스크",
+ "임상실패":"리스크","FDA거절":"리스크","품목허가취소":"리스크","특허침해":"리스크",
+ "파산":"리스크","법정관리":"리스크","최대주주변경":"리스크","CEO교체":"리스크",
+ "신고가":"모멘텀","52주신고가":"모멘텀","역사적신고가":"모멘텀",
+ "급등":"모멘텀","폭등":"모멘텀","상한가":"모멘텀","따상":"모멘텀","따따상":"모멘텀",
+ "신저가":"모멘텀","52주신저가":"모멘텀","급락":"모멘텀","폭락":"모멘텀","하한가":"모멘텀",
+ "골든크로스":"모멘텀","데드크로스":"모멘텀",
+ "외국인순매수":"모멘텀","기관순매수":"모멘텀","외국인순매도":"모멘텀","기관순매도":"모멘텀",
+ "공매도잔고증가":"모멘텀",
+}
+
+
+def keyword_rule(text: str) -> dict:
+ """text 안의 POSITIVE/NEGATIVE 단어 카운트 → 룰 기반 sentiment 후보.
+ sentiment None이면 결정 불가 (애매) → LLM으로 위임.
+ """
+ if not text:
+ return {"pos": 0, "neg": 0, "sentiment": None, "catalyst": "기타",
+ "intensity": 0, "pos_hits": [], "neg_hits": []}
+ pos_hits = sorted({w for w in POSITIVE_TERMS if w in text})
+ neg_hits = sorted({w for w in NEGATIVE_TERMS if w in text})
+ pos, neg = len(pos_hits), len(neg_hits)
+ sentiment = None
+ if pos >= 2 and neg == 0:
+ sentiment = "호재"
+ elif neg >= 2 and pos == 0:
+ sentiment = "악재"
+ catalyst = "기타"
+ hits_for_cat = pos_hits if sentiment == "호재" else neg_hits
+ for w in hits_for_cat:
+ if w in CATALYST_MAP:
+ catalyst = CATALYST_MAP[w]
+ break
+ return {
+ "pos": pos, "neg": neg, "sentiment": sentiment, "catalyst": catalyst,
+ "intensity": min(max(pos, neg), 5) if sentiment else 0,
+ "pos_hits": pos_hits, "neg_hits": neg_hits,
+ }
+
+
+_NOUN_RE = re.compile(r"[가-힣]{2,}")
+_STOPWORDS = {
+ "있다","없다","있으며","위해","대해","대한","따라","기업","종목","주가",
+ "투자","시장","최근","이번","현재","오늘","전망","판단","결과","증가","감소",
+ "보이","나타","제시","의미","수준","상황","경우","가능","필요","효과","영향",
+}
+
+def hallucination_match_ratio(reason: str, source_text: str) -> float:
+ """reason의 한글 명사(2자 이상)가 source_text에 등장하는 비율(0~1).
+ 1.0=모든 단어 원문에 있음, 0.0=전부 지어냄. stopword 제외.
+ """
+ if not reason or not source_text:
+ return 1.0
+ words = {w for w in _NOUN_RE.findall(reason) if w not in _STOPWORDS}
+ if not words:
+ return 1.0
+ matched = sum(1 for w in words if w in source_text)
+ return matched / len(words)
diff --git a/score-engine/requirements.txt b/score-engine/requirements.txt
index 5f8e0da..b92ecad 100644
--- a/score-engine/requirements.txt
+++ b/score-engine/requirements.txt
@@ -11,3 +11,4 @@ numpy==1.26.4
xgboost==2.0.3
arch==7.0.0
scipy==1.13.1
+google-genai==0.8.0
diff --git a/us-market/main.py b/us-market/main.py
index aed16b9..cdc5e60 100644
--- a/us-market/main.py
+++ b/us-market/main.py
@@ -829,6 +829,110 @@ async def etf_latest(etf: str):
return dict(row) if row else {"err": "no data"}
+# ─────────────────────────────────────────────────────────────
+# 미국 티커 → 한국어 라벨 (브리핑 표시용 — 비전문가도 알아보게)
+# ─────────────────────────────────────────────────────────────
+US_TICKER_LABEL = {
+ "NVDA":"엔비디아 (AI반도체)","AMD":"AMD (반도체)","MU":"마이크론 (메모리반도체)",
+ "INTC":"인텔 (반도체)","TSM":"TSMC (파운드리)","AVGO":"브로드컴 (반도체)",
+ "QCOM":"퀄컴 (모바일칩)","TXN":"텍사스인스트루먼트 (반도체)",
+ "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":"브리스톨마이어스 (제약)",
+ "GILD":"길리어드 (바이오)",
+ "WMT":"월마트 (유통)","COST":"코스트코 (유통)","HD":"홈디포 (유통)",
+ "MCD":"맥도날드","NKE":"나이키","SBUX":"스타벅스","DIS":"디즈니 (엔터)",
+ "BA":"보잉 (항공)","LMT":"록히드마틴 (방산)","RTX":"RTX (방산)",
+ "GD":"제너럴다이내믹스 (방산)","CAT":"캐터필러 (중장비)","DE":"디어 (중장비)",
+ "F":"포드 (자동차)","GM":"제너럴모터스 (자동차)","TM":"도요타 (자동차)",
+ "ALB":"앨버말 (리튬)","RIVN":"리비안 (전기차)",
+ "DOW":"다우 (화학)","LYB":"라이온델바젤 (화학)",
+}
+ETF_KR_LABEL = {
+ "SOXX":"미국 반도체 ETF","SMH":"미국 반도체 ETF","XLK":"미국 기술주 ETF",
+ "QQQ":"나스닥100 ETF","XBI":"미국 바이오 ETF","IBB":"미국 바이오 ETF",
+ "LIT":"글로벌 리튬·2차전지 ETF","XLE":"미국 에너지 ETF","XLF":"미국 금융 ETF",
+ "XLV":"미국 헬스케어 ETF","XLI":"미국 산업재 ETF","XLP":"미국 필수소비재 ETF",
+ "XLY":"미국 경기소비재 ETF","ITA":"미국 항공·방산 ETF",
+}
+
+
+@app.get("/overnight-briefing")
+async def overnight_briefing(top: int = Query(default=6, ge=3, le=15)):
+ """오늘 새벽(=어젯밤 미국장) 핫/저조 종목 + 관련 KOSPI 종목 추천.
+ 한국어 라벨로 비전문가도 바로 이해하게 구성."""
+ etf_set = set(SECTOR_ETFS.keys())
+ async with pg_pool.acquire() as conn:
+ latest = await conn.fetchval("SELECT MAX(trade_date) FROM us_market_daily")
+ if not latest:
+ return {"err": "미국 시장 데이터 없음", "hot": [], "cold": [], "sector_heat": []}
+ rows = await conn.fetch("""
+ SELECT ticker, change_pct FROM us_market_daily
+ WHERE trade_date=$1 AND change_pct IS NOT NULL
+ """, latest)
+ stocks = sorted([(r["ticker"], float(r["change_pct"])) for r in rows
+ if r["ticker"] not in etf_set], key=lambda x: -x[1])
+ hot = [s for s in stocks if s[1] > 0.5][:top]
+ cold = sorted([s for s in stocks if s[1] < -0.5], key=lambda x: x[1])[:top]
+
+ pair_rows = await conn.fetch("""
+ SELECT p.us_ticker, p.kr_code, p.beta_60d, d.corp_name,
+ s.total_score, s.recommendation
+ FROM us_kr_pairs p
+ JOIN dart_corps d ON d.stock_code = p.kr_code AND d.is_active = true
+ LEFT JOIN (
+ SELECT DISTINCT ON (stock_code) stock_code, total_score, recommendation
+ FROM stock_scores ORDER BY stock_code, score_date DESC
+ ) s ON s.stock_code = p.kr_code
+ """)
+ etf_rows = await conn.fetch("""
+ SELECT ticker, change_pct FROM us_market_daily
+ WHERE trade_date=$1 AND ticker = ANY($2) AND change_pct IS NOT NULL
+ """, latest, list(etf_set))
+
+ pairs_by_us: Dict[str, List] = {}
+ for r in pair_rows:
+ pairs_by_us.setdefault(r["us_ticker"], []).append(r)
+
+ def related(tk: str) -> list:
+ out, seen = [], set()
+ for r in pairs_by_us.get(tk, []):
+ if r["kr_code"] in seen:
+ continue
+ seen.add(r["kr_code"])
+ out.append({
+ "kr_code": r["kr_code"],
+ "kr_name": r["corp_name"] or r["kr_code"],
+ "beta": round(float(r["beta_60d"]), 2) if r["beta_60d"] else None,
+ "ai_score": round(float(r["total_score"]), 1) if r["total_score"] is not None else None,
+ "recommendation": r["recommendation"] or "-",
+ })
+ out.sort(key=lambda x: x["ai_score"] if x["ai_score"] is not None else -999,
+ reverse=True)
+ return out[:4]
+
+ def pack(lst: list) -> list:
+ return [{"ticker": t, "label": US_TICKER_LABEL.get(t, t),
+ "change_pct": round(c, 2), "related_kr": related(t)} for t, c in lst]
+
+ sector_heat = sorted([
+ {"etf": r["ticker"], "label": ETF_KR_LABEL.get(r["ticker"], r["ticker"]),
+ "change_pct": round(float(r["change_pct"]), 2)} for r in etf_rows
+ ], key=lambda x: -x["change_pct"])
+
+ return {"trade_date": str(latest), "hot": pack(hot), "cold": pack(cold),
+ "sector_heat": sector_heat}
+
+
@app.get("/stats")
async def stats_endpoint():
async with pg_pool.acquire() as conn: