이번 세션 외 그간 쌓인 변경 일괄 저장: - 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:
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user