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: