메인 시세 41에 H0STASP0를 붙이는 폴백을 없앤다. MINIMAL과 무관하게 후보∪보유∪영구를 LS RAM에 붙인다. 그 분 틱이 없거나 봉끝 대비 늦으면 그 WS 봉을 버리고 2차·LS·REST 봉을 통째로 쓴다. 같은 파일에 직전 커밋 이후 쌓인 시세 폴백/ENV 키 정리도 포함된다. Co-authored-by: Cursor <cursoragent@cursor.com>
328 lines
11 KiB
Python
328 lines
11 KiB
Python
"""
|
|
kis_trader/engine/feed_fallback.py — 실매·옵투나 공통 읽기 나이 헬퍼
|
|
======================================================================
|
|
메인이 지금보다 LIVE_FEED_FALLBACK_MAX_AGE_SEC 초보다 오래면 그 벤더는 없는 것과 같음.
|
|
2차(보조 WS) → 3차(LS) → (실매 매도만) 4차 키움 REST.
|
|
적재(공책) 임계와 읽기 나이는 분리한다. OHLC 로 틱을 메우지 않음.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
|
|
from kis_trader.utils.env import get_env_bool, get_env_float, get_env_from_db
|
|
|
|
|
|
def packet_lag_seconds(pkt_raw: str, *, now_dt: Optional[datetime] = None) -> Optional[float]:
|
|
"""체결시각 문자열 vs now_dt(기본=서버 지금). 증권사끼리 비교 아님.
|
|
|
|
YYYYMMDDHHMMSS 또는 HHMMSS. 파싱 실패면 None(나이를 모름 → 읽기에서 버리지 않음).
|
|
"""
|
|
tt = str(pkt_raw or "").strip()
|
|
if not tt:
|
|
return None
|
|
wall = now_dt if now_dt is not None else datetime.now()
|
|
pkt_dt = None
|
|
try:
|
|
if len(tt) >= 14 and tt[:14].isdigit():
|
|
pkt_dt = datetime.strptime(tt[:14], "%Y%m%d%H%M%S")
|
|
elif len(tt) >= 6 and tt[-6:].isdigit():
|
|
pkt_dt = datetime.strptime(
|
|
wall.strftime("%Y%m%d") + tt[-6:], "%Y%m%d%H%M%S",
|
|
)
|
|
except Exception:
|
|
return None
|
|
if pkt_dt is None:
|
|
return None
|
|
return float((wall - pkt_dt).total_seconds())
|
|
|
|
|
|
def is_feed_read_stale(
|
|
lag_sec: Optional[float], max_age_sec: Optional[float] = None,
|
|
) -> bool:
|
|
"""읽기 나이 초과. lag/나이를 모르면 False(버리지 않음)."""
|
|
age = live_feed_fallback_max_age_sec() if max_age_sec is None else float(max_age_sec)
|
|
if age <= 0 or lag_sec is None:
|
|
return False
|
|
try:
|
|
return float(lag_sec) > age
|
|
except (TypeError, ValueError):
|
|
return False
|
|
|
|
|
|
def live_feed_fallback_max_age_sec() -> float:
|
|
"""읽기 유효 나이(초). 기본 3. 0 이하면 나이로 벤더를 버리지 않음(레거시)."""
|
|
try:
|
|
v = float(get_env_float("LIVE_FEED_FALLBACK_MAX_AGE_SEC", 3.0) or 3.0)
|
|
except (TypeError, ValueError):
|
|
v = 3.0
|
|
return v
|
|
|
|
|
|
def live_tick_primary() -> str:
|
|
p = str(get_env_from_db("LIVE_TICK_PROVIDER", "kiwoom") or "kiwoom").strip().lower()
|
|
return p if p in ("kis", "kiwoom") else "kiwoom"
|
|
|
|
|
|
def live_ob_primary() -> str:
|
|
p = str(get_env_from_db("LIVE_OB_PROVIDER", "kiwoom") or "kiwoom").strip().lower()
|
|
return p if p in ("kis", "kiwoom") else "kiwoom"
|
|
|
|
|
|
def vendor_read_max_age_sec(caller_max_age: Optional[float]) -> Optional[float]:
|
|
"""벤더 1회 조회에 쓸 나이.
|
|
|
|
- caller None = 마지막 RAM(명시, 나이 무시)
|
|
- caller 0/음수 또는 생략 대체 = 폴백 나이(기본 3초)
|
|
- caller 양수 = min(caller, 폴백나이). 폴백 0이면 caller 그대로.
|
|
"""
|
|
fb = live_feed_fallback_max_age_sec()
|
|
if caller_max_age is None:
|
|
return None
|
|
try:
|
|
c = float(caller_max_age)
|
|
except (TypeError, ValueError):
|
|
c = 0.0
|
|
if fb <= 0:
|
|
return None if c <= 0 else c
|
|
if c <= 0:
|
|
return fb
|
|
return min(c, fb)
|
|
|
|
|
|
def tick_feed_tier(vendor: str, primary: str) -> int:
|
|
v = str(vendor or "").strip().lower()
|
|
p = str(primary or "kiwoom").strip().lower()
|
|
if v in ("kiwoom_rest", "rest", "ka10007"):
|
|
return 4
|
|
if v == "ls":
|
|
return 3
|
|
if v == p:
|
|
return 1
|
|
return 2
|
|
|
|
|
|
def format_vendor_label(vendor: str, tier: int = 0, spilled: bool = False) -> str:
|
|
v = str(vendor or "").strip().lower() or "?"
|
|
names = {
|
|
"kiwoom": "kiwoom",
|
|
"kis": "kis",
|
|
"ls": "ls",
|
|
"kiwoom_rest": "kiwoom_rest",
|
|
"rest": "kiwoom_rest",
|
|
"ka10007": "kiwoom_rest",
|
|
}
|
|
name = names.get(v, v)
|
|
extra: List[str] = []
|
|
tmap = {1: "1차", 2: "2차", 3: "3차", 4: "4차"}
|
|
tlab = tmap.get(int(tier or 0), "")
|
|
if tlab:
|
|
extra.append(tlab)
|
|
if spilled:
|
|
extra.append("spill")
|
|
if extra:
|
|
return f"{name}({','.join(extra)})"
|
|
return name
|
|
|
|
|
|
def format_mm_feed_line(tick_lab: str, ob_lab: str) -> str:
|
|
t = str(tick_lab or "").strip() or "?"
|
|
o = str(ob_lab or "").strip() or "?"
|
|
return f"시세: {t} | 호가: {o}"
|
|
|
|
|
|
def _tick_second_key(tick: Dict[str, Any]) -> str:
|
|
tt = str(tick.get("tick_time") or "")[:14]
|
|
if len(tt) >= 14:
|
|
return tt[:14]
|
|
if len(tt) >= 12:
|
|
return tt[:12] + "00"
|
|
return tt
|
|
|
|
|
|
def merge_ticks_time_axis_fallback(
|
|
ticks: Sequence[Dict[str, Any]],
|
|
*,
|
|
main_src: str = "kiwoom",
|
|
max_lag_sec: Optional[float] = None,
|
|
) -> List[Dict[str, Any]]:
|
|
"""같은 초에는 메인만. 메인·보조 모두 lag>나이면 그 초는 비움(OHLC 메우지 않음).
|
|
|
|
분봉에 메인 1건 있다고 보조를 통째로 버리지 않음 (실매 2초 실패→2차와 동일).
|
|
"""
|
|
if not ticks:
|
|
return list(ticks)
|
|
main = str(main_src or "kiwoom").strip().lower()
|
|
if main not in ("kis", "kiwoom"):
|
|
main = "kiwoom"
|
|
age = live_feed_fallback_max_age_sec() if max_lag_sec is None else float(max_lag_sec)
|
|
grouped: Dict[str, List[Dict[str, Any]]] = {}
|
|
order: List[str] = []
|
|
for t in ticks:
|
|
if not isinstance(t, dict):
|
|
continue
|
|
sk = _tick_second_key(t)
|
|
if not sk:
|
|
continue
|
|
if sk not in grouped:
|
|
grouped[sk] = []
|
|
order.append(sk)
|
|
grouped[sk].append(t)
|
|
out: List[Dict[str, Any]] = []
|
|
for sk in order:
|
|
bucket = grouped[sk]
|
|
mains: List[Dict[str, Any]] = []
|
|
aux: List[Dict[str, Any]] = []
|
|
for t in bucket:
|
|
src = str(t.get("source") or "").strip().lower()
|
|
lag = t.get("_lag_sec")
|
|
if is_feed_read_stale(lag, age):
|
|
continue
|
|
if src == main:
|
|
mains.append(t)
|
|
else:
|
|
aux.append(t)
|
|
if mains:
|
|
out.extend(mains)
|
|
else:
|
|
out.extend(aux)
|
|
return out
|
|
|
|
|
|
def orderbook_row_lag_seconds(row: Dict[str, Any]) -> Optional[int]:
|
|
"""recv_ts vs snap_time 초 차이. 파싱 실패면 None(유효로 봄)."""
|
|
recv_ts = str(row.get("recv_ts") or "").strip()
|
|
st = str(row.get("snap_time") or "").strip()
|
|
if len(recv_ts) < 19 or len(st) < 6:
|
|
return None
|
|
try:
|
|
recv_dt = datetime.strptime(recv_ts[:19], "%Y-%m-%d %H:%M:%S")
|
|
except Exception:
|
|
return None
|
|
try:
|
|
if len(st) >= 14 and st[:14].isdigit():
|
|
pkt = datetime.strptime(st[:14], "%Y%m%d%H%M%S")
|
|
elif len(st) >= 6 and st[-6:].isdigit():
|
|
pkt = datetime.strptime(recv_ts[:10].replace("-", "") + st[-6:], "%Y%m%d%H%M%S")
|
|
else:
|
|
return None
|
|
return int((recv_dt - pkt).total_seconds())
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def candle_garbage_fallback_enabled() -> bool:
|
|
try:
|
|
return bool(get_env_bool("CANDLE_GARBAGE_FALLBACK", True))
|
|
except Exception:
|
|
return True
|
|
|
|
|
|
def bar_end_datetime(candle_time: str, tf_min: int):
|
|
"""봉 시작 candle_time(YYYYMMDDHHMM) + tf → 봉 끝 datetime."""
|
|
from kis_trader.engine.candle_rollup import add_candle_minutes
|
|
|
|
end = add_candle_minutes(str(candle_time or "")[:12], int(tf_min or 1))
|
|
if not end or len(end) < 12:
|
|
return None
|
|
try:
|
|
return datetime.strptime(end[:12], "%Y%m%d%H%M")
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def tick_in_bar_bucket(tick_time: str, candle_time: str, tf_min: int) -> bool:
|
|
from kis_trader.engine.candle_rollup import add_candle_minutes
|
|
|
|
ct = str(candle_time or "").strip()[:12]
|
|
raw = str(tick_time or "").strip().replace(":", "").replace("-", "").replace(" ", "")
|
|
if len(ct) < 12:
|
|
return False
|
|
tmin = raw[:12] if len(raw) >= 12 else ""
|
|
if len(tmin) < 12 or not tmin.isdigit():
|
|
return False
|
|
end = add_candle_minutes(ct, int(tf_min or 1))
|
|
if not end:
|
|
return False
|
|
return ct <= tmin < end[:12]
|
|
|
|
|
|
def bar_is_garbage(
|
|
ticks: Sequence[Dict[str, Any]],
|
|
*,
|
|
candle_time: str,
|
|
tf_min: int,
|
|
source: str,
|
|
missing_policy: str = "hole",
|
|
) -> bool:
|
|
"""그 분·그 source 틱이 없거나 전부 봉끝 대비 읽기나이 초과면 True(구멍).
|
|
|
|
missing_policy:
|
|
hole — 0건이면 쓰레기 (옵투나 공책)
|
|
keep — 0건이면 유지 (실매 링이 그 분을 커버 못 할 때 호출측에서 keep)
|
|
증권사 시계끼리 비교하지 않음.
|
|
"""
|
|
if not candle_garbage_fallback_enabled():
|
|
return False
|
|
src = str(source or "").strip().lower()
|
|
bucket: List[Dict[str, Any]] = []
|
|
for t in ticks or []:
|
|
if not isinstance(t, dict):
|
|
continue
|
|
tsrc = str(t.get("source") or "").strip().lower()
|
|
if tsrc and src and tsrc != src:
|
|
continue
|
|
raw = t.get("tick_time_raw") or t.get("tick_time") or ""
|
|
if tick_in_bar_bucket(str(raw), candle_time, tf_min):
|
|
bucket.append(t)
|
|
if not bucket:
|
|
return str(missing_policy or "hole").strip().lower() == "hole"
|
|
bar_end = bar_end_datetime(candle_time, tf_min)
|
|
if bar_end is None:
|
|
return False
|
|
for t in bucket:
|
|
raw = str(t.get("tick_time_raw") or t.get("tick_time") or "")
|
|
lag = packet_lag_seconds(raw, now_dt=bar_end)
|
|
if not is_feed_read_stale(lag):
|
|
return False
|
|
return True
|
|
|
|
|
|
def live_bar_is_garbage(
|
|
ticks: Sequence[Dict[str, Any]],
|
|
*,
|
|
candle_time: str,
|
|
tf_min: int,
|
|
source: str,
|
|
) -> bool:
|
|
"""실매 링버퍼: 그 소스 틱이 그 분까지 없으면 판정 불가 → False(유지).
|
|
|
|
LS 틱은 TickRecorder 링에 안 들어갈 수 있음. 다른 증권사 틱만으로 cover 하면
|
|
LS/2차 봉을 구멍으로 잘못 버린다. 소스별 cover 만 본다.
|
|
"""
|
|
if not candle_garbage_fallback_enabled():
|
|
return False
|
|
ct = str(candle_time or "")[:12]
|
|
if len(ct) < 12:
|
|
return False
|
|
src = str(source or "").strip().lower()
|
|
cover = False
|
|
for t in ticks or []:
|
|
if not isinstance(t, dict):
|
|
continue
|
|
tsrc = str(t.get("source") or "").strip().lower()
|
|
if tsrc and src and tsrc != src:
|
|
continue
|
|
raw = str(t.get("tick_time_raw") or t.get("tick_time") or "").strip()
|
|
digits = raw.replace(":", "").replace("-", "").replace(" ", "")
|
|
tmin = digits[:12] if len(digits) >= 12 else ""
|
|
if tmin and tmin.isdigit() and tmin <= ct:
|
|
cover = True
|
|
break
|
|
if not cover:
|
|
return False
|
|
return bar_is_garbage(
|
|
ticks, candle_time=candle_time, tf_min=tf_min, source=source,
|
|
missing_policy="hole",
|
|
)
|