chore: 누적 미커밋 작업분 일괄 커밋
trading/pipeline/head This commit looks good

이번 세션 외 그간 쌓인 변경 일괄 저장:
- bareunaapi: finance_dict 금융용어 / stock_loader 종목 로더 보강
- kis-api: 키움 토큰·수집 로직
- us-market / dart-collector: 수집 보강
- docker-compose: GEMINI_API_KEY 등 환경변수 추가
- score-engine/news-collector requirements, CLAUDE.md
- 신규: PROJECT.md, news-collector/sentiment_rules.py

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
kyu
2026-06-02 01:24:16 +09:00
parent 8b200e6cc6
commit 97cf5aecb0
11 changed files with 1105 additions and 52 deletions
+6 -5
View File
@@ -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)
+134
View File
@@ -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회로 축소
+205 -8
View File
@@ -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",
# 글로벌 충격
"지정학","대만해협","리세션","경기침체","경착륙","연착륙","스태그플레이션",
}
+152 -6
View File
@@ -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",
}
+7 -3
View File
@@ -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 (
+38 -5
View File
@@ -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
+340 -5
View File
@@ -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
# 글로벌 페이싱 + 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,13 +601,23 @@ class Stats:
stats = Stats()
_job_price_running = False
async def job_price():
"""평일 9~16시 5분마다: 현재가·재무지표 수집 (ka10001)"""
codes = await get_stock_codes(0)
"""평일 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
_job_price_running = True
try:
codes = await get_priority_codes()
if not codes:
logger.warning("job_price.no_codes")
return
sem = asyncio.Semaphore(10) # 동시 10개 제한
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]
@@ -573,7 +633,56 @@ async def job_price():
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🤖 <b>AI 하이브리드 분석 {agr_tag}</b>\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"🔔 <b>가격 알림: {name} ({code})</b>\n"
f"현재가: {int(price):,}\n"
f"임계값: {int(a['threshold']):,}원 ({a['alert_type']})\n\n"
f"📊 <b>물타기 진단 ({score}/5)</b>\n"
+ "\n".join(lines)
+ f"\n\n<b>종합: {verdict}</b>"
+ 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())
+2
View File
@@ -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
+96
View File
@@ -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)
+1
View File
@@ -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
+104
View File
@@ -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: