Compare commits
2 Commits
a81feb48d8
...
97cf5aecb0
| Author | SHA1 | Date | |
|---|---|---|---|
| 97cf5aecb0 | |||
| 8b200e6cc6 |
@@ -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 (
|
||||
|
||||
+534
-71
@@ -7,7 +7,7 @@
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;800;900&family=JetBrains+Mono:wght@400;600;700&display=swap');
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:#0D1117;color:#E0E0E0;font-family:'Noto Sans KR',sans-serif;padding:0 24px 40px}
|
||||
body{background:#0D1117;color:#E0E0E0;font-family:'Noto Sans KR',sans-serif;padding:0}
|
||||
::-webkit-scrollbar{width:6px}::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.1);border-radius:3px}
|
||||
.wrap{max-width:1400px;margin:0 auto}
|
||||
.header{display:flex;justify-content:space-between;align-items:center;padding:20px 0;border-bottom:1px solid rgba(255,255,255,0.06);margin-bottom:24px}
|
||||
@@ -15,9 +15,26 @@ body{background:#0D1117;color:#E0E0E0;font-family:'Noto Sans KR',sans-serif;padd
|
||||
.header .sub{font-size:12px;color:#546E7A;margin-top:4px}
|
||||
.header .time{font-size:13px;color:#607D8B;font-family:'JetBrains Mono',monospace}
|
||||
.btn{background:rgba(0,230,118,0.1);border:1px solid rgba(0,230,118,0.2);border-radius:8px;padding:8px 16px;color:#00E676;font-size:12px;cursor:pointer;font-weight:600}
|
||||
.tabs{display:flex;gap:4px;margin-bottom:24px}
|
||||
.tab{background:transparent;border:1px solid transparent;border-radius:8px;padding:8px 16px;color:#607D8B;font-size:13px;font-weight:500;cursor:pointer}
|
||||
.sidebar{position:fixed;left:0;top:0;bottom:0;width:212px;background:#0A0E14;border-right:1px solid rgba(255,255,255,0.06);padding:18px 12px;overflow-y:auto;z-index:200;transition:transform .25s ease}
|
||||
.brand{font-size:16px;font-weight:800;letter-spacing:-0.5px;padding:4px 10px 16px;display:flex;align-items:center;gap:7px;color:#E0E0E0}
|
||||
.brand span{color:#00E676}
|
||||
.tabs{display:flex;flex-direction:column;gap:2px;margin:0}
|
||||
.tab{display:flex;align-items:center;gap:8px;width:100%;text-align:left;background:transparent;border:1px solid transparent;border-radius:8px;padding:10px 12px;color:#90A4AE;font-size:13px;font-weight:500;cursor:pointer;font-family:inherit;transition:background .15s}
|
||||
.tab:hover{background:rgba(255,255,255,0.04);color:#CFD8DC}
|
||||
.tab.active{background:rgba(0,230,118,0.1);border-color:rgba(0,230,118,0.2);color:#00E676;font-weight:700}
|
||||
.main{margin-left:212px;padding:0 24px 40px;min-height:100vh}
|
||||
.hamburger{display:none;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);border-radius:8px;width:40px;height:40px;color:#CFD8DC;font-size:18px;cursor:pointer;flex-shrink:0;align-items:center;justify-content:center}
|
||||
.sidebar-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:150}
|
||||
@media(max-width:860px){
|
||||
.sidebar{transform:translateX(-100%);width:248px;box-shadow:6px 0 28px rgba(0,0,0,0.55)}
|
||||
.sidebar.open{transform:translateX(0)}
|
||||
.main{margin-left:0;padding:0 14px 40px}
|
||||
.hamburger{display:flex}
|
||||
.sidebar-overlay.show{display:block}
|
||||
.header{flex-wrap:wrap}
|
||||
.header h1{font-size:17px}
|
||||
.header .sub{display:none}
|
||||
}
|
||||
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:24px}
|
||||
.card{background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06);border-radius:16px;padding:24px 20px}
|
||||
.card .label{font-size:13px;color:#90A4AE;letter-spacing:1px;margin-bottom:8px}
|
||||
@@ -25,6 +42,7 @@ body{background:#0D1117;color:#E0E0E0;font-family:'Noto Sans KR',sans-serif;padd
|
||||
.card .sub{font-size:12px;color:#607D8B;margin-top:8px}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||
@media(max-width:900px){.grid2{grid-template-columns:1fr}}
|
||||
@media(max-width:680px){.usbrief-grid{grid-template-columns:1fr !important}}
|
||||
.panel{background:rgba(255,255,255,0.02);border:1px solid rgba(255,255,255,0.05);border-radius:16px;padding:20px;max-height:600px;overflow:auto}
|
||||
.panel h3{font-size:14px;font-weight:700;color:#90A4AE;margin-bottom:16px}
|
||||
.rank-item{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid rgba(255,255,255,0.03)}
|
||||
@@ -108,7 +126,7 @@ tr[data-stock]:hover{background:rgba(0,230,118,0.06) !important}
|
||||
body.auth-required .wrap, body.auth-required #chat-btn, body.auth-required #chat-panel { display:none !important; }
|
||||
body:not(.auth-required) #login-screen { display:none !important; }
|
||||
|
||||
#login-screen{position:fixed;inset:0;background:#0E141A;display:flex;align-items:center;justify-content:center;padding:20px;z-index:100;overflow-y:auto}
|
||||
#login-screen{position:fixed;inset:0;background:#0E141A;display:flex;align-items:center;justify-content:center;padding:20px;z-index:300;overflow-y:auto}
|
||||
.login-card{background:#141B22;border:1px solid rgba(0,230,118,0.18);border-radius:20px;padding:40px;width:420px;max-width:100%;box-shadow:0 12px 60px rgba(0,0,0,0.6)}
|
||||
.login-logo{font-size:24px;font-weight:800;color:#E0E0E0;margin-bottom:6px;letter-spacing:0.5px}
|
||||
.login-logo span{color:#00E676}
|
||||
@@ -174,8 +192,33 @@ body:not(.auth-required) #login-screen { display:none !important; }
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="brand"><span>◆</span> Trading AI</div>
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="showTab('overview',this)">📊 종합</button>
|
||||
<button class="tab" onclick="showTab('performance',this)">🎯 추천성과</button>
|
||||
<button class="tab" onclick="showTab('signals',this)">💰 매매시그널</button>
|
||||
<button class="tab" onclick="showTab('surge',this)">🚀 거래량급증</button>
|
||||
<button class="tab" onclick="showTab('hot',this)">🔥 지금뜨는</button>
|
||||
<button class="tab" onclick="showTab('portfolio',this)">💼 포트폴리오</button>
|
||||
<button class="tab" onclick="showTab('mystock',this)">📂 내 종목 분석</button>
|
||||
<button class="tab" onclick="showTab('recommend',this)">🏆 추천종목</button>
|
||||
<button class="tab" onclick="showTab('news',this)">📰 뉴스피드</button>
|
||||
<button class="tab" onclick="showTab('supplydemand',this)">💹 수급</button>
|
||||
<button class="tab" onclick="showTab('alerts',this)">🚨 알림</button>
|
||||
<button class="tab" onclick="showTab('formulas',this)">🧮 10공식</button>
|
||||
<button class="tab" onclick="showTab('backtest',this)">📈 백테스트</button>
|
||||
<button class="tab" onclick="showTab('usmarket',this)">🌎 미증시</button>
|
||||
<button class="tab" onclick="showTab('macro',this)">🌐 매크로·섹터</button>
|
||||
<button class="tab" onclick="showTab('glossary',this)">📖 용어설명</button>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="sidebar-overlay" id="sbOverlay" onclick="toggleSidebar(false)"></div>
|
||||
|
||||
<div class="main">
|
||||
<div class="wrap">
|
||||
<div class="header">
|
||||
<button class="hamburger" onclick="toggleSidebar()">☰</button>
|
||||
<div><h1><span>◆</span> Trading AI Dashboard</h1><div class="sub">뉴스 + DART 공시 기반 종목 분석 시스템</div></div>
|
||||
<div style="display:flex;align-items:center;gap:16px">
|
||||
<div class="time" id="clock"></div>
|
||||
@@ -190,26 +233,11 @@ body:not(.auth-required) #login-screen { display:none !important; }
|
||||
<button onclick="doSearch()" style="background:rgba(0,230,118,0.1);border:1px solid rgba(0,230,118,0.2);border-radius:8px;padding:10px 20px;color:#00E676;font-size:13px;cursor:pointer;font-weight:600;white-space:nowrap">🔍 검색</button>
|
||||
</div>
|
||||
<div id="searchResults" style="display:none;margin-bottom:24px"></div>
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="showTab('overview',this)">📊 종합</button>
|
||||
<button class="tab" onclick="showTab('performance',this)">🎯 추천성과</button>
|
||||
<button class="tab" onclick="showTab('signals',this)">💰 매매시그널</button>
|
||||
<button class="tab" onclick="showTab('surge',this)">🚀 거래량급증</button>
|
||||
<button class="tab" onclick="showTab('portfolio',this)">💼 포트폴리오</button>
|
||||
<button class="tab" onclick="showTab('mystock',this)">📂 내 종목 분석</button>
|
||||
<button class="tab" onclick="showTab('recommend',this)">🏆 추천종목</button>
|
||||
<button class="tab" onclick="showTab('news',this)">📰 뉴스피드</button>
|
||||
<button class="tab" onclick="showTab('supplydemand',this)">💹 수급</button>
|
||||
<button class="tab" onclick="showTab('alerts',this)">🚨 알림</button>
|
||||
<button class="tab" onclick="showTab('formulas',this)">🧮 10공식</button>
|
||||
<button class="tab" onclick="showTab('backtest',this)">📈 백테스트</button>
|
||||
<button class="tab" onclick="showTab('usmarket',this)">🌎 미증시</button>
|
||||
<button class="tab" onclick="showTab('macro',this)">🌐 매크로·섹터</button>
|
||||
</div>
|
||||
<div id="overview"></div>
|
||||
<div id="performance" style="display:none"></div>
|
||||
<div id="signals" style="display:none"></div>
|
||||
<div id="surge" style="display:none"></div>
|
||||
<div id="hot" style="display:none"></div>
|
||||
<div id="portfolio" style="display:none"></div>
|
||||
<div id="mystock" style="display:none"></div>
|
||||
<div id="recommend" style="display:none"></div>
|
||||
@@ -220,8 +248,10 @@ body:not(.auth-required) #login-screen { display:none !important; }
|
||||
<div id="backtest" style="display:none"></div>
|
||||
<div id="usmarket" style="display:none"></div>
|
||||
<div id="macro" style="display:none"></div>
|
||||
<div id="glossary" style="display:none"></div>
|
||||
<div class="footer">Trading AI System • 뉴스/공시 기반 자동 분석 • 투자 판단의 참고 자료로만 활용하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const API = window.location.origin + '/api';
|
||||
const SCOL = {호재:'#00E676',악재:'#FF5252',중립:'#78909C'};
|
||||
@@ -231,20 +261,117 @@ const RCOL = {강력매수:'#00E676',매수관심:'#69F0AE',관망:'#78909C',매
|
||||
function ftime(ts){if(!ts)return'-';const d=new Date(ts),m=Math.floor((Date.now()-d)/60000);if(m<60)return m+'분 전';if(m<1440)return Math.floor(m/60)+'시간 전';return d.toLocaleDateString('ko-KR',{month:'short',day:'numeric'})}
|
||||
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
||||
|
||||
function toggleSidebar(force){
|
||||
const sb=document.getElementById('sidebar'), ov=document.getElementById('sbOverlay');
|
||||
if(!sb)return;
|
||||
const open = force===undefined ? !sb.classList.contains('open') : !!force;
|
||||
sb.classList.toggle('open',open);
|
||||
if(ov)ov.classList.toggle('show',open);
|
||||
}
|
||||
|
||||
function showTab(id,el){
|
||||
['overview','performance','signals','surge','portfolio','mystock','recommend','news','supplydemand','alerts','formulas','backtest','usmarket','macro'].forEach(t=>{
|
||||
['overview','performance','signals','surge','hot','portfolio','mystock','recommend','news','supplydemand','alerts','formulas','backtest','usmarket','macro','glossary'].forEach(t=>{
|
||||
document.getElementById(t).style.display=t===id?'block':'none'});
|
||||
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
|
||||
if(el)el.classList.add('active');
|
||||
toggleSidebar(false);
|
||||
if(id==='mystock'){ if(isLoggedIn()) loadPortfolio().then(()=>renderMyStock()); else renderMyStock(); }
|
||||
if(id==='portfolio') renderPortfolio();
|
||||
if(id==='performance') renderPerformance();
|
||||
if(id==='supplydemand') renderSupplyDemand();
|
||||
if(id==='surge') renderVolumeSurge();
|
||||
if(id==='hot') renderHot();
|
||||
if(id==='formulas') renderFormulas();
|
||||
if(id==='backtest') renderBacktest();
|
||||
if(id==='usmarket') renderUsMarket();
|
||||
if(id==='macro') renderMacro();
|
||||
if(id==='glossary') renderGlossary();
|
||||
}
|
||||
|
||||
// ── 용어 설명 ────────────────────────────────────────────────
|
||||
const GLOSSARY=[
|
||||
{t:'📊 차트 보는 법',items:[
|
||||
['캔들(봉)','하루 주가 움직임을 막대 하나로 표시. 끝가가 시작가보다 <b>높으면 빨강(양봉=올랐다)</b>, <b>낮으면 파랑(음봉=내렸다)</b>. 위·아래로 삐져나온 가는 선은 그날의 최고가·최저가.'],
|
||||
['거래량','그날 사고판 주식 수. 많을수록 시장 관심이 크다는 뜻. 거래량 없이 슬금슬금 오르는 상승은 힘이 약하다.'],
|
||||
['시가/종가/고가/저가','시가=장 시작 가격, 종가=장 끝 가격, 고가=그날 최고가, 저가=그날 최저가.'],
|
||||
]},
|
||||
{t:'📈 이동평균선 (MA = Moving Average)',items:[
|
||||
['MA5','최근 <b>5일</b> 종가의 평균선. 약 1주일치 단기 흐름.'],
|
||||
['MA20','최근 <b>20일</b> 평균선. 약 한 달 흐름이라 "생명선"이라 부른다. 주가가 이 위면 단기 분위기 양호.'],
|
||||
['MA60','최근 <b>60일</b> 평균선. 약 3개월, 중기 추세.'],
|
||||
['MA120','최근 <b>120일</b> 평균선. 약 6개월, 장기 추세라 "경기선"이라 부른다.'],
|
||||
['정배열 / 역배열','MA5>MA20>MA60 순으로 짧은 선이 위에 있으면 <b>정배열=상승 추세</b>. 거꾸로면 역배열=하락 추세.'],
|
||||
['골든크로스 / 데드크로스','짧은 선이 긴 선을 아래→위로 뚫으면 <b>골든크로스(매수 신호)</b>, 위→아래로 뚫으면 데드크로스(매도 신호).'],
|
||||
]},
|
||||
{t:'🎯 기술적 지표 (매매 타이밍)',items:[
|
||||
['RSI','0~100 숫자. <b>70 이상이면 과열</b>(너무 많이 사서 비쌈), <b>30 이하면 과매도</b>(너무 많이 팔려 쌈). 되돌림 가능성을 본다.'],
|
||||
['MACD','단기·장기 이동평균의 차이로 추세 전환을 잡는 지표. 0선 위로 올라오면 상승 힘이 붙는 것.'],
|
||||
['볼린저밴드','주가가 보통 움직이는 범위를 상단·중간·하단 띠로 표시. 상단에 닿으면 단기 비쌈, 하단이면 쌈.'],
|
||||
['스토캐스틱','RSI처럼 과열/과매도를 보는 지표. 20 이하 과매도, 80 이상 과열.'],
|
||||
['OBV','거래량으로 돈이 들어오는지(매집)·나가는지(분산)를 추적. 주가보다 먼저 움직이기도 한다.'],
|
||||
['VWAP','거래량 가중 평균가. 기관 투자자가 "본전" 기준으로 삼는 가격.'],
|
||||
['일목균형표','구름대(저항·지지 구간)로 추세를 보는 일본식 종합 지표.'],
|
||||
]},
|
||||
{t:'💰 재무 지표 (회사가 돈을 잘 버나)',items:[
|
||||
['ROE','자기자본이익률. 주주 돈 100원으로 1년에 얼마 벌었나. <b>높을수록 좋고 15% 이상이면 우수</b>. 워런 버핏이 가장 중시하는 숫자.'],
|
||||
['영업이익률','매출 100원 중 본업으로 남긴 이익 비율. 높을수록 사업 경쟁력이 강하다.'],
|
||||
['순이익률','매출에서 모든 비용·이자·세금을 다 빼고 최종으로 남은 비율.'],
|
||||
['부채비율','자기자본 대비 빚의 비율. <b>100% 이하면 안전</b>, 200%를 넘으면 위험 신호.'],
|
||||
['FCF비율','잉여현금흐름(Free Cash Flow). 회사가 벌어서 쓸 거 다 쓰고 <b>실제로 남긴 진짜 현금</b>. 회계상 이익보다 정직한 숫자라 플러스면 건강.'],
|
||||
['매출성장률','작년 대비 올해 매출이 얼마나 늘었나. 회사가 커가고 있는지를 본다.'],
|
||||
]},
|
||||
{t:'🏷️ 가치 지표 (주가가 싼가 비싼가)',items:[
|
||||
['PER','주가 ÷ 주당순이익. 회사 이익 대비 주가가 몇 배인가. <b>낮을수록 싸다</b>. 보통 10배 이하면 저평가 경향.'],
|
||||
['PBR','주가 ÷ 주당순자산. <b>1배 미만이면 회사가 가진 자산보다 주가가 싸게</b> 거래되는 것.'],
|
||||
['EPS / BPS','EPS=주당순이익(1주가 1년에 번 돈). BPS=주당순자산(회사 청산 시 1주가 받을 몫).'],
|
||||
['시가총액','주가 × 총 주식 수 = 회사 전체의 시장 몸값.'],
|
||||
['배당수익률','주가 대비 1년간 받는 배당금 비율. 은행 이자처럼 보면 된다.'],
|
||||
]},
|
||||
{t:'🧮 이 시스템의 점수',items:[
|
||||
['종합점수 (-100~100)','재무·뉴스·기술·공시·수급을 다 합친 최종 점수. <b>높을수록 매수 매력</b>이 크다.'],
|
||||
['펀더멘털 점수','회사의 본질 가치 점수 (ROE·이익률·부채·현금흐름 등 기반).'],
|
||||
['기술점수','차트·기술 지표 기반의 단기 매매 타이밍 점수.'],
|
||||
['뉴스점수','최근 7일 뉴스의 호재/악재를 종합한 점수.'],
|
||||
['10공식 보팅','매직포뮬러·F-Score 등 학계·실전 검증된 10개 투자 공식이 각자 매수/매도를 투표한 결과.'],
|
||||
['추천등급','강력매수 > 매수관심 > 관망 > 매도관심 > 강력매도 순. 점수와 공식 투표를 함께 본다.'],
|
||||
]},
|
||||
{t:'🏛️ 시장 제도·거래 용어',items:[
|
||||
['공매도','내가 갖고 있지도 않은 주식을 <b>빌려서 먼저 판 뒤</b>, 값이 떨어지면 싸게 되사서 갚아 차익을 먹는 기법. 주가 <b>하락에 베팅</b>하는 것. 공매도 물량이 많으면 하락 압력 신호로 본다 (이 시스템의 공매도 점수에 반영).'],
|
||||
['사이드카','선물 가격이 ±5~6% 급등락하면 <b>프로그램 매매(컴퓨터 자동매매)를 5분간 정지</b>시키는 장치. 시장 쏠림을 잠깐 식히는 가벼운 브레이크. 5분 후 자동 해제.'],
|
||||
['서킷브레이커','사이드카보다 센 비상정지. 지수가 8%·15%·20% 급락하면 <b>주식 거래 전체를 20분간 멈춤</b>. 패닉 매도를 막는 장치.'],
|
||||
['VI (변동성완화장치)','개별 종목이 갑자기 급등락하면 2~3분간 단일가 매매로 바꿔 진정시키는 장치. 사이드카의 개별 종목 버전.'],
|
||||
['상한가 / 하한가','하루에 주가가 오를·내릴 수 있는 한계. 한국은 전일 종가 대비 <b>±30%</b>.'],
|
||||
['ISA','개인종합자산관리계좌. 주식·펀드·예금을 한 계좌에 담아 굴리는 <b>절세 계좌</b>. 수익 일정액까지 세금 면제·저율 과세. 의무 보유기간(보통 3년) 있음. "세금 혜택 받는 장기투자용 계좌".'],
|
||||
['배당','회사가 번 이익의 일부를 주주에게 나눠주는 돈. 1년에 한 번(결산배당)이 보통이고 분기마다 주는 회사도 있다.'],
|
||||
]},
|
||||
{t:'💡 투자 스타일·철학',items:[
|
||||
['가치투자','회사의 본질가치보다 <b>싸게 거래되는 주식</b>을 사서 제값을 찾아갈 때까지 기다리는 방식. 대표: 벤저민 그레이엄·워런 버핏. 이 시스템의 기본 관점.'],
|
||||
['성장투자','지금 좀 비싸도 <b>매출·이익이 빠르게 크는 회사</b>면 산다. 미래 성장이 현재 가격을 정당화한다는 관점. 대표: 필립 피셔·피터 린치.'],
|
||||
['모멘텀투자','<b>오르는 주식이 더 오른다</b>는 경험칙에 기대 추세에 올라타는 방식(추세추종). 대표: AQR·제시 리버모어. 이 시스템의 12-1 모멘텀 공식이 여기 해당.'],
|
||||
['퀀트투자','감정을 배제하고 <b>데이터·통계로 패턴을 찾아 기계적으로</b> 매매. 대표: 짐 사이먼스(르네상스). 이 시스템의 10공식 엔진 자체가 퀀트 방식.'],
|
||||
['매크로투자','개별 종목보다 <b>금리·환율·경기 같은 큰 흐름</b>에 베팅. 대표: 조지 소로스·레이 달리오.'],
|
||||
['역발상투자','남들이 공포에 팔 때 사고 열광할 때 판다 — <b>대중과 반대로</b>. 대표: 존 템플턴.'],
|
||||
['인덱스투자','시장을 이기기 어려우니 <b>지수 전체를 싸게 사서 장기 보유</b>(패시브). 대표: 존 보글. 버핏도 일반인에겐 이걸 권한다.'],
|
||||
['기술적분석','재무가 아니라 <b>차트·거래량·지표로 매매 타이밍</b>을 잡는 방식. 가격이 모든 정보를 반영한다고 본다.'],
|
||||
['배당투자','<b>배당을 꾸준히·많이 주는 회사</b>로 안정적인 현금흐름을 추구. 주가 등락보다 배당 수입을 중시.'],
|
||||
['멀티팩터','가치·성장·모멘텀·퀄리티 등 <b>여러 방식을 데이터로 섞는</b> 현대 퀀트 접근. 한 스타일도 항상 이기진 못하기 때문 — <b>이 시스템이 실제로 쓰는 방식.</b>'],
|
||||
]},
|
||||
];
|
||||
function renderGlossary(){
|
||||
const el=document.getElementById('glossary');
|
||||
let h=`<div style="font-size:13px;color:#90A4AE;margin-bottom:16px;line-height:1.6">
|
||||
📖 주식 표·차트에 나오는 용어를 <b style="color:#00E676">쉬운 말</b>로 풀이했습니다. 모르는 단어가 보이면 여기서 찾아보세요.</div>`;
|
||||
GLOSSARY.forEach(g=>{
|
||||
h+=`<div class="panel" style="max-height:none;margin-bottom:14px"><h3 style="margin-bottom:10px">${g.t}</h3>`;
|
||||
g.items.forEach(it=>{
|
||||
h+=`<div style="display:flex;gap:14px;padding:11px 2px;border-bottom:1px solid rgba(255,255,255,0.05)">
|
||||
<div style="flex:0 0 96px;color:#00E676;font-weight:700;font-size:13px">${it[0]}</div>
|
||||
<div style="flex:1;color:#B0BEC5;font-size:12.5px;line-height:1.65">${it[1]}</div>
|
||||
</div>`;
|
||||
});
|
||||
h+=`</div>`;
|
||||
});
|
||||
el.innerHTML=h;
|
||||
}
|
||||
|
||||
async function renderVolumeSurge(){
|
||||
@@ -281,6 +408,41 @@ async function renderVolumeSurge(){
|
||||
}
|
||||
}
|
||||
|
||||
async function renderHot(){
|
||||
const el=document.getElementById('hot');
|
||||
el.innerHTML=`<div style="text-align:center;color:#546E7A;padding:30px">불러오는 중...</div>`;
|
||||
try{
|
||||
const list=await api('/hot')||[];
|
||||
if(!list.length){el.innerHTML='<div class="empty">지금 뜨는 종목 없음</div>';return;}
|
||||
let h=`<div style="font-size:12px;color:#FFB74D;background:rgba(255,152,0,0.08);border:1px solid rgba(255,152,0,0.2);border-radius:8px;padding:10px 12px;margin-bottom:14px">
|
||||
🔥 <b>지금 뜨는 종목</b> — 최근 뉴스 호재 + 거래량 급증 기준. <b style="color:#FF8A65">※ 관찰용입니다.</b> 백테스트상 핫종목은 5~10일 들면 시장에 지므로(되돌림) 매수신호가 아니라 "지금 뭐가 움직이나" 보는 창으로만 쓰세요.</div>`;
|
||||
h+=`<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:10px">`;
|
||||
list.forEach((r,i)=>{
|
||||
const recoC=r.value_reco==='강력매수'?'#00E676':r.value_reco==='매수관심'?'#69F0AE':(r.value_reco||'').includes('매도')?'#FF5252':'#90A4AE';
|
||||
const p5=r.price_5d_pct||0, p5C=p5>=0?'#00E676':'#FF5252';
|
||||
h+=`<div data-stock="${r.stock_code}" data-stock-name="${esc(r.stock_name)}" style="cursor:pointer;background:rgba(255,255,255,0.03);border:1px solid rgba(255,152,0,0.12);border-radius:10px;padding:12px 14px;transition:background .15s" onmouseover="this.style.background='rgba(255,255,255,0.06)'" onmouseout="this.style.background='rgba(255,255,255,0.03)'">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px">
|
||||
<div>
|
||||
<div style="font-size:13px;font-weight:700;color:#E0E0E0">${i+1}. ${esc(r.stock_name||r.stock_code)}</div>
|
||||
<div style="font-size:10px;color:#546E7A;font-family:'JetBrains Mono',monospace">${r.stock_code}</div>
|
||||
</div>
|
||||
<div style="font-size:15px;color:#FFB74D;font-weight:700">🔥 ${(r.hot_score||0).toFixed(0)}</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;font-size:11px;color:#90A4AE">
|
||||
${r.pos_news_3d?`<span>호재 ${r.pos_news_3d}건</span>`:''}
|
||||
<span>5일 <b style="color:${p5C}">${p5>=0?'+':''}${p5.toFixed(1)}%</b></span>
|
||||
${r.vol_ratio>=1.5?`<span>거래량 ${(r.vol_ratio||0).toFixed(1)}배</span>`:''}
|
||||
</div>
|
||||
<div style="margin-top:6px;font-size:11px;color:#78909C">가치판단: <b style="color:${recoC}">${r.value_reco||'-'}</b>${r.value_score!=null?` (${r.value_score}점)`:''}</div>
|
||||
</div>`;
|
||||
});
|
||||
h+=`</div>`;
|
||||
el.innerHTML=h;
|
||||
}catch(e){
|
||||
el.innerHTML='<div class="empty">로드 실패: '+esc(e.message)+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function api(path){try{const r=await fetch(API+path);return await r.json()}catch(e){return null}}
|
||||
|
||||
async function loadAll(){
|
||||
@@ -1012,11 +1174,9 @@ async function renderPortfolio(){
|
||||
const el=document.getElementById('portfolio');
|
||||
const list=getPortfolio();
|
||||
if(!list.length){
|
||||
el.innerHTML=`<div style="text-align:center;padding:80px 20px;color:#546E7A">
|
||||
<div style="font-size:48px;margin-bottom:16px">💼</div>
|
||||
<div style="font-size:16px;margin-bottom:8px;color:#78909C">보유 종목이 없습니다</div>
|
||||
<div style="font-size:13px">📂 내 종목 분석 탭에서 종목을 추가하면 여기에 표시됩니다</div>
|
||||
</div>`;
|
||||
el.innerHTML='<div class="empty">AI 추천 포트폴리오 불러오는 중...</div>';
|
||||
const reco=await api('/portfolio/recommended'+(recoAmount>0?'?amount='+recoAmount:'')).catch(()=>null);
|
||||
el.innerHTML=`<div style="background:rgba(64,196,255,0.06);border:1px solid rgba(64,196,255,0.2);border-radius:12px;padding:16px 18px;margin-bottom:16px;color:#CFD8DC;font-size:13px;line-height:1.6">💼 아직 등록된 보유 종목이 없습니다. 아래 <b style="color:#69F0AE">AI 추천 포트폴리오</b>를 참고하거나, <b>📂 내 종목 분석</b> 탭에서 종목을 추가하세요.</div>`+recoPortfolioHTML(reco);
|
||||
return;
|
||||
}
|
||||
el.innerHTML='<div class="empty">현재가 · AI점수 불러오는 중...</div>';
|
||||
@@ -1035,7 +1195,7 @@ async function renderPortfolio(){
|
||||
return{...p,cur,changePct:pd.change_pct||0,cost,val,
|
||||
pnl:val-cost,pnlPct:cost>0?(val-cost)/cost*100:0,
|
||||
techScore:pd.tech_score||0,signal:pd.signal||'관망',
|
||||
aiScore:pd.ai_score,recommendation:pd.recommendation};
|
||||
aiScore:pd.ai_score,recommendation:pd.recommendation,sector:pd.sector||'기타'};
|
||||
});
|
||||
const totalPnl=totalValue-totalCost;
|
||||
const totalPnlPct=totalCost>0?totalPnl/totalCost*100:0;
|
||||
@@ -1109,9 +1269,134 @@ async function renderPortfolio(){
|
||||
</tr>`;
|
||||
});
|
||||
h+=`</tbody></table></div></div>`;
|
||||
h+=portfolioSectorHTML(holdings);
|
||||
h+=portfolioRebalanceHTML(holdings);
|
||||
const reco=await api('/portfolio/recommended'+(recoAmount>0?'?amount='+recoAmount:'')).catch(()=>null);
|
||||
h+=recoPortfolioHTML(reco);
|
||||
el.innerHTML=h;
|
||||
}
|
||||
|
||||
let recoAmount=0;
|
||||
async function applyRecoAmount(){
|
||||
const inp=document.getElementById('reco-amt');
|
||||
const v=parseInt((inp&&inp.value)||'0',10);
|
||||
recoAmount=isNaN(v)?0:v;
|
||||
renderPortfolio();
|
||||
}
|
||||
|
||||
function portfolioSectorHTML(holdings){
|
||||
const sec={};
|
||||
holdings.forEach(s=>{ if(s.weight>0) sec[s.sector]=(sec[s.sector]||0)+s.weight; });
|
||||
const arr=Object.entries(sec).sort((a,b)=>b[1]-a[1]);
|
||||
if(!arr.length) return '';
|
||||
let h=`<div class="panel" style="max-height:none;margin-bottom:16px"><h3>📊 섹터(업종) 비중</h3>`;
|
||||
arr.forEach(([name,w])=>{
|
||||
const over=w>30, c=over?'#FF8A80':'#40C4FF';
|
||||
h+=`<div style="margin-bottom:10px">
|
||||
<div style="display:flex;justify-content:space-between;font-size:12px;margin-bottom:4px">
|
||||
<span style="color:#CFD8DC">${esc(name)}${over?' ⚠️':''}</span>
|
||||
<span style="color:${c};font-weight:700">${w.toFixed(1)}%</span></div>
|
||||
<div style="height:8px;background:rgba(255,255,255,0.06);border-radius:4px;overflow:hidden">
|
||||
<div style="height:100%;width:${Math.min(100,w).toFixed(0)}%;background:${c};border-radius:4px"></div></div></div>`;
|
||||
});
|
||||
if(arr[0][1]>30) h+=`<div style="font-size:11.5px;color:#FF8A80;margin-top:4px">⚠️ <b>${esc(arr[0][0])}</b> 업종이 ${arr[0][1].toFixed(0)}%로 한쪽에 쏠렸습니다. 한 업종 30% 이내로 분산하면 위험이 줄어듭니다.</div>`;
|
||||
return h+`</div>`;
|
||||
}
|
||||
|
||||
function portfolioRebalanceHTML(holdings){
|
||||
const acts=[];
|
||||
holdings.forEach(s=>{
|
||||
if(s.recommendation==='강력매도'||s.recommendation==='매도관심')
|
||||
acts.push([s,'sell',`AI가 <b>${esc(s.recommendation)}</b> 신호를 냈습니다 — 매도를 검토하세요`]);
|
||||
else if(s.cur>0&&s.pnlPct<=-10)
|
||||
acts.push([s,'cut',`손실 ${s.pnlPct.toFixed(1)}% — 손절 기준을 점검하세요`]);
|
||||
else if(s.aiScore!=null&&s.aiScore<=-30)
|
||||
acts.push([s,'sell',`AI점수 ${s.aiScore.toFixed(0)}점으로 부진 — 매도를 검토하세요`]);
|
||||
else if(s.aiScore!=null&&s.aiScore>=70&&s.weight<7)
|
||||
acts.push([s,'add',`AI점수 ${s.aiScore.toFixed(0)}점 우량인데 비중이 작습니다 — 추가 매수 고려`]);
|
||||
});
|
||||
let h=`<div class="panel" style="max-height:none;margin-bottom:16px"><h3>🔧 리밸런싱 진단</h3>`;
|
||||
if(!acts.length){
|
||||
h+=`<div style="color:#69F0AE;font-size:12.5px">✅ 보유 종목에 특별한 조치 신호가 없습니다. 그대로 유지해도 무난합니다.</div>`;
|
||||
}else{
|
||||
acts.forEach(([s,kind,msg])=>{
|
||||
const c=kind==='add'?'#69F0AE':kind==='sell'?'#FF8A80':'#FFD740';
|
||||
const ic=kind==='add'?'➕':kind==='sell'?'⚠️':'🔻';
|
||||
h+=`<div data-stock="${s.code}" data-stock-name="${esc(s.name||'')}" style="display:flex;gap:10px;align-items:center;padding:9px 0;border-bottom:1px solid rgba(255,255,255,0.05);cursor:pointer">
|
||||
<span style="font-size:15px">${ic}</span>
|
||||
<div style="flex:1;min-width:0"><div style="color:#CFD8DC;font-size:12.5px;font-weight:600">${esc(s.name||s.code)}</div>
|
||||
<div style="color:${c};font-size:11px;line-height:1.5">${msg}</div></div>
|
||||
<span style="color:${s.pnlPct>=0?'#69F0AE':'#FF8A80'};font-size:11px;font-family:'JetBrains Mono',monospace">${s.cur>0?(s.pnlPct>=0?'+':'')+s.pnlPct.toFixed(1)+'%':'-'}</span></div>`;
|
||||
});
|
||||
}
|
||||
return h+`</div>`;
|
||||
}
|
||||
|
||||
function recoPortfolioHTML(reco){
|
||||
if(!reco||!reco.holdings||!reco.holdings.length)
|
||||
return `<div class="panel" style="max-height:none"><h3>🤖 AI 추천 포트폴리오</h3><div style="color:#546E7A;font-size:12px">${esc((reco&&reco.msg)||'추천 데이터를 불러오지 못했습니다.')}</div></div>`;
|
||||
let h=`<div class="panel" style="max-height:none"><h3>🤖 AI 추천 포트폴리오 (${esc(reco.as_of||'')})</h3>
|
||||
<div style="font-size:11.5px;color:#90A4AE;margin-bottom:12px;line-height:1.6">오늘 <b style="color:#69F0AE">강력매수·매수관심</b> 종목을 변동성·점수로 비중을 매기고, 한 업종 30% 이내로 분산한 구성입니다. 투자금액을 넣으면 종목별 매수 주수를 계산합니다.</div>
|
||||
<div style="display:flex;gap:8px;margin-bottom:14px;align-items:center;flex-wrap:wrap">
|
||||
<input id="reco-amt" type="number" placeholder="투자금액 입력 (원)" value="${recoAmount||''}"
|
||||
style="background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);border-radius:8px;padding:8px 12px;color:#E0E0E0;font-size:13px;width:180px;outline:none"
|
||||
onkeypress="if(event.key==='Enter')applyRecoAmount()">
|
||||
<button onclick="applyRecoAmount()" style="background:rgba(0,230,118,0.1);border:1px solid rgba(0,230,118,0.25);border-radius:8px;padding:8px 16px;color:#00E676;font-size:12px;font-weight:600;cursor:pointer">계산</button>
|
||||
</div>`;
|
||||
const sbArr=Object.entries(reco.sector_breakdown||{});
|
||||
if(sbArr.length){
|
||||
h+=`<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:12px">`;
|
||||
sbArr.forEach(([n,w])=>{ h+=`<span style="background:rgba(64,196,255,0.1);border:1px solid rgba(64,196,255,0.25);border-radius:6px;padding:3px 9px;font-size:10.5px;color:#CFD8DC">${esc(n)} ${w.toFixed(0)}%</span>`; });
|
||||
h+=`</div>`;
|
||||
}
|
||||
const hasAmt=reco.input_amount>0;
|
||||
h+=`<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:12px">
|
||||
<thead><tr style="color:#78909C;border-bottom:1px solid rgba(255,255,255,0.08)">
|
||||
<th style="text-align:left;padding:8px 6px;font-weight:600">종목</th>
|
||||
<th style="text-align:center;padding:8px 6px;font-weight:600">AI점수</th>
|
||||
<th style="text-align:right;padding:8px 6px;font-weight:600">비중</th>
|
||||
${hasAmt?'<th style="text-align:right;padding:8px 6px;font-weight:600">매수금액</th><th style="text-align:right;padding:8px 6px;font-weight:600">주수</th>':''}
|
||||
</tr></thead><tbody>`;
|
||||
reco.holdings.forEach((s,i)=>{
|
||||
const aic=s.total_score>=70?'#00E676':s.total_score>=40?'#69F0AE':'#90A4AE';
|
||||
h+=`<tr data-stock="${s.stock_code}" data-stock-name="${esc(s.stock_name)}" style="border-bottom:1px solid rgba(255,255,255,0.04);cursor:pointer;${i%2?'background:rgba(255,255,255,0.01)':''}">
|
||||
<td style="padding:9px 6px"><div style="color:#CFD8DC;font-weight:600">${esc(s.stock_name)}</div>
|
||||
<div style="font-size:10px;color:#546E7A">${esc(s.sector)} · ${esc(s.recommendation)}</div></td>
|
||||
<td style="text-align:center;padding:9px 6px;color:${aic};font-weight:700;font-family:'JetBrains Mono',monospace">${s.total_score.toFixed(0)}</td>
|
||||
<td style="text-align:right;padding:9px 6px"><span style="color:#40C4FF;font-weight:700">${s.weight_pct.toFixed(1)}%</span></td>
|
||||
${hasAmt?`<td style="text-align:right;padding:9px 6px;color:#CFD8DC;font-family:'JetBrains Mono',monospace">${fmt(s.invest_amount)}</td><td style="text-align:right;padding:9px 6px;color:#E0E0E0;font-weight:600">${s.shares.toLocaleString()}</td>`:''}
|
||||
</tr>`;
|
||||
});
|
||||
h+=`</tbody></table></div>`;
|
||||
if(hasAmt)
|
||||
h+=`<div style="margin-top:10px;font-size:11.5px;color:#90A4AE">투자금 ${fmt(reco.input_amount)}원 중 <b style="color:#69F0AE">${fmt(reco.invested_amount)}원</b> 매수 · 현금 ${fmt(reco.cash_remaining)}원 남음</div>`;
|
||||
return h+`</div>`;
|
||||
}
|
||||
|
||||
function equityCurveSVG(curve){
|
||||
if(!curve||curve.length<2) return '';
|
||||
const W=640,H=180,pad=30;
|
||||
const allv=curve.flatMap(p=>[p.strategy_pct,p.kospi_pct]).concat([0]);
|
||||
let mn=Math.min(...allv), mx=Math.max(...allv);
|
||||
if(mx===mn)mx=mn+1;
|
||||
const X=i=>pad+(W-2*pad)*i/(curve.length-1);
|
||||
const Y=v=>H-pad-(H-2*pad)*(v-mn)/(mx-mn);
|
||||
const path=k=>curve.map((p,i)=>`${i?'L':'M'}${X(i).toFixed(1)},${Y(p[k]).toFixed(1)}`).join('');
|
||||
const last=curve[curve.length-1];
|
||||
const sC=last.strategy_pct>=0?'#00E676':'#FF5252';
|
||||
return `<div class="panel" style="margin-top:14px"><h3>📈 자산곡선 — 추천을 따랐을 때 누적 수익률</h3>
|
||||
<div style="font-size:11px;color:#90A4AE;margin-bottom:8px">매일 추천 종목을 등비중으로 샀다고 가정한 누적 수익률(거래비용 차감). <b style="color:${sC}">초록=전략</b> · <b style="color:#FFB74D">주황=KOSPI</b></div>
|
||||
<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:auto">
|
||||
<line x1="${pad}" y1="${Y(0).toFixed(1)}" x2="${W-pad}" y2="${Y(0).toFixed(1)}" stroke="rgba(255,255,255,0.15)" stroke-dasharray="3"/>
|
||||
<path d="${path('kospi_pct')}" fill="none" stroke="#FFB74D" stroke-width="1.6"/>
|
||||
<path d="${path('strategy_pct')}" fill="none" stroke="${sC}" stroke-width="2.2"/>
|
||||
</svg>
|
||||
<div style="display:flex;justify-content:space-between;font-size:11px;color:#78909C;margin-top:4px">
|
||||
<span>${esc(curve[0].date)}</span>
|
||||
<span style="color:${sC};font-weight:700">전략 ${last.strategy_pct>=0?'+':''}${last.strategy_pct}% vs KOSPI ${last.kospi_pct>=0?'+':''}${last.kospi_pct}%</span>
|
||||
<span>${esc(last.date)}</span></div></div>`;
|
||||
}
|
||||
|
||||
function renderSignals(signals,cands){
|
||||
let h=`<div style="margin-bottom:20px">
|
||||
<div style="font-size:12px;color:#546E7A;margin-bottom:16px">
|
||||
@@ -1871,6 +2156,7 @@ async function renderBacktest(){
|
||||
${_btCard('MDD', oa.max_drawdown_pct, '%', true)}
|
||||
${_btCard('알파', oa.avg_alpha_pct, '%', true)}
|
||||
</div>
|
||||
${equityCurveSVG(d.equity_curve)}
|
||||
<div class="panel" style="margin-top:14px"><h3>등급별 성과 (7일)</h3>${_btTable(d.by_recommendation_7d||{})}</div>`;
|
||||
if(oa30.n){
|
||||
h += `<h3 style="color:#90A4AE;font-size:13px;margin:18px 0 10px">30일 보유</h3>
|
||||
@@ -1892,64 +2178,241 @@ async function renderBacktest(){
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 🌎 미증시 동조
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 미국 주요 티커 → 한글 회사명/설명 매핑 (페어·ETF 표시용)
|
||||
const US_TICKER_KR = {
|
||||
// 반도체
|
||||
NVDA:'엔비디아', AMD:'AMD', MU:'마이크론', INTC:'인텔', TSM:'TSMC(대만 반도체)',
|
||||
AVGO:'브로드컴', QCOM:'퀄컴', TXN:'TI', AMAT:'어플라이드머티리얼', LRCX:'램리서치', KLAC:'KLA',
|
||||
// 빅테크
|
||||
AAPL:'애플', MSFT:'마이크로소프트', GOOGL:'구글', AMZN:'아마존', META:'메타(페북)',
|
||||
TSLA:'테슬라', NFLX:'넷플릭스', ORCL:'오라클', ADBE:'어도비', CRM:'세일즈포스',
|
||||
// 금융
|
||||
JPM:'JP모건', BAC:'뱅크오브아메리카', GS:'골드만삭스', MS:'모건스탠리',
|
||||
WFC:'웰스파고', C:'씨티그룹', BLK:'블랙록',
|
||||
// 에너지/소재
|
||||
XOM:'엑손모빌', CVX:'쉐브론', COP:'코노코필립스', NUE:'뉴코어(철강)', FCX:'프리포트(구리)',
|
||||
// 헬스/바이오
|
||||
JNJ:'존슨앤존슨', PFE:'화이자', MRK:'머크', ABBV:'애브비', LLY:'일라이릴리',
|
||||
BMY:'BMS', GILD:'길리어드',
|
||||
// 소비재/유통
|
||||
WMT:'월마트', COST:'코스트코', HD:'홈디포', MCD:'맥도날드', NKE:'나이키',
|
||||
SBUX:'스타벅스', DIS:'디즈니',
|
||||
// 산업/방산
|
||||
BA:'보잉', LMT:'록히드마틴', RTX:'레이시온', GD:'제너럴다이내믹스', CAT:'캐터필러', DE:'존디어',
|
||||
// 자동차
|
||||
F:'포드', GM:'GM', TM:'도요타',
|
||||
// 2차전지/리튬
|
||||
ALB:'알버말(리튬)', RIVN:'리비안',
|
||||
// 화학
|
||||
DOW:'다우', LYB:'라이언델바젤',
|
||||
// 게임/엔터
|
||||
NTES:'넷이즈(중국게임)',
|
||||
// ETF
|
||||
SOXX:'반도체 ETF', SMH:'반도체 ETF(VanEck)', XLK:'미국 기술주 ETF', QQQ:'나스닥100 ETF',
|
||||
XBI:'바이오 ETF', IBB:'바이오 ETF(나스닥)', LIT:'리튬·전기차 ETF',
|
||||
XLE:'에너지 ETF', XLF:'금융 ETF', XLV:'헬스케어 ETF', XLI:'산업재 ETF',
|
||||
XLP:'필수소비재 ETF', XLY:'경기소비재 ETF', ITA:'항공·방산 ETF',
|
||||
};
|
||||
function usName(t){ return US_TICKER_KR[t] || ''; }
|
||||
|
||||
async function renderUsMarket(){
|
||||
const el=document.getElementById('usmarket');
|
||||
el.innerHTML=`<div style="font-size:12px;color:#546E7A;margin-bottom:14px">미국 섹터 ETF·KR↔US 페어 회귀로 한국 종목별 동조 시그널 산출 (매일 KST 08:00)</div><div style="text-align:center;color:#546E7A;padding:30px">불러오는 중...</div>`;
|
||||
el.innerHTML=`<div style="text-align:center;color:#546E7A;padding:30px">불러오는 중...</div>`;
|
||||
try{
|
||||
const [etfs, pairs, signals] = await Promise.all([
|
||||
api('/usmarket/etfs'), api('/usmarket/pairs'), api('/usmarket/signals')
|
||||
const [etfs, pairs, signals, brief] = await Promise.all([
|
||||
api('/usmarket/etfs'), api('/usmarket/pairs'), api('/usmarket/signals'), api('/usmarket/briefing')
|
||||
]);
|
||||
let h = `<div class="grid2">`;
|
||||
// 섹터 ETF
|
||||
h += `<div class="panel"><h3>섹터 ETF (${Array.isArray(etfs)?etfs.length:0})</h3>`;
|
||||
if(Array.isArray(etfs) && etfs.length){
|
||||
h += `<table style="width:100%;font-size:11px;border-collapse:separate;border-spacing:1px">
|
||||
<thead><tr style="color:#90A4AE"><th style="text-align:left;padding:6px">티커</th><th style="text-align:left">설명</th><th style="text-align:left">한국 섹터</th></tr></thead><tbody>`;
|
||||
etfs.slice(0,30).forEach(e=>{
|
||||
const kw = Array.isArray(e.sector_keywords)?e.sector_keywords.join(', '):'';
|
||||
h += `<tr><td style="padding:5px 6px;color:#69F0AE;font-family:'JetBrains Mono',monospace;font-weight:700">${esc(e.etf_ticker)}</td><td style="color:#CFD8DC">${esc(e.description||'-')}</td><td style="color:#90A4AE;font-size:10px">${esc(kw)}</td></tr>`;
|
||||
});
|
||||
h += `</tbody></table>`;
|
||||
} else h += `<div class="empty">데이터 없음</div>`;
|
||||
h += `</div>`;
|
||||
// 페어
|
||||
h += `<div class="panel"><h3>KR ↔ US 페어 (${Array.isArray(pairs)?pairs.length:0})</h3>`;
|
||||
if(Array.isArray(pairs) && pairs.length){
|
||||
h += `<table style="width:100%;font-size:11px;border-collapse:separate;border-spacing:1px">
|
||||
<thead><tr style="color:#90A4AE"><th style="text-align:left;padding:6px">US</th><th>KR</th><th title="60일 회귀 베타">β</th><th title="60일 상관">ρ</th><th>N</th></tr></thead><tbody>`;
|
||||
pairs.slice(0,30).forEach(p=>{
|
||||
const beta = p.beta_60d!=null?(+p.beta_60d).toFixed(2):'-';
|
||||
const corr = p.correlation_60d!=null?(+p.correlation_60d).toFixed(2):'-';
|
||||
h += `<tr><td style="padding:5px 6px;color:#69F0AE;font-family:'JetBrains Mono',monospace">${esc(p.us_ticker)}</td><td style="color:#CFD8DC;font-family:'JetBrains Mono',monospace">${esc(p.kr_code)}</td><td style="text-align:center;font-family:'JetBrains Mono',monospace;color:#FFD740">${beta}</td><td style="text-align:center;font-family:'JetBrains Mono',monospace;color:#90A4AE">${corr}</td><td style="text-align:center;color:#546E7A">${p.sample_size||'-'}</td></tr>`;
|
||||
});
|
||||
h += `</tbody></table>`;
|
||||
} else h += `<div class="empty">데이터 없음</div>`;
|
||||
h += `</div></div>`;
|
||||
// 시그널
|
||||
h += `<div class="panel" style="margin-top:14px"><h3>최신 동조 시그널 (${Array.isArray(signals)?signals.length:0})</h3>`;
|
||||
|
||||
// ── 0. 탭 안내 (이 탭은 뭐 하는 곳?) ──
|
||||
let h = `
|
||||
<div style="background:linear-gradient(135deg,rgba(64,196,255,0.07),rgba(105,240,174,0.05));border:1px solid rgba(64,196,255,0.25);border-radius:12px;padding:14px 18px;margin-bottom:16px">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
<span style="font-size:18px">🌎</span>
|
||||
<span style="font-weight:700;color:#40C4FF;font-size:14px">이 탭은 뭐예요?</span>
|
||||
</div>
|
||||
<div style="color:#CFD8DC;font-size:12.5px;line-height:1.7">
|
||||
<b style="color:#69F0AE">미국 증시의 움직임</b>이 한국 종목에 어떤 영향을 줄지 미리 보여주는 곳입니다.
|
||||
예) <span style="color:#FFD740">엔비디아가 어제 +5% 올랐다 → SK하이닉스도 오를 가능성 ↑</span>
|
||||
<br>
|
||||
매일 한국 시장 시작 전(KST 08:00)에 미국 마감을 보고 자동 계산됩니다.
|
||||
</div>
|
||||
<div style="background:rgba(255,255,255,0.04);border-radius:8px;padding:10px 12px;margin-top:10px;font-size:11.5px;color:#90A4AE;line-height:1.7">
|
||||
<b style="color:#FFD740">📖 보는 법</b><br>
|
||||
① <b style="color:#CFD8DC">최신 동조 시그널</b>: 한국 종목별로 "오늘 미국 영향을 +/- 몇 점 받았는지". <b style="color:#69F0AE">+5 이상이면 강한 호재</b>, <b style="color:#FF8A80">-5 이하면 강한 악재</b>. 종합점수에 반영됨.<br>
|
||||
② <b style="color:#CFD8DC">섹터 ETF</b>: 미국 업종 ETF가 한국 어떤 업종에 영향 주는지. 예) 미국 반도체 ETF(SOXX) ↑ → 한국 반도체 ↑<br>
|
||||
③ <b style="color:#CFD8DC">KR↔US 페어</b>: 개별 종목 간 동조 (엔비디아↔SK하이닉스 등). β(민감도)가 클수록 강하게 따라감.
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// ── 0.5. 오늘 새벽 미국증시 브리핑 (핫/저조 + 관련 KOSPI) ──
|
||||
if(brief && !brief.err && ((brief.hot&&brief.hot.length)||(brief.cold&&brief.cold.length))){
|
||||
const aiC=(v)=> v==null?'#546E7A': v>=30?'#69F0AE': v<=-30?'#FF8A80':'#90A4AE';
|
||||
const krLine=(k)=>{
|
||||
const sc=(k.ai_score!=null)?`<span style="color:${aiC(k.ai_score)};font-family:'JetBrains Mono',monospace;font-size:11px">${k.ai_score>=0?'+':''}${k.ai_score}</span>`:'<span style="color:#546E7A;font-size:11px">점수없음</span>';
|
||||
const rec=(k.recommendation&&k.recommendation!=='-')?`<span style="color:${RCOL[k.recommendation]||'#78909C'};font-size:10px;font-weight:600">${esc(k.recommendation)}</span>`:'';
|
||||
return `<div data-stock="${esc(k.kr_code)}" data-stock-name="${esc(k.kr_name)}" style="display:flex;gap:6px;align-items:center;padding:3px 0;cursor:pointer">
|
||||
<span style="color:#CFD8DC;font-size:11.5px;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(k.kr_name)}</span>${sc}${rec}</div>`;
|
||||
};
|
||||
const stockCard=(s,isHot)=>{
|
||||
const c=isHot?'#69F0AE':'#FF8A80';
|
||||
const krs=(s.related_kr||[]).map(krLine).join('')||'<span style="color:#546E7A;font-size:11px">연결된 한국 종목 없음</span>';
|
||||
return `<div style="background:rgba(255,255,255,0.03);border:1px solid ${c}33;border-radius:10px;padding:10px 12px;margin-bottom:8px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;gap:8px">
|
||||
<span style="color:#E0E0E0;font-weight:700;font-size:12.5px">${esc(s.label)}</span>
|
||||
<span style="color:${c};font-weight:700;font-family:'JetBrains Mono',monospace;font-size:14px;white-space:nowrap">${s.change_pct>=0?'+':''}${s.change_pct}%</span>
|
||||
</div>
|
||||
<div style="border-top:1px solid rgba(255,255,255,0.05);padding-top:5px">
|
||||
<div style="font-size:10px;color:#607D8B;margin-bottom:2px">관련 KOSPI 종목 (AI점수 높은 순)</div>${krs}
|
||||
</div></div>`;
|
||||
};
|
||||
h += `<div class="panel"><h3>🌙 오늘 새벽 미국증시 (${esc(brief.trade_date||'')} 기준)</h3>
|
||||
<div style="font-size:11.5px;color:#90A4AE;margin-bottom:14px;line-height:1.6">간밤 미국에서 <b style="color:#69F0AE">오른 종목</b>·<b style="color:#FF8A80">내린 종목</b>과, 같이 움직이는 한국(KOSPI) 관련주입니다. 관련주의 <b style="color:#FFD740">AI점수가 높으면(+30↑)</b> 오늘 주목해볼 만합니다.</div>
|
||||
<div class="usbrief-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:14px">
|
||||
<div><div style="color:#69F0AE;font-weight:700;font-size:12px;margin-bottom:8px">🔥 어젯밤 강했던 종목</div>
|
||||
${(brief.hot||[]).map(s=>stockCard(s,true)).join('')||'<div style="color:#546E7A;font-size:11px">데이터 없음</div>'}</div>
|
||||
<div><div style="color:#FF8A80;font-weight:700;font-size:12px;margin-bottom:8px">❄️ 어젯밤 약했던 종목</div>
|
||||
${(brief.cold||[]).map(s=>stockCard(s,false)).join('')||'<div style="color:#546E7A;font-size:11px">데이터 없음</div>'}</div>
|
||||
</div>`;
|
||||
if(brief.sector_heat&&brief.sector_heat.length){
|
||||
h += `<div style="margin-top:14px"><div style="font-size:11px;color:#607D8B;margin-bottom:6px">미국 업종 ETF 등락 — 한국 같은 업종에 영향</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px">`;
|
||||
brief.sector_heat.forEach(e=>{
|
||||
const c=e.change_pct>=0?'#69F0AE':'#FF8A80';
|
||||
h += `<span style="background:${c}1a;border:1px solid ${c}40;border-radius:6px;padding:3px 9px;font-size:10.5px;color:#CFD8DC">${esc(e.label)} <b style="color:${c}">${e.change_pct>=0?'+':''}${e.change_pct}%</b></span>`;
|
||||
});
|
||||
h += `</div></div>`;
|
||||
}
|
||||
h += `</div>`;
|
||||
}
|
||||
|
||||
// ── 1. 최신 동조 시그널 (가장 중요 — 맨 위로) ──
|
||||
h += `<div class="panel"><h3>📡 오늘의 미국→한국 영향 (${Array.isArray(signals)?signals.length:0}개 종목)</h3>`;
|
||||
h += `<div style="font-size:11px;color:#90A4AE;margin-bottom:12px">
|
||||
🟢 양수(+) = 미국 영향으로 <b style="color:#69F0AE">올라갈 압력</b> · 🔴 음수(-) = <b style="color:#FF8A80">내려갈 압력</b><br>
|
||||
"섹터"는 미국 업종 ETF, "페어"는 짝지어진 미국 개별주의 영향. 둘을 합친 게 총점.
|
||||
</div>`;
|
||||
if(Array.isArray(signals) && signals.length){
|
||||
h += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:8px">`;
|
||||
// 절대값 큰 순으로 정렬
|
||||
const sorted = signals.slice().sort((a,b)=>Math.abs(+b.total_adj||0) - Math.abs(+a.total_adj||0));
|
||||
sorted.slice(0,60).forEach(s=>{
|
||||
// 강도 그룹별로 분류
|
||||
const strong = sorted.filter(s=>Math.abs(+s.total_adj||0) >= 5);
|
||||
const medium = sorted.filter(s=>{ const a=Math.abs(+s.total_adj||0); return a>=2 && a<5; });
|
||||
const weak = sorted.filter(s=>Math.abs(+s.total_adj||0) < 2);
|
||||
const renderCard = (s)=>{
|
||||
const total = +s.total_adj || 0;
|
||||
const sec = +s.sector_adj || 0;
|
||||
const pair = +s.pair_adj || 0;
|
||||
const c = total>0?'#69F0AE':total<0?'#FF8A80':'#90A4AE';
|
||||
h += `<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.05);border-radius:10px;padding:10px 12px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span style="color:#CFD8DC;font-family:'JetBrains Mono',monospace;font-weight:700">${esc(s.kr_code||'-')}</span>
|
||||
<span style="color:${c};font-weight:700;font-family:'JetBrains Mono',monospace">${total>0?'+':''}${total.toFixed(2)}</span>
|
||||
const arrow = total>=5?'🚀':total<=-5?'⚠️':total>0?'▲':total<0?'▼':'·';
|
||||
const krName = esc(s.kr_name || s.kr_code || '-');
|
||||
// contributing_pairs에서 어떤 미국 종목이 주된 원인인지 표시
|
||||
let topReason = '';
|
||||
try{
|
||||
const cp = s.contributing_pairs;
|
||||
const pairs = (cp && cp.pairs) ? cp.pairs : (Array.isArray(cp)?cp:[]);
|
||||
if(pairs && pairs.length){
|
||||
const top = pairs.slice().sort((a,b)=>Math.abs(+b.contribution||0)-Math.abs(+a.contribution||0))[0];
|
||||
if(top){
|
||||
const usK = usName(top.us) || '';
|
||||
topReason = `${esc(top.us)}${usK?`(${esc(usK)})`:''} ${(+top.pct||0)>=0?'+':''}${(+top.pct||0).toFixed(1)}%`;
|
||||
}
|
||||
}
|
||||
}catch(e){}
|
||||
return `<div style="background:rgba(255,255,255,0.03);border:1px solid ${c}33;border-radius:10px;padding:12px 14px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:8px">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="color:#CFD8DC;font-weight:700;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${krName}</div>
|
||||
<div style="font-family:'JetBrains Mono',monospace;font-size:10px;color:#546E7A">${esc(s.kr_code||'')}</div>
|
||||
</div>
|
||||
<div style="text-align:right">
|
||||
<div style="color:${c};font-weight:700;font-family:'JetBrains Mono',monospace;font-size:18px">${arrow} ${total>0?'+':''}${total.toFixed(1)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:10px;color:#90A4AE;margin-top:4px;display:flex;gap:10px">
|
||||
<span>섹터 ${sec>=0?'+':''}${sec.toFixed(2)}</span>
|
||||
<span>페어 ${pair>=0?'+':''}${pair.toFixed(2)}</span>
|
||||
<div style="font-size:10.5px;color:#78909C;margin-top:6px;display:flex;gap:10px;flex-wrap:wrap">
|
||||
<span title="섹터 ETF 영향">섹터 <span style="color:${sec>=0?'#69F0AE':'#FF8A80'}">${sec>=0?'+':''}${sec.toFixed(1)}</span></span>
|
||||
<span title="개별 페어 영향">페어 <span style="color:${pair>=0?'#69F0AE':'#FF8A80'}">${pair>=0?'+':''}${pair.toFixed(1)}</span></span>
|
||||
</div>
|
||||
${topReason?`<div style="font-size:10px;color:#90A4AE;margin-top:5px;border-top:1px solid rgba(255,255,255,0.05);padding-top:5px">📌 주원인: ${topReason}</div>`:''}
|
||||
</div>`;
|
||||
};
|
||||
const renderGroup = (title, color, arr) => {
|
||||
if(!arr.length) return '';
|
||||
return `<div style="margin-bottom:14px">
|
||||
<div style="font-size:12px;color:${color};font-weight:700;margin-bottom:8px">${title} <span style="color:#546E7A;font-weight:400">${arr.length}개</span></div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px">
|
||||
${arr.slice(0,40).map(renderCard).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
});
|
||||
h += `</div>`;
|
||||
} else h += `<div class="empty">시그널 없음 (아직 계산 전)</div>`;
|
||||
};
|
||||
h += renderGroup('🔥 강한 영향 (|점수| ≥ 5)', '#FFD740', strong);
|
||||
h += renderGroup('➡️ 보통 영향 (2 ~ 5)', '#40C4FF', medium);
|
||||
if(weak.length && (strong.length + medium.length) < 5){
|
||||
h += renderGroup('약한 영향 (< 2)', '#78909C', weak.slice(0,20));
|
||||
}
|
||||
} else h += `<div class="empty">시그널 없음 — 매일 08:00에 자동 계산됨. 처음 배포 직후라면 아직 생성 전입니다.</div>`;
|
||||
h += `</div>`;
|
||||
|
||||
// ── 2. 섹터 ETF (어떤 업종이 어떻게 매칭되는지) ──
|
||||
h += `<div class="grid2" style="margin-top:14px">`;
|
||||
h += `<div class="panel"><h3>🏭 미국 섹터 ETF → 한국 업종 매칭 (${Array.isArray(etfs)?etfs.length:0})</h3>`;
|
||||
h += `<div style="font-size:11px;color:#90A4AE;margin-bottom:10px">미국 ETF가 오르내리면 매칭된 한국 업종도 같이 영향받습니다.</div>`;
|
||||
if(Array.isArray(etfs) && etfs.length){
|
||||
h += `<table style="width:100%;font-size:11.5px;border-collapse:separate;border-spacing:0 3px">
|
||||
<thead><tr style="color:#90A4AE;font-size:10.5px">
|
||||
<th style="text-align:left;padding:4px 6px">미국 ETF</th>
|
||||
<th style="text-align:left">설명</th>
|
||||
<th style="text-align:left">한국 업종</th>
|
||||
</tr></thead><tbody>`;
|
||||
etfs.slice(0,30).forEach(e=>{
|
||||
const kw = Array.isArray(e.sector_keywords)?e.sector_keywords.join(' · '):'';
|
||||
const krDesc = usName(e.etf_ticker) || esc(e.description||'-');
|
||||
h += `<tr style="background:rgba(255,255,255,0.02)">
|
||||
<td style="padding:7px 8px;color:#69F0AE;font-family:'JetBrains Mono',monospace;font-weight:700">${esc(e.etf_ticker)}</td>
|
||||
<td style="color:#CFD8DC">${esc(krDesc)}</td>
|
||||
<td style="color:#FFD740;font-size:11px">${esc(kw)}</td>
|
||||
</tr>`;
|
||||
});
|
||||
h += `</tbody></table>`;
|
||||
} else h += `<div class="empty">데이터 없음</div>`;
|
||||
h += `</div>`;
|
||||
|
||||
// ── 3. 개별 페어 ──
|
||||
h += `<div class="panel"><h3>🔗 개별 종목 짝짓기 (${Array.isArray(pairs)?pairs.length:0})</h3>`;
|
||||
h += `<div style="font-size:11px;color:#90A4AE;margin-bottom:10px">
|
||||
<b>민감도(β)</b>: 미국 종목이 1% 오를 때 한국 종목이 몇% 따라가는지<br>
|
||||
<b>상관계수(ρ)</b>: 같이 움직이는 정도. 0.7↑면 매우 강함, 0.5↑면 의미 있음, 0.3↓면 약함
|
||||
</div>`;
|
||||
if(Array.isArray(pairs) && pairs.length){
|
||||
// 한국명 매칭되는 페어를 우선 정렬
|
||||
const sorted = pairs.slice().sort((a,b)=>{
|
||||
const corA = Math.abs(+a.correlation_60d||0);
|
||||
const corB = Math.abs(+b.correlation_60d||0);
|
||||
return corB - corA;
|
||||
});
|
||||
h += `<table style="width:100%;font-size:11.5px;border-collapse:separate;border-spacing:0 3px">
|
||||
<thead><tr style="color:#90A4AE;font-size:10.5px">
|
||||
<th style="text-align:left;padding:4px 6px">미국</th>
|
||||
<th style="text-align:left">한국</th>
|
||||
<th title="민감도: 미국 +1%일 때 한국 +β%" style="text-align:center">민감도(β)</th>
|
||||
<th title="상관: 같이 움직이는 정도 (0~1)" style="text-align:center">상관(ρ)</th>
|
||||
</tr></thead><tbody>`;
|
||||
sorted.slice(0,40).forEach(p=>{
|
||||
const beta = p.beta_60d!=null?(+p.beta_60d).toFixed(2):'-';
|
||||
const corr = p.correlation_60d!=null?(+p.correlation_60d).toFixed(2):'-';
|
||||
const corrN = +p.correlation_60d || 0;
|
||||
const corrC = Math.abs(corrN)>=0.7?'#69F0AE':Math.abs(corrN)>=0.5?'#FFD740':Math.abs(corrN)>=0.3?'#90A4AE':'#546E7A';
|
||||
const usK = usName(p.us_ticker) || '';
|
||||
const krN = esc(p.kr_name || '');
|
||||
h += `<tr style="background:rgba(255,255,255,0.02)">
|
||||
<td style="padding:6px 8px;color:#69F0AE;font-family:'JetBrains Mono',monospace">${esc(p.us_ticker)}${usK?`<span style="color:#90A4AE;font-family:'Pretendard',sans-serif;font-size:10px;margin-left:4px">${esc(usK)}</span>`:''}</td>
|
||||
<td style="color:#CFD8DC">${krN || `<span style="font-family:'JetBrains Mono',monospace">${esc(p.kr_code)}</span>`}</td>
|
||||
<td style="text-align:center;font-family:'JetBrains Mono',monospace;color:#FFD740">${beta}</td>
|
||||
<td style="text-align:center;font-family:'JetBrains Mono',monospace;color:${corrC};font-weight:700">${corr}</td>
|
||||
</tr>`;
|
||||
});
|
||||
h += `</tbody></table>`;
|
||||
} else h += `<div class="empty">페어 없음</div>`;
|
||||
h += `</div></div>`;
|
||||
|
||||
el.innerHTML = h;
|
||||
}catch(e){el.innerHTML=`<div class="empty">오류: ${esc(String(e))}</div>`}
|
||||
}
|
||||
|
||||
+76
-12
@@ -317,7 +317,9 @@ async def recent(limit: int = Query(default=40), only_stock: bool = Query(defaul
|
||||
async with pg_pool.acquire() as c:
|
||||
rows = await c.fetch(f"""
|
||||
SELECT title, sentiment, intensity, primary_stock, reason,
|
||||
investment_action, source, analyzed_at, url, catalyst, stock_codes
|
||||
investment_action, source,
|
||||
COALESCE(published_at, analyzed_at) AS analyzed_at,
|
||||
url, catalyst, stock_codes
|
||||
FROM news_analysis {where}
|
||||
ORDER BY analyzed_at DESC LIMIT $1
|
||||
""", limit)
|
||||
@@ -473,7 +475,7 @@ async def buy_candidates(limit: int = Query(default=20)):
|
||||
ON t.stock_code = s.stock_code
|
||||
AND s.score_date = (SELECT MAX(score_date) FROM stock_scores)
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT roe, operating_margin, debt_ratio, revenue_growth, net_margin,
|
||||
SELECT stock_code, roe, operating_margin, debt_ratio, revenue_growth, net_margin,
|
||||
operating_profit, revenue
|
||||
FROM dart_financials
|
||||
WHERE stock_code = t.stock_code
|
||||
@@ -507,9 +509,11 @@ async def alerts():
|
||||
async with pg_pool.acquire() as c:
|
||||
rows = await c.fetch("""
|
||||
SELECT title, sentiment, intensity, primary_stock,
|
||||
reason, investment_action, source, analyzed_at
|
||||
reason, investment_action, source,
|
||||
COALESCE(published_at, analyzed_at) AS analyzed_at
|
||||
FROM news_analysis
|
||||
WHERE intensity >= 3 AND analyzed_at >= NOW() - INTERVAL '24 hours'
|
||||
WHERE intensity >= 3
|
||||
AND COALESCE(published_at, analyzed_at) >= NOW() - INTERVAL '24 hours'
|
||||
ORDER BY intensity DESC, analyzed_at DESC LIMIT 20
|
||||
""")
|
||||
return [dict(r) for r in rows]
|
||||
@@ -520,12 +524,12 @@ async def alerts():
|
||||
async def timeline(hours: int = Query(default=24)):
|
||||
async with pg_pool.acquire() as c:
|
||||
rows = await c.fetch("""
|
||||
SELECT date_trunc('hour', analyzed_at) AS hour,
|
||||
SELECT date_trunc('hour', COALESCE(published_at, analyzed_at)) AS hour,
|
||||
SUM(CASE WHEN sentiment='호재' THEN 1 ELSE 0 END) AS pos,
|
||||
SUM(CASE WHEN sentiment='악재' THEN 1 ELSE 0 END) AS neg,
|
||||
COUNT(*) AS total
|
||||
FROM news_analysis
|
||||
WHERE analyzed_at >= NOW() - INTERVAL '%s hours'
|
||||
WHERE COALESCE(published_at, analyzed_at) >= NOW() - INTERVAL '%s hours'
|
||||
GROUP BY hour ORDER BY hour
|
||||
""" % hours)
|
||||
return [{"hour": str(r["hour"]), "positive": r["pos"],
|
||||
@@ -537,7 +541,8 @@ async def timeline(hours: int = Query(default=24)):
|
||||
async def stock(code: str):
|
||||
async with pg_pool.acquire() as c:
|
||||
news = await c.fetch("""
|
||||
SELECT title, sentiment, intensity, reason, source, analyzed_at
|
||||
SELECT title, sentiment, intensity, reason, source,
|
||||
COALESCE(published_at, analyzed_at) AS analyzed_at
|
||||
FROM news_analysis WHERE primary_stock=$1
|
||||
ORDER BY analyzed_at DESC LIMIT 20
|
||||
""", code)
|
||||
@@ -766,11 +771,23 @@ async def portfolio_prices(codes: str = Query(default="")):
|
||||
except: pass
|
||||
async with pg_pool.acquire() as c:
|
||||
rows = await c.fetch("""
|
||||
SELECT stock_code, total_score, recommendation, news_score, technical_score
|
||||
SELECT stock_code, total_score, recommendation, news_score, technical_score, sector
|
||||
FROM stock_scores
|
||||
WHERE stock_code = ANY($1)
|
||||
AND score_date = (SELECT MAX(score_date) FROM stock_scores)
|
||||
""", code_list)
|
||||
# Redis 가격캐시 미스 시 stock_prices 테이블에서 폴백 (캐시 비어도 손익 계산되게)
|
||||
missing = [x for x in code_list if not price_map.get(x)]
|
||||
if missing:
|
||||
prows = await c.fetch("""
|
||||
SELECT DISTINCT ON (stock_code) stock_code, price, change_pct
|
||||
FROM stock_prices
|
||||
WHERE stock_code = ANY($1) AND price > 0
|
||||
ORDER BY stock_code, collected_at DESC
|
||||
""", missing)
|
||||
for pr in prows:
|
||||
price_map[pr["stock_code"]] = {
|
||||
"price": pr["price"], "change_pct": float(pr["change_pct"] or 0)}
|
||||
score_map = {r["stock_code"]: dict(r) for r in rows}
|
||||
result = []
|
||||
for code in code_list:
|
||||
@@ -786,6 +803,7 @@ async def portfolio_prices(codes: str = Query(default="")):
|
||||
"signal": ta.get("signal") or "관망",
|
||||
"ai_score": sc.get("total_score"),
|
||||
"recommendation": sc.get("recommendation"),
|
||||
"sector": sc.get("sector") or "기타",
|
||||
})
|
||||
return result
|
||||
|
||||
@@ -1487,6 +1505,17 @@ async def volume_surge():
|
||||
except Exception as e:
|
||||
return JSONResponse(content={"data": [], "error": str(e)})
|
||||
|
||||
|
||||
@app.get("/api/hot")
|
||||
async def hot():
|
||||
"""지금 뜨는 종목 — 뉴스+거래량 모멘텀 (score-engine /hot 프록시)"""
|
||||
async with httpx.AsyncClient() as c:
|
||||
try:
|
||||
r = await c.get(f"{SCORE_ENGINE_URL}/hot?limit=20", timeout=15)
|
||||
return JSONResponse(content=r.json())
|
||||
except Exception as e:
|
||||
return JSONResponse(content=[], status_code=200)
|
||||
|
||||
# ── 사용자 인증 + 포트폴리오 ────────────────────────────────
|
||||
|
||||
class RegisterReq(BaseModel):
|
||||
@@ -2042,12 +2071,39 @@ async def proxy_backtest(days: int = Query(default=180, ge=30, le=720)):
|
||||
return await _proxy_get(f"{SCORE_ENGINE_URL}/backtest?days={days}", timeout=30.0)
|
||||
|
||||
|
||||
@app.get("/api/portfolio/recommended")
|
||||
async def proxy_portfolio_recommended(amount: int = Query(default=0, ge=0)):
|
||||
"""AI 추천 포트폴리오 구성 (score-engine /portfolio/recommended)"""
|
||||
return await _proxy_get(
|
||||
f"{SCORE_ENGINE_URL}/portfolio/recommended?amount={amount}", timeout=20.0)
|
||||
|
||||
|
||||
@app.get("/api/sector-concentration")
|
||||
async def proxy_sector_concentration():
|
||||
"""섹터 집중도 + 30% 초과 경고 (score-engine /sector/concentration)"""
|
||||
return await _proxy_get(f"{SCORE_ENGINE_URL}/sector/concentration")
|
||||
|
||||
|
||||
async def _enrich_kr_names(rows):
|
||||
"""rows의 kr_code/stock_code에 dart_corps.corp_name을 kr_name으로 첨부"""
|
||||
if not isinstance(rows, list) or not rows:
|
||||
return rows
|
||||
codes = list({(r.get("kr_code") or r.get("stock_code") or "") for r in rows if isinstance(r, dict)})
|
||||
codes = [c for c in codes if c]
|
||||
if not codes:
|
||||
return rows
|
||||
async with pg_pool.acquire() as c:
|
||||
name_rows = await c.fetch(
|
||||
"SELECT stock_code, corp_name FROM dart_corps WHERE stock_code = ANY($1::text[])", codes)
|
||||
name_map = {r["stock_code"]: r["corp_name"] for r in name_rows}
|
||||
for r in rows:
|
||||
if not isinstance(r, dict): continue
|
||||
code = r.get("kr_code") or r.get("stock_code")
|
||||
if code and name_map.get(code):
|
||||
r["kr_name"] = name_map[code]
|
||||
return rows
|
||||
|
||||
|
||||
@app.get("/api/usmarket/etfs")
|
||||
async def proxy_us_etfs():
|
||||
"""미국 섹터 ETF 목록"""
|
||||
@@ -2056,14 +2112,22 @@ async def proxy_us_etfs():
|
||||
|
||||
@app.get("/api/usmarket/pairs")
|
||||
async def proxy_us_pairs():
|
||||
"""KR↔US 페어 + 60일 회귀 베타"""
|
||||
return await _proxy_get(f"{US_MARKET_URL}/pairs")
|
||||
"""KR↔US 페어 + 60일 회귀 베타 + 한국 종목명"""
|
||||
rows = await _proxy_get(f"{US_MARKET_URL}/pairs")
|
||||
return await _enrich_kr_names(rows)
|
||||
|
||||
|
||||
@app.get("/api/usmarket/signals")
|
||||
async def proxy_us_signals():
|
||||
"""미증시 동조 시그널 (전체)"""
|
||||
return await _proxy_get(f"{US_MARKET_URL}/signal/latest")
|
||||
"""미증시 동조 시그널 (전체) + 한국 종목명"""
|
||||
rows = await _proxy_get(f"{US_MARKET_URL}/signal/latest")
|
||||
return await _enrich_kr_names(rows)
|
||||
|
||||
|
||||
@app.get("/api/usmarket/briefing")
|
||||
async def proxy_us_briefing():
|
||||
"""미증시 새벽 핫/저조 종목 + 관련 KOSPI 추천 (us-market /overnight-briefing)"""
|
||||
return await _proxy_get(f"{US_MARKET_URL}/overnight-briefing")
|
||||
|
||||
|
||||
@app.get("/api/macro/ecos")
|
||||
|
||||
+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
|
||||
|
||||
+360
-25
@@ -32,6 +32,8 @@ PG_PASS = os.getenv("POSTGRES_PASSWORD", "")
|
||||
KIWOOM_APP_KEY = os.getenv("KIWOOM_APP_KEY", "")
|
||||
KIWOOM_SECRET_KEY = os.getenv("KIWOOM_SECRET_KEY", "")
|
||||
KIWOOM_BASE_URL = os.getenv("KIWOOM_BASE_URL", "https://api.kiwoom.com")
|
||||
TG_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
||||
TG_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "")
|
||||
|
||||
pg_pool: Optional[asyncpg.Pool] = None
|
||||
redis_cl: Optional[aioredis.Redis] = None
|
||||
@@ -68,6 +70,25 @@ class KiwoomToken:
|
||||
|
||||
kiwoom_token = KiwoomToken()
|
||||
|
||||
|
||||
class _RateLimiter:
|
||||
"""Kiwoom 글로벌 레이트리밋 — 초당 max_rate건으로 직렬 페이싱 (429 방지)."""
|
||||
def __init__(self, max_rate: float):
|
||||
self.min_gap = 1.0 / max_rate
|
||||
self._last = 0.0
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def acquire(self):
|
||||
async with self._lock:
|
||||
loop = asyncio.get_running_loop()
|
||||
wait = self.min_gap - (loop.time() - self._last)
|
||||
if wait > 0:
|
||||
await asyncio.sleep(wait)
|
||||
self._last = loop.time()
|
||||
|
||||
# 실측: 페이싱 시 10/s도 정상. 보수적으로 6/s (≈2,300종목 6분 주기).
|
||||
kiwoom_rate = _RateLimiter(6.0)
|
||||
|
||||
# ── 키움 API 호출 헬퍼 ────────────────────────────────────
|
||||
|
||||
async def kiwoom_post(client: httpx.AsyncClient, endpoint: str, api_id: str,
|
||||
@@ -82,8 +103,15 @@ async def kiwoom_post(client: httpx.AsyncClient, endpoint: str, api_id: str,
|
||||
}
|
||||
if next_key:
|
||||
headers["next-key"] = next_key
|
||||
r = await client.post(f"{KIWOOM_BASE_URL}{endpoint}",
|
||||
headers=headers, json=body, timeout=15)
|
||||
# 글로벌 페이싱 + 429(요청한도 초과) 재시도
|
||||
for attempt in range(3):
|
||||
await kiwoom_rate.acquire()
|
||||
r = await client.post(f"{KIWOOM_BASE_URL}{endpoint}",
|
||||
headers=headers, json=body, timeout=15)
|
||||
if r.status_code == 429 and attempt < 2:
|
||||
await asyncio.sleep(0.6 * (attempt + 1))
|
||||
continue
|
||||
break
|
||||
if return_headers: # 연속조회용 cont-yn/next-key 응답헤더 필요
|
||||
return r.json(), r.headers
|
||||
return r.json()
|
||||
@@ -440,6 +468,28 @@ async def get_stock_codes(limit: int = 0) -> list:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
async def get_priority_codes(min_mcap: int = 30_000_000_000) -> list:
|
||||
"""5분 수집 우선군 — 시총 min_mcap(기본 300억) 이상 활성종목 (잡주 제외).
|
||||
조회 실패·표본 부족 시 전체 종목으로 폴백."""
|
||||
if pg_pool:
|
||||
try:
|
||||
rows = await pg_pool.fetch("""
|
||||
SELECT d.stock_code FROM dart_corps d
|
||||
JOIN LATERAL (
|
||||
SELECT market_cap FROM stock_prices
|
||||
WHERE stock_code=d.stock_code AND market_cap>0
|
||||
ORDER BY collected_at DESC LIMIT 1) p ON true
|
||||
WHERE d.is_active=TRUE AND p.market_cap >= $1
|
||||
ORDER BY d.stock_code
|
||||
""", min_mcap)
|
||||
codes = [r["stock_code"] for r in rows if r["stock_code"]]
|
||||
if len(codes) >= 50:
|
||||
return codes
|
||||
except Exception:
|
||||
pass
|
||||
return await get_stock_codes(0)
|
||||
|
||||
# ── 저장 함수 ─────────────────────────────────────────────
|
||||
|
||||
async def save_price(info: dict):
|
||||
@@ -551,29 +601,88 @@ class Stats:
|
||||
|
||||
stats = Stats()
|
||||
|
||||
_job_price_running = False
|
||||
|
||||
async def job_price():
|
||||
"""평일 9~16시 5분마다: 현재가·재무지표 수집 (ka10001)"""
|
||||
codes = await get_stock_codes(0)
|
||||
if not codes:
|
||||
logger.warning("job_price.no_codes")
|
||||
"""평일 9~15시: 시총 300억+ 우선군 현재가 수집 (ka10001).
|
||||
잡주(시총<300억)는 후순위 — job_full(17시)에서 일봉으로 커버.
|
||||
글로벌 6/s 페이싱으로 한 사이클 수 분 소요 → 중복 실행 가드."""
|
||||
global _job_price_running
|
||||
if _job_price_running:
|
||||
logger.info("job_price.skip_running")
|
||||
return
|
||||
sem = asyncio.Semaphore(10) # 동시 10개 제한
|
||||
ok = 0
|
||||
async with httpx.AsyncClient() as client:
|
||||
tasks = [fetch_basic_info(client, code, sem) for code in codes]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
for info in results:
|
||||
if isinstance(info, dict) and info.get("price", 0) > 0:
|
||||
await save_price(info)
|
||||
ok += 1
|
||||
else:
|
||||
stats.errors += 1
|
||||
await gen_signals()
|
||||
stats.collected += ok
|
||||
stats.last_run = datetime.now().isoformat()
|
||||
if redis_cl:
|
||||
await redis_cl.set("prices:last_update", datetime.now().isoformat())
|
||||
logger.info("job_price.done", ok=ok, total=len(codes))
|
||||
_job_price_running = True
|
||||
try:
|
||||
codes = await get_priority_codes()
|
||||
if not codes:
|
||||
logger.warning("job_price.no_codes")
|
||||
return
|
||||
sem = asyncio.Semaphore(8) # 동시 in-flight 한도 (실제 속도는 kiwoom_rate가 제어)
|
||||
ok = 0
|
||||
async with httpx.AsyncClient() as client:
|
||||
tasks = [fetch_basic_info(client, code, sem) for code in codes]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
for info in results:
|
||||
if isinstance(info, dict) and info.get("price", 0) > 0:
|
||||
await save_price(info)
|
||||
ok += 1
|
||||
else:
|
||||
stats.errors += 1
|
||||
await gen_signals()
|
||||
stats.collected += ok
|
||||
stats.last_run = datetime.now().isoformat()
|
||||
if redis_cl:
|
||||
await redis_cl.set("prices:last_update", datetime.now().isoformat())
|
||||
try:
|
||||
await check_price_alerts()
|
||||
except Exception as e:
|
||||
logger.warning("alert.check_err", error=str(e))
|
||||
logger.info("job_price.done", ok=ok, total=len(codes))
|
||||
finally:
|
||||
_job_price_running = False
|
||||
|
||||
_job_priority_running = False
|
||||
|
||||
async def job_price_priority():
|
||||
"""30초마다: 보유 + 강력매수 + 매수관심 종목만 빠른 수집 (자동매매·알림 latency 단축).
|
||||
키움 rate limit 안전 — 우선순위 종목은 보통 30~50건이라 6/s × 10초 이내 완료."""
|
||||
global _job_priority_running
|
||||
if _job_priority_running:
|
||||
return
|
||||
_job_priority_running = True
|
||||
try:
|
||||
async with pg_pool.acquire() as conn:
|
||||
rows = await conn.fetch("""
|
||||
SELECT DISTINCT stock_code FROM (
|
||||
SELECT stock_code FROM user_portfolio WHERE active=true
|
||||
UNION
|
||||
SELECT stock_code FROM user_alerts WHERE active=true
|
||||
UNION
|
||||
SELECT stock_code FROM stock_scores
|
||||
WHERE score_date=(SELECT MAX(score_date) FROM stock_scores)
|
||||
AND recommendation IN ('강력매수','매수관심')
|
||||
) AS t LIMIT 60
|
||||
""")
|
||||
codes = [r["stock_code"] for r in rows]
|
||||
if not codes:
|
||||
return
|
||||
sem = asyncio.Semaphore(8)
|
||||
ok = 0
|
||||
async with httpx.AsyncClient() as client:
|
||||
tasks = [fetch_basic_info(client, c, sem) for c in codes]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
for info in results:
|
||||
if isinstance(info, dict) and info.get("price", 0) > 0:
|
||||
await save_price(info)
|
||||
ok += 1
|
||||
try:
|
||||
await check_price_alerts() # 알림 즉시 평가
|
||||
except Exception as e:
|
||||
logger.warning("priority.alert_err", error=str(e))
|
||||
logger.info("job_price_priority.done", ok=ok, total=len(codes))
|
||||
finally:
|
||||
_job_priority_running = False
|
||||
|
||||
|
||||
async def job_full(days: int = 10):
|
||||
"""평일 17:00: 일봉·외국인·공매도 전체 수집 (ka10005·ka10008·ka10014).
|
||||
@@ -736,9 +845,12 @@ async def startup():
|
||||
redis_cl = aioredis.Redis(
|
||||
host=REDIS_HOST, port=6379, password=REDIS_PASSWORD, db=3, decode_responses=True)
|
||||
await init_db()
|
||||
# 현재가: 평일 9~16시 5분마다
|
||||
# 현재가: 평일 9~16시 2분마다 (전체)
|
||||
scheduler.add_job(job_price, "cron", day_of_week="mon-fri",
|
||||
hour="9-15", minute="*/5", id="price", replace_existing=True)
|
||||
hour="9-15", minute="*/2", id="price", replace_existing=True)
|
||||
# Fast track: 보유 + 강력매수 + 매수관심만 30초마다 (자동매매 latency)
|
||||
scheduler.add_job(job_price_priority, "cron", day_of_week="mon-fri",
|
||||
hour="9-15", second="*/30", id="price_priority", replace_existing=True)
|
||||
# 장 마감 후 전체 수집: 평일 17:00
|
||||
scheduler.add_job(job_full, "cron", day_of_week="mon-fri",
|
||||
hour="17", minute="0", id="full", replace_existing=True)
|
||||
@@ -858,6 +970,229 @@ async def summary(code: str):
|
||||
result[field] = json.loads(c) if field != "price" else json.loads(c)
|
||||
return JSONResponse(content=result)
|
||||
|
||||
# ── 가격 알림 (user_alerts 트리거 + 5가지 물타기 진단) ────────
|
||||
|
||||
async def send_telegram(msg: str):
|
||||
if not TG_TOKEN or not TG_CHAT_ID:
|
||||
return
|
||||
try:
|
||||
async with httpx.AsyncClient() as c:
|
||||
await c.post(
|
||||
f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage",
|
||||
json={"chat_id": TG_CHAT_ID, "text": msg, "parse_mode": "HTML"},
|
||||
timeout=10)
|
||||
except Exception as e:
|
||||
logger.warning("telegram.err", error=str(e))
|
||||
|
||||
|
||||
async def diagnose_avg_down(conn, code: str, current_price: float) -> tuple[int, list[str]]:
|
||||
"""물타기 진단 5가지 조건. (충족 개수, 표시줄 리스트) 반환."""
|
||||
score = 0
|
||||
lines = []
|
||||
|
||||
# 1. RSI ≤ 30 (과매도)
|
||||
rsi = await conn.fetchval("""
|
||||
SELECT rsi FROM stock_technical WHERE stock_code=$1
|
||||
""", code)
|
||||
rsi_disp = f"{rsi:.0f}" if rsi is not None else "N/A"
|
||||
if rsi is not None and rsi <= 30:
|
||||
score += 1
|
||||
lines.append(f"✅ RSI {rsi_disp} (과매도)")
|
||||
elif rsi is not None and rsi <= 40:
|
||||
lines.append(f"⚠️ RSI {rsi_disp} (약한 과매도)")
|
||||
else:
|
||||
lines.append(f"❌ RSI {rsi_disp} (과매도 아님)")
|
||||
|
||||
# 2. 거래량 비율 ≤ 1.5 (패닉 매도 끝)
|
||||
vol_ratio = await conn.fetchval("""
|
||||
SELECT vol_ratio FROM stock_technical WHERE stock_code=$1
|
||||
""", code)
|
||||
vol_disp = f"{vol_ratio:.2f}" if vol_ratio is not None else "N/A"
|
||||
if vol_ratio is not None and vol_ratio <= 1.5:
|
||||
score += 1
|
||||
lines.append(f"✅ 거래량비율 {vol_disp} (안정)")
|
||||
else:
|
||||
lines.append(f"❌ 거래량비율 {vol_disp} (패닉 진행)")
|
||||
|
||||
# 3. 공매도 잔고 감소 추세 (최근 5일)
|
||||
short_rows = await conn.fetch("""
|
||||
SELECT short_balance_qty FROM stock_short_sale
|
||||
WHERE stock_code=$1 ORDER BY dt DESC LIMIT 5
|
||||
""", code)
|
||||
if len(short_rows) >= 2:
|
||||
latest = short_rows[0]["short_balance_qty"] or 0
|
||||
older = short_rows[-1]["short_balance_qty"] or 0
|
||||
if older > 0 and latest < older:
|
||||
chg = (latest - older) / older * 100
|
||||
score += 1
|
||||
lines.append(f"✅ 공매도잔고 {chg:+.1f}% (감소)")
|
||||
else:
|
||||
lines.append(f"❌ 공매도잔고 증가/유지")
|
||||
else:
|
||||
lines.append("⚠️ 공매도 데이터 부족")
|
||||
|
||||
# 4. 최근 24시간 악재(intensity≥3) 없음
|
||||
bad_news = await conn.fetchval("""
|
||||
SELECT COUNT(*) FROM news_analysis
|
||||
WHERE primary_stock=$1
|
||||
AND sentiment='악재' AND intensity >= 3
|
||||
AND analyzed_at >= NOW() - INTERVAL '24 hours'
|
||||
""", code)
|
||||
if bad_news == 0:
|
||||
score += 1
|
||||
lines.append("✅ 최근 악재 없음")
|
||||
else:
|
||||
lines.append(f"❌ 최근 악재 {bad_news}건")
|
||||
|
||||
# 5. 시장 레짐 = 강세/중립 (약세장 회피)
|
||||
regime = await conn.fetchval("""
|
||||
SELECT regime FROM market_regime ORDER BY dt DESC LIMIT 1
|
||||
""")
|
||||
if regime in ("강세", "중립"):
|
||||
score += 1
|
||||
lines.append(f"✅ 시장레짐 {regime}")
|
||||
else:
|
||||
lines.append(f"❌ 시장레짐 {regime} (약세장 회피)")
|
||||
|
||||
return score, lines
|
||||
|
||||
|
||||
async def check_price_alerts():
|
||||
"""수집 후 호출: active=true 알림 중 임계값 도달한 것 처리."""
|
||||
if not pg_pool:
|
||||
return
|
||||
async with pg_pool.acquire() as conn:
|
||||
alerts = await conn.fetch("""
|
||||
SELECT a.id, a.stock_code, a.alert_type, a.threshold,
|
||||
d.corp_name
|
||||
FROM user_alerts a
|
||||
LEFT JOIN dart_corps d ON d.stock_code=a.stock_code
|
||||
WHERE a.active = true
|
||||
""")
|
||||
for a in alerts:
|
||||
code = a["stock_code"]
|
||||
price = 0.0
|
||||
# 1차: Redis price:{code} (평일 수집 직후)
|
||||
cached = await redis_cl.get(f"price:{code}") if redis_cl else None
|
||||
if cached:
|
||||
try:
|
||||
price = float(json.loads(cached).get("price", 0))
|
||||
except Exception:
|
||||
price = 0.0
|
||||
# 2차 폴백: stock_ohlcv 최신 종가 (장 닫힘·주말·실패 대비)
|
||||
if price <= 0:
|
||||
p = await conn.fetchval("""
|
||||
SELECT close_price FROM stock_ohlcv
|
||||
WHERE stock_code=$1 ORDER BY dt DESC LIMIT 1
|
||||
""", code)
|
||||
price = float(p) if p else 0.0
|
||||
if price <= 0:
|
||||
continue
|
||||
hit = False
|
||||
if a["alert_type"] == "price_below" and price <= a["threshold"]:
|
||||
hit = True
|
||||
elif a["alert_type"] == "price_above" and price >= a["threshold"]:
|
||||
hit = True
|
||||
if not hit:
|
||||
continue
|
||||
|
||||
score, lines = await diagnose_avg_down(conn, code, price)
|
||||
verdict = "🟢 물타기 OK" if score >= 4 else ("🟡 신중" if score >= 3 else "🔴 NO")
|
||||
name = a["corp_name"] or code
|
||||
|
||||
# LLM hybrid 분석 동봉 (score-engine /deep-analysis 호출)
|
||||
llm_block = ""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120) as c:
|
||||
r = await c.get(
|
||||
f"http://score-engine:8686/deep-analysis/{code}",
|
||||
params={"refresh": "true", "model": "hybrid", "notify": "false"})
|
||||
if r.status_code == 200:
|
||||
d = r.json()
|
||||
if not d.get("error"):
|
||||
agr = d.get("agreement")
|
||||
agr_tag = "✅두AI일치" if agr is True else ("⚠️AI의견갈림" if agr is False else "")
|
||||
llm_block = (
|
||||
f"\n\n🤖 <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())
|
||||
|
||||
+458
-93
@@ -8,13 +8,15 @@
|
||||
import asyncio, hashlib, json, os, re, random, time
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from typing import Optional, Literal
|
||||
import asyncpg, httpx, redis.asyncio as aioredis, structlog
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from bs4 import BeautifulSoup
|
||||
from fastapi import FastAPI, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel, Field
|
||||
from sentiment_rules import keyword_rule, hallucination_match_ratio
|
||||
|
||||
structlog.configure(processors=[
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
@@ -33,6 +35,94 @@ PG_PASS = os.getenv("POSTGRES_PASSWORD", "7895123")
|
||||
BAREUN_URL = os.getenv("BAREUN_API_URL", "http://bareunaapi:5757")
|
||||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://ollama:11434")
|
||||
QDRANT_URL = os.getenv("QDRANT_URL", "http://qdrant:6333")
|
||||
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
|
||||
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-pro")
|
||||
GEMINI_DAILY_LIMIT = int(os.getenv("GEMINI_DAILY_LIMIT", "50")) # 파싱실패 폴백 포함 일일 한도(비용통제)
|
||||
|
||||
|
||||
class _GeminiNewsClf(BaseModel):
|
||||
sentiment: Literal["호재", "악재", "중립"]
|
||||
intensity: int = Field(ge=1, le=5)
|
||||
catalyst: Literal["실적", "수주", "배당", "리스크", "모멘텀", "기타"]
|
||||
reason: str
|
||||
confidence: float = Field(ge=0.0, le=1.0)
|
||||
|
||||
|
||||
async def _gemini_news_classify(title: str, content: str, exaone: dict,
|
||||
rule: dict) -> tuple[dict, int]:
|
||||
"""Gemini 짧은 프롬프트로 뉴스 sentiment 재판정 (애매한 건만).
|
||||
response_schema로 JSON 강제, max_output_tokens=1500.
|
||||
returns: (parsed_dict_or_empty, cost_krw)
|
||||
"""
|
||||
if not GEMINI_API_KEY:
|
||||
return {}, 0
|
||||
# === 글로벌 일일 1회 한도 (score-engine과 공유) ===
|
||||
try:
|
||||
async with pg_pool.acquire() as conn:
|
||||
cnt = await conn.fetchval(
|
||||
"SELECT COUNT(*) FROM gemini_call_log WHERE called_at::date = CURRENT_DATE")
|
||||
if cnt and int(cnt) >= GEMINI_DAILY_LIMIT:
|
||||
logger.warning("gemini.daily_limit", source="news_collector", today_count=int(cnt))
|
||||
return {}, 0
|
||||
except Exception as e:
|
||||
logger.warning("gemini.limit_check_err", error=str(e))
|
||||
try:
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
gclient = genai.Client(api_key=GEMINI_API_KEY)
|
||||
system = (
|
||||
"한국 주식 뉴스 한 건의 호재/악재/중립 여부를 판단합니다. "
|
||||
"본문에 명시된 사실만 근거로 하고, 본문에 없는 정보는 절대 지어내지 마세요. "
|
||||
"키워드 카운트와 EXAONE 1차 판단이 충돌하거나 신뢰도가 낮은 경우 호출됩니다."
|
||||
)
|
||||
prompt = (
|
||||
f"[제목]\n{title}\n\n"
|
||||
f"[본문(요약)]\n{(content or '')[:600]}\n\n"
|
||||
f"[EXAONE 1차] {exaone.get('sentiment')}/"
|
||||
f"강도{exaone.get('intensity',0)}/conf{float(exaone.get('confidence',0) or 0):.2f}\n"
|
||||
f"[키워드 카운트] 호재 {rule['pos']} / 악재 {rule['neg']}"
|
||||
)
|
||||
config = types.GenerateContentConfig(
|
||||
system_instruction=system,
|
||||
temperature=0.1,
|
||||
response_mime_type="application/json",
|
||||
response_schema=_GeminiNewsClf,
|
||||
max_output_tokens=1500,
|
||||
)
|
||||
resp = await asyncio.to_thread(
|
||||
gclient.models.generate_content,
|
||||
model=GEMINI_MODEL,
|
||||
contents=prompt,
|
||||
config=config,
|
||||
)
|
||||
text = getattr(resp, "text", "") or ""
|
||||
parsed = {}
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except Exception:
|
||||
pass
|
||||
usage = getattr(resp, "usage_metadata", None)
|
||||
cost_krw = 0
|
||||
if usage:
|
||||
in_tok = getattr(usage, "prompt_token_count", 0) or 0
|
||||
total_tok = getattr(usage, "total_token_count", 0) or 0
|
||||
cand_tok = getattr(usage, "candidates_token_count", 0) or 0
|
||||
# 출력 전체(candidates + thinking) = total - prompt
|
||||
out_tok = max(total_tok - in_tok, cand_tok)
|
||||
cost_usd = (in_tok / 1_000_000 * 1.25) + (out_tok / 1_000_000 * 10.0)
|
||||
cost_krw = int(cost_usd * 1400)
|
||||
# 일일 한도 카운터 기록
|
||||
try:
|
||||
async with pg_pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO gemini_call_log (source, cost_krw) VALUES ($1, $2)",
|
||||
"news_collector", cost_krw)
|
||||
except Exception:
|
||||
pass
|
||||
return parsed, cost_krw
|
||||
except Exception as e:
|
||||
logger.warning("news.gemini_err", error=str(e))
|
||||
return {}, 0
|
||||
|
||||
HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
|
||||
|
||||
@@ -42,12 +132,76 @@ scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
||||
|
||||
class S:
|
||||
collected = 0; processed = 0; duplicates = 0; errors = 0
|
||||
noise = 0; off_topic = 0
|
||||
noise = 0; off_topic = 0; stale = 0
|
||||
last_run = ""; running = False
|
||||
stats = S()
|
||||
|
||||
def nhash(title, url=""): return hashlib.sha256(f"{title.strip()}{url.strip()}".encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def _loads_lenient(s: str):
|
||||
"""EXAONE 출력에서 JSON 객체를 관대하게 파싱. 실패 시 None.
|
||||
마크다운 펜스 제거 + {..} 추출 + 트레일링 콤마 보정."""
|
||||
if not s:
|
||||
return None
|
||||
s = s.replace("```json", "").replace("```", "").strip()
|
||||
if not s.startswith("{"):
|
||||
sx, ex = s.find("{"), s.rfind("}")
|
||||
if sx != -1 and ex > sx:
|
||||
s = s[sx:ex + 1]
|
||||
try:
|
||||
return json.loads(s)
|
||||
except Exception:
|
||||
pass
|
||||
s2 = re.sub(r",\s*([}\]])", r"\1", s) # 트레일링 콤마 제거
|
||||
try:
|
||||
return json.loads(s2)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# ── 출처 신뢰도 (정적 기본값 — score-engine 사후검증이 news_source_credibility 자동 갱신) ──
|
||||
# 0.0~1.0. 메이저 통신사·전문지일수록 높음. score-engine이 가격반응 기반으로 자동 보정.
|
||||
SOURCE_CREDIBILITY = {
|
||||
"DART공시": 1.00,
|
||||
"연합뉴스": 0.95, "한국경제": 0.92, "매일경제": 0.90, "조선비즈": 0.90,
|
||||
"이데일리": 0.88, "머니투데이": 0.85, "파이낸셜뉴스": 0.85,
|
||||
"서울경제": 0.85, "SBS Biz": 0.85, "더벨": 0.82,
|
||||
"비즈니스포스트": 0.80, "전자신문": 0.80, "인포스탁": 0.78,
|
||||
"헤럴드경제": 0.78, "디지털타임스": 0.75, "뉴스1": 0.78,
|
||||
"뉴시스": 0.75, "이투데이": 0.72, "아시아경제": 0.72,
|
||||
"네이버금융": 0.75, "뉴스핌": 0.70, "글로벌이코노믹": 0.65,
|
||||
"스탁데일리": 0.60, "세계일보": 0.65,
|
||||
}
|
||||
async def get_source_credibility(source: str) -> float:
|
||||
"""static dict + DB 사후 학습 결과 융합. DB에 sample≥20이면 그 값으로 덮어씀."""
|
||||
base = SOURCE_CREDIBILITY.get(source, 0.50)
|
||||
if not pg_pool: return base
|
||||
try:
|
||||
row = await pg_pool.fetchrow(
|
||||
"SELECT credibility, sample_size FROM news_source_credibility WHERE source=$1",
|
||||
source)
|
||||
if row and (row["sample_size"] or 0) >= 20:
|
||||
return float(row["credibility"])
|
||||
except Exception:
|
||||
pass
|
||||
return base
|
||||
|
||||
# ── 제목 강도 (제목이 본문 대비 얼마나 강한 신호인지) ──
|
||||
_STRONG_TITLE_WORDS = (
|
||||
"폭락","폭등","급락","급등","충격","역대급","쇼크","서프라이즈","어닝",
|
||||
"사상최대","사상최저","신고가","신저가","상한가","하한가","적자전환","흑자전환",
|
||||
"어닝쇼크","어닝서프라이즈","대규모","돌파","무너","급증","급감",
|
||||
)
|
||||
def measure_title_strength(title: str) -> float:
|
||||
"""0.3~1.5. 제목 강조어 ↑ 가중, 추측성("?"·"…")은 감점."""
|
||||
if not title: return 0.5
|
||||
s = 1.0
|
||||
s += 0.10 * sum(1 for w in _STRONG_TITLE_WORDS if w in title)
|
||||
if "!" in title: s += 0.05
|
||||
if title.endswith("?") or "?" in title[-3:]: s -= 0.15
|
||||
if "…" in title or "..." in title: s -= 0.05
|
||||
return max(0.3, min(1.5, s))
|
||||
|
||||
def normalize_title(title: str) -> str:
|
||||
"""[속보][단독](종합) 등 제거 후 특수문자·공백 제거 → 유사 제목 중복 감지용"""
|
||||
t = re.sub(r'[\[\(【〔][^\]\)】〕]{0,10}[\]\)】〕]', '', title)
|
||||
@@ -105,7 +259,6 @@ RSS_SOURCES = [
|
||||
("전자신문", "https://www.etnews.com/news/latest_news.xml"),
|
||||
("디지털타임스", "https://www.dt.co.kr/rss/rss_economy.html"),
|
||||
("더벨", "https://www.thebell.co.kr/free/content/xmlService.asp"),
|
||||
("스탁데일리", "https://www.stockdaily.kr/rss/rss.xml"),
|
||||
("세계일보", "https://www.segye.com/RSS/economyRss.xml"),
|
||||
("SBS Biz", "https://news.sbs.co.kr/news/SectionRssFeed.do?sectionId=EC"),
|
||||
]
|
||||
@@ -273,12 +426,26 @@ async def _corp_name(code: str) -> str:
|
||||
return nm
|
||||
|
||||
|
||||
async def pipeline(item, client):
|
||||
async def pipeline(item, client, reprocess: bool = False):
|
||||
try:
|
||||
# 0. 비주식 카테고리 prefix 차단 (바른API 호출 전)
|
||||
if _is_noise_title(item.get("title", "")):
|
||||
return "noise"
|
||||
|
||||
# 0.5. published_at이 30일 이상 과거면 차단.
|
||||
# 일부 RSS 사이트가 오래된 기사를 재노출해서 점수 왜곡 유발 → LLM 호출 전에 컷.
|
||||
# 명시적 백필 엔드포인트(/collect/historical-raw)는 별도 경로라 영향 없음.
|
||||
pub_str = item.get("published_at") or ""
|
||||
if pub_str and not item.get("allow_stale"):
|
||||
try:
|
||||
pub_dt = datetime.fromisoformat(pub_str.replace("Z", "+00:00"))
|
||||
# tz-aware 비교를 위해 둘 다 aware로 맞춤
|
||||
now_dt = datetime.now(pub_dt.tzinfo) if pub_dt.tzinfo else datetime.now()
|
||||
if (now_dt - pub_dt).days > 30:
|
||||
return "stale"
|
||||
except Exception:
|
||||
pass # 파싱 실패는 정상 진행 (parse_rss_date가 fallback으로 NOW 채움)
|
||||
|
||||
# 1. 바른API
|
||||
br = await client.post(f"{BAREUN_URL}/analyze", json={
|
||||
"title": item["title"], "content": item.get("content",""),
|
||||
@@ -312,9 +479,36 @@ async def pipeline(item, client):
|
||||
sr = await client.post(f"{QDRANT_URL}/collections/news_vectors/points/search",
|
||||
json={"vector":emb,"limit":6,"score_threshold":0.80,"with_payload":True}, timeout=15)
|
||||
hits = sr.json().get("result",[])
|
||||
if any(h["score"]>=0.92 for h in hits): return "sim_dup" # ≥0.99 근접중복도 차단
|
||||
if not reprocess and any(h["score"]>=0.92 for h in hits): return "sim_dup" # ≥0.99 근접중복도 차단
|
||||
except: hits = []
|
||||
|
||||
# 3.1. 사건 클러스터링: 유사도 0.85~0.92 안에 있는 hit이 있으면 그 클러스터로 합류.
|
||||
# 첫 뉴스만 풀 가중, 후속은 score-engine에서 감쇠 (is_event_seed=True 인 것만 큰 신호로).
|
||||
event_cluster_id = ""
|
||||
is_event_seed = True
|
||||
try:
|
||||
for h in hits:
|
||||
if 0.85 <= h.get("score", 0) < 0.92:
|
||||
pl = h.get("payload", {}) or {}
|
||||
cid = pl.get("event_cluster_id") or pl.get("hash") or ""
|
||||
if cid:
|
||||
event_cluster_id = cid
|
||||
is_event_seed = False
|
||||
try:
|
||||
await pg_pool.execute("""
|
||||
UPDATE news_event_cluster
|
||||
SET last_seen_at=NOW(), member_count=member_count+1
|
||||
WHERE cluster_id=$1
|
||||
""", cid)
|
||||
except Exception: pass
|
||||
break
|
||||
if not event_cluster_id:
|
||||
event_cluster_id = hashlib.sha256(
|
||||
normalize_title(data["title"]).encode()).hexdigest()[:32]
|
||||
except Exception:
|
||||
event_cluster_id = ""
|
||||
is_event_seed = True
|
||||
|
||||
# 3.5. RAG 컨텍스트: 유사 과거뉴스 + 종목 재무·추세·점수 (버핏 판단 근거 주입)
|
||||
ctx = []
|
||||
rel = [h for h in hits if 0.80 <= h.get("score", 0) < 0.92][:4]
|
||||
@@ -373,94 +567,232 @@ async def pipeline(item, client):
|
||||
logger.debug("rag.ctx_err", code=focus["code"], error=str(e))
|
||||
context_block = "\n".join(ctx)
|
||||
|
||||
# 4. Ollama EXAONE 분석 (버핏 관점 강화 프롬프트)
|
||||
stocks_str = ", ".join([f'{s["name"]}({s["code"]})' for s in data["stocks"][:5]])
|
||||
source = item.get("source", "")
|
||||
content_preview = (data.get("filtered_text") or item.get("content") or "")[:400]
|
||||
system_prompt = (
|
||||
"당신은 워렌 버핏 스타일의 한국 주식 가치투자 애널리스트입니다.\n"
|
||||
"뉴스 한 건을 읽고 기업 본질가치 관점에서 영향을 평가합니다.\n"
|
||||
"판단 우선순위: 기업 본질가치(실적·FCF·경쟁우위) > 재무 리스크 > 단기 수급·테마\n\n"
|
||||
"[sentiment] 호재 / 악재 / 중립 — 본질가치 또는 주가에 미치는 방향\n\n"
|
||||
"[intensity] 1~5 정수\n"
|
||||
" 5=상장폐지·대규모 횡령 등 기업 존폐\n"
|
||||
" 4=연간실적 20% 이상 영향(대형 수주·어닝쇼크 등)\n"
|
||||
" 3=분기실적에 유의미한 영향\n"
|
||||
" 2=영업환경·업황 변화(직접 실적 수치 영향은 제한적)\n"
|
||||
" 1=단순 정보성·정기공시·소폭 코멘트\n\n"
|
||||
"[catalyst] 반드시 아래 6개 중 정확히 하나만 출력 (다른 단어·복합어·영문·한자 절대 금지):\n"
|
||||
' "실적" = 영업이익·매출·순이익 증감, 흑자/적자 전환, 어닝 서프라이즈/쇼크, 실적 가이던스\n'
|
||||
' "수주" = 신규 수주·공급계약·납품, M&A, 신사업·증설·신제품 등 성장 동인\n'
|
||||
' "배당" = 배당 확대/축소, 자사주 매입·소각, 주주환원 정책\n'
|
||||
' "리스크" = 횡령·배임·소송·감사의견거절·부채급증·유상증자(희석)·경영진 돌발사임·규제/제재·상폐위험\n'
|
||||
' "모멘텀" = 본질가치 변화 없는 가격 동인(외국인/기관 수급, 목표주가 조정, 테마·정책 기대)\n'
|
||||
' "기타" = 위 어디에도 명확히 속하지 않는 정보성 뉴스\n'
|
||||
" 호재/악재면 catalyst를 '기타'로 두지 말고 실적·수주·배당·리스크·모멘텀 중 가장 가까운 것을 고르세요.\n\n"
|
||||
"[참고] 블록은 보조 자료일 뿐 — 본 기사 내용으로 판단하고 과거 감성 흐름에 휩쓸리지 마세요.\n"
|
||||
"동일 내용이 [참고]의 유사 과거뉴스에 반복되면 신규성이 낮으니 intensity를 보수적으로.\n\n"
|
||||
"반드시 스키마에 맞는 유효한 JSON 객체 하나만 출력. 마크다운·설명문 금지."
|
||||
)
|
||||
user_prompt = (
|
||||
f"[출처] {source}\n"
|
||||
f"[제목] {data['title'][:200]}\n"
|
||||
f"[내용] {content_preview}\n"
|
||||
f"[관련종목] {stocks_str or '없음'}\n"
|
||||
+ (f"\n[참고]\n{context_block}\n" if context_block else "")
|
||||
+ "\ninvestment_action 규칙: 호재+intensity≥3→매수관심, 악재+intensity≥3→매도관심, 그 외→관망\n"
|
||||
"primary_stock은 6자리 숫자코드(시장 전체 뉴스면 빈 문자열), "
|
||||
"affected_stocks는 영향받는 다른 종목코드 배열, reason은 한 문장 핵심 근거.\n\n"
|
||||
"JSON 스키마:\n"
|
||||
'{"sentiment":"호재|악재|중립","intensity":1~5,"primary_stock":"005930",'
|
||||
'"affected_stocks":["000660"],"reason":"핵심 근거 한 문장",'
|
||||
'"investment_action":"매수관심|매도관심|관망",'
|
||||
'"catalyst":"실적|수주|배당|리스크|모멘텀|기타"}\n'
|
||||
"예시: "
|
||||
'{"sentiment":"호재","intensity":4,"primary_stock":"005930",'
|
||||
'"affected_stocks":["000660"],"reason":"HBM 대형 수주 확정으로 연간 반도체 실적 큰 폭 개선 기대",'
|
||||
'"investment_action":"매수관심","catalyst":"수주"}'
|
||||
)
|
||||
# EXAONE 호출 + JSON 파싱 (실패 시 1회 재시도 → ~15% 유실 감소)
|
||||
a = None
|
||||
raw_prev = ""
|
||||
for attempt in range(2):
|
||||
msgs = [{"role":"system","content": system_prompt},
|
||||
{"role":"user","content": user_prompt}]
|
||||
if attempt == 1:
|
||||
msgs += [{"role":"assistant","content": raw_prev[:600]},
|
||||
{"role":"user","content":
|
||||
"위 응답이 유효한 JSON이 아닙니다. 설명·마크다운 없이 "
|
||||
"스키마에 맞는 JSON 객체 하나만 출력하세요."}]
|
||||
# 3.9. 키워드 룰 1차 판정 — 명확한 호재/악재면 LLM 스킵 (비용 0)
|
||||
rule_text = (data.get("title", "") or "") + " " + (data.get("filtered_text", "") or "")
|
||||
rule = keyword_rule(rule_text)
|
||||
if rule["sentiment"] is not None:
|
||||
# 키워드 룰로 sentiment 확정 — LLM 스킵 (비용 0)
|
||||
primary = (data["stocks"][0]["code"] if data["stocks"] else "")
|
||||
affected = [s["code"] for s in data["stocks"][1:5]]
|
||||
investment = ("매수관심" if rule["sentiment"] == "호재" and rule["intensity"] >= 3
|
||||
else "매도관심" if rule["sentiment"] == "악재" and rule["intensity"] >= 3
|
||||
else "관망")
|
||||
hits = rule["pos_hits"] if rule["sentiment"] == "호재" else rule["neg_hits"]
|
||||
stock_impacts = {primary: 1.0} if primary else {}
|
||||
for c in affected:
|
||||
stock_impacts[c] = 0.5
|
||||
a = {
|
||||
"sentiment": rule["sentiment"], "intensity": rule["intensity"],
|
||||
"primary_stock": primary, "affected_stocks": affected,
|
||||
"reason": f"키워드 룰 매칭: {'/'.join(hits)}"[:500],
|
||||
"investment_action": investment,
|
||||
"catalyst": rule["catalyst"],
|
||||
"time_horizon": "단기", "impact_scope": "종목",
|
||||
"confidence": 1.0, "stock_impacts": stock_impacts,
|
||||
"sector_hint": "",
|
||||
}
|
||||
llm_conf = 1.0
|
||||
logger.info("news.path", path="rule", sentiment=rule["sentiment"],
|
||||
pos=rule["pos"], neg=rule["neg"], hits=hits[:3])
|
||||
else:
|
||||
# 4. Ollama EXAONE 분석 (버핏 관점 + 라벨 세분화) — 키워드 룰 애매 케이스
|
||||
stocks_str = ", ".join([f'{s["name"]}({s["code"]})' for s in data["stocks"][:5]])
|
||||
source = item.get("source", "")
|
||||
content_preview = (data.get("filtered_text") or item.get("content") or "")[:400]
|
||||
system_prompt = (
|
||||
"당신은 워렌 버핏 스타일의 한국 주식 가치투자 애널리스트입니다.\n"
|
||||
"뉴스 한 건을 읽고 기업 본질가치 관점에서 영향을 평가합니다.\n"
|
||||
"판단 우선순위: 기업 본질가치(실적·FCF·경쟁우위) > 재무 리스크 > 단기 수급·테마\n\n"
|
||||
"[sentiment] 호재 / 악재 / 중립 — 본질가치 또는 주가에 미치는 방향\n\n"
|
||||
"[intensity] 1~5 정수\n"
|
||||
" 5=상장폐지·대규모 횡령 등 기업 존폐\n"
|
||||
" 4=연간실적 20% 이상 영향(대형 수주·어닝쇼크 등)\n"
|
||||
" 3=분기실적에 유의미한 영향\n"
|
||||
" 2=영업환경·업황 변화(직접 실적 수치 영향은 제한적)\n"
|
||||
" 1=단순 정보성·정기공시·소폭 코멘트\n\n"
|
||||
"[catalyst] 반드시 아래 6개 중 정확히 하나만 출력 (다른 단어·복합어·영문·한자 절대 금지):\n"
|
||||
' "실적" = 영업이익·매출·순이익 증감, 흑자/적자 전환, 어닝 서프라이즈/쇼크, 실적 가이던스\n'
|
||||
' "수주" = 신규 수주·공급계약·납품, M&A, 신사업·증설·신제품 등 성장 동인\n'
|
||||
' "배당" = 배당 확대/축소, 자사주 매입·소각, 주주환원 정책\n'
|
||||
' "리스크" = 횡령·배임·소송·감사의견거절·부채급증·유상증자(희석)·경영진 돌발사임·규제/제재·상폐위험\n'
|
||||
' "모멘텀" = 본질가치 변화 없는 가격 동인(외국인/기관 수급, 목표주가 조정, 테마·정책 기대)\n'
|
||||
' "기타" = 위 어디에도 명확히 속하지 않는 정보성 뉴스\n'
|
||||
" 호재/악재면 catalyst를 '기타'로 두지 말고 실적·수주·배당·리스크·모멘텀 중 가장 가까운 것을 고르세요.\n\n"
|
||||
"[time_horizon] 영향이 발현되는 시간프레임\n"
|
||||
' "즉시" = 당일~3일 내 주가 반응 (수주공시·실적 발표·대형 사건)\n'
|
||||
' "단기" = 1~4주 (분기실적·수급 변화·일시적 모멘텀)\n'
|
||||
' "중기" = 1~6개월 (업황·정책 변화·신제품 출시 효과)\n'
|
||||
' "장기" = 6개월 이상 (구조적 변화·신사업 본격화·체질개선)\n\n'
|
||||
"[impact_scope] 영향 받는 대상 범위\n"
|
||||
' "종목" = 특정 종목(들)에만 영향\n'
|
||||
' "섹터" = 동일 업종/테마 전체 영향 (예: 반도체 업황·2차전지 정책)\n'
|
||||
' "시장" = 코스피/코스닥 전체 또는 매크로 (금리·환율·지정학)\n\n'
|
||||
"[stock_impacts] {종목코드: 영향가중치 0~1} 매핑. primary_stock=1.0, 보조 종목은 0.3~0.8.\n"
|
||||
" affected_stocks와 일치하는 모든 종목에 가중치를 매김. 단일 종목이면 {primary:1.0}.\n\n"
|
||||
"[sector_hint] 관련 업종 한 단어 (예: 반도체, 2차전지, 바이오, 조선, 방산, 자동차, 금융,\n"
|
||||
" 통신, 화학, 철강, 건설, 유통, AI, 로봇, 게임, 미디어, 에너지, 정유, 기타).\n\n"
|
||||
"[confidence] 0.0~1.0 — 본인 판단의 신뢰도. 사실/숫자가 명확하면 0.8+, 추측·소문이면 0.3-0.5,\n"
|
||||
" 맥락 부족·해석 모호하면 0.5 이하. 정직하게 평가하세요.\n\n"
|
||||
"[참고] 블록은 보조 자료일 뿐 — 본 기사 내용으로 판단하고 과거 감성 흐름에 휩쓸리지 마세요.\n"
|
||||
"동일 내용이 [참고]의 유사 과거뉴스에 반복되면 신규성이 낮으니 intensity를 보수적으로.\n\n"
|
||||
"반드시 스키마에 맞는 유효한 JSON 객체 하나만 출력. 마크다운·설명문 금지."
|
||||
)
|
||||
user_prompt = (
|
||||
f"[출처] {source}\n"
|
||||
f"[제목] {data['title'][:200]}\n"
|
||||
f"[내용] {content_preview}\n"
|
||||
f"[관련종목] {stocks_str or '없음'}\n"
|
||||
+ (f"\n[참고]\n{context_block}\n" if context_block else "")
|
||||
+ "\ninvestment_action 규칙: 호재+intensity≥3→매수관심, 악재+intensity≥3→매도관심, 그 외→관망\n"
|
||||
"primary_stock은 6자리 숫자코드(시장 전체 뉴스면 빈 문자열), "
|
||||
"affected_stocks는 영향받는 다른 종목코드 배열.\n\n"
|
||||
"JSON 스키마:\n"
|
||||
'{"sentiment":"호재|악재|중립","intensity":1~5,"primary_stock":"005930",'
|
||||
'"affected_stocks":["000660"],"stock_impacts":{"005930":1.0,"000660":0.5},'
|
||||
'"reason":"핵심 근거 한 문장",'
|
||||
'"investment_action":"매수관심|매도관심|관망",'
|
||||
'"catalyst":"실적|수주|배당|리스크|모멘텀|기타",'
|
||||
'"time_horizon":"즉시|단기|중기|장기","impact_scope":"종목|섹터|시장",'
|
||||
'"sector_hint":"반도체|2차전지|바이오|조선|방산|자동차|금융|통신|화학|철강|건설|유통|AI|로봇|게임|미디어|에너지|정유|기타",'
|
||||
'"confidence":0.85}\n'
|
||||
"예시: "
|
||||
'{"sentiment":"호재","intensity":4,"primary_stock":"005930",'
|
||||
'"affected_stocks":["000660"],"stock_impacts":{"005930":1.0,"000660":0.6},'
|
||||
'"reason":"HBM 대형 수주 확정으로 연간 반도체 실적 큰 폭 개선 기대",'
|
||||
'"investment_action":"매수관심","catalyst":"수주",'
|
||||
'"time_horizon":"중기","impact_scope":"섹터",'
|
||||
'"sector_hint":"반도체","confidence":0.85}'
|
||||
)
|
||||
# EXAONE 호출 + JSON 파싱 (1차 정상, 2차 파싱실패/저신뢰 재시도)
|
||||
a = None
|
||||
raw_prev = ""
|
||||
for attempt in range(2):
|
||||
msgs = [{"role":"system","content": system_prompt},
|
||||
{"role":"user","content": user_prompt}]
|
||||
if attempt == 1:
|
||||
hint = ("위 응답이 유효한 JSON이 아니거나 신뢰도가 낮습니다. "
|
||||
"설명·마크다운 없이 스키마에 맞는 JSON 객체 하나만 출력하고, "
|
||||
"확실하지 않으면 sentiment='중립', intensity=1로 보수적으로 표기하세요.")
|
||||
msgs += [{"role":"assistant","content": raw_prev[:600]},
|
||||
{"role":"user","content": hint}]
|
||||
try:
|
||||
vr = await client.post(f"{OLLAMA_URL}/v1/chat/completions", json={
|
||||
"model":"exaone3.5:7.8b","messages": msgs,
|
||||
"max_tokens":800,"temperature":0.0 if attempt else 0.05},
|
||||
timeout=120)
|
||||
raw_prev = vr.json()["choices"][0]["message"]["content"]
|
||||
cand = _loads_lenient(raw_prev)
|
||||
if cand is None:
|
||||
continue
|
||||
conf = float(cand.get("confidence", 0.5) or 0.5)
|
||||
if attempt == 0 and conf < 0.40 and cand.get("sentiment") in ("호재","악재") \
|
||||
and int(cand.get("intensity", 0) or 0) >= 3:
|
||||
a = cand
|
||||
continue
|
||||
a = cand
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if a is None:
|
||||
a = {"sentiment":"중립","intensity":0,"primary_stock":"","affected_stocks":[],
|
||||
"reason":"파싱실패","investment_action":"관망","confidence":0.0}
|
||||
# enum 강제 / confidence clamp / stock_impacts 정합
|
||||
if a.get("catalyst") not in ("실적","수주","배당","리스크","모멘텀","기타"):
|
||||
a["catalyst"] = "기타"
|
||||
if a.get("time_horizon") not in ("즉시","단기","중기","장기"):
|
||||
a["time_horizon"] = "단기"
|
||||
if a.get("impact_scope") not in ("종목","섹터","시장"):
|
||||
a["impact_scope"] = "종목"
|
||||
try:
|
||||
vr = await client.post(f"{OLLAMA_URL}/v1/chat/completions", json={
|
||||
"model":"exaone3.5:7.8b","messages": msgs,
|
||||
"max_tokens":400,"temperature":0.0 if attempt else 0.05},
|
||||
timeout=120)
|
||||
raw_prev = vr.json()["choices"][0]["message"]["content"]
|
||||
c = raw_prev.replace("```json","").replace("```","").strip()
|
||||
if not c.startswith("{"): # 앞뒤 설명 제거
|
||||
s, e = c.find("{"), c.rfind("}")
|
||||
if s != -1 and e > s: c = c[s:e+1]
|
||||
a = json.loads(c)
|
||||
break
|
||||
llm_conf = max(0.0, min(1.0, float(a.get("confidence", 0.5) or 0.5)))
|
||||
except Exception:
|
||||
continue
|
||||
if a is None:
|
||||
a = {"sentiment":"중립","intensity":0,"primary_stock":"","affected_stocks":[],"reason":"파싱실패","investment_action":"관망"}
|
||||
llm_conf = 0.5
|
||||
if a.get("reason") == "파싱실패":
|
||||
llm_conf = 0.0
|
||||
si = a.get("stock_impacts")
|
||||
if not isinstance(si, dict): si = {}
|
||||
ps = a.get("primary_stock") or ""
|
||||
if ps and ps not in si: si[ps] = 1.0
|
||||
for c in (a.get("affected_stocks") or []):
|
||||
if c and c not in si: si[c] = 0.5
|
||||
si = {k: max(0.0, min(1.0, float(v or 0)))
|
||||
for k, v in si.items() if k and isinstance(k, str) and len(k) <= 10}
|
||||
a["stock_impacts"] = si
|
||||
|
||||
# catalyst 6개 enum 강제 (score-engine CATALYST_WEIGHTS 정합 — 일탈값은 '기타')
|
||||
if a.get("catalyst") not in ("실적","수주","배당","리스크","모멘텀","기타"):
|
||||
a["catalyst"] = "기타"
|
||||
# === 환각 / 키워드 충돌 검증 → 필요 시 Gemini fallback ===
|
||||
# 임계값을 보수적으로 (Gemini 과호출 방지 — Pro thinking 비용 큼)
|
||||
halluc = hallucination_match_ratio(a.get("reason", ""), rule_text)
|
||||
conflict = ((rule["pos"] >= 2 and a.get("sentiment") == "악재") or
|
||||
(rule["neg"] >= 2 and a.get("sentiment") == "호재"))
|
||||
sent_strong = (a.get("sentiment") in ("호재", "악재")
|
||||
and int(a.get("intensity") or 0) >= 4) # 4→매우 강한 시그널만
|
||||
parse_failed = (a.get("reason") == "파싱실패")
|
||||
need_gem = (parse_failed # EXAONE 파싱실패 → Gemini 재판정(중립 방치 대신)
|
||||
or (sent_strong and llm_conf < 0.4) # 0.6→0.4 (정말 저신뢰만)
|
||||
or halluc < 0.25 # 0.5→0.25 (명백한 환각만)
|
||||
or conflict)
|
||||
if need_gem and GEMINI_API_KEY:
|
||||
gem, gem_cost = await _gemini_news_classify(
|
||||
data.get("title", ""), content_preview,
|
||||
{"sentiment": a.get("sentiment"),
|
||||
"intensity": a.get("intensity"),
|
||||
"confidence": llm_conf}, rule)
|
||||
if gem:
|
||||
a["sentiment"] = gem.get("sentiment", a["sentiment"])
|
||||
try:
|
||||
a["intensity"] = int(gem.get("intensity", a["intensity"]) or a["intensity"])
|
||||
except Exception:
|
||||
pass
|
||||
if gem.get("catalyst") in ("실적","수주","배당","리스크","모멘텀","기타"):
|
||||
a["catalyst"] = gem["catalyst"]
|
||||
a["reason"] = f"[Gemini검증] {gem.get('reason','')}"[:500]
|
||||
try:
|
||||
llm_conf = float(gem.get("confidence", llm_conf) or llm_conf)
|
||||
except Exception:
|
||||
pass
|
||||
a["investment_action"] = (
|
||||
"매수관심" if a["sentiment"] == "호재" and (a.get("intensity") or 0) >= 3
|
||||
else "매도관심" if a["sentiment"] == "악재" and (a.get("intensity") or 0) >= 3
|
||||
else "관망"
|
||||
)
|
||||
logger.info("news.path", path="gemini", final=a["sentiment"],
|
||||
halluc=round(halluc, 2), conflict=conflict,
|
||||
cost_krw=gem_cost)
|
||||
else:
|
||||
logger.info("news.path", path="exaone_lowconf",
|
||||
halluc=round(halluc, 2), conflict=conflict,
|
||||
conf=round(llm_conf, 2))
|
||||
else:
|
||||
logger.info("news.path", path="exaone",
|
||||
sentiment=a.get("sentiment"),
|
||||
conf=round(llm_conf, 2), halluc=round(halluc, 2))
|
||||
|
||||
# 5. Qdrant 저장
|
||||
# 파싱실패 복구분: EXAONE/Gemini는 종목을 안 주므로(스키마 없음), 바른 감지 종목으로
|
||||
# primary_stock·stock_impacts 채움 — 안 그러면 score-engine 뉴스점수에 안 잡힘.
|
||||
if parse_failed and not a.get("primary_stock") and not a.get("stock_impacts"):
|
||||
_codes = [st["code"] for st in data.get("stocks", []) if st.get("code")]
|
||||
if _codes:
|
||||
_w = 1.0 if len(_codes) == 1 else 0.6
|
||||
a["primary_stock"] = _codes[0]
|
||||
a["stock_impacts"] = {c: _w for c in _codes[:5]}
|
||||
|
||||
# 출처 신뢰도 + 제목 강도 (분석 후 DB INSERT 직전에 계산)
|
||||
src_cred = await get_source_credibility(item.get("source", ""))
|
||||
title_str = measure_title_strength(data["title"])
|
||||
|
||||
# 5. Qdrant 저장 (event_cluster_id 포함 — 다음 뉴스의 클러스터 매칭에 사용)
|
||||
try:
|
||||
await client.put(f"{QDRANT_URL}/collections/news_vectors/points", json={
|
||||
"points":[{"id":random.randint(1,999999999),"vector":emb,
|
||||
"payload":{"title":data["title"],"hash":data["hash"],
|
||||
"sentiment":a.get("sentiment",""),"intensity":a.get("intensity",0),
|
||||
"primary_stock":a.get("primary_stock","")}}]}, timeout=15)
|
||||
"primary_stock":a.get("primary_stock",""),
|
||||
"event_cluster_id": event_cluster_id,
|
||||
"catalyst": a.get("catalyst", "기타"),
|
||||
"time_horizon": a.get("time_horizon","단기")}}]}, timeout=15)
|
||||
except: pass
|
||||
|
||||
# 6. PostgreSQL 저장 (파라미터 바인딩 — 인젝션·이스케이프 유실 방지)
|
||||
# 6. PostgreSQL 저장
|
||||
pub = data.get("published_at") or ""
|
||||
try:
|
||||
pub_dt = datetime.fromisoformat(pub.replace("Z", "+00:00")) if pub else datetime.now()
|
||||
@@ -474,8 +806,11 @@ async def pipeline(item, client):
|
||||
await pg_pool.execute("""
|
||||
INSERT INTO news_analysis (title,url,source,published_at,hash,sentiment,intensity,
|
||||
primary_stock,affected_stocks,reason,investment_action,keywords,stock_names,stock_codes,
|
||||
catalyst,similar_count,analyzed_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10,$11,$12::jsonb,$13::jsonb,$14::jsonb,$15,0,NOW())
|
||||
catalyst,similar_count,analyzed_at,
|
||||
time_horizon,impact_scope,llm_confidence,source_credibility,title_strength,
|
||||
stock_impacts,event_cluster_id,is_event_seed,sector_hint)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10,$11,$12::jsonb,$13::jsonb,$14::jsonb,$15,0,NOW(),
|
||||
$16,$17,$18,$19,$20,$21::jsonb,$22,$23,$24)
|
||||
ON CONFLICT (hash) DO NOTHING
|
||||
""",
|
||||
s(data.get("title"))[:500], s(data.get("url"))[:500], s(data.get("source"))[:100],
|
||||
@@ -485,7 +820,26 @@ async def pipeline(item, client):
|
||||
json.dumps(data.get("keywords", [])[:20], ensure_ascii=False),
|
||||
json.dumps([s_["name"] for s_ in data.get("stocks", [])], ensure_ascii=False),
|
||||
json.dumps([s_["code"] for s_ in data.get("stocks", [])], ensure_ascii=False),
|
||||
s(a.get("catalyst", "기타"))[:20])
|
||||
s(a.get("catalyst", "기타"))[:20],
|
||||
s(a.get("time_horizon","단기"))[:10],
|
||||
s(a.get("impact_scope","종목"))[:10],
|
||||
llm_conf, src_cred, title_str,
|
||||
json.dumps(a.get("stock_impacts", {}), ensure_ascii=False),
|
||||
event_cluster_id[:32], is_event_seed,
|
||||
s(a.get("sector_hint",""))[:40])
|
||||
|
||||
# 이벤트 클러스터 시드 등록 (첫 뉴스만)
|
||||
if is_event_seed and event_cluster_id:
|
||||
try:
|
||||
await pg_pool.execute("""
|
||||
INSERT INTO news_event_cluster (cluster_id, member_count,
|
||||
seed_sentiment, seed_intensity, seed_catalyst)
|
||||
VALUES ($1, 1, $2, $3, $4)
|
||||
ON CONFLICT (cluster_id) DO NOTHING
|
||||
""", event_cluster_id[:32], s(a.get("sentiment","중립"))[:10], iv,
|
||||
s(a.get("catalyst","기타"))[:20])
|
||||
except Exception: pass
|
||||
|
||||
return "ok"
|
||||
except Exception as e:
|
||||
logger.warning("pipeline.err", title=item.get("title","")[:50], error=str(e))
|
||||
@@ -511,6 +865,8 @@ async def job_rss():
|
||||
stats.processed += 1
|
||||
elif r == "noise":
|
||||
stats.noise += 1
|
||||
elif r == "stale":
|
||||
stats.stale += 1
|
||||
elif r == "off_topic":
|
||||
stats.off_topic += 1
|
||||
stats.last_run = datetime.now().isoformat()
|
||||
@@ -655,7 +1011,7 @@ async def job_historical_raw(count: int = 0, max_pages: int = 100):
|
||||
stats.running = False
|
||||
|
||||
|
||||
async def job_process_raw(batch_size: int = 200):
|
||||
async def job_process_raw(batch_size: int = 200, reprocess: bool = False):
|
||||
"""news_raw의 미처리 행을 batch_size만큼 EXAONE 분석 → news_analysis로 이동.
|
||||
OLLAMA_NUM_PARALLEL=4와 매칭되는 4-동시 병렬 처리."""
|
||||
async with pg_pool.acquire() as conn:
|
||||
@@ -680,11 +1036,11 @@ async def job_process_raw(batch_size: int = 200):
|
||||
"stock_code": r["stock_code"],
|
||||
}
|
||||
try:
|
||||
if await is_dup(item["title"], item["url"]):
|
||||
if not reprocess and await is_dup(item["title"], item["url"]):
|
||||
stats.duplicates += 1
|
||||
else:
|
||||
stats.collected += 1
|
||||
res = await pipeline(item, c)
|
||||
res = await pipeline(item, c, reprocess=reprocess)
|
||||
if res == "ok": ok += 1; stats.processed += 1
|
||||
elif res == "noise": stats.noise += 1
|
||||
elif res == "off_topic": stats.off_topic += 1
|
||||
@@ -741,6 +1097,7 @@ async def job_historical(count: int = 500, max_pages: int = 20):
|
||||
if r == "ok":
|
||||
ok += 1; stats.processed += 1
|
||||
elif r == "noise": stats.noise += 1
|
||||
elif r == "stale": stats.stale += 1
|
||||
elif r == "off_topic": stats.off_topic += 1
|
||||
if (i + 1) % 50 == 0:
|
||||
logger.info("historical.progress", done=i+1, of=len(codes),
|
||||
@@ -774,6 +1131,8 @@ async def job_market():
|
||||
stats.processed += 1
|
||||
elif r == "noise":
|
||||
stats.noise += 1
|
||||
elif r == "stale":
|
||||
stats.stale += 1
|
||||
elif r == "off_topic":
|
||||
stats.off_topic += 1
|
||||
stats.last_run = datetime.now().isoformat()
|
||||
@@ -817,8 +1176,8 @@ async def startup():
|
||||
pg_pool = await asyncpg.create_pool(host=PG_HOST,port=PG_PORT,database=PG_DB,user=PG_USER,password=PG_PASS,min_size=2,max_size=5)
|
||||
redis_cl = aioredis.Redis(host=REDIS_HOST,port=6379,password=REDIS_PASSWORD,db=4,decode_responses=True)
|
||||
await init_news_tables()
|
||||
# 평일 RSS: 8-18시 5분마다
|
||||
scheduler.add_job(job_rss,"cron",day_of_week="mon-fri",hour="8-18",minute="*/5",id="rss_weekday",replace_existing=True)
|
||||
# 평일 RSS: 8-18시 2분마다 (자동매매 latency 단축)
|
||||
scheduler.add_job(job_rss,"cron",day_of_week="mon-fri",hour="8-18",minute="*/2",id="rss_weekday",replace_existing=True)
|
||||
# 주말 RSS: 8-22시 15분마다 (누적 학습용)
|
||||
scheduler.add_job(job_rss,"cron",day_of_week="sat,sun",hour="8-22",minute="*/15",id="rss_weekend",replace_existing=True)
|
||||
# 평일 마켓 (네이버 금융): 9-17시 10분마다
|
||||
@@ -831,6 +1190,11 @@ async def startup():
|
||||
scheduler.add_job(job_historical_raw,"cron",day_of_week="sun",hour="2",minute="0",
|
||||
id="historical_raw_weekly",replace_existing=True,
|
||||
kwargs={"count":0,"max_pages":50})
|
||||
# 평일 18:30 전 종목 "최신 1페이지" 스위프 — RSS/마켓메인에 안 잡히는 중형주
|
||||
# 신선뉴스가 주1회 백필까지 누락되던 사각지대 해소 (전 종목×1페이지 ≈ 25분)
|
||||
scheduler.add_job(job_historical_raw,"cron",day_of_week="mon-fri",hour="18",minute="30",
|
||||
id="fresh_stock_sweep",replace_existing=True,
|
||||
kwargs={"count":0,"max_pages":1})
|
||||
scheduler.start()
|
||||
logger.info("news-collector.started", sources=len(RSS_SOURCES))
|
||||
|
||||
@@ -866,9 +1230,10 @@ async def m_historical_raw(count: int = 0, max_pages: int = 100):
|
||||
return {"status":"started","type":"historical_raw","count":count or "전체","max_pages":max_pages}
|
||||
|
||||
@app.post("/process/raw")
|
||||
async def m_process_raw(batch_size: int = 200):
|
||||
"""news_raw 미처리분 batch 분석 → news_analysis로 이동"""
|
||||
n = await job_process_raw(batch_size)
|
||||
async def m_process_raw(batch_size: int = 200, reprocess: bool = False):
|
||||
"""news_raw 미처리분 batch 분석 → news_analysis로 이동.
|
||||
reprocess=true: is_dup·Qdrant sim_dup 우회 (파싱실패 복구 재분석용)."""
|
||||
n = await job_process_raw(batch_size, reprocess=reprocess)
|
||||
return {"status":"done","processed":n}
|
||||
|
||||
@app.get("/raw/stats")
|
||||
|
||||
@@ -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)
|
||||
+2609
-179
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
+312
-12
@@ -23,7 +23,7 @@ import httpx
|
||||
import structlog
|
||||
from telegram import Update, BotCommand
|
||||
from telegram.constants import ParseMode
|
||||
from telegram.ext import (Application, CommandHandler, MessageHandler,
|
||||
from telegram.ext import (Application, CommandHandler, MessageHandler, CallbackQueryHandler,
|
||||
filters, ContextTypes)
|
||||
|
||||
logging.basicConfig(level=logging.INFO,
|
||||
@@ -254,16 +254,25 @@ async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
if not is_allowed(update): return
|
||||
msg = (
|
||||
"👋 <b>Trading AI 봇</b>\n\n"
|
||||
"명령어:\n"
|
||||
"/buy — 오늘 매수 추천\n"
|
||||
"/sell — 오늘 매도 신호\n"
|
||||
"/stock 005930 — 특정 종목 상세\n"
|
||||
"/deep 005930 — AI 심층분석(RAG+EXAONE)\n"
|
||||
"/market — 시장 상황\n"
|
||||
"/help — 도움말\n\n"
|
||||
"자유 텍스트로 질문해도 됩니다. 예:\n"
|
||||
"<b>📊 추천 조회</b>\n"
|
||||
"• /buy 또는 '추천' — 오늘 매수 추천(가치투자)\n"
|
||||
"• /hot 또는 '뜨는' — 지금 뜨는 종목(모멘텀)\n"
|
||||
"• /sell 또는 '매도종목' — 오늘 매도 신호\n"
|
||||
"• /stock 005930 — 종목 상세\n"
|
||||
"• /deep 005930 — AI 심층분석\n"
|
||||
"• /market 또는 '시장' — 시장 상황\n\n"
|
||||
"<b>🤖 자동매매</b>\n"
|
||||
"• /status 또는 '상태' — 시스템 상태\n"
|
||||
"• /portfolio 또는 '보유' — 보유 종목 손익\n"
|
||||
"• /orders 또는 '주문' — 매매 주문 목록\n"
|
||||
"• /settings 또는 '설정' — 안전 설정\n"
|
||||
"• /autotrade on|off 또는 '자동매매꺼' — 자동매매 토글\n\n"
|
||||
"<b>💱 주문 처리</b> (텔레그램 알림 도착 시)\n"
|
||||
"• 버튼: [✅ 매수/매도 실행] [❌ 취소]\n"
|
||||
"• 텍스트: '매수 2', '매도 2', '취소 2', '승인 2'\n"
|
||||
"• 명령: /confirm 2, /cancel 2\n\n"
|
||||
"<b>💬 자유 질문</b>\n"
|
||||
"<i>\"에스엠 사도 돼?\"</i>\n"
|
||||
"<i>\"오늘 추천 알려줘\"</i>\n"
|
||||
"<i>\"삼성전자 어때?\"</i>"
|
||||
)
|
||||
await update.message.reply_text(msg, parse_mode=ParseMode.HTML)
|
||||
@@ -285,6 +294,40 @@ async def cmd_sells(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
await update.message.reply_text(fmt_sells(rows), parse_mode=ParseMode.HTML)
|
||||
|
||||
|
||||
def fmt_hot(rows: list) -> str:
|
||||
if not rows:
|
||||
return "📭 지금 뜨는 종목 없음"
|
||||
lines = ["🔥 <b>지금 뜨는 종목</b> <i>(뉴스·가격·거래량 모멘텀)</i>"]
|
||||
for i, r in enumerate(rows, 1):
|
||||
lines.append(f"🔥 <b>{i}. {r['stock_name']}({r['stock_code']})</b> {r['hot_score']:.0f}점")
|
||||
parts = []
|
||||
if r.get("pos_news_3d"):
|
||||
parts.append(f"호재 {r['pos_news_3d']}건")
|
||||
if r.get("price_5d_pct"):
|
||||
parts.append(f"5일 {r['price_5d_pct']:+.1f}%")
|
||||
if r.get("vol_ratio") and r["vol_ratio"] >= 1.5:
|
||||
parts.append(f"거래량 {r['vol_ratio']:.1f}배")
|
||||
if parts:
|
||||
lines.append(f" ▸ {' · '.join(parts)}")
|
||||
vs, vr = r.get("value_score"), r.get("value_reco")
|
||||
if vr:
|
||||
lines.append(f" ▸ 가치판단: {vr} ({vs}점)")
|
||||
lines.append("\n<i>※ 모멘텀 기반이라 가치투자 추천(/buy)과 별개입니다. 변동성·추격매수 주의</i>")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def cmd_hot(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
if not is_allowed(update): return
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
r = await c.get(f"{SCORE_API}/hot?limit=12")
|
||||
rows = r.json()
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"오류: {e}")
|
||||
return
|
||||
await update.message.reply_text(fmt_hot(rows), parse_mode=ParseMode.HTML)
|
||||
|
||||
|
||||
async def cmd_stock(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
if not is_allowed(update): return
|
||||
args = ctx.args
|
||||
@@ -352,6 +395,208 @@ async def cmd_deep(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
await update.message.reply_text(fmt_deep(r), parse_mode=ParseMode.HTML)
|
||||
|
||||
|
||||
SCORE_ENGINE_URL = os.getenv("SCORE_ENGINE_URL", "http://score-engine:8686")
|
||||
|
||||
|
||||
async def cmd_confirm(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
"""/confirm <order_id> — 자동매매 매수 신호 승인"""
|
||||
if not is_allowed(update): return
|
||||
if not ctx.args:
|
||||
await update.message.reply_text("사용법: /confirm <주문번호>")
|
||||
return
|
||||
try:
|
||||
oid = int(ctx.args[0])
|
||||
except ValueError:
|
||||
await update.message.reply_text("주문번호는 숫자")
|
||||
return
|
||||
import httpx
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
r = await c.post(f"{SCORE_ENGINE_URL}/trade/confirm/{oid}")
|
||||
res = r.json()
|
||||
if "error" in res:
|
||||
await update.message.reply_text(f"❌ {res['error']}")
|
||||
else:
|
||||
await update.message.reply_text(f"✅ 주문 #{oid} 체결 완료")
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"오류: {e}")
|
||||
|
||||
|
||||
async def cmd_cancel(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
"""/cancel <order_id> — 매수 신호 거부"""
|
||||
if not is_allowed(update): return
|
||||
if not ctx.args:
|
||||
await update.message.reply_text("사용법: /cancel <주문번호>")
|
||||
return
|
||||
try:
|
||||
oid = int(ctx.args[0])
|
||||
except ValueError:
|
||||
await update.message.reply_text("주문번호는 숫자")
|
||||
return
|
||||
import httpx
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
r = await c.post(f"{SCORE_ENGINE_URL}/trade/cancel/{oid}")
|
||||
await update.message.reply_text(f"🚫 주문 #{oid} 취소")
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"오류: {e}")
|
||||
|
||||
|
||||
async def on_callback(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
"""inline 버튼 콜백 처리: confirm:<id> / cancel:<id>"""
|
||||
q = update.callback_query
|
||||
if not q or not q.data:
|
||||
return
|
||||
await q.answer()
|
||||
try:
|
||||
action, oid_str = q.data.split(":", 1)
|
||||
oid = int(oid_str)
|
||||
except Exception:
|
||||
await q.edit_message_text(f"{q.message.text}\n\n❌ 잘못된 콜백")
|
||||
return
|
||||
import httpx
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
r = await c.post(f"{SCORE_ENGINE_URL}/trade/{action}/{oid}")
|
||||
res = r.json()
|
||||
if "error" in res:
|
||||
new_text = f"{q.message.text_html}\n\n❌ {res['error']}"
|
||||
elif action == "confirm":
|
||||
new_text = f"{q.message.text_html}\n\n✅ 승인됨 (#{oid} 체결)"
|
||||
else:
|
||||
new_text = f"{q.message.text_html}\n\n🚫 거부됨 (#{oid} 취소)"
|
||||
# 버튼 제거 + 결과 표시
|
||||
await q.edit_message_text(new_text, parse_mode=ParseMode.HTML)
|
||||
except Exception as e:
|
||||
await q.edit_message_text(f"{q.message.text}\n\n⚠️ 오류: {e}")
|
||||
|
||||
|
||||
async def cmd_portfolio(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
"""/portfolio — 보유 종목 + 손익"""
|
||||
if not is_allowed(update): return
|
||||
import httpx
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
r = await c.get(f"{SCORE_ENGINE_URL}/portfolio")
|
||||
rows = r.json()
|
||||
if not rows:
|
||||
await update.message.reply_text("보유 종목 없음")
|
||||
return
|
||||
lines = ["👜 <b>보유 종목</b>\n"]
|
||||
total_buy = 0
|
||||
total_cur = 0
|
||||
for p in rows:
|
||||
cur = int(p.get("current_price") or 0)
|
||||
buy = p["buy_price"]
|
||||
qty = p["qty"]
|
||||
pnl_pct = p.get("pnl_pct") or 0
|
||||
pnl_won = p.get("pnl_won") or 0
|
||||
total_buy += buy * qty
|
||||
total_cur += cur * qty
|
||||
sign = "🟢" if pnl_pct > 0 else "🔴" if pnl_pct < -3 else "🟡"
|
||||
lines.append(
|
||||
f"{sign} <b>{p.get('stock_name') or p['stock_code']}</b>({p['stock_code']})\n"
|
||||
f" {qty}주 @{buy:,}원 → {cur:,}원 <b>{pnl_pct:+.2f}%</b> ({pnl_won:+,}원)"
|
||||
)
|
||||
if total_buy:
|
||||
tot_pct = (total_cur - total_buy) / total_buy * 100
|
||||
lines.append(f"\n<b>합계 {tot_pct:+.2f}% ({total_cur - total_buy:+,}원)</b>")
|
||||
await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.HTML)
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"오류: {e}")
|
||||
|
||||
|
||||
async def cmd_autotrade(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
"""/autotrade on|off — 자동매매 토글"""
|
||||
if not is_allowed(update): return
|
||||
if not ctx.args or ctx.args[0].lower() not in ("on","off"):
|
||||
await update.message.reply_text("사용법: /autotrade on 또는 /autotrade off")
|
||||
return
|
||||
enabled = ctx.args[0].lower() == "on"
|
||||
import httpx
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
await c.post(f"{SCORE_ENGINE_URL}/trade/autotrade?enabled={'true' if enabled else 'false'}")
|
||||
await update.message.reply_text(
|
||||
f"⚙️ 자동매매 {'ON ✅' if enabled else 'OFF 🛑'}",
|
||||
parse_mode=ParseMode.HTML)
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"오류: {e}")
|
||||
|
||||
|
||||
async def cmd_settings(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
"""/settings — 자동매매 안전설정 조회"""
|
||||
if not is_allowed(update): return
|
||||
import httpx
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.get(f"{SCORE_ENGINE_URL}/trade/settings")
|
||||
s = r.json().get("current", {})
|
||||
lines = ["⚙️ <b>자동매매 설정</b>\n"]
|
||||
for k, v in s.items():
|
||||
lines.append(f" • {k}: <code>{v}</code>")
|
||||
await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.HTML)
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"오류: {e}")
|
||||
|
||||
|
||||
async def cmd_status(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
"""/status — 시스템 상태 한눈에"""
|
||||
if not is_allowed(update): return
|
||||
import httpx
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as c:
|
||||
r = await c.get(f"{SCORE_ENGINE_URL}/system/status")
|
||||
s = r.json()
|
||||
t = s.get("trading", {})
|
||||
a = s.get("analysis", {})
|
||||
sc = s.get("scoring", {})
|
||||
p = s.get("portfolio", {})
|
||||
o = t.get("orders_today", {})
|
||||
auto_icon = "✅" if t.get("enabled") else "🛑"
|
||||
halt_icon = "🛑 HALTED" if t.get("halted") else ""
|
||||
lines = [
|
||||
f"📊 <b>시스템 상태</b>",
|
||||
f"\n<b>자동매매</b> {auto_icon} {halt_icon}",
|
||||
f" 오늘 주문: pending {o.get('pending',0)} / filled {o.get('filled',0)} / cancelled {o.get('cancelled',0)} / expired {o.get('expired',0)}",
|
||||
f" 실현손익: {t.get('realized_pnl_today', 0):+,}원 / 평가손익: {t.get('unrealized_pnl_today', 0):+,}원",
|
||||
f"\n<b>점수 ({sc.get('latest_score_date')})</b>",
|
||||
f" 분석 종목: {sc.get('scored_today',0)}건 / 강력매수: {sc.get('strong_buy_count',0)}건",
|
||||
f"\n<b>LLM 분석</b>",
|
||||
f" 오늘: {a.get('today_analyses',0)}건 ({a.get('today_llm_cost_krw',0)}원)",
|
||||
f" 이번 달: {a.get('month_llm_cost_krw',0)}원",
|
||||
f"\n<b>보유</b>",
|
||||
f" 활성 포지션: {p.get('active_positions',0)}건",
|
||||
]
|
||||
await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.HTML)
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"오류: {e}")
|
||||
|
||||
|
||||
async def cmd_orders(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
"""/orders — 최근 매매 주문 목록"""
|
||||
if not is_allowed(update): return
|
||||
import httpx
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as c:
|
||||
r = await c.get(f"{SCORE_ENGINE_URL}/trade/orders?days=7")
|
||||
rows = r.json()
|
||||
if not rows:
|
||||
await update.message.reply_text("최근 주문 없음")
|
||||
return
|
||||
lines = ["📋 <b>최근 매매 주문 (7일)</b>\n"]
|
||||
for r in rows[:15]:
|
||||
icon = {"pending":"⏳","confirmed":"✅","filled":"✅",
|
||||
"cancelled":"🚫","expired":"⌛"}.get(r["status"], "·")
|
||||
lines.append(
|
||||
f"{icon} #{r['id']} {r['stock_name']}({r['stock_code']}) "
|
||||
f"{r['side']} {r['qty']}주 @{r['price']:,}원 [{r['status']}]"
|
||||
)
|
||||
await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.HTML)
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"오류: {e}")
|
||||
|
||||
|
||||
async def cmd_market(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
if not is_allowed(update): return
|
||||
m = await db_market_regime()
|
||||
@@ -378,17 +623,63 @@ async def on_text(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||
return
|
||||
|
||||
# 한글 단축 키워드 → 명령 라우팅 (빠른 응답)
|
||||
if text in ("추천", "매수", "오늘추천", "buy"):
|
||||
if text in ("추천", "오늘추천", "buy"):
|
||||
rows = await db_top_buys(10)
|
||||
await update.message.reply_text(fmt_buys(rows), parse_mode=ParseMode.HTML)
|
||||
return
|
||||
if text in ("매도", "팔것", "sell"):
|
||||
if text in ("팔것", "매도종목", "sell"):
|
||||
rows = await db_top_sells(5)
|
||||
await update.message.reply_text(fmt_sells(rows), parse_mode=ParseMode.HTML)
|
||||
return
|
||||
if text in ("시장", "장", "market"):
|
||||
await cmd_market(update, ctx)
|
||||
return
|
||||
if text in ("뜨는", "핫", "급등", "뜨는종목", "핫종목", "hot"):
|
||||
await cmd_hot(update, ctx)
|
||||
return
|
||||
|
||||
# 자동매매 한글 명령 라우팅
|
||||
if text in ("상태", "status"):
|
||||
await cmd_status(update, ctx); return
|
||||
if text in ("보유", "포트폴리오", "내주식", "손익"):
|
||||
await cmd_portfolio(update, ctx); return
|
||||
if text in ("주문", "주문목록", "오더"):
|
||||
await cmd_orders(update, ctx); return
|
||||
if text in ("설정", "셋팅"):
|
||||
await cmd_settings(update, ctx); return
|
||||
if text in ("자동매매켜", "자동매매on", "자동매매시작"):
|
||||
ctx.args = ["on"]
|
||||
await cmd_autotrade(update, ctx); return
|
||||
if text in ("자동매매꺼", "자동매매off", "자동매매중단", "자동매매정지"):
|
||||
ctx.args = ["off"]
|
||||
await cmd_autotrade(update, ctx); return
|
||||
|
||||
# 주문 confirm/cancel 자연어 — "매수 2", "매도 2", "승인 2", "취소 2"
|
||||
import re as _re, httpx as _httpx
|
||||
m = _re.match(r"^(매수|매도|승인|확인|실행)\s+(\d+)$", text)
|
||||
if m:
|
||||
oid = int(m.group(2))
|
||||
try:
|
||||
async with _httpx.AsyncClient(timeout=30) as c:
|
||||
r = await c.post(f"{SCORE_ENGINE_URL}/trade/confirm/{oid}")
|
||||
res = r.json()
|
||||
if "error" in res:
|
||||
await update.message.reply_text(f"❌ {res['error']}")
|
||||
else:
|
||||
await update.message.reply_text(f"✅ 주문 #{oid} 체결 완료")
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"오류: {e}")
|
||||
return
|
||||
m = _re.match(r"^(취소|거부|중단)\s+(\d+)$", text)
|
||||
if m:
|
||||
oid = int(m.group(2))
|
||||
try:
|
||||
async with _httpx.AsyncClient(timeout=30) as c:
|
||||
await c.post(f"{SCORE_ENGINE_URL}/trade/cancel/{oid}")
|
||||
await update.message.reply_text(f"🚫 주문 #{oid} 취소")
|
||||
except Exception as e:
|
||||
await update.message.reply_text(f"오류: {e}")
|
||||
return
|
||||
|
||||
await update.message.chat.send_action("typing")
|
||||
|
||||
@@ -488,8 +779,17 @@ def main():
|
||||
app.add_handler(CommandHandler(["buy", "buys"], cmd_buys))
|
||||
app.add_handler(CommandHandler(["sell", "sells"], cmd_sells))
|
||||
app.add_handler(CommandHandler(["stock"], cmd_stock))
|
||||
app.add_handler(CommandHandler(["hot"], cmd_hot))
|
||||
app.add_handler(CommandHandler(["deep"], cmd_deep))
|
||||
app.add_handler(CommandHandler(["market"], cmd_market))
|
||||
app.add_handler(CommandHandler(["confirm"], cmd_confirm))
|
||||
app.add_handler(CommandHandler(["cancel"], cmd_cancel))
|
||||
app.add_handler(CommandHandler(["orders"], cmd_orders))
|
||||
app.add_handler(CommandHandler(["portfolio", "pnl"], cmd_portfolio))
|
||||
app.add_handler(CommandHandler(["autotrade"], cmd_autotrade))
|
||||
app.add_handler(CommandHandler(["settings"], cmd_settings))
|
||||
app.add_handler(CommandHandler(["status"], cmd_status))
|
||||
app.add_handler(CallbackQueryHandler(on_callback))
|
||||
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, on_text))
|
||||
logger.info("telegram-bot.start", model=EXAONE_MODEL)
|
||||
app.run_polling(close_loop=False)
|
||||
|
||||
@@ -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