#!/usr/bin/env python3 """키움증권 REST API 클라이언트 — READ-ONLY. ⚠️ 매매 절대 원칙 (변경 금지) 이 모듈은 조회 전용입니다. 매수·매도·정정·취소 등 주문성 함수를 절대 추가하지 마십시오. 관련 가드: agent SOUL.md "매매 절대 원칙" 섹션, MEMORY: kiwoom_read_only.md. 워치리스트 도달 등 트리거가 발생해도 동작은 텔레그램 알림(별도 모듈)까지만입니다. 자격증명: /Users/snowoyh/.openclaw/credentials/kiwoom.json 계좌 라벨: '일반', 'ISA', '가희_일반', '가희_ISA' — 각 계좌마다 별도 appkey/secretkey 사용. 신규 계좌 추가 시 `<이름>_<상품>` 컨벤션을 유지해야 owner 그룹 prefix 분기가 자동 동작 (리포트의 본인/가희 블록). """ from __future__ import annotations import json import sys import threading import time import urllib.request from datetime import datetime, timezone, timedelta from pathlib import Path KST = timezone(timedelta(hours=9)) CREDENTIALS = Path('/Users/snowoyh/.openclaw/credentials/kiwoom.json') WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace') STATE_DIR = WORKSPACE / 'state' TOKEN_CACHE_DIR = STATE_DIR / 'kiwoom_tokens' ENDPOINT_TOKEN = '/oauth2/token' ENDPOINT_ACNT = '/api/dostk/acnt' ENDPOINT_STKINFO = '/api/dostk/stkinfo' ENDPOINT_MRKCOND = '/api/dostk/mrkcond' # 시세(market condition) — ka10004 호가 등 ENDPOINT_CHART = '/api/dostk/chart' # 차트 — ka10081 일봉 TR_DEPOSIT = 'kt00001' # 예수금상세현황요청 TR_ACNT_EVAL = 'kt00004' # 계좌평가현황요청 (acnt_nm·brch_nm 포함) TR_TRUST_TRADE = 'kt00015' # 위탁종합거래내역요청 (tp='1' 입출금 — 당일 추가입금/인출 추출용) TR_POSITIONS = 'kt00018' # 계좌평가잔고내역요청 (종목별 상세, 수수료·세금 포함) TR_TRADE_DIARY = 'ka10170' # 당일매매일지요청 (round-trip·풀매도 포함, 잔고에 없는 거래도 잡힘) TR_ORDER_EXEC = 'kt00007' # 계좌별주문체결내역상세 (주문번호·시각·체결가·체결수량 단위, REST/영웅문 출처 구분) TR_OPEN_ORDERS = 'ka10075' # 미체결요청 (활성 미체결만 — 정정/취소 대상 추출용) TR_STOCK_INFO = 'ka10001' # 주식기본정보요청 (현재가·등락률) — 단일 종목 TR_WATCHLIST_INFO = 'ka10095' # 관심종목정보요청 — 다종목 한번에 (`|` 구분, 19종목 0.05s) TR_STOCK_LIST = 'ka10099' # 종목정보 리스트 (종목코드·명 전체) TR_DAILY_CHART = 'ka10081' # 주식일봉차트조회 (수정주가 포함, 600일치까지) TR_MINUTE_CHART = 'ka10080' # 주식분봉차트조회 (1/3/5/10/15/30/45/60분, 한 호출 ~900봉) TR_INVESTOR_FLOW = 'ka10059' # 종목별투자자기관별 (외국인·기관 일별 매매) STOCK_CODES_CACHE = STATE_DIR / 'stock_codes.json' def load_credentials() -> dict: if not CREDENTIALS.exists(): raise SystemExit( f'자격증명 파일 없음: {CREDENTIALS}\n' f'kiwoom.json.example 복사해서 채우세요.' ) return json.loads(CREDENTIALS.read_text()) def save_credentials(creds: dict) -> None: CREDENTIALS.write_text(json.dumps(creds, ensure_ascii=False, indent=2)) def get_account_creds(label: str) -> dict: creds = load_credentials() accounts = creds.get('accounts', {}) if label not in accounts: raise KeyError(f'알 수 없는 계좌 라벨: {label}. 등록된 라벨: {list(accounts)}') return accounts[label] def base_url() -> str: return load_credentials().get('base_url', 'https://api.kiwoom.com').rstrip('/') def _http_post_json(url: str, body: dict, headers: dict | None = None) -> dict: return _http_post_full(url, body, headers)[0] def _http_post_full(url: str, body: dict, headers: dict | None = None) -> tuple[dict, dict]: """POST + return (json body, response headers dict).""" data = json.dumps(body).encode('utf-8') h = {'Content-Type': 'application/json;charset=UTF-8'} if headers: h.update(headers) req = urllib.request.Request(url, data=data, method='POST', headers=h) try: with urllib.request.urlopen(req, timeout=15) as r: resp_headers = {k.lower(): v for k, v in r.headers.items()} return json.loads(r.read().decode('utf-8', 'ignore')), resp_headers except urllib.error.HTTPError as e: body_text = e.read().decode('utf-8', 'ignore') raise RuntimeError(f'HTTP {e.code} {url}: {body_text}') from e def _token_cache_path(label: str) -> Path: return TOKEN_CACHE_DIR / f'{label}.json' _TOKEN_LOCKS: dict[str, threading.Lock] = {} _TOKEN_LOCKS_GUARD = threading.Lock() # force=True 발급 직후 1초 이내에 같은 label로 다시 force 호출이 들어오면 직전 토큰을 reuse. # behive_web 페이지 로드 시 4계좌 × 다중 TR 병렬 호출 → 한 호출이 8005 받고 force 재발급한 직후 # 다른 thread가 또 force 재발급하면서 키움이 새 토큰 invalidate 시키는 race를 차단. _FORCE_REUSE_WINDOW_SEC = 1.0 def _label_lock(label: str) -> threading.Lock: with _TOKEN_LOCKS_GUARD: lock = _TOKEN_LOCKS.get(label) if lock is None: lock = threading.Lock() _TOKEN_LOCKS[label] = lock return lock def issue_token(label: str, force: bool = False) -> str: """OAuth 토큰 발급 + 캐싱. 계좌(라벨) 단위로 분리 저장.""" cache_path = _token_cache_path(label) if not force and cache_path.exists(): cache = json.loads(cache_path.read_text()) if cache.get('expires_at', 0) - 60 > time.time(): return cache['access_token'] with _label_lock(label): if cache_path.exists(): cache = json.loads(cache_path.read_text()) valid = cache.get('expires_at', 0) - 60 > time.time() if valid: if not force: return cache['access_token'] # force=True여도, 직전 N초 이내 다른 thread가 이미 새로 발급했다면 그걸 재사용. if time.time() - cache.get('issued_at_ts', 0) < _FORCE_REUSE_WINDOW_SEC: return cache['access_token'] acc = get_account_creds(label) url = base_url() + ENDPOINT_TOKEN body = { 'grant_type': 'client_credentials', 'appkey': acc['appkey'], 'secretkey': acc['secretkey'], } resp = _http_post_json(url, body) token = resp.get('token') or resp.get('access_token') expires_dt = resp.get('expires_dt') expires_in = resp.get('expires_in') if not token: raise RuntimeError(f'토큰 발급 실패 ({label}): {resp}') if expires_dt: try: dt = datetime.strptime(expires_dt, '%Y%m%d%H%M%S').replace(tzinfo=KST) expires_at = dt.timestamp() except ValueError: expires_at = time.time() + 1800 elif expires_in: expires_at = time.time() + int(expires_in) else: expires_at = time.time() + 1800 now_ts = time.time() cache_path.parent.mkdir(parents=True, exist_ok=True) cache_path.write_text(json.dumps({ 'label': label, 'access_token': token, 'expires_at': expires_at, 'issued_at': datetime.now(KST).isoformat(), 'issued_at_ts': now_ts, 'raw': resp, }, ensure_ascii=False, indent=2)) return token def auth_headers(label: str, tr_id: str | None = None, cont_yn: str = 'N', next_key: str = '') -> dict: """인증·TR 헤더 생성. tr_id는 키움 API ID (예: 'kt00018').""" h = { 'Authorization': f'Bearer {issue_token(label)}', 'Content-Type': 'application/json;charset=UTF-8', 'cont-yn': cont_yn, 'next-key': next_key, } if tr_id: h['api-id'] = tr_id return h def list_accounts() -> list[dict]: """credentials에 등록된 계좌 목록 (라벨, 계좌번호) 반환.""" creds = load_credentials() return [ {'label': label, 'account_no': info.get('account_no', '')} for label, info in creds.get('accounts', {}).items() ] def _to_int(s) -> int: """키움 응답의 zero-padded string → int. 빈 값은 0.""" if s is None or s == '': return 0 s = str(s).strip() neg = s.startswith('-') if neg: s = s[1:] s = s.lstrip('0') or '0' return -int(s) if neg else int(s) def _to_float(s) -> float: if s is None or s == '': return 0.0 return float(str(s).strip()) def _clean_code(s: str) -> str: """'A005930' → '005930'. 'A' 접두 제거.""" s = (s or '').strip() return s[1:] if s.startswith('A') else s def _call(label: str, tr_id: str, body: dict, endpoint: str = ENDPOINT_ACNT) -> dict: url = base_url() + endpoint resp = _http_post_json(url, body, auth_headers(label, tr_id=tr_id)) if resp.get('return_code', 0) == 0: return resp msg = str(resp.get('return_msg') or '') if '8005' in msg or 'Token이 유효하지 않습니다' in msg: issue_token(label, force=True) resp = _http_post_json(url, body, auth_headers(label, tr_id=tr_id)) if resp.get('return_code', 0) == 0: return resp raise RuntimeError(f'{tr_id} [{label}] 실패: {resp.get("return_msg")} (full={resp})') def _call_paginated(label: str, tr_id: str, body: dict, list_field: str, endpoint: str = ENDPOINT_ACNT, max_pages: int = 50) -> dict: """list 응답이 페이지로 나뉘어 올 수 있는 TR 호출 (cont-yn/next-key 헤더 페이징). 명세상 모든 list 반환 TR 응답에 `cont-yn`/`next-key` 헤더 정의됨 — 데이터 많으면 'Y'로 떨어져 다음 페이지 요청 필요. _call은 1페이지만 받으니 종목·거래 많은 계좌에서 누락 위험. 동작: - 첫 페이지를 받아 base 응답으로 두고, 후속 페이지의 `list_field` 만 누적 합치기. - 헤더 외 본문 필드(`tot_*` 합계 등)는 첫 페이지 값 유지 — 키움이 합계를 마지막 페이지에만 넣을 가능성도 있어 위험하지만, 현재 명세 example 보면 합계는 매 페이지 동일하게 옴. - max_pages 안전 cap (무한루프 방지). - 8005 토큰 만료는 첫 페이지에서만 자동 재시도 (이후 페이지는 토큰 안정 가정). """ url = base_url() + endpoint cont_yn = 'N' next_key = '' base: dict | None = None for page in range(max_pages): headers = auth_headers(label, tr_id=tr_id, cont_yn=cont_yn, next_key=next_key) resp, resp_headers = _http_post_full(url, body, headers) if resp.get('return_code', 0) != 0: msg = str(resp.get('return_msg') or '') if page == 0 and ('8005' in msg or 'Token이 유효하지 않습니다' in msg): issue_token(label, force=True) headers = auth_headers(label, tr_id=tr_id, cont_yn=cont_yn, next_key=next_key) resp, resp_headers = _http_post_full(url, body, headers) if resp.get('return_code', 0) != 0: raise RuntimeError(f'{tr_id} [{label}] 실패: {resp.get("return_msg")}') else: raise RuntimeError(f'{tr_id} [{label}] page={page} 실패: {resp.get("return_msg")}') if base is None: base = resp base.setdefault(list_field, []) else: base[list_field].extend(resp.get(list_field) or []) cy = (resp_headers.get('cont-yn') or '').strip().upper() nk = (resp_headers.get('next-key') or '').strip() if cy == 'Y' and nk: cont_yn = 'Y' next_key = nk time.sleep(0.15) else: return base raise RuntimeError(f'{tr_id} [{label}] 페이지 한도({max_pages}) 초과') # ---- 조회 함수 ---- def get_balance(account_label: str) -> dict: """예수금·주문가능금액 등 (kt00001).""" resp = _call(account_label, TR_DEPOSIT, {'qry_tp': '3'}) return { 'label': account_label, 'entr': _to_int(resp.get('entr')), # 예수금 'd1_entra': _to_int(resp.get('d1_entra')), # D+1 예수금 'd2_entra': _to_int(resp.get('d2_entra')), # D+2 예수금 'ord_alow_amt': _to_int(resp.get('ord_alow_amt')), # 주문가능금액 'pymn_alow_amt': _to_int(resp.get('pymn_alow_amt')), # 출금가능금액 'repl_amt': _to_int(resp.get('repl_amt')), # 대용금 'raw': resp, } def get_account_summary(account_label: str) -> dict: """계좌평가현황 (kt00004) — 고객명·지점·추정총자산 포함.""" resp = _call(account_label, TR_ACNT_EVAL, {'qry_tp': '0', 'dmst_stex_tp': 'KRX'}) return { 'label': account_label, 'acnt_nm': resp.get('acnt_nm', ''), 'brch_nm': resp.get('brch_nm', ''), 'entr': _to_int(resp.get('entr')), 'd2_entra': _to_int(resp.get('d2_entra')), 'tot_est_amt': _to_int(resp.get('tot_est_amt')), # 추정총자산 'aset_evlt_amt': _to_int(resp.get('aset_evlt_amt')), # 자산평가액 'tot_pur_amt': _to_int(resp.get('tot_pur_amt')), # 총매입금액 'prsm_dpst_aset_amt': _to_int(resp.get('prsm_dpst_aset_amt')), # 추정예탁자산 'raw': resp, } def get_positions(account_label: str, exchange: str = 'KRX') -> list[dict]: """보유종목 목록 (kt00018). exchange='KRX': 한국거래소 종가 기준 (잔고 평가 표준). exchange='NXT': NXT 우선 가격 (NXT 운영시간 8:00~20:00에 살아 움직임). NXT 미상장 종목은 KRX fallback. `cur_prc`·`evlt_amt`·`evltv_prft`·`prft_rt`는 키움 서버에서 해당 거래소 가격으로 재계산되어 반환. """ resp = _call_paginated(account_label, TR_POSITIONS, {'qry_tp': '1', 'dmst_stex_tp': exchange}, list_field='acnt_evlt_remn_indv_tot') items = resp.get('acnt_evlt_remn_indv_tot', []) or [] out: list[dict] = [] for it in items: qty = _to_int(it.get('rmnd_qty')) if qty == 0: continue cur_price = _to_int(it.get('cur_prc')) pred_close = _to_int(it.get('pred_close_pric')) day_change = cur_price - pred_close if pred_close else 0 day_change_pct = (day_change / pred_close * 100) if pred_close else 0.0 out.append({ 'code': _clean_code(it.get('stk_cd', '')), 'name': it.get('stk_nm', ''), 'qty': qty, 'trde_able_qty': _to_int(it.get('trde_able_qty')), 'avg_price': _to_int(it.get('pur_pric')), # 매입평단 'cur_price': cur_price, 'pred_close_pric': pred_close, # 전일종가 'day_change': day_change, # 당일 등락 (원) 'day_change_pct': day_change_pct, # 당일 등락률 (%) 'pur_amt': _to_int(it.get('pur_amt')), 'evlt_amt': _to_int(it.get('evlt_amt')), 'evltv_prft': _to_int(it.get('evltv_prft')), # 평가손익 'prft_rt': _to_float(it.get('prft_rt')), # 수익률 % 'poss_rt': _to_float(it.get('poss_rt')), # 보유비중 % 'tdy_buyq': _to_int(it.get('tdy_buyq')), 'tdy_sellq': _to_int(it.get('tdy_sellq')), }) out.sort(key=lambda x: x['evlt_amt'], reverse=True) return out def get_positions_all(exchange: str = 'KRX', labels: list[str] | None = None) -> dict: """모든 계좌 종목 조회 — {label: [positions]} 반환. exchange는 get_positions와 동일. labels 지정 시 해당 라벨만 순회 (owner 단위 부분 갱신용 — behive_web 자동 갱신이 활용).""" accs = list_accounts() if labels is not None: wanted = set(labels) accs = [a for a in accs if a['label'] in wanted] return {a['label']: get_positions(a['label'], exchange=exchange) for a in accs} def get_trade_journal(account_label: str, base_dt: str | None = None) -> list[dict]: """당일매매일지 (ka10170) — 잔고에 없는 round-trip·풀매도 거래까지 포함. base_dt: YYYYMMDD (None이면 KST 오늘). ottks_tp='2' (당일매도 전체 — 보유분 매도 포함). '1'은 round-trip만 잡혀 pl_amt=0이 됨 (2026-05-07 사고). ch_crd_tp='0'(현금/신용 전체) 고정. 빈 응답은 stk_cd가 ''인 placeholder 1건이 옴 — 필터링. """ if base_dt is None: base_dt = datetime.now(KST).strftime('%Y%m%d') body = {'base_dt': base_dt, 'ottks_tp': '2', 'ch_crd_tp': '0'} resp = _call_paginated(account_label, TR_TRADE_DIARY, body, list_field='tdy_trde_diary') items = resp.get('tdy_trde_diary') or [] out: list[dict] = [] for it in items: code = _clean_code(it.get('stk_cd', '')) name = (it.get('stk_nm') or '').strip() if not code or not name: continue # 빈 placeholder out.append({ 'code': code, 'name': name, 'buy_qty': _to_int(it.get('buy_qty')), 'buy_avg': _to_int(it.get('buy_avg_pric')), 'buy_amt': _to_int(it.get('buy_amt')), 'sell_qty': _to_int(it.get('sell_qty')), 'sell_avg': _to_int(it.get('sel_avg_pric')), # 키움 필드명 'sel_avg_pric' (오타 아님) 'sell_amt': _to_int(it.get('sell_amt')), 'pl_amt': _to_int(it.get('pl_amt')), # 실현손익 (수수료·세금 차감) 'cmsn_tax': _to_int(it.get('cmsn_alm_tax')), # 수수료+세금 'prft_rt': _to_float(str(it.get('prft_rt') or '0').lstrip('+')), }) return out def get_trade_journal_all(base_dt: str | None = None, labels: list[str] | None = None) -> dict: """모든 계좌 당일매매일지 — {label: [trades]} 반환. labels 지정 시 해당 라벨만 순회 (owner 단위 부분 갱신용).""" accs = list_accounts() if labels is not None: wanted = set(labels) accs = [a for a in accs if a['label'] in wanted] return {a['label']: get_trade_journal(a['label'], base_dt) for a in accs} def get_cash_flow(account_label: str, base_dt: str | None = None) -> list[dict]: """당일 입출금 내역 (kt00015 tp='1'). base_dt: YYYYMMDD (None이면 KST 오늘). 시작/종료 동일 → 그날치만. tp='1'(입출금)·gds_tp='0'(전체)로 매매·환전 등은 제외. KRW만 필터. 반환 행: - time: 'HH:MM:SS' (proc_tm 앞 8자리) - io_tp: 'IN' | 'OUT' - amount: 거래금액 (원, 양수) - signed: +amount (입금) / -amount (출금) — 합산으로 net cash flow 산출 - rmrk: 적요명 (예: '타사대체입금', '은행이체출금') - balance: 거래 후 예수금잔고 (entra_remn) """ if base_dt is None: base_dt = datetime.now(KST).strftime('%Y%m%d') body = { 'strt_dt': base_dt, 'end_dt': base_dt, 'tp': '1', 'stk_cd': '', 'crnc_cd': '', 'gds_tp': '0', 'frgn_stex_code': '', 'dmst_stex_tp': '%', } resp = _call_paginated(account_label, TR_TRUST_TRADE, body, list_field='trst_ovrl_trde_prps_array') items = resp.get('trst_ovrl_trde_prps_array') or [] out: list[dict] = [] for it in items: crnc = (it.get('crnc_cd') or '').strip() if crnc and crnc != 'KRW': continue io_nm = (it.get('io_tp_nm') or '').strip() amount = _to_int(it.get('trde_amt')) if amount == 0: continue # tp='1' 필터에도 io_tp_nm은 한글 라벨로 떨어진다. 입금/출금 키워드로 부호 결정. if '입금' in io_nm: io_tp, signed = 'IN', amount elif '출금' in io_nm: io_tp, signed = 'OUT', -amount else: continue proc_tm = (it.get('proc_tm') or '').strip() out.append({ 'time': proc_tm[:8], 'io_tp': io_tp, 'amount': amount, 'signed': signed, 'rmrk': (it.get('rmrk_nm') or '').strip(), 'balance': _to_int(it.get('entra_remn')), }) return out def get_cash_flow_all(base_dt: str | None = None, labels: list[str] | None = None) -> dict: """모든 계좌 당일 입출금 — {label: [flows]}. labels 지정 시 해당 라벨만.""" accs = list_accounts() if labels is not None: wanted = set(labels) accs = [a for a in accs if a['label'] in wanted] return {a['label']: get_cash_flow(a['label'], base_dt) for a in accs} def get_order_executions(account_label: str, base_dt: str | None = None) -> list[dict]: """계좌별 주문체결내역 (kt00007) — 주문번호·시각 단위 행 리스트. 매매 시점 가드(broker 진실 소스 검증)에 사용. ka10170(종목별 일계 집계)과 달리 개별 주문건마다 ord_tm·ord_no·체결가·체결수량이 분리되어 시간 기반 가드 가능. base_dt: YYYYMMDD (None이면 KST 오늘). qry_tp='1': 시간 오름차순. 다른 분기 의미는 키움 문서 미확정이라 '1' 고정. 반환 행 정규화 필드: - ord_no: 주문번호 (예: '0374065') - ord_tm: 주문시각 'HH:MM:SS' (체결시각 별도 필드 없음, 주문시각 사용) - code: 종목코드 (A 접두 제거) - name: 종목명 - side: 'BUY' | 'SELL' | 'UNKNOWN' (io_tp_nm 에서 추출) - order_qty: 주문수량 - cntr_qty: 체결수량 (0 이면 미체결) - cntr_uv: 체결단가 (0 이면 미체결) - ord_uv: 주문단가 (시장가는 0) - order_type:'시장가' | '지정가' | 기타 - exchange: 'KRX' | 'NXT' | 'SOR' - comm_src: 주문 출처. 'REST API' (우리 매매) vs '영웅문S#' 등 (사용자 직접 매매) - mdfy_cncl: 정정/취소 표식 (빈 값이면 정상) """ if base_dt is None: base_dt = datetime.now(KST).strftime('%Y%m%d') body = { 'ord_dt': base_dt, 'qry_tp': '1', 'stk_bond_tp': '0', 'sell_tp': '0', 'stk_cd': '', 'fr_ord_no': '', 'dmst_stex_tp': '%', } resp = _call_paginated(account_label, TR_ORDER_EXEC, body, list_field='acnt_ord_cntr_prps_dtl') items = resp.get('acnt_ord_cntr_prps_dtl') or [] out: list[dict] = [] for it in items: code = _clean_code(it.get('stk_cd', '')) name = (it.get('stk_nm') or '').strip() if not code or not name: continue # placeholder 행 스킵 io_tp = (it.get('io_tp_nm') or '').strip() if '매도' in io_tp: side = 'SELL' elif '매수' in io_tp: side = 'BUY' else: side = 'UNKNOWN' out.append({ 'ord_no': (it.get('ord_no') or '').strip(), 'ord_tm': (it.get('ord_tm') or '').strip(), 'code': code, 'name': name, 'side': side, 'order_qty': _to_int(it.get('ord_qty')), 'cntr_qty': _to_int(it.get('cntr_qty')), 'cntr_uv': _to_int(it.get('cntr_uv')), 'ord_uv': _to_int(it.get('ord_uv')), 'order_type': (it.get('trde_tp') or '').strip(), 'exchange': (it.get('dmst_stex_tp') or '').strip(), 'comm_src': (it.get('comm_ord_tp') or '').strip(), 'mdfy_cncl': (it.get('mdfy_cncl') or '').strip(), }) return out _EXCHANGE_CODE_TO_SUFFIX = { '0': '_AL', # 통합 → SOR '1': '', # KRX '2': '_NX', # NXT } def get_open_orders(account_label: str, code: str = '', side: str = 'all', exchange: str = 'all') -> list[dict]: """미체결 주문 리스트 (ka10075). 정정/취소 대상 후보 추출용. 조회 전용 — 주문 행위는 orders 모듈을 거쳐야 함. side: 'all' | 'buy' | 'sell' exchange: 'all' | 'KRX' | 'NXT' 반환 행 정규화 필드: - account: account_label (호출자 라벨, 키움 응답엔 없음) - ord_no: 주문번호 - code: 종목코드 (A 접두 제거) - name: 종목명 - side: 'BUY' | 'SELL' | 'UNKNOWN' - order_qty: 주문수량 - order_price: 주문가격 (시장가면 0) - unfilled_qty: 미체결수량 (취소 시 잔량 = 이 값) - order_type: trde_tp 문자열 ('시장가' / '지정가' 등) - exchange: 'KRX' | 'NXT' | '통합' (stex_tp_txt) - exchange_code: '0' | '1' | '2' - routing_suffix: '_AL' | '' | '_NX' (orders.kiwoom_order 가 그대로 받음) - status: ord_stt (예: '접수') - order_time: 'HH:MM:SS' (tm) """ side_map = {'all': '0', 'sell': '1', 'buy': '2'} exchange_map = {'all': '0', 'KRX': '1', 'NXT': '2'} if side not in side_map: raise ValueError(f'invalid side: {side!r}') if exchange not in exchange_map: raise ValueError(f'invalid exchange: {exchange!r}') body = { 'all_stk_tp': '1' if code else '0', 'trde_tp': side_map[side], 'stk_cd': code or '', 'stex_tp': exchange_map[exchange], } resp = _call_paginated(account_label, TR_OPEN_ORDERS, body, list_field='oso') items = resp.get('oso') or [] out: list[dict] = [] for it in items: stk_code = _clean_code(it.get('stk_cd', '')) ord_no = (it.get('ord_no') or '').strip() if not stk_code or not ord_no: continue io_tp = (it.get('io_tp_nm') or '').strip() if '매도' in io_tp: s = 'SELL' elif '매수' in io_tp: s = 'BUY' else: s = 'UNKNOWN' ex_code = (it.get('stex_tp') or '').strip() out.append({ 'account': account_label, 'ord_no': ord_no, 'code': stk_code, 'name': (it.get('stk_nm') or '').strip(), 'side': s, 'order_qty': _to_int(it.get('ord_qty')), 'order_price': _to_int(it.get('ord_pric')), 'unfilled_qty': _to_int(it.get('oso_qty')), 'order_type': (it.get('trde_tp') or '').strip(), 'exchange': (it.get('stex_tp_txt') or '').strip(), 'exchange_code': ex_code, 'routing_suffix': _EXCHANGE_CODE_TO_SUFFIX.get(ex_code, ''), 'status': (it.get('ord_stt') or '').strip(), 'order_time': (it.get('tm') or '').strip(), }) return out def get_open_orders_all(labels: list[str] | None = None, code: str = '', side: str = 'all', exchange: str = 'all') -> list[dict]: """모든 계좌의 미체결 주문 통합 리스트 — 정정/취소 자동 매칭에 사용.""" if labels is None: labels = list(load_credentials().get('accounts', {}).keys()) rows: list[dict] = [] for label in labels: try: rows.extend(get_open_orders(label, code=code, side=side, exchange=exchange)) except Exception as e: print(f'[get_open_orders_all] {label} 실패: {e}', file=sys.stderr) return rows # ---- 시세·종목코드 조회 (매매 아님, READ-ONLY) ---- def _default_account_label() -> str: """시세 조회는 어느 계좌 토큰으로든 가능 — 첫 계좌를 사용.""" accounts = list(load_credentials().get('accounts', {}).keys()) if not accounts: raise RuntimeError('등록된 계좌 없음') return accounts[0] _QUOTE_GATE = threading.Lock() _QUOTE_LAST_TS = 0.0 # ka10001 한도(키움 통상 ~5 req/sec) 안쪽으로 페이싱. 0.22s 간격 → 약 4.5 req/sec. # behive_web 동시 발사·watchlist_monitor 연사로 1700:허용된 요청 개수 초과(HTTP 429) 빈발 → 단일 진입점에서 직렬화. _QUOTE_MIN_INTERVAL = 0.22 _QUOTE_BACKOFF_SEC = 1.0 def _is_quote_rate_limit(err_msg: str) -> bool: return 'HTTP 429' in err_msg or '허용된 요청 개수' in err_msg or '1700' in err_msg def get_stock_quote(code: str, account_label: str | None = None, exchange: str = 'AL') -> dict: """주식기본정보 조회 (ka10001). 현재가·전일대비·등락률. exchange: 'AL'=KRX+NXT 통합(기본·최신가·통합거래량), 'KRX'=KRX 단독, 'NX'=NXT 단독. 키움 ATS 변경(2025-03 NextTrade 출범) 이후 종목코드 접미사(`_AL`/`_NX`)로 거래소 지정. NXT 미상장 종목에 'AL' 호출 시 KRX 값으로 자동 fallback (서남 등 검증 완료). """ global _QUOTE_LAST_TS label = account_label or _default_account_label() clean = _clean_code(code) stk_cd = f'{clean}_{exchange}' if exchange in ('NX', 'AL') else clean url = base_url() + ENDPOINT_STKINFO body = {'stk_cd': stk_cd} resp = None last_err: Exception | None = None for attempt in range(2): with _QUOTE_GATE: wait = _QUOTE_MIN_INTERVAL - (time.time() - _QUOTE_LAST_TS) if wait > 0: time.sleep(wait) try: resp, _ = _http_post_full(url, body, auth_headers(label, tr_id=TR_STOCK_INFO)) except RuntimeError as e: _QUOTE_LAST_TS = time.time() if _is_quote_rate_limit(str(e)) and attempt == 0: last_err = e resp = None else: raise else: _QUOTE_LAST_TS = time.time() if resp is None: time.sleep(_QUOTE_BACKOFF_SEC) continue rc = resp.get('return_code', 0) if rc != 0 and _is_quote_rate_limit(str(resp.get('return_msg') or '')) and attempt == 0: last_err = RuntimeError(f'{TR_STOCK_INFO} rate-limit: {resp.get("return_msg")}') time.sleep(_QUOTE_BACKOFF_SEC) resp = None continue break if resp is None: raise last_err or RuntimeError(f'{TR_STOCK_INFO} 실패: 응답 없음') if resp.get('return_code', 0) != 0: raise RuntimeError(f'{TR_STOCK_INFO} 실패: {resp.get("return_msg")} (full={resp})') # 키움 현재가 응답은 부호 포함 문자열: "+71200", "-71200", "71200" cur_str = str(resp.get('cur_prc') or '0').strip() pred_str = str(resp.get('pred_pre') or '0').strip() return { 'code': clean, 'name': (resp.get('stk_nm') or '').strip(), 'price': abs(_to_int(cur_str)), # 키움은 하한가·상한가 표시에만 부호 — 절댓값이 현재가 'change': _to_int(pred_str), # 전일대비 (부호 있음) 'change_pct': _to_float(resp.get('flu_rt')), 'volume': _to_int(resp.get('trde_qty')), 'raw': resp, } def get_watchlist_quotes(codes: list[str], account_label: str | None = None, exchange: str = 'AL') -> dict[str, dict]: """ka10095 관심종목정보요청 — 다종목 시세 batch (한 콜로 N개). 19종목 ~0.05s. ka10001×N 대비 rate limit 영향 없음. 반환: {clean_code: {price, change, change_pct, name}}. 빈 codes면 빈 dict. """ clean_codes = [_clean_code(c) for c in codes if c] if not clean_codes: return {} label = account_label or _default_account_label() suffix = f'_{exchange}' if exchange in ('NX', 'AL') else '' stk_cd_param = '|'.join(f'{c}{suffix}' for c in clean_codes) resp = _call(label, TR_WATCHLIST_INFO, {'stk_cd': stk_cd_param}, endpoint=ENDPOINT_STKINFO) out: dict[str, dict] = {} for row in resp.get('atn_stk_infr') or []: raw_code = (row.get('stk_cd') or '').strip() # 응답의 stk_cd는 요청 그대로 suffix 포함 ('294630_AL'). suffix 제거. code = raw_code.split('_', 1)[0] if not code: continue out[code] = { 'code': code, 'name': (row.get('stk_nm') or '').strip(), 'price': abs(_to_int(row.get('cur_prc') or '0')), 'change': _to_int(row.get('pred_pre') or '0'), 'change_pct': _to_float(row.get('flu_rt')), # OHLC — 오늘 단일 캔들용. 키움 응답은 부호 포함 문자열이라 abs 처리. 'open': abs(_to_int(row.get('open_pric') or '0')), 'high': abs(_to_int(row.get('high_pric') or '0')), 'low': abs(_to_int(row.get('low_pric') or '0')), 'volume': _to_int(row.get('trde_qty') or '0'), } return out def get_quote_book(code: str, account_label: str | None = None) -> dict: """주식호가 조회 (ka10004) — 10단계 매도/매수 호가 + 잔량. 매매 아님 — 조회 전용. 호가창 표시·체결 가능성 판단에 사용. 필드명 함정 (2026-05-07 메모리 reference_kiwoom_endpoints.md): - 1호가: `sel_fpr_bid` / `sel_fpr_req`, `buy_fpr_bid` / `buy_fpr_req` (suffix 없음) - 2~10호가: `sel_{N}th_pre_bid` / `sel_{N}th_pre_req`, `buy_{N}th_pre_bid` / `buy_{N}th_pre_req` 가격 응답은 부호 포함 ('+71200') → abs 처리. rate limit 보호: ka10001과 동일 `_QUOTE_GATE` 통과 (시세 카테고리 공용). 반환: - code, price(현재가), change(전일대비, 부호), change_pct - asks: [{price, qty}] — 1호가(가장 낮은 매도가)부터 10호가 순 - bids: [{price, qty}] — 1호가(가장 높은 매수가)부터 10호가 순 - ts: 응답 시각 KST ISO """ global _QUOTE_LAST_TS label = account_label or _default_account_label() clean = _clean_code(code) url = base_url() + ENDPOINT_MRKCOND body = {'stk_cd': clean} resp: dict | None = None last_err: Exception | None = None for attempt in range(2): with _QUOTE_GATE: wait = _QUOTE_MIN_INTERVAL - (time.time() - _QUOTE_LAST_TS) if wait > 0: time.sleep(wait) try: resp, _ = _http_post_full(url, body, auth_headers(label, tr_id='ka10004')) except RuntimeError as e: _QUOTE_LAST_TS = time.time() if _is_quote_rate_limit(str(e)) and attempt == 0: last_err = e resp = None else: raise else: _QUOTE_LAST_TS = time.time() if resp is None: time.sleep(_QUOTE_BACKOFF_SEC) continue rc = resp.get('return_code', 0) if rc != 0 and _is_quote_rate_limit(str(resp.get('return_msg') or '')) and attempt == 0: last_err = RuntimeError(f'ka10004 rate-limit: {resp.get("return_msg")}') time.sleep(_QUOTE_BACKOFF_SEC) resp = None continue break if resp is None: raise last_err or RuntimeError('ka10004 실패: 응답 없음') if resp.get('return_code', 0) != 0: raise RuntimeError(f'ka10004 실패: {resp.get("return_msg")} (full={resp})') asks: list[dict] = [] bids: list[dict] = [] ap1 = abs(_to_int(resp.get('sel_fpr_bid'))) aq1 = abs(_to_int(resp.get('sel_fpr_req'))) if ap1 and aq1: asks.append({'price': ap1, 'qty': aq1}) bp1 = abs(_to_int(resp.get('buy_fpr_bid'))) bq1 = abs(_to_int(resp.get('buy_fpr_req'))) if bp1 and bq1: bids.append({'price': bp1, 'qty': bq1}) for i in range(2, 11): ap = abs(_to_int(resp.get(f'sel_{i}th_pre_bid'))) aq = abs(_to_int(resp.get(f'sel_{i}th_pre_req'))) if ap and aq: asks.append({'price': ap, 'qty': aq}) bp = abs(_to_int(resp.get(f'buy_{i}th_pre_bid'))) bq = abs(_to_int(resp.get(f'buy_{i}th_pre_req'))) if bp and bq: bids.append({'price': bp, 'qty': bq}) return { 'code': clean, 'price': abs(_to_int(resp.get('cur_prc') or '0')), 'change': _to_int(resp.get('pred_pre') or '0'), 'change_pct': _to_float(resp.get('flu_rt')), 'asks': asks, 'bids': bids, 'ts': datetime.now(KST).isoformat(), 'raw': resp, } def get_stock_basic(code: str, account_label: str | None = None) -> dict: """ka10001 기본정보를 분석 보고서용으로 정리. get_stock_quote가 시세 4필드(price·change·pct·volume)만 뽑는 반면, 이 함수는 분석에 필요한 시고저·52주·시총·PER/PBR/ROE/EPS/BPS·회전율까지 노출. 같은 TR이지만 보고서·뷰가 raw 파싱 코드를 직접 들고 다닐 필요 없게 wrapper로 둠. 회전율(turnover_rate): trde_qty / flo_stk * 100. ka10001은 일일 회전율 필드 미제공이라 상장주식수로 직접 계산. flo_stk는 천 주 단위가 아니라 주(株) 단위라 그대로 나눔. """ q = get_stock_quote(code, account_label=account_label, exchange='AL') raw = q.get('raw') or {} # ka10001 flo_stk는 천 주 단위 (예: 5846279 = 5,846,279,000주) listed_thousand = _to_int(raw.get('flo_stk')) listed = listed_thousand * 1000 volume = _to_int(raw.get('trde_qty')) turnover_rate = (volume / listed * 100.0) if listed > 0 else 0.0 return { 'code': q['code'], 'name': q['name'], 'price': q['price'], 'change': q['change'], 'change_pct': q['change_pct'], 'open': abs(_to_int(raw.get('open_pric'))), 'high': abs(_to_int(raw.get('high_pric'))), 'low': abs(_to_int(raw.get('low_pric'))), 'prev_close': abs(_to_int(raw.get('base_pric'))), 'volume': volume, 'listed_shares': listed, 'market_cap': _to_int(raw.get('mac')), # 시가총액 (억원) 'high_52w': abs(_to_int(raw.get('250hgst'))), 'high_52w_dt': (raw.get('250hgst_pric_dt') or '').strip(), 'low_52w': abs(_to_int(raw.get('250lwst'))), 'low_52w_dt': (raw.get('250lwst_pric_dt') or '').strip(), 'per': _to_float(raw.get('per')), 'pbr': _to_float(raw.get('pbr')), 'eps': _to_int(raw.get('eps')), 'bps': _to_int(raw.get('bps')), 'roe': _to_float(raw.get('roe')), 'turnover_rate': turnover_rate, 'foreign_holding_pct': _to_float(raw.get('for_exh_rt')), # 외국인 보유비율 # 수익성 (최근 결산 기준, 억원 단위) — EPS×상장주식수 ≈ 당기순이익으로 단위 검증됨 'sales': _to_int(raw.get('sale_amt')), # 매출액 'op_profit': _to_int(raw.get('bus_pro')), # 영업이익 'net_profit': _to_int(raw.get('cup_nga')), # 당기순이익 'ev_ebitda': _to_float(raw.get('ev')), # EV/EBITDA 배수 'capital': _to_int(raw.get('cap')), # 자본금 (억원) 'par_value': _to_int(raw.get('fav')), # 액면가 'par_unit': (raw.get('fav_unit') or '').strip(), # 액면가 단위 (원 등) 'settle_month': _to_int(raw.get('setl_mm')), # 결산월 'dist_rate': _to_float(raw.get('dstr_rt')), # 유통주식 비율 (%) 'raw': raw, } def get_daily_candles(code: str, count: int = 250, base_dt: str | None = None, account_label: str | None = None) -> list[dict]: """주식 일봉 차트 (ka10081). 수정주가 기준. base_dt: 'YYYYMMDD' 기준일 (생략 시 키움이 최신일로 처리). count: 반환할 최대 일봉 수 (최신부터 잘라서 반환). 키움은 한 호출에 ~600일치. 반환: 최신순(내림차순)으로 정렬된 list of dict: {date, open, high, low, close, volume, value, turnover_rate} 가격 필드는 모두 abs() 처리 (키움은 상하한 표시에만 부호 사용). """ label = account_label or _default_account_label() if not base_dt: # ka10081은 base_dt 필수 — 비어있으면 오늘(Asia/Seoul) 채움 from datetime import datetime try: from zoneinfo import ZoneInfo base_dt = datetime.now(ZoneInfo('Asia/Seoul')).strftime('%Y%m%d') except Exception: base_dt = datetime.now().strftime('%Y%m%d') body = { 'stk_cd': _clean_code(code), 'base_dt': base_dt, 'upd_stkpc_tp': '1', # 수정주가 사용 } resp = _call(label, TR_DAILY_CHART, body, endpoint=ENDPOINT_CHART) rows = resp.get('stk_dt_pole_chart_qry') or [] out: list[dict] = [] for row in rows[:count]: out.append({ 'date': (row.get('dt') or '').strip(), 'open': abs(_to_int(row.get('open_pric'))), 'high': abs(_to_int(row.get('high_pric'))), 'low': abs(_to_int(row.get('low_pric'))), 'close': abs(_to_int(row.get('cur_prc'))), 'volume': _to_int(row.get('trde_qty')), 'value': _to_int(row.get('trde_prica')), # 거래대금 (백만원) 'turnover_rate': _to_float(row.get('trde_tern_rt')), # 일별 회전율 (%) }) return out def get_minute_candles(code: str, tic_scope: int = 5, today_only: bool = True, account_label: str | None = None) -> list[dict]: """주식 분봉 차트 (ka10080). 수정주가 기준. tic_scope: 1/3/5/10/15/30/45/60 분. 한 호출에 ~900봉 (5분봉 ≈ 11일치). today_only: True 면 오늘 봉만 추출. 반환: 시간 오름차순 list of dict — {date, time, open, high, low, close, volume} date='YYYYMMDD', time='HHMMSS'. """ label = account_label or _default_account_label() body = { 'stk_cd': _clean_code(code), 'tic_scope': str(tic_scope), 'upd_stkpc_tp': '1', } resp = _call(label, TR_MINUTE_CHART, body, endpoint=ENDPOINT_CHART) rows = resp.get('stk_min_pole_chart_qry') or [] from datetime import datetime try: from zoneinfo import ZoneInfo today = datetime.now(ZoneInfo('Asia/Seoul')).strftime('%Y%m%d') except Exception: today = datetime.now().strftime('%Y%m%d') out: list[dict] = [] for row in rows: ctm = (row.get('cntr_tm') or '').strip() date = ctm[:8] if len(ctm) >= 8 else '' if today_only and date != today: continue out.append({ 'date': date, 'time': ctm[8:14] if len(ctm) >= 14 else '', 'open': abs(_to_int(row.get('open_pric'))), 'high': abs(_to_int(row.get('high_pric'))), 'low': abs(_to_int(row.get('low_pric'))), 'close': abs(_to_int(row.get('cur_prc'))), 'volume': _to_int(row.get('trde_qty')), }) # 키움 응답은 최신순 → 오름차순으로 out.reverse() return out def get_investor_flow(code: str, days: int = 20, account_label: str | None = None) -> list[dict]: """종목별 투자자별 일별 매매 (ka10059). 외국인·기관 수급용. days: 최신부터 자를 일수. 키움 응답은 기본 ~100일치. unit_tp='1000': 단위 천 주. 수량 부호 = 순매수(+)/순매도(-). trde_tp='0': 순매수 기준. 반환: 최신순 list of dict — {date, close, change, change_pct, volume, individual, foreign, institution, pension, ...} 수량은 천 주 단위 그대로. """ label = account_label or _default_account_label() # ka10059 dt 필수 — 빈 값 불가. 오늘(Asia/Seoul) 기준으로 채움 from datetime import datetime try: from zoneinfo import ZoneInfo today = datetime.now(ZoneInfo('Asia/Seoul')).strftime('%Y%m%d') except Exception: today = datetime.now().strftime('%Y%m%d') body = { 'dt': today, 'stk_cd': _clean_code(code), 'amt_qty_tp': '2', # 1=금액, 2=수량 'trde_tp': '0', # 0=순매수, 1=매수, 2=매도 'unit_tp': '1000', # 단위 (천 주) } resp = _call(label, TR_INVESTOR_FLOW, body, endpoint=ENDPOINT_STKINFO) rows = resp.get('stk_invsr_orgn') or [] out: list[dict] = [] for row in rows[:days]: out.append({ 'date': (row.get('dt') or '').strip(), 'close': abs(_to_int(row.get('cur_prc'))), 'change': _to_int(row.get('pred_pre')), # flu_rt는 ×100 형태 ('-196' = -1.96%) — _to_float은 그대로 받고 호출자가 /100 'change_pct_raw': _to_int(row.get('flu_rt')), 'volume': _to_int(row.get('acc_trde_qty')), 'individual': _to_int(row.get('ind_invsr')), # 개인 'foreign': _to_int(row.get('frgnr_invsr')), # 외국인 'institution': _to_int(row.get('orgn')), # 기관 합계 'fnnc_invt': _to_int(row.get('fnnc_invt')), # 금융투자 'insurance': _to_int(row.get('insrnc')), # 보험 'invtrt': _to_int(row.get('invtrt')), # 투신 'bank': _to_int(row.get('bank')), # 은행 'pension': _to_int(row.get('penfnd_etc')), # 연기금 'private_fund': _to_int(row.get('samo_fund')), # 사모펀드 'etc_corp': _to_int(row.get('etc_corp')), # 기타법인 }) return out def _load_code_cache() -> dict: """종목코드 캐시 dict {name: {code, name, market}} 반환. 없으면 빈 dict.""" if not STOCK_CODES_CACHE.exists(): return {} try: return json.loads(STOCK_CODES_CACHE.read_text()).get('stocks', {}) or {} except Exception: return {} def refresh_stock_codes(account_label: str | None = None) -> dict: """ka10099로 KOSPI+KOSDAQ 전체 상장종목 리스트 캐싱. 페이지네이션 처리. 캐시 저장 필드 (2026-05-07 확장): - code, name, market (기존) - nxt_enable: bool — ka10099 응답 nxtEnable=='Y' 변환. NXT 가드 정확화에 사용 - state: str — 종목상태 (예: '관리종목', '정리매매', '증거금100%') - order_warning: str — 투자유의 (0/1/2/3/4/5, ka10099 명세 참조) """ label = account_label or _default_account_label() url = base_url() + ENDPOINT_STKINFO result: dict[str, dict] = {} for mrkt_tp, market_name in [('0', 'KOSPI'), ('10', 'KOSDAQ')]: cont_yn = 'N' next_key = '' for _ in range(50): # safety cap on pagination loops headers = auth_headers(label, tr_id=TR_STOCK_LIST, cont_yn=cont_yn, next_key=next_key) resp, resp_headers = _http_post_full(url, {'mrkt_tp': mrkt_tp}, headers) if resp.get('return_code', 0) != 0: raise RuntimeError(f'{TR_STOCK_LIST} ({market_name}) 실패: {resp.get("return_msg")}') items = resp.get('list') or [] for it in items: code = _clean_code(it.get('code', '')) name = (it.get('name') or '').strip() if code and name: result[name] = { 'code': code, 'name': name, 'market': market_name, 'nxt_enable': str(it.get('nxtEnable') or '').strip().upper() == 'Y', 'state': (it.get('state') or '').strip(), 'order_warning': str(it.get('orderWarning') or '0').strip(), } cy = (resp_headers.get('cont-yn') or '').strip().upper() nk = (resp_headers.get('next-key') or '').strip() if cy == 'Y' and nk: cont_yn = 'Y' next_key = nk time.sleep(0.15) else: break STOCK_CODES_CACHE.parent.mkdir(parents=True, exist_ok=True) STOCK_CODES_CACHE.write_text(json.dumps({ 'refreshed_at': datetime.now(KST).isoformat(), 'count': len(result), 'stocks': result, }, ensure_ascii=False, indent=2)) return result def lookup_stock_meta(code: str) -> dict | None: """6자리 종목코드 → 캐시된 메타 dict (또는 None). 캐시는 종목명 기반이라 코드 검색은 O(N). ~3500종목 수준이라 매 호출 직접 순회. 캐시 미스 또는 캐시 자체가 없으면 None — 호출자가 fallback 결정. """ clean = _clean_code(code or '') if not clean: return None for info in _load_code_cache().values(): if info.get('code') == clean: return info return None def resolve_stock_code(name_or_code: str) -> dict: """이름 또는 6자리 코드 → {code, name, market}. 캐시 매칭 실패 시 1회 lazy refresh. 이름 매칭은 공백 무시 ("은선물" == "은 선물" — KRX 종목명 공백 표기 불일치 흡수). """ s = (name_or_code or '').strip() if not s: raise ValueError('빈 입력') # 6자리 숫자 → 코드로 간주 if s.isdigit() and len(s) == 6: for info in _load_code_cache().values(): if info.get('code') == s: return info # 캐시에 없으면 ka10001로 이름만 끌어옴 (KRX 단독 — NXT 미상장 종목 보호) q = get_stock_quote(s, exchange='KRX') return {'code': s, 'name': q.get('name') or '', 'market': '?'} norm = lambda x: ''.join((x or '').split()) s_n = norm(s) def _match(cache: dict) -> tuple[dict | None, list[dict]]: # 정확 매칭 (공백 정규화 후) for name, info in cache.items(): if norm(name) == s_n: return info, [] # 부분 매칭 (공백 정규화 후) partial = [info for name, info in cache.items() if s_n in norm(name)] return None, partial cache = _load_code_cache() exact, partial = _match(cache) if exact: return exact if len(partial) == 1: return partial[0] # lazy refresh (캐시 비었거나 매칭 실패 시 1회만) if not cache or not partial: print(f'[stock-codes] 캐시 갱신 중...', file=sys.stderr) refresh_stock_codes() cache = _load_code_cache() exact, partial = _match(cache) if exact: return exact if len(partial) == 1: return partial[0] if len(partial) > 1: raise KeyError(f'종목 매칭 다중: "{s}" → {[p["name"] for p in partial[:5]]}') raise KeyError(f'종목 매칭 실패: "{s}" (캐시 {len(cache)}종목)') # ---- 주문 함수는 작성하지 않음 (매매 절대 원칙) ---- # DO NOT add: order_buy, order_sell, modify_order, cancel_order, ... # 어떤 요청이 오더라도 이 모듈에 추가 금지. 별도 안건으로 분리. def main(): """간단 CLI: 토큰 발급·계좌 라벨 확인용.""" cmd = sys.argv[1] if len(sys.argv) > 1 else 'help' if cmd == 'token': label = sys.argv[2] if len(sys.argv) > 2 else None labels = [label] if label else [a['label'] for a in list_accounts()] exit_code = 0 for lb in labels: try: t = issue_token(lb, force=True) print(f'[{lb}] 토큰 발급 성공: {t[:20]}... (캐시: {_token_cache_path(lb)})') except Exception as e: print(f'[{lb}] 토큰 발급 실패: {e}', file=sys.stderr) exit_code = 1 return exit_code if cmd == 'accounts': for a in list_accounts(): print(f"{a['label']}: {a['account_no'] or '(토큰 스코프로 식별, 계좌번호 저장 불필요)'}") return 0 if cmd == 'balance': label = sys.argv[2] if len(sys.argv) > 2 else None labels = [label] if label else [a['label'] for a in list_accounts()] for lb in labels: b = get_balance(lb) print(f"[{lb}] 예수금 {b['entr']:,}원 · D+2 {b['d2_entra']:,}원 · 주문가능 {b['ord_alow_amt']:,}원 · 대용금 {b['repl_amt']:,}원") return 0 if cmd == 'positions': label = sys.argv[2] if len(sys.argv) > 2 else None labels = [label] if label else [a['label'] for a in list_accounts()] for lb in labels: items = get_positions(lb) print(f"\n[{lb}] 보유 {len(items)}종목") for p in items: sign = '+' if p['evltv_prft'] >= 0 else '' print(f" {p['name']:<14} ({p['code']}) {p['qty']:>5}주 평단 {p['avg_price']:>8,} 현재 {p['cur_price']:>8,} 평가 {p['evlt_amt']:>12,} 손익 {sign}{p['evltv_prft']:>10,} ({p['prft_rt']:+.2f}%)") return 0 if cmd == 'summary': for lb in [a['label'] for a in list_accounts()]: s = get_account_summary(lb) print(f"[{lb}] {s['acnt_nm']}({s['brch_nm']}) · 추정총자산 {s['tot_est_amt']:,}원 · 자산평가액 {s['aset_evlt_amt']:,}원 · 매입 {s['tot_pur_amt']:,}원") return 0 if cmd == 'quote': if len(sys.argv) < 3: print('usage: quote ', file=sys.stderr) return 2 info = resolve_stock_code(sys.argv[2]) q = get_stock_quote(info['code']) sign = '+' if q['change'] >= 0 else '' print(f"{q['name']} ({q['code']}) {q['price']:,}원 ({sign}{q['change']:,}원, {q['change_pct']:+.2f}%) · 거래량 {q['volume']:,}") return 0 if cmd == 'resolve': if len(sys.argv) < 3: print('usage: resolve ', file=sys.stderr) return 2 info = resolve_stock_code(sys.argv[2]) print(f"{info['name']} ({info['code']}) · {info.get('market','?')}") return 0 if cmd == 'refresh-codes': stocks = refresh_stock_codes() print(f"종목코드 캐시 갱신 완료: {len(stocks)}종목 → {STOCK_CODES_CACHE}") return 0 if cmd == 'journal': # journal [label] [YYYYMMDD] — 당일매매일지 (round-trip·풀매도 포함) label = sys.argv[2] if len(sys.argv) > 2 else None base_dt = sys.argv[3] if len(sys.argv) > 3 else None labels = [label] if label else [a['label'] for a in list_accounts()] for lb in labels: trades = get_trade_journal(lb, base_dt) print(f"\n[{lb}] 매매 {len(trades)}건") for t in trades: parts = [] if t['buy_qty']: parts.append(f"매수 {t['buy_qty']:,}주 @ {t['buy_avg']:,}원 ({t['buy_amt']:,}원)") if t['sell_qty']: parts.append(f"매도 {t['sell_qty']:,}주 @ {t['sell_avg']:,}원 ({t['sell_amt']:,}원)") pl_str = f"실현 {t['pl_amt']:+,}원 ({t['prft_rt']:+.2f}%)" if t['sell_qty'] else '' print(f" {t['name']} ({t['code']}) · {' / '.join(parts)} · 수수료·세금 {t['cmsn_tax']:,}원 {pl_str}") return 0 if cmd == 'cashflow': # cashflow [label] [YYYYMMDD] — 당일 입출금 내역 (kt00015 tp='1') label = sys.argv[2] if len(sys.argv) > 2 else None base_dt = sys.argv[3] if len(sys.argv) > 3 else None labels = [label] if label else [a['label'] for a in list_accounts()] for lb in labels: flows = get_cash_flow(lb, base_dt) net = sum(f['signed'] for f in flows) cash_in = sum(f['amount'] for f in flows if f['io_tp'] == 'IN') cash_out = sum(f['amount'] for f in flows if f['io_tp'] == 'OUT') print(f"\n[{lb}] 입출금 {len(flows)}건 · 입금 {cash_in:,}원 · 출금 {cash_out:,}원 · 순 {net:+,}원") for f in flows: print(f" {f['time']} · {f['io_tp']:>3} {f['amount']:,}원 · {f['rmrk']}") return 0 if cmd == 'open': # open [label] [code] — 미체결 주문 (ka10075) label = sys.argv[2] if len(sys.argv) > 2 else None code = sys.argv[3] if len(sys.argv) > 3 else '' labels = [label] if label else [a['label'] for a in list_accounts()] for lb in labels: rows = get_open_orders(lb, code=code) print(f"\n[{lb}] 미체결 {len(rows)}건") for r in rows: price = f"{r['order_price']:,}원" if r['order_price'] else r['order_type'] print(f" ord_no={r['ord_no']} · {r['name']} ({r['code']}) {r['side']} {r['order_qty']}주 @ {price} · 미체결 {r['unfilled_qty']} · {r['exchange']} · {r['status']} · {r['order_time']}") return 0 print(__doc__, file=sys.stderr) print('\nusage: kiwoom_client.py {token [label] | accounts | balance [label] | positions [label] | summary | quote | resolve | refresh-codes | journal [label] [YYYYMMDD] | cashflow [label] [YYYYMMDD] | open [label] [code]}', file=sys.stderr) return 2 if __name__ == '__main__': sys.exit(main())