30일 롤링 윈도우 공시 활동도
타이밍만으로 이벤트 감지
이 문서는 AI_Finance(ALCHEMETRIC) 프로젝트에서 DART 공시 데이터를 30일 롤링 윈도우로 집계하여 5개 피처를 생성하는 과정을 다룹니다. 공시의 “내용”뿐 아니라 “빈도 변화” 자체가 주가 예측에 유의미한 시그널이라는 가설에 기반합니다.
1. 핵심 아이디어
전통적 공시 분석은 공시 내용(실적, M&A, 유상증자 등)에 초점을 맞춥니다. 그러나 실전에서는 공시가 갑자기 많아지는 시점 자체가 강력한 선행 지표가 됩니다.
가설: 공시 빈도의 급변(volume spike)은 기업 구조적 이벤트(M&A, 유상증자, 경영권 분쟁)의 전조 현상이다. 공시 내용을 해석하지 않아도, 타이밍과 빈도만으로 이벤트 리스크를 감지할 수 있다.
이 가설을 검증하기 위해, 피처 엔지니어링 Phase 5A에서 30일 롤링 윈도우 기반 5개 공시 피처를 설계했습니다. 각 피처는 공시의 양(count), 시점(recency), 감성(sentiment), 유형(type), 변화율(momentum)을 포착합니다.
공시 빈도 변화 → 이벤트 리스크 신호 평소: 월 1건 갑자기: 월 5건 ┌─────────────┐ ┌─────────────┐ │ . │ │ . . . . . │ │ │ ───▶ │ │ │ volume_change 0.0 │ volume_change 4.0 └─────────────┘ └─────────────┘ → M&A 준비? 유상증자?
2. 5개 공시 피처 정의
_merge_disclosure_features() 메서드가 생성하는 5개 피처의 정의입니다. 모든 피처는 각 거래일(date) 기준으로 독립 계산됩니다.
| 피처명 | 설명 | 타입 | 범위 |
| disclosure_count_30d | 최근 30일간 공시 건수 | int | 0 ~ |
| days_since_disclosure | 마지막 공시로부터 경과일 | int / NULL | 0 ~ / NULL |
| disclosure_sentiment | 30일 공시 제목 평균 센티먼트 | float / NULL | -1.0 ~ +1.0 |
| disclosure_type_score | 30일 공시 유형 가중치 평균 | float / NULL | 0.0 ~ 1.0 |
| disclosure_volume_change | 공시 빈도 변화율 (30d vs 이전 30d) | float / NULL | -1.0 ~ (무한) |
type_score 가중치: DART 공시 유형별로 주가 영향력을 사전에 매핑합니다. 실적 공시, 주요사항보고(M&A, 유상증자)는 높은 점수(0.8~1.0), 정기보고서는 낮은 점수(0.2)를 부여합니다. 데이터가 없으면 기본값 0.2를 사용합니다.
3. 롤링 윈도우 타임라인
각 거래일(feat_date)에 대해 두 개의 30일 윈도우를 비교합니다. 현재 윈도우(window_30d)와 이전 윈도우(window_prev30d)의 건수 차이로 disclosure_volume_change를 계산합니다.
feat_date = 2026-03-11 인 경우 60일 전 30일 전 오늘 | | | 2026-01-10 2026-02-09 2026-03-11 |═══════════════════════|═════════════════════| window_prev30d window_30d [date-60 ~ date-30) [date-30 ~ date] count_prev30d = 1 count_30d = 5 volume_change = (5 - 1) / 1 = 4.0 (400% 증가) 타임라인 시각화 (. = 공시 1건) | . | . . . . . | | prev window (1건) | current window (5건)| └─────────────────────────┴──────────────────────┘ 30 days 30 days
이렇게 슬라이딩 윈도우가 매일 1일씩 이동하면서, 각 거래일마다 독립적인 공시 활동도 스냅샷을 생성합니다. 60일 전 데이터가 필요하므로, 공시 조회 시 lookback_start = min_date - timedelta(days=60)으로 여유 있게 가져옵니다.
4. 계산 알고리즘
4.1 전체 흐름
_merge_disclosure_features()는 feature_store의 날짜 목록(df)을 순회하며 각 날짜에 대한 5개 피처를 계산합니다.
def _merge_disclosure_features(df, market, code, session): # 1. 공시 데이터 조회 (60일 lookback) lookback_start = min_date - timedelta(days=60) disc_rows = repo.get_disclosures_for_features( market, code, lookback_start, max_date ) # 2. 공시 DataFrame 생성 disc_df = DataFrame([{ "date": d.date, "type_score": d.type_score or 0.2, # 기본값 0.2 "sentiment_score": d.sentiment_score, } for d in disc_rows]) # 3. 각 거래일에 대해 피처 계산 for feat_date in df["date"]: d30_start = feat_date - timedelta(days=30) d60_start = feat_date - timedelta(days=60) # 현재 30일 윈도우 window_30d = disc_df[d30_start <= date <= feat_date] # 이전 30일 윈도우 window_prev30d = disc_df[d60_start <= date < d30_start] count_30d = len(window_30d) count_prev30d = len(window_prev30d)
4.2 개별 피처 계산
# ── disclosure_count_30d ── disclosure_count_30d = len(window_30d) # ── days_since_disclosure ── recent = disc_df[disc_df["date"] <= feat_date] if not recent.empty: last_disc_date = recent["date"].max() days_since = (feat_date - last_disc_date).days else: days_since = None # 공시 이력 자체가 없음 # ── disclosure_sentiment ── if count_30d > 0: sent_vals = window_30d["sentiment_score"].dropna() avg_sent = sent_vals.mean() if not sent_vals.empty else None else: avg_sent = None # ── disclosure_type_score ── avg_type = window_30d["type_score"].mean() if count_30d > 0 else None # ── disclosure_volume_change ── if count_prev30d > 0: vol_change = (count_30d - count_prev30d) / count_prev30d elif count_30d > 0: vol_change = 1.0 # 이전 0건 → 100% 증가 else: vol_change = None # 양쪽 모두 0건
4.3 반올림 및 병합
# 정밀도 제어 disclosure_sentiment = round(avg_sent, 4) # 소수점 4자리 disclosure_type_score = round(avg_type, 2) # 소수점 2자리 disclosure_volume_change = round(vol_change, 4) # 소수점 4자리 # DataFrame 생성 후 원본에 left join disc_feat_df = DataFrame(disc_features) df = df.merge(disc_feat_df, on="date", how="left") # 컬럼 보장: 공시 데이터가 아예 없는 종목도 컬럼은 존재 for col in _DISC_COLS: if col not in df.columns: df[col] = None
5. Edge Case — Zero Denominator & Graceful NULL
공시 데이터는 종목마다 편차가 큽니다. 상장 직후 기업은 공시가 없을 수 있고, 지주회사는 자회사 공시까지 포함하면 월 수십 건이 됩니다. 이런 편차를 안전하게 처리하는 것이 핵심입니다.
5.1 volume_change 분기 로직
| count_prev30d | count_30d | volume_change | 해석 |
| 3 | 6 | 1.0 | 100% 증가 — 정상 계산 |
| 5 | 2 | -0.6 | 60% 감소 — 활동 둔화 |
| 0 | 3 | 1.0 | 이전 0건 → fallback 1.0 (100% 증가) |
| 0 | 0 | NULL | 양쪽 모두 0건 → 계산 불가 |
# Zero denominator 방어 로직 (실제 코드) if count_prev30d > 0: vol_change = (count_30d - count_prev30d) / count_prev30d elif count_30d > 0: vol_change = 1.0 # 분모 0 방지: "무에서 유" = 100% else: vol_change = None # 둘 다 0 = 정보 없음 = NULL
5.2 Graceful NULL 패턴
공시 이력이 전혀 없는 종목(예: 상장 직후, 스팩)은 5개 피처가 모두 NULL로 채워집니다. 이는 의도적인 설계로, ML 모델(XGBoost, LightGBM)이 NULL을 자체적으로 처리하도록 위임합니다.
# 공시 데이터가 아예 없으면 → 5개 피처 모두 NULL if not disc_rows: for col in _DISC_COLS: df[col] = None return df # _DISC_COLS = [ # "disclosure_count_30d", # "days_since_disclosure", # "disclosure_sentiment", # "disclosure_type_score", # "disclosure_volume_change", # ]
NULL vs 0 선택 이유: disclosure_count_30d = 0은 “공시를 확인했지만 0건”이라는 의미입니다. 반면 NULL은 “공시 이력 자체를 확인할 수 없다”는 의미입니다. 트리 기반 모델은 이 두 가지를 다른 분기로 처리할 수 있으므로, 의미적 구분을 유지합니다.
6. 실제 시나리오 — 정상 vs 이상
실제 한국 주식 시장에서 관찰되는 두 가지 패턴을 비교합니다. 피처 값의 조합이 어떤 시그널을 나타내는지 살펴봅니다.
6.1 시나리오 비교 테이블
| 피처 | 정상 (안정 기업) | 이상 (이벤트 전조) | 무공시 종목 |
| disclosure_count_30d | 1 | 5 | NULL |
| days_since_disclosure | 15 | 2 | NULL |
| disclosure_sentiment | +0.12 | -0.35 | NULL |
| disclosure_type_score | 0.20 | 0.75 | NULL |
| disclosure_volume_change | 0.0 | 4.0 | NULL |
| 해석 | 정기보고 1건, 평온 | 고중요도 공시 폭증 + 부정 센티먼트 | 정보 부재 |
6.2 이상 시나리오 해석
“이상” 열의 피처 조합을 해석해 보겠습니다:
- count_30d = 5, volume_change = 4.0 → 이전 30일 대비 공시 건수 400% 급증
- days_since = 2 → 이틀 전에도 공시 → 연속 공시 진행 중
- sentiment = -0.35 → 공시 제목이 부정적 (“유상증자 결정”, “소송 제기” 등)
- type_score = 0.75 → 주요사항보고 중심 (주가 영향 큰 유형)
ML 모델 관점: 이 5개 피처는 개별적으로도 유의미하지만, 조합될 때 더 강력합니다. 트리 기반 모델은 “volume_change > 2.0 AND type_score > 0.5 AND sentiment < 0” 같은 복합 조건을 자동으로 학습합니다.
6.3 정보 공백 시나리오
반대로, days_since_disclosure가 극단적으로 큰 경우도 주목할 만합니다.
시나리오: 60일간 공시 없음 disclosure_count_30d = 0 days_since_disclosure = 60 disclosure_sentiment = None disclosure_type_score = None disclosure_volume_change = None → "정보 공백" 자체가 피처 → 장기 무공시 = 경영진 침묵 = 불확실성 피처로 활용
7. 파이프라인 통합 & 코드 위치
7.1 피처 엔지니어링 파이프라인 내 위치
공시 피처는 전체 피처 엔지니어링의 Phase 5A에 해당합니다. Phase 1(기술적 지표) ~ Phase 4(뉴스 센티먼트)를 거친 DataFrame에 공시 피처 5개를 추가 병합합니다.
compute_features() 내부 호출 순서 Phase 1 _compute_all_features() # 가격 + 기술적 지표 (25개) Phase 2 _merge_fundamental_features() # 재무 피처 (9개) Phase 3 _merge_macro_features() # 거시 피처 (11개) Phase 4 _merge_news_features() # 뉴스 센티먼트 (5개) Phase 5A _merge_disclosure_features() # 공시 피처 (5개) ← HERE Phase 5B _merge_supply_demand_features() # 수급 피처 (5개) Phase 6A _merge_sector_features() # 섹터/상대강도 (7개) Phase 6B _merge_news_refined_features() # 뉴스 정제 (3개) Phase 7 _merge_alternative_features() # 대안 데이터 (6개) 총 76개 피처 → feature_store 테이블
7.2 코드 구조
| 파일 | 역할 |
| app/ml/feature_engineer.py | _merge_disclosure_features() — 5개 피처 계산 및 병합 로직 |
| app/repositories/disclosure_repository.py | get_disclosures_for_features() — 기간별 공시 조회 쿼리 |
| app/models/ml.py | FeatureStore 모델 — 5개 피처 컬럼 정의 |
| app/data_collector/disclosure_fetcher.py | DART API에서 공시 수집 → type_score, sentiment_score 산출 |
7.3 데이터 흐름 요약
┌─────────────────────────────────────────────────────────────────┐ │ DART OpenAPI │ │ 공시 제목 + 유형 + 날짜 수집 │ └───────────────────────────┬─────────────────────────────────────┘ ▼ ┌───────────────────────────┴─────────────────────────────────────┐ │ M1: NLP 분석 │ │ KR-FinBert-SC → sentiment_score │ │ 키워드 분류 → type_score │ └───────────────────────────┬─────────────────────────────────────┘ ▼ ┌───────────────────────────┴─────────────────────────────────────┐ │ M2: Feature Engineer (Phase 5A) │ │ _merge_disclosure_features() │ │ 30d Rolling Window → 5 features per date │ └───────────────────────────┬─────────────────────────────────────┘ ▼ ┌───────────────────────────┴─────────────────────────────────────┐ │ feature_store (PostgreSQL) │ │ market + code + date + 76 features │ └───────────────────────────┬─────────────────────────────────────┘ ▼ ┌───────────────────────────┴─────────────────────────────────────┐ │ M3: ML Training / Prediction │ │ XGBoost, LightGBM → BUY / HOLD / SELL Signal │ └─────────────────────────────────────────────────────────────────┘
설계 원칙: 공시 피처는 “내용 해석”보다 “빈도 패턴”에 집중합니다. 내용 해석은 NLP 모델의 한계(한국어 금융 도메인의 모호성)가 있지만, 빈도 변화는 명확한 수치 신호입니다. 두 관점을 독립 피처로 분리해 ML 모델이 스스로 최적의 가중치를 학습하도록 했습니다.
'Project > 🚀 AI Finance 2026' 카테고리의 다른 글
| #16 [모듈2] Feature Engineering (0) | 2026.03.13 |
|---|---|
| #15 [모듈1-9] pytrends + BS4 — 대안 데이터 (0) | 2026.03.13 |
| #13 [모듈1-7] DART 공시 7유형 자동 분류 + 감성 분석 (0) | 2026.03.09 |
| #12 [모듈1-6] opendartreader + 규칙 기반 공시 분류 (0) | 2026.03.06 |
| #11 [모듈1-5] SNR 개선 - 뉴스 관련성 필터링 (0) | 2026.03.04 |