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
+63 -10
View File
@@ -76,6 +76,18 @@ def _parse_hms(s: str) -> dtime:
def session_at(now: datetime) -> str:
"""현재 시각의 거래 세션. 경계 시각은 limits.json['trading_hours'] 가 단일 진실 소스다.
NXT_PRE(08:00~09:00) / KRX_NXT(09:00:30~15:20) / KRX_CLOSE(15:20~15:30)
NXT_AFTER(15:30~16:00) / AFTER(16:00~20:00) / CLOSED
⚠️ AFTER = **KRX 애프터마켓 + NXT 애프터마켓 동시**(2026-09-14 시행). 이걸 NXT_AFTER 로
뭉쳐 두면 NXT 미상장 종목이 validate_trading_hours 의 NXT_NOT_ELIGIBLE 로 막혀
**애프터마켓에 주문을 낼 수 없다** — 정작 KRX 애프터마켓에서는 거래되는 종목들이다
(2026-09-21 실측: 삼성전자우 KRX 거래량 16:05→18:00 +134,198주).
⚠️ 15:30~16:00 은 NXT_AFTER 로 남긴다 — KRX 쪽은 시간외 종가매매(15:40~16:00)뿐이고
우리는 그 주문유형(trde_tp=81)을 쓰지 않으므로 "NXT 만 거래 가능"이 맞다.
"""
th = _limits()['trading_hours']
t = now.replace(tzinfo=None).time()
if _parse_hms(th['nxt_pre_start']) <= t < _parse_hms(th['nxt_pre_end']):
@@ -84,6 +96,10 @@ def session_at(now: datetime) -> str:
return 'KRX_NXT'
if _parse_hms(th['krx_closing_auction_start']) <= t < _parse_hms(th['krx_closing_auction_end']):
return 'KRX_CLOSE'
# ⚠️ AFTER 를 NXT_AFTER 보다 **먼저** 본다 — nxt_after(15:30~20:00) 구간이 애프터마켓을
# 통째로 덮고 있어서, 순서를 바꾸면 16:00 이후가 영원히 NXT_AFTER 로 잡힌다.
if _parse_hms(th['krx_after_start']) <= t < _parse_hms(th['nxt_after_end']):
return 'AFTER'
if _parse_hms(th['nxt_after_start']) <= t < _parse_hms(th['nxt_after_end']):
return 'NXT_AFTER'
return 'CLOSED'
@@ -137,10 +153,31 @@ def validate_trading_hours(now: datetime, is_holiday: bool, nxt_eligible: bool)
return Result.REJECT('OUTSIDE_HOURS', f'거래시간 외 ({now.strftime("%H:%M:%S")})')
if sess in ('NXT_PRE', 'NXT_AFTER') and not nxt_eligible:
return Result.REJECT('NXT_NOT_ELIGIBLE', '지금은 NXT 시간대인데 이 종목은 NXT 거래 불가')
# ⚠️ AFTER(16:00~20:00)는 nxt_eligible 을 보지 않는다 — KRX 애프터마켓이 NXT 미상장
# 종목을 받으므로, 여기서 막으면 정작 거래되는 종목을 거부한다.
# ⚠️ 애프터마켓 제외종목(ETF/ETN + 거래정지·투자경고·이상급등 등 9종)의 **서버 가드는
# 없다** — ETF/ETN 은 자산웹 버튼이 stock_codes 의 etf_etn 으로 막고, 나머지는 판별
# 필드가 없어 키움 거부 메시지가 최종 방어선이다. market_data 에 플래그를 실어
# 여기서 막으려면 datasource 를 함께 고쳐야 한다(미구현).
return Result.OK(code=sess)
def determine_routing(now: datetime, nxt_eligible: bool, force: Optional[str]) -> str:
# 스톱지정가(trde_tp=28)를 쓰는 주문유형. SOR 라우팅으로는 발주되지 않는다.
STOP_ORDER_TYPES = ('STOP_LIMIT', 'TRAILING_STOP', 'TRAILING_ARM')
def determine_routing(now: datetime, nxt_eligible: bool, force: Optional[str],
order_type: Optional[str] = None) -> str:
"""세션·NXT 거래여부로 거래소 라우팅 suffix 를 정한다.
⚠️ 스톱 계열은 SOR(_AL) 로 낼 수 없다 — 키움이 `407022: SOR주문이 불가능한
주문종류입니다` 로 거부한다(2026-09-21 삼성전자 실측). NXT 거래 종목은 정규장
라우팅이 _AL 이라, 이 강등이 없으면 대형주 트레일링이 **항상** 거부된다.
(2026-07-30 제닉스로보틱스가 성공했던 건 NXT 미거래 종목이라 KRX 로 나갔기 때문)
NXT 단독 시간대(_NX)가 스톱을 받는지는 실증 안 됐다 — 시도는 그대로 두고,
거부되면 그 메시지로 드러나게 한다(KRX 가 닫힌 시간이라 강등할 곳도 없다).
"""
routing = _limits()['routing']
suffix_map = {
'AL': routing['suffix_AL'],
@@ -150,15 +187,30 @@ def determine_routing(now: datetime, nxt_eligible: bool, force: Optional[str]) -
if force:
if force not in routing['force_options']:
raise ValueError(f'unknown routing force: {force}')
return suffix_map[force]
sess = session_at(now)
if sess in ('NXT_PRE', 'NXT_AFTER'):
return routing['suffix_NX']
if sess == 'KRX_CLOSE':
suffix = suffix_map[force]
else:
sess = session_at(now)
if sess in ('NXT_PRE', 'NXT_AFTER'):
suffix = routing['suffix_NX']
elif sess == 'KRX_CLOSE':
suffix = routing['suffix_KRX']
elif sess == 'AFTER':
# 애프터마켓은 **KRX 로 보낸다** — KRX 애프터마켓이 대상 종목을 전부 받고
# (2026-09-21 실측: NXT 미상장 종목도 16:00 이후 KRX 거래량이 증가),
# dmst_stex_tp 에 애프터 전용 값이 생기지도 않았다(KRX/NXT/SOR 그대로).
# ⚠️ SOR 을 쓰지 않는 이유 — 시세는 SOR 이 KRX+NXT 통합으로 오지만
# **주문이 애프터마켓에 어떻게 라우팅되는지는 미검증**이다. 오늘 아침
# "SOR 주문이 불가능한 주문종류입니다"(407022) 가 정확히 그 함정이었다.
# 검증된 경로만 쓴다. NXT 상장 종목도 KRX 애프터마켓에서 거래되므로 손실 없다.
suffix = routing['suffix_KRX']
elif sess == 'KRX_NXT':
suffix = routing['suffix_AL'] if nxt_eligible else routing['suffix_KRX']
else:
raise ValueError('CLOSED session has no valid routing')
if order_type in STOP_ORDER_TYPES and suffix == routing['suffix_AL']:
return routing['suffix_KRX']
if sess == 'KRX_NXT':
return routing['suffix_AL'] if nxt_eligible else routing['suffix_KRX']
raise ValueError('CLOSED session has no valid routing')
return suffix
# ---- 가격 가드 (±30% 상한가/하한가) ----
@@ -196,7 +248,8 @@ def validate_market_order_session(now: datetime, order_type: str) -> Result:
if order_type != 'MARKET':
return Result.OK()
sess = session_at(now)
if sess in ('NXT_PRE', 'NXT_AFTER'):
# 애프터마켓 제출가능 호가는 지정가·최우선지정가·최유리지정가(+IOC/FOK)뿐 — **시장가 없음**.
if sess in ('NXT_PRE', 'NXT_AFTER', 'AFTER'):
return Result.REJECT('MARKET_NOT_ALLOWED_IN_NXT',
'NXT 시간대에는 시장가 불가 — 지정가만 가능. "지금 바로" 또는 가격 명시로 다시 시도.')
if sess == 'KRX_CLOSE':
@@ -376,7 +376,7 @@ def propose_trade(
return {'ok': False, 'message': card.format_rejected(sr.code, sr.message)}
state_warning = state_eval['warning'] # 카드에 표시할 경고 (None이면 정상)
suffix = guards.determine_routing(md['now'], md['nxt_eligible'], routing_force)
suffix = guards.determine_routing(md['now'], md['nxt_eligible'], routing_force, order_type)
estimate = None
final_price = price
@@ -1237,7 +1237,7 @@ def amend_trade(qty: Optional[int] = None, price: Optional[int] = None,
return {'ok': False, 'message': card.format_rejected(sr.code, sr.message)}
state_warning = state_eval['warning']
suffix = guards.determine_routing(md['now'], md['nxt_eligible'], routing_force)
suffix = guards.determine_routing(md['now'], md['nxt_eligible'], routing_force, new_order_type)
estimate = None
final_price = new_price
@@ -81,6 +81,15 @@ def idempotency_hash(account: str, symbol: str, side: str, qty: int, price: int
return hashlib.sha256(key.encode('utf-8')).hexdigest()[:16]
# 멱등성 매칭 대상 — **이중 발주 위험이 실재하는 이벤트만** 본다.
# submitted : 키움이 접수했다 (확정)
# failed : 네트워크 에러 — 나갔는지 모른다 (가장 위험, 반드시 차단)
# rejected(브로커 거부·idem 중복)·dryrun 은 접수되지 않은 게 확정이라 재시도를 막지 않는다.
# ⚠️ 이게 없으면 키움이 거부한 직후 같은 주문을 다시 내는 게 90초간 IDEMPOTENCY_DUP 으로
# 막혀, 관리자님이 영문 모를 두 번째 실패를 본다 (2026-09-21 SOR 스톱 거부 때 실측).
IDEM_MATCH_EVENTS = ('submitted', 'failed')
def find_recent_idempotency(h: str, within_seconds: int = 90) -> Optional[dict]:
path = _ledger_path()
if not path.exists():
@@ -99,7 +108,8 @@ def find_recent_idempotency(h: str, within_seconds: int = 90) -> Optional[dict]:
continue
if ts < cutoff:
return None
if rec.get('payload', {}).get('idem_hash') == h:
if (rec.get('event') in IDEM_MATCH_EVENTS
and rec.get('payload', {}).get('idem_hash') == h):
return rec
return None
@@ -4,14 +4,22 @@
"last_updated": "2026-05-06",
"note": "변경 시 별도 커밋. guards 단위테스트가 이 파일을 검증. 손으로 풀지 말고 항상 이 파일을 통해 조정."
},
"enabled": false,
"kill_switch_file": "state/orders_disabled",
"accounts_whitelist": ["일반", "ISA", "가희_일반", "가희_ISA"],
"owner_accounts": ["일반", "ISA"],
"spouse_accounts": ["가희_일반", "가희_ISA"],
"accounts_whitelist": [
"일반",
"ISA",
"가희_일반",
"가희_ISA"
],
"owner_accounts": [
"일반",
"ISA"
],
"spouse_accounts": [
"가희_일반",
"가희_ISA"
],
"limits": {
"single_order_max_won": null,
"daily_total_max_won": null,
@@ -19,11 +27,9 @@
"price_band_pct_soft": null,
"price_band_pct_hard": 30
},
"delays": {
"between_orders_seconds": 5
},
"pin": {
"owner_length": 4,
"owner_charset": "digits",
@@ -33,7 +39,6 @@
"expiry_seconds": 120,
"max_attempts": 1
},
"trading_hours": {
"tz": "Asia/Seoul",
"krx_regular_start": "09:00:30",
@@ -44,27 +49,36 @@
"nxt_pre_end": "09:00:00",
"nxt_after_start": "15:30:00",
"nxt_after_end": "20:00:00",
"krx_after_start": "16:00:00",
"block_outside": true,
"holiday_state_file": "state/market_holidays.json"
},
"routing": {
"default": "AL",
"force_options": ["AL", "NX", "KRX"],
"force_options": [
"AL",
"NX",
"KRX"
],
"suffix_AL": "_AL",
"suffix_NX": "_NX",
"suffix_KRX": ""
},
"market_order": {
"allowed": true,
"natural_language_market": ["시장가"],
"natural_language_aggressive_limit": ["지금 바로", "즉시", "빨리", "당장"],
"natural_language_market": [
"시장가"
],
"natural_language_aggressive_limit": [
"지금 바로",
"즉시",
"빨리",
"당장"
],
"aggressive_limit_ticks": 1,
"show_orderbook_depth": 3,
"estimate_slippage": true
},
"guards": {
"block_market_holidays": true,
"block_trading_halt": true,
@@ -74,9 +88,13 @@
"require_position_check_for_sell": true,
"block_market_order_when_orderbook_thin": false
},
"card": {
"highlight_fields": ["side", "account", "symbol_name", "price"],
"highlight_fields": [
"side",
"account",
"symbol_name",
"price"
],
"highlight_marker": "▶",
"highlight_bold_markdown": true,
"buy_emoji": "🛒",
@@ -86,13 +104,35 @@
"orderbook_depth": 3,
"show_balance_ratio": true
},
"ledger": {
"log_file": "state/order_log.jsonl",
"events": ["card_issued", "pin_issued", "amended", "approved", "rejected", "expired", "canceled", "submitted", "filled", "partial", "failed", "dryrun", "cancel_submitted", "cancel_rejected", "cancel_confirmed", "cancel_unconfirmed_timeout", "modify_submitted", "modify_rejected", "trailing_register_failed", "arm_registered", "arm_fired", "arm_fire_failed", "arm_canceled"],
"events": [
"card_issued",
"pin_issued",
"amended",
"approved",
"rejected",
"expired",
"canceled",
"submitted",
"filled",
"partial",
"failed",
"dryrun",
"cancel_submitted",
"cancel_rejected",
"cancel_confirmed",
"cancel_unconfirmed_timeout",
"modify_submitted",
"modify_rejected",
"trailing_register_failed",
"arm_registered",
"arm_fired",
"arm_fire_failed",
"arm_canceled"
],
"mask_token_in_logs": true
},
"telegram": {
"dm_only": true,
"chat_id_whitelist_env": "OPENCLAW_OWNER_CHAT_ID",
@@ -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) # 고점 = 발동 시점 현재가