브랜치 분리 방식: A / B / C

A 선택 시 커밋 메시지: 위 초안 OK / 수정 / 직접 작성
작업 시점: 지금 / 운영 데이터 1~2일 쌓고 / 주말
This commit is contained in:
2026-05-05 21:04:17 +09:00
parent c2b2b711e0
commit f61c471aac
58 changed files with 803502 additions and 1430 deletions

View File

@@ -0,0 +1 @@
"""kis_trader.utils — 공통 유틸(환경변수, 로거, 안전 요청)."""

86
kis_trader/utils/env.py Normal file
View File

@@ -0,0 +1,86 @@
"""
kis_trader/utils/env.py — DB(env_config) 우선 + os.environ 폴백 환경변수 헬퍼
===========================================================================
기존 kis_scalping_ver2 / kis_short_ver3 에 흩어져 있던 `get_env_*` 를 통합.
- DB(env_config 최신 row) → os.environ → default 순으로 조회.
- 하드코딩 금지 원칙에 맞춰 전 모듈에서 이 함수들만 사용하도록 한다.
"""
from __future__ import annotations
import logging
import os
from typing import Any
logger = logging.getLogger("kis_trader.env")
# 순환 import 방지용 레이지 TradeDB 참조
_db_instance = None
def _get_db():
"""TradeDB 싱글톤. 최초 호출 시 MariaDB 연결."""
global _db_instance
if _db_instance is None:
try:
from database import TradeDB # 프로젝트 루트의 기존 모듈
_db_instance = TradeDB()
except Exception as e:
logger.warning("TradeDB 초기화 실패(%s) → env_config 조회 불가 → os.environ 폴백", e)
_db_instance = False # 실패 기록 (None과 구분)
return _db_instance or None
def set_db(db_obj) -> None:
"""외부에서 이미 만든 TradeDB 인스턴스를 재사용할 때 주입."""
global _db_instance
_db_instance = db_obj
def _strip_comment(val: Any) -> Any:
"""DB 컬럼에 `#` 이후 주석이 들어 있을 때 제거. 기존 규칙 유지."""
if isinstance(val, str) and "#" in val:
return val.split("#", 1)[0].strip()
return val
def get_env_from_db(key: str, default: str = "") -> str:
"""env_config 최신 row → 키 값을 문자열로 반환. 없으면 os.environ → default."""
db = _get_db()
if db is not None:
try:
row = db.get_latest_env()
if row and row.get("snapshot"):
v = row["snapshot"].get(key)
v = _strip_comment(v)
if v not in (None, ""):
return str(v)
except Exception as e:
logger.debug("env_config 조회 실패 (%s): %s", key, e)
# os.environ 폴백 (기존 호환)
return os.environ.get(key, str(default))
def get_env_float(key: str, default: float) -> float:
raw = get_env_from_db(key, str(default))
try:
return float(raw) if raw != "" else float(default)
except (ValueError, TypeError):
return float(default)
def get_env_int(key: str, default: int) -> int:
raw = get_env_from_db(key, str(default))
try:
# "1.0" 같은 값이 들어와도 int 캐스팅되도록 float 경유
return int(float(raw)) if raw != "" else int(default)
except (ValueError, TypeError):
return int(default)
def get_env_bool(key: str, default: bool = False) -> bool:
raw = str(get_env_from_db(key, str(default))).strip().lower()
if raw in ("true", "1", "yes", "y", "on"):
return True
if raw in ("false", "0", "no", "n", "off", ""):
return False
return bool(default)

174
kis_trader/utils/logger.py Normal file
View 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

View File

@@ -0,0 +1,155 @@
"""
kis_trader/utils/request_handler.py — SafeRequest 공통 HTTP 래퍼
================================================================
.cursorrules 규정:
"모든 API 요청은 utils/request_handler.py 의 SafeRequest 클래스를
상속받아 구현하세요. HTTP 429(Too Many Requests) 에러 발생 시
재시도(Retry) 로직을 반드시 포함하세요."
- 한투 EGW00201 (초당 거래 초과)도 동일 취급.
- 최소 호출 간격 (`min_interval_sec`) 으로 클라이언트 측 스로틀.
- 재시도 시 지수 백오프 (1.0s → 2.0s → 4.0s ... cap 10s)
- 타임아웃 발생 시 1회 재시도 허용.
"""
from __future__ import annotations
import random
import threading
import time
from typing import Any, Optional
import requests
from .logger import get_logger
logger = get_logger("kis_trader.safe_request")
class SafeRequest:
"""
HTTP 요청 공통 기반 클래스. 한투·키움 REST 클라이언트가 상속해서 사용.
"""
# 429 상태 코드 or 한투 초당거래 초과 msg_cd
RETRYABLE_STATUSES = {429, 500, 502, 503, 504}
KIS_RATE_LIMIT_MSG_CD = {"EGW00201", "EGW00202"}
def __init__(
self,
min_interval_sec: float = 0.22,
max_retries: int = 5,
backoff_base: float = 1.0,
backoff_cap: float = 10.0,
timeout_sec: float = 10.0,
):
# 기본값은 안전 마진(한투 REST 공식 초당 5건 내외 감안)
self.min_interval_sec = float(min_interval_sec)
self.max_retries = int(max_retries)
self.backoff_base = float(backoff_base)
self.backoff_cap = float(backoff_cap)
self.timeout_sec = float(timeout_sec)
self._last_call_ts: float = 0.0
self._throttle_lock = threading.Lock()
# ------------------------------------------------------------------
# 내부 유틸
# ------------------------------------------------------------------
def _throttle(self) -> None:
"""호출 간 최소 간격 보장 (클라이언트 측 429 방지)."""
with self._throttle_lock:
elapsed = time.time() - self._last_call_ts
if elapsed < self.min_interval_sec:
time.sleep(self.min_interval_sec - elapsed)
self._last_call_ts = time.time()
def _sleep_backoff(self, attempt: int) -> None:
"""지수 백오프 + 지터. attempt=1,2,3..."""
wait = min(self.backoff_base * (2 ** (attempt - 1)), self.backoff_cap)
wait += random.uniform(0, 0.3)
time.sleep(wait)
@staticmethod
def _is_kis_rate_limited(json_body: dict) -> bool:
if not isinstance(json_body, dict):
return False
return json_body.get("msg_cd") in SafeRequest.KIS_RATE_LIMIT_MSG_CD
# ------------------------------------------------------------------
# 외부 인터페이스
# ------------------------------------------------------------------
def request(
self,
method: str,
url: str,
*,
headers: Optional[dict] = None,
params: Optional[dict] = None,
json_body: Optional[dict] = None,
data: Optional[Any] = None,
timeout: Optional[float] = None,
) -> requests.Response:
"""
재시도/스로틀이 포함된 HTTP 호출.
한투 EGW00201 같은 `status=200 but rate-limited` 도 재시도.
실패 시 마지막 Response(또는 빈 Response) 반환.
"""
timeout = timeout or self.timeout_sec
last_resp: Optional[requests.Response] = None
for attempt in range(1, self.max_retries + 1):
self._throttle()
try:
resp = requests.request(
method.upper(),
url,
headers=headers,
params=params,
json=json_body,
data=data,
timeout=timeout,
)
last_resp = resp
# [1] 재시도 대상 HTTP 상태
if resp.status_code in self.RETRYABLE_STATUSES:
logger.warning(
"HTTP %d on %s %s (%d/%d) → 백오프 후 재시도",
resp.status_code, method.upper(), url, attempt, self.max_retries,
)
self._sleep_backoff(attempt)
continue
# [2] 한투 EGW00201 (HTTP 200 but 초당거래 초과)
if resp.status_code == 200:
try:
body = resp.json()
except Exception:
body = None
if body and self._is_kis_rate_limited(body):
logger.warning(
"KIS rate-limit %s on %s (%d/%d) → 백오프 후 재시도",
body.get("msg_cd"), url, attempt, self.max_retries,
)
self._sleep_backoff(attempt)
continue
return resp
except requests.exceptions.Timeout:
logger.warning(
"⏰ Timeout %s %s (%d/%d)",
method.upper(), url, attempt, self.max_retries,
)
time.sleep(1.0)
except requests.exceptions.RequestException as e:
logger.warning(
"⚠️ RequestException %s %s (%d/%d): %s",
method.upper(), url, attempt, self.max_retries, e,
)
self._sleep_backoff(attempt)
return last_resp if last_resp is not None else requests.Response()
def get(self, url: str, **kw) -> requests.Response:
return self.request("GET", url, **kw)
def post(self, url: str, **kw) -> requests.Response:
return self.request("POST", url, **kw)