Initial commit: OpenClaw 워크스페이스 버전관리 시작

설정·스크립트·스킬·문서·큐레이션 메모리 추적.
시크릿(credentials/identity)·런타임 상태(state/logs/sessions/sqlite)·
백업(clobbered/bak)·dream 캐시는 .gitignore로 제외.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-06-04 15:39:41 +09:00
commit fed3526b20
199 changed files with 49671 additions and 0 deletions
@@ -0,0 +1,55 @@
"""
OpenClaw Stock Agent — Order Module (orders/)
이 패키지는 키움 REST API 로 매수·매도 주문을 발행하는 유일한 통로입니다.
조회 전용 모듈(`agents/stock/workspace/scripts/kiwoom_client.py`)와 분리됩니다.
매매 절대 원칙
==============
1. LLM 결정 금지
- LLM(클로·레이) 출력으로 매매 트리거 안 됨.
- 자연어 → 페이로드 파싱은 LLM 가능, 단 사람의 PIN echo 가 마지막 게이트.
- 환경변수 OPENCLAW_AGENT 가 셋된 세션은 즉시 sys.exit(99). 진입점 첫 줄 강제.
2. DM + chat_id 화이트리스트
- 그룹·익명 메시지는 즉시 거부.
- 토큰 검증 시 발신자 ID 화이트리스트 통과해야 실행.
3. 사이드카 default OFF
- state/orders_disabled 파일 존재 시 모든 진입점 첫 줄에서 거부.
- /orders_on 으로 풀고, /orders_off 또는 trash 로 막음.
4. 한도·시간·종목 가드는 limits.json 으로 관리
- 코드 상수 X. 변경 시 별도 커밋, 단위테스트 강제.
- guards.py 가 limits.json 을 읽어 검증.
5. 자동 재시도 금지
- 네트워크 timeout 후 자동 재시도 안 함 (중복 체결 위험).
- 실패 시 사람이 ord_no 조회로 체결 확인 후 재판단.
확정 사양
=========
- 매매 허용 계좌: 본인 4계좌 (가희 포함)
- 1회 주문 / 1일 누적 / 잔고% 한도: 없음
- 가격 가드: ±30% (상한가/하한가) 초과 지정가 거부 (시장가에는 적용 불가, 거래소가 자연 차단)
- 시장가: 허용
- 자연어 "시장가" → 시장가 주문
- 자연어 "지금 바로 / 즉시 / 빨리 / 당장" → 최우선호가 +1틱 지정가 (모호함 보호)
- 거래시간: 08:0020:00 (NXT 포함)
- 08:0009:00 NXT 단독 (NXT 미가능 종목 거부)
- 09:00:3015:20 KRX + NXT 동시 (SOR)
- 15:2015:30 KRX 단일가
- 15:3020:00 NXT 단독 (NXT 미가능 종목 거부)
- 라우팅 default: SOR (_AL) 자동
- 거래 간 딜레이: 마지막 카드 종료(체결·만료·취소) 후 60초
- 동일 종목 딜레이: 마지막 체결 후 600초 (10분)
- PIN
- 본인 계좌(일반·ISA): 숫자 4자리
- 가희 계좌(가희_일반·가희_ISA): 영숫자 8자리, 혼동 글자 제외 55자
- 만료 120초, 1회용, 1회 시도
- 카드+PIN 분리 발송: 메시지 A(카드, 매수/매도·계좌·종목명·가격 하이라이트) + 메시지 B(PIN만 단독)
- 만료/취소/오입력: 모두 텔레그램 알림
- 사이드카: /orders_on /orders_off
상세 한도값과 시간 경계는 limits.json 참조.
"""
@@ -0,0 +1,269 @@
"""
텔레그램 카드 메시지 포맷터.
매수/매도, 계좌, 종목명, 가격 4개를 ▶ 마커 + *bold* (텔레그램 Markdown)로 하이라이트.
시장가일 때 호가창 + 평균체결가 + 슬리피지% 표시.
PIN 메시지는 PIN 만 단독 — 메타 정보 일체 없음.
가희 계좌는 카드 헤더에 🔐 마커 추가.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional
LIMITS_FILE = Path(__file__).resolve().parent / 'limits.json'
ACCOUNT_DISPLAY = {
'일반': '본인 일반',
'ISA': '본인 ISA',
'가희_일반': '가희 일반',
'가희_ISA': '가희 ISA',
}
def _limits() -> dict:
return json.loads(LIMITS_FILE.read_text(encoding='utf-8'))
def _is_spouse(account: str) -> bool:
return account in _limits()['spouse_accounts']
def _account_display(account: str) -> str:
return ACCOUNT_DISPLAY.get(account, account)
def _md_bold(s: str) -> str:
return f'*{s}*'
def _money(n) -> str:
return f'{int(n):,}'
def _pct(p: float, digits: int = 2) -> str:
if abs(p) < 10 ** -digits:
p = 0.0
sign = '+' if p > 0 else ''
return f'{sign}{p:.{digits}f}%'
def format_card(request: dict, market_data: dict, card_id: str,
estimate: Optional[dict] = None,
budget_conversion: Optional[dict] = None,
state_warning: Optional[str] = None,
amended: bool = False) -> str:
cfg = _limits()['card']
side = request['side']
side_word = '매수' if side == 'BUY' else '매도'
side_emoji = cfg['buy_emoji'] if side == 'BUY' else cfg['sell_emoji']
marker = cfg['highlight_marker']
spouse_marker = ' 🔐 가희 계좌' if _is_spouse(request['account']) else ''
type_marker = ''
if request['order_type'] == 'MARKET':
type_marker = f' {cfg["warning_emoji"]} 시장가'
elif request['order_type'] == 'AGGRESSIVE_LIMIT':
type_marker = f' {cfg["warning_emoji"]} 공격적 지정가'
amend_marker = ' ✏️ 수정됨' if amended else ''
lines = [f'{side_emoji} {_md_bold(side_word + " 미리보기")} [#{card_id}]{type_marker}{spouse_marker}{amend_marker}', '']
lines.append(f'{marker} {_md_bold(side_word)}')
lines.append(f'{marker} 계좌: {_md_bold(_account_display(request["account"]))}')
symbol_name = request.get('symbol_name', request['symbol'])
lines.append(f'{marker} 종목: {_md_bold(symbol_name)} ({request["symbol"]})')
if state_warning:
lines.append(state_warning)
if request['order_type'] == 'LIMIT':
price_part = _md_bold(_money(request['price']))
if market_data.get('prev_close'):
ratio = (request['price'] - market_data['prev_close']) / market_data['prev_close'] * 100
price_part += f' (전일종가 {_pct(ratio)})'
lines.append(f'{marker} 가격: {price_part}')
elif request['order_type'] == 'MARKET':
lines.append(f'{marker} 가격: {_md_bold("시장가")}')
else:
if estimate and 'aggressive_price' in estimate:
tail = '최우선호가+1틱'
if estimate.get('source') == 'fallback':
now_dt = market_data.get('now')
tm = now_dt.strftime('%H:%M') if now_dt else ''
tm_part = f' {tm}' if tm else ''
tail = f'호가창 비어 현재가{tm_part} +1틱'
lines.append(f'{marker} 가격: {_md_bold(_money(estimate["aggressive_price"]))} ({tail})')
else:
lines.append(f'{marker} 가격: {_md_bold("최우선호가+1틱")}')
lines.append('')
qty_line = f'수량: {request["qty"]:,}'
if budget_conversion:
if budget_conversion.get('source') == 'fallback':
now_dt = market_data.get('now')
tm = now_dt.strftime('%H:%M') if now_dt else ''
tm_part = f' {tm}' if tm else ''
ref_label = f'호가창 비어 현재가{tm_part}'
else:
ref_label = '매도1호가' if side == 'BUY' else '매수1호가'
rem = budget_conversion['remainder']
if budget_conversion.get('bumped'):
rem_part = f'1주 추가 매수, 초과액 {_money(abs(rem))}'
else:
rem_part = f'잔액 {_money(rem)}'
qty_line += (f' (예산 {_money(budget_conversion["budget"])}'
f'{ref_label} {_money(budget_conversion["ref_price"])} 기준 환산, '
f'{rem_part})')
lines.append(qty_line)
if request['order_type'] == 'MARKET':
lines.append(f'현재가: {_money(market_data["current_price"])}')
ob = market_data.get('orderbook') or {}
levels = (ob.get('asks' if side == 'BUY' else 'bids') or [])[:cfg['orderbook_depth']]
label = '매도' if side == 'BUY' else '매수'
for i, lvl in enumerate(levels, 1):
lines.append(f'{label}{i}호가: {_money(lvl["price"])} (잔량 {lvl["qty"]:,}주)')
if estimate:
lines.append('')
money_label = '예상 금액' if side == 'BUY' else '예상 회수'
lines.append(f'예상 평균체결가: {_money(estimate["avg_fill"])}')
lines.append(f'{money_label}: 약 {_money(estimate["total_won"])}')
lines.append(f'예상 슬리피지: {_pct(estimate["slippage_pct"], 3)}')
else:
price = request.get('price') or (estimate and estimate.get('aggressive_price'))
if price:
money_label = '예상 금액' if side == 'BUY' else '예상 회수'
lines.append(f'{money_label}: {_money(price * request["qty"])}')
if cfg['show_balance_ratio']:
if side == 'BUY' and market_data.get('balance_d2') is not None:
balance = market_data['balance_d2']
need = (estimate['total_won'] if estimate and request['order_type'] == 'MARKET'
else (request.get('price') or (estimate and estimate.get('aggressive_price')) or 0) * request['qty'])
if balance and need:
ratio = need / balance * 100
lines.append(f'매수가능금액: {_money(balance)} (잔고대비 {ratio:.1f}%)')
else:
lines.append(f'매수가능금액: {_money(balance)}')
if side == 'SELL' and market_data.get('position_qty') is not None:
lines.append(f'보유수량: {market_data["position_qty"]:,}')
lines.append(f'{_limits()["pin"]["expiry_seconds"]}초 후 만료')
return '\n'.join(lines)
def format_pin_message(pin: str) -> str:
return pin
def format_filled(card_id: str, side: str, symbol_name: str, qty: int,
fill_price: int, ord_no: str, remaining_balance: Optional[int] = None) -> str:
side_word = '매수' if side == 'BUY' else '매도'
out = [f'✅ [#{card_id}] 체결: {symbol_name} {qty:,}주 @ {_money(fill_price)} ({side_word})',
f'주문번호: {ord_no}']
if remaining_balance is not None:
out.append(f'잔여 예수금: {_money(remaining_balance)}')
return '\n'.join(out)
def format_submitted(card_id: str, side: str, symbol_name: str, qty: int,
price: Optional[int], ord_no: str) -> str:
side_word = '매수' if side == 'BUY' else '매도'
price_str = _money(price) if price else '시장가'
return f'📨 [#{card_id}] {side_word} 접수: {symbol_name} {qty:,}주 @ {price_str}\n주문번호: {ord_no}'
def format_expired(card_id: str, side: str) -> str:
side_word = '매수' if side == 'BUY' else '매도'
return f'⏱️ [#{card_id}] 승인 만료. {side_word}가 취소되었습니다.'
def format_canceled(card_id: str, side: str) -> str:
side_word = '매수' if side == 'BUY' else '매도'
return f'❌ [#{card_id}] {side_word} 취소되었습니다.'
def format_pin_mismatch(card_id: str) -> str:
return f'❌ [#{card_id}] PIN 불일치. 카드 무효. 다시 신호 보내주세요.'
def format_partial(card_id: str, side: str, symbol_name: str,
cntr_qty: int, order_qty: int, fill_price: int, ord_no: str) -> str:
side_word = '매수' if side == 'BUY' else '매도'
return (f'🟡 [#{card_id}] {side_word} 부분체결: {symbol_name} '
f'{cntr_qty:,}/{order_qty:,}주 @ {_money(fill_price)}\n주문번호: {ord_no}')
def format_broker_post_reject(card_id: str, side: str, symbol_name: str,
ord_no: str, mdfy_cncl: str) -> str:
side_word = '매수' if side == 'BUY' else '매도'
return (f'❌ [#{card_id}] {side_word} 사후거절({mdfy_cncl}): {symbol_name}\n주문번호: {ord_no}')
def format_unfilled_timeout(card_id: str, side: str, symbol_name: str,
cntr_qty: int, order_qty: int, ord_no: str) -> str:
side_word = '매수' if side == 'BUY' else '매도'
if cntr_qty == 0:
head = f'⏱️ [#{card_id}] {side_word} 30분째 미체결: {symbol_name} 0/{order_qty:,}'
else:
head = (f'⏱️ [#{card_id}] {side_word} 30분 추적 종료 (부분체결): '
f'{symbol_name} {cntr_qty:,}/{order_qty:,}')
return (f'{head}\n주문번호: {ord_no}\n'
f'필요 시 키움 직접 정정/취소 또는 추가 추적 명령')
def format_cancel_confirmed(side: str, symbol_name: str, orig_ord_no: str,
new_ord_no: str, cancel_qty: int) -> str:
side_word = '매수' if side == 'BUY' else ('매도' if side == 'SELL' else side)
return (f'{side_word} 취소 확인: {symbol_name} {cancel_qty:,}주 취소됨\n'
f'원주문: {orig_ord_no} / 취소주문: {new_ord_no}')
def format_cancel_unconfirmed_timeout(side: str, symbol_name: str,
orig_ord_no: str, new_ord_no: str) -> str:
side_word = '매수' if side == 'BUY' else ('매도' if side == 'SELL' else side)
return (f'⏱️ {side_word} 취소 30분째 미확인: {symbol_name}\n'
f'원주문: {orig_ord_no} / 취소주문: {new_ord_no}\n'
f'키움에서 직접 확인 필요')
def format_rejected(code: str, message: str) -> str:
return f'⛔ 거부 [{code}]: {message}'
def format_sidecar_blocked() -> str:
return '🚫 매매 비활성화 상태. /orders_on 으로 재개하세요.'
def format_card_locked(active: Optional[dict] = None) -> str:
header = '⏳ 이전 카드가 아직 활성 — 처리 후 다시 신호 주세요.'
if not active:
return header
side_word = '매수' if active.get('side') == 'BUY' else '매도'
account = _account_display(active.get('account', ''))
name = active.get('symbol_name') or active.get('symbol') or ''
qty = active.get('qty')
order_type = active.get('order_type')
price = active.get('price')
if order_type == 'MARKET':
price_str = '시장가'
elif order_type == 'AGGRESSIVE_LIMIT':
price_str = f'공격적 지정가 {_money(price)}' if price else '공격적 지정가'
else:
price_str = _money(price) if price is not None else '지정가'
qty_str = f'{qty}' if qty is not None else ''
desc = ' · '.join(p for p in [account, side_word, f'{name} {qty_str}'.strip(), price_str] if p)
expires_in = int(active.get('expires_in') or 0)
return '\n'.join([
header,
f' [#{active.get("card_id", "?")}] {desc}',
f' 남은 시간: {expires_in}초 (또는 /cancel 로 즉시 폐기)',
])
def format_dryrun(payload: dict) -> str:
return '🧪 DRYRUN — 실주문 안 함\n' + json.dumps(payload, ensure_ascii=False, indent=2)
@@ -0,0 +1,177 @@
"""
키움 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
@@ -0,0 +1,24 @@
"""launchd 진입점 — 만료된 활성 카드 정리 + 텔레그램 알림.
10초 간격으로 호출 (StartInterval=10). 활성 카드가 없거나 만료되지 않았으면 즉시 종료.
PinStore 가 파일 기반이므로 별도 프로세스에서도 동일 활성 카드를 본다.
"""
from __future__ import annotations
import sys
from . import handler
def main() -> int:
try:
handler._sweep_expired_and_notify()
except Exception as e:
print(f'[expiry_watcher] {type(e).__name__}: {e}', file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,457 @@
"""주문 접수 후 체결·취소 추적 — on-demand 데몬 패턴.
알바 측:
watch(...) [fill kind] / watch_cancel(...) [cancel kind]
→ 큐 파일(state/fill_pending.jsonl)에 entry append + 데몬 ensure_running.
데몬 측 (orders/fill_watcher_daemon.py main):
큐 파일 읽어 _FillWatcher._tracked 에 동기화
→ kt00007 폴링(체결 추적) + ka10075 폴링(취소 추적, cancel watch 존재 시만)
→ 알림 → 추적 끝난 entry 큐에서 제거 → 큐 비면 자기 종료(sys.exit + PID 파일 삭제).
폴링 스케줄 (모든 주문 공통, 가장 어린 주문 경과 시간 기준):
- 0~30초: 5초 간격
- 30~120초: 10초 간격
- 120~600초: 30초 간격
- 600~1800초: 60초 간격
- 1800초(30분) 경과 시 미체결/미확인 알림 1회 + 추적 종료
cancel kind: 원주문(orig_ord_no)이 ka10075 미체결 목록에서 사라지면 취소 확정.
사용자가 직접 발주한 fill watch 가 동시에 있으면 mdfy_cncl 발생 시 '사후거절' 메시지를
억제(취소 watch 가 확정 메시지 책임).
"""
from __future__ import annotations
import contextlib
import fcntl
import json
import os
import subprocess
import sys
import threading
import time
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Callable, Optional
from . import card, ledger
UNFILLED_TIMEOUT_SECONDS = 1800 # 30분
def _spawn_journal_collect() -> None:
"""전량 체결(filled) 직후 trade_journal.collect 비동기 호출.
scripts/는 _SCRIPTS_DIR(=parent) 기준 sys.path 추가 후 import.
실패해도 fill 흐름·21:00 launchd 재적재에 영향 없음."""
def _run():
try:
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
import trade_journal as tj
tj.collect(quiet=True)
except Exception as e:
sys.stderr.write(f'[fill→journal] collect failed: {e}\n')
threading.Thread(target=_run, daemon=True).start()
_ORDERS_DIR = Path(__file__).resolve().parent
_SCRIPTS_DIR = _ORDERS_DIR.parent
_WORKSPACE_ROOT = _SCRIPTS_DIR.parent
_STATE_DIR = _WORKSPACE_ROOT / 'state'
QUEUE_FILE = _STATE_DIR / 'fill_pending.jsonl'
QUEUE_LOCK = _STATE_DIR / 'fill_pending.jsonl.lock'
PID_FILE = _STATE_DIR / 'fill_watcher.pid'
# ---------- Tracked entry ----------
@dataclass
class Tracked:
ord_no: str
account: str
side: str
symbol: str
symbol_name: str
order_qty: int
price: Optional[int]
order_type: str
card_id: str
started_at: float
last_cntr_qty: int = 0
kind: str = 'fill' # 'fill' (체결추적) | 'cancel' (취소확정추적)
orig_ord_no: Optional[str] = None # cancel kind 전용 — 취소 대상 원주문 번호
# ---------- 큐 파일 IO (atomic, file-locked) ----------
@contextlib.contextmanager
def _file_lock(path: Path):
path.parent.mkdir(parents=True, exist_ok=True)
fp = open(path, 'w')
try:
fcntl.flock(fp, fcntl.LOCK_EX)
yield
finally:
try:
fcntl.flock(fp, fcntl.LOCK_UN)
finally:
fp.close()
def append_queue_entry(entry: dict) -> None:
"""큐 파일에 한 줄 append (lock 보호)."""
with _file_lock(QUEUE_LOCK):
QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
with QUEUE_FILE.open('a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
def read_queue() -> list[dict]:
"""큐 파일을 읽어 entry 리스트 반환. 빈 줄/잘못된 JSON 은 스킵."""
if not QUEUE_FILE.exists():
return []
with _file_lock(QUEUE_LOCK):
out: list[dict] = []
for line in QUEUE_FILE.read_text(encoding='utf-8').splitlines():
line = line.strip()
if not line:
continue
try:
out.append(json.loads(line))
except ValueError:
continue
return out
def persist_queue(entries: list[dict]) -> None:
"""큐 파일 전체 다시 쓰기 (rewrite)."""
with _file_lock(QUEUE_LOCK):
QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
if not entries:
if QUEUE_FILE.exists():
try:
QUEUE_FILE.unlink()
except OSError:
QUEUE_FILE.write_text('', encoding='utf-8')
return
body = '\n'.join(json.dumps(e, ensure_ascii=False) for e in entries) + '\n'
tmp = QUEUE_FILE.with_suffix(QUEUE_FILE.suffix + '.tmp')
tmp.write_text(body, encoding='utf-8')
os.replace(tmp, QUEUE_FILE)
# ---------- PID 파일 / 데몬 기동 ----------
def is_daemon_alive() -> bool:
"""PID 파일 기반 데몬 생존 확인. stale 자동 검출."""
if not PID_FILE.exists():
return False
try:
pid = int(PID_FILE.read_text().strip())
except (ValueError, OSError):
return False
if pid <= 0:
return False
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
# 다른 사용자 PID 와 충돌 — 매우 드묾. 살아있다고 보수적 가정.
return True
def ensure_daemon_running() -> None:
"""데몬 살아있으면 패스, 죽었으면 fork. 두 알바 동시 호출에도 PID 파일 lock 으로 한 데몬만 살아남음."""
if is_daemon_alive():
return
# 데몬 자체가 PID 파일 작성·정리 — 알바는 fork 만.
subprocess.Popen(
[sys.executable, '-m', 'orders.fill_watcher_daemon'],
cwd=str(_SCRIPTS_DIR),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
start_new_session=True,
)
# ---------- 데몬 entry — _FillWatcher 가 사용 ----------
class _FillWatcher:
"""데몬 프로세스 안에서 사용되는 추적 워커. in-memory _tracked + 텔레그램·키움 콜백."""
def __init__(self):
self._lock = threading.RLock()
self._tracked: dict[str, Tracked] = {}
self._send: Callable[[str], bool] = lambda msg: True
self._fetch_executions: Callable[[str], list[dict]] = lambda account: []
self._fetch_open_orders: Callable[[str], list[dict]] = lambda account: []
def configure(self, send_func, fetch_executions, fetch_open_orders=None) -> None:
with self._lock:
self._send = send_func
self._fetch_executions = fetch_executions
if fetch_open_orders is not None:
self._fetch_open_orders = fetch_open_orders
def sync_from_queue(self, entries: list[dict]) -> None:
"""큐 파일 entry 리스트로 _tracked 동기화. 큐에 있고 _tracked 에 없으면 추가, 큐에서 사라진 ord_no 는 _tracked 에서도 제거."""
with self._lock:
queue_ord_nos = {e['ord_no'] for e in entries}
# 추가
for e in entries:
ord_no = e['ord_no']
if ord_no not in self._tracked:
self._tracked[ord_no] = Tracked(
ord_no=ord_no, account=e['account'], side=e['side'],
symbol=e['symbol'], symbol_name=e['symbol_name'],
order_qty=int(e['order_qty']),
price=e.get('price'),
order_type=e['order_type'], card_id=e['card_id'],
started_at=float(e['started_at']),
last_cntr_qty=int(e.get('last_cntr_qty', 0)),
kind=e.get('kind', 'fill'),
orig_ord_no=e.get('orig_ord_no'),
)
else:
# 이미 있으면 last_cntr_qty 만 동기화 (큐가 진실 소스)
self._tracked[ord_no].last_cntr_qty = int(e.get('last_cntr_qty', 0))
# 제거
for ord_no in list(self._tracked.keys()):
if ord_no not in queue_ord_nos:
self._tracked.pop(ord_no, None)
def snapshot_entries(self) -> list[dict]:
"""현재 _tracked 를 큐 파일에 쓸 수 있는 entry 리스트로 직렬화."""
with self._lock:
return [asdict(t) for t in self._tracked.values()]
def _next_sleep_seconds(self) -> int:
with self._lock:
if not self._tracked:
return 5
now = time.time()
min_elapsed = min(now - t.started_at for t in self._tracked.values())
if min_elapsed < 30:
return 5
if min_elapsed < 120:
return 10
if min_elapsed < 600:
return 30
return 60
def _poll_once(self) -> None:
with self._lock:
fill_accounts = sorted({t.account for t in self._tracked.values()
if t.kind == 'fill'})
cancel_accounts = sorted({t.account for t in self._tracked.values()
if t.kind == 'cancel'})
tracked_snapshot = dict(self._tracked)
rows_by_account: dict[str, list[dict]] = {}
for account in fill_accounts:
try:
rows_by_account[account] = self._fetch_executions(account) or []
except Exception as e:
ledger.append('rejected', {'reason': 'FILL_WATCHER_FETCH_ERROR',
'account': account, 'message': repr(e)})
open_orders_by_account: dict[str, list[dict]] = {}
for account in cancel_accounts:
try:
open_orders_by_account[account] = self._fetch_open_orders(account) or []
except Exception as e:
ledger.append('rejected', {'reason': 'CANCEL_WATCHER_FETCH_ERROR',
'account': account, 'message': repr(e)})
now = time.time()
for ord_no, t in tracked_snapshot.items():
elapsed = now - t.started_at
if t.kind == 'cancel':
# 원주문이 미체결 목록에서 사라지면 취소 확정
open_rows = open_orders_by_account.get(t.account)
if open_rows is None:
# fetch 실패 시 다음 폴링으로 미룸
continue
still_open = any((r.get('ord_no') or '').strip() == t.orig_ord_no
for r in open_rows)
if not still_open:
self._handle_cancel_confirmed(t)
elif elapsed >= UNFILLED_TIMEOUT_SECONDS:
self._handle_cancel_timeout(t)
continue
# fill kind (기본)
rows = rows_by_account.get(t.account, [])
row = next((r for r in rows if (r.get('ord_no') or '').strip() == ord_no), None)
if row is not None:
self._handle_row(t, row)
elif elapsed >= UNFILLED_TIMEOUT_SECONDS:
self._handle_timeout(t)
def _handle_row(self, t: Tracked, row: dict) -> None:
cntr_qty = int(row.get('cntr_qty') or 0)
cntr_uv = int(row.get('cntr_uv') or 0)
mdfy_cncl = (row.get('mdfy_cncl') or '').strip()
if mdfy_cncl:
# 사용자가 cancel_open_order 로 발주한 취소가 잡힌 거면, cancel watch 가
# 확정 메시지를 보낸다 — 여기서는 사후거절 알림 억제 + 조용히 fill watch 해제.
with self._lock:
user_initiated = any(
x.kind == 'cancel' and x.orig_ord_no == t.ord_no
for x in self._tracked.values()
)
self._tracked.pop(t.ord_no, None)
if user_initiated:
ledger.append('canceled', {'card_id': t.card_id, 'ord_no': t.ord_no,
'account': t.account, 'symbol': t.symbol,
'reason': 'USER_CANCEL_VIA_CANCEL_ORDER'})
return
ledger.append('failed', {'card_id': t.card_id, 'ord_no': t.ord_no,
'account': t.account, 'symbol': t.symbol,
'reason': 'BROKER_POST_REJECT',
'mdfy_cncl': mdfy_cncl})
self._send(card.format_broker_post_reject(t.card_id, t.side, t.symbol_name,
t.ord_no, mdfy_cncl))
return
if cntr_qty > t.last_cntr_qty:
new_fill = cntr_qty - t.last_cntr_qty
t.last_cntr_qty = cntr_qty
if cntr_qty >= t.order_qty:
with self._lock:
self._tracked.pop(t.ord_no, None)
ledger.append('filled', {'card_id': t.card_id, 'ord_no': t.ord_no,
'account': t.account, 'symbol': t.symbol,
'qty': cntr_qty, 'price': cntr_uv})
self._send(card.format_filled(t.card_id, t.side, t.symbol_name,
cntr_qty, cntr_uv, t.ord_no))
# 전량 체결 → 자산웹 거래내역 즉시 갱신 (별도 스레드, 추적 블로킹 X)
_spawn_journal_collect()
else:
ledger.append('partial', {'card_id': t.card_id, 'ord_no': t.ord_no,
'account': t.account, 'symbol': t.symbol,
'cntr_qty': cntr_qty, 'order_qty': t.order_qty,
'price': cntr_uv, 'new_fill': new_fill})
self._send(card.format_partial(t.card_id, t.side, t.symbol_name,
cntr_qty, t.order_qty, cntr_uv, t.ord_no))
def _handle_timeout(self, t: Tracked) -> None:
with self._lock:
self._tracked.pop(t.ord_no, None)
ledger.append('expired', {'card_id': t.card_id, 'ord_no': t.ord_no,
'account': t.account, 'symbol': t.symbol,
'reason': 'FILL_WATCH_TIMEOUT',
'order_qty': t.order_qty,
'last_cntr_qty': t.last_cntr_qty})
self._send(card.format_unfilled_timeout(t.card_id, t.side, t.symbol_name,
t.last_cntr_qty, t.order_qty, t.ord_no))
def _handle_cancel_confirmed(self, t: Tracked) -> None:
with self._lock:
self._tracked.pop(t.ord_no, None)
if t.orig_ord_no:
self._tracked.pop(str(t.orig_ord_no), None)
ledger.append('cancel_confirmed', {'card_id': t.card_id,
'new_ord_no': t.ord_no,
'orig_ord_no': t.orig_ord_no,
'account': t.account, 'symbol': t.symbol,
'cancel_qty': t.order_qty})
self._send(card.format_cancel_confirmed(t.side, t.symbol_name,
t.orig_ord_no, t.ord_no, t.order_qty))
def _handle_cancel_timeout(self, t: Tracked) -> None:
with self._lock:
self._tracked.pop(t.ord_no, None)
ledger.append('cancel_unconfirmed_timeout', {'card_id': t.card_id,
'new_ord_no': t.ord_no,
'orig_ord_no': t.orig_ord_no,
'account': t.account,
'symbol': t.symbol,
'cancel_qty': t.order_qty})
self._send(card.format_cancel_unconfirmed_timeout(t.side, t.symbol_name,
t.orig_ord_no, t.ord_no))
_watcher = _FillWatcher()
# ---------- 외부 진입점 ----------
def configure(send_func, fetch_executions, fetch_open_orders=None):
_watcher.configure(send_func, fetch_executions, fetch_open_orders)
def watch(ord_no, account, side, symbol, symbol_name, order_qty, price, order_type, card_id):
"""알바 측 진입점 — 큐에 append + 데몬 ensure_running.
in-memory 가 아니라 별도 데몬 프로세스가 추적. 호출 프로세스는 즉시 반환.
"""
if not ord_no:
return
entry = {
'ord_no': str(ord_no), 'account': account, 'side': side,
'symbol': symbol, 'symbol_name': symbol_name,
'order_qty': int(order_qty), 'price': price,
'order_type': order_type, 'card_id': card_id,
'started_at': time.time(), 'last_cntr_qty': 0,
'kind': 'fill',
}
# 큐에 이미 있는 ord_no 면 중복 append 방지
existing = read_queue()
if any(e.get('ord_no') == entry['ord_no'] for e in existing):
ensure_daemon_running()
return
append_queue_entry(entry)
ensure_daemon_running()
def watch_cancel(new_ord_no, orig_ord_no, account, side, symbol, symbol_name,
cancel_qty, card_id=None):
"""취소 주문(kt10003) 접수 후 broker 확정 추적.
new_ord_no: kt10003 응답의 ord_no (취소 주문 자체 번호 — _tracked dict key)
orig_ord_no: 취소 대상 원주문 ord_no — ka10075 폴링으로 사라지는지 감시
cancel_qty: 취소 요청 수량 (cancel_qty=0 호출 시 발주 시점 unfilled_qty 전달)
side/symbol/symbol_name: 원주문의 것 (텔레그램 메시지 가독성용)
"""
if not new_ord_no or not orig_ord_no:
return
entry = {
'ord_no': str(new_ord_no), 'account': account, 'side': side,
'symbol': symbol, 'symbol_name': symbol_name,
'order_qty': int(cancel_qty), 'price': None,
'order_type': 'CANCEL', 'card_id': card_id or '',
'started_at': time.time(), 'last_cntr_qty': 0,
'kind': 'cancel', 'orig_ord_no': str(orig_ord_no),
}
existing = read_queue()
if any(e.get('ord_no') == entry['ord_no'] for e in existing):
ensure_daemon_running()
return
append_queue_entry(entry)
ensure_daemon_running()
# ---------- 테스트 헬퍼 ----------
def _reset_for_test():
with _watcher._lock:
_watcher._tracked.clear()
if QUEUE_FILE.exists():
try:
QUEUE_FILE.unlink()
except OSError:
pass
if PID_FILE.exists():
try:
PID_FILE.unlink()
except OSError:
pass
def _peek_for_test():
with _watcher._lock:
return dict(_watcher._tracked)
@@ -0,0 +1,144 @@
"""체결 추적 on-demand 데몬 entry point.
알바(handler.submit_with_pin)가 fork 한 자식 프로세스에서 실행됨.
수명:
1. PID 파일 atomic 작성 (이미 살아있는 데몬 있으면 즉시 종료)
2. 큐 파일 → _FillWatcher._tracked 동기화
3. kt00007 폴링 → 체결/거절/타임아웃 알림 → 추적 종료된 ord_no 큐에서 제거
4. 큐 비면 PID 파일 삭제 + sys.exit(0)
5. 다음 매매 시 알바가 다시 fork
CLI 직접 호출은 하지 않음. 운영은 알바의 ensure_daemon_running() 만.
"""
from __future__ import annotations
import fcntl
import json
import os
import signal
import sys
import time
import traceback
from contextlib import contextmanager
from pathlib import Path
# 패키지 import 경로 보장 — 알바가 cwd=scripts/ 로 띄움
_SCRIPTS_DIR = Path(__file__).resolve().parent.parent
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from orders import fill_watcher # noqa: E402
LOG_FILE = fill_watcher._STATE_DIR / 'fill_watcher.log'
PID_LOCK = fill_watcher._STATE_DIR / 'fill_watcher.pid.lock'
_shutdown = False
def _log(msg: str) -> None:
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
ts = time.strftime('%Y-%m-%dT%H:%M:%S%z', time.localtime())
try:
with LOG_FILE.open('a', encoding='utf-8') as f:
f.write(f'[{ts}] {msg}\n')
except OSError:
pass
@contextmanager
def _pid_lock():
PID_LOCK.parent.mkdir(parents=True, exist_ok=True)
fp = open(PID_LOCK, 'w')
try:
fcntl.flock(fp, fcntl.LOCK_EX)
yield
finally:
try:
fcntl.flock(fp, fcntl.LOCK_UN)
finally:
fp.close()
def _claim_pid() -> bool:
"""PID 파일 atomic 작성. 이미 살아있는 데몬 있으면 False."""
with _pid_lock():
if fill_watcher.is_daemon_alive():
return False
fill_watcher.PID_FILE.parent.mkdir(parents=True, exist_ok=True)
fill_watcher.PID_FILE.write_text(str(os.getpid()), encoding='utf-8')
return True
def _release_pid() -> None:
try:
if fill_watcher.PID_FILE.exists():
try:
pid = int(fill_watcher.PID_FILE.read_text().strip())
except (ValueError, OSError):
pid = -1
if pid == os.getpid():
fill_watcher.PID_FILE.unlink()
except OSError:
pass
def _handle_sigterm(signum, frame):
global _shutdown
_shutdown = True
def _build_telegram_sender():
# 순환 import 방지 — 데몬에서만 import
from orders import handler
return lambda msg: handler.send_telegram(msg, parse_mode=None)
def _build_kiwoom_fetcher():
import kiwoom_client as kc
return lambda account: kc.get_order_executions(account)
def _build_kiwoom_open_orders_fetcher():
import kiwoom_client as kc
return lambda account: kc.get_open_orders(account)
def main() -> int:
if not _claim_pid():
_log('skip — daemon already running')
return 0
signal.signal(signal.SIGTERM, _handle_sigterm)
signal.signal(signal.SIGINT, _handle_sigterm)
fill_watcher.configure(
send_func=_build_telegram_sender(),
fetch_executions=_build_kiwoom_fetcher(),
fetch_open_orders=_build_kiwoom_open_orders_fetcher(),
)
_log(f'started pid={os.getpid()}')
try:
while not _shutdown:
queue = fill_watcher.read_queue()
if not queue:
_log('queue empty — exiting')
return 0
fill_watcher._watcher.sync_from_queue(queue)
try:
fill_watcher._watcher._poll_once()
except Exception:
_log('poll error:\n' + traceback.format_exc())
# 추적 종료된 entry 큐에서 제거
fill_watcher.persist_queue(fill_watcher._watcher.snapshot_entries())
time.sleep(fill_watcher._watcher._next_sleep_seconds())
_log('shutdown signal — exiting')
return 0
finally:
_release_pid()
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,549 @@
"""
주문 검증 가드 (순수 함수).
키움 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()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,263 @@
"""
키움 REST API 주문 호출 — 매수 kt10000 / 매도 kt10001 / 정정 kt10002 / 취소 kt10003.
* dry-run default. dry_run=False 명시 시에만 실주문.
* 진입점 첫 줄에서 sidecar.guard_or_raise() 호출 강제.
* 자동 재시도 금지 — 네트워크 에러 시 한 번만 시도하고 사람이 ord_no 조회로 재판단.
* 멱등성: 동일 (계좌·종목·side·수량·가격) 60초 윈도우 해시로 중복 차단.
"""
from __future__ import annotations
import json
import sys
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 ledger, sidecar
LIMITS_FILE = Path(__file__).resolve().parent / 'limits.json'
TR_BUY = 'kt10000'
TR_SELL = 'kt10001'
TR_MODIFY = 'kt10002'
TR_CANCEL = 'kt10003'
# 매매구분 코드
TRDE_TP_LIMIT = '0' # 보통가 (지정가)
TRDE_TP_MARKET = '3' # 시장가
# 거래소 코드 (라우팅 suffix → API 거래소 구분)
_EXCHANGE_BY_SUFFIX = {
'_AL': 'SOR',
'_NX': 'NXT',
'': 'KRX',
}
def _limits() -> dict:
return json.loads(LIMITS_FILE.read_text(encoding='utf-8'))
def _exchange_for(suffix: str) -> str:
if suffix not in _EXCHANGE_BY_SUFFIX:
raise ValueError(f'unknown routing suffix: {suffix!r}')
return _EXCHANGE_BY_SUFFIX[suffix]
def submit(account_label: str, side: str, symbol: str, qty: int,
price: Optional[int], order_type: str, routing_suffix: str,
dry_run: bool = True, card_id: Optional[str] = None) -> dict:
sidecar.guard_or_raise()
if side not in ('BUY', 'SELL'):
raise ValueError(f'invalid side: {side}')
if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT'):
raise ValueError(f'invalid order_type: {order_type}')
if order_type in ('LIMIT', 'AGGRESSIVE_LIMIT') and not price:
raise ValueError(f'{order_type} requires price')
tr_id = TR_BUY if side == 'BUY' else TR_SELL
exchange = _exchange_for(routing_suffix)
is_market = order_type == 'MARKET'
body = {
'dmst_stex_tp': exchange,
'stk_cd': symbol,
'ord_qty': str(qty),
'ord_uv': '' if is_market else str(price),
'trde_tp': TRDE_TP_MARKET if is_market else TRDE_TP_LIMIT,
'cond_uv': '',
}
payload = {
'card_id': card_id,
'account': account_label,
'side': side,
'symbol': symbol,
'qty': qty,
'price': price,
'order_type': order_type,
'routing_suffix': routing_suffix,
'exchange': exchange,
'tr_id': tr_id,
'dry_run': dry_run,
'idem_hash': ledger.idempotency_hash(account_label, symbol, side, qty, price or 0),
}
if dry_run:
ledger.append('dryrun', dict(payload, body=body))
return {'ok': True, 'dry_run': True, 'payload': payload, 'body': body}
if ledger.find_recent_idempotency(payload['idem_hash']):
ledger.append('rejected', dict(payload, reason='IDEMPOTENCY_DUP'))
return {'ok': False, 'reason': 'IDEMPOTENCY_DUP', 'payload': payload}
try:
url = kc.base_url() + '/api/dostk/ordr'
resp = kc._http_post_json(url, body, kc.auth_headers(account_label, tr_id=tr_id))
if resp.get('return_code', 0) != 0:
msg = str(resp.get('return_msg') or '')
if '8005' in msg or 'Token이 유효하지 않습니다' in msg:
kc.issue_token(account_label, force=True)
resp = kc._http_post_json(url, body, kc.auth_headers(account_label, tr_id=tr_id))
except Exception as e:
ledger.append('failed', dict(payload, error=repr(e)))
return {'ok': False, 'reason': 'NETWORK', 'error': repr(e), 'payload': payload}
# 키움 kt10000/kt10001 명세 응답 필드: ord_no, dmst_stex_tp, return_code, return_msg
# (resp가 dict 아닌 경우는 위 try의 .get 호출에서 AttributeError로 NETWORK 분기됨)
return_code = resp.get('return_code')
if return_code != 0:
ledger.append('rejected', dict(payload, response=resp))
return {'ok': False, 'reason': 'BROKER_REJECT', 'response': resp, 'payload': payload}
ord_no = resp.get('ord_no', '')
ledger.append('submitted', dict(payload, ord_no=ord_no,
response_summary={'return_code': return_code,
'return_msg': resp.get('return_msg', '')}))
return {'ok': True, 'ord_no': ord_no, 'response': resp, 'payload': payload}
def _post_order_tr(tr_id: str, account_label: str, body: dict) -> dict:
"""주문 ordr endpoint POST + 토큰 만료 시 1회 재시도. 응답 raw dict 반환."""
url = kc.base_url() + '/api/dostk/ordr'
resp = kc._http_post_json(url, body, kc.auth_headers(account_label, tr_id=tr_id))
if resp.get('return_code', 0) != 0:
msg = str(resp.get('return_msg') or '')
if '8005' in msg or 'Token이 유효하지 않습니다' in msg:
kc.issue_token(account_label, force=True)
resp = kc._http_post_json(url, body, kc.auth_headers(account_label, tr_id=tr_id))
return resp
def cancel_order(account_label: str, orig_ord_no: str, symbol: str,
cancel_qty: int = 0, routing_suffix: str = '',
dry_run: bool = True, card_id: Optional[str] = None) -> dict:
"""미체결 주문 취소 (kt10003).
cancel_qty=0 → 잔량 전부 취소 (키움 명세: '0' 입력시 잔량 전부 취소).
routing_suffix 는 원주문의 거래소와 동일해야 함 — 호출자가 ka10075 응답의
routing_suffix 그대로 전달하는 게 안전.
"""
sidecar.guard_or_raise()
if not orig_ord_no:
raise ValueError('orig_ord_no required')
if not symbol:
raise ValueError('symbol required')
if cancel_qty < 0:
raise ValueError(f'cancel_qty must be >= 0 (got {cancel_qty})')
exchange = _exchange_for(routing_suffix)
body = {
'dmst_stex_tp': exchange,
'orig_ord_no': str(orig_ord_no),
'stk_cd': symbol,
'cncl_qty': str(cancel_qty),
}
payload = {
'card_id': card_id,
'account': account_label,
'symbol': symbol,
'orig_ord_no': orig_ord_no,
'cancel_qty': cancel_qty,
'routing_suffix': routing_suffix,
'exchange': exchange,
'tr_id': TR_CANCEL,
'dry_run': dry_run,
}
if dry_run:
ledger.append('dryrun', dict(payload, body=body, kind='cancel'))
return {'ok': True, 'dry_run': True, 'payload': payload, 'body': body}
try:
resp = _post_order_tr(TR_CANCEL, account_label, body)
except Exception as e:
ledger.append('failed', dict(payload, error=repr(e), kind='cancel'))
return {'ok': False, 'reason': 'NETWORK', 'error': repr(e), 'payload': payload}
return_code = resp.get('return_code')
if return_code != 0:
ledger.append('cancel_rejected', dict(payload, response=resp))
return {'ok': False, 'reason': 'BROKER_REJECT', 'response': resp, 'payload': payload}
new_ord_no = resp.get('ord_no', '')
ledger.append('cancel_submitted', dict(payload, new_ord_no=new_ord_no,
response_summary={'return_code': return_code,
'return_msg': resp.get('return_msg', '')}))
return {'ok': True, 'new_ord_no': new_ord_no, 'response': resp, 'payload': payload}
def modify_order(account_label: str, orig_ord_no: str, symbol: str,
modify_qty: int, modify_price: int,
routing_suffix: str = '',
dry_run: bool = True, card_id: Optional[str] = None) -> dict:
"""미체결 주문 정정 (kt10002).
modify_qty / modify_price 둘 다 필수 — 키움 명세상 mdfy_qty/mdfy_uv 모두 Required.
수량만 바꿀 땐 기존 가격, 가격만 바꿀 땐 기존 수량을 그대로 전달.
시장가 주문은 정정 불가 (mdfy_uv 가 가격이라 0 불가) — 호출 전에 차단.
routing_suffix 는 원주문의 거래소와 동일해야 함.
"""
sidecar.guard_or_raise()
if not orig_ord_no:
raise ValueError('orig_ord_no required')
if not symbol:
raise ValueError('symbol required')
if modify_qty <= 0:
raise ValueError(f'modify_qty must be > 0 (got {modify_qty})')
if modify_price <= 0:
raise ValueError(f'modify_price must be > 0 — 시장가 주문은 정정 불가, 취소 후 신규 발주')
exchange = _exchange_for(routing_suffix)
body = {
'dmst_stex_tp': exchange,
'orig_ord_no': str(orig_ord_no),
'stk_cd': symbol,
'mdfy_qty': str(modify_qty),
'mdfy_uv': str(modify_price),
'mdfy_cond_uv': '',
}
payload = {
'card_id': card_id,
'account': account_label,
'symbol': symbol,
'orig_ord_no': orig_ord_no,
'modify_qty': modify_qty,
'modify_price': modify_price,
'routing_suffix': routing_suffix,
'exchange': exchange,
'tr_id': TR_MODIFY,
'dry_run': dry_run,
}
if dry_run:
ledger.append('dryrun', dict(payload, body=body, kind='modify'))
return {'ok': True, 'dry_run': True, 'payload': payload, 'body': body}
try:
resp = _post_order_tr(TR_MODIFY, account_label, body)
except Exception as e:
ledger.append('failed', dict(payload, error=repr(e), kind='modify'))
return {'ok': False, 'reason': 'NETWORK', 'error': repr(e), 'payload': payload}
return_code = resp.get('return_code')
if return_code != 0:
ledger.append('modify_rejected', dict(payload, response=resp))
return {'ok': False, 'reason': 'BROKER_REJECT', 'response': resp, 'payload': payload}
new_ord_no = resp.get('ord_no', '')
ledger.append('modify_submitted', dict(payload, new_ord_no=new_ord_no,
response_summary={'return_code': return_code,
'return_msg': resp.get('return_msg', '')}))
return {'ok': True, 'new_ord_no': new_ord_no, 'response': resp, 'payload': payload}
@@ -0,0 +1,153 @@
"""
Append-only 주문 ledger.
state/order_log.jsonl 에 매매 모든 라이프사이클(card_issued, pin_issued, approved,
rejected, expired, canceled, submitted, filled, partial, failed, dryrun)을 KST 타임스탬프와
함께 한 줄씩 기록한다. 토큰·시크릿 평문 기록을 차단한다.
"""
from __future__ import annotations
import hashlib
import json
import os
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Optional
WORKSPACE_ROOT = Path(__file__).resolve().parent.parent.parent
LIMITS_FILE = Path(__file__).resolve().parent / 'limits.json'
KST = timezone(timedelta(hours=9))
def _load_limits() -> dict:
return json.loads(LIMITS_FILE.read_text(encoding='utf-8'))
def _ledger_path() -> Path:
return WORKSPACE_ROOT / _load_limits()['ledger']['log_file']
def _now_iso() -> str:
return datetime.now(KST).isoformat()
def _mask(value: str, keep: int = 4) -> str:
if not value:
return '***'
s = str(value)
if len(s) <= keep:
return '***'
return s[:keep] + '*' * (len(s) - keep)
def _scrub_secrets(payload: dict) -> dict:
out = {}
for k, v in payload.items():
kl = k.lower()
if any(s in kl for s in ('token', 'secret', 'pin', 'password', 'appkey', 'appsecret')):
out[k] = _mask(v, 2) if v is not None else None
elif isinstance(v, dict):
out[k] = _scrub_secrets(v)
elif isinstance(v, list):
out[k] = [_scrub_secrets(x) if isinstance(x, dict) else x for x in v]
else:
out[k] = v
return out
def append(event: str, payload: dict) -> None:
limits = _load_limits()
allowed = set(limits['ledger']['events'])
if event not in allowed:
raise ValueError(f'unknown ledger event: {event}')
safe_payload = _scrub_secrets(payload) if limits['ledger']['mask_token_in_logs'] else payload
record = {'ts': _now_iso(), 'event': event, 'payload': safe_payload}
path = _ledger_path()
path.parent.mkdir(parents=True, exist_ok=True)
with path.open('a', encoding='utf-8') as f:
f.write(json.dumps(record, ensure_ascii=False) + '\n')
try:
os.chmod(path, 0o600)
except OSError:
pass
def idempotency_hash(account: str, symbol: str, side: str, qty: int, price: int | str) -> str:
"""동일 (계좌·종목·방향·수량·가격) 60초 윈도우 중복 주문 차단용 해시."""
minute = int(datetime.now(KST).timestamp() // 60)
key = f'{account}|{symbol}|{side}|{qty}|{price}|{minute}'
return hashlib.sha256(key.encode('utf-8')).hexdigest()[:16]
def find_recent_idempotency(h: str, within_seconds: int = 90) -> Optional[dict]:
path = _ledger_path()
if not path.exists():
return None
cutoff = datetime.now(KST) - timedelta(seconds=within_seconds)
with path.open('r', encoding='utf-8') as f:
lines = f.readlines()
for line in reversed(lines):
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
ts = datetime.fromisoformat(rec['ts'])
except (ValueError, KeyError):
continue
if ts < cutoff:
return None
if rec.get('payload', {}).get('idem_hash') == h:
return rec
return None
def last_event_for_symbol(account: str, symbol: str, events: tuple[str, ...] = ('submitted', 'filled', 'partial')) -> Optional[dict]:
"""동일 종목 딜레이 계산을 위해 가장 최근 '실주문 접수 이후' 매칭 이벤트를 반환.
rejected/canceled/expired/failed 는 키움 접수 전 실패이므로 쿨다운 대상 아님.
"""
path = _ledger_path()
if not path.exists():
return None
with path.open('r', encoding='utf-8') as f:
lines = f.readlines()
for line in reversed(lines):
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except ValueError:
continue
if rec.get('event') not in events:
continue
p = rec.get('payload', {})
if p.get('account') == account and p.get('symbol') == symbol:
return rec
return None
def last_terminal_event(events: tuple[str, ...] = ('submitted', 'filled', 'partial')) -> Optional[dict]:
"""전체 거래 간 60초 딜레이 계산을 위해 가장 최근 '실주문 접수 이후' 이벤트를 반환.
rejected/canceled/expired/failed 는 키움 접수 전 실패이므로 쿨다운 대상 아님.
"""
path = _ledger_path()
if not path.exists():
return None
with path.open('r', encoding='utf-8') as f:
lines = f.readlines()
for line in reversed(lines):
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except ValueError:
continue
if rec.get('event') in events:
return rec
return None
@@ -0,0 +1,102 @@
{
"_meta": {
"schema_version": 1,
"last_updated": "2026-05-06",
"note": "변경 시 별도 커밋. guards 단위테스트가 이 파일을 검증. 손으로 풀지 말고 항상 이 파일을 통해 조정."
},
"enabled": false,
"kill_switch_file": "state/orders_disabled",
"accounts_whitelist": ["일반", "ISA", "가희_일반", "가희_ISA"],
"owner_accounts": ["일반", "ISA"],
"spouse_accounts": ["가희_일반", "가희_ISA"],
"limits": {
"single_order_max_won": null,
"daily_total_max_won": null,
"balance_ratio_max": null,
"price_band_pct_soft": null,
"price_band_pct_hard": 30
},
"delays": {
"between_orders_seconds": 5
},
"pin": {
"owner_length": 4,
"owner_charset": "digits",
"spouse_length": 8,
"spouse_charset": "alnum_no_confusing",
"alnum_no_confusing_chars": "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789",
"expiry_seconds": 120,
"max_attempts": 1
},
"trading_hours": {
"tz": "Asia/Seoul",
"krx_regular_start": "09:00:30",
"krx_regular_end": "15:20:00",
"krx_closing_auction_start": "15:20:00",
"krx_closing_auction_end": "15:30:00",
"nxt_pre_start": "08:00:00",
"nxt_pre_end": "09:00:00",
"nxt_after_start": "15:30:00",
"nxt_after_end": "20:00:00",
"block_outside": true,
"holiday_state_file": "state/market_holidays.json"
},
"routing": {
"default": "AL",
"force_options": ["AL", "NX", "KRX"],
"suffix_AL": "_AL",
"suffix_NX": "_NX",
"suffix_KRX": ""
},
"market_order": {
"allowed": true,
"natural_language_market": ["시장가"],
"natural_language_aggressive_limit": ["지금 바로", "즉시", "빨리", "당장"],
"aggressive_limit_ticks": 1,
"show_orderbook_depth": 3,
"estimate_slippage": true
},
"guards": {
"block_market_holidays": true,
"block_trading_halt": true,
"block_vi": true,
"block_outside_trading_hours": true,
"require_balance_check_for_buy": true,
"require_position_check_for_sell": true,
"block_market_order_when_orderbook_thin": false
},
"card": {
"highlight_fields": ["side", "account", "symbol_name", "price"],
"highlight_marker": "▶",
"highlight_bold_markdown": true,
"buy_emoji": "🛒",
"sell_emoji": "💰",
"warning_emoji": "⚠️",
"show_orderbook_for_market": true,
"orderbook_depth": 3,
"show_balance_ratio": true
},
"ledger": {
"log_file": "state/order_log.jsonl",
"events": ["card_issued", "pin_issued", "amended", "approved", "rejected", "expired", "canceled", "submitted", "filled", "partial", "failed", "dryrun", "cancel_submitted", "cancel_rejected", "cancel_confirmed", "cancel_unconfirmed_timeout", "modify_submitted", "modify_rejected"],
"mask_token_in_logs": true
},
"telegram": {
"dm_only": true,
"chat_id_whitelist_env": "OPENCLAW_OWNER_CHAT_ID",
"block_groups": true,
"block_when_agent_env_set": "OPENCLAW_AGENT"
}
}
@@ -0,0 +1,213 @@
"""
PIN 발급·검증·만료.
본인 계좌(일반·ISA): 숫자 4자리.
가희 계좌(가희_일반·가희_ISA): 영숫자 8자리, 혼동 글자(0/O/o/1/l/I) 제외 55자.
120초 만료, 1회용, 1회 시도.
동시 활성 카드는 1개로 제한.
상태는 파일(state/active_card.json)에 저장 — CLI 가 매번 새 프로세스로 호출돼도
같은 활성 카드를 본다. 동시성은 fcntl flock 으로 직렬화.
"""
from __future__ import annotations
import fcntl
import json
import os
import secrets
import time
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Optional
LIMITS_FILE = Path(__file__).resolve().parent / 'limits.json'
WORKSPACE_ROOT = Path(__file__).resolve().parent.parent.parent
DEFAULT_STATE_FILE = WORKSPACE_ROOT / 'state' / 'active_card.json'
def _limits() -> dict:
return json.loads(LIMITS_FILE.read_text(encoding='utf-8'))
def _account_groups() -> tuple[set[str], set[str]]:
d = _limits()
return set(d['owner_accounts']), set(d['spouse_accounts'])
def issue_pin(account_label: str) -> str:
cfg = _limits()['pin']
owners, spouses = _account_groups()
if account_label in spouses:
chars = cfg['alnum_no_confusing_chars']
length = cfg['spouse_length']
elif account_label in owners:
chars = '0123456789'
length = cfg['owner_length']
else:
raise ValueError(f'unknown account label: {account_label}')
return ''.join(secrets.choice(chars) for _ in range(length))
def issue_card_id() -> str:
return ''.join(secrets.choice('ABCDEFGHJKLMNPQRSTUVWXYZ23456789') for _ in range(4))
@dataclass
class PendingCard:
card_id: str
pin: str
account_label: str
issued_at: float
expiry_seconds: int
payload: dict = field(default_factory=dict)
attempts: int = 0
consumed: bool = False
def is_expired(self) -> bool:
return (time.time() - self.issued_at) >= self.expiry_seconds
class _FileLock:
def __init__(self, path: Path):
self.path = path
self._fp = None
def __enter__(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
self._fp = open(self.path, 'w')
fcntl.flock(self._fp, fcntl.LOCK_EX)
return self
def __exit__(self, *args):
try:
fcntl.flock(self._fp, fcntl.LOCK_UN)
finally:
self._fp.close()
class PinStore:
"""파일 기반 PinStore. 모든 프로세스가 같은 활성 카드를 본다."""
def __init__(self, state_file: Optional[Path] = None):
self._state_file = Path(state_file) if state_file else DEFAULT_STATE_FILE
self._lock_file = self._state_file.with_suffix(self._state_file.suffix + '.lock')
def _read_locked(self) -> Optional[PendingCard]:
if not self._state_file.exists():
return None
try:
data = json.loads(self._state_file.read_text(encoding='utf-8'))
except (OSError, ValueError):
return None
try:
return PendingCard(**data)
except TypeError:
return None
def _write_locked(self, card: PendingCard) -> None:
self._state_file.parent.mkdir(parents=True, exist_ok=True)
tmp = self._state_file.with_suffix(self._state_file.suffix + '.tmp')
tmp.write_text(json.dumps(asdict(card), ensure_ascii=False), encoding='utf-8')
try:
os.chmod(tmp, 0o600)
except OSError:
pass
os.replace(tmp, self._state_file)
def _clear_locked(self) -> None:
if self._state_file.exists():
try:
self._state_file.unlink()
except OSError:
pass
def issue(self, account_label: str, payload: Optional[dict] = None) -> PendingCard:
cfg = _limits()['pin']
with _FileLock(self._lock_file):
existing = self._read_locked()
if existing is not None and not existing.is_expired() and not existing.consumed:
raise RuntimeError('이전 카드가 아직 활성. 처리 후 다시 시도.')
card = PendingCard(
card_id=issue_card_id(),
pin=issue_pin(account_label),
account_label=account_label,
issued_at=time.time(),
expiry_seconds=cfg['expiry_seconds'],
payload=payload or {},
)
self._write_locked(card)
return card
def verify(self, pin_input: str) -> tuple[bool, str, Optional[PendingCard]]:
cfg = _limits()['pin']
with _FileLock(self._lock_file):
card = self._read_locked()
if card is None:
return False, '활성 카드 없음', None
if card.is_expired():
self._clear_locked()
return False, '만료', card
if card.consumed:
self._clear_locked()
return False, '이미 사용됨', card
card.attempts += 1
if card.pin != (pin_input or '').strip():
if card.attempts >= cfg['max_attempts']:
self._clear_locked()
return False, 'PIN 불일치, 카드 무효', card
self._write_locked(card)
return False, 'PIN 불일치', card
card.consumed = True
self._clear_locked()
return True, 'OK', card
def cancel(self) -> Optional[PendingCard]:
with _FileLock(self._lock_file):
card = self._read_locked()
if card is None:
return None
self._clear_locked()
return card
def amend(self, payload: dict, account_label: Optional[str] = None) -> Optional[PendingCard]:
"""활성 카드의 페이로드 갱신 + PIN 재발급 + 만료 리셋. card_id 는 유지.
account_label 명시 시 새 계좌 기준으로 PIN 재발급(본인 4자리 / 가희 8자리).
활성 카드가 없거나 만료/소비 상태면 None 반환 (호출자가 NO_ACTIVE_CARD 거부).
"""
cfg = _limits()['pin']
with _FileLock(self._lock_file):
existing = self._read_locked()
if existing is None or existing.is_expired() or existing.consumed:
if existing is not None and (existing.is_expired() or existing.consumed):
self._clear_locked()
return None
new_account = account_label or existing.account_label
new_card = PendingCard(
card_id=existing.card_id,
pin=issue_pin(new_account),
account_label=new_account,
issued_at=time.time(),
expiry_seconds=cfg['expiry_seconds'],
payload=payload,
attempts=0,
)
self._write_locked(new_card)
return new_card
def peek(self) -> Optional[PendingCard]:
with _FileLock(self._lock_file):
return self._read_locked()
def sweep_expired(self) -> Optional[PendingCard]:
"""만료된 카드를 정리하고 정리된 카드를 반환 (만료 알림 송신용)."""
with _FileLock(self._lock_file):
card = self._read_locked()
if card is None:
return None
if card.is_expired() and not card.consumed:
self._clear_locked()
return card
return None
@@ -0,0 +1,106 @@
"""
Kill switch.
state/orders_disabled 파일 존재 시 모든 진입점에서 거부.
LLM 에이전트(클로·레이) 세션에서 호출 시 즉시 차단 (OPENCLAW_AGENT 환경변수).
모든 매매 진입점 함수 첫 줄에서 guard_or_raise() 호출이 강제이다.
"""
from __future__ import annotations
import json
import os
import sys
from datetime import datetime, timezone, timedelta
from pathlib import Path
WORKSPACE_ROOT = Path(__file__).resolve().parent.parent.parent
LIMITS_FILE = Path(__file__).resolve().parent / 'limits.json'
KST = timezone(timedelta(hours=9))
class SidecarBlocked(RuntimeError):
pass
def _limits() -> dict:
return json.loads(LIMITS_FILE.read_text(encoding='utf-8'))
def _kill_switch_path() -> Path:
return WORKSPACE_ROOT / _limits()['kill_switch_file']
def _agent_env_blocked() -> bool:
var = _limits().get('telegram', {}).get('block_when_agent_env_set')
return bool(var and os.getenv(var))
def is_disabled() -> bool:
return _kill_switch_path().exists()
def guard_or_raise() -> None:
if _agent_env_blocked():
raise SidecarBlocked('LLM agent session cannot invoke order module (OPENCLAW_AGENT env set)')
if is_disabled():
raise SidecarBlocked('Order module is disabled (sidecar ON)')
def disable(reason: str = 'manual') -> Path:
p = _kill_switch_path()
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(
json.dumps({'disabled_at': datetime.now(KST).isoformat(), 'reason': reason}, ensure_ascii=False, indent=2),
encoding='utf-8',
)
try:
os.chmod(p, 0o600)
except OSError:
pass
return p
def enable() -> bool:
p = _kill_switch_path()
if not p.exists():
return False
p.unlink()
return True
def status() -> dict:
p = _kill_switch_path()
if not p.exists():
return {'disabled': False, 'path': str(p), 'agent_env_blocked': _agent_env_blocked()}
try:
meta = json.loads(p.read_text(encoding='utf-8'))
except (OSError, ValueError):
meta = {}
meta.update({'disabled': True, 'path': str(p), 'agent_env_blocked': _agent_env_blocked()})
return meta
def _cli(argv: list[str]) -> int:
if not argv or argv[0] == 'status':
print(json.dumps(status(), ensure_ascii=False, indent=2))
return 0
cmd = argv[0]
if cmd == 'disable':
reason = ' '.join(argv[1:]) or 'manual'
p = disable(reason)
print(f'sidecar disabled: {p}')
return 0
if cmd == 'enable':
ok = enable()
print('sidecar enabled' if ok else 'sidecar already enabled (no file)')
return 0
print(f'unknown command: {cmd}', file=sys.stderr)
print('usage: sidecar.py {status | disable [reason] | enable}', file=sys.stderr)
return 2
if __name__ == '__main__':
sys.exit(_cli(sys.argv[1:]))
@@ -0,0 +1,446 @@
"""fill_watcher 회귀 테스트.
- _FillWatcher._poll_once: 부분/완전체결, 사후거절, 타임아웃, 중복 방지, 계좌 묶음 fetch.
- 큐 파일 IO: append, read, persist, 빈 큐 처리.
- watch(): 큐 append + 데몬 ensure (subprocess.Popen mock).
- is_daemon_alive: PID 파일 stale 검출.
"""
from __future__ import annotations
import os
import time
import unittest
from unittest import mock
from orders import fill_watcher
from orders.fill_watcher import Tracked
def _row(ord_no='100001', cntr_qty=0, cntr_uv=0, mdfy_cncl=''):
return {
'ord_no': ord_no,
'ord_tm': '10:00:00',
'code': '005930',
'name': '삼성전자',
'side': 'BUY',
'order_qty': 10,
'cntr_qty': cntr_qty,
'cntr_uv': cntr_uv,
'ord_uv': 75000,
'order_type': '지정가',
'exchange': 'KRX',
'comm_src': 'REST API',
'mdfy_cncl': mdfy_cncl,
}
class FillWatcherPollTests(unittest.TestCase):
"""_FillWatcher._poll_once 직접 호출로 격리 (시간 의존 X)."""
def setUp(self):
fill_watcher._reset_for_test()
self.sent = []
self.fetch = mock.MagicMock(return_value=[])
self.fetch_open = mock.MagicMock(return_value=[])
fill_watcher.configure(send_func=lambda msg: self.sent.append(msg) or True,
fetch_executions=self.fetch,
fetch_open_orders=self.fetch_open)
def _track(self, ord_no='100001', order_qty=10):
w = fill_watcher._watcher
with w._lock:
w._tracked[ord_no] = Tracked(
ord_no=ord_no, account='일반', side='BUY', symbol='005930',
symbol_name='삼성전자', order_qty=order_qty, price=75000,
order_type='LIMIT', card_id='A7K3', started_at=time.time(),
)
def test_no_row_no_alert(self):
self._track()
self.fetch.return_value = []
fill_watcher._watcher._poll_once()
self.assertEqual(self.sent, [])
self.assertIn('100001', fill_watcher._peek_for_test())
def test_full_fill_alerts_and_removes(self):
self._track()
self.fetch.return_value = [_row(cntr_qty=10, cntr_uv=75050)]
fill_watcher._watcher._poll_once()
self.assertEqual(len(self.sent), 1)
self.assertIn('체결', self.sent[0])
self.assertNotIn('100001', fill_watcher._peek_for_test())
def test_partial_fill_alerts_and_keeps(self):
self._track()
self.fetch.return_value = [_row(cntr_qty=3, cntr_uv=75050)]
fill_watcher._watcher._poll_once()
self.assertIn('부분체결', self.sent[0])
self.assertIn('100001', fill_watcher._peek_for_test())
self.assertEqual(fill_watcher._peek_for_test()['100001'].last_cntr_qty, 3)
def test_partial_then_full(self):
self._track()
self.fetch.return_value = [_row(cntr_qty=3, cntr_uv=75050)]
fill_watcher._watcher._poll_once()
self.fetch.return_value = [_row(cntr_qty=10, cntr_uv=75080)]
fill_watcher._watcher._poll_once()
self.assertEqual(len(self.sent), 2)
self.assertIn('부분체결', self.sent[0])
self.assertIn('체결', self.sent[1])
self.assertNotIn('100001', fill_watcher._peek_for_test())
def test_post_reject(self):
self._track()
self.fetch.return_value = [_row(cntr_qty=0, mdfy_cncl='취소')]
fill_watcher._watcher._poll_once()
self.assertIn('사후거절', self.sent[0])
self.assertNotIn('100001', fill_watcher._peek_for_test())
def test_timeout(self):
self._track()
with fill_watcher._watcher._lock:
fill_watcher._watcher._tracked['100001'].started_at = time.time() - 1801
self.fetch.return_value = []
fill_watcher._watcher._poll_once()
self.assertIn('미체결', self.sent[0])
self.assertNotIn('100001', fill_watcher._peek_for_test())
def test_no_duplicate_alert_on_same_state(self):
self._track()
self.fetch.return_value = [_row(cntr_qty=3, cntr_uv=75050)]
fill_watcher._watcher._poll_once()
fill_watcher._watcher._poll_once()
fill_watcher._watcher._poll_once()
self.assertEqual(len(self.sent), 1)
def test_one_fetch_per_account(self):
self._track(ord_no='100001')
self._track(ord_no='100002')
self.fetch.return_value = [
_row(ord_no='100001', cntr_qty=10, cntr_uv=75100),
_row(ord_no='100002', cntr_qty=5, cntr_uv=75100),
]
fill_watcher._watcher._poll_once()
self.assertEqual(self.fetch.call_count, 1)
self.assertEqual(len(self.sent), 2)
class CancelWatcherTests(unittest.TestCase):
"""cancel kind 회귀 — ka10075 폴링으로 원주문이 사라지면 확정."""
def setUp(self):
fill_watcher._reset_for_test()
self.sent = []
self.fetch_exec = mock.MagicMock(return_value=[])
self.fetch_open = mock.MagicMock(return_value=[])
fill_watcher.configure(send_func=lambda msg: self.sent.append(msg) or True,
fetch_executions=self.fetch_exec,
fetch_open_orders=self.fetch_open)
def _track_cancel(self, new_ord_no='200001', orig_ord_no='100001',
cancel_qty=10, started_at=None):
w = fill_watcher._watcher
with w._lock:
w._tracked[new_ord_no] = Tracked(
ord_no=new_ord_no, account='일반', side='BUY', symbol='005930',
symbol_name='삼성전자', order_qty=cancel_qty, price=None,
order_type='CANCEL', card_id='', started_at=started_at or time.time(),
kind='cancel', orig_ord_no=orig_ord_no,
)
def _track_fill(self, ord_no='100001', order_qty=10):
w = fill_watcher._watcher
with w._lock:
w._tracked[ord_no] = Tracked(
ord_no=ord_no, account='일반', side='BUY', symbol='005930',
symbol_name='삼성전자', order_qty=order_qty, price=75000,
order_type='LIMIT', card_id='A7K3', started_at=time.time(),
)
def test_cancel_confirmed_when_orig_disappears(self):
self._track_cancel()
self.fetch_open.return_value = [] # 원주문 사라짐
fill_watcher._watcher._poll_once()
self.assertEqual(len(self.sent), 1)
self.assertIn('취소 확인', self.sent[0])
self.assertNotIn('200001', fill_watcher._peek_for_test())
def test_cancel_confirmed_also_stops_original_fill_watch(self):
"""취소 확정 시 원주문 체결 감시도 같이 끝낸다."""
self._track_fill(ord_no='100001')
self._track_cancel(new_ord_no='200001', orig_ord_no='100001')
self.fetch_open.return_value = [] # 원주문 사라짐 → 취소 확정
self.fetch_exec.return_value = [] # kt00007 에 취소 상태가 안 잡혀도
fill_watcher._watcher._poll_once()
tracked = fill_watcher._peek_for_test()
self.assertNotIn('200001', tracked)
self.assertNotIn('100001', tracked)
def test_cancel_pending_when_orig_still_open(self):
self._track_cancel()
self.fetch_open.return_value = [{'ord_no': '100001'}] # 원주문 아직 미체결
fill_watcher._watcher._poll_once()
self.assertEqual(self.sent, [])
self.assertIn('200001', fill_watcher._peek_for_test())
def test_cancel_timeout_when_orig_still_open_after_30min(self):
self._track_cancel(started_at=time.time() - 1801)
self.fetch_open.return_value = [{'ord_no': '100001'}]
fill_watcher._watcher._poll_once()
self.assertEqual(len(self.sent), 1)
self.assertIn('미확인', self.sent[0])
self.assertNotIn('200001', fill_watcher._peek_for_test())
def test_cancel_fetch_error_skips_quietly(self):
self._track_cancel()
self.fetch_open.side_effect = RuntimeError('network')
fill_watcher._watcher._poll_once()
self.assertEqual(self.sent, [])
# 추적 유지 (다음 폴링으로 미룸)
self.assertIn('200001', fill_watcher._peek_for_test())
def test_executions_not_fetched_when_only_cancel_watches(self):
self._track_cancel()
self.fetch_open.return_value = [{'ord_no': '100001'}]
fill_watcher._watcher._poll_once()
self.fetch_exec.assert_not_called()
self.fetch_open.assert_called_once_with('일반')
def test_open_orders_not_fetched_when_only_fill_watches(self):
self._track_fill()
self.fetch_exec.return_value = []
fill_watcher._watcher._poll_once()
self.fetch_exec.assert_called_once_with('일반')
self.fetch_open.assert_not_called()
def test_user_cancel_suppresses_post_reject_message(self):
"""fill watch 중인 주문에 cancel watch 가 같이 걸려있으면 mdfy_cncl 떠도 사후거절 메시지 X."""
self._track_fill(ord_no='100001')
self._track_cancel(new_ord_no='200001', orig_ord_no='100001')
self.fetch_exec.return_value = [{
'ord_no': '100001', 'cntr_qty': 0, 'cntr_uv': 0, 'mdfy_cncl': '취소',
}]
self.fetch_open.return_value = [{'ord_no': '100001'}] # 아직 미체결 목록에 있음
fill_watcher._watcher._poll_once()
# 사후거절 메시지 억제 — fill watch 만 해제, 취소 확정은 cancel watch 가 별도로 보냄
self.assertEqual(self.sent, [])
self.assertNotIn('100001', fill_watcher._peek_for_test())
self.assertIn('200001', fill_watcher._peek_for_test())
def test_broker_post_reject_still_alerts_when_no_cancel_watch(self):
"""사용자 cancel 아닌 broker 사후거절은 그대로 알림."""
self._track_fill(ord_no='100001')
self.fetch_exec.return_value = [{
'ord_no': '100001', 'cntr_qty': 0, 'cntr_uv': 0, 'mdfy_cncl': '취소',
}]
fill_watcher._watcher._poll_once()
self.assertEqual(len(self.sent), 1)
self.assertIn('사후거절', self.sent[0])
class FillWatcherQueueIOTests(unittest.TestCase):
"""큐 파일 read/append/persist."""
def setUp(self):
fill_watcher._reset_for_test()
self.addCleanup(fill_watcher._reset_for_test)
def test_append_and_read(self):
e1 = {'ord_no': '100001', 'account': '일반', 'side': 'BUY',
'symbol': '005930', 'symbol_name': '삼성전자', 'order_qty': 10,
'price': 75000, 'order_type': 'LIMIT', 'card_id': 'A7K3',
'started_at': 1.0, 'last_cntr_qty': 0}
e2 = dict(e1, ord_no='100002', card_id='B2K9')
fill_watcher.append_queue_entry(e1)
fill_watcher.append_queue_entry(e2)
out = fill_watcher.read_queue()
self.assertEqual(len(out), 2)
self.assertEqual(out[0]['ord_no'], '100001')
self.assertEqual(out[1]['ord_no'], '100002')
def test_read_empty_queue(self):
self.assertEqual(fill_watcher.read_queue(), [])
def test_persist_overwrites(self):
e = {'ord_no': '100001', 'account': '일반', 'side': 'BUY',
'symbol': '005930', 'symbol_name': '삼성전자', 'order_qty': 10,
'price': 75000, 'order_type': 'LIMIT', 'card_id': 'A7K3',
'started_at': 1.0, 'last_cntr_qty': 0}
fill_watcher.append_queue_entry(e)
fill_watcher.append_queue_entry(dict(e, ord_no='100002'))
fill_watcher.persist_queue([dict(e, ord_no='100003')])
out = fill_watcher.read_queue()
self.assertEqual(len(out), 1)
self.assertEqual(out[0]['ord_no'], '100003')
def test_persist_empty_removes_file(self):
e = {'ord_no': '100001', 'account': '일반', 'side': 'BUY',
'symbol': '005930', 'symbol_name': '삼성전자', 'order_qty': 10,
'price': 75000, 'order_type': 'LIMIT', 'card_id': 'A7K3',
'started_at': 1.0, 'last_cntr_qty': 0}
fill_watcher.append_queue_entry(e)
self.assertTrue(fill_watcher.QUEUE_FILE.exists())
fill_watcher.persist_queue([])
self.assertFalse(fill_watcher.QUEUE_FILE.exists())
def test_read_skips_malformed_lines(self):
fill_watcher.QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
fill_watcher.QUEUE_FILE.write_text(
'{"ord_no": "100001", "account": "일반", "side": "BUY", '
'"symbol": "005930", "symbol_name": "삼성전자", "order_qty": 10, '
'"price": 75000, "order_type": "LIMIT", "card_id": "A7K3", '
'"started_at": 1.0, "last_cntr_qty": 0}\n'
'\n'
'NOT_JSON_GARBAGE\n',
encoding='utf-8',
)
out = fill_watcher.read_queue()
self.assertEqual(len(out), 1)
class FillWatcherWatchEntryTests(unittest.TestCase):
"""watch() = 큐 append + 데몬 ensure_running. subprocess.Popen mock."""
def setUp(self):
fill_watcher._reset_for_test()
self.addCleanup(fill_watcher._reset_for_test)
# subprocess.Popen mock — 실제 데몬 fork 막음
self.popen = mock.patch.object(fill_watcher.subprocess, 'Popen').start()
self.addCleanup(mock.patch.stopall)
def test_watch_appends_to_queue(self):
fill_watcher.watch(ord_no='100001', account='일반', side='BUY',
symbol='005930', symbol_name='삼성전자', order_qty=10,
price=75000, order_type='LIMIT', card_id='A7K3')
out = fill_watcher.read_queue()
self.assertEqual(len(out), 1)
self.assertEqual(out[0]['ord_no'], '100001')
def test_watch_starts_daemon(self):
fill_watcher.watch(ord_no='100001', account='일반', side='BUY',
symbol='005930', symbol_name='삼성전자', order_qty=10,
price=75000, order_type='LIMIT', card_id='A7K3')
self.popen.assert_called_once()
# 인자 검증 — orders.fill_watcher_daemon 모듈 호출
args = self.popen.call_args[0][0]
self.assertEqual(args[1], '-m')
self.assertEqual(args[2], 'orders.fill_watcher_daemon')
def test_watch_dedupe_same_ord_no(self):
fill_watcher.watch(ord_no='100001', account='일반', side='BUY',
symbol='005930', symbol_name='삼성전자', order_qty=10,
price=75000, order_type='LIMIT', card_id='A7K3')
fill_watcher.watch(ord_no='100001', account='일반', side='BUY',
symbol='005930', symbol_name='삼성전자', order_qty=99,
price=99999, order_type='LIMIT', card_id='B2K9')
out = fill_watcher.read_queue()
self.assertEqual(len(out), 1)
# 첫 등록값 유지
self.assertEqual(out[0]['order_qty'], 10)
self.assertEqual(out[0]['card_id'], 'A7K3')
def test_watch_empty_ord_no_ignored(self):
fill_watcher.watch(ord_no='', account='일반', side='BUY',
symbol='005930', symbol_name='삼성전자', order_qty=10,
price=75000, order_type='LIMIT', card_id='A7K3')
self.assertEqual(fill_watcher.read_queue(), [])
self.popen.assert_not_called()
def test_watch_cancel_appends_with_kind(self):
fill_watcher.watch_cancel(new_ord_no='200001', orig_ord_no='100001',
account='일반', side='BUY', symbol='005930',
symbol_name='삼성전자', cancel_qty=7)
out = fill_watcher.read_queue()
self.assertEqual(len(out), 1)
self.assertEqual(out[0]['ord_no'], '200001')
self.assertEqual(out[0]['orig_ord_no'], '100001')
self.assertEqual(out[0]['kind'], 'cancel')
self.assertEqual(out[0]['order_qty'], 7)
self.popen.assert_called_once()
def test_watch_cancel_empty_args_ignored(self):
fill_watcher.watch_cancel(new_ord_no='', orig_ord_no='100001',
account='일반', side='BUY', symbol='005930',
symbol_name='삼성전자', cancel_qty=10)
fill_watcher.watch_cancel(new_ord_no='200001', orig_ord_no='',
account='일반', side='BUY', symbol='005930',
symbol_name='삼성전자', cancel_qty=10)
self.assertEqual(fill_watcher.read_queue(), [])
self.popen.assert_not_called()
def test_watch_skips_popen_when_daemon_alive(self):
# 살아있는 데몬 시뮬레이션 — 현재 프로세스 PID 사용
fill_watcher.PID_FILE.parent.mkdir(parents=True, exist_ok=True)
fill_watcher.PID_FILE.write_text(str(os.getpid()), encoding='utf-8')
fill_watcher.watch(ord_no='100001', account='일반', side='BUY',
symbol='005930', symbol_name='삼성전자', order_qty=10,
price=75000, order_type='LIMIT', card_id='A7K3')
self.popen.assert_not_called()
self.assertEqual(len(fill_watcher.read_queue()), 1)
class FillWatcherDaemonAliveTests(unittest.TestCase):
"""is_daemon_alive — PID 파일 stale 검출."""
def setUp(self):
fill_watcher._reset_for_test()
self.addCleanup(fill_watcher._reset_for_test)
def test_no_pid_file(self):
self.assertFalse(fill_watcher.is_daemon_alive())
def test_invalid_pid_file(self):
fill_watcher.PID_FILE.parent.mkdir(parents=True, exist_ok=True)
fill_watcher.PID_FILE.write_text('not_a_number', encoding='utf-8')
self.assertFalse(fill_watcher.is_daemon_alive())
def test_stale_pid(self):
# 절대 안 쓰일 큰 PID — Pid lookup 실패
fill_watcher.PID_FILE.parent.mkdir(parents=True, exist_ok=True)
fill_watcher.PID_FILE.write_text('99999999', encoding='utf-8')
self.assertFalse(fill_watcher.is_daemon_alive())
def test_alive_pid(self):
# 현재 프로세스는 살아있음
fill_watcher.PID_FILE.parent.mkdir(parents=True, exist_ok=True)
fill_watcher.PID_FILE.write_text(str(os.getpid()), encoding='utf-8')
self.assertTrue(fill_watcher.is_daemon_alive())
class FillWatcherSyncFromQueueTests(unittest.TestCase):
"""sync_from_queue — 큐 → _tracked 양방향 동기화."""
def setUp(self):
fill_watcher._reset_for_test()
self.addCleanup(fill_watcher._reset_for_test)
def _entry(self, ord_no='100001', last_cntr=0):
return {'ord_no': ord_no, 'account': '일반', 'side': 'BUY',
'symbol': '005930', 'symbol_name': '삼성전자', 'order_qty': 10,
'price': 75000, 'order_type': 'LIMIT', 'card_id': 'A7K3',
'started_at': 1.0, 'last_cntr_qty': last_cntr}
def test_sync_adds_new_entries(self):
fill_watcher._watcher.sync_from_queue([self._entry('100001'), self._entry('100002')])
self.assertEqual(set(fill_watcher._peek_for_test().keys()), {'100001', '100002'})
def test_sync_removes_missing(self):
fill_watcher._watcher.sync_from_queue([self._entry('100001'), self._entry('100002')])
fill_watcher._watcher.sync_from_queue([self._entry('100001')])
self.assertEqual(set(fill_watcher._peek_for_test().keys()), {'100001'})
def test_sync_preserves_progress(self):
fill_watcher._watcher.sync_from_queue([self._entry('100001', last_cntr=3)])
self.assertEqual(fill_watcher._peek_for_test()['100001'].last_cntr_qty, 3)
def test_snapshot_round_trip(self):
fill_watcher._watcher.sync_from_queue([self._entry('100001', last_cntr=3)])
snap = fill_watcher._watcher.snapshot_entries()
self.assertEqual(len(snap), 1)
self.assertEqual(snap[0]['ord_no'], '100001')
self.assertEqual(snap[0]['last_cntr_qty'], 3)
if __name__ == '__main__':
unittest.main(verbosity=2)
@@ -0,0 +1,749 @@
"""guards 단위테스트. 매매 가드의 모든 분기를 mock 데이터로 검증."""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from unittest import mock
from orders import guards, ledger
KST = timezone(timedelta(hours=9))
def t(h, m, s=0, day=6):
return datetime(2026, 5, day, h, m, s, tzinfo=KST)
# ---------- 계좌 ----------
class AccountTests(unittest.TestCase):
def test_owner_accounts_allowed(self):
self.assertTrue(guards.validate_account('일반').ok)
self.assertTrue(guards.validate_account('ISA').ok)
def test_spouse_accounts_allowed(self):
self.assertTrue(guards.validate_account('가희_일반').ok)
self.assertTrue(guards.validate_account('가희_ISA').ok)
def test_unknown_account_rejected(self):
r = guards.validate_account('해외')
self.assertFalse(r.ok)
self.assertEqual(r.code, 'ACCOUNT_NOT_WHITELISTED')
def test_is_spouse_account(self):
self.assertTrue(guards.is_spouse_account('가희_일반'))
self.assertTrue(guards.is_spouse_account('가희_ISA'))
self.assertFalse(guards.is_spouse_account('일반'))
self.assertFalse(guards.is_spouse_account('ISA'))
# ---------- 시간대 ----------
class SessionTests(unittest.TestCase):
def test_closed_before_pre(self):
self.assertEqual(guards.session_at(t(7, 30)), 'CLOSED')
def test_nxt_pre_boundaries(self):
self.assertEqual(guards.session_at(t(8, 0)), 'NXT_PRE')
self.assertEqual(guards.session_at(t(8, 30)), 'NXT_PRE')
self.assertEqual(guards.session_at(t(8, 59, 59)), 'NXT_PRE')
def test_krx_nxt_concurrent(self):
self.assertEqual(guards.session_at(t(9, 0, 30)), 'KRX_NXT')
self.assertEqual(guards.session_at(t(12, 0)), 'KRX_NXT')
self.assertEqual(guards.session_at(t(15, 19, 59)), 'KRX_NXT')
def test_krx_close(self):
self.assertEqual(guards.session_at(t(15, 20)), 'KRX_CLOSE')
self.assertEqual(guards.session_at(t(15, 25)), 'KRX_CLOSE')
self.assertEqual(guards.session_at(t(15, 29, 59)), 'KRX_CLOSE')
def test_nxt_after(self):
self.assertEqual(guards.session_at(t(15, 30)), 'NXT_AFTER')
self.assertEqual(guards.session_at(t(18, 0)), 'NXT_AFTER')
self.assertEqual(guards.session_at(t(19, 59, 59)), 'NXT_AFTER')
def test_closed_after_after(self):
self.assertEqual(guards.session_at(t(20, 0)), 'CLOSED')
self.assertEqual(guards.session_at(t(21, 0)), 'CLOSED')
def test_gap_between_pre_and_regular_is_closed(self):
self.assertEqual(guards.session_at(t(9, 0, 0)), 'CLOSED')
# ---------- 거래시간 + NXT 매트릭스 ----------
class TradingHoursTests(unittest.TestCase):
def test_holiday(self):
r = guards.validate_trading_hours(t(10, 0), True, True)
self.assertFalse(r.ok)
self.assertEqual(r.code, 'HOLIDAY')
def test_closed(self):
r = guards.validate_trading_hours(t(21, 0), False, True)
self.assertFalse(r.ok)
self.assertEqual(r.code, 'OUTSIDE_HOURS')
def test_nxt_pre_eligible(self):
self.assertTrue(guards.validate_trading_hours(t(8, 30), False, True).ok)
def test_nxt_pre_not_eligible(self):
r = guards.validate_trading_hours(t(8, 30), False, False)
self.assertFalse(r.ok)
self.assertEqual(r.code, 'NXT_NOT_ELIGIBLE')
def test_nxt_after_not_eligible(self):
r = guards.validate_trading_hours(t(18, 0), False, False)
self.assertEqual(r.code, 'NXT_NOT_ELIGIBLE')
def test_krx_close_no_nxt_required(self):
self.assertTrue(guards.validate_trading_hours(t(15, 25), False, False).ok)
# ---------- 라우팅 ----------
class RoutingTests(unittest.TestCase):
def test_krx_nxt_with_eligible(self):
self.assertEqual(guards.determine_routing(t(10, 0), True, None), '_AL')
def test_krx_nxt_without_eligible(self):
self.assertEqual(guards.determine_routing(t(10, 0), False, None), '')
def test_nxt_pre(self):
self.assertEqual(guards.determine_routing(t(8, 30), True, None), '_NX')
def test_nxt_after(self):
self.assertEqual(guards.determine_routing(t(18, 0), True, None), '_NX')
def test_krx_close(self):
self.assertEqual(guards.determine_routing(t(15, 25), False, None), '')
def test_force_options(self):
self.assertEqual(guards.determine_routing(t(10, 0), True, 'AL'), '_AL')
self.assertEqual(guards.determine_routing(t(10, 0), True, 'NX'), '_NX')
self.assertEqual(guards.determine_routing(t(10, 0), True, 'KRX'), '')
def test_invalid_force(self):
with self.assertRaises(ValueError):
guards.determine_routing(t(10, 0), True, 'XYZ')
def test_closed_session_raises(self):
with self.assertRaises(ValueError):
guards.determine_routing(t(21, 0), True, None)
# ---------- 가격 가드 ----------
class PriceBandTests(unittest.TestCase):
def test_within_band(self):
self.assertTrue(guards.validate_price_band('BUY', 75000, 97500, 52500).ok)
self.assertTrue(guards.validate_price_band('SELL', 75000, 97500, 52500).ok)
def test_at_upper_inclusive(self):
self.assertTrue(guards.validate_price_band('BUY', 97500, 97500, 52500).ok)
def test_at_lower_inclusive(self):
self.assertTrue(guards.validate_price_band('SELL', 52500, 97500, 52500).ok)
def test_above_upper(self):
r = guards.validate_price_band('BUY', 100000, 97500, 52500)
self.assertEqual(r.code, 'PRICE_ABOVE_UPPER')
def test_below_lower(self):
r = guards.validate_price_band('SELL', 50000, 97500, 52500)
self.assertEqual(r.code, 'PRICE_BELOW_LOWER')
# ---------- 잔고 / 보유 / 정지 / VI ----------
class BalancePositionTests(unittest.TestCase):
def test_balance_sufficient(self):
self.assertTrue(guards.validate_balance_for_buy(5, 75000, 1_000_000).ok)
def test_balance_exact(self):
self.assertTrue(guards.validate_balance_for_buy(5, 75000, 375_000).ok)
def test_balance_insufficient(self):
r = guards.validate_balance_for_buy(5, 75000, 100)
self.assertEqual(r.code, 'INSUFFICIENT_BALANCE')
def test_position_sufficient(self):
self.assertTrue(guards.validate_position_for_sell(5, 100).ok)
def test_position_exact(self):
self.assertTrue(guards.validate_position_for_sell(5, 5).ok)
def test_position_insufficient(self):
r = guards.validate_position_for_sell(5, 3)
self.assertEqual(r.code, 'INSUFFICIENT_POSITION')
class MarketOrderSessionTests(unittest.TestCase):
def test_market_in_krx_regular_ok(self):
self.assertTrue(guards.validate_market_order_session(t(10, 0), 'MARKET').ok)
def test_market_in_nxt_pre_blocked(self):
r = guards.validate_market_order_session(t(8, 30), 'MARKET')
self.assertFalse(r.ok)
self.assertEqual(r.code, 'MARKET_NOT_ALLOWED_IN_NXT')
def test_market_in_nxt_after_blocked(self):
r = guards.validate_market_order_session(t(18, 0), 'MARKET')
self.assertEqual(r.code, 'MARKET_NOT_ALLOWED_IN_NXT')
def test_market_in_auction_blocked(self):
r = guards.validate_market_order_session(t(15, 25), 'MARKET')
self.assertEqual(r.code, 'MARKET_NOT_ALLOWED_IN_AUCTION')
def test_limit_unaffected(self):
for hour, minute in [(8, 30), (10, 0), (15, 25), (18, 0)]:
with self.subTest(time=(hour, minute)):
self.assertTrue(guards.validate_market_order_session(t(hour, minute), 'LIMIT').ok)
def test_aggressive_limit_unaffected(self):
for hour, minute in [(8, 30), (15, 25), (18, 0)]:
self.assertTrue(guards.validate_market_order_session(t(hour, minute), 'AGGRESSIVE_LIMIT').ok)
class HaltViTests(unittest.TestCase):
def test_normal(self):
self.assertTrue(guards.validate_halt_vi(False, False).ok)
def test_halt(self):
r = guards.validate_halt_vi(True, False)
self.assertEqual(r.code, 'TRADING_HALT')
def test_vi(self):
r = guards.validate_halt_vi(False, True)
self.assertEqual(r.code, 'VI')
# ---------- 딜레이 ----------
class DelayTests(unittest.TestCase):
def setUp(self):
self.now = t(10, 0)
def test_global_no_history(self):
with mock.patch.object(ledger, 'last_terminal_event', return_value=None):
self.assertTrue(guards.validate_delay_between_orders(self.now).ok)
def test_global_within_cooldown(self):
recent = (self.now - timedelta(seconds=30)).isoformat()
with mock.patch.object(ledger, 'last_terminal_event',
return_value={'ts': recent, 'event': 'filled'}):
r = guards.validate_delay_between_orders(self.now)
self.assertEqual(r.code, 'COOLDOWN_GLOBAL')
def test_global_after_cooldown(self):
old = (self.now - timedelta(seconds=120)).isoformat()
with mock.patch.object(ledger, 'last_terminal_event',
return_value={'ts': old, 'event': 'filled'}):
self.assertTrue(guards.validate_delay_between_orders(self.now).ok)
def test_same_symbol_within_3min(self):
recent = (self.now - timedelta(seconds=60)).isoformat()
with mock.patch.object(ledger, 'last_event_for_symbol',
return_value={'ts': recent, 'event': 'filled',
'payload': {'account': '일반', 'symbol': '005930'}}):
r = guards.validate_delay_same_symbol(self.now, '일반', '005930')
self.assertEqual(r.code, 'COOLDOWN_SAME_SYMBOL')
def test_same_symbol_after_3min(self):
old = (self.now - timedelta(seconds=200)).isoformat()
with mock.patch.object(ledger, 'last_event_for_symbol',
return_value={'ts': old, 'event': 'filled',
'payload': {'account': '일반', 'symbol': '005930'}}):
self.assertTrue(guards.validate_delay_same_symbol(self.now, '일반', '005930').ok)
def test_same_symbol_no_history(self):
with mock.patch.object(ledger, 'last_event_for_symbol', return_value=None):
self.assertTrue(guards.validate_delay_same_symbol(self.now, '일반', '005930').ok)
def test_same_symbol_blocked_by_submitted(self):
"""체결 폴링 미구현 상태에서도 submitted 만으로 동일 종목 가드 작동."""
recent = (self.now - timedelta(seconds=30)).isoformat()
with mock.patch.object(ledger, 'last_event_for_symbol',
return_value={'ts': recent, 'event': 'submitted',
'payload': {'account': '일반', 'symbol': '005930'}}):
r = guards.validate_delay_same_symbol(self.now, '일반', '005930')
self.assertEqual(r.code, 'COOLDOWN_SAME_SYMBOL')
def test_same_symbol_only_counts_post_submit_events(self):
"""rejected/canceled/expired/failed 는 키움 접수 전이라 동일 종목 가드 대상 아님."""
captured = {}
def fake_last(account, symbol, events=()):
captured['events'] = events
return None
with mock.patch.object(ledger, 'last_event_for_symbol', side_effect=fake_last):
self.assertTrue(guards.validate_delay_same_symbol(self.now, '일반', '005930').ok)
self.assertEqual(captured['events'], ('submitted', 'filled', 'partial'))
# ---------- 키움 진실 소스 가드 (kt00007) ----------
class BrokerDelayTests(unittest.TestCase):
"""validate_delay_same_symbol_via_broker — NETWORK 사각·키움앱 직접 매매까지 잡는 가드."""
def setUp(self):
self.now = t(10, 0)
def _exec(self, code: str, seconds_ago: int, comm_src: str = 'REST API') -> dict:
ord_tm = (self.now - timedelta(seconds=seconds_ago)).strftime('%H:%M:%S')
return {'code': code, 'ord_tm': ord_tm, 'comm_src': comm_src}
def test_no_executions_passes(self):
r = guards.validate_delay_same_symbol_via_broker(self.now, '005930', [], None)
self.assertTrue(r.ok)
def test_query_failed_blocks_conservatively(self):
"""키움 조회 자체 실패 시 보수적 차단 — 정확도 우선."""
r = guards.validate_delay_same_symbol_via_broker(self.now, '005930', None, 'TimeoutError(...)')
self.assertEqual(r.code, 'BROKER_QUERY_FAILED')
def test_recent_execution_blocks(self):
executions = [self._exec('005930', seconds_ago=60)]
r = guards.validate_delay_same_symbol_via_broker(self.now, '005930', executions, None)
self.assertEqual(r.code, 'COOLDOWN_SAME_SYMBOL_BROKER')
def test_old_execution_passes(self):
executions = [self._exec('005930', seconds_ago=200)]
r = guards.validate_delay_same_symbol_via_broker(self.now, '005930', executions, None)
self.assertTrue(r.ok)
def test_different_symbol_ignored(self):
executions = [self._exec('000660', seconds_ago=30)]
r = guards.validate_delay_same_symbol_via_broker(self.now, '005930', executions, None)
self.assertTrue(r.ok)
def test_external_app_execution_also_blocks(self):
"""사용자가 키움앱(영웅문)으로 직접 매매한 것도 가드 대상."""
executions = [self._exec('005930', seconds_ago=30, comm_src='영웅문S#')]
r = guards.validate_delay_same_symbol_via_broker(self.now, '005930', executions, None)
self.assertEqual(r.code, 'COOLDOWN_SAME_SYMBOL_BROKER')
self.assertIn('영웅문', r.message)
def test_picks_latest_among_multiple(self):
executions = [
self._exec('005930', seconds_ago=300, comm_src='영웅문S#'),
self._exec('005930', seconds_ago=60, comm_src='REST API'),
]
r = guards.validate_delay_same_symbol_via_broker(self.now, '005930', executions, None)
self.assertEqual(r.code, 'COOLDOWN_SAME_SYMBOL_BROKER')
def test_malformed_ord_tm_skipped(self):
"""ord_tm 파싱 실패 행은 무시. 다른 정상 행이 없으면 통과."""
executions = [{'code': '005930', 'ord_tm': '', 'comm_src': 'REST API'}]
r = guards.validate_delay_same_symbol_via_broker(self.now, '005930', executions, None)
self.assertTrue(r.ok)
def test_global_cooldown_only_counts_post_submit_events(self):
"""rejected/canceled/expired/failed 는 키움 접수 전이라 쿨다운 대상 아님."""
captured = {}
def fake_last(events=()):
captured['events'] = events
return None
with mock.patch.object(ledger, 'last_terminal_event', side_effect=fake_last):
self.assertTrue(guards.validate_delay_between_orders(self.now).ok)
self.assertEqual(captured['events'], ('submitted', 'filled', 'partial'))
def test_global_cooldown_triggered_by_submitted(self):
"""submitted (실주문 접수) 도 쿨다운 트리거."""
recent = (self.now - timedelta(seconds=30)).isoformat()
with mock.patch.object(ledger, 'last_terminal_event',
return_value={'ts': recent, 'event': 'submitted'}):
r = guards.validate_delay_between_orders(self.now)
self.assertEqual(r.code, 'COOLDOWN_GLOBAL')
# ---------- 자연어 시장가 분류 ----------
class IntentTests(unittest.TestCase):
def test_market_keyword(self):
self.assertEqual(guards.classify_order_intent('삼성 5주 시장가'), 'MARKET')
def test_aggressive_keywords(self):
for kw in ('지금 바로 사줘', '즉시 매수', '빨리 사', '당장 사줘'):
self.assertEqual(guards.classify_order_intent(kw), 'AGGRESSIVE_LIMIT')
def test_default_limit(self):
self.assertEqual(guards.classify_order_intent('삼성 5주 75000원'), 'LIMIT')
self.assertEqual(guards.classify_order_intent(''), 'LIMIT')
def test_market_takes_priority_over_aggressive(self):
# 시장가 키워드가 우선 — "지금 바로 시장가" 같은 경우
self.assertEqual(guards.classify_order_intent('지금 바로 시장가로'), 'MARKET')
# ---------- 호가단위 + 공격적 지정가 ----------
class TickSizeTests(unittest.TestCase):
def test_ranges(self):
cases = [(1500, 1), (3000, 5), (15000, 10), (45000, 50),
(100000, 100), (300000, 500), (700000, 1000)]
for price, expected in cases:
with self.subTest(price=price):
self.assertEqual(guards.tick_size(price), expected)
class AggressivePriceTests(unittest.TestCase):
def test_buy_one_tick_above(self):
ob = {'asks': [{'price': 75100, 'qty': 1000}], 'bids': [{'price': 75000, 'qty': 1000}]}
r = guards.aggressive_limit_price('BUY', ob)
self.assertTrue(r['ok'])
self.assertEqual(r['price'], 75200)
self.assertEqual(r['source'], 'orderbook')
def test_sell_one_tick_below(self):
ob = {'asks': [{'price': 75100, 'qty': 1000}], 'bids': [{'price': 75000, 'qty': 1000}]}
r = guards.aggressive_limit_price('SELL', ob)
self.assertTrue(r['ok'])
self.assertEqual(r['price'], 74900)
def test_buy_at_low_price_band(self):
# 1500원대 → tick=1
ob = {'asks': [{'price': 1500, 'qty': 1000}], 'bids': [{'price': 1499, 'qty': 1000}]}
r = guards.aggressive_limit_price('BUY', ob)
self.assertEqual(r['price'], 1501)
def test_no_orderbook_uses_fallback_price(self):
r = guards.aggressive_limit_price('BUY', None, fallback_price=75050)
self.assertTrue(r['ok'])
self.assertEqual(r['price'], 75150) # 75050 + 100tick
self.assertEqual(r['source'], 'fallback')
def test_empty_orderbook_uses_fallback_price(self):
r = guards.aggressive_limit_price('SELL', {'asks': [], 'bids': []}, fallback_price=75050)
self.assertTrue(r['ok'])
self.assertEqual(r['price'], 74950)
self.assertEqual(r['source'], 'fallback')
def test_no_orderbook_no_fallback_rejects(self):
r = guards.aggressive_limit_price('BUY', None)
self.assertFalse(r['ok'])
self.assertEqual(r['code'], 'NO_ORDERBOOK')
def test_orderbook_takes_priority_over_fallback(self):
ob = {'asks': [{'price': 75100, 'qty': 1000}], 'bids': [{'price': 75000, 'qty': 1000}]}
r = guards.aggressive_limit_price('BUY', ob, fallback_price=80000)
self.assertEqual(r['source'], 'orderbook')
self.assertEqual(r['price'], 75200)
# ---------- 시장가 슬리피지 추정 ----------
class MarketEstimateTests(unittest.TestCase):
def setUp(self):
self.ob = {
'asks': [{'price': 75100, 'qty': 1000}, {'price': 75200, 'qty': 500}],
'bids': [{'price': 75000, 'qty': 800}, {'price': 74900, 'qty': 1200}],
}
def test_buy_within_first_level(self):
est = guards.estimate_market_fill('BUY', 5, self.ob)
self.assertEqual(est['avg_fill'], 75100)
self.assertEqual(est['slippage_pct'], 0.0)
def test_buy_across_levels(self):
est = guards.estimate_market_fill('BUY', 1500, self.ob)
# 1000 × 75100 + 500 × 75200 = 112,700,000 → avg 75133
self.assertEqual(est['total_won'], 112_700_000)
self.assertEqual(est['avg_fill'], 75133)
self.assertGreater(est['slippage_pct'], 0)
def test_sell_first_level(self):
est = guards.estimate_market_fill('SELL', 5, self.ob)
self.assertEqual(est['avg_fill'], 75000)
def test_buy_exceeds_orderbook_depth(self):
# depth=3 default 지만 ob asks 2단계뿐 → 5000주 매수면 마지막 호가가 fallback
est = guards.estimate_market_fill('BUY', 5000, self.ob)
self.assertGreater(est['total_won'], 0)
self.assertGreater(est['avg_fill'], 0)
# ---------- 통합 검증 ----------
class IntegrationTests(unittest.TestCase):
def setUp(self):
self.now = t(10, 0)
self.ob = {
'asks': [{'price': 75100, 'qty': 1250}, {'price': 75200, 'qty': 890}],
'bids': [{'price': 75000, 'qty': 800}, {'price': 74900, 'qty': 1200}],
}
self.md_buy = {
'now': self.now, 'is_holiday': False, 'nxt_eligible': True,
'current_price': 75000, 'prev_close': 74800,
'upper_limit': 97500, 'lower_limit': 52500,
'halt': False, 'vi': False, 'orderbook': self.ob,
'balance_d2': 1_000_000,
'broker_executions': [], # broker 가드 통과용 (정상=빈 리스트)
'broker_query_error': None,
}
self.md_sell = dict(self.md_buy, position_qty=100)
self.req_buy = {'account': '일반', 'side': 'BUY', 'symbol': '005930',
'qty': 5, 'order_type': 'LIMIT', 'price': 75000}
self.req_sell = {'account': 'ISA', 'side': 'SELL', 'symbol': '005930',
'qty': 5, 'order_type': 'LIMIT', 'price': 75000}
def _no_history(self):
return mock.patch.multiple(
ledger,
last_terminal_event=mock.MagicMock(return_value=None),
last_event_for_symbol=mock.MagicMock(return_value=None),
)
def test_normal_buy(self):
with self._no_history():
self.assertTrue(guards.validate_request(self.req_buy, self.md_buy).ok)
def test_normal_sell(self):
with self._no_history():
self.assertTrue(guards.validate_request(self.req_sell, self.md_sell).ok)
def test_account_rejected(self):
with self._no_history():
req = dict(self.req_buy, account='해외')
r = guards.validate_request(req, self.md_buy)
self.assertEqual(r.code, 'ACCOUNT_NOT_WHITELISTED')
def test_invalid_side(self):
with self._no_history():
req = dict(self.req_buy, side='HOLD')
r = guards.validate_request(req, self.md_buy)
self.assertEqual(r.code, 'INVALID_SIDE')
def test_invalid_order_type(self):
with self._no_history():
req = dict(self.req_buy, order_type='UNKNOWN')
r = guards.validate_request(req, self.md_buy)
self.assertEqual(r.code, 'INVALID_ORDER_TYPE')
def test_holiday_blocks(self):
with self._no_history():
md = dict(self.md_buy, is_holiday=True)
self.assertEqual(guards.validate_request(self.req_buy, md).code, 'HOLIDAY')
def test_outside_hours(self):
with self._no_history():
md = dict(self.md_buy, now=t(21, 0))
self.assertEqual(guards.validate_request(self.req_buy, md).code, 'OUTSIDE_HOURS')
def test_halt(self):
with self._no_history():
md = dict(self.md_buy, halt=True)
self.assertEqual(guards.validate_request(self.req_buy, md).code, 'TRADING_HALT')
def test_vi(self):
with self._no_history():
md = dict(self.md_buy, vi=True)
self.assertEqual(guards.validate_request(self.req_buy, md).code, 'VI')
def test_price_above_upper(self):
with self._no_history():
req = dict(self.req_buy, price=100_000)
self.assertEqual(guards.validate_request(req, self.md_buy).code, 'PRICE_ABOVE_UPPER')
def test_insufficient_balance(self):
with self._no_history():
md = dict(self.md_buy, balance_d2=100)
self.assertEqual(guards.validate_request(self.req_buy, md).code, 'INSUFFICIENT_BALANCE')
def test_insufficient_position(self):
with self._no_history():
md = dict(self.md_sell, position_qty=2)
self.assertEqual(guards.validate_request(self.req_sell, md).code, 'INSUFFICIENT_POSITION')
def test_market_buy_uses_orderbook(self):
with self._no_history():
req = dict(self.req_buy, order_type='MARKET')
req.pop('price', None)
self.assertTrue(guards.validate_request(req, self.md_buy).ok)
def test_market_buy_in_nxt_blocked(self):
with self._no_history():
req = dict(self.req_buy, order_type='MARKET'); req.pop('price', None)
md = dict(self.md_buy, now=t(8, 30))
r = guards.validate_request(req, md)
self.assertEqual(r.code, 'MARKET_NOT_ALLOWED_IN_NXT')
def test_market_buy_in_auction_blocked(self):
with self._no_history():
req = dict(self.req_buy, order_type='MARKET'); req.pop('price', None)
md = dict(self.md_buy, now=t(15, 25))
r = guards.validate_request(req, md)
self.assertEqual(r.code, 'MARKET_NOT_ALLOWED_IN_AUCTION')
def test_nxt_time_not_eligible(self):
with self._no_history():
md = dict(self.md_buy, now=t(8, 30), nxt_eligible=False)
self.assertEqual(guards.validate_request(self.req_buy, md).code, 'NXT_NOT_ELIGIBLE')
def test_global_cooldown_blocks_new_order(self):
recent = (self.now - timedelta(seconds=30)).isoformat()
with mock.patch.object(ledger, 'last_terminal_event',
return_value={'ts': recent, 'event': 'filled'}), \
mock.patch.object(ledger, 'last_event_for_symbol', return_value=None):
self.assertEqual(guards.validate_request(self.req_buy, self.md_buy).code, 'COOLDOWN_GLOBAL')
def test_aggressive_limit_buy_uses_orderbook(self):
with self._no_history():
req = dict(self.req_buy, order_type='AGGRESSIVE_LIMIT'); req.pop('price', None)
self.assertTrue(guards.validate_request(req, self.md_buy).ok)
def test_aggressive_limit_buy_fallback_when_orderbook_missing(self):
"""호가창 비어도 current_price fallback 으로 잔고 가드까지 통과."""
with self._no_history():
req = dict(self.req_buy, order_type='AGGRESSIVE_LIMIT'); req.pop('price', None)
md = dict(self.md_buy, orderbook=None, current_price=75000)
self.assertTrue(guards.validate_request(req, md).ok)
def test_aggressive_limit_buy_no_orderbook_no_quote_rejects(self):
with self._no_history():
req = dict(self.req_buy, order_type='AGGRESSIVE_LIMIT'); req.pop('price', None)
md = dict(self.md_buy, orderbook=None, current_price=0)
r = guards.validate_request(req, md)
self.assertEqual(r.code, 'NO_ORDERBOOK')
def test_same_symbol_cooldown_blocks(self):
recent = (self.now - timedelta(seconds=60)).isoformat()
with mock.patch.object(ledger, 'last_terminal_event', return_value=None), \
mock.patch.object(ledger, 'last_event_for_symbol',
return_value={'ts': recent, 'event': 'filled',
'payload': {'account': '일반', 'symbol': '005930'}}):
self.assertEqual(guards.validate_request(self.req_buy, self.md_buy).code,
'COOLDOWN_SAME_SYMBOL')
# ---------- 예산 → 수량 환산 ----------
class BudgetConversionTests(unittest.TestCase):
def setUp(self):
self.orderbook = {
'asks': [{'price': 75100, 'qty': 200}, {'price': 75200, 'qty': 150}],
'bids': [{'price': 75000, 'qty': 180}, {'price': 74900, 'qty': 220}],
}
def test_buy_uses_ask1_floor_plus_one_under_threshold(self):
# 1,000,000 / 75,100 = 13.31... → floor 13 + bump → 14주, 초과액 51,400원
# 75,100원 ≤ 30만원 → BUY +1 정책 적용
r = guards.convert_budget_to_qty('BUY', 1_000_000, self.orderbook)
self.assertTrue(r['ok'])
self.assertEqual(r['qty'], 14)
self.assertEqual(r['ref_price'], 75100)
self.assertTrue(r['bumped'])
self.assertEqual(r['remainder'], 1_000_000 - 14 * 75100) # 음수
self.assertLess(r['remainder'], 0)
def test_sell_uses_bid1_floor(self):
# SELL 은 ref_price 가 30만원 이하여도 bump 안 함
# 500,000 / 75,000 = 6.66... → 6주
r = guards.convert_budget_to_qty('SELL', 500_000, self.orderbook)
self.assertTrue(r['ok'])
self.assertEqual(r['qty'], 6)
self.assertEqual(r['ref_price'], 75000)
self.assertFalse(r['bumped'])
self.assertEqual(r['remainder'], 500_000 - 6 * 75000)
def test_budget_too_small_rejects(self):
r = guards.convert_budget_to_qty('BUY', 50_000, self.orderbook)
self.assertFalse(r['ok'])
self.assertEqual(r['code'], 'BUDGET_TOO_SMALL')
def test_budget_zero_rejects(self):
r = guards.convert_budget_to_qty('BUY', 0, self.orderbook)
self.assertFalse(r['ok'])
self.assertEqual(r['code'], 'BUDGET_INVALID')
def test_budget_negative_rejects(self):
r = guards.convert_budget_to_qty('BUY', -1000, self.orderbook)
self.assertFalse(r['ok'])
self.assertEqual(r['code'], 'BUDGET_INVALID')
def test_no_orderbook_no_fallback_rejects(self):
r = guards.convert_budget_to_qty('BUY', 1_000_000, None)
self.assertFalse(r['ok'])
self.assertEqual(r['code'], 'NO_ORDERBOOK')
def test_empty_asks_no_fallback_rejects(self):
ob = {'asks': [], 'bids': self.orderbook['bids']}
r = guards.convert_budget_to_qty('BUY', 1_000_000, ob)
self.assertFalse(r['ok'])
self.assertEqual(r['code'], 'NO_ORDERBOOK')
def test_no_orderbook_uses_fallback_price(self):
# fallback 도 ≤ 30만원 이면 bump 적용
r = guards.convert_budget_to_qty('BUY', 1_000_000, None, fallback_price=75050)
self.assertTrue(r['ok'])
self.assertEqual(r['qty'], 14) # floor 13 + bump
self.assertEqual(r['ref_price'], 75050)
self.assertEqual(r['source'], 'fallback')
self.assertTrue(r['bumped'])
def test_empty_asks_uses_fallback_price(self):
ob = {'asks': [], 'bids': self.orderbook['bids']}
r = guards.convert_budget_to_qty('BUY', 1_000_000, ob, fallback_price=75050)
self.assertTrue(r['ok'])
self.assertEqual(r['source'], 'fallback')
self.assertEqual(r['ref_price'], 75050)
def test_orderbook_takes_priority_over_fallback(self):
r = guards.convert_budget_to_qty('BUY', 1_000_000, self.orderbook, fallback_price=80000)
self.assertTrue(r['ok'])
self.assertEqual(r['source'], 'orderbook')
self.assertEqual(r['ref_price'], 75100)
def test_fallback_budget_too_small(self):
r = guards.convert_budget_to_qty('BUY', 50_000, None, fallback_price=75050)
self.assertFalse(r['ok'])
self.assertEqual(r['code'], 'BUDGET_TOO_SMALL')
self.assertIn('현재가', r['message'])
def test_exact_multiple_no_remainder(self):
# 75,100 × 10 = 751,000 — remainder=0 이면 bump 안 함
r = guards.convert_budget_to_qty('BUY', 751_000, self.orderbook)
self.assertTrue(r['ok'])
self.assertEqual(r['qty'], 10)
self.assertEqual(r['remainder'], 0)
self.assertFalse(r['bumped'])
def test_just_below_one_share(self):
r = guards.convert_budget_to_qty('BUY', 75_099, self.orderbook)
self.assertFalse(r['ok'])
self.assertEqual(r['code'], 'BUDGET_TOO_SMALL')
def test_exactly_one_share(self):
# remainder=0 이면 bump 안 함
r = guards.convert_budget_to_qty('BUY', 75_100, self.orderbook)
self.assertTrue(r['ok'])
self.assertEqual(r['qty'], 1)
self.assertEqual(r['remainder'], 0)
self.assertFalse(r['bumped'])
def test_buy_no_bump_when_ref_price_exactly_at_threshold(self):
# ref_price 정확히 30만원 → bump 적용 (≤ 경계 inclusive)
ob = {'asks': [{'price': 300_000, 'qty': 50}],
'bids': [{'price': 299_500, 'qty': 50}]}
r = guards.convert_budget_to_qty('BUY', 1_000_000, ob)
self.assertTrue(r['ok'])
self.assertEqual(r['qty'], 4) # floor 3 + bump
self.assertTrue(r['bumped'])
def test_buy_no_bump_when_ref_price_above_threshold(self):
# ref_price 30만원 초과 → bump 미적용
ob = {'asks': [{'price': 300_001, 'qty': 50}],
'bids': [{'price': 299_500, 'qty': 50}]}
r = guards.convert_budget_to_qty('BUY', 1_000_000, ob)
self.assertTrue(r['ok'])
self.assertEqual(r['qty'], 3) # floor 그대로
self.assertFalse(r['bumped'])
self.assertGreater(r['remainder'], 0)
if __name__ == '__main__':
unittest.main(verbosity=2)
@@ -0,0 +1,328 @@
"""handler.amend_trade / cancel_active_card 회귀 테스트.
활성 카드 머지 → 가드 재실행 → PIN 재발급 흐름 검증.
0 입력 시 cancel 위임, ambiguous 입력 거부, account/symbol/side 변경 불가.
"""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from unittest import mock
from orders import handler, ledger, pin
KST = timezone(timedelta(hours=9))
def _md(orderbook=None):
return {
'now': datetime(2026, 5, 8, 11, 30, tzinfo=KST),
'is_holiday': False,
'nxt_eligible': True,
'current_price': 75050,
'prev_close': 75000,
'upper_limit': 100000,
'lower_limit': 50000,
'halt': False,
'vi': False,
'orderbook': orderbook if orderbook is not None else {
'asks': [{'price': 75100, 'qty': 200}, {'price': 75200, 'qty': 150}],
'bids': [{'price': 75000, 'qty': 180}, {'price': 74900, 'qty': 220}],
},
'broker_executions': [],
'broker_query_error': None,
'balance_d2': 5_000_000,
}
def _active_card(payload=None, expired=False, consumed=False):
p = mock.MagicMock(spec=pin.PendingCard)
p.card_id = 'A7K3'
p.pin = '1234'
p.account_label = '일반'
p.payload = payload or {
'account': '일반', 'side': 'BUY', 'symbol': '005930', 'symbol_name': '삼성전자',
'qty': 14, 'price': None, 'order_type': 'MARKET', 'routing_suffix': '_AL',
}
p.consumed = consumed
p.is_expired = mock.MagicMock(return_value=expired)
return p
class HandlerAmendTests(unittest.TestCase):
def setUp(self):
self.collect = mock.patch.object(handler.datasource, 'collect_market_data',
return_value=_md()).start()
mock.patch.object(handler.sidecar, 'guard_or_raise', return_value=None).start()
mock.patch.object(handler, '_sweep_expired_and_notify', return_value=None).start()
mock.patch.object(handler.ledger, 'append', return_value=None).start()
mock.patch.object(ledger, 'last_terminal_event', return_value=None).start()
mock.patch.object(ledger, 'last_event_for_symbol', return_value=None).start()
# 활성 카드 mock — peek 으로 반환
self.active = _active_card()
mock.patch.object(handler._pin_store, 'peek', return_value=self.active).start()
# amend 호출 시 새 PendingCard 반환
self.amended_pending = mock.MagicMock()
self.amended_pending.card_id = 'A7K3'
self.amended_pending.pin = '5678'
self.amended_pending.expiry_seconds = 120
mock.patch.object(handler._pin_store, 'amend', return_value=self.amended_pending).start()
# cancel mock — cancel 위임 검증용
self.cancel_card = _active_card()
mock.patch.object(handler._pin_store, 'cancel', return_value=self.cancel_card).start()
self.addCleanup(mock.patch.stopall)
def test_amend_qty_only_succeeds(self):
res = handler.amend_trade(qty=20)
self.assertTrue(res['ok'], msg=res.get('message'))
new_payload = handler._pin_store.amend.call_args[0][0]
self.assertEqual(new_payload['qty'], 20)
self.assertEqual(new_payload['order_type'], 'MARKET') # 기존 유지
self.assertEqual(new_payload['symbol'], '005930') # 기존 유지
def test_amend_price_only_changes_to_limit_keeps_existing_type(self):
# 기존 MARKET 유지, price 만 변경 — order_type 안 줬으면 그대로 MARKET
res = handler.amend_trade(price=75500)
self.assertTrue(res['ok'], msg=res.get('message'))
new_payload = handler._pin_store.amend.call_args[0][0]
# MARKET 일 땐 final_price 가 None (estimate 만 사용)
self.assertEqual(new_payload['order_type'], 'MARKET')
def test_amend_order_type_to_limit_with_price(self):
res = handler.amend_trade(order_type='LIMIT', price=75500)
self.assertTrue(res['ok'], msg=res.get('message'))
new_payload = handler._pin_store.amend.call_args[0][0]
self.assertEqual(new_payload['order_type'], 'LIMIT')
self.assertEqual(new_payload['price'], 75500)
def test_amend_budget_changes_qty_and_forces_market(self):
res = handler.amend_trade(budget=2_000_000)
self.assertTrue(res['ok'], msg=res.get('message'))
new_payload = handler._pin_store.amend.call_args[0][0]
# 2,000,000 / 75,100 = 26.6 → floor 26 + bump → 27주 (75,100 ≤ 30만원)
self.assertEqual(new_payload['qty'], 27)
self.assertEqual(new_payload['order_type'], 'MARKET')
def test_amend_zero_qty_delegates_to_cancel(self):
res = handler.amend_trade(qty=0)
self.assertTrue(res['ok'])
# cancel 호출됨, amend 호출 안 됨
handler._pin_store.cancel.assert_called_once()
handler._pin_store.amend.assert_not_called()
def test_amend_zero_price_delegates_to_cancel(self):
res = handler.amend_trade(price=0)
self.assertTrue(res['ok'])
handler._pin_store.cancel.assert_called_once()
handler._pin_store.amend.assert_not_called()
def test_amend_zero_budget_delegates_to_cancel(self):
res = handler.amend_trade(budget=0)
self.assertTrue(res['ok'])
handler._pin_store.cancel.assert_called_once()
def test_amend_zero_with_nonzero_rejected(self):
# qty=0 + price=75000 동시 → AMBIGUOUS_AMEND
res = handler.amend_trade(qty=0, price=75000)
self.assertFalse(res['ok'])
self.assertIn('AMBIGUOUS_AMEND', res['message'])
handler._pin_store.cancel.assert_not_called()
def test_amend_zero_with_order_type_rejected(self):
res = handler.amend_trade(qty=0, order_type='LIMIT')
self.assertFalse(res['ok'])
self.assertIn('AMBIGUOUS_AMEND', res['message'])
def test_amend_no_active_card_rejected(self):
handler._pin_store.peek.return_value = None
res = handler.amend_trade(qty=20)
self.assertFalse(res['ok'])
self.assertIn('NO_ACTIVE_CARD', res['message'])
def test_amend_expired_card_rejected(self):
handler._pin_store.peek.return_value = _active_card(expired=True)
res = handler.amend_trade(qty=20)
self.assertFalse(res['ok'])
self.assertIn('NO_ACTIVE_CARD', res['message'])
def test_amend_consumed_card_rejected(self):
handler._pin_store.peek.return_value = _active_card(consumed=True)
res = handler.amend_trade(qty=20)
self.assertFalse(res['ok'])
self.assertIn('NO_ACTIVE_CARD', res['message'])
def test_amend_qty_and_budget_both_rejected(self):
res = handler.amend_trade(qty=20, budget=1_000_000)
self.assertFalse(res['ok'])
self.assertIn('AMBIGUOUS_INPUT', res['message'])
def test_amend_blocked_by_balance_guard(self):
# 잔고를 50만원으로 낮추고 100주로 늘리면 guards.validate_request 의 INSUFFICIENT_BALANCE 에서 거부
md = _md()
md['balance_d2'] = 500_000
self.collect.return_value = md
res = handler.amend_trade(qty=100)
self.assertFalse(res['ok'])
self.assertIn('INSUFFICIENT_BALANCE', res['message'])
handler._pin_store.amend.assert_not_called()
def test_amend_invalid_order_type_rejected(self):
res = handler.amend_trade(order_type='WEIRD')
self.assertFalse(res['ok'])
self.assertIn('INVALID_ORDER_TYPE', res['message'])
def test_amend_card_carries_amended_marker(self):
res = handler.amend_trade(qty=20)
self.assertTrue(res['ok'])
self.assertIn('수정됨', res['card_message'])
def test_amend_returns_new_pin(self):
res = handler.amend_trade(qty=20)
self.assertTrue(res['ok'])
# PIN 재발급 — amend mock 이 5678 반환
self.assertEqual(res['pin_message'], '5678')
def test_amend_account_only(self):
res = handler.amend_trade(account='ISA')
self.assertTrue(res['ok'], msg=res.get('message'))
new_payload, kwargs = (handler._pin_store.amend.call_args[0],
handler._pin_store.amend.call_args[1])
self.assertEqual(new_payload[0]['account'], 'ISA')
self.assertEqual(new_payload[0]['symbol'], '005930') # 종목 유지
self.assertEqual(kwargs.get('account_label'), 'ISA') # PinStore.amend 에 새 account 전달
def test_amend_account_to_spouse_passes_new_account_label(self):
res = handler.amend_trade(account='가희_ISA')
self.assertTrue(res['ok'], msg=res.get('message'))
kwargs = handler._pin_store.amend.call_args[1]
self.assertEqual(kwargs.get('account_label'), '가희_ISA')
def test_amend_invalid_account_rejected(self):
res = handler.amend_trade(account='UNKNOWN')
self.assertFalse(res['ok'])
self.assertIn('INVALID_ACCOUNT', res['message'])
handler._pin_store.amend.assert_not_called()
def test_amend_symbol_with_name(self):
res = handler.amend_trade(symbol='035720', symbol_name='카카오')
self.assertTrue(res['ok'], msg=res.get('message'))
new_payload = handler._pin_store.amend.call_args[0][0]
self.assertEqual(new_payload['symbol'], '035720')
self.assertEqual(new_payload['symbol_name'], '카카오')
self.assertEqual(new_payload['account'], '일반') # 계좌 유지
def test_amend_symbol_without_name_rejected(self):
res = handler.amend_trade(symbol='035720')
self.assertFalse(res['ok'])
self.assertIn('MISSING_SYMBOL_NAME', res['message'])
handler._pin_store.amend.assert_not_called()
def test_amend_invalid_symbol_format_rejected(self):
# 5자리 — 6자리 강제
res = handler.amend_trade(symbol='12345', symbol_name='임시')
self.assertFalse(res['ok'])
self.assertIn('INVALID_SYMBOL', res['message'])
def test_amend_invalid_symbol_non_digit_rejected(self):
res = handler.amend_trade(symbol='ABC123', symbol_name='임시')
self.assertFalse(res['ok'])
self.assertIn('INVALID_SYMBOL', res['message'])
def test_amend_account_and_symbol_together(self):
res = handler.amend_trade(account='ISA', symbol='035720', symbol_name='카카오', qty=10)
self.assertTrue(res['ok'], msg=res.get('message'))
new_payload = handler._pin_store.amend.call_args[0][0]
self.assertEqual(new_payload['account'], 'ISA')
self.assertEqual(new_payload['symbol'], '035720')
self.assertEqual(new_payload['symbol_name'], '카카오')
self.assertEqual(new_payload['qty'], 10)
def test_amend_zero_with_account_change_rejected(self):
# 0 + account 변경 동시 → AMBIGUOUS_AMEND
res = handler.amend_trade(qty=0, account='ISA')
self.assertFalse(res['ok'])
self.assertIn('AMBIGUOUS_AMEND', res['message'])
def test_amend_zero_with_symbol_change_rejected(self):
res = handler.amend_trade(qty=0, symbol='035720', symbol_name='카카오')
self.assertFalse(res['ok'])
self.assertIn('AMBIGUOUS_AMEND', res['message'])
def test_amend_symbol_unchanged_no_name_required(self):
# 같은 종목코드 다시 입력은 변경 아님 — symbol_name 미입력 OK
res = handler.amend_trade(symbol='005930')
self.assertTrue(res['ok'], msg=res.get('message'))
def test_amend_orphan_symbol_name_rejected(self):
# symbol_name 만 단독 변경 → 카드 이름 vs 발주 코드 불일치 위험 → 거부
res = handler.amend_trade(symbol_name='카카오')
self.assertFalse(res['ok'])
self.assertIn('ORPHAN_SYMBOL_NAME', res['message'])
handler._pin_store.amend.assert_not_called()
def test_amend_same_symbol_name_no_change_passes(self):
# 같은 이름 다시 입력은 변경 아님 → OK
res = handler.amend_trade(symbol_name='삼성전자')
self.assertTrue(res['ok'], msg=res.get('message'))
def test_amend_market_to_limit_without_price_rejected(self):
# 활성 카드가 MARKET 인 상태에서 LIMIT 으로만 전환 → price 없으면 거부
# (기본 active fixture 가 order_type=MARKET, price=None)
res = handler.amend_trade(order_type='LIMIT')
self.assertFalse(res['ok'])
self.assertIn('MISSING_LIMIT_PRICE', res['message'])
handler._pin_store.amend.assert_not_called()
def test_amend_market_to_limit_with_price_passes(self):
res = handler.amend_trade(order_type='LIMIT', price=75500)
self.assertTrue(res['ok'], msg=res.get('message'))
new_payload = handler._pin_store.amend.call_args[0][0]
self.assertEqual(new_payload['order_type'], 'LIMIT')
self.assertEqual(new_payload['price'], 75500)
class SubmitPinZeroCancelTests(unittest.TestCase):
"""PIN echo 자리에 '0' 입력 → cancel 위임 검증."""
def setUp(self):
mock.patch.object(handler.sidecar, 'guard_or_raise', return_value=None).start()
mock.patch.object(handler, '_sweep_expired_and_notify', return_value=None).start()
mock.patch.object(handler.ledger, 'append', return_value=None).start()
cancel_card = _active_card()
mock.patch.object(handler._pin_store, 'cancel', return_value=cancel_card).start()
self.verify = mock.patch.object(handler._pin_store, 'verify').start()
self.addCleanup(mock.patch.stopall)
def test_pin_zero_delegates_to_cancel(self):
res = handler.submit_with_pin('0', dry_run=False)
self.assertTrue(res['ok'])
# verify 는 호출 안 됨 — 0 분기에서 즉시 cancel
self.verify.assert_not_called()
handler._pin_store.cancel.assert_called_once()
self.assertIn('취소', res['message'])
def test_pin_zero_with_whitespace_delegates_to_cancel(self):
# 양옆 공백 strip
res = handler.submit_with_pin(' 0 ', dry_run=False)
self.assertTrue(res['ok'])
self.verify.assert_not_called()
def test_pin_zero_with_no_active_card_returns_no_card(self):
handler._pin_store.cancel.return_value = None
res = handler.submit_with_pin('0', dry_run=False)
self.assertFalse(res['ok'])
self.assertIn('활성 카드 없음', res['message'])
def test_pin_nonzero_proceeds_normal_flow(self):
# "1234" 같은 정상 PIN 은 verify 경로 그대로
self.verify.return_value = (False, '활성 카드 없음', None)
res = handler.submit_with_pin('1234', dry_run=False)
self.assertFalse(res['ok'])
self.verify.assert_called_once()
# cancel 은 호출 안 됨
handler._pin_store.cancel.assert_not_called()
if __name__ == '__main__':
unittest.main(verbosity=2)
@@ -0,0 +1,232 @@
"""handler.propose_trade 의 budget 입력 통합 회귀 테스트.
datasource·sidecar·pin_store·ledger 를 mock 해서 budget 환산 후 12가드 체인이 그대로 흘러가는지 검증.
"""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from unittest import mock
from orders import handler, ledger
KST = timezone(timedelta(hours=9))
def _md(now=None, orderbook=None):
return {
'now': now or datetime(2026, 5, 6, 10, 0, tzinfo=KST),
'is_holiday': False,
'nxt_eligible': True,
'current_price': 75050,
'prev_close': 75000,
'upper_limit': 100000,
'lower_limit': 50000,
'halt': False,
'vi': False,
'orderbook': orderbook if orderbook is not None else {
'asks': [{'price': 75100, 'qty': 200}, {'price': 75200, 'qty': 150}],
'bids': [{'price': 75000, 'qty': 180}, {'price': 74900, 'qty': 220}],
},
'broker_executions': [],
'broker_query_error': None,
'balance_d2': 5_000_000,
}
class HandlerBudgetTests(unittest.TestCase):
def setUp(self):
# 모든 외부 의존 mock — budget 환산 → guards → pin_store 흐름만 검증
self.collect = mock.patch.object(handler.datasource, 'collect_market_data',
return_value=_md()).start()
mock.patch.object(handler.sidecar, 'guard_or_raise', return_value=None).start()
mock.patch.object(handler, '_sweep_expired_and_notify', return_value=None).start()
mock.patch.object(handler.ledger, 'append', return_value=None).start()
mock.patch.object(ledger, 'last_terminal_event', return_value=None).start()
mock.patch.object(ledger, 'last_event_for_symbol', return_value=None).start()
# pin_store.issue 가 카드 발행 — 단순한 fake 객체 반환
fake_pending = mock.MagicMock()
fake_pending.card_id = 'TEST'
fake_pending.pin = '1234'
fake_pending.expiry_seconds = 120
fake_pending.account_label = '일반'
mock.patch.object(handler._pin_store, 'issue', return_value=fake_pending).start()
self.addCleanup(mock.patch.stopall)
def test_budget_market_buy_succeeds_with_correct_qty(self):
res = handler.propose_trade(
account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
qty=None, order_type='MARKET', budget=1_000_000,
)
self.assertTrue(res['ok'], msg=res.get('message'))
# ref_price=75,100원 ≤ 30만원 → BUY +1 적용. 13 → 14주.
call_args = handler._pin_store.issue.call_args
self.assertIsNotNone(call_args)
payload = call_args[0][1]
self.assertEqual(payload['qty'], 14)
self.assertEqual(payload['order_type'], 'MARKET')
def test_budget_too_small_rejects_before_card(self):
res = handler.propose_trade(
account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
qty=None, order_type='MARKET', budget=50_000,
)
self.assertFalse(res['ok'])
self.assertIn('BUDGET_TOO_SMALL', res['message'])
# 환산 거부 시 카드 발행 없어야 함
handler._pin_store.issue.assert_not_called()
def test_budget_invalid_zero_rejects(self):
res = handler.propose_trade(
account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
qty=None, order_type='MARKET', budget=0,
)
self.assertFalse(res['ok'])
self.assertIn('BUDGET_INVALID', res['message'])
def test_qty_and_budget_both_set_rejected(self):
res = handler.propose_trade(
account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
qty=5, order_type='MARKET', budget=1_000_000,
)
self.assertFalse(res['ok'])
self.assertIn('AMBIGUOUS_INPUT', res['message'])
def test_neither_qty_nor_budget_rejected(self):
res = handler.propose_trade(
account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
qty=None, order_type='MARKET', budget=None,
)
self.assertFalse(res['ok'])
self.assertIn('MISSING_QTY', res['message'])
def test_budget_sell_uses_bid1(self):
sell_md = _md()
sell_md.pop('balance_d2', None)
sell_md['position_qty'] = 100
self.collect.return_value = sell_md
res = handler.propose_trade(
account='ISA', side='SELL', symbol='005930', symbol_name='삼성전자',
qty=None, order_type='MARKET', budget=500_000,
)
self.assertTrue(res['ok'], msg=res.get('message'))
payload = handler._pin_store.issue.call_args[0][1]
self.assertEqual(payload['qty'], 6) # 500_000 // 75_000
self.assertEqual(payload['side'], 'SELL')
def test_budget_passes_balance_guard_when_sufficient(self):
# 잔고 5,000,000원 / 환산 13주 × 75,100 ≒ 976,300원 → 통과해야 함
res = handler.propose_trade(
account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
qty=None, order_type='MARKET', budget=1_000_000,
)
self.assertTrue(res['ok'])
def test_budget_buy_fallback_uses_current_price_when_orderbook_missing(self):
"""호가창 비어도 current_price 로 환산해서 카드 발행 통과."""
md = _md()
md['orderbook'] = None
md['current_price'] = 75050
self.collect.return_value = md
res = handler.propose_trade(
account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
qty=None, order_type='LIMIT', price=75000, budget=1_000_000,
)
self.assertTrue(res['ok'], msg=res.get('message'))
payload = handler._pin_store.issue.call_args[0][1]
# ref_price=75,050(fallback) ≤ 30만원 → BUY +1 적용 → 14주
self.assertEqual(payload['qty'], 14)
def test_aggressive_limit_buy_fallback_when_orderbook_missing(self):
"""AGGRESSIVE_LIMIT BUY — 호가창 없어도 current_price+1tick 으로 카드 발행."""
md = _md()
md['orderbook'] = None
md['current_price'] = 75050
self.collect.return_value = md
res = handler.propose_trade(
account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
qty=10, order_type='AGGRESSIVE_LIMIT', budget=None,
)
self.assertTrue(res['ok'], msg=res.get('message'))
payload = handler._pin_store.issue.call_args[0][1]
self.assertEqual(payload['order_type'], 'AGGRESSIVE_LIMIT')
self.assertEqual(payload['price'], 75150) # 75050 + 100tick
def test_aggressive_limit_buy_no_orderbook_no_quote_rejects(self):
md = _md()
md['orderbook'] = None
md['current_price'] = 0
self.collect.return_value = md
res = handler.propose_trade(
account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
qty=10, order_type='AGGRESSIVE_LIMIT', budget=None,
)
self.assertFalse(res['ok'])
self.assertIn('NO_ORDERBOOK', res['message'])
def test_budget_blocked_by_balance_guard_when_insufficient(self):
# 잔고를 50만원으로 낮추고 100만원 예산 시도 → 환산 후 잔고 가드에서 거부
self.collect.return_value = _md()
self.collect.return_value['balance_d2'] = 500_000
res = handler.propose_trade(
account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
qty=None, order_type='MARKET', budget=1_000_000,
)
self.assertFalse(res['ok'])
self.assertIn('INSUFFICIENT_BALANCE', res['message'])
def test_budget_buy_no_bump_when_ref_price_above_threshold(self):
# ref_price 가 30만원 초과면 +1 정책 미적용 → floor 유지
md = _md()
md['orderbook'] = {
'asks': [{'price': 350_000, 'qty': 50}, {'price': 350_500, 'qty': 30}],
'bids': [{'price': 349_500, 'qty': 40}, {'price': 349_000, 'qty': 60}],
}
md['balance_d2'] = 50_000_000
self.collect.return_value = md
res = handler.propose_trade(
account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
qty=None, order_type='MARKET', budget=1_000_000,
)
self.assertTrue(res['ok'], msg=res.get('message'))
payload = handler._pin_store.issue.call_args[0][1]
# 1,000,000 // 350,000 = 2 (remainder 300,000) — but ref_price > 300,000 → bump 안 함
self.assertEqual(payload['qty'], 2)
def test_budget_buy_no_bump_when_remainder_zero(self):
# 예산이 ref_price 의 정확한 배수면 remainder=0 → +1 안 함
md = _md()
md['orderbook'] = {
'asks': [{'price': 100_000, 'qty': 50}, {'price': 100_100, 'qty': 30}],
'bids': [{'price': 99_900, 'qty': 40}, {'price': 99_800, 'qty': 60}],
}
md['balance_d2'] = 50_000_000
self.collect.return_value = md
res = handler.propose_trade(
account='일반', side='BUY', symbol='005930', symbol_name='삼성전자',
qty=None, order_type='MARKET', budget=1_000_000,
)
self.assertTrue(res['ok'], msg=res.get('message'))
payload = handler._pin_store.issue.call_args[0][1]
# 1,000,000 // 100,000 = 10 정확히 — bump 안 함
self.assertEqual(payload['qty'], 10)
def test_budget_sell_never_bumps_even_under_threshold(self):
# SELL 은 ref_price 가 30만원 이하여도 +1 안 함 (보유수량 초과 매도 방지)
sell_md = _md()
sell_md.pop('balance_d2', None)
sell_md['position_qty'] = 100
self.collect.return_value = sell_md
res = handler.propose_trade(
account='ISA', side='SELL', symbol='005930', symbol_name='삼성전자',
qty=None, order_type='MARKET', budget=500_000,
)
self.assertTrue(res['ok'], msg=res.get('message'))
payload = handler._pin_store.issue.call_args[0][1]
# 500,000 // 75,000 = 6 (remainder 50,000) — SELL 이라 bump 안 함
self.assertEqual(payload['qty'], 6)
if __name__ == '__main__':
unittest.main(verbosity=2)
@@ -0,0 +1,100 @@
"""kiwoom_order.submit 응답 처리 회귀 테스트.
명세 (PDF 2026-05-07 검증):
- kt10000/kt10001 응답 필드: ord_no, dmst_stex_tp, return_code, return_msg
- 정상: return_code == 0
- ord_seq_no, rt_cd 같은 키는 명세에 없음
"""
from __future__ import annotations
import unittest
from unittest import mock
from orders import kiwoom_order, ledger, sidecar
class SubmitResponseTests(unittest.TestCase):
def setUp(self):
mock.patch.object(sidecar, 'guard_or_raise', return_value=None).start()
mock.patch.object(ledger, 'append', return_value=None).start()
mock.patch.object(ledger, 'find_recent_idempotency', return_value=False).start()
# idempotency_hash 는 순수 함수라 mock 불필요
self.kc_post = mock.patch('orders.kiwoom_order.kc._http_post_json').start()
mock.patch('orders.kiwoom_order.kc.auth_headers', return_value={}).start()
mock.patch('orders.kiwoom_order.kc.base_url', return_value='https://api.kiwoom.com').start()
self.addCleanup(mock.patch.stopall)
def _submit(self):
return kiwoom_order.submit(
account_label='일반', side='BUY', symbol='005930', qty=1,
price=None, order_type='MARKET', routing_suffix='_AL',
dry_run=False, card_id='TEST',
)
def test_normal_response_succeeds(self):
"""명세 정상 응답 — return_code=0, ord_no 추출."""
self.kc_post.return_value = {
'ord_no': '0000138',
'dmst_stex_tp': 'KRX',
'return_code': 0,
'return_msg': '매수주문이 완료되었습니다.',
}
res = self._submit()
self.assertTrue(res['ok'])
self.assertEqual(res['ord_no'], '0000138')
def test_broker_reject_when_return_code_nonzero(self):
"""return_code != 0 → BROKER_REJECT."""
self.kc_post.return_value = {
'return_code': 1,
'return_msg': '주문불가종목',
}
res = self._submit()
self.assertFalse(res['ok'])
self.assertEqual(res['reason'], 'BROKER_REJECT')
def test_unexpected_response_type_falls_to_network(self):
"""응답이 dict 아닌 경우 — try 안의 .get 호출 AttributeError → NETWORK."""
self.kc_post.return_value = 'unexpected string'
res = self._submit()
self.assertFalse(res['ok'])
self.assertEqual(res['reason'], 'NETWORK')
def test_ord_seq_no_not_used(self):
"""명세에 없는 ord_seq_no fallback은 더 이상 동작하지 않음.
ord_no 없고 ord_seq_no만 있는 (가짜) 응답 → ord_no 빈 문자열.
"""
self.kc_post.return_value = {
'ord_seq_no': '0000999', # 명세에 없는 키
'return_code': 0,
}
res = self._submit()
self.assertTrue(res['ok'])
self.assertEqual(res['ord_no'], '') # ord_seq_no fallback 제거됨
def test_rt_cd_not_used(self):
"""명세에 없는 rt_cd 키는 무시. return_code 누락 시 None != 0 → BROKER_REJECT."""
self.kc_post.return_value = {
'ord_no': '0000138',
'rt_cd': '0', # 명세에 없는 키 — 이걸로는 정상 판정 안 됨
}
res = self._submit()
self.assertFalse(res['ok'])
self.assertEqual(res['reason'], 'BROKER_REJECT')
def test_8005_token_retry_then_success(self):
"""첫 호출 8005 토큰 만료 → 재발급 → 두 번째 호출 정상."""
self.kc_post.side_effect = [
{'return_code': 1, 'return_msg': '8005 Token이 유효하지 않습니다'},
{'ord_no': '0000200', 'return_code': 0, 'return_msg': '정상'},
]
with mock.patch('orders.kiwoom_order.kc.issue_token', return_value=None):
res = self._submit()
self.assertTrue(res['ok'])
self.assertEqual(res['ord_no'], '0000200')
if __name__ == '__main__':
unittest.main(verbosity=2)
@@ -0,0 +1,46 @@
"""datasource._nxt_eligible 캐시 lookup 회귀 테스트.
ka10099 (`stock_codes.json`) 캐시의 `nxt_enable` 필드를 사용해 NXT 거래가능 여부 판단.
캐시 미스/구 스키마는 보수적 True 유지 (현재 동작 보존).
"""
from __future__ import annotations
import unittest
from unittest import mock
from orders import datasource
class NxtEligibleTests(unittest.TestCase):
def test_returns_true_when_meta_says_yes(self):
with mock.patch.object(datasource.kc, 'lookup_stock_meta',
return_value={'code': '005930', 'nxt_enable': True}):
self.assertTrue(datasource._nxt_eligible('005930', {}))
def test_returns_false_when_meta_says_no(self):
"""NXT 미상장 종목 — 가드가 NXT 시간대 매수 거부."""
with mock.patch.object(datasource.kc, 'lookup_stock_meta',
return_value={'code': '900100', 'nxt_enable': False}):
self.assertFalse(datasource._nxt_eligible('900100', {}))
def test_cache_miss_falls_to_conservative_true(self):
"""캐시에 없는 종목 → True (사후 broker reject로 안전판)."""
with mock.patch.object(datasource.kc, 'lookup_stock_meta', return_value=None):
self.assertTrue(datasource._nxt_eligible('999999', {}))
def test_old_schema_cache_falls_to_conservative_true(self):
"""구 스키마 캐시 (nxt_enable 필드 없음) → True 유지."""
with mock.patch.object(datasource.kc, 'lookup_stock_meta',
return_value={'code': '005930', 'name': '삼성전자', 'market': 'KOSPI'}):
self.assertTrue(datasource._nxt_eligible('005930', {}))
def test_lookup_exception_falls_to_conservative_true(self):
"""lookup 자체가 예외 → True 유지 (가드 안전판은 사후 broker reject)."""
with mock.patch.object(datasource.kc, 'lookup_stock_meta',
side_effect=RuntimeError('cache read fail')):
self.assertTrue(datasource._nxt_eligible('005930', {}))
if __name__ == '__main__':
unittest.main(verbosity=2)
@@ -0,0 +1,119 @@
"""kiwoom_client._call_paginated 회귀 테스트.
명세상 list 반환 TR 응답 헤더에 cont-yn/next-key 정의됨. 1페이지로 끝나는 경우(cont-yn=N)와
다중 페이지(cont-yn=Y → next-key 후속 호출) 모두 list 누적이 정확한지 검증.
"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
from unittest import mock
_PARENT = Path(__file__).resolve().parent.parent.parent
if str(_PARENT) not in sys.path:
sys.path.insert(0, str(_PARENT))
import kiwoom_client as kc
class CallPaginatedTests(unittest.TestCase):
def setUp(self):
mock.patch.object(kc, 'auth_headers', return_value={}).start()
mock.patch.object(kc, 'base_url', return_value='https://api.kiwoom.com').start()
mock.patch.object(kc, 'issue_token', return_value=None).start()
mock.patch('kiwoom_client.time.sleep', return_value=None).start()
self.post = mock.patch.object(kc, '_http_post_full').start()
self.addCleanup(mock.patch.stopall)
def test_single_page_returns_immediately(self):
"""cont-yn=N → 1회 호출, list 그대로 반환."""
self.post.return_value = (
{'return_code': 0, 'tot_pl_amt': '1000', 'tdy_trde_diary': [{'stk_cd': 'A005930'}]},
{'cont-yn': 'N', 'next-key': ''},
)
out = kc._call_paginated('일반', 'ka10170', {}, list_field='tdy_trde_diary')
self.assertEqual(self.post.call_count, 1)
self.assertEqual(len(out['tdy_trde_diary']), 1)
self.assertEqual(out['tot_pl_amt'], '1000')
def test_multi_page_accumulates_list(self):
"""cont-yn=Y → next-key로 후속 호출, list 누적."""
self.post.side_effect = [
(
{'return_code': 0, 'tot_pl_amt': '500',
'tdy_trde_diary': [{'stk_cd': 'A005930'}, {'stk_cd': 'A035720'}]},
{'cont-yn': 'Y', 'next-key': 'page2'},
),
(
{'return_code': 0, 'tot_pl_amt': '500',
'tdy_trde_diary': [{'stk_cd': 'A000660'}]},
{'cont-yn': 'N', 'next-key': ''},
),
]
out = kc._call_paginated('일반', 'ka10170', {}, list_field='tdy_trde_diary')
self.assertEqual(self.post.call_count, 2)
self.assertEqual(len(out['tdy_trde_diary']), 3)
codes = [it['stk_cd'] for it in out['tdy_trde_diary']]
self.assertEqual(codes, ['A005930', 'A035720', 'A000660'])
def test_three_pages(self):
self.post.side_effect = [
({'return_code': 0, 'list': [1, 2]}, {'cont-yn': 'Y', 'next-key': 'p2'}),
({'return_code': 0, 'list': [3]}, {'cont-yn': 'Y', 'next-key': 'p3'}),
({'return_code': 0, 'list': [4, 5]}, {'cont-yn': 'N', 'next-key': ''}),
]
out = kc._call_paginated('일반', 'tr', {}, list_field='list')
self.assertEqual(self.post.call_count, 3)
self.assertEqual(out['list'], [1, 2, 3, 4, 5])
def test_max_pages_safety_cap(self):
"""무한 cont-yn=Y 응답 → max_pages 초과 시 RuntimeError."""
self.post.return_value = (
{'return_code': 0, 'list': [1]},
{'cont-yn': 'Y', 'next-key': 'next'},
)
with self.assertRaisesRegex(RuntimeError, '페이지 한도'):
kc._call_paginated('일반', 'tr', {}, list_field='list', max_pages=3)
def test_cont_yn_y_without_next_key_stops(self):
"""cont-yn=Y지만 next-key 빈 값 → 페이징 중단."""
self.post.return_value = (
{'return_code': 0, 'list': [1]},
{'cont-yn': 'Y', 'next-key': ''},
)
out = kc._call_paginated('일반', 'tr', {}, list_field='list')
self.assertEqual(self.post.call_count, 1)
self.assertEqual(out['list'], [1])
def test_first_page_8005_token_retry(self):
"""첫 페이지 8005 → 토큰 재발급 후 재호출 정상."""
self.post.side_effect = [
({'return_code': 1, 'return_msg': '8005 Token이 유효하지 않습니다'}, {}),
({'return_code': 0, 'list': [1, 2]}, {'cont-yn': 'N', 'next-key': ''}),
]
out = kc._call_paginated('일반', 'tr', {}, list_field='list')
self.assertEqual(self.post.call_count, 2)
self.assertEqual(out['list'], [1, 2])
def test_non_8005_error_raises(self):
self.post.return_value = (
{'return_code': 1, 'return_msg': '잘못된 파라미터'},
{},
)
with self.assertRaisesRegex(RuntimeError, '잘못된 파라미터'):
kc._call_paginated('일반', 'tr', {}, list_field='list')
def test_empty_list_field_initialized(self):
"""1페이지 응답에 list_field 없어도 빈 list로 초기화."""
self.post.return_value = (
{'return_code': 0},
{'cont-yn': 'N', 'next-key': ''},
)
out = kc._call_paginated('일반', 'tr', {}, list_field='list')
self.assertEqual(out['list'], [])
if __name__ == '__main__':
unittest.main(verbosity=2)
@@ -0,0 +1,164 @@
"""guards.evaluate_stock_state 회귀 테스트.
정책 (등급별 차등 — 2026-05-07 결정):
- 거부: orderWarning ∈ {2 정리매매, 4 투자위험} 또는 state 에 '거래정지'·'정리매매' 키워드
- 경고: orderWarning ∈ {1 ETF주의, 3 단기과열, 5 투자경과} 또는 state 에 '관리종목'
"""
from __future__ import annotations
import unittest
from unittest import mock
from orders import guards, handler, ledger
class EvaluateStockStateTests(unittest.TestCase):
def test_normal_stock_passes_no_warning(self):
meta = {'code': '005930', 'state': '증거금20%|담보대출|신용가능', 'order_warning': '0'}
out = guards.evaluate_stock_state(meta)
self.assertTrue(out['result'].ok)
self.assertIsNone(out['warning'])
def test_no_meta_passes_no_warning(self):
out = guards.evaluate_stock_state(None)
self.assertTrue(out['result'].ok)
self.assertIsNone(out['warning'])
def test_order_warning_2_정리매매_rejects(self):
meta = {'code': 'X', 'state': '', 'order_warning': '2'}
out = guards.evaluate_stock_state(meta)
self.assertFalse(out['result'].ok)
self.assertEqual(out['result'].code, 'STOCK_STATE_BLOCKED')
self.assertIn('정리매매', out['result'].message)
def test_order_warning_4_투자위험_rejects(self):
meta = {'code': 'X', 'state': '', 'order_warning': '4'}
out = guards.evaluate_stock_state(meta)
self.assertFalse(out['result'].ok)
self.assertIn('투자위험', out['result'].message)
def test_state_거래정지_rejects(self):
meta = {'code': 'X', 'state': '거래정지', 'order_warning': '0'}
out = guards.evaluate_stock_state(meta)
self.assertFalse(out['result'].ok)
self.assertIn('거래정지', out['result'].message)
def test_state_정리매매_rejects(self):
meta = {'code': 'X', 'state': '정리매매|거래제한', 'order_warning': '0'}
out = guards.evaluate_stock_state(meta)
self.assertFalse(out['result'].ok)
def test_order_warning_1_ETF주의_warns(self):
meta = {'code': 'X', 'state': '', 'order_warning': '1'}
out = guards.evaluate_stock_state(meta)
self.assertTrue(out['result'].ok)
self.assertIsNotNone(out['warning'])
self.assertIn('ETF투자주의요망', out['warning'])
def test_order_warning_3_단기과열_warns(self):
meta = {'code': 'X', 'state': '', 'order_warning': '3'}
out = guards.evaluate_stock_state(meta)
self.assertTrue(out['result'].ok)
self.assertIn('단기과열', out['warning'])
def test_order_warning_5_투자경과_warns(self):
meta = {'code': 'X', 'state': '', 'order_warning': '5'}
out = guards.evaluate_stock_state(meta)
self.assertTrue(out['result'].ok)
self.assertIn('투자경과', out['warning'])
def test_state_관리종목_warns(self):
meta = {'code': 'X', 'state': '관리종목|증거금100%', 'order_warning': '0'}
out = guards.evaluate_stock_state(meta)
self.assertTrue(out['result'].ok)
self.assertIn('관리종목', out['warning'])
def test_reject_takes_precedence_over_warning(self):
"""state에 '관리종목'(경고) + orderWarning=2(정리매매·거부) → 거부 우선."""
meta = {'code': 'X', 'state': '관리종목', 'order_warning': '2'}
out = guards.evaluate_stock_state(meta)
self.assertFalse(out['result'].ok)
def _md_with_meta(stock_meta):
"""handler 통합 테스트용 fixture market_data."""
from datetime import datetime, timedelta, timezone
KST = timezone(timedelta(hours=9))
return {
'now': datetime(2026, 5, 6, 10, 0, tzinfo=KST),
'is_holiday': False,
'nxt_eligible': True,
'current_price': 75050,
'prev_close': 75000,
'upper_limit': 100000,
'lower_limit': 50000,
'halt': False,
'vi': False,
'orderbook': {
'asks': [{'price': 75100, 'qty': 200}],
'bids': [{'price': 75000, 'qty': 180}],
},
'broker_executions': [],
'broker_query_error': None,
'balance_d2': 5_000_000,
'stock_meta': stock_meta,
}
class HandlerIntegrationTests(unittest.TestCase):
"""propose_trade가 종목 상태 가드를 적용하는지 통합 검증."""
def setUp(self):
self.collect = mock.patch.object(handler.datasource, 'collect_market_data').start()
mock.patch.object(handler.sidecar, 'guard_or_raise', return_value=None).start()
mock.patch.object(handler, '_sweep_expired_and_notify', return_value=None).start()
mock.patch.object(handler.ledger, 'append', return_value=None).start()
mock.patch.object(ledger, 'last_terminal_event', return_value=None).start()
mock.patch.object(ledger, 'last_event_for_symbol', return_value=None).start()
fake_pending = mock.MagicMock()
fake_pending.card_id = 'TEST'
fake_pending.pin = '1234'
fake_pending.expiry_seconds = 120
fake_pending.account_label = '일반'
self.issue = mock.patch.object(handler._pin_store, 'issue',
return_value=fake_pending).start()
self.addCleanup(mock.patch.stopall)
def test_정리매매_rejects_before_card(self):
self.collect.return_value = _md_with_meta(
{'code': '005930', 'state': '', 'order_warning': '2'}
)
res = handler.propose_trade(
account='일반', side='BUY', symbol='005930', symbol_name='X',
qty=1, order_type='LIMIT', price=75000,
)
self.assertFalse(res['ok'])
self.assertIn('STOCK_STATE_BLOCKED', res['message'])
self.issue.assert_not_called()
def test_관리종목_card_includes_warning(self):
self.collect.return_value = _md_with_meta(
{'code': '005930', 'state': '관리종목', 'order_warning': '0'}
)
res = handler.propose_trade(
account='일반', side='BUY', symbol='005930', symbol_name='X',
qty=1, order_type='LIMIT', price=75000,
)
self.assertTrue(res['ok'], msg=res.get('message'))
self.assertIn('관리종목', res['card_message'])
def test_normal_stock_no_warning_in_card(self):
self.collect.return_value = _md_with_meta(
{'code': '005930', 'state': '증거금20%', 'order_warning': '0'}
)
res = handler.propose_trade(
account='일반', side='BUY', symbol='005930', symbol_name='X',
qty=1, order_type='LIMIT', price=75000,
)
self.assertTrue(res['ok'])
self.assertNotIn('키움 경고', res['card_message'])
if __name__ == '__main__':
unittest.main(verbosity=2)