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

#20 [모듈2-4] 피어 비교 피처 — 섹터 상대강도 + 복합 인덱스

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

피어 비교 피처 합성 — 섹터 상대강도 + 대안 데이터 복합 인덱스

여러 소스를 하나의 시그널로 압축하는 벡터화 전략

Data Collect NLP Feature Eng. ML Signal

이 글은 AI_Finance(ALCHEMETRIC) 프로젝트에서 개별 종목 피처만으로는 포착할 수 없는 "시장 대비 상대적 위치"를 수치화하기 위해 도입한 Phase 6A(섹터 상대강도), Phase 6B(뉴스 정제), Phase 7(대안 데이터 복합 인덱스)의 합성 전략을 다룹니다. 동일 섹터 피어 데이터를 벌크 로드한 뒤 groupby 벡터화 연산으로 16개 피처를 계산하는 과정과, Google Trends와 커뮤니티 데이터를 단일 activity index로 압축하는 fallback 집계 설계를 코드 수준에서 분석합니다.

1. 문제 정의 — 절대 수익률의 한계와 통합 인덱스의 필요성

Phase 1~5까지의 피처 파이프라인은 개별 종목의 절대적 속성만 다룬다. return_1d, rsi_14, news_sentiment 등은 모두 "이 종목이 어제 얼마나 올랐는가", "과매수/과매도 구간인가"를 알려주지만, "동일 업종 내에서 상대적으로 강한가 약한가"는 알 수 없다.

절대 수익률 vs 상대강도

시나리오 종목 수익률 섹터 평균 상대강도 해석
A: 반도체 호황 +3.2% +4.5% -1.3% 올랐지만 업종 대비 약세
B: 시장 조정기 -1.0% -2.8% +1.8% 떨어졌지만 업종 대비 강세
절대 수익률만으로는 시나리오 A의 종목을 "좋다"고 판단하고, B를 "나쁘다"고 판단한다. 하지만 섹터 상대강도를 추가하면, A는 업종 대비 뒤처지고 있고, B는 방어적으로 선방 중임을 알 수 있다. 이것이 Phase 6A가 필요한 이유다.

한편 대안 데이터(Google Trends, 네이버 종목 커뮤니티)는 전통 가격/재무 데이터가 놓치는 시장 참여자의 관심도 변화를 포착한다. 그러나 소스별 커버리지가 다르다. Google Trends는 주간 단위로만 발표되고, 커뮤니티 데이터는 소형주에서 게시글이 아예 없는 경우가 많다. 두 소스를 개별 피처로만 쓰면 NULL이 난무한다. 이를 단일 복합 인덱스로 압축하되, 한쪽만 존재해도 유효한 값을 생성하는 fallback 집계가 Phase 7의 핵심이다.

2. Phase 6A — 섹터 상대강도 7피처

_merge_sector_features() 메서드는 feature_store에 이미 저장된 동일 섹터 종목들의 수익률을 벌크 조회하여 7개 피처를 계산한다.

데이터 흐름

종목코드 → StockRepository.get_sector_for_code()로 섹터 조회 → get_codes_in_sector()로 동일 섹터 종목 리스트 획득 → MLRepository.get_sector_features_bulk()로 전체 수익률 일괄 로드.

벌크 조회 (MLRepository)

def get_sector_features_bulk(self, market, codes, start_date, end_date):
    """섹터 내 종목들의 수익률 + 뉴스 센티먼트 벌크 조회 (Phase 6)"""
    return (
        self.session.query(
            FeatureStore.code,
            FeatureStore.date,
            FeatureStore.return_1d,
            FeatureStore.return_5d,
            FeatureStore.return_20d,
            FeatureStore.news_sentiment,
        )
        .filter(
            FeatureStore.market == market,
            FeatureStore.code.in_(codes),      # 섹터 전종목 한 번에
            FeatureStore.date >= start_date,
            FeatureStore.date <= end_date,
        )
        .order_by(FeatureStore.date)
        .all()
    )

핵심은 code.in_(codes) 한 번의 쿼리로 섹터 전체 종목의 수익률을 가져온다는 점이다. 종목별로 쿼리를 반복하지 않는다.

groupby 집계 + 상대강도 계산

# 1) 날짜별 섹터 집계
sector_agg = sector_df.groupby("date").agg(
    sector_return_1d  = ("return_1d", "mean"),
    sector_return_5d  = ("return_5d", "mean"),
    _sector_return_20d= ("return_20d", "mean"),
    _positive_count   = ("return_1d", lambda x: (x > 0).sum()),
    _total_count      = ("return_1d", "count"),
).reset_index()

# 2) 시장폭 = 상승 종목 / 전체 종목
sector_agg["sector_breadth"] = (
    sector_agg["_positive_count"] / sector_agg["_total_count"]
)

# 3) 상대강도 = 내 종목 수익률 - 섹터 평균
sector_feat["relative_strength_1d"] = (
    sector_feat["return_1d"] - sector_feat["sector_return_1d"]
)
sector_feat["relative_strength_5d"] = (
    sector_feat["return_5d"] - sector_feat["sector_return_5d"]
)
sector_feat["relative_strength_20d"] = (
    sector_feat["return_20d"] - sector_feat["_sector_return_20d"]
)

모멘텀 순위 (퍼센타일 랭크)

# 날짜별로 해당 종목의 5일 수익률이 섹터 내 몇 번째인지
for date_val in sector_feat["date"].unique():
    day_returns = sector_df.loc[
        sector_df["date"] == date_val, "return_5d"
    ].dropna()
    my_val = my_row["return_5d"].iloc[0]
    rank = float((day_returns < my_val).sum()) / len(day_returns)
    # rank = 0.0 (최하위) ~ 1.0 (최상위)
sector_momentum_rank = 0.85이면 섹터 내 85%의 종목보다 5일 수익률이 높다는 뜻이다. 이 값이 높으면서 sector_breadth가 낮다면, 섹터 전체는 약세인데 혼자 강한 "이탈 시그널"이 된다.

7피처 요약

피처명 범위 계산
sector_return_1d 실수 섹터 전종목 return_1d의 mean
sector_return_5d 실수 섹터 전종목 return_5d의 mean
relative_strength_1d 실수 종목 return_1d - sector_return_1d
relative_strength_5d 실수 종목 return_5d - sector_return_5d
relative_strength_20d 실수 종목 return_20d - 섹터 return_20d 평균
sector_momentum_rank 0 ~ 1 섹터 내 return_5d 퍼센타일 순위
sector_breadth 0 ~ 1 섹터 내 return_1d > 0인 종목 비율

3. 벡터화 vs 루프 — bulk-load + groupby 전략

Phase 6A의 피처를 계산하는 가장 직관적인 방법은 종목별 루프다. 350종목, 250거래일이라면 매 종목마다 섹터 피어를 조회하고 수익률을 계산한다. 이 방식의 문제는 명확하다.

두 접근의 차이

항목 종목별 루프 bulk-load + groupby
DB 쿼리 수 350 × N (섹터당) 1회 (IN 절)
집계 연산 Python for-loop pandas groupby (C 벡터화)
메모리 종목당 할당/해제 반복 한 번에 로드
캐시 효율 낮음 (난수 접근) 높음 (연속 배열)

Alchemetric의 구현은 동일 섹터 종목의 feature_store 레코드를 한 번에 로드한 뒤, pandas groupby("date").agg()로 섹터 평균, 상승 종목 수, 전체 종목 수를 한 번의 연산으로 계산한다. 상대강도 역시 DataFrame 브로드캐스트 연산이므로 루프가 없다.

# 벌크 조회: 섹터 전종목 × 전기간을 단일 쿼리로
sector_rows = ml_repo.get_sector_features_bulk(
    market, sector_codes, min_date, max_date,
)

# DataFrame 변환 → groupby 집계
sector_df = pd.DataFrame([{
    "code": r.code,
    "date": r.date,
    "return_1d": float(r.return_1d) if r.return_1d is not None else None,
    "return_5d": float(r.return_5d) if r.return_5d is not None else None,
    "return_20d": float(r.return_20d) if r.return_20d is not None else None,
} for r in sector_rows])

# pandas C 엔진이 벡터화 집계 수행
sector_agg = sector_df.groupby("date").agg(...).reset_index()
설계 원칙: 섹터 피처는 본질적으로 "같은 날 같은 섹터 종목들의 집계"이다. 집계 대상(피어)이 변하지 않으므로 한 번의 벌크 쿼리 + 한 번의 groupby가 최적이다. 종목 수가 늘어도 DB 쿼리 수는 1로 고정된다.

단, sector_momentum_rank 계산에서는 날짜별 루프가 남아 있다. 각 날짜의 섹터 내 수익률 분포에서 현재 종목의 퍼센타일을 구하는 연산은 groupby만으로 표현하기 어렵기 때문이다. 그러나 루프 내부에서 DB를 조회하지 않으므로, 이미 메모리에 올라온 DataFrame 위에서 NumPy 비교 연산만 수행한다.

4. Phase 6B — 뉴스 정제 3피처

_merge_news_refined_features()는 Phase 4의 뉴스 센티먼트를 관련성 필터링으로 정제하고, 섹터 수준의 뉴스 분위기를 추가한다. Phase 4의 news_sentiment를 대체하지 않고 보완한다.

3피처 계산 흐름

피처명 범위 데이터 소스 설명
news_relevance_ratio 0 ~ 1 news_sentiment 테이블 제목에 종목명이 포함된 기사 비율
news_sentiment_filtered -1 ~ 1 news_sentiment 테이블 제목에 종목명 포함 기사만의 센티먼트 평균
sector_news_sentiment -1 ~ 1 feature_store (피어) 동일 섹터 전종목 news_sentiment 평균

관련성 필터링 (SQL CASE 기반)

뉴스 관련성 판별은 NewsRepository.get_daily_sentiment_filtered()에서 SQL 레벨로 처리한다. NER이나 ML 분류기 없이, "뉴스 제목에 종목명이 포함되어 있는가"라는 단순 휴리스틱을 SQL CASE 문으로 구현한다.

# news_repository.py
is_relevant = NewsSentiment.title.contains(stock_name)

rows = self.session.query(
    NewsSentiment.date,
    func.count(NewsSentiment.id).label("total_count"),
    func.sum(case((is_relevant, 1), else_=0)).label("filtered_count"),
    func.avg(
        case((is_relevant, NewsSentiment.sentiment_score), else_=None)
    ).label("filtered_avg_score"),
).filter(...).group_by(NewsSentiment.date)

# 결과 변환
news_relevance_ratio   = filtered_count / total_count
news_sentiment_filtered = filtered_avg_score

섹터 뉴스 센티먼트

섹터 뉴스 센티먼트는 Phase 6A와 동일한 get_sector_features_bulk()를 재사용한다. 이번에는 return_* 대신 news_sentiment 컬럼을 groupby로 평균한다.

# 동일 벌크 쿼리, 다른 컬럼 사용
sector_rows = ml_repo.get_sector_features_bulk(
    market, sector_codes, min_date, max_date,
)

s_df = pd.DataFrame([{
    "date": r.date,
    "news_sentiment": float(r.news_sentiment) if r.news_sentiment is not None else None,
} for r in sector_rows])

sector_sent = s_df.dropna(subset=["news_sentiment"]).groupby("date").agg(
    sector_news_sentiment=("news_sentiment", "mean"),
).reset_index()
설계 포인트: get_sector_features_bulk()는 return_1d/5d/20d와 news_sentiment를 함께 반환하므로, Phase 6A와 6B에서 동일 쿼리를 재사용할 수 있다. DB 접근을 최소화하는 설계다.

5. Phase 7 — 대안 데이터 복합 인덱스 6피처

_merge_alternative_features()는 Google Trends와 네이버 종목 커뮤니티 데이터를 6개 피처로 변환한다. 핵심은 두 소스의 커버리지 격차를 선택적 집계(fallback)로 해결하는 alternative_activity_index다.

Google Trends 피처 (2개)

# 1) google_trend_score: 0~100 원시값 → 0~1 정규화
alt_df["google_trend_score"] = alt_df["trend_interp"] / 100.0

# 2) google_trend_momentum: (현재 - 5일전) / max(5일전, 1)
shifted = alt_df["trend_interp"].shift(5)
alt_df["google_trend_momentum"] = (
    (alt_df["trend_interp"] - shifted) / shifted.clip(lower=1.0)
)

Google Trends는 주간 단위로 발표되므로 google_trend_interpolated (보간 값)을 사용한다. shift(5)로 5일 전 값과 비교하여 모멘텀을 계산하며, 0으로 나누는 것을 방지하기 위해 clip(lower=1.0)을 적용한다.

커뮤니티 피처 (3개)

# 3) community_post_volume: log1p 변환 (스파이크 완화)
alt_df["community_post_volume"] = np.log1p(alt_df["post_count"].fillna(0))
alt_df.loc[alt_df["post_count"].isna(), "community_post_volume"] = None

# 4) community_comment_volume: log1p 변환
alt_df["community_comment_volume"] = np.log1p(alt_df["comment_count"].fillna(0))
alt_df.loc[alt_df["comment_count"].isna(), "community_comment_volume"] = None

# 5) community_engagement_ratio: 댓글 / 게시글
alt_df["community_engagement_ratio"] = None
valid_posts = alt_df["post_count"].notna() & (alt_df["post_count"] > 0)
alt_df.loc[valid_posts, "community_engagement_ratio"] = (
    alt_df.loc[valid_posts, "comment_count"].fillna(0)
    / alt_df.loc[valid_posts, "post_count"]
)
log1p 변환: 커뮤니티 게시글 수는 대형주(수백 건)와 소형주(0~2건)의 편차가 극심하다. log1p(x) = ln(1+x)를 적용하면 스파이크가 완화되면서 0을 그대로 0으로 유지한다. 원본이 NULL인 경우에는 변환 후에도 NULL을 복원한다.

alternative_activity_index — 선택적 집계

이 피처가 Phase 7의 핵심이다. Google Trends와 커뮤니티 데이터를 0~1 범위의 단일 값으로 합산하되, 한쪽 소스가 없어도 유효한 값을 생성한다.

# 정규화
trend_norm = alt_df["google_trend_score"]         # 이미 0~1
post_max   = post_vol.max() if post_vol.notna().any() else 1.0
post_norm  = post_vol / post_max                  # 0~1로 정규화

# 3가지 조건 분기
both       = trend_norm.notna() & post_norm.notna()
trend_only = trend_norm.notna() & post_norm.isna()
post_only  = trend_norm.isna()  & post_norm.notna()

alt_df["alternative_activity_index"] = None
alt_df.loc[both, ...]       = 0.5 * trend_norm + 0.5 * post_norm   # 두 소스 모두 존재
alt_df.loc[trend_only, ...] = trend_norm                          # 트렌드만 존재
alt_df.loc[post_only, ...]  = post_norm                           # 커뮤니티만 존재

Fallback 전략 도해

Google Trends 커뮤니티 계산 방식 가중치
존재 존재 0.5 × trend + 0.5 × post 동일 가중
존재 NULL trend_norm 그대로 trend_only fallback
NULL 존재 post_norm 그대로 post_only fallback
NULL NULL NULL 데이터 없음
왜 fallback이 필요한가? Google Trends는 검색량이 일정 기준 미만이면 0을 반환하지만, 커뮤니티에서는 활발히 논의될 수 있다(소형 바이오주 등). 반대로 대형주는 Trends 데이터가 풍부하지만 네이버 커뮤니티에 게시글이 없을 수 있다. 한쪽만 있어도 NULL 대신 유효한 activity 값을 제공하는 것이 모델의 입력 커버리지를 높인다.

6. 16개 합성 피처 한눈에

Phase 6A(7개) + Phase 6B(3개) + Phase 7(6개) = 총 16개의 합성 피처가 기존 Phase 1~5의 49개 피처에 추가된다. 아래 테이블은 전체 합성 피처의 소스, 범위, 의미를 정리한 것이다.

Phase 피처명 범위 데이터 소스 의미
6A sector_return_1d 실수 feature_store (피어) 섹터 평균 1일 수익률
sector_return_5d 실수 feature_store (피어) 섹터 평균 5일 수익률
relative_strength_1d 실수 종목 - 섹터 평균 1일 상대강도
relative_strength_5d 실수 종목 - 섹터 평균 5일 상대강도
relative_strength_20d 실수 종목 - 섹터 평균 20일 상대강도
sector_momentum_rank 0 ~ 1 섹터 내 퍼센타일 5일 수익률 기준 섹터 내 순위
sector_breadth 0 ~ 1 섹터 전종목 당일 상승 종목 비율 (시장폭)
6B news_relevance_ratio 0 ~ 1 news_sentiment 테이블 뉴스 제목 종목명 포함 비율
news_sentiment_filtered -1 ~ 1 news_sentiment 테이블 관련 기사만의 센티먼트 평균
sector_news_sentiment -1 ~ 1 feature_store (피어) 섹터 전종목 뉴스 센티먼트 평균
7 google_trend_score 0 ~ 1 Google Trends (보간) 정규화된 검색량 지수
google_trend_momentum 실수 Google Trends (보간) 5일 전 대비 변화율
community_post_volume 0 ~ 네이버 커뮤니티 게시글 수 (log1p 변환)
community_comment_volume 0 ~ 네이버 커뮤니티 댓글 수 (log1p 변환)
community_engagement_ratio 0 ~ 네이버 커뮤니티 댓글/게시글 비율 (참여도)
alternative_activity_index 0 ~ 1 Trends + 커뮤니티 합산 대안 데이터 복합 활동 지수
피처 상수 정의 (feature_engineer.py)
_SECTOR_FEATURE_COLUMNS = Phase 6A 7개
_NEWS_REFINED_FEATURE_COLUMNS = Phase 6B 3개
_ALTERNATIVE_FEATURE_COLUMNS = Phase 7 6개
이 상수들은 PHASE6_FEATURE_COLUMNSPHASE7_FEATURE_COLUMNS 누적 리스트로 합산되어 학습 시 피처 선택에 사용된다.

7. 파이프라인 통합 — 2-Pass 아키텍처

16개 합성 피처 중 Phase 6A/6B는 Pass 2에, Phase 7은 Pass 1에 속한다. 이 구분은 데이터 의존성에서 비롯된다.

왜 2-Pass인가?

Phase Pass 의존성 이유
Phase 1~5 Pass 1 자기 자신 데이터만 가격, 재무, 거시, 뉴스, 공시 → 독립 계산 가능
Phase 7 Pass 1 alternative_data 테이블 외부 테이블 직접 참조, 피어 데이터 불필요
Phase 6A Pass 2 피어의 feature_store 섹터 종목들의 return_1d/5d/20d가 먼저 저장되어야 함
Phase 6B Pass 2 피어의 feature_store 섹터 종목들의 news_sentiment가 먼저 저장되어야 함

compute_all() 흐름

def compute_all(self, market, codes, ...):
    # Pass 1: Phase 1~5 + Phase 7 (include_phase6=False)
    for code in codes:
        self.compute_features(
            market, code, ...,
            include_phase6=False,  # Phase 6 스킵
        )

    # Pass 2: Phase 6A/6B만 별도 계산
    phase6_targets = self._get_phase6_targets(...)
    for code in phase6_targets:
        self._compute_phase6_only(market, code, ...)

_compute_phase6_only()는 이미 저장된 feature_store에서 Phase 6 미계산 레코드(sector_return_1d IS NULL)만 필터링하여 처리한다. 증분 모드에서는 Pass 1에서 갱신된 종목 + Phase 6 미계산 종목만 대상으로 하여 불필요한 재계산을 피한다.

def _compute_phase6_only(self, market, code, ...):
    features = ml_repo.get_features(market, code, start_date, end_date)

    # Phase 6 미계산 레코드만 추출
    features = [f for f in features if f.sector_return_1d is None]

    df = pd.DataFrame([{
        "date": f.date,
        "return_1d": f.return_1d,  # Pass 1에서 이미 계산됨
        "return_5d": f.return_5d,
        "return_20d": f.return_20d,
        "news_sentiment": f.news_sentiment,
    } for f in features])

    # Phase 6A + 6B 계산 후 upsert
    df = self._merge_sector_features(df, market, code, session)
    df = self._merge_news_refined_features(df, market, code, session)

    phase6_cols = _SECTOR_FEATURE_COLUMNS + _NEWS_REFINED_FEATURE_COLUMNS
    ml_repo.upsert_features(records)  # Phase 6 컬럼만 업데이트
Phase 7이 Pass 1인 이유: _merge_alternative_features()AlternativeRepository에서 데이터를 직접 조회한다. 다른 종목의 feature_store 데이터에 의존하지 않으므로, 피어 데이터가 저장되기 전인 Pass 1에서 계산할 수 있다. Phase 번호(7)가 Phase 6보다 크지만, 실행 순서는 먼저다 — 번호는 도입 시점의 명명이지 실행 순서가 아니다.

최종 피처 누적 구조

# feature_engineer.py 상수 정의
PHASE1_FEATURE_COLUMNS = [...]                       # 24개
PHASE2_FEATURE_COLUMNS = PHASE1 + ["per", ...]       # +9  = 33
PHASE3_FEATURE_COLUMNS = PHASE2 + ["krw_usd", ...]   # +11 = 44
PHASE4_FEATURE_COLUMNS = PHASE3 + ["news_*", ...]    # +5  = 49
PHASE5_FEATURE_COLUMNS = PHASE4 + ["disc_*", ...]    # +10 = 59
PHASE6_FEATURE_COLUMNS = PHASE5 + ["sector_*", ...]  # +10 = 69
PHASE7_FEATURE_COLUMNS = PHASE6 + ["google_*", ...]  # +6  = 75

최종적으로 75개 피처feature_store에 저장되며, 학습 시 PHASE7_FEATURE_COLUMNS를 참조하여 전체 피처를 사용한다. 각 Phase의 피처 상수는 학습 코드에서 Phase별 선택적 사용도 가능하게 한다.