From b64a76a8b947a3652915bd680accb21c93239df5 Mon Sep 17 00:00:00 2001 From: Jongheon Kim Date: Sun, 8 Feb 2026 13:54:05 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A0=84=EB=9E=B5=20=EB=B0=B1=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8C=85=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- strategies/base.py | 27 ++- strategies/impls/asset_allocation.py | 220 ++++++++++++++++---- strategies/services/__init__.py | 0 strategies/services/backtest.py | 292 +++++++++++++++++++++++++++ strategies/urls.py | 1 + strategies/views.py | 104 ++++++++++ 6 files changed, 605 insertions(+), 39 deletions(-) create mode 100644 strategies/services/__init__.py create mode 100644 strategies/services/backtest.py diff --git a/strategies/base.py b/strategies/base.py index 0ea474a..772a745 100644 --- a/strategies/base.py +++ b/strategies/base.py @@ -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: """전략 구현체 레지스트리""" diff --git a/strategies/impls/asset_allocation.py b/strategies/impls/asset_allocation.py index 1c1a115..f8e1fed 100644 --- a/strategies/impls/asset_allocation.py +++ b/strategies/impls/asset_allocation.py @@ -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) diff --git a/strategies/services/__init__.py b/strategies/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/strategies/services/backtest.py b/strategies/services/backtest.py new file mode 100644 index 0000000..534f0ff --- /dev/null +++ b/strategies/services/backtest.py @@ -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 diff --git a/strategies/urls.py b/strategies/urls.py index da749da..ddfde78 100644 --- a/strategies/urls.py +++ b/strategies/urls.py @@ -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//', views.execution_status, name='execution_status'), ] \ No newline at end of file diff --git a/strategies/views.py b/strategies/views.py index c40292d..fba872c 100644 --- a/strategies/views.py +++ b/strategies/views.py @@ -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)