feat: 전략 백테스팅 기능 추가
- Introduced a `backtest_strategy` endpoint to enable strategy backtesting with user-specified parameters. - Implemented a generic backtesting engine allowing rebalancing, equity curve tracking, and performance metric calculations. - Added `BacktestMixin` for strategies to support backtesting-related operations. - Extended BAA strategy to support backtesting with ticker data download and portfolio simulation. - Updated `urls.py` to include the new backtesting endpoint. - Enhanced logging and error handling throughout the backtesting process.
This commit is contained in:
@@ -4,17 +4,52 @@
|
||||
전술적 자산배분, 리스크 패리티 등 다양한 자산에 배분하는 전략들을 포함합니다.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any
|
||||
from typing import Dict, Any, List
|
||||
import time
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
import yfinance as yf
|
||||
import pandas as pd
|
||||
from ..base import BaseQuantStrategy, strategy
|
||||
from ..base import BaseQuantStrategy, BacktestMixin, strategy
|
||||
|
||||
|
||||
# BAA 전략 자산 유니버스 설정
|
||||
BAA_UNIVERSE_CONFIG = {
|
||||
"BAA-G12": {
|
||||
"offensive": ["SPY", "QQQ", "IWM", "VGK", "EWJ", "VWO", "VNQ", "DBC", "GLD", "TLT", "HYG", "LQD"],
|
||||
"defensive": ["TIP", "DBC", "BIL", "IEF", "TLT", "LQD", "BND"],
|
||||
"canary": ["SPY", "VWO", "VEA", "BND"],
|
||||
"offensive_top": 6,
|
||||
},
|
||||
"BAA-G4": {
|
||||
"offensive": ["QQQ", "VWO", "VEA", "BND"],
|
||||
"defensive": ["TIP", "DBC", "BIL", "IEF", "TLT", "LQD", "BND"],
|
||||
"canary": ["SPY", "VWO", "VEA", "BND"],
|
||||
"offensive_top": 1,
|
||||
},
|
||||
"BAA-G12/T3": {
|
||||
"offensive": ["SPY", "QQQ", "IWM", "VGK", "EWJ", "VWO", "VNQ", "DBC", "GLD", "TLT", "HYG", "LQD"],
|
||||
"defensive": ["TIP", "DBC", "BIL", "IEF", "TLT", "LQD", "BND"],
|
||||
"canary": ["SPY", "VWO", "VEA", "BND"],
|
||||
"offensive_top": 3,
|
||||
},
|
||||
"BAA-G4/T2": {
|
||||
"offensive": ["QQQ", "VWO", "VEA", "BND"],
|
||||
"defensive": ["TIP", "DBC", "BIL", "IEF", "TLT", "LQD", "BND"],
|
||||
"canary": ["SPY", "VWO", "VEA", "BND"],
|
||||
"offensive_top": 2,
|
||||
},
|
||||
"BAA-SPY": {
|
||||
"offensive": ["SPY"],
|
||||
"defensive": ["TIP", "DBC", "BIL", "IEF", "TLT", "LQD", "BND"],
|
||||
"canary": ["SPY", "VWO", "VEA", "BND"],
|
||||
"offensive_top": 1,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@strategy
|
||||
class BoldAssetAllocation(BaseQuantStrategy):
|
||||
class BoldAssetAllocation(BaseQuantStrategy, BacktestMixin):
|
||||
"""Bold Asset Allocation (BAA) 전략
|
||||
|
||||
상대 모멘텀과 절대 모멘텀을 결합한 공격적 전술적 자산배분 전략.
|
||||
@@ -118,6 +153,149 @@ class BoldAssetAllocation(BaseQuantStrategy):
|
||||
print(f"Error downloading {ticker}: {e}")
|
||||
return pd.Series()
|
||||
|
||||
# === BacktestMixin 구현 ===
|
||||
|
||||
def get_backtest_tickers(self, parameters: Dict[str, Any]) -> List[str]:
|
||||
"""백테스트에 필요한 모든 티커 목록 반환"""
|
||||
variant = parameters.get('variant', 'BAA-G12')
|
||||
config = BAA_UNIVERSE_CONFIG.get(variant, BAA_UNIVERSE_CONFIG['BAA-G12'])
|
||||
|
||||
tickers = set()
|
||||
tickers.update(config['offensive'])
|
||||
tickers.update(config['defensive'])
|
||||
tickers.update(config['canary'])
|
||||
tickers.add('BIL') # 안전자산 대체용
|
||||
return sorted(tickers)
|
||||
|
||||
def bulk_download_data(self, tickers: List[str], start_date, end_date) -> Dict[str, pd.Series]:
|
||||
"""전체 기간 데이터 일괄 다운로드 (모멘텀 lookback 포함)"""
|
||||
# 모멘텀 계산에 필요한 lookback 기간 추가 (400일)
|
||||
adjusted_start = start_date - timedelta(days=400)
|
||||
|
||||
data_cache = {}
|
||||
try:
|
||||
# yfinance 일괄 다운로드
|
||||
raw_data = yf.download(
|
||||
tickers, start=adjusted_start, end=end_date,
|
||||
progress=False, auto_adjust=True
|
||||
)
|
||||
|
||||
if raw_data.empty:
|
||||
return data_cache
|
||||
|
||||
for ticker in tickers:
|
||||
try:
|
||||
if len(tickers) == 1:
|
||||
# 단일 티커: 컬럼 구조가 다름
|
||||
if 'Close' in raw_data.columns:
|
||||
series = raw_data['Close'].dropna()
|
||||
else:
|
||||
series = raw_data.dropna()
|
||||
else:
|
||||
# 다중 티커
|
||||
if 'Close' in raw_data.columns:
|
||||
series = raw_data['Close'][ticker].dropna()
|
||||
else:
|
||||
series = raw_data[ticker].dropna()
|
||||
|
||||
if not series.empty:
|
||||
data_cache[ticker] = series
|
||||
except (KeyError, TypeError):
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error bulk downloading data: {e}")
|
||||
|
||||
return data_cache
|
||||
|
||||
def calculate_portfolio_for_date(self, parameters: Dict[str, Any], as_of_date, data_cache: Dict[str, pd.Series]) -> Dict[str, Any]:
|
||||
"""특정 날짜의 포트폴리오 배분 계산 (캐시 데이터 사용)"""
|
||||
variant = parameters.get('variant', 'BAA-G12')
|
||||
config = BAA_UNIVERSE_CONFIG.get(variant, BAA_UNIVERSE_CONFIG['BAA-G12'])
|
||||
offensive_top = parameters.get('offensive_top', config['offensive_top'])
|
||||
defensive_top = parameters.get('defensive_top', 3)
|
||||
breadth_param = parameters.get('breadth_param', 1)
|
||||
|
||||
def get_prices_from_cache(ticker):
|
||||
"""캐시에서 as_of_date 이전 데이터만 슬라이싱 (미래 데이터 유출 방지)"""
|
||||
if ticker not in data_cache:
|
||||
return pd.Series()
|
||||
series = data_cache[ticker]
|
||||
# as_of_date 이전 데이터만 사용
|
||||
mask = series.index <= pd.Timestamp(as_of_date)
|
||||
return series[mask]
|
||||
|
||||
# 카나리아 유니버스 체크 (13612W 모멘텀 사용)
|
||||
canary_bad_count = 0
|
||||
for ticker in config['canary']:
|
||||
prices = get_prices_from_cache(ticker)
|
||||
if not prices.empty:
|
||||
momentum = self._calculate_13612w_momentum(prices)
|
||||
if momentum < 0:
|
||||
canary_bad_count += 1
|
||||
|
||||
# 방어 모드 여부 결정
|
||||
is_defensive = canary_bad_count >= breadth_param
|
||||
|
||||
portfolio = {}
|
||||
|
||||
if is_defensive:
|
||||
# 방어 유니버스에서 선택
|
||||
defensive_scores = {}
|
||||
for ticker in config['defensive']:
|
||||
prices = get_prices_from_cache(ticker)
|
||||
if not prices.empty:
|
||||
momentum = self._calculate_sma12_momentum(prices)
|
||||
defensive_scores[ticker] = momentum
|
||||
|
||||
# BIL 모멘텀
|
||||
bil_prices = get_prices_from_cache('BIL')
|
||||
bil_momentum = self._calculate_sma12_momentum(bil_prices) if not bil_prices.empty else 0
|
||||
|
||||
# 상위 defensive_top개 선택
|
||||
sorted_defensive = sorted(defensive_scores.items(), key=lambda x: x[1], reverse=True)
|
||||
selected_assets = []
|
||||
|
||||
for ticker, momentum in sorted_defensive[:defensive_top]:
|
||||
if momentum < bil_momentum:
|
||||
selected_assets.append('BIL')
|
||||
else:
|
||||
selected_assets.append(ticker)
|
||||
|
||||
if not selected_assets:
|
||||
selected_assets = ['BIL']
|
||||
|
||||
weight_per_asset = 1.0 / len(selected_assets)
|
||||
for ticker in selected_assets:
|
||||
if ticker in portfolio:
|
||||
portfolio[ticker] += weight_per_asset
|
||||
else:
|
||||
portfolio[ticker] = weight_per_asset
|
||||
|
||||
else:
|
||||
# 공격 유니버스에서 선택 (SMA12 상대 모멘텀)
|
||||
offensive_scores = {}
|
||||
for ticker in config['offensive']:
|
||||
prices = get_prices_from_cache(ticker)
|
||||
if not prices.empty:
|
||||
momentum = self._calculate_sma12_momentum(prices)
|
||||
offensive_scores[ticker] = momentum
|
||||
|
||||
sorted_offensive = sorted(offensive_scores.items(), key=lambda x: x[1], reverse=True)
|
||||
selected_assets = [ticker for ticker, _ in sorted_offensive[:offensive_top]]
|
||||
|
||||
if not selected_assets:
|
||||
selected_assets = ['BIL']
|
||||
|
||||
weight_per_asset = 1.0 / len(selected_assets)
|
||||
for ticker in selected_assets:
|
||||
portfolio[ticker] = weight_per_asset
|
||||
|
||||
return {
|
||||
'mode': 'defensive' if is_defensive else 'offensive',
|
||||
'portfolio_weights': portfolio
|
||||
}
|
||||
|
||||
def _calculate_portfolio_real_data(self, parameters: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""실제 데이터를 사용한 포트폴리오 계산"""
|
||||
variant = parameters.get("variant", "BAA-G12")
|
||||
@@ -130,41 +308,7 @@ class BoldAssetAllocation(BaseQuantStrategy):
|
||||
elif isinstance(as_of_date, str):
|
||||
as_of_date = datetime.strptime(as_of_date, "%Y-%m-%d")
|
||||
|
||||
# 자산 유니버스 정의
|
||||
universe_config = {
|
||||
"BAA-G12": {
|
||||
"offensive": ["SPY", "QQQ", "IWM", "VGK", "EWJ", "VWO", "VNQ", "DBC", "GLD", "TLT", "HYG", "LQD"],
|
||||
"defensive": ["TIP", "DBC", "BIL", "IEF", "TLT", "LQD", "BND"],
|
||||
"canary": ["SPY", "VWO", "VEA", "BND"],
|
||||
"offensive_top": 6,
|
||||
},
|
||||
"BAA-G4": {
|
||||
"offensive": ["QQQ", "VWO", "VEA", "BND"],
|
||||
"defensive": ["TIP", "DBC", "BIL", "IEF", "TLT", "LQD", "BND"],
|
||||
"canary": ["SPY", "VWO", "VEA", "BND"],
|
||||
"offensive_top": 1,
|
||||
},
|
||||
"BAA-G12/T3": {
|
||||
"offensive": ["SPY", "QQQ", "IWM", "VGK", "EWJ", "VWO", "VNQ", "DBC", "GLD", "TLT", "HYG", "LQD"],
|
||||
"defensive": ["TIP", "DBC", "BIL", "IEF", "TLT", "LQD", "BND"],
|
||||
"canary": ["SPY", "VWO", "VEA", "BND"],
|
||||
"offensive_top": 3,
|
||||
},
|
||||
"BAA-G4/T2": {
|
||||
"offensive": ["QQQ", "VWO", "VEA", "BND"],
|
||||
"defensive": ["TIP", "DBC", "BIL", "IEF", "TLT", "LQD", "BND"],
|
||||
"canary": ["SPY", "VWO", "VEA", "BND"],
|
||||
"offensive_top": 2,
|
||||
},
|
||||
"BAA-SPY": {
|
||||
"offensive": ["SPY"],
|
||||
"defensive": ["TIP", "DBC", "BIL", "IEF", "TLT", "LQD", "BND"],
|
||||
"canary": ["SPY", "VWO", "VEA", "BND"],
|
||||
"offensive_top": 1,
|
||||
}
|
||||
}
|
||||
|
||||
config = universe_config.get(variant, universe_config["BAA-G12"])
|
||||
config = BAA_UNIVERSE_CONFIG.get(variant, BAA_UNIVERSE_CONFIG["BAA-G12"])
|
||||
offensive_top = parameters.get("offensive_top", config["offensive_top"])
|
||||
defensive_top = parameters.get("defensive_top", 3)
|
||||
breadth_param = parameters.get("breadth_param", 1)
|
||||
|
||||
Reference in New Issue
Block a user