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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-06-04 15:39:41 +09:00
commit fed3526b20
199 changed files with 49671 additions and 0 deletions
@@ -0,0 +1,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)