diff --git a/__pycache__/database.cpython-312.pyc b/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000..2bd5861 Binary files /dev/null and b/__pycache__/database.cpython-312.pyc differ diff --git a/__pycache__/kis_short_ver1.cpython-312.pyc b/__pycache__/kis_short_ver1.cpython-312.pyc new file mode 100644 index 0000000..6a490f7 Binary files /dev/null and b/__pycache__/kis_short_ver1.cpython-312.pyc differ diff --git a/__pycache__/ml_predictor.cpython-312.pyc b/__pycache__/ml_predictor.cpython-312.pyc new file mode 100644 index 0000000..bd19741 Binary files /dev/null and b/__pycache__/ml_predictor.cpython-312.pyc differ diff --git a/__pycache__/risk_manager.cpython-312.pyc b/__pycache__/risk_manager.cpython-312.pyc new file mode 100644 index 0000000..1ef8794 Binary files /dev/null and b/__pycache__/risk_manager.cpython-312.pyc differ diff --git a/check_account_config.py b/check_account_config.py new file mode 100644 index 0000000..960437a --- /dev/null +++ b/check_account_config.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +""" +DB에서 계좌번호 설정 확인 스크립트 +- 현재 최신 env_config에서 계좌번호 관련 필드 조회 +- 모의/실전 구분 및 실제 읽히는 값 확인 +""" +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from database import TradeDB + +def main(): + db = TradeDB(db_path=str(SCRIPT_DIR / "quant_bot.db")) + env_data = db.get_latest_env() + db.close() + + if not env_data or not env_data.get("snapshot"): + print("❌ env_config에 데이터가 없습니다.") + return + + snapshot = env_data["snapshot"] + print(f"📊 최신 env_config (id={env_data['id']}, created_at={env_data['created_at']})\n") + + # 모의 여부 + kis_mock = snapshot.get("KIS_MOCK", "").lower() + is_mock = kis_mock in ("true", "1", "yes") + print(f"🔹 KIS_MOCK = '{kis_mock}' → 모의투자: {is_mock}\n") + + # 실전 계좌 + print("📌 실전 계좌 (KIS_MOCK=false 시 사용):") + acc_no_real = snapshot.get("KIS_ACCOUNT_NO", "").strip() + acc_code_real = snapshot.get("KIS_ACCOUNT_CODE", "").strip() or "01" + print(f" KIS_ACCOUNT_NO = '{acc_no_real}' ({len(acc_no_real)}자리)") + print(f" KIS_ACCOUNT_CODE = '{acc_code_real}' ({len(acc_code_real)}자리)") + if acc_no_real: + digits_no = "".join(c for c in acc_no_real if c.isdigit()) + print(f" → 숫자만 추출: '{digits_no}' ({len(digits_no)}자리)") + if len(digits_no) >= 10: + print(f" → CANO: '{digits_no[:8]}', ACNT_PRDT_CD: '{digits_no[8:10]}' ✅") + elif len(digits_no) == 8: + print(f" → CANO: '{digits_no}', ACNT_PRDT_CD: '{acc_code_real.zfill(2)[:2]}' ✅") + else: + print(f" → ⚠️ 8자리 미만: CANO='{digits_no.zfill(8)[:8]}', ACNT_PRDT_CD='{acc_code_real.zfill(2)[:2]}' (부족한 자리 0으로 채움)") + else: + print(" → ❌ 값이 비어있음!") + + print() + + # 모의 계좌 + print("📌 모의 계좌 (KIS_MOCK=true 시 사용):") + acc_no_mock = snapshot.get("KIS_ACCOUNT_NO_MOCK", "").strip() + acc_code_mock = snapshot.get("KIS_ACCOUNT_CODE_MOCK", "").strip() or "01" + print(f" KIS_ACCOUNT_NO_MOCK = '{acc_no_mock}' ({len(acc_no_mock)}자리)") + print(f" KIS_ACCOUNT_CODE_MOCK = '{acc_code_mock}' ({len(acc_code_mock)}자리)") + if acc_no_mock: + digits_no = "".join(c for c in acc_no_mock if c.isdigit()) + print(f" → 숫자만 추출: '{digits_no}' ({len(digits_no)}자리)") + if len(digits_no) >= 10: + print(f" → CANO: '{digits_no[:8]}', ACNT_PRDT_CD: '{digits_no[8:10]}' ✅") + elif len(digits_no) == 8: + print(f" → CANO: '{digits_no}', ACNT_PRDT_CD: '{acc_code_mock.zfill(2)[:2]}' ✅") + else: + print(f" → ⚠️ 8자리 미만: CANO='{digits_no.zfill(8)[:8]}', ACNT_PRDT_CD='{acc_code_mock.zfill(2)[:2]}' (부족한 자리 0으로 채움)") + else: + print(" → ❌ 값이 비어있음! (fallback: 실전 계좌 사용)") + + print() + + # 실제 사용될 계좌 + print("🎯 실제 사용될 계좌:") + if is_mock: + final_no = acc_no_mock or acc_no_real + final_code = acc_code_mock if acc_no_mock else acc_code_real + print(f" 모의투자 모드 → KIS_ACCOUNT_NO_MOCK='{acc_no_mock}' 또는 KIS_ACCOUNT_NO='{acc_no_real}'") + else: + final_no = acc_no_real + final_code = acc_code_real + print(f" 실전투자 모드 → KIS_ACCOUNT_NO='{acc_no_real}'") + + if final_no: + digits_no = "".join(c for c in final_no if c.isdigit()) + if len(digits_no) >= 10: + cano = digits_no[:8] + acnt = digits_no[8:10] + elif len(digits_no) == 8: + cano = digits_no + acnt = final_code.zfill(2)[:2] + else: + cano = digits_no.zfill(8)[:8] + acnt = final_code.zfill(2)[:2] + + if len(cano) == 8 and len(acnt) == 2: + print(f" ✅ 최종: CANO={cano}, ACNT_PRDT_CD={acnt}") + else: + print(f" ❌ 최종: CANO={cano}({len(cano)}자리), ACNT_PRDT_CD={acnt}({len(acnt)}자리) → OPSQ2000 발생 가능!") + else: + print(" ❌ 계좌번호가 비어있음 → OPSQ2000 발생!") + +if __name__ == "__main__": + main() diff --git a/check_db.py b/check_db.py new file mode 100644 index 0000000..0ddae4d --- /dev/null +++ b/check_db.py @@ -0,0 +1,28 @@ +import sqlite3 +import os + +# 데이터베이스 파일 경로 +db_path = os.path.join(os.path.dirname(__file__), 'quant_bot.db') + +# 연결 생성 +conn = sqlite3.connect(db_path) +cursor = conn.cursor() + +# 테이블 목록 조회 +cursor.execute("SELECT name FROM sqlite_master WHERE type='table';") +tables = cursor.fetchall() + +print("Tables in database:") +for table in tables: + print(f" {table[0]}") + +# 각 테이블의 구조 조회 +for table in tables: + table_name = table[0] + print(f"\nStructure of {table_name}:") + cursor.execute(f"PRAGMA table_info({table_name});") + columns = cursor.fetchall() + for column in columns: + print(f" {column[1]} ({column[2]}) - nullable: {column[3]}, primary key: {column[4]}") + +conn.close() \ No newline at end of file diff --git a/copy_env_row_to_latest.py b/copy_env_row_to_latest.py new file mode 100644 index 0000000..0dab11e --- /dev/null +++ b/copy_env_row_to_latest.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +""" +env_config에서 지정한 id 행을 그대로 복사해 새 행으로 INSERT. +→ 봇은 항상 id 최대(최신) 행만 쓰므로, 4번 내용이 곧 '최신 설정'이 됨. + +사용: python copy_env_row_to_latest.py [소스_id] + 소스_id 기본값: 4 +""" +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from database import TradeDB, ENV_CONFIG_KEYS + +def main(): + source_id = int(sys.argv[1]) if len(sys.argv) > 1 else 4 + db_path = SCRIPT_DIR / "quant_bot.db" + db = TradeDB(db_path=str(db_path)) + + row = db.conn.execute("SELECT * FROM env_config WHERE id = ?", (source_id,)).fetchone() + if not row: + print(f"id={source_id} 행이 없습니다.") + db.close() + return + + # id=4 내용으로 새 스냅샷 생성 (created_at만 현재 시각) + import datetime + snapshot = {k: (row[k] if row[k] is not None else "") for k in ENV_CONFIG_KEYS if k in row.keys()} + env_id = db.insert_env_snapshot(snapshot) + db.close() + + if env_id: + print(f"✅ id={source_id} 내용을 새 행(id={env_id})으로 복사했습니다. 이제 최신 설정입니다.") + else: + print("❌ INSERT 실패") + +if __name__ == "__main__": + main() diff --git a/database.py b/database.py new file mode 100644 index 0000000..14e69a8 --- /dev/null +++ b/database.py @@ -0,0 +1,872 @@ +""" +트레이딩 봇 데이터베이스 관리 모듈 +- SQLite 기반 데이터 무결성 보장 (JSON 대비 재시작 안전성 향상) +- 활성 트레이딩 관리 (active_trades) +- 매매 히스토리 관리 (trade_history) +""" +import json +import sqlite3 +import datetime +import logging +from typing import Dict, List, Optional, Tuple + +logger = logging.getLogger("TradeDB") + +# env_config 테이블 컬럼 (키 하나당 컬럼 하나, 추가/삭제 시 여기와 CREATE TABLE만 수정) +ENV_CONFIG_KEYS = ( + "STOP_LOSS_PCT", "SHOULDER_CUT_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", + "INTRADAY_INVESTOR_NET_BUY_THRESHOLD", "SIZE_CLASS_LARGE_MIN", "SIZE_CLASS_MID_MIN", + "USE_RANDOM_SPLIT", "FORCE_MARKET_OPEN", "TOTAL_DEPOSIT", + # POP/LOCK·금액 손절 관련 추가 키 + "ROUND_TRIP_COST_PCT", "POP_NET_PCT", "LOCK_NET_PCT", "MAX_LOSS_PER_TRADE_KRW", + # 한투 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", + # 늘림목 봇 전용 키 + "MAX_PER", "MAX_PEG", "MIN_GROWTH_PCT", "DCA_INTERVALS", "DCA_AMOUNTS", + # Mattermost 및 AI 리포트 관련 키 + "MM_SERVER_URL", "MM_BOT_TOKEN_", "MATTERMOST_CHANNEL", "GEMINI_API_KEY", + # 봇별 Mattermost 채널 구분용 키 + "KIS_SHORT_MM_CHANNEL", "KIS_LONG_MM_CHANNEL", +) + + +class TradeDB: + """ + 트레이딩 봇용 SQLite 데이터베이스 관리 클래스 + """ + def __init__(self, db_path="quant_bot.db"): + """ + Args: + db_path: SQLite DB 파일 경로 (기본: quant_bot.db) + """ + self.db_path = db_path + self.conn = sqlite3.connect(db_path, check_same_thread=False) + self.conn.row_factory = sqlite3.Row # 딕셔너리처럼 접근 가능 + self._create_tables() + logger.info(f"✅ TradeDB 초기화 완료: {db_path}") + + def _create_tables(self): + """DB 테이블 생성 (없을 경우)""" + with self.conn: + # 1. 활성 트레이딩 테이블 (현재 보유 중이거나 매수 중인 종목) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS active_trades ( + code TEXT PRIMARY KEY, -- 종목코드 + name TEXT NOT NULL, -- 종목명 + strategy TEXT, -- 매매 전략 (TAIL_CATCH_3M 등) + + -- [가격 정보] + avg_buy_price REAL NOT NULL, -- 평단가 + current_price REAL, -- 현재가 (업데이트용) + stop_price REAL, -- 손절가 + 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 INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL, + name TEXT NOT NULL, + strategy TEXT, + buy_price REAL NOT NULL, -- 평단가 + sell_price REAL NOT NULL, -- 매도가 + qty INTEGER NOT NULL, -- 수량 + profit_rate REAL NOT NULL, -- 수익률 (%) + realized_pnl REAL NOT NULL, -- 실현 손익금 (원) + hold_minutes INTEGER, -- 보유 시간 (분) + buy_date TEXT, -- 매수 시작 시간 + sell_date TEXT NOT NULL, -- 매도 완료 시간 + sell_reason TEXT, -- 매도 사유 + env_snapshot TEXT, -- 매도 시점 env (JSON) + size_class TEXT -- 대/중/소형 (변동성 구간) + ) + """) + + # 3. 일일 손익 요약 테이블 (대시보드용) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS daily_summary ( + date TEXT PRIMARY KEY, -- 날짜 (YYYY-MM-DD) + start_asset REAL, -- 시작 자산 + end_asset REAL, -- 종료 자산 + total_trades INTEGER, -- 총 매매 횟수 + win_trades INTEGER, -- 익절 횟수 + total_pnl REAL, -- 총 손익 + win_rate REAL -- 승률 (%) + ) + """) + + # 4. 주문·체결 보강 테이블 (kt00007 / ka10076) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS order_execution_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source TEXT NOT NULL, -- 'kt00007' or 'ka10076' + ord_no TEXT, + stk_cd TEXT, + stk_nm TEXT, + trde_tp TEXT, -- 매매구분 + ord_qty TEXT, + ord_uv TEXT, + cntr_qty TEXT, + cntr_uv TEXT, + ord_tm TEXT, + cnfm_tm TEXT, + sell_tp TEXT, + ord_dt TEXT, + raw_json TEXT, + fetched_at TEXT NOT NULL + ) + """) + + # 5. 매수 후보군 테이블 (target_universe 대체) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS target_candidates ( + code TEXT PRIMARY KEY, -- 종목코드 + name TEXT NOT NULL, -- 종목명 + score REAL NOT NULL, -- 개미털기 점수 (높을수록 좋음) + price REAL NOT NULL, -- 현재가 + scan_time TEXT NOT NULL, -- 스캔 시간 + updated_at TEXT NOT NULL -- 마지막 업데이트 + ) + """) + + # 6. 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 INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + {cols} + ) + """) + + self._migrate_add_columns() + self._migrate_env_config_to_columns() + logger.info("📊 DB 테이블 생성/확인 완료") + + def _migrate_add_columns(self): + """기존 DB에 누락된 컬럼 추가 (한 번만)""" + try: + cur = self.conn.execute("PRAGMA table_info(trade_history)") + cols = [row[1] for row in cur.fetchall()] + 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 TEXT") + logger.info("📌 trade_history.size_class 컬럼 추가") + except Exception as e: + logger.debug(f"migrate trade_history: {e}") + try: + cur = self.conn.execute("PRAGMA table_info(active_trades)") + cols = [row[1] for row in cur.fetchall()] + if "size_class" not in cols: + self.conn.execute("ALTER TABLE active_trades ADD COLUMN size_class TEXT") + logger.info("📌 active_trades.size_class 컬럼 추가") + except Exception as e: + logger.debug(f"migrate active_trades: {e}") + # env_config 테이블에 ENV_CONFIG_KEYS에 정의된 컬럼 누락 시 추가 + try: + cur = self.conn.execute("PRAGMA table_info(env_config)") + cols = [row[1] for row in cur.fetchall()] + 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}") + + def _migrate_env_config_to_columns(self): + """env_config가 예전 JSON 컬럼(snapshot_json)이면 컬럼 스키마로 이전""" + try: + cur = self.conn.execute("PRAGMA table_info(env_config)") + cols = [row[1] for row in cur.fetchall()] + 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 INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + {col_defs} + ) + """) + key_list = ", ".join(f'"{k}"' for k in ENV_CONFIG_KEYS) + placeholders = ", ".join(["?"] * (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") + self.conn.commit() + 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 + """ + 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') + sql = """ + INSERT INTO active_trades ( + 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 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(code) DO UPDATE SET + avg_buy_price = excluded.avg_buy_price, + current_price = excluded.current_price, + stop_price = COALESCE(excluded.stop_price, active_trades.stop_price), + target_price = COALESCE(excluded.target_price, active_trades.target_price), + atr_entry = COALESCE(excluded.atr_entry, active_trades.atr_entry), + current_qty = excluded.current_qty, + total_invested = excluded.total_invested, + max_price = MAX(active_trades.max_price, excluded.max_price), + status = excluded.status, + updated_at = excluded.updated_at, + size_class = COALESCE(excluded.size_class, active_trades.size_class) + """ + 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 + ) + + 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): + """ + 활성 트레이딩 목록 조회 (봇 재시작 시 사용) + + Returns: + {종목코드: {trade_info}} 형태의 딕셔너리 + """ + try: + 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 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, + ): + """ + 매도 완료 처리: active_trades 삭제 -> trade_history 이동 (INSERT만, env 스냅샷 포함) + + Args: + code: 종목코드 + sell_price: 매도가 + sell_reason: 매도 사유 + env_snapshot: 매도 시점 env JSON (백테스트/대시보드용) + size_class: 대/중/소형 (매수 시점 저장값) + """ + try: + # 1. 활성 트레이드 정보 조회 + cursor = self.conn.execute("SELECT * FROM active_trades WHERE code=?", (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'] + profit_rate = ((sell_price - buy_price) / buy_price) * 100 if buy_price > 0 else 0 + realized_pnl = (sell_price - buy_price) * qty + + # 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 포함 INSERT) + with self.conn: + self.conn.execute(""" + INSERT INTO trade_history ( + code, name, strategy, buy_price, sell_price, qty, + profit_rate, realized_pnl, hold_minutes, buy_date, sell_date, sell_reason, + env_snapshot, size_class + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + 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, + )) + + # 5. active_trades에서 삭제 + self.conn.execute("DELETE FROM active_trades WHERE code=?", (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): + """활성 트레이드 삭제 (긴급 정리용)""" + try: + with self.conn: + self.conn.execute("DELETE FROM active_trades WHERE code=?", (code,)) + logger.info(f"🗑️ active_trade 삭제: {code}") + return True + except Exception as e: + logger.error(f"❌ 삭제 실패 ({code}): {e}") + return False + + # ============================================================ + # [보강] 주문·체결 이력 (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}, ...] + """ + try: + scan_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # 기존 데이터 전체 삭제 (5분마다 새로 갱신) + with self.conn: + self.conn.execute("DELETE FROM target_candidates") + + # 새 후보군 삽입 + for item in candidates: + self.conn.execute(""" + INSERT INTO target_candidates (code, name, score, price, scan_time, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + """, ( + item['code'], + item['name'], + item['score'], + item['price'], + scan_time, + scan_time + )) + + logger.info(f"✅ 매수 후보군 DB 저장: {len(candidates)}개") + 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, ...} + """ + try: + scan_time = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + with self.conn: + # UPSERT: 있으면 업데이트, 없으면 삽입 + self.conn.execute(""" + INSERT INTO target_candidates (code, name, score, price, scan_time, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(code) DO UPDATE SET + name = excluded.name, + score = excluded.score, + price = excluded.price, + scan_time = excluded.scan_time, + updated_at = excluded.updated_at + """, ( + candidate['code'], + candidate.get('name', ''), + candidate.get('score', 0), + candidate.get('price', 0), + scan_time, + scan_time + )) + + return True + + except Exception as e: + logger.debug(f"후보 개별 저장 실패({candidate.get('code', '')}): {e}") + return False + + def get_target_candidates(self) -> List[Dict]: + """ + 매수 후보군 조회 (점수 순) + + Returns: + [{'code': '005930', 'name': '삼성전자', 'score': 5.2, 'price': 75000}, ...] + """ + try: + cursor = self.conn.execute(""" + SELECT code, name, score, price, scan_time + FROM target_candidates + ORDER BY score DESC, 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'] + }) + + return result + + except Exception as e: + logger.error(f"❌ 후보군 조회 실패: {e}") + return [] + + def get_trades_by_date(self, date_str: str) -> List[Dict]: + """ + 특정 날짜의 매매 기록 조회 + + Args: + date_str: 날짜 (YYYYMMDD) + + Returns: + 매매 기록 리스트 + """ + try: + # YYYYMMDD -> YYYY-MM-DD 변환 + date_formatted = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:]}" + + cursor = self.conn.execute(""" + SELECT * FROM trade_history + WHERE DATE(sell_date) = ? + 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 [] + + # ============================================================ + # [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(["?"] * (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, + ) + self.conn.commit() + 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 + + def close(self): + """DB 연결 종료""" + if self.conn: + self.conn.close() + logger.info("🔒 DB 연결 종료") diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..4698dcb --- /dev/null +++ b/init_db.py @@ -0,0 +1,78 @@ +""" +KIS 봇용 DB 초기화 스크립트 +- DB 파일 생성 및 테이블 생성 +- 기본 env_config 삽입 (새 스냅샷 행 1개 INSERT) + +실행 시점: 자동 실행 없음. 최초 1회 또는 DB 새로 쓸 때만 수동 실행. + 예: python kis_bot/init_db.py +- 실행할 때마다 env_config에 새 행이 추가됨(INSERT만, 기존 행 수정 안 함). +- 봇은 항상 '가장 최신 행(id 최대)'만 사용함. +- 모의/실전 계좌 둘 다 넣으려면 아래 default_env에 KIS_ACCOUNT_NO(실전), KIS_ACCOUNT_NO_MOCK(모의) 값을 채워두고 실행하면 됨. +""" +import os +import sys +from pathlib import Path + +# 현재 디렉토리를 경로에 추가 +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from database import TradeDB + +def init_database(): + """DB 초기화 및 기본 설정 삽입""" + db_path = SCRIPT_DIR / "quant_bot.db" + + print(f"📊 DB 초기화 시작: {db_path}") + + # DB 초기화 (테이블 생성) + db = TradeDB(db_path=str(db_path)) + + # 기본 env_config 삽입 + default_env = { + # 한투 API 설정 + "KIS_APP_KEY": os.environ.get("KIS_APP_KEY", ""), + "KIS_APP_SECRET": os.environ.get("KIS_APP_SECRET", ""), + "KIS_ACCOUNT_NO": os.environ.get("KIS_ACCOUNT_NO", ""), + "KIS_ACCOUNT_CODE": os.environ.get("KIS_ACCOUNT_CODE", "01"), + "KIS_ACCOUNT_NO_MOCK": os.environ.get("KIS_ACCOUNT_NO_MOCK", ""), + "KIS_ACCOUNT_CODE_MOCK": os.environ.get("KIS_ACCOUNT_CODE_MOCK", "01"), + "KIS_MOCK": os.environ.get("KIS_MOCK", "true"), + + # 단타 봇 설정 + "STOP_LOSS_PCT": "-0.03", + "TAKE_PROFIT_PCT": "0.05", + "MIN_DROP_RATE": "0.03", + "MIN_RECOVERY_RATIO_SHORT": "0.5", + "MAX_STOCKS": "3", + + # 늘림목 봇 설정 + "MAX_PER": "25", + "MAX_PEG": "1.5", + "MIN_GROWTH_PCT": "10", + "MAX_POSITION_PCT": "0.20", + "STOP_LOSS_PCT": "-0.30", + "TAKE_PROFIT_PCT": "0.50", + + # 공통 설정 + "USE_RISK_CHECK": "true", + "DAILY_STOP_LOSS_PCT": "-0.05", + "CONSECUTIVE_LOSS_LIMIT": "4", + "MAX_LOSS_PER_TRADE_KRW": "200000", + "USE_BAN_SYSTEM": "true", + "BAN_HOURS": "24", + "USE_STOCK_FILTER": "true", + "FORCE_MARKET_OPEN": "false", + } + + env_id = db.insert_env_snapshot(default_env) + if env_id: + print(f"✅ 기본 env_config 삽입 완료 (id: {env_id})") + else: + print("⚠️ env_config 삽입 실패") + + print(f"✅ DB 초기화 완료: {db_path}") + db.close() + +if __name__ == "__main__": + init_database() diff --git a/kis_long_ver1.py b/kis_long_ver1.py new file mode 100644 index 0000000..24e2b7f --- /dev/null +++ b/kis_long_ver1.py @@ -0,0 +1,1668 @@ +""" +KIS Long Trading Bot Ver1 - 늘림목 전략용 한투 API 트레이딩 시스템 +- 한국투자증권(KIS) Open API 사용 +- 장기 보유 + 분할 매수(늘림목) 전략 +- PER/PEG 기반 밸류에이션 +- kis_long_term_checker.py의 장기 전략을 참고하되, 늘림목 전략으로 수정 +""" + +import os +import json +import time +import random +import logging +import datetime +import hashlib +import hmac +import base64 +import warnings +import asyncio +from datetime import datetime as dt, timedelta +from pathlib import Path +from typing import List, Dict, Optional + +import pandas as pd +import requests + +# DB 모듈 임포트 +from database import TradeDB + +# 로깅 설정 (먼저 초기화) +logging.basicConfig( + format='[%(asctime)s] %(message)s', + datefmt='%H:%M:%S', + level=logging.INFO +) +logger = logging.getLogger("KISLongBot") + +# Gemini API (AI 리포트용) +warnings.filterwarnings("ignore", message=".*google.generativeai.*") +try: + import google.generativeai as genai + GEMINI_AVAILABLE = True +except ImportError: + GEMINI_AVAILABLE = False + logger.warning("⚠️ google-generativeai 미설치 - AI 리포트 기능 사용 불가") + +# ML 예측 (선택적) +try: + from ml_predictor import MLPredictor + ML_AVAILABLE = True +except ImportError: + ML_AVAILABLE = False + logger.warning("⚠️ ml_predictor 미설치 - ML 예측 기능 사용 불가") + +# DB 초기화 +SCRIPT_DIR = Path(__file__).resolve().parent +db = TradeDB(db_path=str(SCRIPT_DIR / "quant_bot.db")) + +# DB에서 환경변수 로드 (초기 버전: Mattermost/Gemini 설정용) +def get_env_from_db(key, default=""): + """DB에서 환경변수 읽기 (초기화 단계)""" + env_data = db.get_latest_env() + if env_data and env_data.get("snapshot"): + return env_data["snapshot"].get(key, default) + return default + +# Mattermost 설정 +MM_SERVER_URL = get_env_from_db("MM_SERVER_URL", "https://mattermost.hoonfam.org") +MM_BOT_TOKEN = get_env_from_db("MM_BOT_TOKEN_", "").strip() +MM_CONFIG_FILE = SCRIPT_DIR / "mm_config.json" +# 기본 채널(alias) + 롱/늘림목 봇 전용 채널(alias) +MM_CHANNEL_DEFAULT = get_env_from_db("MATTERMOST_CHANNEL", "stock") +MM_CHANNEL_LONG = get_env_from_db("KIS_LONG_MM_CHANNEL", MM_CHANNEL_DEFAULT) + +# Gemini API 설정 (기존 google.generativeai 그대로 유지; 추후 google.genai로 마이그레이션 가능) +GEMINI_API_KEY = get_env_from_db("GEMINI_API_KEY", "").strip() +if GEMINI_API_KEY and 'GEMINI_AVAILABLE' in globals() and GEMINI_AVAILABLE: + try: + genai.configure(api_key=GEMINI_API_KEY) + gemini_model = genai.GenerativeModel('gemini-2.5-flash') + except Exception as e: + logger.warning(f"⚠️ Gemini 초기화 실패: {e}") + gemini_model = None +else: + gemini_model = None + +def get_env_float(key, default): + """환경변수를 float로 변환 (DB 우선)""" + value = get_env_from_db(key, str(default)) + if isinstance(value, str) and "#" in value: + value = value.split("#")[0].strip() + try: + return float(value) if value else default + except (ValueError, TypeError): + return default + +def get_env_int(key, default): + """환경변수를 int로 변환 (DB 우선)""" + value = get_env_from_db(key, str(default)) + if isinstance(value, str) and "#" in value: + value = value.split("#")[0].strip() + try: + return int(value) if value else default + except (ValueError, TypeError): + return default + +def get_env_bool(key, default=False): + """환경변수를 bool로 변환 (DB 우선)""" + value = get_env_from_db(key, str(default)).lower() + return value in ("true", "1", "yes") + +# ============================================================ +# 한투(KIS) API 클라이언트 (kis_long_term_checker.py 참고) +# ============================================================ +KIS_TOKEN_CACHE_PATH = SCRIPT_DIR / ".kis_token_cache.json" +KIS_TOKEN_EXPIRE_MARGIN_SEC = 300 + + +def _load_kis_token_cache(mock): + """캐시 파일에서 토큰 로드""" + if not KIS_TOKEN_CACHE_PATH.exists(): + return None + try: + with open(KIS_TOKEN_CACHE_PATH, "r", encoding="utf-8") as f: + cache = json.load(f) + if cache.get("mock") != mock: + return None + token = cache.get("access_token") + expired_str = cache.get("access_token_token_expired") or cache.get("expires_at") + if not token or not expired_str: + return None + try: + expired_dt = dt.strptime(expired_str.strip()[:19], "%Y-%m-%d %H:%M:%S") + except ValueError: + return None + if dt.now() >= expired_dt - timedelta(seconds=KIS_TOKEN_EXPIRE_MARGIN_SEC): + return None + return token + except Exception: + return None + + +def _save_kis_token_cache(access_token, access_token_token_expired, mock): + """토큰 캐시 저장""" + try: + with open(KIS_TOKEN_CACHE_PATH, "w", encoding="utf-8") as f: + json.dump({ + "access_token": access_token, + "access_token_token_expired": access_token_token_expired, + "mock": mock, + }, f, ensure_ascii=False, indent=0) + except Exception: + pass + + +class KISClientWithOrder: + """주문 기능이 추가된 KIS 클라이언트""" + def __init__(self, mock=None): + # DB에서 환경변수 읽기 + self.app_key = get_env_from_db("KIS_APP_KEY", "").strip() + self.app_secret = get_env_from_db("KIS_APP_SECRET", "").strip() + # 모의 여부 결정 + if mock is not None: + use_mock = mock + else: + use_mock = get_env_bool("KIS_MOCK", True) + + # 계좌번호: 모의/실전 분리 (사용자가 8자리로 직접 입력) + if use_mock: + raw_no_mock = get_env_from_db("KIS_ACCOUNT_NO_MOCK", "").strip() + raw_code_mock = get_env_from_db("KIS_ACCOUNT_CODE_MOCK", "").strip() + if raw_no_mock: + raw_no = raw_no_mock + if raw_code_mock: + raw_code = raw_code_mock + else: + raw_code = "01" + else: + raw_no = get_env_from_db("KIS_ACCOUNT_NO", "").strip() + raw_code = get_env_from_db("KIS_ACCOUNT_CODE", "01").strip() + if not raw_code: + raw_code = "01" + else: + raw_no = get_env_from_db("KIS_ACCOUNT_NO", "").strip() + raw_code = get_env_from_db("KIS_ACCOUNT_CODE", "01").strip() + if not raw_code: + raw_code = "01" + + # DB 값 그대로 사용 (10자리면 앞8/뒤2만 분리) + if len(raw_no) >= 10: + self.acc_no = raw_no[:8] + self.acc_code = raw_no[8:10] + else: + self.acc_no = raw_no + if len(raw_code) >= 2: + self.acc_code = raw_code[:2] + else: + self.acc_code = "01" + if len(self.acc_no) != 8: + logger.warning("⚠️ 계좌번호 CANO 8자리 아님: '%s'(%s자리). DB 확인.", self.acc_no, len(self.acc_no)) + + if len(self.acc_no) != 8 or len(self.acc_code) != 2: + logger.error( + "❌ 계좌번호 형식 오류: CANO=%s(%s자리), ACNT_PRDT_CD=%s(%s자리) → OPSQ2000 발생. " + "모의면 KIS_ACCOUNT_NO_MOCK/KIS_ACCOUNT_CODE_MOCK, 실전이면 KIS_ACCOUNT_NO/KIS_ACCOUNT_CODE 확인.", + self.acc_no, len(self.acc_no), self.acc_code, len(self.acc_code) + ) + else: + logger.info("✅ 한투 계좌 CANO=%s, ACNT_PRDT_CD=%s (모의=%s)", self.acc_no, self.acc_code, use_mock) + self.mock = use_mock + # 모의(True)=모의 URL, 실전(False)=실전 URL (self.mock 사용, 인자 mock은 None일 수 있음) + if self.mock is True: + self.base_url = "https://openapivts.koreainvestment.com:29443" + else: + self.base_url = "https://openapi.koreainvestment.com:9443" + self.access_token = None + self._auth() + + def _auth(self): + """접근 토큰 발급""" + if not self.app_key or not self.app_secret: + raise ValueError("KIS_APP_KEY, KIS_APP_SECRET 설정 필요") + + cached = _load_kis_token_cache(self.mock) + if cached: + self.access_token = cached + logger.info("한투 토큰 캐시 사용 (%s)", "모의" if self.mock else "실전") + return + + url = f"{self.base_url}/oauth2/tokenP" + body = {"grant_type": "client_credentials", "appkey": self.app_key, "appsecret": self.app_secret} + try: + r = requests.post(url, json=body, timeout=10) + data = r.json() + if "access_token" in data: + self.access_token = data["access_token"] + expired = data.get("access_token_token_expired") or "" + _save_kis_token_cache(self.access_token, expired, self.mock) + logger.info("한투 토큰 발급 완료 (%s)", "모의" if self.mock else "실전") + else: + raise RuntimeError("한투 토큰 발급 실패") + except Exception as e: + logger.error("한투 인증 예외: %s", e) + raise + + def _get_hashkey(self, body): + """해시키(Hashkey) 발급 - POST 요청 시 body 무결성 검증용""" + try: + url = f"{self.base_url}/uapi/hashkey" + headers = { + "content-type": "application/json", + "appkey": self.app_key, + "appsecret": self.app_secret, + } + r = requests.post(url, headers=headers, json=body, timeout=5) + if r.status_code == 200: + data = r.json() + if data.get("rt_cd") == "0": + return data.get("HASH") + return None + except Exception as e: + logger.debug(f"해시키 발급 실패: {e}") + return None + + def _headers(self, tr_id, hashkey=None): + """API 호출용 헤더""" + headers = { + "content-type": "application/json; charset=utf-8", + "authorization": f"Bearer {self.access_token}", + "appkey": self.app_key, + "appsecret": self.app_secret, + "tr_id": tr_id, + } + if hashkey: + headers["hashkey"] = hashkey + return headers + + def _get(self, path, tr_id, params, max_retries=3): + """GET 요청. 429 시 지수 백오프 재시도""" + url = f"{self.base_url}{path}" + for attempt in range(max_retries): + try: + r = requests.get(url, headers=self._headers(tr_id), params=params, timeout=15) + if r.status_code == 429: + wait_time = (2 ** attempt) + random.uniform(0.5, 1.5) + logger.warning(f"⏳ API 호출 제한 (429) -> {wait_time:.1f}초 대기 후 재시도 ({attempt+1}/{max_retries})") + time.sleep(wait_time) + continue + if r.status_code == 200: + j = r.json() + if j.get("rt_cd") == "0": + return r + elif "초과" in str(j.get("msg1", "")) or "과부하" in str(j.get("msg1", "")): + wait_time = (2 ** attempt) + random.uniform(0.5, 1.5) + logger.warning(f"⏳ API 과부하 -> {wait_time:.1f}초 대기 후 재시도") + time.sleep(wait_time) + continue + return r + except requests.exceptions.RequestException as e: + if attempt < max_retries - 1: + wait_time = (2 ** attempt) + random.uniform(0.5, 1.5) + logger.warning(f"⚠️ 네트워크 에러 -> {wait_time:.1f}초 대기 후 재시도: {e}") + time.sleep(wait_time) + else: + logger.error(f"❌ GET 요청 실패 ({path}): {e}") + return r + + def inquire_price(self, stock_code): + """주식 현재가 시세 조회 [v1_국내주식-008]""" + r = self._get( + "/uapi/domestic-stock/v1/quotations/inquire-price", + "FHKST01010100", + {"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": stock_code}, + ) + if r.status_code != 200: + return None + j = r.json() + if j.get("rt_cd") != "0": + return None + return j.get("output") + + def get_orderbook(self, stock_code): + """호가 조회 [v1_국내주식-009]""" + try: + r = self._get( + "/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn", + "FHKST01010200", + { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": stock_code, + }, + ) + if r.status_code != 200: + return None + j = r.json() + if j.get("rt_cd") != "0": + return None + return j.get("output") + except Exception as e: + logger.error(f"호가 조회 실패({stock_code}): {e}") + return None + + def get_account_evaluation(self): + """계좌 평가 잔고 조회 [v1_국내주식-011]. 모의=VTTC8494R, 실전=TTTC8494R.""" + if self.mock is True: + tr_id = "VTTC8494R" + else: + tr_id = "TTTC8494R" + try: + r = self._get( + "/uapi/domestic-stock/v1/trading/inquire-balance-rlz-pl", + tr_id, + { + "CANO": self.acc_no, + "ACNT_PRDT_CD": self.acc_code, + "AFHR_FLPR_YN": "N", + "OFL_YN": "", + "INQR_DVSN": "01", + "UNPR_DVSN": "01", + "FUND_STTL_ICLD_YN": "N", + "FNCG_AMT_AUTO_RDPT_YN": "N", + "PRCS_DVSN": "01", + "CTX_AREA_FK100": "", + "CTX_AREA_NK100": "", + }, + ) + if r.status_code != 200: + return None + j = r.json() + if j.get("rt_cd") != "0": + return None + return j + except Exception as e: + logger.error(f"계좌 평가 조회 실패: {e}") + return None + + def inquire_daily_itemchartprice(self, stock_code, start_ymd, end_ymd, period="D", adj="1"): + """국내주식 기간별 시세(일봉)""" + r = self._get( + "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice", + "FHKST03010100", + { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": stock_code, + "FID_INPUT_DATE_1": start_ymd, + "FID_INPUT_DATE_2": end_ymd, + "FID_PERIOD_DIV_CODE": period, + "FID_ORG_ADJ_PRC": adj, + }, + ) + if r.status_code != 200: + return [], [] + j = r.json() + if j.get("rt_cd") != "0": + return [], [] + out1 = j.get("output1") or {} + out2 = j.get("output2") or [] + return out1, out2 + + def finance_financial_ratio(self, stock_code, fid_div_cls_code="0"): + """국내주식 재무비율""" + try: + r = self._get( + "/uapi/domestic-stock/v1/finance/financial-ratio", + "FHKST66430300", + { + "FID_DIV_CLS_CODE": fid_div_cls_code, + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": stock_code, + }, + ) + if r.status_code != 200: + return None + j = r.json() + if j.get("rt_cd") != "0": + return None + out = j.get("output") + if isinstance(out, list) and out: + return out[0] + return out if isinstance(out, dict) else None + except Exception: + return None + + def finance_growth_ratio(self, stock_code, fid_div_cls_code="0"): + """국내주식 성장성비율""" + try: + r = self._get( + "/uapi/domestic-stock/v1/finance/growth-ratio", + "FHKST66430800", + { + "FID_INPUT_ISCD": stock_code, + "FID_DIV_CLS_CODE": fid_div_cls_code, + "FID_COND_MRKT_DIV_CODE": "J", + }, + ) + if r.status_code != 200: + return None + j = r.json() + if j.get("rt_cd") != "0": + return None + out = j.get("output") + if isinstance(out, list) and out: + return out[0] + return out if isinstance(out, dict) else None + except Exception: + return None + + def _post(self, path, tr_id, body, use_hashkey=True, max_retries=3): + """POST 요청. 해시키 사용 및 429 시 지수 백오프 재시도""" + url = f"{self.base_url}{path}" + hashkey = None + + if use_hashkey: + hashkey = self._get_hashkey(body) + if not hashkey: + logger.debug("해시키 발급 실패, 해시키 없이 진행") + + for attempt in range(max_retries): + try: + r = requests.post(url, headers=self._headers(tr_id, hashkey), json=body, timeout=15) + if r.status_code == 429: + wait_time = (2 ** attempt) + random.uniform(0.5, 1.5) + logger.warning(f"⏳ API 호출 제한 (429) -> {wait_time:.1f}초 대기 후 재시도 ({attempt+1}/{max_retries})") + time.sleep(wait_time) + if use_hashkey: + hashkey = self._get_hashkey(body) + continue + if r.status_code == 200: + j = r.json() + if j.get("rt_cd") == "0": + return r + elif "초과" in str(j.get("msg1", "")) or "과부하" in str(j.get("msg1", "")): + wait_time = (2 ** attempt) + random.uniform(0.5, 1.5) + logger.warning(f"⏳ API 과부하 -> {wait_time:.1f}초 대기 후 재시도") + time.sleep(wait_time) + if use_hashkey: + hashkey = self._get_hashkey(body) + continue + return r + except requests.exceptions.RequestException as e: + if attempt < max_retries - 1: + wait_time = (2 ** attempt) + random.uniform(0.5, 1.5) + logger.warning(f"⚠️ 네트워크 에러 -> {wait_time:.1f}초 대기 후 재시도: {e}") + time.sleep(wait_time) + else: + logger.error(f"❌ POST 요청 실패 ({path}): {e}") + return r + + def buy_order(self, code, qty, price=0, order_type="01"): + """매수 주문 (모의=VTTC0802U, 실전=TTTC0802U) - 고급 주문 타입 지원""" + try: + if self.mock: + tr_id = "VTTC0802U" + else: + tr_id = "TTTC0802U" + body = { + "CANO": self.acc_no, + "ACNT_PRDT_CD": self.acc_code, + "PDNO": code, + "ORD_DVSN": order_type, + "ORD_QTY": str(qty), + "ORD_UNPR": str(price) if price > 0 else "0", + } + r = self._post( + "/uapi/domestic-stock/v1/trading/order-cash", + tr_id, + body, + use_hashkey=True + ) + if r.status_code != 200: + logger.error(f"매수 주문 HTTP 에러: {r.status_code}") + return False + j = r.json() + if j.get("rt_cd") == "0": + ord_no = j.get("output", {}).get("ODNO", "") + logger.info(f"✅ 매수 주문 성공: {code} {qty}주 (주문번호: {ord_no})") + return True + else: + logger.error(f"매수 주문 실패: {j.get('msg1', '알 수 없음')}") + return False + except Exception as e: + logger.error(f"매수 주문 예외({code}): {e}") + return False + + def buy_market_order(self, code, qty): + """시장가 매수 주문 (간편 메서드)""" + return self.buy_order(code, qty, price=0, order_type="01") + + def sell_order(self, code, qty, price=0, order_type="01"): + """매도 주문 (모의=VTTC0801U, 실전=TTTC0801U) - 고급 주문 타입 지원""" + try: + if self.mock: + tr_id = "VTTC0801U" + else: + tr_id = "TTTC0801U" + body = { + "CANO": self.acc_no, + "ACNT_PRDT_CD": self.acc_code, + "PDNO": code, + "ORD_DVSN": order_type, + "ORD_QTY": str(qty), + "ORD_UNPR": str(price) if price > 0 else "0", + } + r = self._post( + "/uapi/domestic-stock/v1/trading/order-cash", + tr_id, + body, + use_hashkey=True + ) + if r.status_code != 200: + logger.error(f"매도 주문 HTTP 에러: {r.status_code}") + return False + j = r.json() + if j.get("rt_cd") == "0": + ord_no = j.get("output", {}).get("ODNO", "") + logger.info(f"✅ 매도 주문 성공: {code} {qty}주 (주문번호: {ord_no})") + return True + else: + logger.error(f"매도 주문 실패: {j.get('msg1', '알 수 없음')}") + return False + except Exception as e: + logger.error(f"매도 주문 예외({code}): {e}") + return False + + def sell_market_order(self, code, qty): + """시장가 매도 주문 (간편 메서드)""" + return self.sell_order(code, qty, price=0, order_type="01") + + def inquire_prices_batch(self, stock_codes: List[str]): + """다중 종목 현재가 일괄 조회 (최대 20개)""" + if not stock_codes or len(stock_codes) > 20: + logger.warning("종목 코드는 1~20개만 가능") + return {} + + result = {} + # 한투 API는 다중 종목 조회를 지원하지 않으므로 순차 조회 + for code in stock_codes: + try: + price_data = self.inquire_price(code) + if price_data: + result[code] = price_data + time.sleep(random.uniform(0.1, 0.3)) + except Exception as e: + logger.debug(f"종목 조회 실패({code}): {e}") + continue + + return result + + def get_investor_trend(self, stock_code, days=5): + """외국인/기관 매매 동향 조회""" + try: + end_dt = dt.now() + start_dt = end_dt - timedelta(days=days + 10) + start_ymd = start_dt.strftime("%Y%m%d") + end_ymd = end_dt.strftime("%Y%m%d") + + _, out2 = self.inquire_daily_itemchartprice(stock_code, start_ymd, end_ymd, "D", "1") + if not out2: + return None + + foreign_sum = 0 + org_sum = 0 + + for item in out2[:days]: + try: + foreign_raw = item.get("frgn_ntby_qty") or item.get("frgn_ntby_shnu") or "0" + foreign_net = int(float(str(foreign_raw).replace(",", "").replace("+", "").replace("-", ""))) + if str(foreign_raw).startswith("-"): + foreign_net = -foreign_net + + org_raw = item.get("orgn_ntby_qty") or "0" + org_net = int(float(str(org_raw).replace(",", "").replace("+", "").replace("-", ""))) + if str(org_raw).startswith("-"): + org_net = -org_net + + foreign_sum += foreign_net + org_sum += org_net + except: + continue + + return { + "foreign_net_buy": foreign_sum, + "org_net_buy": org_sum, + "total_net_buy": foreign_sum + org_sum, + } + except Exception as e: + logger.error(f"외국인/기관 동향 조회 실패({stock_code}): {e}") + return None + + +# ============================================================ +# 헬퍼 함수들 (kis_long_term_checker.py 참고) +# ============================================================ +def get_kis_daily_chart(client, stock_code, max_days=65, exchange=None): + """한투 일봉 조회 후 DataFrame 반환""" + end_dt = dt.now() + end_ymd = end_dt.strftime("%Y%m%d") + + if exchange: + return pd.DataFrame() # 해외 종목은 미구현 + + start_dt = end_dt - timedelta(days=max_days + 30) + start_ymd = start_dt.strftime("%Y%m%d") + _, out2 = client.inquire_daily_itemchartprice(stock_code, start_ymd, end_ymd, "D", "1") + if not out2: + return pd.DataFrame() + + rows = [] + for b in out2: + try: + date_str = (b.get("stck_bsop_date") or "").strip() + close_p = abs(float(str(b.get("stck_clpr") or "0").replace(",", ""))) + open_p = abs(float(str(b.get("stck_oprc") or "0").replace(",", ""))) + high_p = abs(float(str(b.get("stck_hgpr") or "0").replace(",", ""))) + low_p = abs(float(str(b.get("stck_lwpr") or "0").replace(",", ""))) + vol = int(float(str(b.get("acml_vol") or "0").replace(",", ""))) + except (TypeError, ValueError): + continue + if date_str and close_p > 0: + rows.append({"dt": date_str, "open": open_p, "high": high_p, "low": low_p, "close": close_p, "volume": vol}) + + if not rows: + return pd.DataFrame() + df = pd.DataFrame(rows) + df = df.sort_values("dt").reset_index(drop=True) + df["MA20"] = df["close"].rolling(window=20).mean() + df["MA60"] = df["close"].rolling(window=60).mean() + return df + + +def calculate_volatility(df, period=20): + """변동성 계산 (20일 표준편차 / 평균 * 100)""" + if df is None or df.empty or len(df) < period: + return None + try: + returns = df["close"].pct_change().dropna() + volatility = returns.tail(period).std() * 100 + return round(volatility, 2) if pd.notna(volatility) else None + except Exception: + return None + + +def get_kis_per_eps_peg(client, stock_code, current_price): + """한투 재무비율·성장성비율로 PER, EPS, 성장률, PEG 계산""" + try: + fin = client.finance_financial_ratio(stock_code, "0") + growth = client.finance_growth_ratio(stock_code, "0") + time.sleep(random.uniform(1, 2)) + + per, eps = None, None + if fin: + try: + eps_raw = fin.get("eps") or fin.get("EPS") + if eps_raw is not None: + eps = float(str(eps_raw).replace(",", "").strip()) + per_raw = fin.get("per") or fin.get("PER") + if per_raw is not None: + per = float(str(per_raw).replace(",", "").strip()) + except (TypeError, ValueError): + pass + if per is None and eps is not None and eps > 0 and current_price and current_price > 0: + per = current_price / eps + if per is not None and per <= 0: + per = None + + growth_pct = None + if growth: + for key in ("bsop_prfi_inrt", "ntin_inrt", "grs"): + val = growth.get(key) + if val is not None: + try: + growth_pct = float(str(val).replace(",", "").strip()) + if growth_pct != 0: + break + except (TypeError, ValueError): + continue + if fin and growth_pct is None: + growth_pct = fin.get("ntin_inrt") or fin.get("bsop_prfi_inrt") + if growth_pct is not None: + try: + growth_pct = float(str(growth_pct).replace(",", "").strip()) + except (TypeError, ValueError): + growth_pct = None + + peg = None + if per is not None and growth_pct is not None and growth_pct > 0: + peg = round(per / growth_pct, 2) + + return {"per": per, "eps": eps, "growth_pct": growth_pct, "peg": peg} + except Exception as e: + logger.error(f"PER/PEG 계산 실패 {stock_code}: {e}") + return {"per": None, "eps": None, "growth_pct": None, "peg": None} + + +# ============================================================ +# Mattermost 봇 클래스 +# ============================================================ +class MattermostBot: + """Mattermost 알림 봇""" + def __init__(self): + self.api_url = f"{MM_SERVER_URL.rstrip('/')}/api/v4/posts" + self.headers = { + "Authorization": f"Bearer {MM_BOT_TOKEN}", + "Content-Type": "application/json" + } + self.channels = self._load_channels() + + def _load_channels(self): + """채널 설정 로드""" + try: + if MM_CONFIG_FILE.exists(): + with open(MM_CONFIG_FILE, 'r', encoding='utf-8') as f: + return json.load(f).get("channels", {}) + return {} + except Exception as e: + logger.error(f"⚠️ MM 설정 로드 실패: {e}") + return {} + + def send(self, channel_alias, message): + """메시지 전송""" + channel_id = self.channels.get(channel_alias) + if not channel_id: + logger.warning(f"❌ '{channel_alias}' 채널 ID 없음") + return False + + payload = {"channel_id": channel_id, "message": message} + try: + res = requests.post(self.api_url, headers=self.headers, json=payload, timeout=3) + res.raise_for_status() + return True + except Exception as e: + logger.error(f"❌ MM 전송 에러: {e}") + return False + + +# ============================================================ +# 늘림목 트레이딩 봇 +# ============================================================ +class LongTradingBot: + """늘림목 전략 트레이딩 봇 - 장기 보유 + 분할 매수""" + def __init__(self): + self.db = db + self.client = KISClientWithOrder() + + # Mattermost 초기화 + self.mm = MattermostBot() + # 롱/늘림목 봇 전용 채널(alias) 우선 사용, 없으면 기본 채널 사용 + self.mm_channel = MM_CHANNEL_LONG + + # 전략 파라미터 (DB에서 읽기) + self.max_per = get_env_float("MAX_PER", 25) + self.max_peg = get_env_float("MAX_PEG", 1.5) + self.min_growth_pct = get_env_float("MIN_GROWTH_PCT", 10) + + # 늘림목 파라미터 + dca_intervals_str = get_env_from_db("DCA_INTERVALS", "0,-0.05,-0.10,-0.15,-0.20") + self.dca_intervals = [float(x.strip()) for x in dca_intervals_str.split(",") if x.strip()] + dca_amounts_str = get_env_from_db("DCA_AMOUNTS", "100000,100000,100000,100000,100000") + self.dca_amounts = [int(x.strip()) for x in dca_amounts_str.split(",") if x.strip()] + self.max_position_pct = get_env_float("MAX_POSITION_PCT", 0.20) + + # 손절/익절 + self.stop_loss_pct = get_env_float("STOP_LOSS_PCT", -0.30) + self.take_profit_pct = get_env_float("TAKE_PROFIT_PCT", 0.50) + + # DB에서 활성 트레이드 로드 + self.holdings = {} + active_trades = self.db.get_active_trades() + for code, trade in active_trades.items(): + # 분할 매수 정보 복원 (간단화) + self.holdings[code] = { + "buy_prices": [trade.get("avg_buy_price", 0)], + "qtys": [trade.get("current_qty", 0)], + "total_qty": trade.get("current_qty", 0), + "avg_price": trade.get("avg_buy_price", 0), + "first_buy_date": trade.get("buy_date", dt.now().strftime("%Y-%m-%d %H:%M:%S")), + "name": trade.get("name", code), + } + + # 관심 종목 리스트 (JSON 파일에서 로드) + self.watchlist_path = SCRIPT_DIR / "long_term_watchlist.json" + self.watchlist = self._load_watchlist() + + # 초기 자산 조회 + self._update_assets() + + # 비동기 태스크 관리 + self._report_task = None + self._asset_task = None + + def _load_watchlist(self): + """관심 종목 리스트 로드""" + if not self.watchlist_path.exists(): + logger.warning(f"관심 종목 파일 없음: {self.watchlist_path}") + return [] + + try: + with open(self.watchlist_path, "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("items", []) + except Exception as e: + logger.error(f"관심 종목 로드 실패: {e}") + return [] + + def _update_assets(self): + """자산 정보 업데이트""" + try: + balance = self.client.get_account_evaluation() + if balance: + output1 = balance.get("output1", {}) + self.current_cash = float(output1.get("dnca_tot_amt", 0)) + # 보유 종목 평가액 계산 + holdings_value = 0 + for code, holding in self.holdings.items(): + price_data = self.client.inquire_price(code) + if price_data: + current_price = abs(float(price_data.get("stck_prpr", 0))) + holdings_value += current_price * holding["total_qty"] + + self.current_total_asset = self.current_cash + holdings_value + + # 오늘 첫 실행 시 시작 자산 저장 + if self.start_day_asset == 0: + self.start_day_asset = self.current_total_asset + except Exception as e: + logger.error(f"자산 정보 업데이트 실패: {e}") + + def _update_account_light(self, profit_val=0): + """ + 경량 계좌 갱신 (매수/매도 직후 즉시 호출!) + - API 부하를 줄이기 위해 예수금 + 보유 종목 평가액만 빠르게 계산 + - 총자산 = 예수금 + 보유 종목 평가액 (+ 손익 반영) + """ + try: + balance = self.client.get_account_evaluation() + if balance: + output1 = balance.get("output1", {}) + new_cash = float(output1.get("dnca_tot_amt", 0)) + + # 예수금 업데이트 (0원이 아닐 때만, 또는 초기화 시) + if new_cash > 0 or self.current_cash == 0: + self.current_cash = new_cash + + # 보유 종목 평가액 계산 (빠른 버전: 로컬 holdings 사용) + holdings_value = 0 + for code, holding in self.holdings.items(): + # output2에서 보유 종목 정보 확인 (더 빠름) + output2 = balance.get("output2", []) + for item in output2: + if item.get("pdno", "").strip() == code: + # API에서 받은 평가액 사용 + evlu_amt = float(item.get("evlu_amt", 0)) # 평가금액 + holdings_value += evlu_amt + break + else: + # output2에 없으면 로컬 계산 (fallback) + price_data = self.client.inquire_price(code) + if price_data: + current_price = abs(float(price_data.get("stck_prpr", 0))) + holdings_value += current_price * holding["total_qty"] + + # 총자산 계산 (손익 반영) + self.current_total_asset = self.current_cash + holdings_value + if profit_val != 0: + self.current_total_asset += profit_val + + logger.debug(f"💵 [경량갱신] 예수금: {self.current_cash:,.0f}원 | 총자산: {self.current_total_asset:,.0f}원") + return True + except Exception as e: + logger.error(f"❌ 경량 갱신 실패: {e}") + return False + + def _update_cash_only(self): + """예수금만 빠르게 업데이트 (하위 호환성용, _update_account_light 사용 권장)""" + return self._update_account_light(profit_val=0) + + def _seconds_until_next_5min(self): + """다음 5분 정각까지 남은 초 계산""" + now = dt.now() + next_min = ((now.minute // 5) + 1) * 5 + if next_min >= 60: + next_time = now.replace(hour=now.hour + 1, minute=0, second=0, microsecond=0) + else: + next_time = now.replace(minute=next_min, second=0, microsecond=0) + return (next_time - now).total_seconds() + + async def _report_scheduler(self): + """리포트 전송 스케줄러 (비동기 백그라운드)""" + while True: + try: + await asyncio.sleep(60) # 1분마다 체크 + now = dt.now() + + # 13:00 - 오전 리포트 + AI 리포트 + if now.hour == 13 and now.minute == 0 and not self.morning_report_sent: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self.send_morning_report) + await loop.run_in_executor(None, self.send_ai_report) + + # 15:15 - 장마감 전 리포트 + elif now.hour == 15 and now.minute == 15 and not self.closing_report_sent: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self.send_closing_report) + + # 15:35 - 최종 리포트 + elif now.hour == 15 and now.minute == 35 and not self.final_report_sent: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self.send_final_report) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"❌ [리포트 스케줄러] 에러: {e}") + await asyncio.sleep(60) + + async def _asset_update_scheduler(self): + """자산 정보 업데이트 스케줄러 (30분마다, 비동기 백그라운드)""" + while True: + try: + await asyncio.sleep(60) # 1분마다 체크 + now = dt.now() + + # 30분마다 자산 업데이트 + if now.minute % 30 == 0: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self._update_assets) + await asyncio.sleep(60) # 업데이트 후 1분 대기 (중복 방지) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"❌ [자산 업데이트 스케줄러] 에러: {e}") + await asyncio.sleep(60) + + def send_mm(self, msg): + """Mattermost 알림 전송""" + try: + self.mm.send(self.mm_channel, msg) + except Exception as e: + logger.error(f"❌ MM 전송 에러: {e}") + + def check_market_status(self): + """장 운영 시간 체크""" + # FORCE_MARKET_OPEN 플래그 확인 (테스트용) + force_open = get_env_bool("FORCE_MARKET_OPEN", False) + if force_open: + logger.debug("🔓 FORCE_MARKET_OPEN=true - 장 상태 무시하고 계속 진행") + return True + + # 정상 장 운영 시간 체크 + now = dt.now() + if not (datetime.time(8, 30) <= now.time() <= datetime.time(16, 0)): + return False + if now.weekday() >= 5: # 주말 + return False + return True + + def send_morning_report(self): + """오전 장 뜸할 때 리포트 (13:00)""" + if self.morning_report_sent: + return + + self._update_assets() + day_pnl = self.current_total_asset - self.start_day_asset + day_pnl_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0 + + msg = f"""📊 **[오전 장 현황 - 13:00]** +- 당일 시작: {self.start_day_asset:,.0f}원 +- 현재 자산: {self.current_total_asset:,.0f}원 +- 당일 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%) +- 보유 종목: {len(self.holdings)}개""" + + self.send_mm(msg) + self.morning_report_sent = True + logger.info("📊 오전 리포트 전송 완료") + + def send_closing_report(self): + """장마감 전 리포트 (15:15)""" + if self.closing_report_sent: + return + + self._update_assets() + day_pnl = self.current_total_asset - self.start_day_asset + day_pnl_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0 + + msg = f"""📈 **[장마감 전 현황 - 15:15]** +- 당일 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%) +- 현재 자산: {self.current_total_asset:,.0f}원 +- 보유 종목: {len(self.holdings)}개 +- 예수금: {self.current_cash:,.0f}원""" + + self.send_mm(msg) + self.closing_report_sent = True + logger.info("📈 장마감 전 리포트 전송 완료") + + def send_final_report(self): + """장마감 후 최종 리포트 (15:35)""" + if self.final_report_sent: + return + + self._update_assets() + + # 당일 손익 + day_pnl = self.current_total_asset - self.start_day_asset + day_pnl_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0 + + # 누적 손익 + cumulative_pnl = self.current_total_asset - self.total_deposit + cumulative_pnl_pct = (cumulative_pnl / self.total_deposit * 100) if self.total_deposit > 0 else 0 + + # 오늘 거래 내역 + today_trades = self.db.get_trades_by_date(self.today_date) + + msg = f"""🏁 **[장마감 최종 보고 - 15:35]** +━━━━━━━━━━━━━━━━━━━━ +📅 **당일 손익** +- 시작: {self.start_day_asset:,.0f}원 +- 종료: {self.current_total_asset:,.0f}원 +- 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%) + +💰 **누적 손익 (총 입금액 대비)** +- 총 입금: {self.total_deposit:,.0f}원 +- 현재 자산: {self.current_total_asset:,.0f}원 +- 누적 손익: {cumulative_pnl:+,.0f}원 ({cumulative_pnl_pct:+.2f}%) + +📊 **거래 현황** +- 오늘 매매: {len(today_trades)}건 +- 보유 종목: {len(self.holdings)}개 +- 예수금: {self.current_cash:,.0f}원 +━━━━━━━━━━━━━━━━━━━━""" + + self.send_mm(msg) + self.final_report_sent = True + logger.info("🏁 장마감 최종 리포트 전송 완료") + + def send_ai_report(self): + """AI 분석 리포트 (13:00)""" + if self.ai_report_sent or not gemini_model: + return + + try: + # 최근 거래 내역 조회 + recent_trades = [] + try: + conn = self.db.conn + cursor = conn.execute(""" + SELECT code, name, buy_price, sell_price, qty, profit_rate, + realized_pnl, strategy, sell_reason, buy_date, sell_date, hold_minutes + FROM trade_history + WHERE strategy LIKE 'LONG%' + ORDER BY id DESC + LIMIT 10 + """) + for row in cursor.fetchall(): + recent_trades.append({ + 'code': row[0], + 'name': row[1], + 'buy_price': row[2], + 'sell_price': row[3], + 'qty': row[4], + 'profit_rate': row[5], + 'realized_pnl': row[6], + 'strategy': row[7], + 'sell_reason': row[8], + 'buy_date': row[9], + 'sell_date': row[10], + 'hold_minutes': row[11] or 0 + }) + except Exception as e: + logger.error(f"거래 내역 조회 실패: {e}") + return + + if not recent_trades: + return + + # 통계 계산 + total = len(recent_trades) + wins = sum(1 for t in recent_trades if t['profit_rate'] > 0) + losses = total - wins + win_rate = wins / total * 100 if total > 0 else 0 + avg_profit = sum(t['profit_rate'] for t in recent_trades) / total + total_pnl = sum(t['realized_pnl'] for t in recent_trades) + avg_hold_days = sum(t['hold_minutes'] for t in recent_trades) / total / 1440 # 분을 일로 변환 + + # AI 분석 + trades_text = "" + for i, t in enumerate(recent_trades, 1): + trades_text += f""" +[거래 {i}] {t['name']} ({t['strategy']}) +- 매수: {t['buy_price']:,.0f}원 × {t['qty']}주 +- 매도: {t['sell_price']:,.0f}원 +- 손익: {t['profit_rate']:+.2f}% ({t['realized_pnl']:,.0f}원) +- 보유: {avg_hold_days:.1f}일 +- 사유: {t['sell_reason']} +""" + + prompt = f"""당신은 퀀트 트레이딩 전문가입니다. + +다음은 최근 {total}건의 장기/늘림목 거래 내역입니다: + +{trades_text} + +📊 통계: +- 승률: {win_rate:.1f}% ({wins}승 {losses}패) +- 평균 수익률: {avg_profit:.2f}% +- 총 손익: {total_pnl:,.0f}원 +- 평균 보유: {avg_hold_days:.1f}일 + +**당신의 임무:** +1. 문제점 3가지 진단 (구체적으로) +2. DB 설정 수정 권장사항 (변수명=값 형식) +3. 예상 효과 + +**출력 형식:** +## 🔍 문제점 +1. [구체적 문제 1] +2. [구체적 문제 2] +3. [구체적 문제 3] + +## 💡 권장 수정사항 +``` +MAX_PER=XX +MAX_PEG=X.XX +DCA_INTERVALS=X,-X.XX,-X.XX +... +``` + +## 📈 예상 효과 +- [효과 1] +- [효과 2] + +**간결하고 명확하게 답변하세요.** +""" + + response = gemini_model.generate_content(prompt) + analysis = response.text + + summary = f"""📊 최근 {total}건 거래 통계 +- 승률: {win_rate:.1f}% ({wins}승 {losses}패) +- 평균 수익률: {avg_profit:.2f}% +- 총 손익: {total_pnl:,.0f}원 +- 평균 보유: {avg_hold_days:.1f}일""" + + message = f"""🤖 **[13시 AI 자동 분석]** + +{summary} + +{analysis} + +--- +💬 늘림목 전략 최적화를 위한 AI 분석입니다. +""" + + self.send_mm(message) + self.ai_report_sent = True + logger.info("🤖 AI 리포트 전송 완료") + + except Exception as e: + logger.error(f"AI 리포트 생성 실패: {e}") + + def analyze_stock_value(self, code, name): + """종목 밸류에이션 분석 - 고도화된 분석""" + try: + # 현재가 조회 + price_data = self.client.inquire_price(code) + if not price_data: + return None + + current_price = abs(float(price_data.get("stck_prpr", 0))) + if current_price == 0: + return None + + # PER/PEG 조회 + fund_data = get_kis_per_eps_peg(self.client, code, current_price) + per = fund_data.get("per") + peg = fund_data.get("peg") + growth_pct = fund_data.get("growth_pct") + + # 차트 분석 + df = get_kis_daily_chart(self.client, code, max_days=65) + if df.empty: + return None + + # RSI 계산 + delta = df["close"].diff(1) + gain = delta.where(delta > 0, 0).rolling(window=14).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean() + rs = gain / loss.replace(0, float("nan")) + rsi = 100 - (100 / (1 + rs)) + current_rsi = float(rsi.iloc[-1]) if len(rsi) > 0 else None + + # [필터 1] RSI 과열 체크 + rsi_threshold = get_env_float("RSI_OVERHEAT_THRESHOLD", 78.0) + if current_rsi and current_rsi >= rsi_threshold: + logger.info(f"🔍 [Pass-RSI] {name} {code}: RSI 과열 ({current_rsi:.1f} >= {rsi_threshold})") + return None + + # 변동성 + volatility = calculate_volatility(df) + + # 이동평균 + ma20 = float(df["MA20"].iloc[-1]) if "MA20" in df.columns and len(df) >= 20 else None + ma60 = float(df["MA60"].iloc[-1]) if "MA60" in df.columns and len(df) >= 60 else None + + # [필터 2] MA20 체크 + if ma20 and current_price < ma20: + logger.info(f"🔍 [Pass-MA20] {name} {code}: 현재가({current_price:.0f}) < MA20({ma20:.2f})") + return None + + # 외국인/기관 동향 분석 + investor_trend = self.client.get_investor_trend(code, days=5) + investor_score = 0 + investor_signal = "중립" + if investor_trend: + total_net = investor_trend.get("total_net_buy", 0) + if total_net > 20000: + investor_score = 25 + investor_signal = "강한 매수세" + elif total_net > 5000: + investor_score = 15 + investor_signal = "매수세" + elif total_net < -20000: + investor_score = -25 + investor_signal = "강한 매도세" + elif total_net < -5000: + investor_score = -15 + investor_signal = "매도세" + + # 종합 점수 계산 + score = 50 # 기본 점수 + + # PER 점수 + if per is not None: + if per < 10: + score += 30 + elif per < 15: + score += 20 + elif per < 20: + score += 10 + elif per > 30: + score -= 20 + + # PEG 점수 + if peg is not None: + if peg < 1.0: + score += 30 + elif peg < 1.5: + score += 15 + elif peg > 2.0: + score -= 20 + + # RSI 점수 + if current_rsi is not None: + if current_rsi < 30: + score += 20 # 과매도 구간 + elif current_rsi < 40: + score += 10 + elif current_rsi > 70: + score -= 20 # 과매수 구간 + + # 이동평균 점수 + if ma20 is not None and ma60 is not None: + if ma20 > ma60: + score += 10 # 정배열 + ma_gap_pct = ((ma20 - ma60) / ma60) * 100 + if ma_gap_pct > 5: + score += 5 # 강한 상승세 + + # 외국인/기관 점수 추가 + score += investor_score + + return { + "code": code, + "name": name, + "current_price": current_price, + "per": per, + "peg": peg, + "growth_pct": growth_pct, + "rsi": current_rsi, + "volatility": volatility, + "ma20": ma20, + "ma60": ma60, + "investor_trend": investor_trend, + "investor_signal": investor_signal, + "score": score, + "is_buyable": ( + (per is None or per <= self.max_per) and + (peg is None or peg <= self.max_peg) and + (growth_pct is None or growth_pct >= self.min_growth_pct) and + score >= 60 and + investor_score >= -10 # 강한 매도세는 제외 + ), + } + + # 필터링 로그 출력 + if not analysis["is_buyable"]: + reasons = [] + if per and per > self.max_per: + reasons.append(f"PER {per:.1f} > {self.max_per}") + if peg and peg > self.max_peg: + reasons.append(f"PEG {peg:.2f} > {self.max_peg}") + if growth_pct and growth_pct < self.min_growth_pct: + reasons.append(f"성장률 {growth_pct:.1f}% < {self.min_growth_pct}%") + if score < 60: + reasons.append(f"점수 {score:.1f} < 60") + if investor_score < -10: + reasons.append(f"수급 점수 {investor_score} < -10") + + if reasons: + logger.info(f"🔍 [Pass-밸류] {name} {code}: {', '.join(reasons)}") + + return { + } + except Exception as e: + logger.error(f"종목 분석 실패({code}): {e}") + return None + + def check_dca_opportunity(self, code, holding): + """늘림목(분할 매수) 기회 체크""" + if code not in self.holdings: + return None + + try: + price_data = self.client.inquire_price(code) + if not price_data: + return None + + current_price = abs(float(price_data.get("stck_prpr", 0))) + avg_price = holding["avg_price"] + + # 평단가 대비 하락률 계산 + drop_pct = (current_price - avg_price) / avg_price + + # 분할 매수 구간 확인 + for i, interval in enumerate(self.dca_intervals): + if drop_pct <= interval: + # 이미 이 구간에서 매수했는지 확인 + buy_prices = holding.get("buy_prices", []) + if buy_prices and min(buy_prices) <= current_price * 1.02: # 2% 오차 범위 내 + continue # 이미 매수한 구간 + + # 분할 매수 실행 + amount = self.dca_amounts[i] if i < len(self.dca_amounts) else self.dca_amounts[-1] + qty = int(amount / current_price) + if qty > 0: + return { + "code": code, + "name": holding.get("name", code), + "price": current_price, + "qty": qty, + "drop_pct": drop_pct, + "interval": interval, + } + + return None + except Exception as e: + logger.error(f"늘림목 체크 실패({code}): {e}") + return None + + def check_sell_signals(self): + """매도 신호 체크 (손절/익절)""" + if not self.holdings: + return [] + + sell_signals = [] + for code, holding in list(self.holdings.items()): + try: + price_data = self.client.inquire_price(code) + if not price_data: + continue + + current_price = abs(float(price_data.get("stck_prpr", 0))) + avg_price = holding["avg_price"] + profit_pct = (current_price - avg_price) / avg_price + + # 손절 또는 익절 체크 + if profit_pct <= self.stop_loss_pct: + sell_signals.append({ + "code": code, + "name": holding.get("name", code), + "reason": "손절", + "profit_pct": profit_pct, + "qty": holding["total_qty"], + }) + elif profit_pct >= self.take_profit_pct: + sell_signals.append({ + "code": code, + "name": holding.get("name", code), + "reason": "익절", + "profit_pct": profit_pct, + "qty": holding["total_qty"], + }) + + time.sleep(random.uniform(0.3, 0.7)) + except Exception as e: + logger.error(f"매도 신호 체크 실패({code}): {e}") + continue + + return sell_signals + + def execute_buy(self, signal, is_dca=False): + """매수 실행""" + code = signal["code"] + name = signal["name"] + price = signal["price"] + qty = signal["qty"] + + # 🔥 매수 직전 예수금 실시간 확인 (30분마다 업데이트된 값이 부정확할 수 있음) + if not self._update_account_light(profit_val=0): + logger.warning(f"⚠️ [{name}] 예수금 조회 실패 -> 매수 스킵") + return False + + # 예수금 부족 체크 (수수료 포함 여유분 5% 고려) + required_amount = price * qty * 1.05 # 수수료 포함 + if self.current_cash < required_amount: + logger.warning( + f"⚠️ [{name}] 예수금 부족: 필요 {required_amount:,.0f}원 / " + f"보유 {self.current_cash:,.0f}원 -> 매수 스킵" + ) + return False + + # 매수 주문 + success = self.client.buy_market_order(code, qty) + if success: + # 매수 후 예수금 + 총자산 즉시 업데이트 (다음 매수 시 정확한 예수금 확인) + self._update_account_light(profit_val=0) + buy_time = dt.now().strftime("%Y-%m-%d %H:%M:%S") + + if code not in self.holdings: + self.holdings[code] = { + "buy_prices": [], + "qtys": [], + "total_qty": 0, + "avg_price": 0, + "first_buy_date": buy_time, + "name": name, + } + + holding = self.holdings[code] + holding["buy_prices"].append(price) + holding["qtys"].append(qty) + holding["total_qty"] += qty + + # 평단가 재계산 + total_cost = sum(p * q for p, q in zip(holding["buy_prices"], holding["qtys"])) + holding["avg_price"] = total_cost / holding["total_qty"] + + # DB에 저장/업데이트 + self.db.upsert_trade({ + "code": code, + "name": name, + "strategy": "LONG_DCA" if is_dca else "LONG_INITIAL", + "avg_buy_price": holding["avg_price"], + "current_price": price, + "target_qty": holding["total_qty"], + "current_qty": holding["total_qty"], + "status": "HOLDING", + "buy_date": holding["first_buy_date"], + }) + + action = "늘림목 매수" if is_dca else "초기 매수" + logger.info(f"💰 [{action}] {name} ({code}): {price:,.0f}원 × {qty}주 (평단: {holding['avg_price']:,.0f}원)") + return True + + return False + + def execute_sell(self, signal): + """매도 실행""" + code = signal["code"] + name = signal["name"] + qty = signal["qty"] + + if code not in self.holdings: + logger.warning(f"⚠️ [{name}] 보유 종목 아님") + return False + + # 매도 주문 + success = self.client.sell_market_order(code, qty) + if success: + # 현재가 조회 + price_data = self.client.inquire_price(code) + sell_price = abs(float(price_data.get("stck_prpr", 0))) if price_data else signal.get("price", 0) + + holding = self.holdings[code] + profit_pct = signal["profit_pct"] + + # DB에서 매도 처리 + self.db.close_trade( + code=code, + sell_price=sell_price, + sell_reason=signal['reason'], + ) + + # 손익 계산 (매도 후 총자산 반영용) + profit_val = (sell_price - holding["avg_price"]) * qty # 손익 금액 + + del self.holdings[code] + + # 🔥 매도 후 예수금 + 총자산 즉시 업데이트 (손익 반영) + self._update_account_light(profit_val=profit_val) + + logger.info( + f"💸 [매도 체결] {name} ({code}): {qty}주 " + f"({signal['reason']}, {profit_pct*100:+.2f}%, 평단: {holding['avg_price']:,.0f}원)" + ) + return True + + return False + + def run(self): + """메인 루프 (진입점). 내부적으로 asyncio.run(_run_async()) 호출.""" + asyncio.run(self._run_async()) + + async def _run_async(self): + """비동기 메인 루프 - 백그라운드 태스크 시작 후 동기 매매 루프 실행""" + logger.info("🚀 늘림목 트레이딩 봇 시작 (비동기 백그라운드 작업 활성화)") + + # 백그라운드 태스크 시작 + self._report_task = asyncio.create_task(self._report_scheduler()) + self._asset_task = asyncio.create_task(self._asset_update_scheduler()) + logger.info("✅ 백그라운드 태스크 시작 완료 (리포트, 자산 업데이트)") + + # 동기 매매 루프는 별도 스레드에서 실행 (메인 이벤트 루프 블로킹 방지) + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self._sync_trading_loop) + + def _sync_trading_loop(self): + """동기 매매 루프 (메인 로직) - 백그라운드 작업과 분리""" + logger.info("📈 매매 루프 시작 (동기 모드)") + + while True: + try: + now = dt.now() + current_date = now.strftime("%Y-%m-%d") + + # 날짜 변경 시 리포트 플래그 리셋 + if current_date != self.today_date: + self.today_date = current_date + self.morning_report_sent = False + self.closing_report_sent = False + self.final_report_sent = False + self.ai_report_sent = False + self.start_day_asset = 0 + logger.info(f"📅 날짜 변경: {current_date}") + + # 리포트 전송은 백그라운드 태스크에서 처리하므로 여기서는 제거 + # (기존 코드와의 호환성을 위해 주석 처리) + # if now.hour == 13 and now.minute == 0: + # self.send_morning_report() + # self.send_ai_report() + # elif now.hour == 15 and now.minute == 15: + # self.send_closing_report() + # elif now.hour == 15 and now.minute == 35: + # self.send_final_report() + + # 장 상태 체크 + if not self.check_market_status(): + logger.info("⏸ 장 시간 아님 - 대기 중...") + time.sleep(60) + continue + + # 자산 정보 업데이트는 백그라운드 태스크에서 처리하므로 여기서는 제거 + # (기존 코드와의 호환성을 위해 주석 처리) + # if now.minute % 30 == 0: # 30분마다 + # self._update_assets() + + # 매도 신호 체크 (우선) - 메인 루프에서 처리 + sell_signals = self.check_sell_signals() + for signal in sell_signals: + self.execute_sell(signal) + time.sleep(random.uniform(1, 2)) + + # 늘림목 기회 체크 + for code, holding in list(self.holdings.items()): + dca_signal = self.check_dca_opportunity(code, holding) + if dca_signal: + self.execute_buy(dca_signal, is_dca=True) + time.sleep(random.uniform(1, 2)) + + # 신규 매수 기회 스캔 (관심 종목 리스트 기준) + active_count = len(self.holdings) + if len(self.watchlist) > 0: + logger.info(f"🔍 [매수 기회 탐색] 타겟:{len(self.watchlist)}개 | 보유:{active_count}/{self.max_stocks}") + + for item in self.watchlist: + code = item.get("code", "").strip() + name = item.get("name", code) + + if not code or code in self.holdings: + continue + + # 밸류에이션 분석 + analysis = self.analyze_stock_value(code, name) + if not analysis or not analysis["is_buyable"]: + continue + + # 초기 매수 실행 + signal = { + "code": code, + "name": name, + "price": analysis["current_price"], + "qty": int(100000 / analysis["current_price"]), # 10만원 분할 + } + self.execute_buy(signal, is_dca=False) + time.sleep(random.uniform(2, 4)) + break # 한 번에 하나씩만 + + # 대기 + time.sleep(random.uniform(10, 15)) + + except KeyboardInterrupt: + logger.info("⏹ 봇 종료") + # 백그라운드 태스크 취소 + if self._report_task: + self._report_task.cancel() + if self._asset_task: + self._asset_task.cancel() + break + except Exception as e: + logger.error(f"❌ 루프 에러: {e}") + import traceback + logger.error(traceback.format_exc()) + time.sleep(10) + + +if __name__ == "__main__": + bot = LongTradingBot() + bot.run() diff --git a/kis_short_ver1.py b/kis_short_ver1.py new file mode 100644 index 0000000..e0bab0a --- /dev/null +++ b/kis_short_ver1.py @@ -0,0 +1,3296 @@ +""" +KIS Short Trading Bot Ver1 - 단타용 한투 API 트레이딩 시스템 +- 한국투자증권(KIS) Open API 사용 +- 개미털기(눌림목) 전략 기반 단타 매매 +- 빠른 손절/익절 로직 +- kiwoom_trader_ver2.py의 단타 전략을 한투 API로 변환 +""" + +import os +import re +import json +import time +import random +import logging +import datetime +import hashlib +import hmac +import base64 +import warnings +import asyncio +from datetime import datetime as dt +from pathlib import Path +from typing import List, Dict, Optional + +import pandas as pd +import requests + +from database import TradeDB + +# 로깅 설정 +logging.basicConfig( + format='[%(asctime)s] %(message)s', + datefmt='%H:%M:%S', + level=logging.INFO, +) +logger = logging.getLogger("KISShortBot") + +# 로그 색상 (ANSI) - 탈락/통과 구분 +LOG_RED = "\033[91m" # 탈락 +LOG_YELLOW = "\033[93m" # 탈락 (Pass-조건) +LOG_GREEN = "\033[92m" # 통과 +LOG_CYAN = "\033[96m" # 강조 +LOG_RESET = "\033[0m" + +# DB 초기화 +SCRIPT_DIR = Path(__file__).resolve().parent +db = TradeDB(db_path=str(SCRIPT_DIR / "quant_bot.db")) + +# DB에서 환경변수 로드 +def get_env_from_db(key, default=""): + """DB에서 환경변수 읽기""" + env_data = db.get_latest_env() + if env_data and env_data.get("snapshot"): + return env_data["snapshot"].get(key, default) + return default + +def get_env_float(key, default): + """환경변수를 float로 변환 (DB 우선)""" + value = get_env_from_db(key, str(default)) + if isinstance(value, str) and "#" in value: + value = value.split("#")[0].strip() + try: + return float(value) if value else default + except (ValueError, TypeError): + return default + +def get_env_int(key, default): + """환경변수를 int로 변환 (DB 우선)""" + value = get_env_from_db(key, str(default)) + if isinstance(value, str) and "#" in value: + value = value.split("#")[0].strip() + try: + return int(value) if value else default + except (ValueError, TypeError): + return default + +def get_env_bool(key, default=False): + """환경변수를 bool로 변환 (DB 우선)""" + value = get_env_from_db(key, str(default)).lower() + return value in ("true", "1", "yes") + +# Mattermost 설정 +MM_SERVER_URL = get_env_from_db("MM_SERVER_URL", "https://mattermost.hoonfam.org") +MM_BOT_TOKEN = get_env_from_db("MM_BOT_TOKEN_", "").strip() +MM_CONFIG_FILE = SCRIPT_DIR / "mm_config.json" +# 기본 채널(alias) + 단타 봇 전용 채널(alias) +MM_CHANNEL_DEFAULT = get_env_from_db("MATTERMOST_CHANNEL", "stock") +MM_CHANNEL_SHORT = get_env_from_db("KIS_SHORT_MM_CHANNEL", MM_CHANNEL_DEFAULT) + +# Gemini API (AI 리포트용) - google.genai 신규 SDK (Client 사용, configure 없음) +try: + import google.genai as genai + GEMINI_AVAILABLE = True +except ImportError: + GEMINI_AVAILABLE = False + logger.warning("⚠️ google-genai 미설치 - AI 리포트 기능 사용 불가") + +GEMINI_API_KEY = get_env_from_db("GEMINI_API_KEY", "").strip() +GEMINI_MODEL_ID = "gemini-2.5-flash" # 또는 gemini-2.5-flash (모델명 확인) +gemini_client = None +if GEMINI_API_KEY and GEMINI_AVAILABLE: + try: + gemini_client = genai.Client(api_key=GEMINI_API_KEY) + except Exception as e: + logger.warning(f"⚠️ Gemini 초기화 실패: {e}") + gemini_client = None +else: + gemini_client = None + +# ML 예측 (선택적) +try: + from ml_predictor import MLPredictor + ML_AVAILABLE = True +except ImportError: + ML_AVAILABLE = False + logger.warning("⚠️ ml_predictor 미설치 - ML 예측 기능 사용 불가") + +# RiskManager (변동성 기반 리스크 관리) +try: + from risk_manager import RiskManager + RISK_MANAGER_AVAILABLE = True +except ImportError: + RISK_MANAGER_AVAILABLE = False + logger.warning("⚠️ risk_manager 미설치 - 변동성 역가중 매수 금액 계산 불가") + +# ============================================================ +# 한투(KIS) API 클라이언트 (kis_long_term_checker.py 참고) +# ============================================================ +# 모의계좌용 토큰 캐시 경로 +KIS_TOKEN_CACHE_PATH_MOCK = SCRIPT_DIR / ".kis_token_cache_mock.json" +# 실계좌용 토큰 캐시 경로 +KIS_TOKEN_CACHE_PATH_REAL = SCRIPT_DIR / ".kis_token_cache_real.json" + + + +# 한투 접근 토큰 유효기간 24시간. 자주 발급하면 영구 제명될 수 있으므로 캐시 철저 재사용. +# 만료 1분 전에만 재발급 (불필요한 발급 최소화) +KIS_TOKEN_EXPIRE_MARGIN_SEC = 60 + + +def _parse_kis_token_expired(expired_str): + """한투 API 만료시간 문자열 파싱. 'YYYY-MM-DD HH:MM:SS' 또는 'YYYY-MM-DDTHH:MM:SS' 등 지원.""" + if not expired_str or not isinstance(expired_str, str): + return None + s = expired_str.strip().replace("T", " ")[:19] + if len(s) < 19: + return None + try: + return dt.strptime(s, "%Y-%m-%d %H:%M:%S") + except ValueError: + return None + + +def _load_kis_token_cache(mock): + """캐시 파일에서 토큰 로드. 만료 1분 전까지 유효하면 재사용 (24h 토큰 자주 발급 시 영구 제명 주의).""" + if mock: + path = KIS_TOKEN_CACHE_PATH_MOCK + else: + path = KIS_TOKEN_CACHE_PATH_REAL + if not path.exists(): + logger.info("한투 토큰 캐시 없음 → API 발급 예정 (캐시 경로: %s)", path) + return None + try: + logger.info("패스 %s", path) + with open(path, "r", encoding="utf-8") as f: + cache = json.load(f) + if cache.get("mock") != mock: + logger.info("한투 토큰 캐시 모의/실전 불일치 → API 발급 예정") + return None + token = cache.get("access_token") + expired_str = cache.get("access_token_token_expired") or cache.get("expires_at") + if not token or not expired_str: + logger.info("한투 토큰 캐시 내용 불완전 → API 발급 예정") + return None + expired_dt = _parse_kis_token_expired(expired_str) + if expired_dt is None: + logger.info("한투 토큰 캐시 만료시간 파싱 실패(%s) → API 발급 예정", expired_str[:30]) + return None + if dt.now() >= expired_dt - datetime.timedelta(seconds=KIS_TOKEN_EXPIRE_MARGIN_SEC): + logger.info("한투 토큰 캐시 만료 임박(%s) → API 발급 예정", expired_str[:19]) + return None + return token + except Exception as e: + logger.warning("한투 토큰 캐시 로드 실패(%s): %s", path, e) + return None + + +def _save_kis_token_cache(access_token, access_token_token_expired, mock): + """발급받은 토큰을 캐시 파일에 저장.""" + try: + if mock: + path = KIS_TOKEN_CACHE_PATH_MOCK + else: + path = KIS_TOKEN_CACHE_PATH_REAL + with open(path, "w", encoding="utf-8") as f: + json.dump({ + "access_token": access_token, + "access_token_token_expired": access_token_token_expired, + "mock": mock, + }, f, ensure_ascii=False, indent=2) + logger.info("한투 토큰 캐시 저장 완료: %s", path) + except Exception as e: + logger.warning("한투 토큰 캐시 저장 실패: %s", e) + + +class KISClient: + """한국투자증권 Open API 클라이언트""" + def __init__(self, mock=None): + + # 모의 여부 결정 + if mock is not None: + use_mock = mock + else: + use_mock = get_env_bool("KIS_MOCK", True) + + # 모의투자는 MOCK 전용 키만 사용(실전 키로 폴백 안 함 → 토큰/캐시가 실전이랑 섞이지 않도록) + if use_mock: + self.app_key = get_env_from_db("KIS_APP_KEY_MOCK", "").strip() + self.app_secret = get_env_from_db("KIS_APP_SECRET_MOCK", "").strip() + if not self.app_key or not self.app_secret: + logger.error("❌ 모의투자용 APP KEY/SECRET이 DB에 없습니다. KIS_APP_KEY_MOCK, KIS_APP_SECRET_MOCK 설정 필요.") + raise ValueError("모의투자 KIS_APP_KEY_MOCK / KIS_APP_SECRET_MOCK 미설정") + else: + self.app_key = (get_env_from_db("KIS_APP_KEY_REAL", "") or get_env_from_db("KIS_APP_KEY", "")).strip() + self.app_secret = (get_env_from_db("KIS_APP_SECRET_REAL", "") or get_env_from_db("KIS_APP_SECRET", "")).strip() + + # 계좌번호: 모의/실전 분리 + if use_mock: + raw_no = get_env_from_db("KIS_ACCOUNT_NO_MOCK", "").strip() + raw_code = get_env_from_db("KIS_ACCOUNT_CODE_MOCK", "").strip() + if not raw_code: + raw_code = "01" + else: + raw_no = (get_env_from_db("KIS_ACCOUNT_NO_REAL", "") or get_env_from_db("KIS_ACCOUNT_NO", "")).strip() + raw_code = (get_env_from_db("KIS_ACCOUNT_CODE_REAL", "") or get_env_from_db("KIS_ACCOUNT_CODE", "01")).strip() + if not raw_code: + raw_code = "01" + + # 10자리면 앞 8 / 뒤 2 분리 + if len(raw_no) >= 10: + self.acc_no = raw_no[:8] + self.acc_code = raw_no[8:10] + else: + self.acc_no = raw_no + self.acc_code = raw_code[:2] if len(raw_code) >= 2 else "01" + if len(self.acc_no) != 8: + logger.warning("⚠️ 계좌번호 CANO 8자리 아님: '%s'(%s자리). DB 확인.", self.acc_no, len(self.acc_no)) + + if len(self.acc_no) != 8 or len(self.acc_code) != 2: + logger.error( + "❌ 계좌번호 형식 오류: CANO=%s(%s자리), ACNT_PRDT_CD=%s(%s자리) → OPSQ2000 발생. " + "모의면 KIS_ACCOUNT_NO_MOCK/KIS_ACCOUNT_CODE_MOCK, 실전이면 KIS_ACCOUNT_NO/KIS_ACCOUNT_CODE 확인.", + self.acc_no, len(self.acc_no), self.acc_code, len(self.acc_code) + ) + else: + logger.info("✅ 한투 계좌 CANO=%s, ACNT_PRDT_CD=%s (모의=%s)", self.acc_no, self.acc_code, use_mock) + + self.mock = use_mock + + if self.mock is True: + self.base_url = "https://openapivts.koreainvestment.com:29443" + else: + self.base_url = "https://openapi.koreainvestment.com:9443" + + self.access_token = None + logger.info("한투 API 연결: 모의=%s → %s", self.mock, self.base_url) + self._auth() + + + def _auth(self): + """접근 토큰 발급""" + if not self.app_key or not self.app_secret: + if self.mock: + key_hint = "KIS_APP_KEY_MOCK, KIS_APP_SECRET_MOCK" + else: + key_hint = "KIS_APP_KEY_REAL, KIS_APP_SECRET_REAL (또는 KIS_APP_KEY, KIS_APP_SECRET)" + logger.error("한투 API 키가 없습니다. DB env_config에 설정 필요: %s", key_hint) + raise ValueError("KIS 앱키/시크릿 설정 필요 (모의=%s)" % self.mock) + + # ✅ path를 먼저 정의 (발급 성공/실패 양쪽에서 사용) + path = KIS_TOKEN_CACHE_PATH_MOCK if self.mock else KIS_TOKEN_CACHE_PATH_REAL + mode_str = "모의" if self.mock else "실전" + + cached = _load_kis_token_cache(self.mock) + if cached: + self.access_token = cached + token_head = (cached[:8] + "…") if cached and len(cached) > 8 else "(없음)" + logger.info("한투 토큰 캐시 사용 (%s) | 파일=%s | 토큰앞8=%s", mode_str, path, token_head) + return + + # 캐시 없음/만료 → API로 새 토큰 발급 (캐시 파일 없어도 자동 발급) + appkey_tail = (self.app_key[-4:] if len(self.app_key) >= 4 else self.app_key) or "????" + logger.info( + "한투 토큰 발급 요청 (%s) | 앱키 끝4자리=%s | 저장할 캐시=%s", + mode_str, appkey_tail, path, + ) + url = f"{self.base_url}/oauth2/tokenP" + body = {"grant_type": "client_credentials", "appkey": self.app_key, "appsecret": self.app_secret} + try: + r = requests.post(url, json=body, timeout=10) + data = r.json() + if "access_token" in data: + self.access_token = data["access_token"] + expired = data.get("access_token_token_expired") or "" + _save_kis_token_cache(self.access_token, expired, self.mock) + token_head = (self.access_token[:8] + "…") if self.access_token and len(self.access_token) > 8 else "(없음)" + logger.info( + "한투 토큰 발급 완료 (%s) | 캐시=%s | 앱키끝4=%s | 토큰앞8=%s", + mode_str, path, appkey_tail, token_head, + ) + else: + logger.error("한투 토큰 발급 실패: %s", data) + if isinstance(data, dict) and data.get("error_code") == "EGW00133": + logger.warning("한투 1분당 1회 제한. 1분 후 재시도하거나 캐시 사용: %s", path) + raise RuntimeError("한투 토큰 발급 실패") + except Exception as e: + logger.error("한투 인증 예외: %s", e) + raise + + def _get_hashkey(self, body): + """해시키(Hashkey) 발급 - POST 요청 시 body 무결성 검증용""" + try: + url = f"{self.base_url}/uapi/hashkey" + headers = { + "content-type": "application/json", + "appkey": self.app_key, + "appsecret": self.app_secret, + } + r = requests.post(url, headers=headers, json=body, timeout=5) + if r.status_code == 200: + data = r.json() + if data.get("rt_cd") == "0": + return data.get("HASH") + return None + except Exception as e: + logger.debug(f"해시키 발급 실패: {e}") + return None + + def _headers(self, tr_id, hashkey=None): + """API 호출용 헤더 생성""" + headers = { + "content-type": "application/json; charset=utf-8", + "authorization": f"Bearer {self.access_token}", + "appkey": self.app_key, + "appsecret": self.app_secret, + "tr_id": tr_id, + } + if hashkey: + headers["hashkey"] = hashkey + return headers + + def _get(self, path, tr_id, params, max_retries=3, tr_cont=None): + """ + GET 요청. EGW00201(초당 거래건수 초과) 시 점진적 대기 시간 증가 재시도 + - 한투 API 제한: 초당 20개 (실제로는 더 엄격, 모의투자는 초당 2~3회 권장) + - EGW00201 감지 시: 5초 + (attempt * 1초) 대기 후 재시도 + - 기본 호출 간격: 0.5초 이상 권장 + """ + url = f"{self.base_url}{path}" + headers = self._headers(tr_id) + if tr_cont: + headers["tr_cont"] = tr_cont # 연속 조회 시 다음 페이지 요청 (한투: Response Header tr_cont=M 이면 Request Header tr_cont=N) + logger.debug(f"[API호출] GET {path} TR_ID={tr_id} params={params} tr_cont={tr_cont}") + + time.sleep(0.5) + + for attempt in range(max_retries): + try: + r = requests.get(url, headers=headers, params=params, timeout=15) + + # HTTP 429 (Too Many Requests) + if r.status_code == 429: + wait_time = 1 + (attempt * 1) # 5초, 6초, 7초... + logger.warning( + f"⏳ API 호출 제한 (429) -> {wait_time}초 대기 후 재시도 " + f"({attempt+1}/{max_retries}) path={path}" + ) + time.sleep(wait_time) + continue + + if r.status_code == 200: + j = r.json() + if j.get("rt_cd") == "0": + logger.debug(f"[API성공] GET {path} TR_ID={tr_id} status=200 rt_cd=0") + return r + # EGW00201: 초당 거래건수 초과 + elif j.get("msg_cd") == "EGW00201" or "초과" in str(j.get("msg1", "")) or "과부하" in str(j.get("msg1", "")): + wait_time = 1 + (attempt * 1) # 5초, 6초, 7초... (키움 봇 방식) + logger.warning( + f"⏳ API 과부하 (EGW00201) GET {path} TR_ID={tr_id} -> {wait_time}초 대기 후 재시도 " + f"({attempt+1}/{max_retries}) rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + ) + time.sleep(wait_time) + continue + # HTTP 200이 아니거나 rt_cd != "0"인 경우 + try: + body_preview = (r.text or "")[:500] + except Exception: + body_preview = "" + logger.warning( + f"[API실패] GET {path} TR_ID={tr_id} status={r.status_code} " + f"params={params} body={body_preview}" + ) + return r + except requests.exceptions.RequestException as e: + if attempt < max_retries - 1: + wait_time = (2 ** attempt) + random.uniform(0.5, 1.5) + logger.warning(f"⚠️ 네트워크 에러 -> {wait_time:.1f}초 대기 후 재시도: {e}") + time.sleep(wait_time) + else: + logger.error(f"❌ GET 요청 실패 ({path}): {e}") + return r + + def _post(self, path, tr_id, body, use_hashkey=True, max_retries=3): + """ + POST 요청. EGW00201(초당 거래건수 초과) 시 점진적 대기 시간 증가 재시도 + - 한투 API 제한: 초당 20개 (실제로는 더 엄격) + - EGW00201 감지 시: 5초 + (attempt * 1초) 대기 후 재시도 + """ + url = f"{self.base_url}{path}" + hashkey = None + + # API 호출 정보 디버그 로그 (민감 정보는 일부만) + body_preview = str(body)[:200] if body else "{}" + logger.debug(f"[API호출] POST {path} TR_ID={tr_id} body={body_preview}...") + + # 기본 안전 대기 (서버 부하 완화) + time.sleep(0.5) + + # 해시키 발급 (선택적이지만 보안 강화) + if use_hashkey: + hashkey = self._get_hashkey(body) + if not hashkey: + logger.debug("해시키 발급 실패, 해시키 없이 진행") + + for attempt in range(max_retries): + try: + r = requests.post(url, headers=self._headers(tr_id, hashkey), json=body, timeout=15) + + # HTTP 429 (Too Many Requests) + if r.status_code == 429: + wait_time = 5 + (attempt * 1) # 5초, 6초, 7초... + logger.warning( + f"⏳ API 호출 제한 (429) -> {wait_time}초 대기 후 재시도 " + f"({attempt+1}/{max_retries}) path={path}" + ) + time.sleep(wait_time) + # 해시키 재발급 + if use_hashkey: + hashkey = self._get_hashkey(body) + continue + + if r.status_code == 200: + j = r.json() + if j.get("rt_cd") == "0": + logger.debug(f"[API성공] POST {path} TR_ID={tr_id} status=200 rt_cd=0") + return r + # EGW00201: 초당 거래건수 초과 + elif j.get("msg_cd") == "EGW00201" or "초과" in str(j.get("msg1", "")) or "과부하" in str(j.get("msg1", "")): + wait_time = 5 + (attempt * 1) # 5초, 6초, 7초... (키움 봇 방식) + logger.warning( + f"⏳ API 과부하 (EGW00201) POST {path} TR_ID={tr_id} -> {wait_time}초 대기 후 재시도 " + f"({attempt+1}/{max_retries}) rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + ) + time.sleep(wait_time) + if use_hashkey: + hashkey = self._get_hashkey(body) + continue + # HTTP 200이 아니거나 rt_cd != "0"인 경우 + try: + body_preview = (r.text or "")[:500] + except Exception: + body_preview = "" + logger.warning( + f"[API실패] POST {path} TR_ID={tr_id} status={r.status_code} " + f"body={body_preview}" + ) + return r + except requests.exceptions.RequestException as e: + if attempt < max_retries - 1: + wait_time = (2 ** attempt) + random.uniform(0.5, 1.5) + logger.warning(f"⚠️ 네트워크 에러 -> {wait_time:.1f}초 대기 후 재시도: {e}") + time.sleep(wait_time) + else: + logger.error(f"❌ POST 요청 실패 ({path}): {e}") + return r + + def inquire_price(self, stock_code): + """ + 주식 현재가 시세 조회 [v1_국내주식-008] (단건). + output: stck_prpr(현재가) ✅, stck_oprc(시가), stck_hgpr(고가), stck_lwpr(저가) 등 당일 OHLC 포함. + 실패 시 오류코드(rt_cd, msg_cd, msg1) 로깅. + """ + path = "/uapi/domestic-stock/v1/quotations/inquire-price" + tr_id = "FHKST01010100" + params = {"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": stock_code} + logger.debug(f"[현재가API] 호출 code={stock_code} path={path} TR_ID={tr_id}") + r = self._get(path, tr_id, params) + if r.status_code != 200: + try: + body_preview = (r.text or "")[:300] + except Exception: + body_preview = "" + logger.warning( + f"[현재가API] HTTP 실패 code={stock_code} path={path} TR_ID={tr_id} " + f"status={r.status_code} body={body_preview}" + ) + return None + try: + j = r.json() + except Exception as e: + logger.warning( + f"[현재가API] JSON 파싱 실패 code={stock_code} path={path} TR_ID={tr_id} exception={e}" + ) + return None + if j.get("rt_cd") != "0": + logger.warning( + f"[현재가API] 오류 code={stock_code} path={path} TR_ID={tr_id} " + f"rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + ) + return None + return j.get("output") + + def inquire_multprice(self, stock_codes: List[str], max_per_call: int = 20): + """ + 다중 종목 현재가 조회 [intstock-multprice] + - 한투 API: /uapi/domestic-stock/v1/quotations/intstock-multprice + - 성공 시 {종목코드: output딕셔너리} 반환, 실패 시 None (오류 시 rt_cd/msg_cd/msg1 로깅) + - TR_ID: FHKST01010600 + - ⚠️ 배치 응답에는 stck_oprc(시가)/stck_hgpr(고가)/stck_lwpr(저가)가 없을 수 있음(API 스펙). + 시가·고가·저가 필요 시 단건 inquire_price() 사용. + """ + if not stock_codes: + return None + codes = list(stock_codes)[: max_per_call * 10] + result = {} + for i in range(0, len(codes), max_per_call): + chunk = codes[i : i + max_per_call] + iscd = ",".join(chunk) + path = "/uapi/domestic-stock/v1/quotations/intstock-multprice" + tr_id = "FHKST01010600" + params = {"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": iscd} + r = self._get(path, tr_id, params) + if r.status_code != 200: + try: + body_preview = (r.text or "")[:300] + except Exception: + body_preview = "" + logger.warning( + f"[다중시세API] HTTP 실패 status={r.status_code} body={body_preview}" + ) + continue + try: + j = r.json() + except Exception as e: + logger.warning(f"[다중시세API] JSON 파싱 실패 exception={e}") + continue + if j.get("rt_cd") != "0": + logger.warning( + f"[다중시세API] 오류 rt_cd={j.get('rt_cd')} " + f"msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + ) + continue + out = j.get("output") + if out is None: + continue + if isinstance(out, list): + for item in out: + if isinstance(item, dict): + code = ( + item.get("stck_shrn_iscd") + or item.get("rsym") + or item.get("FID_INPUT_ISCD") + or item.get("mksc_shrn_iscd") + ) + if code: + result[code] = item + elif isinstance(out, dict): + code = ( + out.get("stck_shrn_iscd") + or out.get("rsym") + or out.get("FID_INPUT_ISCD") + ) + if code: + result[code] = out + time.sleep(random.uniform(0.2, 0.5)) + return result if result else None + + def inquire_prices_batch(self, stock_codes: List[str]): + """ + 다중 종목 현재가 일괄 조회 + - intstock-multprice API 우선 시도 후, 실패 시 순차 조회(inquire_price)로 fallback + - 순차 조회 시 종목당 0.3~0.6초 딜레이 + - 배치 응답에는 stck_prpr(현재가)는 있으나, stck_oprc/stck_hgpr/stck_lwpr 는 없을 수 있음. + 시가·고가·저가 필요 시 배치 대신 종목별 inquire_price() 사용(개미털기 스캔은 이미 단건 사용). + """ + if not stock_codes: + return {} + multi = self.inquire_multprice(stock_codes) + if multi: + return multi + result = {} + for code in stock_codes: + try: + price_data = self.inquire_price(code) + if price_data: + result[code] = price_data + time.sleep(random.uniform(0.3, 0.6)) + except Exception as e: + logger.warning(f"종목 조회 실패({code}) exception={e!r}") + continue + return result + + def get_account_balance(self): + """계좌 잔고 조회 [v1_국내주식-010]. 모의/실전에 따라 TR ID 분기 (EGW2004 방지).""" + if self.mock: + tr_id = "VTTC8434R" + else: + tr_id = "TTTC8434R" + params = { + "CANO": self.acc_no, + "ACNT_PRDT_CD": self.acc_code, + "AFHR_FLPR_YN": "N", + "OFL_YN": "", + "INQR_DVSN": "01", # 01: 예수금/잔고 요약 (output2에 예수금) - 블로그·한투 문서 기준 + "UNPR_DVSN": "01", + "FUND_STTL_ICLD_YN": "N", + "FNCG_AMT_AUTO_RDPT_YN": "N", + "PRCS_DVSN": "00", # 00: 조회 (블로그 기준) + "CTX_AREA_FK100": "", + "CTX_AREA_NK100": "", + } + try: + logger.info(f"💵 [예수금] 잔고 조회 요청: TR={tr_id}, CANO={self.acc_no}, ACNT_PRDT_CD={self.acc_code}, 모의={self.mock}") + r = self._get( + "/uapi/domestic-stock/v1/trading/inquire-balance", + tr_id, + params, + ) + if r.status_code != 200: + logger.warning( + f"💵 [예수금] 잔고 API HTTP 오류: status={r.status_code}, body={getattr(r, 'text', '')[:200]} | " + f"TR={tr_id} (모의={self.mock}), CANO={self.acc_no}, ACNT_PRDT_CD={self.acc_code}. " + f"EGW2004 시 모의면 VTTC8434R/실전이면 TTTC8434R 확인" + ) + return None + j = r.json() + if j.get("rt_cd") != "0": + msg1 = (j.get("msg1") or "")[:150] + msg_cd = j.get("msg_cd", "") + logger.error( + f"💵 [예수금] 잔고 API 응답 오류: rt_cd={j.get('rt_cd')}, msg_cd={msg_cd}, msg1={msg1} | " + f"요청 파라미터: TR={tr_id}, CANO={self.acc_no}({len(self.acc_no)}자리), " + f"ACNT_PRDT_CD={self.acc_code}({len(self.acc_code)}자리), 모의={self.mock} | " + f"전체 응답: {j}" + ) + if "OPSQ2000" in str(msg_cd) or "INVALID_CHECK_ACNO" in msg1: + logger.error( + "💵 [예수금] OPSQ2000 = 계좌번호 검증 실패. " + f"모의투자 서버({self.base_url})에 계좌번호 CANO={self.acc_no}, ACNT_PRDT_CD={self.acc_code}가 등록되어 있는지 확인. " + f"한투 모의투자 앱/웹에서 계좌번호 확인 필요. DB의 KIS_ACCOUNT_NO_MOCK/KIS_ACCOUNT_CODE_MOCK 값 확인." + ) + return None + logger.debug(f"💵 [예수금] 잔고 조회 성공: output2 keys={list(j.get('output2', [{}])[0].keys()) if isinstance(j.get('output2'), list) and j.get('output2') else []}") + return j + except Exception as e: + logger.error(f"💵 [예수금] 잔고 조회 예외: {e} | CANO={self.acc_no}, ACNT_PRDT_CD={self.acc_code}, 모의={self.mock}") + return None + + def get_account_evaluation(self): + """계좌 평가 잔고 조회 [v1_국내주식-011]. 모의=VTTC8494R, 실전=TTTC8494R.""" + if self.mock: + tr_id = "VTTC8494R" + else: + tr_id = "TTTC8494R" + try: + logger.info(tr_id) + r = self._get( + "/uapi/domestic-stock/v1/trading/inquire-balance-rlz-pl", + tr_id, + { + "CANO": self.acc_no, + "ACNT_PRDT_CD": self.acc_code, + "AFHR_FLPR_YN": "N", + "OFL_YN": "", + "INQR_DVSN": "01", + "UNPR_DVSN": "01", + "FUND_STTL_ICLD_YN": "N", + "FNCG_AMT_AUTO_RDPT_YN": "N", + "PRCS_DVSN": "01", + "CTX_AREA_FK100": "", + "CTX_AREA_NK100": "", + }, + ) + if r.status_code != 200: + return None + j = r.json() + if j.get("rt_cd") != "0": + return None + return j + except Exception as e: + logger.error(f"계좌 평가 조회 실패: {e}") + return None + + def get_order_history(self, start_date=None, end_date=None): + """주문 체결 내역 조회 [v1_국내주식-012] (모의=VTTC8001R, 실전=TTTC8001R)""" + try: + if self.mock: + tr_id = "VTTC8001R" + else: + tr_id = "TTTC8001R" + if not start_date: + start_date = dt.now().strftime("%Y%m%d") + if not end_date: + end_date = dt.now().strftime("%Y%m%d") + + r = self._get( + "/uapi/domestic-stock/v1/trading/inquire-daily-ccld", + tr_id, + { + "CANO": self.acc_no, + "ACNT_PRDT_CD": self.acc_code, + "INQR_STRT_DT": start_date, + "INQR_END_DT": end_date, + "SLL_BUY_DVSN_CD": "00", # 00:전체 + "INQR_DVSN": "00", # 00:역순 + "PDNO": "", + "CCLD_DVSN": "00", # 00:전체 + "ORD_GNO_BRNO": "", + "ODNO": "", + "INQR_DVSN_3": "00", + "INQR_DVSN_1": "", + "CTX_AREA_FK100": "", + "CTX_AREA_NK100": "", + }, + ) + if r.status_code != 200: + return None + j = r.json() + if j.get("rt_cd") != "0": + return None + return j + except Exception as e: + logger.error(f"주문 내역 조회 실패: {e}") + return None + + def get_volume_surge_stocks(self, market="J", min_volume_rate="50", limit=50): + """거래량 급증 종목 조회 [v1_국내주식-023]""" + try: + r = self._get( + "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice", + "FHKST03010200", + { + "FID_COND_MRKT_DIV_CODE": market, + "FID_INPUT_ISCD": "", + "FID_INPUT_HOUR_1": dt.now().strftime("%Y%m%d"), + "FID_INPUT_HOUR_2": dt.now().strftime("%Y%m%d"), + "FID_PW_DATA_INCU_YN": "Y", + }, + ) + # 실제로는 거래량 급증 API를 사용해야 하지만, 여기서는 예시로 현재가 조회 활용 + # 실제 구현 시: /uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice 사용 + return [] + except Exception as e: + logger.error(f"거래량 급증 종목 조회 실패: {e}") + return [] + + def get_top_price_movers(self, market="J", sort_type="1", limit=50): + """등락률 상위 종목 조회 [v1_국내주식-027]""" + try: + r = self._get( + "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice", + "FHKST03010200", + { + "FID_COND_MRKT_DIV_CODE": market, + "FID_INPUT_ISCD": "", + "FID_INPUT_HOUR_1": dt.now().strftime("%Y%m%d"), + "FID_INPUT_HOUR_2": dt.now().strftime("%Y%m%d"), + "FID_PW_DATA_INCU_YN": "Y", + }, + ) + # 실제 구현 필요 + return [] + except Exception as e: + logger.error(f"등락률 상위 조회 실패: {e}") + return [] + + def get_investor_trend(self, stock_code, days=5): + """외국인/기관 매매 동향 조회""" + path = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice" + tr_id = "FHKST03010100" + try: + # 일봉 데이터에서 외국인/기관 정보 추출 + end_dt = dt.now() + start_dt = end_dt - datetime.timedelta(days=days + 10) + start_ymd = start_dt.strftime("%Y%m%d") + end_ymd = end_dt.strftime("%Y%m%d") + + params = { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": stock_code, + "FID_INPUT_DATE_1": start_ymd, + "FID_INPUT_DATE_2": end_ymd, + "FID_PERIOD_DIV_CODE": "D", + "FID_ORG_ADJ_PRC": "1", + } + logger.debug(f"[투자자동향] 호출 code={stock_code} path={path} TR_ID={tr_id}") + r = self._get(path, tr_id, params) + if r.status_code != 200: + logger.warning(f"[투자자동향] HTTP 실패 code={stock_code} path={path} TR_ID={tr_id} status={r.status_code}") + return None + + j = r.json() + if j.get("rt_cd") != "0": + logger.warning( + f"[투자자동향] 오류 code={stock_code} path={path} TR_ID={tr_id} " + f"rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + ) + return None + + out2 = j.get("output2", []) + if not out2: + return None + + # 최근 N일 외국인/기관 순매수 합계 + foreign_sum = 0 + org_sum = 0 + + for item in out2[:days]: + try: + foreign_raw = item.get("frgn_ntby_qty") or item.get("frgn_ntby_shnu") or "0" + foreign_net = int(float(str(foreign_raw).replace(",", "").replace("+", "").replace("-", ""))) + if str(foreign_raw).startswith("-"): + foreign_net = -foreign_net + + org_raw = item.get("orgn_ntby_qty") or "0" + org_net = int(float(str(org_raw).replace(",", "").replace("+", "").replace("-", ""))) + if str(org_raw).startswith("-"): + org_net = -org_net + + foreign_sum += foreign_net + org_sum += org_net + except: + continue + + return { + "foreign_net_buy": foreign_sum, + "org_net_buy": org_sum, + "total_net_buy": foreign_sum + org_sum, + } + except Exception as e: + logger.error(f"외국인/기관 동향 조회 실패({stock_code}): {e}") + return None + + def get_daily_chart(self, code, limit=10): + """일봉 차트 조회 [v1_국내주식-017] - 거래대금(대/중/소형) 계산용""" + path = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice" + tr_id = "FHKST03010100" + try: + end_dt = dt.now() + start_dt = end_dt - datetime.timedelta(days=limit + 30) + start_ymd = start_dt.strftime("%Y%m%d") + end_ymd = end_dt.strftime("%Y%m%d") + params = { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": code, + "FID_INPUT_DATE_1": start_ymd, + "FID_INPUT_DATE_2": end_ymd, + "FID_PERIOD_DIV_CODE": "D", + "FID_ORG_ADJ_PRC": "1", + } + logger.debug(f"[일봉차트] 호출 code={code} path={path} TR_ID={tr_id}") + r = self._get(path, tr_id, params) + if r.status_code != 200: + logger.warning(f"[일봉차트] HTTP 실패 code={code} path={path} TR_ID={tr_id} status={r.status_code}") + return pd.DataFrame() + j = r.json() + if j.get("rt_cd") != "0": + logger.warning( + f"[일봉차트] 오류 code={code} path={path} TR_ID={tr_id} " + f"rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + ) + return pd.DataFrame() + data = j.get("output2", []) + if not data: + return pd.DataFrame() + rows = [] + for item in data: + try: + rows.append({ + "date": item.get("stck_bsop_date", ""), + "open": abs(float(item.get("stck_oprc", 0))), + "high": abs(float(item.get("stck_hgpr", 0))), + "low": abs(float(item.get("stck_lwpr", 0))), + "close": abs(float(item.get("stck_clpr", 0))), + "volume": int(item.get("acml_vol", 0)), + }) + except Exception: + continue + if not rows: + return pd.DataFrame() + df = pd.DataFrame(rows) + df = df.sort_values("date").reset_index(drop=True) + return df.tail(limit) + except Exception as e: + logger.error(f"일봉 조회 실패({code}): {e}") + return pd.DataFrame() + + def buy_order(self, code, qty, price=0, order_type="01"): + """ + 매수 주문 (모의=VTTC0802U, 실전=TTTC0802U) + + Args: + code: 종목코드 + qty: 수량 + price: 가격 (0이면 시장가) + order_type: 주문구분 + - "01": 시장가 + - "00": 지정가 + - "05": 조건부지정가 + - "06": 최유리지정가 + - "07": 최우선지정가 + - "10": 보통(IOC) + - "13": 시장가(IOC) + - "16": 최유리(IOC) + - "20": 보통(FOK) + - "23": 시장가(FOK) + - "26": 최유리(FOK) + """ + try: + if self.mock: + tr_id = "VTTC0802U" + else: + tr_id = "TTTC0802U" + if price > 0: + ord_unpr = str(price) + else: + ord_unpr = "0" + path = "/uapi/domestic-stock/v1/trading/order-cash" + body = { + "CANO": self.acc_no, + "ACNT_PRDT_CD": self.acc_code, + "PDNO": code, + "ORD_DVSN": order_type, + "ORD_QTY": str(qty), + "ORD_UNPR": ord_unpr, + } + logger.debug(f"[매수주문] 호출 code={code} qty={qty} price={price} path={path} TR_ID={tr_id}") + r = self._post(path, tr_id, body, use_hashkey=True) + if r.status_code != 200: + logger.error(f"[매수주문] HTTP 에러 code={code} path={path} TR_ID={tr_id} status={r.status_code}") + return False + j = r.json() + if j.get("rt_cd") == "0": + ord_no = j.get("output", {}).get("ODNO", "") + logger.info(f"✅ 매수 주문 성공: {code} {qty}주 (주문번호: {ord_no})") + return True + else: + logger.error( + f"[매수주문] 실패 code={code} path={path} TR_ID={tr_id} " + f"rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + ) + return False + except Exception as e: + logger.error(f"매수 주문 예외({code}): {e}") + return False + + def buy_market_order(self, code, qty): + """시장가 매수 주문 (간편 메서드)""" + return self.buy_order(code, qty, price=0, order_type="01") + + def sell_order(self, code, qty, price=0, order_type="01"): + """ + 매도 주문 (모의=VTTC0801U, 실전=TTTC0801U) + + Args: + code: 종목코드 + qty: 수량 + price: 가격 (0이면 시장가) + order_type: 주문구분 (buy_order와 동일) + """ + try: + if self.mock: + tr_id = "VTTC0801U" + else: + tr_id = "TTTC0801U" + if price > 0: + ord_unpr = str(price) + else: + ord_unpr = "0" + path = "/uapi/domestic-stock/v1/trading/order-cash" + body = { + "CANO": self.acc_no, + "ACNT_PRDT_CD": self.acc_code, + "PDNO": code, + "ORD_DVSN": order_type, + "ORD_QTY": str(qty), + "ORD_UNPR": ord_unpr, + } + logger.debug(f"[매도주문] 호출 code={code} qty={qty} price={price} path={path} TR_ID={tr_id}") + r = self._post(path, tr_id, body, use_hashkey=True) + if r.status_code != 200: + logger.error(f"[매도주문] HTTP 에러 code={code} path={path} TR_ID={tr_id} status={r.status_code}") + return False + j = r.json() + if j.get("rt_cd") == "0": + ord_no = j.get("output", {}).get("ODNO", "") + logger.info(f"✅ 매도 주문 성공: {code} {qty}주 (주문번호: {ord_no})") + return True + else: + logger.error( + f"[매도주문] 실패 code={code} path={path} TR_ID={tr_id} " + f"rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + ) + return False + except Exception as e: + logger.error(f"매도 주문 예외({code}): {e}") + return False + + def sell_market_order(self, code, qty): + """시장가 매도 주문 (간편 메서드)""" + return self.sell_order(code, qty, price=0, order_type="01") + + def get_minute_chart(self, code, period="3", limit=100): + """분봉 차트 조회 [v1_국내주식-017]""" + path = "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice" + tr_id = "FHKST03010200" + try: + end_dt = dt.now() + start_dt = end_dt - datetime.timedelta(days=1) + start_ymd = start_dt.strftime("%Y%m%d") + end_ymd = end_dt.strftime("%Y%m%d") + + # 분봉 코드: 1분=1, 3분=3, 5분=5, 10분=10, 30분=30, 60분=60 + period_map = {"1": "1", "3": "3", "5": "5", "10": "10", "30": "30", "60": "60"} + period_code = period_map.get(str(period), "3") + + params = { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": code, + "FID_INPUT_HOUR_1": start_ymd, + "FID_INPUT_HOUR_2": end_ymd, + "FID_PW_DATA_INCU_YN": "Y", + "FID_ETC_CLS_CODE": "", # 기타분류코드 (필수 파라미터, 빈 값 가능) + } + logger.debug(f"[분봉차트] 호출 code={code} period={period} path={path} TR_ID={tr_id}") + r = self._get(path, tr_id, params) + if r.status_code != 200: + logger.warning(f"[분봉차트] HTTP 실패 code={code} path={path} TR_ID={tr_id} status={r.status_code}") + return pd.DataFrame() + j = r.json() + if j.get("rt_cd") != "0": + logger.warning( + f"[분봉차트] 오류 code={code} path={path} TR_ID={tr_id} " + f"rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + ) + return pd.DataFrame() + + data = j.get("output2", []) + if not data: + return pd.DataFrame() + + rows = [] + for item in data: + try: + rows.append({ + "time": item.get("stck_bsop_date", ""), + "open": abs(float(item.get("stck_oprc", 0))), + "high": abs(float(item.get("stck_hgpr", 0))), + "low": abs(float(item.get("stck_lwpr", 0))), + "close": abs(float(item.get("stck_clpr", 0))), + "volume": int(item.get("acml_vol", 0)), + }) + except: + continue + + if not rows: + return pd.DataFrame() + + df = pd.DataFrame(rows) + df = df.sort_values("time").reset_index(drop=True) + + # 기술적 지표 추가 + if len(df) >= 14: + delta = df["close"].diff(1) + gain = delta.where(delta > 0, 0).rolling(window=14).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean() + rs = gain / loss.replace(0, float("nan")) + df["RSI"] = 100 - (100 / (1 + rs)) + + if len(df) >= 20: + df["MA20"] = df["close"].rolling(window=20).mean() + + return df.tail(limit) + except Exception as e: + logger.error(f"분봉 조회 실패({code}): {e}") + return pd.DataFrame() + + def get_orderbook(self, stock_code): + """호가 조회 [v1_국내주식-009]""" + try: + r = self._get( + "/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn", + "FHKST01010200", + { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": stock_code, + }, + ) + if r.status_code != 200: + return None + j = r.json() + if j.get("rt_cd") != "0": + return None + return j.get("output") + except Exception as e: + logger.error(f"호가 조회 실패({stock_code}): {e}") + return None + + # ============================================================ + # 순위분석 API (키움 봇과 동일한 로직 + 레버리지/스팩/ETN 제외 옵션) + # ============================================================ + + @staticmethod + def _is_valid_stock(name: str, code: str) -> bool: + """ + 종목 필터링 (키움 kiwoom_trader_ver2와 동일, ETF는 포함) + - 스팩, ETN, 우선주, 레버리지, 인버스 등만 제외 (ETF는 위험도 낮아 포함) + """ + if not code or len(code) != 6 or not code.isdigit(): + return False + name = (name or "").strip() + exclude = [ + "스팩", "SPAC", "ETN", "W", "ELW", "채권", + "레버리지", "인버스", "곱버스", "선물", "콜", "풋", + "2X", "3X", "합성", "H", "B", + ] + # ETF는 exclude 목록에 없음 → 일반 주식·ETF 모두 통과 + if any(k in name for k in exclude): + return False + if name.endswith("우") or name.endswith("우B"): + return False + return True + + def _filter_rank_by_valid_stock(self, rank_list: list) -> list: + """랭크 API 응답 리스트에서 스팩/ETN/레버리지 등 제외 (키움 옵션과 동일)""" + if not rank_list: + return [] + filtered = [] + for item in rank_list: + code = (item.get("stk_cd") or item.get("mksc_shrn_iscd") or item.get("code") or "").strip() + name = (item.get("stk_nm") or item.get("prst_name") or item.get("hts_kor_isnm") or "").strip() + if self._is_valid_stock(name, code): + filtered.append(item) + return filtered + + def _fetch_volume_rank_paged( + self, + market: str, + blng_cls_code: str, + limit: int, + exclude_spec_etn_leverage: bool, + ) -> list: + """ + 거래량순위 API 연속 조회 (tr_cont)로 limit건까지 수집. + API가 한 번에 20~30건만 주므로, tr_cont='M'이면 다음 페이지 요청 반복. + """ + path = "/uapi/domestic-stock/v1/quotations/volume-rank" + tr_id = "FHPST01710000" + base = { + "FID_COND_MRKT_DIV_CODE": market, + "FID_COND_SCR_DIV_CODE": "20171", + "FID_INPUT_ISCD": "0000", + "FID_DIV_CLS_CODE": "0", + "FID_BLNG_CLS_CODE": blng_cls_code, + "FID_TRGT_CLS_CODE": "111111111", + "FID_TRGT_EXLS_CLS_CODE": "0000000000", + "FID_INPUT_PRICE_1": "0", + "FID_INPUT_PRICE_2": "0", + "FID_VOL_CNT": "0", + "FID_INPUT_DATE_1": "", + } + accumulated = [] + tr_cont = "" + max_pages = 20 # 20페이지 이상이면 중단 (과부하 방지) + page = 0 + try: + while len(accumulated) < limit and page < max_pages: + params = {**base} + time.sleep(0.5) + # 연속 조회 시 tr_cont는 요청 헤더로 전달 (한투 문서: Request Header tr_cont=N) + r = self._get(path, tr_id, params, tr_cont=tr_cont if tr_cont else None) + if r.status_code != 200: + break + j = r.json() + if j.get("rt_cd") != "0": + break + output = j.get("output", []) + if exclude_spec_etn_leverage: + output = self._filter_rank_by_valid_stock(output) + # 2차 이상 수신 시: 이번 output이 이미 누적된 종목과 완전 동일하면 서버가 같은 페이지를 반복 준 것 → 중복 누적·추가 요청 중단 + def _codes_from_list(lst): + s = set() + for item in lst: + c = (item.get("stk_cd") or item.get("mksc_shrn_iscd") or item.get("code") or "").strip() + if c: + s.add(c) + return s + if page >= 1 and output: + already = _codes_from_list(accumulated) + this_codes = _codes_from_list(output) + if this_codes and this_codes <= already: + logger.info(f" 📡 [순위API] 2차 수신 {len(output)}건은 1차와 동일(중복) → 연속조회 중단 (API가 다음 페이지 미지원 또는 동일 데이터 반환)") + break + accumulated.extend(output) + # 연속 조회: tr_cont는 HTTP 응답 헤더에 있음 (한투 문서). 소문자/대문자 모두 확인 + tr_cont_resp = "" + for k, v in (r.headers or {}).items(): + if k.strip().lower() == "tr_cont" and v: + tr_cont_resp = v.strip() if isinstance(v, str) else str(v) + break + if not tr_cont_resp: + tr_cont_resp = (r.headers.get("tr_cont") or r.headers.get("TR_CONT") or "").strip() + if isinstance(tr_cont_resp, str): + tr_cont_resp = tr_cont_resp.strip() + # 페이지네이션 동작 확인용 INFO 로그 (기본 로그 레벨에서 보이도록) + header_keys = list((r.headers or {}).keys()) + logger.info(f" 📡 [순위API] 1차 수신 {len(output)}건, 누적 {len(accumulated)}건 | 응답 tr_cont='{tr_cont_resp}' | 헤더키: {header_keys}") + # tr_cont=M이면 다음 페이지 있음. 또는 첫 페이지에서 누적이 limit 미만이면 한 번 더 시도 (서버가 tr_cont 없이도 다음 페이지 지원하는 경우 대비) + if tr_cont_resp == "M": + tr_cont = "N" + page += 1 + logger.info(f" 📡 [연속조회] tr_cont=M → tr_cont=N으로 다음 페이지 요청 (페이지 {page})") + elif page == 0 and len(output) > 0 and len(accumulated) < limit: + tr_cont = "N" + page += 1 + logger.info(f" 📡 [연속조회] 누적 {len(accumulated)}건 < {limit}건 → tr_cont=N으로 다음 페이지 1회 시도") + else: + break + time.sleep(random.uniform(0.8, 1.5)) + return accumulated[:limit] + except Exception as e: + logger.debug(f"거래량순위 연속 조회 실패: {e}") + return accumulated[:limit] + + def get_volume_rank( + self, + market: str = "J", + limit: int = 50, + exclude_spec_etn_leverage: bool = True, + ): + """ + 거래량순위 조회 [v1_국내주식-047] (연속 조회로 limit건까지 수집) + """ + try: + output = self._fetch_volume_rank_paged( + market=market, + blng_cls_code="0", + limit=limit, + exclude_spec_etn_leverage=exclude_spec_etn_leverage, + ) + return output + except Exception as e: + logger.debug(f"거래량순위 조회 실패: {e}") + return [] + + def get_price_change_rank( + self, + market: str = "J", + sort_type: str = "1", + limit: int = 50, + exclude_spec_etn_leverage: bool = True, + ): + """ + 등락률순위 조회 (동일 volume-rank API, FID_BLNG_CLS_CODE로 등락 구분) + sort_type: "1"=상승률 상위, "2"=하락률 상위(낙폭 큰 종목, N자 망치봉 스캔에 유리) + """ + # 한투 volume-rank API: 4=등락률(상승), 5=등락률(하락). 미지원 시 빈값/에러 가능. + blng = "5" if sort_type == "2" else "4" + try: + out = self._fetch_volume_rank_paged( + market=market, + blng_cls_code=blng, + limit=limit, + exclude_spec_etn_leverage=exclude_spec_etn_leverage, + ) + if out: + return out + except Exception as e: + logger.debug(f"등락률순위(blng={blng}) 조회 실패: {e}") + # API가 4/5 미지원이면 기존처럼 거래량순위로 fallback (상승 위주 후보 확보) + if sort_type != "2": + return self.get_volume_rank(market=market, limit=limit, exclude_spec_etn_leverage=exclude_spec_etn_leverage) + return [] + + def get_price_decline_rank( + self, + market: str = "J", + limit: int = 100, + exclude_spec_etn_leverage: bool = True, + ): + """하락률 순위(낙폭 큰 종목) 조회. N자 망치봉/개미털기 스캔 유니버스 확대용.""" + return self.get_price_change_rank( + market=market, + sort_type="2", + limit=limit, + exclude_spec_etn_leverage=exclude_spec_etn_leverage, + ) + + def get_trading_value_rank( + self, + market: str = "J", + limit: int = 50, + exclude_spec_etn_leverage: bool = True, + ): + """거래대금순위 조회 (연속 조회로 limit건까지). FID_BLNG_CLS_CODE=3""" + try: + return self._fetch_volume_rank_paged( + market=market, + blng_cls_code="3", + limit=limit, + exclude_spec_etn_leverage=exclude_spec_etn_leverage, + ) + except Exception as e: + logger.debug(f"거래대금순위 조회 실패: {e}") + return [] + + def get_turnover_rank( + self, + market: str = "J", + limit: int = 50, + exclude_spec_etn_leverage: bool = True, + ): + """회전율순위 조회 (연속 조회로 limit건까지). FID_BLNG_CLS_CODE=2""" + try: + return self._fetch_volume_rank_paged( + market=market, + blng_cls_code="2", + limit=limit, + exclude_spec_etn_leverage=exclude_spec_etn_leverage, + ) + except Exception as e: + logger.debug(f"회전율순위 조회 실패: {e}") + return [] + + def get_volume_growth_rank( + self, + market: str = "J", + limit: int = 50, + exclude_spec_etn_leverage: bool = True, + ): + """거래증가율순위 조회 (연속 조회로 limit건까지). FID_BLNG_CLS_CODE=1""" + try: + return self._fetch_volume_rank_paged( + market=market, + blng_cls_code="1", + limit=limit, + exclude_spec_etn_leverage=exclude_spec_etn_leverage, + ) + except Exception as e: + logger.debug(f"거래증가율순위 조회 실패: {e}") + return [] + + def get_execution_strength_rank( + self, + market: str = "J", + limit: int = 200, + exclude_spec_etn_leverage: bool = True, + ): + """체결강도 상위 순위 조회 (FHPST01710000, FID_BLNG_CLS_CODE=6). 매수세 강한 종목 필터용.""" + try: + return self._fetch_volume_rank_paged( + market=market, + blng_cls_code="6", + limit=limit, + exclude_spec_etn_leverage=exclude_spec_etn_leverage, + ) + except Exception as e: + logger.debug(f"체결강도순위 조회 실패: {e}") + return [] + + def get_execution_strength_map(self, market: str = "J", limit: int = 200): + """ + 체결강도 상위 API 조회 후 종목코드 -> 체결강도 값 매핑 반환. + output 필드: cntr_str(체결강도) 등 한투 문서 기준으로 파싱. 미제공 시 0. + """ + strength_map = {} + try: + rows = self.get_execution_strength_rank(market=market, limit=limit, exclude_spec_etn_leverage=True) + for item in (rows or []): + code = (item.get("stk_cd") or item.get("mksc_shrn_iscd") or item.get("code") or "").strip() + if not code or len(code) != 6: + continue + # 한투 volume-rank 체결강도: cntr_str 또는 유사 필드 (문서 확인 후 조정) + raw = item.get("cntr_str") or item.get("exec_str") or item.get("strg_rt") or item.get("prdy_ctrt") or "" + try: + strength = float(str(raw).replace(",", "").strip()) if raw else 0 + except (ValueError, TypeError): + strength = 0 + strength_map[code] = strength + except Exception as e: + logger.debug(f"체결강도 맵 조회 실패: {e}") + return strength_map + + +# ============================================================ +# Mattermost 봇 클래스 +# ============================================================ +class MattermostBot: + """Mattermost 알림 봇""" + def __init__(self): + self.api_url = f"{MM_SERVER_URL.rstrip('/')}/api/v4/posts" + self.headers = { + "Authorization": f"Bearer {MM_BOT_TOKEN}", + "Content-Type": "application/json" + } + self.channels = self._load_channels() + + def _load_channels(self): + """채널 설정 로드""" + try: + if MM_CONFIG_FILE.exists(): + with open(MM_CONFIG_FILE, 'r', encoding='utf-8') as f: + return json.load(f).get("channels", {}) + return {} + except Exception as e: + logger.error(f"⚠️ MM 설정 로드 실패: {e}") + return {} + + def send(self, channel_alias, message): + """메시지 전송""" + channel_id = self.channels.get(channel_alias) + if not channel_id: + logger.warning(f"❌ '{channel_alias}' 채널 ID 없음") + return False + + payload = {"channel_id": channel_id, "message": message} + try: + res = requests.post(self.api_url, headers=self.headers, json=payload, timeout=3) + res.raise_for_status() + return True + except Exception as e: + logger.error(f"❌ MM 전송 에러: {e}") + return False + + +# ============================================================ +# 단타 트레이딩 봇 +# ============================================================ +class ShortTradingBot: + """단타용 트레이딩 봇 - 개미털기(눌림목) 전략""" + def __init__(self): + self.db = db + self.client = KISClient() + + # Mattermost 초기화 + self.mm = MattermostBot() + # 단타 봇 전용 채널(alias) 우선 사용, 없으면 기본 채널 사용 + self.mm_channel = MM_CHANNEL_SHORT + + # ML 예측 초기화 (선택적) + self.ml_predictor = None + if ML_AVAILABLE: + try: + self.ml_predictor = MLPredictor(db_path=str(SCRIPT_DIR / "quant_bot.db")) + if self.ml_predictor.should_retrain(): + self.ml_predictor.train_model(retrain=True) + except Exception as e: + logger.warning(f"⚠️ ML 예측 초기화 실패: {e}") + + # 전략 파라미터 (DB·env 연동) + # ============================================================ + # [손절/익절 설정] - 대형주 기준 현실적인 값 + # ============================================================ + # 손절 라인: -4% (주가 기준, 대형주는 노이즈 감안해 넉넉히) + self.stop_loss_pct = get_env_float("STOP_LOSS_PCT", -0.04) # -4% 손절 (대형주 기준) + self.take_profit_pct = get_env_float("TAKE_PROFIT_PCT", 0.05) + self.max_stocks = get_env_int("MAX_STOCKS", 3) + # 개미털기 필터: 낙폭 3% 이상 + 회복률 50% 이상 통과 (후보가 너무 적으면 MIN_DROP_RATE=0.02, MIN_RECOVERY_RATIO_SHORT=0.32 등 완화) + self.min_drop_rate = get_env_float("MIN_DROP_RATE", 0.03) + self.min_recovery_ratio = get_env_float("MIN_RECOVERY_RATIO_SHORT", 0.5) + + # ATR 기반 손절/익절 배수 (변동성 기반 동적 손절가/목표가) + self.stop_atr_multiplier = get_env_float("STOP_ATR_MULTIPLIER_TAIL", 2.5) + self.target_atr_multiplier = get_env_float("TARGET_ATR_MULTIPLIER_TAIL", 8.0) + + # 24시간 보유 전략 (N자 패턴 등 다음날 상승 가능성 있는 종목) + self.min_hold_hours = get_env_float("MIN_HOLD_HOURS", 24.0) # 최소 보유 시간 (기본 24시간) + + # ============================================================ + # [리스크 관리 설정] - 변동성 역가중 (Volatility Inverse Weighting) + # ============================================================ + self.risk_pct_per_trade = get_env_float("RISK_PCT_PER_TRADE", 0.01) # 1% (계좌 기준) + self.kelly_multiplier = get_env_float("KELLY_MULTIPLIER", 0.25) # 쿼터 켈리 (0.25) + self.max_position_pct = get_env_float("MAX_POSITION_PCT", 0.15) # 종목당 최대 15% + self.min_position_amount = get_env_int("MIN_POSITION_AMOUNT", 50000) # 최소 5만원 + + # RiskManager 초기화 (변동성 역가중 + 쿼터 켈리 매수 금액 계산) + self.risk_mgr = None + if RISK_MANAGER_AVAILABLE: + # 켈리 공식 사용 여부 (기본 ON - 쿼터 켈리 0.25 적용) + use_kelly = get_env_bool("USE_KELLY_FORMULA", True) + self.risk_mgr = RiskManager( + risk_pct_per_trade=self.risk_pct_per_trade, + max_position_pct=self.max_position_pct, + min_position_amount=self.min_position_amount, + use_kelly=use_kelly, + kelly_multiplier=self.kelly_multiplier, # 쿼터 켈리 0.25 + ) + kelly_status = f"켈리{'ON' if use_kelly else 'OFF'}(배율={self.kelly_multiplier})" + logger.info(f"✅ RiskManager 활성화: 변동성 역가중 + {kelly_status}") + else: + logger.warning("⚠️ RiskManager 미사용: 고정 슬롯 금액 방식으로 폴백") + + # ML 신호 필터링 설정 + self.use_ml_signal = get_env_bool("USE_ML_SIGNAL", False) + self.ml_min_probability = get_env_float("ML_MIN_PROBABILITY", 0.57) + + # 리포트 플래그 + self.morning_report_sent = False + self.closing_report_sent = False + self.final_report_sent = False + self.ai_report_sent = False + + # 자산 추적 + self.today_date = dt.now().strftime("%Y-%m-%d") + self.start_day_asset = 0 + self.current_total_asset = 0 + self.current_cash = 0 + self.d2_excc_amt = 0 # D+2 예수금 (output2 prvs_rcdl_excc_amt) + self.total_deposit = get_env_float("TOTAL_DEPOSIT", 0) + + # DB에서 활성 트레이드 로드 + self.holdings = {} + active_trades = self.db.get_active_trades() + for code, trade in active_trades.items(): + self.holdings[code] = { + "buy_price": trade.get("avg_buy_price", 0), + "qty": trade.get("current_qty", 0), + "buy_time": trade.get("buy_date", dt.now().strftime("%Y-%m-%d %H:%M:%S")), + "name": trade.get("name", code), + } + + # 초기 자산 조회 + self._update_assets() + + # 비동기 태스크 관리 + self._universe_task = None + self._report_task = None + self._asset_task = None + self.is_first_run = True + + def _seconds_until_next_5min(self): + """다음 5분 정각까지 남은 초 계산""" + now = dt.now() + next_min = ((now.minute // 5) + 1) * 5 + if next_min >= 60: + next_time = now.replace(hour=now.hour + 1, minute=0, second=0, microsecond=0) + else: + next_time = now.replace(minute=next_min, second=0, microsecond=0) + return (next_time - now).total_seconds() + + def update_universe(self): + """ + 유니버스 업데이트 (5분마다 호출) - 키움 봇과 동일한 복합 스캔 로직 + - 개미털기 우선 (원본 점수 유지) + - 외국인/거래량/상승률/기관 추가 (보너스 점수) + - 강도 순으로 정렬 → Top 30 + """ + logger.info(f"🔄 [유니버스 업데이트] 시작 | 예수금: {self.current_cash:,.0f}원") + logger.info("📡 [복합 스캔] 개미털기 우선 + 4가지 보너스 소스") + + try: + # 매수 가능 금액 계산 + if self.max_stocks > 0: + slot_money = int(self.current_cash * 0.9 / self.max_stocks) + else: + slot_money = 100000 + + all_candidates = {} # {code: {name, price, base_score, bonus_score, total_score}} + + # 이번 스캔 주기용으로 후보 테이블 비움 (통과 시마다 add_target_candidate로 채워짐) + self.db.update_target_candidates([]) + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 1. 개미털기 (눌림목) - 원본 점수 100% 유지! (통과 시마다 즉시 add_target_candidate) + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + try: + ant_shaking = self.scan_ant_shaking_candidates(max_candidates=50) + logger.info(f" ✅ [개미털기] {len(ant_shaking)}개 수집 (강도 원본 유지)") + for item in ant_shaking: + code = item['code'] + all_candidates[code] = { + 'code': code, + 'name': item['name'], + 'price': item['price'], + 'base_score': item['score'], # 개미털기 원본 점수 (100%) + 'bonus_score': 0.0, # 보너스 점수 (추가) + 'from_ant': True, + 'drop_rate': item.get('drop_rate', 0), + 'recovery': item.get('recovery', 0), + } + except Exception as e: + logger.warning(f" ⚠️ [개미털기] 수집 실패: {e}") + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 2. 거래량순위 - 보너스 +0.3 (순위별 가산점) + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + try: + volume_rank = self.client.get_volume_rank(market="J", limit=50) + logger.info(f" ✅ [거래량순위] {len(volume_rank)}개 수집") + for idx, item in enumerate(volume_rank): + code = item.get('stk_cd', '').strip() or item.get('code', '').strip() + if not code or len(code) != 6: + continue + bonus = (50 - idx) / 50.0 * 0.3 # 순위별 보너스 (최대 +0.3) + + if code in all_candidates: + all_candidates[code]['bonus_score'] += bonus + else: + # 신규 추가 (가격 확인 필요) + price_data = self.client.inquire_price(code) + if price_data: + current_price = abs(float(price_data.get("stck_prpr", 0))) + if current_price > 0 and current_price <= slot_money: + all_candidates[code] = { + 'code': code, + 'name': item.get('stk_nm', code), + 'price': current_price, + 'base_score': 0.0, + 'bonus_score': bonus, + 'from_ant': False, + } + except Exception as e: + logger.warning(f" ⚠️ [거래량순위] 수집 실패: {e}") + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 3. 등락률순위 (상승률) - 보너스 +0.2 + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + try: + price_movers = self.client.get_price_change_rank(market="J", sort_type="1", limit=30) + logger.info(f" ✅ [등락률순위] {len(price_movers)}개 수집") + for idx, item in enumerate(price_movers): + code = item.get('stk_cd', '').strip() or item.get('code', '').strip() + if not code or len(code) != 6: + continue + bonus = (30 - idx) / 30.0 * 0.2 # 순위별 보너스 (최대 +0.2) + + if code in all_candidates: + all_candidates[code]['bonus_score'] += bonus + else: + price_data = self.client.inquire_price(code) + if price_data: + current_price = abs(float(price_data.get("stck_prpr", 0))) + if current_price > 0 and current_price <= slot_money: + all_candidates[code] = { + 'code': code, + 'name': item.get('stk_nm', code), + 'price': current_price, + 'base_score': 0.0, + 'bonus_score': bonus, + 'from_ant': False, + } + except Exception as e: + logger.warning(f" ⚠️ [등락률순위] 수집 실패: {e}") + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 4. 거래대금순위 - 보너스 +0.2 + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + try: + value_rank = self.client.get_trading_value_rank(market="J", limit=30) + logger.info(f" ✅ [거래대금순위] {len(value_rank)}개 수집") + for idx, item in enumerate(value_rank): + code = item.get('stk_cd', '').strip() or item.get('code', '').strip() + if not code or len(code) != 6: + continue + bonus = (30 - idx) / 30.0 * 0.2 # 순위별 보너스 (최대 +0.2) + + if code in all_candidates: + all_candidates[code]['bonus_score'] += bonus + except Exception as e: + logger.warning(f" ⚠️ [거래대금순위] 수집 실패: {e}") + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 5. 외국인/기관 순매수 - 보너스 +0.3 (투자자 동향 기반) + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 개미털기 후보에 대해서만 투자자 동향 확인 (API 호출 최소화) + investor_check_count = 0 + for code, candidate in list(all_candidates.items()): + if candidate.get('from_ant') and investor_check_count < 10: # 최대 10개만 체크 + try: + investor_trend = self.client.get_investor_trend(code, days=2) + investor_check_count += 1 + if investor_trend: + total_net = investor_trend.get("total_net_buy", 0) + if total_net > 20000: + candidate['bonus_score'] += 0.3 # 강한 매수세 + elif total_net > 5000: + candidate['bonus_score'] += 0.15 # 매수세 + except Exception as e: + logger.debug(f"투자자 동향 조회 실패({code}): {e}") + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 최종 점수 계산 및 정렬 + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + final_candidates = [] + for code, item in all_candidates.items(): + total_score = item['base_score'] + item['bonus_score'] + final_candidates.append({ + 'code': code, + 'name': item['name'], + 'price': item['price'], + 'score': total_score, + 'base_score': item['base_score'], + 'bonus_score': item['bonus_score'], + 'from_ant': item.get('from_ant', False), + 'drop_rate': item.get('drop_rate', 0), + 'recovery': item.get('recovery', 0), + }) + + # 강도 순 정렬 (total_score 기준) + final_candidates.sort(key=lambda x: x['score'], reverse=True) + + # 강도 4.0 이상만 필터링 (키움 봇과 동일) + filtered = [c for c in final_candidates if c['score'] >= 4.0] + + if not filtered: + logger.warning(f" ⚠️ 강도 4.0 이상 후보 없음 (전체: {len(final_candidates)}개)") + # 강도 낮춰서라도 상위 30개 저장 + filtered = final_candidates[:30] + + # DB 저장: 스캔 중 통과 시마다 이미 add_target_candidate()로 실시간 저장됨 → 여기서는 전체 교체 안 함 + # 스캔 완료 후에는 정렬만 해서 로그만 찍음 (매매 루프는 DB에서 실시간으로 후보 읽음) + logger.info(f" 💾 매수 후보군: 스캔 중 통과 즉시 저장 완료, 최종 후보 {len(filtered)}개 (DB 반영됨, 정렬 순 로그만 출력)") + + # Top 5 상세 로그 (강도 순, 종목명 표시) + logger.info(f" 🔝 [유니버스 Top 5] (강도 순)") + + # Top 5 종목의 전일 대비 정보 조회 (API 문서: stck_prdy_clpr = 전일 종가) + def _safe_float(val): + if not val: + return 0.0 + try: + return abs(float(str(val).replace(",", "").strip() or 0)) + except: + return 0.0 + + prev_day_map = {} + for x in filtered[:5]: + code = x["code"] + name = x.get("name", code) + try: + price_data = self.client.inquire_price(code) + if not price_data: + logger.info(f" ⚠️ 전일대비: {name} {code} API응답없음") + continue + current_price = _safe_float(price_data.get("stck_prpr")) or _safe_float(x.get("price", 0)) + prev_close = _safe_float(price_data.get("stck_prdy_clpr")) # API 문서 필드명 그대로 사용 + if prev_close <= 0: # 없으면 일봉에서 전일 close + df_daily = self.client.get_daily_chart(code, limit=3) + if not df_daily.empty and len(df_daily) >= 2: + prev_close = _safe_float(df_daily["close"].iloc[-2]) + logger.info(f" 📊 전일대비: {name} {code} 일봉에서 전일종가 조회 성공 ({prev_close:,.0f}원)") + else: + logger.info(f" ⚠️ 전일대비: {name} {code} 일봉데이터없음 (len={len(df_daily) if not df_daily.empty else 0})") + if prev_close > 0 and current_price > 0: + change = current_price - prev_close + change_pct = (change / prev_close) * 100 + prev_day_map[code] = (change, change_pct) + else: + logger.info(f" ⚠️ 전일대비: {name} {code} prev_close={prev_close}, current={current_price}") + time.sleep(random.uniform(0.2, 0.3)) + except Exception as e: + logger.warning(f" ⚠️ 전일대비 조회 실패({name} {code}): {e}") + + for i, x in enumerate(filtered[:5], 1): + base = x.get('base_score', 0) + bonus = x.get('bonus_score', 0) + total = x['score'] + drop_pct = x.get('drop_rate', 0) * 100 + recovery_pct = x.get('recovery', 0) * 100 + if x.get('from_ant'): + source = "개미털기" + else: + source = "랭킹" + if x.get('ml_probability'): + ml_info = f" | ML {x.get('ml_probability', 0):.1%}" + else: + ml_info = "" + + # 전일 대비 등락률 표시 + prev_day_info = "" + code = x['code'] + if code in prev_day_map: + change, change_pct = prev_day_map[code] + sign = "+" if change >= 0 else "" + prev_day_info = f" | 어제보다 {sign}{change:,.0f}원 ({sign}{change_pct:.2f}%)" + + logger.info( + f" {i}. {x['name']} {x['code']}: " + f"강도 {total:.1f} (기본 {base:.1f} + 보너스 {bonus:.1f}) | " + f"낙폭 {drop_pct:.1f}% | 회복 {recovery_pct:.0f}% | {source}{ml_info}{prev_day_info}" + ) + + logger.info(f" ✅ 최종 후보: {len(filtered)}개 (강도 4.0 이상: {len([c for c in final_candidates if c['score'] >= 4.0])}개)") + + except Exception as e: + logger.error(f"❌ 유니버스 업데이트 실패: {e}") + import traceback + logger.error(traceback.format_exc()) + + async def _universe_scan_scheduler(self): + """5분마다 정각에 유니버스 스캔 실행 (비동기 백그라운드)""" + loop = asyncio.get_event_loop() + while True: + try: + if self.is_first_run: + wait_sec = 0 # 첫 실행은 즉시 + else: + wait_sec = max(0, self._seconds_until_next_5min()) + if wait_sec > 0: + await asyncio.sleep(wait_sec) + now = dt.now() + logger.info(f"🔄 [스캔 주기] 정각 스캔 시작 | 시각:{now.hour:02d}:{now.minute:02d}:{now.second:02d}") + # 동기 함수를 executor에서 실행 (메인 루프 블로킹 방지) + await loop.run_in_executor(None, self.update_universe) + self.is_first_run = False + await asyncio.sleep(5) # 스캔 직후 5초 대기 (과부하 방지) + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"❌ [스캔 스케줄러] 에러: {e}") + await asyncio.sleep(60) + + async def _report_scheduler(self): + """리포트 전송 스케줄러 (비동기 백그라운드)""" + while True: + try: + await asyncio.sleep(60) # 1분마다 체크 + now = dt.now() + + # 13:00 - 오전 리포트 + AI 리포트 + if now.hour == 13 and now.minute == 0 and not self.morning_report_sent: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self.send_morning_report) + await loop.run_in_executor(None, self.send_ai_report) + + # 15:15 - 장마감 전 리포트 + elif now.hour == 15 and now.minute == 15 and not self.closing_report_sent: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self.send_closing_report) + + # 15:35 - 최종 리포트 + elif now.hour == 15 and now.minute == 35 and not self.final_report_sent: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self.send_final_report) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"❌ [리포트 스케줄러] 에러: {e}") + await asyncio.sleep(60) + + async def _asset_update_scheduler(self): + """자산 정보 업데이트 스케줄러 (30분마다, 비동기 백그라운드)""" + while True: + try: + await asyncio.sleep(60) # 1분마다 체크 + now = dt.now() + + # 30분마다 자산 업데이트 + if now.minute % 30 == 0: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self._update_assets) + await asyncio.sleep(60) # 업데이트 후 1분 대기 (중복 방지) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"❌ [자산 업데이트 스케줄러] 에러: {e}") + await asyncio.sleep(60) + + def _update_assets(self): + """자산 정보 업데이트""" + try: + balance = self.client.get_account_balance() + if balance is None: + logger.warning( + "💵 [예수금] get_account_balance가 None 반환 → 예수금 갱신 스킵 " + "(토큰·계좌·TR ID 확인. 모의=VTTC8434R, 실전=TTTC8434R)" + ) + return + # 한투 API: output1=주식 잔고(종목별), output2=예수금 관련(dnca_tot_amt 등) - 블로그·문서 기준 + def _parse_amt(v): + if v is None or str(v).strip() == "": + return None + return float(str(v).replace(",", "").strip()) + def _cash_block(obj): + if not obj: + return {} + if isinstance(obj, list) and obj: + return obj[0] + if isinstance(obj, dict): + return obj + return {} + out2 = _cash_block(balance.get("output2")) + if isinstance(balance.get("output1"), dict): + out1 = balance.get("output1", {}) + else: + out1 = {} + ord_psbl_val = _parse_amt(out2.get("ord_psbl_cash") or out1.get("ord_psbl_cash")) + dnca_tot_val = _parse_amt(out2.get("dnca_tot_amt") or out1.get("dnca_tot_amt")) or 0 + # D+2 예수금 (전일 정산 수령 예정 금액) - 한투 output2 prvs_rcdl_excc_amt + prvs_rcdl = _parse_amt(out2.get("prvs_rcdl_excc_amt")) + if prvs_rcdl is not None: + self.d2_excc_amt = prvs_rcdl + else: + self.d2_excc_amt = 0 + if ord_psbl_val is not None: + self.current_cash = ord_psbl_val + logger.info( + f"💵 [예수금] 주문가능(ord_psbl_cash)={self.current_cash:,.0f}원 | " + f"dnca_tot_amt={dnca_tot_val:,.0f} | D+2예수금(prvs_rcdl_excc_amt)={self.d2_excc_amt:,.0f}원" + ) + else: + self.current_cash = dnca_tot_val + logger.info( + f"💵 [예수금] dnca_tot_amt={self.current_cash:,.0f}원 | " + f"D+2예수금(prvs_rcdl_excc_amt)={self.d2_excc_amt:,.0f}원 (output2 keys={list(out2.keys()) if out2 else []})" + ) + # 보유 종목 평가액 계산 + holdings_value = 0 + for code, holding in self.holdings.items(): + price_data = self.client.inquire_price(code) + if price_data: + current_price = abs(float(price_data.get("stck_prpr", 0))) + holdings_value += current_price * holding["qty"] + self.current_total_asset = self.current_cash + holdings_value + if self.start_day_asset == 0: + self.start_day_asset = self.current_total_asset + except Exception as e: + logger.error(f"자산 정보 업데이트 실패: {e}") + + def _update_account_light(self, profit_val=0): + """ + 경량 계좌 갱신 (매수/매도 직후 즉시 호출!) + - API 부하를 줄이기 위해 예수금 + 보유 종목 평가액만 빠르게 계산 + - 총자산 = 예수금 + 보유 종목 평가액 (+ 손익 반영) + """ + try: + balance = self.client.get_account_balance() + if balance is None: + logger.warning("💵 [예수금-경량] get_account_balance None → 예수금 갱신 스킵") + return False + def _parse_amt_light(v): + if v is None or str(v).strip() == "": + return None + return float(str(v).replace(",", "").strip()) + def _cash_block_light(obj): + if not obj: + return {} + if isinstance(obj, list) and obj: + return obj[0] + if isinstance(obj, dict): + return obj + return {} + out2 = _cash_block_light(balance.get("output2")) + if isinstance(balance.get("output1"), dict): + out1 = balance.get("output1", {}) + else: + out1 = {} + ord_psbl_val = _parse_amt_light(out2.get("ord_psbl_cash") or out1.get("ord_psbl_cash")) + dnca_tot_val = _parse_amt_light(out2.get("dnca_tot_amt") or out1.get("dnca_tot_amt")) or 0 + prvs_rcdl = _parse_amt_light(out2.get("prvs_rcdl_excc_amt")) + if prvs_rcdl is not None: + self.d2_excc_amt = prvs_rcdl + if ord_psbl_val is not None: + new_cash = ord_psbl_val + else: + new_cash = dnca_tot_val + logger.info( + f"💵 [예수금-경량] 주문가능={new_cash:,.0f}원 (이전={self.current_cash:,.0f}) | D+2예수금={self.d2_excc_amt:,.0f}원" + ) + if new_cash > 0 or self.current_cash == 0: + self.current_cash = new_cash + # 보유 종목 평가액: output1=주식 잔고(종목별), output2=예수금 요약 (블로그 기준) + output1_list = balance.get("output1", []) + if isinstance(output1_list, dict): + output1_list = [output1_list] + holdings_value = 0 + for code, holding in self.holdings.items(): + for item in output1_list: + if (item.get("pdno") or "").strip() == code: + evlu_amt = float(item.get("evlu_amt", 0)) + holdings_value += evlu_amt + break + else: + price_data = self.client.inquire_price(code) + if price_data: + current_price = abs(float(price_data.get("stck_prpr", 0))) + holdings_value += current_price * holding["qty"] + self.current_total_asset = self.current_cash + holdings_value + if profit_val != 0: + self.current_total_asset += profit_val + logger.debug(f"💵 [경량갱신] 예수금: {self.current_cash:,.0f}원 | 총자산: {self.current_total_asset:,.0f}원") + return True + except Exception as e: + logger.error(f"❌ 경량 갱신 실패: {e}") + return False + + def _update_cash_only(self): + """예수금만 빠르게 업데이트 (하위 호환성용, _update_account_light 사용 권장)""" + return self._update_account_light(profit_val=0) + + def send_mm(self, msg): + """Mattermost 알림 전송""" + try: + self.mm.send(self.mm_channel, msg) + except Exception as e: + logger.error(f"❌ MM 전송 에러: {e}") + + def check_market_status(self): + """장 운영 시간 체크""" + # FORCE_MARKET_OPEN 플래그 확인 (테스트용) + force_open = get_env_bool("FORCE_MARKET_OPEN", False) + if force_open: + logger.debug("🔓 FORCE_MARKET_OPEN=true - 장 상태 무시하고 계속 진행") + return True + + # 정상 장 운영 시간 체크 + now = dt.now() + if not (datetime.time(8, 30) <= now.time() <= datetime.time(16, 0)): + return False + if now.weekday() >= 5: # 주말 + return False + return True + + def send_morning_report(self): + """오전 장 뜸할 때 리포트 (13:00)""" + if self.morning_report_sent: + return + + self._update_assets() + day_pnl = self.current_total_asset - self.start_day_asset + if self.start_day_asset > 0: + day_pnl_pct = day_pnl / self.start_day_asset * 100 + else: + day_pnl_pct = 0 + + msg = f"""📊 **[오전 장 현황 - 13:00]** +- 당일 시작: {self.start_day_asset:,.0f}원 +- 현재 자산: {self.current_total_asset:,.0f}원 +- 당일 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%) +- 보유 종목: {len(self.holdings)}개""" + + self.send_mm(msg) + self.morning_report_sent = True + logger.info("📊 오전 리포트 전송 완료") + + def send_closing_report(self): + """장마감 전 리포트 (15:15)""" + if self.closing_report_sent: + return + + self._update_assets() + day_pnl = self.current_total_asset - self.start_day_asset + if self.start_day_asset > 0: + day_pnl_pct = day_pnl / self.start_day_asset * 100 + else: + day_pnl_pct = 0 + + msg = f"""📈 **[장마감 전 현황 - 15:15]** +- 당일 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%) +- 현재 자산: {self.current_total_asset:,.0f}원 +- 보유 종목: {len(self.holdings)}개 +- 예수금(주문가능): {self.current_cash:,.0f}원 | D+2예수금: {self.d2_excc_amt:,.0f}원""" + + self.send_mm(msg) + self.closing_report_sent = True + logger.info("📈 장마감 전 리포트 전송 완료") + + def send_final_report(self): + """장마감 후 최종 리포트 (15:35)""" + if self.final_report_sent: + return + + self._update_assets() + + # 당일 손익 + day_pnl = self.current_total_asset - self.start_day_asset + if self.start_day_asset > 0: + day_pnl_pct = day_pnl / self.start_day_asset * 100 + else: + day_pnl_pct = 0 + + # 누적 손익 + cumulative_pnl = self.current_total_asset - self.total_deposit + if self.total_deposit > 0: + cumulative_pnl_pct = cumulative_pnl / self.total_deposit * 100 + else: + cumulative_pnl_pct = 0 + + # 오늘 거래 내역 + today_trades = self.db.get_trades_by_date(self.today_date) + + msg = f"""🏁 **[장마감 최종 보고 - 15:35]** +━━━━━━━━━━━━━━━━━━━━ +📅 **당일 손익** +- 시작: {self.start_day_asset:,.0f}원 +- 종료: {self.current_total_asset:,.0f}원 +- 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%) + +💰 **누적 손익 (총 입금액 대비)** +- 총 입금: {self.total_deposit:,.0f}원 +- 현재 자산: {self.current_total_asset:,.0f}원 +- 누적 손익: {cumulative_pnl:+,.0f}원 ({cumulative_pnl_pct:+.2f}%) + +📊 **거래 현황** +- 오늘 매매: {len(today_trades)}건 +- 보유 종목: {len(self.holdings)}개 +- 예수금(주문가능): {self.current_cash:,.0f}원 | D+2예수금: {self.d2_excc_amt:,.0f}원 +━━━━━━━━━━━━━━━━━━━━""" + + self.send_mm(msg) + self.final_report_sent = True + logger.info("🏁 장마감 최종 리포트 전송 완료") + + def send_ai_report(self): + """AI 분석 리포트 (13:00)""" + if self.ai_report_sent or not gemini_client: + return + + try: + # 최근 거래 내역 조회 + recent_trades = [] + try: + conn = self.db.conn + cursor = conn.execute(""" + SELECT code, name, buy_price, sell_price, qty, profit_rate, + realized_pnl, strategy, sell_reason, buy_date, sell_date, hold_minutes + FROM trade_history + ORDER BY id DESC + LIMIT 10 + """) + for row in cursor.fetchall(): + recent_trades.append({ + 'code': row[0], + 'name': row[1], + 'buy_price': row[2], + 'sell_price': row[3], + 'qty': row[4], + 'profit_rate': row[5], + 'realized_pnl': row[6], + 'strategy': row[7], + 'sell_reason': row[8], + 'buy_date': row[9], + 'sell_date': row[10], + 'hold_minutes': row[11] or 0 + }) + except Exception as e: + logger.error(f"거래 내역 조회 실패: {e}") + return + + # 현재 유니버스 상태 + db_candidates = self.db.get_target_candidates() + candidate_count = len(db_candidates) + + # 거래 내역이 없어도 유니버스 상태는 리포트에 포함 + if not recent_trades: + # 거래 내역 없을 때도 유니버스 상태 리포트 + summary = f"""📊 **현재 상태** +- 유니버스 후보: {candidate_count}개 +- 최근 거래: 없음""" + + prompt = f"""당신은 퀀트 트레이딩 전문가입니다. + +**현재 상태:** +- 유니버스 후보: {candidate_count}개 +- 최근 거래: 없음 + +**현재 설정값:** +- 최대 보유: {self.max_stocks}개 +- 최소 낙폭: {self.min_drop_rate*100:.1f}% +- 최소 회복률: {self.min_recovery_ratio*100:.0f}% +- ML 사용: {self.use_ml_signal} +- ML 최소 승률: {self.ml_min_probability:.1%} + +**당신의 임무:** +1. 후보가 {candidate_count}개인 이유 분석 (필터 조건이 너무 까다로운지 등) +2. **수치 추천** (변수명=값 형식, 정확한 숫자 제시) + - 예: MIN_DROP_RATE=0.025 (2.5%) + - 예: MIN_RECOVERY_RATIO=0.40 (40%) +3. 예상 효과 + +**출력 형식:** +## 🔍 문제점 +1. [구체적 문제 1] +2. [구체적 문제 2] + +## 💡 수치 추천 (DB 설정 변경) +- 변수명1=값1 (이유: ...) +- 변수명2=값2 (이유: ...) + +## 📈 예상 효과 +- [효과 1] +- [효과 2] +""" + + response = gemini_client.models.generate_content(model=GEMINI_MODEL_ID, contents=prompt) + analysis = getattr(response, "text", None) or (response.candidates[0].content.parts[0].text if response.candidates else "") + + message = f"""🤖 **[13시 AI 자동 분석 + 수치 추천]** + +{summary} + +{analysis} + +--- +💬 단타 전략 최적화를 위한 AI 분석입니다. (수치 추천 포함) +""" + + self.send_mm(message) + self.ai_report_sent = True + logger.info("🤖 AI 리포트 전송 완료 (거래 내역 없음, 유니버스 상태 포함)") + return + + # 통계 계산 + total = len(recent_trades) + wins = sum(1 for t in recent_trades if t['profit_rate'] > 0) + losses = total - wins + if total > 0: + win_rate = wins / total * 100 + else: + win_rate = 0 + avg_profit = sum(t['profit_rate'] for t in recent_trades) / total + total_pnl = sum(t['realized_pnl'] for t in recent_trades) + avg_hold = sum(t['hold_minutes'] for t in recent_trades) / total + + # AI 분석 + trades_text = "" + for i, t in enumerate(recent_trades, 1): + trades_text += f""" +[거래 {i}] {t['name']} ({t['strategy']}) +- 매수: {t['buy_price']:,.0f}원 × {t['qty']}주 +- 매도: {t['sell_price']:,.0f}원 +- 손익: {t['profit_rate']:+.2f}% ({t['realized_pnl']:,.0f}원) +- 보유: {t['hold_minutes']}분 +- 사유: {t['sell_reason']} +""" + + # 현재 유니버스 상태 + db_candidates = self.db.get_target_candidates() + candidate_count = len(db_candidates) + + prompt = f"""당신은 퀀트 트레이딩 전문가입니다. + +**현재 상태:** +- 유니버스 후보: {candidate_count}개 +- 최근 거래: {total}건 +- 승률: {win_rate:.1f}% ({wins}승 {losses}패) +- 평균 수익률: {avg_profit:.2f}% +- 총 손익: {total_pnl:,.0f}원 +- 평균 보유: {avg_hold:.0f}분 + +**최근 거래 내역:** +{trades_text} + +**현재 설정값:** +- 최대 보유: {self.max_stocks}개 +- 최소 낙폭: {self.min_drop_rate*100:.1f}% +- 최소 회복률: {self.min_recovery_ratio*100:.0f}% +- ML 사용: {self.use_ml_signal} +- ML 최소 승률: {self.ml_min_probability:.1%} + +**당신의 임무:** +1. 문제점 3가지 진단 (구체적으로) + - 특히 후보가 {candidate_count}개인 이유 분석 +2. **수치 추천** (변수명=값 형식, 정확한 숫자 제시) + - 예: MIN_DROP_RATE=0.025 (2.5%) + - 예: MIN_RECOVERY_RATIO=0.40 (40%) + - 예: MAX_STOCKS=5 +3. 예상 효과 + +**출력 형식:** +## 🔍 문제점 +1. [구체적 문제 1] +2. [구체적 문제 2] +3. [구체적 문제 3] + +## 💡 수치 추천 (DB 설정 변경) +- 변수명1=값1 (이유: ...) +- 변수명2=값2 (이유: ...) + +## 📈 예상 효과 +- [효과 1] +- [효과 2] + +**간결하고 명확하게 답변하세요.** +""" + + response = gemini_client.models.generate_content(model=GEMINI_MODEL_ID, contents=prompt) + analysis = getattr(response, "text", None) or (response.candidates[0].content.parts[0].text if response.candidates else "") + + summary = f"""📊 **현재 상태** +- 유니버스 후보: {candidate_count}개 +- 최근 거래: {total}건 +- 승률: {win_rate:.1f}% ({wins}승 {losses}패) +- 평균 수익률: {avg_profit:.2f}% +- 총 손익: {total_pnl:+,.0f}원 +- 평균 보유: {avg_hold:.0f}분""" + + message = f"""🤖 **[13시 AI 자동 분석 + 수치 추천]** + +{summary} + +{analysis} + +--- +💬 단타 전략 최적화를 위한 AI 분석입니다. (수치 추천 포함) +""" + + self.send_mm(message) + self.ai_report_sent = True + logger.info("🤖 AI 리포트 전송 완료") + + except Exception as e: + logger.error(f"AI 리포트 생성 실패: {e}") + + def _fetch_scan_universe_from_api(self, max_codes=500): + """ + KIS API로 스캔 대상 종목 리스트 조회 (6소스 각 100건 → 최대 500개). + - 거래량·거래대금·회전율·등락률(상승)·등락률(하락)·거래증가율 각 100건 합산 후 중복 제거. + - 리스트는 DB에 저장하지 않음. 스캔 끝난 뒤 후보만 DB에 한 번에 인서트. + Returns: + list[dict]: [{"code": "006자리", "name": "종목명"}, ...] (중복 제거, 최대 max_codes개) + """ + def _code_from_item(item): + code = (item.get("stk_cd") or item.get("mksc_shrn_iscd") or item.get("code") or "").strip() + return code if code and len(code) == 6 else None + + def _name_from_item(item): + return ( + (item.get("stk_nm") or item.get("prst_name") or item.get("hts_kor_isnm") or "").strip() + or "" + ) + + scan_list = [] + seen = set() + + # 1) 거래량순위 100개 (키움은 거래대금+회전율만 사용, KIS는 거래량+거래대금+회전율 3가지로 풀 확대) + try: + time.sleep(random.uniform(0.5, 1.0)) + vol_list = self.client.get_volume_rank(market="J", limit=100) + for item in (vol_list or []): + c = _code_from_item(item) + if c and c not in seen: + seen.add(c) + scan_list.append({"code": c, "name": _name_from_item(item) or ""}) + logger.info(f" 📡 [스캔유니버스] 거래량순위 API → {len(vol_list)}건 수신, 누적 {len(scan_list)}종목") + except Exception as e: + logger.warning(f" ⚠️ [스캔유니버스] 거래량순위 조회 실패: {e}") + + # 2) 거래대금순위 100개 (키움과 동일 소스) + try: + time.sleep(random.uniform(0.5, 1.0)) + val_list = self.client.get_trading_value_rank(market="J", limit=100) + for item in (val_list or []): + c = _code_from_item(item) + if c and c not in seen: + seen.add(c) + scan_list.append({"code": c, "name": _name_from_item(item) or ""}) + logger.info(f" 📡 [스캔유니버스] 거래대금순위 API → {len(val_list)}건 수신, 누적 {len(scan_list)}종목") + except Exception as e: + logger.warning(f" ⚠️ [스캔유니버스] 거래대금순위 조회 실패: {e}") + + # 3) 회전율순위 100개 (키움 개미털기 2번째 소스와 동일) + try: + time.sleep(random.uniform(0.5, 1.0)) + turn_list = self.client.get_turnover_rank(market="J", limit=100) + for item in (turn_list or []): + c = _code_from_item(item) + if c and c not in seen: + seen.add(c) + scan_list.append({"code": c, "name": _name_from_item(item) or ""}) + logger.info(f" 📡 [스캔유니버스] 회전율순위 API → {len(turn_list)}건 수신, 누적 {len(scan_list)}종목") + except Exception as e: + logger.warning(f" ⚠️ [스캔유니버스] 회전율순위 조회 실패: {e}") + + # 4) 등락률순위(상승) 100개 (기존 후보군 풀 확대) + try: + time.sleep(random.uniform(0.5, 1.0)) + chg_list = self.client.get_price_change_rank(market="J", sort_type="1", limit=100) + for item in (chg_list or []): + c = _code_from_item(item) + if c and c not in seen: + seen.add(c) + scan_list.append({"code": c, "name": _name_from_item(item) or ""}) + logger.info(f" 📡 [스캔유니버스] 등락률순위(상승) API → {len(chg_list)}건 수신, 누적 {len(scan_list)}종목") + except Exception as e: + logger.warning(f" ⚠️ [스캔유니버스] 등락률순위(상승) 조회 실패: {e}") + + # 4-2) 등락률순위(하락) 100개 — 낙폭 큰 종목 직접 조회 → N자 망치봉 스캔 효율·Pass-낙폭 포착 + try: + time.sleep(random.uniform(0.5, 1.0)) + decline_list = self.client.get_price_decline_rank(market="J", limit=100) + for item in (decline_list or []): + c = _code_from_item(item) + if c and c not in seen: + seen.add(c) + scan_list.append({"code": c, "name": _name_from_item(item) or ""}) + logger.info(f" 📡 [스캔유니버스] 등락률순위(하락) API → {len(decline_list)}건 수신, 누적 {len(scan_list)}종목") + except Exception as e: + logger.warning(f" ⚠️ [스캔유니버스] 등락률순위(하락) 조회 실패: {e}") + + # 5) 거래증가율순위 100개 (거래량/대금/회전율과 다른 풀 → 후보 다양화) + try: + time.sleep(random.uniform(0.5, 1.0)) + growth_list = self.client.get_volume_growth_rank(market="J", limit=100) + for item in (growth_list or []): + c = _code_from_item(item) + if c and c not in seen: + seen.add(c) + scan_list.append({"code": c, "name": _name_from_item(item) or ""}) + logger.info(f" 📡 [스캔유니버스] 거래증가율순위 API → {len(growth_list)}건 수신, 누적 {len(scan_list)}종목 (6소스 합산 → 개미털기 필터)") + except Exception as e: + logger.warning(f" ⚠️ [스캔유니버스] 거래증가율순위 조회 실패: {e}") + + if not scan_list: + logger.warning(" ⚠️ [스캔유니버스] API에서 0건 수신 → 스캔 불가 (권한/계정/시간 확인)") + return [] + + scan_list = scan_list[:max_codes] + # 종목명 비어 있으면 시세 배치로 채우기 (KIS volume-rank는 종목명 미제공 시 많음) + need_name_codes = [x["code"] for x in scan_list[:20] if not (x.get("name") or "").strip()] + if need_name_codes: + try: + time.sleep(random.uniform(0.2, 0.4)) + batch = self.client.inquire_prices_batch(need_name_codes[:20]) + name_map = {} + _market_names = {"KOSPI", "KOSDAQ", "ETF", "KOSPI200", "KSQ150"} + for code, out in (batch or {}).items(): + n = (out.get("stck_kor_isnm") or out.get("rprs_mrkt_kor_name") or "").strip() + if n and n not in _market_names: + name_map[code] = n + for x in scan_list: + if not (x.get("name") or "").strip() and x["code"] in name_map: + x["name"] = name_map[x["code"]] + except Exception as e: + logger.debug(f" 스캔 종목명 배치 조회 스킵: {e}") + for x in scan_list: + if not (x.get("name") or "").strip(): + x["name"] = x["code"] + + logger.info( + f" 📋 스캔 대상: {len(scan_list)}개 종목 (거래량·거래대금·회전율·등락률상승·등락률하락·거래증가율 각 100건 합산 → 개미털기 필터)" + ) + if scan_list: + part = ", ".join(f"{x['code']} {x.get('name') or x['code']}" for x in scan_list[:15]) + logger.info(f" 📋 스캔 대상(일부): {part}{' ...' if len(scan_list) > 15 else ''}") + return scan_list + + def scan_ant_shaking_candidates(self, max_candidates=20): + """개미털기(눌림목) 후보 종목 스캔 - KIS API로 스캔 대상 조회 후 필터링""" + logger.info("🐜 [개미털기] 고급 스캔 시작 (KIS API 스캔유니버스 사용)") + logger.info( + " 📌 스캔 대상 리스트는 DB에 저장되지 않음. " + "수치 체크(낙폭/회복률 등)는 이 리스트를 순회하며 개미털기 전략 필터를 적용한 결과입니다." + ) + candidates = [] + seen_codes = set() + # 필터별 탈락 건수 (0개 나오는 이유 확인용) - 세분화 + filter_counts = { + "낙폭부족": 0, + "회복률부족": 0, + "피뢰침(고점근접)": 0, + "피뢰침(급등주)": 0, + "RSI과열": 0, + "MA20": 0, + "API응답없음": 0, + "API예외": 0, + "시가0": 0, + "동전주": 0, + "가격파싱오류": 0, + } + + scan_list = self._fetch_scan_universe_from_api(max_codes=500) + scan_codes = [x["code"] for x in scan_list] + # scan_list를 딕셔너리로 변환하여 코드로 종목명을 빠르게 찾을 수 있게 함 + scan_name_map = {x["code"]: x.get("name", "") for x in scan_list} + if not scan_codes: + logger.warning(" ⚠️ [개미털기] 스캔 대상 0개 → 스캔 생략 (API에서 종목 리스트를 받지 못함)") + return [] + + # 스캔 대상 리스트를 거래량/낙폭 체크 전에 한 번 출력 (종목명 · 코드) + # 참고: 위 [스캔유니버스] 6소스(거래량·거래대금·회전율·등락률상승·등락률하락·거래증가율) 합산 → 동일 개미털기 필터 적용 + logger.info(f" 📋 [개미털기 스캔 대상] {len(scan_list)}개 (6소스 합산, 종목명 · 코드)") + for i, x in enumerate(scan_list): + logger.info(f" {i+1}. {x.get('name') or x['code']} {x['code']}") + + # 체결강도 상위 API(FHPST01710000) 1회 조회 → 통과 종목 보너스(100 이상 +10, 120 이상 +20) 적용용 + execution_strength_map = {} + try: + time.sleep(random.uniform(0.3, 0.6)) + execution_strength_map = self.client.get_execution_strength_map(market="J", limit=200) + if execution_strength_map: + logger.info(f" 📡 [체결강도] 상위 {len(execution_strength_map)}종목 로드 (통과 시 100+ → +10점, 120+ → +20점)") + except Exception as e: + logger.debug(f"체결강도 맵 로드 스킵: {e}") + + # 순차 조회 (한투 API는 다중 종목 조회 미지원 - 순차 조회 필수) + # 규칙: 일반 루프에는 random.sleep(1~3) 기본 적용 (서버 부하 방지) + total_scanned = 0 + passed_filters = 0 + + for code in scan_codes: + total_scanned += 1 + if code in seen_codes: + continue + seen_codes.add(code) + + try: + # 순차 조회 (종목당 1회 API 호출) + price_data = self.client.inquire_price(code) + if not price_data: + filter_counts["API응답없음"] += 1 + if code == "001510": # SK증권 추적 + logger.warning(f" ⚠️ SK증권(001510) API응답없음 (상세는 위 [현재가API] 로그 참고)") + time.sleep(random.uniform(1.0, 2.0)) + continue + time.sleep(random.uniform(1.0, 2.0)) + + # 가격 데이터 파싱 + try: + current_price = abs(float(str(price_data.get("stck_prpr", 0)).replace(",", ""))) + open_price = abs(float(str(price_data.get("stck_oprc", current_price)).replace(",", ""))) + high_price = abs(float(str(price_data.get("stck_hgpr", current_price)).replace(",", ""))) + low_price = abs(float(str(price_data.get("stck_lwpr", current_price)).replace(",", ""))) + volume = int(float(str(price_data.get("acml_vol", 0)).replace(",", ""))) + except (ValueError, TypeError) as e: + filter_counts["가격파싱오류"] += 1 + logger.debug(f"가격 파싱 실패({code}): {e}") + continue + + if open_price == 0: + filter_counts["시가0"] += 1 + continue + if current_price < 1000: # 동전주 제외 + filter_counts["동전주"] += 1 + continue + + # 종목명 가져오기: 시장명(KOSPI/KOSDAQ 등)이 아닌 진짜 종목명만 사용 + # KIS API는 rprs_mrkt_kor_name에 시장구분을 넣는 경우가 있어, stck_kor_isnm(종목한글명) 우선 사용 + _MARKET_NAMES = {"KOSPI", "KOSDAQ", "ETF", "KOSPI200", "KSQ150"} + name = scan_name_map.get(code, "").strip() + if not name or name in _MARKET_NAMES: + name = (price_data.get("stck_kor_isnm") or price_data.get("rprs_mrkt_kor_name") or "").strip() + if not name or name in _MARKET_NAMES: + name = code + + # 낙폭 계산 + if open_price > 0: + drop_rate = (open_price - low_price) / open_price + else: + drop_rate = 0 + total_range = high_price - low_price + if total_range > 0: + recovery_pos = (current_price - low_price) / total_range + else: + recovery_pos = 0 + + # 필터 조건 수치 로그 (디버깅용) + logger.debug( + f" 📊 [{name} {code}] 수치: " + f"낙폭 {drop_rate*100:.2f}% (기준: {self.min_drop_rate*100:.1f}%) | " + f"회복 {recovery_pos*100:.1f}% (기준: {self.min_recovery_ratio*100:.0f}%) | " + f"고점 {high_price:,.0f}원 | 저점 {low_price:,.0f}원 | 현재 {current_price:,.0f}원" + ) + + # [필터 1] 낙폭 체크 + if drop_rate < self.min_drop_rate: + filter_counts["낙폭부족"] += 1 + logger.info( + f"{LOG_YELLOW}🔍 [탈락-낙폭] {name} {code}: 낙폭 {drop_rate*100:.2f}% < {self.min_drop_rate*100:.1f}% " + f"(시가 {open_price:,.0f}원 → 저점 {low_price:,.0f}원){LOG_RESET}" + ) + if code == "001510": # SK증권 추적 + logger.warning(f" ⚠️ SK증권(001510) 낙폭부족으로 탈락: 낙폭={drop_rate*100:.2f}%, 기준={self.min_drop_rate*100:.1f}%") + continue + + # [필터 2] 회복률 체크 + if recovery_pos < self.min_recovery_ratio: + filter_counts["회복률부족"] += 1 + logger.info( + f"{LOG_YELLOW}🔍 [탈락-회복률] {name} {code}: 회복률 {recovery_pos*100:.1f}% < {self.min_recovery_ratio*100:.0f}% " + f"(저점 {low_price:,.0f}원 → 현재 {current_price:,.0f}원 / 범위 {total_range:,.0f}원){LOG_RESET}" + ) + if code == "001510": # SK증권 추적 + logger.warning(f" ⚠️ SK증권(001510) 회복률부족으로 탈락: 회복률={recovery_pos*100:.1f}%, 기준={self.min_recovery_ratio*100:.0f}%") + continue + + # [필터 3] 피뢰침 방지 - 고점 추격 매수 방지 + high_chase_threshold = get_env_float("HIGH_PRICE_CHASE_THRESHOLD", 0.96) + if current_price >= high_price * high_chase_threshold: + filter_counts["피뢰침(고점근접)"] += 1 + drop_from_high = (high_price - current_price) / high_price * 100 + logger.info(f"{LOG_YELLOW}🔍 [탈락-피뢰침] {name} {code}: 고점 대비 {drop_from_high:.1f}% 조정 부족 (최소 4% 필요){LOG_RESET}") + continue + + # [필터 4] 피뢰침 방지 - 급등주 제외 + if low_price > 0: + daily_change_pct = (high_price - low_price) / low_price * 100 + else: + daily_change_pct = 0 + max_daily_change = get_env_float("MAX_DAILY_CHANGE_PCT", 20.0) + if daily_change_pct > max_daily_change: + filter_counts["피뢰침(급등주)"] += 1 + logger.info(f"{LOG_YELLOW}🔍 [탈락-피뢰침] {name} {code}: 당일 변동폭 {daily_change_pct:.1f}% 과도 (최대 {max_daily_change}%){LOG_RESET}") + continue + + # [필터 5] RSI 과열 체크 (분봉 데이터 필요) + try: + df = self.client.get_minute_chart(code, period="3", limit=20) + if not df.empty and len(df) >= 14 and "RSI" in df.columns: + rsi = float(df["RSI"].iloc[-1]) + rsi_threshold = get_env_float("RSI_OVERHEAT_THRESHOLD", 78.0) + if rsi >= rsi_threshold: + filter_counts["RSI과열"] += 1 + logger.info(f"{LOG_YELLOW}🔍 [탈락-RSI] {name} {code}: RSI 과열 ({rsi:.1f} >= {rsi_threshold}){LOG_RESET}") + continue + + # [필터 6] MA20 체크 + if "MA20" in df.columns and len(df) >= 20: + ma20 = float(df["MA20"].iloc[-1]) + if current_price < ma20: + filter_counts["MA20"] += 1 + logger.info(f"{LOG_YELLOW}🔍 [탈락-MA20] {name} {code}: 현재가({current_price:.0f}) < MA20({ma20:.2f}){LOG_RESET}") + continue + + ma20_cap_pct = get_env_float("MA20_MAX_ABOVE_PCT", 3.0) + if ma20 > 0 and current_price > ma20 * (1 + ma20_cap_pct / 100): + filter_counts["MA20"] += 1 + gap_pct = (current_price - ma20) / ma20 * 100 + logger.info(f"{LOG_YELLOW}🔍 [탈락-MA20과열] {name} {code}: 20선 대비 {gap_pct:.1f}% 위 (최대 {ma20_cap_pct}%){LOG_RESET}") + continue + except Exception as e: + logger.debug(f"RSI/MA20 체크 실패({code}): {e}") + + # 외국인/기관 동향 확인 + investor_trend = self.client.get_investor_trend(code, days=3) + investor_score = 0 + if investor_trend: + total_net = investor_trend.get("total_net_buy", 0) + if total_net > 10000: + investor_score = 20 # 강한 매수세 + elif total_net > 0: + investor_score = 10 # 매수세 + + # 조건 통과: 낙폭 3% 이상 & 회복 50% 이상 + if drop_rate >= self.min_drop_rate and recovery_pos >= self.min_recovery_ratio: + # ML 승률 예측 (USE_ML_SIGNAL=true일 때만) + ml_prob = None + if self.use_ml_signal and self.ml_predictor: + try: + # ML 피처 추출 (간단 버전 - 실제로는 더 많은 피처 필요) + # TODO: 실제 피처 데이터로 교체 필요 (RSI, 거래량비, 이동평균 등) + ml_features = { + "rsi": 50.0, # 임시값 + "volume_ratio": 1.0, + "tail_length_pct": drop_rate * 100, + "ma5_gap_pct": 0.0, + "ma20_gap_pct": 0.0, + "foreign_net_buy": investor_trend.get("foreign_net_buy", 0) if investor_trend else 0, + "institution_net_buy": investor_trend.get("org_net_buy", 0) if investor_trend else 0, + "market_hour": dt.now().hour, + } + ml_prob = self.ml_predictor.predict_win_probability(ml_features) + + # ML 임계값 미달 시 스킵 + if ml_prob < self.ml_min_probability: + logger.info( + f"{LOG_YELLOW}🔍 [탈락-ML] {name} {code}: ML 승률 {ml_prob:.1%} < {self.ml_min_probability:.1%}{LOG_RESET}" + ) + continue + except Exception as e: + logger.debug(f"ML 예측 실패({code}): {e}") + + # 점수 계산: 낙폭 + 회복률 + 거래량 + 수급 + ML 승률 + score = (drop_rate * 100) + (recovery_pos * 50) + investor_score + if volume > 1000000: # 거래량 100만주 이상 가산점 + score += 10 + if ml_prob is not None: + score += (ml_prob - 0.5) * 100 # ML 승률 가산점 + # 체결강도 상위 API(FHPST01710000) 보너스: 100 이상 +10점, 120 이상 +20점 + execution_strength = execution_strength_map.get(code, 0) + if execution_strength >= 120: + score += 20 + elif execution_strength >= 100: + score += 10 + candidate = { + "code": code, + "name": name, + "price": current_price, + "score": score, + "drop_rate": drop_rate, + "recovery": recovery_pos, + "volume": volume, + "investor_trend": investor_trend, + "execution_strength": execution_strength, + } + if ml_prob is not None: + candidate["ml_probability"] = ml_prob + + candidates.append(candidate) + passed_filters += 1 + + # 통과 즉시 DB 저장 (매매 루프가 스캔 완료를 기다리지 않고 실시간으로 후보 읽기 위함) + # 같은 종목 중복 시 ON CONFLICT(code) DO UPDATE 로 최신 점수/가격으로 갱신됨 + try: + self.db.add_target_candidate({ + "code": code, + "name": name, + "score": score, + "price": current_price, + }) + except Exception as e: + logger.debug(f"후보 즉시 저장 실패({code}): {e}") + + ml_info = f" | ML {ml_prob:.1%}" if ml_prob is not None else "" + strength_info = f" | 체결강도 {execution_strength:.0f}(+{'20' if execution_strength >= 120 else '10'}점)" if execution_strength >= 100 else "" + logger.info( + f"{LOG_GREEN}✅ [통과] {name} {code}: 낙폭 {drop_rate*100:.1f}% | 회복 {recovery_pos*100:.0f}% | 강도 {score:.1f}{strength_info}{ml_info}{LOG_RESET}" + ) + + except Exception as e: + filter_counts["API예외"] = filter_counts.get("API예외", 0) + 1 + logger.warning( + f"종목 스캔 예외 code={code} exception={e!r} type={type(e).__name__}" + ) + time.sleep(random.uniform(1.0, 2.0)) + continue + + candidates.sort(key=lambda x: x["score"], reverse=True) + + # 필터별 탈락/통과 요약 (색상: 탈락=노랑, 통과=초록) + summary = ", ".join(f"{k}={v}" for k, v in filter_counts.items() if v > 0) + logger.info(f" 📊 [필터 요약] 스캔 {total_scanned}개 중 {LOG_YELLOW}탈락: {summary or '없음'}{LOG_RESET} | {LOG_GREEN}통과: {len(candidates)}개{LOG_RESET}") + count_ge4 = sum(1 for c in candidates if c.get("score", 0) >= 4.0) + logger.info( + f" ✅ 스캔 완료: 개미털기 {len(candidates)}개 통과 (강도 4.0 이상: {count_ge4}개) " + f"[스캔 {total_scanned}개 → 필터 통과 {passed_filters}개]" + ) + # 통과 종목 전부 출력: 종목명 · 코드 · 강도 (몇 개 안 되므로 전부 표시) + if candidates: + logger.info(" 📌 [개미털기 통과 목록] 종목명 · 코드 · 강도") + for i, c in enumerate(candidates): + logger.info(f" {i+1}. {c['name']} {c['code']} 강도 {c['score']:.1f}") + # 강도순 상위 10개 → Mattermost 전송 + top10 = candidates[:10] + lines = [f"🐜 **개미털기 강도순 TOP{len(top10)}** (스캔 {total_scanned}개 중 통과 {len(candidates)}개)"] + for i, c in enumerate(top10, 1): + name = (c.get("name") or c.get("code") or "").strip() + score = c.get("score", 0) + lines.append(f"{i}. 강도 **{score:.1f}** {name}") + try: + self.send_mm("\n".join(lines)) + except Exception as e: + logger.debug(f"Mattermost 개미털기 TOP10 전송 스킵: {e}") + return candidates[:max_candidates] + + def calculate_atr(self, df, period=14): + """ + ATR (Average True Range) 계산 - 변동성 지표 + - TR(True Range) = max(고가-저가, |고가-전일종가|, |저가-전일종가|) + - ATR = TR의 14일 이동평균 + """ + try: + if df is None or len(df) < period: + return 0 + + df = df.copy() + # True Range 계산 + high_low = df['high'] - df['low'] + high_close = (df['high'] - df['close'].shift()).abs() + low_close = (df['low'] - df['close'].shift()).abs() + df['tr'] = pd.concat([high_low, high_close, low_close], axis=1).max(axis=1) + + # ATR = TR의 14일 이동평균 + atr = df['tr'].rolling(window=period).mean().iloc[-1] + return float(atr) if not pd.isna(atr) and atr > 0 else 0 + except Exception as e: + logger.debug(f"ATR 계산 실패: {e}") + return 0 + + def check_sell_signals(self): + """ + 매도 신호 체크 (ATR 기반 변동성 매도 로직) + - 어깨 매도 (고점 대비 3% 하락) + - ATR 기반 스캘핑 (본절사수/익절보존) + - 순수익 보존 + - 목표가/손절가 + """ + if not self.holdings: + return [] + + sell_signals = [] + for code, holding in list(self.holdings.items()): + try: + name = holding.get("name", code) + buy_price = holding["buy_price"] + buy_time_str = holding.get("buy_time", "") + qty = holding["qty"] + + # 현재가 조회 + price_data = self.client.inquire_price(code) + if not price_data: + continue + + current_price = abs(float(price_data.get("stck_prpr", 0))) + if current_price == 0: + continue + + # 고점 갱신 (holdings에 저장) + max_price = holding.get("max_price", buy_price) + if current_price > max_price: + max_price = current_price + self.holdings[code]["max_price"] = max_price + + # 손익률 계산 + profit_pct = (current_price - buy_price) / buy_price if buy_price > 0 else 0 + profit_val = (current_price - buy_price) * qty + + # ATR 조회 (DB 또는 재계산) + atr = holding.get("atr_entry", 0) + if atr == 0: + # ATR 재계산 (3분봉) + try: + df = self.client.get_minute_chart(code, period="3", limit=20) + if not df.empty: + atr = self.calculate_atr(df) + if atr > 0: + self.holdings[code]["atr_entry"] = atr + except Exception as e: + logger.debug(f"ATR 조회 실패({code}): {e}") + atr = buy_price * 0.01 # 기본값 1% + + # 손절가/목표가 (ATR 기반 또는 기본값) + stop_price = holding.get("stop_price", buy_price * (1 + self.stop_loss_pct)) + target_price_atr = holding.get("target_price", buy_price * (1 + self.take_profit_pct)) + target_price_pct = buy_price * (1 + self.take_profit_pct) # 퍼센트 기반 목표가 + + # ATR 기반 손절가/목표가 계산 + if atr > 0: + stop_price = buy_price - (atr * self.stop_atr_multiplier) + target_price_atr = buy_price + (atr * self.target_atr_multiplier) + + # 목표가: ATR 기반과 퍼센트 기반 중 더 작은 값 사용 (둘 다 체크) + target_price = min(target_price_atr, target_price_pct) + + # 매도 사유 판단 + sell_reason = None + + # ========================================================== + # [1] 어깨 매도 (Shoulder Cut) - 최우선! + # 고점 대비 3% 이상 빠지면 수익/손실 불문하고 즉시 탈출 + # ========================================================== + shoulder_cut_pct = get_env_float("SHOULDER_CUT_PCT", 0.03) + drop_from_high = (max_price - current_price) / max_price if max_price > 0 else 0 + if drop_from_high >= shoulder_cut_pct: + sell_reason = "어깨매도" + + # ========================================================== + # [2] 금액 기준 손절 (원 단위) + # ========================================================== + max_loss_per_trade_krw = get_env_int("MAX_LOSS_PER_TRADE_KRW", 200000) + if not sell_reason and profit_val <= -max_loss_per_trade_krw: + sell_reason = "금액손실컷" + + # ========================================================== + # [3] ATR 기반 스캘핑 로직 + # ========================================================== + if not sell_reason and atr > 0: + # [스캘핑 1] 본절사수: 고점이 ATR 1배 이상 올랐는데 현재가가 ATR 0.2배 이하로 떨어짐 + if (max_price >= buy_price + atr * 1.0) and (current_price <= buy_price + atr * 0.2): + sell_reason = "스캘핑_본절사수" + + # [스캘핑 2] 익절보존: 수익 중인데 고점 대비 ATR 1배 이상 하락 + if not sell_reason and current_price < (max_price - atr * 1.0) and profit_pct > 0: + sell_reason = "스캘핑_익절보존" + + # 보유 시간 계산 (N자 패턴 등 다음날 상승 가능성 있는 종목 24시간 보유) + hours_passed = 0 + if buy_time_str: + try: + buy_time = dt.strptime(buy_time_str, "%Y-%m-%d %H:%M:%S") + hours_passed = (dt.now() - buy_time).total_seconds() / 3600 + except: + pass + + # ========================================================== + # [4] 빠른 익절 보호 (매수 후 30분 이내) + # ========================================================== + if not sell_reason and hours_passed > 0: + use_quick_profit = get_env_bool("USE_QUICK_PROFIT_PROTECTION", True) + if use_quick_profit and hours_passed < 0.5: + if max_price >= buy_price * 1.005 and current_price <= buy_price * 1.0015: + sell_reason = "💨 작은수익보호" + + # ========================================================== + # [5] 24시간 보유 전략 (N자 패턴 등 다음날 상승 가능성) + # ========================================================== + # 세력이 N자 만들어서 털어먹으려는 종목은 하루에 안 끝나고 다음날 오르는 경우가 있음 + # 24시간 이내: 특정 조건에서만 매도 (보수적) + # 24시간 이후: 일반 매도 조건 적용 + min_hold_hours = get_env_float("MIN_HOLD_HOURS", 24.0) # 최소 보유 시간 (기본 24시간) + + if not sell_reason and hours_passed > 0: + if hours_passed < min_hold_hours: + # 24시간 이내: 큰 수익(5% 이상) 또는 고점 대비 큰 하락만 매도 + if profit_pct > 0.05: # 5% 이상 수익 + sell_reason = f"💰 {hours_passed:.1f}시간내 5%+ 익절" + elif max_price >= buy_price * 1.07 and current_price <= max_price * 0.97: + # 고점 7% 이상 찍고 고점 대비 3% 이상 하락 + sell_reason = f"📈 {hours_passed:.1f}시간내 고점7%→3%하락" + # 그 외에는 24시간 보유 유지 (손절은 제외) + else: + # 24시간 이후: 일반 매도 조건 적용 + if profit_pct > 0.02: # 2% 이상 수익 + sell_reason = f"⏰ {hours_passed:.1f}시간 경과 2%+ 익절" + elif profit_pct > 0 and current_price < max_price * 0.97: + # 수익 중인데 고점 대비 3% 이상 하락 + sell_reason = f"⏰ {hours_passed:.1f}시간 경과 익절보호" + + # ========================================================== + # [6] 목표가 달성 및 손절 (24시간 보유 전략과 별개로 항상 체크) + # ========================================================== + if not sell_reason: + # 목표가 달성 체크 (ATR 기반 또는 퍼센트 기반 중 먼저 도달한 것) + if current_price >= target_price: + if current_price >= target_price_atr and current_price >= target_price_pct: + sell_reason = "목표달성(ATR+퍼센트)" + elif current_price >= target_price_atr: + sell_reason = "목표달성(ATR)" + else: + sell_reason = "목표달성(퍼센트)" + elif current_price <= stop_price: + sell_reason = "전략손절" + elif profit_pct <= self.stop_loss_pct: + sell_reason = f"칼손절({profit_pct * 100:.1f}%)" + elif profit_pct >= self.take_profit_pct: + sell_reason = "익절(퍼센트)" + + # 매도 신호 추가 + if sell_reason: + sell_signals.append({ + "code": code, + "name": name, + "reason": sell_reason, + "profit_pct": profit_pct, + "qty": qty, + "price": current_price, + }) + + time.sleep(random.uniform(0.3, 0.7)) + except Exception as e: + logger.error(f"매도 신호 체크 실패({code}): {e}") + continue + + return sell_signals + + def execute_buy(self, signal): + """ + 매수 실행 + - 키움 봇과 동일하게 대형주/소형주 비율 맞추는 로직 포함 + - 대형주: 기본 금액 100% / 중형주: 85% / 소형주: 70% + - 켈리 기반 매수 금액 + 종목당 최대 15% 제한 + """ + code = signal["code"] + name = signal["name"] + price = signal["price"] + + # 이미 보유 중이면 스킵 + if code in self.holdings: + logger.warning(f"⚠️ [{name}] 이미 보유 중 -> 매수 스킵") + return False + + # 최대 보유 종목 수 체크 + if len(self.holdings) >= self.max_stocks: + logger.warning(f"⚠️ 최대 보유 종목 수 도달 ({self.max_stocks}개)") + return False + + # 🔥 매수 직전 예수금 실시간 확인 + if not self._update_account_light(profit_val=0): + logger.warning(f"⚠️ [{name}] 예수금 조회 실패 -> 매수 스킵") + return False + + # ============================================================ + # [대/중/소형주 구분] - 일봉 거래대금 평균 (키움 봇과 동일) + # ============================================================ + size_class = None + try: + df = self.client.get_daily_chart(code, limit=10) + if not df.empty and "volume" in df.columns and "close" in df.columns: + trade_values = df["volume"] * df["close"] + avg_trade_value = trade_values.mean() + large_min = get_env_float("SIZE_CLASS_LARGE_MIN", 5000000000) # 50억 (대형주) + mid_min = get_env_float("SIZE_CLASS_MID_MIN", 500000000) # 5억 (중형주) + if avg_trade_value >= large_min: + size_class = "대" + elif avg_trade_value >= mid_min: + size_class = "중" + else: + size_class = "소" + logger.info( + f"📊 [{name}] 거래대금 평균 {avg_trade_value/1e8:.1f}억원 → {size_class}형주" + ) + except Exception as e: + logger.debug(f"대/중/소형 조회 스킵({code}): {e}") + + # ============================================================ + # [매수 금액] 변동성 역가중 (Volatility Inverse Weighting) + # ============================================================ + # ATR 계산용 분봉 데이터 (변동성 계산에 필요) + df_minute = None + try: + df_minute = self.client.get_minute_chart(code, period="3", limit=20) + except Exception as e: + logger.debug(f"분봉 조회 실패({code}): {e}") + + # RiskManager 사용 시: 변동성 역가중으로 매수 금액 계산 + if self.risk_mgr is not None: + # 켈리 비율 (DB에서 계산, 없으면 None) + kelly_fraction = None + if self.risk_mgr.use_kelly: + try: + kelly_fraction = self.db.calculate_half_kelly() + except Exception as e: + logger.debug(f"켈리 비율 계산 스킵: {e}") + + # 변동성 역가중 매수 금액 계산 + amount = self.risk_mgr.get_position_size( + stock_name=name, + current_balance=self.current_cash, + df=df_minute, # ATR 계산용 분봉 데이터 + kelly_fraction=kelly_fraction, + size_class=size_class, # 대/중/소형 구분 + ) + + if amount <= 0: + logger.warning(f"⚠️ [{name}] RiskManager 계산 금액 0원 -> 매수 스킵") + return False + + # 수량 계산 (수수료 고려) + qty = self.risk_mgr.calculate_quantity(price, amount) + else: + # 폴백: 기존 고정 슬롯 방식 (RiskManager 미사용 시) + if self.max_stocks > 0: + slot_money = int(self.current_cash * 0.9 / self.max_stocks) + else: + slot_money = 100000 + base_amount = min(slot_money, 100000) + if self.stop_loss_pct != 0: + stop_pct_abs = abs(self.stop_loss_pct) + else: + stop_pct_abs = 0.04 + if stop_pct_abs > 0: + kelly_risk_amount = self.current_cash * self.risk_pct_per_trade * self.kelly_multiplier + kelly_based_amount = int(kelly_risk_amount / stop_pct_abs) + base_amount = min(base_amount, kelly_based_amount) + if size_class == "소": + amount = int(base_amount * 0.7) + logger.info(f"💰 [{name}] 소형주 → 매수 금액 70%: {amount:,.0f}원") + elif size_class == "중": + amount = int(base_amount * 0.85) + logger.info(f"💰 [{name}] 중형주 → 매수 금액 85%: {amount:,.0f}원") + else: + amount = base_amount + max_limit = int(self.current_cash * self.max_position_pct) + if amount > max_limit: + logger.info(f"📐 [{name}] 최대 포지션 제한: {amount:,.0f}원 → {max_limit:,.0f}원") + amount = max_limit + amount = max(amount, self.min_position_amount) + qty = int(amount / price) + if qty <= 0: + logger.warning(f"⚠️ [{name}] 매수 수량 0 (가격: {price:,.0f}원, 금액: {amount:,.0f}원)") + return False + + required_amount = price * qty * 1.05 + if self.current_cash < required_amount: + logger.warning( + f"⚠️ [{name}] 예수금 부족: 필요 {required_amount:,.0f}원 / " + f"보유 {self.current_cash:,.0f}원 -> 매수 스킵" + ) + return False + + # ATR 계산 (변동성 기반 손절가/목표가 설정용) + # df_minute는 위에서 이미 조회했으므로 재사용 + atr = 0 + stop_price = price * (1 + self.stop_loss_pct) + target_price = price * (1 + self.take_profit_pct) + if df_minute is not None and not df_minute.empty: + try: + atr = self.calculate_atr(df_minute) + if atr > 0: + # ATR 기반 손절가/목표가 설정 + stop_price = price - (atr * self.stop_atr_multiplier) + target_price = price + (atr * self.target_atr_multiplier) + logger.info(f"📊 [{name}] ATR 기반 손절가/목표가: ATR={atr:.0f}원, 손절={stop_price:,.0f}원, 목표={target_price:,.0f}원") + except Exception as e: + logger.debug(f"ATR 계산 스킵({code}): {e}") + atr = price * 0.01 # 기본값 1% + else: + atr = price * 0.01 # 기본값 1% + + success = self.client.buy_market_order(code, qty) + if success: + self._update_account_light(profit_val=0) + buy_time = dt.now().strftime("%Y-%m-%d %H:%M:%S") + self.holdings[code] = { + "buy_price": price, + "qty": qty, + "buy_time": buy_time, + "name": name, + "max_price": price, # 고점 추적 + "atr_entry": atr, # 매수 시점 ATR 저장 + "stop_price": stop_price, # 손절가 + "target_price": target_price, # 목표가 + } + self.db.upsert_trade({ + "code": code, + "name": name, + "strategy": "SHORT_ANT_SHAKING", + "avg_buy_price": price, + "current_price": price, + "target_qty": qty, + "current_qty": qty, + "status": "HOLDING", + "buy_date": buy_time, + "stop_price": stop_price, + "target_price": target_price, + "atr_entry": atr, + }) + logger.info(f"💰 [매수 체결] {name} ({code}): {price:,.0f}원 × {qty}주 | 손절={stop_price:,.0f}원, 목표={target_price:,.0f}원") + return True + return False + + def execute_sell(self, signal): + """매도 실행""" + code = signal["code"] + name = signal["name"] + qty = signal["qty"] + + if code not in self.holdings: + logger.warning(f"⚠️ [{name}] 보유 종목 아님") + return False + + # 매도 주문 + success = self.client.sell_market_order(code, qty) + if success: + # 현재가 조회 + price_data = self.client.inquire_price(code) + if price_data: + sell_price = abs(float(price_data.get("stck_prpr", 0))) + else: + sell_price = signal.get("price", 0) + + # 손익 계산 (매도 후 총자산 반영용) + holding = self.holdings.get(code, {}) + buy_price = holding.get("buy_price", sell_price) + profit_val = (sell_price - buy_price) * qty # 손익 금액 + + # DB에서 매도 처리 + self.db.close_trade( + code=code, + sell_price=sell_price, + sell_reason=signal['reason'], + ) + + del self.holdings[code] + + # 🔥 매도 후 예수금 + 총자산 즉시 업데이트 (손익 반영) + self._update_account_light(profit_val=profit_val) + + logger.info(f"💸 [매도 체결] {name} ({code}): {qty}주 ({signal['reason']}, {signal['profit_pct']*100:+.2f}%)") + return True + + return False + + def run(self): + """메인 루프 (진입점). 내부적으로 asyncio.run(_run_async()) 호출.""" + asyncio.run(self._run_async()) + + async def _run_async(self): + """비동기 메인 루프 - 백그라운드 태스크 시작 후 동기 매매 루프 실행""" + logger.info("🚀 단타 트레이딩 봇 시작 (비동기 백그라운드 작업 활성화)") + + # 백그라운드 태스크 시작 + self._universe_task = asyncio.create_task(self._universe_scan_scheduler()) + self._report_task = asyncio.create_task(self._report_scheduler()) + self._asset_task = asyncio.create_task(self._asset_update_scheduler()) + logger.info("✅ 백그라운드 태스크 시작 완료 (유니버스 스캔, 리포트, 자산 업데이트)") + + # 동기 매매 루프는 별도 스레드에서 실행 (메인 이벤트 루프 블로킹 방지) + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self._sync_trading_loop) + + def _sync_trading_loop(self): + """동기 매매 루프 (메인 로직) - 백그라운드 작업과 분리""" + logger.info("📈 매매 루프 시작 (동기 모드)") + + while True: + try: + now = dt.now() + current_date = now.strftime("%Y-%m-%d") + + # 날짜 변경 시 리포트 플래그 리셋 + if current_date != self.today_date: + self.today_date = current_date + self.morning_report_sent = False + self.closing_report_sent = False + self.final_report_sent = False + self.ai_report_sent = False + self.start_day_asset = 0 + logger.info(f"📅 날짜 변경: {current_date}") + + # 리포트 전송은 백그라운드 태스크에서 처리하므로 여기서는 제거 + # (기존 코드와의 호환성을 위해 주석 처리) + # if now.hour == 13 and now.minute == 0: + # self.send_morning_report() + # self.send_ai_report() + # elif now.hour == 15 and now.minute == 15: + # self.send_closing_report() + # elif now.hour == 15 and now.minute == 35: + # self.send_final_report() + + # 장 상태 체크 + if not self.check_market_status(): + logger.info("⏸ 장 시간 아님 - 대기 중...") + time.sleep(60) + continue + + # 자산 정보 업데이트는 백그라운드 태스크에서 처리하므로 여기서는 제거 + # (기존 코드와의 호환성을 위해 주석 처리) + # if now.minute % 30 == 0: # 30분마다 + # self._update_assets() + + # 매도 신호 체크 (우선) - 메인 루프에서 처리 + sell_signals = self.check_sell_signals() + for signal in sell_signals: + self.execute_sell(signal) + db_candidates = self.db.get_target_candidates() + if db_candidates: + logger.info(f"🔍 [매수 기회 탐색] 타겟:{len(db_candidates)}개 | 보유:{active_count}/{self.max_stocks}") + # DB 후보군이 있으면 사용 + for db_item in db_candidates[:1]: # 상위 1개만 + code = db_item['code'] + name = db_item['name'] + # 실제 가격 확인 후 매수 + price_data = self.client.inquire_price(code) + if price_data: + current_price = abs(float(price_data.get("stck_prpr", 0))) + candidate = { + 'code': code, + 'name': name, + 'price': current_price, + 'score': db_item.get('score', 0), + } + self.execute_buy(candidate) + time.sleep(random.uniform(1, 2)) + break + else: + # DB 후보군이 없으면 대기 (유니버스 업데이트 대기) + # ⚠️ 직접 스캔하지 않음 - 백그라운드 태스크에서 5분마다 업데이트됨 + if active_count == 0: # 첫 실행 시에만 로그 + logger.info(f"🔍 [매수 기회 탐색] 타겟:0개 (유니버스 스캔 대기 중) | 보유:{active_count}/{self.max_stocks}") + + # 대기 + time.sleep(random.uniform(3, 5)) + + except KeyboardInterrupt: + logger.info("⏹ 봇 종료") + # 백그라운드 태스크 취소 + if self._universe_task: + self._universe_task.cancel() + if self._report_task: + self._report_task.cancel() + if self._asset_task: + self._asset_task.cancel() + break + except Exception as e: + logger.error(f"❌ 루프 에러: {e}") + time.sleep(5) + + +if __name__ == "__main__": + bot = ShortTradingBot() + bot.run() diff --git a/ml_predictor.py b/ml_predictor.py new file mode 100644 index 0000000..4ff41fa --- /dev/null +++ b/ml_predictor.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +""" +KIS Bot용 ML 승률 예측 모델 +- kis_bot/quant_bot.db의 trade_history 데이터로 학습 +- 매수 신호의 승률 예측 (0.0 ~ 1.0) +- 주간 단위 자동 재학습 +""" +import os +import pickle +import sqlite3 +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.model_selection import train_test_split + 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, + model_path: str = None, + ): + # 기본값: kis_bot/quant_bot.db, kis_bot/ml_model.pkl + self.db_path = db_path or str(SCRIPT_DIR / "quant_bot.db") + 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: + """DB에서 학습용 피처 추출 + + 현재는 trade_history의 profit_rate 기반으로 승/패 라벨만 생성하고, + 피처는 프로토타입 단계로 랜덤 값을 사용한다. + (실전에서는 active_trades에 진입 시점 피처를 저장해서 사용해야 함) + """ + try: + conn = sqlite3.connect(self.db_path) + cutoff_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d") + + query = f""" + SELECT profit_rate, buy_date, sell_date, strategy + FROM trade_history + WHERE sell_date >= '{cutoff_date}' + ORDER BY sell_date DESC + """ + + df = pd.read_sql_query(query, conn) + conn.close() + + if len(df) < self.min_train_samples: + logger.warning( + f"⚠️ 학습 데이터 부족: {len(df)}건 (최소 {self.min_train_samples}건 필요)" + ) + return None + + df["is_win"] = (df["profit_rate"] > 0).astype(int) + logger.info( + f"📊 학습 데이터 로드: {len(df)}건 " + f"(익절: {df['is_win'].sum()}건, 손절: {(1 - 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 + + logger.warning("⚠️ [프로토타입] 랜덤 피처로 학습 중") + logger.warning(" → 실제 운영 시: active_trades 테이블에 진입 피처 저장 후 사용") + + # TODO: 실제 피처 데이터로 교체 필요 + # 현재는 데모용 랜덤 피처 사용 + np.random.seed(42) + X = pd.DataFrame( + { + "rsi": np.random.uniform(20, 80, len(df)), + "volume_ratio": np.random.uniform(0.5, 5.0, len(df)), + "tail_length_pct": np.random.uniform(0, 5, len(df)), + "ma5_gap_pct": np.random.uniform(-5, 5, len(df)), + "ma20_gap_pct": np.random.uniform(-10, 10, len(df)), + "foreign_net_buy": np.random.uniform(-1000, 1000, len(df)), + "institution_net_buy": np.random.uniform(-500, 500, len(df)), + "market_hour": np.random.randint(9, 15, len(df)), + } + ) + y = df["is_win"].values + + X_train, X_test, y_train, y_test = train_test_split( + X, y, test_size=0.2, random_state=42, stratify=y + ) + + 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)""" + 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 + diff --git a/modify_db_schema.py b/modify_db_schema.py new file mode 100644 index 0000000..8b615f7 --- /dev/null +++ b/modify_db_schema.py @@ -0,0 +1,37 @@ +import sqlite3 +import os + +# 데이터베이스 파일 경로 +db_path = os.path.join(os.path.dirname(__file__), 'quant_bot.db') + +# 연결 생성 +conn = sqlite3.connect(db_path) +cursor = conn.cursor() + +# 기존 필드 이름 변경 +print("기존 테이블 필드 변경 중...") +cursor.execute("ALTER TABLE env_config RENAME COLUMN KIS_APP_KEY TO KIS_APP_KEY_REAL") +cursor.execute("ALTER TABLE env_config RENAME COLUMN KIS_APP_SECRET TO KIS_APP_SECRET_REAL") +cursor.execute("ALTER TABLE env_config RENAME COLUMN KIS_ACCOUNT_NO TO KIS_ACCOUNT_NO_REAL") +cursor.execute("ALTER TABLE env_config RENAME COLUMN KIS_ACCOUNT_CODE TO KIS_ACCOUNT_CODE_REAL") + +# 모의계좌용 필드 추가 +print("모의계좌용 필드 추가 중...") +cursor.execute("ALTER TABLE env_config ADD COLUMN KIS_APP_KEY_MOCK TEXT") +cursor.execute("ALTER TABLE env_config ADD COLUMN KIS_APP_SECRET_MOCK TEXT") +cursor.execute("ALTER TABLE env_config ADD COLUMN KIS_ACCOUNT_NO_MOCK TEXT") +cursor.execute("ALTER TABLE env_config ADD COLUMN KIS_ACCOUNT_CODE_MOCK TEXT") + +# 변경된 필드명으로 데이터 이동 (기존 데이터 복사) +print("기존 데이터 이동 중...") +# 실제 데이터베이스에서 KIS_APP_KEY_REAL을 KIS_APP_KEY_MOCK로 복사 (모의계좌용으로 사용) +cursor.execute("UPDATE env_config SET KIS_APP_KEY_MOCK = KIS_APP_KEY_REAL") +cursor.execute("UPDATE env_config SET KIS_APP_SECRET_MOCK = KIS_APP_SECRET_REAL") +cursor.execute("UPDATE env_config SET KIS_ACCOUNT_NO_MOCK = KIS_ACCOUNT_NO_REAL") +cursor.execute("UPDATE env_config SET KIS_ACCOUNT_CODE_MOCK = KIS_ACCOUNT_CODE_REAL") + +# 변경 내용 저장 +conn.commit() + +print("데이터베이스 스키마 변경 완료") +conn.close() \ No newline at end of file diff --git a/quant_bot.db b/quant_bot.db new file mode 100644 index 0000000..a7d8ab6 Binary files /dev/null and b/quant_bot.db differ diff --git a/risk_manager.py b/risk_manager.py new file mode 100644 index 0000000..52d2e08 --- /dev/null +++ b/risk_manager.py @@ -0,0 +1,270 @@ +""" +변동성 기반 리스크 관리 모듈 +- 예수금 비율 기반 비중 조절 +- ATR/변동성 역가중 (Volatility Inverse Weighting) +- 종목별 안전 매수 금액 계산 +""" +import logging +import pandas as pd +import numpy as np +from typing import List, Dict + +logger = logging.getLogger("RiskManager") + + +class RiskManager: + """ + [예수금 연동 리스크 관리 모듈] + - 고정 금액이 아닌 '현재 예수금' 대비 퍼센트로 투자금 계산 + - 변동성 큰 종목 = 적게 매수, 변동성 작은 종목 = 많이 매수 + """ + + def __init__( + self, + risk_pct_per_trade: float = 0.02, # 1회 거래 시 허용 손실 비율 (2%) + max_position_pct: float = 0.20, # 종목당 최대 비중 (20%) + min_position_amount: int = 50000, # 최소 매수 금액 (5만원) + use_kelly: bool = False, # 켈리 공식 사용 여부 + kelly_multiplier: float = 0.5 # 켈리 배율 (0.5 = 하프 켈리) + ): + """ + Args: + risk_pct_per_trade: 1회 매매 시 허용할 손실 비율 (기본 0.02 = 2%) + max_position_pct: 종목당 최대 투자 비율 (기본 0.20 = 20%) + min_position_amount: 최소 매수 금액 (너무 작은 주문 방지) + use_kelly: 켈리 공식 활성화 여부 + kelly_multiplier: 켈리 배율 (0.5 = 하프켈리, 0.25 = 쿼터켈리) + """ + self.risk_pct = risk_pct_per_trade + self.max_pos_pct = max_position_pct + self.min_amount = min_position_amount + self.use_kelly = use_kelly + self.kelly_mult = kelly_multiplier + + logger.info( + f"💰 RiskManager 초기화: 리스크{self.risk_pct*100}% | " + f"최대비중{self.max_pos_pct*100}% | 켈리{'ON' if use_kelly else 'OFF'}" + ) + + def calculate_volatility_atr(self, df: pd.DataFrame, period: int = 14) -> float: + """ + ATR(Average True Range) 기반 변동성 계산 + + Args: + df: OHLC 데이터프레임 (컬럼: open, high, low, close) + period: ATR 계산 기간 (기본 14) + + Returns: + 현재가 대비 ATR 비율 (%) + """ + try: + if df is None or len(df) < period: + logger.warning(f"⚠️ ATR 계산: 데이터 부족 -> 기본값 3.0% 리턴") + return 3.0 + + # True Range 계산 + df = df.copy() + df['tr'] = np.maximum( + df['high'] - df['low'], + np.maximum( + np.abs(df['high'] - df['close'].shift()), + np.abs(df['low'] - df['close'].shift()) + ) + ) + + # ATR = TR의 이동평균 + atr = df['tr'].rolling(window=period).mean().iloc[-1] + current_price = df['close'].iloc[-1] + + if current_price == 0: + return 3.0 + + # 현재가 대비 ATR 비율 (%) + volatility_pct = (atr / current_price) * 100 + + # 최소값 보정 (0.5% 미만은 0.5%로) + return max(round(volatility_pct, 2), 0.5) + + except Exception as e: + logger.error(f"❌ ATR 계산 에러: {e}") + return 3.0 + + def calculate_volatility_simple(self, candles: List[Dict], period: int = 10) -> float: + """ + 간단한 변동성 계산 (고저차 비율 평균) + + Args: + candles: 캔들 데이터 리스트 [{'high': , 'low': , 'close': }, ...] + period: 계산 기간 (기본 10일) + + Returns: + 변동성 (%) + """ + try: + if not candles or len(candles) < period: + return 3.0 + + recent = candles[-period:] + volatility_sum = 0.0 + + for candle in recent: + high = float(candle.get('high', 0)) + low = float(candle.get('low', 0)) + close = float(candle.get('close', 1)) + + if close == 0: + continue + + # (고가 - 저가) / 종가 + volatility_sum += (high - low) / close + + avg_vol = (volatility_sum / len(recent)) * 100 + return max(round(avg_vol, 2), 0.5) + + except Exception as e: + logger.error(f"❌ 변동성 계산 에러: {e}") + return 3.0 + + def get_position_size( + self, + stock_name: str, + current_balance: float, + volatility_pct: float = None, + df: pd.DataFrame = None, + kelly_fraction: float = None, + size_class: str = None, + ) -> int: + """ + [메인 함수] 종목별 안전 매수 금액 계산 + + Args: + stock_name: 종목명 (로깅용) + current_balance: 현재 예수금 + volatility_pct: 변동성 (직접 제공 시) + df: OHLC 데이터프레임 (ATR 계산용) + kelly_fraction: 켈리 비율 (DB에서 계산한 값) + size_class: 대/중/소형 구분 ("대", "중", "소") + + Returns: + 추천 매수 금액 (원) + """ + try: + if current_balance <= 0: + logger.warning(f"⚠️ [{stock_name}] 예수금 0원 이하") + return 0 + + # 1. 변동성 계산 + if volatility_pct is None: + if df is not None and not df.empty: + volatility_pct = self.calculate_volatility_atr(df) + else: + logger.warning(f"⚠️ [{stock_name}] 변동성 데이터 없음 -> 기본값 3.0%") + volatility_pct = 3.0 + + # 2. 기본 리스크 금액 계산 (예수금 * 리스크 비율) + # 예: 1,000만 원 * 2% = 20만 원 (이 종목에서 최대 20만원까지 잃을 수 있음) + allowable_risk = current_balance * self.risk_pct + + # 3. 켈리 공식 적용 (쿼터 켈리/하프 켈리) + if self.use_kelly: + if kelly_fraction is not None and kelly_fraction > 0: + # DB에서 계산한 켈리 비율 사용 (하프 켈리) + base_position_pct = kelly_fraction * self.kelly_mult + allowable_risk = current_balance * base_position_pct + logger.info(f"🎲 [{stock_name}] 켈리 적용(DB): {base_position_pct*100:.1f}% (켈리={kelly_fraction*100:.1f}% × 배율={self.kelly_mult})") + else: + # 켈리 비율이 없으면 쿼터 켈리 배율을 기본값으로 사용 + # 예: 리스크 1% × 쿼터 켈리 0.25 = 0.25% (더 보수적) + base_position_pct = self.risk_pct * self.kelly_mult + allowable_risk = current_balance * base_position_pct + logger.info(f"🎲 [{stock_name}] 켈리 적용(기본): {base_position_pct*100:.1f}% (리스크={self.risk_pct*100}% × 쿼터켈리={self.kelly_mult})") + + # 4. 변동성 역가중으로 매수 금액 계산 + # 공식: (허용 손실액) / (변동성 비율) + # 변동성 5% -> 20만 / 0.05 = 400만원 매수 + # 변동성 10% -> 20만 / 0.10 = 200만원 매수 + risk_ratio = volatility_pct / 100.0 + calculated_amount = allowable_risk / risk_ratio + + # 5. 상한선 체크 (종목당 최대 비중) + max_limit = current_balance * self.max_pos_pct + + if calculated_amount > max_limit: + final_amount = max_limit + note = f"(최대{self.max_pos_pct*100:.0f}% 제한)" + else: + final_amount = calculated_amount + note = "(변동성기반)" + + # 5-2. 대형/소형 구간별 조정 (소형주는 포지션 축소) + if size_class == "소": + final_amount = int(final_amount * 0.7) + note = "(소형주 70%)" + elif size_class == "중": + final_amount = int(final_amount * 0.85) + note = "(중형주 85%)" + elif size_class == "대": + pass # 대형주는 기존과 동일 + + # 6. 하한선 체크 (너무 작은 금액은 거래 안함) + if final_amount < self.min_amount: + logger.info( + f"🚫 [{stock_name}] 계산 금액({final_amount:,.0f}원) < " + f"최소금액({self.min_amount:,.0f}원) -> 매수 보류" + ) + return 0 + + logger.info( + f"💰 [{stock_name}] 예수금:{current_balance:,.0f}원 | " + f"변동성:{volatility_pct:.2f}% | 추천:{int(final_amount):,.0f}원 {note}" + ) + + return int(final_amount) + + except Exception as e: + logger.error(f"❌ [{stock_name}] 포지션 사이즈 계산 실패: {e}") + return 0 + + def calculate_quantity( + self, + current_price: float, + target_amount: int, + fee_rate: float = 0.00015 + ) -> int: + """ + 목표 금액을 현재가와 수수료 고려하여 수량으로 변환 + + Args: + current_price: 현재 주가 + target_amount: 목표 투자 금액 + fee_rate: 수수료율 (기본 0.015%) + + Returns: + 매수 가능 수량 (주) + """ + try: + if current_price <= 0 or target_amount <= 0: + return 0 + + # 수수료 고려 실제 매수 가능 금액 + # 총비용 = 가격 * 수량 * (1 + 수수료율) + # 수량 = 목표금액 / (가격 * (1 + 수수료율)) + max_buy_amount = target_amount / (1 + fee_rate) + quantity = int(max_buy_amount / current_price) + + if quantity < 1: + return 0 + + # 예상 총 비용 계산 (확인용) + expected_cost = quantity * current_price * (1 + fee_rate) + + logger.debug( + f"💵 수량 계산: {current_price:,.0f}원 × {quantity}주 = " + f"예상비용 {expected_cost:,.0f}원 (목표: {target_amount:,.0f}원)" + ) + + return quantity + + except Exception as e: + logger.error(f"❌ 수량 계산 에러: {e}") + return 0 diff --git a/scripts/copy_env_row_to_latest.py b/scripts/copy_env_row_to_latest.py new file mode 100644 index 0000000..e69de29 diff --git a/update_env.py b/update_env.py new file mode 100644 index 0000000..75d461c --- /dev/null +++ b/update_env.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +""" +환경변수 DB 업데이트 스크립트 +- 기존 최신 설정을 불러와서 +- 새로운 값으로 업데이트하고 +- 새 스냅샷으로 저장 +""" +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from database import TradeDB + +def update_env_config(): + """환경변수 업데이트 (대화형)""" + db_path = SCRIPT_DIR / "quant_bot.db" + db = TradeDB(db_path=str(db_path)) + + # 기존 최신 설정 불러오기 + latest = db.get_latest_env() + if latest and latest.get("snapshot"): + current = latest["snapshot"] + print("📋 현재 설정:") + print(f" (최신 스냅샷 ID: {latest['id']}, 생성일: {latest['created_at']})") + print() + else: + current = {} + print("⚠️ 기존 설정 없음 - 새로 생성합니다.") + print() + + # 업데이트할 값들 + updates = {} + + print("=" * 60) + print("환경변수 업데이트") + print("=" * 60) + print("(값을 입력하지 않으면 기존 값 유지, 'skip' 입력 시 건너뜀)") + print() + + # 한투 API 설정 + print("🔵 [한투 API 설정]") + val = input(f"KIS_APP_KEY [{current.get('KIS_APP_KEY', '')[:20]}...]: ").strip() + if val and val.lower() != 'skip': + updates["KIS_APP_KEY"] = val + + val = input(f"KIS_APP_SECRET [{current.get('KIS_APP_SECRET', '')[:20]}...]: ").strip() + if val and val.lower() != 'skip': + updates["KIS_APP_SECRET"] = val + + val = input(f"KIS_ACCOUNT_NO [{current.get('KIS_ACCOUNT_NO', '')}]: ").strip() + if val and val.lower() != 'skip': + updates["KIS_ACCOUNT_NO"] = val + + val = input(f"KIS_ACCOUNT_CODE [{current.get('KIS_ACCOUNT_CODE', '01')}]: ").strip() + if val and val.lower() != 'skip': + updates["KIS_ACCOUNT_CODE"] = val + + val = input(f"KIS_MOCK (true/false) [{current.get('KIS_MOCK', 'true')}]: ").strip() + if val and val.lower() != 'skip': + updates["KIS_MOCK"] = val.lower() + + print() + + # Mattermost 설정 + print("💬 [Mattermost 설정]") + val = input(f"MM_SERVER_URL [{current.get('MM_SERVER_URL', 'https://mattermost.hoonfam.org')}]: ").strip() + if val and val.lower() != 'skip': + updates["MM_SERVER_URL"] = val + + val = input(f"MM_BOT_TOKEN_ [{current.get('MM_BOT_TOKEN_', '')[:20]}...]: ").strip() + if val and val.lower() != 'skip': + updates["MM_BOT_TOKEN_"] = val + + val = input(f"MATTERMOST_CHANNEL (기본 채널 alias) [{current.get('MATTERMOST_CHANNEL', 'stock')}]: ").strip() + if val and val.lower() != 'skip': + updates["MATTERMOST_CHANNEL"] = val + + val = input(f"KIS_SHORT_MM_CHANNEL (단타 봇 채널 alias) [{current.get('KIS_SHORT_MM_CHANNEL', '')}]: ").strip() + if val and val.lower() != 'skip': + updates["KIS_SHORT_MM_CHANNEL"] = val + + val = input(f"KIS_LONG_MM_CHANNEL (롱타 봇 채널 alias) [{current.get('KIS_LONG_MM_CHANNEL', '')}]: ").strip() + if val and val.lower() != 'skip': + updates["KIS_LONG_MM_CHANNEL"] = val + + print() + + # Gemini API 설정 + print("🤖 [Gemini AI 설정]") + val = input(f"GEMINI_API_KEY [{current.get('GEMINI_API_KEY', '')[:20]}...]: ").strip() + if val and val.lower() != 'skip': + updates["GEMINI_API_KEY"] = val + + print() + + # 기존 값과 병합 + new_snapshot = {**current, **updates} + + # 확인 + print("=" * 60) + print("업데이트할 값:") + print("=" * 60) + for key, value in updates.items(): + display_value = value[:30] + "..." if len(str(value)) > 30 else value + print(f" {key}: {display_value}") + + print() + confirm = input("위 설정을 저장하시겠습니까? (y/n): ").strip().lower() + + if confirm != 'y': + print("❌ 취소되었습니다.") + db.close() + return + + # 새 스냅샷 저장 + env_id = db.insert_env_snapshot(new_snapshot) + if env_id: + print(f"✅ 환경변수 저장 완료! (새 스냅샷 ID: {env_id})") + print() + print("💡 팁:") + print(" - 봇을 재시작하면 새 설정이 적용됩니다.") + print(" - mm_config.json 파일도 확인하세요:") + print(" {") + print(' "channels": {') + print(' "stock": "채널_ID",') + print(' "kis-short": "단타_채널_ID",') + print(' "kis-long": "롱타_채널_ID"') + print(" }") + print(" }") + else: + print("❌ 저장 실패!") + + db.close() + +if __name__ == "__main__": + try: + update_env_config() + except KeyboardInterrupt: + print("\n\n❌ 사용자 취소") + except Exception as e: + print(f"\n\n❌ 에러: {e}") + import traceback + traceback.print_exc() diff --git a/update_env_short.py b/update_env_short.py new file mode 100644 index 0000000..7c9c44c --- /dev/null +++ b/update_env_short.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +""" +단타 봇용 환경변수 DB 업데이트 스크립트 +- .env 파일의 단타 설정값을 DB에 저장 +""" +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from database import TradeDB + +def update_short_bot_env(): + """단타 봇 환경변수 업데이트""" + db_path = SCRIPT_DIR / "quant_bot.db" + db = TradeDB(db_path=str(db_path)) + + # 기존 최신 설정 불러오기 + latest = db.get_latest_env() + if latest and latest.get("snapshot"): + current = latest["snapshot"] + print(f"📋 기존 설정 불러옴 (ID: {latest['id']})") + else: + current = {} + print("⚠️ 기존 설정 없음 - 새로 생성") + + # 단타 봇 설정값 (.env 23-140줄 기준) + short_bot_updates = { + # ========== 기본 거래 설정 ========== + "MAX_STOCKS": "7", # 최대 보유 종목 수 + "STOP_LOSS_PCT": "-0.18", # 종목별 손절 기준 (-18%) + "USE_SLOT_CAP": "true", # 종목당 금액 상한(슬롯) 적용 + "SLOT_CAP_PCT": "0.9", # 슬롯 계산 시 예수금 반영 비율 (90%) + + # ========== 리스크 관리 (손실 한도) ========== + "USE_RISK_CHECK": "false", # 일일 손실 한도 체크 + "DAILY_STOP_LOSS_PCT": "-0.05", # 일일 손실 한도 (-5%) + "CONSECUTIVE_LOSS_LIMIT": "7", # 연속 손절 한도 (7회) + + # ========== 금지 종목 관리 (재매수 방지) ========== + "USE_BAN_SYSTEM": "true", # 금지 종목 관리 시스템 + "BAN_HOURS": "24", # 금지 시간 (24시간) + + # ========== 종목 필터링 (쓰레기 종목 제외) ========== + "USE_STOCK_FILTER": "true", # 종목 필터링 + + # ========== 작은 수익 보호 (30분 내) ========== + "USE_QUICK_PROFIT_PROTECTION": "true", # 작은 수익 보호 + + # ========== 리스크 관리 (Ver2 신규) ========== + "RISK_PCT_PER_TRADE": "0.015", # 1회 거래 시 허용 손실 비율 (1.5%) + "MAX_POSITION_PCT": "0.20", # 종목당 최대 투자 비중 (20%) + "MIN_POSITION_AMOUNT": "20000", # 최소 매수 금액 (2만원) + + # ========== 켈리 공식 (Ver2 신규) ========== + "USE_KELLY": "false", # 하프 켈리 공식 활성화 + + # ========== ML 승률 예측 (Ver2 신규 - 실험적!) ========== + "USE_ML_SIGNAL": "false", # ML 기반 매수 신호 필터링 + "ML_MIN_PROBABILITY": "0.57", # ML 승률 임계값 (57%) + + # ========== 뉴스 AI 분석 (Ver2 신규 - 참고용!) ========== + "USE_NEWS_ANALYSIS": "false", # 뉴스 AI 분석 활성화 + "NEWS_ANALYSIS_HOUR": "9", # 뉴스 분석 시간 (9시) + "NEWS_MAX_COUNT": "5", # 크롤링할 뉴스 개수 (5개) + + # ========== 랜덤 분할 매수 (Dual 방식 - 승률 최적화!) ========== + "USE_RANDOM_SPLIT": "true", # 랜덤 분할 매수 활성화 + + # ========== TWAP 분할 매수 (Ver2 신규) ========== + "USE_TWAP": "false", # TWAP 분할 매수 활성화 + "TWAP_MIN_SPLIT": "500000", # 1회 최소 주문 금액 (50만원) + "TWAP_MAX_SPLIT": "2000000", # 1회 최대 주문 금액 (200만원) + "TWAP_MIN_DELAY": "30", # 분할 매수 간 최소 딜레이 (초) + "TWAP_MAX_DELAY": "180", # 분할 매수 간 최대 딜레이 (초) + + # ========== 디버그용 장 상태 강제 오픈 ========== + "FORCE_MARKET_OPEN": "true", # 장 상태에 관계없이 항상 매매 로직 실행 (테스트용!) + + # ========== 매수 전략 파라미터 ========== + "RSI_OVERHEAT_THRESHOLD": "78", # RSI 과열 기준 (진입 보류 기준) + "SHOULDER_CUT_PCT": "0.03", # 어깨 매도 기준 (고점 대비 하락률) + + # ========== 🚨 피뢰침 방지 필터 (강화!) ========== + "HIGH_PRICE_CHASE_THRESHOLD": "0.96", # 일일 최고가 추격 매수 방지 기준 + "MAX_DAILY_CHANGE_PCT": "18.0", # 당일 최대 변동폭 (%) + "MA20_MAX_ABOVE_PCT": "8.8", # 20선 과열 방지: MA20 대비 이 % 초과 위면 매수 스킵 + + "MIN_RECOVERY_RATIO": "0.32", # 꼬리 잡기 최소 회복 비율 + "MAX_RECOVERY_RATIO": "1.15", # 꼬리 잡기 최대 회복 비율 + "CANDLE_OPEN_PRICE_BUFFER": "0.98", # 시가 대비 현재가 버퍼 (음봉 꼬리 위험 방지) + "VOLUME_AVG_MULTIPLIER": "0.50", # 거래량 평균 대비 배율 + + "INTRADAY_INVESTOR_NET_BUY_THRESHOLD": "-1000", # 장중 투자자 순매수 기준 + + # ========== ATR 배율 설정 ========== + "STOP_ATR_MULTIPLIER_TAIL": "2.5", # TAIL_CATCH_3M 전략 손절가 (ATR × 2.5) + "TARGET_ATR_MULTIPLIER_TAIL": "8.0", # TAIL_CATCH_3M 전략 목표가 (ATR × 8.0) + + # 단타 봇 전용 설정 (기존 값 유지 또는 기본값) + "TAKE_PROFIT_PCT": current.get("TAKE_PROFIT_PCT", "0.05"), # 익절 기준 (5%) + "MIN_DROP_RATE": current.get("MIN_DROP_RATE", "0.03"), # 최소 낙폭 (3%) + "MIN_RECOVERY_RATIO_SHORT": current.get("MIN_RECOVERY_RATIO_SHORT", "0.5"), # 최소 회복 비율 (50%) + } + + # 기존 값과 병합 (단타 봇 설정으로 덮어쓰기) + new_snapshot = {**current, **short_bot_updates} + + # 새 스냅샷 저장 + env_id = db.insert_env_snapshot(new_snapshot) + if env_id: + print(f"✅ 단타 봇 환경변수 저장 완료! (새 스냅샷 ID: {env_id})") + print() + print("📋 저장된 주요 설정:") + print(f" - MAX_STOCKS: {short_bot_updates['MAX_STOCKS']}") + print(f" - STOP_LOSS_PCT: {short_bot_updates['STOP_LOSS_PCT']}") + print(f" - USE_RANDOM_SPLIT: {short_bot_updates['USE_RANDOM_SPLIT']}") + print(f" - RSI_OVERHEAT_THRESHOLD: {short_bot_updates['RSI_OVERHEAT_THRESHOLD']}") + print(f" - FORCE_MARKET_OPEN: {short_bot_updates['FORCE_MARKET_OPEN']} (테스트용!)") + print() + print("💡 팁:") + print(" - 봇을 재시작하면 새 설정이 적용됩니다.") + print(" - FORCE_MARKET_OPEN=true는 테스트용이므로 실전에서는 false로 변경하세요.") + else: + print("❌ 저장 실패!") + + db.close() + return env_id + +if __name__ == "__main__": + try: + update_short_bot_env() + except Exception as e: + print(f"\n\n❌ 에러: {e}") + import traceback + traceback.print_exc() diff --git a/update_env_simple.py b/update_env_simple.py new file mode 100644 index 0000000..86ae4ce --- /dev/null +++ b/update_env_simple.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +""" +환경변수 DB 업데이트 스크립트 (간단 버전) +- 딕셔너리로 직접 값을 넣어서 업데이트 +""" +import sys +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(SCRIPT_DIR)) + +from database import TradeDB + +def update_env(updates): + """ + 환경변수 업데이트 + + Args: + updates: 업데이트할 환경변수 딕셔너리 + 예: { + "KIS_APP_KEY": "your_app_key", + "KIS_APP_SECRET": "your_app_secret", + "MM_BOT_TOKEN_": "your_token", + ... + } + """ + db_path = SCRIPT_DIR / "quant_bot.db" + db = TradeDB(db_path=str(db_path)) + + # 기존 최신 설정 불러오기 + latest = db.get_latest_env() + if latest and latest.get("snapshot"): + current = latest["snapshot"] + print(f"📋 기존 설정 불러옴 (ID: {latest['id']})") + else: + current = {} + print("⚠️ 기존 설정 없음 - 새로 생성") + + # 기존 값과 병합 + new_snapshot = {**current, **updates} + + # 새 스냅샷 저장 + env_id = db.insert_env_snapshot(new_snapshot) + if env_id: + print(f"✅ 환경변수 저장 완료! (새 스냅샷 ID: {env_id})") + print(f" 업데이트된 키: {', '.join(updates.keys())}") + else: + print("❌ 저장 실패!") + + db.close() + return env_id + +if __name__ == "__main__": + # 여기에 업데이트할 값들을 딕셔너리로 넣으세요 + my_updates = { + # 한투 API 설정 + + # "KIS_APP_KEY_REAL": "PSUbfJUp3eiA0rthF1GSK8yWI7dD7GvXMPQL", # 여기에 앱키 입력 + # "KIS_APP_SECRET_REAL": "DzG04RbksnUMROslum/2DliJiVZAdeSgwUNHKSbFehMmD2WKGVUeSd0N1B8LY947W/aNtEmU8pdkvKTFnVX1u68DvCj7cvEtlJc++wCUeaRD3z1Ov48b5PLsPiWvwE+pMd0pEl6jmFg0J6td1TidugAsZtEQ3GUBimyQyDSgw3jkdbnM390=", + # "KIS_APP_KEY_MOCK": "PSdfKtsMihgC9tLiUr2XISscuR3fHxl6kvmV", # 여기에 앱키 입력` + # "KIS_APP_SECRET_MOCK": "Ip+XZrZcoz11thgDD40XS8i6R1AalYkKFZwg2w8+ZMulVKN8rJVXiqGONxc4EYxw1S3TgOcx7fSldDc6EGq63bprfbgHwKWxstu29ZmLAtRNU0oFqV7e9vCOfgiWxrfnCqwcihoS7ovmza9+Ylqd8/EtjFGNmhQHWocyTAm8kdp5IG6tFtc=", # 여기에 앱시크릿 입력 + + # "KIS_ACCOUNT_NO_REAL": "44030801", + # "KIS_ACCOUNT_NO_MOCK": "50166974", + # "KIS_ACCOUNT_NO_MOCK": "44030801", + # "KIS_ACCOUNT_NO_MOCK": "50169256", + # "KIS_ACCOUNT_CODE_REAL": "01", + # "KIS_ACCOUNT_CODE_MOCK": "01", + + "MIN_RECOVERY_RATIO": 0.35, + "MIN_DROP_RATE": 0.02, + + } + + # 빈 값 제거 (업데이트하지 않음) + my_updates = {k: v for k, v in my_updates.items() if v} + + if not my_updates: + print("⚠️ 업데이트할 값이 없습니다.") + print(" update_env_simple.py 파일을 열어서 my_updates 딕셔너리에 값을 입력하세요.") + sys.exit(1) + + update_env(my_updates)