""" kis_trader/scripts/backfill_trigger_eval_from_log.py ==================================================== journalctl 로그에서 호가필터 판정 라인을 파싱 → ws_orderbook(source='log_backfill') 적재. ⚠️ 한계 (정직): - 로그엔 호가 "본체"(10호가/bid3/ask3 절대값)가 없다. - 매 판정마다 "처음 걸린 검사 1개"의 수치만 reject_msg 텍스트로 남는다. - 따라서 본체 재계산 백테엔 못 쓰고, "판정 재생 + 완화방향 근사 파람서치"용이다. 저장 정책: - source='log_backfill' → 본체 기반 백테(source='filter_eval')와 분리. - reject_code/reject_msg(원문 보존)/strategy/snap_time 저장. - 매도벽은 ask3 절대값을 ask_qty_l3 에 best-effort 채움 (있는 정보만). 사용: # ① 파일 1개 → 하루 python -m kis_trader.scripts.backfill_trigger_eval_from_log /tmp/mom_today.txt --ymd 20260626 # ② journalctl 에서 하루 자동 추출 (파일 불필요) python -m kis_trader.scripts.backfill_trigger_eval_from_log --journalctl --ymd 20260626 # ③ journalctl 에서 이번주 5일치 (월~금) 한 방에 python -m kis_trader.scripts.backfill_trigger_eval_from_log --journalctl --from 20260622 --to 20260626 """ from __future__ import annotations import argparse import datetime import re import subprocess import sys from typing import Any, Dict, Iterable, List, Optional from kis_trader.database.db_manager import get_db LOG_BACKFILL_SOURCE = "log_backfill" # 모든 전략(MOMENTUM·BREAKOUT·SHORT) 로그가 적재되는 systemd 유닛 (heartbeat 로그 동일 출처) DEFAULT_JOURNAL_UNIT = "kis_trader_main.service" # [09:02:22] [kis_trader.strategy.MOMENTUM] 🔍 [탈락-호가수급] 세미티에스 0017J0: 매수/매도잔량(3호가) 0.13 < 0.85 _REJECT_RE = re.compile( r"\[(?P\d{2}:\d{2}:\d{2})\]\s+" r"\[kis_trader\.strategy\.(?P[A-Z]+)\]\s+" r"🔍\s+\[(?P탈락-[^\]]+)\]\s+" r".*?\s(?P[0-9A-Z]{6}):\s+(?P.+?)\s*$" ) # [10:47:18] [kis_trader.strategy.BREAKOUT] 🔍 [탈락-호가수급] 원익IPS(240810) 매수/매도잔량(3호가) 0.83 < 0.85 _REJECT_RE_PAREN = re.compile( r"\[(?P\d{2}:\d{2}:\d{2})\]\s+" r"\[kis_trader\.strategy\.(?P[A-Z]+)\]\s+" r"🔍\s+\[(?P탈락-[^\]]+)\]\s+" r".*?\((?P[0-9A-Z]{6})\)\s+(?P.+?)\s*$" ) # [09:31:16] [kis_trader.strategy.MOMENTUM] 🎯 [MOMENTUM 시그널] 모헨즈(006920) price=4255 ... _SIGNAL_RE = re.compile( r"\[(?P\d{2}:\d{2}:\d{2})\]\s+" r"\[kis_trader\.strategy\.(?P[A-Z]+)\]\s+" r"🎯\s+\[[A-Z]+ 시그널\]\s+.*?\((?P[0-9A-Z]{6})\)" ) # 호가필터 단계에서만 발생하는 사유 (백필 대상) _ORDERBOOK_REJECTS = { "탈락-호가수급", "탈락-매도벽", "탈락-호가스프레드", "탈락-매수호가얇음", "탈락-돌파매도벽", "탈락-꼬리지지부족", } # 매도벽: "매도3호가 합 7762주 > 허용 1380주" → ask3 절대값 추출 _ASK3_RE = re.compile(r"합\s+(\d+)주\s+>\s+허용\s+(\d+)주") def _parse_line(line: str, ymd: str) -> Optional[Dict[str, Any]]: m = _REJECT_RE.search(line) if not m: m = _REJECT_RE_PAREN.search(line) if m and m.group("reject") in _ORDERBOOK_REJECTS: hms = m.group("hms").replace(":", "") msg = m.group("msg").strip() ask3 = 0 am = _ASK3_RE.search(msg) if am: ask3 = int(am.group(1)) return { "market": "KR", "code": m.group("code"), "snap_time": ymd + hms, "best_bid": 0, "best_ask": 0, "total_bid_qty": 0, "total_ask_qty": 0, "bid_qty_l3": 0, "ask_qty_l3": ask3, "levels_json": "{}", "source": LOG_BACKFILL_SOURCE, "strategy": m.group("strategy"), "reject_code": m.group("reject"), "reject_msg": msg[:255], "eval_price": 0, } s = _SIGNAL_RE.search(line) if s: hms = s.group("hms").replace(":", "") return { "market": "KR", "code": s.group("code"), "snap_time": ymd + hms, "best_bid": 0, "best_ask": 0, "total_bid_qty": 0, "total_ask_qty": 0, "bid_qty_l3": 0, "ask_qty_l3": 0, "levels_json": "{}", "source": LOG_BACKFILL_SOURCE, "strategy": s.group("strategy"), "reject_code": None, # PASS (호가필터 통과) "reject_msg": "PASS-호가필터통과", "eval_price": 0, } return None def _iter_journalctl_lines(ymd: str, unit: str) -> Iterable[str]: """journalctl 에서 해당 거래일(ymd) 하루치 로그 라인 스트리밍. 로그 메시지엔 날짜가 없고 ``[HH:MM:SS]`` 만 있으므로, journalctl ``--since/--until`` 로 하루 범위를 잘라 ymd 를 확정한다. (호스트 TZ=KST 기준 — 거래일과 동일) """ since = f"{ymd[0:4]}-{ymd[4:6]}-{ymd[6:8]} 00:00:00" until = f"{ymd[0:4]}-{ymd[4:6]}-{ymd[6:8]} 23:59:59" cmd = [ "journalctl", "-u", unit, "--no-pager", "-o", "cat", "--since", since, "--until", until, ] proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="ignore", bufsize=1, ) assert proc.stdout is not None try: for line in proc.stdout: yield line finally: proc.stdout.close() proc.wait() def _parse_lines(lines: Iterable[str], ymd: str, *, strategy_only: str = "") -> tuple: """라인 이터러블 → (items, stats). 파일/journalctl 공통 코어.""" items: List[Dict[str, Any]] = [] stats: Dict[str, int] = {"파싱": 0, "거절": 0, "통과": 0, "스킵전략": 0} for line in lines: row = _parse_line(line, ymd) if row is None: continue if strategy_only and row["strategy"] != strategy_only.upper(): stats["스킵전략"] += 1 continue stats["파싱"] += 1 if row["reject_code"]: stats["거절"] += 1 else: stats["통과"] += 1 items.append(row) return items, stats def _insert_items(db, items: List[Dict[str, Any]], ymd: str) -> int: """동일 ymd+source 기존 백필 삭제 후 재적재 (idempotent).""" try: db.conn.execute( "DELETE FROM ws_orderbook WHERE source=%s AND snap_time LIKE %s", (LOG_BACKFILL_SOURCE, ymd + "%"), ) except Exception as e: print("기존 백필 삭제 경고:", e) inserted = 0 BATCH = 200 for i in range(0, len(items), BATCH): chunk = items[i:i + BATCH] if hasattr(db.raw, "insert_ws_orderbook_eval_batch"): inserted += db.raw.insert_ws_orderbook_eval_batch(chunk) else: inserted += db.raw.insert_ws_orderbook_batch(chunk) return inserted def backfill_one_day( ymd: str, *, log_path: str = "", use_journalctl: bool = False, unit: str = DEFAULT_JOURNAL_UNIT, strategy_only: str = "", dry_run: bool = False, db=None, ) -> Dict[str, int]: """하루치 백필 (파일 또는 journalctl 소스).""" if db is None: db = get_db() if hasattr(db.raw, "migrate_trigger_eval_columns"): db.raw.migrate_trigger_eval_columns() if use_journalctl: lines: Iterable[str] = _iter_journalctl_lines(ymd, unit) src_label = f"journalctl:{unit}" items, stats = _parse_lines(lines, ymd, strategy_only=strategy_only) else: src_label = log_path with open(log_path, "r", encoding="utf-8", errors="ignore") as f: items, stats = _parse_lines(f, ymd, strategy_only=strategy_only) if dry_run: print(f"[DRY-RUN] {ymd} ({src_label}) 파싱 {stats['파싱']}건 " f"(거절 {stats['거절']}, 통과 {stats['통과']}) — INSERT 안 함") for r in items[:5]: print(" 샘플:", r["snap_time"], r["strategy"], r["code"], r["reject_code"] or "PASS", "|", r["reject_msg"]) return stats inserted = _insert_items(db, items, ymd) stats["적재"] = inserted print(f"✅ 백필 완료: {ymd} ({src_label}) → ws_orderbook(log_backfill) {inserted}건 " f"(거절 {stats['거절']}, 통과 {stats['통과']})") return stats # 하위호환 — 기존 호출부(파일+ymd) 유지 def backfill(log_path: str, ymd: str, *, strategy_only: str = "", dry_run: bool = False) -> Dict[str, int]: return backfill_one_day( ymd, log_path=log_path, use_journalctl=False, strategy_only=strategy_only, dry_run=dry_run, ) def _ymd_range(start_ymd: str, end_ymd: str) -> List[str]: """start~end (포함) 평일(월~금)만 YYYYMMDD 리스트. (주말 자동 스킵)""" d0 = datetime.datetime.strptime(start_ymd, "%Y%m%d").date() d1 = datetime.datetime.strptime(end_ymd, "%Y%m%d").date() out: List[str] = [] cur = d0 one = datetime.timedelta(days=1) while cur <= d1: if cur.weekday() < 5: # 0=월 ~ 4=금 (주말 제외) out.append(cur.strftime("%Y%m%d")) cur += one return out def main(argv: Optional[List[str]] = None) -> int: ap = argparse.ArgumentParser(description="호가필터 판정 로그 → ws_orderbook(log_backfill) 백필") ap.add_argument("log_path", nargs="?", default="", help="로그 파일 경로 (단일일·파일소스 시). --journalctl 사용 시 생략") ap.add_argument("--ymd", help="거래일 YYYYMMDD (단일일)") ap.add_argument("--from", dest="ymd_from", help="시작일 YYYYMMDD (범위, journalctl 권장)") ap.add_argument("--to", dest="ymd_to", help="종료일 YYYYMMDD (범위)") ap.add_argument("--journalctl", action="store_true", help="파일 대신 journalctl 에서 추출 (날짜별 자동 범위)") ap.add_argument("--unit", default=DEFAULT_JOURNAL_UNIT, help="journalctl 유닛명") ap.add_argument("--strategy", default="", help="특정 전략만 (예: MOMENTUM). 미지정=전체") ap.add_argument("--dry-run", action="store_true", help="INSERT 없이 파싱 통계만") args = ap.parse_args(argv) # 범위 모드 (--from/--to) — journalctl 강제 if args.ymd_from or args.ymd_to: if not (args.ymd_from and args.ymd_to): print("--from 과 --to 는 함께 지정", file=sys.stderr) return 2 for d in (args.ymd_from, args.ymd_to): if len(d) != 8 or not d.isdigit(): print("--from/--to 는 YYYYMMDD 8자리", file=sys.stderr) return 2 days = _ymd_range(args.ymd_from, args.ymd_to) if not days: print("범위 내 평일이 없습니다.", file=sys.stderr) return 2 db = get_db() if hasattr(db.raw, "migrate_trigger_eval_columns"): db.raw.migrate_trigger_eval_columns() total = {"파싱": 0, "거절": 0, "통과": 0, "적재": 0} print(f"📅 범위 백필 {args.ymd_from}~{args.ymd_to} | 평일 {len(days)}일 " f"| 소스={'journalctl:' + args.unit if not args.log_path else args.log_path}") for d in days: st = backfill_one_day( d, log_path=args.log_path, use_journalctl=(args.journalctl or not args.log_path), unit=args.unit, strategy_only=args.strategy, dry_run=args.dry_run, db=db, ) for k in ("파싱", "거절", "통과", "적재"): total[k] += int(st.get(k, 0)) print(f"🧮 합계: 파싱 {total['파싱']}건 (거절 {total['거절']}, 통과 {total['통과']}) " f"| 적재 {total['적재']}건") return 0 # 단일일 모드 if not args.ymd or len(args.ymd) != 8 or not args.ymd.isdigit(): print("--ymd 는 YYYYMMDD 8자리 (또는 --from/--to 범위 사용)", file=sys.stderr) return 2 use_jc = args.journalctl or not args.log_path if not use_jc and not args.log_path: print("파일 경로 또는 --journalctl 중 하나 필요", file=sys.stderr) return 2 backfill_one_day( args.ymd, log_path=args.log_path, use_journalctl=use_jc, unit=args.unit, strategy_only=args.strategy, dry_run=args.dry_run, ) return 0 if __name__ == "__main__": raise SystemExit(main())