#!/usr/bin/env python3 """ test_psearch.py — KIS 종목조건검색 API 응답 확인용 단독 스크립트 ================================================================== [국내주식] 시세분석 ① psearch-title (HHKST03900300) — 서버 저장 조건식 목록 조회 ② psearch-result (HHKST03900400) — 특정 조건식 현재 결과 조회 ⚠️ KIS 종목조건검색은 **모의투자 미지원**이라 실키(KIS_APP_KEY_REAL / KIS_APP_SECRET_REAL) + 실전 도메인으로만 호출 가능. 본 스크립트는 KIS_MOCK 값과 무관하게 항상 실전 키로만 호출한다. 이 스크립트의 목적: - 두 API 의 **요청 (URL/헤더/params)** 과 **응답 (status / 응답헤더 / 원본 JSON)** 을 그대로 찍어서 필드명·구조를 사람이 직접 검증. - condition_manager 가 파싱하기 전 원본 응답을 보고 매핑 정확도 점검. 사용: # 1) 목록만 보고 끝내기 $ python test_psearch.py --list # 2) 목록 출력 후 seq 인터랙티브 입력 $ python test_psearch.py # 3) 특정 seq 결과까지 한 번에 $ python test_psearch.py --seq 0 $ python test_psearch.py --seq 3 --user-id mylogin # HTS ID 임시 지정 옵션: --seq <번호> 결과조회 대상 조건식 번호 (없으면 인터랙티브) --user-id KIS_HTS_ID 대신 임시 지정 (테스트용) --list 목록(psearch-title)만 호출하고 종료 --raw 응답 JSON 을 indent 없이 한 줄로 출력 (grep 용이) """ from __future__ import annotations import argparse import json import sys from pathlib import Path SCRIPT_DIR = Path(__file__).resolve().parent sys.path.insert(0, str(SCRIPT_DIR)) # kis_trader 패키지의 검증된 헬퍼들을 그대로 재사용 (별도 토큰/SafeRequest 구현 X) from kis_trader.execution.kis_client import KISClient # noqa: E402 from kis_trader.utils.env import get_env_from_db # noqa: E402 # ────────────────────────────────────────────────────────────────── # 출력 헬퍼 # ────────────────────────────────────────────────────────────────── def _mask(s: str, head: int = 6, tail: int = 4) -> str: """앱키 같은 시크릿을 로그에 찍을 때 앞뒤만 남기고 가린다.""" if not s: return "(empty)" if len(s) <= head + tail: return "*" * len(s) return f"{s[:head]}…{s[-tail:]}" def _print_section(title: str) -> None: print() print("=" * 78) print(f" {title}") print("=" * 78) def _dump_request(url: str, headers: dict, params: dict) -> None: """요청 정보 출력. authorization/appkey/appsecret 은 마스킹.""" print(f"[REQUEST]") print(f" URL : {url}") print(f" PARAMS : {json.dumps(params, ensure_ascii=False)}") safe_h = dict(headers) if "authorization" in safe_h: tok = safe_h["authorization"].replace("Bearer ", "") safe_h["authorization"] = "Bearer " + _mask(tok, 8, 6) if "appkey" in safe_h: safe_h["appkey"] = _mask(safe_h["appkey"]) if "appsecret" in safe_h: safe_h["appsecret"] = _mask(safe_h["appsecret"]) print(f" HEADERS: {json.dumps(safe_h, ensure_ascii=False)}") def _dump_response(r, raw: bool) -> dict: """응답 정보 출력 + 파싱한 dict 반환 (실패 시 빈 dict).""" print(f"[RESPONSE]") print(f" STATUS : {r.status_code}") interesting_resp_headers = { k: v for k, v in r.headers.items() if k.lower() in ( "content-type", "tr_id", "tr_cont", "gt_uid", "rate-limit-remaining", "rate-limit-reset", ) } if interesting_resp_headers: print(f" HEADERS: {json.dumps(interesting_resp_headers, ensure_ascii=False)}") try: j = r.json() except Exception as e: print(f" BODY : ") print(f" TEXT : {r.text[:1000]}") return {} print(f" rt_cd : {j.get('rt_cd')} (0=정상, 그 외=에러)") print(f" msg_cd : {j.get('msg_cd')}") print(f" msg1 : {j.get('msg1')}") print() print("[RAW BODY]") if raw: print(json.dumps(j, ensure_ascii=False)) else: print(json.dumps(j, ensure_ascii=False, indent=2)) return j # ────────────────────────────────────────────────────────────────── # 핵심: 두 API 호출 (raw) # ────────────────────────────────────────────────────────────────── def call_psearch_title(client: KISClient, user_id: str, raw: bool) -> list: """ psearch-title (HHKST03900300) 호출 + 응답 출력. Returns: parsed output2 list (사용자가 보기 쉽게 seq/이름 표 만들기용) """ _print_section("① psearch-title — 서버 저장 조건식 목록") path = "/uapi/domestic-stock/v1/quotations/psearch-title" tr_id = "HHKST03900300" params = {"user_id": user_id} headers = client._headers(tr_id) url = client.base_url + path _dump_request(url, headers, params) r = client._get(path, tr_id, params) j = _dump_response(r, raw) out2 = j.get("output2") or [] if isinstance(out2, dict): out2 = [out2] if out2: print() print("[요약 — 사용자에게 의미 있는 필드만]") print(f" 총 {len(out2)}건") # KIS 실제 응답 키: condition_nm / grp_nm # (문서엔 condition_name 처럼 적힌 자료가 많아 폴백 후보 다중 적용) print(" ┌─────┬──────────────────────────────┬────────────────────┐") print(" │ seq │ condition_nm │ grp_nm │") print(" ├─────┼──────────────────────────────┼────────────────────┤") for row in out2: seq = str(row.get("seq", "?"))[:3].rjust(3) nm = ( row.get("condition_nm") or row.get("condition_name") or row.get("cond_nm") or "" ) grp = (row.get("grp_nm") or row.get("group_nm") or "") print( f" │ {seq} │ {str(nm)[:28].ljust(28)} │ " f"{str(grp)[:18].ljust(18)} │" ) print(" └─────┴──────────────────────────────┴────────────────────┘") return out2 def call_psearch_result( client: KISClient, user_id: str, seq: str, raw: bool, ) -> list: """ psearch-result (HHKST03900400) 호출 + 응답 출력. Returns: parsed output2 list """ _print_section(f"② psearch-result — 조건식 결과 (seq={seq})") path = "/uapi/domestic-stock/v1/quotations/psearch-result" tr_id = "HHKST03900400" params = {"user_id": user_id, "seq": str(seq)} headers = client._headers(tr_id) url = client.base_url + path _dump_request(url, headers, params) r = client._get(path, tr_id, params) j = _dump_response(r, raw) out2 = j.get("output2") or [] if isinstance(out2, dict): out2 = [out2] if out2: print() print("[요약 — 가능한 키 후보 모두 시도해서 추출]") print(f" 총 {len(out2)}종목") print(" ┌────────┬──────────────────────────────┬──────────────┐") print(" │ code │ name │ price │") print(" ├────────┼──────────────────────────────┼──────────────┤") for it in out2[:50]: # 상위 50개만 code = ( it.get("code") or it.get("stck_shrn_iscd") or it.get("mksc_shrn_iscd") or "" ) name = ( it.get("name") or it.get("hts_kor_isnm") or "" ) price = ( it.get("stck_prpr") or it.get("price") or "" ) print( f" │ {str(code)[:6].ljust(6)} │ " f"{str(name)[:28].ljust(28)} │ " f"{str(price)[:12].rjust(12)} │" ) if len(out2) > 50: print(f" …(이하 {len(out2) - 50}종목 생략)") print(" └────────┴──────────────────────────────┴──────────────┘") return out2 # ────────────────────────────────────────────────────────────────── # 메인 # ────────────────────────────────────────────────────────────────── def main() -> int: parser = argparse.ArgumentParser( description="KIS 종목조건검색(psearch-title / psearch-result) 응답 확인", ) parser.add_argument("--seq", help="조건식 번호 (지정 시 결과조회까지 자동 실행)") parser.add_argument("--user-id", help="KIS_HTS_ID 대신 임시 지정") parser.add_argument("--list", action="store_true", help="목록만 호출하고 종료") parser.add_argument("--raw", action="store_true", help="응답 JSON 을 한 줄로 출력") args = parser.parse_args() # ── HTS ID 결정 ── user_id = (args.user_id or get_env_from_db("KIS_HTS_ID", "") or "").strip() if not user_id: print("❌ KIS_HTS_ID 미설정.") print(" update_env_simple.py 로 KIS_HTS_ID 를 DB 에 저장하거나,") print(" --user-id 옵션으로 임시 지정하세요.") return 1 # ── 실키 검증 (모의 미지원) ── real_key = (get_env_from_db("KIS_APP_KEY_REAL", "") or "").strip() real_secret = (get_env_from_db("KIS_APP_SECRET_REAL", "") or "").strip() if not real_key or not real_secret: print("❌ KIS_APP_KEY_REAL / KIS_APP_SECRET_REAL 미설정.") print(" psearch 는 모의투자 미지원이라 실키가 필수입니다.") return 1 print(f"🔑 KIS_APP_KEY_REAL : {_mask(real_key)}") print(f"🔑 KIS_APP_SECRET : {_mask(real_secret)}") print(f"🪪 KIS_HTS_ID : {user_id}") # ── 실전 도메인 KISClient (mock=False 강제) ── try: client = KISClient( mock=False, app_key=real_key, app_secret=real_secret, account_no=(get_env_from_db("KIS_ACCOUNT_NO_REAL", "") or "").strip(), account_code=(get_env_from_db("KIS_ACCOUNT_CODE_REAL", "01") or "01").strip(), ) except Exception as e: print(f"❌ KISClient 초기화 실패: {e}") return 1 if not client._token: print("❌ 접근토큰 발급 실패. kis_token_manager 로그/실키 유효성을 확인하세요.") return 1 print(f"🎫 access_token : {_mask(client._token, 10, 6)}") print(f"🌐 base_url : {client.base_url}") # ── ① 조건식 목록 ── titles = call_psearch_title(client, user_id, args.raw) if args.list: return 0 # ── seq 결정 ── seq = (args.seq or "").strip() if args.seq else "" if not seq: if not titles: print() print("⚠️ 조건식 목록이 비어 결과조회 단계로 진행할 수 없습니다.") print(" HTS/MTS 에 먼저 조건식을 저장한 뒤 다시 실행하세요.") return 0 print() try: seq = input(f"조회할 seq 입력 (예: {titles[0].get('seq', '0')}, 비우면 종료) > ").strip() except EOFError: seq = "" if not seq: print("⏹ 사용자 취소") return 0 # ── ② 조건식 결과 ── call_psearch_result(client, user_id, seq, args.raw) return 0 if __name__ == "__main__": sys.exit(main())