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:
@@ -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:00–20:00 (NXT 포함)
|
||||
- 08:00–09:00 NXT 단독 (NXT 미가능 종목 거부)
|
||||
- 09:00:30–15:20 KRX + NXT 동시 (SOR)
|
||||
- 15:20–15:30 KRX 단일가
|
||||
- 15:30–20: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)
|
||||
Reference in New Issue
Block a user