수정본
This commit is contained in:
@@ -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()},
|
||||
|
||||
Reference in New Issue
Block a user