#!/usr/bin/env python3 """ scripts/test_ws_tick_kis_kiwoom_gap.py ===================================== 한투(kis) vs 키움(kiwoom) ``ws_ticks`` 밀도·틱타임/수신갭 비교. 실 WS/주문 호출 없음. DB 조회만. 틱매도 비동기 적용 후 ~1시간 뒤, 매도 시각 전후로 「키움만 비고 한투는 쌓임」이 줄었는지 볼 때 사용. python3 -u scripts/test_ws_tick_kis_kiwoom_gap.py python3 -u scripts/test_ws_tick_kis_kiwoom_gap.py --minutes 60 python3 -u scripts/test_ws_tick_kis_kiwoom_gap.py --start '2026-08-19 09:00:00' --end '2026-08-19 10:00:00' python3 -u scripts/test_ws_tick_kis_kiwoom_gap.py --code 025980 --minutes 60 로그: logs/test_ws_tick_kis_kiwoom_gap_YYYYMMDD_HHMMSS.log """ from __future__ import annotations import argparse import sys from datetime import datetime, timedelta from pathlib import Path from typing import Any, Dict, List, Optional, Tuple ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) def _tee_log() -> Path: log_dir = ROOT / "logs" log_dir.mkdir(exist_ok=True) ts = datetime.now().strftime("%Y%m%d_%H%M%S") path = log_dir / ("test_ws_tick_kis_kiwoom_gap_%s.log" % ts) fp = path.open("w", encoding="utf-8") class _Tee: def __init__(self, *files: Any) -> None: self.files = files def write(self, s: str) -> None: for f in self.files: f.write(s) f.flush() def flush(self) -> None: for f in self.files: f.flush() sys.stdout = _Tee(sys.__stdout__, fp) # type: ignore[assignment] sys.stderr = _Tee(sys.__stderr__, fp) # type: ignore[assignment] print("로그 파일:", path) return path def _cols(db: Any, table: str) -> List[str]: rows = db.conn.execute("SHOW COLUMNS FROM `%s`" % table).fetchall() return [str(r["Field"]) for r in rows] def _parse_ts(raw: Any) -> Optional[datetime]: s = str(raw or "").strip() if not s: return None for fmt in ( "%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S", "%Y%m%d%H%M%S", ): try: return datetime.strptime(s[:26] if "." in s else s[:19], fmt) except Exception: continue try: return datetime.fromisoformat(s.replace("Z", "")) except Exception: return None def _gaps(ts_list: List[datetime]) -> List[float]: if len(ts_list) < 2: return [] ordered = sorted(ts_list) out: List[float] = [] for i in range(1, len(ordered)): out.append((ordered[i] - ordered[i - 1]).total_seconds()) return out def _gap_stats(gaps: List[float]) -> Dict[str, Any]: if not gaps: return {"n": 0, "max": None, "p50": None, "gt2": 0, "gt5": 0} sg = sorted(gaps) n = len(sg) p50 = sg[n // 2] return { "n": n, "max": round(max(sg), 3), "p50": round(p50, 3), "gt2": sum(1 for x in sg if x >= 2.0), "gt5": sum(1 for x in sg if x >= 5.0), } def _pick_time_col(cols: List[str]) -> Optional[str]: for c in ( "created_at", "updated_at", "order_time", "ord_time", "submitted_at", "ts", "insert_time", ): if c in cols: return c return None def main() -> int: ap = argparse.ArgumentParser(description="kis vs kiwoom ws_ticks 밀도·갭") ap.add_argument("--minutes", type=int, default=60, help="끝시각 기준 최근 N분 (start/end 없을 때)") ap.add_argument("--start", default="", help="YYYY-MM-DD HH:MM:SS") ap.add_argument("--end", default="", help="YYYY-MM-DD HH:MM:SS") ap.add_argument("--code", default="", help="6자리 종목. 비우면 겹치는 종목 상위") ap.add_argument("--limit-codes", type=int, default=15, help="겹치는 종목 리포트 개수") args = ap.parse_args() now = datetime.now() if str(args.start or "").strip() and str(args.end or "").strip(): start_s = str(args.start).strip() end_s = str(args.end).strip() else: end_dt = now start_dt = end_dt - timedelta(minutes=max(1, int(args.minutes or 60))) start_s = start_dt.strftime("%Y-%m-%d %H:%M:%S") end_s = end_dt.strftime("%Y-%m-%d %H:%M:%S") from database import TradeDB db = TradeDB() print("구간:", start_s, "~", end_s) print("code:", args.code or "(겹치는 종목 자동)") tick_cols = _cols(db, "ws_ticks") print("SHOW COLUMNS ws_ticks:", tick_cols) need = {"code", "source", "recv_ts"} missing = sorted(need - set(tick_cols)) if missing: print("실패: ws_ticks 컬럼 없음", missing) return 1 has_tick_time = "tick_time" in tick_cols # 전체 종목수·행수 tot = db.conn.execute( """ SELECT COUNT(*) n, SUM(source='kiwoom') n_kw, SUM(source='kis') n_kis, COUNT(DISTINCT CASE WHEN source='kiwoom' THEN code END) codes_kw, COUNT(DISTINCT CASE WHEN source='kis' THEN code END) codes_kis, COUNT(DISTINCT code) codes_either FROM ws_ticks WHERE recv_ts >= %s AND recv_ts < %s AND source IN ('kiwoom','kis') """, (start_s, end_s), ).fetchone() print("\n== 구간 합계 ==") print(dict(tot or {})) n_kw = int((tot or {}).get("n_kw") or 0) n_kis = int((tot or {}).get("n_kis") or 0) ratio = round(n_kis / n_kw, 3) if n_kw else None print("kis/kiwoom 행수비:", ratio, "(1에 가까울수록 밀도 비슷. 예전에 ~1.7배면 한투가 더 촘촘)") split = db.conn.execute( """ SELECT SUM(srcs='kis') only_kis, SUM(srcs='kiwoom') only_kw, SUM(srcs='both') both_n FROM ( SELECT code, CASE WHEN SUM(source='kis')>0 AND SUM(source='kiwoom')>0 THEN 'both' WHEN SUM(source='kis')>0 THEN 'kis' ELSE 'kiwoom' END srcs FROM ws_ticks WHERE recv_ts >= %s AND recv_ts < %s AND source IN ('kiwoom','kis') GROUP BY code ) z """, (start_s, end_s), ).fetchone() print("종목 커버 키움만/한투만/둘다:", dict(split or {})) code_filter = str(args.code or "").strip() if code_filter: codes = [code_filter] else: rows = db.conn.execute( """ SELECT code, SUM(source='kiwoom') n_kw, SUM(source='kis') n_kis FROM ws_ticks WHERE recv_ts >= %s AND recv_ts < %s AND source IN ('kiwoom','kis') GROUP BY code HAVING SUM(source='kiwoom') > 0 AND SUM(source='kis') > 0 ORDER BY (SUM(source='kis') / GREATEST(SUM(source='kiwoom'), 1)) DESC LIMIT %s """, (start_s, end_s, int(args.limit_codes)), ).fetchall() codes = [str(r["code"]) for r in rows] print("\n== 겹치는 종목 kis/kw 비 상위 ==") for r in rows: d = dict(r) kw = int(d.get("n_kw") or 0) ki = int(d.get("n_kis") or 0) d["kis_per_kw"] = round(ki / kw, 2) if kw else None print(d) print("\n== 종목별 recv_ts 갭 (키움 vs 한투) ==") stall_codes = [] for code in codes: raw = db.conn.execute( """ SELECT source, recv_ts, tick_time FROM ws_ticks WHERE code=%s AND recv_ts >= %s AND recv_ts < %s AND source IN ('kiwoom','kis') ORDER BY recv_ts """, (code, start_s, end_s), ).fetchall() if has_tick_time else db.conn.execute( """ SELECT source, recv_ts FROM ws_ticks WHERE code=%s AND recv_ts >= %s AND recv_ts < %s AND source IN ('kiwoom','kis') ORDER BY recv_ts """, (code, start_s, end_s), ).fetchall() by_src: Dict[str, List[datetime]] = {"kiwoom": [], "kis": []} by_fid: Dict[str, List[str]] = {"kiwoom": [], "kis": []} for r in raw: src = str(r.get("source") or "") dtv = _parse_ts(r.get("recv_ts")) if src in by_src and dtv is not None: by_src[src].append(dtv) if has_tick_time and src in by_fid: by_fid[src].append(str(r.get("tick_time") or "")) kw_g = _gap_stats(_gaps(by_src["kiwoom"])) ki_g = _gap_stats(_gaps(by_src["kis"])) rec = { "code": code, "n_kw": len(by_src["kiwoom"]), "n_kis": len(by_src["kis"]), "kis_per_kw": round(len(by_src["kis"]) / len(by_src["kiwoom"]), 2) if by_src["kiwoom"] else None, "kw_recv_gap": kw_g, "kis_recv_gap": ki_g, } # 키움 max gap 이 한투보다 훨씬 크면 수신 스톨 후보 kw_max = kw_g.get("max") ki_max = ki_g.get("max") if kw_max is not None and ki_max is not None and kw_max >= 5.0 and kw_max > (ki_max * 2.0 + 1.0): rec["stall_candidate"] = True stall_codes.append(rec) print(rec) if has_tick_time and (args.code or rec.get("stall_candidate")): # FID tick_time 이 같은 초로 밀집 vs recv 공백 print(" tick_time 샘플 kw", by_fid["kiwoom"][:3], "...", by_fid["kiwoom"][-3:] if by_fid["kiwoom"] else []) print(" tick_time 샘플 kis", by_fid["kis"][:3], "...", by_fid["kis"][-3:] if by_fid["kis"] else []) print("\n== 스톨 후보 (키움 recv 갭>=5s 이고 한투 갭의 2배+1 초과) ==") if not stall_codes: print("(없음)") else: for r in stall_codes: print(r) # 매도 시각 근처 try: ocols = _cols(db, "orders") print("\nSHOW COLUMNS orders:", ocols) tcol = _pick_time_col(ocols) side_col = "side" if "side" in ocols else ("ord_side" if "ord_side" in ocols else None) code_col = "code" if "code" in ocols else None if tcol and side_col and code_col: for c in (tcol, side_col, code_col): if not str(c).replace("_", "").isalnum(): raise ValueError("bad col %s" % c) sql = ( "SELECT `" + tcol + "` AS ts, `" + code_col + "` AS code, `" + side_col + "` AS side FROM orders WHERE `" + tcol + "` >= %s AND `" + tcol + "` < %s" ) sells = db.conn.execute(sql, (start_s, end_s)).fetchall() sell_rows = [dict(r) for r in sells if str(r.get("side") or "").upper() in ("SELL", "01", "sell")] if not sell_rows: sell_rows = [dict(r) for r in sells] print("orders 구간 행", len(sells), "매도후보", len(sell_rows), "시각컬럼", tcol) for r in sell_rows[:20]: print(" ", r) else: print("orders 에서 시각/side/code 컬럼을 못 찾음 — 매도 교차는 스킵") except Exception as ex: print("orders 조회 스킵:", ex) print("\n판독 힌트:") print("- kis/kw 행수비가 1.5~2+ 이고 키움만 max recv 갭이 크면, 예전처럼 키움 수신이 멈춘 패턴.") print("- 틱매도 비동기 적용 후 매도 시각 전후 키움 갭이 한투와 비슷해지면 1~5 효과.") print("- 한투만 종목이 많으면 구독 유니버스 차이(41 vs 100)이지 스톨이 아님.") print("로그 파일은 이 실행 stdout 과 동일") return 0 if __name__ == "__main__": _tee_log() try: raise SystemExit(main()) except SystemExit: raise except Exception: import traceback traceback.print_exc() raise SystemExit(1)