Files
auto-stock/backend/app/services/kr_backtest_engine.py
shuim de7921d270 fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리
## CRITICAL 수정 (20건)
- day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가
- trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산
- risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수
- risk_management/recommendations: date.today() → KST 변환
- mdd_manager: Lock 밖 executed_key 참조 수정
- correlation_filter: output → output2 키 수정
- portfolio: 해외 종목 USD/KRW 환율 적용 분리
- Sharpe Ratio 거래빈도 기반 연간화
- telegram_notifier: send_message() 공개 래퍼 추가
- position_manager: 앱 재시작 시 DB 포지션 복원
- swing exit_service: remaining_pct 조건 범위 수정
- run_backtest: original_qty 기반 remaining_pct 계산
- us_market_schedule: 2027년 미국 공휴일 추가
- Login.tsx: res.ok 검증 추가
- mean_reversion_strategy: RSI 로그 하드코딩 수정

## HIGH 수정 (10건)
- db_worker: DLQ 파일 백업 메커니즘 추가
- korea_investment_api: 만료 토큰 임의 연장 제거
- market_data: chart_cache 최대 100개 + TTL 만료
- main.py: rate_limit_store 1시간 미접근 IP 정리
- recommendations: fire-and-forget try/except 추가
- swing/scheduler: DB 세션 3개 분리
- Dashboard: wsMessages 전체 순회
- Layout: /health 엔드포인트 + 30초 폴링
- ml_ensemble: POST /train require_api_key 추가
- day_trading: record_trade_result_safe() 전환

## 코드 정리
- 프론트엔드: isUsStock, formatTime 공통 유틸 추출
- 백엔드: dead code 제거, 중복 import 통합
- MCP: JSON 파싱 헬퍼 추출 (~60줄 감소)
- 전체 ~100개 파일 한국어 주석 추가
- 테스트 13건 코드 기준 수정 (468 passed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:05:41 +09:00

983 lines
34 KiB
Python

"""
한국 주식 백테스트 엔진
stock_daily_prices DB 테이블에서 일봉 OHLCV를 로드하여
날짜별 순차 시뮬레이션으로 전략 성과를 평가합니다.
[설계 원칙]
- 동기 함수 (배치 작업이므로 async 불필요, DB 조회만 async)
- numpy 최소 사용 (표준 라이브러리 우선)
- 기존 mean_reversion_strategy.py의 RSI/BB 함수 재활용
- CLAUDE.md 리스크 규칙 준수 (수수료, 세금, 슬리피지, 손절/익절)
[수수료 모델]
- 매수 수수료: 0.015%
- 매도 수수료: 0.015% + 증권거래세 (KOSPI 0.18%, KOSDAQ 0.23%)
[슬리피지 모델]
- 시장가 기준 +/-0.1% 랜덤 슬리피지
"""
import logging
import math
import random
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import date, datetime
from typing import Any, Dict, List, Optional, Tuple
from app.core.config import settings
from app.services.mean_reversion_strategy import (
MarketRegime,
calculate_atr,
calculate_bollinger,
calculate_rsi,
check_buy_signal,
check_exit_signal,
detect_market_regime,
is_reversal_candle,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# 데이터 구조
# ---------------------------------------------------------------------------
@dataclass
class BarData:
"""일봉 OHLCV 데이터 (1행)"""
trade_date: date
open_price: int
high_price: int
low_price: int
close_price: int
volume: int
stock_code: str = ""
stock_name: str = ""
@dataclass
class BacktestTrade:
"""체결된 거래 1건"""
stock_code: str
side: str # "BUY" | "SELL"
entry_date: Optional[date] = None
exit_date: Optional[date] = None
entry_price: float = 0.0
exit_price: float = 0.0
quantity: int = 0
commission: float = 0.0
tax: float = 0.0
slippage: float = 0.0
pnl: float = 0.0
pnl_pct: float = 0.0
reason: str = ""
@dataclass
class BacktestMetrics:
"""백테스트 성과 지표"""
total_return_pct: float = 0.0
cagr_pct: float = 0.0
sharpe_ratio: float = 0.0
sortino_ratio: float = 0.0
max_drawdown_pct: float = 0.0
win_rate: float = 0.0
profit_factor: float = 0.0
avg_win_pct: float = 0.0
avg_loss_pct: float = 0.0
total_trades: int = 0
winning_trades: int = 0
losing_trades: int = 0
max_consecutive_wins: int = 0
max_consecutive_losses: int = 0
avg_holding_days: float = 0.0
@dataclass
class BacktestResult:
"""백테스트 전체 결과"""
metrics: BacktestMetrics
equity_curve: List[Dict[str, Any]] = field(default_factory=list)
trades: List[Dict[str, Any]] = field(default_factory=list)
drawdown_curve: List[Dict[str, Any]] = field(default_factory=list)
config: Dict[str, Any] = field(default_factory=dict)
@dataclass
class Position:
"""현재 보유 포지션"""
stock_code: str
entry_date: date
entry_price: float
quantity: int
highest_since_entry: float # 트레일링 스탑용 최고가
remaining_pct: float = 1.0 # 분할매도 잔여 비율
# ---------------------------------------------------------------------------
# 수수료/세금/슬리피지 모델
# ---------------------------------------------------------------------------
# 수수료율 (config.py 참조)
BUY_COMMISSION_RATE = settings.KR_COMMISSION_RATE # 0.00015 (0.015%)
SELL_COMMISSION_RATE = settings.KR_COMMISSION_RATE # 0.00015 (0.015%)
SELL_TAX_KOSPI = settings.KR_SELL_TAX_RATE_KOSPI # 0.0018 (0.18%)
SELL_TAX_KOSDAQ = settings.KR_SELL_TAX_RATE_KOSDAQ # 0.0023 (0.23%)
SLIPPAGE_PCT = 0.001 # +/-0.1%
def _apply_buy_slippage(price: float, seed: Optional[int] = None) -> float:
"""매수 시 슬리피지: 약간 높은 가격에 체결"""
rng = random.Random(seed)
slip = rng.uniform(0, SLIPPAGE_PCT)
return price * (1 + slip)
def _apply_sell_slippage(price: float, seed: Optional[int] = None) -> float:
"""매도 시 슬리피지: 약간 낮은 가격에 체결"""
rng = random.Random(seed)
slip = rng.uniform(0, SLIPPAGE_PCT)
return price * (1 - slip)
def _calc_buy_cost(price: float, quantity: int) -> Tuple[float, float]:
"""매수 비용 계산 -> (총비용, 수수료)"""
amount = price * quantity
commission = amount * BUY_COMMISSION_RATE
return amount + commission, commission
def _calc_sell_proceeds(
price: float,
quantity: int,
market: str = "KOSPI",
) -> Tuple[float, float, float]:
"""매도 수입 계산 -> (순수입, 수수료, 세금)"""
amount = price * quantity
commission = amount * SELL_COMMISSION_RATE
tax_rate = SELL_TAX_KOSDAQ if market.upper() == "KOSDAQ" else SELL_TAX_KOSPI
tax = amount * tax_rate
return amount - commission - tax, commission, tax
# ---------------------------------------------------------------------------
# 성과 지표 계산
# ---------------------------------------------------------------------------
def _compute_metrics(
equity_curve: List[float],
trades: List[BacktestTrade],
initial_capital: float,
trading_days: int,
) -> BacktestMetrics:
"""equity_curve와 trades로부터 성과 지표 산출"""
m = BacktestMetrics()
if not equity_curve or len(equity_curve) < 2:
return m
final_equity = equity_curve[-1]
m.total_return_pct = ((final_equity - initial_capital) / initial_capital) * 100
# CAGR
years = trading_days / 252.0
if years > 0 and final_equity > 0:
m.cagr_pct = ((final_equity / initial_capital) ** (1 / years) - 1) * 100
else:
m.cagr_pct = 0.0
# 일별 수익률
daily_returns: List[float] = []
for i in range(1, len(equity_curve)):
prev = equity_curve[i - 1]
if prev > 0:
daily_returns.append((equity_curve[i] - prev) / prev)
# Sharpe Ratio (무위험 이자율 3% 가정)
if daily_returns:
rf_daily = 0.03 / 252
excess = [r - rf_daily for r in daily_returns]
avg_excess = sum(excess) / len(excess)
variance = sum((r - avg_excess) ** 2 for r in excess) / max(len(excess) - 1, 1)
std = math.sqrt(variance) if variance > 0 else 0
m.sharpe_ratio = round((avg_excess / std) * math.sqrt(252), 4) if std > 0 else 0.0
else:
m.sharpe_ratio = 0.0
# Sortino Ratio (하방 편차만 사용)
if daily_returns:
rf_daily = 0.03 / 252
excess = [r - rf_daily for r in daily_returns]
avg_excess = sum(excess) / len(excess)
downside = [min(r, 0) ** 2 for r in excess]
downside_var = sum(downside) / max(len(downside) - 1, 1)
downside_std = math.sqrt(downside_var) if downside_var > 0 else 0
m.sortino_ratio = round((avg_excess / downside_std) * math.sqrt(252), 4) if downside_std > 0 else 0.0
else:
m.sortino_ratio = 0.0
# Max Drawdown
peak = equity_curve[0]
max_dd = 0.0
for eq in equity_curve:
if eq > peak:
peak = eq
dd = (eq - peak) / peak if peak > 0 else 0
if dd < max_dd:
max_dd = dd
m.max_drawdown_pct = round(max_dd * 100, 4)
# 거래 통계
closed_trades = [t for t in trades if t.side == "SELL"]
m.total_trades = len(closed_trades)
if m.total_trades == 0:
return m
wins = [t for t in closed_trades if t.pnl > 0]
losses = [t for t in closed_trades if t.pnl <= 0]
m.winning_trades = len(wins)
m.losing_trades = len(losses)
m.win_rate = round(m.winning_trades / m.total_trades * 100, 2)
# Profit Factor
gross_profit = sum(t.pnl for t in wins) if wins else 0
gross_loss = abs(sum(t.pnl for t in losses)) if losses else 0
m.profit_factor = round(gross_profit / gross_loss, 4) if gross_loss > 0 else float("inf") if gross_profit > 0 else 0.0
# Average Win/Loss %
m.avg_win_pct = round(sum(t.pnl_pct for t in wins) / len(wins), 4) if wins else 0.0
m.avg_loss_pct = round(sum(t.pnl_pct for t in losses) / len(losses), 4) if losses else 0.0
# 연속 승/패
consecutive_w = 0
consecutive_l = 0
max_cw = 0
max_cl = 0
for t in closed_trades:
if t.pnl > 0:
consecutive_w += 1
consecutive_l = 0
else:
consecutive_l += 1
consecutive_w = 0
max_cw = max(max_cw, consecutive_w)
max_cl = max(max_cl, consecutive_l)
m.max_consecutive_wins = max_cw
m.max_consecutive_losses = max_cl
# 평균 보유일
holding_days = []
for t in closed_trades:
if t.entry_date and t.exit_date:
delta = (t.exit_date - t.entry_date).days
holding_days.append(delta)
m.avg_holding_days = round(sum(holding_days) / len(holding_days), 1) if holding_days else 0.0
return m
# ---------------------------------------------------------------------------
# 전략 추상 클래스
# ---------------------------------------------------------------------------
class BacktestStrategy(ABC):
"""백테스트 전략 인터페이스
on_bar()는 매 일봉마다 호출되며, 매수/매도 주문 리스트를 반환합니다.
"""
@abstractmethod
def name(self) -> str:
"""전략 이름"""
...
@abstractmethod
def description(self) -> str:
"""전략 설명"""
...
@abstractmethod
def default_params(self) -> Dict[str, Any]:
"""기본 파라미터"""
...
@abstractmethod
def on_bar(
self,
bar_idx: int,
bars: Dict[str, List[BarData]],
positions: Dict[str, Position],
cash: float,
total_equity: float,
params: Dict[str, Any],
) -> List[Dict[str, Any]]:
"""일봉 콜백
Args:
bar_idx: 현재 봉 인덱스 (0-based, 모든 종목 공통 날짜 기준)
bars: {stock_code: [BarData, ...]} 전체 일봉 (bar_idx까지 접근 가능)
positions: 현재 보유 포지션 {stock_code: Position}
cash: 현재 현금
total_equity: 현재 총 자산 (현금 + 평가금)
params: 전략 파라미터
Returns:
주문 리스트 [{"action": "BUY"|"SELL", "stock_code": str,
"price": float, "quantity": int, "reason": str,
"sell_pct": float (SELL 시 매도 비율)}]
"""
...
# ---------------------------------------------------------------------------
# 내장 전략: MeanReversionBacktest (RSI + BB 평균회귀)
# ---------------------------------------------------------------------------
class MeanReversionBacktest(BacktestStrategy):
"""CLAUDE.md 섹션 6 RSI+BB 평균회귀 전략 백테스트 버전
기존 mean_reversion_strategy.py의 check_buy_signal / check_exit_signal을
그대로 재활용합니다. 시장 국면(regime)은 코스피200 데이터가 없으므로
전체 종목 평균으로 대체하거나, 파라미터로 고정 지정 가능합니다.
"""
def name(self) -> str:
return "mean_reversion"
def description(self) -> str:
return "RSI + Bollinger Band 평균회귀 전략 (CLAUDE.md 섹션 6)"
def default_params(self) -> Dict[str, Any]:
return {
"rsi_period": settings.RSI_PERIOD,
"bb_period": settings.BOLLINGER_PERIOD,
"atr_period": settings.ATR_PERIOD,
"max_positions": settings.MAX_HOLDINGS_COUNT,
"position_size_pct": settings.MAX_POSITION_SIZE,
"stop_loss_pct": settings.STOP_LOSS_PERCENT,
"atr_stop_multiplier": settings.ATR_STOP_MULTIPLIER,
"atr_trailing_multiplier": settings.ATR_TRAILING_MULTIPLIER,
"rsi_exit_threshold": settings.MEAN_REVERSION_RSI_EXIT_THRESHOLD,
"rsi_full_exit": settings.MEAN_REVERSION_RSI_FULL_EXIT,
"regime": "BULL", # "BULL", "BEAR", "AUTO"
"market": "KOSPI", # 세금 계산용
"min_price": settings.MIN_PRICE_KRW,
"min_data_bars": 60, # RSI/BB 계산에 필요한 최소 봉 수
}
def on_bar(
self,
bar_idx: int,
bars: Dict[str, List[BarData]],
positions: Dict[str, Position],
cash: float,
total_equity: float,
params: Dict[str, Any],
) -> List[Dict[str, Any]]:
orders: List[Dict[str, Any]] = []
min_bars = params.get("min_data_bars", 60)
# --- 매도 시그널 먼저 (기존 포지션) ---
for code, pos in list(positions.items()):
stock_bars = bars.get(code, [])
if bar_idx >= len(stock_bars):
continue
current_bar = stock_bars[bar_idx]
closes = [b.close_price for b in stock_bars[: bar_idx + 1]]
highs = [b.high_price for b in stock_bars[: bar_idx + 1]]
lows = [b.low_price for b in stock_bars[: bar_idx + 1]]
if len(closes) < min_bars:
continue
rsi = calculate_rsi(closes, params.get("rsi_period", 14))
bb = calculate_bollinger(closes, params.get("bb_period", 20))
atr = calculate_atr(highs, lows, closes, params.get("atr_period", 14))
if rsi is None or bb is None or atr is None:
continue
bb_upper, bb_middle, bb_lower = bb
# 최고가 업데이트
pos.highest_since_entry = max(pos.highest_since_entry, current_bar.high_price)
exit_signal = check_exit_signal(
regime=self._get_regime(params),
rsi=rsi,
close=float(current_bar.close_price),
bb_upper=bb_upper,
bb_middle=bb_middle,
bb_lower=bb_lower,
entry_price=pos.entry_price,
atr=atr,
lowest_since_entry=float(current_bar.low_price),
remaining_pct=pos.remaining_pct,
trailing_high=pos.highest_since_entry,
)
if exit_signal:
sell_qty = int(pos.quantity * exit_signal.sell_pct)
if sell_qty <= 0:
sell_qty = pos.quantity
orders.append({
"action": "SELL",
"stock_code": code,
"price": float(current_bar.close_price),
"quantity": sell_qty,
"reason": exit_signal.reason,
"sell_pct": exit_signal.sell_pct,
})
# --- 매수 시그널 ---
max_pos = params.get("max_positions", 20)
if len(positions) >= max_pos:
return orders
for code, stock_bars in bars.items():
if code in positions:
continue
if len(positions) + sum(1 for o in orders if o["action"] == "BUY") >= max_pos:
break
if bar_idx >= len(stock_bars):
continue
current_bar = stock_bars[bar_idx]
closes = [b.close_price for b in stock_bars[: bar_idx + 1]]
highs = [b.high_price for b in stock_bars[: bar_idx + 1]]
lows = [b.low_price for b in stock_bars[: bar_idx + 1]]
opens = [b.open_price for b in stock_bars[: bar_idx + 1]]
volumes = [b.volume for b in stock_bars[: bar_idx + 1]]
if len(closes) < min_bars:
continue
# 가격 필터
if current_bar.close_price < params.get("min_price", 5000):
continue
rsi = calculate_rsi(closes, params.get("rsi_period", 14))
bb = calculate_bollinger(closes, params.get("bb_period", 20))
if rsi is None or bb is None:
continue
bb_upper, bb_middle, bb_lower = bb
# 거래량 20일 평균
vol_window = volumes[-20:] if len(volumes) >= 20 else volumes
avg_vol = sum(vol_window) / len(vol_window) if vol_window else 0
# 이전 RSI (반등 확인)
rsi_prev = None
if len(closes) > min_bars + 3:
rsi_prev = calculate_rsi(closes[:-3], params.get("rsi_period", 14))
# 되돌림 캔들 확인 (BEAR 진입 조건)
reversal = is_reversal_candle(
[float(x) for x in opens[-3:]],
[float(x) for x in highs[-3:]],
[float(x) for x in lows[-3:]],
[float(x) for x in closes[-3:]],
)
prev_vol = volumes[-2] if len(volumes) >= 2 else 0
signal = check_buy_signal(
regime=self._get_regime(params),
rsi=rsi,
close=float(current_bar.close_price),
bb_upper=bb_upper,
bb_middle=bb_middle,
bb_lower=bb_lower,
avg_volume_20d=avg_vol,
current_volume=current_bar.volume,
prev_volume=prev_vol,
is_reversal=reversal,
rsi_prev=rsi_prev,
)
if signal:
# 포지션 사이징: total_equity * position_size_pct
budget = total_equity * params.get("position_size_pct", 0.04)
budget = min(budget, cash)
if budget <= 0:
continue
price = float(current_bar.close_price)
quantity = int(budget / price)
if quantity <= 0:
continue
orders.append({
"action": "BUY",
"stock_code": code,
"price": price,
"quantity": quantity,
"reason": signal.get("description", ""),
"sell_pct": 0.0,
})
return orders
@staticmethod
def _get_regime(params: Dict[str, Any]) -> MarketRegime:
regime_str = params.get("regime", "BULL").upper()
if regime_str == "BEAR":
return MarketRegime.BEAR
if regime_str == "SIDEWAYS":
return MarketRegime.SIDEWAYS
return MarketRegime.BULL
# ---------------------------------------------------------------------------
# 전략 레지스트리
# ---------------------------------------------------------------------------
_STRATEGY_REGISTRY: Dict[str, type] = {
"mean_reversion": MeanReversionBacktest,
}
def get_available_strategies() -> List[Dict[str, Any]]:
"""사용 가능한 전략 목록 반환"""
result = []
for key, cls in _STRATEGY_REGISTRY.items():
instance = cls()
result.append({
"key": key,
"name": instance.name(),
"description": instance.description(),
"default_params": instance.default_params(),
})
return result
def get_strategy(name: str) -> BacktestStrategy:
"""이름으로 전략 인스턴스 반환"""
cls = _STRATEGY_REGISTRY.get(name)
if cls is None:
raise ValueError(f"Unknown strategy: {name}. Available: {list(_STRATEGY_REGISTRY.keys())}")
return cls()
# ---------------------------------------------------------------------------
# 데이터 로더 (async — DB 조회)
# ---------------------------------------------------------------------------
async def load_daily_bars(
stock_codes: List[str],
start_date: date,
end_date: date,
) -> Tuple[Dict[str, List[BarData]], List[date]]:
"""stock_daily_prices 테이블에서 일봉 데이터 로드
Returns:
(bars_by_code, all_dates)
- bars_by_code: {stock_code: [BarData...]} 날짜 오름차순
- all_dates: 전체 거래일 리스트 (유니온)
"""
from sqlalchemy import select
from app.core.database import AsyncSessionLocal
from app.models.models import StockDailyPrice
bars_by_code: Dict[str, List[BarData]] = {}
all_dates_set: set = set()
async with AsyncSessionLocal() as session:
for code in stock_codes:
stmt = (
select(StockDailyPrice)
.where(
StockDailyPrice.stock_code == code,
StockDailyPrice.trade_date >= start_date,
StockDailyPrice.trade_date <= end_date,
)
.order_by(StockDailyPrice.trade_date.asc())
)
result = await session.execute(stmt)
rows = result.scalars().all()
if not rows:
logger.debug("종목 %s: 기간 내 데이터 없음 (%s ~ %s)", code, start_date, end_date)
continue
bar_list: List[BarData] = []
for row in rows:
bar_list.append(BarData(
trade_date=row.trade_date,
open_price=row.open_price or 0,
high_price=row.high_price or 0,
low_price=row.low_price or 0,
close_price=row.close_price or 0,
volume=row.volume or 0,
stock_code=row.stock_code,
stock_name=row.stock_name or "",
))
all_dates_set.add(row.trade_date)
bars_by_code[code] = bar_list
all_dates = sorted(all_dates_set)
return bars_by_code, all_dates
def _align_bars_to_dates(
bars_by_code: Dict[str, List[BarData]],
all_dates: List[date],
) -> Dict[str, List[BarData]]:
"""모든 종목을 동일 날짜 인덱스에 맞춤 (결측일은 이전 종가 복제)
bar_idx로 접근할 때 모든 종목이 같은 날짜를 가리키도록 합니다.
"""
aligned: Dict[str, List[BarData]] = {}
date_to_idx = {d: i for i, d in enumerate(all_dates)}
for code, bar_list in bars_by_code.items():
# 원본 데이터를 날짜 인덱스로 매핑
date_map = {b.trade_date: b for b in bar_list}
result: List[BarData] = []
last_bar: Optional[BarData] = None
for d in all_dates:
if d in date_map:
last_bar = date_map[d]
result.append(last_bar)
elif last_bar is not None:
# 이전 종가를 OHLC 모두 같게 복제 (거래량 0)
filler = BarData(
trade_date=d,
open_price=last_bar.close_price,
high_price=last_bar.close_price,
low_price=last_bar.close_price,
close_price=last_bar.close_price,
volume=0,
stock_code=code,
stock_name=last_bar.stock_name,
)
result.append(filler)
# 첫 날짜 이전 데이터가 없으면 건너뜀 (나중에 min_bars로 필터)
# 빈 봉이 앞에 있을 수 있으므로 첫 실제 데이터부터 시작
if result:
aligned[code] = result
return aligned
# ---------------------------------------------------------------------------
# 백테스트 엔진
# ---------------------------------------------------------------------------
class KrBacktestEngine:
"""한국 주식 백테스트 엔진 (메인 클래스)
사용법:
engine = KrBacktestEngine()
result = await engine.run(
stock_codes=["005930", "035720"],
start_date=date(2025, 1, 1),
end_date=date(2025, 12, 31),
strategy_name="mean_reversion",
params={...},
initial_capital=10_000_000,
market="KOSPI",
random_seed=42,
)
"""
def __init__(self):
pass
async def run(
self,
stock_codes: List[str],
start_date: date,
end_date: date,
strategy_name: str = "mean_reversion",
params: Optional[Dict[str, Any]] = None,
initial_capital: float = 10_000_000,
market: str = "KOSPI",
random_seed: Optional[int] = 42,
) -> BacktestResult:
"""백테스트 실행
Args:
stock_codes: 대상 종목 코드 리스트
start_date: 시작일
end_date: 종료일
strategy_name: 전략 키 ("mean_reversion" 등)
params: 전략 파라미터 (None이면 기본값 사용)
initial_capital: 초기 자본금
market: 시장 구분 (KOSPI/KOSDAQ) - 세금 계산용
random_seed: 슬리피지 재현성을 위한 랜덤 시드
Returns:
BacktestResult
"""
strategy = get_strategy(strategy_name)
effective_params = strategy.default_params()
if params:
effective_params.update(params)
effective_params["market"] = market
# 1. 데이터 로드
logger.info(
"백테스트 데이터 로드: %d 종목, %s ~ %s",
len(stock_codes), start_date, end_date,
)
bars_by_code, all_dates = await load_daily_bars(stock_codes, start_date, end_date)
if not bars_by_code or not all_dates:
logger.warning("백테스트 데이터 없음")
return BacktestResult(
metrics=BacktestMetrics(),
config=self._build_config_dict(
stock_codes, start_date, end_date, strategy_name, effective_params, initial_capital,
),
)
# 2. 날짜 정렬
aligned = _align_bars_to_dates(bars_by_code, all_dates)
# 3. 시뮬레이션 실행
result = self._simulate(
aligned, all_dates, strategy, effective_params,
initial_capital, market, random_seed,
)
result.config = self._build_config_dict(
stock_codes, start_date, end_date, strategy_name, effective_params, initial_capital,
)
return result
def _simulate(
self,
bars: Dict[str, List[BarData]],
all_dates: List[date],
strategy: BacktestStrategy,
params: Dict[str, Any],
initial_capital: float,
market: str,
random_seed: Optional[int],
) -> BacktestResult:
"""핵심 시뮬레이션 루프 (동기)"""
cash = initial_capital
positions: Dict[str, Position] = {}
all_trades: List[BacktestTrade] = []
# 포지션별 진입 거래 추적 (매도 시 PnL 계산용)
entry_trades: Dict[str, BacktestTrade] = {}
equity_values: List[float] = [initial_capital]
equity_curve: List[Dict[str, Any]] = [{
"date": str(all_dates[0]) if all_dates else "",
"equity": initial_capital,
}]
drawdown_curve: List[Dict[str, Any]] = []
peak_equity = initial_capital
seed_counter = random_seed if random_seed is not None else None
for bar_idx in range(len(all_dates)):
current_date = all_dates[bar_idx]
# 현재 평가 자산
position_value = 0.0
for code, pos in positions.items():
stock_bars = bars.get(code, [])
if bar_idx < len(stock_bars):
position_value += stock_bars[bar_idx].close_price * pos.quantity
total_equity = cash + position_value
# 전략 호출
orders = strategy.on_bar(
bar_idx=bar_idx,
bars=bars,
positions=positions,
cash=cash,
total_equity=total_equity,
params=params,
)
# 주문 실행
for order in orders:
action = order["action"]
code = order["stock_code"]
price = order["price"]
quantity = order["quantity"]
# 슬리피지용 시드
if seed_counter is not None:
seed_counter += 1
slip_seed = seed_counter
else:
slip_seed = None
if action == "BUY":
exec_price = _apply_buy_slippage(price, slip_seed)
total_cost, commission = _calc_buy_cost(exec_price, quantity)
if total_cost > cash:
# 자금 부족 시 수량 축소
quantity = int(cash / (exec_price * (1 + BUY_COMMISSION_RATE)))
if quantity <= 0:
continue
total_cost, commission = _calc_buy_cost(exec_price, quantity)
cash -= total_cost
slippage_amount = abs(exec_price - price) * quantity
trade = BacktestTrade(
stock_code=code,
side="BUY",
entry_date=current_date,
entry_price=exec_price,
quantity=quantity,
commission=commission,
slippage=slippage_amount,
reason=order.get("reason", ""),
)
all_trades.append(trade)
entry_trades[code] = trade
positions[code] = Position(
stock_code=code,
entry_date=current_date,
entry_price=exec_price,
quantity=quantity,
highest_since_entry=exec_price,
)
elif action == "SELL":
pos = positions.get(code)
if pos is None:
continue
sell_pct = order.get("sell_pct", 1.0)
sell_qty = min(quantity, pos.quantity)
exec_price = _apply_sell_slippage(price, slip_seed)
proceeds, commission, tax = _calc_sell_proceeds(exec_price, sell_qty, market)
cash += proceeds
slippage_amount = abs(price - exec_price) * sell_qty
# PnL 계산
entry_cost = pos.entry_price * sell_qty * (1 + BUY_COMMISSION_RATE)
pnl = proceeds - entry_cost
pnl_pct = (pnl / entry_cost) * 100 if entry_cost > 0 else 0.0
trade = BacktestTrade(
stock_code=code,
side="SELL",
entry_date=pos.entry_date,
exit_date=current_date,
entry_price=pos.entry_price,
exit_price=exec_price,
quantity=sell_qty,
commission=commission,
tax=tax,
slippage=slippage_amount,
pnl=round(pnl, 2),
pnl_pct=round(pnl_pct, 4),
reason=order.get("reason", ""),
)
all_trades.append(trade)
# 포지션 업데이트
remaining_qty = pos.quantity - sell_qty
if remaining_qty <= 0:
del positions[code]
entry_trades.pop(code, None)
else:
pos.quantity = remaining_qty
pos.remaining_pct = remaining_qty / (pos.quantity + sell_qty)
# 일말 평가
position_value = 0.0
for code, pos in positions.items():
stock_bars = bars.get(code, [])
if bar_idx < len(stock_bars):
position_value += stock_bars[bar_idx].close_price * pos.quantity
total_equity = cash + position_value
equity_values.append(total_equity)
equity_curve.append({
"date": str(current_date),
"equity": round(total_equity, 2),
})
# Drawdown
if total_equity > peak_equity:
peak_equity = total_equity
dd = ((total_equity - peak_equity) / peak_equity * 100) if peak_equity > 0 else 0
drawdown_curve.append({
"date": str(current_date),
"drawdown_pct": round(dd, 4),
})
# 성과 지표 계산
metrics = _compute_metrics(
equity_curve=equity_values,
trades=all_trades,
initial_capital=initial_capital,
trading_days=len(all_dates),
)
# 거래 내역 직렬화
trades_dicts = []
for t in all_trades:
trades_dicts.append({
"stock_code": t.stock_code,
"side": t.side,
"entry_date": str(t.entry_date) if t.entry_date else None,
"exit_date": str(t.exit_date) if t.exit_date else None,
"entry_price": t.entry_price,
"exit_price": t.exit_price,
"quantity": t.quantity,
"commission": round(t.commission, 2),
"tax": round(t.tax, 2),
"slippage": round(t.slippage, 2),
"pnl": t.pnl,
"pnl_pct": t.pnl_pct,
"reason": t.reason,
})
return BacktestResult(
metrics=metrics,
equity_curve=equity_curve,
trades=trades_dicts,
drawdown_curve=drawdown_curve,
)
@staticmethod
def _build_config_dict(
stock_codes: List[str],
start_date: date,
end_date: date,
strategy_name: str,
params: Dict[str, Any],
initial_capital: float,
) -> Dict[str, Any]:
return {
"stock_codes": stock_codes,
"start_date": str(start_date),
"end_date": str(end_date),
"strategy": strategy_name,
"params": params,
"initial_capital": initial_capital,
"commission": {
"buy_rate": BUY_COMMISSION_RATE,
"sell_rate": SELL_COMMISSION_RATE,
"tax_kospi": SELL_TAX_KOSPI,
"tax_kosdaq": SELL_TAX_KOSDAQ,
},
"slippage_pct": SLIPPAGE_PCT,
}