97cf5aecb0
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>
97 lines
5.4 KiB
Python
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)
|