source push

This commit is contained in:
2026-02-22 18:05:14 +09:00
parent 899a3c6543
commit b827f03d56
19 changed files with 6986 additions and 0 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

103
check_account_config.py Normal file
View File

@@ -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()

28
check_db.py Normal file
View File

@@ -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()

40
copy_env_row_to_latest.py Normal file
View File

@@ -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()

872
database.py Normal file
View File

@@ -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 연결 종료")

78
init_db.py Normal file
View File

@@ -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()

1668
kis_long_ver1.py Normal file

File diff suppressed because it is too large Load Diff

3296
kis_short_ver1.py Normal file

File diff suppressed because it is too large Load Diff

229
ml_predictor.py Normal file
View File

@@ -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

37
modify_db_schema.py Normal file
View File

@@ -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()

BIN
quant_bot.db Normal file

Binary file not shown.

270
risk_manager.py Normal file
View File

@@ -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

View File

145
update_env.py Normal file
View File

@@ -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()

137
update_env_short.py Normal file
View File

@@ -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()

83
update_env_simple.py Normal file
View File

@@ -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)