fix(시세): LS spill-only·토큰 파일캐시·호가 snap_time 3초컷
- _feed_fallback 미러 OFF, LS cap/grace/hold RAM을 KIS·키움 spill과 정합 - LS 접근토큰 .ls_token_cache_*.json (재시작 재사용, revoke 루프 없음) - 호가 RAM을 틱과 동일 LIVE_FEED_FALLBACK(snap_time)로 컷, 필터 max_age=0은 유지 - 익절 지정가 로그에 실제 호가 벤더(kis/kiwoom/ls 1·2·3차) 표기 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
운영 준수:
|
||||
- 응답 ``expires_in``/``expire_in``(초) 로 만료 시각을 잡고 **만료 전 재사용**
|
||||
- 만료·IGW00121/123(무효/기간만료) 시에만 재발급
|
||||
- 프로세스 공유 캐시 + 최소 재발급 간격(한도·폭주 방지)
|
||||
- 프로세스 RAM + **파일 캐시** (``.ls_token_cache_{real|mock}.json``) — KIS REST 토큰과 동일 패턴
|
||||
- 최소 재발급 간격(한도·폭주 방지)
|
||||
- ``/oauth2/revoke`` 는 정상 폐기용 — 매 루프 강제 발급에 쓰지 않음
|
||||
- **국내↔해외 세션 전환·WS 재연결에서 force 재발급 금지**
|
||||
(LS 토큰은 조건검색 REST·국내/해외 WS 공용 — 재발급 시 상대 경로 토큰 무효화.
|
||||
@@ -14,9 +15,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
import requests
|
||||
@@ -28,6 +32,13 @@ 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"
|
||||
|
||||
# kis_token_manager.py · kis_approval_manager.py 와 동일 — repo 루트
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CACHE_REAL = ROOT / ".ls_token_cache_real.json"
|
||||
CACHE_MOCK = ROOT / ".ls_token_cache_mock.json"
|
||||
LOCK_FILE = ROOT / ".ls_token_manager.lock"
|
||||
LOCK_TIMEOUT_S = 60
|
||||
|
||||
# 스펙 응답 예시 기본 유효기간(초)
|
||||
_DEFAULT_EXPIRES_IN = 86400
|
||||
|
||||
@@ -78,8 +89,124 @@ def _parse_expires_in(body: Dict[str, Any]) -> int:
|
||||
return max(60, n)
|
||||
|
||||
|
||||
def _is_mock_app_key(app_key: str) -> bool:
|
||||
"""모의/실전 파일 분리 — env ``LS_APP_KEY_MOCK`` 과 일치하면 mock."""
|
||||
k = (app_key or "").strip()
|
||||
mock = (os.environ.get("LS_APP_KEY_MOCK") or "").strip()
|
||||
return bool(mock and k == mock)
|
||||
|
||||
|
||||
def _cache_file_path(app_key: str) -> Path:
|
||||
return CACHE_MOCK if _is_mock_app_key(app_key) else CACHE_REAL
|
||||
|
||||
|
||||
def _acquire_file_lock() -> bool:
|
||||
deadline = time.time() + LOCK_TIMEOUT_S
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
fd = os.open(str(LOCK_FILE), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
os.write(fd, str(os.getpid()).encode())
|
||||
os.close(fd)
|
||||
return True
|
||||
except FileExistsError:
|
||||
try:
|
||||
if time.time() - LOCK_FILE.stat().st_mtime > 300:
|
||||
LOCK_FILE.unlink(missing_ok=True)
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
except Exception as e:
|
||||
logger.warning("LS 토큰 파일 잠금 실패: %s", e)
|
||||
return False
|
||||
logger.warning("LS 토큰 파일 잠금 타임아웃 → 파일 캐시 건너뜀")
|
||||
return False
|
||||
|
||||
|
||||
def _release_file_lock() -> None:
|
||||
try:
|
||||
LOCK_FILE.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _read_file_cache(app_key: str, app_secret: str) -> Optional[Dict[str, Any]]:
|
||||
path = _cache_file_path(app_key)
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
logger.warning("LS 토큰 파일 읽기 실패 (%s): %s", path.name, e)
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
fk = f"{(app_key or '').strip()}|{(app_secret or '')[:8]}"
|
||||
stored_key = str(data.get("cache_key") or "")
|
||||
if stored_key and stored_key != fk:
|
||||
return None
|
||||
tok = str(data.get("access_token") or data.get("token") or "")
|
||||
if not tok:
|
||||
return None
|
||||
try:
|
||||
expire_at = float(data.get("expire_at") or 0)
|
||||
issued_at = float(data.get("issued_at") or 0)
|
||||
expires_in = int(data.get("expires_in") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return {
|
||||
"token": tok,
|
||||
"expire_at": expire_at,
|
||||
"expires_in": expires_in,
|
||||
"issued_at": issued_at,
|
||||
}
|
||||
|
||||
|
||||
def _write_file_cache(app_key: str, app_secret: str, ent: Dict[str, Any]) -> None:
|
||||
path = _cache_file_path(app_key)
|
||||
fk = f"{(app_key or '').strip()}|{(app_secret or '')[:8]}"
|
||||
payload = {
|
||||
"cache_key": fk,
|
||||
"access_token": str(ent.get("token") or ""),
|
||||
"expire_at": float(ent.get("expire_at") or 0),
|
||||
"expires_in": int(ent.get("expires_in") or 0),
|
||||
"issued_at": float(ent.get("issued_at") or 0),
|
||||
"saved_at": time.time(),
|
||||
}
|
||||
locked = _acquire_file_lock()
|
||||
try:
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
logger.info("💾 LS 토큰 파일 저장 | %s | 만료까지 %.1fh", path.name, max(0.0, payload["expire_at"] - time.time()) / 3600.0)
|
||||
except Exception as e:
|
||||
logger.warning("LS 토큰 파일 저장 실패 (%s): %s", path.name, e)
|
||||
finally:
|
||||
if locked:
|
||||
_release_file_lock()
|
||||
|
||||
|
||||
def _clear_file_cache(app_key: str, app_secret: str) -> None:
|
||||
path = _cache_file_path(app_key)
|
||||
if not path.is_file():
|
||||
return
|
||||
locked = _acquire_file_lock()
|
||||
try:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
data = {}
|
||||
fk = f"{(app_key or '').strip()}|{(app_secret or '')[:8]}"
|
||||
if isinstance(data, dict) and str(data.get("cache_key") or "") == fk:
|
||||
path.unlink(missing_ok=True)
|
||||
logger.info("🗑 LS 토큰 파일 삭제 | %s", path.name)
|
||||
except Exception as e:
|
||||
logger.warning("LS 토큰 파일 삭제 실패 (%s): %s", path.name, e)
|
||||
finally:
|
||||
if locked:
|
||||
_release_file_lock()
|
||||
|
||||
|
||||
class LSTokenCache:
|
||||
"""appkey 단위 공유 접근토큰 캐시."""
|
||||
"""appkey 단위 공유 접근토큰 캐시 (RAM + JSON 파일)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
@@ -97,7 +224,28 @@ class LSTokenCache:
|
||||
# 연속 재발급 최소 간격 — 폭주 방지 (기본 60초)
|
||||
return float(get_env_float("LS_TOKEN_MIN_REISSUE_SEC", 60.0) or 60.0)
|
||||
|
||||
def _hydrate_from_file(self, app_key: str, app_secret: str) -> None:
|
||||
"""RAM 비었을 때 재시작 직후 파일 캐시 복구."""
|
||||
ck = self._cache_key(app_key, app_secret)
|
||||
with self._lock:
|
||||
if self._by_key.get(ck, {}).get("token"):
|
||||
return
|
||||
ent = _read_file_cache(app_key, app_secret)
|
||||
if not ent:
|
||||
return
|
||||
with self._lock:
|
||||
cur = self._by_key.get(ck) or {}
|
||||
if cur.get("token"):
|
||||
return
|
||||
self._by_key[ck] = dict(ent)
|
||||
logger.info(
|
||||
"📂 LS 토큰 파일 캐시 로드 | %s | 만료까지 %.1fh",
|
||||
_cache_file_path(app_key).name,
|
||||
max(0.0, float(ent.get("expire_at") or 0) - time.time()) / 3600.0,
|
||||
)
|
||||
|
||||
def peek(self, app_key: str, app_secret: str) -> Optional[str]:
|
||||
self._hydrate_from_file(app_key, app_secret)
|
||||
with self._lock:
|
||||
ent = self._by_key.get(self._cache_key(app_key, app_secret))
|
||||
if not ent:
|
||||
@@ -123,6 +271,8 @@ class LSTokenCache:
|
||||
if not (app_key and app_secret):
|
||||
raise RuntimeError("LS appkey/appsecret 필요")
|
||||
|
||||
self._hydrate_from_file(app_key, app_secret)
|
||||
|
||||
ck = self._cache_key(app_key, app_secret)
|
||||
with self._lock:
|
||||
ent = self._by_key.get(ck) or {}
|
||||
@@ -145,6 +295,38 @@ class LSTokenCache:
|
||||
)
|
||||
return tok
|
||||
|
||||
# invalidate 후 빈 토큰: 최소간격 안이면 대기 후 발급(한도 준수)
|
||||
if (not still_ok) and last_iss and (now - last_iss) < min_gap:
|
||||
wait = min_gap - (now - last_iss)
|
||||
logger.info(
|
||||
"LS 토큰 재발급 대기 %.1fs (최소간격, reason=%s)",
|
||||
wait, reason or "reissue",
|
||||
)
|
||||
# lock 밖에서 sleep — 아래 재진입
|
||||
pass
|
||||
else:
|
||||
wait = 0.0
|
||||
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
return self.get(
|
||||
app_key, app_secret, force=force, reason=reason, timeout=timeout,
|
||||
)
|
||||
|
||||
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
|
||||
min_gap = self._min_reissue_sec()
|
||||
if still_ok and force and (now - last_iss) < min_gap:
|
||||
return tok
|
||||
|
||||
# 만료 직전·만료·강제 — 발급
|
||||
if still_ok and force:
|
||||
logger.info("LS 토큰 재발급 요청 reason=%s (캐시 유효하나 force)", reason or "force")
|
||||
@@ -162,18 +344,53 @@ class LSTokenCache:
|
||||
raise RuntimeError(f"LS token empty: {body}")
|
||||
expires_in = _parse_expires_in(body)
|
||||
expire_at = now + float(expires_in)
|
||||
self._by_key[ck] = {
|
||||
new_ent = {
|
||||
"token": token,
|
||||
"expire_at": expire_at,
|
||||
"expires_in": expires_in,
|
||||
"issued_at": now,
|
||||
}
|
||||
self._by_key[ck] = new_ent
|
||||
logger.info(
|
||||
"✅ LS 접근토큰 발급 | expires_in=%ds | 만료까지 %.1fh | reason=%s",
|
||||
expires_in, expires_in / 3600.0, reason or "ok",
|
||||
)
|
||||
_write_file_cache(app_key, app_secret, new_ent)
|
||||
return token
|
||||
|
||||
def invalidate(
|
||||
self,
|
||||
app_key: str,
|
||||
app_secret: str,
|
||||
*,
|
||||
reason: str = "",
|
||||
) -> None:
|
||||
"""서버가 토큰을 무효화했을 때 RAM + 파일 캐시 비운다.
|
||||
|
||||
``issued_at`` 은 남겨 두어 다음 ``get()`` 이 최소 재발급 간격을
|
||||
존중할 수 있게 한다. (빈 token → still_ok=False → 간격 후 재발급)
|
||||
"""
|
||||
app_key = (app_key or "").strip()
|
||||
app_secret = (app_secret or "").strip()
|
||||
if not (app_key and app_secret):
|
||||
return
|
||||
ck = self._cache_key(app_key, app_secret)
|
||||
with self._lock:
|
||||
ent = self._by_key.get(ck) or {}
|
||||
last_iss = float(ent.get("issued_at") or 0)
|
||||
if not ent and not last_iss:
|
||||
logger.info("LS 토큰 캐시 무효화(이미 비어 있음) reason=%s", reason or "-")
|
||||
_clear_file_cache(app_key, app_secret)
|
||||
return
|
||||
self._by_key[ck] = {
|
||||
"token": "",
|
||||
"expire_at": 0.0,
|
||||
"expires_in": 0,
|
||||
"issued_at": last_iss,
|
||||
}
|
||||
logger.warning("LS 토큰 캐시 무효화 reason=%s", reason or "-")
|
||||
_clear_file_cache(app_key, app_secret)
|
||||
|
||||
@staticmethod
|
||||
def _issue(app_key: str, app_secret: str, *, timeout: float) -> Dict[str, Any]:
|
||||
resp = requests.post(
|
||||
@@ -217,6 +434,16 @@ def fetch_ls_access_token(
|
||||
)
|
||||
|
||||
|
||||
def invalidate_ls_access_token(
|
||||
app_key: str,
|
||||
app_secret: str,
|
||||
*,
|
||||
reason: str = "",
|
||||
) -> None:
|
||||
"""로컬 RAM·파일 캐시 무효화 (서버 Bye/auth 복구용). OAuth 호출 없음."""
|
||||
_CACHE.invalidate(app_key, app_secret, reason=reason)
|
||||
|
||||
|
||||
def fetch_ls_access_token_info(
|
||||
app_key: str,
|
||||
app_secret: str,
|
||||
|
||||
Reference in New Issue
Block a user