#!/usr/bin/env python3 """ kis_trader/backtest/optuna_common.py — Optuna storage·DB 공통 (MariaDB 141) ========================================================================= TradeDB(database.py) 와 동일 호스트·계정, 전용 DB kis_optuna 에 study 저장. Win11·VM 양쪽에서 같은 storage 로 trial 공유·재개 가능. """ from __future__ import annotations import logging import os from typing import Any, Optional from urllib.parse import quote_plus from kis_trader.utils.env import get_env_from_db logger = logging.getLogger("optuna_common") # Optuna 전용 MariaDB (매매 DB kis_quant_db 와 분리) DEFAULT_OPTUNA_DB_NAME = "kis_optuna" OPTUNA_STRATEGIES = ("tail", "momentum", "breakout", "scalp", "dart") def mariadb_creds() -> dict: """TradeDB(database.py) 와 동일 우선순위 — env > 기본 141.""" return { "host": os.environ.get("DB_HOST", "192.168.0.141"), "port": int(os.environ.get("DB_PORT", "3306")), "user": os.environ.get("DB_USER", "jae"), "password": os.environ.get("DB_PASS", "1234"), } def resolve_optuna_db_name() -> str: """ Optuna storage DB — 기본 kis_optuna (매매 kis_quant_db 와 분리). env OPTUNA_DB_NAME 로 오버라이드 가능. """ raw = get_env_from_db("OPTUNA_DB_NAME", "") if raw and str(raw).strip() not in ("", "None"): return str(raw).strip() env = os.environ.get("OPTUNA_DB_NAME", "") if env and str(env).strip(): return str(env).strip() return DEFAULT_OPTUNA_DB_NAME def build_mariadb_storage_url(db_name: Optional[str] = None) -> str: """mysql+pymysql://…@141/optuna 형식 storage URL.""" creds = mariadb_creds() name = (db_name or resolve_optuna_db_name()).strip() user = quote_plus(creds["user"]) passwd = quote_plus(creds["password"]) return ( f"mysql+pymysql://{user}:{passwd}@{creds['host']}:{creds['port']}/{name}" f"?charset=utf8mb4" ) def ensure_optuna_database(db_name: Optional[str] = None) -> str: """ MariaDB 141 — kis_optuna 존재 확인 (없으면 CREATE 시도). """ name = (db_name or resolve_optuna_db_name()).strip() creds = mariadb_creds() try: import pymysql except ImportError as exc: raise ImportError( "Optuna MariaDB storage 는 pymysql 필요: pip install PyMySQL" ) from exc # DB 존재 여부만 확인 (이미 있으면 CREATE 생략) conn = pymysql.connect( host=creds["host"], port=creds["port"], user=creds["user"], password=creds["password"], charset="utf8mb4", autocommit=True, connect_timeout=10, ) try: with conn.cursor() as cur: cur.execute("SHOW DATABASES LIKE %s", (name,)) exists = cur.fetchone() is not None if not exists: cur.execute( f"CREATE DATABASE IF NOT EXISTS `{name}` " "DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" ) logger.info( "📦 Optuna DB 생성: %s@%s:%s/%s", creds["user"], creds["host"], creds["port"], name, ) else: logger.info( "📦 Optuna storage DB: %s@%s:%s/%s", creds["user"], creds["host"], creds["port"], name, ) except Exception as exc: logger.error("❌ Optuna DB '%s' 접속/확인 실패: %s", name, exc) raise finally: conn.close() return name def resolve_optuna_storage_url(cli_override: Optional[str] = None) -> str: """ Storage URL 우선순위: 1) CLI --storage 2) OPTUNA_STORAGE_URL (DB/env) 3) MariaDB 141 / kis_optuna (TradeDB 동일 계정) """ if cli_override and str(cli_override).strip(): return str(cli_override).strip() from_db = get_env_from_db("OPTUNA_STORAGE_URL", "") if from_db and str(from_db).strip() not in ("", "None"): return str(from_db).strip() db_name = ensure_optuna_database() return build_mariadb_storage_url(db_name) def resolve_study_name( *, strategy: str, mode: str, start: str, end: str, cli_override: Optional[str] = None, ) -> str: """Study 이름 — 전략·기간·모드 포함.""" if cli_override and str(cli_override).strip(): return str(cli_override).strip() env_key = f"OPTUNA_{strategy.upper()}_STUDY_NAME" from_db = get_env_from_db(env_key, "") if from_db and str(from_db).strip() not in ("", "None"): return str(from_db).strip() legacy = get_env_from_db("OPTUNA_TAIL_STUDY_NAME", "") if strategy == "tail" and legacy and str(legacy).strip() not in ("", "None"): return str(legacy).strip() return f"{strategy}_{mode}_{start}_{end}" def optuna_run_lock_name(strategy: str) -> str: return f"{strategy}_param_search_optuna" def release_shared_tick_store(ctx: Any, *, log: Optional[logging.Logger] = None) -> None: """ Optuna ctx.shared_tick_store 해제. 주의: ticks_by_code 가 공유메모리 뷰인 경우, unlink 이후 접근하면 SIGBUS/강제종료(트레이스백 없음) 난다. 최빈(mode_combo) 실측·JSON 저장이 끝난 뒤에만 호출할 것. optimize() 직후 즉시 unlink 금지. """ lg = log or logger store = getattr(ctx, "shared_tick_store", None) if store is None: return try: store.unlink() except Exception as exc: lg.warning("⚠️ shared_tick_store unlink 실패: %s", exc) try: ctx.shared_tick_store = None except Exception: pass def announce_optuna_json_path( out_path: str, *, strategy: str = "", mode: str = "", note: str = "", log: Optional[logging.Logger] = None, ) -> str: """ 결과 JSON 절대경로를 터미널·로그에 눈에 띄게 고지. 또한 logs/optuna___latest.jsonpath 에 기록 (없으면 strategy만). """ abs_path = os.path.abspath(str(out_path or "").strip()) lg = log or logger tag = note.strip() or "결과 JSON" line = f"📁 [{tag}] {abs_path}" # logger + print 이중 — nohup 로그·터미널 모두에서 바로 보이게 lg.info("%s", line) print(line, flush=True) print(f"OPTUNA_RESULT_JSON={abs_path}", flush=True) try: root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) logs_dir = os.path.join(root, "logs") os.makedirs(logs_dir, exist_ok=True) s = (strategy or "optuna").strip().lower() or "optuna" m = (mode or "run").strip().lower() or "run" for name in ( f"optuna_{s}_{m}_latest.jsonpath", f"optuna_{s}_latest.jsonpath", "optuna_latest.jsonpath", ): with open(os.path.join(logs_dir, name), "w", encoding="utf-8") as f: f.write(abs_path + "\n") except OSError as exc: lg.warning("⚠️ jsonpath 사이드카 기록 실패: %s", exc) return abs_path