auto: 일일 백업 2026-09-22 02:00

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hyowons
2026-09-22 02:00:05 +09:00
parent 8e12b4027c
commit 408a3e0d4a
133 changed files with 1748 additions and 1597 deletions
@@ -61,8 +61,14 @@ class SessionTests(unittest.TestCase):
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')
self.assertEqual(guards.session_at(t(15, 59, 59)), 'NXT_AFTER')
def test_krx_aftermarket(self):
"""16:00~20:00 = KRX 애프터마켓 + NXT 애프터마켓 (2026-09-14 시행)."""
self.assertEqual(guards.session_at(t(16, 0)), 'AFTER')
self.assertEqual(guards.session_at(t(18, 0)), 'AFTER')
self.assertEqual(guards.session_at(t(19, 59, 59)), 'AFTER')
self.assertEqual(guards.session_at(t(20, 0)), 'CLOSED')
def test_closed_after_after(self):
self.assertEqual(guards.session_at(t(20, 0)), 'CLOSED')
@@ -94,9 +100,13 @@ class TradingHoursTests(unittest.TestCase):
self.assertEqual(r.code, 'NXT_NOT_ELIGIBLE')
def test_nxt_after_not_eligible(self):
r = guards.validate_trading_hours(t(18, 0), False, False)
r = guards.validate_trading_hours(t(15, 45), False, False)
self.assertEqual(r.code, 'NXT_NOT_ELIGIBLE')
def test_aftermarket_allows_non_nxt(self):
"""애프터마켓엔 KRX 가 NXT 미상장 종목을 받는다 — 여기서 막으면 안 된다."""
self.assertTrue(guards.validate_trading_hours(t(18, 0), False, False).ok)
def test_krx_close_no_nxt_required(self):
self.assertTrue(guards.validate_trading_hours(t(15, 25), False, False).ok)
@@ -114,7 +124,12 @@ class RoutingTests(unittest.TestCase):
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')
self.assertEqual(guards.determine_routing(t(15, 45), True, None), '_NX')
def test_aftermarket_routes_to_krx(self):
"""애프터마켓은 검증된 KRX 경로만 쓴다 — SOR 주문 라우팅은 미검증."""
self.assertEqual(guards.determine_routing(t(18, 0), True, None), '')
self.assertEqual(guards.determine_routing(t(18, 0), False, None), '')
def test_krx_close(self):
self.assertEqual(guards.determine_routing(t(15, 25), False, None), '')
@@ -133,6 +148,31 @@ class RoutingTests(unittest.TestCase):
guards.determine_routing(t(21, 0), True, None)
class StopRoutingTests(unittest.TestCase):
"""스톱 계열은 SOR(_AL) 로 못 낸다 — 키움 407022 (2026-09-21 삼성전자 실측)."""
def test_stop_types_downgrade_al_to_krx(self):
for ot in ('STOP_LIMIT', 'TRAILING_STOP', 'TRAILING_ARM'):
self.assertEqual(guards.determine_routing(t(10, 0), True, None, ot), '', ot)
def test_force_al_also_downgraded(self):
# force 는 사람의 명시 지정이지만 SOR 자체가 스톱을 안 받는다
self.assertEqual(guards.determine_routing(t(10, 0), True, 'AL', 'STOP_LIMIT'), '')
def test_nxt_session_kept(self):
# NXT 단독 시간대는 KRX 가 닫혀 강등할 곳이 없다 — 시도는 그대로 둔다
self.assertEqual(guards.determine_routing(t(8, 30), True, None, 'STOP_LIMIT'), '_NX')
self.assertEqual(guards.determine_routing(t(15, 45), True, None, 'STOP_LIMIT'), '_NX')
def test_non_stop_types_unaffected(self):
self.assertEqual(guards.determine_routing(t(10, 0), True, None, 'LIMIT'), '_AL')
self.assertEqual(guards.determine_routing(t(10, 0), True, None, 'MARKET'), '_AL')
self.assertEqual(guards.determine_routing(t(10, 0), True, None, None), '_AL')
def test_non_nxt_symbol_already_krx(self):
self.assertEqual(guards.determine_routing(t(10, 0), False, None, 'STOP_LIMIT'), '')
# ---------- 가격 가드 ----------
class PriceBandTests(unittest.TestCase):
@@ -98,3 +98,46 @@ class SubmitResponseTests(unittest.TestCase):
if __name__ == '__main__':
unittest.main(verbosity=2)
class IdempotencyMatchTests(unittest.TestCase):
"""멱등성은 '이중 발주 위험이 실재하는' 이벤트만 본다.
2026-09-21: SOR 스톱 거부 직후 같은 주문을 다시 냈더니 IDEMPOTENCY_DUP 으로 또 막혔다.
키움이 거부한 주문은 접수되지 않은 게 확정이라 재시도를 막을 이유가 없다.
"""
def setUp(self):
import tempfile
from pathlib import Path
self.tmp = Path(tempfile.mkdtemp()) / 'order_log.jsonl'
mock.patch.object(ledger, '_ledger_path', return_value=self.tmp).start()
self.addCleanup(mock.patch.stopall)
def _write(self, event: str, h: str = 'HASH1'):
import json
from datetime import datetime
with self.tmp.open('a', encoding='utf-8') as f:
f.write(json.dumps({'ts': datetime.now(ledger.KST).isoformat(),
'event': event, 'payload': {'idem_hash': h}}) + '\n')
def test_submitted_blocks(self):
self._write('submitted')
self.assertIsNotNone(ledger.find_recent_idempotency('HASH1'))
def test_network_failure_blocks(self):
"""나갔는지 모르는 상태 — 가장 위험하므로 반드시 차단."""
self._write('failed')
self.assertIsNotNone(ledger.find_recent_idempotency('HASH1'))
def test_broker_reject_does_not_block(self):
self._write('rejected')
self.assertIsNone(ledger.find_recent_idempotency('HASH1'))
def test_dryrun_does_not_block(self):
self._write('dryrun')
self.assertIsNone(ledger.find_recent_idempotency('HASH1'))
def test_other_hash_ignored(self):
self._write('submitted', h='OTHER')
self.assertIsNone(ledger.find_recent_idempotency('HASH1'))
@@ -723,7 +723,11 @@ class ArmMonitorTests(unittest.TestCase):
tm.sidecar.is_disabled = lambda: self.state['disabled']
tm.ledger.append = lambda ev, payload: None
tm.datasource.nxt_eligible = lambda code: True
tm.guards.determine_routing = lambda now, nxt, force: '_AL'
# 발동 레그는 스톱지정가라 SOR(_AL) 로 나갈 수 없다 — 실제 determine_routing 이
# KRX 로 강등한다. 스텁도 같은 값을 주고, order_type 이 실제로 넘어오는지 기록한다.
self.routing_calls = []
tm.guards.determine_routing = lambda now, nxt, force, order_type=None: (
self.routing_calls.append(order_type) or '')
self.steps = t.normalize_steps([{'pct': 10, 'cum': 20}, {'pct': 20, 'cum': 50},
{'pct': 30, 'cum': 100}])
@@ -760,7 +764,9 @@ class ArmMonitorTests(unittest.TestCase):
self.state['quote'] = 81000
self.tm.check(force=True)
self.assertEqual(len(self.submits), 3)
self.assertEqual([s['routing'] for s in self.submits], ['_AL'] * 3)
self.assertEqual([s['routing'] for s in self.submits], [''] * 3)
# 발동 레그는 스톱지정가 — SOR 로 내면 키움이 407022 로 거부한다
self.assertEqual(self.routing_calls, ['STOP_LIMIT'])
self.assertEqual(t.list_arms(), [])
self.assertEqual(len(t.list_active()), 1)
self.assertEqual(t.list_active()[0]['peak'], 81000) # 고점 = 발동 시점 현재가