브랜치 분리 방식: A / B / C
A 선택 시 커밋 메시지: 위 초안 OK / 수정 / 직접 작성 작업 시점: 지금 / 운영 데이터 1~2일 쌓고 / 주말
This commit is contained in:
174
kis_trader/utils/logger.py
Normal file
174
kis_trader/utils/logger.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user