커밋 1 — 실매 가격 TTL 구멍 (본체)
왜: 체결이 없어도 마지막가는 유지인데, TTL로 None 만들고 매도/EOD를 건너뛰어 8/5 돌파·금요일 leftover가 남음. 호가필터 TTL 구멍과 같은 병. 넣을 파일 신규: kis_trader/engine/live_sell_price.py kis_trader/strategies/base.py (_ws_last_quote, _resolve_sell_price) 전략: momentum.py scalping.py tail_catch.py breakout.py range_break.py dart_strategy.py updow_strategy.py updown_feed.py us_momentum.py WS: ws_manager.py kis_ws.py kiwoom_ws.py ls_ws.py kis_ws_overseas.py kis_trader/web/live_config_schema.py (WS_PRICE_MAX_AGE_SEC 기본 0) database.py (키 주석 + legacy/ sys.path) kis_trader/execution/order_manager.py (잔고 있는데 40240000 ghost_purge 금지 — 같은 EOD 사고) EOD가 min_hold에 안 막히게 손본 momentum_hts_logic.py / scalping_engine.py / tail_engine.py (이 대화에서 손본 부분만 확인 후) 문서: docs/like_mcp.md/db_erd.md code_architecture.md (가격 TTL 문구) 메시지 초안 fix: 매수·매도 현재가를 TTL로 버리지 않음 (마지막 RAM) 횡보·체결 공백을 죽은 캐시로 오인해 None 처리하면 손절·EOD가 스킵된다. 호가필터와 같이 나이는 무시하고 마지막 체결가를 유지한다. EOD는 매수가 폴백. 영향: 실매 O / 백테·옵투나 봉 경로 거의 무관 (엔진 식 변경 아님) 커밋 2 — 루트 정리 (remove/ vs legacy/) 왜: 루트 단독봇·테스트는 지울 보관함으로. 웹·알람이 아직 쓰는 모듈은 remove에 두면 나중에 폴더째 삭제 때 깨짐. 넣을 파일 이동: 미사용 → remove/legacy_root/ (래퍼, ETF/키움 옛봇, 테스트, kiwoom_rest_api 등) 이동: 사용 중 → legacy/ (holding_bot kis_holding_ver1 news_analyzer kis_long_ver1/2) 신규: kis_trader/utils/legacy_root.py legacy/README.md remove/README.md import 경로: backtest_web.py mm_butler.py mm_remote.py updow_holding_cfg.py dbband_stock_cfg.py param_search_updow*.py dbband_param_search.py param_search_apply_snapshot.py verify_three_paths.py docs/like_mcp.md/code_architecture.md 수동 노트 메시지 초안 chore: 미사용 루트는 remove/, 웹·알람 구모듈은 legacy/ remove는 나중에 통째 삭제 예정. holding_bot·news_analyzer·kis_long은 ensure_legacy_root로 legacy/만 본다. 빼기: scratch/set_ws_price_max_age_zero.py (일회성)
This commit is contained in:
228
remove/legacy_root/_test_kiwoom_condition_realtime.py
Normal file
228
remove/legacy_root/_test_kiwoom_condition_realtime.py
Normal file
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
_test_kiwoom_condition_realtime.py — 키움 웹소켓 '실시간 조건검색'(ka10173 / CNSRREQ) 수신 테스트
|
||||
====================================================================================================
|
||||
[목적]
|
||||
키움 신형 WS 로 조건검색식 'momentum' 을 실시간(search_type=1) 등록해,
|
||||
① 초기 매칭 종목 리스트와 ② 실시간 편입/이탈(REAL, 843=삽입 I/삭제 D, 9001=종목코드)을
|
||||
수신할 수 있는지 확인한다. (유니버스 실매 전환의 핵심 전제)
|
||||
|
||||
[흐름]
|
||||
1) LOGIN → CNSRLST 로 'momentum' 조건식 seq 를 이름으로 해결
|
||||
2) CNSRREQ(search_type=1, stex_tp=K) 로 실시간 등록 → 초기 응답(현재 매칭) 출력
|
||||
3) WATCH_SEC 동안 REAL push(편입/이탈) 수신·출력
|
||||
4) CNSRCLR 로 실시간 해제 후 종료
|
||||
|
||||
[주의]
|
||||
- 읽기 전용(조회/실시간 수신)만 한다. 주문·저장은 하지 않는다.
|
||||
- 장 마감 후에는 실시간 편입/이탈 push 가 없을 수 있다(초기 매칭 리스트로 요청 성공만 확인).
|
||||
- 응답 원문(raw)을 그대로 찍어 필드명이 문서와 달라도 눈으로 확인 가능.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from kis_trader.utils.env import get_env_from_db, get_env_int
|
||||
from kis_trader.ws.kis_ws import _get_kiwoom_token_cached
|
||||
|
||||
TARGET_NAME = "momentum" # 실시간 등록할 조건식 이름
|
||||
WATCH_SEC = get_env_int("KIWOOM_COND_RT_WATCH_SEC", 30) # 실시간 수신 관찰 시간(초)
|
||||
|
||||
|
||||
def _load_kiwoom_creds():
|
||||
kw_mock_raw = (get_env_from_db("KIWOOM_MOCK", "") or "").strip().lower()
|
||||
is_mock = kw_mock_raw in ("1", "true", "y", "yes", "on")
|
||||
if is_mock:
|
||||
key = (get_env_from_db("KIWOOM_APP_KEY_MOCK", "") or "").strip()
|
||||
secret = (get_env_from_db("KIWOOM_APP_SECRET_MOCK", "") or "").strip()
|
||||
else:
|
||||
key = (get_env_from_db("KIWOOM_APP_KEY_REAL", "") or "").strip()
|
||||
secret = (get_env_from_db("KIWOOM_APP_SECRET_REAL", "") or "").strip()
|
||||
if not key or not secret:
|
||||
key = key or (get_env_from_db("KIWOOM_APP_KEY", "") or "").strip()
|
||||
secret = secret or (get_env_from_db("KIWOOM_APP_SECRET", "") or "").strip()
|
||||
return key, secret, is_mock
|
||||
|
||||
|
||||
def _seq_name(item):
|
||||
"""CNSRLST data 항목: [seq, name] 배열 또는 {seq,name} dict 모두 허용."""
|
||||
if isinstance(item, (list, tuple)):
|
||||
return (str(item[0]) if len(item) > 0 else ""), (str(item[1]) if len(item) > 1 else "")
|
||||
return str(item.get("seq")), str(item.get("name"))
|
||||
|
||||
|
||||
def _extract_code(item):
|
||||
"""조건검색 결과 항목에서 종목코드 추출 (9001 우선, jmcode 폴백)."""
|
||||
if isinstance(item, dict):
|
||||
return str(item.get("9001") or item.get("jmcode") or "").strip()
|
||||
if isinstance(item, (list, tuple)) and item:
|
||||
return str(item[0]).strip()
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
import websocket # websocket-client
|
||||
except Exception as e:
|
||||
print(f"❌ websocket-client 미설치: {e}")
|
||||
return 1
|
||||
|
||||
key, secret, is_mock = _load_kiwoom_creds()
|
||||
if not key or not secret:
|
||||
print("❌ 키움 앱키/시크릿 미설정 — env_config 확인")
|
||||
return 1
|
||||
token = _get_kiwoom_token_cached(key, secret, is_mock)
|
||||
if not token:
|
||||
print("❌ 키움 토큰 발급 실패")
|
||||
return 1
|
||||
print(f"🎫 토큰 OK (mock={is_mock}, 앞8자={token[:8]}…)")
|
||||
|
||||
url = (
|
||||
get_env_from_db("KIWOOM_WS_URL_MOCK", "wss://mockapi.kiwoom.com:10000/api/dostk/websocket")
|
||||
if is_mock else
|
||||
get_env_from_db("KIWOOM_WS_URL_REAL", "wss://api.kiwoom.com:10000/api/dostk/websocket")
|
||||
)
|
||||
print(f"🌐 WS 연결: {url}")
|
||||
|
||||
st = {
|
||||
"seq": None, "registered": False, "cleared": False,
|
||||
"reg_deadline": None, "error": None, "close": False,
|
||||
"real_events": 0, "initial_codes": None,
|
||||
}
|
||||
|
||||
def on_open(ws):
|
||||
ws.send(json.dumps({"trnm": "LOGIN", "token": token}))
|
||||
print("📡 LOGIN 발송")
|
||||
|
||||
def on_message(ws, message):
|
||||
try:
|
||||
data = json.loads(message)
|
||||
except Exception:
|
||||
print(f"📥 (raw) {message[:200]}")
|
||||
return
|
||||
trnm = data.get("trnm")
|
||||
|
||||
if trnm == "PING":
|
||||
try:
|
||||
ws.send(message)
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
if trnm == "LOGIN":
|
||||
if str(data.get("return_code")) in ("0", "0.0"):
|
||||
print("✅ LOGIN OK → CNSRLST(목록조회)")
|
||||
ws.send(json.dumps({"trnm": "CNSRLST"}))
|
||||
else:
|
||||
st["error"] = f"LOGIN 실패: {data.get('return_msg')}"
|
||||
ws.close()
|
||||
return
|
||||
|
||||
if trnm == "CNSRLST":
|
||||
found = None
|
||||
for it in (data.get("data") or []):
|
||||
seq, name = _seq_name(it)
|
||||
if name.strip().lower() == TARGET_NAME:
|
||||
found = seq
|
||||
break
|
||||
if found is None:
|
||||
st["error"] = f"'{TARGET_NAME}' 조건식 없음 (목록: {data.get('data')})"
|
||||
ws.close()
|
||||
return
|
||||
st["seq"] = found
|
||||
print(f"🎯 '{TARGET_NAME}' seq={found} → CNSRREQ 실시간(search_type=1) 등록")
|
||||
ws.send(json.dumps({
|
||||
"trnm": "CNSRREQ", "seq": found, "search_type": "1", "stex_tp": "K",
|
||||
}))
|
||||
return
|
||||
|
||||
if trnm == "CNSRREQ":
|
||||
rc = str(data.get("return_code"))
|
||||
print("\n===== CNSRREQ 초기 응답 원문 =====")
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2)[:2000])
|
||||
if rc not in ("0", "0.0"):
|
||||
st["error"] = f"CNSRREQ 실패 rc={rc} msg={data.get('return_msg')}"
|
||||
ws.close()
|
||||
return
|
||||
codes = [_extract_code(it) for it in (data.get("data") or [])]
|
||||
codes = [c for c in codes if c]
|
||||
st["initial_codes"] = codes
|
||||
st["registered"] = True
|
||||
st["reg_deadline"] = time.time() + WATCH_SEC
|
||||
print(f"\n✅ 실시간 등록 성공 — 초기 매칭 {len(codes)}종목: {codes[:20]}")
|
||||
print(f"⏳ {WATCH_SEC}초간 실시간 편입/이탈(REAL) 수신 대기…")
|
||||
return
|
||||
|
||||
if trnm == "REAL":
|
||||
for it in (data.get("data") or []):
|
||||
vals = it.get("values") if isinstance(it, dict) else None
|
||||
if not isinstance(vals, dict):
|
||||
continue
|
||||
code = str(vals.get("9001") or "").strip()
|
||||
ins_del = str(vals.get("843") or "").strip() # I=삽입(편입), D=삭제(이탈)
|
||||
sig = str(vals.get("841") or "").strip()
|
||||
tm = str(vals.get("20") or "").strip()
|
||||
kind = "편입(I)" if ins_del == "I" else ("이탈(D)" if ins_del == "D" else ins_del)
|
||||
st["real_events"] += 1
|
||||
print(f" 📶 REAL {kind} 종목={code} 신호seq={sig} 시각={tm}")
|
||||
return
|
||||
|
||||
if trnm == "CNSRCLR":
|
||||
print(f"🧹 CNSRCLR(실시간 해제) 응답: rc={data.get('return_code')}")
|
||||
st["cleared"] = True
|
||||
ws.close()
|
||||
return
|
||||
|
||||
print(f"📥 기타: {json.dumps(data, ensure_ascii=False)[:300]}")
|
||||
|
||||
def on_error(ws, err):
|
||||
st["error"] = f"WS 오류: {err}"
|
||||
|
||||
def on_close(ws, code, msg):
|
||||
st["close"] = True
|
||||
|
||||
ws = websocket.WebSocketApp(
|
||||
url, on_open=on_open, on_message=on_message,
|
||||
on_error=on_error, on_close=on_close,
|
||||
)
|
||||
th = threading.Thread(target=ws.run_forever, kwargs={"ping_interval": 0}, daemon=True)
|
||||
th.start()
|
||||
|
||||
# 등록 후 WATCH_SEC 경과 → CNSRCLR 해제 → 종료
|
||||
hard_deadline = time.time() + WATCH_SEC + 30
|
||||
while not st["close"] and time.time() < hard_deadline:
|
||||
time.sleep(0.2)
|
||||
if st["error"]:
|
||||
break
|
||||
if (st["registered"] and not st["cleared"]
|
||||
and st["reg_deadline"] and time.time() >= st["reg_deadline"]):
|
||||
try:
|
||||
ws.send(json.dumps({"trnm": "CNSRCLR", "seq": st["seq"]}))
|
||||
print("📤 CNSRCLR(실시간 해제) 발송")
|
||||
except Exception:
|
||||
pass
|
||||
st["reg_deadline"] = None # 1회만
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if st["error"]:
|
||||
print(f"\n🚨 {st['error']}")
|
||||
return 2
|
||||
if not st["registered"]:
|
||||
print("\n⏱️ 실시간 등록 미완료(응답 없음/타임아웃)")
|
||||
return 3
|
||||
|
||||
print(f"\n📊 요약: 초기매칭 {len(st['initial_codes'] or [])}종목 · REAL 이벤트 {st['real_events']}건")
|
||||
print("🎉 실시간 조건검색 요청/등록/해제 경로 검증 완료"
|
||||
+ (" (장중 아니라 편입/이탈 push 는 0건일 수 있음)" if st["real_events"] == 0 else ""))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user