549545bde6
설정·스크립트·스킬·문서·큐레이션 메모리 추적. 시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)· 백업(clobbered/bak)·dream 캐시는 .gitignore로 제외. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
178 lines
6.3 KiB
Python
178 lines
6.3 KiB
Python
"""
|
|
키움 REST API → market_data dict.
|
|
|
|
guards.validate_request 가 요구하는 모든 필드를 채워 반환한다.
|
|
기존 kiwoom_client 의 조회 함수를 재사용.
|
|
|
|
호가창(ka10004)·NXT 가능여부 등 일부 필드는 키움 응답 필드명이 환경마다 다를 수 있어
|
|
best-effort 파싱이며, 실패 시 None/0 으로 떨어진다. 첫 실거래 검증 시 실데이터로 보강 필요.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from datetime import datetime, timezone, timedelta
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
_PARENT = Path(__file__).resolve().parent.parent
|
|
if str(_PARENT) not in sys.path:
|
|
sys.path.insert(0, str(_PARENT))
|
|
|
|
import kiwoom_client as kc
|
|
from . import guards
|
|
|
|
KST = timezone(timedelta(hours=9))
|
|
|
|
|
|
def _to_int(s) -> int:
|
|
if s is None or s == '':
|
|
return 0
|
|
try:
|
|
return int(str(s).replace(',', '').replace('+', '').strip() or 0)
|
|
except (ValueError, AttributeError):
|
|
return 0
|
|
|
|
|
|
def _safe_quote(symbol: str, account_label: str) -> dict:
|
|
"""ka10001 (주식기본정보) 응답에서 가드용 필드 추출.
|
|
|
|
공식 명세 필드명:
|
|
- cur_prc: 현재가, base_pric: 기준가(전일종가)
|
|
- upl_pric: 상한가, lst_pric: 하한가
|
|
- 거래정지(halt) 필드는 ka10001 명세에 없음 — 별도 TR 보강 필요. 보수적으로 False.
|
|
"""
|
|
try:
|
|
q_wrap = kc.get_stock_quote(symbol, account_label=account_label, exchange='AL')
|
|
except Exception:
|
|
return {}
|
|
if not isinstance(q_wrap, dict):
|
|
return {}
|
|
raw = q_wrap.get('raw') if isinstance(q_wrap.get('raw'), dict) else q_wrap
|
|
out = {
|
|
'cur_price': abs(_to_int(raw.get('cur_prc'))),
|
|
'prev_close': abs(_to_int(raw.get('base_pric'))),
|
|
'upper_limit': abs(_to_int(raw.get('upl_pric'))),
|
|
'lower_limit': abs(_to_int(raw.get('lst_pric'))),
|
|
'halt': False, # ka10001 명세에 거래정지 플래그 없음 — 별도 TR 보강 예정
|
|
}
|
|
out['_raw'] = raw
|
|
return out
|
|
|
|
|
|
def _safe_orderbook(symbol: str, account_label: str) -> Optional[dict]:
|
|
"""ka10004 (주식호가요청) — /api/dostk/mrkcond.
|
|
|
|
필드 매핑 (키움 공식 명세):
|
|
- 1호가: sel_fpr_bid / sel_fpr_req (매도), buy_fpr_bid / buy_fpr_req (매수)
|
|
- 2~10호가: sel_{N}th_pre_bid / sel_{N}th_pre_req (매도), buy_{N}th_pre_bid / buy_{N}th_pre_req (매수)
|
|
가격에 부호(+/-) 붙어올 수 있어 abs 처리.
|
|
"""
|
|
try:
|
|
resp = kc._call(account_label, 'ka10004', {'stk_cd': symbol},
|
|
endpoint=kc.ENDPOINT_MRKCOND)
|
|
except Exception:
|
|
return None
|
|
if not isinstance(resp, dict):
|
|
return None
|
|
asks, bids = [], []
|
|
# 1호가
|
|
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})
|
|
# 2~10호가
|
|
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})
|
|
if not asks and not bids:
|
|
return None
|
|
return {'asks': asks, 'bids': bids}
|
|
|
|
|
|
def _nxt_eligible(symbol: str, quote: dict) -> bool:
|
|
"""NXT 거래가능 여부.
|
|
|
|
1차 소스: ka10099 종목정보 캐시 (`state/stock_codes.json`)의 `nxt_enable` 플래그.
|
|
캐시 미스 또는 구 스키마(필드 없음) → 보수적 True (= 가드 통과 → 키움이 사후 거부).
|
|
캐시 갱신 명령: `python3 kiwoom_client.py refresh-codes`.
|
|
"""
|
|
try:
|
|
meta = kc.lookup_stock_meta(symbol)
|
|
except Exception:
|
|
return True
|
|
if not meta:
|
|
return True
|
|
if 'nxt_enable' not in meta: # 구 스키마 캐시 — 갱신 전엔 보수적 True
|
|
return True
|
|
return bool(meta['nxt_enable'])
|
|
|
|
|
|
def _safe_broker_executions(account_label: str, now: datetime) -> tuple[Optional[list], Optional[str]]:
|
|
"""kt00007 기준 오늘자 매매 행 조회. 실패 시 (None, error_repr).
|
|
|
|
가드(validate_delay_same_symbol_via_broker)에서 사용. 정확도 우선이라 실패 시 보수적 차단.
|
|
"""
|
|
try:
|
|
executions = kc.get_order_executions(account_label, base_dt=now.strftime('%Y%m%d'))
|
|
except Exception as e:
|
|
return None, repr(e)
|
|
return executions, None
|
|
|
|
|
|
def collect_market_data(account_label: str, symbol: str, side: str, qty: int) -> dict:
|
|
now = datetime.now(KST)
|
|
quote = _safe_quote(symbol, account_label)
|
|
orderbook = _safe_orderbook(symbol, account_label)
|
|
broker_executions, broker_query_error = _safe_broker_executions(account_label, now)
|
|
try:
|
|
stock_meta = kc.lookup_stock_meta(symbol)
|
|
except Exception:
|
|
stock_meta = None
|
|
|
|
md = {
|
|
'now': now,
|
|
'is_holiday': guards.is_today_holiday(now),
|
|
'nxt_eligible': _nxt_eligible(symbol, quote),
|
|
'current_price': quote.get('cur_price', 0),
|
|
'prev_close': quote.get('prev_close', 0),
|
|
'upper_limit': quote.get('upper_limit', 0),
|
|
'lower_limit': quote.get('lower_limit', 0),
|
|
'halt': quote.get('halt', False),
|
|
'vi': False, # VI 실시간 감지는 별도 채널 필요. 보강 예정.
|
|
'orderbook': orderbook,
|
|
'broker_executions': broker_executions,
|
|
'broker_query_error': broker_query_error,
|
|
'stock_meta': stock_meta,
|
|
}
|
|
|
|
if side == 'BUY':
|
|
try:
|
|
bal = kc.get_balance(account_label)
|
|
md['balance_d2'] = _to_int(bal.get('d2_entra'))
|
|
except Exception:
|
|
md['balance_d2'] = 0
|
|
else:
|
|
try:
|
|
positions = kc.get_positions(account_label)
|
|
md['position_qty'] = next(
|
|
(_to_int(p.get('trde_able_qty') or p.get('qty') or p.get('hold_qty') or p.get('rmnd_qty'))
|
|
for p in positions
|
|
if (p.get('code') == symbol) or (p.get('symbol') == symbol) or (p.get('stk_cd') == symbol)),
|
|
0,
|
|
)
|
|
except Exception:
|
|
md['position_qty'] = 0
|
|
|
|
return md
|