""" kis_trader/utils/logger.py — 공통 로거 + JSON Atomic Save + 텔레그램/매터모스트 알림 ============================================================================= - get_logger(name): 표준 포맷(시:분:초 + 메시지)과 ANSI 컬러 로거 반환. - atomic_save_json / atomic_load_json: 프로세스 동시 실행에도 파일 파손 없이 쓰기. - msg_mm / msg_tg: 메시지 채널 분리. 서버 부하 방지를 위해 호출 전 짧은 jitter. """ from __future__ import annotations import json import logging import os import random import tempfile import threading import time from pathlib import Path from typing import Any, Iterable, Optional import requests from .env import get_env_bool, get_env_from_db # ── 로깅 ───────────────────────────────────────────────────────────── # 루트 로거 한 번만 설정 (여러 모듈에서 import되어도 중복 핸들러 안 생기게) _ROOT_CONFIGURED = False def _configure_root() -> None: global _ROOT_CONFIGURED if _ROOT_CONFIGURED: return logging.basicConfig( format="[%(asctime)s] [%(name)s] %(message)s", datefmt="%H:%M:%S", level=logging.INFO, ) _ROOT_CONFIGURED = True def get_logger(name: str) -> logging.Logger: """프로젝트 표준 로거.""" _configure_root() return logging.getLogger(name) # ── ANSI 컬러 (기존 봇 관례 유지) ────────────────────────────────── LOG_RED = "\033[91m" LOG_YELLOW = "\033[93m" LOG_GREEN = "\033[92m" LOG_CYAN = "\033[96m" LOG_RESET = "\033[0m" # ── Atomic JSON I/O ───────────────────────────────────────────────── # json 파일 손상 방지: 임시파일에 쓰고 os.replace 로 원자적 교체. # 이벤트 발생 시 즉시 저장(재시작해도 최신 상태 유지). _IO_LOCK = threading.Lock() def atomic_save_json(path: str | os.PathLike, data: Any) -> bool: """임시파일 → rename 으로 원자적 저장. 실패해도 기존 파일 보존.""" p = Path(path) try: p.parent.mkdir(parents=True, exist_ok=True) with _IO_LOCK: with tempfile.NamedTemporaryFile( "w", delete=False, dir=str(p.parent), prefix=p.name + ".", suffix=".tmp", encoding="utf-8", ) as tf: json.dump(data, tf, ensure_ascii=False, indent=2) tf.flush() os.fsync(tf.fileno()) tmp_name = tf.name os.replace(tmp_name, p) return True except Exception as e: logging.getLogger("kis_trader.logger").error( "atomic_save_json 실패 (%s): %s", p, e ) return False def atomic_load_json(path: str | os.PathLike, default: Any = None) -> Any: p = Path(path) if not p.exists(): return default try: with _IO_LOCK: with open(p, "r", encoding="utf-8") as f: return json.load(f) except Exception: return default # ── Mattermost / Telegram 알림 ───────────────────────────────────── _MM_CONFIG_PATH_CACHE: Optional[Path] = None def _mm_config_path() -> Path: """mm_config.json 위치 탐색 (프로젝트 루트 기준).""" global _MM_CONFIG_PATH_CACHE if _MM_CONFIG_PATH_CACHE is not None: return _MM_CONFIG_PATH_CACHE here = Path(__file__).resolve() # kis_trader/utils/logger.py → kis_bot/ project_root = here.parent.parent.parent _MM_CONFIG_PATH_CACHE = project_root / "mm_config.json" return _MM_CONFIG_PATH_CACHE def _load_mm_channel_id(channel_alias: str) -> Optional[str]: cfg = atomic_load_json(_mm_config_path(), default={}) try: return (cfg.get("channels") or {}).get(channel_alias) except Exception: return None def msg_mm_multi( text: str, channel_aliases: Iterable[str], *, jitter: bool = True, ) -> bool: """ 동일 메시지를 여러 MM alias 로 발송 (중복 alias 는 1회만). jitter=True 이면 첫 채널에만 슬립 — 체결 알림은 jitter=False 권장. """ seen: set[str] = set() any_ok = False first = True for raw in channel_aliases: alias = str(raw or "").strip() if not alias or alias in seen: continue seen.add(alias) if msg_mm(text, channel_alias=alias, jitter=jitter and first): any_ok = True first = False return any_ok def msg_mm_strategy( text: str, strategy_channel_alias: str, *, jitter: bool = False, ) -> bool: """ 전략 채널 + (옵션) 통합 채널 이중 발송. MM_DUAL_CHANNEL_ENABLED=true 일 때 MATTERMOST_CHANNEL 도 함께 전송 (alias 같으면 1회). """ strat_ch = str(strategy_channel_alias or "").strip() or str( get_env_from_db("MATTERMOST_CHANNEL", "stock") ) aliases = [strat_ch] if get_env_bool("MM_DUAL_CHANNEL_ENABLED", False): unified = str(get_env_from_db("MATTERMOST_CHANNEL", "stock") or "stock").strip() if unified and unified != strat_ch: aliases.append(unified) return msg_mm_multi(text, aliases, jitter=jitter) def msg_mm(text: str, channel_alias: Optional[str] = None, jitter: bool = True) -> bool: """ Mattermost 메시지 전송. Args: channel_alias: mm_config.json 의 channels 키. None 이면 MATTERMOST_CHANNEL 사용. jitter: True 면 0.3~1.2s 랜덤 슬립 (버스트 차단 방지). 실시간 매매 로직에서는 False 권장. """ alias = channel_alias or get_env_from_db("MATTERMOST_CHANNEL", "stock") server_url = get_env_from_db("MM_SERVER_URL", "https://mattermost.hoonfam.org") bot_token = get_env_from_db("MM_BOT_TOKEN_", "").strip() cid = _load_mm_channel_id(alias) if not cid or not bot_token: logging.getLogger("kis_trader.logger").debug( "[MM 스킵] alias=%s cid=%s", alias, cid ) return False if jitter: time.sleep(random.uniform(0.3, 1.2)) try: r = requests.post( f"{server_url}/api/v4/posts", headers={ "Authorization": f"Bearer {bot_token}", "Content-Type": "application/json", }, json={"channel_id": cid, "message": text}, timeout=5, ) return r.status_code < 400 except Exception as e: logging.getLogger("kis_trader.logger").debug("MM 발송 실패: %s", e) return False def msg_tg(text: str, jitter: bool = True) -> bool: """ Telegram Bot 알림. TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID 둘 다 설정돼 있을 때만 전송. 실시간 매매 루프에서는 jitter=False 로 호출. """ token = get_env_from_db("TELEGRAM_BOT_TOKEN", "").strip() chat_id = get_env_from_db("TELEGRAM_CHAT_ID", "").strip() if not token or not chat_id: return False if jitter: time.sleep(random.uniform(0.3, 1.2)) try: r = requests.post( f"https://api.telegram.org/bot{token}/sendMessage", json={"chat_id": chat_id, "text": text, "parse_mode": "Markdown"}, timeout=5, ) return r.status_code < 400 except Exception as e: logging.getLogger("kis_trader.logger").debug("TG 발송 실패: %s", e) return False