Files
auto-stock/backend/app/services/walk_forward.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

650 lines
24 KiB
Python

"""
Walk-Forward Optimization (WFO) 엔진
롤링 윈도우 기반으로 In-Sample 구간에서 Optuna를 통해 파라미터를 최적화하고,
Out-of-Sample 구간에서 검증하여 과적합 여부를 탐지합니다.
핵심 흐름:
1. 전체 기간을 IS(70%) / OOS(30%) 비율로 롤링 윈도우 생성
2. 각 윈도우의 IS 구간에서 Optuna Trial 50회 실행 (목적함수: Sharpe Ratio)
3. 최적 파라미터로 OOS 구간 백테스트 실행
4. IS vs OOS Sharpe 비교 -> 50% 이상 감소 시 과적합 경고
5. 전체 윈도우 종합 결과 반환
의존성:
- kr_backtest_engine.py (KrBacktestEngine + MeanReversionBacktest, 실전 스윙 청산 로직)
- optuna (requirements.txt에 추가 필요)
[2026-10 하드닝 R2] 최적화기는 이제 kr_backtest_engine(_v2 경로)을 돌린다.
이전 판은 장난감 엔진(backtest_engine.BacktestEngine: 고정 손절 + 2R 고정 익절)을
돌렸고 BB_PERIOD·ATR_STOP_MULTIPLIER 는 아무 효과가 없었다. 따라서
**이 변경 이전에 생성된 walk-forward 산출물(recommended_params 포함)은 인용 금지**이다.
알려진 한계(추정이 아니라 코드 확인 사항):
- kr_backtest_engine 의 진입 RSI 임계는 params 로 주입되지 않는다
(mean_reversion_strategy.check_buy_signal 이 adaptive → strategy_cfg → settings 를 읽음).
그래서 RSI 진입 임계는 탐색 공간에서 제외했다.
- 엔진의 수수료·세금은 settings(KR_*) 고정이라 WalkForwardConfig.commission_rate/tax_rate 는
_v2 경로에서 쓰이지 않는다.
"""
import asyncio
import logging
from dataclasses import dataclass, field, asdict
from datetime import date, timedelta
from typing import Dict, List, Any, Optional
import numpy as np
logger = logging.getLogger(__name__)
# ── 데이터 클래스 ────────────────────────────────────────────────────────────
@dataclass
class WalkForwardConfig:
"""Walk-Forward 최적화 설정"""
stock_codes: List[str]
start_date: date
end_date: date
initial_capital: float = 10_000_000
max_positions: int = 5
position_size: float = 0.2
commission_rate: float = 0.00015
tax_rate: float = 0.0023
# 롤링 윈도우 설정
is_months: int = 6 # In-Sample 기간 (월)
oos_months: int = 2 # Out-of-Sample 기간 (월)
step_months: int = 1 # 슬라이딩 간격 (월)
# Optuna 설정
n_trials: int = 50 # 윈도우당 최대 trial 수
optuna_timeout: int = 300 # 윈도우당 최대 시간 (초)
# 과적합 탐지
overfit_threshold: float = 0.5 # IS vs OOS Sharpe 감소율 경고 임계값
@dataclass
class WindowResult:
"""개별 윈도우 결과"""
window_index: int
is_start: str
is_end: str
oos_start: str
oos_end: str
best_params: Dict[str, Any]
is_sharpe: float
oos_sharpe: float
is_return_pct: float
oos_return_pct: float
is_mdd_pct: float
oos_mdd_pct: float
is_win_rate: float
oos_win_rate: float
is_total_trades: int
oos_total_trades: int
sharpe_degradation: float # (IS - OOS) / |IS|, 높을수록 과적합
overfit_warning: bool # sharpe_degradation > threshold
@dataclass
class WalkForwardResult:
"""Walk-Forward 최적화 종합 결과"""
config: Dict[str, Any]
total_windows: int
windows: List[Dict[str, Any]]
# 종합 OOS 성과
avg_oos_sharpe: float
avg_oos_return_pct: float
avg_oos_mdd_pct: float
avg_oos_win_rate: float
total_oos_trades: int
# 과적합 분석
overfit_windows: int # 과적합 경고가 발생한 윈도우 수
avg_sharpe_degradation: float
overfit_ratio: float # overfit_windows / total_windows
# 최종 추천 파라미터 (OOS Sharpe 가중평균)
recommended_params: Dict[str, Any]
# 산출 엔진 표식 (구 장난감 엔진 산출물과 구분)
engine: str = ""
# ── 유틸리티 ─────────────────────────────────────────────────────────────────
def _add_months(d: date, months: int) -> date:
"""날짜에 월 수를 더합니다. 월말 보정 포함."""
month = d.month - 1 + months
year = d.year + month // 12
month = month % 12 + 1
import calendar
max_day = calendar.monthrange(year, month)[1]
day = min(d.day, max_day)
return date(year, month, day)
def _generate_windows(
start_date: date,
end_date: date,
is_months: int,
oos_months: int,
step_months: int,
) -> List[Dict[str, date]]:
"""롤링 윈도우 목록을 생성합니다.
각 윈도우는 {is_start, is_end, oos_start, oos_end}를 포함합니다.
OOS 종료일이 전체 end_date를 초과하면 해당 윈도우부터 중단합니다.
"""
windows = []
current_start = start_date
while True:
is_end = _add_months(current_start, is_months) - timedelta(days=1)
oos_start = is_end + timedelta(days=1)
oos_end = _add_months(oos_start, oos_months) - timedelta(days=1)
if oos_end > end_date:
# OOS 구간이 전체 기간을 초과하면 end_date로 클램핑 후 마지막 윈도우
if oos_start <= end_date:
oos_end = end_date
windows.append({
"is_start": current_start,
"is_end": is_end,
"oos_start": oos_start,
"oos_end": oos_end,
})
break
windows.append({
"is_start": current_start,
"is_end": is_end,
"oos_start": oos_start,
"oos_end": oos_end,
})
current_start = _add_months(current_start, step_months)
# IS 구간이 전체 기간의 50% 이상을 넘기면 중단
if _add_months(current_start, is_months) > end_date:
break
return windows
# ── Walk-Forward 엔진 ────────────────────────────────────────────────────────
class WalkForwardOptimizer:
"""Walk-Forward Optimization 엔진
Optuna를 사용하여 각 IS 윈도우에서 파라미터를 최적화하고,
OOS 윈도우에서 검증합니다.
"""
def __init__(self):
self._engine_cls = None
self._config_cls = None
def _lazy_import(self):
"""BacktestEngine을 실행 시점에 import합니다 (lazy import)."""
if self._engine_cls is None:
try:
from app.services.kr_backtest_engine import (
BacktestEngine as KREngine,
MeanReversionBacktest,
)
self._engine_cls = KREngine
self._config_cls = MeanReversionBacktest
logger.info("kr_backtest_engine 로드 성공")
except ImportError:
from app.services.backtest_engine import (
BacktestEngine,
BacktestConfig,
)
self._engine_cls = BacktestEngine
self._config_cls = BacktestConfig
logger.info("kr_backtest_engine 없음 -> backtest_engine 폴백 사용")
async def _run_single_backtest(
self,
stock_codes: List[str],
start_date: date,
end_date: date,
params: Dict[str, Any],
wf_config: WalkForwardConfig,
) -> Dict[str, float]:
"""[DEPRECATED — 인용 금지] 장난감 엔진(backtest_engine) 실행. `_run_single_backtest_v2` 사용.
Returns:
{sharpe_ratio, total_return_pct, mdd_pct, win_rate, total_trades}
"""
self._lazy_import()
from app.services.backtest_engine import BacktestEngine, BacktestConfig
config = BacktestConfig(
stock_codes=stock_codes,
start_date=start_date,
end_date=end_date,
initial_capital=wf_config.initial_capital,
strategy_params=params,
max_positions=wf_config.max_positions,
position_size=wf_config.position_size,
commission_rate=wf_config.commission_rate,
tax_rate=wf_config.tax_rate,
)
engine = BacktestEngine()
result = await engine.run_backtest(config)
return {
"sharpe_ratio": result.sharpe_ratio,
"total_return_pct": result.total_return_pct,
"mdd_pct": result.mdd_pct,
"win_rate": result.win_rate,
"total_trades": result.total_trades,
}
def _lazy_import_v2(self):
"""실전 스윙 청산 로직을 담은 KrBacktestEngine 을 로드한다.
구 `_lazy_import` 는 존재하지 않는 이름(BacktestEngine)을 import 해 항상 ImportError 로
장난감 엔진에 폴백했다. v2 는 폴백 없이 ImportError 를 그대로 올린다(조용한 폴백 금지).
"""
if getattr(self, "_kr_engine_cls", None) is None:
from app.services.kr_backtest_engine import KrBacktestEngine
self._kr_engine_cls = KrBacktestEngine
async def _run_single_backtest_v2(
self,
stock_codes: List[str],
start_date: date,
end_date: date,
params: Dict[str, Any],
wf_config: WalkForwardConfig,
) -> Dict[str, float]:
"""kr_backtest_engine(실전 청산 모델)으로 단일 백테스트를 실행한다.
반환 단위는 구 `_run_single_backtest` 와 동일하게 맞춘다(mdd/수익률/승률 = 비율, 0.1=10%).
kr 엔진은 % 단위(10.0=10%)이므로 /100 변환한다.
"""
self._lazy_import_v2()
from app.core.config import settings
engine_params = dict(params)
# 사이징은 하드 상한(MAX_POSITION_SIZE)·보유수 상한(MAX_HOLDINGS_COUNT)을 넘기지 않는다.
engine_params["position_size_pct"] = min(
float(wf_config.position_size), float(settings.MAX_POSITION_SIZE))
engine_params["max_positions"] = min(
int(wf_config.max_positions), int(settings.MAX_HOLDINGS_COUNT))
engine = self._kr_engine_cls()
result = await engine.run(
stock_codes=stock_codes,
start_date=start_date,
end_date=end_date,
strategy_name="mean_reversion",
params=engine_params,
initial_capital=wf_config.initial_capital,
)
m = result.metrics
return {
"sharpe_ratio": m.sharpe_ratio,
"total_return_pct": m.total_return_pct / 100.0,
"mdd_pct": m.max_drawdown_pct / 100.0,
"win_rate": m.win_rate / 100.0,
"total_trades": m.total_trades,
}
def _build_params_from_trial_v2(self, trial) -> Dict[str, Any]:
"""kr_backtest_engine 이 실제로 소비하는 키만 탐색한다.
탐색 범위(구 버전과 동일 값):
- bb_period: 15 ~ 25
- atr_stop_multiplier: 1.5 ~ 3.5
- stop_loss_pct: 4 ~ 10 (양수 규약, 엔진이 -abs() 부호 가드 적용)
RSI 진입 임계는 엔진이 params 로 받지 않으므로 제외. 익절은 엔진의 실전 분할익절 모델.
"""
bb_period = trial.suggest_int("bb_period", 15, 25)
atr_multiplier = trial.suggest_float("atr_multiplier", 1.5, 3.5, step=0.1)
stop_loss_pct = trial.suggest_float("stop_loss_pct", 4.0, 10.0, step=0.5)
return {
"bb_period": bb_period,
"atr_stop_multiplier": atr_multiplier,
"stop_loss_pct": abs(stop_loss_pct),
}
@staticmethod
def _default_params_v2() -> Dict[str, Any]:
"""모든 trial 실패 시 폴백 — 임의 상수가 아니라 현행 settings 값."""
from app.core.config import settings
return {
"bb_period": int(settings.BOLLINGER_PERIOD),
"atr_stop_multiplier": float(settings.ATR_STOP_MULTIPLIER),
"stop_loss_pct": abs(float(settings.STOP_LOSS_PERCENT)),
}
def _build_params_from_trial(self, trial) -> Dict[str, Any]:
"""Optuna trial에서 전략 파라미터를 생성합니다.
탐색 범위:
- RSI 임계값: 25 ~ 45
- BB 기간: 15 ~ 25
- ATR 배수: 1.5 ~ 3.5
- 손절 %: 4 ~ 10
"""
rsi_threshold = trial.suggest_int("rsi_threshold", 25, 45)
bb_period = trial.suggest_int("bb_period", 15, 25)
atr_multiplier = trial.suggest_float("atr_multiplier", 1.5, 3.5, step=0.1)
stop_loss_pct = trial.suggest_float("stop_loss_pct", 4.0, 10.0, step=0.5)
return {
"MEAN_REVERSION_RSI_THRESHOLD": rsi_threshold,
"BB_PERIOD": bb_period,
"ATR_STOP_MULTIPLIER": atr_multiplier,
"MEAN_REVERSION_STOP_LOSS_PCT": -stop_loss_pct,
# 익절은 손절의 2배 (2R 목표)
"MEAN_REVERSION_TAKE_PROFIT_PCT": stop_loss_pct * 2.0,
"RSI_OVERBOUGHT_LEVEL": 70.0,
}
async def _optimize_window(
self,
window_idx: int,
window: Dict[str, date],
wf_config: WalkForwardConfig,
) -> WindowResult:
"""단일 윈도우에 대해 IS 최적화 + OOS 검증을 수행합니다."""
try:
import optuna
optuna.logging.set_verbosity(optuna.logging.WARNING)
except ImportError:
raise ImportError(
"optuna 패키지가 필요합니다. "
"'pip install optuna' 를 실행하세요."
)
is_start = window["is_start"]
is_end = window["is_end"]
oos_start = window["oos_start"]
oos_end = window["oos_end"]
logger.info(
f"[윈도우 {window_idx}] IS: {is_start}~{is_end}, "
f"OOS: {oos_start}~{oos_end}"
)
# ── Optuna 목적함수 (IS 구간 Sharpe Ratio 최대화) ──
best_is_metrics = {"sharpe_ratio": -999.0}
best_params_holder = [None]
async def objective_async(trial) -> float:
params = self._build_params_from_trial_v2(trial)
try:
metrics = await self._run_single_backtest_v2(
stock_codes=wf_config.stock_codes,
start_date=is_start,
end_date=is_end,
params=params,
wf_config=wf_config,
)
except Exception as e:
logger.warning(f" Trial {trial.number} 실패: {e}")
return -999.0
sharpe = metrics["sharpe_ratio"]
# 거래가 너무 적으면 페널티 (최소 3회)
if metrics["total_trades"] < 3:
sharpe = sharpe - 1.0
# MDD가 -20% 이하이면 페널티
if metrics["mdd_pct"] < -0.20:
sharpe = sharpe - 0.5
if sharpe > best_is_metrics["sharpe_ratio"]:
best_is_metrics.update(metrics)
best_params_holder[0] = params
return sharpe
# Optuna study 생성 (동기 wrapper로 async objective 실행)
study = optuna.create_study(
direction="maximize",
study_name=f"wfo_window_{window_idx}",
sampler=optuna.samplers.TPESampler(seed=42 + window_idx),
)
def objective_sync(trial):
loop = asyncio.get_event_loop()
if loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as pool:
future = pool.submit(
asyncio.run, objective_async(trial)
)
return future.result()
else:
return loop.run_until_complete(objective_async(trial))
# async 환경에서 Optuna 실행
# Optuna는 동기 API이므로 thread에서 실행
def run_study():
study.optimize(
objective_sync,
n_trials=wf_config.n_trials,
timeout=wf_config.optuna_timeout,
show_progress_bar=False,
)
await asyncio.to_thread(run_study)
best_params = best_params_holder[0]
if best_params is None:
# 모든 trial이 실패한 경우 기본 파라미터 사용
best_params = self._default_params_v2()
logger.warning(f"[윈도우 {window_idx}] 모든 trial 실패 -> 기본 파라미터 사용")
# ── IS 최종 성과 (best_params) ──
is_metrics = await self._run_single_backtest_v2(
stock_codes=wf_config.stock_codes,
start_date=is_start,
end_date=is_end,
params=best_params,
wf_config=wf_config,
)
# ── OOS 검증 ──
oos_metrics = await self._run_single_backtest_v2(
stock_codes=wf_config.stock_codes,
start_date=oos_start,
end_date=oos_end,
params=best_params,
wf_config=wf_config,
)
# ── 과적합 탐지 ──
is_sharpe = is_metrics["sharpe_ratio"]
oos_sharpe = oos_metrics["sharpe_ratio"]
if abs(is_sharpe) > 1e-10:
sharpe_degradation = (is_sharpe - oos_sharpe) / abs(is_sharpe)
else:
sharpe_degradation = 0.0
overfit_warning = sharpe_degradation > wf_config.overfit_threshold
if overfit_warning:
logger.warning(
f"[윈도우 {window_idx}] 과적합 경고: "
f"IS Sharpe={is_sharpe:.2f}, OOS Sharpe={oos_sharpe:.2f}, "
f"감소율={sharpe_degradation:.1%}"
)
return WindowResult(
window_index=window_idx,
is_start=str(is_start),
is_end=str(is_end),
oos_start=str(oos_start),
oos_end=str(oos_end),
best_params=best_params,
is_sharpe=round(is_sharpe, 4),
oos_sharpe=round(oos_sharpe, 4),
is_return_pct=round(is_metrics["total_return_pct"], 4),
oos_return_pct=round(oos_metrics["total_return_pct"], 4),
is_mdd_pct=round(is_metrics["mdd_pct"], 4),
oos_mdd_pct=round(oos_metrics["mdd_pct"], 4),
is_win_rate=round(is_metrics["win_rate"], 4),
oos_win_rate=round(oos_metrics["win_rate"], 4),
is_total_trades=is_metrics["total_trades"],
oos_total_trades=oos_metrics["total_trades"],
sharpe_degradation=round(sharpe_degradation, 4),
overfit_warning=overfit_warning,
)
def _compute_recommended_params(
self, window_results: List[WindowResult]
) -> Dict[str, Any]:
"""OOS Sharpe 가중평균으로 최종 추천 파라미터를 산출합니다.
OOS Sharpe가 양수인 윈도우만 가중치에 포함합니다.
모든 OOS Sharpe가 음수이면 동일 가중치(산술 평균)를 사용합니다.
"""
param_keys = [
"bb_period",
"atr_stop_multiplier",
"stop_loss_pct",
]
# 가중치 계산 (OOS Sharpe, 최소 0)
weights = []
for wr in window_results:
w = max(wr.oos_sharpe, 0.0)
weights.append(w)
total_weight = sum(weights)
# 모든 가중치가 0이면 동일 가중치
if total_weight < 1e-10:
weights = [1.0] * len(window_results)
total_weight = float(len(window_results))
recommended = {}
for key in param_keys:
weighted_sum = 0.0
for i, wr in enumerate(window_results):
val = wr.best_params.get(key, 0.0)
weighted_sum += val * weights[i]
raw_val = weighted_sum / total_weight
# 정수 파라미터 반올림
if key == "bb_period":
recommended[key] = int(round(raw_val))
else:
recommended[key] = round(raw_val, 2)
return recommended
async def run(self, config: WalkForwardConfig) -> WalkForwardResult:
"""Walk-Forward 최적화를 실행합니다.
Args:
config: WalkForwardConfig 설정
Returns:
WalkForwardResult 종합 결과
"""
logger.info(
f"Walk-Forward 최적화 시작: {config.start_date} ~ {config.end_date}, "
f"IS={config.is_months}개월, OOS={config.oos_months}개월, "
f"Step={config.step_months}개월, Trials={config.n_trials}"
)
# 1. 롤링 윈도우 생성
windows = _generate_windows(
start_date=config.start_date,
end_date=config.end_date,
is_months=config.is_months,
oos_months=config.oos_months,
step_months=config.step_months,
)
if not windows:
raise ValueError(
f"유효한 윈도우가 없습니다. 기간이 최소 "
f"{config.is_months + config.oos_months}개월 이상이어야 합니다. "
f"입력 기간: {config.start_date} ~ {config.end_date}"
)
logger.info(f"총 {len(windows)}개 윈도우 생성")
# 2. 각 윈도우 순차 실행 (DB 부하 제한)
window_results: List[WindowResult] = []
for idx, window in enumerate(windows):
try:
wr = await self._optimize_window(idx, window, config)
window_results.append(wr)
logger.info(
f"[윈도우 {idx}] 완료 - "
f"IS Sharpe={wr.is_sharpe:.2f}, OOS Sharpe={wr.oos_sharpe:.2f}"
f"{' [과적합 경고]' if wr.overfit_warning else ''}"
)
except Exception as e:
logger.error(f"[윈도우 {idx}] 실패: {e}")
continue
if not window_results:
raise RuntimeError("모든 윈도우 최적화가 실패했습니다.")
# 3. 종합 OOS 성과 계산
oos_sharpes = [wr.oos_sharpe for wr in window_results]
oos_returns = [wr.oos_return_pct for wr in window_results]
oos_mdds = [wr.oos_mdd_pct for wr in window_results]
oos_winrates = [wr.oos_win_rate for wr in window_results]
oos_trades_total = sum(wr.oos_total_trades for wr in window_results)
overfit_count = sum(1 for wr in window_results if wr.overfit_warning)
degradations = [wr.sharpe_degradation for wr in window_results]
# 4. 추천 파라미터 계산
recommended = self._compute_recommended_params(window_results)
result = WalkForwardResult(
config={
"stock_codes": config.stock_codes,
"start_date": str(config.start_date),
"end_date": str(config.end_date),
"is_months": config.is_months,
"oos_months": config.oos_months,
"step_months": config.step_months,
"n_trials": config.n_trials,
"initial_capital": config.initial_capital,
},
total_windows=len(window_results),
windows=[asdict(wr) for wr in window_results],
avg_oos_sharpe=round(float(np.mean(oos_sharpes)), 4),
avg_oos_return_pct=round(float(np.mean(oos_returns)), 4),
avg_oos_mdd_pct=round(float(np.mean(oos_mdds)), 4),
avg_oos_win_rate=round(float(np.mean(oos_winrates)), 4),
total_oos_trades=oos_trades_total,
overfit_windows=overfit_count,
avg_sharpe_degradation=round(float(np.mean(degradations)), 4),
overfit_ratio=round(overfit_count / len(window_results), 4),
recommended_params=recommended,
engine="kr_backtest_engine_v2",
)
logger.info(
f"Walk-Forward 최적화 완료: "
f"{len(window_results)}개 윈도우, "
f"평균 OOS Sharpe={result.avg_oos_sharpe:.2f}, "
f"과적합 비율={result.overfit_ratio:.0%}"
)
return result