""" 백테스트 엔진 3가지 주요 기능: 1. ML 학습 데이터 생성 (TensorFlow/PyTorch 학습용 라벨링) 2. 전략 성과 백테스트 (과거 수익률 시뮬레이션) 3. 파라미터 최적화 (VectorBT) """ import asyncio import logging import random from collections import defaultdict, deque from datetime import date, datetime, timedelta from typing import Dict, List, Any, Optional, Tuple from dataclasses import dataclass, asdict from decimal import Decimal import numpy as np import pandas as pd from sqlalchemy import select, and_ from app.core.database import AsyncSessionLocal from app.models.models import StockDailyPrice from app.services.feature_engineering import ( db_data_to_dataframe, extract_features, prepare_training_data, FEATURE_COLUMNS, ) logger = logging.getLogger(__name__) # ── 슬리피지 모델 (국내 주식 0.1%) ────────────────────────────────────────────── # QA 검증 항목: 슬리피지 미반영 시 백테스트 수익률이 실전 대비 과대 추정됨 # kr_backtest_engine.py와 동일한 패턴 적용 KR_SLIPPAGE_PCT = 0.001 # +/-0.1% (국내 주식 평균 슬리피지) def _apply_buy_slippage(price: float, seed: Optional[int] = None) -> float: """매수 시 슬리피지: 시장가 주문 시 약간 높은 가격에 체결 매수 시에는 호가 스프레드와 시장 충격으로 인해 기대 가격보다 0~0.1% 높은 가격에 체결되는 현실을 반영합니다. """ rng = random.Random(seed) slip = rng.uniform(0, KR_SLIPPAGE_PCT) return price * (1 + slip) def _apply_sell_slippage(price: float, seed: Optional[int] = None) -> float: """매도 시 슬리피지: 시장가 주문 시 약간 낮은 가격에 체결 매도 시에는 호가 스프레드와 시장 충격으로 인해 기대 가격보다 0~0.1% 낮은 가격에 체결되는 현실을 반영합니다. """ rng = random.Random(seed) slip = rng.uniform(0, KR_SLIPPAGE_PCT) return price * (1 - slip) # ── 데이터 클래스 ──────────────────────────────────────────────────────────── @dataclass class TrainingDataConfig: """ML 학습 데이터 생성 설정""" stock_codes: List[str] start_date: date end_date: date forward_days: int = 5 # N일 후 수익률로 라벨링 up_threshold: float = 0.03 # +3% 이상 = UP (label 2) down_threshold: float = -0.03 # -3% 이하 = DOWN (label 0) min_samples_per_stock: int = 50 # 종목당 최소 샘플 수 @dataclass class BacktestConfig: """전략 성과 백테스트 설정""" stock_codes: List[str] start_date: date end_date: date initial_capital: float = 10_000_000 strategy_params: Dict[str, Any] = None # 프리셋 파라미터 max_positions: int = 5 position_size: float = 0.2 # 종목당 20% commission_rate: float = 0.00015 # 매수/매도 수수료 0.015% tax_rate: float = 0.0023 # 매도세 0.23% @dataclass class Trade: """거래 기록""" date: date stock_code: str action: str # BUY | SELL price: float quantity: int amount: float commission: float reason: str # 진입/청산 이유 @dataclass class Position: """보유 포지션""" stock_code: str quantity: int avg_price: float entry_date: date entry_reason: str current_price: float = 0.0 unrealized_pnl: float = 0.0 unrealized_pnl_pct: float = 0.0 @dataclass class BacktestResult: """백테스트 결과""" config: Dict[str, Any] total_trades: int win_trades: int lose_trades: int win_rate: float total_return: float total_return_pct: float mdd: float # Maximum Drawdown mdd_pct: float sharpe_ratio: float sortino_ratio: float profit_factor: float # 총이익 / 총손실 avg_win: float avg_lose: float max_win: float max_lose: float equity_curve: List[Dict[str, Any]] # 일별 자산 추이 trades: List[Dict[str, Any]] # 거래 내역 final_positions: List[Dict[str, Any]] # 최종 보유 종목 # ── 백테스트 엔진 ───────────────────────────────────────────────────────────── class BacktestEngine: """백테스트 핵심 엔진""" def __init__(self): self.cash = 0.0 self.positions: Dict[str, Position] = {} self.trades: List[Trade] = [] self.equity_curve: List[Dict[str, Any]] = [] self.daily_returns: List[float] = [] async def _fetch_daily_data( self, stock_codes: List[str], start_date: date, end_date: date ) -> Dict[str, pd.DataFrame]: """DB에서 일봉 데이터 조회""" result = {} async with AsyncSessionLocal() as session: for code in stock_codes: stmt = select(StockDailyPrice).where( and_( StockDailyPrice.stock_code == code, StockDailyPrice.trade_date >= start_date, StockDailyPrice.trade_date <= end_date ) ).order_by(StockDailyPrice.trade_date) db_result = await session.execute(stmt) rows = db_result.scalars().all() if rows: df = db_data_to_dataframe([{ "trade_date": r.trade_date, "open_price": float(r.open_price), "high_price": float(r.high_price), "low_price": float(r.low_price), "close_price": float(r.close_price), "volume": float(r.volume), } for r in rows]) result[code] = df return result def _calculate_commission(self, amount: float, is_sell: bool, config: BacktestConfig) -> float: """수수료 계산 (매수/매도 수수료 + 매도세)""" commission = amount * config.commission_rate if is_sell: commission += amount * config.tax_rate return commission def _execute_buy( self, stock_code: str, price: float, amount: float, current_date: date, reason: str, config: BacktestConfig ): """매수 실행 (슬리피지 적용: 실제 체결가는 신호가보다 높음)""" # 슬리피지 적용: 매수 시 약간 높은 가격에 체결 exec_price = _apply_buy_slippage(price) quantity = int(amount / exec_price) if quantity == 0: return total_cost = exec_price * quantity commission = self._calculate_commission(total_cost, False, config) total_amount = total_cost + commission if total_amount > self.cash: return self.cash -= total_amount if stock_code in self.positions: # 기존 포지션에 추가 매수 pos = self.positions[stock_code] new_quantity = pos.quantity + quantity new_avg_price = (pos.avg_price * pos.quantity + exec_price * quantity) / new_quantity pos.quantity = new_quantity pos.avg_price = new_avg_price else: self.positions[stock_code] = Position( stock_code=stock_code, quantity=quantity, avg_price=exec_price, entry_date=current_date, entry_reason=reason ) self.trades.append(Trade( date=current_date, stock_code=stock_code, action="BUY", price=exec_price, quantity=quantity, amount=total_cost, commission=commission, reason=reason )) def _execute_sell( self, stock_code: str, price: float, current_date: date, reason: str, config: BacktestConfig ): """매도 실행 (전량, 슬리피지 적용: 실제 체결가는 신호가보다 낮음)""" if stock_code not in self.positions: return # 슬리피지 적용: 매도 시 약간 낮은 가격에 체결 exec_price = _apply_sell_slippage(price) pos = self.positions[stock_code] total_amount = exec_price * pos.quantity commission = self._calculate_commission(total_amount, True, config) net_amount = total_amount - commission self.cash += net_amount self.trades.append(Trade( date=current_date, stock_code=stock_code, action="SELL", price=exec_price, quantity=pos.quantity, amount=total_amount, commission=commission, reason=reason )) del self.positions[stock_code] def _generate_signals( self, stock_code: str, df: pd.DataFrame, features_df: pd.DataFrame, current_idx: int, config: BacktestConfig ) -> Optional[Dict[str, Any]]: """전략 신호 생성 (RSI + 볼린저밴드 평균회귀)""" if current_idx < 60: # 충분한 데이터 필요 return None # 현재 시점의 피처 rsi = features_df.loc[current_idx, "rsi_14"] bb_pos = features_df.loc[current_idx, "bb_position"] ma20_ratio = features_df.loc[current_idx, "ma20_ratio"] if pd.isna(rsi) or pd.isna(bb_pos) or pd.isna(ma20_ratio): return None # 전략 파라미터 (프리셋 또는 기본값) params = config.strategy_params or {} rsi_threshold = params.get("MEAN_REVERSION_RSI_THRESHOLD", 30) stop_loss = params.get("MEAN_REVERSION_STOP_LOSS_PCT", -6.0) / 100 take_profit = params.get("MEAN_REVERSION_TAKE_PROFIT_PCT", 12.0) / 100 rsi_overbought = params.get("RSI_OVERBOUGHT_LEVEL", 70.0) current_price = df.loc[current_idx, "close"] # 매수 신호 (과매도 + BB 하단) if rsi < rsi_threshold and bb_pos < 0.2: if stock_code not in self.positions: return { "action": "BUY", "price": current_price, "reason": f"RSI={rsi:.1f} BB하단" } # 매도 신호 (손절/익절) if stock_code in self.positions: pos = self.positions[stock_code] pnl_pct = (current_price - pos.avg_price) / pos.avg_price if pnl_pct <= stop_loss: return { "action": "SELL", "price": current_price, "reason": f"손절 {pnl_pct*100:.1f}%" } elif pnl_pct >= take_profit: return { "action": "SELL", "price": current_price, "reason": f"익절 {pnl_pct*100:.1f}%" } elif rsi > rsi_overbought and bb_pos > 0.8: return { "action": "SELL", "price": current_price, "reason": "과매수 청산" } return None def _calculate_portfolio_value(self, current_prices: Dict[str, float]) -> float: """현재 포트폴리오 가치 계산""" position_value = sum( pos.quantity * current_prices.get(code, pos.avg_price) for code, pos in self.positions.items() ) return self.cash + position_value def _calculate_metrics(self, config: BacktestConfig) -> BacktestResult: """성과 지표 계산""" if not self.trades: return BacktestResult( config=asdict(config), total_trades=0, win_trades=0, lose_trades=0, win_rate=0.0, total_return=0.0, total_return_pct=0.0, mdd=0.0, mdd_pct=0.0, sharpe_ratio=0.0, sortino_ratio=0.0, profit_factor=0.0, avg_win=0.0, avg_lose=0.0, max_win=0.0, max_lose=0.0, equity_curve=self.equity_curve, trades=[], final_positions=[] ) # 거래별 수익률 계산 (FIFO 매칭: 동일 종목 복수 매수-매도 사이클 대응) buy_queues: Dict[str, deque] = defaultdict(deque) for t in self.trades: if t.action == "BUY": buy_queues[t.stock_code].append(t) sell_trades = [t for t in self.trades if t.action == "SELL"] trade_returns = [] for sell in sell_trades: if buy_queues[sell.stock_code]: buy = buy_queues[sell.stock_code].popleft() pnl = (sell.price - buy.price) * sell.quantity - sell.commission - buy.commission buy_cost = buy.price * buy.quantity pnl_pct = pnl / buy_cost if buy_cost > 0 else 0.0 trade_returns.append(pnl_pct) win_trades = [r for r in trade_returns if r > 0] lose_trades = [r for r in trade_returns if r < 0] # 최종 자산 final_value = self.equity_curve[-1]["value"] if self.equity_curve else config.initial_capital total_return = final_value - config.initial_capital total_return_pct = total_return / config.initial_capital # MDD 계산 peak = config.initial_capital mdd = 0.0 for point in self.equity_curve: if point["value"] > peak: peak = point["value"] drawdown = point["value"] - peak if drawdown < mdd: mdd = drawdown mdd_pct = mdd / peak if peak > 0 else 0.0 # Sharpe/Sortino (무위험 이자율 3% 차감 -- kr_backtest_engine.py와 일관성 유지) if len(self.daily_returns) > 1: rf_daily = 0.03 / 252 excess_returns = [r - rf_daily for r in self.daily_returns] avg_excess = np.mean(excess_returns) std_excess = np.std(excess_returns, ddof=1) downside_excess = [min(r, 0) for r in excess_returns] downside_var = np.mean([d ** 2 for d in downside_excess]) downside_std = np.sqrt(downside_var) if downside_var > 0 else 1e-10 sharpe = (avg_excess / std_excess) * np.sqrt(252) if std_excess > 0 else 0.0 sortino = (avg_excess / downside_std) * np.sqrt(252) if downside_std > 0 else 0.0 else: sharpe = sortino = 0.0 # Profit Factor total_win = sum(win_trades) if win_trades else 0.0 total_lose = abs(sum(lose_trades)) if lose_trades else 1e-10 profit_factor = total_win / total_lose if total_lose > 0 else 0.0 return BacktestResult( config=asdict(config), total_trades=len(sell_trades), win_trades=len(win_trades), lose_trades=len(lose_trades), win_rate=len(win_trades) / len(sell_trades) if sell_trades else 0.0, total_return=total_return, total_return_pct=total_return_pct, mdd=mdd, mdd_pct=mdd_pct, sharpe_ratio=sharpe, sortino_ratio=sortino, profit_factor=profit_factor, avg_win=np.mean(win_trades) if win_trades else 0.0, avg_lose=np.mean(lose_trades) if lose_trades else 0.0, max_win=max(win_trades) if win_trades else 0.0, max_lose=min(lose_trades) if lose_trades else 0.0, equity_curve=self.equity_curve, trades=[asdict(t) for t in self.trades], final_positions=[asdict(p) for p in self.positions.values()] ) async def run_backtest(self, config: BacktestConfig) -> BacktestResult: """전략 성과 백테스트 실행 [Look-ahead bias 수정] T일 close로 신호 생성 -> T+1일 open으로 체결. 마지막 봉 신호는 다음 봉이 없으므로 무시합니다. """ logger.info(f"백테스트 시작: {config.start_date} ~ {config.end_date}, {len(config.stock_codes)}종목") self.cash = config.initial_capital self.positions = {} self.trades = [] self.equity_curve = [] self.daily_returns = [] # 데이터 로드 stock_data = await self._fetch_daily_data( config.stock_codes, config.start_date, config.end_date ) if not stock_data: logger.warning("데이터 없음") return self._calculate_metrics(config) # 피처 추출 features_data = {} for code, df in stock_data.items(): features_df = extract_features(df) features_data[code] = features_df # 날짜별 시뮬레이션 all_dates = sorted(set( date_val for df in stock_data.values() for date_val in df["date"].values )) prev_portfolio_value = config.initial_capital # 전일 발생한 신호를 저장 (T일 close 신호 -> T+1일 open 체결) pending_signals: Dict[str, Dict[str, Any]] = {} for date_idx, current_date in enumerate(all_dates): fmt = "%Y%m%d" if len(current_date) == 8 else "%Y-%m-%d" current_date_obj = datetime.strptime(current_date, fmt).date() # 종목별 현재 봉 정보 수집 (close, open, idx) current_prices = {} current_bar_info: Dict[str, Dict[str, Any]] = {} for code, df in stock_data.items(): date_mask = df["date"] == current_date if not date_mask.any(): continue current_idx = df.index[date_mask][0] current_price = df.loc[current_idx, "close"] current_prices[code] = current_price current_bar_info[code] = { "idx": current_idx, "open": df.loc[current_idx, "open"], "close": current_price, } # -- 1단계: 전일 신호를 오늘 open으로 체결 -- for code, signal in list(pending_signals.items()): bar_info = current_bar_info.get(code) if bar_info is None: # 오늘 해당 종목 데이터 없음 -> 신호 폐기 continue exec_price = bar_info["open"] if signal["action"] == "BUY": if len(self.positions) < config.max_positions and code not in self.positions: position_amount = self.cash * config.position_size self._execute_buy( code, exec_price, position_amount, current_date_obj, signal["reason"], config ) elif signal["action"] == "SELL": self._execute_sell( code, exec_price, current_date_obj, signal["reason"], config ) pending_signals.clear() # -- 2단계: 오늘 close 기준으로 신호 생성 (체결은 내일 open) -- is_last_bar = (date_idx == len(all_dates) - 1) if not is_last_bar: for code, df in stock_data.items(): if code not in current_bar_info: continue current_idx = current_bar_info[code]["idx"] signal = self._generate_signals( code, df, features_data[code], current_idx, config ) if signal: pending_signals[code] = signal # 일별 자산 기록 portfolio_value = self._calculate_portfolio_value(current_prices) daily_return = (portfolio_value - prev_portfolio_value) / prev_portfolio_value if prev_portfolio_value > 0 else 0.0 self.daily_returns.append(daily_return) prev_portfolio_value = portfolio_value self.equity_curve.append({ "date": current_date, "value": portfolio_value, "cash": self.cash, "positions": len(self.positions), "return_pct": (portfolio_value - config.initial_capital) / config.initial_capital }) result = self._calculate_metrics(config) logger.info(f"백테스트 완료: 수익률 {result.total_return_pct*100:.2f}%, 승률 {result.win_rate*100:.1f}%") return result # ── ML 학습 데이터 생성 ──────────────────────────────────────────────────────── async def generate_training_data(config: TrainingDataConfig) -> Dict[str, Any]: """ML 학습 데이터 생성 (라벨링된 피처) Returns: { "total_samples": int, "label_distribution": {"UP": int, "HOLD": int, "DOWN": int}, "data": pd.DataFrame with columns [date, stock_code, features..., label] } """ logger.info(f"ML 학습 데이터 생성 시작: {len(config.stock_codes)}종목") engine = BacktestEngine() stock_data = await engine._fetch_daily_data( config.stock_codes, config.start_date, config.end_date ) all_samples = [] for stock_code, df in stock_data.items(): if len(df) < config.forward_days + 60: continue # 피처 추출 features_df = extract_features(df) # 라벨 생성 X_tabular, X_seq, y, valid_indices = prepare_training_data( df, features_df, forecast_days=config.forward_days, up_threshold=config.up_threshold, down_threshold=config.down_threshold ) if len(valid_indices) < config.min_samples_per_stock: logger.warning(f"{stock_code}: 샘플 부족 ({len(valid_indices)}개)") continue # DataFrame으로 변환 for i, idx in enumerate(valid_indices): sample = { "date": df.loc[idx, "date"], "stock_code": stock_code, **{col: X_tabular[i, j] for j, col in enumerate(FEATURE_COLUMNS)}, "label": int(y[i]) } all_samples.append(sample) result_df = pd.DataFrame(all_samples) if len(result_df) == 0: return { "error": "학습 데이터 생성 실패", "total_samples": 0, "label_distribution": {} } label_dist = result_df["label"].value_counts().to_dict() label_names = {0: "DOWN", 1: "HOLD", 2: "UP"} logger.info(f"ML 학습 데이터 생성 완료: {len(result_df)}샘플, 분포 {label_dist}") return { "total_samples": len(result_df), "label_distribution": {label_names[k]: int(v) for k, v in label_dist.items()}, "data": result_df, "config": asdict(config) }