본문 바로가기
Project/🚀 AI Finance 2026

#10 [모듈1-4] 종목 뉴스 vs 시장 뉴스 분리 전략

by 로띠 2026. 2. 26.
데이터 분석

종목 뉴스 vs 시장 뉴스 분리 전략

뉴스 소스 분리로 피처 품질 높이기

Data Collect News Separation NLP Sentiment Feature Eng. ML Signal

이 문서는 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