Files
kis_bot/kis_trader/scripts/backfill_trigger_eval_from_log.py
Hwang 61c72a8a4c feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가
변경 사항
----
- _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가
- _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가
- _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가

영향
----
- 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임
- 기존 기능에 대한 영향 없음

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 01:27:00 +09:00

319 lines
12 KiB
Python

"""
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<hms>\d{2}:\d{2}:\d{2})\]\s+"
r"\[kis_trader\.strategy\.(?P<strategy>[A-Z]+)\]\s+"
r"🔍\s+\[(?P<reject>탈락-[^\]]+)\]\s+"
r".*?\s(?P<code>[0-9A-Z]{6}):\s+(?P<msg>.+?)\s*$"
)
# [10:47:18] [kis_trader.strategy.BREAKOUT] 🔍 [탈락-호가수급] 원익IPS(240810) 매수/매도잔량(3호가) 0.83 < 0.85
_REJECT_RE_PAREN = re.compile(
r"\[(?P<hms>\d{2}:\d{2}:\d{2})\]\s+"
r"\[kis_trader\.strategy\.(?P<strategy>[A-Z]+)\]\s+"
r"🔍\s+\[(?P<reject>탈락-[^\]]+)\]\s+"
r".*?\((?P<code>[0-9A-Z]{6})\)\s+(?P<msg>.+?)\s*$"
)
# [09:31:16] [kis_trader.strategy.MOMENTUM] 🎯 [MOMENTUM 시그널] 모헨즈(006920) price=4255 ...
_SIGNAL_RE = re.compile(
r"\[(?P<hms>\d{2}:\d{2}:\d{2})\]\s+"
r"\[kis_trader\.strategy\.(?P<strategy>[A-Z]+)\]\s+"
r"🎯\s+\[[A-Z]+ 시그널\]\s+.*?\((?P<code>[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())