ls kiwoom 구독 히스토리 모두 적재
This commit is contained in:
@@ -756,6 +756,13 @@ class KiwoomConditionSearchManager(ConditionSearchManager):
|
||||
|
||||
부모 ``_apply_result(strategy_id, rows)`` 를 그대로 호출 →
|
||||
enters/exits 계산·순서·EXIT grace·스냅샷 저장까지 KIS 와 동일하게 처리.
|
||||
|
||||
이중 적재 (기본 ON):
|
||||
실매 ``UNIVERSE_SOURCE`` 가 kiwoom_condition 이 아니어도
|
||||
(예: SCALP/BREAKOUT=ls_condition) RAM·``target_candidates_history`` 는 갱신한다.
|
||||
→ 다전략 동시 운영 시 키움/LS 이력 둘 다 쌓기 · 런타임 소스 전환 대비.
|
||||
``on_change`` 는 main 미주입이라 WS 구독 부작용 없음.
|
||||
끄기: ``KIWOOM_CONDITION_DUAL_HISTORY=false`` (레거시: 실매 소스만 반영).
|
||||
"""
|
||||
with self._kw_lock:
|
||||
bucket = dict(self._seq_codes.get(seq, {}))
|
||||
@@ -772,8 +779,10 @@ class KiwoomConditionSearchManager(ConditionSearchManager):
|
||||
disp = c
|
||||
rows.append({"code": c, "name": disp})
|
||||
from ..utils.universe_source import universe_source_active
|
||||
|
||||
dual = get_env_bool("KIWOOM_CONDITION_DUAL_HISTORY", True)
|
||||
for sid in sids:
|
||||
if not universe_source_active(sid, "kiwoom_condition"):
|
||||
if not dual and not universe_source_active(sid, "kiwoom_condition"):
|
||||
continue
|
||||
try:
|
||||
self._apply_result(sid, rows)
|
||||
|
||||
@@ -88,11 +88,12 @@ class LSChartClient(SafeRequest):
|
||||
|
||||
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)
|
||||
# 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
|
||||
|
||||
|
||||
@@ -202,6 +202,7 @@ class LsConditionSearchManager(ConditionSearchManager):
|
||||
)
|
||||
self._use_mock = bool(use_mock)
|
||||
self._token: Optional[str] = None
|
||||
self._token_expire_at: float = 0.0
|
||||
self._app_key: str = ""
|
||||
self._app_secret: str = ""
|
||||
self._rt = None
|
||||
@@ -221,6 +222,8 @@ class LsConditionSearchManager(ConditionSearchManager):
|
||||
self._last_remap_mono = 0.0
|
||||
self._last_snap_refresh_mono = 0.0
|
||||
self._last_missing_warn: Dict[str, float] = {}
|
||||
# t1860 E 가 sAlertNum=0 이면 장중 재시도 (장외 정상 ACK + 키 미발급)
|
||||
self._afr_pending_retry: Set[str] = set()
|
||||
self._ready = threading.Event()
|
||||
self._start_ok = False
|
||||
# LS 조건 유니버스 합집합 → LS WS sync 등 (main 이 등록)
|
||||
@@ -297,7 +300,7 @@ class LsConditionSearchManager(ConditionSearchManager):
|
||||
self._app_key = app_key
|
||||
self._app_secret = app_secret
|
||||
|
||||
if not self._ensure_token(force=True):
|
||||
if not self._ensure_token(force=False, reason="ls_cond_start"):
|
||||
return False
|
||||
|
||||
logger.warning(
|
||||
@@ -388,13 +391,19 @@ class LsConditionSearchManager(ConditionSearchManager):
|
||||
out.append((sid, nm))
|
||||
return out
|
||||
|
||||
def _ensure_token(self, *, force: bool = False) -> bool:
|
||||
if self._token and not force:
|
||||
return True
|
||||
def _ensure_token(self, *, force: bool = False, reason: str = "") -> bool:
|
||||
"""``/oauth2/token`` — expires_in 캐시 재사용. 강제 연타 발급 금지."""
|
||||
if not (self._app_key and self._app_secret and self._rt):
|
||||
return False
|
||||
try:
|
||||
tok = self._rt.fetch_access_token(self._app_key, self._app_secret)
|
||||
from kis_trader.network.ls_token import fetch_ls_access_token_info
|
||||
|
||||
tok, exp_at, _exp_in = fetch_ls_access_token_info(
|
||||
self._app_key,
|
||||
self._app_secret,
|
||||
force=force,
|
||||
reason=reason or ("force" if force else "ensure"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("LS 토큰 발급 실패: %s", e)
|
||||
return False
|
||||
@@ -402,6 +411,7 @@ class LsConditionSearchManager(ConditionSearchManager):
|
||||
logger.warning("LS 토큰 빈값")
|
||||
return False
|
||||
self._token = str(tok)
|
||||
self._token_expire_at = float(exp_at or 0)
|
||||
w = self._watcher
|
||||
if w is not None:
|
||||
try:
|
||||
@@ -410,6 +420,18 @@ class LsConditionSearchManager(ConditionSearchManager):
|
||||
pass
|
||||
return True
|
||||
|
||||
def _refresh_token_on_auth_error(self, err: Any, *, where: str) -> bool:
|
||||
"""IGW00121/123 등 — 스펙 준수 1회 재발급(최소간격 캐시)."""
|
||||
from kis_trader.network.ls_token import is_ls_auth_error
|
||||
|
||||
err_s = str(err or "")
|
||||
if not is_ls_auth_error(rsp_msg=err_s, text=err_s):
|
||||
# RuntimeError 메시지에 rsp_cd 포함
|
||||
if "IGW00121" not in err_s and "IGW00123" not in err_s:
|
||||
return False
|
||||
logger.warning("LS 인증 오류(%s) → /oauth2/token 갱신: %s", where, err_s[:200])
|
||||
return self._ensure_token(force=True, reason=f"auth:{where}")
|
||||
|
||||
def _start_afr_watcher(self) -> bool:
|
||||
if self._watcher is not None:
|
||||
return True
|
||||
@@ -551,8 +573,19 @@ class LsConditionSearchManager(ConditionSearchManager):
|
||||
)
|
||||
self._tr_sleep()
|
||||
except Exception as e:
|
||||
logger.error("t1860 예외 %s: %s", st.strategy_id, e)
|
||||
return
|
||||
if self._refresh_token_on_auth_error(e, where="t1860"):
|
||||
try:
|
||||
ob = self._rt.t1860_realtime(
|
||||
self._token, st.query_index, flag="E", alert_num="",
|
||||
logger=logger,
|
||||
)
|
||||
self._tr_sleep()
|
||||
except Exception as e2:
|
||||
logger.error("t1860 재시도 예외 %s: %s", st.strategy_id, e2)
|
||||
return
|
||||
else:
|
||||
logger.error("t1860 예외 %s: %s", st.strategy_id, e)
|
||||
return
|
||||
if str(ob.get("sResultFlag") or "").strip() != "S":
|
||||
logger.error(
|
||||
"t1860 실패 sid=%s %s",
|
||||
@@ -561,11 +594,16 @@ class LsConditionSearchManager(ConditionSearchManager):
|
||||
return
|
||||
alert = str(ob.get("sAlertNum") or "").strip()
|
||||
if (not alert) or (alert.strip("0") == ""):
|
||||
logger.error(
|
||||
"sAlertNum 무효 sid=%s alert=%r — AFR 스킵",
|
||||
st.strategy_id, alert,
|
||||
# 장외·장전: sResultFlag=S + Msg=정상처리 인데 sAlertNum=000… 인 경우
|
||||
# (실측 로그 다수). AFR WS 등록 불가 → 대기 후 rematch 재시도.
|
||||
self._afr_pending_retry.add(st.strategy_id)
|
||||
logger.warning(
|
||||
"sAlertNum 미발급 sid=%s alert=%r msg=%s — AFR 대기재시도 "
|
||||
"(장외/장전 LS 서버가 키 0 반환. 코드 버그 아님)",
|
||||
st.strategy_id, alert, str(ob.get("Msg") or "")[:40],
|
||||
)
|
||||
return
|
||||
self._afr_pending_retry.discard(st.strategy_id)
|
||||
st.alert_num = alert
|
||||
self._by_alert[alert] = st
|
||||
logger.info(
|
||||
@@ -591,7 +629,7 @@ class LsConditionSearchManager(ConditionSearchManager):
|
||||
) -> None:
|
||||
"""t1866 이름→index 재매핑. 변경 시 AFR 재부착 / 신규 부착 / 소실 시 해제."""
|
||||
with self._maint_lock:
|
||||
if not self._ensure_token(force=False):
|
||||
if not self._ensure_token(force=False, reason="remap"):
|
||||
return
|
||||
assert self._rt is not None and self._token
|
||||
try:
|
||||
@@ -599,22 +637,20 @@ class LsConditionSearchManager(ConditionSearchManager):
|
||||
self._token, self.user_id, logger=logger,
|
||||
)
|
||||
except Exception as e:
|
||||
err_s = str(e).lower()
|
||||
# 인증 만료 시에만 1회 재발급 (한도 존중)
|
||||
if any(x in err_s for x in ("401", "403", "token", "auth", "unauthorized")):
|
||||
logger.warning("t1866 인증성 오류 → 토큰 1회 재발급: %s", e)
|
||||
if self._ensure_token(force=True):
|
||||
try:
|
||||
rows = self._rt.t1866_list_conditions(
|
||||
self._token, self.user_id, logger=logger,
|
||||
)
|
||||
except Exception as e2:
|
||||
logger.error("t1866 재시도 실패: %s", e2)
|
||||
return
|
||||
else:
|
||||
# 인증 오류 → 스펙 준수 재발급 후 1회 재시도. 그 외(GW라우팅 등)는
|
||||
# 빈목록 '조건 소실'로 오판해 AFR 해제하지 않음.
|
||||
if self._refresh_token_on_auth_error(e, where="t1866"):
|
||||
try:
|
||||
rows = self._rt.t1866_list_conditions(
|
||||
self._token, self.user_id, logger=logger,
|
||||
)
|
||||
except Exception as e2:
|
||||
logger.error("t1866 재시도 실패: %s", e2)
|
||||
return
|
||||
else:
|
||||
logger.error("t1866 실패: %s", e)
|
||||
logger.error(
|
||||
"t1866 실패(기존 매핑·AFR 유지, 소실 해제 안 함): %s", e,
|
||||
)
|
||||
return
|
||||
|
||||
wanted = self._desired_bindings()
|
||||
@@ -624,6 +660,8 @@ class LsConditionSearchManager(ConditionSearchManager):
|
||||
hit = self._rt._resolve_query(rows, name=nm, query_index="")
|
||||
if not hit or not hit.get("query_index"):
|
||||
self._warn_missing(sid, nm)
|
||||
# 목록 조회 성공인데 이름만 없음 = 진짜 소실.
|
||||
# (HTTP/인증 실패는 위에서 return — 여기 도달 안 함)
|
||||
st_old = self._states_by_sid.get(sid)
|
||||
if st_old is not None:
|
||||
logger.warning(
|
||||
@@ -632,6 +670,7 @@ class LsConditionSearchManager(ConditionSearchManager):
|
||||
)
|
||||
self._teardown_afr(st_old, clear_ram=True)
|
||||
self._states_by_sid.pop(sid, None)
|
||||
self._afr_pending_retry.discard(sid)
|
||||
continue
|
||||
|
||||
qidx = str(hit["query_index"])
|
||||
@@ -655,7 +694,11 @@ class LsConditionSearchManager(ConditionSearchManager):
|
||||
|
||||
idx_changed = str(st.query_index) != qidx
|
||||
name_changed = str(st.query_name) != qname
|
||||
need_afr = force_afr or (not str(st.alert_num or "").strip())
|
||||
need_afr = (
|
||||
force_afr
|
||||
or (not str(st.alert_num or "").strip())
|
||||
or (sid in self._afr_pending_retry)
|
||||
)
|
||||
if idx_changed or name_changed:
|
||||
logger.warning(
|
||||
"🔄 LS rematch sid=%s %s/%s → %s/%s",
|
||||
@@ -669,7 +712,7 @@ class LsConditionSearchManager(ConditionSearchManager):
|
||||
)
|
||||
elif need_afr:
|
||||
logger.info(
|
||||
"🔄 LS AFR 재등록 sid=%s name=%s (alert 없음/강제)",
|
||||
"🔄 LS AFR 재등록 sid=%s name=%s (alert 없음/강제/대기재시도)",
|
||||
sid, st.query_name,
|
||||
)
|
||||
self._mount_snapshot_and_afr(
|
||||
@@ -687,6 +730,7 @@ class LsConditionSearchManager(ConditionSearchManager):
|
||||
st = self._states_by_sid.pop(sid)
|
||||
logger.warning("🔄 LS 설정 제거 → 해제 sid=%s", sid)
|
||||
self._teardown_afr(st, clear_ram=True)
|
||||
self._afr_pending_retry.discard(sid)
|
||||
|
||||
self._last_remap_mono = time.monotonic()
|
||||
if force_snapshot:
|
||||
@@ -695,7 +739,7 @@ class LsConditionSearchManager(ConditionSearchManager):
|
||||
def _refresh_snapshots_only(self) -> None:
|
||||
"""인덱스 유지한 채 t1859 만 재동기화 (AFR sticky 보정)."""
|
||||
with self._maint_lock:
|
||||
if not self._ensure_token(force=False):
|
||||
if not self._ensure_token(force=False, reason="snap_refresh"):
|
||||
return
|
||||
for st in list(self._states_by_sid.values()):
|
||||
if not str(st.query_index or "").strip():
|
||||
|
||||
231
kis_trader/network/ls_token.py
Normal file
231
kis_trader/network/ls_token.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""LS OpenAPI 접근토큰 — ``POST /oauth2/token`` (스펙 ``token``).
|
||||
|
||||
운영 준수:
|
||||
- 응답 ``expires_in``/``expire_in``(초) 로 만료 시각을 잡고 **만료 전 재사용**
|
||||
- 만료·IGW00121/123(무효/기간만료) 시에만 재발급
|
||||
- 프로세스 공유 캐시 + 최소 재발급 간격(한도·폭주 방지)
|
||||
- ``/oauth2/revoke`` 는 정상 폐기용 — 매 루프 강제 발급에 쓰지 않음
|
||||
|
||||
스펙 예시: expires_in=86400 (24h). transactionPerSec='-'.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from kis_trader.utils.env import get_env_float, get_env_int
|
||||
|
||||
logger = logging.getLogger("kis_trader.ls_token")
|
||||
|
||||
LS_REST_BASE = "https://openapi.ls-sec.co.kr:8080"
|
||||
LS_TOKEN_URL = f"{LS_REST_BASE}/oauth2/token"
|
||||
|
||||
# 스펙 응답 예시 기본 유효기간(초)
|
||||
_DEFAULT_EXPIRES_IN = 86400
|
||||
|
||||
# LS GW 인증 오류 코드 (재발급 트리거)
|
||||
LS_AUTH_RSP_CODES = frozenset({"IGW00121", "IGW00123"})
|
||||
|
||||
|
||||
def is_ls_auth_error(
|
||||
*,
|
||||
rsp_cd: Any = None,
|
||||
rsp_msg: Any = None,
|
||||
http_status: Any = None,
|
||||
text: Any = None,
|
||||
) -> bool:
|
||||
"""만료·무효 토큰 응답인지."""
|
||||
cd = str(rsp_cd or "").strip().upper()
|
||||
if cd in LS_AUTH_RSP_CODES:
|
||||
return True
|
||||
blob = f"{rsp_msg or ''} {text or ''}".lower()
|
||||
if any(
|
||||
x in blob
|
||||
for x in (
|
||||
"기간이 만료된 token",
|
||||
"유효하지 않은 token",
|
||||
"invalid token",
|
||||
"expired token",
|
||||
)
|
||||
):
|
||||
return True
|
||||
try:
|
||||
st = int(http_status or 0)
|
||||
except (TypeError, ValueError):
|
||||
st = 0
|
||||
if st in (401, 403) and "token" in blob:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _parse_expires_in(body: Dict[str, Any]) -> int:
|
||||
"""스펙 필드 ``expire_in`` + 실제 응답 ``expires_in`` 모두 수용."""
|
||||
raw = body.get("expires_in", body.get("expire_in", None))
|
||||
try:
|
||||
n = int(float(raw))
|
||||
except (TypeError, ValueError):
|
||||
n = 0
|
||||
if n <= 0:
|
||||
n = int(get_env_int("LS_TOKEN_EXPIRES_IN_DEFAULT", _DEFAULT_EXPIRES_IN) or _DEFAULT_EXPIRES_IN)
|
||||
return max(60, n)
|
||||
|
||||
|
||||
class LSTokenCache:
|
||||
"""appkey 단위 공유 접근토큰 캐시."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
# key = f"{app_key}|{app_secret[:8]}" → dict
|
||||
self._by_key: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def _cache_key(self, app_key: str, app_secret: str) -> str:
|
||||
return f"{(app_key or '').strip()}|{(app_secret or '')[:8]}"
|
||||
|
||||
def _margin_sec(self) -> float:
|
||||
# 만료 N초 전부터 선제 갱신 (기본 600초)
|
||||
return float(get_env_int("LS_TOKEN_REFRESH_MARGIN_SEC", 600) or 600)
|
||||
|
||||
def _min_reissue_sec(self) -> float:
|
||||
# 연속 재발급 최소 간격 — 폭주 방지 (기본 60초)
|
||||
return float(get_env_float("LS_TOKEN_MIN_REISSUE_SEC", 60.0) or 60.0)
|
||||
|
||||
def peek(self, app_key: str, app_secret: str) -> Optional[str]:
|
||||
with self._lock:
|
||||
ent = self._by_key.get(self._cache_key(app_key, app_secret))
|
||||
if not ent:
|
||||
return None
|
||||
tok = str(ent.get("token") or "")
|
||||
exp = float(ent.get("expire_at") or 0)
|
||||
if tok and time.time() < exp - self._margin_sec():
|
||||
return tok
|
||||
return None
|
||||
|
||||
def get(
|
||||
self,
|
||||
app_key: str,
|
||||
app_secret: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
reason: str = "",
|
||||
timeout: float = 15.0,
|
||||
) -> str:
|
||||
"""유효 토큰 반환. force=True 여도 최소 재발급 간격 준수(캐시 유효하면 재사용)."""
|
||||
app_key = (app_key or "").strip()
|
||||
app_secret = (app_secret or "").strip()
|
||||
if not (app_key and app_secret):
|
||||
raise RuntimeError("LS appkey/appsecret 필요")
|
||||
|
||||
ck = self._cache_key(app_key, app_secret)
|
||||
with self._lock:
|
||||
ent = self._by_key.get(ck) or {}
|
||||
tok = str(ent.get("token") or "")
|
||||
exp = float(ent.get("expire_at") or 0)
|
||||
last_iss = float(ent.get("issued_at") or 0)
|
||||
now = time.time()
|
||||
margin = self._margin_sec()
|
||||
still_ok = bool(tok) and now < (exp - margin)
|
||||
|
||||
if still_ok and not force:
|
||||
return tok
|
||||
|
||||
# force 여도 방금 발급분이면 재사용 (한도·폭주 방지)
|
||||
min_gap = self._min_reissue_sec()
|
||||
if still_ok and force and (now - last_iss) < min_gap:
|
||||
logger.info(
|
||||
"LS 토큰 재발급 스킵(최소간격 %.0fs, reason=%s) → 캐시 재사용",
|
||||
min_gap, reason or "force",
|
||||
)
|
||||
return tok
|
||||
|
||||
# 만료 직전·만료·강제 — 발급
|
||||
if still_ok and force:
|
||||
logger.info("LS 토큰 재발급 요청 reason=%s (캐시 유효하나 force)", reason or "force")
|
||||
elif tok and not still_ok:
|
||||
logger.info(
|
||||
"LS 토큰 만료/임박 → /oauth2/token 발급 (남은 %.0fs, reason=%s)",
|
||||
exp - now, reason or "expire",
|
||||
)
|
||||
else:
|
||||
logger.info("LS 토큰 신규 발급 (/oauth2/token) reason=%s", reason or "start")
|
||||
|
||||
body = self._issue(app_key, app_secret, timeout=timeout)
|
||||
token = str(body.get("access_token") or body.get("accesstoken") or "")
|
||||
if not token:
|
||||
raise RuntimeError(f"LS token empty: {body}")
|
||||
expires_in = _parse_expires_in(body)
|
||||
expire_at = now + float(expires_in)
|
||||
self._by_key[ck] = {
|
||||
"token": token,
|
||||
"expire_at": expire_at,
|
||||
"expires_in": expires_in,
|
||||
"issued_at": now,
|
||||
}
|
||||
logger.info(
|
||||
"✅ LS 접근토큰 발급 | expires_in=%ds | 만료까지 %.1fh | reason=%s",
|
||||
expires_in, expires_in / 3600.0, reason or "ok",
|
||||
)
|
||||
return token
|
||||
|
||||
@staticmethod
|
||||
def _issue(app_key: str, app_secret: str, *, timeout: float) -> Dict[str, Any]:
|
||||
resp = requests.post(
|
||||
LS_TOKEN_URL,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"appkey": app_key,
|
||||
"appsecretkey": app_secret,
|
||||
"scope": "oob",
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(
|
||||
f"LS token HTTP {resp.status_code}: {resp.text[:300]}"
|
||||
)
|
||||
try:
|
||||
body = resp.json()
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"LS token JSON 실패: {e}") from e
|
||||
if not isinstance(body, dict):
|
||||
raise RuntimeError(f"LS token body 형식 오류: {body!r}")
|
||||
return body
|
||||
|
||||
|
||||
_CACHE = LSTokenCache()
|
||||
|
||||
|
||||
def fetch_ls_access_token(
|
||||
app_key: str,
|
||||
app_secret: str,
|
||||
timeout: float = 15.0,
|
||||
*,
|
||||
force: bool = False,
|
||||
reason: str = "",
|
||||
) -> str:
|
||||
"""호환 래퍼 — 문자열 토큰만 반환 (expires_in 은 캐시에 보관)."""
|
||||
return _CACHE.get(
|
||||
app_key, app_secret, force=force, reason=reason, timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def fetch_ls_access_token_info(
|
||||
app_key: str,
|
||||
app_secret: str,
|
||||
timeout: float = 15.0,
|
||||
*,
|
||||
force: bool = False,
|
||||
reason: str = "",
|
||||
) -> Tuple[str, float, int]:
|
||||
"""(token, expire_at_epoch, expires_in_sec)."""
|
||||
tok = _CACHE.get(
|
||||
app_key, app_secret, force=force, reason=reason, timeout=timeout,
|
||||
)
|
||||
with _CACHE._lock:
|
||||
ent = _CACHE._by_key.get(_CACHE._cache_key(app_key, app_secret)) or {}
|
||||
return tok, float(ent.get("expire_at") or 0), int(ent.get("expires_in") or 0)
|
||||
Reference in New Issue
Block a user