""" 트레이딩 봇 데이터베이스 관리 모듈 - MariaDB 기반 (pymysql) — SQLite 에서 이전 - 활성 트레이딩 관리 (active_trades) - 매매 히스토리 관리 (trade_history) MariaDB 접속 정보 우선순위: 1) 환경변수 DB_HOST / DB_PORT / DB_USER / DB_PASS / DB_NAME 2) 아래 DEFAULT 상수 (기본값) """ import json import os import datetime import logging import threading from typing import Any, Dict, List, Optional, Tuple try: import pymysql import pymysql.cursors _PYMYSQL_AVAILABLE = True except ImportError: _PYMYSQL_AVAILABLE = False logger = logging.getLogger("TradeDB") # ── MariaDB 기본 접속 정보 (환경변수로 재정의 가능) ───────────────── _DB_HOST = os.environ.get("DB_HOST", "192.168.0.141") _DB_PORT = int(os.environ.get("DB_PORT", "3306")) _DB_USER = os.environ.get("DB_USER", "jae") _DB_PASS = os.environ.get("DB_PASS", "1234") _DB_NAME = os.environ.get("DB_NAME", "kis_quant_db") # ══════════════════════════════════════════════════════════════════════ # SQLite 호환 래퍼 — 기존 conn.execute() / with conn: 패턴 유지 # ══════════════════════════════════════════════════════════════════════ class _MariaDBConn: """ pymysql 연결을 sqlite3 인터페이스처럼 감싸는 래퍼. - conn.execute(sql, params) → DictCursor 반환 (row['col'] 접근) - with conn: ... → 자동커밋(autocommit=True) 이므로 no-op - conn.commit() → 명시적 커밋 (autocommit=True라 호환) - ? 플레이스홀더 → %s 자동 변환 - SQLite-only DDL 키워드 자동 변환 """ # SQL 텍스트 자동 변환 규칙 (SQLite → MySQL) _REPLACE_PAIRS = [ # DDL 키워드 ("INTEGER PRIMARY KEY AUTOINCREMENT", "INT NOT NULL AUTO_INCREMENT PRIMARY KEY"), ("INTEGER PRIMARY KEY", "INT NOT NULL PRIMARY KEY"), ("AUTOINCREMENT", "AUTO_INCREMENT"), # DML — SQLite 전용 구문 ("INSERT OR REPLACE INTO", "REPLACE INTO"), ("last_insert_rowid()", "LAST_INSERT_ID()"), # ON CONFLICT 처리: 단순 패턴 제거 후 ON DUPLICATE KEY UPDATE 로 수동 변환 # (복잡한 케이스는 각 메서드에서 직접 처리) ] def __init__(self): self._lock = threading.Lock() self._conn = None self._connect() def _connect(self): """pymysql 연결 (재연결 포함).""" if not _PYMYSQL_AVAILABLE: raise ImportError( "pymysql 미설치. 설치: pip install pymysql\n" "또는: pip install PyMySQL" ) self._conn = pymysql.connect( host=_DB_HOST, port=_DB_PORT, user=_DB_USER, password=_DB_PASS, database=_DB_NAME, charset="utf8mb4", autocommit=True, cursorclass=pymysql.cursors.DictCursor, connect_timeout=10, read_timeout=30, write_timeout=30, ) logger.debug("✅ MariaDB 연결 완료 (%s:%s/%s)", _DB_HOST, _DB_PORT, _DB_NAME) def _ensure_connected(self): """연결 끊김 시 자동 재접속.""" try: self._conn.ping(reconnect=True) except Exception: try: self._connect() except Exception as e: logger.error("❌ MariaDB 재접속 실패: %s", e) raise @staticmethod def _translate_sql(sql: str) -> str: """SQLite 전용 SQL 구문을 MySQL 호환으로 변환.""" for old, new in _MariaDBConn._REPLACE_PAIRS: sql = sql.replace(old, new) # ? → %s (파라미터 플레이스홀더) # 단, 이미 %s 가 있는 경우 중복 변환 방지 if "?" in sql: sql = sql.replace("?", "%s") return sql def execute(self, sql: str, params=None): """ SQL 실행. sqlite3.Connection.execute() 와 동일한 인터페이스. 반환값: DictCursor (fetchone/fetchall/lastrowid 사용 가능) """ sql = self._translate_sql(sql) with self._lock: self._ensure_connected() cur = self._conn.cursor() cur.execute(sql, params or ()) return cur def __enter__(self): """with conn: 패턴 호환 (autocommit=True 이므로 실질적으로 no-op).""" return self def __exit__(self, exc_type, exc_val, exc_tb): pass def commit(self): """명시적 커밋 (autocommit=True 환경에서 호환성 유지용).""" try: with self._lock: self._conn.commit() except Exception: pass @property def row_factory(self): return None # DictCursor 가 이미 dict 반환, 호환용 stub @row_factory.setter def row_factory(self, _): pass # sqlite3 호환용 stub (무시) def close(self): try: self._conn.close() except Exception: pass # ── information_schema 기반 컬럼 목록 조회 (PRAGMA 대체) ────── def get_columns(self, table_name: str) -> list: """ PRAGMA table_info() 대체. 반환: 컬럼명 문자열 리스트 """ try: cur = self.execute( "SELECT COLUMN_NAME FROM information_schema.COLUMNS " "WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s " "ORDER BY ORDINAL_POSITION", (_DB_NAME, table_name), ) return [row["COLUMN_NAME"] for row in cur.fetchall()] except Exception as e: logger.debug("get_columns(%s) 실패: %s", table_name, e) return [] # ML 학습용 진입 시점 피처 컬럼 (active_trades / trade_history 공통) ML_ENTRY_FEATURE_COLUMNS = ( "rsi", "volume_ratio", "tail_length_pct", "ma5_gap_pct", "ma20_gap_pct", "foreign_net_buy", "institution_net_buy", "market_hour", ) # env_config 테이블 컬럼 (키 하나당 컬럼 하나, 추가/삭제 시 여기와 CREATE TABLE만 수정) ENV_CONFIG_KEYS = ( "STOP_LOSS_PCT", "SHOULDER_CUT_PCT", "SHOULDER_MIN_HIGH_PCT", "SHOULDER_MIN_NET_PCT", "STOP_ATR_MULTIPLIER_TAIL", "TARGET_ATR_MULTIPLIER_TAIL", "MAX_POSITION_PCT", "USE_SLOT_CAP", "SLOT_CAP_PCT", "MAX_STOCKS", "USE_KELLY", "RISK_PCT_PER_TRADE", "MIN_POSITION_AMOUNT", "USE_RISK_CHECK", "DAILY_STOP_LOSS_PCT", "CONSECUTIVE_LOSS_LIMIT", "USE_BAN_SYSTEM", "BAN_HOURS", "USE_STOCK_FILTER", "RSI_OVERHEAT_THRESHOLD", "MIN_RECOVERY_RATIO", "MAX_RECOVERY_RATIO", "USE_TWAP", "TWAP_MIN_SPLIT", "TWAP_MAX_SPLIT", "TWAP_MIN_DELAY", "TWAP_MAX_DELAY", "USE_ML_SIGNAL", "ML_MIN_PROBABILITY", "USE_NEWS_ANALYSIS", "NEWS_ANALYSIS_HOUR", "NEWS_MAX_COUNT", "USE_QUICK_PROFIT_PROTECTION", "HIGH_PRICE_CHASE_THRESHOLD", "MAX_DAILY_CHANGE_PCT", "MA20_MAX_ABOVE_PCT", "VOLUME_AVG_MULTIPLIER", "CANDLE_OPEN_PRICE_BUFFER", "MIN_CANDLE_LEN_TAIL", "MIN_PRICE_TAIL", "TAIL_SCORE_BASE", "TAIL_SCORE_RATIO_MULT", "TAIL_RATIO_MIN", "TAIL_PCT_MIN", # 꼬리 비율/하락률 최소 (AI·봇 공통 기준) "INTRADAY_INVESTOR_NET_BUY_THRESHOLD", "SIZE_CLASS_LARGE_MIN", "SIZE_CLASS_MID_MIN", # 단타 스캔 후보 점수 (전부 env/DB) "SCAN_INVESTOR_NET_STRONG", "SCAN_INVESTOR_SCORE_STRONG", "SCAN_INVESTOR_SCORE_WEAK", "SCAN_INVESTOR_BONUS_STRONG", "SCAN_INVESTOR_BONUS_WEAK", "SCAN_VOLUME_BONUS_MIN", "SCAN_VOLUME_BONUS_POINT", "SCAN_EXEC_STRENGTH_HIGH", "SCAN_EXEC_STRENGTH_MID", "SCAN_EXEC_BONUS_HIGH", "SCAN_EXEC_BONUS_MID", "SCAN_SCORE_DROP_WEIGHT", "SCAN_SCORE_RECOVERY_WEIGHT", "MIN_SCORE_DISPLAY", "MM_TOP_N", "SLOT_MONEY_DEFAULT", "SLOT_BASE_AMOUNT_CAP", "SIZE_CLASS_SMALL_RATIO", "SIZE_CLASS_MID_RATIO", # 개미털기/유니버스 필터 추가 키 "SCAN_MIN_PREV_DAY_PCT", "UPDATE_UNIVERSE_MIN_CANDIDATES", "UPDATE_UNIVERSE_MIN_SCORE", "UPDATE_UNIVERSE_FALLBACK_TOP_N", "UPDATE_UNIVERSE_TOP_LOG", "UPDATE_UNIVERSE_TOP_N", "SCAN_INTERVAL_SEC", # 유니버스 스캔 주기(초), kiwoom_universe_scanner 전용 "USE_RANDOM_SPLIT", "FORCE_MARKET_OPEN", "FORCE_BUY_TEST", "TOTAL_DEPOSIT", # POP/LOCK·금액 손절 관련 추가 키 "ROUND_TRIP_COST_PCT", "POP_NET_PCT", "LOCK_NET_PCT", "MAX_LOSS_PER_TRADE_KRW", # 금액손실컷 발동 최소 하락률(%): 이 하락률 미만이면 슬리피지/흔들림으로 간주하고 금액손실컷 미발동 (꼬리잡기) "MIN_DROP_PCT_FOR_LOSS_CUT", # 한투 API 관련 키 추가 (실전/모의 계좌 분리) "KIS_APP_KEY_REAL", "KIS_APP_SECRET_REAL", "KIS_APP_KEY_MOCK", "KIS_APP_SECRET_MOCK", "KIS_ACCOUNT_NO_REAL", "KIS_ACCOUNT_CODE_REAL", # 실전 계좌 (KIS_MOCK=false 시 사용) "KIS_ACCOUNT_NO_MOCK", "KIS_ACCOUNT_CODE_MOCK", # 모의 계좌 (KIS_MOCK=true 시 사용) "KIS_MOCK", # 단타 봇 전용 키 "TAKE_PROFIT_PCT", "MIN_DROP_RATE", "MIN_RECOVERY_RATIO_SHORT", # 단타 매도 로직 (env/DB에서 수치 로드) "SCALP_ATR_UP_MULT", "SCALP_ATR_DOWN_MULT", "SCALP_ATR_DROP_MULT", "QUICK_PROFIT_PROTECT_HOURS", "QUICK_PROFIT_MAX_RATIO", "QUICK_PROFIT_CURRENT_MIN", "MIN_HOLD_EARLY_TAKE_PCT", "MIN_HOLD_HIGH_PCT", "MIN_HOLD_DROP_FROM_HIGH", "POST_HOLD_TAKE_PCT", "POST_HOLD_DROP_FROM_HIGH", # 늘림목 봇 전용 키 "MAX_PER", "MAX_PEG", "MIN_GROWTH_PCT", "DCA_INTERVALS", "DCA_AMOUNTS", # Mattermost 및 AI 리포트 관련 키 "MM_SERVER_URL", "MM_BOT_TOKEN_", "MATTERMOST_CHANNEL", "GEMINI_API_KEY", "AI_JOURNAL_LINES", "ANTHROPIC_API_KEY", "CLAUDE_MODEL_ID", "CLAUDE_MAX_TOKENS", "MM_BUTLER_CHANNEL", # OpenRouter API 연동용 키 "OPENROUTER_API_KEY", "OPENROUTER_MODEL_ID", # 봇별 Mattermost 채널 구분용 키 "KIS_SHORT_MM_CHANNEL", "KIS_LONG_MM_CHANNEL", # 롱 위시리스트/뉴스 리포트 전용 키 "LONG_DAILY_LOOKBACK_DAYS", "LONG_MA_SHORT_DAYS", "LONG_MA_LONG_DAYS", "LONG_REPORT_AM_HOUR", "LONG_REPORT_AM_MIN", "LONG_REPORT_PM_HOUR", "LONG_REPORT_PM_MIN", "LONG_NEWS_ENABLED", "LONG_NEWS_INTERVAL_MIN", "LONG_NEWS_ACTIVE_START_HOUR", "LONG_NEWS_ACTIVE_END_HOUR", "LONG_ANALYSIS_DELAY_MIN_SEC", "LONG_ANALYSIS_DELAY_MAX_SEC", # RSI 기간: 스윙=14(기본), 단타=5, 스캘핑=3 권장 (DB에서 실시간 변경 가능) "RSI_PERIOD", # KIS WebSocket 관련 키 "KIS_WS_URL_REAL", "KIS_WS_URL_MOCK", "KIS_WS_MOCK_ENABLED", # 재진입 쿨다운: 매도 후 같은 종목 재매수를 N초 동안 차단 (반복매매 루프 방지) "REENTRY_COOLDOWN_SEC", # 꼬리잡기/단타 매수 허용 시간대 (HHMM 정수, 930=09:30, 1500=15:00) — 백테스트·실매 공통 "TIME_START", "TIME_END", # 매도 실패 백오프: 영업일 아님·장외 시간 오류 시 N초 동안 재시도 금지 (API 낭비·차단 방지) "SELL_FAILURE_BACKOFF_SEC", # ── 스캘핑봇(kis_scalping_ver1) 전용 키 ────────────────────────────── # RSI 과매도 임계값: 이 값 이하면 "과매도 → 되돌림 가능" 후보로 판단 "SCALP_RSI_OVERSOLD", # RSI 과매수 임계값: 이 값 이상이면 신규 진입 금지 (고점 추격 방지) "SCALP_RSI_OVERBOUGHT", # 스캘핑봇이 사용할 봉 단위 (분): 1 또는 3 권장 "SCALP_CANDLE_TIMEFRAME", # 장 시작 후 몇 분 뒤부터 매매 허용 (장 시작 직후 변동성 회피) "SCALP_MARKET_OPEN_WAIT_MIN", # WS 재접속 후 갭 보정에 사용할 REST 분봉 조회 캔들 수 "SCALP_GAP_FILL_LIMIT", # 확정 봉 기준 최소 거래량: 이 값 미만인 봉은 유동성 부족으로 무시 "SCALP_MIN_VOLUME", # 스캘핑봇 채널 (MM 알림) "KIS_SCALP_MM_CHANNEL", # ws_candles 자동 정리 보존 일수 "SCALP_CANDLE_KEEP_DAYS", # 스캘핑 전용 손절 % (꼬리잡기 STOP_LOSS_PCT와 분리, 기본 1.5%) # 1분봉 초단타: 손절이 넓으면(-4%) 자금이 묶여 회전율 0 → 타이트하게 "SCALP_STOP_LOSS_PCT", # 스캘핑 전용 익절 % (꼬리잡기 TAKE_PROFIT_PCT와 분리, 기본 1.5%) # 1분봉에서 +5% 익절은 도달 불가 → 박리다매 전략으로 1.5%씩 수익 적립 "SCALP_TAKE_PROFIT_PCT", # 스캘핑 낙폭 필터 % (꼬리잡기 MIN_DROP_RATE와 분리, 기본 1.5%) # 3% 기준은 1분봉 소형주에서 너무 엄격 → 1.5%로 완화해 타점 빈도 증가 "SCALP_MIN_DROP_RATE", # 봉부족 감지 시 재갭보정 최소 간격(초): 같은 종목 중복 REST 호출 방지 (기본 30초) "SCALP_GAP_RETRY_SEC", # 스캘핑 전용 재진입 쿨다운(초): 매도 후 같은 종목 N초 동안 재매수 차단 (기본 600=10분) # 백테스트와 동일 파라미터로 맞추려면 DB에 값 저장 후 봇/백테스트 모두 이 값 사용 "SCALP_REQUIRE_REVERSAL_CANDLE", # true=직전 음봉+현재 양봉 필수, false=RSI 과매도만으로 진입 허용 "SCALP_COOLDOWN_SEC", # 트레일링 발동 최소 수익률(%): 고점이 매수가 대비 이 이상 올라야 트레일링 활성화 # 0.5%면 수수료(~0.21%) 뺀 나머지만 이익 → 1.5 이상 권장 "SCALP_TRAIL_TRIGGER_PCT", # 트레일링/본절사수 후 최소 순이익(%): 수수료+세금 위에 이 값만큼 추가 마진 확보 # 0이면 본절(수수료 이후 0원), 0.2면 최소 +0.2% 순이익 보장 "SCALP_MIN_PROFIT_PCT", # 스캘핑 전용 방어로직 (꼬리잡기와 값 분리: HIGH_PRICE_CHASE_THRESHOLD / MIN_PRICE_TAIL 등과 별도) "SCALP_HIGH_PRICE_CHASE_THRESHOLD", # 고점추격 방지: 당일고가 대비 비율 이하면 진입 허용 (0.96 등) "SCALP_MAX_DAILY_CHANGE_PCT", # 급등주 필터: 당일 등락률 이 값 초과 시 진입 금지 (%) "SCALP_MIN_PRICE", # 최소 가격(원): 이 값 미만 종목 진입 금지 "SCALP_MAX_LOSS_PER_TRADE_KRW", # 1회 최대 손실(원): 종목당 손실 상한 "SCALP_MIN_DROP_PCT_FOR_LOSS_CUT", # 금액손실컷 발동 최소 하락률(%): 이 미만은 흔들림으로 미발동 "SCALP_USE_DEFENSE_FILTERS", # true/false: 스캘핑 진입 방어 필터(낙폭/고점추격/급등/최소가격) ON/OFF # ── 단타봇(kis_short_ver2) 전용 키 ─────────────────────────────────── # 켈리 공식 사용 여부 (true=켈리 적용, false=고정 비중) "USE_KELLY_FORMULA", # 켈리 공식 적용 배수 (0.25 = Full Kelly의 25%, 과도한 베팅 방지) "KELLY_MULTIPLIER", # 시장가 IOC 주문 사용 여부 (실전: true=IOC, false=일반 시장가) "USE_MARKET_IOC", # WebSocket 실시간 가격 캐시 유효기간(초): 이 시간 이상 지나면 REST 재조회 "KIS_PRICE_CACHE_TTL_SEC", # WS 재접속 후 갭 보정에 사용할 REST 분봉 조회 캔들 수 (단타봇) "SHORT_GAP_FILL_LIMIT", # 매수 직후 최소 보유 시간(초): 이 기간 내 매도 신호 무시 (API 잔고 반영 지연 대응) "MIN_HOLD_AFTER_BUY_SEC", # 최소 보유 시간(시간): 이 기간 이전에는 손절 외 매도 금지 (꼬리잡기 전략 충분히 대기) "MIN_HOLD_HOURS", # 3개월 최대 회복 비율: 전고점 대비 이 비율 이상 회복한 종목은 추격 매수 제외 "MAX_RECOVERY_RATIO_3M", # 유니버스 상위 N개 후보 경량 체크 (매수 후보 1차 필터) "CANDIDATE_LIST_TOP_N_LIGHT", # 매수 신호 체크 시 사용할 유니버스 최대 종목 수 (과부하 방지) "SCAN_UNIVERSE_MAX_CODES", # 꼬리 캔들 패턴 인식 시 과거 몇 봉까지 확인할지 (lookback) "TAIL_CANDLE_LOOKBACK", # ── 레거시 단일 계좌 키 (KIS_APP_KEY_REAL/MOCK 이전 버전 호환용) ────── "KIS_APP_KEY", "KIS_APP_SECRET", "KIS_ACCOUNT_NO", "KIS_ACCOUNT_CODE", # ── mm_butler 전용 키 ──────────────────────────────────────────────── # Gemini AI 모델 ID (gemini-2.5-flash, gemini-1.5-pro 등) "GEMINI_MODEL_ID", # MM 원격 명령 폴링 주기(초): 너무 짧으면 API 과부하 "MM_BUTLER_POLL_SEC", # AI 소스 텍스트 최대 길이(문자): 초과 시 잘라서 전송 (토큰 비용 관리) "AI_SOURCE_MAX_CHARS", # ── 수수료·거래세 (전략 공통) ────────────────────────────────────────── # FEE_RATE_PCT : 위탁수수료율 (매수/매도 각각, 기본 0.015%) # SELL_TAX_RATE_PCT: 증권거래세율 (매도 시만 부과, 기본 0.18% ← 2025 코스피/코스닥 공통) "FEE_RATE_PCT", "SELL_TAX_RATE_PCT", # ── 키움증권 REST API 키 (60분봉 과거 데이터 수집 전용) ──────────────── # 실전/모의 분리 (KIS_MOCK 값에 따라 자동 선택) # KIWOOM_APP_KEY_REAL : 키움증권 실전 앱키 # KIWOOM_APP_SECRET_REAL: 키움증권 실전 시크릿 # KIWOOM_APP_KEY_MOCK : 키움증권 모의 앱키 (mockapi.kiwoom.com) # KIWOOM_APP_SECRET_MOCK: 키움증권 모의 시크릿 # KIWOOM_APP_KEY / KIWOOM_APP_SECRET: 레거시 (단일 키 호환용) "KIWOOM_APP_KEY_REAL", "KIWOOM_APP_SECRET_REAL", "KIWOOM_APP_KEY_MOCK", "KIWOOM_APP_SECRET_MOCK", "KIWOOM_APP_KEY", "KIWOOM_APP_SECRET", # ── WebSocket 영구 구독 종목 (시장 방향 필터용) ───────────────────── # KOSPI/KOSDAQ 지수 ETF는 매매 후보와 무관하게 항상 구독 유지. # 60분봉 RSI 로 상승장/하락장 체크 → 스캘핑/꼬리잡기 진입 방향 결정. # 기본값: KODEX200(069500), KODEX KOSDAQ150(229200) # 콤마 구분 코드: "069500,229200" "PERMANENT_WS_CODES", # ── 시장 방향 필터 (상승장에서만 롱 진입) ────────────────────────── # USE_MARKET_REGIME_FILTER: true=활성, false=비활성 (기본 false) # MARKET_REGIME_MIN_RSI : ETF 60분봉 RSI 이 값 이상이어야 롱 진입 허용 (기본 48) "USE_MARKET_REGIME_FILTER", "MARKET_REGIME_MIN_RSI", # ── 테마 과열 필터 (테마 전체가 과열이면 신규 진입 억제) ─────────── # USE_THEME_HEAT_FILTER : true=활성 (기본 false) # THEME_HEAT_RSI_MAX : 테마 평균 RSI 이 값 초과면 진입 차단 (기본 72) "USE_THEME_HEAT_FILTER", "THEME_HEAT_RSI_MAX", # ── kis_trader 통합봇 전용 키 (전략 스위치 / 유니버스 / 랭킹) ──────── # 전략 on/off "STRATEGY_SCALP_ENABLED", "STRATEGY_SHORT_ENABLED", "STRATEGY_BREAKOUT_ENABLED", # 중복매매 정책: block(기본)=같은 종목 동시 양봇 차단 / allow=허용 "STRATEGY_SAME_CODE_POLICY", # 유니버스 소스: ranking(기본) | condition "SCALP_UNIVERSE_SOURCE", "SHORT_UNIVERSE_SOURCE", "BREAKOUT_UNIVERSE_SOURCE", # 랭킹 정렬/크기 — sort: volume|trading_value|strength|fluct_up|fluct_down "RANK_SCALP_SORT", "RANK_SCALP_LIMIT", "RANK_SHORT_SORT", "RANK_SHORT_LIMIT", "RANK_BREAKOUT_SORT", "RANK_BREAKOUT_LIMIT", # 랭킹 폴링 주기(초) — 기본 10초 (모의 TPS 5건/s 대비 안전, 시간당 720회) "RANKING_POLL_INTERVAL_SEC", # 조건검색 파라미터 "KIS_HTS_ID", "CONDITION_POLL_INTERVAL_SEC", "CONDITION_SCALP_NAME", "CONDITION_SCALP_SEQ", "CONDITION_SHORT_NAME", "CONDITION_SHORT_SEQ", "CONDITION_BREAKOUT_NAME", "CONDITION_BREAKOUT_SEQ", # 유니버스 히스토리(백테스트용) — 기본 true, 배치 INSERT 로 DB 부담 최소화 "UNIVERSE_HISTORY_SAVE", # WS 갭보정 파라미터 (KIS_FALLBACK 은 기본 false — 모의서버 500 폭탄 회피) "WS_GAP_FILL_OFF_HOURS", "WS_GAP_FILL_LIMIT", "WS_TIMEFRAMES", "WS_GAP_FILL_KIS_FALLBACK", # 종목당 최대 매수금액 하드캡 (손절%가 작으면 포지션 사이즈 폭주 방지) "MAX_BUY_AMOUNT_PER_STOCK", "SCALP_MAX_BUY_AMOUNT", "SHORT_MAX_BUY_AMOUNT", "TAIL_MAX_BUY_AMOUNT", # KIS REST 안정화 파라미터 "KIS_MIN_INTERVAL_SEC", "KIS_REST_MAX_RETRIES", "KIS_REST_BACKOFF_CAP_SEC", "KIS_REST_TIMEOUT_SEC", # ── 시장 급락 서킷브레이커 (KOSPI/KOSDAQ 지수 폭락 시 신규 매수 차단) ── # 거래소 공식 서킷브레이커는 -8% 이지만, 봇 보호용으로 더 빨리(-2%) 반응. # PANIC 모드 시 매수만 차단, 매도는 평소처럼 동작 (포지션 정리 가능). "MARKET_GUARD_ENABLED", # 활성화 토글 (true/false). 백테스트 못하므로 운영 후 켜기 "MARKET_GUARD_5MIN_DROP_PCT", # 5분 내 -N% 하락 시 PANIC 진입 (기본 2.0) "MARKET_GUARD_DAILY_DROP_PCT", # 일중 누적 -N% 하락 시 PANIC 진입 (기본 3.0) "MARKET_GUARD_RECOVERY_PCT", # PANIC 중 5분 +N% 반등 시 자동 해제 (기본 1.0) "MARKET_GUARD_INDEX_CODE", # 감시 지수 ("0001"=KOSPI, "1001"=KOSDAQ, "both"=둘 다) "MARKET_GUARD_POLL_SEC", # 지수 폴링 주기 초 (기본 30) # ── 전략별 후보 하드캡 (WS 구독 41 한도 보호 + 매수 체크 회전율 보장) ─ # cond/ranking 매니저가 폭주해도 본 전략은 상위 N개만 처리. 0=무제한. # 권장: 세 전략 합산 ≤ 30 (영구구독 + 보유 여유 11종목 확보). "SCALP_CAND_LIMIT", "SHORT_CAND_LIMIT", "BREAKOUT_CAND_LIMIT", # ── 전략별 매매 시간대 (HHMM 정수, 930=09:30, 1500=15:00) ────────── # 미설정 시 글로벌 TIME_START/TIME_END → 기본 900~1530. # 예: SCALP는 변동성 큰 9시 직후 회피하고 09:30~14:30만 → SCALP_TIME_START=930, SCALP_TIME_END=1430 "SCALP_TIME_START", "SCALP_TIME_END", "SHORT_TIME_START", "SHORT_TIME_END", "BREAKOUT_TIME_START", "BREAKOUT_TIME_END", # ETN/ETF/스팩/우선주 자동 제외 토글 (기본 true) — 후보 매니저 결과를 한 번 더 거른다. "EXCLUDE_NON_STOCK", # ── SCALP 진입 모드 토글 (reversal | momentum) ─────────────────── # reversal : 기존 RSI 과매도 V자 반전(원본 로직). # momentum : 키움 'scalp' 조건검색(갭상승+신고가 돌파+거래량 폭증)으로 # 걸러진 종목군을 따라붙기. 봇은 모멘텀 살아있음/끝물 아님/ # 양봉 마무리/거래량 평균 초과만 검증. "SCALP_MODE", # SCALP momentum 모드 전용 파라미터 # mom_rsi_min ≤ RSI3 ≤ mom_rsi_max 만 진입 (모멘텀 살아있고 과열은 아님). "SCALP_MOM_RSI_MIN", "SCALP_MOM_RSI_MAX", # 직전 N봉 평균 거래량 × 배수 이상이어야 진입. "SCALP_MOM_VOL_MULT", "SCALP_MOM_VOL_WIN", # momentum 모드 진입 마감 시각(HHMM, 기본 1430). 종가 후폭풍 회피용. "SCALP_MOM_TIME_END_HM", # SCALP 일일 진입 횟수 (momentum은 회전 빠르므로 5 정도 권장) "SCALP_MAX_DAILY", # ── 전략별 동시 보유 종목 한도 (기존 글로벌 MAX_STOCKS 분리) ───────── # 미설정(0/빈값) 시 글로벌 MAX_STOCKS 로 폴백 → 구버전 호환. # 권장: 합계 ≤ MAX_STOCKS (계좌 슬롯 분산), 예: 3+2+2=7. "SCALP_MAX_STOCKS", "SHORT_MAX_STOCKS", "BREAKOUT_MAX_STOCKS", # ── 시세 WS 공급자 토글 (키움 시세 마이그레이션) ───────────────── # 운영(매매 의사결정)에는 항상 KIS WS 만 사용. 키움 WS 는 검증 모드에서만 # 백그라운드 동시 구독 → ws_price_validation 테이블에 가격 비교 기록. # kis_only : 현행 (기본). 키움 WS 미기동. # kis_with_validation : KIS WS 운영 + 키움 WS 검증 동시 (매매 영향 없음) # kiwoom_only : 시세를 키움으로 전환 (검증 통과 후에만 사용) "WS_PROVIDER", # 검증 비교 주기(초) — 너무 짧으면 부하, 너무 길면 표본 부족. 기본 5. "WS_VALIDATION_INTERVAL_SEC", # 차이 경고 임계(%). |diff| 가 이 값 이상이면 WARN 로그. 기본 0.10. "WS_VALIDATION_DIFF_WARN_PCT", ) class TradeDB: """ 트레이딩 봇용 MariaDB 데이터베이스 관리 클래스. 기존 SQLite 인터페이스와 100% 호환 (db_path 인수는 무시됨). ※ 호출자가 매번 ``TradeDB()`` → ``db.close()`` 패턴으로 쓰므로, DDL(`CREATE TABLE IF NOT EXISTS`) 과 INFO 로그는 **프로세스당 1회만** 실행한다. (내부 클래스 변수 ``_tables_created`` 가드) """ _tables_created = False # 프로세스 내 테이블 생성 1회만 _tables_lock = threading.Lock() # 멀티스레드 race 방지 def __init__(self, db_path="quant_bot.db"): """ Args: db_path: 하위 호환용 (무시됨). MariaDB 접속 정보는 환경변수/모듈 상수 사용. """ self.db_path = db_path # 호환용 보존 self.conn = _MariaDBConn() # 테이블 생성은 첫 인스턴스에서만 — 매 호출마다 21개 DDL 폭탄 방지 with TradeDB._tables_lock: if not TradeDB._tables_created: self._create_tables() logger.info( "✅ TradeDB 초기화 완료: MariaDB %s:%s/%s", _DB_HOST, _DB_PORT, _DB_NAME, ) TradeDB._tables_created = True else: # 이후 인스턴스는 조용히 — 같은 정보 매초 찍어 로그 오염 방지 logger.debug( "TradeDB 인스턴스 재생성 (테이블 스킵): %s:%s/%s", _DB_HOST, _DB_PORT, _DB_NAME, ) def _create_tables(self): """DB 테이블 생성 (없을 경우)""" with self.conn: # 1. 활성 트레이딩 테이블 (현재 보유 중이거나 매수 중인 종목) self.conn.execute(""" CREATE TABLE IF NOT EXISTS active_trades ( code VARCHAR(20) NOT NULL, -- 종목코드 name VARCHAR(100) NOT NULL, -- 종목명 strategy VARCHAR(50) NOT NULL DEFAULT 'MANUAL', -- 매매 전략 (SHORT_ANT_SHAKING / SCALP_RSI_REVERSAL 등) PRIMARY KEY (code, strategy), -- 복합 PK: 같은 종목을 서로 다른 봇이 독립 보유 가능 -- [가격 정보] avg_buy_price DOUBLE NOT NULL, -- 평단가 current_price DOUBLE, -- 현재가 (업데이트용) stop_price DOUBLE, -- 손절가 target_price REAL, -- 목표가 max_price REAL, -- 최고가 (트레일링 스탑용) atr_entry REAL, -- 진입 시 ATR 변동성 -- [수량 및 진행 상태 (분할매수용)] target_qty INTEGER NOT NULL, -- 목표 매수 수량 current_qty INTEGER NOT NULL,-- 현재 체결 수량 total_invested REAL, -- 총 투입 금액 (수수료 제외) -- [상태 관리] status TEXT NOT NULL, -- BUYING(매수중), HOLDING(보유중), SELLING(매도중) buy_date TEXT NOT NULL, -- 첫 매수 시작 시간 updated_at TEXT NOT NULL, -- 마지막 업데이트 시간 size_class TEXT -- 대/중/소형 (매수 시점) ) """) # 2. 매매 기록 테이블 (손익 분석 & 켈리 공식용) self.conn.execute(""" CREATE TABLE IF NOT EXISTS trade_history ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, code VARCHAR(20) NOT NULL, name VARCHAR(100) NOT NULL, strategy VARCHAR(50), buy_price DOUBLE NOT NULL, sell_price DOUBLE NOT NULL, qty INT NOT NULL, profit_rate DOUBLE NOT NULL, realized_pnl DOUBLE NOT NULL, hold_minutes INT, buy_date VARCHAR(30), sell_date VARCHAR(30) NOT NULL, sell_reason VARCHAR(200), env_snapshot TEXT, size_class VARCHAR(20) ) CHARACTER SET utf8mb4 """) # 3. 일일 손익 요약 테이블 (대시보드용) self.conn.execute(""" CREATE TABLE IF NOT EXISTS daily_summary ( date VARCHAR(10) NOT NULL PRIMARY KEY, start_asset DOUBLE, end_asset DOUBLE, total_trades INT, win_trades INT, total_pnl DOUBLE, win_rate DOUBLE ) CHARACTER SET utf8mb4 """) # 4. 주문·체결 보강 테이블 (kt00007 / ka10076) self.conn.execute(""" CREATE TABLE IF NOT EXISTS order_execution_history ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, source VARCHAR(20) NOT NULL, ord_no VARCHAR(30), stk_cd VARCHAR(20), stk_nm VARCHAR(100), trde_tp VARCHAR(20), ord_qty VARCHAR(20), ord_uv VARCHAR(20), cntr_qty VARCHAR(20), cntr_uv VARCHAR(20), ord_tm VARCHAR(20), cnfm_tm VARCHAR(20), sell_tp VARCHAR(20), ord_dt VARCHAR(20), raw_json TEXT, fetched_at VARCHAR(30) NOT NULL ) CHARACTER SET utf8mb4 """) # 5. 매수 체결 이력 (일일 한도용 - '산 시점' 날짜 기준 누적) self.conn.execute(""" CREATE TABLE IF NOT EXISTS buy_execution_log ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, code VARCHAR(20) NOT NULL, name VARCHAR(100) NOT NULL, strategy VARCHAR(50) NOT NULL, buy_date VARCHAR(10) NOT NULL, executed_at VARCHAR(30) NOT NULL, amount DOUBLE NOT NULL, qty INT NOT NULL ) CHARACTER SET utf8mb4 """) # 6. 매수 후보군 테이블 (target_universe 대체) self.conn.execute(""" CREATE TABLE IF NOT EXISTS target_candidates ( code VARCHAR(20) NOT NULL PRIMARY KEY, name VARCHAR(100) NOT NULL, score DOUBLE NOT NULL, price DOUBLE NOT NULL, scan_time VARCHAR(30) NOT NULL, updated_at VARCHAR(30) NOT NULL ) CHARACTER SET utf8mb4 """) # 6-2. 매수 후보군 이력 (5분마다 쌓아서 백테스트 시 '실제 그 시각 유니버스' 사용 가능) self.conn.execute(""" CREATE TABLE IF NOT EXISTS target_candidates_history ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, slot_key VARCHAR(12) NOT NULL, scan_time VARCHAR(30) NOT NULL, code VARCHAR(20) NOT NULL, name VARCHAR(100) NOT NULL DEFAULT '', score DOUBLE NOT NULL DEFAULT 0, price DOUBLE NOT NULL DEFAULT 0, market CHAR(1) DEFAULT 'Q', sector VARCHAR(100), theme VARCHAR(100), INDEX idx_slot (slot_key), INDEX idx_scan (scan_time) ) CHARACTER SET utf8mb4 """) # 7. 종목 메타데이터 (테마·섹터·시장구분) — 스캐너가 채움, 조인 분석 용 # market: 'K'=KOSPI, 'Q'=KOSDAQ, 'E'=ETF/기타 # sector: 업종명 (KIS bstp_kor_isnm, 예: '반도체') # theme : 주요 테마 (예: 'AI반도체', '2차전지', '원자력') # - 직접 UPDATE 또는 별도 스크립트로 채움 # theme_rank: 테마 내 대장주/추종주 순위 (1=핵심, 2=연관, 3=주변) self.conn.execute(""" CREATE TABLE IF NOT EXISTS stock_meta ( code VARCHAR(20) NOT NULL PRIMARY KEY, name VARCHAR(100) NOT NULL DEFAULT '', market CHAR(1) NOT NULL DEFAULT 'Q', sector_code VARCHAR(20), sector VARCHAR(100), theme VARCHAR(100), theme_rank TINYINT DEFAULT 3, updated_at VARCHAR(30) NOT NULL ) CHARACTER SET utf8mb4 """) # 8. env 설정 전용 테이블 (관리자용, INSERT만 / 최신 1건 = 현재 설정, 키당 컬럼) cols = ", ".join([f"`{k}` TEXT" for k in ENV_CONFIG_KEYS]) self.conn.execute(f""" CREATE TABLE IF NOT EXISTS env_config ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, created_at VARCHAR(30) NOT NULL, {cols} ) CHARACTER SET utf8mb4 """) # 8. 키-값 저장소 (매터모스트 원격 조종: 마지막 AI 추천문, last_seen 등) self.conn.execute(""" CREATE TABLE IF NOT EXISTS kv_store ( k VARCHAR(100) NOT NULL PRIMARY KEY, v MEDIUMTEXT ) CHARACTER SET utf8mb4 """) # 9. AI 분석 기록 (Butler !클로드분석/!애미분석 시 프롬프트 요약·응답 저장 → 나중에 꺼내보기) self.conn.execute(""" CREATE TABLE IF NOT EXISTS ai_analysis_log ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, created_at VARCHAR(30) NOT NULL, model VARCHAR(50) NOT NULL, context_summary TEXT, response MEDIUMTEXT ) CHARACTER SET utf8mb4 """) # 10. WebSocket 실시간 봉 집계 (백테스트용 — CandleAggregator 배치 INSERT) # - is_confirmed=1 인 확정 봉만 저장 (진행 중 봉은 RAM에만 존재) # - source: 'ws'=WebSocket틱 집계, 'rest'=갭보정 REST 조회 # - UNIQUE(code, timeframe, candle_time) → ON DUPLICATE KEY UPDATE self.conn.execute(""" CREATE TABLE IF NOT EXISTS ws_candles ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, code VARCHAR(20) NOT NULL, timeframe TINYINT NOT NULL, candle_time VARCHAR(12) NOT NULL, `open` DOUBLE NOT NULL, high DOUBLE NOT NULL, low DOUBLE NOT NULL, close DOUBLE NOT NULL, volume BIGINT NOT NULL DEFAULT 0, rsi_2 DOUBLE, rsi_3 DOUBLE, rsi_5 DOUBLE, is_confirmed TINYINT NOT NULL DEFAULT 1, source VARCHAR(10) NOT NULL DEFAULT 'ws', updated_at VARCHAR(30) NOT NULL, UNIQUE KEY uq_candle (code, timeframe, candle_time) ) CHARACTER SET utf8mb4 """) self._migrate_add_columns() self._migrate_env_config_to_columns() logger.info("📊 DB 테이블 생성/확인 완료") def _migrate_add_columns(self): """기존 DB에 누락된 컬럼 추가 (한 번만) — PRAGMA → information_schema 대체""" try: cols = self.conn.get_columns("trade_history") if "env_snapshot" not in cols: self.conn.execute("ALTER TABLE trade_history ADD COLUMN env_snapshot TEXT") logger.info("📌 trade_history.env_snapshot 컬럼 추가") if "size_class" not in cols: self.conn.execute("ALTER TABLE trade_history ADD COLUMN size_class VARCHAR(20)") logger.info("📌 trade_history.size_class 컬럼 추가") except Exception as e: logger.debug(f"migrate trade_history: {e}") # ── active_trades PK 복합키 마이그레이션 (code → code+strategy) ────────── # 두 봇(SHORT/SCALP)이 같은 종목을 독립 보유 가능하도록 PK 확장. # 신규 설치는 DDL에서 처리됨. 기존 테이블은 여기서 한 번만 ALTER. try: cursor = self.conn.execute(""" SELECT COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'active_trades' AND CONSTRAINT_NAME = 'PRIMARY' ORDER BY ORDINAL_POSITION """) pk_cols = [row[0] if isinstance(row, (list, tuple)) else row['COLUMN_NAME'] for row in cursor.fetchall()] if 'strategy' not in pk_cols: logger.info("⚙️ active_trades PK 복합키 마이그레이션 시작 (code → code+strategy)") # NULL strategy → 'MANUAL' 로 채움 (NOT NULL 변경 전 필수) self.conn.execute("UPDATE active_trades SET strategy = 'MANUAL' WHERE strategy IS NULL OR strategy = ''") # strategy 컬럼 NOT NULL DEFAULT 'MANUAL' 로 변경 후 PK 재구성 self.conn.execute("ALTER TABLE active_trades MODIFY COLUMN strategy VARCHAR(50) NOT NULL DEFAULT 'MANUAL'") self.conn.execute("ALTER TABLE active_trades DROP PRIMARY KEY, ADD PRIMARY KEY (code, strategy)") logger.info("✅ active_trades PK 복합키(code, strategy) 변환 완료") except Exception as e: logger.debug("active_trades PK 마이그레이션 스킵(이미 완료 또는 신규): %s", e) try: cols = self.conn.get_columns("active_trades") if "size_class" not in cols: self.conn.execute("ALTER TABLE active_trades ADD COLUMN size_class VARCHAR(20)") logger.info("📌 active_trades.size_class 컬럼 추가") for c in ML_ENTRY_FEATURE_COLUMNS: if c not in cols: self.conn.execute(f"ALTER TABLE active_trades ADD COLUMN `{c}` DOUBLE") logger.info(f"📌 active_trades.{c} 컬럼 추가 (ML 진입 피처)") except Exception as e: logger.debug(f"migrate active_trades: {e}") try: cols = self.conn.get_columns("trade_history") for c in ML_ENTRY_FEATURE_COLUMNS: if c not in cols: self.conn.execute(f"ALTER TABLE trade_history ADD COLUMN `{c}` DOUBLE") logger.info(f"📌 trade_history.{c} 컬럼 추가 (ML 진입 피처)") except Exception as e: logger.debug(f"migrate trade_history ML columns: {e}") try: cols = self.conn.get_columns("env_config") for key in ENV_CONFIG_KEYS: if key not in cols: self.conn.execute(f"ALTER TABLE env_config ADD COLUMN `{key}` TEXT") logger.info(f"📌 env_config.{key} 컬럼 추가") except Exception as e: logger.debug(f"migrate env_config columns: {e}") # ── target_candidates 테마/섹터/시장구분 컬럼 추가 ────────────────── try: cols = self.conn.get_columns("target_candidates") for col, ddl in [ ("market", "CHAR(1) DEFAULT 'Q'"), ("sector", "VARCHAR(100)"), ("theme", "VARCHAR(100)"), ]: if col not in cols: self.conn.execute( f"ALTER TABLE target_candidates ADD COLUMN `{col}` {ddl}" ) logger.info(f"📌 target_candidates.{col} 컬럼 추가") except Exception as e: logger.debug(f"migrate target_candidates theme cols: {e}") # ── target_candidates_history (후보 이력, 백테스트용) ────────────────── try: self.conn.execute(""" CREATE TABLE IF NOT EXISTS target_candidates_history ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, slot_key VARCHAR(12) NOT NULL, scan_time VARCHAR(30) NOT NULL, code VARCHAR(20) NOT NULL, name VARCHAR(100) NOT NULL DEFAULT '', score DOUBLE NOT NULL DEFAULT 0, price DOUBLE NOT NULL DEFAULT 0, market CHAR(1) DEFAULT 'Q', sector VARCHAR(100), theme VARCHAR(100), INDEX idx_slot (slot_key), INDEX idx_scan (scan_time) ) CHARACTER SET utf8mb4 """) logger.info("📌 target_candidates_history 테이블 확인/생성") except Exception as e: logger.warning(f"migrate target_candidates_history 실패(이력 미적재 가능): {e}") # ── ws_price_validation (KIS↔키움 시세 검증, 마이그레이션 단계용) ──── # 5초마다 같은 종목의 KIS WS 가격과 키움 WS 가격을 비교해 한 행 INSERT. # diff_pct = (kiwoom - kis) / kis × 100. # 운영에는 영향 없음 (검증 모드 ON 일 때만 채워짐). 1~2주 누적 후 # 통계 분석 → 본격 마이그레이션 결정 근거. try: self.conn.execute(""" CREATE TABLE IF NOT EXISTS ws_price_validation ( id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, ts DATETIME(3) NOT NULL, code VARCHAR(20) NOT NULL, kis_price DOUBLE, kiwoom_price DOUBLE, diff_pct DOUBLE, kis_age_ms INT, kiwoom_age_ms INT, INDEX idx_ts (ts), INDEX idx_code (code), INDEX idx_diff (diff_pct) ) CHARACTER SET utf8mb4 """) logger.info("📌 ws_price_validation 테이블 확인/생성") except Exception as e: logger.warning(f"migrate ws_price_validation 실패: {e}") def _migrate_env_config_to_columns(self): """env_config가 예전 JSON 컬럼(snapshot_json)이면 컬럼 스키마로 이전""" try: cols = self.conn.get_columns("env_config") if "snapshot_json" not in cols: return rows = self.conn.execute( "SELECT id, created_at, snapshot_json FROM env_config ORDER BY id" ).fetchall() col_defs = ", ".join([f"`{k}` TEXT" for k in ENV_CONFIG_KEYS]) self.conn.execute(f""" CREATE TABLE IF NOT EXISTS env_config_new ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, created_at VARCHAR(30) NOT NULL, {col_defs} ) CHARACTER SET utf8mb4 """) key_list = ", ".join(f"`{k}`" for k in ENV_CONFIG_KEYS) placeholders = ", ".join(["%s"] * (1 + len(ENV_CONFIG_KEYS))) for row in rows: snap = json.loads(row["snapshot_json"]) if row["snapshot_json"] else {} vals = [row["created_at"]] + [snap.get(k) for k in ENV_CONFIG_KEYS] self.conn.execute( f"INSERT INTO env_config_new (created_at, {key_list}) VALUES ({placeholders})", vals, ) self.conn.execute("DROP TABLE env_config") self.conn.execute("ALTER TABLE env_config_new RENAME TO env_config") logger.info("📌 env_config: snapshot_json -> 컬럼 스키마 마이그레이션 완료") except Exception as e: logger.debug(f"migrate env_config: {e}") # ============================================================ # [CRUD] Active Trades (활성 트레이딩 관리) # ============================================================ def upsert_trade(self, trade_data: Dict): """ 신규 매수하거나 정보 업데이트 (평단가, 수량 등) Args: trade_data: 트레이드 정보 딕셔너리 필수: code, name, avg_buy_price, target_qty, current_qty, status 선택: strategy, stop_price, target_price, max_price, atr_entry, total_invested ML 학습용: entry_features (dict) 또는 rsi, volume_ratio 등 개별 키 """ now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") # 기본값 설정 code = trade_data.get('code') if not code: logger.error("종목코드 누락: upsert 실패") return False size_class = trade_data.get('size_class') feats = trade_data.get('entry_features') or {} feat_vals = [] for k in ML_ENTRY_FEATURE_COLUMNS: v = feats.get(k) if k in feats else trade_data.get(k) feat_vals.append(v if isinstance(v, (int, float)) else None) cols = ", ".join(["code", "name", "strategy", "avg_buy_price", "current_price", "stop_price", "target_price", "max_price", "atr_entry", "target_qty", "current_qty", "total_invested", "status", "buy_date", "updated_at", "size_class"] + list(ML_ENTRY_FEATURE_COLUMNS)) placeholders = ", ".join(["%s"] * (16 + len(ML_ENTRY_FEATURE_COLUMNS))) # MySQL: ON DUPLICATE KEY UPDATE (excluded. → VALUES()) updates = ( "avg_buy_price = VALUES(avg_buy_price), current_price = VALUES(current_price), " "stop_price = COALESCE(VALUES(stop_price), stop_price), " "target_price = COALESCE(VALUES(target_price), target_price), " "atr_entry = COALESCE(VALUES(atr_entry), atr_entry), " "current_qty = VALUES(current_qty), total_invested = VALUES(total_invested), " "max_price = GREATEST(max_price, VALUES(max_price)), " "status = VALUES(status), updated_at = VALUES(updated_at), " "size_class = COALESCE(VALUES(size_class), size_class)" ) for c in ML_ENTRY_FEATURE_COLUMNS: updates += f", `{c}` = COALESCE(VALUES(`{c}`), `{c}`)" sql = f""" INSERT INTO active_trades ( {cols} ) VALUES ({placeholders}) ON DUPLICATE KEY UPDATE {updates} """ params = ( code, trade_data.get('name', 'Unknown'), trade_data.get('strategy', 'MANUAL'), trade_data.get('avg_buy_price') or trade_data.get('buy_price', 0), trade_data.get('current_price', 0), trade_data.get('stop_price', 0), trade_data.get('target_price', 0), trade_data.get('max_price', trade_data.get('buy_price', 0)), trade_data.get('atr_at_entry') or trade_data.get('atr_entry', 0), trade_data.get('target_qty', trade_data.get('qty', 0)), trade_data.get('current_qty') or trade_data.get('qty', 0), trade_data.get('total_invested', 0), trade_data.get('status', 'HOLDING'), trade_data.get('buy_date', now), now, size_class, ) + tuple(feat_vals) try: with self.conn: self.conn.execute(sql, params) return True except Exception as e: logger.error(f"❌ upsert_trade 실패 ({code}): {e}") return False def get_active_trades(self, strategy_prefix: Optional[str] = None): """ 활성 트레이딩 목록 조회 (봇 재시작 시 사용) Args: strategy_prefix: None이면 전부, 'LONG'이면 strategy LIKE 'LONG%'만, 'SHORT'면 'SHORT%'만 (늘림목/단타 섞임 방지) Returns: {종목코드: {trade_info}} 형태의 딕셔너리 """ try: if strategy_prefix: cursor = self.conn.execute( "SELECT * FROM active_trades WHERE strategy LIKE %s", (strategy_prefix.strip().upper() + "%",) ) else: cursor = self.conn.execute("SELECT * FROM active_trades") rows = cursor.fetchall() # 기존 JSON 포맷과 호환되도록 딕셔너리 변환 result = {} for row in rows: code = row['code'] result[code] = { 'code': code, 'name': row['name'], 'strategy': row['strategy'], 'buy_price': row['avg_buy_price'], # JSON 호환 'avg_buy_price': row['avg_buy_price'], 'current_price': row['current_price'], 'stop_price': row['stop_price'], 'target_price': row['target_price'], 'max_price': row['max_price'], 'atr_at_entry': row['atr_entry'], 'qty': row['current_qty'], # JSON 호환 'target_qty': row['target_qty'], 'current_qty': row['current_qty'], 'total_invested': row['total_invested'], 'status': row['status'], 'buy_date': row['buy_date'], 'updated_at': row['updated_at'], 'size_class': row['size_class'] if 'size_class' in row.keys() else None, } logger.debug(f"📂 활성 트레이드 로드: {len(result)}개") return result except Exception as e: logger.error(f"❌ get_active_trades 실패: {e}") return {} def get_active_trade(self, code: str) -> Optional[Dict]: """ 활성 트레이딩 단일 종목 조회. 잔고 동기화 시 DB에 저장된 평단가를 폴백용으로 사용할 때 쓴다. """ try: cursor = self.conn.execute( "SELECT * FROM active_trades WHERE code = ?", (code,), ) row = cursor.fetchone() if not row: return None return { "code": row["code"], "name": row["name"], "strategy": row["strategy"], "avg_buy_price": row["avg_buy_price"], "current_price": row["current_price"], "stop_price": row["stop_price"], "target_price": row["target_price"], "max_price": row["max_price"], "atr_entry": row["atr_entry"], "target_qty": row["target_qty"], "current_qty": row["current_qty"], "total_invested": row["total_invested"], "status": row["status"], "buy_date": row["buy_date"], "updated_at": row["updated_at"], } except Exception as e: logger.error(f"❌ get_active_trade 실패 ({code}): {e}") return None def update_current_price(self, code: str, current_price: float): """현재가 업데이트 (매도 판단용)""" now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") try: with self.conn: self.conn.execute( "UPDATE active_trades SET current_price=?, updated_at=? WHERE code=?", (current_price, now, code) ) except Exception as e: logger.error(f"❌ 현재가 업데이트 실패 ({code}): {e}") def update_max_price(self, code: str, new_max_price: float): """최고가 갱신 (트레일링 스탑용)""" now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") try: with self.conn: # 기존 max_price보다 클 때만 업데이트 self.conn.execute( """UPDATE active_trades SET max_price = MAX(max_price, ?), updated_at = ? WHERE code = ?""", (new_max_price, now, code) ) except Exception as e: logger.error(f"❌ 최고가 갱신 실패 ({code}): {e}") def close_trade( self, code: str, sell_price: float, sell_reason: str = "", env_snapshot: str = None, size_class: str = None, strategy: str = None, realized_pnl_override: float = None, ): """ 매도 완료 처리: active_trades 삭제 -> trade_history 이동 (INSERT만, env 스냅샷 포함) Args: code: 종목코드 sell_price: 매도가 sell_reason: 매도 사유 env_snapshot: 매도 시점 env JSON (백테스트/대시보드용) size_class: 대/중/소형 (매수 시점 저장값) strategy: 봇 전략 ID (SHORT_ANT_SHAKING / SCALP_RSI_REVERSAL 등) 지정 시 해당 전략 row만 삭제 (다른 봇의 동일 종목 보호). None이면 code 단독 조회 (단일 봇 운영 환경 호환). """ try: # 1. 활성 트레이드 정보 조회 (strategy 지정 시 정확히 해당 row만 조회) if strategy: cursor = self.conn.execute( "SELECT * FROM active_trades WHERE code=%s AND strategy=%s", (code, strategy), ) else: cursor = self.conn.execute("SELECT * FROM active_trades WHERE code=%s", (code,)) trade = cursor.fetchone() if not trade: logger.warning(f"⚠️ close_trade: {code} 종목이 active_trades에 없음") return False # 2. 손익 계산 buy_price = trade['avg_buy_price'] qty = trade['current_qty'] # realized_pnl_override 가 있으면 수수료·세금 반영 순손익을 외부에서 주입 # 없으면 내부 계산 (수수료 미포함 gross) if realized_pnl_override is not None: realized_pnl = realized_pnl_override else: realized_pnl = (sell_price - buy_price) * qty profit_rate = (realized_pnl / (buy_price * qty) * 100) if buy_price * qty > 0 else 0 # 3. 보유 시간 계산 buy_time = datetime.datetime.strptime(trade['buy_date'], '%Y-%m-%d %H:%M:%S') sell_time = datetime.datetime.now() hold_minutes = int((sell_time - buy_time).total_seconds() / 60) # size_class는 active_trades에 있으면 그대로 사용 if size_class is None and 'size_class' in trade.keys() and trade['size_class']: size_class = trade['size_class'] # 4. trade_history에 저장 (env_snapshot, size_class, ML 진입 피처 포함 INSERT) feat_vals = [trade[c] if c in trade.keys() else None for c in ML_ENTRY_FEATURE_COLUMNS] cols_th = "code, name, strategy, buy_price, sell_price, qty, profit_rate, realized_pnl, hold_minutes, buy_date, sell_date, sell_reason, env_snapshot, size_class" if ML_ENTRY_FEATURE_COLUMNS: cols_th += ", " + ", ".join(ML_ENTRY_FEATURE_COLUMNS) placeholders = ", ".join(["?"] * (14 + len(ML_ENTRY_FEATURE_COLUMNS))) with self.conn: self.conn.execute(f""" INSERT INTO trade_history ( {cols_th} ) VALUES ({placeholders}) """, ( trade['code'], trade['name'], trade['strategy'], buy_price, sell_price, qty, profit_rate, realized_pnl, hold_minutes, trade['buy_date'], sell_time.strftime('%Y-%m-%d %H:%M:%S'), sell_reason, env_snapshot, size_class, ) + tuple(feat_vals)) # 5. active_trades에서 삭제 (strategy 지정 시 해당 봇 row만 삭제) if strategy: self.conn.execute( "DELETE FROM active_trades WHERE code=%s AND strategy=%s", (code, strategy), ) else: self.conn.execute("DELETE FROM active_trades WHERE code=%s", (code,)) logger.info(f"✅ [{trade['name']}] 매매 종료: 수익률 {profit_rate:.2f}% ({realized_pnl:+,.0f}원)") return True except Exception as e: logger.error(f"❌ close_trade 실패 ({code}): {e}") return False def delete_active_trade(self, code: str, strategy: str = None): """ 활성 트레이드 삭제 (긴급 정리용). strategy 지정 시 해당 봇 row만 삭제, None이면 해당 종목 전체 삭제. """ try: with self.conn: if strategy: self.conn.execute( "DELETE FROM active_trades WHERE code=%s AND strategy=%s", (code, strategy), ) else: self.conn.execute("DELETE FROM active_trades WHERE code=%s", (code,)) logger.info(f"🗑️ active_trade 삭제: {code}" + (f" [{strategy}]" if strategy else "")) return True except Exception as e: logger.error(f"❌ 삭제 실패 ({code}): {e}") return False def insert_buy_execution( self, code: str, name: str, strategy: str, amount: float, qty: int, ): """ 매수 체결 이력 저장 (일일 한도용). '하루' = 산 날짜(buy_date) 기준. """ now = datetime.datetime.now() buy_date = now.strftime("%Y-%m-%d") executed_at = now.strftime("%Y-%m-%d %H:%M:%S") try: with self.conn: self.conn.execute(""" INSERT INTO buy_execution_log (code, name, strategy, buy_date, executed_at, amount, qty) VALUES (?, ?, ?, ?, ?, ?, ?) """, (code, name, strategy, buy_date, executed_at, amount, qty)) return True except Exception as e: logger.error(f"❌ insert_buy_execution 실패 ({code}): {e}") return False def get_daily_buy_amount(self, date_str: str, strategy_prefix: str = "LONG") -> Tuple[float, int]: """ 해당 날짜(산 시점 기준)에 strategy_prefix에 해당하는 매수 누적 금액·건수. date_str: YYYY-MM-DD Returns: (누적 금액, 건수) """ try: cursor = self.conn.execute(""" SELECT COALESCE(SUM(amount), 0) AS total_amount, COUNT(*) AS cnt FROM buy_execution_log WHERE buy_date = %s AND strategy LIKE %s """, (date_str, strategy_prefix.strip().upper() + "%")) row = cursor.fetchone() return (float(row["total_amount"]), int(row["cnt"])) except Exception as e: logger.error(f"❌ get_daily_buy_amount 실패: {e}") return (0.0, 0) # ============================================================ # [보강] 주문·체결 이력 (kt00007 / ka10076) # ============================================================ def insert_order_execution( self, source: str, row: dict, ord_dt: str = None, sell_tp: str = None, raw_json: str = None ): """주문·체결 1건 INSERT (보강용, 이력만 쌓음)""" now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") try: self.conn.execute(""" INSERT INTO order_execution_history ( source, ord_no, stk_cd, stk_nm, trde_tp, ord_qty, ord_uv, cntr_qty, cntr_uv, ord_tm, cnfm_tm, sell_tp, ord_dt, raw_json, fetched_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( source, row.get('ord_no') or row.get('orig_ord_no'), row.get('stk_cd', ''), row.get('stk_nm', ''), row.get('trde_tp', ''), str(row.get('ord_qty', '') or row.get('cntr_qty', '')), str(row.get('ord_uv', '') or row.get('ord_pric', '') or row.get('cntr_uv', '')), str(row.get('cntr_qty', '') or row.get('cnfm_qty', '')), str(row.get('cntr_uv', '') or row.get('cntr_pric', '')), row.get('ord_tm', ''), row.get('cnfm_tm', ''), sell_tp or '', ord_dt or '', raw_json, now, )) self.conn.commit() return True except Exception as e: logger.debug(f"insert_order_execution: {e}") return False # ============================================================ # [분석] 켈리 공식 및 통계 계산 # ============================================================ def calculate_half_kelly(self, recent_days: int = 30) -> float: """ 하프 켈리 공식 계산 (과거 매매 기록 기반) Args: recent_days: 최근 N일 데이터만 사용 Returns: 하프 켈리 비율 (0.0 ~ 1.0) 예: 0.15 리턴 -> "예수금의 15%씩 배팅하는 게 최적" """ try: # 최근 N일 데이터 조회 cutoff_date = (datetime.datetime.now() - datetime.timedelta(days=recent_days)).strftime('%Y-%m-%d') cursor = self.conn.execute( "SELECT profit_rate FROM trade_history WHERE sell_date >= ? ORDER BY sell_date DESC", (cutoff_date,) ) rows = cursor.fetchall() if len(rows) < 20: # 최소 20건 이상 필요 logger.warning(f"⚠️ 켈리 공식: 데이터 부족 ({len(rows)}건) -> 기본값 10% 리턴") return 0.10 # 승률 계산 wins = [r['profit_rate'] for r in rows if r['profit_rate'] > 0] losses = [r['profit_rate'] for r in rows if r['profit_rate'] <= 0] total_count = len(rows) win_count = len(wins) win_rate = win_count / total_count loss_rate = 1.0 - win_rate # 손익비 계산 (평균 수익 / 평균 손실) if not wins or not losses: logger.warning("⚠️ 켈리 공식: 승 또는 패만 있음 -> 기본값 10%") return 0.10 avg_win = sum(wins) / len(wins) avg_loss = abs(sum(losses) / len(losses)) if avg_loss == 0: return 0.50 # 손실이 0이면 최대치 odds = avg_win / avg_loss # 켈리 공식: f = (p * b - q) / b # p=승률, b=손익비, q=패율 kelly_fraction = ((win_rate * odds) - loss_rate) / odds # 하프 켈리 (안전성 확보) half_kelly = kelly_fraction * 0.5 # 음수면 0 리턴 (통계적으로 지는 구조) final_kelly = max(0.0, min(half_kelly, 0.5)) # 최대 50%로 제한 logger.info( f"📊 [켈리 분석] 승률:{win_rate*100:.1f}% | 손익비:{odds:.2f} | " f"켈리:{kelly_fraction*100:.1f}% | 하프켈리:{final_kelly*100:.1f}%" ) return final_kelly except Exception as e: logger.error(f"❌ 켈리 계산 실패: {e}") return 0.10 def get_recent_performance(self, days: int = 7) -> Tuple[float, int, int]: """ 최근 N일 성과 조회 Returns: (총손익, 익절횟수, 손절횟수) """ try: cutoff = (datetime.datetime.now() - datetime.timedelta(days=days)).strftime('%Y-%m-%d') cursor = self.conn.execute( "SELECT realized_pnl FROM trade_history WHERE sell_date >= ?", (cutoff,) ) rows = cursor.fetchall() total_pnl = sum([r['realized_pnl'] for r in rows]) wins = len([r for r in rows if r['realized_pnl'] > 0]) losses = len([r for r in rows if r['realized_pnl'] <= 0]) return total_pnl, wins, losses except Exception as e: logger.error(f"❌ 성과 조회 실패: {e}") return 0.0, 0, 0 def get_trade_stats(self) -> Dict: """전체 매매 통계""" try: cursor = self.conn.execute(""" SELECT COUNT(*) as total, SUM(CASE WHEN profit_rate > 0 THEN 1 ELSE 0 END) as wins, AVG(profit_rate) as avg_profit_rate, SUM(realized_pnl) as total_pnl FROM trade_history """) row = cursor.fetchone() return { 'total_trades': row['total'] or 0, 'win_trades': row['wins'] or 0, 'win_rate': (row['wins'] / row['total'] * 100) if row['total'] > 0 else 0, 'avg_profit_rate': row['avg_profit_rate'] or 0, 'total_pnl': row['total_pnl'] or 0 } except Exception as e: logger.error(f"❌ 통계 조회 실패: {e}") return {} # ============================================================ # [유틸] JSON 마이그레이션 # ============================================================ def migrate_from_json(self, json_data: Dict): """ 기존 JSON 포트폴리오를 DB로 마이그레이션 Args: json_data: portfolio.json 내용 (딕셔너리) """ count = 0 for code, info in json_data.items(): trade_data = info.copy() trade_data['code'] = code # 필드 매핑 (JSON -> DB) if 'target_qty' not in trade_data: trade_data['target_qty'] = info.get('qty', 0) if 'current_qty' not in trade_data: trade_data['current_qty'] = info.get('qty', 0) if 'total_invested' not in trade_data: trade_data['total_invested'] = info.get('buy_price', 0) * info.get('qty', 0) if 'status' not in trade_data: trade_data['status'] = 'HOLDING' if self.upsert_trade(trade_data): count += 1 logger.info(f"✅ JSON -> DB 마이그레이션 완료: {count}개 종목") return count # ============================================================ # [CRUD] Target Candidates (매수 후보군 관리) # ============================================================ def update_target_candidates(self, candidates: List[Dict]): """ 매수 후보군 업데이트 (5분마다 호출) Args: candidates: [{ 'code': '005930', 'name': '삼성전자', 'score': 5.2, 'price': 75000, 'market': 'K', # 선택 (없으면 stock_meta에서 조회) 'sector': '반도체', # 선택 'theme': 'AI반도체' # 선택 }, ...] """ try: scan_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") # 5분 슬롯 키 (백테스트 유니버스 이력 조회용): 2026-03-18 15:55:21 → 202603181555 _now = datetime.datetime.now() slot_min = (_now.hour * 60 + _now.minute) // 5 * 5 slot_key = _now.strftime("%Y%m%d") + "%02d%02d" % (slot_min // 60, slot_min % 60) # 이력 테이블 없으면 생성 시도 (마이그레이션 누락/실패 시 자동 보정) try: self.conn.execute(""" CREATE TABLE IF NOT EXISTS target_candidates_history ( id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, slot_key VARCHAR(12) NOT NULL, scan_time VARCHAR(30) NOT NULL, code VARCHAR(20) NOT NULL, name VARCHAR(100) NOT NULL DEFAULT '', score DOUBLE NOT NULL DEFAULT 0, price DOUBLE NOT NULL DEFAULT 0, market CHAR(1) DEFAULT 'Q', sector VARCHAR(100), theme VARCHAR(100), INDEX idx_slot (slot_key), INDEX idx_scan (scan_time) ) CHARACTER SET utf8mb4 """) except Exception as e: logger.debug("target_candidates_history CREATE 확인: %s", e) # stock_meta 캐시: 테마/섹터가 없는 항목을 DB에서 보완 meta_cache: Dict[str, Dict] = {} # 기존 데이터 전체 삭제 (5분마다 새로 갱신) with self.conn: self.conn.execute("DELETE FROM target_candidates") for item in candidates: code = item['code'] # market/sector/theme 없으면 stock_meta에서 조회 market = item.get('market') or "" sector = item.get('sector') or "" theme = item.get('theme') or "" if not (market and sector and theme): if code not in meta_cache: try: r = self.conn.execute( "SELECT market, sector, theme FROM stock_meta WHERE code=%s", (code,) ).fetchone() meta_cache[code] = dict(r) if r else {} except Exception: meta_cache[code] = {} m = meta_cache[code] market = market or m.get("market") or "Q" sector = sector or m.get("sector") or "" theme = theme or m.get("theme") or "" self.conn.execute(""" INSERT INTO target_candidates (code, name, score, price, scan_time, updated_at, market, sector, theme) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( code, item.get('name', ''), item.get('score', 0), item.get('price', 0), scan_time, scan_time, market, sector, theme, )) # [DEPRECATED] 구봇(kiwoom_universe_scanner 등) 의 5분 슬롯 이력 적재는 제거됨. # 신봇(kis_trader/**) 은 RankingManager/ConditionSearchManager 가 # ``insert_condition_universe_snapshot()`` 으로 초단위 event_time 스냅샷을 # 기록한다. 구봇이 우발적으로 실행되더라도 이력 혼선을 일으키지 않도록 # 이 경로에서의 history 적재는 의도적으로 하지 않는다. # (과거 코드: INSERT INTO target_candidates_history ...) logger.info(f"✅ 매수 후보군 DB 저장: {len(candidates)}개 (slot_key={slot_key}, 이력 적재 안함)") return True except Exception as e: logger.error(f"❌ 후보군 저장 실패: {e}") return False def add_target_candidate(self, candidate: Dict): """ 매수 후보군 개별 추가 (통과 즉시 저장용, UPSERT 방식) - 500개 스캔 시 시간이 오래 걸려서 통과하는 즉시 DB에 저장 Args: candidate: { 'code': '005930', 'name': '삼성전자', 'score': 5.2, 'price': 75000, 'market': 'K', 'sector': '반도체', 'theme': 'AI반도체' # 선택 } """ try: scan_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") code = candidate['code'] market = candidate.get('market') or "" sector = candidate.get('sector') or "" theme = candidate.get('theme') or "" # 없으면 stock_meta 에서 보완 if not (market and sector and theme): try: r = self.conn.execute( "SELECT market, sector, theme FROM stock_meta WHERE code=%s", (code,) ).fetchone() if r: m = dict(r) market = market or m.get("market") or "Q" sector = sector or m.get("sector") or "" theme = theme or m.get("theme") or "" except Exception: pass with self.conn: self.conn.execute(""" INSERT INTO target_candidates (code, name, score, price, scan_time, updated_at, market, sector, theme) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE name = VALUES(name), score = VALUES(score), price = VALUES(price), scan_time = VALUES(scan_time), updated_at = VALUES(updated_at), market = VALUES(market), sector = VALUES(sector), theme = VALUES(theme) """, ( code, candidate.get('name', ''), candidate.get('score', 0), candidate.get('price', 0), scan_time, scan_time, market, sector, theme, )) return True except Exception as e: logger.debug(f"후보 개별 저장 실패({candidate.get('code', '')}): {e}") return False def get_target_candidates(self) -> List[Dict]: """ 매수 후보군 조회 (점수 순). stock_meta JOIN으로 테마/섹터 보완 반환. Returns: [{'code': '005930', 'name': '삼성전자', 'score': 5.2, 'price': 75000, 'market': 'K', 'sector': '반도체', 'theme': 'AI반도체'}, ...] """ try: cursor = self.conn.execute(""" SELECT t.code, t.name, t.score, t.price, t.scan_time, COALESCE(t.market, m.market, 'Q') AS market, COALESCE(t.sector, m.sector, '') AS sector, COALESCE(t.theme, m.theme, '') AS theme FROM target_candidates t LEFT JOIN stock_meta m ON m.code = t.code ORDER BY t.score DESC, t.price ASC """) rows = cursor.fetchall() result = [] for row in rows: result.append({ 'code': row['code'], 'name': row['name'], 'score': row['score'], 'price': row['price'], 'scan_time': row['scan_time'], 'market': row.get('market', 'Q'), 'sector': row.get('sector', ''), 'theme': row.get('theme', ''), }) return result except Exception as e: logger.error(f"❌ 후보군 조회 실패: {e}") return [] def get_universe_history_for_backtest( self, start_ymd: str, end_ymd: str ) -> Dict[str, List[str]]: """ target_candidates_history에서 기간 내 5분별 유니버스 조회. 백테스트 시 '실제 그 시각에 봇이 보던 후보'로 검사할 때 사용. Args: start_ymd: 시작일 YYYYMMDD end_ymd: 종료일 YYYYMMDD Returns: slot_key(YYYYMMDDHHMM) → 해당 슬롯 후보 코드 리스트 (점수 순 유지) """ try: start_key = start_ymd + "0000" end_key = end_ymd + "2359" rows = self.conn.execute(""" SELECT slot_key, code FROM target_candidates_history WHERE slot_key >= %s AND slot_key <= %s ORDER BY slot_key, score DESC """, (start_key, end_key)).fetchall() out: Dict[str, List[str]] = {} for r in rows: # 백테스트 엔진 _slot_key()는 항상 str → 조회 키도 str 통일 (DB 드라이버에 따라 int 올 수 있음) sk = str(r["slot_key"]) if sk not in out: out[sk] = [] out[sk].append(r["code"]) return out except Exception as e: logger.debug(f"후보 이력 조회 실패: {e}") return {} # ------------------------------------------------------------------ # stock_meta 테마/섹터 메타데이터 헬퍼 # ------------------------------------------------------------------ def upsert_stock_meta( self, code: str, name: str = "", market: str = "Q", sector_code: str = "", sector: str = "", theme: str = "", theme_rank: int = 3, ) -> bool: """ 종목 메타데이터 저장/갱신 (스캐너·수동 입력 모두 사용). Args: code : 종목코드 (6자리) name : 종목명 market : 'K'=KOSPI, 'Q'=KOSDAQ, 'E'=ETF sector_code: 업종코드 (KIS bstp_cls_code 등) sector : 업종명 (예: '반도체') theme : 테마명 (예: 'AI반도체', '2차전지') theme_rank : 테마 내 순위 (1=핵심주, 2=연관, 3=주변) """ try: now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") with self.conn: self.conn.execute(""" INSERT INTO stock_meta (code, name, market, sector_code, sector, theme, theme_rank, updated_at) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE name = IF(VALUES(name) != '', VALUES(name), name), market = IF(VALUES(market) != '', VALUES(market), market), sector_code = IF(VALUES(sector_code) != '', VALUES(sector_code), sector_code), sector = IF(VALUES(sector) != '', VALUES(sector), sector), theme = IF(VALUES(theme) != '', VALUES(theme), theme), theme_rank = IF(VALUES(theme) != '', VALUES(theme_rank), theme_rank), updated_at = VALUES(updated_at) """, (code, name, market, sector_code, sector, theme, theme_rank, now)) return True except Exception as e: logger.debug("upsert_stock_meta 실패(%s): %s", code, e) return False def get_stock_meta(self, code: str) -> Optional[Dict]: """종목 메타데이터 단건 조회. 없으면 None.""" try: row = self.conn.execute( "SELECT * FROM stock_meta WHERE code = %s", (code,) ).fetchone() return dict(row) if row else None except Exception as e: logger.debug("get_stock_meta 실패(%s): %s", code, e) return None def get_theme_momentum(self, theme: str, tf: int = 60) -> Dict: """ 테마 종목들의 최신 확정 봉(timeframe=tf) RSI 통계. "이 테마가 지금 뜨겁냐 식었냐"를 숫자로 파악. Args: theme: 테마명 (stock_meta.theme) tf : 봉 단위 분 (기본 60분봉) Returns: { 'theme': 'AI반도체', 'tf': 60, 'count': 5, # 데이터 있는 종목 수 'avg_rsi3': 62.1, # 평균 RSI(3) 'max_rsi3': 78.4, # 최고 RSI(3) 'min_rsi3': 44.2, # 최저 RSI(3) 'hot_count': 2, # RSI>70 (과열) 종목 수 'cold_count': 1, # RSI<30 (과매도) 종목 수 } """ try: row = self.conn.execute(""" SELECT COUNT(*) AS cnt, AVG(w.rsi_3) AS avg_rsi, MAX(w.rsi_3) AS max_rsi, MIN(w.rsi_3) AS min_rsi, SUM(w.rsi_3 > 70) AS hot_cnt, SUM(w.rsi_3 < 30) AS cold_cnt FROM ws_candles w INNER JOIN stock_meta m ON m.code = w.code WHERE m.theme = %s AND w.timeframe = %s AND w.is_confirmed = 1 AND w.candle_time = ( SELECT MAX(candle_time) FROM ws_candles WHERE code = w.code AND timeframe = w.timeframe ) """, (theme, tf)).fetchone() if not row or not row['cnt']: return {'theme': theme, 'tf': tf, 'count': 0} return { 'theme': theme, 'tf': tf, 'count': int(row['cnt'] or 0), 'avg_rsi3': round(float(row['avg_rsi'] or 0), 1), 'max_rsi3': round(float(row['max_rsi'] or 0), 1), 'min_rsi3': round(float(row['min_rsi'] or 0), 1), 'hot_count': int(row['hot_cnt'] or 0), 'cold_count':int(row['cold_cnt'] or 0), } except Exception as e: logger.debug("get_theme_momentum 실패(%s, %dM): %s", theme, tf, e) return {'theme': theme, 'tf': tf, 'count': 0} def get_all_theme_momentum(self, tf: int = 60) -> List[Dict]: """ 모든 테마의 최신 모멘텀 요약 (테마 히트맵). 가장 뜨거운 테마부터 정렬 반환. """ try: rows = self.conn.execute(""" SELECT m.theme, COUNT(DISTINCT w.code) AS cnt, AVG(w.rsi_3) AS avg_rsi, SUM(w.rsi_3 > 70) AS hot_cnt, SUM(w.rsi_3 < 30) AS cold_cnt FROM ws_candles w INNER JOIN stock_meta m ON m.code = w.code WHERE m.theme IS NOT NULL AND m.theme != '' AND w.timeframe = %s AND w.is_confirmed = 1 AND w.candle_time = ( SELECT MAX(candle_time) FROM ws_candles WHERE code = w.code AND timeframe = w.timeframe ) GROUP BY m.theme ORDER BY avg_rsi DESC """, (tf,)).fetchall() return [{ 'theme': r['theme'], 'count': int(r['cnt'] or 0), 'avg_rsi3': round(float(r['avg_rsi'] or 0), 1), 'hot_count': int(r['hot_cnt'] or 0), 'cold_count':int(r['cold_cnt'] or 0), } for r in rows] except Exception as e: logger.debug("get_all_theme_momentum 실패: %s", e) return [] def get_market_regime(self, codes: list = None, tf: int = 60) -> Dict: """ 영구 구독 ETF (KODEX200 등)의 최신 60분봉 RSI로 시장 방향 판단. 스캘핑/꼬리잡기 진입 전 "지금 상승장인가?" 필터로 활용. Args: codes: ETF 코드 리스트 (None=env PERMANENT_WS_CODES) tf : 봉 단위 분 (기본 60) Returns: { 'is_bull': True, # avg_rsi > 50 → 상승 국면 'avg_rsi': 58.4, 'details': [{'code': '069500', 'rsi': 61.2}, ...] } """ try: if not codes: raw = self.conn.execute( "SELECT PERMANENT_WS_CODES FROM env_config ORDER BY id DESC LIMIT 1" ).fetchone() perm = str(raw['PERMANENT_WS_CODES'] if raw else "") if raw else "" codes = [c.strip() for c in perm.split(",") if c.strip()] or ["069500", "229200"] rows = self.conn.execute(""" SELECT code, rsi_3 FROM ws_candles WHERE code IN ({}) AND timeframe = %s AND is_confirmed = 1 AND candle_time = ( SELECT MAX(candle_time) FROM ws_candles WHERE code = ws_candles.code AND timeframe = ws_candles.timeframe ) """.format(",".join(["%s"] * len(codes))), (*codes, tf)).fetchall() details = [{'code': r['code'], 'rsi': round(float(r['rsi_3'] or 50), 1)} for r in rows] avg_rsi = sum(d['rsi'] for d in details) / len(details) if details else 50.0 return { 'is_bull': avg_rsi > 50, 'avg_rsi': round(avg_rsi, 1), 'details': details, } except Exception as e: logger.debug("get_market_regime 실패: %s", e) return {'is_bull': True, 'avg_rsi': 50.0, 'details': []} def get_trades_by_date(self, date_str: str) -> List[Dict]: """ 특정 날짜의 매매 기록 조회 Args: date_str: 날짜 (YYYYMMDD 또는 YYYY-MM-DD 모두 허용) Returns: 매매 기록 리스트 """ try: # YYYYMMDD(8자) → YYYY-MM-DD 변환, 이미 YYYY-MM-DD(10자)면 그대로 사용 if len(date_str) == 8 and "-" not in date_str: date_formatted = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:]}" else: date_formatted = date_str[:10] # 'YYYY-MM-DD'만 사용 (시간 부분 제거) cursor = self.conn.execute(""" SELECT * FROM trade_history WHERE DATE(sell_date) = %s ORDER BY sell_date DESC """, (date_formatted,)) rows = cursor.fetchall() result = [] for row in rows: result.append({ 'id': row['id'], 'code': row['code'], 'name': row['name'], 'strategy': row['strategy'], 'buy_price': row['buy_price'], 'sell_price': row['sell_price'], 'qty': row['qty'], 'profit_rate': row['profit_rate'], 'realized_pnl': row['realized_pnl'], 'hold_minutes': row['hold_minutes'], 'buy_date': row['buy_date'], 'sell_date': row['sell_date'], 'sell_reason': row['sell_reason'] }) return result except Exception as e: logger.error(f"❌ 날짜별 조회 실패: {e}") return [] # ============================================================ # [ws_price_validation] KIS↔키움 시세 비교 검증 # ============================================================ def insert_ws_price_validation( self, *, code: str, kis_price: Optional[float], kiwoom_price: Optional[float], kis_age_ms: Optional[int] = None, kiwoom_age_ms: Optional[int] = None, ) -> bool: """단일 비교 결과 1행 INSERT. 둘 다 None 이면 저장 안 함. 한쪽만 있어도 저장(소스별 가용성 분석용). diff_pct 는 둘 다 있을 때만 계산. """ if kis_price is None and kiwoom_price is None: return False diff_pct: Optional[float] = None if kis_price not in (None, 0) and kiwoom_price is not None: try: diff_pct = (float(kiwoom_price) - float(kis_price)) / float(kis_price) * 100.0 except (ValueError, ZeroDivisionError): diff_pct = None try: now = datetime.datetime.now() self.conn.execute( "INSERT INTO ws_price_validation " "(ts, code, kis_price, kiwoom_price, diff_pct, kis_age_ms, kiwoom_age_ms) " "VALUES (%s, %s, %s, %s, %s, %s, %s)", (now, code, kis_price, kiwoom_price, diff_pct, kis_age_ms, kiwoom_age_ms), ) return True except Exception as e: logger.debug("ws_price_validation INSERT 실패: %s", e) return False def get_ws_validation_stats( self, *, hours: int = 24, code: Optional[str] = None, ) -> Dict[str, Any]: """최근 N시간 검증 통계 (운영자용 분석). Returns: { "samples": 1234, "both_present": 1100, # KIS·키움 둘 다 가격 있던 비율 "avg_diff_pct": 0.012, "max_abs_diff_pct": 0.45, "stddev_diff_pct": 0.08, "kis_only": 80, # KIS 만 가격 있던 횟수 (키움 미수신) "kiwoom_only": 30, # 키움 만 가격 있던 횟수 } """ try: where = ["ts >= NOW() - INTERVAL %s HOUR"] args: List[Any] = [hours] if code: where.append("code = %s") args.append(code) wsql = " AND ".join(where) row = self.conn.execute(f""" SELECT COUNT(*) AS samples, SUM(kis_price IS NOT NULL AND kiwoom_price IS NOT NULL) AS both_present, AVG(diff_pct) AS avg_diff_pct, MAX(ABS(diff_pct)) AS max_abs_diff_pct, STDDEV(diff_pct) AS stddev_diff_pct, SUM(kis_price IS NOT NULL AND kiwoom_price IS NULL) AS kis_only, SUM(kis_price IS NULL AND kiwoom_price IS NOT NULL) AS kiwoom_only FROM ws_price_validation WHERE {wsql} """, args).fetchone() return dict(row) if row else {} except Exception as e: logger.debug("ws_validation_stats 조회 실패: %s", e) return {} # ============================================================ # [env_config] 관리자용 env (INSERT만, 최신 1건 = 살아있는 값) # ============================================================ def insert_env_snapshot(self, snapshot) -> Optional[int]: """ env 설정 INSERT (UPDATE 없음). 관리자가 설정 변경할 때 호출. snapshot: dict 또는 JSON 문자열. 키는 ENV_CONFIG_KEYS에 있는 것만 저장. Returns: 새 행 id, 실패 시 None """ try: if isinstance(snapshot, str): snapshot = json.loads(snapshot) if snapshot else {} if not isinstance(snapshot, dict): return None now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") key_list = ", ".join(f"`{k}`" for k in ENV_CONFIG_KEYS) placeholders = ", ".join(["%s"] * (1 + len(ENV_CONFIG_KEYS))) vals = [now] + [snapshot.get(k) for k in ENV_CONFIG_KEYS] cur = self.conn.execute( f"INSERT INTO env_config (created_at, {key_list}) VALUES ({placeholders})", vals, ) return cur.lastrowid except Exception as e: logger.error(f"❌ env_config INSERT 실패: {e}") return None def get_latest_env(self) -> Optional[Dict]: """ 살아있는 최신 env 1건 조회 (ORDER BY id DESC LIMIT 1). Returns: {"id": int, "created_at": str, "snapshot": dict} 또는 없으면 None """ try: row = self.conn.execute( "SELECT * FROM env_config ORDER BY id DESC LIMIT 1" ).fetchone() if not row: return None snapshot = { k: (row[k] if row[k] is not None else "") for k in ENV_CONFIG_KEYS if k in row.keys() } return { "id": row["id"], "created_at": row["created_at"], "snapshot": snapshot, } except Exception as e: logger.error(f"❌ env_config 최신 조회 실패: {e}") return None # ============================================================ # [kv_store] 매터모스트 원격 조종용 키-값 (마지막 AI 추천, last_seen 등) # ============================================================ def get_kv(self, key: str) -> Optional[str]: """kv_store에서 값 조회. 없으면 None.""" try: row = self.conn.execute("SELECT v FROM kv_store WHERE k = ?", (key,)).fetchone() return row["v"] if row else None except Exception as e: logger.debug(f"get_kv 실패: {e}") return None def set_kv(self, key: str, value: str) -> bool: """kv_store에 값 저장 (REPLACE INTO = INSERT OR REPLACE MySQL 버전).""" try: self.conn.execute("REPLACE INTO kv_store (k, v) VALUES (%s, %s)", (key, value)) return True except Exception as e: logger.error(f"❌ set_kv 실패: {e}") return False def set_last_ai_recommendations(self, text: str) -> bool: """마지막 AI 수치 추천문 저장 (!적용 시 사용).""" return self.set_kv("last_ai_recommendations", text) def get_last_ai_recommendations(self) -> Optional[str]: """마지막 AI 수치 추천문 조회. 없으면 None.""" return self.get_kv("last_ai_recommendations") # ------------------------------------------------------------------ # [ai_analysis_log] Butler 분석 기록 (프롬프트 요약·응답 저장/조회) # ------------------------------------------------------------------ def insert_ai_analysis_log(self, model: str, context_summary: str, response: str) -> Optional[int]: """AI 분석 한 건 저장. model=claude|gemini, context_summary=현재상태 요약, response=AI 전체 응답.""" try: now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") cur = self.conn.execute( "INSERT INTO ai_analysis_log (created_at, model, context_summary, response) VALUES (%s, %s, %s, %s)", (now, model, (context_summary or "")[:2000], response or ""), ) return cur.lastrowid except Exception as e: logger.error("insert_ai_analysis_log 실패: %s", e) return None def get_ai_analysis_log_list(self, limit: int = 10) -> list: """최근 N건 목록. 각 항목: id, created_at, model, context_summary, response(앞 400자).""" try: rows = self.conn.execute( """ SELECT id, created_at, model, context_summary, response FROM ai_analysis_log ORDER BY id DESC LIMIT ? """, (limit,), ).fetchall() out = [] for r in rows: resp = (r["response"] or "")[:400] if len(r["response"] or "") > 400: resp += "..." out.append({"id": r["id"], "created_at": r["created_at"], "model": r["model"], "context_summary": r["context_summary"], "response_preview": resp}) return out except Exception as e: logger.error("get_ai_analysis_log_list 실패: %s", e) return [] def get_ai_analysis_log_by_id(self, log_id: int) -> Optional[dict]: """id로 전체 한 건 조회. 없으면 None.""" try: row = self.conn.execute( "SELECT id, created_at, model, context_summary, response FROM ai_analysis_log WHERE id = ?", (log_id,), ).fetchone() if not row: return None return {"id": row["id"], "created_at": row["created_at"], "model": row["model"], "context_summary": row["context_summary"], "response": row["response"]} except Exception as e: logger.error("get_ai_analysis_log_by_id 실패: %s", e) return None # ================================================================== # ws_candles 헬퍼 (kis_scalping_ver1 전용) # ================================================================== def upsert_ws_candle(self, code: str, timeframe: int, candle_time: str, open_: float, high: float, low: float, close: float, volume: int, is_confirmed: int = 0, source: str = "ws", rsi_2: float = None, rsi_3: float = None, rsi_5: float = None): """ 봉 1개를 INSERT OR REPLACE 로 저장. - 진행 중인 봉(is_confirmed=0): 틱마다 close/high/low/volume 갱신 - 확정 봉(is_confirmed=1): RSI 계산 후 최종 저장 """ now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") try: with self.conn: self.conn.execute(""" INSERT INTO ws_candles (code, timeframe, candle_time, open, high, low, close, volume, rsi_2, rsi_3, rsi_5, is_confirmed, source, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(code, timeframe, candle_time) DO UPDATE SET high = MAX(high, excluded.high), low = MIN(low, excluded.low), close = excluded.close, volume = excluded.volume, rsi_2 = COALESCE(excluded.rsi_2, rsi_2), rsi_3 = COALESCE(excluded.rsi_3, rsi_3), rsi_5 = COALESCE(excluded.rsi_5, rsi_5), is_confirmed = excluded.is_confirmed, source = excluded.source, updated_at = excluded.updated_at """, (code, timeframe, candle_time, open_, high, low, close, volume, rsi_2, rsi_3, rsi_5, is_confirmed, source, now_str)) except Exception as e: logger.error("upsert_ws_candle 실패(%s): %s", code, e) def get_ws_candles(self, code: str, timeframe: int, limit: int = 100, confirmed_only: bool = False) -> list: """ 최근 N개 봉 리스트 반환 (오래된 순 → 최신 순). confirmed_only=True 면 확정 봉만 반환 (RSI 계산용). """ try: cond = "AND is_confirmed = 1" if confirmed_only else "" rows = self.conn.execute(f""" SELECT candle_time, open, high, low, close, volume, rsi_2, rsi_3, rsi_5, is_confirmed, source FROM ws_candles WHERE code = ? AND timeframe = ? {cond} ORDER BY candle_time DESC LIMIT ? """, (code, timeframe, limit)).fetchall() # 최신→오래된 순으로 왔으므로 역정렬해서 반환 (오래된→최신) result = [dict(r) for r in rows] result.reverse() return result except Exception as e: logger.error("get_ws_candles 실패(%s): %s", code, e) return [] def get_latest_ws_candle(self, code: str, timeframe: int) -> Optional[dict]: """가장 최신 봉 1개 반환 (진행 중 봉 포함).""" try: row = self.conn.execute(""" SELECT candle_time, open, high, low, close, volume, rsi_2, rsi_3, rsi_5, is_confirmed FROM ws_candles WHERE code = ? AND timeframe = ? ORDER BY candle_time DESC LIMIT 1 """, (code, timeframe)).fetchone() return dict(row) if row else None except Exception as e: logger.error("get_latest_ws_candle 실패(%s): %s", code, e) return None def get_latest_confirmed_ws_candle(self, code: str, timeframe: int) -> Optional[dict]: """가장 최신 확정 봉 1개 반환 (RSI 포함).""" try: row = self.conn.execute(""" SELECT candle_time, open, high, low, close, volume, rsi_2, rsi_3, rsi_5 FROM ws_candles WHERE code = ? AND timeframe = ? AND is_confirmed = 1 ORDER BY candle_time DESC LIMIT 1 """, (code, timeframe)).fetchone() return dict(row) if row else None except Exception as e: logger.error("get_latest_confirmed_ws_candle 실패(%s): %s", code, e) return None def cleanup_old_ws_candles(self, keep_days: int = 3): """오래된 ws_candles 정리 (기본 3일 이상 지난 봉 삭제).""" cutoff = (datetime.datetime.now() - datetime.timedelta(days=keep_days)).strftime("%Y%m%d%H%M") try: with self.conn: self.conn.execute( "DELETE FROM ws_candles WHERE candle_time < ?", (cutoff,) ) except Exception as e: logger.error("cleanup_old_ws_candles 실패: %s", e) def close(self): """DB 연결 종료 (호출자가 매번 close 하므로 로그는 DEBUG 로 강등).""" if self.conn: self.conn.close() logger.debug("🔒 DB 연결 종료")