변경 사항 (Changes): 구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함. 스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함. 코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함. 시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함. 기대 효과 (Impact): 이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
388 lines
12 KiB
Python
Executable File
388 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
kis_error_watch_mm.py — kis_trader_main journalctl 실시간(tail -f) 감시 → Mattermost
|
|
|
|
실매 봇과 분리된 프로세스. journald 만 보고 오류 시 MM 알림.
|
|
- Traceback / FATAL / dead=[...] / 유닛 다운 등
|
|
- 동일·유사 알림은 쿨다운으로 스팸 방지
|
|
- 상태 JSON 즉시 저장(재시작 후에도 쿨다운 유지)
|
|
|
|
실행:
|
|
nohup .venv/bin/python -u scripts/kis_error_watch_mm.py \\
|
|
>> logs/kis_error_watch_mm.log 2>&1 &
|
|
tail -f logs/kis_error_watch_mm.log
|
|
|
|
테스트:
|
|
.venv/bin/python scripts/kis_error_watch_mm.py --test-mm
|
|
|
|
systemd (선택):
|
|
sudo cp deploy/kis_error_watch_mm.service /etc/systemd/system/
|
|
sudo systemctl daemon-reload && sudo systemctl enable --now kis_error_watch_mm
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import logging
|
|
import os
|
|
import re
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import List, Optional, Pattern, Tuple
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from kis_trader.utils.env import ( # noqa: E402
|
|
get_env_bool,
|
|
get_env_float,
|
|
get_env_from_db,
|
|
get_env_int,
|
|
)
|
|
from kis_trader.utils.logger import atomic_load_json, atomic_save_json, msg_mm # noqa: E402
|
|
|
|
LOG_PATH = ROOT / "logs" / "kis_error_watch_mm.log"
|
|
STATE_PATH = ROOT / "logs" / "kis_error_watch_mm_state.json"
|
|
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="[%(asctime)s] %(message)s",
|
|
datefmt="%H:%M:%S",
|
|
handlers=[logging.StreamHandler(sys.stdout)],
|
|
)
|
|
log = logging.getLogger("error_watch")
|
|
|
|
_STOP = False
|
|
|
|
|
|
def _on_signal(signum, _frame) -> None:
|
|
global _STOP
|
|
_STOP = True
|
|
log.info("⏹ signal=%s → 종료 예약", signum)
|
|
|
|
|
|
def _cfg() -> dict:
|
|
"""DB/env 설정 — 하드코딩 수치 금지, get_env_* 만."""
|
|
# Traceback·FATAL·비어있지 않은 dead=·유닛 크래시 시그니처
|
|
default_match = (
|
|
r"(?i)("
|
|
r"Traceback \(most recent call last\)|"
|
|
r"\bCRITICAL\b|\bFATAL\b|MemoryError|SIGBUS|Segmentation fault|"
|
|
r"dead=\[[^\]]|" # dead=[] 제외, dead=['Strat-... 매칭
|
|
r"Main process exited|Failed with result|"
|
|
r"can't open file|"
|
|
r"강제\s*종료|Out of memory"
|
|
r")"
|
|
)
|
|
default_ignore = (
|
|
r"(?i)("
|
|
r"numexpr\.utils|"
|
|
r"\[MM 스킵\]|"
|
|
r"MM 발송 실패|"
|
|
r"heartbeat ws="
|
|
r")"
|
|
)
|
|
return {
|
|
"enabled": get_env_bool("ERROR_WATCH_ENABLED", True),
|
|
"unit": str(
|
|
get_env_from_db("ERROR_WATCH_UNIT", "kis_trader_main.service")
|
|
or "kis_trader_main.service"
|
|
).strip(),
|
|
"channel": str(
|
|
get_env_from_db("ERROR_WATCH_MM_CHANNEL", "")
|
|
or get_env_from_db("KIS_SYSTEM_MM_CHANNEL", "default")
|
|
or "default"
|
|
).strip()
|
|
or "default",
|
|
"cooldown_sec": max(30, get_env_int("ERROR_WATCH_COOLDOWN_SEC", 180)),
|
|
"context_lines": max(1, min(20, get_env_int("ERROR_WATCH_CONTEXT_LINES", 5))),
|
|
"traceback_extra": max(0, min(40, get_env_int("ERROR_WATCH_TRACEBACK_EXTRA_LINES", 12))),
|
|
"health_sec": max(15, get_env_int("ERROR_WATCH_HEALTH_CHECK_SEC", 60)),
|
|
"match_re": str(
|
|
get_env_from_db("ERROR_WATCH_MATCH_REGEX", default_match) or default_match
|
|
),
|
|
"ignore_re": str(
|
|
get_env_from_db("ERROR_WATCH_IGNORE_REGEX", default_ignore) or default_ignore
|
|
),
|
|
"jitter": get_env_bool("ERROR_WATCH_MM_JITTER", False),
|
|
}
|
|
|
|
|
|
def _compile_re(pat: str, name: str) -> Optional[Pattern[str]]:
|
|
try:
|
|
return re.compile(pat)
|
|
except re.error as e:
|
|
log.error("❌ regex 컴파일 실패 (%s): %s", name, e)
|
|
return None
|
|
|
|
|
|
def _load_state() -> dict:
|
|
st = atomic_load_json(STATE_PATH, default={})
|
|
if not isinstance(st, dict):
|
|
return {}
|
|
return st
|
|
|
|
|
|
def _save_state(st: dict) -> None:
|
|
atomic_save_json(STATE_PATH, st)
|
|
|
|
|
|
def _fp(text: str) -> str:
|
|
# 시각·PID 제거 후 지문 → 같은 오류 반복 쿨다운
|
|
norm = re.sub(r"\d{2}:\d{2}:\d{2}", "", text)
|
|
norm = re.sub(r"python\[\d+\]", "python[PID]", norm)
|
|
norm = re.sub(r"\s+", " ", norm).strip()[:800]
|
|
return hashlib.sha1(norm.encode("utf-8", errors="ignore")).hexdigest()[:16]
|
|
|
|
|
|
def _can_alert(st: dict, fingerprint: str, cooldown_sec: int) -> bool:
|
|
now = time.time()
|
|
last_ts = float(st.get("last_alert_ts") or 0)
|
|
last_fp = str(st.get("last_fingerprint") or "")
|
|
if fingerprint == last_fp and (now - last_ts) < cooldown_sec:
|
|
return False
|
|
if (now - last_ts) < float(get_env_float("ERROR_WATCH_GLOBAL_MIN_GAP_SEC", 20.0)):
|
|
# 서로 다른 오류라도 최소 간격
|
|
if fingerprint != last_fp and (now - last_ts) < cooldown_sec * 0.15:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _send_alert(title: str, lines: List[str], channel: str, jitter: bool, st: dict, fingerprint: str) -> bool:
|
|
body_lines = [
|
|
f"🚨 **[오류감시] {title}**",
|
|
f"- 시각: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
f"- 유닛: `{get_env_from_db('ERROR_WATCH_UNIT', 'kis_trader_main.service')}`",
|
|
"```",
|
|
]
|
|
clipped = "\n".join(lines)[:3500]
|
|
body_lines.append(clipped)
|
|
body_lines.append("```")
|
|
body = "\n".join(body_lines)
|
|
ok = msg_mm(body, channel_alias=channel, jitter=jitter)
|
|
st["last_alert_ts"] = time.time()
|
|
st["last_fingerprint"] = fingerprint
|
|
st["last_title"] = title
|
|
st["alert_count"] = int(st.get("alert_count") or 0) + 1
|
|
_save_state(st)
|
|
log.info("📤 MM %s title=%s fp=%s", "OK" if ok else "FAIL", title, fingerprint)
|
|
return ok
|
|
|
|
|
|
def _unit_active(unit: str) -> Tuple[bool, str]:
|
|
try:
|
|
r = subprocess.run(
|
|
["systemctl", "is-active", unit],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
state = (r.stdout or "").strip() or (r.stderr or "").strip() or "unknown"
|
|
return state == "active", state
|
|
except Exception as e:
|
|
return False, f"check_error:{e}"
|
|
|
|
|
|
def _follow_journal(unit: str) -> subprocess.Popen:
|
|
# -n 0: 과거 덤프 없이 follow만 (기동 직후 과거 Traceback 폭주 방지)
|
|
cmd = [
|
|
"journalctl",
|
|
"-u", unit,
|
|
"-f",
|
|
"-n", "0",
|
|
"--output=short-iso",
|
|
"--no-pager",
|
|
]
|
|
log.info("📡 follow: %s", " ".join(cmd))
|
|
return subprocess.Popen(
|
|
cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
bufsize=1,
|
|
errors="replace",
|
|
)
|
|
|
|
|
|
def run_watch() -> int:
|
|
cfg = _cfg()
|
|
if not cfg["enabled"]:
|
|
log.warning("ERROR_WATCH_ENABLED=false → 종료")
|
|
return 0
|
|
|
|
match_re = _compile_re(cfg["match_re"], "MATCH")
|
|
ignore_re = _compile_re(cfg["ignore_re"], "IGNORE")
|
|
if match_re is None:
|
|
return 2
|
|
|
|
unit = cfg["unit"]
|
|
channel = cfg["channel"]
|
|
st = _load_state()
|
|
log.info(
|
|
"✅ 감시 시작 unit=%s ch=%s cooldown=%ss health=%ss",
|
|
unit, channel, cfg["cooldown_sec"], cfg["health_sec"],
|
|
)
|
|
|
|
# 기동 알림 (감시자 살아있음 확인)
|
|
if get_env_bool("ERROR_WATCH_STARTUP_NOTIFY", True):
|
|
active, state = _unit_active(unit)
|
|
msg_mm(
|
|
f"👁️ **[오류감시 기동]** `{unit}` → `{state}`"
|
|
f"{' ✅' if active else ' ⚠️ 비활성'}",
|
|
channel_alias=channel,
|
|
jitter=False,
|
|
)
|
|
|
|
proc = _follow_journal(unit)
|
|
buf: List[str] = []
|
|
collecting_tb = False
|
|
tb_left = 0
|
|
last_health = time.time()
|
|
was_active = True
|
|
|
|
assert proc.stdout is not None
|
|
|
|
while not _STOP:
|
|
# health poll
|
|
now = time.time()
|
|
if now - last_health >= cfg["health_sec"]:
|
|
last_health = now
|
|
active, state = _unit_active(unit)
|
|
if not active:
|
|
fp = _fp(f"unit_down:{unit}:{state}")
|
|
if _can_alert(st, fp, cfg["cooldown_sec"]):
|
|
_send_alert(
|
|
f"유닛 비활성 ({state})",
|
|
[f"systemctl is-active {unit} → {state}"],
|
|
channel,
|
|
cfg["jitter"],
|
|
st,
|
|
fp,
|
|
)
|
|
was_active = False
|
|
elif not was_active:
|
|
# 복구 알림
|
|
fp = _fp(f"unit_up:{unit}")
|
|
if _can_alert(st, fp, max(30, cfg["cooldown_sec"] // 3)):
|
|
_send_alert(
|
|
"유닛 복구 (active)",
|
|
[f"systemctl is-active {unit} → active"],
|
|
channel,
|
|
cfg["jitter"],
|
|
st,
|
|
fp,
|
|
)
|
|
was_active = True
|
|
|
|
# journalctl 죽었으면 재기동
|
|
if proc.poll() is not None:
|
|
log.warning("⚠️ journalctl 종료 code=%s → 재기동", proc.returncode)
|
|
proc = _follow_journal(unit)
|
|
assert proc.stdout is not None
|
|
|
|
# non-blocking-ish read with timeout via select
|
|
import select
|
|
|
|
ready, _, _ = select.select([proc.stdout], [], [], 1.0)
|
|
if not ready:
|
|
continue
|
|
line = proc.stdout.readline()
|
|
if line == "":
|
|
# EOF — 재기동
|
|
time.sleep(1.0)
|
|
if proc.poll() is not None:
|
|
proc = _follow_journal(unit)
|
|
assert proc.stdout is not None
|
|
continue
|
|
|
|
line = line.rstrip("\n")
|
|
if not line:
|
|
continue
|
|
|
|
# ignore
|
|
if ignore_re is not None and ignore_re.search(line):
|
|
continue
|
|
|
|
# Traceback 블록 수집
|
|
if "Traceback (most recent call last)" in line:
|
|
collecting_tb = True
|
|
tb_left = cfg["traceback_extra"]
|
|
buf = [line]
|
|
continue
|
|
|
|
if collecting_tb:
|
|
buf.append(line)
|
|
tb_left -= 1
|
|
# 들여쓴 프레임이 끝나고 일반 로그가 오면 종료
|
|
if tb_left <= 0 or (
|
|
len(buf) > 2
|
|
and not line.startswith(" ")
|
|
and not line.startswith("\t")
|
|
and "File \"" not in line
|
|
and not line.lstrip().startswith("File ")
|
|
and "Error" not in line
|
|
and "Exception" not in line
|
|
):
|
|
collecting_tb = False
|
|
block = buf[:]
|
|
buf = []
|
|
fp = _fp("\n".join(block))
|
|
if _can_alert(st, fp, cfg["cooldown_sec"]):
|
|
_send_alert("Traceback", block, channel, cfg["jitter"], st, fp)
|
|
continue
|
|
|
|
if match_re.search(line):
|
|
# 직전 컨텍스트는 journal에 없으므로 히트 라인 + 이후 N줄은 어려움 → 히트만
|
|
ctx = [line]
|
|
fp = _fp(line)
|
|
if _can_alert(st, fp, cfg["cooldown_sec"]):
|
|
title = "로그 오류 매칭"
|
|
if "dead=[" in line:
|
|
title = "전략 dead 감지"
|
|
elif "exited" in line.lower() or "Failed with result" in line:
|
|
title = "프로세스 종료"
|
|
_send_alert(title, ctx, channel, cfg["jitter"], st, fp)
|
|
|
|
try:
|
|
proc.terminate()
|
|
except Exception:
|
|
pass
|
|
log.info("👋 오류감시 종료")
|
|
return 0
|
|
|
|
|
|
def run_test_mm() -> int:
|
|
ch = str(
|
|
get_env_from_db("ERROR_WATCH_MM_CHANNEL", "")
|
|
or get_env_from_db("KIS_SYSTEM_MM_CHANNEL", "default")
|
|
or "default"
|
|
).strip() or "default"
|
|
ok = msg_mm(
|
|
"🧪 **[오류감시 테스트]** kis_error_watch_mm.py --test-mm OK",
|
|
channel_alias=ch,
|
|
jitter=False,
|
|
)
|
|
print(f"test_mm channel={ch} ok={ok}")
|
|
return 0 if ok else 1
|
|
|
|
|
|
def main() -> int:
|
|
signal.signal(signal.SIGINT, _on_signal)
|
|
signal.signal(signal.SIGTERM, _on_signal)
|
|
ap = argparse.ArgumentParser(description="kis_trader journal 오류 → Mattermost")
|
|
ap.add_argument("--test-mm", action="` `", help="테스트 메시지 1회 발송 후 종료")
|
|
args = ap.parse_args()
|
|
if args.test_mm:
|
|
return run_test_mm()
|
|
return run_watch()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|