브랜치 분리 방식: A / B / C
A 선택 시 커밋 메시지: 위 초안 OK / 수정 / 직접 작성 작업 시점: 지금 / 운영 데이터 1~2일 쌓고 / 주말
This commit is contained in:
1
kis_trader/utils/__init__.py
Normal file
1
kis_trader/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""kis_trader.utils — 공통 유틸(환경변수, 로거, 안전 요청)."""
|
||||
86
kis_trader/utils/env.py
Normal file
86
kis_trader/utils/env.py
Normal 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
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
|
||||
155
kis_trader/utils/request_handler.py
Normal file
155
kis_trader/utils/request_handler.py
Normal 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)
|
||||
Reference in New Issue
Block a user