""" 주문 검증 가드 (순수 함수). 키움 API 호출은 datasource 가 담당. guards 는 (request, market_data) 두 dict 만 받아 결정한다. 단위테스트가 모든 분기를 mock 데이터로 검증한다. 검증 순서 (validate_request): 1. 계좌 화이트리스트 2. side 유효성 (BUY/SELL) 3. 거래시간 + 휴장일 + NXT 매트릭스 4. 거래정지 / VI 5. 전체 거래 60초 딜레이 (ledger 조회) 6. 동일 종목 3분 딜레이 (ledger 조회) 7. 동일 종목 3분 딜레이 (키움 진실 소스 — kt00007). NETWORK 사각·키움앱 직접 매매까지 포함 8. ±30% 가격 가드 (지정가만) 9. 잔고(매수) / 보유수량(매도) 사전조회 라우팅 결정 (determine_routing) 은 검증 통과 후 호출자가 별도로 부른다. 시장가 슬리피지·평균체결가 추정은 estimate_market_fill 로 카드에 표시. """ from __future__ import annotations import json from dataclasses import dataclass from datetime import datetime, time as dtime, timezone, timedelta from pathlib import Path from typing import Optional from . import ledger LIMITS_FILE = Path(__file__).resolve().parent / 'limits.json' WORKSPACE_ROOT = Path(__file__).resolve().parent.parent.parent KST = timezone(timedelta(hours=9)) def _limits() -> dict: return json.loads(LIMITS_FILE.read_text(encoding='utf-8')) @dataclass class Result: ok: bool code: str message: str @classmethod def OK(cls, code: str = 'OK', message: str = '') -> 'Result': return cls(True, code, message) @classmethod def REJECT(cls, code: str, message: str) -> 'Result': return cls(False, code, message) # ---- 계좌 ---- def validate_account(account: str) -> Result: if account not in _limits()['accounts_whitelist']: return Result.REJECT('ACCOUNT_NOT_WHITELISTED', f'허용되지 않은 계좌: {account}') return Result.OK() def is_spouse_account(account: str) -> bool: return account in _limits()['spouse_accounts'] # ---- 거래시간 + NXT 매트릭스 ---- def _parse_hms(s: str) -> dtime: parts = [int(x) for x in s.split(':')] while len(parts) < 3: parts.append(0) return dtime(parts[0], parts[1], parts[2]) def session_at(now: datetime) -> str: th = _limits()['trading_hours'] t = now.replace(tzinfo=None).time() if _parse_hms(th['nxt_pre_start']) <= t < _parse_hms(th['nxt_pre_end']): return 'NXT_PRE' if _parse_hms(th['krx_regular_start']) <= t < _parse_hms(th['krx_regular_end']): return 'KRX_NXT' if _parse_hms(th['krx_closing_auction_start']) <= t < _parse_hms(th['krx_closing_auction_end']): return 'KRX_CLOSE' if _parse_hms(th['nxt_after_start']) <= t < _parse_hms(th['nxt_after_end']): return 'NXT_AFTER' return 'CLOSED' def is_today_holiday(now: datetime) -> bool: th = _limits()['trading_hours'] rel = th.get('holiday_state_file') if not rel: return False p = WORKSPACE_ROOT / rel if not p.exists(): return False try: data = json.loads(p.read_text(encoding='utf-8')) except (OSError, ValueError): return False today = now.strftime('%Y-%m-%d') holidays = data.get('holidays') if isinstance(data, dict) else data if not isinstance(holidays, list): return False for h in holidays: if isinstance(h, str) and h == today: return True if isinstance(h, dict) and h.get('date') == today: return True return False def validate_trading_hours(now: datetime, is_holiday: bool, nxt_eligible: bool) -> Result: if is_holiday: return Result.REJECT('HOLIDAY', '휴장일에는 매매 불가') sess = session_at(now) if sess == 'CLOSED': return Result.REJECT('OUTSIDE_HOURS', f'거래시간 외 ({now.strftime("%H:%M:%S")})') if sess in ('NXT_PRE', 'NXT_AFTER') and not nxt_eligible: return Result.REJECT('NXT_NOT_ELIGIBLE', '지금은 NXT 시간대인데 이 종목은 NXT 거래 불가') return Result.OK(code=sess) def determine_routing(now: datetime, nxt_eligible: bool, force: Optional[str]) -> str: routing = _limits()['routing'] suffix_map = { 'AL': routing['suffix_AL'], 'NX': routing['suffix_NX'], 'KRX': routing['suffix_KRX'], } if force: if force not in routing['force_options']: raise ValueError(f'unknown routing force: {force}') return suffix_map[force] sess = session_at(now) if sess in ('NXT_PRE', 'NXT_AFTER'): return routing['suffix_NX'] if sess == 'KRX_CLOSE': return routing['suffix_KRX'] if sess == 'KRX_NXT': return routing['suffix_AL'] if nxt_eligible else routing['suffix_KRX'] raise ValueError('CLOSED session has no valid routing') # ---- 가격 가드 (±30% 상한가/하한가) ---- def validate_price_band(side: str, price: int, upper_limit: int, lower_limit: int) -> Result: if price > upper_limit: return Result.REJECT('PRICE_ABOVE_UPPER', f'지정가 {price:,}원 > 상한가 {upper_limit:,}원') if price < lower_limit: return Result.REJECT('PRICE_BELOW_LOWER', f'지정가 {price:,}원 < 하한가 {lower_limit:,}원') return Result.OK() # ---- 잔고 / 보유 ---- def validate_balance_for_buy(qty: int, price_estimate: int, balance_d2: int, basis: Optional[str] = None) -> Result: needed = qty * price_estimate if balance_d2 < needed: basis_label = f' ({basis} 기준)' if basis else '' return Result.REJECT('INSUFFICIENT_BALANCE', f'예수금 부족: 필요 {needed:,}원{basis_label} / 가용 {balance_d2:,}원') return Result.OK() def validate_position_for_sell(qty: int, position_qty: int) -> Result: if position_qty < qty: return Result.REJECT('INSUFFICIENT_POSITION', f'보유 부족: 매도 {qty}주 / 보유 {position_qty}주') return Result.OK() # ---- 시장가 세션 제한 (NXT 단독·KRX 단일가에선 시장가 불가) ---- def validate_market_order_session(now: datetime, order_type: str) -> Result: if order_type != 'MARKET': return Result.OK() sess = session_at(now) if sess in ('NXT_PRE', 'NXT_AFTER'): return Result.REJECT('MARKET_NOT_ALLOWED_IN_NXT', 'NXT 시간대에는 시장가 불가 — 지정가만 가능. "지금 바로" 또는 가격 명시로 다시 시도.') if sess == 'KRX_CLOSE': return Result.REJECT('MARKET_NOT_ALLOWED_IN_AUCTION', '단일가 동시호가 시간대에는 시장가 불가 — 지정가만 가능.') return Result.OK() # ---- 정지 / VI ---- def validate_halt_vi(halt: bool, vi: bool) -> Result: if halt: return Result.REJECT('TRADING_HALT', '거래정지 종목') if vi: return Result.REJECT('VI', 'VI 발동 종목') return Result.OK() # ---- 종목 상태 (ka10099 캐시 기반) ---- # orderWarning 코드 (ka10099 명세): # 0: 해당없음, 1: ETF투자주의요망, 2: 정리매매, 3: 단기과열, 4: 투자위험, 5: 투자경과 _ORDER_WARNING_REJECT = {'2', '4'} # 정리매매·투자위험 → 사전 거부 _ORDER_WARNING_LABELS = { '1': 'ETF투자주의요망', '2': '정리매매', '3': '단기과열', '4': '투자위험', '5': '투자경과', } _STATE_REJECT_KEYWORDS = ('거래정지', '정리매매') # state 텍스트 부분 매치 → 거부 _STATE_WARN_KEYWORDS = ('관리종목',) # state 텍스트 부분 매치 → 경고 def evaluate_stock_state(stock_meta: dict | None) -> dict: """ka10099 캐시 메타로 종목 상태 평가. 정책 (등급별 차등 — 2026-05-07 결정): - 거부(STOCK_STATE_BLOCKED): orderWarning ∈ {2 정리매매, 4 투자위험} 또는 state 에 '거래정지'·'정리매매' 키워드 포함 - 경고(warning): orderWarning ∈ {1 ETF주의, 3 단기과열, 5 투자경과} 또는 state 에 '관리종목' 포함 Returns: {'result': Result, 'warning': str | None, 'state': str, 'order_warning': str} """ state = (stock_meta or {}).get('state', '') or '' ow = str((stock_meta or {}).get('order_warning', '0') or '0').strip() # 거부 우선 if ow in _ORDER_WARNING_REJECT: label = _ORDER_WARNING_LABELS.get(ow, f'코드 {ow}') return { 'result': Result.REJECT('STOCK_STATE_BLOCKED', f'{label} 종목 — 매매 차단'), 'warning': None, 'state': state, 'order_warning': ow, } for kw in _STATE_REJECT_KEYWORDS: if kw in state: return { 'result': Result.REJECT('STOCK_STATE_BLOCKED', f'{kw} 종목 — 매매 차단'), 'warning': None, 'state': state, 'order_warning': ow, } # 경고 warning = None if ow in _ORDER_WARNING_LABELS: # 1, 3, 5 (2/4는 위에서 이미 거부됨) warning = f'⚠️ 키움 경고: {_ORDER_WARNING_LABELS[ow]} (orderWarning={ow})' else: for kw in _STATE_WARN_KEYWORDS: if kw in state: warning = f'⚠️ 키움 경고: {kw} (state="{state}")' break return {'result': Result.OK(), 'warning': warning, 'state': state, 'order_warning': ow} # ---- 딜레이 (ledger 조회) ---- def validate_delay_between_orders(now: datetime) -> Result: # 접수(submitted) 시점만 카운트 — filled/partial은 키움 처리 결과 (사용자 명령 시점 아님) last = ledger.last_terminal_event(events=('submitted',)) if not last: return Result.OK() last_ts = datetime.fromisoformat(last['ts']) elapsed = (now - last_ts).total_seconds() cooldown = _limits()['delays']['between_orders_seconds'] if elapsed < cooldown: return Result.REJECT('COOLDOWN_GLOBAL', f'마지막 거래 후 {int(cooldown - elapsed)}초 남음') return Result.OK() def validate_delay_same_symbol(now: datetime, account: str, symbol: str) -> Result: # 접수(submitted) 시점만 카운트 — filled/partial은 키움 처리 결과라 사용자 명령 시점 아님, # 한 번 접수 후 N초간 같은 종목 재시도만 차단하면 충분. last = ledger.last_event_for_symbol(account, symbol, events=('submitted',)) if not last: return Result.OK() last_ts = datetime.fromisoformat(last['ts']) elapsed = (now - last_ts).total_seconds() cooldown = _limits()['delays']['same_symbol_seconds'] if elapsed < cooldown: remaining = int(cooldown - elapsed) m, s = divmod(remaining, 60) return Result.REJECT('COOLDOWN_SAME_SYMBOL', f'[{symbol}] 마지막 체결 후 {m}분 {s}초 남음') return Result.OK() def validate_delay_same_symbol_via_broker(now: datetime, symbol: str, broker_executions: Optional[list], broker_query_error: Optional[str]) -> Result: """키움 진실 소스(kt00007) 기준 동일 종목 딜레이. NETWORK 사각(우리 ledger 'failed' 인데 실은 키움에 들어감) 과 사용자 키움앱 직접 매매까지 포함한 검증. ledger 가드 통과 후 추가로 호출. 조회 자체 실패 시 보수적 차단 (BROKER_QUERY_FAILED) — 정확도 우선. """ if broker_query_error is not None: return Result.REJECT('BROKER_QUERY_FAILED', f'키움 체결조회 실패로 안전 차단: {broker_query_error}') if not broker_executions: return Result.OK() cooldown = _limits()['delays']['same_symbol_seconds'] latest_ts: Optional[datetime] = None latest_src = '' for ex in broker_executions: if ex.get('code') != symbol: continue # 접수(ord_tm) 시점 카운트 — cntr_qty 무관 (체결 여부와 별개로 접수 자체가 사용자 명령 시점) ord_tm = (ex.get('ord_tm') or '').strip() if len(ord_tm) < 5: continue try: t = datetime.strptime(ord_tm, '%H:%M:%S').time() except ValueError: continue ts = datetime.combine(now.date(), t).replace(tzinfo=KST) if latest_ts is None or ts > latest_ts: latest_ts = ts latest_src = ex.get('comm_src', '') or '' if latest_ts is None: return Result.OK() elapsed = (now - latest_ts).total_seconds() if elapsed < cooldown: remaining = int(cooldown - elapsed) m, s = divmod(remaining, 60) src_hint = f' [출처: {latest_src}]' if latest_src else '' return Result.REJECT('COOLDOWN_SAME_SYMBOL_BROKER', f'[{symbol}] 키움 기준 마지막 매매({latest_ts.strftime("%H:%M:%S")}) 후 ' f'{m}분 {s}초 남음{src_hint}') return Result.OK() # ---- 자연어 시장가 분류 + 호가단위 ---- def classify_order_intent(text: str) -> str: """레이 파싱 보조. 'MARKET' / 'AGGRESSIVE_LIMIT' / 'LIMIT'. 명시 키워드만 체크. 모호하면 안전한 LIMIT. """ cfg = _limits()['market_order'] lower = text.lower() if text else '' for kw in cfg['natural_language_market']: if kw.lower() in lower: return 'MARKET' for kw in cfg['natural_language_aggressive_limit']: if kw.lower() in lower: return 'AGGRESSIVE_LIMIT' return 'LIMIT' def tick_size(price: int) -> int: """KRX 표준 호가단위 (2023 개편 후, NXT 도 동일 적용).""" if price < 2000: return 1 if price < 5000: return 5 if price < 20000: return 10 if price < 50000: return 50 if price < 200000: return 100 if price < 500000: return 500 return 1000 def aggressive_limit_price(side: str, orderbook: Optional[dict], ticks: Optional[int] = None, fallback_price: Optional[int] = None) -> dict: """공격적 지정가 산정. 호가창 우선, 없으면 fallback_price (ka10001 현재가) 로 대체. Returns: {'ok': bool, 'price': int, 'source': 'orderbook'|'fallback', 'ref_price': int} 또는 {'ok': False, 'code': str, 'message': str} """ if ticks is None: ticks = _limits()['market_order']['aggressive_limit_ticks'] if orderbook: levels = orderbook.get('asks' if side == 'BUY' else 'bids') or [] if levels: ref = int(levels[0]['price']) if ref > 0: if side == 'BUY': price = ref + ticks * tick_size(ref) else: price = ref - ticks * tick_size(ref) return {'ok': True, 'price': price, 'source': 'orderbook', 'ref_price': ref} # fallback — ka10001 현재가 if fallback_price and fallback_price > 0: ref = int(fallback_price) if side == 'BUY': price = ref + ticks * tick_size(ref) else: price = ref - ticks * tick_size(ref) return {'ok': True, 'price': price, 'source': 'fallback', 'ref_price': ref} return {'ok': False, 'code': 'NO_ORDERBOOK', 'message': '호가창·현재가 모두 조회 실패 — 공격적 지정가 산정 불가'} BUDGET_BUMP_MAX_REF_PRICE = 300_000 def convert_budget_to_qty(side: str, budget: int, orderbook: Optional[dict], fallback_price: Optional[int] = None) -> dict: """예산(원) → 정수 주식 수량 환산. BUY: 매도1호가 기준 (시장가 매수 시 실제 체결 가능성 가장 높은 가격). SELL: 매수1호가 기준 (대칭 — 매도 회수 추정). 호가창 비어있으면 fallback_price (ka10001 현재가) 로 대체. 버림 (floor). 슬리피지 마진 0%. BUY +1 정책: 1주 가격 ≤ 300,000원 이고 floor 잔액이 0보다 크면 qty+=1. 예산을 살짝 초과해 1주 더 매수. SELL 은 항상 floor (보유수량 초과 매도 방지). Returns: ok=True 시 {'ok': True, 'qty': int, 'ref_price': int, 'remainder': int, 'source': 'orderbook'|'fallback', 'bumped': bool} remainder: 음수면 예산 초과액 (bumped=True 일 때만 음수 가능). ok=False 시 {'ok': False, 'code': str, 'message': str} """ if not isinstance(budget, int) or budget <= 0: return {'ok': False, 'code': 'BUDGET_INVALID', 'message': f'예산은 양의 정수여야 합니다 (입력: {budget!r})'} ref_price = 0 source = 'orderbook' if orderbook: levels = orderbook.get('asks' if side == 'BUY' else 'bids') or [] if levels: cand = int(levels[0]['price']) if cand > 0: ref_price = cand if ref_price <= 0: if fallback_price and fallback_price > 0: ref_price = int(fallback_price) source = 'fallback' else: return {'ok': False, 'code': 'NO_ORDERBOOK', 'message': '호가창·현재가 모두 조회 실패 — 금액 환산 불가'} qty = budget // ref_price remainder = budget - qty * ref_price bumped = False if (side == 'BUY' and qty > 0 and remainder > 0 and ref_price <= BUDGET_BUMP_MAX_REF_PRICE): qty += 1 remainder = budget - qty * ref_price # 음수 — 예산 초과액 bumped = True if qty <= 0: ref_label_map = { ('BUY', 'orderbook'): '매도1호가', ('SELL', 'orderbook'): '매수1호가', ('BUY', 'fallback'): '현재가', ('SELL', 'fallback'): '현재가', } ref_label = ref_label_map[(side, source)] return {'ok': False, 'code': 'BUDGET_TOO_SMALL', 'message': f'1주 가격({ref_price:,}원, {ref_label})이 예산({budget:,}원)보다 큽니다'} return {'ok': True, 'qty': qty, 'ref_price': ref_price, 'remainder': remainder, 'source': source, 'bumped': bumped} def estimate_market_fill(side: str, qty: int, orderbook: dict, depth: Optional[int] = None) -> dict: """시장가 평균체결가·슬리피지 추정. 호가창 부족분은 마지막 호가가 추정.""" if depth is None: depth = _limits()['market_order']['show_orderbook_depth'] levels = (orderbook['asks'] if side == 'BUY' else orderbook['bids'])[:depth] if not levels: return {'avg_fill': 0, 'total_won': 0, 'reference_price': 0, 'slippage_pct': 0.0} remaining = qty total_won = 0 last_price = levels[0]['price'] for lvl in levels: if remaining <= 0: break take = min(remaining, lvl['qty']) total_won += take * lvl['price'] remaining -= take last_price = lvl['price'] if remaining > 0: total_won += remaining * last_price avg = total_won // qty ref = levels[0]['price'] slip = ((avg - ref) / ref * 100) if ref else 0.0 if side == 'SELL': slip = -slip return { 'avg_fill': avg, 'total_won': total_won, 'reference_price': ref, 'slippage_pct': round(slip, 3), } # ---- 통합 ---- def validate_request(request: dict, market_data: dict) -> Result: r = validate_account(request['account']) if not r.ok: return r if request['side'] not in ('BUY', 'SELL'): return Result.REJECT('INVALID_SIDE', f'잘못된 방향: {request["side"]}') if request['order_type'] not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT'): return Result.REJECT('INVALID_ORDER_TYPE', f'잘못된 주문방식: {request["order_type"]}') r = validate_trading_hours(market_data['now'], market_data.get('is_holiday', False), market_data.get('nxt_eligible', False)) if not r.ok: return r r = validate_halt_vi(market_data.get('halt', False), market_data.get('vi', False)) if not r.ok: return r r = validate_market_order_session(market_data['now'], request['order_type']) if not r.ok: return r r = validate_delay_between_orders(market_data['now']) if not r.ok: return r # same_symbol 가드는 between_orders_seconds 글로벌 가드로 통합됨 — 같은 종목 별도 필터 X. # 함수 자체는 보존 (limits.json 분리 설정 시 부활 가능). if request['order_type'] == 'LIMIT': r = validate_price_band(request['side'], request['price'], market_data['upper_limit'], market_data['lower_limit']) if not r.ok: return r if request['side'] == 'BUY': basis = None if request['order_type'] == 'MARKET': # 키움 시장가 매수 증거금은 상한가 × qty 기준. 호가창 평균이 아닌 상한가로 사전 차단. price_est = market_data.get('upper_limit', 0) basis = '상한가' elif request['order_type'] == 'AGGRESSIVE_LIMIT': agg_res = aggressive_limit_price('BUY', market_data.get('orderbook'), fallback_price=market_data.get('current_price')) if not agg_res['ok']: return Result.REJECT(agg_res['code'], agg_res['message']) price_est = agg_res['price'] else: price_est = request['price'] r = validate_balance_for_buy(request['qty'], price_est, market_data['balance_d2'], basis=basis) if not r.ok: return r else: r = validate_position_for_sell(request['qty'], market_data['position_qty']) if not r.ok: return r return Result.OK()