fix: 매수 차단·백테스트 왜곡 유발한 버그 5종 수정

수개월간 매수 0건 + 백테스트가 손실로 보이던 근본 원인들을 수정.

- ADX 발산(mean_reversion_strategy, run_backtest): Wilder 평활에 /period
  누락으로 ADX가 100 초과 발산 → 실서비스 SIDEWAYS 게이트(ADX<20) 영구
  무력화. 공식 복원(표준 Wilder와 대수적으로 동일 검증).
- 백테스트 워밍업(run_backtest): 국면판정 220봉을 start_date부터 세서
  14개월 미만 백테스트의 ~90%가 강제 BEAR → 가짜 손실. start_date 340일
  전부터 프리로드하고 성과는 sim_start_date부터만 집계. CAGR 분모도 워밍업
  제외한 실제 거래일로 수정. → 최근1년 -0.33%→+6.95%, 가산성 회복.
- ML HOLD 게이트(trade_executor): 신뢰도 무관 매수 차단 → 데이터 부족으로
  ML이 실패(conf=0)한 HOLD까지 모든 매수를 막던 것을 conf>=0.6 조건부로.
- 빈 Redis 추천 캐시(swing/scheduler): 빈 캐시를 '히트'로 처리해 DB 폴백을
  건너뛰던 것을 recommendations_by_rule 비면 DB 폴백하도록.
- alembic 로깅(env.py, main.py): lifespan의 alembic upgrade가 fileConfig로
  앱 로거를 꺼 startup 이후 로그 전멸 → 스킵(스키마는 create_all 담당).

검증: code-verifier 통과(실전 경로 CONFIRMED 버그 없음), walk-forward 6/9구간
양(+)·과적합 아님(조건부 견고). 서바이버십 편향은 별도 이슈로 검증 진행 중.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBmzf1HDnvFHLraJ8eaaKJ
This commit is contained in:
2026-09-16 10:03:56 +09:00
parent 3965760021
commit a0973812fb
7 changed files with 112 additions and 26 deletions

View File

@@ -424,8 +424,13 @@ def calculate_adx(highs: List[float], lows: List[float], closes: List[float], pe
if len(dxs) < period:
return None
adx_s = _wilder_smooth(dxs)
return round(adx_s[-1], 2)
# [FIX] ADX는 합산형 _wilder_smooth(초기값=sum)가 아니라 평균형 Wilder 평활을
# 써야 한다. 기존엔 DX(0~100)에 sum-accumulation을 적용해 ADX가 100을 넘어
# 발산했고, SIDEWAYS 게이트(ADX<20)가 사실상 무력화됐다. mean_reversion_strategy와 동일 수정.
adx_val = sum(dxs[:period]) / period # 초기값 = 평균
for dx_v in dxs[period:]:
adx_val = adx_val - adx_val / period + dx_v / period
return round(adx_val, 2)
def is_reversal_candle(opens: List[float], highs: List[float],
@@ -992,6 +997,7 @@ def run_simulation(
all_dates: List[date],
initial_capital: float,
universe: Dict[str, Tuple[str, str]],
sim_start_date: Optional[date] = None,
) -> Tuple[BacktestMetrics, List[Trade], List[Dict], List[Dict]]:
"""핵심 백테스트 시뮬레이션 -- 일봉 단위로 매매 결정 실행.
@@ -1071,6 +1077,13 @@ def run_simulation(
mode_params = ADAPTIVE_MODES[adaptive_mode]
regime_update_counter = 0
# [FIX] 워밍업 구간(sim_start_date 이전): 국면·지표 누적만 하고
# 매매·평가·성과기록은 건너뛴다. 이렇게 해야 국면판정 220봉 워밍업이
# sim_start_date 시점에 이미 완료되어, 짧은 기간 백테스트도 유효해지고
# 구간 분할 시 거래수·수익이 가산적으로 일치한다.
if sim_start_date is not None and current_date < sim_start_date:
continue
# 현재 평가 자산
position_value = 0.0
for code, pos in positions.items():
@@ -1377,7 +1390,12 @@ def run_simulation(
"unrealized_pnl_pct": round((last_close / pos.entry_price - 1) * 100, 2),
})
metrics = compute_metrics(equity_values, all_trades, initial_capital, len(all_dates))
# [FIX] CAGR 연율화 분모는 워밍업(sim_start_date 이전)을 제외한 실제 거래일 수를
# 써야 한다. len(all_dates)는 워밍업 340일을 포함해 years를 부풀려 CAGR을 과소평가했다.
_sim_trading_days = sum(
1 for _d in all_dates if sim_start_date is None or _d >= sim_start_date
)
metrics = compute_metrics(equity_values, all_trades, initial_capital, _sim_trading_days)
return metrics, all_trades, equity_curve, drawdown_curve, open_positions, mode_changes
@@ -1607,8 +1625,15 @@ def main():
t0 = time.time()
# 1. 데이터 수집
# [FIX] 국면판정(MA200+기울기=220거래일) 및 지표 워밍업을 위해 start_date보다
# 약 340일(≈230거래일) 앞선 데이터를 추가 로드한다. 실제 매매·성과 집계는
# run_simulation의 sim_start_date=start_date 부터만 이뤄진다(워밍업 아티팩트 제거).
from datetime import datetime as _dt
from datetime import timedelta as _td
_sim_start = _dt.strptime(start_date, "%Y-%m-%d").date()
_warmup_start = (_sim_start - _td(days=340)).strftime("%Y-%m-%d")
bars_by_code, index_bars, all_dates = load_data_fdr(
BACKTEST_UNIVERSE, start_date, end_date,
BACKTEST_UNIVERSE, _warmup_start, end_date,
)
if not bars_by_code:
@@ -1625,6 +1650,7 @@ def main():
print(f"\n 시뮬레이션 시작...")
metrics, all_trades, equity_curve, drawdown_curve, open_positions, mode_changes = run_simulation(
aligned, index_bars, all_dates, initial_capital, BACKTEST_UNIVERSE,
sim_start_date=_sim_start,
)
elapsed = time.time() - t0