Files
auto-stock/backend/app/services/regime_hmm.py
shuim 9fab4018d4 fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소.

주문·리스크
- MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문)
- KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류)
- 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백
- 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락
- 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드)
- 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단
데이트레이딩
- 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한
- 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정
스윙
- 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT)
- 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed
- 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일
- 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON
인프라·보안
- 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹
- KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환
- db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시
- KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패
하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준
CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신

backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:01:31 +09:00

486 lines
16 KiB
Python
Raw Permalink 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.
"""
HMM(Hidden Markov Model) 기반 시장 국면 감지
관측 변수 3개: [일간 수익률, 변동성(20일 rolling std), 거래량 변화율]
은닉 상태 3개: BULL / BEAR / SIDEWAYS
학습 데이터: KODEX 200 (069500) 최근 2년 일봉 (네이버 파이낸스 또는 DB)
모델 캐싱: ~/.cache/auto-stock/hmm_model.pkl (TTL 7일, 주 1회 재학습)
hmmlearn 미설치 시 ImportError 를 잡아 graceful degradation —
기존 규칙 기반(MA200+ADX)만 사용.
"""
import asyncio
import logging
import os
import pickle
import time
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import numpy as np
logger = logging.getLogger(__name__)
# ── hmmlearn optional import ─────────────────────────────────────────────────
_HMM_AVAILABLE = False
try:
from hmmlearn.hmm import GaussianHMM # type: ignore
_HMM_AVAILABLE = True
except ImportError:
logger.info("hmmlearn 미설치 — HMM 국면 감지 비활성화 (규칙 기반만 사용)")
# ── 상수 ──────────────────────────────────────────────────────────────────────
_CACHE_DIR = Path.home() / ".cache" / "auto-stock"
_MODEL_PATH = _CACHE_DIR / "hmm_model.pkl"
_CACHE_TTL_SECONDS = 7 * 24 * 3600 # 7일
_N_STATES = 3 # BULL=0, BEAR=1, SIDEWAYS=2 (학습 후 재매핑)
_ROLLING_VOL_WINDOW = 20
_MIN_TRAIN_DAYS = 120 # 최소 학습 데이터 (거래일 기준)
# 앙상블 가중치 (초기값)
HMM_WEIGHT = 0.4
RULE_WEIGHT = 0.6
# ── 관측 데이터 구성 ──────────────────────────────────────────────────────────
def _build_observations(
closes: List[float],
volumes: List[float],
) -> Optional[np.ndarray]:
"""일봉 종가·거래량 → HMM 관측 행렬 (N×3).
Features:
0: 일간 수익률 log(close_t / close_{t-1})
1: 변동성 20일 rolling std of 일간 수익률
2: 거래량 변화율 log(vol_t / vol_{t-1}), vol=0 → NaN 제거
Returns:
np.ndarray shape (T, 3) or None if 데이터 부족
"""
if len(closes) < _ROLLING_VOL_WINDOW + 2 or len(volumes) < _ROLLING_VOL_WINDOW + 2:
return None
c = np.array(closes, dtype=np.float64)
v = np.array(volumes, dtype=np.float64)
# 일간 수익률 (log return)
log_ret = np.diff(np.log(np.maximum(c, 1e-8)))
# 20일 rolling std
rolling_std = np.full_like(log_ret, np.nan)
for i in range(_ROLLING_VOL_WINDOW - 1, len(log_ret)):
window = log_ret[i - _ROLLING_VOL_WINDOW + 1 : i + 1]
rolling_std[i] = np.std(window, ddof=1)
# 거래량 변화율 (log scale)
v_safe = np.maximum(v[1:], 1.0) # 0 방지
v_prev = np.maximum(v[:-1], 1.0)
vol_change = np.log(v_safe / v_prev)
# rolling_std 가 NaN 인 초기 구간 제거
valid_start = _ROLLING_VOL_WINDOW - 1
log_ret = log_ret[valid_start:]
rolling_std = rolling_std[valid_start:]
vol_change = vol_change[valid_start:]
if len(log_ret) < _MIN_TRAIN_DAYS:
return None
obs = np.column_stack([log_ret, rolling_std, vol_change])
# NaN / Inf 제거
mask = np.all(np.isfinite(obs), axis=1)
obs = obs[mask]
if len(obs) < _MIN_TRAIN_DAYS:
return None
return obs
# ── 상태 매핑 ──────────────────────────────────────────────────────────────────
def _map_states(model: "GaussianHMM") -> Dict[int, str]:
"""학습된 HMM 은닉 상태 → BULL/BEAR/SIDEWAYS 매핑.
기준: 각 상태의 평균 수익률(means_[state][0])
- 수익률 가장 높은 상태 → BULL
- 수익률 가장 낮은 상태 → BEAR
- 나머지 → SIDEWAYS
"""
mean_returns = model.means_[:, 0] # 각 상태의 평균 일간 수익률
sorted_idx = np.argsort(mean_returns) # 오름차순: [가장작은, 중간, 가장큰]
mapping = {}
mapping[int(sorted_idx[0])] = "bear"
mapping[int(sorted_idx[1])] = "sideways"
mapping[int(sorted_idx[2])] = "bull"
return mapping
# ── 모델 학습 ──────────────────────────────────────────────────────────────────
async def _load_chart_data_for_training() -> Optional[Tuple[List[float], List[float]]]:
"""KODEX 200(069500) 최근 2년(≈500거래일) 일봉 로드.
1차: 네이버 파이낸스 (최대 600일)
2차: DB stock_daily_prices 폴백
Returns:
(closes, volumes) oldest-first, or None
"""
# 1차: 네이버 파이낸스
try:
from app.services.naver_finance import get_naver_daily_chart
chart = await get_naver_daily_chart("069500", days=600)
if chart and len(chart) >= _MIN_TRAIN_DAYS:
closes = [float(item.get("stck_clpr", 0)) for item in chart]
volumes = [float(item.get("acml_vol", 0)) for item in chart]
if all(c > 0 for c in closes[-_MIN_TRAIN_DAYS:]):
logger.info("HMM 학습 데이터: 네이버 파이낸스 %d일", len(closes))
return closes, volumes
except Exception as e:
logger.warning("HMM: 네이버 차트 로드 실패 — %s", e)
# 2차: DB 폴백
try:
from app.core.database import AsyncSessionLocal
from app.models.models import StockDailyPrice
from sqlalchemy import select
from datetime import date
two_years_ago = date.today() - timedelta(days=730)
async with AsyncSessionLocal() as session:
stmt = (
select(StockDailyPrice)
.where(StockDailyPrice.stock_code == "069500")
.where(StockDailyPrice.trade_date >= two_years_ago)
.order_by(StockDailyPrice.trade_date.asc())
)
result = await session.execute(stmt)
rows = result.scalars().all()
if rows and len(rows) >= _MIN_TRAIN_DAYS:
closes = [float(r.close_price) for r in rows if r.close_price]
volumes = [float(r.volume or 0) for r in rows]
logger.info("HMM 학습 데이터: DB stock_daily_prices %d일", len(closes))
return closes, volumes
except Exception as e:
logger.warning("HMM: DB 폴백 실패 — %s", e)
return None
def _train_hmm(obs: np.ndarray) -> Optional["GaussianHMM"]:
"""GaussianHMM 학습 (3 states, diagonal covariance).
hmmlearn 의 ConvergenceWarning 을 무시하고,
최대 200회 반복, random seed 고정으로 재현성 확보.
"""
if not _HMM_AVAILABLE:
return None
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore")
try:
model = GaussianHMM(
n_components=_N_STATES,
covariance_type="diag",
n_iter=200,
random_state=42,
tol=1e-4,
)
model.fit(obs)
return model
except Exception as e:
logger.error("HMM 학습 실패: %s", e)
return None
def _save_model(model: "GaussianHMM", state_map: Dict[int, str]) -> bool:
"""학습된 모델 + 상태 매핑을 pkl 캐시에 저장."""
try:
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
payload = {
"model": model,
"state_map": state_map,
"trained_at": time.time(),
}
with open(_MODEL_PATH, "wb") as f:
pickle.dump(payload, f)
logger.info("HMM 모델 저장 완료: %s", _MODEL_PATH)
return True
except Exception as e:
logger.error("HMM 모델 저장 실패: %s", e)
return False
def _load_model() -> Optional[Dict]:
"""캐시에서 모델 로드. TTL 7일 초과 시 None 반환."""
if not _MODEL_PATH.exists():
return None
try:
with open(_MODEL_PATH, "rb") as f:
payload = pickle.load(f)
trained_at = payload.get("trained_at", 0)
if time.time() - trained_at > _CACHE_TTL_SECONDS:
logger.info("HMM 모델 캐시 만료 (%.1f일 경과)", (time.time() - trained_at) / 86400)
return None
return payload
except Exception as e:
logger.warning("HMM 모델 로드 실패: %s", e)
return None
# ── 공개 API ──────────────────────────────────────────────────────────────────
async def fit_regime_model(force: bool = False) -> bool:
"""HMM 시장 국면 모델 학습.
Args:
force: True 이면 캐시 TTL 무시하고 재학습
Returns:
True 학습 성공, False 실패
"""
if not _HMM_AVAILABLE:
logger.info("hmmlearn 미설치 — HMM 학습 스킵")
return False
# 캐시 TTL 확인
if not force:
cached = _load_model()
if cached is not None:
logger.info("HMM 모델 캐시 유효 — 재학습 불필요")
return True
# 데이터 로드
data = await _load_chart_data_for_training()
if data is None:
logger.warning("HMM: 학습 데이터 부족")
return False
closes, volumes = data
# 관측 행렬 구성
obs = _build_observations(closes, volumes)
if obs is None:
logger.warning("HMM: 관측 데이터 구성 실패")
return False
# 학습 (CPU-bound → thread pool)
model = await asyncio.to_thread(_train_hmm, obs)
if model is None:
return False
# 상태 매핑 결정
state_map = _map_states(model)
logger.info(
"HMM 상태 매핑: %s (평균 수익률: %s)",
state_map,
{k: f"{model.means_[k, 0]:.6f}" for k in state_map},
)
# 저장
return await asyncio.to_thread(_save_model, model, state_map)
async def predict_current_regime(
closes: Optional[List[float]] = None,
volumes: Optional[List[float]] = None,
) -> Optional[Dict]:
"""현재 시장 국면 HMM 예측.
Args:
closes: 최근 일봉 종가 (oldest first, 최소 40일)
volumes: 최근 일봉 거래량 (oldest first, 같은 길이)
미제공 시 네이버/DB 에서 자동 로드
Returns:
{
"regime": "bull" | "bear" | "sideways",
"confidence": float (0.0~1.0),
"transition_prob": {"bull": float, "bear": float, "sideways": float},
"state_sequence_last5": [str, ...],
}
or None (모델 없음/예측 실패)
"""
if not _HMM_AVAILABLE:
return None
# 모델 로드
cached = _load_model()
if cached is None:
# 자동 학습 시도
success = await fit_regime_model()
if not success:
return None
cached = _load_model()
if cached is None:
return None
model: "GaussianHMM" = cached["model"]
state_map: Dict[int, str] = cached["state_map"]
# 관측 데이터 준비
if closes is None or volumes is None:
data = await _load_chart_data_for_training()
if data is None:
return None
closes, volumes = data
obs = _build_observations(closes, volumes)
if obs is None:
return None
try:
# Viterbi 디코딩으로 최적 상태 시퀀스
_, state_seq = model.decode(obs, algorithm="viterbi")
# 현재 상태
current_state = int(state_seq[-1])
current_regime = state_map.get(current_state, "sideways")
# 사후 확률 (마지막 관측에 대한 각 상태 확률)
posteriors = model.predict_proba(obs)
last_prob = posteriors[-1] # shape: (n_states,)
# confidence = 현재 상태의 사후 확률
confidence = float(last_prob[current_state])
# 전이 확률: 현재 상태에서 다음 상태로의 전이 확률
trans_row = model.transmat_[current_state]
transition_prob = {}
for state_idx, regime_name in state_map.items():
transition_prob[regime_name] = round(float(trans_row[state_idx]), 4)
# 최근 5일 상태 시퀀스
last_5 = [state_map.get(int(s), "sideways") for s in state_seq[-5:]]
return {
"regime": current_regime,
"confidence": round(confidence, 4),
"transition_prob": transition_prob,
"state_sequence_last5": last_5,
}
except Exception as e:
logger.error("HMM 예측 실패: %s", e)
return None
def ensemble_regime(
hmm_result: Optional[Dict],
rule_regime: str,
) -> Dict:
"""HMM 결과와 규칙 기반 결과를 앙상블.
가중 평균 방식:
- HMM weight 0.4, Rule weight 0.6 (초기값)
- 두 모델 일치 시 confidence 높음
- 불일치 시 보수적 판정 (SIDEWAYS)
Args:
hmm_result: predict_current_regime() 반환값 (None 가능)
rule_regime: 기존 detect_market_regime() 결과
("bull", "bear", "sideways")
Returns:
{
"regime": str,
"confidence": float,
"hmm_regime": str | None,
"rule_regime": str,
"method": "ensemble" | "rule_only",
}
"""
rule_lower = rule_regime.lower()
# HMM 결과 없으면 규칙 기반만 사용
if hmm_result is None:
return {
"regime": rule_lower,
"confidence": 0.6, # 규칙 기반 기본 신뢰도
"hmm_regime": None,
"rule_regime": rule_lower,
"method": "rule_only",
}
hmm_regime = hmm_result["regime"]
hmm_conf = hmm_result["confidence"]
# 일치 여부 판정
if hmm_regime == rule_lower:
# 두 모델 일치 → 높은 confidence
combined_conf = min(1.0, RULE_WEIGHT * 0.9 + HMM_WEIGHT * hmm_conf)
return {
"regime": rule_lower,
"confidence": round(combined_conf, 4),
"hmm_regime": hmm_regime,
"rule_regime": rule_lower,
"method": "ensemble",
}
# 불일치: 보수적 판정
# CASE 1: 한쪽이 sideways → sideways
if hmm_regime == "sideways" or rule_lower == "sideways":
return {
"regime": "sideways",
"confidence": round(0.5 * hmm_conf, 4),
"hmm_regime": hmm_regime,
"rule_regime": rule_lower,
"method": "ensemble",
}
# CASE 2: bull vs bear → HMM confidence 높으면 HMM, 아니면 규칙 기반
# 단, confidence 낮으면 sideways 로 보수적 처리
if hmm_conf >= 0.7:
# HMM 이 확신 높으면 HMM 에 가중치 부여
# 그래도 RULE_WEIGHT 가 0.6 이므로 규칙 기반 우선
final_regime = rule_lower
combined_conf = round(RULE_WEIGHT * 0.5, 4) # 낮은 신뢰도
else:
# 불확실 → sideways (보수적)
final_regime = "sideways"
combined_conf = round(0.3 + 0.2 * hmm_conf, 4)
return {
"regime": final_regime,
"confidence": combined_conf,
"hmm_regime": hmm_regime,
"rule_regime": rule_lower,
"method": "ensemble",
}
# ── 모듈 상태 확인 ────────────────────────────────────────────────────────────
def is_hmm_available() -> bool:
"""hmmlearn 라이브러리 설치 여부."""
return _HMM_AVAILABLE
def get_model_info() -> Dict:
"""캐시된 모델 메타 정보 반환."""
if not _MODEL_PATH.exists():
return {"status": "no_model", "available": _HMM_AVAILABLE}
cached = _load_model()
if cached is None:
return {"status": "expired", "available": _HMM_AVAILABLE, "path": str(_MODEL_PATH)}
trained_at = cached.get("trained_at", 0)
age_days = (time.time() - trained_at) / 86400
return {
"status": "ready",
"available": _HMM_AVAILABLE,
"path": str(_MODEL_PATH),
"trained_at": datetime.fromtimestamp(trained_at).isoformat(),
"age_days": round(age_days, 1),
"ttl_remaining_days": round(7 - age_days, 1),
"state_map": cached.get("state_map", {}),
}