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:
@@ -1,7 +1,9 @@
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any, List
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
class BaseQuantStrategy(ABC):
|
class BaseQuantStrategy(ABC):
|
||||||
"""
|
"""
|
||||||
@@ -58,6 +60,29 @@ class BaseQuantStrategy(ABC):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class BacktestMixin(ABC):
|
||||||
|
"""백테스트 가능한 전략을 위한 믹스인 클래스"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_backtest_tickers(self, parameters: Dict[str, Any]) -> List[str]:
|
||||||
|
"""백테스트에 필요한 모든 티커 목록 반환"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def bulk_download_data(self, tickers: List[str], start_date, end_date) -> Dict[str, pd.Series]:
|
||||||
|
"""전체 기간 데이터 일괄 다운로드"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def calculate_portfolio_for_date(self, parameters: Dict[str, Any], as_of_date, data_cache: Dict[str, pd.Series]) -> Dict[str, Any]:
|
||||||
|
"""특정 날짜의 포트폴리오 배분 계산 (캐시 데이터 사용)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{'mode': str, 'portfolio_weights': dict[str, float]}
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class StrategyRegistry:
|
class StrategyRegistry:
|
||||||
"""전략 구현체 레지스트리"""
|
"""전략 구현체 레지스트리"""
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,52 @@
|
|||||||
전술적 자산배분, 리스크 패리티 등 다양한 자산에 배분하는 전략들을 포함합니다.
|
전술적 자산배분, 리스크 패리티 등 다양한 자산에 배분하는 전략들을 포함합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any, List
|
||||||
import time
|
import time
|
||||||
import random
|
import random
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
import yfinance as yf
|
import yfinance as yf
|
||||||
import pandas as pd
|
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
|
@strategy
|
||||||
class BoldAssetAllocation(BaseQuantStrategy):
|
class BoldAssetAllocation(BaseQuantStrategy, BacktestMixin):
|
||||||
"""Bold Asset Allocation (BAA) 전략
|
"""Bold Asset Allocation (BAA) 전략
|
||||||
|
|
||||||
상대 모멘텀과 절대 모멘텀을 결합한 공격적 전술적 자산배분 전략.
|
상대 모멘텀과 절대 모멘텀을 결합한 공격적 전술적 자산배분 전략.
|
||||||
@@ -118,6 +153,149 @@ class BoldAssetAllocation(BaseQuantStrategy):
|
|||||||
print(f"Error downloading {ticker}: {e}")
|
print(f"Error downloading {ticker}: {e}")
|
||||||
return pd.Series()
|
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]:
|
def _calculate_portfolio_real_data(self, parameters: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""실제 데이터를 사용한 포트폴리오 계산"""
|
"""실제 데이터를 사용한 포트폴리오 계산"""
|
||||||
variant = parameters.get("variant", "BAA-G12")
|
variant = parameters.get("variant", "BAA-G12")
|
||||||
@@ -130,41 +308,7 @@ class BoldAssetAllocation(BaseQuantStrategy):
|
|||||||
elif isinstance(as_of_date, str):
|
elif isinstance(as_of_date, str):
|
||||||
as_of_date = datetime.strptime(as_of_date, "%Y-%m-%d")
|
as_of_date = datetime.strptime(as_of_date, "%Y-%m-%d")
|
||||||
|
|
||||||
# 자산 유니버스 정의
|
config = BAA_UNIVERSE_CONFIG.get(variant, BAA_UNIVERSE_CONFIG["BAA-G12"])
|
||||||
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"])
|
|
||||||
offensive_top = parameters.get("offensive_top", config["offensive_top"])
|
offensive_top = parameters.get("offensive_top", config["offensive_top"])
|
||||||
defensive_top = parameters.get("defensive_top", 3)
|
defensive_top = parameters.get("defensive_top", 3)
|
||||||
breadth_param = parameters.get("breadth_param", 1)
|
breadth_param = parameters.get("breadth_param", 1)
|
||||||
|
|||||||
0
strategies/services/__init__.py
Normal file
0
strategies/services/__init__.py
Normal file
292
strategies/services/backtest.py
Normal file
292
strategies/services/backtest.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
"""
|
||||||
|
백테스트 엔진 모듈
|
||||||
|
|
||||||
|
전략에 무관한 범용 백테스팅 로직을 제공합니다.
|
||||||
|
BacktestMixin을 구현한 전략이면 어떤 전략이든 백테스트할 수 있습니다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from ..base import BacktestMixin
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def run_backtest(strategy_instance, parameters):
|
||||||
|
"""범용 백테스트 실행
|
||||||
|
|
||||||
|
Args:
|
||||||
|
strategy_instance: BacktestMixin을 구현한 전략 인스턴스
|
||||||
|
parameters: 백테스트 파라미터 (backtest_start_date, backtest_end_date, initial_capital 등)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
백테스트 결과 딕셔너리
|
||||||
|
"""
|
||||||
|
if not isinstance(strategy_instance, BacktestMixin):
|
||||||
|
raise ValueError(f'{strategy_instance.__class__.__name__} does not implement BacktestMixin')
|
||||||
|
|
||||||
|
# 파라미터 추출
|
||||||
|
start_date_str = parameters['backtest_start_date']
|
||||||
|
end_date_str = parameters['backtest_end_date']
|
||||||
|
initial_capital = parameters.get('initial_capital', 100000)
|
||||||
|
transaction_cost_rate = parameters.get('transaction_cost', 0.001)
|
||||||
|
|
||||||
|
start_date = datetime.strptime(start_date_str, '%Y-%m-%d')
|
||||||
|
end_date = datetime.strptime(end_date_str, '%Y-%m-%d')
|
||||||
|
|
||||||
|
# 1. 티커 수집
|
||||||
|
tickers = strategy_instance.get_backtest_tickers(parameters)
|
||||||
|
|
||||||
|
# 2. 전체 기간 데이터 일괄 다운로드
|
||||||
|
data_cache = strategy_instance.bulk_download_data(tickers, start_date, end_date)
|
||||||
|
|
||||||
|
if not data_cache:
|
||||||
|
raise ValueError('Failed to download market data')
|
||||||
|
|
||||||
|
# 3. 리밸런싱 날짜 생성 (월말 영업일)
|
||||||
|
rebalance_dates = pd.date_range(
|
||||||
|
start=start_date, end=end_date, freq='BMS'
|
||||||
|
)
|
||||||
|
# BMS = Business Month Start -> 첫 영업일이지만, 실제로는 월말에 리밸런싱하므로 BMonthEnd 사용
|
||||||
|
rebalance_dates = pd.date_range(
|
||||||
|
start=start_date, end=end_date, freq='BME'
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(rebalance_dates) == 0:
|
||||||
|
raise ValueError('No rebalancing dates in the given period')
|
||||||
|
|
||||||
|
# 4. 모든 티커의 공통 거래일 인덱스 구축
|
||||||
|
all_dates = set()
|
||||||
|
for series in data_cache.values():
|
||||||
|
all_dates.update(series.index)
|
||||||
|
all_dates = sorted(all_dates)
|
||||||
|
all_trading_dates = pd.DatetimeIndex(all_dates)
|
||||||
|
# start_date 이후의 거래일만
|
||||||
|
all_trading_dates = all_trading_dates[all_trading_dates >= pd.Timestamp(start_date)]
|
||||||
|
all_trading_dates = all_trading_dates[all_trading_dates <= pd.Timestamp(end_date)]
|
||||||
|
|
||||||
|
if len(all_trading_dates) == 0:
|
||||||
|
raise ValueError('No trading dates in the given period')
|
||||||
|
|
||||||
|
# 5. 백테스트 루프
|
||||||
|
portfolio_value = float(initial_capital)
|
||||||
|
current_weights = {} # ticker -> weight
|
||||||
|
prev_weights = {}
|
||||||
|
|
||||||
|
daily_equity_curve = []
|
||||||
|
monthly_returns_list = []
|
||||||
|
allocation_history = []
|
||||||
|
|
||||||
|
# 리밸런싱 날짜를 set으로 변환하여 빠른 조회
|
||||||
|
rebal_date_set = set()
|
||||||
|
for rd in rebalance_dates:
|
||||||
|
# 가장 가까운 거래일 찾기 (rd 이하)
|
||||||
|
candidates = all_trading_dates[all_trading_dates <= rd]
|
||||||
|
if len(candidates) > 0:
|
||||||
|
rebal_date_set.add(candidates[-1])
|
||||||
|
|
||||||
|
rebal_dates_sorted = sorted(rebal_date_set)
|
||||||
|
|
||||||
|
month_start_value = portfolio_value
|
||||||
|
last_rebal_idx = -1
|
||||||
|
|
||||||
|
for i, date in enumerate(all_trading_dates):
|
||||||
|
# 일별 포트폴리오 가치 업데이트 (리밸런싱 사이)
|
||||||
|
if current_weights and i > 0:
|
||||||
|
prev_date = all_trading_dates[i - 1]
|
||||||
|
daily_return = 0.0
|
||||||
|
for ticker, weight in current_weights.items():
|
||||||
|
if ticker in data_cache:
|
||||||
|
series = data_cache[ticker]
|
||||||
|
if date in series.index and prev_date in series.index:
|
||||||
|
prev_price = float(series[prev_date])
|
||||||
|
curr_price = float(series[date])
|
||||||
|
if prev_price > 0:
|
||||||
|
ticker_return = (curr_price / prev_price) - 1
|
||||||
|
daily_return += weight * ticker_return
|
||||||
|
portfolio_value *= (1 + daily_return)
|
||||||
|
|
||||||
|
# 리밸런싱 체크
|
||||||
|
if date in rebal_date_set:
|
||||||
|
result = strategy_instance.calculate_portfolio_for_date(
|
||||||
|
parameters, date, data_cache
|
||||||
|
)
|
||||||
|
new_weights = result['portfolio_weights']
|
||||||
|
mode = result['mode']
|
||||||
|
|
||||||
|
# 턴오버 계산
|
||||||
|
turnover = _calculate_turnover(current_weights, new_weights)
|
||||||
|
cost = portfolio_value * turnover * transaction_cost_rate
|
||||||
|
portfolio_value -= cost
|
||||||
|
|
||||||
|
prev_weights = current_weights.copy()
|
||||||
|
current_weights = new_weights.copy()
|
||||||
|
|
||||||
|
allocation_history.append({
|
||||||
|
'date': date.strftime('%Y-%m-%d'),
|
||||||
|
'mode': mode,
|
||||||
|
'weights': {k: round(v, 4) for k, v in new_weights.items()},
|
||||||
|
'turnover': round(turnover, 4),
|
||||||
|
'cost': round(cost, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
# 일별 equity curve 기록
|
||||||
|
daily_equity_curve.append({
|
||||||
|
'date': date.strftime('%Y-%m-%d'),
|
||||||
|
'value': round(portfolio_value, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
# 월말 수익률 기록
|
||||||
|
if date in rebal_date_set:
|
||||||
|
monthly_return = (portfolio_value / month_start_value) - 1 if month_start_value > 0 else 0
|
||||||
|
monthly_returns_list.append({
|
||||||
|
'date': date.strftime('%Y-%m-%d'),
|
||||||
|
'return': round(monthly_return, 6)
|
||||||
|
})
|
||||||
|
month_start_value = portfolio_value
|
||||||
|
|
||||||
|
# 6. 성과 지표 계산
|
||||||
|
daily_values = [d['value'] for d in daily_equity_curve]
|
||||||
|
monthly_rets = [m['return'] for m in monthly_returns_list]
|
||||||
|
|
||||||
|
metrics = calculate_performance_metrics(daily_values, monthly_rets, initial_capital)
|
||||||
|
|
||||||
|
# drawdown 시리즈 계산
|
||||||
|
drawdown_series = _calculate_drawdown_series(daily_equity_curve)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'strategy': strategy_instance.name,
|
||||||
|
'variant': parameters.get('variant', ''),
|
||||||
|
'backtest_start_date': start_date_str,
|
||||||
|
'backtest_end_date': end_date_str,
|
||||||
|
'initial_capital': initial_capital,
|
||||||
|
'final_capital': round(portfolio_value, 2),
|
||||||
|
'metrics': metrics,
|
||||||
|
'daily_equity_curve': daily_equity_curve,
|
||||||
|
'monthly_returns': monthly_returns_list,
|
||||||
|
'allocation_history': allocation_history,
|
||||||
|
'drawdown_series': drawdown_series,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_performance_metrics(daily_values, monthly_returns, initial_capital):
|
||||||
|
"""백테스트 성과 지표 계산
|
||||||
|
|
||||||
|
Args:
|
||||||
|
daily_values: 일별 포트폴리오 가치 리스트
|
||||||
|
monthly_returns: 월별 수익률 리스트
|
||||||
|
initial_capital: 초기 자본
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
성과 지표 딕셔너리
|
||||||
|
"""
|
||||||
|
if not daily_values or len(daily_values) < 2:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
final_value = daily_values[-1]
|
||||||
|
total_return = (final_value / initial_capital - 1) * 100
|
||||||
|
|
||||||
|
# 일별 수익률
|
||||||
|
daily_returns = []
|
||||||
|
for i in range(1, len(daily_values)):
|
||||||
|
if daily_values[i - 1] > 0:
|
||||||
|
daily_returns.append(daily_values[i] / daily_values[i - 1] - 1)
|
||||||
|
|
||||||
|
if not daily_returns:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
daily_returns_arr = np.array(daily_returns)
|
||||||
|
trading_days = len(daily_returns)
|
||||||
|
years = trading_days / 252
|
||||||
|
|
||||||
|
# CAGR
|
||||||
|
if years > 0 and initial_capital > 0:
|
||||||
|
cagr = (final_value / initial_capital) ** (1 / years) - 1
|
||||||
|
else:
|
||||||
|
cagr = 0
|
||||||
|
|
||||||
|
# 연율화 변동성
|
||||||
|
annual_volatility = float(np.std(daily_returns_arr) * np.sqrt(252))
|
||||||
|
|
||||||
|
# 최대 낙폭 (MDD)
|
||||||
|
peak = daily_values[0]
|
||||||
|
max_dd = 0
|
||||||
|
for val in daily_values:
|
||||||
|
if val > peak:
|
||||||
|
peak = val
|
||||||
|
dd = (peak - val) / peak
|
||||||
|
if dd > max_dd:
|
||||||
|
max_dd = dd
|
||||||
|
|
||||||
|
# Sharpe ratio (무위험 수익률 0 가정)
|
||||||
|
if annual_volatility > 0:
|
||||||
|
sharpe_ratio = cagr / annual_volatility
|
||||||
|
else:
|
||||||
|
sharpe_ratio = 0
|
||||||
|
|
||||||
|
# Sortino ratio
|
||||||
|
downside_returns = daily_returns_arr[daily_returns_arr < 0]
|
||||||
|
if len(downside_returns) > 0:
|
||||||
|
downside_vol = float(np.std(downside_returns) * np.sqrt(252))
|
||||||
|
sortino_ratio = cagr / downside_vol if downside_vol > 0 else 0
|
||||||
|
else:
|
||||||
|
sortino_ratio = 0
|
||||||
|
|
||||||
|
# Calmar ratio
|
||||||
|
calmar_ratio = cagr / max_dd if max_dd > 0 else 0
|
||||||
|
|
||||||
|
# 월별 통계
|
||||||
|
if monthly_returns:
|
||||||
|
win_months = sum(1 for r in monthly_returns if r > 0)
|
||||||
|
win_rate = (win_months / len(monthly_returns)) * 100
|
||||||
|
best_month = max(monthly_returns) * 100
|
||||||
|
worst_month = min(monthly_returns) * 100
|
||||||
|
else:
|
||||||
|
win_rate = 0
|
||||||
|
best_month = 0
|
||||||
|
worst_month = 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
'cagr': round(cagr * 100, 2),
|
||||||
|
'total_return': round(total_return, 2),
|
||||||
|
'annual_volatility': round(annual_volatility * 100, 2),
|
||||||
|
'max_drawdown': round(max_dd * 100, 2),
|
||||||
|
'sharpe_ratio': round(sharpe_ratio, 2),
|
||||||
|
'sortino_ratio': round(sortino_ratio, 2),
|
||||||
|
'calmar_ratio': round(calmar_ratio, 2),
|
||||||
|
'win_rate': round(win_rate, 2),
|
||||||
|
'best_month': round(best_month, 2),
|
||||||
|
'worst_month': round(worst_month, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_turnover(old_weights, new_weights):
|
||||||
|
"""포트폴리오 턴오버 계산 (0~2 범위)"""
|
||||||
|
all_tickers = set(list(old_weights.keys()) + list(new_weights.keys()))
|
||||||
|
turnover = 0.0
|
||||||
|
for ticker in all_tickers:
|
||||||
|
old_w = old_weights.get(ticker, 0)
|
||||||
|
new_w = new_weights.get(ticker, 0)
|
||||||
|
turnover += abs(new_w - old_w)
|
||||||
|
return turnover
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_drawdown_series(daily_equity_curve):
|
||||||
|
"""drawdown 시리즈 계산"""
|
||||||
|
drawdowns = []
|
||||||
|
peak = 0
|
||||||
|
for entry in daily_equity_curve:
|
||||||
|
val = entry['value']
|
||||||
|
if val > peak:
|
||||||
|
peak = val
|
||||||
|
dd = (val - peak) / peak if peak > 0 else 0
|
||||||
|
drawdowns.append({
|
||||||
|
'date': entry['date'],
|
||||||
|
'drawdown': round(dd * 100, 2)
|
||||||
|
})
|
||||||
|
return drawdowns
|
||||||
@@ -4,6 +4,7 @@ from . import views
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('strategies/', views.list_strategies, name='list_strategies'),
|
path('strategies/', views.list_strategies, name='list_strategies'),
|
||||||
path('strategies/execute/', views.execute_strategy, name='execute_strategy'),
|
path('strategies/execute/', views.execute_strategy, name='execute_strategy'),
|
||||||
|
path('strategies/backtest/', views.backtest_strategy, name='backtest_strategy'),
|
||||||
path('strategies/implementations/', views.list_available_implementations, name='list_available_implementations'),
|
path('strategies/implementations/', views.list_available_implementations, name='list_available_implementations'),
|
||||||
path('executions/<int:execution_id>/', views.execution_status, name='execution_status'),
|
path('executions/<int:execution_id>/', views.execution_status, name='execution_status'),
|
||||||
]
|
]
|
||||||
@@ -11,6 +11,7 @@ import logging
|
|||||||
|
|
||||||
from .models import QuantStrategy, StrategyVersion, StrategyExecution
|
from .models import QuantStrategy, StrategyVersion, StrategyExecution
|
||||||
from .base import StrategyRegistry
|
from .base import StrategyRegistry
|
||||||
|
from .services.backtest import run_backtest
|
||||||
from . import implementations # 구현체들을 로드하여 레지스트리에 등록
|
from . import implementations # 구현체들을 로드하여 레지스트리에 등록
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -239,3 +240,106 @@ def list_available_implementations(request):
|
|||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'available_implementations': available_strategies
|
'available_implementations': available_strategies
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@require_http_methods(["POST"])
|
||||||
|
def backtest_strategy(request):
|
||||||
|
"""전략 백테스트 실행"""
|
||||||
|
try:
|
||||||
|
data = json.loads(request.body)
|
||||||
|
strategy_name = data.get('strategy_name')
|
||||||
|
version = data.get('version')
|
||||||
|
execution_parameters = data.get('parameters', {})
|
||||||
|
callback_url = data.get('callback_url')
|
||||||
|
|
||||||
|
if not strategy_name:
|
||||||
|
return JsonResponse({
|
||||||
|
'error': 'strategy_name is required'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
# 백테스트 필수 파라미터 검증
|
||||||
|
if 'backtest_start_date' not in execution_parameters:
|
||||||
|
return JsonResponse({
|
||||||
|
'error': 'parameters.backtest_start_date is required'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
if 'backtest_end_date' not in execution_parameters:
|
||||||
|
return JsonResponse({
|
||||||
|
'error': 'parameters.backtest_end_date is required'
|
||||||
|
}, status=400)
|
||||||
|
|
||||||
|
strategy = get_object_or_404(QuantStrategy, name=strategy_name, is_active=True)
|
||||||
|
|
||||||
|
if version:
|
||||||
|
strategy_version = get_object_or_404(
|
||||||
|
StrategyVersion,
|
||||||
|
strategy=strategy,
|
||||||
|
version=version
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
strategy_version = strategy.versions.filter(is_current=True).first()
|
||||||
|
if not strategy_version:
|
||||||
|
return JsonResponse({
|
||||||
|
'error': 'No current version found for this strategy'
|
||||||
|
}, status=404)
|
||||||
|
|
||||||
|
# execution_mode를 backtest로 표시
|
||||||
|
backtest_params = execution_parameters.copy()
|
||||||
|
backtest_params['execution_mode'] = 'backtest'
|
||||||
|
|
||||||
|
execution = StrategyExecution.objects.create(
|
||||||
|
strategy_version=strategy_version,
|
||||||
|
execution_parameters=backtest_params,
|
||||||
|
status='pending',
|
||||||
|
callback_url=callback_url
|
||||||
|
)
|
||||||
|
|
||||||
|
def run_backtest_task():
|
||||||
|
try:
|
||||||
|
execution.status = 'running'
|
||||||
|
execution.save()
|
||||||
|
|
||||||
|
strategy_impl = strategy_version.get_strategy_implementation()
|
||||||
|
|
||||||
|
# 기본 파라미터와 실행 파라미터를 병합
|
||||||
|
merged_parameters = strategy_impl.default_parameters.copy()
|
||||||
|
merged_parameters.update(execution_parameters)
|
||||||
|
|
||||||
|
# 백테스트 실행
|
||||||
|
result = run_backtest(strategy_impl, merged_parameters)
|
||||||
|
|
||||||
|
execution.status = 'completed'
|
||||||
|
execution.result = result
|
||||||
|
execution.completed_at = timezone.now()
|
||||||
|
execution.save()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f'Backtest failed for execution {execution.id}')
|
||||||
|
execution.status = 'failed'
|
||||||
|
execution.error_message = str(e)
|
||||||
|
execution.completed_at = timezone.now()
|
||||||
|
execution.save()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if execution.callback_url:
|
||||||
|
send_callback(execution)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=run_backtest_task)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'execution_id': execution.id,
|
||||||
|
'status': 'pending',
|
||||||
|
'message': 'Backtest execution started',
|
||||||
|
'callback_url': callback_url
|
||||||
|
})
|
||||||
|
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return JsonResponse({
|
||||||
|
'error': 'Invalid JSON in request body'
|
||||||
|
}, status=400)
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({
|
||||||
|
'error': str(e)
|
||||||
|
}, status=500)
|
||||||
|
|||||||
Reference in New Issue
Block a user