Files
auto-stock/backend/app/services/kr_backtest_engine.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
2026-10-10 12:17:49 +09:00

1398 lines
55 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
한국 주식 백테스트 엔진
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_backtest_v2,
check_exit_signal,
detect_market_regime,
is_reversal_candle,
ExitSignal,
)
from app.services.sizing_core import remaining_pct_after_partial_sell
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 = ""
# True 면 DB 에 없는 날(거래정지·부분 수집일·상장폐지 후)을 전일 종가로 메운 가짜 봉.
# 체결·신호·지표 계산에서 제외한다 (기본 False = 기존 호출부와 호환).
is_filler: bool = False
@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 # 분할매도 잔여 비율 (원본 수량 대비 단계값)
original_quantity: int = 0 # 진입 시 원본 수량 (0 이면 quantity 로 간주)
entry_bar_idx: int = -1 # 진입 체결 봉 인덱스 (시간손절 보유 거래일 계산, -1=미상)
strategy: str = "MEAN_REVERSION" # 청산 분기용 ("MOMENTUM_BREAKOUT" 이면 모멘텀 전용 청산)
# ---------------------------------------------------------------------------
# 수수료/세금/슬리피지 모델
# ---------------------------------------------------------------------------
# 수수료율 (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 _apply_sell_slippage_v2(price: float, seed: Optional[int] = None,
max_pct: float = SLIPPAGE_PCT) -> float:
"""장중 손절 체결용 매도 슬리피지 — 폭(max_pct)을 별도 파라미터(stop_slippage_pct)로 지정."""
rng = random.Random(seed)
return price * (1 - rng.uniform(0, abs(max_pct)))
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 시 매도 비율)}]
"""
...
def intraday_exits_v2(
self,
bar_idx: int,
bars: Dict[str, List[BarData]],
positions: Dict[str, Position],
params: Dict[str, Any],
) -> List[Dict[str, Any]]:
"""장중 체결형 청산(손절). 엔진이 bar_idx 봉 시가 체결 직후 호출한다.
반환: [{"stock_code", "base_price"(슬리피지 전 체결기준가), "reason"}] — 전량 청산.
기본은 없음(기존 전략 호환)."""
return []
def _bt_param_or_default_v2(params: Dict[str, Any], key: str, default: float,
lo: float, hi: float) -> float:
"""백테스트 파라미터 검증: 유한하고 [lo, hi] 안이면 params 값, 아니면 default(+warning)."""
val = params.get(key)
if val is None:
return float(default)
try:
f = float(val)
except (TypeError, ValueError):
f = float("nan")
if f != f or f in (float("inf"), float("-inf")) or not (lo <= abs(f) <= hi):
logger.warning("[KRBacktest] 파라미터 %s=%r 비정상 — 기본값 %s 사용", key, val, default)
return float(default)
return f
def _stop_price_v2(params: Dict[str, Any], entry_price: float, atr: float,
ref_close: float) -> Tuple[float, float, float, float]:
"""손절가 단일 계산(종가 판정·장중 판정 공용) -> (stop, atr_stop, pct_stop, stop_loss_pct).
stop = max(진입-배수×ATR, 진입×(1+손절률)) — 가격 비교이므로 max 가 타이트한 쪽(§5.2).
고변동(ATR/ref_close > 3%)이면 STOP_LOSS_HIGH_VOL. stop_loss_pct 는 `-abs()` 부호 가드.
"""
atr_mult = _bt_param_or_default_v2(
params, "atr_stop_multiplier", settings.ATR_STOP_MULTIPLIER,
float(settings.SWING_ATR_STOP_MULT_MIN), float(settings.SWING_ATR_STOP_MULT_MAX))
atr_pct = (float(atr) / ref_close * 100) if (atr and ref_close > 0) else 0.0
if atr_pct > 3.0:
sl_raw = _bt_param_or_default_v2(
params, "stop_loss_high_vol", settings.STOP_LOSS_HIGH_VOL,
float(settings.SWING_STOP_LOSS_PCT_MIN), float(settings.SWING_STOP_LOSS_PCT_MAX))
else:
sl_raw = _bt_param_or_default_v2(
params, "stop_loss_pct", settings.STOP_LOSS_PERCENT,
float(settings.SWING_STOP_LOSS_PCT_MIN), float(settings.SWING_STOP_LOSS_PCT_MAX))
stop_loss_pct = -abs(sl_raw)
atr_stop = entry_price - atr_mult * atr
pct_stop = entry_price * (1 + stop_loss_pct / 100)
return max(atr_stop, pct_stop), atr_stop, pct_stop, stop_loss_pct
def intraday_stop_fill_v2(params: Dict[str, Any], entry_price: float, atr: float,
prev_close: float, bar_open: float, bar_low: float
) -> Optional[Tuple[float, float]]:
"""장중 손절 판정(순수) -> (체결기준가, 손절가) 또는 None.
실전은 60초 주기 실시세로 close<=stop 을 보고 시장가 매도하므로, 일봉에서는
bar.low <= stop 이면 손절로 본다. 체결 기준가는 min(stop, open) — 갭다운이면 시가.
ATR·고변동 판정은 전일까지의 값(prev_close)만 쓴다(당일 정보 룩어헤드 금지).
"""
if entry_price <= 0 or atr is None or atr <= 0:
return None
stop_price, _, _, _ = _stop_price_v2(params, entry_price, atr, prev_close)
if bar_low <= stop_price:
return min(stop_price, float(bar_open)), stop_price
return None
def check_time_stop_backtest_v2(params: Dict[str, Any], holding_bars: int,
profit_pct: float) -> Optional[ExitSignal]:
"""스윙 시간손절(순수): 보유 봉수(=거래일) >= N 이고 수익률 < 최소수익이면 전량.
기간·최소수익은 params(swing_time_stop_*) → settings.SWING_TIME_STOP_* 순.
SWING_TIME_STOP_ENABLED 가 꺼져 있으면(params 로도 override 가능) None.
"""
enabled = params.get("swing_time_stop_enabled", settings.SWING_TIME_STOP_ENABLED)
if not enabled:
return None
max_days = int(params.get("swing_time_stop_days", settings.SWING_TIME_STOP_DAYS))
min_profit = float(params.get("swing_time_stop_min_profit_pct", settings.SWING_TIME_STOP_MIN_PROFIT_PCT))
if holding_bars >= max_days and profit_pct < min_profit:
return ExitSignal(
action="SELL_ALL",
reason=f"시간손절 ({holding_bars}거래일, {profit_pct:+.1f}% < +{min_profit}%)",
sell_pct=1.0,
)
return None
def check_momentum_exit_backtest_v2(params: Dict[str, Any], close: float, entry_price: float,
atr: float, remaining_pct: float,
trailing_high: Optional[float]) -> Optional[ExitSignal]:
"""모멘텀 돌파 전용 청산(결정론). swing/exit_service._check_momentum_exit 와 같은 구조:
손절 → 트레일링(수익 중) → +10% 30% → +20% 30%. 라이브 적응형·DB 를 읽지 않는다."""
if entry_price <= 0:
return None
profit_pct = (close - entry_price) / entry_price * 100
stop_price, _, _, _ = _stop_price_v2(params, entry_price, atr, close)
if close <= stop_price:
return ExitSignal(action="SELL_ALL",
reason=f"모멘텀 손절 ({profit_pct:.1f}%, stop={stop_price:.0f})", sell_pct=1.0)
if trailing_high and trailing_high > entry_price:
trail_mult = float(params.get("atr_trailing_multiplier", settings.ATR_TRAILING_MULTIPLIER))
trail_stop = trailing_high - trail_mult * atr
if close <= trail_stop and profit_pct > 0:
return ExitSignal(action="SELL_ALL",
reason=f"모멘텀 트레일링 (고점={trailing_high:.0f}, 트레일={trail_stop:.0f})",
sell_pct=1.0)
if profit_pct >= 10.0 and remaining_pct > 0.75:
return ExitSignal(action="SELL_PARTIAL", reason=f"모멘텀 1차 익절 +{profit_pct:.1f}% → 30% 매도",
sell_pct=0.30)
if profit_pct >= 20.0 and 0.45 < remaining_pct <= 0.75:
return ExitSignal(action="SELL_PARTIAL", reason=f"모멘텀 2차 익절 +{profit_pct:.1f}% → 30% 매도",
sell_pct=0.30)
return None
def check_exit_signal_backtest_v2(
params: Dict[str, Any],
rsi: float,
close: float,
bb_upper: float,
bb_middle: float,
entry_price: float,
atr: float,
remaining_pct: float,
trailing_high: Optional[float] = None,
) -> Optional[ExitSignal]:
"""백테스트 전용 청산 판정 — 라이브 적응형 모드·DB(strategy_cfg)를 읽지 않는다.
손절·익절 임계는 전부 `params`(기본 settings) 에서 명시 주입된다. 같은 입력이면
실행 시각과 무관하게 같은 결과(결정론). 로직 구조는 mean_reversion_strategy.check_exit_signal
과 동일(손절 → 1차 익절 → 전량/트레일링). 손절률은 `-abs()` 부호 가드를 적용한다.
"""
stop_price, atr_stop, pct_stop, stop_loss_pct = _stop_price_v2(params, entry_price, atr, close)
if close <= stop_price:
return ExitSignal(
action="SELL_ALL",
reason=f"손절 (close={close:.0f} <= stop={stop_price:.0f}, ATR기준={atr_stop:.0f}, {stop_loss_pct:.1f}%기준={pct_stop:.0f})",
sell_pct=1.0,
)
rsi_partial = float(params.get("rsi_exit_threshold", settings.MEAN_REVERSION_RSI_EXIT_THRESHOLD))
rsi_full = float(params.get("rsi_full_exit", settings.MEAN_REVERSION_RSI_FULL_EXIT))
trail_mult = float(params.get("atr_trailing_multiplier", 1.5))
threshold = 0.50
profit_pct = ((close - entry_price) / entry_price * 100) if entry_price > 0 else 0.0
if remaining_pct > threshold and profit_pct >= 0.5:
reasons = []
if rsi >= rsi_partial:
reasons.append(f"RSI({rsi:.1f})>={rsi_partial:.0f}")
if close > bb_middle:
reasons.append(f"BB중앙({bb_middle:.0f})상회")
if close >= entry_price + 2.0 * atr:
reasons.append(f"2×ATR({atr:.0f})수익")
if reasons:
return ExitSignal(action="SELL_PARTIAL",
reason=f"1차 익절(50%): {' OR '.join(reasons)}", sell_pct=0.50)
full = []
if rsi >= rsi_full:
full.append(f"RSI({rsi:.1f})>={rsi_full}")
if close >= bb_upper * settings.MEAN_REVERSION_BB_UPPER_THRESHOLD:
full.append(f"BB상단({bb_upper:.0f})도달")
if remaining_pct <= threshold + 1e-9 and trailing_high is not None and trailing_high > entry_price:
trail_stop = trailing_high - trail_mult * atr
if close < trail_stop:
full.append(f"트레일링(close={close:.0f}<{trail_stop:.0f})")
if full:
return ExitSignal(action="SELL_ALL", reason=f"전량청산: {' OR '.join(full)}", sell_pct=1.0)
return None
# ---------------------------------------------------------------------------
# 내장 전략: MeanReversionBacktest (RSI + BB 평균회귀)
# ---------------------------------------------------------------------------
class MeanReversionBacktest(BacktestStrategy):
"""CLAUDE.md 섹션 6 RSI+BB 평균회귀 전략 백테스트 버전
기존 mean_reversion_strategy.py의 check_buy_signal_backtest_v2(결정론 진입) / check_exit_signal_backtest_v2 를
그대로 재활용합니다. 시장 국면(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,
"stop_loss_high_vol": settings.STOP_LOSS_HIGH_VOL,
"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", "SIDEWAYS" 고정만 지원 (AUTO 는 ValueError)
"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]
if current_bar.is_filler:
# 결측일(정지·미수집): 손절/고점 갱신 판정 보류, 다음 실제 봉에서 판정
continue
real_bars = _real_bars_upto(stock_bars, bar_idx)
closes = [b.close_price for b in real_bars]
highs = [b.high_price for b in real_bars]
lows = [b.low_price for b in real_bars]
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)
is_momentum = str(getattr(pos, "strategy", "")).upper() == "MOMENTUM_BREAKOUT"
if is_momentum:
exit_signal = check_momentum_exit_backtest_v2(
params, float(current_bar.close_price), pos.entry_price, atr,
pos.remaining_pct, pos.highest_since_entry,
)
else:
exit_signal = check_exit_signal_backtest_v2(
params=params,
rsi=rsi,
close=float(current_bar.close_price),
bb_upper=bb_upper,
bb_middle=bb_middle,
entry_price=pos.entry_price,
atr=atr,
remaining_pct=pos.remaining_pct,
trailing_high=pos.highest_since_entry,
)
# 시간손절(실전 swing/exit_service 와 같은 우선순위): 모멘텀 제외, 전량 신호가 이미
# 있으면 유지, 없거나 부분매도면 시간손절(전량)이 덮어쓴다. 보유일=봉 인덱스 차.
if (not is_momentum and pos.entry_bar_idx >= 0
and (exit_signal is None or exit_signal.sell_pct < 1.0)):
ts_profit = ((float(current_bar.close_price) - pos.entry_price) / pos.entry_price * 100
if pos.entry_price > 0 else 0.0)
ts_signal = check_time_stop_backtest_v2(params, bar_idx - pos.entry_bar_idx, ts_profit)
if ts_signal is not None:
exit_signal = ts_signal
if exit_signal:
# 실전 규약: sell_pct 는 원본 수량 대비, 잔량 ≤3주면 전량
orig_qty = pos.original_quantity or pos.quantity
if exit_signal.sell_pct >= 1.0 or pos.quantity <= 3:
sell_qty = pos.quantity
else:
sell_qty = min(int(orig_qty * exit_signal.sell_pct), pos.quantity)
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]
if current_bar.is_filler:
continue # 결측일에는 신규 진입 신호를 만들지 않는다
real_bars = _real_bars_upto(stock_bars, bar_idx)
closes = [b.close_price for b in real_bars]
highs = [b.high_price for b in real_bars]
lows = [b.low_price for b in real_bars]
opens = [b.open_price for b in real_bars]
volumes = [b.volume for b in real_bars]
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
# [안전형 개선①] own_trend 게이트용 — 종목 자체 MA20/MA60 (데이터 있으면 재사용)
ma20_val = sum(closes[-20:]) / 20 if len(closes) >= 20 else None
ma60_val = sum(closes[-60:]) / 60 if len(closes) >= 60 else None
# [하드닝 R2 2026-10-09] 라이브 적응형 모드·strategy_cfg 를 읽지 않는 결정론 진입 판정.
# 진입 임계(rsi_entry·bear_rsi_entry·bb_pct_b·bear_volume_mult)는 params 로 주입한다.
signal = check_buy_signal_backtest_v2(
params,
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,
ma20=ma20_val,
ma60=ma60_val,
)
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
def intraday_exits_v2(
self,
bar_idx: int,
bars: Dict[str, List[BarData]],
positions: Dict[str, Position],
params: Dict[str, Any],
) -> List[Dict[str, Any]]:
"""장중 저가 손절: bar.low <= stop 이면 min(stop, open) 기준가로 전량 청산.
ATR 은 전일까지의 실제 봉으로만 계산(룩어헤드 없음). params['intraday_stop_enabled']=False
로 끄면 종전(종가 판정) 동작 — A-B 비교용."""
if not params.get("intraday_stop_enabled", True) or bar_idx < 1:
return []
min_bars = params.get("min_data_bars", 60)
out: List[Dict[str, Any]] = []
for code, pos in list(positions.items()):
stock_bars = bars.get(code, [])
if bar_idx >= len(stock_bars):
continue
cur = stock_bars[bar_idx]
if cur.is_filler:
continue
prev = _real_bars_upto(stock_bars, bar_idx - 1)
if len(prev) < min_bars:
continue
atr = calculate_atr([b.high_price for b in prev], [b.low_price for b in prev],
[b.close_price for b in prev], params.get("atr_period", 14))
if atr is None:
continue
hit = intraday_stop_fill_v2(params, pos.entry_price, atr, float(prev[-1].close_price),
float(cur.open_price), float(cur.low_price))
if hit is None:
continue
base, stop = hit
out.append({
"stock_code": code,
"base_price": base,
"reason": f"장중손절 (low={cur.low_price} <= stop={stop:.0f}, 체결기준={base:.0f})",
})
return out
@staticmethod
def _get_regime(params: Dict[str, Any]) -> MarketRegime:
regime_str = str(params.get("regime", "BULL")).upper()
if regime_str == "AUTO":
# 지수(코스피200/KODEX200) 데이터가 DB 에 없어 일자별 국면을 재현할 수 없다.
# 조용히 BULL 로 돌리면 SIDEWAYS/BEAR 게이트가 빠진 결과가 실전처럼 보인다.
raise ValueError("regime='AUTO' 는 미지원(지수 데이터 없음) — BULL/BEAR/SIDEWAYS 중 고정 지정")
if regime_str not in ("BULL", "BEAR", "SIDEWAYS"):
raise ValueError(f"알 수 없는 regime: {regime_str}")
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 조회)
# ---------------------------------------------------------------------------
PRICE_EVENT_TOL = 0.25 # collect_krx_data.PRICE_EVENT_TOL 과 동일 정의
PRICE_EVENT_MAX_GAP_DAYS = 4 # 이보다 긴 누락 구간은 이벤트가 아니라 갭 (수집기와 동일)
def back_adjust_price_events_v2(
bars: List[BarData],
change_rates: List[Optional[float]],
tol: float = PRICE_EVENT_TOL,
max_gap_days: int = PRICE_EVENT_MAX_GAP_DAYS,
) -> int:
"""원주가 불연속을 감지해 이벤트 이전 봉의 OHLC/거래량을 제자리에서 역보정한다. 순수 함수.
change_rates[i] 는 bars[i] 의 거래소 등락률(소수, 분할 반영된 값). 실제 종가비와 tol 초과
괴리면 이벤트: 이전 봉들을 k = close / (prev_close*(1+cr)) 배 하고 거래량은 k 로 나눈다.
change_rate 가 없으면 판정 불가라 보정하지 않는다. 반환: 보정한 이벤트 수.
"""
n = 0
for i in range(len(bars) - 1, 0, -1):
cr = change_rates[i] if i < len(change_rates) else None
if cr is None:
continue
cur, prev = bars[i], bars[i - 1]
if not cur.close_price or not prev.close_price or cur.close_price <= 0 or prev.close_price <= 0:
continue
if (cur.trade_date - prev.trade_date).days > max_gap_days:
continue
if abs(cur.close_price / prev.close_price - 1.0 - cr) <= tol:
continue
denom = prev.close_price * (1.0 + cr)
if cr <= -0.99 or denom <= 0:
continue
k = cur.close_price / denom
if k <= 0:
continue
for b in bars[:i]:
b.open_price = int(round(b.open_price * k))
b.high_price = int(round(b.high_price * k))
b.low_price = int(round(b.low_price * k))
b.close_price = int(round(b.close_price * k))
b.volume = int(round(b.volume / k))
n += 1
return n
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
# 주의: stock_daily_prices 는 수정주가가 아니다. 분할·병합·권리락 불연속은
# change_rate(거래소 등락률)와 종가비 괴리로 감지해 과거 봉을 역보정한다.
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] = []
change_rates: List[Optional[float]] = []
for row in rows:
change_rates.append(
float(row.change_rate) if row.change_rate is not None else None
)
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)
n_events = back_adjust_price_events_v2(bar_list, change_rates)
if n_events:
logger.warning(
"종목 %s: 원주가 불연속 %d건 역보정 (분할/병합/권리락 추정)", code, n_events,
)
bars_by_code[code] = bar_list
all_dates = sorted(all_dates_set)
return bars_by_code, all_dates
def _real_bars_upto(stock_bars: List[BarData], bar_idx: int) -> List[BarData]:
"""bar_idx 까지의 실제 봉만 (가짜 채움 봉 제외) — 지표 계산용"""
return [b for b in stock_bars[: bar_idx + 1] if not getattr(b, "is_filler", False)]
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,
is_filler=True,
)
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
# 데이터 로드 전에 거부 (AUTO 가 조용히 BULL 로 처리되던 결함)
MeanReversionBacktest._get_regime(effective_params)
# 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:
"""핵심 시뮬레이션 루프 (동기)
[Look-ahead bias 수정]
T일 close로 신호(on_bar) 생성 -> T+1일 open + 슬리피지로 체결.
마지막 봉 신호는 다음 봉이 없으므로 무시합니다.
"""
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
# 전일 신호를 저장 (T일 close 신호 -> T+1일 open 체결)
pending_orders: List[Dict[str, Any]] = []
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
# -- 1단계: 전일 신호를 오늘 open + 슬리피지로 체결 --
carry_orders: List[Dict[str, Any]] = []
for order in pending_orders:
action = order["action"]
code = order["stock_code"]
quantity = order["quantity"]
# T+1일 open 가격을 base price로 사용
stock_bars = bars.get(code, [])
if bar_idx >= len(stock_bars):
continue
if getattr(stock_bars[bar_idx], "is_filler", False):
# 가짜 봉(정지·미수집일)에서는 체결 불가 -> 매도만 다음 실제 봉으로 이월.
# 매수는 낡은 신호이므로 폐기. 이후 실제 봉이 없으면(상장폐지 등) 미체결 유지.
if order["action"] == "SELL":
carry_orders.append(order)
continue
next_open = float(stock_bars[bar_idx].open_price)
# 슬리피지용 시드
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(next_open, 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 - next_open) * 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,
original_quantity=quantity,
entry_bar_idx=bar_idx,
)
elif action == "SELL":
pos = positions.get(code)
if pos is None:
continue
sell_qty = min(quantity, pos.quantity)
exec_price = _apply_sell_slippage(next_open, slip_seed)
proceeds, commission, tax = _calc_sell_proceeds(exec_price, sell_qty, market)
cash += proceeds
slippage_amount = abs(next_open - 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_pct_after_partial_sell(
pos.remaining_pct,
float(order.get("sell_pct", 0.0) or 0.0),
remaining_qty,
pos.original_quantity or (remaining_qty + sell_qty),
)
pending_orders = carry_orders
# -- 1.5단계: 장중 손절(저가 터치) — 오늘 봉 안에서 체결 (실전 60초 감시 근사) --
for ix in strategy.intraday_exits_v2(bar_idx, bars, positions, params):
code = ix["stock_code"]
pos = positions.get(code)
if pos is None:
continue
if seed_counter is not None:
seed_counter += 1
slip_seed = seed_counter
else:
slip_seed = None
base_price = float(ix["base_price"])
exec_price = _apply_sell_slippage_v2(
base_price, slip_seed, float(params.get("stop_slippage_pct", SLIPPAGE_PCT)))
sell_qty = pos.quantity
proceeds, commission, tax = _calc_sell_proceeds(exec_price, sell_qty, market)
cash += proceeds
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
all_trades.append(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=abs(base_price - exec_price) * sell_qty,
pnl=round(pnl, 2), pnl_pct=round(pnl_pct, 4), reason=ix.get("reason", ""),
))
del positions[code]
entry_trades.pop(code, None)
# -- 2단계: 오늘 close 기준으로 신호 생성 (체결은 내일 open) --
is_last_bar = (bar_idx == len(all_dates) - 1)
if not is_last_bar:
# 체결 이후의 상태로 평가 자산 재계산 (전략에 전달)
position_value = 0.0
for code, pos in positions.items():
stock_bars_eval = bars.get(code, [])
if bar_idx < len(stock_bars_eval):
position_value += stock_bars_eval[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,
)
pending_orders = carry_orders + list(orders)
# 일말 평가
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),
})
# 종료 시점에 마지막 봉이 가짜 봉인 보유 종목: 정지/상장폐지 가능성 -> 청산 불가 상태로 평가됨
for code in positions:
sb = bars.get(code, [])
if sb and getattr(sb[-1], "is_filler", False):
logger.warning(
"백테스트 종료 시점 %s 은 실제 시세가 끊긴 채(정지/상장폐지 추정) 마지막 실제 종가로 평가됨 — 손실이 과소평가될 수 있음",
code,
)
# 성과 지표 계산
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,
}