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

@@ -26,9 +26,12 @@ from app.core.database import Base
import app.models.models # noqa: F401
# ── alembic.ini 로거 설정 ─────────────────────────────────────────────────────
# [FIX] disable_existing_loggers=False: 앱 내(main.py lifespan)에서 alembic upgrade를
# 호출할 때 fileConfig가 기본값(True)으로 앱의 모든 로거를 비활성화해 startup 이후
# 로그가 전멸하는 문제 방지. CLI 단독 실행 시에도 무해.
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
fileConfig(config.config_file_name, disable_existing_loggers=False)
# ── DB URL: asyncpg → psycopg2 (Alembic 동기 드라이버용) ────────────────────
_db_url = settings.DATABASE_URL

View File

@@ -147,19 +147,17 @@ async def lifespan(app: FastAPI):
logger.error(traceback.format_exc())
raise
# ── Alembic 자동 마이그레이션 (서버 이전 시 DB 스키마 자동 동기화) ──────
try:
from alembic.config import Config as AlembicConfig
from alembic import command as alembic_command
import os
alembic_ini = os.path.join(os.path.dirname(os.path.dirname(__file__)), "alembic.ini")
if os.path.exists(alembic_ini):
alembic_cfg = AlembicConfig(alembic_ini)
alembic_cfg.set_main_option("script_location", os.path.join(os.path.dirname(os.path.dirname(__file__)), "alembic"))
alembic_command.upgrade(alembic_cfg, "head")
logger.info("✅ Alembic migration completed (upgrade head)")
except Exception as e:
logger.warning(f"⚠️ Alembic migration 실패 (non-critical): {e}")
# ── Alembic 자동 마이그레이션: 의도적으로 비활성화 ────────────────────────
# [FIX] alembic_command.upgrade()를 lifespan에서 호출하면 두 가지 문제가 있었다:
# (1) env.py가 이벤트 루프 내부에서 asyncio.run()을 호출 → 항상 실패
# ("asyncio.run() cannot be called from a running event loop"). 즉 수개월간
# 실제 마이그레이션이 한 번도 동작하지 않았다.
# (2) 그 실패 이전에 env.py 모듈 로딩 시 fileConfig()가 alembic.ini의 root 로거
# 설정(level=WARN)으로 앱 로깅을 덮어써, startup 이후 모든 INFO 로그(스케줄러
# 사이클/진입 로그 등)가 침묵 → 매매 관측 불가의 원인.
# 스키마는 위의 create_all()로 생성되며, 컬럼 변경 등 실제 마이그레이션은 배포 시
# CLI(`alembic upgrade head`)로 수행한다. 따라서 여기서는 호출하지 않는다.
logger.info("ℹ️ Alembic auto-migration skipped (schema via create_all; migrations run via CLI)")
# ── KRX 일봉 데이터 초기 수집 (DB 비어있을 때만 백그라운드 실행) ──────
try:

View File

@@ -144,7 +144,11 @@ def calculate_adx(highs: List[float], lows: List[float], closes: List[float], pe
# _wilder_smooth()는 초기값이 sum이므로 ADX에는 인라인 계산 사용
adx_val = sum(dxs[:period]) / period # 초기값 = 평균
for dx_v in dxs[period:]:
adx_val = adx_val - adx_val / period + dx_v
# [FIX] Wilder ADX 평활: ADX = ADX_prev*(p-1)/p + DX/p
# = ADX_prev - ADX_prev/p + DX/p. 기존엔 마지막 항의 /period가 누락돼
# (+ dx_v) DX(0~100)를 그대로 더해 ADX가 100을 넘어 발산 → SIDEWAYS
# 게이트(ADX<20)가 실서비스에서 영구 무력화되던 버그. (CLAUDE.md 6.1)
adx_val = adx_val - adx_val / period + dx_v / period
return round(adx_val, 2)

View File

@@ -397,7 +397,11 @@ async def _load_trade_history(
from sqlalchemy import select, and_
from app.models.models import TradeHistory
cutoff = datetime.now(KST) - timedelta(days=months * 30)
# [FIX] TradeHistory.created_at은 timestamp WITHOUT time zone(naive UTC, datetime.utcnow
# 저장)이므로, tz-aware인 datetime.now(KST)로 비교하면 asyncpg가
# "can't subtract offset-naive and offset-aware datetimes" DataError를 낸다.
# 컬럼과 동일하게 naive UTC로 맞춘다.
cutoff = datetime.utcnow() - timedelta(days=months * 30)
async with AsyncSessionLocal() as session:
stmt = (

View File

@@ -155,11 +155,15 @@ async def swing_scheduler() -> None:
# ── Phase 3: 추천 조회 + 진입 실행 (별도 세션) ──
# 4. Redis 캐시 확인 → cache miss 시 DB 조회
rec = await get_json("recommendations:latest")
if rec:
# [FIX] Redis 캐시가 존재해도 recommendations_by_rule이 비어있으면(배치가 빈
# 추천을 캐싱한 경우) 기존엔 '히트'로 처리해 DB 폴백을 건너뛰고 스킵했다.
# 빈 캐시가 30분 TTL 동안 DB의 유효 후보를 가려 매매를 막으므로, 비어있으면
# 캐시 미스와 동일하게 DB 조회로 폴백한다.
if rec and rec.get("recommendations_by_rule"):
rec = _normalize_recommendations(rec)
logger.info(f"[Swing] Redis 캐시 히트 (추천 {len(rec.get('recommendations_by_rule', []))}그룹)")
else:
logger.info("[Swing] Redis 캐시 미스 → DB 조회")
logger.info("[Swing] Redis 캐시 미스/빈값 → DB 조회")
async with AsyncSessionLocal() as rec_session:
rec = await get_rule_based_recommendations_from_db(rec_session)

View File

@@ -705,6 +705,27 @@ async def _execute_buy(
except Exception as _pm_err:
logger.debug(f"[PATTERN_MEMORY] {stock_code} 포지션 조정 실패 (무시): {_pm_err}")
# [BUG FIX] 국면별 배율(_position_multiplier)과 뉴스 배율(_news_position_scale)을
# invest_amount에 반영 — 기존에는 stock dict에 주입만 되고 실제로 사용되지 않았음.
try:
_position_multiplier = float(stock.get("_position_multiplier", 1.0) or 1.0)
except Exception:
_position_multiplier = 1.0
_position_multiplier = max(0.0, min(1.0, _position_multiplier))
try:
_news_position_scale = float(stock.get("_news_position_scale", 1.0) or 1.0)
except Exception:
_news_position_scale = 1.0
_news_position_scale = max(0.0, min(1.0, _news_position_scale))
if _position_multiplier != 1.0 or _news_position_scale != 1.0:
invest_amount = invest_amount * _position_multiplier * _news_position_scale
logger.debug(
f"[POSITION_SCALE] {stock_code}: position_multiplier={_position_multiplier:.2f} "
f"news_scale={_news_position_scale:.2f} → invest_amount={invest_amount:,.0f}"
)
# 투자금액 → 주수 환산 (Decimal 사용으로 부동소수점 오차 방지)
quantity = int(Decimal(str(invest_amount)) / Decimal(str(current_price)))
logger.info(
@@ -929,7 +950,7 @@ async def _execute_sell(
TradeHistory.created_at >= recent_cutoff,
)
)
if duplicate_check.scalar_one_or_none():
if duplicate_check.scalars().first():
return {"action": "SKIP", "reason": "duplicate_order", "stock": stock_name}
# ── 2. 주문 실행 (종목별 세마포어 + 재시도) ──────────────────────────
@@ -1321,6 +1342,24 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
)
holding = result.scalar_one_or_none() # None이면 신규매수, 있으면 추가매수/매도 판단
# [BUG FIX] ActiveTrade.remaining_pct 조회 — 미주입 시 1차 익절 후에도
# remaining_pct가 항상 1.0으로 간주되어 SELL_PARTIAL이 반복 발생하는 문제 방지.
# 읽기 전용 SELECT만 수행 (DB 쓰기 없음, swing/exit_service.py의 자체 추적과 무충돌).
if holding is not None and stock.get("remaining_pct") is None:
try:
from app.models.models import ActiveTrade
_at_result = await db.execute(
select(ActiveTrade).where(
ActiveTrade.stock_code == stock_code,
ActiveTrade.status == "active",
)
)
_active_trade = _at_result.scalars().first()
if _active_trade is not None and _active_trade.remaining_pct is not None:
stock["remaining_pct"] = float(_active_trade.remaining_pct)
except Exception as _at_err:
logger.debug(f"[TRADE] {stock_code}: ActiveTrade remaining_pct 조회 실패: {_at_err}")
# ── 신호 분석 (Enum 기반) ─────────────────────────────────────────────
text_signals = " ".join(signals)
try:
@@ -1390,10 +1429,18 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
if any(b in text_signals for b in BLOCK_KEYWORDS):
return {"action": "HOLD", "stock": stock_name, "reason": "overheat_no_rule"}
# ML HOLD이면 매수 억제 (신뢰도 무관 — HOLD는 매수하지 않음)
if ml_signal_trade == "HOLD" and settings.ML_ENSEMBLE_ENABLED:
# ML HOLD이면 매수 억제 — 단, 신뢰도 60% 이상의 '확신 있는' HOLD만 차단.
# [FIX] 기존에는 신뢰도 무관하게 차단해, 일봉 데이터 부족으로 ML 피처 추출이
# 실패한 경우(signal=HOLD, confidence=0 = 사실상 "의견 없음")까지 모든 매수를
# 막아버렸다(수개월간 거래 0의 근본 원인). 위 ml_hold_active(1417~) 로직과 동일하게
# confidence>=0.6 조건을 적용해, 저신뢰/실패 HOLD는 룰 기반 판단을 막지 않도록 한다.
if (
ml_signal_trade == "HOLD"
and settings.ML_ENSEMBLE_ENABLED
and (ml_confidence_trade or 0) >= 0.6
):
should_buy = False
logger.info(f"[TRADE] {stock_code}: ML HOLD → 매수 차단")
logger.info(f"[TRADE] {stock_code}: ML HOLD(conf≥0.6) → 매수 차단")
if should_buy and not has_rule:
# 비규칙 종목: 강한 신호 OR (신호 2개 이상 AND 점수 4점 이상) 필수

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