#!/usr/bin/env python3 """ LS 섀도 시세 유니버스 히스토리 수집 + 종목 히스토리 퀄리티 리포트 (단독 실행). 테이블: ``ls_universe_history`` - target_candidates_history(키움 조건식)와 분리 - 슬롯(기본 1분)마다 LS가 실제로 틱/봉을 쌓은 종목을 스냅샷 사용: # 오늘 틱으로 슬롯 백필 + 퀄리티 출력 python3 scripts/collect_ls_universe_history.py --day 2026-07-27 --backfill --quality # 현재 시각 슬롯 1회 수집만 python3 scripts/collect_ls_universe_history.py --collect # N초마다 수집 (Ctrl+C 종료) python3 scripts/collect_ls_universe_history.py --loop 60 --quality-every 5 # 퀄리티만 (이미 쌓인 테이블/틱/봉 기준) python3 scripts/collect_ls_universe_history.py --day 2026-07-27 --quality """ from __future__ import annotations import argparse import os import sys import time from datetime import datetime, timedelta from typing import Any, Dict, List, Optional, Sequence, Tuple _ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if _ROOT not in sys.path: sys.path.insert(0, _ROOT) DDL = """ CREATE TABLE IF NOT EXISTS ls_universe_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 '', price DOUBLE NOT NULL DEFAULT 0, tick_count INT NOT NULL DEFAULT 0, candle_1m_count INT NOT NULL DEFAULT 0, last_tick_ts DATETIME(3) NULL, last_candle_dt VARCHAR(20) NULL, gap_1m_missing INT NOT NULL DEFAULT 0, source VARCHAR(16) NOT NULL DEFAULT 'ls_ws', note VARCHAR(200) NULL, UNIQUE KEY uq_ls_univ_slot_code (slot_key, code), INDEX idx_ls_univ_slot (slot_key), INDEX idx_ls_univ_code (code), INDEX idx_ls_univ_scan (scan_time) ) CHARACTER SET utf8mb4 """ def _parse_day(s: str) -> str: s = (s or "").strip() if not s: return datetime.now().strftime("%Y-%m-%d") if len(s) == 8 and s.isdigit(): return f"{s[:4]}-{s[5:7]}-{s[6:8]}" if False else f"{s[0:4]}-{s[4:6]}-{s[6:8]}" return s[:10] def _day_compact(day: str) -> str: return day.replace("-", "")[:8] def _slot_key_from_dt(dt: datetime, slot_min: int = 1) -> str: slot_min = max(1, int(slot_min)) m = (dt.minute // slot_min) * slot_min return dt.strftime("%Y%m%d%H") + f"{m:02d}" def _ensure_table(db) -> None: db.conn.execute(DDL) def _name_map(db, codes: Sequence[str]) -> Dict[str, str]: out: Dict[str, str] = {} if not codes: return out # target_candidates 에 있으면 이름 재사용 try: cols = [r["Field"] for r in db.conn.execute("SHOW COLUMNS FROM target_candidates").fetchall()] if "code" in cols and "name" in cols: # chunk codes = [str(c) for c in codes] for i in range(0, len(codes), 200): chunk = codes[i : i + 200] ph = ",".join(["%s"] * len(chunk)) rows = db.conn.execute( f"SELECT code, name FROM target_candidates WHERE code IN ({ph})", tuple(chunk), ).fetchall() for r in rows: out[str(r["code"])] = str(r.get("name") or "") except Exception: pass return out def _market_1m_slots(day: str) -> List[str]: """정규장 09:00~15:29 1분 슬롯 키 목록.""" d = _day_compact(day) out: List[str] = [] t0 = datetime.strptime(d + "0900", "%Y%m%d%H%M") t1 = datetime.strptime(d + "1530", "%Y%m%d%H%M") cur = t0 while cur < t1: out.append(cur.strftime("%Y%m%d%H%M")) cur += timedelta(minutes=1) return out def collect_slot(db, *, slot_key: Optional[str] = None, slot_min: int = 1) -> int: """현재(또는 지정) 슬롯에 LS 틱이 있는 종목을 ls_universe_history 에 upsert.""" _ensure_table(db) now = datetime.now() sk = slot_key or _slot_key_from_dt(now, slot_min) # 슬롯 구간 try: slot_dt = datetime.strptime(sk, "%Y%m%d%H%M") except ValueError: slot_dt = now.replace(second=0, microsecond=0) slot_end = slot_dt + timedelta(minutes=max(1, slot_min)) ts0 = slot_dt.strftime("%Y-%m-%d %H:%M:%S") ts1 = slot_end.strftime("%Y-%m-%d %H:%M:%S") day = slot_dt.strftime("%Y-%m-%d") rows = db.conn.execute( "SELECT a.code AS code, a.tick_count AS tick_count, a.last_tick_ts AS last_tick_ts, " "b.price AS last_price " "FROM (" " SELECT code, COUNT(*) AS tick_count, MAX(ts) AS last_tick_ts " " FROM ls_ws_ticks WHERE ts >= %s AND ts < %s " " GROUP BY code" ") a " "LEFT JOIN ls_ws_ticks b ON b.code = a.code AND b.ts = a.last_tick_ts", (ts0, ts1), ).fetchall() if not rows: return 0 codes = [str(r["code"]) for r in rows] names = _name_map(db, codes) scan_time = now.strftime("%Y-%m-%d %H:%M:%S") n = 0 for r in rows: code = str(r["code"]) # 당일 1분봉 수 + 최신 봉 cinfo = db.conn.execute( "SELECT COUNT(*) n, MAX(datetime) mx FROM ls_ws_candles " "WHERE code=%s AND tf_min=1 AND datetime LIKE %s", (code, f"{day}%"), ).fetchone() c_n = int((cinfo or {}).get("n") or 0) c_mx = (cinfo or {}).get("mx") # 간단 갭: 장 시작~해당 슬롯까지 기대 1분봉 − 보유 expect = sum(1 for s in _market_1m_slots(day) if s <= sk) gap = max(0, expect - c_n) if expect > 0 else 0 price = float(r.get("last_price") or 0) db.conn.execute( "INSERT INTO ls_universe_history " "(slot_key, scan_time, code, name, price, tick_count, candle_1m_count, " " last_tick_ts, last_candle_dt, gap_1m_missing, source, note) " "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) " "ON DUPLICATE KEY UPDATE " "scan_time=VALUES(scan_time), name=VALUES(name), price=VALUES(price), " "tick_count=VALUES(tick_count), candle_1m_count=VALUES(candle_1m_count), " "last_tick_ts=VALUES(last_tick_ts), last_candle_dt=VALUES(last_candle_dt), " "gap_1m_missing=VALUES(gap_1m_missing), note=VALUES(note)", ( sk, scan_time, code, names.get(code, ""), price, int(r.get("tick_count") or 0), c_n, r.get("last_tick_ts"), str(c_mx or "")[:20] or None, int(gap), "ls_ws", f"slot_min={slot_min}", ), ) n += 1 return n def backfill_day(db, day: str, *, slot_min: int = 1) -> Tuple[int, int]: """해당 일의 ls_ws_ticks 를 분 슬롯으로 묶어 히스토리 upsert. (slots, rows)""" _ensure_table(db) d = _day_compact(day) ts0 = f"{day} 00:00:00" ts1 = f"{day} 23:59:59.999" slots = db.conn.execute( "SELECT DATE_FORMAT(ts, '%%Y%%m%%d%%H%%i') sk FROM ls_ws_ticks " "WHERE ts >= %s AND ts <= %s GROUP BY sk ORDER BY sk", (ts0, ts1), ).fetchall() # DATE_FORMAT minute is exact; for slot_min>1 floor in python seen = set() slot_list: List[str] = [] for r in slots: sk_raw = str(r["sk"]) if len(sk_raw) < 12: continue if slot_min <= 1: sk = sk_raw[:12] else: dt = datetime.strptime(sk_raw[:12], "%Y%m%d%H%M") sk = _slot_key_from_dt(dt, slot_min) if sk not in seen: seen.add(sk) slot_list.append(sk) total_rows = 0 for sk in slot_list: if not sk.startswith(d): continue total_rows += collect_slot(db, slot_key=sk, slot_min=slot_min) return len(slot_list), total_rows def quality_report(db, day: str, *, top: int = 20) -> None: """LS 틱/봉/히스토리/키움후보 대비 퀄리티 출력.""" _ensure_table(db) d = _day_compact(day) print("=" * 72) print(f"LS 종목 히스토리 퀄리티 — day={day}") print("=" * 72) tick = db.conn.execute( "SELECT COUNT(*) n, COUNT(DISTINCT code) codes, MIN(ts) mn, MAX(ts) mx " "FROM ls_ws_ticks WHERE ts >= %s AND ts <= %s", (f"{day} 00:00:00", f"{day} 23:59:59.999"), ).fetchone() print(f"[ls_ws_ticks] n={tick['n']:,} codes={tick['codes']} range={tick['mn']} ~ {tick['mx']}") cndl = db.conn.execute( "SELECT COUNT(*) n, COUNT(DISTINCT code) codes, MIN(datetime) mn, MAX(datetime) mx " "FROM ls_ws_candles WHERE tf_min=1 AND datetime LIKE %s", (f"{day}%",), ).fetchone() print( f"[ls_ws_candles 1m] n={cndl['n']:,} codes={cndl['codes']} " f"range={cndl['mn']} ~ {cndl['mx']}" ) hist = db.conn.execute( "SELECT COUNT(*) n, COUNT(DISTINCT code) codes, COUNT(DISTINCT slot_key) slots " "FROM ls_universe_history WHERE slot_key LIKE %s", (f"{d}%",), ).fetchone() print( f"[ls_universe_history] rows={hist['n']:,} codes={hist['codes']} " f"slots={hist['slots']}" ) # 종목별 틱/봉 per = db.conn.execute( "SELECT code, COUNT(*) ticks, MIN(ts) mn, MAX(ts) mx " "FROM ls_ws_ticks WHERE ts >= %s AND ts <= %s " "GROUP BY code ORDER BY ticks DESC LIMIT %s", (f"{day} 00:00:00", f"{day} 23:59:59.999", int(top)), ).fetchall() print(f"\n--- 틱 상위 {top} 종목 ---") print(f"{'code':8} {'ticks':>8} {'candle_1m':>9} {'gap_est':>7} {'last_tick'}") expect_full = len(_market_1m_slots(day)) now_sk = _slot_key_from_dt(datetime.now(), 1) if now_sk.startswith(d): expect_now = sum(1 for s in _market_1m_slots(day) if s <= now_sk) else: expect_now = expect_full for r in per: code = str(r["code"]) cn = db.conn.execute( "SELECT COUNT(*) n FROM ls_ws_candles WHERE code=%s AND tf_min=1 AND datetime LIKE %s", (code, f"{day}%"), ).fetchone()["n"] gap = max(0, int(expect_now) - int(cn)) print( f"{code:8} {int(r['ticks']):8d} {int(cn):9d} {gap:7d} {r['mx']}" ) # 키움 후보 히스토리와 교집합 (오늘) try: kw = db.conn.execute( "SELECT COUNT(DISTINCT code) n FROM target_candidates_history " "WHERE slot_key LIKE %s", (f"{d}%",), ).fetchone()["n"] both = db.conn.execute( "SELECT COUNT(DISTINCT h.code) n FROM ls_universe_history h " "INNER JOIN target_candidates_history t " " ON t.code=h.code AND t.slot_key LIKE %s " "WHERE h.slot_key LIKE %s", (f"{d}%", f"{d}%"), ).fetchone()["n"] ls_only = db.conn.execute( "SELECT COUNT(DISTINCT code) n FROM ls_universe_history WHERE slot_key LIKE %s", (f"{d}%",), ).fetchone()["n"] print(f"\n--- 키움 후보(history) vs LS 히스토리 ---") print(f"키움 distinct codes(today): {kw}") print(f"LS history distinct: {ls_only}") print(f"교집합(대략): {both}") except Exception as e: print(f"(키움 교차 비교 스킵: {e})") # KIS↔LS 갭 (validation) try: v = db.conn.execute( "SELECT COUNT(*) n, " "AVG(ABS(diff_kis_ls_pct)) avg_abs, " "MAX(ABS(diff_kis_ls_pct)) max_abs " "FROM ws_price_validation_ls " "WHERE ts >= %s AND ts <= %s AND diff_kis_ls_pct IS NOT NULL", (f"{day} 00:00:00", f"{day} 23:59:59.999"), ).fetchone() print( f"\n[ws_price_validation_ls] samples={v['n']:,} " f"avg|kis-ls|%={v['avg_abs']} max|kis-ls|%={v['max_abs']}" ) except Exception as e: print(f"(validation_ls 스킵: {e})") # 최근 슬롯 커버 recent = db.conn.execute( "SELECT slot_key, COUNT(*) n FROM ls_universe_history " "WHERE slot_key LIKE %s GROUP BY slot_key ORDER BY slot_key DESC LIMIT 8", (f"{d}%",), ).fetchall() print("\n--- 최근 슬롯 종목수 ---") for r in recent: print(f" {r['slot_key']}: {r['n']} codes") print("=" * 72) def main() -> int: ap = argparse.ArgumentParser(description="LS universe history collect + quality") ap.add_argument("--day", default="", help="YYYY-MM-DD (기본=오늘)") ap.add_argument("--slot-min", type=int, default=1, help="슬롯 분 (기본 1)") ap.add_argument("--collect", action="store_true", help="현재 슬롯 1회 수집") ap.add_argument("--backfill", action="store_true", help="해당일 틱→슬롯 백필") ap.add_argument("--quality", action="store_true", help="퀄리티 리포트 출력") ap.add_argument("--loop", type=int, default=0, help="N초마다 --collect 반복 (0=OFF)") ap.add_argument("--quality-every", type=int, default=0, help="루프 N회마다 quality") ap.add_argument("--top", type=int, default=20, help="퀄리티 상위 종목 수") args = ap.parse_args() day = _parse_day(args.day) from database import TradeDB db = TradeDB() try: _ensure_table(db) print(f"[ok] ls_universe_history ready | day={day} slot_min={args.slot_min}") if args.backfill: ns, nr = backfill_day(db, day, slot_min=max(1, args.slot_min)) print(f"[backfill] slots={ns} upsert_rows≈{nr}") if args.collect and not args.loop: n = collect_slot(db, slot_min=max(1, args.slot_min)) print(f"[collect] upsert codes={n} slot={_slot_key_from_dt(datetime.now(), args.slot_min)}") if args.quality and not args.loop: quality_report(db, day, top=max(5, args.top)) if args.loop and args.loop > 0: i = 0 print(f"[loop] every {args.loop}s — Ctrl+C to stop") while True: n = collect_slot(db, slot_min=max(1, args.slot_min)) sk = _slot_key_from_dt(datetime.now(), args.slot_min) print(f"[{datetime.now():%H:%M:%S}] collect slot={sk} codes={n}") i += 1 if args.quality_every > 0 and i % args.quality_every == 0: quality_report(db, day, top=max(5, args.top)) time.sleep(max(5, args.loop)) # 아무 플래그 없으면 backfill+quality 기본 if not any([args.collect, args.backfill, args.quality, args.loop]): ns, nr = backfill_day(db, day, slot_min=max(1, args.slot_min)) print(f"[backfill] slots={ns} upsert_rows≈{nr}") quality_report(db, day, top=max(5, args.top)) finally: try: db.close() except Exception: pass return 0 if __name__ == "__main__": raise SystemExit(main())