170 lines
5.9 KiB
Python

"""Magic Formula Strategy (EY + ROC)."""
from typing import List, Dict
from decimal import Decimal
from datetime import datetime
from sqlalchemy.orm import Session
import pandas as pd
import numpy as np
from app.strategies.base import BaseStrategy
from app.utils.data_helpers import (
get_ticker_list,
get_financial_statements,
get_prices_on_date
)
class MagicFormulaStrategy(BaseStrategy):
"""
마법 공식 (Magic Formula) 전략.
조엘 그린블라트의 마법공식:
- Earnings Yield (이익수익률): EBIT / EV
- Return on Capital (투하자본 수익률): EBIT / IC
두 지표의 순위를 합산하여 상위 종목 선정
"""
def __init__(self, config: Dict = None):
"""
초기화.
Args:
config: 전략 설정
- count: 선정 종목 수 (기본 20)
"""
super().__init__(config)
self.count = config.get('count', 20)
def select_stocks(self, rebal_date: datetime, db_session: Session) -> List[str]:
"""
종목 선정.
Args:
rebal_date: 리밸런싱 날짜
db_session: 데이터베이스 세션
Returns:
선정된 종목 코드 리스트
"""
try:
# 1. 종목 리스트 조회
ticker_list = get_ticker_list(db_session)
if ticker_list.empty:
return []
tickers = ticker_list['종목코드'].tolist()
# 2. 재무제표 데이터 조회
fs_list = get_financial_statements(db_session, tickers, rebal_date)
if fs_list.empty:
return []
# 3. TTM (Trailing Twelve Months) 계산
fs_list = fs_list.sort_values(['종목코드', '계정', '기준일'])
fs_list['ttm'] = fs_list.groupby(['종목코드', '계정'], as_index=False)[''].rolling(
window=4, min_periods=4
).sum()['']
fs_list_clean = fs_list.copy()
# 재무상태표 현황은 평균값 사용
fs_list_clean['ttm'] = np.where(
fs_list_clean['계정'].isin(['부채', '유동부채', '유동자산', '비유동자산']),
fs_list_clean['ttm'] / 4,
fs_list_clean['ttm']
)
fs_list_clean = fs_list_clean.groupby(['종목코드', '계정']).tail(1)
fs_list_pivot = fs_list_clean.pivot(index='종목코드', columns='계정', values='ttm')
# 4. 티커 데이터와 병합
data_bind = ticker_list[['종목코드', '종목명']].merge(
fs_list_pivot,
how='left',
on='종목코드'
)
# 시가총액 추가 (assets 테이블에서)
from app.models.asset import Asset
assets = db_session.query(Asset).filter(
Asset.ticker.in_(tickers)
).all()
market_cap_dict = {asset.ticker: float(asset.market_cap) / 100000000 if asset.market_cap else None
for asset in assets}
data_bind['시가총액'] = data_bind['종목코드'].map(market_cap_dict)
# 5. 이익수익률 (Earnings Yield) 계산
# EBIT = 당기순이익 + 법인세비용 + 이자비용
magic_ebit = (
data_bind.get('당기순이익', 0) +
data_bind.get('법인세비용', 0) +
data_bind.get('이자비용', 0)
)
# EV (Enterprise Value) = 시가총액 + 부채 - 여유자금
magic_cap = data_bind.get('시가총액', 0)
magic_debt = data_bind.get('부채', 0)
# 여유자금 = 현금 - max(0, 유동부채 - 유동자산 + 현금)
magic_excess_cash = (
data_bind.get('유동부채', 0) -
data_bind.get('유동자산', 0) +
data_bind.get('현금및현금성자산', 0)
)
magic_excess_cash[magic_excess_cash < 0] = 0
magic_excess_cash_final = data_bind.get('현금및현금성자산', 0) - magic_excess_cash
magic_ev = magic_cap + magic_debt - magic_excess_cash_final
magic_ey = magic_ebit / magic_ev
# 6. 투하자본 수익률 (Return on Capital) 계산
# IC (Invested Capital) = (유동자산 - 유동부채) + (비유동자산 - 감가상각비)
magic_ic = (
(data_bind.get('유동자산', 0) - data_bind.get('유동부채', 0)) +
(data_bind.get('비유동자산', 0) - data_bind.get('감가상각비', 0))
)
magic_roc = magic_ebit / magic_ic
# 7. 지표 추가
data_bind['이익_수익률'] = magic_ey
data_bind['투하자본_수익률'] = magic_roc
# 8. 순위 합산 및 상위 종목 선정
magic_rank = (
magic_ey.rank(ascending=False, axis=0) +
magic_roc.rank(ascending=False, axis=0)
).rank(axis=0)
# 결측치 제거
data_bind = data_bind.dropna(subset=['이익_수익률', '투하자본_수익률'])
# 상위 N개 종목
top_stocks = data_bind.loc[magic_rank <= self.count, ['종목코드', '종목명', '이익_수익률', '투하자본_수익률']]
return top_stocks['종목코드'].tolist()
except Exception as e:
print(f"Magic Formula 종목 선정 오류: {e}")
return []
def get_prices(
self,
tickers: List[str],
date: datetime,
db_session: Session
) -> Dict[str, Decimal]:
"""
종목 가격 조회.
Args:
tickers: 종목 코드 리스트
date: 조회 날짜
db_session: 데이터베이스 세션
Returns:
{ticker: price} 딕셔너리
"""
return get_prices_on_date(db_session, tickers, date)