변경 사항 ---- - _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>
319 lines
12 KiB
Python
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())
|