source push
This commit is contained in:
BIN
__pycache__/database.cpython-312.pyc
Normal file
BIN
__pycache__/database.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/kis_short_ver1.cpython-312.pyc
Normal file
BIN
__pycache__/kis_short_ver1.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/ml_predictor.cpython-312.pyc
Normal file
BIN
__pycache__/ml_predictor.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/risk_manager.cpython-312.pyc
Normal file
BIN
__pycache__/risk_manager.cpython-312.pyc
Normal file
Binary file not shown.
103
check_account_config.py
Normal file
103
check_account_config.py
Normal 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
28
check_db.py
Normal 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
40
copy_env_row_to_latest.py
Normal 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
872
database.py
Normal 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
78
init_db.py
Normal 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
1668
kis_long_ver1.py
Normal file
File diff suppressed because it is too large
Load Diff
3296
kis_short_ver1.py
Normal file
3296
kis_short_ver1.py
Normal file
File diff suppressed because it is too large
Load Diff
229
ml_predictor.py
Normal file
229
ml_predictor.py
Normal 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
37
modify_db_schema.py
Normal 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
BIN
quant_bot.db
Normal file
Binary file not shown.
270
risk_manager.py
Normal file
270
risk_manager.py
Normal 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
|
||||
0
scripts/copy_env_row_to_latest.py
Normal file
0
scripts/copy_env_row_to_latest.py
Normal file
145
update_env.py
Normal file
145
update_env.py
Normal 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
137
update_env_short.py
Normal 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
83
update_env_simple.py
Normal 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)
|
||||
Reference in New Issue
Block a user