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
This commit is contained in:
2026-10-10 12:17:49 +09:00
parent 5c4803b339
commit a264561576
230 changed files with 41160 additions and 2334 deletions

View File

@@ -12,8 +12,20 @@ Out-of-Sample 구간에서 검증하여 과적합 여부를 탐지합니다.
5. 전체 윈도우 종합 결과 반환
의존성:
- backtest_engine.py (BacktestEngine, BacktestConfig)
- 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
@@ -99,6 +111,9 @@ class WalkForwardResult:
# 최종 추천 파라미터 (OOS Sharpe 가중평균)
recommended_params: Dict[str, Any]
# 산출 엔진 표식 (구 장난감 엔진 산출물과 구분)
engine: str = ""
# ── 유틸리티 ─────────────────────────────────────────────────────────────────
@@ -202,7 +217,7 @@ class WalkForwardOptimizer:
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}
@@ -234,6 +249,85 @@ class WalkForwardOptimizer:
"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에서 전략 파라미터를 생성합니다.
@@ -289,10 +383,10 @@ class WalkForwardOptimizer:
best_params_holder = [None]
async def objective_async(trial) -> float:
params = self._build_params_from_trial(trial)
params = self._build_params_from_trial_v2(trial)
try:
metrics = await self._run_single_backtest(
metrics = await self._run_single_backtest_v2(
stock_codes=wf_config.stock_codes,
start_date=is_start,
end_date=is_end,
@@ -353,18 +447,11 @@ class WalkForwardOptimizer:
best_params = best_params_holder[0]
if best_params is None:
# 모든 trial이 실패한 경우 기본 파라미터 사용
best_params = {
"MEAN_REVERSION_RSI_THRESHOLD": 30,
"BB_PERIOD": 20,
"ATR_STOP_MULTIPLIER": 2.5,
"MEAN_REVERSION_STOP_LOSS_PCT": -6.0,
"MEAN_REVERSION_TAKE_PROFIT_PCT": 12.0,
"RSI_OVERBOUGHT_LEVEL": 70.0,
}
best_params = self._default_params_v2()
logger.warning(f"[윈도우 {window_idx}] 모든 trial 실패 -> 기본 파라미터 사용")
# ── IS 최종 성과 (best_params) ──
is_metrics = await self._run_single_backtest(
is_metrics = await self._run_single_backtest_v2(
stock_codes=wf_config.stock_codes,
start_date=is_start,
end_date=is_end,
@@ -373,7 +460,7 @@ class WalkForwardOptimizer:
)
# ── OOS 검증 ──
oos_metrics = await self._run_single_backtest(
oos_metrics = await self._run_single_backtest_v2(
stock_codes=wf_config.stock_codes,
start_date=oos_start,
end_date=oos_end,
@@ -429,12 +516,9 @@ class WalkForwardOptimizer:
모든 OOS Sharpe가 음수이면 동일 가중치(산술 평균)를 사용합니다.
"""
param_keys = [
"MEAN_REVERSION_RSI_THRESHOLD",
"BB_PERIOD",
"ATR_STOP_MULTIPLIER",
"MEAN_REVERSION_STOP_LOSS_PCT",
"MEAN_REVERSION_TAKE_PROFIT_PCT",
"RSI_OVERBOUGHT_LEVEL",
"bb_period",
"atr_stop_multiplier",
"stop_loss_pct",
]
# 가중치 계산 (OOS Sharpe, 최소 0)
@@ -459,7 +543,7 @@ class WalkForwardOptimizer:
raw_val = weighted_sum / total_weight
# 정수 파라미터 반올림
if key in ("MEAN_REVERSION_RSI_THRESHOLD", "BB_PERIOD"):
if key == "bb_period":
recommended[key] = int(round(raw_val))
else:
recommended[key] = round(raw_val, 2)
@@ -552,6 +636,7 @@ class WalkForwardOptimizer:
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(