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

#13 [모듈1-7] DART 공시 7유형 자동 분류 + 감성 분석

by 로띠 2026. 3. 9.
NLP

DART 공시 7유형 자동 분류 + 감성 분석 하이브리드

규칙 기반 분류와 NLP를 결합한 공시 분석

Data Collect NLP Sentiment Disclosure Classification Feature Eng. ML Signal

이 문서는 AI_Finance(ALCHEMETRIC) 프로젝트의 DART 공시 자동 분류 시스템을 다룹니다. DART에서 수집한 공시 제목을 규칙 기반으로 7개 유형(실적, 주요사항, 지분, 자본변동, 정기보고, 구조변경, 기타)으로 분류하고, KR-FinBert-SC BERT 모델의 감성 분석 점수와 결합하는 하이브리드 접근법입니다. 최종적으로 Phase 5A에서 5개의 ML 피처를 생성하여 주가 예측 모델에 투입됩니다.

QUICK FACTS
분류 유형: 7개 (6 + 기타)
type_score 범위: 0.2 ~ 1.0
감성 모델: KR-FinBert-SC
sentiment 범위: -1.0 ~ +1.0
생성 피처: 5개
파이프라인: Phase 5A

1. 문제 정의 — 왜 공시 분류가 필요한가

DART(전자공시시스템)에는 수백 종류의 공시가 등록됩니다. 그러나 모든 공시가 주가에 동일한 영향을 미치지는 않습니다. "영업(잠정)실적 공시""정기보고서(사업보고서) 제출"은 시장에 미치는 임팩트가 전혀 다릅니다.

문제는 DART API가 공시 유형을 우리가 원하는 수준으로 구분해주지 않는다는 점입니다. report_nm(공시 제목)에 유형 정보가 암묵적으로 포함되어 있지만, 이를 활용하려면 제목 텍스트에서 유형을 추출하는 분류 로직이 필요합니다.

핵심 목표: 수동 라벨링 없이, 공시 제목의 키워드 패턴만으로 7개 유형을 자동 분류하고, 각 유형에 "주가 영향도 가중치(type_score)"를 부여하여 ML 피처로 활용한다.

추가로, 공시 제목 자체가 긍정/부정 뉴앙스를 내포하는 경우가 많습니다. 예를 들어 "영업(잠정)실적(호전)""영업(잠정)실적(악화)"는 같은 "실적" 유형이지만 방향성이 정반대입니다. 이를 포착하기 위해 BERT 기반 감성 분석을 결합한 하이브리드 접근법을 채택했습니다.

접근법 역할 출력
규칙 기반 분류 "얼마나 중요한 공시인가" (유형 판별) type_score 0.2 ~ 1.0
BERT 감성 분석 "긍정인가 부정인가" (방향성 판별) sentiment_score -1.0 ~ +1.0

2. _TYPE_RULES 규칙 기반 분류

data_collector/disclosure_fetcher.py에 정의된 분류 규칙입니다. 6개 유형 + "기타"로 총 7개 유형을 정의하며, 각 유형에 주가 영향도를 반영한 type_score를 부여합니다.

# 공시 제목 키워드 → report_type + type_score
_TYPE_RULES = [
    # (키워드 리스트, report_type, type_score)
    (["영업(잠정)실적", "매출액또는손익구조", "실적"], "실적", 1.0),
    (["주요사항보고서", "소송"], "주요사항", 0.8),
    (["지분", "임원ㆍ주요주주", "주식등의대량보유", "공개매수"], "지분", 0.7),
    (["유상증자", "무상증자", "전환사채", "신주인수권"], "자본변동", 0.6),
    (["사업보고서", "분기보고서", "반기보고서"], "정기보고", 0.5),
    (["합병", "분할", "영업양수도"], "구조변경", 0.9),
]
# 매칭 안 되면 → "기타", 0.2

2.1 7유형 상세

유형 type_score 매칭 키워드 주가 영향
실적 1.0 영업(잠정)실적, 매출액또는손익구조, 실적 가장 높음 — 직접적 실적 서프라이즈
구조변경 0.9 합병, 분할, 영업양수도 매우 높음 — 기업 구조 자체 변경
주요사항 0.8 주요사항보고서, 소송 높음 — 소송, 횡령 등 이벤트성
지분 0.7 지분, 임원ㆍ주요주주, 주식등의대량보유, 공개매수 중간~높음 — 경영권 변동 시그널
자본변동 0.6 유상증자, 무상증자, 전환사채, 신주인수권 중간 — 희석 효과 / 자금 조달
정기보고 0.5 사업보고서, 분기보고서, 반기보고서 중간 이하 — 정기적, 예측 가능
기타 0.2 (매칭 안 되는 모든 공시) 낮음 — 일상적 공시

2.2 type_score 설계 원칙

type_score는 0.2~1.0 범위로 설정됩니다. 0이 아닌 0.2를 최솟값으로 둔 이유는, 어떤 공시든 최소한의 정보 가치가 있으며, 추후 가중 평균 계산 시 완전히 무시되지 않도록 하기 위함입니다.

규칙 우선순위: _TYPE_RULES 리스트의 순서가 곧 우선순위입니다. "실적"이 최상위에 있으므로, 공시 제목에 "실적"과 "사업보고서"가 동시에 포함되어도 "실적"으로 분류됩니다. 첫 번째 매칭이 발생하면 즉시 반환하는 early return 패턴입니다.

3. _classify_report() 함수

실제 분류를 수행하는 핵심 함수입니다. 공시 제목(report_nm)을 입력받아 (report_type, type_score) 튜플을 반환합니다.

def _classify_report(report_nm: str) -> tuple[str, float]:
    """공시 제목에서 유형과 가중치 판별"""
    for keywords, rtype, score in _TYPE_RULES:
        for kw in keywords:
            if kw in report_nm:
                return rtype, score
    return "기타", 0.2

3.1 분류 예시

공시 제목 (report_nm) 매칭 키워드 report_type type_score
영업(잠정)실적(호전) 영업(잠정)실적 실적 1.0
합병등종료보고서(합병) 합병 구조변경 0.9
주요사항보고서(전환사채발행결정) 주요사항보고서 주요사항 0.8
임원ㆍ주요주주특정증권등소유상황보고서 임원ㆍ주요주주 지분 0.7
사업보고서 (2024.12) 사업보고서 정기보고 0.5
증권발행실적보고서 (매칭 없음) 기타 0.2

3.2 호출 시점

_classify_report()DisclosureFetcher.fetch_disclosures() 내부에서 각 공시를 파싱할 때 호출됩니다. 분류 결과는 report_typetype_score 필드로 DB에 저장됩니다.

# DisclosureFetcher.fetch_disclosures() 내부
for _, row in df.iterrows():
    report_nm = str(row.get("report_nm", ""))
    rcept_no  = str(row.get("rcept_no", ""))

    # 규칙 기반 분류 호출
    report_type, type_score = _classify_report(report_nm)

    records.append({
        "date":        rec_date,
        "code":        code,
        "report_nm":   report_nm,
        "rcept_no":    rcept_no,
        "report_type": report_type,   # "실적", "주요사항", ... "기타"
        "type_score":  type_score,    # 0.2 ~ 1.0
    })

4. KR-FinBert-SC 감성 분석

규칙 기반 분류가 "이 공시가 어떤 종류인가"를 판별한다면, 감성 분석은 "이 공시가 긍정인가 부정인가"를 판별합니다. 서울대 NLP 연구실의 snunlp/KR-FinBert-SC 모델을 사용하며, 한국어 금융 텍스트에 특화된 BERT 모델입니다.

4.1 센티먼트 점수 산출

# label → normalized score 매핑
_LABEL_SCORE = {
    "positive": 1.0,
    "negative": -1.0,
    "neutral":  0.0,
}

class SentimentAnalyzer:
    def analyze(self, texts: list[str], batch_size: int = 32) -> list[dict]:
        outputs = self._pipeline(texts, batch_size=batch_size)

        results = []
        for output in outputs:
            label = output["label"].lower()
            confidence = output["score"]
            # 방향(+1/-1/0) x 확신도(0~1) = 최종 점수
            sentiment_score = _LABEL_SCORE.get(label, 0.0) * confidence
            results.append({
                "label": label,
                "confidence": round(confidence, 4),
                "sentiment_score": round(sentiment_score, 4),
            })
        return results

4.2 점수 산출 공식

BERT 출력 label confidence sentiment_score
positive, 0.92 positive 0.92 +0.92
negative, 0.87 negative 0.87 -0.87
neutral, 0.78 neutral 0.78 0.0

sentiment_score = label_direction * confidence 공식을 통해 단순 3-class 분류를 연속적인 -1.0~+1.0 범위의 수치로 변환합니다. neutral의 방향값이 0이므로, confidence에 관계없이 항상 0.0이 됩니다.

4.3 공시 제목에 적용

DisclosureService.collect_disclosures()에서 analyze_sentiment=True(기본값)일 때, 수집된 공시 제목들을 배치로 SentimentAnalyzer에 전달합니다.

# DisclosureService._analyze_sentiment()
@staticmethod
def _analyze_sentiment(records: list[dict]) -> list[dict]:
    """공시 제목에 센티먼트 분석 적용"""
    analyzer = SentimentAnalyzer.get_instance()

    texts = [r["report_nm"] for r in records]
    sentiments = analyzer.analyze(texts)

    for rec, sent in zip(records, sentiments):
        rec["sentiment_score"] = sent["sentiment_score"]
        rec["sentiment_label"] = sent["label"]

    return records

5. 하이브리드 접근 — 규칙 + BERT 결합

ALCHEMETRIC의 공시 분석은 두 개의 독립적인 축으로 공시를 평가합니다. 이 두 축은 서로 다른 질문에 답하며, ML 모델에 별도의 피처로 투입되어 상호 보완합니다.

┌─────────────────────────────────────────────────────────────┐
  DART 공시 제목                                               
  "영업(잠정)실적(호전)"                                         
└────────────────┬────────────────────────────────────────────┘
                 
        ┌────────┴────────┐
                         
┌───────────────────┐  ┌───────────────────┐
     규칙 기반 분류    │  │   BERT 감성 분석    
 _classify_report() │  │ KR-FinBert-SC    
                    │  │                  
 Q: 어떤 종류인가?     │  │ Q: 긍정? 부정?      
 A: "실적"           │  │ A: positive 0.91 
└────────┬──────────┘  └────────┬──────────┘
                               
  type_score = 1.0       sentiment_score = +0.91
                               
                               
┌────────┴──────────────────────┴──────────┐
  disclosure 테이블                         
  report_type: "실적"                      
  type_score: 1.0                         
  sentiment_score: +0.91                  
  sentiment_label: positive               
└──────────────────────────────────────────┘
         
         
┌──────────────────────────────────────────┐
  FeatureEngineer (Phase 5A)              
  30일 윈도우 집계 → ML 피처 5개 생성           
└──────────────────────────────────────────┘

5.1 왜 하이브리드인가

규칙 기반 분류만으로는 같은 유형 내의 방향성을 구분할 수 없습니다. 반대로 BERT 감성 분석만으로는 공시의 중요도를 판별할 수 없습니다. 두 접근법을 결합함으로써 "중요한 공시인데 부정적이다"와 같은 복합 시그널을 포착합니다.

공시 제목 type_score sentiment 해석
영업(잠정)실적(호전) 1.0 +0.91 최고 중요도 + 긍정 → 강한 매수 시그널
영업(잠정)실적(악화) 1.0 -0.85 최고 중요도 + 부정 → 강한 매도 시그널
사업보고서 (2024.12) 0.5 0.0 보통 중요도 + 중립 → 약한 시그널
증권발행실적보고서 0.2 0.0 낮은 중요도 + 중립 → 무시 가능
설계 핵심: type_score와 sentiment_score를 직접 곱하거나 합산하지 않습니다. 두 값은 독립적인 피처로 ML 모델에 투입되어, 모델이 스스로 최적의 결합 방식을 학습합니다. 이것이 "규칙을 하드코딩하지 않는" ML 접근법의 강점입니다.

6. Phase 5A 피처 5개 생성

FeatureEngineer._merge_disclosure_features()에서 disclosure 테이블의 raw 데이터를 30일 슬라이딩 윈도우로 집계하여 5개의 ML 피처를 생성합니다.

6.1 5개 피처 상세

피처명 설명 산출 방식
disclosure_count_30d 최근 30일 공시 건수 해당 날짜 기준 과거 30일 윈도우 내 공시 수 카운트
days_since_disclosure 마지막 공시 이후 경과일 현재 날짜 - 가장 최근 공시 날짜
disclosure_sentiment 30일 공시 평균 센티먼트 30일 윈도우 내 sentiment_score의 평균 (NULL 제외)
disclosure_type_score 30일 공시 유형 가중치 평균 30일 윈도우 내 type_score의 평균
disclosure_volume_change 공시 빈도 변화율 (30일 건수 - 이전 30일 건수) / 이전 30일 건수

6.2 핵심 구현 코드

def _merge_disclosure_features(self, df, market, code, session):
    """Phase 5A: 공시 피처를 기존 피처 DataFrame에 병합"""

    # 60일 lookback (30d + 이전 30d 비교용)
    lookback_start = min_date - timedelta(days=60)
    disc_rows = repo.get_disclosures_for_features(
        market, code, lookback_start, max_date,
    )

    # 날짜별 피처 계산
    for _, row in df.iterrows():
        feat_date = row["date"]
        d30_start = feat_date - timedelta(days=30)
        d60_start = feat_date - timedelta(days=60)

        # 30일 윈도우
        mask_30d = (disc_df["date"] >= d30_start) & \
                   (disc_df["date"] <= feat_date)
        # 이전 30일 윈도우
        mask_prev30d = (disc_df["date"] >= d60_start) & \
                       (disc_df["date"] < d30_start)

        window_30d = disc_df[mask_30d]
        count_30d  = len(window_30d)
        count_prev = len(disc_df[mask_prev30d])

        # disclosure_count_30d
        # days_since_disclosure
        # disclosure_sentiment (평균)
        # disclosure_type_score (평균)
        # disclosure_volume_change (빈도 변화율)
        if count_prev > 0:
            vol_change = (count_30d - count_prev) / count_prev
        elif count_30d > 0:
            vol_change = 1.0   # 이전 0건 → 100% 증가
        else:
            vol_change = None  # 양쪽 다 0건

6.3 슬라이딩 윈도우 시각화

Timeline (feat_date = 2025-03-10):

  2025-01-09           2025-02-08          2025-03-10
  ───── 이전 30일 ─────━━━ 최근 30일 ━━━
    count_prev: 2      count_30d: 5    
                                       
                      disclosure_count_30d = 5
                      disclosure_volume_change = (5-2)/2 = 1.5

6.4 피처 컬럼 등록

Phase 5A의 5개 피처는 feature_engineer.pyPHASE5_FEATURE_COLUMNS에 등록되어 학습 시 자동으로 포함됩니다.

# Phase 5 피처 컬럼 (Phase 4 + 공시 + 수급 피처)
PHASE5_FEATURE_COLUMNS = PHASE4_FEATURE_COLUMNS + [
    "disclosure_count_30d", "days_since_disclosure",
    "disclosure_sentiment", "disclosure_type_score",
    "disclosure_volume_change",
    # Phase 5B: 수급 피처 (별도)
    "short_selling_volume", "short_selling_ratio",
    "program_buy_volume", "program_sell_volume",
    "program_net_volume",
]

7. Graceful NULL 전략

모든 종목에 공시가 있는 것은 아닙니다. 특히 소형주는 공시 자체가 드물 수 있습니다. ALCHEMETRIC은 공시가 없는 종목에 대해 5개 피처 모두 NULL로 유지하는 Graceful NULL 전략을 채택합니다.

# 공시 데이터 없으면 → 5개 피처 모두 NULL
if not disc_rows:
    for col in _DISC_COLS:
        df[col] = None
    return df
왜 NULL인가: 0으로 채우면 "공시가 없다"와 "공시 점수가 0이다"를 구분할 수 없습니다. NULL을 유지하면 트리 기반 ML 모델(RandomForest, XGBoost, LightGBM)이 자체적으로 결측값을 처리하여, "데이터 없음"이라는 정보 자체를 학습에 활용합니다.
전략 처리 방식 모델 호환성
0 채우기 공시 없음 = 중립으로 간주 정보 손실 발생
NULL 유지 (채택) 모델이 결측값 자체를 학습 RF / XGB / LGBM 네이티브 지원
제거 해당 행 삭제 데이터 손실 (공시 없는 종목 전체 제외)

8. 전체 파이프라인 통합

공시 분류와 감성 분석은 전체 ALCHEMETRIC 파이프라인의 한 모듈입니다. 스케줄러(APScheduler)에 의해 자동 실행되거나, Admin UI / REST API를 통해 수동 트리거됩니다.

┌─────────────────────────────────────────────────────────────┐
  Step 1: Data Collection                                    
  DisclosureFetcher.fetch_all()                              
    └ OpenDartReader.list() → 공시 목록 수집                    
    └ _classify_report() → 7유형 분류 + type_score 부여         
└──────────────────────────┬──────────────────────────────────┘
                           
┌──────────────────────────┴──────────────────────────────────┐
  Step 2: Sentiment Analysis                                 
  DisclosureService._analyze_sentiment()                     
    └ SentimentAnalyzer.analyze() → KR-FinBert-SC 추론        
    └ sentiment_score, sentiment_label 부여                   
└──────────────────────────┬──────────────────────────────────┘
                           
┌──────────────────────────┴──────────────────────────────────┐
  Step 3: DB Upsert                                          
  DisclosureRepository.upsert_disclosures()                  
    └ disclosure 테이블에 저장                                  
└──────────────────────────┬──────────────────────────────────┘
                           
┌──────────────────────────┴──────────────────────────────────┐
  Step 4: Feature Engineering (Phase 5A)                     
  FeatureEngineer._merge_disclosure_features()               
    └ 30일 윈도우 집계 → 5개 피처 생성                             
    └ feature_store 테이블에 저장                               
└──────────────────────────┬──────────────────────────────────┘
                           
┌──────────────────────────┴──────────────────────────────────┐
  Step 5: ML Training / Prediction                           
  Trainer / Predictor                                        
    └ 75개 피처 중 5개가 공시 피처                                 
    └ RF / XGBoost / LightGBM                                
└─────────────────────────────────────────────────────────────┘

8.1 피처 계산 순서 (compute_features)

FeatureEngineer.compute_features()는 Phase 1부터 Phase 7까지 순차적으로 피처를 병합합니다. 공시 피처는 Step 3.8(Phase 5A)에서 계산됩니다.

단계 Phase 피처 수 데이터 소스
Step 3 Phase 1 — 가격/기술지표 24개 stock_price
Step 3.5 Phase 2 — 재무 +9개 stock_fundamental, financial_statement
Step 3.6 Phase 3 — 거시경제 +11개 macro_indicator
Step 3.7 Phase 4 — 뉴스 센티먼트 +5개 news_sentiment
Step 3.8 Phase 5A — 공시 (본 문서) +5개 disclosure
Step 3.9 Phase 5B — 수급 +5개 supply_demand
Step 3.10~11 Phase 6 — 섹터/뉴스정제 +10개 feature_store (2-pass)
Step 3.10 Phase 7 — 대안 데이터 +6개 alternative_data
합계 총 75개 피처 + 4개 타겟

8.2 DB 스키마

공시 원본은 disclosure 테이블에, ML 피처는 feature_store 테이블에 저장됩니다.

-- disclosure 테이블 (공시 원본)
CREATE TABLE disclosure (
    id              SERIAL PRIMARY KEY,
    date            DATE NOT NULL,
    market          VARCHAR(10),
    code            VARCHAR(10),
    report_nm       TEXT,
    rcept_no        VARCHAR(20) UNIQUE,
    report_type     VARCHAR(20),          -- "실적", "주요사항", ...
    type_score      NUMERIC(3,1),         -- 0.2 ~ 1.0
    sentiment_score NUMERIC(6,4),         -- -1.0 ~ +1.0
    sentiment_label VARCHAR(10)          -- positive/negative/neutral
);

-- feature_store 테이블 (Phase 5A 부분)
-- disclosure_count_30d      INTEGER
-- days_since_disclosure     INTEGER
-- disclosure_sentiment      NUMERIC(8,4)
-- disclosure_type_score     NUMERIC(4,2)
-- disclosure_volume_change  NUMERIC(8,4)
요약: DART 공시는 규칙 기반 7유형 분류(type_score)와 BERT 감성 분석(sentiment_score)의 하이브리드로 처리됩니다. 두 점수는 독립적인 축으로 유지되어 Phase 5A에서 30일 윈도우 집계를 거쳐 5개의 ML 피처로 변환됩니다. 공시 데이터가 없는 종목은 Graceful NULL 전략으로 처리하여, 트리 기반 ML 모델이 결측 자체를 정보로 활용할 수 있도록 합니다.