fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리
## CRITICAL 수정 (20건) - day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가 - trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산 - risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수 - risk_management/recommendations: date.today() → KST 변환 - mdd_manager: Lock 밖 executed_key 참조 수정 - correlation_filter: output → output2 키 수정 - portfolio: 해외 종목 USD/KRW 환율 적용 분리 - Sharpe Ratio 거래빈도 기반 연간화 - telegram_notifier: send_message() 공개 래퍼 추가 - position_manager: 앱 재시작 시 DB 포지션 복원 - swing exit_service: remaining_pct 조건 범위 수정 - run_backtest: original_qty 기반 remaining_pct 계산 - us_market_schedule: 2027년 미국 공휴일 추가 - Login.tsx: res.ok 검증 추가 - mean_reversion_strategy: RSI 로그 하드코딩 수정 ## HIGH 수정 (10건) - db_worker: DLQ 파일 백업 메커니즘 추가 - korea_investment_api: 만료 토큰 임의 연장 제거 - market_data: chart_cache 최대 100개 + TTL 만료 - main.py: rate_limit_store 1시간 미접근 IP 정리 - recommendations: fire-and-forget try/except 추가 - swing/scheduler: DB 세션 3개 분리 - Dashboard: wsMessages 전체 순회 - Layout: /health 엔드포인트 + 30초 폴링 - ml_ensemble: POST /train require_api_key 추가 - day_trading: record_trade_result_safe() 전환 ## 코드 정리 - 프론트엔드: isUsStock, formatTime 공통 유틸 추출 - 백엔드: dead code 제거, 중복 import 통합 - MCP: JSON 파싱 헬퍼 추출 (~60줄 감소) - 전체 ~100개 파일 한국어 주석 추가 - 테스트 13건 코드 기준 수정 (468 passed) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
603
backend/app/services/news_sentiment.py
Normal file
603
backend/app/services/news_sentiment.py
Normal file
@@ -0,0 +1,603 @@
|
||||
"""뉴스 수집 + 감성 분석 엔진 (news_sentiment.py)
|
||||
|
||||
아키텍처:
|
||||
- 수집 소스 3개: 네이버 금융(종목별), 네이버 시장 뉴스, NewsAPI.org(선택)
|
||||
- 감성 분석 2단계: (1) 키워드 사전 기반 빠른 분석 → (2) AI 배치 분석 (선택)
|
||||
- Redis 캐시: TTL 30분 (settings.NEWS_CACHE_TTL)
|
||||
- DB 저장: db_worker 큐 경유 (CLAUDE.md 3.2 준수)
|
||||
|
||||
공개 API:
|
||||
- get_stock_news_score(stock_code) → float (-1.0 ~ +1.0)
|
||||
- get_market_news_score() → float (-1.0 ~ +1.0)
|
||||
- collect_stock_news(stock_code) → list[dict]
|
||||
- collect_market_news() → list[dict]
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.redis import setex_json, get_json
|
||||
from app.services.db_worker import db_task_queue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 1. 한국어 금융 감성 키워드 사전
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# (키워드, 가중치) 튜플 — 가중치 절대값이 클수록 강한 신호
|
||||
_POSITIVE_KEYWORDS: List[Tuple[str, float]] = [
|
||||
# 실적 관련 (강한 긍정)
|
||||
("실적호전", 0.8), ("어닝서프라이즈", 0.9), ("깜짝실적", 0.9),
|
||||
("흑자전환", 0.85), ("사상최대", 0.8), ("최대실적", 0.8),
|
||||
("영업이익증가", 0.7), ("매출증가", 0.6), ("순이익증가", 0.7),
|
||||
("실적개선", 0.6), ("호실적", 0.7),
|
||||
# 투자의견 (중간 긍정)
|
||||
("목표가상향", 0.7), ("투자의견상향", 0.7), ("매수추천", 0.6),
|
||||
("컨센서스상회", 0.65), ("상향조정", 0.6), ("목표주가상향", 0.7),
|
||||
# 사업/계약 (중간 긍정)
|
||||
("수주", 0.6), ("대형계약", 0.7), ("신규수주", 0.65),
|
||||
("수출증가", 0.5), ("해외진출", 0.5), ("사업확장", 0.5),
|
||||
("신사업", 0.4), ("협력", 0.3), ("제휴", 0.3), ("MOU", 0.3),
|
||||
# 제약/바이오 (강한 긍정)
|
||||
("신약승인", 0.9), ("FDA승인", 0.95), ("임상성공", 0.85),
|
||||
("기술이전", 0.7), ("임상3상", 0.6),
|
||||
# 주주환원 (중간 긍정)
|
||||
("배당확대", 0.5), ("배당증가", 0.5), ("자사주매입", 0.6),
|
||||
("자사주소각", 0.65), ("주주환원", 0.5),
|
||||
# 일반 긍정
|
||||
("강세", 0.3), ("상승", 0.2), ("반등", 0.3), ("회복", 0.3),
|
||||
("돌파", 0.3), ("신고가", 0.4), ("급등", 0.35),
|
||||
]
|
||||
|
||||
_NEGATIVE_KEYWORDS: List[Tuple[str, float]] = [
|
||||
# 실적 관련 (강한 부정)
|
||||
("실적악화", -0.8), ("어닝쇼크", -0.9), ("적자전환", -0.85),
|
||||
("적자확대", -0.8), ("영업손실", -0.7), ("매출감소", -0.6),
|
||||
("순손실", -0.7), ("실적부진", -0.6),
|
||||
# 투자의견 (중간 부정)
|
||||
("목표가하향", -0.7), ("투자의견하향", -0.7), ("매도추천", -0.6),
|
||||
("하향조정", -0.6), ("목표주가하향", -0.7), ("컨센서스하회", -0.65),
|
||||
# 기업 리스크 (강한 부정)
|
||||
("유상증자", -0.7), ("감자", -0.8), ("무상감자", -0.85),
|
||||
("상장폐지", -0.95), ("관리종목", -0.9), ("영업정지", -0.9),
|
||||
("횡령", -0.85), ("배임", -0.8), ("분식회계", -0.9),
|
||||
("소송", -0.5), ("과징금", -0.5), ("리콜", -0.6),
|
||||
("부도", -0.95), ("워크아웃", -0.9), ("법정관리", -0.9),
|
||||
# 규제/정책 (중간 부정)
|
||||
("규제강화", -0.4), ("공매도", -0.3), ("대주주매도", -0.5),
|
||||
("지분매각", -0.4), ("블록딜", -0.35),
|
||||
# 제약/바이오 (강한 부정)
|
||||
("임상실패", -0.9), ("FDA거부", -0.9), ("임상중단", -0.8),
|
||||
# 일반 부정
|
||||
("하락", -0.2), ("급락", -0.4), ("폭락", -0.5),
|
||||
("약세", -0.3), ("부진", -0.3), ("위기", -0.4),
|
||||
("불확실", -0.2), ("우려", -0.2),
|
||||
]
|
||||
|
||||
# 전처리된 룩업용 — O(1) 탐색
|
||||
_POS_DICT: Dict[str, float] = {kw: w for kw, w in _POSITIVE_KEYWORDS}
|
||||
_NEG_DICT: Dict[str, float] = {kw: w for kw, w in _NEGATIVE_KEYWORDS}
|
||||
|
||||
# HTTP 헤더 (네이버 크롤링)
|
||||
_HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 2. 감성 분석 함수
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def analyze_sentiment_keyword(title: str, content: str = "") -> Tuple[float, str]:
|
||||
"""키워드 사전 기반 감성 점수 산출 (1단계).
|
||||
|
||||
Args:
|
||||
title: 뉴스 제목
|
||||
content: 뉴스 본문 (선택, 제목에 비해 가중치 낮음)
|
||||
|
||||
Returns:
|
||||
(score, label)
|
||||
score: -1.0 ~ +1.0 (클램프 적용)
|
||||
label: "positive" | "negative" | "neutral"
|
||||
"""
|
||||
text = (title or "").replace(" ", "") + (content or "").replace(" ", "")
|
||||
if not text:
|
||||
return 0.0, "neutral"
|
||||
|
||||
total_score = 0.0
|
||||
match_count = 0
|
||||
|
||||
# 제목 매칭 — 가중치 1.5배 (제목이 핵심 정보)
|
||||
title_clean = (title or "").replace(" ", "")
|
||||
for kw, weight in _POSITIVE_KEYWORDS:
|
||||
if kw in title_clean:
|
||||
total_score += weight * 1.5
|
||||
match_count += 1
|
||||
for kw, weight in _NEGATIVE_KEYWORDS:
|
||||
if kw in title_clean:
|
||||
total_score += weight * 1.5 # weight 자체가 음수
|
||||
match_count += 1
|
||||
|
||||
# 본문 매칭 — 가중치 0.5배
|
||||
if content:
|
||||
content_clean = content.replace(" ", "")
|
||||
for kw, weight in _POSITIVE_KEYWORDS:
|
||||
if kw in content_clean:
|
||||
total_score += weight * 0.5
|
||||
match_count += 1
|
||||
for kw, weight in _NEGATIVE_KEYWORDS:
|
||||
if kw in content_clean:
|
||||
total_score += weight * 0.5
|
||||
match_count += 1
|
||||
|
||||
if match_count == 0:
|
||||
return 0.0, "neutral"
|
||||
|
||||
# 평균 후 클램프
|
||||
avg_score = total_score / match_count
|
||||
clamped = max(-1.0, min(1.0, avg_score))
|
||||
|
||||
if clamped > 0.1:
|
||||
label = "positive"
|
||||
elif clamped < -0.1:
|
||||
label = "negative"
|
||||
else:
|
||||
label = "neutral"
|
||||
|
||||
return round(clamped, 3), label
|
||||
|
||||
|
||||
def _compute_aggregate_score(articles: List[Dict[str, Any]]) -> float:
|
||||
"""뉴스 리스트에서 종합 감성 점수 산출.
|
||||
|
||||
최신 뉴스일수록 가중치가 높은 시간 감쇠 적용.
|
||||
- 24시간 이내: 가중치 1.0
|
||||
- 24~48시간: 가중치 0.6
|
||||
- 48시간 이후: 가중치 0.3
|
||||
"""
|
||||
if not articles:
|
||||
return 0.0
|
||||
|
||||
now = datetime.utcnow()
|
||||
weighted_sum = 0.0
|
||||
weight_total = 0.0
|
||||
|
||||
for art in articles:
|
||||
score = art.get("sentiment_score", 0.0)
|
||||
if score is None:
|
||||
score = 0.0
|
||||
|
||||
pub_at = art.get("published_at")
|
||||
if isinstance(pub_at, str):
|
||||
try:
|
||||
pub_at = datetime.fromisoformat(pub_at.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
pub_at = now
|
||||
|
||||
if isinstance(pub_at, datetime):
|
||||
hours_ago = (now - pub_at.replace(tzinfo=None)).total_seconds() / 3600
|
||||
else:
|
||||
hours_ago = 24 # 파싱 실패 시 기본 24시간
|
||||
|
||||
if hours_ago <= 24:
|
||||
time_weight = 1.0
|
||||
elif hours_ago <= 48:
|
||||
time_weight = 0.6
|
||||
else:
|
||||
time_weight = 0.3
|
||||
|
||||
weighted_sum += score * time_weight
|
||||
weight_total += time_weight
|
||||
|
||||
if weight_total == 0:
|
||||
return 0.0
|
||||
|
||||
return round(max(-1.0, min(1.0, weighted_sum / weight_total)), 3)
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 3. 뉴스 수집 (네이버 금융 — 종목별)
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async def collect_stock_news(
|
||||
stock_code: str,
|
||||
limit: int = 0,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""네이버 금융에서 종목별 뉴스를 크롤링하고 감성 분석을 수행한다.
|
||||
|
||||
Args:
|
||||
stock_code: 종목 코드 (예: "005930")
|
||||
limit: 수집할 최대 뉴스 개수 (0 이면 settings.NEWS_MAX_ARTICLES)
|
||||
|
||||
Returns:
|
||||
[{"title", "source", "url", "published_at", "sentiment", "sentiment_score"}, ...]
|
||||
"""
|
||||
if limit <= 0:
|
||||
limit = settings.NEWS_MAX_ARTICLES
|
||||
|
||||
url = f"https://finance.naver.com/item/news_news.naver?code={stock_code}&page=1"
|
||||
articles: List[Dict[str, Any]] = []
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(url, headers=_HEADERS)
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"[NEWS] 네이버 종목뉴스 HTTP {resp.status_code}: {stock_code}")
|
||||
return []
|
||||
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
|
||||
for row in soup.select("table.type5 tbody tr"):
|
||||
title_tag = row.select_one(".title a")
|
||||
if not title_tag:
|
||||
continue
|
||||
|
||||
title = title_tag.get_text(strip=True)
|
||||
href = title_tag.get("href", "")
|
||||
link = ("https://finance.naver.com" + href) if href else ""
|
||||
date_tag = row.select_one(".date")
|
||||
date_str = date_tag.get_text(strip=True) if date_tag else ""
|
||||
source_tag = row.select_one(".info")
|
||||
source = source_tag.get_text(strip=True) if source_tag else "네이버금융"
|
||||
|
||||
# 날짜 파싱 (YYYY.MM.DD HH:MM 형식)
|
||||
published_at = _parse_naver_date(date_str)
|
||||
|
||||
# 감성 분석 (1단계: 키워드)
|
||||
score, label = analyze_sentiment_keyword(title)
|
||||
|
||||
articles.append({
|
||||
"title": title,
|
||||
"source": source,
|
||||
"url": link,
|
||||
"stock_codes": [stock_code],
|
||||
"sentiment": label,
|
||||
"sentiment_score": score,
|
||||
"published_at": published_at,
|
||||
})
|
||||
|
||||
if len(articles) >= limit:
|
||||
break
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.warning(f"[NEWS] 네이버 종목뉴스 타임아웃: {stock_code}")
|
||||
except Exception as e:
|
||||
logger.error(f"[NEWS] 네이버 종목뉴스 크롤링 오류 ({stock_code}): {e}")
|
||||
|
||||
return articles
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 4. 뉴스 수집 (네이버 시장 뉴스 — 코스피/코스닥)
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async def collect_market_news(limit: int = 0) -> List[Dict[str, Any]]:
|
||||
"""네이버 금융 시장 뉴스(코스피/코스닥) 크롤링 + 감성 분석.
|
||||
|
||||
Returns:
|
||||
[{"title", "source", "url", "published_at", "sentiment", "sentiment_score"}, ...]
|
||||
"""
|
||||
if limit <= 0:
|
||||
limit = settings.NEWS_MAX_ARTICLES
|
||||
|
||||
urls = [
|
||||
"https://finance.naver.com/news/mainnews.naver",
|
||||
"https://finance.naver.com/news/news_list.naver?mode=LSS2D§ion_id=101§ion_id2=258",
|
||||
]
|
||||
articles: List[Dict[str, Any]] = []
|
||||
|
||||
for page_url in urls:
|
||||
if len(articles) >= limit:
|
||||
break
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(page_url, headers=_HEADERS)
|
||||
|
||||
if resp.status_code != 200:
|
||||
continue
|
||||
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
|
||||
# 네이버 증권 메인뉴스 / 뉴스리스트 파싱
|
||||
for link_tag in soup.select("a[href*='/news/news_read.naver']"):
|
||||
title = link_tag.get_text(strip=True)
|
||||
if not title or len(title) < 5:
|
||||
continue
|
||||
|
||||
href = link_tag.get("href", "")
|
||||
full_url = ("https://finance.naver.com" + href) if href.startswith("/") else href
|
||||
|
||||
score, label = analyze_sentiment_keyword(title)
|
||||
|
||||
articles.append({
|
||||
"title": title,
|
||||
"source": "네이버금융",
|
||||
"url": full_url,
|
||||
"stock_codes": [], # 시장 뉴스는 특정 종목 없음
|
||||
"sentiment": label,
|
||||
"sentiment_score": score,
|
||||
"published_at": datetime.utcnow(), # 파싱 불가 시 현재 시간
|
||||
})
|
||||
|
||||
if len(articles) >= limit:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[NEWS] 시장뉴스 크롤링 오류: {e}")
|
||||
|
||||
return articles
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 5. 뉴스 수집 (NewsAPI.org — 글로벌, 선택적)
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async def collect_newsapi_news(
|
||||
query: str = "한국 주식 OR KOSPI OR KOSDAQ",
|
||||
limit: int = 0,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""NewsAPI.org에서 글로벌 뉴스 수집. NEWS_API_KEY 미설정 시 빈 리스트 반환.
|
||||
|
||||
Args:
|
||||
query: 검색 키워드
|
||||
limit: 최대 수집 개수
|
||||
|
||||
Returns:
|
||||
[{"title", "source", "url", "published_at", "sentiment", "sentiment_score"}, ...]
|
||||
"""
|
||||
if not settings.NEWS_API_KEY:
|
||||
return []
|
||||
|
||||
if limit <= 0:
|
||||
limit = min(settings.NEWS_MAX_ARTICLES, 20)
|
||||
|
||||
api_url = "https://newsapi.org/v2/everything"
|
||||
params = {
|
||||
"q": query,
|
||||
"language": "ko",
|
||||
"sortBy": "publishedAt",
|
||||
"pageSize": limit,
|
||||
"apiKey": settings.NEWS_API_KEY,
|
||||
}
|
||||
|
||||
articles: List[Dict[str, Any]] = []
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(api_url, params=params)
|
||||
|
||||
if resp.status_code != 200:
|
||||
logger.warning(f"[NEWS] NewsAPI HTTP {resp.status_code}")
|
||||
return []
|
||||
|
||||
data = resp.json()
|
||||
for item in data.get("articles", []):
|
||||
title = item.get("title", "")
|
||||
content = item.get("description", "") or ""
|
||||
source = (item.get("source") or {}).get("name", "NewsAPI")
|
||||
url = item.get("url", "")
|
||||
pub_str = item.get("publishedAt", "")
|
||||
|
||||
try:
|
||||
published_at = datetime.fromisoformat(pub_str.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
published_at = datetime.utcnow()
|
||||
|
||||
score, label = analyze_sentiment_keyword(title, content)
|
||||
|
||||
articles.append({
|
||||
"title": title,
|
||||
"source": source,
|
||||
"url": url,
|
||||
"stock_codes": [],
|
||||
"sentiment": label,
|
||||
"sentiment_score": score,
|
||||
"published_at": published_at,
|
||||
})
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.warning("[NEWS] NewsAPI 타임아웃")
|
||||
except Exception as e:
|
||||
logger.error(f"[NEWS] NewsAPI 오류: {e}")
|
||||
|
||||
return articles
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 6. 스코어 조회 (캐시 우선 → 실시간 수집 폴백)
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async def get_stock_news_score(stock_code: str) -> float:
|
||||
"""종목별 뉴스 감성 점수를 반환한다 (-1.0 ~ +1.0).
|
||||
|
||||
- Redis 캐시 히트 시 즉시 반환 (TTL: NEWS_CACHE_TTL)
|
||||
- 캐시 미스 시 실시간 크롤링 → 점수 산출 → 캐시 저장
|
||||
- 크롤링 실패 시 0.0 (중립) 반환
|
||||
"""
|
||||
if not settings.NEWS_ENABLED:
|
||||
return 0.0
|
||||
|
||||
cache_key = f"news_score:stock:{stock_code}"
|
||||
|
||||
# 1. Redis 캐시 조회
|
||||
cached = await get_json(cache_key)
|
||||
if cached is not None:
|
||||
return float(cached)
|
||||
|
||||
# 2. 실시간 크롤링
|
||||
try:
|
||||
articles = await collect_stock_news(stock_code)
|
||||
score = _compute_aggregate_score(articles)
|
||||
|
||||
# 캐시 저장
|
||||
await setex_json(cache_key, settings.NEWS_CACHE_TTL, score)
|
||||
|
||||
# DB 저장 (db_worker 큐 경유)
|
||||
await _save_news_to_db(articles)
|
||||
|
||||
return score
|
||||
except Exception as e:
|
||||
logger.error(f"[NEWS_SCORE] {stock_code} 점수 산출 오류: {e}")
|
||||
return 0.0
|
||||
|
||||
|
||||
async def get_market_news_score() -> float:
|
||||
"""시장 전체 뉴스 감성 점수를 반환한다 (-1.0 ~ +1.0).
|
||||
|
||||
- Redis 캐시 키: "news_score:market"
|
||||
- 캐시 미스 시 시장뉴스 + NewsAPI 수집 → 점수 산출
|
||||
"""
|
||||
if not settings.NEWS_ENABLED:
|
||||
return 0.0
|
||||
|
||||
cache_key = "news_score:market"
|
||||
|
||||
cached = await get_json(cache_key)
|
||||
if cached is not None:
|
||||
return float(cached)
|
||||
|
||||
try:
|
||||
# 시장 뉴스 + NewsAPI 병렬 수집
|
||||
market_task = collect_market_news()
|
||||
api_task = collect_newsapi_news()
|
||||
market_news, api_news = await asyncio.gather(market_task, api_task)
|
||||
|
||||
all_articles = market_news + api_news
|
||||
score = _compute_aggregate_score(all_articles)
|
||||
|
||||
await setex_json(cache_key, settings.NEWS_CACHE_TTL, score)
|
||||
await _save_news_to_db(all_articles)
|
||||
|
||||
return score
|
||||
except Exception as e:
|
||||
logger.error(f"[NEWS_SCORE] 시장 뉴스 점수 산출 오류: {e}")
|
||||
return 0.0
|
||||
|
||||
|
||||
async def get_stock_news_summary(stock_code: str) -> Dict[str, Any]:
|
||||
"""종목 뉴스 요약 정보 반환 (API 엔드포인트용).
|
||||
|
||||
Returns:
|
||||
{
|
||||
"stock_code": str,
|
||||
"score": float,
|
||||
"article_count": int,
|
||||
"latest_articles": list,
|
||||
"sentiment_distribution": {"positive": int, "negative": int, "neutral": int}
|
||||
}
|
||||
"""
|
||||
articles = await collect_stock_news(stock_code)
|
||||
score = _compute_aggregate_score(articles)
|
||||
|
||||
dist = {"positive": 0, "negative": 0, "neutral": 0}
|
||||
for a in articles:
|
||||
label = a.get("sentiment", "neutral")
|
||||
if label in dist:
|
||||
dist[label] += 1
|
||||
|
||||
# 최신 5개만 반환 (토큰 절약)
|
||||
latest = articles[:5] if settings.TOKEN_SAVE_MODE else articles[:10]
|
||||
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"score": score,
|
||||
"article_count": len(articles),
|
||||
"latest_articles": [
|
||||
{
|
||||
"title": a["title"],
|
||||
"source": a.get("source", ""),
|
||||
"sentiment": a.get("sentiment", "neutral"),
|
||||
"sentiment_score": a.get("sentiment_score", 0.0),
|
||||
}
|
||||
for a in latest
|
||||
],
|
||||
"sentiment_distribution": dist,
|
||||
}
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 7. DB 저장 (db_worker 큐 경유)
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async def _save_news_to_db(articles: List[Dict[str, Any]]) -> None:
|
||||
"""뉴스 리스트를 db_worker 큐에 일괄 전달.
|
||||
|
||||
CLAUDE.md 3.2: 실시간 계층에서 직접 DB 접근 금지 → 큐 경유.
|
||||
"""
|
||||
if not articles:
|
||||
return
|
||||
|
||||
for article in articles:
|
||||
try:
|
||||
await db_task_queue.put({
|
||||
"type": "insert_news",
|
||||
"data": {
|
||||
"title": (article.get("title") or "")[:500],
|
||||
"content": article.get("content"),
|
||||
"source": (article.get("source") or "")[:100],
|
||||
"url": article.get("url"),
|
||||
"stock_codes": article.get("stock_codes", []),
|
||||
"sentiment": article.get("sentiment", "neutral"),
|
||||
"sentiment_score": article.get("sentiment_score", 0.0),
|
||||
"published_at": article.get("published_at"),
|
||||
},
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"[NEWS_DB] 큐 전달 오류: {e}")
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 8. 유틸리티
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _parse_naver_date(date_str: str) -> datetime:
|
||||
"""네이버 금융 날짜 문자열을 datetime으로 변환.
|
||||
|
||||
형식: "2026.03.24 09:15" 또는 "2026.03.24"
|
||||
"""
|
||||
if not date_str:
|
||||
return datetime.utcnow()
|
||||
|
||||
date_str = date_str.strip()
|
||||
for fmt in ("%Y.%m.%d %H:%M", "%Y.%m.%d"):
|
||||
try:
|
||||
return datetime.strptime(date_str, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return datetime.utcnow()
|
||||
|
||||
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
# 9. 뉴스 수집 스케줄러 함수 (schedulers.py에서 호출)
|
||||
# ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async def news_collect_cycle() -> Dict[str, Any]:
|
||||
"""한 번의 뉴스 수집 사이클 실행.
|
||||
|
||||
- 시장 뉴스 수집 + 점수 산출
|
||||
- 활성 포트폴리오 종목 뉴스 수집 (별도 호출 필요 시 확장)
|
||||
|
||||
Returns:
|
||||
{"market_score": float, "articles_collected": int}
|
||||
"""
|
||||
market_score = await get_market_news_score()
|
||||
|
||||
logger.info(
|
||||
f"[NEWS_CYCLE] 시장 뉴스 수집 완료: market_score={market_score:.3f}"
|
||||
)
|
||||
|
||||
return {
|
||||
"market_score": market_score,
|
||||
}
|
||||
Reference in New Issue
Block a user