549545bde6
설정·스크립트·스킬·문서·큐레이션 메모리 추적. 시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)· 백업(clobbered/bak)·dream 캐시는 .gitignore로 제외. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1284 lines
56 KiB
Python
1284 lines
56 KiB
Python
#!/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())
|