""" kis_trader/network/ls_chart.py — LS 분봉 REST (t8412) ============================================================================== ``ls_condition`` 전략 갭보정용. 주문 경로와 무관. TR: t8412 주식차트(N분) — POST /stock/chart — 초당 1건. """ from __future__ import annotations import json import threading import time from datetime import datetime from typing import Any, Dict, List, Optional, Tuple import pandas as pd from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int from ..utils.logger import get_logger from ..utils.request_handler import SafeRequest from ..ws.ls_ws import LS_REST_BASE, fetch_ls_access_token logger = get_logger("kis_trader.ls_chart") CHART_URL = f"{LS_REST_BASE}/stock/chart" def ls_datetime_to_candle_time(dt_s: str) -> str: """``YYYY-MM-DD HH:MM:00`` → ``YYYYMMDDHHMM``.""" s = (dt_s or "").strip() if len(s) >= 16 and s[4] == "-" and s[10] == " ": return s[0:4] + s[5:7] + s[8:10] + s[11:13] + s[14:16] digits = "".join(ch for ch in s if ch.isdigit()) return digits[:12] def candle_time_to_ls_datetime(ct: str) -> str: """``YYYYMMDDHHMM`` → ``YYYY-MM-DD HH:MM:00``.""" d = "".join(ch for ch in (ct or "") if ch.isdigit())[:12] if len(d) < 12: return "" return f"{d[0:4]}-{d[4:6]}-{d[6:8]} {d[8:10]}:{d[10:12]}:00" def load_ls_real_creds() -> Tuple[str, str]: """실키만 (시세 REST). 빈 값이면 ("","").""" try: from database import TradeDB db = TradeDB() row = db.conn.execute( "SELECT LS_APP_KEY_REAL, LS_APP_SECRET_REAL FROM env_config " "ORDER BY id DESC LIMIT 1" ).fetchone() if not row: return "", "" r = dict(row) return ( (r.get("LS_APP_KEY_REAL") or "").strip(), (r.get("LS_APP_SECRET_REAL") or "").strip(), ) except Exception as e: logger.debug("LS 키 로드 실패: %s", e) return "", "" class LSChartClient(SafeRequest): """t8412 SafeRequest — min_interval 기본 1.05s (서버 초당 1건).""" def __init__(self) -> None: gap = max(1.0, float(get_env_float("LS_GAP_FILL_INTERVAL_SEC", 1.05) or 1.05)) super().__init__( min_interval_sec=gap, max_retries=max(1, get_env_int("LS_GAP_FILL_MAX_RETRIES", 3)), timeout_sec=max(5.0, float(get_env_float("LS_GAP_FILL_TIMEOUT_SEC", 20.0) or 20.0)), ) self._token = "" self._token_at = 0.0 self._tok_lock = threading.Lock() self._app_key = "" self._app_secret = "" def _ensure_creds(self) -> bool: if self._app_key and self._app_secret: return True k, s = load_ls_real_creds() self._app_key, self._app_secret = k, s return bool(k and s) def _ensure_token(self) -> str: with self._tok_lock: if self._token and (time.time() - self._token_at) < 12 * 3600: return self._token if not self._ensure_creds(): raise RuntimeError("LS AppKey/Secret 미설정") self._token = fetch_ls_access_token(self._app_key, self._app_secret) self._token_at = time.time() return self._token def fetch_minute_bars( self, code: str, *, ncnt: int = 1, qrycnt: Optional[int] = None, edate: str = "99999999", ) -> pd.DataFrame: """ t8412 → DataFrame columns: time(YYYYMMDDHHMM), open, high, low, close, volume 오래된→최신 정렬. """ if not get_env_bool("LS_GAP_FILL_ENABLED", True): return pd.DataFrame() code = (code or "").strip() if not (code.isdigit() and len(code) == 6): return pd.DataFrame() limit = int( qrycnt if qrycnt is not None else get_env_int("LS_GAP_FILL_LIMIT", 150) ) limit = max(1, min(500, limit)) ncnt = max(1, int(ncnt or 1)) token = self._ensure_token() body = { "t8412InBlock": { "shcode": code, "ncnt": ncnt, "qrycnt": limit, "nday": "0", "sdate": "", "stime": "", "edate": edate or "99999999", "etime": "", "cts_date": "", "cts_time": "", "comp_yn": "N", } } headers = { "content-type": "application/json; charset=UTF-8", "authorization": f"Bearer {token}", "tr_cd": "t8412", "tr_cont": "N", "tr_cont_key": "", "mac_address": "", } resp = self.request("POST", CHART_URL, headers=headers, data=json.dumps(body)) if resp is None: return pd.DataFrame() try: data = resp.json() if resp.text else {} except Exception: logger.warning("t8412 JSON 파싱 실패 code=%s", code) return pd.DataFrame() rsp = str(data.get("rsp_cd") or "") if rsp and rsp not in ("00000", "0"): # 09000 등 자료없음은 빈 DF logger.debug( "t8412 rsp_cd=%s msg=%s code=%s", rsp, data.get("rsp_msg"), code, ) if rsp != "00000": return pd.DataFrame() rows_out = data.get("t8412OutBlock1") or [] if isinstance(rows_out, dict): rows_out = [rows_out] parsed: List[Dict[str, Any]] = [] for r in rows_out: if not isinstance(r, dict): continue date = "".join(ch for ch in str(r.get("date") or "") if ch.isdigit()) tim = "".join(ch for ch in str(r.get("time") or "") if ch.isdigit()) if len(tim) >= 6: tim = tim[:4] # HHMMSS → HHMM for candle_time elif len(tim) == 4: pass else: continue if len(date) < 8: continue ctime = date[:8] + tim[:4] try: close = float(r.get("close") or 0) except (TypeError, ValueError): continue if close <= 0: continue try: o = float(r.get("open") or close) h = float(r.get("high") or close) l = float(r.get("low") or close) v = float(r.get("jdiff_vol") or r.get("volume") or 0) except (TypeError, ValueError): o = h = l = close v = 0.0 parsed.append({ "time": ctime, "open": o, "high": h, "low": l, "close": close, "volume": v, }) if not parsed: return pd.DataFrame() df = pd.DataFrame(parsed) df = df.drop_duplicates(subset=["time"], keep="last") df = df.sort_values("time").reset_index(drop=True) return df _client: Optional[LSChartClient] = None _client_lock = threading.Lock() def get_ls_chart_client() -> LSChartClient: global _client with _client_lock: if _client is None: _client = LSChartClient() return _client def fetch_ls_minute_chart_df( code: str, *, ncnt: int = 1, qrycnt: Optional[int] = None, ) -> pd.DataFrame: """갭보정 진입점 — DataFrame(time/open/high/low/close/volume).""" try: return get_ls_chart_client().fetch_minute_bars( code, ncnt=ncnt, qrycnt=qrycnt, ) except Exception as e: logger.warning("LS t8412 실패 %s: %s", code, e) return pd.DataFrame()