Files
kis_bot/kis_trader/utils/logger.py
Hwang 61c72a8a4c feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가
변경 사항
----
- _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가
- _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가
- _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가

영향
----
- 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임
- 기존 기능에 대한 영향 없음

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 01:27:00 +09:00

220 lines
7.5 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, 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