Files
kis_bot/kis_trader/engine/feed_fallback.py
Your Name d1dc274f0e fix: 한투 호가 2키 전용, LS RAM 합집합, 분봉 쓰레기면 다음 소스 통째
메인 시세 41에 H0STASP0를 붙이는 폴백을 없앤다. MINIMAL과 무관하게
후보∪보유∪영구를 LS RAM에 붙인다. 그 분 틱이 없거나 봉끝 대비 늦으면
그 WS 봉을 버리고 2차·LS·REST 봉을 통째로 쓴다.

같은 파일에 직전 커밋 이후 쌓인 시세 폴백/ENV 키 정리도 포함된다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 22:09:03 +09:00

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",
)