Files
openclaw/agents/stock/workspace/scripts/kiwoom_client.py
T
hyowons fed3526b20 Initial commit: OpenClaw 워크스페이스 버전관리 시작
설정·스크립트·스킬·문서·큐레이션 메모리 추적.
시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)·
백업(clobbered/bak)·dream 캐시는 .gitignore로 제외.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:39:41 +09:00

1284 lines
56 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
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
"""키움증권 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 <name_or_code>', 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 <name_or_code>', 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 <name|code> | resolve <name|code> | refresh-codes | journal [label] [YYYYMMDD] | cashflow [label] [YYYYMMDD] | open [label] [code]}', file=sys.stderr)
return 2
if __name__ == '__main__':
sys.exit(main())