- Optuna web jobs/TPE/apply snapshot·틱로더 정합, jobs limit·감사로그 - 백테 UI 호가모드·후보 적용 흐름, feed_collect_stats API/탭 - 가설검증·교차검증 룰, 4전략 스모크·OB slot41 진단 스크립트 Co-authored-by: Cursor <cursoragent@cursor.com>
780 lines
28 KiB
Python
780 lines
28 KiB
Python
"""일자별 틱·호가 수집 통계 (백테웹 통계 탭).
|
|
|
|
실매 벤더별 DB 적재량 + 틱↔호가 공백 비율.
|
|
- 틱/호가 DB는 벤더마다 SAVE 플래그로 **각각** 적재한다 (2·3차 spill일 때만 아님).
|
|
- MODE=tick: 체결 1건당 그 벤더 RAM 호가가 ``WS_ORDERBOOK_TICK_MAX_AGE_SEC``(기본 3초)
|
|
안일 때만 호가행. 스냅 없으면 그 틱의 호가행은 안 생김.
|
|
- ``LIVE_FEED_FALLBACK``(기본 3초) 초과 틱은 매매 RAM·호가틱동기 skip (틱 DB는 기본 전부 적재).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from typing import Any, Dict, List, Optional, Set
|
|
|
|
|
|
def _ymd8(day: str) -> str:
|
|
s = (day or "").strip().replace("-", "")[:8]
|
|
if len(s) == 8 and s.isdigit():
|
|
return s
|
|
return datetime.now().strftime("%Y%m%d")
|
|
|
|
|
|
def _like(day8: str) -> str:
|
|
return f"{day8}%"
|
|
|
|
|
|
def _row_nc(r: Any) -> Dict[str, Any]:
|
|
if r is None:
|
|
return {"n": 0, "codes": 0}
|
|
if isinstance(r, dict):
|
|
return {"n": int(r.get("n") or 0), "codes": int(r.get("c") or r.get("codes") or 0)}
|
|
return {"n": int(r[0] or 0), "codes": int(r[1] or 0)}
|
|
|
|
|
|
def _pct(num: float, den: float) -> Optional[float]:
|
|
if den is None or float(den) <= 0:
|
|
return None
|
|
return round(100.0 * float(num) / float(den), 2)
|
|
|
|
|
|
def _age_cut_row(
|
|
*,
|
|
channel: str,
|
|
source: str,
|
|
total: int,
|
|
pass_n: int,
|
|
fail_n: int,
|
|
unknown_n: int,
|
|
empty_minutes: int = 0,
|
|
) -> Dict[str, Any]:
|
|
"""옵투나/실매 읽기나이(LIVE_FEED_FALLBACK) 합격 요약 1행."""
|
|
usable = int(pass_n) + int(unknown_n) # lag 미상=실매와 같이 유지
|
|
tot = int(total or 0)
|
|
return {
|
|
"channel": channel,
|
|
"source": source,
|
|
"total": tot,
|
|
"pass_n": int(pass_n),
|
|
"fail_n": int(fail_n),
|
|
"unknown_n": int(unknown_n),
|
|
"usable_n": usable,
|
|
"empty_minutes": int(empty_minutes or 0),
|
|
"pass_pct": _pct(pass_n, tot),
|
|
"fail_pct": _pct(fail_n, tot),
|
|
"usable_pct": _pct(usable, tot),
|
|
}
|
|
|
|
|
|
def _feed_age_cut_stats(db, day8: str, age_sec: float) -> Dict[str, Any]:
|
|
"""recv_ts vs 체결/스냅 시각 지연 → 3초(폴백나이) 합격·탈락·미상.
|
|
|
|
옵투나 ``merge_ticks_time_axis_fallback`` / 호가 lag 컷과 동일 축.
|
|
lag 미상(NULL)은 실매와 같이 **버리지 않음** → usable 에 포함.
|
|
empty_minutes = 그 분 틱이 전부 fail 인 (code,분) 수 (비어 재현되는 분).
|
|
"""
|
|
age = max(0.0, float(age_sec or 3.0))
|
|
like = _like(day8)
|
|
rows_out: List[Dict[str, Any]] = []
|
|
|
|
# ── ws_ticks ──
|
|
try:
|
|
tick_sql = (
|
|
"SELECT source AS src, "
|
|
"COUNT(*) AS total, "
|
|
"SUM(CASE WHEN lag_sec IS NOT NULL AND lag_sec <= %s THEN 1 ELSE 0 END) AS pass_n, "
|
|
"SUM(CASE WHEN lag_sec IS NOT NULL AND lag_sec > %s THEN 1 ELSE 0 END) AS fail_n, "
|
|
"SUM(CASE WHEN lag_sec IS NULL THEN 1 ELSE 0 END) AS unknown_n "
|
|
"FROM ("
|
|
" SELECT source, "
|
|
" TIMESTAMPDIFF(SECOND, "
|
|
" STR_TO_DATE(LEFT(COALESCE(NULLIF(tick_time_raw,''), tick_time), 14), '%%Y%%m%%d%%H%%i%%s'), "
|
|
" recv_ts"
|
|
" ) AS lag_sec "
|
|
" FROM ws_ticks WHERE tick_time LIKE %s"
|
|
") t GROUP BY source"
|
|
)
|
|
for r in db.conn.execute(tick_sql, (age, age, like)).fetchall() or []:
|
|
d = dict(r) if not isinstance(r, dict) else r
|
|
rows_out.append(
|
|
_age_cut_row(
|
|
channel="tick",
|
|
source=str(d.get("src") or ""),
|
|
total=int(d.get("total") or 0),
|
|
pass_n=int(d.get("pass_n") or 0),
|
|
fail_n=int(d.get("fail_n") or 0),
|
|
unknown_n=int(d.get("unknown_n") or 0),
|
|
)
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
# 전량 컷된 분 (틱 채널) — 옵투나에서 그 분 시세 공백
|
|
empty_min = 0
|
|
try:
|
|
empty_sql = (
|
|
"SELECT COUNT(*) AS n FROM ("
|
|
" SELECT code, LEFT(tick_time,12) AS mk "
|
|
" FROM ("
|
|
" SELECT code, tick_time, "
|
|
" TIMESTAMPDIFF(SECOND, "
|
|
" STR_TO_DATE(LEFT(COALESCE(NULLIF(tick_time_raw,''), tick_time), 14), '%%Y%%m%%d%%H%%i%%s'), "
|
|
" recv_ts"
|
|
" ) AS lag_sec "
|
|
" FROM ws_ticks WHERE tick_time LIKE %s"
|
|
" ) x "
|
|
" GROUP BY code, LEFT(tick_time,12) "
|
|
" HAVING SUM(CASE WHEN lag_sec IS NULL OR lag_sec <= %s THEN 1 ELSE 0 END)=0 "
|
|
" AND COUNT(*)>0"
|
|
") z"
|
|
)
|
|
r = db.conn.execute(empty_sql, (like, age)).fetchone()
|
|
empty_min = int((r["n"] if isinstance(r, dict) else r[0]) or 0)
|
|
except Exception:
|
|
empty_min = 0
|
|
|
|
# ── ls_ws_ticks ──
|
|
try:
|
|
d0 = datetime.strptime(day8, "%Y%m%d")
|
|
d1 = d0.replace(hour=23, minute=59, second=59)
|
|
ls_sql = (
|
|
"SELECT 'ls' AS src, "
|
|
"COUNT(*) AS total, "
|
|
"SUM(CASE WHEN lag_sec IS NOT NULL AND lag_sec <= %s THEN 1 ELSE 0 END) AS pass_n, "
|
|
"SUM(CASE WHEN lag_sec IS NOT NULL AND lag_sec > %s THEN 1 ELSE 0 END) AS fail_n, "
|
|
"SUM(CASE WHEN lag_sec IS NULL THEN 1 ELSE 0 END) AS unknown_n "
|
|
"FROM ("
|
|
" SELECT TIMESTAMPDIFF(SECOND, "
|
|
" STR_TO_DATE("
|
|
" CASE WHEN CHAR_LENGTH(REGEXP_REPLACE(IFNULL(chetime,''), '[^0-9]', ''))>=14 "
|
|
" THEN LEFT(REGEXP_REPLACE(chetime, '[^0-9]', ''), 14) "
|
|
" WHEN CHAR_LENGTH(REGEXP_REPLACE(IFNULL(chetime,''), '[^0-9]', ''))>=6 "
|
|
" THEN CONCAT(DATE_FORMAT(ts,'%%Y%%m%%d'), "
|
|
" RIGHT(REGEXP_REPLACE(chetime, '[^0-9]', ''), 6)) "
|
|
" ELSE DATE_FORMAT(ts,'%%Y%%m%%d%%H%%i%%s') END, "
|
|
" '%%Y%%m%%d%%H%%i%%s'), "
|
|
" ts) AS lag_sec "
|
|
" FROM ls_ws_ticks WHERE ts>=%s AND ts<=%s"
|
|
") t"
|
|
)
|
|
r = db.conn.execute(ls_sql, (age, age, d0, d1)).fetchone()
|
|
if r:
|
|
d = dict(r) if not isinstance(r, dict) else r
|
|
if int(d.get("total") or 0) > 0:
|
|
rows_out.append(
|
|
_age_cut_row(
|
|
channel="tick",
|
|
source="ls",
|
|
total=int(d.get("total") or 0),
|
|
pass_n=int(d.get("pass_n") or 0),
|
|
fail_n=int(d.get("fail_n") or 0),
|
|
unknown_n=int(d.get("unknown_n") or 0),
|
|
)
|
|
)
|
|
else:
|
|
rows_out.append(
|
|
_age_cut_row(
|
|
channel="tick", source="ls",
|
|
total=0, pass_n=0, fail_n=0, unknown_n=0,
|
|
)
|
|
)
|
|
except Exception:
|
|
rows_out.append(
|
|
_age_cut_row(
|
|
channel="tick", source="ls",
|
|
total=0, pass_n=0, fail_n=0, unknown_n=0,
|
|
)
|
|
)
|
|
|
|
# ── ws_orderbook 스트림 ──
|
|
try:
|
|
ob_sql = (
|
|
"SELECT source AS src, "
|
|
"COUNT(*) AS total, "
|
|
"SUM(CASE WHEN lag_sec IS NOT NULL AND lag_sec <= %s THEN 1 ELSE 0 END) AS pass_n, "
|
|
"SUM(CASE WHEN lag_sec IS NOT NULL AND lag_sec > %s THEN 1 ELSE 0 END) AS fail_n, "
|
|
"SUM(CASE WHEN lag_sec IS NULL THEN 1 ELSE 0 END) AS unknown_n "
|
|
"FROM ("
|
|
" SELECT source, "
|
|
" TIMESTAMPDIFF(SECOND, "
|
|
" STR_TO_DATE("
|
|
" CASE WHEN CHAR_LENGTH(snap_time)>=14 THEN LEFT(snap_time,14) "
|
|
" WHEN CHAR_LENGTH(snap_time)>=6 THEN CONCAT(LEFT(%s,8), RIGHT(snap_time,6)) "
|
|
" ELSE NULL END, "
|
|
" '%%Y%%m%%d%%H%%i%%s'), "
|
|
" recv_ts"
|
|
" ) AS lag_sec "
|
|
" FROM ws_orderbook WHERE snap_time LIKE %s AND source<>%s"
|
|
") t GROUP BY source"
|
|
)
|
|
for r in db.conn.execute(ob_sql, (age, age, day8, like, "filter_eval")).fetchall() or []:
|
|
d = dict(r) if not isinstance(r, dict) else r
|
|
rows_out.append(
|
|
_age_cut_row(
|
|
channel="orderbook",
|
|
source=str(d.get("src") or ""),
|
|
total=int(d.get("total") or 0),
|
|
pass_n=int(d.get("pass_n") or 0),
|
|
fail_n=int(d.get("fail_n") or 0),
|
|
unknown_n=int(d.get("unknown_n") or 0),
|
|
)
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
# LS 호가
|
|
try:
|
|
ls_ob_sql = (
|
|
"SELECT 'ls_uh1' AS src, "
|
|
"COUNT(*) AS total, "
|
|
"SUM(CASE WHEN lag_sec IS NOT NULL AND lag_sec <= %s THEN 1 ELSE 0 END) AS pass_n, "
|
|
"SUM(CASE WHEN lag_sec IS NOT NULL AND lag_sec > %s THEN 1 ELSE 0 END) AS fail_n, "
|
|
"SUM(CASE WHEN lag_sec IS NULL THEN 1 ELSE 0 END) AS unknown_n "
|
|
"FROM ("
|
|
" SELECT TIMESTAMPDIFF(SECOND, "
|
|
" STR_TO_DATE(LEFT(snap_time,14), '%%Y%%m%%d%%H%%i%%s'), "
|
|
" recv_ts) AS lag_sec "
|
|
" FROM ls_ws_orderbook WHERE snap_time LIKE %s"
|
|
") t"
|
|
)
|
|
r = db.conn.execute(ls_ob_sql, (age, age, like)).fetchone()
|
|
if r:
|
|
d = dict(r) if not isinstance(r, dict) else r
|
|
rows_out.append(
|
|
_age_cut_row(
|
|
channel="orderbook",
|
|
source="ls_uh1",
|
|
total=int(d.get("total") or 0),
|
|
pass_n=int(d.get("pass_n") or 0),
|
|
fail_n=int(d.get("fail_n") or 0),
|
|
unknown_n=int(d.get("unknown_n") or 0),
|
|
)
|
|
)
|
|
except Exception:
|
|
rows_out.append(
|
|
_age_cut_row(
|
|
channel="orderbook", source="ls_uh1",
|
|
total=0, pass_n=0, fail_n=0, unknown_n=0,
|
|
)
|
|
)
|
|
|
|
# 틱 합계 행
|
|
tick_rows = [x for x in rows_out if x.get("channel") == "tick"]
|
|
if tick_rows:
|
|
rows_out.append(
|
|
_age_cut_row(
|
|
channel="tick",
|
|
source="Σ(틱)",
|
|
total=sum(x["total"] for x in tick_rows),
|
|
pass_n=sum(x["pass_n"] for x in tick_rows),
|
|
fail_n=sum(x["fail_n"] for x in tick_rows),
|
|
unknown_n=sum(x["unknown_n"] for x in tick_rows),
|
|
empty_minutes=empty_min,
|
|
)
|
|
)
|
|
|
|
return {
|
|
"age_sec": age,
|
|
"empty_minutes_all_fail": empty_min,
|
|
"rows": rows_out,
|
|
"note": (
|
|
f"나이={age:g}s (LIVE_FEED_FALLBACK). "
|
|
"합격=lag≤나이 · 탈락=lag>나이 · 미상=lag계산불가(유지=usable). "
|
|
f"전량탈락분={empty_min} (그 분 옵투나 시세 공백 후보)."
|
|
),
|
|
}
|
|
|
|
|
|
def _safe_group(db, sql: str, params: tuple) -> List[Dict[str, Any]]:
|
|
try:
|
|
rows = db.conn.execute(sql, params).fetchall() or []
|
|
except Exception:
|
|
return []
|
|
out: List[Dict[str, Any]] = []
|
|
for r in rows:
|
|
if isinstance(r, dict):
|
|
out.append(
|
|
{
|
|
"key": str(r.get("k") or r.get("source") or r.get("strategy") or ""),
|
|
"n": int(r.get("n") or 0),
|
|
"codes": int(r.get("c") or 0),
|
|
}
|
|
)
|
|
else:
|
|
out.append({"key": str(r[0] or ""), "n": int(r[1] or 0), "codes": int(r[2] or 0)})
|
|
return out
|
|
|
|
|
|
def _safe_nc(db, sql: str, params: tuple) -> Dict[str, Any]:
|
|
try:
|
|
r = db.conn.execute(sql, params).fetchone()
|
|
except Exception:
|
|
return {"n": 0, "codes": 0}
|
|
return _row_nc(r)
|
|
|
|
|
|
def _distinct_codes(db, sql: str, params: tuple) -> Set[str]:
|
|
try:
|
|
rows = db.conn.execute(sql, params).fetchall() or []
|
|
except Exception:
|
|
return set()
|
|
out: Set[str] = set()
|
|
for r in rows:
|
|
if isinstance(r, dict):
|
|
c = str(r.get("code") or "").strip()
|
|
else:
|
|
c = str(r[0] or "").strip()
|
|
if c:
|
|
out.add(c)
|
|
return out
|
|
|
|
|
|
def _env_flags(db) -> Dict[str, Any]:
|
|
"""운영설정 스냅샷 (표시용). get_env_* 실패해도 탭은 동작."""
|
|
out: Dict[str, Any] = {}
|
|
try:
|
|
from kis_trader.utils.env import get_env_bool, get_env_float, get_env_from_db
|
|
except Exception:
|
|
return out
|
|
for k, dflt in (
|
|
("LIVE_TICK_PROVIDER", "kis"),
|
|
("LIVE_OB_PROVIDER", "kis"),
|
|
("LS_WS_ORDERBOOK_SAVE_MODE", "tick"),
|
|
("WS_ORDERBOOK_SAVE_MODE", "tick"),
|
|
):
|
|
try:
|
|
out[k] = str(get_env_from_db(k, dflt) or dflt)
|
|
except Exception:
|
|
out[k] = dflt
|
|
for k, dflt in (
|
|
("LS_WS_ORDERBOOK_SAVE", True),
|
|
("WS_ORDERBOOK_SAVE_KIS", True),
|
|
("WS_ORDERBOOK_SAVE_KIWOOM", True),
|
|
("WS_TICK_SAVE_KIS", True),
|
|
("WS_TICK_SAVE_KIWOOM", True),
|
|
("LS_WS_TICK_SAVE", False),
|
|
("LS_WS_CANDLE_SAVE", True),
|
|
("LS_WS_TICK_MIRROR_WS_TICKS", False),
|
|
):
|
|
try:
|
|
out[k] = bool(get_env_bool(k, dflt))
|
|
except Exception:
|
|
out[k] = dflt
|
|
for k, dflt in (
|
|
("LIVE_FEED_FALLBACK_MAX_AGE_SEC", 3.0),
|
|
("WS_ORDERBOOK_TICK_MAX_AGE_SEC", 3.0),
|
|
):
|
|
try:
|
|
out[k] = float(get_env_float(k, dflt) or dflt)
|
|
except Exception:
|
|
out[k] = float(dflt)
|
|
return out
|
|
|
|
|
|
def _vendor_coverage(
|
|
*,
|
|
vendor: str,
|
|
tick: Dict[str, Any],
|
|
ob: Dict[str, Any],
|
|
tick_codes: Optional[Set[str]],
|
|
ob_codes: Optional[Set[str]],
|
|
max_age_sec: float,
|
|
) -> Dict[str, Any]:
|
|
"""같은 벤더 틱 vs 호가 공백·동기율.
|
|
|
|
tick_codes/ob_codes 가 None 이면(빠른 조회) 종목교집합 지표는 null.
|
|
"""
|
|
tick_n = int(tick.get("n") or 0)
|
|
tick_c = int(tick.get("codes") or (len(tick_codes) if tick_codes is not None else 0) or 0)
|
|
ob_n = int(ob.get("n") or 0)
|
|
ob_c = int(ob.get("codes") or (len(ob_codes) if ob_codes is not None else 0) or 0)
|
|
if tick_codes is None or ob_codes is None:
|
|
tick_no_ob_n: Optional[int] = None
|
|
ob_no_tick_n: Optional[int] = None
|
|
tick_no_ob_pct: Optional[float] = None
|
|
else:
|
|
tick_no_ob_n = len(tick_codes - ob_codes)
|
|
ob_no_tick_n = len(ob_codes - tick_codes)
|
|
tick_no_ob_pct = _pct(tick_no_ob_n, tick_c)
|
|
# 호가틱동기 미적재 대리지표: 틱행 대비 호가행이 없는 비율
|
|
# (MODE=tick 에서 snap None / skip_ram / 큐드롭 포함)
|
|
missing_ob_rows = max(0, tick_n - ob_n)
|
|
return {
|
|
"vendor": vendor,
|
|
"tick_n": tick_n,
|
|
"tick_codes": tick_c,
|
|
"ob_n": ob_n,
|
|
"ob_codes": ob_c,
|
|
"tick_codes_no_ob": tick_no_ob_n,
|
|
"tick_codes_no_ob_pct": tick_no_ob_pct,
|
|
"ob_codes_no_tick": ob_no_tick_n,
|
|
"ob_per_tick_row_pct": _pct(ob_n, tick_n),
|
|
"missing_ob_row_pct": _pct(missing_ob_rows, tick_n),
|
|
"avg_tick_per_code": round(tick_n / tick_c, 1) if tick_c else 0.0,
|
|
"avg_ob_per_code": round(ob_n / ob_c, 1) if ob_c else 0.0,
|
|
"orderbook_tick_max_age_sec": float(max_age_sec),
|
|
"sample_tick_no_ob_codes": (
|
|
sorted(tick_codes - ob_codes)[:30]
|
|
if tick_codes is not None and ob_codes is not None
|
|
else []
|
|
),
|
|
}
|
|
|
|
|
|
def _ob_per_tick_ratio(ob_n: int, tick_n: int) -> Optional[float]:
|
|
"""호가행/틱행 (소수). 틱 0이면 None."""
|
|
if tick_n is None or int(tick_n) <= 0:
|
|
return None
|
|
return round(float(ob_n) / float(tick_n), 3)
|
|
|
|
|
|
def _pack_vendor(label: str, tick_n: int, tick_c: int, ob_n: int, ob_c: int) -> Dict[str, Any]:
|
|
tn = int(tick_n or 0)
|
|
on = int(ob_n or 0)
|
|
ratio = _ob_per_tick_ratio(on, tn)
|
|
return {
|
|
"vendor": label,
|
|
"tick_n": tn,
|
|
"tick_codes": int(tick_c or 0),
|
|
"ob_n": on,
|
|
"ob_codes": int(ob_c or 0),
|
|
"ob_per_tick": ratio,
|
|
"ob_per_tick_pct": _pct(on, tn),
|
|
}
|
|
|
|
|
|
|
|
def build_feed_collect_stats(
|
|
db,
|
|
day: Optional[str] = None,
|
|
*,
|
|
heavy: bool = False,
|
|
) -> Dict[str, Any]:
|
|
"""한 거래일(KST YYYYMMDD) 수집 요약 + 증권사 대비.
|
|
|
|
기본(heavy=False): GROUP BY COUNT 만 — 탭 체감용.
|
|
heavy=True: DISTINCT 종목교집합 + 폴백나이(STR_TO_DATE 전수) — 느림, 버튼으로만.
|
|
최근 N일 일괄 GROUP BY 는 제거(틱·호가 테이블 폭주).
|
|
"""
|
|
day8 = _ymd8(day or "")
|
|
like = _like(day8)
|
|
notes: List[str] = []
|
|
heavy = bool(heavy)
|
|
|
|
ticks_by_src = _safe_group(
|
|
db,
|
|
"SELECT source AS k, COUNT(*) AS n, COUNT(DISTINCT code) AS c "
|
|
"FROM ws_ticks WHERE tick_time LIKE %s GROUP BY source ORDER BY n DESC",
|
|
(like,),
|
|
)
|
|
ob_by_src = _safe_group(
|
|
db,
|
|
"SELECT source AS k, COUNT(*) AS n, COUNT(DISTINCT code) AS c "
|
|
"FROM ws_orderbook WHERE snap_time LIKE %s GROUP BY source ORDER BY n DESC",
|
|
(like,),
|
|
)
|
|
fe_by_strat = _safe_group(
|
|
db,
|
|
"SELECT COALESCE(strategy,'') AS k, COUNT(*) AS n, COUNT(DISTINCT code) AS c "
|
|
"FROM ws_orderbook WHERE source=%s AND snap_time LIKE %s "
|
|
"GROUP BY strategy ORDER BY n DESC",
|
|
("filter_eval", like),
|
|
)
|
|
fe_reject = {"n": 0, "codes": 0}
|
|
fe_pass = {"n": 0, "codes": 0}
|
|
if heavy:
|
|
fe_reject = _safe_nc(
|
|
db,
|
|
"SELECT COUNT(*) AS n, COUNT(DISTINCT code) AS c FROM ws_orderbook "
|
|
"WHERE source=%s AND snap_time LIKE %s AND reject_code IS NOT NULL AND reject_code<>''",
|
|
("filter_eval", like),
|
|
)
|
|
fe_pass = _safe_nc(
|
|
db,
|
|
"SELECT COUNT(*) AS n, COUNT(DISTINCT code) AS c FROM ws_orderbook "
|
|
"WHERE source=%s AND snap_time LIKE %s AND (reject_code IS NULL OR reject_code='')",
|
|
("filter_eval", like),
|
|
)
|
|
|
|
ls_ob = _safe_nc(
|
|
db,
|
|
"SELECT COUNT(*) AS n, COUNT(DISTINCT code) AS c FROM ls_ws_orderbook WHERE snap_time LIKE %s",
|
|
(like,),
|
|
)
|
|
try:
|
|
d0 = datetime.strptime(day8, "%Y%m%d")
|
|
d1 = d0.replace(hour=23, minute=59, second=59)
|
|
ls_ticks = _safe_nc(
|
|
db,
|
|
"SELECT COUNT(*) AS n, COUNT(DISTINCT code) AS c FROM ls_ws_ticks "
|
|
"WHERE ts >= %s AND ts <= %s",
|
|
(d0, d1),
|
|
)
|
|
except Exception:
|
|
ls_ticks = {"n": 0, "codes": 0}
|
|
|
|
ls_candles = {"n": 0, "codes": 0}
|
|
try:
|
|
ls_candles = _safe_nc(
|
|
db,
|
|
"SELECT COUNT(*) AS n, COUNT(DISTINCT code) AS c FROM ls_ws_candles "
|
|
"WHERE candle_time LIKE %s",
|
|
(like,),
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
kis_dedicated = _safe_nc(
|
|
db,
|
|
"SELECT COUNT(*) AS n, COUNT(DISTINCT code) AS c FROM kis_ws_orderbook WHERE snap_time LIKE %s",
|
|
(like,),
|
|
)
|
|
|
|
def _pick(rows: List[Dict[str, Any]], *keys: str) -> Dict[str, Any]:
|
|
want = {k.lower() for k in keys}
|
|
n, c = 0, 0
|
|
for r in rows:
|
|
if str(r.get("key") or "").lower() in want:
|
|
n += int(r.get("n") or 0)
|
|
c = max(c, int(r.get("codes") or 0))
|
|
return {"n": n, "codes": c}
|
|
|
|
tick_kis = _pick(ticks_by_src, "kis")
|
|
tick_kw = _pick(ticks_by_src, "kiwoom")
|
|
tick_ls = _pick(ticks_by_src, "ls")
|
|
ob_kis = _pick(ob_by_src, "kis_h0stasp0", "kis")
|
|
ob_kw = _pick(ob_by_src, "kiwoom_0d", "kiwoom")
|
|
ob_fe = _pick(ob_by_src, "filter_eval")
|
|
|
|
env = _env_flags(db)
|
|
max_age = float(env.get("WS_ORDERBOOK_TICK_MAX_AGE_SEC") or 3.0)
|
|
fb_age = float(env.get("LIVE_FEED_FALLBACK_MAX_AGE_SEC") or 3.0)
|
|
|
|
kis_tick_codes: Optional[Set[str]] = None
|
|
kis_ob_codes: Optional[Set[str]] = None
|
|
kw_tick_codes: Optional[Set[str]] = None
|
|
kw_ob_codes: Optional[Set[str]] = None
|
|
ls_tick_codes: Optional[Set[str]] = None
|
|
ls_ob_codes: Optional[Set[str]] = None
|
|
if heavy:
|
|
kis_tick_codes = _distinct_codes(
|
|
db,
|
|
"SELECT DISTINCT code FROM ws_ticks WHERE tick_time LIKE %s AND source=%s",
|
|
(like, "kis"),
|
|
)
|
|
kis_ob_codes = _distinct_codes(
|
|
db,
|
|
"SELECT DISTINCT code FROM ws_orderbook WHERE snap_time LIKE %s AND source=%s",
|
|
(like, "kis_h0stasp0"),
|
|
)
|
|
kw_tick_codes = _distinct_codes(
|
|
db,
|
|
"SELECT DISTINCT code FROM ws_ticks WHERE tick_time LIKE %s AND source=%s",
|
|
(like, "kiwoom"),
|
|
)
|
|
kw_ob_codes = _distinct_codes(
|
|
db,
|
|
"SELECT DISTINCT code FROM ws_orderbook WHERE snap_time LIKE %s AND source=%s",
|
|
(like, "kiwoom_0d"),
|
|
)
|
|
try:
|
|
d0 = datetime.strptime(day8, "%Y%m%d")
|
|
d1 = d0.replace(hour=23, minute=59, second=59)
|
|
ls_tick_codes = _distinct_codes(
|
|
db,
|
|
"SELECT DISTINCT code FROM ls_ws_ticks WHERE ts >= %s AND ts <= %s",
|
|
(d0, d1),
|
|
)
|
|
except Exception:
|
|
ls_tick_codes = set()
|
|
ls_ob_codes = _distinct_codes(
|
|
db,
|
|
"SELECT DISTINCT code FROM ls_ws_orderbook WHERE snap_time LIKE %s",
|
|
(like,),
|
|
)
|
|
|
|
coverage = [
|
|
_vendor_coverage(
|
|
vendor="kis",
|
|
tick=tick_kis,
|
|
ob=ob_kis,
|
|
tick_codes=kis_tick_codes,
|
|
ob_codes=kis_ob_codes,
|
|
max_age_sec=max_age,
|
|
),
|
|
_vendor_coverage(
|
|
vendor="kiwoom",
|
|
tick=tick_kw,
|
|
ob=ob_kw,
|
|
tick_codes=kw_tick_codes,
|
|
ob_codes=kw_ob_codes,
|
|
max_age_sec=max_age,
|
|
),
|
|
_vendor_coverage(
|
|
vendor="ls",
|
|
tick=ls_ticks,
|
|
ob=ls_ob,
|
|
tick_codes=ls_tick_codes,
|
|
ob_codes=ls_ob_codes,
|
|
max_age_sec=max_age,
|
|
),
|
|
]
|
|
vendor_matrix = {
|
|
"day8": day8,
|
|
"day": f"{day8[:4]}-{day8[4:6]}-{day8[6:8]}",
|
|
"vendors": [
|
|
_pack_vendor(
|
|
"kis",
|
|
int(tick_kis.get("n") or 0),
|
|
int(tick_kis.get("codes") or 0),
|
|
int(ob_kis.get("n") or 0),
|
|
int(ob_kis.get("codes") or 0),
|
|
),
|
|
_pack_vendor(
|
|
"kiwoom",
|
|
int(tick_kw.get("n") or 0),
|
|
int(tick_kw.get("codes") or 0),
|
|
int(ob_kw.get("n") or 0),
|
|
int(ob_kw.get("codes") or 0),
|
|
),
|
|
_pack_vendor(
|
|
"ls",
|
|
int(ls_ticks.get("n") or 0),
|
|
int(ls_ticks.get("codes") or 0),
|
|
int(ls_ob.get("n") or 0),
|
|
int(ls_ob.get("codes") or 0),
|
|
),
|
|
],
|
|
"filter_eval_n": int(ob_fe.get("n") or 0),
|
|
"filter_eval_codes": int(ob_fe.get("codes") or 0),
|
|
"kis_tick": int(tick_kis.get("n") or 0),
|
|
"kis_ob": int(ob_kis.get("n") or 0),
|
|
"kis_ob_tick": _ob_per_tick_ratio(int(ob_kis.get("n") or 0), int(tick_kis.get("n") or 0)),
|
|
"kw_tick": int(tick_kw.get("n") or 0),
|
|
"kw_ob": int(ob_kw.get("n") or 0),
|
|
"kw_ob_tick": _ob_per_tick_ratio(int(ob_kw.get("n") or 0), int(tick_kw.get("n") or 0)),
|
|
"ls_tick": int(ls_ticks.get("n") or 0),
|
|
"ls_ob": int(ls_ob.get("n") or 0),
|
|
"ls_ob_tick": _ob_per_tick_ratio(int(ls_ob.get("n") or 0), int(ls_ticks.get("n") or 0)),
|
|
}
|
|
|
|
if heavy and kis_tick_codes is not None and kw_tick_codes is not None:
|
|
both_tick = len(kis_tick_codes & kw_tick_codes)
|
|
overlap = {
|
|
"both_tick_codes": both_tick,
|
|
"kis_only_tick_codes": len(kis_tick_codes - kw_tick_codes),
|
|
"kiwoom_only_tick_codes": len(kw_tick_codes - kis_tick_codes),
|
|
"both_ob_codes": len((kis_ob_codes or set()) & (kw_ob_codes or set())),
|
|
"kis_only_ob_codes": len((kis_ob_codes or set()) - (kw_ob_codes or set())),
|
|
"kiwoom_only_ob_codes": len((kw_ob_codes or set()) - (kis_ob_codes or set())),
|
|
}
|
|
else:
|
|
overlap = {
|
|
"both_tick_codes": None,
|
|
"kis_only_tick_codes": None,
|
|
"kiwoom_only_tick_codes": None,
|
|
"both_ob_codes": None,
|
|
"kis_only_ob_codes": None,
|
|
"kiwoom_only_ob_codes": None,
|
|
"deferred": True,
|
|
}
|
|
|
|
notes.append(
|
|
"틱·호가 DB는 벤더별 SAVE로 각각 적재 (2·3차 spill일 때만 쌓이는 구조 아님). "
|
|
f"읽기 폴백나이={fb_age:g}s · 호가틱동기 max_age={max_age:g}s."
|
|
)
|
|
if not heavy:
|
|
notes.append(
|
|
"빠른 조회(기본): 나이컷·호가0종목 교집합은 「상세 집계」로. "
|
|
"인덱스(market,code,time)는 종목단위 조회용이라 당일 전수 COUNT/STR_TO_DATE는 여전히 무겁다."
|
|
)
|
|
for cov in coverage:
|
|
if int(cov.get("tick_codes") or 0) <= 0 and int(cov.get("ob_n") or 0) <= 0:
|
|
continue
|
|
no_ob = cov.get("tick_codes_no_ob")
|
|
miss = cov.get("missing_ob_row_pct")
|
|
if no_ob is None:
|
|
if int(cov.get("tick_n") or 0) > 0 and miss is not None and float(miss) >= 5.0:
|
|
notes.append(
|
|
f"[{cov['vendor']}] 틱행 대비 호가행 공백≈{miss}% "
|
|
f"(호가행/틱행={cov.get('ob_per_tick_row_pct')}%). "
|
|
"호가0종목 수는 상세 집계에서 확인."
|
|
)
|
|
continue
|
|
no_ob_i = int(no_ob or 0)
|
|
if int(cov.get("tick_n") or 0) > 0 and (
|
|
no_ob_i > 0 or (miss is not None and float(miss) >= 5.0)
|
|
):
|
|
notes.append(
|
|
f"[{cov['vendor']}] 틱종목 중 호가0종목 {no_ob_i}/{cov['tick_codes']}"
|
|
f"({cov.get('tick_codes_no_ob_pct')}%) · "
|
|
f"틱행 대비 호가행 공백≈{miss}% "
|
|
f"(호가행/틱행={cov.get('ob_per_tick_row_pct')}%). "
|
|
"원인후보: MODE=tick에서 해당벤더 호가RAM이 max_age 밖·미수신, "
|
|
"또는 체결 lag>폴백나이로 호가틱동기 skip(틱DB는 기본 유지)."
|
|
)
|
|
if cov.get("vendor") == "ls" and int(cov.get("ob_n") or 0) == 0 and int(cov.get("tick_n") or 0) == 0:
|
|
notes.append("[ls] 당일 틱·호가 DB 0 — hold외·Bye·SAVE OFF·구독 실패 점검.")
|
|
|
|
if int(ls_ob.get("n") or 0) == 0 and env.get("LS_WS_ORDERBOOK_SAVE"):
|
|
mode = str(env.get("LS_WS_ORDERBOOK_SAVE_MODE") or "tick").lower()
|
|
if mode in ("tick", "on_tick", "tick_sync", "sync"):
|
|
notes.append(
|
|
"LS 호가 SAVE=ON·MODE=tick 인데 당일 0건 → UH1만으로 DB 안 씀. "
|
|
"체결 콜백 필요. Bye/구독 빈약이면 0."
|
|
)
|
|
else:
|
|
notes.append("LS 호가 SAVE=ON 인데 당일 0건 → Bye·구독·interval 스로틀 점검.")
|
|
if not env.get("LS_WS_TICK_SAVE"):
|
|
notes.append("LS_WS_TICK_SAVE=false (ls_ws_ticks 미적재). ON이면 구독전체 적재.")
|
|
else:
|
|
notes.append("LS_WS_TICK_SAVE=true → 구독 종목 US3를 ls_ws_ticks 에 적재 (영구만이 아님).")
|
|
|
|
if heavy:
|
|
age_cut = _feed_age_cut_stats(db, day8, fb_age)
|
|
else:
|
|
age_cut = {
|
|
"age_sec": fb_age,
|
|
"empty_minutes_all_fail": None,
|
|
"rows": [],
|
|
"deferred": True,
|
|
"note": (
|
|
f"나이={fb_age:g}s · 기본 조회에서는 생략(전수 STR_TO_DATE 스캔). "
|
|
"「상세 집계」로 로드."
|
|
),
|
|
}
|
|
|
|
return {
|
|
"day": f"{day8[:4]}-{day8[4:6]}-{day8[6:8]}",
|
|
"day8": day8,
|
|
"heavy": heavy,
|
|
"env": env,
|
|
"notes": notes,
|
|
"vendor_matrix": vendor_matrix,
|
|
"recent_days": [],
|
|
"recent_days_n": 0,
|
|
"age_cut": age_cut,
|
|
"summary": {
|
|
"ticks": {
|
|
"kis": tick_kis,
|
|
"kiwoom": tick_kw,
|
|
"ls_mirror": tick_ls,
|
|
"ls_table": ls_ticks,
|
|
},
|
|
"orderbook": {
|
|
"kis_h0stasp0": ob_kis,
|
|
"kiwoom_0d": ob_kw,
|
|
"filter_eval": ob_fe,
|
|
"ls_ws_orderbook": ls_ob,
|
|
"kis_ws_orderbook": kis_dedicated,
|
|
},
|
|
"ls_candles": ls_candles,
|
|
"filter_eval_pass": fe_pass,
|
|
"filter_eval_reject": fe_reject,
|
|
},
|
|
"coverage": coverage,
|
|
"overlap": overlap,
|
|
"ticks_by_source": ticks_by_src,
|
|
"orderbook_by_source": ob_by_src,
|
|
"filter_eval_by_strategy": fe_by_strat,
|
|
}
|