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

#18 [모듈2-2] 결측 데이터 전략 - Graceful NULL + 이중 Forward-Fill

by 로띠 2026. 3. 13.
피처 엔지니어링

결측 데이터 전략 — Graceful NULL + 이중 Forward-Fill

ML 피처 파이프라인에서 NULL을 다루는 세 가지 원칙

Data Collect NLP Analysis Feature Eng. Feature Store ML Signal

이 문서는 AI_Finance(ALCHEMETRIC) 프로젝트의 피처 엔지니어링 파이프라인에서 결측 데이터(NULL)를 다루는 세 가지 원칙을 다룹니다. 350+ 종목 × 7개 데이터 소스를 병합하는 과정에서, 데이터가 없는 종목에 대해 0으로 채우지 않고 NULL을 유지하는 전략과 월별 지표에 대한 이중 Forward-Fill 기법을 설명합니다.

1. 문제 정의 — 350+ 종목, 7개 소스, 불가피한 결측

Alchemetric의 피처 엔지니어링 파이프라인은 단일 종목에 대해 7개 데이터 소스를 병합하여 최대 75개 피처를 생성합니다. 그러나 350+ 종목 모두가 7개 소스의 데이터를 완전히 갖추고 있지는 않습니다.

데이터 소스 Phase 결측 발생 원인
가격/기술적 지표 Phase 1 lookback 부족 (상장 60일 미만)
펀더멘털/재무제표 Phase 2 비상장 전환, DART 미등록, 스팩
거시경제지표 Phase 3 월별/분기별 발표 → 거래일 갭
뉴스 센티먼트 Phase 4 소형주는 뉴스 자체가 없음
공시 Phase 5A DART 미등록, 공시 이력 없는 종목
수급 (공매도/프로그램) Phase 5B 소형주 공매도 데이터 미제공
대안 데이터 (트렌드/커뮤니티) Phase 7 Google Trends 미수집, 커뮤니티 미존재

이때 발생하는 두 가지 핵심 문제:

문제 1: 컬럼 누락 → 스키마 에러

  종목 A: [close, return_1d, ..., per, pbr, news_sentiment, ...]  # 75 cols
  종목 B: [close, return_1d, ...,                            ...]  # per, pbr 컬럼 자체가 없음

  → ML 학습 시 "KeyError: 'per'" → 파이프라인 중단

문제 2: 0-fill → 거짓 시그널

  disclosure_count_30d = 0   # "공시 0건" (실제로 확인했지만 없었음)
  disclosure_count_30d = 0   # "데이터 없음" (확인 자체를 못 함)

  → 두 상황을 같은 값으로 처리하면 ML 모델이 잘못된 패턴을 학습
핵심 질문: 데이터가 없는 종목의 피처를 어떻게 채워야 ML 모델이 올바르게 학습할 수 있는가? 0으로 채우면 거짓 시그널이 되고, 컬럼을 빼면 스키마가 깨진다. 정답은 NULL을 유지하되 컬럼은 반드시 존재하게 하는 것이다.

2. 원칙 1 — Graceful NULL 보장

Alchemetric의 모든 _merge_*() 함수는 동일한 패턴을 따릅니다: 데이터가 없어도 컬럼은 반드시 존재하며, 값은 NULL(None)로 채웁니다.

2.1 공통 패턴 — 컬럼 보장 루프

모든 merge 함수의 마지막에는 동일한 구조의 컬럼 보장 코드가 존재합니다.

# 공통 패턴: 모든 _merge_*() 함수의 마지막

for col in EXPECTED_COLUMNS:
    if col not in df.columns:
        df[col] = None

return df

이 패턴이 적용된 실제 코드를 각 Phase별로 살펴보겠습니다.

2.2 Phase 2 — _merge_fundamental_features()

def _merge_fundamental_features(df, market, code, session):
    """재무 피처 병합 (Phase 2)"""

    # --- 1) KIS 기초정보 병합 ---
    fund_rows = fund_repo.get_fundamentals(market=market, code=code)

    if fund_rows:
        fund_df = DataFrame([...])
        df = df.merge(fund_df, on="date", how="left")
    else:
        # 데이터 없음 → 컬럼만 추가 (NULL)
        for col in ["per", "pbr", "eps", "market_cap",
                    "foreign_ratio", "inst_net_buy", "foreign_net_buy"]:
            if col not in df.columns:
                df[col] = None

    # --- 2) DART 재무제표 (forward-fill) ---
    stmt_rows = fund_repo.get_financial_statements_for_features(...)

    if stmt_rows:
        # 분기별 데이터를 일별로 forward-fill
        ...
    else:
        for col in ["roe", "debt_ratio"]:
            if col not in df.columns:
                df[col] = None

2.3 Phase 5A — _merge_disclosure_features()

공시 함수는 이중 방어를 적용합니다: 데이터가 아예 없으면 조기 리턴하면서 NULL을 채우고, 데이터가 있더라도 마지막에 컬럼 보장을 수행합니다.

_DISC_COLS = [
    "disclosure_count_30d", "days_since_disclosure",
    "disclosure_sentiment", "disclosure_type_score",
    "disclosure_volume_change",
]

# 방어 1: 데이터 자체가 없으면 조기 리턴
if not disc_rows:
    for col in _DISC_COLS:
        df[col] = None
    return df

# ... 계산 로직 ...
df = df.merge(disc_feat_df, on="date", how="left")

# 방어 2: merge 후에도 컬럼 보장
for col in _DISC_COLS:
    if col not in df.columns:
        df[col] = None

2.4 Phase 7 — _merge_alternative_features()

_ALTERNATIVE_FEATURE_COLUMNS = [
    "google_trend_score", "google_trend_momentum",
    "community_post_volume", "community_comment_volume",
    "community_engagement_ratio", "alternative_activity_index",
]

# 대안 데이터가 없으면 → 6개 피처 모두 NULL
if not alt_rows:
    for col in _ALTERNATIVE_FEATURE_COLUMNS:
        df[col] = None
    return df

# ... 계산 로직 ...

# 마지막 컬럼 보장
for col in _ALTERNATIVE_FEATURE_COLUMNS:
    if col not in df.columns:
        df[col] = None
설계 의도: XGBoost와 LightGBM은 NULL(NaN)을 자연스럽게 처리하는 내장 메커니즘을 갖고 있습니다. 데이터가 없을 때 NULL을 유지하면 모델이 “정보 부재”라는 사실 자체를 학습할 수 있습니다. 반면 0이나 평균으로 채우면 모델이 실제 값과 대체 값을 구분할 수 없게 됩니다.

3. 원칙 2 — 이중 Forward-Fill

거시경제지표 중 월별/분기별로 발표되는 지표(예: US CPI)는 일별 주가 데이터와 주기가 다릅니다. CPI가 매월 1회 발표되면, 나머지 ~20 거래일은 값이 비어 있게 됩니다. 이 문제를 해결하기 위해 이중 Forward-Fill 전략을 사용합니다.

3.1 Forward-Fill 대상 컬럼

# 월별/분기별 지표 — forward-fill 대상 (일별 지표는 left join만으로 충분)
_MACRO_FFILL_COLUMNS = {"us_cpi"}

현재 us_cpi가 Forward-Fill 대상입니다. fed_rate 등 다른 월별 지표도 이 Set에 추가하면 자동으로 이중 ffill이 적용됩니다. krw_usd, vix 같은 일별 지표는 left join만으로 충분하므로 ffill 대상에 포함하지 않습니다.

3.2 왜 두 단계가 필요한가?

Forward-Fill을 한 번만 하면 해결되지 않는 이유는, 데이터가 두 개의 다른 시간축에 존재하기 때문입니다.

━━━ Level 1: 피벗 테이블 내 ffill ━━━

  macro_indicator 테이블 (원본):
  ┌────────────┬──────────┬───────┐
   date        name      value 
  ├────────────┼──────────┼───────┤
   2026-01-14  US_CPI    3.2     ← 1월 발표
   2026-01-14  KRW_USD   1320  
   2026-01-15  KRW_USD   1318  
   2026-02-12  US_CPI    3.1     ← 2월 발표
  └────────────┴──────────┴───────┘

  피벗 후 (Level 1 ffill 전):
  ┌────────────┬─────────┬────────┐
   date        us_cpi   krw_usd
  ├────────────┼─────────┼────────┤
   2026-01-14  3.2      1320   
   2026-01-15  NaN      1318     ← CPI는 1/14에만 존재
     ...       NaN      ...    
   2026-02-12  3.1      1325   
  └────────────┴─────────┴────────┘

  Level 1 ffill: 피벗 테이블 안에서 us_cpi를 아래로 채움
  ┌────────────┬─────────┬────────┐
   2026-01-14  3.2      1320   
   2026-01-15  3.2      1318     ← 채워짐
     ...       3.2      ...    
   2026-02-12  3.1      1325   
  └────────────┴─────────┴────────┘
━━━ Level 2: merge 후 거래일 갭 ffill ━━━

  문제: 피벗에는 거시지표가 존재하는 날짜만 있음
        주식 거래일과 거시지표 날짜가 다를 수 있음

  feature_store (left join 후):
  ┌────────────┬────────┬─────────┬────────┐
   date        close   us_cpi   krw_usd
  ├────────────┼────────┼─────────┼────────┤
   2026-01-13  50000   NaN      1319     ← CPI 발표 전
   2026-01-14  50500   3.2      1320     ← CPI 발표일
   2026-01-15  50300   NaN      1318     ← 거래일이지만 피벗에 없음
   2026-01-16  50800   NaN      1321   
  └────────────┴────────┴─────────┴────────┘

  Level 2 ffill: merge된 df에서 us_cpi를 다시 아래로 채움
  ┌────────────┬────────┬─────────┬────────┐
   2026-01-13  50000   NaN      1319     ← 발표 전이므로 NULL 유지 (정확!)
   2026-01-14  50500   3.2      1320   
   2026-01-15  50300   3.2      1318     ← 채워짐
   2026-01-16  50800   3.2      1321     ← 채워짐
  └────────────┴────────┴─────────┴────────┘

3.3 실제 구현 코드

def _merge_macro_features(df, session):
    """거시 피처 병합 (Phase 3)"""

    macro_rows = MacroRepository(session).get_all_by_date_range(...)

    if macro_rows:
        # 피벗: (date) x (indicator_name) → value
        pivot = macro_df.pivot_table(
            index="date", columns="indicator_name",
            values="value", aggfunc="first",
        )
        pivot = pivot.rename(columns=rename)

        # ━━━ Level 1: 피벗 테이블 내 ffill ━━━
        for col in _MACRO_FFILL_COLUMNS:
            if col in pivot.columns:
                pivot[col] = pivot[col].ffill()

        df = df.merge(pivot, on="date", how="left")

        # ━━━ Level 2: merge 후 거래일 갭 ffill ━━━
        for col in _MACRO_FFILL_COLUMNS:
            if col in df.columns:
                df[col] = df[col].ffill()

    # 컬럼 보장 (거시 데이터가 아예 없어도 컬럼은 존재)
    for col in _MACRO_COLUMN_MAP.values():
        if col not in df.columns:
            df[col] = None
Level 1만으로 부족한 이유: 피벗 테이블에는 거시지표가 기록된 날짜만 존재합니다. 주식 거래일(feature_store의 date)과 거시지표 날짜가 다를 수 있습니다. 예를 들어, 거시지표가 1/14에만 기록되었지만 주식은 1/13~1/16 모두 거래됩니다. left join 후 1/15, 1/16의 us_cpi는 NaN이 되므로, Level 2에서 한 번 더 ffill이 필요합니다.

3.4 이중 Forward-Fill 흐름도

이중 Forward-Fill 흐름

  ┌──────────────────┐
   macro_indicator    원본 (date, name, value)
  └────────┬─────────┘
           
  ┌──────────────────┐
   pivot_table()      가로 전환 (date × indicator)
  └────────┬─────────┘
           
  ┌──────────────────┐
   Level 1 ffill      피벗 내 월별 지표 갭 채움
   pivot[col].ffill()  (CPI: 1/14 → 2/12 사이 채움)
  └────────┬─────────┘
           
  ┌──────────────────┐
   df.merge(pivot)    주가 DataFrame과 left join
  └────────┬─────────┘
           
  ┌──────────────────┐
   Level 2 ffill      merge 후 거래일 갭 채움
   df[col].ffill()    (피벗에 없는 거래일의 NaN 채움)
  └────────┬─────────┘
           
  ┌──────────────────┐
   컬럼 보장          11개 거시 컬럼 존재 보장
  └──────────────────┘

4. 원칙 3 — NULL vs 0의 의미론

NULL과 0은 근본적으로 다른 의미를 가집니다. 이 구분을 유지하는 것이 ML 모델의 예측 품질에 직접적인 영향을 줍니다.

4.1 의미론적 차이

의미 트리 모델 처리
NULL (None / NaN) “데이터 없음” — 관측 자체가 불가능 별도 분기 경로 (default direction)
0 “실제로 0” — 관측했지만 값이 0 일반 분기 (threshold 비교)

4.2 disclosure_count_30d 예시

공시 피처에서 이 차이가 가장 극명하게 드러납니다.

시나리오 A: DART에 등록된 종목, 최근 30일 공시 0건

  disclosure_count_30d = 0     # 실제로 0건 (확인했지만 없었음)
  days_since_disclosure = 45   # 45일 전 마지막 공시
  disclosure_sentiment = None  # 30일 내 공시 없으니 센티먼트도 없음

  → 모델 해석: "이 종목은 최근 조용함, 45일 전 마지막 공시"


시나리오 B: DART에 미등록된 종목 (데이터 소스 자체 없음)

  disclosure_count_30d = None  # 데이터 자체가 없음
  days_since_disclosure = None # 알 수 없음
  disclosure_sentiment = None  # 알 수 없음

  → 모델 해석: "이 종목은 공시 데이터 추적 불가"


시나리오 C: 만약 0-fill을 했다면 (잘못된 접근)

  disclosure_count_30d = 0     # A와 B가 동일한 값!
  days_since_disclosure = 0    # "오늘 공시했다"라는 거짓 시그널
  disclosure_sentiment = 0.0   # "중립 센티먼트"라는 거짓 시그널

  → 모델이 "공시 0건"과 "데이터 없음"을 구분 불가
  → 잘못된 패턴 학습
경고: days_since_disclosure = 0으로 채우면 “오늘 공시가 있었다”는 강력한 거짓 시그널이 됩니다. 공시 당일은 주가 변동이 큰 날이므로, 모델이 이 잘못된 패턴을 학습하면 예측 정확도가 크게 하락합니다.

4.3 다른 피처의 NULL vs 0

피처 NULL 의미 0 의미
per 펀더멘털 데이터 없음 PER이 0 (적자 전환)
news_sentiment 뉴스 자체가 없는 종목 센티먼트가 정확히 중립
short_selling_ratio 공매도 데이터 미제공 공매도 비율 0% (매우 긍정적)
google_trend_score 트렌드 데이터 미수집 검색량 0 (관심 없음)
roe 재무제표 미공시 ROE 0% (수익 없음)

5. 구현 패턴 비교 — Phase별 merge 함수

각 Phase의 merge 함수가 NULL을 처리하는 방식을 비교합니다. 모든 함수는 Graceful NULL 보장 패턴을 공유하지만, 데이터 특성에 따라 세부 전략이 다릅니다.

Phase 함수 피처 수 NULL 전략 Forward-Fill
Phase 2 _merge_fundamental_features() 9개 조기 리턴 + 컬럼 보장 roe/debt_ratio만 분기별 ffill
Phase 3 _merge_macro_features() 11개 컬럼 보장 이중 ffill (Level 1 + 2)
Phase 4 _merge_news_features() 5개 컬럼 보장 없음 (일별 데이터)
Phase 5A _merge_disclosure_features() 5개 이중 방어 (조기 리턴 + 컬럼 보장) 없음 (롤링 윈도우)
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개 조기 리턴 + 컬럼 보장 없음

5.1 Phase 6A의 다단계 방어

섹터/상대강도 피처는 NULL이 발생할 수 있는 경로가 3가지입니다. 각 경로에서 모두 Graceful NULL을 보장합니다.

def _merge_sector_features(df, market, code, session):

    # 방어 1: 섹터 정보 없음
    sector = stock_repo.get_sector_for_code(code, market)
    if not sector:
        for col in _SECTOR_FEATURE_COLUMNS:
            df[col] = None
        return df

    # 방어 2: 동일 섹터 피어가 2개 미만
    sector_codes = stock_repo.get_codes_in_sector(sector, market)
    if len(sector_codes) < 2:
        for col in _SECTOR_FEATURE_COLUMNS:
            df[col] = None
        return df

    # 방어 3: feature_store에 피어 데이터 없음
    sector_rows = ml_repo.get_sector_features_bulk(...)
    if not sector_rows:
        for col in _SECTOR_FEATURE_COLUMNS:
            df[col] = None
        return df

    # ... 계산 로직 ...

    # 최종 컬럼 보장
    for col in _SECTOR_FEATURE_COLUMNS:
        if col not in df.columns:
            df[col] = None

5.2 Phase 5B의 파생 피처 NULL 처리

수급 피처에서 program_net_volumeprogram_buy_volume - program_sell_volume으로 파생됩니다. buy와 sell이 모두 NULL인 경우, net도 NULL로 설정합니다.

# program_net_volume 파생
supply_df["program_net_volume"] = (
    supply_df["program_buy_volume"].fillna(0) -
    supply_df["program_sell_volume"].fillna(0)
)

# buy/sell 모두 None인 경우 net도 None
both_null = (
    supply_df["program_buy_volume"].isna() &
    supply_df["program_sell_volume"].isna()
)
supply_df.loc[both_null, "program_net_volume"] = None
핵심 원칙: 파생 피처도 원본이 모두 NULL이면 파생 값도 NULL이어야 합니다. buy=NULL, sell=NULL일 때 net=0으로 계산하면 “프로그램 순매수 0”이라는 거짓 시그널이 됩니다.

6. 트리 모델과 NULL

Alchemetric은 예측 모델로 XGBoostLightGBM을 사용합니다. 이 두 모델은 NULL(NaN)을 자체적으로 처리하는 메커니즘을 내장하고 있어, Graceful NULL 전략과 완벽하게 호환됩니다.

6.1 XGBoost의 Default Direction

XGBoost는 트리의 각 노드에서 default direction을 학습합니다. 특정 피처가 NULL인 샘플이 왔을 때, 좌측/우측 중 어느 방향으로 보낼지 학습 데이터에서 최적의 방향을 찾습니다.

XGBoost 트리 분기 예시

                  disclosure_count_30d < 3 ?
                 /                        \
          Yes (0,1,2건)              No (3건 이상)
            /                              \
     낮은 위험 리프              disclosure_sentiment < 0 ?
                                /                    \
                         높은 위험 리프        중간 위험 리프

  NULL이 오면?
  disclosure_count_30d = NaN
  → default direction으로 분기 (학습 시 최적 방향 결정)
  → 0-fill과 다른 경로를 탈 수 있음!

6.2 LightGBM의 use_missing

LightGBM은 기본적으로 use_missing=True로 설정되어, NULL 값을 별도의 bin으로 분류합니다. 이를 통해 모델은 “값이 있는 경우”와 “값이 없는 경우”를 다른 분기로 처리할 수 있습니다.

6.3 NULL vs 0-fill vs Mean-fill 비교

전략 장점 단점 트리 모델 호환
NULL 유지 의미 보존, 별도 분기 학습 선형 모델에서 사용 불가 최적
0-fill 단순, 모든 모델 호환 거짓 시그널, 의미 왜곡 비추
Mean-fill 분포 유지 정보 손실, 분산 축소 비추
Indicator 변수 추가 명시적 결측 표시 피처 수 2배 증가 불필요 (트리 모델이 자체 처리)
Alchemetric의 선택: XGBoost/LightGBM은 NULL을 자체적으로 최적 분기에 배치합니다. 별도의 결측 지시 변수(indicator variable)를 추가할 필요가 없으므로, 75개 피처로 충분합니다. 0-fill이나 mean-fill은 모델이 “데이터 없음”이라는 정보를 학습할 기회를 박탈합니다.

7. 파이프라인 컨텍스트 — 75개 피처와 NULL 빈도

7.1 전체 피처 엔지니어링 호출 순서

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개
  Phase 5B  _merge_supply_demand_features()  # 수급 피처        5개
  Phase 7   _merge_alternative_features()    # 대안 데이터      6개
  Phase 6A  _merge_sector_features()         # 섹터/상대강도    7개
  Phase 6B  _merge_news_refined_features()   # 뉴스 정제        3개
  ──────────────────────────────────────────────
  합계                                          76개 (+ target 4개)

7.2 NULL 발생 가능 피처 분류

NULL 빈도 피처 그룹 피처 수 원인
거의 없음 Phase 1 (가격/기술지표) 25개 주가 데이터는 필수 조건
거의 없음 Phase 3 (거시지표) 11개 종목 무관, 공통 데이터
중간 Phase 2 (펀더멘털) 9개 스팩, 신규상장, DART 미등록
중간 Phase 4 (뉴스) 5개 소형주 뉴스 부재
중간 Phase 6 (섹터/뉴스 정제) 10개 섹터 미분류, 피어 부족
높음 Phase 5 (공시/수급) 10개 소형주 공매도 미제공, 공시 미등록
높음 Phase 7 (대안 데이터) 6개 Google Trends/커뮤니티 미수집

7.3 NULL 발생 가능 피처 전체 목록

Phase 2 (펀더멘털) — 9개
  per, pbr, eps, market_cap,
  foreign_ratio, inst_net_buy, foreign_net_buy,
  roe, debt_ratio

Phase 4 (뉴스) — 5개
  news_sentiment, news_volume, news_sentiment_std,
  market_sentiment, market_news_volume

Phase 5A (공시) — 5개
  disclosure_count_30d, days_since_disclosure,
  disclosure_sentiment, disclosure_type_score,
  disclosure_volume_change

Phase 5B (수급) — 5개
  short_selling_volume, short_selling_ratio,
  program_buy_volume, program_sell_volume, program_net_volume

Phase 6A (섹터/상대강도) — 7개
  sector_return_1d, sector_return_5d,
  relative_strength_1d, relative_strength_5d, relative_strength_20d,
  sector_momentum_rank, sector_breadth

Phase 6B (뉴스 정제) — 3개
  news_relevance_ratio, news_sentiment_filtered,
  sector_news_sentiment

Phase 7 (대안 데이터) — 6개
  google_trend_score, google_trend_momentum,
  community_post_volume, community_comment_volume,
  community_engagement_ratio, alternative_activity_index

NULL 가능 피처 합계: 40개 / 전체 76개 (52.6%)

7.4 DB 저장 시 NULL 처리

compute_features()의 마지막 단계에서 DataFrame의 NaN을 Python None으로 변환하여 DB에 저장합니다. PostgreSQL의 NULL과 pandas의 NaN이 정확히 매핑됩니다.

# feature_store 레코드 생성 시 NaN → None 변환

for _, row in features_df.iterrows():
    record = {"market": market, "code": code, "date": row["date"]}
    for col in features_df.columns:
        if col == "date":
            continue
        val = row[col]
        if pd.isna(val):
            record[col] = None          # NaN → NULL
        elif isinstance(val, np.integer):
            record[col] = int(val)
        elif isinstance(val, np.floating):
            record[col] = float(val)
        else:
            record[col] = val
    records.append(record)

7.5 전략 요약

Alchemetric 결측 데이터 전략 (3원칙)

  원칙 1: Graceful NULL 보장
  ┌──────────────────────────────────────────────────┐
   데이터 없음 → 컬럼은 존재 + 값은 NULL                   
   for col in COLS:                                 
       if col not in df.columns: df[col] = None     
  └──────────────────────────────────────────────────┘

  원칙 2: 이중 Forward-Fill
  ┌──────────────────────────────────────────────────┐
   Level 1: 피벗 내 ffill (월별 갭 채움)                 
   Level 2: merge 후 ffill (거래일 갭 채움)             
   대상: _MACRO_FFILL_COLUMNS = {"us_cpi"}           
  └──────────────────────────────────────────────────┘

  원칙 3: NULL vs 0 의미 보존
  ┌──────────────────────────────────────────────────┐
   NULL = "데이터 없음" → 트리 모델 별도 분기              
   0    = "실제로 0"   → 일반 threshold 비교            
   0-fill 금지, mean-fill 금지                        
  └──────────────────────────────────────────────────┘
결론: 76개 피처 중 52.6%(40개)가 NULL 가능합니다. 이 결측은 데이터 품질 문제가 아니라, 350+ 종목의 이질적인 데이터 커버리지를 반영하는 정상적인 현상입니다. Graceful NULL + 이중 Forward-Fill + NULL/0 의미 보존, 이 세 가지 원칙이 XGBoost/LightGBM의 내장 결측 처리 메커니즘과 결합되어, 데이터 결측이 모델 성능 저하로 이어지지 않도록 합니다.