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>
This commit is contained in:
2026-07-06 01:27:00 +09:00
parent d8ba01afa4
commit 61c72a8a4c
171 changed files with 176914 additions and 7329 deletions

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""
전략 공통 리스크·슬롯 env_config 스냅샷 INSERT.
- 1회 매수금(슬롯): 300만 원 통일
- 1회 최대 손실(금액컷): 20만 원 통일
- SHORT(꼬리): ATR 익절 타이트 + 어깨 0.5%/0.3%
- UPDOW 총 운용 한도: 300만 원
실행:
cd ~/kis_bot && python3 -m kis_trader.scripts.apply_unified_risk_env
저장 위치 (2026-05 분리):
- 공통(API·MM·인프라): env_config
- 전략별: config_scalp / config_short / config_momentum / config_breakout / config_updow
최초 1회: python3 -m kis_trader.scripts.migrate_split_env_config
"""
from __future__ import annotations
import sys
from pathlib import Path
_ROOT = Path(__file__).resolve().parents[2]
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
from database import TradeDB # noqa: E402
PATCH = {
# ── 슬롯 300만 통일 ──
"SLOT_MONEY_DEFAULT": "3000000",
"MOMENTUM_SLOT_MONEY": "3000000",
"BREAKOUT_SLOT_MONEY": "3000000",
"UPDOW_SLOT_MONEY": "3000000",
# UPDOW: 이 금액 안에서만 동시 보유 (하락매수 총 한도)
"UPDOW_MAX_BUY_AMOUNT": "3000000",
"MAX_BUY_AMOUNT_PER_STOCK": "3000000",
# ── 금액 손실컷 20만 통일 (청산 엔진용, 포지션 축소 공식과 분리) ──
"MAX_LOSS_PER_TRADE_KRW": "200000",
"SCALP_MAX_LOSS_PER_TRADE_KRW": "200000",
"MOMENTUM_MAX_LOSS_PER_TRADE_KRW": "200000",
"BREAKOUT_MAX_LOSS_PER_TRADE_KRW": "200000",
"UPDOW_MAX_LOSS_PER_TRADE_KRW": "200000",
# ── SHORT: 작은 익절·어깨 우선 / ATR 익·손 상한 타이트 (0.2%/일 프로필) ──
"TARGET_ATR_MULTIPLIER_TAIL": "2.0",
"STOP_ATR_MULTIPLIER_TAIL": "1.5",
"TAIL_ATR_TP_MAX_PCT": "1.0",
"TAIL_ATR_TP_MIN_PCT": "0.3",
"TAIL_ATR_SL_MAX_PCT": "1.0",
"TAIL_ATR_SL_MIN_PCT": "0.5",
"SHOULDER_MIN_HIGH_PCT": "0.003",
"SHOULDER_CUT_PCT": "0.002",
# ── SHORT 진입 회복률 (HTS 조건검색 후보 → TRIGGER 완화) ──
"MIN_RECOVERY_RATIO_SHORT": "0.45",
"MAX_RECOVERY_RATIO_3M": "0.9",
# ── 익절 호가 지정가 (손절은 시장가 유지) ──
"SELL_USE_ORDERBOOK_ON_PROFIT": "true",
"SELL_ORDERBOOK_BID_LEVELS": "2",
"SELL_ORDERBOOK_DEPTH_MULT": "1.5",
}
def main() -> int:
db = TradeDB()
latest = db.get_latest_env()
base = dict((latest or {}).get("snapshot") or {})
if latest and latest.get("id"):
print(f"기존 env_config id={latest['id']} 복사 후 패치")
merged = {**base, **PATCH}
eid = db.insert_env_snapshot(merged)
db.close()
if not eid:
print("❌ insert_env_snapshot 실패")
return 1
print(f"✅ env_config 저장 완료 (id={eid})")
for k, v in PATCH.items():
print(f" {k} = {v}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,131 @@
"""
kis_trader/scripts/approx_param_search_from_log.py
==================================================
log_backfill(판정 로그 백필) 기반 **근사 파람서치** — 완화 방향 1차 추정 전용.
⚠️ 한계 (정직):
- 매 판정마다 "처음 걸린 검사 1개"의 수치만 있다.
- 따라서 임계값을 **완화**했을 때 "그 검사를 통과로 전환하는 건수의 상한"만 안다.
- 전환 후 다음 검사(미관측) 통과 여부는 모른다 → 실제 매수 증가는 이보다 적다.
- 임계 **강화** 방향은 통과(시그널) 건에 수치가 없어 계산 불가.
→ 정밀 파람서치는 filter_eval(호가 본체)이 쌓인 뒤 별도로.
사용:
python -m kis_trader.scripts.approx_param_search_from_log --ymd 20260626 --strategy MOMENTUM
"""
from __future__ import annotations
import argparse
import re
from collections import Counter
from typing import Any, Dict, List, Optional
from kis_trader.database.db_manager import get_db
LOG_BACKFILL_SOURCE = "log_backfill"
_RATIO_RE = re.compile(r"잔량\(\d+호가\)\s+([\d.]+)\s+<\s+([\d.]+)")
_SPREAD_RE = re.compile(r"스프레드\s+([\d.]+)%\s+>\s+([\d.]+)%")
_WALL_RE = re.compile(r"\s+(\d+)주\s+>\s+허용\s+(\d+)주")
def _load_rows(db, ymd: str, strategy: str) -> List[Dict[str, Any]]:
sql = (
"SELECT snap_time, code, strategy, reject_code, reject_msg "
"FROM ws_orderbook WHERE source=%s AND snap_time LIKE %s"
)
params: List[Any] = [LOG_BACKFILL_SOURCE, ymd + "%"]
strat = (strategy or "").strip().upper()
if strat in ("TAIL", "SHORT"):
sql += " AND strategy IN ('TAIL','SHORT')"
elif strat:
sql += " AND strategy=%s"
params.append(strat)
sql += " ORDER BY snap_time"
return [dict(r) for r in db.conn.execute(sql, tuple(params)).fetchall()]
def run(ymd: str, strategy: str, *, ask_mult_default: float = 3.0) -> None:
db = get_db()
rows = _load_rows(db, ymd, strategy)
if not rows:
print(f"⚠️ log_backfill 데이터 없음 (ymd={ymd}, strategy={strategy}). 먼저 백필 실행.")
return
reason_cnt: Counter = Counter()
passes = 0
ratios: List[float] = [] # 호가수급: 실제 ratio
spreads: List[float] = [] # 호가스프레드: 실제 spread%
walls: List[tuple] = [] # 매도벽: (ask3, allow)
cur_ratio_thr = 0.85
cur_spread_thr = 0.45
for r in rows:
rc = r.get("reject_code")
msg = r.get("reject_msg") or ""
if not rc:
passes += 1
continue
reason_cnt[rc] += 1
if rc == "탈락-호가수급":
m = _RATIO_RE.search(msg)
if m:
ratios.append(float(m.group(1)))
cur_ratio_thr = float(m.group(2))
elif rc == "탈락-호가스프레드":
m = _SPREAD_RE.search(msg)
if m:
spreads.append(float(m.group(1)))
cur_spread_thr = float(m.group(2))
elif rc == "탈락-매도벽":
m = _WALL_RE.search(msg)
if m:
walls.append((int(m.group(1)), int(m.group(2))))
total = len(rows)
print(f"\n===== 근사 파람서치 (ymd={ymd}, strategy={strategy or 'ALL'}) =====")
print(f"호가필터 판정 총 {total}건 | 통과(시그널) {passes} | 거절 {total - passes}")
print("거절 사유 분포:", dict(reason_cnt))
# ── 1) 호가수급(min_bid_ask_ratio) 완화 스윕 ───────────────
print(f"\n[호가수급] 현재 임계 min_bid_ask_ratio = {cur_ratio_thr}")
print(" 임계 낮추면 '호가수급 거절→통과 전환(상한)':")
for thr in (0.85, 0.7, 0.5, 0.35, 0.2, 0.0):
flip = sum(1 for x in ratios if x >= thr)
print(f" ratio>={thr:<4}{flip:>4}/{len(ratios)} 전환")
# ── 2) 스프레드(max_spread_pct) 완화 스윕 ──────────────────
print(f"\n[스프레드] 현재 임계 max_spread_pct = {cur_spread_thr}%")
print(" 임계 높이면 '스프레드 거절→통과 전환(상한)':")
for thr in (0.45, 0.6, 0.8, 1.0, 1.5, 2.0):
flip = sum(1 for x in spreads if x <= thr)
print(f" spread<={thr:<4}% → {flip:>4}/{len(spreads)} 전환")
# ── 3) 매도벽(entry_ask_max_mult) 완화 스윕 ────────────────
print(f"\n[매도벽] 현재 entry_ask_max_mult = {ask_mult_default} (허용=주문수량×배수)")
print(" 배수 높이면 '매도벽 거절→통과 전환(상한)':")
for new_mult in (3.0, 5.0, 8.0, 12.0, 20.0, 50.0):
flip = 0
for ask3, allow in walls:
qty_need = allow / ask_mult_default if ask_mult_default > 0 else 0
new_allow = qty_need * new_mult
if ask3 <= new_allow:
flip += 1
print(f" mult={new_mult:<5}{flip:>4}/{len(walls)} 전환")
print("\n※ 모든 수치는 '해당 검사 통과 전환 상한'이다. 다음 검사(미관측) 통과 여부는")
print(" 포함하지 않으므로 실제 매수 증가는 이보다 적다. 정밀치는 filter_eval 누적 후.")
def main(argv: Optional[List[str]] = None) -> int:
ap = argparse.ArgumentParser(description="log_backfill 기반 근사 파람서치(완화방향)")
ap.add_argument("--ymd", required=True, help="거래일 YYYYMMDD")
ap.add_argument("--strategy", default="MOMENTUM", help="전략 (기본 MOMENTUM)")
ap.add_argument("--ask-mult-default", type=float, default=3.0,
help="당시 entry_ask_max_mult (허용 역산용, 기본 3.0)")
args = ap.parse_args(argv)
run(args.ymd, args.strategy, ask_mult_default=args.ask_mult_default)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,318 @@
"""
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())

View File

@@ -0,0 +1,233 @@
#!/usr/bin/env python3
"""
모멘텀 백테 — baseline vs 스캔 eval 메모이즈 동일성·속도 비교 (프로덕션 코드 수정 없음).
제안 최적화: 틱·호가 없을 때 eval_momentum_buy_at_index 를
(code, signal_idx, daily_cnt, last_exit_dt) 키로 캐시 → 10초 스캔큐 내 중복 연산 제거.
유니버스·포트폴리오·청산 루프는 그대로 유지 → 결과 불변 전제 검증용.
사용:
cd /home/hoon/kis_bot
python3 kis_trader/scripts/compare_momentum_scan_memo.py
python3 kis_trader/scripts/compare_momentum_scan_memo.py --start 2026-06-22 --end 2026-06-26
"""
from __future__ import annotations
import argparse
import copy
import json
import sys
import time
from typing import Any, Dict, List, Optional, Tuple
sys.path.insert(0, "/home/hoon/kis_bot")
from kis_trader.backtest import momentum_backtest_common as mbc
from kis_trader.backtest import momentum_portfolio_backtest as mpb
from kis_trader.backtest.momentum_backtest_common import resolve_momentum_universe
from kis_trader.backtest.param_search_momentum import _load_candles_for_search, _ui_to_engine_params
_orig_eval = mpb.eval_momentum_buy_at_index
_eval_cache: Dict[Tuple, Tuple] = {}
_cache_hits = 0
_cache_miss = 0
def _state_key(state: Dict[str, Any]) -> Tuple:
led = state.get("last_exit_dt")
led_s = led.isoformat() if led is not None else ""
return (int(state.get("daily_cnt", 0) or 0), led_s)
def _params_eval_key(params: Dict[str, Any]) -> Tuple:
"""매수신호에 영향 주는 엔진 파라미터 + indicator cache 객체 id."""
ic = params.get("_indicator_cache")
keys = (
"rsi_period", "mom_rsi_min", "mom_rsi_max", "time_start_hm", "mom_time_end_hm",
"time_end_hm", "cooldown_min", "max_daily", "max_daily_chg", "min_price",
"use_defense_filters", "use_high_chase_filter", "use_daily_range_filter",
"use_ema_filter", "use_rsi_max_filter", "ema_fast_period", "ema_slow_period",
"high_chase_thr", "mom_vol_mult", "mom_vol_win", "pattern_breakout",
"pattern_pullback", "chase_lookback_min", "pullback_lookback_min",
"pullback_min_pct", "pullback_max_pct", "mom_max_from_open_pct",
"mom_min_from_open_pct", "skip_hts_scan_dupes",
"_ob_max_spread_pct", "_ob_min_bid_ask_ratio", "_ob_ask_max_mult",
"_backtest_orderbook_snapshot", "_backtest_program_snapshot",
"_backtest_log_orderbook_verdict",
)
parts: List[Any] = [id(ic)]
for k in keys:
v = params.get(k)
if isinstance(v, dict):
parts.append(id(v))
elif isinstance(v, (list, tuple)):
parts.append(tuple(v) if len(v) < 8 else id(v))
else:
parts.append(v)
return tuple(parts)
def _memo_eval(
candles: List[Dict],
i: int,
params: Dict[str, Any],
state: Dict[str, Any],
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
global _cache_hits, _cache_miss
key = (id(candles), int(i), _state_key(state), _params_eval_key(params))
if key in _eval_cache:
_cache_hits += 1
return _eval_cache[key]
r = _orig_eval(candles, i, params, state)
_eval_cache[key] = r
_cache_miss += 1
return r
def _install_memo(enabled: bool) -> None:
global _cache_hits, _cache_miss
_cache_hits = 0
_cache_miss = 0
_eval_cache.clear()
if enabled:
mpb.eval_momentum_buy_at_index = _memo_eval
else:
mpb.eval_momentum_buy_at_index = _orig_eval
def _trade_key(t: Dict[str, Any]) -> Tuple:
return (
str(t.get("code") or ""),
str(t.get("buy_time") or t.get("entry_time") or ""),
str(t.get("sell_time") or t.get("exit_time") or ""),
)
def _norm_trade(t: Dict[str, Any]) -> Dict[str, Any]:
return {
"code": str(t.get("code") or ""),
"buy_time": str(t.get("buy_time") or t.get("entry_time") or ""),
"sell_time": str(t.get("sell_time") or t.get("exit_time") or ""),
"buy_price": round(float(t.get("buy_price") or t.get("entry") or 0), 4),
"sell_price": round(float(t.get("sell_price") or t.get("exit") or 0), 4),
"qty": int(t.get("qty") or 0),
"pnl": int(round(float(t.get("pnl") or 0))),
"sell_reason": str(t.get("sell_reason") or ""),
"hold_min": round(float(t.get("hold_min") or 0), 1),
}
def _run_once(
cc: Dict,
eng: Dict,
univ: Optional[Dict],
meta: Dict,
*,
use_memo: bool,
) -> Tuple[List[Dict], Dict[str, Any], float, Dict[str, int]]:
_install_memo(use_memo)
t0 = time.time()
tr = mbc.run_momentum_backtest_web_aligned(
cc, copy.deepcopy(eng), univ,
slot_money=300000, fee_rate=0.00015, sell_tax=0.0018,
max_stocks=20, total_budget_krw=6000000,
meta_out=copy.deepcopy(meta),
)
elapsed = time.time() - t0
stats = mbc.summarize_momentum_trades(
tr, total_budget_krw=6000000, period_days=5,
)
cache_info = {"hits": _cache_hits, "misses": _cache_miss}
return tr, stats, elapsed, cache_info
def _compare_trades(base: List[Dict], opt: List[Dict]) -> Dict[str, Any]:
bn = [_norm_trade(t) for t in sorted(base, key=_trade_key)]
on = [_norm_trade(t) for t in sorted(opt, key=_trade_key)]
out: Dict[str, Any] = {
"baseline_count": len(bn),
"memo_count": len(on),
"identical": bn == on,
"diff_samples": [],
}
if bn == on:
return out
n = max(len(bn), len(on))
for i in range(n):
b = bn[i] if i < len(bn) else None
o = on[i] if i < len(on) else None
if b != o:
out["diff_samples"].append({"idx": i, "baseline": b, "memo": o})
if len(out["diff_samples"]) >= 10:
break
return out
def _load_params_from_json() -> Dict[str, Any]:
path = "kis_trader/backtest/results/search_momentum_fast_20260627_015211.json"
with open(path, encoding="utf-8") as f:
return json.load(f)["top"][0]["merged_params"]
def main() -> int:
parser = argparse.ArgumentParser(description="모멘텀 baseline vs scan-eval 메모이즈 비교")
parser.add_argument("--start", default="2026-06-22")
parser.add_argument("--end", default="2026-06-26")
args = parser.parse_args()
sk = args.start.replace("-", "") + "0000"
ek = args.end.replace("-", "") + "2359"
print(f"기간: {args.start} ~ {args.end}")
print("=" * 70)
t_load = time.time()
cc = _load_candles_for_search(args.start, args.end, 3)
univ, src, n_slots, _, _ = resolve_momentum_universe(
sk[:8], ek[:8], use_saved_history=True,
)
mp = _load_params_from_json()
eng = _ui_to_engine_params(mp)
eng.update({
"slot_money": 300000,
"max_stocks": 20,
"total_budget_krw": 6000000,
"portfolio_mode": True,
})
meta = {"start_key": sk, "end_key": ek}
print(f"로드 {time.time() - t_load:.1f}s | 종목 {len(cc)} | 유니버스 {src} {n_slots}슬롯")
print("파라미터: search_momentum_fast 1위 merged (EMA OFF, vol×3, sl1.5%)")
print("=" * 70)
print("\n[1/2] BASELINE (현재 코드 그대로)")
tr_b, st_b, el_b, _ = _run_once(cc, eng, univ, meta, use_memo=False)
print(f" 시간 {el_b:.1f}s | 거래 {st_b['total_trades']} | 손익 {int(st_b['total_pnl']):+,} | "
f"PF {st_b['pf']} | 승률 {st_b['win_rate']}%")
print("\n[2/2] MEMO (eval_momentum_buy_at_index 메모이즈 — 프로덕션 미적용, 스크립트만)")
tr_m, st_m, el_m, cache = _run_once(cc, eng, univ, meta, use_memo=True)
print(f" 시간 {el_m:.1f}s | 거래 {st_m['total_trades']} | 손익 {int(st_m['total_pnl']):+,} | "
f"PF {st_m['pf']} | 승률 {st_m['win_rate']}%")
print(f" eval 캐시 hit {cache['hits']:,} / miss {cache['misses']:,} "
f"(hit률 {100.0 * cache['hits'] / max(1, cache['hits'] + cache['misses']):.1f}%)")
cmp = _compare_trades(tr_b, tr_m)
speedup = el_b / el_m if el_m > 0 else 0.0
print("\n" + "=" * 70)
print("비교 결과")
print(f" 거래건수: baseline {cmp['baseline_count']} vs memo {cmp['memo_count']}")
print(f" 손익: baseline {int(st_b['total_pnl']):+,} vs memo {int(st_m['total_pnl']):+,}")
print(f" PF: baseline {st_b['pf']} vs memo {st_m['pf']}")
print(f" 속도: baseline {el_b:.1f}s → memo {el_m:.1f}s ({speedup:.2f}x)")
print(f" 거래내역 동일: {'✅ YES' if cmp['identical'] else '❌ NO'}")
if not cmp["identical"]:
print("\n ⚠️ 차이 샘플 (최대 10건):")
for d in cmp["diff_samples"]:
print(f" #{d['idx']}")
print(f" baseline: {d['baseline']}")
print(f" memo: {d['memo']}")
print("=" * 70)
return 0 if cmp["identical"] else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -61,7 +61,7 @@ if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from database import TradeDB # noqa: E402
from kis_ws import get_kiwoom_candles_df # noqa: E402
from kis_trader.ws.kis_ws import get_kiwoom_candles_df # noqa: E402
logger = logging.getLogger("fill_kiwoom_candles")

View File

@@ -0,0 +1,190 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
키움 ka10001 로 유통주식수(dstr_stk)를 수집해 ``stock_share_meta`` 테이블에 저장.
백테스트 회전율 필터(BREAKOUT_MIN_TURNOVER_1M_PCT 등)는 유통주식수가 필요하다.
라이브 봇(WSManager)도 동일 테이블을 쓰므로, 백테 전에 한 번 채워두면 웹/파서치와 정합된다.
대상 종목 (기본)
----------------
* ``ws_candles`` 에 기간 내 데이터가 있는 DISTINCT code
* ``--codes`` 로 수동 지정 가능
사용 예
--------
python3 kis_trader/scripts/fill_stock_share_meta.py
python3 kis_trader/scripts/fill_stock_share_meta.py --days 14
python3 kis_trader/scripts/fill_stock_share_meta.py --codes 005930,035810
python3 kis_trader/scripts/fill_stock_share_meta.py --dry-run --codes 035810
주의
----
키움 ka10001 은 초당 호출 한도가 있어(오류 1700 / HTTP 429) 기본 호출 간격 1초.
로그의 ``ka10001 실패`` 대부분은 레이트리밋 — ``--sleep`` 을 늘리거나 재실행하면 된다.
"""
from __future__ import annotations
import argparse
import logging
import random
import sys
import time
from collections import Counter
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, List, Optional, Sequence
HERE = Path(__file__).resolve()
ROOT = HERE.parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from database import TradeDB # noqa: E402
from kis_trader.share.stock_share import ( # noqa: E402
apply_fetched_meta,
codes_missing_dstr,
load_stock_share_meta_map,
)
from kis_trader.utils.env import get_env_float # noqa: E402
from kis_trader.ws.kis_ws import ( # noqa: E402
_get_kiwoom_creds,
fetch_kiwoom_stock_meta_detail,
)
logger = logging.getLogger("fill_stock_share_meta")
def _parse_codes(raw: str) -> List[str]:
return [c.strip() for c in str(raw or "").split(",") if c.strip()]
def _codes_from_ws_candles(db: TradeDB, days: int) -> List[str]:
end = datetime.now()
start = end - timedelta(days=max(1, days))
start_key = start.strftime("%Y%m%d") + "0000"
end_key = end.strftime("%Y%m%d") + "2359"
rows = db.conn.execute(
"SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 "
"AND candle_time >= %s AND candle_time <= %s ORDER BY code",
[start_key, end_key],
).fetchall()
return [str(r["code"]).strip() for r in rows if r.get("code")]
def _format_fail_detail(res: Dict) -> str:
http_st = res.get("http_status")
rc = res.get("return_code")
msg = str(res.get("return_msg") or "").strip()
reason = str(res.get("reason") or "").strip()
parts = [f"reason={reason}"]
if http_st:
parts.append(f"http={http_st}")
if rc is not None:
parts.append(f"rc={rc}")
if msg:
parts.append(f"msg={msg}")
return " | ".join(parts)
def fill_share_meta(
db: TradeDB,
codes: Sequence[str],
*,
dry_run: bool = False,
sleep_sec: Optional[float] = None,
) -> int:
kw_key, kw_secret, kw_mock = _get_kiwoom_creds(db)
if not kw_key or not kw_secret:
logger.error("키움 API 키 없음 — env_config KIWOOM_APP_KEY_* 확인")
return 0
if sleep_sec is None:
sleep_sec = get_env_float("STOCK_SHARE_FILL_SLEEP_SEC", 1.0)
cache = load_stock_share_meta_map(db, codes)
missing = codes_missing_dstr(cache, codes)
if not missing:
logger.info("모든 종목 stock_share_meta 적재 완료 (%d종)", len(codes))
return 0
logger.info(
"ka10001 대상: %d/%d종목 (호출간격 %.2fs, 레이트리밋 시 자동 재시도)",
len(missing), len(codes), sleep_sec,
)
ok = 0
fail_reasons: Counter = Counter()
fail_samples: Dict[str, List[str]] = {}
for i, code in enumerate(missing, 1):
if dry_run:
logger.info("[%d/%d] dry-run %s", i, len(missing), code)
continue
res = fetch_kiwoom_stock_meta_detail(
code, kw_key, kw_secret, is_mock=kw_mock,
)
meta = res.get("meta")
if res.get("ok") and meta:
apply_fetched_meta(cache, db, code, meta)
ok += 1
logger.info(
"[%d/%d] %s flo=%s dstr=%s",
i, len(missing), code, meta.get("flo_stk"), meta.get("dstr_stk"),
)
else:
reason = str(res.get("reason") or "unknown")
fail_reasons[reason] += 1
fail_samples.setdefault(reason, [])
if len(fail_samples[reason]) < 3:
fail_samples[reason].append(code)
logger.warning(
"[%d/%d] %s ka10001 실패 — %s",
i, len(missing), code, _format_fail_detail(res),
)
time.sleep(max(0.1, sleep_sec) + random.uniform(0.05, 0.2))
if fail_reasons:
logger.warning("── 실패 요약 ──")
for reason, cnt in fail_reasons.most_common():
samples = ", ".join(fail_samples.get(reason, [])[:3])
logger.warning(" %s: %d건 (예: %s)", reason, cnt, samples or "-")
if fail_reasons.get("rate_limit_1700", 0) > 0:
logger.warning(
" 💡 대부분 키움 API 호출한도(1700)입니다. "
"--sleep 1.5 이상으로 재실행하거나 잠시 후 이어서 실행하세요.",
)
return ok
def main(argv: Optional[List[str]] = None) -> int:
logging.basicConfig(level=logging.INFO, format="%(message)s")
p = argparse.ArgumentParser(description="stock_share_meta 일괄 적재 (ka10001)")
p.add_argument("--days", type=int, default=7, help="ws_candles 조회 일수 (기본 7)")
p.add_argument("--codes", type=str, default="", help="종목코드 콤마 구분")
p.add_argument("--dry-run", action="store_true", help="API 호출 없이 대상만 출력")
p.add_argument(
"--sleep", type=float, default=None,
help="종목 간 대기초 (기본 env STOCK_SHARE_FILL_SLEEP_SEC=1.0)",
)
args = p.parse_args(argv)
db = TradeDB()
try:
if args.codes:
codes = _parse_codes(args.codes)
else:
codes = _codes_from_ws_candles(db, args.days)
if not codes:
logger.warning("대상 종목 없음")
return 1
n = fill_share_meta(db, codes, dry_run=args.dry_run, sleep_sec=args.sleep)
if not args.dry_run:
logger.info("완료: %d종목 저장 (대상 %d종 중)", n, len(codes))
finally:
db.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""
env_config → env_config(공통) + config_{전략} 분리 마이그레이션.
1) config_scalp / config_short / config_momentum / config_breakout / config_updow 테이블 생성·컬럼 보강
2) 기존 env_config 최신 행 + env_config_ext 값을 테이블별로 INSERT
3) 공통 키만 env_config 에 새 스냅샷 INSERT
실행:
cd ~/kis_bot && python3 -m kis_trader.scripts.migrate_split_env_config
"""
from __future__ import annotations
import sys
from pathlib import Path
_ROOT = Path(__file__).resolve().parents[2]
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
from config_schema import CONFIG_TABLE_NAMES, classify_config_key # noqa: E402
from database import ( # noqa: E402
CONFIG_TABLE_KEYS,
ENV_CONFIG_KEYS,
TradeDB,
)
def _load_legacy_flat(db: TradeDB) -> dict:
"""기존 fat env_config 1행 + env_config_ext."""
flat = {}
try:
row = db.conn.execute(
"SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
).fetchone()
if row:
rk = row.keys() if hasattr(row, "keys") else []
for k in ENV_CONFIG_KEYS:
if k in rk and row[k] is not None and str(row[k]).strip() != "":
flat[k] = str(row[k])
except Exception as e:
print(f"legacy env_config 읽기 실패: {e}")
try:
ext = db.conn.execute(
"SELECT env_key, env_value FROM env_config_ext"
).fetchall()
for er in ext or []:
ek = er["env_key"] if isinstance(er, dict) else er[0]
ev = er["env_value"] if isinstance(er, dict) else er[1]
if ek:
flat[str(ek)] = "" if ev is None else str(ev)
except Exception:
pass
return flat
def main() -> int:
db = TradeDB()
db._migrate_config_table_columns()
flat = _load_legacy_flat(db)
if not flat:
merged = db.get_merged_env_snapshot()
if merged:
flat = dict(merged)
if not flat:
print("❌ 마이그레이션할 env 데이터 없음")
db.close()
return 1
print(f"소스 키 {len(flat)}개 → 테이블별 INSERT")
eid = db.insert_env_snapshot(flat)
if not eid:
print("❌ insert_env_snapshot 실패")
db.close()
return 1
snap = db.get_merged_env_snapshot()
print(f"\n✅ 완료 env_config id={eid}")
print(f"병합 snapshot 키 수: {len(snap)}")
for tbl in CONFIG_TABLE_NAMES:
n = len(CONFIG_TABLE_KEYS.get(tbl, ()))
filled = sum(1 for k in CONFIG_TABLE_KEYS.get(tbl, ()) if snap.get(k))
print(f" {tbl}: 컬럼 {n} / 값 있음 {filled}")
checks = [
"KIS_APP_KEY_REAL",
"SCALP_STOP_LOSS_PCT",
"TAIL_ATR_TP_MAX_PCT",
"SHOULDER_MIN_HIGH_PCT",
"MOMENTUM_SLOT_MONEY",
"UPDOW_MAX_BUY_AMOUNT",
"SELL_USE_ORDERBOOK_ON_PROFIT",
]
print("\n검증:")
for k in checks:
tbl = classify_config_key(k)
print(f" {k} [{tbl}] = {snap.get(k, '(없음)')}")
db.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""
env_config fat 컬럼 정리 — 전략별 config_* 로 이전된 컬럼 제거.
1) 백업 (JSON): merged env, env_config 전체 행, 삭제 대상 KV, config_*, env_config_ext
2) env_config 를 ENV_GLOBAL_KEYS 만 가진 슬림 테이블로 교체
3) _migrate_config_table_columns 로 누락 글로벌 컬럼 추가
실행:
cd ~/kis_bot && python3 -m kis_trader.scripts.prune_env_config_strategy_columns
cd ~/kis_bot && python3 -m kis_trader.scripts.prune_env_config_strategy_columns --dry-run
"""
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Set
_ROOT = Path(__file__).resolve().parents[2]
if str(_ROOT) not in sys.path:
sys.path.insert(0, str(_ROOT))
from config_schema import classify_config_key # noqa: E402
from database import ENV_CONFIG_KEYS, ENV_GLOBAL_KEYS, TradeDB # noqa: E402
def _row_to_dict(row: Any) -> Dict[str, Any]:
if row is None:
return {}
if hasattr(row, "keys"):
return {k: row[k] for k in row.keys()}
return dict(row)
def _json_safe(val: Any) -> Any:
if val is None:
return None
if isinstance(val, (int, float, bool, str)):
return val
return str(val)
def backup_all(db: TradeDB, out_dir: Path) -> Dict[str, Any]:
out_dir.mkdir(parents=True, exist_ok=True)
meta: Dict[str, Any] = {"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
merged = db.get_merged_env_snapshot() or {}
(out_dir / "merged_env_snapshot.json").write_text(
json.dumps(merged, ensure_ascii=False, indent=2), encoding="utf-8",
)
meta["merged_keys"] = len(merged)
phys_cols = set(db.conn.get_columns("env_config"))
phys_cols -= {"id", "created_at"}
global_set = set(ENV_GLOBAL_KEYS)
drop_cols = sorted(c for c in phys_cols if c not in global_set)
keep_cols = [k for k in ENV_GLOBAL_KEYS if k in phys_cols]
meta["physical_cols"] = len(phys_cols)
meta["keep_cols"] = len(keep_cols)
meta["drop_cols"] = len(drop_cols)
(out_dir / "drop_column_names.json").write_text(
json.dumps(drop_cols, ensure_ascii=False, indent=2), encoding="utf-8",
)
rows = db.conn.execute("SELECT * FROM env_config ORDER BY id").fetchall()
full_rows: List[Dict[str, Any]] = []
dropped_kv: List[Dict[str, Any]] = []
for raw in rows or []:
row = _row_to_dict(raw)
rid = row.get("id")
full_rows.append({k: _json_safe(v) for k, v in row.items()})
kv: Dict[str, Any] = {"id": rid, "created_at": row.get("created_at")}
for col in drop_cols:
v = row.get(col)
if v is not None and str(v).strip() != "":
kv[col] = _json_safe(v)
if len(kv) > 2:
dropped_kv.append(kv)
(out_dir / "env_config_full_rows.json").write_text(
json.dumps(full_rows, ensure_ascii=False, indent=2), encoding="utf-8",
)
(out_dir / "env_config_dropped_columns_kv.json").write_text(
json.dumps(dropped_kv, ensure_ascii=False, indent=2), encoding="utf-8",
)
meta["env_config_rows"] = len(full_rows)
for tbl in (
"config_scalp", "config_short", "config_momentum", "config_breakout",
"config_range_break", "config_updow", "config_dbband", "env_config_ext",
):
try:
trows = db.conn.execute(f"SELECT * FROM {tbl}").fetchall()
data = [_row_to_dict(r) for r in (trows or [])]
(out_dir / f"{tbl}.json").write_text(
json.dumps(data, ensure_ascii=False, indent=2, default=str),
encoding="utf-8",
)
meta[f"{tbl}_rows"] = len(data)
except Exception as ex:
meta[f"{tbl}_error"] = str(ex)
(out_dir / "backup_meta.json").write_text(
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8",
)
return meta
def prune_env_config(db: TradeDB, dry_run: bool = False) -> None:
phys = set(db.conn.get_columns("env_config")) - {"id", "created_at"}
keep_in_phys = [k for k in ENV_GLOBAL_KEYS if k in phys]
if not keep_in_phys:
raise RuntimeError("env_config 에 유지할 글로벌 컬럼 없음")
cols_sql = ", ".join(f"`{k}`" for k in keep_in_phys)
gcols_all = ", ".join(f"`{k}` TEXT" for k in ENV_GLOBAL_KEYS)
print(f"유지 컬럼 {len(keep_in_phys)} / ENV_GLOBAL 전체 {len(ENV_GLOBAL_KEYS)}")
print(f"삭제 컬럼 {len(phys - set(ENV_GLOBAL_KEYS))}")
if dry_run:
print("[dry-run] 슬림 교체 스킵")
return
db.conn.execute("DROP TABLE IF EXISTS env_config_new")
db.conn.execute(
f"CREATE TABLE env_config_new ("
f"id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, "
f"created_at VARCHAR(30) NOT NULL, {gcols_all}"
f") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
)
db.conn.execute(
f"INSERT INTO env_config_new (created_at, {cols_sql}) "
f"SELECT created_at, {cols_sql} FROM env_config",
)
cnt = db.conn.execute("SELECT COUNT(*) AS c FROM env_config_new").fetchone()
n = cnt["c"] if isinstance(cnt, dict) else cnt[0]
print(f"env_config_new 적재 {n}")
db.conn.execute("DROP TABLE env_config")
db.conn.execute("RENAME TABLE env_config_new TO env_config")
print("env_config 슬림 교체 완료")
if hasattr(db, "_env_config_cols_cache"):
db._env_config_cols_cache = None
db._migrate_config_table_columns()
try:
from kis_trader.utils.env import invalidate_merged_env_cache
invalidate_merged_env_cache()
except Exception:
pass
after = len(db.conn.get_columns("env_config"))
print(f"env_config 컬럼 수: {after} (id/created_at 포함)")
def main() -> int:
ap = argparse.ArgumentParser(description="env_config 전략 컬럼 백업 후 제거")
ap.add_argument("--dry-run", action="store_true", help="백업만, 테이블 교체 안 함")
ap.add_argument(
"--backup-dir",
default="",
help="백업 디렉터리 (기본: backups/env_config_prune_YYYYMMDD_HHMMSS)",
)
args = ap.parse_args()
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
out_dir = Path(args.backup_dir) if args.backup_dir else _ROOT / "backups" / f"env_config_prune_{ts}"
db = TradeDB()
try:
print(f"백업 → {out_dir}")
meta = backup_all(db, out_dir)
print(json.dumps(meta, ensure_ascii=False, indent=2))
prune_env_config(db, dry_run=args.dry_run)
if not args.dry_run:
snap = db.get_merged_env_snapshot()
print(f"병합 snapshot 키 수: {len(snap)} (기능 유지 확인)")
finally:
db.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,418 @@
#!/usr/bin/env python3
"""
journalctl / 로그 파일에서 조건검색 유니버스 스냅샷을 재구축해
``target_candidates_history`` 를 덮어씁니다.
배경:
- ``ranking_manager`` 와 ``condition_manager`` 가 같은 strategy_id 로 저장하면서
실매(cond)와 다른 유니버스가 DB에 섞임.
- 저장 시 ``sorted(codes)`` 로 HTS 응답 순서가 깨짐.
사용:
# 6/24 MOMENTUM cond 로그만 재구축 (dry-run)
python -m kis_trader.scripts.rebuild_universe_history_from_logs \\
--start 2026-06-24 --end 2026-06-24 --strategy MOMENTUM --dry-run
# 실제 반영
python -m kis_trader.scripts.rebuild_universe_history_from_logs \\
--start 2026-06-24 --end 2026-06-24 --strategy MOMENTUM --apply
# 로그 파일 지정 (journal 대신)
python -m kis_trader.scripts.rebuild_universe_history_from_logs \\
--log-file /path/to.log --start 2026-06-24 --strategy MOMENTUM --apply
"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
# 패키지 실행 보정
if __package__ in (None, ""):
import os
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
JOURNAL_LINE_RE = re.compile(
r"^(?P<month>\w{3})\s+(?P<day>\d{1,2})\s+"
r"(?P<hm>\d{2}:\d{2}:\d{2})\s+"
)
CHANGE_RE = re.compile(
r"\[(?P<hm>\d{2}:\d{2}:\d{2})\]\s+"
r"\[(?P<logger>kis_trader\.(?:cond|rank))\]\s+"
r"🔄\s+\[(?P<sid>[A-Z_]+)\]\s+"
r"\+(?P<enter_n>\d+)\s+/\s+-(?P<exit_n>\d+)\s+"
r"\(현재\s+(?P<count>\d+)종목"
)
ENTER_RE = re.compile(r"ENTER:\s*(.+?)\s*$")
EXIT_RE = re.compile(r"EXIT\s*:\s*(.+?)\s*$")
CODE_NAME_RE = re.compile(r"(\d{6})\(([^)]*)\)")
CODE_ONLY_RE = re.compile(r"\b(\d{6})\b")
@dataclass
class UniverseEvent:
event_time: str # YYYY-MM-DD HH:MM:SS
logger: str
strategy_id: str
enter_n: int
exit_n: int
count: int
enter_codes: List[Tuple[str, str]] = field(default_factory=list)
exit_codes: List[str] = field(default_factory=list)
enter_partial: bool = False
exit_partial: bool = False
def _month_map() -> Dict[str, int]:
return {
"Jan": 1, "Feb": 2, "Mar": 3, "Apr": 4, "May": 5, "Jun": 6,
"Jul": 7, "Aug": 8, "Sep": 9, "Oct": 10, "Nov": 11, "Dec": 12,
}
def _journal_ts(year: int, month_s: str, day: int, hm: str) -> str:
mo = _month_map().get(month_s, 1)
return f"{year:04d}-{mo:02d}-{int(day):02d} {hm}"
def _parse_code_tokens(fragment: str) -> Tuple[List[Tuple[str, str]], List[str], bool]:
"""ENTER 줄 파싱 → (code,name), exit codes, partial."""
partial = "" in fragment or "..." in fragment
fragment = fragment.replace("", "").replace("...", "").strip().rstrip(",")
named: List[Tuple[str, str]] = []
for m in CODE_NAME_RE.finditer(fragment):
named.append((m.group(1), m.group(2) or m.group(1)))
codes_only: List[str] = []
if not named:
for m in CODE_ONLY_RE.finditer(fragment):
codes_only.append(m.group(1))
return named, codes_only, partial
def _parse_exit_tokens(fragment: str) -> Tuple[List[str], bool]:
partial = "" in fragment or "..." in fragment
fragment = fragment.replace("", "").replace("...", "").strip().rstrip(",")
codes = CODE_ONLY_RE.findall(fragment)
return codes, partial
def iter_log_lines(
*,
start: str,
end: str,
log_file: Optional[str],
) -> Iterable[Tuple[str, str]]:
"""(event_time_str, message_body)"""
if log_file:
with open(log_file, "r", encoding="utf-8", errors="replace") as f:
for raw in f:
yield "", raw.rstrip("\n")
return
start_dt = f"{start} 00:00:00"
end_dt = (datetime.strptime(end, "%Y-%m-%d") + timedelta(days=1)).strftime("%Y-%m-%d 00:00:00")
cmd = [
"journalctl",
"--since", start_dt,
"--until", end_dt,
"-o", "short-iso",
"--no-pager",
]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
assert proc.stdout is not None
year = int(start[:4])
for line in proc.stdout:
line = line.rstrip("\n")
if not line:
continue
# ISO: 2026-06-24T09:00:12+0900 ...
if line[0:4].isdigit() and "T" in line[:20]:
try:
iso = line.split(" ", 1)[0]
dt_part = iso.replace("T", " ")[:19]
msg = line.split(" ", 1)[1] if " " in line else ""
yield dt_part, msg
continue
except Exception:
pass
m = JOURNAL_LINE_RE.match(line)
if not m:
continue
hm = m.group("hm")
ts = _journal_ts(year, m.group("month"), int(m.group("day")), hm)
msg = line[m.end():].strip()
yield ts, msg
proc.wait()
def parse_cond_events(
lines: Iterable[Tuple[str, str]],
*,
strategy_id: str,
logger_filter: str = "kis_trader.cond",
) -> List[UniverseEvent]:
sid = strategy_id.upper()
events: List[UniverseEvent] = []
cur: Optional[UniverseEvent] = None
def flush() -> None:
nonlocal cur
if cur is not None:
events.append(cur)
cur = None
for ts_journal, msg in lines:
ch = CHANGE_RE.search(msg)
if ch:
if ch.group("sid").upper() != sid:
continue
logger = ch.group("logger")
if logger != logger_filter:
continue
flush()
et = ts_journal or ch.group("hm")
if len(et) <= 8:
et = f"{datetime.now():%Y-%m-%d} {et}"
elif "T" in et:
et = et.replace("T", " ")[:19]
cur = UniverseEvent(
event_time=et[:19],
logger=logger,
strategy_id=sid,
enter_n=int(ch.group("enter_n")),
exit_n=int(ch.group("exit_n")),
count=int(ch.group("count")),
)
continue
if cur is None:
continue
if f"[{logger_filter}]" not in msg and "kis_trader.cond" not in msg:
continue
em = ENTER_RE.search(msg)
if em:
named, codes_only, partial = _parse_code_tokens(em.group(1))
for c, n in named:
cur.enter_codes.append((c, n))
for c in codes_only:
cur.enter_codes.append((c, c))
cur.enter_partial = cur.enter_partial or partial
continue
xm = EXIT_RE.search(msg)
if xm:
codes, partial = _parse_exit_tokens(xm.group(1))
cur.exit_codes.extend(codes)
cur.exit_partial = cur.exit_partial or partial
flush()
return events
def load_db_snapshot_index(
db,
*,
strategy_id: str,
start: str,
end: str,
) -> Dict[str, List[Tuple[str, str]]]:
"""event_time → [(code, name), ...] insert(id) 순."""
start_t = f"{start} 00:00:00"
end_t = f"{end} 23:59:59"
rows = db.conn.execute(
"""
SELECT event_time, code, name, id
FROM target_candidates_history
WHERE strategy_id=%s
AND event_time BETWEEN %s AND %s
ORDER BY event_time, id
""",
(strategy_id, start_t, end_t),
).fetchall()
out: Dict[str, List[Tuple[str, str]]] = {}
for r in rows:
et = str(r["event_time"])[:19]
out.setdefault(et, []).append((str(r["code"]), str(r.get("name") or r["code"])))
return out
def rebuild_ordered_universe(
events: Sequence[UniverseEvent],
db_index: Dict[str, List[Tuple[str, str]]],
) -> List[Tuple[str, List[Tuple[str, str]]]]:
"""
로그 ENTER/EXIT 로 순서 복원. 멤버십 불일치 시 기존 DB 스냅샷으로 보정.
"""
state: List[str] = []
names: Dict[str, str] = {}
out: List[Tuple[str, List[Tuple[str, str]]]] = []
for ev in events:
for c in ev.exit_codes:
if c in state:
state.remove(c)
for c, n in ev.enter_codes:
if c not in state:
state.append(c)
names[c] = n or names.get(c, c)
db_rows = db_index.get(ev.event_time) or []
db_codes = [c for c, _ in db_rows]
db_set = set(db_codes)
if len(state) != ev.count or set(state) != db_set:
# 멤버십: DB(당시 저장본) 우선, 순서: 로그 state 우선
merged: List[str] = []
seen: set = set()
for c in state:
if c in db_set and c not in seen:
merged.append(c)
seen.add(c)
for c in db_codes:
if c not in seen:
merged.append(c)
seen.add(c)
if db_codes and set(merged) != db_set:
merged = list(db_codes)
state = merged
for c, n in db_rows:
if n:
names[c] = n
items = [(c, names.get(c, c)) for c in state]
out.append((ev.event_time, items))
return out
def apply_snapshots(
db,
*,
strategy_id: str,
start: str,
end: str,
snapshots: Sequence[Tuple[str, List[Tuple[str, str]]]],
dry_run: bool,
) -> Dict[str, int]:
start_t = f"{start} 00:00:00"
end_t = f"{end} 23:59:59"
stats = {"deleted_events": 0, "inserted_rows": 0, "snapshots": len(snapshots)}
if dry_run:
return stats
from kis_trader.database.db_manager import get_db
ext = get_db()
with ext.conn:
del_row = ext.conn.execute(
"""
SELECT COUNT(DISTINCT event_time) AS c FROM target_candidates_history
WHERE strategy_id=%s AND event_time BETWEEN %s AND %s
""",
(strategy_id, start_t, end_t),
).fetchone()
stats["deleted_events"] = int((del_row or {}).get("c") or 0)
ext.conn.execute(
"""
DELETE FROM target_candidates_history
WHERE strategy_id=%s AND event_time BETWEEN %s AND %s
""",
(strategy_id, start_t, end_t),
)
for event_time, items in snapshots:
payload = [{"code": c, "name": n} for c, n in items if c]
stats["inserted_rows"] += ext.insert_condition_universe_snapshot(
strategy_id=strategy_id,
event_time=event_time,
items=payload,
)
return stats
def main(argv: Optional[Sequence[str]] = None) -> int:
ap = argparse.ArgumentParser(description="로그 → target_candidates_history 재구축")
ap.add_argument("--start", required=True, help="YYYY-MM-DD")
ap.add_argument("--end", default=None, help="YYYY-MM-DD (기본=start)")
ap.add_argument("--strategy", default="MOMENTUM")
ap.add_argument("--logger", default="kis_trader.cond",
help="유니버스 소스 로거 (기본 cond=조건검색)")
ap.add_argument("--log-file", default=None, help="journal 대신 파일")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--apply", action="store_true")
args = ap.parse_args(argv)
if not args.dry_run and not args.apply:
print("⚠️ --dry-run 또는 --apply 중 하나를 지정하세요.")
return 2
end = args.end or args.start
print(f"📖 로그 파싱 {args.start} ~ {end} | strategy={args.strategy} | logger={args.logger}")
lines = list(iter_log_lines(start=args.start, end=end, log_file=args.log_file))
print(f" 총 로그 줄: {len(lines):,}")
events = parse_cond_events(lines, strategy_id=args.strategy, logger_filter=args.logger)
print(f" 유니버스 변동 이벤트: {len(events):,}")
if not events:
print("❌ 이벤트 없음 — journal 권한/기간/전략을 확인하세요.")
return 1
from database import TradeDB
db = TradeDB()
try:
db_index = load_db_snapshot_index(
db, strategy_id=args.strategy.upper(), start=args.start, end=end,
)
print(f" 기존 DB 스냅샷(보정용): {len(db_index):,}")
snapshots = rebuild_ordered_universe(events, db_index)
partial_n = sum(1 for e in events if e.enter_partial or e.exit_partial)
mismatches = 0
for ev, (_, items) in zip(events, snapshots):
if len(items) != ev.count:
mismatches += 1
print(f" ENTER/EXIT 잘림(…): {partial_n}건 | count 불일치(보정 후): {mismatches}")
if snapshots[:3]:
et0, it0 = snapshots[0]
print(f" 첫 스냅샷 {et0}{len(it0)}종목 | 앞 5: {[c for c,_ in it0[:5]]}")
if len(snapshots) > 3:
et1, it1 = snapshots[-1]
print(f" 마지막 {et1}{len(it1)}종목")
if args.dry_run:
print("✅ dry-run 완료 (--apply 로 DB 반영)")
return 0
stats = apply_snapshots(
db,
strategy_id=args.strategy.upper(),
start=args.start,
end=end,
snapshots=snapshots,
dry_run=False,
)
print(
f"✅ 적용 완료: 삭제 {stats['deleted_events']} 스냅샷 | "
f"신규 {stats['snapshots']} 스냅샷 / {stats['inserted_rows']}"
)
return 0
finally:
db.close()
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,312 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
계좌 스냅샷 단독 조회 스크립트.
목적
----
- 봇 전체 로그 없이 계좌 핵심값(예수금총액/D+2/주문가능/평가금액)을 빠르게 확인.
- 모의/실전 어느 쪽 계좌를 보고 있는지 즉시 검증.
사용 예
--------
python3 kis_trader/scripts/show_account_snapshot.py
python3 kis_trader/scripts/show_account_snapshot.py --mode both
python3 kis_trader/scripts/show_account_snapshot.py --mode real
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
# 스크립트 직접 실행 시 프로젝트 루트 import 보정
HERE = Path(__file__).resolve()
ROOT = HERE.parents[2] # /home/hoon/kis_bot
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from kis_trader.execution.kis_client import KISClient # noqa: E402
from kis_trader.utils.env import get_env_bool, get_env_from_db # noqa: E402
def _parse_amt(v: Any) -> float:
try:
return float(str(v or 0).replace(",", "").strip())
except Exception:
return 0.0
def _mask_account_no(raw: str) -> str:
s = str(raw or "").strip()
if not s:
return "-"
if len(s) <= 5:
return s[0] + "***" + s[-1]
return s[:3] + "***" + s[-2:]
def _pick_first_positive(d: Dict[str, Any], keys: List[str]) -> float:
for k in keys:
v = _parse_amt(d.get(k))
if v > 0:
return v
return 0.0
def _extract_snapshot(balance: Dict[str, Any], client: KISClient) -> Dict[str, Any]:
out2_raw = balance.get("output2") or []
if isinstance(out2_raw, dict):
out2 = out2_raw
elif isinstance(out2_raw, list) and out2_raw:
out2 = out2_raw[0]
else:
out2 = {}
out1_raw = balance.get("output1") or []
if isinstance(out1_raw, dict):
out1 = [out1_raw]
elif isinstance(out1_raw, list):
out1 = out1_raw
else:
out1 = []
dnca = _parse_amt(out2.get("dnca_tot_amt"))
d2 = _parse_amt(out2.get("prvs_rcdl_excc_amt"))
ord_psbl = _pick_first_positive(
out2,
["ord_psbl_cash", "ord_psbl_amt", "ord_psbl", "buy_psbl_cash", "buy_psbl_amt"],
)
total_asset = _parse_amt(out2.get("tot_evlu_amt"))
if total_asset <= 0:
holding_eval = 0.0
for it in out1:
qty = _parse_amt(it.get("hldg_qty") or it.get("HLDG_QTY"))
if qty <= 0:
continue
evlu = _parse_amt(it.get("evlu_amt") or it.get("EVLU_AMT"))
if evlu > 0:
holding_eval += evlu
continue
price = _parse_amt(it.get("prpr") or it.get("PRPR"))
holding_eval += price * qty
total_asset = (d2 if d2 > 0 else dnca) + holding_eval
holdings = 0
for it in out1:
if _parse_amt(it.get("hldg_qty") or it.get("HLDG_QTY")) > 0:
holdings += 1
return {
"mode": "MOCK" if client.mock else "REAL",
"account_no": client.account_no or "",
"account_code": client.account_code or "",
"account_masked": f"{_mask_account_no(client.account_no)}-{client.account_code}",
"dnca": dnca,
"d2": d2,
"ord_psbl": ord_psbl,
"total_asset": total_asset,
"holdings": holdings,
"rt_cd": balance.get("rt_cd"),
"msg_cd": str(balance.get("msg_cd", "") or "").strip(),
"msg1": str(balance.get("msg1", "") or "").strip(),
}
def _fetch_balance_raw(client: KISClient) -> Dict[str, Any]:
"""
get_account_balance 래퍼 대신 raw 응답/HTTP 상태까지 수집.
실패 원인 힌트를 주기 위해 msg_cd/msg1/status_code를 그대로 보존한다.
"""
tr_id = "VTTC8434R" if client.mock else "TTTC8434R"
params = {
"CANO": client.account_no,
"ACNT_PRDT_CD": client.account_code,
"AFHR_FLPR_YN": "N",
"OFL_YN": "N",
"INQR_DVSN": "01",
"UNPR_DVSN": "01",
"FUND_STTL_ICLD_YN": "N",
"FNCG_AMT_AUTO_RDPT_YN": "N",
"PRCS_DVSN": "00",
"CTX_AREA_FK100": "",
"CTX_AREA_NK100": "",
}
try:
resp = client._get( # pylint: disable=protected-access
"/uapi/domestic-stock/v1/trading/inquire-balance",
tr_id,
params,
)
status = int(getattr(resp, "status_code", 0) or 0)
try:
body = resp.json()
except Exception:
body = {}
return {"ok": status == 200 and body.get("rt_cd") == "0", "status": status, "body": body}
except Exception as e:
return {"ok": False, "status": 0, "body": {}, "exception": str(e)}
def _diagnose_hint(row: Dict[str, Any]) -> List[str]:
hints: List[str] = []
status = int(row.get("http_status", 0) or 0)
mode = str(row.get("mode", "")).upper()
msg1 = str(row.get("msg1", "") or "")
rt_cd = str(row.get("rt_cd", "") or "")
account_code = str(row.get("account_code", "") or "")
if status >= 500:
hints.append("한투 서버/도메인 오류(HTTP 5xx). 계좌정보보다 API 상태 이슈 가능성이 큼")
if mode == "MOCK" and status >= 500:
hints.append("모의투자 잔고 API가 불안정할 수 있음. --mode real 결과와 비교 필요")
if rt_cd != "0":
hints.append("rt_cd!=0 이면 계좌번호/상품코드 조합 또는 권한/거래구분 불일치 가능")
if "조회할 내용이 없습니다" in msg1:
hints.append("계좌는 인식됐지만 조회 데이터가 비어있음: CANO/ACNT_PRDT_CD(보통 01) 재확인")
if account_code not in ("01", "03"):
hints.append("ACNT_PRDT_CD가 일반적 값(01/03)과 다름")
if float(row.get("dnca", 0) or 0) <= 0 and float(row.get("total_asset", 0) or 0) <= 0:
hints.append("응답상 현금/평가금 모두 0: 조회 대상 계좌가 다른 계좌일 가능성 높음")
return hints
def _print_snapshot(s: Dict[str, Any]) -> None:
print("=" * 72)
print(f"[{s['mode']}] 계좌 {s['account_masked']}")
print("-" * 72)
print(f"HTTP 상태 : {int(s.get('http_status', 0))}")
print(f"rt_cd/msg_cd : {s.get('rt_cd', '-')}/{s.get('msg_cd', '-')}")
print(f"예수금총액(dnca) : {s['dnca']:>15,.0f}")
print(f"D+2예수금(d2) : {s['d2']:>15,.0f}")
print(f"주문가능금액 : {s['ord_psbl']:>15,.0f}")
print(f"총자산(tot_evlu) : {s['total_asset']:>15,.0f}")
print(f"보유종목수 : {int(s['holdings']):>15d}")
if s.get("msg1"):
print(f"응답메시지 : {s['msg1']}")
hints = _diagnose_hint(s)
if hints:
print("힌트 :")
for h in hints:
print(f" - {h}")
def _build_client(mode: str) -> KISClient:
if mode == "mock":
return KISClient(mock=True)
if mode == "real":
return KISClient(mock=False)
# auto: env KIS_MOCK 사용
return KISClient(mock=get_env_bool("KIS_MOCK", True))
def _resolve_modes(arg_mode: str) -> List[str]:
m = (arg_mode or "auto").strip().lower()
if m == "both":
return ["mock", "real"]
if m in ("mock", "real", "auto"):
return [m]
return ["auto"]
def main() -> None:
p = argparse.ArgumentParser(description="계좌 핵심 스냅샷(예수금/주문가능/총자산) 조회")
p.add_argument(
"--mode",
choices=["auto", "mock", "real", "both"],
default="auto",
help="조회 모드 (기본 auto=KIS_MOCK 따름)",
)
p.add_argument("--json", action="store_true", help="JSON 형식으로 출력")
p.add_argument("--verbose", action="store_true", help="내부 HTTP/DB 로그 표시")
args = p.parse_args()
if not args.verbose:
# 스냅샷 결과만 보이도록 내부 로거 소음 억제
for name in (
"TradeDB",
"kis_trader.safe_request",
"kis_token_manager",
"kis_trader.kis_client",
):
logging.getLogger(name).setLevel(logging.CRITICAL)
rows: List[Dict[str, Any]] = []
for mode in _resolve_modes(args.mode):
client: Optional[KISClient] = None
try:
client = _build_client(mode)
raw = _fetch_balance_raw(client)
body = raw.get("body") or {}
if not raw.get("ok"):
rows.append(
{
"mode": "MOCK" if (client and client.mock) else "REAL",
"account_no": client.account_no if client else "",
"account_code": client.account_code if client else "",
"account_masked": (
f"{_mask_account_no(client.account_no)}-{client.account_code}"
if client
else "-"
),
"http_status": int(raw.get("status", 0) or 0),
"rt_cd": str(body.get("rt_cd", "") or ""),
"msg_cd": str(body.get("msg_cd", "") or ""),
"msg1": str(body.get("msg1", "") or ""),
"error": (
str(raw.get("exception"))
if raw.get("exception")
else "잔고 조회 실패"
),
}
)
continue
snap = _extract_snapshot(body, client)
snap["http_status"] = int(raw.get("status", 0) or 0)
rows.append(snap)
except Exception as e:
rows.append(
{
"mode": mode.upper(),
"account_no": client.account_no if client else "",
"account_code": client.account_code if client else "",
"account_masked": (
f"{_mask_account_no(client.account_no)}-{client.account_code}"
if client
else "-"
),
"http_status": 0,
"error": str(e),
}
)
if args.json:
print(json.dumps(rows, ensure_ascii=False, indent=2))
return
print(f"KIS_MOCK(env) = {str(get_env_from_db('KIS_MOCK', 'true')).strip().lower()}")
for r in rows:
if r.get("error"):
print("=" * 72)
print(f"[{r.get('mode')}] 계좌 {r.get('account_masked')} 조회 실패")
print(f"HTTP 상태 : {int(r.get('http_status', 0))}")
print(f"rt_cd/msg_cd : {r.get('rt_cd', '-')}/{r.get('msg_cd', '-')}")
if r.get("msg1"):
print(f"응답메시지 : {r.get('msg1')}")
print(f"에러 : {r.get('error')}")
hints = _diagnose_hint(r)
if hints:
print("힌트 :")
for h in hints:
print(f" - {h}")
else:
_print_snapshot(r)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,191 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
verify_three_paths.py — 실매 / param_search / 웹백테 가 '똑같은 엔진 파라미터'
도는지 검증한다.
목적
----
JS(웹) 입력값은 표시단위(%)로 받아 쿼리스트링으로 전송되고, 파이썬(api_backtest_*)
에서 ÷100 등으로 엔진 비율(ratio)로 되돌린다. 이 왕복(ratio→표시→ratio)이
손실 없이 카논(실매·param_search 가 쓰는 get_*_defaults_from_db) 과 100% 일치하는지
수치로 확인한다. (소수점/정수 변환 차이 적발)
검증 구조
---------
- 실매(live) : 전략 객체가 get_*_defaults_from_db() 비율값을 그대로 사용.
- param_search: base = get_*_defaults_from_db() (코드상 동일 함수 → 자동 일치).
- 웹백테(web) : _*_ui_defaults_from_db() (표시%) → (JS는 숫자 그대로 통과)
→ api_backtest_* 변환(÷100) → 엔진 비율.
따라서 'web 왕복 후 비율' == 'canonical 비율' 이면 세 경로가 동일하다.
이 스크립트는 외부 서버/데이터 없이 변환만 재현해 비교한다.
"""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
# 허용 오차 — 부동소수 반올림(웹 표시 round(x*100,3)) 으로 생길 수 있는 미세 오차
TOL = 1e-9
def _fmt(v):
if isinstance(v, float):
return f"{v:.10g}"
return str(v)
def _cmp_rows(rows):
"""rows: [(name, canonical, web, unit)] → 출력 + 불일치 수 반환."""
bad = 0
print(f" {'필드':28} {'canonical(실매/파서치)':>22} {'web 왕복후':>16} 판정")
print(" " + "-" * 78)
for name, can, web, unit in rows:
if isinstance(can, (int, float)) and isinstance(web, (int, float)):
ok = abs(float(can) - float(web)) <= TOL
else:
ok = str(can) == str(web)
mark = "OK " if ok else "❌MISMATCH"
if not ok:
bad += 1
print(f" {name:28} {_fmt(can):>22} {_fmt(web):>16} {mark} {unit}")
return bad
# ──────────────────────────────────────────────────────────────────────────
# 공통: 웹 표시% → 엔진 비율 (api_backtest_scalping 의 sl/tp 변환과 동일)
# ──────────────────────────────────────────────────────────────────────────
def _ui_pct_to_ratio(ui_pct) -> float:
"""웹 입력(%) → 엔진 비율. api_backtest_scalping: float(x)/100 후 abs."""
return abs(float(ui_pct) / 100.0)
def verify_momentum() -> int:
import backtest_web as bw
import kis_trader.engine.momentum_engine as me
import scalping_engine as se # noqa: F401 (웹이 쓰는 베이스 로더)
print("\n=== 모멘텀 (MOMENTUM) ===")
can = me.get_momentum_defaults_from_db() # 실매 + param_search base (비율)
ui = bw._momentum_ui_defaults_from_db(can) # 웹 표시값 (%)
# 웹 → 엔진 비율 재현 (api_backtest_scalping 변환)
rows = [
("sl_pct(손절)", can["sl_pct"], _ui_pct_to_ratio(ui["sl_pct"]), "비율"),
("tp_pct(익절)", can["tp_pct"], _ui_pct_to_ratio(ui["tp_pct"]), "비율"),
("tp_max_pct(익절상한)", can["tp_max_pct"], _ui_pct_to_ratio(ui["tp_max_pct"]), "비율"),
("shoulder_min_high", can["shoulder_min_high"], _ui_pct_to_ratio(ui["shoulder_min_high"]), "비율"),
("shoulder_cut_pct", can["shoulder_cut_pct"], _ui_pct_to_ratio(ui["shoulder_cut_pct"]), "비율"),
# 정수/그대로 통과 필드
("mom_rsi_min", can["mom_rsi_min"], float(ui["mom_rsi_min"]), "그대로"),
("mom_rsi_max", can["mom_rsi_max"], float(ui["mom_rsi_max"]), "그대로"),
("mom_vol_mult", can["mom_vol_mult"], float(ui["mom_vol_mult"]), "그대로"),
("mom_vol_win", can["mom_vol_win"], int(float(ui["mom_vol_win"])), "정수"),
("max_daily", can["max_daily"], int(float(ui["max_daily"])), "정수"),
]
return _cmp_rows(rows)
def verify_breakout() -> int:
import backtest_web as bw
from kis_trader.strategies.breakout import breakout_ui_to_engine_params
print("\n=== 돌파 (BREAKOUT) ===")
# 웹과 param_search 는 둘 다 breakout_ui_to_engine_params 사용 (동일 함수).
ui = bw._bo_defaults_from_db()
web_engine = bw._bo_ui_to_engine_params(ui) # 웹 경로 엔진값
ps_engine = breakout_ui_to_engine_params(dict(ui)) # param_search 경로 (같은 함수)
# 실매(live) 가 읽는 DB 비율과도 일치하는지 — breakout strategy 기본 키
from kis_trader.utils.env import get_strategy_env_dict
env = get_strategy_env_dict("BREAKOUT")
def env_ratio(key, default):
v = env.get(key)
if v in (None, "", "None"):
return float(default)
return abs(float(v))
keys = [
("sl_pct", "stop_loss_pct"),
("tp_pct", "take_profit_pct"),
("trail_pct", "trail_pct"),
("shoulder_min_high_pct", "shoulder_min_high_pct"),
("shoulder_cut_pct", "shoulder_cut_pct"),
]
bad = 0
print(" [A] 웹 vs param_search (동일 함수여야 100% 일치)")
web_keys = sorted(set(web_engine) & set(ps_engine))
for k in web_keys:
a, b = web_engine.get(k), ps_engine.get(k)
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
if abs(float(a) - float(b)) > TOL:
print(f"{k}: web={a} ps={b}")
bad += 1
elif str(a) != str(b):
print(f"{k}: web={a} ps={b}")
bad += 1
if bad == 0:
print(f" OK — 공통 {len(web_keys)}개 키 전부 일치")
print(" [B] 웹 엔진비율 vs 실매 DB 비율")
rows = []
for eng_key, _ in keys:
if eng_key not in web_engine:
continue
rows.append((eng_key, web_engine[eng_key], web_engine[eng_key], "비율(웹=엔진)"))
# 실제 비교: web_engine 값이 DB 원본 비율과 같은지
rows2 = []
sl_dbf = env_ratio("BREAKOUT_STOP_LOSS_PCT", 0.02)
rows2.append(("BREAKOUT_STOP_LOSS_PCT", sl_dbf, abs(float(web_engine.get("stop_loss_pct", web_engine.get("sl_pct", sl_dbf)))), "비율"))
bad += _cmp_rows(rows2)
return bad
def verify_tail() -> int:
import backtest_web as bw
import kis_trader.engine.tail_engine as te
print("\n=== 꼬리 (TAIL/SHORT) ===")
can = te.get_tail_defaults_from_db() # 실매 + param_search base
ui = bw._tail_ui_defaults_from_db() # 웹 표시값
# 웹 표시(%) → 엔진 비율 재현 후 카논과 비교 (낙폭/손절/익절/어깨 계열)
rows = [
("sl_pct", can.get("sl_pct"), _ui_pct_to_ratio(ui["sl_pct"]), "비율"),
("tp_pct", can.get("tp_pct"), _ui_pct_to_ratio(ui["tp_pct"]), "비율"),
("shoulder_min_high", can.get("shoulder_min_high"), _ui_pct_to_ratio(ui["smin"]), "비율"),
("shoulder_cut_pct", can.get("shoulder_cut_pct"), _ui_pct_to_ratio(ui["scut"]), "비율"),
("rsi_threshold", can.get("rsi_threshold"), float(ui["rsi"]), "그대로"),
("rsi_period", can.get("rsi_period"), int(float(ui["rsi_period"])), "정수"),
("max_daily", can.get("max_daily"), int(float(ui["max_daily"])), "정수"),
("stop_atr_mult", can.get("stop_atr_mult"), float(ui["stop_atr_mult"]), "그대로"),
("target_atr_mult", can.get("target_atr_mult"), float(ui["target_atr_mult"]), "그대로"),
]
return _cmp_rows(rows)
def main() -> int:
total_bad = 0
for fn in (verify_momentum, verify_breakout, verify_tail):
try:
total_bad += fn()
except Exception as e: # noqa: BLE001
import traceback
print(f"\n[ERROR] {fn.__name__}: {e}")
traceback.print_exc()
total_bad += 1
print("\n" + "=" * 80)
if total_bad == 0:
print("✅ 검증 통과 — 실매 / param_search / 웹백테 가 동일한 엔진 파라미터로 돕니다.")
else:
print(f"❌ 불일치 {total_bad}건 — 위 MISMATCH 항목을 확인하세요.")
return total_bad
if __name__ == "__main__":
sys.exit(0 if main() == 0 else 1)