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 typing import Dict, Any
|
||||
from typing import Dict, Any, List
|
||||
import json
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
class BaseQuantStrategy(ABC):
|
||||
"""
|
||||
@@ -58,6 +60,29 @@ class BaseQuantStrategy(ABC):
|
||||
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:
|
||||
"""전략 구현체 레지스트리"""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
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 = [
|
||||
path('strategies/', views.list_strategies, name='list_strategies'),
|
||||
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('executions/<int:execution_id>/', views.execution_status, name='execution_status'),
|
||||
]
|
||||
@@ -11,6 +11,7 @@ import logging
|
||||
|
||||
from .models import QuantStrategy, StrategyVersion, StrategyExecution
|
||||
from .base import StrategyRegistry
|
||||
from .services.backtest import run_backtest
|
||||
from . import implementations # 구현체들을 로드하여 레지스트리에 등록
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -239,3 +240,106 @@ def list_available_implementations(request):
|
||||
return JsonResponse({
|
||||
'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