피어 비교 피처 합성 — 섹터 상대강도 + 대안 데이터 복합 인덱스
여러 소스를 하나의 시그널로 압축하는 벡터화 전략
이 글은 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_COLUMNS와 PHASE7_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별 선택적 사용도 가능하게 한다.
'Project > 🚀 AI Finance 2026' 카테고리의 다른 글
| #22 [모듈3-1] scikit-learn, XGBoost, LightGBM — 3대 ML 라이브러리 (0) | 2026.03.13 |
|---|---|
| #21 [모듈3] Machine Learning (0) | 2026.03.13 |
| #19 [모듈2-3] 증분 파이프라인 + Lookback 버퍼 (0) | 2026.03.13 |
| #18 [모듈2-2] 결측 데이터 전략 - Graceful NULL + 이중 Forward-Fill (0) | 2026.03.13 |
| #17 [모듈2-1] 2-Pass 아키텍처 피처 엔지니어링 (0) | 2026.03.13 |