종목 뉴스 vs 시장 뉴스 분리 전략
뉴스 소스 분리로 피처 품질 높이기
이 문서는 AI_Finance(ALCHEMETRIC) 프로젝트의 뉴스 센티먼트 파이프라인에서 종목 뉴스와 시장 뉴스를 분리하는 전략을 다룹니다. "삼성전자 실적 호조"와 "코스피 폭락"은 동일한 뉴스 센티먼트 점수를 가질 수 있지만, ML 모델이 학습해야 하는 시그널은 근본적으로 다릅니다. 이 분리 전략이 왜 필요하고, 어떻게 수집→저장→피처 단계에서 일관되게 구현되는지를 정리합니다.
1. 문제 정의 — 왜 분리해야 하는가
뉴스 센티먼트를 ML 피처로 활용할 때, 가장 흔한 실수는 종목 뉴스와 시장 뉴스를 하나의 피처로 합산하는 것입니다. 두 뉴스는 근본적으로 다른 시그널을 담고 있습니다.
| 구분 | 예시 | 영향 범위 | 시그널 성격 |
| 종목 뉴스 | "삼성전자 실적 호조, HBM4 양산 앞당겨" | 개별 종목 | 알파 시그널 (종목 고유) |
| 시장 뉴스 | "코스피 폭락, 외국인 대량 매도" | 시장 전체 | 베타 시그널 (시스템 리스크) |
혼합 시 문제: 삼성전자에 긍정 뉴스 3건, 코스피 폭락 뉴스 7건이 있다면, 합산 센티먼트는 부정(-0.4)입니다. 하지만 삼성전자 자체의 모멘텀은 긍정(+0.8)이고, 시장 전체 공포(-0.7)와는 별개의 시그널입니다. 혼합하면 둘 다 날아갑니다.
이 문제를 해결하기 위해 ALCHEMETRIC은 수집 시점부터 분리합니다. DB에 저장할 때 code 컬럼으로 종목/시장을 구분하고, ML 피처 생성 시 별도 피처로 분기합니다.
2. 분리 전략 — code 기반 이분법
분리의 핵심은 단순합니다. news_sentiment 테이블의 code 컬럼이 분기점입니다.
| code 값 | 의미 | 수집 경로 | 피처 대상 |
| NULL | 시장 전체 뉴스 | fetch_market_news() | market_sentiment, market_news_volume |
| '005930' 등 | 종목별 뉴스 | fetch_stock_news() | news_sentiment, news_volume, news_sentiment_std |
2.1 시장 뉴스 키워드 상수
시장 뉴스를 수집하기 위한 6개 키워드가 news_fetcher.py에 모듈 상수로 정의되어 있습니다. 각 키워드가 한국 주식시장의 주요 매크로 토픽을 커버합니다.
# 시장 전체 뉴스 검색 키워드 (data_collector/news_fetcher.py) MARKET_KEYWORDS = [ "코스피", # 대형주 시장 동향 "코스닥", # 중소형주 시장 동향 "증시", # 포괄적 시장 뉴스 "주식시장", # 시장 전반 분석 기사 "한국은행 금리", # 통화정책 시그널 "경기 전망", # 거시경제 전망 ]
2.2 종목 뉴스 쿼리 패턴
종목 뉴스는 {종목명} 주가 패턴으로 검색합니다. "주가" 키워드를 붙여 주식 관련 기사만 필터링하는 효과를 얻습니다.
def fetch_stock_news(self, code, stock_name, market="KR", max_items=100): """종목별 뉴스 수집 — code 값이 레코드에 포함됨""" query = f"{stock_name} 주가" return self._fetch_news(query, code=code, market=market, max_items=max_items) # ^^^^^^^^^ code가 레코드에 삽입됨
3. 시장 뉴스 수집 — fetch_market_news()
fetch_market_news()는 6개 키워드에 대해 균등 분배 방식으로 뉴스를 수집합니다. 특정 키워드에 치우치지 않도록 max_items를 키워드 수로 나누어 각 키워드별 할당량을 계산합니다.
def fetch_market_news(self, market="KR", max_items=100): """시장 전체 뉴스 수집 — code=None으로 저장""" all_records = [] per_keyword = max(max_items // len(MARKET_KEYWORDS), 10) # max_items=100, 키워드 6개 → per_keyword=16 for keyword in MARKET_KEYWORDS: records = self._fetch_news( keyword, code=None, # ← 핵심: code가 None이면 시장 뉴스 market=market, max_items=per_keyword, ) all_records.extend(records) return all_records
3.1 균등 분배 로직
| max_items | 키워드 수 | per_keyword | 실제 최대 수집 |
| 100 | 6 | 16 | 96건 (16 x 6) |
| 60 | 6 | 10 | 60건 (10 x 6) |
| 30 | 6 | 10 | 60건 (최소 10건 보장) |
최소 10건 보장: max(max_items // len(MARKET_KEYWORDS), 10)에서 per_keyword가 10 미만이 되지 않도록 하한을 두었습니다. 키워드당 최소 10건은 수집해야 유의미한 센티먼트 집계가 가능하기 때문입니다.
3.2 _fetch_news() 내부 — code 삽입 지점
_fetch_news()는 API 응답을 레코드로 변환할 때, 전달받은 code 파라미터를 그대로 레코드에 삽입합니다. 이것이 분리의 물리적 지점입니다.
def _fetch_news(self, query, code, market, max_items): """API 호출 → 레코드 변환 (페이지네이션)""" records = [] for item in items: records.append({ "date": pub_date, "market": market, "code": code, # ← None이면 시장, "005930"이면 종목 "title": title[:500], "source": "naver", }) return records[:max_items]
4. DB 분기 — SQL 쿼리 전략
피처 엔지니어링 단계(FeatureEngineer._merge_news_features())에서는 NewsRepository의 두 메서드를 호출하여 종목과 시장 센티먼트를 각각 조회합니다. 두 메서드의 SQL 분기점은 code 컬럼입니다.
4.1 종목 센티먼트 조회
# NewsRepository.get_daily_sentiment() SELECT date, AVG(sentiment_score) AS news_sentiment, COUNT(id) AS news_volume, STDDEV(sentiment_score) AS news_sentiment_std FROM news_sentiment WHERE code = :code -- 특정 종목 필터 AND date BETWEEN :start AND :end GROUP BY date ORDER BY date;
4.2 시장 센티먼트 조회
# NewsRepository.get_daily_market_sentiment() SELECT date, AVG(sentiment_score) AS market_sentiment, COUNT(id) AS market_news_volume FROM news_sentiment WHERE code IS NULL -- 시장 전체 뉴스만 (핵심 분기) AND market = :market AND date BETWEEN :start AND :end GROUP BY date ORDER BY date;
4.3 SQLAlchemy 구현
실제 코드에서는 SQLAlchemy ORM을 통해 위 SQL이 생성됩니다. code.is_(None)이 IS NULL로 변환되는 것이 핵심입니다.
# 종목 센티먼트: code == "005930" .filter(NewsSentiment.code == code) # 시장 센티먼트: code IS NULL .filter(NewsSentiment.code.is_(None))
왜 code == None이 아닌 code.is_(None)인가: SQL에서 NULL = NULL은 항상 FALSE입니다 (3-valued logic). 반드시 IS NULL을 사용해야 시장 뉴스를 올바르게 필터링할 수 있습니다. SQLAlchemy에서는 .is_(None)이 이를 보장합니다.
5. Phase 4 피처 분리 — 종목 3 + 시장 2
Phase 4에서 생성되는 뉴스 센티먼트 피처는 총 5개이며, 종목 피처 3개와 시장 피처 2개로 명확히 분리됩니다. 이 분리가 feature_store 테이블에 저장되어 ML 학습에 직접 사용됩니다.
| 유형 | 피처명 | 산출 방법 | SQL 조건 |
| 종목 Alpha |
news_sentiment | 일별 sentiment_score 평균 | code = :code |
| news_volume | 일별 기사 건수 (COUNT) | code = :code | |
| news_sentiment_std | 일별 sentiment_score 표준편차 | code = :code | |
| 시장 Beta |
market_sentiment | 시장 뉴스 sentiment_score 평균 | code IS NULL |
| market_news_volume | 시장 뉴스 건수 (COUNT) | code IS NULL |
5.1 피처 컬럼 정의 코드
# ml/feature_engineer.py — Phase 4 피처 컬럼 PHASE4_FEATURE_COLUMNS = PHASE3_FEATURE_COLUMNS + [ "news_sentiment", # 종목 — 일별 평균 "news_volume", # 종목 — 기사 건수 "news_sentiment_std", # 종목 — 센티먼트 분산 "market_sentiment", # 시장 — 전체 분위기 "market_news_volume", # 시장 — 뉴스 볼륨 ]
5.2 _merge_news_features() 병합 흐름
FeatureEngineer._merge_news_features()는 두 Repository 메서드를 순차 호출하여 종목/시장 센티먼트를 각각 조회한 뒤, date 기준 left join으로 기존 피처 DataFrame에 병합합니다.
def _merge_news_features(self, df, market, code, session): repo = NewsRepository(session) # 1) 종목별 센티먼트 (code = :code) stock_sent = repo.get_daily_sentiment(code, min_date, max_date) if stock_sent: sent_df = pd.DataFrame(stock_sent) df = df.merge(sent_df, on="date", how="left") # 2) 시장 전체 센티먼트 (code IS NULL) market_sent = repo.get_daily_market_sentiment(min_date, max_date, market) if market_sent: msent_df = pd.DataFrame(market_sent) df = df.merge(msent_df, on="date", how="left") # 컬럼 보장 (데이터 없어도 컬럼은 존재해야 함) for col in _NEWS_FEATURE_COLUMNS: if col not in df.columns: df[col] = None return df
6. 역행 시그널 — ML이 학습하는 교차 패턴
종목/시장 센티먼트를 분리하면, ML 모델이 학습할 수 있는 교차 패턴(Cross Signal)이 만들어집니다. 이것이 단순 합산 대비 분리의 가장 큰 이점입니다.
| 패턴 | news_sentiment | market_sentiment | 해석 | 시그널 |
| 동행 긍정 | +0.7 | +0.5 | 종목 호재 + 시장 상승 | 강한 BUY |
| 역행 긍정 | +0.8 | -0.6 | 시장 하락에도 강한 종목 | 알파 BUY |
| 역행 부정 | -0.5 | +0.6 | 시장 상승에도 약한 종목 | SELL |
| 동행 부정 | -0.6 | -0.7 | 종목 악재 + 시장 하락 | 강한 SELL |
핵심 인사이트 — "역행 긍정" 패턴:
종목 센티먼트가 긍정(+0.8)이고 시장 센티먼트가 부정(-0.6)인 경우, 이 종목은 시장 전체 하락에도 불구하고 고유한 강세 모멘텀을 보유하고 있습니다. 이런 패턴을 보이는 종목은 시장 반등 시 가장 먼저, 가장 크게 상승하는 경향이 있습니다. 분리하지 않았다면 합산 센티먼트는 +0.1로 중립이 되어, 이 귀중한 알파 시그널을 놓치게 됩니다.
XGBoost와 같은 트리 기반 모델은 이런 교차 패턴을 특히 잘 학습합니다. 트리 분할(split)이 news_sentiment > 0.3 AND market_sentiment < -0.2와 같은 조건을 자연스럽게 만들어낼 수 있기 때문입니다.
7. End-to-End 흐름도
수집부터 ML 피처 생성까지, 종목 뉴스와 시장 뉴스가 어떻게 분리되어 흐르는지를 전체 흐름도로 정리합니다.
┌─────────────────────────────────────────────────────────────────┐ │ NewsService.collect() │ └───────────────────┬───────────────────┬─────────────────────────┘ ▼ ▼ ┌─────────────┴────────┐ ┌────────┴──────────────┐ │ fetch_stock_news() │ │ fetch_market_news() │ │ query: "삼성전자 주가" │ │ query: 6개 키워드 순회 │ │ code = "005930" │ │ code = None │ └─────────────┬────────┘ └────────┬──────────────┘ ▼ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ SentimentAnalyzer (KR-FinBert-SC) │ │ title + description → sentiment_score (-1.0 ~ +1.0) │ └───────────────────┬─────────────────────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ news_sentiment 테이블 │ │ │ │ code = '005930' → 종목 뉴스 레코드 │ │ code = NULL → 시장 뉴스 레코드 │ └───────────────────┬───────────────────┬─────────────────────────┘ ▼ ▼ ┌─────────────┴────────┐ ┌────────┴──────────────┐ │ get_daily_sentiment │ │ get_daily_market_ │ │ │ │ sentiment │ │ WHERE code = :code │ │ WHERE code IS NULL │ │ → AVG, COUNT, STD │ │ → AVG, COUNT │ └─────────────┬────────┘ └────────┬──────────────┘ ▼ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ feature_store (Phase 4) │ │ │ │ news_sentiment = +0.82 (종목 알파) │ │ news_volume = 15 (종목 주목도) │ │ news_sentiment_std = 0.31 (의견 분산) │ │ market_sentiment = -0.45 (시장 공포) │ │ market_news_volume = 87 (시장 관심도) │ └───────────────────┬─────────────────────────────────────────────┘ ▼ ┌─────────────────────────────────────────────────────────────────┐ │ XGBoost / ML Model │ │ 종목 긍정 + 시장 부정 = "역행 시그널" 학습 가능 │ │ → BUY / HOLD / SELL 시그널 생성 │ └─────────────────────────────────────────────────────────────────┘
7.1 전체 Phase 컨텍스트에서의 위치
Phase 4 뉴스 피처는 전체 피처 파이프라인의 한 단계입니다. Phase 1(가격/기술지표) + Phase 2(재무) + Phase 3(거시) 위에 쌓이며, 이후 Phase 5(공시/수급), Phase 6(섹터/상대강도)으로 확장됩니다.
| Phase | 피처 수 | 누적 | 도메인 |
| Phase 1 | 24 | 24 | 가격, 기술지표, 파생 |
| Phase 2 | 9 | 33 | 재무 (PER, PBR, ROE 등) |
| Phase 3 | 11 | 44 | 거시 (환율, 금리, VIX 등) |
| Phase 4 | 5 | 49 | 뉴스 센티먼트 (종목 3 + 시장 2) |
| Phase 5 | 10 | 59 | 공시 + 수급 |
| Phase 6 | 10 | 69 | 섹터/상대강도 + 뉴스 정제 |
| Phase 7 | 6 | 75 | 대안 데이터 (트렌드, 커뮤니티) |
설계 원칙 — Graceful Degradation: 뉴스 데이터가 없더라도 피처 컬럼은 NULL로 보장됩니다. _merge_news_features()의 마지막 블록에서 for col in _NEWS_FEATURE_COLUMNS: if col not in df.columns: df[col] = None 처리로 하위 Phase(Phase 5, 6)와 ML 학습이 중단 없이 진행됩니다.
ALCHEMETRIC — DOCUMENT No. M1-2 — News Separation Strategy
'Project > 🚀 AI Finance 2026' 카테고리의 다른 글
| #12 [모듈1-6] opendartreader + 규칙 기반 공시 분류 (0) | 2026.03.06 |
|---|---|
| #11 [모듈1-5] SNR 개선 - 뉴스 관련성 필터링 (0) | 2026.03.04 |
| #9 [모듈1-3] 뉴스 센티먼트 분석 & 일간 리포트 시스템 (0) | 2026.02.19 |
| #8 [모듈1-2] 금융 BERT 모델 메모리 관리 (0) | 2026.02.19 |
| #7 [모듈1-1] 한국어 금융 뉴스의 감성 sentiment 분석 (0) | 2026.02.19 |