이번에 들어간 내용
한투 호가 = 2번째 앱키 전용 키 없거나 start 실패 시 메인에 H0STASP0 안 붙임. 운영설정 WS_ORDERBOOK_SAVE_KIS 빨간 danger. LS RAM 합집합 후보∪보유∪영구∪grace. sync_targets와 split reconcile 둘 다. 틱 DB 영구 게이트는 그대로. 분봉 쓰레기 → 다음 소스 봉 통째 그 분 틱 0건이거나 전부 봉끝 대비 LIVE_FEED_FALLBACK_MAX_AGE_SEC 초과면 구멍. 메인 WS → 2차 → LS → REST → rollup. CANDLE_GARBAGE_FALLBACK 기본 true. 파일: feed_fallback.py(신규), ws_manager.py, kis_ws.py, candle_series.py, bt_candle_source.py, live_config_schema.py, database.py, 스모크, MD 2개. 같은 ws_manager/database/kis_ws/live_config에는 직전 커밋 이후 쌓여 있던 시세 폴백·ENV 키 정리도 같이 들어갔습니다. 파일 단위로 나눌 수 없어서입니다.
This commit is contained in:
@@ -82,19 +82,16 @@ def _bars_per_day(tf_min: int) -> int:
|
||||
|
||||
# ─── 키움 키 로드 ─────────────────────────────────────────────────────
|
||||
def _load_kiwoom_keys(db: TradeDB) -> tuple[str, str, bool]:
|
||||
"""env_config 에서 키움 앱키/시크릿/모의여부를 로드.
|
||||
"""env_auth_config 에서 키움 앱키/시크릿/모의여부를 로드.
|
||||
|
||||
우선순위
|
||||
1. KIS_MOCK=true → KIWOOM_APP_KEY_MOCK/SECRET_MOCK
|
||||
2. KIS_MOCK=false → KIWOOM_APP_KEY_REAL/SECRET_REAL
|
||||
3. 위 둘 다 비어있으면 레거시 KIWOOM_APP_KEY/SECRET
|
||||
"""
|
||||
row = db.conn.execute(
|
||||
"SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise RuntimeError("env_config 테이블이 비어 있습니다.")
|
||||
r = dict(row)
|
||||
r = db.get_auth_env()
|
||||
if not r:
|
||||
raise RuntimeError("env_auth_config 테이블이 비어 있습니다.")
|
||||
|
||||
is_mock = str(r.get("KIS_MOCK", "")).strip().lower() in ("true", "1", "yes", "on")
|
||||
|
||||
@@ -114,7 +111,7 @@ def _load_kiwoom_keys(db: TradeDB) -> tuple[str, str, bool]:
|
||||
|
||||
if not key or not sec:
|
||||
raise RuntimeError(
|
||||
"키움 API 키 미설정. env_config 에 KIWOOM_APP_KEY_REAL/MOCK 또는 "
|
||||
"키움 API 키 미설정. env_auth_config 에 KIWOOM_APP_KEY_REAL/MOCK 또는 "
|
||||
"레거시 KIWOOM_APP_KEY 가 있어야 합니다."
|
||||
)
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ class WsProbe:
|
||||
self._ws = None
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._done = threading.Event()
|
||||
self._sent_codes: List[str] = []
|
||||
|
||||
def _build_sub(self, code: str, subscribe: bool = True) -> str:
|
||||
return json.dumps({
|
||||
@@ -223,6 +224,7 @@ class WsProbe:
|
||||
time.sleep(self.gap_sec)
|
||||
try:
|
||||
app.send(self._build_sub(code, True))
|
||||
self._sent_codes.append(code)
|
||||
self.logger.info(" [WS] subscribe sent: %s", code)
|
||||
except Exception as e:
|
||||
self.logger.warning(" [WS] subscribe fail %s: %s", code, e)
|
||||
@@ -242,6 +244,18 @@ class WsProbe:
|
||||
self._thread = threading.Thread(target=_runner, daemon=True)
|
||||
self._thread.start()
|
||||
self._done.wait(timeout=hold_sec + 15.0)
|
||||
gap = max(0.0, float(self.gap_sec or 0.12))
|
||||
if self._ws and self._sent_codes:
|
||||
self.logger.info(" [WS] unsubscribe %d 후 close", len(self._sent_codes))
|
||||
for i, code in enumerate(self._sent_codes):
|
||||
if i > 0 and gap > 0:
|
||||
time.sleep(gap)
|
||||
try:
|
||||
self._ws.send(self._build_sub(code, False))
|
||||
except Exception:
|
||||
pass
|
||||
if gap > 0:
|
||||
time.sleep(gap)
|
||||
try:
|
||||
if self._ws:
|
||||
self._ws.close()
|
||||
|
||||
497
kis_trader/scripts/test_kis_ws_tick_ob_limit.py
Normal file
497
kis_trader/scripts/test_kis_ws_tick_ob_limit.py
Normal file
@@ -0,0 +1,497 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
한투(KIS) 원키(시세 실키 · approval slot=main) 한 세션에서
|
||||
체결(H0STCNT0) 40종 + 호가(H0STASP0) 같은 40종을 같이 구독하고,
|
||||
41·42번째가 TR별로 따로 한도인지 실측한다.
|
||||
|
||||
질문: 세션 합산 41인가, 체결 41 + 호가 41 따로인가?
|
||||
|
||||
정책:
|
||||
- REST Approval 재발급 없음 (파일 캐시만). 새 키 = 실매 WS 무효.
|
||||
- access_token(tokenP) 발급 없음. KISClient 생성 안 함.
|
||||
- 동일 approval 로 2번째 소켓을 열면 기존 세션(해외 포함)이 잠깐 끊길 수 있음.
|
||||
끝나면 close 해서 봇이 같은 키로 재연결하게 둔다.
|
||||
|
||||
사용:
|
||||
cd /home/hoon/kis_bot
|
||||
python3 -u kis_trader/scripts/test_kis_ws_tick_ob_limit.py
|
||||
python3 -u kis_trader/scripts/test_kis_ws_tick_ob_limit.py --slot ob
|
||||
|
||||
로그:
|
||||
logs/test_kis_ws_tick_ob_limit.log
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
HERE = Path(__file__).resolve()
|
||||
ROOT = HERE.parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from kis_approval_manager import KISApprovalManager # noqa: E402
|
||||
from kis_trader.utils.env import get_env_float, get_env_from_db # noqa: E402
|
||||
|
||||
LOG_DIR = ROOT / "logs"
|
||||
LOG_DIR.mkdir(exist_ok=True)
|
||||
LOG_PATH = LOG_DIR / "test_kis_ws_tick_ob_limit.log"
|
||||
|
||||
# 임의 대형·중형 40종 (REST 랭킹 조회 없음 — 한도 테스트용 고정 리스트)
|
||||
CODES_40 = [
|
||||
"005930", "000660", "005380", "035420", "051910",
|
||||
"006400", "035720", "068270", "207940", "005490",
|
||||
"028260", "012330", "066570", "003670", "096770",
|
||||
"034730", "015760", "032830", "086790", "009150",
|
||||
"011200", "010130", "018260", "009830", "010950",
|
||||
"024110", "030200", "034220", "047810", "267250",
|
||||
"003550", "017670", "036570", "251270", "352820",
|
||||
"259960", "326030", "377300", "042700", "000270",
|
||||
]
|
||||
CODE_41 = "105560" # KB금융
|
||||
CODE_42 = "055550" # 신한지주
|
||||
|
||||
TR_TICK = "H0STCNT0"
|
||||
TR_OB = "H0STASP0"
|
||||
|
||||
|
||||
def _setup_logging() -> logging.Logger:
|
||||
lg = logging.getLogger("kis_tick_ob_limit")
|
||||
lg.setLevel(logging.DEBUG)
|
||||
lg.propagate = False
|
||||
lg.handlers.clear()
|
||||
fmt = logging.Formatter("[%(asctime)s] %(message)s", datefmt="%H:%M:%S")
|
||||
for h in (logging.StreamHandler(sys.stdout), logging.FileHandler(LOG_PATH, encoding="utf-8")):
|
||||
h.setFormatter(fmt)
|
||||
lg.addHandler(h)
|
||||
return lg
|
||||
|
||||
|
||||
def _is_success(rt: str, msg: str) -> bool:
|
||||
rt = (rt or "").strip()
|
||||
msg_u = (msg or "").upper()
|
||||
if rt == "0":
|
||||
return True
|
||||
if "SUBSCRIBE SUCCESS" in msg_u:
|
||||
return True
|
||||
if "ALREADY IN SUBSCRIBE" in msg_u:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class TickObLimitProbe:
|
||||
"""한 세션 · 한 approval · 체결+호가 구독 ACK 수집."""
|
||||
|
||||
def __init__(self, ws_url: str, approval_key: str, logger: logging.Logger, gap_sec: float):
|
||||
self.ws_url = ws_url
|
||||
self.approval_key = approval_key
|
||||
self.logger = logger
|
||||
self.gap_sec = max(0.0, float(gap_sec))
|
||||
self.acks: List[Dict[str, Any]] = []
|
||||
self.ticks = 0
|
||||
self.obs = 0
|
||||
self.connected_at = 0.0
|
||||
self.closed_at = 0.0
|
||||
self.close_code: Any = None
|
||||
self.close_msg = ""
|
||||
self.error = ""
|
||||
self._ws = None
|
||||
self._send_lock = threading.Lock()
|
||||
self._open_evt = threading.Event()
|
||||
self._done = threading.Event()
|
||||
self._pending: List[Tuple[str, str]] = [] # (tr_id, code) 보낸 순서
|
||||
self._pending_lock = threading.Lock()
|
||||
self.fatal_msg = ""
|
||||
self._alive = True
|
||||
self._ok_subs: List[Tuple[str, str]] = [] # 해제할 SUCCESS (tr_id, code)
|
||||
|
||||
def _build_sub(self, tr_id: str, code: str, subscribe: bool = True) -> str:
|
||||
return json.dumps({
|
||||
"header": {
|
||||
"approval_key": self.approval_key,
|
||||
"custtype": "P",
|
||||
"tr_type": "1" if subscribe else "2",
|
||||
"content-type": "utf-8",
|
||||
},
|
||||
"body": {
|
||||
"input": {
|
||||
"tr_id": tr_id,
|
||||
"tr_key": code,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
def send_sub(self, tr_id: str, code: str) -> bool:
|
||||
if not self._alive:
|
||||
return False
|
||||
with self._pending_lock:
|
||||
self._pending.append((tr_id, code))
|
||||
with self._send_lock:
|
||||
if not self._ws or not self._alive:
|
||||
self.logger.warning("send skip (closed) %s %s", tr_id, code)
|
||||
return False
|
||||
try:
|
||||
self._ws.send(self._build_sub(tr_id, code))
|
||||
except Exception as e:
|
||||
self._alive = False
|
||||
self.logger.warning("send fail %s %s: %s", tr_id, code, e)
|
||||
return False
|
||||
self.logger.info("SENT %s %s", tr_id, code)
|
||||
return True
|
||||
|
||||
def _pop_pending(self, tr_id: str, code: str) -> Tuple[str, str]:
|
||||
with self._pending_lock:
|
||||
if code:
|
||||
for i, (pt, pc) in enumerate(self._pending):
|
||||
if pt == tr_id and pc == code:
|
||||
return self._pending.pop(i)
|
||||
if self._pending:
|
||||
if tr_id:
|
||||
for i, (pt, pc) in enumerate(self._pending):
|
||||
if pt == tr_id:
|
||||
return self._pending.pop(i)
|
||||
return self._pending.pop(0)
|
||||
return (tr_id, code)
|
||||
|
||||
def _on_open(self, ws) -> None:
|
||||
self.connected_at = time.time()
|
||||
self.logger.info("on_open OK")
|
||||
self._open_evt.set()
|
||||
|
||||
def _on_message(self, ws, message: str) -> None:
|
||||
raw = (message or "").strip()
|
||||
if raw == "PINGPONG":
|
||||
try:
|
||||
ws.send("PINGPONG")
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
if raw.startswith("{"):
|
||||
try:
|
||||
j = json.loads(raw)
|
||||
except Exception as e:
|
||||
self.logger.warning("JSON parse fail: %s | %s", e, raw[:180])
|
||||
return
|
||||
hdr = j.get("header") or {}
|
||||
body = j.get("body") or {}
|
||||
out = body.get("output") or {}
|
||||
if not isinstance(out, dict):
|
||||
out = {}
|
||||
tr_id = str(hdr.get("tr_id") or "").strip()
|
||||
tr_key = str(
|
||||
out.get("tr_key")
|
||||
or hdr.get("tr_key")
|
||||
or ""
|
||||
).strip()
|
||||
rt = str(body.get("rt_cd") or "").strip()
|
||||
msg1 = str(body.get("msg1") or "").strip()
|
||||
matched_tr, matched_code = self._pop_pending(tr_id, tr_key)
|
||||
ok = _is_success(rt, msg1)
|
||||
msg_u = msg1.upper()
|
||||
if "ALREADY IN USE" in msg_u:
|
||||
self.fatal_msg = msg1
|
||||
self._alive = False
|
||||
rec = {
|
||||
"ts": datetime.now().strftime("%H:%M:%S"),
|
||||
"tr_id": tr_id or matched_tr,
|
||||
"code": tr_key or matched_code,
|
||||
"rt_cd": rt,
|
||||
"msg1": msg1,
|
||||
"ok": ok,
|
||||
}
|
||||
self.acks.append(rec)
|
||||
if ok and rec["tr_id"] and rec["code"]:
|
||||
self._ok_subs.append((rec["tr_id"], rec["code"]))
|
||||
flag = "OK" if ok else "REJECT"
|
||||
self.logger.info(
|
||||
"ACK %s tr=%s code=%s rt_cd=%s msg1=%s",
|
||||
flag, rec["tr_id"], rec["code"], rt, msg1,
|
||||
)
|
||||
return
|
||||
parts = raw.split("|")
|
||||
if len(parts) >= 2:
|
||||
tr = parts[1]
|
||||
if tr == TR_TICK:
|
||||
self.ticks += 1
|
||||
if self.ticks <= 3:
|
||||
self.logger.info("H0STCNT0 tick #%d (장중만 의미)", self.ticks)
|
||||
elif tr == TR_OB:
|
||||
self.obs += 1
|
||||
if self.obs <= 3:
|
||||
self.logger.info("H0STASP0 ob #%d (장중만 의미)", self.obs)
|
||||
|
||||
def _on_error(self, ws, error) -> None:
|
||||
self.error = str(error)
|
||||
self._alive = False
|
||||
self.logger.warning("on_error: %s", error)
|
||||
|
||||
def _on_close(self, ws, code, msg) -> None:
|
||||
self._alive = False
|
||||
self.closed_at = time.time()
|
||||
self.close_code = code
|
||||
self.close_msg = str(msg or "")
|
||||
dur = self.closed_at - self.connected_at if self.connected_at else 0.0
|
||||
self.logger.info("on_close code=%s dur=%.2fs msg=%s", code, dur, self.close_msg or "-")
|
||||
self._done.set()
|
||||
|
||||
def connect(self) -> bool:
|
||||
try:
|
||||
import websocket as ws_lib
|
||||
except ImportError:
|
||||
self.logger.error("websocket-client 미설치")
|
||||
return False
|
||||
|
||||
def _runner():
|
||||
app = ws_lib.WebSocketApp(
|
||||
self.ws_url,
|
||||
on_open=self._on_open,
|
||||
on_message=self._on_message,
|
||||
on_error=self._on_error,
|
||||
on_close=self._on_close,
|
||||
)
|
||||
self._ws = app
|
||||
app.run_forever(ping_interval=20, ping_timeout=10)
|
||||
|
||||
t = threading.Thread(target=_runner, daemon=True, name="KIS-limit-probe")
|
||||
t.start()
|
||||
if not self._open_evt.wait(timeout=12.0):
|
||||
self.logger.error("on_open 타임아웃")
|
||||
return False
|
||||
time.sleep(0.4)
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
gap = max(0.0, float(self.gap_sec or 0.12))
|
||||
if self._ws and self._ok_subs:
|
||||
self.logger.info("구독 해제 %d건 후 close", len(self._ok_subs))
|
||||
for i, (tr_id, code) in enumerate(self._ok_subs):
|
||||
if i > 0 and gap > 0:
|
||||
time.sleep(gap)
|
||||
try:
|
||||
self._ws.send(self._build_sub(tr_id, code, subscribe=False))
|
||||
self.logger.info("UNSUB %s %s", tr_id, code)
|
||||
except Exception as e:
|
||||
self.logger.warning("UNSUB fail %s %s: %s", tr_id, code, e)
|
||||
if gap > 0:
|
||||
time.sleep(gap)
|
||||
self._ok_subs.clear()
|
||||
try:
|
||||
if self._ws:
|
||||
self._ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._done.wait(timeout=5.0)
|
||||
|
||||
|
||||
def _count(acks: List[Dict[str, Any]], tr_id: str, codes: List[str]) -> Tuple[int, int, List[str]]:
|
||||
want = set(codes)
|
||||
ok_n = 0
|
||||
fail_n = 0
|
||||
fail_msgs: List[str] = []
|
||||
seen = set()
|
||||
for a in acks:
|
||||
if a.get("tr_id") != tr_id:
|
||||
continue
|
||||
c = str(a.get("code") or "")
|
||||
if c not in want or c in seen:
|
||||
continue
|
||||
seen.add(c)
|
||||
if a.get("ok"):
|
||||
ok_n += 1
|
||||
else:
|
||||
fail_n += 1
|
||||
fail_msgs.append(f"{c}:{a.get('rt_cd')}:{a.get('msg1')}")
|
||||
return ok_n, fail_n, fail_msgs
|
||||
|
||||
|
||||
def _ack_for(acks: List[Dict[str, Any]], tr_id: str, code: str) -> Optional[Dict[str, Any]]:
|
||||
for a in reversed(acks):
|
||||
if a.get("tr_id") == tr_id and a.get("code") == code:
|
||||
return a
|
||||
return None
|
||||
|
||||
|
||||
def _verdict(tick40_ok: int, ob40_ok: int, a41t: Optional[Dict], a42t: Optional[Dict],
|
||||
a41o: Optional[Dict], a42o: Optional[Dict]) -> str:
|
||||
def _ok(a: Optional[Dict]) -> Optional[bool]:
|
||||
if a is None:
|
||||
return None
|
||||
return bool(a.get("ok"))
|
||||
|
||||
t41, t42 = _ok(a41t), _ok(a42t)
|
||||
o41, o42 = _ok(a41o), _ok(a42o)
|
||||
|
||||
if tick40_ok >= 40 and ob40_ok >= 40:
|
||||
if t41 is True and o41 is True and t42 is False and o42 is False:
|
||||
return "따로따로 41 — 체결 41 + 호가 41 (같은 세션·같은 키)"
|
||||
if t41 is True and o41 is True and t42 is True and o42 is True:
|
||||
return "40+40 통과, 42번도 통과 — 한도가 41보다 크거나 장외라 한도 ACK가 안 옴"
|
||||
if t41 is False or o41 is False:
|
||||
return "40+40은 됐는데 41번째 종목이 거부 — 종목수 41(TR 합산이 아님) 쪽"
|
||||
return "40종 체결+호가 모두 성공 → 합산 41은 아님. 41/42 ACK로 세부 판정"
|
||||
if tick40_ok >= 40 and ob40_ok <= 1:
|
||||
return "합산 41 — 체결 40 후 호가는 남은 1칸만 (또는 0칸)"
|
||||
if tick40_ok + ob40_ok <= 41 and tick40_ok >= 1 and ob40_ok >= 1:
|
||||
return "합산 41 가능성 — 체결+호가 성공 합이 41 근처"
|
||||
if tick40_ok + ob40_ok == 0:
|
||||
return "ACK 없음 — 장외 즉시 끊김·invalid approval·동시세션 충돌. 한도 판정 불가"
|
||||
return f"애매 — tick40_ok={tick40_ok} ob40_ok={ob40_ok} (로그 ACK 확인)"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="KIS WS 틱+호가 세션 한도 실측")
|
||||
parser.add_argument(
|
||||
"--slot",
|
||||
choices=("main", "ob"),
|
||||
default="main",
|
||||
help="main=시세 원키, ob=호가 전용 2번째 실전 앱키",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
slot = args.slot
|
||||
|
||||
log = _setup_logging()
|
||||
log.info("=" * 64)
|
||||
log.info("KIS slot=%s 틱+호가 40종 한도 테스트 → %s", slot, LOG_PATH)
|
||||
log.info("=" * 64)
|
||||
|
||||
if len(CODES_40) != 40:
|
||||
log.error("CODES_40 길이 %d ≠ 40", len(CODES_40))
|
||||
return 1
|
||||
|
||||
if slot == "ob":
|
||||
app_key = (get_env_from_db("KIS_APP_KEY_OB_REAL", "") or "").strip()
|
||||
app_secret = (get_env_from_db("KIS_APP_SECRET_OB_REAL", "") or "").strip()
|
||||
key_names = "KIS_APP_KEY_OB_REAL / KIS_APP_SECRET_OB_REAL"
|
||||
else:
|
||||
app_key = (get_env_from_db("KIS_APP_KEY_REAL", "") or "").strip()
|
||||
app_secret = (get_env_from_db("KIS_APP_SECRET_REAL", "") or "").strip()
|
||||
key_names = "KIS_APP_KEY_REAL / KIS_APP_SECRET_REAL"
|
||||
if not app_key or not app_secret:
|
||||
log.error("%s 없음", key_names)
|
||||
return 1
|
||||
|
||||
ws_url = (get_env_from_db("KIS_WS_URL_REAL", "") or "").strip() or "ws://ops.koreainvestment.com:21000"
|
||||
gap = float(get_env_float("KIS_WS_SUBSCRIBE_GAP_MIN_SEC", 0.12))
|
||||
if gap < 0.05:
|
||||
gap = 0.12
|
||||
|
||||
mgr = KISApprovalManager.instance(False, slot=slot)
|
||||
key = mgr.reload_from_file()
|
||||
if not key:
|
||||
log.error(
|
||||
"approval 파일 캐시 없음 (%s). REST 재발급은 실매 WS를 죽이므로 중단.",
|
||||
mgr._cache_path.name,
|
||||
)
|
||||
return 1
|
||||
age_min = mgr.age_sec() / 60.0
|
||||
log.info("키=%s(앞8자 %s…) slot=%s", key_names.split(" / ")[0], app_key[:8], slot)
|
||||
log.info("WS=%s", ws_url)
|
||||
log.info("approval 캐시 재사용 앞8자 %s… age=%.0f분 REST없음", key[:8], age_min)
|
||||
log.info("구독간격=%.2fs | 종목40 + 41=%s 42=%s", gap, CODE_41, CODE_42)
|
||||
log.info("주의: 같은 approval 2세션 → 해당 키 WS가 있으면 ALREADY IN USE")
|
||||
|
||||
probe = TickObLimitProbe(ws_url, key, log, gap)
|
||||
if not probe.connect():
|
||||
log.error("WS 연결 실패")
|
||||
probe.close()
|
||||
return 1
|
||||
|
||||
log.info("--- Phase A: H0STCNT0 x 40 ---")
|
||||
for i, code in enumerate(CODES_40):
|
||||
if not probe._alive:
|
||||
log.warning("세션 종료 — Phase A 중단 (%d/%d)", i, len(CODES_40))
|
||||
break
|
||||
if i > 0 and gap > 0:
|
||||
time.sleep(gap)
|
||||
if not probe.send_sub(TR_TICK, code):
|
||||
break
|
||||
|
||||
if probe._alive:
|
||||
log.info("--- Phase B: H0STASP0 x 같은 40 ---")
|
||||
for i, code in enumerate(CODES_40):
|
||||
if not probe._alive:
|
||||
log.warning("세션 종료 — Phase B 중단 (%d/%d)", i, len(CODES_40))
|
||||
break
|
||||
if gap > 0:
|
||||
time.sleep(gap)
|
||||
if not probe.send_sub(TR_OB, code):
|
||||
break
|
||||
else:
|
||||
log.warning("Phase B 스킵 (세션 없음)")
|
||||
|
||||
if probe._alive:
|
||||
log.info("--- Phase C: 41·42번째 체결 ---")
|
||||
time.sleep(gap)
|
||||
probe.send_sub(TR_TICK, CODE_41)
|
||||
time.sleep(gap)
|
||||
probe.send_sub(TR_TICK, CODE_42)
|
||||
log.info("--- Phase D: 41·42번째 호가 ---")
|
||||
time.sleep(gap)
|
||||
probe.send_sub(TR_OB, CODE_41)
|
||||
time.sleep(gap)
|
||||
probe.send_sub(TR_OB, CODE_42)
|
||||
wait_acks = 8.0
|
||||
log.info("ACK 대기 %.0fs (장외면 틱/호가 데이터는 0이어도 ACK는 올 수 있음)", wait_acks)
|
||||
time.sleep(wait_acks)
|
||||
else:
|
||||
log.warning("Phase C/D 스킵 — %s", probe.fatal_msg or probe.error or "연결 끊김")
|
||||
time.sleep(0.5)
|
||||
|
||||
dur = (time.time() - probe.connected_at) if probe.connected_at else 0.0
|
||||
log.info("연결유지 %.1fs close전 | ticks=%d obs=%d acks=%d err=%s",
|
||||
dur, probe.ticks, probe.obs, len(probe.acks), probe.error or "-")
|
||||
|
||||
probe.close()
|
||||
|
||||
tick40_ok, tick40_fail, tick40_fail_msgs = _count(probe.acks, TR_TICK, CODES_40)
|
||||
ob40_ok, ob40_fail, ob40_fail_msgs = _count(probe.acks, TR_OB, CODES_40)
|
||||
a41t = _ack_for(probe.acks, TR_TICK, CODE_41)
|
||||
a42t = _ack_for(probe.acks, TR_TICK, CODE_42)
|
||||
a41o = _ack_for(probe.acks, TR_OB, CODE_41)
|
||||
a42o = _ack_for(probe.acks, TR_OB, CODE_42)
|
||||
|
||||
def _fmt(a: Optional[Dict[str, Any]]) -> str:
|
||||
if a is None:
|
||||
return "ACK없음"
|
||||
return f"{'OK' if a.get('ok') else 'REJECT'} rt={a.get('rt_cd')} {a.get('msg1')}"
|
||||
|
||||
log.info("=" * 64)
|
||||
log.info("요약")
|
||||
log.info(" 체결 40종 OK=%d FAIL=%d", tick40_ok, tick40_fail)
|
||||
log.info(" 호가 40종 OK=%d FAIL=%d", ob40_ok, ob40_fail)
|
||||
if tick40_fail_msgs:
|
||||
log.info(" 체결 FAIL 샘플: %s", "; ".join(tick40_fail_msgs[:5]))
|
||||
if ob40_fail_msgs:
|
||||
log.info(" 호가 FAIL 샘플: %s", "; ".join(ob40_fail_msgs[:5]))
|
||||
log.info(" 체결 41(%s): %s", CODE_41, _fmt(a41t))
|
||||
log.info(" 체결 42(%s): %s", CODE_42, _fmt(a42t))
|
||||
log.info(" 호가 41(%s): %s", CODE_41, _fmt(a41o))
|
||||
log.info(" 호가 42(%s): %s", CODE_42, _fmt(a42o))
|
||||
log.info(" 실시간 수신 tick=%d ob=%d (지금은 장외면 0이 정상)", probe.ticks, probe.obs)
|
||||
log.info(" close=%s %s", probe.close_code, probe.close_msg or "-")
|
||||
if "ALREADY IN USE" in (probe.fatal_msg or "").upper() or "ALREADY IN USE" in " ".join(
|
||||
str(a.get("msg1") or "") for a in probe.acks
|
||||
).upper():
|
||||
v = (
|
||||
"한도 미측정 — 원키(appkey)가 이미 봇 WS에 점유됨 "
|
||||
"(rt_cd=9 ALREADY IN USE appkey). 앱키당 세션 1개. "
|
||||
"봇이 원키 소켓을 비운 뒤에야 40+호가 한도 실측 가능."
|
||||
)
|
||||
else:
|
||||
v = _verdict(tick40_ok, ob40_ok, a41t, a42t, a41o, a42o)
|
||||
log.info("판정: %s", v)
|
||||
log.info("로그: %s", LOG_PATH)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -67,7 +67,7 @@ def _ui_pct_to_ratio(ui_pct) -> float:
|
||||
def verify_momentum() -> int:
|
||||
import backtest_web as bw
|
||||
import kis_trader.engine.momentum_engine as me
|
||||
import scalping_engine as se # noqa: F401 (웹이 쓰는 베이스 로더)
|
||||
import kis_trader.engine.scalping_engine as se # noqa: F401
|
||||
|
||||
print("\n=== 모멘텀 (MOMENTUM) ===")
|
||||
can = me.get_momentum_defaults_from_db() # 실매 + param_search base (비율)
|
||||
|
||||
Reference in New Issue
Block a user