Files
auto-stock/backend/app/services/backtest_engine.py
shuim c818810667
Some checks failed
CI/CD Pipeline / test-backend (push) Has been cancelled
CI/CD Pipeline / test-frontend (push) Has been cancelled
CI/CD Pipeline / build-and-push (push) Has been cancelled
수정본
2026-03-27 14:47:49 +09:00

652 lines
23 KiB
Python

"""
백테스트 엔진
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)
}