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

#16 [모듈2] Feature Engineering

by 로띠 2026. 3. 13.

1. 모듈 개요

M2 피처 엔지니어링 모듈은 M0/M1에서 수집·분석한 원시 데이터를 ML 학습에 사용할 수 있는 75개 수치 피처로 변환하는 파이프라인이다. 7단계(Phase)에 걸쳐 기술적 지표, 펀더멘털, 거시경제, 뉴스 센티먼트, 공시/수급, 섹터/상대강도, 대안 데이터를 하나의 DataFrame으로 병합하고, feature_store 테이블에 적재한다. Phase 6은 섹터 피어 데이터가 필요하므로 2-Pass 아키텍처로 동작한다.

항목 수치
총 피처 수 75개 + 타겟 4개 (1일/5일 수익률 + 분류)
Phase 7단계 (기술 23 → 재무 +9 → 거시 +11 → 뉴스 +5 → 공시/수급 +11 → 섹터/뉴스정제 +10 → 대안 +6)
데이터 소스 9개 테이블 (stock_price, stock_fundamental, financial_statement, macro_indicator, news_sentiment, dart_disclosure, krx_supply_demand, google_trends, naver_community)
출력 feature_store 테이블 (종목×일자별 79컬럼)
증분 모드 마지막 계산일 이후 신규 데이터만 처리 (기본 활성)
아키텍처 2-Pass (Pass 1: Phase 1~5+7, Pass 2: Phase 6 섹터/뉴스정제)
핵심 파일 feature_engineer.py (~1,100줄)

2. 아키텍처 — 7-Phase 파이프라인 (2-Pass)

FeatureEngineer.compute_all()이 2-Pass로 전체 파이프라인을 오케스트레이션한다. Pass 1에서 compute_features()가 종목별 Phase 1~5+7을 실행하고, Pass 2에서 _compute_phase6_only()가 섹터 피어 데이터를 활용해 Phase 6을 계산한다. 각 Phase는 이전 Phase의 DataFrame에 새 컬럼을 추가(left join)하는 방식으로 누적된다.

stock_price 테이블 — OHLCV (date, open, high, low, close, volume)
Phase 1: 기술적 지표 — 23개 피처
수익률 · 변동성 · SMA/EMA · RSI · MACD · 볼린저밴드 · OBV · 파생
left join on date
Phase 2: 펀더멘털 — +9개 = 32개
PER · PBR · EPS · 시총 · 외국인비율 · 기관/외인 순매수 · ROE · 부채비율
left join + pivot
Phase 3: 거시경제 — +11개 = 43개
환율 · VIX · KOSPI · S&P500 · 금리 · 유가 · 금 · CPI
left join on date
Phase 4: 뉴스 센티먼트 — +5개 = 48개
종목 감성 · 뉴스량 · 감성 분산 · 시장 감성 · 시장 뉴스량
rolling window 30d/60d
Phase 5: 공시 + 수급 — +11개 = 59개
공시 건수 · 공시 감성 · 유형 가중치 · 공매도 · 프로그램 매매
left join on date
Phase 7: 대안 데이터 — +6개 = 65개
Google Trends 검색량 · 검색 모멘텀 · 커뮤니티 게시글/댓글 · 참여도 · 종합 지수
Pass 1 완료 → feature_store 저장
Phase 6A: 섹터/상대강도 — +7개 = 72개
섹터 수익률 · 상대강도 · 모멘텀 순위 · 시장 폭 (피어 데이터 필요 → Pass 2)
peer data lookup
Phase 6B: 뉴스 정제 — +3개 = 75개
뉴스 관련도 비율 · 필터링 센티먼트 · 섹터 뉴스 센티먼트
feature_store 테이블 — 75 피처 + 4 타겟 + 메타데이터

오케스트레이션 코드

# FeatureEngineer.compute_all() — 2-Pass 오케스트레이션

── Pass 1/2: Phase 1~5 + 7 (종목별) ──

# compute_features(include_phase6=False)
# 1. StockPrice 조회 (lookback 120일 포함)
rows = session.query(StockPrice).filter(...)

# 2. Phase 1 — 기술적 지표 계산
features_df = self._compute_all_features(df, target_days=[1, 5])

# 3. Phase 2 — 재무 피처 병합
features_df = self._merge_fundamental_features(features_df, market, code, session)

# 4. Phase 3 — 거시경제 피처 병합
features_df = self._merge_macro_features(features_df, session)

# 5. Phase 4 — 뉴스 센티먼트 피처 병합
features_df = self._merge_news_features(features_df, market, code, session)

# 6. Phase 5A — 공시 피처 병합
features_df = self._merge_disclosure_features(features_df, market, code, session)

# 7. Phase 5B — 수급 피처 병합
features_df = self._merge_supply_demand_features(features_df, market, code, session)

# 8. Phase 7 — 대안 데이터 피처 병합
features_df = self._merge_alternative_features(features_df, market, code, session)

# 9. feature_store 저장 (Upsert)
repo.upsert_features(records)

── Pass 2/2: Phase 6 (섹터 피어 데이터 활용) ──

# _compute_phase6_only()
# 1. feature_store에서 동일 섹터 피어 데이터 로드
# 2. Phase 6A — 섹터/상대강도 피처 계산
features_df = self._merge_sector_features(features_df, market, code, session)

# 3. Phase 6B — 뉴스 정제 피처 계산
features_df = self._merge_news_refined_features(features_df, market, code, session)

# 4. feature_store 업데이트 (Phase 6 컬럼만 upsert)
repo.upsert_features(phase6_records)

3. Phase 1: 기술적 지표 (23개)

OHLCV 데이터에서 가격 파생 → 이동평균 → 모멘텀 → 변동성 → 거래량 → 파생 시그널 순서로 23개 기술적 지표를 계산한다. 모든 지표는 indicators/ 모듈의 표준 금융 공식을 사용한다.

3.1 가격 파생 (5개)

피처 산출 방법 윈도우
return_1d close.pct_change(1) 1일
return_5d close.pct_change(5) 5일
return_20d close.pct_change(20) 20일
volatility_20d close.pct_change().rolling(20).std() 20일
volume_ratio volume / volume.rolling(20).mean() 20일

3.2 이동평균 (5개)

# SMA (Simple Moving Average)
sma_5  = close.rolling(5).mean()
sma_20 = close.rolling(20).mean()
sma_60 = close.rolling(60).mean()   # ← lookback 120일 필요 이유

# EMA (Exponential Moving Average)
ema_12 = close.ewm(span=12, adjust=False).mean()
ema_26 = close.ewm(span=26, adjust=False).mean()

3.3 모멘텀 — RSI + MACD (4개)

# RSI (Relative Strength Index) — Wilder's smoothing
delta = close.diff()
gain  = delta.where(delta > 0, 0.0)
loss  = (-delta).where(delta < 0, 0.0)
avg_gain = gain.ewm(alpha=1/14, min_periods=14).mean()
avg_loss = loss.ewm(alpha=1/14, min_periods=14).mean()
rsi_14 = 100 - (100 / (1 + avg_gain / avg_loss))

# MACD (12-26-9)
macd           = EMA(close, 12) - EMA(close, 26)
macd_signal    = EMA(macd, 9)
macd_histogram = macd - macd_signal

3.4 변동성 — 볼린저밴드 (5개)

# Bollinger Bands (20일, 2σ)
bb_middle = close.rolling(20).mean()
bb_std    = close.rolling(20).std()
bb_upper  = bb_middle + bb_std * 2.0
bb_lower  = bb_middle - bb_std * 2.0

# 파생 지표
bb_width = (bb_upper - bb_lower) / bb_middle  # 밴드 폭 비율
bb_pctb  = (close - bb_lower) / (bb_upper - bb_lower)  # %B 위치

3.5 거래량 + 파생 시그널 (4개)

피처 산출 방법 설명
obv On-Balance Volume 누적 상승일 +volume, 하락일 -volume
price_to_sma20 close / SMA(20) 이격도 — 1.0 초과 시 과매수
price_to_sma60 close / SMA(60) 장기 이격도
golden_cross (SMA5 > SMA20) → 1, else 0 골든크로스 여부
rsi_zone RSI < 30 → -1, 30~70 → 0, > 70 → 1 과매도/중립/과매수 구간

4. Phase 2: 펀더멘털 (9개)

두 소스에서 재무 피처를 가져와 서로 다른 전략으로 병합한다. KIS API(일별)는 left join, DART 재무제표(분기별)는 forward-fill로 일별 데이터에 투영한다.

피처 소스 주기 병합 전략
per KIS API 일간 left join on date
pbr KIS API 일간 left join on date
eps KIS API 일간 left join on date
market_cap KIS API 일간 left join on date
foreign_ratio KIS API 일간 left join on date
inst_net_buy KIS API 일간 left join on date
foreign_net_buy KIS API 일간 left join on date
roe DART 재무제표 분기 forward-fill
debt_ratio DART 재무제표 분기 forward-fill
# DART 재무제표 forward-fill 전략
# 분기 데이터(3/6/9/12월)를 다음 분기까지 유지
for _, stmt_row in stmt_df.iterrows():
    mask = df["date"] >= stmt_row["period_date"]
    df.loc[mask, "roe"] = stmt_row["roe"]
    df.loc[mask, "debt_ratio"] = stmt_row["debt_ratio"]
# 예: 2024-03 ROE 12.5% → 2024-03-01~2024-05-31 전체에 12.5% 적용
#     2024-06 ROE 13.1% → 2024-06-01~ 부터 13.1% 적용

5. Phase 3: 거시경제 (11개)

macro_indicator 테이블(EAV 구조)을 피벗(행→열 변환)하여 날짜 기준으로 병합한다. indicator_name을 피처 컬럼명으로 매핑하는 딕셔너리를 사용한다.

indicator_name (DB) 피처 컬럼 소스 주기
KRW_USD krw_usd yfinance 일간
VIX vix yfinance 일간
KOSPI kospi_index yfinance 일간
SP500 sp500 yfinance 일간
US_10Y us_10y yfinance 일간
KR_3Y kr_3y FDR 일간
WTI wti yfinance 일간
GOLD gold yfinance 일간
FED_RATE fed_rate FRED API 일간
USD_INDEX usd_index FRED API 일간(주간 평균)
US_CPI us_cpi FRED API 월간 → forward-fill

EAV → Pivot 변환: macro_indicator 테이블은 EAV(Entity-Attribute-Value) 구조로, 모든 지표가 (date, indicator_name, value) 형태로 저장된다. Feature Engineer는 이를 df.pivot(index="date", columns="indicator_name", values="value")로 피벗한 뒤, 컬럼명을 _MACRO_COLUMN_MAP으로 매핑한다. CPI만 월별 발표이므로 forward-fill을 적용한다.

6. Phase 4~5: 센티먼트 + 공시/수급 (16개)

Phase 4~5는 M1(NLP 분석)과 M0(수급 수집)의 결과를 피처로 변환한다. 상세 산출 로직은 M1 문서에 기술했으므로, 여기서는 피처 병합 전략에 집중한다.

Phase 4: 뉴스 센티먼트 (5개)

피처 집계 방식 병합
news_sentiment 종목별 일별 AVG(sentiment_score) left join on date
news_volume 종목별 일별 COUNT(id) left join on date
news_sentiment_std 종목별 일별 STDDEV(sentiment_score) left join on date
market_sentiment 시장 전체(code=NULL) AVG(score) left join on date
market_news_volume 시장 전체 COUNT(id) left join on date

Phase 5A: 공시 (5개)

피처 산출 방식 윈도우
disclosure_count_30d 최근 30일 공시 건수 30일
days_since_disclosure 마지막 공시 이후 경과일 -
disclosure_sentiment 30일 공시 제목 평균 감성 30일
disclosure_type_score 30일 공시 유형 가중치 평균 30일
disclosure_volume_change (count_30d - count_prev30d) / count_prev30d 60일

Phase 5B: 수급 (6개)

피처 소스 병합
short_selling_volume KIS TR (FHPST04830000) left join on date
short_selling_ratio KIS TR left join on date
program_buy_volume KIS TR (FHPPG04650201) left join on date
program_sell_volume KIS TR left join on date
program_net_volume buy - sell (파생) 계산

7. Phase 6: 섹터/상대강도 + 뉴스 정제 (10개)

Phase 6은 2-Pass 아키텍처의 Pass 2에서 실행된다. Pass 1에서 저장된 동일 섹터 피어의 feature_store 데이터를 활용하여 상대적 위치와 뉴스 품질을 계산한다.

Phase 6A: 섹터/상대강도 (7개)

피처 산출 방식 윈도우
sector_return_1d 동일 섹터 피어 return_1d 평균 1일
sector_return_5d 동일 섹터 피어 return_5d 평균 5일
relative_strength_1d 종목 return_1d - sector_return_1d 1일
relative_strength_5d 종목 return_5d - sector_return_5d 5일
relative_strength_20d 종목 return_20d - 섹터 return_20d 평균 20일
sector_momentum_rank 섹터 내 return_5d 백분위 순위 (0~1) 5일
sector_breadth 섹터 내 return_1d > 0인 종목 비율 1일

Phase 6B: 뉴스 정제 (3개)

피처 산출 방식 병합
news_relevance_ratio 직접 관련 뉴스 수 / 전체 뉴스 수 left join on date
news_sentiment_filtered 관련도 높은 뉴스만의 AVG(sentiment_score) left join on date
sector_news_sentiment 동일 섹터 뉴스 센티먼트 평균 peer data lookup

왜 Pass 2인가? 섹터 상대강도(relative_strength)는 같은 섹터에 속한 모든 종목의 return 데이터가 필요하다. Pass 1에서 모든 종목의 Phase 1~5를 feature_store에 저장한 뒤, Pass 2에서 피어 데이터를 조회하여 계산한다. 증분 모드에서는 Pass 1에서 갱신된 종목 + sector_return_1d IS NULL인 종목만 처리한다.

8. Phase 7: 대안 데이터 (6개)

Phase 7은 전통적 금융 데이터 외의 대안 데이터(Alternative Data)를 피처로 변환한다. Google Trends 검색량과 네이버 커뮤니티 활성도를 통해 시장 관심도를 정량화한다.

피처 소스 설명
google_trend_score Google Trends API 종목 키워드 검색 관심도 (0~100)
google_trend_momentum Google Trends API 검색 관심도 변화율
community_post_volume 네이버 커뮤니티 종목 관련 게시글 수
community_comment_volume 네이버 커뮤니티 종목 관련 댓글 수
community_engagement_ratio 파생 댓글 수 / 게시글 수
alternative_activity_index 파생 트렌드 + 커뮤니티 종합 활성도 지수

외부 API 격리: Phase 7은 Google Trends API와 네이버 커뮤니티 크롤링에 의존한다. API 장애 시에도 Phase 1~6 피처에 영향을 주지 않도록 독립 Phase로 격리했으며, 데이터가 없는 경우 Graceful NULL 패턴이 적용된다.

9. 전체 75개 피처 목록

# Phase 1: 기술적 지표 (23개)
PHASE1_FEATURE_COLUMNS = [
    "return_1d", "return_5d", "return_20d", "volatility_20d", "volume_ratio",
    "sma_5", "sma_20", "sma_60", "ema_12", "ema_26",
    "rsi_14", "macd", "macd_signal", "macd_histogram",
    "bb_upper", "bb_middle", "bb_lower", "bb_width", "bb_pctb",
    "obv",
    "price_to_sma20", "price_to_sma60", "golden_cross", "rsi_zone",
]

# Phase 2: + 펀더멘털 (9개) = 32개
PHASE2_FEATURE_COLUMNS = PHASE1 + [
    "per", "pbr", "eps", "market_cap",
    "foreign_ratio", "inst_net_buy", "foreign_net_buy",
    "roe", "debt_ratio",
]

# Phase 3: + 거시경제 (11개) = 43개
PHASE3_FEATURE_COLUMNS = PHASE2 + [
    "krw_usd", "vix", "kospi_index",
    "us_10y", "kr_3y", "sp500", "wti", "gold",
    "fed_rate", "usd_index", "us_cpi",
]

# Phase 4: + 뉴스 센티먼트 (5개) = 48개
PHASE4_FEATURE_COLUMNS = PHASE3 + [
    "news_sentiment", "news_volume", "news_sentiment_std",
    "market_sentiment", "market_news_volume",
]

# Phase 5: + 공시/수급 (11개) = 59개
PHASE5_FEATURE_COLUMNS = PHASE4 + [
    "disclosure_count_30d", "days_since_disclosure",
    "disclosure_sentiment", "disclosure_type_score",
    "disclosure_volume_change",
    "short_selling_volume", "short_selling_ratio",
    "program_buy_volume", "program_sell_volume", "program_net_volume",
]

# Phase 6: + 섹터/상대강도 + 뉴스 정제 (10개) = 69개
PHASE6_FEATURE_COLUMNS = PHASE5 + [
    "sector_return_1d", "sector_return_5d",
    "relative_strength_1d", "relative_strength_5d", "relative_strength_20d",
    "sector_momentum_rank", "sector_breadth",
    "news_relevance_ratio", "news_sentiment_filtered", "sector_news_sentiment",
]

# Phase 7: + 대안 데이터 (6개) = 75개
PHASE7_FEATURE_COLUMNS = PHASE6 + [
    "google_trend_score", "google_trend_momentum",
    "community_post_volume", "community_comment_volume",
    "community_engagement_ratio", "alternative_activity_index",
]

# 타겟 변수 (4개)
TARGET_COLUMNS = [
    "target_class_1d", "target_class_5d",     # 분류 (0/1)
    "target_return_1d", "target_return_5d",   # 회귀 (수익률)
]

타겟 변수 생성: target_return_Nd = close.shift(-N) / close - 1로 N일 후 수익률을 계산하고, target_class_Nd = (return > threshold) → 1, else 0으로 이진 분류 라벨을 생성한다. 기본값은 1일/5일이며 target_days 파라미터로 커스터마이징 가능하다. Update: target_threshold 파라미터가 추가되어 분류 기준을 조정할 수 있다 (기본값 0.0으로 하위 호환성 유지).

10. 엔지니어링 패턴

8.1 증분 모드 (Incremental)

기본 활성 상태. 매일 전체 히스토리를 재계산하지 않고, 마지막 계산일 이후 신규 데이터만 처리한다.

# 증분 모드 동작
last_feature = repo.get_latest_features(market, code)
last_date = last_feature.date  # e.g. 2024-12-20

# 지표 연속성을 위해 lookback 구간부터 조회
lookback_start = last_date - timedelta(days=120)
# → 2024-08-23부터 StockPrice 조회
# → 전체 구간 피처 계산 후, last_date 이후 행만 DB 저장

features_df = features_df[features_df["date"] > last_date]
# → 2024-12-21~ 신규분만 upsert

왜 120일을 다시 조회하는가? SMA(60)이 안정적인 값을 갖으려면 60영업일(≈120캘린더일)의 가격 데이터가 필요하다. 마지막 계산일 다음 날만 조회하면 SMA/EMA/RSI 등의 초기값이 NaN이 되므로, 충분한 lookback을 확보한 뒤 신규 행만 저장한다.

8.2 Graceful NULL

Phase 2~7 피처가 없는 종목/날짜에도 파이프라인이 중단되지 않는다.

# 모든 Phase merge 메서드의 공통 패턴
if data_rows:
    df = df.merge(data_df, on="date", how="left")  # 데이터 있으면 병합
else:
    for col in required_columns:
        df[col] = None  # 데이터 없으면 NULL 컬럼 추가

# ML 모델들(RF/XGB/LGBM)이 NaN을 자체 처리하므로
# 별도 imputation 없이 NULL 그대로 전달

8.3 누적 병합 전략

병합 전략 대상 설명
left join on date 일별 데이터 (KIS, 거시, 뉴스, 수급) 날짜 기준 단순 병합. 데이터 없는 날 = NULL
forward-fill 분기 데이터 (DART ROE/부채비율), 월별 (CPI) 최신 값을 다음 발표까지 유지
pivot 거시경제 (EAV → 컬럼형) 행 구조를 열 구조로 변환
rolling window 공시 (30일/60일 윈도우) 날짜별 슬라이딩 윈도우 집계

8.4 최소 데이터 요구

# StockPrice 60행 미만 → 피처 계산 skip
if len(rows) < 60:
    logger.warning(f"데이터 부족: {market}:{code} ({len(rows)}행, 최소 60행 필요)")
    return 0

# SMA60에 60 영업일 필요 → 120 캘린더일 lookback
_LOOKBACK_CALENDAR_DAYS = 120

11. 코드 구조 & 통계

역할 파일 라인 핵심 함수
피처 엔지니어 ml/feature_engineer.py ~1,100 compute_all(), compute_features(), _compute_phase6_only()
이동평균 indicators/moving_average.py ~50 calc_sma(), calc_ema()
모멘텀 indicators/momentum.py 76 calc_rsi() (Wilder's), calc_macd()
변동성 indicators/volatility.py 32 calc_bollinger_bands()
거래량 indicators/volume.py ~30 calc_obv()
ML Repository repositories/ml_repository.py ~200 upsert_features(), get_latest_features()
Feature Store 모델 models/ml.py ~100 FeatureStore (SQLAlchemy)

합계: 약 1,500줄. 핵심은 feature_engineer.py (~1,100줄)이며, indicators/ 모듈(~190줄)이 기술적 지표 계산을 분담한다. 모든 Phase별 merge 메서드가 동일한 패턴(조회 → left join → NULL 보장)을 따르므로, 새 Phase 추가 시 메서드 하나만 추가하면 된다. Phase 6은 2-Pass 아키텍처로 _compute_phase6_only()에서 별도 처리된다.

파일 트리

app/
├── ml/
│   ├── feature_engineer.py    # 7-Phase 피처 파이프라인 (~1,100줄, 2-Pass)
│   ├── trainer.py             # ML 학습 (→ M3 영역)
│   ├── predictor.py           # 예측 추론 (→ M3 영역)
│   ├── tuner.py               # Optuna 하이퍼파라미터 (→ M3)
│   └── training_scheduler.py  # 학습 스케줄링 (→ M3)
├── indicators/
│   ├── __init__.py            # calc_sma/ema/rsi/macd/bb/obv export
│   ├── moving_average.py      # SMA, EMA
│   ├── momentum.py            # RSI (Wilder's), MACD (12-26-9)
│   ├── volatility.py          # Bollinger Bands (20d, 2σ)
│   └── volume.py              # OBV
├── repositories/
│   └── ml_repository.py       # feature_store CRUD
└── models/
    └── ml.py                  # FeatureStore 테이블 정의

12. ADR (Architecture Decision Records)

M2 피처 엔지니어링 모듈의 핵심 아키텍처 의사결정을 기록한다. 각 ADR은 맥락(Context) → 결정(Decision) → 근거(Rationale) → 결과(Consequences) 구조로 작성되었다.

ADR-M2-001

2-Pass 아키텍처로 섹터 피어 의존성 해결

Context

Phase 6(섹터/상대강도)은 동일 섹터에 속한 피어 종목들의 수익률 데이터가 필요하다. 삼성전자의 sector_return_1d를 구하려면 SK하이닉스, LG전자 등 피어들의 Phase 1 데이터가 먼저 계산되어야 한다. 모든 종목이 동시에 피어 데이터를 요구하므로 단일 Pass로는 순환 의존이 발생한다.

Decision

파이프라인을 2-Pass로 분리한다.

Pass 실행 Phase 설명
Pass 1 Phase 1~5 + 7 종목별 독립 피처 계산 → feature_store 저장
Pass 2 Phase 6A + 6B feature_store에서 피어 데이터 로드 → 섹터/뉴스정제 피처 계산

Rationale

대안 장점 단점 기각 사유
Single Pass + 메모리 캐시 코드 단순 350종목 전체를 메모리에 보관 메모리 사용량 과다 (350 × 75col × 250row)
토폴로지컬 정렬 이론적 최적 섹터 간 순환 → 정렬 불가 구조적 한계
2-Pass (채택) 의존성 깔끔 해소, DB 기반 DB I/O 1회 추가

Consequences

긍정: Phase 6을 선택적으로 재계산할 수 있어 디버깅이 용이하다. 피어 데이터가 DB에서 조회되므로 메모리 사용량이 예측 가능하다.
부정: 전체 파이프라인 실행 시 DB 쓰기→읽기가 1회 추가된다. Phase 6 단독 실행 시 Pass 1 데이터가 feature_store에 있어야 한다.

ADR-M2-002

120일 Lookback 증분 파이프라인

Context

350종목 × 250영업일 × 75피처를 매일 전체 재계산하면 종목당 10~15초, 전체 약 80분이 소요된다. 일상적인 데이터 업데이트에서는 신규 1~5일치만 추가되므로, 전체 재계산은 비효율적이다.

Decision

증분 모드를 기본 활성화한다. 마지막 계산일로부터 120 캘린더일(≈80영업일) 전까지 가격 데이터를 조회하고, 전체 구간의 피처를 계산한 뒤 신규 행만 DB에 저장한다.

# 증분 모드 핵심 로직
last_date = repo.get_latest_features(market, code).date
lookback_start = last_date - timedelta(days=120)

# lookback 구간 포함 조회 → 전체 계산 → 신규분만 저장
rows = query(StockPrice).filter(date >= lookback_start)
features_df = compute(rows)
features_df = features_df[features_df["date"] > last_date]  # 신규만

Rationale

120일 선택 근거: SMA(60)이 안정적 값을 가지려면 최소 60영업일(≈84캘린더일) 데이터가 필요하다. 공휴일/연휴를 감안하여 120 캘린더일로 설정했다. 60일로 줄이면 장기 연휴 후 SMA/EMA 초기값이 NaN이 되고, 180일로 늘리면 불필요한 조회량이 증가한다.

Consequences

성능 개선: 종목당 2~5초 (80~90% 감소). 전체 350종목 약 15~20분.
트레이드오프: 종목당 ~2KB의 중복 계산이 발생하지만, 이동평균/RSI 등의 연속성 정확도를 보장한다. 전체 재계산이 필요한 경우 incremental=False 옵션으로 강제 전체 실행 가능.

ADR-M2-003

Graceful NULL 전략 (Zero-Fill 거부)

Context

Phase 2~7에서 특정 종목/날짜에 외부 데이터(재무, 뉴스, 공시 등)가 없는 경우가 빈번하다. 신규 상장 종목은 뉴스가 적고, 소형주는 공시가 드물며, Google Trends 데이터가 수집 시작 전이면 아예 없다. 이런 "데이터 부재"를 어떻게 처리할지 결정해야 한다.

Decision

데이터가 없으면 NULL(None)을 유지한다. 0으로 대체하지 않는다.

# 모든 _merge_*() 메서드의 공통 패턴
if data_rows:
    df = df.merge(data_df, on="date", how="left")
else:
    for col in required_columns:
        df[col] = None  # ← NULL, not 0

Rationale

전략 의미 문제점
Zero-Fill 뉴스 감성 = 0 → "완전 중립" 뉴스 없음 ≠ 중립. 모델이 "0=중립" 패턴을 잘못 학습
Mean-Fill 평균값으로 대체 피처 엔지니어링 단계에서 통계적 가정 도입 → 분석 오염
NULL 유지 (채택) 데이터 없음을 그대로 표현 ML 단계에서 별도 imputation 필요

Consequences

긍정: 데이터 의미를 보존한다. Tree 모델(RF/XGB/LGBM)은 NaN을 네이티브로 처리하므로 추가 비용이 없다. M2와 M3의 관심사가 분리된다 (피처 생성 vs 결측 처리).
부정: M3에서 SimpleImputer(median) + RobustScaler 파이프라인이 별도 필요하다. DL 모델(LSTM/Transformer)은 NaN을 직접 처리하지 못하므로 전처리 필수.

ADR-M2-004

기술적 지표 직접 구현 (TA-Lib 미사용)

Context

Phase 1에서 SMA, EMA, RSI, MACD, 볼린저밴드, OBV 등 23개 기술적 지표를 계산해야 한다. Python 금융 라이브러리로 TA-Lib(C 기반), pandas-ta, ta(Pure Python) 등의 선택지가 있다.

Decision

indicators/ 모듈에서 pandas 기반으로 직접 구현한다.

Rationale

대안 장점 기각 사유
TA-Lib C 기반, 200+ 지표, 빠름 C 의존성 설치 복잡 (macOS/Linux/Docker), 계산 로직 블랙박스
pandas-ta Pure Python, 130+ 지표 불필요한 의존성 과다, 사용하는 지표 6종뿐
직접 구현 (채택) 의존성 0, 로직 투명, 디버깅 용이

Consequences

긍정: 외부 C 라이브러리 없이 pip install만으로 환경 구성 완료. Docker 이미지 크기 절감. RSI 계산 방식(Wilder's vs SMA)을 명확히 제어 가능.
부정: 새 지표 추가 시 직접 구현해야 한다. ~190줄의 코드 유지보수 부담이 있지만, 사용 지표가 6종으로 고정되어 있어 현실적 부담은 적다.

ADR-M2-005

이중 Forward-Fill — 월별/분기별 데이터 일별 보간

Context

CPI는 월별, ROE/부채비율은 분기별로 발표된다. 하지만 feature_store는 일별 행 구조이므로, 발표되지 않은 날짜의 값을 채워야 한다. 단순 forward-fill 한 번으로는 DB 집계 단계와 피처 병합 단계 사이의 공백이 채워지지 않는 경우가 있다.

Decision

2단계 forward-fill을 적용한다.

# 1단계: DB 집계 시 forward-fill
macro_df = macro_df.sort_values("date").ffill()

# 2단계: left join 후 forward-fill
features_df = features_df.merge(macro_df, on="date", how="left")
features_df[macro_cols] = features_df[macro_cols].ffill()

Rationale

1단계에서 macro_indicator 테이블 내의 공백을 채우고, 2단계에서 stock_price 기준 날짜 그리드와 merge 후 남은 공백을 채운다. 보간(interpolation)은 미래 정보를 사용할 위험이 있으므로 채택하지 않았다.

Consequences

긍정: 월별/분기별 데이터가 다음 발표일까지 유지되어 일별 피처 행에 빈 값이 없다. 미래 데이터 누수(data leakage) 위험이 없다.
부정: 발표일 전에도 같은 값이 반복되므로, 모델이 "변동 없음"과 "데이터 부재"를 구분하지 못한다. 그러나 월/분기 데이터 특성상 실제로도 그 기간 동안 값이 동일하므로 실질적 왜곡은 없다.