feat: 프로젝트 기본 구조 구축
This commit is contained in:
@@ -4,3 +4,56 @@ from django.apps import AppConfig
|
||||
class StrategiesConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'strategies'
|
||||
|
||||
def ready(self):
|
||||
"""앱 초기화 시 전략 데이터를 DB에 로드"""
|
||||
# 마이그레이션이나 다른 명령어 실행 중에는 초기화하지 않음
|
||||
import sys
|
||||
if 'runserver' in sys.argv or 'test' in sys.argv:
|
||||
self._initialize_strategies()
|
||||
|
||||
def _initialize_strategies(self):
|
||||
"""전략 레지스트리를 DB에 초기화"""
|
||||
try:
|
||||
from .base import StrategyRegistry
|
||||
from .models import QuantStrategy, StrategyVersion
|
||||
|
||||
available_strategies = StrategyRegistry.list_strategies()
|
||||
|
||||
for strategy_name, strategy_info in available_strategies.items():
|
||||
# QuantStrategy 생성 또는 업데이트
|
||||
strategy_obj, created = QuantStrategy.objects.get_or_create(
|
||||
name=strategy_name,
|
||||
defaults={
|
||||
'description': strategy_info['description'],
|
||||
'is_active': True
|
||||
}
|
||||
)
|
||||
|
||||
# 설명이 변경된 경우 업데이트
|
||||
if not created and strategy_obj.description != strategy_info['description']:
|
||||
strategy_obj.description = strategy_info['description']
|
||||
strategy_obj.save()
|
||||
|
||||
# 각 버전 생성
|
||||
for version_info in strategy_info['versions']:
|
||||
version_obj, version_created = StrategyVersion.objects.get_or_create(
|
||||
strategy=strategy_obj,
|
||||
version=version_info['version'],
|
||||
defaults={
|
||||
'implementation_key': f"{strategy_name}:{version_info['version']}",
|
||||
'parameters': version_info['default_parameters'],
|
||||
'is_current': True
|
||||
}
|
||||
)
|
||||
|
||||
# 파라미터가 변경된 경우 업데이트
|
||||
if not version_created and version_obj.parameters != version_info['default_parameters']:
|
||||
version_obj.parameters = version_info['default_parameters']
|
||||
version_obj.save()
|
||||
|
||||
except Exception as e:
|
||||
# DB가 아직 준비되지 않았거나 마이그레이션 전인 경우 무시
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug(f"전략 초기화 건너뜀: {e}")
|
||||
|
||||
@@ -1,202 +1,27 @@
|
||||
from typing import Dict, Any
|
||||
import time
|
||||
import random
|
||||
import math
|
||||
from .base import BaseQuantStrategy, strategy
|
||||
"""
|
||||
전략 구현체 통합 모듈
|
||||
|
||||
모든 전략 구현체를 임포트하여 레지스트리에 등록합니다.
|
||||
실제 구현은 strategies/impls/ 하위 모듈에 분리되어 있습니다.
|
||||
"""
|
||||
|
||||
@strategy
|
||||
class MovingAverageCrossover(BaseQuantStrategy):
|
||||
"""이동평균선 교차 전략"""
|
||||
# 각 카테고리별 전략 임포트
|
||||
from .impls.trend_following import MovingAverageCrossover
|
||||
from .impls.mean_reversion import RSIMeanReversion
|
||||
from .impls.volatility_breakout import BollingerBandBreakout
|
||||
from .impls.asset_allocation import BoldAssetAllocation
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "MovingAverageCrossover"
|
||||
# __all__에 등록하여 외부에서 임포트 가능하도록 설정
|
||||
__all__ = [
|
||||
# Trend Following
|
||||
'MovingAverageCrossover',
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "단기 이동평균선이 장기 이동평균선을 상향 돌파할 때 매수, 하향 돌파할 때 매도하는 전략"
|
||||
# Mean Reversion
|
||||
'RSIMeanReversion',
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return "1.0.0"
|
||||
# Volatility Breakout
|
||||
'BollingerBandBreakout',
|
||||
|
||||
@property
|
||||
def default_parameters(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"short_window": 20,
|
||||
"long_window": 50,
|
||||
"initial_capital": 100000,
|
||||
"position_size": 0.1
|
||||
}
|
||||
|
||||
def validate_parameters(self, parameters: Dict[str, Any]) -> bool:
|
||||
required_params = ["short_window", "long_window", "initial_capital"]
|
||||
for param in required_params:
|
||||
if param not in parameters:
|
||||
return False
|
||||
|
||||
if parameters["short_window"] >= parameters["long_window"]:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def execute(self, parameters: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
if parameters is None:
|
||||
parameters = self.default_parameters
|
||||
|
||||
if not self.validate_parameters(parameters):
|
||||
raise ValueError("Invalid parameters")
|
||||
|
||||
# 시뮬레이션 실행
|
||||
time.sleep(1) # 실행 시간 시뮬레이션
|
||||
|
||||
# 모의 결과 생성
|
||||
profit_rate = random.uniform(-0.15, 0.25)
|
||||
trades_count = random.randint(15, 60)
|
||||
win_rate = random.uniform(0.45, 0.75)
|
||||
|
||||
return {
|
||||
"strategy": self.name,
|
||||
"version": self.version,
|
||||
"profit_loss": round(parameters["initial_capital"] * profit_rate, 2),
|
||||
"profit_rate": round(profit_rate * 100, 2),
|
||||
"trades_executed": trades_count,
|
||||
"win_rate": round(win_rate, 3),
|
||||
"execution_time": "1.2s",
|
||||
"parameters_used": parameters,
|
||||
"final_capital": round(parameters["initial_capital"] * (1 + profit_rate), 2)
|
||||
}
|
||||
|
||||
|
||||
@strategy
|
||||
class RSIMeanReversion(BaseQuantStrategy):
|
||||
"""RSI 평균회귀 전략"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "RSIMeanReversion"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "RSI 지표를 이용한 평균회귀 전략. RSI가 과매수/과매도 구간에서 반대 방향으로 거래"
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return "1.0.0"
|
||||
|
||||
@property
|
||||
def default_parameters(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"rsi_period": 14,
|
||||
"oversold_threshold": 30,
|
||||
"overbought_threshold": 70,
|
||||
"initial_capital": 100000,
|
||||
"position_size": 0.05
|
||||
}
|
||||
|
||||
def validate_parameters(self, parameters: Dict[str, Any]) -> bool:
|
||||
required_params = ["rsi_period", "oversold_threshold", "overbought_threshold", "initial_capital"]
|
||||
for param in required_params:
|
||||
if param not in parameters:
|
||||
return False
|
||||
|
||||
if not (0 < parameters["oversold_threshold"] < parameters["overbought_threshold"] < 100):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def execute(self, parameters: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
if parameters is None:
|
||||
parameters = self.default_parameters
|
||||
|
||||
if not self.validate_parameters(parameters):
|
||||
raise ValueError("Invalid parameters")
|
||||
|
||||
# 시뮬레이션 실행
|
||||
time.sleep(1.5) # 실행 시간 시뮬레이션
|
||||
|
||||
# 모의 결과 생성
|
||||
profit_rate = random.uniform(-0.10, 0.18)
|
||||
trades_count = random.randint(25, 80)
|
||||
win_rate = random.uniform(0.40, 0.65)
|
||||
|
||||
return {
|
||||
"strategy": self.name,
|
||||
"version": self.version,
|
||||
"profit_loss": round(parameters["initial_capital"] * profit_rate, 2),
|
||||
"profit_rate": round(profit_rate * 100, 2),
|
||||
"trades_executed": trades_count,
|
||||
"win_rate": round(win_rate, 3),
|
||||
"execution_time": "1.5s",
|
||||
"parameters_used": parameters,
|
||||
"final_capital": round(parameters["initial_capital"] * (1 + profit_rate), 2),
|
||||
"max_drawdown": round(random.uniform(0.05, 0.20), 3)
|
||||
}
|
||||
|
||||
|
||||
@strategy
|
||||
class BollingerBandBreakout(BaseQuantStrategy):
|
||||
"""볼린저 밴드 돌파 전략"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "BollingerBandBreakout"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "볼린저 밴드 상한선 돌파시 매수, 하한선 돌파시 매도하는 돌파 전략"
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return "2.0.0"
|
||||
|
||||
@property
|
||||
def default_parameters(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"period": 20,
|
||||
"std_dev": 2.0,
|
||||
"initial_capital": 100000,
|
||||
"position_size": 0.08,
|
||||
"stop_loss": 0.05
|
||||
}
|
||||
|
||||
def validate_parameters(self, parameters: Dict[str, Any]) -> bool:
|
||||
required_params = ["period", "std_dev", "initial_capital"]
|
||||
for param in required_params:
|
||||
if param not in parameters:
|
||||
return False
|
||||
|
||||
if parameters["std_dev"] <= 0 or parameters["period"] <= 0:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def execute(self, parameters: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
if parameters is None:
|
||||
parameters = self.default_parameters
|
||||
|
||||
if not self.validate_parameters(parameters):
|
||||
raise ValueError("Invalid parameters")
|
||||
|
||||
# 시뮬레이션 실행
|
||||
time.sleep(2) # 실행 시간 시뮬레이션
|
||||
|
||||
# 모의 결과 생성
|
||||
profit_rate = random.uniform(-0.20, 0.30)
|
||||
trades_count = random.randint(10, 40)
|
||||
win_rate = random.uniform(0.35, 0.70)
|
||||
|
||||
return {
|
||||
"strategy": self.name,
|
||||
"version": self.version,
|
||||
"profit_loss": round(parameters["initial_capital"] * profit_rate, 2),
|
||||
"profit_rate": round(profit_rate * 100, 2),
|
||||
"trades_executed": trades_count,
|
||||
"win_rate": round(win_rate, 3),
|
||||
"execution_time": "2.0s",
|
||||
"parameters_used": parameters,
|
||||
"final_capital": round(parameters["initial_capital"] * (1 + profit_rate), 2),
|
||||
"sharpe_ratio": round(random.uniform(0.5, 2.5), 2),
|
||||
"volatility": round(random.uniform(0.15, 0.35), 3)
|
||||
}
|
||||
# Asset Allocation
|
||||
'BoldAssetAllocation',
|
||||
]
|
||||
|
||||
18
strategies/impls/__init__.py
Normal file
18
strategies/impls/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
전략 구현체 패키지
|
||||
|
||||
각 전략 유형별로 모듈을 분리하여 관리합니다.
|
||||
"""
|
||||
|
||||
# 각 모듈에서 전략을 import
|
||||
from .trend_following import MovingAverageCrossover
|
||||
from .mean_reversion import RSIMeanReversion
|
||||
from .volatility_breakout import BollingerBandBreakout
|
||||
from .asset_allocation import BoldAssetAllocation
|
||||
|
||||
__all__ = [
|
||||
'MovingAverageCrossover',
|
||||
'RSIMeanReversion',
|
||||
'BollingerBandBreakout',
|
||||
'BoldAssetAllocation',
|
||||
]
|
||||
449
strategies/impls/asset_allocation.py
Normal file
449
strategies/impls/asset_allocation.py
Normal file
@@ -0,0 +1,449 @@
|
||||
"""
|
||||
자산 배분(Asset Allocation) 전략 구현체
|
||||
|
||||
전술적 자산배분, 리스크 패리티 등 다양한 자산에 배분하는 전략들을 포함합니다.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any
|
||||
import time
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
import yfinance as yf
|
||||
import pandas as pd
|
||||
from ..base import BaseQuantStrategy, strategy
|
||||
|
||||
|
||||
@strategy
|
||||
class BoldAssetAllocation(BaseQuantStrategy):
|
||||
"""Bold Asset Allocation (BAA) 전략
|
||||
|
||||
상대 모멘텀과 절대 모멘텀을 결합한 공격적 전술적 자산배분 전략.
|
||||
느린 상대 모멘텀(SMA12)과 빠른 절대 모멘텀(13612W)을 조합하여
|
||||
카나리아 유니버스 기반의 크래시 보호 메커니즘을 구현.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "BoldAssetAllocation"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "상대 모멘텀과 절대 모멘텀을 결합한 공격적 전술적 자산배분 전략. 카나리아 유니버스 기반 크래시 보호"
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return "1.0.0"
|
||||
|
||||
@property
|
||||
def default_parameters(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"initial_capital": 100000,
|
||||
"variant": "BAA-G12", # BAA-G12 (balanced), BAA-G4 (aggressive), BAA-SPY
|
||||
"offensive_top": 6, # TO: 공격 유니버스에서 선택할 자산 수 (BAA-G12: 6, BAA-G4: 1)
|
||||
"defensive_top": 3, # TD: 방어 유니버스에서 선택할 자산 수
|
||||
"breadth_param": 1, # B: 카나리아 자산 중 몇 개가 bad일 때 방어로 전환
|
||||
"transaction_cost": 0.001, # 0.1% 거래비용
|
||||
"as_of_date": None, # 기준일 (None이면 현재 날짜)
|
||||
"use_real_data": False, # 실제 데이터 사용 여부
|
||||
}
|
||||
|
||||
def validate_parameters(self, parameters: Dict[str, Any]) -> bool:
|
||||
required_params = ["initial_capital", "variant"]
|
||||
for param in required_params:
|
||||
if param not in parameters:
|
||||
return False
|
||||
|
||||
if parameters["initial_capital"] <= 0:
|
||||
return False
|
||||
|
||||
valid_variants = ["BAA-G12", "BAA-G4", "BAA-SPY", "BAA-G12/T3", "BAA-G4/T2"]
|
||||
if parameters.get("variant") not in valid_variants:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _calculate_sma12_momentum(self, prices: pd.Series) -> float:
|
||||
"""SMA(12) 모멘텀 계산: pt / SMA(13) - 1"""
|
||||
if len(prices) < 13:
|
||||
return 0.0
|
||||
|
||||
# numpy scalar 또는 pandas Series를 float로 변환
|
||||
mean_val = prices.tail(13).mean()
|
||||
sma13 = float(mean_val.item()) if hasattr(mean_val, 'item') else float(mean_val)
|
||||
|
||||
current_val = prices.iloc[-1]
|
||||
current_price = float(current_val.item()) if hasattr(current_val, 'item') else float(current_val)
|
||||
|
||||
return (current_price / sma13) - 1
|
||||
|
||||
def _calculate_13612w_momentum(self, prices: pd.Series) -> float:
|
||||
"""13612W 모멘텀 계산: 가중평균 (1m:12, 3m:4, 6m:2, 12m:1)"""
|
||||
if len(prices) < 252: # 최소 12개월 데이터 필요
|
||||
return 0.0
|
||||
|
||||
current_val = prices.iloc[-1]
|
||||
current_price = float(current_val.item()) if hasattr(current_val, 'item') else float(current_val)
|
||||
|
||||
# 각 기간별 수익률 계산
|
||||
def get_price(idx):
|
||||
val = prices.iloc[idx]
|
||||
return float(val.item()) if hasattr(val, 'item') else float(val)
|
||||
|
||||
ret_1m = (current_price / get_price(-21) - 1) if len(prices) > 21 else 0.0
|
||||
ret_3m = (current_price / get_price(-63) - 1) if len(prices) > 63 else 0.0
|
||||
ret_6m = (current_price / get_price(-126) - 1) if len(prices) > 126 else 0.0
|
||||
ret_12m = (current_price / get_price(-252) - 1) if len(prices) > 252 else 0.0
|
||||
|
||||
# 가중평균
|
||||
weighted_momentum = (12 * ret_1m + 4 * ret_3m + 2 * ret_6m + 1 * ret_12m) / 19
|
||||
return float(weighted_momentum)
|
||||
|
||||
def _get_ticker_data(self, ticker: str, end_date: datetime) -> pd.Series:
|
||||
"""특정 티커의 가격 데이터 가져오기"""
|
||||
start_date = end_date - timedelta(days=400) # 13개월 + 여유
|
||||
|
||||
try:
|
||||
data = yf.download(ticker, start=start_date, end=end_date, progress=False, auto_adjust=True)
|
||||
if data.empty:
|
||||
return pd.Series()
|
||||
|
||||
# 데이터 구조에 따라 처리
|
||||
if 'Close' in data.columns:
|
||||
return data['Close']
|
||||
elif isinstance(data, pd.Series):
|
||||
return data
|
||||
else:
|
||||
return pd.Series()
|
||||
except Exception as e:
|
||||
print(f"Error downloading {ticker}: {e}")
|
||||
return pd.Series()
|
||||
|
||||
def _calculate_portfolio_real_data(self, parameters: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""실제 데이터를 사용한 포트폴리오 계산"""
|
||||
variant = parameters.get("variant", "BAA-G12")
|
||||
as_of_date = parameters.get("as_of_date")
|
||||
initial_capital = parameters["initial_capital"]
|
||||
|
||||
# 기준일 설정
|
||||
if as_of_date is None:
|
||||
as_of_date = datetime.now()
|
||||
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"])
|
||||
offensive_top = parameters.get("offensive_top", config["offensive_top"])
|
||||
defensive_top = parameters.get("defensive_top", 3)
|
||||
breadth_param = parameters.get("breadth_param", 1)
|
||||
|
||||
# 카나리아 유니버스 체크 (13612W 모멘텀 사용)
|
||||
canary_bad_count = 0
|
||||
canary_status = {}
|
||||
|
||||
for ticker in config["canary"]:
|
||||
prices = self._get_ticker_data(ticker, as_of_date)
|
||||
if not prices.empty:
|
||||
momentum = self._calculate_13612w_momentum(prices)
|
||||
is_bad = bool(momentum < 0)
|
||||
canary_status[ticker] = {
|
||||
"momentum": float(momentum),
|
||||
"is_bad": is_bad
|
||||
}
|
||||
if is_bad:
|
||||
canary_bad_count += 1
|
||||
|
||||
# 방어 모드 여부 결정
|
||||
is_defensive = canary_bad_count >= breadth_param
|
||||
|
||||
# 포트폴리오 구성
|
||||
portfolio = {}
|
||||
current_prices = {}
|
||||
|
||||
if is_defensive:
|
||||
# 방어 유니버스에서 선택
|
||||
defensive_scores = {}
|
||||
for ticker in config["defensive"]:
|
||||
prices = self._get_ticker_data(ticker, as_of_date)
|
||||
if not prices.empty:
|
||||
momentum = self._calculate_sma12_momentum(prices)
|
||||
defensive_scores[ticker] = momentum
|
||||
price_val = prices.iloc[-1]
|
||||
current_prices[ticker] = float(price_val.item()) if hasattr(price_val, 'item') else float(price_val)
|
||||
|
||||
# 상위 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]:
|
||||
# BIL보다 나쁜 자산은 BIL로 교체
|
||||
bil_prices = self._get_ticker_data("BIL", as_of_date)
|
||||
bil_momentum = self._calculate_sma12_momentum(bil_prices) if not bil_prices.empty else 0
|
||||
|
||||
if momentum < bil_momentum:
|
||||
selected_assets.append("BIL")
|
||||
if "BIL" not in current_prices and not bil_prices.empty:
|
||||
price_val = bil_prices.iloc[-1]
|
||||
current_prices["BIL"] = float(price_val.item()) if hasattr(price_val, 'item') else float(price_val)
|
||||
else:
|
||||
selected_assets.append(ticker)
|
||||
|
||||
# 선택된 자산이 없는 경우 BIL로 대체
|
||||
if not selected_assets:
|
||||
selected_assets = ["BIL"]
|
||||
bil_prices = self._get_ticker_data("BIL", as_of_date)
|
||||
if not bil_prices.empty:
|
||||
price_val = bil_prices.iloc[-1]
|
||||
current_prices["BIL"] = float(price_val.item()) if hasattr(price_val, 'item') else float(price_val)
|
||||
|
||||
# 동일 가중
|
||||
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 = self._get_ticker_data(ticker, as_of_date)
|
||||
if not prices.empty:
|
||||
momentum = self._calculate_sma12_momentum(prices)
|
||||
offensive_scores[ticker] = momentum
|
||||
price_val = prices.iloc[-1]
|
||||
current_prices[ticker] = float(price_val.item()) if hasattr(price_val, 'item') else float(price_val)
|
||||
|
||||
# 상위 offensive_top개 선택
|
||||
sorted_offensive = sorted(offensive_scores.items(), key=lambda x: x[1], reverse=True)
|
||||
selected_assets = [ticker for ticker, _ in sorted_offensive[:offensive_top]]
|
||||
|
||||
# 선택된 자산이 없는 경우 BIL로 대체
|
||||
if not selected_assets:
|
||||
selected_assets = ["BIL"]
|
||||
bil_prices = self._get_ticker_data("BIL", as_of_date)
|
||||
if not bil_prices.empty:
|
||||
price_val = bil_prices.iloc[-1]
|
||||
current_prices["BIL"] = float(price_val.item()) if hasattr(price_val, 'item') else float(price_val)
|
||||
|
||||
# 동일 가중
|
||||
weight_per_asset = 1.0 / len(selected_assets)
|
||||
for ticker in selected_assets:
|
||||
portfolio[ticker] = weight_per_asset
|
||||
|
||||
# 실제 매수 수량 계산
|
||||
allocation_details = []
|
||||
total_allocated = 0
|
||||
|
||||
for ticker, weight in portfolio.items():
|
||||
if ticker in current_prices:
|
||||
allocation_amount = float(initial_capital * weight)
|
||||
price = float(current_prices[ticker])
|
||||
shares = int(allocation_amount / price) # 정수 주식만 매수
|
||||
actual_amount = float(shares * price)
|
||||
|
||||
allocation_details.append({
|
||||
"ticker": ticker,
|
||||
"weight": round(weight * 100, 2),
|
||||
"target_amount": round(allocation_amount, 2),
|
||||
"current_price": round(price, 2),
|
||||
"shares": shares,
|
||||
"actual_amount": round(actual_amount, 2)
|
||||
})
|
||||
total_allocated += actual_amount
|
||||
|
||||
cash_remaining = initial_capital - total_allocated
|
||||
|
||||
return {
|
||||
"as_of_date": as_of_date.strftime("%Y-%m-%d"),
|
||||
"mode": "defensive" if is_defensive else "offensive",
|
||||
"canary_status": canary_status,
|
||||
"canary_bad_count": canary_bad_count,
|
||||
"breadth_threshold": breadth_param,
|
||||
"portfolio": allocation_details,
|
||||
"total_allocated": round(total_allocated, 2),
|
||||
"cash_remaining": round(cash_remaining, 2),
|
||||
"initial_capital": initial_capital,
|
||||
"portfolio_summary": {
|
||||
ticker: weight for ticker, weight in portfolio.items()
|
||||
}
|
||||
}
|
||||
|
||||
def execute(self, parameters: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
if parameters is None:
|
||||
parameters = self.default_parameters
|
||||
|
||||
if not self.validate_parameters(parameters):
|
||||
raise ValueError("Invalid parameters")
|
||||
|
||||
# 실제 데이터를 사용하는 경우
|
||||
if parameters.get("use_real_data", False):
|
||||
real_portfolio = self._calculate_portfolio_real_data(parameters)
|
||||
return {
|
||||
"strategy": self.name,
|
||||
"version": self.version,
|
||||
"variant": parameters.get("variant", "BAA-G12"),
|
||||
"mode": "real_data_portfolio",
|
||||
"execution_time": "variable",
|
||||
"parameters_used": parameters,
|
||||
**real_portfolio
|
||||
}
|
||||
|
||||
variant = parameters.get("variant", "BAA-G12")
|
||||
|
||||
# 시뮬레이션 실행
|
||||
time.sleep(1.8) # 실행 시간 시뮬레이션
|
||||
|
||||
# 전략 변형에 따른 결과 시뮬레이션 (Full Sample 기준)
|
||||
variant_stats = {
|
||||
"BAA-G12": {
|
||||
"cagr": 0.146,
|
||||
"max_dd": 0.087,
|
||||
"volatility": 0.085,
|
||||
"sharpe": 1.19,
|
||||
"upi": 4.81,
|
||||
"defensive_fraction": 0.572,
|
||||
"turnover": 4.72,
|
||||
"offensive_assets": ["SPY", "QQQ", "IWM", "VGK", "EWJ", "VWO", "VNQ", "DBC", "GLD", "TLT", "HYG", "LQD"],
|
||||
"defensive_assets": ["TIP", "DBC", "BIL", "IEF", "TLT", "LQD", "BND"],
|
||||
"canary_assets": ["SPY", "VWO", "VEA", "BND"]
|
||||
},
|
||||
"BAA-G4": {
|
||||
"cagr": 0.210,
|
||||
"max_dd": 0.146,
|
||||
"volatility": 0.136,
|
||||
"sharpe": 1.21,
|
||||
"upi": 5.20,
|
||||
"defensive_fraction": 0.572,
|
||||
"turnover": 5.23,
|
||||
"offensive_assets": ["QQQ", "VWO", "VEA", "BND"],
|
||||
"defensive_assets": ["TIP", "DBC", "BIL", "IEF", "TLT", "LQD", "BND"],
|
||||
"canary_assets": ["SPY", "VWO", "VEA", "BND"]
|
||||
},
|
||||
"BAA-G12/T3": {
|
||||
"cagr": 0.164,
|
||||
"max_dd": 0.114,
|
||||
"volatility": 0.104,
|
||||
"sharpe": 1.13,
|
||||
"upi": 4.23,
|
||||
"defensive_fraction": 0.572,
|
||||
"turnover": 5.13,
|
||||
"offensive_assets": ["SPY", "QQQ", "IWM", "VGK", "EWJ", "VWO", "VNQ", "DBC", "GLD", "TLT", "HYG", "LQD"],
|
||||
"defensive_assets": ["TIP", "DBC", "BIL", "IEF", "TLT", "LQD", "BND"],
|
||||
"canary_assets": ["SPY", "VWO", "VEA", "BND"]
|
||||
},
|
||||
"BAA-G4/T2": {
|
||||
"cagr": 0.177,
|
||||
"max_dd": 0.127,
|
||||
"volatility": 0.106,
|
||||
"sharpe": 1.25,
|
||||
"upi": 5.08,
|
||||
"defensive_fraction": 0.572,
|
||||
"turnover": 5.02,
|
||||
"offensive_assets": ["QQQ", "VWO", "VEA", "BND"],
|
||||
"defensive_assets": ["TIP", "DBC", "BIL", "IEF", "TLT", "LQD", "BND"],
|
||||
"canary_assets": ["SPY", "VWO", "VEA", "BND"]
|
||||
},
|
||||
"BAA-SPY": {
|
||||
"cagr": 0.114,
|
||||
"max_dd": 0.162,
|
||||
"volatility": 0.095,
|
||||
"sharpe": 0.72,
|
||||
"upi": 1.88,
|
||||
"defensive_fraction": 0.572,
|
||||
"turnover": 4.73,
|
||||
"offensive_assets": ["SPY"],
|
||||
"defensive_assets": ["TIP", "DBC", "BIL", "IEF", "TLT", "LQD", "BND"],
|
||||
"canary_assets": ["SPY", "VWO", "VEA", "BND"]
|
||||
}
|
||||
}
|
||||
|
||||
stats = variant_stats.get(variant, variant_stats["BAA-G12"])
|
||||
|
||||
# 약간의 랜덤성 추가 (±5%)
|
||||
cagr = stats["cagr"] * random.uniform(0.95, 1.05)
|
||||
max_dd = stats["max_dd"] * random.uniform(0.95, 1.05)
|
||||
volatility = stats["volatility"] * random.uniform(0.95, 1.05)
|
||||
|
||||
# 최종 자본 계산 (CAGR 기반 - 간단히 1년 성과로 시뮬레이션)
|
||||
final_capital = parameters["initial_capital"] * (1 + cagr)
|
||||
profit_loss = final_capital - parameters["initial_capital"]
|
||||
profit_rate = (profit_loss / parameters["initial_capital"]) * 100
|
||||
|
||||
# 거래 횟수 (Turnover 기반)
|
||||
trades_count = int(stats["turnover"] * 12 * random.uniform(0.9, 1.1)) # 연간 turnover
|
||||
|
||||
# Keller Ratio 계산: K = R(1-2D/(1-2D)) when D<25%
|
||||
if max_dd < 0.25:
|
||||
keller_ratio = cagr * (1 - 2 * max_dd) / (1 - 2 * max_dd)
|
||||
else:
|
||||
keller_ratio = 0.0
|
||||
|
||||
return {
|
||||
"strategy": self.name,
|
||||
"version": self.version,
|
||||
"variant": variant,
|
||||
"profit_loss": round(profit_loss, 2),
|
||||
"profit_rate": round(profit_rate, 2),
|
||||
"final_capital": round(final_capital, 2),
|
||||
"cagr": round(cagr * 100, 2),
|
||||
"max_drawdown": round(max_dd * 100, 2),
|
||||
"volatility": round(volatility * 100, 2),
|
||||
"sharpe_ratio": round(stats["sharpe"], 2),
|
||||
"upi": round(stats["upi"], 2),
|
||||
"keller_ratio": round(keller_ratio * 100, 2),
|
||||
"defensive_fraction": round(stats["defensive_fraction"] * 100, 2),
|
||||
"trades_executed": trades_count,
|
||||
"annual_turnover": round(stats["turnover"] * 100, 2),
|
||||
"execution_time": "1.8s",
|
||||
"parameters_used": parameters,
|
||||
"portfolio_details": {
|
||||
"offensive_universe": stats["offensive_assets"],
|
||||
"defensive_universe": stats["defensive_assets"],
|
||||
"canary_universe": stats["canary_assets"],
|
||||
"offensive_selection": parameters.get("offensive_top", 6),
|
||||
"defensive_selection": parameters.get("defensive_top", 3),
|
||||
"relative_momentum": "SMA(12)",
|
||||
"absolute_momentum": "13612W",
|
||||
"breadth_parameter": parameters.get("breadth_param", 1)
|
||||
},
|
||||
"methodology": {
|
||||
"relative_momentum_filter": "SMA(12) - slow momentum for offensive/defensive selection",
|
||||
"absolute_momentum_filter": "13612W - fast momentum for canary universe",
|
||||
"crash_protection": "Switch to defensive when any canary asset shows negative momentum (B=1)",
|
||||
"rebalancing": "Monthly, last trading day"
|
||||
}
|
||||
}
|
||||
76
strategies/impls/mean_reversion.py
Normal file
76
strategies/impls/mean_reversion.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
평균 회귀(Mean Reversion) 전략 구현체
|
||||
|
||||
RSI, 볼린저밴드 등 과매수/과매도 구간에서 반대 방향으로 거래하는 전략들을 포함합니다.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any
|
||||
import time
|
||||
import random
|
||||
from ..base import BaseQuantStrategy, strategy
|
||||
|
||||
|
||||
@strategy
|
||||
class RSIMeanReversion(BaseQuantStrategy):
|
||||
"""RSI 평균회귀 전략"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "RSIMeanReversion"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "RSI 지표를 이용한 평균회귀 전략. RSI가 과매수/과매도 구간에서 반대 방향으로 거래"
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return "1.0.0"
|
||||
|
||||
@property
|
||||
def default_parameters(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"rsi_period": 14,
|
||||
"oversold_threshold": 30,
|
||||
"overbought_threshold": 70,
|
||||
"initial_capital": 100000,
|
||||
"position_size": 0.05
|
||||
}
|
||||
|
||||
def validate_parameters(self, parameters: Dict[str, Any]) -> bool:
|
||||
required_params = ["rsi_period", "oversold_threshold", "overbought_threshold", "initial_capital"]
|
||||
for param in required_params:
|
||||
if param not in parameters:
|
||||
return False
|
||||
|
||||
if not (0 < parameters["oversold_threshold"] < parameters["overbought_threshold"] < 100):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def execute(self, parameters: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
if parameters is None:
|
||||
parameters = self.default_parameters
|
||||
|
||||
if not self.validate_parameters(parameters):
|
||||
raise ValueError("Invalid parameters")
|
||||
|
||||
# 시뮬레이션 실행
|
||||
time.sleep(1.5) # 실행 시간 시뮬레이션
|
||||
|
||||
# 모의 결과 생성
|
||||
profit_rate = random.uniform(-0.10, 0.18)
|
||||
trades_count = random.randint(25, 80)
|
||||
win_rate = random.uniform(0.40, 0.65)
|
||||
|
||||
return {
|
||||
"strategy": self.name,
|
||||
"version": self.version,
|
||||
"profit_loss": round(parameters["initial_capital"] * profit_rate, 2),
|
||||
"profit_rate": round(profit_rate * 100, 2),
|
||||
"trades_executed": trades_count,
|
||||
"win_rate": round(win_rate, 3),
|
||||
"execution_time": "1.5s",
|
||||
"parameters_used": parameters,
|
||||
"final_capital": round(parameters["initial_capital"] * (1 + profit_rate), 2),
|
||||
"max_drawdown": round(random.uniform(0.05, 0.20), 3)
|
||||
}
|
||||
74
strategies/impls/trend_following.py
Normal file
74
strategies/impls/trend_following.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
추세 추종(Trend Following) 전략 구현체
|
||||
|
||||
이동평균선, 모멘텀 등 추세를 따라가는 전략들을 포함합니다.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any
|
||||
import time
|
||||
import random
|
||||
from ..base import BaseQuantStrategy, strategy
|
||||
|
||||
|
||||
@strategy
|
||||
class MovingAverageCrossover(BaseQuantStrategy):
|
||||
"""이동평균선 교차 전략"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "MovingAverageCrossover"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "단기 이동평균선이 장기 이동평균선을 상향 돌파할 때 매수, 하향 돌파할 때 매도하는 전략"
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return "1.0.0"
|
||||
|
||||
@property
|
||||
def default_parameters(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"short_window": 20,
|
||||
"long_window": 50,
|
||||
"initial_capital": 100000,
|
||||
"position_size": 0.1
|
||||
}
|
||||
|
||||
def validate_parameters(self, parameters: Dict[str, Any]) -> bool:
|
||||
required_params = ["short_window", "long_window", "initial_capital"]
|
||||
for param in required_params:
|
||||
if param not in parameters:
|
||||
return False
|
||||
|
||||
if parameters["short_window"] >= parameters["long_window"]:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def execute(self, parameters: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
if parameters is None:
|
||||
parameters = self.default_parameters
|
||||
|
||||
if not self.validate_parameters(parameters):
|
||||
raise ValueError("Invalid parameters")
|
||||
|
||||
# 시뮬레이션 실행
|
||||
time.sleep(1) # 실행 시간 시뮬레이션
|
||||
|
||||
# 모의 결과 생성
|
||||
profit_rate = random.uniform(-0.15, 0.25)
|
||||
trades_count = random.randint(15, 60)
|
||||
win_rate = random.uniform(0.45, 0.75)
|
||||
|
||||
return {
|
||||
"strategy": self.name,
|
||||
"version": self.version,
|
||||
"profit_loss": round(parameters["initial_capital"] * profit_rate, 2),
|
||||
"profit_rate": round(profit_rate * 100, 2),
|
||||
"trades_executed": trades_count,
|
||||
"win_rate": round(win_rate, 3),
|
||||
"execution_time": "1.2s",
|
||||
"parameters_used": parameters,
|
||||
"final_capital": round(parameters["initial_capital"] * (1 + profit_rate), 2)
|
||||
}
|
||||
77
strategies/impls/volatility_breakout.py
Normal file
77
strategies/impls/volatility_breakout.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
변동성 돌파(Volatility Breakout) 전략 구현체
|
||||
|
||||
볼린저 밴드, ATR 등 변동성 기반 돌파 전략들을 포함합니다.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any
|
||||
import time
|
||||
import random
|
||||
from ..base import BaseQuantStrategy, strategy
|
||||
|
||||
|
||||
@strategy
|
||||
class BollingerBandBreakout(BaseQuantStrategy):
|
||||
"""볼린저 밴드 돌파 전략"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "BollingerBandBreakout"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "볼린저 밴드 상한선 돌파시 매수, 하한선 돌파시 매도하는 돌파 전략"
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return "2.0.0"
|
||||
|
||||
@property
|
||||
def default_parameters(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"period": 20,
|
||||
"std_dev": 2.0,
|
||||
"initial_capital": 100000,
|
||||
"position_size": 0.08,
|
||||
"stop_loss": 0.05
|
||||
}
|
||||
|
||||
def validate_parameters(self, parameters: Dict[str, Any]) -> bool:
|
||||
required_params = ["period", "std_dev", "initial_capital"]
|
||||
for param in required_params:
|
||||
if param not in parameters:
|
||||
return False
|
||||
|
||||
if parameters["std_dev"] <= 0 or parameters["period"] <= 0:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def execute(self, parameters: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
if parameters is None:
|
||||
parameters = self.default_parameters
|
||||
|
||||
if not self.validate_parameters(parameters):
|
||||
raise ValueError("Invalid parameters")
|
||||
|
||||
# 시뮬레이션 실행
|
||||
time.sleep(2) # 실행 시간 시뮬레이션
|
||||
|
||||
# 모의 결과 생성
|
||||
profit_rate = random.uniform(-0.20, 0.30)
|
||||
trades_count = random.randint(10, 40)
|
||||
win_rate = random.uniform(0.35, 0.70)
|
||||
|
||||
return {
|
||||
"strategy": self.name,
|
||||
"version": self.version,
|
||||
"profit_loss": round(parameters["initial_capital"] * profit_rate, 2),
|
||||
"profit_rate": round(profit_rate * 100, 2),
|
||||
"trades_executed": trades_count,
|
||||
"win_rate": round(win_rate, 3),
|
||||
"execution_time": "2.0s",
|
||||
"parameters_used": parameters,
|
||||
"final_capital": round(parameters["initial_capital"] * (1 + profit_rate), 2),
|
||||
"sharpe_ratio": round(random.uniform(0.5, 2.5), 2),
|
||||
"volatility": round(random.uniform(0.15, 0.35), 3)
|
||||
}
|
||||
0
strategies/management/__init__.py
Normal file
0
strategies/management/__init__.py
Normal file
0
strategies/management/commands/__init__.py
Normal file
0
strategies/management/commands/__init__.py
Normal file
78
strategies/management/commands/init_strategies.py
Normal file
78
strategies/management/commands/init_strategies.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
전략 레지스트리를 DB에 초기화하는 management command
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from strategies.base import StrategyRegistry
|
||||
from strategies.models import QuantStrategy, StrategyVersion
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '레지스트리에 등록된 모든 전략을 데이터베이스에 초기화합니다'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write(self.style.MIGRATE_HEADING('전략 초기화 시작...'))
|
||||
|
||||
# 레지스트리에서 모든 전략 가져오기
|
||||
available_strategies = StrategyRegistry.list_strategies()
|
||||
|
||||
created_strategies = 0
|
||||
updated_strategies = 0
|
||||
created_versions = 0
|
||||
|
||||
for strategy_name, strategy_info in available_strategies.items():
|
||||
# QuantStrategy 생성 또는 업데이트
|
||||
strategy_obj, created = QuantStrategy.objects.get_or_create(
|
||||
name=strategy_name,
|
||||
defaults={
|
||||
'description': strategy_info['description'],
|
||||
'is_active': True
|
||||
}
|
||||
)
|
||||
|
||||
if created:
|
||||
created_strategies += 1
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f' ✓ 전략 생성: {strategy_name}')
|
||||
)
|
||||
else:
|
||||
# 설명이 변경된 경우 업데이트
|
||||
if strategy_obj.description != strategy_info['description']:
|
||||
strategy_obj.description = strategy_info['description']
|
||||
strategy_obj.save()
|
||||
updated_strategies += 1
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f' ⟳ 전략 업데이트: {strategy_name}')
|
||||
)
|
||||
|
||||
# 각 버전 생성
|
||||
for version_info in strategy_info['versions']:
|
||||
version_obj, version_created = StrategyVersion.objects.get_or_create(
|
||||
strategy=strategy_obj,
|
||||
version=version_info['version'],
|
||||
defaults={
|
||||
'implementation_key': f"{strategy_name}:{version_info['version']}",
|
||||
'parameters': version_info['default_parameters'],
|
||||
'is_current': True # 각 전략의 버전을 현재 버전으로 설정
|
||||
}
|
||||
)
|
||||
|
||||
if version_created:
|
||||
created_versions += 1
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f' → 버전 생성: v{version_info["version"]}')
|
||||
)
|
||||
else:
|
||||
# 파라미터가 변경된 경우 업데이트
|
||||
if version_obj.parameters != version_info['default_parameters']:
|
||||
version_obj.parameters = version_info['default_parameters']
|
||||
version_obj.save()
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f' → 버전 업데이트: v{version_info["version"]}')
|
||||
)
|
||||
|
||||
self.stdout.write('')
|
||||
self.stdout.write(self.style.MIGRATE_HEADING('초기화 완료!'))
|
||||
self.stdout.write(self.style.SUCCESS(f' 생성된 전략: {created_strategies}개'))
|
||||
self.stdout.write(self.style.SUCCESS(f' 업데이트된 전략: {updated_strategies}개'))
|
||||
self.stdout.write(self.style.SUCCESS(f' 생성된 버전: {created_versions}개'))
|
||||
self.stdout.write(self.style.SUCCESS(f' 총 전략 수: {len(available_strategies)}개'))
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-04 13:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('strategies', '0002_remove_strategyversion_code_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='strategyexecution',
|
||||
name='callback_response',
|
||||
field=models.JSONField(blank=True, help_text='콜백 응답 데이터', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='strategyexecution',
|
||||
name='callback_sent',
|
||||
field=models.BooleanField(default=False, help_text='콜백 전송 완료 여부'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='strategyexecution',
|
||||
name='callback_sent_at',
|
||||
field=models.DateTimeField(blank=True, help_text='콜백 전송 일시', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='strategyexecution',
|
||||
name='callback_url',
|
||||
field=models.URLField(blank=True, help_text='전략 실행 완료 후 결과를 전송할 콜백 URL', max_length=500, null=True),
|
||||
),
|
||||
]
|
||||
@@ -73,6 +73,10 @@ class StrategyExecution(models.Model):
|
||||
completed_at = models.DateTimeField(null=True, blank=True)
|
||||
result = models.JSONField(null=True, blank=True)
|
||||
error_message = models.TextField(blank=True)
|
||||
callback_url = models.URLField(max_length=500, blank=True, null=True, help_text="전략 실행 완료 후 결과를 전송할 콜백 URL")
|
||||
callback_sent = models.BooleanField(default=False, help_text="콜백 전송 완료 여부")
|
||||
callback_sent_at = models.DateTimeField(null=True, blank=True, help_text="콜백 전송 일시")
|
||||
callback_response = models.JSONField(null=True, blank=True, help_text="콜백 응답 데이터")
|
||||
|
||||
def __str__(self):
|
||||
return f"Execution of {self.strategy_version} - {self.status}"
|
||||
|
||||
@@ -6,11 +6,15 @@ from django.utils import timezone
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import requests
|
||||
import logging
|
||||
|
||||
from .models import QuantStrategy, StrategyVersion, StrategyExecution
|
||||
from .base import StrategyRegistry
|
||||
from . import implementations # 구현체들을 로드하여 레지스트리에 등록
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
def list_strategies(request):
|
||||
@@ -55,6 +59,7 @@ def execute_strategy(request):
|
||||
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({
|
||||
@@ -79,7 +84,8 @@ def execute_strategy(request):
|
||||
execution = StrategyExecution.objects.create(
|
||||
strategy_version=strategy_version,
|
||||
execution_parameters=execution_parameters,
|
||||
status='pending'
|
||||
status='pending',
|
||||
callback_url=callback_url
|
||||
)
|
||||
|
||||
def run_strategy():
|
||||
@@ -108,13 +114,19 @@ def execute_strategy(request):
|
||||
execution.completed_at = timezone.now()
|
||||
execution.save()
|
||||
|
||||
finally:
|
||||
# 콜백 URL이 있으면 결과 전송
|
||||
if execution.callback_url:
|
||||
send_callback(execution)
|
||||
|
||||
thread = threading.Thread(target=run_strategy)
|
||||
thread.start()
|
||||
|
||||
return JsonResponse({
|
||||
'execution_id': execution.id,
|
||||
'status': 'pending',
|
||||
'message': 'Strategy execution started'
|
||||
'message': 'Strategy execution started',
|
||||
'callback_url': callback_url
|
||||
})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
@@ -127,6 +139,65 @@ def execute_strategy(request):
|
||||
}, status=500)
|
||||
|
||||
|
||||
def send_callback(execution):
|
||||
"""전략 실행 완료 후 콜백 URL로 결과 전송"""
|
||||
if not execution.callback_url:
|
||||
return
|
||||
|
||||
try:
|
||||
# 콜백 데이터 구성
|
||||
callback_data = {
|
||||
'execution_id': execution.id,
|
||||
'strategy': execution.strategy_version.strategy.name,
|
||||
'version': execution.strategy_version.version,
|
||||
'status': execution.status,
|
||||
'started_at': execution.started_at.isoformat(),
|
||||
'completed_at': execution.completed_at.isoformat() if execution.completed_at else None,
|
||||
'execution_parameters': execution.execution_parameters
|
||||
}
|
||||
|
||||
if execution.status == 'completed' and execution.result:
|
||||
callback_data['result'] = execution.result
|
||||
|
||||
if execution.status == 'failed' and execution.error_message:
|
||||
callback_data['error_message'] = execution.error_message
|
||||
|
||||
# POST 요청 전송 (타임아웃 10초)
|
||||
response = requests.post(
|
||||
execution.callback_url,
|
||||
json=callback_data,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
# 콜백 전송 결과 저장
|
||||
execution.callback_sent = True
|
||||
execution.callback_sent_at = timezone.now()
|
||||
execution.callback_response = {
|
||||
'status_code': response.status_code,
|
||||
'response_text': response.text[:500], # 처음 500자만 저장
|
||||
'headers': dict(response.headers)
|
||||
}
|
||||
execution.save()
|
||||
|
||||
logger.info(f"Callback sent successfully to {execution.callback_url} for execution {execution.id}")
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.error(f"Callback timeout for execution {execution.id} to {execution.callback_url}")
|
||||
execution.callback_response = {'error': 'timeout'}
|
||||
execution.save()
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Callback failed for execution {execution.id} to {execution.callback_url}: {str(e)}")
|
||||
execution.callback_response = {'error': str(e)}
|
||||
execution.save()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error sending callback for execution {execution.id}: {str(e)}")
|
||||
execution.callback_response = {'error': f'unexpected_error: {str(e)}'}
|
||||
execution.save()
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
def execution_status(request, execution_id):
|
||||
execution = get_object_or_404(StrategyExecution, id=execution_id)
|
||||
@@ -149,6 +220,15 @@ def execution_status(request, execution_id):
|
||||
if execution.status == 'failed' and execution.error_message:
|
||||
response_data['error_message'] = execution.error_message
|
||||
|
||||
# 콜백 정보 추가
|
||||
if execution.callback_url:
|
||||
response_data['callback'] = {
|
||||
'url': execution.callback_url,
|
||||
'sent': execution.callback_sent,
|
||||
'sent_at': execution.callback_sent_at.isoformat() if execution.callback_sent_at else None,
|
||||
'response': execution.callback_response
|
||||
}
|
||||
|
||||
return JsonResponse(response_data)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user