ls증권 히스토리 구독 넣음
This commit is contained in:
238
kis_trader/network/ls_chart.py
Normal file
238
kis_trader/network/ls_chart.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user