Compare commits

...

2 Commits

Author SHA1 Message Date
kyu 97cf5aecb0 chore: 누적 미커밋 작업분 일괄 커밋
trading/pipeline/head This commit looks good
이번 세션 외 그간 쌓인 변경 일괄 저장:
- bareunaapi: finance_dict 금융용어 / stock_loader 종목 로더 보강
- kis-api: 키움 토큰·수집 로직
- us-market / dart-collector: 수집 보강
- docker-compose: GEMINI_API_KEY 등 환경변수 추가
- score-engine/news-collector requirements, CLAUDE.md
- 신규: PROJECT.md, news-collector/sentiment_rules.py

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:24:16 +09:00
kyu 8b200e6cc6 feat: 핫종목 렌즈 + 뉴스 파서 견고화 + 텔레그램 알림 2회 축소
- news-collector: EXAONE JSON 파서 견고화(max_tokens 800 + 관대 파싱 → 파싱실패율 9.3%→3.3%),
  평일 18:30 종목뉴스 스위프(중형주 신선도 사각지대 해소), /process/raw reprocess 플래그,
  파싱실패→Gemini 폴백 라우팅(일일캡 1→50 상수화)
- score-engine: /hot 모멘텀 렌즈(뉴스+거래량, 백테스트로 가격 제외) + /hot/backtest,
  텔레그램 정기 브리핑 2회로 축소(중복 send_briefing 자동스케줄 제거)
- dashboard-api: /api/hot 프록시 + 🔥 지금뜨는 탭
- telegram-bot: /hot 명령 + 한글(뜨는/핫/급등) 라우팅

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