왜: 체결이 없어도 마지막가는 유지인데, 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 (일회성)
340 lines
15 KiB
Python
340 lines
15 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
키움 OpenAPI 토큰 발급 진단 스크립트
|
||
====================================
|
||
|
||
DB(env_config 최신 스냅샷)에 저장된 키움 키 4종 (MOCK·REAL × KEY·SECRET)을
|
||
자동으로 읽어 모의/실전 도메인 각각에 ``POST /oauth2/token`` 을 던지고,
|
||
응답·키 메타정보(길이·공백포함·앞뒤 일부)를 한 번에 보여 준다.
|
||
|
||
키움 토큰 엔드포인트 (kis_ws.KiwoomTokenManager._request_new_token 와 동일):
|
||
- 실전: https://api.kiwoom.com/oauth2/token
|
||
- 모의: https://mockapi.kiwoom.com/oauth2/token
|
||
- body: {"grant_type":"client_credentials","appkey":..., "secretkey":...}
|
||
|
||
사용법
|
||
------
|
||
|
||
DB 키로 자동 테스트::
|
||
|
||
python3 test_kiwoom_token.py
|
||
|
||
인터랙티브 입력 (키에 특수문자/공백 있을 때 가장 안전)::
|
||
|
||
python3 test_kiwoom_token.py --manual
|
||
python3 test_kiwoom_token.py --manual --mode real
|
||
|
||
CLI 인자로 직접 입력 (키 값은 반드시 ``"…"`` 로 감싸세요)::
|
||
|
||
python3 test_kiwoom_token.py --key "<APPKEY>" --secret "<SECRET>" --mode mock
|
||
python3 test_kiwoom_token.py --key="<APPKEY>" --secret="<SECRET>" --mode real
|
||
|
||
자주 보는 에러 코드
|
||
-------------------
|
||
|
||
- 8001: App Key / Secret 검증 실패 → 키 자체가 틀렸거나 OpenAPI 미신청 / 만료
|
||
- 1700: 요청 개수 초과 (au10001 rate limit) → 1분 정도 기다렸다가 재시도
|
||
- 9999: 서버 일시 오류 → 재시도
|
||
|
||
이 스크립트는 토큰 발급만 하므로 계좌번호는 사용하지 않는다 (키움 시세 API 정책).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import getpass
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
from typing import Optional, Tuple
|
||
|
||
import requests
|
||
|
||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||
sys.path.insert(0, str(SCRIPT_DIR))
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
# 도메인 / 모드
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
DOMAINS = {
|
||
"mock": "mockapi.kiwoom.com",
|
||
"real": "api.kiwoom.com",
|
||
}
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
# 키 메타정보 진단
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
def _safe_preview(s: str, head: int = 8, tail: int = 4) -> str:
|
||
"""키를 가운데를 가린 형태로 표시 (앞8자 …끝4자)."""
|
||
if not s:
|
||
return "(empty)"
|
||
if len(s) <= head + tail:
|
||
return repr(s)
|
||
return f"{s[:head]}…{s[-tail:]}"
|
||
|
||
|
||
def _has_whitespace(s: str) -> bool:
|
||
return any(c in s for c in (" ", "\t", "\n", "\r", "\v", "\f"))
|
||
|
||
|
||
def diagnose_key(label: str, value: Optional[str]) -> bool:
|
||
"""키 값 메타 출력. 사용 가능하면 True."""
|
||
v = (value or "")
|
||
raw_len = len(v)
|
||
stripped = v.strip()
|
||
has_ws = _has_whitespace(v)
|
||
print(f" • {label:28s} len={raw_len:3d} preview={_safe_preview(stripped)}"
|
||
f" whitespace={'YES⚠️ ' if has_ws else 'no '}"
|
||
f" {'(empty)' if not stripped else ''}")
|
||
return bool(stripped)
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
# 토큰 발급
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
def request_token(
|
||
appkey: str,
|
||
secretkey: str,
|
||
mode: str,
|
||
timeout: int = 10,
|
||
) -> Tuple[bool, dict]:
|
||
"""
|
||
키움 토큰 발급 단일 호출.
|
||
|
||
Returns:
|
||
(success, response_data_dict)
|
||
success=True 이면 ``response_data_dict`` 안에 token 이 들어 있음.
|
||
"""
|
||
if mode not in DOMAINS:
|
||
raise ValueError(f"mode must be 'mock' or 'real', got {mode!r}")
|
||
|
||
url = f"https://{DOMAINS[mode]}/oauth2/token"
|
||
payload = {
|
||
"grant_type": "client_credentials",
|
||
"appkey": appkey,
|
||
"secretkey": secretkey,
|
||
}
|
||
|
||
try:
|
||
resp = requests.post(url, json=payload, timeout=timeout)
|
||
except requests.RequestException as e:
|
||
return False, {"_exception": str(e), "_url": url}
|
||
|
||
try:
|
||
data = resp.json()
|
||
except ValueError:
|
||
return False, {"_status": resp.status_code, "_text": resp.text[:500]}
|
||
|
||
data["_status"] = resp.status_code
|
||
token = (data.get("token") or data.get("access_token") or "").strip()
|
||
return bool(token), data
|
||
|
||
|
||
def explain_error(data: dict) -> str:
|
||
"""에러 응답 → 사람이 읽는 진단 메시지."""
|
||
if "_exception" in data:
|
||
return f"네트워크 예외: {data['_exception']}"
|
||
|
||
code = str(data.get("return_code", "")).strip()
|
||
msg = str(data.get("return_msg", "")).strip()
|
||
|
||
if "8001" in msg or code == "3":
|
||
return ("[8001] App Key / Secret 검증 실패\n"
|
||
" → 키 자체가 잘못되었거나 OpenAPI 신청이 안 된 상태.\n"
|
||
" → openapi.kiwoom.com 에서 모의/실전 OpenAPI 신청 여부 확인.\n"
|
||
" → 키를 사이트에서 다시 복사해 DB 재등록 (앞뒤 공백 주의).")
|
||
if "1700" in msg or code == "5":
|
||
return ("[1700] 요청 개수 초과 (au10001 rate limit)\n"
|
||
" → 1분 정도 기다렸다가 재시도. 키는 정상일 수 있음.")
|
||
if "9999" in msg:
|
||
return "[9999] 서버 일시 오류 → 재시도"
|
||
return f"기타 오류: code={code} msg={msg}"
|
||
|
||
|
||
def run_test(appkey: str, secretkey: str, mode: str, label: str) -> None:
|
||
"""단일 모드 테스트 + 결과 출력."""
|
||
print(f"\n── [{label}] mode={mode} domain={DOMAINS[mode]} ──")
|
||
|
||
if not appkey or not secretkey:
|
||
print(f" ⏭ 키 없음 (appkey={'∅' if not appkey else 'OK'}, "
|
||
f"secretkey={'∅' if not secretkey else 'OK'}) — 건너뜀")
|
||
return
|
||
|
||
ok, data = request_token(appkey.strip(), secretkey.strip(), mode)
|
||
if ok:
|
||
token = data.get("token") or data.get("access_token")
|
||
exp_dt = data.get("expires_dt", "?")
|
||
exp_in = data.get("expires_in", "?")
|
||
print(f" ✅ 발급 성공! token={_safe_preview(token, 8, 4)} "
|
||
f"expires_dt={exp_dt} expires_in={exp_in}")
|
||
else:
|
||
status = data.pop("_status", "?")
|
||
print(f" ❌ 발급 실패 HTTP={status}")
|
||
print(f" 응답: {json.dumps(data, ensure_ascii=False)}")
|
||
print(f" 진단: {explain_error(data)}")
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
# DB 키 로드
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
def load_keys_from_db() -> dict:
|
||
"""
|
||
DB(env_config 최신 스냅샷)에서 키움 4개 키 + 레거시 폴백 + KIS_MOCK 로드.
|
||
"""
|
||
try:
|
||
from database import TradeDB # type: ignore
|
||
except ImportError as e:
|
||
print(f"⚠️ database 모듈 import 실패: {e}")
|
||
return {}
|
||
|
||
db_path = SCRIPT_DIR / "quant_bot.db"
|
||
try:
|
||
db = TradeDB(db_path=str(db_path))
|
||
except TypeError:
|
||
db = TradeDB()
|
||
|
||
latest = db.get_latest_env()
|
||
snap = (latest or {}).get("snapshot") or {}
|
||
db.close()
|
||
|
||
return {
|
||
"KIWOOM_APP_KEY_MOCK": str(snap.get("KIWOOM_APP_KEY_MOCK", "") or ""),
|
||
"KIWOOM_APP_SECRET_MOCK": str(snap.get("KIWOOM_APP_SECRET_MOCK", "") or ""),
|
||
"KIWOOM_APP_KEY_REAL": str(snap.get("KIWOOM_APP_KEY_REAL", "") or ""),
|
||
"KIWOOM_APP_SECRET_REAL": str(snap.get("KIWOOM_APP_SECRET_REAL", "") or ""),
|
||
# 레거시 폴백
|
||
"KIWOOM_APP_KEY": str(snap.get("KIWOOM_APP_KEY", "") or ""),
|
||
"KIWOOM_APP_SECRET": str(snap.get("KIWOOM_APP_SECRET", "") or ""),
|
||
"KIS_MOCK": str(snap.get("KIS_MOCK", "true") or "true"),
|
||
}
|
||
|
||
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
# main
|
||
# ──────────────────────────────────────────────────────────────────────
|
||
def _prompt_manual_keys() -> Tuple[str, str]:
|
||
"""
|
||
인터랙티브 입력 — 키에 ``--`` ``=`` ``$`` ``"`` 같은 특수문자가 있어도
|
||
셸 escaping 영향 없이 안전하게 받음. secretkey 는 getpass 로 화면 가림.
|
||
"""
|
||
print("\n📝 인터랙티브 입력 (Enter 로 확정, secretkey 는 화면에 안 찍힘)")
|
||
appkey = input(" appkey : ").strip()
|
||
secretkey = getpass.getpass(" secretkey: ").strip()
|
||
if not appkey or not secretkey:
|
||
print("❌ 빈 값은 허용되지 않습니다.")
|
||
sys.exit(2)
|
||
return appkey, secretkey
|
||
|
||
|
||
def main() -> int:
|
||
p = argparse.ArgumentParser(
|
||
description="키움 OpenAPI 토큰 발급 진단",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog=__doc__,
|
||
)
|
||
p.add_argument("--key", help='직접 입력할 appkey (값은 "…" 로 감싸길 권장)')
|
||
p.add_argument("--secret", help='직접 입력할 secretkey (값은 "…" 로 감싸길 권장)')
|
||
p.add_argument("--manual", action="store_true",
|
||
help="인터랙티브 입력 모드 (키에 특수문자 있을 때 권장)")
|
||
p.add_argument("--mode", choices=("mock", "real", "both"), default="both",
|
||
help="테스트 도메인 (default: both)")
|
||
args = p.parse_args()
|
||
|
||
# ── 인터랙티브 모드 ────────────────────────────────────────────
|
||
if args.manual:
|
||
print("=" * 70)
|
||
print("🔧 인터랙티브 입력 모드 (DB 무시)")
|
||
print("=" * 70)
|
||
appkey, secretkey = _prompt_manual_keys()
|
||
diagnose_key("입력 APPKEY", appkey)
|
||
diagnose_key("입력 SECRETKEY", secretkey)
|
||
modes = ("mock", "real") if args.mode == "both" else (args.mode,)
|
||
for m in modes:
|
||
run_test(appkey, secretkey, m, label=f"입력키→{m.upper()}")
|
||
return 0
|
||
|
||
# ── CLI 인자 직접 입력 모드 ─────────────────────────────────────
|
||
if args.key or args.secret:
|
||
if not (args.key and args.secret):
|
||
print("❌ --key 와 --secret 은 함께 지정해야 합니다.\n"
|
||
" 특수문자 때문에 까다롭다면 --manual 을 사용하세요.\n"
|
||
" 예시: python3 test_kiwoom_token.py --manual --mode real")
|
||
return 2
|
||
print("=" * 70)
|
||
print("🔧 CLI 인자 입력 모드 (DB 무시)")
|
||
print("=" * 70)
|
||
diagnose_key("입력 APPKEY", args.key)
|
||
diagnose_key("입력 SECRETKEY", args.secret)
|
||
modes = ("mock", "real") if args.mode == "both" else (args.mode,)
|
||
for m in modes:
|
||
run_test(args.key, args.secret, m, label=f"입력키→{m.upper()}")
|
||
return 0
|
||
|
||
# ── DB 자동 모드 ──────────────────────────────────────────────
|
||
print("=" * 70)
|
||
print("🔍 키움 토큰 진단 — DB env_config 최신 스냅샷")
|
||
print("=" * 70)
|
||
|
||
keys = load_keys_from_db()
|
||
if not keys:
|
||
print("❌ DB 에서 키를 불러오지 못했습니다.")
|
||
return 1
|
||
|
||
print(f"\nKIS_MOCK = {keys['KIS_MOCK']!r} "
|
||
f"(코드 정책: 시세는 항상 실키, 단 본 스크립트는 둘 다 시도)")
|
||
print("\n📦 DB에 저장된 키 메타:")
|
||
diagnose_key("KIWOOM_APP_KEY_MOCK", keys["KIWOOM_APP_KEY_MOCK"])
|
||
diagnose_key("KIWOOM_APP_SECRET_MOCK", keys["KIWOOM_APP_SECRET_MOCK"])
|
||
diagnose_key("KIWOOM_APP_KEY_REAL", keys["KIWOOM_APP_KEY_REAL"])
|
||
diagnose_key("KIWOOM_APP_SECRET_REAL", keys["KIWOOM_APP_SECRET_REAL"])
|
||
if keys["KIWOOM_APP_KEY"] or keys["KIWOOM_APP_SECRET"]:
|
||
print(" (레거시 폴백)")
|
||
diagnose_key("KIWOOM_APP_KEY", keys["KIWOOM_APP_KEY"])
|
||
diagnose_key("KIWOOM_APP_SECRET", keys["KIWOOM_APP_SECRET"])
|
||
|
||
# ── 호출 ─────────────────────────────────────────────────────
|
||
if args.mode in ("mock", "both"):
|
||
run_test(
|
||
keys["KIWOOM_APP_KEY_MOCK"],
|
||
keys["KIWOOM_APP_SECRET_MOCK"],
|
||
"mock",
|
||
label="MOCK키→모의도메인",
|
||
)
|
||
|
||
if args.mode in ("real", "both"):
|
||
run_test(
|
||
keys["KIWOOM_APP_KEY_REAL"],
|
||
keys["KIWOOM_APP_SECRET_REAL"],
|
||
"real",
|
||
label="REAL키→실전도메인",
|
||
)
|
||
|
||
# 레거시 키도 있으면 둘 다 찔러본다 (어느 도메인에서 통하는지 확인)
|
||
if keys["KIWOOM_APP_KEY"] and keys["KIWOOM_APP_SECRET"]:
|
||
if args.mode in ("mock", "both"):
|
||
run_test(
|
||
keys["KIWOOM_APP_KEY"], keys["KIWOOM_APP_SECRET"],
|
||
"mock", label="LEGACY키→모의도메인",
|
||
)
|
||
if args.mode in ("real", "both"):
|
||
run_test(
|
||
keys["KIWOOM_APP_KEY"], keys["KIWOOM_APP_SECRET"],
|
||
"real", label="LEGACY키→실전도메인",
|
||
)
|
||
|
||
print("\n" + "=" * 70)
|
||
print("✅ 진단 끝")
|
||
print("=" * 70)
|
||
print("팁:")
|
||
print(" • whitespace=YES 가 보이면 → DB 에 공백 섞임. 키 다시 등록 필요.")
|
||
print(" • 8001 만 계속 → openapi.kiwoom.com 에서 OpenAPI 신청 여부 확인.")
|
||
print(" • 1700 → 1분 후 재시도 (rate limit).")
|
||
print(" • 둘 다 실패해도 매매에는 영향 없음 — 갭보정만 비활성됨.")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|