한투 호가 = 2번째 앱키 전용 키 없거나 start 실패 시 메인에 H0STASP0 안 붙임. 운영설정 WS_ORDERBOOK_SAVE_KIS 빨간 danger. LS RAM 합집합 후보∪보유∪영구∪grace. sync_targets와 split reconcile 둘 다. 틱 DB 영구 게이트는 그대로. 분봉 쓰레기 → 다음 소스 봉 통째 그 분 틱 0건이거나 전부 봉끝 대비 LIVE_FEED_FALLBACK_MAX_AGE_SEC 초과면 구멍. 메인 WS → 2차 → LS → REST → rollup. CANDLE_GARBAGE_FALLBACK 기본 true. 파일: feed_fallback.py(신규), ws_manager.py, kis_ws.py, candle_series.py, bt_candle_source.py, live_config_schema.py, database.py, 스모크, MD 2개. 같은 ws_manager/database/kis_ws/live_config에는 직전 커밋 이후 쌓여 있던 시세 폴백·ENV 키 정리도 같이 들어갔습니다. 파일 단위로 나눌 수 없어서입니다.
234 lines
7.5 KiB
Python
234 lines
7.5 KiB
Python
"""
|
|
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). 빈 값이면 ("",""). env_auth_config 우선."""
|
|
try:
|
|
from database import TradeDB
|
|
|
|
db = TradeDB()
|
|
r = db.get_auth_env()
|
|
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 not self._ensure_creds():
|
|
raise RuntimeError("LS AppKey/Secret 미설정")
|
|
# expires_in 공유 캐시 (12h 하드코딩 제거)
|
|
self._token = fetch_ls_access_token(
|
|
self._app_key, self._app_secret, reason="ls_chart",
|
|
)
|
|
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()
|