변경 사항 ---- - _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>
313 lines
11 KiB
Python
313 lines
11 KiB
Python
#!/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()
|
|
|