""" 키움 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