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

#19 [모듈2-3] 증분 파이프라인 + Lookback 버퍼

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

증분 파이프라인 + Lookback 버퍼

SMA60을 위한 120일 버퍼, 신규 데이터만 저장하는 온라인 피처 계산

Data Collect NLP Feature Eng. ML Signal

이 글은 AI_Finance(ALCHEMETRIC) 프로젝트의 피처 엔지니어링 모듈에서 증분(incremental) 파이프라인Lookback 버퍼 설계를 다룬다. KOSPI 350종목 x 250거래일의 피처를 매일 전체 재계산하는 대신, _LOOKBACK_CALENDAR_DAYS = 120일의 버퍼를 두고 신규 데이터만 계산하여 upsert하는 구조를 상세히 분석한다.

1. 문제 정의 — 전체 재계산의 비용

ALCHEMETRIC의 피처 엔지니어링 모듈은 Phase 1부터 Phase 7까지 총 75개 피처를 계산한다. KOSPI 기준 약 350종목, 1년치 데이터가 약 250거래일이므로 전체 재계산 시 처리량은 다음과 같다.

항목 수량
종목 수 ~350
종목당 거래일 ~250일/년
피처 수 (Phase 1~7) 75개
전체 재계산 셀 수 350 x 250 x 75 = ~6,562,500

매일 시장이 끝나고 추가되는 신규 데이터는 종목당 1~2일치에 불과하다. 1~2일치를 추가하기 위해 매번 250일 x 350종목을 전부 다시 계산하는 것은 명백한 낭비다.

그러나 단순히 "오늘 데이터만" 가져와서 피처를 계산하면 문제가 생긴다. SMA60(60일 이동평균), RSI14(14일 상대강도지수), Bollinger Bands(20일) 등 롤링 지표는 과거 N일의 가격 데이터가 반드시 필요하기 때문이다.

핵심 딜레마: 전체 재계산은 너무 느리고, 당일만 계산하면 롤링 지표가 NaN이 된다.
Lookback 버퍼 전략이 이 딜레마를 해결한다. 계산에는 과거 데이터를 포함하되, 저장은 신규분만.

2. Lookback 버퍼 설계 — 왜 120일인가

피처 엔지니어링 모듈 상단에는 다음과 같은 상수가 정의되어 있다.

# SMA60이 가장 긴 lookback → 캘린더일 120일 ≈ 영업일 80일
_LOOKBACK_CALENDAR_DAYS = 120

달력일 vs 거래일

주식 시장의 거래일과 달력일은 같지 않다. 주말(토, 일) 및 공휴일을 제외하면 1달 ≈ 21~22거래일이다. SMA60은 60거래일의 데이터가 필요하므로, 달력일로 환산하면 다음과 같다.

롤링 지표 필요 거래일 달력일 환산 (약)
SMA5, return_1d 5일 ~7일
RSI14 14일 ~20일
SMA20, BB(20), volatility_20d 20일 ~28일
EMA26, MACD 26일 (+9 signal) ~50일
SMA60 60일 ~85일

SMA60이 가장 긴 lookback window이며, 달력일로 약 85일이 필요하다. 여기에 공휴일 집중 구간(설, 추석 등)에 대한 여유분을 더해 120일로 설정했다. 120달력일 ≈ 80거래일이므로 SMA60의 60거래일을 넉넉하게 커버한다.

설계 원칙: lookback 버퍼는 가장 긴 롤링 윈도우(SMA60 = 60거래일)를 기준으로 결정하고, 달력일/거래일 변환 오차와 공휴일 연속 구간을 감안하여 충분한 여유(120달력일)를 확보한다.

최소 데이터 검증

lookback 버퍼로 확장된 데이터를 가져온 후에도, 데이터가 충분한지 검증하는 안전장치가 있다.

if len(rows) < 60:
    logger.warning(
        f"데이터 부족: {market}:{code} ({len(rows)}행, 최소 60행 필요)",
        "compute_features",
    )
    return 0

최소 60행(60거래일)이 없으면 SMA60을 계산할 수 없으므로 해당 종목은 건너뛴다. 신규 상장 종목이나 거래 정지 종목이 이 조건에 해당한다.

3. 증분 계산 흐름 — compute_features()

compute_features()는 단일 종목의 피처 계산을 담당하는 핵심 메서드다. incremental=True(기본값)일 때의 흐름을 단계별로 분석한다.

Step 1: last_date 조회

먼저 feature_store에서 해당 종목의 마지막 계산일을 조회한다.

# 증분 모드: 마지막 계산일 확인
if incremental:
    last_feature = repo.get_latest_features(market, code)
    if last_feature:
        last_date = last_feature.date

get_latest_features()ORDER BY date DESC LIMIT 1로 최신 레코드를 가져온다. 기존 데이터가 없으면 last_date = None이 되어 전체 계산으로 fallback한다.

Step 2: Lookback 범위 확장

price_start = None
if last_date:
    lookback_start = last_date - timedelta(days=_LOOKBACK_CALENDAR_DAYS)
    price_start = str(lookback_start)
elif start_date:
    price_start = str(start_date)

last_date가 2025-03-10이라면, lookback_start는 2024-11-11이 된다. 이 날짜부터 가격 데이터를 조회하여 SMA60 등 롤링 지표가 정확하게 계산될 수 있도록 한다.

Step 3: 새 데이터 없으면 스킵

# 증분: 새 데이터 없으면 스킵
if last_date:
    max_price_date = rows[-1].date
    if max_price_date <= last_date:
        return 0

가격 데이터의 최신 날짜가 last_date 이하이면 새로 계산할 것이 없으므로 즉시 0을 반환한다. 이 early return 덕분에 스케줄러가 주말이나 공휴일에 실행되어도 불필요한 계산이 발생하지 않는다.

Step 4: 전체 범위에서 피처 계산

# 3. 피처 계산 (lookback 포함 전체 구간)
features_df = self._compute_all_features(df, target_days=target_days)

# 3.5~3.11: Phase 2~7 피처 병합
features_df = self._merge_fundamental_features(features_df, market, code, session)
features_df = self._merge_macro_features(features_df, session)
features_df = self._merge_news_features(features_df, market, code, session)
features_df = self._merge_disclosure_features(features_df, market, code, session)
features_df = self._merge_supply_demand_features(features_df, market, code, session)
features_df = self._merge_alternative_features(features_df, market, code, session)

이 단계에서는 lookback 버퍼를 포함한 전체 범위(120일 전 ~ 오늘)에서 모든 피처를 계산한다. SMA60, RSI14 등 롤링 지표가 과거 데이터로부터 정확하게 계산된다.

Step 5: 신규분만 필터링

# 증분: 신규분만 필터링
if last_date:
    features_df = features_df[features_df["date"] > last_date]
    if features_df.empty:
        return 0

여기가 증분 파이프라인의 핵심이다. 120일치로 계산했지만, 저장하는 것은 last_date 이후의 신규 행만이다. 나머지 lookback 구간의 데이터는 계산 정확성을 위해 사용된 후 폐기된다.

Step 6: Upsert 저장

# 5. DB 저장
saved = repo.upsert_features(records)
mode = "증분" if last_date else "전체"
logger.info(
    f"피처 저장 완료 ({mode}): {market}:{code} ({saved}행)",
    "compute_features",
)

upsert_features()ON CONFLICT (market, code, date) DO UPDATE 방식으로 동작한다. 이미 존재하는 날짜의 레코드는 업데이트하고, 새 날짜는 삽입한다. 멱등성이 보장되므로 동일한 날짜에 대해 여러 번 실행해도 안전하다.

전체 흐름 다이어그램

                 증분 파이프라인 흐름 (incremental=True)
  ───────────────────────────────────────────────────────────

  feature_store                 stock_price
  ┌─────────────┐              ┌──────────────┐
  │ last_date   │              │   전체 가격    │
  │ = 2025-3-10 │              │ 2024-11-11 ~ │
  └──────┬──────┘              └──────┬───────┘
         │                            │
         ▼                            │
  lookback_start= 3-10 - 120일= 2024-11-11                        │
         │                            │
         └─────────┐    ┌─────────────┘
                   ▼    ▼
           ┌───────────────────┐
           │ price query       │
           │ 2024-11-11 ~ 현재  │
           │ (~80거래일 + 신규)   │
           └─────────┬─────────┘
                     ▼
           ┌───────────────────┐
           │ _compute_all_     │
           │ features()        │
           │ Phase 1~7 전부 계산 │
           │ (lookback 포함)    │
           └─────────┬─────────┘
                     ▼
           ┌───────────────────┐
           │ date > last_date  │
           │ 필터링              │
           │ → 3-11, 3-12만     │
           │   남김             │
           └─────────┬─────────┘
                     ▼
           ┌───────────────────┐
           │ upsert_features() │
           │ 2행만 INSERT/UPDATE│
           └───────────────────┘

4. 롤링 지표 정합성 — SMA60 예시

SMA60(60일 단순이동평균)을 예시로 lookback 버퍼의 필요성을 구체적으로 살펴보자.

시나리오: 2025-03-11 피처 계산

feature_store의 마지막 계산일이 2025-03-10이고, 3월 11일의 새 가격 데이터가 들어왔다고 가정하자.

방식 가져오는 데이터 SMA60 결과
lookback 없음 3-11 하루만 NaN (데이터 1행, 60행 필요)
last_date부터 3-10 ~ 3-11 (2행) NaN (데이터 2행, 60행 필요)
lookback 120일 2024-11-11 ~ 3-11 (~80거래일) 정확한 값 (60행 이상)

3월 11일의 SMA60을 정확히 계산하려면 60거래일 전인 대략 12월 초의 종가부터 필요하다. 120달력일(약 80거래일)을 가져오면 SMA60뿐 아니라 SMA20, RSI14, MACD, Bollinger Bands 등 모든 롤링 지표가 정확한 값을 갖게 된다.

코드에서의 피처 계산

# _compute_all_features() 내부

# === 가격 피처 ===
result["return_1d"] = df["close"].pct_change(1)
result["return_5d"] = df["close"].pct_change(5)
result["return_20d"] = df["close"].pct_change(20)
result["volatility_20d"] = df["close"].pct_change().rolling(20).std()

# === 기술적 지표 (가장 긴 window: SMA60) ===
sma60 = calc_sma(df, 60)["sma"]     # ← 60거래일 필요!
result["sma_60"] = sma60

# === 파생 피처 ===
result["price_to_sma60"] = df["close"] / sma60.replace(0, np.nan)
result["golden_cross"] = (sma5 > sma20).astype(int)
요약: lookback 버퍼 덕분에 pandas의 .rolling(60)이 충분한 행을 보고 연산할 수 있다. 3월 11일 행의 SMA60은 120일 lookback으로 가져온 80거래일 중 마지막 60거래일의 평균 종가가 된다. 이 값은 전체 재계산과 100% 동일하다.

5. 타겟 변수 동적 생성 — _compute_targets

피처 엔지니어링 파이프라인은 피처뿐 아니라 타겟 변수(label)도 함께 계산한다. ML 학습에 사용할 미래 수익률과 등락 분류를 생성하는 로직이 _compute_all_features() 내부에 포함되어 있다.

# === 타겟 변수 (동적 생성) ===
for days in target_days:          # 기본값: [1, 5]
    col_return = f"target_return_{days}d"
    col_class = f"target_class_{days}d"

    # 미래 N일 후 수익률 = (미래 종가 / 현재 종가) - 1
    result[col_return] = df["close"].shift(-days) / df["close"] - 1

    # 상승 = 1, 하락 = 0
    result[col_class] = (result[col_return] > 0).astype("Int64")
    result.loc[result[col_return].isna(), col_class] = pd.NA

target_days 파라미터

target_days는 리스트로 전달되며, 기본값은 [1, 5]이다. 이를 통해 다음 4개의 타겟 컬럼이 동적으로 생성된다.

컬럼명 설명 타입
target_return_1d 1일 후 수익률 float (-0.05 ~ +0.05 등)
target_class_1d 1일 후 등락 (상승=1, 하락=0) Int64 (nullable)
target_return_5d 5일 후 수익률 float
target_class_5d 5일 후 등락 (상승=1, 하락=0) Int64 (nullable)

shift(-N)과 자연스러운 NULL

df["close"].shift(-5)는 현재 행에서 5행 뒤의 종가를 가져온다. 데이터의 마지막 5행은 아직 미래 데이터가 존재하지 않으므로 자연스럽게 NaN이 된다.

  date        close    shift(-5)   target_return_5d  target_class_5d
  ──────────  ───────  ──────────  ────────────────  ───────────────
  2025-03-05  50,000   51,500      +0.030            1
  2025-03-06  50,200   52,000      +0.036            1
  2025-03-07  49,800   NaN         NULL              NULL
  2025-03-10  50,100   NaN         NULL              NULL
  2025-03-11  51,500   NaN         NULL              NULL
학습 시 주의: target_class_1d IS NOT NULL 조건으로 필터링해야 한다. MLRepository.get_features_by_market()에서 이미 이 필터가 적용되어 있다. 예측(inference)에는 최신 행(타겟이 NULL인)을 사용한다.

6. 성능 비교 — 전체 vs 증분

compute_features()incremental 파라미터에 따른 동작 차이를 비교한다.

항목 incremental=False (전체) incremental=True (증분)
DB 조회 전체 기간 가격 데이터 last_date - 120일 ~ 현재
계산 범위 전체 기간 (250일+) ~80거래일 (lookback 포함)
저장 행 수 250행 (전체) 1~2행 (신규분만)
350종목 기준 87,500행 INSERT/UPDATE 350~700행 INSERT/UPDATE
스킵 로직 없음 새 데이터 없으면 즉시 return 0

incremental=False가 필요한 경우

증분 모드가 기본이지만, 다음 상황에서는 incremental=False로 전체 재계산이 필요하다.

상황 이유
새 피처 컬럼 추가 기존 레코드에 새 컬럼 값이 NULL. 전체 재계산으로 과거 행도 채워야 한다.
피처 계산 로직 변경 RSI 계산 방식 변경 등. 과거 값도 새 로직으로 다시 계산해야 일관성 유지.
과거 가격 데이터 수정 수정된 종가가 롤링 지표에 영향. 증분으로는 과거 행이 업데이트되지 않는다.
Phase 업그레이드 Phase 5 → Phase 6 전환 시 기존 데이터에 섹터/상대강도 피처가 없음.
운영 가이드: 일상적인 스케줄러 실행에는 incremental=True(기본값)를 사용한다. 스키마 변경이나 로직 수정 후에는 어드민 패널에서 incremental=False로 1회 전체 재계산을 수행한다.

7. 코드 구조 & 파이프라인 컨텍스트

compute_all(): 2-Pass 아키텍처

compute_all()은 마켓 전체 종목의 피처를 계산하는 오케스트레이터로, 2-Pass 구조를 사용한다.

def compute_all(self, market, codes=None, ..., incremental=True):
    """마켓 전체 종목의 피처를 계산 (2-pass)"""

    # Pass 1: Phase 1~5 + Phase 7 피처 계산
    logger.info(f"Pass 1/2: Phase 1~5 피처 계산 ({total}종목)")
    for code in codes:
        count = self.compute_features(
            market, code, ...,
            incremental=incremental,
            include_phase6=False,   # Phase 6은 Pass 2에서
        )
        if count > 0:
            phase1_updated_codes.add(code)

    # Pass 2: Phase 6 섹터/뉴스정제 피처
    phase6_targets = self._get_phase6_targets(...)
    for code in phase6_targets:
        self._compute_phase6_only(market, code, ...)

Phase 6(섹터 상대강도, 뉴스 정제)은 동일 섹터 피어 종목들의 Phase 1~5 데이터가 필요하므로, 모든 종목의 Phase 1~5가 완료된 후에 별도 Pass로 실행된다.

_compute_phase6_only(): 증분 지원

Phase 6 계산도 증분을 지원한다. sector_return_1d가 NULL인 레코드만 필터링하여 처리한다.

def _compute_phase6_only(self, market, code, ...):
    # feature_store에서 Phase 1~5 데이터 로드
    features = ml_repo.get_features(market, code, ...)

    # Phase 6 미계산 레코드만 처리 (sector_return_1d가 NULL)
    features = [f for f in features if f.sector_return_1d is None]

    # Phase 6A: 섹터/상대강도
    df = self._merge_sector_features(df, market, code, session)

    # Phase 6B: 뉴스 정제
    df = self._merge_news_refined_features(df, market, code, session)

    # Phase 6 컬럼만 추출하여 upsert
    saved = ml_repo.upsert_features(records)

_get_phase6_targets(): 증분 모드의 타겟 결정

증분 모드에서 Pass 2의 대상 종목을 결정하는 로직이다. Pass 1에서 신규 데이터가 있던 종목과, Phase 6 컬럼이 아직 NULL인 종목을 합집합으로 처리한다.

def _get_phase6_targets(self, market, all_codes, updated_codes, incremental):
    if not incremental:
        return all_codes          # 전체 모드: 전종목

    # Pass 1에서 갱신된 종목은 무조건 포함
    targets = set(updated_codes)

    # Phase 6 미계산 종목 추가 (sector_return_1d가 NULL인 종목)
    codes_with_phase6 = MLRepository(session).get_codes_with_phase6(market, all_codes)
    for code in all_codes:
        if code not in codes_with_phase6:
            targets.add(code)

    return [c for c in all_codes if c in targets]

Upsert 메커니즘 (MLRepository)

upsert_features()는 PostgreSQL의 ON CONFLICT DO UPDATE 또는 SQLite의 동등 구문을 사용한다. unique constraint는 (market, code, date) 복합키이다.

def upsert_features(self, records: list[dict]) -> int:
    """피처 데이터 Upsert"""
    # records의 키에서 market, code, date를 제외한 나머지가 update 대상
    sample = records[0]
    update_fields = [k for k in sample.keys()
                     if k not in ("market", "code", "date")]

    return self._upsert(
        FeatureStore, records,
        "uq_feature_store",          # constraint name
        ["market", "code", "date"],   # index_elements (SQLite)
        update_fields,
        extra_set={"created_at": datetime.now()},
    )

이 설계 덕분에 Phase 6의 _compute_phase6_only()가 Phase 6 컬럼만 담은 records를 upsert하면, 기존 Phase 1~5 데이터는 보존되면서 Phase 6 컬럼만 업데이트된다. update_fields가 동적으로 결정되기 때문이다.

전체 파이프라인에서의 위치

    ┌─────────────────────────────────────────────────────────────────┐
    │              ALCHEMETRIC 75-Feature Pipeline                    │
    ├─────────────────────────────────────────────────────────────────┤
    │                                                                 │
    │  Scheduler (매일 18:30)                                          │
    │       │                                                         │
    │       ▼                                                         │
    │  full_collect()                                                 │
    │       │                                                         │
    │       ├── [1] 가격/재무/거시/뉴스/공시 수집                            │
    │       │                                                         │
    │       ├── [2] NLP 센티먼트 분석                                    │
    │       │                                                         │
    │       ├── [3] FeatureEngineer.compute_all(incremental=True)     
    │       │        ├── Pass 1: Phase 1~5,7 (종목별 증분)               │
    │       │        └── Pass 2: Phase 6 (섹터/뉴스정제)                 │
    │       │                                                         │
    │       └── [4] ML 학습/예측                                        │
    │                                                                 │
    └─────────────────────────────────────────────────────────────────┘

75개 피처 Phase 구성

Phase 카테고리 피처 수 증분 전략
Phase 1 가격/기술지표/파생 24 Lookback 120일
Phase 2 재무 (PER, PBR, ROE 등) 9 date join + forward-fill
Phase 3 거시경제 11 date join + forward-fill
Phase 4 뉴스 센티먼트 5 date join
Phase 5 공시 + 수급 10 60일 lookback window
Phase 6 섹터 상대강도 + 뉴스 정제 10 Pass 2 별도 실행
Phase 7 대안 데이터 (트렌드, 커뮤니티) 6 10일 lookback
설계 요약: 증분 파이프라인의 핵심은 "계산은 넓게, 저장은 좁게"이다. Lookback 버퍼로 롤링 지표의 정확성을 보장하면서, 최종 저장은 신규 데이터만으로 제한한다. 이를 통해 매일 실행되는 스케줄러가 전체 재계산 대비 수백 배 적은 DB 쓰기를 수행하면서도, 결과의 정합성은 전체 재계산과 100% 동일하게 유지된다.