Files
kis_trader/remove/legacy_root/test_kiwoom_token.py
Your Name 6d2a706a48 커밋 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 (일회성)
2026-08-18 00:13:17 +09:00

340 lines
15 KiB
Python
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())