수정본
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

This commit is contained in:
2026-03-27 14:47:49 +09:00
parent 844ee8944f
commit c818810667
25 changed files with 2708 additions and 266 deletions

View File

@@ -9,6 +9,8 @@
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
@@ -30,6 +32,34 @@ from app.services.feature_engineering import (
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
@@ -137,10 +167,10 @@ class BacktestEngine:
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,
@@ -151,7 +181,7 @@ class BacktestEngine:
"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:
@@ -170,41 +200,44 @@ class BacktestEngine:
reason: str,
config: BacktestConfig
):
"""매수 실행"""
quantity = int(amount / price)
"""매수 실행 (슬리피지 적용: 실제 체결가는 신호가보다 높음)"""
# 슬리피지 적용: 매수 시 약간 높은 가격에 체결
exec_price = _apply_buy_slippage(price)
quantity = int(amount / exec_price)
if quantity == 0:
return
total_cost = price * quantity
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 + price * quantity) / new_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=price,
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=price,
price=exec_price,
quantity=quantity,
amount=total_cost,
commission=commission,
@@ -219,28 +252,31 @@ class BacktestEngine:
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 = price * pos.quantity
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=price,
price=exec_price,
quantity=pos.quantity,
amount=total_amount,
commission=commission,
reason=reason
))
del self.positions[stock_code]
def _generate_signals(
@@ -254,15 +290,15 @@ class BacktestEngine:
"""전략 신호 생성 (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)
@@ -302,9 +338,9 @@ class BacktestEngine:
return {
"action": "SELL",
"price": current_price,
"reason": f"과매수 청산"
"reason": "과매수 청산"
}
return None
def _calculate_portfolio_value(self, current_prices: Dict[str, float]) -> float:
@@ -339,27 +375,32 @@ class BacktestEngine:
trades=[],
final_positions=[]
)
# 거래별 수익률 계산
buy_trades = {t.stock_code: t for t in self.trades if t.action == "BUY"}
# 거래별 수익률 계산 (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 sell.stock_code in buy_trades:
buy = buy_trades[sell.stock_code]
if buy_queues[sell.stock_code]:
buy = buy_queues[sell.stock_code].popleft()
pnl = (sell.price - buy.price) * sell.quantity - sell.commission - buy.commission
pnl_pct = pnl / (buy.price * buy.quantity)
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
@@ -370,24 +411,28 @@ class BacktestEngine:
if drawdown < mdd:
mdd = drawdown
mdd_pct = mdd / peak if peak > 0 else 0.0
# Sharpe/Sortino
# Sharpe/Sortino (무위험 이자율 3% 차감 -- kr_backtest_engine.py와 일관성 유지)
if len(self.daily_returns) > 1:
avg_return = np.mean(self.daily_returns)
std_return = np.std(self.daily_returns, ddof=1)
downside_returns = [r for r in self.daily_returns if r < 0]
downside_std = np.std(downside_returns, ddof=1) if downside_returns else 1e-10
sharpe = (avg_return * 252) / (std_return * np.sqrt(252)) if std_return > 0 else 0.0
sortino = (avg_return * 252) / (downside_std * np.sqrt(252)) if downside_std > 0 else 0.0
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),
@@ -411,81 +456,114 @@ class BacktestEngine:
)
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
for current_date in all_dates:
# 전일 발생한 신호를 저장 (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
# 신호 생성
signal = self._generate_signals(
code, df, features_data[code], current_idx, config
)
if signal:
if signal["action"] == "BUY":
# 포지션 크기 제한
if len(self.positions) < config.max_positions:
position_amount = self.cash * config.position_size
self._execute_buy(
code, signal["price"], position_amount,
current_date_obj, signal["reason"], config
)
elif signal["action"] == "SELL":
self._execute_sell(
code, signal["price"], current_date_obj,
signal["reason"], config
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
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,
@@ -493,7 +571,7 @@ class BacktestEngine:
"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
@@ -503,7 +581,7 @@ class BacktestEngine:
async def generate_training_data(config: TrainingDataConfig) -> Dict[str, Any]:
"""ML 학습 데이터 생성 (라벨링된 피처)
Returns:
{
"total_samples": int,
@@ -512,23 +590,23 @@ async def generate_training_data(config: TrainingDataConfig) -> Dict[str, Any]:
}
"""
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,
@@ -536,11 +614,11 @@ async def generate_training_data(config: TrainingDataConfig) -> Dict[str, Any]:
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 = {
@@ -550,21 +628,21 @@ async def generate_training_data(config: TrainingDataConfig) -> Dict[str, Any]:
"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()},