Files
kis_bot/scripts/collect_ls_universe_history.py

472 lines
19 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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, since: str = "00:00:00") -> None:
"""LS 틱/봉/히스토리/키움후보 대비 퀄리티 출력."""
_ensure_table(db)
d = _day_compact(day)
start_ts = f"{day} {since}"
end_ts = f"{day} 23:59:59.999"
s_compact = since.replace(":", "")
while len(s_compact) < 6:
s_compact += "0"
start_dt_str = f"{d}{s_compact[:6]}"
start_hm_str = f"{d}{s_compact[:4]}"
print("=" * 72)
print(f"LS 종목 히스토리 퀄리티 — day={day} (분석 기준 시각: {since} ~ 23:59:59)")
print("=" * 72)
print(f"\n[1. 실매매 틱 / 캔들 / 호가 적재 현황 — {day} {since} 이후]")
tick_ls = 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",
(start_ts, end_ts),
).fetchone()
print(f" 🔸 [LS 틱 (ls_ws_ticks)] n={tick_ls['n']:>9,d} | codes={tick_ls['codes']:>4} | range={tick_ls['mn']} ~ {tick_ls['mx']}")
try:
tick_kis = db.conn.execute(
"SELECT COUNT(*) n, COUNT(DISTINCT code) codes, MIN(tick_time) mn, MAX(tick_time) mx "
"FROM ws_ticks WHERE tick_time >= %s AND tick_time <= %s",
(start_dt_str, f"{d}235959"),
).fetchone()
print(f" 🔸 [KIS 틱 (ws_ticks)] n={tick_kis['n']:>9,d} | codes={tick_kis['codes']:>4} | range={tick_kis['mn']} ~ {tick_kis['mx']}")
except Exception as e:
print(f" 🔸 [KIS 틱 (ws_ticks)] 조회스킵({e})")
cndl_ls = 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 >= %s AND datetime <= %s",
(start_ts, end_ts),
).fetchone()
print(f" 🔸 [LS 분봉 (ls_ws_candles)] n={cndl_ls['n']:>9,d} | codes={cndl_ls['codes']:>4} | range={cndl_ls['mn']} ~ {cndl_ls['mx']}")
try:
cndl_kis = db.conn.execute(
"SELECT COUNT(*) n, COUNT(DISTINCT code) codes, MIN(candle_time) mn, MAX(candle_time) mx "
"FROM ws_candles WHERE timeframe=1 AND candle_time >= %s AND candle_time <= %s",
(start_hm_str, f"{d}2359"),
).fetchone()
print(f" 🔸 [KIS 분봉 (ws_candles)] n={cndl_kis['n']:>9,d} | codes={cndl_kis['codes']:>4} | range={cndl_kis['mn']} ~ {cndl_kis['mx']}")
except Exception as e:
print(f" 🔸 [KIS 분봉 (ws_candles)] 조회스킵({e})")
try:
ob_ls = db.conn.execute(
"SELECT COUNT(*) n, COUNT(DISTINCT code) codes, MIN(snap_time) mn, MAX(snap_time) mx "
"FROM ls_ws_orderbook WHERE snap_time >= %s AND snap_time <= %s",
(start_dt_str, f"{d}235959"),
).fetchone()
print(f" 🔸 [LS 호가 (ls_ws_orderbook)] n={ob_ls['n']:>9,d} | codes={ob_ls['codes']:>4} | range={ob_ls['mn']} ~ {ob_ls['mx']}")
except Exception as e:
print(f" 🔸 [LS 호가 (ls_ws_orderbook)] 조회스킵({e})")
try:
ob_kis = db.conn.execute(
"SELECT COUNT(*) n, COUNT(DISTINCT code) codes, MIN(snap_time) mn, MAX(snap_time) mx "
"FROM ws_orderbook WHERE snap_time >= %s AND snap_time <= %s",
(start_dt_str, f"{d}235959"),
).fetchone()
print(f" 🔸 [KIS 호가 (ws_orderbook)] n={ob_kis['n']:>9,d} | codes={ob_kis['codes']:>4} | range={ob_kis['mn']} ~ {ob_kis['mx']}")
except Exception as e:
print(f" 🔸 [KIS 호가 (ws_orderbook)] 조회스킵({e})")
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})")
print("\n[2. 증권사 간 시세 및 레이텐시(수신 지연) 3자 비교 실측]")
def _fmt(val):
return f"{val:.1f}ms" if val is not None else "N/A"
try:
v1 = db.conn.execute(
"SELECT COUNT(*) n, AVG(ABS(diff_pct)) avg_abs, MAX(ABS(diff_pct)) max_abs, "
"AVG(kis_age_ms) kis_ms, AVG(kiwoom_age_ms) kw_ms "
"FROM ws_price_validation WHERE ts >= %s AND ts <= %s AND diff_pct IS NOT NULL",
(start_ts, end_ts),
).fetchone()
if v1 and v1["n"] > 0:
print(f" 1) KIS ↔ 키움 시세 갭 실측 (ws_price_validation, 표본: {v1['n']:,}건)")
print(f" 👉 가격 괴리율: 평균 {v1['avg_abs']:.4f}% (최대 {v1['max_abs']:.4f}%)")
print(f" 👉 수신 지연(Age): KIS 평균 {_fmt(v1['kis_ms'])} vs 키움 평균 {_fmt(v1['kw_ms'])}")
else:
print(" 1) KIS ↔ 키움 (ws_price_validation): 당일 실측 표본 없음")
except Exception as e:
print(f" (ws_price_validation 스킵: {e})")
try:
v2 = db.conn.execute(
"SELECT COUNT(*) n, "
"AVG(ABS(diff_kis_ls_pct)) kis_ls_avg, MAX(ABS(diff_kis_ls_pct)) kis_ls_max, "
"AVG(ABS(diff_kw_ls_pct)) kw_ls_avg, MAX(ABS(diff_kw_ls_pct)) kw_ls_max, "
"AVG(kis_age_ms) kis_ms, AVG(kiwoom_age_ms) kw_ms, AVG(ls_age_ms) ls_ms "
"FROM ws_price_validation_ls "
"WHERE ts >= %s AND ts <= %s AND diff_kis_ls_pct IS NOT NULL",
(start_ts, end_ts),
).fetchone()
if v2 and v2["n"] > 0:
print(f" 2) KIS ↔ 키움 ↔ LS 삼각 시세 갭 실측 (ws_price_validation_ls, 표본: {v2['n']:,}건)")
print(f" 👉 KIS vs LS 가격 괴리율: 평균 {v2['kis_ls_avg']:.4f}% (최대 {v2['kis_ls_max']:.4f}%)")
print(f" 👉 키움 vs LS 가격 괴리율: 평균 {v2['kw_ls_avg']:.4f}% (최대 {v2['kw_ls_max']:.4f}%)")
print(f" 👉 수신 지연(Age): KIS 평균 {_fmt(v2['kis_ms'])} vs 키움 평균 {_fmt(v2['kw_ms'])} vs LS 평균 {_fmt(v2['ls_ms'])}")
else:
print(" 2) KIS ↔ 키움 ↔ LS (ws_price_validation_ls): 당일 실측 표본 없음")
except Exception as e:
print(f" (ws_price_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="퀄리티 상위 종목 수")
ap.add_argument("--since", default="00:00:00", help="이 시각(HH:MM:SS) 이후부터 분석 (기본 00:00:00)")
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), since=args.since)
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), since=args.since)
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), since=args.since)
finally:
try:
db.close()
except Exception:
pass
return 0
if __name__ == "__main__":
raise SystemExit(main())