Files
kyu 97cf5aecb0
trading/pipeline/head This commit looks good
chore: 누적 미커밋 작업분 일괄 커밋
이번 세션 외 그간 쌓인 변경 일괄 저장:
- 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

97 lines
5.4 KiB
Python

"""뉴스 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)