408a3e0d4a
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
656 lines
29 KiB
Python
656 lines
29 KiB
Python
"""
|
|
주문 검증 가드 (순수 함수).
|
|
|
|
키움 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:
|
|
"""현재 시각의 거래 세션. 경계 시각은 limits.json['trading_hours'] 가 단일 진실 소스다.
|
|
|
|
NXT_PRE(08:00~09:00) / KRX_NXT(09:00:30~15:20) / KRX_CLOSE(15:20~15:30)
|
|
NXT_AFTER(15:30~16:00) / AFTER(16:00~20:00) / CLOSED
|
|
|
|
⚠️ AFTER = **KRX 애프터마켓 + NXT 애프터마켓 동시**(2026-09-14 시행). 이걸 NXT_AFTER 로
|
|
뭉쳐 두면 NXT 미상장 종목이 validate_trading_hours 의 NXT_NOT_ELIGIBLE 로 막혀
|
|
**애프터마켓에 주문을 낼 수 없다** — 정작 KRX 애프터마켓에서는 거래되는 종목들이다
|
|
(2026-09-21 실측: 삼성전자우 KRX 거래량 16:05→18:00 +134,198주).
|
|
⚠️ 15:30~16:00 은 NXT_AFTER 로 남긴다 — KRX 쪽은 시간외 종가매매(15:40~16:00)뿐이고
|
|
우리는 그 주문유형(trde_tp=81)을 쓰지 않으므로 "NXT 만 거래 가능"이 맞다.
|
|
"""
|
|
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'
|
|
# ⚠️ AFTER 를 NXT_AFTER 보다 **먼저** 본다 — nxt_after(15:30~20:00) 구간이 애프터마켓을
|
|
# 통째로 덮고 있어서, 순서를 바꾸면 16:00 이후가 영원히 NXT_AFTER 로 잡힌다.
|
|
if _parse_hms(th['krx_after_start']) <= t < _parse_hms(th['nxt_after_end']):
|
|
return 'AFTER'
|
|
if _parse_hms(th['nxt_after_start']) <= t < _parse_hms(th['nxt_after_end']):
|
|
return 'NXT_AFTER'
|
|
return 'CLOSED'
|
|
|
|
|
|
def after_trading_day(now: datetime) -> bool:
|
|
"""그날 거래 세션이 전부 끝났는가 (NXT 애프터 마감 = 20:00 이후).
|
|
|
|
⚠️ `session_at(now) == 'CLOSED'` 로 대신하면 안 된다 — 그 판정은 **장 시작 전 새벽**과
|
|
**09:00:00~09:00:30 틈**(NXT 프리 종료와 정규장 시작 사이)에도 CLOSED 를 돌려주므로,
|
|
당일 유효기간을 재는 데 쓰면 아침에 멀쩡한 예약이 만료로 잡힌다.
|
|
"""
|
|
th = _limits()['trading_hours']
|
|
return now.replace(tzinfo=None).time() >= _parse_hms(th['nxt_after_end'])
|
|
|
|
|
|
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
|
|
# holiday_sync.py 가 쓰는 실제 형태는 {날짜: 이름} dict 다. list 만 받던 시절의
|
|
# 코드가 남아 있어 dict 를 만나면 조용히 False 를 반환했고(= 휴장일 가드가 통째로
|
|
# 무력화), 2026-08-25 예약 트레일링 작업에서 발견해 dict 를 먼저 처리하게 했다.
|
|
if isinstance(holidays, dict):
|
|
return today in holidays
|
|
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 거래 불가')
|
|
# ⚠️ AFTER(16:00~20:00)는 nxt_eligible 을 보지 않는다 — KRX 애프터마켓이 NXT 미상장
|
|
# 종목을 받으므로, 여기서 막으면 정작 거래되는 종목을 거부한다.
|
|
# ⚠️ 애프터마켓 제외종목(ETF/ETN + 거래정지·투자경고·이상급등 등 9종)의 **서버 가드는
|
|
# 없다** — ETF/ETN 은 자산웹 버튼이 stock_codes 의 etf_etn 으로 막고, 나머지는 판별
|
|
# 필드가 없어 키움 거부 메시지가 최종 방어선이다. market_data 에 플래그를 실어
|
|
# 여기서 막으려면 datasource 를 함께 고쳐야 한다(미구현).
|
|
return Result.OK(code=sess)
|
|
|
|
|
|
# 스톱지정가(trde_tp=28)를 쓰는 주문유형. SOR 라우팅으로는 발주되지 않는다.
|
|
STOP_ORDER_TYPES = ('STOP_LIMIT', 'TRAILING_STOP', 'TRAILING_ARM')
|
|
|
|
|
|
def determine_routing(now: datetime, nxt_eligible: bool, force: Optional[str],
|
|
order_type: Optional[str] = None) -> str:
|
|
"""세션·NXT 거래여부로 거래소 라우팅 suffix 를 정한다.
|
|
|
|
⚠️ 스톱 계열은 SOR(_AL) 로 낼 수 없다 — 키움이 `407022: SOR주문이 불가능한
|
|
주문종류입니다` 로 거부한다(2026-09-21 삼성전자 실측). NXT 거래 종목은 정규장
|
|
라우팅이 _AL 이라, 이 강등이 없으면 대형주 트레일링이 **항상** 거부된다.
|
|
(2026-07-30 제닉스로보틱스가 성공했던 건 NXT 미거래 종목이라 KRX 로 나갔기 때문)
|
|
|
|
NXT 단독 시간대(_NX)가 스톱을 받는지는 실증 안 됐다 — 시도는 그대로 두고,
|
|
거부되면 그 메시지로 드러나게 한다(KRX 가 닫힌 시간이라 강등할 곳도 없다).
|
|
"""
|
|
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}')
|
|
suffix = suffix_map[force]
|
|
else:
|
|
sess = session_at(now)
|
|
if sess in ('NXT_PRE', 'NXT_AFTER'):
|
|
suffix = routing['suffix_NX']
|
|
elif sess == 'KRX_CLOSE':
|
|
suffix = routing['suffix_KRX']
|
|
elif sess == 'AFTER':
|
|
# 애프터마켓은 **KRX 로 보낸다** — KRX 애프터마켓이 대상 종목을 전부 받고
|
|
# (2026-09-21 실측: NXT 미상장 종목도 16:00 이후 KRX 거래량이 증가),
|
|
# dmst_stex_tp 에 애프터 전용 값이 생기지도 않았다(KRX/NXT/SOR 그대로).
|
|
# ⚠️ SOR 을 쓰지 않는 이유 — 시세는 SOR 이 KRX+NXT 통합으로 오지만
|
|
# **주문이 애프터마켓에 어떻게 라우팅되는지는 미검증**이다. 오늘 아침
|
|
# "SOR 주문이 불가능한 주문종류입니다"(407022) 가 정확히 그 함정이었다.
|
|
# 검증된 경로만 쓴다. NXT 상장 종목도 KRX 애프터마켓에서 거래되므로 손실 없다.
|
|
suffix = routing['suffix_KRX']
|
|
elif sess == 'KRX_NXT':
|
|
suffix = routing['suffix_AL'] if nxt_eligible else routing['suffix_KRX']
|
|
else:
|
|
raise ValueError('CLOSED session has no valid routing')
|
|
|
|
if order_type in STOP_ORDER_TYPES and suffix == routing['suffix_AL']:
|
|
return routing['suffix_KRX']
|
|
return suffix
|
|
|
|
|
|
# ---- 가격 가드 (±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)
|
|
# 애프터마켓 제출가능 호가는 지정가·최우선지정가·최유리지정가(+IOC/FOK)뿐 — **시장가 없음**.
|
|
if sess in ('NXT_PRE', 'NXT_AFTER', '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', 'STOP_LIMIT',
|
|
'TRAILING_STOP', 'TRAILING_ARM'):
|
|
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['order_type'] in ('STOP_LIMIT', 'TRAILING_STOP'):
|
|
# 스톱지정가: 지정가(체결가)·조건단가(트리거) 둘 다 가격제한 이내 + 매도 전용.
|
|
# 보호매도(하락 시 매도)라 트리거는 현재가보다 낮아야 함 — 아니면 즉시 발동돼 스톱 의미 없음.
|
|
# 트레일링도 키움엔 스톱지정가로 나가므로 같은 검증을 받는다 (조건단가는 자동 계산값).
|
|
if request['side'] != 'SELL':
|
|
return Result.REJECT('STOP_SELL_ONLY', '스톱지정가는 현재 매도만 지원')
|
|
stop_price = request.get('stop_price')
|
|
if not stop_price or stop_price <= 0:
|
|
return Result.REJECT('STOP_PRICE_REQUIRED', '조건단가(트리거 가격)를 입력하세요')
|
|
r = validate_price_band(request['side'], request['price'],
|
|
market_data['upper_limit'], market_data['lower_limit'])
|
|
if not r.ok:
|
|
return r
|
|
r = validate_price_band(request['side'], stop_price,
|
|
market_data['upper_limit'], market_data['lower_limit'])
|
|
if not r.ok:
|
|
return r
|
|
cur = market_data.get('current_price') or 0
|
|
if cur and stop_price >= cur:
|
|
return Result.REJECT('STOP_TRIGGER_NOT_BELOW',
|
|
f'조건단가 {stop_price:,}원 ≥ 현재가 {cur:,}원 — '
|
|
f'하락 시 매도(스톱)는 현재가보다 낮게 설정해야 함')
|
|
if request['order_type'] == 'TRAILING_ARM':
|
|
# 예약 트레일링은 지금 나가는 주문이 아니라 "발동가에 닿으면 시작" 하는 예약이다.
|
|
# 그래서 스톱지정가와 두 가지 검증을 일부러 건너뛴다:
|
|
# · 가격제한폭 — 발동가는 오늘 상한가 위여도 통과시킨다. 실제 발주는 발동 시점의
|
|
# 현재가로 계산되므로 그때 검증된다. arm 이 당일 유효가 된 뒤로는 상한가 초과가
|
|
# "오늘 도달 불가 = 그냥 만료" 를 뜻하지만, **막지 않고 경고만** 한다
|
|
# (2026-08-25 관리자님 결정). 경고는 승인 카드(card.format_card)와 웹
|
|
# 미리보기·확인 팝업 3곳에 있다 — 여기서 거부로 바꾸지 말 것.
|
|
# · 조건단가 < 현재가 — 발동가는 정의상 현재가보다 위다. 이 검사를 태우면 전부 거부된다.
|
|
# 아래 매도 보유수량 검증은 그대로 받는다 — 지금 없는 종목을 예약해봐야 무의미하다.
|
|
if request['side'] != 'SELL':
|
|
return Result.REJECT('ARM_SELL_ONLY', '예약 트레일링은 매도 전용')
|
|
if not request.get('trigger_price'):
|
|
return Result.REJECT('ARM_TRIGGER', '발동가를 입력하세요')
|
|
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()
|