ls증권 히스토리 구독 넣음
This commit is contained in:
238
kis_trader/network/ls_chart.py
Normal file
238
kis_trader/network/ls_chart.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
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). 빈 값이면 ("","")."""
|
||||
try:
|
||||
from database import TradeDB
|
||||
|
||||
db = TradeDB()
|
||||
row = db.conn.execute(
|
||||
"SELECT LS_APP_KEY_REAL, LS_APP_SECRET_REAL FROM env_config "
|
||||
"ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
if not row:
|
||||
return "", ""
|
||||
r = dict(row)
|
||||
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 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)
|
||||
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()
|
||||
777
kis_trader/network/ls_condition_manager.py
Normal file
777
kis_trader/network/ls_condition_manager.py
Normal file
@@ -0,0 +1,777 @@
|
||||
"""
|
||||
kis_trader/network/ls_condition_manager.py — LS 서버저장조건(AFR)
|
||||
==============================================================================
|
||||
두 가지 용도 (실매 시세 get_price 와 무관):
|
||||
|
||||
1) ``LS_CONDITION_HISTORY_ENABLED=true``
|
||||
→ ``ls_candidates_history`` 적재 (실매 ``*_UNIVERSE_SOURCE`` 와 **분리**)
|
||||
2) ``{SID}_UNIVERSE_SOURCE=ls_condition``
|
||||
→ BaseStrategy 유니버스 소비 (opt-in)
|
||||
|
||||
흐름: t1866 → t1859 → t1860/AFR → RAM + (옵션) 이력 INSERT
|
||||
+ (옵션) LS WS owner=condition 구독 diff
|
||||
|
||||
운영 중 LS HTS 에서 조건 CRUD(삭제·이름변경·인덱스 재배열) 시:
|
||||
``LS_CONDITION_REMAP_SEC`` 주기로 t1866 이름→query_index 재매핑,
|
||||
바뀌면 구 AFR UNREG + t1860 D → t1859 → t1860 E → AFR REG (재시작 불필요).
|
||||
``LS_CONDITION_SNAPSHOT_REFRESH_SEC`` 주기로 동일 인덱스라도 t1859 스냅샷
|
||||
재동기화 (AFR 는 델타만이라 장중 sticky/빈 RAM 보정).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from .condition_manager import ConditionSearchManager
|
||||
from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger("kis_trader.lscond")
|
||||
|
||||
# LS query_name → 전략 id (키움 CONDITION_*_KIWOOM_NAME 동명 정책)
|
||||
DEFAULT_SID_TO_LS_NAME: Dict[str, str] = {
|
||||
"BREAKOUT": "breakout",
|
||||
"SHORT": "tail",
|
||||
"MOMENTUM": "momentum",
|
||||
"SCALP": "scalp_re",
|
||||
}
|
||||
|
||||
_LS_HISTORY_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS ls_candidates_history (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
slot_key VARCHAR(12) NOT NULL,
|
||||
scan_time VARCHAR(30) NOT NULL,
|
||||
code VARCHAR(20) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL DEFAULT '',
|
||||
score DOUBLE NOT NULL DEFAULT 0,
|
||||
price DOUBLE NOT NULL DEFAULT 0,
|
||||
market CHAR(1) DEFAULT 'Q',
|
||||
sector VARCHAR(100) NULL,
|
||||
theme VARCHAR(100) NULL,
|
||||
strategy_id VARCHAR(32) NOT NULL DEFAULT '',
|
||||
event_time VARCHAR(30) NOT NULL DEFAULT '',
|
||||
query_name VARCHAR(64) NOT NULL DEFAULT '',
|
||||
query_index VARCHAR(32) NOT NULL DEFAULT '',
|
||||
source VARCHAR(16) NOT NULL DEFAULT 'ls_afr',
|
||||
INDEX idx_ls_cand_slot (slot_key),
|
||||
INDEX idx_ls_cand_sid_evt (strategy_id, event_time),
|
||||
INDEX idx_ls_cand_code (code),
|
||||
INDEX idx_ls_cand_scan (scan_time)
|
||||
) CHARACTER SET utf8mb4
|
||||
"""
|
||||
|
||||
|
||||
def _load_ls_rt():
|
||||
"""단독 테스트 모듈의 REST/WS 헬퍼 재사용 (실매 시세 경로와 분리)."""
|
||||
return importlib.import_module("_test_ls_condition_realtime")
|
||||
|
||||
|
||||
def insert_ls_candidates_snapshot(
|
||||
db,
|
||||
*,
|
||||
strategy_id: str,
|
||||
query_name: str,
|
||||
query_index: str,
|
||||
items: List[Dict[str, Any]],
|
||||
event_time: Optional[str] = None,
|
||||
source: str = "ls_afr",
|
||||
) -> int:
|
||||
"""풀 유니버스 스냅샷 → ls_candidates_history (동일 event_time 재기록)."""
|
||||
if db is None:
|
||||
return 0
|
||||
try:
|
||||
db.conn.execute(_LS_HISTORY_DDL)
|
||||
except Exception:
|
||||
pass
|
||||
et = event_time or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
sk = et.replace("-", "").replace(":", "").replace(" ", "")[:12]
|
||||
sid = (strategy_id or "").strip().upper()
|
||||
qn = (query_name or "")[:64]
|
||||
qi = (query_index or "")[:32]
|
||||
inserted = 0
|
||||
with db.conn:
|
||||
db.conn.execute(
|
||||
"DELETE FROM ls_candidates_history "
|
||||
"WHERE strategy_id=%s AND event_time=%s",
|
||||
(sid, et),
|
||||
)
|
||||
for it in items:
|
||||
code = str(it.get("code") or "").strip()
|
||||
if not code:
|
||||
continue
|
||||
name = str(it.get("name") or code)[:100]
|
||||
try:
|
||||
price = float(it.get("price") or 0)
|
||||
except (TypeError, ValueError):
|
||||
price = 0.0
|
||||
db.conn.execute(
|
||||
"""
|
||||
INSERT INTO ls_candidates_history
|
||||
(slot_key, scan_time, code, name, score, price,
|
||||
market, sector, theme, strategy_id, event_time,
|
||||
query_name, query_index, source)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
""",
|
||||
(
|
||||
sk, et, code, name, 0.0, price,
|
||||
"Q", "", "", sid, et, qn, qi, source[:16],
|
||||
),
|
||||
)
|
||||
inserted += 1
|
||||
return inserted
|
||||
|
||||
|
||||
class _LsConditionState:
|
||||
"""AFR 편입/이탈 누적 (수집기 ConditionState 와 동일)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
strategy_id: str,
|
||||
query_name: str,
|
||||
query_index: str,
|
||||
alert_num: str = "",
|
||||
) -> None:
|
||||
self.strategy_id = strategy_id
|
||||
self.query_name = query_name
|
||||
self.query_index = query_index
|
||||
self.alert_num = alert_num
|
||||
self.codes: Dict[str, Dict[str, Any]] = {}
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def set_from_snapshot(self, rows: List[Dict[str, Any]]) -> None:
|
||||
with self.lock:
|
||||
self.codes.clear()
|
||||
for r in rows:
|
||||
code = str(r.get("shcode") or r.get("code") or "").strip()
|
||||
if not code:
|
||||
continue
|
||||
self.codes[code] = {
|
||||
"code": code,
|
||||
"name": str(r.get("hname") or r.get("name") or code)[:100],
|
||||
"price": r.get("price") or 0,
|
||||
}
|
||||
|
||||
def apply_afr(self, body: Dict[str, Any]) -> tuple[str, Optional[str]]:
|
||||
job = str(body.get("gsJobFlag") or "").strip().upper()
|
||||
code = str(body.get("gsCode") or body.get("shcode") or "").strip()
|
||||
if not code:
|
||||
return job, None
|
||||
name = str(body.get("gsHname") or body.get("hname") or code)[:100]
|
||||
try:
|
||||
price = float(str(body.get("gsPrice") or body.get("price") or 0).replace(",", ""))
|
||||
except (TypeError, ValueError):
|
||||
price = 0.0
|
||||
with self.lock:
|
||||
if job in ("N", "R"):
|
||||
self.codes[code] = {"code": code, "name": name, "price": price}
|
||||
elif job == "O":
|
||||
self.codes.pop(code, None)
|
||||
return job, code
|
||||
|
||||
def items(self) -> List[Dict[str, Any]]:
|
||||
with self.lock:
|
||||
return [dict(v) for v in self.codes.values()]
|
||||
|
||||
def as_rows(self) -> List[Dict[str, Any]]:
|
||||
return [{"code": it["code"], "name": it.get("name", it["code"])} for it in self.items()]
|
||||
|
||||
|
||||
class LsConditionSearchManager(ConditionSearchManager):
|
||||
"""LS AFR 실시간 조건검색 — ConditionSearchManager 와 동일 public API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
configs: Optional[List[Dict]] = None,
|
||||
db=None,
|
||||
on_change=None,
|
||||
user_id: str = "",
|
||||
use_mock: bool = False,
|
||||
):
|
||||
super().__init__(
|
||||
client=None,
|
||||
user_id=(user_id or "LS").strip() or "LS",
|
||||
configs=configs,
|
||||
db=db,
|
||||
on_change=on_change,
|
||||
)
|
||||
self._use_mock = bool(use_mock)
|
||||
self._token: Optional[str] = None
|
||||
self._app_key: str = ""
|
||||
self._app_secret: str = ""
|
||||
self._rt = None
|
||||
self._by_alert: Dict[str, _LsConditionState] = {}
|
||||
self._states_by_sid: Dict[str, _LsConditionState] = {}
|
||||
self._watcher: Any = None
|
||||
self._flush_sec = float(get_env_int("LS_CONDITION_FLUSH_SEC", 60))
|
||||
# 서버 조건 CRUD 후 query_index/alert 재부착 (재시작 대체)
|
||||
self._remap_sec = float(get_env_int("LS_CONDITION_REMAP_SEC", 60))
|
||||
# AFR 델타만 보정 — 동일 인덱스라도 t1859 로 RAM sticky 동기화
|
||||
self._snapshot_refresh_sec = float(
|
||||
get_env_int("LS_CONDITION_SNAPSHOT_REFRESH_SEC", 120)
|
||||
)
|
||||
self._tr_gap_sec = float(get_env_float("LS_CONDITION_TR_GAP_SEC", 1.1))
|
||||
self._flush_thread: Optional[threading.Thread] = None
|
||||
self._maint_lock = threading.RLock()
|
||||
self._last_remap_mono = 0.0
|
||||
self._last_snap_refresh_mono = 0.0
|
||||
self._last_missing_warn: Dict[str, float] = {}
|
||||
self._ready = threading.Event()
|
||||
self._start_ok = False
|
||||
# LS 조건 유니버스 합집합 → LS WS sync 등 (main 이 등록)
|
||||
self._universe_codes_listener: Optional[Callable[[Set[str]], None]] = None
|
||||
# 이력: LS_CONDITION_HISTORY_ENABLED 우선, 없으면 기존 CONDITION/UNIVERSE 저장 토글
|
||||
self.history_enabled = get_env_bool(
|
||||
"LS_CONDITION_HISTORY_ENABLED",
|
||||
get_env_bool(
|
||||
"CONDITION_HISTORY_SAVE",
|
||||
get_env_bool("UNIVERSE_HISTORY_SAVE", True),
|
||||
),
|
||||
)
|
||||
|
||||
def set_universe_codes_listener(
|
||||
self, fn: Optional[Callable[[Set[str]], None]],
|
||||
) -> None:
|
||||
"""전 전략 LS 유니버스 코드 합집합이 바뀔 때 호출 (WS condition 구독 diff)."""
|
||||
self._universe_codes_listener = fn
|
||||
|
||||
def universe_code_union(self, *, plain6_only: Optional[bool] = None) -> Set[str]:
|
||||
"""RAM 상 전 전략 조건 종목 합집합."""
|
||||
if plain6_only is None:
|
||||
plain6_only = get_env_bool("LS_WS_CONDITION_PLAIN6_ONLY", True)
|
||||
out: Set[str] = set()
|
||||
for st in list(self._states_by_sid.values()):
|
||||
for it in st.items():
|
||||
code = str(it.get("code") or "").strip()
|
||||
if not code:
|
||||
continue
|
||||
if plain6_only and not (code.isdigit() and len(code) == 6):
|
||||
continue
|
||||
out.add(code)
|
||||
return out
|
||||
|
||||
def emit_universe_union(self) -> None:
|
||||
fn = self._universe_codes_listener
|
||||
if fn is None:
|
||||
return
|
||||
try:
|
||||
fn(self.universe_code_union())
|
||||
except Exception as e:
|
||||
logger.debug("LS universe listener: %s", e)
|
||||
|
||||
def start(self) -> bool:
|
||||
if not self._configs:
|
||||
logger.info("LS 조건검색 configs 비어 있음 → 매니저 비활성")
|
||||
return False
|
||||
if not get_env_bool("LS_CONDITION_MANAGER_ENABLED", True):
|
||||
logger.info("LS_CONDITION_MANAGER_ENABLED=false → 매니저 비활성")
|
||||
return False
|
||||
|
||||
try:
|
||||
self._rt = _load_ls_rt()
|
||||
except Exception as e:
|
||||
logger.warning("LS 조건 헬퍼 로드 실패: %s", e)
|
||||
return False
|
||||
|
||||
user_id = (self.user_id or "").strip()
|
||||
if user_id in ("", "LS"):
|
||||
user_id = (get_env_from_db("LS_USER_ID", "") or "").strip()
|
||||
if not user_id:
|
||||
logger.warning("LS_USER_ID 미설정 → LS 조건검색 매니저 비활성")
|
||||
return False
|
||||
self.user_id = user_id
|
||||
|
||||
try:
|
||||
app_key, app_secret = self._rt.load_ls_creds(use_mock=self._use_mock)
|
||||
except Exception as e:
|
||||
logger.warning("LS 앱키 로드 실패: %s", e)
|
||||
return False
|
||||
if not (app_key and app_secret):
|
||||
logger.warning("LS 앱키 없음 → 조건검색 매니저 비활성")
|
||||
return False
|
||||
self._app_key = app_key
|
||||
self._app_secret = app_secret
|
||||
|
||||
if not self._ensure_token(force=True):
|
||||
return False
|
||||
|
||||
logger.warning(
|
||||
"⚠️ LS 조건 매니저 ON — 단독 collect_ls_condition_history 와 "
|
||||
"ls_candidates_history 이중 기록 가능. 수집기는 중지 권고."
|
||||
)
|
||||
|
||||
# by_alert 는 watcher 와 동일 dict 참조 — rematch 시 in-place 갱신
|
||||
self._by_alert = {}
|
||||
self._states_by_sid = {}
|
||||
self._running = True
|
||||
|
||||
# 초기 매핑 (실패해도 rematch 대기 모드로 기동 — 재시작 없이 조건 복구)
|
||||
try:
|
||||
self._remap_all(force_snapshot=True, force_afr=True)
|
||||
except Exception as e:
|
||||
logger.error("LS 초기 rematch 실패: %s", e)
|
||||
|
||||
# AfrHistoryWatcher: 빈 by_alert 로도 기동 → 이후 rematch 가 REG
|
||||
if not self._start_afr_watcher():
|
||||
# WS 실패여도 t1859 스냅샷·rematch 는 유지 (유니버스 sticky)
|
||||
logger.warning(
|
||||
"⚠️ LS AFR WS 미기동 → 스냅샷/rematch 전용 "
|
||||
"(실시간 편입·이탈 없음). rematch 로 재시도."
|
||||
)
|
||||
|
||||
self._flush_thread = threading.Thread(
|
||||
target=self._maint_loop, daemon=True, name="LsCondMaint",
|
||||
)
|
||||
self._flush_thread.start()
|
||||
self._start_ok = True
|
||||
self._ready.set()
|
||||
n_map = len(self._states_by_sid)
|
||||
n_afr = len(self._by_alert)
|
||||
if n_map <= 0:
|
||||
logger.warning(
|
||||
"⚠️ LS 조건 매핑 0건 → rematch 대기 "
|
||||
"(remap=%ss snap=%ss). 서버에 조건명 생기면 자동 부착.",
|
||||
int(self._remap_sec), int(self._snapshot_refresh_sec),
|
||||
)
|
||||
elif n_afr <= 0:
|
||||
logger.warning(
|
||||
"⚠️ LS AFR alert 0건 (매핑 %d) → 스냅샷/rematch 전용. "
|
||||
"장중·조건복구 시 자동 AFR 재등록.",
|
||||
n_map,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"✅ LS 조건검색 매니저 시작 (매핑=%d AFR=%d flush=%ss remap=%ss snap=%ss)",
|
||||
n_map, n_afr,
|
||||
int(self._flush_sec), int(self._remap_sec),
|
||||
int(self._snapshot_refresh_sec),
|
||||
)
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
with self._maint_lock:
|
||||
for st in list(self._states_by_sid.values()):
|
||||
try:
|
||||
self._teardown_afr(st, clear_ram=False)
|
||||
except Exception:
|
||||
pass
|
||||
w = self._watcher
|
||||
self._watcher = None
|
||||
if w is not None:
|
||||
try:
|
||||
w.stop()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("⏹ LS 조건검색 매니저 정지")
|
||||
|
||||
def _tr_sleep(self) -> None:
|
||||
time.sleep(max(0.2, self._tr_gap_sec))
|
||||
|
||||
def _desired_bindings(self) -> List[Tuple[str, str]]:
|
||||
"""configs → (strategy_id, query_name) 목록."""
|
||||
out: List[Tuple[str, str]] = []
|
||||
for cfg in self._configs or []:
|
||||
sid = str(cfg.get("strategy_id") or "").strip().upper()
|
||||
nm = (
|
||||
str(cfg.get("name") or "").strip()
|
||||
or DEFAULT_SID_TO_LS_NAME.get(sid, "")
|
||||
)
|
||||
if not sid or not nm:
|
||||
logger.warning("LS 조건 설정 스킵 (sid/name 부족): %s", cfg)
|
||||
continue
|
||||
out.append((sid, nm))
|
||||
return out
|
||||
|
||||
def _ensure_token(self, *, force: bool = False) -> bool:
|
||||
if self._token and not force:
|
||||
return True
|
||||
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)
|
||||
except Exception as e:
|
||||
logger.warning("LS 토큰 발급 실패: %s", e)
|
||||
return False
|
||||
if not tok:
|
||||
logger.warning("LS 토큰 빈값")
|
||||
return False
|
||||
self._token = str(tok)
|
||||
w = self._watcher
|
||||
if w is not None:
|
||||
try:
|
||||
w.token = self._token
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
def _start_afr_watcher(self) -> bool:
|
||||
if self._watcher is not None:
|
||||
return True
|
||||
if not self._token or self._rt is None:
|
||||
return False
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
_mod_path = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "scripts"
|
||||
/ "collect_ls_condition_history.py"
|
||||
)
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"collect_ls_condition_history_runtime", _mod_path,
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"spec 실패: {_mod_path}")
|
||||
col = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(col)
|
||||
AfrHistoryWatcher = col.AfrHistoryWatcher
|
||||
except Exception as e:
|
||||
logger.error("AfrHistoryWatcher import 실패: %s", e)
|
||||
return False
|
||||
|
||||
ws_url = self._rt.LS_WS_MOCK if self._use_mock else self._rt.LS_WS_REAL
|
||||
|
||||
def on_change(st: Any) -> None:
|
||||
try:
|
||||
self._apply_result(st.strategy_id, st.as_rows())
|
||||
self._save_ls_snapshot(st, source="ls_afr")
|
||||
except Exception as e:
|
||||
logger.warning("LS on_change: %s", e)
|
||||
|
||||
self._watcher = AfrHistoryWatcher(
|
||||
self._token,
|
||||
ws_url,
|
||||
logger,
|
||||
by_alert=self._by_alert,
|
||||
on_change=on_change,
|
||||
also_tick_codes=None,
|
||||
)
|
||||
self._watcher.start()
|
||||
if not self._watcher.wait_open(20.0):
|
||||
logger.error("LS AFR WS OPEN 타임아웃")
|
||||
try:
|
||||
self._watcher.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._watcher = None
|
||||
return False
|
||||
# on_open 이 by_alert 전량 REG — 여기서 중복 REG 하지 않음
|
||||
return True
|
||||
|
||||
def _warn_missing(self, sid: str, name: str) -> None:
|
||||
now = time.monotonic()
|
||||
last = float(self._last_missing_warn.get(sid) or 0.0)
|
||||
# 동일 sid 미매칭은 5분마다 1회 (로그 폭주 방지)
|
||||
gap = float(get_env_int("LS_CONDITION_MISSING_WARN_SEC", 300))
|
||||
if now - last < max(30.0, gap):
|
||||
return
|
||||
self._last_missing_warn[sid] = now
|
||||
logger.warning(
|
||||
"⚠️ LS 조건명 미매칭 sid=%s name=%r — 서버 목록에 없음. "
|
||||
"HTS에서 생성·이름복구 시 rematch(%ss)로 자동 부착",
|
||||
sid, name, int(self._remap_sec),
|
||||
)
|
||||
|
||||
def _teardown_afr(self, st: _LsConditionState, *, clear_ram: bool) -> None:
|
||||
"""구 alert UNREG + t1860 D. clear_ram 시 유니버스 비움."""
|
||||
alert = str(st.alert_num or "").strip()
|
||||
qidx = str(st.query_index or "").strip()
|
||||
w = self._watcher
|
||||
if alert and w is not None:
|
||||
try:
|
||||
w.reg_afr(alert, tr_type="4")
|
||||
except Exception as e:
|
||||
logger.debug("AFR UNREG %s: %s", alert, e)
|
||||
self._by_alert.pop(alert, None)
|
||||
if alert and qidx and self._token and self._rt:
|
||||
try:
|
||||
self._rt.t1860_realtime(
|
||||
self._token, qidx, flag="D", alert_num=alert, logger=logger,
|
||||
)
|
||||
self._tr_sleep()
|
||||
except Exception as e:
|
||||
logger.debug("t1860 D %s: %s", st.strategy_id, e)
|
||||
st.alert_num = ""
|
||||
if clear_ram:
|
||||
st.set_from_snapshot([])
|
||||
self._apply_result(st.strategy_id, [])
|
||||
self._save_ls_snapshot(st, source="ls_remap_clear")
|
||||
|
||||
def _mount_snapshot_and_afr(
|
||||
self,
|
||||
st: _LsConditionState,
|
||||
*,
|
||||
do_snapshot: bool,
|
||||
do_afr: bool,
|
||||
) -> None:
|
||||
"""t1859 → RAM, (옵션) t1860 E + AFR REG."""
|
||||
if not self._token or self._rt is None:
|
||||
return
|
||||
if do_snapshot:
|
||||
try:
|
||||
snap = self._rt.t1859_snapshot(
|
||||
self._token, st.query_index, logger=logger,
|
||||
)
|
||||
self._tr_sleep()
|
||||
except Exception as e:
|
||||
logger.warning("t1859 실패 %s: %s", st.strategy_id, e)
|
||||
snap = []
|
||||
st.set_from_snapshot(snap or [])
|
||||
self._apply_result(st.strategy_id, st.as_rows())
|
||||
self._save_ls_snapshot(st, source="ls_t1859")
|
||||
n = len(st.as_rows())
|
||||
if n <= 0:
|
||||
logger.warning(
|
||||
"⚠️ LS 스냅샷 0종목 sid=%s name=%s idx=%s "
|
||||
"(AFR 델타만으로는 장중 sticky 불가 → 주기 t1859 대기)",
|
||||
st.strategy_id, st.query_name, st.query_index,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"📌 LS 스냅샷 sid=%s name=%s %d종목",
|
||||
st.strategy_id, st.query_name, n,
|
||||
)
|
||||
|
||||
if not do_afr:
|
||||
return
|
||||
|
||||
# 기존 alert 있으면 먼저 정리
|
||||
if str(st.alert_num or "").strip():
|
||||
self._teardown_afr(st, clear_ram=False)
|
||||
|
||||
try:
|
||||
ob = self._rt.t1860_realtime(
|
||||
self._token, st.query_index, flag="E", alert_num="", logger=logger,
|
||||
)
|
||||
self._tr_sleep()
|
||||
except Exception as e:
|
||||
logger.error("t1860 예외 %s: %s", st.strategy_id, e)
|
||||
return
|
||||
if str(ob.get("sResultFlag") or "").strip() != "S":
|
||||
logger.error(
|
||||
"t1860 실패 sid=%s %s",
|
||||
st.strategy_id, json.dumps(ob, ensure_ascii=False),
|
||||
)
|
||||
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,
|
||||
)
|
||||
return
|
||||
st.alert_num = alert
|
||||
self._by_alert[alert] = st
|
||||
logger.info(
|
||||
"✅ LS AFR 등록 sid=%s name=%s alert=%s idx=%s",
|
||||
st.strategy_id, st.query_name, alert, st.query_index,
|
||||
)
|
||||
# watcher 신규 기동 시 on_open 이 by_alert REG — 중복 전송 방지
|
||||
started_fresh = self._watcher is None
|
||||
if started_fresh:
|
||||
self._start_afr_watcher()
|
||||
w = self._watcher
|
||||
if w is not None and not started_fresh:
|
||||
try:
|
||||
w.reg_afr(alert, tr_type="3")
|
||||
except Exception as e:
|
||||
logger.warning("AFR REG 실패 %s: %s", alert, e)
|
||||
|
||||
def _remap_all(
|
||||
self,
|
||||
*,
|
||||
force_snapshot: bool = False,
|
||||
force_afr: bool = False,
|
||||
) -> None:
|
||||
"""t1866 이름→index 재매핑. 변경 시 AFR 재부착 / 신규 부착 / 소실 시 해제."""
|
||||
with self._maint_lock:
|
||||
if not self._ensure_token(force=False):
|
||||
return
|
||||
assert self._rt is not None and self._token
|
||||
try:
|
||||
rows = self._rt.t1866_list_conditions(
|
||||
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:
|
||||
return
|
||||
else:
|
||||
logger.error("t1866 실패: %s", e)
|
||||
return
|
||||
|
||||
wanted = self._desired_bindings()
|
||||
seen_sid: Set[str] = set()
|
||||
for sid, nm in wanted:
|
||||
seen_sid.add(sid)
|
||||
hit = self._rt._resolve_query(rows, name=nm, query_index="")
|
||||
if not hit or not hit.get("query_index"):
|
||||
self._warn_missing(sid, nm)
|
||||
st_old = self._states_by_sid.get(sid)
|
||||
if st_old is not None:
|
||||
logger.warning(
|
||||
"🔄 LS 조건 소실 → 해제 sid=%s was=%s/%s",
|
||||
sid, st_old.query_name, st_old.query_index,
|
||||
)
|
||||
self._teardown_afr(st_old, clear_ram=True)
|
||||
self._states_by_sid.pop(sid, None)
|
||||
continue
|
||||
|
||||
qidx = str(hit["query_index"])
|
||||
qname = str(hit.get("query_name") or nm)
|
||||
st = self._states_by_sid.get(sid)
|
||||
if st is None:
|
||||
st = _LsConditionState(
|
||||
strategy_id=sid,
|
||||
query_name=qname,
|
||||
query_index=qidx,
|
||||
)
|
||||
self._states_by_sid[sid] = st
|
||||
logger.info(
|
||||
"📌 LS 신규 매핑 sid=%s name=%s query_index=%s",
|
||||
sid, qname, qidx,
|
||||
)
|
||||
self._mount_snapshot_and_afr(
|
||||
st, do_snapshot=True, do_afr=True,
|
||||
)
|
||||
continue
|
||||
|
||||
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())
|
||||
if idx_changed or name_changed:
|
||||
logger.warning(
|
||||
"🔄 LS rematch sid=%s %s/%s → %s/%s",
|
||||
sid, st.query_name, st.query_index, qname, qidx,
|
||||
)
|
||||
self._teardown_afr(st, clear_ram=False)
|
||||
st.query_name = qname
|
||||
st.query_index = qidx
|
||||
self._mount_snapshot_and_afr(
|
||||
st, do_snapshot=True, do_afr=True,
|
||||
)
|
||||
elif need_afr:
|
||||
logger.info(
|
||||
"🔄 LS AFR 재등록 sid=%s name=%s (alert 없음/강제)",
|
||||
sid, st.query_name,
|
||||
)
|
||||
self._mount_snapshot_and_afr(
|
||||
st, do_snapshot=True, do_afr=True,
|
||||
)
|
||||
elif force_snapshot:
|
||||
self._mount_snapshot_and_afr(
|
||||
st, do_snapshot=True, do_afr=False,
|
||||
)
|
||||
|
||||
# configs 에 없는 sid 정리 (설정 제거된 경우)
|
||||
for sid in list(self._states_by_sid.keys()):
|
||||
if sid in seen_sid:
|
||||
continue
|
||||
st = self._states_by_sid.pop(sid)
|
||||
logger.warning("🔄 LS 설정 제거 → 해제 sid=%s", sid)
|
||||
self._teardown_afr(st, clear_ram=True)
|
||||
|
||||
self._last_remap_mono = time.monotonic()
|
||||
if force_snapshot:
|
||||
self._last_snap_refresh_mono = self._last_remap_mono
|
||||
|
||||
def _refresh_snapshots_only(self) -> None:
|
||||
"""인덱스 유지한 채 t1859 만 재동기화 (AFR sticky 보정)."""
|
||||
with self._maint_lock:
|
||||
if not self._ensure_token(force=False):
|
||||
return
|
||||
for st in list(self._states_by_sid.values()):
|
||||
if not str(st.query_index or "").strip():
|
||||
continue
|
||||
try:
|
||||
self._mount_snapshot_and_afr(
|
||||
st, do_snapshot=True, do_afr=False,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"LS snapshot refresh 실패 %s: %s", st.strategy_id, e,
|
||||
)
|
||||
self._last_snap_refresh_mono = time.monotonic()
|
||||
|
||||
def _maint_loop(self) -> None:
|
||||
"""flush + rematch + t1859 sticky 주기 루프."""
|
||||
# 기동 직후 즉시 rematch 하지 않음 (start 에서 1회 완료)
|
||||
now0 = time.monotonic()
|
||||
self._last_remap_mono = now0
|
||||
self._last_snap_refresh_mono = now0
|
||||
last_flush_mono = now0
|
||||
while self._running:
|
||||
tick = min(
|
||||
max(5.0, self._flush_sec),
|
||||
max(5.0, self._remap_sec),
|
||||
max(5.0, self._snapshot_refresh_sec),
|
||||
)
|
||||
time.sleep(tick)
|
||||
if not self._running:
|
||||
return
|
||||
now = time.monotonic()
|
||||
try:
|
||||
if now - self._last_remap_mono >= max(5.0, self._remap_sec):
|
||||
self._remap_all(force_snapshot=False, force_afr=False)
|
||||
now = time.monotonic()
|
||||
if (
|
||||
now - self._last_snap_refresh_mono
|
||||
>= max(5.0, self._snapshot_refresh_sec)
|
||||
):
|
||||
self._refresh_snapshots_only()
|
||||
now = time.monotonic()
|
||||
if now - last_flush_mono >= max(5.0, self._flush_sec):
|
||||
for st in list(self._states_by_sid.values()):
|
||||
try:
|
||||
self._save_ls_snapshot(st, source="ls_flush")
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"LS flush 실패 %s: %s", st.strategy_id, e,
|
||||
)
|
||||
last_flush_mono = time.monotonic()
|
||||
except Exception as e:
|
||||
logger.warning("LS maint 루프 예외: %s", e)
|
||||
|
||||
def _save_snapshot(
|
||||
self,
|
||||
strategy_id: str,
|
||||
codes_ordered: List[str],
|
||||
names: Dict[str, str],
|
||||
) -> None:
|
||||
"""부모는 target_candidates_history — LS 는 override 로 no-op 후 별도 저장."""
|
||||
# _apply_result 가 호출하는 부모 경로를 차단 (키움 테이블 오염 금지)
|
||||
return
|
||||
|
||||
def _save_ls_snapshot(self, st: _LsConditionState, *, source: str) -> None:
|
||||
if self.history_enabled and self.db is not None:
|
||||
n = insert_ls_candidates_snapshot(
|
||||
self.db,
|
||||
strategy_id=st.strategy_id,
|
||||
query_name=st.query_name,
|
||||
query_index=st.query_index,
|
||||
items=st.items(),
|
||||
source=source,
|
||||
)
|
||||
logger.debug(
|
||||
"📼 [ls_history] %s src=%s %d종목",
|
||||
st.strategy_id, source, n,
|
||||
)
|
||||
# 이력 OFF 여도 WS follow 를 위해 합집합 emit
|
||||
self.emit_universe_union()
|
||||
168
kis_trader/network/ls_ws_validator.py
Normal file
168
kis_trader/network/ls_ws_validator.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
kis_trader/network/ls_ws_validator.py — KIS/키움 ↔ LS 시세 갭 검증
|
||||
================================================================
|
||||
``LS_WS_VALIDATION_ENABLED=true`` 일 때만 기동.
|
||||
실매 시세 경로는 건드리지 않음 — 구독 동기화 + DB 비교 INSERT 만.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional, Set
|
||||
|
||||
from ..utils.env import get_env_float, get_env_int
|
||||
from ..utils.logger import get_logger
|
||||
|
||||
logger = get_logger("kis_trader.ls_ws_validator")
|
||||
|
||||
|
||||
class LSWSPriceValidator:
|
||||
"""KIS(+키움) 구독 종목을 LS 에도 맞추고 가격 갭을 기록."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
ls_ws,
|
||||
db,
|
||||
kis_ws=None,
|
||||
kiwoom_ws=None,
|
||||
) -> None:
|
||||
self.ls_ws = ls_ws
|
||||
self.db = db
|
||||
self.kis_ws = kis_ws
|
||||
self.kiwoom_ws = kiwoom_ws
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._running = False
|
||||
self._last_warn_ts: dict = {}
|
||||
|
||||
def start(self) -> bool:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return True
|
||||
self._running = True
|
||||
self._thread = threading.Thread(
|
||||
target=self._loop, daemon=True, name="LSWSPriceValidator",
|
||||
)
|
||||
self._thread.start()
|
||||
logger.info(
|
||||
"✅ LS WS 갭 검증기 시작 (interval=%ds warn≥%.2f%%)",
|
||||
self._interval_sec(),
|
||||
self._warn_pct(),
|
||||
)
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
|
||||
def _interval_sec(self) -> int:
|
||||
return max(1, get_env_int("LS_WS_VALIDATION_INTERVAL_SEC", 5))
|
||||
|
||||
def _warn_pct(self) -> float:
|
||||
return max(0.0, get_env_float("LS_WS_VALIDATION_DIFF_WARN_PCT", 0.10))
|
||||
|
||||
def _codes_from(self, ws) -> Set[str]:
|
||||
if ws is None:
|
||||
return set()
|
||||
try:
|
||||
with ws._sub_lock: # type: ignore[attr-defined]
|
||||
return set(ws._subscribed) # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
def _sync_ls(self, target: Set[str]) -> None:
|
||||
try:
|
||||
with self.ls_ws._sub_lock:
|
||||
current = set(self.ls_ws._subscribed)
|
||||
# KR 6자리만 동기화 (US 는 영구구독에서 별도 붙일 수 있음)
|
||||
kr_target = {c for c in target if c.isdigit() and len(c) == 6}
|
||||
for code in kr_target - current:
|
||||
self.ls_ws.subscribe(code)
|
||||
for code in current - kr_target:
|
||||
self.ls_ws.unsubscribe(code)
|
||||
except Exception as e:
|
||||
logger.debug("LS 구독 동기화 실패: %s", e)
|
||||
|
||||
def _parse_price(self, data, key: str = "stck_prpr") -> Optional[float]:
|
||||
if not data:
|
||||
return None
|
||||
raw = data.get(key)
|
||||
if raw is None and "_price_f" in data:
|
||||
raw = data.get("_price_f")
|
||||
try:
|
||||
v = float(str(raw).replace(",", ""))
|
||||
return v if v > 0 else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _parse_age_ms(self, data) -> Optional[int]:
|
||||
if not data:
|
||||
return None
|
||||
try:
|
||||
return int(data.get("_age_ms"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def _loop(self) -> None:
|
||||
time.sleep(15)
|
||||
while self._running:
|
||||
try:
|
||||
self._tick()
|
||||
except Exception as e:
|
||||
logger.warning("LS 검증기 tick 예외: %s", e)
|
||||
time.sleep(self._interval_sec())
|
||||
|
||||
def _tick(self) -> None:
|
||||
kis_codes = self._codes_from(self.kis_ws)
|
||||
kw_codes = self._codes_from(self.kiwoom_ws)
|
||||
codes = kis_codes | kw_codes
|
||||
if not codes:
|
||||
return
|
||||
self._sync_ls(codes)
|
||||
if not self.ls_ws.is_connected():
|
||||
return
|
||||
|
||||
warn_pct = self._warn_pct()
|
||||
n = 0
|
||||
for code in codes:
|
||||
if not (code.isdigit() and len(code) == 6):
|
||||
continue
|
||||
kis_data = (
|
||||
self.kis_ws.get_price(code, max_age_sec=10.0)
|
||||
if self.kis_ws is not None
|
||||
else None
|
||||
)
|
||||
kw_data = (
|
||||
self.kiwoom_ws.get_price(code, max_age_sec=10.0)
|
||||
if self.kiwoom_ws is not None and self.kiwoom_ws.is_connected()
|
||||
else None
|
||||
)
|
||||
ls_data = self.ls_ws.get_price(code, max_age_sec=10.0)
|
||||
|
||||
kis_p = self._parse_price(kis_data)
|
||||
kw_p = self._parse_price(kw_data)
|
||||
ls_p = self._parse_price(ls_data)
|
||||
if ls_p is None and kis_p is None and kw_p is None:
|
||||
continue
|
||||
|
||||
self.db.insert_ws_price_validation_ls(
|
||||
code=code,
|
||||
kis_price=kis_p,
|
||||
kiwoom_price=kw_p,
|
||||
ls_price=ls_p,
|
||||
kis_age_ms=self._parse_age_ms(kis_data),
|
||||
kiwoom_age_ms=self._parse_age_ms(kw_data),
|
||||
ls_age_ms=self._parse_age_ms(ls_data),
|
||||
)
|
||||
n += 1
|
||||
|
||||
if kis_p not in (None, 0) and ls_p is not None:
|
||||
diff = (ls_p - kis_p) / kis_p * 100.0
|
||||
if abs(diff) >= warn_pct:
|
||||
now = time.time()
|
||||
if now - self._last_warn_ts.get(code, 0) >= 60:
|
||||
self._last_warn_ts[code] = now
|
||||
logger.warning(
|
||||
"⚠️ [LS 갭] %s KIS↔LS %.3f%% (KIS=%.0f LS=%.0f)",
|
||||
code, diff, kis_p, ls_p,
|
||||
)
|
||||
if n > 0:
|
||||
logger.debug("LS 갭 검증 샘플 %d건", n)
|
||||
@@ -115,6 +115,8 @@ class WSManager:
|
||||
self._lock = threading.Lock()
|
||||
# 갭보정 WS 재접속 시: split 모드면 KIS∪키움 관심 종목 전체
|
||||
self._gap_refill_codes: Set[str] = set()
|
||||
# BaseStrategy 틱매도 등 — 시세 캐시 갱신 리스너
|
||||
self._price_listeners: list = []
|
||||
|
||||
# ── 갭보정 비동기 파이프라인 ─────────────────────────────
|
||||
# (전략 쓰레드에서 subscribe() 시 동기 REST 호출하면 매수 체크가
|
||||
@@ -126,12 +128,19 @@ class WSManager:
|
||||
self._gap_inflight: Set[str] = set() # 큐에 등록/처리 중인 코드
|
||||
self._gap_retry_count: Dict[str, int] = {} # TF 실패 시 재시도 카운터
|
||||
self._gap_tf_ok: Dict[str, Set[int]] = {} # 종목별 성공한 TF (재시도 시 스킵)
|
||||
# 최대 재시도 초과 후 force 재큐 차단 (전략 check_buy 매초 fill_gap(force) 폭주 방지)
|
||||
self._gap_give_up_until: Dict[str, float] = {}
|
||||
self._gap_empty_log_ts: Dict[str, float] = {} # 빈응답 로그 스로틀
|
||||
self._gap_empty_hit_ts: List[float] = [] # 전역 빈응답 회로차단용
|
||||
self._gap_empty_circuit_until: float = 0.0
|
||||
self._gap_lock = threading.Lock()
|
||||
self._gap_worker_threads: List[threading.Thread] = []
|
||||
self._gap_worker_boot_logged = False
|
||||
# 전체 재갭보정(재접속 시) 중복 트리거 방지
|
||||
self._bulk_refill_running = False
|
||||
self._bulk_refill_last_ts: float = 0.0
|
||||
# 평일 장시작 1회 — 장외 거짓완료 마커 클리어 + bulk refill (YYYYMMDD)
|
||||
self._gap_session_day: Optional[str] = None
|
||||
# 키움 ka10001 유통/상장주식수 — 전략 공통 (stock_share_meta DB 동기)
|
||||
self._share_cache: Dict[str, Dict[str, int]] = {}
|
||||
self._share_q: "queue.Queue[str]" = queue.Queue(maxsize=1024)
|
||||
@@ -139,6 +148,17 @@ class WSManager:
|
||||
self._share_lock = threading.Lock()
|
||||
self._share_worker_thread: Optional[threading.Thread] = None
|
||||
|
||||
# ── ls_condition 전략 시세 라우팅 (키움/KIS 와 분리) ─────────────
|
||||
# owner → 해당 전략의 LS 피드 코드 (후보∪보유)
|
||||
self._ls_feed_owners: Dict[str, Set[str]] = defaultdict(set)
|
||||
self._ls_gap_q: "queue.Queue[str]" = queue.Queue(maxsize=512)
|
||||
self._ls_gap_filled: Set[str] = set()
|
||||
self._ls_gap_inflight: Set[str] = set()
|
||||
self._ls_gap_fail: Dict[str, int] = {}
|
||||
self._ls_gap_lock = threading.Lock()
|
||||
self._ls_gap_worker_threads: List[threading.Thread] = []
|
||||
self._ls_ws_missing_warned: bool = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 시작/종료
|
||||
# ------------------------------------------------------------------
|
||||
@@ -206,6 +226,7 @@ class WSManager:
|
||||
self._load_share_cache_from_db()
|
||||
self._start_share_meta_worker()
|
||||
self._start_gap_worker()
|
||||
self._start_ls_gap_worker()
|
||||
|
||||
# 영구 구독 (KOSPI/KOSDAQ ETF 등)
|
||||
self._load_permanent_codes()
|
||||
@@ -218,11 +239,12 @@ class WSManager:
|
||||
|
||||
# 연결 성공 후 자동 갭 보정 등록 (WS 재접속 시 전체 재갭보정)
|
||||
self.ws_cache.set_on_connected_callback(self._trigger_bulk_refill_async)
|
||||
self._reattach_all_price_listeners()
|
||||
|
||||
logger.info(
|
||||
"✅ WSManager 활성 (tfs=%s, permanent=%d, gap_workers=%d)",
|
||||
tfs, len(self._permanent_codes),
|
||||
max(1, min(get_env_int("WS_GAP_FILL_WORKERS", 4), 4)),
|
||||
max(1, min(get_env_int("WS_GAP_FILL_WORKERS", 2), 4)),
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -248,6 +270,7 @@ class WSManager:
|
||||
def set_kiwoom_ws(self, kiwoom_ws: Any) -> None:
|
||||
"""키움 WS 인스턴스 (기동 후 주입). ``activate_split_feed`` 전에 설정."""
|
||||
self._kiwoom_ws = kiwoom_ws
|
||||
self._reattach_all_price_listeners()
|
||||
|
||||
def activate_split_feed(self, active: bool) -> None:
|
||||
"""``WS_SUBSCRIBE_KIS_MINIMAL`` + 키움 준비 완료 후 True → 후보 구독을 키움으로."""
|
||||
@@ -260,23 +283,178 @@ class WSManager:
|
||||
owner: str,
|
||||
candidates: Iterable[str],
|
||||
holdings: Iterable[str],
|
||||
*,
|
||||
ls_feed: bool = False,
|
||||
) -> None:
|
||||
"""전략별 후보/보유를 분리 반영. ``WS_SUBSCRIBE_KIS_MINIMAL`` 아니면 레거시와 동일."""
|
||||
"""전략별 후보/보유를 분리 반영. ``WS_SUBSCRIBE_KIS_MINIMAL`` 아니면 레거시와 동일.
|
||||
|
||||
ls_feed=True (``UNIVERSE_SOURCE=ls_condition``):
|
||||
- LS US3 구독 → 틱·현재가 (ls_ws_ticks 와 동일 파이프)
|
||||
- 키움 구독·갭보정·분봉은 **그대로** (pure_ls 제외 안 함)
|
||||
"""
|
||||
cand = {str(c).strip() for c in candidates if c}
|
||||
hold = {str(h).strip() for h in holdings if h}
|
||||
with self._lock:
|
||||
if ls_feed:
|
||||
self._ls_feed_owners[owner] = set(cand | hold)
|
||||
else:
|
||||
self._ls_feed_owners.pop(owner, None)
|
||||
if not self._split_feed_active:
|
||||
if ls_feed:
|
||||
self._reconcile_ls_feed_subscriptions()
|
||||
self.sync_targets(owner, cand | hold)
|
||||
return
|
||||
if not self._kiwoom_ws:
|
||||
logger.warning(
|
||||
"⚠️ WS 분리 시세 요청이나 키움 WS 없음 → KIS 전체 구독(레거시)으로 폴백",
|
||||
)
|
||||
if ls_feed:
|
||||
self._reconcile_ls_feed_subscriptions()
|
||||
self.sync_targets(owner, cand | hold)
|
||||
return
|
||||
with self._lock:
|
||||
self._owner_candidates[owner] = cand
|
||||
self._owner_holdings[owner] = hold
|
||||
self._reconcile_split_subscriptions()
|
||||
self._reconcile_ls_feed_subscriptions()
|
||||
|
||||
def _ls_feed_codes_locked(self) -> Set[str]:
|
||||
"""호출자 _lock 보유 가정."""
|
||||
out: Set[str] = set()
|
||||
for s in self._ls_feed_owners.values():
|
||||
out |= s
|
||||
return out
|
||||
|
||||
def _non_ls_codes_locked(self) -> Set[str]:
|
||||
"""ls_feed 가 아닌 전략의 후보∪보유. 호출자 _lock 보유."""
|
||||
ls_owners = set(self._ls_feed_owners.keys())
|
||||
out: Set[str] = set()
|
||||
for own, s in self._owner_candidates.items():
|
||||
if own not in ls_owners:
|
||||
out |= s
|
||||
for own, s in self._owner_holdings.items():
|
||||
if own not in ls_owners:
|
||||
out |= s
|
||||
# 레거시 sync_targets 경로
|
||||
for own, s in self._owner_codes.items():
|
||||
if own not in ls_owners:
|
||||
out |= s
|
||||
return out
|
||||
|
||||
def _pure_ls_codes_locked(self) -> Set[str]:
|
||||
"""예전: LS 전용 코드를 키움/갭에서 빼던 집합.
|
||||
|
||||
지금은 비움 — ls_condition 도 키움 갭·분봉을 쓰고, LS 는 틱만.
|
||||
"""
|
||||
return set()
|
||||
|
||||
def is_ls_feed_code(self, code: str) -> bool:
|
||||
"""LS 틱·현재가 라우팅 대상 (갭/분봉은 키움)."""
|
||||
with self._lock:
|
||||
return code in self._ls_feed_codes_locked()
|
||||
|
||||
def _get_ls_ws(self):
|
||||
try:
|
||||
from ..ws.ls_ws import get_active_ls_ws
|
||||
return get_active_ls_ws()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _reconcile_ls_feed_subscriptions(self) -> None:
|
||||
"""ls_condition 코드를 LS US3 에만 sync — 갭(t8412)은 돌리지 않음."""
|
||||
with self._lock:
|
||||
owners = {
|
||||
str(o): set(codes)
|
||||
for o, codes in self._ls_feed_owners.items()
|
||||
}
|
||||
if not owners:
|
||||
return
|
||||
ls_ws = self._get_ls_ws()
|
||||
if ls_ws is None:
|
||||
if not self._ls_ws_missing_warned:
|
||||
logger.warning(
|
||||
"⚠️ ls_condition 틱용 LS WS 미기동 — "
|
||||
"AFR/틱 수신 실패 가능 (main LS WS 확인)",
|
||||
)
|
||||
self._ls_ws_missing_warned = True
|
||||
return
|
||||
self._ls_ws_missing_warned = False
|
||||
for owner, codes in owners.items():
|
||||
try:
|
||||
ls_ws.sync_owner_codes(owner, codes)
|
||||
except Exception as e:
|
||||
logger.warning("LS sync_owner_codes(%s) 실패: %s", owner, e)
|
||||
|
||||
def _enqueue_ls_gap_fill(self, code: str, *, force: bool = False, priority: bool = False) -> None:
|
||||
if not code or not get_env_bool("LS_GAP_FILL_ENABLED", True):
|
||||
return
|
||||
fail_max = max(1, get_env_int("LS_GAP_FILL_FAIL_MAX", 5))
|
||||
with self._ls_gap_lock:
|
||||
if self._ls_gap_fail.get(code, 0) >= fail_max and not force:
|
||||
return
|
||||
if code in self._ls_gap_inflight:
|
||||
return
|
||||
if code in self._ls_gap_filled and not force:
|
||||
return
|
||||
if force:
|
||||
self._ls_gap_filled.discard(code)
|
||||
self._ls_gap_inflight.add(code)
|
||||
try:
|
||||
self._ls_gap_q.put_nowait(code)
|
||||
except queue.Full:
|
||||
with self._ls_gap_lock:
|
||||
self._ls_gap_inflight.discard(code)
|
||||
logger.warning("⚠️ LS 갭보정 큐 full → %s 스킵", code)
|
||||
|
||||
def _start_ls_gap_worker(self) -> None:
|
||||
if self._ls_gap_worker_threads:
|
||||
alive = [t for t in self._ls_gap_worker_threads if t.is_alive()]
|
||||
if alive:
|
||||
return
|
||||
n = max(1, min(get_env_int("LS_GAP_FILL_WORKERS", 1), 2))
|
||||
threads = []
|
||||
for i in range(n):
|
||||
t = threading.Thread(
|
||||
target=self._ls_gap_worker_loop,
|
||||
name=f"WS-LSGapWorker-{i}",
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
self._ls_gap_worker_threads = threads
|
||||
logger.info("✅ LS 갭보정 워커 시작 (t8412, workers=%d)", n)
|
||||
|
||||
def _ls_gap_worker_loop(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
try:
|
||||
code = self._ls_gap_q.get(timeout=1.0)
|
||||
except queue.Empty:
|
||||
continue
|
||||
if not code:
|
||||
continue
|
||||
ok = False
|
||||
try:
|
||||
ls_ws = self._get_ls_ws()
|
||||
if ls_ws is None:
|
||||
logger.debug("LS 갭 워커: LS WS 없음 code=%s", code)
|
||||
else:
|
||||
n = int(ls_ws.fill_gap_from_rest(code) or 0)
|
||||
ok = n > 0
|
||||
if not ok:
|
||||
logger.debug("LS 갭 빈응답 code=%s", code)
|
||||
except Exception as e:
|
||||
logger.warning("LS 갭 실패 %s: %s", code, e)
|
||||
with self._ls_gap_lock:
|
||||
self._ls_gap_inflight.discard(code)
|
||||
if ok:
|
||||
self._ls_gap_filled.add(code)
|
||||
self._ls_gap_fail.pop(code, None)
|
||||
else:
|
||||
self._ls_gap_fail[code] = int(self._ls_gap_fail.get(code, 0)) + 1
|
||||
except Exception as e:
|
||||
logger.debug("LS gap worker: %s", e)
|
||||
time.sleep(0.5)
|
||||
|
||||
def _reconcile_split_subscriptions(self) -> None:
|
||||
"""KIS/키움 구독 집합을 후보·보유·영구 기준으로 재동기화."""
|
||||
@@ -295,9 +473,13 @@ class WSManager:
|
||||
for s in self._owner_holdings.values():
|
||||
hold_u |= s
|
||||
perm = set(self._permanent_codes)
|
||||
kis_want = perm | hold_u
|
||||
kw_want = cand_u | hold_u | perm
|
||||
tick_to_agg = set(cand_u - hold_u)
|
||||
pure_ls = self._pure_ls_codes_locked() - perm
|
||||
# ls_condition 전용 종목은 키움/KIS 후보·갭에서 제외 (교차 폭주 방지)
|
||||
cand_u_kw = cand_u - pure_ls
|
||||
hold_u_kw = hold_u - pure_ls
|
||||
kis_want = perm | hold_u_kw
|
||||
kw_want = cand_u_kw | hold_u_kw | perm
|
||||
tick_to_agg = set(cand_u_kw - hold_u_kw)
|
||||
self._gap_refill_codes = set(kis_want) | set(kw_want)
|
||||
# 재진입 시 grace 재사용 가능하도록 소진 플래그 해제
|
||||
active_want = cand_u | hold_u | perm
|
||||
@@ -341,7 +523,10 @@ class WSManager:
|
||||
str(owner): set(codes)
|
||||
for owner, codes in self._owner_candidates.items()
|
||||
}
|
||||
pure_ls_now = self._pure_ls_codes_locked()
|
||||
for code in added_kw:
|
||||
if code in pure_ls_now:
|
||||
continue # 방어: LS 전용은 키움 갭 enqueue 금지
|
||||
if code in self._permanent_codes:
|
||||
self._enqueue_gap_fill(code)
|
||||
else:
|
||||
@@ -525,7 +710,7 @@ class WSManager:
|
||||
|
||||
if first_ref and self.ws_cache:
|
||||
self.ws_cache.subscribe(code)
|
||||
# 신규 구독 → 워커에게 갭보정 위임 (논블로킹)
|
||||
# 신규 구독 → 워커에게 갭보정 위임 (논블로킹) — 키움/KIS 경로
|
||||
self._enqueue_gap_fill(code)
|
||||
self._sync_tick_record_codes()
|
||||
|
||||
@@ -578,19 +763,22 @@ class WSManager:
|
||||
return []
|
||||
return []
|
||||
|
||||
def get_current_candle(self, code: str, tf: int) -> Optional[dict]:
|
||||
"""진행 중 봉 (RAM, is_confirmed=0) — B안 거래량·양봉 판정용."""
|
||||
if self.candle_agg:
|
||||
try:
|
||||
return self.candle_agg.get_current_candle(code, tf)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 조회 헬퍼 (전략이 쓰는 API)
|
||||
# ------------------------------------------------------------------
|
||||
def get_price(self, code: str, max_age_sec: float = 5.0) -> Optional[dict]:
|
||||
# ls_condition 전략 코드 → LS WS 우선
|
||||
if self.is_ls_feed_code(code):
|
||||
ls_ws = self._get_ls_ws()
|
||||
if ls_ws is not None:
|
||||
try:
|
||||
p = ls_ws.get_price(code, max_age_sec=max_age_sec)
|
||||
if p:
|
||||
return p
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
logger.debug("LS 피드 코드이나 LS WS 없음: %s", code)
|
||||
if self.ws_cache:
|
||||
try:
|
||||
p = self.ws_cache.get_price(code, max_age_sec=max_age_sec)
|
||||
@@ -605,6 +793,41 @@ class WSManager:
|
||||
return None
|
||||
return None
|
||||
|
||||
def register_price_listener(self, callback) -> None:
|
||||
"""현재가 틱 갱신 콜백. callback(code, price, data_dict). BaseStrategy 틱매도용."""
|
||||
if callback is None:
|
||||
return
|
||||
if callback not in self._price_listeners:
|
||||
self._price_listeners.append(callback)
|
||||
self._attach_price_listener(callback)
|
||||
|
||||
def _attach_price_listener(self, callback) -> None:
|
||||
for src in (self.ws_cache, self._kiwoom_ws, self._get_ls_ws()):
|
||||
if src is not None and hasattr(src, "add_price_listener"):
|
||||
try:
|
||||
src.add_price_listener(callback)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def unregister_price_listener(self, callback) -> None:
|
||||
if callback is None:
|
||||
return
|
||||
try:
|
||||
self._price_listeners.remove(callback)
|
||||
except ValueError:
|
||||
pass
|
||||
for src in (self.ws_cache, self._kiwoom_ws, self._get_ls_ws()):
|
||||
if src is not None and hasattr(src, "remove_price_listener"):
|
||||
try:
|
||||
src.remove_price_listener(callback)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _reattach_all_price_listeners(self) -> None:
|
||||
"""키움/KIS WS 기동·교체 후 기존 리스너 재연결."""
|
||||
for cb in list(self._price_listeners):
|
||||
self._attach_price_listener(cb)
|
||||
|
||||
def get_orderbook_snapshot(self, code: str, max_age_sec: float = 3.0):
|
||||
"""키움 0D 호가 스냅샷 (분리 시세·키움 WS 활성 시)."""
|
||||
if self._kiwoom_ws and hasattr(self._kiwoom_ws, "get_orderbook_snapshot"):
|
||||
@@ -639,6 +862,7 @@ class WSManager:
|
||||
return None
|
||||
|
||||
def get_candles(self, code: str, tf: int, n: int = 50) -> list:
|
||||
# ls_condition 도 분봉은 키움 갭·candle_agg (틱만 LS)
|
||||
if self.candle_agg:
|
||||
try:
|
||||
return self.candle_agg.get_candles(code, tf, n)
|
||||
@@ -650,21 +874,50 @@ class WSManager:
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def get_current_candle(self, code: str, tf: int) -> Optional[dict]:
|
||||
"""진행 중 봉 (RAM, is_confirmed=0) — B안 거래량·양봉 판정용."""
|
||||
if self.candle_agg:
|
||||
try:
|
||||
return self.candle_agg.get_current_candle(code, tf)
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def fill_gap(
|
||||
self,
|
||||
codes: Optional[Iterable[str]] = None,
|
||||
*,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
"""외부에서 수동으로 갭 보정 트리거 (비동기: 큐 등록 후 즉시 리턴).
|
||||
"""갭 보정 — 전부 키움/KIS 기존 경로 (LS t8412 안 씀).
|
||||
|
||||
force=True: 이미 ``_gap_filled`` 여도 재큐 (RAM 삭제 후 재ENTER·봉부족 복구).
|
||||
force=True: 이미 ``_gap_filled`` 여도 재큐.
|
||||
"""
|
||||
self._maybe_arm_session_gap_refill()
|
||||
if codes is None:
|
||||
self._trigger_bulk_refill_async()
|
||||
else:
|
||||
for c in codes:
|
||||
self._enqueue_gap_fill(c, force=bool(force), priority=bool(force))
|
||||
return
|
||||
for c in codes:
|
||||
self._enqueue_gap_fill(c, force=bool(force), priority=bool(force))
|
||||
|
||||
def _maybe_arm_session_gap_refill(self) -> None:
|
||||
"""평일 장시작 세션 1회: 장외 거짓완료 마커 제거 + 구독 종목 bulk refill.
|
||||
|
||||
주말/장외에 ``_gap_filled`` 만 찍히면 개장 직후 REST 없이 have=0 레이스가 난다.
|
||||
"""
|
||||
if not self._is_market_hours():
|
||||
return
|
||||
day = time.strftime("%Y%m%d")
|
||||
if self._gap_session_day == day:
|
||||
return
|
||||
self._gap_session_day = day
|
||||
logger.info(
|
||||
"🔄 [갭보정-장시작] 세션 %s 오픈 → 완료마커 클리어 + bulk refill",
|
||||
day,
|
||||
)
|
||||
# debounce 우회: 세션 오픈은 강제 (직전 장외 bulk 와 충돌해도 재실행)
|
||||
self._bulk_refill_last_ts = 0.0
|
||||
self._trigger_bulk_refill_async()
|
||||
|
||||
def _clear_gap_fill_state(self, code: str) -> None:
|
||||
"""RAM 봉 삭제와 짝 — 갭보정 완료 마커 해제.
|
||||
@@ -677,6 +930,80 @@ class WSManager:
|
||||
with self._gap_lock:
|
||||
self._gap_filled.discard(code)
|
||||
self._gap_tf_ok.pop(code, None)
|
||||
self._gap_retry_count.pop(code, None)
|
||||
self._gap_give_up_until.pop(code, None)
|
||||
self._gap_empty_log_ts.pop(code, None)
|
||||
|
||||
def _gap_give_up_sec(self) -> float:
|
||||
"""최대 재시도 초과 후 force 재큐 차단 시간(초). 계정 REST 폭주 방지."""
|
||||
return float(get_env_float("WS_GAP_FILL_GIVE_UP_SEC", 300.0))
|
||||
|
||||
def _gap_in_give_up(self, code: str, *, now: Optional[float] = None) -> bool:
|
||||
"""포기 쿨다운 중이면 True. 만료 시 카운터 리셋 후 False."""
|
||||
ts = float(now if now is not None else time.time())
|
||||
with self._gap_lock:
|
||||
until = float(self._gap_give_up_until.get(code, 0.0) or 0.0)
|
||||
if until <= 0:
|
||||
return False
|
||||
if ts < until:
|
||||
return True
|
||||
self._gap_give_up_until.pop(code, None)
|
||||
self._gap_retry_count.pop(code, None)
|
||||
return False
|
||||
|
||||
def _mark_gap_give_up(self, code: str) -> None:
|
||||
"""REST 실패 상한 도달 — 전략 force 재큐를 일정 시간 무시."""
|
||||
sec = self._gap_give_up_sec()
|
||||
if sec <= 0 or not code:
|
||||
return
|
||||
until = time.time() + sec
|
||||
with self._gap_lock:
|
||||
self._gap_give_up_until[code] = until
|
||||
logger.warning(
|
||||
"🛑 [갭보정] %s 포기 쿨다운 %.0fs — force 재큐 차단 (REST 폭주 방지)",
|
||||
code, sec,
|
||||
)
|
||||
|
||||
def _gap_empty_circuit_open(self) -> bool:
|
||||
"""짧은 구간에 빈응답이 몰리면 전역 갭보정 REST 일시 정지."""
|
||||
now = time.time()
|
||||
with self._gap_lock:
|
||||
if now < float(self._gap_empty_circuit_until or 0.0):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _note_gap_empty_response(self, code: str, tf: int) -> None:
|
||||
"""빈응답 기록 + 로그 스로틀 + 전역 회로차단 갱신."""
|
||||
now = time.time()
|
||||
win = float(get_env_float("WS_GAP_FILL_EMPTY_CIRCUIT_WINDOW_SEC", 10.0))
|
||||
max_hits = get_env_int("WS_GAP_FILL_EMPTY_CIRCUIT_MAX", 40)
|
||||
pause = float(get_env_float("WS_GAP_FILL_EMPTY_CIRCUIT_PAUSE_SEC", 60.0))
|
||||
log_every = float(get_env_float("WS_GAP_FILL_EMPTY_LOG_SEC", 30.0))
|
||||
opened = False
|
||||
with self._gap_lock:
|
||||
last_log = float(self._gap_empty_log_ts.get(code, 0.0) or 0.0)
|
||||
do_log = (now - last_log) >= log_every
|
||||
if do_log:
|
||||
self._gap_empty_log_ts[code] = now
|
||||
if win > 0 and max_hits > 0:
|
||||
self._gap_empty_hit_ts.append(now)
|
||||
cut = now - win
|
||||
self._gap_empty_hit_ts = [t for t in self._gap_empty_hit_ts if t >= cut]
|
||||
if len(self._gap_empty_hit_ts) >= max_hits and pause > 0:
|
||||
if now >= float(self._gap_empty_circuit_until or 0.0):
|
||||
self._gap_empty_circuit_until = now + pause
|
||||
self._gap_empty_hit_ts.clear()
|
||||
opened = True
|
||||
if do_log:
|
||||
logger.warning(
|
||||
"⚠️ [갭보정] %s %dM → REST 빈 응답 (재시도 대상)", code, tf,
|
||||
)
|
||||
if opened:
|
||||
logger.error(
|
||||
"🚨 [갭보정] 빈응답 폭주 → 전역 REST %.0fs 정지 "
|
||||
"(window=%.0fs max=%d) — 계정 한도 보호",
|
||||
pause, win, max_hits,
|
||||
)
|
||||
|
||||
def _remove_candle_ram(self, code: str) -> None:
|
||||
"""구독 해제 시 RAM 봉 정리 + 갭보정 재실행 가능하도록 상태 리셋."""
|
||||
@@ -694,7 +1021,7 @@ class WSManager:
|
||||
# ------------------------------------------------------------------
|
||||
def _start_gap_worker(self) -> None:
|
||||
"""갭보정 백그라운드 워커 N개 기동 — 우선큐(후보 1M)와 일반큐 병렬 소진."""
|
||||
want = max(1, min(get_env_int("WS_GAP_FILL_WORKERS", 4), 4))
|
||||
want = max(1, min(get_env_int("WS_GAP_FILL_WORKERS", 2), 4))
|
||||
alive = [t for t in self._gap_worker_threads if t.is_alive()]
|
||||
if len(alive) >= want:
|
||||
return
|
||||
@@ -758,6 +1085,11 @@ class WSManager:
|
||||
return
|
||||
mode_key = str(mode).strip().lower()
|
||||
fill_mode = mode_key if mode_key in ("1m", "3m") else "full"
|
||||
# 빈응답 회로차단 / 종목 포기 쿨다운 — force 여부와 무관하게 REST 재큐 차단
|
||||
if self._gap_empty_circuit_open():
|
||||
return
|
||||
if self._gap_in_give_up(code):
|
||||
return
|
||||
with self._gap_lock:
|
||||
if code in self._gap_inflight:
|
||||
return
|
||||
@@ -942,18 +1274,32 @@ class WSManager:
|
||||
1 if gap_mode == "1m" else (3 if gap_mode == "3m" else None)
|
||||
)
|
||||
|
||||
# 장중만 실행 (장외면 완료 마커 찍고 다음)
|
||||
# 장시작 세션 암 — 워커가 장중 첫 작업을 잡을 때도 보장
|
||||
self._maybe_arm_session_gap_refill()
|
||||
|
||||
# 장중만 REST. 장외는 완료 마커를 찍지 않음(거짓완료 → 개장 have=0 방지).
|
||||
if not self._is_market_hours() and not get_env_bool("WS_GAP_FILL_OFF_HOURS", False):
|
||||
with self._gap_lock:
|
||||
self._gap_inflight.discard(code)
|
||||
self._gap_mode.pop(code, None)
|
||||
self._gap_filled.add(code)
|
||||
if from_prio:
|
||||
self._gap_prio_q.task_done()
|
||||
else:
|
||||
self._gap_q.task_done()
|
||||
continue
|
||||
|
||||
# 전역 빈응답 회로차단 중이면 REST 호출 없이 큐만 비움 (재큐는 enqueue가 막음)
|
||||
if self._gap_empty_circuit_open():
|
||||
with self._gap_lock:
|
||||
self._gap_inflight.discard(code)
|
||||
self._gap_mode.pop(code, None)
|
||||
if from_prio:
|
||||
self._gap_prio_q.task_done()
|
||||
else:
|
||||
self._gap_q.task_done()
|
||||
time.sleep(0.2)
|
||||
continue
|
||||
|
||||
if kw_key is None:
|
||||
kw_key, kw_secret, kw_mock = self._get_kiwoom_credentials()
|
||||
with self._gap_lock:
|
||||
@@ -969,7 +1315,7 @@ class WSManager:
|
||||
else:
|
||||
kw_status = "❌"
|
||||
n_workers = max(
|
||||
1, min(get_env_int("WS_GAP_FILL_WORKERS", 4), 4),
|
||||
1, min(get_env_int("WS_GAP_FILL_WORKERS", 2), 4),
|
||||
)
|
||||
logger.info(
|
||||
"🔧 [갭보정-워커×%d] kiwoom=%s, KIS_fallback=%s",
|
||||
@@ -977,6 +1323,11 @@ class WSManager:
|
||||
kw_status,
|
||||
"ON" if get_env_bool("WS_GAP_FILL_KIS_FALLBACK", False) else "OFF",
|
||||
)
|
||||
# 키움 ka10080 유량=5 — 워커 수 > 세마포어면 대기만 늘어남
|
||||
logger.info(
|
||||
"🔧 [갭보정] ka10080 MAX_INFLIGHT=%d (유량=5 보호)",
|
||||
max(1, min(get_env_int("KIWOOM_KA10080_MAX_INFLIGHT", 2), 4)),
|
||||
)
|
||||
self._gap_worker_boot_logged = True
|
||||
|
||||
try:
|
||||
@@ -993,6 +1344,7 @@ class WSManager:
|
||||
logger.debug("갭보정 워커 예외 (%s): %s", code, e)
|
||||
ok = False
|
||||
finally:
|
||||
_give_up_code: Optional[str] = None
|
||||
with self._gap_lock:
|
||||
self._gap_inflight.discard(code)
|
||||
self._gap_mode.pop(code, None)
|
||||
@@ -1013,6 +1365,7 @@ class WSManager:
|
||||
else:
|
||||
self._gap_filled.add(code)
|
||||
self._gap_retry_count.pop(code, None)
|
||||
self._gap_give_up_until.pop(code, None)
|
||||
else:
|
||||
retries = self._gap_retry_count.get(code, 0) + 1
|
||||
max_retries = get_env_int("WS_GAP_FILL_MAX_RETRIES", 3)
|
||||
@@ -1036,9 +1389,11 @@ class WSManager:
|
||||
code, partial_tf,
|
||||
)
|
||||
self._gap_filled.add(code)
|
||||
_give_up_code = code
|
||||
elif ok:
|
||||
self._gap_filled.add(code)
|
||||
self._gap_retry_count.pop(code, None)
|
||||
self._gap_give_up_until.pop(code, None)
|
||||
else:
|
||||
retries = self._gap_retry_count.get(code, 0) + 1
|
||||
max_retries = get_env_int("WS_GAP_FILL_MAX_RETRIES", 3)
|
||||
@@ -1059,6 +1414,10 @@ class WSManager:
|
||||
code,
|
||||
)
|
||||
self._gap_filled.add(code)
|
||||
_give_up_code = code
|
||||
|
||||
if _give_up_code:
|
||||
self._mark_gap_give_up(_give_up_code)
|
||||
|
||||
if from_prio:
|
||||
self._gap_prio_q.task_done()
|
||||
@@ -1330,6 +1689,8 @@ class WSManager:
|
||||
return False
|
||||
if not self._is_market_hours() and not get_env_bool("WS_GAP_FILL_OFF_HOURS", False):
|
||||
return False
|
||||
if self._gap_empty_circuit_open():
|
||||
return False
|
||||
|
||||
if self._all_gap_tfs_ok(code):
|
||||
return True
|
||||
@@ -1408,7 +1769,10 @@ class WSManager:
|
||||
if tf == 1:
|
||||
self._maybe_rollup_3m_from_1m(code)
|
||||
else:
|
||||
logger.warning("⚠️ [갭보정] %s %dM → REST 빈 응답 (재시도 대상)", code, tf)
|
||||
self._note_gap_empty_response(code, tf)
|
||||
# 한 TF 빈응답이면 같은 패스의 나머지 TF REST도 생략 (1M+3M 이중 폭격 방지)
|
||||
if get_env_bool("WS_GAP_FILL_ABORT_TF_ON_EMPTY", True):
|
||||
break
|
||||
|
||||
prev_tf = tf
|
||||
self._gap_tf_sleep()
|
||||
|
||||
Reference in New Issue
Block a user