Files
kis_bot/kis_trader/network/ls_chart.py
Your Name 0ecac7cb95 이번에 들어간 내용
한투 호가 = 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 키 정리도 같이 들어갔습니다. 파일 단위로 나눌 수 없어서입니다.
2026-08-19 22:11:31 +09:00

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()