ls kiwoom 구독 히스토리 모두 적재

This commit is contained in:
Your Name
2026-07-30 20:23:37 +09:00
parent 67eab24603
commit 7050f788c5
18 changed files with 1379 additions and 120 deletions

View File

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