커밋 1 — 실매 가격 TTL 구멍 (본체)

왜: 체결이 없어도 마지막가는 유지인데, TTL로 None 만들고 매도/EOD를 건너뛰어 8/5 돌파·금요일 leftover가 남음. 호가필터 TTL 구멍과 같은 병.

넣을 파일

신규: kis_trader/engine/live_sell_price.py
kis_trader/strategies/base.py (_ws_last_quote, _resolve_sell_price)
전략: momentum.py scalping.py tail_catch.py breakout.py range_break.py dart_strategy.py updow_strategy.py updown_feed.py us_momentum.py
WS: ws_manager.py kis_ws.py kiwoom_ws.py ls_ws.py kis_ws_overseas.py
kis_trader/web/live_config_schema.py (WS_PRICE_MAX_AGE_SEC 기본 0)
database.py (키 주석 + legacy/ sys.path)
kis_trader/execution/order_manager.py (잔고 있는데 40240000 ghost_purge 금지 — 같은 EOD 사고)
EOD가 min_hold에 안 막히게 손본 momentum_hts_logic.py / scalping_engine.py / tail_engine.py (이 대화에서 손본 부분만 확인 후)
문서: docs/like_mcp.md/db_erd.md code_architecture.md (가격 TTL 문구)
메시지 초안

fix: 매수·매도 현재가를 TTL로 버리지 않음 (마지막 RAM)
횡보·체결 공백을 죽은 캐시로 오인해 None 처리하면 손절·EOD가 스킵된다.
호가필터와 같이 나이는 무시하고 마지막 체결가를 유지한다. EOD는 매수가 폴백.
영향: 실매 O / 백테·옵투나 봉 경로 거의 무관 (엔진 식 변경 아님)

커밋 2 — 루트 정리 (remove/ vs legacy/)
왜: 루트 단독봇·테스트는 지울 보관함으로. 웹·알람이 아직 쓰는 모듈은 remove에 두면 나중에 폴더째 삭제 때 깨짐.

넣을 파일

이동: 미사용 → remove/legacy_root/ (래퍼, ETF/키움 옛봇, 테스트, kiwoom_rest_api 등)
이동: 사용 중 → legacy/ (holding_bot kis_holding_ver1 news_analyzer kis_long_ver1/2)
신규: kis_trader/utils/legacy_root.py legacy/README.md remove/README.md
import 경로: backtest_web.py mm_butler.py mm_remote.py updow_holding_cfg.py dbband_stock_cfg.py param_search_updow*.py dbband_param_search.py param_search_apply_snapshot.py verify_three_paths.py
docs/like_mcp.md/code_architecture.md 수동 노트
메시지 초안

chore: 미사용 루트는 remove/, 웹·알람 구모듈은 legacy/
remove는 나중에 통째 삭제 예정. holding_bot·news_analyzer·kis_long은
ensure_legacy_root로 legacy/만 본다.
빼기: scratch/set_ws_price_max_age_zero.py (일회성)
This commit is contained in:
Your Name
2026-08-18 00:13:17 +09:00
parent 36a3e2b4a1
commit 6d2a706a48
1416 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,230 @@
#!/usr/bin/env python3
"""
KIS Bot용 ML 승률 예측 모델
- MariaDB kis_quant_db의 trade_history 데이터로 학습
- 매수 신호의 승률 예측 (0.0 ~ 1.0)
- 주간 단위 자동 재학습
"""
import os
import pickle
import logging
from pathlib import Path
from datetime import datetime, timedelta
import numpy as np
import pandas as pd
# Logger 설정
logger = logging.getLogger("KIS_MLPredictor")
try:
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, precision_score, recall_score
ML_AVAILABLE = True
except ImportError:
ML_AVAILABLE = False
logger.warning("⚠️ scikit-learn 미설치! ML 기능 사용 불가")
logger.warning(" 설치: pip install scikit-learn")
SCRIPT_DIR = Path(__file__).resolve().parent
class MLPredictor:
"""매수 신호 승률 예측 모델"""
def __init__(
self,
db_path: str = None, # 하위 호환용 (무시됨) — MariaDB 사용
model_path: str = None,
):
# db_path: 하위 호환을 위해 파라미터 유지하나 내부적으로 MariaDB 사용
self.model_path = model_path or str(SCRIPT_DIR / "ml_model.pkl")
self.model = None
self.feature_names = [
"rsi",
"volume_ratio",
"tail_length_pct",
"ma5_gap_pct",
"ma20_gap_pct",
"foreign_net_buy",
"institution_net_buy",
"market_hour",
]
self.min_train_samples = 30
if not ML_AVAILABLE:
logger.error("❌ scikit-learn이 설치되지 않았습니다!")
return
self.load_model()
def extract_features_from_db(self, days: int = 90) -> pd.DataFrame:
"""MariaDB trade_history 에서 학습용 피처 추출.
- profit_rate 로 승/패 라벨 생성
- 진입 시점 피처(rsi, volume_ratio 등)는 매수 시 DB에 저장된 값 사용
- 시간순 정렬 후 반환 (시계열 분리 시 미래 참조 방지)
"""
try:
from database import TradeDB
db = TradeDB()
cutoff_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
feat_cols = ", ".join(f"`{c}`" for c in self.feature_names)
rows = db.conn.execute(
f"SELECT profit_rate, buy_date, sell_date, strategy, {feat_cols} "
f"FROM trade_history WHERE sell_date >= %s ORDER BY sell_date ASC",
(cutoff_date,)
).fetchall()
if not rows:
logger.warning("⚠️ 학습 데이터 없음 (trade_history 조회 결과 0건)")
return None
# DictCursor 반환 → DataFrame 직접 생성
df = pd.DataFrame(rows)
if len(df) < self.min_train_samples:
logger.warning(
f"⚠️ 학습 데이터 부족: {len(df)}건 (최소 {self.min_train_samples}건 필요)"
)
return None
# 진입 피처가 전부 NULL인 과거 데이터 제외
feature_ok = df[self.feature_names].notna().any(axis=1)
df = df.loc[feature_ok].copy()
if len(df) < self.min_train_samples:
logger.warning(
f"⚠️ 진입 피처가 있는 데이터 부족: {len(df)}건 (최소 {self.min_train_samples}건 필요). "
"매수 시 entry_features 저장 후 누적되면 학습 가능합니다."
)
return None
df["is_win"] = (df["profit_rate"] > 0).astype(int)
logger.info(
f"📊 학습 데이터 로드: {len(df)}건 (시간순) "
f"(익절: {df['is_win'].sum()}건, 손절: {(~df['is_win']).sum()}건)"
)
return df
except Exception as e:
logger.error(f"❌ 피처 추출 실패: {e}")
return None
def train_model(self, retrain: bool = False) -> bool:
"""모델 학습"""
if not ML_AVAILABLE:
logger.error("❌ scikit-learn 미설치로 학습 불가")
return False
if self.model is not None and not retrain:
logger.info("✅ 기존 모델 사용")
return True
df = self.extract_features_from_db(days=90)
if df is None or len(df) < self.min_train_samples:
logger.warning("⚠️ 학습 데이터 부족 - ML 모델 사용 불가")
return False
# 실제 DB 진입 피처 사용 (누락값은 0으로 보정)
X = df[self.feature_names].fillna(0).astype(float)
y = df["is_win"].values
# 시계열 분리: 무작위 셔플 금지. 과거 80% = 학습, 최근 20% = 테스트 (미래 참조 방지)
n = len(X)
train_size = int(n * 0.8)
if train_size < 10 or (n - train_size) < 5:
logger.warning("⚠️ 데이터 적어 시계열 분리 불가 (학습/테스트 각 10건·5건 이상 권장)")
train_size = max(10, n - 5)
X_train, X_test = X.iloc[:train_size], X.iloc[train_size:]
y_train, y_test = y[:train_size], y[train_size:]
logger.info(f"📅 시계열 분리: 학습 {len(y_train)}건 (과거) / 테스트 {len(y_test)}건 (최근)")
logger.info("🤖 RandomForest 학습 시작...")
self.model = RandomForestClassifier(
n_estimators=100,
max_depth=10,
min_samples_split=5,
random_state=42,
)
self.model.fit(X_train, y_train)
y_pred = self.model.predict(X_test)
accuracy = accuracy_score(y_test, y_pred)
precision = precision_score(y_test, y_pred, zero_division=0)
recall = recall_score(y_test, y_pred, zero_division=0)
logger.info("✅ 학습 완료!")
logger.info(f" 정확도: {accuracy:.2%}")
logger.info(f" 정밀도: {precision:.2%}")
logger.info(f" 재현율: {recall:.2%}")
feature_importance = sorted(
zip(self.feature_names, self.model.feature_importances_),
key=lambda x: x[1],
reverse=True,
)
logger.info(" 중요 피처:")
for fname, importance in feature_importance[:5]:
logger.info(f" {fname}: {importance:.3f}")
self.save_model()
return True
def predict_win_probability(self, features: dict) -> float:
"""매수 신호의 승률 예측 (0.0 ~ 1.0).
매매 로직에서는 ML_MIN_PROBABILITY(권장 0.65 이상) 미만일 때 진입 보류 권장."""
if not ML_AVAILABLE or self.model is None:
return 0.5
try:
X = pd.DataFrame([features])[self.feature_names]
proba = self.model.predict_proba(X)[0]
win_prob = proba[1]
return float(win_prob)
except Exception as e:
logger.error(f"❌ 예측 실패: {e}")
return 0.5
def save_model(self) -> None:
"""모델 파일로 저장"""
try:
with open(self.model_path, "wb") as f:
pickle.dump(self.model, f)
logger.info(f"💾 모델 저장: {self.model_path}")
except Exception as e:
logger.error(f"❌ 모델 저장 실패: {e}")
def load_model(self) -> bool:
"""저장된 모델 로드"""
if not ML_AVAILABLE:
return False
if os.path.exists(self.model_path):
try:
with open(self.model_path, "rb") as f:
self.model = pickle.load(f)
logger.info(f"✅ 모델 로드: {self.model_path}")
return True
except Exception as e:
logger.error(f"❌ 모델 로드 실패: {e}")
else:
logger.info(" 저장된 모델 없음 - 첫 실행 시 학습 필요")
return False
def should_retrain(self) -> bool:
"""재학습이 필요한지 체크 (7일 경과 시)"""
if not os.path.exists(self.model_path):
return True
model_mtime = datetime.fromtimestamp(os.path.getmtime(self.model_path))
days_old = (datetime.now() - model_mtime).days
if days_old >= 7:
logger.info(f"🔄 모델 {days_old}일 경과 → 재학습 필요")
return True
return False