""" kis_trader/utils/env.py — DB(config_* + env_config 병합) 우선 + os.environ 폴백 환경변수 헬퍼 =========================================================================== 기존 kis_scalping_ver2 / kis_short_ver3 에 흩어져 있던 `get_env_*` 를 통합. - DB get_merged_env_snapshot / get_strategy_env_dict → os.environ → default 순으로 조회. - 하드코딩 금지 원칙에 맞춰 전 모듈에서 이 함수들만 사용하도록 한다. """ from __future__ import annotations import logging import os import time from typing import Any, Dict, Optional logger = logging.getLogger("kis_trader.env") # 순환 import 방지용 레이지 TradeDB 참조 _db_instance = None # get_merged_env_snapshot() — TTL 내 재사용 (실매: 웹에서 MAX_DAILY 등 변경 즉시 반영) _merged_env_cache: Optional[Dict[str, str]] = None _merged_env_cache_ts: float = 0.0 # get_strategy_env_dict — 전략 config_* 스냅샷 RAM (매 루프 DB 금지) # 유니버스/봉 정합과 무관. 임계값·한도만. 기본 1초 · 분단위 동결 금지. _strategy_env_cache: Dict[str, Any] = {} # env 캐시 세대 카운터 — invalidate 시 +1. 파생 캐시(예: whipsaw 파라미터)가 # 이 값으로 무효화를 감지해 안전하게 재계산한다(값은 그대로, 재계산 시점만 동일). _env_generation: int = 0 def _get_db(): """TradeDB 싱글톤. 최초 호출 시 MariaDB 연결.""" global _db_instance if _db_instance is None: try: from database import TradeDB # 프로젝트 루트의 기존 모듈 _db_instance = TradeDB() except Exception as e: logger.warning("TradeDB 초기화 실패(%s) → env_config 조회 불가 → os.environ 폴백", e) _db_instance = False # 실패 기록 (None과 구분) return _db_instance or None def set_db(db_obj) -> None: """외부에서 이미 만든 TradeDB 인스턴스를 재사용할 때 주입.""" global _db_instance _db_instance = db_obj def invalidate_merged_env_cache() -> None: """env/config 저장 후 스냅샷 캐시 무효화 (insert_env_snapshot 등).""" global _merged_env_cache, _merged_env_cache_ts, _env_generation, _strategy_env_cache _merged_env_cache = None _merged_env_cache_ts = 0.0 _strategy_env_cache = {} _env_generation += 1 def _merged_env_cache_ttl_sec() -> float: """DB env 스냅샷 메모리 TTL(초). os.environ 만 사용(순환 import 방지).""" try: return max(0.0, float(os.environ.get("ENV_MERGED_CACHE_TTL_SEC", "60"))) except (ValueError, TypeError): return 60.0 def _strategy_env_cache_ttl_sec() -> float: """전략 config_* RAM TTL(초). 기본 1 — 유니버스 분슬롯과 무관. 웹 저장 시 invalidate_merged_env_cache 로 즉시 무효화. """ try: return max(0.0, float(os.environ.get("STRATEGY_ENV_CACHE_TTL_SEC", "1"))) except (ValueError, TypeError): return 1.0 def env_cache_generation() -> int: """현재 env 캐시 세대. invalidate 될 때마다 증가. 파생 캐시가 이 값을 키에 포함하면, 설정 저장(무효화) 시 자동으로 재계산되고 그 전까지는 동일 값을 재사용한다(get_env_from_db 의 병합캐시와 동일 수명). """ return _env_generation def _strip_comment(val: Any) -> Any: """DB 컬럼에 `#` 이후 주석이 들어 있을 때 제거. 기존 규칙 유지.""" if isinstance(val, str) and "#" in val: return val.split("#", 1)[0].strip() return val def get_merged_env_dict() -> dict: """env_config + config_scalp/short/momentum/… 최신 행 병합 flat dict.""" global _merged_env_cache, _merged_env_cache_ts ttl = _merged_env_cache_ttl_sec() now = time.time() if _merged_env_cache is not None and (ttl <= 0 or (now - _merged_env_cache_ts) < ttl): return _merged_env_cache db = _get_db() if db is None: return _merged_env_cache or {} try: if hasattr(db, "get_merged_env_snapshot"): merged = db.get_merged_env_snapshot() else: row = db.get_latest_env() merged = dict((row or {}).get("snapshot") or {}) _merged_env_cache = dict(merged or {}) _merged_env_cache_ts = now return _merged_env_cache except Exception as e: logger.debug("merged env 조회 실패: %s", e) return _merged_env_cache or {} def get_strategy_env_dict(strategy_id: str) -> dict: """전략 config_* 테이블 + 공통 env 병합 (전략 키 우선). SCALP → config_scalp, MOMENTUM → config_momentum, SHORT → config_short … 실매(get_env_from_db) · 웹 · 파라서치가 동일 소스를 쓰도록 한다. RAM TTL(``STRATEGY_ENV_CACHE_TTL_SEC`` 기본 1초): 매 루프 config_* SELECT 금지. 유니버스 event_time/분슬롯과 무관 — 손절·한도 등 **설정값**만. 웹 저장 → ``invalidate_merged_env_cache`` 즉시 반영. """ global _strategy_env_cache sid = str(strategy_id or "").strip().upper() or "_" ttl = _strategy_env_cache_ttl_sec() now = time.time() gen = _env_generation hit = _strategy_env_cache.get(sid) if ( isinstance(hit, dict) and hit.get("gen") == gen and (ttl <= 0 or (now - float(hit.get("ts") or 0)) < ttl) and isinstance(hit.get("data"), dict) ): return hit["data"] merged = get_merged_env_dict() db = _get_db() out = dict(merged) if db is not None: try: if hasattr(db, "get_strategy_config_snapshot"): strat = db.get_strategy_config_snapshot(strategy_id) if strat: out.update(strat) except Exception as e: logger.debug("strategy env 조회 실패 (%s): %s", strategy_id, e) _strategy_env_cache[sid] = {"data": out, "ts": now, "gen": gen} return out def get_env_from_db(key: str, default: str = "") -> str: """config_* + env_config 병합 스냅샷 → 키 값. 없으면 os.environ → default.""" merged = get_merged_env_dict() if merged: v = _strip_comment(merged.get(key)) if v not in (None, ""): return str(v) # os.environ 폴백 (기존 호환) return os.environ.get(key, str(default)) def get_env_float(key: str, default: float) -> float: raw = get_env_from_db(key, str(default)) try: return float(raw) if raw != "" else float(default) except (ValueError, TypeError): return float(default) def get_env_int(key: str, default: int) -> int: raw = get_env_from_db(key, str(default)) try: # "1.0" 같은 값이 들어와도 int 캐스팅되도록 float 경유 return int(float(raw)) if raw != "" else int(default) except (ValueError, TypeError): return int(default) def get_env_bool(key: str, default: bool = False) -> bool: raw = str(get_env_from_db(key, str(default))).strip().lower() if raw in ("true", "1", "yes", "y", "on"): return True if raw in ("false", "0", "no", "n", "off", ""): return False return bool(default)