175 lines
6.1 KiB
Python
175 lines
6.1 KiB
Python
"""
|
|
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, Optional
|
|
|
|
import requests
|
|
|
|
from .env import 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(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
|