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:
81
kis_trader/scripts/apply_unified_risk_env.py
Normal file
81
kis_trader/scripts/apply_unified_risk_env.py
Normal 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())
|
||||
131
kis_trader/scripts/approx_param_search_from_log.py
Normal file
131
kis_trader/scripts/approx_param_search_from_log.py
Normal 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())
|
||||
318
kis_trader/scripts/backfill_trigger_eval_from_log.py
Normal file
318
kis_trader/scripts/backfill_trigger_eval_from_log.py
Normal 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())
|
||||
233
kis_trader/scripts/compare_momentum_scan_memo.py
Normal file
233
kis_trader/scripts/compare_momentum_scan_memo.py
Normal 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())
|
||||
@@ -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")
|
||||
|
||||
190
kis_trader/scripts/fill_stock_share_meta.py
Normal file
190
kis_trader/scripts/fill_stock_share_meta.py
Normal 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())
|
||||
105
kis_trader/scripts/migrate_split_env_config.py
Normal file
105
kis_trader/scripts/migrate_split_env_config.py
Normal 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())
|
||||
188
kis_trader/scripts/prune_env_config_strategy_columns.py
Normal file
188
kis_trader/scripts/prune_env_config_strategy_columns.py
Normal 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())
|
||||
418
kis_trader/scripts/rebuild_universe_history_from_logs.py
Normal file
418
kis_trader/scripts/rebuild_universe_history_from_logs.py
Normal 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())
|
||||
312
kis_trader/scripts/show_account_snapshot.py
Normal file
312
kis_trader/scripts/show_account_snapshot.py
Normal 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()
|
||||
|
||||
191
kis_trader/scripts/verify_three_paths.py
Normal file
191
kis_trader/scripts/verify_three_paths.py
Normal 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)
|
||||
Reference in New Issue
Block a user