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,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