DART 공시 7유형 자동 분류 + 감성 분석 하이브리드
규칙 기반 분류와 NLP를 결합한 공시 분석
이 문서는 AI_Finance(ALCHEMETRIC) 프로젝트의 DART 공시 자동 분류 시스템을 다룹니다. DART에서 수집한 공시 제목을 규칙 기반으로 7개 유형(실적, 주요사항, 지분, 자본변동, 정기보고, 구조변경, 기타)으로 분류하고, KR-FinBert-SC BERT 모델의 감성 분석 점수와 결합하는 하이브리드 접근법입니다. 최종적으로 Phase 5A에서 5개의 ML 피처를 생성하여 주가 예측 모델에 투입됩니다.
목차
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_type과 type_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.py의 PHASE5_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 모델이 결측 자체를 정보로 활용할 수 있도록 합니다.
'Project > 🚀 AI Finance 2026' 카테고리의 다른 글
| #15 [모듈1-9] pytrends + BS4 — 대안 데이터 (0) | 2026.03.13 |
|---|---|
| #14 [모듈 1-8] 30일 롤링 윈도우 공시 활동도 (0) | 2026.03.10 |
| #12 [모듈1-6] opendartreader + 규칙 기반 공시 분류 (0) | 2026.03.06 |
| #11 [모듈1-5] SNR 개선 - 뉴스 관련성 필터링 (0) | 2026.03.04 |
| #10 [모듈1-4] 종목 뉴스 vs 시장 뉴스 분리 전략 (0) | 2026.02.26 |